From 4ee7a620b95c7d9aaa3ea79511b7809fe87d9a54 Mon Sep 17 00:00:00 2001 From: chenhao Date: Thu, 26 Mar 2026 17:42:29 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E5=88=A0=E9=99=A4MeetingCreate?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E5=B9=B6=E6=9B=B4=E6=96=B0=E4=B8=BB=E9=A1=B5?= =?UTF-8?q?=E5=92=8CAI=E6=A8=A1=E5=9E=8B=E6=9C=8D=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除 `MeetingCreate` 页面及其相关代码 - 更新主页组件,替换静态视觉元素为动态 `RightVisual` 组件 - 在 `AiModelService` 和 `AiModelController` 中添加本地连通性测试功能 - 重构 `AiModelServiceImpl`,增加验证和配置更新逻辑 --- .../controller/biz/AiModelController.java | 13 + .../imeeting/service/biz/AiModelService.java | 2 + .../service/biz/impl/AiModelServiceImpl.java | 157 +++++ .../service/biz/impl/AiTaskServiceImpl.java | 56 +- frontend/src/api/business/aimodel.ts | 16 + frontend/src/pages/business/AiModels.tsx | 150 +++- frontend/src/pages/business/MeetingCreate.tsx | 358 ---------- frontend/src/pages/business/Meetings.tsx | 12 +- frontend/src/pages/home/index.less | 638 +++++++++++++++--- frontend/src/pages/home/index.tsx | 71 +- frontend/src/routes/routes.tsx | 4 +- 11 files changed, 972 insertions(+), 505 deletions(-) delete mode 100644 frontend/src/pages/business/MeetingCreate.tsx diff --git a/backend/src/main/java/com/imeeting/controller/biz/AiModelController.java b/backend/src/main/java/com/imeeting/controller/biz/AiModelController.java index 431efcb..4fdfd6c 100644 --- a/backend/src/main/java/com/imeeting/controller/biz/AiModelController.java +++ b/backend/src/main/java/com/imeeting/controller/biz/AiModelController.java @@ -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 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 getDefault(@RequestParam String type) { diff --git a/backend/src/main/java/com/imeeting/service/biz/AiModelService.java b/backend/src/main/java/com/imeeting/service/biz/AiModelService.java index af9c0b1..0bfc171 100644 --- a/backend/src/main/java/com/imeeting/service/biz/AiModelService.java +++ b/backend/src/main/java/com/imeeting/service/biz/AiModelService.java @@ -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> pageModels(Integer current, Integer size, String name, String type, Long tenantId); List 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); diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/AiModelServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/AiModelServiceImpl.java index 9a9ce84..287c932 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/AiModelServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/AiModelServiceImpl.java @@ -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 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 mediaConfig = entity.getMediaConfig() == null ? Collections.emptyMap() : entity.getMediaConfig(); + String speakerModel = readConfigString(mediaConfig.get("speakerModel")); + BigDecimal svThreshold = readConfigDecimal(mediaConfig.get("svThreshold")); + + Map 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 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 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 extractModelNames(JsonNode node) { + if (node == null || !node.isArray()) { + return Collections.emptyList(); + } + List 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()); diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java index 6605db0..259d981 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java @@ -164,17 +164,17 @@ public class AiTaskServiceImpl extends ServiceImpl 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 req = buildAsrRequest(meeting, taskRecord); + Map 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 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 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 impleme return saveTranscripts(meeting, resultNode); } - private Map buildAsrRequest(Meeting meeting, AiTask taskRecord) { + private Map buildAsrRequest(Meeting meeting, AiTask taskRecord, AiModelVO asrModel) { Map req = new HashMap<>(); String rawAudioUrl = meeting.getAudioUrl(); String encodedAudioUrl = Arrays.stream(rawAudioUrl.split("/")) @@ -232,11 +232,18 @@ public class AiTaskServiceImpl extends ServiceImpl 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 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> hotwords = new ArrayList<>(); Object hotWordsObj = taskRecord.getTaskConfig().get("hotWords"); @@ -251,7 +258,8 @@ public class AiTaskServiceImpl extends ServiceImpl impleme } } } - req.put("hotwords", hotwords); + config.put("hotwords", hotwords); + req.put("config", config); return req; } @@ -379,14 +387,32 @@ public class AiTaskServiceImpl extends ServiceImpl 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) { diff --git a/frontend/src/api/business/aimodel.ts b/frontend/src/api/business/aimodel.ts index 7419575..462d335 100644 --- a/frontend/src/api/business/aimodel.ts +++ b/frontend/src/api/business/aimodel.ts @@ -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( + "/api/biz/aimodel/local-connectivity-test", + data + ); +}; + export const getAiModelDefault = (type: 'ASR' | 'LLM') => { return http.get( "/api/biz/aimodel/default", diff --git a/frontend/src/pages/business/AiModels.tsx b/frontend/src/pages/business/AiModels.tsx index 1b124a3..810afa0 100644 --- a/frontend/src/pages/business/AiModels.tsx +++ b/frontend/src/pages/business/AiModels.tsx @@ -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(null); const [submitLoading, setSubmitLoading] = useState(false); const [fetchLoading, setFetchLoading] = useState(false); + const [connectivityLoading, setConnectivityLoading] = useState(false); const [remoteModels, setRemoteModels] = useState([]); + const [speakerModels, setSpeakerModels] = useState([]); 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 = {}; + 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 = () => { - {!(activeType === "ASR" && provider === "Custom") && ( - - + + + + + {isLocalProvider && ( + + )} @@ -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 = () => { )} + {activeType === "ASR" && isLocalProvider && ( + + + + - - - - - - - - - - - {userList.map(u => ( - - ))} - - - - - - - {/* 右侧:AI 配置 - 固定且不滚动 */} - - AI 分析配置} - 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' }} - > -
- - - - - - - - - -
- - {prompts.map(p => { - const isSelected = watchedPromptId === p.id; - return ( - -
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' - }} - > -
- -
-
- {p.templateName} -
- {isSelected && ( -
- -
- )} -
- - ); - })} -
-
-
- - - - 纠错热词 } - style={{ marginBottom: 0 }} - > - - - - - 声纹识别 } - valuePropName="checked" - getValueProps={(value) => ({ checked: value === 1 })} - normalize={(value) => (value ? 1 : 0)} - style={{ marginBottom: 0 }} - > - - - - -
- -
-
- - - 系统将自动执行:转录固化 + 智能总结。 - -
- - -
-
- -
- - - - ); -}; - -export default MeetingCreate; diff --git a/frontend/src/pages/business/Meetings.tsx b/frontend/src/pages/business/Meetings.tsx index 5c6e963..966f67f 100644 --- a/frontend/src/pages/business/Meetings.tsx +++ b/frontend/src/pages/business/Meetings.tsx @@ -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); @@ -352,6 +353,15 @@ const Meetings: React.FC = () => { const [searchTitle, setSearchTitle] = useState(''); const [viewType, setViewType] = useState<'all' | 'created' | 'involved'>('all'); const [createDrawerVisible, setCreateDrawerVisible] = useState(false); + + useEffect(() => { + if (searchParams.get('create') === 'true') { + setCreateDrawerVisible(true); + const newParams = new URLSearchParams(searchParams); + newParams.delete('create'); + setSearchParams(newParams, { replace: true }); + } + }, [searchParams]); const [audioUrl, setAudioUrl] = useState(''); const [uploadProgress, setUploadProgress] = useState(0); diff --git a/frontend/src/pages/home/index.less b/frontend/src/pages/home/index.less index 1e1704c..63fc9c8 100644 --- a/frontend/src/pages/home/index.less +++ b/frontend/src/pages/home/index.less @@ -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; + } +} \ No newline at end of file diff --git a/frontend/src/pages/home/index.tsx b/frontend/src/pages/home/index.tsx index 6f9b079..2f69118 100644 --- a/frontend/src/pages/home/index.tsx +++ b/frontend/src/pages/home/index.tsx @@ -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: , description: ["上传录音文件,区分发言人并整理内容", "适合访谈录音、培训音频、课程复盘"], accent: "cyan", - onClick: () => navigate("/meeting-create") + onClick: () => navigate("/meetings?create=true") } ], [navigate] @@ -120,6 +121,7 @@ export default function HomePage() {
+ {/* Replaced ambient field with dynamic right visual */}
@@ -144,22 +146,7 @@ export default function HomePage() {
@@ -215,36 +202,26 @@ export default function HomePage() {