语音服务
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.WorkReportAttachmentDTO;
|
||||
import com.unis.crm.dto.work.WorkReportToUserDTO;
|
||||
import com.unis.crm.dto.work.WorkSpeechTranscriptionDTO;
|
||||
import com.unis.crm.service.WorkService;
|
||||
import com.unisbase.common.annotation.Log;
|
||||
import jakarta.validation.Valid;
|
||||
|
|
@ -174,6 +175,14 @@ public class WorkController {
|
|||
.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")
|
||||
@Log(type = "工作管理", value = "提交日报")
|
||||
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.WorkReportAttachmentDTO;
|
||||
import com.unis.crm.dto.work.WorkReportToUserDTO;
|
||||
import com.unis.crm.dto.work.WorkSpeechTranscriptionDTO;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
|
@ -59,4 +60,6 @@ public interface WorkService {
|
|||
WorkReportAttachmentDTO uploadReportAttachment(Long userId, MultipartFile file);
|
||||
|
||||
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.unis.crm.common.BusinessException;
|
||||
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.CreateWorkDailyReportRequest;
|
||||
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.WorkReportAttachmentDTO;
|
||||
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.ProfileMapper;
|
||||
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.FileStorageService;
|
||||
import com.unis.crm.service.ReportReminderService;
|
||||
import com.unis.crm.service.SpeechRecognitionConfigService;
|
||||
import com.unis.crm.service.WorkService;
|
||||
import com.unisbase.service.SysPermissionService;
|
||||
import com.unisbase.spi.UnisBaseTenantProvider;
|
||||
|
|
@ -36,8 +39,10 @@ import java.math.BigDecimal;
|
|||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpConnectTimeoutException;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.net.http.HttpTimeoutException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDate;
|
||||
|
|
@ -52,6 +57,7 @@ import java.util.Comparator;
|
|||
import java.util.LinkedHashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
|
@ -59,12 +65,22 @@ import java.util.concurrent.ConcurrentHashMap;
|
|||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
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.util.LinkedMultiValueMap;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.transaction.support.TransactionSynchronization;
|
||||
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 com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
|
@ -114,6 +130,7 @@ public class WorkServiceImpl implements WorkService {
|
|||
private final CrmDataVisibilityService crmDataVisibilityService;
|
||||
private final ReportReminderService reportReminderService;
|
||||
private final WorkReportProperties workReportProperties;
|
||||
private final SpeechRecognitionConfigService speechRecognitionConfigService;
|
||||
private final SysPermissionService sysPermissionService;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final FileStorageService fileStorageService;
|
||||
|
|
@ -126,6 +143,7 @@ public class WorkServiceImpl implements WorkService {
|
|||
ProfileMapper profileMapper,
|
||||
ReportReminderService reportReminderService,
|
||||
WorkReportProperties workReportProperties,
|
||||
SpeechRecognitionConfigService speechRecognitionConfigService,
|
||||
ObjectMapper objectMapper,
|
||||
UnisBaseTenantProvider tenantProvider,
|
||||
CrmDataVisibilityService crmDataVisibilityService,
|
||||
|
|
@ -138,6 +156,7 @@ public class WorkServiceImpl implements WorkService {
|
|||
this.profileMapper = profileMapper;
|
||||
this.reportReminderService = reportReminderService;
|
||||
this.workReportProperties = workReportProperties == null ? new WorkReportProperties() : workReportProperties;
|
||||
this.speechRecognitionConfigService = speechRecognitionConfigService;
|
||||
this.objectMapper = objectMapper;
|
||||
this.tenantProvider = tenantProvider;
|
||||
this.crmDataVisibilityService = crmDataVisibilityService;
|
||||
|
|
@ -493,6 +512,429 @@ public class WorkServiceImpl implements WorkService {
|
|||
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) {
|
||||
if (userId == null || userId <= 0) {
|
||||
throw new BusinessException("未获取到当前登录用户");
|
||||
|
|
@ -2454,6 +2896,11 @@ public class WorkServiceImpl implements WorkService {
|
|||
return null;
|
||||
}
|
||||
|
||||
private String defaultIfBlank(String value, String defaultValue) {
|
||||
String normalized = normalizeOptionalText(value);
|
||||
return normalized == null ? defaultValue : normalized;
|
||||
}
|
||||
|
||||
private String normalizeBizType(String value) {
|
||||
String normalized = normalizeOptionalText(value);
|
||||
if (normalized == null) {
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ spring:
|
|||
max-file-size: 500MB
|
||||
max-request-size: 600MB
|
||||
datasource:
|
||||
url: jdbc:postgresql://192.168.124.202:5432/unis_crm
|
||||
url: jdbc:postgresql://192.168.124.202:5432/unis_crm_dev
|
||||
username: postgres
|
||||
password: unis@123
|
||||
driver-class-name: org.postgresql.Driver
|
||||
|
|
@ -26,7 +26,7 @@ spring:
|
|||
host: 192.168.124.202
|
||||
port: 6379
|
||||
password: zghz@123
|
||||
database: 12
|
||||
database: 10
|
||||
|
||||
mybatis-plus:
|
||||
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.CrmDataVisibilityService.DataVisibility;
|
||||
import com.unis.crm.service.ReportReminderService;
|
||||
import com.unis.crm.service.SpeechRecognitionConfigService;
|
||||
import com.unisbase.service.SysPermissionService;
|
||||
import com.unisbase.spi.UnisBaseTenantProvider;
|
||||
import java.util.List;
|
||||
|
|
@ -61,6 +62,9 @@ class WorkServiceImplTest {
|
|||
@Mock
|
||||
private ReportReminderService reportReminderService;
|
||||
|
||||
@Mock
|
||||
private SpeechRecognitionConfigService speechRecognitionConfigService;
|
||||
|
||||
@Mock
|
||||
private UnisBaseTenantProvider tenantProvider;
|
||||
|
||||
|
|
@ -89,6 +93,7 @@ class WorkServiceImplTest {
|
|||
profileMapper,
|
||||
reportReminderService,
|
||||
workReportProperties,
|
||||
speechRecognitionConfigService,
|
||||
new ObjectMapper(),
|
||||
tenantProvider,
|
||||
crmDataVisibilityService,
|
||||
|
|
|
|||
|
|
@ -22,8 +22,8 @@ server {
|
|||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_read_timeout 360s;
|
||||
proxy_send_timeout 360s;
|
||||
}
|
||||
|
||||
# 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-Proto $scheme;
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_read_timeout 360s;
|
||||
proxy_send_timeout 360s;
|
||||
}
|
||||
|
||||
location /sys/ {
|
||||
|
|
@ -49,7 +49,7 @@ server {
|
|||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_read_timeout 360s;
|
||||
proxy_send_timeout 360s;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -334,6 +334,10 @@ export interface UploadReportAttachmentOptions {
|
|||
onProgress?: (progress: number) => void;
|
||||
}
|
||||
|
||||
export interface WorkSpeechTranscription {
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export interface WorkReportToUser {
|
||||
userId: CrmId;
|
||||
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) {
|
||||
return request<number>("/api/work/daily-reports", {
|
||||
method: "POST",
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, ty
|
|||
import { format } from "date-fns";
|
||||
import { zhCN } from "date-fns/locale";
|
||||
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 { Link, Navigate, useLocation, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
|
|
@ -29,6 +29,7 @@ import {
|
|||
reverseWorkGeocode,
|
||||
saveWorkCheckIn,
|
||||
saveWorkDailyReport,
|
||||
transcribeWorkReportSpeech,
|
||||
uploadWorkCheckInPhoto,
|
||||
uploadWorkReportAttachment,
|
||||
type ChannelExpansionItem,
|
||||
|
|
@ -83,6 +84,7 @@ const OPPORTUNITY_NEXT_PLAN_LABEL = "下一步销售计划";
|
|||
const OPPORTUNITY_STAGE_LABEL = "项目阶段";
|
||||
const WORK_DETAIL_NEXT_PLAN_HEADER = "后续规划 / 下一步销售计划";
|
||||
const REPORT_ATTACHMENT_MAX_SIZE_BYTES = 500 * 1024 * 1024;
|
||||
const REPORT_SPEECH_MIN_RECORDING_MS = 1000;
|
||||
|
||||
const reportFieldLabels = {
|
||||
sales: ["沟通内容", LEGACY_NEXT_PLAN_LABEL],
|
||||
|
|
@ -148,6 +150,19 @@ type WorkRelationOption = {
|
|||
type BizType = WorkReportLineItem["bizType"];
|
||||
type PickerMode = "report" | "checkin";
|
||||
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 = {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
|
|
@ -2290,6 +2305,7 @@ export default function Work() {
|
|||
uploadingReportAttachmentProgress={uploadingReportAttachmentProgress}
|
||||
reportAttachmentUploadErrors={reportAttachmentUploadErrors}
|
||||
dailyReportFeatureLoaded={dailyReportFeatureLoaded}
|
||||
objectSelectionRequired={dailyReportObjectSelectionRequired}
|
||||
notifyUsersVisible={dailyReportNotifyUsersVisible}
|
||||
attachmentsVisible={dailyReportAttachmentsVisible}
|
||||
onReportAttachmentChange={(index, event) => void handleReportAttachmentChange(index, event)}
|
||||
|
|
@ -4172,6 +4188,7 @@ function ReportPanel({
|
|||
uploadingReportAttachmentProgress,
|
||||
reportAttachmentUploadErrors,
|
||||
dailyReportFeatureLoaded,
|
||||
objectSelectionRequired,
|
||||
notifyUsersVisible,
|
||||
attachmentsVisible,
|
||||
onReportAttachmentChange,
|
||||
|
|
@ -4204,6 +4221,7 @@ function ReportPanel({
|
|||
uploadingReportAttachmentProgress: Record<number, number>;
|
||||
reportAttachmentUploadErrors: Record<number, string>;
|
||||
dailyReportFeatureLoaded: boolean;
|
||||
objectSelectionRequired: boolean;
|
||||
notifyUsersVisible: boolean;
|
||||
attachmentsVisible: boolean;
|
||||
onReportAttachmentChange: (index: number, event: ChangeEvent<HTMLInputElement>) => void;
|
||||
|
|
@ -4222,14 +4240,32 @@ function ReportPanel({
|
|||
}) {
|
||||
const [editingReportLineIndex, setEditingReportLineIndex] = 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 reportLineSelectionRefs = useRef<Record<number, ReportSpeechInsertionRange>>({});
|
||||
const pendingReportSpeechInsertionRef = useRef<{ lineIndex: number } & ReportSpeechInsertionRange | null>(null);
|
||||
const reportAttachmentInputRefs = 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 previousPlanItemCountRef = useRef(reportForm.planItems.length);
|
||||
const pendingNewReportLineFocusRef = useRef<number | null>(null);
|
||||
const pendingNewPlanItemFocusRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
reportLineItemsRef.current = reportForm.lineItems;
|
||||
}, [reportForm.lineItems]);
|
||||
|
||||
const focusTextControl = useCallback((element: HTMLTextAreaElement | HTMLInputElement | null) => {
|
||||
if (!element) {
|
||||
return;
|
||||
|
|
@ -4242,11 +4278,45 @@ function ReportPanel({
|
|||
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(() => {
|
||||
setEditingPlanItemIndex(null);
|
||||
setEditingReportLineIndex(index);
|
||||
});
|
||||
if (options?.focus === false) {
|
||||
return;
|
||||
}
|
||||
const target = reportLineTextareaRefs.current[index];
|
||||
if (target) {
|
||||
focusTextControl(target);
|
||||
|
|
@ -4268,6 +4338,222 @@ function ReportPanel({
|
|||
requestAnimationFrame(() => focusTextControl(planItemInputRefs.current[index]));
|
||||
}, [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(() => {
|
||||
pendingNewReportLineFocusRef.current = reportForm.lineItems.length;
|
||||
onAddReportLine();
|
||||
|
|
@ -4389,6 +4675,13 @@ function ReportPanel({
|
|||
const isUploadingAttachment = uploadingReportAttachmentIndex === index;
|
||||
const attachmentProgress = uploadingReportAttachmentProgress[index] ?? 0;
|
||||
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 (
|
||||
<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
|
||||
|
|
@ -4451,26 +4744,95 @@ function ReportPanel({
|
|||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<textarea
|
||||
rows={2}
|
||||
ref={(element) => {
|
||||
reportLineTextareaRefs.current[index] = element;
|
||||
syncAutoHeightTextarea(element);
|
||||
}}
|
||||
value={item.bizName ? editorBodyText : item.editorText || ""}
|
||||
onKeyDown={(event) => onReportLineKeyDown(index, event)}
|
||||
onChange={(event) => {
|
||||
syncAutoHeightTextarea(event.currentTarget);
|
||||
onReportLineChange(
|
||||
index,
|
||||
item.bizName
|
||||
? `${objectLine}\n${event.target.value}`
|
||||
: event.target.value,
|
||||
);
|
||||
}}
|
||||
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 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"
|
||||
/>
|
||||
<div className="relative">
|
||||
<textarea
|
||||
rows={2}
|
||||
ref={(element) => {
|
||||
reportLineTextareaRefs.current[index] = element;
|
||||
syncAutoHeightTextarea(element);
|
||||
}}
|
||||
value={item.bizName ? editorBodyText : item.editorText || ""}
|
||||
onKeyDown={(event) => onReportLineKeyDown(index, event)}
|
||||
onChange={(event) => {
|
||||
syncAutoHeightTextarea(event.currentTarget);
|
||||
onReportLineChange(
|
||||
index,
|
||||
item.bizName
|
||||
? `${objectLine}\n${event.target.value}`
|
||||
: event.target.value,
|
||||
);
|
||||
rememberReportLineSelection(index, event.currentTarget);
|
||||
}}
|
||||
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 ? (
|
||||
<ReportAttachmentList
|
||||
attachments={attachments}
|
||||
|
|
@ -4883,6 +5245,122 @@ function getReportLineDisplayText(lineItem: WorkReportLineItem) {
|
|||
.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({
|
||||
url,
|
||||
alt,
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ export default defineConfig(({mode}) => {
|
|||
},
|
||||
server: {
|
||||
https,
|
||||
allowedHosts: ['crmdev.oa.unissense.tech'],
|
||||
// HMR is disabled in AI Studio via DISABLE_HMR env var.
|
||||
// Do not modifyâfile watching is disabled to prevent flickering during agent edits.
|
||||
hmr: process.env.DISABLE_HMR !== 'true',
|
||||
|
|
@ -35,6 +36,8 @@ export default defineConfig(({mode}) => {
|
|||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
timeout: 360000,
|
||||
proxyTimeout: 360000,
|
||||
rewrite: (requestPath) =>
|
||||
requestPath.startsWith('/api/sys')
|
||||
? requestPath.replace(/^\/api\/sys/, '/sys')
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<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">
|
||||
</head>
|
||||
<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 UserDataScope = lazy(() => import("@/pages/system/user-data-scope"));
|
||||
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 OwnerTransfer = lazy(() => import("@/features/owner-transfer/pages/owner-transfer"));
|
||||
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: "/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: "/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: "/devices", label: "设备管理", element: <LazyPage><Devices /></LazyPage>, perm: "menu:devices" },
|
||||
{ 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