更新关键词管理模块:新增素材选择功能,优化回复类型切换逻辑,提升用户体验和代码可读性。

This commit is contained in:
超级老白兔
2025-09-29 11:05:52 +08:00
parent e38b0fde51
commit f5480bdc58
8 changed files with 720 additions and 24 deletions

View File

@@ -0,0 +1,10 @@
import request from "@/api/request";
// 获取群组列表
export function getGroupList(params: {
page: number;
limit: number;
keyword?: string;
}) {
return request("/v1/kefu/content/material/list", params, "GET");
}

View File

@@ -0,0 +1,27 @@
export interface GroupSelectionItem {
id: string;
title: string;
cover?: string;
status: number;
[key: string]: any;
}
// 组件属性接口
export interface GroupSelectionProps {
selectedOptions: GroupSelectionItem[];
onSelect: (groups: GroupSelectionItem[]) => void;
onSelectDetail?: (groups: GroupSelectionItem[]) => void;
placeholder?: string;
className?: string;
visible?: boolean;
onVisibleChange?: (visible: boolean) => void;
selectedListMaxHeight?: number;
showInput?: boolean;
showSelectedList?: boolean;
readonly?: boolean;
selectionMode?: "multiple" | "single"; // 新增:选择模式,默认为多选
onConfirm?: (
selectedIds: string[],
selectedItems: GroupSelectionItem[],
) => void;
}

View File

@@ -0,0 +1,206 @@
.inputWrapper {
position: relative;
}
.inputIcon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: #bdbdbd;
font-size: 20px;
}
.input {
padding-left: 38px !important;
height: 48px;
border-radius: 16px !important;
border: 1px solid #e5e6eb !important;
font-size: 16px;
background: #f8f9fa;
}
.selectedListRow {
padding: 8px;
border-bottom: 1px solid #f0f0f0;
font-size: 14px;
}
.selectedListRowContent {
flex: 1;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.selectedListRowContentText {
flex: 1;
}
.popupContainer {
display: flex;
flex-direction: column;
height: 100vh;
background: #fff;
}
.popupHeader {
padding: 24px;
}
.popupTitle {
text-align: center;
font-size: 20px;
font-weight: 600;
margin-bottom: 24px;
}
.searchWrapper {
position: relative;
margin-bottom: 16px;
}
.searchInput {
padding-left: 40px !important;
padding-top: 8px !important;
padding-bottom: 8px !important;
border-radius: 24px !important;
border: 1px solid #e5e6eb !important;
font-size: 15px;
background: #f8f9fa;
}
.searchIcon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: #bdbdbd;
font-size: 16px;
}
.clearBtn {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
height: 24px;
width: 24px;
border-radius: 50%;
min-width: 24px;
}
.groupList {
flex: 1;
overflow-y: auto;
}
.groupListInner {
border-top: 1px solid #f0f0f0;
}
.groupItem {
display: flex;
align-items: center;
padding: 16px 24px;
border-bottom: 1px solid #f0f0f0;
transition: background 0.2s;
&:hover {
background: #f5f6fa;
}
}
.groupInfo {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
}
.groupAvatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 14px;
font-weight: 500;
overflow: hidden;
}
.avatarImg {
width: 100%;
height: 100%;
object-fit: cover;
}
.groupDetail {
flex: 1;
}
.groupName {
font-weight: 500;
font-size: 16px;
color: #222;
margin-bottom: 2px;
}
.groupId {
font-size: 13px;
color: #888;
margin-bottom: 2px;
}
.groupOwner {
font-size: 13px;
color: #bdbdbd;
}
.loadingBox {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.loadingText {
color: #888;
font-size: 15px;
}
.emptyBox {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.emptyText {
color: #888;
font-size: 15px;
}
.paginationRow {
border-top: 1px solid #f0f0f0;
padding: 16px;
display: flex;
align-items: center;
justify-content: space-between;
background: #fff;
}
.totalCount {
font-size: 14px;
color: #888;
}
.paginationControls {
display: flex;
align-items: center;
gap: 8px;
}
.pageBtn {
padding: 0 8px;
height: 32px;
min-width: 32px;
}
.pageInfo {
font-size: 14px;
color: #222;
}
.popupFooter {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-top: 1px solid #f0f0f0;
background: #fff;
}
.selectedCount {
font-size: 14px;
color: #888;
}
.footerBtnGroup {
display: flex;
gap: 12px;
}

View File

@@ -0,0 +1,138 @@
import React, { useState } from "react";
import { SearchOutlined, DeleteOutlined } from "@ant-design/icons";
import { Button, Input } from "antd";
import { Avatar } from "antd-mobile";
import style from "./index.module.scss";
import SelectionPopup from "./selectionPopup";
import { GroupSelectionProps } from "./data";
export default function GroupSelection({
selectedOptions,
onSelect,
onSelectDetail,
placeholder = "选择素材",
className = "",
visible,
onVisibleChange,
selectedListMaxHeight = 300,
showInput = true,
showSelectedList = true,
readonly = false,
selectionMode = "single", // 默认为多选模式
onConfirm,
}: GroupSelectionProps) {
const [popupVisible, setPopupVisible] = useState(false);
// 删除已选素材
const handleRemoveGroup = (id: string) => {
if (readonly) return;
onSelect(selectedOptions.filter(g => g.id !== id));
};
// 受控弹窗逻辑
const realVisible = visible !== undefined ? visible : popupVisible;
const setRealVisible = (v: boolean) => {
if (onVisibleChange) onVisibleChange(v);
if (visible === undefined) setPopupVisible(v);
};
// 打开弹窗
const openPopup = () => {
if (readonly) return;
setRealVisible(true);
};
// 清空已选择的素材
const handleClear = () => {
if (readonly) return;
onSelect([]);
};
// 获取显示文本
const getDisplayText = () => {
if (selectedOptions.length === 0) return "";
if (selectionMode === "single") {
return selectedOptions[0]?.title || "已选择素材";
}
return `已选择 ${selectedOptions.length} 个素材`;
};
return (
<>
{/* 输入框 */}
{showInput && (
<div className={`${style.inputWrapper} ${className}`}>
<Input
placeholder={placeholder}
value={getDisplayText()}
onClick={openPopup}
prefix={<SearchOutlined />}
allowClear={!readonly && selectedOptions.length > 0}
onClear={handleClear}
size="large"
readOnly={readonly}
disabled={readonly}
style={
readonly ? { background: "#f5f5f5", cursor: "not-allowed" } : {}
}
/>
</div>
)}
{/* 已选素材列表窗口 */}
{showSelectedList && selectedOptions.length > 0 && (
<div
className={style.selectedListWindow}
style={{
maxHeight: selectedListMaxHeight,
overflowY: "auto",
marginTop: 8,
border: "1px solid #e5e6eb",
borderRadius: 8,
background: "#fff",
}}
>
{selectedOptions.map(group => (
<div key={group.id} className={style.selectedListRow}>
<div className={style.selectedListRowContent}>
<Avatar src={group.cover} />
<div className={style.selectedListRowContentText}>
<div>{group.title}</div>
<div>ID: {group.id}</div>
</div>
{!readonly && (
<Button
type="text"
icon={<DeleteOutlined />}
size="small"
style={{
marginLeft: 4,
color: "#ff4d4f",
border: "none",
background: "none",
minWidth: 24,
height: 24,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
onClick={() => handleRemoveGroup(group.id)}
/>
)}
</div>
</div>
))}
</div>
)}
{/* 弹窗 */}
<SelectionPopup
visible={realVisible}
onVisibleChange={setRealVisible}
selectedOptions={selectedOptions}
onSelect={onSelect}
onSelectDetail={onSelectDetail}
readonly={readonly}
selectionMode={selectionMode}
onConfirm={onConfirm}
/>
</>
);
}

View File

@@ -0,0 +1,257 @@
import React, { useState, useEffect } from "react";
import { Popup, Checkbox, Radio } from "antd-mobile";
import { getGroupList } from "./api";
import style from "./index.module.scss";
import Layout from "@/components/Layout/Layout";
import PopupHeader from "@/components/PopuLayout/header";
import PopupFooter from "@/components/PopuLayout/footer";
import { GroupSelectionItem } from "./data";
// 弹窗属性接口
interface SelectionPopupProps {
visible: boolean;
onVisibleChange: (visible: boolean) => void;
selectedOptions: GroupSelectionItem[];
onSelect: (groups: GroupSelectionItem[]) => void;
onSelectDetail?: (groups: GroupSelectionItem[]) => void;
readonly?: boolean;
selectionMode?: "multiple" | "single"; // 新增:选择模式,默认为多选
onConfirm?: (
selectedIds: string[],
selectedItems: GroupSelectionItem[],
) => void;
}
export default function SelectionPopup({
visible,
onVisibleChange,
selectedOptions,
onSelect,
onSelectDetail,
readonly = false,
selectionMode = "multiple", // 默认为多选模式
onConfirm,
}: SelectionPopupProps) {
const [groups, setGroups] = useState<GroupSelectionItem[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalGroups, setTotalGroups] = useState(0);
const [loading, setLoading] = useState(false);
const [tempSelectedOptions, setTempSelectedOptions] = useState<
GroupSelectionItem[]
>([]);
// 获取素材列表API
const fetchGroups = async (page: number, keyword: string = "") => {
setLoading(true);
try {
const params: any = {
page,
limit: 20,
};
if (keyword.trim()) {
params.keyword = keyword.trim();
}
const response = await getGroupList(params);
if (response && response.list) {
setGroups(response.list);
setTotalGroups(response.total || 0);
setTotalPages(Math.ceil((response.total || 0) / 20));
}
} catch (error) {
console.error("获取素材列表失败:", error);
} finally {
setLoading(false);
}
};
// 处理素材选择
const handleGroupToggle = (group: GroupSelectionItem) => {
if (readonly) return;
if (selectionMode === "single") {
// 单选模式:直接设置为当前选中的项
setTempSelectedOptions([group]);
} else {
// 多选模式:切换选中状态
const newSelectedGroups = tempSelectedOptions.some(g => g.id === group.id)
? tempSelectedOptions.filter(g => g.id !== group.id)
: tempSelectedOptions.concat(group);
setTempSelectedOptions(newSelectedGroups);
}
};
// 全选当前页(仅在多选模式下有效)
const handleSelectAllCurrentPage = (checked: boolean) => {
if (readonly || selectionMode === "single") return;
if (checked) {
// 全选:添加当前页面所有未选中的素材
const currentPageGroups = groups.filter(
group => !tempSelectedOptions.some(g => g.id === group.id),
);
setTempSelectedOptions(prev => [...prev, ...currentPageGroups]);
} else {
// 取消全选:移除当前页面的所有素材
const currentPageGroupIds = groups.map(g => g.id);
setTempSelectedOptions(prev =>
prev.filter(g => !currentPageGroupIds.includes(g.id)),
);
}
};
// 检查当前页是否全选(仅在多选模式下有效)
const isCurrentPageAllSelected =
selectionMode === "multiple" &&
groups.length > 0 &&
groups.every(group => tempSelectedOptions.some(g => g.id === group.id));
// 确认选择
const handleConfirm = () => {
// 用户点击确认时才更新实际的selectedOptions
onSelect(tempSelectedOptions);
// 如果有 onSelectDetail 回调,传递完整的素材对象
if (onSelectDetail) {
const selectedGroupObjs = groups.filter(group =>
tempSelectedOptions.some(g => g.id === group.id),
);
onSelectDetail(selectedGroupObjs);
}
if (onConfirm) {
onConfirm(
tempSelectedOptions.map(g => g.id),
tempSelectedOptions,
);
}
onVisibleChange(false);
};
// 弹窗打开时初始化数据(只执行一次)
useEffect(() => {
if (visible) {
setCurrentPage(1);
setSearchQuery("");
// 复制一份selectedOptions到临时变量
setTempSelectedOptions([...selectedOptions]);
fetchGroups(1, "");
}
}, [visible]);
// 搜索防抖(只在弹窗打开且搜索词变化时执行)
useEffect(() => {
if (!visible || searchQuery === "") return;
const timer = setTimeout(() => {
setCurrentPage(1);
fetchGroups(1, searchQuery);
}, 500);
return () => clearTimeout(timer);
}, [searchQuery, visible]);
// 页码变化时请求数据只在弹窗打开且页码不是1时执行
useEffect(() => {
if (!visible) return;
fetchGroups(currentPage, searchQuery);
}, [currentPage, visible, searchQuery]);
return (
<Popup
visible={visible && !readonly}
onMaskClick={() => onVisibleChange(false)}
position="bottom"
bodyStyle={{ height: "100vh" }}
>
<Layout
header={
<PopupHeader
title="选择素材"
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
searchPlaceholder="搜索素材"
loading={loading}
onRefresh={() => fetchGroups(currentPage, searchQuery)}
/>
}
footer={
<PopupFooter
currentPage={currentPage}
totalPages={totalPages}
loading={loading}
selectedCount={tempSelectedOptions.length}
onPageChange={setCurrentPage}
onCancel={() => onVisibleChange(false)}
onConfirm={handleConfirm}
isAllSelected={isCurrentPageAllSelected}
onSelectAll={handleSelectAllCurrentPage}
showSelectAll={selectionMode === "multiple"} // 只在多选模式下显示全选功能
/>
}
>
<div className={style.groupList}>
{loading ? (
<div className={style.loadingBox}>
<div className={style.loadingText}>...</div>
</div>
) : groups.length > 0 ? (
<div className={style.groupListInner}>
{groups.map(group => (
<div key={group.id} className={style.groupItem}>
{selectionMode === "single" ? (
<Radio
checked={tempSelectedOptions.some(g => g.id === group.id)}
onChange={() => !readonly && handleGroupToggle(group)}
disabled={readonly}
style={{ marginRight: 12 }}
/>
) : (
<Checkbox
checked={tempSelectedOptions.some(g => g.id === group.id)}
onChange={() => !readonly && handleGroupToggle(group)}
disabled={readonly}
style={{ marginRight: 12 }}
/>
)}
<div className={style.groupInfo}>
<div className={style.groupAvatar}>
{group.cover ? (
<img
src={group.cover}
alt={group.title}
className={style.avatarImg}
/>
) : (
group.title.charAt(0)
)}
</div>
<div className={style.groupDetail}>
<div className={style.groupName}>{group.title}</div>
<div className={style.groupOwner}>
: {group.userName}
</div>
</div>
</div>
</div>
))}
</div>
) : (
<div className={style.emptyBox}>
<div className={style.emptyText}>
{searchQuery
? `没有找到包含"${searchQuery}"的素材`
: "没有找到素材"}
</div>
</div>
)}
</div>
</Layout>
</Popup>
);
}

View File

@@ -14,6 +14,7 @@ interface PopupFooterProps {
// 全选功能相关
isAllSelected?: boolean;
onSelectAll?: (checked: boolean) => void;
showSelectAll?: boolean; // 新增控制全选功能显示默认为true
}
const PopupFooter: React.FC<PopupFooterProps> = ({
@@ -26,19 +27,22 @@ const PopupFooter: React.FC<PopupFooterProps> = ({
onConfirm,
isAllSelected = false,
onSelectAll,
showSelectAll = true, // 默认为true显示全选功能
}) => {
return (
<>
{/* 分页栏 */}
<div className={style.paginationRow}>
<div className={style.totalCount}>
{showSelectAll && (
<Checkbox
checked={isAllSelected}
onChange={e => onSelectAll(e.target.checked)}
onChange={e => onSelectAll?.(e.target.checked)}
className={style.selectAllCheckbox}
>
</Checkbox>
)}
</div>
<div className={style.paginationControls}>
<Button

View File

@@ -136,10 +136,11 @@ export interface KeywordAddRequest {
level: number; // 优先级
replyType: number; // 回复类型:文本回复、模板回复
status: string;
materialId: number;
}
export interface KeywordUpdateRequest extends KeywordAddRequest {
id?: string;
id?: number;
}
export interface KeywordSetStatusRequest {

View File

@@ -7,7 +7,7 @@ import {
type KeywordAddRequest,
type KeywordUpdateRequest,
} from "../../api";
import MetailSelection from "@/components/MetailSelection";
const { TextArea } = Input;
const { Option } = Select;
@@ -44,6 +44,7 @@ const KeywordModal: React.FC<KeywordModalProps> = ({
level: keyword.level,
replyType: keyword.replyType,
status: keyword.status,
materialId: keyword.materialId,
});
}
} catch (error) {
@@ -63,6 +64,7 @@ const KeywordModal: React.FC<KeywordModalProps> = ({
} else if (mode === "add") {
// 添加模式:重置表单
form.resetFields();
setSelectedOptions([]);
}
}
}, [visible, mode, keywordId, fetchKeywordDetails, form]);
@@ -80,6 +82,7 @@ const KeywordModal: React.FC<KeywordModalProps> = ({
level: values.level,
replyType: values.replyType,
status: values.status || "1",
materialId: values.materialId,
};
const response = await addKeyword(data);
@@ -101,6 +104,7 @@ const KeywordModal: React.FC<KeywordModalProps> = ({
level: values.level,
replyType: values.replyType,
status: values.status,
materialId: values.materialId,
};
const response = await updateKeyword(data);
@@ -123,11 +127,44 @@ const KeywordModal: React.FC<KeywordModalProps> = ({
const handleCancel = () => {
form.resetFields();
setSelectedOptions([]);
onCancel();
};
const title = mode === "add" ? "添加关键词回复" : "编辑关键词回复";
const [selectedOptions, setSelectedOptions] = useState<any[]>([]);
const handSelectMaterial = (options: any[]) => {
if (options.length === 0) {
form.setFieldsValue({
materialId: null,
});
} else {
// 在单选模式下只取第一个选项的ID
form.setFieldsValue({
materialId: options[0].id,
});
}
setSelectedOptions(options);
};
// 监听表单值变化
const handleFormValuesChange = (changedValues: any) => {
// 当回复类型切换时,清空素材选择
if (changedValues.replyType !== undefined) {
setSelectedOptions([]);
if (changedValues.replyType === 1) {
// 切换到自定义回复时清空materialId
form.setFieldsValue({
materialId: null,
});
} else {
// 切换到素材回复时清空content
form.setFieldsValue({
content: null,
});
}
}
};
return (
<Modal
title={title}
@@ -140,6 +177,7 @@ const KeywordModal: React.FC<KeywordModalProps> = ({
form={form}
layout="vertical"
onFinish={handleSubmit}
onValuesChange={handleFormValuesChange}
initialValues={{
status: 1,
type: "模糊匹配",
@@ -163,6 +201,18 @@ const KeywordModal: React.FC<KeywordModalProps> = ({
<Input placeholder="请输入关键词" />
</Form.Item>
<Form.Item
name="replyType"
label="回复类型"
rules={[{ required: true, message: "请选择回复类型" }]}
>
<Select placeholder="请选择回复类型">
<Option value={0}></Option>
<Option value={1}></Option>
</Select>
</Form.Item>
{form.getFieldValue("replyType") === 1 ? (
<Form.Item
name="content"
label="回复内容"
@@ -170,6 +220,20 @@ const KeywordModal: React.FC<KeywordModalProps> = ({
>
<TextArea rows={4} placeholder="请输入回复内容" />
</Form.Item>
) : (
<Form.Item
name="materialId"
label="回复内容"
rules={[{ required: true, message: "请输入回复内容" }]}
>
<MetailSelection
selectedOptions={selectedOptions}
onSelect={handSelectMaterial}
selectionMode="single"
placeholder="选择素材"
/>
</Form.Item>
)}
<Form.Item
name="type"
@@ -194,17 +258,6 @@ const KeywordModal: React.FC<KeywordModalProps> = ({
</Select>
</Form.Item>
<Form.Item
name="replyType"
label="回复类型"
rules={[{ required: true, message: "请选择回复类型" }]}
>
<Select placeholder="请选择回复类型">
<Option value={0}></Option>
<Option value={1}></Option>
</Select>
</Form.Item>
<Form.Item
name="status"
label="状态"