feat: 本次提交更新内容如下
存一波
This commit is contained in:
@@ -1,21 +1,21 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: {
|
||||
browser: true,
|
||||
es2021: true,
|
||||
node: true,
|
||||
},
|
||||
extends: [
|
||||
'react-app',
|
||||
'plugin:react/recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:prettier/recommended',
|
||||
],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['react', '@typescript-eslint', 'prettier'],
|
||||
rules: {
|
||||
'prettier/prettier': 'warn',
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
'@typescript-eslint/no-unused-vars': 'warn',
|
||||
},
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: {
|
||||
browser: true,
|
||||
es2021: true,
|
||||
node: true,
|
||||
},
|
||||
extends: [
|
||||
'react-app',
|
||||
'plugin:react/recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:prettier/recommended',
|
||||
],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['react', '@typescript-eslint', 'prettier'],
|
||||
rules: {
|
||||
'prettier/prettier': 'warn',
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
'@typescript-eslint/no-unused-vars': 'warn',
|
||||
},
|
||||
};
|
||||
@@ -6,6 +6,12 @@ import {
|
||||
ClockCircleOutline,
|
||||
SendOutline,
|
||||
StarOutline,
|
||||
Bell,
|
||||
Smartphone,
|
||||
Users,
|
||||
Activity,
|
||||
MessageSquare,
|
||||
TrendingUp,
|
||||
} from "antd-mobile-icons";
|
||||
import MeauMobile from "@/components/MeauMobile/MeauMoible";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
@@ -23,160 +29,358 @@ const Home: React.FC = () => {
|
||||
const [todayStats, setTodayStats] = useState<any[]>([]);
|
||||
const [dashboard, setDashboard] = useState<any>({});
|
||||
const [sevenDayStats, setSevenDayStats] = useState<any>({});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [apiError, setApiError] = useState("");
|
||||
|
||||
// 场景获客数据
|
||||
const scenarioFeatures = [
|
||||
{
|
||||
id: "douyin",
|
||||
name: "抖音获客",
|
||||
icon: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-QR8ManuDplYTySUJsY4mymiZkDYnQ9.png",
|
||||
color: "bg-blue-100 text-blue-600",
|
||||
value: 156,
|
||||
growth: 12,
|
||||
},
|
||||
{
|
||||
id: "xiaohongshu",
|
||||
name: "小红书获客",
|
||||
icon: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-yvnMxpoBUzcvEkr8DfvHgPHEo1kmQ3.png",
|
||||
color: "bg-red-100 text-red-600",
|
||||
value: 89,
|
||||
growth: 8,
|
||||
},
|
||||
{
|
||||
id: "gongzhonghao",
|
||||
name: "公众号获客",
|
||||
icon: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-Gsg0CMf5tsZb41mioszdjqU1WmsRxW.png",
|
||||
color: "bg-green-100 text-green-600",
|
||||
value: 234,
|
||||
growth: 15,
|
||||
},
|
||||
{
|
||||
id: "haibao",
|
||||
name: "海报获客",
|
||||
icon: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-x92XJgXy4MI7moNYlA1EAes2FqDxMH.png",
|
||||
color: "bg-orange-100 text-orange-600",
|
||||
value: 167,
|
||||
growth: 10,
|
||||
},
|
||||
];
|
||||
|
||||
// 今日数据统计
|
||||
const todayStatsData = [
|
||||
{
|
||||
title: "朋友圈同步",
|
||||
value: "12",
|
||||
icon: <MessageSquare className="h-4 w-4" />,
|
||||
color: "text-purple-600",
|
||||
path: "/workspace/moments-sync",
|
||||
},
|
||||
{
|
||||
title: "群发任务",
|
||||
value: "8",
|
||||
icon: <Users className="h-4 w-4" />,
|
||||
color: "text-orange-600",
|
||||
path: "/workspace/group-push",
|
||||
},
|
||||
{
|
||||
title: "获客转化",
|
||||
value: "85%",
|
||||
icon: <TrendingUp className="h-4 w-4" />,
|
||||
color: "text-green-600",
|
||||
path: "/scenarios",
|
||||
},
|
||||
{
|
||||
title: "系统活跃度",
|
||||
value: "98%",
|
||||
icon: <Activity className="h-4 w-4" />,
|
||||
color: "text-blue-600",
|
||||
path: "/workspace",
|
||||
},
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
getDashboard().then((res: any) => {
|
||||
setDashboard(res);
|
||||
});
|
||||
getPlanStats({ num: 4 }).then((res: any) => {
|
||||
setSceneStats(res);
|
||||
});
|
||||
getSevenDayStats().then((res: any) => {
|
||||
setSevenDayStats(res);
|
||||
});
|
||||
getTodayStats().then((res: any) => {
|
||||
const todayStatsData = [
|
||||
{
|
||||
label: "同步朋友圈",
|
||||
value: res.momentsNum,
|
||||
icon: (
|
||||
<ClockCircleOutline style={{ fontSize: 16, color: "#ff6b35" }} />
|
||||
),
|
||||
color: "#ff6b35",
|
||||
},
|
||||
{
|
||||
label: "群发任务",
|
||||
value: res.groupPushNum,
|
||||
icon: <SendOutline style={{ fontSize: 16, color: "#ffd700" }} />,
|
||||
color: "#ffd700",
|
||||
},
|
||||
{
|
||||
label: "获客转化率",
|
||||
value: res.passRate,
|
||||
icon: <StarOutline style={{ fontSize: 16, color: "#4caf50" }} />,
|
||||
color: "#4caf50",
|
||||
},
|
||||
{
|
||||
label: "系统活跃度",
|
||||
value: res.sysActive,
|
||||
icon: (
|
||||
<ClockCircleOutline style={{ fontSize: 16, color: "#2196f3" }} />
|
||||
),
|
||||
color: "#2196f3",
|
||||
},
|
||||
];
|
||||
setTodayStats(todayStatsData);
|
||||
});
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setApiError("");
|
||||
|
||||
// 并行请求多个接口
|
||||
const [dashboardResult, planStatsResult, sevenDayResult, todayResult] =
|
||||
await Promise.allSettled([
|
||||
getDashboard(),
|
||||
getPlanStats({ num: 4 }),
|
||||
getSevenDayStats(),
|
||||
getTodayStats(),
|
||||
]);
|
||||
|
||||
// 处理仪表板数据
|
||||
if (dashboardResult.status === "fulfilled") {
|
||||
setDashboard(dashboardResult.value);
|
||||
} else {
|
||||
console.warn("仪表板API失败:", dashboardResult.reason);
|
||||
setApiError("API连接异常,显示默认数据");
|
||||
}
|
||||
|
||||
// 处理计划统计数据
|
||||
if (planStatsResult.status === "fulfilled") {
|
||||
setSceneStats(planStatsResult.value);
|
||||
} else {
|
||||
console.warn("计划统计API失败:", planStatsResult.reason);
|
||||
}
|
||||
|
||||
// 处理七天统计数据
|
||||
if (sevenDayResult.status === "fulfilled") {
|
||||
setSevenDayStats(sevenDayResult.value);
|
||||
} else {
|
||||
console.warn("七天统计API失败:", sevenDayResult.reason);
|
||||
}
|
||||
|
||||
// 处理今日统计数据
|
||||
if (todayResult.status === "fulfilled") {
|
||||
const todayStatsData = [
|
||||
{
|
||||
label: "同步朋友圈",
|
||||
value: todayResult.value.momentsNum,
|
||||
icon: (
|
||||
<ClockCircleOutline
|
||||
style={{ fontSize: 16, color: "#ff6b35" }}
|
||||
/>
|
||||
),
|
||||
color: "#ff6b35",
|
||||
},
|
||||
{
|
||||
label: "群发任务",
|
||||
value: todayResult.value.groupPushNum,
|
||||
icon: <SendOutline style={{ fontSize: 16, color: "#ffd700" }} />,
|
||||
color: "#ffd700",
|
||||
},
|
||||
{
|
||||
label: "获客转化率",
|
||||
value: todayResult.value.passRate,
|
||||
icon: <StarOutline style={{ fontSize: 16, color: "#4caf50" }} />,
|
||||
color: "#4caf50",
|
||||
},
|
||||
{
|
||||
label: "系统活跃度",
|
||||
value: todayResult.value.sysActive,
|
||||
icon: (
|
||||
<ClockCircleOutline
|
||||
style={{ fontSize: 16, color: "#2196f3" }}
|
||||
/>
|
||||
),
|
||||
color: "#2196f3",
|
||||
},
|
||||
];
|
||||
setTodayStats(todayStatsData);
|
||||
} else {
|
||||
console.warn("今日统计API失败:", todayResult.reason);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取数据失败:", error);
|
||||
setApiError(error instanceof Error ? error.message : "数据加载失败");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const handleDevicesClick = () => {
|
||||
// 导航到设备页面
|
||||
console.log("点击设备");
|
||||
};
|
||||
|
||||
const handleWechatClick = () => {
|
||||
// 导航到微信号页面
|
||||
console.log("点击微信号");
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<NavBar back={null} style={{ background: "#fff" }}>
|
||||
<div className={style["nav-title"]}>
|
||||
<span className={style["nav-text"]}>存客宝</span>
|
||||
</div>
|
||||
</NavBar>
|
||||
}
|
||||
footer={<MeauMobile />}
|
||||
loading={true}
|
||||
>
|
||||
<div className="bg-gray-50">
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div key={i} className="p-3 bg-white animate-pulse rounded-lg">
|
||||
<div className="h-4 bg-gray-200 rounded mb-2"></div>
|
||||
<div className="h-6 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<NavBar back={null} style={{ background: "#fff" }}>
|
||||
<div className={style["nav-title"]}>
|
||||
<span className={style["nav-text"]}>存客宝</span>
|
||||
<div className="flex items-center ml-auto">
|
||||
{apiError && (
|
||||
<div className="text-xs text-orange-600 bg-orange-50 px-2 py-1 rounded mr-2">
|
||||
API连接异常,显示默认数据
|
||||
</div>
|
||||
)}
|
||||
<button className="p-2 hover:bg-gray-100 rounded-full">
|
||||
<Bell className="h-5 w-5 text-gray-600" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</NavBar>
|
||||
}
|
||||
footer={<MeauMobile />}
|
||||
>
|
||||
<div className={style["home-page"]}>
|
||||
{/* 顶部统计卡片 */}
|
||||
<div className={style["stats-grid"]}>
|
||||
<div className={style["stat-card"]}>
|
||||
<div className={style["stat-icon"]}>
|
||||
<AppOutline
|
||||
style={{ fontSize: 18, color: "var(--primary-color)" }}
|
||||
/>
|
||||
</div>
|
||||
<div className={style["stat-content"]}>
|
||||
<div className={style["stat-label"]}>设备数量</div>
|
||||
|
||||
<div className={style["stat-value"]}>{dashboard.deviceNum}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={style["stat-card"]}>
|
||||
<div className={style["stat-icon"]}>
|
||||
<UserOutline
|
||||
style={{ fontSize: 18, color: "var(--primary-color)" }}
|
||||
/>
|
||||
</div>
|
||||
<div className={style["stat-content"]}>
|
||||
<div className={style["stat-label"]}>微信号</div>
|
||||
<div className={style["stat-value"]}>{dashboard.wechatNum}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={style["stat-card"]}>
|
||||
<div className={style["stat-icon"]}>
|
||||
<ClockCircleOutline
|
||||
style={{ fontSize: 18, color: "var(--primary-color)" }}
|
||||
/>
|
||||
</div>
|
||||
<div className={style["stat-content"]}>
|
||||
<div className={style["stat-label"]}>在线</div>
|
||||
<div className={style["stat-value"]}>
|
||||
{dashboard.aliveWechatNum}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 场景获客统计 */}
|
||||
<div className={style["section"]}>
|
||||
<div className={style["section-header"]}>
|
||||
<span className={style["section-title"]}>获客统计</span>
|
||||
</div>
|
||||
<div className={style["scene-grid"]}>
|
||||
{sceneStats.map((item) => (
|
||||
<div key={item.id} className={style["scene-item"]}>
|
||||
<div className={style["scene-icon"]}>
|
||||
<img
|
||||
src={item.image}
|
||||
alt={item.name}
|
||||
className={style["scene-image"]}
|
||||
/>
|
||||
<div className="bg-gray-50">
|
||||
<div className="p-4 space-y-4">
|
||||
{/* 统计卡片 */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="cursor-pointer" onClick={handleDevicesClick}>
|
||||
<div className="p-3 bg-white hover:shadow-md transition-all rounded-lg">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-gray-500 mb-1">设备数量</span>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-lg font-bold text-blue-600">
|
||||
{dashboard.deviceNum || 42}
|
||||
</span>
|
||||
<Smartphone className="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
<div className="h-2"></div>
|
||||
</div>
|
||||
<div className={style["scene-value"]}>{item.allNum}</div>
|
||||
<div className={style["scene-label"]}>{item.name}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="cursor-pointer" onClick={handleWechatClick}>
|
||||
<div className="p-3 bg-white hover:shadow-md transition-all rounded-lg">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-gray-500 mb-1">微信号数量</span>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-lg font-bold text-blue-600">
|
||||
{dashboard.wechatNum || 42}
|
||||
</span>
|
||||
<Users className="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 bg-white rounded-lg">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-gray-500 mb-1">在线微信号</span>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-lg font-bold text-blue-600">
|
||||
{dashboard.aliveWechatNum || 35}
|
||||
</span>
|
||||
<Activity className="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
<div className="h-1 bg-gray-200 rounded-full">
|
||||
<div
|
||||
className="h-1 bg-blue-600 rounded-full"
|
||||
style={{
|
||||
width: `${
|
||||
dashboard.wechatNum > 0
|
||||
? (dashboard.aliveWechatNum / dashboard.wechatNum) *
|
||||
100
|
||||
: 0
|
||||
}%`,
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 今日数据 */}
|
||||
<div className={style["section"]}>
|
||||
<div className={style["section-header"]}>
|
||||
<span className={style["section-title"]}>今日数据</span>
|
||||
{/* 场景获客统计 */}
|
||||
<div className="p-4 bg-white rounded-lg">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<h2 className="text-base font-semibold">场景获客统计</h2>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
{scenarioFeatures
|
||||
.sort((a, b) => b.value - a.value)
|
||||
.slice(0, 4) // 只显示前4个
|
||||
.map((scenario) => (
|
||||
<div
|
||||
key={scenario.id}
|
||||
className="block flex-1 cursor-pointer"
|
||||
>
|
||||
<div className="flex flex-col items-center text-center space-y-1">
|
||||
<div
|
||||
className={`w-10 h-10 rounded-full ${scenario.color} flex items-center justify-center`}
|
||||
>
|
||||
<img
|
||||
src={scenario.icon || "/placeholder.svg"}
|
||||
alt={scenario.name}
|
||||
className="w-5 h-5"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-sm font-medium">
|
||||
{scenario.value}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 whitespace-nowrap overflow-hidden text-ellipsis w-full">
|
||||
{scenario.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className={style["today-grid"]}>
|
||||
{todayStats.map((item) => (
|
||||
<div key={item.label} className={style["today-item"]}>
|
||||
<div className={style["today-icon"]}>{item.icon}</div>
|
||||
|
||||
{/* 今日数据统计 */}
|
||||
<div className="p-4 bg-white rounded-lg">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<h2 className="text-base font-semibold">今日数据</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{todayStatsData.map((stat, index) => (
|
||||
<div
|
||||
className={style["today-value"]}
|
||||
style={{ color: item.color }}
|
||||
key={index}
|
||||
className="flex items-center space-x-3 p-3 bg-gray-50 rounded-lg cursor-pointer hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
{item.value}
|
||||
<div className={`p-2 rounded-full bg-white ${stat.color}`}>
|
||||
{stat.icon}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-semibold">{stat.value}</div>
|
||||
<div className="text-xs text-gray-500">{stat.title}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={style["today-label"]}>{item.label}</div>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 趋势图表 */}
|
||||
<div className={style["section"]}>
|
||||
<div className={style["section-header"]}>
|
||||
<span className={style["section-title"]}>获客趋势</span>
|
||||
</div>
|
||||
<div className={style["chart-container"]}>
|
||||
<LineChart
|
||||
xData={sevenDayStats.date}
|
||||
yData={sevenDayStats.allNum}
|
||||
/>
|
||||
{/* 趋势图表 - 保持原有实现 */}
|
||||
<div className={style["section"]}>
|
||||
<div className={style["section-header"]}>
|
||||
<span className={style["section-title"]}>获客趋势</span>
|
||||
</div>
|
||||
<div className={style["chart-container"]}>
|
||||
<LineChart
|
||||
xData={sevenDayStats.date}
|
||||
yData={sevenDayStats.allNum}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Home;
|
||||
|
||||
@@ -1,27 +1,32 @@
|
||||
import request from "@/api/request";
|
||||
import { PlanDetail, PlanListResponse, ApiResponse } from "./data";
|
||||
|
||||
// ==================== 计划相关接口 ====================
|
||||
// 获取计划列表
|
||||
export function getPlanList(params: any) {
|
||||
export function getPlanList(params: {
|
||||
sceneId: string;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}): Promise<PlanListResponse> {
|
||||
return request(`/v1/plan/list`, params, "GET");
|
||||
}
|
||||
|
||||
// 获取计划详情
|
||||
export function getPlanDetail(planId: string) {
|
||||
return request(`/api/scenarios/plans/${planId}`, {}, "GET");
|
||||
export function getPlanDetail(planId: string): Promise<PlanDetail> {
|
||||
return request(`/v1/plan/detail`, { planId }, "GET");
|
||||
}
|
||||
|
||||
// 复制计划
|
||||
export function copyPlan(planId: string) {
|
||||
return request(`/api/scenarios/plans/${planId}/copy`, {}, "POST");
|
||||
export function copyPlan(planId: string): Promise<ApiResponse<any>> {
|
||||
return request(`/v1/plan/copy`, { planId }, "GET");
|
||||
}
|
||||
|
||||
// 删除计划
|
||||
export function deletePlan(planId: string) {
|
||||
return request(`/api/scenarios/plans/${planId}`, {}, "DELETE");
|
||||
export function deletePlan(planId: string): Promise<ApiResponse<any>> {
|
||||
return request(`/v1/plan/delete`, { planId }, "DELETE");
|
||||
}
|
||||
|
||||
// 获取小程序二维码
|
||||
export function getWxMinAppCode(planId: string) {
|
||||
return request(`/api/scenarios/plans/${planId}/qrcode`, {}, "GET");
|
||||
export function getWxMinAppCode(planId: string): Promise<ApiResponse<string>> {
|
||||
return request(`/v1/plan/getWxMinAppCode`, { taskId: planId }, "GET");
|
||||
}
|
||||
|
||||
@@ -27,3 +27,33 @@ export interface ApiSettings {
|
||||
webhookUrl: string;
|
||||
taskId: string;
|
||||
}
|
||||
|
||||
// API响应相关类型
|
||||
export interface TextUrl {
|
||||
apiKey: string;
|
||||
originalString?: string;
|
||||
sign?: string;
|
||||
fullUrl: string;
|
||||
}
|
||||
|
||||
export interface PlanDetail {
|
||||
id: number;
|
||||
name: string;
|
||||
scenario: number;
|
||||
enabled: boolean;
|
||||
status: number;
|
||||
apiKey: string;
|
||||
textUrl: TextUrl;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
code: number;
|
||||
msg?: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
export interface PlanListResponse {
|
||||
list: Task[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
@@ -172,6 +172,67 @@
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
// 加载更多按钮样式
|
||||
.load-more-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.load-more-btn {
|
||||
height: 44px;
|
||||
padding: 0 32px;
|
||||
border-radius: 22px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
// 没有更多数据提示样式
|
||||
.no-more-data {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 20px 0;
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
|
||||
span {
|
||||
position: relative;
|
||||
padding: 0 20px;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 40px;
|
||||
height: 1px;
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: -50px;
|
||||
}
|
||||
|
||||
&::after {
|
||||
right: -50px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.action-menu-dialog {
|
||||
background: white;
|
||||
border-radius: 16px 16px 0 0;
|
||||
@@ -270,12 +331,61 @@
|
||||
}
|
||||
}
|
||||
|
||||
// API设置相关样式
|
||||
.api-tip {
|
||||
margin-top: 12px;
|
||||
padding: 12px;
|
||||
background-color: #fff7e6;
|
||||
border: 1px solid #ffd591;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
color: #d46b08;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.api-params {
|
||||
margin-top: 16px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.param-section {
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
border: 1px solid #e9ecef;
|
||||
|
||||
h4 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
.param-list {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
line-height: 1.6;
|
||||
|
||||
div {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
code {
|
||||
background-color: #e9ecef;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
.qr-dialog {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
width: 300px;
|
||||
max-width: 90vw;
|
||||
}
|
||||
|
||||
.qr-loading {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import { useParams, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
NavBar,
|
||||
List,
|
||||
Button,
|
||||
Toast,
|
||||
SpinLoading,
|
||||
@@ -10,13 +9,10 @@ import {
|
||||
Popup,
|
||||
Card,
|
||||
Tag,
|
||||
Space,
|
||||
} from "antd-mobile";
|
||||
import { Input } from "antd";
|
||||
import {
|
||||
PlusOutlined,
|
||||
UserOutlined,
|
||||
CalendarOutlined,
|
||||
CopyOutlined,
|
||||
DeleteOutlined,
|
||||
SettingOutlined,
|
||||
@@ -26,10 +22,10 @@ import {
|
||||
EditOutlined,
|
||||
MoreOutlined,
|
||||
ClockCircleOutlined,
|
||||
DownOutlined,
|
||||
} from "@ant-design/icons";
|
||||
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import MeauMobile from "@/components/MeauMobile/MeauMoible";
|
||||
import {
|
||||
getPlanList,
|
||||
getPlanDetail,
|
||||
@@ -38,7 +34,7 @@ import {
|
||||
getWxMinAppCode,
|
||||
} from "./api";
|
||||
import style from "./index.module.scss";
|
||||
import { Task, ApiSettings } from "./data";
|
||||
import { Task, ApiSettings, PlanDetail } from "./data";
|
||||
|
||||
const ScenarioList: React.FC = () => {
|
||||
const { scenarioId, scenarioName } = useParams<{
|
||||
@@ -46,8 +42,6 @@ const ScenarioList: React.FC = () => {
|
||||
scenarioName: string;
|
||||
}>();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [pageTitle, setPageTitle] = useState<string>("");
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showApiDialog, setShowApiDialog] = useState(false);
|
||||
@@ -60,9 +54,16 @@ const ScenarioList: React.FC = () => {
|
||||
const [loadingTasks, setLoadingTasks] = useState(false);
|
||||
const [showQrDialog, setShowQrDialog] = useState(false);
|
||||
const [qrLoading, setQrLoading] = useState(false);
|
||||
const [qrImg, setQrImg] = useState("");
|
||||
const [qrImg, setQrImg] = useState<any>("");
|
||||
const [showActionMenu, setShowActionMenu] = useState<string | null>(null);
|
||||
|
||||
// 分页相关状态
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [total, setTotal] = useState(0);
|
||||
const pageSize = 20;
|
||||
|
||||
// 获取渠道中文名称
|
||||
const getChannelName = (channel: string) => {
|
||||
const channelMap: Record<string, string> = {
|
||||
@@ -80,21 +81,62 @@ const ScenarioList: React.FC = () => {
|
||||
return channelMap[channel] || `${channel}获客`;
|
||||
};
|
||||
|
||||
// 获取计划列表数据
|
||||
const fetchPlanList = async (page: number, isLoadMore: boolean = false) => {
|
||||
if (!scenarioId) return;
|
||||
|
||||
if (isLoadMore) {
|
||||
setLoadingMore(true);
|
||||
} else {
|
||||
setLoadingTasks(true);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await getPlanList({
|
||||
sceneId: scenarioId,
|
||||
page: page,
|
||||
pageSize: pageSize,
|
||||
});
|
||||
|
||||
if (response && response.list) {
|
||||
if (isLoadMore) {
|
||||
// 加载更多时,追加数据
|
||||
setTasks((prev) => [...prev, ...response.list]);
|
||||
} else {
|
||||
// 首次加载或刷新时,替换数据
|
||||
setTasks(response.list);
|
||||
}
|
||||
|
||||
// 更新分页信息
|
||||
setTotal(response.total || 0);
|
||||
setHasMore(response.list.length === pageSize);
|
||||
setCurrentPage(page);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取计划列表失败:", error);
|
||||
if (!isLoadMore) {
|
||||
setTasks([]);
|
||||
}
|
||||
Toast.show({
|
||||
content: "获取数据失败",
|
||||
position: "top",
|
||||
});
|
||||
} finally {
|
||||
if (isLoadMore) {
|
||||
setLoadingMore(false);
|
||||
} else {
|
||||
setLoadingTasks(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchScenarioData = async () => {
|
||||
if (!scenarioId) return;
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// 获取计划列表
|
||||
const response = await getPlanList({
|
||||
sceneId: scenarioId,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
});
|
||||
|
||||
// 设置计划列表
|
||||
setTasks(response.list);
|
||||
await fetchPlanList(1, false);
|
||||
} catch (error) {
|
||||
console.error("获取场景数据失败:", error);
|
||||
setTasks([]);
|
||||
@@ -106,16 +148,30 @@ const ScenarioList: React.FC = () => {
|
||||
fetchScenarioData();
|
||||
}, [scenarioId]);
|
||||
|
||||
// 加载下一页数据
|
||||
const handleLoadMore = async () => {
|
||||
if (loadingMore || !hasMore) return;
|
||||
await fetchPlanList(currentPage + 1, true);
|
||||
};
|
||||
|
||||
const handleCopyPlan = async (taskId: string) => {
|
||||
const taskToCopy = tasks.find((task) => task.id === taskId);
|
||||
if (!taskToCopy) return;
|
||||
await copyPlan(taskId);
|
||||
Toast.show({
|
||||
content: `已成功复制"${taskToCopy.name}"`,
|
||||
position: "top",
|
||||
});
|
||||
// 刷新列表
|
||||
handleRefresh();
|
||||
|
||||
try {
|
||||
await copyPlan(taskId);
|
||||
Toast.show({
|
||||
content: `已成功复制"${taskToCopy.name}"`,
|
||||
position: "top",
|
||||
});
|
||||
// 刷新列表
|
||||
handleRefresh();
|
||||
} catch (error) {
|
||||
Toast.show({
|
||||
content: "复制失败,请重试",
|
||||
position: "top",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeletePlan = async (taskId: string) => {
|
||||
@@ -130,20 +186,13 @@ const ScenarioList: React.FC = () => {
|
||||
|
||||
if (result) {
|
||||
try {
|
||||
const response = await deletePlan(taskId);
|
||||
if (response && response.code === 200) {
|
||||
Toast.show({
|
||||
content: "计划已删除",
|
||||
position: "top",
|
||||
});
|
||||
// 刷新列表
|
||||
handleRefresh();
|
||||
} else {
|
||||
Toast.show({
|
||||
content: response?.msg || "删除失败",
|
||||
position: "top",
|
||||
});
|
||||
}
|
||||
await deletePlan(taskId);
|
||||
Toast.show({
|
||||
content: "计划已删除",
|
||||
position: "top",
|
||||
});
|
||||
// 刷新列表
|
||||
handleRefresh();
|
||||
} catch (error) {
|
||||
Toast.show({
|
||||
content: "删除失败,请重试",
|
||||
@@ -155,12 +204,13 @@ const ScenarioList: React.FC = () => {
|
||||
|
||||
const handleOpenApiSettings = async (taskId: string) => {
|
||||
try {
|
||||
const response = await getPlanDetail(taskId);
|
||||
if (response && response.code === 200 && response.data) {
|
||||
const detail = response.data;
|
||||
const response: PlanDetail = await getPlanDetail(taskId);
|
||||
if (response) {
|
||||
setCurrentApiSettings({
|
||||
apiKey: detail.apiKey || "",
|
||||
webhookUrl: detail.webhookUrl || "",
|
||||
apiKey: response.apiKey || "demo-api-key-123456",
|
||||
webhookUrl:
|
||||
response.textUrl?.fullUrl ||
|
||||
`https://api.example.com/webhook/${taskId}`,
|
||||
taskId: taskId,
|
||||
});
|
||||
setShowApiDialog(true);
|
||||
@@ -188,17 +238,11 @@ const ScenarioList: React.FC = () => {
|
||||
const handleShowQrCode = async (taskId: string) => {
|
||||
setQrLoading(true);
|
||||
setShowQrDialog(true);
|
||||
setQrImg("");
|
||||
|
||||
try {
|
||||
const response = await getWxMinAppCode(taskId);
|
||||
if (response && response.code === 200 && response.data) {
|
||||
setQrImg(response.data.qrCode || "");
|
||||
} else {
|
||||
Toast.show({
|
||||
content: "获取二维码失败",
|
||||
position: "top",
|
||||
});
|
||||
}
|
||||
setQrImg(response);
|
||||
} catch (error) {
|
||||
Toast.show({
|
||||
content: "获取二维码失败",
|
||||
@@ -236,49 +280,16 @@ const ScenarioList: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setLoadingTasks(true);
|
||||
try {
|
||||
const response = await getPlanList({
|
||||
sceneId: scenarioId!,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
});
|
||||
setTasks(response.list);
|
||||
} catch (error) {
|
||||
Toast.show({
|
||||
content: "刷新失败",
|
||||
position: "top",
|
||||
});
|
||||
} finally {
|
||||
setLoadingTasks(false);
|
||||
}
|
||||
// 重置分页状态
|
||||
setCurrentPage(1);
|
||||
setHasMore(true);
|
||||
await fetchPlanList(1, false);
|
||||
};
|
||||
|
||||
const filteredTasks = tasks.filter((task) =>
|
||||
task.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
// 计算通过率
|
||||
const calculatePassRate = (acquired: number, added: number) => {
|
||||
if (added === 0) return "0.00%";
|
||||
return `${((acquired / added) * 100).toFixed(2)}%`;
|
||||
};
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (timeString: string) => {
|
||||
if (!timeString) return "";
|
||||
const date = new Date(timeString);
|
||||
return date
|
||||
.toLocaleString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
.replace(/\//g, "-");
|
||||
};
|
||||
|
||||
// 生成操作菜单
|
||||
const getActionMenu = (task: Task) => [
|
||||
{
|
||||
@@ -398,61 +409,98 @@ const ScenarioList: React.FC = () => {
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
filteredTasks.map((task) => (
|
||||
<Card key={task.id} className={style["plan-item"]}>
|
||||
{/* 头部:标题、状态和操作菜单 */}
|
||||
<div className={style["plan-header"]}>
|
||||
<div className={style["plan-name"]}>{task.name}</div>
|
||||
<div className={style["plan-header-right"]}>
|
||||
<Tag color={getStatusColor(task.status)}>
|
||||
{getStatusText(task.status)}
|
||||
</Tag>
|
||||
<Button
|
||||
size="mini"
|
||||
fill="none"
|
||||
className={style["more-btn"]}
|
||||
onClick={() => setShowActionMenu(task.id)}
|
||||
>
|
||||
<MoreOutlined />
|
||||
</Button>
|
||||
<>
|
||||
{filteredTasks.map((task) => (
|
||||
<Card key={task.id} className={style["plan-item"]}>
|
||||
{/* 头部:标题、状态和操作菜单 */}
|
||||
<div className={style["plan-header"]}>
|
||||
<div className={style["plan-name"]}>{task.name}</div>
|
||||
<div className={style["plan-header-right"]}>
|
||||
<Tag color={getStatusColor(task.status)}>
|
||||
{getStatusText(task.status)}
|
||||
</Tag>
|
||||
<Button
|
||||
size="mini"
|
||||
fill="none"
|
||||
className={style["more-btn"]}
|
||||
onClick={() => setShowActionMenu(task.id)}
|
||||
>
|
||||
<MoreOutlined />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 统计数据网格 */}
|
||||
<div className={style["stats-grid"]}>
|
||||
<div className={style["stat-item"]}>
|
||||
<div className={style["stat-label"]}>设备数</div>
|
||||
<div className={style["stat-value"]}>
|
||||
{deviceCount(task)}
|
||||
{/* 统计数据网格 */}
|
||||
<div className={style["stats-grid"]}>
|
||||
<div className={style["stat-item"]}>
|
||||
<div className={style["stat-label"]}>设备数</div>
|
||||
<div className={style["stat-value"]}>
|
||||
{deviceCount(task)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={style["stat-item"]}>
|
||||
<div className={style["stat-label"]}>已获客</div>
|
||||
<div className={style["stat-value"]}>
|
||||
{task?.acquiredCount || 0}
|
||||
</div>
|
||||
</div>
|
||||
<div className={style["stat-item"]}>
|
||||
<div className={style["stat-label"]}>已添加</div>
|
||||
<div className={style["stat-value"]}>
|
||||
{task.addedCount || 0}
|
||||
</div>
|
||||
</div>
|
||||
<div className={style["stat-item"]}>
|
||||
<div className={style["stat-label"]}>通过率</div>
|
||||
<div className={style["stat-value"]}>
|
||||
{task.passRate}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={style["stat-item"]}>
|
||||
<div className={style["stat-label"]}>已获客</div>
|
||||
<div className={style["stat-value"]}>
|
||||
{task?.acquiredCount || 0}
|
||||
</div>
|
||||
</div>
|
||||
<div className={style["stat-item"]}>
|
||||
<div className={style["stat-label"]}>已添加</div>
|
||||
<div className={style["stat-value"]}>
|
||||
{task.addedCount || 0}
|
||||
</div>
|
||||
</div>
|
||||
<div className={style["stat-item"]}>
|
||||
<div className={style["stat-label"]}>通过率</div>
|
||||
<div className={style["stat-value"]}>{task.passRate}%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部:上次执行时间 */}
|
||||
<div className={style["plan-footer"]}>
|
||||
<div className={style["last-execution"]}>
|
||||
<ClockCircleOutlined />
|
||||
<span>上次执行: {task.lastUpdated || "--"}</span>
|
||||
{/* 底部:上次执行时间 */}
|
||||
<div className={style["plan-footer"]}>
|
||||
<div className={style["last-execution"]}>
|
||||
<ClockCircleOutlined />
|
||||
<span>上次执行: {task.lastUpdated || "--"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{/* 加载更多按钮 */}
|
||||
{hasMore && (
|
||||
<div className={style["load-more-container"]}>
|
||||
<Button
|
||||
color="primary"
|
||||
fill="outline"
|
||||
size="large"
|
||||
onClick={handleLoadMore}
|
||||
loading={loadingMore}
|
||||
className={style["load-more-btn"]}
|
||||
>
|
||||
{loadingMore ? (
|
||||
<>
|
||||
<SpinLoading color="primary" />
|
||||
加载中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<DownOutlined />
|
||||
加载更多
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
|
||||
{/* 没有更多数据提示 */}
|
||||
{!hasMore && filteredTasks.length > 0 && (
|
||||
<div className={style["no-more-data"]}>
|
||||
<span>没有更多数据了</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -461,7 +509,7 @@ const ScenarioList: React.FC = () => {
|
||||
visible={showApiDialog}
|
||||
onMaskClick={() => setShowApiDialog(false)}
|
||||
position="bottom"
|
||||
bodyStyle={{ height: "60vh" }}
|
||||
bodyStyle={{ height: "70vh" }}
|
||||
>
|
||||
<div className={style["api-dialog"]}>
|
||||
<div className={style["dialog-header"]}>
|
||||
@@ -482,6 +530,10 @@ const ScenarioList: React.FC = () => {
|
||||
复制
|
||||
</Button>
|
||||
</div>
|
||||
<div className={style["api-tip"]}>
|
||||
<strong>安全提示:</strong>
|
||||
请妥善保管API密钥,不要在客户端代码中暴露。建议在服务器端使用该密钥。
|
||||
</div>
|
||||
</div>
|
||||
<div className={style["api-item"]}>
|
||||
<label>Webhook URL:</label>
|
||||
@@ -496,6 +548,33 @@ const ScenarioList: React.FC = () => {
|
||||
复制
|
||||
</Button>
|
||||
</div>
|
||||
<div className={style["api-params"]}>
|
||||
<div className={style["param-section"]}>
|
||||
<h4>必要参数</h4>
|
||||
<div className={style["param-list"]}>
|
||||
<div>
|
||||
<code>name</code> - 客户姓名
|
||||
</div>
|
||||
<div>
|
||||
<code>phone</code> - 手机号码
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={style["param-section"]}>
|
||||
<h4>可选参数</h4>
|
||||
<div className={style["param-list"]}>
|
||||
<div>
|
||||
<code>source</code> - 来源标识
|
||||
</div>
|
||||
<div>
|
||||
<code>remark</code> - 备注信息
|
||||
</div>
|
||||
<div>
|
||||
<code>tags</code> - 客户标签
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user