feat: 本次提交更新内容如下
迁移到pc端
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# 基础环境变量示例
|
||||
# VITE_API_BASE_URL=http://www.yishi.com
|
||||
VITE_API_BASE_URL=https://ckbapi.quwanzhi.com
|
||||
VITE_API_BASE_URL=http://www.yishi.com
|
||||
# VITE_API_BASE_URL=https://ckbapi.quwanzhi.com
|
||||
|
||||
VITE_APP_TITLE=Nkebao Base
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |
26
nkebao/src/pages/mobile/scenarios/list/api.ts
Normal file
26
nkebao/src/pages/mobile/scenarios/list/api.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import request from '@/api/request';
|
||||
|
||||
// 获取场景列表
|
||||
export function getScenarios(params: any) {
|
||||
return request('/v1/plan/scenes', params, 'GET');
|
||||
}
|
||||
|
||||
// 获取场景详情
|
||||
export function getScenarioDetail(id: string) {
|
||||
return request(`/v1/scenarios/${id}`, {}, 'GET');
|
||||
}
|
||||
|
||||
// 创建场景
|
||||
export function createScenario(data: any) {
|
||||
return request('/v1/scenarios', data, 'POST');
|
||||
}
|
||||
|
||||
// 更新场景
|
||||
export function updateScenario(id: string, data: any) {
|
||||
return request(`/v1/scenarios/${id}`, data, 'PUT');
|
||||
}
|
||||
|
||||
// 删除场景
|
||||
export function deleteScenario(id: string) {
|
||||
return request(`/v1/scenarios/${id}`, {}, 'DELETE');
|
||||
}
|
||||
322
nkebao/src/pages/mobile/scenarios/list/index.module.scss
Normal file
322
nkebao/src/pages/mobile/scenarios/list/index.module.scss
Normal file
@@ -0,0 +1,322 @@
|
||||
// 导航栏样式
|
||||
.nav-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.nav-text {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.nav-right {
|
||||
margin-left: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.new-plan-btn {
|
||||
border-radius: 20px;
|
||||
padding: 4px 12px;
|
||||
height: 32px;
|
||||
font-size: 12px;
|
||||
background: var(--primary-gradient);
|
||||
border: none;
|
||||
box-shadow: 0 2px 8px var(--primary-shadow);
|
||||
|
||||
&:active {
|
||||
transform: translateY(1px);
|
||||
box-shadow: 0 1px 4px var(--primary-shadow);
|
||||
}
|
||||
}
|
||||
|
||||
// 页面容器
|
||||
.scene-page {
|
||||
background: #f5f6fa;
|
||||
min-height: 100vh;
|
||||
padding: 0 0 60px 0;
|
||||
}
|
||||
|
||||
// 错误提示
|
||||
.error-notice {
|
||||
margin-bottom: 12px;
|
||||
padding: 8px 12px;
|
||||
background: #fff2e8;
|
||||
border: 1px solid #ffd591;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 4px rgba(255, 213, 145, 0.2);
|
||||
}
|
||||
|
||||
.error-notice-text {
|
||||
font-size: 12px;
|
||||
color: #d46b08;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
// 加载状态
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 200px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
// 错误状态
|
||||
.error-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 200px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
font-size: 14px;
|
||||
color: #ff4d4f;
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.retry-button {
|
||||
min-width: 100px;
|
||||
border-radius: 20px;
|
||||
background: var(--primary-gradient);
|
||||
border: none;
|
||||
box-shadow: 0 2px 8px var(--primary-shadow);
|
||||
}
|
||||
|
||||
// 页面头部
|
||||
.scene-header {
|
||||
margin-bottom: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--primary-color);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
font-size: 20px;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.header-subtitle {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
// 场景列表
|
||||
.scenarios-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
// 场景卡片
|
||||
.scenario-item {
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.scenarios-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.scenario-card {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
|
||||
transition: box-shadow 0.2s, transform 0.2s;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
&:hover {
|
||||
box-shadow: 0 6px 16px rgba(0,0,0,0.12);
|
||||
transform: translateY(-2px) scale(1.02);
|
||||
}
|
||||
}
|
||||
|
||||
.card-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 18px 10px 14px 10px;
|
||||
}
|
||||
|
||||
.card-img-wrap {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.card-img-bg {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: #f0f2f5;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.card-img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1677ff;
|
||||
text-align: center;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.card-desc {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.card-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.card-count {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
.card-growth {
|
||||
font-size: 12px;
|
||||
color: #52c41a;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
// 空状态
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 36px;
|
||||
margin-bottom: 12px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin-bottom: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.empty-action {
|
||||
border-radius: 20px;
|
||||
background: var(--primary-gradient);
|
||||
border: none;
|
||||
box-shadow: 0 2px 8px var(--primary-shadow);
|
||||
padding: 6px 16px;
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 480px) {
|
||||
|
||||
.scenario-card {
|
||||
padding: 14px 16px;
|
||||
min-height: 70px;
|
||||
}
|
||||
|
||||
.scenario-icon {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
}
|
||||
|
||||
.scenario-image {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.scenario-name {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.stat-text {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.scenario-growth {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.growth-icon {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
.scenarios-grid {
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
}
|
||||
.card-inner {
|
||||
padding: 12px 4px 10px 4px;
|
||||
}
|
||||
.card-img-bg {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
}
|
||||
.card-img {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
.card-title {
|
||||
font-size: 15px;
|
||||
}
|
||||
.card-desc {
|
||||
font-size: 11px;
|
||||
}
|
||||
.card-count {
|
||||
font-size: 12px;
|
||||
}
|
||||
.card-growth {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
172
nkebao/src/pages/mobile/scenarios/list/index.tsx
Normal file
172
nkebao/src/pages/mobile/scenarios/list/index.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { NavBar, Button, Toast } from "antd-mobile";
|
||||
import { PlusOutlined, RiseOutlined } from "@ant-design/icons";
|
||||
import MeauMobile from "@/components/MeauMobile/MeauMoible";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import { getScenarios } from "./api";
|
||||
import style from "./index.module.scss";
|
||||
|
||||
interface Scenario {
|
||||
id: string;
|
||||
name: string;
|
||||
image: string;
|
||||
description?: string;
|
||||
count: number;
|
||||
growth: string;
|
||||
status: number;
|
||||
}
|
||||
|
||||
const scenarioDescriptions: Record<string, string> = {
|
||||
douyin: "通过抖音平台进行精准获客",
|
||||
xiaohongshu: "利用小红书平台进行内容营销获客",
|
||||
gongzhonghao: "通过微信公众号进行获客",
|
||||
haibao: "通过海报分享进行获客",
|
||||
phone: "通过电话营销进行获客",
|
||||
weixinqun: "通过微信群进行获客",
|
||||
payment: "通过付款码进行获客",
|
||||
api: "通过API接口进行获客",
|
||||
};
|
||||
|
||||
const Scene: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [scenarios, setScenarios] = useState<Scenario[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const fetchScenarios = async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const response = await getScenarios({ page: 1, limit: 20 });
|
||||
const transformedScenarios: Scenario[] = response.map((item: any) => ({
|
||||
id: item.id.toString(),
|
||||
name: item.name,
|
||||
image:
|
||||
item.image ||
|
||||
"https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-api.png",
|
||||
description:
|
||||
scenarioDescriptions[item.name?.toLowerCase()] ||
|
||||
"通过该平台进行获客",
|
||||
count: item.count,
|
||||
growth: item.growth,
|
||||
status: item.status,
|
||||
}));
|
||||
setScenarios(transformedScenarios);
|
||||
} catch (error) {
|
||||
setError("获取场景数据失败,请稍后重试");
|
||||
Toast.show({
|
||||
content: "获取场景数据失败,请稍后重试",
|
||||
position: "top",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchScenarios();
|
||||
}, []);
|
||||
|
||||
const handleScenarioClick = (scenarioId: string, scenarioName: string) => {
|
||||
navigate(
|
||||
`/scenarios/list/${scenarioId}/${encodeURIComponent(scenarioName)}`
|
||||
);
|
||||
};
|
||||
|
||||
const handleNewPlan = () => {
|
||||
navigate("/scenarios/new");
|
||||
};
|
||||
|
||||
if (error && scenarios.length === 0) {
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<NavBar back={null} style={{ background: "#fff" }}>
|
||||
<div className="nav-title">场景获客</div>
|
||||
<Button size="small" color="primary" onClick={handleNewPlan}>
|
||||
<PlusOutlined /> 新建计划
|
||||
</Button>
|
||||
</NavBar>
|
||||
}
|
||||
>
|
||||
<div className={style["error"]}>
|
||||
<div className={style["error-text"]}>{error}</div>
|
||||
<Button color="primary" onClick={() => window.location.reload()}>
|
||||
重新加载
|
||||
</Button>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout
|
||||
loading={loading}
|
||||
header={
|
||||
<NavBar
|
||||
back={null}
|
||||
style={{ background: "#fff" }}
|
||||
left={<div className="nav-title">场景获客</div>}
|
||||
right={
|
||||
<Button
|
||||
size="small"
|
||||
color="primary"
|
||||
onClick={handleNewPlan}
|
||||
className="new-plan-btn"
|
||||
>
|
||||
<PlusOutlined /> 新建计划
|
||||
</Button>
|
||||
}
|
||||
></NavBar>
|
||||
}
|
||||
footer={<MeauMobile activeKey="scenarios" />}
|
||||
>
|
||||
<div className={style["scene-page"]}>
|
||||
<div className={style["scenarios-grid"]}>
|
||||
{scenarios.map((scenario) => (
|
||||
<div
|
||||
key={scenario.id}
|
||||
className={style["scenario-card"]}
|
||||
onClick={() => handleScenarioClick(scenario.id, scenario.name)}
|
||||
>
|
||||
<div className={style["card-inner"]}>
|
||||
<div className={style["card-img-wrap"]}>
|
||||
<div className={style["card-img-bg"]}>
|
||||
<img
|
||||
src={scenario.image}
|
||||
alt={scenario.name}
|
||||
className={style["card-img"]}
|
||||
onError={(e) => {
|
||||
e.currentTarget.src =
|
||||
"https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-api.png";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={style["card-title"]}>{scenario.name}</div>
|
||||
{scenario.description && (
|
||||
<div className={style["card-desc"]}>
|
||||
{scenario.description}
|
||||
</div>
|
||||
)}
|
||||
<div className={style["card-stats"]}>
|
||||
<span className={style["card-count"]}>
|
||||
今日: {scenario.count}
|
||||
</span>
|
||||
<span className={style["card-growth"]}>
|
||||
<RiseOutlined
|
||||
style={{ fontSize: 14, color: "#52c41a", marginRight: 2 }}
|
||||
/>
|
||||
{scenario.growth}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Scene;
|
||||
32
nkebao/src/pages/mobile/scenarios/plan/list/api.ts
Normal file
32
nkebao/src/pages/mobile/scenarios/plan/list/api.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import request from "@/api/request";
|
||||
import { PlanDetail, PlanListResponse, ApiResponse } from "./data";
|
||||
|
||||
// ==================== 计划相关接口 ====================
|
||||
// 获取计划列表
|
||||
export function getPlanList(params: {
|
||||
sceneId: string;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}): Promise<PlanListResponse> {
|
||||
return request(`/v1/plan/list`, params, "GET");
|
||||
}
|
||||
|
||||
// 获取计划详情
|
||||
export function getPlanDetail(planId: string): Promise<PlanDetail> {
|
||||
return request(`/v1/plan/detail`, { planId }, "GET");
|
||||
}
|
||||
|
||||
// 复制计划
|
||||
export function copyPlan(planId: string): Promise<ApiResponse<any>> {
|
||||
return request(`/v1/plan/copy`, { planId }, "GET");
|
||||
}
|
||||
|
||||
// 删除计划
|
||||
export function deletePlan(planId: string): Promise<ApiResponse<any>> {
|
||||
return request(`/v1/plan/delete`, { planId }, "DELETE");
|
||||
}
|
||||
|
||||
// 获取小程序二维码
|
||||
export function getWxMinAppCode(planId: string): Promise<ApiResponse<string>> {
|
||||
return request(`/v1/plan/getWxMinAppCode`, { taskId: planId }, "GET");
|
||||
}
|
||||
59
nkebao/src/pages/mobile/scenarios/plan/list/data.ts
Normal file
59
nkebao/src/pages/mobile/scenarios/plan/list/data.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
export interface Task {
|
||||
id: string;
|
||||
name: string;
|
||||
status: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
enabled: boolean;
|
||||
total_customers?: number;
|
||||
today_customers?: number;
|
||||
lastUpdated?: string;
|
||||
stats?: {
|
||||
devices?: number;
|
||||
acquired?: number;
|
||||
added?: number;
|
||||
};
|
||||
reqConf?: {
|
||||
device?: string[];
|
||||
selectedDevices?: string[];
|
||||
};
|
||||
acquiredCount?: number;
|
||||
addedCount?: number;
|
||||
passRate?: number;
|
||||
}
|
||||
|
||||
export interface ApiSettings {
|
||||
apiKey: string;
|
||||
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;
|
||||
}
|
||||
383
nkebao/src/pages/mobile/scenarios/plan/list/index.module.scss
Normal file
383
nkebao/src/pages/mobile/scenarios/plan/list/index.module.scss
Normal file
@@ -0,0 +1,383 @@
|
||||
.scenario-list-page {
|
||||
padding:0 16px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 60vh;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.search-input-wrapper {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
|
||||
.ant-input {
|
||||
border-radius: 8px;
|
||||
height: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.plan-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.plan-item {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.plan-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.plan-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
flex: 1;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.plan-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.more-btn {
|
||||
padding: 4px;
|
||||
min-width: auto;
|
||||
height: 28px;
|
||||
width: 28px;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
border: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-bottom: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.plan-footer {
|
||||
border-top: 1px solid #f0f0f0;
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.last-execution {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
|
||||
svg {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.create-first-btn {
|
||||
height: 40px;
|
||||
padding: 0 24px;
|
||||
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;
|
||||
padding: 20px;
|
||||
max-height: 60vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.action-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
&.danger {
|
||||
color: #ff4d4f;
|
||||
|
||||
&:hover {
|
||||
background-color: #fff2f0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
font-size: 16px;
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.action-text {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-content {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.qr-dialog {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.qr-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
gap: 16px;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.qr-image {
|
||||
width: 100%;
|
||||
max-width: 200px;
|
||||
height: auto;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.qr-error {
|
||||
text-align: center;
|
||||
color: #ff4d4f;
|
||||
font-size: 14px;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.qr-link-section {
|
||||
margin-top: 20px;
|
||||
width: 100%;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.link-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
margin-bottom: 8px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.link-input-wrapper {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
|
||||
@media (max-width: 480px) {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.link-input {
|
||||
flex: 1;
|
||||
|
||||
.ant-input {
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
background-color: #f8f9fa;
|
||||
border: 1px solid #e9ecef;
|
||||
|
||||
&:focus {
|
||||
border-color: #1890ff;
|
||||
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.copy-button {
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
|
||||
.anticon {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
611
nkebao/src/pages/mobile/scenarios/plan/list/index.tsx
Normal file
611
nkebao/src/pages/mobile/scenarios/plan/list/index.tsx
Normal file
@@ -0,0 +1,611 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
NavBar,
|
||||
Button,
|
||||
Toast,
|
||||
SpinLoading,
|
||||
Dialog,
|
||||
Popup,
|
||||
Card,
|
||||
Tag,
|
||||
} from "antd-mobile";
|
||||
import { Input } from "antd";
|
||||
import {
|
||||
PlusOutlined,
|
||||
CopyOutlined,
|
||||
DeleteOutlined,
|
||||
SettingOutlined,
|
||||
SearchOutlined,
|
||||
ReloadOutlined,
|
||||
QrcodeOutlined,
|
||||
EditOutlined,
|
||||
MoreOutlined,
|
||||
ClockCircleOutlined,
|
||||
DownOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import NavCommon from "@/components/NavCommon";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import {
|
||||
getPlanList,
|
||||
getPlanDetail,
|
||||
copyPlan,
|
||||
deletePlan,
|
||||
getWxMinAppCode,
|
||||
} from "./api";
|
||||
import style from "./index.module.scss";
|
||||
import { Task, ApiSettings, PlanDetail } from "./data";
|
||||
import PlanApi from "./planApi";
|
||||
import { buildApiUrl } from "@/utils/apiUrl";
|
||||
|
||||
const ScenarioList: React.FC = () => {
|
||||
const { scenarioId, scenarioName } = useParams<{
|
||||
scenarioId: string;
|
||||
scenarioName: string;
|
||||
}>();
|
||||
const navigate = useNavigate();
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showApiDialog, setShowApiDialog] = useState(false);
|
||||
const [currentApiSettings, setCurrentApiSettings] = useState<ApiSettings>({
|
||||
apiKey: "",
|
||||
webhookUrl: "",
|
||||
taskId: "",
|
||||
});
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [loadingTasks, setLoadingTasks] = useState(false);
|
||||
const [showQrDialog, setShowQrDialog] = useState(false);
|
||||
const [qrLoading, setQrLoading] = useState(false);
|
||||
const [qrImg, setQrImg] = useState<any>("");
|
||||
const [currentTaskId, setCurrentTaskId] = useState<string>("");
|
||||
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> = {
|
||||
douyin: "抖音直播获客",
|
||||
kuaishou: "快手直播获客",
|
||||
xiaohongshu: "小红书种草获客",
|
||||
weibo: "微博话题获客",
|
||||
haibao: "海报扫码获客",
|
||||
phone: "电话号码获客",
|
||||
gongzhonghao: "公众号引流获客",
|
||||
weixinqun: "微信群裂变获客",
|
||||
payment: "付款码获客",
|
||||
api: "API接口获客",
|
||||
};
|
||||
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 {
|
||||
await fetchPlanList(1, false);
|
||||
} catch (error) {
|
||||
console.error("获取场景数据失败:", error);
|
||||
setTasks([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
try {
|
||||
await copyPlan(taskId);
|
||||
Toast.show({
|
||||
content: `已成功复制"${taskToCopy.name}"`,
|
||||
position: "top",
|
||||
});
|
||||
// 刷新列表
|
||||
handleRefresh();
|
||||
} catch (error) {
|
||||
Toast.show({
|
||||
content: "复制失败,请重试",
|
||||
position: "top",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeletePlan = async (taskId: string) => {
|
||||
const taskToDelete = tasks.find((task) => task.id === taskId);
|
||||
if (!taskToDelete) return;
|
||||
|
||||
const result = await Dialog.confirm({
|
||||
content: `确定要删除"${taskToDelete.name}"吗?`,
|
||||
confirmText: "删除",
|
||||
cancelText: "取消",
|
||||
});
|
||||
|
||||
if (result) {
|
||||
try {
|
||||
await deletePlan(taskId);
|
||||
Toast.show({
|
||||
content: "计划已删除",
|
||||
position: "top",
|
||||
});
|
||||
// 刷新列表
|
||||
handleRefresh();
|
||||
} catch (error) {
|
||||
Toast.show({
|
||||
content: "删除失败,请重试",
|
||||
position: "top",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenApiSettings = async (taskId: string) => {
|
||||
try {
|
||||
const response: PlanDetail = await getPlanDetail(taskId);
|
||||
if (response) {
|
||||
// 处理webhook URL,使用工具函数构建完整地址
|
||||
const webhookUrl = buildApiUrl(
|
||||
response.textUrl?.fullUrl || `webhook/${taskId}`
|
||||
);
|
||||
|
||||
setCurrentApiSettings({
|
||||
apiKey: response.apiKey || "demo-api-key-123456",
|
||||
webhookUrl: webhookUrl,
|
||||
taskId: taskId,
|
||||
});
|
||||
setShowApiDialog(true);
|
||||
}
|
||||
} catch (error) {
|
||||
Toast.show({
|
||||
content: "获取计划接口失败",
|
||||
position: "top",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateNewPlan = () => {
|
||||
navigate(`/scenarios/new/${scenarioId}`);
|
||||
};
|
||||
|
||||
const handleShowQrCode = async (taskId: string) => {
|
||||
setQrLoading(true);
|
||||
setShowQrDialog(true);
|
||||
setQrImg("");
|
||||
setCurrentTaskId(taskId); // 设置当前任务ID
|
||||
|
||||
try {
|
||||
const response = await getWxMinAppCode(taskId);
|
||||
setQrImg(response);
|
||||
} catch (error) {
|
||||
Toast.show({
|
||||
content: "获取二维码失败",
|
||||
position: "top",
|
||||
});
|
||||
} finally {
|
||||
setQrLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: number) => {
|
||||
switch (status) {
|
||||
case 1:
|
||||
return "success";
|
||||
case 0:
|
||||
return "default";
|
||||
case -1:
|
||||
return "danger";
|
||||
default:
|
||||
return "default";
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = (status: number) => {
|
||||
switch (status) {
|
||||
case 1:
|
||||
return "进行中";
|
||||
case 0:
|
||||
return "已暂停";
|
||||
case -1:
|
||||
return "已停止";
|
||||
default:
|
||||
return "未知";
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefresh = async () => {
|
||||
// 重置分页状态
|
||||
setCurrentPage(1);
|
||||
setHasMore(true);
|
||||
await fetchPlanList(1, false);
|
||||
};
|
||||
|
||||
const filteredTasks = tasks.filter((task) =>
|
||||
task.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
// 生成操作菜单
|
||||
const getActionMenu = (task: Task) => [
|
||||
{
|
||||
key: "edit",
|
||||
text: "编辑计划",
|
||||
icon: <EditOutlined />,
|
||||
onClick: () => {
|
||||
setShowActionMenu(null);
|
||||
navigate(`/scenarios/edit/${task.id}`);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "copy",
|
||||
text: "复制计划",
|
||||
icon: <CopyOutlined />,
|
||||
onClick: () => {
|
||||
setShowActionMenu(null);
|
||||
handleCopyPlan(task.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "settings",
|
||||
text: "计划接口",
|
||||
icon: <SettingOutlined />,
|
||||
onClick: () => {
|
||||
setShowActionMenu(null);
|
||||
handleOpenApiSettings(task.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "qrcode",
|
||||
text: "二维码",
|
||||
icon: <QrcodeOutlined />,
|
||||
onClick: () => {
|
||||
setShowActionMenu(null);
|
||||
handleShowQrCode(task.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
text: "删除计划",
|
||||
icon: <DeleteOutlined />,
|
||||
onClick: () => {
|
||||
setShowActionMenu(null);
|
||||
handleDeletePlan(task.id);
|
||||
},
|
||||
danger: true,
|
||||
},
|
||||
];
|
||||
|
||||
const deviceCount = (task: Task) => {
|
||||
return Array.isArray(task.reqConf?.device)
|
||||
? task.reqConf!.device.length
|
||||
: Array.isArray(task.reqConf?.selectedDevices)
|
||||
? task.reqConf!.selectedDevices.length
|
||||
: 0;
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<>
|
||||
<NavCommon
|
||||
backFn={() => navigate("/scenarios")}
|
||||
title={scenarioName || ""}
|
||||
right={
|
||||
<Button
|
||||
size="small"
|
||||
color="primary"
|
||||
onClick={handleCreateNewPlan}
|
||||
>
|
||||
<PlusOutlined /> 新建计划
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 搜索栏 */}
|
||||
<div className={style["search-bar"]}>
|
||||
<div className={style["search-input-wrapper"]}>
|
||||
<Input
|
||||
placeholder="搜索计划名称"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
prefix={<SearchOutlined />}
|
||||
allowClear
|
||||
size="large"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={handleRefresh}
|
||||
loading={loadingTasks}
|
||||
className="refresh-btn"
|
||||
>
|
||||
<ReloadOutlined />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
loading={loading}
|
||||
>
|
||||
<div className={style["scenario-list-page"]}>
|
||||
{/* 计划列表 */}
|
||||
<div className={style["plan-list"]}>
|
||||
{filteredTasks.length === 0 ? (
|
||||
<div className={style["empty-state"]}>
|
||||
<div className={style["empty-text"]}>
|
||||
{searchTerm ? "没有找到匹配的计划" : "暂无计划"}
|
||||
</div>
|
||||
<Button
|
||||
color="primary"
|
||||
onClick={handleCreateNewPlan}
|
||||
className={style["create-first-btn"]}
|
||||
>
|
||||
<PlusOutlined /> 创建第一个计划
|
||||
</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>
|
||||
</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>
|
||||
</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>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* 没有更多数据提示 */}
|
||||
{!hasMore && filteredTasks.length > 0 && (
|
||||
<div className={style["no-more-data"]}>
|
||||
<span>没有更多数据了</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 计划接口弹窗 */}
|
||||
<PlanApi
|
||||
visible={showApiDialog}
|
||||
onClose={() => setShowApiDialog(false)}
|
||||
apiKey={currentApiSettings.apiKey}
|
||||
webhookUrl={currentApiSettings.webhookUrl}
|
||||
taskId={currentApiSettings.taskId}
|
||||
/>
|
||||
|
||||
{/* 操作菜单弹窗 */}
|
||||
<Popup
|
||||
visible={!!showActionMenu}
|
||||
onMaskClick={() => setShowActionMenu(null)}
|
||||
position="bottom"
|
||||
bodyStyle={{ height: "auto", maxHeight: "60vh" }}
|
||||
>
|
||||
<div className={style["action-menu-dialog"]}>
|
||||
<div className={style["dialog-header"]}>
|
||||
<h3>操作菜单</h3>
|
||||
<Button size="small" onClick={() => setShowActionMenu(null)}>
|
||||
关闭
|
||||
</Button>
|
||||
</div>
|
||||
<div className={style["dialog-content"]}>
|
||||
{showActionMenu &&
|
||||
getActionMenu(tasks.find((t) => t.id === showActionMenu)!).map(
|
||||
(item) => (
|
||||
<div
|
||||
key={item.key}
|
||||
className={`${style["action-menu-item"]} ${item.danger ? style["danger"] : ""}`}
|
||||
onClick={item.onClick}
|
||||
>
|
||||
<span className={style["action-icon"]}>{item.icon}</span>
|
||||
<span className={style["action-text"]}>{item.text}</span>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
|
||||
{/* 二维码弹窗 */}
|
||||
<Popup
|
||||
visible={showQrDialog}
|
||||
onMaskClick={() => setShowQrDialog(false)}
|
||||
position="bottom"
|
||||
>
|
||||
<div className={style["qr-dialog"]}>
|
||||
<div className={style["dialog-header"]}>
|
||||
<h3>小程序二维码</h3>
|
||||
<Button size="small" onClick={() => setShowQrDialog(false)}>
|
||||
关闭
|
||||
</Button>
|
||||
</div>
|
||||
<div className={style["dialog-content"]}>
|
||||
{qrLoading ? (
|
||||
<div className={style["qr-loading"]}>
|
||||
<SpinLoading color="primary" />
|
||||
<div>生成二维码中...</div>
|
||||
</div>
|
||||
) : qrImg ? (
|
||||
<>
|
||||
<img
|
||||
src={qrImg}
|
||||
alt="小程序二维码"
|
||||
className={style["qr-image"]}
|
||||
/>
|
||||
{/* 链接复制区域 */}
|
||||
<div className={style["qr-link-section"]}>
|
||||
<div className={style["link-label"]}>小程序链接</div>
|
||||
<div className={style["link-input-wrapper"]}>
|
||||
<Input
|
||||
value={`https://h5.ckb.quwanzhi.com/#/pages/form/input?id=${currentTaskId}`}
|
||||
readOnly
|
||||
className={style["link-input"]}
|
||||
placeholder="小程序链接"
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
const link = `https://h5.ckb.quwanzhi.com/#/pages/form/input?id=${currentTaskId}`;
|
||||
navigator.clipboard.writeText(link);
|
||||
Toast.show({
|
||||
content: "链接已复制到剪贴板",
|
||||
position: "top",
|
||||
});
|
||||
}}
|
||||
className={style["copy-button"]}
|
||||
>
|
||||
<CopyOutlined />
|
||||
复制
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className={style["qr-error"]}>二维码生成失败</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScenarioList;
|
||||
601
nkebao/src/pages/mobile/scenarios/plan/list/planApi.module.scss
Normal file
601
nkebao/src/pages/mobile/scenarios/plan/list/planApi.module.scss
Normal file
@@ -0,0 +1,601 @@
|
||||
// 移动端样式
|
||||
.plan-api-dialog {
|
||||
background: white;
|
||||
border-radius: 16px 16px 0 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
background: #fafafa;
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
font-size: 24px;
|
||||
color: #1890ff;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
flex: 1;
|
||||
|
||||
h3 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
color: #999;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nav-tabs {
|
||||
display: flex;
|
||||
background: white;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
|
||||
.nav-tab {
|
||||
flex: 1;
|
||||
min-width: 80px;
|
||||
padding: 12px 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
|
||||
svg {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: #1890ff;
|
||||
border-bottom: 2px solid #1890ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
background: #fafafa;
|
||||
|
||||
.security-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
|
||||
svg {
|
||||
color: #52c41a;
|
||||
}
|
||||
}
|
||||
|
||||
.complete-btn {
|
||||
height: 36px;
|
||||
padding: 0 24px;
|
||||
border-radius: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
// 配置内容样式
|
||||
.config-content {
|
||||
.config-section {
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
|
||||
.section-icon {
|
||||
color: #1890ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.api-input {
|
||||
flex: 1;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
height: 40px;
|
||||
padding: 0 16px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.security-tip {
|
||||
padding: 12px;
|
||||
background: #fff7e6;
|
||||
border: 1px solid #ffd591;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
color: #d46b08;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.params-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.param-section {
|
||||
background: #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: #e9ecef;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 测试内容样式
|
||||
.test-content {
|
||||
.test-section {
|
||||
h3 {
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.test-input {
|
||||
margin-bottom: 16px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.test-buttons {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
||||
.test-btn {
|
||||
flex: 1;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 文档内容样式
|
||||
.docs-content {
|
||||
.docs-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.doc-card {
|
||||
text-align: center;
|
||||
padding: 24px 16px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #f0f0f0;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.doc-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: #f0f8ff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 16px;
|
||||
font-size: 24px;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 代码内容样式
|
||||
.code-content {
|
||||
.language-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
|
||||
.lang-tab {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid #d9d9d9;
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
border-color: #1890ff;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #1890ff;
|
||||
border-color: #1890ff;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.code-block {
|
||||
position: relative;
|
||||
background: #f6f8fa;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e1e4e8;
|
||||
overflow: hidden;
|
||||
|
||||
.code {
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: #24292e;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.copy-code-btn {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PC端样式覆盖
|
||||
.plan-api-modal {
|
||||
.plan-api-dialog {
|
||||
border-radius: 12px;
|
||||
height: auto;
|
||||
max-height: 80vh;
|
||||
}
|
||||
|
||||
.nav-tabs {
|
||||
.nav-tab {
|
||||
min-width: 100px;
|
||||
padding: 16px 12px;
|
||||
font-size: 15px;
|
||||
|
||||
svg {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-content {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.params-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.docs-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.test-buttons {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 768px) {
|
||||
.plan-api-dialog {
|
||||
.header-content {
|
||||
h3 {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nav-tabs {
|
||||
.nav-tab {
|
||||
font-size: 13px;
|
||||
padding: 10px 6px;
|
||||
|
||||
svg {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.config-content {
|
||||
.params-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.docs-content {
|
||||
.docs-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.test-content {
|
||||
.test-buttons {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.code-content {
|
||||
.language-tabs {
|
||||
.lang-tab {
|
||||
padding: 6px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.code-block {
|
||||
.code {
|
||||
font-size: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 暗色主题支持
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.plan-api-dialog {
|
||||
background: #1f1f1f;
|
||||
color: #fff;
|
||||
|
||||
.dialog-header {
|
||||
background: #262626;
|
||||
border-bottom-color: #434343;
|
||||
|
||||
.header-content {
|
||||
h3 {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #a6a6a6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nav-tabs {
|
||||
background: #262626;
|
||||
border-bottom-color: #434343;
|
||||
|
||||
.nav-tab {
|
||||
color: #a6a6a6;
|
||||
|
||||
&:hover {
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: #1890ff;
|
||||
border-bottom-color: #1890ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
background: #262626;
|
||||
border-top-color: #434343;
|
||||
|
||||
.security-note {
|
||||
color: #a6a6a6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.config-content {
|
||||
.section-title {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.security-tip {
|
||||
background: #2a1f00;
|
||||
border-color: #d48806;
|
||||
color: #ffc53d;
|
||||
}
|
||||
|
||||
.param-section {
|
||||
background: #262626;
|
||||
border-color: #434343;
|
||||
|
||||
h4 {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.param-list {
|
||||
color: #a6a6a6;
|
||||
|
||||
code {
|
||||
background: #434343;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.test-content {
|
||||
h3 {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.docs-content {
|
||||
.doc-card {
|
||||
background: #262626;
|
||||
border-color: #434343;
|
||||
|
||||
h4 {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #a6a6a6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.code-content {
|
||||
.code-block {
|
||||
background: #0d1117;
|
||||
border-color: #30363d;
|
||||
|
||||
.code {
|
||||
color: #c9d1d9;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
437
nkebao/src/pages/mobile/scenarios/plan/list/planApi.tsx
Normal file
437
nkebao/src/pages/mobile/scenarios/plan/list/planApi.tsx
Normal file
@@ -0,0 +1,437 @@
|
||||
import React, { useState, useMemo } from "react";
|
||||
import { Popup, Button, Toast, SpinLoading } from "antd-mobile";
|
||||
import { Modal, Input, Tabs, Card, Tag, Space } from "antd";
|
||||
import {
|
||||
CopyOutlined,
|
||||
CodeOutlined,
|
||||
BookOutlined,
|
||||
ThunderboltOutlined,
|
||||
SettingOutlined,
|
||||
LinkOutlined,
|
||||
SafetyOutlined,
|
||||
CheckCircleOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import style from "./planApi.module.scss";
|
||||
import { buildApiUrl } from "@/utils/apiUrl";
|
||||
|
||||
/**
|
||||
* 计划接口配置弹窗组件
|
||||
*
|
||||
* 使用示例:
|
||||
* ```tsx
|
||||
* const [showApiDialog, setShowApiDialog] = useState(false);
|
||||
* const [apiSettings, setApiSettings] = useState({
|
||||
* apiKey: "your-api-key",
|
||||
* webhookUrl: "https://api.example.com/webhook",
|
||||
* taskId: "task-123"
|
||||
* });
|
||||
*
|
||||
* <PlanApi
|
||||
* visible={showApiDialog}
|
||||
* onClose={() => setShowApiDialog(false)}
|
||||
* apiKey={apiSettings.apiKey}
|
||||
* webhookUrl={apiSettings.webhookUrl}
|
||||
* taskId={apiSettings.taskId}
|
||||
* />
|
||||
* ```
|
||||
*
|
||||
* 特性:
|
||||
* - 移动端使用 Popup,PC端使用 Modal
|
||||
* - 支持四个标签页:接口配置、快速测试、开发文档、代码示例
|
||||
* - 支持多种编程语言的代码示例
|
||||
* - 响应式设计,自适应不同屏幕尺寸
|
||||
* - 支持暗色主题
|
||||
* - 自动拼接API地址前缀
|
||||
*/
|
||||
|
||||
interface PlanApiProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
apiKey: string;
|
||||
webhookUrl: string;
|
||||
taskId: string;
|
||||
}
|
||||
|
||||
interface ApiSettings {
|
||||
apiKey: string;
|
||||
webhookUrl: string;
|
||||
taskId: string;
|
||||
}
|
||||
|
||||
const PlanApi: React.FC<PlanApiProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
apiKey,
|
||||
webhookUrl,
|
||||
taskId,
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState("config");
|
||||
const [activeLanguage, setActiveLanguage] = useState("javascript");
|
||||
|
||||
// 处理webhook URL,确保包含完整的API地址
|
||||
const fullWebhookUrl = useMemo(() => {
|
||||
return buildApiUrl(webhookUrl);
|
||||
}, [webhookUrl]);
|
||||
|
||||
// 生成测试URL
|
||||
const testUrl = useMemo(() => {
|
||||
if (!fullWebhookUrl) return "";
|
||||
return `${fullWebhookUrl}?name=测试客户&phone=13800138000&source=API测试`;
|
||||
}, [fullWebhookUrl]);
|
||||
|
||||
// 检测是否为移动端
|
||||
const isMobile = window.innerWidth <= 768;
|
||||
|
||||
const handleCopy = (text: string, type: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
Toast.show({
|
||||
content: `${type}已复制到剪贴板`,
|
||||
position: "top",
|
||||
});
|
||||
};
|
||||
|
||||
const handleTestInBrowser = () => {
|
||||
window.open(testUrl, "_blank");
|
||||
};
|
||||
|
||||
const renderConfigTab = () => (
|
||||
<div className={style["config-content"]}>
|
||||
{/* API密钥配置 */}
|
||||
<div className={style["config-section"]}>
|
||||
<div className={style["section-header"]}>
|
||||
<div className={style["section-title"]}>
|
||||
<CheckCircleOutlined className={style["section-icon"]} />
|
||||
API密钥
|
||||
</div>
|
||||
<Tag color="green">安全认证</Tag>
|
||||
</div>
|
||||
<div className={style["input-group"]}>
|
||||
<Input value={apiKey} disabled className={style["api-input"]} />
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => handleCopy(apiKey, "API密钥")}
|
||||
className={style["copy-btn"]}
|
||||
>
|
||||
<CopyOutlined />
|
||||
复制
|
||||
</Button>
|
||||
</div>
|
||||
<div className={style["security-tip"]}>
|
||||
<strong>安全提示:</strong>
|
||||
请妥善保管API密钥,不要在客户端代码中暴露。建议在服务器端使用该密钥。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 接口地址配置 */}
|
||||
<div className={style["config-section"]}>
|
||||
<div className={style["section-header"]}>
|
||||
<div className={style["section-title"]}>
|
||||
<LinkOutlined className={style["section-icon"]} />
|
||||
接口地址
|
||||
</div>
|
||||
<Tag color="blue">POST请求</Tag>
|
||||
</div>
|
||||
<div className={style["input-group"]}>
|
||||
<Input
|
||||
value={fullWebhookUrl}
|
||||
disabled
|
||||
className={style["api-input"]}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => handleCopy(fullWebhookUrl, "接口地址")}
|
||||
className={style["copy-btn"]}
|
||||
>
|
||||
<CopyOutlined />
|
||||
复制
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 参数说明 */}
|
||||
<div className={style["params-grid"]}>
|
||||
<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>
|
||||
);
|
||||
|
||||
const renderQuickTestTab = () => (
|
||||
<div className={style["test-content"]}>
|
||||
<div className={style["test-section"]}>
|
||||
<h3>快速测试URL</h3>
|
||||
<div className={style["input-group"]}>
|
||||
<Input value={testUrl} disabled className={style["test-input"]} />
|
||||
</div>
|
||||
<div className={style["test-buttons"]}>
|
||||
<Button
|
||||
onClick={() => handleCopy(testUrl, "测试URL")}
|
||||
className={style["test-btn"]}
|
||||
>
|
||||
<CopyOutlined />
|
||||
复制测试URL
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleTestInBrowser}
|
||||
className={style["test-btn"]}
|
||||
>
|
||||
在浏览器中测试
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderDocsTab = () => (
|
||||
<div className={style["docs-content"]}>
|
||||
<div className={style["docs-grid"]}>
|
||||
<Card className={style["doc-card"]}>
|
||||
<div className={style["doc-icon"]}>
|
||||
<BookOutlined />
|
||||
</div>
|
||||
<h4>完整API文档</h4>
|
||||
<p>详细的接口说明和参数文档</p>
|
||||
</Card>
|
||||
<Card className={style["doc-card"]}>
|
||||
<div className={style["doc-icon"]}>
|
||||
<LinkOutlined />
|
||||
</div>
|
||||
<h4>集成指南</h4>
|
||||
<p>第三方平台集成教程</p>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderCodeTab = () => {
|
||||
const codeExamples = {
|
||||
javascript: `fetch('${fullWebhookUrl}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer ${apiKey}'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: '张三',
|
||||
phone: '13800138000',
|
||||
source: '官网表单',
|
||||
})
|
||||
})`,
|
||||
python: `import requests
|
||||
|
||||
url = '${fullWebhookUrl}'
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer ${apiKey}'
|
||||
}
|
||||
data = {
|
||||
'name': '张三',
|
||||
'phone': '13800138000',
|
||||
'source': '官网表单'
|
||||
}
|
||||
|
||||
response = requests.post(url, json=data, headers=headers)`,
|
||||
php: `<?php
|
||||
$url = '${fullWebhookUrl}';
|
||||
$data = array(
|
||||
'name' => '张三',
|
||||
'phone' => '13800138000',
|
||||
'source' => '官网表单'
|
||||
);
|
||||
|
||||
$options = array(
|
||||
'http' => array(
|
||||
'header' => "Content-type: application/json\\r\\nAuthorization: Bearer ${apiKey}\\r\\n",
|
||||
'method' => 'POST',
|
||||
'content' => json_encode($data)
|
||||
)
|
||||
);
|
||||
|
||||
$context = stream_context_create($options);
|
||||
$result = file_get_contents($url, false, $context);`,
|
||||
java: `import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.net.URI;
|
||||
|
||||
HttpClient client = HttpClient.newHttpClient();
|
||||
String json = "{\\"name\\":\\"张三\\",\\"phone\\":\\"13800138000\\",\\"source\\":\\"官网表单\\"}";
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create("${fullWebhookUrl}"))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Authorization", "Bearer ${apiKey}")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(json))
|
||||
.build();
|
||||
|
||||
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());`,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={style["code-content"]}>
|
||||
<div className={style["language-tabs"]}>
|
||||
{Object.keys(codeExamples).map((lang) => (
|
||||
<button
|
||||
key={lang}
|
||||
className={`${style["lang-tab"]} ${
|
||||
activeLanguage === lang ? style["active"] : ""
|
||||
}`}
|
||||
onClick={() => setActiveLanguage(lang)}
|
||||
>
|
||||
{lang.charAt(0).toUpperCase() + lang.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className={style["code-block"]}>
|
||||
<pre className={style["code"]}>
|
||||
<code>
|
||||
{codeExamples[activeLanguage as keyof typeof codeExamples]}
|
||||
</code>
|
||||
</pre>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() =>
|
||||
handleCopy(
|
||||
codeExamples[activeLanguage as keyof typeof codeExamples],
|
||||
"代码"
|
||||
)
|
||||
}
|
||||
className={style["copy-code-btn"]}
|
||||
>
|
||||
<CopyOutlined />
|
||||
复制代码
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderContent = () => (
|
||||
<div className={style["plan-api-dialog"]}>
|
||||
{/* 头部 */}
|
||||
<div className={style["dialog-header"]}>
|
||||
<div className={style["header-left"]}>
|
||||
<CodeOutlined className={style["header-icon"]} />
|
||||
<div className={style["header-content"]}>
|
||||
<h3>计划接口配置</h3>
|
||||
<p>
|
||||
通过API接口直接导入客资到该获客计划,支持多种编程语言和第三方平台集成
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button size="small" onClick={onClose} className={style["close-btn"]}>
|
||||
×
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 导航标签 */}
|
||||
<div className={style["nav-tabs"]}>
|
||||
<button
|
||||
className={`${style["nav-tab"]} ${activeTab === "config" ? style["active"] : ""}`}
|
||||
onClick={() => setActiveTab("config")}
|
||||
>
|
||||
<SettingOutlined />
|
||||
接口配置
|
||||
</button>
|
||||
<button
|
||||
className={`${style["nav-tab"]} ${activeTab === "test" ? style["active"] : ""}`}
|
||||
onClick={() => setActiveTab("test")}
|
||||
>
|
||||
<ThunderboltOutlined />
|
||||
快速测试
|
||||
</button>
|
||||
<button
|
||||
className={`${style["nav-tab"]} ${activeTab === "docs" ? style["active"] : ""}`}
|
||||
onClick={() => setActiveTab("docs")}
|
||||
>
|
||||
<BookOutlined />
|
||||
开发文档
|
||||
</button>
|
||||
<button
|
||||
className={`${style["nav-tab"]} ${activeTab === "code" ? style["active"] : ""}`}
|
||||
onClick={() => setActiveTab("code")}
|
||||
>
|
||||
<CodeOutlined />
|
||||
代码示例
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 内容区域 */}
|
||||
<div className={style["dialog-content"]}>
|
||||
{activeTab === "config" && renderConfigTab()}
|
||||
{activeTab === "test" && renderQuickTestTab()}
|
||||
{activeTab === "docs" && renderDocsTab()}
|
||||
{activeTab === "code" && renderCodeTab()}
|
||||
</div>
|
||||
|
||||
{/* 底部 */}
|
||||
<div className={style["dialog-footer"]}>
|
||||
<div className={style["security-note"]}>
|
||||
<SafetyOutlined />
|
||||
所有数据传输均采用HTTPS加密
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={onClose}
|
||||
className={style["complete-btn"]}
|
||||
>
|
||||
完成配置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 移动端使用Popup
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Popup
|
||||
visible={visible}
|
||||
onMaskClick={onClose}
|
||||
position="bottom"
|
||||
bodyStyle={{ height: "90vh" }}
|
||||
>
|
||||
{renderContent()}
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
|
||||
// PC端使用Modal
|
||||
return (
|
||||
<Modal
|
||||
open={visible}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
width={800}
|
||||
centered
|
||||
className={style["plan-api-modal"]}
|
||||
>
|
||||
{renderContent()}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlanApi;
|
||||
53
nkebao/src/pages/mobile/scenarios/plan/new/index.api.ts
Normal file
53
nkebao/src/pages/mobile/scenarios/plan/new/index.api.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import request from "@/api/request";
|
||||
// 获取场景类型列表
|
||||
export function getScenarioTypes() {
|
||||
return request("/v1/plan/scenes", undefined, "GET");
|
||||
}
|
||||
|
||||
// 创建计划
|
||||
export function createPlan(data: any) {
|
||||
return request("/v1/plan/create", data, "POST");
|
||||
}
|
||||
|
||||
// 更新计划
|
||||
export function updatePlan(planId: string, data: any) {
|
||||
return request(`/v1/scenarios/plans/${planId}`, data, "PUT");
|
||||
}
|
||||
|
||||
// 获取计划详情
|
||||
export function getPlanDetail(planId: string) {
|
||||
return request(`/v1/scenarios/plans/${planId}`, undefined, "GET");
|
||||
}
|
||||
|
||||
// PlanDetail 类型定义(可根据实际接口返回结构补充字段)
|
||||
export interface PlanDetail {
|
||||
name: string;
|
||||
scenario: number;
|
||||
posters: any[];
|
||||
device: string[];
|
||||
remarkType: string;
|
||||
greeting: string;
|
||||
addInterval: number;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
enabled: boolean;
|
||||
sceneId: string | number;
|
||||
remarkFormat: string;
|
||||
addFriendInterval: number;
|
||||
// 其它字段可扩展
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// 兼容旧代码的接口命名
|
||||
export function getPlanScenes() {
|
||||
return getScenarioTypes();
|
||||
}
|
||||
export function createScenarioPlan(data: any) {
|
||||
return createPlan(data);
|
||||
}
|
||||
export function fetchPlanDetail(planId: string) {
|
||||
return getPlanDetail(planId);
|
||||
}
|
||||
export function updateScenarioPlan(planId: string, data: any) {
|
||||
return updatePlan(planId, data);
|
||||
}
|
||||
217
nkebao/src/pages/mobile/scenarios/plan/new/index.tsx
Normal file
217
nkebao/src/pages/mobile/scenarios/plan/new/index.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { message } from "antd";
|
||||
import NavCommon from "@/components/NavCommon";
|
||||
import BasicSettings from "./steps/BasicSettings";
|
||||
import FriendRequestSettings from "./steps/FriendRequestSettings";
|
||||
import MessageSettings from "./steps/MessageSettings";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import StepIndicator from "@/components/StepIndicator";
|
||||
import {
|
||||
getScenarioTypes,
|
||||
createPlan,
|
||||
getPlanDetail,
|
||||
updatePlan,
|
||||
} from "./index.api";
|
||||
|
||||
// 步骤定义 - 只保留三个步骤
|
||||
const steps = [
|
||||
{ id: 1, title: "步骤一", subtitle: "基础设置" },
|
||||
{ id: 2, title: "步骤二", subtitle: "好友申请设置" },
|
||||
{ id: 3, title: "步骤三", subtitle: "消息设置" },
|
||||
];
|
||||
|
||||
// 类型定义
|
||||
interface FormData {
|
||||
name: string;
|
||||
scenario: number;
|
||||
posters: any[]; // 后续可替换为具体Poster类型
|
||||
device: string[];
|
||||
remarkType: string;
|
||||
greeting: string;
|
||||
addInterval: number;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
enabled: boolean;
|
||||
sceneId: string | number;
|
||||
remarkFormat: string;
|
||||
addFriendInterval: number;
|
||||
}
|
||||
|
||||
export default function NewPlan() {
|
||||
const router = useNavigate();
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const [formData, setFormData] = useState<FormData>({
|
||||
name: "",
|
||||
scenario: 1,
|
||||
posters: [],
|
||||
device: [],
|
||||
remarkType: "phone",
|
||||
greeting: "你好,请通过",
|
||||
addInterval: 1,
|
||||
startTime: "09:00",
|
||||
endTime: "18:00",
|
||||
enabled: true,
|
||||
sceneId: "",
|
||||
remarkFormat: "",
|
||||
addFriendInterval: 1,
|
||||
});
|
||||
const [sceneList, setSceneList] = useState<any[]>([]);
|
||||
const [sceneLoading, setSceneLoading] = useState(true);
|
||||
const { scenarioId, planId } = useParams<{
|
||||
scenarioId: string;
|
||||
planId: string;
|
||||
}>();
|
||||
const [isEdit, setIsEdit] = useState(false);
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const loadData = async () => {
|
||||
setSceneLoading(true);
|
||||
//获取场景类型
|
||||
getScenarioTypes()
|
||||
.then((data) => {
|
||||
setSceneList(data || []);
|
||||
})
|
||||
.catch((err) => {
|
||||
message.error(err.message || "获取场景类型失败");
|
||||
})
|
||||
.finally(() => setSceneLoading(false));
|
||||
if (planId) {
|
||||
setIsEdit(true);
|
||||
//获取计划详情
|
||||
|
||||
const detail = await getPlanDetail(planId);
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
name: detail.name ?? "",
|
||||
scenario: Number(detail.scenario) || 1,
|
||||
posters: detail.posters ?? [],
|
||||
device: detail.device ?? [],
|
||||
remarkType: detail.remarkType ?? "phone",
|
||||
greeting: detail.greeting ?? "",
|
||||
addInterval: detail.addInterval ?? 1,
|
||||
startTime: detail.startTime ?? "09:00",
|
||||
endTime: detail.endTime ?? "18:00",
|
||||
enabled: detail.enabled ?? true,
|
||||
sceneId: Number(detail.scenario) || 1,
|
||||
remarkFormat: detail.remarkFormat ?? "",
|
||||
addFriendInterval: detail.addFriendInterval ?? 1,
|
||||
tips: detail.tips ?? "",
|
||||
}));
|
||||
} else {
|
||||
if (scenarioId) {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
...{ scenario: Number(scenarioId) || 1 },
|
||||
}));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 更新表单数据
|
||||
const onChange = (data: any) => {
|
||||
setFormData((prev) => ({ ...prev, ...data }));
|
||||
};
|
||||
|
||||
// 处理保存
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
let result;
|
||||
if (isEdit && planId) {
|
||||
// 编辑:拼接后端需要的完整参数
|
||||
const editData = {
|
||||
...formData,
|
||||
...{ sceneId: Number(formData.scenario) },
|
||||
id: Number(planId),
|
||||
planId: Number(planId),
|
||||
// 兼容后端需要的字段
|
||||
// 你可以根据实际需要补充其它字段
|
||||
};
|
||||
result = await updatePlan(planId, editData);
|
||||
} else {
|
||||
// 新建
|
||||
formData.sceneId = Number(formData.scenario);
|
||||
result = await createPlan(formData);
|
||||
}
|
||||
message.success(isEdit ? "计划已更新" : "获客计划已创建");
|
||||
const sceneItem = sceneList.find((v) => formData.scenario === v.id);
|
||||
router(`/scenarios/list/${formData.scenario}/${sceneItem.name}`);
|
||||
} catch (error) {
|
||||
message.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: typeof error === "string"
|
||||
? error
|
||||
: isEdit
|
||||
? "更新计划失败,请重试"
|
||||
: "创建计划失败,请重试"
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 下一步
|
||||
const handleNext = () => {
|
||||
if (currentStep === steps.length) {
|
||||
handleSave();
|
||||
} else {
|
||||
setCurrentStep((prev) => prev + 1);
|
||||
}
|
||||
};
|
||||
|
||||
// 上一步
|
||||
const handlePrev = () => {
|
||||
setCurrentStep((prev) => Math.max(prev - 1, 1));
|
||||
};
|
||||
|
||||
// 渲染当前步骤内容
|
||||
const renderStepContent = () => {
|
||||
switch (currentStep) {
|
||||
case 1:
|
||||
return (
|
||||
<BasicSettings
|
||||
isEdit={isEdit}
|
||||
formData={formData}
|
||||
onChange={onChange}
|
||||
onNext={handleNext}
|
||||
sceneList={sceneList}
|
||||
sceneLoading={sceneLoading}
|
||||
/>
|
||||
);
|
||||
case 2:
|
||||
return (
|
||||
<FriendRequestSettings
|
||||
formData={formData}
|
||||
onChange={onChange}
|
||||
onNext={handleNext}
|
||||
onPrev={handlePrev}
|
||||
/>
|
||||
);
|
||||
case 3:
|
||||
return (
|
||||
<MessageSettings
|
||||
formData={formData}
|
||||
onChange={onChange}
|
||||
onNext={handleSave}
|
||||
onPrev={handlePrev}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<>
|
||||
<NavCommon title={isEdit ? "编辑场景计划" : "新建场景计划"} />
|
||||
<StepIndicator currentStep={currentStep} steps={steps} />
|
||||
</>
|
||||
}
|
||||
>
|
||||
{renderStepContent()}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,774 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { Form, Input, Button, Tag, Switch, Modal, Spin } from "antd";
|
||||
import { Button as ButtonMobile } from "antd-mobile";
|
||||
import {
|
||||
PlusOutlined,
|
||||
EyeOutlined,
|
||||
CloseOutlined,
|
||||
DownloadOutlined,
|
||||
UploadOutlined,
|
||||
CheckOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { uploadFile } from "@/api/common";
|
||||
import styles from "./base.module.scss";
|
||||
|
||||
interface BasicSettingsProps {
|
||||
isEdit: boolean;
|
||||
formData: any;
|
||||
onChange: (data: any) => void;
|
||||
onNext?: () => void;
|
||||
sceneList: any[];
|
||||
sceneLoading: boolean;
|
||||
}
|
||||
|
||||
interface Account {
|
||||
id: string;
|
||||
nickname: string;
|
||||
avatar: string;
|
||||
}
|
||||
|
||||
interface Material {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
preview: string;
|
||||
}
|
||||
|
||||
const posterTemplates = [
|
||||
{
|
||||
id: "poster-1",
|
||||
name: "点击领取",
|
||||
preview:
|
||||
"https://hebbkx1anhila5yf.public.blob.vercel-storage.com/%E7%82%B9%E5%87%BB%E9%A2%86%E5%8F%961-tipd1HI7da6qooY5NkhxQnXBnT5LGU.gif",
|
||||
},
|
||||
{
|
||||
id: "poster-2",
|
||||
name: "点击合作",
|
||||
preview:
|
||||
"https://hebbkx1anhila5yf.public.blob.vercel-storage.com/%E7%82%B9%E5%87%BB%E5%90%88%E4%BD%9C-LPlMdgxtvhqCSr4IM1bZFEFDBF3ztI.gif",
|
||||
},
|
||||
{
|
||||
id: "poster-3",
|
||||
name: "点击咨询",
|
||||
preview:
|
||||
"https://hebbkx1anhila5yf.public.blob.vercel-storage.com/%E7%82%B9%E5%87%BB%E5%92%A8%E8%AF%A2-FTiyAMAPop2g9LvjLOLDz0VwPg3KVu.gif",
|
||||
},
|
||||
{
|
||||
id: "poster-4",
|
||||
name: "点击签到",
|
||||
preview:
|
||||
"https://hebbkx1anhila5yf.public.blob.vercel-storage.com/%E7%82%B9%E5%87%BB%E7%AD%BE%E5%88%B0-94TZIkjLldb4P2jTVlI6MkSDg0NbXi.gif",
|
||||
},
|
||||
{
|
||||
id: "poster-5",
|
||||
name: "点击了解",
|
||||
preview:
|
||||
"https://hebbkx1anhila5yf.public.blob.vercel-storage.com/%E7%82%B9%E5%87%BB%E4%BA%86%E8%A7%A3-6GCl7mQVdO4WIiykJyweSubLsTwj71.gif",
|
||||
},
|
||||
{
|
||||
id: "poster-6",
|
||||
name: "点击报名",
|
||||
preview:
|
||||
"https://hebbkx1anhila5yf.public.blob.vercel-storage.com/%E7%82%B9%E5%87%BB%E6%8A%A5%E5%90%8D-Mj0nnva0BiASeDAIhNNaRRAbjPgjEj.gif",
|
||||
},
|
||||
];
|
||||
|
||||
const generateRandomAccounts = (count: number): Account[] => {
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
id: `account-${index + 1}`,
|
||||
nickname: `账号-${Math.random().toString(36).substring(2, 7)}`,
|
||||
avatar: `/placeholder.svg?height=40&width=40&text=${index + 1}`,
|
||||
}));
|
||||
};
|
||||
|
||||
const generatePosterMaterials = (): Material[] => {
|
||||
return posterTemplates.map((template) => ({
|
||||
id: template.id,
|
||||
name: template.name,
|
||||
type: "poster",
|
||||
preview: template.preview,
|
||||
}));
|
||||
};
|
||||
|
||||
const BasicSettings: React.FC<BasicSettingsProps> = ({
|
||||
isEdit,
|
||||
formData,
|
||||
onChange,
|
||||
onNext,
|
||||
sceneList,
|
||||
sceneLoading,
|
||||
}) => {
|
||||
const [isAccountDialogOpen, setIsAccountDialogOpen] = useState(false);
|
||||
const [isMaterialDialogOpen, setIsMaterialDialogOpen] = useState(false);
|
||||
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
|
||||
const [isPhoneSettingsOpen, setIsPhoneSettingsOpen] = useState(false);
|
||||
const [accounts] = useState<Account[]>(generateRandomAccounts(50));
|
||||
const [materials] = useState<Material[]>(generatePosterMaterials());
|
||||
const [selectedAccounts, setSelectedAccounts] = useState<Account[]>(
|
||||
formData.accounts?.length > 0 ? formData.accounts : []
|
||||
);
|
||||
const [selectedMaterials, setSelectedMaterials] = useState<Material[]>(
|
||||
formData.materials?.length > 0 ? formData.materials : []
|
||||
);
|
||||
// showAllScenarios 默认为 true
|
||||
const [showAllScenarios, setShowAllScenarios] = useState(true);
|
||||
const [isImportDialogOpen, setIsImportDialogOpen] = useState(false);
|
||||
const [importedTags, setImportedTags] = useState<
|
||||
Array<{
|
||||
phone: string;
|
||||
wechat: string;
|
||||
source?: string;
|
||||
orderAmount?: number;
|
||||
orderDate?: string;
|
||||
}>
|
||||
>(formData.importedTags || []);
|
||||
|
||||
// 自定义标签相关状态
|
||||
const [customTagInput, setCustomTagInput] = useState("");
|
||||
const [customTags, setCustomTags] = useState(formData.customTags || []);
|
||||
const [tips, setTips] = useState(formData.tips || "");
|
||||
const [selectedScenarioTags, setSelectedScenarioTags] = useState(
|
||||
formData.scenarioTags || []
|
||||
);
|
||||
|
||||
// 电话获客相关状态
|
||||
const [phoneSettings, setPhoneSettings] = useState({
|
||||
autoAdd: formData.phoneSettings?.autoAdd ?? true,
|
||||
speechToText: formData.phoneSettings?.speechToText ?? true,
|
||||
questionExtraction: formData.phoneSettings?.questionExtraction ?? true,
|
||||
});
|
||||
|
||||
// 群设置相关状态
|
||||
const [weixinqunName, setWeixinqunName] = useState(
|
||||
formData.weixinqunName || ""
|
||||
);
|
||||
const [weixinqunNotice, setWeixinqunNotice] = useState(
|
||||
formData.weixinqunNotice || ""
|
||||
);
|
||||
|
||||
// 新增:自定义海报相关状态
|
||||
const [customPosters, setCustomPosters] = useState<Material[]>([]);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
|
||||
// 新增:用于文件选择的ref
|
||||
const uploadInputRef = useRef<HTMLInputElement>(null);
|
||||
const uploadOrderInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 更新电话获客设置
|
||||
const handlePhoneSettingsUpdate = () => {
|
||||
onChange({ ...formData, phoneSettings });
|
||||
setIsPhoneSettingsOpen(false);
|
||||
};
|
||||
|
||||
// 处理标签选择
|
||||
const handleTagToggle = (tagId: string) => {
|
||||
const newTags = selectedScenarioTags.includes(tagId)
|
||||
? selectedScenarioTags.filter((id: string) => id !== tagId)
|
||||
: [...selectedScenarioTags, tagId];
|
||||
|
||||
setSelectedScenarioTags(newTags);
|
||||
onChange({ ...formData, scenarioTags: newTags });
|
||||
};
|
||||
|
||||
// 处理通话类型选择
|
||||
const handleCallTypeChange = (type: string) => {
|
||||
// setPhoneCallType(type) // This line was removed as per the edit hint.
|
||||
onChange({ ...formData, phoneCallType: type });
|
||||
};
|
||||
|
||||
// 初始化时,如果没有选择场景,默认选择海报获客
|
||||
useEffect(() => {
|
||||
if (!formData.scenario) {
|
||||
onChange({ ...formData, scenario: "haibao" });
|
||||
}
|
||||
|
||||
// 检查是否已经有上传的订单文件
|
||||
if (formData.orderFileUploaded) {
|
||||
setOrderUploaded(true);
|
||||
}
|
||||
}, [formData, onChange]);
|
||||
|
||||
useEffect(() => {
|
||||
const today = new Date().toLocaleDateString("zh-CN").replace(/\//g, "");
|
||||
const sceneItem = sceneList.find((v) => formData.scenario === v.id);
|
||||
onChange({ ...formData, name: `${sceneItem?.name || "海报"}${today}` });
|
||||
}, [isEdit]);
|
||||
|
||||
useEffect(() => {
|
||||
setTips(formData.tips || "");
|
||||
}, [formData.tips]);
|
||||
|
||||
// 选中场景
|
||||
const handleScenarioSelect = (sceneId: number) => {
|
||||
onChange({ ...formData, scenario: sceneId });
|
||||
};
|
||||
|
||||
// 选中/取消标签
|
||||
const handleScenarioTagToggle = (tag: string) => {
|
||||
const newTags = selectedScenarioTags.includes(tag)
|
||||
? selectedScenarioTags.filter((t: string) => t !== tag)
|
||||
: [...selectedScenarioTags, tag];
|
||||
setSelectedScenarioTags(newTags);
|
||||
onChange({ ...formData, scenarioTags: newTags });
|
||||
};
|
||||
|
||||
// 添加自定义标签
|
||||
const handleAddCustomTag = () => {
|
||||
if (!customTagInput.trim()) return;
|
||||
const newTag = {
|
||||
id: `custom-${Date.now()}`,
|
||||
name: customTagInput.trim(),
|
||||
};
|
||||
const updatedCustomTags = [...customTags, newTag];
|
||||
setCustomTags(updatedCustomTags);
|
||||
setCustomTagInput("");
|
||||
onChange({ ...formData, customTags: updatedCustomTags });
|
||||
};
|
||||
|
||||
// 删除自定义标签
|
||||
const handleRemoveCustomTag = (tagId: string) => {
|
||||
const updatedCustomTags = customTags.filter((tag: any) => tag.id !== tagId);
|
||||
setCustomTags(updatedCustomTags);
|
||||
onChange({ ...formData, customTags: updatedCustomTags });
|
||||
// 同时从选中标签中移除
|
||||
const updatedSelectedTags = selectedScenarioTags.filter(
|
||||
(t: string) => t !== tagId
|
||||
);
|
||||
setSelectedScenarioTags(updatedSelectedTags);
|
||||
onChange({
|
||||
...formData,
|
||||
scenarioTags: updatedSelectedTags,
|
||||
customTags: updatedCustomTags,
|
||||
});
|
||||
};
|
||||
|
||||
// 新增:自定义上传图片
|
||||
const handleCustomPosterUpload = (urls: string[]) => {
|
||||
if (urls && urls.length > 0) {
|
||||
const newPoster: Material = {
|
||||
id: `custom-${Date.now()}`,
|
||||
name: "自定义海报",
|
||||
type: "poster",
|
||||
preview: urls[0],
|
||||
};
|
||||
setCustomPosters((prev) => [...prev, newPoster]);
|
||||
}
|
||||
};
|
||||
|
||||
// 新增:删除自定义海报
|
||||
const handleRemoveCustomPoster = (id: string) => {
|
||||
setCustomPosters((prev) => prev.filter((p) => p.id !== id));
|
||||
// 如果选中则取消选中
|
||||
if (selectedMaterials.some((m) => m.id === id)) {
|
||||
setSelectedMaterials([]);
|
||||
onChange({ ...formData, materials: [] });
|
||||
}
|
||||
};
|
||||
|
||||
// 修改:选中/取消选中海报
|
||||
const handleMaterialSelect = (material: Material) => {
|
||||
const isSelected = selectedMaterials.some((m) => m.id === material.id);
|
||||
if (isSelected) {
|
||||
setSelectedMaterials([]);
|
||||
onChange({ ...formData, materials: [] });
|
||||
} else {
|
||||
setSelectedMaterials([material]);
|
||||
onChange({ ...formData, materials: [material] });
|
||||
}
|
||||
};
|
||||
|
||||
// 移除已选素材
|
||||
const handleRemoveMaterial = (id: string) => {
|
||||
setSelectedMaterials([]);
|
||||
onChange({ ...formData, materials: [] });
|
||||
};
|
||||
|
||||
// 新增:全屏预览
|
||||
const handlePreviewImage = (url: string) => {
|
||||
setPreviewUrl(url);
|
||||
setIsPreviewOpen(true);
|
||||
};
|
||||
|
||||
// 账号多选切换
|
||||
const handleAccountToggle = (account: Account) => {
|
||||
const isSelected = selectedAccounts.some(
|
||||
(a: Account) => a.id === account.id
|
||||
);
|
||||
let newSelected;
|
||||
if (isSelected) {
|
||||
newSelected = selectedAccounts.filter(
|
||||
(a: Account) => a.id !== account.id
|
||||
);
|
||||
} else {
|
||||
newSelected = [...selectedAccounts, account];
|
||||
}
|
||||
setSelectedAccounts(newSelected);
|
||||
onChange({ ...formData, accounts: newSelected });
|
||||
};
|
||||
|
||||
// 移除已选账号
|
||||
const handleRemoveAccount = (id: string) => {
|
||||
const newSelected = selectedAccounts.filter((a: Account) => a.id !== id);
|
||||
setSelectedAccounts(newSelected);
|
||||
onChange({ ...formData, accounts: newSelected });
|
||||
};
|
||||
|
||||
// 处理文件导入
|
||||
const handleFileImport = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const content = e.target?.result as string;
|
||||
const rows = content.split("\n").filter((row) => row.trim());
|
||||
const tags = rows.slice(1).map((row) => {
|
||||
const [phone, wechat, source, orderAmount, orderDate] =
|
||||
row.split(",");
|
||||
return {
|
||||
phone: phone?.trim(),
|
||||
wechat: wechat?.trim(),
|
||||
source: source?.trim(),
|
||||
orderAmount: orderAmount ? Number(orderAmount) : undefined,
|
||||
orderDate: orderDate?.trim(),
|
||||
};
|
||||
});
|
||||
setImportedTags(tags);
|
||||
onChange({ ...formData, importedTags: tags });
|
||||
} catch (error) {
|
||||
// 可用 toast 提示
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
};
|
||||
|
||||
// 下载模板
|
||||
const handleDownloadTemplate = () => {
|
||||
const template =
|
||||
"电话号码,微信号,来源,订单金额,下单日期\n13800138000,wxid_123,抖音,99.00,2024-03-03";
|
||||
const blob = new Blob([template], { type: "text/csv" });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "订单导入模板.csv";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
// 修改订单表格上传逻辑,使用 uploadFile 公共方法
|
||||
const [orderUploaded, setOrderUploaded] = useState(false);
|
||||
|
||||
const handleOrderFileUpload = async (
|
||||
event: React.ChangeEvent<HTMLInputElement>
|
||||
) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
try {
|
||||
await uploadFile(file); // 默认接口即可
|
||||
setOrderUploaded(true);
|
||||
onChange({ ...formData, orderFileUploaded: true });
|
||||
// 可用 toast 或其它方式提示成功
|
||||
// alert('上传成功');
|
||||
} catch (err) {
|
||||
// 可用 toast 或其它方式提示失败
|
||||
// alert('上传失败');
|
||||
}
|
||||
event.target.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
// 账号弹窗关闭时清理搜索等状态
|
||||
const handleAccountDialogClose = () => {
|
||||
setIsAccountDialogOpen(false);
|
||||
// 可在此清理账号搜索等临时状态
|
||||
};
|
||||
// 素材弹窗关闭时清理搜索等状态
|
||||
const handleMaterialDialogClose = () => {
|
||||
setIsMaterialDialogOpen(false);
|
||||
// 可在此清理素材搜索等临时状态
|
||||
};
|
||||
// 订单导入弹窗关闭时清理文件输入等状态
|
||||
const handleImportDialogClose = () => {
|
||||
setIsImportDialogOpen(false);
|
||||
// 可在此清理文件输入等临时状态
|
||||
};
|
||||
// 电话获客弹窗关闭
|
||||
const handlePhoneSettingsDialogClose = () => {
|
||||
setIsPhoneSettingsOpen(false);
|
||||
};
|
||||
// 图片预览关闭
|
||||
const handleImagePreviewClose = () => {
|
||||
setIsPreviewOpen(false);
|
||||
};
|
||||
|
||||
// 当前选中的场景对象
|
||||
const currentScene = sceneList.find((s) => s.id === formData.scenario);
|
||||
//打开订单
|
||||
const openOrder =
|
||||
formData.scenario !== 2 ? { display: "none" } : { display: "block" };
|
||||
|
||||
const openPoster =
|
||||
formData.scenario !== 1 ? { display: "none" } : { display: "block" };
|
||||
|
||||
return (
|
||||
<div className={styles["basic-container"]}>
|
||||
{/* 场景选择区块 */}
|
||||
<div className={styles["basic-scene-select"]}>
|
||||
{sceneLoading ? (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
minHeight: 80,
|
||||
}}
|
||||
>
|
||||
<Spin size="large"></Spin>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles["basic-scene-grid"]}>
|
||||
{sceneList.map((scene) => {
|
||||
const selected = formData.scenario === scene.id;
|
||||
return (
|
||||
<button
|
||||
key={scene.id}
|
||||
onClick={() => handleScenarioSelect(scene.id)}
|
||||
className={
|
||||
styles["basic-scene-btn"] +
|
||||
(selected ? " " + styles.selected : "")
|
||||
}
|
||||
>
|
||||
{scene.name.replace("获客", "")}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* 计划名称输入区 */}
|
||||
<div className={styles["basic-label"]}>计划名称</div>
|
||||
<div className={styles["basic-input-block"]}>
|
||||
<Input
|
||||
value={formData.name}
|
||||
onChange={(e) =>
|
||||
onChange({ ...formData, name: String(e.target.value) })
|
||||
}
|
||||
placeholder="请输入计划名称"
|
||||
/>
|
||||
</div>
|
||||
<div className={styles["basic-label"]}>获客标签(可多选)</div>
|
||||
{/* 标签选择区块 */}
|
||||
{formData.scenario && (
|
||||
<div className={styles["basic-tag-list"]}>
|
||||
{(currentScene?.scenarioTags || []).map((tag: string) => (
|
||||
<Tag
|
||||
key={tag}
|
||||
color={selectedScenarioTags.includes(tag) ? "blue" : "default"}
|
||||
onClick={() => handleScenarioTagToggle(tag)}
|
||||
className={styles["basic-tag-item"]}
|
||||
>
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
{/* 自定义标签 */}
|
||||
{customTags.map((tag: any) => (
|
||||
<Tag
|
||||
key={tag.id}
|
||||
color={selectedScenarioTags.includes(tag.id) ? "blue" : "default"}
|
||||
onClick={() => handleScenarioTagToggle(tag.id)}
|
||||
closable
|
||||
onClose={() => handleRemoveCustomTag(tag.id)}
|
||||
className={styles["basic-tag-item"]}
|
||||
>
|
||||
{tag.name}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* 自定义标签输入区 */}
|
||||
<div className={styles["basic-custom-tag-input"]}>
|
||||
<Input
|
||||
type="text"
|
||||
value={customTagInput}
|
||||
onChange={(e) => setCustomTagInput(e.target.value)}
|
||||
placeholder="添加自定义标签"
|
||||
/>
|
||||
<Button type="primary" onClick={handleAddCustomTag}>
|
||||
添加
|
||||
</Button>
|
||||
</div>
|
||||
{/* 输入获客成功提示 */}
|
||||
<div className={styles["basic-success-tip"]}>
|
||||
<Input
|
||||
type="text"
|
||||
value={tips}
|
||||
onChange={(e) => {
|
||||
setTips(e.target.value);
|
||||
onChange({ ...formData, tips: e.target.value });
|
||||
}}
|
||||
placeholder="请输入获客成功提示"
|
||||
/>
|
||||
</div>
|
||||
{/* 选素材 */}
|
||||
<div className={styles["basic-materials"]} style={openPoster}>
|
||||
<div className={styles["basic-label"]}>选择海报</div>
|
||||
<div className={styles["basic-materials-grid"]}>
|
||||
{[...materials, ...customPosters].map((material) => {
|
||||
const isSelected = selectedMaterials.some(
|
||||
(m) => m.id === material.id
|
||||
);
|
||||
const isCustom = material.id.startsWith("custom-");
|
||||
return (
|
||||
<div
|
||||
key={material.id}
|
||||
className={
|
||||
styles["basic-material-card"] +
|
||||
(isSelected ? " " + styles.selected : "")
|
||||
}
|
||||
onClick={() => handleMaterialSelect(material)}
|
||||
>
|
||||
{/* 预览按钮:自定义海报在左上,内置海报在右上 */}
|
||||
<span
|
||||
className={styles["basic-material-preview"]}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePreviewImage(material.preview);
|
||||
}}
|
||||
>
|
||||
<EyeOutlined
|
||||
style={{ color: "#fff", width: 18, height: 18 }}
|
||||
/>
|
||||
</span>
|
||||
{/* 删除自定义海报按钮 */}
|
||||
{isCustom && (
|
||||
<Button
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
right: 8,
|
||||
width: 28,
|
||||
height: 28,
|
||||
background: "rgba(0,0,0,0.5)",
|
||||
border: "none",
|
||||
borderRadius: "50%",
|
||||
zIndex: 2,
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
lineHeight: 20,
|
||||
color: "#ffffff",
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRemoveCustomPoster(material.id);
|
||||
}}
|
||||
>
|
||||
<CloseOutlined />
|
||||
</Button>
|
||||
)}
|
||||
<img
|
||||
src={material.preview}
|
||||
alt={material.name}
|
||||
className={styles["basic-material-img"]}
|
||||
/>
|
||||
<div className={styles["basic-material-name"]}>
|
||||
{material.name}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* 添加海报卡片 */}
|
||||
<div
|
||||
className={styles["basic-add-material"]}
|
||||
onClick={() => uploadInputRef.current?.click()}
|
||||
>
|
||||
<span style={{ fontSize: 36, color: "#bbb", marginBottom: 8 }}>
|
||||
<PlusOutlined />
|
||||
</span>
|
||||
<span style={{ color: "#888" }}>添加海报</span>
|
||||
<input
|
||||
ref={uploadInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: "none" }}
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
// 直接上传
|
||||
try {
|
||||
const url = await uploadFile(file);
|
||||
const newPoster = {
|
||||
id: `custom-${Date.now()}`,
|
||||
name: "自定义海报",
|
||||
type: "poster",
|
||||
preview: url,
|
||||
};
|
||||
setCustomPosters((prev) => [...prev, newPoster]);
|
||||
} catch (err) {
|
||||
// 可加toast提示
|
||||
}
|
||||
e.target.value = "";
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* 全屏图片预览 */}
|
||||
<Modal
|
||||
open={isPreviewOpen}
|
||||
onCancel={() => {
|
||||
setIsPreviewOpen(false);
|
||||
setPreviewUrl(null);
|
||||
}}
|
||||
footer={null}
|
||||
width={800}
|
||||
>
|
||||
{previewUrl && (
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Preview"
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
{/* 订单导入区块优化 */}
|
||||
<div className={styles["basic-order-upload"]} style={openOrder}>
|
||||
<div className={styles["basic-order-upload-label"]}>订单表格上传</div>
|
||||
<div className={styles["basic-order-upload-actions"]}>
|
||||
<Button
|
||||
style={{ display: "flex", alignItems: "center", gap: 4 }}
|
||||
onClick={handleDownloadTemplate}
|
||||
>
|
||||
<DownloadOutlined style={{ fontSize: 18 }} /> 下载模板
|
||||
</Button>
|
||||
<Button
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
...(orderUploaded && {
|
||||
backgroundColor: "#52c41a",
|
||||
color: "#fff",
|
||||
borderColor: "#52c41a",
|
||||
}),
|
||||
}}
|
||||
onClick={() => uploadOrderInputRef.current?.click()}
|
||||
>
|
||||
<span className="iconfont" style={{ fontSize: 18 }}>
|
||||
{orderUploaded ? <CheckOutlined /> : <UploadOutlined />}
|
||||
</span>{" "}
|
||||
{orderUploaded ? "已上传" : "上传订单表格"}
|
||||
<input
|
||||
ref={uploadOrderInputRef}
|
||||
type="file"
|
||||
accept=".csv, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, application/vnd.ms-excel"
|
||||
style={{ display: "none" }}
|
||||
onChange={handleOrderFileUpload}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles["basic-order-upload-tip"]}>
|
||||
支持 CSV、Excel 格式,上传后将文件保存到服务器
|
||||
</div>
|
||||
</div>
|
||||
{/* 电话获客设置区块,仅在选择电话获客场景时显示 */}
|
||||
{formData.scenario === 5 && (
|
||||
<div className={styles["basic-phone-settings"]}>
|
||||
<div style={{ fontWeight: 600, fontSize: 16, marginBottom: 16 }}>
|
||||
电话获客设置
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 18 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<span>自动加好友</span>
|
||||
<Switch
|
||||
checked={phoneSettings.autoAdd}
|
||||
onChange={(v) =>
|
||||
setPhoneSettings((s) => ({ ...s, autoAdd: v }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<span>语音转文字</span>
|
||||
<Switch
|
||||
checked={phoneSettings.speechToText}
|
||||
onChange={(v) =>
|
||||
setPhoneSettings((s) => ({ ...s, speechToText: v }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<span>问题提取</span>
|
||||
<Switch
|
||||
checked={phoneSettings.questionExtraction}
|
||||
onChange={(v) =>
|
||||
setPhoneSettings((s) => ({ ...s, questionExtraction: v }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* 微信群设置区块,仅在选择微信群场景时显示 */}
|
||||
{formData.scenario === 7 && (
|
||||
<div className={styles["basic-wechat-group"]}>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<Input
|
||||
value={weixinqunName}
|
||||
onChange={setWeixinqunName}
|
||||
placeholder="微信群名称"
|
||||
maxLength={20}
|
||||
onBlur={() => onChange({ ...formData, weixinqunName })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Input
|
||||
value={weixinqunNotice}
|
||||
onChange={setWeixinqunNotice}
|
||||
placeholder="群公告/欢迎语"
|
||||
maxLength={50}
|
||||
onBlur={() => onChange({ ...formData, weixinqunNotice })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles["basic-footer-switch"]}>
|
||||
<span>是否启用</span>
|
||||
<Switch
|
||||
checked={formData.enabled}
|
||||
onChange={(value) => onChange({ ...formData, enabled: value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles["basic-footer-switch"]}>
|
||||
<ButtonMobile block color="primary" onClick={onNext}>
|
||||
下一步
|
||||
</ButtonMobile>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BasicSettings;
|
||||
@@ -0,0 +1,244 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Input, Button, Modal, Alert, Select } from "antd";
|
||||
import { MessageOutlined } from "@ant-design/icons";
|
||||
import DeviceSelection from "@/components/DeviceSelection";
|
||||
import styles from "./friend.module.scss";
|
||||
|
||||
interface FriendRequestSettingsProps {
|
||||
formData: any;
|
||||
onChange: (data: any) => void;
|
||||
onNext: () => void;
|
||||
onPrev: () => void;
|
||||
}
|
||||
|
||||
// 招呼语模板
|
||||
const greetingTemplates = [
|
||||
"你好,请通过",
|
||||
"你好,了解XX,请通过",
|
||||
"你好,我是XX产品的客服请通过",
|
||||
"你好,感谢关注我们的产品",
|
||||
"你好,很高兴为您服务",
|
||||
];
|
||||
|
||||
// 备注类型选项
|
||||
const remarkTypes = [
|
||||
{ value: "phone", label: "手机号" },
|
||||
{ value: "nickname", label: "昵称" },
|
||||
{ value: "source", label: "来源" },
|
||||
];
|
||||
|
||||
const FriendRequestSettings: React.FC<FriendRequestSettingsProps> = ({
|
||||
formData,
|
||||
onChange,
|
||||
onNext,
|
||||
onPrev,
|
||||
}) => {
|
||||
const [isTemplateDialogOpen, setIsTemplateDialogOpen] = useState(false);
|
||||
const [hasWarnings, setHasWarnings] = useState(false);
|
||||
const [selectedDevices, setSelectedDevices] = useState<string[]>(
|
||||
formData.device || []
|
||||
);
|
||||
const [showRemarkTip, setShowRemarkTip] = useState(false);
|
||||
|
||||
// 获取场景标题
|
||||
const getScenarioTitle = () => {
|
||||
switch (formData.scenario) {
|
||||
case "douyin":
|
||||
return "抖音直播";
|
||||
case "xiaohongshu":
|
||||
return "小红书";
|
||||
case "weixinqun":
|
||||
return "微信群";
|
||||
case "gongzhonghao":
|
||||
return "公众号";
|
||||
default:
|
||||
return formData.name || "获客计划";
|
||||
}
|
||||
};
|
||||
|
||||
// 使用useEffect设置默认值
|
||||
useEffect(() => {
|
||||
if (!formData.greeting) {
|
||||
onChange({
|
||||
...formData,
|
||||
greeting: "你好,请通过",
|
||||
remarkType: "phone", // 默认选择手机号
|
||||
remarkFormat: `手机号+${getScenarioTitle()}`, // 默认备注格式
|
||||
addFriendInterval: 1,
|
||||
});
|
||||
}
|
||||
}, [formData, formData.greeting, onChange]);
|
||||
|
||||
// 检查是否有未完成的必填项
|
||||
useEffect(() => {
|
||||
const hasIncompleteFields = !formData.greeting?.trim();
|
||||
setHasWarnings(hasIncompleteFields);
|
||||
}, [formData]);
|
||||
|
||||
const handleTemplateSelect = (template: string) => {
|
||||
onChange({ ...formData, greeting: template });
|
||||
setIsTemplateDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
// 即使有警告也允许进入下一步,但会显示提示
|
||||
onNext();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles["friend-container"]}>
|
||||
{/* 选择设备区块 */}
|
||||
<div className={styles["friend-label"]}>选择设备</div>
|
||||
<div className={styles["friend-block"]}>
|
||||
<DeviceSelection
|
||||
selectedDevices={selectedDevices}
|
||||
onSelect={(deviceIds) => {
|
||||
setSelectedDevices(deviceIds);
|
||||
onChange({ ...formData, device: deviceIds });
|
||||
}}
|
||||
placeholder="选择设备"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 好友备注区块 */}
|
||||
<div className={styles["friend-label"]}>好友备注</div>
|
||||
<div className={styles["friend-block"]} style={{ position: "relative" }}>
|
||||
<Select
|
||||
value={formData.remarkType || "phone"}
|
||||
onChange={(value) => onChange({ ...formData, remarkType: value })}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
{remarkTypes.map((type) => (
|
||||
<Select.Option key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<span
|
||||
className={styles["friend-remark-q"]}
|
||||
onMouseEnter={() => setShowRemarkTip(true)}
|
||||
onMouseLeave={() => setShowRemarkTip(false)}
|
||||
>
|
||||
?
|
||||
</span>
|
||||
{showRemarkTip && (
|
||||
<div className={styles["friend-remark-tip"]}>
|
||||
<div>设置添加好友时的备注格式</div>
|
||||
<div style={{ marginTop: 8, color: "#888", fontSize: 12 }}>
|
||||
备注格式预览:
|
||||
</div>
|
||||
<div style={{ marginTop: 4, color: "#1677ff" }}>
|
||||
{formData.remarkType === "phone" &&
|
||||
`138****1234+${getScenarioTitle()}`}
|
||||
{formData.remarkType === "nickname" &&
|
||||
`小红书用户2851+${getScenarioTitle()}`}
|
||||
{formData.remarkType === "source" &&
|
||||
`抖音直播+${getScenarioTitle()}`}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 招呼语区块 */}
|
||||
<div className={styles["friend-label"]}>招呼语</div>
|
||||
<div className={styles["friend-block"]}>
|
||||
<Input
|
||||
value={formData.greeting}
|
||||
onChange={(e) => onChange({ ...formData, greeting: e.target.value })}
|
||||
placeholder="请输入招呼语"
|
||||
suffix={
|
||||
<Button
|
||||
type="link"
|
||||
onClick={() => setIsTemplateDialogOpen(true)}
|
||||
style={{ padding: 0 }}
|
||||
>
|
||||
<MessageOutlined /> 参考模板
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 添加间隔区块 */}
|
||||
<div className={styles["friend-label"]}>添加间隔</div>
|
||||
<div
|
||||
className={styles["friend-interval-row"] + " " + styles["friend-block"]}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
value={formData.addFriendInterval || 1}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...formData,
|
||||
addFriendInterval: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
style={{ width: 100 }}
|
||||
/>
|
||||
<span>分钟</span>
|
||||
</div>
|
||||
|
||||
{/* 允许加人时间段区块 */}
|
||||
<div className={styles["friend-label"]}>允许加人的时间段</div>
|
||||
<div className={styles["friend-time-row"] + " " + styles["friend-block"]}>
|
||||
<Input
|
||||
type="time"
|
||||
value={formData.addFriendTimeStart || "09:00"}
|
||||
onChange={(e) =>
|
||||
onChange({ ...formData, addFriendTimeStart: e.target.value })
|
||||
}
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
<span>至</span>
|
||||
<Input
|
||||
type="time"
|
||||
value={formData.addFriendTimeEnd || "18:00"}
|
||||
onChange={(e) =>
|
||||
onChange({ ...formData, addFriendTimeEnd: e.target.value })
|
||||
}
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hasWarnings && (
|
||||
<Alert
|
||||
message="警告"
|
||||
description="您有未完成的设置项,建议完善后再进入下一步。"
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 底部按钮 */}
|
||||
<div className={styles["friend-footer"]}>
|
||||
<Button onClick={onPrev}>上一步</Button>
|
||||
<Button type="primary" onClick={handleNext}>
|
||||
下一步
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 招呼语模板弹窗 */}
|
||||
<Modal
|
||||
open={isTemplateDialogOpen}
|
||||
onCancel={() => setIsTemplateDialogOpen(false)}
|
||||
footer={null}
|
||||
>
|
||||
<div>
|
||||
{greetingTemplates.map((template, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
onClick={() => handleTemplateSelect(template)}
|
||||
className={styles["friend-modal-btn"]}
|
||||
>
|
||||
{template}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FriendRequestSettings;
|
||||
@@ -0,0 +1,645 @@
|
||||
import React, { useState, useRef } from "react";
|
||||
import { Input, Button, Tabs, Modal, Alert, message } from "antd";
|
||||
import {
|
||||
PlusOutlined,
|
||||
CloseOutlined,
|
||||
UploadOutlined,
|
||||
ClockCircleOutlined,
|
||||
MessageOutlined,
|
||||
PictureOutlined,
|
||||
VideoCameraOutlined,
|
||||
FileOutlined,
|
||||
AppstoreOutlined,
|
||||
LinkOutlined,
|
||||
TeamOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import styles from "./messages.module.scss";
|
||||
import { uploadFile } from "@/api/common";
|
||||
|
||||
interface MessageContent {
|
||||
id: string;
|
||||
type: "text" | "image" | "video" | "file" | "miniprogram" | "link" | "group";
|
||||
content: string;
|
||||
sendInterval?: number;
|
||||
intervalUnit?: "seconds" | "minutes";
|
||||
scheduledTime?: {
|
||||
hour: number;
|
||||
minute: number;
|
||||
second: number;
|
||||
};
|
||||
title?: string;
|
||||
description?: string;
|
||||
address?: string;
|
||||
coverImage?: string;
|
||||
groupId?: string;
|
||||
linkUrl?: string;
|
||||
}
|
||||
|
||||
interface DayPlan {
|
||||
day: number;
|
||||
messages: MessageContent[];
|
||||
}
|
||||
|
||||
interface MessageSettingsProps {
|
||||
formData: any;
|
||||
onChange: (data: any) => void;
|
||||
onNext: () => void;
|
||||
onPrev: () => void;
|
||||
}
|
||||
|
||||
// 消息类型配置
|
||||
const messageTypes = [
|
||||
{ id: "text", icon: MessageOutlined, label: "文本" },
|
||||
{ id: "image", icon: PictureOutlined, label: "图片" },
|
||||
{ id: "video", icon: VideoCameraOutlined, label: "视频" },
|
||||
{ id: "file", icon: FileOutlined, label: "文件" },
|
||||
{ id: "miniprogram", icon: AppstoreOutlined, label: "小程序" },
|
||||
{ id: "link", icon: LinkOutlined, label: "链接" },
|
||||
{ id: "group", icon: TeamOutlined, label: "邀请入群" },
|
||||
];
|
||||
|
||||
// 模拟群组数据
|
||||
const mockGroups = [
|
||||
{ id: "1", name: "产品交流群1", memberCount: 156 },
|
||||
{ id: "2", name: "产品交流群2", memberCount: 234 },
|
||||
{ id: "3", name: "产品交流群3", memberCount: 89 },
|
||||
];
|
||||
|
||||
const MessageSettings: React.FC<MessageSettingsProps> = ({
|
||||
formData,
|
||||
onChange,
|
||||
onNext,
|
||||
onPrev,
|
||||
}) => {
|
||||
const [dayPlans, setDayPlans] = useState<DayPlan[]>([
|
||||
{
|
||||
day: 0,
|
||||
messages: [
|
||||
{
|
||||
id: "1",
|
||||
type: "text",
|
||||
content: "",
|
||||
sendInterval: 5,
|
||||
intervalUnit: "seconds",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
const [isAddDayPlanOpen, setIsAddDayPlanOpen] = useState(false);
|
||||
const [isGroupSelectOpen, setIsGroupSelectOpen] = useState(false);
|
||||
const [selectedGroupId, setSelectedGroupId] = useState("");
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [uploadingIndex, setUploadingIndex] = useState<string | null>(null);
|
||||
const [uploadingType, setUploadingType] = useState<
|
||||
"miniprogram" | "link" | null
|
||||
>(null);
|
||||
const [uploadingDay, setUploadingDay] = useState<number | null>(null);
|
||||
const [uploadingMsgIdx, setUploadingMsgIdx] = useState<number | null>(null);
|
||||
|
||||
// 添加新消息
|
||||
const handleAddMessage = (dayIndex: number, type = "text") => {
|
||||
const updatedPlans = [...dayPlans];
|
||||
const newMessage: MessageContent = {
|
||||
id: Date.now().toString(),
|
||||
type: type as MessageContent["type"],
|
||||
content: "",
|
||||
};
|
||||
|
||||
if (dayPlans[dayIndex].day === 0) {
|
||||
newMessage.sendInterval = 5;
|
||||
newMessage.intervalUnit = "seconds";
|
||||
} else {
|
||||
newMessage.scheduledTime = {
|
||||
hour: 9,
|
||||
minute: 0,
|
||||
second: 0,
|
||||
};
|
||||
}
|
||||
|
||||
updatedPlans[dayIndex].messages.push(newMessage);
|
||||
setDayPlans(updatedPlans);
|
||||
onChange({ ...formData, messagePlans: updatedPlans });
|
||||
};
|
||||
|
||||
// 更新消息内容
|
||||
const handleUpdateMessage = (
|
||||
dayIndex: number,
|
||||
messageIndex: number,
|
||||
updates: Partial<MessageContent>
|
||||
) => {
|
||||
const updatedPlans = [...dayPlans];
|
||||
updatedPlans[dayIndex].messages[messageIndex] = {
|
||||
...updatedPlans[dayIndex].messages[messageIndex],
|
||||
...updates,
|
||||
};
|
||||
setDayPlans(updatedPlans);
|
||||
onChange({ ...formData, messagePlans: updatedPlans });
|
||||
};
|
||||
|
||||
// 删除消息
|
||||
const handleRemoveMessage = (dayIndex: number, messageIndex: number) => {
|
||||
const updatedPlans = [...dayPlans];
|
||||
updatedPlans[dayIndex].messages.splice(messageIndex, 1);
|
||||
setDayPlans(updatedPlans);
|
||||
onChange({ ...formData, messagePlans: updatedPlans });
|
||||
};
|
||||
|
||||
// 切换时间单位
|
||||
const toggleIntervalUnit = (dayIndex: number, messageIndex: number) => {
|
||||
const message = dayPlans[dayIndex].messages[messageIndex];
|
||||
const newUnit = message.intervalUnit === "minutes" ? "seconds" : "minutes";
|
||||
handleUpdateMessage(dayIndex, messageIndex, { intervalUnit: newUnit });
|
||||
};
|
||||
|
||||
// 添加新的天数计划
|
||||
const handleAddDayPlan = () => {
|
||||
const newDay = dayPlans.length;
|
||||
setDayPlans([
|
||||
...dayPlans,
|
||||
{
|
||||
day: newDay,
|
||||
messages: [
|
||||
{
|
||||
id: Date.now().toString(),
|
||||
type: "text",
|
||||
content: "",
|
||||
scheduledTime: {
|
||||
hour: 9,
|
||||
minute: 0,
|
||||
second: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
setIsAddDayPlanOpen(false);
|
||||
message.success(`已添加第${newDay}天的消息计划`);
|
||||
};
|
||||
|
||||
// 选择群组
|
||||
const handleSelectGroup = (groupId: string) => {
|
||||
setSelectedGroupId(groupId);
|
||||
setIsGroupSelectOpen(false);
|
||||
message.success(
|
||||
`已选择群组:${mockGroups.find((g) => g.id === groupId)?.name}`
|
||||
);
|
||||
};
|
||||
|
||||
// 触发文件选择
|
||||
const triggerUpload = (
|
||||
dayIdx: number,
|
||||
msgIdx: number,
|
||||
type: "miniprogram" | "link"
|
||||
) => {
|
||||
setUploadingDay(dayIdx);
|
||||
setUploadingMsgIdx(msgIdx);
|
||||
setUploadingType(type);
|
||||
setTimeout(() => {
|
||||
fileInputRef.current?.click();
|
||||
}, 0);
|
||||
};
|
||||
|
||||
// 处理文件上传
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (
|
||||
!file ||
|
||||
uploadingDay === null ||
|
||||
uploadingMsgIdx === null ||
|
||||
!uploadingType
|
||||
)
|
||||
return;
|
||||
setUploadingIndex(`${uploadingDay}-${uploadingMsgIdx}`);
|
||||
try {
|
||||
const url = await uploadFile(file);
|
||||
// 更新对应消息的coverImage
|
||||
setDayPlans((prev) => {
|
||||
const newPlans = [...prev];
|
||||
const msg = newPlans[uploadingDay].messages[uploadingMsgIdx];
|
||||
msg.coverImage = url;
|
||||
return newPlans;
|
||||
});
|
||||
message.success("上传成功");
|
||||
} catch (err) {
|
||||
message.error("上传失败");
|
||||
} finally {
|
||||
setUploadingIndex(null);
|
||||
setUploadingType(null);
|
||||
setUploadingDay(null);
|
||||
setUploadingMsgIdx(null);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const items = dayPlans.map((plan, dayIndex) => ({
|
||||
key: plan.day.toString(),
|
||||
label: plan.day === 0 ? "即时消息" : `第${plan.day}天`,
|
||||
children: (
|
||||
<div className={styles["messages-day-panel"]}>
|
||||
{plan.messages.map((message, messageIndex) => (
|
||||
<div key={message.id} className={styles["messages-message-card"]}>
|
||||
<div className={styles["messages-message-header"]}>
|
||||
{/* 时间/间隔设置 */}
|
||||
<div className={styles["messages-message-header-content"]}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
{plan.day === 0 ? (
|
||||
<>
|
||||
<span style={{ minWidth: 36 }}>间隔</span>
|
||||
<Input
|
||||
type="number"
|
||||
value={String(message.sendInterval || 5)}
|
||||
onChange={(e) =>
|
||||
handleUpdateMessage(dayIndex, messageIndex, {
|
||||
sendInterval: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
style={{ width: 60 }}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() =>
|
||||
toggleIntervalUnit(dayIndex, messageIndex)
|
||||
}
|
||||
>
|
||||
<ClockCircleOutlined />
|
||||
{message.intervalUnit === "minutes" ? "分钟" : "秒"}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span style={{ minWidth: 60 }}>发送时间</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={23}
|
||||
value={String(message.scheduledTime?.hour || 9)}
|
||||
onChange={(e) =>
|
||||
handleUpdateMessage(dayIndex, messageIndex, {
|
||||
scheduledTime: {
|
||||
...(message.scheduledTime || {
|
||||
hour: 9,
|
||||
minute: 0,
|
||||
second: 0,
|
||||
}),
|
||||
hour: Number(e.target.value),
|
||||
},
|
||||
})
|
||||
}
|
||||
style={{ width: 40 }}
|
||||
/>
|
||||
<span>:</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={59}
|
||||
value={String(message.scheduledTime?.minute || 0)}
|
||||
onChange={(e) =>
|
||||
handleUpdateMessage(dayIndex, messageIndex, {
|
||||
scheduledTime: {
|
||||
...(message.scheduledTime || {
|
||||
hour: 9,
|
||||
minute: 0,
|
||||
second: 0,
|
||||
}),
|
||||
minute: Number(e.target.value),
|
||||
},
|
||||
})
|
||||
}
|
||||
style={{ width: 40 }}
|
||||
/>
|
||||
<span>:</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={59}
|
||||
value={String(message.scheduledTime?.second || 0)}
|
||||
onChange={(e) =>
|
||||
handleUpdateMessage(dayIndex, messageIndex, {
|
||||
scheduledTime: {
|
||||
...(message.scheduledTime || {
|
||||
hour: 9,
|
||||
minute: 0,
|
||||
second: 0,
|
||||
}),
|
||||
second: Number(e.target.value),
|
||||
},
|
||||
})
|
||||
}
|
||||
style={{ width: 40 }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className={styles["messages-message-remove-btn"]}
|
||||
onClick={() => handleRemoveMessage(dayIndex, messageIndex)}
|
||||
title="删除"
|
||||
>
|
||||
<CloseOutlined />
|
||||
</button>
|
||||
</div>
|
||||
{/* 类型切换按钮 */}
|
||||
<div className={styles["messages-message-type-btns"]}>
|
||||
{messageTypes.map((type) => (
|
||||
<Button
|
||||
key={type.id}
|
||||
type={message.type === type.id ? "primary" : "default"}
|
||||
onClick={() =>
|
||||
handleUpdateMessage(dayIndex, messageIndex, {
|
||||
type: type.id as any,
|
||||
})
|
||||
}
|
||||
className={styles["messages-message-type-btn"]}
|
||||
>
|
||||
<type.icon />
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles["messages-message-content"]}>
|
||||
{/* 文本消息 */}
|
||||
{message.type === "text" && (
|
||||
<Input.TextArea
|
||||
value={message.content}
|
||||
onChange={(e) =>
|
||||
handleUpdateMessage(dayIndex, messageIndex, {
|
||||
content: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="请输入消息内容"
|
||||
autoSize={{ minRows: 3, maxRows: 6 }}
|
||||
/>
|
||||
)}
|
||||
{/* 小程序消息 */}
|
||||
{message.type === "miniprogram" && (
|
||||
<>
|
||||
<Input
|
||||
value={message.title}
|
||||
onChange={(e) =>
|
||||
handleUpdateMessage(dayIndex, messageIndex, {
|
||||
title: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="请输入小程序标题"
|
||||
style={{ marginBottom: 8 }}
|
||||
/>
|
||||
<Input
|
||||
value={message.description}
|
||||
onChange={(e) =>
|
||||
handleUpdateMessage(dayIndex, messageIndex, {
|
||||
description: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="请输入小程序描述"
|
||||
style={{ marginBottom: 8 }}
|
||||
/>
|
||||
<Input
|
||||
value={message.address}
|
||||
onChange={(e) =>
|
||||
handleUpdateMessage(dayIndex, messageIndex, {
|
||||
address: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="请输入小程序路径"
|
||||
style={{ marginBottom: 8 }}
|
||||
/>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
{message.coverImage ? (
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
display: "inline-block",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={message.coverImage}
|
||||
alt="封面"
|
||||
style={{ width: 120, borderRadius: 6 }}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() =>
|
||||
handleUpdateMessage(dayIndex, messageIndex, {
|
||||
coverImage: undefined,
|
||||
})
|
||||
}
|
||||
style={{ position: "absolute", top: 0, right: 0 }}
|
||||
>
|
||||
<CloseOutlined />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
icon={<UploadOutlined />}
|
||||
loading={
|
||||
uploadingIndex === `${dayIndex}-${messageIndex}`
|
||||
}
|
||||
onClick={() =>
|
||||
triggerUpload(dayIndex, messageIndex, "miniprogram")
|
||||
}
|
||||
>
|
||||
上传封面
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{/* 链接消息 */}
|
||||
{message.type === "link" && (
|
||||
<>
|
||||
<Input
|
||||
value={message.title}
|
||||
onChange={(e) =>
|
||||
handleUpdateMessage(dayIndex, messageIndex, {
|
||||
title: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="请输入链接标题"
|
||||
style={{ marginBottom: 8 }}
|
||||
/>
|
||||
<Input
|
||||
value={message.description}
|
||||
onChange={(e) =>
|
||||
handleUpdateMessage(dayIndex, messageIndex, {
|
||||
description: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="请输入链接描述"
|
||||
style={{ marginBottom: 8 }}
|
||||
/>
|
||||
<Input
|
||||
value={message.linkUrl}
|
||||
onChange={(e) =>
|
||||
handleUpdateMessage(dayIndex, messageIndex, {
|
||||
linkUrl: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="请输入链接地址"
|
||||
style={{ marginBottom: 8 }}
|
||||
/>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
{message.coverImage ? (
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
display: "inline-block",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={message.coverImage}
|
||||
alt="封面"
|
||||
style={{ width: 120, borderRadius: 6 }}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() =>
|
||||
handleUpdateMessage(dayIndex, messageIndex, {
|
||||
coverImage: undefined,
|
||||
})
|
||||
}
|
||||
style={{ position: "absolute", top: 0, right: 0 }}
|
||||
>
|
||||
<CloseOutlined />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
icon={<UploadOutlined />}
|
||||
loading={
|
||||
uploadingIndex === `${dayIndex}-${messageIndex}`
|
||||
}
|
||||
onClick={() =>
|
||||
triggerUpload(dayIndex, messageIndex, "link")
|
||||
}
|
||||
>
|
||||
上传封面
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{/* 群邀请消息 */}
|
||||
{message.type === "group" && (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<Button onClick={() => setIsGroupSelectOpen(true)}>
|
||||
{selectedGroupId
|
||||
? mockGroups.find((g) => g.id === selectedGroupId)?.name
|
||||
: "选择邀请入的群"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{/* 图片/视频/文件消息 */}
|
||||
{(message.type === "image" ||
|
||||
message.type === "video" ||
|
||||
message.type === "file") && (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<Button
|
||||
icon={<UploadOutlined />}
|
||||
onClick={() =>
|
||||
handleFileUpload(
|
||||
dayIndex,
|
||||
messageIndex,
|
||||
message.type as any
|
||||
)
|
||||
}
|
||||
>
|
||||
上传
|
||||
{message.type === "image"
|
||||
? "图片"
|
||||
: message.type === "video"
|
||||
? "视频"
|
||||
: "文件"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
onClick={() => handleAddMessage(dayIndex)}
|
||||
className={styles["messages-add-message-btn"]}
|
||||
>
|
||||
<PlusOutlined className="w-4 h-4 mr-2" />
|
||||
添加消息
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className={styles["messages-container"]}>
|
||||
<div className={styles["messages-header"]}>
|
||||
<h2 className={styles["messages-title"]}>消息设置</h2>
|
||||
<Button onClick={() => setIsAddDayPlanOpen(true)}>
|
||||
<PlusOutlined />
|
||||
</Button>
|
||||
</div>
|
||||
<Tabs
|
||||
defaultActiveKey="0"
|
||||
items={items}
|
||||
className={styles["messages-tab"]}
|
||||
/>
|
||||
<div className={styles["messages-footer"]}>
|
||||
<Button onClick={onPrev}>上一步</Button>
|
||||
<Button type="primary" onClick={onNext}>
|
||||
下一步
|
||||
</Button>
|
||||
</div>
|
||||
{/* 添加天数计划弹窗 */}
|
||||
<Modal
|
||||
title="添加消息计划"
|
||||
open={isAddDayPlanOpen}
|
||||
onCancel={() => setIsAddDayPlanOpen(false)}
|
||||
onOk={() => {
|
||||
handleAddDayPlan();
|
||||
setIsAddDayPlanOpen(false);
|
||||
}}
|
||||
>
|
||||
<p className="text-sm text-gray-500 mb-4">选择要添加的消息计划类型</p>
|
||||
<Button
|
||||
onClick={handleAddDayPlan}
|
||||
className={styles["messages-modal-btn"]}
|
||||
>
|
||||
添加第 {dayPlans.length} 天计划
|
||||
</Button>
|
||||
</Modal>
|
||||
{/* 选择群聊弹窗 */}
|
||||
<Modal
|
||||
title="选择群聊"
|
||||
open={isGroupSelectOpen}
|
||||
onCancel={() => setIsGroupSelectOpen(false)}
|
||||
onOk={() => {
|
||||
handleSelectGroup(selectedGroupId);
|
||||
setIsGroupSelectOpen(false);
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
{mockGroups.map((group) => (
|
||||
<div
|
||||
key={group.id}
|
||||
className={
|
||||
styles["messages-group-select-item"] +
|
||||
(selectedGroupId === group.id ? " " + styles.selected : "")
|
||||
}
|
||||
onClick={() => handleSelectGroup(group.id)}
|
||||
>
|
||||
<div className="font-medium">{group.name}</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
成员数:{group.memberCount}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: "none" }}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MessageSettings;
|
||||
@@ -0,0 +1,164 @@
|
||||
.basic-container {
|
||||
padding: 12px;
|
||||
}
|
||||
.basic-scene-select {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.basic-scene-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 20px;
|
||||
}
|
||||
.basic-scene-btn {
|
||||
height: 40px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-weight: 500;
|
||||
font-size: 16px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
background: rgba(#1677ff,0.1);
|
||||
color: #1677ff;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.basic-scene-btn.selected {
|
||||
background: #1677ff;
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 8px rgba(22,119,255,0.08);
|
||||
}
|
||||
.basic-label {
|
||||
margin-bottom: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.basic-input-block {
|
||||
border: 1px solid #eee;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.basic-tag-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
.basic-tag-item{
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.basic-custom-tag-input {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.basic-success-tip {
|
||||
display: flex;
|
||||
padding-top: 16px;
|
||||
}
|
||||
.basic-materials {
|
||||
margin: 16px 0;
|
||||
}
|
||||
.basic-materials-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
|
||||
.basic-material-preview{
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
padding-left: 2px;
|
||||
right: 8px;
|
||||
background:rgba(0,0,0,0.5);
|
||||
border-radius: 50%;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2;
|
||||
cursor:pointer;
|
||||
}
|
||||
.basic-material-card {
|
||||
border: 2px solid #eee;
|
||||
border-radius: 8px;
|
||||
padding: 6px;
|
||||
cursor: pointer;
|
||||
background: #fff;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
min-height: 192px;
|
||||
transition: border 0.2s;
|
||||
}
|
||||
.basic-material-card.selected {
|
||||
border: 2px solid #1890ff;
|
||||
background: #e6f7ff;
|
||||
}
|
||||
.basic-material-img {
|
||||
width: 100px;
|
||||
height: 180px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 0;
|
||||
display: block;
|
||||
}
|
||||
.basic-material-name {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
background: rgba(0,0,0,0.5);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
padding: 4px 0;
|
||||
border-bottom-left-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
text-align: center;
|
||||
z-index: 3;
|
||||
}
|
||||
.basic-add-material {
|
||||
border: 2px dashed #bbb;
|
||||
border-radius: 8px;
|
||||
padding: 6px;
|
||||
cursor: pointer;
|
||||
background: #fafbfc;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 190px;
|
||||
}
|
||||
.basic-order-upload {
|
||||
margin: 16px 0;
|
||||
}
|
||||
.basic-order-upload-label {
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.basic-order-upload-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.basic-order-upload-tip {
|
||||
color: #888;
|
||||
font-size: 13px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.basic-phone-settings {
|
||||
margin: 16px 0;
|
||||
background: #f7f8fa;
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.03);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.basic-wechat-group {
|
||||
margin: 16px 0;
|
||||
}
|
||||
.basic-footer-switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin: 16px 0;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
.friend-container {
|
||||
padding: 12px;
|
||||
}
|
||||
.friend-label {
|
||||
margin-bottom: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.friend-block {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.friend-remark-tip {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 36px;
|
||||
z-index: 10;
|
||||
background: #fff;
|
||||
border: 1px solid #eee;
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
width: 220px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
||||
}
|
||||
.friend-remark-q {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 8px;
|
||||
cursor: pointer;
|
||||
color: #888;
|
||||
}
|
||||
.friend-interval-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.friend-time-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.friend-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 32px;
|
||||
}
|
||||
.friend-modal-btn {
|
||||
width: 100%;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
.messages-container {
|
||||
padding: 16px;
|
||||
}
|
||||
.messages-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.messages-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.messages-tab {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.messages-day-panel {
|
||||
background: #fafbfc;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.messages-message-card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 16px rgba(22, 119, 255, 0.06), 0 1.5px 4px rgba(0,0,0,0.04);
|
||||
padding: 20px 12px 16px 12px;
|
||||
margin-bottom: 20px;
|
||||
border: 1.5px solid #f0f3fa;
|
||||
transition: box-shadow 0.2s, border 0.2s, transform 0.2s;
|
||||
position: relative;
|
||||
}
|
||||
.messages-message-card:hover {
|
||||
box-shadow: 0 8px 24px rgba(22, 119, 255, 0.12), 0 2px 8px rgba(0,0,0,0.08);
|
||||
border: 1.5px solid #1677ff;
|
||||
transform: translateY(-2px) scale(1.01);
|
||||
}
|
||||
.messages-message-header {
|
||||
|
||||
}
|
||||
.messages-message-header-content{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.messages-message-type-btns {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.messages-message-type-btn{
|
||||
width: 20px;
|
||||
}
|
||||
.messages-message-content {
|
||||
margin-bottom: 10px;
|
||||
font-size: 15px;
|
||||
color: #222;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.messages-message-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.messages-message-remove-btn {
|
||||
color: #ff4d4f;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
padding: 0 8px;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.messages-message-remove-btn:hover {
|
||||
color: #d9363e;
|
||||
}
|
||||
.messages-add-message-btn {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.messages-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 32px;
|
||||
}
|
||||
.messages-modal-btn {
|
||||
width: 100%;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.messages-group-select-item {
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
background: #fff;
|
||||
margin-bottom: 8px;
|
||||
border: 1px solid #eee;
|
||||
transition: border 0.2s, background 0.2s;
|
||||
}
|
||||
.messages-group-select-item.selected {
|
||||
background: #e6f7ff;
|
||||
border: 1.5px solid #1677ff;
|
||||
}
|
||||
363
nkebao/src/pages/mobile/scenarios/plan/new/steps/step.api.ts
Normal file
363
nkebao/src/pages/mobile/scenarios/plan/new/steps/step.api.ts
Normal file
@@ -0,0 +1,363 @@
|
||||
import request from '@/api/request';
|
||||
|
||||
// ==================== 场景相关接口 ====================
|
||||
|
||||
// 获取场景列表
|
||||
export function getScenarios(params: any) {
|
||||
return request('/v1/plan/scenes', params, 'GET');
|
||||
}
|
||||
|
||||
// 获取场景详情
|
||||
export function getScenarioDetail(id: string) {
|
||||
return request(`/v1/scenarios/${id}`, {}, 'GET');
|
||||
}
|
||||
|
||||
// 创建场景
|
||||
export function createScenario(data: any) {
|
||||
return request('/v1/scenarios', data, 'POST');
|
||||
}
|
||||
|
||||
// 更新场景
|
||||
export function updateScenario(id: string, data: any) {
|
||||
return request(`/v1/scenarios/${id}`, data, 'PUT');
|
||||
}
|
||||
|
||||
// 删除场景
|
||||
export function deleteScenario(id: string) {
|
||||
return request(`/v1/scenarios/${id}`, {}, 'DELETE');
|
||||
}
|
||||
|
||||
// ==================== 计划相关接口 ====================
|
||||
|
||||
// 获取计划列表
|
||||
export function getPlanList(scenarioId: string, page: number = 1, limit: number = 20) {
|
||||
return request(`/api/scenarios/${scenarioId}/plans`, { page, limit }, 'GET');
|
||||
}
|
||||
// 复制计划
|
||||
export function copyPlan(planId: string) {
|
||||
return request(`/api/scenarios/plans/${planId}/copy`, undefined, 'POST');
|
||||
}
|
||||
|
||||
// 删除计划
|
||||
export function deletePlan(planId: string) {
|
||||
return request(`/api/scenarios/plans/${planId}`, undefined, 'DELETE');
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 获取小程序二维码
|
||||
export function getWxMinAppCode(planId: string) {
|
||||
return request(`/api/scenarios/plans/${planId}/qrcode`, undefined, 'GET');
|
||||
}
|
||||
|
||||
|
||||
// ==================== 设备相关接口 ====================
|
||||
|
||||
// 获取设备列表
|
||||
export function getDevices() {
|
||||
return request('/api/devices', undefined, 'GET');
|
||||
}
|
||||
|
||||
// 获取设备详情
|
||||
export function getDeviceDetail(deviceId: string) {
|
||||
return request(`/api/devices/${deviceId}`, undefined, 'GET');
|
||||
}
|
||||
|
||||
// 创建设备
|
||||
export function createDevice(data: any) {
|
||||
return request('/api/devices', data, 'POST');
|
||||
}
|
||||
|
||||
// 更新设备
|
||||
export function updateDevice(deviceId: string, data: any) {
|
||||
return request(`/api/devices/${deviceId}`, data, 'PUT');
|
||||
}
|
||||
|
||||
// 删除设备
|
||||
export function deleteDevice(deviceId: string) {
|
||||
return request(`/api/devices/${deviceId}`, undefined, 'DELETE');
|
||||
}
|
||||
|
||||
// ==================== 微信号相关接口 ====================
|
||||
|
||||
// 获取微信号列表
|
||||
export function getWechatAccounts() {
|
||||
return request('/api/wechat-accounts', undefined, 'GET');
|
||||
}
|
||||
|
||||
// 获取微信号详情
|
||||
export function getWechatAccountDetail(accountId: string) {
|
||||
return request(`/api/wechat-accounts/${accountId}`, undefined, 'GET');
|
||||
}
|
||||
|
||||
// 创建微信号
|
||||
export function createWechatAccount(data: any) {
|
||||
return request('/api/wechat-accounts', data, 'POST');
|
||||
}
|
||||
|
||||
// 更新微信号
|
||||
export function updateWechatAccount(accountId: string, data: any) {
|
||||
return request(`/api/wechat-accounts/${accountId}`, data, 'PUT');
|
||||
}
|
||||
|
||||
// 删除微信号
|
||||
export function deleteWechatAccount(accountId: string) {
|
||||
return request(`/api/wechat-accounts/${accountId}`, undefined, 'DELETE');
|
||||
}
|
||||
|
||||
// ==================== 海报相关接口 ====================
|
||||
|
||||
// 获取海报列表
|
||||
export function getPosters() {
|
||||
return request('/api/posters', undefined, 'GET');
|
||||
}
|
||||
|
||||
// 获取海报详情
|
||||
export function getPosterDetail(posterId: string) {
|
||||
return request(`/api/posters/${posterId}`, undefined, 'GET');
|
||||
}
|
||||
|
||||
// 创建海报
|
||||
export function createPoster(data: any) {
|
||||
return request('/api/posters', data, 'POST');
|
||||
}
|
||||
|
||||
// 更新海报
|
||||
export function updatePoster(posterId: string, data: any) {
|
||||
return request(`/api/posters/${posterId}`, data, 'PUT');
|
||||
}
|
||||
|
||||
// 删除海报
|
||||
export function deletePoster(posterId: string) {
|
||||
return request(`/api/posters/${posterId}`, undefined, 'DELETE');
|
||||
}
|
||||
|
||||
// ==================== 内容相关接口 ====================
|
||||
|
||||
// 获取内容列表
|
||||
export function getContents(params: any) {
|
||||
return request('/api/contents', params, 'GET');
|
||||
}
|
||||
|
||||
// 获取内容详情
|
||||
export function getContentDetail(contentId: string) {
|
||||
return request(`/api/contents/${contentId}`, undefined, 'GET');
|
||||
}
|
||||
|
||||
// 创建内容
|
||||
export function createContent(data: any) {
|
||||
return request('/api/contents', data, 'POST');
|
||||
}
|
||||
|
||||
// 更新内容
|
||||
export function updateContent(contentId: string, data: any) {
|
||||
return request(`/api/contents/${contentId}`, data, 'PUT');
|
||||
}
|
||||
|
||||
// 删除内容
|
||||
export function deleteContent(contentId: string) {
|
||||
return request(`/api/contents/${contentId}`, undefined, 'DELETE');
|
||||
}
|
||||
|
||||
// ==================== 流量池相关接口 ====================
|
||||
|
||||
// 获取流量池列表
|
||||
export function getTrafficPools() {
|
||||
return request('/api/traffic-pools', undefined, 'GET');
|
||||
}
|
||||
|
||||
// 获取流量池详情
|
||||
export function getTrafficPoolDetail(poolId: string) {
|
||||
return request(`/api/traffic-pools/${poolId}`, undefined, 'GET');
|
||||
}
|
||||
|
||||
// 创建流量池
|
||||
export function createTrafficPool(data: any) {
|
||||
return request('/api/traffic-pools', data, 'POST');
|
||||
}
|
||||
|
||||
// 更新流量池
|
||||
export function updateTrafficPool(poolId: string, data: any) {
|
||||
return request(`/api/traffic-pools/${poolId}`, data, 'PUT');
|
||||
}
|
||||
|
||||
// 删除流量池
|
||||
export function deleteTrafficPool(poolId: string) {
|
||||
return request(`/api/traffic-pools/${poolId}`, undefined, 'DELETE');
|
||||
}
|
||||
|
||||
// ==================== 工作台相关接口 ====================
|
||||
|
||||
// 获取工作台统计数据
|
||||
export function getWorkspaceStats() {
|
||||
return request('/api/workspace/stats', undefined, 'GET');
|
||||
}
|
||||
|
||||
// 获取自动点赞任务列表
|
||||
export function getAutoLikeTasks() {
|
||||
return request('/api/workspace/auto-like/tasks', undefined, 'GET');
|
||||
}
|
||||
|
||||
// 创建自动点赞任务
|
||||
export function createAutoLikeTask(data: any) {
|
||||
return request('/api/workspace/auto-like/tasks', data, 'POST');
|
||||
}
|
||||
|
||||
// 更新自动点赞任务
|
||||
export function updateAutoLikeTask(taskId: string, data: any) {
|
||||
return request(`/api/workspace/auto-like/tasks/${taskId}`, data, 'PUT');
|
||||
}
|
||||
|
||||
// 删除自动点赞任务
|
||||
export function deleteAutoLikeTask(taskId: string) {
|
||||
return request(`/api/workspace/auto-like/tasks/${taskId}`, undefined, 'DELETE');
|
||||
}
|
||||
|
||||
// ==================== 群发相关接口 ====================
|
||||
|
||||
// 获取群发任务列表
|
||||
export function getGroupPushTasks() {
|
||||
return request('/api/workspace/group-push/tasks', undefined, 'GET');
|
||||
}
|
||||
|
||||
// 创建群发任务
|
||||
export function createGroupPushTask(data: any) {
|
||||
return request('/api/workspace/group-push/tasks', data, 'POST');
|
||||
}
|
||||
|
||||
// 更新群发任务
|
||||
export function updateGroupPushTask(taskId: string, data: any) {
|
||||
return request(`/api/workspace/group-push/tasks/${taskId}`, data, 'PUT');
|
||||
}
|
||||
|
||||
// 删除群发任务
|
||||
export function deleteGroupPushTask(taskId: string) {
|
||||
return request(`/api/workspace/group-push/tasks/${taskId}`, undefined, 'DELETE');
|
||||
}
|
||||
|
||||
// ==================== 自动建群相关接口 ====================
|
||||
|
||||
// 获取自动建群任务列表
|
||||
export function getAutoGroupTasks() {
|
||||
return request('/api/workspace/auto-group/tasks', undefined, 'GET');
|
||||
}
|
||||
|
||||
// 创建自动建群任务
|
||||
export function createAutoGroupTask(data: any) {
|
||||
return request('/api/workspace/auto-group/tasks', data, 'POST');
|
||||
}
|
||||
|
||||
// 更新自动建群任务
|
||||
export function updateAutoGroupTask(taskId: string, data: any) {
|
||||
return request(`/api/workspace/auto-group/tasks/${taskId}`, data, 'PUT');
|
||||
}
|
||||
|
||||
// 删除自动建群任务
|
||||
export function deleteAutoGroupTask(taskId: string) {
|
||||
return request(`/api/workspace/auto-group/tasks/${taskId}`, undefined, 'DELETE');
|
||||
}
|
||||
|
||||
// ==================== AI助手相关接口 ====================
|
||||
|
||||
// 获取AI对话历史
|
||||
export function getAIChatHistory() {
|
||||
return request('/api/workspace/ai-assistant/chat-history', undefined, 'GET');
|
||||
}
|
||||
|
||||
// 发送AI消息
|
||||
export function sendAIMessage(data: any) {
|
||||
return request('/api/workspace/ai-assistant/send-message', data, 'POST');
|
||||
}
|
||||
|
||||
// 获取AI分析报告
|
||||
export function getAIAnalysisReport() {
|
||||
return request('/api/workspace/ai-assistant/analysis-report', undefined, 'GET');
|
||||
}
|
||||
|
||||
// ==================== 订单相关接口 ====================
|
||||
|
||||
// 获取订单列表
|
||||
export function getOrders(params: any) {
|
||||
return request('/api/orders', params, 'GET');
|
||||
}
|
||||
|
||||
// 获取订单详情
|
||||
export function getOrderDetail(orderId: string) {
|
||||
return request(`/api/orders/${orderId}`, undefined, 'GET');
|
||||
}
|
||||
|
||||
// 创建订单
|
||||
export function createOrder(data: any) {
|
||||
return request('/api/orders', data, 'POST');
|
||||
}
|
||||
|
||||
// 更新订单
|
||||
export function updateOrder(orderId: string, data: any) {
|
||||
return request(`/api/orders/${orderId}`, data, 'PUT');
|
||||
}
|
||||
|
||||
// 删除订单
|
||||
export function deleteOrder(orderId: string) {
|
||||
return request(`/api/orders/${orderId}`, undefined, 'DELETE');
|
||||
}
|
||||
|
||||
// ==================== 用户相关接口 ====================
|
||||
|
||||
// 获取用户信息
|
||||
export function getUserInfo() {
|
||||
return request('/api/user/info', undefined, 'GET');
|
||||
}
|
||||
|
||||
// 更新用户信息
|
||||
export function updateUserInfo(data: any) {
|
||||
return request('/api/user/info', data, 'PUT');
|
||||
}
|
||||
|
||||
// 修改密码
|
||||
export function changePassword(data: any) {
|
||||
return request('/api/user/change-password', data, 'POST');
|
||||
}
|
||||
|
||||
// 上传头像
|
||||
export function uploadAvatar(data: any) {
|
||||
return request('/api/user/upload-avatar', data, 'POST');
|
||||
}
|
||||
|
||||
// ==================== 文件上传相关接口 ====================
|
||||
|
||||
// 上传文件
|
||||
export function uploadFile(data: any) {
|
||||
return request('/api/upload/file', data, 'POST');
|
||||
}
|
||||
|
||||
// 上传图片
|
||||
export function uploadImage(data: any) {
|
||||
return request('/api/upload/image', data, 'POST');
|
||||
}
|
||||
|
||||
// 删除文件
|
||||
export function deleteFile(fileId: string) {
|
||||
return request(`/api/upload/files/${fileId}`, undefined, 'DELETE');
|
||||
}
|
||||
|
||||
// ==================== 系统配置相关接口 ====================
|
||||
|
||||
// 获取系统配置
|
||||
export function getSystemConfig() {
|
||||
return request('/api/system/config', undefined, 'GET');
|
||||
}
|
||||
|
||||
// 更新系统配置
|
||||
export function updateSystemConfig(data: any) {
|
||||
return request('/api/system/config', data, 'PUT');
|
||||
}
|
||||
|
||||
// 获取系统通知
|
||||
export function getSystemNotifications() {
|
||||
return request('/api/system/notifications', undefined, 'GET');
|
||||
}
|
||||
|
||||
// 标记通知为已读
|
||||
export function markNotificationAsRead(notificationId: string) {
|
||||
return request(`/api/system/notifications/${notificationId}/read`, undefined, 'PUT');
|
||||
}
|
||||
@@ -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/mobile/workspace/ai-analyzer/index.tsx
Normal file
141
nkebao/src/pages/mobile/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;
|
||||
@@ -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);
|
||||
}
|
||||
264
nkebao/src/pages/mobile/workspace/ai-assistant/AIAssistant.tsx
Normal file
264
nkebao/src/pages/mobile/workspace/ai-assistant/AIAssistant.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
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 = () => {
|
||||
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;
|
||||
@@ -0,0 +1,6 @@
|
||||
import request from "@/api/request";
|
||||
|
||||
// 获取自动建群任务详情
|
||||
export function getAutoGroupDetail(id: string) {
|
||||
return request(`/api/auto-group/detail/${id}`);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
.autoGroupDetail {
|
||||
padding: 16px 0 80px 0;
|
||||
background: #f7f8fa;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.headerBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 48px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #222;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.infoCard {
|
||||
margin-bottom: 16px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.03);
|
||||
border: none;
|
||||
background: #fff;
|
||||
padding: 16px;
|
||||
}
|
||||
.infoGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
.infoTitle {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #1677ff;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.infoItem {
|
||||
font-size: 13px;
|
||||
color: #444;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.progressSection {
|
||||
margin-top: 16px;
|
||||
}
|
||||
.progressCard {
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.03);
|
||||
border: none;
|
||||
background: #fff;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.progressHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.groupList {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.groupCard {
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.03);
|
||||
border: none;
|
||||
background: #fff;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
.groupHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.memberGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.memberItem {
|
||||
background: #f5f7fa;
|
||||
border-radius: 8px;
|
||||
padding: 4px 8px;
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.warnText {
|
||||
color: #faad14;
|
||||
font-size: 13px;
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.successText {
|
||||
color: #389e0d;
|
||||
font-size: 13px;
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.successAlert {
|
||||
color: #389e0d;
|
||||
background: #f6ffed;
|
||||
border-radius: 8px;
|
||||
padding: 8px 0;
|
||||
text-align: center;
|
||||
margin-top: 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.emptyCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 0;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.03);
|
||||
margin-top: 32px;
|
||||
}
|
||||
.emptyTitle {
|
||||
font-size: 16px;
|
||||
color: #888;
|
||||
margin: 12px 0 4px 0;
|
||||
}
|
||||
.emptyDesc {
|
||||
font-size: 13px;
|
||||
color: #bbb;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
384
nkebao/src/pages/mobile/workspace/auto-group/detail/index.tsx
Normal file
384
nkebao/src/pages/mobile/workspace/auto-group/detail/index.tsx
Normal file
@@ -0,0 +1,384 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
Toast,
|
||||
ProgressBar,
|
||||
Tag,
|
||||
SpinLoading,
|
||||
} from "antd-mobile";
|
||||
import { TeamOutline, LeftOutline } from "antd-mobile-icons";
|
||||
import { AlertOutlined } from "@ant-design/icons";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import MeauMobile from "@/components/MeauMobile/MeauMoible";
|
||||
import style from "./index.module.scss";
|
||||
|
||||
interface GroupMember {
|
||||
id: string;
|
||||
nickname: string;
|
||||
wechatId: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
interface Group {
|
||||
id: string;
|
||||
members: GroupMember[];
|
||||
}
|
||||
|
||||
interface GroupTaskDetail {
|
||||
id: string;
|
||||
name: string;
|
||||
status: "preparing" | "creating" | "completed" | "paused";
|
||||
totalGroups: number;
|
||||
currentGroupIndex: number;
|
||||
groups: Group[];
|
||||
createTime: string;
|
||||
lastUpdateTime: string;
|
||||
creator: string;
|
||||
deviceCount: number;
|
||||
targetFriends: number;
|
||||
groupSize: { min: number; max: number };
|
||||
timeRange: { start: string; end: string };
|
||||
targetTags: string[];
|
||||
groupNameTemplate: string;
|
||||
groupDescription: string;
|
||||
}
|
||||
|
||||
const mockTaskDetail: GroupTaskDetail = {
|
||||
id: "1",
|
||||
name: "VIP客户建群",
|
||||
status: "creating",
|
||||
totalGroups: 5,
|
||||
currentGroupIndex: 2,
|
||||
groups: Array.from({ length: 5 }).map((_, index) => ({
|
||||
id: `group-${index}`,
|
||||
members: Array.from({ length: Math.floor(Math.random() * 10) + 30 }).map(
|
||||
(_, mIndex) => ({
|
||||
id: `member-${index}-${mIndex}`,
|
||||
nickname: `用户${mIndex + 1}`,
|
||||
wechatId: `wx_${mIndex}`,
|
||||
tags: [`标签${(mIndex % 3) + 1}`],
|
||||
})
|
||||
),
|
||||
})),
|
||||
createTime: "2024-11-20 19:04:14",
|
||||
lastUpdateTime: "2025-02-06 13:12:35",
|
||||
creator: "admin",
|
||||
deviceCount: 2,
|
||||
targetFriends: 156,
|
||||
groupSize: { min: 20, max: 50 },
|
||||
timeRange: { start: "09:00", end: "21:00" },
|
||||
targetTags: ["VIP客户", "高价值"],
|
||||
groupNameTemplate: "VIP客户交流群{序号}",
|
||||
groupDescription: "VIP客户专属交流群,提供优质服务",
|
||||
};
|
||||
|
||||
const GroupPreview: React.FC<{
|
||||
groupIndex: number;
|
||||
members: GroupMember[];
|
||||
isCreating: boolean;
|
||||
isCompleted: boolean;
|
||||
onRetry?: () => void;
|
||||
}> = ({ groupIndex, members, isCreating, isCompleted, onRetry }) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const targetSize = 38;
|
||||
return (
|
||||
<Card className={style.groupCard}>
|
||||
<div className={style.groupHeader}>
|
||||
<div>
|
||||
群 {groupIndex + 1}
|
||||
<Tag
|
||||
color={isCompleted ? "success" : isCreating ? "warning" : "default"}
|
||||
style={{ marginLeft: 8 }}
|
||||
>
|
||||
{isCompleted ? "已完成" : isCreating ? "创建中" : "等待中"}
|
||||
</Tag>
|
||||
</div>
|
||||
<div style={{ color: "#888", fontSize: 12 }}>
|
||||
<TeamOutline style={{ marginRight: 4 }} />
|
||||
{members.length}/{targetSize}
|
||||
</div>
|
||||
</div>
|
||||
{isCreating && !isCompleted && (
|
||||
<ProgressBar
|
||||
percent={Math.round((members.length / targetSize) * 100)}
|
||||
style={{ margin: "8px 0" }}
|
||||
/>
|
||||
)}
|
||||
{expanded ? (
|
||||
<>
|
||||
<div className={style.memberGrid}>
|
||||
{members.map((member) => (
|
||||
<div key={member.id} className={style.memberItem}>
|
||||
<span>{member.nickname}</span>
|
||||
{member.tags.length > 0 && (
|
||||
<Tag color="primary" style={{ marginLeft: 4 }}>
|
||||
{member.tags[0]}
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
size="mini"
|
||||
fill="none"
|
||||
block
|
||||
onClick={() => setExpanded(false)}
|
||||
style={{ marginTop: 8 }}
|
||||
>
|
||||
收起
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
size="mini"
|
||||
fill="none"
|
||||
block
|
||||
onClick={() => setExpanded(true)}
|
||||
style={{ marginTop: 8 }}
|
||||
>
|
||||
查看成员 ({members.length})
|
||||
</Button>
|
||||
)}
|
||||
{!isCompleted && members.length < targetSize && (
|
||||
<div className={style.warnText}>
|
||||
<AlertOutlined style={{ marginRight: 4 }} />
|
||||
群人数不足{targetSize}人
|
||||
{onRetry && (
|
||||
<Button
|
||||
size="mini"
|
||||
fill="none"
|
||||
color="primary"
|
||||
style={{ marginLeft: 8 }}
|
||||
onClick={onRetry}
|
||||
>
|
||||
继续拉人
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{isCompleted && <div className={style.successText}>群创建完成</div>}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const GroupCreationProgress: React.FC<{
|
||||
taskDetail: GroupTaskDetail;
|
||||
onComplete: () => void;
|
||||
}> = ({ taskDetail, onComplete }) => {
|
||||
const [groups, setGroups] = useState<Group[]>(taskDetail.groups);
|
||||
const [currentGroupIndex, setCurrentGroupIndex] = useState(
|
||||
taskDetail.currentGroupIndex
|
||||
);
|
||||
const [status, setStatus] = useState<GroupTaskDetail["status"]>(
|
||||
taskDetail.status
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "creating" && currentGroupIndex < groups.length) {
|
||||
const timer = setTimeout(() => {
|
||||
if (currentGroupIndex === groups.length - 1) {
|
||||
setStatus("completed");
|
||||
onComplete();
|
||||
} else {
|
||||
setCurrentGroupIndex((prev) => prev + 1);
|
||||
}
|
||||
}, 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [status, currentGroupIndex, groups.length, onComplete]);
|
||||
|
||||
const handleRetryGroup = (groupIndex: number) => {
|
||||
setGroups((prev) =>
|
||||
prev.map((group, index) => {
|
||||
if (index === groupIndex) {
|
||||
return {
|
||||
...group,
|
||||
members: [
|
||||
...group.members,
|
||||
{
|
||||
id: `retry-member-${Date.now()}`,
|
||||
nickname: `补充用户${group.members.length + 1}`,
|
||||
wechatId: `wx_retry_${Date.now()}`,
|
||||
tags: ["新加入"],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return group;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={style.progressSection}>
|
||||
<Card className={style.progressCard}>
|
||||
<div className={style.progressHeader}>
|
||||
<div>
|
||||
建群进度
|
||||
<Tag
|
||||
color={
|
||||
status === "completed"
|
||||
? "success"
|
||||
: status === "creating"
|
||||
? "warning"
|
||||
: "default"
|
||||
}
|
||||
style={{ marginLeft: 8 }}
|
||||
>
|
||||
{status === "preparing"
|
||||
? "准备中"
|
||||
: status === "creating"
|
||||
? "创建中"
|
||||
: "已完成"}
|
||||
</Tag>
|
||||
</div>
|
||||
<div style={{ color: "#888", fontSize: 12 }}>
|
||||
{currentGroupIndex + 1}/{groups.length}组
|
||||
</div>
|
||||
</div>
|
||||
<ProgressBar
|
||||
percent={Math.round(((currentGroupIndex + 1) / groups.length) * 100)}
|
||||
style={{ marginTop: 8 }}
|
||||
/>
|
||||
</Card>
|
||||
<div className={style.groupList}>
|
||||
{groups.map((group, index) => (
|
||||
<GroupPreview
|
||||
key={group.id}
|
||||
groupIndex={index}
|
||||
members={group.members}
|
||||
isCreating={status === "creating" && index === currentGroupIndex}
|
||||
isCompleted={status === "completed" || index < currentGroupIndex}
|
||||
onRetry={() => handleRetryGroup(index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{status === "completed" && (
|
||||
<div className={style.successAlert}>所有群组已创建完成</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AutoGroupDetail: React.FC = () => {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [taskDetail, setTaskDetail] = useState<GroupTaskDetail | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setTimeout(() => {
|
||||
setTaskDetail(mockTaskDetail);
|
||||
setLoading(false);
|
||||
}, 800);
|
||||
}, [id]);
|
||||
|
||||
const handleComplete = () => {
|
||||
Toast.show({ content: "所有群组已创建完成" });
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<div className={style.headerBar}>
|
||||
<Button fill="none" size="small" onClick={() => navigate(-1)}>
|
||||
<LeftOutline />
|
||||
</Button>
|
||||
<div className={style.title}>建群详情</div>
|
||||
</div>
|
||||
}
|
||||
footer={<MeauMobile />}
|
||||
loading={true}
|
||||
>
|
||||
<div style={{ minHeight: 300 }} />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
if (!taskDetail) {
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<div className={style.headerBar}>
|
||||
<Button fill="none" size="small" onClick={() => navigate(-1)}>
|
||||
<LeftOutline />
|
||||
</Button>
|
||||
<div className={style.title}>建群详情</div>
|
||||
</div>
|
||||
}
|
||||
footer={<MeauMobile />}
|
||||
>
|
||||
<Card className={style.emptyCard}>
|
||||
<AlertOutlined style={{ fontSize: 48, color: "#ccc" }} />
|
||||
<div className={style.emptyTitle}>任务不存在</div>
|
||||
<div className={style.emptyDesc}>请检查任务ID是否正确</div>
|
||||
<Button
|
||||
color="primary"
|
||||
onClick={() => navigate("/workspace/auto-group")}
|
||||
>
|
||||
返回列表
|
||||
</Button>
|
||||
</Card>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<div className={style.headerBar}>
|
||||
<Button fill="none" size="small" onClick={() => navigate(-1)}>
|
||||
<LeftOutline />
|
||||
</Button>
|
||||
<div className={style.title}>{taskDetail.name} - 建群详情</div>
|
||||
</div>
|
||||
}
|
||||
footer={<MeauMobile />}
|
||||
>
|
||||
<div className={style.autoGroupDetail}>
|
||||
<Card className={style.infoCard}>
|
||||
<div className={style.infoGrid}>
|
||||
<div>
|
||||
<div className={style.infoTitle}>基本信息</div>
|
||||
<div className={style.infoItem}>任务名称:{taskDetail.name}</div>
|
||||
<div className={style.infoItem}>
|
||||
创建时间:{taskDetail.createTime}
|
||||
</div>
|
||||
<div className={style.infoItem}>创建人:{taskDetail.creator}</div>
|
||||
<div className={style.infoItem}>
|
||||
执行设备:{taskDetail.deviceCount} 个
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className={style.infoTitle}>建群配置</div>
|
||||
<div className={style.infoItem}>
|
||||
群组规模:{taskDetail.groupSize.min}-{taskDetail.groupSize.max}{" "}
|
||||
人
|
||||
</div>
|
||||
<div className={style.infoItem}>
|
||||
执行时间:{taskDetail.timeRange.start} -{" "}
|
||||
{taskDetail.timeRange.end}
|
||||
</div>
|
||||
<div className={style.infoItem}>
|
||||
目标标签:{taskDetail.targetTags.join(", ")}
|
||||
</div>
|
||||
<div className={style.infoItem}>
|
||||
群名称模板:{taskDetail.groupNameTemplate}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<GroupCreationProgress
|
||||
taskDetail={taskDetail}
|
||||
onComplete={handleComplete}
|
||||
/>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default AutoGroupDetail;
|
||||
11
nkebao/src/pages/mobile/workspace/auto-group/form/api.ts
Normal file
11
nkebao/src/pages/mobile/workspace/auto-group/form/api.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import request from "@/api/request";
|
||||
|
||||
// 新建自动建群任务
|
||||
export function createAutoGroup(data: any) {
|
||||
return request("/api/auto-group/create", data, "POST");
|
||||
}
|
||||
|
||||
// 编辑自动建群任务
|
||||
export function updateAutoGroup(id: string, data: any) {
|
||||
return request(`/api/auto-group/update/${id}`, data, "POST");
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
.autoGroupForm {
|
||||
padding: 10px;
|
||||
background: #f7f8fa;
|
||||
}
|
||||
|
||||
.headerBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 48px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #222;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.timeRangeRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.groupSizeRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
253
nkebao/src/pages/mobile/workspace/auto-group/form/index.tsx
Normal file
253
nkebao/src/pages/mobile/workspace/auto-group/form/index.tsx
Normal file
@@ -0,0 +1,253 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
Form,
|
||||
Input,
|
||||
Button,
|
||||
Toast,
|
||||
Switch,
|
||||
Selector,
|
||||
TextArea,
|
||||
NavBar,
|
||||
} from "antd-mobile";
|
||||
import { ArrowLeftOutlined } from "@ant-design/icons";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import style from "./index.module.scss";
|
||||
import { createAutoGroup, updateAutoGroup } from "./api";
|
||||
|
||||
const defaultForm = {
|
||||
name: "",
|
||||
deviceCount: 1,
|
||||
targetFriends: 0,
|
||||
createInterval: 300,
|
||||
maxGroupsPerDay: 10,
|
||||
timeRange: { start: "09:00", end: "21:00" },
|
||||
groupSize: { min: 20, max: 50 },
|
||||
targetTags: [],
|
||||
groupNameTemplate: "VIP客户交流群{序号}",
|
||||
groupDescription: "",
|
||||
};
|
||||
|
||||
const tagOptions = [
|
||||
{ label: "VIP客户", value: "VIP客户" },
|
||||
{ label: "高价值", value: "高价值" },
|
||||
{ label: "潜在客户", value: "潜在客户" },
|
||||
{ label: "中意向", value: "中意向" },
|
||||
];
|
||||
|
||||
const AutoGroupForm: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams();
|
||||
const isEdit = Boolean(id);
|
||||
const [form, setForm] = useState<any>(defaultForm);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEdit) {
|
||||
// 这里应请求详情接口,回填表单,演示用mock
|
||||
setForm({
|
||||
...defaultForm,
|
||||
name: "VIP客户建群",
|
||||
deviceCount: 2,
|
||||
targetFriends: 156,
|
||||
createInterval: 300,
|
||||
maxGroupsPerDay: 20,
|
||||
timeRange: { start: "09:00", end: "21:00" },
|
||||
groupSize: { min: 20, max: 50 },
|
||||
targetTags: ["VIP客户", "高价值"],
|
||||
groupNameTemplate: "VIP客户交流群{序号}",
|
||||
groupDescription: "VIP客户专属交流群,提供优质服务",
|
||||
});
|
||||
}
|
||||
}, [isEdit, id]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
if (isEdit) {
|
||||
await updateAutoGroup(id as string, form);
|
||||
Toast.show({ content: "编辑成功" });
|
||||
} else {
|
||||
await createAutoGroup(form);
|
||||
Toast.show({ content: "创建成功" });
|
||||
}
|
||||
navigate("/workspace/auto-group");
|
||||
} catch (e) {
|
||||
Toast.show({ content: "提交失败" });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<NavBar
|
||||
back={null}
|
||||
style={{ background: "#fff" }}
|
||||
left={
|
||||
<div className="nav-title">
|
||||
<ArrowLeftOutlined
|
||||
twoToneColor="#1677ff"
|
||||
onClick={() => navigate(-1)}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<span className="nav-title">
|
||||
{isEdit ? "编辑建群任务" : "新建建群任务"}
|
||||
</span>
|
||||
</NavBar>
|
||||
}
|
||||
>
|
||||
<div className={style.autoGroupForm}>
|
||||
<Form
|
||||
layout="vertical"
|
||||
footer={
|
||||
<Button
|
||||
block
|
||||
color="primary"
|
||||
loading={loading}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{isEdit ? "保存修改" : "创建任务"}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Form.Item label="任务名称" name="name" required>
|
||||
<Input
|
||||
value={form.name}
|
||||
onChange={(val) => setForm((f: any) => ({ ...f, name: val }))}
|
||||
placeholder="请输入任务名称"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="执行设备数量" name="deviceCount" required>
|
||||
<Input
|
||||
type="number"
|
||||
value={form.deviceCount}
|
||||
onChange={(val) =>
|
||||
setForm((f: any) => ({ ...f, deviceCount: Number(val) }))
|
||||
}
|
||||
placeholder="请输入设备数量"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="目标好友数" name="targetFriends" required>
|
||||
<Input
|
||||
type="number"
|
||||
value={form.targetFriends}
|
||||
onChange={(val) =>
|
||||
setForm((f: any) => ({ ...f, targetFriends: Number(val) }))
|
||||
}
|
||||
placeholder="请输入目标好友数"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="建群间隔(秒)" name="createInterval" required>
|
||||
<Input
|
||||
type="number"
|
||||
value={form.createInterval}
|
||||
onChange={(val) =>
|
||||
setForm((f: any) => ({ ...f, createInterval: Number(val) }))
|
||||
}
|
||||
placeholder="请输入建群间隔"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="每日最大建群数" name="maxGroupsPerDay" required>
|
||||
<Input
|
||||
type="number"
|
||||
value={form.maxGroupsPerDay}
|
||||
onChange={(val) =>
|
||||
setForm((f: any) => ({ ...f, maxGroupsPerDay: Number(val) }))
|
||||
}
|
||||
placeholder="请输入最大建群数"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="执行时间段" name="timeRange" required>
|
||||
<div className={style.timeRangeRow}>
|
||||
<Input
|
||||
value={form.timeRange.start}
|
||||
onChange={(val) =>
|
||||
setForm((f: any) => ({
|
||||
...f,
|
||||
timeRange: { ...f.timeRange, start: val },
|
||||
}))
|
||||
}
|
||||
placeholder="开始时间"
|
||||
/>
|
||||
<span style={{ margin: "0 8px" }}>-</span>
|
||||
<Input
|
||||
value={form.timeRange.end}
|
||||
onChange={(val) =>
|
||||
setForm((f: any) => ({
|
||||
...f,
|
||||
timeRange: { ...f.timeRange, end: val },
|
||||
}))
|
||||
}
|
||||
placeholder="结束时间"
|
||||
/>
|
||||
</div>
|
||||
</Form.Item>
|
||||
<Form.Item label="群组规模" name="groupSize" required>
|
||||
<div className={style.groupSizeRow}>
|
||||
<Input
|
||||
type="number"
|
||||
value={form.groupSize.min}
|
||||
onChange={(val) =>
|
||||
setForm((f: any) => ({
|
||||
...f,
|
||||
groupSize: { ...f.groupSize, min: Number(val) },
|
||||
}))
|
||||
}
|
||||
placeholder="最小人数"
|
||||
/>
|
||||
<span style={{ margin: "0 8px" }}>-</span>
|
||||
<Input
|
||||
type="number"
|
||||
value={form.groupSize.max}
|
||||
onChange={(val) =>
|
||||
setForm((f: any) => ({
|
||||
...f,
|
||||
groupSize: { ...f.groupSize, max: Number(val) },
|
||||
}))
|
||||
}
|
||||
placeholder="最大人数"
|
||||
/>
|
||||
</div>
|
||||
</Form.Item>
|
||||
<Form.Item label="目标标签" name="targetTags">
|
||||
<Selector
|
||||
options={tagOptions}
|
||||
multiple
|
||||
value={form.targetTags}
|
||||
onChange={(val) =>
|
||||
setForm((f: any) => ({ ...f, targetTags: val }))
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="群名称模板" name="groupNameTemplate" required>
|
||||
<Input
|
||||
value={form.groupNameTemplate}
|
||||
onChange={(val) =>
|
||||
setForm((f: any) => ({ ...f, groupNameTemplate: val }))
|
||||
}
|
||||
placeholder="请输入群名称模板"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="群描述" name="groupDescription">
|
||||
<TextArea
|
||||
value={form.groupDescription}
|
||||
onChange={(val) =>
|
||||
setForm((f: any) => ({ ...f, groupDescription: val }))
|
||||
}
|
||||
placeholder="请输入群描述"
|
||||
rows={3}
|
||||
maxLength={100}
|
||||
showCount
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default AutoGroupForm;
|
||||
8
nkebao/src/pages/mobile/workspace/auto-group/list/api.ts
Normal file
8
nkebao/src/pages/mobile/workspace/auto-group/list/api.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import request from "@/api/request";
|
||||
|
||||
// 获取自动建群任务列表
|
||||
export function getAutoGroupList(params?: any) {
|
||||
return request("/api/auto-group/list", params, "GET");
|
||||
}
|
||||
|
||||
// 其他相关API可按需添加
|
||||
@@ -0,0 +1,173 @@
|
||||
.autoGroupList {
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.taskList {
|
||||
}
|
||||
|
||||
.taskCard {
|
||||
margin-bottom: 16px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.03);
|
||||
border: none;
|
||||
background: #fff;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.taskHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.taskTitle {
|
||||
flex: 1;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.statusRunning {
|
||||
background: #e6f7e6;
|
||||
color: #389e0d;
|
||||
border-radius: 8px;
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
.statusPaused {
|
||||
background: #f5f5f5;
|
||||
color: #888;
|
||||
border-radius: 8px;
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
.statusCompleted {
|
||||
background: #e6f4ff;
|
||||
color: #1677ff;
|
||||
border-radius: 8px;
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.taskInfoGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 8px 16px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.infoLabel {
|
||||
color: #888;
|
||||
font-size: 12px;
|
||||
}
|
||||
.infoValue {
|
||||
color: #222;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.taskFooter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
padding-top: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.footerLeft {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.footerRight {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.expandPanel {
|
||||
margin-top: 16px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px dashed #e0e0e0;
|
||||
}
|
||||
.expandGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
.expandTitle {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #1677ff;
|
||||
}
|
||||
.expandInfo {
|
||||
font-size: 13px;
|
||||
color: #444;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.expandTags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.tag {
|
||||
background: #f0f5ff;
|
||||
color: #1677ff;
|
||||
border-radius: 8px;
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.menuItem {
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
color: #222;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.menuItem:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
.menuItemDanger {
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
color: #e53e3e;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.menuItemDanger:hover {
|
||||
background: #fff1f0;
|
||||
}
|
||||
|
||||
.emptyCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 0;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.03);
|
||||
margin-top: 32px;
|
||||
}
|
||||
.emptyTitle {
|
||||
font-size: 16px;
|
||||
color: #888;
|
||||
margin: 12px 0 4px 0;
|
||||
}
|
||||
.emptyDesc {
|
||||
font-size: 13px;
|
||||
color: #bbb;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
312
nkebao/src/pages/mobile/workspace/auto-group/list/index.tsx
Normal file
312
nkebao/src/pages/mobile/workspace/auto-group/list/index.tsx
Normal file
@@ -0,0 +1,312 @@
|
||||
import React, { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button, Card, ProgressBar, Popover, Toast, NavBar } from "antd-mobile";
|
||||
import { Input, Switch } from "antd";
|
||||
import {
|
||||
MoreOutline,
|
||||
AddCircleOutline,
|
||||
UserAddOutline,
|
||||
ClockCircleOutline,
|
||||
TeamOutline,
|
||||
CalendarOutline,
|
||||
} from "antd-mobile-icons";
|
||||
|
||||
import {
|
||||
ReloadOutlined,
|
||||
SettingOutlined,
|
||||
PlusOutlined,
|
||||
ArrowLeftOutlined,
|
||||
SearchOutlined,
|
||||
} from "@ant-design/icons";
|
||||
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import style from "./index.module.scss";
|
||||
|
||||
interface GroupTask {
|
||||
id: string;
|
||||
name: string;
|
||||
status: "running" | "paused" | "completed";
|
||||
deviceCount: number;
|
||||
targetFriends: number;
|
||||
createdGroups: number;
|
||||
lastCreateTime: string;
|
||||
createTime: string;
|
||||
creator: string;
|
||||
createInterval: number;
|
||||
maxGroupsPerDay: number;
|
||||
timeRange: { start: string; end: string };
|
||||
groupSize: { min: number; max: number };
|
||||
targetTags: string[];
|
||||
groupNameTemplate: string;
|
||||
groupDescription: string;
|
||||
}
|
||||
|
||||
const mockTasks: GroupTask[] = [
|
||||
{
|
||||
id: "1",
|
||||
name: "VIP客户建群",
|
||||
deviceCount: 2,
|
||||
targetFriends: 156,
|
||||
createdGroups: 12,
|
||||
lastCreateTime: "2025-02-06 13:12:35",
|
||||
createTime: "2024-11-20 19:04:14",
|
||||
creator: "admin",
|
||||
status: "running",
|
||||
createInterval: 300,
|
||||
maxGroupsPerDay: 20,
|
||||
timeRange: { start: "09:00", end: "21:00" },
|
||||
groupSize: { min: 20, max: 50 },
|
||||
targetTags: ["VIP客户", "高价值"],
|
||||
groupNameTemplate: "VIP客户交流群{序号}",
|
||||
groupDescription: "VIP客户专属交流群,提供优质服务",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "产品推广建群",
|
||||
deviceCount: 1,
|
||||
targetFriends: 89,
|
||||
createdGroups: 8,
|
||||
lastCreateTime: "2024-03-04 14:09:35",
|
||||
createTime: "2024-03-04 14:29:04",
|
||||
creator: "manager",
|
||||
status: "paused",
|
||||
createInterval: 600,
|
||||
maxGroupsPerDay: 10,
|
||||
timeRange: { start: "10:00", end: "20:00" },
|
||||
groupSize: { min: 15, max: 30 },
|
||||
targetTags: ["潜在客户", "中意向"],
|
||||
groupNameTemplate: "产品推广群{序号}",
|
||||
groupDescription: "产品推广交流群,了解最新产品信息",
|
||||
},
|
||||
];
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "running":
|
||||
return style.statusRunning;
|
||||
case "paused":
|
||||
return style.statusPaused;
|
||||
case "completed":
|
||||
return style.statusCompleted;
|
||||
default:
|
||||
return style.statusPaused;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
switch (status) {
|
||||
case "running":
|
||||
return "进行中";
|
||||
case "paused":
|
||||
return "已暂停";
|
||||
case "completed":
|
||||
return "已完成";
|
||||
default:
|
||||
return "未知";
|
||||
}
|
||||
};
|
||||
|
||||
const AutoGroupList: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [tasks, setTasks] = useState<GroupTask[]>(mockTasks);
|
||||
|
||||
const handleDelete = (taskId: string) => {
|
||||
const taskToDelete = tasks.find((task) => task.id === taskId);
|
||||
if (!taskToDelete) return;
|
||||
window.confirm(`确定要删除"${taskToDelete.name}"吗?`) &&
|
||||
setTasks(tasks.filter((task) => task.id !== taskId));
|
||||
Toast.show({ content: "删除成功" });
|
||||
};
|
||||
|
||||
const handleEdit = (taskId: string) => {
|
||||
navigate(`/workspace/auto-group/${taskId}/edit`);
|
||||
};
|
||||
|
||||
const handleView = (taskId: string) => {
|
||||
navigate(`/workspace/auto-group/${taskId}`);
|
||||
};
|
||||
|
||||
const handleCopy = (taskId: string) => {
|
||||
const taskToCopy = tasks.find((task) => task.id === taskId);
|
||||
if (taskToCopy) {
|
||||
const newTask = {
|
||||
...taskToCopy,
|
||||
id: `${Date.now()}`,
|
||||
name: `${taskToCopy.name} (复制)`,
|
||||
createTime: new Date().toISOString().replace("T", " ").substring(0, 19),
|
||||
};
|
||||
setTasks([...tasks, newTask]);
|
||||
Toast.show({ content: "复制成功" });
|
||||
}
|
||||
};
|
||||
|
||||
const toggleTaskStatus = (taskId: string) => {
|
||||
setTasks((prev) =>
|
||||
prev.map((task) =>
|
||||
task.id === taskId
|
||||
? {
|
||||
...task,
|
||||
status: task.status === "running" ? "paused" : "running",
|
||||
}
|
||||
: task
|
||||
)
|
||||
);
|
||||
Toast.show({ content: "状态已切换" });
|
||||
};
|
||||
|
||||
const handleCreateNew = () => {
|
||||
navigate("/workspace/auto-group/new");
|
||||
};
|
||||
|
||||
const filteredTasks = tasks.filter((task) =>
|
||||
task.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<>
|
||||
<NavBar
|
||||
back={null}
|
||||
style={{ background: "#fff" }}
|
||||
left={
|
||||
<div className="nav-title">
|
||||
<ArrowLeftOutlined
|
||||
twoToneColor="#1677ff"
|
||||
onClick={() => navigate(-1)}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
right={
|
||||
<Button size="small" color="primary" onClick={handleCreateNew}>
|
||||
<PlusOutlined /> 新建任务
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<span className="nav-title">自动建群</span>
|
||||
</NavBar>
|
||||
{/* 搜索栏 */}
|
||||
<div className="search-bar">
|
||||
<div className="search-input-wrapper">
|
||||
<Input
|
||||
placeholder="搜索计划名称"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
prefix={<SearchOutlined />}
|
||||
allowClear
|
||||
size="large"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {}}
|
||||
loading={false}
|
||||
className="refresh-btn"
|
||||
>
|
||||
<ReloadOutlined />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className={style.autoGroupList}>
|
||||
<div className={style.taskList}>
|
||||
{filteredTasks.length === 0 ? (
|
||||
<Card className={style.emptyCard}>
|
||||
<UserAddOutline style={{ fontSize: 48, color: "#ccc" }} />
|
||||
<div className={style.emptyTitle}>暂无建群任务</div>
|
||||
<div className={style.emptyDesc}>创建您的第一个自动建群任务</div>
|
||||
<Button color="primary" onClick={handleCreateNew}>
|
||||
<AddCircleOutline /> 创建第一个任务
|
||||
</Button>
|
||||
</Card>
|
||||
) : (
|
||||
filteredTasks.map((task) => (
|
||||
<Card key={task.id} className={style.taskCard}>
|
||||
<div className={style.taskHeader}>
|
||||
<div className={style.taskTitle}>{task.name}</div>
|
||||
<span className={getStatusColor(task.status)}>
|
||||
{getStatusText(task.status)}
|
||||
</span>
|
||||
<Switch
|
||||
checked={task.status === "running"}
|
||||
onChange={() => toggleTaskStatus(task.id)}
|
||||
disabled={task.status === "completed"}
|
||||
style={{ marginLeft: 8 }}
|
||||
/>
|
||||
<Popover
|
||||
content={
|
||||
<div>
|
||||
<div
|
||||
className={style.menuItem}
|
||||
onClick={() => handleView(task.id)}
|
||||
>
|
||||
查看
|
||||
</div>
|
||||
<div
|
||||
className={style.menuItem}
|
||||
onClick={() => handleEdit(task.id)}
|
||||
>
|
||||
编辑
|
||||
</div>
|
||||
<div
|
||||
className={style.menuItem}
|
||||
onClick={() => handleCopy(task.id)}
|
||||
>
|
||||
复制
|
||||
</div>
|
||||
<div
|
||||
className={style.menuItemDanger}
|
||||
onClick={() => handleDelete(task.id)}
|
||||
>
|
||||
删除
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
trigger="click"
|
||||
>
|
||||
<MoreOutline style={{ fontSize: 20, marginLeft: 8 }} />
|
||||
</Popover>
|
||||
</div>
|
||||
<div className={style.taskInfoGrid}>
|
||||
<div>
|
||||
<div className={style.infoLabel}>执行设备</div>
|
||||
<div className={style.infoValue}>{task.deviceCount} 个</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className={style.infoLabel}>目标好友</div>
|
||||
<div className={style.infoValue}>
|
||||
{task.targetFriends} 个
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className={style.infoLabel}>已建群</div>
|
||||
<div className={style.infoValue}>
|
||||
{task.createdGroups} 个
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className={style.infoLabel}>创建人</div>
|
||||
<div className={style.infoValue}>{task.creator}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={style.taskFooter}>
|
||||
<div className={style.footerLeft}>
|
||||
<ClockCircleOutline style={{ marginRight: 4 }} />
|
||||
更新时间:{task.lastCreateTime}
|
||||
</div>
|
||||
<div className={style.footerRight}>
|
||||
创建时间:{task.createTime}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default AutoGroupList;
|
||||
63
nkebao/src/pages/mobile/workspace/auto-like/list/api.ts
Normal file
63
nkebao/src/pages/mobile/workspace/auto-like/list/api.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import request from "@/api/request";
|
||||
import {
|
||||
LikeTask,
|
||||
CreateLikeTaskData,
|
||||
UpdateLikeTaskData,
|
||||
LikeRecord,
|
||||
PaginatedResponse,
|
||||
} from "@/pages/workspace/auto-like/record/data";
|
||||
|
||||
// 获取自动点赞任务列表
|
||||
export function fetchAutoLikeTasks(
|
||||
params = { type: 1, page: 1, limit: 100 }
|
||||
): Promise<LikeTask[]> {
|
||||
return request("/v1/workbench/list", params, "GET");
|
||||
}
|
||||
|
||||
// 获取单个任务详情
|
||||
export function fetchAutoLikeTaskDetail(id: string): Promise<LikeTask | null> {
|
||||
return request("/v1/workbench/detail", { id }, "GET");
|
||||
}
|
||||
|
||||
// 创建自动点赞任务
|
||||
export function createAutoLikeTask(data: CreateLikeTaskData): Promise<any> {
|
||||
return request("/v1/workbench/create", { ...data, type: 1 }, "POST");
|
||||
}
|
||||
|
||||
// 更新自动点赞任务
|
||||
export function updateAutoLikeTask(data: UpdateLikeTaskData): Promise<any> {
|
||||
return request("/v1/workbench/update", { ...data, type: 1 }, "POST");
|
||||
}
|
||||
|
||||
// 删除自动点赞任务
|
||||
export function deleteAutoLikeTask(id: string): Promise<any> {
|
||||
return request("/v1/workbench/delete", { id }, "DELETE");
|
||||
}
|
||||
|
||||
// 切换任务状态
|
||||
export function toggleAutoLikeTask(id: string, status: string): Promise<any> {
|
||||
return request("/v1/workbench/update-status", { id, status }, "POST");
|
||||
}
|
||||
|
||||
// 复制自动点赞任务
|
||||
export function copyAutoLikeTask(id: string): Promise<any> {
|
||||
return request("/v1/workbench/copy", { id }, "POST");
|
||||
}
|
||||
|
||||
// 获取点赞记录
|
||||
export function fetchLikeRecords(
|
||||
workbenchId: string,
|
||||
page: number = 1,
|
||||
limit: number = 20,
|
||||
keyword?: string
|
||||
): Promise<PaginatedResponse<LikeRecord>> {
|
||||
const params: any = {
|
||||
workbenchId,
|
||||
page: page.toString(),
|
||||
limit: limit.toString(),
|
||||
};
|
||||
if (keyword) {
|
||||
params.keyword = keyword;
|
||||
}
|
||||
return request("/v1/workbench/records", params, "GET");
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
.task-list {
|
||||
padding: 0 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.task-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.2s;
|
||||
&:hover {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.task-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.task-title-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.task-name {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.task-status {
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
|
||||
&.active {
|
||||
background: #f6ffed;
|
||||
color: #52c41a;
|
||||
border: 1px solid #b7eb8f;
|
||||
}
|
||||
|
||||
&.inactive {
|
||||
background: #f5f5f5;
|
||||
color: #666;
|
||||
border: 1px solid #d9d9d9;
|
||||
}
|
||||
}
|
||||
|
||||
.task-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
|
||||
input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: #ccc;
|
||||
transition: 0.4s;
|
||||
border-radius: 24px;
|
||||
|
||||
&:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: white;
|
||||
transition: 0.4s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
input:checked + .slider {
|
||||
background-color: #1890ff;
|
||||
}
|
||||
|
||||
input:checked + .slider:before {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
}
|
||||
|
||||
.menu-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 4px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
color: #666;
|
||||
|
||||
&:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-dropdown {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 28px;
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
z-index: 100;
|
||||
min-width: 120px;
|
||||
padding: 4px;
|
||||
border: 1px solid #e5e5e5;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
gap: 8px;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
&.danger {
|
||||
color: #ff4d4f;
|
||||
|
||||
&:hover {
|
||||
background: #fff2f0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.task-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.info-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.task-stats {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.stats-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.stats-icon {
|
||||
font-size: 16px;
|
||||
|
||||
&.blue {
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
&.green {
|
||||
color: #52c41a;
|
||||
}
|
||||
}
|
||||
|
||||
.stats-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.stats-value {
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 60vh;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 48px;
|
||||
color: #d9d9d9;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 16px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.empty-subtext {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
// 移动端适配
|
||||
@media (max-width: 768px) {
|
||||
.task-info {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.task-stats {
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.search-section {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.task-list {
|
||||
padding: 0 12px;
|
||||
}
|
||||
}
|
||||
382
nkebao/src/pages/mobile/workspace/auto-like/list/index.tsx
Normal file
382
nkebao/src/pages/mobile/workspace/auto-like/list/index.tsx
Normal file
@@ -0,0 +1,382 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button, Toast, SpinLoading, Dialog, Card } from "antd-mobile";
|
||||
import NavCommon from "@/components/NavCommon";
|
||||
import { Input } from "antd";
|
||||
import {
|
||||
PlusOutlined,
|
||||
CopyOutlined,
|
||||
DeleteOutlined,
|
||||
SearchOutlined,
|
||||
ReloadOutlined,
|
||||
EyeOutlined,
|
||||
EditOutlined,
|
||||
MoreOutlined,
|
||||
LikeOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { ArrowLeftOutlined } from "@ant-design/icons";
|
||||
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import {
|
||||
fetchAutoLikeTasks,
|
||||
deleteAutoLikeTask,
|
||||
toggleAutoLikeTask,
|
||||
copyAutoLikeTask,
|
||||
} from "./api";
|
||||
import { LikeTask } from "@/pages/workspace/auto-like/record/data";
|
||||
import style from "./index.module.scss";
|
||||
|
||||
// 卡片菜单组件
|
||||
interface CardMenuProps {
|
||||
onView: () => void;
|
||||
onEdit: () => void;
|
||||
onCopy: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
const CardMenu: React.FC<CardMenuProps> = ({
|
||||
onView,
|
||||
onEdit,
|
||||
onCopy,
|
||||
onDelete,
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
if (open) document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative" }}>
|
||||
<button onClick={() => setOpen((v) => !v)} className={style["menu-btn"]}>
|
||||
<MoreOutlined />
|
||||
</button>
|
||||
{open && (
|
||||
<div ref={menuRef} className={style["menu-dropdown"]}>
|
||||
<div
|
||||
onClick={() => {
|
||||
onView();
|
||||
setOpen(false);
|
||||
}}
|
||||
className={style["menu-item"]}
|
||||
>
|
||||
<EyeOutlined />
|
||||
查看
|
||||
</div>
|
||||
<div
|
||||
onClick={() => {
|
||||
onEdit();
|
||||
setOpen(false);
|
||||
}}
|
||||
className={style["menu-item"]}
|
||||
>
|
||||
<EditOutlined />
|
||||
编辑
|
||||
</div>
|
||||
<div
|
||||
onClick={() => {
|
||||
onCopy();
|
||||
setOpen(false);
|
||||
}}
|
||||
className={style["menu-item"]}
|
||||
>
|
||||
<CopyOutlined />
|
||||
复制
|
||||
</div>
|
||||
<div
|
||||
onClick={() => {
|
||||
onDelete();
|
||||
setOpen(false);
|
||||
}}
|
||||
className={`${style["menu-item"]} ${style["danger"]}`}
|
||||
>
|
||||
<DeleteOutlined />
|
||||
删除
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AutoLike: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [tasks, setTasks] = useState<LikeTask[]>([]);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 获取任务列表
|
||||
const fetchTasks = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const Res: any = await fetchAutoLikeTasks();
|
||||
// 直接就是任务数组,无需再解包
|
||||
const mappedTasks = Res?.list?.map((task: any) => ({
|
||||
...task,
|
||||
status: task.status || 2, // 默认为关闭状态
|
||||
deviceCount: task.deviceCount || 0,
|
||||
targetGroup: task.targetGroup || "全部好友",
|
||||
likeInterval: task.likeInterval || 60,
|
||||
maxLikesPerDay: task.maxLikesPerDay || 100,
|
||||
lastLikeTime: task.lastLikeTime || "暂无",
|
||||
createTime: task.createTime || "",
|
||||
updateTime: task.updateTime || "",
|
||||
todayLikeCount: task.todayLikeCount || 0,
|
||||
totalLikeCount: task.totalLikeCount || 0,
|
||||
}));
|
||||
setTasks(mappedTasks);
|
||||
} catch (error) {
|
||||
console.error("获取自动点赞任务失败:", error);
|
||||
Toast.show({
|
||||
content: "获取任务列表失败",
|
||||
position: "top",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchTasks();
|
||||
}, []);
|
||||
|
||||
// 删除任务
|
||||
const handleDelete = async (id: string) => {
|
||||
const result = await Dialog.confirm({
|
||||
content: "确定要删除这个任务吗?",
|
||||
confirmText: "删除",
|
||||
cancelText: "取消",
|
||||
});
|
||||
|
||||
if (result) {
|
||||
try {
|
||||
await deleteAutoLikeTask(id);
|
||||
Toast.show({
|
||||
content: "删除成功",
|
||||
position: "top",
|
||||
});
|
||||
fetchTasks(); // 重新获取列表
|
||||
} catch (error) {
|
||||
Toast.show({
|
||||
content: "删除失败",
|
||||
position: "top",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 编辑任务
|
||||
const handleEdit = (taskId: string) => {
|
||||
navigate(`/workspace/auto-like/edit/${taskId}`);
|
||||
};
|
||||
|
||||
// 查看任务
|
||||
const handleView = (taskId: string) => {
|
||||
navigate(`/workspace/auto-like/record/${taskId}`);
|
||||
};
|
||||
|
||||
// 复制任务
|
||||
const handleCopy = async (id: string) => {
|
||||
try {
|
||||
await copyAutoLikeTask(id);
|
||||
Toast.show({
|
||||
content: "复制成功",
|
||||
position: "top",
|
||||
});
|
||||
fetchTasks(); // 重新获取列表
|
||||
} catch (error) {
|
||||
Toast.show({
|
||||
content: "复制失败",
|
||||
position: "top",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 切换任务状态
|
||||
const toggleTaskStatus = async (id: string, status: number) => {
|
||||
try {
|
||||
const newStatus = status === 1 ? "2" : "1";
|
||||
await toggleAutoLikeTask(id, newStatus);
|
||||
Toast.show({
|
||||
content: status === 1 ? "已暂停" : "已启动",
|
||||
position: "top",
|
||||
});
|
||||
fetchTasks(); // 重新获取列表
|
||||
} catch (error) {
|
||||
Toast.show({
|
||||
content: "操作失败",
|
||||
position: "top",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 创建新任务
|
||||
const handleCreateNew = () => {
|
||||
navigate("/workspace/auto-like/new");
|
||||
};
|
||||
|
||||
// 过滤任务
|
||||
const filteredTasks = tasks.filter((task) =>
|
||||
task.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<>
|
||||
<NavCommon
|
||||
title="自动点赞"
|
||||
backFn={() => navigate("/workspace")}
|
||||
right={
|
||||
<Button size="small" color="primary" onClick={handleCreateNew}>
|
||||
<PlusOutlined /> 新建计划
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 搜索栏 */}
|
||||
<div className="search-bar">
|
||||
<div className="search-input-wrapper">
|
||||
<Input
|
||||
placeholder="搜索计划名称"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
prefix={<SearchOutlined />}
|
||||
allowClear
|
||||
size="large"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={fetchTasks}
|
||||
loading={loading}
|
||||
className="refresh-btn"
|
||||
>
|
||||
<ReloadOutlined />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className={style["auto-like-page"]}>
|
||||
{/* 任务列表 */}
|
||||
<div className={style["task-list"]}>
|
||||
{loading ? (
|
||||
<div className={style["loading"]}>
|
||||
<SpinLoading color="primary" />
|
||||
<div className={style["loading-text"]}>加载中...</div>
|
||||
</div>
|
||||
) : filteredTasks.length === 0 ? (
|
||||
<div className={style["empty-state"]}>
|
||||
<div className={style["empty-icon"]}>
|
||||
<LikeOutlined />
|
||||
</div>
|
||||
<div className={style["empty-text"]}>暂无自动点赞任务</div>
|
||||
<div className={style["empty-subtext"]}>
|
||||
点击右上角按钮创建新任务
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
filteredTasks.map((task) => (
|
||||
<Card key={task.id} className={style["task-card"]}>
|
||||
<div className={style["task-header"]}>
|
||||
<div className={style["task-title-section"]}>
|
||||
<h3 className={style["task-name"]}>{task.name}</h3>
|
||||
<span
|
||||
className={`${style["task-status"]} ${
|
||||
Number(task.status) === 1
|
||||
? style["active"]
|
||||
: style["inactive"]
|
||||
}`}
|
||||
>
|
||||
{Number(task.status) === 1 ? "进行中" : "已暂停"}
|
||||
</span>
|
||||
</div>
|
||||
<div className={style["task-controls"]}>
|
||||
<label className={style["switch"]}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Number(task.status) === 1}
|
||||
onChange={() =>
|
||||
toggleTaskStatus(task.id, Number(task.status))
|
||||
}
|
||||
/>
|
||||
<span className={style["slider"]}></span>
|
||||
</label>
|
||||
<CardMenu
|
||||
onView={() => handleView(task.id)}
|
||||
onEdit={() => handleEdit(task.id)}
|
||||
onCopy={() => handleCopy(task.id)}
|
||||
onDelete={() => handleDelete(task.id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={style["task-info"]}>
|
||||
<div className={style["info-section"]}>
|
||||
<div className={style["info-item"]}>
|
||||
<span className={style["info-label"]}>执行设备:</span>
|
||||
<span className={style["info-value"]}>
|
||||
{task.deviceCount} 个
|
||||
</span>
|
||||
</div>
|
||||
<div className={style["info-item"]}>
|
||||
<span className={style["info-label"]}>目标人群:</span>
|
||||
<span className={style["info-value"]}>
|
||||
{task.targetGroup}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={style["info-section"]}>
|
||||
<div className={style["info-item"]}>
|
||||
<span className={style["info-label"]}>点赞间隔:</span>
|
||||
<span className={style["info-value"]}>
|
||||
{task.likeInterval} 秒
|
||||
</span>
|
||||
</div>
|
||||
<div className={style["info-item"]}>
|
||||
<span className={style["info-label"]}>每日上限:</span>
|
||||
<span className={style["info-value"]}>
|
||||
{task.maxLikesPerDay} 次
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={style["task-stats"]}>
|
||||
<div className={style["stats-item"]}>
|
||||
<LikeOutlined
|
||||
className={`${style["stats-icon"]} ${style["blue"]}`}
|
||||
/>
|
||||
<span className={style["stats-label"]}>今日点赞:</span>
|
||||
<span className={style["stats-value"]}>
|
||||
{task.lastLikeTime}
|
||||
</span>
|
||||
</div>
|
||||
<div className={style["stats-item"]}>
|
||||
<LikeOutlined
|
||||
className={`${style["stats-icon"]} ${style["green"]}`}
|
||||
/>
|
||||
<span className={style["stats-label"]}>总点赞数:</span>
|
||||
<span className={style["stats-value"]}>
|
||||
{task.totalLikeCount || 0}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default AutoLike;
|
||||
552
nkebao/src/pages/mobile/workspace/auto-like/new/NewAutoLike.tsx
Normal file
552
nkebao/src/pages/mobile/workspace/auto-like/new/NewAutoLike.tsx
Normal file
@@ -0,0 +1,552 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { ChevronLeft,Plus, Minus, Check, X, Tag as TagIcon } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { createAutoLikeTask, updateAutoLikeTask, fetchAutoLikeTaskDetail } from '@/api/autoLike';
|
||||
import { ContentType } from '@/types/auto-like';
|
||||
import { useToast } from '@/components/ui/toast';
|
||||
import Layout from '@/components/Layout';
|
||||
import DeviceSelection from '@/components/DeviceSelection';
|
||||
import FriendSelection from '@/components/FriendSelection';
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// 修改CreateLikeTaskData接口,确保friends字段不是可选的
|
||||
interface CreateLikeTaskDataLocal {
|
||||
name: string;
|
||||
interval: number;
|
||||
maxLikes: number;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
contentTypes: ContentType[];
|
||||
devices: string[];
|
||||
friends: string[];
|
||||
friendMaxLikes: number;
|
||||
friendTags: string;
|
||||
enableFriendTags: boolean;
|
||||
targetTags: string[];
|
||||
}
|
||||
|
||||
export default function NewAutoLike() {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const isEditMode = !!id;
|
||||
const { toast } = useToast();
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(isEditMode);
|
||||
const [formData, setFormData] = useState<CreateLikeTaskDataLocal>({
|
||||
name: '',
|
||||
interval: 5,
|
||||
maxLikes: 200,
|
||||
startTime: '08:00',
|
||||
endTime: '22:00',
|
||||
contentTypes: ['text', 'image', 'video'],
|
||||
devices: [],
|
||||
friends: [], // 确保初始化为空数组而不是undefined
|
||||
targetTags: [],
|
||||
friendMaxLikes: 10,
|
||||
enableFriendTags: false,
|
||||
friendTags: '',
|
||||
});
|
||||
// 新增自动开启的独立状态
|
||||
const [autoEnabled, setAutoEnabled] = useState(false);
|
||||
|
||||
// 如果是编辑模式,获取任务详情
|
||||
useEffect(() => {
|
||||
if (isEditMode && id) {
|
||||
fetchTaskDetail();
|
||||
}
|
||||
}, [id, isEditMode]);
|
||||
|
||||
// 获取任务详情
|
||||
const fetchTaskDetail = async () => {
|
||||
try {
|
||||
const taskDetail = await fetchAutoLikeTaskDetail(id!);
|
||||
console.log('Task detail response:', taskDetail); // 添加日志用于调试
|
||||
|
||||
if (taskDetail) {
|
||||
// 使用类型断言处理可能的字段名称差异
|
||||
const taskAny = taskDetail as any;
|
||||
// 处理可能的嵌套结构
|
||||
const config = taskAny.config || taskAny;
|
||||
|
||||
setFormData({
|
||||
name: taskDetail.name || '',
|
||||
interval: config.likeInterval || config.interval || 5,
|
||||
maxLikes: config.maxLikesPerDay || config.maxLikes || 200,
|
||||
startTime: config.timeRange?.start || config.startTime || '08:00',
|
||||
endTime: config.timeRange?.end || config.endTime || '22:00',
|
||||
contentTypes: config.contentTypes || ['text', 'image', 'video'],
|
||||
devices: config.devices || [],
|
||||
friends: config.friends || [],
|
||||
targetTags: config.targetTags || [],
|
||||
friendMaxLikes: config.friendMaxLikes || 10,
|
||||
enableFriendTags: config.enableFriendTags || false,
|
||||
friendTags: config.friendTags || '',
|
||||
});
|
||||
|
||||
// 处理状态字段,使用双等号允许类型自动转换
|
||||
const status = taskAny.status;
|
||||
setAutoEnabled(status === 1 || status === 'running');
|
||||
} else {
|
||||
toast({
|
||||
title: '获取任务详情失败',
|
||||
description: '无法找到该任务',
|
||||
variant: 'destructive',
|
||||
});
|
||||
navigate('/workspace/auto-like');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取任务详情出错:', error); // 添加错误日志
|
||||
toast({
|
||||
title: '获取任务详情失败',
|
||||
description: '请检查网络连接后重试',
|
||||
variant: 'destructive',
|
||||
});
|
||||
navigate('/workspace/auto-like');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
const handleUpdateFormData = (data: Partial<CreateLikeTaskDataLocal>) => {
|
||||
setFormData((prev) => ({ ...prev, ...data }));
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
setCurrentStep((prev) => Math.min(prev + 1, 3));
|
||||
// 滚动到顶部
|
||||
const mainElement = document.querySelector('main');
|
||||
if (mainElement) {
|
||||
mainElement.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrev = () => {
|
||||
setCurrentStep((prev) => Math.max(prev - 1, 1));
|
||||
// 滚动到顶部
|
||||
const mainElement = document.querySelector('main');
|
||||
if (mainElement) {
|
||||
mainElement.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleComplete = async () => {
|
||||
if (isSubmitting) return;
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
// 转换为API需要的格式
|
||||
const apiFormData = {
|
||||
...formData,
|
||||
// 如果API需要其他转换,可以在这里添加
|
||||
};
|
||||
|
||||
let response;
|
||||
if (isEditMode) {
|
||||
// 编辑模式,调用更新API
|
||||
response = await updateAutoLikeTask({
|
||||
...apiFormData,
|
||||
id: id!
|
||||
});
|
||||
} else {
|
||||
// 新建模式,调用创建API
|
||||
response = await createAutoLikeTask(apiFormData);
|
||||
}
|
||||
|
||||
if (response.code === 200) {
|
||||
toast({
|
||||
title: isEditMode ? '更新成功' : '创建成功',
|
||||
description: isEditMode ? '自动点赞任务已更新' : '自动点赞任务已创建并开始执行',
|
||||
});
|
||||
navigate('/workspace/auto-like');
|
||||
} else {
|
||||
toast({
|
||||
title: isEditMode ? '更新失败' : '创建失败',
|
||||
description: response.msg || '请稍后重试',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: isEditMode ? '更新失败' : '创建失败',
|
||||
description: '请检查网络连接后重试',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const header = (
|
||||
<div className="sticky top-0 z-10 bg-white pb-4">
|
||||
<div className="flex items-center h-14 px-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate(-1)} className="hover:bg-gray-50">
|
||||
<ChevronLeft className="h-6 w-6" />
|
||||
</Button>
|
||||
<h1 className="ml-2 text-lg font-medium">{isEditMode ? '编辑自动点赞' : '新建自动点赞'}</h1>
|
||||
</div>
|
||||
<StepIndicator currentStep={currentStep} />
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Layout header={header}>
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-500">加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout header={header}>
|
||||
<div className="min-h-screen bg-[#F8F9FA]">
|
||||
<div className="pt-4">
|
||||
|
||||
<div className="mt-8">
|
||||
{currentStep === 1 && (
|
||||
<BasicSettings
|
||||
formData={formData}
|
||||
onChange={handleUpdateFormData}
|
||||
onNext={handleNext}
|
||||
autoEnabled={autoEnabled}
|
||||
setAutoEnabled={setAutoEnabled}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentStep === 2 && (
|
||||
<div className="space-y-6 px-6">
|
||||
<DeviceSelection
|
||||
selectedDevices={formData.devices}
|
||||
onSelect={(devices) => handleUpdateFormData({ devices })}
|
||||
placeholder="选择设备"
|
||||
/>
|
||||
|
||||
<div className="flex space-x-4">
|
||||
<Button variant="outline" className="flex-1 h-12 rounded-xl text-base" onClick={handlePrev}>
|
||||
上一步
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1 h-12 bg-blue-600 hover:bg-blue-700 rounded-xl text-base shadow-sm"
|
||||
onClick={handleNext}
|
||||
disabled={formData.devices.length === 0}
|
||||
>
|
||||
下一步
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep === 3 && (
|
||||
<div className="px-6 space-y-6">
|
||||
<FriendSelection
|
||||
selectedFriends={formData.friends || []}
|
||||
onSelect={(friends) => handleUpdateFormData({ friends })}
|
||||
deviceIds={formData.devices}
|
||||
placeholder="选择微信好友"
|
||||
/>
|
||||
|
||||
<div className="flex space-x-4">
|
||||
<Button variant="outline" className="flex-1 h-12 rounded-xl text-base" onClick={handlePrev}>
|
||||
上一步
|
||||
</Button>
|
||||
<Button className="flex-1 h-12 bg-blue-600 hover:bg-blue-700 rounded-xl text-base shadow-sm" onClick={handleComplete}>
|
||||
完成
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
// 步骤指示器组件
|
||||
interface StepIndicatorProps {
|
||||
currentStep: number;
|
||||
}
|
||||
|
||||
function StepIndicator({ currentStep }: StepIndicatorProps) {
|
||||
const steps = [
|
||||
{ title: '基础设置', description: '设置点赞规则' },
|
||||
{ title: '设备选择', description: '选择执行设备' },
|
||||
{ title: '人群选择', description: '选择目标人群' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="px-6">
|
||||
<div className="relative">
|
||||
<div className="flex items-center justify-between">
|
||||
{steps.map((step, index) => (
|
||||
<div key={index} className="flex flex-col items-center relative z-10">
|
||||
<div
|
||||
className={`flex items-center justify-center w-8 h-8 rounded-full ${
|
||||
index < currentStep
|
||||
? 'bg-blue-600 text-white'
|
||||
: index === currentStep
|
||||
? 'border-2 border-blue-600 text-blue-600'
|
||||
: 'border-2 border-gray-300 text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{index < currentStep ? <Check className="w-5 h-5" /> : index + 1}
|
||||
</div>
|
||||
<div className="text-center mt-2">
|
||||
<div className={`text-sm font-medium ${index <= currentStep ? 'text-gray-900' : 'text-gray-400'}`}>
|
||||
{step.title}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">{step.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="absolute top-4 left-0 w-full h-0.5 bg-gray-200 -translate-y-1/2 z-0">
|
||||
<div
|
||||
className="absolute top-0 left-0 h-full bg-blue-600 transition-all duration-300"
|
||||
style={{ width: `${((currentStep - 1) / (steps.length - 1)) * 100}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 基础设置组件
|
||||
interface BasicSettingsProps {
|
||||
formData: CreateLikeTaskDataLocal;
|
||||
onChange: (data: Partial<CreateLikeTaskDataLocal>) => void;
|
||||
onNext: () => void;
|
||||
autoEnabled: boolean;
|
||||
setAutoEnabled: (v: boolean) => void;
|
||||
}
|
||||
|
||||
function BasicSettings({ formData, onChange, onNext, autoEnabled, setAutoEnabled }: BasicSettingsProps) {
|
||||
const handleContentTypeChange = (type: ContentType) => {
|
||||
const currentTypes = [...formData.contentTypes];
|
||||
if (currentTypes.includes(type)) {
|
||||
onChange({ contentTypes: currentTypes.filter((t) => t !== type) });
|
||||
} else {
|
||||
onChange({ contentTypes: [...currentTypes, type] });
|
||||
}
|
||||
};
|
||||
|
||||
const incrementInterval = () => {
|
||||
onChange({ interval: Math.min(formData.interval + 5, 60) });
|
||||
};
|
||||
|
||||
const decrementInterval = () => {
|
||||
onChange({ interval: Math.max(formData.interval - 5, 5) });
|
||||
};
|
||||
|
||||
const incrementMaxLikes = () => {
|
||||
onChange({ maxLikes: Math.min(formData.maxLikes + 10, 500) });
|
||||
};
|
||||
|
||||
const decrementMaxLikes = () => {
|
||||
onChange({ maxLikes: Math.max(formData.maxLikes - 10, 10) });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 px-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="task-name">任务名称</Label>
|
||||
<Input
|
||||
id="task-name"
|
||||
placeholder="请输入任务名称"
|
||||
value={formData.name}
|
||||
onChange={(e) => onChange({ name: e.target.value })}
|
||||
className="h-12 rounded-xl border-gray-200"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="like-interval">点赞间隔</Label>
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-12 w-12 rounded-l-xl border-gray-200 bg-white hover:bg-gray-50"
|
||||
onClick={decrementInterval}
|
||||
>
|
||||
<Minus className="h-5 w-5" />
|
||||
</Button>
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
id="like-interval"
|
||||
type="number"
|
||||
min={5}
|
||||
max={60}
|
||||
value={formData.interval.toString()}
|
||||
onChange={(e) => onChange({ interval: Number.parseInt(e.target.value) || 5 })}
|
||||
className="h-12 rounded-none border-x-0 border-gray-200 text-center"
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pr-4 pointer-events-none text-gray-500">
|
||||
秒
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-12 w-12 rounded-r-xl border-gray-200 bg-white hover:bg-gray-50"
|
||||
onClick={incrementInterval}
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">设置两次点赞之间的最小时间间隔</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="max-likes">每日最大点赞数</Label>
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-12 w-12 rounded-l-xl border-gray-200 bg-white hover:bg-gray-50"
|
||||
onClick={decrementMaxLikes}
|
||||
>
|
||||
<Minus className="h-5 w-5" />
|
||||
</Button>
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
id="max-likes"
|
||||
type="number"
|
||||
min={10}
|
||||
max={500}
|
||||
value={formData.maxLikes.toString()}
|
||||
onChange={(e) => onChange({ maxLikes: Number.parseInt(e.target.value) || 10 })}
|
||||
className="h-12 rounded-none border-x-0 border-gray-200 text-center"
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pr-4 pointer-events-none text-gray-500">
|
||||
次/天
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-12 w-12 rounded-r-xl border-gray-200 bg-white hover:bg-gray-50"
|
||||
onClick={incrementMaxLikes}
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">设置每天最多点赞的次数</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>点赞时间范围</Label>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Input
|
||||
type="time"
|
||||
value={formData.startTime}
|
||||
onChange={(e) => onChange({ startTime: e.target.value })}
|
||||
className="h-12 rounded-xl border-gray-200"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Input
|
||||
type="time"
|
||||
value={formData.endTime}
|
||||
onChange={(e) => onChange({ endTime: e.target.value })}
|
||||
className="h-12 rounded-xl border-gray-200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">设置每天可以点赞的时间段</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>点赞内容类型</Label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{[
|
||||
{ id: 'text' as ContentType, label: '文字' },
|
||||
{ id: 'image' as ContentType, label: '图片' },
|
||||
{ id: 'video' as ContentType, label: '视频' },
|
||||
].map((type) => (
|
||||
<div
|
||||
key={type.id}
|
||||
className={`flex items-center justify-center h-12 rounded-xl border cursor-pointer ${
|
||||
formData.contentTypes.includes(type.id)
|
||||
? 'border-blue-500 bg-blue-50 text-blue-600'
|
||||
: 'border-gray-200 text-gray-600'
|
||||
}`}
|
||||
onClick={() => handleContentTypeChange(type.id)}
|
||||
>
|
||||
{type.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">选择要点赞的内容类型</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 rounded-xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="enable-friend-tags" className="cursor-pointer">
|
||||
启用好友标签
|
||||
</Label>
|
||||
<Switch
|
||||
id="enable-friend-tags"
|
||||
checked={formData.enableFriendTags}
|
||||
onCheckedChange={(checked) => onChange({ enableFriendTags: checked })}
|
||||
/>
|
||||
</div>
|
||||
{formData.enableFriendTags && (
|
||||
<>
|
||||
<div className="space-y-2 mt-4">
|
||||
<Label htmlFor="friend-tags">好友标签</Label>
|
||||
<Input
|
||||
id="friend-tags"
|
||||
placeholder="请输入标签"
|
||||
value={formData.friendTags || ''}
|
||||
onChange={e => onChange({ friendTags: e.target.value })}
|
||||
className="h-12 rounded-xl border-gray-200"
|
||||
/>
|
||||
<p className="text-xs text-gray-500">只给有此标签的好友点赞</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<Label htmlFor="auto-enabled" className="cursor-pointer">
|
||||
自动开启
|
||||
</Label>
|
||||
<Switch
|
||||
id="auto-enabled"
|
||||
checked={autoEnabled}
|
||||
onCheckedChange={setAutoEnabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button onClick={onNext} className="w-full h-12 bg-blue-600 hover:bg-blue-700 rounded-xl text-base shadow-sm">
|
||||
下一步
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
21
nkebao/src/pages/mobile/workspace/auto-like/new/api.ts
Normal file
21
nkebao/src/pages/mobile/workspace/auto-like/new/api.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import request from "@/api/request";
|
||||
import {
|
||||
CreateLikeTaskData,
|
||||
UpdateLikeTaskData,
|
||||
LikeTask,
|
||||
} from "@/pages/workspace/auto-like/record/data";
|
||||
|
||||
// 获取自动点赞任务详情
|
||||
export function fetchAutoLikeTaskDetail(id: string): Promise<LikeTask | null> {
|
||||
return request("/v1/workbench/detail", { id }, "GET");
|
||||
}
|
||||
|
||||
// 创建自动点赞任务
|
||||
export function createAutoLikeTask(data: CreateLikeTaskData): Promise<any> {
|
||||
return request("/v1/workbench/create", { ...data, type: 1 }, "POST");
|
||||
}
|
||||
|
||||
// 更新自动点赞任务
|
||||
export function updateAutoLikeTask(data: UpdateLikeTaskData): Promise<any> {
|
||||
return request("/v1/workbench/update", { ...data, type: 1 }, "POST");
|
||||
}
|
||||
416
nkebao/src/pages/mobile/workspace/auto-like/new/index.tsx
Normal file
416
nkebao/src/pages/mobile/workspace/auto-like/new/index.tsx
Normal file
@@ -0,0 +1,416 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { PlusOutlined, MinusOutlined } from "@ant-design/icons";
|
||||
import { Button, Input, Switch, Spin, message } from "antd";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import DeviceSelection from "@/components/DeviceSelection";
|
||||
import FriendSelection from "@/components/FriendSelection";
|
||||
import StepIndicator from "@/components/StepIndicator";
|
||||
import NavCommon from "@/components/NavCommon";
|
||||
import {
|
||||
createAutoLikeTask,
|
||||
updateAutoLikeTask,
|
||||
fetchAutoLikeTaskDetail,
|
||||
} from "./api";
|
||||
import {
|
||||
CreateLikeTaskData,
|
||||
ContentType,
|
||||
} from "@/pages/workspace/auto-like/record/data";
|
||||
import style from "./new.module.scss";
|
||||
import MeauMobile from "@/components/MeauMobile/MeauMoible";
|
||||
|
||||
const contentTypeLabels: Record<ContentType, string> = {
|
||||
text: "文字",
|
||||
image: "图片",
|
||||
video: "视频",
|
||||
};
|
||||
|
||||
const steps = [
|
||||
{ id: 1, title: "基础设置", subtitle: "设置点赞规则" },
|
||||
{ id: 2, title: "设备选择", subtitle: "选择执行设备" },
|
||||
{ id: 3, title: "人群选择", subtitle: "选择目标人群" },
|
||||
];
|
||||
|
||||
const NewAutoLike: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const isEditMode = !!id;
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(isEditMode);
|
||||
const [autoEnabled, setAutoEnabled] = useState(false);
|
||||
const [formData, setFormData] = useState<CreateLikeTaskData>({
|
||||
name: "",
|
||||
interval: 5,
|
||||
maxLikes: 200,
|
||||
startTime: "08:00",
|
||||
endTime: "22:00",
|
||||
contentTypes: ["text", "image", "video"],
|
||||
devices: [],
|
||||
friends: [],
|
||||
targetTags: [],
|
||||
friendMaxLikes: 10,
|
||||
enableFriendTags: false,
|
||||
friendTags: "",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditMode && id) {
|
||||
fetchTaskDetail();
|
||||
}
|
||||
}, [id, isEditMode]);
|
||||
|
||||
const fetchTaskDetail = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const taskDetail = await fetchAutoLikeTaskDetail(id!);
|
||||
if (taskDetail) {
|
||||
const config = (taskDetail as any).config || taskDetail;
|
||||
setFormData({
|
||||
name: taskDetail.name || "",
|
||||
interval: config.likeInterval || config.interval || 5,
|
||||
maxLikes: config.maxLikesPerDay || config.maxLikes || 200,
|
||||
startTime: config.timeRange?.start || config.startTime || "08:00",
|
||||
endTime: config.timeRange?.end || config.endTime || "22:00",
|
||||
contentTypes: config.contentTypes || ["text", "image", "video"],
|
||||
devices: config.devices || [],
|
||||
friends: config.friends || [],
|
||||
targetTags: config.targetTags || [],
|
||||
friendMaxLikes: config.friendMaxLikes || 10,
|
||||
enableFriendTags: config.enableFriendTags || false,
|
||||
friendTags: config.friendTags || "",
|
||||
});
|
||||
setAutoEnabled(
|
||||
(taskDetail as any).status === 1 ||
|
||||
(taskDetail as any).status === "running"
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
message.error("获取任务详情失败");
|
||||
navigate("/workspace/auto-like");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateFormData = (data: Partial<CreateLikeTaskData>) => {
|
||||
setFormData((prev) => ({ ...prev, ...data }));
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
setCurrentStep((prev) => Math.min(prev + 1, 3));
|
||||
// 滚动到顶部
|
||||
const mainElement = document.querySelector("main");
|
||||
if (mainElement) {
|
||||
mainElement.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrev = () => {
|
||||
setCurrentStep((prev) => Math.max(prev - 1, 1));
|
||||
// 滚动到顶部
|
||||
const mainElement = document.querySelector("main");
|
||||
if (mainElement) {
|
||||
mainElement.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
};
|
||||
|
||||
const handleComplete = async () => {
|
||||
if (!formData.name.trim()) {
|
||||
message.warning("请输入任务名称");
|
||||
return;
|
||||
}
|
||||
if (!formData.devices || formData.devices.length === 0) {
|
||||
message.warning("请选择执行设备");
|
||||
return;
|
||||
}
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
if (isEditMode && id) {
|
||||
await updateAutoLikeTask({ ...formData, id });
|
||||
message.success("更新成功");
|
||||
} else {
|
||||
await createAutoLikeTask(formData);
|
||||
message.success("创建成功");
|
||||
}
|
||||
navigate("/workspace/auto-like");
|
||||
} catch (error) {
|
||||
message.error(isEditMode ? "更新失败" : "创建失败");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 步骤器
|
||||
const renderStepIndicator = () => (
|
||||
<StepIndicator steps={steps} currentStep={currentStep} />
|
||||
);
|
||||
|
||||
// 步骤1:基础设置
|
||||
const renderBasicSettings = () => (
|
||||
<div className={style.basicSection}>
|
||||
<div className={style.formItem}>
|
||||
<div className={style.formLabel}>任务名称</div>
|
||||
<Input
|
||||
placeholder="请输入任务名称"
|
||||
value={formData.name}
|
||||
onChange={(e) => handleUpdateFormData({ name: e.target.value })}
|
||||
className={style.input}
|
||||
/>
|
||||
</div>
|
||||
<div className={style.formItem}>
|
||||
<div className={style.formLabel}>点赞间隔</div>
|
||||
<div className={style.counterRow}>
|
||||
<Button
|
||||
icon={<MinusOutlined />}
|
||||
onClick={() =>
|
||||
handleUpdateFormData({
|
||||
interval: Math.max(1, formData.interval - 1),
|
||||
})
|
||||
}
|
||||
className={style.counterBtn}
|
||||
/>
|
||||
<div className={style.counterInputWrapper}>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={60}
|
||||
value={formData.interval}
|
||||
onChange={(e) =>
|
||||
handleUpdateFormData({
|
||||
interval: Number.parseInt(e.target.value) || 1,
|
||||
})
|
||||
}
|
||||
className={style.counterInput}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() =>
|
||||
handleUpdateFormData({ interval: formData.interval + 1 })
|
||||
}
|
||||
className={style.counterBtn}
|
||||
/>
|
||||
<span className={style.counterUnit}>秒</span>
|
||||
</div>
|
||||
<div className={style.counterTip}>设置两次点赞之间的最小时间间隔</div>
|
||||
</div>
|
||||
<div className={style.formItem}>
|
||||
<div className={style.formLabel}>每日最大点赞数</div>
|
||||
<div className={style.counterRow}>
|
||||
<Button
|
||||
icon={<MinusOutlined />}
|
||||
onClick={() =>
|
||||
handleUpdateFormData({
|
||||
maxLikes: Math.max(1, formData.maxLikes - 10),
|
||||
})
|
||||
}
|
||||
className={style.counterBtn}
|
||||
/>
|
||||
<div className={style.counterInputWrapper}>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={500}
|
||||
value={formData.maxLikes}
|
||||
onChange={(e) =>
|
||||
handleUpdateFormData({
|
||||
maxLikes: Number.parseInt(e.target.value) || 1,
|
||||
})
|
||||
}
|
||||
className={style.counterInput}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() =>
|
||||
handleUpdateFormData({ maxLikes: formData.maxLikes + 10 })
|
||||
}
|
||||
className={style.counterBtn}
|
||||
/>
|
||||
<span className={style.counterUnit}>次/天</span>
|
||||
</div>
|
||||
<div className={style.counterTip}>设置每天最多点赞的次数</div>
|
||||
</div>
|
||||
<div className={style.formItem}>
|
||||
<div className={style.formLabel}>点赞时间范围</div>
|
||||
<div className={style.timeRow}>
|
||||
<Input
|
||||
type="time"
|
||||
value={formData.startTime}
|
||||
onChange={(e) =>
|
||||
handleUpdateFormData({ startTime: e.target.value })
|
||||
}
|
||||
className={style.inputTime}
|
||||
/>
|
||||
<span className={style.timeSeparator}>至</span>
|
||||
<Input
|
||||
type="time"
|
||||
value={formData.endTime}
|
||||
onChange={(e) => handleUpdateFormData({ endTime: e.target.value })}
|
||||
className={style.inputTime}
|
||||
/>
|
||||
</div>
|
||||
<div className={style.counterTip}>设置每天可以点赞的时间段</div>
|
||||
</div>
|
||||
<div className={style.formItem}>
|
||||
<div className={style.formLabel}>点赞内容类型</div>
|
||||
<div className={style.contentTypes}>
|
||||
{(["text", "image", "video"] as ContentType[]).map((type) => (
|
||||
<Button
|
||||
key={type}
|
||||
type={
|
||||
formData.contentTypes.includes(type) ? "primary" : "default"
|
||||
}
|
||||
ghost={!formData.contentTypes.includes(type)}
|
||||
className={style.contentTypeBtn}
|
||||
onClick={() => {
|
||||
const newTypes = formData.contentTypes.includes(type)
|
||||
? formData.contentTypes.filter((t) => t !== type)
|
||||
: [...formData.contentTypes, type];
|
||||
handleUpdateFormData({ contentTypes: newTypes });
|
||||
}}
|
||||
>
|
||||
{contentTypeLabels[type]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<div className={style.counterTip}>选择要点赞的内容类型</div>
|
||||
</div>
|
||||
<div className={style.formItem}>
|
||||
<div className={style.switchRow}>
|
||||
<span className={style.switchLabel}>启用好友标签</span>
|
||||
<Switch
|
||||
checked={formData.enableFriendTags}
|
||||
onChange={(checked) =>
|
||||
handleUpdateFormData({ enableFriendTags: checked })
|
||||
}
|
||||
className={style.switch}
|
||||
/>
|
||||
</div>
|
||||
{formData.enableFriendTags && (
|
||||
<div className={style.formItem}>
|
||||
<Input
|
||||
placeholder="请输入标签"
|
||||
value={formData.friendTags}
|
||||
onChange={(e) =>
|
||||
handleUpdateFormData({ friendTags: e.target.value })
|
||||
}
|
||||
className={style.input}
|
||||
/>
|
||||
<div className={style.counterTip}>只给有此标签的好友点赞</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={style.formItem}>
|
||||
<div className={style.switchRow}>
|
||||
<span className={style.switchLabel}>自动开启</span>
|
||||
<Switch
|
||||
checked={autoEnabled}
|
||||
onChange={setAutoEnabled}
|
||||
className={style.switch}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
block
|
||||
onClick={handleNext}
|
||||
size="large"
|
||||
className={style.mainBtn}
|
||||
disabled={!formData.name.trim()}
|
||||
>
|
||||
下一步
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 步骤2:设备选择
|
||||
const renderDeviceSelection = () => (
|
||||
<div className={style.basicSection}>
|
||||
<div className={style.formItem}>
|
||||
<DeviceSelection
|
||||
selectedDevices={formData.devices}
|
||||
onSelect={(devices) => handleUpdateFormData({ devices })}
|
||||
showInput={true}
|
||||
showSelectedList={true}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handlePrev}
|
||||
className={style.prevBtn}
|
||||
size="large"
|
||||
style={{ marginRight: 16 }}
|
||||
>
|
||||
上一步
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleNext}
|
||||
className={style.nextBtn}
|
||||
size="large"
|
||||
disabled={formData.devices.length === 0}
|
||||
>
|
||||
下一步
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 步骤3:好友设置
|
||||
const renderFriendSettings = () => (
|
||||
<div className={style.basicSection}>
|
||||
<div className={style.formItem}>
|
||||
<FriendSelection
|
||||
selectedFriends={formData.friends || []}
|
||||
onSelect={(friends) => handleUpdateFormData({ friends })}
|
||||
deviceIds={formData.devices}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handlePrev}
|
||||
className={style.prevBtn}
|
||||
size="large"
|
||||
style={{ marginRight: 16 }}
|
||||
>
|
||||
上一步
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleComplete}
|
||||
className={style.completeBtn}
|
||||
size="large"
|
||||
loading={isSubmitting}
|
||||
disabled={!formData.friends || formData.friends.length === 0}
|
||||
>
|
||||
{isEditMode ? "更新任务" : "创建任务"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<>
|
||||
<NavCommon title={isEditMode ? "编辑自动点赞" : "新建自动点赞"} />
|
||||
{renderStepIndicator()}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className={style.formBg}>
|
||||
{isLoading ? (
|
||||
<div className={style.formLoading}>
|
||||
<Spin />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{currentStep === 1 && renderBasicSettings()}
|
||||
{currentStep === 2 && renderDeviceSelection()}
|
||||
{currentStep === 3 && renderFriendSettings()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewAutoLike;
|
||||
230
nkebao/src/pages/mobile/workspace/auto-like/new/new.module.scss
Normal file
230
nkebao/src/pages/mobile/workspace/auto-like/new/new.module.scss
Normal file
@@ -0,0 +1,230 @@
|
||||
.formBg {
|
||||
background: #f8f6f3;
|
||||
min-height: 100vh;
|
||||
padding: 0 0 80px 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.basicSection {
|
||||
background: none;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
padding: 24px 16px 0 16px;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.formItem {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.formLabel {
|
||||
font-size: 15px;
|
||||
color: #222;
|
||||
font-weight: 500;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.input {
|
||||
height: 44px;
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.timeRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.inputTime {
|
||||
width: 100px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.timeTo {
|
||||
margin: 0 8px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.counterRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.counterBtn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
font-size: 16px;
|
||||
color: #188eee;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: border 0.2s;
|
||||
}
|
||||
|
||||
.counterBtn:hover {
|
||||
border: 1px solid #188eee;
|
||||
}
|
||||
|
||||
.counterValue {
|
||||
width: 48px;
|
||||
text-align: center;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.counterInputWrapper {
|
||||
position: relative;
|
||||
width: 80px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.counterInput {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
border-radius: 0;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #222;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.counterUnit {
|
||||
color: #888;
|
||||
font-size: 14px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.timeSeparator {
|
||||
margin: 0 8px;
|
||||
color: #888;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.counterTip {
|
||||
font-size: 12px;
|
||||
color: #aaa;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.contentTypes {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.contentTypeTag {
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
background: #f5f5f5;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.contentTypeTag:hover {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.contentTypeTagActive {
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
background: #e6f7ff;
|
||||
color: #188eee;
|
||||
border: 1px solid #91d5ff;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.switchRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.switchLabel {
|
||||
font-size: 15px;
|
||||
color: #222;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.switch {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.selectedTip {
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.formStepBtnRow {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.prevBtn {
|
||||
height: 44px;
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.nextBtn {
|
||||
height: 44px;
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.completeBtn {
|
||||
height: 44px;
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.formLoading {
|
||||
min-height: 200px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.footerBtnBar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 30;
|
||||
background: #fff;
|
||||
box-shadow: 0 -2px 8px rgba(0,0,0,0.04);
|
||||
padding: 16px 24px 24px 24px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.prevBtn, .nextBtn, .completeBtn {
|
||||
height: 44px;
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
min-width: 120px;
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import {
|
||||
ThumbsUp,
|
||||
RefreshCw,
|
||||
Search,
|
||||
} from 'lucide-react';
|
||||
import { Card, } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Avatar } from '@/components/ui/avatar';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import Layout from '@/components/Layout';
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import { useToast } from '@/components/ui/toast';
|
||||
import '@/components/Layout.css';
|
||||
import {
|
||||
fetchLikeRecords,
|
||||
LikeRecord,
|
||||
} from '@/api/autoLike';
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateString: string) => {
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
} catch (error) {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
export default function AutoLikeDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { toast } = useToast();
|
||||
const [records, setRecords] = useState<LikeRecord[]>([]);
|
||||
const [recordsLoading, setRecordsLoading] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const pageSize = 10;
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
setRecordsLoading(true);
|
||||
fetchLikeRecords(id, 1, pageSize)
|
||||
.then(response => {
|
||||
setRecords(response.list || []);
|
||||
setTotal(response.total || 0);
|
||||
setCurrentPage(1);
|
||||
})
|
||||
.catch(() => {
|
||||
toast({
|
||||
title: '获取点赞记录失败',
|
||||
description: '请稍后重试',
|
||||
variant: 'destructive',
|
||||
});
|
||||
})
|
||||
.finally(() => setRecordsLoading(false));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [id]);
|
||||
|
||||
const handleSearch = () => {
|
||||
setCurrentPage(1);
|
||||
fetchLikeRecords(id!, 1, pageSize, searchTerm)
|
||||
.then(response => {
|
||||
setRecords(response.list || []);
|
||||
setTotal(response.total || 0);
|
||||
setCurrentPage(1);
|
||||
})
|
||||
.catch(() => {
|
||||
toast({
|
||||
title: '获取点赞记录失败',
|
||||
description: '请稍后重试',
|
||||
variant: 'destructive',
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
fetchLikeRecords(id!, currentPage, pageSize, searchTerm)
|
||||
.then(response => {
|
||||
setRecords(response.list || []);
|
||||
setTotal(response.total || 0);
|
||||
})
|
||||
.catch(() => {
|
||||
toast({
|
||||
title: '获取点赞记录失败',
|
||||
description: '请稍后重试',
|
||||
variant: 'destructive',
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
fetchLikeRecords(id!, newPage, pageSize, searchTerm)
|
||||
.then(response => {
|
||||
setRecords(response.list || []);
|
||||
setTotal(response.total || 0);
|
||||
setCurrentPage(newPage);
|
||||
})
|
||||
.catch(() => {
|
||||
toast({
|
||||
title: '获取点赞记录失败',
|
||||
description: '请稍后重试',
|
||||
variant: 'destructive',
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<>
|
||||
<PageHeader
|
||||
title="点赞记录"
|
||||
defaultBackPath="/workspace/auto-like"
|
||||
/>
|
||||
<div className="flex items-center space-x-2 px-4 py-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder="搜索好友昵称或内容"
|
||||
className="pl-9"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" size="icon" onClick={handleRefresh} disabled={recordsLoading}>
|
||||
<RefreshCw className={`h-4 w-4 ${recordsLoading ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
footer={
|
||||
<>
|
||||
{records.length > 0 && total > pageSize && (
|
||||
<div className="flex justify-center py-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={currentPage === 1}
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
className="mx-1"
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<span className="mx-4 py-2 text-sm text-gray-500">
|
||||
第 {currentPage} 页,共 {Math.ceil(total / pageSize)} 页
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={currentPage >= Math.ceil(total / pageSize)}
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
className="mx-1"
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
}
|
||||
>
|
||||
<div className="bg-gray-50 min-h-screen pb-20">
|
||||
<div className="p-4 space-y-4">
|
||||
|
||||
{recordsLoading ? (
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<Card key={index} className="p-4">
|
||||
<div className="flex items-center space-x-3 mb-3">
|
||||
<Skeleton className="h-10 w-10 rounded-full" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-3 w-16" />
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="my-3" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<div className="flex space-x-2 mt-3">
|
||||
<Skeleton className="h-20 w-20" />
|
||||
<Skeleton className="h-20 w-20" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : records.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<ThumbsUp className="h-12 w-12 text-gray-300 mx-auto mb-3" />
|
||||
<p className="text-gray-500">暂无点赞记录</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{records.map((record) => (
|
||||
<div key={record.id} className="p-4 mb-4 bg-white rounded-2xl shadow-sm">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center space-x-3 max-w-[65%]">
|
||||
<Avatar>
|
||||
<img
|
||||
src={record.friendAvatar || "https://api.dicebear.com/7.x/avataaars/svg?seed=fallback"}
|
||||
alt={record.friendName}
|
||||
className="w-10 h-10 rounded-full"
|
||||
/>
|
||||
</Avatar>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium truncate" title={record.friendName}>
|
||||
{record.friendName}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">内容发布者</div>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline" className="bg-blue-50 whitespace-nowrap shrink-0">
|
||||
{formatDate(record.momentTime || record.likeTime)}
|
||||
</Badge>
|
||||
</div>
|
||||
<Separator className="my-3" />
|
||||
<div className="mb-3">
|
||||
{record.content && (
|
||||
<p className="text-gray-700 mb-3 whitespace-pre-line">
|
||||
{record.content}
|
||||
</p>
|
||||
)}
|
||||
{Array.isArray(record.resUrls) && record.resUrls.length > 0 && (
|
||||
<div className={`grid gap-2 ${
|
||||
record.resUrls.length === 1 ? "grid-cols-1" :
|
||||
record.resUrls.length === 2 ? "grid-cols-2" :
|
||||
record.resUrls.length <= 3 ? "grid-cols-3" :
|
||||
record.resUrls.length <= 6 ? "grid-cols-3 grid-rows-2" :
|
||||
"grid-cols-3 grid-rows-3"
|
||||
}`}>
|
||||
{record.resUrls.slice(0, 9).map((image: string, idx: number) => (
|
||||
<div key={idx} className="relative aspect-square rounded-md overflow-hidden">
|
||||
<img
|
||||
src={image}
|
||||
alt={`内容图片 ${idx + 1}`}
|
||||
className="object-cover w-full h-full"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center mt-4 p-2 bg-gray-50 rounded-md">
|
||||
<Avatar className="h-8 w-8 mr-2 shrink-0">
|
||||
<img
|
||||
src={record.operatorAvatar || "https://api.dicebear.com/7.x/avataaars/svg?seed=operator"}
|
||||
alt={record.operatorName}
|
||||
className="w-8 h-8 rounded-full"
|
||||
/>
|
||||
</Avatar>
|
||||
<div className="text-sm min-w-0">
|
||||
<span className="font-medium truncate inline-block max-w-full" title={record.operatorName}>
|
||||
{record.operatorName}
|
||||
</span>
|
||||
<span className="text-gray-500 ml-2">点赞了这条内容</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
63
nkebao/src/pages/mobile/workspace/auto-like/record/api.ts
Normal file
63
nkebao/src/pages/mobile/workspace/auto-like/record/api.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import request from "@/api/request";
|
||||
import {
|
||||
LikeTask,
|
||||
CreateLikeTaskData,
|
||||
UpdateLikeTaskData,
|
||||
LikeRecord,
|
||||
PaginatedResponse,
|
||||
} from "@/pages/workspace/auto-like/record/data";
|
||||
|
||||
// 获取自动点赞任务列表
|
||||
export function fetchAutoLikeTasks(
|
||||
params = { type: 1, page: 1, limit: 100 }
|
||||
): Promise<LikeTask[]> {
|
||||
return request("/v1/workbench/list", params, "GET");
|
||||
}
|
||||
|
||||
// 获取单个任务详情
|
||||
export function fetchAutoLikeTaskDetail(id: string): Promise<LikeTask | null> {
|
||||
return request("/v1/workbench/detail", { id }, "GET");
|
||||
}
|
||||
|
||||
// 创建自动点赞任务
|
||||
export function createAutoLikeTask(data: CreateLikeTaskData): Promise<any> {
|
||||
return request("/v1/workbench/create", { ...data, type: 1 }, "POST");
|
||||
}
|
||||
|
||||
// 更新自动点赞任务
|
||||
export function updateAutoLikeTask(data: UpdateLikeTaskData): Promise<any> {
|
||||
return request("/v1/workbench/update", { ...data, type: 1 }, "POST");
|
||||
}
|
||||
|
||||
// 删除自动点赞任务
|
||||
export function deleteAutoLikeTask(id: string): Promise<any> {
|
||||
return request("/v1/workbench/delete", { id }, "DELETE");
|
||||
}
|
||||
|
||||
// 切换任务状态
|
||||
export function toggleAutoLikeTask(id: string, status: string): Promise<any> {
|
||||
return request("/v1/workbench/update-status", { id, status }, "POST");
|
||||
}
|
||||
|
||||
// 复制自动点赞任务
|
||||
export function copyAutoLikeTask(id: string): Promise<any> {
|
||||
return request("/v1/workbench/copy", { id }, "POST");
|
||||
}
|
||||
|
||||
// 获取点赞记录
|
||||
export function fetchLikeRecords(
|
||||
workbenchId: string,
|
||||
page: number = 1,
|
||||
limit: number = 20,
|
||||
keyword?: string
|
||||
): Promise<PaginatedResponse<LikeRecord>> {
|
||||
const params: any = {
|
||||
workbenchId,
|
||||
page: page.toString(),
|
||||
limit: limit.toString(),
|
||||
};
|
||||
if (keyword) {
|
||||
params.keyword = keyword;
|
||||
}
|
||||
return request("/v1/workbench/like-records", params, "GET");
|
||||
}
|
||||
119
nkebao/src/pages/mobile/workspace/auto-like/record/data.ts
Normal file
119
nkebao/src/pages/mobile/workspace/auto-like/record/data.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
// 自动点赞任务状态
|
||||
export type LikeTaskStatus = 1 | 2; // 1: 开启, 2: 关闭
|
||||
|
||||
// 内容类型
|
||||
export type ContentType = "text" | "image" | "video" | "link";
|
||||
|
||||
// 设备信息
|
||||
export interface Device {
|
||||
id: string;
|
||||
name: string;
|
||||
status: "online" | "offline";
|
||||
lastActive: string;
|
||||
}
|
||||
|
||||
// 好友信息
|
||||
export interface Friend {
|
||||
id: string;
|
||||
nickname: string;
|
||||
wechatId: string;
|
||||
avatar: string;
|
||||
tags: string[];
|
||||
region: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
// 点赞记录
|
||||
export interface LikeRecord {
|
||||
id: string;
|
||||
workbenchId: string;
|
||||
momentsId: string;
|
||||
snsId: string;
|
||||
wechatAccountId: string;
|
||||
wechatFriendId: string;
|
||||
likeTime: string;
|
||||
content: string;
|
||||
resUrls: string[];
|
||||
momentTime: string;
|
||||
userName: string;
|
||||
operatorName: string;
|
||||
operatorAvatar: string;
|
||||
friendName: string;
|
||||
friendAvatar: string;
|
||||
}
|
||||
|
||||
// 自动点赞任务
|
||||
export interface LikeTask {
|
||||
id: string;
|
||||
name: string;
|
||||
status: LikeTaskStatus;
|
||||
deviceCount: number;
|
||||
targetGroup: string;
|
||||
likeCount: number;
|
||||
lastLikeTime: string;
|
||||
createTime: string;
|
||||
creator: string;
|
||||
likeInterval: number;
|
||||
maxLikesPerDay: number;
|
||||
timeRange: { start: string; end: string };
|
||||
contentTypes: ContentType[];
|
||||
targetTags: string[];
|
||||
devices: string[];
|
||||
friends: string[];
|
||||
friendMaxLikes: number;
|
||||
friendTags: string;
|
||||
enableFriendTags: boolean;
|
||||
todayLikeCount: number;
|
||||
totalLikeCount: number;
|
||||
updateTime: string;
|
||||
}
|
||||
|
||||
// 创建任务数据
|
||||
export interface CreateLikeTaskData {
|
||||
name: string;
|
||||
interval: number;
|
||||
maxLikes: number;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
contentTypes: ContentType[];
|
||||
devices: string[];
|
||||
friends?: string[];
|
||||
friendMaxLikes: number;
|
||||
friendTags?: string;
|
||||
enableFriendTags: boolean;
|
||||
targetTags: string[];
|
||||
}
|
||||
|
||||
// 更新任务数据
|
||||
export interface UpdateLikeTaskData extends CreateLikeTaskData {
|
||||
id: string;
|
||||
}
|
||||
|
||||
// 任务配置
|
||||
export interface TaskConfig {
|
||||
interval: number;
|
||||
maxLikes: number;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
contentTypes: ContentType[];
|
||||
devices: string[];
|
||||
friends: string[];
|
||||
friendMaxLikes: number;
|
||||
friendTags: string;
|
||||
enableFriendTags: boolean;
|
||||
}
|
||||
|
||||
// API响应类型
|
||||
export interface ApiResponse<T = any> {
|
||||
code: number;
|
||||
msg: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
// 分页响应类型
|
||||
export interface PaginatedResponse<T> {
|
||||
list: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
309
nkebao/src/pages/mobile/workspace/auto-like/record/index.tsx
Normal file
309
nkebao/src/pages/mobile/workspace/auto-like/record/index.tsx
Normal file
@@ -0,0 +1,309 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Card,
|
||||
Badge,
|
||||
Avatar,
|
||||
Skeleton,
|
||||
message,
|
||||
Spin,
|
||||
Divider,
|
||||
Pagination,
|
||||
} from "antd";
|
||||
import {
|
||||
LikeOutlined,
|
||||
ReloadOutlined,
|
||||
SearchOutlined,
|
||||
UserOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import styles from "./record.module.scss";
|
||||
import NavCommon from "@/components/NavCommon";
|
||||
import { fetchLikeRecords } from "./api";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateString: string) => {
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
} catch (error) {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
export default function AutoLikeRecord() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [records, setRecords] = useState<any[]>([]);
|
||||
const [recordsLoading, setRecordsLoading] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const pageSize = 10;
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
setRecordsLoading(true);
|
||||
fetchLikeRecords(id, 1, pageSize)
|
||||
.then((response: any) => {
|
||||
setRecords(response.list || []);
|
||||
setTotal(response.total || 0);
|
||||
setCurrentPage(1);
|
||||
})
|
||||
.catch(() => {
|
||||
message.error("获取点赞记录失败,请稍后重试");
|
||||
})
|
||||
.finally(() => setRecordsLoading(false));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [id]);
|
||||
|
||||
const handleSearch = () => {
|
||||
setCurrentPage(1);
|
||||
fetchLikeRecords(id!, 1, pageSize, searchTerm)
|
||||
.then((response: any) => {
|
||||
setRecords(response.list || []);
|
||||
setTotal(response.total || 0);
|
||||
setCurrentPage(1);
|
||||
})
|
||||
.catch(() => {
|
||||
message.error("获取点赞记录失败,请稍后重试");
|
||||
});
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
fetchLikeRecords(id!, currentPage, pageSize, searchTerm)
|
||||
.then((response: any) => {
|
||||
setRecords(response.list || []);
|
||||
setTotal(response.total || 0);
|
||||
})
|
||||
.catch(() => {
|
||||
message.error("获取点赞记录失败,请稍后重试");
|
||||
});
|
||||
};
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
fetchLikeRecords(id!, newPage, pageSize, searchTerm)
|
||||
.then((response: any) => {
|
||||
setRecords(response.list || []);
|
||||
setTotal(response.total || 0);
|
||||
setCurrentPage(newPage);
|
||||
})
|
||||
.catch(() => {
|
||||
message.error("获取点赞记录失败,请稍后重试");
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<>
|
||||
<NavCommon title="点赞记录" />
|
||||
<div className={styles.headerSearchBar}>
|
||||
<div className={styles.headerSearchInputWrap}>
|
||||
<Input
|
||||
prefix={<SearchOutlined className={styles.headerSearchIcon} />}
|
||||
placeholder="搜索好友昵称或内容"
|
||||
className={styles.headerSearchInput}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
onPressEnter={handleSearch}
|
||||
allowClear
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
icon={<ReloadOutlined spin={recordsLoading} />}
|
||||
onClick={handleRefresh}
|
||||
loading={recordsLoading}
|
||||
type="default"
|
||||
shape="circle"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
footer={
|
||||
<>
|
||||
<div className={styles.footerPagination}>
|
||||
<Pagination
|
||||
current={currentPage}
|
||||
total={total}
|
||||
pageSize={pageSize}
|
||||
onChange={handlePageChange}
|
||||
showSizeChanger={false}
|
||||
showQuickJumper
|
||||
showTotal={(total, range) =>
|
||||
`第 ${range[0]}-${range[1]} 条,共 ${total} 条`
|
||||
}
|
||||
size="default"
|
||||
className={styles.pagination}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className={styles.bgWrap}>
|
||||
<div className={styles.contentWrap}>
|
||||
{recordsLoading ? (
|
||||
<div className={styles.skeletonWrap}>
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div key={index} className={styles.skeletonCard}>
|
||||
<div className={styles.skeletonCardHeader}>
|
||||
<Skeleton.Avatar
|
||||
active
|
||||
size={40}
|
||||
className={styles.skeletonAvatar}
|
||||
/>
|
||||
<div className={styles.skeletonNameWrap}>
|
||||
<Skeleton.Input
|
||||
active
|
||||
size="small"
|
||||
className={styles.skeletonName}
|
||||
style={{ width: 96 }}
|
||||
/>
|
||||
<Skeleton.Input
|
||||
active
|
||||
size="small"
|
||||
className={styles.skeletonSub}
|
||||
style={{ width: 64 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Divider className={styles.skeletonSep} />
|
||||
<div className={styles.skeletonContentWrap}>
|
||||
<Skeleton.Input
|
||||
active
|
||||
size="small"
|
||||
className={styles.skeletonContent1}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
<Skeleton.Input
|
||||
active
|
||||
size="small"
|
||||
className={styles.skeletonContent2}
|
||||
style={{ width: "75%" }}
|
||||
/>
|
||||
<div className={styles.skeletonImgWrap}>
|
||||
<Skeleton.Image
|
||||
active
|
||||
className={styles.skeletonImg}
|
||||
style={{ width: 80, height: 80 }}
|
||||
/>
|
||||
<Skeleton.Image
|
||||
active
|
||||
className={styles.skeletonImg}
|
||||
style={{ width: 80, height: 80 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : records.length === 0 ? (
|
||||
<div className={styles.emptyWrap}>
|
||||
<LikeOutlined className={styles.emptyIcon} />
|
||||
<p className={styles.emptyText}>暂无点赞记录</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{records.map((record) => (
|
||||
<div key={record.id} className={styles.recordCard}>
|
||||
<div className={styles.recordCardHeader}>
|
||||
<div className={styles.recordCardHeaderLeft}>
|
||||
<Avatar
|
||||
src={record.friendAvatar || undefined}
|
||||
icon={<UserOutlined />}
|
||||
size={40}
|
||||
className={styles.avatarImg}
|
||||
/>
|
||||
<div className={styles.friendInfo}>
|
||||
<div
|
||||
className={styles.friendName}
|
||||
title={record.friendName}
|
||||
>
|
||||
{record.friendName}
|
||||
</div>
|
||||
<div className={styles.friendSub}>内容发布者</div>
|
||||
</div>
|
||||
</div>
|
||||
<Badge
|
||||
className={styles.timeBadge}
|
||||
count={formatDate(record.momentTime || record.likeTime)}
|
||||
style={{
|
||||
background: "#e8f0fe",
|
||||
color: "#333",
|
||||
fontWeight: 400,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Divider className={styles.cardSep} />
|
||||
<div className={styles.cardContent}>
|
||||
{record.content && (
|
||||
<p className={styles.contentText}>{record.content}</p>
|
||||
)}
|
||||
{Array.isArray(record.resUrls) &&
|
||||
record.resUrls.length > 0 && (
|
||||
<div
|
||||
className={
|
||||
`${styles.imgGrid} ` +
|
||||
(record.resUrls.length === 1
|
||||
? styles.grid1
|
||||
: record.resUrls.length === 2
|
||||
? styles.grid2
|
||||
: record.resUrls.length <= 3
|
||||
? styles.grid3
|
||||
: record.resUrls.length <= 6
|
||||
? styles.grid6
|
||||
: styles.grid9)
|
||||
}
|
||||
>
|
||||
{record.resUrls
|
||||
.slice(0, 9)
|
||||
.map((image: string, idx: number) => (
|
||||
<div key={idx} className={styles.imgItem}>
|
||||
<img
|
||||
src={image}
|
||||
alt={`内容图片 ${idx + 1}`}
|
||||
className={styles.img}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.operatorWrap}>
|
||||
<Avatar
|
||||
src={record.operatorAvatar || undefined}
|
||||
icon={<UserOutlined />}
|
||||
size={32}
|
||||
className={styles.operatorAvatar}
|
||||
/>
|
||||
<div className={styles.operatorInfo}>
|
||||
<span
|
||||
className={styles.operatorName}
|
||||
title={record.operatorName}
|
||||
>
|
||||
{record.operatorName}
|
||||
</span>
|
||||
<span className={styles.operatorAction}>
|
||||
<LikeOutlined
|
||||
style={{ color: "red", marginRight: 4 }}
|
||||
/>
|
||||
已赞
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
// 搜索栏
|
||||
.headerSearchBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
.headerSearchInputWrap {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
}
|
||||
.headerSearchIcon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 10px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: #a3a3a3;
|
||||
}
|
||||
.headerSearchInput {
|
||||
padding-left: 32px !important;
|
||||
}
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
// 分页
|
||||
.footerPagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
background: #fff;
|
||||
}
|
||||
.pagination {
|
||||
:global(.ant-pagination-item) {
|
||||
border-radius: 6px;
|
||||
}
|
||||
:global(.ant-pagination-item-active) {
|
||||
background: #1890ff;
|
||||
border-color: #1890ff;
|
||||
}
|
||||
:global(.ant-pagination-prev),
|
||||
:global(.ant-pagination-next) {
|
||||
border-radius: 6px;
|
||||
}
|
||||
:global(.ant-pagination-jump-prev),
|
||||
:global(.ant-pagination-jump-next) {
|
||||
border-radius: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
// 背景和内容
|
||||
.bgWrap {
|
||||
background: #f7f7fa;
|
||||
min-height: 100vh;
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
.contentWrap {
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
// 骨架屏
|
||||
.skeletonWrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.skeletonCard {
|
||||
padding: 0px;
|
||||
}
|
||||
.skeletonCardHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.skeletonAvatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.skeletonNameWrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.skeletonName {
|
||||
width: 96px;
|
||||
height: 16px;
|
||||
}
|
||||
.skeletonSub {
|
||||
width: 64px;
|
||||
height: 12px;
|
||||
}
|
||||
.skeletonSep {
|
||||
margin: 12px 0;
|
||||
}
|
||||
.skeletonContentWrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.skeletonContent1 {
|
||||
width: 100%;
|
||||
height: 16px;
|
||||
}
|
||||
.skeletonContent2 {
|
||||
width: 75%;
|
||||
height: 16px;
|
||||
}
|
||||
.skeletonImgWrap {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.skeletonImg {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
// 空状态
|
||||
.emptyWrap {
|
||||
text-align: center;
|
||||
padding: 48px 0;
|
||||
}
|
||||
.emptyIcon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: #e5e7eb;
|
||||
margin: 0 auto 12px auto;
|
||||
}
|
||||
.emptyText {
|
||||
color: #888;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
// 记录卡片
|
||||
.recordCard {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
||||
padding: 16px;
|
||||
}
|
||||
.recordCardHeader {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.recordCardHeaderLeft {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
max-width: 65%;
|
||||
}
|
||||
.avatarImg {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.friendInfo {
|
||||
min-width: 0;
|
||||
}
|
||||
.friendName {
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.friendSub {
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
}
|
||||
.timeBadge {
|
||||
background: #e8f0fe;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.cardSep {
|
||||
margin: 12px 0;
|
||||
}
|
||||
.cardContent {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.contentText {
|
||||
color: #444;
|
||||
margin-bottom: 12px;
|
||||
white-space: pre-line;
|
||||
}
|
||||
.imgGrid {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.grid1 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.grid2 {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.grid3 {
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
}
|
||||
.grid6 {
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
}
|
||||
.grid9 {
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
grid-template-rows: 1fr 1fr 1fr;
|
||||
}
|
||||
.imgItem {
|
||||
position: relative;
|
||||
aspect-ratio: 1/1;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
// 操作人
|
||||
.operatorWrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 16px;
|
||||
padding: 8px;
|
||||
background: #f3f4f6;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.operatorAvatar {
|
||||
width: 32px !important;
|
||||
height: 32px !important;
|
||||
margin-right: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.operatorInfo {
|
||||
font-size: 14px;
|
||||
position: relative;
|
||||
flex: 1;
|
||||
position: relative;
|
||||
}
|
||||
.operatorName {
|
||||
font-weight: 500;
|
||||
max-width: 100%;
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.operatorAction {
|
||||
color: #888;
|
||||
margin-left: 8px;
|
||||
font-size: 12px;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 2px;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import request from "../../../../api/request";
|
||||
|
||||
export interface GroupPushTask {
|
||||
id: string;
|
||||
name: string;
|
||||
status: number; // 1: 运行中, 2: 已暂停
|
||||
deviceCount: number;
|
||||
targetGroups: string[];
|
||||
pushCount: number;
|
||||
successCount: number;
|
||||
lastPushTime: string;
|
||||
createTime: string;
|
||||
creator: string;
|
||||
pushInterval: number;
|
||||
maxPushPerDay: number;
|
||||
timeRange: { start: string; end: string };
|
||||
messageType: "text" | "image" | "video" | "link";
|
||||
messageContent: string;
|
||||
targetTags: string[];
|
||||
pushMode: "immediate" | "scheduled";
|
||||
scheduledTime?: string;
|
||||
}
|
||||
|
||||
interface ApiResponse<T = any> {
|
||||
code: number;
|
||||
message: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
export async function fetchGroupPushTasks(): Promise<GroupPushTask[]> {
|
||||
const response = await request("/v1/workbench/list", { type: 3 }, "GET");
|
||||
if (Array.isArray(response)) return response;
|
||||
if (response && Array.isArray(response.data)) return response.data;
|
||||
return [];
|
||||
}
|
||||
|
||||
export async function deleteGroupPushTask(id: string): Promise<ApiResponse> {
|
||||
return request(`/v1/workspace/group-push/tasks/${id}`, {}, "DELETE");
|
||||
}
|
||||
|
||||
export async function toggleGroupPushTask(
|
||||
id: string,
|
||||
status: string
|
||||
): Promise<ApiResponse> {
|
||||
return request(
|
||||
`/v1/workspace/group-push/tasks/${id}/toggle`,
|
||||
{ status },
|
||||
"POST"
|
||||
);
|
||||
}
|
||||
|
||||
export async function copyGroupPushTask(id: string): Promise<ApiResponse> {
|
||||
return request(`/v1/workspace/group-push/tasks/${id}/copy`, {}, "POST");
|
||||
}
|
||||
|
||||
export async function createGroupPushTask(
|
||||
taskData: Partial<GroupPushTask>
|
||||
): Promise<ApiResponse> {
|
||||
return request("/v1/workspace/group-push/tasks", taskData, "POST");
|
||||
}
|
||||
|
||||
export async function updateGroupPushTask(
|
||||
id: string,
|
||||
taskData: Partial<GroupPushTask>
|
||||
): Promise<ApiResponse> {
|
||||
return request(`/v1/workspace/group-push/tasks/${id}`, taskData, "PUT");
|
||||
}
|
||||
|
||||
export async function getGroupPushTaskDetail(
|
||||
id: string
|
||||
): Promise<GroupPushTask> {
|
||||
return request(`/v1/workspace/group-push/tasks/${id}`);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
|
||||
|
||||
.searchBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 16px 0 8px 0;
|
||||
}
|
||||
|
||||
.taskList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.emptyCard {
|
||||
text-align: center;
|
||||
padding: 48px 0;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
||||
}
|
||||
|
||||
.taskCard {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
||||
padding: 20px 16px 12px 16px;
|
||||
}
|
||||
|
||||
.taskHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.taskTitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.taskActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.taskInfoGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 8px 16px;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.progressBlock {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.progressLabel {
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.taskFooter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
border-top: 1px dashed #eee;
|
||||
padding-top: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.expandedPanel {
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px dashed #eee;
|
||||
}
|
||||
|
||||
.expandedGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.taskCard {
|
||||
padding: 12px 6px 8px 6px;
|
||||
}
|
||||
.expandedGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
261
nkebao/src/pages/mobile/workspace/group-push/detail/index.tsx
Normal file
261
nkebao/src/pages/mobile/workspace/group-push/detail/index.tsx
Normal file
@@ -0,0 +1,261 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { Card, Badge, Button, Progress, Spin } from "antd";
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
SettingOutlined,
|
||||
TeamOutlined,
|
||||
MessageOutlined,
|
||||
CalendarOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import MeauMobile from "@/components/MeauMobile/MeauMoible";
|
||||
import {
|
||||
getGroupPushTaskDetail,
|
||||
GroupPushTask,
|
||||
} from "@/pages/workspace/group-push/detail/groupPush";
|
||||
import styles from "./index.module.scss";
|
||||
|
||||
const Detail: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [task, setTask] = useState<GroupPushTask | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
getGroupPushTaskDetail(id)
|
||||
.then((res) => {
|
||||
setTask(res.data || res); // 兼容两种返回格式
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<div
|
||||
style={{
|
||||
background: "#fff",
|
||||
padding: "0 16px",
|
||||
fontWeight: 600,
|
||||
fontSize: 18,
|
||||
}}
|
||||
>
|
||||
<ArrowLeftOutlined
|
||||
onClick={() => navigate(-1)}
|
||||
style={{ marginRight: 12, cursor: "pointer" }}
|
||||
/>
|
||||
群发推送详情
|
||||
</div>
|
||||
}
|
||||
footer={<MeauMobile />}
|
||||
>
|
||||
<div style={{ padding: 48, textAlign: "center" }}>
|
||||
<Spin />
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
if (!task) {
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<div
|
||||
style={{
|
||||
background: "#fff",
|
||||
padding: "0 16px",
|
||||
fontWeight: 600,
|
||||
fontSize: 18,
|
||||
}}
|
||||
>
|
||||
<ArrowLeftOutlined
|
||||
onClick={() => navigate(-1)}
|
||||
style={{ marginRight: 12, cursor: "pointer" }}
|
||||
/>
|
||||
群发推送详情
|
||||
</div>
|
||||
}
|
||||
footer={<MeauMobile />}
|
||||
>
|
||||
<div style={{ padding: 48, textAlign: "center", color: "#888" }}>
|
||||
未找到该任务
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
const getStatusColor = (status: number) => {
|
||||
switch (status) {
|
||||
case 1:
|
||||
return "green";
|
||||
case 2:
|
||||
return "gray";
|
||||
default:
|
||||
return "gray";
|
||||
}
|
||||
};
|
||||
const getStatusText = (status: number) => {
|
||||
switch (status) {
|
||||
case 1:
|
||||
return "进行中";
|
||||
case 2:
|
||||
return "已暂停";
|
||||
default:
|
||||
return "未知";
|
||||
}
|
||||
};
|
||||
const getMessageTypeText = (type: string) => {
|
||||
switch (type) {
|
||||
case "text":
|
||||
return "文字";
|
||||
case "image":
|
||||
return "图片";
|
||||
case "video":
|
||||
return "视频";
|
||||
case "link":
|
||||
return "链接";
|
||||
default:
|
||||
return "未知";
|
||||
}
|
||||
};
|
||||
const getSuccessRate = (pushCount: number, successCount: number) => {
|
||||
if (pushCount === 0) return 0;
|
||||
return Math.round((successCount / pushCount) * 100);
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<div
|
||||
style={{
|
||||
background: "#fff",
|
||||
padding: "0 16px",
|
||||
fontWeight: 600,
|
||||
fontSize: 18,
|
||||
}}
|
||||
>
|
||||
<ArrowLeftOutlined
|
||||
onClick={() => navigate(-1)}
|
||||
style={{ marginRight: 12, cursor: "pointer" }}
|
||||
/>
|
||||
群发推送详情
|
||||
</div>
|
||||
}
|
||||
footer={<MeauMobile />}
|
||||
>
|
||||
<div className={styles.bg}>
|
||||
<Card className={styles.taskCard}>
|
||||
<div className={styles.taskHeader}>
|
||||
<div className={styles.taskTitle}>
|
||||
<span>{task.name}</span>
|
||||
<Badge
|
||||
color={getStatusColor(task.status)}
|
||||
text={getStatusText(task.status)}
|
||||
style={{ marginLeft: 8 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.taskInfoGrid}>
|
||||
<div>执行设备:{task.deviceCount} 个</div>
|
||||
<div>目标群组:{task.targetGroups.length} 个</div>
|
||||
<div>
|
||||
推送成功:{task.successCount}/{task.pushCount}
|
||||
</div>
|
||||
<div>创建人:{task.creator}</div>
|
||||
</div>
|
||||
<div className={styles.progressBlock}>
|
||||
<div className={styles.progressLabel}>推送成功率</div>
|
||||
<Progress
|
||||
percent={getSuccessRate(task.pushCount, task.successCount)}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.taskFooter}>
|
||||
<div>
|
||||
<CalendarOutlined /> 上次推送:{task.lastPushTime}
|
||||
</div>
|
||||
<div>创建时间:{task.createTime}</div>
|
||||
</div>
|
||||
<div className={styles.expandedPanel}>
|
||||
<div className={styles.expandedGrid}>
|
||||
<div>
|
||||
<SettingOutlined /> <b>基本设置</b>
|
||||
<div>推送间隔:{task.pushInterval} 秒</div>
|
||||
<div>每日最大推送数:{task.maxPushPerDay} 条</div>
|
||||
<div>
|
||||
执行时间段:{task.timeRange.start} - {task.timeRange.end}
|
||||
</div>
|
||||
<div>
|
||||
推送模式:
|
||||
{task.pushMode === "immediate" ? "立即推送" : "定时推送"}
|
||||
</div>
|
||||
{task.scheduledTime && (
|
||||
<div>定时时间:{task.scheduledTime}</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<TeamOutlined /> <b>目标群组</b>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 4 }}>
|
||||
{task.targetGroups.map((group) => (
|
||||
<Badge
|
||||
key={group}
|
||||
color="blue"
|
||||
text={group}
|
||||
style={{ background: "#f0f5ff", marginRight: 4 }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<MessageOutlined /> <b>消息内容</b>
|
||||
<div>消息类型:{getMessageTypeText(task.messageType)}</div>
|
||||
<div
|
||||
style={{
|
||||
background: "#f5f5f5",
|
||||
padding: 8,
|
||||
borderRadius: 4,
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
{task.messageContent}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<CalendarOutlined /> <b>执行进度</b>
|
||||
<div>
|
||||
今日已推送:{task.pushCount} / {task.maxPushPerDay}
|
||||
</div>
|
||||
<Progress
|
||||
percent={Math.round(
|
||||
(task.pushCount / task.maxPushPerDay) * 100
|
||||
)}
|
||||
size="small"
|
||||
/>
|
||||
{task.targetTags.length > 0 && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<div>目标标签:</div>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 4 }}>
|
||||
{task.targetTags.map((tag) => (
|
||||
<Badge
|
||||
key={tag}
|
||||
color="purple"
|
||||
text={tag}
|
||||
style={{ background: "#f9f0ff", marginRight: 4 }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Detail;
|
||||
@@ -0,0 +1,237 @@
|
||||
import React, { useState } from "react";
|
||||
import { Input, Button, Card, Switch } from "antd";
|
||||
import { MinusOutlined, PlusOutlined } from "@ant-design/icons";
|
||||
|
||||
interface BasicSettingsProps {
|
||||
defaultValues?: {
|
||||
name: string;
|
||||
pushTimeStart: string;
|
||||
pushTimeEnd: string;
|
||||
dailyPushCount: number;
|
||||
pushOrder: "earliest" | "latest";
|
||||
isLoopPush: boolean;
|
||||
isImmediatePush: boolean;
|
||||
isEnabled: boolean;
|
||||
};
|
||||
onNext: (values: any) => void;
|
||||
onSave: (values: any) => void;
|
||||
onCancel: () => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const BasicSettings: React.FC<BasicSettingsProps> = ({
|
||||
defaultValues = {
|
||||
name: "",
|
||||
pushTimeStart: "06:00",
|
||||
pushTimeEnd: "23:59",
|
||||
dailyPushCount: 20,
|
||||
pushOrder: "latest",
|
||||
isLoopPush: false,
|
||||
isImmediatePush: false,
|
||||
isEnabled: false,
|
||||
},
|
||||
onNext,
|
||||
onSave,
|
||||
onCancel,
|
||||
loading = false,
|
||||
}) => {
|
||||
const [values, setValues] = useState(defaultValues);
|
||||
|
||||
const handleChange = (field: string, value: any) => {
|
||||
setValues((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleCountChange = (increment: boolean) => {
|
||||
setValues((prev) => ({
|
||||
...prev,
|
||||
dailyPushCount: increment
|
||||
? prev.dailyPushCount + 1
|
||||
: Math.max(1, prev.dailyPushCount - 1),
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Card>
|
||||
<div style={{ padding: 16 }}>
|
||||
{/* 任务名称 */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<span style={{ color: "red", marginRight: 4 }}>*</span>任务名称:
|
||||
<Input
|
||||
value={values.name}
|
||||
onChange={(e) => handleChange("name", e.target.value)}
|
||||
placeholder="请输入任务名称"
|
||||
style={{ marginTop: 4 }}
|
||||
/>
|
||||
</div>
|
||||
{/* 允许推送的时间段 */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<span>允许推送的时间段:</span>
|
||||
<div style={{ display: "flex", gap: 8, marginTop: 4 }}>
|
||||
<Input
|
||||
type="time"
|
||||
value={values.pushTimeStart}
|
||||
onChange={(e) => handleChange("pushTimeStart", e.target.value)}
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
<span style={{ color: "#888" }}>至</span>
|
||||
<Input
|
||||
type="time"
|
||||
value={values.pushTimeEnd}
|
||||
onChange={(e) => handleChange("pushTimeEnd", e.target.value)}
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* 每日推送 */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<span>每日推送:</span>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
icon={<MinusOutlined />}
|
||||
onClick={() => handleCountChange(false)}
|
||||
disabled={loading}
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
value={values.dailyPushCount}
|
||||
onChange={(e) =>
|
||||
handleChange(
|
||||
"dailyPushCount",
|
||||
Number.parseInt(e.target.value) || 1
|
||||
)
|
||||
}
|
||||
style={{ width: 80, textAlign: "center" }}
|
||||
min={1}
|
||||
disabled={loading}
|
||||
/>
|
||||
<Button
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => handleCountChange(true)}
|
||||
disabled={loading}
|
||||
/>
|
||||
<span style={{ color: "#888" }}>条内容</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* 推送顺序 */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<span>推送顺序:</span>
|
||||
<Button.Group style={{ marginLeft: 8 }}>
|
||||
<Button
|
||||
type={values.pushOrder === "earliest" ? "primary" : "default"}
|
||||
onClick={() => handleChange("pushOrder", "earliest")}
|
||||
disabled={loading}
|
||||
>
|
||||
按最早
|
||||
</Button>
|
||||
<Button
|
||||
type={values.pushOrder === "latest" ? "primary" : "default"}
|
||||
onClick={() => handleChange("pushOrder", "latest")}
|
||||
disabled={loading}
|
||||
>
|
||||
按最新
|
||||
</Button>
|
||||
</Button.Group>
|
||||
</div>
|
||||
{/* 是否循环推送 */}
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
<span style={{ color: "red", marginRight: 4 }}>*</span>
|
||||
是否循环推送:
|
||||
</span>
|
||||
<Switch
|
||||
checked={values.isLoopPush}
|
||||
onChange={(checked) => handleChange("isLoopPush", checked)}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
{/* 是否立即推送 */}
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
<span style={{ color: "red", marginRight: 4 }}>*</span>
|
||||
是否立即推送:
|
||||
</span>
|
||||
<Switch
|
||||
checked={values.isImmediatePush}
|
||||
onChange={(checked) => handleChange("isImmediatePush", checked)}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
{values.isImmediatePush && (
|
||||
<div
|
||||
style={{
|
||||
background: "#fffbe6",
|
||||
border: "1px solid #ffe58f",
|
||||
borderRadius: 4,
|
||||
padding: 8,
|
||||
color: "#ad8b00",
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
如果启用,系统会把内容库里所有的内容按顺序推送到指定的社群
|
||||
</div>
|
||||
)}
|
||||
{/* 是否启用 */}
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
<span style={{ color: "red", marginRight: 4 }}>*</span>是否启用:
|
||||
</span>
|
||||
<Switch
|
||||
checked={values.isEnabled}
|
||||
onChange={(checked) => handleChange("isEnabled", checked)}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 8,
|
||||
justifyContent: "flex-end",
|
||||
marginTop: 16,
|
||||
}}
|
||||
>
|
||||
<Button onClick={() => onNext(values)} disabled={loading}>
|
||||
下一步
|
||||
</Button>
|
||||
<Button onClick={() => onSave(values)} disabled={loading}>
|
||||
{loading ? "保存中..." : "保存"}
|
||||
</Button>
|
||||
<Button onClick={onCancel} disabled={loading}>
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BasicSettings;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user