refactor: 删除MeetingCreate页面并更新主页和AI模型服务
- 删除 `MeetingCreate` 页面及其相关代码 - 更新主页组件,替换静态视觉元素为动态 `RightVisual` 组件 - 在 `AiModelService` 和 `AiModelController` 中添加本地连通性测试功能 - 重构 `AiModelServiceImpl`,增加验证和配置更新逻辑dev_na
parent
92e6b9fd4d
commit
4ee7a620b9
|
|
@ -2,6 +2,7 @@ package com.imeeting.controller.biz;
|
|||
|
||||
|
||||
import com.imeeting.dto.biz.AiModelDTO;
|
||||
import com.imeeting.dto.biz.AiLocalProfileVO;
|
||||
import com.imeeting.dto.biz.AiModelVO;
|
||||
|
||||
import com.imeeting.service.biz.AiModelService;
|
||||
|
|
@ -93,6 +94,18 @@ public class AiModelController {
|
|||
return ApiResponse.ok(aiModelService.fetchRemoteModels(provider, baseUrl, apiKey));
|
||||
}
|
||||
|
||||
@PostMapping("/local-connectivity-test")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ApiResponse<AiLocalProfileVO> testLocalConnectivity(@RequestBody AiModelDTO dto) {
|
||||
if (dto.getBaseUrl() == null || dto.getBaseUrl().isBlank()) {
|
||||
return ApiResponse.error("Base URL不能为空");
|
||||
}
|
||||
if (dto.getApiKey() == null || dto.getApiKey().isBlank()) {
|
||||
return ApiResponse.error("API Key不能为空");
|
||||
}
|
||||
return ApiResponse.ok(aiModelService.testLocalConnectivity(dto.getBaseUrl(), dto.getApiKey()));
|
||||
}
|
||||
|
||||
@GetMapping("/default")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ApiResponse<AiModelVO> getDefault(@RequestParam String type) {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package com.imeeting.service.biz;
|
|||
|
||||
|
||||
import com.imeeting.dto.biz.AiModelDTO;
|
||||
import com.imeeting.dto.biz.AiLocalProfileVO;
|
||||
import com.imeeting.dto.biz.AiModelVO;
|
||||
import com.unisbase.dto.PageResult;
|
||||
|
||||
|
|
@ -12,6 +13,7 @@ public interface AiModelService {
|
|||
AiModelVO updateModel(AiModelDTO dto);
|
||||
PageResult<List<AiModelVO>> pageModels(Integer current, Integer size, String name, String type, Long tenantId);
|
||||
List<String> fetchRemoteModels(String provider, String baseUrl, String apiKey);
|
||||
AiLocalProfileVO testLocalConnectivity(String baseUrl, String apiKey);
|
||||
AiModelVO getDefaultModel(String type, Long tenantId);
|
||||
AiModelVO getModelById(Long id, String type);
|
||||
boolean removeModelById(Long id, String type);
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import com.fasterxml.jackson.databind.JsonNode;
|
|||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import com.imeeting.dto.biz.AiModelDTO;
|
||||
import com.imeeting.dto.biz.AiLocalProfileVO;
|
||||
import com.imeeting.dto.biz.AiModelVO;
|
||||
import com.imeeting.entity.biz.AsrModel;
|
||||
import com.imeeting.entity.biz.LlmModel;
|
||||
|
|
@ -55,6 +56,7 @@ public class AiModelServiceImpl implements AiModelService {
|
|||
@Transactional(rollbackFor = Exception.class)
|
||||
public AiModelVO saveModel(AiModelDTO dto) {
|
||||
String type = normalizeType(dto.getModelType());
|
||||
validateModel(dto);
|
||||
if (TYPE_ASR.equals(type)) {
|
||||
AsrModel entity = new AsrModel();
|
||||
copyAsrProperties(dto, entity);
|
||||
|
|
@ -76,6 +78,7 @@ public class AiModelServiceImpl implements AiModelService {
|
|||
@Transactional(rollbackFor = Exception.class)
|
||||
public AiModelVO updateModel(AiModelDTO dto) {
|
||||
String type = normalizeType(dto.getModelType());
|
||||
validateModel(dto);
|
||||
if (TYPE_ASR.equals(type)) {
|
||||
AsrModel entity = asrModelMapper.selectById(dto.getId());
|
||||
if (entity == null) {
|
||||
|
|
@ -145,6 +148,9 @@ public class AiModelServiceImpl implements AiModelService {
|
|||
if (resolvedBaseUrl == null || resolvedBaseUrl.isBlank()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
if ("custom".equals(providerKey)) {
|
||||
return fetchLocalProfile(resolvedBaseUrl, apiKey).getAsrModels();
|
||||
}
|
||||
String targetUrl = resolveModelListUrl(providerKey, resolvedBaseUrl, apiKey);
|
||||
if (targetUrl == null || targetUrl.isBlank()) {
|
||||
return Collections.emptyList();
|
||||
|
|
@ -216,6 +222,88 @@ public class AiModelServiceImpl implements AiModelService {
|
|||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public AiLocalProfileVO testLocalConnectivity(String baseUrl, String apiKey) {
|
||||
return fetchLocalProfile(baseUrl, apiKey);
|
||||
}
|
||||
|
||||
private AiLocalProfileVO fetchLocalProfile(String baseUrl, String apiKey) {
|
||||
String targetUrl = appendPath(baseUrl, "api/v1/system/profile");
|
||||
try {
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(targetUrl))
|
||||
.timeout(Duration.ofSeconds(10))
|
||||
.header("Authorization", "Bearer " + apiKey)
|
||||
.GET()
|
||||
.build();
|
||||
|
||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
if (response.statusCode() < 200 || response.statusCode() >= 300) {
|
||||
throw new RuntimeException("本地模型连通性测试失败: HTTP " + response.statusCode());
|
||||
}
|
||||
|
||||
JsonNode root = objectMapper.readTree(response.body());
|
||||
JsonNode dataNode = root.path("data");
|
||||
|
||||
AiLocalProfileVO profile = new AiLocalProfileVO();
|
||||
profile.setAsrModels(extractModelNames(dataNode.path("models").path("asr")));
|
||||
JsonNode speakerNode = dataNode.path("models").path("speaker");
|
||||
if (speakerNode.isMissingNode() || speakerNode.isNull()) {
|
||||
speakerNode = dataNode.path("model").path("speaker");
|
||||
}
|
||||
profile.setSpeakerModels(extractModelNames(speakerNode));
|
||||
profile.setActiveAsrModel(readText(dataNode.path("runtime").path("asr_model")));
|
||||
profile.setActiveSpeakerModel(readText(dataNode.path("runtime").path("speaker_model")));
|
||||
if (dataNode.path("runtime").has("sv_threshold")) {
|
||||
profile.setSvThreshold(dataNode.path("runtime").path("sv_threshold").decimalValue());
|
||||
}
|
||||
profile.setWsEndpoint(readText(dataNode.path("ws_endpoint")));
|
||||
return profile;
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("本地模型连通性测试失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private void updateLocalProfile(AsrModel entity) {
|
||||
if (entity.getBaseUrl() == null || entity.getBaseUrl().isBlank()) {
|
||||
throw new RuntimeException("baseUrl is required for ASR model");
|
||||
}
|
||||
if (entity.getApiKey() == null || entity.getApiKey().isBlank()) {
|
||||
throw new RuntimeException("apiKey is required for local ASR model");
|
||||
}
|
||||
if (entity.getModelCode() == null || entity.getModelCode().isBlank()) {
|
||||
throw new RuntimeException("modelCode is required for ASR model");
|
||||
}
|
||||
|
||||
Map<String, Object> mediaConfig = entity.getMediaConfig() == null ? Collections.emptyMap() : entity.getMediaConfig();
|
||||
String speakerModel = readConfigString(mediaConfig.get("speakerModel"));
|
||||
BigDecimal svThreshold = readConfigDecimal(mediaConfig.get("svThreshold"));
|
||||
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("asr_model", entity.getModelCode());
|
||||
body.put("save_audio", false);
|
||||
body.put("speaker_model", speakerModel);
|
||||
body.put("sv_threshold", svThreshold);
|
||||
|
||||
String targetUrl = appendPath(entity.getBaseUrl(), "api/v1/system/profile");
|
||||
try {
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(targetUrl))
|
||||
.timeout(Duration.ofSeconds(30))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Authorization", "Bearer " + entity.getApiKey())
|
||||
.PUT(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body)))
|
||||
.build();
|
||||
|
||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
if (response.statusCode() < 200 || response.statusCode() >= 300) {
|
||||
throw new RuntimeException("本地模型配置保存失败: HTTP " + response.statusCode());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("本地模型配置保存失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private String resolveBaseUrl(String providerKey, String baseUrl) {
|
||||
if (baseUrl != null && !baseUrl.isBlank()) {
|
||||
return baseUrl;
|
||||
|
|
@ -267,6 +355,26 @@ public class AiModelServiceImpl implements AiModelService {
|
|||
return provider.trim().toLowerCase();
|
||||
}
|
||||
|
||||
private void validateModel(AiModelDTO dto) {
|
||||
if (dto == null) {
|
||||
throw new RuntimeException("模型配置不能为空");
|
||||
}
|
||||
if ("custom".equals(normalizeProvider(dto.getProvider()))) {
|
||||
if (dto.getApiKey() == null || dto.getApiKey().isBlank()) {
|
||||
throw new RuntimeException("本地模型必须填写API Key");
|
||||
}
|
||||
if (TYPE_ASR.equals(normalizeType(dto.getModelType()))) {
|
||||
Map<String, Object> mediaConfig = dto.getMediaConfig() == null ? Collections.emptyMap() : dto.getMediaConfig();
|
||||
if (readConfigString(mediaConfig.get("speakerModel")) == null) {
|
||||
throw new RuntimeException("本地ASR模型必须选择声纹模型");
|
||||
}
|
||||
if (mediaConfig.get("svThreshold") == null) {
|
||||
throw new RuntimeException("本地ASR模型必须填写声纹阈值");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public AiModelVO getDefaultModel(String type, Long tenantId) {
|
||||
String resolvedType = normalizeType(type);
|
||||
|
|
@ -340,6 +448,10 @@ public class AiModelServiceImpl implements AiModelService {
|
|||
}
|
||||
|
||||
private void pushAsrConfig(AsrModel entity) {
|
||||
if ("custom".equals(normalizeProvider(entity.getProvider()))) {
|
||||
updateLocalProfile(entity);
|
||||
return;
|
||||
}
|
||||
if (entity.getBaseUrl() == null || entity.getBaseUrl().isBlank()) {
|
||||
throw new RuntimeException("baseUrl is required for ASR model");
|
||||
}
|
||||
|
|
@ -384,6 +496,51 @@ public class AiModelServiceImpl implements AiModelService {
|
|||
entity.setRemark(dto.getRemark());
|
||||
}
|
||||
|
||||
private List<String> extractModelNames(JsonNode node) {
|
||||
if (node == null || !node.isArray()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<String> result = new ArrayList<>();
|
||||
for (JsonNode item : node) {
|
||||
String name;
|
||||
if (item.isObject()) {
|
||||
name = readText(item.path("name"));
|
||||
} else {
|
||||
name = item.asText("");
|
||||
}
|
||||
if (name != null && !name.isBlank()) {
|
||||
result.add(name);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private String readText(JsonNode node) {
|
||||
if (node == null || node.isMissingNode() || node.isNull()) {
|
||||
return null;
|
||||
}
|
||||
String value = node.asText();
|
||||
return value == null || value.isBlank() ? null : value;
|
||||
}
|
||||
|
||||
private String readConfigString(Object value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String text = String.valueOf(value).trim();
|
||||
return text.isEmpty() ? null : text;
|
||||
}
|
||||
|
||||
private BigDecimal readConfigDecimal(Object value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (value instanceof BigDecimal decimal) {
|
||||
return decimal;
|
||||
}
|
||||
return new BigDecimal(String.valueOf(value));
|
||||
}
|
||||
|
||||
private void copyLlmProperties(AiModelDTO dto, LlmModel entity) {
|
||||
entity.setModelName(dto.getModelName());
|
||||
entity.setProvider(dto.getProvider());
|
||||
|
|
|
|||
|
|
@ -164,17 +164,17 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
AiModelVO asrModel = aiModelService.getModelById(asrModelId, "ASR");
|
||||
if (asrModel == null) throw new RuntimeException("ASR模型配置不存在");
|
||||
|
||||
String submitUrl = asrModel.getBaseUrl().endsWith("/") ? asrModel.getBaseUrl() + "api/tasks/recognition" : asrModel.getBaseUrl() + "/api/tasks/recognition";
|
||||
String submitUrl = appendPath(asrModel.getBaseUrl(), "api/v1/asr/transcriptions");
|
||||
String taskId = null;
|
||||
|
||||
updateProgress(meeting.getId(), 5, "正在提交识别请求...", 0);
|
||||
Map<String, Object> req = buildAsrRequest(meeting, taskRecord);
|
||||
Map<String, Object> req = buildAsrRequest(meeting, taskRecord, asrModel);
|
||||
taskRecord.setRequestData(req);
|
||||
this.updateById(taskRecord);
|
||||
|
||||
String respBody = postJson(submitUrl, req);
|
||||
String respBody = postJson(submitUrl, req, asrModel.getApiKey());
|
||||
JsonNode submitNode = objectMapper.readTree(respBody);
|
||||
if (submitNode.path("code").asInt() != 200) {
|
||||
if (submitNode.path("code").asInt() != 0) {
|
||||
updateAiTaskFail(taskRecord, "Submission Failed: " + respBody);
|
||||
throw new RuntimeException("ASR引擎拒绝请求: " + submitNode.path("msg").asText());
|
||||
}
|
||||
|
|
@ -182,7 +182,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
taskRecord.setResponseData(Map.of("task_id", taskId));
|
||||
this.updateById(taskRecord);
|
||||
|
||||
String queryUrl = asrModel.getBaseUrl().endsWith("/") ? asrModel.getBaseUrl() + "api/tasks/" + taskId : asrModel.getBaseUrl() + "/api/tasks/" + taskId;
|
||||
String queryUrl = appendPath(asrModel.getBaseUrl(), "api/v1/asr/transcriptions/" + taskId);
|
||||
|
||||
// 轮询逻辑 (带防卡死防护)
|
||||
JsonNode resultNode = null;
|
||||
|
|
@ -191,7 +191,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
|
||||
for (int i = 0; i < 600; i++) {
|
||||
Thread.sleep(2000);
|
||||
String queryResp = get(queryUrl);
|
||||
String queryResp = get(queryUrl, asrModel.getApiKey());
|
||||
JsonNode statusNode = objectMapper.readTree(queryResp);
|
||||
JsonNode data = statusNode.path("data");
|
||||
String status = data.path("status").asText();
|
||||
|
|
@ -223,7 +223,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
return saveTranscripts(meeting, resultNode);
|
||||
}
|
||||
|
||||
private Map<String, Object> buildAsrRequest(Meeting meeting, AiTask taskRecord) {
|
||||
private Map<String, Object> buildAsrRequest(Meeting meeting, AiTask taskRecord, AiModelVO asrModel) {
|
||||
Map<String, Object> req = new HashMap<>();
|
||||
String rawAudioUrl = meeting.getAudioUrl();
|
||||
String encodedAudioUrl = Arrays.stream(rawAudioUrl.split("/"))
|
||||
|
|
@ -232,11 +232,18 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
catch (Exception e) { return part; }
|
||||
})
|
||||
.collect(Collectors.joining("/"));
|
||||
req.put("file_path", serverBaseUrl + (encodedAudioUrl.startsWith("/") ? "" : "/") + encodedAudioUrl);
|
||||
req.put("file_url", serverBaseUrl + (encodedAudioUrl.startsWith("/") ? "" : "/") + encodedAudioUrl);
|
||||
|
||||
Map<String, Object> config = new HashMap<>();
|
||||
if (asrModel.getModelCode() != null && !asrModel.getModelCode().isBlank()) {
|
||||
config.put("model", asrModel.getModelCode());
|
||||
}
|
||||
|
||||
Object useSpkObj = taskRecord.getTaskConfig().get("useSpkId");
|
||||
boolean useSpk = useSpkObj != null && useSpkObj.toString().equals("1");
|
||||
req.put("use_spk_id", useSpk);
|
||||
config.put("enable_speaker", useSpk);
|
||||
config.put("enable_two_pass", true);
|
||||
|
||||
|
||||
List<Map<String, Object>> hotwords = new ArrayList<>();
|
||||
Object hotWordsObj = taskRecord.getTaskConfig().get("hotWords");
|
||||
|
|
@ -251,7 +258,8 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
}
|
||||
}
|
||||
}
|
||||
req.put("hotwords", hotwords);
|
||||
config.put("hotwords", hotwords);
|
||||
req.put("config", config);
|
||||
return req;
|
||||
}
|
||||
|
||||
|
|
@ -379,14 +387,32 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
}
|
||||
}
|
||||
|
||||
private String postJson(String url, Object body) throws Exception {
|
||||
return httpClient.send(HttpRequest.newBuilder().uri(URI.create(url)).header("Content-Type", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body))).build(),
|
||||
private String postJson(String url, Object body, String apiKey) throws Exception {
|
||||
HttpRequest.Builder builder = HttpRequest.newBuilder()
|
||||
.uri(URI.create(url))
|
||||
.header("Content-Type", "application/json");
|
||||
if (apiKey != null && !apiKey.isBlank()) {
|
||||
builder.header("Authorization", "Bearer " + apiKey);
|
||||
}
|
||||
return httpClient.send(builder
|
||||
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body)))
|
||||
.build(),
|
||||
HttpResponse.BodyHandlers.ofString()).body();
|
||||
}
|
||||
|
||||
private String get(String url) throws Exception {
|
||||
return httpClient.send(HttpRequest.newBuilder().uri(URI.create(url)).GET().build(), HttpResponse.BodyHandlers.ofString()).body();
|
||||
private String get(String url, String apiKey) throws Exception {
|
||||
HttpRequest.Builder builder = HttpRequest.newBuilder().uri(URI.create(url));
|
||||
if (apiKey != null && !apiKey.isBlank()) {
|
||||
builder.header("Authorization", "Bearer " + apiKey);
|
||||
}
|
||||
return httpClient.send(builder.GET().build(), HttpResponse.BodyHandlers.ofString()).body();
|
||||
}
|
||||
|
||||
private String appendPath(String baseUrl, String path) {
|
||||
if (baseUrl.endsWith("/")) {
|
||||
return baseUrl + path;
|
||||
}
|
||||
return baseUrl + "/" + path;
|
||||
}
|
||||
|
||||
private String sanitizeSummaryContent(String content) {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,15 @@ export interface AiModelVO {
|
|||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AiLocalProfileVO {
|
||||
asrModels: string[];
|
||||
speakerModels: string[];
|
||||
activeAsrModel?: string;
|
||||
activeSpeakerModel?: string;
|
||||
svThreshold?: number;
|
||||
wsEndpoint?: string;
|
||||
}
|
||||
|
||||
export interface AiModelDTO {
|
||||
id?: number;
|
||||
modelType: string;
|
||||
|
|
@ -84,6 +93,13 @@ export const getRemoteModelList = (params: { provider: string; baseUrl: string;
|
|||
);
|
||||
};
|
||||
|
||||
export const testLocalModelConnectivity = (data: { baseUrl: string; apiKey: string }) => {
|
||||
return http.post<any, { 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 }>(
|
||||
"/api/biz/aimodel/default",
|
||||
|
|
|
|||
|
|
@ -29,15 +29,18 @@ import {
|
|||
SaveOutlined,
|
||||
SearchOutlined,
|
||||
SyncOutlined,
|
||||
WifiOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useDict } from "../../hooks/useDict";
|
||||
import {
|
||||
AiModelDTO,
|
||||
AiLocalProfileVO,
|
||||
AiModelVO,
|
||||
deleteAiModelByType,
|
||||
getAiModelPage,
|
||||
getRemoteModelList,
|
||||
saveAiModel,
|
||||
testLocalModelConnectivity,
|
||||
updateAiModel,
|
||||
} from "../../api/business/aimodel";
|
||||
|
||||
|
|
@ -73,10 +76,14 @@ const AiModels: React.FC = () => {
|
|||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [submitLoading, setSubmitLoading] = useState(false);
|
||||
const [fetchLoading, setFetchLoading] = useState(false);
|
||||
const [connectivityLoading, setConnectivityLoading] = useState(false);
|
||||
const [remoteModels, setRemoteModels] = useState<string[]>([]);
|
||||
const [speakerModels, setSpeakerModels] = useState<string[]>([]);
|
||||
const modelNameAutoFilledRef = useRef(false);
|
||||
const localProfileLoadedRef = useRef(false);
|
||||
|
||||
const provider = Form.useWatch("provider", form);
|
||||
const isLocalProvider = String(provider || "").toLowerCase() === "custom";
|
||||
|
||||
const isPlatformAdmin = useMemo(() => {
|
||||
const profileStr = sessionStorage.getItem("userProfile");
|
||||
|
|
@ -132,19 +139,28 @@ const AiModels: React.FC = () => {
|
|||
|
||||
const openDrawer = (record?: AiModelVO) => {
|
||||
setRemoteModels([]);
|
||||
setSpeakerModels([]);
|
||||
modelNameAutoFilledRef.current = false;
|
||||
localProfileLoadedRef.current = false;
|
||||
|
||||
if (record) {
|
||||
setEditingId(record.id);
|
||||
const speakerModel = record.mediaConfig?.speakerModel;
|
||||
const svThreshold = record.mediaConfig?.svThreshold;
|
||||
form.setFieldsValue({
|
||||
...record,
|
||||
modelType: record.modelType,
|
||||
speakerModel,
|
||||
svThreshold,
|
||||
isDefaultChecked: record.isDefault === 1,
|
||||
statusChecked: record.status === 1,
|
||||
});
|
||||
if (record.modelCode) {
|
||||
setRemoteModels([record.modelCode]);
|
||||
}
|
||||
if (speakerModel) {
|
||||
setSpeakerModels([String(speakerModel)]);
|
||||
}
|
||||
} else {
|
||||
setEditingId(null);
|
||||
form.resetFields();
|
||||
|
|
@ -155,13 +171,33 @@ const AiModels: React.FC = () => {
|
|||
temperature: 0.7,
|
||||
topP: 0.9,
|
||||
apiPath: "/v1/chat/completions",
|
||||
svThreshold: 0.45,
|
||||
});
|
||||
}
|
||||
|
||||
setDrawerVisible(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!drawerVisible || !isLocalProvider || !editingId || localProfileLoadedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const values = form.getFieldsValue(["baseUrl", "apiKey"]);
|
||||
if (!values.baseUrl || !values.apiKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
localProfileLoadedRef.current = true;
|
||||
void handleTestConnectivity();
|
||||
}, [drawerVisible, isLocalProvider, editingId, form]);
|
||||
|
||||
const handleFetchRemote = async () => {
|
||||
if (isLocalProvider) {
|
||||
await handleTestConnectivity();
|
||||
return;
|
||||
}
|
||||
|
||||
const values = form.getFieldsValue(["provider", "baseUrl", "apiKey"]);
|
||||
if (!values.provider || !values.baseUrl) {
|
||||
message.warning("请先填写提供商和 Base URL");
|
||||
|
|
@ -180,6 +216,43 @@ const AiModels: React.FC = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const resolveLocalWsUrl = (baseUrl: string, wsEndpoint?: string) => {
|
||||
if (!wsEndpoint) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const base = new URL(baseUrl);
|
||||
const endpoint = wsEndpoint.startsWith("/") ? wsEndpoint : `/${wsEndpoint}`;
|
||||
const protocol = base.protocol === "https:" ? "wss:" : "ws:";
|
||||
return `${protocol}//${base.host}${endpoint}`;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const applyLocalProfile = (profile: AiLocalProfileVO, baseUrl: string) => {
|
||||
const nextRemoteModels = Array.isArray(profile.asrModels) ? profile.asrModels : [];
|
||||
const nextSpeakerModels = Array.isArray(profile.speakerModels) ? profile.speakerModels : [];
|
||||
setRemoteModels(nextRemoteModels);
|
||||
setSpeakerModels(nextSpeakerModels);
|
||||
|
||||
const nextValues: Record<string, unknown> = {};
|
||||
if (profile.activeAsrModel) {
|
||||
nextValues.modelCode = profile.activeAsrModel;
|
||||
}
|
||||
if (profile.activeSpeakerModel) {
|
||||
nextValues.speakerModel = profile.activeSpeakerModel;
|
||||
}
|
||||
if (profile.svThreshold !== undefined) {
|
||||
nextValues.svThreshold = profile.svThreshold;
|
||||
}
|
||||
const wsUrl = resolveLocalWsUrl(baseUrl, profile.wsEndpoint);
|
||||
if (wsUrl) {
|
||||
nextValues.wsUrl = wsUrl;
|
||||
}
|
||||
form.setFieldsValue(nextValues);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const values = await form.validateFields();
|
||||
const payload: AiModelDTO = {
|
||||
|
|
@ -192,6 +265,13 @@ const AiModels: React.FC = () => {
|
|||
apiKey: values.apiKey,
|
||||
modelCode: values.modelCode,
|
||||
wsUrl: values.wsUrl,
|
||||
mediaConfig:
|
||||
activeType === "ASR" && isLocalProvider
|
||||
? {
|
||||
speakerModel: values.speakerModel,
|
||||
svThreshold: values.svThreshold,
|
||||
}
|
||||
: undefined,
|
||||
temperature: values.temperature,
|
||||
topP: values.topP,
|
||||
isDefault: values.isDefaultChecked ? 1 : 0,
|
||||
|
|
@ -215,6 +295,27 @@ const AiModels: React.FC = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const handleTestConnectivity = async () => {
|
||||
const values = await form.validateFields(["provider", "baseUrl", "apiKey"]);
|
||||
if (String(values.provider || "").toLowerCase() !== "custom") {
|
||||
message.warning("仅本地模型支持连通性测试");
|
||||
return;
|
||||
}
|
||||
|
||||
setConnectivityLoading(true);
|
||||
try {
|
||||
const res = await testLocalModelConnectivity({
|
||||
baseUrl: values.baseUrl,
|
||||
apiKey: values.apiKey,
|
||||
});
|
||||
const profile = (res as any)?.data?.data ?? (res as any)?.data ?? (res as any);
|
||||
applyLocalProfile(profile as AiLocalProfileVO, values.baseUrl);
|
||||
message.success("本地模型连通性测试成功");
|
||||
} finally {
|
||||
setConnectivityLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (record: AiModelVO) => {
|
||||
await deleteAiModelByType(record.id, record.modelType);
|
||||
message.success("删除成功");
|
||||
|
|
@ -388,9 +489,19 @@ const AiModels: React.FC = () => {
|
|||
<Input placeholder="https://api.example.com/v1" />
|
||||
</Form.Item>
|
||||
|
||||
{!(activeType === "ASR" && provider === "Custom") && (
|
||||
<Form.Item name="apiKey" label="API Key">
|
||||
<Input.Password />
|
||||
<Form.Item
|
||||
name="apiKey"
|
||||
label="API Key"
|
||||
rules={isLocalProvider ? [{ required: true, message: "本地模型必须填写 API Key" }] : undefined}
|
||||
>
|
||||
<Input.Password />
|
||||
</Form.Item>
|
||||
|
||||
{isLocalProvider && (
|
||||
<Form.Item label="连通性测试">
|
||||
<Button icon={<WifiOutlined />} loading={connectivityLoading} onClick={handleTestConnectivity}>
|
||||
测试连接
|
||||
</Button>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
|
|
@ -413,8 +524,14 @@ const AiModels: React.FC = () => {
|
|||
allowClear
|
||||
style={{ width: "calc(100% - 100px)" }}
|
||||
placeholder="可选择或自定义输入模型名称"
|
||||
onFocus={() => {
|
||||
if (isLocalProvider && remoteModels.length === 0) {
|
||||
void handleFetchRemote();
|
||||
}
|
||||
}}
|
||||
options={remoteModels.map((model) => ({ value: model }))}
|
||||
filterOption={(inputValue, option) =>
|
||||
isLocalProvider ||
|
||||
String(option?.value || "").toLowerCase().includes(inputValue.toLowerCase())
|
||||
}
|
||||
>
|
||||
|
|
@ -433,6 +550,33 @@ const AiModels: React.FC = () => {
|
|||
</Form.Item>
|
||||
)}
|
||||
|
||||
{activeType === "ASR" && isLocalProvider && (
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="speakerModel"
|
||||
label="声纹模型"
|
||||
rules={[{ required: true, message: "请选择声纹模型" }]}
|
||||
>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="请先测试连接获取声纹模型"
|
||||
options={speakerModels.map((model) => ({ label: model, value: model }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="svThreshold"
|
||||
label="声纹阈值"
|
||||
rules={[{ required: true, message: "请输入声纹阈值" }]}
|
||||
>
|
||||
<InputNumber min={0} max={1} step={0.01} style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{activeType === "LLM" && (
|
||||
<>
|
||||
<Form.Item name="apiPath" label="API 路径" initialValue="/v1/chat/completions">
|
||||
|
|
|
|||
|
|
@ -1,358 +0,0 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, Button, Form, Input, Space, Select, Tag, message, Typography, Divider, Row, Col, DatePicker, Upload, Progress, Tooltip, Avatar, Switch } from 'antd';
|
||||
import {
|
||||
AudioOutlined, CheckCircleOutlined, UserOutlined, CloudUploadOutlined,
|
||||
LeftOutlined, SettingOutlined, QuestionCircleOutlined, InfoCircleOutlined,
|
||||
CalendarOutlined, TeamOutlined, RobotOutlined, RocketOutlined,
|
||||
FileTextOutlined, CheckOutlined
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import dayjs from 'dayjs';
|
||||
import { createMeeting, uploadAudio } from '../../api/business/meeting';
|
||||
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 { SysUser } from '../../types';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { Dragger } = Upload;
|
||||
const { Option } = Select;
|
||||
|
||||
const MeetingCreate: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [form] = Form.useForm();
|
||||
const [submitLoading, setSubmitLoading] = 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 watchedPromptId = Form.useWatch('promptId', form);
|
||||
|
||||
const [fileList, setFileList] = useState<any[]>([]);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [audioUrl, setAudioUrl] = useState('');
|
||||
|
||||
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
|
||||
});
|
||||
} catch (err) {}
|
||||
};
|
||||
|
||||
const customUpload = async (options: any) => {
|
||||
const { file, onSuccess, 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);
|
||||
onSuccess(res.data.data);
|
||||
message.success('录音上传成功');
|
||||
} catch (err) {
|
||||
onError(err);
|
||||
message.error('文件上传失败');
|
||||
}
|
||||
};
|
||||
|
||||
const onFinish = async (values: any) => {
|
||||
if (!audioUrl) {
|
||||
message.error('请先上传录音文件');
|
||||
return;
|
||||
}
|
||||
setSubmitLoading(true);
|
||||
try {
|
||||
await createMeeting({
|
||||
...values,
|
||||
meetingTime: values.meetingTime.format('YYYY-MM-DD HH:mm:ss'),
|
||||
audioUrl,
|
||||
participants: values.participants?.join(','),
|
||||
tags: values.tags?.join(',')
|
||||
});
|
||||
message.success('会议发起成功');
|
||||
navigate('/meetings');
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setSubmitLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
height: 'calc(100vh - 64px)',
|
||||
backgroundColor: '#f4f7f9',
|
||||
padding: '20px 24px',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}>
|
||||
<div style={{ maxWidth: 1300, margin: '0 auto', width: '100%', height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
|
||||
{/* 头部导航 - 紧凑化 */}
|
||||
<div style={{ marginBottom: 16, flexShrink: 0 }}>
|
||||
<Button icon={<LeftOutlined />} onClick={() => navigate('/meetings')} type="link" style={{ padding: 0, fontSize: '13px' }}>返回列表</Button>
|
||||
<div style={{ display: 'flex', alignItems: 'center', marginTop: 4 }}>
|
||||
<Title level={4} style={{ margin: 0 }}>发起新会议分析</Title>
|
||||
<Text type="secondary" size="small" style={{ marginLeft: 12 }}>请配置录音文件及 AI 模型参数</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Form form={form} layout="vertical" onFinish={onFinish} style={{ flex: 1, minHeight: 0 }}>
|
||||
<Row gutter={24} style={{ height: '100%' }}>
|
||||
{/* 左侧:文件与基础信息 */}
|
||||
<Col span={14} style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<Space direction="vertical" size={16} style={{ width: '100%', flex: 1, overflowY: 'auto', paddingRight: 8 }}>
|
||||
|
||||
<Card size="small" title={<Space><AudioOutlined /> 录音上传</Space>} bordered={false} style={{ borderRadius: 12, boxShadow: '0 2px 8px rgba(0,0,0,0.03)' }}>
|
||||
<Dragger
|
||||
accept=".mp3,.wav,.m4a"
|
||||
fileList={fileList}
|
||||
customRequest={customUpload}
|
||||
onChange={info => setFileList(info.fileList.slice(-1))}
|
||||
maxCount={1}
|
||||
style={{ borderRadius: 8, padding: '16px 0' }}
|
||||
>
|
||||
<p className="ant-upload-drag-icon" style={{ marginBottom: 4 }}><CloudUploadOutlined style={{ fontSize: 32 }} /></p>
|
||||
<p className="ant-upload-text" style={{ fontSize: 14 }}>点击或拖拽录音文件</p>
|
||||
{uploadProgress > 0 && uploadProgress < 100 && <Progress percent={uploadProgress} size="small" style={{ width: '60%', margin: '0 auto' }} />}
|
||||
{audioUrl && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Tag
|
||||
color="success"
|
||||
style={{
|
||||
maxWidth: '90%',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
display: 'inline-block',
|
||||
verticalAlign: 'middle'
|
||||
}}
|
||||
>
|
||||
就绪: {audioUrl.split('/').pop()}
|
||||
</Tag>
|
||||
</div>
|
||||
)}
|
||||
</Dragger>
|
||||
</Card>
|
||||
|
||||
<Card size="small" title={<Space><InfoCircleOutlined /> 基础信息</Space>} bordered={false} style={{ borderRadius: 12, boxShadow: '0 2px 8px rgba(0,0,0,0.03)' }}>
|
||||
<Form.Item name="title" label="会议标题" rules={[{ required: true }]} style={{ marginBottom: 12 }}>
|
||||
<Input placeholder="输入标题" />
|
||||
</Form.Item>
|
||||
<Row gutter={12}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="meetingTime" label="会议时间" rules={[{ required: true }]} style={{ marginBottom: 12 }}>
|
||||
<DatePicker showTime style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="tags" label="会议标签" style={{ marginBottom: 12 }}>
|
||||
<Select mode="tags" placeholder="输入标签" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Item name="participants" label="参会人员" style={{ marginBottom: 0 }}>
|
||||
<Select mode="multiple" placeholder="选择人员" showSearch optionFilterProp="children">
|
||||
{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>
|
||||
</Space>
|
||||
</Col>
|
||||
|
||||
{/* 右侧:AI 配置 - 固定且不滚动 */}
|
||||
<Col span={10} style={{ height: '100%' }}>
|
||||
<Card
|
||||
size="small"
|
||||
title={<Space><SettingOutlined /> AI 分析配置</Space>}
|
||||
bordered={false}
|
||||
style={{ borderRadius: 12, height: '100%', display: 'flex', flexDirection: 'column', boxShadow: '0 2px 8px rgba(0,0,0,0.03)' }}
|
||||
bodyStyle={{ flex: 1, display: 'flex', flexDirection: 'column', padding: '16px 20px' }}
|
||||
>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Form.Item name="asrModelId" label="语音识别 (ASR)" rules={[{ required: true }]} style={{ marginBottom: 16 }}>
|
||||
<Select placeholder="选择 ASR 模型">
|
||||
{asrModels.map(m => (
|
||||
<Option key={m.id} value={m.id}>{m.modelName} {m.isDefault === 1 && <Tag color="gold" size="small">默认</Tag>}</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="summaryModelId" label="内容总结 (LLM)" rules={[{ required: true }]} style={{ marginBottom: 16 }}>
|
||||
<Select placeholder="选择总结模型">
|
||||
{llmModels.map(m => (
|
||||
<Option key={m.id} value={m.id}>{m.modelName} {m.isDefault === 1 && <Tag color="gold" size="small">默认</Tag>}</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="promptId" label="总结模板" rules={[{ required: true }]} style={{ marginBottom: 12 }}>
|
||||
<div style={{ maxHeight: 180, overflowY: 'auto', overflowX: 'hidden', padding: '2px 4px' }}>
|
||||
<Row gutter={[8, 8]} style={{ margin: 0 }}>
|
||||
{prompts.map(p => {
|
||||
const isSelected = watchedPromptId === p.id;
|
||||
return (
|
||||
<Col span={8} key={p.id}>
|
||||
<div
|
||||
onClick={() => form.setFieldsValue({ promptId: p.id })}
|
||||
style={{
|
||||
padding: '8px 6px',
|
||||
borderRadius: 8,
|
||||
border: `1.5px solid ${isSelected ? '#1890ff' : '#f0f0f0'}`,
|
||||
backgroundColor: isSelected ? '#f0f7ff' : '#fff',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
position: 'relative',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
textAlign: 'center',
|
||||
boxShadow: isSelected ? '0 2px 6px rgba(24, 144, 255, 0.12)' : 'none'
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 6,
|
||||
backgroundColor: isSelected ? '#1890ff' : '#f5f5f5',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 4
|
||||
}}>
|
||||
<FileTextOutlined style={{ color: isSelected ? '#fff' : '#999', fontSize: 12 }} />
|
||||
</div>
|
||||
<div style={{
|
||||
fontWeight: 500,
|
||||
fontSize: '12px',
|
||||
color: isSelected ? '#1890ff' : '#434343',
|
||||
width: '100%',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
lineHeight: 1.2
|
||||
}} title={p.templateName}>
|
||||
{p.templateName}
|
||||
</div>
|
||||
{isSelected && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
width: 14,
|
||||
height: 14,
|
||||
backgroundColor: '#1890ff',
|
||||
borderRadius: '0 6px 0 6px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
<CheckOutlined style={{ color: '#fff', fontSize: 8 }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Col>
|
||||
);
|
||||
})}
|
||||
</Row>
|
||||
</div>
|
||||
</Form.Item>
|
||||
|
||||
<Row gutter={16} align="middle" style={{ marginBottom: 16 }}>
|
||||
<Col flex="auto">
|
||||
<Form.Item
|
||||
name="hotWords"
|
||||
label={<span>纠错热词 <Tooltip title="不选默认应用全部启用热词"><QuestionCircleOutlined /></Tooltip></span>}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Select mode="multiple" placeholder="可选热词" allowClear maxTagCount="responsive">
|
||||
{hotwordList.map(h => <Option key={h.word} value={h.word}>{h.word}</Option>)}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col>
|
||||
<Form.Item
|
||||
name="useSpkId"
|
||||
label={<span>声纹识别 <Tooltip title="开启后将区分不同发言人"><QuestionCircleOutlined /></Tooltip></span>}
|
||||
valuePropName="checked"
|
||||
getValueProps={(value) => ({ checked: value === 1 })}
|
||||
normalize={(value) => (value ? 1 : 0)}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
|
||||
<div style={{ flexShrink: 0 }}>
|
||||
<div style={{ backgroundColor: '#f6ffed', border: '1px solid #b7eb8f', padding: '10px 12px', borderRadius: 8, marginBottom: 16 }}>
|
||||
<Text type="secondary" style={{ fontSize: '12px' }}>
|
||||
<CheckCircleOutlined style={{ color: '#52c41a', marginRight: 6 }} />
|
||||
系统将自动执行:转录固化 + 智能总结。
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
block
|
||||
icon={<RocketOutlined />}
|
||||
htmlType="submit"
|
||||
loading={submitLoading}
|
||||
style={{ height: 48, borderRadius: 8, fontSize: 16, fontWeight: 600, boxShadow: '0 4px 12px rgba(24, 144, 255, 0.3)' }}
|
||||
>
|
||||
开始智能分析
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MeetingCreate;
|
||||
|
|
@ -8,7 +8,7 @@ import {
|
|||
QuestionCircleOutlined, FileTextOutlined, CheckOutlined, RocketOutlined,
|
||||
AudioOutlined
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { usePermission } from '../../hooks/usePermission';
|
||||
import { getMeetingPage, deleteMeeting, MeetingVO, getMeetingProgress, MeetingProgress, createMeeting, uploadAudio, updateMeetingParticipants } from '../../api/business/meeting';
|
||||
import { getAiModelPage, getAiModelDefault, AiModelVO } from '../../api/business/aimodel';
|
||||
|
|
@ -342,6 +342,7 @@ const Meetings: React.FC = () => {
|
|||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { can } = usePermission();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [submitLoading, setSubmitLoading] = useState(false);
|
||||
|
|
@ -353,6 +354,15 @@ const Meetings: React.FC = () => {
|
|||
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[]>([]);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,15 @@
|
|||
.home-landing {
|
||||
--home-primary-rgb: 103, 103, 244;
|
||||
--home-primary: #6767f4;
|
||||
--home-title-color: #272554;
|
||||
--home-body-color: #5d678c;
|
||||
--home-muted-color: #9198b2;
|
||||
--home-surface-strong: rgba(255, 255, 255, 0.92);
|
||||
--home-surface: rgba(247, 246, 255, 0.84);
|
||||
--home-surface-soft: rgba(255, 255, 255, 0.74);
|
||||
--home-border-strong: rgba(214, 205, 255, 0.96);
|
||||
--home-border: rgba(233, 228, 255, 0.96);
|
||||
--home-shadow: 0 22px 48px rgba(141, 132, 223, 0.14);
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -48,10 +59,10 @@
|
|||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(420px, 640px) 1fr;
|
||||
align-items: start;
|
||||
min-height: 258px;
|
||||
gap: 16px;
|
||||
grid-template-columns: minmax(420px, 1.06fr) minmax(320px, 0.94fr);
|
||||
align-items: center;
|
||||
min-height: 282px;
|
||||
gap: 28px;
|
||||
}
|
||||
|
||||
.home-landing__copy {
|
||||
|
|
@ -113,8 +124,9 @@
|
|||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
min-height: 300px;
|
||||
min-height: 276px;
|
||||
isolation: isolate;
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
.home-landing__visual::before {
|
||||
|
|
@ -122,8 +134,8 @@
|
|||
position: absolute;
|
||||
top: 16px;
|
||||
right: 14px;
|
||||
width: min(42vw, 520px);
|
||||
height: 292px;
|
||||
width: min(38vw, 480px);
|
||||
height: 264px;
|
||||
border-radius: 48px;
|
||||
background:
|
||||
radial-gradient(circle at 28% 30%, rgba(255, 255, 255, 0.96), rgba(255, 255, 255, 0) 42%),
|
||||
|
|
@ -134,8 +146,8 @@
|
|||
|
||||
.home-landing__visual-frame {
|
||||
position: relative;
|
||||
width: min(42vw, 560px);
|
||||
min-height: 304px;
|
||||
width: min(38vw, 500px);
|
||||
min-height: 278px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.84);
|
||||
border-radius: 34px;
|
||||
overflow: hidden;
|
||||
|
|
@ -224,8 +236,8 @@
|
|||
.home-landing__visual-radar {
|
||||
position: absolute;
|
||||
top: 34px;
|
||||
right: 58px;
|
||||
width: min(21vw, 246px);
|
||||
right: 54px;
|
||||
width: min(18vw, 214px);
|
||||
aspect-ratio: 1;
|
||||
border-radius: 50%;
|
||||
background:
|
||||
|
|
@ -259,17 +271,17 @@
|
|||
|
||||
.home-landing__visual-pulse--one {
|
||||
top: 52px;
|
||||
right: 78px;
|
||||
width: 188px;
|
||||
height: 188px;
|
||||
right: 70px;
|
||||
width: 164px;
|
||||
height: 164px;
|
||||
animation: visualPulseRing 6s ease-out infinite;
|
||||
}
|
||||
|
||||
.home-landing__visual-pulse--two {
|
||||
top: 34px;
|
||||
right: 58px;
|
||||
width: 226px;
|
||||
height: 226px;
|
||||
right: 48px;
|
||||
width: 196px;
|
||||
height: 196px;
|
||||
border-color: rgba(190, 182, 255, 0.16);
|
||||
animation: visualPulseRing 6s ease-out infinite 1.8s;
|
||||
}
|
||||
|
|
@ -333,10 +345,10 @@
|
|||
position: relative;
|
||||
z-index: 2;
|
||||
display: grid;
|
||||
grid-template-columns: 860px minmax(260px, 1fr);
|
||||
align-items: stretch;
|
||||
gap: 28px;
|
||||
margin-top: 16px;
|
||||
grid-template-columns: minmax(0, 1.5fr) minmax(280px, 0.72fr);
|
||||
align-items: start;
|
||||
gap: 22px;
|
||||
margin-top: 22px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
|
|
@ -346,20 +358,20 @@
|
|||
}
|
||||
|
||||
.home-landing__entry-grid--two {
|
||||
grid-template-columns: repeat(2, minmax(320px, 420px));
|
||||
justify-content: start;
|
||||
grid-template-columns: repeat(2, minmax(280px, 1fr));
|
||||
}
|
||||
|
||||
.home-landing__soundstage {
|
||||
position: relative;
|
||||
min-height: 240px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.84);
|
||||
border-radius: 32px;
|
||||
min-height: 224px;
|
||||
margin-top: 54px;
|
||||
border: 1px solid var(--home-border);
|
||||
border-radius: 30px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(249, 252, 255, 0.9), rgba(235, 242, 255, 0.8)),
|
||||
linear-gradient(180deg, var(--home-surface-strong), color-mix(in srgb, var(--home-surface) 78%, rgba(var(--home-primary-rgb), 0.08))),
|
||||
linear-gradient(135deg, rgba(185, 204, 255, 0.22), rgba(208, 236, 255, 0.12) 56%, rgba(255, 255, 255, 0));
|
||||
box-shadow:
|
||||
0 22px 44px rgba(102, 128, 204, 0.12),
|
||||
0 20px 38px rgba(var(--home-primary-rgb), 0.14),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.92);
|
||||
backdrop-filter: blur(18px);
|
||||
overflow: hidden;
|
||||
|
|
@ -368,17 +380,55 @@
|
|||
.home-landing__soundstage::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 16px;
|
||||
border: 1px solid rgba(218, 228, 255, 0.96);
|
||||
border-radius: 24px;
|
||||
inset: 14px;
|
||||
border: 1px solid rgba(var(--home-primary-rgb), 0.14);
|
||||
border-radius: 22px;
|
||||
}
|
||||
|
||||
.home-landing__soundstage-head {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 22px;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.home-landing__soundstage-kicker {
|
||||
display: inline-flex;
|
||||
width: fit-content;
|
||||
padding: 5px 10px;
|
||||
border: 1px solid rgba(var(--home-primary-rgb), 0.16);
|
||||
border-radius: 999px;
|
||||
background: rgba(var(--home-primary-rgb), 0.08);
|
||||
color: var(--home-primary);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.home-landing__soundstage-copy {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.home-landing__soundstage-copy span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 6px 10px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.64);
|
||||
color: var(--home-body-color);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.home-landing__board-glow {
|
||||
position: absolute;
|
||||
right: 22px;
|
||||
top: 24px;
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
right: 8px;
|
||||
top: 10px;
|
||||
width: 148px;
|
||||
height: 148px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(194, 212, 255, 0.7) 0%, rgba(169, 212, 255, 0.34) 42%, rgba(214, 226, 239, 0) 74%);
|
||||
filter: blur(10px);
|
||||
|
|
@ -387,7 +437,7 @@
|
|||
|
||||
.home-landing__board-grid {
|
||||
position: absolute;
|
||||
inset: 20px;
|
||||
inset: 18px;
|
||||
background-image:
|
||||
linear-gradient(rgba(120, 145, 212, 0.08) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(120, 145, 212, 0.08) 1px, transparent 1px);
|
||||
|
|
@ -409,10 +459,10 @@
|
|||
}
|
||||
|
||||
.home-landing__board-panel--summary {
|
||||
top: 26px;
|
||||
left: 24px;
|
||||
width: 170px;
|
||||
padding: 14px;
|
||||
top: 92px;
|
||||
left: 22px;
|
||||
width: 156px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.home-landing__board-pill {
|
||||
|
|
@ -428,8 +478,8 @@
|
|||
|
||||
.home-landing__board-lines {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: 14px;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.home-landing__board-line {
|
||||
|
|
@ -444,11 +494,11 @@
|
|||
.home-landing__board-line--sm { width: 62%; }
|
||||
|
||||
.home-landing__board-panel--activity {
|
||||
right: 26px;
|
||||
top: 34px;
|
||||
width: 124px;
|
||||
height: 152px;
|
||||
padding: 18px 16px;
|
||||
right: 22px;
|
||||
top: 82px;
|
||||
width: 108px;
|
||||
height: 132px;
|
||||
padding: 16px 14px;
|
||||
}
|
||||
|
||||
.home-landing__board-bars {
|
||||
|
|
@ -474,11 +524,11 @@
|
|||
.home-landing__board-bars span:nth-child(6) { height: 52px; animation-delay: 0.6s; }
|
||||
|
||||
.home-landing__board-panel--timeline {
|
||||
left: 24px;
|
||||
right: 24px;
|
||||
bottom: 28px;
|
||||
height: 74px;
|
||||
padding: 0 18px;
|
||||
left: 22px;
|
||||
right: 22px;
|
||||
bottom: 22px;
|
||||
height: 58px;
|
||||
padding: 0 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
|
@ -486,8 +536,8 @@
|
|||
|
||||
.home-landing__board-rail {
|
||||
position: absolute;
|
||||
left: 28px;
|
||||
right: 28px;
|
||||
left: 24px;
|
||||
right: 24px;
|
||||
top: 50%;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, rgba(194, 212, 255, 0.28), rgba(93, 128, 245, 0.62), rgba(188, 229, 255, 0.3));
|
||||
|
|
@ -513,15 +563,16 @@
|
|||
|
||||
.home-landing__board-stats {
|
||||
position: absolute;
|
||||
left: 216px;
|
||||
top: 34px;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
left: 194px;
|
||||
top: 96px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.home-landing__board-stat {
|
||||
width: 98px;
|
||||
height: 34px;
|
||||
width: 84px;
|
||||
height: 28px;
|
||||
border: 1px solid rgba(208, 223, 255, 0.94);
|
||||
border-radius: 14px;
|
||||
background:
|
||||
|
|
@ -534,9 +585,9 @@
|
|||
position: relative;
|
||||
min-height: 212px;
|
||||
padding: 20px 138px 18px 20px;
|
||||
border: 1px solid rgba(233, 226, 255, 0.9);
|
||||
border: 1px solid var(--home-border);
|
||||
border-radius: 28px;
|
||||
box-shadow: 0 18px 40px rgba(118, 109, 188, 0.12);
|
||||
box-shadow: 0 18px 40px rgba(var(--home-primary-rgb), 0.14);
|
||||
backdrop-filter: blur(18px);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
|
|
@ -570,7 +621,7 @@
|
|||
}
|
||||
|
||||
.home-entry-card:hover .home-entry-card__cta {
|
||||
color: #656cf6;
|
||||
color: var(--home-primary);
|
||||
}
|
||||
|
||||
.home-entry-card:hover .home-entry-card__cta .anticon {
|
||||
|
|
@ -654,7 +705,7 @@
|
|||
|
||||
.home-entry-card h3 {
|
||||
margin: 20px 0 10px !important;
|
||||
color: #283158 !important;
|
||||
color: var(--home-title-color) !important;
|
||||
font-size: 22px !important;
|
||||
}
|
||||
|
||||
|
|
@ -665,7 +716,7 @@
|
|||
|
||||
.home-entry-card__line {
|
||||
display: block;
|
||||
color: #5d678c;
|
||||
color: var(--home-body-color);
|
||||
font-size: 15px;
|
||||
line-height: 1.62;
|
||||
}
|
||||
|
|
@ -738,7 +789,7 @@
|
|||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
color: #656cf6;
|
||||
color: var(--home-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
transition: color 0.28s ease;
|
||||
|
|
@ -1014,6 +1065,297 @@
|
|||
}
|
||||
}
|
||||
|
||||
.home-landing__ambient-field {
|
||||
position: absolute;
|
||||
top: -36px;
|
||||
right: -64px;
|
||||
width: min(52vw, 760px);
|
||||
height: 620px;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.home-landing__ambient-orb,
|
||||
.home-landing__ambient-ripple,
|
||||
.home-landing__ambient-sheen,
|
||||
.home-landing__ambient-bubble,
|
||||
.home-landing__visual-note,
|
||||
.home-landing__soundstage-pills,
|
||||
.home-landing__soundstage-trace {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.home-landing__ambient-orb {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.home-landing__ambient-orb--main {
|
||||
inset: 0 18px 18px 0;
|
||||
background:
|
||||
radial-gradient(circle at 32% 30%, rgba(255, 255, 255, 0.98) 0%, rgba(239, 245, 255, 0.94) 34%, rgba(218, 230, 255, 0.86) 66%, rgba(255, 255, 255, 0) 100%),
|
||||
radial-gradient(circle at 55% 70%, rgba(var(--home-primary-rgb), 0.1) 0%, rgba(134, 213, 255, 0.08) 28%, rgba(255, 255, 255, 0) 62%);
|
||||
filter: blur(1px);
|
||||
opacity: 0.96;
|
||||
animation: ambientOrbFloat 18s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.home-landing__ambient-orb--ghost {
|
||||
right: 18px;
|
||||
bottom: 26px;
|
||||
width: 176px;
|
||||
height: 176px;
|
||||
background: radial-gradient(circle, rgba(236, 242, 255, 0.9) 0%, rgba(213, 224, 255, 0.42) 44%, rgba(255, 255, 255, 0) 74%);
|
||||
filter: blur(8px);
|
||||
opacity: 0.9;
|
||||
animation: ambientGhostFloat 14s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.home-landing__ambient-ripple {
|
||||
position: absolute;
|
||||
left: 82px;
|
||||
right: 128px;
|
||||
bottom: 96px;
|
||||
height: 208px;
|
||||
border-radius: 52% 48% 54% 46%;
|
||||
background: repeating-radial-gradient(ellipse at 34% 72%, rgba(var(--home-primary-rgb), 0.32) 0 2px, rgba(255, 255, 255, 0) 2px 9px);
|
||||
opacity: 0.72;
|
||||
transform: rotate(-12deg);
|
||||
filter: blur(0.2px);
|
||||
mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.14), rgba(0, 0, 0, 0.94));
|
||||
animation: ambientRippleShift 16s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.home-landing__ambient-sheen {
|
||||
position: absolute;
|
||||
right: 84px;
|
||||
top: 74px;
|
||||
width: 210px;
|
||||
height: 360px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0), rgba(255, 255, 255, 0.82), rgba(184, 212, 255, 0.14), rgba(255, 255, 255, 0));
|
||||
filter: blur(14px);
|
||||
opacity: 0.52;
|
||||
transform: rotate(18deg);
|
||||
animation: ambientSheenDrift 13s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.home-landing__ambient-bubble {
|
||||
position: absolute;
|
||||
border: 1px solid rgba(255, 255, 255, 0.76);
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(160deg, rgba(255, 255, 255, 0.94), rgba(219, 231, 255, 0.34));
|
||||
box-shadow:
|
||||
inset -6px -10px 18px rgba(var(--home-primary-rgb), 0.08),
|
||||
inset 6px 8px 14px rgba(255, 255, 255, 0.92),
|
||||
0 14px 30px rgba(var(--home-primary-rgb), 0.1);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.home-landing__ambient-bubble--lg {
|
||||
top: 34px;
|
||||
right: 84px;
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
animation: ambientBubbleFloat 11s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.home-landing__ambient-bubble--md {
|
||||
top: 24px;
|
||||
right: -8px;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
animation: ambientBubbleFloat 13s ease-in-out infinite 1.6s;
|
||||
}
|
||||
|
||||
.home-landing__ambient-bubble--sm {
|
||||
top: 164px;
|
||||
right: 34px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
animation: ambientBubbleFloat 10s ease-in-out infinite 0.8s;
|
||||
}
|
||||
|
||||
.home-landing__visual,
|
||||
.home-landing__soundstage {
|
||||
position: relative;
|
||||
border: none;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
backdrop-filter: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.home-landing__visual::before,
|
||||
.home-landing__soundstage::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.home-landing__visual {
|
||||
min-height: 300px;
|
||||
justify-content: flex-end;
|
||||
align-items: flex-start;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.home-landing__visual-note {
|
||||
position: absolute;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 9px 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.74);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.42);
|
||||
color: color-mix(in srgb, var(--home-primary) 70%, white 6%);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
backdrop-filter: blur(14px);
|
||||
box-shadow: 0 14px 28px rgba(var(--home-primary-rgb), 0.1);
|
||||
}
|
||||
|
||||
.home-landing__visual-note--top {
|
||||
top: 28px;
|
||||
left: 42px;
|
||||
}
|
||||
|
||||
.home-landing__visual-note--bottom {
|
||||
right: 44px;
|
||||
bottom: 14px;
|
||||
}
|
||||
|
||||
.home-landing__soundstage {
|
||||
min-height: 184px;
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
.home-landing__soundstage-head {
|
||||
top: 18px;
|
||||
left: 12px;
|
||||
}
|
||||
|
||||
.home-landing__soundstage-copy span {
|
||||
background: rgba(255, 255, 255, 0.44);
|
||||
border: 1px solid rgba(255, 255, 255, 0.56);
|
||||
box-shadow: 0 12px 24px rgba(var(--home-primary-rgb), 0.08);
|
||||
}
|
||||
|
||||
.home-landing__soundstage-pills {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
top: 30px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
max-width: 250px;
|
||||
}
|
||||
|
||||
.home-landing__soundstage-pills span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.68);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
color: var(--home-body-color);
|
||||
font-size: 12px;
|
||||
backdrop-filter: blur(12px);
|
||||
box-shadow: 0 12px 26px rgba(var(--home-primary-rgb), 0.08);
|
||||
}
|
||||
|
||||
.home-landing__soundstage-trace {
|
||||
position: absolute;
|
||||
left: 34px;
|
||||
right: 40px;
|
||||
bottom: 20px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
height: 54px;
|
||||
}
|
||||
|
||||
.home-landing__soundstage-trace span {
|
||||
width: 10px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.94), rgba(var(--home-primary-rgb), 0.54));
|
||||
box-shadow: 0 10px 18px rgba(var(--home-primary-rgb), 0.14);
|
||||
animation: ambientTracePulse 3.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.home-landing__soundstage-trace span:nth-child(1) { height: 12px; animation-delay: 0s; }
|
||||
.home-landing__soundstage-trace span:nth-child(2) { height: 18px; animation-delay: 0.18s; }
|
||||
.home-landing__soundstage-trace span:nth-child(3) { height: 26px; animation-delay: 0.36s; }
|
||||
.home-landing__soundstage-trace span:nth-child(4) { height: 34px; animation-delay: 0.54s; }
|
||||
.home-landing__soundstage-trace span:nth-child(5) { height: 28px; animation-delay: 0.72s; }
|
||||
.home-landing__soundstage-trace span:nth-child(6) { height: 20px; animation-delay: 0.9s; }
|
||||
.home-landing__soundstage-trace span:nth-child(7) { height: 14px; animation-delay: 1.08s; }
|
||||
|
||||
@keyframes ambientOrbFloat {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate3d(0, 0, 0) scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: translate3d(-12px, 12px, 0) scale(1.02);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ambientGhostFloat {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate3d(0, 0, 0) scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: translate3d(10px, -12px, 0) scale(1.04);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ambientRippleShift {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(-12deg) translate3d(0, 0, 0) scale(0.98);
|
||||
opacity: 0.66;
|
||||
}
|
||||
50% {
|
||||
transform: rotate(-8deg) translate3d(16px, -6px, 0) scale(1.02);
|
||||
opacity: 0.88;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ambientSheenDrift {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(18deg) translate3d(0, 0, 0);
|
||||
opacity: 0.42;
|
||||
}
|
||||
50% {
|
||||
transform: rotate(14deg) translate3d(20px, -10px, 0);
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ambientBubbleFloat {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
50% {
|
||||
transform: translate3d(-8px, 12px, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ambientTracePulse {
|
||||
0%,
|
||||
100% {
|
||||
transform: scaleY(0.72);
|
||||
opacity: 0.62;
|
||||
}
|
||||
50% {
|
||||
transform: scaleY(1.14);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@media (max-width: 1200px) {
|
||||
.home-landing__hero {
|
||||
grid-template-columns: 1fr;
|
||||
|
|
@ -1023,6 +1365,7 @@
|
|||
.home-landing__visual {
|
||||
justify-content: flex-start;
|
||||
min-height: 280px;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.home-landing__visual-frame {
|
||||
|
|
@ -1039,6 +1382,10 @@
|
|||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.home-landing__soundstage {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.home-entry-card {
|
||||
padding-right: 120px;
|
||||
}
|
||||
|
|
@ -1157,30 +1504,41 @@
|
|||
min-height: 208px;
|
||||
}
|
||||
|
||||
.home-landing__soundstage-head {
|
||||
top: 16px;
|
||||
left: 18px;
|
||||
}
|
||||
|
||||
.home-landing__board-panel--summary {
|
||||
width: 150px;
|
||||
padding: 12px;
|
||||
top: 84px;
|
||||
width: 140px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.home-landing__board-panel--activity {
|
||||
right: 20px;
|
||||
width: 110px;
|
||||
height: 138px;
|
||||
padding: 16px 14px;
|
||||
right: 18px;
|
||||
top: 84px;
|
||||
width: 96px;
|
||||
height: 118px;
|
||||
padding: 14px 12px;
|
||||
}
|
||||
|
||||
.home-landing__board-stats {
|
||||
left: 186px;
|
||||
gap: 10px;
|
||||
left: 172px;
|
||||
top: 90px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.home-landing__board-stat {
|
||||
width: 84px;
|
||||
height: 30px;
|
||||
width: 72px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.home-landing__board-panel--timeline {
|
||||
height: 66px;
|
||||
left: 18px;
|
||||
right: 18px;
|
||||
bottom: 18px;
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
.home-landing__recent {
|
||||
|
|
@ -1218,3 +1576,129 @@
|
|||
}
|
||||
|
||||
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.home-landing__ambient-field {
|
||||
top: 12px;
|
||||
right: -92px;
|
||||
width: min(92vw, 700px);
|
||||
height: 520px;
|
||||
}
|
||||
|
||||
.home-landing__visual-note--top {
|
||||
left: 18px;
|
||||
}
|
||||
|
||||
.home-landing__visual-note--bottom {
|
||||
right: 22px;
|
||||
bottom: 4px;
|
||||
}
|
||||
|
||||
.home-landing__soundstage-pills {
|
||||
right: 0;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.home-landing__ambient-field {
|
||||
top: 38px;
|
||||
right: -130px;
|
||||
width: 120vw;
|
||||
height: 420px;
|
||||
}
|
||||
|
||||
.home-landing__ambient-ripple {
|
||||
left: 34px;
|
||||
right: 84px;
|
||||
bottom: 78px;
|
||||
height: 136px;
|
||||
}
|
||||
|
||||
.home-landing__ambient-sheen {
|
||||
top: 54px;
|
||||
right: 74px;
|
||||
width: 120px;
|
||||
height: 240px;
|
||||
}
|
||||
|
||||
.home-landing__ambient-bubble--lg {
|
||||
width: 62px;
|
||||
height: 62px;
|
||||
right: 104px;
|
||||
}
|
||||
|
||||
.home-landing__ambient-bubble--md {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
right: 26px;
|
||||
}
|
||||
|
||||
.home-landing__ambient-bubble--sm {
|
||||
top: 126px;
|
||||
right: 58px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.home-landing__visual {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.home-landing__visual-note {
|
||||
padding: 8px 12px;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.home-landing__visual-note--top {
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
}
|
||||
|
||||
.home-landing__visual-note--bottom {
|
||||
right: 10px;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.home-landing__soundstage {
|
||||
min-height: 156px;
|
||||
}
|
||||
|
||||
.home-landing__soundstage-copy span,
|
||||
.home-landing__soundstage-pills span {
|
||||
padding: 6px 10px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.home-landing__soundstage-pills {
|
||||
top: 74px;
|
||||
left: 12px;
|
||||
right: auto;
|
||||
justify-content: flex-start;
|
||||
max-width: 240px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.home-landing__soundstage-trace {
|
||||
left: 18px;
|
||||
right: 18px;
|
||||
bottom: 8px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.home-landing__soundstage-trace span {
|
||||
width: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.home-landing__ambient-orb--main,
|
||||
.home-landing__ambient-orb--ghost,
|
||||
.home-landing__ambient-ripple,
|
||||
.home-landing__ambient-sheen,
|
||||
.home-landing__ambient-bubble,
|
||||
.home-landing__soundstage-trace span {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
AudioOutlined,
|
||||
ArrowRightOutlined,
|
||||
|
|
@ -14,6 +14,7 @@ import dayjs from "dayjs";
|
|||
import { getRecentTasks } from "@/api/business/dashboard";
|
||||
import type { MeetingVO } from "@/api/business/meeting";
|
||||
import "./index.less";
|
||||
import RightVisual from "./RightVisual";
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
|
|
@ -108,7 +109,7 @@ export default function HomePage() {
|
|||
icon: <VideoCameraAddOutlined />,
|
||||
description: ["上传录音文件,区分发言人并整理内容", "适合访谈录音、培训音频、课程复盘"],
|
||||
accent: "cyan",
|
||||
onClick: () => navigate("/meeting-create")
|
||||
onClick: () => navigate("/meetings?create=true")
|
||||
}
|
||||
],
|
||||
[navigate]
|
||||
|
|
@ -120,6 +121,7 @@ export default function HomePage() {
|
|||
<div className="home-landing">
|
||||
<div className="home-landing__halo home-landing__halo--large" />
|
||||
<div className="home-landing__halo home-landing__halo--small" />
|
||||
{/* Replaced ambient field with dynamic right visual */}
|
||||
|
||||
<section className="home-landing__hero">
|
||||
<div className="home-landing__copy">
|
||||
|
|
@ -144,22 +146,7 @@ export default function HomePage() {
|
|||
</div>
|
||||
|
||||
<div className="home-landing__visual" aria-hidden="true">
|
||||
<div className="home-landing__visual-frame">
|
||||
<div className="home-landing__visual-glow home-landing__visual-glow--primary" />
|
||||
<div className="home-landing__visual-glow home-landing__visual-glow--secondary" />
|
||||
<div className="home-landing__visual-grid" />
|
||||
<div className="home-landing__visual-beam" />
|
||||
<div className="home-landing__visual-radar" />
|
||||
<div className="home-landing__visual-pulse home-landing__visual-pulse--one" />
|
||||
<div className="home-landing__visual-pulse home-landing__visual-pulse--two" />
|
||||
<div className="home-landing__visual-chip home-landing__visual-chip--top">Live capture</div>
|
||||
<div className="home-landing__visual-chip home-landing__visual-chip--bottom">Speaker focus</div>
|
||||
<div className="home-landing__visual-waveform">
|
||||
{Array.from({ length: 10 }).map((_, index) => (
|
||||
<span key={`visual-wave-${index}`} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<RightVisual />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
|
@ -215,36 +202,26 @@ export default function HomePage() {
|
|||
</div>
|
||||
|
||||
<div className="home-landing__soundstage" aria-hidden="true">
|
||||
<div className="home-landing__board-glow" />
|
||||
<div className="home-landing__board-grid" />
|
||||
<div className="home-landing__board-panel home-landing__board-panel--summary">
|
||||
<span className="home-landing__board-pill">Meeting Summary</span>
|
||||
<div className="home-landing__board-lines">
|
||||
<span className="home-landing__board-line home-landing__board-line--lg" />
|
||||
<span className="home-landing__board-line home-landing__board-line--md" />
|
||||
<span className="home-landing__board-line home-landing__board-line--sm" />
|
||||
<div className="home-landing__soundstage-head">
|
||||
<span className="home-landing__soundstage-kicker">Workflow Lens</span>
|
||||
<div className="home-landing__soundstage-copy">
|
||||
<span>会中记录</span>
|
||||
<span>会后整理</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="home-landing__board-panel home-landing__board-panel--activity">
|
||||
<div className="home-landing__board-bars">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
<div className="home-landing__soundstage-pills">
|
||||
<span>自动转写</span>
|
||||
<span>发言人区分</span>
|
||||
<span>纪要整理</span>
|
||||
</div>
|
||||
<div className="home-landing__board-panel home-landing__board-panel--timeline">
|
||||
<div className="home-landing__board-node home-landing__board-node--active" />
|
||||
<div className="home-landing__board-node" />
|
||||
<div className="home-landing__board-node" />
|
||||
<div className="home-landing__board-rail" />
|
||||
</div>
|
||||
<div className="home-landing__board-stats">
|
||||
<div className="home-landing__board-stat" />
|
||||
<div className="home-landing__board-stat" />
|
||||
<div className="home-landing__board-stat" />
|
||||
<div className="home-landing__soundstage-trace">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -299,5 +276,3 @@ export default function HomePage() {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@ import PromptTemplates from "../pages/business/PromptTemplates";
|
|||
import AiModels from "../pages/business/AiModels";
|
||||
import Meetings from "../pages/business/Meetings";
|
||||
import MeetingDetail from "../pages/business/MeetingDetail";
|
||||
import MeetingCreate from "../pages/business/MeetingCreate";
|
||||
|
||||
|
||||
function RouteFallback() {
|
||||
|
|
@ -60,8 +59,7 @@ export const menuRoutes: MenuRoute[] = [
|
|||
{ path: "/hotwords", label: "热词管理", element: <HotWords />, perm: "menu:hotword" },
|
||||
{ path: "/prompts", label: "总结模板", element: <PromptTemplates />, perm: "menu:prompt" },
|
||||
{ path: "/aimodels", label: "模型配置", element: <AiModels />, perm: "menu:aimodel" },
|
||||
{ path: "/meetings", label: "会议中心", element: <Meetings />, perm: "menu:meeting" },
|
||||
{ path: "/meeting-create", label: "发起会议", element: <MeetingCreate />, perm: "menu:meeting:create" }
|
||||
{ path: "/meetings", label: "会议中心", element: <Meetings />, perm: "menu:meeting" }
|
||||
];
|
||||
|
||||
export const extraRoutes = [
|
||||
|
|
|
|||
Loading…
Reference in New Issue