diff --git a/Cunkebao/src/components/Upload/FileUpload/index.tsx b/Cunkebao/src/components/Upload/FileUpload/index.tsx index 9edf1c4f2..88825bdad 100644 --- a/Cunkebao/src/components/Upload/FileUpload/index.tsx +++ b/Cunkebao/src/components/Upload/FileUpload/index.tsx @@ -72,25 +72,83 @@ const FileUpload: React.FC = ({ 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 = ({ } }, [value]); + // 获取类型名称 + const getTypeName = (type: string) => { + const config = fileTypeConfig[type as keyof typeof fileTypeConfig]; + if (config) return config.name; + // 如果是扩展名,返回友好的名称 + const extensionNames: Record = { + 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 = ({
支持{" "} {acceptTypes - .map( - type => - fileTypeConfig[type as keyof typeof fileTypeConfig]?.name, - ) + .map(type => getTypeName(type)) .filter(Boolean) .join("、")} ,最大 {maxSize}MB diff --git a/Cunkebao/src/components/Upload/FileUploadButton/index.module.scss b/Cunkebao/src/components/Upload/FileUploadButton/index.module.scss new file mode 100644 index 000000000..1d832e7cb --- /dev/null +++ b/Cunkebao/src/components/Upload/FileUploadButton/index.module.scss @@ -0,0 +1,13 @@ +.uploadButtonWrapper { + // 使用 :global() 包装 Ant Design 的全局类名 + :global { + .ant-upload-select { + // 这里可以修改 .ant-upload-select 的样式 + display: block; + width: 100%; + span { + display: block; + } + } + } +} diff --git a/Cunkebao/src/components/Upload/FileUploadButton/index.tsx b/Cunkebao/src/components/Upload/FileUploadButton/index.tsx new file mode 100644 index 000000000..72efd753d --- /dev/null +++ b/Cunkebao/src/components/Upload/FileUploadButton/index.tsx @@ -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 = ({ + 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(""); // 保存文件名 + + // 文件类型配置 + 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 = { + 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 ( +
+ + + +
+ ); +}; + +export default FileUpload; diff --git a/Cunkebao/src/pages/mobile/mine/recharge/buy-power/api.ts b/Cunkebao/src/pages/mobile/mine/recharge/buy-power/api.ts new file mode 100644 index 000000000..d067b1a3c --- /dev/null +++ b/Cunkebao/src/pages/mobile/mine/recharge/buy-power/api.ts @@ -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"); +} diff --git a/Cunkebao/src/pages/mobile/mine/recharge/buy-power/index.module.scss b/Cunkebao/src/pages/mobile/mine/recharge/buy-power/index.module.scss new file mode 100644 index 000000000..5e1ee0839 --- /dev/null +++ b/Cunkebao/src/pages/mobile/mine/recharge/buy-power/index.module.scss @@ -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; + } +} diff --git a/Cunkebao/src/pages/mobile/mine/recharge/buy-power/index.tsx b/Cunkebao/src/pages/mobile/mine/recharge/buy-power/index.tsx new file mode 100644 index 000000000..33d307b31 --- /dev/null +++ b/Cunkebao/src/pages/mobile/mine/recharge/buy-power/index.tsx @@ -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([]); + const [selectedPackage, setSelectedPackage] = useState( + 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: ( +
+
+ 请使用微信扫码支付 +
+ 支付二维码 +
+ {selectedPackage + ? `支付金额: ¥${selectedPackage.price / 100}` + : `支付金额: ¥${customAmount}`} +
+
+ ), + closeOnMaskClick: true, + }); + } + } catch (error) { + console.error("购买失败:", error); + Toast.show({ content: "购买失败,请重试", position: "top" }); + } finally { + setLoading(false); + } + }; + + return ( + } + footer={ +
+ +
+ } + > +
+ {/* 选择套餐标题 */} +
选择套餐
+ + {/* 套餐列表 */} +
+ {packages.map(pkg => { + const tag = getPackageTag(pkg); + const isSelected = selectedPackage?.id === pkg.id; + + return ( + handleSelectPackage(pkg)} + > + {/* 套餐头部 */} +
+
+ {pkg.name} + {tag && ( + + {tag.text} + + )} +
+
+ + ¥{pkg.price / 100} + + {pkg.originalPrice && ( + + ¥{pkg.originalPrice / 100} + + )} +
+
+ + {/* 算力信息 */} +
+ {pkg.tokens?.toLocaleString()} 算力点 +
+ + {/* 单价和优惠 */} +
+
+ 单价 +
+ + ¥{(pkg.unitPrice / 100).toFixed(4)}/点 + +
+ {pkg.discount > 0 && ( +
+ 优惠幅度 +
+ + {pkg.discount}% + +
+ )} +
+ + {/* 特性列表 */} + {pkg.features && pkg.features.length > 0 && ( +
+ {pkg.features.map((feature, index) => ( +
+ + {feature} +
+ ))} +
+ )} +
+ ); + })} +
+ + {/* 自定义购买 */} + +
+ + 自定义购买 +
+
+
自定义金额(1-50000元)
+ { + setCustomAmount(e.target.value); + setSelectedPackage(null); // 清空套餐选择 + }} + style={{ fontSize: 16 }} + /> +
+
+ + {/* 安全保障 */} + +
+ + 安全保障 +
+
+
+ • 所有算力永久有效,无使用期限 +
+
+ • 支持微信支付、支付宝安全支付 +
+
+ • 购买后立即到账,7x24小时客服支持 +
+
+
+
+
+ ); +}; + +export default BuyPowerPage; diff --git a/Cunkebao/src/pages/mobile/mine/recharge/index/api.ts b/Cunkebao/src/pages/mobile/mine/recharge/index/api.ts index c2034d7e3..6a870667e 100644 --- a/Cunkebao/src/pages/mobile/mine/recharge/index/api.ts +++ b/Cunkebao/src/pages/mobile/mine/recharge/index/api.ts @@ -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 { + 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 { + +// 算力统计信息 +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 { + 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"); +} diff --git a/Cunkebao/src/pages/mobile/mine/recharge/index/index.module.scss b/Cunkebao/src/pages/mobile/mine/recharge/index/index.module.scss index 46a297c11..be4e8f82f 100644 --- a/Cunkebao/src/pages/mobile/mine/recharge/index/index.module.scss +++ b/Cunkebao/src/pages/mobile/mine/recharge/index/index.module.scss @@ -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; } diff --git a/Cunkebao/src/pages/mobile/mine/recharge/index/index.tsx b/Cunkebao/src/pages/mobile/mine/recharge/index/index.tsx index c0a493b12..d713b2f3f 100644 --- a/Cunkebao/src/pages/mobile/mine/recharge/index/index.tsx +++ b/Cunkebao/src/pages/mobile/mine/recharge/index/index.tsx @@ -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(null); + const [activeTab, setActiveTab] = useState("overview"); const [loading, setLoading] = useState(false); - const [activeTab, setActiveTab] = useState("account"); - const [taocanList, setTaocanList] = useState([]); + const [stats, setStats] = useState(null); + const [records, setRecords] = useState([]); + const [filterType, setFilterType] = useState("all"); + const [filterStatus, setFilterStatus] = useState("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 = { + 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: ( -
-
- 请使用微信扫码支付 -
- 支付二维码 -
- 支付金额: ¥{selected.price / 100} -
-
- ), - 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 = () => ( -
- -
- -
-
当前余额
-
- ¥{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 = () => ( +
+ {/* 账户信息卡片 */} +
+ +
+
+
-
-
- - -
选择套餐
-
- {taocanList.map(item => ( - - ))} -
- {selected && ( -
-
- {selected.name} - {selected.isRecommend === 1 && ( - - 推荐 - - )} - {selected.isHot === 1 && ( - - 热门 - - )} -
-
- 包含 {selected.tokens} Tokens -
- {selected.originalPrice && ( -
- 原价: ¥{selected.originalPrice / 100} -
- )} -
- )} - -
- -
服务消耗
-
- 使用以下服务将从余额中扣除相应费用。 -
-
- {balance < 10 && ( - -
- -
-
余额不足提醒
-
- 当前余额较低,建议及时充值以免影响服务使用 +
+
总算力
+
+ {formatNumber(stats?.totalTokens)}
- )} -
- ); +
- // 渲染AI服务tab内容 - const renderAiServices = () => ( -
-
-
- - AI智能服务收费 + {/* 使用情况卡片 */} + +
使用情况
+
+
+
+ {formatNumber(stats?.todayUsed)} +
+
今日使用
+
+
+
+ {formatNumber(stats?.monthUsed)} +
+
本月使用
+
+
+
+ {formatNumber(stats?.remainingTokens)} +
+
剩余算力
+
-
统一按次收费
-
-
- 三项核心AI服务,按使用次数收费,每次1元 -
+ -
- {aiServices.map(service => ( - -
-
-
{service.icon}
-
-
{service.name}
-
- ¥{service.price}/次 -
-
-
-
-
- {service.description} -
-
- {service.features.map((feature, index) => ( -
- - {feature} -
- ))} -
-
-
今日使用进度
-
-
-
-
- {service.usage.current} / {service.usage.total} -
-
-
- ))} -
+ {/* 快速操作 */} + +
快速操作
+
+ + +
+
); - // 渲染版本套餐tab内容 - const renderVersionPackages = () => ( -
-
- - 存客宝版本套餐 -
-
- 选择适合的版本,享受不同级别的AI服务 + // 渲染消费记录Tab + const renderRecords = () => ( +
+ {/* 筛选器 */} +
+ setFilterTypeVisible(false)} + value={[filterType]} + onConfirm={value => { + setFilterType(value[0] as string); + setFilterTypeVisible(false); + }} + > + {() => ( +
setFilterTypeVisible(true)} + > + + {getTypeLabel()} +
+ )} +
+ + setFilterStatusVisible(false)} + value={[filterStatus]} + onConfirm={value => { + setFilterStatus(value[0] as string); + setFilterStatusVisible(false); + }} + > + {() => ( +
setFilterStatusVisible(true)} + > + + {getStatusLabel()} +
+ )} +
-
- {versionPackages.map(pkg => ( - -
-
-
{pkg.icon}
-
-
- {pkg.name} - {pkg.tag && ( - - {pkg.tag} - - )} + {/* 消费记录列表 */} +
+ {loading && records.length === 0 ? ( +
+
加载中...
+
+ ) : records.length > 0 ? ( + records.map(record => ( + +
+
+
{record.type}
+ + {record.status} + +
+
+
+ -¥{record.amount.toFixed(1)} +
+
+ {formatNumber(record.power)} 算力
-
{pkg.price}
-
-
- {pkg.description} -
-
-
包含功能:
- {pkg.features.map((feature, index) => ( -
- - {feature} -
- ))} -
- {pkg.status && ( -
{pkg.status}
- )} - {pkg.buttonText && ( - - )} - - ))} +
{record.description}
+
{record.createTime}
+ + )) + ) : ( +
+
📋
+
暂无消费记录
+
+ )}
); return ( navigate("/recharge/order")} - > - -  记录 -
- } - /> + <> + + +
+ } + /> + + + + + + } + footer={ + activeTab === "records" && records.length > 0 ? ( +
+ { + setPage(p); + fetchRecords(p); + }} + /> +
+ ) : null } > -
- - - {renderAccountRecharge()} - - - {renderAiServices()} - - - {renderVersionPackages()} - - +
+ {activeTab === "overview" && renderOverview()} + {activeTab === "records" && renderRecords()}
); }; -export default Recharge; +export default PowerManagement; diff --git a/Cunkebao/src/pages/mobile/mine/recharge/usage-records/api.ts b/Cunkebao/src/pages/mobile/mine/recharge/usage-records/api.ts new file mode 100644 index 000000000..5d3aff230 --- /dev/null +++ b/Cunkebao/src/pages/mobile/mine/recharge/usage-records/api.ts @@ -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 { + return request("/v1/kefu/tokensRecord/list", TokensUseRecordParams, "GET"); +} diff --git a/Cunkebao/src/pages/mobile/mine/recharge/usage-records/index.module.scss b/Cunkebao/src/pages/mobile/mine/recharge/usage-records/index.module.scss new file mode 100644 index 000000000..f0ab5b4a8 --- /dev/null +++ b/Cunkebao/src/pages/mobile/mine/recharge/usage-records/index.module.scss @@ -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; +} diff --git a/Cunkebao/src/pages/mobile/mine/recharge/usage-records/index.tsx b/Cunkebao/src/pages/mobile/mine/recharge/usage-records/index.tsx new file mode 100644 index 000000000..dd732dcab --- /dev/null +++ b/Cunkebao/src/pages/mobile/mine/recharge/usage-records/index.tsx @@ -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([]); + const [type, setType] = useState("all"); + const [status, setStatus] = useState("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 = { + 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 = { + 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 ( + + +
+ setTypeVisible(false)} + value={[type]} + onConfirm={val => { + setType(val[0] as string); + setTypeVisible(false); + }} + > + {() => ( +
setTypeVisible(true)} + > + + + + {getTypeLabel()} +
+ )} +
+ + setStatusVisible(false)} + value={[status]} + onConfirm={val => { + setStatus(val[0] as string); + setStatusVisible(false); + }} + > + {() => ( +
setStatusVisible(true)} + > + + + + {getStatusLabel()} +
+ )} +
+
+ +
+
+
{records.length}
+
记录总数
+
+
+
+ {records.reduce( + (acc, cur) => acc + (Number(cur.power) || 0), + 0, + )} +
+
总消耗算力
+
+
+ + } + footer={ +
+ { + setPage(newPage); + }} + /> +
+ } + > +
+
+ {loading && records.length === 0 ? ( +
加载中...
+ ) : records.length === 0 ? ( +
暂无使用记录
+ ) : ( + records.map(item => ( + +
+
+ {item.title || item.typeLabel || "使用任务"} +
+ + {item.status} + +
+ {item.description ? ( +
{item.description}
+ ) : null} +
+
消耗 {item.power ?? 0} 算力
+
{item.createTime}
+
+
+ )) + )} +
+
+
+ ); +}; + +export default UsageRecords; diff --git a/Cunkebao/src/pages/mobile/mine/traffic-pool/detail/api.ts b/Cunkebao/src/pages/mobile/mine/traffic-pool/detail/api.ts deleted file mode 100644 index 3f3341ae9..000000000 --- a/Cunkebao/src/pages/mobile/mine/traffic-pool/detail/api.ts +++ /dev/null @@ -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 { - return request("/v1/traffic/pool/getUserTags", { userId }, "GET"); -} - -// 添加用户标签 -export function addUserTag(userId: string, tagData: any): Promise { - return request("/v1/user/tags", { userId, ...tagData }, "POST"); -} diff --git a/Cunkebao/src/pages/mobile/mine/traffic-pool/detail/data.ts b/Cunkebao/src/pages/mobile/mine/traffic-pool/detail/data.ts deleted file mode 100644 index f32ec073d..000000000 --- a/Cunkebao/src/pages/mobile/mine/traffic-pool/detail/data.ts +++ /dev/null @@ -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; -} diff --git a/Cunkebao/src/pages/mobile/mine/traffic-pool/detail/index.module.scss b/Cunkebao/src/pages/mobile/mine/traffic-pool/detail/index.module.scss deleted file mode 100644 index 23fd44d2a..000000000 --- a/Cunkebao/src/pages/mobile/mine/traffic-pool/detail/index.module.scss +++ /dev/null @@ -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; - } -} diff --git a/Cunkebao/src/pages/mobile/mine/traffic-pool/detail/index.tsx b/Cunkebao/src/pages/mobile/mine/traffic-pool/detail/index.tsx deleted file mode 100644 index ca0242b4c..000000000 --- a/Cunkebao/src/pages/mobile/mine/traffic-pool/detail/index.tsx +++ /dev/null @@ -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(null); - const [activeTab, setActiveTab] = useState("basic"); - - // 用户旅程相关状态 - const [journeyLoading, setJourneyLoading] = useState(false); - const [journeyList, setJourneyList] = useState([]); - const [journeyPage, setJourneyPage] = useState(1); - const [journeyTotal, setJourneyTotal] = useState(0); - const pageSize = 10; - - // 用户标签相关状态 - const [tagsLoading, setTagsLoading] = useState(false); - const [userTagsList, setUserTagsList] = useState([]); - const [wechatTagsList, setWechatTagsList] = useState([]); - - 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 ; - case 2: // 提交订单 - return ; - case 3: // 注册 - return ; - default: - return ; - } - }; - - 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 ; - case "view": - return ; - case "purchase": - return ; - default: - return ; - } - }; - - 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 ( - } loading={loading}> -
-
未找到该用户
-
-
- ); - } - - return ( - - - {/* 用户基本信息 */} - -
- - -
- } - /> -
-
{user.nickname}
-
{user.wechatId}
-
- {user.valueTags?.map(tag => ( - - - {tag.name} - - ))} - {user.total.isFriend && ( - - 已添加好友 - - )} -
-
-
-
- {/* 导航标签 */} -
-
handleTabChange("basic")} - > - 基本信息 -
-
handleTabChange("journey")} - > - 用户旅程 -
-
handleTabChange("tags")} - > - 用户标签 -
-
- - } - > -
- {/* 内容区域 */} -
- {activeTab === "basic" && ( -
- {/* 关联信息 */} - - - - 设备 - - 微信号 - 别名 - - 添加时间 - - - 最近互动 - - - - - {/* RFM评分 */} - {user.rfmScore && ( - -
-
-
最近性(R)
-
- {user.rfmScore.recency} -
-
-
-
频率(F)
-
- {user.rfmScore.frequency} -
-
-
-
金额(M)
-
- {user.rfmScore.monetary} -
-
-
-
总分
-
- {user.rfmScore.totalScore}/15 -
-
-
-
- )} - - {/* 流量池 */} - {user.trafficPools && ( - -
-
- 当前池: - - {user.trafficPools.currentPool} - -
-
- 可选池: - {user.trafficPools.availablePools.map((pool, index) => ( - - {pool} - - ))} -
-
-
- )} - - {/* 统计数据 */} - -
-
-
- ¥{user.total.money || 0} -
-
总消费
-
-
-
- {user.total.msg || 0} -
-
互动次数
-
-
-
- {user.total.percentage || "0"}% -
-
转化率
-
-
-
- {user.total.isFriend ? "已添加" : "未添加"} -
-
添加状态
-
-
-
- - {/* 好友统计 */} - -
-
-
- {user.userInfo?.friendShip.totalFriend || 0} -
-
总好友
-
-
-
- {user.userInfo?.friendShip.maleFriend || 0} -
-
男性好友
-
-
-
- {user.userInfo?.friendShip.femaleFriend || 0} -
-
女性好友
-
-
-
- {user.userInfo?.friendShip.unknowFriend || 0} -
-
未知性别
-
-
-
- - {/* 限制记录 */} - - {user.restrictions && user.restrictions.length > 0 ? ( - - {user.restrictions.map(restriction => ( - - {restriction.reason || "未知原因"} - - {getRestrictionLevelText(restriction.level)} - -
- } - description={ -
- 限制ID: {restriction.id} - {restriction.date && ( - - 限制时间: {formatDate(restriction.date)} - - )} -
- } - /> - ))} - - ) : ( -
-
- -
-
暂无限制记录
-
- 该用户没有任何限制记录 -
-
- )} - -
- )} - - {activeTab === "journey" && ( -
- - {journeyLoading && journeyList.length === 0 ? ( -
- -
加载中...
-
- ) : journeyList.length === 0 ? ( -
-
- -
-
暂无互动记录
-
- 该用户还没有任何互动行为 -
-
- ) : ( - - {journeyList.map(record => ( - - {record.remark} - - {formatDateTime(record.createTime)} - -
- } - /> - ))} - {journeyLoading && journeyList.length > 0 && ( -
- - 加载更多... -
- )} - {!journeyLoading && journeyList.length < journeyTotal && ( -
- -
- )} - - )} - -
- )} - - {activeTab === "tags" && ( -
- {/* 站内标签 */} - - {tagsLoading && userTagsList.length === 0 ? ( -
- -
加载中...
-
- ) : userTagsList.length === 0 ? ( -
-
- -
-
暂无站内标签
-
- 该用户还没有任何站内标签 -
-
- ) : ( -
- {userTagsList.map((tag, index) => ( - - {tag.name} - - ))} -
- )} -
- - {/* 微信标签 */} - - {tagsLoading && wechatTagsList.length === 0 ? ( -
- -
加载中...
-
- ) : wechatTagsList.length === 0 ? ( -
-
- -
-
暂无微信标签
-
- 该用户还没有任何微信标签 -
-
- ) : ( -
- {wechatTagsList.map((tag, index) => ( - - {tag} - - ))} -
- )} -
- - {/* 价值标签 */} - - {user.valueTags && user.valueTags.length > 0 ? ( -
- {user.valueTags.map(tag => ( -
-
- - {tag.icon === "crown" && } - {tag.name} - - - RFM总分: {tag.rfmScore}/15 - -
-
- - 价值等级: - - - {tag.valueLevel} - -
-
- ))} -
- ) : ( -
-
- -
-
暂无价值标签
-
- 该用户还没有任何价值标签 -
-
- )} -
-
- )} -
-
- - ); -}; - -export default TrafficPoolDetail; diff --git a/Cunkebao/src/pages/mobile/mine/traffic-pool/form/README.md b/Cunkebao/src/pages/mobile/mine/traffic-pool/form/README.md new file mode 100644 index 000000000..f8b95a7ef --- /dev/null +++ b/Cunkebao/src/pages/mobile/mine/traffic-pool/form/README.md @@ -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管理复杂状态 +- **用户体验**:提供丰富的交互反馈 +- **数据模拟**:包含完整的模拟数据用于演示 + +## 扩展性 + +- 支持添加新的标签类型 +- 支持添加新的预设方案 +- 支持自定义筛选逻辑 +- 支持导出用户列表 +- 支持批量操作功能 diff --git a/Cunkebao/src/pages/mobile/mine/traffic-pool/form/api.ts b/Cunkebao/src/pages/mobile/mine/traffic-pool/form/api.ts new file mode 100644 index 000000000..781ebe281 --- /dev/null +++ b/Cunkebao/src/pages/mobile/mine/traffic-pool/form/api.ts @@ -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 { + 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 { + 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 { + // 模拟数据 + 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 { + return request("/v1/traffic/pool/industries", {}, "GET"); +} diff --git a/Cunkebao/src/pages/mobile/mine/traffic-pool/form/components/AudienceFilter.module.scss b/Cunkebao/src/pages/mobile/mine/traffic-pool/form/components/AudienceFilter.module.scss new file mode 100644 index 000000000..c1c7e292c --- /dev/null +++ b/Cunkebao/src/pages/mobile/mine/traffic-pool/form/components/AudienceFilter.module.scss @@ -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; +} diff --git a/Cunkebao/src/pages/mobile/mine/traffic-pool/form/components/AudienceFilter.tsx b/Cunkebao/src/pages/mobile/mine/traffic-pool/form/components/AudienceFilter.tsx new file mode 100644 index 000000000..c126041c5 --- /dev/null +++ b/Cunkebao/src/pages/mobile/mine/traffic-pool/form/components/AudienceFilter.tsx @@ -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 = ({ + conditions, + onChange, +}) => { + const [showCustomModal, setShowCustomModal] = useState(false); + const [industryOptions, setIndustryOptions] = useState([]); + const [presetSchemes, setPresetSchemes] = useState([]); + const [selectedIndustry, setSelectedIndustry] = useState< + string | number | undefined + >(undefined); + const [selectedScheme, setSelectedScheme] = useState( + 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 ( +
+ +
+
人群筛选
+
+ + {/* 方案推荐选择 */} +
+
方案推荐
+
+ setSelectedIndustry(value)} + options={industryOptions.map(opt => ({ + label: opt.label, + value: opt.value, + }))} + allowClear + /> +
+ + {/* 标签筛选 */} +
+
标签筛选
+
+ {[ + { 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) => ( +
+ {tag.name} +
+ ))} +
+
+ + {/* 自定义条件列表 */} + + + {/* 添加自定义条件 */} + + + )} + + + {/* 自定义条件弹窗 */} + setShowCustomModal(false)} + onAdd={handleAddCondition} + /> +
+ ); +}; + +export default AudienceFilter; diff --git a/Cunkebao/src/pages/mobile/mine/traffic-pool/form/components/BasicInfo.module.scss b/Cunkebao/src/pages/mobile/mine/traffic-pool/form/components/BasicInfo.module.scss new file mode 100644 index 000000000..7691d6c85 --- /dev/null +++ b/Cunkebao/src/pages/mobile/mine/traffic-pool/form/components/BasicInfo.module.scss @@ -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; +} diff --git a/Cunkebao/src/pages/mobile/mine/traffic-pool/form/components/BasicInfo.tsx b/Cunkebao/src/pages/mobile/mine/traffic-pool/form/components/BasicInfo.tsx new file mode 100644 index 000000000..37e2a99bb --- /dev/null +++ b/Cunkebao/src/pages/mobile/mine/traffic-pool/form/components/BasicInfo.tsx @@ -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 = ({ data, onChange }) => { + const handleChange = (field: string, value: string) => { + onChange({ [field]: value }); + }; + + return ( +
+ +
基本信息
+ +
+ + 流量包名称* + + } + required + > + handleChange("name", value)} + className={styles.input} + /> + + + 描述}> + handleChange("description", value)} + className={styles.input} + /> + + + 备注}> + handleChange("remarks", value)} + className={styles.textarea} + rows={3} + /> + +
+
+
+ ); +}; + +export default BasicInfo; diff --git a/Cunkebao/src/pages/mobile/mine/traffic-pool/form/components/ConditionList.module.scss b/Cunkebao/src/pages/mobile/mine/traffic-pool/form/components/ConditionList.module.scss new file mode 100644 index 000000000..823417b81 --- /dev/null +++ b/Cunkebao/src/pages/mobile/mine/traffic-pool/form/components/ConditionList.module.scss @@ -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; + } +} diff --git a/Cunkebao/src/pages/mobile/mine/traffic-pool/form/components/ConditionList.tsx b/Cunkebao/src/pages/mobile/mine/traffic-pool/form/components/ConditionList.tsx new file mode 100644 index 000000000..d0c27e852 --- /dev/null +++ b/Cunkebao/src/pages/mobile/mine/traffic-pool/form/components/ConditionList.tsx @@ -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 = ({ + 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 ( +
+
自定义条件
+
+ {conditions.map(condition => ( +
+
+ {condition.label}: + + {formatConditionValue(condition)} + +
+ +
+ ))} +
+
+ ); +}; + +export default ConditionList; diff --git a/Cunkebao/src/pages/mobile/mine/traffic-pool/form/components/CustomConditionModal.module.scss b/Cunkebao/src/pages/mobile/mine/traffic-pool/form/components/CustomConditionModal.module.scss new file mode 100644 index 000000000..41a71d0b3 --- /dev/null +++ b/Cunkebao/src/pages/mobile/mine/traffic-pool/form/components/CustomConditionModal.module.scss @@ -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; +} diff --git a/Cunkebao/src/pages/mobile/mine/traffic-pool/form/components/CustomConditionModal.tsx b/Cunkebao/src/pages/mobile/mine/traffic-pool/form/components/CustomConditionModal.tsx new file mode 100644 index 000000000..f4ae5e50d --- /dev/null +++ b/Cunkebao/src/pages/mobile/mine/traffic-pool/form/components/CustomConditionModal.tsx @@ -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 = ({ + visible, + onClose, + onAdd, +}) => { + const [selectedTag, setSelectedTag] = useState(null); + const [conditionValue, setConditionValue] = useState(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 ( +
+ + setConditionValue(prev => ({ ...prev, min: value })) + } + /> + - + + setConditionValue(prev => ({ ...prev, max: value })) + } + /> +
+ ); + + case "select": + return ( + handleValueChange(value[0])} + multiple={false} + /> + ); + + default: + return ( + + ); + } + }; + + return ( + +
+
+
添加自定义条件
+ +
+ +
+
+
选择标签
+
+ {mockTags.map(tag => ( +
handleTagSelect(tag)} + > + {tag.name} +
+ ))} +
+
+ + {selectedTag && ( +
+
设置条件
+ {renderValueInput()} +
+ )} +
+ +
+ +
+
+
+ ); +}; + +export default CustomConditionModal; diff --git a/Cunkebao/src/pages/mobile/mine/traffic-pool/form/components/SchemeRecommendation.module.scss b/Cunkebao/src/pages/mobile/mine/traffic-pool/form/components/SchemeRecommendation.module.scss new file mode 100644 index 000000000..20b5d1575 --- /dev/null +++ b/Cunkebao/src/pages/mobile/mine/traffic-pool/form/components/SchemeRecommendation.module.scss @@ -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%; +} diff --git a/Cunkebao/src/pages/mobile/mine/traffic-pool/form/components/SchemeRecommendation.tsx b/Cunkebao/src/pages/mobile/mine/traffic-pool/form/components/SchemeRecommendation.tsx new file mode 100644 index 000000000..2e43a5463 --- /dev/null +++ b/Cunkebao/src/pages/mobile/mine/traffic-pool/form/components/SchemeRecommendation.tsx @@ -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 = ({ + visible, + onClose, + onApply, +}) => { + const handleApplyScheme = (scheme: any) => { + onApply(scheme.conditions); + }; + + return ( + +
+
+
方案推荐
+ +
+ +
+
+ {presetSchemes.map(scheme => ( + +
+
{scheme.name}
+
+ {scheme.userCount}人 +
+
+ +
+ {scheme.description} +
+ +
+
筛选条件:
+
+ {scheme.conditions.map((condition, index) => ( + + {condition.label}: {condition.value} + + ))} +
+
+ + +
+ ))} +
+
+
+
+ ); +}; + +export default SchemeRecommendation; diff --git a/Cunkebao/src/pages/mobile/mine/traffic-pool/form/components/UserListPreview.module.scss b/Cunkebao/src/pages/mobile/mine/traffic-pool/form/components/UserListPreview.module.scss new file mode 100644 index 000000000..ff7e0adbf --- /dev/null +++ b/Cunkebao/src/pages/mobile/mine/traffic-pool/form/components/UserListPreview.module.scss @@ -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; + } +} diff --git a/Cunkebao/src/pages/mobile/mine/traffic-pool/form/components/UserListPreview.tsx b/Cunkebao/src/pages/mobile/mine/traffic-pool/form/components/UserListPreview.tsx new file mode 100644 index 000000000..7e7279481 --- /dev/null +++ b/Cunkebao/src/pages/mobile/mine/traffic-pool/form/components/UserListPreview.tsx @@ -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 = ({ + users, + onRemoveUser, +}) => { + const [selectedUsers, setSelectedUsers] = useState([]); + + 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 ( +
+ + + +
+ ); + } + + return ( +
+ +
+
用户列表预览
+
共 {users.length} 个用户
+
+ + {users.length > 0 && ( +
+ 0 + } + onChange={handleSelectAll} + className={styles.selectAllCheckbox} + > + 全选 + + {selectedUsers.length > 0 && ( + + )} +
+ )} + +
+ {users.map(user => { + const rfmInfo = getRfmLevel(user.rfmScore); + + return ( +
+ handleSelectUser(user.id, checked)} + className={styles.userCheckbox} + /> + + + +
+
{user.name}
+
ID: {user.id}
+
+ {user.tags.map((tag, index) => ( + + {tag} + + ))} +
+
+ + RFM:{" "} + + {rfmInfo.level} + + + + 活跃: {user.lastActive} + + + 消费: ¥{user.consumption} + +
+
+ + +
+ ); + })} +
+
+
+ ); +}; + +export default UserListPreview; diff --git a/Cunkebao/src/pages/mobile/mine/traffic-pool/form/index.module.scss b/Cunkebao/src/pages/mobile/mine/traffic-pool/form/index.module.scss new file mode 100644 index 000000000..1712b8d45 --- /dev/null +++ b/Cunkebao/src/pages/mobile/mine/traffic-pool/form/index.module.scss @@ -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; +} diff --git a/Cunkebao/src/pages/mobile/mine/traffic-pool/form/index.tsx b/Cunkebao/src/pages/mobile/mine/traffic-pool/form/index.tsx new file mode 100644 index 000000000..5d2c828a5 --- /dev/null +++ b/Cunkebao/src/pages/mobile/mine/traffic-pool/form/index.tsx @@ -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 ( +
+
+ {currentStep > 1 && ( + + )} + {currentStep < 3 ? ( + + ) : ( + + )} +
+
+ ); + }; + + return ( + + + + + } + footer={renderFooter()} + > +
+ {currentStep === 1 && ( + + )} + + {currentStep === 2 && ( + + )} + + {currentStep === 3 && ( + { + setFormData(prev => ({ + ...prev, + filteredUsers: prev.filteredUsers.filter( + (user: any) => user.id !== userId, + ), + })); + }} + /> + )} +
+
+ ); +}; + +export default CreateTrafficPackage; diff --git a/Cunkebao/src/pages/mobile/mine/traffic-pool/list/api.ts b/Cunkebao/src/pages/mobile/mine/traffic-pool/list/api.ts index 9b53c030a..9171337aa 100644 --- a/Cunkebao/src/pages/mobile/mine/traffic-pool/list/api.ts +++ b/Cunkebao/src/pages/mobile/mine/traffic-pool/list/api.ts @@ -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 { + 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"); } diff --git a/Cunkebao/src/pages/mobile/mine/traffic-pool/list/index.module.scss b/Cunkebao/src/pages/mobile/mine/traffic-pool/list/index.module.scss index 2fbee5e38..c24427b2f 100644 --- a/Cunkebao/src/pages/mobile/mine/traffic-pool/list/index.module.scss +++ b/Cunkebao/src/pages/mobile/mine/traffic-pool/list/index.module.scss @@ -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; } diff --git a/Cunkebao/src/pages/mobile/mine/traffic-pool/list/index.tsx b/Cunkebao/src/pages/mobile/mine/traffic-pool/list/index.tsx index c5c6306da..dbea85c80 100644 --- a/Cunkebao/src/pages/mobile/mine/traffic-pool/list/index.tsx +++ b/Cunkebao/src/pages/mobile/mine/traffic-pool/list/index.tsx @@ -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([]); + const [list, setList] = useState([]); 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([]); + 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([]); - 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 ( { header={ <> setShowStats(s => !s)} - style={{ marginLeft: 8 }} + type="primary" + size="small" + icon={} + onClick={() => { + navigate("/mine/traffic-pool/create"); + }} > - {showStats ? "收起分析" : "数据分析"} + 新建分组 } /> - {/* 搜索栏 */} +
handSearch(e.target.value)} + placeholder="搜索分组" + value={search} + onChange={e => handleSearch(e.target.value)} prefix={} allowClear size="large" />
-
- {/* 数据分析面板 */} - { - // 可以在这里处理统计数据,比如更新本地状态或发送到父组件 - console.log("收到统计数据:", statsData); - }} - /> - - {/* 批量操作栏 */} -
- 0} - onChange={e => handleSelectAll(e.target.checked)} - style={{ marginRight: 8 }} /> - 全选 - {selectedIds.length > 0 && ( - <> - {`已选${selectedIds.length}项`} - - - )} - {searchInput.length > 0 && ( - <> - - - )} -
-
} @@ -283,103 +163,107 @@ const TrafficPoolList: React.FC = () => {
{ - setPage(newPage); - getList({ page: newPage }); - }} + onChange={setPage} />
} > - {/* 批量加入分组弹窗 */} - setBatchModal(false)} - selectedCount={selectedIds.length} - onConfirm={data => { - // 处理批量加入逻辑 - handleBatchAdd(data); - }} - /> - {/* 筛选弹窗 */} - 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} - />
{list.length === 0 && !loading ? ( - + ) : (
{list.map(item => ( -
-
- navigate( - `/mine/traffic-pool/detail/${item.wechatId}/${item.id}`, - ) - } - > -
- handleSelect(item.id, e.target.checked)} - style={{ marginRight: 8 }} - onClick={e => e.stopPropagation()} - className={styles.checkbox} - /> - -
-
- {item.nickname || item.identifier} - {/* 性别icon可自行封装 */} +
+
+
e.stopPropagation()} + > + + navigate( + `/mine/traffic-pool/userList/${item.id}`, + ), + }, + { + key: "delete", + danger: true, + label: "删除数据包", + onClick: () => handleDelete(item.id, item.name), + }, + ], + }} + trigger={["click"]} + > + + +
+ +
+ navigate(`/mine/traffic-pool/userList/${item.id}`) + } + > + {/* 左侧图片区域(优先展示 pic,缺省时使用假头像) */} +
+ {item.pic ? ( + {item.name} + ) : ( + + {getGroupIcon(item.type, item.name)} + + )} +
+ + {/* 右侧仅展示选中字段 */} +
+ {/* 标题与人数 */} +
+
{item.name}
+
共{item.num}人
-
- 微信号:{item.wechatId || "-"} + + {/* RFM 汇总 */} +
+ RFM:{item.RFM} + + R:{item.R} F:{item.F} M:{item.M} +
-
- 来源:{item.fromd || "-"} -
-
- 分组: - {item.packages && item.packages.length - ? item.packages.join(",") - : "-"} -
-
- 创建时间:{item.createTime} + + {/* 类型与创建时间 */} +
+ + 类型: {item.type === 0 ? "自定义" : "系统分组"} + + 创建:{item.createTime || "-"}
@@ -392,5 +276,5 @@ const TrafficPoolList: React.FC = () => { ); }; - +//EEws export default TrafficPoolList; diff --git a/Cunkebao/src/pages/mobile/mine/traffic-pool/list/BatchAddModal.tsx b/Cunkebao/src/pages/mobile/mine/traffic-pool/poolList1/BatchAddModal.tsx similarity index 100% rename from Cunkebao/src/pages/mobile/mine/traffic-pool/list/BatchAddModal.tsx rename to Cunkebao/src/pages/mobile/mine/traffic-pool/poolList1/BatchAddModal.tsx diff --git a/Cunkebao/src/pages/mobile/mine/traffic-pool/list/DataAnalysisPanel.tsx b/Cunkebao/src/pages/mobile/mine/traffic-pool/poolList1/DataAnalysisPanel.tsx similarity index 100% rename from Cunkebao/src/pages/mobile/mine/traffic-pool/list/DataAnalysisPanel.tsx rename to Cunkebao/src/pages/mobile/mine/traffic-pool/poolList1/DataAnalysisPanel.tsx diff --git a/Cunkebao/src/pages/mobile/mine/traffic-pool/list/FilterModal.tsx b/Cunkebao/src/pages/mobile/mine/traffic-pool/poolList1/FilterModal.tsx similarity index 100% rename from Cunkebao/src/pages/mobile/mine/traffic-pool/list/FilterModal.tsx rename to Cunkebao/src/pages/mobile/mine/traffic-pool/poolList1/FilterModal.tsx diff --git a/Cunkebao/src/pages/mobile/mine/traffic-pool/poolList1/api.ts b/Cunkebao/src/pages/mobile/mine/traffic-pool/poolList1/api.ts new file mode 100644 index 000000000..9b53c030a --- /dev/null +++ b/Cunkebao/src/pages/mobile/mine/traffic-pool/poolList1/api.ts @@ -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"); +} diff --git a/Cunkebao/src/pages/mobile/mine/traffic-pool/poolList1/data.ts b/Cunkebao/src/pages/mobile/mine/traffic-pool/poolList1/data.ts new file mode 100644 index 000000000..65ad7f559 --- /dev/null +++ b/Cunkebao/src/pages/mobile/mine/traffic-pool/poolList1/data.ts @@ -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; +} diff --git a/Cunkebao/src/pages/mobile/mine/traffic-pool/poolList1/index.module.scss b/Cunkebao/src/pages/mobile/mine/traffic-pool/poolList1/index.module.scss new file mode 100644 index 000000000..2fbee5e38 --- /dev/null +++ b/Cunkebao/src/pages/mobile/mine/traffic-pool/poolList1/index.module.scss @@ -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; +} diff --git a/Cunkebao/src/pages/mobile/mine/traffic-pool/poolList1/index.tsx b/Cunkebao/src/pages/mobile/mine/traffic-pool/poolList1/index.tsx new file mode 100644 index 000000000..c5c6306da --- /dev/null +++ b/Cunkebao/src/pages/mobile/mine/traffic-pool/poolList1/index.tsx @@ -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([]); + 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([]); + + // 公共筛选条件状态 + const [filterParams, setFilterParams] = useState({ + selectedDevices: [] as DeviceSelectionItem[], + packageId: 0, + scenarioId: 0, + userValue: 0, + userStatus: 0, + }); + + // 批量相关 + const [selectedIds, setSelectedIds] = useState([]); + 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 ( + + setShowStats(s => !s)} + style={{ marginLeft: 8 }} + > + {showStats ? "收起分析" : "数据分析"} + + } + /> + {/* 搜索栏 */} +
+
+ handSearch(e.target.value)} + prefix={} + allowClear + size="large" + /> +
+ +
+ {/* 数据分析面板 */} + { + // 可以在这里处理统计数据,比如更新本地状态或发送到父组件 + console.log("收到统计数据:", statsData); + }} + /> + + {/* 批量操作栏 */} +
+ 0} + onChange={e => handleSelectAll(e.target.checked)} + style={{ marginRight: 8 }} + /> + 全选 + {selectedIds.length > 0 && ( + <> + {`已选${selectedIds.length}项`} + + + )} + {searchInput.length > 0 && ( + <> + + + )} +
+ +
+ + } + footer={ +
+ { + setPage(newPage); + getList({ page: newPage }); + }} + /> +
+ } + > + {/* 批量加入分组弹窗 */} + setBatchModal(false)} + selectedCount={selectedIds.length} + onConfirm={data => { + // 处理批量加入逻辑 + handleBatchAdd(data); + }} + /> + {/* 筛选弹窗 */} + 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} + /> +
+ {list.length === 0 && !loading ? ( + + ) : ( +
+ {list.map(item => ( +
+
+ navigate( + `/mine/traffic-pool/detail/${item.wechatId}/${item.id}`, + ) + } + > +
+ handleSelect(item.id, e.target.checked)} + style={{ marginRight: 8 }} + onClick={e => e.stopPropagation()} + className={styles.checkbox} + /> + +
+
+ {item.nickname || item.identifier} + {/* 性别icon可自行封装 */} +
+
+ 微信号:{item.wechatId || "-"} +
+
+ 来源:{item.fromd || "-"} +
+
+ 分组: + {item.packages && item.packages.length + ? item.packages.join(",") + : "-"} +
+
+ 创建时间:{item.createTime} +
+
+
+
+
+ ))} +
+ )} +
+ + ); +}; + +export default TrafficPoolList; diff --git a/Cunkebao/src/pages/mobile/mine/traffic-pool/userList/api.ts b/Cunkebao/src/pages/mobile/mine/traffic-pool/userList/api.ts new file mode 100644 index 000000000..7d4efe7d8 --- /dev/null +++ b/Cunkebao/src/pages/mobile/mine/traffic-pool/userList/api.ts @@ -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"); +} diff --git a/Cunkebao/src/pages/mobile/mine/traffic-pool/userList/data.ts b/Cunkebao/src/pages/mobile/mine/traffic-pool/userList/data.ts new file mode 100644 index 000000000..204547faf --- /dev/null +++ b/Cunkebao/src/pages/mobile/mine/traffic-pool/userList/data.ts @@ -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; +} diff --git a/Cunkebao/src/pages/mobile/mine/traffic-pool/userList/index.module.scss b/Cunkebao/src/pages/mobile/mine/traffic-pool/userList/index.module.scss new file mode 100644 index 000000000..68ecdca15 --- /dev/null +++ b/Cunkebao/src/pages/mobile/mine/traffic-pool/userList/index.module.scss @@ -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; +} diff --git a/Cunkebao/src/pages/mobile/mine/traffic-pool/userList/index.tsx b/Cunkebao/src/pages/mobile/mine/traffic-pool/userList/index.tsx new file mode 100644 index 000000000..823ccf655 --- /dev/null +++ b/Cunkebao/src/pages/mobile/mine/traffic-pool/userList/index.tsx @@ -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([]); + 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 ( + + + {/* 搜索栏 */} +
+
+ handSearch(e.target.value)} + prefix={} + allowClear + size="large" + /> +
+
+ + } + footer={ +
+ { + setPage(newPage); + getList({ page: newPage }); + }} + /> +
+ } + > +
+ {list.length === 0 && !loading ? ( + + ) : ( +
+ {list.map(item => ( +
+
+ navigate( + `/mine/traffic-pool/detail/${item.wechatId}/${item.id}`, + ) + } + > +
+ +
+
+ {item.nickname || item.identifier} +
+
+ 微信号:{item.wechatId || "-"} +
+
+ 来源:{item.fromd || "-"} +
+
+ 分组: + {item.packages && item.packages.length + ? item.packages.join(",") + : "-"} +
+
+ 创建时间:{item.createTime} +
+
+
+
+
+ ))} +
+ )} +
+
+ ); +}; + +export default TrafficPoolUserList; diff --git a/Cunkebao/src/pages/mobile/scenarios/plan/list/components/AccountListModal.tsx b/Cunkebao/src/pages/mobile/scenarios/plan/list/components/AccountListModal.tsx index 7d16ad61b..0f4217c19 100644 --- a/Cunkebao/src/pages/mobile/scenarios/plan/list/components/AccountListModal.tsx +++ b/Cunkebao/src/pages/mobile/scenarios/plan/list/components/AccountListModal.tsx @@ -18,6 +18,7 @@ interface AccountItem { wechatId: string; }; phone?: string; + fail_reason: string; } interface AccountListModalProps { @@ -153,11 +154,18 @@ const AccountListModal: React.FC = ({
{account.userinfo.wechatId || "未绑定微信号"}
+ {account.fail_reason && ( +
+ 原因:{account.fail_reason} +
+ )}
{getStatusText(Number(account.status))} diff --git a/Cunkebao/src/pages/mobile/scenarios/plan/new/index.tsx b/Cunkebao/src/pages/mobile/scenarios/plan/new/index.tsx index 949574a12..e7032d04e 100644 --- a/Cunkebao/src/pages/mobile/scenarios/plan/new/index.tsx +++ b/Cunkebao/src/pages/mobile/scenarios/plan/new/index.tsx @@ -20,6 +20,7 @@ export default function NewPlan() { const router = useNavigate(); const [currentStep, setCurrentStep] = useState(1); const [formData, setFormData] = useState(defFormData); + const [submitting, setSubmitting] = useState(false); // 添加提交状态 const [sceneList, setSceneList] = useState([]); 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 (
{currentStep > 1 && ( - )} @@ -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 + ? "完成" + : "下一步"}
); diff --git a/Cunkebao/src/pages/mobile/workspace/ai-knowledge/api 文档 b/Cunkebao/src/pages/mobile/workspace/ai-knowledge/api 文档 new file mode 100644 index 000000000..1659a0c3c --- /dev/null +++ b/Cunkebao/src/pages/mobile/workspace/ai-knowledge/api 文档 @@ -0,0 +1,168 @@ +初始化AI功能(每次都得执行) + GET /v1/knowledge/init + + + + +发布并应用AI工具(修改知识库需要重新发布) + GET /v1/knowledge/release + 传参: + { + id:number + } + + 返回参数: + { + "id": 1, + "companyId": 2130, + "userId": 128, + "config": { + "name": "魔兽世界", + "model_id": "1737521813", + "prompt_info": "# 角色\r\n你是一位全能知识客服,作为专业的客服智能体,具备全面的知识储备,能够回答用户提出的各类问题。在回答问题前,会仔细查阅知识库内容,并且始终严格遵守中国法律法规。\r\n\r\n## 技能\r\n### 技能 1: 回答用户问题\r\n1. 当用户提出问题时,首先在知识库中进行搜索查找相关信息。\r\n2. 依据知识库中的内容,为用户提供准确、清晰、完整的回答。\r\n \r\n## 限制\r\n- 仅依据知识库内容回答问题,对于知识库中没有的信息,如实告知用户无法回答。\r\n- 回答必须严格遵循中国法律法规,不得出现任何违法违规内容。\r\n- 回答需简洁明了,避免冗长复杂的表述。" + }, + "createTime": "2025-10-24 16:55:08", + "updateTime": "2025-10-24 16:56:28", + "isRelease": 1, + "releaseTime": 1761296188, + "botId": "7564707767488610345", + "datasetId": "7564708881499619366" + } + + + +知识库类型 - 列表 + GET /v1/knowledge/typeList + 传参: + { + page:number + limit:number + } + 返回参数: + "total": 5, + "per_page": 20, + "current_page": 1, + "last_page": 1, + "data": [ + { + "id": 1, + "type": 0, + "name": "产品介绍库", + "description": "包含所有产品相关的介绍文档、图片和视频资料", + "label": [ + "产品", + "营销" + ], + "prompt": null, + "companyId": 0, + "userId": 0, + "createTime": null, + "updateTime": null, + "isDel": 0, + "delTime": 0 + }, + + ] + + +知识库类型 - 添加 + POST /v1/knowledge/addType + 传参: + { + name:string + description:string + label:string[] + prompt:string + } + + +知识库类型 - 编辑 + POST /v1/knowledge/editType + 传参: + { + id:number + name:string + description:string + label:string[] + prompt:string + } + + +知识库类型 - 删除 + DELETE /v1/knowledge/deleteType + 传参: + { + id:number + } + + +知识库 - 列表 + GET /v1/knowledge/getList + 传参: + { + name:number + typeId:number + label:string[] + fileUrl:string + } + 返回参数: + { + "total": 1, + "per_page": 20, + "current_page": 1, + "last_page": 1, + "data": [ + { + "id": 1, + "typeId": 1, + "name": "存客宝项目介绍(面向开发人员).docx", + "label": [ + "1231", + "3453" + ], + "companyId": 2130, + "userId": 128, + "createTime": 1761296164, + "updateTime": 1761296165, + "isDel": 0, + "delTime": 0, + "documentId": "7564706328503189558", + "fileUrl": "http://karuosiyujzk.oss-cn-shenzhen.aliyuncs.com/2025/10/22/9de59fc8723f10973ade586650dfb235.docx", + "type": { + "id": 1, + "type": 0, + "name": "产品介绍库", + "description": "包含所有产品相关的介绍文档、图片和视频资料", + "label": [ + "产品", + "营销" + ], + "prompt": null, + "companyId": 0, + "userId": 0, + "createTime": null, + "updateTime": null, + "isDel": 0, + "delTime": 0 + } + } + ] + } + + +知识库 - 添加 + POST /v1/knowledge/add + 传参: + { + name:number + typeId:number + label:string[] + fileUrl:string + } + +知识库 - 删除 + DELETE /v1/knowledge/delete + 传参: + { + id:number + } diff --git a/Cunkebao/src/pages/mobile/workspace/ai-knowledge/detail/api.ts b/Cunkebao/src/pages/mobile/workspace/ai-knowledge/detail/api.ts new file mode 100644 index 000000000..aecda9a04 --- /dev/null +++ b/Cunkebao/src/pages/mobile/workspace/ai-knowledge/detail/api.ts @@ -0,0 +1,109 @@ +import request from "@/api/request"; +import type { + KnowledgeBaseDetailResponse, + MaterialListResponse, + CallerListResponse, +} from "./data"; + +// 获取知识库类型详情(复用列表接口) +export function getKnowledgeBaseDetail( + id: number, +): Promise { + // 接口文档中没有单独的详情接口,通过列表接口获取 + 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 { + 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 { + return request("/v1/knowledge/add", data, "POST"); +} + +// 删除素材 +export function deleteMaterial(id: number): Promise { + 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 { + // 注意:实际接口未提供,需要后端补充 + 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 { + return request( + "/v1/knowledge/editType", + { + id: data.id, + name: data.name || "", + description: data.description || "", + label: data.label || [], + prompt: data.useIndependentPrompt ? data.independentPrompt || "" : "", + }, + "POST", + ); +} diff --git a/Cunkebao/src/pages/mobile/workspace/ai-knowledge/detail/data.ts b/Cunkebao/src/pages/mobile/workspace/ai-knowledge/detail/data.ts new file mode 100644 index 000000000..751e87623 --- /dev/null +++ b/Cunkebao/src/pages/mobile/workspace/ai-knowledge/detail/data.ts @@ -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; +} diff --git a/Cunkebao/src/pages/mobile/workspace/ai-knowledge/detail/index.module.scss b/Cunkebao/src/pages/mobile/workspace/ai-knowledge/detail/index.module.scss new file mode 100644 index 000000000..89793022d --- /dev/null +++ b/Cunkebao/src/pages/mobile/workspace/ai-knowledge/detail/index.module.scss @@ -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; + } +} diff --git a/Cunkebao/src/pages/mobile/workspace/ai-knowledge/detail/index.tsx b/Cunkebao/src/pages/mobile/workspace/ai-knowledge/detail/index.tsx new file mode 100644 index 000000000..964ad95e8 --- /dev/null +++ b/Cunkebao/src/pages/mobile/workspace/ai-knowledge/detail/index.tsx @@ -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( + null, + ); + const [materials, setMaterials] = useState([]); + const [callers, setCallers] = useState([]); + 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 ( +
+ +
+ ); + } else if (["doc", "docx", "pdf", "txt"].includes(type)) { + return ( +
+ +
+ ); + } else { + return ( +
+ +
+ ); + } + }; + + 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 ( +
+ {/* 提示横幅 */} +
+ + +
+ + {/* 知识库信息卡片 */} +
+
+
+ +
+
+
+ {knowledgeBase.name} + {isSystemPreset && ( + + (系统预设) + + )} +
+ {knowledgeBase.description && ( +
+ {knowledgeBase.description} +
+ )} +
+
+ +
+
+
+ {knowledgeBase.materialCount || 0} +
+
素材总数
+
+
+
+ {knowledgeBase.aiCallEnabled ? "启用" : "关闭"} +
+
AI状态
+
+
+
+ {knowledgeBase.tags?.length || 0} +
+
标签数
+
+
+ + {/* 内容标签 */} + {knowledgeBase.tags && knowledgeBase.tags.length > 0 && ( +
+
内容标签
+
+ {knowledgeBase.tags.map((tag, index) => ( + + {tag} + + ))} +
+
+ )} + +
+
+
+
+ + AI调用配置 +
+
+ +
+ +
+
+
+ + AI助手可以使用此内容库的素材 +
+
+
+
+
+
+ + 支持智能应答和推荐 +
+
+
+ +
+
+
+ + 实时响应用户查询 +
+
+
+
+ + {/* 提示词生效规则 */} +
+
+
+ + 提示词生效规则 +
+
+
    +
  1. 1、先应用统一提示词 (全局规范)
  2. +
  3. 2、再结合知识库独立提示词 (专业指导)
  4. +
  5. 3、最终形成针对性的回复风格
  6. +
+
+ + {/* 知识库独立提示词 */} +
+
+
+ + 知识库独立提示词 +
+ +
+
+ {knowledgeBase.independentPrompt || independentPrompt ? ( +
+ {knowledgeBase.independentPrompt || independentPrompt} +
+ ) : ( +
+ 暂无独立提示词,点击编辑按钮添加 +
+ )} +
+ +
+ + {/* 调用客服名单 */} + {callers.length > 0 && ( +
+
+
+ + 调用客服名单 + {callers.length} +
+
+
+ {callers.slice(0, 3).map(caller => ( +
+
+ {caller.avatar ? ( + {caller.name} + ) : ( + + )} +
+
+
{caller.name}
+
{caller.role}
+
+
+ 调用{caller.callCount}次 · {caller.lastCallTime} +
+
+ ))} +
+
+ )} +
+ + {/* 上传素材按钮 */} + {!isSystemPreset && ( +
+ { + 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("上传失败"); + } + } + }} + /> +
+ )} + + {/* 库内素材 */} +
+
+
+ 库内素材 + {materials.length} +
+
+
+ {materials.length > 0 ? ( + materials.map(material => ( +
+ {getFileIcon(material.fileType)} +
+
+
+ {material.fileName} +
+ {!isSystemPreset && ( + , + label: "删除", + danger: true, + }, + ], + onClick: () => handleDeleteMaterial(material.id), + }} + trigger={["click"]} + placement="bottomRight" + > + + + )} +
+
+
+ {formatFileSize(material?.size || 0)} +
+
+ {material.uploadTime} +
+
+ {material.tags && material.tags.length > 0 && ( +
+ {material.tags.map((tag, index) => ( + + {tag} + + ))} +
+ )} +
+
+ )) + ) : ( +
+
+ +
+
暂无素材
+
+ )} +
+
+ + {/* 底部操作按钮 */} + {!isSystemPreset && ( +
+ + +
+ )} +
+ ); + }; + + return ( + + navigate("/workspace/ai-knowledge")} + right={ +
+ + +
+ } + /> + + } + > +
+ {loading ? ( +
+ +
+ ) : ( + renderContent() + )} +
+ + {/* 编辑独立提示词弹窗 */} + setPromptEditVisible(false)} + onOk={handlePromptSave} + okText="保存" + cancelText="取消" + className={style.promptEditModal} + > +