diff --git a/nkebao/src/api/request.ts b/nkebao/src/api/request.ts index b52caa9c..1e57fac9 100644 --- a/nkebao/src/api/request.ts +++ b/nkebao/src/api/request.ts @@ -11,7 +11,7 @@ const debounceMap = new Map(); 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", }, diff --git a/nkebao/src/pages/content/list/index.module.scss b/nkebao/src/pages/content/list/index.module.scss index d7600b53..101d4e19 100644 --- a/nkebao/src/pages/content/list/index.module.scss +++ b/nkebao/src/pages/content/list/index.module.scss @@ -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; diff --git a/nkebao/src/pages/content/materials/list/index.module.scss b/nkebao/src/pages/content/materials/list/index.module.scss index bebcd3f4..3abd8d22 100644 --- a/nkebao/src/pages/content/materials/list/index.module.scss +++ b/nkebao/src/pages/content/materials/list/index.module.scss @@ -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; diff --git a/nkebao/src/pages/mine/traffic-pool/TrafficPool.tsx b/nkebao/src/pages/mine/traffic-pool/TrafficPool.tsx deleted file mode 100644 index d64714d2..00000000 --- a/nkebao/src/pages/mine/traffic-pool/TrafficPool.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import React from "react"; -import PlaceholderPage from "@/components/PlaceholderPage"; - -const TrafficPool: React.FC = () => { - return ( - - ); -}; - -export default TrafficPool; diff --git a/nkebao/src/pages/mine/traffic-pool/TrafficPoolDetail.tsx b/nkebao/src/pages/mine/traffic-pool/TrafficPoolDetail.tsx deleted file mode 100644 index d4c1ceef..00000000 --- a/nkebao/src/pages/mine/traffic-pool/TrafficPoolDetail.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import React from "react"; -import PlaceholderPage from "@/components/PlaceholderPage"; - -const TrafficPoolDetail: React.FC = () => { - return ; -}; - -export default TrafficPoolDetail; diff --git a/nkebao/src/pages/mine/traffic-pool/detail/api.ts b/nkebao/src/pages/mine/traffic-pool/detail/api.ts index 6b6377fe..13e3d535 100644 --- a/nkebao/src/pages/mine/traffic-pool/detail/api.ts +++ b/nkebao/src/pages/mine/traffic-pool/detail/api.ts @@ -1,5 +1,5 @@ import request from "@/api/request"; export function getTrafficPoolDetail(id: string): Promise { - return request("/v1/traffic/pool/detail", { id }, "GET"); + return request("/v1/workbench/detail", { id }, "GET"); } diff --git a/nkebao/src/pages/mine/traffic-pool/detail/data.ts b/nkebao/src/pages/mine/traffic-pool/detail/data.ts index e69de29b..f1c30d00 100644 --- a/nkebao/src/pages/mine/traffic-pool/detail/data.ts +++ b/nkebao/src/pages/mine/traffic-pool/detail/data.ts @@ -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; + }>; +} diff --git a/nkebao/src/pages/mine/traffic-pool/detail/index.tsx b/nkebao/src/pages/mine/traffic-pool/detail/index.tsx index bc908842..6a3b983c 100644 --- a/nkebao/src/pages/mine/traffic-pool/detail/index.tsx +++ b/nkebao/src/pages/mine/traffic-pool/detail/index.tsx @@ -1,3 +1,300 @@ -export default function TrafficPoolDetail() { - return
TrafficPoolDetail
; -} +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(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 ( + +
+ +
+
+ ); + } + if (!user) { + return ( + +
+ 未找到该用户 +
+
+ ); + } + + return ( + + +
用户详情
+ + } + > +
+ {/* 顶部信息 */} +
+ +
+
{user.nickname}
+
+ {user.wechatId} +
+ {user.packages && + user.packages.length > 0 && + user.packages.map((pkg) => ( + + {pkg} + + ))} +
+
+ {/* Tab栏 */} +
+ {tabList.map((tab) => ( +
setActiveTab(tab.key as any)} + > + {tab.label} +
+ ))} +
+ {/* Tab内容 */} + {activeTab === "base" && ( + <> + +
+
设备:{user.deviceName || "--"}
+
微信号:{user.wechatAccountName || "--"}
+
客服:{user.customerServiceName || "--"}
+
添加时间:{user.addTime || "--"}
+
最近互动:{user.lastInteraction || "--"}
+
+
+ +
+
+
+ {user.rfmScore?.recency ?? "-"} +
+
最近性(R)
+
+
+
+ {user.rfmScore?.frequency ?? "-"} +
+
频率(F)
+
+
+
+ {user.rfmScore?.monetary ?? "-"} +
+
金额(M)
+
+
+
+ +
+
+
+ ¥{user.totalSpent ?? "-"} +
+
总消费
+
+
+
+ {user.interactionCount ?? "-"} +
+
互动次数
+
+
+
+ {user.conversionRate ?? "-"} +
+
转化率
+
+
+
+ {user.status === "failed" + ? "添加失败" + : user.status === "added" + ? "添加成功" + : "未添加"} +
+
添加状态
+
+
+
+ + )} + {activeTab === "journey" && ( + + {user.interactions && user.interactions.length > 0 ? ( + user.interactions.slice(0, 4).map((it) => ( +
+
+ {it.type === "click" && "📱"} + {it.type === "message" && "💬"} + {it.type === "purchase" && "💲"} + {it.type === "view" && "👁️"} +
+
+
+ {it.type === "click" && "点击行为"} + {it.type === "message" && "消息互动"} + {it.type === "purchase" && "购买行为"} + {it.type === "view" && "页面浏览"} +
+
+ {it.content} + {it.type === "purchase" && it.value && ( + + ¥{it.value} + + )} +
+
+
+ {it.timestamp} +
+
+ )) + ) : ( +
+ 暂无互动记录 +
+ )} +
+ )} + {activeTab === "tags" && ( + +
+ {user.tags && user.tags.length > 0 ? ( + user.tags.map((tag) => ( + + {tag} + + )) + ) : ( + 暂无标签 + )} +
+ +
+ )} +
+
+ ); +}; + +export default TrafficPoolDetail; diff --git a/nkebao/src/pages/mine/traffic-pool/list/BatchAddModal.tsx b/nkebao/src/pages/mine/traffic-pool/list/BatchAddModal.tsx new file mode 100644 index 00000000..6fa79bfc --- /dev/null +++ b/nkebao/src/pages/mine/traffic-pool/list/BatchAddModal.tsx @@ -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 = ({ + visible, + onClose, + packageOptions, + batchTarget, + setBatchTarget, + selectedCount, + onConfirm, +}) => ( + +
+
选择目标分组
+ ({ label: p.name, value: p.id }))} + value={[batchTarget]} + onChange={(v) => setBatchTarget(v[0])} + /> +
+
+ 将选中的{selectedCount}个用户加入所选分组 +
+
+); + +export default BatchAddModal; diff --git a/nkebao/src/pages/mine/traffic-pool/list/DataAnalysisPanel.tsx b/nkebao/src/pages/mine/traffic-pool/list/DataAnalysisPanel.tsx new file mode 100644 index 00000000..9ba4fead --- /dev/null +++ b/nkebao/src/pages/mine/traffic-pool/list/DataAnalysisPanel.tsx @@ -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 = ({ + stats, + showStats, + setShowStats, +}) => { + if (!showStats) return null; + return ( +
+
+ +
+ {stats.total} +
+
总用户数
+
+ +
+ {stats.highValue} +
+
高价值用户
+
+
+
+ +
+ {stats.addSuccessRate}% +
+
添加成功率
+
+ +
+ {stats.added} +
+
已添加
+
+ +
+ {stats.pending} +
+
待添加
+
+ +
+ {stats.failed} +
+
添加失败
+
+
+ +
+ ); +}; + +export default DataAnalysisPanel; diff --git a/nkebao/src/pages/mine/traffic-pool/list/FilterModal.tsx b/nkebao/src/pages/mine/traffic-pool/list/FilterModal.tsx new file mode 100644 index 00000000..ab4badc4 --- /dev/null +++ b/nkebao/src/pages/mine/traffic-pool/list/FilterModal.tsx @@ -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 = ({ + visible, + onClose, + deviceOptions, + packageOptions, + deviceId, + setDeviceId, + packageId, + setPackageId, + valueLevel, + setValueLevel, + userStatus, + setUserStatus, + onReset, +}) => ( + +
+ 筛选选项 +
+
+
设备
+ ({ label: p.name, value: p.id })), + ]} + /> +
+
+
用户价值
+ setUserStatus(v as UserStatus)} + options={statusOptions} + /> +
+
+ + +
+
+); + +export default FilterModal; diff --git a/nkebao/src/pages/mine/traffic-pool/list/api.ts b/nkebao/src/pages/mine/traffic-pool/list/api.ts index e69de29b..b5b9bc36 100644 --- a/nkebao/src/pages/mine/traffic-pool/list/api.ts +++ b/nkebao/src/pages/mine/traffic-pool/list/api.ts @@ -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 { + 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 { + // TODO: 替换为真实接口 + return [ + { id: "pkg-1", name: "高价值客户池" }, + { id: "pkg-2", name: "测试流量池" }, + ]; +} diff --git a/nkebao/src/pages/mine/traffic-pool/list/data.ts b/nkebao/src/pages/mine/traffic-pool/list/data.ts index e69de29b..17e8face 100644 --- a/nkebao/src/pages/mine/traffic-pool/list/data.ts +++ b/nkebao/src/pages/mine/traffic-pool/list/data.ts @@ -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"; diff --git a/nkebao/src/pages/mine/traffic-pool/list/dataAnyx.tsx b/nkebao/src/pages/mine/traffic-pool/list/dataAnyx.tsx new file mode 100644 index 00000000..2bf1ad85 --- /dev/null +++ b/nkebao/src/pages/mine/traffic-pool/list/dataAnyx.tsx @@ -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([]); + 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([]); + const [packageOptions, setPackageOptions] = useState([]); + const [deviceId, setDeviceId] = useState("all"); + const [packageId, setPackageId] = useState("all"); + const [valueLevel, setValueLevel] = useState("all"); + const [userStatus, setUserStatus] = useState("all"); + + // 批量相关 + const [selectedIds, setSelectedIds] = useState([]); + const [batchModal, setBatchModal] = useState(false); + const [batchTarget, setBatchTarget] = useState(""); + + // 数据分析 + 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, + }; +} diff --git a/nkebao/src/pages/mine/traffic-pool/list/index.module.scss b/nkebao/src/pages/mine/traffic-pool/list/index.module.scss index e69de29b..c37654d8 100644 --- a/nkebao/src/pages/mine/traffic-pool/list/index.module.scss +++ b/nkebao/src/pages/mine/traffic-pool/list/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/nkebao/src/pages/mine/traffic-pool/list/index.tsx b/nkebao/src/pages/mine/traffic-pool/list/index.tsx index 94006840..afa631e4 100644 --- a/nkebao/src/pages/mine/traffic-pool/list/index.tsx +++ b/nkebao/src/pages/mine/traffic-pool/list/index.tsx @@ -1,3 +1,257 @@ -export default function TrafficPoolList() { - return
TrafficPoolList
; -} +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 ( + + setShowStats((s) => !s)} + style={{ marginLeft: 8 }} + > + {showStats ? "收起分析" : "数据分析"} + + } + /> + {/* 搜索栏 */} +
+
+ setSearch(e.target.value)} + prefix={} + allowClear + size="large" + /> +
+ +
+ {/* 数据分析面板 */} + + + {/* 批量操作栏 */} +
+ 0} + onChange={(e) => handleSelectAll(e.target.checked)} + style={{ marginRight: 8 }} + /> + 全选 + {selectedIds.length > 0 && ( + <> + {`已选${selectedIds.length}项`} + + + )} +
+ +
+ + } + > + {/* 批量加入分组弹窗 */} + setBatchModal(false)} + packageOptions={packageOptions} + batchTarget={batchTarget} + setBatchTarget={setBatchTarget} + selectedCount={selectedIds.length} + onConfirm={handleBatchAdd} + /> + {/* 筛选弹窗 */} + setShowFilter(false)} + deviceOptions={deviceOptions} + packageOptions={packageOptions} + deviceId={deviceId} + setDeviceId={setDeviceId} + packageId={packageId} + setPackageId={setPackageId} + valueLevel={valueLevel} + setValueLevel={setValueLevel} + userStatus={userStatus} + setUserStatus={setUserStatus} + onReset={resetFilter} + /> +
+ {list.length === 0 && !loading ? ( + + ) : ( +
+ {list.map((item) => ( +
+
navigate(`/traffic-pool/detail/${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} +
+
+
+
+
+ ))} +
+ )} +
+ {/* 分页 */} + {total > pageSize && ( +
+ + + {page} / {Math.ceil(total / pageSize)} + + +
+ )} + + ); +}; + +export default TrafficPoolList; diff --git a/nkebao/src/pages/scenarios/plan/list/index.module.scss b/nkebao/src/pages/scenarios/plan/list/index.module.scss index 4df03dc4..cfd64be7 100644 --- a/nkebao/src/pages/scenarios/plan/list/index.module.scss +++ b/nkebao/src/pages/scenarios/plan/list/index.module.scss @@ -33,12 +33,6 @@ } } -.refresh-btn { - height: 40px; - width: 40px; - padding: 0; - border-radius: 8px; -} .plan-list { display: flex; diff --git a/nkebao/src/pages/scenarios/plan/list/index.tsx b/nkebao/src/pages/scenarios/plan/list/index.tsx index 82daa1dd..552f7347 100644 --- a/nkebao/src/pages/scenarios/plan/list/index.tsx +++ b/nkebao/src/pages/scenarios/plan/list/index.tsx @@ -381,7 +381,7 @@ const ScenarioList: React.FC = () => { size="small" onClick={handleRefresh} loading={loadingTasks} - className={style["refresh-btn"]} + className="refresh-btn" > diff --git a/nkebao/src/pages/workspace/ai-analyzer/index.module.scss b/nkebao/src/pages/workspace/ai-analyzer/index.module.scss new file mode 100644 index 00000000..1fe010a2 --- /dev/null +++ b/nkebao/src/pages/workspace/ai-analyzer/index.module.scss @@ -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; +} \ No newline at end of file diff --git a/nkebao/src/pages/workspace/ai-analyzer/index.tsx b/nkebao/src/pages/workspace/ai-analyzer/index.tsx new file mode 100644 index 00000000..09224bd6 --- /dev/null +++ b/nkebao/src/pages/workspace/ai-analyzer/index.tsx @@ -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: 已完成, + doing: 分析中, +}; + +const AiAnalyzer: React.FC = () => { + const [tab, setTab] = useState<"all" | "doing" | "done">("all"); + + const filteredPlans = + tab === "all" ? mockPlans : mockPlans.filter((p) => p.status === tab); + + return ( + + 新建计划 + + } + /> + } + > +
+ setTab(key as any)} + className={styles.tabs} + > + + + + +
+ {filteredPlans.map((plan) => ( +
+
+ {plan.title} + {statusTag[plan.status as "done" | "doing"]} +
+
+
+ 设备: + {plan.device} | 微信号: {plan.wechat} +
+
+ 分析类型: + {plan.type} +
+
+ 关键词: + {plan.keywords.map((k) => ( + + {k} + + ))} +
+
+ 创建时间: + {plan.createTime} +
+ {plan.status === "done" && ( +
+ 完成时间: + {plan.finishTime} +
+ )} +
+
+ {plan.status === "done" ? ( + <> + + + + ) : ( + + )} +
+
+ ))} +
+
+
+ ); +}; + +export default AiAnalyzer; diff --git a/nkebao/src/pages/workspace/ai-assistant/AIAssistant.module.scss b/nkebao/src/pages/workspace/ai-assistant/AIAssistant.module.scss new file mode 100644 index 00000000..d5a7cd33 --- /dev/null +++ b/nkebao/src/pages/workspace/ai-assistant/AIAssistant.module.scss @@ -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); +} \ No newline at end of file diff --git a/nkebao/src/pages/workspace/ai-assistant/AIAssistant.tsx b/nkebao/src/pages/workspace/ai-assistant/AIAssistant.tsx index 2727f453..4e60367e 100644 --- a/nkebao/src/pages/workspace/ai-assistant/AIAssistant.tsx +++ b/nkebao/src/pages/workspace/ai-assistant/AIAssistant.tsx @@ -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 ; + const [messages, setMessages] = useState(initialMessages); + const [input, setInput] = useState(""); + const [loading, setLoading] = useState(false); + const messagesEndRef = useRef(null); + const fileInputRef = useRef(null); + const imageInputRef = useRef(null); + const [recognizing, setRecognizing] = useState(false); + const recognitionRef = useRef(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) => { + 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) => { + 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 ( + } loading={false}> +
+
+ {messages.map((msg) => ( +
+ {msg.type === "text" && ( +
{msg.content}
+ )} + {msg.type === "image" && ( +
+ {msg.fileName} +
+ )} + {msg.type === "file" && ( + + )} + {/* 语音消息可后续扩展 */} +
{msg.time}
+
+ ))} + {loading && ( +
+
AI正在输入...
+
+ )} +
+
+
+ + + + + + setInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleSend(); + }} + disabled={loading} + /> + +
+
+ + ); }; export default AIAssistant; diff --git a/nkebao/src/router/module/mine.tsx b/nkebao/src/router/module/mine.tsx index e6a8222b..a483e1d3 100644 --- a/nkebao/src/router/module/mine.tsx +++ b/nkebao/src/router/module/mine.tsx @@ -29,7 +29,7 @@ const routes = [ auth: true, }, { - path: "/traffic-pool/:id", + path: "/traffic-pool/detail/:id", element: , auth: true, }, diff --git a/nkebao/src/router/module/workspace.tsx b/nkebao/src/router/module/workspace.tsx index 7413452e..d0208186 100644 --- a/nkebao/src/router/module/workspace.tsx +++ b/nkebao/src/router/module/workspace.tsx @@ -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: , + element: , auth: true, }, // AI策略优化