语音服务

main
kangwenjing 2026-07-24 14:05:35 +08:00
parent 4eed47cc70
commit 349cdf26e6
22 changed files with 2345 additions and 31 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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) {

View File

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

View File

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

View File

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

View File

@ -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",

View File

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

View File

@ -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')

View File

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

View File

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

View File

@ -0,0 +1 @@
export { default } from "./pages/speech-recognition-settings";

View File

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

View File

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

View File

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

View File

@ -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" },

View File

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