feat: 添加旧版Android API支持和会议预览功能
- 添加 `LegacyLoginResponse`, `LegacyRefreshTokenResponse`, `LegacyLoginUserResponse` 和 `LegacyMeetingTagResponse` DTO - 添加 `LegacyAuthController` 以处理旧版登录和刷新令牌请求 - 更新 `MeetingAccessServiceImpl` 中的异常信息为英文 - 在前端 `MeetingPreview.tsx` 中添加多语言文本和分享功能dev_na
parent
db310fc803
commit
017e1d2ded
|
|
@ -0,0 +1,96 @@
|
||||||
|
package com.imeeting.controller.android.legacy;
|
||||||
|
|
||||||
|
import com.imeeting.dto.android.legacy.LegacyApiResponse;
|
||||||
|
import com.imeeting.dto.android.legacy.LegacyLoginResponse;
|
||||||
|
import com.imeeting.dto.android.legacy.LegacyLoginUserResponse;
|
||||||
|
import com.imeeting.dto.android.legacy.LegacyRefreshTokenResponse;
|
||||||
|
import com.unisbase.dto.LoginRequest;
|
||||||
|
import com.unisbase.dto.RefreshRequest;
|
||||||
|
import com.unisbase.dto.SysRoleDTO;
|
||||||
|
import com.unisbase.dto.SysUserDTO;
|
||||||
|
import com.unisbase.dto.TokenResponse;
|
||||||
|
import com.unisbase.service.AuthService;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestHeader;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/auth")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class LegacyAuthController {
|
||||||
|
|
||||||
|
private final AuthService authService;
|
||||||
|
|
||||||
|
@PostMapping("/login")
|
||||||
|
public LegacyApiResponse<LegacyLoginResponse> login(@Valid @RequestBody LoginRequest request) {
|
||||||
|
TokenResponse tokenResponse = authService.login(request, true);
|
||||||
|
return LegacyApiResponse.ok(new LegacyLoginResponse(
|
||||||
|
tokenResponse.getAccessToken(),
|
||||||
|
tokenResponse.getRefreshToken(),
|
||||||
|
toLegacyUser(tokenResponse.getUser())
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/refresh")
|
||||||
|
public LegacyApiResponse<LegacyRefreshTokenResponse> refresh(@RequestBody(required = false) RefreshRequest request,
|
||||||
|
@RequestHeader(value = "Authorization", required = false) String authorization,
|
||||||
|
@RequestHeader(value = "X-Android-Access-Token", required = false) String androidAccessToken) {
|
||||||
|
TokenResponse tokenResponse = authService.refresh(resolveRefreshToken(request, authorization, androidAccessToken));
|
||||||
|
return LegacyApiResponse.ok(new LegacyRefreshTokenResponse(tokenResponse.getAccessToken(),tokenResponse.getRefreshToken()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private LegacyLoginUserResponse toLegacyUser(SysUserDTO user) {
|
||||||
|
if (user == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
SysRoleDTO primaryRole = resolvePrimaryRole(user);
|
||||||
|
return new LegacyLoginUserResponse(
|
||||||
|
user.getUserId(),
|
||||||
|
user.getUsername(),
|
||||||
|
user.getDisplayName(),
|
||||||
|
user.getAvatarUrl(),
|
||||||
|
user.getEmail(),
|
||||||
|
primaryRole == null ? null : primaryRole.getRoleId(),
|
||||||
|
primaryRole == null ? null : primaryRole.getRoleName(),
|
||||||
|
user.getCreatedAt()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private SysRoleDTO resolvePrimaryRole(SysUserDTO user) {
|
||||||
|
List<SysRoleDTO> roles = user.getRoles();
|
||||||
|
if (roles != null && !roles.isEmpty()) {
|
||||||
|
return roles.get(0);
|
||||||
|
}
|
||||||
|
List<Long> roleIds = user.getRoleIds();
|
||||||
|
if (roleIds != null && !roleIds.isEmpty()) {
|
||||||
|
SysRoleDTO role = new SysRoleDTO();
|
||||||
|
role.setRoleId(roleIds.get(0));
|
||||||
|
return role;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveRefreshToken(RefreshRequest request, String authorization, String androidAccessToken) {
|
||||||
|
if (request != null && StringUtils.hasText(request.getRefreshToken())) {
|
||||||
|
return request.getRefreshToken().trim();
|
||||||
|
}
|
||||||
|
if (StringUtils.hasText(androidAccessToken)) {
|
||||||
|
return androidAccessToken.trim();
|
||||||
|
}
|
||||||
|
if (StringUtils.hasText(authorization)) {
|
||||||
|
String value = authorization.trim();
|
||||||
|
if (value.startsWith("Bearer ")) {
|
||||||
|
return value.substring(7).trim();
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
throw new IllegalArgumentException("refreshToken不能为空");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
package com.imeeting.controller.biz;
|
||||||
|
|
||||||
|
import com.imeeting.dto.biz.MeetingPreviewAccessVO;
|
||||||
|
import com.imeeting.dto.biz.PublicMeetingPreviewVO;
|
||||||
|
import com.imeeting.entity.biz.Meeting;
|
||||||
|
import com.imeeting.service.biz.MeetingAccessService;
|
||||||
|
import com.imeeting.service.biz.MeetingQueryService;
|
||||||
|
import com.unisbase.common.ApiResponse;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/public/meetings")
|
||||||
|
public class MeetingPublicPreviewController {
|
||||||
|
|
||||||
|
private final MeetingQueryService meetingQueryService;
|
||||||
|
private final MeetingAccessService meetingAccessService;
|
||||||
|
|
||||||
|
public MeetingPublicPreviewController(MeetingQueryService meetingQueryService,
|
||||||
|
MeetingAccessService meetingAccessService) {
|
||||||
|
this.meetingQueryService = meetingQueryService;
|
||||||
|
this.meetingAccessService = meetingAccessService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}/preview/access")
|
||||||
|
public ApiResponse<MeetingPreviewAccessVO> getPreviewAccess(@PathVariable Long id) {
|
||||||
|
try {
|
||||||
|
Meeting meeting = meetingAccessService.requireMeeting(id);
|
||||||
|
return ApiResponse.ok(new MeetingPreviewAccessVO(meetingAccessService.isPreviewPasswordRequired(meeting)));
|
||||||
|
} catch (RuntimeException ex) {
|
||||||
|
return ApiResponse.error(ex.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}/preview")
|
||||||
|
public ApiResponse<PublicMeetingPreviewVO> getPreview(@PathVariable Long id,
|
||||||
|
@RequestParam(required = false) String accessPassword) {
|
||||||
|
try {
|
||||||
|
Meeting meeting = meetingAccessService.requireMeeting(id);
|
||||||
|
meetingAccessService.assertCanPreviewMeeting(meeting, accessPassword);
|
||||||
|
|
||||||
|
PublicMeetingPreviewVO data = new PublicMeetingPreviewVO();
|
||||||
|
data.setMeeting(meetingQueryService.getDetail(id));
|
||||||
|
data.setTranscripts(meetingQueryService.getTranscripts(id));
|
||||||
|
return ApiResponse.ok(data);
|
||||||
|
} catch (RuntimeException ex) {
|
||||||
|
return ApiResponse.error(ex.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
package com.imeeting.dto.android.legacy;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class LegacyLoginResponse {
|
||||||
|
private String token;
|
||||||
|
private String refreshToken;
|
||||||
|
private LegacyLoginUserResponse user;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
package com.imeeting.dto.android.legacy;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class LegacyLoginUserResponse {
|
||||||
|
private Long user_id;
|
||||||
|
private String username;
|
||||||
|
private String caption;
|
||||||
|
private String avatar_url;
|
||||||
|
private String email;
|
||||||
|
private Long role_id;
|
||||||
|
private String role_name;
|
||||||
|
private LocalDateTime created_at;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
package com.imeeting.dto.android.legacy;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class LegacyMeetingTagResponse {
|
||||||
|
private Long id;
|
||||||
|
private String name;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
package com.imeeting.dto.android.legacy;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class LegacyRefreshTokenResponse {
|
||||||
|
private String token;
|
||||||
|
private String refreshToken;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
package com.imeeting.dto.biz;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class MeetingPreviewAccessVO {
|
||||||
|
private boolean passwordRequired;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
package com.imeeting.dto.biz;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class PublicMeetingPreviewVO {
|
||||||
|
private MeetingVO meeting;
|
||||||
|
private List<MeetingTranscriptVO> transcripts;
|
||||||
|
}
|
||||||
|
|
@ -17,7 +17,7 @@ public class MeetingAccessServiceImpl implements MeetingAccessService {
|
||||||
public Meeting requireMeeting(Long meetingId) {
|
public Meeting requireMeeting(Long meetingId) {
|
||||||
Meeting meeting = meetingMapper.selectById(meetingId);
|
Meeting meeting = meetingMapper.selectById(meetingId);
|
||||||
if (meeting == null) {
|
if (meeting == null) {
|
||||||
throw new RuntimeException("会议不存在");
|
throw new RuntimeException("Meeting not found");
|
||||||
}
|
}
|
||||||
return meeting;
|
return meeting;
|
||||||
}
|
}
|
||||||
|
|
@ -35,10 +35,10 @@ public class MeetingAccessServiceImpl implements MeetingAccessService {
|
||||||
}
|
}
|
||||||
String providedPassword = normalizePreviewPassword(accessPassword);
|
String providedPassword = normalizePreviewPassword(accessPassword);
|
||||||
if (providedPassword == null) {
|
if (providedPassword == null) {
|
||||||
throw new RuntimeException("该会议需要访问密码");
|
throw new RuntimeException("Access password is required");
|
||||||
}
|
}
|
||||||
if (!expectedPassword.equals(providedPassword)) {
|
if (!expectedPassword.equals(providedPassword)) {
|
||||||
throw new RuntimeException("访问密码错误");
|
throw new RuntimeException("Access password is incorrect");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -48,7 +48,7 @@ public class MeetingAccessServiceImpl implements MeetingAccessService {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!isSameTenant(meeting, loginUser)) {
|
if (!isSameTenant(meeting, loginUser)) {
|
||||||
throw new RuntimeException("无权查看此会议");
|
throw new RuntimeException("No permission to view this meeting");
|
||||||
}
|
}
|
||||||
if (isTenantAdmin(loginUser)) {
|
if (isTenantAdmin(loginUser)) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -56,7 +56,7 @@ public class MeetingAccessServiceImpl implements MeetingAccessService {
|
||||||
if (isCreator(meeting, loginUser) || isParticipant(meeting, loginUser)) {
|
if (isCreator(meeting, loginUser) || isParticipant(meeting, loginUser)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
throw new RuntimeException("无权查看此会议");
|
throw new RuntimeException("No permission to view this meeting");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|
@ -65,12 +65,12 @@ public class MeetingAccessServiceImpl implements MeetingAccessService {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!isSameTenant(meeting, loginUser)) {
|
if (!isSameTenant(meeting, loginUser)) {
|
||||||
throw new RuntimeException("无权修改此会议");
|
throw new RuntimeException("No permission to edit this meeting");
|
||||||
}
|
}
|
||||||
if (isTenantAdmin(loginUser) || isCreator(meeting, loginUser)) {
|
if (isTenantAdmin(loginUser) || isCreator(meeting, loginUser)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
throw new RuntimeException("无权修改此会议");
|
throw new RuntimeException("No permission to edit this meeting");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|
@ -79,12 +79,12 @@ public class MeetingAccessServiceImpl implements MeetingAccessService {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!isSameTenant(meeting, loginUser)) {
|
if (!isSameTenant(meeting, loginUser)) {
|
||||||
throw new RuntimeException("无权操作此实时会议");
|
throw new RuntimeException("No permission to manage this realtime meeting");
|
||||||
}
|
}
|
||||||
if (isTenantAdmin(loginUser) || isCreator(meeting, loginUser)) {
|
if (isTenantAdmin(loginUser) || isCreator(meeting, loginUser)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
throw new RuntimeException("无权操作此实时会议");
|
throw new RuntimeException("No permission to manage this realtime meeting");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|
@ -93,12 +93,12 @@ public class MeetingAccessServiceImpl implements MeetingAccessService {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!isSameTenant(meeting, loginUser)) {
|
if (!isSameTenant(meeting, loginUser)) {
|
||||||
throw new RuntimeException("无权导出此会议");
|
throw new RuntimeException("No permission to export this meeting");
|
||||||
}
|
}
|
||||||
if (isTenantAdmin(loginUser) || isCreator(meeting, loginUser) || isParticipant(meeting, loginUser)) {
|
if (isTenantAdmin(loginUser) || isCreator(meeting, loginUser) || isParticipant(meeting, loginUser)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
throw new RuntimeException("无权导出此会议");
|
throw new RuntimeException("No permission to export this meeting");
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean isPlatformAdmin(LoginUser loginUser) {
|
private boolean isPlatformAdmin(LoginUser loginUser) {
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ unisbase:
|
||||||
token-prefix: "Bearer "
|
token-prefix: "Bearer "
|
||||||
permit-all-urls:
|
permit-all-urls:
|
||||||
- /actuator/health
|
- /actuator/health
|
||||||
|
- /api/auth/**
|
||||||
- /api/static/**
|
- /api/static/**
|
||||||
- /api/public/meetings/**
|
- /api/public/meetings/**
|
||||||
- /ws/**
|
- /ws/**
|
||||||
|
|
|
||||||
|
|
@ -151,6 +151,23 @@
|
||||||
letter-spacing: -0.04em;
|
letter-spacing: -0.04em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.meeting-preview-title-number {
|
||||||
|
font-family: "Segoe UI", "SF Pro Display", "Helvetica Neue", sans-serif;
|
||||||
|
font-variant-numeric: lining-nums proportional-nums;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-preview-hero-toolbar {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-preview-hero-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.meeting-preview-subtitle {
|
.meeting-preview-subtitle {
|
||||||
margin: 14px 0 0;
|
margin: 14px 0 0;
|
||||||
color: var(--preview-muted);
|
color: var(--preview-muted);
|
||||||
|
|
@ -193,6 +210,19 @@
|
||||||
margin-top: 18px;
|
margin-top: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.meeting-preview-page-tabs .ant-tabs-nav {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-preview-page-tabs .ant-tabs-tab {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-preview-tab-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
.meeting-preview-section {
|
.meeting-preview-section {
|
||||||
padding: 22px 18px;
|
padding: 22px 18px;
|
||||||
}
|
}
|
||||||
|
|
@ -501,10 +531,17 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.meeting-preview-transcript-item.is-active {
|
.meeting-preview-transcript-item.is-active {
|
||||||
|
transform: translateY(-2px);
|
||||||
border-color: rgba(184, 100, 50, 0.28);
|
border-color: rgba(184, 100, 50, 0.28);
|
||||||
|
box-shadow: 0 12px 26px rgba(105, 72, 40, 0.08);
|
||||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.88), rgba(255, 244, 235, 0.92));
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.88), rgba(255, 244, 235, 0.92));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.meeting-preview-transcript-item.is-active .meeting-preview-transcript-copy {
|
||||||
|
color: #5a3113;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.meeting-preview-transcript-head {
|
.meeting-preview-transcript-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -589,3 +626,10 @@
|
||||||
gap: 22px;
|
gap: 22px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.meeting-preview-hero-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,15 @@
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { Alert, Button, Empty, Input, Result, Segmented, Skeleton, Tabs, Tag, message } from "antd";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { Alert, Button, Empty, Input, Result, Segmented, Skeleton, Tag } from "antd";
|
|
||||||
import {
|
import {
|
||||||
AudioOutlined,
|
AudioOutlined,
|
||||||
CalendarOutlined,
|
CalendarOutlined,
|
||||||
ClockCircleOutlined,
|
ClockCircleOutlined,
|
||||||
|
CopyOutlined,
|
||||||
FileTextOutlined,
|
FileTextOutlined,
|
||||||
LockOutlined,
|
LockOutlined,
|
||||||
RobotOutlined,
|
RobotOutlined,
|
||||||
|
ShareAltOutlined,
|
||||||
TeamOutlined,
|
TeamOutlined,
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
|
|
@ -23,11 +25,91 @@ import { buildMeetingAnalysis } from "./meetingAnalysis";
|
||||||
import "./MeetingPreview.css";
|
import "./MeetingPreview.css";
|
||||||
|
|
||||||
type AnalysisTab = "chapters" | "speakers" | "actions" | "todos";
|
type AnalysisTab = "chapters" | "speakers" | "actions" | "todos";
|
||||||
|
type PreviewPageTab = "summary" | "transcript";
|
||||||
|
|
||||||
|
const TEXT = {
|
||||||
|
statusTranscribing: "\u8f6c\u5199\u4e2d",
|
||||||
|
statusSummarizing: "\u603b\u7ed3\u4e2d",
|
||||||
|
statusCompleted: "\u5df2\u5b8c\u6210",
|
||||||
|
statusPending: "\u5f85\u5904\u7406",
|
||||||
|
hintTranscribing: "\u4f1a\u8bae\u5185\u5bb9\u4ecd\u5728\u6574\u7406\u4e2d\uff0c\u9884\u89c8\u4f1a\u6301\u7eed\u8865\u5168\u3002",
|
||||||
|
hintSummarizing: "AI \u6b63\u5728\u751f\u6210\u4f1a\u8bae\u603b\u7ed3\uff0c\u5df2\u5b8c\u6210\u5185\u5bb9\u4f1a\u4f18\u5148\u5c55\u793a\u3002",
|
||||||
|
hintCompleted: "\u4f1a\u8bae\u7eaa\u8981\u3001\u5206\u6790\u548c\u8f6c\u5f55\u5185\u5bb9\u5df2\u751f\u6210\u5b8c\u6210\u3002",
|
||||||
|
hintPending: "\u5f53\u524d\u4f1a\u8bae\u5c1a\u672a\u751f\u6210\u5b8c\u6574\u5185\u5bb9\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5\u3002",
|
||||||
|
missingMeetingId: "\u672a\u63d0\u4f9b\u4f1a\u8bae\u7f16\u53f7",
|
||||||
|
loadFailed: "\u4f1a\u8bae\u9884\u89c8\u52a0\u8f7d\u5931\u8d25",
|
||||||
|
noMeetingData: "\u672a\u627e\u5230\u4f1a\u8bae\u6570\u636e",
|
||||||
|
previewLabel: "\u4f1a\u8bae\u9884\u89c8",
|
||||||
|
untitledMeeting: "\u672a\u547d\u540d\u4f1a\u8bae",
|
||||||
|
meetingTime: "\u4f1a\u8bae\u65f6\u95f4",
|
||||||
|
hostCreator: "\u4e3b\u6301/\u521b\u5efa",
|
||||||
|
participantsCount: "\u53c2\u4f1a\u4eba\u6570",
|
||||||
|
tagsCount: "\u6807\u7b7e\u6570\u91cf",
|
||||||
|
notSet: "\u672a\u8bbe\u7f6e",
|
||||||
|
notFilled: "\u672a\u586b\u5199",
|
||||||
|
pageSummary: "\u603b\u7ed3\u4e0e\u5206\u6790",
|
||||||
|
pageTranscript: "\u8f6c\u5f55\u4e0e\u97f3\u9891",
|
||||||
|
copyLink: "\u590d\u5236\u94fe\u63a5",
|
||||||
|
shareNow: "\u7acb\u5373\u5206\u4eab",
|
||||||
|
shareCopied: "\u9884\u89c8\u94fe\u63a5\u5df2\u590d\u5236",
|
||||||
|
shareFallbackCopied: "\u5f53\u524d\u8bbe\u5907\u4e0d\u652f\u6301\u7cfb\u7edf\u5206\u4eab\uff0c\u5df2\u4e3a\u4f60\u590d\u5236\u94fe\u63a5",
|
||||||
|
shareFailed: "\u5206\u4eab\u5931\u8d25\uff0c\u8bf7\u5148\u590d\u5236\u94fe\u63a5",
|
||||||
|
accessCheck: "\u8bbf\u95ee\u6821\u9a8c",
|
||||||
|
passwordRequired: "\u8be5\u4f1a\u8bae\u9700\u8981\u8bbf\u95ee\u5bc6\u7801",
|
||||||
|
passwordHint: "\u8bf7\u8f93\u5165\u4f1a\u8bae\u7684 access_password \u540e\u7ee7\u7eed\u8bbf\u95ee\u9884\u89c8\u5185\u5bb9\u3002",
|
||||||
|
passwordPlaceholder: "\u8bf7\u8f93\u5165 access_password",
|
||||||
|
openPreview: "\u8fdb\u5165\u9884\u89c8",
|
||||||
|
invalidPassword: "\u8bbf\u95ee\u5bc6\u7801\u9519\u8bef",
|
||||||
|
basicInfo: "\u57fa\u672c\u4fe1\u606f",
|
||||||
|
meetingOverview: "\u4f1a\u8bae\u6982\u51b5",
|
||||||
|
creator: "\u521b\u5efa\u4eba",
|
||||||
|
host: "\u4e3b\u6301\u4eba",
|
||||||
|
createdAt: "\u521b\u5efa\u65f6\u95f4",
|
||||||
|
audioStatus: "\u97f3\u9891\u72b6\u6001",
|
||||||
|
participants: "\u53c2\u4f1a\u4eba\u5458",
|
||||||
|
tags: "\u4f1a\u8bae\u6807\u7b7e",
|
||||||
|
aiAnalysis: "AI \u5206\u6790",
|
||||||
|
analysis: "\u4f1a\u8bae\u5206\u6790",
|
||||||
|
previewExtra: "\u9884\u89c8\u9875\u4ec5\u8bfb\u5c55\u793a",
|
||||||
|
audioPlaybackWarning: "\u97f3\u9891\u4fdd\u5b58\u5931\u8d25\uff0c\u53ef\u80fd\u5f71\u54cd\u56de\u653e\u3002",
|
||||||
|
summaryOverview: "\u5168\u6587\u6982\u8981",
|
||||||
|
summaryEmpty: "\u6682\u65e0\u6982\u8981\u5185\u5bb9",
|
||||||
|
analysisChapters: "\u7ae0\u8282",
|
||||||
|
analysisSpeakers: "\u53d1\u8a00\u4eba",
|
||||||
|
analysisKeyPoints: "\u5173\u952e\u8981\u70b9",
|
||||||
|
analysisTodos: "\u5f85\u529e\u4e8b\u9879",
|
||||||
|
noChapterAnalysis: "\u6682\u65e0\u7ae0\u8282\u5206\u6790",
|
||||||
|
noSpeakerAnalysis: "\u6682\u65e0\u53d1\u8a00\u4eba\u5206\u6790",
|
||||||
|
noKeyPoints: "\u6682\u65e0\u5173\u952e\u8981\u70b9",
|
||||||
|
noTodos: "\u6682\u65e0\u5f85\u529e\u4e8b\u9879",
|
||||||
|
chapterFallback: "\u7ae0\u8282",
|
||||||
|
speakerFallback: "\u53d1\u8a00\u4eba",
|
||||||
|
speakerSummary: "\u53d1\u8a00\u6982\u8ff0",
|
||||||
|
keyPointFallback: "\u8981\u70b9",
|
||||||
|
noChapterSummary: "\u6682\u65e0\u7ae0\u8282\u63cf\u8ff0",
|
||||||
|
noSpeakerSummary: "\u6682\u65e0\u53d1\u8a00\u603b\u7ed3",
|
||||||
|
noKeyPointSummary: "\u6682\u65e0\u8981\u70b9\u8bf4\u660e",
|
||||||
|
summarySection: "\u4f1a\u8bae\u7eaa\u8981",
|
||||||
|
fullSummary: "\u5b8c\u6574\u7eaa\u8981",
|
||||||
|
noSummary: "\u6682\u65e0\u4f1a\u8bae\u7eaa\u8981",
|
||||||
|
transcriptSection: "\u4f1a\u8bae\u8f6c\u5f55",
|
||||||
|
transcriptTitle: "\u9010\u6bb5\u8f6c\u5f55",
|
||||||
|
noDuration: "\u6682\u65e0\u65f6\u957f",
|
||||||
|
audioUnavailable: "\u97f3\u9891\u6587\u4ef6\u4e0d\u53ef\u7528\uff0c\u4ec5\u5c55\u793a\u8f6c\u5f55\u5185\u5bb9\u3002",
|
||||||
|
noTranscript: "\u6682\u65e0\u8f6c\u5f55\u5185\u5bb9",
|
||||||
|
unknownSpeaker: "\u672a\u77e5\u53d1\u8a00\u4eba",
|
||||||
|
disclaimer: "\u8be5\u9875\u9762\u4e3a\u4f1a\u8bae\u5206\u4eab\u9884\u89c8\u9875\uff0c\u672a\u5f00\u653e\u7f16\u8f91\u3001\u5bfc\u51fa\u548c\u5176\u4ed6\u53d7\u4fdd\u62a4\u64cd\u4f5c\u3002",
|
||||||
|
shareText: "\u6211\u5411\u4f60\u5206\u4eab\u4e86\u4e00\u4e2a\u4f1a\u8bae\u9884\u89c8\u94fe\u63a5",
|
||||||
|
audioSaved: "\u5df2\u4fdd\u5b58",
|
||||||
|
audioSaveFailed: "\u4fdd\u5b58\u5931\u8d25",
|
||||||
|
audioUploaded: "\u5df2\u4e0a\u4f20",
|
||||||
|
audioNotSaved: "\u672a\u4fdd\u5b58",
|
||||||
|
};
|
||||||
|
|
||||||
const STATUS_META: Record<number, { label: string; className: string; hint: string }> = {
|
const STATUS_META: Record<number, { label: string; className: string; hint: string }> = {
|
||||||
1: { label: "閺夌儐鍓欓崯鎾寸▔?, className: "is-processing", hint: "濞村吋淇洪鍛村礃閸涱収鍟囧ù鐘茬Т濠€顏堝极鐎靛憡鍊炲☉鎿冨弿缁辨繃锛愰崟顕呮綌闁轰胶澧楀畵浣瑰濮橆厼鐦紓渚囧弨钘熼柛蹇嬪妸閳? },
|
1: { label: TEXT.statusTranscribing, className: "is-processing", hint: TEXT.hintTranscribing },
|
||||||
2: { label: "闁诡剝宕电划銊︾▔?, className: "is-processing", hint: "AI 婵繐绲藉﹢顏堟偨閻旂鐏囬柟顒冨吹缁劑鏁嶅畝鍕垫殨閻熸瑥鐗撻妴澶愭椤厾绐楅柡鍕⒔閵囨艾顔忛幓鎺旀殮闁瑰瓨鍔楀▓鎴﹀礃閸涱収鍟囬柕? },
|
2: { label: TEXT.statusSummarizing, className: "is-processing", hint: TEXT.hintSummarizing },
|
||||||
3: { label: "鐎瑰憡褰冮悾顒勫箣?, className: "is-complete", hint: "濞村吋淇洪鍛棯椤忓浂娲i柕鍡曠閸ㄥ酣寮搁幇顒佸闁告鍠愰弸鍐啅閼碱剚鏅搁柟瀛樺姇閻n剟骞嬮幇鈹惧亾? },
|
3: { label: TEXT.statusCompleted, className: "is-complete", hint: TEXT.hintCompleted },
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatDurationRange(startTime?: number, endTime?: number) {
|
function formatDurationRange(startTime?: number, endTime?: number) {
|
||||||
|
|
@ -44,7 +126,7 @@ function formatDurationRange(startTime?: number, endTime?: number) {
|
||||||
|
|
||||||
function splitDisplayItems(value?: string) {
|
function splitDisplayItems(value?: string) {
|
||||||
return (value || "")
|
return (value || "")
|
||||||
.split(/[闁?闁靛棔绠?)
|
.split(",")
|
||||||
.map((item) => item.trim())
|
.map((item) => item.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
@ -55,26 +137,48 @@ function transcriptColorSeed(speakerKey: string) {
|
||||||
return palette[score % palette.length];
|
return palette[score % palette.length];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function copyText(text: string) {
|
||||||
|
if (navigator.clipboard?.writeText) {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const textarea = document.createElement("textarea");
|
||||||
|
textarea.value = text;
|
||||||
|
textarea.setAttribute("readonly", "true");
|
||||||
|
textarea.style.position = "fixed";
|
||||||
|
textarea.style.opacity = "0";
|
||||||
|
document.body.appendChild(textarea);
|
||||||
|
textarea.select();
|
||||||
|
document.execCommand("copy");
|
||||||
|
document.body.removeChild(textarea);
|
||||||
|
}
|
||||||
|
|
||||||
export default function MeetingPreview() {
|
export default function MeetingPreview() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
const transcriptItemRefs = useRef<Record<number, HTMLDivElement | null>>({});
|
||||||
const [meeting, setMeeting] = useState<MeetingVO | null>(null);
|
const [meeting, setMeeting] = useState<MeetingVO | null>(null);
|
||||||
const [transcripts, setTranscripts] = useState<MeetingTranscriptVO[]>([]);
|
const [transcripts, setTranscripts] = useState<MeetingTranscriptVO[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [analysisTab, setAnalysisTab] = useState<AnalysisTab>("chapters");
|
const [analysisTab, setAnalysisTab] = useState<AnalysisTab>("chapters");
|
||||||
|
const [pageTab, setPageTab] = useState<PreviewPageTab>("summary");
|
||||||
const [activeTranscriptId, setActiveTranscriptId] = useState<number | null>(null);
|
const [activeTranscriptId, setActiveTranscriptId] = useState<number | null>(null);
|
||||||
const [passwordRequired, setPasswordRequired] = useState(false);
|
const [passwordRequired, setPasswordRequired] = useState(false);
|
||||||
const [passwordVerified, setPasswordVerified] = useState(false);
|
const [passwordVerified, setPasswordVerified] = useState(false);
|
||||||
const [accessPassword, setAccessPassword] = useState("");
|
const [accessPassword, setAccessPassword] = useState("");
|
||||||
const [passwordError, setPasswordError] = useState("");
|
const [passwordError, setPasswordError] = useState("");
|
||||||
|
const [isMobile, setIsMobile] = useState(() =>
|
||||||
|
typeof window !== "undefined" ? window.matchMedia("(max-width: 767px)").matches : false,
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let mounted = true;
|
let mounted = true;
|
||||||
|
|
||||||
const fetchData = async () => {
|
const load = async () => {
|
||||||
if (!id) {
|
if (!id) {
|
||||||
setError("闁哄牜浜濊ぐ浣圭瑹濞戞绐楅悹渚囧枤缁鳖亪宕?);
|
setError(TEXT.missingMeetingId);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -91,7 +195,6 @@ export default function MeetingPreview() {
|
||||||
try {
|
try {
|
||||||
const meetingId = Number(id);
|
const meetingId = Number(id);
|
||||||
const accessRes = await getMeetingPreviewAccess(meetingId);
|
const accessRes = await getMeetingPreviewAccess(meetingId);
|
||||||
|
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -116,7 +219,7 @@ export default function MeetingPreview() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setError(requestError?.response?.data?.msg || "濞村吋淇洪鍛紣閸曨噮娼旈柛鏃傚Ь濞村洦寰勬潏顐バ?);
|
setError(requestError?.response?.data?.msg || requestError?.msg || TEXT.loadFailed);
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
@ -124,25 +227,78 @@ export default function MeetingPreview() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchData();
|
load();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
mounted = false;
|
mounted = false;
|
||||||
};
|
};
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaQuery = window.matchMedia("(max-width: 767px)");
|
||||||
|
const handleChange = (event: MediaQueryListEvent) => {
|
||||||
|
setIsMobile(event.matches);
|
||||||
|
};
|
||||||
|
|
||||||
|
setIsMobile(mediaQuery.matches);
|
||||||
|
mediaQuery.addEventListener("change", handleChange);
|
||||||
|
return () => {
|
||||||
|
mediaQuery.removeEventListener("change", handleChange);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const analysis = useMemo(
|
const analysis = useMemo(
|
||||||
() => buildMeetingAnalysis(meeting?.analysis, meeting?.summaryContent, meeting?.tags || ""),
|
() => buildMeetingAnalysis(meeting?.analysis, meeting?.summaryContent, meeting?.tags || ""),
|
||||||
[meeting?.analysis, meeting?.summaryContent, meeting?.tags],
|
[meeting?.analysis, meeting?.summaryContent, meeting?.tags],
|
||||||
);
|
);
|
||||||
|
|
||||||
const participants = useMemo(() => splitDisplayItems(meeting?.participants), [meeting?.participants]);
|
const participants = useMemo(() => splitDisplayItems(meeting?.participants), [meeting?.participants]);
|
||||||
|
const transcriptSpeakers = useMemo(() => {
|
||||||
|
const speakers = transcripts
|
||||||
|
.map((item) => item.speakerName || item.speakerLabel || item.speakerId || "")
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
return Array.from(new Set(speakers));
|
||||||
|
}, [transcripts]);
|
||||||
const tags = useMemo(() => splitDisplayItems(meeting?.tags), [meeting?.tags]);
|
const tags = useMemo(() => splitDisplayItems(meeting?.tags), [meeting?.tags]);
|
||||||
|
const keywords = useMemo(() => analysis.keywords || [], [analysis.keywords]);
|
||||||
const statusMeta = STATUS_META[meeting?.status || 0] || {
|
const statusMeta = STATUS_META[meeting?.status || 0] || {
|
||||||
label: "鐎垫澘鎳庨ˇ鈺呮偠?,
|
label: TEXT.statusPending,
|
||||||
className: "is-warning",
|
className: "is-warning",
|
||||||
hint: "鐟滅増鎸告晶鐘冲濮樻剚鍞村ù鐘茬У濠€顓㈡偨閻旂鐏囬悗鐟版湰閺嗭綁宕橀崨顓у晣闁挎稑鐭侀顒傜矙瀹ュ懏鍊甸柛鎰Х閻︻垶濡?,
|
hint: TEXT.hintPending,
|
||||||
};
|
};
|
||||||
|
const audioStatusLabel = useMemo(() => {
|
||||||
|
if (meeting?.audioSaveStatus === "SUCCESS") {
|
||||||
|
return TEXT.audioSaved;
|
||||||
|
}
|
||||||
|
if (meeting?.audioSaveStatus === "FAILED") {
|
||||||
|
return TEXT.audioSaveFailed;
|
||||||
|
}
|
||||||
|
if (meeting?.audioUrl) {
|
||||||
|
return TEXT.audioUploaded;
|
||||||
|
}
|
||||||
|
return TEXT.audioNotSaved;
|
||||||
|
}, [meeting?.audioSaveStatus, meeting?.audioUrl]);
|
||||||
|
const shareUrl = typeof window !== "undefined" ? window.location.href : "";
|
||||||
|
const participantCountValue =
|
||||||
|
isMobile && transcriptSpeakers.length > 0 ? transcriptSpeakers.length : participants.length;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeTranscriptId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = transcriptItemRefs.current[activeTranscriptId];
|
||||||
|
if (!target) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
target.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||||||
|
}, [activeTranscriptId]);
|
||||||
|
|
||||||
const handleTranscriptSeek = (item: MeetingTranscriptVO) => {
|
const handleTranscriptSeek = (item: MeetingTranscriptVO) => {
|
||||||
if (!audioRef.current) {
|
if (!audioRef.current) {
|
||||||
|
|
@ -166,6 +322,19 @@ export default function MeetingPreview() {
|
||||||
setActiveTranscriptId(currentItem?.id || null);
|
setActiveTranscriptId(currentItem?.id || null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderMeetingTitle = (title?: string) => {
|
||||||
|
const safeTitle = title || TEXT.untitledMeeting;
|
||||||
|
return safeTitle.split(/(\d+)/).map((part, index) =>
|
||||||
|
/\d+/.test(part) ? (
|
||||||
|
<span key={`${part}-${index}`} className="meeting-preview-title-number">
|
||||||
|
{part}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span key={`${part}-${index}`}>{part}</span>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const handlePasswordSubmit = async () => {
|
const handlePasswordSubmit = async () => {
|
||||||
if (!id) {
|
if (!id) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -179,12 +348,39 @@ export default function MeetingPreview() {
|
||||||
setTranscripts(previewRes.data.data.transcripts || []);
|
setTranscripts(previewRes.data.data.transcripts || []);
|
||||||
setPasswordVerified(true);
|
setPasswordVerified(true);
|
||||||
} catch (requestError: any) {
|
} catch (requestError: any) {
|
||||||
setPasswordError(requestError?.response?.data?.msg || requestError?.msg || "閻犱礁娼″Λ鍓佲偓闈涙閻栨粓鏌ㄥ▎鎺濆殩");
|
setPasswordError(requestError?.response?.data?.msg || requestError?.msg || TEXT.invalidPassword);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCopyLink = async () => {
|
||||||
|
try {
|
||||||
|
await copyText(shareUrl);
|
||||||
|
message.success(TEXT.shareCopied);
|
||||||
|
} catch {
|
||||||
|
message.error(TEXT.shareFailed);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleShareNow = async () => {
|
||||||
|
try {
|
||||||
|
if (navigator.share) {
|
||||||
|
await navigator.share({
|
||||||
|
title: meeting?.title || TEXT.previewLabel,
|
||||||
|
text: TEXT.shareText,
|
||||||
|
url: shareUrl,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await copyText(shareUrl);
|
||||||
|
message.success(TEXT.shareFallbackCopied);
|
||||||
|
} catch {
|
||||||
|
message.error(TEXT.shareFailed);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (loading && (!passwordRequired || passwordVerified)) {
|
if (loading && (!passwordRequired || passwordVerified)) {
|
||||||
return (
|
return (
|
||||||
<div className="meeting-preview-page">
|
<div className="meeting-preview-page">
|
||||||
|
|
@ -212,25 +408,23 @@ export default function MeetingPreview() {
|
||||||
<div>
|
<div>
|
||||||
<div className="meeting-preview-section-kicker">
|
<div className="meeting-preview-section-kicker">
|
||||||
<LockOutlined />
|
<LockOutlined />
|
||||||
Access Check
|
{TEXT.accessCheck}
|
||||||
</div>
|
</div>
|
||||||
<h2 className="meeting-preview-section-title">Password Required</h2>
|
<h2 className="meeting-preview-section-title">{TEXT.passwordRequired}</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="meeting-preview-subtitle">
|
<p className="meeting-preview-subtitle">{TEXT.passwordHint}</p>
|
||||||
Enter access_password to view this meeting preview.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="meeting-preview-password-form">
|
<div className="meeting-preview-password-form">
|
||||||
<Input.Password
|
<Input.Password
|
||||||
value={accessPassword}
|
value={accessPassword}
|
||||||
placeholder="Enter access_password"
|
placeholder={TEXT.passwordPlaceholder}
|
||||||
onChange={(event) => setAccessPassword(event.target.value)}
|
onChange={(event) => setAccessPassword(event.target.value)}
|
||||||
onPressEnter={handlePasswordSubmit}
|
onPressEnter={handlePasswordSubmit}
|
||||||
/>
|
/>
|
||||||
<Button type="primary" onClick={handlePasswordSubmit} loading={loading} disabled={!accessPassword.trim()}>
|
<Button type="primary" onClick={handlePasswordSubmit} loading={loading} disabled={!accessPassword.trim()}>
|
||||||
Open Preview
|
{TEXT.openPreview}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -246,7 +440,7 @@ export default function MeetingPreview() {
|
||||||
<div className="meeting-preview-page">
|
<div className="meeting-preview-page">
|
||||||
<div className="meeting-preview-shell meeting-preview-empty">
|
<div className="meeting-preview-shell meeting-preview-empty">
|
||||||
<div className="meeting-preview-card meeting-preview-section">
|
<div className="meeting-preview-card meeting-preview-section">
|
||||||
<Result status="error" title="濞村吋淇洪鍛紣閸曨噮娼旈柛鏃傚Ь濞村洦寰勬潏顐バ? subTitle={error} />
|
<Result status="error" title={TEXT.loadFailed} subTitle={error} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -258,86 +452,50 @@ export default function MeetingPreview() {
|
||||||
<div className="meeting-preview-page">
|
<div className="meeting-preview-page">
|
||||||
<div className="meeting-preview-shell meeting-preview-empty">
|
<div className="meeting-preview-shell meeting-preview-empty">
|
||||||
<div className="meeting-preview-card meeting-preview-section">
|
<div className="meeting-preview-card meeting-preview-section">
|
||||||
<Empty description="闁哄牜浜濇竟姗€宕氭0浣虹獥閻犱緡鍠楅弳鐔煎箲? />
|
<Empty description={TEXT.noMeetingData} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const summaryTab = (
|
||||||
<div className="meeting-preview-page">
|
<div className="meeting-preview-tab-panel">
|
||||||
<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">
|
<section className="meeting-preview-card meeting-preview-section">
|
||||||
<div className="meeting-preview-section-header">
|
<div className="meeting-preview-section-header">
|
||||||
<div>
|
<div>
|
||||||
<div className="meeting-preview-section-kicker">
|
<div className="meeting-preview-section-kicker">
|
||||||
<CalendarOutlined />
|
<CalendarOutlined />
|
||||||
闁糕晝鍎ゅ﹢鐗堢┍閳╁啩绱?
|
{TEXT.basicInfo}
|
||||||
</div>
|
</div>
|
||||||
<h2 className="meeting-preview-section-title">濞村吋淇洪鍛潡閸屾艾鏋?/h2>
|
<h2 className="meeting-preview-section-title">{TEXT.meetingOverview}</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="meeting-preview-metrics">
|
<div className="meeting-preview-metrics">
|
||||||
<div className="meeting-preview-metric">
|
<div className="meeting-preview-metric">
|
||||||
<span className="meeting-preview-metric-label">闁告帗绋戠紓鎾寸?/span>
|
<span className="meeting-preview-metric-label">{TEXT.creator}</span>
|
||||||
<span className="meeting-preview-metric-value">{meeting.creatorName || "闁哄牜浜i鏇犵磾?}</span>
|
<span className="meeting-preview-metric-value">{meeting.creatorName || TEXT.notSet}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="meeting-preview-metric">
|
<div className="meeting-preview-metric">
|
||||||
<span className="meeting-preview-metric-label">濞戞挾绮€垫梹绂?/span>
|
<span className="meeting-preview-metric-label">{TEXT.host}</span>
|
||||||
<span className="meeting-preview-metric-value">{meeting.hostName || "闁哄牜浜i鏇犵磾?}</span>
|
<span className="meeting-preview-metric-value">{meeting.hostName || TEXT.notSet}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="meeting-preview-metric">
|
<div className="meeting-preview-metric">
|
||||||
<span className="meeting-preview-metric-label">闁告帗绋戠紓鎾诲籍閸洘锛?/span>
|
<span className="meeting-preview-metric-label">{TEXT.createdAt}</span>
|
||||||
<span className="meeting-preview-metric-value">
|
<span className="meeting-preview-metric-value">
|
||||||
{meeting.createdAt ? dayjs(meeting.createdAt).format("YYYY.MM.DD HH:mm") : "闁哄牜浜i鏇犵磾?}
|
{meeting.createdAt ? dayjs(meeting.createdAt).format("YYYY.MM.DD HH:mm") : TEXT.notSet}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="meeting-preview-metric">
|
<div className="meeting-preview-metric">
|
||||||
<span className="meeting-preview-metric-label">闂傚﹥濞婇。鍫曟偐閼哥鍋?/span>
|
<span className="meeting-preview-metric-label">{TEXT.audioStatus}</span>
|
||||||
<span className="meeting-preview-metric-value">{meeting.audioSaveStatus || "NONE"}</span>
|
<span className="meeting-preview-metric-value">{audioStatusLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{participants.length > 0 ? (
|
{participants.length > 0 ? (
|
||||||
<div className="meeting-preview-overview">
|
<div className="meeting-preview-overview">
|
||||||
<div className="meeting-preview-overview-label">闁告瑥鍊风槐鐗堢閸濆嫭鍠?/div>
|
<div className="meeting-preview-overview-label">{TEXT.participants}</div>
|
||||||
<div className="meeting-preview-tags">
|
<div className="meeting-preview-tags">
|
||||||
{participants.map((item) => (
|
{participants.map((item) => (
|
||||||
<span key={item} className="meeting-preview-tag">
|
<span key={item} className="meeting-preview-tag">
|
||||||
|
|
@ -351,7 +509,7 @@ export default function MeetingPreview() {
|
||||||
|
|
||||||
{tags.length > 0 ? (
|
{tags.length > 0 ? (
|
||||||
<div className="meeting-preview-overview">
|
<div className="meeting-preview-overview">
|
||||||
<div className="meeting-preview-overview-label">濞村吋淇洪鍛村冀閸モ晩鍔?/div>
|
<div className="meeting-preview-overview-label">{TEXT.tags}</div>
|
||||||
<div className="meeting-preview-tags">
|
<div className="meeting-preview-tags">
|
||||||
{tags.map((item) => (
|
{tags.map((item) => (
|
||||||
<Tag key={item} bordered={false} className="meeting-preview-tag">
|
<Tag key={item} bordered={false} className="meeting-preview-tag">
|
||||||
|
|
@ -368,26 +526,25 @@ export default function MeetingPreview() {
|
||||||
<div>
|
<div>
|
||||||
<div className="meeting-preview-section-kicker">
|
<div className="meeting-preview-section-kicker">
|
||||||
<RobotOutlined />
|
<RobotOutlined />
|
||||||
闁哄懘缂氶崗姗€鏌呴悢娲绘綌
|
{TEXT.aiAnalysis}
|
||||||
</div>
|
</div>
|
||||||
<h2 className="meeting-preview-section-title">濞村吋淇洪鍛村礆閸℃鈧?/h2>
|
<h2 className="meeting-preview-section-title">{TEXT.analysis}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="meeting-preview-section-extra">濞戞挸绨肩槐鎵媼椤旀鍤婇柟顖氭噸缁绘岸骞愭担鍛婂€遍柛娆欑到缁?/div>
|
<div className="meeting-preview-section-extra">{TEXT.previewExtra}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{meeting.status < 3 ? (
|
{meeting.audioSaveStatus === "FAILED" ? (
|
||||||
<Alert
|
<Alert
|
||||||
className="meeting-preview-alert"
|
className="meeting-preview-alert"
|
||||||
type="info"
|
type="warning"
|
||||||
showIcon
|
showIcon
|
||||||
message="濞村吋淇洪鍛瀹ュ懏韬璺哄閹﹥绋?
|
message={meeting.audioSaveMessage || TEXT.audioPlaybackWarning}
|
||||||
description="鐟滅増鎸告晶鐘炽亜閻㈠憡妗ㄥù鍏肩煯缁鳖參宕楅崼婵堟綌缂佲偓閸濆嫬鍤掗柣銏㈠枑閸ㄦ岸鎯冮崟顐㈡暥閻庡湱娅㈢槐婵堚偓鐟版湰閺嗭綁骞€閼姐倗娉㈤柛婊冭嫰鐢偊寮崶褏娈洪柛锔哄妺閹广垽宕濋垾宕囨殮闁瑰瓨鍔曢幃妤冩偘閵夆晝绉烽柕?
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{analysis.keywords.length > 0 ? (
|
{keywords.length > 0 ? (
|
||||||
<div className="meeting-preview-tags">
|
<div className="meeting-preview-tags">
|
||||||
{analysis.keywords.map((item) => (
|
{keywords.map((item) => (
|
||||||
<span key={item} className="meeting-preview-tag">
|
<span key={item} className="meeting-preview-tag">
|
||||||
{item}
|
{item}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -396,87 +553,83 @@ export default function MeetingPreview() {
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="meeting-preview-overview">
|
<div className="meeting-preview-overview">
|
||||||
<div className="meeting-preview-overview-label">闁稿繈鍔嶉弸鍐潡閸屾繍娲?/div>
|
<div className="meeting-preview-overview-label">{TEXT.summaryOverview}</div>
|
||||||
<p className="meeting-preview-overview-copy">{analysis.overview || "闁哄棗鍊瑰Λ銈咁潡閸屾繍娲i柛鎰噹椤?}</p>
|
<p className="meeting-preview-overview-copy">{analysis.overview || TEXT.summaryEmpty}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="meeting-preview-analysis-tabs">
|
<div className="meeting-preview-analysis-tabs">
|
||||||
<Segmented<AnalysisTab>
|
<Segmented
|
||||||
block
|
|
||||||
value={analysisTab}
|
value={analysisTab}
|
||||||
onChange={(value) => setAnalysisTab(value)}
|
onChange={(value) => setAnalysisTab(value as AnalysisTab)}
|
||||||
options={[
|
options={[
|
||||||
{ label: "缂佹梻濮炬俊?, value: "chapters" },
|
{ label: TEXT.analysisChapters, value: "chapters" },
|
||||||
{ label: "闁告瑦鍨奸埢?, value: "speakers" },
|
{ label: TEXT.analysisSpeakers, value: "speakers" },
|
||||||
{ label: "閻熸洑鑳堕崑?, value: "actions" },
|
{ label: TEXT.analysisKeyPoints, value: "actions" },
|
||||||
{ label: "鐎垫澘鎳庢慨?, value: "todos" },
|
{ label: TEXT.analysisTodos, value: "todos" },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="meeting-preview-analysis-panel">
|
<div className="meeting-preview-analysis-panel">
|
||||||
{analysisTab === "chapters" &&
|
{analysisTab === "chapters" ? (
|
||||||
(analysis.chapters.length ? (
|
analysis.chapters.length > 0 ? (
|
||||||
analysis.chapters.map((item, index) => (
|
analysis.chapters.map((item, index) => (
|
||||||
<div className="meeting-preview-chapter" key={`${item.title}-${index}`}>
|
<div className="meeting-preview-chapter" key={`${item.title || "chapter"}-${index}`}>
|
||||||
<div className="meeting-preview-chapter-time">{item.time || "--:--"}</div>
|
<div className="meeting-preview-chapter-time">{item.time || "--:--"}</div>
|
||||||
<div>
|
<div>
|
||||||
<strong className="meeting-preview-item-title">{item.title || `缂佹梻濮炬俊?${index + 1}`}</strong>
|
<strong className="meeting-preview-item-title">{item.title || `${TEXT.chapterFallback} ${index + 1}`}</strong>
|
||||||
<span className="meeting-preview-item-copy">{item.summary || "闁哄棗鍊瑰Λ銈囩博閻樺搫螡闁硅绻楅崼?}</span>
|
<span className="meeting-preview-item-copy">{item.summary || TEXT.noChapterSummary}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<div className="meeting-preview-list-empty">
|
<div className="meeting-preview-list-empty">{TEXT.noChapterAnalysis}</div>
|
||||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="闁哄棗鍊瑰Λ銈囩博閻樺搫螡闂侇偆鍠曢~? />
|
)
|
||||||
</div>
|
) : null}
|
||||||
))}
|
|
||||||
|
|
||||||
{analysisTab === "speakers" &&
|
{analysisTab === "speakers" ? (
|
||||||
(analysis.speakerSummaries.length ? (
|
analysis.speakerSummaries.length > 0 ? (
|
||||||
analysis.speakerSummaries.map((item, index) => (
|
analysis.speakerSummaries.map((item, index) => (
|
||||||
<div className="meeting-preview-speaker-card" key={`${item.speaker}-${index}`}>
|
<div className="meeting-preview-speaker-card" key={`${item.speaker || "speaker"}-${index}`}>
|
||||||
<div className="meeting-preview-speaker-head">
|
<div className="meeting-preview-speaker-head">
|
||||||
<div className="meeting-preview-speaker-avatar">{(item.speaker || "闁?).slice(0, 1)}</div>
|
<div className="meeting-preview-speaker-avatar">{(item.speaker || "S").slice(0, 1)}</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="meeting-preview-speaker-name">{item.speaker || `闁告瑦鍨奸埢鍫熺?${index + 1}`}</div>
|
<div className="meeting-preview-speaker-name">{item.speaker || `${TEXT.speakerFallback} ${index + 1}`}</div>
|
||||||
<div className="meeting-preview-speaker-role">闁告瑦鍨奸埢鍫濐潡閸屾繂鐗?/div>
|
<div className="meeting-preview-speaker-role">{TEXT.speakerSummary}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="meeting-preview-item-copy">{item.summary || "闁哄棗鍊瑰Λ銈夊矗閹达絺鏋呴柟顒冨吹缁?}</div>
|
<div className="meeting-preview-item-copy">{item.summary || TEXT.noSpeakerSummary}</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<div className="meeting-preview-list-empty">
|
<div className="meeting-preview-list-empty">{TEXT.noSpeakerAnalysis}</div>
|
||||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="闁哄棗鍊瑰Λ銈夊矗閹达絺鏋呴柟顒冨吹缁? />
|
)
|
||||||
</div>
|
) : null}
|
||||||
))}
|
|
||||||
|
|
||||||
{analysisTab === "actions" &&
|
{analysisTab === "actions" ? (
|
||||||
(analysis.keyPoints.length ? (
|
analysis.keyPoints.length > 0 ? (
|
||||||
analysis.keyPoints.map((item, index) => (
|
analysis.keyPoints.map((item, index) => (
|
||||||
<div className="meeting-preview-keypoint" key={`${item.title}-${index}`}>
|
<div className="meeting-preview-keypoint" key={`${item.title || "key-point"}-${index}`}>
|
||||||
<div className="meeting-preview-keypoint-index">{String(index + 1).padStart(2, "0")}</div>
|
<div className="meeting-preview-keypoint-index">{String(index + 1).padStart(2, "0")}</div>
|
||||||
<div>
|
<div>
|
||||||
<strong className="meeting-preview-item-title">{item.title || `閻熸洑鑳堕崑?${index + 1}`}</strong>
|
<strong className="meeting-preview-item-title">{item.title || `${TEXT.keyPointFallback} ${index + 1}`}</strong>
|
||||||
<span className="meeting-preview-item-copy">{item.summary || "闁哄棗鍊瑰Λ銈囨啺娴e搫浠悹鍥х摠濡?}</span>
|
<span className="meeting-preview-item-copy">{item.summary || TEXT.noKeyPointSummary}</span>
|
||||||
{(item.speaker || item.time) && (
|
{(item.speaker || item.time) ? (
|
||||||
<div className="meeting-preview-item-meta">
|
<div className="meeting-preview-item-meta">
|
||||||
{item.speaker ? <span className="meeting-preview-meta-pill">{item.speaker}</span> : null}
|
{item.speaker ? <span className="meeting-preview-meta-pill">{item.speaker}</span> : null}
|
||||||
{item.time ? <span className="meeting-preview-meta-pill">{item.time}</span> : null}
|
{item.time ? <span className="meeting-preview-meta-pill">{item.time}</span> : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<div className="meeting-preview-list-empty">
|
<div className="meeting-preview-list-empty">{TEXT.noKeyPoints}</div>
|
||||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="闁哄棗鍊瑰Λ銈囨啺娴e搫浠柛銉у仱閵? />
|
)
|
||||||
</div>
|
) : null}
|
||||||
))}
|
|
||||||
|
|
||||||
{analysisTab === "todos" &&
|
{analysisTab === "todos" ? (
|
||||||
(analysis.todos.length ? (
|
analysis.todos.length > 0 ? (
|
||||||
analysis.todos.map((item, index) => (
|
analysis.todos.map((item, index) => (
|
||||||
<div className="meeting-preview-todo" key={`${item}-${index}`}>
|
<div className="meeting-preview-todo" key={`${item}-${index}`}>
|
||||||
<span className="meeting-preview-todo-dot" />
|
<span className="meeting-preview-todo-dot" />
|
||||||
|
|
@ -484,10 +637,9 @@ export default function MeetingPreview() {
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<div className="meeting-preview-list-empty">
|
<div className="meeting-preview-list-empty">{TEXT.noTodos}</div>
|
||||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="闁哄棗鍊瑰Λ銈咁嚗閸涱厼顫炲ù婊冾儔閵? />
|
)
|
||||||
</div>
|
) : null}
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
@ -496,35 +648,37 @@ export default function MeetingPreview() {
|
||||||
<div>
|
<div>
|
||||||
<div className="meeting-preview-section-kicker">
|
<div className="meeting-preview-section-kicker">
|
||||||
<FileTextOutlined />
|
<FileTextOutlined />
|
||||||
AI 闁诡剝宕电划?
|
{TEXT.summarySection}
|
||||||
</div>
|
</div>
|
||||||
<h2 className="meeting-preview-section-title">閻庣懓鏈弳锝囩棯椤忓浂娲?/h2>
|
<h2 className="meeting-preview-section-title">{TEXT.fullSummary}</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="meeting-preview-markdown">
|
<div className="meeting-preview-markdown">
|
||||||
{meeting.summaryContent ? (
|
{meeting.summaryContent ? (
|
||||||
<div className="markdown-body">
|
|
||||||
<ReactMarkdown>{meeting.summaryContent}</ReactMarkdown>
|
<ReactMarkdown>{meeting.summaryContent}</ReactMarkdown>
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<Empty description="闁哄棗鍊瑰Λ銈夊箑閼姐倗娉㈤柛鎰噹椤? />
|
<Empty description={TEXT.noSummary} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const transcriptTab = (
|
||||||
|
<div className="meeting-preview-tab-panel">
|
||||||
<section className="meeting-preview-card meeting-preview-section">
|
<section className="meeting-preview-card meeting-preview-section">
|
||||||
<div className="meeting-preview-section-header">
|
<div className="meeting-preview-section-header">
|
||||||
<div>
|
<div>
|
||||||
<div className="meeting-preview-section-kicker">
|
<div className="meeting-preview-section-kicker">
|
||||||
<AudioOutlined />
|
<AudioOutlined />
|
||||||
闁告鍠愰弸鍐媼閺夎法绉?
|
{TEXT.transcriptSection}
|
||||||
</div>
|
</div>
|
||||||
<h2 className="meeting-preview-section-title">濞村吋淇洪鍛姜椤掆偓缂?/h2>
|
<h2 className="meeting-preview-section-title">{TEXT.transcriptTitle}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="meeting-preview-section-extra">
|
<div className="meeting-preview-section-extra">
|
||||||
<ClockCircleOutlined style={{ marginRight: 6 }} />
|
<ClockCircleOutlined style={{ marginRight: 6 }} />
|
||||||
闁绘劗鎳撻崵顔尖枔娴e啯鍎伴柛娆樺灥閻戯附娼鍕従濡?
|
{meeting.duration ? formatDurationRange(0, meeting.duration) : TEXT.noDuration}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -533,8 +687,7 @@ export default function MeetingPreview() {
|
||||||
className="meeting-preview-alert"
|
className="meeting-preview-alert"
|
||||||
type="warning"
|
type="warning"
|
||||||
showIcon
|
showIcon
|
||||||
message="鐟滅増娲熼悡鍫曞棘閸ワ附顐藉☉鎾崇Т瑜版煡鎮?
|
message={meeting.audioSaveMessage || TEXT.audioUnavailable}
|
||||||
description={meeting.audioSaveMessage || "濞村吋淇洪鍛啅閹绘帞鏆氶柟瀛樺姧缁辨繃鎷呴崱娑氬従濡増鍨崇换姘扁偓娑櫭妵鎴犳嫻閵夘垳绀夌憸鐗堟尭婢х娀宕i鍥у幋闁哄被鍎冲﹢鍛村棘閸パ呮憻閺夌儐鍓欑紞宥夊Υ?}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|
@ -542,52 +695,117 @@ export default function MeetingPreview() {
|
||||||
<audio
|
<audio
|
||||||
ref={audioRef}
|
ref={audioRef}
|
||||||
className="meeting-preview-transcript-audio"
|
className="meeting-preview-transcript-audio"
|
||||||
src={meeting.audioUrl}
|
|
||||||
controls
|
controls
|
||||||
preload="metadata"
|
src={meeting.audioUrl}
|
||||||
onTimeUpdate={handleAudioTimeUpdate}
|
onTimeUpdate={handleAudioTimeUpdate}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="meeting-preview-transcript-list">
|
<div className="meeting-preview-transcript-list">
|
||||||
{transcripts.length ? (
|
{transcripts.length > 0 ? (
|
||||||
transcripts.map((item) => {
|
transcripts.map((item) => {
|
||||||
const speakerName = item.speakerLabel || item.speakerName || item.speakerId || "闁告瑦鍨奸埢鍫熺?;
|
const speakerKey = item.speakerName || item.speakerLabel || item.speakerId || "speaker";
|
||||||
const avatarColor = transcriptColorSeed(speakerName);
|
const avatarColor = transcriptColorSeed(speakerKey);
|
||||||
return (
|
return (
|
||||||
<button
|
<div
|
||||||
key={item.id}
|
key={item.id}
|
||||||
type="button"
|
ref={(node) => {
|
||||||
|
transcriptItemRefs.current[item.id] = node;
|
||||||
|
}}
|
||||||
className={`meeting-preview-transcript-item ${activeTranscriptId === item.id ? "is-active" : ""}`}
|
className={`meeting-preview-transcript-item ${activeTranscriptId === item.id ? "is-active" : ""}`}
|
||||||
onClick={() => handleTranscriptSeek(item)}
|
onClick={() => handleTranscriptSeek(item)}
|
||||||
>
|
>
|
||||||
<div className="meeting-preview-transcript-head">
|
<div className="meeting-preview-transcript-head">
|
||||||
<div className="meeting-preview-transcript-speaker">
|
<div className="meeting-preview-transcript-speaker">
|
||||||
<div className="meeting-preview-transcript-avatar" style={{ backgroundColor: avatarColor }}>
|
<div className="meeting-preview-transcript-avatar" style={{ backgroundColor: avatarColor }}>
|
||||||
{(speakerName || "闁?).slice(0, 1)}
|
{(speakerKey || "S").slice(0, 1)}
|
||||||
</div>
|
</div>
|
||||||
<div className="meeting-preview-transcript-name">
|
<div className="meeting-preview-transcript-name">
|
||||||
<UserOutlined style={{ marginRight: 6, color: avatarColor }} />
|
{item.speakerName || item.speakerLabel || item.speakerId || TEXT.unknownSpeaker}
|
||||||
{speakerName}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="meeting-preview-transcript-time">
|
<div className="meeting-preview-transcript-time">
|
||||||
{formatDurationRange(item.startTime, item.endTime)}
|
{formatDurationRange(item.startTime, item.endTime)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="meeting-preview-transcript-copy">{item.content || "闁哄棗鍊瑰Λ銈嗘姜椤掆偓缂嶅秹宕橀崨顓у晣"}</div>
|
<div className="meeting-preview-transcript-copy">{item.content || TEXT.noTranscript}</div>
|
||||||
</button>
|
</div>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
) : (
|
) : (
|
||||||
<Empty description="闁哄棗鍊瑰Λ銈嗘姜椤掆偓缂嶅秹宕橀崨顓у晣" />
|
<Empty description={TEXT.noTranscript} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</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 />
|
||||||
|
{TEXT.previewLabel}
|
||||||
|
</div>
|
||||||
|
<span className={`meeting-preview-status ${statusMeta.className}`}>{statusMeta.label}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="meeting-preview-hero-toolbar">
|
||||||
|
<div>
|
||||||
|
<h1 className="meeting-preview-title">{renderMeetingTitle(meeting.title)}</h1>
|
||||||
|
<p className="meeting-preview-subtitle">{statusMeta.hint}</p>
|
||||||
|
</div>
|
||||||
|
<div className="meeting-preview-hero-actions">
|
||||||
|
<Button icon={<CopyOutlined />} onClick={handleCopyLink}>
|
||||||
|
{TEXT.copyLink}
|
||||||
|
</Button>
|
||||||
|
<Button type="primary" icon={<ShareAltOutlined />} onClick={handleShareNow}>
|
||||||
|
{TEXT.shareNow}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="meeting-preview-metrics">
|
||||||
|
<div className="meeting-preview-metric">
|
||||||
|
<span className="meeting-preview-metric-label">{TEXT.meetingTime}</span>
|
||||||
|
<span className="meeting-preview-metric-value">
|
||||||
|
{meeting.meetingTime ? dayjs(meeting.meetingTime).format("YYYY.MM.DD HH:mm") : TEXT.notSet}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="meeting-preview-metric">
|
||||||
|
<span className="meeting-preview-metric-label">{TEXT.hostCreator}</span>
|
||||||
|
<span className="meeting-preview-metric-value">{meeting.hostName || meeting.creatorName || TEXT.notSet}</span>
|
||||||
|
</div>
|
||||||
|
<div className="meeting-preview-metric">
|
||||||
|
<span className="meeting-preview-metric-label">{TEXT.participantsCount}</span>
|
||||||
|
<span className="meeting-preview-metric-value">{participantCountValue || TEXT.notFilled}</span>
|
||||||
|
</div>
|
||||||
|
<div className="meeting-preview-metric">
|
||||||
|
<span className="meeting-preview-metric-label">{TEXT.tagsCount}</span>
|
||||||
|
<span className="meeting-preview-metric-value">{tags.length || TEXT.notSet}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="meeting-preview-panels">
|
||||||
|
<section className="meeting-preview-card meeting-preview-section">
|
||||||
|
<Tabs
|
||||||
|
className="meeting-preview-page-tabs"
|
||||||
|
activeKey={pageTab}
|
||||||
|
onChange={(key) => setPageTab(key as PreviewPageTab)}
|
||||||
|
items={[
|
||||||
|
{ key: "summary", label: TEXT.pageSummary, children: summaryTab },
|
||||||
|
{ key: "transcript", label: TEXT.pageTranscript, children: transcriptTab },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="meeting-preview-disclaimer">
|
<div className="meeting-preview-disclaimer">
|
||||||
闁哄懘缂氶崗姗€宕橀崨顓у晣闁?AI 婵☆垪鈧磭鈧兘鎮介悢绋跨亣闁挎稑濂旂划搴ㄦ偨閵娿倗鑹惧ù鍏间亢椤斿懏绌遍埄鍐х礀濡澘瀚~宥夋晬瀹€鍐惧殲缂備焦鎸搁幃搴ㄥ储閻斿娼楀ù鍏间亢椤斿懐鎷犻鐑嗘殧閻庣櫢绻濆Σ鍕椽瀹€鈧垾妯兼媼閵堝啠鍋?
|
<UserOutlined style={{ marginRight: 8 }} />
|
||||||
|
{TEXT.disclaimer}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue