语音服务
parent
4eed47cc70
commit
349cdf26e6
|
|
@ -0,0 +1,67 @@
|
||||||
|
package com.unis.crm.common;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.sql.Statement;
|
||||||
|
import javax.sql.DataSource;
|
||||||
|
import org.springframework.boot.ApplicationArguments;
|
||||||
|
import org.springframework.boot.ApplicationRunner;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class SpeechRecognitionSchemaInitializer implements ApplicationRunner {
|
||||||
|
|
||||||
|
private final DataSource dataSource;
|
||||||
|
|
||||||
|
public SpeechRecognitionSchemaInitializer(DataSource dataSource) {
|
||||||
|
this.dataSource = dataSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void run(ApplicationArguments args) {
|
||||||
|
try (Connection connection = dataSource.getConnection();
|
||||||
|
Statement statement = connection.createStatement()) {
|
||||||
|
statement.execute("""
|
||||||
|
create table if not exists speech_recognition_config (
|
||||||
|
id bigint generated by default as identity primary key,
|
||||||
|
tenant_id bigint not null,
|
||||||
|
enabled boolean not null default true,
|
||||||
|
base_url varchar(512),
|
||||||
|
transcribe_path varchar(255) not null default '/v1/audio/transcriptions',
|
||||||
|
task_status_path varchar(255) not null default '/v1/tasks/{task_id}',
|
||||||
|
file_field_name varchar(64) not null default 'file',
|
||||||
|
language varchar(32),
|
||||||
|
response_format varchar(32) not null default 'json',
|
||||||
|
model varchar(128),
|
||||||
|
prompt text,
|
||||||
|
hotwords text,
|
||||||
|
timestamp_granularities varchar(128),
|
||||||
|
enable_speaker_diarization boolean,
|
||||||
|
enable_speaker_identification boolean,
|
||||||
|
enable_text_cleanup boolean,
|
||||||
|
temperature double precision,
|
||||||
|
api_key varchar(512),
|
||||||
|
api_key_header varchar(128) not null default 'X-API-Key',
|
||||||
|
connect_timeout_seconds integer not null default 10,
|
||||||
|
read_timeout_seconds integer not null default 300,
|
||||||
|
task_timeout_seconds integer not null default 300,
|
||||||
|
task_poll_interval_millis integer not null default 1500,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now(),
|
||||||
|
constraint uk_speech_recognition_config_tenant unique (tenant_id)
|
||||||
|
)
|
||||||
|
""");
|
||||||
|
statement.execute("alter table if exists speech_recognition_config add column if not exists response_format varchar(32) not null default 'json'");
|
||||||
|
statement.execute("alter table if exists speech_recognition_config add column if not exists model varchar(128)");
|
||||||
|
statement.execute("alter table if exists speech_recognition_config add column if not exists prompt text");
|
||||||
|
statement.execute("alter table if exists speech_recognition_config add column if not exists hotwords text");
|
||||||
|
statement.execute("alter table if exists speech_recognition_config add column if not exists timestamp_granularities varchar(128)");
|
||||||
|
statement.execute("alter table if exists speech_recognition_config add column if not exists enable_speaker_diarization boolean");
|
||||||
|
statement.execute("alter table if exists speech_recognition_config add column if not exists enable_speaker_identification boolean");
|
||||||
|
statement.execute("alter table if exists speech_recognition_config add column if not exists enable_text_cleanup boolean");
|
||||||
|
statement.execute("alter table if exists speech_recognition_config add column if not exists temperature double precision");
|
||||||
|
} catch (SQLException exception) {
|
||||||
|
throw new IllegalStateException("Failed to initialize speech recognition schema", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
package com.unis.crm.controller;
|
||||||
|
|
||||||
|
import com.unis.crm.common.ApiResponse;
|
||||||
|
import com.unis.crm.dto.speech.SpeechRecognitionConfigDTO;
|
||||||
|
import com.unis.crm.service.SpeechRecognitionConfigService;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/sys/api/admin")
|
||||||
|
public class SpeechRecognitionAdminController {
|
||||||
|
|
||||||
|
private final SpeechRecognitionConfigService speechRecognitionConfigService;
|
||||||
|
|
||||||
|
public SpeechRecognitionAdminController(SpeechRecognitionConfigService speechRecognitionConfigService) {
|
||||||
|
this.speechRecognitionConfigService = speechRecognitionConfigService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/speech-recognition-config")
|
||||||
|
public ApiResponse<SpeechRecognitionConfigDTO> getSpeechRecognitionConfig(
|
||||||
|
@RequestParam(value = "tenantId", required = false) Long tenantId) {
|
||||||
|
return ApiResponse.success(speechRecognitionConfigService.getConfig(tenantId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/speech-recognition-config")
|
||||||
|
public ApiResponse<Boolean> updateSpeechRecognitionConfig(@Valid @RequestBody SpeechRecognitionConfigDTO payload) {
|
||||||
|
return ApiResponse.success(speechRecognitionConfigService.saveConfig(payload));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -13,6 +13,7 @@ import com.unis.crm.dto.work.WorkHistoryPageDTO;
|
||||||
import com.unis.crm.dto.work.WorkOverviewDTO;
|
import com.unis.crm.dto.work.WorkOverviewDTO;
|
||||||
import com.unis.crm.dto.work.WorkReportAttachmentDTO;
|
import com.unis.crm.dto.work.WorkReportAttachmentDTO;
|
||||||
import com.unis.crm.dto.work.WorkReportToUserDTO;
|
import com.unis.crm.dto.work.WorkReportToUserDTO;
|
||||||
|
import com.unis.crm.dto.work.WorkSpeechTranscriptionDTO;
|
||||||
import com.unis.crm.service.WorkService;
|
import com.unis.crm.service.WorkService;
|
||||||
import com.unisbase.common.annotation.Log;
|
import com.unisbase.common.annotation.Log;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
|
|
@ -174,6 +175,14 @@ public class WorkController {
|
||||||
.body(resource);
|
.body(resource);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping(path = "/report-speech/transcribe", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
|
@Log(type = "工作管理", value = "日报语音转写")
|
||||||
|
public ApiResponse<WorkSpeechTranscriptionDTO> transcribeReportSpeech(
|
||||||
|
@RequestHeader("X-User-Id") Long userId,
|
||||||
|
@RequestPart("file") MultipartFile file) {
|
||||||
|
return ApiResponse.success(workService.transcribeReportSpeech(CurrentUserUtils.requireCurrentUserId(userId), file));
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/daily-reports")
|
@PostMapping("/daily-reports")
|
||||||
@Log(type = "工作管理", value = "提交日报")
|
@Log(type = "工作管理", value = "提交日报")
|
||||||
public ApiResponse<Long> saveDailyReport(
|
public ApiResponse<Long> saveDailyReport(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,218 @@
|
||||||
|
package com.unis.crm.dto.speech;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.Max;
|
||||||
|
import jakarta.validation.constraints.Min;
|
||||||
|
|
||||||
|
public class SpeechRecognitionConfigDTO {
|
||||||
|
|
||||||
|
private Long tenantId;
|
||||||
|
private boolean enabled = true;
|
||||||
|
private String baseUrl;
|
||||||
|
private String transcribePath = "/v1/audio/transcriptions";
|
||||||
|
private String taskStatusPath = "/v1/tasks/{task_id}";
|
||||||
|
private String fileFieldName = "file";
|
||||||
|
private String language;
|
||||||
|
private String responseFormat = "json";
|
||||||
|
private String model;
|
||||||
|
private String prompt;
|
||||||
|
private String hotwords;
|
||||||
|
private String timestampGranularities;
|
||||||
|
private Boolean enableSpeakerDiarization;
|
||||||
|
private Boolean enableSpeakerIdentification;
|
||||||
|
private Boolean enableTextCleanup;
|
||||||
|
private Double temperature;
|
||||||
|
private String apiKey;
|
||||||
|
private String apiKeyHeader = "X-API-Key";
|
||||||
|
|
||||||
|
@Min(1)
|
||||||
|
@Max(120)
|
||||||
|
private long connectTimeoutSeconds = 10;
|
||||||
|
|
||||||
|
@Min(1)
|
||||||
|
@Max(1800)
|
||||||
|
private long readTimeoutSeconds = 300;
|
||||||
|
|
||||||
|
@Min(1)
|
||||||
|
@Max(1800)
|
||||||
|
private long taskTimeoutSeconds = 300;
|
||||||
|
|
||||||
|
@Min(300)
|
||||||
|
@Max(60000)
|
||||||
|
private long taskPollIntervalMillis = 1500;
|
||||||
|
|
||||||
|
public Long getTenantId() {
|
||||||
|
return tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTenantId(Long tenantId) {
|
||||||
|
this.tenantId = tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isEnabled() {
|
||||||
|
return enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setEnabled(boolean enabled) {
|
||||||
|
this.enabled = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getBaseUrl() {
|
||||||
|
return baseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setBaseUrl(String baseUrl) {
|
||||||
|
this.baseUrl = baseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTranscribePath() {
|
||||||
|
return transcribePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTranscribePath(String transcribePath) {
|
||||||
|
this.transcribePath = transcribePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTaskStatusPath() {
|
||||||
|
return taskStatusPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTaskStatusPath(String taskStatusPath) {
|
||||||
|
this.taskStatusPath = taskStatusPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getFileFieldName() {
|
||||||
|
return fileFieldName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFileFieldName(String fileFieldName) {
|
||||||
|
this.fileFieldName = fileFieldName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getLanguage() {
|
||||||
|
return language;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLanguage(String language) {
|
||||||
|
this.language = language;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getResponseFormat() {
|
||||||
|
return responseFormat;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setResponseFormat(String responseFormat) {
|
||||||
|
this.responseFormat = responseFormat;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getModel() {
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setModel(String model) {
|
||||||
|
this.model = model;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getPrompt() {
|
||||||
|
return prompt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPrompt(String prompt) {
|
||||||
|
this.prompt = prompt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getHotwords() {
|
||||||
|
return hotwords;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setHotwords(String hotwords) {
|
||||||
|
this.hotwords = hotwords;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTimestampGranularities() {
|
||||||
|
return timestampGranularities;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTimestampGranularities(String timestampGranularities) {
|
||||||
|
this.timestampGranularities = timestampGranularities;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean getEnableSpeakerDiarization() {
|
||||||
|
return enableSpeakerDiarization;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setEnableSpeakerDiarization(Boolean enableSpeakerDiarization) {
|
||||||
|
this.enableSpeakerDiarization = enableSpeakerDiarization;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean getEnableSpeakerIdentification() {
|
||||||
|
return enableSpeakerIdentification;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setEnableSpeakerIdentification(Boolean enableSpeakerIdentification) {
|
||||||
|
this.enableSpeakerIdentification = enableSpeakerIdentification;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean getEnableTextCleanup() {
|
||||||
|
return enableTextCleanup;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setEnableTextCleanup(Boolean enableTextCleanup) {
|
||||||
|
this.enableTextCleanup = enableTextCleanup;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Double getTemperature() {
|
||||||
|
return temperature;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTemperature(Double temperature) {
|
||||||
|
this.temperature = temperature;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getApiKey() {
|
||||||
|
return apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setApiKey(String apiKey) {
|
||||||
|
this.apiKey = apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getApiKeyHeader() {
|
||||||
|
return apiKeyHeader;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setApiKeyHeader(String apiKeyHeader) {
|
||||||
|
this.apiKeyHeader = apiKeyHeader;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getConnectTimeoutSeconds() {
|
||||||
|
return connectTimeoutSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setConnectTimeoutSeconds(long connectTimeoutSeconds) {
|
||||||
|
this.connectTimeoutSeconds = connectTimeoutSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getReadTimeoutSeconds() {
|
||||||
|
return readTimeoutSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setReadTimeoutSeconds(long readTimeoutSeconds) {
|
||||||
|
this.readTimeoutSeconds = readTimeoutSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getTaskTimeoutSeconds() {
|
||||||
|
return taskTimeoutSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTaskTimeoutSeconds(long taskTimeoutSeconds) {
|
||||||
|
this.taskTimeoutSeconds = taskTimeoutSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getTaskPollIntervalMillis() {
|
||||||
|
return taskPollIntervalMillis;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTaskPollIntervalMillis(long taskPollIntervalMillis) {
|
||||||
|
this.taskPollIntervalMillis = taskPollIntervalMillis;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
package com.unis.crm.dto.work;
|
||||||
|
|
||||||
|
public class WorkSpeechTranscriptionDTO {
|
||||||
|
|
||||||
|
private String text;
|
||||||
|
|
||||||
|
public WorkSpeechTranscriptionDTO() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public WorkSpeechTranscriptionDTO(String text) {
|
||||||
|
this.text = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getText() {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setText(String text) {
|
||||||
|
this.text = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,318 @@
|
||||||
|
package com.unis.crm.service;
|
||||||
|
|
||||||
|
import com.unis.crm.common.BusinessException;
|
||||||
|
import com.unis.crm.common.UnauthorizedException;
|
||||||
|
import com.unis.crm.dto.speech.SpeechRecognitionConfigDTO;
|
||||||
|
import com.unisbase.security.PermissionService;
|
||||||
|
import com.unisbase.security.SpringSecurityTenantProvider;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.jdbc.core.RowMapper;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class SpeechRecognitionConfigService {
|
||||||
|
|
||||||
|
private static final String VIEW_PERM = "speech_recognition_config:view";
|
||||||
|
private static final String UPDATE_PERM = "speech_recognition_config:update";
|
||||||
|
private static final long GLOBAL_TENANT_ID = 0L;
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final PermissionService permissionService;
|
||||||
|
private final SpringSecurityTenantProvider tenantProvider;
|
||||||
|
|
||||||
|
public SpeechRecognitionConfigService(
|
||||||
|
JdbcTemplate jdbcTemplate,
|
||||||
|
PermissionService permissionService,
|
||||||
|
SpringSecurityTenantProvider tenantProvider) {
|
||||||
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
|
this.permissionService = permissionService;
|
||||||
|
this.tenantProvider = tenantProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SpeechRecognitionConfigDTO getConfig(Long tenantId) {
|
||||||
|
requirePermission(VIEW_PERM, "无权查看语音识别配置");
|
||||||
|
Long resolvedTenantId = resolveTenantId(tenantId);
|
||||||
|
SpeechRecognitionConfigDTO config = findConfig(resolvedTenantId);
|
||||||
|
return config == null ? defaultConfig(resolvedTenantId) : config;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public boolean saveConfig(SpeechRecognitionConfigDTO payload) {
|
||||||
|
requirePermission(UPDATE_PERM, "无权修改语音识别配置");
|
||||||
|
Long tenantId = resolveTenantId(payload == null ? null : payload.getTenantId());
|
||||||
|
SpeechRecognitionConfigDTO normalized = normalizeConfig(payload, tenantId);
|
||||||
|
jdbcTemplate.update("""
|
||||||
|
insert into speech_recognition_config (
|
||||||
|
tenant_id,
|
||||||
|
enabled,
|
||||||
|
base_url,
|
||||||
|
transcribe_path,
|
||||||
|
task_status_path,
|
||||||
|
file_field_name,
|
||||||
|
language,
|
||||||
|
response_format,
|
||||||
|
model,
|
||||||
|
prompt,
|
||||||
|
hotwords,
|
||||||
|
timestamp_granularities,
|
||||||
|
enable_speaker_diarization,
|
||||||
|
enable_speaker_identification,
|
||||||
|
enable_text_cleanup,
|
||||||
|
temperature,
|
||||||
|
api_key,
|
||||||
|
api_key_header,
|
||||||
|
connect_timeout_seconds,
|
||||||
|
read_timeout_seconds,
|
||||||
|
task_timeout_seconds,
|
||||||
|
task_poll_interval_millis,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
)
|
||||||
|
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, now(), now())
|
||||||
|
on conflict (tenant_id) do update
|
||||||
|
set enabled = excluded.enabled,
|
||||||
|
base_url = excluded.base_url,
|
||||||
|
transcribe_path = excluded.transcribe_path,
|
||||||
|
task_status_path = excluded.task_status_path,
|
||||||
|
file_field_name = excluded.file_field_name,
|
||||||
|
language = excluded.language,
|
||||||
|
response_format = excluded.response_format,
|
||||||
|
model = excluded.model,
|
||||||
|
prompt = excluded.prompt,
|
||||||
|
hotwords = excluded.hotwords,
|
||||||
|
timestamp_granularities = excluded.timestamp_granularities,
|
||||||
|
enable_speaker_diarization = excluded.enable_speaker_diarization,
|
||||||
|
enable_speaker_identification = excluded.enable_speaker_identification,
|
||||||
|
enable_text_cleanup = excluded.enable_text_cleanup,
|
||||||
|
temperature = excluded.temperature,
|
||||||
|
api_key = excluded.api_key,
|
||||||
|
api_key_header = excluded.api_key_header,
|
||||||
|
connect_timeout_seconds = excluded.connect_timeout_seconds,
|
||||||
|
read_timeout_seconds = excluded.read_timeout_seconds,
|
||||||
|
task_timeout_seconds = excluded.task_timeout_seconds,
|
||||||
|
task_poll_interval_millis = excluded.task_poll_interval_millis,
|
||||||
|
updated_at = now()
|
||||||
|
""",
|
||||||
|
tenantId,
|
||||||
|
normalized.isEnabled(),
|
||||||
|
normalized.getBaseUrl(),
|
||||||
|
normalized.getTranscribePath(),
|
||||||
|
normalized.getTaskStatusPath(),
|
||||||
|
normalized.getFileFieldName(),
|
||||||
|
normalized.getLanguage(),
|
||||||
|
normalized.getResponseFormat(),
|
||||||
|
normalized.getModel(),
|
||||||
|
normalized.getPrompt(),
|
||||||
|
normalized.getHotwords(),
|
||||||
|
normalized.getTimestampGranularities(),
|
||||||
|
normalized.getEnableSpeakerDiarization(),
|
||||||
|
normalized.getEnableSpeakerIdentification(),
|
||||||
|
normalized.getEnableTextCleanup(),
|
||||||
|
normalized.getTemperature(),
|
||||||
|
normalized.getApiKey(),
|
||||||
|
normalized.getApiKeyHeader(),
|
||||||
|
normalized.getConnectTimeoutSeconds(),
|
||||||
|
normalized.getReadTimeoutSeconds(),
|
||||||
|
normalized.getTaskTimeoutSeconds(),
|
||||||
|
normalized.getTaskPollIntervalMillis());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SpeechRecognitionConfigDTO getEffectiveConfig(Long tenantId) {
|
||||||
|
Long resolvedTenantId = tenantId == null ? resolveCurrentTenantId() : tenantId;
|
||||||
|
if (resolvedTenantId == null) {
|
||||||
|
resolvedTenantId = GLOBAL_TENANT_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
SpeechRecognitionConfigDTO tenantConfig = findConfig(resolvedTenantId);
|
||||||
|
if (tenantConfig != null) {
|
||||||
|
return tenantConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolvedTenantId != GLOBAL_TENANT_ID) {
|
||||||
|
SpeechRecognitionConfigDTO globalConfig = findConfig(GLOBAL_TENANT_ID);
|
||||||
|
if (globalConfig != null) {
|
||||||
|
return globalConfig;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultConfig(resolvedTenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private SpeechRecognitionConfigDTO normalizeConfig(SpeechRecognitionConfigDTO payload, Long tenantId) {
|
||||||
|
SpeechRecognitionConfigDTO defaults = defaultConfig(tenantId);
|
||||||
|
SpeechRecognitionConfigDTO config = payload == null ? defaults : payload;
|
||||||
|
|
||||||
|
SpeechRecognitionConfigDTO normalized = new SpeechRecognitionConfigDTO();
|
||||||
|
normalized.setTenantId(tenantId);
|
||||||
|
normalized.setEnabled(config.isEnabled());
|
||||||
|
normalized.setBaseUrl(defaultIfBlank(config.getBaseUrl(), defaults.getBaseUrl()));
|
||||||
|
normalized.setTranscribePath(defaultIfBlank(config.getTranscribePath(), defaults.getTranscribePath()));
|
||||||
|
normalized.setTaskStatusPath(defaultIfBlank(config.getTaskStatusPath(), defaults.getTaskStatusPath()));
|
||||||
|
normalized.setFileFieldName("file");
|
||||||
|
normalized.setLanguage(trimToNull(config.getLanguage()));
|
||||||
|
normalized.setResponseFormat(defaultIfBlank(config.getResponseFormat(), defaults.getResponseFormat()));
|
||||||
|
normalized.setModel(trimToNull(config.getModel()));
|
||||||
|
normalized.setPrompt(trimToNull(config.getPrompt()));
|
||||||
|
normalized.setHotwords(trimToNull(config.getHotwords()));
|
||||||
|
normalized.setTimestampGranularities(trimToNull(config.getTimestampGranularities()));
|
||||||
|
normalized.setEnableSpeakerDiarization(config.getEnableSpeakerDiarization());
|
||||||
|
normalized.setEnableSpeakerIdentification(config.getEnableSpeakerIdentification());
|
||||||
|
normalized.setEnableTextCleanup(config.getEnableTextCleanup());
|
||||||
|
normalized.setTemperature(config.getTemperature());
|
||||||
|
normalized.setApiKey(trimToNull(config.getApiKey()));
|
||||||
|
normalized.setApiKeyHeader(defaultIfBlank(config.getApiKeyHeader(), defaults.getApiKeyHeader()));
|
||||||
|
normalized.setConnectTimeoutSeconds(clamp(config.getConnectTimeoutSeconds(), 1, 120, defaults.getConnectTimeoutSeconds()));
|
||||||
|
normalized.setReadTimeoutSeconds(clamp(config.getReadTimeoutSeconds(), 1, 1800, defaults.getReadTimeoutSeconds()));
|
||||||
|
normalized.setTaskTimeoutSeconds(clamp(config.getTaskTimeoutSeconds(), 1, 1800, defaults.getTaskTimeoutSeconds()));
|
||||||
|
normalized.setTaskPollIntervalMillis(clamp(config.getTaskPollIntervalMillis(), 300, 60000, defaults.getTaskPollIntervalMillis()));
|
||||||
|
|
||||||
|
if (normalized.isEnabled()) {
|
||||||
|
validateBaseUrl(normalized.getBaseUrl());
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private SpeechRecognitionConfigDTO defaultConfig(Long tenantId) {
|
||||||
|
SpeechRecognitionConfigDTO config = new SpeechRecognitionConfigDTO();
|
||||||
|
config.setTenantId(tenantId);
|
||||||
|
config.setEnabled(true);
|
||||||
|
config.setBaseUrl(null);
|
||||||
|
config.setTranscribePath("/v1/audio/transcriptions");
|
||||||
|
config.setTaskStatusPath("/v1/tasks/{task_id}");
|
||||||
|
config.setFileFieldName("file");
|
||||||
|
config.setLanguage(null);
|
||||||
|
config.setResponseFormat("json");
|
||||||
|
config.setApiKey(null);
|
||||||
|
config.setApiKeyHeader("X-API-Key");
|
||||||
|
config.setConnectTimeoutSeconds(10);
|
||||||
|
config.setReadTimeoutSeconds(300);
|
||||||
|
config.setTaskTimeoutSeconds(300);
|
||||||
|
config.setTaskPollIntervalMillis(1500);
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
private SpeechRecognitionConfigDTO findConfig(Long tenantId) {
|
||||||
|
if (tenantId == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
List<SpeechRecognitionConfigDTO> rows = jdbcTemplate.query("""
|
||||||
|
select tenant_id,
|
||||||
|
enabled,
|
||||||
|
base_url,
|
||||||
|
transcribe_path,
|
||||||
|
task_status_path,
|
||||||
|
file_field_name,
|
||||||
|
language,
|
||||||
|
response_format,
|
||||||
|
model,
|
||||||
|
prompt,
|
||||||
|
hotwords,
|
||||||
|
timestamp_granularities,
|
||||||
|
enable_speaker_diarization,
|
||||||
|
enable_speaker_identification,
|
||||||
|
enable_text_cleanup,
|
||||||
|
temperature,
|
||||||
|
api_key,
|
||||||
|
api_key_header,
|
||||||
|
connect_timeout_seconds,
|
||||||
|
read_timeout_seconds,
|
||||||
|
task_timeout_seconds,
|
||||||
|
task_poll_interval_millis
|
||||||
|
from speech_recognition_config
|
||||||
|
where tenant_id = ?
|
||||||
|
limit 1
|
||||||
|
""", configRowMapper(), tenantId);
|
||||||
|
return rows.isEmpty() ? null : rows.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private RowMapper<SpeechRecognitionConfigDTO> configRowMapper() {
|
||||||
|
return (resultSet, rowNum) -> {
|
||||||
|
SpeechRecognitionConfigDTO dto = new SpeechRecognitionConfigDTO();
|
||||||
|
dto.setTenantId(resultSet.getLong("tenant_id"));
|
||||||
|
dto.setEnabled(resultSet.getBoolean("enabled"));
|
||||||
|
dto.setBaseUrl(trimToNull(resultSet.getString("base_url")));
|
||||||
|
dto.setTranscribePath(trimToNull(resultSet.getString("transcribe_path")));
|
||||||
|
dto.setTaskStatusPath(trimToNull(resultSet.getString("task_status_path")));
|
||||||
|
dto.setFileFieldName("file");
|
||||||
|
dto.setLanguage(trimToNull(resultSet.getString("language")));
|
||||||
|
dto.setResponseFormat(defaultIfBlank(resultSet.getString("response_format"), "json"));
|
||||||
|
dto.setModel(trimToNull(resultSet.getString("model")));
|
||||||
|
dto.setPrompt(trimToNull(resultSet.getString("prompt")));
|
||||||
|
dto.setHotwords(trimToNull(resultSet.getString("hotwords")));
|
||||||
|
dto.setTimestampGranularities(trimToNull(resultSet.getString("timestamp_granularities")));
|
||||||
|
dto.setEnableSpeakerDiarization((Boolean) resultSet.getObject("enable_speaker_diarization"));
|
||||||
|
dto.setEnableSpeakerIdentification((Boolean) resultSet.getObject("enable_speaker_identification"));
|
||||||
|
dto.setEnableTextCleanup((Boolean) resultSet.getObject("enable_text_cleanup"));
|
||||||
|
dto.setTemperature((Double) resultSet.getObject("temperature"));
|
||||||
|
dto.setApiKey(trimToNull(resultSet.getString("api_key")));
|
||||||
|
dto.setApiKeyHeader(trimToNull(resultSet.getString("api_key_header")));
|
||||||
|
dto.setConnectTimeoutSeconds(resultSet.getLong("connect_timeout_seconds"));
|
||||||
|
dto.setReadTimeoutSeconds(resultSet.getLong("read_timeout_seconds"));
|
||||||
|
dto.setTaskTimeoutSeconds(resultSet.getLong("task_timeout_seconds"));
|
||||||
|
dto.setTaskPollIntervalMillis(resultSet.getLong("task_poll_interval_millis"));
|
||||||
|
return dto;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateBaseUrl(String baseUrl) {
|
||||||
|
if (!StringUtils.hasText(baseUrl)) {
|
||||||
|
throw new BusinessException("语音识别服务地址不能为空");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
URI uri = URI.create(baseUrl);
|
||||||
|
if (!uri.isAbsolute() || uri.getScheme() == null || !Set.of("http", "https").contains(uri.getScheme().toLowerCase())) {
|
||||||
|
throw new IllegalArgumentException();
|
||||||
|
}
|
||||||
|
} catch (Exception exception) {
|
||||||
|
throw new BusinessException("语音识别服务地址格式不正确");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long resolveTenantId(Long tenantId) {
|
||||||
|
return tenantId == null ? resolveCurrentTenantId() : tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long resolveCurrentTenantId() {
|
||||||
|
if (tenantProvider == null) {
|
||||||
|
return GLOBAL_TENANT_ID;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Long tenantId = tenantProvider.getCurrentTenantId();
|
||||||
|
return tenantId == null ? GLOBAL_TENANT_ID : tenantId;
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
return GLOBAL_TENANT_ID;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void requirePermission(String perm, String message) {
|
||||||
|
if (!permissionService.hasPermi(perm)) {
|
||||||
|
throw new UnauthorizedException(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String defaultIfBlank(String value, String defaultValue) {
|
||||||
|
String trimmed = trimToNull(value);
|
||||||
|
return trimmed == null ? defaultValue : trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String trimToNull(String value) {
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String trimmed = value.trim();
|
||||||
|
return trimmed.isEmpty() ? null : trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private long clamp(long value, long min, long max, long defaultValue) {
|
||||||
|
if (value <= 0) {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
return Math.max(min, Math.min(max, value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,7 @@ import com.unis.crm.dto.work.WorkOverviewDTO;
|
||||||
import com.unis.crm.dto.work.WorkDailyReportRequirementDTO;
|
import com.unis.crm.dto.work.WorkDailyReportRequirementDTO;
|
||||||
import com.unis.crm.dto.work.WorkReportAttachmentDTO;
|
import com.unis.crm.dto.work.WorkReportAttachmentDTO;
|
||||||
import com.unis.crm.dto.work.WorkReportToUserDTO;
|
import com.unis.crm.dto.work.WorkReportToUserDTO;
|
||||||
|
import com.unis.crm.dto.work.WorkSpeechTranscriptionDTO;
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
@ -59,4 +60,6 @@ public interface WorkService {
|
||||||
WorkReportAttachmentDTO uploadReportAttachment(Long userId, MultipartFile file);
|
WorkReportAttachmentDTO uploadReportAttachment(Long userId, MultipartFile file);
|
||||||
|
|
||||||
Resource loadReportAttachment(String fileName);
|
Resource loadReportAttachment(String fileName);
|
||||||
|
|
||||||
|
WorkSpeechTranscriptionDTO transcribeReportSpeech(Long userId, MultipartFile file);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||||
import com.fasterxml.jackson.core.type.TypeReference;
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
import com.unis.crm.common.BusinessException;
|
import com.unis.crm.common.BusinessException;
|
||||||
import com.unis.crm.config.WorkReportProperties;
|
import com.unis.crm.config.WorkReportProperties;
|
||||||
|
import com.unis.crm.dto.speech.SpeechRecognitionConfigDTO;
|
||||||
import com.unis.crm.dto.work.CreateWorkCheckInRequest;
|
import com.unis.crm.dto.work.CreateWorkCheckInRequest;
|
||||||
import com.unis.crm.dto.work.CreateWorkDailyReportRequest;
|
import com.unis.crm.dto.work.CreateWorkDailyReportRequest;
|
||||||
import com.unis.crm.dto.work.WorkCheckInDTO;
|
import com.unis.crm.dto.work.WorkCheckInDTO;
|
||||||
|
|
@ -21,6 +22,7 @@ import com.unis.crm.dto.work.WorkTomorrowPlanItemRequest;
|
||||||
import com.unis.crm.dto.work.WorkOverviewDTO;
|
import com.unis.crm.dto.work.WorkOverviewDTO;
|
||||||
import com.unis.crm.dto.work.WorkReportAttachmentDTO;
|
import com.unis.crm.dto.work.WorkReportAttachmentDTO;
|
||||||
import com.unis.crm.dto.work.WorkSuggestedActionDTO;
|
import com.unis.crm.dto.work.WorkSuggestedActionDTO;
|
||||||
|
import com.unis.crm.dto.work.WorkSpeechTranscriptionDTO;
|
||||||
import com.unis.crm.mapper.OpportunityMapper;
|
import com.unis.crm.mapper.OpportunityMapper;
|
||||||
import com.unis.crm.mapper.ProfileMapper;
|
import com.unis.crm.mapper.ProfileMapper;
|
||||||
import com.unis.crm.mapper.WorkMapper;
|
import com.unis.crm.mapper.WorkMapper;
|
||||||
|
|
@ -28,6 +30,7 @@ import com.unis.crm.service.CrmDataVisibilityService;
|
||||||
import com.unis.crm.service.CrmDataVisibilityService.DataVisibility;
|
import com.unis.crm.service.CrmDataVisibilityService.DataVisibility;
|
||||||
import com.unis.crm.service.FileStorageService;
|
import com.unis.crm.service.FileStorageService;
|
||||||
import com.unis.crm.service.ReportReminderService;
|
import com.unis.crm.service.ReportReminderService;
|
||||||
|
import com.unis.crm.service.SpeechRecognitionConfigService;
|
||||||
import com.unis.crm.service.WorkService;
|
import com.unis.crm.service.WorkService;
|
||||||
import com.unisbase.service.SysPermissionService;
|
import com.unisbase.service.SysPermissionService;
|
||||||
import com.unisbase.spi.UnisBaseTenantProvider;
|
import com.unisbase.spi.UnisBaseTenantProvider;
|
||||||
|
|
@ -36,8 +39,10 @@ import java.math.BigDecimal;
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.net.URLEncoder;
|
import java.net.URLEncoder;
|
||||||
import java.net.http.HttpClient;
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpConnectTimeoutException;
|
||||||
import java.net.http.HttpRequest;
|
import java.net.http.HttpRequest;
|
||||||
import java.net.http.HttpResponse;
|
import java.net.http.HttpResponse;
|
||||||
|
import java.net.http.HttpTimeoutException;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
|
@ -52,6 +57,7 @@ import java.util.Comparator;
|
||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
@ -59,12 +65,22 @@ import java.util.concurrent.ConcurrentHashMap;
|
||||||
import java.util.regex.Matcher;
|
import java.util.regex.Matcher;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.core.io.ByteArrayResource;
|
||||||
import org.springframework.core.io.Resource;
|
import org.springframework.core.io.Resource;
|
||||||
|
import org.springframework.http.HttpEntity;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.util.LinkedMultiValueMap;
|
||||||
|
import org.springframework.util.MultiValueMap;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.transaction.support.TransactionSynchronization;
|
import org.springframework.transaction.support.TransactionSynchronization;
|
||||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||||
|
import org.springframework.web.client.RestClientResponseException;
|
||||||
|
import org.springframework.web.client.RestTemplate;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
|
@ -114,6 +130,7 @@ public class WorkServiceImpl implements WorkService {
|
||||||
private final CrmDataVisibilityService crmDataVisibilityService;
|
private final CrmDataVisibilityService crmDataVisibilityService;
|
||||||
private final ReportReminderService reportReminderService;
|
private final ReportReminderService reportReminderService;
|
||||||
private final WorkReportProperties workReportProperties;
|
private final WorkReportProperties workReportProperties;
|
||||||
|
private final SpeechRecognitionConfigService speechRecognitionConfigService;
|
||||||
private final SysPermissionService sysPermissionService;
|
private final SysPermissionService sysPermissionService;
|
||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
private final FileStorageService fileStorageService;
|
private final FileStorageService fileStorageService;
|
||||||
|
|
@ -126,6 +143,7 @@ public class WorkServiceImpl implements WorkService {
|
||||||
ProfileMapper profileMapper,
|
ProfileMapper profileMapper,
|
||||||
ReportReminderService reportReminderService,
|
ReportReminderService reportReminderService,
|
||||||
WorkReportProperties workReportProperties,
|
WorkReportProperties workReportProperties,
|
||||||
|
SpeechRecognitionConfigService speechRecognitionConfigService,
|
||||||
ObjectMapper objectMapper,
|
ObjectMapper objectMapper,
|
||||||
UnisBaseTenantProvider tenantProvider,
|
UnisBaseTenantProvider tenantProvider,
|
||||||
CrmDataVisibilityService crmDataVisibilityService,
|
CrmDataVisibilityService crmDataVisibilityService,
|
||||||
|
|
@ -138,6 +156,7 @@ public class WorkServiceImpl implements WorkService {
|
||||||
this.profileMapper = profileMapper;
|
this.profileMapper = profileMapper;
|
||||||
this.reportReminderService = reportReminderService;
|
this.reportReminderService = reportReminderService;
|
||||||
this.workReportProperties = workReportProperties == null ? new WorkReportProperties() : workReportProperties;
|
this.workReportProperties = workReportProperties == null ? new WorkReportProperties() : workReportProperties;
|
||||||
|
this.speechRecognitionConfigService = speechRecognitionConfigService;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
this.tenantProvider = tenantProvider;
|
this.tenantProvider = tenantProvider;
|
||||||
this.crmDataVisibilityService = crmDataVisibilityService;
|
this.crmDataVisibilityService = crmDataVisibilityService;
|
||||||
|
|
@ -493,6 +512,429 @@ public class WorkServiceImpl implements WorkService {
|
||||||
return fileStorageService.load("work-report-attachments/" + normalizedFileName);
|
return fileStorageService.load("work-report-attachments/" + normalizedFileName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public WorkSpeechTranscriptionDTO transcribeReportSpeech(Long userId, MultipartFile file) {
|
||||||
|
requireUser(userId);
|
||||||
|
ensureWorkWriteAllowed(userId, "当前角色仅可查看日报历史记录");
|
||||||
|
SpeechRecognitionConfigDTO speechConfig = resolveSpeechRecognitionConfig();
|
||||||
|
if (!speechConfig.isEnabled()) {
|
||||||
|
throw new BusinessException("日报语音识别功能未启用");
|
||||||
|
}
|
||||||
|
if (file == null || file.isEmpty()) {
|
||||||
|
throw new BusinessException("请先录制语音");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
HttpClient speechHttpClient = HttpClient.newBuilder()
|
||||||
|
.connectTimeout(Duration.ofSeconds(Math.max(1, speechConfig.getConnectTimeoutSeconds())))
|
||||||
|
.build();
|
||||||
|
ResponseEntity<String> response = postSpeechRecognitionMultipart(file, speechConfig);
|
||||||
|
int statusCode = response.getStatusCode().value();
|
||||||
|
String responseBody = response.getBody();
|
||||||
|
if (statusCode == 401 || statusCode == 403) {
|
||||||
|
throw new BusinessException("语音识别服务鉴权失败,请检查 API Key 配置");
|
||||||
|
}
|
||||||
|
if (statusCode < 200 || statusCode >= 300) {
|
||||||
|
String errorMessage = extractSpeechRecognitionErrorMessage(responseBody);
|
||||||
|
throw new BusinessException(errorMessage == null
|
||||||
|
? "语音识别服务返回异常:" + statusCode
|
||||||
|
: "语音识别失败:" + errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
String text = extractSpeechRecognitionText(responseBody);
|
||||||
|
if (text == null) {
|
||||||
|
String taskId = extractSpeechRecognitionTaskId(responseBody);
|
||||||
|
if (taskId == null) {
|
||||||
|
throw new BusinessException("语音识别结果为空,请重新录音");
|
||||||
|
}
|
||||||
|
text = pollSpeechRecognitionTask(taskId, speechConfig, speechHttpClient);
|
||||||
|
}
|
||||||
|
return new WorkSpeechTranscriptionDTO(text);
|
||||||
|
} catch (RestClientResponseException exception) {
|
||||||
|
if (exception.getStatusCode().value() == 401 || exception.getStatusCode().value() == 403) {
|
||||||
|
throw new BusinessException("语音识别服务鉴权失败,请检查 API Key 配置");
|
||||||
|
}
|
||||||
|
String errorMessage = extractSpeechRecognitionErrorMessage(exception.getResponseBodyAsString());
|
||||||
|
throw new BusinessException(errorMessage == null
|
||||||
|
? "语音识别服务返回异常:" + exception.getStatusCode().value()
|
||||||
|
: "语音识别失败:" + errorMessage);
|
||||||
|
} catch (BusinessException exception) {
|
||||||
|
throw exception;
|
||||||
|
} catch (InterruptedException exception) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
throw new BusinessException("语音识别已中断,请重试");
|
||||||
|
} catch (HttpConnectTimeoutException exception) {
|
||||||
|
throw new BusinessException("语音识别服务连接超时,请检查 ASR 服务网络或稍后重试");
|
||||||
|
} catch (HttpTimeoutException exception) {
|
||||||
|
throw new BusinessException("语音识别服务响应超时,请缩短录音时长后重试");
|
||||||
|
} catch (Exception exception) {
|
||||||
|
throw new BusinessException("语音识别失败,请稍后重试");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResponseEntity<String> postSpeechRecognitionMultipart(MultipartFile file, SpeechRecognitionConfigDTO speechConfig) throws IOException {
|
||||||
|
SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
|
||||||
|
requestFactory.setConnectTimeout((int) Duration.ofSeconds(Math.max(1, speechConfig.getConnectTimeoutSeconds())).toMillis());
|
||||||
|
requestFactory.setReadTimeout((int) Duration.ofSeconds(Math.max(1, speechConfig.getReadTimeoutSeconds())).toMillis());
|
||||||
|
RestTemplate restTemplate = new RestTemplate(requestFactory);
|
||||||
|
|
||||||
|
HttpHeaders headers = new HttpHeaders();
|
||||||
|
headers.setAccept(List.of(MediaType.APPLICATION_JSON, MediaType.TEXT_PLAIN, MediaType.ALL));
|
||||||
|
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
|
||||||
|
applySpeechRecognitionAuthentication(headers, speechConfig);
|
||||||
|
|
||||||
|
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
|
||||||
|
String language = normalizeOptionalText(speechConfig.getLanguage());
|
||||||
|
if (language != null) {
|
||||||
|
body.add("language", language);
|
||||||
|
}
|
||||||
|
addSpeechRecognitionTextPart(body, "response_format", defaultIfBlank(speechConfig.getResponseFormat(), "json"));
|
||||||
|
addSpeechRecognitionTextPart(body, "model", speechConfig.getModel());
|
||||||
|
addSpeechRecognitionTextPart(body, "prompt", speechConfig.getPrompt());
|
||||||
|
addSpeechRecognitionTextPart(body, "hotwords", speechConfig.getHotwords());
|
||||||
|
addSpeechRecognitionTextPart(body, "timestamp_granularities", speechConfig.getTimestampGranularities());
|
||||||
|
addSpeechRecognitionBooleanPart(body, "enable_speaker_diarization", speechConfig.getEnableSpeakerDiarization());
|
||||||
|
addSpeechRecognitionBooleanPart(body, "enable_speaker_identification", speechConfig.getEnableSpeakerIdentification());
|
||||||
|
addSpeechRecognitionBooleanPart(body, "enable_text_cleanup", speechConfig.getEnableTextCleanup());
|
||||||
|
if (speechConfig.getTemperature() != null) {
|
||||||
|
body.add("temperature", String.valueOf(speechConfig.getTemperature()));
|
||||||
|
}
|
||||||
|
body.add("file", buildSpeechRecognitionFilePart(file));
|
||||||
|
|
||||||
|
return restTemplate.postForEntity(buildSpeechRecognitionUri(speechConfig), new HttpEntity<>(body, headers), String.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addSpeechRecognitionTextPart(MultiValueMap<String, Object> body, String name, String value) {
|
||||||
|
String normalizedValue = normalizeOptionalText(value);
|
||||||
|
if (normalizedValue != null) {
|
||||||
|
body.add(name, normalizedValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addSpeechRecognitionBooleanPart(MultiValueMap<String, Object> body, String name, Boolean value) {
|
||||||
|
if (value != null) {
|
||||||
|
body.add(name, String.valueOf(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private HttpEntity<ByteArrayResource> buildSpeechRecognitionFilePart(MultipartFile file) throws IOException {
|
||||||
|
String fileName = normalizeOptionalText(file.getOriginalFilename());
|
||||||
|
if (fileName == null) {
|
||||||
|
fileName = "report-speech.webm";
|
||||||
|
}
|
||||||
|
String resolvedFileName = fileName;
|
||||||
|
String contentType = normalizeOptionalText(file.getContentType());
|
||||||
|
|
||||||
|
HttpHeaders fileHeaders = new HttpHeaders();
|
||||||
|
fileHeaders.setContentType(contentType == null ? MediaType.APPLICATION_OCTET_STREAM : MediaType.parseMediaType(contentType));
|
||||||
|
ByteArrayResource resource = new ByteArrayResource(file.getBytes()) {
|
||||||
|
@Override
|
||||||
|
public String getFilename() {
|
||||||
|
return resolvedFileName;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public long contentLength() {
|
||||||
|
return file.getSize();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return new HttpEntity<>(resource, fileHeaders);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String pollSpeechRecognitionTask(String taskId, SpeechRecognitionConfigDTO speechConfig, HttpClient speechHttpClient) throws IOException, InterruptedException {
|
||||||
|
long timeoutSeconds = Math.max(1, speechConfig.getTaskTimeoutSeconds());
|
||||||
|
long pollIntervalMillis = Math.max(300, speechConfig.getTaskPollIntervalMillis());
|
||||||
|
long deadlineNanos = System.nanoTime() + Duration.ofSeconds(timeoutSeconds).toNanos();
|
||||||
|
URI taskUri = buildSpeechRecognitionTaskUri(taskId, speechConfig);
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
|
||||||
|
.uri(taskUri)
|
||||||
|
.timeout(Duration.ofSeconds(Math.max(1, speechConfig.getReadTimeoutSeconds())))
|
||||||
|
.header("Accept", "application/json,text/plain,*/*")
|
||||||
|
.GET();
|
||||||
|
applySpeechRecognitionAuthentication(requestBuilder, speechConfig);
|
||||||
|
HttpResponse<String> response = speechHttpClient.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||||
|
if (response.statusCode() == 401 || response.statusCode() == 403) {
|
||||||
|
throw new BusinessException("语音识别服务鉴权失败,请检查 API Key 配置");
|
||||||
|
}
|
||||||
|
if (response.statusCode() < 200 || response.statusCode() >= 300) {
|
||||||
|
String errorMessage = extractSpeechRecognitionErrorMessage(response.body());
|
||||||
|
throw new BusinessException(errorMessage == null
|
||||||
|
? "语音识别任务查询异常:" + response.statusCode()
|
||||||
|
: "语音识别失败:" + errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
String text = extractSpeechRecognitionText(response.body());
|
||||||
|
if (text != null) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
String status = extractSpeechRecognitionTaskStatus(response.body());
|
||||||
|
if (isSpeechRecognitionFailedStatus(status)) {
|
||||||
|
String errorMessage = extractSpeechRecognitionErrorMessage(response.body());
|
||||||
|
throw new BusinessException(errorMessage == null ? "语音识别任务处理失败" : "语音识别失败:" + errorMessage);
|
||||||
|
}
|
||||||
|
if (isSpeechRecognitionCompletedStatus(status)) {
|
||||||
|
throw new BusinessException("语音识别结果为空,请重新录音");
|
||||||
|
}
|
||||||
|
if (System.nanoTime() >= deadlineNanos) {
|
||||||
|
throw new BusinessException("语音识别服务响应超时,请缩短录音时长后重试");
|
||||||
|
}
|
||||||
|
Thread.sleep(Math.min(pollIntervalMillis, Math.max(300, Duration.ofNanos(deadlineNanos - System.nanoTime()).toMillis())));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private SpeechRecognitionConfigDTO resolveSpeechRecognitionConfig() {
|
||||||
|
if (speechRecognitionConfigService != null) {
|
||||||
|
return speechRecognitionConfigService.getEffectiveConfig(resolveCurrentTenantId());
|
||||||
|
}
|
||||||
|
SpeechRecognitionConfigDTO config = new SpeechRecognitionConfigDTO();
|
||||||
|
config.setEnabled(true);
|
||||||
|
config.setBaseUrl(null);
|
||||||
|
config.setTranscribePath("/v1/audio/transcriptions");
|
||||||
|
config.setTaskStatusPath("/v1/tasks/{task_id}");
|
||||||
|
config.setFileFieldName("file");
|
||||||
|
config.setLanguage(null);
|
||||||
|
config.setResponseFormat("json");
|
||||||
|
config.setApiKey(null);
|
||||||
|
config.setApiKeyHeader("X-API-Key");
|
||||||
|
config.setConnectTimeoutSeconds(10);
|
||||||
|
config.setReadTimeoutSeconds(300);
|
||||||
|
config.setTaskTimeoutSeconds(300);
|
||||||
|
config.setTaskPollIntervalMillis(1500);
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applySpeechRecognitionAuthentication(HttpRequest.Builder requestBuilder, SpeechRecognitionConfigDTO speechConfig) {
|
||||||
|
String apiKey = normalizeOptionalText(speechConfig.getApiKey());
|
||||||
|
if (apiKey == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String apiKeyHeader = normalizeOptionalText(speechConfig.getApiKeyHeader());
|
||||||
|
requestBuilder.header(apiKeyHeader == null ? "X-API-Key" : apiKeyHeader, apiKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applySpeechRecognitionAuthentication(HttpHeaders headers, SpeechRecognitionConfigDTO speechConfig) {
|
||||||
|
String apiKey = normalizeOptionalText(speechConfig.getApiKey());
|
||||||
|
if (apiKey == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String apiKeyHeader = normalizeOptionalText(speechConfig.getApiKeyHeader());
|
||||||
|
headers.set(apiKeyHeader == null ? "X-API-Key" : apiKeyHeader, apiKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
private URI buildSpeechRecognitionUri(SpeechRecognitionConfigDTO speechConfig) {
|
||||||
|
String baseUrl = normalizeOptionalText(speechConfig.getBaseUrl());
|
||||||
|
if (baseUrl == null) {
|
||||||
|
throw new BusinessException("语音识别服务地址未配置");
|
||||||
|
}
|
||||||
|
String transcribePath = normalizeOptionalText(speechConfig.getTranscribePath());
|
||||||
|
String normalizedBaseUrl = baseUrl.endsWith("/") ? baseUrl : baseUrl + "/";
|
||||||
|
String normalizedPath = transcribePath == null ? "" : transcribePath.replaceFirst("^/+", "");
|
||||||
|
return URI.create(normalizedBaseUrl).resolve(normalizedPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private URI buildSpeechRecognitionTaskUri(String taskId, SpeechRecognitionConfigDTO speechConfig) {
|
||||||
|
String baseUrl = normalizeOptionalText(speechConfig.getBaseUrl());
|
||||||
|
if (baseUrl == null) {
|
||||||
|
throw new BusinessException("语音识别服务地址未配置");
|
||||||
|
}
|
||||||
|
String taskStatusPath = normalizeOptionalText(speechConfig.getTaskStatusPath());
|
||||||
|
String normalizedBaseUrl = baseUrl.endsWith("/") ? baseUrl : baseUrl + "/";
|
||||||
|
String encodedTaskId = URLEncoder.encode(taskId, StandardCharsets.UTF_8).replace("+", "%20");
|
||||||
|
String normalizedPath = taskStatusPath == null ? "v1/tasks/" + encodedTaskId : taskStatusPath.replaceFirst("^/+", "");
|
||||||
|
if (normalizedPath.contains("{task_id}")) {
|
||||||
|
normalizedPath = normalizedPath.replace("{task_id}", encodedTaskId);
|
||||||
|
} else if (normalizedPath.contains("{taskId}")) {
|
||||||
|
normalizedPath = normalizedPath.replace("{taskId}", encodedTaskId);
|
||||||
|
} else if (!normalizedPath.endsWith("/" + encodedTaskId)) {
|
||||||
|
normalizedPath = (normalizedPath.endsWith("/") ? normalizedPath : normalizedPath + "/") + encodedTaskId;
|
||||||
|
}
|
||||||
|
return URI.create(normalizedBaseUrl).resolve(normalizedPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String extractSpeechRecognitionText(String rawBody) {
|
||||||
|
String rawText = normalizeOptionalText(rawBody);
|
||||||
|
if (rawText == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
JsonNode root = objectMapper.readTree(rawText);
|
||||||
|
String text = pickSpeechRecognitionText(root);
|
||||||
|
return normalizeOptionalText(text);
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
return rawText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String extractSpeechRecognitionTaskId(String rawBody) {
|
||||||
|
String rawText = normalizeOptionalText(rawBody);
|
||||||
|
if (rawText == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
JsonNode root = objectMapper.readTree(rawText);
|
||||||
|
return normalizeOptionalText(pickSpeechRecognitionFieldText(root, List.of("task_id", "taskId", "taskID", "id")));
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String extractSpeechRecognitionTaskStatus(String rawBody) {
|
||||||
|
String rawText = normalizeOptionalText(rawBody);
|
||||||
|
if (rawText == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
JsonNode root = objectMapper.readTree(rawText);
|
||||||
|
return normalizeOptionalText(pickSpeechRecognitionFieldText(root, List.of("status", "state", "task_status", "taskStatus")));
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String extractSpeechRecognitionErrorMessage(String rawBody) {
|
||||||
|
String rawText = normalizeOptionalText(rawBody);
|
||||||
|
if (rawText == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
JsonNode root = objectMapper.readTree(rawText);
|
||||||
|
String detail = pickSpeechRecognitionErrorText(root.get("detail"));
|
||||||
|
if (normalizeOptionalText(detail) != null) {
|
||||||
|
return detail;
|
||||||
|
}
|
||||||
|
String message = pickSpeechRecognitionErrorText(root.get("message"));
|
||||||
|
if (normalizeOptionalText(message) != null) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
String error = pickSpeechRecognitionErrorText(root.get("error"));
|
||||||
|
if (normalizeOptionalText(error) != null) {
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
return rawText;
|
||||||
|
}
|
||||||
|
return rawText;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String pickSpeechRecognitionErrorText(JsonNode node) {
|
||||||
|
if (node == null || node.isNull()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (node.isTextual()) {
|
||||||
|
return node.asText();
|
||||||
|
}
|
||||||
|
if (node.isArray()) {
|
||||||
|
List<String> parts = new ArrayList<>();
|
||||||
|
for (JsonNode child : node) {
|
||||||
|
String text = normalizeOptionalText(pickSpeechRecognitionErrorText(child));
|
||||||
|
if (text != null) {
|
||||||
|
parts.add(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts.isEmpty() ? null : String.join(";", parts);
|
||||||
|
}
|
||||||
|
for (String fieldName : List.of("msg", "message", "detail", "error")) {
|
||||||
|
String text = pickSpeechRecognitionErrorText(node.get(fieldName));
|
||||||
|
if (normalizeOptionalText(text) != null) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String pickSpeechRecognitionFieldText(JsonNode node, List<String> fieldNames) {
|
||||||
|
if (node == null || node.isNull()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (node.isObject()) {
|
||||||
|
for (String fieldName : fieldNames) {
|
||||||
|
JsonNode value = node.get(fieldName);
|
||||||
|
if (value == null || value.isNull()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (value.isTextual() || value.isNumber() || value.isBoolean()) {
|
||||||
|
return value.asText();
|
||||||
|
}
|
||||||
|
String nestedText = pickSpeechRecognitionFieldText(value, fieldNames);
|
||||||
|
if (normalizeOptionalText(nestedText) != null) {
|
||||||
|
return nestedText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (String fieldName : List.of("data", "payload", "result", "task")) {
|
||||||
|
String nestedText = pickSpeechRecognitionFieldText(node.get(fieldName), fieldNames);
|
||||||
|
if (normalizeOptionalText(nestedText) != null) {
|
||||||
|
return nestedText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (node.isArray()) {
|
||||||
|
for (JsonNode child : node) {
|
||||||
|
String nestedText = pickSpeechRecognitionFieldText(child, fieldNames);
|
||||||
|
if (normalizeOptionalText(nestedText) != null) {
|
||||||
|
return nestedText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isSpeechRecognitionCompletedStatus(String status) {
|
||||||
|
String normalizedStatus = normalizeSpeechRecognitionStatus(status);
|
||||||
|
return normalizedStatus != null && Set.of("completed", "complete", "succeeded", "success", "done", "finished").contains(normalizedStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isSpeechRecognitionFailedStatus(String status) {
|
||||||
|
String normalizedStatus = normalizeSpeechRecognitionStatus(status);
|
||||||
|
return normalizedStatus != null && Set.of("failed", "failure", "error", "canceled", "cancelled", "timeout", "timedout").contains(normalizedStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeSpeechRecognitionStatus(String status) {
|
||||||
|
String normalizedStatus = normalizeOptionalText(status);
|
||||||
|
return normalizedStatus == null ? null : normalizedStatus.toLowerCase(Locale.ROOT).replace("_", "").replace("-", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
private String pickSpeechRecognitionText(JsonNode node) {
|
||||||
|
if (node == null || node.isNull()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (node.isTextual()) {
|
||||||
|
return node.asText();
|
||||||
|
}
|
||||||
|
if (node.isArray()) {
|
||||||
|
List<String> parts = new ArrayList<>();
|
||||||
|
for (JsonNode child : node) {
|
||||||
|
String text = normalizeOptionalText(pickSpeechRecognitionText(child));
|
||||||
|
if (text != null) {
|
||||||
|
parts.add(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts.isEmpty() ? null : String.join("", parts);
|
||||||
|
}
|
||||||
|
for (String fieldName : List.of("text", "transcription", "result", "content")) {
|
||||||
|
JsonNode value = node.get(fieldName);
|
||||||
|
if (value == null || value.isNull()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (value.isTextual()) {
|
||||||
|
return value.asText();
|
||||||
|
}
|
||||||
|
String nestedText = pickSpeechRecognitionText(value);
|
||||||
|
if (normalizeOptionalText(nestedText) != null) {
|
||||||
|
return nestedText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (String fieldName : List.of("data", "payload", "segments")) {
|
||||||
|
String nestedText = pickSpeechRecognitionText(node.get(fieldName));
|
||||||
|
if (normalizeOptionalText(nestedText) != null) {
|
||||||
|
return nestedText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
private void requireUser(Long userId) {
|
private void requireUser(Long userId) {
|
||||||
if (userId == null || userId <= 0) {
|
if (userId == null || userId <= 0) {
|
||||||
throw new BusinessException("未获取到当前登录用户");
|
throw new BusinessException("未获取到当前登录用户");
|
||||||
|
|
@ -2454,6 +2896,11 @@ public class WorkServiceImpl implements WorkService {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String defaultIfBlank(String value, String defaultValue) {
|
||||||
|
String normalized = normalizeOptionalText(value);
|
||||||
|
return normalized == null ? defaultValue : normalized;
|
||||||
|
}
|
||||||
|
|
||||||
private String normalizeBizType(String value) {
|
private String normalizeBizType(String value) {
|
||||||
String normalized = normalizeOptionalText(value);
|
String normalized = normalizeOptionalText(value);
|
||||||
if (normalized == null) {
|
if (normalized == null) {
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ spring:
|
||||||
max-file-size: 500MB
|
max-file-size: 500MB
|
||||||
max-request-size: 600MB
|
max-request-size: 600MB
|
||||||
datasource:
|
datasource:
|
||||||
url: jdbc:postgresql://192.168.124.202:5432/unis_crm
|
url: jdbc:postgresql://192.168.124.202:5432/unis_crm_dev
|
||||||
username: postgres
|
username: postgres
|
||||||
password: unis@123
|
password: unis@123
|
||||||
driver-class-name: org.postgresql.Driver
|
driver-class-name: org.postgresql.Driver
|
||||||
|
|
@ -26,7 +26,7 @@ spring:
|
||||||
host: 192.168.124.202
|
host: 192.168.124.202
|
||||||
port: 6379
|
port: 6379
|
||||||
password: zghz@123
|
password: zghz@123
|
||||||
database: 12
|
database: 10
|
||||||
|
|
||||||
mybatis-plus:
|
mybatis-plus:
|
||||||
mapper-locations: classpath*:/mapper/**/*.xml
|
mapper-locations: classpath*:/mapper/**/*.xml
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ import com.unis.crm.service.CrmDataVisibilityService;
|
||||||
import com.unis.crm.service.FileStorageService;
|
import com.unis.crm.service.FileStorageService;
|
||||||
import com.unis.crm.service.CrmDataVisibilityService.DataVisibility;
|
import com.unis.crm.service.CrmDataVisibilityService.DataVisibility;
|
||||||
import com.unis.crm.service.ReportReminderService;
|
import com.unis.crm.service.ReportReminderService;
|
||||||
|
import com.unis.crm.service.SpeechRecognitionConfigService;
|
||||||
import com.unisbase.service.SysPermissionService;
|
import com.unisbase.service.SysPermissionService;
|
||||||
import com.unisbase.spi.UnisBaseTenantProvider;
|
import com.unisbase.spi.UnisBaseTenantProvider;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
@ -61,6 +62,9 @@ class WorkServiceImplTest {
|
||||||
@Mock
|
@Mock
|
||||||
private ReportReminderService reportReminderService;
|
private ReportReminderService reportReminderService;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private SpeechRecognitionConfigService speechRecognitionConfigService;
|
||||||
|
|
||||||
@Mock
|
@Mock
|
||||||
private UnisBaseTenantProvider tenantProvider;
|
private UnisBaseTenantProvider tenantProvider;
|
||||||
|
|
||||||
|
|
@ -89,6 +93,7 @@ class WorkServiceImplTest {
|
||||||
profileMapper,
|
profileMapper,
|
||||||
reportReminderService,
|
reportReminderService,
|
||||||
workReportProperties,
|
workReportProperties,
|
||||||
|
speechRecognitionConfigService,
|
||||||
new ObjectMapper(),
|
new ObjectMapper(),
|
||||||
tenantProvider,
|
tenantProvider,
|
||||||
crmDataVisibilityService,
|
crmDataVisibilityService,
|
||||||
|
|
|
||||||
|
|
@ -22,8 +22,8 @@ server {
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
proxy_connect_timeout 10s;
|
proxy_connect_timeout 10s;
|
||||||
proxy_read_timeout 300s;
|
proxy_read_timeout 360s;
|
||||||
proxy_send_timeout 300s;
|
proxy_send_timeout 360s;
|
||||||
}
|
}
|
||||||
|
|
||||||
# CRM controllers already use /api as part of their canonical path.
|
# CRM controllers already use /api as part of their canonical path.
|
||||||
|
|
@ -36,8 +36,8 @@ server {
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
proxy_connect_timeout 10s;
|
proxy_connect_timeout 10s;
|
||||||
proxy_read_timeout 300s;
|
proxy_read_timeout 360s;
|
||||||
proxy_send_timeout 300s;
|
proxy_send_timeout 360s;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /sys/ {
|
location /sys/ {
|
||||||
|
|
@ -49,7 +49,7 @@ server {
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
proxy_connect_timeout 10s;
|
proxy_connect_timeout 10s;
|
||||||
proxy_read_timeout 300s;
|
proxy_read_timeout 360s;
|
||||||
proxy_send_timeout 300s;
|
proxy_send_timeout 360s;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -334,6 +334,10 @@ export interface UploadReportAttachmentOptions {
|
||||||
onProgress?: (progress: number) => void;
|
onProgress?: (progress: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WorkSpeechTranscription {
|
||||||
|
text?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface WorkReportToUser {
|
export interface WorkReportToUser {
|
||||||
userId: CrmId;
|
userId: CrmId;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -1600,6 +1604,15 @@ export async function uploadWorkReportAttachment(file: File, options?: UploadRep
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function transcribeWorkReportSpeech(file: File) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
return request<WorkSpeechTranscription>("/api/work/report-speech/transcribe", {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
}, true);
|
||||||
|
}
|
||||||
|
|
||||||
export async function saveWorkDailyReport(payload: CreateWorkDailyReportPayload) {
|
export async function saveWorkDailyReport(payload: CreateWorkDailyReportPayload) {
|
||||||
return request<number>("/api/work/daily-reports", {
|
return request<number>("/api/work/daily-reports", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, ty
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { zhCN } from "date-fns/locale";
|
import { zhCN } from "date-fns/locale";
|
||||||
import { motion } from "motion/react";
|
import { motion } from "motion/react";
|
||||||
import { ArrowUp, Camera, CheckCircle2, ChevronDown, Download, FileText, Hash, ListTodo, MapPin, NotebookPen, Paperclip, Plus, RefreshCw, RotateCcw, Search, Send, Trash2, UserRoundPlus, X } from "lucide-react";
|
import { ArrowUp, Camera, CheckCircle2, ChevronDown, Download, FileText, Hash, ListTodo, MapPin, Mic, NotebookPen, Paperclip, Plus, RefreshCw, RotateCcw, Search, Send, Square, Trash2, UserRoundPlus, X } from "lucide-react";
|
||||||
import { flushSync } from "react-dom";
|
import { flushSync } from "react-dom";
|
||||||
import { Link, Navigate, useLocation, useNavigate } from "react-router-dom";
|
import { Link, Navigate, useLocation, useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
|
|
@ -29,6 +29,7 @@ import {
|
||||||
reverseWorkGeocode,
|
reverseWorkGeocode,
|
||||||
saveWorkCheckIn,
|
saveWorkCheckIn,
|
||||||
saveWorkDailyReport,
|
saveWorkDailyReport,
|
||||||
|
transcribeWorkReportSpeech,
|
||||||
uploadWorkCheckInPhoto,
|
uploadWorkCheckInPhoto,
|
||||||
uploadWorkReportAttachment,
|
uploadWorkReportAttachment,
|
||||||
type ChannelExpansionItem,
|
type ChannelExpansionItem,
|
||||||
|
|
@ -83,6 +84,7 @@ const OPPORTUNITY_NEXT_PLAN_LABEL = "下一步销售计划";
|
||||||
const OPPORTUNITY_STAGE_LABEL = "项目阶段";
|
const OPPORTUNITY_STAGE_LABEL = "项目阶段";
|
||||||
const WORK_DETAIL_NEXT_PLAN_HEADER = "后续规划 / 下一步销售计划";
|
const WORK_DETAIL_NEXT_PLAN_HEADER = "后续规划 / 下一步销售计划";
|
||||||
const REPORT_ATTACHMENT_MAX_SIZE_BYTES = 500 * 1024 * 1024;
|
const REPORT_ATTACHMENT_MAX_SIZE_BYTES = 500 * 1024 * 1024;
|
||||||
|
const REPORT_SPEECH_MIN_RECORDING_MS = 1000;
|
||||||
|
|
||||||
const reportFieldLabels = {
|
const reportFieldLabels = {
|
||||||
sales: ["沟通内容", LEGACY_NEXT_PLAN_LABEL],
|
sales: ["沟通内容", LEGACY_NEXT_PLAN_LABEL],
|
||||||
|
|
@ -148,6 +150,19 @@ type WorkRelationOption = {
|
||||||
type BizType = WorkReportLineItem["bizType"];
|
type BizType = WorkReportLineItem["bizType"];
|
||||||
type PickerMode = "report" | "checkin";
|
type PickerMode = "report" | "checkin";
|
||||||
type WorkSection = (typeof workSectionItems)[number]["key"];
|
type WorkSection = (typeof workSectionItems)[number]["key"];
|
||||||
|
type ReportSpeechStatus = "idle" | "recording" | "transcribing" | "error";
|
||||||
|
type ReportSpeechState = {
|
||||||
|
lineIndex: number;
|
||||||
|
targetField: string;
|
||||||
|
insertionStart?: number;
|
||||||
|
insertionEnd?: number;
|
||||||
|
status: ReportSpeechStatus;
|
||||||
|
error: string;
|
||||||
|
};
|
||||||
|
type ReportSpeechInsertionRange = {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
};
|
||||||
type LocationPoint = {
|
type LocationPoint = {
|
||||||
latitude: number;
|
latitude: number;
|
||||||
longitude: number;
|
longitude: number;
|
||||||
|
|
@ -2290,6 +2305,7 @@ export default function Work() {
|
||||||
uploadingReportAttachmentProgress={uploadingReportAttachmentProgress}
|
uploadingReportAttachmentProgress={uploadingReportAttachmentProgress}
|
||||||
reportAttachmentUploadErrors={reportAttachmentUploadErrors}
|
reportAttachmentUploadErrors={reportAttachmentUploadErrors}
|
||||||
dailyReportFeatureLoaded={dailyReportFeatureLoaded}
|
dailyReportFeatureLoaded={dailyReportFeatureLoaded}
|
||||||
|
objectSelectionRequired={dailyReportObjectSelectionRequired}
|
||||||
notifyUsersVisible={dailyReportNotifyUsersVisible}
|
notifyUsersVisible={dailyReportNotifyUsersVisible}
|
||||||
attachmentsVisible={dailyReportAttachmentsVisible}
|
attachmentsVisible={dailyReportAttachmentsVisible}
|
||||||
onReportAttachmentChange={(index, event) => void handleReportAttachmentChange(index, event)}
|
onReportAttachmentChange={(index, event) => void handleReportAttachmentChange(index, event)}
|
||||||
|
|
@ -4172,6 +4188,7 @@ function ReportPanel({
|
||||||
uploadingReportAttachmentProgress,
|
uploadingReportAttachmentProgress,
|
||||||
reportAttachmentUploadErrors,
|
reportAttachmentUploadErrors,
|
||||||
dailyReportFeatureLoaded,
|
dailyReportFeatureLoaded,
|
||||||
|
objectSelectionRequired,
|
||||||
notifyUsersVisible,
|
notifyUsersVisible,
|
||||||
attachmentsVisible,
|
attachmentsVisible,
|
||||||
onReportAttachmentChange,
|
onReportAttachmentChange,
|
||||||
|
|
@ -4204,6 +4221,7 @@ function ReportPanel({
|
||||||
uploadingReportAttachmentProgress: Record<number, number>;
|
uploadingReportAttachmentProgress: Record<number, number>;
|
||||||
reportAttachmentUploadErrors: Record<number, string>;
|
reportAttachmentUploadErrors: Record<number, string>;
|
||||||
dailyReportFeatureLoaded: boolean;
|
dailyReportFeatureLoaded: boolean;
|
||||||
|
objectSelectionRequired: boolean;
|
||||||
notifyUsersVisible: boolean;
|
notifyUsersVisible: boolean;
|
||||||
attachmentsVisible: boolean;
|
attachmentsVisible: boolean;
|
||||||
onReportAttachmentChange: (index: number, event: ChangeEvent<HTMLInputElement>) => void;
|
onReportAttachmentChange: (index: number, event: ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
|
@ -4222,14 +4240,32 @@ function ReportPanel({
|
||||||
}) {
|
}) {
|
||||||
const [editingReportLineIndex, setEditingReportLineIndex] = useState<number | null>(null);
|
const [editingReportLineIndex, setEditingReportLineIndex] = useState<number | null>(null);
|
||||||
const [editingPlanItemIndex, setEditingPlanItemIndex] = useState<number | null>(null);
|
const [editingPlanItemIndex, setEditingPlanItemIndex] = useState<number | null>(null);
|
||||||
|
const [reportSpeechState, setReportSpeechState] = useState<ReportSpeechState | null>(null);
|
||||||
|
const [speechElapsedSeconds, setSpeechElapsedSeconds] = useState(0);
|
||||||
const reportLineTextareaRefs = useRef<(HTMLTextAreaElement | null)[]>([]);
|
const reportLineTextareaRefs = useRef<(HTMLTextAreaElement | null)[]>([]);
|
||||||
|
const reportLineSelectionRefs = useRef<Record<number, ReportSpeechInsertionRange>>({});
|
||||||
|
const pendingReportSpeechInsertionRef = useRef<{ lineIndex: number } & ReportSpeechInsertionRange | null>(null);
|
||||||
const reportAttachmentInputRefs = useRef<(HTMLInputElement | null)[]>([]);
|
const reportAttachmentInputRefs = useRef<(HTMLInputElement | null)[]>([]);
|
||||||
const planItemInputRefs = useRef<(HTMLInputElement | null)[]>([]);
|
const planItemInputRefs = useRef<(HTMLInputElement | null)[]>([]);
|
||||||
|
const reportLineItemsRef = useRef(reportForm.lineItems);
|
||||||
|
const speechStreamRef = useRef<MediaStream | null>(null);
|
||||||
|
const speechAudioContextRef = useRef<AudioContext | null>(null);
|
||||||
|
const speechAudioSourceRef = useRef<MediaStreamAudioSourceNode | null>(null);
|
||||||
|
const speechAudioProcessorRef = useRef<ScriptProcessorNode | null>(null);
|
||||||
|
const speechSampleChunksRef = useRef<Float32Array[]>([]);
|
||||||
|
const speechSampleCountRef = useRef(0);
|
||||||
|
const speechTimerRef = useRef<number | null>(null);
|
||||||
|
const speechCancelledRef = useRef(false);
|
||||||
|
const speechRecordingStartedAtRef = useRef(0);
|
||||||
const previousReportLineCountRef = useRef(reportForm.lineItems.length);
|
const previousReportLineCountRef = useRef(reportForm.lineItems.length);
|
||||||
const previousPlanItemCountRef = useRef(reportForm.planItems.length);
|
const previousPlanItemCountRef = useRef(reportForm.planItems.length);
|
||||||
const pendingNewReportLineFocusRef = useRef<number | null>(null);
|
const pendingNewReportLineFocusRef = useRef<number | null>(null);
|
||||||
const pendingNewPlanItemFocusRef = useRef<number | null>(null);
|
const pendingNewPlanItemFocusRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
reportLineItemsRef.current = reportForm.lineItems;
|
||||||
|
}, [reportForm.lineItems]);
|
||||||
|
|
||||||
const focusTextControl = useCallback((element: HTMLTextAreaElement | HTMLInputElement | null) => {
|
const focusTextControl = useCallback((element: HTMLTextAreaElement | HTMLInputElement | null) => {
|
||||||
if (!element) {
|
if (!element) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -4242,11 +4278,45 @@ function ReportPanel({
|
||||||
element.setSelectionRange(textLength, textLength);
|
element.setSelectionRange(textLength, textLength);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const activateReportLineEditor = useCallback((index: number) => {
|
const blurActiveTextControl = useCallback(() => {
|
||||||
|
const activeElement = document.activeElement;
|
||||||
|
if (activeElement instanceof HTMLTextAreaElement || activeElement instanceof HTMLInputElement) {
|
||||||
|
activeElement.blur();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const rememberReportLineSelection = useCallback((lineIndex: number, element: HTMLTextAreaElement | null) => {
|
||||||
|
if (!element) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reportLineSelectionRefs.current[lineIndex] = {
|
||||||
|
start: element.selectionStart ?? element.value.length,
|
||||||
|
end: element.selectionEnd ?? element.selectionStart ?? element.value.length,
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getReportSpeechInsertionRange = useCallback((lineIndex: number): ReportSpeechInsertionRange | undefined => {
|
||||||
|
const element = reportLineTextareaRefs.current[lineIndex];
|
||||||
|
if (!element) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (document.activeElement === element) {
|
||||||
|
return {
|
||||||
|
start: element.selectionStart ?? element.value.length,
|
||||||
|
end: element.selectionEnd ?? element.selectionStart ?? element.value.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return reportLineSelectionRefs.current[lineIndex] ?? { start: element.value.length, end: element.value.length };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const activateReportLineEditor = useCallback((index: number, options?: { focus?: boolean }) => {
|
||||||
flushSync(() => {
|
flushSync(() => {
|
||||||
setEditingPlanItemIndex(null);
|
setEditingPlanItemIndex(null);
|
||||||
setEditingReportLineIndex(index);
|
setEditingReportLineIndex(index);
|
||||||
});
|
});
|
||||||
|
if (options?.focus === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const target = reportLineTextareaRefs.current[index];
|
const target = reportLineTextareaRefs.current[index];
|
||||||
if (target) {
|
if (target) {
|
||||||
focusTextControl(target);
|
focusTextControl(target);
|
||||||
|
|
@ -4268,6 +4338,222 @@ function ReportPanel({
|
||||||
requestAnimationFrame(() => focusTextControl(planItemInputRefs.current[index]));
|
requestAnimationFrame(() => focusTextControl(planItemInputRefs.current[index]));
|
||||||
}, [focusTextControl]);
|
}, [focusTextControl]);
|
||||||
|
|
||||||
|
const clearSpeechTimer = useCallback(() => {
|
||||||
|
if (speechTimerRef.current !== null) {
|
||||||
|
window.clearInterval(speechTimerRef.current);
|
||||||
|
speechTimerRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resetSpeechSampleBuffer = useCallback(() => {
|
||||||
|
speechSampleChunksRef.current = [];
|
||||||
|
speechSampleCountRef.current = 0;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const stopCurrentSpeechStream = useCallback(() => {
|
||||||
|
speechAudioProcessorRef.current?.disconnect();
|
||||||
|
speechAudioSourceRef.current?.disconnect();
|
||||||
|
speechAudioProcessorRef.current = null;
|
||||||
|
speechAudioSourceRef.current = null;
|
||||||
|
const audioContext = speechAudioContextRef.current;
|
||||||
|
speechAudioContextRef.current = null;
|
||||||
|
if (audioContext && audioContext.state !== "closed") {
|
||||||
|
void audioContext.close();
|
||||||
|
}
|
||||||
|
speechStreamRef.current?.getTracks().forEach((track) => track.stop());
|
||||||
|
speechStreamRef.current = null;
|
||||||
|
clearSpeechTimer();
|
||||||
|
}, [clearSpeechTimer]);
|
||||||
|
|
||||||
|
const writeReportSpeechText = useCallback((lineIndex: number, speechText: string, insertionRange?: ReportSpeechInsertionRange) => {
|
||||||
|
const item = reportLineItemsRef.current[lineIndex];
|
||||||
|
const text = speechText.trim();
|
||||||
|
if (!item || !text) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const textArea = reportLineTextareaRefs.current[lineIndex];
|
||||||
|
let currentEditorBody = textArea?.value ?? "";
|
||||||
|
if (!item.bizName) {
|
||||||
|
currentEditorBody = currentEditorBody || item.editorText || "";
|
||||||
|
} else {
|
||||||
|
currentEditorBody = currentEditorBody || (
|
||||||
|
item.editorText
|
||||||
|
? item.editorText.replace(/\r/g, "").split("\n").slice(1).join("\n")
|
||||||
|
: buildEditorTemplate(item.bizType, item.bizName).split("\n").slice(1).join("\n")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const nextEditorBody = insertReportSpeechText(currentEditorBody, text, insertionRange);
|
||||||
|
const nextEditorText = item.bizName
|
||||||
|
? `${buildEditorMentionLine(item.bizType, item.bizName)}\n${nextEditorBody}`
|
||||||
|
: nextEditorBody;
|
||||||
|
const nextItems = [...reportLineItemsRef.current];
|
||||||
|
nextItems[lineIndex] = { ...item, editorText: nextEditorText };
|
||||||
|
reportLineItemsRef.current = nextItems;
|
||||||
|
onReportLineChange(lineIndex, nextEditorText);
|
||||||
|
const nextCaretPosition = resolveInsertedSpeechCaretPosition(currentEditorBody, text, insertionRange);
|
||||||
|
reportLineSelectionRefs.current[lineIndex] = { start: nextCaretPosition, end: nextCaretPosition };
|
||||||
|
blurActiveTextControl();
|
||||||
|
}, [blurActiveTextControl, onReportLineChange]);
|
||||||
|
|
||||||
|
const startReportSpeechRecording = useCallback(async (lineIndex: number, insertionRange?: ReportSpeechInsertionRange) => {
|
||||||
|
const item = reportLineItemsRef.current[lineIndex];
|
||||||
|
if (!item || (!item.bizName && objectSelectionRequired)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
blurActiveTextControl();
|
||||||
|
activateReportLineEditor(lineIndex, { focus: false });
|
||||||
|
const AudioContextConstructor = getAudioContextConstructor();
|
||||||
|
if (!navigator.mediaDevices?.getUserMedia || !AudioContextConstructor) {
|
||||||
|
setReportSpeechState({
|
||||||
|
lineIndex,
|
||||||
|
targetField: "",
|
||||||
|
insertionStart: insertionRange?.start,
|
||||||
|
insertionEnd: insertionRange?.end,
|
||||||
|
status: "error",
|
||||||
|
error: "当前浏览器不支持语音录音,请使用新版 Chrome、Edge 或企业微信内置浏览器。",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
speechCancelledRef.current = false;
|
||||||
|
resetSpeechSampleBuffer();
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
const audioContext = new AudioContextConstructor();
|
||||||
|
if (audioContext.state === "suspended") {
|
||||||
|
await audioContext.resume();
|
||||||
|
}
|
||||||
|
const source = audioContext.createMediaStreamSource(stream);
|
||||||
|
const processor = audioContext.createScriptProcessor(4096, 1, 1);
|
||||||
|
processor.onaudioprocess = (event) => {
|
||||||
|
event.outputBuffer.getChannelData(0).fill(0);
|
||||||
|
if (speechCancelledRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const input = event.inputBuffer.getChannelData(0);
|
||||||
|
speechSampleChunksRef.current.push(new Float32Array(input));
|
||||||
|
speechSampleCountRef.current += input.length;
|
||||||
|
};
|
||||||
|
source.connect(processor);
|
||||||
|
processor.connect(audioContext.destination);
|
||||||
|
speechStreamRef.current = stream;
|
||||||
|
speechAudioContextRef.current = audioContext;
|
||||||
|
speechAudioSourceRef.current = source;
|
||||||
|
speechAudioProcessorRef.current = processor;
|
||||||
|
|
||||||
|
speechRecordingStartedAtRef.current = Date.now();
|
||||||
|
setSpeechElapsedSeconds(0);
|
||||||
|
speechTimerRef.current = window.setInterval(() => {
|
||||||
|
setSpeechElapsedSeconds((current) => current + 1);
|
||||||
|
}, 1000);
|
||||||
|
setReportSpeechState({
|
||||||
|
lineIndex,
|
||||||
|
targetField: "",
|
||||||
|
insertionStart: insertionRange?.start,
|
||||||
|
insertionEnd: insertionRange?.end,
|
||||||
|
status: "recording",
|
||||||
|
error: "",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
stopCurrentSpeechStream();
|
||||||
|
setReportSpeechState({
|
||||||
|
lineIndex,
|
||||||
|
targetField: "",
|
||||||
|
insertionStart: insertionRange?.start,
|
||||||
|
insertionEnd: insertionRange?.end,
|
||||||
|
status: "error",
|
||||||
|
error: error instanceof Error ? error.message : "无法访问麦克风,请检查浏览器权限。",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [activateReportLineEditor, blurActiveTextControl, objectSelectionRequired, resetSpeechSampleBuffer, stopCurrentSpeechStream]);
|
||||||
|
|
||||||
|
const stopReportSpeechRecording = useCallback(() => {
|
||||||
|
const currentState = reportSpeechState;
|
||||||
|
if (!currentState || currentState.status !== "recording") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const recordingDurationMs = Date.now() - speechRecordingStartedAtRef.current;
|
||||||
|
const audioContext = speechAudioContextRef.current;
|
||||||
|
const sampleRate = audioContext?.sampleRate || 16000;
|
||||||
|
const sampleCount = speechSampleCountRef.current;
|
||||||
|
const samples = sampleCount ? mergeFloat32Chunks(speechSampleChunksRef.current, sampleCount) : null;
|
||||||
|
stopCurrentSpeechStream();
|
||||||
|
resetSpeechSampleBuffer();
|
||||||
|
if (!samples || sampleCount / sampleRate * 1000 < REPORT_SPEECH_MIN_RECORDING_MS) {
|
||||||
|
setReportSpeechState({
|
||||||
|
lineIndex: currentState.lineIndex,
|
||||||
|
targetField: currentState.targetField,
|
||||||
|
status: "error",
|
||||||
|
error: recordingDurationMs < REPORT_SPEECH_MIN_RECORDING_MS
|
||||||
|
? "录音时间太短,请至少录制 1 秒后再识别。"
|
||||||
|
: "没有录到声音,请检查麦克风后重试。",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setReportSpeechState({
|
||||||
|
lineIndex: currentState.lineIndex,
|
||||||
|
targetField: currentState.targetField,
|
||||||
|
status: "transcribing",
|
||||||
|
error: "",
|
||||||
|
});
|
||||||
|
const file = buildReportSpeechWavFile(samples, sampleRate);
|
||||||
|
transcribeWorkReportSpeech(file)
|
||||||
|
.then((result) => {
|
||||||
|
const text = result.text?.trim() || "";
|
||||||
|
if (!text) {
|
||||||
|
setReportSpeechState({
|
||||||
|
lineIndex: currentState.lineIndex,
|
||||||
|
targetField: currentState.targetField,
|
||||||
|
status: "error",
|
||||||
|
error: "语音识别结果为空,请重新录音。",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
writeReportSpeechText(
|
||||||
|
currentState.lineIndex,
|
||||||
|
text,
|
||||||
|
currentState.insertionStart !== undefined
|
||||||
|
? {
|
||||||
|
start: currentState.insertionStart,
|
||||||
|
end: currentState.insertionEnd ?? currentState.insertionStart,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
setReportSpeechState(null);
|
||||||
|
setSpeechElapsedSeconds(0);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
setReportSpeechState({
|
||||||
|
lineIndex: currentState.lineIndex,
|
||||||
|
targetField: currentState.targetField,
|
||||||
|
status: "error",
|
||||||
|
error: error instanceof Error ? error.message : "语音识别失败,请重试。",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [reportSpeechState, resetSpeechSampleBuffer, stopCurrentSpeechStream, writeReportSpeechText]);
|
||||||
|
|
||||||
|
const handleReportSpeechToggle = useCallback((lineIndex: number) => {
|
||||||
|
const pendingInsertion = pendingReportSpeechInsertionRef.current?.lineIndex === lineIndex
|
||||||
|
? pendingReportSpeechInsertionRef.current
|
||||||
|
: undefined;
|
||||||
|
pendingReportSpeechInsertionRef.current = null;
|
||||||
|
const insertionRange = pendingInsertion ?? getReportSpeechInsertionRange(lineIndex);
|
||||||
|
blurActiveTextControl();
|
||||||
|
if (reportSpeechState?.lineIndex === lineIndex && reportSpeechState.status === "recording") {
|
||||||
|
stopReportSpeechRecording();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (reportSpeechState?.status === "recording" || reportSpeechState?.status === "transcribing") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void startReportSpeechRecording(lineIndex, insertionRange);
|
||||||
|
}, [blurActiveTextControl, getReportSpeechInsertionRange, reportSpeechState, startReportSpeechRecording, stopReportSpeechRecording]);
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
speechCancelledRef.current = true;
|
||||||
|
stopCurrentSpeechStream();
|
||||||
|
}, [stopCurrentSpeechStream]);
|
||||||
|
|
||||||
const handleAddReportLineAndFocus = useCallback(() => {
|
const handleAddReportLineAndFocus = useCallback(() => {
|
||||||
pendingNewReportLineFocusRef.current = reportForm.lineItems.length;
|
pendingNewReportLineFocusRef.current = reportForm.lineItems.length;
|
||||||
onAddReportLine();
|
onAddReportLine();
|
||||||
|
|
@ -4389,6 +4675,13 @@ function ReportPanel({
|
||||||
const isUploadingAttachment = uploadingReportAttachmentIndex === index;
|
const isUploadingAttachment = uploadingReportAttachmentIndex === index;
|
||||||
const attachmentProgress = uploadingReportAttachmentProgress[index] ?? 0;
|
const attachmentProgress = uploadingReportAttachmentProgress[index] ?? 0;
|
||||||
const attachmentUploadError = reportAttachmentUploadErrors[index];
|
const attachmentUploadError = reportAttachmentUploadErrors[index];
|
||||||
|
const speechLineState = reportSpeechState?.lineIndex === index ? reportSpeechState : null;
|
||||||
|
const speechInputBlocked = objectSelectionRequired && !item.bizName;
|
||||||
|
const isSpeechRecording = speechLineState?.status === "recording";
|
||||||
|
const isSpeechTranscribing = speechLineState?.status === "transcribing";
|
||||||
|
const isSpeechBusyOnAnotherLine = !!reportSpeechState
|
||||||
|
&& reportSpeechState.lineIndex !== index
|
||||||
|
&& (reportSpeechState.status === "recording" || reportSpeechState.status === "transcribing");
|
||||||
return (
|
return (
|
||||||
<div key={`report-line-${index}`} className="relative rounded-2xl border border-slate-100/90 bg-slate-50/30 p-3 dark:border-slate-800/70 dark:bg-slate-900/20">
|
<div key={`report-line-${index}`} className="relative rounded-2xl border border-slate-100/90 bg-slate-50/30 p-3 dark:border-slate-800/70 dark:bg-slate-900/20">
|
||||||
<span
|
<span
|
||||||
|
|
@ -4451,26 +4744,95 @@ function ReportPanel({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<textarea
|
<div className="relative">
|
||||||
rows={2}
|
<textarea
|
||||||
ref={(element) => {
|
rows={2}
|
||||||
reportLineTextareaRefs.current[index] = element;
|
ref={(element) => {
|
||||||
syncAutoHeightTextarea(element);
|
reportLineTextareaRefs.current[index] = element;
|
||||||
}}
|
syncAutoHeightTextarea(element);
|
||||||
value={item.bizName ? editorBodyText : item.editorText || ""}
|
}}
|
||||||
onKeyDown={(event) => onReportLineKeyDown(index, event)}
|
value={item.bizName ? editorBodyText : item.editorText || ""}
|
||||||
onChange={(event) => {
|
onKeyDown={(event) => onReportLineKeyDown(index, event)}
|
||||||
syncAutoHeightTextarea(event.currentTarget);
|
onChange={(event) => {
|
||||||
onReportLineChange(
|
syncAutoHeightTextarea(event.currentTarget);
|
||||||
index,
|
onReportLineChange(
|
||||||
item.bizName
|
index,
|
||||||
? `${objectLine}\n${event.target.value}`
|
item.bizName
|
||||||
: event.target.value,
|
? `${objectLine}\n${event.target.value}`
|
||||||
);
|
: event.target.value,
|
||||||
}}
|
);
|
||||||
placeholder="输入工作内容,或输入 # 选择对象生成字段。"
|
rememberReportLineSelection(index, event.currentTarget);
|
||||||
className="crm-input-box crm-input-text min-h-[88px] w-full resize-none overflow-hidden rounded-2xl border border-slate-200 bg-white leading-7 text-slate-900 outline-none transition-colors focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/60 dark:text-white"
|
}}
|
||||||
/>
|
onSelect={(event) => rememberReportLineSelection(index, event.currentTarget)}
|
||||||
|
onClick={(event) => rememberReportLineSelection(index, event.currentTarget)}
|
||||||
|
onKeyUp={(event) => rememberReportLineSelection(index, event.currentTarget)}
|
||||||
|
placeholder="输入工作内容,或输入 # 选择对象生成字段。"
|
||||||
|
className="crm-input-box crm-input-text min-h-[88px] w-full resize-none overflow-hidden rounded-2xl border border-slate-200 bg-white pb-11 pr-12 leading-7 text-slate-900 outline-none transition-colors focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/60 dark:text-white"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onPointerDown={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const insertionRange = getReportSpeechInsertionRange(index);
|
||||||
|
if (insertionRange) {
|
||||||
|
pendingReportSpeechInsertionRef.current = { lineIndex: index, ...insertionRange };
|
||||||
|
}
|
||||||
|
blurActiveTextControl();
|
||||||
|
}}
|
||||||
|
onClick={() => handleReportSpeechToggle(index)}
|
||||||
|
disabled={speechInputBlocked || isSpeechTranscribing || isSpeechBusyOnAnotherLine}
|
||||||
|
className={cn(
|
||||||
|
"absolute bottom-2.5 right-2.5 inline-flex h-8 w-8 items-center justify-center rounded-full border shadow-sm transition-colors disabled:cursor-not-allowed disabled:opacity-45",
|
||||||
|
isSpeechRecording
|
||||||
|
? "border-rose-200 bg-rose-50 text-rose-600 dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-300"
|
||||||
|
: "border-violet-200 bg-violet-50 text-violet-600 hover:bg-violet-100 dark:border-violet-500/30 dark:bg-violet-500/10 dark:text-violet-300 dark:hover:bg-violet-500/20",
|
||||||
|
)}
|
||||||
|
title={
|
||||||
|
speechInputBlocked
|
||||||
|
? "请先选择关联对象"
|
||||||
|
: isSpeechTranscribing
|
||||||
|
? "语音识别中"
|
||||||
|
: isSpeechBusyOnAnotherLine
|
||||||
|
? "其他行正在语音输入"
|
||||||
|
: isSpeechRecording
|
||||||
|
? "停止录音并识别"
|
||||||
|
: "语音输入到光标位置"
|
||||||
|
}
|
||||||
|
aria-label={
|
||||||
|
speechInputBlocked
|
||||||
|
? "请先选择关联对象"
|
||||||
|
: "语音输入到光标位置"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isSpeechTranscribing ? <RefreshCw className="h-4 w-4 animate-spin" /> : isSpeechRecording ? <Square className="h-3.5 w-3.5 fill-current" /> : <Mic className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{isSpeechRecording || isSpeechTranscribing || speechLineState?.status === "error" ? (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 rounded-xl px-3 py-2 text-xs font-medium",
|
||||||
|
isSpeechRecording
|
||||||
|
? "bg-rose-50 text-rose-600 dark:bg-rose-500/10 dark:text-rose-300"
|
||||||
|
: isSpeechTranscribing
|
||||||
|
? "bg-violet-50 text-violet-600 dark:bg-violet-500/10 dark:text-violet-300"
|
||||||
|
: "bg-amber-50 text-amber-700 dark:bg-amber-500/10 dark:text-amber-200",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isSpeechRecording ? (
|
||||||
|
<>
|
||||||
|
<span className="h-2 w-2 rounded-full bg-rose-500" />
|
||||||
|
<span>录音中 {formatDuration(speechElapsedSeconds)},再次点击麦克风停止并识别</span>
|
||||||
|
</>
|
||||||
|
) : isSpeechTranscribing ? (
|
||||||
|
<>
|
||||||
|
<RefreshCw className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
<span>正在识别,完成后会自动写入光标位置</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span>{speechLineState?.error || "语音识别失败,请重新录音。"}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{attachmentsVisible ? (
|
{attachmentsVisible ? (
|
||||||
<ReportAttachmentList
|
<ReportAttachmentList
|
||||||
attachments={attachments}
|
attachments={attachments}
|
||||||
|
|
@ -4883,6 +5245,122 @@ function getReportLineDisplayText(lineItem: WorkReportLineItem) {
|
||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type WindowWithWebkitAudioContext = Window & typeof globalThis & {
|
||||||
|
webkitAudioContext?: typeof AudioContext;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getAudioContextConstructor() {
|
||||||
|
return window.AudioContext || (window as WindowWithWebkitAudioContext).webkitAudioContext || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeFloat32Chunks(chunks: Float32Array[], totalLength: number) {
|
||||||
|
const merged = new Float32Array(totalLength);
|
||||||
|
let offset = 0;
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
merged.set(chunk, offset);
|
||||||
|
offset += chunk.length;
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildReportSpeechWavFile(samples: Float32Array, sampleRate: number) {
|
||||||
|
const wavBlob = encodeMonoPcm16Wav(samples, sampleRate);
|
||||||
|
return new File([wavBlob], `report-speech-${Date.now()}.wav`, { type: "audio/wav" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeMonoPcm16Wav(samples: Float32Array, sampleRate: number) {
|
||||||
|
const headerSize = 44;
|
||||||
|
const bytesPerSample = 2;
|
||||||
|
const buffer = new ArrayBuffer(headerSize + samples.length * bytesPerSample);
|
||||||
|
const view = new DataView(buffer);
|
||||||
|
writeAsciiString(view, 0, "RIFF");
|
||||||
|
view.setUint32(4, 36 + samples.length * bytesPerSample, true);
|
||||||
|
writeAsciiString(view, 8, "WAVE");
|
||||||
|
writeAsciiString(view, 12, "fmt ");
|
||||||
|
view.setUint32(16, 16, true);
|
||||||
|
view.setUint16(20, 1, true);
|
||||||
|
view.setUint16(22, 1, true);
|
||||||
|
view.setUint32(24, sampleRate, true);
|
||||||
|
view.setUint32(28, sampleRate * bytesPerSample, true);
|
||||||
|
view.setUint16(32, bytesPerSample, true);
|
||||||
|
view.setUint16(34, 8 * bytesPerSample, true);
|
||||||
|
writeAsciiString(view, 36, "data");
|
||||||
|
view.setUint32(40, samples.length * bytesPerSample, true);
|
||||||
|
|
||||||
|
let offset = headerSize;
|
||||||
|
for (let index = 0; index < samples.length; index += 1) {
|
||||||
|
const sample = Math.max(-1, Math.min(1, samples[index]));
|
||||||
|
view.setInt16(offset, sample < 0 ? sample * 0x8000 : sample * 0x7fff, true);
|
||||||
|
offset += bytesPerSample;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Blob([buffer], { type: "audio/wav" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeAsciiString(view: DataView, offset: number, value: string) {
|
||||||
|
for (let index = 0; index < value.length; index += 1) {
|
||||||
|
view.setUint8(offset + index, value.charCodeAt(index));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertReportSpeechText(currentText: string, speechText: string, insertionRange?: ReportSpeechInsertionRange) {
|
||||||
|
const normalizedSpeechText = speechText.replace(/\s+/g, " ").trim();
|
||||||
|
if (!normalizedSpeechText) {
|
||||||
|
return currentText;
|
||||||
|
}
|
||||||
|
const normalizedCurrentText = currentText.replace(/\r/g, "");
|
||||||
|
if (!normalizedCurrentText.trim()) {
|
||||||
|
return normalizedSpeechText;
|
||||||
|
}
|
||||||
|
const { start, end } = normalizeInsertionRange(insertionRange, normalizedCurrentText.length);
|
||||||
|
const beforeText = normalizedCurrentText.slice(0, start);
|
||||||
|
const afterText = normalizedCurrentText.slice(end);
|
||||||
|
const prefix = beforeText && !shouldJoinSpeechTextWithoutSpace(beforeText, normalizedSpeechText) ? " " : "";
|
||||||
|
const suffix = afterText && !shouldJoinSpeechTextWithoutSpace(normalizedSpeechText, afterText) ? " " : "";
|
||||||
|
return `${beforeText}${prefix}${normalizedSpeechText}${suffix}${afterText}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveInsertedSpeechCaretPosition(currentText: string, speechText: string, insertionRange?: ReportSpeechInsertionRange) {
|
||||||
|
const normalizedSpeechText = speechText.replace(/\s+/g, " ").trim();
|
||||||
|
const normalizedCurrentText = currentText.replace(/\r/g, "");
|
||||||
|
const { start, end } = normalizeInsertionRange(insertionRange, normalizedCurrentText.length);
|
||||||
|
const beforeText = normalizedCurrentText.slice(0, start);
|
||||||
|
const afterText = normalizedCurrentText.slice(end);
|
||||||
|
const prefix = beforeText && !shouldJoinSpeechTextWithoutSpace(beforeText, normalizedSpeechText) ? " " : "";
|
||||||
|
const suffix = afterText && !shouldJoinSpeechTextWithoutSpace(normalizedSpeechText, afterText) ? " " : "";
|
||||||
|
return beforeText.length + prefix.length + normalizedSpeechText.length + suffix.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeInsertionRange(insertionRange: ReportSpeechInsertionRange | undefined, textLength: number) {
|
||||||
|
if (!insertionRange) {
|
||||||
|
return { start: textLength, end: textLength };
|
||||||
|
}
|
||||||
|
const start = Math.max(0, Math.min(textLength, insertionRange.start));
|
||||||
|
const end = Math.max(start, Math.min(textLength, insertionRange.end));
|
||||||
|
return { start, end };
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldJoinSpeechTextWithoutSpace(currentText: string, speechText: string) {
|
||||||
|
const previousChar = currentText.slice(-1);
|
||||||
|
const nextChar = speechText.charAt(0);
|
||||||
|
if (
|
||||||
|
!previousChar
|
||||||
|
|| !nextChar
|
||||||
|
|| /\s/.test(previousChar)
|
||||||
|
|| /^[,。!?;:、,.!?;:)]/.test(nextChar)
|
||||||
|
|| /[,。!?;:、]$/.test(previousChar)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return /[\u4e00-\u9fff]/.test(previousChar) && /[\u4e00-\u9fff]/.test(nextChar);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(totalSeconds: number) {
|
||||||
|
const minutes = Math.floor(totalSeconds / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
function PhotoPreviewModal({
|
function PhotoPreviewModal({
|
||||||
url,
|
url,
|
||||||
alt,
|
alt,
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ export default defineConfig(({mode}) => {
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
https,
|
https,
|
||||||
|
allowedHosts: ['crmdev.oa.unissense.tech'],
|
||||||
// HMR is disabled in AI Studio via DISABLE_HMR env var.
|
// HMR is disabled in AI Studio via DISABLE_HMR env var.
|
||||||
// Do not modifyâfile watching is disabled to prevent flickering during agent edits.
|
// Do not modifyâfile watching is disabled to prevent flickering during agent edits.
|
||||||
hmr: process.env.DISABLE_HMR !== 'true',
|
hmr: process.env.DISABLE_HMR !== 'true',
|
||||||
|
|
@ -35,6 +36,8 @@ export default defineConfig(({mode}) => {
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:8080',
|
target: 'http://localhost:8080',
|
||||||
|
timeout: 360000,
|
||||||
|
proxyTimeout: 360000,
|
||||||
rewrite: (requestPath) =>
|
rewrite: (requestPath) =>
|
||||||
requestPath.startsWith('/api/sys')
|
requestPath.startsWith('/api/sys')
|
||||||
? requestPath.replace(/^\/api\/sys/, '/sys')
|
? requestPath.replace(/^\/api\/sys/, '/sys')
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>UnisBase - 智能会议系统</title>
|
<title>UnisBase - 智能会议系统</title>
|
||||||
<script type="module" crossorigin src="/assets/index-CmIS0HGH.js"></script>
|
<script type="module" crossorigin src="/assets/index-BBoOWaLk.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-CaWPk49l.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-CaWPk49l.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
import http from "@/api/http";
|
||||||
|
import type { SpeechRecognitionConfig } from "./types";
|
||||||
|
|
||||||
|
export async function getSpeechRecognitionConfig(tenantId?: number) {
|
||||||
|
const resp = await http.get("/sys/api/admin/speech-recognition-config", { params: { tenantId } });
|
||||||
|
return resp.data.data as SpeechRecognitionConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSpeechRecognitionConfig(payload: SpeechRecognitionConfig) {
|
||||||
|
const resp = await http.put("/sys/api/admin/speech-recognition-config", payload);
|
||||||
|
return resp.data.data as boolean;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export { default } from "./pages/speech-recognition-settings";
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
.speech-recognition-page {
|
||||||
|
max-width: 1120px;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speech-recognition-scroll {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding: 0 4px 32px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speech-recognition-alert {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speech-recognition-card {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speech-recognition-section {
|
||||||
|
padding-bottom: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speech-recognition-section:last-of-type {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speech-recognition-section__title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speech-recognition-tenant {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 0 12px;
|
||||||
|
background: #f7f9fc;
|
||||||
|
border: 1px solid #edf1f7;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speech-recognition-number {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speech-recognition-hint {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speech-recognition-advanced {
|
||||||
|
margin: -4px 0 20px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speech-recognition-advanced .ant-collapse-header {
|
||||||
|
padding: 0 0 16px !important;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speech-recognition-advanced .ant-collapse-content-box {
|
||||||
|
padding: 0 0 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speech-recognition-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.speech-recognition-page {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speech-recognition-actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,359 @@
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
App,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Col,
|
||||||
|
Collapse,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
InputNumber,
|
||||||
|
Row,
|
||||||
|
Select,
|
||||||
|
Skeleton,
|
||||||
|
Space,
|
||||||
|
Switch,
|
||||||
|
Typography
|
||||||
|
} from "antd";
|
||||||
|
import {
|
||||||
|
ApiOutlined,
|
||||||
|
AudioOutlined,
|
||||||
|
ClockCircleOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
SaveOutlined,
|
||||||
|
SecurityScanOutlined
|
||||||
|
} from "@ant-design/icons";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import PageHeader from "@/components/shared/PageHeader";
|
||||||
|
import { getSpeechRecognitionConfig, updateSpeechRecognitionConfig } from "@/features/speech-recognition/api";
|
||||||
|
import type { SpeechRecognitionConfig } from "@/features/speech-recognition/types";
|
||||||
|
import "./index.less";
|
||||||
|
|
||||||
|
const { Text, Paragraph } = Typography;
|
||||||
|
const { Panel } = Collapse;
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG: SpeechRecognitionConfig = {
|
||||||
|
enabled: true,
|
||||||
|
baseUrl: "http://10.211.147.11:17003/",
|
||||||
|
transcribePath: "/v1/audio/transcriptions",
|
||||||
|
taskStatusPath: "/v1/tasks/{task_id}",
|
||||||
|
fileFieldName: "file",
|
||||||
|
language: "",
|
||||||
|
responseFormat: "json",
|
||||||
|
model: "",
|
||||||
|
prompt: "",
|
||||||
|
hotwords: "",
|
||||||
|
timestampGranularities: "",
|
||||||
|
enableSpeakerDiarization: undefined,
|
||||||
|
enableSpeakerIdentification: undefined,
|
||||||
|
enableTextCleanup: undefined,
|
||||||
|
temperature: undefined,
|
||||||
|
apiKey: "",
|
||||||
|
apiKeyHeader: "X-API-Key",
|
||||||
|
connectTimeoutSeconds: 10,
|
||||||
|
readTimeoutSeconds: 300,
|
||||||
|
taskTimeoutSeconds: 300,
|
||||||
|
taskPollIntervalMillis: 1500
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveActiveTenantId() {
|
||||||
|
const rawTenantId = localStorage.getItem("activeTenantId");
|
||||||
|
if (!rawTenantId) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const tenantId = Number(rawTenantId);
|
||||||
|
return Number.isFinite(tenantId) ? tenantId : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePayload(values: SpeechRecognitionConfig, tenantId?: number): SpeechRecognitionConfig {
|
||||||
|
return {
|
||||||
|
...DEFAULT_CONFIG,
|
||||||
|
...values,
|
||||||
|
tenantId,
|
||||||
|
baseUrl: values.baseUrl?.trim() || "",
|
||||||
|
transcribePath: values.transcribePath?.trim() || DEFAULT_CONFIG.transcribePath,
|
||||||
|
taskStatusPath: values.taskStatusPath?.trim() || DEFAULT_CONFIG.taskStatusPath,
|
||||||
|
fileFieldName: "file",
|
||||||
|
language: values.language?.trim() || "",
|
||||||
|
responseFormat: values.responseFormat || DEFAULT_CONFIG.responseFormat,
|
||||||
|
model: values.model?.trim() || "",
|
||||||
|
prompt: values.prompt?.trim() || "",
|
||||||
|
hotwords: values.hotwords?.trim() || "",
|
||||||
|
timestampGranularities: values.timestampGranularities?.trim() || "",
|
||||||
|
apiKey: values.apiKey?.trim() || "",
|
||||||
|
apiKeyHeader: values.apiKeyHeader?.trim() || DEFAULT_CONFIG.apiKeyHeader
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SpeechRecognitionSettings() {
|
||||||
|
const { message } = App.useApp();
|
||||||
|
const [form] = Form.useForm<SpeechRecognitionConfig>();
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const tenantId = useMemo(() => resolveActiveTenantId(), []);
|
||||||
|
|
||||||
|
const loadConfig = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await getSpeechRecognitionConfig(tenantId);
|
||||||
|
form.setFieldsValue({ ...DEFAULT_CONFIG, ...data });
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.message || "语音识别配置加载失败");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [form, message, tenantId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadConfig();
|
||||||
|
}, [loadConfig]);
|
||||||
|
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const values = await form.validateFields();
|
||||||
|
const payload = normalizePayload(values, tenantId);
|
||||||
|
setSaving(true);
|
||||||
|
await updateSpeechRecognitionConfig(payload);
|
||||||
|
form.setFieldsValue(payload);
|
||||||
|
message.success("语音识别配置已保存");
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.errorFields) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
message.error(error.message || "保存失败");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}, [form, message, tenantId]);
|
||||||
|
|
||||||
|
const handleResetDefaults = useCallback(() => {
|
||||||
|
form.setFieldsValue({ ...DEFAULT_CONFIG, tenantId });
|
||||||
|
}, [form, tenantId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app-page speech-recognition-page">
|
||||||
|
<PageHeader
|
||||||
|
title="语音识别配置"
|
||||||
|
subtitle="配置日报音频转写服务地址、鉴权信息和基础转写行为。"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="speech-recognition-scroll">
|
||||||
|
<Alert
|
||||||
|
className="speech-recognition-alert"
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
message="保存后立即影响当前租户的日报语音转文字功能;上传字段固定为 OpenAI Audio API 兼容的 file。"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Card className="app-page__content-card speech-recognition-card" styles={{ body: { padding: 24 } }}>
|
||||||
|
{loading ? (
|
||||||
|
<Skeleton active paragraph={{ rows: 10 }} />
|
||||||
|
) : (
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
initialValues={DEFAULT_CONFIG}
|
||||||
|
className="speech-recognition-form"
|
||||||
|
>
|
||||||
|
<div className="speech-recognition-section">
|
||||||
|
<div className="speech-recognition-section__title">
|
||||||
|
<AudioOutlined />
|
||||||
|
<span>功能开关</span>
|
||||||
|
</div>
|
||||||
|
<Row gutter={[24, 8]}>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Form.Item name="enabled" label="启用语音识别" valuePropName="checked">
|
||||||
|
<Switch checkedChildren="启用" unCheckedChildren="停用" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<div className="speech-recognition-tenant">
|
||||||
|
<Text type="secondary">当前配置租户</Text>
|
||||||
|
<Text strong>{tenantId ?? 0}</Text>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="speech-recognition-section">
|
||||||
|
<div className="speech-recognition-section__title">
|
||||||
|
<ApiOutlined />
|
||||||
|
<span>服务接口</span>
|
||||||
|
</div>
|
||||||
|
<Row gutter={[24, 8]}>
|
||||||
|
<Col xs={24}>
|
||||||
|
<Form.Item
|
||||||
|
name="baseUrl"
|
||||||
|
label="服务 Base URL"
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: "请输入语音识别服务地址" },
|
||||||
|
{ type: "url", warningOnly: true, message: "建议填写完整 http/https 地址" }
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input placeholder="http://10.211.147.11:17003/" allowClear />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Form.Item name="transcribePath" label="转写接口路径" rules={[{ required: true, message: "请输入转写接口路径" }]}>
|
||||||
|
<Input placeholder="/v1/audio/transcriptions" allowClear />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Form.Item name="taskStatusPath" label="任务状态路径" rules={[{ required: true, message: "请输入任务状态路径" }]}>
|
||||||
|
<Input placeholder="/v1/tasks/{task_id}" allowClear />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Form.Item name="language" label="识别语言">
|
||||||
|
<Input placeholder="留空则不传 language 参数" allowClear />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Form.Item name="responseFormat" label="输出格式" rules={[{ required: true, message: "请选择输出格式" }]}>
|
||||||
|
<Select
|
||||||
|
options={[
|
||||||
|
{ label: "json", value: "json" },
|
||||||
|
{ label: "text", value: "text" },
|
||||||
|
{ label: "verbose_json", value: "verbose_json" },
|
||||||
|
{ label: "srt", value: "srt" },
|
||||||
|
{ label: "vtt", value: "vtt" }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="speech-recognition-section">
|
||||||
|
<div className="speech-recognition-section__title">
|
||||||
|
<AudioOutlined />
|
||||||
|
<span>转写效果</span>
|
||||||
|
</div>
|
||||||
|
<Row gutter={[24, 8]}>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Form.Item name="enableSpeakerDiarization" label="说话人分离" valuePropName="checked">
|
||||||
|
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24}>
|
||||||
|
<Paragraph type="secondary" className="speech-recognition-hint">
|
||||||
|
多人录音建议开启;单人日报录音可以关闭。
|
||||||
|
</Paragraph>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="speech-recognition-section">
|
||||||
|
<div className="speech-recognition-section__title">
|
||||||
|
<SecurityScanOutlined />
|
||||||
|
<span>鉴权</span>
|
||||||
|
</div>
|
||||||
|
<Row gutter={[24, 8]}>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Form.Item name="apiKeyHeader" label="API Key 请求头" rules={[{ required: true, message: "请输入请求头名称" }]}>
|
||||||
|
<Input placeholder="X-API-Key" allowClear />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Form.Item name="apiKey" label="API Key">
|
||||||
|
<Input.Password placeholder="留空则不发送鉴权头" autoComplete="new-password" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="speech-recognition-section">
|
||||||
|
<div className="speech-recognition-section__title">
|
||||||
|
<ClockCircleOutlined />
|
||||||
|
<span>超时与轮询</span>
|
||||||
|
</div>
|
||||||
|
<Row gutter={[24, 8]}>
|
||||||
|
<Col xs={24} md={12} lg={6}>
|
||||||
|
<Form.Item name="connectTimeoutSeconds" label="连接超时(秒)" rules={[{ required: true }]}>
|
||||||
|
<InputNumber min={1} max={120} precision={0} className="speech-recognition-number" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={12} lg={6}>
|
||||||
|
<Form.Item name="readTimeoutSeconds" label="读取超时(秒)" rules={[{ required: true }]}>
|
||||||
|
<InputNumber min={1} max={1800} precision={0} className="speech-recognition-number" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={12} lg={6}>
|
||||||
|
<Form.Item name="taskTimeoutSeconds" label="任务超时(秒)" rules={[{ required: true }]}>
|
||||||
|
<InputNumber min={1} max={1800} precision={0} className="speech-recognition-number" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={12} lg={6}>
|
||||||
|
<Form.Item name="taskPollIntervalMillis" label="轮询间隔(毫秒)" rules={[{ required: true }]}>
|
||||||
|
<InputNumber min={300} max={60000} step={100} precision={0} className="speech-recognition-number" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Paragraph type="secondary" className="speech-recognition-hint">
|
||||||
|
任务状态路径支持 <Text code>{"{task_id}"}</Text> 或 <Text code>{"{taskId}"}</Text> 占位符。
|
||||||
|
</Paragraph>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Collapse ghost className="speech-recognition-advanced">
|
||||||
|
<Panel header="高级参数" key="advanced">
|
||||||
|
<Row gutter={[24, 8]}>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Form.Item name="model" label="模型">
|
||||||
|
<Input placeholder="留空使用服务端默认模型" allowClear />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Form.Item name="enableSpeakerIdentification" label="说话人识别" valuePropName="checked">
|
||||||
|
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Form.Item name="enableTextCleanup" label="文本清理" valuePropName="checked">
|
||||||
|
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Form.Item name="hotwords" label="热词">
|
||||||
|
<Input placeholder="热词1 权重1 热词2 权重2" allowClear />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Form.Item name="prompt" label="提示词">
|
||||||
|
<Input placeholder="可作为热词提示的兼容入口" allowClear />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Form.Item name="timestampGranularities" label="时间戳粒度">
|
||||||
|
<Input placeholder="例如 segment 或 word" allowClear />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Form.Item name="temperature" label="Temperature">
|
||||||
|
<InputNumber min={0} max={2} step={0.1} className="speech-recognition-number" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Panel>
|
||||||
|
</Collapse>
|
||||||
|
|
||||||
|
<div className="speech-recognition-actions">
|
||||||
|
<Space wrap>
|
||||||
|
<Button icon={<ReloadOutlined />} onClick={loadConfig}>
|
||||||
|
重新加载
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleResetDefaults}>
|
||||||
|
填入默认值
|
||||||
|
</Button>
|
||||||
|
<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={handleSave}>
|
||||||
|
保存配置
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
export interface SpeechRecognitionConfig {
|
||||||
|
tenantId?: number;
|
||||||
|
enabled: boolean;
|
||||||
|
baseUrl: string;
|
||||||
|
transcribePath: string;
|
||||||
|
taskStatusPath: string;
|
||||||
|
fileFieldName: string;
|
||||||
|
language?: string;
|
||||||
|
responseFormat: string;
|
||||||
|
model?: string;
|
||||||
|
prompt?: string;
|
||||||
|
hotwords?: string;
|
||||||
|
timestampGranularities?: string;
|
||||||
|
enableSpeakerDiarization?: boolean;
|
||||||
|
enableSpeakerIdentification?: boolean;
|
||||||
|
enableTextCleanup?: boolean;
|
||||||
|
temperature?: number;
|
||||||
|
apiKey?: string;
|
||||||
|
apiKeyHeader: string;
|
||||||
|
connectTimeoutSeconds: number;
|
||||||
|
readTimeoutSeconds: number;
|
||||||
|
taskTimeoutSeconds: number;
|
||||||
|
taskPollIntervalMillis: number;
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,7 @@ const Dictionaries = lazy(() => import("@/pages/system/dictionaries"));
|
||||||
const Logs = lazy(() => import("@/pages/system/logs"));
|
const Logs = lazy(() => import("@/pages/system/logs"));
|
||||||
const UserDataScope = lazy(() => import("@/pages/system/user-data-scope"));
|
const UserDataScope = lazy(() => import("@/pages/system/user-data-scope"));
|
||||||
const ReportReminderSettings = lazy(() => import("@/features/report-reminder/pages/report-reminder-settings"));
|
const ReportReminderSettings = lazy(() => import("@/features/report-reminder/pages/report-reminder-settings"));
|
||||||
|
const SpeechRecognitionSettings = lazy(() => import("@/features/speech-recognition/pages/speech-recognition-settings"));
|
||||||
const DashboardAnalyticsSettings = lazy(() => import("@/features/dashboard-analytics/pages/dashboard-analytics-settings"));
|
const DashboardAnalyticsSettings = lazy(() => import("@/features/dashboard-analytics/pages/dashboard-analytics-settings"));
|
||||||
const OwnerTransfer = lazy(() => import("@/features/owner-transfer/pages/owner-transfer"));
|
const OwnerTransfer = lazy(() => import("@/features/owner-transfer/pages/owner-transfer"));
|
||||||
const Devices = lazy(() => import("@/pages/devices"));
|
const Devices = lazy(() => import("@/pages/devices"));
|
||||||
|
|
@ -48,6 +49,7 @@ export const menuRoutes: MenuRoute[] = [
|
||||||
{ path: "/data-scope-users", label: "数据授权管理", element: <LazyPage><UserDataScope /></LazyPage>, perm: "menu:data-scope-users" },
|
{ path: "/data-scope-users", label: "数据授权管理", element: <LazyPage><UserDataScope /></LazyPage>, perm: "menu:data-scope-users" },
|
||||||
{ path: "/dashboard-analytics-settings", label: "首页经营分析配置", element: <LazyPage><DashboardAnalyticsSettings /></LazyPage>, perm: "menu:dashboard-analytics-settings" },
|
{ path: "/dashboard-analytics-settings", label: "首页经营分析配置", element: <LazyPage><DashboardAnalyticsSettings /></LazyPage>, perm: "menu:dashboard-analytics-settings" },
|
||||||
{ path: "/report-reminder-settings", label: "日报提醒设置", element: <LazyPage><ReportReminderSettings /></LazyPage>, perm: "menu:report-reminder-settings" },
|
{ path: "/report-reminder-settings", label: "日报提醒设置", element: <LazyPage><ReportReminderSettings /></LazyPage>, perm: "menu:report-reminder-settings" },
|
||||||
|
{ path: "/speech-recognition-settings", label: "语音识别配置", element: <LazyPage><SpeechRecognitionSettings /></LazyPage>, perm: "menu:speech-recognition-settings" },
|
||||||
{ path: "/owner-transfer", label: "归属人转移", element: <LazyPage><OwnerTransfer /></LazyPage>, perm: "menu:owner-transfer" },
|
{ path: "/owner-transfer", label: "归属人转移", element: <LazyPage><OwnerTransfer /></LazyPage>, perm: "menu:owner-transfer" },
|
||||||
{ path: "/devices", label: "设备管理", element: <LazyPage><Devices /></LazyPage>, perm: "menu:devices" },
|
{ path: "/devices", label: "设备管理", element: <LazyPage><Devices /></LazyPage>, perm: "menu:devices" },
|
||||||
{ path: "/user-roles", label: "用户角色绑定", element: <LazyPage><UserRoleBinding /></LazyPage>, perm: "menu:user-roles" },
|
{ path: "/user-roles", label: "用户角色绑定", element: <LazyPage><UserRoleBinding /></LazyPage>, perm: "menu:user-roles" },
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,209 @@
|
||||||
|
begin;
|
||||||
|
|
||||||
|
set search_path to public;
|
||||||
|
|
||||||
|
create table if not exists speech_recognition_config (
|
||||||
|
id bigint generated by default as identity primary key,
|
||||||
|
tenant_id bigint not null,
|
||||||
|
enabled boolean not null default true,
|
||||||
|
base_url varchar(512),
|
||||||
|
transcribe_path varchar(255) not null default '/v1/audio/transcriptions',
|
||||||
|
task_status_path varchar(255) not null default '/v1/tasks/{task_id}',
|
||||||
|
file_field_name varchar(64) not null default 'file',
|
||||||
|
language varchar(32),
|
||||||
|
response_format varchar(32) not null default 'json',
|
||||||
|
model varchar(128),
|
||||||
|
prompt text,
|
||||||
|
hotwords text,
|
||||||
|
timestamp_granularities varchar(128),
|
||||||
|
enable_speaker_diarization boolean,
|
||||||
|
enable_speaker_identification boolean,
|
||||||
|
enable_text_cleanup boolean,
|
||||||
|
temperature double precision,
|
||||||
|
api_key varchar(512),
|
||||||
|
api_key_header varchar(128) not null default 'X-API-Key',
|
||||||
|
connect_timeout_seconds integer not null default 10,
|
||||||
|
read_timeout_seconds integer not null default 300,
|
||||||
|
task_timeout_seconds integer not null default 300,
|
||||||
|
task_poll_interval_millis integer not null default 1500,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now(),
|
||||||
|
constraint uk_speech_recognition_config_tenant unique (tenant_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
alter table if exists speech_recognition_config add column if not exists response_format varchar(32) not null default 'json';
|
||||||
|
alter table if exists speech_recognition_config add column if not exists model varchar(128);
|
||||||
|
alter table if exists speech_recognition_config add column if not exists prompt text;
|
||||||
|
alter table if exists speech_recognition_config add column if not exists hotwords text;
|
||||||
|
alter table if exists speech_recognition_config add column if not exists timestamp_granularities varchar(128);
|
||||||
|
alter table if exists speech_recognition_config add column if not exists enable_speaker_diarization boolean;
|
||||||
|
alter table if exists speech_recognition_config add column if not exists enable_speaker_identification boolean;
|
||||||
|
alter table if exists speech_recognition_config add column if not exists enable_text_cleanup boolean;
|
||||||
|
alter table if exists speech_recognition_config add column if not exists temperature double precision;
|
||||||
|
|
||||||
|
do $$
|
||||||
|
declare
|
||||||
|
v_system_parent_id bigint;
|
||||||
|
v_menu_perm_id bigint;
|
||||||
|
v_view_perm_id bigint;
|
||||||
|
v_update_perm_id bigint;
|
||||||
|
begin
|
||||||
|
perform setval('sys_permission_perm_id_seq', coalesce((select max(perm_id) from sys_permission), 0) + 1, false);
|
||||||
|
perform setval('sys_role_permission_id_seq', coalesce((select max(id) from sys_role_permission), 0) + 1, false);
|
||||||
|
|
||||||
|
select perm_id
|
||||||
|
into v_system_parent_id
|
||||||
|
from sys_permission
|
||||||
|
where code = 'system'
|
||||||
|
and coalesce(is_deleted, 0) = 0
|
||||||
|
order by perm_id
|
||||||
|
limit 1;
|
||||||
|
|
||||||
|
if v_system_parent_id is null then
|
||||||
|
insert into sys_permission (
|
||||||
|
parent_id, name, code, perm_type, level, path, component, icon,
|
||||||
|
sort_order, is_visible, status, description, meta, is_deleted, created_at, updated_at
|
||||||
|
) values (
|
||||||
|
null, '系统管理', 'system', 'directory', 1, null, null, 'SettingOutlined',
|
||||||
|
110, 1, 1, '系统管理目录', '{}'::jsonb, 0, now(), now()
|
||||||
|
)
|
||||||
|
returning perm_id into v_system_parent_id;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select perm_id
|
||||||
|
into v_menu_perm_id
|
||||||
|
from sys_permission
|
||||||
|
where code = 'menu:speech-recognition-settings'
|
||||||
|
order by perm_id
|
||||||
|
limit 1;
|
||||||
|
|
||||||
|
if v_menu_perm_id is null then
|
||||||
|
insert into sys_permission (
|
||||||
|
parent_id, name, code, perm_type, level, path, component, icon,
|
||||||
|
sort_order, is_visible, status, description, meta, is_deleted, created_at, updated_at
|
||||||
|
) values (
|
||||||
|
v_system_parent_id, '语音识别配置', 'menu:speech-recognition-settings', 'menu', 2,
|
||||||
|
'/speech-recognition-settings', null, 'AudioOutlined', 8, 1, 1,
|
||||||
|
'日报语音转文字服务配置页面', jsonb_build_object('tenantScoped', true), 0, now(), now()
|
||||||
|
)
|
||||||
|
returning perm_id into v_menu_perm_id;
|
||||||
|
else
|
||||||
|
update sys_permission
|
||||||
|
set parent_id = v_system_parent_id,
|
||||||
|
name = '语音识别配置',
|
||||||
|
perm_type = 'menu',
|
||||||
|
level = 2,
|
||||||
|
path = '/speech-recognition-settings',
|
||||||
|
component = null,
|
||||||
|
icon = 'AudioOutlined',
|
||||||
|
sort_order = 8,
|
||||||
|
is_visible = 1,
|
||||||
|
status = 1,
|
||||||
|
description = '日报语音转文字服务配置页面',
|
||||||
|
meta = jsonb_build_object('tenantScoped', true),
|
||||||
|
is_deleted = 0,
|
||||||
|
updated_at = now()
|
||||||
|
where perm_id = v_menu_perm_id;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select perm_id
|
||||||
|
into v_view_perm_id
|
||||||
|
from sys_permission
|
||||||
|
where code = 'speech_recognition_config:view'
|
||||||
|
order by perm_id
|
||||||
|
limit 1;
|
||||||
|
|
||||||
|
if v_view_perm_id is null then
|
||||||
|
insert into sys_permission (
|
||||||
|
parent_id, name, code, perm_type, level, path, component, icon,
|
||||||
|
sort_order, is_visible, status, description, meta, is_deleted, created_at, updated_at
|
||||||
|
) values (
|
||||||
|
v_menu_perm_id, '查看语音识别配置', 'speech_recognition_config:view', 'button', 3,
|
||||||
|
null, null, null, 1, 1, 1, '查看日报语音转文字服务配置', '{}'::jsonb, 0, now(), now()
|
||||||
|
)
|
||||||
|
returning perm_id into v_view_perm_id;
|
||||||
|
else
|
||||||
|
update sys_permission
|
||||||
|
set parent_id = v_menu_perm_id,
|
||||||
|
name = '查看语音识别配置',
|
||||||
|
perm_type = 'button',
|
||||||
|
level = 3,
|
||||||
|
sort_order = 1,
|
||||||
|
is_visible = 1,
|
||||||
|
status = 1,
|
||||||
|
description = '查看日报语音转文字服务配置',
|
||||||
|
meta = '{}'::jsonb,
|
||||||
|
is_deleted = 0,
|
||||||
|
updated_at = now()
|
||||||
|
where perm_id = v_view_perm_id;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select perm_id
|
||||||
|
into v_update_perm_id
|
||||||
|
from sys_permission
|
||||||
|
where code = 'speech_recognition_config:update'
|
||||||
|
order by perm_id
|
||||||
|
limit 1;
|
||||||
|
|
||||||
|
if v_update_perm_id is null then
|
||||||
|
insert into sys_permission (
|
||||||
|
parent_id, name, code, perm_type, level, path, component, icon,
|
||||||
|
sort_order, is_visible, status, description, meta, is_deleted, created_at, updated_at
|
||||||
|
) values (
|
||||||
|
v_menu_perm_id, '修改语音识别配置', 'speech_recognition_config:update', 'button', 3,
|
||||||
|
null, null, null, 2, 1, 1, '保存日报语音转文字服务配置', '{}'::jsonb, 0, now(), now()
|
||||||
|
)
|
||||||
|
returning perm_id into v_update_perm_id;
|
||||||
|
else
|
||||||
|
update sys_permission
|
||||||
|
set parent_id = v_menu_perm_id,
|
||||||
|
name = '修改语音识别配置',
|
||||||
|
perm_type = 'button',
|
||||||
|
level = 3,
|
||||||
|
sort_order = 2,
|
||||||
|
is_visible = 1,
|
||||||
|
status = 1,
|
||||||
|
description = '保存日报语音转文字服务配置',
|
||||||
|
meta = '{}'::jsonb,
|
||||||
|
is_deleted = 0,
|
||||||
|
updated_at = now()
|
||||||
|
where perm_id = v_update_perm_id;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
insert into sys_role_permission (role_id, perm_id, is_deleted, created_at, updated_at)
|
||||||
|
select r.role_id, p.perm_id, 0, now(), now()
|
||||||
|
from sys_role r
|
||||||
|
cross join (
|
||||||
|
select unnest(array[v_menu_perm_id, v_view_perm_id, v_update_perm_id]) as perm_id
|
||||||
|
) p
|
||||||
|
where coalesce(r.is_deleted, 0) = 0
|
||||||
|
and (
|
||||||
|
r.role_code in ('TENANT_ADMIN', 'ADMIN', 'SYS_ADMIN', 'PLATFORM_ADMIN', 'SUPER_ADMIN')
|
||||||
|
or r.role_name ilike '%管理员%'
|
||||||
|
or r.role_name ilike '%admin%'
|
||||||
|
)
|
||||||
|
and p.perm_id is not null
|
||||||
|
and not exists (
|
||||||
|
select 1
|
||||||
|
from sys_role_permission rp
|
||||||
|
where rp.role_id = r.role_id
|
||||||
|
and rp.perm_id = p.perm_id
|
||||||
|
);
|
||||||
|
|
||||||
|
update sys_role_permission
|
||||||
|
set is_deleted = 0,
|
||||||
|
updated_at = now()
|
||||||
|
where perm_id in (v_menu_perm_id, v_view_perm_id, v_update_perm_id)
|
||||||
|
and role_id in (
|
||||||
|
select r.role_id
|
||||||
|
from sys_role r
|
||||||
|
where coalesce(r.is_deleted, 0) = 0
|
||||||
|
and (
|
||||||
|
r.role_code in ('TENANT_ADMIN', 'ADMIN', 'SYS_ADMIN', 'PLATFORM_ADMIN', 'SUPER_ADMIN')
|
||||||
|
or r.role_name ilike '%管理员%'
|
||||||
|
or r.role_name ilike '%admin%'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
end $$;
|
||||||
|
|
||||||
|
commit;
|
||||||
Loading…
Reference in New Issue