Merge branch 'yongpxu-dev' into yongxu-dev3
This commit is contained in:
@@ -11,7 +11,7 @@ const debounceMap = new Map<string, number>();
|
||||
|
||||
const instance: AxiosInstance = axios.create({
|
||||
baseURL: (import.meta as any).env?.VITE_API_BASE_URL || "/api",
|
||||
timeout: 10000,
|
||||
timeout: 20000,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
|
||||
@@ -40,16 +40,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
border-radius: 20px;
|
||||
border: 1px solid #e0e0e0;
|
||||
background: white;
|
||||
|
||||
&:hover {
|
||||
border-color: #1677ff;
|
||||
color: #1677ff;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.create-btn {
|
||||
border-radius: 20px;
|
||||
|
||||
@@ -40,16 +40,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
border-radius: 20px;
|
||||
border: 1px solid #e0e0e0;
|
||||
background: white;
|
||||
|
||||
&:hover {
|
||||
border-color: #1677ff;
|
||||
color: #1677ff;
|
||||
}
|
||||
}
|
||||
|
||||
.create-btn {
|
||||
border-radius: 20px;
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import React from "react";
|
||||
import PlaceholderPage from "@/components/PlaceholderPage";
|
||||
|
||||
const TrafficPool: React.FC = () => {
|
||||
return (
|
||||
<PlaceholderPage title="流量池" showAddButton addButtonText="新建流量池" />
|
||||
);
|
||||
};
|
||||
|
||||
export default TrafficPool;
|
||||
@@ -1,8 +0,0 @@
|
||||
import React from "react";
|
||||
import PlaceholderPage from "@/components/PlaceholderPage";
|
||||
|
||||
const TrafficPoolDetail: React.FC = () => {
|
||||
return <PlaceholderPage title="流量池详情" />;
|
||||
};
|
||||
|
||||
export default TrafficPoolDetail;
|
||||
@@ -1,5 +1,5 @@
|
||||
import request from "@/api/request";
|
||||
|
||||
export function getTrafficPoolDetail(id: string): Promise<any> {
|
||||
return request("/v1/traffic/pool/detail", { id }, "GET");
|
||||
return request("/v1/workbench/detail", { id }, "GET");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
// 用户详情类型
|
||||
export interface TrafficPoolUserDetail {
|
||||
id: number;
|
||||
nickname: string;
|
||||
avatar: string;
|
||||
wechatId: string;
|
||||
status: number | string;
|
||||
addTime: string;
|
||||
lastInteraction: string;
|
||||
deviceName?: string;
|
||||
wechatAccountName?: string;
|
||||
customerServiceName?: string;
|
||||
poolNames?: string[];
|
||||
rfmScore?: {
|
||||
recency: number;
|
||||
frequency: number;
|
||||
monetary: number;
|
||||
segment?: string;
|
||||
};
|
||||
totalSpent?: number;
|
||||
interactionCount?: number;
|
||||
conversionRate?: number;
|
||||
tags?: string[];
|
||||
packages?: string[];
|
||||
interactions?: Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
content: string;
|
||||
timestamp: string;
|
||||
value?: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,300 @@
|
||||
export default function TrafficPoolDetail() {
|
||||
return <div>TrafficPoolDetail</div>;
|
||||
}
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import { getTrafficPoolDetail } from "./api";
|
||||
import type { TrafficPoolUserDetail } from "./data";
|
||||
import { Card, Button, Avatar, Tag, Spin } from "antd";
|
||||
|
||||
const tabList = [
|
||||
{ key: "base", label: "基本信息" },
|
||||
{ key: "journey", label: "用户旅程" },
|
||||
{ key: "tags", label: "用户标签" },
|
||||
];
|
||||
|
||||
const TrafficPoolDetail: React.FC = () => {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [user, setUser] = useState<TrafficPoolUserDetail | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<"base" | "journey" | "tags">(
|
||||
"base"
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
getTrafficPoolDetail(id as string)
|
||||
.then((res) => setUser(res))
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Layout>
|
||||
<div style={{ textAlign: "center", padding: "64px 0" }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
if (!user) {
|
||||
return (
|
||||
<Layout>
|
||||
<div style={{ textAlign: "center", color: "#aaa", padding: "64px 0" }}>
|
||||
未找到该用户
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
height: 48,
|
||||
borderBottom: "1px solid #eee",
|
||||
background: "#fff",
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="link"
|
||||
onClick={() => navigate(-1)}
|
||||
style={{ marginRight: 8 }}
|
||||
>
|
||||
< 返回
|
||||
</Button>
|
||||
<div style={{ fontWeight: 600, fontSize: 18 }}>用户详情</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div style={{ padding: 16 }}>
|
||||
{/* 顶部信息 */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 16,
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<Avatar src={user.avatar} size={64} />
|
||||
<div>
|
||||
<div style={{ fontSize: 20, fontWeight: 600 }}>{user.nickname}</div>
|
||||
<div style={{ color: "#1677ff", fontSize: 14, margin: "4px 0" }}>
|
||||
{user.wechatId}
|
||||
</div>
|
||||
{user.packages &&
|
||||
user.packages.length > 0 &&
|
||||
user.packages.map((pkg) => (
|
||||
<Tag color="purple" key={pkg} style={{ marginRight: 4 }}>
|
||||
{pkg}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Tab栏 */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 24,
|
||||
borderBottom: "1px solid #eee",
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
{tabList.map((tab) => (
|
||||
<div
|
||||
key={tab.key}
|
||||
style={{
|
||||
padding: "8px 0",
|
||||
fontWeight: 500,
|
||||
color: activeTab === tab.key ? "#1677ff" : "#888",
|
||||
borderBottom:
|
||||
activeTab === tab.key ? "2px solid #1677ff" : "none",
|
||||
cursor: "pointer",
|
||||
fontSize: 16,
|
||||
}}
|
||||
onClick={() => setActiveTab(tab.key as any)}
|
||||
>
|
||||
{tab.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Tab内容 */}
|
||||
{activeTab === "base" && (
|
||||
<>
|
||||
<Card style={{ marginBottom: 16 }} title="关键信息">
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 24 }}>
|
||||
<div>设备:{user.deviceName || "--"}</div>
|
||||
<div>微信号:{user.wechatAccountName || "--"}</div>
|
||||
<div>客服:{user.customerServiceName || "--"}</div>
|
||||
<div>添加时间:{user.addTime || "--"}</div>
|
||||
<div>最近互动:{user.lastInteraction || "--"}</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card style={{ marginBottom: 16 }} title="RFM评分">
|
||||
<div style={{ display: "flex", gap: 32 }}>
|
||||
<div>
|
||||
<div
|
||||
style={{ fontSize: 20, fontWeight: 600, color: "#1677ff" }}
|
||||
>
|
||||
{user.rfmScore?.recency ?? "-"}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "#888" }}>最近性(R)</div>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
style={{ fontSize: 20, fontWeight: 600, color: "#52c41a" }}
|
||||
>
|
||||
{user.rfmScore?.frequency ?? "-"}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "#888" }}>频率(F)</div>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
style={{ fontSize: 20, fontWeight: 600, color: "#eb2f96" }}
|
||||
>
|
||||
{user.rfmScore?.monetary ?? "-"}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "#888" }}>金额(M)</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card style={{ marginBottom: 16 }} title="统计数据">
|
||||
<div style={{ display: "flex", gap: 32 }}>
|
||||
<div>
|
||||
<div
|
||||
style={{ fontSize: 18, fontWeight: 600, color: "#52c41a" }}
|
||||
>
|
||||
¥{user.totalSpent ?? "-"}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "#888" }}>总消费</div>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
style={{ fontSize: 18, fontWeight: 600, color: "#1677ff" }}
|
||||
>
|
||||
{user.interactionCount ?? "-"}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "#888" }}>互动次数</div>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
style={{ fontSize: 18, fontWeight: 600, color: "#faad14" }}
|
||||
>
|
||||
{user.conversionRate ?? "-"}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "#888" }}>转化率</div>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
style={{ fontSize: 18, fontWeight: 600, color: "#ff4d4f" }}
|
||||
>
|
||||
{user.status === "failed"
|
||||
? "添加失败"
|
||||
: user.status === "added"
|
||||
? "添加成功"
|
||||
: "未添加"}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "#888" }}>添加状态</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
{activeTab === "journey" && (
|
||||
<Card title="互动记录">
|
||||
{user.interactions && user.interactions.length > 0 ? (
|
||||
user.interactions.slice(0, 4).map((it) => (
|
||||
<div
|
||||
key={it.id}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
borderBottom: "1px solid #f0f0f0",
|
||||
padding: "12px 0",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 22 }}>
|
||||
{it.type === "click" && "📱"}
|
||||
{it.type === "message" && "💬"}
|
||||
{it.type === "purchase" && "💲"}
|
||||
{it.type === "view" && "👁️"}
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontWeight: 500 }}>
|
||||
{it.type === "click" && "点击行为"}
|
||||
{it.type === "message" && "消息互动"}
|
||||
{it.type === "purchase" && "购买行为"}
|
||||
{it.type === "view" && "页面浏览"}
|
||||
</div>
|
||||
<div style={{ color: "#888", fontSize: 13 }}>
|
||||
{it.content}
|
||||
{it.type === "purchase" && it.value && (
|
||||
<span
|
||||
style={{
|
||||
color: "#52c41a",
|
||||
fontWeight: 600,
|
||||
marginLeft: 4,
|
||||
}}
|
||||
>
|
||||
¥{it.value}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "#aaa",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{it.timestamp}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
color: "#aaa",
|
||||
textAlign: "center",
|
||||
padding: "24px 0",
|
||||
}}
|
||||
>
|
||||
暂无互动记录
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
{activeTab === "tags" && (
|
||||
<Card title="用户标签">
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
{user.tags && user.tags.length > 0 ? (
|
||||
user.tags.map((tag) => (
|
||||
<Tag
|
||||
key={tag}
|
||||
color="blue"
|
||||
style={{ marginRight: 8, marginBottom: 8 }}
|
||||
>
|
||||
{tag}
|
||||
</Tag>
|
||||
))
|
||||
) : (
|
||||
<span style={{ color: "#aaa" }}>暂无标签</span>
|
||||
)}
|
||||
</div>
|
||||
<Button type="dashed" block>
|
||||
➕ 添加新标签
|
||||
</Button>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default TrafficPoolDetail;
|
||||
|
||||
47
nkebao/src/pages/mine/traffic-pool/list/BatchAddModal.tsx
Normal file
47
nkebao/src/pages/mine/traffic-pool/list/BatchAddModal.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import React from "react";
|
||||
import { Modal, Selector } from "antd-mobile";
|
||||
import type { PackageOption } from "./data";
|
||||
|
||||
interface BatchAddModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
packageOptions: PackageOption[];
|
||||
batchTarget: string;
|
||||
setBatchTarget: (v: string) => void;
|
||||
selectedCount: number;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
const BatchAddModal: React.FC<BatchAddModalProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
packageOptions,
|
||||
batchTarget,
|
||||
setBatchTarget,
|
||||
selectedCount,
|
||||
onConfirm,
|
||||
}) => (
|
||||
<Modal
|
||||
visible={visible}
|
||||
title="批量加入分组"
|
||||
onClose={onClose}
|
||||
footer={[
|
||||
{ text: "取消", onClick: onClose },
|
||||
{ text: "确定", onClick: onConfirm },
|
||||
]}
|
||||
>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div>选择目标分组</div>
|
||||
<Selector
|
||||
options={packageOptions.map((p) => ({ label: p.name, value: p.id }))}
|
||||
value={[batchTarget]}
|
||||
onChange={(v) => setBatchTarget(v[0])}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ color: "#888", fontSize: 13 }}>
|
||||
将选中的{selectedCount}个用户加入所选分组
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
export default BatchAddModal;
|
||||
@@ -0,0 +1,84 @@
|
||||
import React from "react";
|
||||
import { Card, Button } from "antd-mobile";
|
||||
|
||||
interface DataAnalysisPanelProps {
|
||||
stats: {
|
||||
total: number;
|
||||
highValue: number;
|
||||
added: number;
|
||||
pending: number;
|
||||
failed: number;
|
||||
addSuccessRate: number;
|
||||
};
|
||||
showStats: boolean;
|
||||
setShowStats: (v: boolean) => void;
|
||||
}
|
||||
|
||||
const DataAnalysisPanel: React.FC<DataAnalysisPanelProps> = ({
|
||||
stats,
|
||||
showStats,
|
||||
setShowStats,
|
||||
}) => {
|
||||
if (!showStats) return null;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: "#fff",
|
||||
padding: "16px",
|
||||
margin: "8px 0",
|
||||
borderRadius: 8,
|
||||
boxShadow: "0 2px 8px rgba(0,0,0,0.04)",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", gap: 16, marginBottom: 12 }}>
|
||||
<Card style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 18, fontWeight: 600, color: "#1677ff" }}>
|
||||
{stats.total}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: "#888" }}>总用户数</div>
|
||||
</Card>
|
||||
<Card style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 18, fontWeight: 600, color: "#eb2f96" }}>
|
||||
{stats.highValue}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: "#888" }}>高价值用户</div>
|
||||
</Card>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 16 }}>
|
||||
<Card style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 18, fontWeight: 600, color: "#52c41a" }}>
|
||||
{stats.addSuccessRate}%
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: "#888" }}>添加成功率</div>
|
||||
</Card>
|
||||
<Card style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 18, fontWeight: 600, color: "#faad14" }}>
|
||||
{stats.added}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: "#888" }}>已添加</div>
|
||||
</Card>
|
||||
<Card style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 18, fontWeight: 600, color: "#bfbfbf" }}>
|
||||
{stats.pending}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: "#888" }}>待添加</div>
|
||||
</Card>
|
||||
<Card style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 18, fontWeight: 600, color: "#ff4d4f" }}>
|
||||
{stats.failed}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: "#888" }}>添加失败</div>
|
||||
</Card>
|
||||
</div>
|
||||
<Button
|
||||
size="small"
|
||||
style={{ marginTop: 12 }}
|
||||
onClick={() => setShowStats(false)}
|
||||
>
|
||||
收起分析
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DataAnalysisPanel;
|
||||
118
nkebao/src/pages/mine/traffic-pool/list/FilterModal.tsx
Normal file
118
nkebao/src/pages/mine/traffic-pool/list/FilterModal.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import React from "react";
|
||||
import { Popup } from "antd-mobile";
|
||||
import { Select, Button } from "antd";
|
||||
import type {
|
||||
DeviceOption,
|
||||
PackageOption,
|
||||
ValueLevel,
|
||||
UserStatus,
|
||||
} from "./data";
|
||||
|
||||
interface FilterModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
deviceOptions: DeviceOption[];
|
||||
packageOptions: PackageOption[];
|
||||
deviceId: string;
|
||||
setDeviceId: (v: string) => void;
|
||||
packageId: string;
|
||||
setPackageId: (v: string) => void;
|
||||
valueLevel: ValueLevel;
|
||||
setValueLevel: (v: ValueLevel) => void;
|
||||
userStatus: UserStatus;
|
||||
setUserStatus: (v: UserStatus) => void;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
const valueLevelOptions = [
|
||||
{ label: "全部价值", value: "all" },
|
||||
{ label: "高价值", value: "high" },
|
||||
{ label: "中价值", value: "medium" },
|
||||
{ label: "低价值", value: "low" },
|
||||
];
|
||||
const statusOptions = [
|
||||
{ label: "全部状态", value: "all" },
|
||||
{ label: "已添加", value: "added" },
|
||||
{ label: "待添加", value: "pending" },
|
||||
{ label: "添加失败", value: "failed" },
|
||||
{ label: "重复", value: "duplicate" },
|
||||
];
|
||||
|
||||
const FilterModal: React.FC<FilterModalProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
deviceOptions,
|
||||
packageOptions,
|
||||
deviceId,
|
||||
setDeviceId,
|
||||
packageId,
|
||||
setPackageId,
|
||||
valueLevel,
|
||||
setValueLevel,
|
||||
userStatus,
|
||||
setUserStatus,
|
||||
onReset,
|
||||
}) => (
|
||||
<Popup
|
||||
visible={visible}
|
||||
onMaskClick={onClose}
|
||||
position="right"
|
||||
bodyStyle={{ width: "80vw", maxWidth: 360, padding: 24 }}
|
||||
>
|
||||
<div style={{ fontWeight: 600, fontSize: 18, marginBottom: 20 }}>
|
||||
筛选选项
|
||||
</div>
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<div style={{ marginBottom: 6 }}>设备</div>
|
||||
<Select
|
||||
style={{ width: "100%" }}
|
||||
value={deviceId}
|
||||
onChange={setDeviceId}
|
||||
options={[
|
||||
{ label: "全部设备", value: "all" },
|
||||
...deviceOptions.map((d) => ({ label: d.name, value: d.id })),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<div style={{ marginBottom: 6 }}>流量池</div>
|
||||
<Select
|
||||
style={{ width: "100%" }}
|
||||
value={packageId}
|
||||
onChange={setPackageId}
|
||||
options={[
|
||||
{ label: "全部流量池", value: "all" },
|
||||
...packageOptions.map((p) => ({ label: p.name, value: p.id })),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<div style={{ marginBottom: 6 }}>用户价值</div>
|
||||
<Select
|
||||
style={{ width: "100%" }}
|
||||
value={valueLevel}
|
||||
onChange={(v) => setValueLevel(v as ValueLevel)}
|
||||
options={valueLevelOptions}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<div style={{ marginBottom: 6 }}>添加状态</div>
|
||||
<Select
|
||||
style={{ width: "100%" }}
|
||||
value={userStatus}
|
||||
onChange={(v) => setUserStatus(v as UserStatus)}
|
||||
options={statusOptions}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 12, marginTop: 32 }}>
|
||||
<Button onClick={onReset} style={{ flex: 1 }}>
|
||||
重置筛选
|
||||
</Button>
|
||||
<Button type="primary" onClick={onClose} style={{ flex: 1 }}>
|
||||
应用筛选
|
||||
</Button>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
|
||||
export default FilterModal;
|
||||
@@ -0,0 +1,31 @@
|
||||
import request from "@/api/request";
|
||||
import type { TrafficPoolListResponse, DeviceOption } from "./data";
|
||||
import { fetchDeviceList } from "@/api/devices";
|
||||
|
||||
// 获取流量池列表
|
||||
export function fetchTrafficPoolList(params: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
keyword?: string;
|
||||
}) {
|
||||
return request("/v1/traffic/pool", params, "GET");
|
||||
}
|
||||
|
||||
// 获取设备列表(真实接口)
|
||||
export async function fetchDeviceOptions(): Promise<DeviceOption[]> {
|
||||
const res = await fetchDeviceList({ page: 1, limit: 100 });
|
||||
// 假设返回 { list: [{ id, name, ... }], ... }
|
||||
return (res.list || []).map((item: any) => ({
|
||||
id: String(item.id),
|
||||
name: item.name,
|
||||
}));
|
||||
}
|
||||
|
||||
// 获取分组列表(如无真实接口可用mock)
|
||||
export async function fetchPackageOptions(): Promise<any[]> {
|
||||
// TODO: 替换为真实接口
|
||||
return [
|
||||
{ id: "pkg-1", name: "高价值客户池" },
|
||||
{ id: "pkg-2", name: "测试流量池" },
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// 流量池用户类型
|
||||
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";
|
||||
|
||||
160
nkebao/src/pages/mine/traffic-pool/list/dataAnyx.tsx
Normal file
160
nkebao/src/pages/mine/traffic-pool/list/dataAnyx.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import {
|
||||
fetchTrafficPoolList,
|
||||
fetchDeviceOptions,
|
||||
fetchPackageOptions,
|
||||
} from "./api";
|
||||
import type {
|
||||
TrafficPoolUser,
|
||||
DeviceOption,
|
||||
PackageOption,
|
||||
ValueLevel,
|
||||
UserStatus,
|
||||
} from "./data";
|
||||
import { Toast } from "antd-mobile";
|
||||
|
||||
export function useTrafficPoolListLogic() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [list, setList] = useState<TrafficPoolUser[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize] = useState(10);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
// 筛选相关
|
||||
const [showFilter, setShowFilter] = useState(false);
|
||||
const [deviceOptions, setDeviceOptions] = useState<DeviceOption[]>([]);
|
||||
const [packageOptions, setPackageOptions] = useState<PackageOption[]>([]);
|
||||
const [deviceId, setDeviceId] = useState<string>("all");
|
||||
const [packageId, setPackageId] = useState<string>("all");
|
||||
const [valueLevel, setValueLevel] = useState<ValueLevel>("all");
|
||||
const [userStatus, setUserStatus] = useState<UserStatus>("all");
|
||||
|
||||
// 批量相关
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||
const [batchModal, setBatchModal] = useState(false);
|
||||
const [batchTarget, setBatchTarget] = useState<string>("");
|
||||
|
||||
// 数据分析
|
||||
const [showStats, setShowStats] = useState(false);
|
||||
const stats = useMemo(() => {
|
||||
const total = list.length;
|
||||
const highValue = list.filter((u) =>
|
||||
u.tags.includes("高价值客户池")
|
||||
).length;
|
||||
const added = list.filter((u) => u.status === 1).length;
|
||||
const pending = list.filter((u) => u.status === 0).length;
|
||||
const failed = list.filter((u) => u.status === -1).length;
|
||||
const addSuccessRate = total ? Math.round((added / total) * 100) : 0;
|
||||
return { total, highValue, added, pending, failed, addSuccessRate };
|
||||
}, [list]);
|
||||
|
||||
// 获取列表
|
||||
const getList = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetchTrafficPoolList({
|
||||
page,
|
||||
pageSize,
|
||||
keyword: search,
|
||||
// deviceId,
|
||||
// packageId,
|
||||
// valueLevel,
|
||||
// userStatus,
|
||||
});
|
||||
setList(res.list || []);
|
||||
setTotal(res.total || 0);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取筛选项
|
||||
useEffect(() => {
|
||||
fetchDeviceOptions().then(setDeviceOptions);
|
||||
fetchPackageOptions().then(setPackageOptions);
|
||||
}, []);
|
||||
|
||||
// 筛选条件变化时刷新列表
|
||||
useEffect(() => {
|
||||
getList();
|
||||
// eslint-disable-next-line
|
||||
}, [page, search /*, deviceId, packageId, valueLevel, userStatus*/]);
|
||||
|
||||
// 全选/反选
|
||||
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 = () => {
|
||||
if (!batchTarget) {
|
||||
Toast.show({ content: "请选择目标分组", position: "top" });
|
||||
return;
|
||||
}
|
||||
// TODO: 调用后端批量接口,这里仅模拟
|
||||
Toast.show({
|
||||
content: `已将${selectedIds.length}个用户加入${packageOptions.find((p) => p.id === batchTarget)?.name || ""}`,
|
||||
position: "top",
|
||||
});
|
||||
setBatchModal(false);
|
||||
setSelectedIds([]);
|
||||
setBatchTarget("");
|
||||
// 可刷新列表
|
||||
};
|
||||
|
||||
// 筛选重置
|
||||
const resetFilter = () => {
|
||||
setDeviceId("all");
|
||||
setPackageId("all");
|
||||
setValueLevel("all");
|
||||
setUserStatus("all");
|
||||
};
|
||||
|
||||
return {
|
||||
loading,
|
||||
list,
|
||||
page,
|
||||
setPage,
|
||||
pageSize,
|
||||
total,
|
||||
search,
|
||||
setSearch,
|
||||
showFilter,
|
||||
setShowFilter,
|
||||
deviceOptions,
|
||||
packageOptions,
|
||||
deviceId,
|
||||
setDeviceId,
|
||||
packageId,
|
||||
setPackageId,
|
||||
valueLevel,
|
||||
setValueLevel,
|
||||
userStatus,
|
||||
setUserStatus,
|
||||
selectedIds,
|
||||
setSelectedIds,
|
||||
handleSelectAll,
|
||||
handleSelect,
|
||||
batchModal,
|
||||
setBatchModal,
|
||||
batchTarget,
|
||||
setBatchTarget,
|
||||
handleBatchAdd,
|
||||
showStats,
|
||||
setShowStats,
|
||||
stats,
|
||||
getList,
|
||||
resetFilter,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,257 @@
|
||||
export default function TrafficPoolList() {
|
||||
return <div>TrafficPoolList</div>;
|
||||
}
|
||||
import React from "react";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import {
|
||||
SearchOutlined,
|
||||
ReloadOutlined,
|
||||
BarChartOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { Input, Button, Checkbox } from "antd";
|
||||
import styles from "./index.module.scss";
|
||||
import { List, Empty, Avatar, Modal, Selector, Toast, Card } from "antd-mobile";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import NavCommon from "@/components/NavCommon";
|
||||
import { useTrafficPoolListLogic } from "./dataAnyx";
|
||||
import DataAnalysisPanel from "./DataAnalysisPanel";
|
||||
import FilterModal from "./FilterModal";
|
||||
import BatchAddModal from "./BatchAddModal";
|
||||
|
||||
const defaultAvatar =
|
||||
"https://cdn.jsdelivr.net/gh/maokaka/static/avatar-default.png";
|
||||
|
||||
const valueLevelOptions = [
|
||||
{ label: "全部", value: "all" },
|
||||
{ label: "高价值", value: "high" },
|
||||
{ label: "中价值", value: "medium" },
|
||||
{ label: "低价值", value: "low" },
|
||||
];
|
||||
const statusOptions = [
|
||||
{ label: "全部", value: "all" },
|
||||
{ label: "已添加", value: "added" },
|
||||
{ label: "待添加", value: "pending" },
|
||||
{ label: "添加失败", value: "failed" },
|
||||
{ label: "重复", value: "duplicate" },
|
||||
];
|
||||
|
||||
const TrafficPoolList: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
loading,
|
||||
list,
|
||||
page,
|
||||
setPage,
|
||||
pageSize,
|
||||
total,
|
||||
search,
|
||||
setSearch,
|
||||
showFilter,
|
||||
setShowFilter,
|
||||
deviceOptions,
|
||||
packageOptions,
|
||||
deviceId,
|
||||
setDeviceId,
|
||||
packageId,
|
||||
setPackageId,
|
||||
valueLevel,
|
||||
setValueLevel,
|
||||
userStatus,
|
||||
setUserStatus,
|
||||
selectedIds,
|
||||
handleSelectAll,
|
||||
handleSelect,
|
||||
batchModal,
|
||||
setBatchModal,
|
||||
batchTarget,
|
||||
setBatchTarget,
|
||||
handleBatchAdd,
|
||||
showStats,
|
||||
setShowStats,
|
||||
stats,
|
||||
getList,
|
||||
resetFilter,
|
||||
} = useTrafficPoolListLogic();
|
||||
|
||||
return (
|
||||
<Layout
|
||||
loading={loading}
|
||||
header={
|
||||
<>
|
||||
<NavCommon
|
||||
title="流量池用户列表"
|
||||
right={
|
||||
<Button
|
||||
onClick={() => setShowStats((s) => !s)}
|
||||
style={{ marginLeft: 8 }}
|
||||
>
|
||||
<BarChartOutlined /> {showStats ? "收起分析" : "数据分析"}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{/* 搜索栏 */}
|
||||
<div className="search-bar">
|
||||
<div className="search-input-wrapper">
|
||||
<Input
|
||||
placeholder="搜索计划名称"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
prefix={<SearchOutlined />}
|
||||
allowClear
|
||||
size="large"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={getList}
|
||||
loading={loading}
|
||||
size="large"
|
||||
icon={<ReloadOutlined />}
|
||||
></Button>
|
||||
</div>
|
||||
{/* 数据分析面板 */}
|
||||
<DataAnalysisPanel
|
||||
stats={stats}
|
||||
showStats={showStats}
|
||||
setShowStats={setShowStats}
|
||||
/>
|
||||
|
||||
{/* 批量操作栏 */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
padding: "8px 12px",
|
||||
background: "#fff",
|
||||
borderBottom: "1px solid #f0f0f0",
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedIds.length === list.length && list.length > 0}
|
||||
onChange={(e) => handleSelectAll(e.target.checked)}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
<span>全选</span>
|
||||
{selectedIds.length > 0 && (
|
||||
<>
|
||||
<span
|
||||
style={{ marginLeft: 16, color: "#1677ff" }}
|
||||
>{`已选${selectedIds.length}项`}</span>
|
||||
<Button
|
||||
size="small"
|
||||
color="primary"
|
||||
style={{ marginLeft: 16 }}
|
||||
onClick={() => setBatchModal(true)}
|
||||
>
|
||||
批量加入分组
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<div style={{ flex: 1 }} />
|
||||
<Button
|
||||
size="small"
|
||||
style={{ marginLeft: 8 }}
|
||||
onClick={() => setShowFilter(true)}
|
||||
>
|
||||
筛选
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{/* 批量加入分组弹窗 */}
|
||||
<BatchAddModal
|
||||
visible={batchModal}
|
||||
onClose={() => setBatchModal(false)}
|
||||
packageOptions={packageOptions}
|
||||
batchTarget={batchTarget}
|
||||
setBatchTarget={setBatchTarget}
|
||||
selectedCount={selectedIds.length}
|
||||
onConfirm={handleBatchAdd}
|
||||
/>
|
||||
{/* 筛选弹窗 */}
|
||||
<FilterModal
|
||||
visible={showFilter}
|
||||
onClose={() => setShowFilter(false)}
|
||||
deviceOptions={deviceOptions}
|
||||
packageOptions={packageOptions}
|
||||
deviceId={deviceId}
|
||||
setDeviceId={setDeviceId}
|
||||
packageId={packageId}
|
||||
setPackageId={setPackageId}
|
||||
valueLevel={valueLevel}
|
||||
setValueLevel={setValueLevel}
|
||||
userStatus={userStatus}
|
||||
setUserStatus={setUserStatus}
|
||||
onReset={resetFilter}
|
||||
/>
|
||||
<div className={styles.listWrap}>
|
||||
{list.length === 0 && !loading ? (
|
||||
<Empty description="暂无数据" />
|
||||
) : (
|
||||
<div>
|
||||
{list.map((item) => (
|
||||
<div key={item.id} className={styles.cardWrap}>
|
||||
<div
|
||||
className={styles.card}
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => navigate(`/traffic-pool/detail/${item.id}`)}
|
||||
>
|
||||
<div className={styles.cardContent}>
|
||||
<Checkbox
|
||||
checked={selectedIds.includes(item.id)}
|
||||
onChange={(e) => handleSelect(item.id, e.target.checked)}
|
||||
style={{ marginRight: 8 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className={styles.checkbox}
|
||||
/>
|
||||
<Avatar
|
||||
src={item.avatar || defaultAvatar}
|
||||
style={{ "--size": "60px" }}
|
||||
/>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div className={styles.title}>
|
||||
{item.nickname || item.identifier}
|
||||
{/* 性别icon可自行封装 */}
|
||||
</div>
|
||||
<div className={styles.desc}>
|
||||
微信号:{item.wechatId || "-"}
|
||||
</div>
|
||||
<div className={styles.desc}>
|
||||
来源:{item.fromd || "-"}
|
||||
</div>
|
||||
<div className={styles.desc}>
|
||||
分组:
|
||||
{item.packages && item.packages.length
|
||||
? item.packages.join(",")
|
||||
: "-"}
|
||||
</div>
|
||||
<div className={styles.desc}>
|
||||
创建时间:{item.createTime}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* 分页 */}
|
||||
{total > pageSize && (
|
||||
<div className={styles.pagination}>
|
||||
<button disabled={page === 1} onClick={() => setPage(page - 1)}>
|
||||
上一页
|
||||
</button>
|
||||
<span>
|
||||
{page} / {Math.ceil(total / pageSize)}
|
||||
</span>
|
||||
<button
|
||||
disabled={page === Math.ceil(total / pageSize)}
|
||||
onClick={() => setPage(page + 1)}
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default TrafficPoolList;
|
||||
|
||||
@@ -33,12 +33,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
padding: 0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.plan-list {
|
||||
display: flex;
|
||||
|
||||
@@ -381,7 +381,7 @@ const ScenarioList: React.FC = () => {
|
||||
size="small"
|
||||
onClick={handleRefresh}
|
||||
loading={loadingTasks}
|
||||
className={style["refresh-btn"]}
|
||||
className="refresh-btn"
|
||||
>
|
||||
<ReloadOutlined />
|
||||
</Button>
|
||||
|
||||
96
nkebao/src/pages/workspace/ai-analyzer/index.module.scss
Normal file
96
nkebao/src/pages/workspace/ai-analyzer/index.module.scss
Normal file
@@ -0,0 +1,96 @@
|
||||
.analyzerPage {
|
||||
|
||||
|
||||
}
|
||||
|
||||
.tabs {
|
||||
background: #fff;
|
||||
padding: 0 12px;
|
||||
border-radius: 0 0 12px 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.planList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 0 12px 16px 12px;
|
||||
}
|
||||
|
||||
.planCard {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
|
||||
padding: 16px 14px 12px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cardHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.cardTitle {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.statusDone {
|
||||
background: #e6f9e6;
|
||||
color: #22c55e;
|
||||
font-size: 12px;
|
||||
border-radius: 8px;
|
||||
padding: 2px 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.statusDoing {
|
||||
background: #e0f2fe;
|
||||
color: #1677ff;
|
||||
font-size: 12px;
|
||||
border-radius: 8px;
|
||||
padding: 2px 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cardInfo {
|
||||
font-size: 13px;
|
||||
color: #444;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: #888;
|
||||
font-size: 12px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.keyword {
|
||||
display: inline-block;
|
||||
background: #f3f4f6;
|
||||
color: #1677ff;
|
||||
border-radius: 6px;
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
margin-right: 6px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.cardActions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.actionBtn {
|
||||
border-radius: 6px !important;
|
||||
font-size: 13px !important;
|
||||
padding: 0 12px !important;
|
||||
}
|
||||
141
nkebao/src/pages/workspace/ai-analyzer/index.tsx
Normal file
141
nkebao/src/pages/workspace/ai-analyzer/index.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import React, { useState } from "react";
|
||||
import NavCommon from "@/components/NavCommon";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import { Tabs } from "antd-mobile";
|
||||
import { Button } from "antd";
|
||||
import styles from "./index.module.scss";
|
||||
import { PlusOutlined } from "@ant-design/icons";
|
||||
|
||||
const mockPlans = [
|
||||
{
|
||||
id: "1",
|
||||
title: "美妆用户分析",
|
||||
status: "done",
|
||||
device: "设备1",
|
||||
wechat: "wxid_abc123",
|
||||
type: "综合分析",
|
||||
keywords: ["美妆", "护肤", "彩妆"],
|
||||
createTime: "2023/12/15 18:30:00",
|
||||
finishTime: "2023/12/15 19:45:00",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
title: "健身爱好者分析",
|
||||
status: "doing",
|
||||
device: "设备2",
|
||||
wechat: "wxid_fit456",
|
||||
type: "好友信息分析",
|
||||
keywords: ["健身", "运动", "健康"],
|
||||
createTime: "2023/12/16 17:15:00",
|
||||
finishTime: "",
|
||||
},
|
||||
];
|
||||
|
||||
const statusMap = {
|
||||
all: "全部计划",
|
||||
doing: "进行中",
|
||||
done: "已完成",
|
||||
};
|
||||
|
||||
const statusTag = {
|
||||
done: <span className={styles.statusDone}>已完成</span>,
|
||||
doing: <span className={styles.statusDoing}>分析中</span>,
|
||||
};
|
||||
|
||||
const AiAnalyzer: React.FC = () => {
|
||||
const [tab, setTab] = useState<"all" | "doing" | "done">("all");
|
||||
|
||||
const filteredPlans =
|
||||
tab === "all" ? mockPlans : mockPlans.filter((p) => p.status === tab);
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<NavCommon
|
||||
title="AI数据分析"
|
||||
right={
|
||||
<Button type="primary" size="small" style={{ borderRadius: 6 }}>
|
||||
<PlusOutlined /> 新建计划
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className={styles.analyzerPage}>
|
||||
<Tabs
|
||||
activeKey={tab}
|
||||
onChange={(key) => setTab(key as any)}
|
||||
className={styles.tabs}
|
||||
>
|
||||
<Tabs.Tab title="全部计划" key="all" />
|
||||
<Tabs.Tab title="进行中" key="doing" />
|
||||
<Tabs.Tab title="已完成" key="done" />
|
||||
</Tabs>
|
||||
<div className={styles.planList}>
|
||||
{filteredPlans.map((plan) => (
|
||||
<div className={styles.planCard} key={plan.id}>
|
||||
<div className={styles.cardHeader}>
|
||||
<span className={styles.cardTitle}>{plan.title}</span>
|
||||
{statusTag[plan.status as "done" | "doing"]}
|
||||
</div>
|
||||
<div className={styles.cardInfo}>
|
||||
<div>
|
||||
<span className={styles.label}>设备:</span>
|
||||
{plan.device} | 微信号: {plan.wechat}
|
||||
</div>
|
||||
<div>
|
||||
<span className={styles.label}>分析类型:</span>
|
||||
{plan.type}
|
||||
</div>
|
||||
<div>
|
||||
<span className={styles.label}>关键词:</span>
|
||||
{plan.keywords.map((k) => (
|
||||
<span className={styles.keyword} key={k}>
|
||||
{k}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<span className={styles.label}>创建时间:</span>
|
||||
{plan.createTime}
|
||||
</div>
|
||||
{plan.status === "done" && (
|
||||
<div>
|
||||
<span className={styles.label}>完成时间:</span>
|
||||
{plan.finishTime}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.cardActions}>
|
||||
{plan.status === "done" ? (
|
||||
<>
|
||||
<Button size="small" className={styles.actionBtn}>
|
||||
发送报告
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
className={styles.actionBtn}
|
||||
>
|
||||
查看报告
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
className={styles.actionBtn}
|
||||
>
|
||||
查看进度
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default AiAnalyzer;
|
||||
139
nkebao/src/pages/workspace/ai-assistant/AIAssistant.module.scss
Normal file
139
nkebao/src/pages/workspace/ai-assistant/AIAssistant.module.scss
Normal file
@@ -0,0 +1,139 @@
|
||||
.chatContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.messageList {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px 12px 80px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.userMessage {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.aiMessage {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
max-width: 80%;
|
||||
padding: 10px 14px;
|
||||
border-radius: 18px;
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
word-break: break-word;
|
||||
background: #fff;
|
||||
color: #222;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
||||
}
|
||||
|
||||
.userMessage .bubble {
|
||||
background: linear-gradient(135deg, #a7e0ff 0%, #5bbcff 100%);
|
||||
color: #222;
|
||||
border-bottom-right-radius: 6px;
|
||||
}
|
||||
|
||||
.aiMessage .bubble {
|
||||
background: #fff;
|
||||
color: #222;
|
||||
border-bottom-left-radius: 6px;
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: 11px;
|
||||
color: #aaa;
|
||||
margin: 4px 8px 0 8px;
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.inputBar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: #fff;
|
||||
padding: 10px 12px 10px 12px;
|
||||
box-shadow: 0 -2px 8px rgba(0,0,0,0.04);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: #f3f4f6;
|
||||
border-radius: 18px;
|
||||
padding: 10px 14px;
|
||||
font-size: 15px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.sendButton {
|
||||
background: var(--primary-gradient, linear-gradient(135deg, #a7e0ff 0%, #5bbcff 100%));
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 18px;
|
||||
padding: 8px 18px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.sendButton:disabled {
|
||||
background: #e5e7eb;
|
||||
color: #aaa;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.iconBtn {
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
margin-right: 6px;
|
||||
font-size: 20px;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 50%;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.iconBtn:hover, .iconBtn:active {
|
||||
background: #f3f4f6;
|
||||
color: #5bbcff;
|
||||
}
|
||||
|
||||
.image {
|
||||
max-width: 180px;
|
||||
max-height: 180px;
|
||||
border-radius: 10px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.fileLink {
|
||||
color: #5bbcff;
|
||||
text-decoration: none;
|
||||
font-size: 15px;
|
||||
word-break: break-all;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nav-title {
|
||||
color: var(--primary-color);
|
||||
font-weight: 700;
|
||||
font-size: 18px;
|
||||
text-shadow: 0 2px 4px rgba(24, 142, 238, 0.2);
|
||||
}
|
||||
@@ -1,8 +1,264 @@
|
||||
import React from "react";
|
||||
import PlaceholderPage from "@/components/PlaceholderPage";
|
||||
import React, { useRef, useState, useEffect } from "react";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import NavCommon from "@/components/NavCommon";
|
||||
import {
|
||||
PictureOutlined,
|
||||
PaperClipOutlined,
|
||||
AudioOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import styles from "./AIAssistant.module.scss";
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
content: string;
|
||||
from: "user" | "ai";
|
||||
time: string;
|
||||
type?: "text" | "image" | "file" | "audio";
|
||||
fileName?: string;
|
||||
fileUrl?: string;
|
||||
}
|
||||
|
||||
const initialMessages: Message[] = [
|
||||
{
|
||||
id: "1",
|
||||
content: "你好!我是你的AI助手,有什么可以帮助你的吗?",
|
||||
from: "ai",
|
||||
time: "15:29",
|
||||
type: "text",
|
||||
},
|
||||
];
|
||||
|
||||
const AIAssistant: React.FC = () => {
|
||||
return <PlaceholderPage title="AI助手" showBack={false} />;
|
||||
const [messages, setMessages] = useState<Message[]>(initialMessages);
|
||||
const [input, setInput] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||
const [recognizing, setRecognizing] = useState(false);
|
||||
const recognitionRef = useRef<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages]);
|
||||
|
||||
// 语音识别初始化
|
||||
useEffect(() => {
|
||||
if (!("webkitSpeechRecognition" in window)) return;
|
||||
const SpeechRecognition = (window as any).webkitSpeechRecognition;
|
||||
recognitionRef.current = new SpeechRecognition();
|
||||
recognitionRef.current.continuous = false;
|
||||
recognitionRef.current.interimResults = false;
|
||||
recognitionRef.current.lang = "zh-CN";
|
||||
recognitionRef.current.onresult = (event: any) => {
|
||||
const transcript = event.results[0][0].transcript;
|
||||
setInput((prev) => prev + transcript);
|
||||
setRecognizing(false);
|
||||
};
|
||||
recognitionRef.current.onerror = () => setRecognizing(false);
|
||||
recognitionRef.current.onend = () => setRecognizing(false);
|
||||
}, []);
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!input.trim()) return;
|
||||
const userMsg: Message = {
|
||||
id: Date.now().toString(),
|
||||
content: input,
|
||||
from: "user",
|
||||
time: new Date().toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}),
|
||||
type: "text",
|
||||
};
|
||||
setMessages((prev) => [...prev, userMsg]);
|
||||
setInput("");
|
||||
setLoading(true);
|
||||
setTimeout(() => {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: Date.now().toString() + "-ai",
|
||||
content: "AI正在思考...(此处可接入真实API)",
|
||||
from: "ai",
|
||||
time: new Date().toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}),
|
||||
type: "text",
|
||||
},
|
||||
]);
|
||||
setLoading(false);
|
||||
}, 1200);
|
||||
};
|
||||
|
||||
// 图片上传
|
||||
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
const url = URL.createObjectURL(file);
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: Date.now().toString(),
|
||||
content: url,
|
||||
from: "user",
|
||||
time: new Date().toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}),
|
||||
type: "image",
|
||||
fileName: file.name,
|
||||
fileUrl: url,
|
||||
},
|
||||
]);
|
||||
}
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
// 文件上传
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
const url = URL.createObjectURL(file);
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: Date.now().toString(),
|
||||
content: file.name,
|
||||
from: "user",
|
||||
time: new Date().toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}),
|
||||
type: "file",
|
||||
fileName: file.name,
|
||||
fileUrl: url,
|
||||
},
|
||||
]);
|
||||
}
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
// 语音输入
|
||||
const handleVoiceInput = () => {
|
||||
if (!recognitionRef.current) return alert("当前浏览器不支持语音输入");
|
||||
if (recognizing) {
|
||||
recognitionRef.current.stop();
|
||||
setRecognizing(false);
|
||||
} else {
|
||||
recognitionRef.current.start();
|
||||
setRecognizing(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout header={<NavCommon title="AI助手" />} loading={false}>
|
||||
<div className={styles.chatContainer}>
|
||||
<div className={styles.messageList}>
|
||||
{messages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={
|
||||
msg.from === "user" ? styles.userMessage : styles.aiMessage
|
||||
}
|
||||
>
|
||||
{msg.type === "text" && (
|
||||
<div className={styles.bubble}>{msg.content}</div>
|
||||
)}
|
||||
{msg.type === "image" && (
|
||||
<div className={styles.bubble}>
|
||||
<img
|
||||
src={msg.fileUrl}
|
||||
alt={msg.fileName}
|
||||
className={styles.image}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{msg.type === "file" && (
|
||||
<div className={styles.bubble}>
|
||||
<a
|
||||
href={msg.fileUrl}
|
||||
download={msg.fileName}
|
||||
className={styles.fileLink}
|
||||
>
|
||||
<PaperClipOutlined style={{ marginRight: 6 }} />
|
||||
{msg.fileName}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{/* 语音消息可后续扩展 */}
|
||||
<div className={styles.time}>{msg.time}</div>
|
||||
</div>
|
||||
))}
|
||||
{loading && (
|
||||
<div className={styles.aiMessage}>
|
||||
<div className={styles.bubble}>AI正在输入...</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
<div className={styles.inputBar}>
|
||||
<button
|
||||
className={styles.iconBtn}
|
||||
onClick={() => imageInputRef.current?.click()}
|
||||
title="图片"
|
||||
type="button"
|
||||
>
|
||||
<PictureOutlined />
|
||||
</button>
|
||||
<input
|
||||
ref={imageInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: "none" }}
|
||||
onChange={handleImageChange}
|
||||
/>
|
||||
<button
|
||||
className={styles.iconBtn}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
title="文件"
|
||||
type="button"
|
||||
>
|
||||
<PaperClipOutlined />
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
style={{ display: "none" }}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<button
|
||||
className={styles.iconBtn}
|
||||
onClick={handleVoiceInput}
|
||||
title="语音输入"
|
||||
type="button"
|
||||
style={{ color: recognizing ? "#5bbcff" : undefined }}
|
||||
>
|
||||
<AudioOutlined />
|
||||
</button>
|
||||
<input
|
||||
className={styles.input}
|
||||
type="text"
|
||||
placeholder="输入消息..."
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleSend();
|
||||
}}
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
className={styles.sendButton}
|
||||
onClick={handleSend}
|
||||
disabled={loading || !input.trim()}
|
||||
>
|
||||
发送
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default AIAssistant;
|
||||
|
||||
@@ -29,7 +29,7 @@ const routes = [
|
||||
auth: true,
|
||||
},
|
||||
{
|
||||
path: "/traffic-pool/:id",
|
||||
path: "/traffic-pool/detail/:id",
|
||||
element: <TrafficPoolDetail />,
|
||||
auth: true,
|
||||
},
|
||||
|
||||
@@ -16,6 +16,7 @@ import TrafficDistribution from "@/pages/workspace/traffic-distribution/list/ind
|
||||
import TrafficDistributionDetail from "@/pages/workspace/traffic-distribution/detail/index";
|
||||
import NewDistribution from "@/pages/workspace/traffic-distribution/form/index";
|
||||
import PlaceholderPage from "@/components/PlaceholderPage";
|
||||
import AiAnalyzer from "@/pages/workspace/ai-analyzer";
|
||||
|
||||
const workspaceRoutes = [
|
||||
{
|
||||
@@ -116,7 +117,7 @@ const workspaceRoutes = [
|
||||
// AI数据分析
|
||||
{
|
||||
path: "/workspace/ai-analyzer",
|
||||
element: <PlaceholderPage title="AI数据分析" />,
|
||||
element: <AiAnalyzer />,
|
||||
auth: true,
|
||||
},
|
||||
// AI策略优化
|
||||
|
||||
Reference in New Issue
Block a user