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目录
- 更新字体文件到资源目录
更新首页样式
alanpaine 2026-04-08 14:34:59 +08:00
parent 135203b9f6
commit 84a21f4960
54 changed files with 1404 additions and 1327 deletions

View File

@ -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/}

View File

@ -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}

View File

@ -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/}

View File

@ -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.

24
frontend/.gitignore vendored 100644
View File

@ -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?

View File

@ -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() {
}
}}
>
<AppRoutes />
<AntdApp>
<AppRoutes />
</AntdApp>
</ConfigProvider>
);
}

View File

@ -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 } }
);

View File

@ -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"
);
};

View File

@ -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 } }
);

View File

@ -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`
);
};

View File

@ -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 } }

View File

@ -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}`
);
};

View File

@ -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>

View File

@ -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>
);
};

View File

@ -67,7 +67,7 @@ function ButtonWithHoverCard({
>
<Card
size="small"
bordered={false}
variant="borderless"
className="hover-info-card-content"
>
{/* 标题区 */}

View File

@ -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()

View File

@ -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)

View File

@ -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

View File

@ -1,4 +1,4 @@
import { notification } from "antd";
import { notification } from 'antd';
import {
CheckCircleOutlined,
CloseCircleOutlined,

View File

@ -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 {
}

View File

@ -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)",

View File

@ -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}>

View File

@ -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,102 +509,119 @@ 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">
<div className="role-detail-pane">
<div className="permission-tree-wrapper">
<Tree
checkable
selectable={false}
checkStrictly={false}
treeData={permissionTreeData}
checkedKeys={selectedPermIds}
onCheck={(keys, info) => {
const checked = Array.isArray(keys) ? keys : keys.checked;
const halfChecked = info.halfCheckedKeys || [];
setSelectedPermIds(checked.map((key) => Number(key)));
setHalfCheckedIds(halfChecked.map((key) => Number(key)));
}}
defaultExpandAll
/>
</div>
</div>
</Tabs.TabPane>
<Tabs.TabPane tab={<Space><ApartmentOutlined />{"数据权限"}</Space>} key="dataScope">
<div className="role-detail-pane">
<div style={{ marginBottom: 16 }}>
<Radio.Group value={dataScopeType} onChange={(event) => setDataScopeType(event.target.value)} optionType="button" buttonStyle="solid">
{DATA_SCOPE_OPTIONS.map((item) => (
<Radio.Button key={item.value} value={item.value}>{item.label}</Radio.Button>
))}
</Radio.Group>
</div>
<div style={{ marginBottom: 16, color: "#64748b" }}>{getDataScopeDescription(dataScopeType)}</div>
{dataScopeType === "CUSTOM" ? (
<div className="permission-tree-wrapper">
<Tree
checkable
selectable={false}
treeData={scopeOrgTree}
checkedKeys={scopeOrgIds}
onCheck={(keys) => {
const checked = Array.isArray(keys) ? keys : keys.checked;
setScopeOrgIds(checked.map((key) => Number(key)));
}}
defaultExpandAll
<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
checkable
selectable={false}
checkStrictly={false}
treeData={permissionTreeData}
checkedKeys={selectedPermIds}
onCheck={(keys, info) => {
const checked = Array.isArray(keys) ? keys : keys.checked;
const halfChecked = info.halfCheckedKeys || [];
setSelectedPermIds(checked.map((key) => Number(key)));
setHalfCheckedIds(halfChecked.map((key) => Number(key)));
}}
defaultExpandAll
/>
</div>
</div>
)
},
{
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">
{DATA_SCOPE_OPTIONS.map((item) => (
<Radio.Button key={item.value} value={item.value}>{item.label}</Radio.Button>
))}
</Radio.Group>
</div>
<div style={{ marginBottom: 16, color: "#64748b" }}>{getDataScopeDescription(dataScopeType)}</div>
{dataScopeType === "CUSTOM" ? (
<div className="permission-tree-wrapper">
<Tree
checkable
selectable={false}
treeData={scopeOrgTree}
checkedKeys={scopeOrgIds}
onCheck={(keys) => {
const checked = Array.isArray(keys) ? keys : keys.checked;
setScopeOrgIds(checked.map((key) => Number(key)));
}}
defaultExpandAll
/>
</div>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="当前范围不需要选择部门" />
)}
</div>
)
},
{
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>
<Button type="primary" ghost icon={<UserAddOutlined />} onClick={openUserModal} disabled={!can("sys:role:update")}>{"绑定用户"}</Button>
</div>
<Table
rowKey="userId"
size="small"
loading={loadingUsers}
dataSource={roleUsers}
pagination={{ pageSize: 10, size: "small" }}
columns={[
{
title: "用户信息",
render: (_: unknown, user: SysUser) => (
<Space>
<Avatar size="small" icon={<UserOutlined />} style={{ backgroundColor: "#f0f2f5", color: "#8c8c8c" }} />
<div>
<div style={{ fontWeight: 500 }}>{user.displayName}</div>
<div style={{ fontSize: 11, color: "#bfbfbf" }}>@{user.username}</div>
</div>
</Space>
)
},
{ title: "手机号", dataIndex: "phone", className: "tabular-nums" },
{ title: "状态", dataIndex: "status", width: 80, render: (status: number) => <Tag color={status === 1 ? "green" : "red"}>{status === 1 ? "启用" : "停用"}</Tag> },
{
title: "操作",
key: "action",
width: 80,
render: (_: unknown, user: SysUser) => (
<Popconfirm title="确定解除该用户绑定吗?" okText="确定" cancelText="取消" onConfirm={() => void handleUnbindUser(user.userId)} disabled={!can("sys:role:update")}>
<Button type="text" danger size="small" icon={<DeleteOutlined />} disabled={!can("sys:role:update")} />
</Popconfirm>
)
}
]}
/>
</div>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="当前范围不需要选择部门" />
)}
</div>
</Tabs.TabPane>
<Tabs.TabPane tab={<Space><TeamOutlined />{`成员管理 (${roleUsers.length})`}</Space>} key="users">
<div className="role-detail-pane">
<div className="role-members-toolbar">
<Title level={5} style={{ margin: 0 }}>{"已绑定用户"}</Title>
<Button type="primary" ghost icon={<UserAddOutlined />} onClick={openUserModal} disabled={!can("sys:role:update")}>{"绑定用户"}</Button>
</div>
<Table
rowKey="userId"
size="small"
loading={loadingUsers}
dataSource={roleUsers}
pagination={{ pageSize: 10, size: "small" }}
columns={[
{
title: "用户信息",
render: (_: unknown, user: SysUser) => (
<Space>
<Avatar size="small" icon={<UserOutlined />} style={{ backgroundColor: "#f0f2f5", color: "#8c8c8c" }} />
<div>
<div style={{ fontWeight: 500 }}>{user.displayName}</div>
<div style={{ fontSize: 11, color: "#bfbfbf" }}>@{user.username}</div>
</div>
</Space>
)
},
{ title: "手机号", dataIndex: "phone", className: "tabular-nums" },
{ title: "状态", dataIndex: "status", width: 80, render: (status: number) => <Tag color={status === 1 ? "green" : "red"}>{status === 1 ? "启用" : "停用"}</Tag> },
{
title: "操作",
key: "action",
width: 80,
render: (_: unknown, user: SysUser) => (
<Popconfirm title="确定解除该用户绑定吗?" okText="确定" cancelText="取消" onConfirm={() => void handleUnbindUser(user.userId)} disabled={!can("sys:role:update")}>
<Button type="text" danger size="small" icon={<DeleteOutlined />} disabled={!can("sys:role:update")} />
</Popconfirm>
)
}
]}
/>
</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} />

View File

@ -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}>

View File

@ -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) {

View File

@ -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>();

View File

@ -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[]>([]);

View File

@ -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[]>([]);

View File

@ -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");

View File

@ -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>

View File

@ -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 ? (

View File

@ -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}
/>
</Drawer>
initialType={createDrawerType}
onCancel={() => setCreateDrawerVisible(false)}
onSuccess={() => {
setCreateDrawerVisible(false);
fetchData();
}}
/>
<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

View File

@ -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}>

View File

@ -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>
);
}

View File

@ -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>

View File

@ -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,48 +548,60 @@ const SpeakerReg: React.FC = () => {
</Form.Item>
</Form>
<Tabs defaultActiveKey="1" className="custom-tabs" size="middle">
<Tabs.TabPane tab={<span><AudioOutlined /> </span>} key="1">
<div className="recording-area">
<div className="script-box">
<Text strong style={{ fontSize: 13, display: 'block', marginBottom: 6, opacity: 0.6 }}></Text>
<div style={{ fontSize: 14, fontWeight: 500 }}>{REG_CONTENT}</div>
</div>
<div className="record-controls">
<button
className={`btn-record ${recording ? 'recording' : 'idle'}`}
onClick={recording ? stopRecording : startRecording}
>
{recording ? <StopOutlined style={{ fontSize: 24 }} /> : <AudioOutlined style={{ fontSize: 24 }} />}
</button>
<div style={{ flex: 1, maxWidth: 240 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
<Text strong style={{ fontSize: 14, color: recording ? 'var(--accent-blue)' : undefined }}>{recording ? '正在采集声音...' : '等待录制'}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>{seconds}s / {DEFAULT_DURATION}s</Text>
<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>
<div style={{ fontSize: 14, fontWeight: 500 }}>{REG_CONTENT}</div>
</div>
<div className="record-controls">
<button
className={`btn-record ${recording ? 'recording' : 'idle'}`}
onClick={recording ? stopRecording : startRecording}
>
{recording ? <StopOutlined style={{ fontSize: 24 }} /> : <AudioOutlined style={{ fontSize: 24 }} />}
</button>
<div style={{ flex: 1, maxWidth: 240 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
<Text strong style={{ fontSize: 14, color: recording ? 'var(--accent-blue)' : undefined }}>{recording ? '正在采集声音...' : '等待录制'}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>{seconds}s / {DEFAULT_DURATION}s</Text>
</div>
<Progress
percent={(seconds / DEFAULT_DURATION) * 100}
showInfo={false}
strokeColor="var(--accent-blue)"
size="small"
/>
</div>
</div>
<Progress
percent={(seconds / DEFAULT_DURATION) * 100}
showInfo={false}
strokeColor="var(--accent-blue)"
size="small"
strokeWidth={6}
/>
</div>
</div>
</div>
</Tabs.TabPane>
<Tabs.TabPane tab={<span><CloudUploadOutlined /> 线</span>} key="2">
<Upload {...uploadProps} accept="audio/*" style={{ width: '100%' }}>
<div className="upload-compact">
<CloudUploadOutlined style={{ fontSize: 32, color: 'var(--accent-blue)', marginBottom: 12 }} />
<div style={{ fontSize: 14, marginBottom: 4 }}><Text strong></Text></div>
<Text type="secondary" style={{ fontSize: 12 }}> MP3 / WAV / M4A 5-15</Text>
</div>
</Upload>
</Tabs.TabPane>
</Tabs>
)
},
{
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 }} />
<div style={{ fontSize: 14, marginBottom: 4 }}><Text strong></Text></div>
<Text type="secondary" style={{ fontSize: 12 }}> MP3 / WAV / M4A 5-15</Text>
</div>
</Upload>
)
}
]}
/>
{audioUrl && (
<div style={{ background: 'var(--app-bg-surface-soft)', padding: 12, borderRadius: 12, border: '1px solid var(--accent-blue)' }}>

View File

@ -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

View File

@ -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>

View File

@ -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 {
transform: translateY(-6px);
box-shadow: 0 20px 40px -10px var(--action-shadow);
.home-action-icon-wrapper {
transform: translateY(-6px);
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;
}
}

View File

@ -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,18 +234,34 @@ 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-footer">
<span className="home-recent-card-duration">{card.duration}</span>
<span className="home-recent-card-action"></span>
<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-time">{card.time}</span>
</div>
</article>
))}
</div>

View File

@ -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 }))} />

View File

@ -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}>

View File

@ -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);

View File

@ -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>

View File

@ -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

View File

@ -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);

View File

@ -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">

View File

@ -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" }
];

View File

@ -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']
};
};

View File

@ -1,5 +1,6 @@
{
"compilerOptions": {
"baseUrl": ".",
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",

View File

@ -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
}
}