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,113 +1,259 @@
import React from "react"; "use client";
import {
Form, import React, { useState, useEffect } from "react";
Input, import {
Selector, Form,
Button, Input,
Card, Button,
Space, Checkbox,
TextArea, Modal,
} from "antd-mobile"; Alert,
import style from "./FriendRequestSettings.module.scss"; Select,
message,
interface FriendRequestSettingsProps { } from "antd";
formData: any; import { QuestionCircleOutlined, MessageOutlined } from "@ant-design/icons";
onChange: (data: any) => void; import DeviceSelection from "@/components/DeviceSelection";
onNext: () => void;
onPrev: () => void; interface FriendRequestSettingsProps {
} formData: any;
onChange: (data: any) => void;
const FriendRequestSettings: React.FC<FriendRequestSettingsProps> = ({ onNext: () => void;
formData, onPrev: () => void;
onChange, }
onNext,
onPrev, // 招呼语模板
}) => { const greetingTemplates = [
const remarkTypeOptions = [ "你好,请通过",
{ label: "手机号", value: "phone" }, "你好,了解XX,请通过",
{ label: "微信号", value: "wechat" }, "你好我是XX产品的客服请通过",
{ label: "QQ号", value: "qq" }, "你好,感谢关注我们的产品",
{ label: "自定义", value: "custom" }, "你好,很高兴为您服务",
]; ];
const handleNext = () => { // 备注类型选项
if (!formData.greeting.trim()) { const remarkTypes = [
// 可以添加验证逻辑 { value: "phone", label: "手机号" },
} { value: "nickname", label: "昵称" },
onNext(); { value: "source", label: "来源" },
}; ];
return ( const FriendRequestSettings: React.FC<FriendRequestSettingsProps> = ({
<div className={style["friend-request-settings"]}> formData,
<Card className={style["form-card"]}> onChange,
<Form layout="vertical"> onNext,
{/* 备注类型 */} onPrev,
<Form.Item label="备注类型" required className={style["form-item"]}> }) => {
<Selector const [isTemplateDialogOpen, setIsTemplateDialogOpen] = useState(false);
options={remarkTypeOptions} const [hasWarnings, setHasWarnings] = useState(false);
value={[formData.remarkType]} const [selectedDevices, setSelectedDevices] = useState<any[]>(
onChange={(value) => onChange({ remarkType: value[0] })} formData.selectedDevices || []
/> );
</Form.Item> const [showRemarkTip, setShowRemarkTip] = useState(false);
{/* 备注格式 */} // 获取场景标题
{formData.remarkType === "custom" && ( const getScenarioTitle = () => {
<Form.Item label="备注格式" required className={style["form-item"]}> switch (formData.scenario) {
<Input case "douyin":
placeholder="请输入备注格式,如:{name}-{phone}" return "抖音直播";
value={formData.remarkFormat} case "xiaohongshu":
onChange={(value) => onChange({ remarkFormat: value })} return "小红书";
clearable case "weixinqun":
/> return "微信群";
</Form.Item> case "gongzhonghao":
)} return "公众号";
default:
{/* 打招呼消息 */} return formData.name || "获客计划";
<Form.Item label="打招呼消息" required className={style["form-item"]}> }
<TextArea };
placeholder="请输入打招呼消息"
value={formData.greeting} // 使用useEffect设置默认值
onChange={(value) => onChange({ greeting: value })} useEffect(() => {
rows={4} if (!formData.greeting) {
maxLength={200} onChange({
showCount ...formData,
/> greeting: "你好,请通过",
</Form.Item> remarkType: "phone", // 默认选择手机号
remarkFormat: `手机号+${getScenarioTitle()}`, // 默认备注格式
{/* 好友申请间隔 */} addFriendInterval: 1,
<Form.Item label="好友申请间隔(分钟)" className={style["form-item"]}> });
<Input }
type="number" }, [formData, formData.greeting, onChange]);
placeholder="请输入好友申请间隔"
value={formData.addFriendInterval.toString()} // 检查是否有未完成的必填项
onChange={(value) => useEffect(() => {
onChange({ addFriendInterval: Number(value) || 1 }) const hasIncompleteFields = !formData.greeting?.trim();
} setHasWarnings(hasIncompleteFields);
min={1} }, [formData]);
max={60}
/> const handleTemplateSelect = (template: string) => {
</Form.Item> onChange({ ...formData, greeting: template });
</Form> setIsTemplateDialogOpen(false);
</Card> };
{/* 操作按钮 */} const handleNext = () => {
<div className={style["actions"]}> // 即使有警告也允许进入下一步,但会显示提示
<Space style={{ width: "100%" }}> onNext();
<Button size="large" onClick={onPrev} className={style["prev-btn"]}> };
</Button> return (
<Button <>
color="primary" <div className="space-y-6">
size="large" <div>
onClick={handleNext} <span className="font-medium text-base"></span>
className={style["next-btn"]} <div className="mt-2">
> <DeviceSelection
selectedDevices={selectedDevices.map((d) => d.id)}
</Button> onSelect={(deviceIds) => {
</Space> const newSelectedDevices = deviceIds.map((id) => ({
</div> id,
</div> name: `设备 ${id}`,
); status: "online",
}; }));
setSelectedDevices(newSelectedDevices);
export default FriendRequestSettings; onChange({ ...formData, device: deviceIds });
}}
placeholder="选择设备"
/>
</div>
</div>
<div className="mb-4">
<div className="flex items-center space-x-2 mb-1 relative">
<span className="font-medium text-base"></span>
<span
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"
onMouseEnter={() => setShowRemarkTip(true)}
onMouseLeave={() => setShowRemarkTip(false)}
onClick={() => setShowRemarkTip((v) => !v)}
>
?
</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>
<div className="flex items-center justify-between">
<span className="font-medium text-base"></span>
<Button
onClick={() => setIsTemplateDialogOpen(true)}
className="text-blue-500"
>
<MessageOutlined className="h-4 w-4 mr-2" />
</Button>
</div>
<Input
value={formData.greeting}
onChange={(e) =>
onChange({ ...formData, greeting: e.target.value })
}
placeholder="请输入招呼语"
className="mt-2"
/>
</div>
<div>
<span className="font-medium text-base"></span>
<div className="flex items-center space-x-2 mt-2">
<Input
type="number"
value={formData.addFriendInterval || 1}
onChange={(e) =>
onChange({
...formData,
addFriendInterval: Number(e.target.value),
})
}
/>
<div className="w-10"></div>
</div>
</div>
<div>
<span className="font-medium text-base"></span>
<div className="flex items-center space-x-2 mt-2">
<Input
type="time"
value={formData.addFriendTimeStart || "09:00"}
onChange={(e) =>
onChange({ ...formData, addFriendTimeStart: e.target.value })
}
className="w-32"
/>
<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>
</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>
</>
);
};
export default FriendRequestSettings;

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,140 +1,603 @@
import React from "react"; import React, { useState } from "react";
import { import { Form, Input, Button, Tabs, Modal, Alert, Upload, message } from "antd";
Form, import {
Input, PlusOutlined,
Selector, CloseOutlined,
Button, UploadOutlined,
Card, ClockCircleOutlined,
Space, MessageOutlined,
TextArea, PictureOutlined,
Switch, VideoCameraOutlined,
} from "antd-mobile"; FileOutlined,
import style from "./MessageSettings.module.scss"; AppstoreOutlined,
LinkOutlined,
interface MessageSettingsProps { TeamOutlined,
formData: any; } from "@ant-design/icons";
onChange: (data: any) => void;
onNext: () => void; interface MessageContent {
onPrev: () => void; id: string;
saving: boolean; type: "text" | "image" | "video" | "file" | "miniprogram" | "link" | "group";
} content: string;
sendInterval?: number;
const MessageSettings: React.FC<MessageSettingsProps> = ({ intervalUnit?: "seconds" | "minutes";
formData, scheduledTime?: {
onChange, hour: number;
onNext, minute: number;
onPrev, second: number;
saving, };
}) => { title?: string;
const handleSave = () => { description?: string;
onNext(); address?: string;
}; coverImage?: string;
groupId?: string;
return ( linkUrl?: string;
<div className={style["message-settings"]}> }
<Card className={style["form-card"]}>
<Form layout="vertical"> interface DayPlan {
{/* 启用状态 */} day: number;
<Form.Item label="启用状态" className={style["form-item"]}> messages: MessageContent[];
<div className={style["switch-item"]}> }
<span></span>
<Switch interface MessageSettingsProps {
checked={formData.enabled} formData: any;
onChange={(checked) => onChange({ enabled: checked })} onChange: (data: any) => void;
/> onNext: () => void;
</div> onPrev: () => void;
</Form.Item> }
{/* 自动回复消息 */} // 消息类型配置
<Form.Item label="自动回复消息" className={style["form-item"]}> const messageTypes = [
<TextArea { id: "text", icon: MessageOutlined, label: "文本" },
placeholder="请输入自动回复消息" { id: "image", icon: PictureOutlined, label: "图片" },
value={formData.autoReply || ""} { id: "video", icon: VideoCameraOutlined, label: "视频" },
onChange={(value) => onChange({ autoReply: value })} { id: "file", icon: FileOutlined, label: "文件" },
rows={4} { id: "miniprogram", icon: AppstoreOutlined, label: "小程序" },
maxLength={500} { id: "link", icon: LinkOutlined, label: "链接" },
showCount { id: "group", icon: TeamOutlined, label: "邀请入群" },
/> ];
</Form.Item>
// 模拟群组数据
{/* 关键词回复 */} const mockGroups = [
<Form.Item label="关键词回复" className={style["form-item"]}> { id: "1", name: "产品交流群1", memberCount: 156 },
<TextArea { id: "2", name: "产品交流群2", memberCount: 234 },
placeholder="请输入关键词回复规则,格式:关键词=回复内容" { id: "3", name: "产品交流群3", memberCount: 89 },
value={formData.keywordReply || ""} ];
onChange={(value) => onChange({ keywordReply: value })}
rows={4} const MessageSettings: React.FC<MessageSettingsProps> = ({
maxLength={1000} formData,
showCount onChange,
/> onNext,
</Form.Item> onPrev,
}) => {
{/* 群发消息 */} const [dayPlans, setDayPlans] = useState<DayPlan[]>([
<Form.Item label="群发消息" className={style["form-item"]}> {
<TextArea day: 0,
placeholder="请输入群发消息内容" messages: [
value={formData.groupMessage || ""} {
onChange={(value) => onChange({ groupMessage: value })} id: "1",
rows={4} type: "text",
maxLength={500} content: "",
showCount sendInterval: 5,
/> intervalUnit: "seconds",
</Form.Item> },
],
{/* 消息发送间隔 */} },
<Form.Item label="消息发送间隔(秒)" className={style["form-item"]}> ]);
<Input const [isAddDayPlanOpen, setIsAddDayPlanOpen] = useState(false);
type="number" const [isGroupSelectOpen, setIsGroupSelectOpen] = useState(false);
placeholder="请输入消息发送间隔" const [selectedGroupId, setSelectedGroupId] = useState("");
value={formData.messageInterval?.toString() || "5"}
onChange={(value) => // 添加新消息
onChange({ messageInterval: Number(value) || 5 }) const handleAddMessage = (dayIndex: number, type = "text") => {
} const updatedPlans = [...dayPlans];
min={1} const newMessage: MessageContent = {
max={300} id: Date.now().toString(),
/> type: type as MessageContent["type"],
</Form.Item> content: "",
};
{/* 每日发送限制 */}
<Form.Item label="每日发送限制" className={style["form-item"]}> if (dayPlans[dayIndex].day === 0) {
<Input newMessage.sendInterval = 5;
type="number" newMessage.intervalUnit = "seconds";
placeholder="请输入每日发送限制数量" } else {
value={formData.dailyLimit?.toString() || "100"} newMessage.scheduledTime = {
onChange={(value) => hour: 9,
onChange({ dailyLimit: Number(value) || 100 }) minute: 0,
} second: 0,
min={1} };
max={1000} }
/>
</Form.Item> updatedPlans[dayIndex].messages.push(newMessage);
</Form> setDayPlans(updatedPlans);
</Card> onChange({ ...formData, messagePlans: updatedPlans });
};
{/* 操作按钮 */}
<div className={style["actions"]}> // 更新消息内容
<Space style={{ width: "100%" }}> const handleUpdateMessage = (
<Button dayIndex: number,
size="large" messageIndex: number,
onClick={onPrev} updates: Partial<MessageContent>
className={style["prev-btn"]} ) => {
disabled={saving} const updatedPlans = [...dayPlans];
> updatedPlans[dayIndex].messages[messageIndex] = {
...updatedPlans[dayIndex].messages[messageIndex],
</Button> ...updates,
<Button };
color="primary" setDayPlans(updatedPlans);
size="large" onChange({ ...formData, messagePlans: updatedPlans });
onClick={handleSave} };
className={style["save-btn"]}
loading={saving} // 删除消息
> const handleRemoveMessage = (dayIndex: number, messageIndex: number) => {
{saving ? "保存中..." : "保存计划"} const updatedPlans = [...dayPlans];
</Button> updatedPlans[dayIndex].messages.splice(messageIndex, 1);
</Space> setDayPlans(updatedPlans);
</div> onChange({ ...formData, messagePlans: updatedPlans });
</div> };
);
}; // 切换时间单位
const toggleIntervalUnit = (dayIndex: number, messageIndex: number) => {
export default MessageSettings; 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 className="flex items-center space-x-2 bg-white p-2 rounded-lg">
{messageTypes.map((type) => (
<Button
key={type.id}
type={message.type === type.id ? "primary" : "default"}
onClick={() =>
handleUpdateMessage(dayIndex, messageIndex, {
type: type.id as any,
})
}
className="flex flex-col items-center p-2 h-auto"
>
<type.icon className="h-4 w-4" />
</Button>
))}
</div>
{message.type === "text" && (
<Input.TextArea
value={message.content}
onChange={(e) =>
handleUpdateMessage(dayIndex, messageIndex, {
content: e.target.value,
})
}
placeholder="请输入消息内容"
className="min-h-[100px]"
/>
)}
{message.type === "miniprogram" && (
<div className="space-y-4">
<div className="space-y-2">
<div className="font-medium">
<span className="text-red-500">*</span>
</div>
<Input
value={message.title}
onChange={(e) =>
handleUpdateMessage(dayIndex, messageIndex, {
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" && (
<div className="space-y-4">
<div className="space-y-2">
<div className="font-medium">
<span className="text-red-500">*</span>
</div>
<Input
value={message.title}
onChange={(e) =>
handleUpdateMessage(dayIndex, messageIndex, {
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.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" && (
<div className="space-y-2">
<div className="font-medium">
<span className="text-red-500">*</span>
</div>
<Button onClick={() => setIsGroupSelectOpen(true)}>
{selectedGroupId
? mockGroups.find((g) => g.id === selectedGroupId)?.name
: "选择邀请入的群"}
</Button>
</div>
)}
{(message.type === "image" ||
message.type === "video" ||
message.type === "file") && (
<div className="border-2 border-dashed rounded-lg p-4 text-center">
<Button
onClick={() =>
handleFileUpload(
dayIndex,
messageIndex,
message.type as any
)
}
>
<UploadOutlined className="h-4 w-4 mr-2" />
{message.type === "image"
? "图片"
: message.type === "video"
? "视频"
: "文件"}
</Button>
</div>
)}
</div>
))}
<Button onClick={() => handleAddMessage(dayIndex)} className="w-full">
<PlusOutlined className="w-4 h-4 mr-2" />
</Button>
</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>
</>
);
};
export default MessageSettings;