refactor: 删除MeetingCreate页面并更新主页和AI模型服务

- 删除 `MeetingCreate` 页面及其相关代码
- 更新主页组件,替换静态视觉元素为动态 `RightVisual` 组件
- 在 `AiModelService` 和 `AiModelController` 中添加本地连通性测试功能
- 重构 `AiModelServiceImpl`,增加验证和配置更新逻辑
dev_na
chenhao 2026-03-26 17:42:29 +08:00
parent 92e6b9fd4d
commit 4ee7a620b9
11 changed files with 972 additions and 505 deletions

View File

@ -2,6 +2,7 @@ package com.imeeting.controller.biz;
import com.imeeting.dto.biz.AiModelDTO;
import com.imeeting.dto.biz.AiLocalProfileVO;
import com.imeeting.dto.biz.AiModelVO;
import com.imeeting.service.biz.AiModelService;
@ -93,6 +94,18 @@ public class AiModelController {
return ApiResponse.ok(aiModelService.fetchRemoteModels(provider, baseUrl, apiKey));
}
@PostMapping("/local-connectivity-test")
@PreAuthorize("isAuthenticated()")
public ApiResponse<AiLocalProfileVO> testLocalConnectivity(@RequestBody AiModelDTO dto) {
if (dto.getBaseUrl() == null || dto.getBaseUrl().isBlank()) {
return ApiResponse.error("Base URL不能为空");
}
if (dto.getApiKey() == null || dto.getApiKey().isBlank()) {
return ApiResponse.error("API Key不能为空");
}
return ApiResponse.ok(aiModelService.testLocalConnectivity(dto.getBaseUrl(), dto.getApiKey()));
}
@GetMapping("/default")
@PreAuthorize("isAuthenticated()")
public ApiResponse<AiModelVO> getDefault(@RequestParam String type) {

View File

@ -2,6 +2,7 @@ package com.imeeting.service.biz;
import com.imeeting.dto.biz.AiModelDTO;
import com.imeeting.dto.biz.AiLocalProfileVO;
import com.imeeting.dto.biz.AiModelVO;
import com.unisbase.dto.PageResult;
@ -12,6 +13,7 @@ public interface AiModelService {
AiModelVO updateModel(AiModelDTO dto);
PageResult<List<AiModelVO>> pageModels(Integer current, Integer size, String name, String type, Long tenantId);
List<String> fetchRemoteModels(String provider, String baseUrl, String apiKey);
AiLocalProfileVO testLocalConnectivity(String baseUrl, String apiKey);
AiModelVO getDefaultModel(String type, Long tenantId);
AiModelVO getModelById(Long id, String type);
boolean removeModelById(Long id, String type);

View File

@ -7,6 +7,7 @@ import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.imeeting.dto.biz.AiModelDTO;
import com.imeeting.dto.biz.AiLocalProfileVO;
import com.imeeting.dto.biz.AiModelVO;
import com.imeeting.entity.biz.AsrModel;
import com.imeeting.entity.biz.LlmModel;
@ -55,6 +56,7 @@ public class AiModelServiceImpl implements AiModelService {
@Transactional(rollbackFor = Exception.class)
public AiModelVO saveModel(AiModelDTO dto) {
String type = normalizeType(dto.getModelType());
validateModel(dto);
if (TYPE_ASR.equals(type)) {
AsrModel entity = new AsrModel();
copyAsrProperties(dto, entity);
@ -76,6 +78,7 @@ public class AiModelServiceImpl implements AiModelService {
@Transactional(rollbackFor = Exception.class)
public AiModelVO updateModel(AiModelDTO dto) {
String type = normalizeType(dto.getModelType());
validateModel(dto);
if (TYPE_ASR.equals(type)) {
AsrModel entity = asrModelMapper.selectById(dto.getId());
if (entity == null) {
@ -145,6 +148,9 @@ public class AiModelServiceImpl implements AiModelService {
if (resolvedBaseUrl == null || resolvedBaseUrl.isBlank()) {
return Collections.emptyList();
}
if ("custom".equals(providerKey)) {
return fetchLocalProfile(resolvedBaseUrl, apiKey).getAsrModels();
}
String targetUrl = resolveModelListUrl(providerKey, resolvedBaseUrl, apiKey);
if (targetUrl == null || targetUrl.isBlank()) {
return Collections.emptyList();
@ -216,6 +222,88 @@ public class AiModelServiceImpl implements AiModelService {
}
}
@Override
public AiLocalProfileVO testLocalConnectivity(String baseUrl, String apiKey) {
return fetchLocalProfile(baseUrl, apiKey);
}
private AiLocalProfileVO fetchLocalProfile(String baseUrl, String apiKey) {
String targetUrl = appendPath(baseUrl, "api/v1/system/profile");
try {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(targetUrl))
.timeout(Duration.ofSeconds(10))
.header("Authorization", "Bearer " + apiKey)
.GET()
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() < 200 || response.statusCode() >= 300) {
throw new RuntimeException("本地模型连通性测试失败: HTTP " + response.statusCode());
}
JsonNode root = objectMapper.readTree(response.body());
JsonNode dataNode = root.path("data");
AiLocalProfileVO profile = new AiLocalProfileVO();
profile.setAsrModels(extractModelNames(dataNode.path("models").path("asr")));
JsonNode speakerNode = dataNode.path("models").path("speaker");
if (speakerNode.isMissingNode() || speakerNode.isNull()) {
speakerNode = dataNode.path("model").path("speaker");
}
profile.setSpeakerModels(extractModelNames(speakerNode));
profile.setActiveAsrModel(readText(dataNode.path("runtime").path("asr_model")));
profile.setActiveSpeakerModel(readText(dataNode.path("runtime").path("speaker_model")));
if (dataNode.path("runtime").has("sv_threshold")) {
profile.setSvThreshold(dataNode.path("runtime").path("sv_threshold").decimalValue());
}
profile.setWsEndpoint(readText(dataNode.path("ws_endpoint")));
return profile;
} catch (Exception e) {
throw new RuntimeException("本地模型连通性测试失败: " + e.getMessage(), e);
}
}
private void updateLocalProfile(AsrModel entity) {
if (entity.getBaseUrl() == null || entity.getBaseUrl().isBlank()) {
throw new RuntimeException("baseUrl is required for ASR model");
}
if (entity.getApiKey() == null || entity.getApiKey().isBlank()) {
throw new RuntimeException("apiKey is required for local ASR model");
}
if (entity.getModelCode() == null || entity.getModelCode().isBlank()) {
throw new RuntimeException("modelCode is required for ASR model");
}
Map<String, Object> mediaConfig = entity.getMediaConfig() == null ? Collections.emptyMap() : entity.getMediaConfig();
String speakerModel = readConfigString(mediaConfig.get("speakerModel"));
BigDecimal svThreshold = readConfigDecimal(mediaConfig.get("svThreshold"));
Map<String, Object> body = new HashMap<>();
body.put("asr_model", entity.getModelCode());
body.put("save_audio", false);
body.put("speaker_model", speakerModel);
body.put("sv_threshold", svThreshold);
String targetUrl = appendPath(entity.getBaseUrl(), "api/v1/system/profile");
try {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(targetUrl))
.timeout(Duration.ofSeconds(30))
.header("Content-Type", "application/json")
.header("Authorization", "Bearer " + entity.getApiKey())
.PUT(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body)))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() < 200 || response.statusCode() >= 300) {
throw new RuntimeException("本地模型配置保存失败: HTTP " + response.statusCode());
}
} catch (Exception e) {
throw new RuntimeException("本地模型配置保存失败: " + e.getMessage(), e);
}
}
private String resolveBaseUrl(String providerKey, String baseUrl) {
if (baseUrl != null && !baseUrl.isBlank()) {
return baseUrl;
@ -267,6 +355,26 @@ public class AiModelServiceImpl implements AiModelService {
return provider.trim().toLowerCase();
}
private void validateModel(AiModelDTO dto) {
if (dto == null) {
throw new RuntimeException("模型配置不能为空");
}
if ("custom".equals(normalizeProvider(dto.getProvider()))) {
if (dto.getApiKey() == null || dto.getApiKey().isBlank()) {
throw new RuntimeException("本地模型必须填写API Key");
}
if (TYPE_ASR.equals(normalizeType(dto.getModelType()))) {
Map<String, Object> mediaConfig = dto.getMediaConfig() == null ? Collections.emptyMap() : dto.getMediaConfig();
if (readConfigString(mediaConfig.get("speakerModel")) == null) {
throw new RuntimeException("本地ASR模型必须选择声纹模型");
}
if (mediaConfig.get("svThreshold") == null) {
throw new RuntimeException("本地ASR模型必须填写声纹阈值");
}
}
}
}
@Override
public AiModelVO getDefaultModel(String type, Long tenantId) {
String resolvedType = normalizeType(type);
@ -340,6 +448,10 @@ public class AiModelServiceImpl implements AiModelService {
}
private void pushAsrConfig(AsrModel entity) {
if ("custom".equals(normalizeProvider(entity.getProvider()))) {
updateLocalProfile(entity);
return;
}
if (entity.getBaseUrl() == null || entity.getBaseUrl().isBlank()) {
throw new RuntimeException("baseUrl is required for ASR model");
}
@ -384,6 +496,51 @@ public class AiModelServiceImpl implements AiModelService {
entity.setRemark(dto.getRemark());
}
private List<String> extractModelNames(JsonNode node) {
if (node == null || !node.isArray()) {
return Collections.emptyList();
}
List<String> result = new ArrayList<>();
for (JsonNode item : node) {
String name;
if (item.isObject()) {
name = readText(item.path("name"));
} else {
name = item.asText("");
}
if (name != null && !name.isBlank()) {
result.add(name);
}
}
return result;
}
private String readText(JsonNode node) {
if (node == null || node.isMissingNode() || node.isNull()) {
return null;
}
String value = node.asText();
return value == null || value.isBlank() ? null : value;
}
private String readConfigString(Object value) {
if (value == null) {
return null;
}
String text = String.valueOf(value).trim();
return text.isEmpty() ? null : text;
}
private BigDecimal readConfigDecimal(Object value) {
if (value == null) {
return null;
}
if (value instanceof BigDecimal decimal) {
return decimal;
}
return new BigDecimal(String.valueOf(value));
}
private void copyLlmProperties(AiModelDTO dto, LlmModel entity) {
entity.setModelName(dto.getModelName());
entity.setProvider(dto.getProvider());

View File

@ -164,17 +164,17 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
AiModelVO asrModel = aiModelService.getModelById(asrModelId, "ASR");
if (asrModel == null) throw new RuntimeException("ASR模型配置不存在");
String submitUrl = asrModel.getBaseUrl().endsWith("/") ? asrModel.getBaseUrl() + "api/tasks/recognition" : asrModel.getBaseUrl() + "/api/tasks/recognition";
String submitUrl = appendPath(asrModel.getBaseUrl(), "api/v1/asr/transcriptions");
String taskId = null;
updateProgress(meeting.getId(), 5, "正在提交识别请求...", 0);
Map<String, Object> req = buildAsrRequest(meeting, taskRecord);
Map<String, Object> req = buildAsrRequest(meeting, taskRecord, asrModel);
taskRecord.setRequestData(req);
this.updateById(taskRecord);
String respBody = postJson(submitUrl, req);
String respBody = postJson(submitUrl, req, asrModel.getApiKey());
JsonNode submitNode = objectMapper.readTree(respBody);
if (submitNode.path("code").asInt() != 200) {
if (submitNode.path("code").asInt() != 0) {
updateAiTaskFail(taskRecord, "Submission Failed: " + respBody);
throw new RuntimeException("ASR引擎拒绝请求: " + submitNode.path("msg").asText());
}
@ -182,7 +182,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
taskRecord.setResponseData(Map.of("task_id", taskId));
this.updateById(taskRecord);
String queryUrl = asrModel.getBaseUrl().endsWith("/") ? asrModel.getBaseUrl() + "api/tasks/" + taskId : asrModel.getBaseUrl() + "/api/tasks/" + taskId;
String queryUrl = appendPath(asrModel.getBaseUrl(), "api/v1/asr/transcriptions/" + taskId);
// 轮询逻辑 (带防卡死防护)
JsonNode resultNode = null;
@ -191,7 +191,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
for (int i = 0; i < 600; i++) {
Thread.sleep(2000);
String queryResp = get(queryUrl);
String queryResp = get(queryUrl, asrModel.getApiKey());
JsonNode statusNode = objectMapper.readTree(queryResp);
JsonNode data = statusNode.path("data");
String status = data.path("status").asText();
@ -223,7 +223,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
return saveTranscripts(meeting, resultNode);
}
private Map<String, Object> buildAsrRequest(Meeting meeting, AiTask taskRecord) {
private Map<String, Object> buildAsrRequest(Meeting meeting, AiTask taskRecord, AiModelVO asrModel) {
Map<String, Object> req = new HashMap<>();
String rawAudioUrl = meeting.getAudioUrl();
String encodedAudioUrl = Arrays.stream(rawAudioUrl.split("/"))
@ -232,11 +232,18 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
catch (Exception e) { return part; }
})
.collect(Collectors.joining("/"));
req.put("file_path", serverBaseUrl + (encodedAudioUrl.startsWith("/") ? "" : "/") + encodedAudioUrl);
req.put("file_url", serverBaseUrl + (encodedAudioUrl.startsWith("/") ? "" : "/") + encodedAudioUrl);
Map<String, Object> config = new HashMap<>();
if (asrModel.getModelCode() != null && !asrModel.getModelCode().isBlank()) {
config.put("model", asrModel.getModelCode());
}
Object useSpkObj = taskRecord.getTaskConfig().get("useSpkId");
boolean useSpk = useSpkObj != null && useSpkObj.toString().equals("1");
req.put("use_spk_id", useSpk);
config.put("enable_speaker", useSpk);
config.put("enable_two_pass", true);
List<Map<String, Object>> hotwords = new ArrayList<>();
Object hotWordsObj = taskRecord.getTaskConfig().get("hotWords");
@ -251,7 +258,8 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
}
}
}
req.put("hotwords", hotwords);
config.put("hotwords", hotwords);
req.put("config", config);
return req;
}
@ -379,14 +387,32 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
}
}
private String postJson(String url, Object body) throws Exception {
return httpClient.send(HttpRequest.newBuilder().uri(URI.create(url)).header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body))).build(),
private String postJson(String url, Object body, String apiKey) throws Exception {
HttpRequest.Builder builder = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Content-Type", "application/json");
if (apiKey != null && !apiKey.isBlank()) {
builder.header("Authorization", "Bearer " + apiKey);
}
return httpClient.send(builder
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body)))
.build(),
HttpResponse.BodyHandlers.ofString()).body();
}
private String get(String url) throws Exception {
return httpClient.send(HttpRequest.newBuilder().uri(URI.create(url)).GET().build(), HttpResponse.BodyHandlers.ofString()).body();
private String get(String url, String apiKey) throws Exception {
HttpRequest.Builder builder = HttpRequest.newBuilder().uri(URI.create(url));
if (apiKey != null && !apiKey.isBlank()) {
builder.header("Authorization", "Bearer " + apiKey);
}
return httpClient.send(builder.GET().build(), HttpResponse.BodyHandlers.ofString()).body();
}
private String appendPath(String baseUrl, String path) {
if (baseUrl.endsWith("/")) {
return baseUrl + path;
}
return baseUrl + "/" + path;
}
private String sanitizeSummaryContent(String content) {

View File

@ -20,6 +20,15 @@ export interface AiModelVO {
createdAt: string;
}
export interface AiLocalProfileVO {
asrModels: string[];
speakerModels: string[];
activeAsrModel?: string;
activeSpeakerModel?: string;
svThreshold?: number;
wsEndpoint?: string;
}
export interface AiModelDTO {
id?: number;
modelType: string;
@ -84,6 +93,13 @@ export const getRemoteModelList = (params: { provider: string; baseUrl: string;
);
};
export const testLocalModelConnectivity = (data: { baseUrl: string; apiKey: string }) => {
return http.post<any, { code: string; data: AiLocalProfileVO; msg: string }>(
"/api/biz/aimodel/local-connectivity-test",
data
);
};
export const getAiModelDefault = (type: 'ASR' | 'LLM') => {
return http.get<any, { code: string; data: AiModelVO; msg: string }>(
"/api/biz/aimodel/default",

View File

@ -29,15 +29,18 @@ import {
SaveOutlined,
SearchOutlined,
SyncOutlined,
WifiOutlined,
} from "@ant-design/icons";
import { useDict } from "../../hooks/useDict";
import {
AiModelDTO,
AiLocalProfileVO,
AiModelVO,
deleteAiModelByType,
getAiModelPage,
getRemoteModelList,
saveAiModel,
testLocalModelConnectivity,
updateAiModel,
} from "../../api/business/aimodel";
@ -73,10 +76,14 @@ const AiModels: React.FC = () => {
const [editingId, setEditingId] = useState<number | null>(null);
const [submitLoading, setSubmitLoading] = useState(false);
const [fetchLoading, setFetchLoading] = useState(false);
const [connectivityLoading, setConnectivityLoading] = useState(false);
const [remoteModels, setRemoteModels] = useState<string[]>([]);
const [speakerModels, setSpeakerModels] = useState<string[]>([]);
const modelNameAutoFilledRef = useRef(false);
const localProfileLoadedRef = useRef(false);
const provider = Form.useWatch("provider", form);
const isLocalProvider = String(provider || "").toLowerCase() === "custom";
const isPlatformAdmin = useMemo(() => {
const profileStr = sessionStorage.getItem("userProfile");
@ -132,19 +139,28 @@ const AiModels: React.FC = () => {
const openDrawer = (record?: AiModelVO) => {
setRemoteModels([]);
setSpeakerModels([]);
modelNameAutoFilledRef.current = false;
localProfileLoadedRef.current = false;
if (record) {
setEditingId(record.id);
const speakerModel = record.mediaConfig?.speakerModel;
const svThreshold = record.mediaConfig?.svThreshold;
form.setFieldsValue({
...record,
modelType: record.modelType,
speakerModel,
svThreshold,
isDefaultChecked: record.isDefault === 1,
statusChecked: record.status === 1,
});
if (record.modelCode) {
setRemoteModels([record.modelCode]);
}
if (speakerModel) {
setSpeakerModels([String(speakerModel)]);
}
} else {
setEditingId(null);
form.resetFields();
@ -155,13 +171,33 @@ const AiModels: React.FC = () => {
temperature: 0.7,
topP: 0.9,
apiPath: "/v1/chat/completions",
svThreshold: 0.45,
});
}
setDrawerVisible(true);
};
useEffect(() => {
if (!drawerVisible || !isLocalProvider || !editingId || localProfileLoadedRef.current) {
return;
}
const values = form.getFieldsValue(["baseUrl", "apiKey"]);
if (!values.baseUrl || !values.apiKey) {
return;
}
localProfileLoadedRef.current = true;
void handleTestConnectivity();
}, [drawerVisible, isLocalProvider, editingId, form]);
const handleFetchRemote = async () => {
if (isLocalProvider) {
await handleTestConnectivity();
return;
}
const values = form.getFieldsValue(["provider", "baseUrl", "apiKey"]);
if (!values.provider || !values.baseUrl) {
message.warning("请先填写提供商和 Base URL");
@ -180,6 +216,43 @@ const AiModels: React.FC = () => {
}
};
const resolveLocalWsUrl = (baseUrl: string, wsEndpoint?: string) => {
if (!wsEndpoint) {
return undefined;
}
try {
const base = new URL(baseUrl);
const endpoint = wsEndpoint.startsWith("/") ? wsEndpoint : `/${wsEndpoint}`;
const protocol = base.protocol === "https:" ? "wss:" : "ws:";
return `${protocol}//${base.host}${endpoint}`;
} catch {
return undefined;
}
};
const applyLocalProfile = (profile: AiLocalProfileVO, baseUrl: string) => {
const nextRemoteModels = Array.isArray(profile.asrModels) ? profile.asrModels : [];
const nextSpeakerModels = Array.isArray(profile.speakerModels) ? profile.speakerModels : [];
setRemoteModels(nextRemoteModels);
setSpeakerModels(nextSpeakerModels);
const nextValues: Record<string, unknown> = {};
if (profile.activeAsrModel) {
nextValues.modelCode = profile.activeAsrModel;
}
if (profile.activeSpeakerModel) {
nextValues.speakerModel = profile.activeSpeakerModel;
}
if (profile.svThreshold !== undefined) {
nextValues.svThreshold = profile.svThreshold;
}
const wsUrl = resolveLocalWsUrl(baseUrl, profile.wsEndpoint);
if (wsUrl) {
nextValues.wsUrl = wsUrl;
}
form.setFieldsValue(nextValues);
};
const handleSubmit = async () => {
const values = await form.validateFields();
const payload: AiModelDTO = {
@ -192,6 +265,13 @@ const AiModels: React.FC = () => {
apiKey: values.apiKey,
modelCode: values.modelCode,
wsUrl: values.wsUrl,
mediaConfig:
activeType === "ASR" && isLocalProvider
? {
speakerModel: values.speakerModel,
svThreshold: values.svThreshold,
}
: undefined,
temperature: values.temperature,
topP: values.topP,
isDefault: values.isDefaultChecked ? 1 : 0,
@ -215,6 +295,27 @@ const AiModels: React.FC = () => {
}
};
const handleTestConnectivity = async () => {
const values = await form.validateFields(["provider", "baseUrl", "apiKey"]);
if (String(values.provider || "").toLowerCase() !== "custom") {
message.warning("仅本地模型支持连通性测试");
return;
}
setConnectivityLoading(true);
try {
const res = await testLocalModelConnectivity({
baseUrl: values.baseUrl,
apiKey: values.apiKey,
});
const profile = (res as any)?.data?.data ?? (res as any)?.data ?? (res as any);
applyLocalProfile(profile as AiLocalProfileVO, values.baseUrl);
message.success("本地模型连通性测试成功");
} finally {
setConnectivityLoading(false);
}
};
const handleDelete = async (record: AiModelVO) => {
await deleteAiModelByType(record.id, record.modelType);
message.success("删除成功");
@ -388,9 +489,19 @@ const AiModels: React.FC = () => {
<Input placeholder="https://api.example.com/v1" />
</Form.Item>
{!(activeType === "ASR" && provider === "Custom") && (
<Form.Item name="apiKey" label="API Key">
<Input.Password />
<Form.Item
name="apiKey"
label="API Key"
rules={isLocalProvider ? [{ required: true, message: "本地模型必须填写 API Key" }] : undefined}
>
<Input.Password />
</Form.Item>
{isLocalProvider && (
<Form.Item label="连通性测试">
<Button icon={<WifiOutlined />} loading={connectivityLoading} onClick={handleTestConnectivity}>
</Button>
</Form.Item>
)}
@ -413,8 +524,14 @@ const AiModels: React.FC = () => {
allowClear
style={{ width: "calc(100% - 100px)" }}
placeholder="可选择或自定义输入模型名称"
onFocus={() => {
if (isLocalProvider && remoteModels.length === 0) {
void handleFetchRemote();
}
}}
options={remoteModels.map((model) => ({ value: model }))}
filterOption={(inputValue, option) =>
isLocalProvider ||
String(option?.value || "").toLowerCase().includes(inputValue.toLowerCase())
}
>
@ -433,6 +550,33 @@ const AiModels: React.FC = () => {
</Form.Item>
)}
{activeType === "ASR" && isLocalProvider && (
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="speakerModel"
label="声纹模型"
rules={[{ required: true, message: "请选择声纹模型" }]}
>
<Select
allowClear
placeholder="请先测试连接获取声纹模型"
options={speakerModels.map((model) => ({ label: model, value: model }))}
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="svThreshold"
label="声纹阈值"
rules={[{ required: true, message: "请输入声纹阈值" }]}
>
<InputNumber min={0} max={1} step={0.01} style={{ width: "100%" }} />
</Form.Item>
</Col>
</Row>
)}
{activeType === "LLM" && (
<>
<Form.Item name="apiPath" label="API 路径" initialValue="/v1/chat/completions">

View File

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

View File

@ -8,7 +8,7 @@ import {
QuestionCircleOutlined, FileTextOutlined, CheckOutlined, RocketOutlined,
AudioOutlined
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { usePermission } from '../../hooks/usePermission';
import { getMeetingPage, deleteMeeting, MeetingVO, getMeetingProgress, MeetingProgress, createMeeting, uploadAudio, updateMeetingParticipants } from '../../api/business/meeting';
import { getAiModelPage, getAiModelDefault, AiModelVO } from '../../api/business/aimodel';
@ -342,6 +342,7 @@ const Meetings: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { can } = usePermission();
const [searchParams, setSearchParams] = useSearchParams();
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [submitLoading, setSubmitLoading] = useState(false);
@ -353,6 +354,15 @@ const Meetings: React.FC = () => {
const [viewType, setViewType] = useState<'all' | 'created' | 'involved'>('all');
const [createDrawerVisible, setCreateDrawerVisible] = useState(false);
useEffect(() => {
if (searchParams.get('create') === 'true') {
setCreateDrawerVisible(true);
const newParams = new URLSearchParams(searchParams);
newParams.delete('create');
setSearchParams(newParams, { replace: true });
}
}, [searchParams]);
const [audioUrl, setAudioUrl] = useState('');
const [uploadProgress, setUploadProgress] = useState(0);
const [fileList, setFileList] = useState<any[]>([]);

View File

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

View File

@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import {
AudioOutlined,
ArrowRightOutlined,
@ -14,6 +14,7 @@ import dayjs from "dayjs";
import { getRecentTasks } from "@/api/business/dashboard";
import type { MeetingVO } from "@/api/business/meeting";
import "./index.less";
import RightVisual from "./RightVisual";
const { Text, Title } = Typography;
@ -108,7 +109,7 @@ export default function HomePage() {
icon: <VideoCameraAddOutlined />,
description: ["上传录音文件,区分发言人并整理内容", "适合访谈录音、培训音频、课程复盘"],
accent: "cyan",
onClick: () => navigate("/meeting-create")
onClick: () => navigate("/meetings?create=true")
}
],
[navigate]
@ -120,6 +121,7 @@ export default function HomePage() {
<div className="home-landing">
<div className="home-landing__halo home-landing__halo--large" />
<div className="home-landing__halo home-landing__halo--small" />
{/* Replaced ambient field with dynamic right visual */}
<section className="home-landing__hero">
<div className="home-landing__copy">
@ -144,22 +146,7 @@ export default function HomePage() {
</div>
<div className="home-landing__visual" aria-hidden="true">
<div className="home-landing__visual-frame">
<div className="home-landing__visual-glow home-landing__visual-glow--primary" />
<div className="home-landing__visual-glow home-landing__visual-glow--secondary" />
<div className="home-landing__visual-grid" />
<div className="home-landing__visual-beam" />
<div className="home-landing__visual-radar" />
<div className="home-landing__visual-pulse home-landing__visual-pulse--one" />
<div className="home-landing__visual-pulse home-landing__visual-pulse--two" />
<div className="home-landing__visual-chip home-landing__visual-chip--top">Live capture</div>
<div className="home-landing__visual-chip home-landing__visual-chip--bottom">Speaker focus</div>
<div className="home-landing__visual-waveform">
{Array.from({ length: 10 }).map((_, index) => (
<span key={`visual-wave-${index}`} />
))}
</div>
</div>
<RightVisual />
</div>
</section>
@ -215,36 +202,26 @@ export default function HomePage() {
</div>
<div className="home-landing__soundstage" aria-hidden="true">
<div className="home-landing__board-glow" />
<div className="home-landing__board-grid" />
<div className="home-landing__board-panel home-landing__board-panel--summary">
<span className="home-landing__board-pill">Meeting Summary</span>
<div className="home-landing__board-lines">
<span className="home-landing__board-line home-landing__board-line--lg" />
<span className="home-landing__board-line home-landing__board-line--md" />
<span className="home-landing__board-line home-landing__board-line--sm" />
<div className="home-landing__soundstage-head">
<span className="home-landing__soundstage-kicker">Workflow Lens</span>
<div className="home-landing__soundstage-copy">
<span></span>
<span></span>
</div>
</div>
<div className="home-landing__board-panel home-landing__board-panel--activity">
<div className="home-landing__board-bars">
<span />
<span />
<span />
<span />
<span />
<span />
</div>
<div className="home-landing__soundstage-pills">
<span></span>
<span></span>
<span></span>
</div>
<div className="home-landing__board-panel home-landing__board-panel--timeline">
<div className="home-landing__board-node home-landing__board-node--active" />
<div className="home-landing__board-node" />
<div className="home-landing__board-node" />
<div className="home-landing__board-rail" />
</div>
<div className="home-landing__board-stats">
<div className="home-landing__board-stat" />
<div className="home-landing__board-stat" />
<div className="home-landing__board-stat" />
<div className="home-landing__soundstage-trace">
<span />
<span />
<span />
<span />
<span />
<span />
<span />
</div>
</div>
</section>
@ -299,5 +276,3 @@ export default function HomePage() {
</div>
);
}

View File

@ -26,7 +26,6 @@ import PromptTemplates from "../pages/business/PromptTemplates";
import AiModels from "../pages/business/AiModels";
import Meetings from "../pages/business/Meetings";
import MeetingDetail from "../pages/business/MeetingDetail";
import MeetingCreate from "../pages/business/MeetingCreate";
function RouteFallback() {
@ -60,8 +59,7 @@ export const menuRoutes: MenuRoute[] = [
{ path: "/hotwords", label: "热词管理", element: <HotWords />, perm: "menu:hotword" },
{ path: "/prompts", label: "总结模板", element: <PromptTemplates />, perm: "menu:prompt" },
{ path: "/aimodels", label: "模型配置", element: <AiModels />, perm: "menu:aimodel" },
{ path: "/meetings", label: "会议中心", element: <Meetings />, perm: "menu:meeting" },
{ path: "/meeting-create", label: "发起会议", element: <MeetingCreate />, perm: "menu:meeting:create" }
{ path: "/meetings", label: "会议中心", element: <Meetings />, perm: "menu:meeting" }
];
export const extraRoutes = [