Merge branch 'yongpxu-dev' of https://gitee.com/cunkebao/cunkebao_v3 into yongpxu-dev
This commit is contained in:
@@ -72,25 +72,83 @@ const FileUpload: React.FC<FileUploadProps> = ({
|
|||||||
name: "PPT文件",
|
name: "PPT文件",
|
||||||
extensions: ["pptx", "ppt"],
|
extensions: ["pptx", "ppt"],
|
||||||
},
|
},
|
||||||
|
pdf: {
|
||||||
|
accept: ".pdf",
|
||||||
|
mimeTypes: ["application/pdf"],
|
||||||
|
icon: FileOutlined,
|
||||||
|
name: "PDF文件",
|
||||||
|
extensions: ["pdf"],
|
||||||
|
},
|
||||||
|
txt: {
|
||||||
|
accept: ".txt",
|
||||||
|
mimeTypes: ["text/plain"],
|
||||||
|
icon: FileOutlined,
|
||||||
|
name: "文本文件",
|
||||||
|
extensions: ["txt"],
|
||||||
|
},
|
||||||
|
md: {
|
||||||
|
accept: ".md",
|
||||||
|
mimeTypes: ["text/markdown"],
|
||||||
|
icon: FileOutlined,
|
||||||
|
name: "Markdown文件",
|
||||||
|
extensions: ["md"],
|
||||||
|
},
|
||||||
|
mp4: {
|
||||||
|
accept: ".mp4",
|
||||||
|
mimeTypes: ["video/mp4"],
|
||||||
|
icon: FileOutlined,
|
||||||
|
name: "MP4视频",
|
||||||
|
extensions: ["mp4"],
|
||||||
|
},
|
||||||
|
avi: {
|
||||||
|
accept: ".avi",
|
||||||
|
mimeTypes: ["video/x-msvideo"],
|
||||||
|
icon: FileOutlined,
|
||||||
|
name: "AVI视频",
|
||||||
|
extensions: ["avi"],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// 生成accept字符串
|
// 生成accept字符串
|
||||||
const generateAcceptString = () => {
|
const generateAcceptString = () => {
|
||||||
return acceptTypes
|
const accepts: string[] = [];
|
||||||
.map(type => fileTypeConfig[type as keyof typeof fileTypeConfig]?.accept)
|
|
||||||
.filter(Boolean)
|
for (const type of acceptTypes) {
|
||||||
.join(",");
|
// 如果是配置中的类型键(如 "word", "pdf")
|
||||||
|
const config = fileTypeConfig[type as keyof typeof fileTypeConfig];
|
||||||
|
if (config) {
|
||||||
|
accepts.push(config.accept);
|
||||||
|
} else {
|
||||||
|
// 如果是扩展名(如 "doc", "docx"),直接添加
|
||||||
|
accepts.push(`.${type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return accepts.filter(Boolean).join(",");
|
||||||
};
|
};
|
||||||
|
|
||||||
// 获取文件类型信息
|
// 获取文件类型信息
|
||||||
const getFileTypeInfo = (file: File) => {
|
const getFileTypeInfo = (file: File) => {
|
||||||
const extension = file.name.split(".").pop()?.toLowerCase();
|
const extension = file.name.split(".").pop()?.toLowerCase();
|
||||||
|
if (!extension) return null;
|
||||||
|
|
||||||
|
// 首先尝试通过 acceptTypes 中指定的类型键来查找
|
||||||
for (const type of acceptTypes) {
|
for (const type of acceptTypes) {
|
||||||
const config = fileTypeConfig[type as keyof typeof fileTypeConfig];
|
const config = fileTypeConfig[type as keyof typeof fileTypeConfig];
|
||||||
if (config && config.extensions.includes(extension || "")) {
|
if (config && config.extensions.includes(extension)) {
|
||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 如果 acceptTypes 中包含扩展名本身(如 "doc", "docx"),查找所有包含该扩展名的配置
|
||||||
|
if (acceptTypes.includes(extension)) {
|
||||||
|
for (const [key, config] of Object.entries(fileTypeConfig)) {
|
||||||
|
if (config.extensions.includes(extension)) {
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -116,12 +174,29 @@ const FileUpload: React.FC<FileUploadProps> = ({
|
|||||||
}
|
}
|
||||||
}, [value]);
|
}, [value]);
|
||||||
|
|
||||||
|
// 获取类型名称
|
||||||
|
const getTypeName = (type: string) => {
|
||||||
|
const config = fileTypeConfig[type as keyof typeof fileTypeConfig];
|
||||||
|
if (config) return config.name;
|
||||||
|
// 如果是扩展名,返回友好的名称
|
||||||
|
const extensionNames: Record<string, string> = {
|
||||||
|
doc: "Word文件",
|
||||||
|
docx: "Word文件",
|
||||||
|
pdf: "PDF文件",
|
||||||
|
txt: "文本文件",
|
||||||
|
md: "Markdown文件",
|
||||||
|
mp4: "MP4视频",
|
||||||
|
avi: "AVI视频",
|
||||||
|
};
|
||||||
|
return extensionNames[type] || `${type.toUpperCase()}文件`;
|
||||||
|
};
|
||||||
|
|
||||||
// 文件验证
|
// 文件验证
|
||||||
const beforeUpload = (file: File) => {
|
const beforeUpload = (file: File) => {
|
||||||
const typeInfo = getFileTypeInfo(file);
|
const typeInfo = getFileTypeInfo(file);
|
||||||
if (!typeInfo) {
|
if (!typeInfo) {
|
||||||
const allowedTypes = acceptTypes
|
const allowedTypes = acceptTypes
|
||||||
.map(type => fileTypeConfig[type as keyof typeof fileTypeConfig]?.name)
|
.map(type => getTypeName(type))
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join("、");
|
.join("、");
|
||||||
message.error(`只能上传${allowedTypes}!`);
|
message.error(`只能上传${allowedTypes}!`);
|
||||||
@@ -310,10 +385,7 @@ const FileUpload: React.FC<FileUploadProps> = ({
|
|||||||
<div className={style.uploadSubtitle}>
|
<div className={style.uploadSubtitle}>
|
||||||
支持{" "}
|
支持{" "}
|
||||||
{acceptTypes
|
{acceptTypes
|
||||||
.map(
|
.map(type => getTypeName(type))
|
||||||
type =>
|
|
||||||
fileTypeConfig[type as keyof typeof fileTypeConfig]?.name,
|
|
||||||
)
|
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join("、")}
|
.join("、")}
|
||||||
,最大 {maxSize}MB
|
,最大 {maxSize}MB
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
.uploadButtonWrapper {
|
||||||
|
// 使用 :global() 包装 Ant Design 的全局类名
|
||||||
|
:global {
|
||||||
|
.ant-upload-select {
|
||||||
|
// 这里可以修改 .ant-upload-select 的样式
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
span {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
282
Cunkebao/src/components/Upload/FileUploadButton/index.tsx
Normal file
282
Cunkebao/src/components/Upload/FileUploadButton/index.tsx
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { Upload, message, Button } from "antd";
|
||||||
|
import {
|
||||||
|
LoadingOutlined,
|
||||||
|
CloudUploadOutlined,
|
||||||
|
FileExcelOutlined,
|
||||||
|
FileWordOutlined,
|
||||||
|
FilePptOutlined,
|
||||||
|
FileOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
|
import type { UploadProps } from "antd/es/upload/interface";
|
||||||
|
import style from "./index.module.scss";
|
||||||
|
|
||||||
|
export interface FileUploadResult {
|
||||||
|
fileName: string; // 文件名
|
||||||
|
fileUrl: string; // 文件URL
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileUploadProps {
|
||||||
|
onChange?: (result: FileUploadResult) => void; // 上传成功后的回调,返回文件名和URL
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
maxSize?: number; // 最大文件大小(MB)
|
||||||
|
acceptTypes?: string[]; // 接受的文件类型
|
||||||
|
buttonText?: string; // 按钮文本
|
||||||
|
buttonType?: "default" | "primary" | "dashed" | "text" | "link"; // 按钮类型
|
||||||
|
block?: boolean;
|
||||||
|
size?: "small" | "middle" | "large";
|
||||||
|
showSuccessMessage?: boolean; // 是否显示上传成功提示,默认不显示
|
||||||
|
}
|
||||||
|
|
||||||
|
const FileUpload: React.FC<FileUploadProps> = ({
|
||||||
|
onChange,
|
||||||
|
disabled = false,
|
||||||
|
className,
|
||||||
|
maxSize = 10,
|
||||||
|
acceptTypes = ["excel", "word", "ppt"],
|
||||||
|
buttonText = "上传文件",
|
||||||
|
buttonType = "primary",
|
||||||
|
block = false,
|
||||||
|
size = "middle",
|
||||||
|
showSuccessMessage = false,
|
||||||
|
}) => {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [fileName, setFileName] = useState<string>(""); // 保存文件名
|
||||||
|
|
||||||
|
// 文件类型配置
|
||||||
|
const fileTypeConfig = {
|
||||||
|
excel: {
|
||||||
|
accept: ".xlsx,.xls",
|
||||||
|
mimeTypes: [
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
"application/vnd.ms-excel",
|
||||||
|
],
|
||||||
|
icon: FileExcelOutlined,
|
||||||
|
name: "Excel文件",
|
||||||
|
extensions: ["xlsx", "xls"],
|
||||||
|
},
|
||||||
|
word: {
|
||||||
|
accept: ".docx,.doc",
|
||||||
|
mimeTypes: [
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
"application/msword",
|
||||||
|
],
|
||||||
|
icon: FileWordOutlined,
|
||||||
|
name: "Word文件",
|
||||||
|
extensions: ["docx", "doc"],
|
||||||
|
},
|
||||||
|
ppt: {
|
||||||
|
accept: ".pptx,.ppt",
|
||||||
|
mimeTypes: [
|
||||||
|
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||||
|
"application/vnd.ms-powerpoint",
|
||||||
|
],
|
||||||
|
icon: FilePptOutlined,
|
||||||
|
name: "PPT文件",
|
||||||
|
extensions: ["pptx", "ppt"],
|
||||||
|
},
|
||||||
|
pdf: {
|
||||||
|
accept: ".pdf",
|
||||||
|
mimeTypes: ["application/pdf"],
|
||||||
|
icon: FileOutlined,
|
||||||
|
name: "PDF文件",
|
||||||
|
extensions: ["pdf"],
|
||||||
|
},
|
||||||
|
txt: {
|
||||||
|
accept: ".txt",
|
||||||
|
mimeTypes: ["text/plain"],
|
||||||
|
icon: FileOutlined,
|
||||||
|
name: "文本文件",
|
||||||
|
extensions: ["txt"],
|
||||||
|
},
|
||||||
|
doc: {
|
||||||
|
accept: ".doc,.docx",
|
||||||
|
mimeTypes: [
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
"application/msword",
|
||||||
|
],
|
||||||
|
icon: FileWordOutlined,
|
||||||
|
name: "Word文件",
|
||||||
|
extensions: ["doc", "docx"],
|
||||||
|
},
|
||||||
|
docx: {
|
||||||
|
accept: ".docx,.doc",
|
||||||
|
mimeTypes: [
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
"application/msword",
|
||||||
|
],
|
||||||
|
icon: FileWordOutlined,
|
||||||
|
name: "Word文件",
|
||||||
|
extensions: ["docx", "doc"],
|
||||||
|
},
|
||||||
|
md: {
|
||||||
|
accept: ".md",
|
||||||
|
mimeTypes: ["text/markdown"],
|
||||||
|
icon: FileOutlined,
|
||||||
|
name: "Markdown文件",
|
||||||
|
extensions: ["md"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// 生成accept字符串
|
||||||
|
const generateAcceptString = () => {
|
||||||
|
const accepts: string[] = [];
|
||||||
|
|
||||||
|
for (const type of acceptTypes) {
|
||||||
|
// 如果是配置中的类型键(如 "word", "pdf")
|
||||||
|
const config = fileTypeConfig[type as keyof typeof fileTypeConfig];
|
||||||
|
if (config) {
|
||||||
|
accepts.push(config.accept);
|
||||||
|
} else {
|
||||||
|
// 如果是扩展名(如 "doc", "docx"),直接添加
|
||||||
|
accepts.push(`.${type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return accepts.filter(Boolean).join(",");
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取文件类型信息
|
||||||
|
const getFileTypeInfo = (file: File) => {
|
||||||
|
const extension = file.name.split(".").pop()?.toLowerCase();
|
||||||
|
if (!extension) return null;
|
||||||
|
|
||||||
|
// 首先尝试通过 acceptTypes 中指定的类型键来查找
|
||||||
|
for (const type of acceptTypes) {
|
||||||
|
const config = fileTypeConfig[type as keyof typeof fileTypeConfig];
|
||||||
|
if (config && config.extensions.includes(extension)) {
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果 acceptTypes 中包含扩展名本身(如 "doc", "docx"),查找所有包含该扩展名的配置
|
||||||
|
if (acceptTypes.includes(extension)) {
|
||||||
|
for (const [, config] of Object.entries(fileTypeConfig)) {
|
||||||
|
if (config.extensions.includes(extension)) {
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取类型名称
|
||||||
|
const getTypeName = (type: string) => {
|
||||||
|
const config = fileTypeConfig[type as keyof typeof fileTypeConfig];
|
||||||
|
if (config) return config.name;
|
||||||
|
// 如果是扩展名,返回友好的名称
|
||||||
|
const extensionNames: Record<string, string> = {
|
||||||
|
doc: "Word文件",
|
||||||
|
docx: "Word文件",
|
||||||
|
pdf: "PDF文件",
|
||||||
|
txt: "文本文件",
|
||||||
|
md: "Markdown文件",
|
||||||
|
};
|
||||||
|
return extensionNames[type] || `${type.toUpperCase()}文件`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 文件验证
|
||||||
|
const beforeUpload = (file: File) => {
|
||||||
|
// 保存文件名
|
||||||
|
setFileName(file.name);
|
||||||
|
|
||||||
|
const typeInfo = getFileTypeInfo(file);
|
||||||
|
if (!typeInfo) {
|
||||||
|
const allowedTypes = acceptTypes
|
||||||
|
.map(type => getTypeName(type))
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("、");
|
||||||
|
message.error(`只能上传${allowedTypes}!`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLtMaxSize = file.size / 1024 / 1024 < maxSize;
|
||||||
|
if (!isLtMaxSize) {
|
||||||
|
message.error(`文件大小不能超过${maxSize}MB!`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理文件变化
|
||||||
|
const handleChange: UploadProps["onChange"] = info => {
|
||||||
|
// 处理上传状态
|
||||||
|
if (info.file.status === "uploading") {
|
||||||
|
setLoading(true);
|
||||||
|
} else if (info.file.status === "done") {
|
||||||
|
setLoading(false);
|
||||||
|
if (showSuccessMessage) {
|
||||||
|
message.success("文件上传成功!");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从响应中获取上传后的URL
|
||||||
|
let uploadedUrl = "";
|
||||||
|
|
||||||
|
if (info.file.response) {
|
||||||
|
if (typeof info.file.response === "string") {
|
||||||
|
uploadedUrl = info.file.response;
|
||||||
|
} else if (info.file.response.data) {
|
||||||
|
uploadedUrl =
|
||||||
|
typeof info.file.response.data === "string"
|
||||||
|
? info.file.response.data
|
||||||
|
: info.file.response.data.url || "";
|
||||||
|
} else if (info.file.response.url) {
|
||||||
|
uploadedUrl = info.file.response.url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取文件名,优先使用保存的文件名,如果没有则使用文件对象的名称
|
||||||
|
const finalFileName = fileName || info.file.name || "";
|
||||||
|
|
||||||
|
if (uploadedUrl && finalFileName) {
|
||||||
|
onChange?.({
|
||||||
|
fileName: finalFileName,
|
||||||
|
fileUrl: uploadedUrl,
|
||||||
|
});
|
||||||
|
// 清空保存的文件名,为下次上传做准备
|
||||||
|
setFileName("");
|
||||||
|
}
|
||||||
|
} else if (info.file.status === "error") {
|
||||||
|
setLoading(false);
|
||||||
|
message.error("上传失败,请重试");
|
||||||
|
// 清空保存的文件名
|
||||||
|
setFileName("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const action = import.meta.env.VITE_API_BASE_URL + "/v1/attachment/upload";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={style.uploadButtonWrapper}>
|
||||||
|
<Upload
|
||||||
|
name="file"
|
||||||
|
headers={{
|
||||||
|
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||||
|
}}
|
||||||
|
action={action}
|
||||||
|
accept={generateAcceptString()}
|
||||||
|
showUploadList={false}
|
||||||
|
disabled={disabled || loading}
|
||||||
|
beforeUpload={beforeUpload}
|
||||||
|
onChange={handleChange}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type={buttonType}
|
||||||
|
icon={loading ? <LoadingOutlined /> : <CloudUploadOutlined />}
|
||||||
|
loading={loading}
|
||||||
|
disabled={disabled}
|
||||||
|
className={style.uploadButton}
|
||||||
|
block
|
||||||
|
size={size}
|
||||||
|
>
|
||||||
|
{buttonText}
|
||||||
|
</Button>
|
||||||
|
</Upload>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FileUpload;
|
||||||
46
Cunkebao/src/pages/mobile/mine/recharge/buy-power/api.ts
Normal file
46
Cunkebao/src/pages/mobile/mine/recharge/buy-power/api.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import request from "@/api/request";
|
||||||
|
|
||||||
|
// 算力包套餐类型(仅 buy-power 页面用到类型定义,须保留)
|
||||||
|
export interface PowerPackage {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
tokens: number; // 算力点数
|
||||||
|
price: number; // 价格(分)
|
||||||
|
originalPrice: number; // 原价(分)
|
||||||
|
unitPrice: number; // 单价
|
||||||
|
discount: number; // 折扣百分比
|
||||||
|
isTrial: number; // 是否试用套餐
|
||||||
|
isRecommend: number; // 是否推荐
|
||||||
|
isHot: number; // 是否热门
|
||||||
|
isVip: number; // 是否VIP
|
||||||
|
features: string[]; // 功能特性
|
||||||
|
status: number;
|
||||||
|
createTime: string;
|
||||||
|
updateTime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取套餐列表
|
||||||
|
export function getTaocanList(): Promise<{ list: PowerPackage[] }> {
|
||||||
|
return request("/v1/tokens/list", {}, "GET");
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BuyPackageParams {
|
||||||
|
/**
|
||||||
|
* 二选一
|
||||||
|
*/
|
||||||
|
id?: number;
|
||||||
|
/**
|
||||||
|
* 二选一 自定义购买金额
|
||||||
|
*/
|
||||||
|
price?: number;
|
||||||
|
[property: string]: any;
|
||||||
|
}
|
||||||
|
// 购买套餐
|
||||||
|
export function buyPackage(params: BuyPackageParams) {
|
||||||
|
return request("/v1/tokens/pay", params, "POST");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自定义购买算力
|
||||||
|
export function buyCustomPower(params: { amount: number }) {
|
||||||
|
return request("/v1/power/buy-custom", params, "POST");
|
||||||
|
}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
// 购买算力包页面样式
|
||||||
|
.buyPowerPage {
|
||||||
|
padding: 16px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding-bottom: 80px; // 为底部按钮留空间
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #222;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 套餐列表
|
||||||
|
.packageList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.packageCard {
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 2px solid #e5e5e5;
|
||||||
|
transition: all 0.3s;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.packageCardActive {
|
||||||
|
border-color: #1890ff;
|
||||||
|
background: linear-gradient(135deg, #e6f7ff 0%, #f0f5ff 100%);
|
||||||
|
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.packageHeader {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.packageTitle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.packageName {
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.packageTag {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-orange {
|
||||||
|
background: #fff7e6;
|
||||||
|
color: #fa8c16;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-blue {
|
||||||
|
background: #e6f7ff;
|
||||||
|
color: #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-green {
|
||||||
|
background: #f6ffed;
|
||||||
|
color: #52c41a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-purple {
|
||||||
|
background: #f9f0ff;
|
||||||
|
color: #722ed1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.packagePrice {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.currentPrice {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1890ff;
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.originalPrice {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #999;
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.packageTokens {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.packageMeta {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding: 10px 0;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unitPrice,
|
||||||
|
.discount {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #888;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unitPriceValue,
|
||||||
|
.discountValue {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #52c41a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.packageFeatures {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.featureItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.featureIcon {
|
||||||
|
color: #52c41a;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自定义购买卡片
|
||||||
|
.customCard {
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.customHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customIcon {
|
||||||
|
font-size: 20px;
|
||||||
|
color: #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customTitle {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customContent {
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customLabel {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 安全保障卡片
|
||||||
|
.securityCard {
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #f0fdf4;
|
||||||
|
border: 1px solid #bbf7d0;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.securityHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.securityIcon {
|
||||||
|
font-size: 20px;
|
||||||
|
color: #52c41a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.securityTitle {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #166534;
|
||||||
|
}
|
||||||
|
|
||||||
|
.securityList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.securityItem {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #166534;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 底部按钮
|
||||||
|
.footer {
|
||||||
|
padding: 16px;
|
||||||
|
background: #fff;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.buyButton {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
border: none;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: 12px;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
background: #d9d9d9;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
267
Cunkebao/src/pages/mobile/mine/recharge/buy-power/index.tsx
Normal file
267
Cunkebao/src/pages/mobile/mine/recharge/buy-power/index.tsx
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { Card, Button, Toast, Dialog } from "antd-mobile";
|
||||||
|
import { Input } from "antd";
|
||||||
|
import style from "./index.module.scss";
|
||||||
|
import {
|
||||||
|
ThunderboltOutlined,
|
||||||
|
CheckCircleOutlined,
|
||||||
|
SafetyOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
|
import NavCommon from "@/components/NavCommon";
|
||||||
|
import Layout from "@/components/Layout/Layout";
|
||||||
|
import { getTaocanList, buyPackage } from "./api";
|
||||||
|
import type { PowerPackage } from "./api";
|
||||||
|
|
||||||
|
const BuyPowerPage: React.FC = () => {
|
||||||
|
const [packages, setPackages] = useState<PowerPackage[]>([]);
|
||||||
|
const [selectedPackage, setSelectedPackage] = useState<PowerPackage | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [customAmount, setCustomAmount] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchPackages();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchPackages = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await getTaocanList();
|
||||||
|
setPackages(res.list || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("获取套餐列表失败:", error);
|
||||||
|
Toast.show({ content: "获取套餐列表失败", position: "top" });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPackageTag = (pkg: PowerPackage) => {
|
||||||
|
if (pkg.isTrial === 1) return { text: "限购一次", color: "orange" };
|
||||||
|
if (pkg.isRecommend === 1) return { text: "推荐", color: "blue" };
|
||||||
|
if (pkg.isHot === 1) return { text: "热门", color: "green" };
|
||||||
|
if (pkg.isVip === 1) return { text: "VIP", color: "purple" };
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectPackage = (pkg: PowerPackage) => {
|
||||||
|
setSelectedPackage(pkg);
|
||||||
|
setCustomAmount(""); // 清空自定义金额
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBuy = async () => {
|
||||||
|
if (!selectedPackage && !customAmount) {
|
||||||
|
Toast.show({ content: "请选择套餐或输入自定义金额", position: "top" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
let res;
|
||||||
|
|
||||||
|
if (customAmount) {
|
||||||
|
// 自定义购买
|
||||||
|
const amount = parseFloat(customAmount);
|
||||||
|
if (isNaN(amount) || amount < 1 || amount > 50000) {
|
||||||
|
Toast.show({ content: "请输入1-50000之间的金额", position: "top" });
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res = await buyPackage({ price: amount });
|
||||||
|
} else if (selectedPackage) {
|
||||||
|
// 套餐购买
|
||||||
|
res = await buyPackage({
|
||||||
|
id: selectedPackage.id,
|
||||||
|
price: selectedPackage.price,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res?.code_url) {
|
||||||
|
// 显示支付二维码
|
||||||
|
Dialog.show({
|
||||||
|
content: (
|
||||||
|
<div style={{ textAlign: "center", padding: "20px" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: "16px",
|
||||||
|
fontSize: "16px",
|
||||||
|
fontWeight: "500",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
请使用微信扫码支付
|
||||||
|
</div>
|
||||||
|
<img
|
||||||
|
src={res.code_url}
|
||||||
|
alt="支付二维码"
|
||||||
|
style={{ width: "250px", height: "250px", margin: "0 auto" }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{ marginTop: "16px", color: "#666", fontSize: "14px" }}
|
||||||
|
>
|
||||||
|
{selectedPackage
|
||||||
|
? `支付金额: ¥${selectedPackage.price / 100}`
|
||||||
|
: `支付金额: ¥${customAmount}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
closeOnMaskClick: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("购买失败:", error);
|
||||||
|
Toast.show({ content: "购买失败,请重试", position: "top" });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout
|
||||||
|
header={<NavCommon title="购买算力包" />}
|
||||||
|
footer={
|
||||||
|
<div className={style.footer}>
|
||||||
|
<Button
|
||||||
|
block
|
||||||
|
color="primary"
|
||||||
|
size="large"
|
||||||
|
className={style.buyButton}
|
||||||
|
loading={loading}
|
||||||
|
onClick={handleBuy}
|
||||||
|
disabled={!selectedPackage && !customAmount}
|
||||||
|
>
|
||||||
|
{selectedPackage
|
||||||
|
? `立即购买 ¥${selectedPackage.price / 100}`
|
||||||
|
: customAmount
|
||||||
|
? `立即购买 ¥${customAmount}`
|
||||||
|
: "请选择套餐"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={style.buyPowerPage}>
|
||||||
|
{/* 选择套餐标题 */}
|
||||||
|
<div className={style.sectionTitle}>选择套餐</div>
|
||||||
|
|
||||||
|
{/* 套餐列表 */}
|
||||||
|
<div className={style.packageList}>
|
||||||
|
{packages.map(pkg => {
|
||||||
|
const tag = getPackageTag(pkg);
|
||||||
|
const isSelected = selectedPackage?.id === pkg.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={pkg.id}
|
||||||
|
className={`${style.packageCard} ${isSelected ? style.packageCardActive : ""}`}
|
||||||
|
onClick={() => handleSelectPackage(pkg)}
|
||||||
|
>
|
||||||
|
{/* 套餐头部 */}
|
||||||
|
<div className={style.packageHeader}>
|
||||||
|
<div className={style.packageTitle}>
|
||||||
|
<span className={style.packageName}>{pkg.name}</span>
|
||||||
|
{tag && (
|
||||||
|
<span
|
||||||
|
className={`${style.packageTag} ${style[`tag-${tag.color}`]}`}
|
||||||
|
>
|
||||||
|
{tag.text}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={style.packagePrice}>
|
||||||
|
<span className={style.currentPrice}>
|
||||||
|
¥{pkg.price / 100}
|
||||||
|
</span>
|
||||||
|
{pkg.originalPrice && (
|
||||||
|
<span className={style.originalPrice}>
|
||||||
|
¥{pkg.originalPrice / 100}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 算力信息 */}
|
||||||
|
<div className={style.packageTokens}>
|
||||||
|
{pkg.tokens?.toLocaleString()} 算力点
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 单价和优惠 */}
|
||||||
|
<div className={style.packageMeta}>
|
||||||
|
<div className={style.unitPrice}>
|
||||||
|
单价
|
||||||
|
<br />
|
||||||
|
<span className={style.unitPriceValue}>
|
||||||
|
¥{(pkg.unitPrice / 100).toFixed(4)}/点
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{pkg.discount > 0 && (
|
||||||
|
<div className={style.discount}>
|
||||||
|
优惠幅度
|
||||||
|
<br />
|
||||||
|
<span className={style.discountValue}>
|
||||||
|
{pkg.discount}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 特性列表 */}
|
||||||
|
{pkg.features && pkg.features.length > 0 && (
|
||||||
|
<div className={style.packageFeatures}>
|
||||||
|
{pkg.features.map((feature, index) => (
|
||||||
|
<div key={index} className={style.featureItem}>
|
||||||
|
<CheckCircleOutlined className={style.featureIcon} />
|
||||||
|
{feature}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 自定义购买 */}
|
||||||
|
<Card className={style.customCard}>
|
||||||
|
<div className={style.customHeader}>
|
||||||
|
<ThunderboltOutlined className={style.customIcon} />
|
||||||
|
<span className={style.customTitle}>自定义购买</span>
|
||||||
|
</div>
|
||||||
|
<div className={style.customContent}>
|
||||||
|
<div className={style.customLabel}>自定义金额(1-50000元)</div>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="请输入金额"
|
||||||
|
value={customAmount}
|
||||||
|
onChange={e => {
|
||||||
|
setCustomAmount(e.target.value);
|
||||||
|
setSelectedPackage(null); // 清空套餐选择
|
||||||
|
}}
|
||||||
|
style={{ fontSize: 16 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 安全保障 */}
|
||||||
|
<Card className={style.securityCard}>
|
||||||
|
<div className={style.securityHeader}>
|
||||||
|
<SafetyOutlined className={style.securityIcon} />
|
||||||
|
<span className={style.securityTitle}>安全保障</span>
|
||||||
|
</div>
|
||||||
|
<div className={style.securityList}>
|
||||||
|
<div className={style.securityItem}>
|
||||||
|
• 所有算力永久有效,无使用期限
|
||||||
|
</div>
|
||||||
|
<div className={style.securityItem}>
|
||||||
|
• 支持微信支付、支付宝安全支付
|
||||||
|
</div>
|
||||||
|
<div className={style.securityItem}>
|
||||||
|
• 购买后立即到账,7x24小时客服支持
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BuyPowerPage;
|
||||||
@@ -1,35 +1,143 @@
|
|||||||
import request from "@/api/request";
|
import request from "@/api/request";
|
||||||
|
|
||||||
interface taocanItem {
|
export interface Statistics {
|
||||||
id: 1;
|
totalTokens: number; // 总算力
|
||||||
name: "试用套餐";
|
todayUsed: number; // 今日使用
|
||||||
tokens: "2,800";
|
monthUsed: number; // 本月使用
|
||||||
price: 9800;
|
remainingTokens: number; // 剩余算力
|
||||||
originalPrice: 140;
|
totalConsumed: number; // 总消耗
|
||||||
description: ["适合新用户体验", "包含基础AI功能", "永久有效", "客服支持"];
|
}
|
||||||
sort: 1;
|
// 算力统计接口
|
||||||
isTrial: 1;
|
export function getStatistics(): Promise<Statistics> {
|
||||||
isRecommend: 0;
|
return request("/v1/tokens/statistics", undefined, "GET");
|
||||||
isHot: 0;
|
|
||||||
isVip: 0;
|
|
||||||
status: 1;
|
|
||||||
isDel: 0;
|
|
||||||
delTime: null;
|
|
||||||
createTime: "2025-09-29 16:53:06";
|
|
||||||
updateTime: "2025-09-29 16:53:06";
|
|
||||||
discount: 30;
|
|
||||||
unitPrice: 3.5;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface taocanList {
|
// 算力包套餐类型
|
||||||
list: taocanItem[];
|
export interface PowerPackage {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
tokens: number; // 算力点数
|
||||||
|
price: number; // 价格(分)
|
||||||
|
originalPrice: number; // 原价(分)
|
||||||
|
unitPrice: number; // 单价
|
||||||
|
discount: number; // 折扣百分比
|
||||||
|
isTrial: number; // 是否试用套餐
|
||||||
|
isRecommend: number; // 是否推荐
|
||||||
|
isHot: number; // 是否热门
|
||||||
|
isVip: number; // 是否VIP
|
||||||
|
features: string[]; // 功能特性
|
||||||
|
status: number;
|
||||||
|
createTime: string;
|
||||||
|
updateTime: string;
|
||||||
}
|
}
|
||||||
// 套餐列表
|
|
||||||
export function getTaocanList(): Promise<taocanList> {
|
// 算力统计信息
|
||||||
|
export interface PowerStats {
|
||||||
|
balance: number; // 账户余额(元)
|
||||||
|
totalPower: number; // 总算力
|
||||||
|
todayUsed: number; // 今日使用
|
||||||
|
monthUsed: number; // 本月使用
|
||||||
|
remainingPower: number; // 剩余算力
|
||||||
|
}
|
||||||
|
|
||||||
|
// 消费记录类型
|
||||||
|
export interface ConsumptionRecord {
|
||||||
|
id: number;
|
||||||
|
type: string; // AI分析、内容生成等
|
||||||
|
status: string; // 已完成、进行中等
|
||||||
|
amount: number; // 消费金额(元)
|
||||||
|
power: number; // 消耗算力
|
||||||
|
description: string; // 描述
|
||||||
|
createTime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrderListParams {
|
||||||
|
/**
|
||||||
|
* 关键词搜索(订单号、商品名称)
|
||||||
|
*/
|
||||||
|
keyword?: string;
|
||||||
|
/**
|
||||||
|
* 每页数量(默认10)
|
||||||
|
*/
|
||||||
|
limit?: string;
|
||||||
|
/**
|
||||||
|
* 订单类型(1-算力充值)
|
||||||
|
*/
|
||||||
|
orderType?: string;
|
||||||
|
/**
|
||||||
|
* 页码
|
||||||
|
*/
|
||||||
|
page?: string;
|
||||||
|
/**
|
||||||
|
* 订单状态(0-待支付 1-已支付 2-已取消 3-已退款)
|
||||||
|
*/
|
||||||
|
status?: string;
|
||||||
|
[property: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OrderList {
|
||||||
|
id?: number;
|
||||||
|
mchId?: number;
|
||||||
|
companyId?: number;
|
||||||
|
userId?: number;
|
||||||
|
orderType?: number;
|
||||||
|
status?: number;
|
||||||
|
goodsId?: number;
|
||||||
|
goodsName?: string;
|
||||||
|
goodsSpecs?: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
price: number;
|
||||||
|
tokens: number;
|
||||||
|
};
|
||||||
|
money?: number;
|
||||||
|
orderNo?: string;
|
||||||
|
ip?: string;
|
||||||
|
nonceStr?: string;
|
||||||
|
createTime?: string;
|
||||||
|
payType?: number;
|
||||||
|
payTime?: string;
|
||||||
|
payInfo?: any;
|
||||||
|
deleteTime?: string;
|
||||||
|
tokens?: string;
|
||||||
|
statusText?: string;
|
||||||
|
orderTypeText?: string;
|
||||||
|
payTypeText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取订单列表
|
||||||
|
export function getOrderList(
|
||||||
|
params: OrderListParams,
|
||||||
|
): Promise<{ list: OrderList[]; total: number }> {
|
||||||
|
return request("/v1/tokens/orderList", params, "GET");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取算力统计
|
||||||
|
export function getPowerStats(): Promise<PowerStats> {
|
||||||
|
return request("/v1/power/stats", {}, "GET");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取套餐列表
|
||||||
|
export function getTaocanList(): Promise<{ list: PowerPackage[] }> {
|
||||||
return request("/v1/tokens/list", {}, "GET");
|
return request("/v1/tokens/list", {}, "GET");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 支付id和price 从套餐列表取对应的价格
|
// 获取消费记录
|
||||||
export function pay(params: { id: string; price: number }) {
|
export function getConsumptionRecords(params: {
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
type?: string;
|
||||||
|
status?: string;
|
||||||
|
}): Promise<{ list: ConsumptionRecord[]; total: number }> {
|
||||||
|
return request("/v1/power/consumption-records", params, "GET");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 购买套餐
|
||||||
|
export function buyPackage(params: { id: number; price: number }) {
|
||||||
return request("/v1/tokens/pay", params, "POST");
|
return request("/v1/tokens/pay", params, "POST");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 自定义购买算力
|
||||||
|
export function buyCustomPower(params: { amount: number }) {
|
||||||
|
return request("/v1/power/buy-custom", params, "POST");
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,448 +1,347 @@
|
|||||||
.recharge-page {
|
// 算力管理页面样式
|
||||||
|
.powerPage {
|
||||||
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.record-btn {
|
.powerTabs {
|
||||||
color: var(--primary-color);
|
:global {
|
||||||
font-size: 14px;
|
.adm-tabs-header {
|
||||||
font-weight: 500;
|
background: #fff;
|
||||||
cursor: pointer;
|
border-bottom: 1px solid #f0f0f0;
|
||||||
padding: 4px 8px;
|
}
|
||||||
border-radius: 4px;
|
|
||||||
transition: background-color 0.2s ease;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: rgba(24, 142, 238, 0.1);
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.refreshBtn {
|
||||||
|
font-size: 18px;
|
||||||
|
color: #666;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
|
||||||
&:active {
|
&:active {
|
||||||
background-color: rgba(24, 142, 238, 0.2);
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.recharge-tabs {
|
// ==================== 概览Tab ====================
|
||||||
:global(.adm-tabs-header) {
|
.overviewContent {
|
||||||
background: #fff;
|
|
||||||
border-bottom: 1px solid #f0f0f0;
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.adm-tabs-tab) {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.adm-tabs-tab-active) {
|
|
||||||
color: var(--primary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.adm-tabs-tab-line) {
|
|
||||||
background: var(--primary-color);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-content {
|
.accountCards {
|
||||||
}
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
.balance-card {
|
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
background: #f6ffed;
|
}
|
||||||
border: 1px solid #b7eb8f;
|
|
||||||
border-radius: 12px;
|
.balanceCard,
|
||||||
padding: 18px 0 18px 0;
|
.powerCard {
|
||||||
|
flex: 1;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balanceCard {
|
||||||
|
border: 1px solid #cbdbea;
|
||||||
|
background: #edfcff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.powerCard {
|
||||||
|
border: 1px solid #e3dbf0;
|
||||||
|
background: #f9f0ff;
|
||||||
|
}
|
||||||
|
.powerCard .iconWrapper {
|
||||||
|
background: #e9d9ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardContent {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
.balance-content {
|
gap: 12px;
|
||||||
display: flex;
|
|
||||||
color: #16b364;
|
|
||||||
padding-left: 30px;
|
|
||||||
}
|
|
||||||
.wallet-icon {
|
|
||||||
color: #16b364;
|
|
||||||
font-size: 30px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.balance-info {
|
|
||||||
margin-left: 15px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
.balance-label {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: normal;
|
|
||||||
color: #666;
|
|
||||||
margin-bottom: 2px;
|
|
||||||
}
|
|
||||||
.balance-amount {
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #16b364;
|
|
||||||
line-height: 1.1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.quick-card {
|
.iconWrapper {
|
||||||
margin-bottom: 16px;
|
width: 48px;
|
||||||
.quick-list {
|
height: 48px;
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 6px;
|
|
||||||
justify-content: flex-start;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.desc-card {
|
|
||||||
margin: 16px 0px;
|
|
||||||
background: #fffbe6;
|
|
||||||
border: 1px solid #ffe58f;
|
|
||||||
}
|
|
||||||
|
|
||||||
.warn-card {
|
|
||||||
margin: 16px 0;
|
|
||||||
background: #fff2e8;
|
|
||||||
border: 1px solid #ffbb96;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quick-title {
|
|
||||||
font-weight: 500;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
.quick-list {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
justify-content: flex-start;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
.quick-btn {
|
|
||||||
min-width: 80px;
|
|
||||||
margin: 4px 0;
|
|
||||||
font-size: 16px;
|
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 12px 16px;
|
|
||||||
|
|
||||||
div {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.quick-btn-active {
|
|
||||||
@extend .quick-btn;
|
|
||||||
font-weight: 600;
|
|
||||||
border: 2px solid var(--primary-color);
|
|
||||||
}
|
|
||||||
.recharge-main-btn {
|
|
||||||
margin-top: 16px;
|
|
||||||
font-size: 18px;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
.desc-title {
|
|
||||||
font-weight: 500;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
.desc-text {
|
|
||||||
color: #666;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
.warn-content {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
justify-content: center;
|
||||||
color: #faad14;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
.warn-icon {
|
|
||||||
font-size: 30px;
|
|
||||||
color: #faad14;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.warn-info {
|
|
||||||
display: flex;
|
.balanceCard .iconWrapper {
|
||||||
flex-direction: column;
|
background: #d6edff;
|
||||||
}
|
|
||||||
.warn-title {
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 15px;
|
|
||||||
}
|
|
||||||
.warn-text {
|
|
||||||
color: #faad14;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// AI服务样式
|
.cardIcon {
|
||||||
.ai-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ai-title {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #222;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ai-icon {
|
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
color: var(--primary-color);
|
|
||||||
margin-right: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-tag {
|
.balanceCard .cardIcon {
|
||||||
background: #ff6b35;
|
|
||||||
color: #fff;
|
|
||||||
font-size: 12px;
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ai-description {
|
|
||||||
color: #666;
|
|
||||||
font-size: 14px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ai-services {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ai-service-card {
|
|
||||||
border-radius: 12px;
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
.service-header {
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.service-info {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.service-icon {
|
|
||||||
font-size: 24px;
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: #f0f0f0;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.service-details {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.service-name {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #222;
|
|
||||||
}
|
|
||||||
|
|
||||||
.service-price {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #ff4d4f;
|
|
||||||
}
|
|
||||||
|
|
||||||
.service-description {
|
|
||||||
color: #666;
|
|
||||||
font-size: 14px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.service-features {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feature-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
font-size: 14px;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feature-check {
|
|
||||||
color: #52c41a;
|
|
||||||
font-weight: bold;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.usage-progress {
|
|
||||||
margin-top: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.usage-label {
|
|
||||||
font-size: 14px;
|
|
||||||
color: #666;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-bar {
|
|
||||||
width: 100%;
|
|
||||||
height: 6px;
|
|
||||||
background: #f0f0f0;
|
|
||||||
border-radius: 3px;
|
|
||||||
overflow: hidden;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-fill {
|
|
||||||
height: 100%;
|
|
||||||
background: var(--primary-color);
|
|
||||||
border-radius: 3px;
|
|
||||||
transition: width 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.usage-text {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #999;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 版本套餐样式
|
|
||||||
.version-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #222;
|
|
||||||
}
|
|
||||||
|
|
||||||
.version-icon {
|
|
||||||
font-size: 24px;
|
|
||||||
color: #722ed1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.version-description {
|
|
||||||
color: #666;
|
|
||||||
font-size: 14px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.version-packages {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.version-card {
|
|
||||||
border-radius: 12px;
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.package-header {
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.package-info {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.package-icon {
|
|
||||||
font-size: 24px;
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: #f0f0f0;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.package-details {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.package-name {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #222;
|
|
||||||
}
|
|
||||||
|
|
||||||
.package-tag {
|
|
||||||
font-size: 12px;
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 10px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-blue {
|
|
||||||
background: #e6f7ff;
|
|
||||||
color: #1890ff;
|
color: #1890ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tag-green {
|
.powerCard .cardIcon {
|
||||||
background: #f6ffed;
|
color: #722ed1;
|
||||||
color: #52c41a;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.package-price {
|
.textWrapper {
|
||||||
font-size: 18px;
|
flex: 1;
|
||||||
font-weight: 700;
|
display: flex;
|
||||||
color: var(--primary-color);
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.package-description {
|
.cardTitle {
|
||||||
|
font-size: 12px;
|
||||||
color: #666;
|
color: #666;
|
||||||
font-size: 14px;
|
margin-bottom: 4px;
|
||||||
margin-bottom: 12px;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.package-features {
|
.cardValue {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balanceCard .cardValue {
|
||||||
|
color: #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.powerCard .cardValue {
|
||||||
|
color: #722ed1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用情况卡片
|
||||||
|
.usageCard {
|
||||||
|
padding: 20px 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.usageTitle {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #222;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.features-title {
|
.usageStats {
|
||||||
font-size: 14px;
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usageItem {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usageValue {
|
||||||
|
font-size: 20px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #333;
|
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.package-status {
|
.valueGreen {
|
||||||
text-align: center;
|
|
||||||
color: #52c41a;
|
color: #52c41a;
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.upgrade-btn {
|
.valueBlue {
|
||||||
border-radius: 8px;
|
color: #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.valuePurple {
|
||||||
|
color: #722ed1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usageLabel {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 快速操作卡片
|
||||||
|
.actionCard {
|
||||||
|
padding: 20px 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionTitle {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
color: #222;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionButtons {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buyButton {
|
||||||
|
flex: 1;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonIcon {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordButton {
|
||||||
|
flex: 1;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e5e5e5;
|
||||||
|
color: #333;
|
||||||
|
font-size: 15px;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 消费记录Tab ====================
|
||||||
|
.recordsContent {
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filterButton {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e5e5e5;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #333;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordItem {
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||||
|
border: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordHeader {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordLeft {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordType {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordStatus {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordRight {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordAmount {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #ff4d4f;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordPower {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordDesc {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordTime {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyRecords {
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyIcon {
|
||||||
|
font-size: 64px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyText {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loadingContainer {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loadingText {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #999;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paginationWrap {
|
||||||
|
padding: 15px;
|
||||||
|
background: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,450 +1,366 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { Card, Button, Toast, Tabs, Dialog } from "antd-mobile";
|
import { Card, Button, Toast, Tabs, Tag, Picker } from "antd-mobile";
|
||||||
import style from "./index.module.scss";
|
import style from "./index.module.scss";
|
||||||
import {
|
import {
|
||||||
WalletOutlined,
|
SyncOutlined,
|
||||||
WarningOutlined,
|
ShoppingCartOutlined,
|
||||||
ClockCircleOutlined,
|
HistoryOutlined,
|
||||||
RobotOutlined,
|
LineChartOutlined,
|
||||||
CrownOutlined,
|
DownOutlined,
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import NavCommon from "@/components/NavCommon";
|
import NavCommon from "@/components/NavCommon";
|
||||||
import Layout from "@/components/Layout/Layout";
|
import Layout from "@/components/Layout/Layout";
|
||||||
import { getTaocanList, pay } from "./api";
|
import { getStatistics, getOrderList } from "./api";
|
||||||
|
import type { Statistics } from "./api";
|
||||||
|
import { Pagination } from "antd";
|
||||||
|
|
||||||
// AI服务列表数据
|
type OrderRecordView = {
|
||||||
const aiServices = [
|
id: number;
|
||||||
{
|
type: string;
|
||||||
id: 1,
|
status: string;
|
||||||
name: "添加好友及打招呼",
|
amount: number; // 元
|
||||||
icon: "💬",
|
power: number;
|
||||||
price: 1,
|
description: string;
|
||||||
description: "AI智能添加好友并发送个性化打招呼消息",
|
createTime: string;
|
||||||
features: ["智能筛选目标用户", "发送个性化打招呼消息", "自动记录添加结果"],
|
};
|
||||||
usage: { current: 15, total: 450 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
name: "小室AI内容生产",
|
|
||||||
icon: "⚡",
|
|
||||||
price: 1,
|
|
||||||
description: "AI智能创建朋友圈内容,智能配文与朋友圈内容",
|
|
||||||
features: ["智能生成朋友圈文案", "AI配文智能文案", "内容智能排版优化"],
|
|
||||||
usage: { current: 28, total: 680 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
name: "智能分发服务",
|
|
||||||
icon: "📤",
|
|
||||||
price: 1,
|
|
||||||
description: "AI智能分发内容到多个平台",
|
|
||||||
features: ["多平台智能分发", "内容智能优化", "分发效果分析"],
|
|
||||||
usage: { current: 12, total: 300 },
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// 版本套餐数据
|
const PowerManagement: React.FC = () => {
|
||||||
const versionPackages = [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
name: "普通版本",
|
|
||||||
icon: "📦",
|
|
||||||
price: "免费",
|
|
||||||
description: "充值即可使用,包含基础AI功能",
|
|
||||||
features: ["基础AI服务", "标准客服支持", "基础数据统计"],
|
|
||||||
status: "当前使用中",
|
|
||||||
buttonText: null,
|
|
||||||
tagColor: undefined,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
name: "标准版本",
|
|
||||||
icon: "👑",
|
|
||||||
price: "¥98/月",
|
|
||||||
tag: "推荐",
|
|
||||||
tagColor: "blue",
|
|
||||||
description: "适合中小企业,AI功能更丰富",
|
|
||||||
features: ["高级AI服务", "优先客服支持", "详细数据分析", "API接口访问"],
|
|
||||||
status: null,
|
|
||||||
buttonText: "立即升级",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
name: "企业版本",
|
|
||||||
icon: "🏢",
|
|
||||||
price: "¥1980/月",
|
|
||||||
description: "适合大型企业,提供专属服务",
|
|
||||||
features: [
|
|
||||||
"专属AI服务",
|
|
||||||
"24小时专属客服",
|
|
||||||
"高级数据分析",
|
|
||||||
"API接口访问",
|
|
||||||
"专属技术支持",
|
|
||||||
],
|
|
||||||
status: null,
|
|
||||||
buttonText: "立即升级",
|
|
||||||
tagColor: undefined,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const Recharge: React.FC = () => {
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
// 假设余额从后端接口获取,实际可用props或store传递
|
const [activeTab, setActiveTab] = useState("overview");
|
||||||
const [balance] = useState(0);
|
|
||||||
const [selected, setSelected] = useState<any | null>(null);
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [activeTab, setActiveTab] = useState("account");
|
const [stats, setStats] = useState<Statistics | null>(null);
|
||||||
const [taocanList, setTaocanList] = useState<any[]>([]);
|
const [records, setRecords] = useState<OrderRecordView[]>([]);
|
||||||
|
const [filterType, setFilterType] = useState<string>("all");
|
||||||
|
const [filterStatus, setFilterStatus] = useState<string>("all");
|
||||||
|
const [filterTypeVisible, setFilterTypeVisible] = useState(false);
|
||||||
|
const [filterStatusVisible, setFilterStatusVisible] = useState(false);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize] = useState(10);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
|
||||||
|
const typeOptions = [
|
||||||
|
{ label: "全部类型", value: "all" },
|
||||||
|
{ label: "AI分析", value: "ai_analysis" },
|
||||||
|
{ label: "内容生成", value: "content_gen" },
|
||||||
|
{ label: "数据训练", value: "data_train" },
|
||||||
|
{ label: "智能推荐", value: "smart_rec" },
|
||||||
|
{ label: "语音识别", value: "voice_rec" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ label: "全部状态", value: "all" },
|
||||||
|
{ label: "已完成", value: "completed" },
|
||||||
|
{ label: "进行中", value: "processing" },
|
||||||
|
{ label: "已取消", value: "cancelled" },
|
||||||
|
];
|
||||||
|
|
||||||
// 加载套餐列表
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadTaocanList = async () => {
|
fetchStats();
|
||||||
try {
|
|
||||||
const res = await getTaocanList();
|
|
||||||
if (res.list) {
|
|
||||||
setTaocanList(res.list);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("加载套餐列表失败:", error);
|
|
||||||
Toast.show({ content: "加载套餐列表失败", position: "top" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
loadTaocanList();
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 充值操作
|
useEffect(() => {
|
||||||
const handleRecharge = async () => {
|
if (activeTab === "records") {
|
||||||
if (!selected) {
|
setPage(1);
|
||||||
Toast.show({ content: "请选择充值套餐", position: "top" });
|
fetchRecords(1);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [activeTab, filterType, filterStatus]);
|
||||||
|
|
||||||
|
const fetchStats = async () => {
|
||||||
|
try {
|
||||||
|
const res = await getStatistics();
|
||||||
|
setStats(res);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("获取统计失败:", error);
|
||||||
|
Toast.show({ content: "获取数据失败", position: "top" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchRecords = async (customPage?: number) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await pay({
|
const reqPage = customPage !== undefined ? customPage : page;
|
||||||
id: selected.id,
|
// 映射状态到订单状态:0待支付 1已支付 2已取消 3已退款
|
||||||
price: selected.price,
|
const statusMap: Record<string, string | undefined> = {
|
||||||
|
all: undefined,
|
||||||
|
completed: "1",
|
||||||
|
processing: "0",
|
||||||
|
cancelled: "2",
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await getOrderList({
|
||||||
|
page: String(reqPage),
|
||||||
|
limit: String(pageSize),
|
||||||
|
orderType: "1",
|
||||||
|
status: statusMap[filterStatus],
|
||||||
});
|
});
|
||||||
// 假设返回的是二维码链接,存储在res中
|
|
||||||
if (res) {
|
const list = (res.list || []).map((o: any) => ({
|
||||||
// 显示二维码弹窗
|
id: o.id,
|
||||||
Dialog.show({
|
type: o.orderTypeText || o.goodsName || "充值订单",
|
||||||
content: (
|
status: o.statusText || "",
|
||||||
<div style={{ textAlign: "center", padding: "20px" }}>
|
amount: typeof o.money === "number" ? o.money / 100 : 0,
|
||||||
<div
|
power: Number(o.goodsSpecs?.tokens ?? o.tokens ?? 0),
|
||||||
style={{
|
description: o.goodsName || "",
|
||||||
marginBottom: "16px",
|
createTime: o.createTime || "",
|
||||||
fontSize: "16px",
|
}));
|
||||||
fontWeight: "500",
|
setRecords(list);
|
||||||
}}
|
setTotal(Number(res.total || 0));
|
||||||
>
|
|
||||||
请使用微信扫码支付
|
|
||||||
</div>
|
|
||||||
<img
|
|
||||||
src={res.code_url as any}
|
|
||||||
alt="支付二维码"
|
|
||||||
style={{ width: "250px", height: "250px", margin: "0 auto" }}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
style={{ marginTop: "16px", color: "#666", fontSize: "14px" }}
|
|
||||||
>
|
|
||||||
支付金额: ¥{selected.price / 100}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
closeOnMaskClick: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("支付失败:", error);
|
console.error("获取消费记录失败:", error);
|
||||||
Toast.show({ content: "支付失败,请重试", position: "top" });
|
Toast.show({ content: "获取消费记录失败", position: "top" });
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 渲染账户充值tab内容
|
const handleRefresh = () => {
|
||||||
const renderAccountRecharge = () => (
|
if (loading) return;
|
||||||
<div className={style["tab-content"]}>
|
fetchStats();
|
||||||
<Card className={style["balance-card"]}>
|
if (activeTab === "records") {
|
||||||
<div className={style["balance-content"]}>
|
fetchRecords();
|
||||||
<WalletOutlined className={style["wallet-icon"]} />
|
}
|
||||||
<div className={style["balance-info"]}>
|
};
|
||||||
<div className={style["balance-label"]}>当前余额</div>
|
|
||||||
<div className={style["balance-amount"]}>
|
const handleBuyPower = () => {
|
||||||
¥{balance.toFixed(2)}
|
navigate("/recharge/buy-power");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleViewRecords = () => {
|
||||||
|
navigate("/recharge/usage-records");
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTypeLabel = () => {
|
||||||
|
return (
|
||||||
|
typeOptions.find(opt => opt.value === filterType)?.label || "全部类型"
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusLabel = () => {
|
||||||
|
return (
|
||||||
|
statusOptions.find(opt => opt.value === filterStatus)?.label || "全部状态"
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 格式化数值:超过1000用k,超过10000用w,保留1位小数
|
||||||
|
const formatNumber = (value: number | undefined): string => {
|
||||||
|
if (value === undefined || value === null) return "0";
|
||||||
|
const num = Number(value);
|
||||||
|
if (isNaN(num)) return "0";
|
||||||
|
|
||||||
|
if (num >= 10000) {
|
||||||
|
const w = num / 10000;
|
||||||
|
return w % 1 === 0 ? `${w}w` : `${w.toFixed(1)}w`;
|
||||||
|
} else if (num >= 1000) {
|
||||||
|
const k = num / 1000;
|
||||||
|
return k % 1 === 0 ? `${k}k` : `${k.toFixed(1)}k`;
|
||||||
|
}
|
||||||
|
return String(num);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 渲染概览Tab
|
||||||
|
const renderOverview = () => (
|
||||||
|
<div className={style.overviewContent}>
|
||||||
|
{/* 账户信息卡片 */}
|
||||||
|
<div className={style.accountCards}>
|
||||||
|
<Card className={style.powerCard}>
|
||||||
|
<div className={style.cardContent}>
|
||||||
|
<div className={style.iconWrapper}>
|
||||||
|
<LineChartOutlined className={style.cardIcon} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className={style.textWrapper}>
|
||||||
</div>
|
<div className={style.cardTitle}>总算力</div>
|
||||||
</Card>
|
<div className={style.cardValue}>
|
||||||
<Card className={style["quick-card"]}>
|
{formatNumber(stats?.totalTokens)}
|
||||||
<div className={style["quick-title"]}>选择套餐</div>
|
|
||||||
<div className={style["quick-list"]}>
|
|
||||||
{taocanList.map(item => (
|
|
||||||
<Button
|
|
||||||
key={item.id}
|
|
||||||
color={selected?.id === item.id ? "primary" : "default"}
|
|
||||||
className={
|
|
||||||
selected?.id === item.id
|
|
||||||
? style["quick-btn-active"]
|
|
||||||
: style["quick-btn"]
|
|
||||||
}
|
|
||||||
onClick={() => setSelected(item)}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<div>¥{item.price / 100}</div>
|
|
||||||
{item.discount && (
|
|
||||||
<div style={{ fontSize: "12px", color: "#999" }}>
|
|
||||||
{item.discount}折
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{selected && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginBottom: "12px",
|
|
||||||
padding: "12px",
|
|
||||||
background: "#f5f5f5",
|
|
||||||
borderRadius: "8px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ marginBottom: "6px" }}>
|
|
||||||
<span style={{ fontWeight: "500" }}>{selected.name}</span>
|
|
||||||
{selected.isRecommend === 1 && (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
marginLeft: "8px",
|
|
||||||
fontSize: "12px",
|
|
||||||
color: "#1890ff",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
推荐
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{selected.isHot === 1 && (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
marginLeft: "8px",
|
|
||||||
fontSize: "12px",
|
|
||||||
color: "#ff4d4f",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
热门
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: "14px", color: "#666" }}>
|
|
||||||
包含 {selected.tokens} Tokens
|
|
||||||
</div>
|
|
||||||
{selected.originalPrice && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: "12px",
|
|
||||||
color: "#999",
|
|
||||||
textDecoration: "line-through",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
原价: ¥{selected.originalPrice / 100}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
block
|
|
||||||
color="primary"
|
|
||||||
size="large"
|
|
||||||
className={style["recharge-main-btn"]}
|
|
||||||
loading={loading}
|
|
||||||
onClick={handleRecharge}
|
|
||||||
>
|
|
||||||
立即充值
|
|
||||||
</Button>
|
|
||||||
</Card>
|
|
||||||
<Card className={style["desc-card"]}>
|
|
||||||
<div className={style["desc-title"]}>服务消耗</div>
|
|
||||||
<div className={style["desc-text"]}>
|
|
||||||
使用以下服务将从余额中扣除相应费用。
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
{balance < 10 && (
|
|
||||||
<Card className={style["warn-card"]}>
|
|
||||||
<div className={style["warn-content"]}>
|
|
||||||
<WarningOutlined className={style["warn-icon"]} />
|
|
||||||
<div className={style["warn-info"]}>
|
|
||||||
<div className={style["warn-title"]}>余额不足提醒</div>
|
|
||||||
<div className={style["warn-text"]}>
|
|
||||||
当前余额较低,建议及时充值以免影响服务使用
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
</div>
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
// 渲染AI服务tab内容
|
{/* 使用情况卡片 */}
|
||||||
const renderAiServices = () => (
|
<Card className={style.usageCard}>
|
||||||
<div className={style["tab-content"]}>
|
<div className={style.usageTitle}>使用情况</div>
|
||||||
<div className={style["ai-header"]}>
|
<div className={style.usageStats}>
|
||||||
<div className={style["ai-title"]}>
|
<div className={style.usageItem}>
|
||||||
<RobotOutlined className={style["ai-icon"]} />
|
<div className={`${style.usageValue} ${style.valueGreen}`}>
|
||||||
AI智能服务收费
|
{formatNumber(stats?.todayUsed)}
|
||||||
|
</div>
|
||||||
|
<div className={style.usageLabel}>今日使用</div>
|
||||||
|
</div>
|
||||||
|
<div className={style.usageItem}>
|
||||||
|
<div className={`${style.usageValue} ${style.valueBlue}`}>
|
||||||
|
{formatNumber(stats?.monthUsed)}
|
||||||
|
</div>
|
||||||
|
<div className={style.usageLabel}>本月使用</div>
|
||||||
|
</div>
|
||||||
|
<div className={style.usageItem}>
|
||||||
|
<div className={`${style.usageValue} ${style.valuePurple}`}>
|
||||||
|
{formatNumber(stats?.remainingTokens)}
|
||||||
|
</div>
|
||||||
|
<div className={style.usageLabel}>剩余算力</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={style["ai-tag"]}>统一按次收费</div>
|
</Card>
|
||||||
</div>
|
|
||||||
<div className={style["ai-description"]}>
|
|
||||||
三项核心AI服务,按使用次数收费,每次1元
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={style["ai-services"]}>
|
{/* 快速操作 */}
|
||||||
{aiServices.map(service => (
|
<Card className={style.actionCard}>
|
||||||
<Card key={service.id} className={style["ai-service-card"]}>
|
<div className={style.actionTitle}>快速操作</div>
|
||||||
<div className={style["service-header"]}>
|
<div className={style.actionButtons}>
|
||||||
<div className={style["service-info"]}>
|
<Button className={style.buyButton} onClick={handleBuyPower} block>
|
||||||
<div className={style["service-icon"]}>{service.icon}</div>
|
<ShoppingCartOutlined className={style.buttonIcon} />
|
||||||
<div className={style["service-details"]}>
|
购买算力包
|
||||||
<div className={style["service-name"]}>{service.name}</div>
|
</Button>
|
||||||
<div className={style["service-price"]}>
|
<Button
|
||||||
¥{service.price}/次
|
className={style.recordButton}
|
||||||
</div>
|
onClick={handleViewRecords}
|
||||||
</div>
|
block
|
||||||
</div>
|
>
|
||||||
</div>
|
<HistoryOutlined className={style.buttonIcon} />
|
||||||
<div className={style["service-description"]}>
|
使用记录
|
||||||
{service.description}
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className={style["service-features"]}>
|
</Card>
|
||||||
{service.features.map((feature, index) => (
|
|
||||||
<div key={index} className={style["feature-item"]}>
|
|
||||||
<span className={style["feature-check"]}>✓</span>
|
|
||||||
{feature}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className={style["usage-progress"]}>
|
|
||||||
<div className={style["usage-label"]}>今日使用进度</div>
|
|
||||||
<div className={style["progress-bar"]}>
|
|
||||||
<div
|
|
||||||
className={style["progress-fill"]}
|
|
||||||
style={{
|
|
||||||
width: `${(service.usage.current / service.usage.total) * 100}%`,
|
|
||||||
}}
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
<div className={style["usage-text"]}>
|
|
||||||
{service.usage.current} / {service.usage.total}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
// 渲染版本套餐tab内容
|
// 渲染消费记录Tab
|
||||||
const renderVersionPackages = () => (
|
const renderRecords = () => (
|
||||||
<div className={style["tab-content"]}>
|
<div className={style.recordsContent}>
|
||||||
<div className={style["version-header"]}>
|
{/* 筛选器 */}
|
||||||
<CrownOutlined className={style["version-icon"]} />
|
<div className={style.filters}>
|
||||||
<span>存客宝版本套餐</span>
|
<Picker
|
||||||
</div>
|
columns={[typeOptions]}
|
||||||
<div className={style["version-description"]}>
|
visible={filterTypeVisible}
|
||||||
选择适合的版本,享受不同级别的AI服务
|
onClose={() => setFilterTypeVisible(false)}
|
||||||
|
value={[filterType]}
|
||||||
|
onConfirm={value => {
|
||||||
|
setFilterType(value[0] as string);
|
||||||
|
setFilterTypeVisible(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{() => (
|
||||||
|
<div
|
||||||
|
className={style.filterButton}
|
||||||
|
onClick={() => setFilterTypeVisible(true)}
|
||||||
|
>
|
||||||
|
<DownOutlined className={style.filterIcon} />
|
||||||
|
{getTypeLabel()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Picker>
|
||||||
|
|
||||||
|
<Picker
|
||||||
|
columns={[statusOptions]}
|
||||||
|
visible={filterStatusVisible}
|
||||||
|
onClose={() => setFilterStatusVisible(false)}
|
||||||
|
value={[filterStatus]}
|
||||||
|
onConfirm={value => {
|
||||||
|
setFilterStatus(value[0] as string);
|
||||||
|
setFilterStatusVisible(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{() => (
|
||||||
|
<div
|
||||||
|
className={style.filterButton}
|
||||||
|
onClick={() => setFilterStatusVisible(true)}
|
||||||
|
>
|
||||||
|
<DownOutlined className={style.filterIcon} />
|
||||||
|
{getStatusLabel()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Picker>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={style["version-packages"]}>
|
{/* 消费记录列表 */}
|
||||||
{versionPackages.map(pkg => (
|
<div className={style.recordList}>
|
||||||
<Card key={pkg.id} className={style["version-card"]}>
|
{loading && records.length === 0 ? (
|
||||||
<div className={style["package-header"]}>
|
<div className={style.loadingContainer}>
|
||||||
<div className={style["package-info"]}>
|
<div className={style.loadingText}>加载中...</div>
|
||||||
<div className={style["package-icon"]}>{pkg.icon}</div>
|
</div>
|
||||||
<div className={style["package-details"]}>
|
) : records.length > 0 ? (
|
||||||
<div className={style["package-name"]}>
|
records.map(record => (
|
||||||
{pkg.name}
|
<Card key={record.id} className={style.recordItem}>
|
||||||
{pkg.tag && (
|
<div className={style.recordHeader}>
|
||||||
<span
|
<div className={style.recordLeft}>
|
||||||
className={`${style["package-tag"]} ${style[`tag-${pkg.tagColor || "blue"}`]}`}
|
<div className={style.recordType}>{record.type}</div>
|
||||||
>
|
<Tag
|
||||||
{pkg.tag}
|
color={record.status === "已完成" ? "success" : "primary"}
|
||||||
</span>
|
className={style.recordStatus}
|
||||||
)}
|
>
|
||||||
|
{record.status}
|
||||||
|
</Tag>
|
||||||
|
</div>
|
||||||
|
<div className={style.recordRight}>
|
||||||
|
<div className={style.recordAmount}>
|
||||||
|
-¥{record.amount.toFixed(1)}
|
||||||
|
</div>
|
||||||
|
<div className={style.recordPower}>
|
||||||
|
{formatNumber(record.power)} 算力
|
||||||
</div>
|
</div>
|
||||||
<div className={style["package-price"]}>{pkg.price}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className={style.recordDesc}>{record.description}</div>
|
||||||
<div className={style["package-description"]}>
|
<div className={style.recordTime}>{record.createTime}</div>
|
||||||
{pkg.description}
|
</Card>
|
||||||
</div>
|
))
|
||||||
<div className={style["package-features"]}>
|
) : (
|
||||||
<div className={style["features-title"]}>包含功能:</div>
|
<div className={style.emptyRecords}>
|
||||||
{pkg.features.map((feature, index) => (
|
<div className={style.emptyIcon}>📋</div>
|
||||||
<div key={index} className={style["feature-item"]}>
|
<div className={style.emptyText}>暂无消费记录</div>
|
||||||
<span className={style["feature-check"]}>✓</span>
|
</div>
|
||||||
{feature}
|
)}
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{pkg.status && (
|
|
||||||
<div className={style["package-status"]}>{pkg.status}</div>
|
|
||||||
)}
|
|
||||||
{pkg.buttonText && (
|
|
||||||
<Button
|
|
||||||
block
|
|
||||||
color="primary"
|
|
||||||
className={style["upgrade-btn"]}
|
|
||||||
onClick={() => {
|
|
||||||
Toast.show({ content: "升级功能开发中", position: "top" });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{pkg.buttonText}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout
|
<Layout
|
||||||
|
loading={loading}
|
||||||
header={
|
header={
|
||||||
<NavCommon
|
<>
|
||||||
title="充值中心"
|
<NavCommon
|
||||||
right={
|
title="算力管理"
|
||||||
<div
|
right={
|
||||||
className={style["record-btn"]}
|
<div className={style.refreshBtn} onClick={handleRefresh}>
|
||||||
onClick={() => navigate("/recharge/order")}
|
<SyncOutlined spin={loading} />
|
||||||
>
|
</div>
|
||||||
<ClockCircleOutlined />
|
}
|
||||||
记录
|
/>
|
||||||
</div>
|
<Tabs
|
||||||
}
|
activeKey={activeTab}
|
||||||
/>
|
onChange={setActiveTab}
|
||||||
|
className={style.powerTabs}
|
||||||
|
>
|
||||||
|
<Tabs.Tab title="概览" key="overview" />
|
||||||
|
<Tabs.Tab title="消费记录" key="records" />
|
||||||
|
</Tabs>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
footer={
|
||||||
|
activeTab === "records" && records.length > 0 ? (
|
||||||
|
<div className={style.paginationWrap}>
|
||||||
|
<Pagination
|
||||||
|
current={page}
|
||||||
|
pageSize={pageSize}
|
||||||
|
total={total}
|
||||||
|
showSizeChanger={false}
|
||||||
|
onChange={p => {
|
||||||
|
setPage(p);
|
||||||
|
fetchRecords(p);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className={style["recharge-page"]}>
|
<div className={style.powerPage}>
|
||||||
<Tabs
|
{activeTab === "overview" && renderOverview()}
|
||||||
activeKey={activeTab}
|
{activeTab === "records" && renderRecords()}
|
||||||
onChange={setActiveTab}
|
|
||||||
className={style["recharge-tabs"]}
|
|
||||||
>
|
|
||||||
<Tabs.Tab title="账户充值" key="account">
|
|
||||||
{renderAccountRecharge()}
|
|
||||||
</Tabs.Tab>
|
|
||||||
<Tabs.Tab title="AI服务" key="ai">
|
|
||||||
{renderAiServices()}
|
|
||||||
</Tabs.Tab>
|
|
||||||
<Tabs.Tab title="版本套餐" key="version">
|
|
||||||
{renderVersionPackages()}
|
|
||||||
</Tabs.Tab>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Recharge;
|
export default PowerManagement;
|
||||||
|
|||||||
46
Cunkebao/src/pages/mobile/mine/recharge/usage-records/api.ts
Normal file
46
Cunkebao/src/pages/mobile/mine/recharge/usage-records/api.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import request from "@/api/request";
|
||||||
|
|
||||||
|
export interface TokensUseRecordParams {
|
||||||
|
/**
|
||||||
|
* 来源 0未知 1好友聊天 2群聊天 3群公告 4商家 5充值
|
||||||
|
*/
|
||||||
|
form?: string;
|
||||||
|
/**
|
||||||
|
* 条数
|
||||||
|
*/
|
||||||
|
limit?: string;
|
||||||
|
/**
|
||||||
|
* 分页
|
||||||
|
*/
|
||||||
|
page?: string;
|
||||||
|
/**
|
||||||
|
* 类型 0减少 1增加
|
||||||
|
*/
|
||||||
|
type?: string;
|
||||||
|
[property: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokensUseRecordItem {
|
||||||
|
id: number;
|
||||||
|
companyId: number;
|
||||||
|
userId: number;
|
||||||
|
wechatAccountId: number;
|
||||||
|
friendIdOrGroupId: number;
|
||||||
|
form: number;
|
||||||
|
type: number;
|
||||||
|
tokens: number;
|
||||||
|
balanceTokens: number;
|
||||||
|
remarks: string;
|
||||||
|
createTime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokensUseRecordList {
|
||||||
|
list: TokensUseRecordItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
//算力使用明细
|
||||||
|
export function getTokensUseRecord(
|
||||||
|
TokensUseRecordParams,
|
||||||
|
): Promise<TokensUseRecordList> {
|
||||||
|
return request("/v1/kefu/tokensRecord/list", TokensUseRecordParams, "GET");
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
.page {
|
||||||
|
padding: 12px 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filterButton {
|
||||||
|
flex: 1;
|
||||||
|
height: 36px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #f7f8fa;
|
||||||
|
border: 1px solid #f0f0f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filterIcon {
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 18px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: linear-gradient(90deg, #ebf3ff 0%, #fff1ff 100%);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryItem {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryNumber {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: linear-gradient(90deg, #1677ff 0%, #722ed1 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
color: transparent;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryLabel {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemTitle {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #111;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemDesc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemFooter {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.power {
|
||||||
|
color: #722ed1;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading,
|
||||||
|
.empty {
|
||||||
|
text-align: center;
|
||||||
|
color: #999;
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
225
Cunkebao/src/pages/mobile/mine/recharge/usage-records/index.tsx
Normal file
225
Cunkebao/src/pages/mobile/mine/recharge/usage-records/index.tsx
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { Picker, Card, Tag, Toast } from "antd-mobile";
|
||||||
|
import { Pagination } from "antd";
|
||||||
|
import { DownOutline } from "antd-mobile-icons";
|
||||||
|
import NavCommon from "@/components/NavCommon";
|
||||||
|
import Layout from "@/components/Layout/Layout";
|
||||||
|
import style from "./index.module.scss";
|
||||||
|
import { getTokensUseRecord } from "./api";
|
||||||
|
|
||||||
|
interface UsageRecordItem {
|
||||||
|
id: number | string;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
power?: number;
|
||||||
|
status?: string;
|
||||||
|
createTime?: string;
|
||||||
|
typeLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UsageRecords: React.FC = () => {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [records, setRecords] = useState<UsageRecordItem[]>([]);
|
||||||
|
const [type, setType] = useState<string>("all");
|
||||||
|
const [status, setStatus] = useState<string>("all");
|
||||||
|
const [typeVisible, setTypeVisible] = useState(false);
|
||||||
|
const [statusVisible, setStatusVisible] = useState(false);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize] = useState(10);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
|
||||||
|
const typeOptions = [
|
||||||
|
{ label: "全部类型", value: "all" },
|
||||||
|
{ label: "AI助手对话", value: "chat" },
|
||||||
|
{ label: "智能群发", value: "broadcast" },
|
||||||
|
{ label: "内容生成", value: "content" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ label: "全部状态", value: "all" },
|
||||||
|
{ label: "已完成", value: "completed" },
|
||||||
|
{ label: "进行中", value: "processing" },
|
||||||
|
];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchRecords();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [type, status, page]);
|
||||||
|
|
||||||
|
const fetchRecords = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
// 前端与接口枚举的简易映射:仅映射已知来源,其它为 undefined(即不过滤)
|
||||||
|
const formMap: Record<string, number | undefined> = {
|
||||||
|
all: undefined, // 全部
|
||||||
|
chat: 1, // 好友/群聊天
|
||||||
|
broadcast: 3, // 群公告/群发
|
||||||
|
content: 4, // 商家/内容生成
|
||||||
|
};
|
||||||
|
|
||||||
|
const res: any = await getTokensUseRecord({
|
||||||
|
page: String(page),
|
||||||
|
limit: String(pageSize),
|
||||||
|
form: formMap[type]?.toString(),
|
||||||
|
// 接口的 type 为 0减少/1增加,与当前“状态”筛选无直接对应,暂不传
|
||||||
|
});
|
||||||
|
|
||||||
|
const formLabelMap: Record<number, string> = {
|
||||||
|
0: "未知",
|
||||||
|
1: "好友聊天",
|
||||||
|
2: "群聊天",
|
||||||
|
3: "群公告",
|
||||||
|
4: "商家",
|
||||||
|
5: "充值",
|
||||||
|
};
|
||||||
|
|
||||||
|
const rawList: any[] = res?.list || [];
|
||||||
|
const list: UsageRecordItem[] = rawList.map((item: any, idx: number) => ({
|
||||||
|
id: item.id ?? idx,
|
||||||
|
title: item.remarks || "使用记录",
|
||||||
|
description: "",
|
||||||
|
power: item.tokens ?? 0,
|
||||||
|
status: "已完成",
|
||||||
|
createTime: item.createTime || "",
|
||||||
|
typeLabel: formLabelMap[item.form as number] || "",
|
||||||
|
}));
|
||||||
|
setRecords(list);
|
||||||
|
|
||||||
|
const possibleTotal =
|
||||||
|
(res && (res.total || res.count || res.totalCount)) || 0;
|
||||||
|
setTotal(
|
||||||
|
typeof possibleTotal === "number" && possibleTotal > 0
|
||||||
|
? possibleTotal
|
||||||
|
: page * pageSize + (rawList.length === pageSize ? 1 : 0),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
Toast.show({ content: "获取使用记录失败", position: "top" });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTypeLabel = () =>
|
||||||
|
typeOptions.find(o => o.value === type)?.label || "全部类型";
|
||||||
|
const getStatusLabel = () =>
|
||||||
|
statusOptions.find(o => o.value === status)?.label || "全部状态";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout
|
||||||
|
header={
|
||||||
|
<>
|
||||||
|
<NavCommon title="使用记录" />
|
||||||
|
<div className={style.filters}>
|
||||||
|
<Picker
|
||||||
|
columns={[typeOptions]}
|
||||||
|
visible={typeVisible}
|
||||||
|
onClose={() => setTypeVisible(false)}
|
||||||
|
value={[type]}
|
||||||
|
onConfirm={val => {
|
||||||
|
setType(val[0] as string);
|
||||||
|
setTypeVisible(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{() => (
|
||||||
|
<div
|
||||||
|
className={style.filterButton}
|
||||||
|
onClick={() => setTypeVisible(true)}
|
||||||
|
>
|
||||||
|
<span className={style.filterIcon}>
|
||||||
|
<DownOutline />
|
||||||
|
</span>
|
||||||
|
{getTypeLabel()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Picker>
|
||||||
|
|
||||||
|
<Picker
|
||||||
|
columns={[statusOptions]}
|
||||||
|
visible={statusVisible}
|
||||||
|
onClose={() => setStatusVisible(false)}
|
||||||
|
value={[status]}
|
||||||
|
onConfirm={val => {
|
||||||
|
setStatus(val[0] as string);
|
||||||
|
setStatusVisible(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{() => (
|
||||||
|
<div
|
||||||
|
className={style.filterButton}
|
||||||
|
onClick={() => setStatusVisible(true)}
|
||||||
|
>
|
||||||
|
<span className={style.filterIcon}>
|
||||||
|
<DownOutline />
|
||||||
|
</span>
|
||||||
|
{getStatusLabel()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Picker>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={style.summary}>
|
||||||
|
<div className={style.summaryItem}>
|
||||||
|
<div className={style.summaryNumber}>{records.length}</div>
|
||||||
|
<div className={style.summaryLabel}>记录总数</div>
|
||||||
|
</div>
|
||||||
|
<div className={style.summaryItem}>
|
||||||
|
<div className={style.summaryNumber}>
|
||||||
|
{records.reduce(
|
||||||
|
(acc, cur) => acc + (Number(cur.power) || 0),
|
||||||
|
0,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={style.summaryLabel}>总消耗算力</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
footer={
|
||||||
|
<div className="pagination-container">
|
||||||
|
<Pagination
|
||||||
|
current={page}
|
||||||
|
pageSize={pageSize}
|
||||||
|
total={total}
|
||||||
|
showSizeChanger={false}
|
||||||
|
onChange={newPage => {
|
||||||
|
setPage(newPage);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={style.page}>
|
||||||
|
<div className={style.list}>
|
||||||
|
{loading && records.length === 0 ? (
|
||||||
|
<div className={style.loading}>加载中...</div>
|
||||||
|
) : records.length === 0 ? (
|
||||||
|
<div className={style.empty}>暂无使用记录</div>
|
||||||
|
) : (
|
||||||
|
records.map(item => (
|
||||||
|
<Card key={item.id} className={style.item}>
|
||||||
|
<div className={style.itemHeader}>
|
||||||
|
<div className={style.itemTitle}>
|
||||||
|
{item.title || item.typeLabel || "使用任务"}
|
||||||
|
</div>
|
||||||
|
<Tag color={item.status === "已完成" ? "success" : "primary"}>
|
||||||
|
{item.status}
|
||||||
|
</Tag>
|
||||||
|
</div>
|
||||||
|
{item.description ? (
|
||||||
|
<div className={style.itemDesc}>{item.description}</div>
|
||||||
|
) : null}
|
||||||
|
<div className={style.itemFooter}>
|
||||||
|
<div className={style.power}>消耗 {item.power ?? 0} 算力</div>
|
||||||
|
<div className={style.time}>{item.createTime}</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UsageRecords;
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import request from "@/api/request";
|
|
||||||
import type { UserTagsResponse } from "./data";
|
|
||||||
|
|
||||||
export function getTrafficPoolDetail(wechatId: string) {
|
|
||||||
return request("/v1/traffic/pool/getUserInfo", { wechatId }, "GET");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取用户旅程记录
|
|
||||||
export function getUserJourney(params: {
|
|
||||||
page: number;
|
|
||||||
pageSize: number;
|
|
||||||
userId: string;
|
|
||||||
}) {
|
|
||||||
return request("/v1/traffic/pool/getUserJourney", params, "GET");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取用户标签
|
|
||||||
export function getUserTags(userId: string): Promise<UserTagsResponse> {
|
|
||||||
return request("/v1/traffic/pool/getUserTags", { userId }, "GET");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加用户标签
|
|
||||||
export function addUserTag(userId: string, tagData: any): Promise<any> {
|
|
||||||
return request("/v1/user/tags", { userId, ...tagData }, "POST");
|
|
||||||
}
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
// 设备信息类型
|
|
||||||
export interface DeviceInfo {
|
|
||||||
id: number;
|
|
||||||
memo: string;
|
|
||||||
imei: string;
|
|
||||||
brand: string;
|
|
||||||
alive: number;
|
|
||||||
address: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 来源信息类型
|
|
||||||
export interface SourceInfo {
|
|
||||||
nickname: string;
|
|
||||||
avatar: string;
|
|
||||||
gender: number;
|
|
||||||
phone: string;
|
|
||||||
wechatId: string;
|
|
||||||
alias: string;
|
|
||||||
createTime: string;
|
|
||||||
friendId: number;
|
|
||||||
wechatAccountId: number;
|
|
||||||
lastMsgTime: string;
|
|
||||||
device: DeviceInfo;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 统计总计类型
|
|
||||||
export interface TotalStats {
|
|
||||||
msg: number;
|
|
||||||
money: number;
|
|
||||||
isFriend: boolean;
|
|
||||||
percentage: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// RMM评分类型
|
|
||||||
export interface RmmScore {
|
|
||||||
r: number;
|
|
||||||
f: number;
|
|
||||||
m: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 用户详情类型
|
|
||||||
export interface TrafficPoolUserDetail {
|
|
||||||
id: number;
|
|
||||||
identifier: string;
|
|
||||||
wechatId: string;
|
|
||||||
nickname: string;
|
|
||||||
avatar: string;
|
|
||||||
gender: number;
|
|
||||||
phone: string;
|
|
||||||
alias: string;
|
|
||||||
lastMsgTime: string;
|
|
||||||
source: SourceInfo[];
|
|
||||||
packages: any[];
|
|
||||||
total: TotalStats;
|
|
||||||
rmm: RmmScore;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 扩展的用户详情类型
|
|
||||||
export interface ExtendedUserDetail extends TrafficPoolUserDetail {
|
|
||||||
// 保留原有的扩展字段用于向后兼容
|
|
||||||
userInfo?: {
|
|
||||||
nickname: string;
|
|
||||||
avatar: string;
|
|
||||||
wechatId: string;
|
|
||||||
friendShip: {
|
|
||||||
totalFriend: number;
|
|
||||||
maleFriend: number;
|
|
||||||
femaleFriend: number;
|
|
||||||
unknowFriend: number;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
rfmScore?: {
|
|
||||||
recency: number;
|
|
||||||
frequency: number;
|
|
||||||
monetary: number;
|
|
||||||
totalScore: number;
|
|
||||||
};
|
|
||||||
trafficPools?: {
|
|
||||||
currentPool: string;
|
|
||||||
availablePools: string[];
|
|
||||||
};
|
|
||||||
userTags?: Array<{
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
color: string;
|
|
||||||
type: string;
|
|
||||||
}>;
|
|
||||||
valueTags?: Array<{
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
color: string;
|
|
||||||
icon: string;
|
|
||||||
rfmScore: number;
|
|
||||||
valueLevel: string;
|
|
||||||
}>;
|
|
||||||
restrictions?: Array<{
|
|
||||||
id: string;
|
|
||||||
reason: string;
|
|
||||||
level: number;
|
|
||||||
date: number | null;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 互动记录类型
|
|
||||||
export interface InteractionRecord {
|
|
||||||
id: string;
|
|
||||||
type: string;
|
|
||||||
content: string;
|
|
||||||
timestamp: string;
|
|
||||||
value?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 用户旅程记录类型
|
|
||||||
export interface UserJourneyRecord {
|
|
||||||
id: string;
|
|
||||||
type: number;
|
|
||||||
remark: string;
|
|
||||||
createTime: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 用户标签响应类型
|
|
||||||
export interface UserTagsResponse {
|
|
||||||
wechat: string[];
|
|
||||||
siteLabels: UserTagItem[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// 用户标签项类型
|
|
||||||
export interface UserTagItem {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
color?: string;
|
|
||||||
type?: string;
|
|
||||||
}
|
|
||||||
@@ -1,426 +0,0 @@
|
|||||||
// 头部样式
|
|
||||||
.header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 16px;
|
|
||||||
background: #fff;
|
|
||||||
border-bottom: 1px solid #f0f0f0;
|
|
||||||
|
|
||||||
.title {
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.closeBtn {
|
|
||||||
padding: 8px;
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
color: #999;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 用户卡片
|
|
||||||
.userCard {
|
|
||||||
margin: 16px;
|
|
||||||
border-radius: 12px;
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
|
||||||
|
|
||||||
.userInfo {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatar {
|
|
||||||
width: 60px;
|
|
||||||
height: 60px;
|
|
||||||
border-radius: 50%;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatarFallback {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
color: white;
|
|
||||||
font-size: 24px;
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.userDetails {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nickname {
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #333;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wechatId {
|
|
||||||
font-size: 14px;
|
|
||||||
color: #666;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tags {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.userTag {
|
|
||||||
font-size: 12px;
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-radius: 12px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 标签导航
|
|
||||||
.tabNav {
|
|
||||||
display: flex;
|
|
||||||
background: #fff;
|
|
||||||
margin: 0 16px;
|
|
||||||
border-radius: 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
||||||
|
|
||||||
.tabItem {
|
|
||||||
flex: 1;
|
|
||||||
padding: 12px 16px;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 14px;
|
|
||||||
color: #666;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
border-bottom: 2px solid transparent;
|
|
||||||
|
|
||||||
&.active {
|
|
||||||
color: var(--primary-color);
|
|
||||||
border-bottom-color: var(--primary-color);
|
|
||||||
background: rgba(24, 142, 238, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: rgba(24, 142, 238, 0.05);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 内容区域
|
|
||||||
.content {
|
|
||||||
padding: 10px 10px 10px 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tabContent {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 信息卡片
|
|
||||||
.infoCard {
|
|
||||||
border-radius: 12px;
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
:global(.adm-card-header) {
|
|
||||||
padding: 16px;
|
|
||||||
border-bottom: 1px solid #f0f0f0;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.adm-card-body) {
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// RFM评分网格
|
|
||||||
.rfmGrid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
gap: 16px;
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rfmItem {
|
|
||||||
text-align: center;
|
|
||||||
padding: 12px;
|
|
||||||
background: #f8f9fa;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rfmLabel {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #666;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rfmValue {
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 流量池区域
|
|
||||||
.poolSection {
|
|
||||||
padding: 16px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.currentPool,
|
|
||||||
.availablePools {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.poolLabel {
|
|
||||||
font-size: 14px;
|
|
||||||
color: #666;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 统计数据网格
|
|
||||||
.statsGrid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
gap: 16px;
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.statItem {
|
|
||||||
text-align: center;
|
|
||||||
padding: 12px;
|
|
||||||
background: #f8f9fa;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.statValue {
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.statLabel {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 用户旅程
|
|
||||||
.journeyItem {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #666;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timestamp {
|
|
||||||
color: #999;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 加载状态
|
|
||||||
.loadingContainer {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 40px 16px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loadingText {
|
|
||||||
font-size: 14px;
|
|
||||||
color: #999;
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loadingMore {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 16px;
|
|
||||||
color: #666;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loadMoreBtn {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 标签区域
|
|
||||||
.tagsSection {
|
|
||||||
padding: 16px;
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.valueTagsSection {
|
|
||||||
padding: 16px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tagItem {
|
|
||||||
font-size: 12px;
|
|
||||||
padding: 6px 12px;
|
|
||||||
border-radius: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.valueTagContainer {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.valueTagRow {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rfmScoreText {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #666;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.valueLevelLabel {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #666;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.valueTagItem {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 12px 0;
|
|
||||||
border-bottom: 1px solid #f0f0f0;
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.valueInfo {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加标签按钮
|
|
||||||
.addTagBtn {
|
|
||||||
margin-top: 16px;
|
|
||||||
border-radius: 8px;
|
|
||||||
height: 48px;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 500;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 空状态
|
|
||||||
.emptyState {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 20px 16px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.emptyIcon {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.emptyText {
|
|
||||||
font-size: 14px;
|
|
||||||
color: #666;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.emptyDesc {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #999;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 限制记录样式
|
|
||||||
.restrictionTitle {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 8px;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #333;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.restrictionLevel {
|
|
||||||
font-size: 10px;
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 8px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.restrictionContent {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #666;
|
|
||||||
line-height: 1.4;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 响应式设计
|
|
||||||
@media (max-width: 375px) {
|
|
||||||
.rfmGrid,
|
|
||||||
.statsGrid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.userInfo {
|
|
||||||
flex-direction: column;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatar {
|
|
||||||
align-self: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.restrictionTitle {
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.restrictionContent {
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,795 +0,0 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
|
||||||
import { useParams } from "react-router-dom";
|
|
||||||
import { Card, Button, Avatar, Tag, List, SpinLoading } from "antd-mobile";
|
|
||||||
import {
|
|
||||||
UserOutlined,
|
|
||||||
CrownOutlined,
|
|
||||||
EyeOutlined,
|
|
||||||
DollarOutlined,
|
|
||||||
MobileOutlined,
|
|
||||||
TagOutlined,
|
|
||||||
FileTextOutlined,
|
|
||||||
UserAddOutlined,
|
|
||||||
} from "@ant-design/icons";
|
|
||||||
import Layout from "@/components/Layout/Layout";
|
|
||||||
import NavCommon from "@/components/NavCommon";
|
|
||||||
import { getTrafficPoolDetail, getUserJourney, getUserTags } from "./api";
|
|
||||||
import type {
|
|
||||||
ExtendedUserDetail,
|
|
||||||
UserJourneyRecord,
|
|
||||||
UserTagsResponse,
|
|
||||||
UserTagItem,
|
|
||||||
} from "./data";
|
|
||||||
import styles from "./index.module.scss";
|
|
||||||
|
|
||||||
// RMM评分辅助函数
|
|
||||||
const getRmmValueLevel = (totalScore: number): string => {
|
|
||||||
if (totalScore >= 12) return "高价值客户";
|
|
||||||
if (totalScore >= 8) return "中等价值客户";
|
|
||||||
if (totalScore >= 4) return "低价值客户";
|
|
||||||
return "潜在客户";
|
|
||||||
};
|
|
||||||
|
|
||||||
const getRmmColor = (totalScore: number): string => {
|
|
||||||
if (totalScore >= 12) return "danger";
|
|
||||||
if (totalScore >= 8) return "warning";
|
|
||||||
if (totalScore >= 4) return "primary";
|
|
||||||
return "default";
|
|
||||||
};
|
|
||||||
|
|
||||||
const TrafficPoolDetail: React.FC = () => {
|
|
||||||
const { wxid, userId } = useParams();
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [user, setUser] = useState<ExtendedUserDetail | null>(null);
|
|
||||||
const [activeTab, setActiveTab] = useState("basic");
|
|
||||||
|
|
||||||
// 用户旅程相关状态
|
|
||||||
const [journeyLoading, setJourneyLoading] = useState(false);
|
|
||||||
const [journeyList, setJourneyList] = useState<UserJourneyRecord[]>([]);
|
|
||||||
const [journeyPage, setJourneyPage] = useState(1);
|
|
||||||
const [journeyTotal, setJourneyTotal] = useState(0);
|
|
||||||
const pageSize = 10;
|
|
||||||
|
|
||||||
// 用户标签相关状态
|
|
||||||
const [tagsLoading, setTagsLoading] = useState(false);
|
|
||||||
const [userTagsList, setUserTagsList] = useState<UserTagItem[]>([]);
|
|
||||||
const [wechatTagsList, setWechatTagsList] = useState<string[]>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!wxid) return;
|
|
||||||
setLoading(true);
|
|
||||||
getTrafficPoolDetail(wxid as string)
|
|
||||||
.then(res => {
|
|
||||||
// 直接使用API返回的数据结构
|
|
||||||
const extendedUser: ExtendedUserDetail = {
|
|
||||||
...res,
|
|
||||||
// 根据新数据结构构建userInfo
|
|
||||||
userInfo: {
|
|
||||||
nickname: res.nickname,
|
|
||||||
avatar: res.avatar,
|
|
||||||
wechatId: res.wechatId,
|
|
||||||
friendShip: {
|
|
||||||
totalFriend: res.source?.length || 0,
|
|
||||||
maleFriend: res.source?.filter(s => s.gender === 1).length || 0,
|
|
||||||
femaleFriend: res.source?.filter(s => s.gender === 2).length || 0,
|
|
||||||
unknowFriend: res.source?.filter(s => s.gender === 0).length || 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// 使用API返回的RMM数据
|
|
||||||
rfmScore: {
|
|
||||||
recency: res.rmm.r,
|
|
||||||
frequency: res.rmm.f,
|
|
||||||
monetary: res.rmm.m,
|
|
||||||
totalScore: res.rmm.r + res.rmm.f + res.rmm.m,
|
|
||||||
},
|
|
||||||
// 根据数据推断流量池信息
|
|
||||||
trafficPools: {
|
|
||||||
currentPool: res.total.isFriend ? "已添加好友池" : "待添加池",
|
|
||||||
availablePools: ["高价值客户池", "活跃用户池", "新用户池"],
|
|
||||||
},
|
|
||||||
// 基于数据生成用户标签
|
|
||||||
userTags: [
|
|
||||||
...(res.total.isFriend
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
id: "friend",
|
|
||||||
name: "已添加好友",
|
|
||||||
color: "success",
|
|
||||||
type: "status",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
...(res.total.money > 0
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
id: "paid",
|
|
||||||
name: "付费用户",
|
|
||||||
color: "warning",
|
|
||||||
type: "value",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
...(res.total.msg > 10
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
id: "active",
|
|
||||||
name: "高频互动",
|
|
||||||
color: "primary",
|
|
||||||
type: "behavior",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
...(res.source?.length > 1
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
id: "multi",
|
|
||||||
name: "多设备用户",
|
|
||||||
color: "danger",
|
|
||||||
type: "device",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
],
|
|
||||||
// 基于RMM评分生成价值标签
|
|
||||||
valueTags: [
|
|
||||||
{
|
|
||||||
id: "rmm",
|
|
||||||
name: getRmmValueLevel(res.rmm.r + res.rmm.f + res.rmm.m),
|
|
||||||
color: getRmmColor(res.rmm.r + res.rmm.f + res.rmm.m),
|
|
||||||
icon: "crown",
|
|
||||||
rfmScore: res.rmm.r + res.rmm.f + res.rmm.m,
|
|
||||||
valueLevel: getRmmValueLevel(res.rmm.r + res.rmm.f + res.rmm.m),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
console.log("用户详情数据:", extendedUser);
|
|
||||||
|
|
||||||
setUser(extendedUser);
|
|
||||||
})
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, [wxid]);
|
|
||||||
|
|
||||||
// 获取用户旅程数据
|
|
||||||
const fetchUserJourney = async (page: number = 1) => {
|
|
||||||
if (!userId) return;
|
|
||||||
|
|
||||||
setJourneyLoading(true);
|
|
||||||
try {
|
|
||||||
const response = await getUserJourney({
|
|
||||||
page,
|
|
||||||
pageSize,
|
|
||||||
userId: userId,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (page === 1) {
|
|
||||||
setJourneyList(response.list);
|
|
||||||
} else {
|
|
||||||
setJourneyList(prev => [...prev, ...response.list]);
|
|
||||||
}
|
|
||||||
setJourneyTotal(response.total);
|
|
||||||
setJourneyPage(page);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("获取用户旅程失败:", error);
|
|
||||||
} finally {
|
|
||||||
setJourneyLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 获取用户标签数据
|
|
||||||
const fetchUserTags = async () => {
|
|
||||||
if (!userId) return;
|
|
||||||
|
|
||||||
setTagsLoading(true);
|
|
||||||
try {
|
|
||||||
const response: UserTagsResponse = await getUserTags(userId);
|
|
||||||
setUserTagsList(response.siteLabels || []);
|
|
||||||
setWechatTagsList(response.wechat || []);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("获取用户标签失败:", error);
|
|
||||||
} finally {
|
|
||||||
setTagsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 标签切换处理
|
|
||||||
const handleTabChange = (tab: string) => {
|
|
||||||
setActiveTab(tab);
|
|
||||||
if (tab === "journey" && journeyList.length === 0) {
|
|
||||||
fetchUserJourney(1);
|
|
||||||
}
|
|
||||||
if (tab === "tags" && userTagsList.length === 0) {
|
|
||||||
fetchUserTags();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getJourneyTypeIcon = (type: number) => {
|
|
||||||
switch (type) {
|
|
||||||
case 0: // 浏览
|
|
||||||
return <EyeOutlined style={{ color: "#722ed1" }} />;
|
|
||||||
case 2: // 提交订单
|
|
||||||
return <FileTextOutlined style={{ color: "#52c41a" }} />;
|
|
||||||
case 3: // 注册
|
|
||||||
return <UserAddOutlined style={{ color: "#1677ff" }} />;
|
|
||||||
default:
|
|
||||||
return <MobileOutlined style={{ color: "#999" }} />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getJourneyTypeText = (type: number) => {
|
|
||||||
switch (type) {
|
|
||||||
case 0:
|
|
||||||
return "浏览行为";
|
|
||||||
case 2:
|
|
||||||
return "提交订单";
|
|
||||||
case 3:
|
|
||||||
return "注册行为";
|
|
||||||
default:
|
|
||||||
return "其他行为";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDateTime = (dateTime: string) => {
|
|
||||||
try {
|
|
||||||
const date = new Date(dateTime);
|
|
||||||
return date.toLocaleString("zh-CN", {
|
|
||||||
year: "numeric",
|
|
||||||
month: "2-digit",
|
|
||||||
day: "2-digit",
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
return dateTime;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getActionIcon = (type: string) => {
|
|
||||||
switch (type) {
|
|
||||||
case "click":
|
|
||||||
return <MobileOutlined style={{ color: "#1677ff" }} />;
|
|
||||||
case "view":
|
|
||||||
return <EyeOutlined style={{ color: "#722ed1" }} />;
|
|
||||||
case "purchase":
|
|
||||||
return <DollarOutlined style={{ color: "#52c41a" }} />;
|
|
||||||
default:
|
|
||||||
return <MobileOutlined style={{ color: "#999" }} />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getRestrictionLevelText = (level: number) => {
|
|
||||||
switch (level) {
|
|
||||||
case 1:
|
|
||||||
return "轻微";
|
|
||||||
case 2:
|
|
||||||
return "中等";
|
|
||||||
case 3:
|
|
||||||
return "严重";
|
|
||||||
default:
|
|
||||||
return "未知";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getRestrictionLevelColor = (level: number) => {
|
|
||||||
switch (level) {
|
|
||||||
case 1:
|
|
||||||
return "warning";
|
|
||||||
case 2:
|
|
||||||
return "danger";
|
|
||||||
case 3:
|
|
||||||
return "danger";
|
|
||||||
default:
|
|
||||||
return "default";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDate = (timestamp: number | null) => {
|
|
||||||
if (!timestamp) return "--";
|
|
||||||
try {
|
|
||||||
const date = new Date(timestamp * 1000);
|
|
||||||
return date.toLocaleDateString("zh-CN");
|
|
||||||
} catch (error) {
|
|
||||||
return "--";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 获取标签颜色
|
|
||||||
const getTagColor = (index: number): string => {
|
|
||||||
const colors = ["primary", "success", "warning", "danger", "default"];
|
|
||||||
return colors[index % colors.length];
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return (
|
|
||||||
<Layout header={<NavCommon title="用户详情" />} loading={loading}>
|
|
||||||
<div className={styles.emptyState}>
|
|
||||||
<div className={styles.emptyText}>未找到该用户</div>
|
|
||||||
</div>
|
|
||||||
</Layout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Layout
|
|
||||||
loading={loading}
|
|
||||||
header={
|
|
||||||
<>
|
|
||||||
<NavCommon title="用户详情" />
|
|
||||||
{/* 用户基本信息 */}
|
|
||||||
<Card className={styles.userCard}>
|
|
||||||
<div className={styles.userInfo}>
|
|
||||||
<Avatar
|
|
||||||
src={user.avatar}
|
|
||||||
className={styles.avatar}
|
|
||||||
fallback={
|
|
||||||
<div className={styles.avatarFallback}>
|
|
||||||
<UserOutlined />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<div className={styles.userDetails}>
|
|
||||||
<div className={styles.nickname}>{user.nickname}</div>
|
|
||||||
<div className={styles.wechatId}>{user.wechatId}</div>
|
|
||||||
<div className={styles.tags}>
|
|
||||||
{user.valueTags?.map(tag => (
|
|
||||||
<Tag
|
|
||||||
key={tag.id}
|
|
||||||
color={tag.color}
|
|
||||||
fill="outline"
|
|
||||||
className={styles.userTag}
|
|
||||||
>
|
|
||||||
<CrownOutlined />
|
|
||||||
{tag.name}
|
|
||||||
</Tag>
|
|
||||||
))}
|
|
||||||
{user.total.isFriend && (
|
|
||||||
<Tag
|
|
||||||
color="success"
|
|
||||||
fill="outline"
|
|
||||||
className={styles.userTag}
|
|
||||||
>
|
|
||||||
已添加好友
|
|
||||||
</Tag>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
{/* 导航标签 */}
|
|
||||||
<div className={styles.tabNav}>
|
|
||||||
<div
|
|
||||||
className={`${styles.tabItem} ${
|
|
||||||
activeTab === "basic" ? styles.active : ""
|
|
||||||
}`}
|
|
||||||
onClick={() => handleTabChange("basic")}
|
|
||||||
>
|
|
||||||
基本信息
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={`${styles.tabItem} ${
|
|
||||||
activeTab === "journey" ? styles.active : ""
|
|
||||||
}`}
|
|
||||||
onClick={() => handleTabChange("journey")}
|
|
||||||
>
|
|
||||||
用户旅程
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={`${styles.tabItem} ${
|
|
||||||
activeTab === "tags" ? styles.active : ""
|
|
||||||
}`}
|
|
||||||
onClick={() => handleTabChange("tags")}
|
|
||||||
>
|
|
||||||
用户标签
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className={styles.container}>
|
|
||||||
{/* 内容区域 */}
|
|
||||||
<div className={styles.content}>
|
|
||||||
{activeTab === "basic" && (
|
|
||||||
<div className={styles.tabContent}>
|
|
||||||
{/* 关联信息 */}
|
|
||||||
<Card title="关联信息" className={styles.infoCard}>
|
|
||||||
<List>
|
|
||||||
<List.Item
|
|
||||||
extra={
|
|
||||||
user.source?.length
|
|
||||||
? `${user.source.length}个设备`
|
|
||||||
: "无设备"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
设备
|
|
||||||
</List.Item>
|
|
||||||
<List.Item extra={user.wechatId || "--"}>微信号</List.Item>
|
|
||||||
<List.Item extra={user.alias || "--"}>别名</List.Item>
|
|
||||||
<List.Item
|
|
||||||
extra={
|
|
||||||
user.source?.[0]?.createTime
|
|
||||||
? formatDateTime(user.source[0].createTime)
|
|
||||||
: "--"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
添加时间
|
|
||||||
</List.Item>
|
|
||||||
<List.Item extra={user.lastMsgTime || "--"}>
|
|
||||||
最近互动
|
|
||||||
</List.Item>
|
|
||||||
</List>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* RFM评分 */}
|
|
||||||
{user.rfmScore && (
|
|
||||||
<Card title="RFM评分" className={styles.infoCard}>
|
|
||||||
<div className={styles.rfmGrid}>
|
|
||||||
<div className={styles.rfmItem}>
|
|
||||||
<div className={styles.rfmLabel}>最近性(R)</div>
|
|
||||||
<div
|
|
||||||
className={styles.rfmValue}
|
|
||||||
style={{ color: "#1677ff" }}
|
|
||||||
>
|
|
||||||
{user.rfmScore.recency}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={styles.rfmItem}>
|
|
||||||
<div className={styles.rfmLabel}>频率(F)</div>
|
|
||||||
<div
|
|
||||||
className={styles.rfmValue}
|
|
||||||
style={{ color: "#52c41a" }}
|
|
||||||
>
|
|
||||||
{user.rfmScore.frequency}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={styles.rfmItem}>
|
|
||||||
<div className={styles.rfmLabel}>金额(M)</div>
|
|
||||||
<div
|
|
||||||
className={styles.rfmValue}
|
|
||||||
style={{ color: "#722ed1" }}
|
|
||||||
>
|
|
||||||
{user.rfmScore.monetary}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={styles.rfmItem}>
|
|
||||||
<div className={styles.rfmLabel}>总分</div>
|
|
||||||
<div
|
|
||||||
className={styles.rfmValue}
|
|
||||||
style={{ color: "#ff4d4f" }}
|
|
||||||
>
|
|
||||||
{user.rfmScore.totalScore}/15
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 流量池 */}
|
|
||||||
{user.trafficPools && (
|
|
||||||
<Card title="流量池" className={styles.infoCard}>
|
|
||||||
<div className={styles.poolSection}>
|
|
||||||
<div className={styles.currentPool}>
|
|
||||||
<span className={styles.poolLabel}>当前池:</span>
|
|
||||||
<Tag color="primary" fill="outline">
|
|
||||||
{user.trafficPools.currentPool}
|
|
||||||
</Tag>
|
|
||||||
</div>
|
|
||||||
<div className={styles.availablePools}>
|
|
||||||
<span className={styles.poolLabel}>可选池:</span>
|
|
||||||
{user.trafficPools.availablePools.map((pool, index) => (
|
|
||||||
<Tag key={index} color="default" fill="outline">
|
|
||||||
{pool}
|
|
||||||
</Tag>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 统计数据 */}
|
|
||||||
<Card title="统计数据" className={styles.infoCard}>
|
|
||||||
<div className={styles.statsGrid}>
|
|
||||||
<div className={styles.statItem}>
|
|
||||||
<div
|
|
||||||
className={styles.statValue}
|
|
||||||
style={{ color: "#52c41a" }}
|
|
||||||
>
|
|
||||||
¥{user.total.money || 0}
|
|
||||||
</div>
|
|
||||||
<div className={styles.statLabel}>总消费</div>
|
|
||||||
</div>
|
|
||||||
<div className={styles.statItem}>
|
|
||||||
<div
|
|
||||||
className={styles.statValue}
|
|
||||||
style={{ color: "#1677ff" }}
|
|
||||||
>
|
|
||||||
{user.total.msg || 0}
|
|
||||||
</div>
|
|
||||||
<div className={styles.statLabel}>互动次数</div>
|
|
||||||
</div>
|
|
||||||
<div className={styles.statItem}>
|
|
||||||
<div
|
|
||||||
className={styles.statValue}
|
|
||||||
style={{ color: "#722ed1" }}
|
|
||||||
>
|
|
||||||
{user.total.percentage || "0"}%
|
|
||||||
</div>
|
|
||||||
<div className={styles.statLabel}>转化率</div>
|
|
||||||
</div>
|
|
||||||
<div className={styles.statItem}>
|
|
||||||
<div
|
|
||||||
className={styles.statValue}
|
|
||||||
style={{
|
|
||||||
color: user.total.isFriend ? "#52c41a" : "#999",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{user.total.isFriend ? "已添加" : "未添加"}
|
|
||||||
</div>
|
|
||||||
<div className={styles.statLabel}>添加状态</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 好友统计 */}
|
|
||||||
<Card title="好友统计" className={styles.infoCard}>
|
|
||||||
<div className={styles.statsGrid}>
|
|
||||||
<div className={styles.statItem}>
|
|
||||||
<div
|
|
||||||
className={styles.statValue}
|
|
||||||
style={{ color: "#1677ff" }}
|
|
||||||
>
|
|
||||||
{user.userInfo?.friendShip.totalFriend || 0}
|
|
||||||
</div>
|
|
||||||
<div className={styles.statLabel}>总好友</div>
|
|
||||||
</div>
|
|
||||||
<div className={styles.statItem}>
|
|
||||||
<div
|
|
||||||
className={styles.statValue}
|
|
||||||
style={{ color: "#1677ff" }}
|
|
||||||
>
|
|
||||||
{user.userInfo?.friendShip.maleFriend || 0}
|
|
||||||
</div>
|
|
||||||
<div className={styles.statLabel}>男性好友</div>
|
|
||||||
</div>
|
|
||||||
<div className={styles.statItem}>
|
|
||||||
<div
|
|
||||||
className={styles.statValue}
|
|
||||||
style={{ color: "#eb2f96" }}
|
|
||||||
>
|
|
||||||
{user.userInfo?.friendShip.femaleFriend || 0}
|
|
||||||
</div>
|
|
||||||
<div className={styles.statLabel}>女性好友</div>
|
|
||||||
</div>
|
|
||||||
<div className={styles.statItem}>
|
|
||||||
<div className={styles.statValue} style={{ color: "#999" }}>
|
|
||||||
{user.userInfo?.friendShip.unknowFriend || 0}
|
|
||||||
</div>
|
|
||||||
<div className={styles.statLabel}>未知性别</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 限制记录 */}
|
|
||||||
<Card title="限制记录" className={styles.infoCard}>
|
|
||||||
{user.restrictions && user.restrictions.length > 0 ? (
|
|
||||||
<List>
|
|
||||||
{user.restrictions.map(restriction => (
|
|
||||||
<List.Item
|
|
||||||
key={restriction.id}
|
|
||||||
title={
|
|
||||||
<div className={styles.restrictionTitle}>
|
|
||||||
<span>{restriction.reason || "未知原因"}</span>
|
|
||||||
<Tag
|
|
||||||
color={getRestrictionLevelColor(
|
|
||||||
restriction.level,
|
|
||||||
)}
|
|
||||||
fill="outline"
|
|
||||||
className={styles.restrictionLevel}
|
|
||||||
>
|
|
||||||
{getRestrictionLevelText(restriction.level)}
|
|
||||||
</Tag>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
description={
|
|
||||||
<div className={styles.restrictionContent}>
|
|
||||||
<span>限制ID: {restriction.id}</span>
|
|
||||||
{restriction.date && (
|
|
||||||
<span>
|
|
||||||
限制时间: {formatDate(restriction.date)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</List>
|
|
||||||
) : (
|
|
||||||
<div className={styles.emptyState}>
|
|
||||||
<div className={styles.emptyIcon}>
|
|
||||||
<UserOutlined style={{ fontSize: 48, color: "#ccc" }} />
|
|
||||||
</div>
|
|
||||||
<div className={styles.emptyText}>暂无限制记录</div>
|
|
||||||
<div className={styles.emptyDesc}>
|
|
||||||
该用户没有任何限制记录
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === "journey" && (
|
|
||||||
<div className={styles.tabContent}>
|
|
||||||
<Card title="互动记录" className={styles.infoCard}>
|
|
||||||
{journeyLoading && journeyList.length === 0 ? (
|
|
||||||
<div className={styles.loadingContainer}>
|
|
||||||
<SpinLoading color="primary" style={{ fontSize: 24 }} />
|
|
||||||
<div className={styles.loadingText}>加载中...</div>
|
|
||||||
</div>
|
|
||||||
) : journeyList.length === 0 ? (
|
|
||||||
<div className={styles.emptyState}>
|
|
||||||
<div className={styles.emptyIcon}>
|
|
||||||
<EyeOutlined style={{ fontSize: 48, color: "#ccc" }} />
|
|
||||||
</div>
|
|
||||||
<div className={styles.emptyText}>暂无互动记录</div>
|
|
||||||
<div className={styles.emptyDesc}>
|
|
||||||
该用户还没有任何互动行为
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<List>
|
|
||||||
{journeyList.map(record => (
|
|
||||||
<List.Item
|
|
||||||
key={record.id}
|
|
||||||
prefix={getJourneyTypeIcon(record.type)}
|
|
||||||
title={getJourneyTypeText(record.type)}
|
|
||||||
description={
|
|
||||||
<div className={styles.journeyItem}>
|
|
||||||
<span>{record.remark}</span>
|
|
||||||
<span className={styles.timestamp}>
|
|
||||||
{formatDateTime(record.createTime)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{journeyLoading && journeyList.length > 0 && (
|
|
||||||
<div className={styles.loadingMore}>
|
|
||||||
<SpinLoading color="primary" style={{ fontSize: 16 }} />
|
|
||||||
<span>加载更多...</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!journeyLoading && journeyList.length < journeyTotal && (
|
|
||||||
<div className={styles.loadMoreBtn}>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
fill="outline"
|
|
||||||
onClick={() => fetchUserJourney(journeyPage + 1)}
|
|
||||||
>
|
|
||||||
加载更多
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</List>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === "tags" && (
|
|
||||||
<div className={styles.tabContent}>
|
|
||||||
{/* 站内标签 */}
|
|
||||||
<Card title="站内标签" className={styles.infoCard}>
|
|
||||||
{tagsLoading && userTagsList.length === 0 ? (
|
|
||||||
<div className={styles.loadingContainer}>
|
|
||||||
<SpinLoading color="primary" style={{ fontSize: 20 }} />
|
|
||||||
<div className={styles.loadingText}>加载中...</div>
|
|
||||||
</div>
|
|
||||||
) : userTagsList.length === 0 ? (
|
|
||||||
<div className={styles.emptyState}>
|
|
||||||
<div className={styles.emptyIcon}>
|
|
||||||
<TagOutlined style={{ fontSize: 36, color: "#ccc" }} />
|
|
||||||
</div>
|
|
||||||
<div className={styles.emptyText}>暂无站内标签</div>
|
|
||||||
<div className={styles.emptyDesc}>
|
|
||||||
该用户还没有任何站内标签
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className={styles.tagsSection}>
|
|
||||||
{userTagsList.map((tag, index) => (
|
|
||||||
<Tag
|
|
||||||
key={tag.id}
|
|
||||||
color={getTagColor(index)}
|
|
||||||
fill="outline"
|
|
||||||
className={styles.tagItem}
|
|
||||||
>
|
|
||||||
{tag.name}
|
|
||||||
</Tag>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 微信标签 */}
|
|
||||||
<Card title="微信标签" className={styles.infoCard}>
|
|
||||||
{tagsLoading && wechatTagsList.length === 0 ? (
|
|
||||||
<div className={styles.loadingContainer}>
|
|
||||||
<SpinLoading color="primary" style={{ fontSize: 24 }} />
|
|
||||||
<div className={styles.loadingText}>加载中...</div>
|
|
||||||
</div>
|
|
||||||
) : wechatTagsList.length === 0 ? (
|
|
||||||
<div className={styles.emptyState}>
|
|
||||||
<div className={styles.emptyIcon}>
|
|
||||||
<TagOutlined style={{ fontSize: 48, color: "#ccc" }} />
|
|
||||||
</div>
|
|
||||||
<div className={styles.emptyText}>暂无微信标签</div>
|
|
||||||
<div className={styles.emptyDesc}>
|
|
||||||
该用户还没有任何微信标签
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className={styles.tagsSection}>
|
|
||||||
{wechatTagsList.map((tag, index) => (
|
|
||||||
<Tag
|
|
||||||
key={index}
|
|
||||||
color="danger"
|
|
||||||
fill="outline"
|
|
||||||
className={styles.tagItem}
|
|
||||||
>
|
|
||||||
{tag}
|
|
||||||
</Tag>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 价值标签 */}
|
|
||||||
<Card title="价值标签" className={styles.infoCard}>
|
|
||||||
{user.valueTags && user.valueTags.length > 0 ? (
|
|
||||||
<div className={styles.valueTagsSection}>
|
|
||||||
{user.valueTags.map(tag => (
|
|
||||||
<div key={tag.id} className={styles.valueTagContainer}>
|
|
||||||
<div className={styles.valueTagRow}>
|
|
||||||
<Tag
|
|
||||||
color={tag.color}
|
|
||||||
fill="outline"
|
|
||||||
className={styles.tagItem}
|
|
||||||
>
|
|
||||||
{tag.icon === "crown" && <CrownOutlined />}
|
|
||||||
{tag.name}
|
|
||||||
</Tag>
|
|
||||||
<span className={styles.rfmScoreText}>
|
|
||||||
RFM总分: {tag.rfmScore}/15
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className={styles.valueTagRow}>
|
|
||||||
<span className={styles.valueLevelLabel}>
|
|
||||||
价值等级:
|
|
||||||
</span>
|
|
||||||
<Tag color="danger" fill="outline">
|
|
||||||
{tag.valueLevel}
|
|
||||||
</Tag>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className={styles.emptyState}>
|
|
||||||
<div className={styles.emptyIcon}>
|
|
||||||
<CrownOutlined style={{ fontSize: 48, color: "#ccc" }} />
|
|
||||||
</div>
|
|
||||||
<div className={styles.emptyText}>暂无价值标签</div>
|
|
||||||
<div className={styles.emptyDesc}>
|
|
||||||
该用户还没有任何价值标签
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Layout>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TrafficPoolDetail;
|
|
||||||
101
Cunkebao/src/pages/mobile/mine/traffic-pool/form/README.md
Normal file
101
Cunkebao/src/pages/mobile/mine/traffic-pool/form/README.md
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
# 新建流量包功能
|
||||||
|
|
||||||
|
## 功能概述
|
||||||
|
|
||||||
|
新建流量包功能是一个完整的用户群体管理工具,允许用户创建和管理基于特定条件的用户分组。
|
||||||
|
|
||||||
|
## 页面结构
|
||||||
|
|
||||||
|
### 主页面 (`index.tsx`)
|
||||||
|
|
||||||
|
- 包含三个标签页:基本信息、人群筛选、用户列表
|
||||||
|
- 使用 Tabs 组件进行页面切换
|
||||||
|
- 底部固定提交按钮
|
||||||
|
|
||||||
|
### 组件结构
|
||||||
|
|
||||||
|
#### 1. 基本信息组件 (`BasicInfo.tsx`)
|
||||||
|
|
||||||
|
- **流量包名称**:必填字段
|
||||||
|
- **描述**:可选字段
|
||||||
|
- **备注**:可选字段,支持多行输入
|
||||||
|
|
||||||
|
#### 2. 人群筛选组件 (`AudienceFilter.tsx`)
|
||||||
|
|
||||||
|
- **RFM分析**:展示最近消费、消费频率、消费金额
|
||||||
|
- **年龄层**:显示年龄范围
|
||||||
|
- **消费能力**:显示消费能力等级
|
||||||
|
- **标签筛选**:预设的8个标签
|
||||||
|
- **自定义条件**:支持添加自定义筛选条件
|
||||||
|
- **方案推荐**:提供6个预设方案
|
||||||
|
|
||||||
|
#### 3. 用户列表预览组件 (`UserListPreview.tsx`)
|
||||||
|
|
||||||
|
- 显示筛选后的用户列表
|
||||||
|
- 支持全选和批量操作
|
||||||
|
- 显示用户详细信息(RFM评分、活跃度、消费金额等)
|
||||||
|
- 支持单个用户移除
|
||||||
|
|
||||||
|
#### 4. 自定义条件弹窗 (`CustomConditionModal.tsx`)
|
||||||
|
|
||||||
|
- 支持10种不同的标签类型
|
||||||
|
- 根据标签类型显示不同的输入方式:
|
||||||
|
- 年龄层:两个数字输入框(范围)
|
||||||
|
- 其他标签:下拉选择框
|
||||||
|
- 支持条件的添加和删除
|
||||||
|
|
||||||
|
#### 5. 方案推荐弹窗 (`SchemeRecommendation.tsx`)
|
||||||
|
|
||||||
|
- 提供6个预设方案:
|
||||||
|
- 高价值客户方案
|
||||||
|
- 新用户激活方案
|
||||||
|
- 用户留存方案
|
||||||
|
- 升单转化方案
|
||||||
|
- 价格敏感用户方案
|
||||||
|
- 忠诚客户维护方案
|
||||||
|
- 每个方案包含筛选条件和预估用户数量
|
||||||
|
|
||||||
|
#### 6. 条件列表组件 (`ConditionList.tsx`)
|
||||||
|
|
||||||
|
- 显示已添加的自定义条件
|
||||||
|
- 支持条件的删除和编辑
|
||||||
|
|
||||||
|
## 数据流
|
||||||
|
|
||||||
|
1. **基本信息** → 保存到 `formData` 状态
|
||||||
|
2. **筛选条件** → 保存到 `formData.filterConditions`
|
||||||
|
3. **生成用户列表** → 调用模拟API生成用户数据
|
||||||
|
4. **提交** → 将所有数据提交到后端
|
||||||
|
|
||||||
|
## 路由配置
|
||||||
|
|
||||||
|
- 路径:`/mine/traffic-pool/create`
|
||||||
|
- 组件:`CreateTrafficPackage`
|
||||||
|
- 权限:需要登录
|
||||||
|
|
||||||
|
## 使用流程
|
||||||
|
|
||||||
|
1. 填写基本信息(流量包名称必填)
|
||||||
|
2. 在人群筛选页面设置筛选条件:
|
||||||
|
- 使用预设标签
|
||||||
|
- 添加自定义条件
|
||||||
|
- 或选择预设方案
|
||||||
|
3. 点击"生成用户列表"查看筛选结果
|
||||||
|
4. 在用户列表页面预览和调整用户
|
||||||
|
5. 点击"创建流量包"完成创建
|
||||||
|
|
||||||
|
## 技术特点
|
||||||
|
|
||||||
|
- **模块化设计**:每个功能独立封装为组件
|
||||||
|
- **响应式布局**:适配移动端显示
|
||||||
|
- **状态管理**:使用React Hooks管理复杂状态
|
||||||
|
- **用户体验**:提供丰富的交互反馈
|
||||||
|
- **数据模拟**:包含完整的模拟数据用于演示
|
||||||
|
|
||||||
|
## 扩展性
|
||||||
|
|
||||||
|
- 支持添加新的标签类型
|
||||||
|
- 支持添加新的预设方案
|
||||||
|
- 支持自定义筛选逻辑
|
||||||
|
- 支持导出用户列表
|
||||||
|
- 支持批量操作功能
|
||||||
130
Cunkebao/src/pages/mobile/mine/traffic-pool/form/api.ts
Normal file
130
Cunkebao/src/pages/mobile/mine/traffic-pool/form/api.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import request from "@/api/request";
|
||||||
|
|
||||||
|
// 创建流量包
|
||||||
|
export interface CreateTrafficPackageParams {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
remarks?: string;
|
||||||
|
filterConditions: any[];
|
||||||
|
userIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTrafficPackageResponse {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTrafficPackage(
|
||||||
|
params: CreateTrafficPackageParams,
|
||||||
|
): Promise<CreateTrafficPackageResponse> {
|
||||||
|
return request("/v1/traffic/pool/create", params, "POST");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户列表(根据筛选条件)
|
||||||
|
export interface GetUsersByFilterParams {
|
||||||
|
conditions: any[];
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
avatar: string;
|
||||||
|
tags: string[];
|
||||||
|
rfmScore: number;
|
||||||
|
lastActive: string;
|
||||||
|
consumption: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetUsersByFilterResponse {
|
||||||
|
list: User[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUsersByFilter(
|
||||||
|
params: GetUsersByFilterParams,
|
||||||
|
): Promise<GetUsersByFilterResponse> {
|
||||||
|
return request("/v1/traffic/pool/users/filter", params, "POST");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取预设方案列表
|
||||||
|
export interface PresetScheme {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
conditions: any[];
|
||||||
|
userCount: number;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPresetSchemes(): Promise<PresetScheme[]> {
|
||||||
|
// 模拟数据
|
||||||
|
return new Promise(resolve => {
|
||||||
|
setTimeout(() => {
|
||||||
|
resolve([
|
||||||
|
{
|
||||||
|
id: "scheme_1",
|
||||||
|
name: "高价值客户方案",
|
||||||
|
description: "针对高消费、高活跃度的客户群体",
|
||||||
|
conditions: [
|
||||||
|
{ id: "rfm_high", type: "rfm", label: "RFM评分", value: "high" },
|
||||||
|
{
|
||||||
|
id: "consumption_high",
|
||||||
|
type: "consumption",
|
||||||
|
label: "消费能力",
|
||||||
|
value: "high",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
userCount: 1250,
|
||||||
|
color: "#ff4d4f",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "scheme_2",
|
||||||
|
name: "新用户激活方案",
|
||||||
|
description: "针对新注册用户的激活策略",
|
||||||
|
conditions: [
|
||||||
|
{ id: "new_user", type: "tag", label: "新用户", value: true },
|
||||||
|
{
|
||||||
|
id: "low_activity",
|
||||||
|
type: "activity",
|
||||||
|
label: "活跃度",
|
||||||
|
value: "low",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
userCount: 890,
|
||||||
|
color: "#52c41a",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "scheme_3",
|
||||||
|
name: "流失挽回方案",
|
||||||
|
description: "针对流失风险用户的挽回策略",
|
||||||
|
conditions: [
|
||||||
|
{ id: "churn_risk", type: "tag", label: "流失风险", value: true },
|
||||||
|
{
|
||||||
|
id: "last_active",
|
||||||
|
type: "time",
|
||||||
|
label: "最后活跃",
|
||||||
|
value: "30天前",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
userCount: 567,
|
||||||
|
color: "#faad14",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}, 500);
|
||||||
|
});
|
||||||
|
// return request("/v1/traffic/pool/schemes", {}, "GET");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取行业选项(固定筛选项)
|
||||||
|
export interface IndustryOption {
|
||||||
|
label: string;
|
||||||
|
value: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getIndustryOptions(): Promise<IndustryOption[]> {
|
||||||
|
return request("/v1/traffic/pool/industries", {}, "GET");
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
.container {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schemeRow {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addSchemeBtn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
height: 32px;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rfmGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rfmItem {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rfmLabel {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rfmValue {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ageRange {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consumptionLevel {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.levelTag {
|
||||||
|
background: #52c41a;
|
||||||
|
color: white;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 16px;
|
||||||
|
color: white;
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addConditionBtn {
|
||||||
|
width: 100%;
|
||||||
|
margin: 16px 0;
|
||||||
|
border-style: dashed;
|
||||||
|
border-color: #d9d9d9;
|
||||||
|
color: #666;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #1677ff;
|
||||||
|
color: #1677ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.generateBtn {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { Card, Button } from "antd-mobile";
|
||||||
|
import { Select } from "antd";
|
||||||
|
import { PlusOutlined } from "@ant-design/icons";
|
||||||
|
import CustomConditionModal from "./CustomConditionModal";
|
||||||
|
import ConditionList from "./ConditionList";
|
||||||
|
import styles from "./AudienceFilter.module.scss";
|
||||||
|
import {
|
||||||
|
getIndustryOptions,
|
||||||
|
getPresetSchemes,
|
||||||
|
IndustryOption,
|
||||||
|
PresetScheme,
|
||||||
|
} from "../api";
|
||||||
|
|
||||||
|
interface FilterCondition {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
label: string;
|
||||||
|
value: any;
|
||||||
|
operator?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AudienceFilterProps {
|
||||||
|
conditions: FilterCondition[];
|
||||||
|
onChange: (conditions: FilterCondition[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AudienceFilter: React.FC<AudienceFilterProps> = ({
|
||||||
|
conditions,
|
||||||
|
onChange,
|
||||||
|
}) => {
|
||||||
|
const [showCustomModal, setShowCustomModal] = useState(false);
|
||||||
|
const [industryOptions, setIndustryOptions] = useState<IndustryOption[]>([]);
|
||||||
|
const [presetSchemes, setPresetSchemes] = useState<PresetScheme[]>([]);
|
||||||
|
const [selectedIndustry, setSelectedIndustry] = useState<
|
||||||
|
string | number | undefined
|
||||||
|
>(undefined);
|
||||||
|
const [selectedScheme, setSelectedScheme] = useState<string | undefined>(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 加载行业选项和方案列表
|
||||||
|
useEffect(() => {
|
||||||
|
getIndustryOptions()
|
||||||
|
.then(res => setIndustryOptions(res || []))
|
||||||
|
.catch(() => setIndustryOptions([]));
|
||||||
|
|
||||||
|
getPresetSchemes()
|
||||||
|
.then(res => setPresetSchemes(res || []))
|
||||||
|
.catch(() => setPresetSchemes([]));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAddCondition = (condition: FilterCondition) => {
|
||||||
|
const newConditions = [...conditions, condition];
|
||||||
|
onChange(newConditions);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveCondition = (id: string) => {
|
||||||
|
const newConditions = conditions.filter(c => c.id !== id);
|
||||||
|
onChange(newConditions);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateCondition = (id: string, value: any) => {
|
||||||
|
const newConditions = conditions.map(c =>
|
||||||
|
c.id === id ? { ...c, value } : c,
|
||||||
|
);
|
||||||
|
onChange(newConditions);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSchemeChange = (schemeId: string) => {
|
||||||
|
setSelectedScheme(schemeId);
|
||||||
|
if (schemeId) {
|
||||||
|
// 找到选中的方案并应用其条件
|
||||||
|
const scheme = presetSchemes.find(s => s.id === schemeId);
|
||||||
|
if (scheme) {
|
||||||
|
onChange(scheme.conditions);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 清空方案选择时,清空条件
|
||||||
|
onChange([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddScheme = () => {
|
||||||
|
// 这里可以打开添加方案的弹窗或跳转到方案管理页面
|
||||||
|
console.log("添加新方案");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<Card className={styles.card}>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<div className={styles.title}>人群筛选</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 方案推荐选择 */}
|
||||||
|
<div className={styles.section}>
|
||||||
|
<div className={styles.sectionTitle}>方案推荐</div>
|
||||||
|
<div className={styles.schemeRow}>
|
||||||
|
<Select
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
placeholder="选择预设方案"
|
||||||
|
value={selectedScheme}
|
||||||
|
onChange={handleSchemeChange}
|
||||||
|
options={presetSchemes.map(scheme => ({
|
||||||
|
label: `${scheme.name} (${scheme.userCount}人)`,
|
||||||
|
value: scheme.id,
|
||||||
|
}))}
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
fill="outline"
|
||||||
|
onClick={handleAddScheme}
|
||||||
|
className={styles.addSchemeBtn}
|
||||||
|
>
|
||||||
|
<PlusOutlined />
|
||||||
|
添加方案
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 条件筛选区域 - 当未选择方案时显示 */}
|
||||||
|
{!selectedScheme && (
|
||||||
|
<>
|
||||||
|
{/* 行业筛选(固定项,接口获取选项) */}
|
||||||
|
<div className={styles.section}>
|
||||||
|
<div className={styles.sectionTitle}>行业</div>
|
||||||
|
<Select
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
placeholder="选择行业"
|
||||||
|
value={selectedIndustry}
|
||||||
|
onChange={value => setSelectedIndustry(value)}
|
||||||
|
options={industryOptions.map(opt => ({
|
||||||
|
label: opt.label,
|
||||||
|
value: opt.value,
|
||||||
|
}))}
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 标签筛选 */}
|
||||||
|
<div className={styles.section}>
|
||||||
|
<div className={styles.sectionTitle}>标签筛选</div>
|
||||||
|
<div className={styles.tagGrid}>
|
||||||
|
{[
|
||||||
|
{ name: "高价值用户", color: "#1677ff" },
|
||||||
|
{ name: "新用户", color: "#52c41a" },
|
||||||
|
{ name: "活跃用户", color: "#faad14" },
|
||||||
|
{ name: "流失风险", color: "#eb2f96" },
|
||||||
|
{ name: "复购率高", color: "#722ed1" },
|
||||||
|
{ name: "高潜力", color: "#eb2f96" },
|
||||||
|
{ name: "已沉睡", color: "#bfbfbf" },
|
||||||
|
{ name: "价格敏感", color: "#13c2c2" },
|
||||||
|
].map((tag, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={styles.tag}
|
||||||
|
style={{ backgroundColor: tag.color }}
|
||||||
|
>
|
||||||
|
{tag.name}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 自定义条件列表 */}
|
||||||
|
<ConditionList
|
||||||
|
conditions={conditions}
|
||||||
|
onRemove={handleRemoveCondition}
|
||||||
|
onUpdate={handleUpdateCondition}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 添加自定义条件 */}
|
||||||
|
<Button
|
||||||
|
fill="outline"
|
||||||
|
onClick={() => setShowCustomModal(true)}
|
||||||
|
className={styles.addConditionBtn}
|
||||||
|
>
|
||||||
|
+ 添加自定义条件
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 自定义条件弹窗 */}
|
||||||
|
<CustomConditionModal
|
||||||
|
visible={showCustomModal}
|
||||||
|
onClose={() => setShowCustomModal(false)}
|
||||||
|
onAdd={handleAddCondition}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AudienceFilter;
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
.container {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
color: #333;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.required {
|
||||||
|
color: #ff4d4f;
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: #1677ff;
|
||||||
|
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea {
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 80px;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: #1677ff;
|
||||||
|
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.adm-form-item) {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.adm-form-item-label) {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Card, Form, Input } from "antd-mobile";
|
||||||
|
import styles from "./BasicInfo.module.scss";
|
||||||
|
|
||||||
|
interface BasicInfoProps {
|
||||||
|
data: {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
remarks: string;
|
||||||
|
};
|
||||||
|
onChange: (data: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BasicInfo: React.FC<BasicInfoProps> = ({ data, onChange }) => {
|
||||||
|
const handleChange = (field: string, value: string) => {
|
||||||
|
onChange({ [field]: value });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<Card className={styles.card}>
|
||||||
|
<div className={styles.title}>基本信息</div>
|
||||||
|
|
||||||
|
<Form layout="vertical">
|
||||||
|
<Form.Item
|
||||||
|
label={
|
||||||
|
<span className={styles.label}>
|
||||||
|
流量包名称<span className={styles.required}>*</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
placeholder="输入流量包名称"
|
||||||
|
value={data.name}
|
||||||
|
onChange={value => handleChange("name", value)}
|
||||||
|
className={styles.input}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label={<span className={styles.label}>描述</span>}>
|
||||||
|
<Input
|
||||||
|
placeholder="输入流量包描述"
|
||||||
|
value={data.description}
|
||||||
|
onChange={value => handleChange("description", value)}
|
||||||
|
className={styles.input}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label={<span className={styles.label}>备注</span>}>
|
||||||
|
<Input
|
||||||
|
placeholder="输入备注信息 (选填)"
|
||||||
|
value={data.remarks}
|
||||||
|
onChange={value => handleChange("remarks", value)}
|
||||||
|
className={styles.textarea}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BasicInfo;
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
.container {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conditionList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conditionItem {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conditionContent {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conditionLabel {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conditionValue {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.removeBtn {
|
||||||
|
color: #ff4d4f;
|
||||||
|
padding: 4px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #fff2f0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Button } from "antd-mobile";
|
||||||
|
import { DeleteOutline } from "antd-mobile-icons";
|
||||||
|
import styles from "./ConditionList.module.scss";
|
||||||
|
|
||||||
|
interface FilterCondition {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
label: string;
|
||||||
|
value: any;
|
||||||
|
operator?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConditionListProps {
|
||||||
|
conditions: FilterCondition[];
|
||||||
|
onRemove: (id: string) => void;
|
||||||
|
onUpdate: (id: string, value: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ConditionList: React.FC<ConditionListProps> = ({
|
||||||
|
conditions,
|
||||||
|
onRemove,
|
||||||
|
onUpdate,
|
||||||
|
}) => {
|
||||||
|
const formatConditionValue = (condition: FilterCondition) => {
|
||||||
|
switch (condition.type) {
|
||||||
|
case "range":
|
||||||
|
return `${condition.value.min || 0}-${condition.value.max || 0}岁`;
|
||||||
|
case "select":
|
||||||
|
return condition.value;
|
||||||
|
default:
|
||||||
|
return condition.value;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (conditions.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<div className={styles.title}>自定义条件</div>
|
||||||
|
<div className={styles.conditionList}>
|
||||||
|
{conditions.map(condition => (
|
||||||
|
<div key={condition.id} className={styles.conditionItem}>
|
||||||
|
<div className={styles.conditionContent}>
|
||||||
|
<span className={styles.conditionLabel}>{condition.label}:</span>
|
||||||
|
<span className={styles.conditionValue}>
|
||||||
|
{formatConditionValue(condition)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
fill="none"
|
||||||
|
onClick={() => onRemove(condition.id)}
|
||||||
|
className={styles.removeBtn}
|
||||||
|
>
|
||||||
|
<DeleteOutline />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ConditionList;
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
.container {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
padding: 16px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagList {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagItem {
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
border-radius: 6px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #333;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #1677ff;
|
||||||
|
color: #1677ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
border-color: #1677ff;
|
||||||
|
background-color: #e6f7ff;
|
||||||
|
color: #1677ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rangeInputs {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rangeSeparator {
|
||||||
|
color: #666;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
padding: 16px;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { Popup, Form, Input, Selector, Button } from "antd-mobile";
|
||||||
|
import styles from "./CustomConditionModal.module.scss";
|
||||||
|
|
||||||
|
interface CustomConditionModalProps {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onAdd: (condition: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 模拟标签数据
|
||||||
|
const mockTags = [
|
||||||
|
{ id: "age", name: "年龄层", type: "range", options: [] },
|
||||||
|
{
|
||||||
|
id: "consumption",
|
||||||
|
name: "消费能力",
|
||||||
|
type: "select",
|
||||||
|
options: [
|
||||||
|
{ label: "高", value: "high" },
|
||||||
|
{ label: "中", value: "medium" },
|
||||||
|
{ label: "低", value: "low" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "gender",
|
||||||
|
name: "性别",
|
||||||
|
type: "select",
|
||||||
|
options: [
|
||||||
|
{ label: "男", value: "male" },
|
||||||
|
{ label: "女", value: "female" },
|
||||||
|
{ label: "未知", value: "unknown" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "location",
|
||||||
|
name: "所在地区",
|
||||||
|
type: "select",
|
||||||
|
options: [
|
||||||
|
{ label: "厦门", value: "xiamen" },
|
||||||
|
{ label: "泉州", value: "quanzhou" },
|
||||||
|
{ label: "福州", value: "fuzhou" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "source",
|
||||||
|
name: "客户来源",
|
||||||
|
type: "select",
|
||||||
|
options: [
|
||||||
|
{ label: "抖音", value: "douyin" },
|
||||||
|
{ label: "门店扫码", value: "store" },
|
||||||
|
{ label: "朋友推荐", value: "referral" },
|
||||||
|
{ label: "广告投放", value: "ad" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "frequency",
|
||||||
|
name: "消费频率",
|
||||||
|
type: "select",
|
||||||
|
options: [
|
||||||
|
{ label: "高频(>3次/月)", value: "high" },
|
||||||
|
{ label: "中频", value: "medium" },
|
||||||
|
{ label: "低频", value: "low" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "sensitivity",
|
||||||
|
name: "优惠敏感度",
|
||||||
|
type: "select",
|
||||||
|
options: [
|
||||||
|
{ label: "高", value: "high" },
|
||||||
|
{ label: "中", value: "medium" },
|
||||||
|
{ label: "低", value: "low" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "category",
|
||||||
|
name: "品类偏好",
|
||||||
|
type: "select",
|
||||||
|
options: [
|
||||||
|
{ label: "护肤", value: "skincare" },
|
||||||
|
{ label: "茶饮", value: "tea" },
|
||||||
|
{ label: "宠物", value: "pet" },
|
||||||
|
{ label: "课程", value: "course" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "repurchase",
|
||||||
|
name: "复购行为",
|
||||||
|
type: "select",
|
||||||
|
options: [
|
||||||
|
{ label: "有", value: "yes" },
|
||||||
|
{ label: "无", value: "no" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "satisfaction",
|
||||||
|
name: "售后满意度",
|
||||||
|
type: "select",
|
||||||
|
options: [
|
||||||
|
{ label: "好评", value: "good" },
|
||||||
|
{ label: "一般", value: "average" },
|
||||||
|
{ label: "差评", value: "bad" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const CustomConditionModal: React.FC<CustomConditionModalProps> = ({
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
onAdd,
|
||||||
|
}) => {
|
||||||
|
const [selectedTag, setSelectedTag] = useState<any>(null);
|
||||||
|
const [conditionValue, setConditionValue] = useState<any>(null);
|
||||||
|
|
||||||
|
const handleTagSelect = (tag: any) => {
|
||||||
|
setSelectedTag(tag);
|
||||||
|
setConditionValue(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleValueChange = (value: any) => {
|
||||||
|
setConditionValue(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (!selectedTag || !conditionValue) return;
|
||||||
|
|
||||||
|
const condition = {
|
||||||
|
id: `${selectedTag.id}_${Date.now()}`,
|
||||||
|
type: selectedTag.type,
|
||||||
|
label: selectedTag.name,
|
||||||
|
value: conditionValue,
|
||||||
|
};
|
||||||
|
|
||||||
|
onAdd(condition);
|
||||||
|
onClose();
|
||||||
|
setSelectedTag(null);
|
||||||
|
setConditionValue(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderValueInput = () => {
|
||||||
|
if (!selectedTag) return null;
|
||||||
|
|
||||||
|
switch (selectedTag.type) {
|
||||||
|
case "range":
|
||||||
|
return (
|
||||||
|
<div className={styles.rangeInputs}>
|
||||||
|
<Input
|
||||||
|
placeholder="最小年龄"
|
||||||
|
type="number"
|
||||||
|
onChange={value =>
|
||||||
|
setConditionValue(prev => ({ ...prev, min: value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className={styles.rangeSeparator}>-</span>
|
||||||
|
<Input
|
||||||
|
placeholder="最大年龄"
|
||||||
|
type="number"
|
||||||
|
onChange={value =>
|
||||||
|
setConditionValue(prev => ({ ...prev, max: value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "select":
|
||||||
|
return (
|
||||||
|
<Selector
|
||||||
|
options={selectedTag.options}
|
||||||
|
value={conditionValue ? [conditionValue] : []}
|
||||||
|
onChange={value => handleValueChange(value[0])}
|
||||||
|
multiple={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
placeholder="请输入值"
|
||||||
|
value={conditionValue}
|
||||||
|
onChange={handleValueChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popup
|
||||||
|
visible={visible}
|
||||||
|
onMaskClick={onClose}
|
||||||
|
position="bottom"
|
||||||
|
bodyStyle={{ height: "70vh" }}
|
||||||
|
>
|
||||||
|
<div className={styles.container}>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<div className={styles.title}>添加自定义条件</div>
|
||||||
|
<Button size="small" fill="none" onClick={onClose}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.content}>
|
||||||
|
<div className={styles.section}>
|
||||||
|
<div className={styles.sectionTitle}>选择标签</div>
|
||||||
|
<div className={styles.tagList}>
|
||||||
|
{mockTags.map(tag => (
|
||||||
|
<div
|
||||||
|
key={tag.id}
|
||||||
|
className={`${styles.tagItem} ${
|
||||||
|
selectedTag?.id === tag.id ? styles.selected : ""
|
||||||
|
}`}
|
||||||
|
onClick={() => handleTagSelect(tag)}
|
||||||
|
>
|
||||||
|
{tag.name}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedTag && (
|
||||||
|
<div className={styles.section}>
|
||||||
|
<div className={styles.sectionTitle}>设置条件</div>
|
||||||
|
{renderValueInput()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.footer}>
|
||||||
|
<Button
|
||||||
|
color="primary"
|
||||||
|
block
|
||||||
|
disabled={!selectedTag || !conditionValue}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
>
|
||||||
|
添加条件
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Popup>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CustomConditionModal;
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
.container {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
padding: 16px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schemeList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schemeCard {
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.schemeHeader {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schemeName {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schemeBadge {
|
||||||
|
color: white;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schemeDescription {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schemeConditions {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conditionsTitle {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conditionsList {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conditionTag {
|
||||||
|
background: #f0f0f0;
|
||||||
|
color: #666;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.applyBtn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Popup, Card, Button } from "antd-mobile";
|
||||||
|
import styles from "./SchemeRecommendation.module.scss";
|
||||||
|
|
||||||
|
interface FilterCondition {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
label: string;
|
||||||
|
value: any;
|
||||||
|
operator?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SchemeRecommendationProps {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onApply: (conditions: FilterCondition[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 预设方案数据
|
||||||
|
const presetSchemes = [
|
||||||
|
{
|
||||||
|
id: "high_value",
|
||||||
|
name: "高价值客户方案",
|
||||||
|
description: "针对高消费、高活跃度的优质客户",
|
||||||
|
conditions: [
|
||||||
|
{ id: "consumption_1", type: "select", label: "消费能力", value: "high" },
|
||||||
|
{ id: "frequency_1", type: "select", label: "消费频率", value: "high" },
|
||||||
|
{
|
||||||
|
id: "satisfaction_1",
|
||||||
|
type: "select",
|
||||||
|
label: "售后满意度",
|
||||||
|
value: "good",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
userCount: 1250,
|
||||||
|
color: "#1677ff",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "new_user",
|
||||||
|
name: "新用户激活方案",
|
||||||
|
description: "针对新注册用户,提高首次消费转化",
|
||||||
|
conditions: [
|
||||||
|
{
|
||||||
|
id: "age_2",
|
||||||
|
type: "range",
|
||||||
|
label: "年龄层",
|
||||||
|
value: { min: 18, max: 35 },
|
||||||
|
},
|
||||||
|
{ id: "source_2", type: "select", label: "客户来源", value: "douyin" },
|
||||||
|
{ id: "frequency_2", type: "select", label: "消费频率", value: "low" },
|
||||||
|
],
|
||||||
|
userCount: 3200,
|
||||||
|
color: "#52c41a",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "retention",
|
||||||
|
name: "用户留存方案",
|
||||||
|
description: "针对有流失风险的客户,进行召回激活",
|
||||||
|
conditions: [
|
||||||
|
{ id: "frequency_3", type: "select", label: "消费频率", value: "low" },
|
||||||
|
{
|
||||||
|
id: "satisfaction_3",
|
||||||
|
type: "select",
|
||||||
|
label: "售后满意度",
|
||||||
|
value: "average",
|
||||||
|
},
|
||||||
|
{ id: "repurchase_3", type: "select", label: "复购行为", value: "no" },
|
||||||
|
],
|
||||||
|
userCount: 890,
|
||||||
|
color: "#faad14",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "upsell",
|
||||||
|
name: "升单转化方案",
|
||||||
|
description: "针对有升单潜力的客户,推荐高价值产品",
|
||||||
|
conditions: [
|
||||||
|
{
|
||||||
|
id: "consumption_4",
|
||||||
|
type: "select",
|
||||||
|
label: "消费能力",
|
||||||
|
value: "medium",
|
||||||
|
},
|
||||||
|
{ id: "frequency_4", type: "select", label: "消费频率", value: "medium" },
|
||||||
|
{
|
||||||
|
id: "category_4",
|
||||||
|
type: "select",
|
||||||
|
label: "品类偏好",
|
||||||
|
value: "skincare",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
userCount: 1560,
|
||||||
|
color: "#722ed1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "price_sensitive",
|
||||||
|
name: "价格敏感用户方案",
|
||||||
|
description: "针对对价格敏感的用户,提供优惠活动",
|
||||||
|
conditions: [
|
||||||
|
{
|
||||||
|
id: "sensitivity_5",
|
||||||
|
type: "select",
|
||||||
|
label: "优惠敏感度",
|
||||||
|
value: "high",
|
||||||
|
},
|
||||||
|
{ id: "consumption_5", type: "select", label: "消费能力", value: "low" },
|
||||||
|
{ id: "frequency_5", type: "select", label: "消费频率", value: "low" },
|
||||||
|
],
|
||||||
|
userCount: 2100,
|
||||||
|
color: "#eb2f96",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "loyal_customer",
|
||||||
|
name: "忠诚客户维护方案",
|
||||||
|
description: "针对高忠诚度客户,提供VIP服务",
|
||||||
|
conditions: [
|
||||||
|
{ id: "frequency_6", type: "select", label: "消费频率", value: "high" },
|
||||||
|
{ id: "repurchase_6", type: "select", label: "复购行为", value: "yes" },
|
||||||
|
{
|
||||||
|
id: "satisfaction_6",
|
||||||
|
type: "select",
|
||||||
|
label: "售后满意度",
|
||||||
|
value: "good",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
userCount: 680,
|
||||||
|
color: "#13c2c2",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const SchemeRecommendation: React.FC<SchemeRecommendationProps> = ({
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
onApply,
|
||||||
|
}) => {
|
||||||
|
const handleApplyScheme = (scheme: any) => {
|
||||||
|
onApply(scheme.conditions);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popup
|
||||||
|
visible={visible}
|
||||||
|
onMaskClick={onClose}
|
||||||
|
position="bottom"
|
||||||
|
bodyStyle={{ height: "80vh" }}
|
||||||
|
>
|
||||||
|
<div className={styles.container}>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<div className={styles.title}>方案推荐</div>
|
||||||
|
<Button size="small" fill="none" onClick={onClose}>
|
||||||
|
关闭
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.content}>
|
||||||
|
<div className={styles.schemeList}>
|
||||||
|
{presetSchemes.map(scheme => (
|
||||||
|
<Card key={scheme.id} className={styles.schemeCard}>
|
||||||
|
<div className={styles.schemeHeader}>
|
||||||
|
<div className={styles.schemeName}>{scheme.name}</div>
|
||||||
|
<div
|
||||||
|
className={styles.schemeBadge}
|
||||||
|
style={{ backgroundColor: scheme.color }}
|
||||||
|
>
|
||||||
|
{scheme.userCount}人
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.schemeDescription}>
|
||||||
|
{scheme.description}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.schemeConditions}>
|
||||||
|
<div className={styles.conditionsTitle}>筛选条件:</div>
|
||||||
|
<div className={styles.conditionsList}>
|
||||||
|
{scheme.conditions.map((condition, index) => (
|
||||||
|
<span key={index} className={styles.conditionTag}>
|
||||||
|
{condition.label}: {condition.value}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
fill="outline"
|
||||||
|
onClick={() => handleApplyScheme(scheme)}
|
||||||
|
className={styles.applyBtn}
|
||||||
|
>
|
||||||
|
应用此方案
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Popup>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SchemeRecommendation;
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
.container {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userCount {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #1677ff;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batchActions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 0;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectAllCheckbox {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.removeSelectedBtn {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userCheckbox {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userAvatar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userInfo {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userName {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userId {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userTags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
background: #e6f7ff;
|
||||||
|
color: #1677ff;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userStats {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statItem {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.removeBtn {
|
||||||
|
color: #ff4d4f;
|
||||||
|
padding: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #fff2f0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { Card, Avatar, Button, Checkbox, Empty } from "antd-mobile";
|
||||||
|
import { DeleteOutline } from "antd-mobile-icons";
|
||||||
|
import styles from "./UserListPreview.module.scss";
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
avatar: string;
|
||||||
|
tags: string[];
|
||||||
|
rfmScore: number;
|
||||||
|
lastActive: string;
|
||||||
|
consumption: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserListPreviewProps {
|
||||||
|
users: User[];
|
||||||
|
onRemoveUser: (userId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UserListPreview: React.FC<UserListPreviewProps> = ({
|
||||||
|
users,
|
||||||
|
onRemoveUser,
|
||||||
|
}) => {
|
||||||
|
const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const handleSelectAll = (checked: boolean) => {
|
||||||
|
if (checked) {
|
||||||
|
setSelectedUsers(users.map(user => user.id));
|
||||||
|
} else {
|
||||||
|
setSelectedUsers([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectUser = (userId: string, checked: boolean) => {
|
||||||
|
if (checked) {
|
||||||
|
setSelectedUsers(prev => [...prev, userId]);
|
||||||
|
} else {
|
||||||
|
setSelectedUsers(prev => prev.filter(id => id !== userId));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveSelected = () => {
|
||||||
|
selectedUsers.forEach(userId => onRemoveUser(userId));
|
||||||
|
setSelectedUsers([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRfmLevel = (score: number) => {
|
||||||
|
if (score >= 12) return { level: "高价值", color: "#ff4d4f" };
|
||||||
|
if (score >= 8) return { level: "中等价值", color: "#faad14" };
|
||||||
|
if (score >= 4) return { level: "低价值", color: "#52c41a" };
|
||||||
|
return { level: "潜在客户", color: "#bfbfbf" };
|
||||||
|
};
|
||||||
|
|
||||||
|
if (users.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<Card className={styles.card}>
|
||||||
|
<Empty description="暂无用户数据" />
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<Card className={styles.card}>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<div className={styles.title}>用户列表预览</div>
|
||||||
|
<div className={styles.userCount}>共 {users.length} 个用户</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{users.length > 0 && (
|
||||||
|
<div className={styles.batchActions}>
|
||||||
|
<Checkbox
|
||||||
|
checked={
|
||||||
|
selectedUsers.length === users.length && users.length > 0
|
||||||
|
}
|
||||||
|
onChange={handleSelectAll}
|
||||||
|
className={styles.selectAllCheckbox}
|
||||||
|
>
|
||||||
|
全选
|
||||||
|
</Checkbox>
|
||||||
|
{selectedUsers.length > 0 && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
color="danger"
|
||||||
|
fill="outline"
|
||||||
|
onClick={handleRemoveSelected}
|
||||||
|
className={styles.removeSelectedBtn}
|
||||||
|
>
|
||||||
|
移除选中 ({selectedUsers.length})
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={styles.userList}>
|
||||||
|
{users.map(user => {
|
||||||
|
const rfmInfo = getRfmLevel(user.rfmScore);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={user.id} className={styles.userItem}>
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedUsers.includes(user.id)}
|
||||||
|
onChange={checked => handleSelectUser(user.id, checked)}
|
||||||
|
className={styles.userCheckbox}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Avatar src={user.avatar} className={styles.userAvatar} />
|
||||||
|
|
||||||
|
<div className={styles.userInfo}>
|
||||||
|
<div className={styles.userName}>{user.name}</div>
|
||||||
|
<div className={styles.userId}>ID: {user.id}</div>
|
||||||
|
<div className={styles.userTags}>
|
||||||
|
{user.tags.map((tag, index) => (
|
||||||
|
<span key={index} className={styles.tag}>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className={styles.userStats}>
|
||||||
|
<span className={styles.statItem}>
|
||||||
|
RFM:{" "}
|
||||||
|
<span style={{ color: rfmInfo.color }}>
|
||||||
|
{rfmInfo.level}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className={styles.statItem}>
|
||||||
|
活跃: {user.lastActive}
|
||||||
|
</span>
|
||||||
|
<span className={styles.statItem}>
|
||||||
|
消费: ¥{user.consumption}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
fill="none"
|
||||||
|
onClick={() => onRemoveUser(user.id)}
|
||||||
|
className={styles.removeBtn}
|
||||||
|
>
|
||||||
|
<DeleteOutline />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UserListPreview;
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
.tabsContainer {
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
:global(.adm-tabs-header) {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.adm-tabs-tab) {
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.adm-tabs-tab-active) {
|
||||||
|
color: #1677ff;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 16px;
|
||||||
|
min-height: calc(100vh - 200px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
padding: 16px;
|
||||||
|
background: #fff;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonGroup {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prevButton {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nextButton {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submitButton {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
231
Cunkebao/src/pages/mobile/mine/traffic-pool/form/index.tsx
Normal file
231
Cunkebao/src/pages/mobile/mine/traffic-pool/form/index.tsx
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { Button } from "antd-mobile";
|
||||||
|
import Layout from "@/components/Layout/Layout";
|
||||||
|
import NavCommon from "@/components/NavCommon";
|
||||||
|
import BasicInfo from "./components/BasicInfo";
|
||||||
|
import AudienceFilter from "./components/AudienceFilter";
|
||||||
|
import UserListPreview from "./components/UserListPreview";
|
||||||
|
import styles from "./index.module.scss";
|
||||||
|
import StepIndicator from "@/components/StepIndicator";
|
||||||
|
|
||||||
|
const CreateTrafficPackage: React.FC = () => {
|
||||||
|
const [currentStep, setCurrentStep] = useState(1); // 1 基础信息 2 人群筛选 3 用户列表
|
||||||
|
const [submitting, setSubmitting] = useState(false); // 添加提交状态
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
// 基本信息
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
remarks: "",
|
||||||
|
// 筛选条件
|
||||||
|
filterConditions: [],
|
||||||
|
// 用户列表
|
||||||
|
filteredUsers: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
{ id: 1, title: "basic", subtitle: "基本信息" },
|
||||||
|
{ id: 2, title: "filter", subtitle: "人群筛选" },
|
||||||
|
{ id: 3, title: "users", subtitle: "预览" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleBasicInfoChange = (data: any) => {
|
||||||
|
setFormData(prev => ({ ...prev, ...data }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFilterChange = (conditions: any[]) => {
|
||||||
|
setFormData(prev => ({ ...prev, filterConditions: conditions }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGenerateUsers = (users: any[]) => {
|
||||||
|
setFormData(prev => ({ ...prev, filteredUsers: users }));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 初始化模拟数据
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (currentStep === 3 && formData.filteredUsers.length === 0) {
|
||||||
|
const mockUsers = [
|
||||||
|
{
|
||||||
|
id: "U00000001",
|
||||||
|
name: "张三",
|
||||||
|
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=1",
|
||||||
|
tags: ["高价值用户", "活跃用户"],
|
||||||
|
rfmScore: 12,
|
||||||
|
lastActive: "7天内",
|
||||||
|
consumption: 2500,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "U00000002",
|
||||||
|
name: "李四",
|
||||||
|
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=2",
|
||||||
|
tags: ["新用户", "价格敏感"],
|
||||||
|
rfmScore: 6,
|
||||||
|
lastActive: "3天内",
|
||||||
|
consumption: 800,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "U00000003",
|
||||||
|
name: "王五",
|
||||||
|
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=3",
|
||||||
|
tags: ["复购率高", "高潜力"],
|
||||||
|
rfmScore: 14,
|
||||||
|
lastActive: "1天内",
|
||||||
|
consumption: 3200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "U00000004",
|
||||||
|
name: "赵六",
|
||||||
|
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=4",
|
||||||
|
tags: ["已沉睡", "流失风险"],
|
||||||
|
rfmScore: 3,
|
||||||
|
lastActive: "30天内",
|
||||||
|
consumption: 200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "U00000005",
|
||||||
|
name: "钱七",
|
||||||
|
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=5",
|
||||||
|
tags: ["高价值用户", "复购率高"],
|
||||||
|
rfmScore: 15,
|
||||||
|
lastActive: "2天内",
|
||||||
|
consumption: 4500,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
setFormData(prev => ({ ...prev, filteredUsers: mockUsers }));
|
||||||
|
}
|
||||||
|
}, [currentStep, formData.filteredUsers.length]);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
// 防止重复提交
|
||||||
|
if (submitting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
// 提交逻辑
|
||||||
|
console.log("提交数据:", formData);
|
||||||
|
// 这里可以调用实际的 API
|
||||||
|
// await createTrafficPackage(formData);
|
||||||
|
|
||||||
|
// 模拟 API 调用
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
|
|
||||||
|
// 提交成功后可以跳转或显示成功消息
|
||||||
|
console.log("流量包创建成功");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("创建流量包失败:", error);
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const canSubmit = formData.name && formData.filterConditions.length > 0;
|
||||||
|
|
||||||
|
// 模拟生成用户数据
|
||||||
|
const generateMockUsers = (conditions: any[]) => {
|
||||||
|
const mockUsers = [];
|
||||||
|
const userCount = Math.floor(Math.random() * 1000) + 100; // 100-1100个用户
|
||||||
|
|
||||||
|
for (let i = 1; i <= userCount; i++) {
|
||||||
|
mockUsers.push({
|
||||||
|
id: `U${String(i).padStart(8, "0")}`,
|
||||||
|
name: `用户${i}`,
|
||||||
|
avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${i}`,
|
||||||
|
tags: ["高价值用户", "活跃用户"],
|
||||||
|
rfmScore: Math.floor(Math.random() * 15) + 1,
|
||||||
|
lastActive: "7天内",
|
||||||
|
consumption: Math.floor(Math.random() * 5000) + 100,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return mockUsers;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderFooter = () => {
|
||||||
|
return (
|
||||||
|
<div className={styles.footer}>
|
||||||
|
<div className={styles.buttonGroup}>
|
||||||
|
{currentStep > 1 && (
|
||||||
|
<Button
|
||||||
|
className={styles.prevButton}
|
||||||
|
onClick={() => setCurrentStep(s => Math.max(1, s - 1))}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
上一步
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{currentStep < 3 ? (
|
||||||
|
<Button
|
||||||
|
color="primary"
|
||||||
|
className={styles.nextButton}
|
||||||
|
onClick={() => {
|
||||||
|
if (currentStep === 2) {
|
||||||
|
// 在第二步时生成用户列表
|
||||||
|
const mockUsers = generateMockUsers(
|
||||||
|
formData.filterConditions,
|
||||||
|
);
|
||||||
|
handleGenerateUsers(mockUsers);
|
||||||
|
}
|
||||||
|
setCurrentStep(s => Math.min(3, s + 1));
|
||||||
|
}}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
下一步
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
color="primary"
|
||||||
|
className={styles.submitButton}
|
||||||
|
disabled={!canSubmit || submitting}
|
||||||
|
loading={submitting}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
>
|
||||||
|
{submitting ? "创建中..." : "创建流量包"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout
|
||||||
|
header={
|
||||||
|
<>
|
||||||
|
<NavCommon title="新建流量包" />
|
||||||
|
<StepIndicator currentStep={currentStep} steps={steps} />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
footer={renderFooter()}
|
||||||
|
>
|
||||||
|
<div className={styles.content}>
|
||||||
|
{currentStep === 1 && (
|
||||||
|
<BasicInfo data={formData} onChange={handleBasicInfoChange} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentStep === 2 && (
|
||||||
|
<AudienceFilter
|
||||||
|
conditions={formData.filterConditions}
|
||||||
|
onChange={handleFilterChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentStep === 3 && (
|
||||||
|
<UserListPreview
|
||||||
|
users={formData.filteredUsers}
|
||||||
|
onRemoveUser={userId => {
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
filteredUsers: prev.filteredUsers.filter(
|
||||||
|
(user: any) => user.id !== userId,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CreateTrafficPackage;
|
||||||
@@ -1,34 +1,31 @@
|
|||||||
import request from "@/api/request";
|
import request from "@/api/request";
|
||||||
|
|
||||||
// 获取流量池列表
|
export interface Package {
|
||||||
export function fetchTrafficPoolList(params: {
|
id: number;
|
||||||
page?: number;
|
name: string;
|
||||||
pageSize?: number;
|
description: string;
|
||||||
keyword?: string;
|
pic: string;
|
||||||
}) {
|
type: number;
|
||||||
return request("/v1/traffic/pool", params, "GET");
|
createTime: string;
|
||||||
|
num: number;
|
||||||
|
R: number;
|
||||||
|
F: number;
|
||||||
|
M: number;
|
||||||
|
RFM: number;
|
||||||
|
}
|
||||||
|
export interface PackageList {
|
||||||
|
list: Package[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
export async function getPackage(params: {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
keyword: string;
|
||||||
|
}): Promise<PackageList> {
|
||||||
|
return request("/v1/traffic/pool/getPackage", params, "GET");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchScenarioOptions() {
|
// 删除数据包
|
||||||
return request("/v1/plan/scenes", {}, "GET");
|
export async function deletePackage(id: number): Promise<{ success: boolean }> {
|
||||||
}
|
return request("/v1/traffic/pool/deletePackage", { id }, "POST");
|
||||||
|
|
||||||
export async function fetchPackageOptions() {
|
|
||||||
return request("/v1/traffic/pool/getPackage", {}, "GET");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function addPackage(params: {
|
|
||||||
type: string; // 类型 1搜索 2选择用户 3文件上传
|
|
||||||
addPackageId?: number;
|
|
||||||
addStatus?: number;
|
|
||||||
deviceId?: string;
|
|
||||||
keyword?: string;
|
|
||||||
packageId?: number;
|
|
||||||
packageName?: number; // 添加的流量池名称
|
|
||||||
tableFile?: number;
|
|
||||||
taskId?: number; // 任务id j及场景获客id
|
|
||||||
userIds?: number[];
|
|
||||||
userValue?: number;
|
|
||||||
}) {
|
|
||||||
return request("/v1/traffic/pool/addPackage", params, "POST");
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,65 +1,185 @@
|
|||||||
.listWrap {
|
.listWrap {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
|
background: #f5f5f5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cardContent {
|
/* 美团风格卡片样式 */
|
||||||
|
.cardCompact {
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
overflow: hidden;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardBody {
|
||||||
|
padding: 16px 30px 16px 16px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
.checkbox {
|
|
||||||
|
/* 三点菜单按钮 */
|
||||||
|
.menuButton {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
right: 8px;
|
||||||
left: 0;
|
top: 10px;
|
||||||
}
|
z-index: 10;
|
||||||
.cardWrap {
|
cursor: pointer;
|
||||||
background: #fff;
|
|
||||||
padding: 16px;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
/* 左侧图片区域 */
|
||||||
margin-bottom: 12px;
|
.imageBox {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 右侧内容区域 */
|
||||||
|
.contentArea {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 标题行 */
|
||||||
|
.titleRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #222;
|
color: #1a1a1a;
|
||||||
|
line-height: 1.3;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.desc {
|
/* 右侧标签区域 */
|
||||||
font-size: 13px;
|
.rightTags {
|
||||||
color: #888;
|
display: flex;
|
||||||
margin: 6px 0 4px 0;
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.count {
|
.deliveryTag {
|
||||||
font-size: 13px;
|
background: #fff7e6;
|
||||||
color: #1677ff;
|
color: #d46b08;
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #ffd591;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pagination {
|
.timeTag {
|
||||||
|
color: #ff6b35;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 评分和销售信息 */
|
||||||
|
.ratingRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating {
|
||||||
|
color: #ff6b35;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sales {
|
||||||
|
color: #8c8c8c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price {
|
||||||
|
color: #8c8c8c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 配送信息 */
|
||||||
|
.deliveryInfo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #8c8c8c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 中间标签 */
|
||||||
|
.middleTag {
|
||||||
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 16px;
|
margin: 4px 0;
|
||||||
margin: 16px 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.pagination button {
|
.highScoreTag {
|
||||||
background: #f5f5f5;
|
background: linear-gradient(135deg, #ff6b35, #ff8c42);
|
||||||
border: none;
|
color: white;
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 4px 8px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 4px 12px;
|
font-weight: 500;
|
||||||
color: #1677ff;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.pagination button:disabled {
|
/* 底部按钮区域 */
|
||||||
color: #ccc;
|
.bottomActions {
|
||||||
cursor: not-allowed;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dineInBtn {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid #52c41a;
|
||||||
|
color: #52c41a;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.couponBtn {
|
||||||
|
background: linear-gradient(135deg, #ff6b35, #ff8c42);
|
||||||
|
color: white;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
flex: 1;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.couponText {
|
||||||
|
font-size: 10px;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
margin-left: 4px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,184 +1,122 @@
|
|||||||
import React, { useCallback, useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import Layout from "@/components/Layout/Layout";
|
import Layout from "@/components/Layout/Layout";
|
||||||
import {
|
import {
|
||||||
SearchOutlined,
|
SearchOutlined,
|
||||||
ReloadOutlined,
|
ReloadOutlined,
|
||||||
BarChartOutlined,
|
PlusOutlined,
|
||||||
|
MoreOutlined,
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import { Toast } from "antd-mobile";
|
import { Input, Button, Pagination, Dropdown, message } from "antd";
|
||||||
import { Input, Button, Checkbox, Pagination } from "antd";
|
|
||||||
import styles from "./index.module.scss";
|
import styles from "./index.module.scss";
|
||||||
import { Empty, Avatar } from "antd-mobile";
|
import { Empty } from "antd-mobile";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import NavCommon from "@/components/NavCommon";
|
import NavCommon from "@/components/NavCommon";
|
||||||
import { fetchTrafficPoolList, fetchScenarioOptions, addPackage } from "./api";
|
import { getPackage, deletePackage } from "./api";
|
||||||
import type { TrafficPoolUser, ScenarioOption } from "./data";
|
import type { Package, PackageList } from "./api";
|
||||||
import DataAnalysisPanel from "./DataAnalysisPanel";
|
|
||||||
import FilterModal from "./FilterModal";
|
// 分组图标映射
|
||||||
import BatchAddModal from "./BatchAddModal";
|
const getGroupIcon = (type: number, name?: string) => {
|
||||||
import { DeviceSelectionItem } from "@/components/DeviceSelection/data";
|
if (type === 0 && name) {
|
||||||
const defaultAvatar =
|
// type=0时使用分组名称首个字符
|
||||||
"https://cdn.jsdelivr.net/gh/maokaka/static/avatar-default.png";
|
return name.charAt(0).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
const icons = {
|
||||||
|
1: "👥", // 高价值客户池
|
||||||
|
2: "📈", // 潜在客户池
|
||||||
|
3: "💬", // 高互动客户池
|
||||||
|
4: "⭐", // 自定义分组
|
||||||
|
};
|
||||||
|
return icons[type] || "👥";
|
||||||
|
};
|
||||||
|
|
||||||
|
// 分组颜色映射
|
||||||
|
const getGroupColor = (type: number) => {
|
||||||
|
const colors = {
|
||||||
|
0: "#f0f0f0", // 灰色 - 自定义分组(使用名称首字符)
|
||||||
|
1: "#ff4d4f", // 红色
|
||||||
|
2: "#1890ff", // 蓝色
|
||||||
|
3: "#52c41a", // 绿色
|
||||||
|
4: "#722ed1", // 紫色
|
||||||
|
};
|
||||||
|
return colors[type] || "#1890ff";
|
||||||
|
};
|
||||||
|
|
||||||
const TrafficPoolList: React.FC = () => {
|
const TrafficPoolList: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
// 基础状态
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [list, setList] = useState<TrafficPoolUser[]>([]);
|
const [list, setList] = useState<Package[]>([]);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [pageSize] = useState(10);
|
const [pageSize] = useState(10);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
// 筛选相关
|
const handleSearch = (value: string) => {
|
||||||
const [showFilter, setShowFilter] = useState(false);
|
setSearch(value);
|
||||||
const [scenarioOptions, setScenarioOptions] = useState<ScenarioOption[]>([]);
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
// 公共筛选条件状态
|
const handleRefresh = () => {
|
||||||
const [filterParams, setFilterParams] = useState({
|
setPage(1);
|
||||||
selectedDevices: [] as DeviceSelectionItem[],
|
// 触发数据重新获取
|
||||||
packageId: 0,
|
const fetchData = async () => {
|
||||||
scenarioId: 0,
|
setLoading(true);
|
||||||
userValue: 0,
|
try {
|
||||||
userStatus: 0,
|
const params = {
|
||||||
});
|
page: 1,
|
||||||
|
pageSize,
|
||||||
|
keyword: search,
|
||||||
|
};
|
||||||
|
|
||||||
// 批量相关
|
const res: PackageList = await getPackage(params);
|
||||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
setList(res?.list || []);
|
||||||
const [batchModal, setBatchModal] = useState(false);
|
setTotal(res?.total || 0);
|
||||||
|
} catch (error) {
|
||||||
// 数据分析
|
|
||||||
const [showStats, setShowStats] = useState(false);
|
|
||||||
|
|
||||||
// 获取列表
|
|
||||||
const getList = async (customParams?: any) => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const params: any = {
|
|
||||||
page,
|
|
||||||
pageSize,
|
|
||||||
keyword: search,
|
|
||||||
packageld: filterParams.packageId,
|
|
||||||
sceneId: filterParams.scenarioId,
|
|
||||||
userValue: filterParams.userValue,
|
|
||||||
addStatus: filterParams.userStatus,
|
|
||||||
deviceld: filterParams.selectedDevices.map(d => d.id).join(),
|
|
||||||
...customParams, // 允许传入自定义参数覆盖
|
|
||||||
};
|
|
||||||
|
|
||||||
const res = await fetchTrafficPoolList(params);
|
|
||||||
setList(res.list || []);
|
|
||||||
setTotal(res.total || 0);
|
|
||||||
} catch (error) {
|
|
||||||
// 忽略请求过于频繁的错误,避免页面崩溃
|
|
||||||
if (error !== "请求过于频繁,请稍后再试") {
|
|
||||||
console.error("获取列表失败:", error);
|
console.error("获取列表失败:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
} finally {
|
};
|
||||||
setLoading(false);
|
|
||||||
}
|
fetchData();
|
||||||
};
|
};
|
||||||
|
|
||||||
// 获取筛选项
|
const handleDelete = async (id: number, name: string) => {
|
||||||
useEffect(() => {
|
|
||||||
fetchScenarioOptions().then(res => {
|
|
||||||
setScenarioOptions(res.list || []);
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 全选/反选
|
|
||||||
const handleSelectAll = (checked: boolean) => {
|
|
||||||
if (checked) {
|
|
||||||
setSelectedIds(list.map(item => item.id));
|
|
||||||
} else {
|
|
||||||
setSelectedIds([]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 单选
|
|
||||||
const handleSelect = (id: number, checked: boolean) => {
|
|
||||||
setSelectedIds(prev =>
|
|
||||||
checked ? [...prev, id] : prev.filter(i => i !== id),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 批量加入分组/流量池
|
|
||||||
const handleBatchAdd = async options => {
|
|
||||||
try {
|
try {
|
||||||
// 构建请求参数
|
// eslint-disable-next-line no-alert
|
||||||
const params = {
|
if (!confirm(`确认删除数据包“${name}”吗?`)) return;
|
||||||
type: "2", // 2选择用户
|
await deletePackage(id);
|
||||||
addPackageId: options.selectedPackageId, // 目标分组ID
|
message.success("已删除");
|
||||||
userIds: selectedIds.map(id => id), // 选中的用户ID数组
|
handleRefresh();
|
||||||
// 如果有当前筛选条件,也可以传递
|
} catch (e) {
|
||||||
...(filterParams.packageId && {
|
console.error(e);
|
||||||
packageId: filterParams.packageId,
|
message.error("删除失败");
|
||||||
}),
|
|
||||||
...(filterParams.scenarioId && {
|
|
||||||
taskId: filterParams.scenarioId,
|
|
||||||
}),
|
|
||||||
...(filterParams.userValue && {
|
|
||||||
userValue: filterParams.userValue,
|
|
||||||
}),
|
|
||||||
...(filterParams.userStatus && {
|
|
||||||
addStatus: filterParams.userStatus,
|
|
||||||
}),
|
|
||||||
...(filterParams.selectedDevices.length > 0 && {
|
|
||||||
deviceId: filterParams.selectedDevices.map(d => d.id).join(","),
|
|
||||||
}),
|
|
||||||
...(search && { keyword: search }),
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log("批量加入请求参数:", params);
|
|
||||||
|
|
||||||
// 调用接口
|
|
||||||
const result = await addPackage(params);
|
|
||||||
console.log("批量加入结果:", result);
|
|
||||||
|
|
||||||
// 成功后刷新列表
|
|
||||||
getList();
|
|
||||||
|
|
||||||
// 关闭弹窗并清空选择
|
|
||||||
setBatchModal(false);
|
|
||||||
setSelectedIds([]);
|
|
||||||
|
|
||||||
// 可以添加成功提示
|
|
||||||
Toast.show({
|
|
||||||
content: `成功将用户加入分组`,
|
|
||||||
position: "top",
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("批量加入失败:", error);
|
|
||||||
// 可以添加错误提示
|
|
||||||
Toast.show({ content: "批量加入失败,请重试", position: "top" });
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 搜索防抖处理
|
|
||||||
const [searchInput, setSearchInput] = useState(search);
|
|
||||||
|
|
||||||
const debouncedSearch = useCallback(() => {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
setSearch(searchInput);
|
|
||||||
// 搜索时重置到第一页并请求列表
|
|
||||||
setPage(1);
|
|
||||||
getList({ keyword: searchInput, page: 1 });
|
|
||||||
}, 500); // 500ms 防抖延迟
|
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [searchInput]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const cleanup = debouncedSearch();
|
const fetchData = async () => {
|
||||||
return cleanup;
|
setLoading(true);
|
||||||
}, [debouncedSearch]);
|
try {
|
||||||
|
const params = {
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
keyword: search,
|
||||||
|
};
|
||||||
|
|
||||||
const handSearch = (value: string) => {
|
const res: PackageList = await getPackage(params);
|
||||||
setSearchInput(value);
|
setList(res?.list || []);
|
||||||
setSelectedIds([]);
|
setTotal(res?.total || 0);
|
||||||
debouncedSearch();
|
} catch (error) {
|
||||||
};
|
console.error("获取列表失败:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, [page, pageSize, search]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout
|
<Layout
|
||||||
@@ -186,96 +124,38 @@ const TrafficPoolList: React.FC = () => {
|
|||||||
header={
|
header={
|
||||||
<>
|
<>
|
||||||
<NavCommon
|
<NavCommon
|
||||||
title="流量池用户列表"
|
title="流量池"
|
||||||
right={
|
right={
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setShowStats(s => !s)}
|
type="primary"
|
||||||
style={{ marginLeft: 8 }}
|
size="small"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
navigate("/mine/traffic-pool/create");
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<BarChartOutlined /> {showStats ? "收起分析" : "数据分析"}
|
新建分组
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{/* 搜索栏 */}
|
|
||||||
<div className="search-bar">
|
<div className="search-bar">
|
||||||
<div className="search-input-wrapper">
|
<div className="search-input-wrapper">
|
||||||
<Input
|
<Input
|
||||||
placeholder="搜索计划名称"
|
placeholder="搜索分组"
|
||||||
value={searchInput}
|
value={search}
|
||||||
onChange={e => handSearch(e.target.value)}
|
onChange={e => handleSearch(e.target.value)}
|
||||||
prefix={<SearchOutlined />}
|
prefix={<SearchOutlined />}
|
||||||
allowClear
|
allowClear
|
||||||
size="large"
|
size="large"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => getList()}
|
onClick={handleRefresh}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
size="large"
|
size="large"
|
||||||
icon={<ReloadOutlined />}
|
icon={<ReloadOutlined />}
|
||||||
></Button>
|
|
||||||
</div>
|
|
||||||
{/* 数据分析面板 */}
|
|
||||||
<DataAnalysisPanel
|
|
||||||
showStats={showStats}
|
|
||||||
setShowStats={setShowStats}
|
|
||||||
onConfirm={statsData => {
|
|
||||||
// 可以在这里处理统计数据,比如更新本地状态或发送到父组件
|
|
||||||
console.log("收到统计数据:", statsData);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 批量操作栏 */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
padding: "8px 12px 8px 26px",
|
|
||||||
background: "#fff",
|
|
||||||
borderBottom: "1px solid #f0f0f0",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Checkbox
|
|
||||||
checked={selectedIds.length === list.length && list.length > 0}
|
|
||||||
onChange={e => handleSelectAll(e.target.checked)}
|
|
||||||
style={{ marginRight: 8 }}
|
|
||||||
/>
|
/>
|
||||||
<span>全选</span>
|
|
||||||
{selectedIds.length > 0 && (
|
|
||||||
<>
|
|
||||||
<span
|
|
||||||
style={{ marginLeft: 16, color: "#1677ff" }}
|
|
||||||
>{`已选${selectedIds.length}项`}</span>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
color="primary"
|
|
||||||
style={{ marginLeft: 16 }}
|
|
||||||
onClick={() => setBatchModal(true)}
|
|
||||||
>
|
|
||||||
批量加入分组
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{searchInput.length > 0 && (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
type="primary"
|
|
||||||
style={{ marginLeft: 16 }}
|
|
||||||
onClick={() => setBatchModal(true)}
|
|
||||||
>
|
|
||||||
导入当前搜索结果
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<div style={{ flex: 1 }} />
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
style={{ marginLeft: 8 }}
|
|
||||||
onClick={() => setShowFilter(true)}
|
|
||||||
>
|
|
||||||
筛选
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
@@ -283,103 +163,107 @@ const TrafficPoolList: React.FC = () => {
|
|||||||
<div className="pagination-container">
|
<div className="pagination-container">
|
||||||
<Pagination
|
<Pagination
|
||||||
current={page}
|
current={page}
|
||||||
pageSize={20}
|
pageSize={pageSize}
|
||||||
total={total}
|
total={total}
|
||||||
showSizeChanger={false}
|
showSizeChanger={false}
|
||||||
onChange={newPage => {
|
onChange={setPage}
|
||||||
setPage(newPage);
|
|
||||||
getList({ page: newPage });
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{/* 批量加入分组弹窗 */}
|
|
||||||
<BatchAddModal
|
|
||||||
visible={batchModal}
|
|
||||||
onClose={() => setBatchModal(false)}
|
|
||||||
selectedCount={selectedIds.length}
|
|
||||||
onConfirm={data => {
|
|
||||||
// 处理批量加入逻辑
|
|
||||||
handleBatchAdd(data);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/* 筛选弹窗 */}
|
|
||||||
<FilterModal
|
|
||||||
visible={showFilter}
|
|
||||||
onClose={() => setShowFilter(false)}
|
|
||||||
onConfirm={filters => {
|
|
||||||
// 更新公共筛选条件状态
|
|
||||||
const newFilterParams = {
|
|
||||||
selectedDevices: filters.selectedDevices,
|
|
||||||
packageId: filters.packageld,
|
|
||||||
scenarioId: filters.sceneId,
|
|
||||||
userValue: filters.userValue,
|
|
||||||
userStatus: filters.addStatus,
|
|
||||||
};
|
|
||||||
|
|
||||||
setFilterParams(newFilterParams);
|
|
||||||
// 重置到第一页并请求列表
|
|
||||||
setPage(1);
|
|
||||||
getList({
|
|
||||||
page: 1,
|
|
||||||
packageld: newFilterParams.packageId,
|
|
||||||
sceneId: newFilterParams.scenarioId,
|
|
||||||
userValue: newFilterParams.userValue,
|
|
||||||
addStatus: newFilterParams.userStatus,
|
|
||||||
deviceld: newFilterParams.selectedDevices.map(d => d.id).join(),
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
scenarioOptions={scenarioOptions}
|
|
||||||
initialFilters={filterParams}
|
|
||||||
/>
|
|
||||||
<div className={styles.listWrap}>
|
<div className={styles.listWrap}>
|
||||||
{list.length === 0 && !loading ? (
|
{list.length === 0 && !loading ? (
|
||||||
<Empty description="暂无数据" />
|
<Empty description="暂无分组数据" />
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
{list.map(item => (
|
{list.map(item => (
|
||||||
<div key={item.id} className={styles.cardWrap}>
|
<div key={item.id} className={styles.cardCompact}>
|
||||||
<div
|
<div className={styles.cardBody}>
|
||||||
className={styles.card}
|
<div
|
||||||
style={{ cursor: "pointer" }}
|
className={styles.menuButton}
|
||||||
onClick={() =>
|
onClick={e => e.stopPropagation()}
|
||||||
navigate(
|
>
|
||||||
`/mine/traffic-pool/detail/${item.wechatId}/${item.id}`,
|
<Dropdown
|
||||||
)
|
menu={{
|
||||||
}
|
items: [
|
||||||
>
|
{
|
||||||
<div className={styles.cardContent}>
|
key: "preview",
|
||||||
<Checkbox
|
label: "预览用户",
|
||||||
checked={selectedIds.includes(item.id)}
|
onClick: () =>
|
||||||
onChange={e => handleSelect(item.id, e.target.checked)}
|
navigate(
|
||||||
style={{ marginRight: 8 }}
|
`/mine/traffic-pool/userList/${item.id}`,
|
||||||
onClick={e => e.stopPropagation()}
|
),
|
||||||
className={styles.checkbox}
|
},
|
||||||
/>
|
{
|
||||||
<Avatar
|
key: "delete",
|
||||||
src={item.avatar || defaultAvatar}
|
danger: true,
|
||||||
style={{ "--size": "60px" }}
|
label: "删除数据包",
|
||||||
/>
|
onClick: () => handleDelete(item.id, item.name),
|
||||||
<div style={{ flex: 1 }}>
|
},
|
||||||
<div className={styles.title}>
|
],
|
||||||
{item.nickname || item.identifier}
|
}}
|
||||||
{/* 性别icon可自行封装 */}
|
trigger={["click"]}
|
||||||
|
>
|
||||||
|
<MoreOutlined />
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{ display: "flex", gap: 10, flex: 1 }}
|
||||||
|
onClick={() =>
|
||||||
|
navigate(`/mine/traffic-pool/userList/${item.id}`)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* 左侧图片区域(优先展示 pic,缺省时使用假头像) */}
|
||||||
|
<div
|
||||||
|
className={styles.imageBox}
|
||||||
|
style={{ background: getGroupColor(item.type) }}
|
||||||
|
>
|
||||||
|
{item.pic ? (
|
||||||
|
<img
|
||||||
|
src={item.pic}
|
||||||
|
alt={item.name}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
objectFit: "cover",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#333",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{getGroupIcon(item.type, item.name)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右侧仅展示选中字段 */}
|
||||||
|
<div className={styles.contentArea}>
|
||||||
|
{/* 标题与人数 */}
|
||||||
|
<div className={styles.titleRow}>
|
||||||
|
<div className={styles.title}>{item.name}</div>
|
||||||
|
<div className={styles.timeTag}>共{item.num}人</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.desc}>
|
|
||||||
微信号:{item.wechatId || "-"}
|
{/* RFM 汇总 */}
|
||||||
|
<div className={styles.ratingRow}>
|
||||||
|
<span className={styles.rating}>RFM:{item.RFM}</span>
|
||||||
|
<span className={styles.sales}>
|
||||||
|
R:{item.R} F:{item.F} M:{item.M}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.desc}>
|
|
||||||
来源:{item.fromd || "-"}
|
{/* 类型与创建时间 */}
|
||||||
</div>
|
<div className={styles.deliveryInfo}>
|
||||||
<div className={styles.desc}>
|
<span>
|
||||||
分组:
|
类型: {item.type === 0 ? "自定义" : "系统分组"}
|
||||||
{item.packages && item.packages.length
|
</span>
|
||||||
? item.packages.join(",")
|
<span>创建:{item.createTime || "-"}</span>
|
||||||
: "-"}
|
|
||||||
</div>
|
|
||||||
<div className={styles.desc}>
|
|
||||||
创建时间:{item.createTime}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -392,5 +276,5 @@ const TrafficPoolList: React.FC = () => {
|
|||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
//EEws
|
||||||
export default TrafficPoolList;
|
export default TrafficPoolList;
|
||||||
|
|||||||
34
Cunkebao/src/pages/mobile/mine/traffic-pool/poolList1/api.ts
Normal file
34
Cunkebao/src/pages/mobile/mine/traffic-pool/poolList1/api.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import request from "@/api/request";
|
||||||
|
|
||||||
|
// 获取流量池列表
|
||||||
|
export function fetchTrafficPoolList(params: {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
keyword?: string;
|
||||||
|
}) {
|
||||||
|
return request("/v1/traffic/pool", params, "GET");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchScenarioOptions() {
|
||||||
|
return request("/v1/plan/scenes", {}, "GET");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPackageOptions() {
|
||||||
|
return request("/v1/traffic/pool/getPackage", {}, "GET");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addPackage(params: {
|
||||||
|
type: string; // 类型 1搜索 2选择用户 3文件上传
|
||||||
|
addPackageId?: number;
|
||||||
|
addStatus?: number;
|
||||||
|
deviceId?: string;
|
||||||
|
keyword?: string;
|
||||||
|
packageId?: number;
|
||||||
|
packageName?: number; // 添加的流量池名称
|
||||||
|
tableFile?: number;
|
||||||
|
taskId?: number; // 任务id j及场景获客id
|
||||||
|
userIds?: number[];
|
||||||
|
userValue?: number;
|
||||||
|
}) {
|
||||||
|
return request("/v1/traffic/pool/addPackage", params, "POST");
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
// 流量池用户类型
|
||||||
|
export interface TrafficPoolUser {
|
||||||
|
id: number;
|
||||||
|
identifier: string;
|
||||||
|
mobile: string;
|
||||||
|
wechatId: string;
|
||||||
|
fromd: string;
|
||||||
|
status: number;
|
||||||
|
createTime: string;
|
||||||
|
companyId: number;
|
||||||
|
sourceId: string;
|
||||||
|
type: number;
|
||||||
|
nickname: string;
|
||||||
|
avatar: string;
|
||||||
|
gender: number;
|
||||||
|
phone: string;
|
||||||
|
packages: string[];
|
||||||
|
tags: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 列表响应类型
|
||||||
|
export interface TrafficPoolUserListResponse {
|
||||||
|
list: TrafficPoolUser[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设备类型
|
||||||
|
export interface DeviceOption {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分组类型
|
||||||
|
export interface PackageOption {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户价值类型
|
||||||
|
export type ValueLevel = "all" | "high" | "medium" | "low";
|
||||||
|
|
||||||
|
// 状态类型
|
||||||
|
export type UserStatus = "all" | "added" | "pending" | "failed" | "duplicate";
|
||||||
|
|
||||||
|
// 获客场景类型
|
||||||
|
export interface ScenarioOption {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
.listWrap {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardContent {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.checkbox {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
.cardWrap {
|
||||||
|
background: #fff;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desc {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #888;
|
||||||
|
margin: 6px 0 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #1677ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination button {
|
||||||
|
background: #f5f5f5;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
color: #1677ff;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination button:disabled {
|
||||||
|
color: #ccc;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
396
Cunkebao/src/pages/mobile/mine/traffic-pool/poolList1/index.tsx
Normal file
396
Cunkebao/src/pages/mobile/mine/traffic-pool/poolList1/index.tsx
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
|
import Layout from "@/components/Layout/Layout";
|
||||||
|
import {
|
||||||
|
SearchOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
BarChartOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
|
import { Toast } from "antd-mobile";
|
||||||
|
import { Input, Button, Checkbox, Pagination } from "antd";
|
||||||
|
import styles from "./index.module.scss";
|
||||||
|
import { Empty, Avatar } from "antd-mobile";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import NavCommon from "@/components/NavCommon";
|
||||||
|
import { fetchTrafficPoolList, fetchScenarioOptions, addPackage } from "./api";
|
||||||
|
import type { TrafficPoolUser, ScenarioOption } from "./data";
|
||||||
|
import DataAnalysisPanel from "./DataAnalysisPanel";
|
||||||
|
import FilterModal from "./FilterModal";
|
||||||
|
import BatchAddModal from "./BatchAddModal";
|
||||||
|
import { DeviceSelectionItem } from "@/components/DeviceSelection/data";
|
||||||
|
const defaultAvatar =
|
||||||
|
"https://cdn.jsdelivr.net/gh/maokaka/static/avatar-default.png";
|
||||||
|
|
||||||
|
const TrafficPoolList: React.FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// 基础状态
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [list, setList] = useState<TrafficPoolUser[]>([]);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize] = useState(10);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
|
// 筛选相关
|
||||||
|
const [showFilter, setShowFilter] = useState(false);
|
||||||
|
const [scenarioOptions, setScenarioOptions] = useState<ScenarioOption[]>([]);
|
||||||
|
|
||||||
|
// 公共筛选条件状态
|
||||||
|
const [filterParams, setFilterParams] = useState({
|
||||||
|
selectedDevices: [] as DeviceSelectionItem[],
|
||||||
|
packageId: 0,
|
||||||
|
scenarioId: 0,
|
||||||
|
userValue: 0,
|
||||||
|
userStatus: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 批量相关
|
||||||
|
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||||
|
const [batchModal, setBatchModal] = useState(false);
|
||||||
|
|
||||||
|
// 数据分析
|
||||||
|
const [showStats, setShowStats] = useState(false);
|
||||||
|
|
||||||
|
// 获取列表
|
||||||
|
const getList = async (customParams?: any) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const params: any = {
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
keyword: search,
|
||||||
|
packageld: filterParams.packageId,
|
||||||
|
sceneId: filterParams.scenarioId,
|
||||||
|
userValue: filterParams.userValue,
|
||||||
|
addStatus: filterParams.userStatus,
|
||||||
|
deviceld: filterParams.selectedDevices.map(d => d.id).join(),
|
||||||
|
...customParams, // 允许传入自定义参数覆盖
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await fetchTrafficPoolList(params);
|
||||||
|
setList(res.list || []);
|
||||||
|
setTotal(res.total || 0);
|
||||||
|
} catch (error) {
|
||||||
|
// 忽略请求过于频繁的错误,避免页面崩溃
|
||||||
|
if (error !== "请求过于频繁,请稍后再试") {
|
||||||
|
console.error("获取列表失败:", error);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取筛选项
|
||||||
|
useEffect(() => {
|
||||||
|
fetchScenarioOptions().then(res => {
|
||||||
|
setScenarioOptions(res.list || []);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 全选/反选
|
||||||
|
const handleSelectAll = (checked: boolean) => {
|
||||||
|
if (checked) {
|
||||||
|
setSelectedIds(list.map(item => item.id));
|
||||||
|
} else {
|
||||||
|
setSelectedIds([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 单选
|
||||||
|
const handleSelect = (id: number, checked: boolean) => {
|
||||||
|
setSelectedIds(prev =>
|
||||||
|
checked ? [...prev, id] : prev.filter(i => i !== id),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 批量加入分组/流量池
|
||||||
|
const handleBatchAdd = async options => {
|
||||||
|
try {
|
||||||
|
// 构建请求参数
|
||||||
|
const params = {
|
||||||
|
type: "2", // 2选择用户
|
||||||
|
addPackageId: options.selectedPackageId, // 目标分组ID
|
||||||
|
userIds: selectedIds.map(id => id), // 选中的用户ID数组
|
||||||
|
// 如果有当前筛选条件,也可以传递
|
||||||
|
...(filterParams.packageId && {
|
||||||
|
packageId: filterParams.packageId,
|
||||||
|
}),
|
||||||
|
...(filterParams.scenarioId && {
|
||||||
|
taskId: filterParams.scenarioId,
|
||||||
|
}),
|
||||||
|
...(filterParams.userValue && {
|
||||||
|
userValue: filterParams.userValue,
|
||||||
|
}),
|
||||||
|
...(filterParams.userStatus && {
|
||||||
|
addStatus: filterParams.userStatus,
|
||||||
|
}),
|
||||||
|
...(filterParams.selectedDevices.length > 0 && {
|
||||||
|
deviceId: filterParams.selectedDevices.map(d => d.id).join(","),
|
||||||
|
}),
|
||||||
|
...(search && { keyword: search }),
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("批量加入请求参数:", params);
|
||||||
|
|
||||||
|
// 调用接口
|
||||||
|
const result = await addPackage(params);
|
||||||
|
console.log("批量加入结果:", result);
|
||||||
|
|
||||||
|
// 成功后刷新列表
|
||||||
|
getList();
|
||||||
|
|
||||||
|
// 关闭弹窗并清空选择
|
||||||
|
setBatchModal(false);
|
||||||
|
setSelectedIds([]);
|
||||||
|
|
||||||
|
// 可以添加成功提示
|
||||||
|
Toast.show({
|
||||||
|
content: `成功将用户加入分组`,
|
||||||
|
position: "top",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("批量加入失败:", error);
|
||||||
|
// 可以添加错误提示
|
||||||
|
Toast.show({ content: "批量加入失败,请重试", position: "top" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 搜索防抖处理
|
||||||
|
const [searchInput, setSearchInput] = useState(search);
|
||||||
|
|
||||||
|
const debouncedSearch = useCallback(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setSearch(searchInput);
|
||||||
|
// 搜索时重置到第一页并请求列表
|
||||||
|
setPage(1);
|
||||||
|
getList({ keyword: searchInput, page: 1 });
|
||||||
|
}, 500); // 500ms 防抖延迟
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [searchInput]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const cleanup = debouncedSearch();
|
||||||
|
return cleanup;
|
||||||
|
}, [debouncedSearch]);
|
||||||
|
|
||||||
|
const handSearch = (value: string) => {
|
||||||
|
setSearchInput(value);
|
||||||
|
setSelectedIds([]);
|
||||||
|
debouncedSearch();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout
|
||||||
|
loading={loading}
|
||||||
|
header={
|
||||||
|
<>
|
||||||
|
<NavCommon
|
||||||
|
title="流量池用户列表"
|
||||||
|
right={
|
||||||
|
<Button
|
||||||
|
onClick={() => setShowStats(s => !s)}
|
||||||
|
style={{ marginLeft: 8 }}
|
||||||
|
>
|
||||||
|
<BarChartOutlined /> {showStats ? "收起分析" : "数据分析"}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{/* 搜索栏 */}
|
||||||
|
<div className="search-bar">
|
||||||
|
<div className="search-input-wrapper">
|
||||||
|
<Input
|
||||||
|
placeholder="搜索计划名称"
|
||||||
|
value={searchInput}
|
||||||
|
onChange={e => handSearch(e.target.value)}
|
||||||
|
prefix={<SearchOutlined />}
|
||||||
|
allowClear
|
||||||
|
size="large"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => getList()}
|
||||||
|
loading={loading}
|
||||||
|
size="large"
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
></Button>
|
||||||
|
</div>
|
||||||
|
{/* 数据分析面板 */}
|
||||||
|
<DataAnalysisPanel
|
||||||
|
showStats={showStats}
|
||||||
|
setShowStats={setShowStats}
|
||||||
|
onConfirm={statsData => {
|
||||||
|
// 可以在这里处理统计数据,比如更新本地状态或发送到父组件
|
||||||
|
console.log("收到统计数据:", statsData);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 批量操作栏 */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: "8px 12px 8px 26px",
|
||||||
|
background: "#fff",
|
||||||
|
borderBottom: "1px solid #f0f0f0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedIds.length === list.length && list.length > 0}
|
||||||
|
onChange={e => handleSelectAll(e.target.checked)}
|
||||||
|
style={{ marginRight: 8 }}
|
||||||
|
/>
|
||||||
|
<span>全选</span>
|
||||||
|
{selectedIds.length > 0 && (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
style={{ marginLeft: 16, color: "#1677ff" }}
|
||||||
|
>{`已选${selectedIds.length}项`}</span>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
style={{ marginLeft: 16 }}
|
||||||
|
onClick={() => setBatchModal(true)}
|
||||||
|
>
|
||||||
|
批量加入分组
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{searchInput.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
style={{ marginLeft: 16 }}
|
||||||
|
onClick={() => setBatchModal(true)}
|
||||||
|
>
|
||||||
|
导入当前搜索结果
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div style={{ flex: 1 }} />
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
style={{ marginLeft: 8 }}
|
||||||
|
onClick={() => setShowFilter(true)}
|
||||||
|
>
|
||||||
|
筛选
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
footer={
|
||||||
|
<div className="pagination-container">
|
||||||
|
<Pagination
|
||||||
|
current={page}
|
||||||
|
pageSize={20}
|
||||||
|
total={total}
|
||||||
|
showSizeChanger={false}
|
||||||
|
onChange={newPage => {
|
||||||
|
setPage(newPage);
|
||||||
|
getList({ page: newPage });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* 批量加入分组弹窗 */}
|
||||||
|
<BatchAddModal
|
||||||
|
visible={batchModal}
|
||||||
|
onClose={() => setBatchModal(false)}
|
||||||
|
selectedCount={selectedIds.length}
|
||||||
|
onConfirm={data => {
|
||||||
|
// 处理批量加入逻辑
|
||||||
|
handleBatchAdd(data);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* 筛选弹窗 */}
|
||||||
|
<FilterModal
|
||||||
|
visible={showFilter}
|
||||||
|
onClose={() => setShowFilter(false)}
|
||||||
|
onConfirm={filters => {
|
||||||
|
// 更新公共筛选条件状态
|
||||||
|
const newFilterParams = {
|
||||||
|
selectedDevices: filters.selectedDevices,
|
||||||
|
packageId: filters.packageld,
|
||||||
|
scenarioId: filters.sceneId,
|
||||||
|
userValue: filters.userValue,
|
||||||
|
userStatus: filters.addStatus,
|
||||||
|
};
|
||||||
|
|
||||||
|
setFilterParams(newFilterParams);
|
||||||
|
// 重置到第一页并请求列表
|
||||||
|
setPage(1);
|
||||||
|
getList({
|
||||||
|
page: 1,
|
||||||
|
packageld: newFilterParams.packageId,
|
||||||
|
sceneId: newFilterParams.scenarioId,
|
||||||
|
userValue: newFilterParams.userValue,
|
||||||
|
addStatus: newFilterParams.userStatus,
|
||||||
|
deviceld: newFilterParams.selectedDevices.map(d => d.id).join(),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
scenarioOptions={scenarioOptions}
|
||||||
|
initialFilters={filterParams}
|
||||||
|
/>
|
||||||
|
<div className={styles.listWrap}>
|
||||||
|
{list.length === 0 && !loading ? (
|
||||||
|
<Empty description="暂无数据" />
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
{list.map(item => (
|
||||||
|
<div key={item.id} className={styles.cardWrap}>
|
||||||
|
<div
|
||||||
|
className={styles.card}
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
onClick={() =>
|
||||||
|
navigate(
|
||||||
|
`/mine/traffic-pool/detail/${item.wechatId}/${item.id}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={styles.cardContent}>
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedIds.includes(item.id)}
|
||||||
|
onChange={e => handleSelect(item.id, e.target.checked)}
|
||||||
|
style={{ marginRight: 8 }}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
className={styles.checkbox}
|
||||||
|
/>
|
||||||
|
<Avatar
|
||||||
|
src={item.avatar || defaultAvatar}
|
||||||
|
style={{ "--size": "60px" }}
|
||||||
|
/>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div className={styles.title}>
|
||||||
|
{item.nickname || item.identifier}
|
||||||
|
{/* 性别icon可自行封装 */}
|
||||||
|
</div>
|
||||||
|
<div className={styles.desc}>
|
||||||
|
微信号:{item.wechatId || "-"}
|
||||||
|
</div>
|
||||||
|
<div className={styles.desc}>
|
||||||
|
来源:{item.fromd || "-"}
|
||||||
|
</div>
|
||||||
|
<div className={styles.desc}>
|
||||||
|
分组:
|
||||||
|
{item.packages && item.packages.length
|
||||||
|
? item.packages.join(",")
|
||||||
|
: "-"}
|
||||||
|
</div>
|
||||||
|
<div className={styles.desc}>
|
||||||
|
创建时间:{item.createTime}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TrafficPoolList;
|
||||||
11
Cunkebao/src/pages/mobile/mine/traffic-pool/userList/api.ts
Normal file
11
Cunkebao/src/pages/mobile/mine/traffic-pool/userList/api.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import request from "@/api/request";
|
||||||
|
|
||||||
|
// 获取流量包用户列表
|
||||||
|
export function fetchTrafficPoolList(params: {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
keyword?: string;
|
||||||
|
packageId?: string;
|
||||||
|
}) {
|
||||||
|
return request("/v1/traffic/pool/users", params, "GET");
|
||||||
|
}
|
||||||
27
Cunkebao/src/pages/mobile/mine/traffic-pool/userList/data.ts
Normal file
27
Cunkebao/src/pages/mobile/mine/traffic-pool/userList/data.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
// 流量池用户类型
|
||||||
|
export interface TrafficPoolUser {
|
||||||
|
id: number;
|
||||||
|
identifier: string;
|
||||||
|
mobile: string;
|
||||||
|
wechatId: string;
|
||||||
|
fromd: string;
|
||||||
|
status: number;
|
||||||
|
createTime: string;
|
||||||
|
companyId: number;
|
||||||
|
sourceId: string;
|
||||||
|
type: number;
|
||||||
|
nickname: string;
|
||||||
|
avatar: string;
|
||||||
|
gender: number;
|
||||||
|
phone: string;
|
||||||
|
packages: string[];
|
||||||
|
tags: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 列表响应类型
|
||||||
|
export interface TrafficPoolUserListResponse {
|
||||||
|
list: TrafficPoolUser[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
.listWrap {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardWrap {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||||
|
overflow: hidden;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardContent {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 16px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desc {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
175
Cunkebao/src/pages/mobile/mine/traffic-pool/userList/index.tsx
Normal file
175
Cunkebao/src/pages/mobile/mine/traffic-pool/userList/index.tsx
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
|
import Layout from "@/components/Layout/Layout";
|
||||||
|
import { SearchOutlined, ReloadOutlined } from "@ant-design/icons";
|
||||||
|
import { Input, Button, Pagination } from "antd";
|
||||||
|
import styles from "./index.module.scss";
|
||||||
|
import { Empty, Avatar } from "antd-mobile";
|
||||||
|
import NavCommon from "@/components/NavCommon";
|
||||||
|
import { fetchTrafficPoolList } from "./api";
|
||||||
|
import type { TrafficPoolUser } from "./data";
|
||||||
|
|
||||||
|
const defaultAvatar =
|
||||||
|
"https://cdn.jsdelivr.net/gh/maokaka/static/avatar-default.png";
|
||||||
|
|
||||||
|
const TrafficPoolUserList: React.FC = () => {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// 基础状态
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [list, setList] = useState<TrafficPoolUser[]>([]);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize] = useState(10);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
|
// 获取列表
|
||||||
|
const getList = async (customParams?: any) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const params: any = {
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
keyword: search,
|
||||||
|
packageId: id, // 根据流量包ID筛选用户
|
||||||
|
...customParams, // 允许传入自定义参数覆盖
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await fetchTrafficPoolList(params);
|
||||||
|
setList(res.list || []);
|
||||||
|
setTotal(res.total || 0);
|
||||||
|
} catch (error) {
|
||||||
|
// 忽略请求过于频繁的错误,避免页面崩溃
|
||||||
|
if (error !== "请求过于频繁,请稍后再试") {
|
||||||
|
console.error("获取列表失败:", error);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 搜索防抖处理
|
||||||
|
const [searchInput, setSearchInput] = useState(search);
|
||||||
|
|
||||||
|
const debouncedSearch = useCallback(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setSearch(searchInput);
|
||||||
|
// 搜索时重置到第一页并请求列表
|
||||||
|
setPage(1);
|
||||||
|
getList({ keyword: searchInput, page: 1 });
|
||||||
|
}, 500); // 500ms 防抖延迟
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [searchInput]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const cleanup = debouncedSearch();
|
||||||
|
return cleanup;
|
||||||
|
}, [debouncedSearch]);
|
||||||
|
|
||||||
|
const handSearch = (value: string) => {
|
||||||
|
setSearchInput(value);
|
||||||
|
debouncedSearch();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 初始加载和参数变化时重新获取数据
|
||||||
|
useEffect(() => {
|
||||||
|
getList();
|
||||||
|
}, [page, pageSize, search, id]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout
|
||||||
|
loading={loading}
|
||||||
|
header={
|
||||||
|
<>
|
||||||
|
<NavCommon title="用户列表" />
|
||||||
|
{/* 搜索栏 */}
|
||||||
|
<div className="search-bar">
|
||||||
|
<div className="search-input-wrapper">
|
||||||
|
<Input
|
||||||
|
placeholder="搜索用户"
|
||||||
|
value={searchInput}
|
||||||
|
onChange={e => handSearch(e.target.value)}
|
||||||
|
prefix={<SearchOutlined />}
|
||||||
|
allowClear
|
||||||
|
size="large"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => getList()}
|
||||||
|
loading={loading}
|
||||||
|
size="large"
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
footer={
|
||||||
|
<div className="pagination-container">
|
||||||
|
<Pagination
|
||||||
|
current={page}
|
||||||
|
pageSize={pageSize}
|
||||||
|
total={total}
|
||||||
|
showSizeChanger={false}
|
||||||
|
onChange={newPage => {
|
||||||
|
setPage(newPage);
|
||||||
|
getList({ page: newPage });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={styles.listWrap}>
|
||||||
|
{list.length === 0 && !loading ? (
|
||||||
|
<Empty description="暂无用户数据" />
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
{list.map(item => (
|
||||||
|
<div key={item.id} className={styles.cardWrap}>
|
||||||
|
<div
|
||||||
|
className={styles.card}
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
onClick={() =>
|
||||||
|
navigate(
|
||||||
|
`/mine/traffic-pool/detail/${item.wechatId}/${item.id}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={styles.cardContent}>
|
||||||
|
<Avatar
|
||||||
|
src={item.avatar || defaultAvatar}
|
||||||
|
style={{ "--size": "60px" }}
|
||||||
|
/>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div className={styles.title}>
|
||||||
|
{item.nickname || item.identifier}
|
||||||
|
</div>
|
||||||
|
<div className={styles.desc}>
|
||||||
|
微信号:{item.wechatId || "-"}
|
||||||
|
</div>
|
||||||
|
<div className={styles.desc}>
|
||||||
|
来源:{item.fromd || "-"}
|
||||||
|
</div>
|
||||||
|
<div className={styles.desc}>
|
||||||
|
分组:
|
||||||
|
{item.packages && item.packages.length
|
||||||
|
? item.packages.join(",")
|
||||||
|
: "-"}
|
||||||
|
</div>
|
||||||
|
<div className={styles.desc}>
|
||||||
|
创建时间:{item.createTime}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TrafficPoolUserList;
|
||||||
@@ -18,6 +18,7 @@ interface AccountItem {
|
|||||||
wechatId: string;
|
wechatId: string;
|
||||||
};
|
};
|
||||||
phone?: string;
|
phone?: string;
|
||||||
|
fail_reason: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AccountListModalProps {
|
interface AccountListModalProps {
|
||||||
@@ -153,11 +154,18 @@ const AccountListModal: React.FC<AccountListModalProps> = ({
|
|||||||
<div className={style.accountWechatId}>
|
<div className={style.accountWechatId}>
|
||||||
{account.userinfo.wechatId || "未绑定微信号"}
|
{account.userinfo.wechatId || "未绑定微信号"}
|
||||||
</div>
|
</div>
|
||||||
|
{account.fail_reason && (
|
||||||
|
<div style={{ fontSize: 12, color: "red", marginTop: 4 }}>
|
||||||
|
原因:{account.fail_reason}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={style.accountStatus}>
|
<div className={style.accountStatus}>
|
||||||
<span
|
<span
|
||||||
className={style.statusDot}
|
className={style.statusDot}
|
||||||
style={{ backgroundColor: getStatusColor(account.status) }}
|
style={{
|
||||||
|
backgroundColor: getStatusColor(account.status),
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<span className={style.statusText}>
|
<span className={style.statusText}>
|
||||||
{getStatusText(Number(account.status))}
|
{getStatusText(Number(account.status))}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export default function NewPlan() {
|
|||||||
const router = useNavigate();
|
const router = useNavigate();
|
||||||
const [currentStep, setCurrentStep] = useState(1);
|
const [currentStep, setCurrentStep] = useState(1);
|
||||||
const [formData, setFormData] = useState<FormData>(defFormData);
|
const [formData, setFormData] = useState<FormData>(defFormData);
|
||||||
|
const [submitting, setSubmitting] = useState(false); // 添加提交状态
|
||||||
|
|
||||||
const [sceneList, setSceneList] = useState<any[]>([]);
|
const [sceneList, setSceneList] = useState<any[]>([]);
|
||||||
const [sceneLoading, setSceneLoading] = useState(true);
|
const [sceneLoading, setSceneLoading] = useState(true);
|
||||||
@@ -110,6 +111,12 @@ export default function NewPlan() {
|
|||||||
};
|
};
|
||||||
// 处理保存
|
// 处理保存
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
|
// 防止重复提交
|
||||||
|
if (submitting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
if (isEdit && planId) {
|
if (isEdit && planId) {
|
||||||
// 编辑:拼接后端需要的完整参数
|
// 编辑:拼接后端需要的完整参数
|
||||||
@@ -140,13 +147,18 @@ export default function NewPlan() {
|
|||||||
? "更新计划失败,请重试"
|
? "更新计划失败,请重试"
|
||||||
: "创建计划失败,请重试",
|
: "创建计划失败,请重试",
|
||||||
);
|
);
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 下一步
|
// 下一步
|
||||||
const handleNext = () => {
|
const handleNext = () => {
|
||||||
if (currentStep === steps.length) {
|
if (currentStep === steps.length) {
|
||||||
handleSave();
|
// 最后一步时调用保存,防止重复点击
|
||||||
|
if (!submitting) {
|
||||||
|
handleSave();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setCurrentStep(prev => prev + 1);
|
setCurrentStep(prev => prev + 1);
|
||||||
}
|
}
|
||||||
@@ -186,7 +198,12 @@ export default function NewPlan() {
|
|||||||
return (
|
return (
|
||||||
<div style={{ padding: "16px", display: "flex", gap: "12px" }}>
|
<div style={{ padding: "16px", display: "flex", gap: "12px" }}>
|
||||||
{currentStep > 1 && (
|
{currentStep > 1 && (
|
||||||
<Button onClick={handlePrev} size="large" style={{ flex: 1 }}>
|
<Button
|
||||||
|
onClick={handlePrev}
|
||||||
|
size="large"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
上一步
|
上一步
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -195,8 +212,16 @@ export default function NewPlan() {
|
|||||||
size="large"
|
size="large"
|
||||||
onClick={handleNext}
|
onClick={handleNext}
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
|
loading={submitting}
|
||||||
|
disabled={submitting}
|
||||||
>
|
>
|
||||||
{currentStep === steps.length ? "完成" : "下一步"}
|
{submitting
|
||||||
|
? isEdit
|
||||||
|
? "更新中..."
|
||||||
|
: "创建中..."
|
||||||
|
: currentStep === steps.length
|
||||||
|
? "完成"
|
||||||
|
: "下一步"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
168
Cunkebao/src/pages/mobile/workspace/ai-knowledge/api 文档
Normal file
168
Cunkebao/src/pages/mobile/workspace/ai-knowledge/api 文档
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
初始化AI功能(每次都得执行)
|
||||||
|
GET /v1/knowledge/init
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
发布并应用AI工具(修改知识库需要重新发布)
|
||||||
|
GET /v1/knowledge/release
|
||||||
|
传参:
|
||||||
|
{
|
||||||
|
id:number
|
||||||
|
}
|
||||||
|
|
||||||
|
返回参数:
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"companyId": 2130,
|
||||||
|
"userId": 128,
|
||||||
|
"config": {
|
||||||
|
"name": "魔兽世界",
|
||||||
|
"model_id": "1737521813",
|
||||||
|
"prompt_info": "# 角色\r\n你是一位全能知识客服,作为专业的客服智能体,具备全面的知识储备,能够回答用户提出的各类问题。在回答问题前,会仔细查阅知识库内容,并且始终严格遵守中国法律法规。\r\n\r\n## 技能\r\n### 技能 1: 回答用户问题\r\n1. 当用户提出问题时,首先在知识库中进行搜索查找相关信息。\r\n2. 依据知识库中的内容,为用户提供准确、清晰、完整的回答。\r\n \r\n## 限制\r\n- 仅依据知识库内容回答问题,对于知识库中没有的信息,如实告知用户无法回答。\r\n- 回答必须严格遵循中国法律法规,不得出现任何违法违规内容。\r\n- 回答需简洁明了,避免冗长复杂的表述。"
|
||||||
|
},
|
||||||
|
"createTime": "2025-10-24 16:55:08",
|
||||||
|
"updateTime": "2025-10-24 16:56:28",
|
||||||
|
"isRelease": 1,
|
||||||
|
"releaseTime": 1761296188,
|
||||||
|
"botId": "7564707767488610345",
|
||||||
|
"datasetId": "7564708881499619366"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
知识库类型 - 列表
|
||||||
|
GET /v1/knowledge/typeList
|
||||||
|
传参:
|
||||||
|
{
|
||||||
|
page:number
|
||||||
|
limit:number
|
||||||
|
}
|
||||||
|
返回参数:
|
||||||
|
"total": 5,
|
||||||
|
"per_page": 20,
|
||||||
|
"current_page": 1,
|
||||||
|
"last_page": 1,
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"type": 0,
|
||||||
|
"name": "产品介绍库",
|
||||||
|
"description": "包含所有产品相关的介绍文档、图片和视频资料",
|
||||||
|
"label": [
|
||||||
|
"产品",
|
||||||
|
"营销"
|
||||||
|
],
|
||||||
|
"prompt": null,
|
||||||
|
"companyId": 0,
|
||||||
|
"userId": 0,
|
||||||
|
"createTime": null,
|
||||||
|
"updateTime": null,
|
||||||
|
"isDel": 0,
|
||||||
|
"delTime": 0
|
||||||
|
},
|
||||||
|
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
知识库类型 - 添加
|
||||||
|
POST /v1/knowledge/addType
|
||||||
|
传参:
|
||||||
|
{
|
||||||
|
name:string
|
||||||
|
description:string
|
||||||
|
label:string[]
|
||||||
|
prompt:string
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
知识库类型 - 编辑
|
||||||
|
POST /v1/knowledge/editType
|
||||||
|
传参:
|
||||||
|
{
|
||||||
|
id:number
|
||||||
|
name:string
|
||||||
|
description:string
|
||||||
|
label:string[]
|
||||||
|
prompt:string
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
知识库类型 - 删除
|
||||||
|
DELETE /v1/knowledge/deleteType
|
||||||
|
传参:
|
||||||
|
{
|
||||||
|
id:number
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
知识库 - 列表
|
||||||
|
GET /v1/knowledge/getList
|
||||||
|
传参:
|
||||||
|
{
|
||||||
|
name:number
|
||||||
|
typeId:number
|
||||||
|
label:string[]
|
||||||
|
fileUrl:string
|
||||||
|
}
|
||||||
|
返回参数:
|
||||||
|
{
|
||||||
|
"total": 1,
|
||||||
|
"per_page": 20,
|
||||||
|
"current_page": 1,
|
||||||
|
"last_page": 1,
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"typeId": 1,
|
||||||
|
"name": "存客宝项目介绍(面向开发人员).docx",
|
||||||
|
"label": [
|
||||||
|
"1231",
|
||||||
|
"3453"
|
||||||
|
],
|
||||||
|
"companyId": 2130,
|
||||||
|
"userId": 128,
|
||||||
|
"createTime": 1761296164,
|
||||||
|
"updateTime": 1761296165,
|
||||||
|
"isDel": 0,
|
||||||
|
"delTime": 0,
|
||||||
|
"documentId": "7564706328503189558",
|
||||||
|
"fileUrl": "http://karuosiyujzk.oss-cn-shenzhen.aliyuncs.com/2025/10/22/9de59fc8723f10973ade586650dfb235.docx",
|
||||||
|
"type": {
|
||||||
|
"id": 1,
|
||||||
|
"type": 0,
|
||||||
|
"name": "产品介绍库",
|
||||||
|
"description": "包含所有产品相关的介绍文档、图片和视频资料",
|
||||||
|
"label": [
|
||||||
|
"产品",
|
||||||
|
"营销"
|
||||||
|
],
|
||||||
|
"prompt": null,
|
||||||
|
"companyId": 0,
|
||||||
|
"userId": 0,
|
||||||
|
"createTime": null,
|
||||||
|
"updateTime": null,
|
||||||
|
"isDel": 0,
|
||||||
|
"delTime": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
知识库 - 添加
|
||||||
|
POST /v1/knowledge/add
|
||||||
|
传参:
|
||||||
|
{
|
||||||
|
name:number
|
||||||
|
typeId:number
|
||||||
|
label:string[]
|
||||||
|
fileUrl:string
|
||||||
|
}
|
||||||
|
|
||||||
|
知识库 - 删除
|
||||||
|
DELETE /v1/knowledge/delete
|
||||||
|
传参:
|
||||||
|
{
|
||||||
|
id:number
|
||||||
|
}
|
||||||
109
Cunkebao/src/pages/mobile/workspace/ai-knowledge/detail/api.ts
Normal file
109
Cunkebao/src/pages/mobile/workspace/ai-knowledge/detail/api.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import request from "@/api/request";
|
||||||
|
import type {
|
||||||
|
KnowledgeBaseDetailResponse,
|
||||||
|
MaterialListResponse,
|
||||||
|
CallerListResponse,
|
||||||
|
} from "./data";
|
||||||
|
|
||||||
|
// 获取知识库类型详情(复用列表接口)
|
||||||
|
export function getKnowledgeBaseDetail(
|
||||||
|
id: number,
|
||||||
|
): Promise<KnowledgeBaseDetailResponse> {
|
||||||
|
// 接口文档中没有单独的详情接口,通过列表接口获取
|
||||||
|
return request("/v1/knowledge/typeList", { page: 1, limit: 100 }, "GET").then(
|
||||||
|
(res: any) => {
|
||||||
|
const item = res.data?.find((item: any) => item.id === id);
|
||||||
|
if (!item) {
|
||||||
|
throw new Error("知识库不存在");
|
||||||
|
}
|
||||||
|
// 转换数据格式
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
tags: item.label || [],
|
||||||
|
useIndependentPrompt: !!item.prompt,
|
||||||
|
independentPrompt: item.prompt || "",
|
||||||
|
materials: [], // 需要单独获取
|
||||||
|
callers: [], // 暂无接口
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取知识库素材列表(对应接口的 knowledge/getList)
|
||||||
|
export function getMaterialList(params: {
|
||||||
|
knowledgeBaseId: number;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
name?: string;
|
||||||
|
label?: string[];
|
||||||
|
}): Promise<MaterialListResponse> {
|
||||||
|
return request(
|
||||||
|
"/v1/knowledge/getList",
|
||||||
|
{
|
||||||
|
typeId: params.knowledgeBaseId,
|
||||||
|
name: params.name,
|
||||||
|
label: params.label,
|
||||||
|
page: params.page || 1,
|
||||||
|
limit: params.limit || 20,
|
||||||
|
},
|
||||||
|
"GET",
|
||||||
|
).then((res: any) => ({
|
||||||
|
list: res.data || [],
|
||||||
|
total: res.total || 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加素材
|
||||||
|
export function uploadMaterial(data: {
|
||||||
|
typeId: number;
|
||||||
|
name: string;
|
||||||
|
label: string[];
|
||||||
|
fileUrl: string;
|
||||||
|
}): Promise<any> {
|
||||||
|
return request("/v1/knowledge/add", data, "POST");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除素材
|
||||||
|
export function deleteMaterial(id: number): Promise<any> {
|
||||||
|
return request("/v1/knowledge/delete", { id }, "DELETE");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取调用者列表(接口未提供)
|
||||||
|
export function getCallerList(
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
params: {
|
||||||
|
knowledgeBaseId: number;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
},
|
||||||
|
): Promise<CallerListResponse> {
|
||||||
|
// 注意:实际接口未提供,需要后端补充
|
||||||
|
console.warn("getCallerList 接口未提供");
|
||||||
|
return Promise.resolve({
|
||||||
|
list: [],
|
||||||
|
total: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新知识库配置(使用编辑接口)
|
||||||
|
export function updateKnowledgeBaseConfig(data: {
|
||||||
|
id: number;
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
label?: string[];
|
||||||
|
aiCallEnabled?: boolean;
|
||||||
|
useIndependentPrompt?: boolean;
|
||||||
|
independentPrompt?: string;
|
||||||
|
}): Promise<any> {
|
||||||
|
return request(
|
||||||
|
"/v1/knowledge/editType",
|
||||||
|
{
|
||||||
|
id: data.id,
|
||||||
|
name: data.name || "",
|
||||||
|
description: data.description || "",
|
||||||
|
label: data.label || [],
|
||||||
|
prompt: data.useIndependentPrompt ? data.independentPrompt || "" : "",
|
||||||
|
},
|
||||||
|
"POST",
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// AI知识库详情相关类型定义
|
||||||
|
import type { KnowledgeBase, Caller } from "../list/data";
|
||||||
|
|
||||||
|
export type { KnowledgeBase, Caller };
|
||||||
|
|
||||||
|
// 素材类型(对应接口的 knowledge)
|
||||||
|
export interface Material {
|
||||||
|
id: number;
|
||||||
|
typeId: number; // 知识库类型ID
|
||||||
|
name: string; // 文件名
|
||||||
|
label: string[]; // 标签
|
||||||
|
companyId: number;
|
||||||
|
userId: number;
|
||||||
|
createTime: number;
|
||||||
|
updateTime: number;
|
||||||
|
isDel: number;
|
||||||
|
delTime: number;
|
||||||
|
documentId: string; // 文档ID
|
||||||
|
fileUrl: string; // 文件URL
|
||||||
|
type?: KnowledgeBase; // 关联的知识库类型信息
|
||||||
|
// 前端扩展字段
|
||||||
|
fileName?: string; // 映射自 name
|
||||||
|
size?: number; // 文件大小(前端计算)
|
||||||
|
fileType?: string; // 文件类型(从 name 提取)
|
||||||
|
filePath?: string; // 映射自 fileUrl
|
||||||
|
tags?: string[]; // 映射自 label
|
||||||
|
uploadTime?: string; // 映射自 createTime
|
||||||
|
uploaderId?: number; // 映射自 userId
|
||||||
|
uploaderName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 知识库详情响应
|
||||||
|
export interface KnowledgeBaseDetailResponse extends KnowledgeBase {
|
||||||
|
materials: Material[];
|
||||||
|
callers: Caller[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 素材列表响应
|
||||||
|
export interface MaterialListResponse {
|
||||||
|
list: Material[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用者列表响应
|
||||||
|
export interface CallerListResponse {
|
||||||
|
list: Caller[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,596 @@
|
|||||||
|
// 详情页容器
|
||||||
|
.detailPage {
|
||||||
|
background: #f5f5f5;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailContent {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提示横幅
|
||||||
|
.banner {
|
||||||
|
background: linear-gradient(135deg, #e6f7ff 0%, #f0f5ff 100%);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
border: 1px solid #91d5ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bannerIcon {
|
||||||
|
font-size: 20px;
|
||||||
|
color: #1890ff;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bannerContent {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bannerText {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.5;
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #1890ff;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tab容器
|
||||||
|
.tabContainer {
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
flex: 1;
|
||||||
|
padding: 14px 0;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 15px;
|
||||||
|
color: #666;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabActive {
|
||||||
|
color: #1890ff;
|
||||||
|
font-weight: 600;
|
||||||
|
border-bottom-color: #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 知识库信息卡片
|
||||||
|
.infoCard {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.infoHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infoIcon {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 28px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infoContent {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infoName {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #222;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.systemPresetLabel {
|
||||||
|
margin-left: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infoDescription {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #888;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infoStats {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 0;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statItem {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statValue {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1890ff;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statValueSuccess {
|
||||||
|
color: #52c41a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statLabel {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infoTags {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagTitle {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提示词生效规则
|
||||||
|
.promptRulesSection {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rulesList {
|
||||||
|
margin: 12px 0 0 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #666;
|
||||||
|
line-height: 1.8;
|
||||||
|
|
||||||
|
li {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 知识库独立提示词
|
||||||
|
.independentPromptSection {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.promptDisplay {
|
||||||
|
background: #f9f9f9;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-top: 8px;
|
||||||
|
min-height: 80px;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.promptText {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.promptEmpty {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #999;
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 上传素材区域
|
||||||
|
.uploadSection {
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 库内素材区域
|
||||||
|
.materialsSection {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
background: rgba(24, 144, 255, 0.1);
|
||||||
|
color: #1890ff;
|
||||||
|
border: 1px solid rgba(24, 144, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 配置区域
|
||||||
|
.configSection {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.configItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 0;
|
||||||
|
border-bottom: 1px solid #f5f5f5;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.configLabel {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.configIcon {
|
||||||
|
font-size: 16px;
|
||||||
|
color: #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.configDescription {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #888;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 功能说明列表
|
||||||
|
.featureList {
|
||||||
|
background: #f9f9f9;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.featureItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #666;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.featureIcon {
|
||||||
|
color: #52c41a;
|
||||||
|
margin-top: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用者名单
|
||||||
|
.callerSection {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionIcon {
|
||||||
|
font-size: 16px;
|
||||||
|
color: #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionCount {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #888;
|
||||||
|
font-weight: normal;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.callerList {
|
||||||
|
background: #f9f9f9;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.callerItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.callerAvatar {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: #f0f0f0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anticon {
|
||||||
|
font-size: 20px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.callerInfo {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.callerName {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.callerRole {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.callerTime {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #999;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 素材列表
|
||||||
|
.materialSection {
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uploadButton {
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.materialList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.materialItem {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||||
|
border: 1px solid #ececec;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.materialIcon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 24px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileIcon {
|
||||||
|
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a6f 100%);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.videoIcon {
|
||||||
|
background: linear-gradient(135deg, #a855f7 0%, #9333ea 100%);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docIcon {
|
||||||
|
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.materialContent {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.materialHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.materialName {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex: 1;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.materialMenu {
|
||||||
|
font-size: 16px;
|
||||||
|
color: #888;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.materialMeta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.materialSize {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.materialDate {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.materialTags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.materialTag {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
background: rgba(0, 0, 0, 0.05);
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 底部按钮组
|
||||||
|
.bottomActions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px 0;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editButton {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deleteButton {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 空状态
|
||||||
|
.empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
color: #bbb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyIcon {
|
||||||
|
font-size: 64px;
|
||||||
|
color: #d9d9d9;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyText {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编辑提示词弹窗
|
||||||
|
.promptEditModal {
|
||||||
|
.promptTextarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 200px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
resize: vertical;
|
||||||
|
font-family: inherit;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #1890ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.promptHint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #888;
|
||||||
|
margin-top: 8px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,723 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { Button, Switch, message, Spin, Dropdown, Modal } from "antd";
|
||||||
|
import { Dialog, Toast } from "antd-mobile";
|
||||||
|
import {
|
||||||
|
BookOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
FileOutlined,
|
||||||
|
VideoCameraOutlined,
|
||||||
|
FileTextOutlined,
|
||||||
|
MoreOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
SettingOutlined,
|
||||||
|
ApiOutlined,
|
||||||
|
CheckCircleOutlined,
|
||||||
|
GlobalOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
InfoCircleOutlined,
|
||||||
|
MessageOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
|
import Layout from "@/components/Layout/Layout";
|
||||||
|
import NavCommon from "@/components/NavCommon";
|
||||||
|
import style from "./index.module.scss";
|
||||||
|
import {
|
||||||
|
getKnowledgeBaseDetail,
|
||||||
|
getMaterialList,
|
||||||
|
deleteMaterial,
|
||||||
|
updateKnowledgeBaseConfig,
|
||||||
|
uploadMaterial,
|
||||||
|
} from "./api";
|
||||||
|
import { deleteKnowledgeBase } from "../list/api";
|
||||||
|
import type { KnowledgeBase, Material, Caller } from "./data";
|
||||||
|
import FileUpload from "@/components/Upload/FileUploadButton";
|
||||||
|
import GlobalPromptModal from "../list/components/GlobalPromptModal";
|
||||||
|
|
||||||
|
const AIKnowledgeDetail: React.FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [knowledgeBase, setKnowledgeBase] = useState<KnowledgeBase | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [materials, setMaterials] = useState<Material[]>([]);
|
||||||
|
const [callers, setCallers] = useState<Caller[]>([]);
|
||||||
|
const [promptEditVisible, setPromptEditVisible] = useState(false);
|
||||||
|
const [independentPrompt, setIndependentPrompt] = useState("");
|
||||||
|
const [globalPromptVisible, setGlobalPromptVisible] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (id) {
|
||||||
|
fetchDetail();
|
||||||
|
}
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const fetchDetail = async () => {
|
||||||
|
if (!id) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const detail = await getKnowledgeBaseDetail(Number(id));
|
||||||
|
setKnowledgeBase(detail);
|
||||||
|
setCallers(detail.callers || []);
|
||||||
|
setIndependentPrompt(detail.independentPrompt || "");
|
||||||
|
|
||||||
|
// 获取素材列表
|
||||||
|
const materialRes = await getMaterialList({
|
||||||
|
knowledgeBaseId: Number(id),
|
||||||
|
page: 1,
|
||||||
|
limit: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 转换素材数据格式
|
||||||
|
const transformedMaterials = (materialRes.list || []).map(
|
||||||
|
(item: any) => ({
|
||||||
|
...item,
|
||||||
|
fileName: item.name,
|
||||||
|
tags: item.label || [],
|
||||||
|
filePath: item.fileUrl,
|
||||||
|
uploadTime: item.createTime
|
||||||
|
? new Date(item.createTime * 1000).toLocaleDateString("zh-CN")
|
||||||
|
: "-",
|
||||||
|
uploaderId: item.userId,
|
||||||
|
fileType: item.name?.split(".").pop() || "file",
|
||||||
|
fileSize: 0, // 接口未返回,需要前端计算或后端补充
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
setMaterials(transformedMaterials);
|
||||||
|
|
||||||
|
// 更新知识库的素材数量
|
||||||
|
if (detail) {
|
||||||
|
setKnowledgeBase({
|
||||||
|
...detail,
|
||||||
|
materialCount: transformedMaterials.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.error("获取详情失败");
|
||||||
|
navigate(-1);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 更新素材列表
|
||||||
|
const fetchMaterialList = async () => {
|
||||||
|
if (!id) return;
|
||||||
|
try {
|
||||||
|
const materialRes = await getMaterialList({
|
||||||
|
knowledgeBaseId: Number(id),
|
||||||
|
page: 1,
|
||||||
|
limit: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 转换素材数据格式
|
||||||
|
const transformedMaterials = (materialRes.list || []).map(
|
||||||
|
(item: any) => ({
|
||||||
|
...item,
|
||||||
|
fileName: item.name,
|
||||||
|
tags: item.label || [],
|
||||||
|
filePath: item.fileUrl,
|
||||||
|
uploadTime: item.createTime
|
||||||
|
? new Date(item.createTime * 1000).toLocaleDateString("zh-CN")
|
||||||
|
: "-",
|
||||||
|
uploaderId: item.userId,
|
||||||
|
fileType: item.name?.split(".").pop() || "file",
|
||||||
|
fileSize: 0, // 接口未返回,需要前端计算或后端补充
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
setMaterials(transformedMaterials);
|
||||||
|
|
||||||
|
// 更新知识库的素材数量
|
||||||
|
if (knowledgeBase) {
|
||||||
|
setKnowledgeBase({
|
||||||
|
...knowledgeBase,
|
||||||
|
materialCount: transformedMaterials.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("获取素材列表失败", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAICallToggle = async (checked: boolean) => {
|
||||||
|
if (!id || !knowledgeBase) return;
|
||||||
|
|
||||||
|
// 系统预设不允许修改
|
||||||
|
if (knowledgeBase.type === 0) {
|
||||||
|
message.warning("系统预设知识库不可修改");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateKnowledgeBaseConfig({
|
||||||
|
id: Number(id),
|
||||||
|
name: knowledgeBase.name,
|
||||||
|
description: knowledgeBase.description,
|
||||||
|
label: knowledgeBase.tags || knowledgeBase.label || [],
|
||||||
|
aiCallEnabled: checked,
|
||||||
|
useIndependentPrompt: knowledgeBase.useIndependentPrompt,
|
||||||
|
independentPrompt: knowledgeBase.independentPrompt || "",
|
||||||
|
});
|
||||||
|
message.success(checked ? "已启用AI调用" : "已关闭AI调用");
|
||||||
|
setKnowledgeBase(prev =>
|
||||||
|
prev ? { ...prev, aiCallEnabled: checked } : null,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
message.error("操作失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePromptSave = async () => {
|
||||||
|
if (!id || !knowledgeBase) return;
|
||||||
|
if (!independentPrompt.trim()) {
|
||||||
|
message.error("请输入提示词内容");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateKnowledgeBaseConfig({
|
||||||
|
id: Number(id),
|
||||||
|
name: knowledgeBase.name,
|
||||||
|
description: knowledgeBase.description,
|
||||||
|
label: knowledgeBase.tags || knowledgeBase.label || [],
|
||||||
|
useIndependentPrompt: true,
|
||||||
|
independentPrompt: independentPrompt.trim(),
|
||||||
|
});
|
||||||
|
message.success("保存成功");
|
||||||
|
setKnowledgeBase(prev =>
|
||||||
|
prev
|
||||||
|
? {
|
||||||
|
...prev,
|
||||||
|
useIndependentPrompt: true,
|
||||||
|
independentPrompt: independentPrompt.trim(),
|
||||||
|
prompt: independentPrompt.trim(),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
setPromptEditVisible(false);
|
||||||
|
} catch (error) {
|
||||||
|
message.error("保存失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteKnowledge = async () => {
|
||||||
|
if (!id || !knowledgeBase) return;
|
||||||
|
|
||||||
|
// 系统预设不允许删除
|
||||||
|
if (knowledgeBase.type === 0) {
|
||||||
|
message.warning("系统预设知识库不可删除");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await Dialog.confirm({
|
||||||
|
content: "删除后数据无法恢复,确定要删除该知识库吗?",
|
||||||
|
confirmText: "确定",
|
||||||
|
cancelText: "取消",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
try {
|
||||||
|
await deleteKnowledgeBase(Number(id));
|
||||||
|
Toast.show({
|
||||||
|
content: "删除成功",
|
||||||
|
icon: "success",
|
||||||
|
});
|
||||||
|
// 删除成功后返回上一页
|
||||||
|
navigate(-1);
|
||||||
|
} catch (error) {
|
||||||
|
Toast.show({
|
||||||
|
content: "删除失败",
|
||||||
|
icon: "fail",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteMaterial = async (materialId: number) => {
|
||||||
|
const result = await Dialog.confirm({
|
||||||
|
content: "确定要删除该素材吗?",
|
||||||
|
confirmText: "确定",
|
||||||
|
cancelText: "取消",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
try {
|
||||||
|
await deleteMaterial(materialId);
|
||||||
|
Toast.show({
|
||||||
|
content: "删除成功",
|
||||||
|
icon: "success",
|
||||||
|
});
|
||||||
|
// 刷新库内素材列表
|
||||||
|
await fetchMaterialList();
|
||||||
|
} catch (error) {
|
||||||
|
Toast.show({
|
||||||
|
content: "删除失败",
|
||||||
|
icon: "fail",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpload = async (file: File) => {
|
||||||
|
if (!id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 注意:这里需要先上传文件获取 fileUrl
|
||||||
|
// 实际项目中应该有单独的文件上传接口
|
||||||
|
// 这里暂时使用占位实现
|
||||||
|
message.loading("正在上传文件...", 0);
|
||||||
|
|
||||||
|
// TODO: 调用文件上传接口获取 fileUrl
|
||||||
|
// const fileUrl = await uploadFile(file);
|
||||||
|
|
||||||
|
// 临时方案:直接使用文件名作为占位
|
||||||
|
const fileUrl = `temp://${file.name}`;
|
||||||
|
|
||||||
|
await uploadMaterial({
|
||||||
|
typeId: Number(id),
|
||||||
|
name: file.name,
|
||||||
|
label: [], // 可以后续添加标签编辑功能
|
||||||
|
fileUrl: fileUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
message.destroy();
|
||||||
|
message.success("上传成功");
|
||||||
|
fetchDetail();
|
||||||
|
} catch (error) {
|
||||||
|
message.destroy();
|
||||||
|
message.error("上传失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFileIcon = (fileType: string) => {
|
||||||
|
const type = fileType.toLowerCase();
|
||||||
|
if (["mp4", "avi", "mov", "wmv"].includes(type)) {
|
||||||
|
return (
|
||||||
|
<div className={`${style.materialIcon} ${style.videoIcon}`}>
|
||||||
|
<VideoCameraOutlined />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else if (["doc", "docx", "pdf", "txt"].includes(type)) {
|
||||||
|
return (
|
||||||
|
<div className={`${style.materialIcon} ${style.docIcon}`}>
|
||||||
|
<FileTextOutlined />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<div className={`${style.materialIcon} ${style.fileIcon}`}>
|
||||||
|
<FileOutlined />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatFileSize = (bytes: number) => {
|
||||||
|
if (bytes < 1024) return bytes + " B";
|
||||||
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
|
||||||
|
return (bytes / (1024 * 1024)).toFixed(1) + " MB";
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderContent = () => {
|
||||||
|
if (!knowledgeBase) return null;
|
||||||
|
|
||||||
|
const isSystemPreset = knowledgeBase.type === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={style.detailContent}>
|
||||||
|
{/* 提示横幅 */}
|
||||||
|
<div className={style.banner}>
|
||||||
|
<InfoCircleOutlined className={style.bannerIcon} />
|
||||||
|
<div className={style.bannerContent}>
|
||||||
|
<div className={style.bannerText}>
|
||||||
|
已启用统一提示词规范 ·{" "}
|
||||||
|
<a onClick={() => setGlobalPromptVisible(true)}>
|
||||||
|
点击"统一提示词"可查看和编辑
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 知识库信息卡片 */}
|
||||||
|
<div className={style.infoCard}>
|
||||||
|
<div className={style.infoHeader}>
|
||||||
|
<div className={style.infoIcon}>
|
||||||
|
<BookOutlined />
|
||||||
|
</div>
|
||||||
|
<div className={style.infoContent}>
|
||||||
|
<div className={style.infoName}>
|
||||||
|
{knowledgeBase.name}
|
||||||
|
{isSystemPreset && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
marginLeft: "8px",
|
||||||
|
fontSize: "12px",
|
||||||
|
color: "#999",
|
||||||
|
fontWeight: "normal",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
(系统预设)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{knowledgeBase.description && (
|
||||||
|
<div className={style.infoDescription}>
|
||||||
|
{knowledgeBase.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={style.infoStats}>
|
||||||
|
<div className={style.statItem}>
|
||||||
|
<div className={style.statValue}>
|
||||||
|
{knowledgeBase.materialCount || 0}
|
||||||
|
</div>
|
||||||
|
<div className={style.statLabel}>素材总数</div>
|
||||||
|
</div>
|
||||||
|
<div className={style.statItem}>
|
||||||
|
<div
|
||||||
|
className={`${style.statValue} ${knowledgeBase.aiCallEnabled ? style.statValueSuccess : ""}`}
|
||||||
|
>
|
||||||
|
{knowledgeBase.aiCallEnabled ? "启用" : "关闭"}
|
||||||
|
</div>
|
||||||
|
<div className={style.statLabel}>AI状态</div>
|
||||||
|
</div>
|
||||||
|
<div className={style.statItem}>
|
||||||
|
<div className={style.statValue}>
|
||||||
|
{knowledgeBase.tags?.length || 0}
|
||||||
|
</div>
|
||||||
|
<div className={style.statLabel}>标签数</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 内容标签 */}
|
||||||
|
{knowledgeBase.tags && knowledgeBase.tags.length > 0 && (
|
||||||
|
<div className={style.infoTags}>
|
||||||
|
<div className={style.tagTitle}>内容标签</div>
|
||||||
|
<div className={style.tags}>
|
||||||
|
{knowledgeBase.tags.map((tag, index) => (
|
||||||
|
<span key={index} className={style.tag}>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={style.configSection}>
|
||||||
|
<div className={style.configItem}>
|
||||||
|
<div>
|
||||||
|
<div className={style.configLabel}>
|
||||||
|
<ApiOutlined className={style.configIcon} />
|
||||||
|
AI调用配置
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={knowledgeBase.aiCallEnabled}
|
||||||
|
onChange={handleAICallToggle}
|
||||||
|
disabled={isSystemPreset}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={style.configItem}>
|
||||||
|
<div>
|
||||||
|
<div className={style.configLabel}>
|
||||||
|
<CheckCircleOutlined className={style.configIcon} />
|
||||||
|
AI助手可以使用此内容库的素材
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={style.configItem}>
|
||||||
|
<div>
|
||||||
|
<div className={style.configLabel}>
|
||||||
|
<CheckCircleOutlined className={style.configIcon} />
|
||||||
|
支持智能应答和推荐
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={style.configItem}>
|
||||||
|
<div>
|
||||||
|
<div className={style.configLabel}>
|
||||||
|
<CheckCircleOutlined className={style.configIcon} />
|
||||||
|
实时响应用户查询
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 提示词生效规则 */}
|
||||||
|
<div className={style.promptRulesSection}>
|
||||||
|
<div className={style.sectionHeader}>
|
||||||
|
<div className={style.sectionTitle}>
|
||||||
|
<InfoCircleOutlined className={style.sectionIcon} />
|
||||||
|
提示词生效规则
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ol className={style.rulesList}>
|
||||||
|
<li>1、先应用统一提示词 (全局规范)</li>
|
||||||
|
<li>2、再结合知识库独立提示词 (专业指导)</li>
|
||||||
|
<li>3、最终形成针对性的回复风格</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 知识库独立提示词 */}
|
||||||
|
<div className={style.independentPromptSection}>
|
||||||
|
<div className={style.sectionHeader}>
|
||||||
|
<div className={style.sectionTitle}>
|
||||||
|
<MessageOutlined className={style.sectionIcon} />
|
||||||
|
知识库独立提示词
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<SettingOutlined />}
|
||||||
|
></Button>
|
||||||
|
</div>
|
||||||
|
<div className={style.promptDisplay}>
|
||||||
|
{knowledgeBase.independentPrompt || independentPrompt ? (
|
||||||
|
<div className={style.promptText}>
|
||||||
|
{knowledgeBase.independentPrompt || independentPrompt}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={style.promptEmpty}>
|
||||||
|
暂无独立提示词,点击编辑按钮添加
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
ghost
|
||||||
|
block={true}
|
||||||
|
onClick={() => setPromptEditVisible(true)}
|
||||||
|
disabled={isSystemPreset}
|
||||||
|
style={{ marginTop: 8 }}
|
||||||
|
>
|
||||||
|
编辑独立提示词
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 调用客服名单 */}
|
||||||
|
{callers.length > 0 && (
|
||||||
|
<div className={style.callerSection}>
|
||||||
|
<div className={style.sectionHeader}>
|
||||||
|
<div className={style.sectionTitle}>
|
||||||
|
<UserOutlined className={style.sectionIcon} />
|
||||||
|
调用客服名单
|
||||||
|
<span className={style.sectionCount}>{callers.length}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={style.callerList}>
|
||||||
|
{callers.slice(0, 3).map(caller => (
|
||||||
|
<div key={caller.id} className={style.callerItem}>
|
||||||
|
<div className={style.callerAvatar}>
|
||||||
|
{caller.avatar ? (
|
||||||
|
<img src={caller.avatar} alt={caller.name} />
|
||||||
|
) : (
|
||||||
|
<UserOutlined />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={style.callerInfo}>
|
||||||
|
<div className={style.callerName}>{caller.name}</div>
|
||||||
|
<div className={style.callerRole}>{caller.role}</div>
|
||||||
|
</div>
|
||||||
|
<div className={style.callerTime}>
|
||||||
|
调用{caller.callCount}次 · {caller.lastCallTime}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 上传素材按钮 */}
|
||||||
|
{!isSystemPreset && (
|
||||||
|
<div className={style.uploadSection}>
|
||||||
|
<FileUpload
|
||||||
|
buttonText="上传素材到此库"
|
||||||
|
acceptTypes={["pdf", "txt", "doc", "docx", "md"]}
|
||||||
|
block={true}
|
||||||
|
size="large"
|
||||||
|
onChange={async result => {
|
||||||
|
if (result && result.fileUrl && result.fileName && id) {
|
||||||
|
try {
|
||||||
|
await uploadMaterial({
|
||||||
|
typeId: Number(id),
|
||||||
|
name: result.fileName,
|
||||||
|
label: [],
|
||||||
|
fileUrl: result.fileUrl,
|
||||||
|
});
|
||||||
|
message.success("上传成功");
|
||||||
|
// 更新素材列表
|
||||||
|
await fetchMaterialList();
|
||||||
|
} catch (e) {
|
||||||
|
message.error("上传失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 库内素材 */}
|
||||||
|
<div className={style.materialsSection}>
|
||||||
|
<div className={style.sectionHeader}>
|
||||||
|
<div className={style.sectionTitle}>
|
||||||
|
库内素材
|
||||||
|
<span className={style.sectionCount}>{materials.length}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={style.materialList}>
|
||||||
|
{materials.length > 0 ? (
|
||||||
|
materials.map(material => (
|
||||||
|
<div key={material.id} className={style.materialItem}>
|
||||||
|
{getFileIcon(material.fileType)}
|
||||||
|
<div className={style.materialContent}>
|
||||||
|
<div className={style.materialHeader}>
|
||||||
|
<div className={style.materialName}>
|
||||||
|
{material.fileName}
|
||||||
|
</div>
|
||||||
|
{!isSystemPreset && (
|
||||||
|
<Dropdown
|
||||||
|
menu={{
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: "delete",
|
||||||
|
icon: <DeleteOutlined />,
|
||||||
|
label: "删除",
|
||||||
|
danger: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
onClick: () => handleDeleteMaterial(material.id),
|
||||||
|
}}
|
||||||
|
trigger={["click"]}
|
||||||
|
placement="bottomRight"
|
||||||
|
>
|
||||||
|
<MoreOutlined className={style.materialMenu} />
|
||||||
|
</Dropdown>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={style.materialMeta}>
|
||||||
|
<div className={style.materialSize}>
|
||||||
|
{formatFileSize(material?.size || 0)}
|
||||||
|
</div>
|
||||||
|
<div className={style.materialDate}>
|
||||||
|
{material.uploadTime}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{material.tags && material.tags.length > 0 && (
|
||||||
|
<div className={style.materialTags}>
|
||||||
|
{material.tags.map((tag, index) => (
|
||||||
|
<span key={index} className={style.materialTag}>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className={style.empty}>
|
||||||
|
<div className={style.emptyIcon}>
|
||||||
|
<FileOutlined />
|
||||||
|
</div>
|
||||||
|
<div className={style.emptyText}>暂无素材</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 底部操作按钮 */}
|
||||||
|
{!isSystemPreset && (
|
||||||
|
<div className={style.bottomActions}>
|
||||||
|
<Button
|
||||||
|
className={style.editButton}
|
||||||
|
onClick={() => navigate(`/workspace/ai-knowledge/${id}/edit`)}
|
||||||
|
>
|
||||||
|
<EditOutlined /> 编辑库
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
danger
|
||||||
|
className={style.deleteButton}
|
||||||
|
onClick={handleDeleteKnowledge}
|
||||||
|
>
|
||||||
|
<DeleteOutlined /> 删除库
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout
|
||||||
|
header={
|
||||||
|
<>
|
||||||
|
<NavCommon
|
||||||
|
title="AI知识库"
|
||||||
|
backFn={() => navigate("/workspace/ai-knowledge")}
|
||||||
|
right={
|
||||||
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
|
<Button onClick={() => setGlobalPromptVisible(true)}>
|
||||||
|
<GlobalOutlined /> 统一提示词
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
onClick={() => navigate("/workspace/ai-knowledge/new")}
|
||||||
|
>
|
||||||
|
<PlusOutlined /> 新建
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={style.detailPage}>
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ textAlign: "center", padding: "60px 0" }}>
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
renderContent()
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 编辑独立提示词弹窗 */}
|
||||||
|
<Modal
|
||||||
|
title="编辑独立提示词"
|
||||||
|
open={promptEditVisible}
|
||||||
|
onCancel={() => setPromptEditVisible(false)}
|
||||||
|
onOk={handlePromptSave}
|
||||||
|
okText="保存"
|
||||||
|
cancelText="取消"
|
||||||
|
className={style.promptEditModal}
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
className={style.promptTextarea}
|
||||||
|
value={independentPrompt}
|
||||||
|
onChange={e => setIndependentPrompt(e.target.value)}
|
||||||
|
placeholder="请输入独立提示词..."
|
||||||
|
maxLength={1000}
|
||||||
|
/>
|
||||||
|
<div className={style.promptHint}>
|
||||||
|
💡 独立提示词将与统一提示词合并使用,为该知识库提供专业指导
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* 统一提示词弹窗 */}
|
||||||
|
<GlobalPromptModal
|
||||||
|
visible={globalPromptVisible}
|
||||||
|
onClose={() => setGlobalPromptVisible(false)}
|
||||||
|
/>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AIKnowledgeDetail;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
// 表单页API - 复用列表页的接口
|
||||||
|
export { createKnowledgeBase, updateKnowledgeBase } from "../list/api";
|
||||||
|
|
||||||
|
export { getKnowledgeBaseDetail } from "../detail/api";
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// AI知识库表单相关类型定义
|
||||||
|
export type { KnowledgeBaseFormData } from "../list/data";
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
// 表单页容器
|
||||||
|
.formPage {
|
||||||
|
padding: 16px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
min-height: calc(100vh - 60px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.formContainer {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px 16px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #222;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 2px solid #1890ff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionIcon {
|
||||||
|
color: #1890ff;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formItem {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formLabel {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.required {
|
||||||
|
color: #ff4d4f;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formTextarea {
|
||||||
|
min-height: 80px;
|
||||||
|
resize: vertical;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formHint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #888;
|
||||||
|
margin-top: 6px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.charCount {
|
||||||
|
text-align: right;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 独立提示词区域
|
||||||
|
.promptSection {
|
||||||
|
background: #f9f9f9;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.promptHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.promptLabel {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.promptIcon {
|
||||||
|
color: #1890ff;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.promptDescription {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 6px;
|
||||||
|
border-left: 3px solid #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.promptTextarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 160px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
resize: vertical;
|
||||||
|
font-family: inherit;
|
||||||
|
outline: none;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: #1890ff;
|
||||||
|
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: #bfbfbf;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.promptDisabled {
|
||||||
|
background: #f5f5f5;
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.promptHint {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: #fffbe6;
|
||||||
|
border: 1px solid #ffe58f;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #ad6800;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hintIcon {
|
||||||
|
color: #faad14;
|
||||||
|
margin-top: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI配置区域
|
||||||
|
.configSection {
|
||||||
|
background: #f9f9f9;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.configItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 0;
|
||||||
|
border-bottom: 1px solid #e8e8e8;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.configLabel {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.configIcon {
|
||||||
|
font-size: 16px;
|
||||||
|
color: #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.configDescription {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #888;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标签输入
|
||||||
|
.tagInput {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagList {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagItem {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
background: rgba(24, 144, 255, 0.1);
|
||||||
|
color: #1890ff;
|
||||||
|
border: 1px solid rgba(24, 144, 255, 0.2);
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagRemove {
|
||||||
|
cursor: pointer;
|
||||||
|
color: #1890ff;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: color 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #096dd9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 底部按钮
|
||||||
|
.formFooter {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
background: #fff;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footerButton {
|
||||||
|
flex: 1;
|
||||||
|
padding: 12px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
outline: none;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancelButton {
|
||||||
|
background: #f5f5f5;
|
||||||
|
color: #666;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background: #e8e8e8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.submitButton {
|
||||||
|
background: #1890ff;
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background: #096dd9;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
background: #d9d9d9;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载状态
|
||||||
|
.loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 信息提示卡片
|
||||||
|
.infoCard {
|
||||||
|
background: linear-gradient(135deg, #e6f7ff 0%, #f0f5ff 100%);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
border: 1px solid #91d5ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infoCardIcon {
|
||||||
|
font-size: 18px;
|
||||||
|
color: #1890ff;
|
||||||
|
margin-top: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infoCardContent {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
307
Cunkebao/src/pages/mobile/workspace/ai-knowledge/form/index.tsx
Normal file
307
Cunkebao/src/pages/mobile/workspace/ai-knowledge/form/index.tsx
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { message, Spin, Switch, Input } from "antd";
|
||||||
|
|
||||||
|
const { TextArea } = Input;
|
||||||
|
import {
|
||||||
|
BookOutlined,
|
||||||
|
BulbOutlined,
|
||||||
|
InfoCircleOutlined,
|
||||||
|
CloseOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
|
import Layout from "@/components/Layout/Layout";
|
||||||
|
import NavCommon from "@/components/NavCommon";
|
||||||
|
import style from "./index.module.scss";
|
||||||
|
import {
|
||||||
|
createKnowledgeBase,
|
||||||
|
updateKnowledgeBase,
|
||||||
|
getKnowledgeBaseDetail,
|
||||||
|
} from "./api";
|
||||||
|
import type { KnowledgeBaseFormData } from "./data";
|
||||||
|
|
||||||
|
const AIKnowledgeForm: React.FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const isEdit = !!id;
|
||||||
|
|
||||||
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// 表单字段
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [tagInput, setTagInput] = useState("");
|
||||||
|
const [tags, setTags] = useState<string[]>([]);
|
||||||
|
const [useIndependentPrompt, setUseIndependentPrompt] = useState(false);
|
||||||
|
const [independentPrompt, setIndependentPrompt] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isEdit && id) {
|
||||||
|
fetchDetail();
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [isEdit, id]);
|
||||||
|
|
||||||
|
const fetchDetail = async () => {
|
||||||
|
if (!id) return;
|
||||||
|
setDetailLoading(true);
|
||||||
|
try {
|
||||||
|
const detail = await getKnowledgeBaseDetail(Number(id));
|
||||||
|
|
||||||
|
// 检查是否为系统预设(type === 0),系统预设不允许编辑
|
||||||
|
if (detail.type === 0) {
|
||||||
|
message.warning("系统预设知识库不可编辑");
|
||||||
|
navigate(-1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setName(detail.name || "");
|
||||||
|
setDescription(detail.description || "");
|
||||||
|
setTags(detail.tags || []);
|
||||||
|
setTagInput(detail.tags?.join(", ") || "");
|
||||||
|
setUseIndependentPrompt(detail.useIndependentPrompt || false);
|
||||||
|
setIndependentPrompt(detail.independentPrompt || "");
|
||||||
|
} catch (error) {
|
||||||
|
message.error("获取详情失败");
|
||||||
|
navigate(-1);
|
||||||
|
} finally {
|
||||||
|
setDetailLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTagInputChange = (value: string) => {
|
||||||
|
setTagInput(value);
|
||||||
|
// 实时解析标签
|
||||||
|
const parsedTags = value
|
||||||
|
.split(/[,,]/)
|
||||||
|
.map(t => t.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
setTags(parsedTags);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveTag = (tagToRemove: string) => {
|
||||||
|
const newTags = tags.filter(t => t !== tagToRemove);
|
||||||
|
setTags(newTags);
|
||||||
|
setTagInput(newTags.join(", "));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
// 表单验证
|
||||||
|
if (!name.trim()) {
|
||||||
|
message.error("请输入内容库名称");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name.length > 50) {
|
||||||
|
message.error("名称不能超过50个字符");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (description.length > 200) {
|
||||||
|
message.error("描述不能超过200个字符");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (useIndependentPrompt && !independentPrompt.trim()) {
|
||||||
|
message.error("启用独立提示词时,请输入提示词内容");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (independentPrompt.length > 1000) {
|
||||||
|
message.error("提示词不能超过1000个字符");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const formData: KnowledgeBaseFormData = {
|
||||||
|
name: name.trim(),
|
||||||
|
description: description.trim(),
|
||||||
|
tags: tags,
|
||||||
|
useIndependentPrompt,
|
||||||
|
independentPrompt: useIndependentPrompt ? independentPrompt.trim() : "",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isEdit && id) {
|
||||||
|
formData.id = Number(id);
|
||||||
|
await updateKnowledgeBase(formData);
|
||||||
|
message.success("更新成功");
|
||||||
|
} else {
|
||||||
|
await createKnowledgeBase(formData);
|
||||||
|
message.success("创建成功");
|
||||||
|
}
|
||||||
|
|
||||||
|
navigate(-1);
|
||||||
|
} catch (error) {
|
||||||
|
message.error(isEdit ? "更新失败" : "创建失败");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
navigate(-1);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (detailLoading) {
|
||||||
|
return (
|
||||||
|
<Layout
|
||||||
|
header={<NavCommon title={isEdit ? "编辑内容库" : "新建内容库"} />}
|
||||||
|
>
|
||||||
|
<div className={style.loading}>
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout
|
||||||
|
header={<NavCommon title={isEdit ? "编辑内容库" : "新建内容库"} />}
|
||||||
|
footer={
|
||||||
|
<div className={style.formFooter}>
|
||||||
|
<button
|
||||||
|
className={`${style.footerButton} ${style.cancelButton}`}
|
||||||
|
onClick={handleCancel}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`${style.footerButton} ${style.submitButton}`}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
{submitting ? "提交中..." : isEdit ? "更新" : "创建"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={style.formPage}>
|
||||||
|
<div className={style.formContainer}>
|
||||||
|
{/* 信息提示 */}
|
||||||
|
<div className={style.infoCard}>
|
||||||
|
<InfoCircleOutlined className={style.infoCardIcon} />
|
||||||
|
<div className={style.infoCardContent}>
|
||||||
|
创建一个新的内容库来组织和管理您的素材,支持配置独立提示词和AI调用设置
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 基本信息 */}
|
||||||
|
<div className={style.sectionTitle}>
|
||||||
|
<BookOutlined className={style.sectionIcon} />
|
||||||
|
基本信息
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={style.formItem}>
|
||||||
|
<div className={style.formLabel}>
|
||||||
|
<span className={style.required}>*</span>
|
||||||
|
内容库名称
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
placeholder="如:产品介绍库"
|
||||||
|
value={name}
|
||||||
|
onChange={e => setName(e.target.value)}
|
||||||
|
maxLength={50}
|
||||||
|
size="large"
|
||||||
|
count={{
|
||||||
|
show: true,
|
||||||
|
max: 50,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={style.formItem}>
|
||||||
|
<div className={style.formLabel}>描述</div>
|
||||||
|
<TextArea
|
||||||
|
placeholder="描述这个内容库的用途..."
|
||||||
|
value={description}
|
||||||
|
onChange={e => setDescription(e.target.value)}
|
||||||
|
maxLength={200}
|
||||||
|
rows={4}
|
||||||
|
showCount
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={style.formItem}>
|
||||||
|
<div className={style.formLabel}>标签</div>
|
||||||
|
<div className={style.tagInput}>
|
||||||
|
<Input
|
||||||
|
placeholder="多个标签用逗号分隔,如:产品,营销,介绍"
|
||||||
|
value={tagInput}
|
||||||
|
onChange={e => handleTagInputChange(e.target.value)}
|
||||||
|
size="large"
|
||||||
|
/>
|
||||||
|
{tags.length > 0 && (
|
||||||
|
<div className={style.tagList}>
|
||||||
|
{tags.map((tag, index) => (
|
||||||
|
<span key={index} className={style.tagItem}>
|
||||||
|
{tag}
|
||||||
|
<CloseOutlined
|
||||||
|
className={style.tagRemove}
|
||||||
|
onClick={() => handleRemoveTag(tag)}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={style.formHint}>
|
||||||
|
标签用于分类和快速查找,建议使用3-5个关键标签
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 独立提示词 */}
|
||||||
|
<div className={style.sectionTitle}>
|
||||||
|
<BulbOutlined className={style.sectionIcon} />
|
||||||
|
独立提示词配置
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={style.promptSection}>
|
||||||
|
<div className={style.promptHeader}>
|
||||||
|
<div className={style.promptLabel}>
|
||||||
|
<BulbOutlined className={style.promptIcon} />
|
||||||
|
使用独立提示词
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={useIndependentPrompt}
|
||||||
|
onChange={setUseIndependentPrompt}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{useIndependentPrompt && (
|
||||||
|
<>
|
||||||
|
<div className={style.promptDescription}>
|
||||||
|
设置此知识库的专业指导,将与统一提示词合并使用,为该知识库提供更精准的回答规则
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TextArea
|
||||||
|
placeholder="请输入独立提示词内容,例如:
|
||||||
|
- 回答风格:专业、友好、简洁
|
||||||
|
- 特殊要求:强调产品优势、突出技术细节
|
||||||
|
- 回答格式:分点列举、数据支撑..."
|
||||||
|
value={independentPrompt}
|
||||||
|
onChange={e => setIndependentPrompt(e.target.value)}
|
||||||
|
maxLength={1000}
|
||||||
|
rows={8}
|
||||||
|
showCount
|
||||||
|
disabled={!useIndependentPrompt}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={style.promptHint}>
|
||||||
|
<InfoCircleOutlined className={style.hintIcon} />
|
||||||
|
<div>
|
||||||
|
<strong>生效逻辑:</strong>
|
||||||
|
统一提示词(全局规则) + 独立提示词(专业指导) = 最终AI回复内容
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AIKnowledgeForm;
|
||||||
95
Cunkebao/src/pages/mobile/workspace/ai-knowledge/list/api.ts
Normal file
95
Cunkebao/src/pages/mobile/workspace/ai-knowledge/list/api.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import request from "@/api/request";
|
||||||
|
import type {
|
||||||
|
KnowledgeBaseListResponse,
|
||||||
|
KnowledgeBaseFormData,
|
||||||
|
GlobalPromptConfig,
|
||||||
|
} from "./data";
|
||||||
|
|
||||||
|
// 获取知识库列表
|
||||||
|
export function updateTypeStatus(params: { id: number; status: number }) {
|
||||||
|
return request("/v1/knowledge/updateTypeStatus", params, "PUT");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化AI功能
|
||||||
|
export function initAIKnowledge(): Promise<any> {
|
||||||
|
return request("/v1/knowledge/init", {}, "GET");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发布并应用AI工具
|
||||||
|
export function releaseAIKnowledge(id: number): Promise<any> {
|
||||||
|
return request("/v1/knowledge/release", { id }, "GET");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取知识库类型列表
|
||||||
|
export function fetchKnowledgeBaseList(params: {
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
keyword?: string;
|
||||||
|
}): Promise<KnowledgeBaseListResponse> {
|
||||||
|
return request("/v1/knowledge/typeList", params, "GET");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建知识库类型
|
||||||
|
export function createKnowledgeBase(data: KnowledgeBaseFormData): Promise<any> {
|
||||||
|
return request(
|
||||||
|
"/v1/knowledge/addType",
|
||||||
|
{
|
||||||
|
name: data.name,
|
||||||
|
description: data.description || "",
|
||||||
|
label: data.tags || [],
|
||||||
|
prompt: data.useIndependentPrompt ? data.independentPrompt || "" : "",
|
||||||
|
},
|
||||||
|
"POST",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新知识库类型
|
||||||
|
export function updateKnowledgeBase(data: KnowledgeBaseFormData): Promise<any> {
|
||||||
|
return request(
|
||||||
|
"/v1/knowledge/editType",
|
||||||
|
{
|
||||||
|
id: data.id,
|
||||||
|
name: data.name,
|
||||||
|
description: data.description || "",
|
||||||
|
label: data.tags || [],
|
||||||
|
prompt: data.useIndependentPrompt ? data.independentPrompt || "" : "",
|
||||||
|
},
|
||||||
|
"POST",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除知识库类型
|
||||||
|
export function deleteKnowledgeBase(id: number): Promise<any> {
|
||||||
|
return request("/v1/knowledge/deleteType", { id }, "DELETE");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化统一提示词配置
|
||||||
|
export function initGlobalPrompt(): Promise<any> {
|
||||||
|
return request("/v1/knowledge/init", undefined, "GET");
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SaveGlobalPromptData {
|
||||||
|
promptInfo: string;
|
||||||
|
}
|
||||||
|
interface PromptResponse {
|
||||||
|
id: number;
|
||||||
|
companyId: number;
|
||||||
|
userId: number;
|
||||||
|
config: {
|
||||||
|
name: string;
|
||||||
|
model_id: string;
|
||||||
|
prompt_info: string;
|
||||||
|
};
|
||||||
|
createTime: string;
|
||||||
|
updateTime: string;
|
||||||
|
isRelease: number;
|
||||||
|
releaseTime: number;
|
||||||
|
botId: string;
|
||||||
|
datasetId: string;
|
||||||
|
}
|
||||||
|
// 保存统一提示词配置
|
||||||
|
export function saveGlobalPrompt(
|
||||||
|
data: SaveGlobalPromptData,
|
||||||
|
): Promise<PromptResponse> {
|
||||||
|
return request("/v1/knowledge/savePrompt", data, "POST");
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { Popup, Toast } from "antd-mobile";
|
||||||
|
import { Input, Button } from "antd";
|
||||||
|
const { TextArea } = Input;
|
||||||
|
import {
|
||||||
|
InfoCircleOutlined,
|
||||||
|
ExclamationCircleFilled,
|
||||||
|
InfoCircleFilled,
|
||||||
|
CloseOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
|
import { initGlobalPrompt, saveGlobalPrompt } from "../api";
|
||||||
|
import style from "../index.module.scss";
|
||||||
|
import { config } from "antd-mobile/es/components/toast/methods";
|
||||||
|
|
||||||
|
interface GlobalPromptModalProps {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_PROMPT = `你是存客宝AI知识库助手。请遵循以下基本原则:
|
||||||
|
|
||||||
|
1. 专业性: 使用专业但易懂的语言回答问题
|
||||||
|
2. 准确性: 基于知识库内容提供准确的信息
|
||||||
|
3. 友好性: 保持友好、耐心的服务态度
|
||||||
|
4. 简洁性: 回答简明扼要,重点突出
|
||||||
|
5. 引用性: 回答时注明信息来源`;
|
||||||
|
|
||||||
|
const GlobalPromptModal: React.FC<GlobalPromptModalProps> = ({
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
}) => {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [content, setContent] = useState(DEFAULT_PROMPT);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
fetchGlobalPrompt();
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
const fetchGlobalPrompt = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await initGlobalPrompt();
|
||||||
|
// 假定返回的数据结构包含 promptInfo 字段
|
||||||
|
setContent(res?.config?.prompt_info || DEFAULT_PROMPT);
|
||||||
|
} catch (error) {
|
||||||
|
Toast.show({ content: "获取配置失败", position: "bottom" });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!content.trim()) {
|
||||||
|
Toast.show({
|
||||||
|
content: "请输入提示词内容",
|
||||||
|
position: "bottom",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await saveGlobalPrompt({
|
||||||
|
promptInfo: content.trim(),
|
||||||
|
});
|
||||||
|
Toast.show({ content: "保存成功", position: "bottom" });
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
Toast.show({ content: "保存失败", position: "bottom" });
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popup
|
||||||
|
visible={visible}
|
||||||
|
onMaskClick={onClose}
|
||||||
|
bodyStyle={{ borderRadius: "16px 16px 0 0", minHeight: 300, padding: 0 }}
|
||||||
|
position="bottom"
|
||||||
|
closeOnMaskClick
|
||||||
|
className={style.promptModal}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={style.promptMobileHead}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
padding: "18px 20px 0 20px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "center" }}>
|
||||||
|
<InfoCircleOutlined
|
||||||
|
style={{
|
||||||
|
color: "#1677ff",
|
||||||
|
fontSize: 20,
|
||||||
|
marginRight: 8,
|
||||||
|
verticalAlign: "middle",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>统一提示词配置</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CloseOutlined onClick={onClose} />
|
||||||
|
</div>
|
||||||
|
<div className={style.promptContent}>
|
||||||
|
<div style={{ fontSize: 13, color: "#888", marginBottom: 12 }}>
|
||||||
|
设置所有知识库的通用回复规范
|
||||||
|
</div>
|
||||||
|
<TextArea
|
||||||
|
value={content}
|
||||||
|
onChange={e => setContent(e.target.value)}
|
||||||
|
placeholder="请输入统一提示词..."
|
||||||
|
maxLength={2000}
|
||||||
|
disabled={loading}
|
||||||
|
style={{ height: "200px", marginBottom: 15 }}
|
||||||
|
/>
|
||||||
|
<div className={style.promptSection}>
|
||||||
|
<div className={style.sectionTitle}>
|
||||||
|
<InfoCircleFilled
|
||||||
|
className={style.sectionIcon}
|
||||||
|
style={{ fontSize: 16 }}
|
||||||
|
/>
|
||||||
|
统一提示词作用
|
||||||
|
</div>
|
||||||
|
<div className={style.sectionContent}>
|
||||||
|
<ul>
|
||||||
|
<li>定义AI回复的基本风格和规范</li>
|
||||||
|
<li>确保所有知识库回复的一致性和专业度</li>
|
||||||
|
<li>与各知识库的回复基本合用</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={style.warningBox}>
|
||||||
|
<div className={style.warningTitle}>
|
||||||
|
<ExclamationCircleFilled
|
||||||
|
style={{
|
||||||
|
marginRight: 3,
|
||||||
|
fontSize: 16,
|
||||||
|
verticalAlign: "middle",
|
||||||
|
color: "#FC772B",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
提示词生效逻辑:
|
||||||
|
</div>
|
||||||
|
<div className={style.warningText}>
|
||||||
|
统一提示词(全局规则) + 知识库独立提示词(专业指导) =
|
||||||
|
该终AI回复内容
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={style.modalFooter}>
|
||||||
|
<Button onClick={onClose} size="large">
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
size="large"
|
||||||
|
type="primary"
|
||||||
|
>
|
||||||
|
{saving ? "保存中..." : "保存配置"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Popup>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GlobalPromptModal;
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
// AI知识库相关类型定义
|
||||||
|
|
||||||
|
// 知识库类型(对应接口的 type)
|
||||||
|
export interface KnowledgeBase {
|
||||||
|
id: number;
|
||||||
|
type: number; // 类型
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
label: string[]; // 标签(接口返回的字段名)
|
||||||
|
prompt: string | null; // 独立提示词
|
||||||
|
companyId: number;
|
||||||
|
userId: number;
|
||||||
|
createTime: string | null;
|
||||||
|
updateTime: string | null;
|
||||||
|
isDel: number;
|
||||||
|
delTime: number;
|
||||||
|
// 前端扩展字段
|
||||||
|
tags?: string[]; // 兼容字段,映射自 label
|
||||||
|
status?: number; // 0-禁用 1-启用(前端维护)
|
||||||
|
materialCount?: number; // 素材总数(前端计算)
|
||||||
|
useIndependentPrompt?: boolean; // 是否使用独立提示词(根据 prompt 判断)
|
||||||
|
independentPrompt?: string; // 独立提示词内容(映射自 prompt)
|
||||||
|
aiCallEnabled?: boolean; // AI调用配置(前端维护)
|
||||||
|
creatorName?: string;
|
||||||
|
callerCount?: number; // 调用者数量
|
||||||
|
}
|
||||||
|
|
||||||
|
// 素材类型
|
||||||
|
export interface Material {
|
||||||
|
id: number;
|
||||||
|
knowledgeBaseId: number;
|
||||||
|
fileName: string;
|
||||||
|
fileSize: number; // 字节
|
||||||
|
fileType: string; // 文件扩展名
|
||||||
|
filePath: string;
|
||||||
|
tags: string[];
|
||||||
|
uploadTime: string;
|
||||||
|
uploaderId: number;
|
||||||
|
uploaderName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用者类型
|
||||||
|
export interface Caller {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
avatar: string;
|
||||||
|
role: string; // 角色/职位
|
||||||
|
lastCallTime: string;
|
||||||
|
callCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 统一提示词配置
|
||||||
|
export interface GlobalPromptConfig {
|
||||||
|
enabled: boolean; // 是否启用统一提示词
|
||||||
|
content: string; // 提示词内容
|
||||||
|
}
|
||||||
|
|
||||||
|
// 知识库列表响应
|
||||||
|
export interface KnowledgeBaseListResponse {
|
||||||
|
data: KnowledgeBase[]; // 接口实际返回的是 data 字段
|
||||||
|
total: number;
|
||||||
|
per_page: number;
|
||||||
|
current_page: number;
|
||||||
|
last_page: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新建/编辑知识库表单数据
|
||||||
|
export interface KnowledgeBaseFormData {
|
||||||
|
id?: number;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
tags: string[];
|
||||||
|
useIndependentPrompt: boolean;
|
||||||
|
independentPrompt?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,421 @@
|
|||||||
|
// 页面容器
|
||||||
|
.knowledgePage {
|
||||||
|
padding: 16px 10px 0 16px;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提示横幅
|
||||||
|
.banner {
|
||||||
|
background: linear-gradient(135deg, #e6f7ff 0%, #f0f5ff 100%);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
border: 1px solid #91d5ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bannerIcon {
|
||||||
|
font-size: 20px;
|
||||||
|
color: #1890ff;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bannerContent {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bannerText {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.5;
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #1890ff;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 统计卡片区域
|
||||||
|
.statsContainer {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statCard {
|
||||||
|
flex: 1;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||||
|
text-align: center;
|
||||||
|
border: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statValue {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1890ff;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statLabel {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statValueSuccess {
|
||||||
|
color: #52c41a;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 知识库卡片
|
||||||
|
.knowledgeCard {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||||
|
border: 1px solid #ececec;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.knowledgeCard:hover {
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
|
||||||
|
border-color: #b3e5fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardLeft {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardTitle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardIcon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 20px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardName {
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #222;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardDescription {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #888;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
line-height: 1.4;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardRight {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardSwitch {
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardMenu {
|
||||||
|
font-size: 18px;
|
||||||
|
color: #888;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardStats {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding: 10px 0;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statItem {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statItemValue {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1890ff;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statItemLabel {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardTags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
background: rgba(24, 144, 255, 0.1);
|
||||||
|
color: #1890ff;
|
||||||
|
border: 1px solid rgba(24, 144, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 空状态
|
||||||
|
.empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
color: #bbb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyIcon {
|
||||||
|
font-size: 64px;
|
||||||
|
color: #d9d9d9;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyText {
|
||||||
|
font-size: 15px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新建知识库弹窗样式
|
||||||
|
.modalContent {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalTitle {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #222;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalSubtitle {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #888;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formItem {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formLabel {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formInput {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
outline: none;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: #1890ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.formTextarea {
|
||||||
|
min-height: 80px;
|
||||||
|
resize: vertical;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkboxWrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
background: #f9f9f9;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkboxLabel {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #333;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.promptHint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #888;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalFooter {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancelButton {
|
||||||
|
background: #f5f5f5;
|
||||||
|
color: #666;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background: #e8e8e8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.submitButton {
|
||||||
|
background: #1890ff;
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background: #096dd9;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
background: #d9d9d9;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 统一提示词弹窗
|
||||||
|
.promptModal {
|
||||||
|
.promptContent {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.promptToggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
background: #f9f9f9;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.promptToggleLabel {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.promptTextarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 200px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
resize: vertical;
|
||||||
|
font-family: inherit;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #1890ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.promptSection {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionIcon {
|
||||||
|
color: #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionContent {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #666;
|
||||||
|
line-height: 1.6;
|
||||||
|
padding: 10px;
|
||||||
|
background: #f9f9f9;
|
||||||
|
border-radius: 6px;
|
||||||
|
|
||||||
|
ul {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.warningBox {
|
||||||
|
background: #fffbe6;
|
||||||
|
border: 1px solid #ffe58f;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warningTitle {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #d46b08;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warningText {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #ad6800;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
423
Cunkebao/src/pages/mobile/workspace/ai-knowledge/list/index.tsx
Normal file
423
Cunkebao/src/pages/mobile/workspace/ai-knowledge/list/index.tsx
Normal file
@@ -0,0 +1,423 @@
|
|||||||
|
import React, { useEffect, useState, useCallback, useRef } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Switch,
|
||||||
|
Dropdown,
|
||||||
|
message,
|
||||||
|
Spin,
|
||||||
|
Pagination,
|
||||||
|
Input,
|
||||||
|
} from "antd";
|
||||||
|
import {
|
||||||
|
MoreOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
BookOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
GlobalOutlined,
|
||||||
|
InfoCircleOutlined,
|
||||||
|
SearchOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
|
import Layout from "@/components/Layout/Layout";
|
||||||
|
import NavCommon from "@/components/NavCommon";
|
||||||
|
import style from "./index.module.scss";
|
||||||
|
import {
|
||||||
|
fetchKnowledgeBaseList,
|
||||||
|
deleteKnowledgeBase,
|
||||||
|
initAIKnowledge,
|
||||||
|
updateTypeStatus,
|
||||||
|
} from "./api";
|
||||||
|
import type { KnowledgeBase } from "./data";
|
||||||
|
import GlobalPromptModal from "./components/GlobalPromptModal";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
|
const AIKnowledgeList: React.FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [list, setList] = useState<KnowledgeBase[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [enabledCount, setEnabledCount] = useState(0);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [menuLoadingId, setMenuLoadingId] = useState<number | null>(null);
|
||||||
|
const [searchValue, setSearchValue] = useState(""); // 搜索输入内容
|
||||||
|
const [keyword, setKeyword] = useState(""); // 实际用于搜索的关键词
|
||||||
|
const isInitialMount = useRef(true); // 标记是否是初始挂载
|
||||||
|
|
||||||
|
// 弹窗控制
|
||||||
|
const [globalPromptVisible, setGlobalPromptVisible] = useState(false);
|
||||||
|
|
||||||
|
const fetchList = useCallback(async (pageNum = 1, searchKeyword = "") => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetchKnowledgeBaseList({
|
||||||
|
page: pageNum,
|
||||||
|
limit: PAGE_SIZE,
|
||||||
|
keyword: searchKeyword || undefined,
|
||||||
|
});
|
||||||
|
// 转换数据格式,映射接口字段到前端字段
|
||||||
|
const transformedList = (res?.data || []).map((item: any) => ({
|
||||||
|
...item,
|
||||||
|
tags: item.label || [],
|
||||||
|
useIndependentPrompt: !!item.prompt,
|
||||||
|
independentPrompt: item.prompt || "",
|
||||||
|
status: item.isDel === 0 ? 1 : 0, // 未删除即为启用
|
||||||
|
aiCallEnabled: true, // 默认启用
|
||||||
|
materialCount: item.materialCount || 0, // 需要单独统计
|
||||||
|
}));
|
||||||
|
setList(transformedList);
|
||||||
|
setTotal(Number(res?.total) || 0);
|
||||||
|
} catch (e) {
|
||||||
|
message.error("获取知识库列表失败");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 初始化AI功能
|
||||||
|
initAIKnowledge().catch(err => {
|
||||||
|
console.warn("初始化AI功能失败", err);
|
||||||
|
});
|
||||||
|
fetchList(1, "");
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 搜索防抖处理
|
||||||
|
const debouncedSearch = useCallback(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
const searchKeyword = searchValue.trim();
|
||||||
|
setKeyword(searchKeyword);
|
||||||
|
setPage(1);
|
||||||
|
fetchList(1, searchKeyword);
|
||||||
|
}, 500); // 500ms 防抖延迟
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [searchValue, fetchList]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 初始挂载时不触发搜索(已在初始化时调用 fetchList)
|
||||||
|
if (isInitialMount.current) {
|
||||||
|
isInitialMount.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cleanup = debouncedSearch();
|
||||||
|
return cleanup;
|
||||||
|
}, [debouncedSearch]);
|
||||||
|
|
||||||
|
const handlePageChange = (p: number) => {
|
||||||
|
setPage(p);
|
||||||
|
fetchList(p, keyword);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRefresh = () => {
|
||||||
|
fetchList(page, keyword);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 菜单点击事件
|
||||||
|
const handleMenuClick = async (key: string, item: KnowledgeBase) => {
|
||||||
|
// 系统预设不允许编辑或删除
|
||||||
|
if (item.type === 0) {
|
||||||
|
message.warning("系统预设知识库不可编辑或删除");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === "edit") {
|
||||||
|
navigate(`/workspace/ai-knowledge/${item.id}/edit`);
|
||||||
|
} else if (key === "delete") {
|
||||||
|
setMenuLoadingId(item.id);
|
||||||
|
try {
|
||||||
|
await deleteKnowledgeBase(item.id);
|
||||||
|
message.success("删除成功");
|
||||||
|
handleRefresh();
|
||||||
|
} catch (e) {
|
||||||
|
message.error("删除失败");
|
||||||
|
} finally {
|
||||||
|
setMenuLoadingId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Switch切换状态 - 乐观更新模式
|
||||||
|
const handleSwitchChange = async (checked: boolean, item: KnowledgeBase) => {
|
||||||
|
// 系统预设不允许修改状态
|
||||||
|
if (item.type === 0) {
|
||||||
|
message.warning("系统预设知识库不可修改状态");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存旧状态用于回滚
|
||||||
|
const oldStatus = item.status;
|
||||||
|
const oldEnabledCount = enabledCount;
|
||||||
|
|
||||||
|
// 立即更新本地UI(乐观更新)
|
||||||
|
setList(prevList =>
|
||||||
|
prevList.map(kb =>
|
||||||
|
kb.id === item.id ? { ...kb, status: checked ? 1 : 0 } : kb,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setEnabledCount(prev => (checked ? prev + 1 : prev - 1));
|
||||||
|
|
||||||
|
// 异步请求接口
|
||||||
|
try {
|
||||||
|
await updateTypeStatus({ id: item.id, status: checked ? 1 : 0 });
|
||||||
|
// 成功后显示提示
|
||||||
|
message.success(checked ? "已启用" : "已禁用");
|
||||||
|
} catch (e) {
|
||||||
|
// 失败时回滚状态
|
||||||
|
setList(prevList =>
|
||||||
|
prevList.map(kb =>
|
||||||
|
kb.id === item.id ? { ...kb, status: oldStatus } : kb,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setEnabledCount(oldEnabledCount);
|
||||||
|
message.error("操作失败,请重试");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 打开知识库详情
|
||||||
|
const handleCardClick = (item: KnowledgeBase) => {
|
||||||
|
navigate(`/workspace/ai-knowledge/${item.id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 渲染知识库卡片
|
||||||
|
const renderCard = (item: KnowledgeBase) => {
|
||||||
|
const isSystemPreset = item.type === 0; // 系统预设不可编辑
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={item.id} className={style.knowledgeCard}>
|
||||||
|
<div className={style.cardHeader}>
|
||||||
|
<div
|
||||||
|
className={style.cardLeft}
|
||||||
|
onClick={() => handleCardClick(item)}
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
>
|
||||||
|
<div className={style.cardTitle}>
|
||||||
|
<div className={style.cardIcon}>
|
||||||
|
<BookOutlined />
|
||||||
|
</div>
|
||||||
|
<div className={style.cardName}>
|
||||||
|
{item.name}
|
||||||
|
{isSystemPreset && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
marginLeft: "8px",
|
||||||
|
fontSize: "12px",
|
||||||
|
color: "#999",
|
||||||
|
fontWeight: "normal",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
(系统预设)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{item.description && (
|
||||||
|
<div className={style.cardDescription}>{item.description}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={style.cardRight}>
|
||||||
|
<Switch
|
||||||
|
className={style.cardSwitch}
|
||||||
|
checked={item.status === 1}
|
||||||
|
size="small"
|
||||||
|
loading={menuLoadingId === item.id}
|
||||||
|
disabled={menuLoadingId === item.id || isSystemPreset}
|
||||||
|
onChange={checked => handleSwitchChange(checked, item)}
|
||||||
|
/>
|
||||||
|
{!isSystemPreset && (
|
||||||
|
<Dropdown
|
||||||
|
menu={{
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: "edit",
|
||||||
|
icon: <EditOutlined />,
|
||||||
|
label: "编辑",
|
||||||
|
disabled: menuLoadingId === item.id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "delete",
|
||||||
|
icon: <DeleteOutlined />,
|
||||||
|
label: "删除",
|
||||||
|
disabled: menuLoadingId === item.id,
|
||||||
|
danger: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
onClick: ({ key }) => handleMenuClick(key, item),
|
||||||
|
}}
|
||||||
|
trigger={["click"]}
|
||||||
|
placement="bottomRight"
|
||||||
|
disabled={menuLoadingId === item.id}
|
||||||
|
>
|
||||||
|
<MoreOutlined className={style.cardMenu} />
|
||||||
|
</Dropdown>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={style.cardStats}>
|
||||||
|
<div className={style.statItem}>
|
||||||
|
<div className={style.statItemValue}>{item.materialCount}</div>
|
||||||
|
<div className={style.statItemLabel}>素材总数</div>
|
||||||
|
</div>
|
||||||
|
<div className={style.statItem}>
|
||||||
|
<div
|
||||||
|
className={style.statItemValue}
|
||||||
|
style={{ color: item.aiCallEnabled ? "#52c41a" : "#999" }}
|
||||||
|
>
|
||||||
|
{item.aiCallEnabled ? "启用" : "关闭"}
|
||||||
|
</div>
|
||||||
|
<div className={style.statItemLabel}>AI状态</div>
|
||||||
|
</div>
|
||||||
|
<div className={style.statItem}>
|
||||||
|
<div className={style.statItemValue}>{item.tags?.length || 0}</div>
|
||||||
|
<div className={style.statItemLabel}>标签数</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{item.tags && item.tags.length > 0 && (
|
||||||
|
<div className={style.cardTags}>
|
||||||
|
{item.tags.map((tag, index) => (
|
||||||
|
<span key={index} className={style.tag}>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout
|
||||||
|
header={
|
||||||
|
<>
|
||||||
|
<NavCommon
|
||||||
|
title="AI知识库"
|
||||||
|
backFn={() => navigate("/workspace")}
|
||||||
|
right={
|
||||||
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
|
<Button onClick={() => setGlobalPromptVisible(true)}>
|
||||||
|
<GlobalOutlined /> 统一提示词
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
onClick={() => navigate("/workspace/ai-knowledge/new")}
|
||||||
|
>
|
||||||
|
<PlusOutlined /> 新建
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "16px 16px 0 16px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 提示横幅 */}
|
||||||
|
<div className={style.banner}>
|
||||||
|
<InfoCircleOutlined className={style.bannerIcon} />
|
||||||
|
<div className={style.bannerContent}>
|
||||||
|
<div className={style.bannerText}>
|
||||||
|
已启用统一提示词规则
|
||||||
|
<a onClick={() => setGlobalPromptVisible(true)}>
|
||||||
|
点击“统一提示词”可查看和编辑
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 统计卡片 */}
|
||||||
|
<div className={style.statsContainer}>
|
||||||
|
<div className={style.statCard}>
|
||||||
|
<div className={style.statValue}>{total}</div>
|
||||||
|
<div className={style.statLabel}>内容库总数</div>
|
||||||
|
</div>
|
||||||
|
<div className={style.statCard}>
|
||||||
|
<div className={`${style.statValue} ${style.statValueSuccess}`}>
|
||||||
|
{enabledCount}
|
||||||
|
</div>
|
||||||
|
<div className={style.statLabel}>启用中</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 搜索和客户案例库按钮 */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: 8,
|
||||||
|
marginBottom: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
placeholder="搜索知识库名称或描述"
|
||||||
|
value={searchValue}
|
||||||
|
onChange={e => setSearchValue(e.target.value)}
|
||||||
|
prefix={<SearchOutlined />}
|
||||||
|
allowClear
|
||||||
|
size="large"
|
||||||
|
onPressEnter={() => {
|
||||||
|
const searchKeyword = searchValue.trim();
|
||||||
|
setKeyword(searchKeyword);
|
||||||
|
setPage(1);
|
||||||
|
fetchList(1, searchKeyword);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
footer={
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "16px",
|
||||||
|
background: "#fff",
|
||||||
|
borderTop: "1px solid #f0f0f0",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pagination
|
||||||
|
current={page}
|
||||||
|
pageSize={PAGE_SIZE}
|
||||||
|
total={total}
|
||||||
|
onChange={handlePageChange}
|
||||||
|
showSizeChanger={false}
|
||||||
|
showTotal={total => `共 ${total} 条`}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={style.knowledgePage}>
|
||||||
|
{/* 知识库列表 */}
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ textAlign: "center", padding: "40px 0" }}>
|
||||||
|
<Spin />
|
||||||
|
</div>
|
||||||
|
) : list.length > 0 ? (
|
||||||
|
list.map(renderCard)
|
||||||
|
) : (
|
||||||
|
<div className={style.empty}>
|
||||||
|
<div className={style.emptyIcon}>
|
||||||
|
<BookOutlined />
|
||||||
|
</div>
|
||||||
|
<div className={style.emptyText}>
|
||||||
|
{keyword ? "未找到匹配的知识库" : "暂无知识库"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 统一提示词弹窗 */}
|
||||||
|
<GlobalPromptModal
|
||||||
|
visible={globalPromptVisible}
|
||||||
|
onClose={() => setGlobalPromptVisible(false)}
|
||||||
|
/>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AIKnowledgeList;
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
LinkOutlined,
|
LinkOutlined,
|
||||||
ClockCircleOutlined,
|
ClockCircleOutlined,
|
||||||
ContactsOutlined,
|
ContactsOutlined,
|
||||||
|
BookOutlined,
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import Layout from "@/components/Layout/Layout";
|
import Layout from "@/components/Layout/Layout";
|
||||||
import MeauMobile from "@/components/MeauMobile/MeauMoible";
|
import MeauMobile from "@/components/MeauMobile/MeauMoible";
|
||||||
@@ -75,12 +76,26 @@ const Workspace: React.FC = () => {
|
|||||||
name: "通讯录导入",
|
name: "通讯录导入",
|
||||||
description: "批量导入通讯录联系人",
|
description: "批量导入通讯录联系人",
|
||||||
icon: (
|
icon: (
|
||||||
<ContactsOutlined className={styles.icon} style={{ color: "#722ed1" }} />
|
<ContactsOutlined
|
||||||
|
className={styles.icon}
|
||||||
|
style={{ color: "#722ed1" }}
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
path: "/workspace/contact-import/list",
|
path: "/workspace/contact-import/list",
|
||||||
bgColor: "#f9f0ff",
|
bgColor: "#f9f0ff",
|
||||||
isNew: true,
|
isNew: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "ai-knowledge",
|
||||||
|
name: "AI知识库",
|
||||||
|
description: "管理和配置内容",
|
||||||
|
icon: (
|
||||||
|
<BookOutlined className={styles.icon} style={{ color: "#fa8c16" }} />
|
||||||
|
),
|
||||||
|
path: "/workspace/ai-knowledge",
|
||||||
|
bgColor: "#fff7e6",
|
||||||
|
isNew: true,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -2,17 +2,20 @@ import Mine from "@/pages/mobile/mine/main/index";
|
|||||||
import Devices from "@/pages/mobile/mine/devices/index";
|
import Devices from "@/pages/mobile/mine/devices/index";
|
||||||
import DeviceDetail from "@/pages/mobile/mine/devices/DeviceDetail";
|
import DeviceDetail from "@/pages/mobile/mine/devices/DeviceDetail";
|
||||||
import TrafficPool from "@/pages/mobile/mine/traffic-pool/list/index";
|
import TrafficPool from "@/pages/mobile/mine/traffic-pool/list/index";
|
||||||
import TrafficPoolDetail from "@/pages/mobile/mine/traffic-pool/detail/index";
|
import TrafficPool2 from "@/pages/mobile/mine/traffic-pool/poolList1/index";
|
||||||
|
import TrafficPoolUserList from "@/pages/mobile/mine/traffic-pool/userList/index";
|
||||||
|
import CreateTrafficPackage from "@/pages/mobile/mine/traffic-pool/form/index";
|
||||||
import WechatAccounts from "@/pages/mobile/mine/wechat-accounts/list/index";
|
import WechatAccounts from "@/pages/mobile/mine/wechat-accounts/list/index";
|
||||||
import WechatAccountDetail from "@/pages/mobile/mine/wechat-accounts/detail/index";
|
import WechatAccountDetail from "@/pages/mobile/mine/wechat-accounts/detail/index";
|
||||||
import Recharge from "@/pages/mobile/mine/recharge/index";
|
import Recharge from "@/pages/mobile/mine/recharge/index";
|
||||||
import RechargeOrder from "@/pages/mobile/mine/recharge/order/index";
|
import RechargeOrder from "@/pages/mobile/mine/recharge/order/index";
|
||||||
|
import BuyPower from "@/pages/mobile/mine/recharge/buy-power";
|
||||||
|
import UsageRecords from "@/pages/mobile/mine/recharge/usage-records";
|
||||||
import Setting from "@/pages/mobile/mine/setting/index";
|
import Setting from "@/pages/mobile/mine/setting/index";
|
||||||
import SecuritySetting from "@/pages/mobile/mine/setting/SecuritySetting";
|
import SecuritySetting from "@/pages/mobile/mine/setting/SecuritySetting";
|
||||||
import About from "@/pages/mobile/mine/setting/About";
|
import About from "@/pages/mobile/mine/setting/About";
|
||||||
import Privacy from "@/pages/mobile/mine/setting/Privacy";
|
import Privacy from "@/pages/mobile/mine/setting/Privacy";
|
||||||
import UserSetting from "@/pages/mobile/mine/setting/UserSetting";
|
import UserSetting from "@/pages/mobile/mine/setting/UserSetting";
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{
|
{
|
||||||
path: "/mine",
|
path: "/mine",
|
||||||
@@ -29,16 +32,29 @@ const routes = [
|
|||||||
element: <DeviceDetail />,
|
element: <DeviceDetail />,
|
||||||
auth: true,
|
auth: true,
|
||||||
},
|
},
|
||||||
|
//流量池列表页面
|
||||||
{
|
{
|
||||||
path: "/mine/traffic-pool",
|
path: "/mine/traffic-pool",
|
||||||
element: <TrafficPool />,
|
element: <TrafficPool />,
|
||||||
auth: true,
|
auth: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/mine/traffic-pool/detail/:wxid/:userId",
|
path: "/mine/traffic-pool/list2",
|
||||||
element: <TrafficPoolDetail />,
|
element: <TrafficPool2 />,
|
||||||
auth: true,
|
auth: true,
|
||||||
},
|
},
|
||||||
|
//新建流量包页面
|
||||||
|
{
|
||||||
|
path: "/mine/traffic-pool/create",
|
||||||
|
element: <CreateTrafficPackage />,
|
||||||
|
auth: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/mine/traffic-pool/userList/:id",
|
||||||
|
element: <TrafficPoolUserList />,
|
||||||
|
auth: true,
|
||||||
|
},
|
||||||
|
|
||||||
// 微信号管理路由
|
// 微信号管理路由
|
||||||
{
|
{
|
||||||
path: "/wechat-accounts",
|
path: "/wechat-accounts",
|
||||||
@@ -60,6 +76,16 @@ const routes = [
|
|||||||
element: <RechargeOrder />,
|
element: <RechargeOrder />,
|
||||||
auth: true,
|
auth: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/recharge/buy-power",
|
||||||
|
element: <BuyPower />,
|
||||||
|
auth: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/recharge/usage-records",
|
||||||
|
element: <UsageRecords />,
|
||||||
|
auth: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/settings",
|
path: "/settings",
|
||||||
element: <Setting />,
|
element: <Setting />,
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ import ContactImportForm from "@/pages/mobile/workspace/contact-import/form";
|
|||||||
import ContactImportDetail from "@/pages/mobile/workspace/contact-import/detail";
|
import ContactImportDetail from "@/pages/mobile/workspace/contact-import/detail";
|
||||||
import PlaceholderPage from "@/components/PlaceholderPage";
|
import PlaceholderPage from "@/components/PlaceholderPage";
|
||||||
import AiAnalyzer from "@/pages/mobile/workspace/ai-analyzer";
|
import AiAnalyzer from "@/pages/mobile/workspace/ai-analyzer";
|
||||||
|
import AIKnowledgeList from "@/pages/mobile/workspace/ai-knowledge/list";
|
||||||
|
import AIKnowledgeDetail from "@/pages/mobile/workspace/ai-knowledge/detail";
|
||||||
|
import AIKnowledgeForm from "@/pages/mobile/workspace/ai-knowledge/form";
|
||||||
|
|
||||||
const workspaceRoutes = [
|
const workspaceRoutes = [
|
||||||
{
|
{
|
||||||
@@ -178,6 +181,27 @@ const workspaceRoutes = [
|
|||||||
element: <ContactImportDetail />,
|
element: <ContactImportDetail />,
|
||||||
auth: true,
|
auth: true,
|
||||||
},
|
},
|
||||||
|
// AI知识库
|
||||||
|
{
|
||||||
|
path: "/workspace/ai-knowledge",
|
||||||
|
element: <AIKnowledgeList />,
|
||||||
|
auth: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/workspace/ai-knowledge/new",
|
||||||
|
element: <AIKnowledgeForm />,
|
||||||
|
auth: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/workspace/ai-knowledge/:id",
|
||||||
|
element: <AIKnowledgeDetail />,
|
||||||
|
auth: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/workspace/ai-knowledge/:id/edit",
|
||||||
|
element: <AIKnowledgeForm />,
|
||||||
|
auth: true,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default workspaceRoutes;
|
export default workspaceRoutes;
|
||||||
|
|||||||
Reference in New Issue
Block a user