From 093438e73afed453b39055ce6294fab4ccc4abf9 Mon Sep 17 00:00:00 2001 From: puz <13060209078@163.com> Date: Thu, 16 Jul 2026 16:05:16 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E7=83=AD=E8=AF=8D?= =?UTF-8?q?=E7=BB=84=E5=88=86=E7=BB=84=E7=AD=9B=E9=80=89=E7=BC=96=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/biz/HotWordController.java | 13 ++ .../dto/biz/HotWordBatchGroupDTO.java | 20 ++ .../imeeting/service/biz/HotWordService.java | 1 + .../service/biz/impl/AiTaskServiceImpl.java | 34 +-- .../service/biz/impl/HotWordServiceImpl.java | 54 +++++ frontend/src/api/business/hotword.ts | 15 ++ frontend/src/pages/business/HotWords.css | 8 +- frontend/src/pages/business/HotWords.tsx | 211 ++++++++++++++++-- 8 files changed, 315 insertions(+), 41 deletions(-) create mode 100644 backend/src/main/java/com/imeeting/dto/biz/HotWordBatchGroupDTO.java diff --git a/backend/src/main/java/com/imeeting/controller/biz/HotWordController.java b/backend/src/main/java/com/imeeting/controller/biz/HotWordController.java index 661c54d..5ba1a5c 100644 --- a/backend/src/main/java/com/imeeting/controller/biz/HotWordController.java +++ b/backend/src/main/java/com/imeeting/controller/biz/HotWordController.java @@ -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 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); diff --git a/backend/src/main/java/com/imeeting/dto/biz/HotWordBatchGroupDTO.java b/backend/src/main/java/com/imeeting/dto/biz/HotWordBatchGroupDTO.java new file mode 100644 index 0000000..1d4036b --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/biz/HotWordBatchGroupDTO.java @@ -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 ids; + + @Schema(description = "目标热词组 ID,为空表示移出分组") + private Long hotWordGroupId; +} diff --git a/backend/src/main/java/com/imeeting/service/biz/HotWordService.java b/backend/src/main/java/com/imeeting/service/biz/HotWordService.java index be2b8b1..46da4e8 100644 --- a/backend/src/main/java/com/imeeting/service/biz/HotWordService.java +++ b/backend/src/main/java/com/imeeting/service/biz/HotWordService.java @@ -10,6 +10,7 @@ import java.util.List; public interface HotWordService extends IService { HotWordVO saveHotWord(HotWordDTO hotWordDTO, Long userId, Long tenantId); HotWordVO updateHotWord(HotWordDTO hotWordDTO, Long userId, Long tenantId); + Integer updateHotWordGroupBatch(List ids, Long hotWordGroupId, Long tenantId); List generatePinyin(String word); List listEnabledByGroupIdIgnoreTenant(Long groupId); List listEnabledByGroupIdAndWordsIgnoreTenant(Long groupId, List words); diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java index 26c1668..7e1c914 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java @@ -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 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") diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/HotWordServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/HotWordServiceImpl.java index 9fb7555..75e1c6a 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/HotWordServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/HotWordServiceImpl.java @@ -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 impl return toVO(hotWord); } + @Override + @Transactional(rollbackFor = Exception.class) + public Integer updateHotWordGroupBatch(List ids, Long hotWordGroupId, Long tenantId) { + if (ids == null || ids.isEmpty()) { + throw new IllegalArgumentException("请选择热词"); + } + Set uniqueIds = ids.stream() + .filter(id -> id != null) + .collect(Collectors.toCollection(LinkedHashSet::new)); + if (uniqueIds.isEmpty()) { + throw new IllegalArgumentException("请选择热词"); + } + + List hotWords = this.list(new LambdaQueryWrapper() + .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() + .in(HotWord::getId, uniqueIds) + .eq(HotWord::getTenantId, tenantId) + .set(HotWord::getHotWordGroupId, hotWordGroupId)); + return updated ? uniqueIds.size() : 0; + } + @Override public List generatePinyin(String word) { if (word == null || word.isEmpty()) { @@ -140,6 +174,26 @@ public class HotWordServiceImpl extends ServiceImpl impl return group.getId(); } + private void validateGroupCapacity(Long groupId, Long tenantId, List 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 movingIds = movingHotWords.stream().map(HotWord::getId).collect(Collectors.toSet()); + long currentCount = this.count(new LambdaQueryWrapper() + .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> matrix, int index, String current, List result) { if (index == matrix.size()) { result.add(current.trim()); diff --git a/frontend/src/api/business/hotword.ts b/frontend/src/api/business/hotword.ts index 008c108..02bdf27 100644 --- a/frontend/src/api/business/hotword.ts +++ b/frontend/src/api/business/hotword.ts @@ -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}` diff --git a/frontend/src/pages/business/HotWords.css b/frontend/src/pages/business/HotWords.css index 74af946..9a2dabd 100644 --- a/frontend/src/pages/business/HotWords.css +++ b/frontend/src/pages/business/HotWords.css @@ -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, diff --git a/frontend/src/pages/business/HotWords.tsx b/frontend/src/pages/business/HotWords.tsx index f2537f5..3311030 100644 --- a/frontend/src/pages/business/HotWords.tsx +++ b/frontend/src/pages/business/HotWords.tsx @@ -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(); const [groupForm] = Form.useForm(); + const [bulkGroupForm] = Form.useForm(); 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(undefined); const [searchGroupId, setSearchGroupId] = useState(undefined); + const [hotWordGroupFilter, setHotWordGroupFilter] = useState("all"); const [modalVisible, setModalVisible] = useState(false); const [editingId, setEditingId] = useState(null); @@ -111,15 +131,23 @@ const HotWords: React.FC = () => { const [groupSearchStatus, setGroupSearchStatus] = useState(undefined); const [editingGroupId, setEditingGroupId] = useState(null); const [selectedGroupName, setSelectedGroupName] = useState(undefined); + const [selectedHotWordIds, setSelectedHotWordIds] = useState([]); + const [bulkGroupEditorVisible, setBulkGroupEditorVisible] = useState(false); + const [bulkSubmitLoading, setBulkSubmitLoading] = useState(false); const groupNameMap = useMemo( () => Object.fromEntries(groupOptions.map((item) => [item.id, item.groupName])) as Record, [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 = () => { {hotWordGroupTitle}} + leftActions={ + + {hotWordGroupTitle} + + + -