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.AiModelDTO;
|
||||||
|
import com.imeeting.dto.biz.AiLocalProfileVO;
|
||||||
import com.imeeting.dto.biz.AiModelVO;
|
import com.imeeting.dto.biz.AiModelVO;
|
||||||
|
|
||||||
import com.imeeting.service.biz.AiModelService;
|
import com.imeeting.service.biz.AiModelService;
|
||||||
|
|
@ -93,6 +94,18 @@ public class AiModelController {
|
||||||
return ApiResponse.ok(aiModelService.fetchRemoteModels(provider, baseUrl, apiKey));
|
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")
|
@GetMapping("/default")
|
||||||
@PreAuthorize("isAuthenticated()")
|
@PreAuthorize("isAuthenticated()")
|
||||||
public ApiResponse<AiModelVO> getDefault(@RequestParam String type) {
|
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.AiModelDTO;
|
||||||
|
import com.imeeting.dto.biz.AiLocalProfileVO;
|
||||||
import com.imeeting.dto.biz.AiModelVO;
|
import com.imeeting.dto.biz.AiModelVO;
|
||||||
import com.unisbase.dto.PageResult;
|
import com.unisbase.dto.PageResult;
|
||||||
|
|
||||||
|
|
@ -12,6 +13,7 @@ public interface AiModelService {
|
||||||
AiModelVO updateModel(AiModelDTO dto);
|
AiModelVO updateModel(AiModelDTO dto);
|
||||||
PageResult<List<AiModelVO>> pageModels(Integer current, Integer size, String name, String type, Long tenantId);
|
PageResult<List<AiModelVO>> pageModels(Integer current, Integer size, String name, String type, Long tenantId);
|
||||||
List<String> fetchRemoteModels(String provider, String baseUrl, String apiKey);
|
List<String> fetchRemoteModels(String provider, String baseUrl, String apiKey);
|
||||||
|
AiLocalProfileVO testLocalConnectivity(String baseUrl, String apiKey);
|
||||||
AiModelVO getDefaultModel(String type, Long tenantId);
|
AiModelVO getDefaultModel(String type, Long tenantId);
|
||||||
AiModelVO getModelById(Long id, String type);
|
AiModelVO getModelById(Long id, String type);
|
||||||
boolean removeModelById(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.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
|
||||||
import com.imeeting.dto.biz.AiModelDTO;
|
import com.imeeting.dto.biz.AiModelDTO;
|
||||||
|
import com.imeeting.dto.biz.AiLocalProfileVO;
|
||||||
import com.imeeting.dto.biz.AiModelVO;
|
import com.imeeting.dto.biz.AiModelVO;
|
||||||
import com.imeeting.entity.biz.AsrModel;
|
import com.imeeting.entity.biz.AsrModel;
|
||||||
import com.imeeting.entity.biz.LlmModel;
|
import com.imeeting.entity.biz.LlmModel;
|
||||||
|
|
@ -55,6 +56,7 @@ public class AiModelServiceImpl implements AiModelService {
|
||||||
@Transactional(rollbackFor = Exception.class)
|
@Transactional(rollbackFor = Exception.class)
|
||||||
public AiModelVO saveModel(AiModelDTO dto) {
|
public AiModelVO saveModel(AiModelDTO dto) {
|
||||||
String type = normalizeType(dto.getModelType());
|
String type = normalizeType(dto.getModelType());
|
||||||
|
validateModel(dto);
|
||||||
if (TYPE_ASR.equals(type)) {
|
if (TYPE_ASR.equals(type)) {
|
||||||
AsrModel entity = new AsrModel();
|
AsrModel entity = new AsrModel();
|
||||||
copyAsrProperties(dto, entity);
|
copyAsrProperties(dto, entity);
|
||||||
|
|
@ -76,6 +78,7 @@ public class AiModelServiceImpl implements AiModelService {
|
||||||
@Transactional(rollbackFor = Exception.class)
|
@Transactional(rollbackFor = Exception.class)
|
||||||
public AiModelVO updateModel(AiModelDTO dto) {
|
public AiModelVO updateModel(AiModelDTO dto) {
|
||||||
String type = normalizeType(dto.getModelType());
|
String type = normalizeType(dto.getModelType());
|
||||||
|
validateModel(dto);
|
||||||
if (TYPE_ASR.equals(type)) {
|
if (TYPE_ASR.equals(type)) {
|
||||||
AsrModel entity = asrModelMapper.selectById(dto.getId());
|
AsrModel entity = asrModelMapper.selectById(dto.getId());
|
||||||
if (entity == null) {
|
if (entity == null) {
|
||||||
|
|
@ -145,6 +148,9 @@ public class AiModelServiceImpl implements AiModelService {
|
||||||
if (resolvedBaseUrl == null || resolvedBaseUrl.isBlank()) {
|
if (resolvedBaseUrl == null || resolvedBaseUrl.isBlank()) {
|
||||||
return Collections.emptyList();
|
return Collections.emptyList();
|
||||||
}
|
}
|
||||||
|
if ("custom".equals(providerKey)) {
|
||||||
|
return fetchLocalProfile(resolvedBaseUrl, apiKey).getAsrModels();
|
||||||
|
}
|
||||||
String targetUrl = resolveModelListUrl(providerKey, resolvedBaseUrl, apiKey);
|
String targetUrl = resolveModelListUrl(providerKey, resolvedBaseUrl, apiKey);
|
||||||
if (targetUrl == null || targetUrl.isBlank()) {
|
if (targetUrl == null || targetUrl.isBlank()) {
|
||||||
return Collections.emptyList();
|
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) {
|
private String resolveBaseUrl(String providerKey, String baseUrl) {
|
||||||
if (baseUrl != null && !baseUrl.isBlank()) {
|
if (baseUrl != null && !baseUrl.isBlank()) {
|
||||||
return baseUrl;
|
return baseUrl;
|
||||||
|
|
@ -267,6 +355,26 @@ public class AiModelServiceImpl implements AiModelService {
|
||||||
return provider.trim().toLowerCase();
|
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
|
@Override
|
||||||
public AiModelVO getDefaultModel(String type, Long tenantId) {
|
public AiModelVO getDefaultModel(String type, Long tenantId) {
|
||||||
String resolvedType = normalizeType(type);
|
String resolvedType = normalizeType(type);
|
||||||
|
|
@ -340,6 +448,10 @@ public class AiModelServiceImpl implements AiModelService {
|
||||||
}
|
}
|
||||||
|
|
||||||
private void pushAsrConfig(AsrModel entity) {
|
private void pushAsrConfig(AsrModel entity) {
|
||||||
|
if ("custom".equals(normalizeProvider(entity.getProvider()))) {
|
||||||
|
updateLocalProfile(entity);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (entity.getBaseUrl() == null || entity.getBaseUrl().isBlank()) {
|
if (entity.getBaseUrl() == null || entity.getBaseUrl().isBlank()) {
|
||||||
throw new RuntimeException("baseUrl is required for ASR model");
|
throw new RuntimeException("baseUrl is required for ASR model");
|
||||||
}
|
}
|
||||||
|
|
@ -384,6 +496,51 @@ public class AiModelServiceImpl implements AiModelService {
|
||||||
entity.setRemark(dto.getRemark());
|
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) {
|
private void copyLlmProperties(AiModelDTO dto, LlmModel entity) {
|
||||||
entity.setModelName(dto.getModelName());
|
entity.setModelName(dto.getModelName());
|
||||||
entity.setProvider(dto.getProvider());
|
entity.setProvider(dto.getProvider());
|
||||||
|
|
|
||||||
|
|
@ -164,17 +164,17 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
AiModelVO asrModel = aiModelService.getModelById(asrModelId, "ASR");
|
AiModelVO asrModel = aiModelService.getModelById(asrModelId, "ASR");
|
||||||
if (asrModel == null) throw new RuntimeException("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;
|
String taskId = null;
|
||||||
|
|
||||||
updateProgress(meeting.getId(), 5, "正在提交识别请求...", 0);
|
updateProgress(meeting.getId(), 5, "正在提交识别请求...", 0);
|
||||||
Map<String, Object> req = buildAsrRequest(meeting, taskRecord);
|
Map<String, Object> req = buildAsrRequest(meeting, taskRecord, asrModel);
|
||||||
taskRecord.setRequestData(req);
|
taskRecord.setRequestData(req);
|
||||||
this.updateById(taskRecord);
|
this.updateById(taskRecord);
|
||||||
|
|
||||||
String respBody = postJson(submitUrl, req);
|
String respBody = postJson(submitUrl, req, asrModel.getApiKey());
|
||||||
JsonNode submitNode = objectMapper.readTree(respBody);
|
JsonNode submitNode = objectMapper.readTree(respBody);
|
||||||
if (submitNode.path("code").asInt() != 200) {
|
if (submitNode.path("code").asInt() != 0) {
|
||||||
updateAiTaskFail(taskRecord, "Submission Failed: " + respBody);
|
updateAiTaskFail(taskRecord, "Submission Failed: " + respBody);
|
||||||
throw new RuntimeException("ASR引擎拒绝请求: " + submitNode.path("msg").asText());
|
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));
|
taskRecord.setResponseData(Map.of("task_id", taskId));
|
||||||
this.updateById(taskRecord);
|
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;
|
JsonNode resultNode = null;
|
||||||
|
|
@ -191,7 +191,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
|
|
||||||
for (int i = 0; i < 600; i++) {
|
for (int i = 0; i < 600; i++) {
|
||||||
Thread.sleep(2000);
|
Thread.sleep(2000);
|
||||||
String queryResp = get(queryUrl);
|
String queryResp = get(queryUrl, asrModel.getApiKey());
|
||||||
JsonNode statusNode = objectMapper.readTree(queryResp);
|
JsonNode statusNode = objectMapper.readTree(queryResp);
|
||||||
JsonNode data = statusNode.path("data");
|
JsonNode data = statusNode.path("data");
|
||||||
String status = data.path("status").asText();
|
String status = data.path("status").asText();
|
||||||
|
|
@ -223,7 +223,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
return saveTranscripts(meeting, resultNode);
|
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<>();
|
Map<String, Object> req = new HashMap<>();
|
||||||
String rawAudioUrl = meeting.getAudioUrl();
|
String rawAudioUrl = meeting.getAudioUrl();
|
||||||
String encodedAudioUrl = Arrays.stream(rawAudioUrl.split("/"))
|
String encodedAudioUrl = Arrays.stream(rawAudioUrl.split("/"))
|
||||||
|
|
@ -232,11 +232,18 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
catch (Exception e) { return part; }
|
catch (Exception e) { return part; }
|
||||||
})
|
})
|
||||||
.collect(Collectors.joining("/"));
|
.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");
|
Object useSpkObj = taskRecord.getTaskConfig().get("useSpkId");
|
||||||
boolean useSpk = useSpkObj != null && useSpkObj.toString().equals("1");
|
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<>();
|
List<Map<String, Object>> hotwords = new ArrayList<>();
|
||||||
Object hotWordsObj = taskRecord.getTaskConfig().get("hotWords");
|
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;
|
return req;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -379,14 +387,32 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private String postJson(String url, Object body) throws Exception {
|
private String postJson(String url, Object body, String apiKey) throws Exception {
|
||||||
return httpClient.send(HttpRequest.newBuilder().uri(URI.create(url)).header("Content-Type", "application/json")
|
HttpRequest.Builder builder = HttpRequest.newBuilder()
|
||||||
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body))).build(),
|
.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();
|
HttpResponse.BodyHandlers.ofString()).body();
|
||||||
}
|
}
|
||||||
|
|
||||||
private String get(String url) throws Exception {
|
private String get(String url, String apiKey) throws Exception {
|
||||||
return httpClient.send(HttpRequest.newBuilder().uri(URI.create(url)).GET().build(), HttpResponse.BodyHandlers.ofString()).body();
|
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) {
|
private String sanitizeSummaryContent(String content) {
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,15 @@ export interface AiModelVO {
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AiLocalProfileVO {
|
||||||
|
asrModels: string[];
|
||||||
|
speakerModels: string[];
|
||||||
|
activeAsrModel?: string;
|
||||||
|
activeSpeakerModel?: string;
|
||||||
|
svThreshold?: number;
|
||||||
|
wsEndpoint?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AiModelDTO {
|
export interface AiModelDTO {
|
||||||
id?: number;
|
id?: number;
|
||||||
modelType: string;
|
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') => {
|
export const getAiModelDefault = (type: 'ASR' | 'LLM') => {
|
||||||
return http.get<any, { code: string; data: AiModelVO; msg: string }>(
|
return http.get<any, { code: string; data: AiModelVO; msg: string }>(
|
||||||
"/api/biz/aimodel/default",
|
"/api/biz/aimodel/default",
|
||||||
|
|
|
||||||
|
|
@ -29,15 +29,18 @@ import {
|
||||||
SaveOutlined,
|
SaveOutlined,
|
||||||
SearchOutlined,
|
SearchOutlined,
|
||||||
SyncOutlined,
|
SyncOutlined,
|
||||||
|
WifiOutlined,
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import { useDict } from "../../hooks/useDict";
|
import { useDict } from "../../hooks/useDict";
|
||||||
import {
|
import {
|
||||||
AiModelDTO,
|
AiModelDTO,
|
||||||
|
AiLocalProfileVO,
|
||||||
AiModelVO,
|
AiModelVO,
|
||||||
deleteAiModelByType,
|
deleteAiModelByType,
|
||||||
getAiModelPage,
|
getAiModelPage,
|
||||||
getRemoteModelList,
|
getRemoteModelList,
|
||||||
saveAiModel,
|
saveAiModel,
|
||||||
|
testLocalModelConnectivity,
|
||||||
updateAiModel,
|
updateAiModel,
|
||||||
} from "../../api/business/aimodel";
|
} from "../../api/business/aimodel";
|
||||||
|
|
||||||
|
|
@ -73,10 +76,14 @@ const AiModels: React.FC = () => {
|
||||||
const [editingId, setEditingId] = useState<number | null>(null);
|
const [editingId, setEditingId] = useState<number | null>(null);
|
||||||
const [submitLoading, setSubmitLoading] = useState(false);
|
const [submitLoading, setSubmitLoading] = useState(false);
|
||||||
const [fetchLoading, setFetchLoading] = useState(false);
|
const [fetchLoading, setFetchLoading] = useState(false);
|
||||||
|
const [connectivityLoading, setConnectivityLoading] = useState(false);
|
||||||
const [remoteModels, setRemoteModels] = useState<string[]>([]);
|
const [remoteModels, setRemoteModels] = useState<string[]>([]);
|
||||||
|
const [speakerModels, setSpeakerModels] = useState<string[]>([]);
|
||||||
const modelNameAutoFilledRef = useRef(false);
|
const modelNameAutoFilledRef = useRef(false);
|
||||||
|
const localProfileLoadedRef = useRef(false);
|
||||||
|
|
||||||
const provider = Form.useWatch("provider", form);
|
const provider = Form.useWatch("provider", form);
|
||||||
|
const isLocalProvider = String(provider || "").toLowerCase() === "custom";
|
||||||
|
|
||||||
const isPlatformAdmin = useMemo(() => {
|
const isPlatformAdmin = useMemo(() => {
|
||||||
const profileStr = sessionStorage.getItem("userProfile");
|
const profileStr = sessionStorage.getItem("userProfile");
|
||||||
|
|
@ -132,19 +139,28 @@ const AiModels: React.FC = () => {
|
||||||
|
|
||||||
const openDrawer = (record?: AiModelVO) => {
|
const openDrawer = (record?: AiModelVO) => {
|
||||||
setRemoteModels([]);
|
setRemoteModels([]);
|
||||||
|
setSpeakerModels([]);
|
||||||
modelNameAutoFilledRef.current = false;
|
modelNameAutoFilledRef.current = false;
|
||||||
|
localProfileLoadedRef.current = false;
|
||||||
|
|
||||||
if (record) {
|
if (record) {
|
||||||
setEditingId(record.id);
|
setEditingId(record.id);
|
||||||
|
const speakerModel = record.mediaConfig?.speakerModel;
|
||||||
|
const svThreshold = record.mediaConfig?.svThreshold;
|
||||||
form.setFieldsValue({
|
form.setFieldsValue({
|
||||||
...record,
|
...record,
|
||||||
modelType: record.modelType,
|
modelType: record.modelType,
|
||||||
|
speakerModel,
|
||||||
|
svThreshold,
|
||||||
isDefaultChecked: record.isDefault === 1,
|
isDefaultChecked: record.isDefault === 1,
|
||||||
statusChecked: record.status === 1,
|
statusChecked: record.status === 1,
|
||||||
});
|
});
|
||||||
if (record.modelCode) {
|
if (record.modelCode) {
|
||||||
setRemoteModels([record.modelCode]);
|
setRemoteModels([record.modelCode]);
|
||||||
}
|
}
|
||||||
|
if (speakerModel) {
|
||||||
|
setSpeakerModels([String(speakerModel)]);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
|
|
@ -155,13 +171,33 @@ const AiModels: React.FC = () => {
|
||||||
temperature: 0.7,
|
temperature: 0.7,
|
||||||
topP: 0.9,
|
topP: 0.9,
|
||||||
apiPath: "/v1/chat/completions",
|
apiPath: "/v1/chat/completions",
|
||||||
|
svThreshold: 0.45,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setDrawerVisible(true);
|
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 () => {
|
const handleFetchRemote = async () => {
|
||||||
|
if (isLocalProvider) {
|
||||||
|
await handleTestConnectivity();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const values = form.getFieldsValue(["provider", "baseUrl", "apiKey"]);
|
const values = form.getFieldsValue(["provider", "baseUrl", "apiKey"]);
|
||||||
if (!values.provider || !values.baseUrl) {
|
if (!values.provider || !values.baseUrl) {
|
||||||
message.warning("请先填写提供商和 Base URL");
|
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 handleSubmit = async () => {
|
||||||
const values = await form.validateFields();
|
const values = await form.validateFields();
|
||||||
const payload: AiModelDTO = {
|
const payload: AiModelDTO = {
|
||||||
|
|
@ -192,6 +265,13 @@ const AiModels: React.FC = () => {
|
||||||
apiKey: values.apiKey,
|
apiKey: values.apiKey,
|
||||||
modelCode: values.modelCode,
|
modelCode: values.modelCode,
|
||||||
wsUrl: values.wsUrl,
|
wsUrl: values.wsUrl,
|
||||||
|
mediaConfig:
|
||||||
|
activeType === "ASR" && isLocalProvider
|
||||||
|
? {
|
||||||
|
speakerModel: values.speakerModel,
|
||||||
|
svThreshold: values.svThreshold,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
temperature: values.temperature,
|
temperature: values.temperature,
|
||||||
topP: values.topP,
|
topP: values.topP,
|
||||||
isDefault: values.isDefaultChecked ? 1 : 0,
|
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) => {
|
const handleDelete = async (record: AiModelVO) => {
|
||||||
await deleteAiModelByType(record.id, record.modelType);
|
await deleteAiModelByType(record.id, record.modelType);
|
||||||
message.success("删除成功");
|
message.success("删除成功");
|
||||||
|
|
@ -388,9 +489,19 @@ const AiModels: React.FC = () => {
|
||||||
<Input placeholder="https://api.example.com/v1" />
|
<Input placeholder="https://api.example.com/v1" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
{!(activeType === "ASR" && provider === "Custom") && (
|
<Form.Item
|
||||||
<Form.Item name="apiKey" label="API Key">
|
name="apiKey"
|
||||||
<Input.Password />
|
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>
|
</Form.Item>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -413,8 +524,14 @@ const AiModels: React.FC = () => {
|
||||||
allowClear
|
allowClear
|
||||||
style={{ width: "calc(100% - 100px)" }}
|
style={{ width: "calc(100% - 100px)" }}
|
||||||
placeholder="可选择或自定义输入模型名称"
|
placeholder="可选择或自定义输入模型名称"
|
||||||
|
onFocus={() => {
|
||||||
|
if (isLocalProvider && remoteModels.length === 0) {
|
||||||
|
void handleFetchRemote();
|
||||||
|
}
|
||||||
|
}}
|
||||||
options={remoteModels.map((model) => ({ value: model }))}
|
options={remoteModels.map((model) => ({ value: model }))}
|
||||||
filterOption={(inputValue, option) =>
|
filterOption={(inputValue, option) =>
|
||||||
|
isLocalProvider ||
|
||||||
String(option?.value || "").toLowerCase().includes(inputValue.toLowerCase())
|
String(option?.value || "").toLowerCase().includes(inputValue.toLowerCase())
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
@ -433,6 +550,33 @@ const AiModels: React.FC = () => {
|
||||||
</Form.Item>
|
</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" && (
|
{activeType === "LLM" && (
|
||||||
<>
|
<>
|
||||||
<Form.Item name="apiPath" label="API 路径" initialValue="/v1/chat/completions">
|
<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,
|
QuestionCircleOutlined, FileTextOutlined, CheckOutlined, RocketOutlined,
|
||||||
AudioOutlined
|
AudioOutlined
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { usePermission } from '../../hooks/usePermission';
|
import { usePermission } from '../../hooks/usePermission';
|
||||||
import { getMeetingPage, deleteMeeting, MeetingVO, getMeetingProgress, MeetingProgress, createMeeting, uploadAudio, updateMeetingParticipants } from '../../api/business/meeting';
|
import { getMeetingPage, deleteMeeting, MeetingVO, getMeetingProgress, MeetingProgress, createMeeting, uploadAudio, updateMeetingParticipants } from '../../api/business/meeting';
|
||||||
import { getAiModelPage, getAiModelDefault, AiModelVO } from '../../api/business/aimodel';
|
import { getAiModelPage, getAiModelDefault, AiModelVO } from '../../api/business/aimodel';
|
||||||
|
|
@ -342,6 +342,7 @@ const Meetings: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { can } = usePermission();
|
const { can } = usePermission();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [submitLoading, setSubmitLoading] = useState(false);
|
const [submitLoading, setSubmitLoading] = useState(false);
|
||||||
|
|
@ -352,6 +353,15 @@ const Meetings: React.FC = () => {
|
||||||
const [searchTitle, setSearchTitle] = useState('');
|
const [searchTitle, setSearchTitle] = useState('');
|
||||||
const [viewType, setViewType] = useState<'all' | 'created' | 'involved'>('all');
|
const [viewType, setViewType] = useState<'all' | 'created' | 'involved'>('all');
|
||||||
const [createDrawerVisible, setCreateDrawerVisible] = useState(false);
|
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 [audioUrl, setAudioUrl] = useState('');
|
||||||
const [uploadProgress, setUploadProgress] = useState(0);
|
const [uploadProgress, setUploadProgress] = useState(0);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,15 @@
|
||||||
.home-landing {
|
.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;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -48,10 +59,10 @@
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(420px, 640px) 1fr;
|
grid-template-columns: minmax(420px, 1.06fr) minmax(320px, 0.94fr);
|
||||||
align-items: start;
|
align-items: center;
|
||||||
min-height: 258px;
|
min-height: 282px;
|
||||||
gap: 16px;
|
gap: 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-landing__copy {
|
.home-landing__copy {
|
||||||
|
|
@ -113,8 +124,9 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
min-height: 300px;
|
min-height: 276px;
|
||||||
isolation: isolate;
|
isolation: isolate;
|
||||||
|
padding-right: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-landing__visual::before {
|
.home-landing__visual::before {
|
||||||
|
|
@ -122,8 +134,8 @@
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 16px;
|
top: 16px;
|
||||||
right: 14px;
|
right: 14px;
|
||||||
width: min(42vw, 520px);
|
width: min(38vw, 480px);
|
||||||
height: 292px;
|
height: 264px;
|
||||||
border-radius: 48px;
|
border-radius: 48px;
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at 28% 30%, rgba(255, 255, 255, 0.96), rgba(255, 255, 255, 0) 42%),
|
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 {
|
.home-landing__visual-frame {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: min(42vw, 560px);
|
width: min(38vw, 500px);
|
||||||
min-height: 304px;
|
min-height: 278px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.84);
|
border: 1px solid rgba(255, 255, 255, 0.84);
|
||||||
border-radius: 34px;
|
border-radius: 34px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
@ -224,8 +236,8 @@
|
||||||
.home-landing__visual-radar {
|
.home-landing__visual-radar {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 34px;
|
top: 34px;
|
||||||
right: 58px;
|
right: 54px;
|
||||||
width: min(21vw, 246px);
|
width: min(18vw, 214px);
|
||||||
aspect-ratio: 1;
|
aspect-ratio: 1;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background:
|
background:
|
||||||
|
|
@ -259,17 +271,17 @@
|
||||||
|
|
||||||
.home-landing__visual-pulse--one {
|
.home-landing__visual-pulse--one {
|
||||||
top: 52px;
|
top: 52px;
|
||||||
right: 78px;
|
right: 70px;
|
||||||
width: 188px;
|
width: 164px;
|
||||||
height: 188px;
|
height: 164px;
|
||||||
animation: visualPulseRing 6s ease-out infinite;
|
animation: visualPulseRing 6s ease-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-landing__visual-pulse--two {
|
.home-landing__visual-pulse--two {
|
||||||
top: 34px;
|
top: 34px;
|
||||||
right: 58px;
|
right: 48px;
|
||||||
width: 226px;
|
width: 196px;
|
||||||
height: 226px;
|
height: 196px;
|
||||||
border-color: rgba(190, 182, 255, 0.16);
|
border-color: rgba(190, 182, 255, 0.16);
|
||||||
animation: visualPulseRing 6s ease-out infinite 1.8s;
|
animation: visualPulseRing 6s ease-out infinite 1.8s;
|
||||||
}
|
}
|
||||||
|
|
@ -333,10 +345,10 @@
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 860px minmax(260px, 1fr);
|
grid-template-columns: minmax(0, 1.5fr) minmax(280px, 0.72fr);
|
||||||
align-items: stretch;
|
align-items: start;
|
||||||
gap: 28px;
|
gap: 22px;
|
||||||
margin-top: 16px;
|
margin-top: 22px;
|
||||||
margin-bottom: 18px;
|
margin-bottom: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -346,20 +358,20 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-landing__entry-grid--two {
|
.home-landing__entry-grid--two {
|
||||||
grid-template-columns: repeat(2, minmax(320px, 420px));
|
grid-template-columns: repeat(2, minmax(280px, 1fr));
|
||||||
justify-content: start;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-landing__soundstage {
|
.home-landing__soundstage {
|
||||||
position: relative;
|
position: relative;
|
||||||
min-height: 240px;
|
min-height: 224px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.84);
|
margin-top: 54px;
|
||||||
border-radius: 32px;
|
border: 1px solid var(--home-border);
|
||||||
|
border-radius: 30px;
|
||||||
background:
|
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));
|
linear-gradient(135deg, rgba(185, 204, 255, 0.22), rgba(208, 236, 255, 0.12) 56%, rgba(255, 255, 255, 0));
|
||||||
box-shadow:
|
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);
|
inset 0 1px 0 rgba(255, 255, 255, 0.92);
|
||||||
backdrop-filter: blur(18px);
|
backdrop-filter: blur(18px);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
@ -368,17 +380,55 @@
|
||||||
.home-landing__soundstage::before {
|
.home-landing__soundstage::before {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 16px;
|
inset: 14px;
|
||||||
border: 1px solid rgba(218, 228, 255, 0.96);
|
border: 1px solid rgba(var(--home-primary-rgb), 0.14);
|
||||||
border-radius: 24px;
|
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 {
|
.home-landing__board-glow {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 22px;
|
right: 8px;
|
||||||
top: 24px;
|
top: 10px;
|
||||||
width: 180px;
|
width: 148px;
|
||||||
height: 180px;
|
height: 148px;
|
||||||
border-radius: 50%;
|
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%);
|
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);
|
filter: blur(10px);
|
||||||
|
|
@ -387,7 +437,7 @@
|
||||||
|
|
||||||
.home-landing__board-grid {
|
.home-landing__board-grid {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 20px;
|
inset: 18px;
|
||||||
background-image:
|
background-image:
|
||||||
linear-gradient(rgba(120, 145, 212, 0.08) 1px, transparent 1px),
|
linear-gradient(rgba(120, 145, 212, 0.08) 1px, transparent 1px),
|
||||||
linear-gradient(90deg, 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 {
|
.home-landing__board-panel--summary {
|
||||||
top: 26px;
|
top: 92px;
|
||||||
left: 24px;
|
left: 22px;
|
||||||
width: 170px;
|
width: 156px;
|
||||||
padding: 14px;
|
padding: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-landing__board-pill {
|
.home-landing__board-pill {
|
||||||
|
|
@ -428,8 +478,8 @@
|
||||||
|
|
||||||
.home-landing__board-lines {
|
.home-landing__board-lines {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 8px;
|
||||||
margin-top: 14px;
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-landing__board-line {
|
.home-landing__board-line {
|
||||||
|
|
@ -444,11 +494,11 @@
|
||||||
.home-landing__board-line--sm { width: 62%; }
|
.home-landing__board-line--sm { width: 62%; }
|
||||||
|
|
||||||
.home-landing__board-panel--activity {
|
.home-landing__board-panel--activity {
|
||||||
right: 26px;
|
right: 22px;
|
||||||
top: 34px;
|
top: 82px;
|
||||||
width: 124px;
|
width: 108px;
|
||||||
height: 152px;
|
height: 132px;
|
||||||
padding: 18px 16px;
|
padding: 16px 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-landing__board-bars {
|
.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-bars span:nth-child(6) { height: 52px; animation-delay: 0.6s; }
|
||||||
|
|
||||||
.home-landing__board-panel--timeline {
|
.home-landing__board-panel--timeline {
|
||||||
left: 24px;
|
left: 22px;
|
||||||
right: 24px;
|
right: 22px;
|
||||||
bottom: 28px;
|
bottom: 22px;
|
||||||
height: 74px;
|
height: 58px;
|
||||||
padding: 0 18px;
|
padding: 0 16px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|
@ -486,8 +536,8 @@
|
||||||
|
|
||||||
.home-landing__board-rail {
|
.home-landing__board-rail {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 28px;
|
left: 24px;
|
||||||
right: 28px;
|
right: 24px;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
height: 2px;
|
height: 2px;
|
||||||
background: linear-gradient(90deg, rgba(194, 212, 255, 0.28), rgba(93, 128, 245, 0.62), rgba(188, 229, 255, 0.3));
|
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 {
|
.home-landing__board-stats {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 216px;
|
left: 194px;
|
||||||
top: 34px;
|
top: 96px;
|
||||||
display: grid;
|
display: flex;
|
||||||
gap: 12px;
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-landing__board-stat {
|
.home-landing__board-stat {
|
||||||
width: 98px;
|
width: 84px;
|
||||||
height: 34px;
|
height: 28px;
|
||||||
border: 1px solid rgba(208, 223, 255, 0.94);
|
border: 1px solid rgba(208, 223, 255, 0.94);
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
background:
|
background:
|
||||||
|
|
@ -534,9 +585,9 @@
|
||||||
position: relative;
|
position: relative;
|
||||||
min-height: 212px;
|
min-height: 212px;
|
||||||
padding: 20px 138px 18px 20px;
|
padding: 20px 138px 18px 20px;
|
||||||
border: 1px solid rgba(233, 226, 255, 0.9);
|
border: 1px solid var(--home-border);
|
||||||
border-radius: 28px;
|
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);
|
backdrop-filter: blur(18px);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
@ -570,7 +621,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-entry-card:hover .home-entry-card__cta {
|
.home-entry-card:hover .home-entry-card__cta {
|
||||||
color: #656cf6;
|
color: var(--home-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-entry-card:hover .home-entry-card__cta .anticon {
|
.home-entry-card:hover .home-entry-card__cta .anticon {
|
||||||
|
|
@ -654,7 +705,7 @@
|
||||||
|
|
||||||
.home-entry-card h3 {
|
.home-entry-card h3 {
|
||||||
margin: 20px 0 10px !important;
|
margin: 20px 0 10px !important;
|
||||||
color: #283158 !important;
|
color: var(--home-title-color) !important;
|
||||||
font-size: 22px !important;
|
font-size: 22px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -665,7 +716,7 @@
|
||||||
|
|
||||||
.home-entry-card__line {
|
.home-entry-card__line {
|
||||||
display: block;
|
display: block;
|
||||||
color: #5d678c;
|
color: var(--home-body-color);
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
line-height: 1.62;
|
line-height: 1.62;
|
||||||
}
|
}
|
||||||
|
|
@ -738,7 +789,7 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
color: #656cf6;
|
color: var(--home-primary);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
transition: color 0.28s ease;
|
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) {
|
@media (max-width: 1200px) {
|
||||||
.home-landing__hero {
|
.home-landing__hero {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|
@ -1023,6 +1365,7 @@
|
||||||
.home-landing__visual {
|
.home-landing__visual {
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
min-height: 280px;
|
min-height: 280px;
|
||||||
|
padding-right: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-landing__visual-frame {
|
.home-landing__visual-frame {
|
||||||
|
|
@ -1039,6 +1382,10 @@
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.home-landing__soundstage {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.home-entry-card {
|
.home-entry-card {
|
||||||
padding-right: 120px;
|
padding-right: 120px;
|
||||||
}
|
}
|
||||||
|
|
@ -1157,30 +1504,41 @@
|
||||||
min-height: 208px;
|
min-height: 208px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.home-landing__soundstage-head {
|
||||||
|
top: 16px;
|
||||||
|
left: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
.home-landing__board-panel--summary {
|
.home-landing__board-panel--summary {
|
||||||
width: 150px;
|
top: 84px;
|
||||||
padding: 12px;
|
width: 140px;
|
||||||
|
padding: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-landing__board-panel--activity {
|
.home-landing__board-panel--activity {
|
||||||
right: 20px;
|
right: 18px;
|
||||||
width: 110px;
|
top: 84px;
|
||||||
height: 138px;
|
width: 96px;
|
||||||
padding: 16px 14px;
|
height: 118px;
|
||||||
|
padding: 14px 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-landing__board-stats {
|
.home-landing__board-stats {
|
||||||
left: 186px;
|
left: 172px;
|
||||||
gap: 10px;
|
top: 90px;
|
||||||
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-landing__board-stat {
|
.home-landing__board-stat {
|
||||||
width: 84px;
|
width: 72px;
|
||||||
height: 30px;
|
height: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-landing__board-panel--timeline {
|
.home-landing__board-panel--timeline {
|
||||||
height: 66px;
|
left: 18px;
|
||||||
|
right: 18px;
|
||||||
|
bottom: 18px;
|
||||||
|
height: 56px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-landing__recent {
|
.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 {
|
import {
|
||||||
AudioOutlined,
|
AudioOutlined,
|
||||||
ArrowRightOutlined,
|
ArrowRightOutlined,
|
||||||
|
|
@ -14,6 +14,7 @@ import dayjs from "dayjs";
|
||||||
import { getRecentTasks } from "@/api/business/dashboard";
|
import { getRecentTasks } from "@/api/business/dashboard";
|
||||||
import type { MeetingVO } from "@/api/business/meeting";
|
import type { MeetingVO } from "@/api/business/meeting";
|
||||||
import "./index.less";
|
import "./index.less";
|
||||||
|
import RightVisual from "./RightVisual";
|
||||||
|
|
||||||
const { Text, Title } = Typography;
|
const { Text, Title } = Typography;
|
||||||
|
|
||||||
|
|
@ -108,7 +109,7 @@ export default function HomePage() {
|
||||||
icon: <VideoCameraAddOutlined />,
|
icon: <VideoCameraAddOutlined />,
|
||||||
description: ["上传录音文件,区分发言人并整理内容", "适合访谈录音、培训音频、课程复盘"],
|
description: ["上传录音文件,区分发言人并整理内容", "适合访谈录音、培训音频、课程复盘"],
|
||||||
accent: "cyan",
|
accent: "cyan",
|
||||||
onClick: () => navigate("/meeting-create")
|
onClick: () => navigate("/meetings?create=true")
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
[navigate]
|
[navigate]
|
||||||
|
|
@ -120,6 +121,7 @@ export default function HomePage() {
|
||||||
<div className="home-landing">
|
<div className="home-landing">
|
||||||
<div className="home-landing__halo home-landing__halo--large" />
|
<div className="home-landing__halo home-landing__halo--large" />
|
||||||
<div className="home-landing__halo home-landing__halo--small" />
|
<div className="home-landing__halo home-landing__halo--small" />
|
||||||
|
{/* Replaced ambient field with dynamic right visual */}
|
||||||
|
|
||||||
<section className="home-landing__hero">
|
<section className="home-landing__hero">
|
||||||
<div className="home-landing__copy">
|
<div className="home-landing__copy">
|
||||||
|
|
@ -144,22 +146,7 @@ export default function HomePage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="home-landing__visual" aria-hidden="true">
|
<div className="home-landing__visual" aria-hidden="true">
|
||||||
<div className="home-landing__visual-frame">
|
<RightVisual />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
@ -215,36 +202,26 @@ export default function HomePage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="home-landing__soundstage" aria-hidden="true">
|
<div className="home-landing__soundstage" aria-hidden="true">
|
||||||
<div className="home-landing__board-glow" />
|
<div className="home-landing__soundstage-head">
|
||||||
<div className="home-landing__board-grid" />
|
<span className="home-landing__soundstage-kicker">Workflow Lens</span>
|
||||||
<div className="home-landing__board-panel home-landing__board-panel--summary">
|
<div className="home-landing__soundstage-copy">
|
||||||
<span className="home-landing__board-pill">Meeting Summary</span>
|
<span>会中记录</span>
|
||||||
<div className="home-landing__board-lines">
|
<span>会后整理</span>
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="home-landing__board-panel home-landing__board-panel--activity">
|
<div className="home-landing__soundstage-pills">
|
||||||
<div className="home-landing__board-bars">
|
<span>自动转写</span>
|
||||||
<span />
|
<span>发言人区分</span>
|
||||||
<span />
|
<span>纪要整理</span>
|
||||||
<span />
|
|
||||||
<span />
|
|
||||||
<span />
|
|
||||||
<span />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="home-landing__board-panel home-landing__board-panel--timeline">
|
<div className="home-landing__soundstage-trace">
|
||||||
<div className="home-landing__board-node home-landing__board-node--active" />
|
<span />
|
||||||
<div className="home-landing__board-node" />
|
<span />
|
||||||
<div className="home-landing__board-node" />
|
<span />
|
||||||
<div className="home-landing__board-rail" />
|
<span />
|
||||||
</div>
|
<span />
|
||||||
<div className="home-landing__board-stats">
|
<span />
|
||||||
<div className="home-landing__board-stat" />
|
<span />
|
||||||
<div className="home-landing__board-stat" />
|
|
||||||
<div className="home-landing__board-stat" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -298,6 +275,4 @@ export default function HomePage() {
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -26,7 +26,6 @@ import PromptTemplates from "../pages/business/PromptTemplates";
|
||||||
import AiModels from "../pages/business/AiModels";
|
import AiModels from "../pages/business/AiModels";
|
||||||
import Meetings from "../pages/business/Meetings";
|
import Meetings from "../pages/business/Meetings";
|
||||||
import MeetingDetail from "../pages/business/MeetingDetail";
|
import MeetingDetail from "../pages/business/MeetingDetail";
|
||||||
import MeetingCreate from "../pages/business/MeetingCreate";
|
|
||||||
|
|
||||||
|
|
||||||
function RouteFallback() {
|
function RouteFallback() {
|
||||||
|
|
@ -60,8 +59,7 @@ export const menuRoutes: MenuRoute[] = [
|
||||||
{ path: "/hotwords", label: "热词管理", element: <HotWords />, perm: "menu:hotword" },
|
{ path: "/hotwords", label: "热词管理", element: <HotWords />, perm: "menu:hotword" },
|
||||||
{ path: "/prompts", label: "总结模板", element: <PromptTemplates />, perm: "menu:prompt" },
|
{ path: "/prompts", label: "总结模板", element: <PromptTemplates />, perm: "menu:prompt" },
|
||||||
{ path: "/aimodels", label: "模型配置", element: <AiModels />, perm: "menu:aimodel" },
|
{ path: "/aimodels", label: "模型配置", element: <AiModels />, perm: "menu:aimodel" },
|
||||||
{ path: "/meetings", label: "会议中心", element: <Meetings />, perm: "menu:meeting" },
|
{ path: "/meetings", label: "会议中心", element: <Meetings />, perm: "menu:meeting" }
|
||||||
{ path: "/meeting-create", label: "发起会议", element: <MeetingCreate />, perm: "menu:meeting:create" }
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export const extraRoutes = [
|
export const extraRoutes = [
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue