feat: 本次提交更新内容如下

迁移到pc端
This commit is contained in:
笔记本里的永平
2025-07-25 10:54:34 +08:00
parent 512383a198
commit 52cd0eeee6
127 changed files with 15073 additions and 2 deletions

View File

@@ -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

View File

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

View 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');
}

View 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;
}
}

View 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;

View 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");
}

View 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;
}

View 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;
}
}

View 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;

View 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;
}
}
}
}

View 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}
* />
* ```
*
* 特性:
* - 移动端使用 PopupPC端使用 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;

View 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);
}

View 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>
);
}

View File

@@ -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"]}>
CSVExcel
</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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View 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');
}

View File

@@ -0,0 +1,96 @@
.analyzerPage {
}
.tabs {
background: #fff;
padding: 0 12px;
border-radius: 0 0 12px 12px;
margin-bottom: 8px;
}
.planList {
display: flex;
flex-direction: column;
gap: 16px;
padding: 0 12px 16px 12px;
}
.planCard {
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
padding: 16px 14px 12px 14px;
display: flex;
flex-direction: column;
gap: 8px;
}
.cardHeader {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.cardTitle {
font-size: 16px;
font-weight: 700;
color: #222;
}
.statusDone {
background: #e6f9e6;
color: #22c55e;
font-size: 12px;
border-radius: 8px;
padding: 2px 10px;
font-weight: 600;
}
.statusDoing {
background: #e0f2fe;
color: #1677ff;
font-size: 12px;
border-radius: 8px;
padding: 2px 10px;
font-weight: 600;
}
.cardInfo {
font-size: 13px;
color: #444;
display: flex;
flex-direction: column;
gap: 4px;
}
.label {
color: #888;
font-size: 12px;
margin-right: 2px;
}
.keyword {
display: inline-block;
background: #f3f4f6;
color: #1677ff;
border-radius: 6px;
padding: 2px 8px;
font-size: 12px;
margin-right: 6px;
margin-bottom: 2px;
}
.cardActions {
display: flex;
gap: 10px;
margin-top: 8px;
}
.actionBtn {
border-radius: 6px !important;
font-size: 13px !important;
padding: 0 12px !important;
}

View 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;

View File

@@ -0,0 +1,139 @@
.chatContainer {
display: flex;
flex-direction: column;
}
.messageList {
flex: 1;
overflow-y: auto;
padding: 16px 12px 80px 12px;
display: flex;
flex-direction: column;
gap: 12px;
}
.userMessage {
display: flex;
flex-direction: column;
align-items: flex-end;
}
.aiMessage {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.bubble {
max-width: 80%;
padding: 10px 14px;
border-radius: 18px;
font-size: 15px;
line-height: 1.6;
word-break: break-word;
background: #fff;
color: #222;
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
}
.userMessage .bubble {
background: linear-gradient(135deg, #a7e0ff 0%, #5bbcff 100%);
color: #222;
border-bottom-right-radius: 6px;
}
.aiMessage .bubble {
background: #fff;
color: #222;
border-bottom-left-radius: 6px;
}
.time {
font-size: 11px;
color: #aaa;
margin: 4px 8px 0 8px;
align-self: flex-end;
}
.inputBar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
background: #fff;
padding: 10px 12px 10px 12px;
box-shadow: 0 -2px 8px rgba(0,0,0,0.04);
z-index: 10;
}
.input {
flex: 1;
border: none;
outline: none;
background: #f3f4f6;
border-radius: 18px;
padding: 10px 14px;
font-size: 15px;
margin-right: 8px;
}
.sendButton {
background: var(--primary-gradient, linear-gradient(135deg, #a7e0ff 0%, #5bbcff 100%));
color: #fff;
border: none;
border-radius: 18px;
padding: 8px 18px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.sendButton:disabled {
background: #e5e7eb;
color: #aaa;
cursor: not-allowed;
}
.iconBtn {
background: none;
border: none;
outline: none;
margin-right: 6px;
font-size: 20px;
color: #888;
cursor: pointer;
padding: 4px;
border-radius: 50%;
transition: background 0.2s, color 0.2s;
}
.iconBtn:hover, .iconBtn:active {
background: #f3f4f6;
color: #5bbcff;
}
.image {
max-width: 180px;
max-height: 180px;
border-radius: 10px;
display: block;
}
.fileLink {
color: #5bbcff;
text-decoration: none;
font-size: 15px;
word-break: break-all;
display: flex;
align-items: center;
}
.nav-title {
color: var(--primary-color);
font-weight: 700;
font-size: 18px;
text-shadow: 0 2px 4px rgba(24, 142, 238, 0.2);
}

View 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;

View File

@@ -0,0 +1,6 @@
import request from "@/api/request";
// 获取自动建群任务详情
export function getAutoGroupDetail(id: string) {
return request(`/api/auto-group/detail/${id}`);
}

View File

@@ -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;
}

View 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;

View 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");
}

View File

@@ -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;
}

View 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;

View File

@@ -0,0 +1,8 @@
import request from "@/api/request";
// 获取自动建群任务列表
export function getAutoGroupList(params?: any) {
return request("/api/auto-group/list", params, "GET");
}
// 其他相关API可按需添加

View File

@@ -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;
}

View 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;

View 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");
}

View File

@@ -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;
}
}

View 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;

View 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>
);
}

View 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");
}

View 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;

View 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;
}

View File

@@ -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>
);
}

View 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");
}

View 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;
}

View 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>
);
}

View File

@@ -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;
}

View File

@@ -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}`);
}

View File

@@ -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;
}
}

View 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;

View File

@@ -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