feat: 添加声纹识别开关和优化模板管理权限

- 在会议创建页面添加声纹识别开关
- 优化提示模板的权限管理,区分平台级、租户级和个人级
- 更新后端逻辑处理不同层级的模板权限
- 重构前端模板选择界面,增强用户体验
- 添加热词权重显示并更新数据库表结构
- 修复和优化多处代码逻辑和样式问题
dev_na
chenhao 2026-03-04 15:19:40 +08:00
parent eaadc4ee51
commit 80a4682757
19 changed files with 427 additions and 131 deletions

View File

@ -345,6 +345,7 @@ CREATE TABLE biz_meetings (
asr_model_id BIGINT, -- ASR模型ID
summary_model_id BIGINT, -- LLM模型ID
prompt_content TEXT, -- 发起任务时的提示词模板快照
use_spk_id SMALLINT DEFAULT 1, -- 是否开启声纹识别 (1:开启, 0:关闭)
hot_words JSONB, -- 任务发起时的热词快照
summary_content TEXT, -- Markdown 总结结果
status SMALLINT DEFAULT 0, -- 0:待处理, 1:处理中, 2:成功, 3:失败

View File

@ -27,11 +27,28 @@ public class PromptTemplateController {
@PreAuthorize("isAuthenticated()")
public ApiResponse<PromptTemplateVO> save(@RequestBody PromptTemplateDTO dto) {
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
// Only platform admin can create system templates
if (Integer.valueOf(1).equals(dto.getIsSystem()) && !Boolean.TRUE.equals(loginUser.getIsPlatformAdmin())) {
return ApiResponse.error("无权创建系统模板");
// 权限校验逻辑
if (Integer.valueOf(1).equals(dto.getIsSystem())) {
// 只有平台管理员能创建平台级模板(tenantId=0)
// 只有租户管理员能创建租户级模板(tenantId>0)
if (!Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()) && !Boolean.TRUE.equals(loginUser.getIsTenantAdmin())) {
return ApiResponse.error("无权创建系统模板");
}
// 如果是租户管理员创建系统模板,确保 tenantId 是其所属租户
if (!Boolean.TRUE.equals(loginUser.getIsPlatformAdmin())) {
dto.setTenantId(loginUser.getTenantId());
} else {
// 平台管理员:如果 DTO 没传 tenantId默认设为 0
if (dto.getTenantId() == null) dto.setTenantId(0L);
}
} else {
// 普通模板
dto.setTenantId(loginUser.getTenantId());
}
return ApiResponse.ok(promptTemplateService.saveTemplate(dto, loginUser.getUserId()));
return ApiResponse.ok(promptTemplateService.saveTemplate(dto, loginUser.getUserId(), loginUser.getTenantId()));
}
@PutMapping
@ -43,16 +60,21 @@ public class PromptTemplateController {
return ApiResponse.error("模板不存在");
}
// System template protection
if (Integer.valueOf(1).equals(existing.getIsSystem())) {
if (!Boolean.TRUE.equals(loginUser.getIsPlatformAdmin())) {
return ApiResponse.error("无权修改系统模板");
}
// 核心权限判定
boolean canModify = false;
if (Boolean.TRUE.equals(loginUser.getIsPlatformAdmin())) {
// 平台管理员只能修改平台级模板 (tenantId = 0)
canModify = existing.getTenantId() == 0L;
} else if (Boolean.TRUE.equals(loginUser.getIsTenantAdmin())) {
// 租户管理员可以修改本租户的所有模板 (租户预置 + 个人模板)
canModify = existing.getTenantId().equals(loginUser.getTenantId());
} else {
// Personal template protection
if (!existing.getCreatorId().equals(loginUser.getUserId())) {
return ApiResponse.error("无权修改他人模板");
}
// 普通用户仅限自己的个人模板
canModify = existing.getCreatorId().equals(loginUser.getUserId());
}
if (!canModify) {
return ApiResponse.error("无权修改此模板");
}
return ApiResponse.ok(promptTemplateService.updateTemplate(dto));
@ -65,14 +87,17 @@ public class PromptTemplateController {
PromptTemplate existing = promptTemplateService.getById(id);
if (existing == null) return ApiResponse.error("模板不存在");
if (Integer.valueOf(1).equals(existing.getIsSystem())) {
if (!Boolean.TRUE.equals(loginUser.getIsPlatformAdmin())) {
return ApiResponse.error("无权修改系统模板");
}
boolean canModify = false;
if (Boolean.TRUE.equals(loginUser.getIsPlatformAdmin())) {
canModify = existing.getTenantId() == 0L;
} else if (Boolean.TRUE.equals(loginUser.getIsTenantAdmin())) {
canModify = existing.getTenantId().equals(loginUser.getTenantId());
} else {
if (!existing.getCreatorId().equals(loginUser.getUserId())) {
return ApiResponse.error("无权修改他人模板");
}
canModify = existing.getCreatorId().equals(loginUser.getUserId());
}
if (!canModify) {
return ApiResponse.error("无权修改此模板");
}
existing.setStatus(status);
@ -88,14 +113,17 @@ public class PromptTemplateController {
return ApiResponse.ok(true);
}
if (Integer.valueOf(1).equals(existing.getIsSystem())) {
if (!Boolean.TRUE.equals(loginUser.getIsPlatformAdmin())) {
return ApiResponse.error("无权删除系统模板");
}
boolean canModify = false;
if (Boolean.TRUE.equals(loginUser.getIsPlatformAdmin())) {
canModify = existing.getTenantId() == 0L;
} else if (Boolean.TRUE.equals(loginUser.getIsTenantAdmin())) {
canModify = existing.getTenantId().equals(loginUser.getTenantId());
} else {
if (!existing.getCreatorId().equals(loginUser.getUserId())) {
return ApiResponse.error("无权删除他人模板");
}
canModify = existing.getCreatorId().equals(loginUser.getUserId());
}
if (!canModify) {
return ApiResponse.error("无权删除此模板");
}
return ApiResponse.ok(promptTemplateService.removeById(id));
@ -110,6 +138,9 @@ public class PromptTemplateController {
@RequestParam(required = false) String category) {
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
return ApiResponse.ok(promptTemplateService.pageTemplates(current, size, name, category, loginUser.getTenantId(), loginUser.getUserId()));
return ApiResponse.ok(promptTemplateService.pageTemplates(
current, size, name, category,
loginUser.getTenantId(), loginUser.getUserId(),
loginUser.getIsPlatformAdmin(), loginUser.getIsTenantAdmin()));
}
}

View File

@ -22,5 +22,6 @@ public class MeetingDTO {
private Long asrModelId;
private Long summaryModelId;
private Long promptId;
private Integer useSpkId;
private List<String> hotWords;
}

View File

@ -16,6 +16,7 @@ public class MeetingVO {
private String participants;
private String tags;
private Integer useSpkId;
private String audioUrl;
private String summaryContent;
private Integer status;

View File

@ -5,6 +5,7 @@ import lombok.Data;
@Data
public class PromptTemplateDTO {
private Long id;
private Long tenantId;
private String templateName;
private String category;
private Integer isSystem;

View File

@ -39,6 +39,8 @@ public class Meeting extends BaseEntity {
private String promptContent;
private Integer useSpkId;
@TableField(typeHandler = JacksonTypeHandler.class)
private List<String> hotWords;

View File

@ -10,7 +10,8 @@ import com.imeeting.common.PageResult;
import java.util.List;
public interface PromptTemplateService extends IService<PromptTemplate> {
PromptTemplateVO saveTemplate(PromptTemplateDTO dto, Long userId);
PromptTemplateVO saveTemplate(PromptTemplateDTO dto, Long userId, Long tenantId);
PromptTemplateVO updateTemplate(PromptTemplateDTO dto);
PageResult<List<PromptTemplateVO>> pageTemplates(Integer current, Integer size, String name, String category, Long tenantId, Long userId);
PageResult<List<PromptTemplateVO>> pageTemplates(Integer current, Integer size, String name, String category,
Long tenantId, Long userId, Boolean isPlatformAdmin, Boolean isTenantAdmin);
}

View File

@ -8,11 +8,15 @@ import com.imeeting.entity.biz.AiModel;
import com.imeeting.entity.biz.AiTask;
import com.imeeting.entity.biz.Meeting;
import com.imeeting.entity.biz.MeetingTranscript;
import com.imeeting.entity.biz.HotWord;
import com.imeeting.entity.SysUser;
import com.imeeting.mapper.biz.AiTaskMapper;
import com.imeeting.mapper.biz.MeetingMapper;
import com.imeeting.mapper.biz.MeetingTranscriptMapper;
import com.imeeting.mapper.SysUserMapper;
import com.imeeting.service.biz.AiModelService;
import com.imeeting.service.biz.AiTaskService;
import com.imeeting.service.biz.HotWordService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
@ -20,6 +24,8 @@ import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.net.URI;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
@ -37,6 +43,8 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
private final MeetingTranscriptMapper transcriptMapper;
private final AiModelService aiModelService;
private final ObjectMapper objectMapper;
private final SysUserMapper sysUserMapper;
private final HotWordService hotWordService;
@Value("${app.server-base-url}")
private String serverBaseUrl;
@ -102,10 +110,44 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
// 构建请求参数
Map<String, Object> req = new HashMap<>();
String fullAudioUrl = serverBaseUrl + meeting.getAudioUrl();
String rawAudioUrl = meeting.getAudioUrl();
String encodedAudioUrl = Arrays.stream(rawAudioUrl.split("/"))
.map(part -> {
try {
return URLEncoder.encode(part, StandardCharsets.UTF_8.toString()).replace("+", "%20");
} catch (Exception e) {
return part;
}
})
.collect(Collectors.joining("/"));
String fullAudioUrl = serverBaseUrl + (encodedAudioUrl.startsWith("/") ? "" : "/") + encodedAudioUrl;
req.put("file_path", fullAudioUrl);
req.put("hotwords", meeting.getHotWords() != null ? meeting.getHotWords() : Collections.emptyList());
req.put("use_spk_id", true);
List<Map<String, Object>> formattedHotwords = new ArrayList<>();
if (meeting.getHotWords() != null && !meeting.getHotWords().isEmpty()) {
List<HotWord> hotWordEntities = hotWordService.list(new LambdaQueryWrapper<HotWord>()
.eq(HotWord::getTenantId, meeting.getTenantId())
.in(HotWord::getWord, meeting.getHotWords()));
Map<String, Integer> wordToWeightMap = hotWordEntities.stream()
.collect(Collectors.toMap(HotWord::getWord, hw -> hw.getWeight() != null ? hw.getWeight() : 10, (v1, v2) -> v1));
for (String word : meeting.getHotWords()) {
Map<String, Object> hwMap = new HashMap<>();
hwMap.put("hotword", word);
// Default weight is 1.0 (assuming 10 corresponds to 1.0, 20 corresponds to 2.0 etc., or keep original logic)
// Let's map 1-100 to 0.1-10.0 or just keep integer? The prompt shows weight: 2.0
// Assuming weight in DB is 10 for normal, maybe weight / 10.0
double calculatedWeight = wordToWeightMap.getOrDefault(word, 10) / 10.0;
hwMap.put("weight", calculatedWeight);
formattedHotwords.add(hwMap);
}
}
req.put("hotwords", formattedHotwords);
req.put("use_spk_id", meeting.getUseSpkId() != null && meeting.getUseSpkId() == 1);
AiTask taskRecord = createAiTask(meeting.getId(), "ASR", req);
@ -113,7 +155,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
String submitUrl = asrModel.getBaseUrl().endsWith("/") ? asrModel.getBaseUrl() + "api/tasks/recognition" : asrModel.getBaseUrl() + "/api/tasks/recognition";
String respBody = postJson(submitUrl, req);
JsonNode submitNode = objectMapper.readTree(respBody);
log.info(respBody);
if (submitNode.get("code").asInt() != 200) {
updateAiTaskFail(taskRecord, "Submission Failed: " + respBody);
throw new RuntimeException("ASR submission failed");
@ -150,8 +192,22 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
for (JsonNode seg : resultNode.get("segments")) {
MeetingTranscript mt = new MeetingTranscript();
mt.setMeetingId(meeting.getId());
mt.setSpeakerId(seg.has("speaker") ? seg.get("speaker").asText() : "spk_0");
mt.setSpeakerName(mt.getSpeakerId());
String speakerIdStr = seg.has("speaker") ? seg.get("speaker").asText() : "spk_0";
mt.setSpeakerId(speakerIdStr);
String speakerName = speakerIdStr;
try {
Long userId = Long.valueOf(speakerIdStr);
SysUser user = sysUserMapper.selectById(userId);
if (user != null) {
speakerName = user.getDisplayName() != null ? user.getDisplayName() : user.getUsername();
}
} catch (NumberFormatException e) {
// Not a user ID, keep the original speaker_id as name
}
mt.setSpeakerName(speakerName);
mt.setContent(seg.get("text").asText());
if (seg.has("timestamp")) {
mt.setStartTime(seg.get("timestamp").get(0).asInt());

View File

@ -57,6 +57,7 @@ public class MeetingServiceImpl extends ServiceImpl<MeetingMapper, Meeting> impl
meeting.setAudioUrl(dto.getAudioUrl());
meeting.setAsrModelId(dto.getAsrModelId());
meeting.setSummaryModelId(dto.getSummaryModelId());
meeting.setUseSpkId(dto.getUseSpkId() != null ? dto.getUseSpkId() : 1);
meeting.setCreatorId(dto.getCreatorId());
meeting.setCreatorName(dto.getCreatorName());
@ -214,6 +215,7 @@ public class MeetingServiceImpl extends ServiceImpl<MeetingMapper, Meeting> impl
vo.setTitle(meeting.getTitle());
vo.setMeetingTime(meeting.getMeetingTime());
vo.setTags(meeting.getTags());
vo.setUseSpkId(meeting.getUseSpkId());
vo.setAudioUrl(meeting.getAudioUrl());
vo.setStatus(meeting.getStatus());
vo.setSummaryContent(meeting.getSummaryContent());

View File

@ -20,15 +20,25 @@ public class PromptTemplateServiceImpl extends ServiceImpl<PromptTemplateMapper,
@Override
@Transactional(rollbackFor = Exception.class)
public PromptTemplateVO saveTemplate(PromptTemplateDTO dto, Long userId) {
public PromptTemplateVO saveTemplate(PromptTemplateDTO dto, Long userId, Long tenantId) {
PromptTemplate entity = new PromptTemplate();
copyProperties(dto, entity);
entity.setCreatorId(userId);
// 逻辑纠偏:如果是平台管理员设置了 tenantId 为 0则设为 0否则强制设为用户当前租户
if (dto.getTenantId() != null && dto.getTenantId() == 0L) {
entity.setTenantId(0L);
} else {
entity.setTenantId(tenantId);
}
entity.setUsageCount(0);
this.save(entity);
return toVO(entity);
}
@Override
@Transactional(rollbackFor = Exception.class)
public PromptTemplateVO updateTemplate(PromptTemplateDTO dto) {
@ -42,17 +52,42 @@ public class PromptTemplateServiceImpl extends ServiceImpl<PromptTemplateMapper,
}
@Override
public PageResult<List<PromptTemplateVO>> pageTemplates(Integer current, Integer size, String name, String category, Long tenantId, Long userId) {
Page<PromptTemplate> page = this.page(new Page<>(current, size),
new LambdaQueryWrapper<PromptTemplate>()
.and(wrapper -> wrapper.eq(PromptTemplate::getCreatorId, userId)
.or()
.eq(PromptTemplate::getIsSystem, 1))
.like(name != null && !name.isEmpty(), PromptTemplate::getTemplateName, name)
.eq(category != null && !category.isEmpty(), PromptTemplate::getCategory, category)
.orderByDesc(PromptTemplate::getIsSystem)
.orderByDesc(PromptTemplate::getCreatedAt));
public PageResult<List<PromptTemplateVO>> pageTemplates(Integer current, Integer size, String name, String category,
Long tenantId, Long userId, Boolean isPlatformAdmin, Boolean isTenantAdmin) {
LambdaQueryWrapper<PromptTemplate> wrapper = new LambdaQueryWrapper<>();
// 核心过滤逻辑:分层可见性 (精细化处理)
if (Boolean.TRUE.equals(isPlatformAdmin)) {
// 平台管理员:可以看到所有平台级模板 (tenantId=0)
wrapper.eq(PromptTemplate::getTenantId, 0L);
} else if (Boolean.TRUE.equals(isTenantAdmin)) {
// 租户管理员:
// 1. 本租户所有模板 (tenantId=currentTenantId)
// 2. 平台预置 (tenantId=0 & isSystem=1)
wrapper.and(w -> w
.eq(PromptTemplate::getTenantId, tenantId)
.or(sw -> sw.eq(PromptTemplate::getTenantId, 0L).eq(PromptTemplate::getIsSystem, 1))
);
} else {
// 普通个人用户:
// 1. 个人创建 (creatorId=currentUserId)
// 2. 平台预置 (tenantId=0 & isSystem=1)
// 3. 租户预置 (tenantId=currentTenantId & isSystem=1)
wrapper.and(w -> w
.eq(PromptTemplate::getCreatorId, userId)
.or(sw -> sw.eq(PromptTemplate::getTenantId, 0L).eq(PromptTemplate::getIsSystem, 1))
.or(sw -> sw.eq(PromptTemplate::getTenantId, tenantId).eq(PromptTemplate::getIsSystem, 1))
);
}
// 通用过滤条件
wrapper.like(name != null && !name.isEmpty(), PromptTemplate::getTemplateName, name)
.eq(category != null && !category.isEmpty(), PromptTemplate::getCategory, category)
.orderByDesc(PromptTemplate::getIsSystem)
.orderByDesc(PromptTemplate::getCreatedAt);
Page<PromptTemplate> page = this.page(new Page<>(current, size), wrapper);
List<PromptTemplateVO> vos = page.getRecords().stream().map(this::toVO).collect(Collectors.toList());
PageResult<List<PromptTemplateVO>> result = new PageResult<>();
@ -65,6 +100,7 @@ public class PromptTemplateServiceImpl extends ServiceImpl<PromptTemplateMapper,
entity.setTemplateName(dto.getTemplateName());
entity.setCategory(dto.getCategory());
entity.setIsSystem(dto.getIsSystem());
entity.setTenantId(dto.getTenantId());
entity.setPromptContent(dto.getPromptContent());
entity.setTags(dto.getTags());
entity.setStatus(dto.getStatus());
@ -75,6 +111,7 @@ public class PromptTemplateServiceImpl extends ServiceImpl<PromptTemplateMapper,
PromptTemplateVO vo = new PromptTemplateVO();
vo.setId(entity.getId());
vo.setTenantId(entity.getTenantId());
vo.setCreatorId(entity.getCreatorId());
vo.setTemplateName(entity.getTemplateName());
vo.setCategory(entity.getCategory());
vo.setIsSystem(entity.getIsSystem());

View File

@ -137,6 +137,7 @@ public class SpeakerServiceImpl extends ServiceImpl<SpeakerMapper, Speaker> impl
Map<String, Object> body = new HashMap<>();
body.put("name", String.valueOf(speaker.getUserId()));
body.put("user_id", speaker.getUserId());
// 拼接完整下载路径: serverBaseUrl + resourcePrefix + voicePath
String fullPath = serverBaseUrl;

View File

@ -16,8 +16,8 @@ spring:
type: redis
servlet:
multipart:
max-file-size: 100MB
max-request-size: 100MB
max-file-size: 2048MB
max-request-size: 2048MB
jackson:
date-format: yyyy-MM-dd HH:mm:ss
serialization:

View File

@ -16,8 +16,8 @@ spring:
type: redis
servlet:
multipart:
max-file-size: 100MB
max-request-size: 100MB
max-file-size: 2048MB
max-request-size: 2048MB
jackson:
date-format: yyyy-MM-dd HH:mm:ss

View File

@ -1,4 +1,4 @@
import { Layout, Menu, Button, Space, Avatar, Dropdown, message, type MenuProps, Select } from "antd";
import { Layout, Menu, Button, Space, Avatar, Dropdown, message, type MenuProps, Select, Tooltip } from "antd";
import { useEffect, useState, useMemo, useCallback } from "react";
import { Link, Outlet, useLocation, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
@ -308,22 +308,27 @@ export default function AppLayout() {
style={{ fontSize: '16px', width: 64, height: 64 }}
/>
<div style={{ flex: 1, display: 'flex', alignItems: 'center', paddingLeft: 12 }}>
{availableTenants.length > 0 && (
<Select
value={currentTenantId}
onChange={handleSwitchTenant}
style={{ width: 200 }}
placeholder="切换租户"
variant="borderless"
suffixIcon={<ShopOutlined />}
options={availableTenants.map(t => ({
label: t.tenantName,
value: t.tenantId
}))}
/>
)}
{/* 租户切换已移至右侧头像旁 */}
</div>
<Space size={20}>
{availableTenants.length > 0 && (
<Dropdown
menu={{
items: availableTenants.map(t => ({
key: String(t.tenantId),
label: t.tenantName,
icon: <ShopOutlined />,
style: t.tenantId === currentTenantId ? { color: '#1677ff', fontWeight: 500, backgroundColor: '#e6f4ff' } : {}
})),
onClick: ({ key }) => handleSwitchTenant(Number(key))
}}
placement="bottomRight"
>
<ShopOutlined style={{ fontSize: '18px', color: '#666', cursor: 'pointer', padding: '4px' }} />
</Dropdown>
)}
<Dropdown menu={{ items: langMenuItems }} placement="bottomRight">
<GlobalOutlined style={{ fontSize: '18px', color: '#666', cursor: 'pointer' }} />
</Dropdown>

View File

@ -2,17 +2,16 @@ import React, { useState, useEffect } from 'react';
import { Row, Col, Card, Statistic, List, Tag, Typography, Button, Space, Empty, Steps, Progress, Divider } from 'antd';
import {
HistoryOutlined, CheckCircleOutlined, LoadingOutlined,
ArrowRightOutlined, AudioOutlined, RobotOutlined,
AudioOutlined, RobotOutlined,
CalendarOutlined, TeamOutlined, RiseOutlined, ClockCircleOutlined,
PlayCircleOutlined, FileTextOutlined
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import dayjs from 'dayjs';
import ReactMarkdown from 'react-markdown';
import { getDashboardStats, getRecentTasks, DashboardStats } from '../api/business/dashboard';
import { MeetingVO } from '../api/business/meeting';
const { Title, Text, Paragraph } = Typography;
const { Title, Text } = Typography;
const Dashboard: React.FC = () => {
const navigate = useNavigate();
@ -148,26 +147,6 @@ const Dashboard: React.FC = () => {
</Button>
</Col>
</Row>
{/* 针对已完成任务展示 AI 总结摘要 */}
{item.status === 3 && item.summaryContent && (
<div style={{
marginTop: 20,
padding: '16px 20px',
backgroundColor: '#fafafa',
borderRadius: 12,
border: '1px solid #f0f0f0',
marginLeft: '32px'
}}>
<div style={{ marginBottom: 8, display: 'flex', alignItems: 'center' }}>
<RobotOutlined style={{ marginRight: 8, color: '#722ed1' }} />
<Text strong style={{ color: '#722ed1', fontSize: 13 }}>AI </Text>
</div>
<div className="summary-preview" style={{ maxHeight: 60, overflow: 'hidden' }}>
<ReactMarkdown>{item.summaryContent.length > 150 ? item.summaryContent.substring(0, 150) + '...' : item.summaryContent}</ReactMarkdown>
</div>
</div>
)}
</div>
</List.Item>
)}
@ -176,7 +155,6 @@ const Dashboard: React.FC = () => {
</Card>
</div>
<style>{`
.summary-preview p { margin-bottom: 0; font-size: 13px; color: #595959; }
.ant-steps-item-title { font-size: 13px !important; font-weight: 600 !important; }
.ant-steps-item-description { font-size: 11px !important; }
`}</style>

View File

@ -33,6 +33,8 @@ const AiModels: React.FC = () => {
const [remoteModels, setRemoteModels] = useState<string[]>([]);
const [modelType, setModelType] = useState<'ASR' | 'LLM'>('ASR');
const watchedModelType = Form.useWatch('modelType', form);
const provider = Form.useWatch('provider', form);
// Check if current user is platform admin
const isPlatformAdmin = React.useMemo(() => {
@ -247,9 +249,11 @@ const AiModels: React.FC = () => {
<Input placeholder="https://api.example.com/v1" />
</Form.Item>
<Form.Item name="apiKey" label="API Key / Secret" tooltip="密钥将加密存储,仅在更新时需重新输入">
<Input.Password placeholder="输入您的 API 密钥" />
</Form.Item>
{!(watchedModelType === 'ASR' && provider === 'Custom') && (
<Form.Item name="apiKey" label="API Key / Secret" tooltip="密钥将加密存储,仅在更新时需重新输入">
<Input.Password placeholder="输入您的 API 密钥" />
</Form.Item>
)}
<Divider orientation="left" style={{ fontSize: '14px', color: '#999' }}></Divider>

View File

@ -174,6 +174,12 @@ const HotWords: React.FC = () => {
key: 'isPublic',
render: (val: number) => val === 1 ? <Tag color="green"></Tag> : <Tag color="blue"></Tag>
},
{
title: '权重',
dataIndex: 'weight',
key: 'weight',
render: (val: number) => <Tag color="orange">{val}</Tag>
},
{
title: '状态',
dataIndex: 'status',

View File

@ -1,9 +1,10 @@
import React, { useState, useEffect } from 'react';
import { Card, Button, Form, Input, Space, Select, Tag, message, Typography, Divider, Row, Col, DatePicker, Upload, Progress, Tooltip, Avatar } from 'antd';
import { Card, Button, Form, Input, Space, Select, Tag, message, Typography, Divider, Row, Col, DatePicker, Upload, Progress, Tooltip, Avatar, Switch } from 'antd';
import {
AudioOutlined, CheckCircleOutlined, UserOutlined, CloudUploadOutlined,
LeftOutlined, SettingOutlined, QuestionCircleOutlined, InfoCircleOutlined,
CalendarOutlined, TeamOutlined, RobotOutlined, RocketOutlined
CalendarOutlined, TeamOutlined, RobotOutlined, RocketOutlined,
FileTextOutlined, CheckOutlined
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import dayjs from 'dayjs';
@ -29,6 +30,8 @@ const MeetingCreate: React.FC = () => {
const [hotwordList, setHotwordList] = useState<HotWordVO[]>([]);
const [userList, setUserList] = useState<SysUser[]>([]);
const watchedPromptId = Form.useWatch('promptId', form);
const [fileList, setFileList] = useState<any[]>([]);
const [uploadProgress, setUploadProgress] = useState(0);
const [audioUrl, setAudioUrl] = useState('');
@ -49,7 +52,8 @@ const MeetingCreate: React.FC = () => {
setAsrModels(asrRes.data.data.records.filter(m => m.status === 1));
setLlmModels(llmRes.data.data.records.filter(m => m.status === 1));
setPrompts(promptRes.data.data.records.filter(p => p.status === 1));
const activePrompts = promptRes.data.data.records.filter(p => p.status === 1);
setPrompts(activePrompts);
setHotwordList(hotwordRes.data.data.records.filter(h => h.status === 1));
setUserList(users || []);
@ -59,7 +63,9 @@ const MeetingCreate: React.FC = () => {
form.setFieldsValue({
asrModelId: defaultAsr.data.data?.id,
summaryModelId: defaultLlm.data.data?.id,
meetingTime: dayjs()
promptId: activePrompts.length > 0 ? activePrompts[0].id : undefined,
meetingTime: dayjs(),
useSpkId: 1
});
} catch (err) {}
};
@ -203,23 +209,105 @@ const MeetingCreate: React.FC = () => {
</Select>
</Form.Item>
<Form.Item name="promptId" label="总结模板" rules={[{ required: true }]} style={{ marginBottom: 16 }}>
<Select placeholder="选择模板">
{prompts.map(p => (
<Option key={p.id} value={p.id}>{p.templateName}</Option>
))}
</Select>
<Form.Item name="promptId" label="总结模板" rules={[{ required: true }]} style={{ marginBottom: 12 }}>
<div style={{ maxHeight: 180, overflowY: 'auto', overflowX: 'hidden', padding: '2px 4px' }}>
<Row gutter={[8, 8]} style={{ margin: 0 }}>
{prompts.map(p => {
const isSelected = watchedPromptId === p.id;
return (
<Col span={8} key={p.id}>
<div
onClick={() => form.setFieldsValue({ promptId: p.id })}
style={{
padding: '8px 6px',
borderRadius: 8,
border: `1.5px solid ${isSelected ? '#1890ff' : '#f0f0f0'}`,
backgroundColor: isSelected ? '#f0f7ff' : '#fff',
cursor: 'pointer',
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
position: 'relative',
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center',
boxShadow: isSelected ? '0 2px 6px rgba(24, 144, 255, 0.12)' : 'none'
}}
>
<div style={{
width: 24,
height: 24,
borderRadius: 6,
backgroundColor: isSelected ? '#1890ff' : '#f5f5f5',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: 4
}}>
<FileTextOutlined style={{ color: isSelected ? '#fff' : '#999', fontSize: 12 }} />
</div>
<div style={{
fontWeight: 500,
fontSize: '12px',
color: isSelected ? '#1890ff' : '#434343',
width: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
lineHeight: 1.2
}} title={p.templateName}>
{p.templateName}
</div>
{isSelected && (
<div style={{
position: 'absolute',
top: 0,
right: 0,
width: 14,
height: 14,
backgroundColor: '#1890ff',
borderRadius: '0 6px 0 6px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}>
<CheckOutlined style={{ color: '#fff', fontSize: 8 }} />
</div>
)}
</div>
</Col>
);
})}
</Row>
</div>
</Form.Item>
<Form.Item
name="hotWords"
label={<span> <Tooltip title="不选默认应用全部启用热词"><QuestionCircleOutlined /></Tooltip></span>}
style={{ marginBottom: 16 }}
>
<Select mode="multiple" placeholder="可选热词" allowClear maxTagCount="responsive">
{hotwordList.map(h => <Option key={h.word} value={h.word}>{h.word}</Option>)}
</Select>
</Form.Item>
<Row gutter={16} align="middle" style={{ marginBottom: 16 }}>
<Col flex="auto">
<Form.Item
name="hotWords"
label={<span> <Tooltip title="不选默认应用全部启用热词"><QuestionCircleOutlined /></Tooltip></span>}
style={{ marginBottom: 0 }}
>
<Select mode="multiple" placeholder="可选热词" allowClear maxTagCount="responsive">
{hotwordList.map(h => <Option key={h.word} value={h.word}>{h.word}</Option>)}
</Select>
</Form.Item>
</Col>
<Col>
<Form.Item
name="useSpkId"
label={<span> <Tooltip title="开启后将区分不同发言人"><QuestionCircleOutlined /></Tooltip></span>}
valuePropName="checked"
getValueProps={(value) => ({ checked: value === 1 })}
normalize={(value) => (value ? 1 : 0)}
style={{ marginBottom: 0 }}
>
<Switch />
</Form.Item>
</Col>
</Row>
</div>
<div style={{ flexShrink: 0 }}>

View File

@ -21,6 +21,7 @@ const PromptTemplates: React.FC = () => {
const [searchForm] = Form.useForm();
const { items: categories, loading: dictLoading } = useDict('biz_prompt_category');
const { items: dictTags } = useDict('biz_prompt_tag');
const { items: promptLevels } = useDict('biz_prompt_level');
const [loading, setLoading] = useState(false);
const [data, setData] = useState<PromptTemplateVO[]>([]);
@ -34,7 +35,10 @@ const PromptTemplates: React.FC = () => {
return profileStr ? JSON.parse(profileStr) : {};
}, []);
const activeTenantId = React.useMemo(() => Number(localStorage.getItem("activeTenantId") || 0), []);
const isPlatformAdmin = userProfile.isPlatformAdmin === true;
const isTenantAdmin = userProfile.isTenantAdmin === true;
useEffect(() => {
fetchData();
@ -77,19 +81,30 @@ const PromptTemplates: React.FC = () => {
form.setFieldsValue({
...record,
templateName: `${record.templateName} (副本)`,
isSystem: 0,
id: undefined
isSystem: 0, // 副本强制设为普通模板
id: undefined,
tenantId: undefined
});
setPreviewContent(record.promptContent);
} else {
if (record.isSystem === 1 && !isPlatformAdmin) {
message.error('无权编辑系统模板');
return;
}
if (record.isSystem === 0 && record.creatorId !== userProfile.userId) {
message.error('无权编辑他人模板');
const isPlatformLevel = Number(record.tenantId) === 0;
const currentUserId = userProfile.userId ? Number(userProfile.userId) : -1;
// 权限判定逻辑
let canEdit = false;
if (isPlatformAdmin) {
canEdit = isPlatformLevel;
} else if (isTenantAdmin) {
canEdit = Number(record.tenantId) === activeTenantId;
} else {
canEdit = Number(record.creatorId) === currentUserId;
}
if (!canEdit) {
message.warning('您无权修改此层级的模板');
return;
}
setEditingId(record.id);
form.setFieldsValue(record);
setPreviewContent(record.promptContent);
@ -97,7 +112,11 @@ const PromptTemplates: React.FC = () => {
} else {
setEditingId(null);
form.resetFields();
form.setFieldsValue({ status: 1, isSystem: 0 });
// 租户管理员或平台管理员新增默认选系统/租户预置
form.setFieldsValue({
status: 1,
isSystem: (isTenantAdmin || isPlatformAdmin) ? 1 : 0
});
setPreviewContent('');
}
setDrawerVisible(true);
@ -122,6 +141,12 @@ const PromptTemplates: React.FC = () => {
try {
const values = await form.validateFields();
setSubmitLoading(true);
// 处理 tenantId如果是新增且是平台管理员设为系统模板手动设置 tenantId 为 0
if (!editingId && isPlatformAdmin && values.isSystem === 1) {
values.tenantId = 0;
}
if (editingId) {
await updatePromptTemplate({ ...values, id: editingId });
message.success('更新成功');
@ -149,9 +174,33 @@ const PromptTemplates: React.FC = () => {
}, [data]);
const renderCard = (item: PromptTemplateVO) => {
const isMine = item.creatorId === userProfile.userId;
const isSystem = item.isSystem === 1;
const canEdit = isSystem ? isPlatformAdmin : isMine;
const isPlatformLevel = Number(item.tenantId) === 0;
const isTenantLevel = Number(item.tenantId) > 0 && isSystem;
// 权限判定逻辑 (使用 Number 强制转换防止类型不匹配)
let canEdit = false;
const currentUserId = userProfile.userId ? Number(userProfile.userId) : -1;
if (isPlatformAdmin) {
// 平台管理员管理平台级 (tenantId = 0)
canEdit = isPlatformLevel;
} else if (isTenantAdmin) {
// 租户管理员管理本租户所有模板
canEdit = Number(item.tenantId) === activeTenantId;
} else {
// 普通用户仅限自己的个人模板
canEdit = Number(item.creatorId) === currentUserId;
}
// 标签颜色与文字
const levelTag = isPlatformLevel ? (
<Tag color="gold" style={{ borderRadius: 4 }}></Tag>
) : isTenantLevel ? (
<Tag color="blue" style={{ borderRadius: 4 }}></Tag>
) : (
<Tag color="cyan" style={{ borderRadius: 4 }}></Tag>
);
return (
<Card
@ -162,11 +211,14 @@ const PromptTemplates: React.FC = () => {
bodyStyle={{ padding: '24px' }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<div style={{
width: 40, height: 40, borderRadius: 10, backgroundColor: '#e6f7ff',
display: 'flex', justifyContent: 'center', alignItems: 'center'
}}>
<StarFilled style={{ fontSize: 20, color: '#1890ff' }} />
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{
width: 40, height: 40, borderRadius: 10, backgroundColor: isPlatformLevel ? '#fffbe6' : '#e6f7ff',
display: 'flex', justifyContent: 'center', alignItems: 'center'
}}>
<StarFilled style={{ fontSize: 20, color: isPlatformLevel ? '#faad14' : '#1890ff' }} />
</div>
{levelTag}
</div>
<Space onClick={e => e.stopPropagation()}>
{canEdit && <EditOutlined style={{ fontSize: 18, color: '#bfbfbf', cursor: 'pointer' }} onClick={() => handleOpenDrawer(item)} />}
@ -274,9 +326,38 @@ const PromptTemplates: React.FC = () => {
>
<Form form={form} layout="vertical">
<Row gutter={24}>
<Col span={12}><Form.Item name="templateName" label="模板名称" rules={[{ required: true }]}><Input /></Form.Item></Col>
<Col span={6}><Form.Item name="category" label="分类" rules={[{ required: true }]}><Select loading={dictLoading}>{categories.map(i => <Option key={i.itemValue} value={i.itemValue}>{i.itemLabel}</Option>)}</Select></Form.Item></Col>
<Col span={6}><Form.Item name="status" label="状态"><Select><Option value={1}></Option><Option value={0}></Option></Select></Form.Item></Col>
<Col span={(isPlatformAdmin || isTenantAdmin) ? 8 : 12}>
<Form.Item name="templateName" label="模板名称" rules={[{ required: true }]}><Input /></Form.Item>
</Col>
{(isPlatformAdmin || isTenantAdmin) && (
<Col span={6}>
<Form.Item name="isSystem" label="模板属性" rules={[{ required: true }]}>
<Select placeholder="选择属性">
{promptLevels.length > 0 ? (
promptLevels.map(i => <Option key={i.itemValue} value={Number(i.itemValue)}>{i.itemLabel}</Option>)
) : (
<>
<Option value={1}>{isPlatformAdmin ? '系统预置 (全局)' : '租户预置 (全员)'}</Option>
<Option value={0}></Option>
</>
)}
</Select>
</Form.Item>
</Col>
)}
<Col span={(isPlatformAdmin || isTenantAdmin) ? 5 : 6}>
<Form.Item name="category" label="分类" rules={[{ required: true }]}>
<Select loading={dictLoading}>{categories.map(i => <Option key={i.itemValue} value={i.itemValue}>{i.itemLabel}</Option>)}</Select>
</Form.Item>
</Col>
<Col span={isPlatformAdmin ? 5 : 6}>
<Form.Item name="status" label="状态">
<Select>
<Option value={1}></Option>
<Option value={0}></Option>
</Select>
</Form.Item>
</Col>
</Row>
<Form.Item name="tags" label="业务标签" tooltip="可从现有标签中选择,也可输入新内容按回车保存">
<Select