feat(frontend): 添加Antd App组件和配置更新
- 将全局message替换为App.useApp以支持Antd 5.x静态方法
- 更新vite代理配置指向新后端地址
- 添加baseUrl到tsconfig.json支持路径别名
- 统一Card组件使用variant="borderless"替代bordered={false}
- 移除AppLayout中的菜单loading属性
- 优化热词表格显示,添加文本省略
- 更新Drawer组件的destroyOnClose为destroyOnHidden
- 添加前端.gitignore文件
- 更新分页组件size配置为default
- 修复会议详情页面总结更新参数传递问题
- 移除实时识别创建页面路由,整合到会议列表
- 添加生产环境配置文件到target目录
- 更新字体文件到资源目录
更新首页样式
parent
135203b9f6
commit
84a21f4960
|
|
@ -0,0 +1,27 @@
|
|||
server:
|
||||
port: ${SERVER_PORT:8081}
|
||||
|
||||
spring:
|
||||
datasource:
|
||||
url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://127.0.0.1:5432/imeeting_db}
|
||||
username: ${SPRING_DATASOURCE_USERNAME:postgres}
|
||||
password: ${SPRING_DATASOURCE_PASSWORD:postgres}
|
||||
data:
|
||||
redis:
|
||||
host: ${SPRING_DATA_REDIS_HOST:127.0.0.1}
|
||||
port: ${SPRING_DATA_REDIS_PORT:6379}
|
||||
password: ${SPRING_DATA_REDIS_PASSWORD:}
|
||||
database: ${SPRING_DATA_REDIS_DATABASE:15}
|
||||
|
||||
mybatis-plus:
|
||||
configuration:
|
||||
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
|
||||
|
||||
unisbase:
|
||||
security:
|
||||
jwt-secret: ${SECURITY_JWT_SECRET:change-me-dev-jwt-secret-32bytes}
|
||||
internal-auth:
|
||||
secret: ${INTERNAL_AUTH_SECRET:change-me-dev-internal-secret}
|
||||
app:
|
||||
server-base-url: ${APP_SERVER_BASE_URL:http://127.0.0.1:${server.port}}
|
||||
upload-path: ${APP_UPLOAD_PATH:D:/data/imeeting/uploads/}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
server:
|
||||
port: ${SERVER_PORT:8080}
|
||||
|
||||
spring:
|
||||
datasource:
|
||||
url: ${SPRING_DATASOURCE_URL}
|
||||
username: ${SPRING_DATASOURCE_USERNAME}
|
||||
password: ${SPRING_DATASOURCE_PASSWORD}
|
||||
data:
|
||||
redis:
|
||||
host: ${SPRING_DATA_REDIS_HOST}
|
||||
port: ${SPRING_DATA_REDIS_PORT:6379}
|
||||
password: ${SPRING_DATA_REDIS_PASSWORD:}
|
||||
database: ${SPRING_DATA_REDIS_DATABASE:15}
|
||||
|
||||
unisbase:
|
||||
security:
|
||||
jwt-secret: ${SECURITY_JWT_SECRET}
|
||||
internal-auth:
|
||||
secret: ${INTERNAL_AUTH_SECRET}
|
||||
app:
|
||||
server-base-url: ${APP_SERVER_BASE_URL}
|
||||
upload-path: ${APP_UPLOAD_PATH}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
server:
|
||||
port: ${SERVER_PORT:8082}
|
||||
|
||||
spring:
|
||||
datasource:
|
||||
url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://127.0.0.1:5432/imeeting_test}
|
||||
username: ${SPRING_DATASOURCE_USERNAME:postgres}
|
||||
password: ${SPRING_DATASOURCE_PASSWORD:postgres}
|
||||
data:
|
||||
redis:
|
||||
host: ${SPRING_DATA_REDIS_HOST:127.0.0.1}
|
||||
port: ${SPRING_DATA_REDIS_PORT:6379}
|
||||
password: ${SPRING_DATA_REDIS_PASSWORD:}
|
||||
database: ${SPRING_DATA_REDIS_DATABASE:16}
|
||||
|
||||
mybatis-plus:
|
||||
configuration:
|
||||
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
|
||||
|
||||
unisbase:
|
||||
security:
|
||||
jwt-secret: ${SECURITY_JWT_SECRET:change-me-test-jwt-secret-32bytes}
|
||||
internal-auth:
|
||||
secret: ${INTERNAL_AUTH_SECRET:change-me-test-internal-secret}
|
||||
app:
|
||||
server-base-url: ${APP_SERVER_BASE_URL:http://127.0.0.1:${server.port}}
|
||||
upload-path: ${APP_UPLOAD_PATH:D:/data/imeeting-test/uploads/}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
server:
|
||||
port: ${SERVER_PORT:8080}
|
||||
|
||||
spring:
|
||||
profiles:
|
||||
active: ${SPRING_PROFILES_ACTIVE:dev}
|
||||
cache:
|
||||
type: redis
|
||||
servlet:
|
||||
multipart:
|
||||
max-file-size: 2048MB
|
||||
max-request-size: 2048MB
|
||||
jackson:
|
||||
date-format: yyyy-MM-dd HH:mm:ss
|
||||
serialization:
|
||||
write-dates-as-timestamps: false
|
||||
time-zone: GMT+8
|
||||
|
||||
mybatis-plus:
|
||||
configuration:
|
||||
map-underscore-to-camel-case: true
|
||||
global-config:
|
||||
db-config:
|
||||
logic-delete-field: isDeleted
|
||||
logic-delete-value: 1
|
||||
logic-not-delete-value: 0
|
||||
|
||||
unisbase:
|
||||
web:
|
||||
auth-endpoints-enabled: true
|
||||
management-endpoints-enabled: true
|
||||
tenant:
|
||||
ignoreTables:
|
||||
- biz_ai_tasks
|
||||
- biz_meeting_transcripts
|
||||
- biz_speakers
|
||||
security:
|
||||
enabled: true
|
||||
mode: embedded
|
||||
auth-header: Authorization
|
||||
token-prefix: "Bearer "
|
||||
permit-all-urls:
|
||||
- /actuator/health
|
||||
- /api/static/**
|
||||
- /ws/**
|
||||
internal-auth:
|
||||
enabled: true
|
||||
header-name: X-Internal-Secret
|
||||
app:
|
||||
resource-prefix: /api/static/
|
||||
captcha:
|
||||
ttl-seconds: 120
|
||||
max-attempts: 5
|
||||
token:
|
||||
access-default-minutes: 30
|
||||
refresh-default-days: 7
|
||||
|
||||
imeeting:
|
||||
realtime:
|
||||
resume-window-minutes: 30
|
||||
empty-session-retention-minutes: 720
|
||||
redis-expire-listener-enabled: true
|
||||
grpc:
|
||||
enabled: true
|
||||
port: 19090
|
||||
max-inbound-message-size: 4194304
|
||||
reflection-enabled: true
|
||||
gateway:
|
||||
heartbeat-interval-seconds: 15
|
||||
heartbeat-timeout-seconds: 45
|
||||
realtime:
|
||||
session-ttl-seconds: 600
|
||||
sample-rate: 16000
|
||||
channels: 1
|
||||
encoding: PCM16LE
|
||||
connection-ttl-seconds: 1800
|
||||
auth:
|
||||
enabled: false
|
||||
allow-anonymous: true
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,24 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { ConfigProvider, theme } from "antd";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ConfigProvider, theme, App as AntdApp } from "antd";
|
||||
import AppRoutes from "./routes";
|
||||
import { getOpenPlatformConfig } from "./api";
|
||||
import {useThemeStore} from "./store/themeStore";
|
||||
|
|
@ -51,7 +51,9 @@ export default function App() {
|
|||
}
|
||||
}}
|
||||
>
|
||||
<AntdApp>
|
||||
<AppRoutes />
|
||||
</AntdApp>
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,55 +53,55 @@ export const getAiModelPage = (params: {
|
|||
name?: string;
|
||||
type?: string;
|
||||
}) => {
|
||||
return http.get<any, { code: string; data: { records: AiModelVO[]; total: number }; msg: string }>(
|
||||
return http.get<{ code: string; data: { records: AiModelVO[]; total: number }; msg: string }>(
|
||||
"/api/biz/aimodel/page",
|
||||
{ params }
|
||||
);
|
||||
};
|
||||
|
||||
export const saveAiModel = (data: AiModelDTO) => {
|
||||
return http.post<any, { code: string; data: AiModelVO; msg: string }>(
|
||||
return http.post<{ code: string; data: AiModelVO; msg: string }>(
|
||||
"/api/biz/aimodel",
|
||||
data
|
||||
);
|
||||
};
|
||||
|
||||
export const updateAiModel = (data: AiModelDTO) => {
|
||||
return http.put<any, { code: string; data: AiModelVO; msg: string }>(
|
||||
return http.put<{ code: string; data: AiModelVO; msg: string }>(
|
||||
"/api/biz/aimodel",
|
||||
data
|
||||
);
|
||||
};
|
||||
|
||||
export const deleteAiModel = (id: number) => {
|
||||
return http.delete<any, { code: string; data: boolean; msg: string }>(
|
||||
return http.delete<{ code: string; data: boolean; msg: string }>(
|
||||
`/api/biz/aimodel/${id}`
|
||||
);
|
||||
};
|
||||
|
||||
export const deleteAiModelByType = (id: number, type: 'ASR' | 'LLM') => {
|
||||
return http.delete<any, { code: string; data: boolean; msg: string }>(
|
||||
return http.delete<{ code: string; data: boolean; msg: string }>(
|
||||
`/api/biz/aimodel/${id}`,
|
||||
{ params: { type } }
|
||||
);
|
||||
};
|
||||
|
||||
export const getRemoteModelList = (params: { provider: string; baseUrl: string; apiKey?: string }) => {
|
||||
return http.get<any, { code: string; data: string[]; msg: string }>(
|
||||
return http.get<{ code: string; data: string[]; msg: string }>(
|
||||
"/api/biz/aimodel/remote-list",
|
||||
{ params }
|
||||
);
|
||||
};
|
||||
|
||||
export const testLocalModelConnectivity = (data: { baseUrl: string; apiKey: string }) => {
|
||||
return http.post<any, { code: string; data: AiLocalProfileVO; msg: string }>(
|
||||
return http.post<{ code: string; data: AiLocalProfileVO; msg: string }>(
|
||||
"/api/biz/aimodel/local-connectivity-test",
|
||||
data
|
||||
);
|
||||
};
|
||||
|
||||
export const getAiModelDefault = (type: 'ASR' | 'LLM') => {
|
||||
return http.get<any, { code: string; data: AiModelVO; msg: string }>(
|
||||
return http.get<{ code: string; data: AiModelVO; msg: string }>(
|
||||
"/api/biz/aimodel/default",
|
||||
{ params: { type } }
|
||||
);
|
||||
|
|
|
|||
|
|
@ -9,13 +9,13 @@ export interface DashboardStats {
|
|||
}
|
||||
|
||||
export const getDashboardStats = () => {
|
||||
return http.get<any, { code: string; data: DashboardStats; msg: string }>(
|
||||
return http.get<{ code: string; data: DashboardStats; msg: string }>(
|
||||
"/api/biz/dashboard/stats"
|
||||
);
|
||||
};
|
||||
|
||||
export const getRecentTasks = () => {
|
||||
return http.get<any, { code: string; data: MeetingVO[]; msg: string }>(
|
||||
return http.get<{ code: string; data: MeetingVO[]; msg: string }>(
|
||||
"/api/biz/dashboard/recent"
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -35,40 +35,40 @@ export const getHotWordPage = (params: {
|
|||
category?: string;
|
||||
isPublic?: number;
|
||||
}) => {
|
||||
return http.get<any, { code: string; data: { records: HotWordVO[]; total: number }; msg: string }>(
|
||||
return http.get<{ code: string; data: { records: HotWordVO[]; total: number }; msg: string }>(
|
||||
"/api/biz/hotword/page",
|
||||
{ params }
|
||||
);
|
||||
};
|
||||
|
||||
export const syncHotWord = (id: number) => {
|
||||
return http.post<any, { code: string; data: boolean; msg: string }>(
|
||||
return http.post<{ code: string; data: boolean; msg: string }>(
|
||||
`/api/biz/hotword/${id}/sync`
|
||||
);
|
||||
};
|
||||
|
||||
export const saveHotWord = (data: HotWordDTO) => {
|
||||
return http.post<any, { code: string; data: HotWordVO; msg: string }>(
|
||||
return http.post<{ code: string; data: HotWordVO; msg: string }>(
|
||||
"/api/biz/hotword",
|
||||
data
|
||||
);
|
||||
};
|
||||
|
||||
export const updateHotWord = (data: HotWordDTO) => {
|
||||
return http.put<any, { code: string; data: HotWordVO; msg: string }>(
|
||||
return http.put<{ code: string; data: HotWordVO; msg: string }>(
|
||||
"/api/biz/hotword",
|
||||
data
|
||||
);
|
||||
};
|
||||
|
||||
export const deleteHotWord = (id: number) => {
|
||||
return http.delete<any, { code: string; data: boolean; msg: string }>(
|
||||
return http.delete<{ code: string; data: boolean; msg: string }>(
|
||||
`/api/biz/hotword/${id}`
|
||||
);
|
||||
};
|
||||
|
||||
export const getPinyinSuggestion = (word: string) => {
|
||||
return http.get<any, { code: string; data: string[]; msg: string }>(
|
||||
return http.get<{ code: string; data: string[]; msg: string }>(
|
||||
"/api/biz/hotword/pinyin",
|
||||
{ params: { word } }
|
||||
);
|
||||
|
|
|
|||
|
|
@ -92,14 +92,14 @@ export const getMeetingPage = (params: {
|
|||
title?: string;
|
||||
viewType?: "all" | "created" | "involved";
|
||||
}) => {
|
||||
return http.get<any, { code: string; data: { records: MeetingVO[]; total: number }; msg: string }>(
|
||||
return http.get<{ code: string; data: { records: MeetingVO[]; total: number }; msg: string }>(
|
||||
"/api/biz/meeting/page",
|
||||
{ params }
|
||||
);
|
||||
};
|
||||
|
||||
export const createMeeting = (data: CreateMeetingCommand) => {
|
||||
return http.post<any, { code: string; data: MeetingVO; msg: string }>(
|
||||
return http.post<{ code: string; data: MeetingVO; msg: string }>(
|
||||
"/api/biz/meeting",
|
||||
data
|
||||
);
|
||||
|
|
@ -144,34 +144,34 @@ export interface RealtimeMeetingSessionStatus {
|
|||
}
|
||||
|
||||
export const createRealtimeMeeting = (data: CreateRealtimeMeetingCommand) => {
|
||||
return http.post<any, { code: string; data: MeetingVO; msg: string }>(
|
||||
return http.post<{ code: string; data: MeetingVO; msg: string }>(
|
||||
"/api/biz/meeting/realtime/start",
|
||||
data
|
||||
);
|
||||
};
|
||||
|
||||
export const appendRealtimeTranscripts = (meetingId: number, data: RealtimeTranscriptItemDTO[]) => {
|
||||
return http.post<any, { code: string; data: boolean; msg: string }>(
|
||||
return http.post<{ code: string; data: boolean; msg: string }>(
|
||||
`/api/biz/meeting/${meetingId}/realtime/transcripts`,
|
||||
data
|
||||
);
|
||||
};
|
||||
|
||||
export const getRealtimeMeetingSessionStatus = (meetingId: number) => {
|
||||
return http.get<any, { code: string; data: RealtimeMeetingSessionStatus; msg: string }>(
|
||||
return http.get<{ code: string; data: RealtimeMeetingSessionStatus; msg: string }>(
|
||||
`/api/biz/meeting/${meetingId}/realtime/session-status`
|
||||
);
|
||||
};
|
||||
|
||||
export const getRealtimeMeetingSessionStatuses = (meetingIds: number[]) => {
|
||||
return http.post<any, { code: string; data: Record<number, RealtimeMeetingSessionStatus>; msg: string }>(
|
||||
return http.post<{ code: string; data: Record<number, RealtimeMeetingSessionStatus>; msg: string }>(
|
||||
"/api/biz/meeting/realtime/session-status/batch",
|
||||
meetingIds
|
||||
);
|
||||
};
|
||||
|
||||
export const pauseRealtimeMeeting = (meetingId: number) => {
|
||||
return http.post<any, { code: string; data: RealtimeMeetingSessionStatus; msg: string }>(
|
||||
return http.post<{ code: string; data: RealtimeMeetingSessionStatus; msg: string }>(
|
||||
`/api/biz/meeting/${meetingId}/realtime/pause`,
|
||||
{}
|
||||
);
|
||||
|
|
@ -181,21 +181,21 @@ export const openRealtimeMeetingSocketSession = (
|
|||
meetingId: number,
|
||||
data: RealtimeSocketSessionRequest,
|
||||
) => {
|
||||
return http.post<any, { code: string; data: RealtimeSocketSessionVO; msg: string }>(
|
||||
return http.post<{ code: string; data: RealtimeSocketSessionVO; msg: string }>(
|
||||
`/api/biz/meeting/${meetingId}/realtime/socket-session`,
|
||||
data
|
||||
);
|
||||
};
|
||||
|
||||
export const completeRealtimeMeeting = (meetingId: number, data?: { audioUrl?: string; overwriteAudio?: boolean }) => {
|
||||
return http.post<any, { code: string; data: boolean; msg: string }>(
|
||||
return http.post<{ code: string; data: boolean; msg: string }>(
|
||||
`/api/biz/meeting/${meetingId}/realtime/complete`,
|
||||
data || {}
|
||||
);
|
||||
};
|
||||
|
||||
export const deleteMeeting = (id: number) => {
|
||||
return http.delete<any, { code: string; data: boolean; msg: string }>(
|
||||
return http.delete<{ code: string; data: boolean; msg: string }>(
|
||||
`/api/biz/meeting/${id}`
|
||||
);
|
||||
};
|
||||
|
|
@ -211,13 +211,13 @@ export interface MeetingTranscriptVO {
|
|||
}
|
||||
|
||||
export const getMeetingDetail = (id: number) => {
|
||||
return http.get<any, { code: string; data: MeetingVO; msg: string }>(
|
||||
return http.get<{ code: string; data: MeetingVO; msg: string }>(
|
||||
`/api/biz/meeting/${id}`
|
||||
);
|
||||
};
|
||||
|
||||
export const getTranscripts = (id: number) => {
|
||||
return http.get<any, { code: string; data: MeetingTranscriptVO[]; msg: string }>(
|
||||
return http.get<{ code: string; data: MeetingTranscriptVO[]; msg: string }>(
|
||||
`/api/biz/meeting/${id}/transcripts`
|
||||
);
|
||||
};
|
||||
|
|
@ -236,14 +236,14 @@ export interface MeetingTranscriptUpdateDTO {
|
|||
}
|
||||
|
||||
export const updateSpeakerInfo = (params: MeetingSpeakerUpdateDTO) => {
|
||||
return http.put<any, { code: string; data: boolean; msg: string }>(
|
||||
return http.put<{ code: string; data: boolean; msg: string }>(
|
||||
"/api/biz/meeting/speaker",
|
||||
params
|
||||
);
|
||||
};
|
||||
|
||||
export const updateMeetingTranscript = (params: MeetingTranscriptUpdateDTO) => {
|
||||
return http.put<any, { code: string; data: boolean; msg: string }>(
|
||||
return http.put<{ code: string; data: boolean; msg: string }>(
|
||||
`/api/biz/meeting/${params.meetingId}/transcripts/${params.transcriptId}`,
|
||||
params
|
||||
);
|
||||
|
|
@ -256,21 +256,21 @@ export interface MeetingResummaryDTO {
|
|||
}
|
||||
|
||||
export const reSummary = (params: MeetingResummaryDTO) => {
|
||||
return http.post<any, { code: string; data: boolean; msg: string }>(
|
||||
return http.post<{ code: string; data: boolean; msg: string }>(
|
||||
`/api/biz/meeting/${params.meetingId}/summary/regenerate`,
|
||||
params
|
||||
);
|
||||
};
|
||||
|
||||
export const updateMeetingBasic = (data: UpdateMeetingBasicCommand) => {
|
||||
return http.put<any, { code: string; data: boolean; msg: string }>(
|
||||
return http.put<{ code: string; data: boolean; msg: string }>(
|
||||
`/api/biz/meeting/${data.meetingId}/basic`,
|
||||
data
|
||||
);
|
||||
};
|
||||
|
||||
export const updateMeetingSummary = (data: UpdateMeetingSummaryCommand) => {
|
||||
return http.put<any, { code: string; data: boolean; msg: string }>(
|
||||
return http.put<{ code: string; data: boolean; msg: string }>(
|
||||
`/api/biz/meeting/${data.meetingId}/summary`,
|
||||
data
|
||||
);
|
||||
|
|
@ -284,7 +284,7 @@ export interface UpdateMeetingParticipantsCommand {
|
|||
export type MeetingParticipantsUpdateDTO = UpdateMeetingParticipantsCommand;
|
||||
|
||||
export const updateMeetingParticipants = (params: UpdateMeetingParticipantsCommand) => {
|
||||
return http.put<any, { code: string; data: boolean; msg: string }>(
|
||||
return http.put<{ code: string; data: boolean; msg: string }>(
|
||||
`/api/biz/meeting/${params.meetingId}/participants`,
|
||||
params
|
||||
);
|
||||
|
|
@ -293,7 +293,7 @@ export const updateMeetingParticipants = (params: UpdateMeetingParticipantsComma
|
|||
export const uploadAudio = (file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
return http.post<any, { code: string; data: string; msg: string }>(
|
||||
return http.post<{ code: string; data: string; msg: string }>(
|
||||
"/api/biz/meeting/upload",
|
||||
formData,
|
||||
{ headers: { "Content-Type": "multipart/form-data" } }
|
||||
|
|
@ -304,10 +304,11 @@ export interface MeetingProgress {
|
|||
percent: number;
|
||||
message: string;
|
||||
updateAt: number;
|
||||
eta?: number;
|
||||
}
|
||||
|
||||
export const getMeetingProgress = (id: number) => {
|
||||
return http.get<any, { code: string; data: MeetingProgress; msg: string }>(
|
||||
return http.get<{ code: string; data: MeetingProgress; msg: string }>(
|
||||
`/api/biz/meeting/${id}/progress`
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -33,34 +33,34 @@ export const getPromptPage = (params: {
|
|||
name?: string;
|
||||
category?: string;
|
||||
}) => {
|
||||
return http.get<any, { code: string; data: { records: PromptTemplateVO[]; total: number }; msg: string }>(
|
||||
return http.get<{ code: string; data: { records: PromptTemplateVO[]; total: number }; msg: string }>(
|
||||
"/api/biz/prompt/page",
|
||||
{ params }
|
||||
);
|
||||
};
|
||||
|
||||
export const savePromptTemplate = (data: PromptTemplateDTO) => {
|
||||
return http.post<any, { code: string; data: PromptTemplateVO; msg: string }>(
|
||||
return http.post<{ code: string; data: PromptTemplateVO; msg: string }>(
|
||||
"/api/biz/prompt",
|
||||
data
|
||||
);
|
||||
};
|
||||
|
||||
export const updatePromptTemplate = (data: PromptTemplateDTO) => {
|
||||
return http.put<any, { code: string; data: PromptTemplateVO; msg: string }>(
|
||||
return http.put<{ code: string; data: PromptTemplateVO; msg: string }>(
|
||||
"/api/biz/prompt",
|
||||
data
|
||||
);
|
||||
};
|
||||
|
||||
export const deletePromptTemplate = (id: number) => {
|
||||
return http.delete<any, { code: string; data: boolean; msg: string }>(
|
||||
return http.delete<{ code: string; data: boolean; msg: string }>(
|
||||
`/api/biz/prompt/${id}`
|
||||
);
|
||||
};
|
||||
|
||||
export const updatePromptStatus = (id: number, status: number) => {
|
||||
return http.put<any, { code: string; data: boolean; msg: string }>(
|
||||
return http.put<{ code: string; data: boolean; msg: string }>(
|
||||
`/api/biz/prompt/${id}/status`,
|
||||
null,
|
||||
{ params: { status } }
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ export const registerSpeaker = (params: SpeakerRegisterParams) => {
|
|||
formData.append("file", params.file, "voice.wav");
|
||||
}
|
||||
|
||||
return http.post<any, { code: string; data: SpeakerVO; msg: string }>(
|
||||
return http.post<{ code: string; data: SpeakerVO; msg: string }>(
|
||||
"/api/biz/speaker/register",
|
||||
formData,
|
||||
{
|
||||
|
|
@ -51,20 +51,20 @@ export const registerSpeaker = (params: SpeakerRegisterParams) => {
|
|||
};
|
||||
|
||||
export const getSpeakerList = () => {
|
||||
return http.get<any, { code: string; data: SpeakerVO[]; msg: string }>(
|
||||
return http.get<{ code: string; data: SpeakerVO[]; msg: string }>(
|
||||
"/api/biz/speaker/list"
|
||||
);
|
||||
};
|
||||
|
||||
export const getSpeakerPage = (params: SpeakerPageParams) => {
|
||||
return http.get<any, { code: string; data: { records: SpeakerVO[]; total: number }; msg: string }>(
|
||||
return http.get<{ code: string; data: { records: SpeakerVO[]; total: number }; msg: string }>(
|
||||
"/api/biz/speaker/page",
|
||||
{ params }
|
||||
);
|
||||
};
|
||||
|
||||
export const deleteSpeaker = (id: number) => {
|
||||
return http.delete<any, { code: string; data: boolean; msg: string }>(
|
||||
return http.delete<{ code: string; data: boolean; msg: string }>(
|
||||
`/api/biz/speaker/${id}`
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -92,7 +92,6 @@ export default function AppLayout() {
|
|||
mode="inline"
|
||||
selectedKeys={[location.pathname]}
|
||||
items={menuItems}
|
||||
loading={loading}
|
||||
style={{ height: 'calc(100% - 64px)', display: 'flex', flexDirection: 'column' }}
|
||||
/>
|
||||
</Layout.Sider>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,497 @@
|
|||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Drawer, Form, Input, Select, DatePicker, Switch, Upload, Progress, Space, Avatar, Row, Col, Radio, Typography, Tooltip, App, Tag, Button, Collapse } from 'antd';
|
||||
import { UserOutlined, CloudUploadOutlined, AudioOutlined, QuestionCircleOutlined, CheckOutlined, LinkOutlined, SettingOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { getAiModelPage, getAiModelDefault, AiModelVO } from '../../api/business/aimodel';
|
||||
import { getPromptPage, PromptTemplateVO } from '../../api/business/prompt';
|
||||
import { getHotWordPage, HotWordVO } from '../../api/business/hotword';
|
||||
import { listUsers } from '../../api';
|
||||
import { createMeeting, createRealtimeMeeting, uploadAudio, CreateRealtimeMeetingCommand } from '../../api/business/meeting';
|
||||
import { SysUser } from '../../types';
|
||||
|
||||
const { Option } = Select;
|
||||
const { Dragger } = Upload;
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
export type MeetingCreateType = 'upload' | 'realtime';
|
||||
|
||||
interface MeetingCreateDrawerProps {
|
||||
open: boolean;
|
||||
initialType?: MeetingCreateType;
|
||||
onCancel: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
type RealtimeMeetingSessionDraft = {
|
||||
meetingId: number;
|
||||
meetingTitle: string;
|
||||
asrModelName: string;
|
||||
summaryModelName: string;
|
||||
asrModelId: number;
|
||||
mode: string;
|
||||
language: string;
|
||||
useSpkId: number;
|
||||
enablePunctuation: boolean;
|
||||
enableItn: boolean;
|
||||
enableTextRefine: boolean;
|
||||
saveAudio: boolean;
|
||||
hotwords: Array<{ hotword: string; weight: number }>;
|
||||
};
|
||||
|
||||
function resolveWsUrl(model?: AiModelVO | null) {
|
||||
if (model?.wsUrl) return model.wsUrl;
|
||||
if (model?.baseUrl) return model.baseUrl.replace(/^http:\/\//, "ws://").replace(/^https:\/\//, "wss://");
|
||||
return "";
|
||||
}
|
||||
|
||||
function buildRealtimeProxyPreviewUrl() {
|
||||
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
|
||||
return `${protocol}://${window.location.host}/ws/meeting/realtime`;
|
||||
}
|
||||
|
||||
function getSessionKey(meetingId: number) {
|
||||
return `realtimeMeetingSession:${meetingId}`;
|
||||
}
|
||||
|
||||
export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({ open, initialType = 'upload', onCancel, onSuccess }) => {
|
||||
const { message } = App.useApp();
|
||||
const navigate = useNavigate();
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const [type, setType] = useState<MeetingCreateType>(initialType);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const [asrModels, setAsrModels] = useState<AiModelVO[]>([]);
|
||||
const [llmModels, setLlmModels] = useState<AiModelVO[]>([]);
|
||||
const [prompts, setPrompts] = useState<PromptTemplateVO[]>([]);
|
||||
const [hotwordList, setHotwordList] = useState<HotWordVO[]>([]);
|
||||
const [userList, setUserList] = useState<SysUser[]>([]);
|
||||
|
||||
const [audioUrl, setAudioUrl] = useState('');
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [fileList, setFileList] = useState<any[]>([]);
|
||||
|
||||
const watchedAsrModelId = Form.useWatch("asrModelId", form);
|
||||
const watchedPromptId = Form.useWatch("promptId", form);
|
||||
const watchedSummaryModelId = Form.useWatch("summaryModelId", form);
|
||||
|
||||
const selectedAsrModel = useMemo(() => asrModels.find((item) => item.id === watchedAsrModelId) || null, [asrModels, watchedAsrModelId]);
|
||||
const selectedSummaryModel = useMemo(() => llmModels.find((item) => item.id === watchedSummaryModelId) || null, [llmModels, watchedSummaryModelId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setType(initialType);
|
||||
loadInitialData();
|
||||
setAudioUrl('');
|
||||
setUploadProgress(0);
|
||||
setFileList([]);
|
||||
}
|
||||
}, [open, initialType]);
|
||||
|
||||
const loadInitialData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [asrRes, llmRes, promptRes, hotwordRes, users, defaultAsr, defaultLlm] = await Promise.all([
|
||||
getAiModelPage({ current: 1, size: 100, type: 'ASR' }),
|
||||
getAiModelPage({ current: 1, size: 100, type: 'LLM' }),
|
||||
getPromptPage({ current: 1, size: 100 }),
|
||||
getHotWordPage({ current: 1, size: 1000 }),
|
||||
listUsers(),
|
||||
getAiModelDefault("ASR"),
|
||||
getAiModelDefault("LLM")
|
||||
]);
|
||||
|
||||
const activeAsrModels = asrRes.data.data.records.filter((m: AiModelVO) => m.status === 1);
|
||||
const activeLlmModels = llmRes.data.data.records.filter((m: AiModelVO) => m.status === 1);
|
||||
const activePrompts = promptRes.data.data.records.filter((p: PromptTemplateVO) => p.status === 1);
|
||||
const activeHotwords = hotwordRes.data.data.records.filter((h: HotWordVO) => h.status === 1);
|
||||
|
||||
setAsrModels(activeAsrModels);
|
||||
setLlmModels(activeLlmModels);
|
||||
setPrompts(activePrompts);
|
||||
setHotwordList(activeHotwords);
|
||||
setUserList(users || []);
|
||||
|
||||
form.setFieldsValue({
|
||||
title: type === 'upload' ? `文件会议 ${dayjs().format("MM-DD HH:mm")}` : `实时会议 ${dayjs().format("MM-DD HH:mm")}`,
|
||||
meetingTime: dayjs(),
|
||||
asrModelId: defaultAsr.data.data?.id,
|
||||
summaryModelId: defaultLlm.data.data?.id,
|
||||
promptId: activePrompts.length > 0 ? activePrompts[0].id : undefined,
|
||||
useSpkId: 1,
|
||||
enableTextRefine: false,
|
||||
mode: "2pass",
|
||||
language: "auto",
|
||||
enablePunctuation: true,
|
||||
enableItn: true,
|
||||
saveAudio: false,
|
||||
});
|
||||
} catch (err) {
|
||||
message.error("加载配置失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Sync title when type changes
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const currentTitle = form.getFieldValue('title');
|
||||
if (currentTitle && (currentTitle.startsWith('文件会议') || currentTitle.startsWith('实时会议'))) {
|
||||
form.setFieldsValue({
|
||||
title: type === 'upload' ? `文件会议 ${dayjs().format("MM-DD HH:mm")}` : `实时会议 ${dayjs().format("MM-DD HH:mm")}`
|
||||
});
|
||||
}
|
||||
}, [type, form, open]);
|
||||
|
||||
const customUpload = async (options: any) => {
|
||||
const { file, onSuccess: uploadSuccess, onError } = options;
|
||||
setUploadProgress(0);
|
||||
try {
|
||||
const interval = setInterval(() => setUploadProgress(prev => (prev < 95 ? prev + 5 : prev)), 300);
|
||||
const res = await uploadAudio(file);
|
||||
clearInterval(interval);
|
||||
setUploadProgress(100);
|
||||
setAudioUrl(res.data.data);
|
||||
uploadSuccess(res.data.data);
|
||||
message.success('录音上传成功');
|
||||
} catch (err) {
|
||||
onError(err);
|
||||
message.error('文件上传失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleOk = async () => {
|
||||
if (type === 'upload' && !audioUrl) {
|
||||
message.error('请先上传录音文件');
|
||||
return;
|
||||
}
|
||||
|
||||
const values = await form.validateFields();
|
||||
|
||||
if (type === 'realtime') {
|
||||
const wsUrl = resolveWsUrl(selectedAsrModel);
|
||||
if (!wsUrl) {
|
||||
message.error("当前 ASR 模型没有配置 WebSocket 地址");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const { hostUserId, ...meetingValues } = values;
|
||||
|
||||
if (type === 'upload') {
|
||||
await createMeeting({
|
||||
...meetingValues,
|
||||
...(hostUserId != null ? { hostUserId } : {}),
|
||||
meetingTime: meetingValues.meetingTime.format('YYYY-MM-DD HH:mm:ss'),
|
||||
audioUrl,
|
||||
participants: meetingValues.participants?.join(','),
|
||||
tags: meetingValues.tags?.join(',')
|
||||
});
|
||||
message.success('会议发起成功');
|
||||
onSuccess();
|
||||
onCancel();
|
||||
} else {
|
||||
const selectedHotwords = (values.hotWords?.length
|
||||
? hotwordList.filter((item) => values.hotWords.includes(item.word))
|
||||
: hotwordList
|
||||
).map((item) => ({
|
||||
hotword: item.word,
|
||||
weight: Number(item.weight || 2) / 10,
|
||||
}));
|
||||
|
||||
const payload: CreateRealtimeMeetingCommand = {
|
||||
...meetingValues,
|
||||
...(hostUserId != null ? { hostUserId } : {}),
|
||||
meetingTime: meetingValues.meetingTime.format("YYYY-MM-DD HH:mm:ss"),
|
||||
participants: meetingValues.participants?.join(",") || "",
|
||||
tags: meetingValues.tags?.join(",") || "",
|
||||
mode: meetingValues.mode || "2pass",
|
||||
language: meetingValues.language || "auto",
|
||||
useSpkId: meetingValues.useSpkId ? 1 : 0,
|
||||
enablePunctuation: meetingValues.enablePunctuation !== false,
|
||||
enableItn: meetingValues.enableItn !== false,
|
||||
enableTextRefine: !!meetingValues.enableTextRefine,
|
||||
saveAudio: !!meetingValues.saveAudio,
|
||||
hotWords: meetingValues.hotWords,
|
||||
};
|
||||
|
||||
const res = await createRealtimeMeeting(payload);
|
||||
const createdMeeting = res.data.data;
|
||||
|
||||
const sessionDraft: RealtimeMeetingSessionDraft = {
|
||||
meetingId: createdMeeting.id,
|
||||
meetingTitle: createdMeeting.title,
|
||||
asrModelName: selectedAsrModel?.modelName || "ASR",
|
||||
summaryModelName: selectedSummaryModel?.modelName || "LLM",
|
||||
asrModelId: selectedAsrModel?.id || values.asrModelId,
|
||||
mode: values.mode || "2pass",
|
||||
language: values.language || "auto",
|
||||
useSpkId: values.useSpkId ? 1 : 0,
|
||||
enablePunctuation: values.enablePunctuation !== false,
|
||||
enableItn: values.enableItn !== false,
|
||||
enableTextRefine: !!values.enableTextRefine,
|
||||
saveAudio: !!values.saveAudio,
|
||||
hotwords: selectedHotwords,
|
||||
};
|
||||
|
||||
sessionStorage.setItem(getSessionKey(createdMeeting.id), JSON.stringify(sessionDraft));
|
||||
message.success("会议已创建,即将进入实时识别");
|
||||
onSuccess();
|
||||
onCancel();
|
||||
navigate(`/meeting-live-session/${createdMeeting.id}`);
|
||||
}
|
||||
} catch (err) {
|
||||
message.error(type === 'upload' ? '创建会议失败' : '创建实时会议失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title={null}
|
||||
open={open}
|
||||
onClose={onCancel}
|
||||
width={960}
|
||||
destroyOnClose
|
||||
placement="right"
|
||||
closable={false}
|
||||
footer={
|
||||
<div style={{ textAlign: 'right', padding: '16px 32px' }}>
|
||||
<Space size={16}>
|
||||
<Button onClick={onCancel} size="large" style={{ borderRadius: 8, minWidth: 120 }}>取消</Button>
|
||||
<Button type="primary" onClick={handleOk} loading={submitting} size="large" style={{ borderRadius: 8, minWidth: 140, fontWeight: 500 }}>
|
||||
{type === 'upload' ? (audioUrl ? '开始分析' : '创建并上传') : '创建并进入识别'}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
}
|
||||
styles={{
|
||||
header: { display: 'none' },
|
||||
body: { padding: 0, display: 'flex', flexDirection: 'column', background: 'var(--app-bg-layout)' },
|
||||
footer: { padding: 0, borderTop: '1px solid var(--app-border-color)', background: 'var(--app-bg-surface)' }
|
||||
}}
|
||||
>
|
||||
<div style={{ background: 'var(--app-bg-surface)', padding: '24px 32px', borderBottom: '1px solid var(--app-border-color)' }}>
|
||||
<Row justify="space-between" align="middle">
|
||||
<Col>
|
||||
<Space size={16}>
|
||||
<div style={{ width: 48, height: 48, borderRadius: 12, background: 'var(--app-bg-surface-strong)', color: 'var(--app-text-main)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 24, border: '1px solid var(--app-border-color)' }}>
|
||||
{type === 'upload' ? <CloudUploadOutlined /> : <AudioOutlined />}
|
||||
</div>
|
||||
<div>
|
||||
<Title level={4} style={{ margin: 0, fontWeight: 600 }}>{type === 'upload' ? '上传录音分析' : '创建实时会议'}</Title>
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>{type === 'upload' ? '上传已有录音文件进行转写和总结' : '创建会议实时进行语音转写和内容分析'}</Text>
|
||||
</div>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col>
|
||||
<Radio.Group value={type} onChange={e => setType(e.target.value)} optionType="button" buttonStyle="solid" size="large">
|
||||
<Radio.Button value="upload" style={{ padding: '0 24px' }}><CloudUploadOutlined style={{ marginRight: 6 }} /> 上传录音</Radio.Button>
|
||||
<Radio.Button value="realtime" style={{ padding: '0 24px' }}><AudioOutlined style={{ marginRight: 6 }} /> 实时识别</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '32px 40px', flex: 1, overflowY: 'auto', background: 'var(--app-bg-layout)' }}>
|
||||
<Form form={form} layout="vertical" disabled={loading}>
|
||||
<div style={{ marginBottom: 24, display: 'flex', alignItems: 'center' }}>
|
||||
<div style={{ width: 4, height: 16, background: '#1890ff', borderRadius: 2, marginRight: 8 }} />
|
||||
<Title level={5} style={{ margin: 0 }}>基础信息</Title>
|
||||
</div>
|
||||
|
||||
<Row gutter={32}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="title" label="会议标题" rules={[{ required: true }]}>
|
||||
<Input placeholder="输入会议标题" size="large" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="meetingTime" label="会议时间" rules={[{ required: true }]}>
|
||||
<DatePicker showTime style={{ width: '100%' }} size="large" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={32}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="participants" label="参会人员">
|
||||
<Select mode="multiple" placeholder="选择人员" showSearch optionFilterProp="children" size="large">
|
||||
{userList.map(u => (<Option key={u.userId} value={u.userId}><Space><Avatar size="small" icon={<UserOutlined />} />{u.displayName || u.username}</Space></Option>))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="hostUserId" label="会议主持人">
|
||||
<Select allowClear placeholder="不选择则默认为创建人" showSearch optionFilterProp="children" size="large">
|
||||
{userList.map(u => (<Option key={u.userId} value={u.userId}><Space><Avatar size="small" icon={<UserOutlined />} />{u.displayName || u.username}</Space></Option>))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={32}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="tags" label="会议标签">
|
||||
<Select mode="tags" placeholder="输入标签" size="large" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<div style={{ margin: '32px 0', borderTop: '1px solid var(--app-border-color)' }} />
|
||||
|
||||
<div style={{ marginBottom: 24, display: 'flex', alignItems: 'center' }}>
|
||||
<div style={{ width: 4, height: 16, background: '#1890ff', borderRadius: 2, marginRight: 8 }} />
|
||||
<Title level={5} style={{ margin: 0 }}>AI 模型与分析配置</Title>
|
||||
</div>
|
||||
|
||||
<Row gutter={32}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="asrModelId" label="语音识别 (ASR)" rules={[{ required: true }]}>
|
||||
<Select placeholder="选择 ASR 模型" size="large">{asrModels.map(m => (<Option key={m.id} value={m.id}>{m.modelName}</Option>))}</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="summaryModelId" label="内容总结 (LLM)" rules={[{ required: true }]}>
|
||||
<Select placeholder="选择总结模型" size="large">{llmModels.map(m => (<Option key={m.id} value={m.id}>{m.modelName}</Option>))}</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Form.Item name="promptId" label="总结模板" rules={[{ required: true }]}>
|
||||
{prompts.length > 15 ? (
|
||||
<Select placeholder="请选择模板" showSearch optionFilterProp="children" size="large">
|
||||
{prompts.map(p => <Option key={p.id} value={p.id}>{p.templateName}</Option>)}
|
||||
</Select>
|
||||
) : (
|
||||
<div style={{ padding: '2px' }}>
|
||||
<Row gutter={[12, 12]}>
|
||||
{prompts.map(p => {
|
||||
const isSelected = watchedPromptId === p.id;
|
||||
return (
|
||||
<Col span={8} key={p.id}>
|
||||
<div onClick={() => form.setFieldsValue({ promptId: p.id })} style={{ padding: '12px 16px', borderRadius: 8, border: `1px solid ${isSelected ? '#1890ff' : 'var(--app-border-color)'}`, background: isSelected ? '#e6f7ff' : 'var(--app-bg-surface)', cursor: 'pointer', position: 'relative', transition: 'all 0.2s', display: 'flex', alignItems: 'center', height: '100%' }}>
|
||||
<div style={{ fontSize: '14px', color: isSelected ? '#1890ff' : 'var(--app-text-main)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontWeight: isSelected ? 500 : 400 }}>{p.templateName}</div>
|
||||
{isSelected && <div style={{ position: 'absolute', top: -1, right: -1, width: 20, height: 20, background: '#1890ff', borderRadius: '0 8px 0 8px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}><CheckOutlined style={{ color: '#fff', fontSize: 12 }} /></div>}
|
||||
</div>
|
||||
</Col>
|
||||
);
|
||||
})}
|
||||
</Row>
|
||||
</div>
|
||||
)}
|
||||
</Form.Item>
|
||||
|
||||
<Row gutter={32}>
|
||||
<Col span={24}>
|
||||
<Form.Item name="hotWords" label={<span>热词增强 <Tooltip title="不选择时将带上系统当前启用的热词"><QuestionCircleOutlined /></Tooltip></span>}>
|
||||
<Select mode="multiple" allowClear placeholder="请选择需要增强识别的热词" size="large" style={{ width: '100%' }}>
|
||||
{hotwordList.map((item) => (
|
||||
<Option key={item.word} value={item.word}>{item.word}</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Collapse
|
||||
ghost
|
||||
expandIconPosition="end"
|
||||
style={{ marginBottom: 24, background: 'var(--app-bg-layout)', borderRadius: 8 }}
|
||||
items={[
|
||||
{
|
||||
key: 'advanced',
|
||||
label: (
|
||||
<span style={{ fontWeight: 500, color: 'var(--app-text-secondary)' }}>
|
||||
<SettingOutlined style={{ marginRight: 8 }} />
|
||||
高级设置
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
<div style={{ paddingTop: 8 }}>
|
||||
<Row gutter={32}>
|
||||
<Col span={8}>
|
||||
<Form.Item name="useSpkId" label={<span>声纹区分 <Tooltip title="开启后将尝试区分不同发言人"><QuestionCircleOutlined /></Tooltip></span>} valuePropName="checked" getValueProps={(v) => ({ checked: v === 1 || v === true })} normalize={(v) => (v ? 1 : 0)}>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item name="enableTextRefine" label={<span>文本修正 <Tooltip title="开启后将尝试对识别文本进行修正"><QuestionCircleOutlined /></Tooltip></span>} valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
{type === 'realtime' && (
|
||||
<Col span={8}>
|
||||
<Form.Item name="mode" label="识别模式">
|
||||
<Select size="large">
|
||||
<Option value="2pass">2pass (流式+离线修正)</Option>
|
||||
<Option value="online">online (纯流式)</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
]}
|
||||
/>
|
||||
|
||||
{type === 'realtime' && (
|
||||
<>
|
||||
<Form.Item name="language" hidden><Input /></Form.Item>
|
||||
<Form.Item name="enablePunctuation" hidden valuePropName="checked"><Switch /></Form.Item>
|
||||
<Form.Item name="enableItn" hidden valuePropName="checked"><Switch /></Form.Item>
|
||||
<Form.Item name="saveAudio" hidden valuePropName="checked"><Switch /></Form.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
{type === 'upload' && (
|
||||
<>
|
||||
<div style={{ margin: '32px 0', borderTop: '1px solid var(--app-border-color)' }} />
|
||||
<div style={{ marginBottom: 24, display: 'flex', alignItems: 'center' }}>
|
||||
<div style={{ width: 4, height: 16, background: '#1890ff', borderRadius: 2, marginRight: 8 }} />
|
||||
<Title level={5} style={{ margin: 0 }}>上传录音文件</Title>
|
||||
</div>
|
||||
<Dragger
|
||||
accept=".mp3,.wav,.m4a"
|
||||
fileList={fileList}
|
||||
customRequest={customUpload}
|
||||
onChange={info => setFileList(info.fileList.slice(-1))}
|
||||
maxCount={1}
|
||||
style={{ borderRadius: 12, padding: '32px 0', background: 'var(--app-bg-surface)', border: '1px dashed var(--app-border-color)' }}
|
||||
>
|
||||
<div>
|
||||
<p className="ant-upload-drag-icon" style={{ marginBottom: 16 }}><CloudUploadOutlined style={{ fontSize: 56, color: '#1890ff' }} /></p>
|
||||
<p className="ant-upload-text" style={{ fontSize: 18, fontWeight: 500, color: 'var(--app-text-main)' }}>点击或拖拽录音文件到此处</p>
|
||||
<p className="ant-upload-hint" style={{ fontSize: 14, marginTop: 12, color: 'var(--app-text-secondary)' }}>支持高质量 .mp3, .wav, .m4a 格式音频</p>
|
||||
{uploadProgress > 0 && uploadProgress < 100 && (
|
||||
<div style={{ width: '60%', margin: '32px auto 0' }}>
|
||||
<Progress percent={uploadProgress} size="small" />
|
||||
<div style={{ fontSize: 13, color: '#1890ff', marginTop: 8 }}>文件传输中,请稍候...</div>
|
||||
</div>
|
||||
)}
|
||||
{audioUrl && (
|
||||
<Tag color="processing" style={{ marginTop: 24, padding: '6px 16px', fontSize: 14, borderRadius: 6, maxWidth: '90%', display: 'inline-flex', alignItems: 'center' }}>
|
||||
<span style={{ flexShrink: 0 }}>就绪:</span>
|
||||
<span style={{ marginLeft: 4, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{audioUrl.split('/').pop()}
|
||||
</span>
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
</Dragger>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
|
@ -67,7 +67,7 @@ function ButtonWithHoverCard({
|
|||
>
|
||||
<Card
|
||||
size="small"
|
||||
bordered={false}
|
||||
variant="borderless"
|
||||
className="hover-info-card-content"
|
||||
>
|
||||
{/* 标题区 */}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import {
|
|||
ReadOutlined,
|
||||
BookOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { message } from 'antd'
|
||||
import { App } from 'antd'
|
||||
import { getUserMenus } from '@/api/menu'
|
||||
import useUserStore from '@/stores/userStore'
|
||||
import ModernSidebar from '../ModernSidebar/ModernSidebar'
|
||||
|
|
@ -43,6 +43,7 @@ const iconMap = {
|
|||
}
|
||||
|
||||
function AppSider({ collapsed, onToggle }) {
|
||||
const { message } = App.useApp();
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const { user, logout } = useUserStore()
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState, useMemo } from 'react'
|
||||
import { Document, Page, pdfjs } from 'react-pdf'
|
||||
import { Button, Space, InputNumber, message, Spin } from 'antd'
|
||||
import { Button, Space, InputNumber, Spin, App } from 'antd'
|
||||
import {
|
||||
LeftOutlined,
|
||||
RightOutlined,
|
||||
|
|
@ -15,6 +15,7 @@ import './PDFViewer.css'
|
|||
pdfjs.GlobalWorkerOptions.workerSrc = '/pdf-worker/pdf.worker.min.mjs'
|
||||
|
||||
function PDFViewer({ url, filename }) {
|
||||
const { message } = App.useApp();
|
||||
const [numPages, setNumPages] = useState(null)
|
||||
const [pageNumber, setPageNumber] = useState(1)
|
||||
const [scale, setScale] = useState(1.0)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState, useMemo, useRef, useEffect, useCallback } from 'react'
|
||||
import { Document, Page, pdfjs } from 'react-pdf'
|
||||
import { Button, Space, InputNumber, message, Spin } from 'antd'
|
||||
import { Button, Space, InputNumber, App, Spin } from 'antd'
|
||||
import {
|
||||
ZoomInOutlined,
|
||||
ZoomOutOutlined,
|
||||
|
|
@ -16,6 +16,7 @@ import './VirtualPDFViewer.css'
|
|||
pdfjs.GlobalWorkerOptions.workerSrc = '/pdf-worker/pdf.worker.min.mjs'
|
||||
|
||||
function VirtualPDFViewer({ url, filename }) {
|
||||
const { message } = App.useApp();
|
||||
const [numPages, setNumPages] = useState(null)
|
||||
const [scale, setScale] = useState(1.0)
|
||||
const [pdfOriginalSize, setPdfOriginalSize] = useState({ width: 595, height: 842 }) // 默认 A4
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { notification } from "antd";
|
||||
import { notification } from 'antd';
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
:root {
|
||||
:root {
|
||||
--app-primary-color: #1677ff;
|
||||
--app-primary-rgb: 22, 119, 255;
|
||||
--app-bg-main:
|
||||
|
|
@ -728,6 +728,51 @@ body::after {
|
|||
box-shadow: 0 0 0 6px rgba(var(--home-tech-rgb), 0.14) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="default"] .home-recent-card,
|
||||
:root[data-theme="tech"] .home-recent-card {
|
||||
border: none !important;
|
||||
background: linear-gradient(180deg, #f9f8fe 0%, #f3f2fa 100%) !important;
|
||||
box-shadow: 0 8px 28px rgba(113, 107, 151, 0.08) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="default"] .home-recent-card:hover,
|
||||
:root[data-theme="tech"] .home-recent-card:hover {
|
||||
border: none !important;
|
||||
background: linear-gradient(180deg, #f9f8fe 0%, #f3f2fa 100%) !important;
|
||||
box-shadow: 0 14px 34px rgba(113, 107, 151, 0.12) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="default"] .home-recent-card .home-recent-card-title,
|
||||
:root[data-theme="tech"] .home-recent-card .home-recent-card-title {
|
||||
color: #2d2c59 !important;
|
||||
}
|
||||
|
||||
:root[data-theme="default"] .home-recent-card .home-recent-card-icon,
|
||||
:root[data-theme="tech"] .home-recent-card .home-recent-card-icon {
|
||||
background: #efedf8 !important;
|
||||
color: #8a80ff !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
:root[data-theme="default"] .home-recent-card .home-recent-card-tag,
|
||||
:root[data-theme="tech"] .home-recent-card .home-recent-card-tag {
|
||||
background: #eceaf7 !important;
|
||||
color: #6f66f0 !important;
|
||||
}
|
||||
|
||||
:root[data-theme="default"] .home-recent-card .home-recent-card-duration,
|
||||
:root[data-theme="default"] .home-recent-card .home-recent-card-time,
|
||||
:root[data-theme="tech"] .home-recent-card .home-recent-card-duration,
|
||||
:root[data-theme="tech"] .home-recent-card .home-recent-card-time {
|
||||
color: #7d7d9e !important;
|
||||
}
|
||||
|
||||
:root[data-theme="default"] .home-recent-card .home-recent-card-dot,
|
||||
:root[data-theme="tech"] .home-recent-card .home-recent-card-dot {
|
||||
background: linear-gradient(180deg, #ff8f8f 0%, #f56f6f 100%) !important;
|
||||
box-shadow: 0 0 0 4px rgba(249, 248, 254, 0.96) !important;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
body::after {
|
||||
inset: 10px;
|
||||
|
|
@ -751,4 +796,3 @@ body::after {
|
|||
}
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import * as AntIcons from "@ant-design/icons";
|
||||
import * as AntIcons from "@ant-design/icons";
|
||||
import {
|
||||
BellOutlined,
|
||||
ApartmentOutlined,
|
||||
|
|
@ -17,7 +17,7 @@ import {
|
|||
UserOutlined,
|
||||
VideoCameraOutlined
|
||||
} from "@ant-design/icons";
|
||||
import { Avatar, Button, Dropdown, Layout, Menu, Space, message, type MenuProps } from "antd";
|
||||
import { Avatar, Button, Dropdown, Layout, Menu, Space, type MenuProps, App } from 'antd';
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { Link, Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
|
@ -57,6 +57,7 @@ type PermissionMenuNode = SysPermission & {
|
|||
};
|
||||
|
||||
export default function AppLayout() {
|
||||
const { message } = App.useApp();
|
||||
const { t, i18n } = useTranslation();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [menus, setMenus] = useState<SysPermission[]>([]);
|
||||
|
|
@ -422,7 +423,7 @@ export default function AppLayout() {
|
|||
<Content
|
||||
style={{
|
||||
margin: "24px 24px 12px",
|
||||
padding: 24,
|
||||
padding: location.pathname === "/" || location.pathname === "/home" ? 0 : 24,
|
||||
background: "var(--app-bg-card)",
|
||||
borderRadius: "8px",
|
||||
boxShadow: "var(--app-shadow)",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Card, Col, Drawer, Form, Input, InputNumber, Popconfirm, Row, Select, Space, Table, Tag, Tooltip, Typography, message } from "antd";
|
||||
import { Button, Card, Col, Drawer, Form, Input, InputNumber, Popconfirm, Row, Select, Space, Table, Tag, Tooltip, Typography, App } from 'antd';
|
||||
import * as AntIcons from "@ant-design/icons";
|
||||
import { CheckSquareOutlined, ClusterOutlined, DeleteOutlined, EditOutlined, FolderOutlined, InfoCircleOutlined, MenuOutlined, PlusOutlined, ReloadOutlined, SearchOutlined } from "@ant-design/icons";
|
||||
import { createPermission, deletePermission, listMyPermissions, updatePermission } from "@/api";
|
||||
|
|
@ -64,6 +64,7 @@ function buildTree(list: SysPermission[]): TreePermission[] {
|
|||
}
|
||||
|
||||
export default function Permissions() {
|
||||
const { message } = App.useApp();
|
||||
const { t } = useTranslation();
|
||||
const { can } = usePermission();
|
||||
const { items: statusDict } = useDict("sys_common_status");
|
||||
|
|
@ -316,7 +317,7 @@ export default function Permissions() {
|
|||
<Table className="permissions-table-full" rowKey="permId" loading={loading} dataSource={treeData} columns={columns} pagination={false} size="middle" scroll={{ x: 'max-content', y: '100%' }} expandable={{ defaultExpandAllRows: false, rowExpandable: (record) => record.permType !== "button" && !!record.children?.length }} />
|
||||
</Card>
|
||||
|
||||
<Drawer title={<Space><ClusterOutlined aria-hidden="true" /><span>{editing ? t("permissions.drawerTitleEdit") : t("permissions.drawerTitleCreate")}</span></Space>} open={open} onClose={() => setOpen(false)} width={520} destroyOnClose footer={<div className="app-page__drawer-footer"><Button onClick={() => setOpen(false)}>{t("common.cancel")}</Button><Button type="primary" loading={saving} onClick={submit}>{t("common.save")}</Button></div>}>
|
||||
<Drawer title={<Space><ClusterOutlined aria-hidden="true" /><span>{editing ? t("permissions.drawerTitleEdit") : t("permissions.drawerTitleCreate")}</span></Space>} open={open} onClose={() => setOpen(false)} width={520} destroyOnHidden footer={<div className="app-page__drawer-footer"><Button onClick={() => setOpen(false)}>{t("common.cancel")}</Button><Button type="primary" loading={saving} onClick={submit}>{t("common.save")}</Button></div>}>
|
||||
<Form form={form} layout="vertical" className="permission-form" onValuesChange={(changed) => changed.permType === "button" && form.setFieldsValue({ isVisible: 0 })}>
|
||||
<Row gutter={16}>
|
||||
<Col span={24}>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Avatar, Button, Card, Col, Drawer, Empty, Form, Input, List, Pagination, Radio, message, Modal, Popconfirm, Select, Space, Table, Tabs, Tag, Tooltip, Tree, Typography, Row } from "antd";
|
||||
import { Avatar, Button, Card, Col, Drawer, Empty, Form, Input, List, Pagination, Radio, Modal, Popconfirm, Select, Space, Table, Tabs, Tag, Tooltip, Tree, Typography, Row, App } from 'antd';
|
||||
import type { DataNode } from "antd/es/tree";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
|
|
@ -165,6 +165,7 @@ function getDataScopeDescription(scopeType: string) {
|
|||
const generateRoleCode = () => `ROLE_${Date.now().toString(36).toUpperCase()}`;
|
||||
|
||||
export default function Roles() {
|
||||
const { message } = App.useApp();
|
||||
const { can } = usePermission();
|
||||
const { items: statusDict } = useDict("sys_common_status");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
|
@ -431,7 +432,7 @@ export default function Roles() {
|
|||
<div className="roles-layout">
|
||||
<Row gutter={24} className="roles-layout__row">
|
||||
<Col span={7} className="roles-layout__side">
|
||||
<Card title={<Space><ApartmentOutlined /><span>{"角色列表"}</span></Space>} bordered={false} className="app-page__panel-card roles-side-card">
|
||||
<Card title={<Space><ApartmentOutlined /><span>{"角色列表"}</span></Space>} variant="borderless" className="app-page__panel-card roles-side-card">
|
||||
<div className="role-search-panel">
|
||||
{isPlatformMode && (
|
||||
<Select
|
||||
|
|
@ -508,12 +509,19 @@ export default function Roles() {
|
|||
{selectedRole ? (
|
||||
<Card
|
||||
className="app-page__panel-card roles-detail-card"
|
||||
bordered={false}
|
||||
variant="borderless"
|
||||
title={<div className="role-detail-header"><div className="role-detail-icon"><SafetyCertificateOutlined /></div><div className="role-detail-heading"><div className="role-detail-title">{selectedRole.roleName}</div><Text type="secondary" className="role-detail-code">{selectedRole.roleCode}</Text></div></div>}
|
||||
extra={<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={handlePrimarySave} disabled={saveDisabled} style={{ borderRadius: "6px" }}>{saveLabel}</Button>}
|
||||
>
|
||||
<Tabs activeKey={activeTab} onChange={(key) => setActiveTab(key as RoleTabKey)} className="role-detail-tabs">
|
||||
<Tabs.TabPane tab={<Space><KeyOutlined />{"功能权限"}</Space>} key="permissions">
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={(key) => setActiveTab(key as RoleTabKey)}
|
||||
className="role-detail-tabs"
|
||||
items={[
|
||||
{
|
||||
key: "permissions",
|
||||
label: <Space><KeyOutlined />{"功能权限"}</Space>,
|
||||
children: (
|
||||
<div className="role-detail-pane">
|
||||
<div className="permission-tree-wrapper">
|
||||
<Tree
|
||||
|
|
@ -532,8 +540,12 @@ export default function Roles() {
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab={<Space><ApartmentOutlined />{"数据权限"}</Space>} key="dataScope">
|
||||
)
|
||||
},
|
||||
{
|
||||
key: "dataScope",
|
||||
label: <Space><ApartmentOutlined />{"数据权限"}</Space>,
|
||||
children: (
|
||||
<div className="role-detail-pane">
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Radio.Group value={dataScopeType} onChange={(event) => setDataScopeType(event.target.value)} optionType="button" buttonStyle="solid">
|
||||
|
|
@ -561,8 +573,12 @@ export default function Roles() {
|
|||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="当前范围不需要选择部门" />
|
||||
)}
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab={<Space><TeamOutlined />{`成员管理 (${roleUsers.length})`}</Space>} key="users">
|
||||
)
|
||||
},
|
||||
{
|
||||
key: "users",
|
||||
label: <Space><TeamOutlined />{`成员管理 (${roleUsers.length})`}</Space>,
|
||||
children: (
|
||||
<div className="role-detail-pane">
|
||||
<div className="role-members-toolbar">
|
||||
<Title level={5} style={{ margin: 0 }}>{"已绑定用户"}</Title>
|
||||
|
|
@ -602,8 +618,10 @@ export default function Roles() {
|
|||
]}
|
||||
/>
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="app-page__empty-state"><Empty description="请选择左侧角色查看详情" /></div>
|
||||
|
|
@ -612,14 +630,14 @@ export default function Roles() {
|
|||
</Row>
|
||||
</div>
|
||||
|
||||
<Modal title="绑定用户到角色" open={userModalOpen} onCancel={() => setUserModalOpen(false)} onOk={() => void handleAddUsers()} okText="确定" cancelText="取消" width={650} destroyOnClose>
|
||||
<Modal title="绑定用户到角色" open={userModalOpen} onCancel={() => setUserModalOpen(false)} onOk={() => void handleAddUsers()} okText="确定" cancelText="取消" width={650} destroyOnHidden>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Input placeholder="搜索用户名或显示名称" prefix={<SearchOutlined />} value={userSearchText} onChange={(event) => setUserSearchText(event.target.value)} allowClear />
|
||||
</div>
|
||||
<Table rowKey="userId" size="small" dataSource={filteredModalUsers} pagination={{ pageSize: 6 }} rowSelection={{ selectedRowKeys: selectedUserKeys, onChange: (keys) => setSelectedUserKeys(keys as number[]) }} columns={[{ title: "显示名称", dataIndex: "displayName" }, { title: "用户名", dataIndex: "username" }, { title: "手机号", dataIndex: "phone" }]} />
|
||||
</Modal>
|
||||
|
||||
<Drawer title={editing ? "编辑角色" : "新增角色"} open={drawerOpen} onClose={() => setDrawerOpen(false)} width={420} destroyOnClose footer={<div className="app-page__drawer-footer"><Button onClick={() => setDrawerOpen(false)}>{"取消"}</Button><Button type="primary" loading={saving} onClick={() => void submitBasic()}>{"保存"}</Button></div>}>
|
||||
<Drawer title={editing ? "编辑角色" : "新增角色"} open={drawerOpen} onClose={() => setDrawerOpen(false)} width={420} destroyOnHidden footer={<div className="app-page__drawer-footer"><Button onClick={() => setDrawerOpen(false)}>{"取消"}</Button><Button type="primary" loading={saving} onClick={() => void submitBasic()}>{"保存"}</Button></div>}>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item label="租户" name="tenantId" rules={[{ required: true }]} hidden={!isPlatformMode}>
|
||||
<Select options={tenants.map((tenant) => ({ label: tenant.tenantName, value: tenant.id }))} disabled={!!editing} />
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Button, Card, Col, Drawer, Form, Input, Popconfirm, Row, Select, Space, Switch, Table, Tag, TreeSelect, Typography, message } from "antd";
|
||||
import { Button, Card, Col, Drawer, Form, Input, Popconfirm, Row, Select, Space, Switch, Table, Tag, TreeSelect, Typography, App } from 'antd';
|
||||
import type { DefaultOptionType } from "antd/es/select";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
|
@ -62,6 +62,7 @@ function MembershipOrgSelect({ fieldProps, name, tenantId }: { fieldProps: any;
|
|||
}
|
||||
|
||||
export default function Users() {
|
||||
const { message } = App.useApp();
|
||||
const { t } = useTranslation();
|
||||
const { can } = usePermission();
|
||||
const { items: statusDict } = useDict("sys_common_status");
|
||||
|
|
@ -391,7 +392,7 @@ export default function Users() {
|
|||
</div>
|
||||
</Card>
|
||||
|
||||
<Drawer title={<div className="user-drawer-title"><UserOutlined className="mr-2" aria-hidden="true" />{editing ? t("users.drawerTitleEdit") : t("users.drawerTitleCreate")}</div>} open={drawerOpen} onClose={() => setDrawerOpen(false)} width={520} destroyOnClose footer={<div className="app-page__drawer-footer"><Button onClick={() => setDrawerOpen(false)}>{t("common.cancel")}</Button><Button type="primary" loading={saving} onClick={submit}>{t("common.save")}</Button></div>}>
|
||||
<Drawer title={<div className="user-drawer-title"><UserOutlined className="mr-2" aria-hidden="true" />{editing ? t("users.drawerTitleEdit") : t("users.drawerTitleCreate")}</div>} open={drawerOpen} onClose={() => setDrawerOpen(false)} width={520} destroyOnHidden footer={<div className="app-page__drawer-footer"><Button onClick={() => setDrawerOpen(false)}>{t("common.cancel")}</Button><Button type="primary" loading={saving} onClick={submit}>{t("common.save")}</Button></div>}>
|
||||
<Form form={form} layout="vertical" className="user-form">
|
||||
<Title level={5} style={{ marginBottom: 16 }}>{t("usersExt.basicInfo")}</Title>
|
||||
<Row gutter={16}>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Button, Checkbox, Form, Input, Typography, message } from "antd";
|
||||
import { App, Button, Checkbox, Form, Input, Typography } from "antd";
|
||||
import { LockOutlined, ReloadOutlined, SafetyOutlined, UserOutlined } from "@ant-design/icons";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
|
@ -24,6 +24,7 @@ export default function Login() {
|
|||
const [loading, setLoading] = useState(false);
|
||||
const [platformConfig, setPlatformConfig] = useState<SysPlatformConfig | null>(null);
|
||||
const [form] = Form.useForm<LoginFormValues>();
|
||||
const { message } = App.useApp();
|
||||
|
||||
const loadCaptcha = useCallback(async () => {
|
||||
if (!captchaEnabled) {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Button, Card, Form, Input, Layout, Typography, message } from "antd";
|
||||
import { Button, Card, Form, Input, Layout, Typography, App } from 'antd';
|
||||
import { LockOutlined, LogoutOutlined } from "@ant-design/icons";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
|
@ -13,6 +13,7 @@ type ResetPasswordFormValues = {
|
|||
};
|
||||
|
||||
export default function ResetPassword() {
|
||||
const { message } = App.useApp();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const [form] = Form.useForm<ResetPasswordFormValues>();
|
||||
|
|
|
|||
|
|
@ -1,17 +1,4 @@
|
|||
import {
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Empty,
|
||||
Input,
|
||||
message,
|
||||
Row,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Tree,
|
||||
Typography
|
||||
} from "antd";
|
||||
import { Button, Card, Col, Empty, Input, Row, Space, Table, Tag, Tree, Typography, App } from 'antd';
|
||||
import type { DataNode } from "antd/es/tree";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
|
@ -76,6 +63,7 @@ function toTreeData(nodes: PermissionNode[]): DataNode[] {
|
|||
}
|
||||
|
||||
export default function RolePermissionBinding() {
|
||||
const { message } = App.useApp();
|
||||
const { t } = useTranslation();
|
||||
const [roles, setRoles] = useState<SysRole[]>([]);
|
||||
const [permissions, setPermissions] = useState<SysPermission[]>([]);
|
||||
|
|
|
|||
|
|
@ -1,17 +1,4 @@
|
|||
import {
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Col,
|
||||
Empty,
|
||||
Input,
|
||||
Row,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
message
|
||||
} from "antd";
|
||||
import { Button, Card, Checkbox, Col, Empty, Input, Row, Space, Table, Tag, Typography, App } from 'antd';
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { listRoles, listUserRoles, listUsers, saveUserRoles } from "@/api";
|
||||
|
|
@ -22,6 +9,7 @@ import PageHeader from "@/components/shared/PageHeader";
|
|||
const { Text } = Typography;
|
||||
|
||||
export default function UserRoleBinding() {
|
||||
const { message } = App.useApp();
|
||||
const { t } = useTranslation();
|
||||
const [users, setUsers] = useState<SysUser[]>([]);
|
||||
const [roles, setRoles] = useState<SysRole[]>([]);
|
||||
|
|
|
|||
|
|
@ -1,26 +1,5 @@
|
|||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
AutoComplete,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Divider,
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Popconfirm,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Typography,
|
||||
message,
|
||||
} from "antd";
|
||||
import { AutoComplete, Button, Card, Col, Divider, Drawer, Form, Input, InputNumber, Popconfirm, Row, Select, Space, Switch, Table, Tabs, Tag, Tooltip, Typography, App } from 'antd';
|
||||
import {
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
|
|
@ -61,6 +40,7 @@ const PROVIDER_BASE_URL_MAP: Record<string, string> = {
|
|||
};
|
||||
|
||||
const AiModels: React.FC = () => {
|
||||
const { message } = App.useApp();
|
||||
const [form] = Form.useForm();
|
||||
const { items: providers } = useDict("biz_ai_provider");
|
||||
|
||||
|
|
|
|||
|
|
@ -1,24 +1,5 @@
|
|||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
message,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Radio,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from "antd";
|
||||
import { Badge, Button, Card, Col, Form, Input, InputNumber, Modal, Popconfirm, Radio, Row, Select, Space, Table, Tag, Tooltip, Typography, App } from 'antd';
|
||||
import {
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
|
|
@ -52,6 +33,7 @@ type HotWordFormValues = {
|
|||
};
|
||||
|
||||
const HotWords: React.FC = () => {
|
||||
const { message } = App.useApp();
|
||||
const { t } = useTranslation();
|
||||
const [form] = Form.useForm<HotWordFormValues>();
|
||||
const { items: categories } = useDict("biz_hotword_category");
|
||||
|
|
@ -183,16 +165,17 @@ const HotWords: React.FC = () => {
|
|||
title: "热词原文",
|
||||
dataIndex: "word",
|
||||
key: "word",
|
||||
ellipsis: true,
|
||||
render: (text: string, record: HotWordVO) => (
|
||||
<Space>
|
||||
<Text strong>{text}</Text>
|
||||
<Space style={{ display: 'flex', width: '100%', overflow: 'hidden' }}>
|
||||
<Text strong ellipsis={{ tooltip: text }} style={{ maxWidth: 200, display: 'block' }}>{text}</Text>
|
||||
{record.isPublic === 1 ? (
|
||||
<Tooltip title="租户公开">
|
||||
<GlobalOutlined style={{ color: "#52c41a" }} />
|
||||
<GlobalOutlined style={{ color: "#52c41a", flexShrink: 0 }} />
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip title="个人私有">
|
||||
<UserOutlined style={{ color: "#1890ff" }} />
|
||||
<UserOutlined style={{ color: "#1890ff", flexShrink: 0 }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</Space>
|
||||
|
|
|
|||
|
|
@ -1,30 +1,6 @@
|
|||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Avatar,
|
||||
Breadcrumb,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Col,
|
||||
Divider,
|
||||
Drawer,
|
||||
Empty,
|
||||
Form,
|
||||
Input,
|
||||
List,
|
||||
message,
|
||||
Modal,
|
||||
Popover,
|
||||
Progress,
|
||||
Row,
|
||||
Select,
|
||||
Skeleton,
|
||||
Space,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import { Alert, Avatar, Breadcrumb, Button, Card, Checkbox, Col, Divider, Drawer, Empty, Form, Input, List, Modal, Popover, Progress, Row, Select, Skeleton, Space, Tag, Typography, App } from 'antd';
|
||||
import {
|
||||
AudioOutlined,
|
||||
CaretRightFilled,
|
||||
|
|
@ -332,7 +308,7 @@ const MeetingProgressDisplay: React.FC<{ meetingId: number; onComplete: () => vo
|
|||
status={isError ? 'exception' : percent === 100 ? 'success' : 'active'}
|
||||
strokeColor={isError ? '#ff4d4f' : { '0%': '#6c73ff', '100%': '#8d63ff' }}
|
||||
width={180}
|
||||
strokeWidth={8}
|
||||
size={8}
|
||||
/>
|
||||
<div style={{ marginTop: 32 }}>
|
||||
<Text strong style={{ fontSize: 18, color: isError ? '#ff4d4f' : '#5d67ff', display: 'block', marginBottom: 8 }}>
|
||||
|
|
@ -376,6 +352,7 @@ const SpeakerEditor: React.FC<{
|
|||
const [name, setName] = useState(initialName || speakerId);
|
||||
const [label, setLabel] = useState(initialLabel);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { message } = App.useApp();
|
||||
const { items: speakerLabels } = useDict('biz_speaker_label');
|
||||
|
||||
const handleSave = async (event: React.MouseEvent) => {
|
||||
|
|
@ -527,6 +504,7 @@ const ActiveTranscriptRow = React.memo<ActiveTranscriptRowProps>(({
|
|||
));
|
||||
|
||||
const MeetingDetail: React.FC = () => {
|
||||
const { message } = App.useApp();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [form] = Form.useForm();
|
||||
|
|
@ -706,7 +684,7 @@ const MeetingDetail: React.FC = () => {
|
|||
setActionLoading(true);
|
||||
try {
|
||||
await updateMeetingSummary({
|
||||
meetingId: meeting?.id,
|
||||
meetingId: Number(id),
|
||||
summaryContent: summaryDraft,
|
||||
});
|
||||
message.success('总结内容已更新');
|
||||
|
|
@ -970,7 +948,7 @@ const MeetingDetail: React.FC = () => {
|
|||
<Breadcrumb.Item>会议详情</Breadcrumb.Item>
|
||||
</Breadcrumb>
|
||||
|
||||
<Card style={{ marginBottom: 16, flexShrink: 0 }} bodyStyle={{ padding: '16px 24px' }}>
|
||||
<Card style={{ marginBottom: 16, flexShrink: 0 }} styles={{ body: { padding: '16px 24px' } }}>
|
||||
<Row justify="space-between" align="middle">
|
||||
<Col>
|
||||
<Space direction="vertical" size={4}>
|
||||
|
|
@ -1032,7 +1010,7 @@ const MeetingDetail: React.FC = () => {
|
|||
<Row gutter={24} style={{ height: '100%' }}>
|
||||
<Col xs={24} lg={14} style={{ height: '100%' }}>
|
||||
<div className="detail-side-column detail-left-column">
|
||||
<Card className="left-flow-card summary-panel" bordered={false}>
|
||||
<Card className="left-flow-card summary-panel" variant="borderless">
|
||||
<div className="summary-head">
|
||||
<div className="summary-title">
|
||||
<RobotOutlined />
|
||||
|
|
@ -1206,7 +1184,7 @@ const MeetingDetail: React.FC = () => {
|
|||
<div className="section-divider-line" />
|
||||
</div>
|
||||
|
||||
<Card className="left-flow-card" bordered={false} title={<span><AudioOutlined /> 原文</span>}>
|
||||
<Card className="left-flow-card" variant="borderless" title={<span><AudioOutlined /> 原文</span>}>
|
||||
{meeting.audioUrl && <audio ref={audioRef} src={meeting.audioUrl} style={{ display: 'none' }} preload="metadata" />}
|
||||
{meeting.audioSaveStatus === 'FAILED' && (
|
||||
<Alert
|
||||
|
|
@ -1270,7 +1248,7 @@ const MeetingDetail: React.FC = () => {
|
|||
<div className="detail-side-column ai-summary-column">
|
||||
<Card
|
||||
className="left-flow-card"
|
||||
bordered={false}
|
||||
variant="borderless"
|
||||
title={<span><RobotOutlined /> AI 总结</span>}
|
||||
extra={
|
||||
meeting.summaryContent && isOwner && (
|
||||
|
|
@ -1297,7 +1275,7 @@ const MeetingDetail: React.FC = () => {
|
|||
)
|
||||
}
|
||||
style={{ height: '100%' }}
|
||||
bodyStyle={{ padding: 24, height: '100%', overflowY: 'auto', overflowX: 'hidden', minWidth: 0 }}
|
||||
styles={{ body: { padding: 24, height: '100%', overflowY: 'auto', overflowX: 'hidden', minWidth: 0 } }}
|
||||
>
|
||||
<div ref={summaryPdfRef} className="markdown-shell">
|
||||
{meeting.summaryContent ? (
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, Button, Input, Space, Tag, message, Popconfirm, Typography, Row, Col, List, Badge, Empty, Skeleton, Tooltip, Radio, Pagination, Progress, Drawer, Form, DatePicker, Upload, Avatar, Divider, Switch, Select, Modal } from 'antd';
|
||||
import { Card, Button, Input, Space, Tag, Popconfirm, Typography, Row, Col, List, Badge, Empty, Skeleton, Tooltip, Radio, Pagination, Progress, Drawer, Form, DatePicker, Upload, Avatar, Divider, Switch, Select, Modal, App } from 'antd';
|
||||
import {
|
||||
PlusOutlined, DeleteOutlined, SearchOutlined, CheckCircleOutlined,
|
||||
LoadingOutlined, UserOutlined, CalendarOutlined, PlayCircleOutlined,
|
||||
|
|
@ -20,8 +20,8 @@ import dayjs from 'dayjs';
|
|||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
const { Dragger } = Upload;
|
||||
const { Option } = Select;
|
||||
import { MeetingCreateDrawer, MeetingCreateType } from '../../components/business/MeetingCreateDrawer';
|
||||
const PAUSED_DISPLAY_STATUS = 5;
|
||||
|
||||
const applyRealtimeSessionStatus = (item: MeetingVO, sessionStatus?: RealtimeMeetingSessionStatus): MeetingVO => {
|
||||
|
|
@ -109,194 +109,7 @@ const IntegratedStatusTag: React.FC<{ meeting: MeetingVO, progress: MeetingProgr
|
|||
);
|
||||
};
|
||||
|
||||
// --- 发起会议表单组件 (左侧高度占满版) ---
|
||||
const MeetingCreateForm: React.FC<{
|
||||
form: any,
|
||||
audioUrl: string,
|
||||
setAudioUrl: (url: string) => void,
|
||||
uploadProgress: number,
|
||||
setUploadProgress: (p: number) => void,
|
||||
fileList: any[],
|
||||
setFileList: (list: any[]) => void
|
||||
}> = ({ form, audioUrl, setAudioUrl, uploadProgress, setUploadProgress, fileList, setFileList }) => {
|
||||
const [asrModels, setAsrModels] = useState<AiModelVO[]>([]);
|
||||
const [llmModels, setLlmModels] = useState<AiModelVO[]>([]);
|
||||
const [prompts, setPrompts] = useState<PromptTemplateVO[]>([]);
|
||||
const [hotwordList, setHotwordList] = useState<HotWordVO[]>([]);
|
||||
const [userList, setUserList] = useState<SysUser[]>([]);
|
||||
const watchedPromptId = Form.useWatch('promptId', form);
|
||||
|
||||
useEffect(() => {
|
||||
loadInitialData();
|
||||
}, []);
|
||||
|
||||
const loadInitialData = async () => {
|
||||
try {
|
||||
const [asrRes, llmRes, promptRes, hotwordRes, users] = await Promise.all([
|
||||
getAiModelPage({ current: 1, size: 100, type: 'ASR' }),
|
||||
getAiModelPage({ current: 1, size: 100, type: 'LLM' }),
|
||||
getPromptPage({ current: 1, size: 100 }),
|
||||
getHotWordPage({ current: 1, size: 1000 }),
|
||||
listUsers()
|
||||
]);
|
||||
setAsrModels(asrRes.data.data.records.filter(m => m.status === 1));
|
||||
setLlmModels(llmRes.data.data.records.filter(m => m.status === 1));
|
||||
const activePrompts = promptRes.data.data.records.filter(p => p.status === 1);
|
||||
setPrompts(activePrompts);
|
||||
setHotwordList(hotwordRes.data.data.records.filter(h => h.status === 1));
|
||||
setUserList(users || []);
|
||||
const defaultAsr = await getAiModelDefault('ASR');
|
||||
const defaultLlm = await getAiModelDefault('LLM');
|
||||
form.setFieldsValue({
|
||||
asrModelId: defaultAsr.data.data?.id,
|
||||
summaryModelId: defaultLlm.data.data?.id,
|
||||
promptId: activePrompts.length > 0 ? activePrompts[0].id : undefined,
|
||||
meetingTime: dayjs(),
|
||||
useSpkId: 1,
|
||||
enableTextRefine: false
|
||||
});
|
||||
} catch (err) {}
|
||||
};
|
||||
|
||||
const customUpload = async (options: any) => {
|
||||
const { file, onSuccess: uploadSuccess, onError } = options;
|
||||
setUploadProgress(0);
|
||||
try {
|
||||
const interval = setInterval(() => setUploadProgress(prev => (prev < 95 ? prev + 5 : prev)), 300);
|
||||
const res = await uploadAudio(file);
|
||||
clearInterval(interval);
|
||||
setUploadProgress(100);
|
||||
setAudioUrl(res.data.data);
|
||||
uploadSuccess(res.data.data);
|
||||
message.success('录音上传成功');
|
||||
} catch (err) {
|
||||
onError(err);
|
||||
message.error('文件上传失败');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form form={form} layout="vertical" style={{ height: 'calc(100vh - 220px)', display: 'flex', flexDirection: 'column' }}>
|
||||
<Row gutter={24} style={{ flex: 1, minHeight: 0 }}>
|
||||
<Col span={16} style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
{/* 基础信息卡片 - 固定高度 */}
|
||||
<Card size="small" title={<Space><InfoCircleOutlined /> 基础信息</Space>} bordered={false} style={{ borderRadius: 12, boxShadow: '0 2px 8px rgba(0,0,0,0.03)', marginBottom: 20 }}>
|
||||
<Form.Item name="title" label="会议标题" rules={[{ required: true }]} style={{ marginBottom: 12 }}><Input placeholder="输入会议标题" size="large" /></Form.Item>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}><Form.Item name="meetingTime" label="会议时间" rules={[{ required: true }]} style={{ marginBottom: 12 }}><DatePicker showTime style={{ width: '100%' }} size="large" /></Form.Item></Col>
|
||||
<Col span={12}><Form.Item name="tags" label="会议标签" style={{ marginBottom: 12 }}><Select mode="tags" placeholder="输入标签" size="large" /></Form.Item></Col>
|
||||
</Row>
|
||||
<Form.Item name="participants" label="参会人员" style={{ marginBottom: 12 }}>
|
||||
<Select mode="multiple" placeholder="选择人员" showSearch optionFilterProp="children" size="large">
|
||||
{userList.map(u => (<Option key={u.userId} value={u.userId}><Space><Avatar size="small" icon={<UserOutlined />} />{u.displayName || u.username}</Space></Option>))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item name="hostUserId" label="会议主持人" style={{ marginBottom: 0 }}>
|
||||
<Select allowClear placeholder="不选择则默认为创建人" showSearch optionFilterProp="children" size="large">
|
||||
{userList.map(u => (<Option key={u.userId} value={u.userId}><Space><Avatar size="small" icon={<UserOutlined />} />{u.displayName || u.username}</Space></Option>))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Card>
|
||||
|
||||
{/* 录音上传卡片 - 占满剩余高度 */}
|
||||
<Card
|
||||
size="small"
|
||||
title={<Space><AudioOutlined /> 录音上传</Space>}
|
||||
bordered={false}
|
||||
style={{ borderRadius: 12, boxShadow: 'var(--app-shadow)', background: 'var(--app-bg-surface-soft)', border: '1px solid var(--app-border-color)', flex: 1, display: 'flex', flexDirection: 'column', backdropFilter: 'blur(16px)' }}
|
||||
bodyStyle={{ flex: 1, display: 'flex', flexDirection: 'column', padding: '16px 20px' }}
|
||||
>
|
||||
<Dragger
|
||||
accept=".mp3,.wav,.m4a"
|
||||
fileList={fileList}
|
||||
customRequest={customUpload}
|
||||
onChange={info => setFileList(info.fileList.slice(-1))}
|
||||
maxCount={1}
|
||||
style={{ borderRadius: 8, flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center' }}
|
||||
>
|
||||
<div>
|
||||
<p className="ant-upload-drag-icon" style={{ marginBottom: 12 }}><CloudUploadOutlined style={{ fontSize: 48 }} /></p>
|
||||
<p className="ant-upload-text" style={{ fontSize: 16, fontWeight: 500 }}>点击或拖拽录音文件到此处</p>
|
||||
<p className="ant-upload-hint" style={{ fontSize: 13, marginTop: 8 }}>支持高质量 .mp3, .wav, .m4a 格式音频</p>
|
||||
{uploadProgress > 0 && uploadProgress < 100 && (
|
||||
<div style={{ width: '80%', margin: '24px auto 0' }}>
|
||||
<Progress percent={uploadProgress} size="small" />
|
||||
<div style={{ fontSize: 12, color: '#1890ff', marginTop: 4 }}>文件传输中,请稍候...</div>
|
||||
</div>
|
||||
)}
|
||||
{audioUrl && (
|
||||
<Tooltip title={audioUrl.split('/').pop()}>
|
||||
<Tag
|
||||
color="success"
|
||||
style={{
|
||||
marginTop: 20,
|
||||
padding: '4px 12px',
|
||||
fontSize: 13,
|
||||
maxWidth: '500px',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
// size="large"
|
||||
>
|
||||
就绪: {audioUrl.split('/').pop()}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</Dragger>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col span={8} style={{ height: '100%', overflowY: 'auto', paddingRight: 4 }}>
|
||||
<Card size="small" title={<Space><SettingOutlined /> AI 分析配置</Space>} bordered={false} style={{ borderRadius: 12, boxShadow: '0 2px 8px rgba(0,0,0,0.03)', marginBottom: 20 }}>
|
||||
<Form.Item name="asrModelId" label="语音识别 (ASR)" rules={[{ required: true }]} style={{ marginBottom: 16 }}>
|
||||
<Select placeholder="选择 ASR 模型" size="small">{asrModels.map(m => (<Option key={m.id} value={m.id}>{m.modelName}</Option>))}</Select>
|
||||
</Form.Item>
|
||||
<Form.Item name="summaryModelId" label="内容总结 (LLM)" rules={[{ required: true }]} style={{ marginBottom: 16 }}>
|
||||
<Select placeholder="选择总结模型" size="small">{llmModels.map(m => (<Option key={m.id} value={m.id}>{m.modelName}</Option>))}</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="promptId" label="总结模板" rules={[{ required: true }]} style={{ marginBottom: 16 }}>
|
||||
{prompts.length > 15 ? (
|
||||
<Select placeholder="请选择模板" showSearch optionFilterProp="children" size="small">
|
||||
{prompts.map(p => <Option key={p.id} value={p.id}>{p.templateName}</Option>)}
|
||||
</Select>
|
||||
) : (
|
||||
<div style={{ padding: '2px' }}>
|
||||
<Row gutter={[6, 6]}>
|
||||
{prompts.map(p => {
|
||||
const isSelected = watchedPromptId === p.id;
|
||||
return (
|
||||
<Col span={12} key={p.id}>
|
||||
<div onClick={() => form.setFieldsValue({ promptId: p.id })} style={{ padding: '6px', borderRadius: 6, border: `1.5px solid ${isSelected ? 'var(--app-primary-color)' : 'var(--app-border-color)'}`, background: isSelected ? 'color-mix(in srgb, var(--app-primary-color) 12%, var(--app-bg-surface-strong))' : 'var(--app-bg-surface-strong)', cursor: 'pointer', textAlign: 'center', position: 'relative' }}>
|
||||
<div style={{ fontSize: '11px', color: isSelected ? '#1890ff' : '#434343', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.templateName}</div>
|
||||
{isSelected && <div style={{ position: 'absolute', top: 0, right: 0, width: 12, height: 12, background: '#1890ff', borderRadius: '0 4px 0 4px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}><CheckOutlined style={{ color: '#fff', fontSize: 8 }} /></div>}
|
||||
</div>
|
||||
</Col>
|
||||
);
|
||||
})}
|
||||
</Row>
|
||||
</div>
|
||||
)}
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="useSpkId" label={<span>声纹识别 <Tooltip title="开启后将区分不同发言人"><QuestionCircleOutlined /></Tooltip></span>} valuePropName="checked" getValueProps={(v) => ({ checked: v === 1 })} normalize={(v) => (v ? 1 : 0)} style={{ marginBottom: 20 }}>
|
||||
<Switch size="small" />
|
||||
</Form.Item>
|
||||
<Form.Item name="enableTextRefine" label={<span>文本修正 <Tooltip title="开启后将尝试对识别文本进行修正"><QuestionCircleOutlined /></Tooltip></span>} valuePropName="checked" style={{ marginBottom: 20 }}>
|
||||
<Switch size="small" />
|
||||
</Form.Item>
|
||||
</Card>
|
||||
|
||||
<div style={{ backgroundColor: '#f6ffed', border: '1px solid #b7eb8f', padding: '12px 16px', borderRadius: 12 }}>
|
||||
<Text type="secondary" style={{ fontSize: '12px' }}><CheckCircleOutlined style={{ color: '#52c41a', marginRight: 8 }} />智能分析链路:转录固化 + AI 总结 + 说话人分离。</Text>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
// --- 卡片项组件 ---
|
||||
const MeetingCardItem: React.FC<{ item: MeetingVO, config: any, fetchData: () => void, t: any, onEditParticipants: (meeting: MeetingVO) => void, onOpenMeeting: (meeting: MeetingVO) => void }> = ({ item, config, fetchData, t, onEditParticipants, onOpenMeeting }) => {
|
||||
|
|
@ -308,12 +121,12 @@ const MeetingCardItem: React.FC<{ item: MeetingVO, config: any, fetchData: () =>
|
|||
|
||||
return (
|
||||
<List.Item style={{ marginBottom: 24 }}>
|
||||
<Card hoverable onClick={() => onOpenMeeting(item)} className="meeting-card" style={{ borderRadius: 16, border: '1px solid var(--app-border-color)', background: 'var(--app-bg-card)', backdropFilter: 'blur(16px)', height: '220px', position: 'relative', boxShadow: 'var(--app-shadow)', transition: 'all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1)' }} bodyStyle={{ padding: 0, display: 'flex', height: '100%' }}>
|
||||
<div className={isProcessing ? 'status-bar-active' : ''} style={{ width: 6, backgroundColor: config.color, borderRadius: '16px 0 0 16px' }}></div>
|
||||
<div style={{ flex: 1, padding: '20px 24px', position: 'relative', display: 'flex', flexDirection: 'column' }}>
|
||||
<div className="card-actions" style={{ position: 'absolute', top: 16, right: 16, zIndex: 10 }} onClick={e => e.stopPropagation()}>
|
||||
<Card hoverable onClick={() => onOpenMeeting(item)} className="meeting-card" style={{ borderRadius: 16, border: '1px solid var(--app-border-color)', background: 'var(--app-bg-card)', backdropFilter: 'blur(16px)', height: '220px', position: 'relative', boxShadow: 'var(--app-shadow)', transition: 'all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1)' }} styles={{ body: { padding: 0, display: 'flex', height: '100%' } }}>
|
||||
<div className={isProcessing ? 'status-bar-active' : ''} style={{ width: 6, backgroundColor: config.color, borderRadius: '16px 0 0 16px', flexShrink: 0 }}></div>
|
||||
<div style={{ flex: 1, padding: '20px 24px', position: 'relative', display: 'flex', flexDirection: 'column', minWidth: 0 }}>
|
||||
<div className="card-actions" style={{ position: 'absolute', top: 16, right: 16, zIndex: 10, background: 'var(--app-bg-card)', borderRadius: 8, padding: '4px' }} onClick={e => e.stopPropagation()}>
|
||||
<Space size={8}>
|
||||
<Tooltip title="编辑参会人"><div className="icon-btn edit" onClick={() => onEditParticipants(item)}><EditOutlined /></div></Tooltip>
|
||||
<Tooltip title="编辑参会人"><div className="icon-btn edit"><EditOutlined onClick={() => onEditParticipants(item)} /></div></Tooltip>
|
||||
<Popconfirm
|
||||
title="确定删除?"
|
||||
onConfirm={() => deleteMeeting(item.id).then(fetchData)}
|
||||
|
|
@ -325,13 +138,15 @@ const MeetingCardItem: React.FC<{ item: MeetingVO, config: any, fetchData: () =>
|
|||
</Space>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
|
||||
<div style={{ marginBottom: 12, paddingRight: 80 }}>
|
||||
<IntegratedStatusTag meeting={item} progress={progress} />
|
||||
</div>
|
||||
<div style={{ marginBottom: 16, paddingRight: 40, height: '44px', overflow: 'hidden' }}><Text strong style={{ fontSize: 16, color: '#262626', lineHeight: '22px' }} ellipsis={{ tooltip: item.title }}>{item.title}</Text></div>
|
||||
<Space direction="vertical" size={10} style={{ width: '100%' }}>
|
||||
<div style={{ fontSize: '13px', color: '#8c8c8c', display: 'flex', alignItems: 'center' }}><CalendarOutlined style={{ marginRight: 10 }} />{dayjs(item.meetingTime).format('YYYY-MM-DD HH:mm')}</div>
|
||||
<div style={{ marginBottom: 16, paddingRight: 80, height: '44px', overflow: 'hidden', flexShrink: 0 }}>
|
||||
<Text strong style={{ fontSize: 16, color: '#262626', lineHeight: '22px' }} ellipsis={{ tooltip: item.title }}>{item.title}</Text>
|
||||
</div>
|
||||
<Space direction="vertical" size={10} style={{ width: '100%', minWidth: 0 }}>
|
||||
<div style={{ fontSize: '13px', color: '#8c8c8c', display: 'flex', alignItems: 'center' }}><CalendarOutlined style={{ marginRight: 10, flexShrink: 0 }} /><span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{dayjs(item.meetingTime).format('YYYY-MM-DD HH:mm')}</span></div>
|
||||
{isProcessing ? (
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
|
|
@ -342,11 +157,9 @@ const MeetingCardItem: React.FC<{ item: MeetingVO, config: any, fetchData: () =>
|
|||
padding: '6px 10px',
|
||||
borderRadius: 6,
|
||||
marginTop: 4,
|
||||
width: '100%', // 占满 Space 容器
|
||||
width: '100%',
|
||||
overflow: 'hidden',
|
||||
boxSizing: 'border-box',
|
||||
minWidth: 0 ,// 关键:允许 flex 子项收缩
|
||||
maxWidth: 250
|
||||
boxSizing: 'border-box'
|
||||
}}>
|
||||
<InfoCircleOutlined style={{ marginRight: 6, flexShrink: 0 }} />
|
||||
<Text
|
||||
|
|
@ -355,8 +168,7 @@ const MeetingCardItem: React.FC<{ item: MeetingVO, config: any, fetchData: () =>
|
|||
color: 'inherit',
|
||||
fontSize: '12px',
|
||||
flex: 1,
|
||||
minWidth: 0, // 关键:触发文本截断
|
||||
maxWidth: 250, // 关键:触发文本截断
|
||||
minWidth: 0,
|
||||
fontWeight: 500,
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
|
|
@ -376,25 +188,24 @@ const MeetingCardItem: React.FC<{ item: MeetingVO, config: any, fetchData: () =>
|
|||
marginTop: 4,
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
minWidth: 0,
|
||||
maxWidth: 250
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<PauseCircleOutlined style={{ marginRight: 6, flexShrink: 0 }} />
|
||||
<Text style={{ color: 'inherit', fontSize: '12px', fontWeight: 500 }}>
|
||||
<Text ellipsis style={{ color: 'inherit', fontSize: '12px', fontWeight: 500, flex: 1, minWidth: 0 }}>
|
||||
会议已暂停,可继续识别
|
||||
</Text>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
<div style={{ fontSize: '13px', color: '#8c8c8c', display: 'flex', alignItems: 'center' }}><TeamOutlined style={{ marginRight: 10 }} /><Text type="secondary" ellipsis style={{ maxWidth: '85%' }}>{item.participants || '无参与人员'}</Text></div>
|
||||
<div style={{ fontSize: '13px', color: '#8c8c8c', display: 'flex', alignItems: 'center' }}><TeamOutlined style={{ marginRight: 10, flexShrink: 0 }} /><Text type="secondary" ellipsis style={{ flex: 1, minWidth: 0 }}>{item.participants || '无参与人员'}</Text></div>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 12 }}>
|
||||
<div style={{ display: 'flex', gap: 4 }}>{item.tags?.split(',').slice(0, 2).map(t => (
|
||||
<Tag key={t} style={{ border: '1px solid var(--app-border-color)', background: 'var(--app-bg-surface-strong)', color: 'var(--app-text-main)', fontSize: 10, margin: 0, borderRadius: 4 }}>{t}</Tag>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 12, flexShrink: 0 }}>
|
||||
<div style={{ display: 'flex', gap: 4, overflow: 'hidden' }}>{item.tags?.split(',').slice(0, 2).map(t => (
|
||||
<Tag key={t} style={{ border: '1px solid var(--app-border-color)', background: 'var(--app-bg-surface-strong)', color: 'var(--app-text-main)', fontSize: 10, margin: 0, borderRadius: 4, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '100px' }}>{t}</Tag>
|
||||
))}</div>
|
||||
<RightOutlined style={{ color: '#bfbfbf', fontSize: 12 }} />
|
||||
<RightOutlined style={{ color: '#bfbfbf', fontSize: 12, flexShrink: 0 }} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
|
@ -404,6 +215,7 @@ const MeetingCardItem: React.FC<{ item: MeetingVO, config: any, fetchData: () =>
|
|||
|
||||
// --- 主组件 ---
|
||||
const Meetings: React.FC = () => {
|
||||
const { message } = App.useApp();
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { can } = usePermission();
|
||||
|
|
@ -418,19 +230,7 @@ const Meetings: React.FC = () => {
|
|||
const [searchTitle, setSearchTitle] = useState('');
|
||||
const [viewType, setViewType] = useState<'all' | 'created' | 'involved'>('all');
|
||||
const [createDrawerVisible, setCreateDrawerVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchParams.get('create') === 'true') {
|
||||
setCreateDrawerVisible(true);
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
newParams.delete('create');
|
||||
setSearchParams(newParams, { replace: true });
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
const [audioUrl, setAudioUrl] = useState('');
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [fileList, setFileList] = useState<any[]>([]);
|
||||
const [createDrawerType, setCreateDrawerType] = useState<MeetingCreateType>('upload');
|
||||
const [userList, setUserList] = useState<SysUser[]>([]);
|
||||
const [participantsEditVisible, setParticipantsEditVisible] = useState(false);
|
||||
const [editingMeeting, setEditingMeeting] = useState<MeetingVO | null>(null);
|
||||
|
|
@ -441,6 +241,16 @@ const Meetings: React.FC = () => {
|
|||
return effectiveStatus === 0 || effectiveStatus === 1 || effectiveStatus === 2;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const action = searchParams.get('action');
|
||||
const type = searchParams.get('type') as MeetingCreateType;
|
||||
if (action === 'create' && (type === 'realtime' || type === 'upload')) {
|
||||
setCreateDrawerType(type);
|
||||
setCreateDrawerVisible(true);
|
||||
setSearchParams({});
|
||||
}
|
||||
}, [searchParams, setSearchParams]);
|
||||
|
||||
useEffect(() => { fetchData(); }, [current, size, searchTitle, viewType]);
|
||||
useEffect(() => {
|
||||
if (!hasRunningTasks) return;
|
||||
|
|
@ -486,30 +296,7 @@ const Meetings: React.FC = () => {
|
|||
navigate(`/meetings/${meeting.id}`);
|
||||
};
|
||||
|
||||
const handleCreateSubmit = async () => {
|
||||
if (!audioUrl) {
|
||||
message.error('请先上传录音文件');
|
||||
return;
|
||||
}
|
||||
const values = await form.validateFields();
|
||||
const { hostUserId, ...meetingValues } = values;
|
||||
setSubmitLoading(true);
|
||||
try {
|
||||
await createMeeting({
|
||||
...meetingValues,
|
||||
...(hostUserId != null ? { hostUserId } : {}),
|
||||
meetingTime: meetingValues.meetingTime.format('YYYY-MM-DD HH:mm:ss'),
|
||||
audioUrl,
|
||||
participants: meetingValues.participants?.join(','),
|
||||
tags: meetingValues.tags?.join(',')
|
||||
});
|
||||
message.success('会议发起成功');
|
||||
setCreateDrawerVisible(false);
|
||||
fetchData();
|
||||
} catch (err) {} finally {
|
||||
setSubmitLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const openEditParticipants = (meeting: MeetingVO) => {
|
||||
setEditingMeeting(meeting);
|
||||
|
|
@ -551,7 +338,7 @@ const Meetings: React.FC = () => {
|
|||
return (
|
||||
<div style={{ height: 'calc(100vh - 64px)', display: 'flex', flexDirection: 'column', background: 'var(--app-bg-page)', padding: '24px', overflow: 'hidden' }}>
|
||||
<div style={{ maxWidth: 1600, margin: '0 auto', width: '100%', height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<Card bordered={false} style={{ marginBottom: 20, borderRadius: 16, flexShrink: 0, boxShadow: 'var(--app-shadow)', background: 'var(--app-bg-card)', border: '1px solid var(--app-border-color)', backdropFilter: 'blur(16px)' }} bodyStyle={{ padding: '16px 28px' }}>
|
||||
<Card variant="borderless" style={{ marginBottom: 20, borderRadius: 16, flexShrink: 0, boxShadow: 'var(--app-shadow)', background: 'var(--app-bg-card)', border: '1px solid var(--app-border-color)', backdropFilter: 'blur(16px)' }} styles={{ body: { padding: '16px 28px' } }}>
|
||||
<Row justify="space-between" align="middle">
|
||||
<Col><Space size={12}><div style={{ width: 8, height: 24, background: '#1890ff', borderRadius: 4 }}></div><Title level={4} style={{ margin: 0 }}>会议中心</Title></Space></Col>
|
||||
<Col>
|
||||
|
|
@ -561,13 +348,9 @@ const Meetings: React.FC = () => {
|
|||
</Radio.Group>
|
||||
<Input placeholder="搜索会议标题" prefix={<SearchOutlined style={{ color: '#bfbfbf' }} />} allowClear onPressEnter={(e) => { setSearchTitle((e.target as any).value); setCurrent(1); }} style={{ width: 220, borderRadius: 8 }} />
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => {
|
||||
form.resetFields();
|
||||
setAudioUrl('');
|
||||
setUploadProgress(0);
|
||||
setFileList([]);
|
||||
setCreateDrawerType('upload');
|
||||
setCreateDrawerVisible(true);
|
||||
}} style={{ borderRadius: 8, height: 36, fontWeight: 500 }}>上传文件会议</Button>
|
||||
{can("menu:meeting") && <Button icon={<AudioOutlined />} onClick={() => navigate('/meeting-live-create')} style={{ borderRadius: 8, height: 36, fontWeight: 500 }}>实时识别会议</Button>}
|
||||
}} style={{ borderRadius: 8, height: 36, fontWeight: 500 }}>创建会议</Button>
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
|
|
@ -584,39 +367,20 @@ const Meetings: React.FC = () => {
|
|||
|
||||
{total > 0 && (
|
||||
<div style={{ flexShrink: 0, display: 'flex', justifyContent: 'center', padding: '16px 0 8px 0' }}>
|
||||
<Pagination current={current} pageSize={size} total={total} onChange={(p, s) => { setCurrent(p); setSize(s); }} showTotal={(total) => <Text type="secondary" size="small">为您找到 {total} 场会议</Text>} size="small" />
|
||||
<Pagination current={current} pageSize={size} total={total} onChange={(p, s) => { setCurrent(p); setSize(s); }} showTotal={(total) => <Text type="secondary" style={{ fontSize: 12 }}>为您找到 {total} 场会议</Text>} size="small" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Drawer
|
||||
title={<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}><div style={{ width: 4, height: 18, background: '#1890ff', borderRadius: 2 }} />发起新会议分析</div>}
|
||||
width="clamp(800px, 85vw, 1100px)"
|
||||
onClose={() => setCreateDrawerVisible(false)}
|
||||
<MeetingCreateDrawer
|
||||
open={createDrawerVisible}
|
||||
destroyOnClose
|
||||
styles={{ body: { background: 'var(--app-bg-page)', padding: '24px 32px' } }}
|
||||
footer={
|
||||
<div style={{ textAlign: 'right', padding: '12px 24px' }}>
|
||||
<Space size={12}>
|
||||
<Button onClick={() => setCreateDrawerVisible(false)} size="large" style={{ borderRadius: 8, minWidth: 100 }}>取消</Button>
|
||||
<Button type="primary" size="large" icon={<RocketOutlined />} loading={submitLoading} onClick={handleCreateSubmit} style={{ borderRadius: 8, minWidth: 160, fontWeight: 600, boxShadow: '0 4px 12px rgba(24, 144, 255, 0.3)' }}>
|
||||
开始分析
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<MeetingCreateForm
|
||||
form={form}
|
||||
audioUrl={audioUrl}
|
||||
setAudioUrl={setAudioUrl}
|
||||
uploadProgress={uploadProgress}
|
||||
setUploadProgress={setUploadProgress}
|
||||
fileList={fileList}
|
||||
setFileList={setFileList}
|
||||
initialType={createDrawerType}
|
||||
onCancel={() => setCreateDrawerVisible(false)}
|
||||
onSuccess={() => {
|
||||
setCreateDrawerVisible(false);
|
||||
fetchData();
|
||||
}}
|
||||
/>
|
||||
</Drawer>
|
||||
|
||||
<Modal
|
||||
title="编辑参会人"
|
||||
|
|
@ -624,7 +388,7 @@ const Meetings: React.FC = () => {
|
|||
onCancel={() => setParticipantsEditVisible(false)}
|
||||
onOk={handleUpdateParticipants}
|
||||
confirmLoading={participantsEditLoading}
|
||||
destroyOnClose
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={participantsEditForm} layout="vertical">
|
||||
<Form.Item
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, Button, Input, Space, Drawer, Form, Select, Tag, message, Popconfirm, Typography, Divider, Tooltip, Row, Col, List, Empty, Skeleton, Switch, Modal, Pagination } from 'antd';
|
||||
import { Card, Button, Input, Space, Drawer, Form, Select, Tag, Popconfirm, Typography, Divider, Tooltip, Row, Col, List, Empty, Skeleton, Switch, Modal, Pagination, App } from 'antd';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, CopyOutlined, SearchOutlined, SaveOutlined, StarFilled } from '@ant-design/icons';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { useDict } from '../../hooks/useDict';
|
||||
|
|
@ -19,6 +19,7 @@ const { Option } = Select;
|
|||
const { Text, Title } = Typography;
|
||||
|
||||
const PromptTemplates: React.FC = () => {
|
||||
const { message } = App.useApp();
|
||||
const { t } = useTranslation();
|
||||
const [form] = Form.useForm();
|
||||
const [searchForm] = Form.useForm();
|
||||
|
|
@ -222,20 +223,20 @@ const PromptTemplates: React.FC = () => {
|
|||
hoverable
|
||||
onClick={() => showDetail(item)}
|
||||
style={{ width: 320, borderRadius: 12, border: '1px solid var(--app-border-color)', background: 'var(--app-bg-card)', boxShadow: 'var(--app-shadow)', backdropFilter: 'blur(16px)', position: 'relative', overflow: 'hidden' }}
|
||||
bodyStyle={{ padding: '24px' }}
|
||||
styles={{ body: { padding: '24px' } }}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0, flex: 1 }}>
|
||||
<div style={{
|
||||
width: 40, height: 40, borderRadius: 10,
|
||||
background: isPlatformLevel ? 'color-mix(in srgb, #f5c542 14%, var(--app-bg-surface-strong))' : (isTenantLevel ? 'color-mix(in srgb, var(--app-primary-color) 12%, var(--app-bg-surface-strong))' : 'color-mix(in srgb, #13c2c2 12%, var(--app-bg-surface-strong))'),
|
||||
display: 'flex', justifyContent: 'center', alignItems: 'center'
|
||||
display: 'flex', justifyContent: 'center', alignItems: 'center', flexShrink: 0
|
||||
}}>
|
||||
<StarFilled style={{ fontSize: 20, color: isPlatformLevel ? '#faad14' : (isTenantLevel ? '#1890ff' : '#13c2c2') }} />
|
||||
</div>
|
||||
{levelTag}
|
||||
<div style={{ flexShrink: 0 }}>{levelTag}</div>
|
||||
</div>
|
||||
<Space onClick={e => e.stopPropagation()}>
|
||||
<Space onClick={e => e.stopPropagation()} style={{ flexShrink: 0, marginLeft: 8 }}>
|
||||
{canEdit && <EditOutlined style={{ fontSize: 18, color: '#bfbfbf', cursor: 'pointer' }} onClick={() => handleOpenDrawer(item)} />}
|
||||
<Switch
|
||||
size="small"
|
||||
|
|
@ -247,7 +248,7 @@ const PromptTemplates: React.FC = () => {
|
|||
</div>
|
||||
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Text strong style={{ fontSize: 16, display: 'block' }} ellipsis={{ tooltip: item.templateName }}>{item.templateName}</Text>
|
||||
<Text strong style={{ fontSize: 16, display: 'block', width: '100%' }} ellipsis={{ tooltip: item.templateName }}>{item.templateName}</Text>
|
||||
{/*<Text type="secondary" style={{ fontSize: 12 }}>使用次数: {item.usageCount || 0}</Text>*/}
|
||||
</div>
|
||||
|
||||
|
|
@ -255,7 +256,7 @@ const PromptTemplates: React.FC = () => {
|
|||
{item.tags?.map(tag => {
|
||||
const dictItem = dictTags.find(dt => dt.itemValue === tag);
|
||||
return (
|
||||
<Tag key={tag} style={{ margin: 0, border: '1px solid var(--app-border-color)', background: 'var(--app-bg-surface-soft)', color: 'var(--app-text-main)', borderRadius: 4, fontSize: 10 }}>
|
||||
<Tag key={tag} style={{ margin: 0, border: '1px solid var(--app-border-color)', background: 'var(--app-bg-surface-soft)', color: 'var(--app-text-main)', borderRadius: 4, fontSize: 10, maxWidth: 100, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{dictItem ? dictItem.itemLabel : tag}
|
||||
</Tag>
|
||||
);
|
||||
|
|
@ -295,7 +296,7 @@ const PromptTemplates: React.FC = () => {
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
<Card bordered={false} style={{ borderRadius: 12, marginBottom: 32, background: 'var(--app-bg-card)', border: '1px solid var(--app-border-color)', boxShadow: 'var(--app-shadow)', backdropFilter: 'blur(16px)' }} bodyStyle={{ padding: '20px 24px' }}>
|
||||
<Card variant="borderless" style={{ borderRadius: 12, marginBottom: 32, background: 'var(--app-bg-card)', border: '1px solid var(--app-border-color)', boxShadow: 'var(--app-shadow)', backdropFilter: 'blur(16px)' }} styles={{ body: { padding: '20px 24px' } }}>
|
||||
<Form form={searchForm} layout="inline" onFinish={fetchData}>
|
||||
<Form.Item name="name" label="模板名称"><Input placeholder="请输入..." style={{ width: 180 }} /></Form.Item>
|
||||
<Form.Item name="category" label="分类">
|
||||
|
|
@ -359,7 +360,7 @@ const PromptTemplates: React.FC = () => {
|
|||
<Button type="primary" icon={<SaveOutlined />} loading={submitLoading} onClick={handleSubmit}>保存</Button>
|
||||
</Space>
|
||||
}
|
||||
destroyOnClose
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Row gutter={24}>
|
||||
|
|
|
|||
|
|
@ -1,556 +0,0 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Avatar,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
DatePicker,
|
||||
Form,
|
||||
Input,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Spin,
|
||||
Statistic,
|
||||
Switch,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Typography,
|
||||
message,
|
||||
} from "antd";
|
||||
import {
|
||||
AudioOutlined,
|
||||
CheckCircleOutlined,
|
||||
LinkOutlined,
|
||||
MessageOutlined,
|
||||
QuestionCircleOutlined,
|
||||
RocketOutlined,
|
||||
TeamOutlined,
|
||||
UserOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import dayjs from "dayjs";
|
||||
import PageHeader from "../../components/shared/PageHeader";
|
||||
import { listUsers } from "../../api";
|
||||
import { getAiModelDefault, getAiModelPage, type AiModelVO } from "../../api/business/aimodel";
|
||||
import { getHotWordPage, type HotWordVO } from "../../api/business/hotword";
|
||||
import { getPromptPage, type PromptTemplateVO } from "../../api/business/prompt";
|
||||
import { createRealtimeMeeting, type CreateRealtimeMeetingCommand } from "../../api/business/meeting";
|
||||
import type { SysUser } from "../../types";
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { Option } = Select;
|
||||
|
||||
type RealtimeMeetingSessionDraft = {
|
||||
meetingId: number;
|
||||
meetingTitle: string;
|
||||
asrModelName: string;
|
||||
summaryModelName: string;
|
||||
asrModelId: number;
|
||||
mode: string;
|
||||
language: string;
|
||||
useSpkId: number;
|
||||
enablePunctuation: boolean;
|
||||
enableItn: boolean;
|
||||
enableTextRefine: boolean;
|
||||
saveAudio: boolean;
|
||||
hotwords: Array<{ hotword: string; weight: number }>;
|
||||
};
|
||||
|
||||
function resolveWsUrl(model?: AiModelVO | null) {
|
||||
if (model?.wsUrl) {
|
||||
return model.wsUrl;
|
||||
}
|
||||
if (model?.baseUrl) {
|
||||
return model.baseUrl.replace(/^http:\/\//, "ws://").replace(/^https:\/\//, "wss://");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function buildRealtimeProxyPreviewUrl() {
|
||||
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
|
||||
return `${protocol}://${window.location.host}/ws/meeting/realtime`;
|
||||
}
|
||||
|
||||
function getSessionKey(meetingId: number) {
|
||||
return `realtimeMeetingSession:${meetingId}`;
|
||||
}
|
||||
|
||||
export default function RealtimeAsr() {
|
||||
const navigate = useNavigate();
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [asrModels, setAsrModels] = useState<AiModelVO[]>([]);
|
||||
const [llmModels, setLlmModels] = useState<AiModelVO[]>([]);
|
||||
const [prompts, setPrompts] = useState<PromptTemplateVO[]>([]);
|
||||
const [hotwordList, setHotwordList] = useState<HotWordVO[]>([]);
|
||||
const [userList, setUserList] = useState<SysUser[]>([]);
|
||||
|
||||
const watchedAsrModelId = Form.useWatch("asrModelId", form);
|
||||
const watchedSummaryModelId = Form.useWatch("summaryModelId", form);
|
||||
const watchedHotWords = Form.useWatch("hotWords", form) || [];
|
||||
const watchedParticipants = Form.useWatch("participants", form) || [];
|
||||
const watchedUseSpkId = Form.useWatch("useSpkId", form);
|
||||
|
||||
const selectedAsrModel = useMemo(
|
||||
() => asrModels.find((item) => item.id === watchedAsrModelId) || null,
|
||||
[asrModels, watchedAsrModelId],
|
||||
);
|
||||
const selectedSummaryModel = useMemo(
|
||||
() => llmModels.find((item) => item.id === watchedSummaryModelId) || null,
|
||||
[llmModels, watchedSummaryModelId],
|
||||
);
|
||||
const selectedHotwordCount = watchedHotWords.length > 0 ? watchedHotWords.length : hotwordList.length;
|
||||
|
||||
useEffect(() => {
|
||||
const loadInitialData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [asrRes, llmRes, promptRes, hotwordRes, users, defaultAsr, defaultLlm] = await Promise.all([
|
||||
getAiModelPage({ current: 1, size: 100, type: "ASR" }),
|
||||
getAiModelPage({ current: 1, size: 100, type: "LLM" }),
|
||||
getPromptPage({ current: 1, size: 100 }),
|
||||
getHotWordPage({ current: 1, size: 1000 }),
|
||||
listUsers(),
|
||||
getAiModelDefault("ASR"),
|
||||
getAiModelDefault("LLM"),
|
||||
]);
|
||||
|
||||
const activeAsrModels = asrRes.data.data.records.filter((item) => item.status === 1);
|
||||
const activeLlmModels = llmRes.data.data.records.filter((item) => item.status === 1);
|
||||
const activePrompts = promptRes.data.data.records.filter((item) => item.status === 1);
|
||||
const activeHotwords = hotwordRes.data.data.records.filter((item) => item.status === 1);
|
||||
|
||||
setAsrModels(activeAsrModels);
|
||||
setLlmModels(activeLlmModels);
|
||||
setPrompts(activePrompts);
|
||||
setHotwordList(activeHotwords);
|
||||
setUserList(users || []);
|
||||
|
||||
form.setFieldsValue({
|
||||
title: `实时会议 ${dayjs().format("MM-DD HH:mm")}`,
|
||||
meetingTime: dayjs(),
|
||||
asrModelId: defaultAsr.data.data?.id,
|
||||
summaryModelId: defaultLlm.data.data?.id,
|
||||
promptId: activePrompts[0]?.id,
|
||||
useSpkId: 1,
|
||||
mode: "2pass",
|
||||
language: "auto",
|
||||
enablePunctuation: true,
|
||||
enableItn: true,
|
||||
enableTextRefine: false,
|
||||
saveAudio: false,
|
||||
});
|
||||
} catch {
|
||||
message.error("加载实时会议配置失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void loadInitialData();
|
||||
}, [form]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
const values = await form.validateFields();
|
||||
const wsUrl = resolveWsUrl(selectedAsrModel);
|
||||
if (!wsUrl) {
|
||||
message.error("当前 ASR 模型没有配置 WebSocket 地址");
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const selectedHotwords = (values.hotWords?.length
|
||||
? hotwordList.filter((item) => values.hotWords.includes(item.word))
|
||||
: hotwordList
|
||||
).map((item) => ({
|
||||
hotword: item.word,
|
||||
weight: Number(item.weight || 2) / 10,
|
||||
}));
|
||||
|
||||
const { hostUserId, ...meetingValues } = values;
|
||||
const payload: CreateRealtimeMeetingCommand = {
|
||||
...meetingValues,
|
||||
...(hostUserId != null ? { hostUserId } : {}),
|
||||
meetingTime: meetingValues.meetingTime.format("YYYY-MM-DD HH:mm:ss"),
|
||||
participants: meetingValues.participants?.join(",") || "",
|
||||
tags: meetingValues.tags?.join(",") || "",
|
||||
mode: meetingValues.mode || "2pass",
|
||||
language: meetingValues.language || "auto",
|
||||
useSpkId: meetingValues.useSpkId ? 1 : 0,
|
||||
enablePunctuation: meetingValues.enablePunctuation !== false,
|
||||
enableItn: meetingValues.enableItn !== false,
|
||||
enableTextRefine: !!meetingValues.enableTextRefine,
|
||||
saveAudio: !!meetingValues.saveAudio,
|
||||
hotWords: meetingValues.hotWords,
|
||||
};
|
||||
|
||||
const res = await createRealtimeMeeting(payload);
|
||||
const createdMeeting = res.data.data;
|
||||
const sessionDraft: RealtimeMeetingSessionDraft = {
|
||||
meetingId: createdMeeting.id,
|
||||
meetingTitle: createdMeeting.title,
|
||||
asrModelName: selectedAsrModel?.modelName || "ASR",
|
||||
summaryModelName: selectedSummaryModel?.modelName || "LLM",
|
||||
asrModelId: selectedAsrModel?.id || values.asrModelId,
|
||||
mode: values.mode || "2pass",
|
||||
language: values.language || "auto",
|
||||
useSpkId: values.useSpkId ? 1 : 0,
|
||||
enablePunctuation: values.enablePunctuation !== false,
|
||||
enableItn: values.enableItn !== false,
|
||||
enableTextRefine: !!values.enableTextRefine,
|
||||
saveAudio: !!values.saveAudio,
|
||||
hotwords: selectedHotwords,
|
||||
};
|
||||
|
||||
sessionStorage.setItem(getSessionKey(createdMeeting.id), JSON.stringify(sessionDraft));
|
||||
message.success("会议已创建,进入实时识别");
|
||||
navigate(`/meeting-live-session/${createdMeeting.id}`);
|
||||
} catch {
|
||||
message.error("创建实时会议失败");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ height: "100%", display: "flex", flexDirection: "column", overflow: "hidden" }}>
|
||||
<PageHeader title="实时识别会议" subtitle="先完成配置,再进入会中识别页面,减少会中页面干扰。" />
|
||||
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: "hidden" }}>
|
||||
{loading ? (
|
||||
<Card bordered={false} style={{ borderRadius: 18 }}>
|
||||
<div style={{ textAlign: "center", padding: "88px 0" }}>
|
||||
<Spin />
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<Row gutter={16} style={{ height: "100%" }}>
|
||||
<Col xs={24} xl={17} style={{ height: "100%" }}>
|
||||
<Card
|
||||
bordered={false}
|
||||
style={{ height: "100%", borderRadius: 18, boxShadow: "0 8px 22px rgba(15,23,42,0.05)" }}
|
||||
bodyStyle={{ height: "100%", padding: 16, display: "flex", flexDirection: "column" }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 12,
|
||||
padding: 14,
|
||||
borderRadius: 16,
|
||||
background: "linear-gradient(135deg, #f8fbff 0%, #eef6ff 58%, #ffffff 100%)",
|
||||
border: "1px solid #dbeafe",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Space direction="vertical" size={6}>
|
||||
<Space size={10}>
|
||||
<div
|
||||
style={{
|
||||
width: 42,
|
||||
height: 42,
|
||||
borderRadius: 12,
|
||||
background: "#1677ff",
|
||||
color: "#fff",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<AudioOutlined />
|
||||
</div>
|
||||
<div>
|
||||
<Title level={4} style={{ margin: 0 }}>
|
||||
创建实时会议
|
||||
</Title>
|
||||
<Text type="secondary">会前完成配置后再进入会中识别页。</Text>
|
||||
</div>
|
||||
</Space>
|
||||
<Space wrap size={[8, 8]}>
|
||||
<Tag color="blue">单页配置</Tag>
|
||||
<Tag color="cyan">会中专注转写</Tag>
|
||||
<Tag color="gold">异常关闭自动兜底</Tag>
|
||||
</Space>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={{
|
||||
mode: "2pass",
|
||||
language: "auto",
|
||||
useSpkId: 1,
|
||||
enablePunctuation: true,
|
||||
enableItn: true,
|
||||
enableTextRefine: false,
|
||||
saveAudio: false,
|
||||
}}
|
||||
style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}
|
||||
>
|
||||
<div style={{ flex: 1, minHeight: 0 }}>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="title" label="会议标题" rules={[{ required: true, message: "请输入会议标题" }]}>
|
||||
<Input placeholder="例如:产品例会实时记录" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item
|
||||
name="meetingTime"
|
||||
label="会议时间"
|
||||
rules={[{ required: true, message: "请选择会议时间" }]}
|
||||
>
|
||||
<DatePicker showTime style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="participants" label="参会人员">
|
||||
<Select mode="multiple" showSearch optionFilterProp="children" placeholder="选择参会人员">
|
||||
{userList.map((user) => (
|
||||
<Option key={user.userId} value={user.userId}>
|
||||
<Space>
|
||||
<Avatar size="small" icon={<UserOutlined />} />
|
||||
{user.displayName || user.username}
|
||||
</Space>
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="hostUserId" label="会议主持人">
|
||||
<Select allowClear showSearch optionFilterProp="children" placeholder="不选择则默认为创建人">
|
||||
{userList.map((user) => (
|
||||
<Option key={user.userId} value={user.userId}>
|
||||
<Space>
|
||||
<Avatar size="small" icon={<UserOutlined />} />
|
||||
{user.displayName || user.username}
|
||||
</Space>
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="tags" label="会议标签">
|
||||
<Select mode="tags" placeholder="输入标签后回车" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item
|
||||
name="asrModelId"
|
||||
label="识别模型 (ASR)"
|
||||
rules={[{ required: true, message: "请选择 ASR 模型" }]}
|
||||
>
|
||||
<Select placeholder="选择实时识别模型">
|
||||
{asrModels.map((model) => (
|
||||
<Option key={model.id} value={model.id}>
|
||||
{model.modelName}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item
|
||||
name="summaryModelId"
|
||||
label="总结模型 (LLM)"
|
||||
rules={[{ required: true, message: "请选择总结模型" }]}
|
||||
>
|
||||
<Select placeholder="选择总结模型">
|
||||
{llmModels.map((model) => (
|
||||
<Option key={model.id} value={model.id}>
|
||||
{model.modelName}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item
|
||||
name="promptId"
|
||||
label="总结模板"
|
||||
rules={[{ required: true, message: "请选择总结模板" }]}
|
||||
>
|
||||
<Select placeholder="选择总结模板">
|
||||
{prompts.map((prompt) => (
|
||||
<Option key={prompt.id} value={prompt.id}>
|
||||
{prompt.templateName}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item
|
||||
name="hotWords"
|
||||
label={
|
||||
<span>
|
||||
热词增强{" "}
|
||||
<Tooltip title="不选择时将带上系统当前启用的热词">
|
||||
<QuestionCircleOutlined />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Select mode="multiple" allowClear placeholder="可选热词">
|
||||
{hotwordList.map((item) => (
|
||||
<Option key={item.word} value={item.word}>
|
||||
{item.word}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Form.Item name="language" hidden>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="enablePunctuation" hidden valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item name="enableItn" hidden valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item name="saveAudio" hidden valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
<Row gutter={16} align="middle">
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="mode" label="识别模式">
|
||||
<Select>
|
||||
<Option value="2pass">2pass</Option>
|
||||
<Option value="online">online</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item
|
||||
name="useSpkId"
|
||||
label={
|
||||
<span>
|
||||
说话人区分
|
||||
<Tooltip title="开启后会尝试区分不同发言人">
|
||||
<QuestionCircleOutlined />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
valuePropName="checked"
|
||||
getValueProps={(value) => ({ checked: value === 1 || value === true })}
|
||||
normalize={(value) => (value ? 1 : 0)}
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<Form.Item label="WebSocket 地址">
|
||||
<Input value={buildRealtimeProxyPreviewUrl()} prefix={<LinkOutlined />} readOnly />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<Form.Item name="enableTextRefine" label="文本修正" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} xl={7} style={{ height: "100%" }}>
|
||||
<Card
|
||||
bordered={false}
|
||||
style={{ height: "100%", borderRadius: 18, boxShadow: "0 8px 22px rgba(15,23,42,0.05)" }}
|
||||
bodyStyle={{ height: "100%", padding: 16, display: "flex", flexDirection: "column" }}
|
||||
>
|
||||
<Row gutter={[12, 12]} style={{ marginBottom: 12 }}>
|
||||
<Col span={12}>
|
||||
<Statistic title="参会人数" value={watchedParticipants.length} prefix={<TeamOutlined />} />
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Statistic title="热词数量" value={selectedHotwordCount} prefix={<MessageOutlined />} />
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Statistic
|
||||
title="说话人区分"
|
||||
value={watchedUseSpkId ? "开启" : "关闭"}
|
||||
prefix={<CheckCircleOutlined />}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Statistic
|
||||
title="识别链路"
|
||||
value={selectedAsrModel ? "已就绪" : "待配置"}
|
||||
prefix={<AudioOutlined />}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Space direction="vertical" size={12} style={{ width: "100%", flex: 1, minHeight: 0 }}>
|
||||
<div>
|
||||
<Text strong style={{ fontSize: 15 }}>
|
||||
本次识别摘要
|
||||
</Text>
|
||||
</div>
|
||||
<div style={{ padding: 14, borderRadius: 14, background: "#fafcff", border: "1px solid #edf2ff" }}>
|
||||
<Space direction="vertical" size={8} style={{ width: "100%" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between" }}>
|
||||
<Text type="secondary">ASR</Text>
|
||||
<Text strong>{selectedAsrModel?.modelName || "-"}</Text>
|
||||
</div>
|
||||
<div style={{ display: "flex", justifyContent: "space-between" }}>
|
||||
<Text type="secondary">LLM</Text>
|
||||
<Text strong>{selectedSummaryModel?.modelName || "-"}</Text>
|
||||
</div>
|
||||
<div style={{ display: "flex", justifyContent: "space-between" }}>
|
||||
<Text type="secondary">WebSocket</Text>
|
||||
<Text ellipsis style={{ maxWidth: 220 }}>
|
||||
{buildRealtimeProxyPreviewUrl()}
|
||||
</Text>
|
||||
</div>
|
||||
</Space>
|
||||
</div>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="异常关闭保护"
|
||||
description="会中页持续写库,并在关闭时自动兜底结束。"
|
||||
/>
|
||||
<div style={{ marginTop: "auto", padding: 12, borderRadius: 14, background: "#f6ffed", border: "1px solid #b7eb8f" }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-end", gap: 10 }}>
|
||||
<Text type="secondary">创建成功后会直接进入识别页,不会在当前页占用麦克风。</Text>
|
||||
<Space>
|
||||
<Button onClick={() => navigate("/meetings")}>返回会议中心</Button>
|
||||
<Button type="primary" icon={<RocketOutlined />} loading={submitting} onClick={() => void handleCreate()}>
|
||||
创建并进入识别
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,19 +1,5 @@
|
|||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Avatar,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Empty,
|
||||
Row,
|
||||
Space,
|
||||
Statistic,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from "antd";
|
||||
import { Alert, Avatar, Badge, Button, Card, Col, Empty, Row, Space, Statistic, Tag, Typography, App } from 'antd';
|
||||
import {
|
||||
AudioOutlined,
|
||||
ClockCircleOutlined,
|
||||
|
|
@ -201,6 +187,7 @@ function normalizeWsMessage(payload: WsMessage) {
|
|||
}
|
||||
|
||||
export function RealtimeAsrSession() {
|
||||
const { message } = App.useApp();
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const meetingId = Number(id);
|
||||
|
|
@ -639,7 +626,7 @@ export function RealtimeAsrSession() {
|
|||
if (loading) {
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<Card bordered={false} style={{ borderRadius: 18 }}>
|
||||
<Card variant="borderless" style={{ borderRadius: 18 }}>
|
||||
<div style={{ textAlign: "center", padding: "96px 0" }}>
|
||||
<SyncOutlined spin />
|
||||
</div>
|
||||
|
|
@ -651,7 +638,7 @@ export function RealtimeAsrSession() {
|
|||
if (!meeting) {
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<Card bordered={false} style={{ borderRadius: 18 }}>
|
||||
<Card variant="borderless" style={{ borderRadius: 18 }}>
|
||||
<Empty description="会议不存在" />
|
||||
</Card>
|
||||
</div>
|
||||
|
|
@ -742,22 +729,22 @@ export function RealtimeAsrSession() {
|
|||
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: "hidden" }}>
|
||||
{!sessionDraft ? (
|
||||
<Card bordered={false} style={{ borderRadius: 18 }}>
|
||||
<Card variant="borderless" style={{ borderRadius: 18 }}>
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="缺少实时识别启动配置"
|
||||
description="这个会议的实时识别配置没有保存在当前浏览器中,请返回创建页重新进入。"
|
||||
action={<Button size="small" onClick={() => navigate("/meeting-live-create")}>返回创建页</Button>}
|
||||
action={<Button size="small" onClick={() => navigate("/meetings?action=create&type=realtime")}>返回创建页</Button>}
|
||||
/>
|
||||
</Card>
|
||||
) : (
|
||||
<Row gutter={16} style={{ height: "100%" }}>
|
||||
<Col xs={24} xl={7} style={{ height: "100%" }}>
|
||||
<Card
|
||||
bordered={false}
|
||||
variant="borderless"
|
||||
style={{ height: "100%", borderRadius: 18, boxShadow: "0 8px 22px rgba(15,23,42,0.05)" }}
|
||||
bodyStyle={{ height: "100%", padding: 16, display: "flex", flexDirection: "column" }}
|
||||
styles={{ body: { height: "100%", padding: 16, display: "flex", flexDirection: "column" } }}
|
||||
>
|
||||
<Space direction="vertical" size={16} style={{ width: "100%" }}>
|
||||
<div style={{ padding: 14, borderRadius: 16, background: "linear-gradient(135deg, #0f172a 0%, #1e40af 60%, #60a5fa 100%)", color: "#fff" }}>
|
||||
|
|
@ -809,7 +796,7 @@ export function RealtimeAsrSession() {
|
|||
</Col>
|
||||
|
||||
<Col xs={24} xl={17} style={{ height: "100%" }}>
|
||||
<Card bordered={false} style={{ borderRadius: 18, boxShadow: "0 8px 22px rgba(15,23,42,0.05)", height: "100%" }} bodyStyle={{ padding: 0, height: "100%", display: "flex", flexDirection: "column" }}>
|
||||
<Card variant="borderless" style={{ borderRadius: 18, boxShadow: "0 8px 22px rgba(15,23,42,0.05)", height: "100%" }} styles={{ body: { padding: 0, height: "100%", display: "flex", flexDirection: "column" } }}>
|
||||
<div style={{ padding: "16px 20px", borderBottom: "1px solid #f0f0f0", display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, flexShrink: 0 }}>
|
||||
<div>
|
||||
<Title level={4} style={{ margin: 0 }}>实时转写流</Title>
|
||||
|
|
|
|||
|
|
@ -14,29 +14,7 @@ import {
|
|||
SoundOutlined,
|
||||
SafetyCertificateOutlined
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Col,
|
||||
Empty,
|
||||
Form,
|
||||
Input,
|
||||
List,
|
||||
message,
|
||||
Pagination,
|
||||
Popconfirm,
|
||||
Progress,
|
||||
Row,
|
||||
Select,
|
||||
Spin,
|
||||
Space,
|
||||
Tabs,
|
||||
Tag,
|
||||
Typography,
|
||||
Upload,
|
||||
Tooltip,
|
||||
Divider
|
||||
} from 'antd';
|
||||
import { Badge, Button, Col, Empty, Form, Input, List, Pagination, Popconfirm, Progress, Row, Select, Spin, Space, Tabs, Tag, Typography, Upload, Tooltip, Divider, App } from 'antd';
|
||||
import type { UploadProps } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import { listUsers } from '../../api';
|
||||
|
|
@ -53,6 +31,7 @@ const DEFAULT_DURATION = 10;
|
|||
const DEFAULT_PAGE_SIZE = 8;
|
||||
|
||||
const SpeakerReg: React.FC = () => {
|
||||
const { message } = App.useApp();
|
||||
const [form] = Form.useForm();
|
||||
const [recording, setRecording] = useState(false);
|
||||
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
|
||||
|
|
@ -119,8 +98,9 @@ const SpeakerReg: React.FC = () => {
|
|||
size,
|
||||
name: name || undefined
|
||||
});
|
||||
const records = res.data?.data?.records || [];
|
||||
const nextTotal = res.data?.data?.total || 0;
|
||||
const payload: any = res.data || res;
|
||||
const records = payload?.data?.records || payload?.records || [];
|
||||
const nextTotal = payload?.data?.total || payload?.total || 0;
|
||||
|
||||
if (page > 1 && records.length === 0 && nextTotal > 0) {
|
||||
setCurrent(page - 1);
|
||||
|
|
@ -568,8 +548,15 @@ const SpeakerReg: React.FC = () => {
|
|||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Tabs defaultActiveKey="1" className="custom-tabs" size="middle">
|
||||
<Tabs.TabPane tab={<span><AudioOutlined /> 实时录制采集</span>} key="1">
|
||||
<Tabs
|
||||
defaultActiveKey="1"
|
||||
className="custom-tabs"
|
||||
size="middle"
|
||||
items={[
|
||||
{
|
||||
key: "1",
|
||||
label: <span><AudioOutlined /> 实时录制采集</span>,
|
||||
children: (
|
||||
<div className="recording-area">
|
||||
<div className="script-box">
|
||||
<Text strong style={{ fontSize: 13, display: 'block', marginBottom: 6, opacity: 0.6 }}>录音文本内容:</Text>
|
||||
|
|
@ -594,13 +581,16 @@ const SpeakerReg: React.FC = () => {
|
|||
showInfo={false}
|
||||
strokeColor="var(--accent-blue)"
|
||||
size="small"
|
||||
strokeWidth={6}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab={<span><CloudUploadOutlined /> 离线文件上传</span>} key="2">
|
||||
)
|
||||
},
|
||||
{
|
||||
key: "2",
|
||||
label: <span><CloudUploadOutlined /> 离线文件上传</span>,
|
||||
children: (
|
||||
<Upload {...uploadProps} accept="audio/*" style={{ width: '100%' }}>
|
||||
<div className="upload-compact">
|
||||
<CloudUploadOutlined style={{ fontSize: 32, color: 'var(--accent-blue)', marginBottom: 12 }} />
|
||||
|
|
@ -608,8 +598,10 @@ const SpeakerReg: React.FC = () => {
|
|||
<Text type="secondary" style={{ fontSize: 12 }}>支持 MP3 / WAV / M4A,建议时长 5-15秒</Text>
|
||||
</div>
|
||||
</Upload>
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
|
||||
{audioUrl && (
|
||||
<div style={{ background: 'var(--app-bg-surface-soft)', padding: 12, borderRadius: 12, border: '1px solid var(--accent-blue)' }}>
|
||||
|
|
|
|||
|
|
@ -142,7 +142,7 @@ export const Dashboard: React.FC = () => {
|
|||
<Row gutter={24} style={{ marginBottom: 24 }}>
|
||||
{statCards.map((s, idx) => (
|
||||
<Col span={6} key={idx}>
|
||||
<Card bordered={false} style={{ borderRadius: 16, boxShadow: 'var(--app-shadow)', background: 'var(--app-bg-card)', border: '1px solid var(--app-border-color)', backdropFilter: 'blur(16px)' }}>
|
||||
<Card variant="borderless" style={{ borderRadius: 16, boxShadow: 'var(--app-shadow)', background: 'var(--app-bg-card)', border: '1px solid var(--app-border-color)', backdropFilter: 'blur(16px)' }}>
|
||||
<Statistic
|
||||
title={<Text type="secondary" style={{ fontSize: 13 }}>{s.label}</Text>}
|
||||
value={s.value || 0}
|
||||
|
|
@ -161,7 +161,7 @@ export const Dashboard: React.FC = () => {
|
|||
<Button type="link" onClick={() => navigate('/meetings')}>查看历史记录</Button>
|
||||
</div>
|
||||
}
|
||||
bordered={false}
|
||||
variant="borderless"
|
||||
style={{ borderRadius: 16, boxShadow: 'var(--app-shadow)', background: 'var(--app-bg-card)', border: '1px solid var(--app-border-color)', backdropFilter: 'blur(16px)' }}
|
||||
>
|
||||
<List
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Button, Card, Drawer, Form, Input, Popconfirm, Select, Space, Table, Tag, Typography, message } from "antd";
|
||||
import { Button, Card, Drawer, Form, Input, Popconfirm, Select, Space, Table, Tag, Typography, App } from 'antd';
|
||||
import { DeleteOutlined, DesktopOutlined, EditOutlined, PlusOutlined, SearchOutlined, UserOutlined } from "@ant-design/icons";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
|
@ -20,6 +20,7 @@ type DeviceFormValues = {
|
|||
};
|
||||
|
||||
export default function Devices() {
|
||||
const { message } = App.useApp();
|
||||
const { t } = useTranslation();
|
||||
const { can } = usePermission();
|
||||
const { items: statusDict } = useDict("sys_common_status");
|
||||
|
|
@ -246,7 +247,7 @@ export default function Devices() {
|
|||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
width={420}
|
||||
destroyOnClose
|
||||
destroyOnHidden
|
||||
footer={
|
||||
<div className="app-page__drawer-footer">
|
||||
<Button onClick={() => setOpen(false)}>{t("common.cancel")}</Button>
|
||||
|
|
|
|||
|
|
@ -5,13 +5,14 @@
|
|||
|
||||
.home-container {
|
||||
position: relative;
|
||||
min-height: calc(100vh - 160px);
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
padding: clamp(24px, 4vw, 40px) clamp(24px, 5vw, 60px);
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f1f5f9 100%);
|
||||
color: @home-text-main;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
overflow-x: hidden; // 允许垂直滚动
|
||||
margin: -24px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -76,12 +77,36 @@
|
|||
margin-bottom: 56px !important;
|
||||
color: @home-text-main !important;
|
||||
letter-spacing: -0.02em; // Tighter tracking
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.home-title-accent-wrapper {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
height: 1.2em; /* fixed height for scroller */
|
||||
overflow: hidden;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
.home-title-accent-scroller {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.home-title-accent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 1.2em; /* strictly matched to wrapper height */
|
||||
line-height: 1.2em;
|
||||
white-space: nowrap;
|
||||
background: linear-gradient(90deg, #4f46e5, #7c3aed, #2563eb); // Deeper, more elegant gradient
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -91,20 +116,37 @@
|
|||
flex-wrap: wrap;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
gap: 32px;
|
||||
gap: 24px;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
|
||||
.home-action-item {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.home-action-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
cursor: pointer;
|
||||
max-width: 240px;
|
||||
width: 100%;
|
||||
max-width: 280px;
|
||||
flex: 1;
|
||||
min-width: 240px;
|
||||
padding: 32px 24px;
|
||||
border-radius: 20px;
|
||||
background: var(--action-bg-gradient);
|
||||
transition: transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.4s ease;
|
||||
|
||||
&:hover {
|
||||
.home-action-icon-wrapper {
|
||||
transform: translateY(-6px);
|
||||
box-shadow: 0 20px 40px -10px var(--action-shadow);
|
||||
|
||||
.home-action-icon-wrapper {
|
||||
transform: translateY(-4px) scale(1.05);
|
||||
}
|
||||
.home-action-icon {
|
||||
box-shadow:
|
||||
|
|
@ -116,27 +158,47 @@
|
|||
opacity: 0.8;
|
||||
transform: translateY(14px) scale(0.9);
|
||||
}
|
||||
.home-action-title {
|
||||
color: @home-primary !important;
|
||||
}
|
||||
}
|
||||
|
||||
&--violet {
|
||||
--action-color: #7c3aed;
|
||||
--action-bg: rgba(124, 58, 237, 0.15);
|
||||
--action-bg: #8b5cf6;
|
||||
--action-bg-gradient: linear-gradient(135deg, rgba(237, 233, 254, 0.8) 0%, rgba(2ede, 233, 254, 0.4) 100%);
|
||||
--action-shadow: rgba(124, 58, 237, 0.15);
|
||||
--action-badge-bg: linear-gradient(90deg, #8b5cf6, #a78bfa);
|
||||
--action-icon-bg: linear-gradient(135deg, #7c3aed, #a78bfa);
|
||||
background: linear-gradient(180deg, #f3f0ff 0%, #fdfcff 100%);
|
||||
}
|
||||
|
||||
&--cyan {
|
||||
--action-color: #0284c7;
|
||||
--action-bg: rgba(2, 132, 199, 0.15);
|
||||
--action-bg: #0ea5e9;
|
||||
--action-bg-gradient: linear-gradient(135deg, rgba(224, 242, 254, 0.8) 0%, rgba(224, 242, 254, 0.4) 100%);
|
||||
--action-shadow: rgba(2, 132, 199, 0.15);
|
||||
--action-badge-bg: linear-gradient(90deg, #0ea5e9, #38bdf8);
|
||||
--action-icon-bg: linear-gradient(135deg, #0284c7, #38bdf8);
|
||||
background: linear-gradient(180deg, #e0f2fe 0%, #f0f9ff 100%);
|
||||
}
|
||||
|
||||
.home-action-badge {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
padding: 6px 16px;
|
||||
background: var(--action-badge-bg);
|
||||
color: white;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
border-radius: 0 20px 0 20px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.home-action-icon-wrapper {
|
||||
position: relative;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin-bottom: 24px;
|
||||
transition: transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1); // Springy bounce
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
margin-bottom: 32px;
|
||||
transition: transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
.home-action-icon {
|
||||
|
|
@ -147,19 +209,40 @@
|
|||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 28px;
|
||||
color: var(--action-color);
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f8fafc 100%);
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.8);
|
||||
// Apple-like nested shadow
|
||||
font-size: 32px;
|
||||
color: white;
|
||||
background: var(--action-icon-bg);
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
box-shadow:
|
||||
0 4px 6px -1px rgba(0, 0, 0, 0.05),
|
||||
0 2px 4px -2px rgba(0, 0, 0, 0.05),
|
||||
inset 0 1px 0 rgba(255,255,255,1);
|
||||
0 8px 16px -4px var(--action-shadow),
|
||||
inset 0 2px 4px rgba(255, 255, 255, 0.3);
|
||||
transition: box-shadow 0.4s ease;
|
||||
}
|
||||
|
||||
.home-action-icon-circle {
|
||||
position: absolute;
|
||||
bottom: -8px;
|
||||
right: -12px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
z-index: 3;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: var(--action-color);
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.home-action-icon-glow {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
|
|
@ -176,23 +259,23 @@
|
|||
}
|
||||
|
||||
.home-action-title {
|
||||
font-size: 20px !important;
|
||||
font-size: 24px !important;
|
||||
font-weight: 700 !important;
|
||||
margin-bottom: 12px !important;
|
||||
color: @home-text-main !important;
|
||||
margin-bottom: 16px !important;
|
||||
color: #1e1e38 !important;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
.home-action-desc {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.home-action-line {
|
||||
font-size: 14px;
|
||||
color: @home-text-gray;
|
||||
line-height: 1.6;
|
||||
font-size: 15px;
|
||||
color: #5a5a72;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -232,93 +315,136 @@
|
|||
}
|
||||
|
||||
.home-recent-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
background: rgba(255, 255, 255, 0.7); // Glassmorphism base
|
||||
backdrop-filter: blur(20px);
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
flex-direction: column;
|
||||
min-height: 140px;
|
||||
padding: 20px 24px 20px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
// Sophisticated inner and outer shadows
|
||||
box-shadow:
|
||||
0 4px 6px -1px rgba(0, 0, 0, 0.02),
|
||||
0 2px 4px -2px rgba(0, 0, 0, 0.02),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.6);
|
||||
border: 1px solid rgba(255, 255, 255, 0.8);
|
||||
overflow: hidden;
|
||||
border-radius: 20px;
|
||||
border: none;
|
||||
background: linear-gradient(180deg, #f9f8fe 0%, #f3f2fa 100%);
|
||||
box-shadow: 0 8px 28px rgba(113, 107, 151, 0.08);
|
||||
transition: transform 0.28s ease, box-shadow 0.28s ease, border-color 0.28s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-color: rgba(99, 102, 241, 0.15);
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(0, 0, 0, 0.05),
|
||||
0 8px 10px -6px rgba(0, 0, 0, 0.01),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.8);
|
||||
box-shadow: 0 14px 34px rgba(113, 107, 151, 0.12);
|
||||
|
||||
.home-recent-card-action {
|
||||
color: @home-primary;
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
.home-recent-card-icon {
|
||||
transform: translateX(2px);
|
||||
box-shadow: 0 10px 22px rgba(118, 105, 255, 0.12);
|
||||
}
|
||||
}
|
||||
|
||||
.home-recent-card-thumbnail {
|
||||
width: 100px;
|
||||
height: 72px;
|
||||
background: linear-gradient(135deg, #e0e7ff 0%, #dbeafe 100%);
|
||||
border-radius: 10px;
|
||||
&:focus-visible {
|
||||
outline: 3px solid rgba(126, 105, 255, 0.22);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: auto -92px -136px auto;
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(133, 115, 255, 0.1) 0%, rgba(133, 115, 255, 0) 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.home-recent-card-dot {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(180deg, #ff8f8f 0%, #f56f6f 100%);
|
||||
box-shadow: 0 0 0 4px rgba(249, 248, 254, 0.96);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.home-recent-card-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.home-recent-card-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 16px;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
border-radius: 12px;
|
||||
background: #efedf8;
|
||||
color: #8a80ff;
|
||||
box-shadow: none;
|
||||
transition: transform 0.28s ease, box-shadow 0.28s ease;
|
||||
|
||||
.home-recent-card-play-icon {
|
||||
font-size: 24px;
|
||||
color: #fff;
|
||||
opacity: 0.9;
|
||||
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.1));
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.home-recent-card-content {
|
||||
.home-recent-card-tags {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 10px;
|
||||
margin-bottom: auto;
|
||||
padding-right: 12px;
|
||||
}
|
||||
|
||||
.home-recent-card-tag {
|
||||
margin: 0;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
background: #eceaf7;
|
||||
color: #6f66f0;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.home-recent-card-title {
|
||||
font-size: 15px !important;
|
||||
font-weight: 600 !important;
|
||||
margin-bottom: 8px !important;
|
||||
color: @home-text-main !important;
|
||||
white-space: nowrap;
|
||||
margin: 0 !important;
|
||||
color: #2d2c59 !important;
|
||||
font-size: 17px !important;
|
||||
line-height: 1.4 !important;
|
||||
font-weight: 700 !important;
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.home-recent-card-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 18px;
|
||||
gap: 12px;
|
||||
color: #7d7d9e;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.home-recent-card-duration {
|
||||
color: @home-text-gray;
|
||||
font-family: SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace;
|
||||
font-size: 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #7d7d9e;
|
||||
}
|
||||
|
||||
.home-recent-card-action {
|
||||
color: transparent;
|
||||
.home-recent-card-time {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
transform: translateX(-4px);
|
||||
opacity: 0;
|
||||
color: #7d7d9e;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "react";
|
|||
import {
|
||||
AudioOutlined,
|
||||
ArrowRightOutlined,
|
||||
PlayCircleOutlined,
|
||||
VideoCameraOutlined,
|
||||
VideoCameraAddOutlined
|
||||
} from "@ant-design/icons";
|
||||
import { Button, Empty, Skeleton, Tag, Typography } from "antd";
|
||||
|
|
@ -20,6 +20,7 @@ type QuickEntry = {
|
|||
icon: React.ReactNode;
|
||||
description: string[];
|
||||
accent: string;
|
||||
badge: string;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
|
|
@ -31,6 +32,8 @@ type RecentCard = {
|
|||
tags: string[];
|
||||
};
|
||||
|
||||
const RECENT_CARD_READ_STORAGE_KEY = "home_recent_card_read_ids";
|
||||
|
||||
const fallbackRecentCards: RecentCard[] = [
|
||||
{
|
||||
id: "sample-1",
|
||||
|
|
@ -65,7 +68,7 @@ function buildRecentCards(tasks: MeetingVO[]): RecentCard[] {
|
|||
title: task.title,
|
||||
duration: `0${index + 1}:${10 + index * 12}`,
|
||||
time: dayjs(task.meetingTime || task.createdAt).format("YYYY-MM-DD HH:mm"),
|
||||
tags: task.tags?.split(",").filter(Boolean).slice(0, 5) || ["转写", "总结", "纪要"]
|
||||
tags: task.tags?.split(",").filter(Boolean).slice(0, 5) || []
|
||||
}));
|
||||
}
|
||||
|
||||
|
|
@ -73,12 +76,39 @@ export default function HomePage() {
|
|||
const navigate = useNavigate();
|
||||
const [recentTasks, setRecentTasks] = useState<MeetingVO[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [readCardIds, setReadCardIds] = useState<string[]>(() => {
|
||||
if (typeof window === "undefined") {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const rawValue = window.localStorage.getItem(RECENT_CARD_READ_STORAGE_KEY);
|
||||
if (!rawValue) {
|
||||
return [];
|
||||
}
|
||||
const parsed = JSON.parse(rawValue);
|
||||
return Array.isArray(parsed) ? parsed.map(String) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
const [wordIndex, setWordIndex] = useState(0);
|
||||
const ROTATING_WORDS = useMemo(() => ["都有迹可循", "都能被听见", "都值得记录", "都清晰可见"], []);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setWordIndex((prev) => (prev + 1) % ROTATING_WORDS.length);
|
||||
}, 3000);
|
||||
return () => clearInterval(interval);
|
||||
}, [ROTATING_WORDS.length]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchRecentTasks = async () => {
|
||||
try {
|
||||
const response = await getRecentTasks();
|
||||
setRecentTasks(response.data.data || []);
|
||||
const payload: any = (response as any).data || response;
|
||||
setRecentTasks(payload?.data || payload || []);
|
||||
} catch (error) {
|
||||
console.error("Home recent tasks load failed", error);
|
||||
} finally {
|
||||
|
|
@ -89,6 +119,14 @@ export default function HomePage() {
|
|||
void fetchRecentTasks();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.setItem(RECENT_CARD_READ_STORAGE_KEY, JSON.stringify(readCardIds));
|
||||
}, [readCardIds]);
|
||||
|
||||
const quickEntries = useMemo<QuickEntry[]>(
|
||||
() => [
|
||||
{
|
||||
|
|
@ -96,13 +134,15 @@ export default function HomePage() {
|
|||
icon: <AudioOutlined />,
|
||||
description: ["实时语音转文字", "同步翻译,智能总结要点"],
|
||||
accent: "violet",
|
||||
onClick: () => navigate("/meeting-live-create")
|
||||
badge: "会议神器",
|
||||
onClick: () => navigate("/meetings?action=create&type=realtime")
|
||||
},
|
||||
{
|
||||
title: "上传音视频",
|
||||
icon: <VideoCameraAddOutlined />,
|
||||
description: ["音视频转文字", "区分发言人,一键导出"],
|
||||
accent: "cyan",
|
||||
badge: "iMeeting",
|
||||
onClick: () => navigate("/meetings?create=true")
|
||||
}
|
||||
],
|
||||
|
|
@ -111,6 +151,15 @@ export default function HomePage() {
|
|||
|
||||
const recentCards = useMemo(() => buildRecentCards(recentTasks), [recentTasks]);
|
||||
|
||||
const handleRecentCardClick = (card: RecentCard) => {
|
||||
const cardId = String(card.id);
|
||||
setReadCardIds((prev) => (prev.includes(cardId) ? prev : [...prev, cardId]));
|
||||
|
||||
if (typeof card.id === "number") {
|
||||
navigate(`/meetings/${card.id}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="home-container">
|
||||
{/* Massive Abstract Background Sphere matching the design/img.png */}
|
||||
|
|
@ -122,7 +171,19 @@ export default function HomePage() {
|
|||
<div className="home-content-wrapper">
|
||||
<header className="home-hero">
|
||||
<Title level={1} className="home-title">
|
||||
每一次交流,<span className="home-title-accent">都有迹可循</span>
|
||||
每一次交流,
|
||||
<span className="home-title-accent-wrapper">
|
||||
<span
|
||||
className="home-title-accent-scroller"
|
||||
style={{ transform: `translateY(-${wordIndex * 1.2}em)` }}
|
||||
>
|
||||
{ROTATING_WORDS.map((word) => (
|
||||
<span key={word} className="home-title-accent">
|
||||
{word}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</span>
|
||||
</Title>
|
||||
|
||||
<div className="home-quick-actions">
|
||||
|
|
@ -132,9 +193,11 @@ export default function HomePage() {
|
|||
onClick={entry.onClick}
|
||||
key={entry.title}
|
||||
>
|
||||
<div className="home-action-badge">{entry.badge}</div>
|
||||
<div className="home-action-icon-wrapper">
|
||||
<div className="home-action-icon">{entry.icon}</div>
|
||||
<div className="home-action-icon-glow" />
|
||||
<div className="home-action-icon-circle" />
|
||||
</div>
|
||||
<Title level={3} className="home-action-title">{entry.title}</Title>
|
||||
<div className="home-action-desc">
|
||||
|
|
@ -171,17 +234,33 @@ export default function HomePage() {
|
|||
<article
|
||||
key={card.id}
|
||||
className="home-recent-card"
|
||||
onClick={() => typeof card.id === "number" && navigate(`/meetings/${card.id}`)}
|
||||
onClick={() => handleRecentCardClick(card)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
handleRecentCardClick(card);
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="home-recent-card-thumbnail">
|
||||
<PlayCircleOutlined className="home-recent-card-play-icon" />
|
||||
</div>
|
||||
<div className="home-recent-card-content">
|
||||
{!readCardIds.includes(String(card.id)) && <span className="home-recent-card-dot" />}
|
||||
<div className="home-recent-card-head">
|
||||
<Title level={4} className="home-recent-card-title">{card.title}</Title>
|
||||
<div className="home-recent-card-icon">
|
||||
<VideoCameraOutlined className="home-recent-card-play-icon" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="home-recent-card-tags">
|
||||
{card.tags.slice(0, 4).map((tag) => (
|
||||
<Tag key={`${card.id}-${tag}`} className="home-recent-card-tag" bordered={false}>
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
<div className="home-recent-card-footer">
|
||||
<span className="home-recent-card-duration">{card.duration}</span>
|
||||
<span className="home-recent-card-action">立即了解</span>
|
||||
</div>
|
||||
<span className="home-recent-card-time">{card.time}</span>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Button, Card, Col, Drawer, Empty, Form, Input, InputNumber, Popconfirm, Row, Select, Space, Table, Tag, Tooltip, Typography, message } from "antd";
|
||||
import { Button, Card, Col, Drawer, Empty, Form, Input, InputNumber, Popconfirm, Row, Select, Space, Table, Tag, Tooltip, Typography, App } from 'antd';
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ApartmentOutlined, DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined, ShopOutlined } from "@ant-design/icons";
|
||||
|
|
@ -32,6 +32,7 @@ function buildOrgTree(list: SysOrg[]): OrgNode[] {
|
|||
}
|
||||
|
||||
export default function Orgs() {
|
||||
const { message } = App.useApp();
|
||||
const { t } = useTranslation();
|
||||
const { can } = usePermission();
|
||||
const { items: statusDict } = useDict("sys_common_status");
|
||||
|
|
@ -184,7 +185,7 @@ export default function Orgs() {
|
|||
)}
|
||||
</Card>
|
||||
|
||||
<Drawer title={<Space><ApartmentOutlined aria-hidden="true" /><span>{editing ? t("orgs.drawerTitleEdit") : t("orgs.drawerTitleCreate")}</span></Space>} open={drawerOpen} onClose={() => setDrawerOpen(false)} width={420} destroyOnClose footer={<div className="app-page__drawer-footer"><Button onClick={() => setDrawerOpen(false)}>{t("common.cancel")}</Button><Button type="primary" loading={saving} onClick={submit}>{t("common.save")}</Button></div>}>
|
||||
<Drawer title={<Space><ApartmentOutlined aria-hidden="true" /><span>{editing ? t("orgs.drawerTitleEdit") : t("orgs.drawerTitleCreate")}</span></Space>} open={drawerOpen} onClose={() => setDrawerOpen(false)} width={420} destroyOnHidden footer={<div className="app-page__drawer-footer"><Button onClick={() => setDrawerOpen(false)}>{t("common.cancel")}</Button><Button type="primary" loading={saving} onClick={submit}>{t("common.save")}</Button></div>}>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item label={t("users.tenant")} name="tenantId" rules={[{ required: true }]} hidden={!isPlatformMode}>
|
||||
<Select disabled options={tenants.map((tenant) => ({ label: tenant.tenantName, value: tenant.id }))} />
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Avatar, Button, Card, Col, DatePicker, Divider, Drawer, Empty, Form, Input, List, Popconfirm, Row, Select, Space, Tag, Tooltip, Typography, message } from "antd";
|
||||
import { Avatar, Button, Card, Col, DatePicker, Divider, Drawer, Empty, Form, Input, List, Popconfirm, Row, Select, Space, Tag, Tooltip, Typography, App } from 'antd';
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DeleteOutlined, EditOutlined, PhoneOutlined, PlusOutlined, ReloadOutlined, SearchOutlined, ShopOutlined, UserOutlined } from "@ant-design/icons";
|
||||
|
|
@ -12,6 +12,7 @@ import type { SysTenant } from "@/types";
|
|||
const { Title, Text, Paragraph } = Typography;
|
||||
|
||||
export default function Tenants() {
|
||||
const { message } = App.useApp();
|
||||
const { t } = useTranslation();
|
||||
const { can } = usePermission();
|
||||
const { items: statusDict } = useDict("sys_common_status");
|
||||
|
|
@ -166,7 +167,7 @@ export default function Tenants() {
|
|||
/>
|
||||
</div>
|
||||
|
||||
<Drawer title={<Space><ShopOutlined aria-hidden="true" /><span>{editing ? t("tenants.drawerTitleEdit") : t("tenants.drawerTitleCreate")}</span></Space>} open={drawerOpen} onClose={() => setDrawerOpen(false)} width={480} destroyOnClose footer={<div className="app-page__drawer-footer"><Button onClick={() => setDrawerOpen(false)}>{t("common.cancel")}</Button><Button type="primary" loading={saving} onClick={submit}>{t("common.save")}</Button></div>}>
|
||||
<Drawer title={<Space><ShopOutlined aria-hidden="true" /><span>{editing ? t("tenants.drawerTitleEdit") : t("tenants.drawerTitleCreate")}</span></Space>} open={drawerOpen} onClose={() => setDrawerOpen(false)} width={480} destroyOnHidden footer={<div className="app-page__drawer-footer"><Button onClick={() => setDrawerOpen(false)}>{t("common.cancel")}</Button><Button type="primary" loading={saving} onClick={submit}>{t("common.save")}</Button></div>}>
|
||||
<Form form={form} layout="vertical">
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Alert, Avatar, Button, Card, Col, Descriptions, Form, Input, Row, Space, Tabs, Tag, Typography, message } from "antd";
|
||||
import { Alert, Avatar, Button, Card, Col, Descriptions, Form, Input, Row, Space, Tabs, Tag, Typography, App } from 'antd';
|
||||
import { KeyOutlined, LockOutlined, ReloadOutlined, SaveOutlined, SolutionOutlined, UserOutlined } from "@ant-design/icons";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
|
@ -9,6 +9,7 @@ import type { BotCredential, UserProfile } from "@/types";
|
|||
const { Paragraph, Title, Text } = Typography;
|
||||
|
||||
export default function Profile() {
|
||||
const { message } = App.useApp();
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Button, Card, Col, Drawer, Empty, Form, Input, InputNumber, Popconfirm, Row, Select, Space, Table, Tag, Typography, message } from "antd";
|
||||
import { Button, Card, Col, Drawer, Empty, Form, Input, InputNumber, Popconfirm, Row, Select, Space, Table, Tag, Typography, App } from 'antd';
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BookOutlined, DeleteOutlined, EditOutlined, PlusOutlined, ProfileOutlined, SearchOutlined } from "@ant-design/icons";
|
||||
|
|
@ -13,6 +13,7 @@ import "./index.less";
|
|||
const { Text } = Typography;
|
||||
|
||||
export default function Dictionaries() {
|
||||
const { message } = App.useApp();
|
||||
const { t } = useTranslation();
|
||||
const { can } = usePermission();
|
||||
const { items: statusDict } = useDict("sys_common_status");
|
||||
|
|
@ -249,7 +250,7 @@ export default function Dictionaries() {
|
|||
</Col>
|
||||
</Row>
|
||||
|
||||
<Drawer title={<Space><BookOutlined aria-hidden="true" /><span>{editingType ? t("dicts.drawerTitleTypeEdit") : t("dicts.drawerTitleTypeCreate")}</span></Space>} open={typeDrawerVisible} onClose={() => setTypeDrawerVisible(false)} width={400} destroyOnClose footer={<div className="app-page__drawer-footer"><Button onClick={() => setTypeDrawerVisible(false)}>{t("common.cancel")}</Button><Button type="primary" onClick={handleTypeSubmit}>{t("common.save")}</Button></div>}>
|
||||
<Drawer title={<Space><BookOutlined aria-hidden="true" /><span>{editingType ? t("dicts.drawerTitleTypeEdit") : t("dicts.drawerTitleTypeCreate")}</span></Space>} open={typeDrawerVisible} onClose={() => setTypeDrawerVisible(false)} width={400} destroyOnHidden footer={<div className="app-page__drawer-footer"><Button onClick={() => setTypeDrawerVisible(false)}>{t("common.cancel")}</Button><Button type="primary" onClick={handleTypeSubmit}>{t("common.save")}</Button></div>}>
|
||||
<Form form={typeForm} layout="vertical">
|
||||
<Form.Item label={t("dicts.typeCode")} name="typeCode" rules={[{ required: true, message: t("dicts.typeCode") }]}>
|
||||
<Input disabled={!!editingType} placeholder={t("dictsExt.typeCodePlaceholder")} />
|
||||
|
|
@ -263,7 +264,7 @@ export default function Dictionaries() {
|
|||
</Form>
|
||||
</Drawer>
|
||||
|
||||
<Drawer title={<Space><ProfileOutlined aria-hidden="true" /><span>{editingItem ? t("dicts.drawerTitleItemEdit") : t("dicts.drawerTitleItemCreate")}</span></Space>} open={itemDrawerVisible} onClose={() => setItemDrawerVisible(false)} width={400} destroyOnClose footer={<div className="app-page__drawer-footer"><Button onClick={() => setItemDrawerVisible(false)}>{t("common.cancel")}</Button><Button type="primary" onClick={handleItemSubmit}>{t("common.save")}</Button></div>}>
|
||||
<Drawer title={<Space><ProfileOutlined aria-hidden="true" /><span>{editingItem ? t("dicts.drawerTitleItemEdit") : t("dicts.drawerTitleItemCreate")}</span></Space>} open={itemDrawerVisible} onClose={() => setItemDrawerVisible(false)} width={400} destroyOnHidden footer={<div className="app-page__drawer-footer"><Button onClick={() => setItemDrawerVisible(false)}>{t("common.cancel")}</Button><Button type="primary" onClick={handleItemSubmit}>{t("common.save")}</Button></div>}>
|
||||
<Form form={itemForm} layout="vertical">
|
||||
<Form.Item label={t("dicts.typeCode")} name="typeCode"><Input disabled className="tabular-nums" /></Form.Item>
|
||||
<Form.Item label={t("dicts.itemLabel")} name="itemLabel" rules={[{ required: true, message: t("dicts.itemLabel") }]}><Input placeholder={t("dictsExt.itemLabelPlaceholder")} /></Form.Item>
|
||||
|
|
|
|||
|
|
@ -257,11 +257,23 @@ export default function Logs() {
|
|||
</Card>
|
||||
|
||||
<Card className="app-page__content-card flex-1 flex flex-col overflow-hidden" styles={{ body: { paddingTop: 0, paddingBottom: 0, flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" } }}>
|
||||
<Tabs activeKey={activeTab} onChange={(key) => { setActiveTab(key); setParams((prev) => ({ ...prev, current: 1, moduleName: "" })); }} size="large" className="flex-shrink-0">
|
||||
{logTypeDict.length > 0
|
||||
? logTypeDict.map((item) => <Tabs.TabPane tab={<span>{item.itemValue === "OPERATION" ? <InfoCircleOutlined aria-hidden="true" /> : <UserOutlined aria-hidden="true" />}{item.itemLabel}</span>} key={item.itemValue} />)
|
||||
: <><Tabs.TabPane tab={<span><InfoCircleOutlined aria-hidden="true" />{t("logs.opLog")}</span>} key="OPERATION" /><Tabs.TabPane tab={<span><UserOutlined aria-hidden="true" />{t("logs.loginLog")}</span>} key="LOGIN" /></>}
|
||||
</Tabs>
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={(key) => { setActiveTab(key); setParams((prev) => ({ ...prev, current: 1, moduleName: "" })); }}
|
||||
size="large"
|
||||
className="flex-shrink-0"
|
||||
items={
|
||||
logTypeDict.length > 0
|
||||
? logTypeDict.map((item) => ({
|
||||
key: item.itemValue,
|
||||
label: <span>{item.itemValue === "OPERATION" ? <InfoCircleOutlined aria-hidden="true" /> : <UserOutlined aria-hidden="true" />}{item.itemLabel}</span>
|
||||
}))
|
||||
: [
|
||||
{ key: "OPERATION", label: <span><InfoCircleOutlined aria-hidden="true" />{t("logs.opLog")}</span> },
|
||||
{ key: "LOGIN", label: <span><UserOutlined aria-hidden="true" />{t("logs.loginLog")}</span> }
|
||||
]
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex-1 min-h-0 h-full">
|
||||
<ListTable
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Button, Card, Col, Form, Input, Row, Upload, message } from "antd";
|
||||
import { Button, Card, Col, Form, Input, Row, Upload, App } from 'antd';
|
||||
import { FileTextOutlined, GlobalOutlined, PictureOutlined, SaveOutlined, UploadOutlined } from "@ant-design/icons";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
|
@ -25,6 +25,7 @@ function ImagePreview({ url, label, hint }: { url?: string; label: string; hint:
|
|||
}
|
||||
|
||||
export default function PlatformSettings() {
|
||||
const { message } = App.useApp();
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Button, Card, Col, Drawer, Form, Input, Popconfirm, Row, Select, Space, Switch, Table, Tag, Tooltip, Typography, message } from "antd";
|
||||
import { Button, Card, Col, Drawer, Form, Input, Popconfirm, Row, Select, Space, Switch, Table, Tag, Tooltip, Typography, App } from 'antd';
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DeleteOutlined, EditOutlined, InfoCircleOutlined, PlusOutlined, SearchOutlined, SettingOutlined } from "@ant-design/icons";
|
||||
|
|
@ -13,6 +13,7 @@ import "./index.less";
|
|||
const { Text } = Typography;
|
||||
|
||||
export default function SysParams() {
|
||||
const { message } = App.useApp();
|
||||
const { t } = useTranslation();
|
||||
const { can } = usePermission();
|
||||
const { items: statusDict } = useDict("sys_common_status");
|
||||
|
|
@ -188,7 +189,7 @@ export default function SysParams() {
|
|||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
width={500}
|
||||
destroyOnClose
|
||||
destroyOnHidden
|
||||
footer={<div className="app-page__drawer-footer"><Button onClick={() => setDrawerOpen(false)}>{t("common.cancel")}</Button><Button type="primary" loading={saving} onClick={submit}>{t("common.save")}</Button></div>}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ const UserRoleBinding = lazy(() => import("@/pages/bindings/user-role"));
|
|||
const RolePermissionBinding = lazy(() => import("@/pages/bindings/role-permission"));
|
||||
|
||||
import SpeakerReg from "../pages/business/SpeakerReg";
|
||||
import RealtimeAsr from "../pages/business/RealtimeAsr";
|
||||
const RealtimeAsrSession = lazy(async () => { const mod = await import("../pages/business/RealtimeAsrSession"); return { default: mod.default ?? mod.RealtimeAsrSession }; });
|
||||
import HotWords from "../pages/business/HotWords";
|
||||
import PromptTemplates from "../pages/business/PromptTemplates";
|
||||
|
|
@ -42,7 +41,6 @@ function LazyPage({ children }: { children: JSX.Element }) {
|
|||
export const menuRoutes: MenuRoute[] = [
|
||||
{ path: "/", label: "首页", element: <HomePage />, perm: "menu:dashboard" },
|
||||
{ path: "/profile", label: "个人中心", element: <Profile /> },
|
||||
{ path: "/realtime-asr", label: "实时识别", element: <RealtimeAsr />, perm: "menu:meeting" },
|
||||
{ path: "/speaker-reg", label: "声纹注册", element: <SpeakerReg />, perm: "menu:speaker" },
|
||||
{ path: "/tenants", label: "租户管理", element: <Tenants />, perm: "menu:tenants" },
|
||||
{ path: "/orgs", label: "组织管理", element: <Orgs />, perm: "menu:orgs" },
|
||||
|
|
@ -65,7 +63,6 @@ export const menuRoutes: MenuRoute[] = [
|
|||
export const extraRoutes = [
|
||||
{ path: "/dashboard-monitor", element: <Dashboard />, perm: "menu:dashboard" },
|
||||
{ path: "/meetings/:id", element: <MeetingDetail />, perm: "menu:meeting" },
|
||||
{ path: "/meeting-live-create", element: <RealtimeAsr />, perm: "menu:meeting" },
|
||||
{ path: "/meeting-live-session/:id", element: <RealtimeAsrSession />, perm: "menu:meeting" }
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ export const getStandardPagination = (
|
|||
showQuickJumper: true,
|
||||
showTotal: (totalCount) => i18n.t('common.total', { total: totalCount }),
|
||||
pageSizeOptions: ['10', '20', '50', '100'],
|
||||
size: 'middle',
|
||||
size: 'default',
|
||||
position: ['bottomRight']
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"target": "ES2020",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
|
|
|
|||
|
|
@ -14,11 +14,11 @@ export default defineConfig({
|
|||
server: {
|
||||
port: 5174,
|
||||
proxy: {
|
||||
"/auth": "http://localhost:8081",
|
||||
"/sys": "http://localhost:8081",
|
||||
"/api": "http://localhost:8081",
|
||||
"/auth": "http://10.100.53.199:8080",
|
||||
"/sys": "http://10.100.53.199:8080",
|
||||
"/api": "http://10.100.53.199:8080",
|
||||
"/ws": {
|
||||
target: "ws://localhost:8081",
|
||||
target: "ws://10.100.53.199:8080",
|
||||
ws: true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue