feat: 新增热词组分组筛选编辑

dev_na
puz 2026-07-16 16:05:16 +08:00
parent 85ed5e37fd
commit 093438e73a
8 changed files with 315 additions and 41 deletions

View File

@ -2,6 +2,7 @@ package com.imeeting.controller.biz;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.imeeting.dto.biz.HotWordBatchGroupDTO;
import com.imeeting.dto.biz.HotWordDTO;
import com.imeeting.dto.biz.HotWordVO;
import com.imeeting.entity.biz.HotWord;
@ -64,6 +65,16 @@ public class HotWordController {
return ApiResponse.ok(hotWordService.updateHotWord(hotWordDTO, loginUser.getUserId(), existing.getTenantId()));
}
@Operation(summary = "批量修改热词分组")
@PutMapping("/group/batch")
@PreAuthorize("isAuthenticated()")
@Log(value = "批量修改热词分组", type = "热词管理")
public ApiResponse<Integer> updateGroupBatch(@RequestBody HotWordBatchGroupDTO dto) {
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
Long targetTenantId = resolveTargetTenantId(loginUser, dto.getTenantId());
return ApiResponse.ok(hotWordService.updateHotWordGroupBatch(dto.getIds(), dto.getHotWordGroupId(), targetTenantId));
}
@Operation(summary = "删除热词")
@DeleteMapping("/{id}")
@PreAuthorize("isAuthenticated()")
@ -85,6 +96,7 @@ public class HotWordController {
@RequestParam(required = false) String word,
@RequestParam(required = false) String category,
@RequestParam(required = false) Long hotWordGroupId,
@RequestParam(required = false) Boolean ungrouped,
@RequestParam(required = false) Long tenantId) {
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
Long targetTenantId = Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()) ? tenantId : null;
@ -93,6 +105,7 @@ public class HotWordController {
.like(word != null && !word.isEmpty(), HotWord::getWord, word)
.eq(category != null && !category.isEmpty(), HotWord::getCategory, category)
.eq(hotWordGroupId != null, HotWord::getHotWordGroupId, hotWordGroupId)
.isNull(Boolean.TRUE.equals(ungrouped), HotWord::getHotWordGroupId)
.orderByDesc(HotWord::getCreatedAt);
wrapper.eq(targetTenantId != null, HotWord::getTenantId, targetTenantId);

View File

@ -0,0 +1,20 @@
package com.imeeting.dto.biz;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.List;
@Data
@Schema(description = "热词批量分组请求参数")
public class HotWordBatchGroupDTO {
@Schema(description = "租户 ID平台管理员可传 0 表示平台范围")
private Long tenantId;
@Schema(description = "热词 ID 列表")
private List<Long> ids;
@Schema(description = "目标热词组 ID为空表示移出分组")
private Long hotWordGroupId;
}

View File

@ -10,6 +10,7 @@ import java.util.List;
public interface HotWordService extends IService<HotWord> {
HotWordVO saveHotWord(HotWordDTO hotWordDTO, Long userId, Long tenantId);
HotWordVO updateHotWord(HotWordDTO hotWordDTO, Long userId, Long tenantId);
Integer updateHotWordGroupBatch(List<Long> ids, Long hotWordGroupId, Long tenantId);
List<String> generatePinyin(String word);
List<HotWord> listEnabledByGroupIdIgnoreTenant(Long groupId);
List<HotWord> listEnabledByGroupIdAndWordsIgnoreTenant(Long groupId, List<String> words);

View File

@ -5,9 +5,8 @@ import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.imeeting.common.SysParamKeys;
import com.imeeting.common.MeetingProgressStage;
import com.imeeting.common.SysParamKeys;
import com.imeeting.dto.biz.AiModelVO;
import com.imeeting.dto.biz.MeetingSummarySource;
import com.imeeting.dto.biz.MeetingTranscriptSourceVO;
@ -21,40 +20,25 @@ import com.imeeting.mapper.biz.AiTaskMapper;
import com.imeeting.mapper.biz.MeetingMapper;
import com.imeeting.mapper.biz.MeetingTranscriptMapper;
import com.imeeting.service.android.AndroidMeetingPushService;
import com.imeeting.support.TaskSecurityContextRunner;
import com.imeeting.service.biz.AiModelService;
import com.imeeting.service.biz.AiTaskService;
import com.imeeting.service.biz.HotWordService;
import com.imeeting.service.biz.MeetingProgressService;
import com.imeeting.service.biz.MeetingPointsService;
import com.imeeting.service.biz.MeetingSummaryFileService;
import com.imeeting.service.biz.MeetingTranscriptChapterService;
import com.imeeting.service.biz.MeetingTranscriptFileService;
import com.imeeting.support.retry.RetryExecutor;
import com.imeeting.support.retry.RetryOptions;
import com.imeeting.service.biz.*;
import com.imeeting.support.TaskSecurityContextRunner;
import com.imeeting.support.redis.MeetingAsrPermitCache;
import com.imeeting.support.redis.MeetingLockCache;
import com.unisbase.entity.SysUser;
import com.unisbase.mapper.SysUserMapper;
import com.unisbase.service.SysParamService;
import com.imeeting.support.retry.RetryExecutor;
import com.imeeting.support.retry.RetryOptions;
import com.tencentcloudapi.asr.v20190614.AsrClient;
import com.tencentcloudapi.asr.v20190614.models.CreateRecTaskRequest;
import com.tencentcloudapi.asr.v20190614.models.CreateRecTaskResponse;
import com.tencentcloudapi.asr.v20190614.models.DescribeTaskStatusRequest;
import com.tencentcloudapi.asr.v20190614.models.DescribeTaskStatusResponse;
import com.tencentcloudapi.asr.v20190614.models.SentenceDetail;
import com.tencentcloudapi.asr.v20190614.models.TaskStatus;
import com.tencentcloudapi.asr.v20190614.models.*;
import com.tencentcloudapi.common.Credential;
import com.tencentcloudapi.common.exception.TencentCloudSDKException;
import com.tencentcloudapi.common.profile.ClientProfile;
import com.unisbase.entity.SysUser;
import com.unisbase.mapper.SysUserMapper;
import com.unisbase.service.SysParamService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Lazy;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@ -770,7 +754,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
int unchangedCount = 0;
for (int i = 0; i < 600; i++) {
Thread.sleep(2000);
Thread.sleep(5000);
String queryResp = retryExecutor.execute(
RetryOptions.builder()
.operation("asr-query")

View File

@ -1,6 +1,7 @@
package com.imeeting.service.biz.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.imeeting.dto.biz.HotWordDTO;
import com.imeeting.dto.biz.HotWordVO;
@ -21,7 +22,9 @@ import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
@Slf4j
@ -69,6 +72,37 @@ public class HotWordServiceImpl extends ServiceImpl<HotWordMapper, HotWord> impl
return toVO(hotWord);
}
@Override
@Transactional(rollbackFor = Exception.class)
public Integer updateHotWordGroupBatch(List<Long> ids, Long hotWordGroupId, Long tenantId) {
if (ids == null || ids.isEmpty()) {
throw new IllegalArgumentException("请选择热词");
}
Set<Long> uniqueIds = ids.stream()
.filter(id -> id != null)
.collect(Collectors.toCollection(LinkedHashSet::new));
if (uniqueIds.isEmpty()) {
throw new IllegalArgumentException("请选择热词");
}
List<HotWord> hotWords = this.list(new LambdaQueryWrapper<HotWord>()
.in(HotWord::getId, uniqueIds)
.eq(HotWord::getTenantId, tenantId));
if (hotWords.size() != uniqueIds.size()) {
throw new IllegalArgumentException("部分热词不存在或无权操作");
}
if (hotWordGroupId != null) {
validateGroupCapacity(hotWordGroupId, tenantId, hotWords);
}
boolean updated = this.update(new LambdaUpdateWrapper<HotWord>()
.in(HotWord::getId, uniqueIds)
.eq(HotWord::getTenantId, tenantId)
.set(HotWord::getHotWordGroupId, hotWordGroupId));
return updated ? uniqueIds.size() : 0;
}
@Override
public List<String> generatePinyin(String word) {
if (word == null || word.isEmpty()) {
@ -140,6 +174,26 @@ public class HotWordServiceImpl extends ServiceImpl<HotWordMapper, HotWord> impl
return group.getId();
}
private void validateGroupCapacity(Long groupId, Long tenantId, List<HotWord> movingHotWords) {
HotWordGroup group = hotWordGroupMapper.selectById(groupId);
if (group == null || !tenantId.equals(group.getTenantId())) {
throw new IllegalArgumentException("热词组不存在");
}
if (!Integer.valueOf(1).equals(group.getStatus())) {
throw new IllegalArgumentException("热词组已禁用");
}
Set<Long> movingIds = movingHotWords.stream().map(HotWord::getId).collect(Collectors.toSet());
long currentCount = this.count(new LambdaQueryWrapper<HotWord>()
.eq(HotWord::getHotWordGroupId, groupId)
.notIn(!movingIds.isEmpty(), HotWord::getId, movingIds));
long incomingCount = movingHotWords.stream()
.filter(item -> !groupId.equals(item.getHotWordGroupId()))
.count();
if (currentCount + incomingCount > MAX_HOT_WORDS_PER_GROUP) {
throw new IllegalArgumentException("热词组最多只能包含 200 个热词");
}
}
private void generateCombinations(List<List<String>> matrix, int index, String current, List<String> result) {
if (index == matrix.size()) {
result.add(current.trim());

View File

@ -30,12 +30,19 @@ export interface HotWordDTO {
remark?: string;
}
export interface HotWordBatchGroupDTO {
tenantId?: number;
ids: number[];
hotWordGroupId?: number;
}
export const getHotWordPage = (params: {
current: number;
size: number;
word?: string;
category?: string;
hotWordGroupId?: number;
ungrouped?: boolean;
tenantId?: number;
}) => {
return http.get<{ code: string; data: { records: HotWordVO[]; total: number }; msg: string }>(
@ -64,6 +71,14 @@ export const updateHotWord = (data: HotWordDTO) => {
);
};
export const updateHotWordGroupBatch = (data: HotWordBatchGroupDTO, options?: { suppressErrorToast?: boolean }) => {
return http.put<{ code: string; data: number; msg: string }>(
"/api/biz/hotword/group/batch",
data,
{ suppressErrorToast: options?.suppressErrorToast }
);
};
export const deleteHotWord = (id: number) => {
return http.delete<{ code: string; data: boolean; msg: string }>(
`/api/biz/hotword/${id}`

View File

@ -265,6 +265,10 @@
width: 120px;
}
.hotwords-search__group {
width: 160px;
}
.hotwords-modal-form {
margin-top: 16px;
}
@ -319,7 +323,7 @@
height: 65px;
}
@media (max-width: 992px) {
@media (max-width: 640px) {
.hotwords-page > .page-container__body,
.hotwords-page .section-card,
.hotwords-page .section-card__content {
@ -346,7 +350,7 @@
}
}
@media (max-width: 768px) {
@media (max-width: 640px) {
.hotwords-list-panel .data-list-panel__left-actions,
.hotwords-list-panel .data-list-panel__right-actions,
.hotwords-list-panel .data-list-panel__right-actions .ant-space,

View File

@ -38,6 +38,7 @@ import {
getPinyinSuggestion,
saveHotWord,
updateHotWord,
updateHotWordGroupBatch,
type HotWordVO,
} from "../../api/business/hotword";
import {
@ -70,13 +71,31 @@ type HotWordGroupFormValues = {
remark?: string;
};
type BulkGroupFormValues = {
hotWordGroupId: number;
};
type HotWordGroupFilter = "all" | "ungrouped" | number;
type GroupListItem = HotWordGroupVO | { id?: undefined; groupName: string; remark?: string; hotWordCount?: number; status?: number };
const getBatchGroupErrorMessage = (error: unknown) => {
const err = error as { response?: { status?: number; data?: { msg?: string } }; message?: string; msg?: string };
const messageText = err.response?.data?.msg || err.msg || err.message || "";
if (err.response?.status === 404 || messageText.includes("No static resource") || messageText.includes("group/batch")) {
return "批量修改分组接口暂未生效,请更新或重启后端服务后再试";
}
return messageText || "批量修改分组失败,请稍后重试";
};
const countAssignedHotWords = (records: HotWordVO[]) => records.filter((item) => item.hotWordGroupId).length;
const HotWords: React.FC = () => {
const { message } = App.useApp();
const { t } = useTranslation();
const [form] = Form.useForm<HotWordFormValues>();
const [groupForm] = Form.useForm<HotWordGroupFormValues>();
const [bulkGroupForm] = Form.useForm<BulkGroupFormValues>();
const { items: categories } = useDict("biz_hotword_category");
const userProfile = useMemo(() => {
const profileStr = sessionStorage.getItem("userProfile");
@ -93,6 +112,7 @@ const HotWords: React.FC = () => {
const [searchWord, setSearchWord] = useState("");
const [searchCategory, setSearchCategory] = useState<string | undefined>(undefined);
const [searchGroupId, setSearchGroupId] = useState<number | undefined>(undefined);
const [hotWordGroupFilter, setHotWordGroupFilter] = useState<HotWordGroupFilter>("all");
const [modalVisible, setModalVisible] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
@ -111,15 +131,23 @@ const HotWords: React.FC = () => {
const [groupSearchStatus, setGroupSearchStatus] = useState<number | undefined>(undefined);
const [editingGroupId, setEditingGroupId] = useState<number | null>(null);
const [selectedGroupName, setSelectedGroupName] = useState<string | undefined>(undefined);
const [selectedHotWordIds, setSelectedHotWordIds] = useState<number[]>([]);
const [bulkGroupEditorVisible, setBulkGroupEditorVisible] = useState(false);
const [bulkSubmitLoading, setBulkSubmitLoading] = useState(false);
const groupNameMap = useMemo(
() => Object.fromEntries(groupOptions.map((item) => [item.id, item.groupName])) as Record<number, string>,
[groupOptions]
);
const selectedHotWords = useMemo(
() => data.filter((item) => selectedHotWordIds.includes(item.id)),
[data, selectedHotWordIds]
);
useEffect(() => {
void fetchData();
}, [current, searchCategory, searchGroupId, size]);
}, [current, hotWordGroupFilter, searchCategory, searchGroupId, size]);
useEffect(() => {
void loadGroupOptions();
@ -129,15 +157,33 @@ const HotWords: React.FC = () => {
void loadGroupPage();
}, [groupCurrent, groupSearchName, groupSearchStatus, groupSize, isPlatformAdmin, activeTenantId]);
const fetchData = async () => {
useEffect(() => {
setSelectedHotWordIds((ids) => ids.filter((id) => data.some((item) => item.id === id)));
}, [data]);
const fetchData = async (override?: {
current?: number;
size?: number;
word?: string | null;
category?: string | null;
groupFilter?: HotWordGroupFilter;
searchGroupId?: number | null;
}) => {
const nextCurrent = override?.current ?? current;
const nextSize = override?.size ?? size;
const nextWord = override?.word !== undefined ? override.word ?? "" : searchWord;
const nextCategory = override?.category !== undefined ? override.category ?? undefined : searchCategory;
const nextGroupFilter = override?.groupFilter ?? hotWordGroupFilter;
const nextSearchGroupId = override?.searchGroupId !== undefined ? override.searchGroupId ?? undefined : searchGroupId;
setLoading(true);
try {
const res = await getHotWordPage({
current,
size,
word: searchWord,
category: searchCategory,
hotWordGroupId: searchGroupId,
current: nextCurrent,
size: nextSize,
word: nextWord,
category: nextCategory,
hotWordGroupId: typeof nextGroupFilter === "number" ? nextGroupFilter : nextSearchGroupId,
ungrouped: nextGroupFilter === "ungrouped",
tenantId: isPlatformAdmin ? activeTenantId : undefined,
});
if (res.data?.data) {
@ -293,9 +339,79 @@ const HotWords: React.FC = () => {
await Promise.all([reloadGroupList(), fetchData()]);
};
const openBulkGroupEditor = () => {
if (selectedHotWords.length === 0) {
message.warning("请先选择热词");
return;
}
const openEditor = () => {
bulkGroupForm.resetFields();
const firstGroupId = selectedHotWords[0]?.hotWordGroupId ?? 0;
const sameGroup = selectedHotWords.every((item) => (item.hotWordGroupId ?? 0) === firstGroupId);
if (sameGroup) {
bulkGroupForm.setFieldsValue({ hotWordGroupId: firstGroupId });
}
setBulkGroupEditorVisible(true);
};
const assignedHotWordCount = countAssignedHotWords(selectedHotWords);
if (assignedHotWordCount > 0) {
Modal.confirm({
title: "确认修改热词组?",
content: `当前选择的热词中,有 ${assignedHotWordCount} 个已分配热词组。继续后,这些热词的原分组会被你后续选择的目标热词组覆盖。`,
okText: "继续修改",
cancelText: "取消",
onOk: openEditor,
});
return;
}
openEditor();
};
const handleBulkGroupSubmit = async () => {
const values = await bulkGroupForm.validateFields();
const records = [...selectedHotWords];
const targetGroupId = values.hotWordGroupId === 0 ? undefined : values.hotWordGroupId;
setBulkSubmitLoading(true);
try {
await updateHotWordGroupBatch(
{
ids: records.map((item) => item.id),
hotWordGroupId: targetGroupId,
tenantId: isPlatformAdmin ? activeTenantId : undefined,
},
{ suppressErrorToast: true }
);
message.success(`已修改 ${records.length} 个热词`);
setBulkGroupEditorVisible(false);
setSelectedHotWordIds([]);
await Promise.all([fetchData(), loadGroupOptions(), loadGroupPage()]);
} catch (error) {
message.error(getBatchGroupErrorMessage(error));
} finally {
setBulkSubmitLoading(false);
}
};
const handleResetFilters = () => {
setSelectedHotWordIds([]);
setSearchWord("");
setSearchCategory(undefined);
setSearchGroupId(undefined);
setSelectedGroupName(undefined);
setHotWordGroupFilter("all");
bulkGroupForm.resetFields();
setBulkGroupEditorVisible(false);
setCurrent(1);
void fetchData({ current: 1, word: "", category: null, groupFilter: "all", searchGroupId: null });
};
const handleSelectGroup = (item: GroupListItem) => {
setSearchGroupId(item.id);
setSelectedGroupName(item.id ? item.groupName : undefined);
setHotWordGroupFilter(item.id ?? "all");
setCurrent(1);
};
@ -303,6 +419,15 @@ const HotWords: React.FC = () => {
? selectedGroupName || groupData.find((item) => item.id === searchGroupId)?.groupName || groupNameMap[searchGroupId] || "热词列表"
: "全部热词";
const groupFilterOptions = useMemo(
() => [
{ label: "全部词组", value: "all" as const },
{ label: "未分配", value: "ungrouped" as const },
...groupOptions.map((item) => ({ label: item.groupName, value: item.id })),
],
[groupOptions]
);
const groupListData: GroupListItem[] = [{ id: undefined, groupName: "全部热词" }, ...groupData];
const columns = [
@ -511,7 +636,23 @@ const HotWords: React.FC = () => {
<DataListPanel
className="hotwords-list-panel"
leftActions={<Text strong>{hotWordGroupTitle}</Text>}
leftActions={
<Space wrap size="small">
<Text strong>{hotWordGroupTitle}</Text>
<Button type="primary" icon={<PlusOutlined />} onClick={() => handleOpenModal()}>
</Button>
<Button
type="primary"
ghost
disabled={selectedHotWords.length === 0}
onClick={openBulkGroupEditor}
>
{selectedHotWords.length > 0 ? ` (${selectedHotWords.length})` : ""}
</Button>
<Button icon={<ReloadOutlined />} onClick={() => { setCurrent(1); void fetchData(); void loadGroupPage(); }} title="刷新" aria-label="刷新" />
</Space>
}
rightActions={
<Space wrap size="small">
<Input
@ -520,9 +661,23 @@ const HotWords: React.FC = () => {
allowClear
value={searchWord}
onChange={(e) => setSearchWord(e.target.value)}
onPressEnter={() => { setCurrent(1); void fetchData(); }}
onPressEnter={() => { setCurrent(1); void fetchData({ current: 1, word: searchWord }); }}
className="hotwords-search__word"
/>
<Select
placeholder="筛选热词组"
allowClear
value={hotWordGroupFilter}
options={groupFilterOptions}
onChange={(value) => {
const nextFilter = (value ?? "all") as HotWordGroupFilter;
setHotWordGroupFilter(nextFilter);
setSearchGroupId(undefined);
setSelectedGroupName(undefined);
setCurrent(1);
}}
className="hotwords-search__group"
/>
<Select
placeholder="筛选分类"
allowClear
@ -530,15 +685,13 @@ const HotWords: React.FC = () => {
onChange={(value) => {
setSearchCategory(value as string);
setCurrent(1);
void fetchData();
}}
className="hotwords-search__category"
options={categories.map((c) => ({ label: c.itemLabel, value: c.itemValue }))}
/>
<Button type="primary" icon={<PlusOutlined />} onClick={() => handleOpenModal()}>
<Button onClick={handleResetFilters} disabled={selectedHotWordIds.length === 0 && searchWord === "" && !searchCategory && !searchGroupId && hotWordGroupFilter === "all"}>
</Button>
<Button icon={<ReloadOutlined />} onClick={() => { setCurrent(1); void fetchData(); void loadGroupPage(); }} title="刷新" aria-label="刷新" />
</Space>
}
footer={
@ -546,7 +699,7 @@ const HotWords: React.FC = () => {
current={current}
pageSize={size}
total={total}
onChange={(page, pageSize) => {
onChange={(page, pageSize) => {
setCurrent(page);
setSize(pageSize);
}}
@ -560,6 +713,10 @@ const HotWords: React.FC = () => {
rowKey="id"
loading={loading}
scroll={{ x: "max(100%, 1000px)", y: "100%" }}
rowSelection={{
selectedRowKeys: selectedHotWordIds,
onChange: (keys) => setSelectedHotWordIds(keys as number[]),
}}
pagination={false}
/>
</DataListPanel>
@ -661,6 +818,32 @@ const HotWords: React.FC = () => {
</Form.Item>
</Form>
</Modal>
<Modal
title="批量修改热词组"
open={bulkGroupEditorVisible}
onCancel={handleResetFilters}
onOk={() => void handleBulkGroupSubmit()}
confirmLoading={bulkSubmitLoading}
okText="确定"
cancelText={t("common.cancel")}
destroyOnHidden
>
<Form form={bulkGroupForm} layout="vertical" className="hotwords-modal-form">
<Form.Item>
<Text type="secondary"> {selectedHotWords.length} </Text>
</Form.Item>
<Form.Item name="hotWordGroupId" label="目标热词组" rules={[{ required: true, message: "请选择目标热词组" }]}>
<Select
placeholder="请选择热词组"
options={[
{ label: "未分组", value: 0 },
...groupOptions.map((item) => ({ label: `${item.groupName} (${item.hotWordCount}/200)`, value: item.id })),
]}
/>
</Form.Item>
</Form>
</Modal>
</PageContainer>
);
};