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

场景计划构建90%当务之急,需要封装一下步骤器
This commit is contained in:
笔记本里的永平
2025-07-23 11:33:01 +08:00
parent 00900e1ea3
commit a3bc324943
13 changed files with 1891 additions and 841 deletions

View File

@@ -1,5 +1,5 @@
# 基础环境变量示例 # 基础环境变量示例
VITE_API_BASE_URL=http://www.yishi.com VITE_API_BASE_URL=http://www.yishi.com
VITE_API_BASE_URL=https://ckbapi.quwanzhi.com # VITE_API_BASE_URL=https://ckbapi.quwanzhi.com
VITE_APP_TITLE=Nkebao Base VITE_APP_TITLE=Nkebao Base

33
nkebao/src/api/common.ts Normal file
View File

@@ -0,0 +1,33 @@
import request from "./request";
/**
* 通用文件上传方法(支持图片、文件)
* @param {File} file - 要上传的文件对象
* @param {string} [uploadUrl='/v1/attachment/upload'] - 上传接口地址
* @returns {Promise<string>} - 上传成功后返回文件url
*/
export async function uploadFile(
file: File,
uploadUrl: string = "/v1/attachment/upload"
): Promise<string> {
try {
// 创建 FormData 对象用于文件上传
const formData = new FormData();
formData.append("file", file);
// 使用 request 方法上传文件,设置正确的 Content-Type
const res = await request(uploadUrl, formData, "POST", {
headers: {
"Content-Type": "multipart/form-data",
},
});
// 检查响应结果
if (res?.code === 200 && res?.data?.url) {
return res.data.url;
} else {
throw new Error(res?.msg || "文件上传失败");
}
} catch (e: any) {
throw new Error(e?.message || "文件上传失败");
}
}

View File

@@ -0,0 +1,53 @@
import request from "@/api/request";
// 获取场景类型列表
export function getScenarioTypes() {
return request("/v1/scenarios/types", undefined, "GET");
}
// 创建计划
export function createPlan(data: any) {
return request("/v1/scenarios/plans", 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

@@ -1,21 +1,20 @@
import React, { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { NavBar, Button, Toast, SpinLoading, Steps, Popup } from "antd-mobile"; import { LeftOutlined } from "@ant-design/icons";
import { ArrowLeftOutlined } from "@ant-design/icons"; import { Button, Steps, message } from "antd";
import Layout from "@/components/Layout/Layout";
import BasicSettings from "./steps/BasicSettings"; import BasicSettings from "./steps/BasicSettings";
import FriendRequestSettings from "./steps/FriendRequestSettings"; import FriendRequestSettings from "./steps/FriendRequestSettings";
import MessageSettings from "./steps/MessageSettings"; import MessageSettings from "./steps/MessageSettings";
import Layout from "@/components/Layout/Layout";
import { import {
getScenarioTypes, getPlanScenes,
createPlan, createScenarioPlan,
updatePlan, fetchPlanDetail,
getPlanDetail, PlanDetail,
} from "./page.api"; updateScenarioPlan,
import style from "./page.module.scss"; } from "./index.api";
// 步骤定义 // 步骤定义 - 只保留三个步骤
const steps = [ const steps = [
{ id: 1, title: "步骤一", subtitle: "基础设置" }, { id: 1, title: "步骤一", subtitle: "基础设置" },
{ id: 2, title: "步骤二", subtitle: "好友申请设置" }, { id: 2, title: "步骤二", subtitle: "好友申请设置" },
@@ -26,7 +25,7 @@ const steps = [
interface FormData { interface FormData {
name: string; name: string;
scenario: number; scenario: number;
posters: any[]; posters: any[]; // 后续可替换为具体Poster类型
device: string[]; device: string[];
remarkType: string; remarkType: string;
greeting: string; greeting: string;
@@ -39,8 +38,8 @@ interface FormData {
addFriendInterval: number; addFriendInterval: number;
} }
const NewPlan: React.FC = () => { export default function NewPlan() {
const navigate = useNavigate(); const router = useNavigate();
const [currentStep, setCurrentStep] = useState(1); const [currentStep, setCurrentStep] = useState(1);
const [formData, setFormData] = useState<FormData>({ const [formData, setFormData] = useState<FormData>({
name: "", name: "",
@@ -64,57 +63,53 @@ const NewPlan: React.FC = () => {
planId: string; planId: string;
}>(); }>();
const [isEdit, setIsEdit] = useState(false); const [isEdit, setIsEdit] = useState(false);
const [saving, setSaving] = useState(false);
useEffect(() => { useEffect(() => {
loadData(); loadData();
}, []); }, []);
const loadData = async () => { const loadData = async () => {
setSceneLoading(true); setSceneLoading(true);
try { //获取场景类型
// 获取场景类型 getPlanScenes()
const res = await getScenarioTypes(); .then((data) => {
if (res?.data) { setSceneList(data || []);
setSceneList(res.data); })
} .catch((err) => {
message.error(err.message || "获取场景类型失败");
if (planId) { })
setIsEdit(true); .finally(() => setSceneLoading(false));
// 获取计划详情 if (planId) {
const detailRes = await getPlanDetail(planId); setIsEdit(true);
if (detailRes.code === 200 && detailRes.data) { //获取计划详情
const detail = detailRes.data; try {
setFormData((prev) => ({ const detail = await fetchPlanDetail(planId);
...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,
}));
}
} else if (scenarioId) {
setFormData((prev) => ({ setFormData((prev) => ({
...prev, ...prev,
scenario: Number(scenarioId) || 1, 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 ?? "",
}));
} catch (err) {
message.error(err.message || "获取计划详情失败");
}
} else {
if (scenarioId) {
setFormData((prev) => ({
...prev,
...{ scenario: Number(scenarioId) || 1 },
})); }));
} }
} catch (error) {
Toast.show({
content: "加载数据失败",
position: "top",
});
} finally {
setSceneLoading(false);
} }
}; };
@@ -125,52 +120,35 @@ const NewPlan: React.FC = () => {
// 处理保存 // 处理保存
const handleSave = async () => { const handleSave = async () => {
if (!formData.name.trim()) {
Toast.show({
content: "请输入计划名称",
position: "top",
});
return;
}
setSaving(true);
try { try {
let result; let result;
if (isEdit && planId) { if (isEdit && planId) {
// 编辑 // 编辑:拼接后端需要的完整参数
const editData = { const editData = {
...formData, ...formData,
id: Number(planId), id: Number(planId),
planId: Number(planId), planId: Number(planId),
// 兼容后端需要的字段
// 你可以根据实际需要补充其它字段
}; };
result = await updatePlan(planId, editData); result = await updateScenarioPlan(planId, editData);
} else { } else {
// 新建 // 新建
result = await createPlan(formData); result = await createScenarioPlan(formData);
}
if (result.code === 200) {
Toast.show({
content: isEdit ? "计划已更新" : "获客计划已创建",
position: "top",
});
const sceneItem = sceneList.find((v) => formData.scenario === v.id);
navigate(
`/scenarios/list/${formData.sceneId}/${sceneItem?.name || ""}`
);
} else {
Toast.show({
content: result.msg || "操作失败",
position: "top",
});
} }
message.success(isEdit ? "计划已更新" : "获客计划已创建");
const sceneItem = sceneList.find((v) => formData.scenario === v.id);
router(`/scenarios/list/${formData.sceneId}/${sceneItem.name}`);
} catch (error) { } catch (error) {
Toast.show({ message.error(
content: isEdit ? "更新计划失败,请重试" : "创建计划失败,请重试", error instanceof Error
position: "top", ? error.message
}); : typeof error === "string"
} finally { ? error
setSaving(false); : isEdit
? "更新计划失败,请重试"
: "创建计划失败,请重试"
);
} }
}; };
@@ -194,6 +172,7 @@ const NewPlan: React.FC = () => {
case 1: case 1:
return ( return (
<BasicSettings <BasicSettings
isEdit={isEdit}
formData={formData} formData={formData}
onChange={onChange} onChange={onChange}
onNext={handleNext} onNext={handleNext}
@@ -215,9 +194,8 @@ const NewPlan: React.FC = () => {
<MessageSettings <MessageSettings
formData={formData} formData={formData}
onChange={onChange} onChange={onChange}
onNext={handleNext} onNext={handleSave}
onPrev={handlePrev} onPrev={handlePrev}
saving={saving}
/> />
); );
default: default:
@@ -225,50 +203,25 @@ const NewPlan: React.FC = () => {
} }
}; };
if (sceneLoading) {
return (
<Layout
header={
<NavBar back={null} style={{ background: "#fff" }}>
<div className={style["nav-title"]}>
{isEdit ? "编辑计划" : "新建计划"}
</div>
</NavBar>
}
>
<div className={style["loading"]}>
<SpinLoading color="primary" style={{ fontSize: 32 }} />
<div className={style["loading-text"]}>...</div>
</div>
</Layout>
);
}
return ( return (
<Layout <Layout
header={ header={
<> <>
<NavBar <header className="sticky top-0 z-10 bg-white border-b">
back={null} <div className="flex items-center justify-between h-14 px-4">
style={{ background: "#fff" }} <div className="flex items-center">
left={ <Button
<div className="nav-title"> type="text"
<ArrowLeftOutlined shape="circle"
twoToneColor="#1677ff" icon={<LeftOutlined />}
onClick={() => navigate(-1)} onClick={() => router(-1)}
/> />
</div> </div>
} </div>
> </header>
<span className="nav-title"> <div className="px-4 py-6">
{isEdit ? "编辑计划" : "新建计划"}
</span>
</NavBar>
{/* 步骤指示器 */}
<div className={style["steps-container"]}>
<Steps current={currentStep - 1}> <Steps current={currentStep - 1}>
{steps.map((step) => ( {steps.map((step, idx) => (
<Steps.Step <Steps.Step
key={step.id} key={step.id}
title={step.title} title={step.title}
@@ -280,12 +233,7 @@ const NewPlan: React.FC = () => {
</> </>
} }
> >
<div className={style["new-plan-page"]}> <div className="p-4">{renderStepContent()}</div>
{/* 步骤内容 */}
<div className={style["step-content"]}>{renderStepContent()}</div>
</div>
</Layout> </Layout>
); );
}; }
export default NewPlan;

View File

@@ -1,20 +0,0 @@
import request from "@/api/request";
// 获取场景类型列表
export function getScenarioTypes() {
return request("/api/scenarios/types", undefined, "GET");
}
// 创建计划
export function createPlan(data: any) {
return request("/api/scenarios/plans", data, "POST");
}
// 更新计划
export function updatePlan(planId: string, data: any) {
return request(`/api/scenarios/plans/${planId}`, data, "PUT");
}
// 获取计划详情
export function getPlanDetail(planId: string) {
return request(`/api/scenarios/plans/${planId}`, undefined, "GET");
}

View File

@@ -1,39 +0,0 @@
.new-plan-page {
}
.nav-title {
font-size: 18px;
font-weight: 600;
color: #333;
}
.back-btn {
height: 32px;
width: 32px;
padding: 0;
border-radius: 50%;
}
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 60vh;
gap: 16px;
}
.loading-text {
color: #666;
font-size: 14px;
}
.steps-container {
background: #ffffff;
margin-bottom: 12px;
}
.step-content {
flex: 1;
padding: 0 16px;
}

View File

@@ -1,63 +0,0 @@
.basic-settings {
padding: 16px 0;
}
.form-card {
margin-bottom: 20px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.form-item {
margin-bottom: 20px;
&:last-child {
margin-bottom: 0;
}
.adm-form-item-label {
font-size: 14px;
font-weight: 500;
color: #333;
margin-bottom: 8px;
}
.adm-input {
border-radius: 8px;
}
.adm-selector {
border-radius: 8px;
}
}
.time-input {
width: 120px;
border-radius: 8px;
}
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 40vh;
gap: 16px;
}
.loading-text {
color: #666;
font-size: 14px;
}
.actions {
padding: 20px 0;
}
.next-btn {
width: 100%;
height: 48px;
border-radius: 24px;
font-size: 16px;
font-weight: 500;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,56 +0,0 @@
.friend-request-settings {
padding: 16px 0;
}
.form-card {
margin-bottom: 20px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.form-item {
margin-bottom: 20px;
&:last-child {
margin-bottom: 0;
}
.adm-form-item-label {
font-size: 14px;
font-weight: 500;
color: #333;
margin-bottom: 8px;
}
.adm-input {
border-radius: 8px;
}
.adm-selector {
border-radius: 8px;
}
.adm-text-area {
border-radius: 8px;
}
}
.actions {
padding: 20px 0;
}
.prev-btn {
flex: 1;
height: 48px;
border-radius: 24px;
font-size: 16px;
font-weight: 500;
}
.next-btn {
flex: 1;
height: 48px;
border-radius: 24px;
font-size: 16px;
font-weight: 500;
}

View File

@@ -1,14 +1,18 @@
import React from "react"; "use client";
import React, { useState, useEffect } from "react";
import { import {
Form, Form,
Input, Input,
Selector,
Button, Button,
Card, Checkbox,
Space, Modal,
TextArea, Alert,
} from "antd-mobile"; Select,
import style from "./FriendRequestSettings.module.scss"; message,
} from "antd";
import { QuestionCircleOutlined, MessageOutlined } from "@ant-design/icons";
import DeviceSelection from "@/components/DeviceSelection";
interface FriendRequestSettingsProps { interface FriendRequestSettingsProps {
formData: any; formData: any;
@@ -17,96 +21,238 @@ interface FriendRequestSettingsProps {
onPrev: () => void; onPrev: () => void;
} }
// 招呼语模板
const greetingTemplates = [
"你好,请通过",
"你好,了解XX,请通过",
"你好我是XX产品的客服请通过",
"你好,感谢关注我们的产品",
"你好,很高兴为您服务",
];
// 备注类型选项
const remarkTypes = [
{ value: "phone", label: "手机号" },
{ value: "nickname", label: "昵称" },
{ value: "source", label: "来源" },
];
const FriendRequestSettings: React.FC<FriendRequestSettingsProps> = ({ const FriendRequestSettings: React.FC<FriendRequestSettingsProps> = ({
formData, formData,
onChange, onChange,
onNext, onNext,
onPrev, onPrev,
}) => { }) => {
const remarkTypeOptions = [ const [isTemplateDialogOpen, setIsTemplateDialogOpen] = useState(false);
{ label: "手机号", value: "phone" }, const [hasWarnings, setHasWarnings] = useState(false);
{ label: "微信号", value: "wechat" }, const [selectedDevices, setSelectedDevices] = useState<any[]>(
{ label: "QQ号", value: "qq" }, formData.selectedDevices || []
{ label: "自定义", value: "custom" }, );
]; 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 = () => { const handleNext = () => {
if (!formData.greeting.trim()) { // 即使有警告也允许进入下一步,但会显示提示
// 可以添加验证逻辑
}
onNext(); onNext();
}; };
return ( return (
<div className={style["friend-request-settings"]}> <>
<Card className={style["form-card"]}> <div className="space-y-6">
<Form layout="vertical"> <div>
{/* 备注类型 */} <span className="font-medium text-base"></span>
<Form.Item label="备注类型" required className={style["form-item"]}> <div className="mt-2">
<Selector <DeviceSelection
options={remarkTypeOptions} selectedDevices={selectedDevices.map((d) => d.id)}
value={[formData.remarkType]} onSelect={(deviceIds) => {
onChange={(value) => onChange({ remarkType: value[0] })} const newSelectedDevices = deviceIds.map((id) => ({
id,
name: `设备 ${id}`,
status: "online",
}));
setSelectedDevices(newSelectedDevices);
onChange({ ...formData, device: deviceIds });
}}
placeholder="选择设备"
/> />
</Form.Item> </div>
</div>
{/* 备注格式 */} <div className="mb-4">
{formData.remarkType === "custom" && ( <div className="flex items-center space-x-2 mb-1 relative">
<Form.Item label="备注格式" required className={style["form-item"]}> <span className="font-medium text-base"></span>
<Input <span
placeholder="请输入备注格式,如:{name}-{phone}" className="inline-flex items-center justify-center w-5 h-5 rounded-full bg-gray-200 text-gray-500 text-xs cursor-pointer hover:bg-gray-300 transition-colors"
value={formData.remarkFormat} onMouseEnter={() => setShowRemarkTip(true)}
onChange={(value) => onChange({ remarkFormat: value })} onMouseLeave={() => setShowRemarkTip(false)}
clearable onClick={() => setShowRemarkTip((v) => !v)}
/> >
</Form.Item> ?
)} </span>
{showRemarkTip && (
<div className="absolute left-24 top-0 z-20 w-64 p-3 bg-white border border-gray-200 rounded shadow-lg text-sm text-gray-700">
<div></div>
<div className="mt-2 text-xs text-gray-500"></div>
<div className="mt-1 text-blue-600">
{formData.remarkType === "phone" &&
`138****1234+${getScenarioTitle()}`}
{formData.remarkType === "nickname" &&
`小红书用户2851+${getScenarioTitle()}`}
{formData.remarkType === "source" &&
`抖音直播+${getScenarioTitle()}`}
</div>
</div>
)}
</div>
<Select
value={formData.remarkType || "phone"}
onChange={(value) => onChange({ ...formData, remarkType: value })}
className="w-full mt-2"
>
{remarkTypes.map((type) => (
<Select.Option key={type.value} value={type.value}>
{type.label}
</Select.Option>
))}
</Select>
</div>
{/* 打招呼消息 */} <div>
<Form.Item label="打招呼消息" required className={style["form-item"]}> <div className="flex items-center justify-between">
<TextArea <span className="font-medium text-base"></span>
placeholder="请输入打招呼消息" <Button
value={formData.greeting} onClick={() => setIsTemplateDialogOpen(true)}
onChange={(value) => onChange({ greeting: value })} className="text-blue-500"
rows={4} >
maxLength={200} <MessageOutlined className="h-4 w-4 mr-2" />
showCount
/> </Button>
</Form.Item> </div>
<Input
value={formData.greeting}
onChange={(e) =>
onChange({ ...formData, greeting: e.target.value })
}
placeholder="请输入招呼语"
className="mt-2"
/>
</div>
{/* 好友申请间隔 */} <div>
<Form.Item label="好友申请间隔(分钟)" className={style["form-item"]}> <span className="font-medium text-base"></span>
<div className="flex items-center space-x-2 mt-2">
<Input <Input
type="number" type="number"
placeholder="请输入好友申请间隔" value={formData.addFriendInterval || 1}
value={formData.addFriendInterval.toString()} onChange={(e) =>
onChange={(value) => onChange({
onChange({ addFriendInterval: Number(value) || 1 }) ...formData,
addFriendInterval: Number(e.target.value),
})
} }
min={1}
max={60}
/> />
</Form.Item> <div className="w-10"></div>
</Form> </div>
</Card> </div>
{/* 操作按钮 */} <div>
<div className={style["actions"]}> <span className="font-medium text-base"></span>
<Space style={{ width: "100%" }}> <div className="flex items-center space-x-2 mt-2">
<Button size="large" onClick={onPrev} className={style["prev-btn"]}> <Input
type="time"
</Button> value={formData.addFriendTimeStart || "09:00"}
<Button onChange={(e) =>
color="primary" onChange({ ...formData, addFriendTimeStart: e.target.value })
size="large" }
onClick={handleNext} className="w-32"
className={style["next-btn"]} />
> <span></span>
<Input
type="time"
value={formData.addFriendTimeEnd || "18:00"}
onChange={(e) =>
onChange({ ...formData, addFriendTimeEnd: e.target.value })
}
className="w-32"
/>
</div>
</div>
{hasWarnings && (
<Alert
message="警告"
description="您有未完成的设置项,建议完善后再进入下一步。"
type="warning"
showIcon
className="bg-amber-50 border-amber-200"
/>
)}
<div className="flex justify-between pt-4">
<Button onClick={onPrev}></Button>
<Button type="primary" onClick={handleNext}>
</Button> </Button>
</Space> </div>
</div> </div>
</div>
<Modal
open={isTemplateDialogOpen}
onCancel={() => setIsTemplateDialogOpen(false)}
footer={null}
>
<div className="space-y-2">
{greetingTemplates.map((template, index) => (
<Button
key={index}
onClick={() => handleTemplateSelect(template)}
style={{ width: "100%", marginBottom: 8 }}
>
{template}
</Button>
))}
</div>
</Modal>
</>
); );
}; };

View File

@@ -1,64 +0,0 @@
.message-settings {
padding: 16px 0;
}
.form-card {
margin-bottom: 20px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.form-item {
margin-bottom: 20px;
&:last-child {
margin-bottom: 0;
}
.adm-form-item-label {
font-size: 14px;
font-weight: 500;
color: #333;
margin-bottom: 8px;
}
.adm-input {
border-radius: 8px;
}
.adm-text-area {
border-radius: 8px;
}
}
.switch-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
span {
font-size: 14px;
color: #333;
}
}
.actions {
padding: 20px 0;
}
.prev-btn {
flex: 1;
height: 48px;
border-radius: 24px;
font-size: 16px;
font-weight: 500;
}
.save-btn {
flex: 1;
height: 48px;
border-radius: 24px;
font-size: 16px;
font-weight: 500;
}

View File

@@ -1,139 +1,602 @@
import React from "react"; import React, { useState } from "react";
import { Form, Input, Button, Tabs, Modal, Alert, Upload, message } from "antd";
import { import {
Form, PlusOutlined,
Input, CloseOutlined,
Selector, UploadOutlined,
Button, ClockCircleOutlined,
Card, MessageOutlined,
Space, PictureOutlined,
TextArea, VideoCameraOutlined,
Switch, FileOutlined,
} from "antd-mobile"; AppstoreOutlined,
import style from "./MessageSettings.module.scss"; LinkOutlined,
TeamOutlined,
} from "@ant-design/icons";
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 { interface MessageSettingsProps {
formData: any; formData: any;
onChange: (data: any) => void; onChange: (data: any) => void;
onNext: () => void; onNext: () => void;
onPrev: () => void; onPrev: () => void;
saving: boolean;
} }
// 消息类型配置
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> = ({ const MessageSettings: React.FC<MessageSettingsProps> = ({
formData, formData,
onChange, onChange,
onNext, onNext,
onPrev, onPrev,
saving,
}) => { }) => {
const handleSave = () => { const [dayPlans, setDayPlans] = useState<DayPlan[]>([
onNext(); {
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 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 });
}; };
return ( // 更新消息内容
<div className={style["message-settings"]}> const handleUpdateMessage = (
<Card className={style["form-card"]}> dayIndex: number,
<Form layout="vertical"> messageIndex: number,
{/* 启用状态 */} updates: Partial<MessageContent>
<Form.Item label="启用状态" className={style["form-item"]}> ) => {
<div className={style["switch-item"]}> const updatedPlans = [...dayPlans];
<span></span> updatedPlans[dayIndex].messages[messageIndex] = {
<Switch ...updatedPlans[dayIndex].messages[messageIndex],
checked={formData.enabled} ...updates,
onChange={(checked) => onChange({ enabled: checked })} };
/> 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 handleFileUpload = (
dayIndex: number,
messageIndex: number,
type: "image" | "video" | "file"
) => {
message.success(
`${
type === "image" ? "图片" : type === "video" ? "视频" : "文件"
}上传成功`
);
};
const items = dayPlans.map((plan, dayIndex) => ({
key: plan.day.toString(),
label: plan.day === 0 ? "即时消息" : `${plan.day}`,
children: (
<div className="space-y-4">
{plan.messages.map((message, messageIndex) => (
<div key={message.id} className="space-y-4 p-4 bg-gray-50 rounded-lg">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
{plan.day === 0 ? (
<>
<div className="w-10"></div>
<div className="w-40">
<Input
type="number"
value={String(message.sendInterval)}
onChange={(e) =>
handleUpdateMessage(dayIndex, messageIndex, {
sendInterval: Number(e.target.value),
})
}
/>
</div>
<Button
onClick={() => toggleIntervalUnit(dayIndex, messageIndex)}
className="flex items-center space-x-1"
>
<ClockCircleOutlined className="h-3 w-3" />
<span>
{message.intervalUnit === "minutes" ? "分钟" : "秒"}
</span>
</Button>
</>
) : (
<>
<div className="font-medium"></div>
<div className="flex items-center space-x-1">
<Input
type="number"
min={0}
max={23}
value={String(message.scheduledTime?.hour || 0)}
onChange={(e) =>
handleUpdateMessage(dayIndex, messageIndex, {
scheduledTime: {
...(message.scheduledTime || {
hour: 0,
minute: 0,
second: 0,
}),
hour: Number(e.target.value),
},
})
}
className="w-16"
/>
<span>:</span>
<Input
type="number"
min={0}
max={59}
value={String(message.scheduledTime?.minute || 0)}
onChange={(e) =>
handleUpdateMessage(dayIndex, messageIndex, {
scheduledTime: {
...(message.scheduledTime || {
hour: 0,
minute: 0,
second: 0,
}),
minute: Number(e.target.value),
},
})
}
className="w-16"
/>
<span>:</span>
<Input
type="number"
min={0}
max={59}
value={String(message.scheduledTime?.second || 0)}
onChange={(e) =>
handleUpdateMessage(dayIndex, messageIndex, {
scheduledTime: {
...(message.scheduledTime || {
hour: 0,
minute: 0,
second: 0,
}),
second: Number(e.target.value),
},
})
}
className="w-16"
/>
</div>
</>
)}
</div>
<Button
onClick={() => handleRemoveMessage(dayIndex, messageIndex)}
>
<CloseOutlined className="h-4 w-4" />
</Button>
</div> </div>
</Form.Item>
{/* 自动回复消息 */} <div className="flex items-center space-x-2 bg-white p-2 rounded-lg">
<Form.Item label="自动回复消息" className={style["form-item"]}> {messageTypes.map((type) => (
<TextArea <Button
placeholder="请输入自动回复消息" key={type.id}
value={formData.autoReply || ""} type={message.type === type.id ? "primary" : "default"}
onChange={(value) => onChange({ autoReply: value })} onClick={() =>
rows={4} handleUpdateMessage(dayIndex, messageIndex, {
maxLength={500} type: type.id as any,
showCount })
/> }
</Form.Item> className="flex flex-col items-center p-2 h-auto"
>
<type.icon className="h-4 w-4" />
</Button>
))}
</div>
{/* 关键词回复 */} {message.type === "text" && (
<Form.Item label="关键词回复" className={style["form-item"]}> <Input.TextArea
<TextArea value={message.content}
placeholder="请输入关键词回复规则,格式:关键词=回复内容" onChange={(e) =>
value={formData.keywordReply || ""} handleUpdateMessage(dayIndex, messageIndex, {
onChange={(value) => onChange({ keywordReply: value })} content: e.target.value,
rows={4} })
maxLength={1000} }
showCount placeholder="请输入消息内容"
/> className="min-h-[100px]"
</Form.Item> />
)}
{/* 群发消息 */} {message.type === "miniprogram" && (
<Form.Item label="群发消息" className={style["form-item"]}> <div className="space-y-4">
<TextArea <div className="space-y-2">
placeholder="请输入群发消息内容" <div className="font-medium">
value={formData.groupMessage || ""} <span className="text-red-500">*</span>
onChange={(value) => onChange({ groupMessage: value })} </div>
rows={4} <Input
maxLength={500} value={message.title}
showCount onChange={(e) =>
/> handleUpdateMessage(dayIndex, messageIndex, {
</Form.Item> title: e.target.value,
})
}
placeholder="请输入小程序标题"
/>
</div>
<div className="space-y-2">
<div className="font-medium"></div>
<Input
value={message.description}
onChange={(e) =>
handleUpdateMessage(dayIndex, messageIndex, {
description: e.target.value,
})
}
placeholder="请输入小程序描述"
/>
</div>
<div className="space-y-2">
<div className="font-medium">
<span className="text-red-500">*</span>
</div>
<Input
value={message.address}
onChange={(e) =>
handleUpdateMessage(dayIndex, messageIndex, {
address: e.target.value,
})
}
placeholder="请输入小程序路径"
/>
</div>
<div className="space-y-2">
<div className="font-medium">
<span className="text-red-500">*</span>
</div>
<div className="border-2 border-dashed rounded-lg p-4 text-center">
{message.coverImage ? (
<div className="relative">
<img
src={message.coverImage || "/placeholder.svg"}
alt="封面"
className="max-w-[200px] mx-auto rounded-lg"
/>
<Button
onClick={() =>
handleUpdateMessage(dayIndex, messageIndex, {
coverImage: undefined,
})
}
>
<CloseOutlined className="h-4 w-4" />
</Button>
</div>
) : (
<Button
onClick={() =>
handleFileUpload(dayIndex, messageIndex, "image")
}
>
<UploadOutlined className="h-4 w-4 mr-2" />
</Button>
)}
</div>
</div>
</div>
)}
{/* 消息发送间隔 */} {message.type === "link" && (
<Form.Item label="消息发送间隔(秒)" className={style["form-item"]}> <div className="space-y-4">
<Input <div className="space-y-2">
type="number" <div className="font-medium">
placeholder="请输入消息发送间隔" <span className="text-red-500">*</span>
value={formData.messageInterval?.toString() || "5"} </div>
onChange={(value) => <Input
onChange({ messageInterval: Number(value) || 5 }) value={message.title}
} onChange={(e) =>
min={1} handleUpdateMessage(dayIndex, messageIndex, {
max={300} title: e.target.value,
/> })
</Form.Item> }
placeholder="请输入链接标题"
/>
</div>
<div className="space-y-2">
<div className="font-medium"></div>
<Input
value={message.description}
onChange={(e) =>
handleUpdateMessage(dayIndex, messageIndex, {
description: e.target.value,
})
}
placeholder="请输入链接描述"
/>
</div>
<div className="space-y-2">
<div className="font-medium">
<span className="text-red-500">*</span>
</div>
<Input
value={message.linkUrl}
onChange={(e) =>
handleUpdateMessage(dayIndex, messageIndex, {
linkUrl: e.target.value,
})
}
placeholder="请输入链接地址"
/>
</div>
<div className="space-y-2">
<div className="font-medium">
<span className="text-red-500">*</span>
</div>
<div className="border-2 border-dashed rounded-lg p-4 text-center">
{message.coverImage ? (
<div className="relative">
<img
src={message.coverImage || "/placeholder.svg"}
alt="封面"
className="max-w-[200px] mx-auto rounded-lg"
/>
<Button
onClick={() =>
handleUpdateMessage(dayIndex, messageIndex, {
coverImage: undefined,
})
}
>
<CloseOutlined className="h-4 w-4" />
</Button>
</div>
) : (
<Button
onClick={() =>
handleFileUpload(dayIndex, messageIndex, "image")
}
>
<UploadOutlined className="h-4 w-4 mr-2" />
</Button>
)}
</div>
</div>
</div>
)}
{/* 每日发送限制 */} {message.type === "group" && (
<Form.Item label="每日发送限制" className={style["form-item"]}> <div className="space-y-2">
<Input <div className="font-medium">
type="number" <span className="text-red-500">*</span>
placeholder="请输入每日发送限制数量" </div>
value={formData.dailyLimit?.toString() || "100"} <Button onClick={() => setIsGroupSelectOpen(true)}>
onChange={(value) => {selectedGroupId
onChange({ dailyLimit: Number(value) || 100 }) ? mockGroups.find((g) => g.id === selectedGroupId)?.name
} : "选择邀请入的群"}
min={1} </Button>
max={1000} </div>
/> )}
</Form.Item>
</Form>
</Card>
{/* 操作按钮 */} {(message.type === "image" ||
<div className={style["actions"]}> message.type === "video" ||
<Space style={{ width: "100%" }}> message.type === "file") && (
<Button <div className="border-2 border-dashed rounded-lg p-4 text-center">
size="large" <Button
onClick={onPrev} onClick={() =>
className={style["prev-btn"]} handleFileUpload(
disabled={saving} dayIndex,
> messageIndex,
message.type as any
</Button> )
<Button }
color="primary" >
size="large" <UploadOutlined className="h-4 w-4 mr-2" />
onClick={handleSave}
className={style["save-btn"]} {message.type === "image"
loading={saving} ? "图片"
> : message.type === "video"
{saving ? "保存中..." : "保存计划"} ? "视频"
</Button> : "文件"}
</Space> </Button>
</div>
)}
</div>
))}
<Button onClick={() => handleAddMessage(dayIndex)} className="w-full">
<PlusOutlined className="w-4 h-4 mr-2" />
</Button>
</div> </div>
</div> ),
}));
return (
<>
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold"></h2>
<Button onClick={() => setIsAddDayPlanOpen(true)}>
<PlusOutlined className="h-4 w-4" />
</Button>
</div>
<Tabs defaultActiveKey="0" items={items} />
<div className="flex justify-between pt-4">
<Button onClick={onPrev}></Button>
<Button type="primary" onClick={onNext}>
</Button>
</div>
</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="w-full">
{dayPlans.length}
</Button>
</Modal>
{/* 选择群聊弹窗 */}
<Modal
title="选择群聊"
open={isGroupSelectOpen}
onCancel={() => setIsGroupSelectOpen(false)}
onOk={() => {
handleSelectGroup(selectedGroupId);
setIsGroupSelectOpen(false);
}}
>
<div className="space-y-2">
{mockGroups.map((group) => (
<div
key={group.id}
className={`p-4 rounded-lg cursor-pointer hover:bg-gray-100 ${
selectedGroupId === group.id
? "bg-blue-50 border border-blue-200"
: ""
}`}
onClick={() => handleSelectGroup(group.id)}
>
<div className="font-medium">{group.name}</div>
<div className="text-sm text-gray-500">
{group.memberCount}
</div>
</div>
))}
</div>
</Modal>
</>
); );
}; };