Merge branch 'yongpxu-dev' of https://gitee.com/cunkebao/cunkebao_v3 into yongpxu-dev

This commit is contained in:
超级老白兔
2025-11-03 10:11:16 +08:00
65 changed files with 8780 additions and 2588 deletions

View File

@@ -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

View File

@@ -0,0 +1,13 @@
.uploadButtonWrapper {
// 使用 :global() 包装 Ant Design 的全局类名
:global {
.ant-upload-select {
// 这里可以修改 .ant-upload-select 的样式
display: block;
width: 100%;
span {
display: block;
}
}
}
}

View 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;

View 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");
}

View File

@@ -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;
}
}

View 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;

View File

@@ -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");
}

View File

@@ -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;
}

View File

@@ -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} />
&nbsp;
</Button>
<Button
className={style.recordButton}
onClick={handleViewRecords}
block
>
<HistoryOutlined className={style.buttonIcon} />
&nbsp; 使
</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 />
&nbsp;
</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;

View 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");
}

View File

@@ -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;
}

View 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;

View File

@@ -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");
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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;

View 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管理复杂状态
- **用户体验**:提供丰富的交互反馈
- **数据模拟**:包含完整的模拟数据用于演示
## 扩展性
- 支持添加新的标签类型
- 支持添加新的预设方案
- 支持自定义筛选逻辑
- 支持导出用户列表
- 支持批量操作功能

View 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");
}

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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%;
}

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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;
}

View 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;

View File

@@ -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");
}

View File

@@ -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;
}

View File

@@ -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;

View 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");
}

View File

@@ -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;
}

View File

@@ -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;
}

View 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;

View 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");
}

View 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;
}

View File

@@ -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;
}

View 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;

View File

@@ -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))}

View File

@@ -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>
);

View 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
传参:
{
idnumber
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
}

View 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",
);
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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)}>
&#34;&#34;
</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;

View File

@@ -0,0 +1,4 @@
// 表单页API - 复用列表页的接口
export { createKnowledgeBase, updateKnowledgeBase } from "../list/api";
export { getKnowledgeBaseDetail } from "../detail/api";

View File

@@ -0,0 +1,2 @@
// AI知识库表单相关类型定义
export type { KnowledgeBaseFormData } from "../list/data";

View File

@@ -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;
}

View 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;

View 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");
}

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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;
}
}

View 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)}>
&ldquo;&rdquo;
</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;

View File

@@ -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 (

View File

@@ -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 />,

View File

@@ -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;