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文件",
|
||||
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字符串
|
||||
const generateAcceptString = () => {
|
||||
return acceptTypes
|
||||
.map(type => fileTypeConfig[type as keyof typeof fileTypeConfig]?.accept)
|
||||
.filter(Boolean)
|
||||
.join(",");
|
||||
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 || "")) {
|
||||
if (config && config.extensions.includes(extension)) {
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -116,12 +174,29 @@ const FileUpload: React.FC<FileUploadProps> = ({
|
||||
}
|
||||
}, [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 typeInfo = getFileTypeInfo(file);
|
||||
if (!typeInfo) {
|
||||
const allowedTypes = acceptTypes
|
||||
.map(type => fileTypeConfig[type as keyof typeof fileTypeConfig]?.name)
|
||||
.map(type => getTypeName(type))
|
||||
.filter(Boolean)
|
||||
.join("、");
|
||||
message.error(`只能上传${allowedTypes}!`);
|
||||
@@ -310,10 +385,7 @@ const FileUpload: React.FC<FileUploadProps> = ({
|
||||
<div className={style.uploadSubtitle}>
|
||||
支持{" "}
|
||||
{acceptTypes
|
||||
.map(
|
||||
type =>
|
||||
fileTypeConfig[type as keyof typeof fileTypeConfig]?.name,
|
||||
)
|
||||
.map(type => getTypeName(type))
|
||||
.filter(Boolean)
|
||||
.join("、")}
|
||||
,最大 {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";
|
||||
|
||||
interface taocanItem {
|
||||
id: 1;
|
||||
name: "试用套餐";
|
||||
tokens: "2,800";
|
||||
price: 9800;
|
||||
originalPrice: 140;
|
||||
description: ["适合新用户体验", "包含基础AI功能", "永久有效", "客服支持"];
|
||||
sort: 1;
|
||||
isTrial: 1;
|
||||
isRecommend: 0;
|
||||
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;
|
||||
export interface Statistics {
|
||||
totalTokens: number; // 总算力
|
||||
todayUsed: number; // 今日使用
|
||||
monthUsed: number; // 本月使用
|
||||
remainingTokens: number; // 剩余算力
|
||||
totalConsumed: number; // 总消耗
|
||||
}
|
||||
// 算力统计接口
|
||||
export function getStatistics(): Promise<Statistics> {
|
||||
return request("/v1/tokens/statistics", undefined, "GET");
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
// 支付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");
|
||||
}
|
||||
|
||||
// 自定义购买算力
|
||||
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 {
|
||||
color: var(--primary-color);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(24, 142, 238, 0.1);
|
||||
.powerTabs {
|
||||
:global {
|
||||
.adm-tabs-header {
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.refreshBtn {
|
||||
font-size: 18px;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
|
||||
&:active {
|
||||
background-color: rgba(24, 142, 238, 0.2);
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.recharge-tabs {
|
||||
:global(.adm-tabs-header) {
|
||||
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 ====================
|
||||
.overviewContent {
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
}
|
||||
|
||||
.balance-card {
|
||||
.accountCards {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
background: #f6ffed;
|
||||
border: 1px solid #b7eb8f;
|
||||
border-radius: 12px;
|
||||
padding: 18px 0 18px 0;
|
||||
}
|
||||
|
||||
.balanceCard,
|
||||
.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;
|
||||
align-items: center;
|
||||
.balance-content {
|
||||
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;
|
||||
}
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.quick-card {
|
||||
margin-bottom: 16px;
|
||||
.quick-list {
|
||||
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;
|
||||
.iconWrapper {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
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;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #faad14;
|
||||
font-size: 14px;
|
||||
}
|
||||
.warn-icon {
|
||||
font-size: 30px;
|
||||
color: #faad14;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.warn-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.warn-title {
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
}
|
||||
.warn-text {
|
||||
color: #faad14;
|
||||
font-size: 14px;
|
||||
|
||||
.balanceCard .iconWrapper {
|
||||
background: #d6edff;
|
||||
}
|
||||
|
||||
// AI服务样式
|
||||
.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 {
|
||||
.cardIcon {
|
||||
font-size: 24px;
|
||||
color: var(--primary-color);
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.ai-tag {
|
||||
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;
|
||||
.balanceCard .cardIcon {
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.tag-green {
|
||||
background: #f6ffed;
|
||||
color: #52c41a;
|
||||
.powerCard .cardIcon {
|
||||
color: #722ed1;
|
||||
}
|
||||
|
||||
.package-price {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--primary-color);
|
||||
.textWrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.package-description {
|
||||
.cardTitle {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
margin-bottom: 12px;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.features-title {
|
||||
font-size: 14px;
|
||||
.usageStats {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.usageItem {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.usageValue {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.package-status {
|
||||
text-align: center;
|
||||
.valueGreen {
|
||||
color: #52c41a;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.upgrade-btn {
|
||||
border-radius: 8px;
|
||||
.valueBlue {
|
||||
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-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 { 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 {
|
||||
WalletOutlined,
|
||||
WarningOutlined,
|
||||
ClockCircleOutlined,
|
||||
RobotOutlined,
|
||||
CrownOutlined,
|
||||
SyncOutlined,
|
||||
ShoppingCartOutlined,
|
||||
HistoryOutlined,
|
||||
LineChartOutlined,
|
||||
DownOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import NavCommon from "@/components/NavCommon";
|
||||
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服务列表数据
|
||||
const aiServices = [
|
||||
{
|
||||
id: 1,
|
||||
name: "添加好友及打招呼",
|
||||
icon: "💬",
|
||||
price: 1,
|
||||
description: "AI智能添加好友并发送个性化打招呼消息",
|
||||
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 },
|
||||
},
|
||||
];
|
||||
type OrderRecordView = {
|
||||
id: number;
|
||||
type: string;
|
||||
status: string;
|
||||
amount: number; // 元
|
||||
power: number;
|
||||
description: string;
|
||||
createTime: string;
|
||||
};
|
||||
|
||||
// 版本套餐数据
|
||||
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 PowerManagement: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
// 假设余额从后端接口获取,实际可用props或store传递
|
||||
const [balance] = useState(0);
|
||||
const [selected, setSelected] = useState<any | null>(null);
|
||||
const [activeTab, setActiveTab] = useState("overview");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState("account");
|
||||
const [taocanList, setTaocanList] = useState<any[]>([]);
|
||||
const [stats, setStats] = useState<Statistics | null>(null);
|
||||
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(() => {
|
||||
const loadTaocanList = async () => {
|
||||
try {
|
||||
const res = await getTaocanList();
|
||||
if (res.list) {
|
||||
setTaocanList(res.list);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("加载套餐列表失败:", error);
|
||||
Toast.show({ content: "加载套餐列表失败", position: "top" });
|
||||
}
|
||||
};
|
||||
loadTaocanList();
|
||||
fetchStats();
|
||||
}, []);
|
||||
|
||||
// 充值操作
|
||||
const handleRecharge = async () => {
|
||||
if (!selected) {
|
||||
Toast.show({ content: "请选择充值套餐", position: "top" });
|
||||
return;
|
||||
useEffect(() => {
|
||||
if (activeTab === "records") {
|
||||
setPage(1);
|
||||
fetchRecords(1);
|
||||
}
|
||||
// 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);
|
||||
try {
|
||||
const res = await pay({
|
||||
id: selected.id,
|
||||
price: selected.price,
|
||||
const reqPage = customPage !== undefined ? customPage : page;
|
||||
// 映射状态到订单状态:0待支付 1已支付 2已取消 3已退款
|
||||
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) {
|
||||
// 显示二维码弹窗
|
||||
Dialog.show({
|
||||
content: (
|
||||
<div style={{ textAlign: "center", padding: "20px" }}>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: "16px",
|
||||
fontSize: "16px",
|
||||
fontWeight: "500",
|
||||
}}
|
||||
>
|
||||
请使用微信扫码支付
|
||||
</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,
|
||||
});
|
||||
}
|
||||
|
||||
const list = (res.list || []).map((o: any) => ({
|
||||
id: o.id,
|
||||
type: o.orderTypeText || o.goodsName || "充值订单",
|
||||
status: o.statusText || "",
|
||||
amount: typeof o.money === "number" ? o.money / 100 : 0,
|
||||
power: Number(o.goodsSpecs?.tokens ?? o.tokens ?? 0),
|
||||
description: o.goodsName || "",
|
||||
createTime: o.createTime || "",
|
||||
}));
|
||||
setRecords(list);
|
||||
setTotal(Number(res.total || 0));
|
||||
} catch (error) {
|
||||
console.error("支付失败:", error);
|
||||
Toast.show({ content: "支付失败,请重试", position: "top" });
|
||||
console.error("获取消费记录失败:", error);
|
||||
Toast.show({ content: "获取消费记录失败", position: "top" });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染账户充值tab内容
|
||||
const renderAccountRecharge = () => (
|
||||
<div className={style["tab-content"]}>
|
||||
<Card className={style["balance-card"]}>
|
||||
<div className={style["balance-content"]}>
|
||||
<WalletOutlined className={style["wallet-icon"]} />
|
||||
<div className={style["balance-info"]}>
|
||||
<div className={style["balance-label"]}>当前余额</div>
|
||||
<div className={style["balance-amount"]}>
|
||||
¥{balance.toFixed(2)}
|
||||
const handleRefresh = () => {
|
||||
if (loading) return;
|
||||
fetchStats();
|
||||
if (activeTab === "records") {
|
||||
fetchRecords();
|
||||
}
|
||||
};
|
||||
|
||||
const handleBuyPower = () => {
|
||||
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>
|
||||
</Card>
|
||||
<Card className={style["quick-card"]}>
|
||||
<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 className={style.textWrapper}>
|
||||
<div className={style.cardTitle}>总算力</div>
|
||||
<div className={style.cardValue}>
|
||||
{formatNumber(stats?.totalTokens)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
</div>
|
||||
|
||||
// 渲染AI服务tab内容
|
||||
const renderAiServices = () => (
|
||||
<div className={style["tab-content"]}>
|
||||
<div className={style["ai-header"]}>
|
||||
<div className={style["ai-title"]}>
|
||||
<RobotOutlined className={style["ai-icon"]} />
|
||||
AI智能服务收费
|
||||
{/* 使用情况卡片 */}
|
||||
<Card className={style.usageCard}>
|
||||
<div className={style.usageTitle}>使用情况</div>
|
||||
<div className={style.usageStats}>
|
||||
<div className={style.usageItem}>
|
||||
<div className={`${style.usageValue} ${style.valueGreen}`}>
|
||||
{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 className={style["ai-tag"]}>统一按次收费</div>
|
||||
</div>
|
||||
<div className={style["ai-description"]}>
|
||||
三项核心AI服务,按使用次数收费,每次1元
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className={style["ai-services"]}>
|
||||
{aiServices.map(service => (
|
||||
<Card key={service.id} className={style["ai-service-card"]}>
|
||||
<div className={style["service-header"]}>
|
||||
<div className={style["service-info"]}>
|
||||
<div className={style["service-icon"]}>{service.icon}</div>
|
||||
<div className={style["service-details"]}>
|
||||
<div className={style["service-name"]}>{service.name}</div>
|
||||
<div className={style["service-price"]}>
|
||||
¥{service.price}/次
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={style["service-description"]}>
|
||||
{service.description}
|
||||
</div>
|
||||
<div className={style["service-features"]}>
|
||||
{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>
|
||||
{/* 快速操作 */}
|
||||
<Card className={style.actionCard}>
|
||||
<div className={style.actionTitle}>快速操作</div>
|
||||
<div className={style.actionButtons}>
|
||||
<Button className={style.buyButton} onClick={handleBuyPower} block>
|
||||
<ShoppingCartOutlined className={style.buttonIcon} />
|
||||
购买算力包
|
||||
</Button>
|
||||
<Button
|
||||
className={style.recordButton}
|
||||
onClick={handleViewRecords}
|
||||
block
|
||||
>
|
||||
<HistoryOutlined className={style.buttonIcon} />
|
||||
使用记录
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 渲染版本套餐tab内容
|
||||
const renderVersionPackages = () => (
|
||||
<div className={style["tab-content"]}>
|
||||
<div className={style["version-header"]}>
|
||||
<CrownOutlined className={style["version-icon"]} />
|
||||
<span>存客宝版本套餐</span>
|
||||
</div>
|
||||
<div className={style["version-description"]}>
|
||||
选择适合的版本,享受不同级别的AI服务
|
||||
// 渲染消费记录Tab
|
||||
const renderRecords = () => (
|
||||
<div className={style.recordsContent}>
|
||||
{/* 筛选器 */}
|
||||
<div className={style.filters}>
|
||||
<Picker
|
||||
columns={[typeOptions]}
|
||||
visible={filterTypeVisible}
|
||||
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 className={style["version-packages"]}>
|
||||
{versionPackages.map(pkg => (
|
||||
<Card key={pkg.id} className={style["version-card"]}>
|
||||
<div className={style["package-header"]}>
|
||||
<div className={style["package-info"]}>
|
||||
<div className={style["package-icon"]}>{pkg.icon}</div>
|
||||
<div className={style["package-details"]}>
|
||||
<div className={style["package-name"]}>
|
||||
{pkg.name}
|
||||
{pkg.tag && (
|
||||
<span
|
||||
className={`${style["package-tag"]} ${style[`tag-${pkg.tagColor || "blue"}`]}`}
|
||||
>
|
||||
{pkg.tag}
|
||||
</span>
|
||||
)}
|
||||
{/* 消费记录列表 */}
|
||||
<div className={style.recordList}>
|
||||
{loading && records.length === 0 ? (
|
||||
<div className={style.loadingContainer}>
|
||||
<div className={style.loadingText}>加载中...</div>
|
||||
</div>
|
||||
) : records.length > 0 ? (
|
||||
records.map(record => (
|
||||
<Card key={record.id} className={style.recordItem}>
|
||||
<div className={style.recordHeader}>
|
||||
<div className={style.recordLeft}>
|
||||
<div className={style.recordType}>{record.type}</div>
|
||||
<Tag
|
||||
color={record.status === "已完成" ? "success" : "primary"}
|
||||
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 className={style["package-price"]}>{pkg.price}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={style["package-description"]}>
|
||||
{pkg.description}
|
||||
</div>
|
||||
<div className={style["package-features"]}>
|
||||
<div className={style["features-title"]}>包含功能:</div>
|
||||
{pkg.features.map((feature, index) => (
|
||||
<div key={index} className={style["feature-item"]}>
|
||||
<span className={style["feature-check"]}>✓</span>
|
||||
{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 className={style.recordDesc}>{record.description}</div>
|
||||
<div className={style.recordTime}>{record.createTime}</div>
|
||||
</Card>
|
||||
))
|
||||
) : (
|
||||
<div className={style.emptyRecords}>
|
||||
<div className={style.emptyIcon}>📋</div>
|
||||
<div className={style.emptyText}>暂无消费记录</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Layout
|
||||
loading={loading}
|
||||
header={
|
||||
<NavCommon
|
||||
title="充值中心"
|
||||
right={
|
||||
<div
|
||||
className={style["record-btn"]}
|
||||
onClick={() => navigate("/recharge/order")}
|
||||
>
|
||||
<ClockCircleOutlined />
|
||||
记录
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<>
|
||||
<NavCommon
|
||||
title="算力管理"
|
||||
right={
|
||||
<div className={style.refreshBtn} onClick={handleRefresh}>
|
||||
<SyncOutlined spin={loading} />
|
||||
</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"]}>
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
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 className={style.powerPage}>
|
||||
{activeTab === "overview" && renderOverview()}
|
||||
{activeTab === "records" && renderRecords()}
|
||||
</div>
|
||||
</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";
|
||||
|
||||
// 获取流量池列表
|
||||
export function fetchTrafficPoolList(params: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
keyword?: string;
|
||||
}) {
|
||||
return request("/v1/traffic/pool", params, "GET");
|
||||
export interface Package {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
pic: string;
|
||||
type: number;
|
||||
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 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");
|
||||
// 删除数据包
|
||||
export async function deletePackage(id: number): Promise<{ success: boolean }> {
|
||||
return request("/v1/traffic/pool/deletePackage", { id }, "POST");
|
||||
}
|
||||
|
||||
@@ -1,65 +1,185 @@
|
||||
.listWrap {
|
||||
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;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
position: relative;
|
||||
}
|
||||
.checkbox {
|
||||
|
||||
/* 三点菜单按钮 */
|
||||
.menuButton {
|
||||
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;
|
||||
right: 8px;
|
||||
top: 10px;
|
||||
z-index: 10;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.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 {
|
||||
font-size: 16px;
|
||||
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;
|
||||
color: #888;
|
||||
margin: 6px 0 4px 0;
|
||||
/* 右侧标签区域 */
|
||||
.rightTags {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.count {
|
||||
font-size: 13px;
|
||||
color: #1677ff;
|
||||
.deliveryTag {
|
||||
background: #fff7e6;
|
||||
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;
|
||||
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;
|
||||
gap: 16px;
|
||||
margin: 16px 0;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.pagination button {
|
||||
background: #f5f5f5;
|
||||
border: none;
|
||||
.highScoreTag {
|
||||
background: linear-gradient(135deg, #ff6b35, #ff8c42);
|
||||
color: white;
|
||||
font-size: 10px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
padding: 4px 12px;
|
||||
color: #1677ff;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.pagination button:disabled {
|
||||
color: #ccc;
|
||||
cursor: not-allowed;
|
||||
/* 底部按钮区域 */
|
||||
.bottomActions {
|
||||
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 {
|
||||
SearchOutlined,
|
||||
ReloadOutlined,
|
||||
BarChartOutlined,
|
||||
PlusOutlined,
|
||||
MoreOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { Toast } from "antd-mobile";
|
||||
import { Input, Button, Checkbox, Pagination } from "antd";
|
||||
import { Input, Button, Pagination, Dropdown, message } from "antd";
|
||||
import styles from "./index.module.scss";
|
||||
import { Empty, Avatar } from "antd-mobile";
|
||||
import { Empty } 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";
|
||||
import { getPackage, deletePackage } from "./api";
|
||||
import type { Package, PackageList } from "./api";
|
||||
|
||||
// 分组图标映射
|
||||
const getGroupIcon = (type: number, name?: string) => {
|
||||
if (type === 0 && name) {
|
||||
// type=0时使用分组名称首个字符
|
||||
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 navigate = useNavigate();
|
||||
|
||||
// 基础状态
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [list, setList] = useState<TrafficPoolUser[]>([]);
|
||||
const [list, setList] = useState<Package[]>([]);
|
||||
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 handleSearch = (value: string) => {
|
||||
setSearch(value);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
// 公共筛选条件状态
|
||||
const [filterParams, setFilterParams] = useState({
|
||||
selectedDevices: [] as DeviceSelectionItem[],
|
||||
packageId: 0,
|
||||
scenarioId: 0,
|
||||
userValue: 0,
|
||||
userStatus: 0,
|
||||
});
|
||||
const handleRefresh = () => {
|
||||
setPage(1);
|
||||
// 触发数据重新获取
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = {
|
||||
page: 1,
|
||||
pageSize,
|
||||
keyword: search,
|
||||
};
|
||||
|
||||
// 批量相关
|
||||
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 !== "请求过于频繁,请稍后再试") {
|
||||
const res: PackageList = await getPackage(params);
|
||||
setList(res?.list || []);
|
||||
setTotal(res?.total || 0);
|
||||
} catch (error) {
|
||||
console.error("获取列表失败:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
};
|
||||
|
||||
// 获取筛选项
|
||||
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 => {
|
||||
const handleDelete = async (id: number, name: string) => {
|
||||
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" });
|
||||
// eslint-disable-next-line no-alert
|
||||
if (!confirm(`确认删除数据包“${name}”吗?`)) return;
|
||||
await deletePackage(id);
|
||||
message.success("已删除");
|
||||
handleRefresh();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
message.error("删除失败");
|
||||
}
|
||||
};
|
||||
|
||||
// 搜索防抖处理
|
||||
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 fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = {
|
||||
page,
|
||||
pageSize,
|
||||
keyword: search,
|
||||
};
|
||||
|
||||
const handSearch = (value: string) => {
|
||||
setSearchInput(value);
|
||||
setSelectedIds([]);
|
||||
debouncedSearch();
|
||||
};
|
||||
const res: PackageList = await getPackage(params);
|
||||
setList(res?.list || []);
|
||||
setTotal(res?.total || 0);
|
||||
} catch (error) {
|
||||
console.error("获取列表失败:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [page, pageSize, search]);
|
||||
|
||||
return (
|
||||
<Layout
|
||||
@@ -186,96 +124,38 @@ const TrafficPoolList: React.FC = () => {
|
||||
header={
|
||||
<>
|
||||
<NavCommon
|
||||
title="流量池用户列表"
|
||||
title="流量池"
|
||||
right={
|
||||
<Button
|
||||
onClick={() => setShowStats(s => !s)}
|
||||
style={{ marginLeft: 8 }}
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
navigate("/mine/traffic-pool/create");
|
||||
}}
|
||||
>
|
||||
<BarChartOutlined /> {showStats ? "收起分析" : "数据分析"}
|
||||
新建分组
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{/* 搜索栏 */}
|
||||
|
||||
<div className="search-bar">
|
||||
<div className="search-input-wrapper">
|
||||
<Input
|
||||
placeholder="搜索计划名称"
|
||||
value={searchInput}
|
||||
onChange={e => handSearch(e.target.value)}
|
||||
placeholder="搜索分组"
|
||||
value={search}
|
||||
onChange={e => handleSearch(e.target.value)}
|
||||
prefix={<SearchOutlined />}
|
||||
allowClear
|
||||
size="large"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => getList()}
|
||||
onClick={handleRefresh}
|
||||
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>
|
||||
</>
|
||||
}
|
||||
@@ -283,103 +163,107 @@ const TrafficPoolList: React.FC = () => {
|
||||
<div className="pagination-container">
|
||||
<Pagination
|
||||
current={page}
|
||||
pageSize={20}
|
||||
pageSize={pageSize}
|
||||
total={total}
|
||||
showSizeChanger={false}
|
||||
onChange={newPage => {
|
||||
setPage(newPage);
|
||||
getList({ page: newPage });
|
||||
}}
|
||||
onChange={setPage}
|
||||
/>
|
||||
</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="暂无数据" />
|
||||
<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 key={item.id} className={styles.cardCompact}>
|
||||
<div className={styles.cardBody}>
|
||||
<div
|
||||
className={styles.menuButton}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
key: "preview",
|
||||
label: "预览用户",
|
||||
onClick: () =>
|
||||
navigate(
|
||||
`/mine/traffic-pool/userList/${item.id}`,
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
danger: true,
|
||||
label: "删除数据包",
|
||||
onClick: () => handleDelete(item.id, item.name),
|
||||
},
|
||||
],
|
||||
}}
|
||||
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 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 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 className={styles.deliveryInfo}>
|
||||
<span>
|
||||
类型: {item.type === 0 ? "自定义" : "系统分组"}
|
||||
</span>
|
||||
<span>创建:{item.createTime || "-"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -392,5 +276,5 @@ const TrafficPoolList: React.FC = () => {
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
//EEws
|
||||
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;
|
||||
};
|
||||
phone?: string;
|
||||
fail_reason: string;
|
||||
}
|
||||
|
||||
interface AccountListModalProps {
|
||||
@@ -153,11 +154,18 @@ const AccountListModal: React.FC<AccountListModalProps> = ({
|
||||
<div className={style.accountWechatId}>
|
||||
{account.userinfo.wechatId || "未绑定微信号"}
|
||||
</div>
|
||||
{account.fail_reason && (
|
||||
<div style={{ fontSize: 12, color: "red", marginTop: 4 }}>
|
||||
原因:{account.fail_reason}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={style.accountStatus}>
|
||||
<span
|
||||
className={style.statusDot}
|
||||
style={{ backgroundColor: getStatusColor(account.status) }}
|
||||
style={{
|
||||
backgroundColor: getStatusColor(account.status),
|
||||
}}
|
||||
/>
|
||||
<span className={style.statusText}>
|
||||
{getStatusText(Number(account.status))}
|
||||
|
||||
@@ -20,6 +20,7 @@ export default function NewPlan() {
|
||||
const router = useNavigate();
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const [formData, setFormData] = useState<FormData>(defFormData);
|
||||
const [submitting, setSubmitting] = useState(false); // 添加提交状态
|
||||
|
||||
const [sceneList, setSceneList] = useState<any[]>([]);
|
||||
const [sceneLoading, setSceneLoading] = useState(true);
|
||||
@@ -110,6 +111,12 @@ export default function NewPlan() {
|
||||
};
|
||||
// 处理保存
|
||||
const handleSave = async () => {
|
||||
// 防止重复提交
|
||||
if (submitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
if (isEdit && planId) {
|
||||
// 编辑:拼接后端需要的完整参数
|
||||
@@ -140,13 +147,18 @@ export default function NewPlan() {
|
||||
? "更新计划失败,请重试"
|
||||
: "创建计划失败,请重试",
|
||||
);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 下一步
|
||||
const handleNext = () => {
|
||||
if (currentStep === steps.length) {
|
||||
handleSave();
|
||||
// 最后一步时调用保存,防止重复点击
|
||||
if (!submitting) {
|
||||
handleSave();
|
||||
}
|
||||
} else {
|
||||
setCurrentStep(prev => prev + 1);
|
||||
}
|
||||
@@ -186,7 +198,12 @@ export default function NewPlan() {
|
||||
return (
|
||||
<div style={{ padding: "16px", display: "flex", gap: "12px" }}>
|
||||
{currentStep > 1 && (
|
||||
<Button onClick={handlePrev} size="large" style={{ flex: 1 }}>
|
||||
<Button
|
||||
onClick={handlePrev}
|
||||
size="large"
|
||||
style={{ flex: 1 }}
|
||||
disabled={submitting}
|
||||
>
|
||||
上一步
|
||||
</Button>
|
||||
)}
|
||||
@@ -195,8 +212,16 @@ export default function NewPlan() {
|
||||
size="large"
|
||||
onClick={handleNext}
|
||||
style={{ flex: 1 }}
|
||||
loading={submitting}
|
||||
disabled={submitting}
|
||||
>
|
||||
{currentStep === steps.length ? "完成" : "下一步"}
|
||||
{submitting
|
||||
? isEdit
|
||||
? "更新中..."
|
||||
: "创建中..."
|
||||
: currentStep === steps.length
|
||||
? "完成"
|
||||
: "下一步"}
|
||||
</Button>
|
||||
</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,
|
||||
ClockCircleOutlined,
|
||||
ContactsOutlined,
|
||||
BookOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import MeauMobile from "@/components/MeauMobile/MeauMoible";
|
||||
@@ -75,12 +76,26 @@ const Workspace: React.FC = () => {
|
||||
name: "通讯录导入",
|
||||
description: "批量导入通讯录联系人",
|
||||
icon: (
|
||||
<ContactsOutlined className={styles.icon} style={{ color: "#722ed1" }} />
|
||||
<ContactsOutlined
|
||||
className={styles.icon}
|
||||
style={{ color: "#722ed1" }}
|
||||
/>
|
||||
),
|
||||
path: "/workspace/contact-import/list",
|
||||
bgColor: "#f9f0ff",
|
||||
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 (
|
||||
|
||||
@@ -2,17 +2,20 @@ import Mine from "@/pages/mobile/mine/main/index";
|
||||
import Devices from "@/pages/mobile/mine/devices/index";
|
||||
import DeviceDetail from "@/pages/mobile/mine/devices/DeviceDetail";
|
||||
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 WechatAccountDetail from "@/pages/mobile/mine/wechat-accounts/detail/index";
|
||||
import Recharge from "@/pages/mobile/mine/recharge/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 SecuritySetting from "@/pages/mobile/mine/setting/SecuritySetting";
|
||||
import About from "@/pages/mobile/mine/setting/About";
|
||||
import Privacy from "@/pages/mobile/mine/setting/Privacy";
|
||||
import UserSetting from "@/pages/mobile/mine/setting/UserSetting";
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: "/mine",
|
||||
@@ -29,16 +32,29 @@ const routes = [
|
||||
element: <DeviceDetail />,
|
||||
auth: true,
|
||||
},
|
||||
//流量池列表页面
|
||||
{
|
||||
path: "/mine/traffic-pool",
|
||||
element: <TrafficPool />,
|
||||
auth: true,
|
||||
},
|
||||
{
|
||||
path: "/mine/traffic-pool/detail/:wxid/:userId",
|
||||
element: <TrafficPoolDetail />,
|
||||
path: "/mine/traffic-pool/list2",
|
||||
element: <TrafficPool2 />,
|
||||
auth: true,
|
||||
},
|
||||
//新建流量包页面
|
||||
{
|
||||
path: "/mine/traffic-pool/create",
|
||||
element: <CreateTrafficPackage />,
|
||||
auth: true,
|
||||
},
|
||||
{
|
||||
path: "/mine/traffic-pool/userList/:id",
|
||||
element: <TrafficPoolUserList />,
|
||||
auth: true,
|
||||
},
|
||||
|
||||
// 微信号管理路由
|
||||
{
|
||||
path: "/wechat-accounts",
|
||||
@@ -60,6 +76,16 @@ const routes = [
|
||||
element: <RechargeOrder />,
|
||||
auth: true,
|
||||
},
|
||||
{
|
||||
path: "/recharge/buy-power",
|
||||
element: <BuyPower />,
|
||||
auth: true,
|
||||
},
|
||||
{
|
||||
path: "/recharge/usage-records",
|
||||
element: <UsageRecords />,
|
||||
auth: true,
|
||||
},
|
||||
{
|
||||
path: "/settings",
|
||||
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 PlaceholderPage from "@/components/PlaceholderPage";
|
||||
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 = [
|
||||
{
|
||||
@@ -178,6 +181,27 @@ const workspaceRoutes = [
|
||||
element: <ContactImportDetail />,
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user