Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
caa0325cc9 | ||
|
|
502b9c99b2 | ||
|
|
f61f608aa4 | ||
|
|
9e5fb265ee | ||
|
|
51be5700d8 | ||
|
|
5909bd93e7 | ||
|
|
11194c8c6f | ||
|
|
9b8d39de5d | ||
|
|
27449bb483 | ||
|
|
bd824755b2 |
6
.env copy.development
Normal file
6
.env copy.development
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# 基础环境变量示例
|
||||||
|
VITE_API_BASE_URL=http://www.yishi.com
|
||||||
|
VITE_API_BASE_URL2=https://kf.quwanzhi.com:9991
|
||||||
|
VITE_API_WS_URL=wss://kf.quwanzhi.com:9993
|
||||||
|
# VITE_API_BASE_URL=https://ckbapi.quwanzhi.com
|
||||||
|
VITE_APP_TITLE=存客宝
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# 基础环境变量示例
|
# 基础环境变量示例
|
||||||
VITE_API_BASE_URL=http://www.yishi.com
|
VITE_API_BASE_URL=https://ckbapi.quwanzhi.com
|
||||||
VITE_API_BASE_URL2=https://kf.quwanzhi.com:9991
|
VITE_API_BASE_URL2=https://kf.quwanzhi.com:9991
|
||||||
VITE_API_WS_URL=wss://kf.quwanzhi.com:9993
|
VITE_API_WS_URL=wss://kf.quwanzhi.com:9993
|
||||||
# VITE_API_BASE_URL=https://ckbapi.quwanzhi.com
|
# VITE_API_BASE_URL=http://www.yishi.com
|
||||||
VITE_APP_TITLE=存客宝
|
VITE_APP_TITLE=存客宝
|
||||||
|
|||||||
95
devlop.py
95
devlop.py
@@ -1,95 +0,0 @@
|
|||||||
import os
|
|
||||||
import zipfile
|
|
||||||
import paramiko
|
|
||||||
|
|
||||||
# 配置
|
|
||||||
local_dir = './dist' # 本地要打包的目录
|
|
||||||
zip_name = 'dist.zip'
|
|
||||||
# 上传到服务器的 zip 路径
|
|
||||||
remote_path = '/www/wwwroot/auto-devlop/ckb-operation/dist.zip' # 服务器上的临时zip路径
|
|
||||||
server_ip = '42.194.245.239'
|
|
||||||
server_port = 6523
|
|
||||||
server_user = 'yongpxu'
|
|
||||||
server_pwd = 'Aa123456789.'
|
|
||||||
# 服务器 dist 相关目录
|
|
||||||
remote_base_dir = '/www/wwwroot/auto-devlop/ckb-operation'
|
|
||||||
dist_dir = f'{remote_base_dir}/dist'
|
|
||||||
dist1_dir = f'{remote_base_dir}/dist1'
|
|
||||||
dist2_dir = f'{remote_base_dir}/dist2'
|
|
||||||
|
|
||||||
# 美化输出用的函数
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
def info(msg):
|
|
||||||
print(f"\033[36m[INFO {datetime.now().strftime('%H:%M:%S')}] {msg}\033[0m")
|
|
||||||
|
|
||||||
def success(msg):
|
|
||||||
print(f"\033[32m[SUCCESS] {msg}\033[0m")
|
|
||||||
|
|
||||||
def error(msg):
|
|
||||||
print(f"\033[31m[ERROR] {msg}\033[0m")
|
|
||||||
|
|
||||||
def step(msg):
|
|
||||||
print(f"\n\033[35m==== {msg} ====" + "\033[0m")
|
|
||||||
|
|
||||||
# 1. 先运行 pnpm build
|
|
||||||
step('Step 1: 构建项目 (pnpm build)')
|
|
||||||
info('开始执行 pnpm build...')
|
|
||||||
ret = os.system('pnpm build')
|
|
||||||
if ret != 0:
|
|
||||||
error('pnpm build 失败,终止部署!')
|
|
||||||
exit(1)
|
|
||||||
success('pnpm build 完成')
|
|
||||||
|
|
||||||
# 2. 打包
|
|
||||||
step('Step 2: 打包 dist 目录为 zip')
|
|
||||||
info('开始打包 dist 目录...')
|
|
||||||
with zipfile.ZipFile(zip_name, 'w', zipfile.ZIP_DEFLATED) as zipf:
|
|
||||||
for root, dirs, files in os.walk(local_dir):
|
|
||||||
for file in files:
|
|
||||||
filepath = os.path.join(root, file)
|
|
||||||
arcname = os.path.relpath(filepath, local_dir)
|
|
||||||
zipf.write(filepath, arcname)
|
|
||||||
success('本地打包完成')
|
|
||||||
|
|
||||||
# 3. 上传
|
|
||||||
step('Step 3: 上传 zip 包到服务器')
|
|
||||||
info('开始上传 zip 包...')
|
|
||||||
transport = paramiko.Transport((server_ip, server_port))
|
|
||||||
transport.connect(username=server_user, password=server_pwd)
|
|
||||||
sftp = paramiko.SFTPClient.from_transport(transport)
|
|
||||||
sftp.put(zip_name, remote_path)
|
|
||||||
sftp.close()
|
|
||||||
transport.close()
|
|
||||||
success('上传到服务器完成')
|
|
||||||
|
|
||||||
# 删除本地 dist.zip
|
|
||||||
try:
|
|
||||||
os.remove(zip_name)
|
|
||||||
success('本地 dist.zip 已删除')
|
|
||||||
except Exception as e:
|
|
||||||
error(f'本地 dist.zip 删除失败: {e}')
|
|
||||||
|
|
||||||
# 4. 远程解压并覆盖
|
|
||||||
step('Step 4: 服务器端解压、切换目录')
|
|
||||||
ssh = paramiko.SSHClient()
|
|
||||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
||||||
ssh.connect(server_ip, server_port, server_user, server_pwd)
|
|
||||||
commands = [
|
|
||||||
f'unzip -oq {remote_path} -d {dist2_dir}', # 静默解压
|
|
||||||
f'rm {remote_path}',
|
|
||||||
f'if [ -d {dist_dir} ]; then mv {dist_dir} {dist1_dir}; fi',
|
|
||||||
f'mv {dist2_dir} {dist_dir}',
|
|
||||||
f'rm -rf {dist1_dir}'
|
|
||||||
]
|
|
||||||
for i, cmd in enumerate(commands, 1):
|
|
||||||
info(f'执行第{i}步: {cmd}')
|
|
||||||
stdin, stdout, stderr = ssh.exec_command(cmd)
|
|
||||||
out, err = stdout.read().decode(), stderr.read().decode()
|
|
||||||
# 只打印非 unzip 命令的输出
|
|
||||||
if i != 1 and out.strip():
|
|
||||||
print(out.strip())
|
|
||||||
if err.strip():
|
|
||||||
error(err.strip())
|
|
||||||
ssh.close()
|
|
||||||
success('服务器解压并覆盖完成,部署成功!')
|
|
||||||
596
master.py
Normal file
596
master.py
Normal file
@@ -0,0 +1,596 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
CKB 运营端静态站点部署(统一主入口,全程宝塔面板 API,无需 SSH)。
|
||||||
|
默认宝塔面板主机(最新):43.139.27.93;站点根:/www/wwwroot/auto-devlop/ckb-operation。
|
||||||
|
未设置 BT_PANEL_URL 时,面板地址为 https://{DEPLOY_HOST 或默认 IP}:9988。
|
||||||
|
|
||||||
|
流程与 soul-api/master.py 对齐:
|
||||||
|
探活(GetDiskInfo) → 本地 pnpm 构建 dist → 打包 zip → /files?action=upload
|
||||||
|
→ /files?action=UnZip 到 dist2 → MvFile 切换 dist/dist2(等价原 dist→dist1→删)→ DeleteFile 删包
|
||||||
|
|
||||||
|
环境变量(与 soul-api 共用宝塔相关项):
|
||||||
|
BT_PANEL_URL、BT_API_KEY、BT_UPLOAD_CHUNK_MB(可选,默认 4)
|
||||||
|
DEPLOY_HOST(未设 BT_PANEL_URL 时默认 https://{DEPLOY_HOST}:9988)
|
||||||
|
DEPLOY_BASE_PATH / DEPLOY_BASE_PATH_PROD / DEPLOY_BASE_PATH_DEV
|
||||||
|
|
||||||
|
deploy.py 作为 dev 包装入口:default_profile=dev。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import print_function
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
try:
|
||||||
|
import requests
|
||||||
|
|
||||||
|
try:
|
||||||
|
import urllib3
|
||||||
|
|
||||||
|
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except ImportError:
|
||||||
|
requests = None
|
||||||
|
|
||||||
|
# 与 soul-api/master.py 一致,便于同一套面板密钥
|
||||||
|
BT_API_KEY_DEFAULT = "qcWubCdlfFjS2b2DMT1lzPFaDfmv1cBT"
|
||||||
|
|
||||||
|
# 默认宝塔面板主机(最新 IP);可用环境变量 DEPLOY_HOST 或 BT_PANEL_URL 覆盖
|
||||||
|
DEFAULT_DEPLOY_HOST = "43.139.27.93"
|
||||||
|
DEFAULT_BASE_PATH = "/www/wwwroot/auto-devlop/ckb-operation"
|
||||||
|
|
||||||
|
PROFILE_PRESETS = {
|
||||||
|
"prod": {
|
||||||
|
"title": "ckb-operation",
|
||||||
|
"default_base_path": DEFAULT_BASE_PATH,
|
||||||
|
"build_cmd": ["pnpm", "build"],
|
||||||
|
"build_desc": "pnpm build(正式环境)",
|
||||||
|
},
|
||||||
|
"dev": {
|
||||||
|
"title": "ckb-operation-dev",
|
||||||
|
"default_base_path": DEFAULT_BASE_PATH,
|
||||||
|
"build_cmd": ["pnpm", "run", "build:dev"],
|
||||||
|
"build_desc": "pnpm run build:dev(测试环境)",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_profile(profile_name):
|
||||||
|
profile = (profile_name or "prod").strip().lower()
|
||||||
|
if profile not in PROFILE_PRESETS:
|
||||||
|
raise ValueError("不支持的 profile: %s" % profile_name)
|
||||||
|
return profile
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_base_path(profile, cli_base_path=None):
|
||||||
|
preset = PROFILE_PRESETS[profile]
|
||||||
|
if cli_base_path:
|
||||||
|
return cli_base_path.rstrip("/")
|
||||||
|
|
||||||
|
profile_env_key = "DEPLOY_BASE_PATH_%s" % profile.upper()
|
||||||
|
base = (os.environ.get(profile_env_key) or "").strip()
|
||||||
|
if not base:
|
||||||
|
base = (os.environ.get("DEPLOY_BASE_PATH") or "").strip()
|
||||||
|
if not base:
|
||||||
|
base = preset["default_base_path"]
|
||||||
|
return base.rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def get_cfg(profile, cli_base_path=None):
|
||||||
|
base = _resolve_base_path(profile, cli_base_path=cli_base_path)
|
||||||
|
host = os.environ.get("DEPLOY_HOST", DEFAULT_DEPLOY_HOST)
|
||||||
|
bt_url = (os.environ.get("BT_PANEL_URL") or "").strip().rstrip("/")
|
||||||
|
if not bt_url:
|
||||||
|
bt_url = "https://%s:9988" % host
|
||||||
|
return {
|
||||||
|
"profile": profile,
|
||||||
|
"title": PROFILE_PRESETS[profile]["title"],
|
||||||
|
"build_cmd": list(PROFILE_PRESETS[profile]["build_cmd"]),
|
||||||
|
"build_desc": PROFILE_PRESETS[profile]["build_desc"],
|
||||||
|
"host": host,
|
||||||
|
"base_path": base,
|
||||||
|
"dist_path": base + "/dist",
|
||||||
|
"dist2_path": base + "/dist2",
|
||||||
|
"dist1_path": base + "/dist1",
|
||||||
|
"bt_panel_url": bt_url,
|
||||||
|
"bt_api_key": os.environ.get("BT_API_KEY", BT_API_KEY_DEFAULT),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_project_root():
|
||||||
|
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
if os.path.isfile(os.path.join(script_dir, "package.json")):
|
||||||
|
return script_dir
|
||||||
|
return os.path.dirname(script_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_dist_ready(root):
|
||||||
|
return os.path.isdir(os.path.join(root, "dist")) and os.path.isfile(
|
||||||
|
os.path.join(root, "dist", "index.html")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run_build(root, cfg):
|
||||||
|
print("[1/5] 本地构建 %s ..." % cfg["build_desc"])
|
||||||
|
use_shell = sys.platform == "win32"
|
||||||
|
try:
|
||||||
|
r = subprocess.run(
|
||||||
|
cfg["build_cmd"],
|
||||||
|
cwd=root,
|
||||||
|
shell=use_shell,
|
||||||
|
timeout=300,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
|
)
|
||||||
|
if r.returncode != 0:
|
||||||
|
print(" [失败] 构建失败,退出码:", r.returncode)
|
||||||
|
for line in (r.stdout or "").strip().split("\n")[-10:]:
|
||||||
|
if line:
|
||||||
|
print(" " + line)
|
||||||
|
for line in (r.stderr or "").strip().split("\n")[-10:]:
|
||||||
|
if line:
|
||||||
|
print(" " + line)
|
||||||
|
return False
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
print(" [失败] 构建超时")
|
||||||
|
return False
|
||||||
|
except FileNotFoundError:
|
||||||
|
print(" [失败] 未找到 pnpm,请安装: npm install -g pnpm")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
print(" [失败] 构建异常:", str(e))
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not ensure_dist_ready(root):
|
||||||
|
print(" [失败] 未找到 dist/index.html")
|
||||||
|
return False
|
||||||
|
print(" [成功] 构建完成")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def pack_dist_zip(root, profile):
|
||||||
|
print("[2/5] 打包 dist 为 zip ...")
|
||||||
|
dist_dir = os.path.join(root, "dist")
|
||||||
|
if not os.path.isdir(dist_dir):
|
||||||
|
print(" [失败] 未找到 dist 目录")
|
||||||
|
return None
|
||||||
|
|
||||||
|
index_html = os.path.join(dist_dir, "index.html")
|
||||||
|
if not os.path.isfile(index_html):
|
||||||
|
print(" [失败] 未找到 dist/index.html,请先执行构建")
|
||||||
|
return None
|
||||||
|
|
||||||
|
zip_name = "ckb_operation_%s_deploy.zip" % profile
|
||||||
|
zip_path = os.path.join(tempfile.gettempdir(), zip_name)
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
for dirpath, _dirs, filenames in os.walk(dist_dir):
|
||||||
|
for name in filenames:
|
||||||
|
full = os.path.join(dirpath, name)
|
||||||
|
arcname = os.path.relpath(full, dist_dir).replace("\\", "/")
|
||||||
|
zf.write(full, arcname)
|
||||||
|
print(
|
||||||
|
" [成功] 打包完成: %s (%.2f MB)"
|
||||||
|
% (zip_path, os.path.getsize(zip_path) / 1024 / 1024)
|
||||||
|
)
|
||||||
|
return zip_path
|
||||||
|
except Exception as e:
|
||||||
|
print(" [失败] 打包异常:", str(e))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- 宝塔 API(与 soul-api/master.py 同源精简) ----------
|
||||||
|
|
||||||
|
|
||||||
|
def _bt_signed_post(base_url, key, path, extra_data, timeout=20):
|
||||||
|
req_time = int(time.time())
|
||||||
|
sk_md5 = hashlib.md5(key.encode()).hexdigest()
|
||||||
|
req_token = hashlib.md5(("%s%s" % (req_time, sk_md5)).encode()).hexdigest()
|
||||||
|
data = {"request_time": req_time, "request_token": req_token}
|
||||||
|
data.update(extra_data or {})
|
||||||
|
return requests.post(base_url + path, data=data, timeout=timeout, verify=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _bt_parse_json_response(r):
|
||||||
|
if r is None or r.status_code != 200:
|
||||||
|
return None
|
||||||
|
ct = (r.headers.get("content-type") or "").lower()
|
||||||
|
if "json" in ct:
|
||||||
|
try:
|
||||||
|
return r.json()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
t = (r.text or "").lstrip()
|
||||||
|
if t.startswith("{"):
|
||||||
|
try:
|
||||||
|
return json.loads(r.text)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def bt_panel_ping(cfg):
|
||||||
|
if not requests:
|
||||||
|
return True
|
||||||
|
url = (cfg.get("bt_panel_url") or "").rstrip("/")
|
||||||
|
key = cfg.get("bt_api_key") or ""
|
||||||
|
if not url or not key:
|
||||||
|
print("[宝塔API 探活] 缺少 BT_PANEL_URL 或 BT_API_KEY,已跳过")
|
||||||
|
return True
|
||||||
|
req_time = int(time.time())
|
||||||
|
sk_md5 = hashlib.md5(key.encode()).hexdigest()
|
||||||
|
req_token = hashlib.md5(("%s%s" % (req_time, sk_md5)).encode()).hexdigest()
|
||||||
|
try:
|
||||||
|
r = requests.post(
|
||||||
|
url + "/system?action=GetDiskInfo",
|
||||||
|
data={"request_time": req_time, "request_token": req_token},
|
||||||
|
timeout=20,
|
||||||
|
verify=False,
|
||||||
|
)
|
||||||
|
if r.status_code != 200:
|
||||||
|
print("[宝塔API 探活] HTTP %s" % r.status_code)
|
||||||
|
return False
|
||||||
|
ct = (r.headers.get("content-type") or "").lower()
|
||||||
|
j = {}
|
||||||
|
if "json" in ct:
|
||||||
|
try:
|
||||||
|
j = r.json()
|
||||||
|
except Exception:
|
||||||
|
j = {}
|
||||||
|
else:
|
||||||
|
t = (r.text or "").lstrip()
|
||||||
|
if t.startswith("{"):
|
||||||
|
try:
|
||||||
|
j = json.loads(r.text)
|
||||||
|
except Exception:
|
||||||
|
j = {}
|
||||||
|
if isinstance(j, dict) and j.get("status") is False:
|
||||||
|
print("[宝塔API 探活] %s" % (j.get("msg") or j))
|
||||||
|
return False
|
||||||
|
print("[宝塔API 探活] 成功(GetDiskInfo)", flush=True)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print("[宝塔API 探活] 异常: %s" % e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _bt_upload_headers():
|
||||||
|
return {
|
||||||
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||||
|
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _bt_parse_upload_response(r):
|
||||||
|
if r is None or r.status_code != 200:
|
||||||
|
return "err", None, "HTTP %s" % (r.status_code if r else "?")
|
||||||
|
raw = (r.text or "").strip()
|
||||||
|
if raw.isdigit():
|
||||||
|
return "progress", int(raw), None
|
||||||
|
j = _bt_parse_json_response(r)
|
||||||
|
if isinstance(j, dict):
|
||||||
|
if j.get("status") is True:
|
||||||
|
return "ok", None, None
|
||||||
|
return "err", None, j.get("msg") or str(j)
|
||||||
|
return "err", None, raw[:300] if raw else "empty body"
|
||||||
|
|
||||||
|
|
||||||
|
def bt_upload_file_resumable(cfg, local_path, remote_dir, remote_name):
|
||||||
|
if not requests:
|
||||||
|
print(" [失败] 需要 requests:pip install requests")
|
||||||
|
return False
|
||||||
|
url = (cfg.get("bt_panel_url") or "").rstrip("/")
|
||||||
|
key = cfg.get("bt_api_key") or ""
|
||||||
|
if not url or not key:
|
||||||
|
print(" [失败] 缺少 BT_PANEL_URL 或 BT_API_KEY")
|
||||||
|
return False
|
||||||
|
total = os.path.getsize(local_path)
|
||||||
|
chunk_mb = int(os.environ.get("BT_UPLOAD_CHUNK_MB", "4"))
|
||||||
|
chunk_size = max(1, chunk_mb) * 1024 * 1024
|
||||||
|
f_start = 0
|
||||||
|
last_start = -1
|
||||||
|
upload_url = url + "/files?action=upload"
|
||||||
|
headers = _bt_upload_headers()
|
||||||
|
print(
|
||||||
|
" [宝塔API] 上传 %s → %s/%s(共 %.2f MB,每块 %d MB)…"
|
||||||
|
% (
|
||||||
|
os.path.basename(local_path),
|
||||||
|
remote_dir,
|
||||||
|
remote_name,
|
||||||
|
total / 1024 / 1024,
|
||||||
|
chunk_mb,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with open(local_path, "rb") as fp:
|
||||||
|
while f_start < total:
|
||||||
|
if f_start == last_start:
|
||||||
|
print(" [失败] 上传停滞 offset=%s(请删服务器上同名 .upload.tmp 后重试)" % f_start)
|
||||||
|
return False
|
||||||
|
last_start = f_start
|
||||||
|
fp.seek(f_start)
|
||||||
|
buf = fp.read(min(chunk_size, total - f_start))
|
||||||
|
if not buf:
|
||||||
|
print(" [失败] 读取本地文件失败 offset=%s" % f_start)
|
||||||
|
return False
|
||||||
|
req_time = int(time.time())
|
||||||
|
sk_md5 = hashlib.md5(key.encode()).hexdigest()
|
||||||
|
req_token = hashlib.md5(("%s%s" % (req_time, sk_md5)).encode()).hexdigest()
|
||||||
|
data = {
|
||||||
|
"request_time": str(req_time),
|
||||||
|
"request_token": req_token,
|
||||||
|
"f_path": remote_dir,
|
||||||
|
"f_name": remote_name,
|
||||||
|
"f_size": str(total),
|
||||||
|
"f_start": str(f_start),
|
||||||
|
}
|
||||||
|
files = {"blob": ("blob", buf, "application/octet-stream")}
|
||||||
|
r = requests.post(
|
||||||
|
upload_url,
|
||||||
|
data=data,
|
||||||
|
files=files,
|
||||||
|
headers=headers,
|
||||||
|
timeout=600,
|
||||||
|
verify=False,
|
||||||
|
)
|
||||||
|
kind, prog, err = _bt_parse_upload_response(r)
|
||||||
|
if kind == "ok":
|
||||||
|
print(" [成功] 宝塔上传完成")
|
||||||
|
return True
|
||||||
|
if kind == "progress":
|
||||||
|
if prog <= f_start:
|
||||||
|
print(" [失败] 上传偏移未前进: server=%s local=%s" % (prog, f_start))
|
||||||
|
return False
|
||||||
|
f_start = prog
|
||||||
|
continue
|
||||||
|
print(" [失败] 上传接口: %s" % (err or kind))
|
||||||
|
return False
|
||||||
|
print(" [失败] 上传未正常结束(offset=%s size=%s)" % (f_start, total))
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
print(" [失败] 上传异常: %s" % e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def bt_delete_file(cfg, remote_path):
|
||||||
|
url = (cfg.get("bt_panel_url") or "").rstrip("/")
|
||||||
|
key = cfg.get("bt_api_key") or ""
|
||||||
|
if not url or not key or not remote_path:
|
||||||
|
return False
|
||||||
|
j = _bt_parse_json_response(
|
||||||
|
_bt_signed_post(url, key, "/files?action=DeleteFile", {"path": remote_path})
|
||||||
|
)
|
||||||
|
if isinstance(j, dict) and j.get("status") is True:
|
||||||
|
return True
|
||||||
|
if isinstance(j, dict) and j.get("msg"):
|
||||||
|
print(" [宝塔API] DeleteFile: %s" % j.get("msg"))
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def bt_delete_dir(cfg, remote_dir):
|
||||||
|
"""递归删除目录(不存在时可能返回失败,调用方可忽略)。"""
|
||||||
|
url = (cfg.get("bt_panel_url") or "").rstrip("/")
|
||||||
|
key = cfg.get("bt_api_key") or ""
|
||||||
|
if not url or not key or not remote_dir:
|
||||||
|
return False
|
||||||
|
j = _bt_parse_json_response(
|
||||||
|
_bt_signed_post(
|
||||||
|
url,
|
||||||
|
key,
|
||||||
|
"/files?action=DeleteDir",
|
||||||
|
{"path": remote_dir},
|
||||||
|
timeout=120,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if isinstance(j, dict) and j.get("status") is True:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def bt_create_dir(cfg, path):
|
||||||
|
url = (cfg.get("bt_panel_url") or "").rstrip("/")
|
||||||
|
key = cfg.get("bt_api_key") or ""
|
||||||
|
if not url or not key or not path:
|
||||||
|
return False
|
||||||
|
j = _bt_parse_json_response(
|
||||||
|
_bt_signed_post(url, key, "/files?action=CreateDir", {"path": path})
|
||||||
|
)
|
||||||
|
if isinstance(j, dict) and j.get("status") is True:
|
||||||
|
return True
|
||||||
|
msg = (j.get("msg") if isinstance(j, dict) else "") or ""
|
||||||
|
if msg and ("已存在" in msg or "exist" in msg.lower()):
|
||||||
|
return True
|
||||||
|
if isinstance(j, dict) and j.get("msg"):
|
||||||
|
print(" [宝塔API] CreateDir: %s" % j.get("msg"))
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def bt_mv_file(cfg, sfile, dfile):
|
||||||
|
url = (cfg.get("bt_panel_url") or "").rstrip("/")
|
||||||
|
key = cfg.get("bt_api_key") or ""
|
||||||
|
if not url or not key:
|
||||||
|
return False, "no url/key"
|
||||||
|
j = _bt_parse_json_response(
|
||||||
|
_bt_signed_post(
|
||||||
|
url,
|
||||||
|
key,
|
||||||
|
"/files?action=MvFile",
|
||||||
|
{"sfile": sfile, "dfile": dfile},
|
||||||
|
timeout=120,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if isinstance(j, dict) and j.get("status") is True:
|
||||||
|
return True, None
|
||||||
|
msg = j.get("msg") if isinstance(j, dict) else str(j)
|
||||||
|
return False, msg
|
||||||
|
|
||||||
|
|
||||||
|
def bt_unzip_remote_zip(cfg, sfile, dfile):
|
||||||
|
url = (cfg.get("bt_panel_url") or "").rstrip("/")
|
||||||
|
key = cfg.get("bt_api_key") or ""
|
||||||
|
coding = "UTF-8"
|
||||||
|
for type1 in ("zip", "ZIP"):
|
||||||
|
j = _bt_parse_json_response(
|
||||||
|
_bt_signed_post(
|
||||||
|
url,
|
||||||
|
key,
|
||||||
|
"/files?action=UnZip",
|
||||||
|
{"sfile": sfile, "dfile": dfile, "type1": type1, "coding": coding},
|
||||||
|
timeout=300,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if isinstance(j, dict) and j.get("status") is True:
|
||||||
|
print(" [成功] 宝塔解压完成 (type1=%s)" % type1)
|
||||||
|
return True
|
||||||
|
if isinstance(j, dict) and j.get("msg"):
|
||||||
|
print(" [宝塔API] UnZip type1=%s: %s" % (type1, j.get("msg")))
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def deploy_via_btapi(cfg, zip_path):
|
||||||
|
print("[3/5] 宝塔 API:准备 dist2、上传、解压 …")
|
||||||
|
if not requests:
|
||||||
|
print(" [失败] 需要 requests:pip install requests")
|
||||||
|
return False
|
||||||
|
|
||||||
|
base = cfg["base_path"].rstrip("/")
|
||||||
|
dist = cfg["dist_path"].rstrip("/")
|
||||||
|
dist2 = cfg["dist2_path"].rstrip("/")
|
||||||
|
dist1 = cfg["dist1_path"].rstrip("/")
|
||||||
|
remote_zip_name = "ckb_operation_deploy.zip"
|
||||||
|
remote_zip = base + "/" + remote_zip_name
|
||||||
|
dfile_dist2 = dist2 + "/"
|
||||||
|
|
||||||
|
bt_delete_dir(cfg, dist1)
|
||||||
|
bt_delete_dir(cfg, dist2)
|
||||||
|
if not bt_create_dir(cfg, dist2):
|
||||||
|
print(" [失败] 无法创建 dist2: %s" % dist2)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not bt_upload_file_resumable(cfg, zip_path, base, remote_zip_name):
|
||||||
|
return False
|
||||||
|
|
||||||
|
print(" [宝塔API] 解压 %s → %s …" % (remote_zip, dfile_dist2))
|
||||||
|
if not bt_unzip_remote_zip(cfg, remote_zip, dfile_dist2):
|
||||||
|
print(" [提示] 解压失败时可检查面板「文件」权限与压缩包格式")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if bt_delete_file(cfg, remote_zip):
|
||||||
|
print(" [已清理] 远程 %s" % remote_zip)
|
||||||
|
else:
|
||||||
|
print(" [提示] 未能删除远程压缩包,可在面板手动删: %s" % remote_zip)
|
||||||
|
|
||||||
|
print("[4/5] 宝塔 API:目录切换(等价原 dist→dist1→dist2→dist)…")
|
||||||
|
bt_delete_dir(cfg, dist1)
|
||||||
|
|
||||||
|
ok_move_old, err_old = bt_mv_file(cfg, dist, dist1)
|
||||||
|
if not ok_move_old:
|
||||||
|
if err_old and isinstance(err_old, str):
|
||||||
|
low = err_old.lower()
|
||||||
|
if "不存在" not in err_old and "not exist" not in low and "不存在" not in low:
|
||||||
|
print(" [宝塔API] 移动 dist→dist1: %s" % err_old)
|
||||||
|
|
||||||
|
ok_swap, err_swap = bt_mv_file(cfg, dist2, dist)
|
||||||
|
if not ok_swap:
|
||||||
|
print(" [失败] 将 dist2 切换为 dist 失败: %s" % err_swap)
|
||||||
|
print(" [提示] 可到面板「文件」手动把 %s 改名为 dist" % dist2)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if ok_move_old:
|
||||||
|
if not bt_delete_dir(cfg, dist1):
|
||||||
|
print(" [警告] 未能删除临时目录 dist1: %s(可面板手动删)" % dist1)
|
||||||
|
|
||||||
|
print(" [成功] 新版本已生效: %s" % dist)
|
||||||
|
print("[5/5] 完成(纯 API;若 403 请在面板核对站点目录属主 www)")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def create_parser(default_profile):
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="CKB 静态站点部署(宝塔 API:上传 zip、解压、目录切换)",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--profile",
|
||||||
|
choices=tuple(PROFILE_PRESETS.keys()),
|
||||||
|
default=default_profile,
|
||||||
|
help="部署档位:prod=正式环境,dev=测试环境",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--base-path",
|
||||||
|
default="",
|
||||||
|
help="覆盖部署目录(默认按 profile 取 DEPLOY_BASE_PATH 或预设路径)",
|
||||||
|
)
|
||||||
|
parser.add_argument("--no-build", action="store_true", help="跳过本地构建,直接上传现有 dist")
|
||||||
|
parser.add_argument(
|
||||||
|
"--skip-bt-ping",
|
||||||
|
action="store_true",
|
||||||
|
help="跳过宝塔面板 API 探活(GetDiskInfo)",
|
||||||
|
)
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None, default_profile="prod"):
|
||||||
|
default_profile = resolve_profile(default_profile)
|
||||||
|
parser = create_parser(default_profile=default_profile)
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
profile = resolve_profile(args.profile)
|
||||||
|
root = resolve_project_root()
|
||||||
|
cfg = get_cfg(profile=profile, cli_base_path=(args.base_path or "").strip())
|
||||||
|
|
||||||
|
print("=" * 60)
|
||||||
|
print(" %s 部署(宝塔 API)" % cfg["title"])
|
||||||
|
print("=" * 60)
|
||||||
|
print(" profile: %s" % cfg["profile"])
|
||||||
|
print(" 面板: %s" % cfg["bt_panel_url"])
|
||||||
|
print(" 站点根: %s" % cfg["base_path"])
|
||||||
|
print(" 静态目录: %s" % cfg["dist_path"])
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
if not args.skip_bt_ping:
|
||||||
|
if not bt_panel_ping(cfg):
|
||||||
|
print("[失败] 宝塔 API 探活未通过(可用 --skip-bt-ping 跳过)")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if not args.no_build:
|
||||||
|
if not run_build(root, cfg):
|
||||||
|
return 1
|
||||||
|
else:
|
||||||
|
if not ensure_dist_ready(root):
|
||||||
|
print("[错误] 未找到 dist/index.html,请先执行构建或去掉 --no-build")
|
||||||
|
return 1
|
||||||
|
print("[1/5] 跳过本地构建")
|
||||||
|
|
||||||
|
zip_path = pack_dist_zip(root, profile=profile)
|
||||||
|
if not zip_path:
|
||||||
|
return 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
if not deploy_via_btapi(cfg, zip_path):
|
||||||
|
return 1
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
if zip_path and os.path.isfile(zip_path):
|
||||||
|
os.remove(zip_path)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
print("")
|
||||||
|
print(" 部署完成!站点目录: %s" % cfg["dist_path"])
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
"antd-mobile": "^5.39.1",
|
"antd-mobile": "^5.39.1",
|
||||||
"antd-mobile-icons": "^0.3.0",
|
"antd-mobile-icons": "^0.3.0",
|
||||||
"axios": "^1.6.7",
|
"axios": "^1.6.7",
|
||||||
|
"crypto-js": "^4.2.0",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"echarts": "^5.6.0",
|
"echarts": "^5.6.0",
|
||||||
"echarts-for-react": "^3.0.2",
|
"echarts-for-react": "^3.0.2",
|
||||||
@@ -21,6 +22,7 @@
|
|||||||
"zustand": "^5.0.6"
|
"zustand": "^5.0.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/crypto-js": "^4.2.2",
|
||||||
"@types/node": "^24.0.14",
|
"@types/node": "^24.0.14",
|
||||||
"@types/react": "^19.1.8",
|
"@types/react": "^19.1.8",
|
||||||
"@types/react-dom": "^19.1.6",
|
"@types/react-dom": "^19.1.6",
|
||||||
|
|||||||
33
pnpm-lock.yaml
generated
33
pnpm-lock.yaml
generated
@@ -23,6 +23,9 @@ importers:
|
|||||||
axios:
|
axios:
|
||||||
specifier: ^1.6.7
|
specifier: ^1.6.7
|
||||||
version: 1.11.0
|
version: 1.11.0
|
||||||
|
crypto-js:
|
||||||
|
specifier: ^4.2.0
|
||||||
|
version: 4.2.0
|
||||||
dayjs:
|
dayjs:
|
||||||
specifier: ^1.11.13
|
specifier: ^1.11.13
|
||||||
version: 1.11.13
|
version: 1.11.13
|
||||||
@@ -54,6 +57,9 @@ importers:
|
|||||||
specifier: ^5.0.6
|
specifier: ^5.0.6
|
||||||
version: 5.0.7(@types/react@19.1.10)(react@18.3.1)(use-sync-external-store@1.5.0(react@18.3.1))
|
version: 5.0.7(@types/react@19.1.10)(react@18.3.1)(use-sync-external-store@1.5.0(react@18.3.1))
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@types/crypto-js':
|
||||||
|
specifier: ^4.2.2
|
||||||
|
version: 4.2.2
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^24.0.14
|
specifier: ^24.0.14
|
||||||
version: 24.2.1
|
version: 24.2.1
|
||||||
@@ -489,36 +495,42 @@ packages:
|
|||||||
engines: {node: '>= 10.0.0'}
|
engines: {node: '>= 10.0.0'}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@parcel/watcher-linux-arm-musl@2.5.1':
|
'@parcel/watcher-linux-arm-musl@2.5.1':
|
||||||
resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==}
|
resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==}
|
||||||
engines: {node: '>= 10.0.0'}
|
engines: {node: '>= 10.0.0'}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@parcel/watcher-linux-arm64-glibc@2.5.1':
|
'@parcel/watcher-linux-arm64-glibc@2.5.1':
|
||||||
resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==}
|
resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==}
|
||||||
engines: {node: '>= 10.0.0'}
|
engines: {node: '>= 10.0.0'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@parcel/watcher-linux-arm64-musl@2.5.1':
|
'@parcel/watcher-linux-arm64-musl@2.5.1':
|
||||||
resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==}
|
resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==}
|
||||||
engines: {node: '>= 10.0.0'}
|
engines: {node: '>= 10.0.0'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@parcel/watcher-linux-x64-glibc@2.5.1':
|
'@parcel/watcher-linux-x64-glibc@2.5.1':
|
||||||
resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==}
|
resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==}
|
||||||
engines: {node: '>= 10.0.0'}
|
engines: {node: '>= 10.0.0'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@parcel/watcher-linux-x64-musl@2.5.1':
|
'@parcel/watcher-linux-x64-musl@2.5.1':
|
||||||
resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==}
|
resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==}
|
||||||
engines: {node: '>= 10.0.0'}
|
engines: {node: '>= 10.0.0'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@parcel/watcher-win32-arm64@2.5.1':
|
'@parcel/watcher-win32-arm64@2.5.1':
|
||||||
resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==}
|
resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==}
|
||||||
@@ -669,56 +681,67 @@ packages:
|
|||||||
resolution: {integrity: sha512-EtP8aquZ0xQg0ETFcxUbU71MZlHaw9MChwrQzatiE8U/bvi5uv/oChExXC4mWhjiqK7azGJBqU0tt5H123SzVA==}
|
resolution: {integrity: sha512-EtP8aquZ0xQg0ETFcxUbU71MZlHaw9MChwrQzatiE8U/bvi5uv/oChExXC4mWhjiqK7azGJBqU0tt5H123SzVA==}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm-musleabihf@4.46.2':
|
'@rollup/rollup-linux-arm-musleabihf@4.46.2':
|
||||||
resolution: {integrity: sha512-qO7F7U3u1nfxYRPM8HqFtLd+raev2K137dsV08q/LRKRLEc7RsiDWihUnrINdsWQxPR9jqZ8DIIZ1zJJAm5PjQ==}
|
resolution: {integrity: sha512-qO7F7U3u1nfxYRPM8HqFtLd+raev2K137dsV08q/LRKRLEc7RsiDWihUnrINdsWQxPR9jqZ8DIIZ1zJJAm5PjQ==}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm64-gnu@4.46.2':
|
'@rollup/rollup-linux-arm64-gnu@4.46.2':
|
||||||
resolution: {integrity: sha512-3dRaqLfcOXYsfvw5xMrxAk9Lb1f395gkoBYzSFcc/scgRFptRXL9DOaDpMiehf9CO8ZDRJW2z45b6fpU5nwjng==}
|
resolution: {integrity: sha512-3dRaqLfcOXYsfvw5xMrxAk9Lb1f395gkoBYzSFcc/scgRFptRXL9DOaDpMiehf9CO8ZDRJW2z45b6fpU5nwjng==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm64-musl@4.46.2':
|
'@rollup/rollup-linux-arm64-musl@4.46.2':
|
||||||
resolution: {integrity: sha512-fhHFTutA7SM+IrR6lIfiHskxmpmPTJUXpWIsBXpeEwNgZzZZSg/q4i6FU4J8qOGyJ0TR+wXBwx/L7Ho9z0+uDg==}
|
resolution: {integrity: sha512-fhHFTutA7SM+IrR6lIfiHskxmpmPTJUXpWIsBXpeEwNgZzZZSg/q4i6FU4J8qOGyJ0TR+wXBwx/L7Ho9z0+uDg==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rollup/rollup-linux-loongarch64-gnu@4.46.2':
|
'@rollup/rollup-linux-loongarch64-gnu@4.46.2':
|
||||||
resolution: {integrity: sha512-i7wfGFXu8x4+FRqPymzjD+Hyav8l95UIZ773j7J7zRYc3Xsxy2wIn4x+llpunexXe6laaO72iEjeeGyUFmjKeA==}
|
resolution: {integrity: sha512-i7wfGFXu8x4+FRqPymzjD+Hyav8l95UIZ773j7J7zRYc3Xsxy2wIn4x+llpunexXe6laaO72iEjeeGyUFmjKeA==}
|
||||||
cpu: [loong64]
|
cpu: [loong64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-ppc64-gnu@4.46.2':
|
'@rollup/rollup-linux-ppc64-gnu@4.46.2':
|
||||||
resolution: {integrity: sha512-B/l0dFcHVUnqcGZWKcWBSV2PF01YUt0Rvlurci5P+neqY/yMKchGU8ullZvIv5e8Y1C6wOn+U03mrDylP5q9Yw==}
|
resolution: {integrity: sha512-B/l0dFcHVUnqcGZWKcWBSV2PF01YUt0Rvlurci5P+neqY/yMKchGU8ullZvIv5e8Y1C6wOn+U03mrDylP5q9Yw==}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-riscv64-gnu@4.46.2':
|
'@rollup/rollup-linux-riscv64-gnu@4.46.2':
|
||||||
resolution: {integrity: sha512-32k4ENb5ygtkMwPMucAb8MtV8olkPT03oiTxJbgkJa7lJ7dZMr0GCFJlyvy+K8iq7F/iuOr41ZdUHaOiqyR3iQ==}
|
resolution: {integrity: sha512-32k4ENb5ygtkMwPMucAb8MtV8olkPT03oiTxJbgkJa7lJ7dZMr0GCFJlyvy+K8iq7F/iuOr41ZdUHaOiqyR3iQ==}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-riscv64-musl@4.46.2':
|
'@rollup/rollup-linux-riscv64-musl@4.46.2':
|
||||||
resolution: {integrity: sha512-t5B2loThlFEauloaQkZg9gxV05BYeITLvLkWOkRXogP4qHXLkWSbSHKM9S6H1schf/0YGP/qNKtiISlxvfmmZw==}
|
resolution: {integrity: sha512-t5B2loThlFEauloaQkZg9gxV05BYeITLvLkWOkRXogP4qHXLkWSbSHKM9S6H1schf/0YGP/qNKtiISlxvfmmZw==}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rollup/rollup-linux-s390x-gnu@4.46.2':
|
'@rollup/rollup-linux-s390x-gnu@4.46.2':
|
||||||
resolution: {integrity: sha512-YKjekwTEKgbB7n17gmODSmJVUIvj8CX7q5442/CK80L8nqOUbMtf8b01QkG3jOqyr1rotrAnW6B/qiHwfcuWQA==}
|
resolution: {integrity: sha512-YKjekwTEKgbB7n17gmODSmJVUIvj8CX7q5442/CK80L8nqOUbMtf8b01QkG3jOqyr1rotrAnW6B/qiHwfcuWQA==}
|
||||||
cpu: [s390x]
|
cpu: [s390x]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-x64-gnu@4.46.2':
|
'@rollup/rollup-linux-x64-gnu@4.46.2':
|
||||||
resolution: {integrity: sha512-Jj5a9RUoe5ra+MEyERkDKLwTXVu6s3aACP51nkfnK9wJTraCC8IMe3snOfALkrjTYd2G1ViE1hICj0fZ7ALBPA==}
|
resolution: {integrity: sha512-Jj5a9RUoe5ra+MEyERkDKLwTXVu6s3aACP51nkfnK9wJTraCC8IMe3snOfALkrjTYd2G1ViE1hICj0fZ7ALBPA==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-x64-musl@4.46.2':
|
'@rollup/rollup-linux-x64-musl@4.46.2':
|
||||||
resolution: {integrity: sha512-7kX69DIrBeD7yNp4A5b81izs8BqoZkCIaxQaOpumcJ1S/kmqNFjPhDu1LHeVXv0SexfHQv5cqHsxLOjETuqDuA==}
|
resolution: {integrity: sha512-7kX69DIrBeD7yNp4A5b81izs8BqoZkCIaxQaOpumcJ1S/kmqNFjPhDu1LHeVXv0SexfHQv5cqHsxLOjETuqDuA==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rollup/rollup-win32-arm64-msvc@4.46.2':
|
'@rollup/rollup-win32-arm64-msvc@4.46.2':
|
||||||
resolution: {integrity: sha512-wiJWMIpeaak/jsbaq2HMh/rzZxHVW1rU6coyeNNpMwk5isiPjSTx0a4YLSlYDwBH/WBvLz+EtsNqQScZTLJy3g==}
|
resolution: {integrity: sha512-wiJWMIpeaak/jsbaq2HMh/rzZxHVW1rU6coyeNNpMwk5isiPjSTx0a4YLSlYDwBH/WBvLz+EtsNqQScZTLJy3g==}
|
||||||
@@ -747,6 +770,9 @@ packages:
|
|||||||
'@types/babel__traverse@7.28.0':
|
'@types/babel__traverse@7.28.0':
|
||||||
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
|
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
|
||||||
|
|
||||||
|
'@types/crypto-js@4.2.2':
|
||||||
|
resolution: {integrity: sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==}
|
||||||
|
|
||||||
'@types/estree@1.0.8':
|
'@types/estree@1.0.8':
|
||||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||||
|
|
||||||
@@ -1016,6 +1042,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
|
|
||||||
|
crypto-js@4.2.0:
|
||||||
|
resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==}
|
||||||
|
|
||||||
csstype@3.1.3:
|
csstype@3.1.3:
|
||||||
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
|
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
|
||||||
|
|
||||||
@@ -2958,6 +2987,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@babel/types': 7.28.2
|
'@babel/types': 7.28.2
|
||||||
|
|
||||||
|
'@types/crypto-js@4.2.2': {}
|
||||||
|
|
||||||
'@types/estree@1.0.8': {}
|
'@types/estree@1.0.8': {}
|
||||||
|
|
||||||
'@types/node@24.2.1':
|
'@types/node@24.2.1':
|
||||||
@@ -3357,6 +3388,8 @@ snapshots:
|
|||||||
shebang-command: 2.0.0
|
shebang-command: 2.0.0
|
||||||
which: 2.0.2
|
which: 2.0.2
|
||||||
|
|
||||||
|
crypto-js@4.2.0: {}
|
||||||
|
|
||||||
csstype@3.1.3: {}
|
csstype@3.1.3: {}
|
||||||
|
|
||||||
data-view-buffer@1.0.2:
|
data-view-buffer@1.0.2:
|
||||||
|
|||||||
@@ -82,10 +82,15 @@ export default function GroupSelection({
|
|||||||
{selectedOptions.map(group => (
|
{selectedOptions.map(group => (
|
||||||
<div key={group.id} className={style.selectedListRow}>
|
<div key={group.id} className={style.selectedListRow}>
|
||||||
<div className={style.selectedListRowContent}>
|
<div className={style.selectedListRowContent}>
|
||||||
<Avatar src={group.avatar} />
|
<Avatar src={group.groupAvatar || group.avatar} />
|
||||||
<div className={style.selectedListRowContentText}>
|
<div className={style.selectedListRowContentText}>
|
||||||
<div>{group.name}</div>
|
<div>{group.groupName || group.name}</div>
|
||||||
|
{group.nickName && (
|
||||||
|
<div style={{ fontSize: 12, color: "#666" }}>归属:{group.nickName}</div>
|
||||||
|
)}
|
||||||
|
{!group.nickName && group.chatroomId && (
|
||||||
<div>{group.chatroomId}</div>
|
<div>{group.chatroomId}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!readonly && (
|
{!readonly && (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -2,24 +2,37 @@ import request from "@/api/request";
|
|||||||
|
|
||||||
// 请求参数接口
|
// 请求参数接口
|
||||||
export interface Request {
|
export interface Request {
|
||||||
keyword: string;
|
keyword?: string;
|
||||||
/**
|
/**
|
||||||
* 条数
|
* 条数
|
||||||
*/
|
*/
|
||||||
limit: string;
|
limit?: string;
|
||||||
|
pageSize?: string;
|
||||||
/**
|
/**
|
||||||
* 分页
|
* 分页
|
||||||
*/
|
*/
|
||||||
page: string;
|
page?: string;
|
||||||
[property: string]: any;
|
[property: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取流量池包列表
|
// ===== V2 API =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取流量池分组列表 (V2)
|
||||||
|
* 用于获取流量池包/分组列表
|
||||||
|
*/
|
||||||
export function getPoolPackages(params: Request) {
|
export function getPoolPackages(params: Request) {
|
||||||
return request("/v1/traffic/pool/getPackage", params, "GET");
|
const v2Params = {
|
||||||
|
page: params.page ? Number(params.page) : 1,
|
||||||
|
pageSize: params.limit ? Number(params.limit) : (params.pageSize ? Number(params.pageSize) : 20),
|
||||||
|
keyword: params.keyword || "",
|
||||||
|
};
|
||||||
|
return request("/v1/traffic/pool/v2/groups", v2Params, "GET");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保留原接口以兼容现有代码
|
/**
|
||||||
|
* 获取流量池用户列表 (V2)
|
||||||
|
*/
|
||||||
export function getPoolList(params: {
|
export function getPoolList(params: {
|
||||||
page?: string;
|
page?: string;
|
||||||
pageSize?: string;
|
pageSize?: string;
|
||||||
@@ -27,8 +40,46 @@ export function getPoolList(params: {
|
|||||||
addStatus?: string;
|
addStatus?: string;
|
||||||
deviceId?: string;
|
deviceId?: string;
|
||||||
packageId?: string;
|
packageId?: string;
|
||||||
|
groupId?: string;
|
||||||
userValue?: string;
|
userValue?: string;
|
||||||
[property: string]: any;
|
[property: string]: any;
|
||||||
}) {
|
}) {
|
||||||
return request("/v1/traffic/pool", params, "GET");
|
const v2Params = {
|
||||||
|
page: params.page ? Number(params.page) : 1,
|
||||||
|
pageSize: params.pageSize ? Number(params.pageSize) : 20,
|
||||||
|
keyword: params.keyword || "",
|
||||||
|
groupId: params.groupId || params.packageId || "",
|
||||||
|
};
|
||||||
|
return request("/v1/traffic/pool/v2/group/members", v2Params, "GET");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除流量池分组 (V2)
|
||||||
|
*/
|
||||||
|
export function deletePackage(id: number | string) {
|
||||||
|
return request("/v1/traffic/pool/v2/group/delete", { groupId: id }, "DELETE");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建流量池分组 (V2)
|
||||||
|
*/
|
||||||
|
export function createPackage(params: {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
ruleType?: number;
|
||||||
|
ruleConfig?: any;
|
||||||
|
}) {
|
||||||
|
return request("/v1/traffic/pool/v2/group/create", {
|
||||||
|
groupName: params.name,
|
||||||
|
description: params.description || "",
|
||||||
|
ruleType: params.ruleType || 0,
|
||||||
|
ruleConfig: params.ruleConfig || null,
|
||||||
|
}, "POST");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取流量池分组详情 (V2)
|
||||||
|
*/
|
||||||
|
export function getPackageDetail(groupId: number | string) {
|
||||||
|
return request("/v1/traffic/pool/v2/group/detail", { groupId }, "GET");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export default function SelectionPopup({
|
|||||||
PoolSelectionItem[]
|
PoolSelectionItem[]
|
||||||
>([]);
|
>([]);
|
||||||
|
|
||||||
// 获取流量池包列表API
|
// 获取流量池分组列表API (V2)
|
||||||
const fetchPoolPackages = async (page: number, keyword: string = "") => {
|
const fetchPoolPackages = async (page: number, keyword: string = "") => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -52,13 +52,44 @@ export default function SelectionPopup({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const response = await getPoolPackages(params);
|
const response = await getPoolPackages(params);
|
||||||
if (response && response.list) {
|
console.log("getPoolPackages response:", response);
|
||||||
setPoolPackages(response.list);
|
// request 函数会自动提取 data 字段,所以 response 应该是数组
|
||||||
setTotalItems(response.total || 0);
|
// 但为了兼容,也处理可能的包装格式
|
||||||
setTotalPages(Math.ceil((response.total || 0) / 20));
|
let groupsList: any[] = [];
|
||||||
|
if (Array.isArray(response)) {
|
||||||
|
groupsList = response;
|
||||||
|
} else if (response?.data && Array.isArray(response.data)) {
|
||||||
|
groupsList = response.data;
|
||||||
|
} else if (response?.list && Array.isArray(response.list)) {
|
||||||
|
groupsList = response.list;
|
||||||
|
}
|
||||||
|
console.log("groupsList:", groupsList);
|
||||||
|
|
||||||
|
if (groupsList.length > 0) {
|
||||||
|
// 适配 V2 API 返回格式
|
||||||
|
const formattedList = groupsList.map((item: any) => ({
|
||||||
|
id: item.id,
|
||||||
|
name: item.groupName || item.name || `分组${item.id}`,
|
||||||
|
description: item.description || "",
|
||||||
|
createTime: item.createTime
|
||||||
|
? (typeof item.createTime === 'number'
|
||||||
|
? new Date(item.createTime * 1000).toLocaleString('zh-CN')
|
||||||
|
: String(item.createTime).split(' ')[0])
|
||||||
|
: "",
|
||||||
|
num: item.memberCount || item.num || 0,
|
||||||
|
isSystem: item.isSystem,
|
||||||
|
ruleType: item.ruleType,
|
||||||
|
}));
|
||||||
|
setPoolPackages(formattedList);
|
||||||
|
setTotalItems(formattedList.length);
|
||||||
|
setTotalPages(Math.ceil(formattedList.length / 20));
|
||||||
|
} else {
|
||||||
|
setPoolPackages([]);
|
||||||
|
setTotalItems(0);
|
||||||
|
setTotalPages(1);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("获取流量池包列表失败:", error);
|
console.error("获取流量池分组列表失败:", error);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
38
src/components/StepIndicator/index.module.scss
Normal file
38
src/components/StepIndicator/index.module.scss
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
.container {
|
||||||
|
padding: 20px 30px 12px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steps {
|
||||||
|
--adm-color-primary: #007aff;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.adm-steps-item-title) {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #9ca3af;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.adm-steps-item-active .adm-steps-item-title) {
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.adm-steps-item-icon) {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 24px;
|
||||||
|
border-width: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.adm-steps-item-active .adm-steps-item-icon) {
|
||||||
|
background-color: #007aff;
|
||||||
|
border-color: #007aff;
|
||||||
|
box-shadow: 0 0 0 4px rgba(0, 122, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.adm-steps-item-finish .adm-steps-item-icon) {
|
||||||
|
background-color: #007aff;
|
||||||
|
border-color: #007aff;
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Steps } from "antd-mobile";
|
import { Steps } from "antd-mobile";
|
||||||
|
import styles from "./index.module.scss";
|
||||||
|
|
||||||
interface StepIndicatorProps {
|
interface StepIndicatorProps {
|
||||||
currentStep: number;
|
currentStep: number;
|
||||||
@@ -11,28 +12,13 @@ const StepIndicator: React.FC<StepIndicatorProps> = ({
|
|||||||
steps,
|
steps,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div style={{ overflowX: "auto", padding: "30px 0px", background: "#fff" }}>
|
<div className={styles.container}>
|
||||||
<Steps current={currentStep - 1}>
|
<Steps current={currentStep - 1} className={styles.steps}>
|
||||||
{steps.map((step, idx) => (
|
{steps.map((step) => (
|
||||||
<Steps.Step
|
<Steps.Step
|
||||||
key={step.id}
|
key={step.id}
|
||||||
title={step.subtitle}
|
title={step.subtitle}
|
||||||
icon={
|
className={styles.step}
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 24,
|
|
||||||
height: 24,
|
|
||||||
borderRadius: 12,
|
|
||||||
backgroundColor: idx < currentStep ? "#1677ff" : "#cccccc",
|
|
||||||
color: "#fff",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{step.id}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Steps>
|
</Steps>
|
||||||
|
|||||||
590
src/pages/mobile/mine/traffic-pool/api.ts
Normal file
590
src/pages/mobile/mine/traffic-pool/api.ts
Normal file
@@ -0,0 +1,590 @@
|
|||||||
|
import request from "@/api/request";
|
||||||
|
|
||||||
|
// ==================== 类型定义 ====================
|
||||||
|
|
||||||
|
// RFM 评分
|
||||||
|
export interface RfmScore {
|
||||||
|
R: number;
|
||||||
|
F: number;
|
||||||
|
M: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分组规则条件
|
||||||
|
export interface RuleCondition {
|
||||||
|
type: "field" | "group" | "tag";
|
||||||
|
field?: string;
|
||||||
|
operator?: string;
|
||||||
|
value?: any;
|
||||||
|
valueType?: string;
|
||||||
|
logic?: "AND" | "OR";
|
||||||
|
conditions?: RuleCondition[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分组规则配置
|
||||||
|
export interface RuleConfig {
|
||||||
|
logic: "AND" | "OR";
|
||||||
|
conditions: RuleCondition[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 流量池分组
|
||||||
|
export interface TrafficPoolGroup {
|
||||||
|
id: number;
|
||||||
|
companyId: number;
|
||||||
|
groupCode: string;
|
||||||
|
groupName: string;
|
||||||
|
groupIcon: string | null;
|
||||||
|
groupColor: string | null;
|
||||||
|
description: string | null;
|
||||||
|
isSystem: number;
|
||||||
|
isDefault: number;
|
||||||
|
ruleType: number; // 1=动态规则,2=手动添加
|
||||||
|
ruleConfig: RuleConfig | null;
|
||||||
|
memberCount: number;
|
||||||
|
sort: number;
|
||||||
|
status: number;
|
||||||
|
createTime: number;
|
||||||
|
// RFM 统计
|
||||||
|
avgRfmR?: number;
|
||||||
|
avgRfmF?: number;
|
||||||
|
avgRfmM?: number;
|
||||||
|
avgRfmScore?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 流量池成员
|
||||||
|
export interface TrafficPoolMember {
|
||||||
|
id: number;
|
||||||
|
poolId: number;
|
||||||
|
identifier: string;
|
||||||
|
companyId: number;
|
||||||
|
friendStatus: number;
|
||||||
|
level: number;
|
||||||
|
intentionLevel: number;
|
||||||
|
lifecycle: number;
|
||||||
|
lastInteractTime: number | null;
|
||||||
|
rfmR: number;
|
||||||
|
rfmF: number;
|
||||||
|
rfmM: number;
|
||||||
|
rfmScore: RfmScore;
|
||||||
|
totalMsgCount: number;
|
||||||
|
totalOrderCount: number;
|
||||||
|
totalOrderAmount: number;
|
||||||
|
ownerWechatId: string | null;
|
||||||
|
allocateStatus: number;
|
||||||
|
realName: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
createTime: number;
|
||||||
|
// 关联的总表信息
|
||||||
|
nickname: string | null;
|
||||||
|
avatar: string | null;
|
||||||
|
gender: number;
|
||||||
|
wechatId: string | null;
|
||||||
|
wechatAlias: string | null;
|
||||||
|
mobile: string | null;
|
||||||
|
region: string | null;
|
||||||
|
// 标签
|
||||||
|
tags: TrafficPoolTag[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 流量池标签
|
||||||
|
export interface TrafficPoolTag {
|
||||||
|
id: number;
|
||||||
|
tagDefineId: number;
|
||||||
|
tagName: string;
|
||||||
|
tagType: number;
|
||||||
|
tagValue: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标签类目
|
||||||
|
export interface TagCategory {
|
||||||
|
id: number;
|
||||||
|
companyId: number;
|
||||||
|
parentId: number;
|
||||||
|
tagType: number;
|
||||||
|
categoryCode: string;
|
||||||
|
categoryName: string;
|
||||||
|
categoryIcon: string | null;
|
||||||
|
categoryColor: string | null;
|
||||||
|
description: string | null;
|
||||||
|
isSystem: number;
|
||||||
|
children?: TagCategory[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标签定义
|
||||||
|
export interface TagDefine {
|
||||||
|
id: number;
|
||||||
|
companyId: number;
|
||||||
|
categoryId: number;
|
||||||
|
tagType: number;
|
||||||
|
tagCode: string;
|
||||||
|
tagName: string;
|
||||||
|
tagIcon: string | null;
|
||||||
|
tagColor: string | null;
|
||||||
|
description: string | null;
|
||||||
|
isSystem: number;
|
||||||
|
isExclusive: number;
|
||||||
|
useCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 流量来源
|
||||||
|
export interface TrafficSource {
|
||||||
|
id: number;
|
||||||
|
poolCompanyId: number;
|
||||||
|
sourceType: number;
|
||||||
|
sourceName: string | null;
|
||||||
|
isFirstSource: number;
|
||||||
|
createTime: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 流量行为
|
||||||
|
export interface TrafficBehavior {
|
||||||
|
id: number;
|
||||||
|
poolCompanyId: number;
|
||||||
|
behaviorType: number;
|
||||||
|
behaviorName: string | null;
|
||||||
|
targetName: string | null;
|
||||||
|
amount: number;
|
||||||
|
behaviorTime: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分配记录
|
||||||
|
export interface AllotRecord {
|
||||||
|
id: number;
|
||||||
|
poolCompanyId: number;
|
||||||
|
allotType: number;
|
||||||
|
fromWechatId: string | null;
|
||||||
|
toWechatId: string;
|
||||||
|
expireDays: number;
|
||||||
|
status: number;
|
||||||
|
createTime: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 统计数据
|
||||||
|
export interface TrafficStatistics {
|
||||||
|
totalCount: number;
|
||||||
|
friendCount: number;
|
||||||
|
todayNewCount: number;
|
||||||
|
weekNewCount: number;
|
||||||
|
monthNewCount: number;
|
||||||
|
levelDistribution: { level: number; count: number }[];
|
||||||
|
lifecycleDistribution: { lifecycle: number; count: number }[];
|
||||||
|
sourceDistribution: { sourceType: number; count: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分页响应
|
||||||
|
export interface PageResult<T> {
|
||||||
|
list: T[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 分组相关 API ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取流量池分组列表
|
||||||
|
*/
|
||||||
|
export async function getGroups(): Promise<TrafficPoolGroup[]> {
|
||||||
|
return request("/v1/traffic/pool/v2/groups", {}, "GET");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取分组详情
|
||||||
|
*/
|
||||||
|
export async function getGroupDetail(
|
||||||
|
groupId: number,
|
||||||
|
): Promise<TrafficPoolGroup> {
|
||||||
|
return request("/v1/traffic/pool/v2/group/detail", { groupId }, "GET");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建分组
|
||||||
|
*/
|
||||||
|
export async function createGroup(data: {
|
||||||
|
groupName: string;
|
||||||
|
groupCode?: string;
|
||||||
|
groupIcon?: string;
|
||||||
|
groupColor?: string;
|
||||||
|
description?: string;
|
||||||
|
ruleType?: number;
|
||||||
|
ruleConfig?: RuleConfig;
|
||||||
|
sort?: number;
|
||||||
|
}): Promise<{ id: number }> {
|
||||||
|
return request("/v1/traffic/pool/v2/group/create", data, "POST");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新分组
|
||||||
|
*/
|
||||||
|
export async function updateGroup(
|
||||||
|
groupId: number,
|
||||||
|
data: {
|
||||||
|
groupName?: string;
|
||||||
|
groupIcon?: string;
|
||||||
|
groupColor?: string;
|
||||||
|
description?: string;
|
||||||
|
ruleType?: number;
|
||||||
|
ruleConfig?: RuleConfig;
|
||||||
|
sort?: number;
|
||||||
|
status?: number;
|
||||||
|
},
|
||||||
|
): Promise<void> {
|
||||||
|
return request(
|
||||||
|
"/v1/traffic/pool/v2/group/update",
|
||||||
|
{ groupId, ...data },
|
||||||
|
"PUT",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除分组
|
||||||
|
*/
|
||||||
|
export async function deleteGroup(groupId: number): Promise<void> {
|
||||||
|
return request("/v1/traffic/pool/v2/group/delete", { groupId }, "DELETE");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取分组成员列表
|
||||||
|
*/
|
||||||
|
export async function getGroupMembers(params: {
|
||||||
|
groupId: number;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
keyword?: string;
|
||||||
|
}): Promise<PageResult<TrafficPoolMember>> {
|
||||||
|
return request("/v1/traffic/pool/v2/group/members", params, "GET");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加成员到分组
|
||||||
|
*/
|
||||||
|
export async function addMembersToGroup(
|
||||||
|
groupId: number,
|
||||||
|
poolCompanyIds: number[],
|
||||||
|
): Promise<{ count: number }> {
|
||||||
|
return request(
|
||||||
|
"/v1/traffic/pool/v2/group/add-members",
|
||||||
|
{ groupId, poolCompanyIds },
|
||||||
|
"POST",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从分组移除成员
|
||||||
|
*/
|
||||||
|
export async function removeMembersFromGroup(
|
||||||
|
groupId: number,
|
||||||
|
poolCompanyIds: number[],
|
||||||
|
): Promise<{ count: number }> {
|
||||||
|
return request(
|
||||||
|
"/v1/traffic/pool/v2/group/remove-members",
|
||||||
|
{ groupId, poolCompanyIds },
|
||||||
|
"POST",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 流量池成员相关 API ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取流量池列表
|
||||||
|
*/
|
||||||
|
export async function getPoolList(params: {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
keyword?: string;
|
||||||
|
friendStatus?: number;
|
||||||
|
level?: number;
|
||||||
|
lifecycle?: number;
|
||||||
|
allocateStatus?: number;
|
||||||
|
ownerWechatId?: string;
|
||||||
|
rfmMMin?: number;
|
||||||
|
rfmMMax?: number;
|
||||||
|
}): Promise<PageResult<TrafficPoolMember>> {
|
||||||
|
return request("/v1/traffic/pool/v2/list", params, "GET");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取流量详情
|
||||||
|
*/
|
||||||
|
export async function getPoolDetail(id: number): Promise<
|
||||||
|
TrafficPoolMember & {
|
||||||
|
sources: TrafficSource[];
|
||||||
|
behaviors: TrafficBehavior[];
|
||||||
|
allotRecords: AllotRecord[];
|
||||||
|
}
|
||||||
|
> {
|
||||||
|
return request("/v1/traffic/pool/v2/detail", { id }, "GET", {
|
||||||
|
timeout: 0, // 去除超时限制
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新流量信息
|
||||||
|
*/
|
||||||
|
export async function updatePool(
|
||||||
|
id: number,
|
||||||
|
data: {
|
||||||
|
realName?: string;
|
||||||
|
phone?: string;
|
||||||
|
email?: string;
|
||||||
|
birthday?: string;
|
||||||
|
address?: string;
|
||||||
|
company?: string;
|
||||||
|
position?: string;
|
||||||
|
remark?: string;
|
||||||
|
level?: number;
|
||||||
|
intentionLevel?: number;
|
||||||
|
lifecycle?: number;
|
||||||
|
status?: number;
|
||||||
|
},
|
||||||
|
): Promise<void> {
|
||||||
|
return request("/v1/traffic/pool/v2/update", { id, ...data }, "PUT");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 标签相关 API ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取标签类目
|
||||||
|
*/
|
||||||
|
export async function getTagCategories(
|
||||||
|
tagType?: number,
|
||||||
|
): Promise<TagCategory[]> {
|
||||||
|
return request("/v1/traffic/pool/v2/tag/categories", { tagType }, "GET");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取标签定义
|
||||||
|
*/
|
||||||
|
export async function getTagDefines(params?: {
|
||||||
|
tagType?: number;
|
||||||
|
categoryId?: number;
|
||||||
|
}): Promise<TagDefine[]> {
|
||||||
|
return request("/v1/traffic/pool/v2/tag/defines", params || {}, "GET");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取流量的标签
|
||||||
|
*/
|
||||||
|
export async function getPoolTags(
|
||||||
|
poolCompanyId: number,
|
||||||
|
tagType?: number,
|
||||||
|
): Promise<TrafficPoolTag[]> {
|
||||||
|
return request(
|
||||||
|
"/v1/traffic/pool/v2/tag/pool-tags",
|
||||||
|
{ poolCompanyId, tagType },
|
||||||
|
"GET",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为流量添加标签
|
||||||
|
*/
|
||||||
|
export async function addTag(
|
||||||
|
poolCompanyId: number,
|
||||||
|
tagDefineId: number,
|
||||||
|
tagValue?: string,
|
||||||
|
): Promise<{ id: number }> {
|
||||||
|
return request(
|
||||||
|
"/v1/traffic/pool/v2/tag/add",
|
||||||
|
{ poolCompanyId, tagDefineId, tagValue },
|
||||||
|
"POST",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移除流量标签
|
||||||
|
*/
|
||||||
|
export async function removeTag(
|
||||||
|
poolCompanyId: number,
|
||||||
|
tagDefineId: number,
|
||||||
|
): Promise<void> {
|
||||||
|
return request(
|
||||||
|
"/v1/traffic/pool/v2/tag/remove",
|
||||||
|
{ poolCompanyId, tagDefineId },
|
||||||
|
"DELETE",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从标签引擎同步用户标签
|
||||||
|
*/
|
||||||
|
export async function syncTagsFromEngine(
|
||||||
|
poolCompanyId: number,
|
||||||
|
): Promise<{ syncedCount: number; skippedCount: number; total: number }> {
|
||||||
|
return request(
|
||||||
|
"/v1/traffic/pool/v2/tag/sync-from-engine",
|
||||||
|
{ poolCompanyId },
|
||||||
|
"POST",
|
||||||
|
{
|
||||||
|
timeout: 0, // 去除超时限制
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 来源和行为相关 API ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页获取流量来源
|
||||||
|
*/
|
||||||
|
export async function getPoolSources(params: {
|
||||||
|
poolCompanyId: number;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
keyword?: string;
|
||||||
|
}): Promise<{
|
||||||
|
list: TrafficSource[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}> {
|
||||||
|
return request("/v1/traffic/pool/v2/sources", params, "GET");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页获取流量行为轨迹
|
||||||
|
*/
|
||||||
|
export async function getPoolBehaviors(params: {
|
||||||
|
poolCompanyId: number;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
keyword?: string;
|
||||||
|
behaviorType?: number;
|
||||||
|
}): Promise<{
|
||||||
|
list: TrafficBehavior[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}> {
|
||||||
|
return request("/v1/traffic/pool/v2/behaviors", params, "GET");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 分配相关 API ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分配流量给客服
|
||||||
|
*/
|
||||||
|
export async function allocatePool(params: {
|
||||||
|
poolCompanyId: number;
|
||||||
|
toWechatId: string;
|
||||||
|
toAccountId?: number;
|
||||||
|
toUserId?: number;
|
||||||
|
expireDays?: number;
|
||||||
|
}): Promise<{ id: number }> {
|
||||||
|
return request("/v1/traffic/pool/v2/allocate", params, "POST");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回收流量分配
|
||||||
|
*/
|
||||||
|
export async function recyclePool(poolCompanyId: number): Promise<void> {
|
||||||
|
return request("/v1/traffic/pool/v2/recycle", { poolCompanyId }, "POST");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 统计相关 API ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取流量池统计数据
|
||||||
|
*/
|
||||||
|
export async function getStatistics(): Promise<TrafficStatistics> {
|
||||||
|
return request("/v1/traffic/pool/v2/statistics", {}, "GET");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 常量定义 ====================
|
||||||
|
|
||||||
|
// 好友状态
|
||||||
|
export const FRIEND_STATUS = {
|
||||||
|
NOT_ADDED: 0, // 未加
|
||||||
|
PENDING: 1, // 待通过
|
||||||
|
PASSED: 2, // 已通过
|
||||||
|
DELETED: 3, // 已删除
|
||||||
|
BE_DELETED: 4, // 被删除
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FRIEND_STATUS_TEXT: Record<number, string> = {
|
||||||
|
[FRIEND_STATUS.NOT_ADDED]: "未加",
|
||||||
|
[FRIEND_STATUS.PENDING]: "待通过",
|
||||||
|
[FRIEND_STATUS.PASSED]: "已通过",
|
||||||
|
[FRIEND_STATUS.DELETED]: "已删除",
|
||||||
|
[FRIEND_STATUS.BE_DELETED]: "被删除",
|
||||||
|
};
|
||||||
|
|
||||||
|
// 客户等级
|
||||||
|
export const LEVEL = {
|
||||||
|
NORMAL: 0, // 普通
|
||||||
|
IMPORTANT: 1, // 重要
|
||||||
|
VIP: 2, // VIP
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LEVEL_TEXT: Record<number, string> = {
|
||||||
|
[LEVEL.NORMAL]: "普通",
|
||||||
|
[LEVEL.IMPORTANT]: "重要",
|
||||||
|
[LEVEL.VIP]: "VIP",
|
||||||
|
};
|
||||||
|
|
||||||
|
// 意向度
|
||||||
|
export const INTENTION_LEVEL = {
|
||||||
|
UNKNOWN: 0, // 未知
|
||||||
|
LOW: 1, // 低
|
||||||
|
MEDIUM: 2, // 中
|
||||||
|
HIGH: 3, // 高
|
||||||
|
};
|
||||||
|
|
||||||
|
export const INTENTION_LEVEL_TEXT: Record<number, string> = {
|
||||||
|
[INTENTION_LEVEL.UNKNOWN]: "未知",
|
||||||
|
[INTENTION_LEVEL.LOW]: "低意向",
|
||||||
|
[INTENTION_LEVEL.MEDIUM]: "中意向",
|
||||||
|
[INTENTION_LEVEL.HIGH]: "高意向",
|
||||||
|
};
|
||||||
|
|
||||||
|
// 生命周期
|
||||||
|
export const LIFECYCLE = {
|
||||||
|
NEW: 1, // 新流量
|
||||||
|
FOLLOWING: 2, // 跟进中
|
||||||
|
CONVERTED: 3, // 已成交
|
||||||
|
SILENT: 4, // 沉默
|
||||||
|
LOST: 5, // 流失
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LIFECYCLE_TEXT: Record<number, string> = {
|
||||||
|
[LIFECYCLE.NEW]: "新流量",
|
||||||
|
[LIFECYCLE.FOLLOWING]: "跟进中",
|
||||||
|
[LIFECYCLE.CONVERTED]: "已成交",
|
||||||
|
[LIFECYCLE.SILENT]: "沉默",
|
||||||
|
[LIFECYCLE.LOST]: "流失",
|
||||||
|
};
|
||||||
|
|
||||||
|
// 来源类型
|
||||||
|
export const SOURCE_TYPE = {
|
||||||
|
FRIEND_ADD: 1, // 好友添加
|
||||||
|
GROUP_MEMBER: 2, // 群成员
|
||||||
|
POSTER: 3, // 海报获客
|
||||||
|
PHONE: 4, // 电话获客
|
||||||
|
ORDER: 5, // 订单获客
|
||||||
|
API: 6, // API导入
|
||||||
|
MANUAL: 7, // 手动导入
|
||||||
|
FISSION: 8, // 裂变活动
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SOURCE_TYPE_TEXT: Record<number, string> = {
|
||||||
|
[SOURCE_TYPE.FRIEND_ADD]: "好友添加",
|
||||||
|
[SOURCE_TYPE.GROUP_MEMBER]: "群成员",
|
||||||
|
[SOURCE_TYPE.POSTER]: "海报获客",
|
||||||
|
[SOURCE_TYPE.PHONE]: "电话获客",
|
||||||
|
[SOURCE_TYPE.ORDER]: "订单获客",
|
||||||
|
[SOURCE_TYPE.API]: "API导入",
|
||||||
|
[SOURCE_TYPE.MANUAL]: "手动导入",
|
||||||
|
[SOURCE_TYPE.FISSION]: "裂变活动",
|
||||||
|
};
|
||||||
|
|
||||||
|
// 标签类型
|
||||||
|
export const TAG_TYPE = {
|
||||||
|
WECHAT: 1, // 微信标签
|
||||||
|
SITE: 2, // 站内标签
|
||||||
|
AI: 3, // AI标签
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TAG_TYPE_TEXT: Record<number, string> = {
|
||||||
|
[TAG_TYPE.WECHAT]: "微信标签",
|
||||||
|
[TAG_TYPE.SITE]: "站内标签",
|
||||||
|
[TAG_TYPE.AI]: "AI标签",
|
||||||
|
};
|
||||||
23
src/pages/mobile/mine/traffic-pool/detail/api.ts
Normal file
23
src/pages/mobile/mine/traffic-pool/detail/api.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { getPoolTags, addTag, removeTag, updatePool } from "../api";
|
||||||
|
import request from "@/api/request";
|
||||||
|
|
||||||
|
export { getPoolTags, addTag, removeTag, updatePool };
|
||||||
|
|
||||||
|
// 获取用户详情(根据 poolCompanyId)
|
||||||
|
export async function fetchUserDetail(id: number) {
|
||||||
|
return request("/v1/traffic/pool/v2/detail", { id }, "GET", {
|
||||||
|
timeout: 0, // 去除超时限制
|
||||||
|
}, 0); // debounceGap 设置为 0,避免防抖拦截
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新用户信息
|
||||||
|
export async function updateUserInfo(id: number, data: any) {
|
||||||
|
return updatePool(id, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新用户RFM评分
|
||||||
|
export async function updateRfm(identifier: string) {
|
||||||
|
return request("/v1/traffic/pool/v2/calculate-rfm", { identifier }, "POST", {
|
||||||
|
timeout: 0, // 去除超时限制
|
||||||
|
});
|
||||||
|
}
|
||||||
313
src/pages/mobile/mine/traffic-pool/detail/index.module.scss
Normal file
313
src/pages/mobile/mine/traffic-pool/detail/index.module.scss
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
.container {
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: #f6f7f9;
|
||||||
|
padding-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loadingContainer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 头部核心卡片
|
||||||
|
.headerCard {
|
||||||
|
background: linear-gradient(135deg, #1677ff 0%, #4096ff 100%);
|
||||||
|
padding: 32px 16px 48px;
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
border: 3px solid rgba(255, 255, 255, 0.3);
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.userInfo {
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
.nickname {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 8px;
|
||||||
|
|
||||||
|
.levelBadge {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通用卡片容器
|
||||||
|
.card {
|
||||||
|
margin: -24px 12px 16px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 20px;
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.04);
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
|
||||||
|
&.noOverlap {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a1a;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.titleLine {
|
||||||
|
display: inline-block;
|
||||||
|
width: 4px;
|
||||||
|
height: 16px;
|
||||||
|
background: #1677ff;
|
||||||
|
border-radius: 2px;
|
||||||
|
margin-right: 8px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 基础信息列表
|
||||||
|
.infoGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
|
||||||
|
.infoItem {
|
||||||
|
.label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #8c8c8c;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.value {
|
||||||
|
font-size: 15px;
|
||||||
|
color: #262626;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 统计面板
|
||||||
|
.statsGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
.statBox {
|
||||||
|
background: #f8fbff;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
.statVal {
|
||||||
|
display: block;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #1677ff;
|
||||||
|
}
|
||||||
|
.statLabel {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #595959;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标签样式重构
|
||||||
|
.tagGroups {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
.tagGroup {
|
||||||
|
.tagGroupTitle {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #8c8c8c;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagsList {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagBase {
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagSite {
|
||||||
|
@extend .tagBase;
|
||||||
|
background: #e6f4ff;
|
||||||
|
color: #0958d9;
|
||||||
|
border: 1px solid #91caff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagAi {
|
||||||
|
@extend .tagBase;
|
||||||
|
background: #f9f0ff;
|
||||||
|
color: #531dab;
|
||||||
|
border: 1px solid #d3adf7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagWechat {
|
||||||
|
@extend .tagBase;
|
||||||
|
background: #f6ffed;
|
||||||
|
color: #389e0d;
|
||||||
|
border: 1px solid #b7eb8f;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 轨迹样式
|
||||||
|
.journeyList {
|
||||||
|
position: relative;
|
||||||
|
padding-left: 20px;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 4px;
|
||||||
|
top: 10px;
|
||||||
|
bottom: 10px;
|
||||||
|
width: 1px;
|
||||||
|
background: #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journeyItem {
|
||||||
|
position: relative;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: -20px;
|
||||||
|
top: 6px;
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #bfbfbf;
|
||||||
|
border: 2px solid #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:first-child::after {
|
||||||
|
background: #1677ff;
|
||||||
|
box-shadow: 0 0 0 4px rgba(22, 119, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.journeyHeader {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
|
||||||
|
.jTitle {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
.jTime {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #bfbfbf;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.jContent {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #595959;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sourceIconText {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
background: #f0f5ff;
|
||||||
|
color: #1677ff;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ownerList {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 6px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: #fafafa;
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
.ownerLabel {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #8c8c8c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ownerItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #595959;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 底部按钮
|
||||||
|
.loadMore {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
height: 36px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchWrapper {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
:global(.adm-search-bar) {
|
||||||
|
--background: #f5f5f5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.titleActions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
.actionBtn {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #1677ff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
618
src/pages/mobile/mine/traffic-pool/detail/index.tsx
Normal file
618
src/pages/mobile/mine/traffic-pool/detail/index.tsx
Normal file
@@ -0,0 +1,618 @@
|
|||||||
|
import React, { useEffect, useState, useRef } from "react";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import Layout from "@/components/Layout/Layout";
|
||||||
|
import { Avatar, Popup, CheckList, Button as MobileButton, SearchBar, Tag, Empty } from "antd-mobile";
|
||||||
|
import { Spin, message } from "antd";
|
||||||
|
import {
|
||||||
|
UserOutlined,
|
||||||
|
PhoneOutlined,
|
||||||
|
EnvironmentOutlined,
|
||||||
|
TagOutlined,
|
||||||
|
HistoryOutlined,
|
||||||
|
ShareAltOutlined,
|
||||||
|
TrophyOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
CloseCircleOutlined,
|
||||||
|
SyncOutlined,
|
||||||
|
CommentOutlined,
|
||||||
|
NodeIndexOutlined,
|
||||||
|
ClockCircleOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
|
import NavCommon from "@/components/NavCommon";
|
||||||
|
import { fetchUserDetail, addTag, removeTag, updateRfm } from "./api";
|
||||||
|
import {
|
||||||
|
SOURCE_TYPE_TEXT,
|
||||||
|
LEVEL_TEXT,
|
||||||
|
LIFECYCLE_TEXT,
|
||||||
|
INTENTION_LEVEL_TEXT,
|
||||||
|
FRIEND_STATUS_TEXT,
|
||||||
|
getTagDefines,
|
||||||
|
syncTagsFromEngine,
|
||||||
|
getPoolSources,
|
||||||
|
getPoolBehaviors,
|
||||||
|
type TagDefine,
|
||||||
|
} from "../api";
|
||||||
|
import styles from "./index.module.scss";
|
||||||
|
|
||||||
|
const defaultAvatar = "https://cdn.jsdelivr.net/gh/maokaka/static/avatar-default.png";
|
||||||
|
|
||||||
|
const TrafficPoolDetail: React.FC = () => {
|
||||||
|
const { id } = useParams<{ wechatId: string; id: string }>();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [detail, setDetail] = useState<any>(null);
|
||||||
|
|
||||||
|
// 状态管理保持原有逻辑
|
||||||
|
const [tagModalVisible, setTagModalVisible] = useState(false);
|
||||||
|
const [tagDefines, setTagDefines] = useState<TagDefine[]>([]);
|
||||||
|
const [selectedTagIds, setSelectedTagIds] = useState<number[]>([]);
|
||||||
|
const [tagLoading, setTagLoading] = useState(false);
|
||||||
|
const [syncLoading, setSyncLoading] = useState(false);
|
||||||
|
const [rfmLoading, setRfmLoading] = useState(false);
|
||||||
|
|
||||||
|
const [sourcesPage, setSourcesPage] = useState(1);
|
||||||
|
const [sourcesLoading, setSourcesLoading] = useState(false);
|
||||||
|
const [sourcesTotal, setSourcesTotal] = useState(0);
|
||||||
|
const [allSources, setAllSources] = useState<any[]>([]);
|
||||||
|
const [sourcesKeyword, setSourcesKeyword] = useState("");
|
||||||
|
const [sourcesSearchInput, setSourcesSearchInput] = useState("");
|
||||||
|
const sourcesSearchTimer = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const sourcesKeywordInitialized = useRef(false);
|
||||||
|
|
||||||
|
const [behaviorsPage, setBehaviorsPage] = useState(1);
|
||||||
|
const [behaviorsLoading, setBehaviorsLoading] = useState(false);
|
||||||
|
const [behaviorsTotal, setBehaviorsTotal] = useState(0);
|
||||||
|
const [allBehaviors, setAllBehaviors] = useState<any[]>([]);
|
||||||
|
const [behaviorsKeyword, setBehaviorsKeyword] = useState("");
|
||||||
|
const [behaviorsSearchInput, setBehaviorsSearchInput] = useState("");
|
||||||
|
const behaviorsSearchTimer = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const behaviorsKeywordInitialized = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (id) loadDetail();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const loadDetail = async () => {
|
||||||
|
if (!id) return;
|
||||||
|
// 如果有搜索关键词,不重置搜索状态
|
||||||
|
if (sourcesKeyword || behaviorsKeyword) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetchUserDetail(parseInt(id));
|
||||||
|
setDetail(res);
|
||||||
|
// 初始化来源和行为数据(详情接口返回的前50条)
|
||||||
|
const initialSources = res.sources || [];
|
||||||
|
const initialBehaviors = res.behaviors || [];
|
||||||
|
setAllSources(initialSources);
|
||||||
|
setAllBehaviors(initialBehaviors);
|
||||||
|
|
||||||
|
// 如果返回了50条,说明可能还有更多,需要获取总数
|
||||||
|
if (initialSources.length >= 50) {
|
||||||
|
try {
|
||||||
|
const sourcesResult = await getPoolSources({
|
||||||
|
poolCompanyId: parseInt(id),
|
||||||
|
page: 1,
|
||||||
|
pageSize: 1,
|
||||||
|
});
|
||||||
|
setSourcesTotal(sourcesResult.total);
|
||||||
|
setSourcesPage(3);
|
||||||
|
} catch (e) {
|
||||||
|
setSourcesTotal(initialSources.length);
|
||||||
|
setSourcesPage(1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setSourcesTotal(initialSources.length);
|
||||||
|
setSourcesPage(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (initialBehaviors.length >= 50) {
|
||||||
|
try {
|
||||||
|
const behaviorsResult = await getPoolBehaviors({
|
||||||
|
poolCompanyId: parseInt(id),
|
||||||
|
page: 1,
|
||||||
|
pageSize: 1,
|
||||||
|
});
|
||||||
|
setBehaviorsTotal(behaviorsResult.total);
|
||||||
|
setBehaviorsPage(3);
|
||||||
|
} catch (e) {
|
||||||
|
setBehaviorsTotal(initialBehaviors.length);
|
||||||
|
setBehaviorsPage(1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setBehaviorsTotal(initialBehaviors.length);
|
||||||
|
setBehaviorsPage(1);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error?.message || "获取详情失败");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 搜索来源(带防抖)
|
||||||
|
const handleSourcesSearchChange = (value: string) => {
|
||||||
|
setSourcesSearchInput(value);
|
||||||
|
if (sourcesSearchTimer.current) {
|
||||||
|
clearTimeout(sourcesSearchTimer.current);
|
||||||
|
}
|
||||||
|
sourcesSearchTimer.current = setTimeout(() => {
|
||||||
|
setSourcesKeyword(value);
|
||||||
|
setSourcesPage(1);
|
||||||
|
}, 500);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 搜索来源数据
|
||||||
|
const searchSources = async (keyword: string, page: number = 1) => {
|
||||||
|
if (!id) return;
|
||||||
|
setSourcesLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await getPoolSources({
|
||||||
|
poolCompanyId: parseInt(id),
|
||||||
|
page,
|
||||||
|
pageSize: keyword ? 100 : 50,
|
||||||
|
keyword,
|
||||||
|
});
|
||||||
|
if (page === 1) {
|
||||||
|
setAllSources(result.list);
|
||||||
|
} else {
|
||||||
|
// 去重合并
|
||||||
|
const existingIds = new Set(allSources.map(s => s.id));
|
||||||
|
const newList = result.list.filter(s => !existingIds.has(s.id));
|
||||||
|
setAllSources([...allSources, ...newList]);
|
||||||
|
}
|
||||||
|
setSourcesTotal(result.total);
|
||||||
|
setSourcesPage(page);
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error?.message || "搜索失败");
|
||||||
|
} finally {
|
||||||
|
setSourcesLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 加载更多来源
|
||||||
|
const loadMoreSources = async () => {
|
||||||
|
if (!id || sourcesLoading) return;
|
||||||
|
const nextPage = sourcesPage + 1;
|
||||||
|
await searchSources(sourcesKeyword, nextPage);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 监听搜索关键词变化
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sourcesKeywordInitialized.current) {
|
||||||
|
sourcesKeywordInitialized.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (id && sourcesKeyword !== "") {
|
||||||
|
searchSources(sourcesKeyword, 1);
|
||||||
|
} else if (id && sourcesKeyword === "") {
|
||||||
|
loadDetail();
|
||||||
|
}
|
||||||
|
}, [sourcesKeyword]);
|
||||||
|
|
||||||
|
// 搜索行为(带防抖)
|
||||||
|
const handleBehaviorsSearchChange = (value: string) => {
|
||||||
|
setBehaviorsSearchInput(value);
|
||||||
|
if (behaviorsSearchTimer.current) {
|
||||||
|
clearTimeout(behaviorsSearchTimer.current);
|
||||||
|
}
|
||||||
|
behaviorsSearchTimer.current = setTimeout(() => {
|
||||||
|
setBehaviorsKeyword(value);
|
||||||
|
setBehaviorsPage(1);
|
||||||
|
}, 500);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 搜索行为数据
|
||||||
|
const searchBehaviors = async (keyword: string, page: number = 1) => {
|
||||||
|
if (!id) return;
|
||||||
|
setBehaviorsLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await getPoolBehaviors({
|
||||||
|
poolCompanyId: parseInt(id),
|
||||||
|
page,
|
||||||
|
pageSize: 100,
|
||||||
|
keyword,
|
||||||
|
});
|
||||||
|
if (page === 1) {
|
||||||
|
setAllBehaviors(result.list);
|
||||||
|
} else {
|
||||||
|
const existingIds = new Set(allBehaviors.map(b => b.id));
|
||||||
|
const newList = result.list.filter(b => !existingIds.has(b.id));
|
||||||
|
setAllBehaviors([...allBehaviors, ...newList]);
|
||||||
|
}
|
||||||
|
setBehaviorsTotal(result.total);
|
||||||
|
setBehaviorsPage(page);
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error?.message || "搜索失败");
|
||||||
|
} finally {
|
||||||
|
setBehaviorsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 加载更多行为
|
||||||
|
const loadMoreBehaviors = async () => {
|
||||||
|
if (!id || behaviorsLoading) return;
|
||||||
|
const nextPage = behaviorsPage + 1;
|
||||||
|
await searchBehaviors(behaviorsKeyword, nextPage);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 监听搜索关键词变化
|
||||||
|
useEffect(() => {
|
||||||
|
if (!behaviorsKeywordInitialized.current) {
|
||||||
|
behaviorsKeywordInitialized.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (id && behaviorsKeyword !== "") {
|
||||||
|
searchBehaviors(behaviorsKeyword, 1);
|
||||||
|
} else if (id && behaviorsKeyword === "") {
|
||||||
|
loadDetail();
|
||||||
|
}
|
||||||
|
}, [behaviorsKeyword]);
|
||||||
|
|
||||||
|
// 标签处理逻辑
|
||||||
|
const openTagModal = async () => {
|
||||||
|
setTagModalVisible(true);
|
||||||
|
setTagLoading(true);
|
||||||
|
try {
|
||||||
|
const defines = await getTagDefines({ tagType: 2 });
|
||||||
|
setTagDefines(defines);
|
||||||
|
const existingTagIds = detail?.tags?.filter((t: any) => t.tagType === 2).map((t: any) => t.tagDefineId) || [];
|
||||||
|
setSelectedTagIds(existingTagIds);
|
||||||
|
} catch (error) {
|
||||||
|
message.error("获取标签列表失败");
|
||||||
|
} finally {
|
||||||
|
setTagLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSyncTags = async () => {
|
||||||
|
if (!id || syncLoading) return;
|
||||||
|
setSyncLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await syncTagsFromEngine(parseInt(id));
|
||||||
|
message.success(`同步成功:已同步 ${result.syncedCount} 个标签`);
|
||||||
|
loadDetail();
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error?.message || "同步失败");
|
||||||
|
} finally {
|
||||||
|
setSyncLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateRfm = async () => {
|
||||||
|
if (!detail?.identifier || rfmLoading) return;
|
||||||
|
setRfmLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await updateRfm(detail.identifier);
|
||||||
|
message.success("RFM评分更新成功");
|
||||||
|
// 重新加载详情以获取最新的RFM数据
|
||||||
|
loadDetail();
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error?.message || "RFM更新失败");
|
||||||
|
} finally {
|
||||||
|
setRfmLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveTagChanges = async () => {
|
||||||
|
if (!id || !detail) return;
|
||||||
|
const poolCompanyId = parseInt(id);
|
||||||
|
const existingTagIds = detail.tags
|
||||||
|
?.filter((t: any) => t.tagType === 2)
|
||||||
|
?.map((t: any) => t.tagDefineId) || [];
|
||||||
|
|
||||||
|
const toAdd = selectedTagIds.filter(tagId => !existingTagIds.includes(tagId));
|
||||||
|
const toRemove = existingTagIds.filter((tagId: number) => !selectedTagIds.includes(tagId));
|
||||||
|
|
||||||
|
setTagLoading(true);
|
||||||
|
try {
|
||||||
|
for (const tagDefineId of toAdd) {
|
||||||
|
await addTag(poolCompanyId, tagDefineId);
|
||||||
|
}
|
||||||
|
for (const tagDefineId of toRemove) {
|
||||||
|
await removeTag(poolCompanyId, tagDefineId);
|
||||||
|
}
|
||||||
|
message.success("标签更新成功");
|
||||||
|
setTagModalVisible(false);
|
||||||
|
loadDetail();
|
||||||
|
} catch (error) {
|
||||||
|
message.error("标签更新失败");
|
||||||
|
} finally {
|
||||||
|
setTagLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading && !detail) {
|
||||||
|
return <Layout><div className={styles.loadingContainer}><Spin tip="加载中..." /></div></Layout>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div className={styles.container}>
|
||||||
|
<NavCommon title="用户详情" />
|
||||||
|
|
||||||
|
{/* 顶部核心卡片 */}
|
||||||
|
<div className={styles.headerCard}>
|
||||||
|
<Avatar src={detail?.avatar || defaultAvatar} className={styles.avatar} style={{ '--size': '64px' }} />
|
||||||
|
<div className={styles.userInfo}>
|
||||||
|
<div className={styles.nickname}>
|
||||||
|
{detail?.nickname || '未知用户'}
|
||||||
|
<Tag color='warning' fill='outline' style={{ fontSize: '10px' }}>
|
||||||
|
{LEVEL_TEXT[detail?.level] || '普通'}
|
||||||
|
</Tag>
|
||||||
|
</div>
|
||||||
|
<div className={styles.tags}>
|
||||||
|
<span className={styles.levelBadge}>{detail?.wechatAlias || detail?.wechatId || '无微信号'}</span>
|
||||||
|
<span className={styles.levelBadge}>{detail?.region || '未知地区'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 统计面板卡片 */}
|
||||||
|
<div className={styles.card}>
|
||||||
|
<div className={styles.sectionTitle}>
|
||||||
|
<span><TrophyOutlined style={{ color: '#faad14', marginRight: 8 }} />互动统计</span>
|
||||||
|
<div className={styles.titleActions}>
|
||||||
|
<span className={styles.actionBtn} onClick={handleUpdateRfm}>
|
||||||
|
<SyncOutlined spin={rfmLoading} /> 更新RFM
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.statsGrid}>
|
||||||
|
<div className={styles.statBox}>
|
||||||
|
<span className={styles.statVal}>{detail?.totalMsgCount || 0}</span>
|
||||||
|
<span className={styles.statLabel}>消息数</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.statBox}>
|
||||||
|
<span className={styles.statVal}>{detail?.totalOrderCount || 0}</span>
|
||||||
|
<span className={styles.statLabel}>订单数</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.statBox}>
|
||||||
|
<span className={styles.statVal}>{detail?.rfmScore?.total || 0}</span>
|
||||||
|
<span className={styles.statLabel}>RFM得分</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* RFM详细得分 */}
|
||||||
|
<div className={styles.infoGrid} style={{ marginTop: '12px', paddingTop: '12px', borderTop: '1px solid #f0f0f0' }}>
|
||||||
|
<div className={styles.infoItem}>
|
||||||
|
<div className={styles.label}>R-最近购买</div>
|
||||||
|
<div className={styles.value}>{detail?.rfmScore?.R || 0}分</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.infoItem}>
|
||||||
|
<div className={styles.label}>F-购买频次</div>
|
||||||
|
<div className={styles.value}>{detail?.rfmScore?.F || 0}分</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.infoItem}>
|
||||||
|
<div className={styles.label}>M-购买金额</div>
|
||||||
|
<div className={styles.value}>{detail?.rfmScore?.M || 0}分</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 基础信息卡片 */}
|
||||||
|
<div className={`${styles.card} ${styles.noOverlap}`}>
|
||||||
|
<div className={styles.sectionTitle}>
|
||||||
|
<span><UserOutlined style={{ color: '#1677ff', marginRight: 8 }} />基础资料</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.infoGrid}>
|
||||||
|
<div className={styles.infoItem}>
|
||||||
|
<div className={styles.label}>加粉状态</div>
|
||||||
|
<div className={styles.value}>{FRIEND_STATUS_TEXT[detail?.friendStatus] || '未知'}</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.infoItem}>
|
||||||
|
<div className={styles.label}>客户周期</div>
|
||||||
|
<div className={styles.value}>{LIFECYCLE_TEXT[detail?.lifecycle] || '新流量'}</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.infoItem}>
|
||||||
|
<div className={styles.label}>意向等级</div>
|
||||||
|
<div className={styles.value}>{INTENTION_LEVEL_TEXT[detail?.intentionLevel] || '未知'}</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.infoItem}>
|
||||||
|
<div className={styles.label}>最后互动</div>
|
||||||
|
<div className={styles.value}>{detail?.lastInteractTime ? new Date(detail.lastInteractTime * 1000).toLocaleDateString() : '从未'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 标签管理卡片 */}
|
||||||
|
<div className={styles.card}>
|
||||||
|
<div className={styles.sectionTitle}>
|
||||||
|
<span><TagOutlined style={{ color: '#722ed1', marginRight: 8 }} />标签画像</span>
|
||||||
|
<div className={styles.titleActions}>
|
||||||
|
<span className={styles.actionBtn} onClick={handleSyncTags}><SyncOutlined spin={syncLoading} /> 同步</span>
|
||||||
|
<span className={styles.actionBtn} onClick={openTagModal}><EditOutlined /> 编辑</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.tagGroups}>
|
||||||
|
{/* 微信标签 */}
|
||||||
|
{detail?.tags?.some((t: any) => t.tagType === 1) && (
|
||||||
|
<div className={styles.tagGroup}>
|
||||||
|
<div className={styles.tagGroupTitle}><CommentOutlined /> 微信标签</div>
|
||||||
|
<div className={styles.tagsList}>
|
||||||
|
{detail?.tags?.filter((t: any) => t.tagType === 1).map((tag: any) => (
|
||||||
|
<span key={tag.id} className={styles.tagWechat}>{tag.tagName}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* 站内标签 */}
|
||||||
|
<div className={styles.tagGroup}>
|
||||||
|
<div className={styles.tagGroupTitle}><EditOutlined /> 站内标签</div>
|
||||||
|
<div className={styles.tagsList}>
|
||||||
|
{detail?.tags?.filter((t: any) => t.tagType === 2).length > 0 ? (
|
||||||
|
detail?.tags?.filter((t: any) => t.tagType === 2).map((tag: any) => (
|
||||||
|
<span key={tag.id} className={styles.tagSite}>{tag.tagName}</span>
|
||||||
|
))
|
||||||
|
) : <span style={{ color: '#bfbfbf', fontSize: '12px' }}>暂无标签</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* AI标签 */}
|
||||||
|
{detail?.tags?.some((t: any) => t.tagType === 3) && (
|
||||||
|
<div className={styles.tagGroup}>
|
||||||
|
<div className={styles.tagGroupTitle}><NodeIndexOutlined /> AI标签</div>
|
||||||
|
<div className={styles.tagsList}>
|
||||||
|
{detail?.tags?.filter((t: any) => t.tagType === 3).map((tag: any) => (
|
||||||
|
<span key={tag.id} className={styles.tagAi}>{tag.tagName}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 行为轨迹卡片 */}
|
||||||
|
<div className={styles.card}>
|
||||||
|
<div className={styles.sectionTitle}>
|
||||||
|
<span><HistoryOutlined style={{ color: '#eb2f96', marginRight: 8 }} />行为轨迹</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.searchWrapper}>
|
||||||
|
<SearchBar
|
||||||
|
placeholder="搜索行为..."
|
||||||
|
value={behaviorsSearchInput}
|
||||||
|
onChange={handleBehaviorsSearchChange}
|
||||||
|
onClear={() => {
|
||||||
|
setBehaviorsSearchInput("");
|
||||||
|
setBehaviorsKeyword("");
|
||||||
|
setBehaviorsPage(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.journeyList}>
|
||||||
|
{allBehaviors.length > 0 ? (
|
||||||
|
allBehaviors.map((b, index) => (
|
||||||
|
<div key={index} className={styles.journeyItem}>
|
||||||
|
<div className={styles.journeyHeader}>
|
||||||
|
<span className={styles.jTitle}>{b.behaviorName}</span>
|
||||||
|
<span className={styles.jTime}><ClockCircleOutlined /> {b.behaviorTimeFormatted || '刚刚'}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.jContent}>{b.targetName || '执行了相关操作'}</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : <Empty description="暂无轨迹" />}
|
||||||
|
</div>
|
||||||
|
{behaviorsTotal > allBehaviors.length && (
|
||||||
|
<MobileButton
|
||||||
|
block
|
||||||
|
className={styles.loadMore}
|
||||||
|
fill='none'
|
||||||
|
color='primary'
|
||||||
|
loading={behaviorsLoading}
|
||||||
|
onClick={loadMoreBehaviors}
|
||||||
|
>
|
||||||
|
加载更多
|
||||||
|
</MobileButton>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 来源追溯卡片 */}
|
||||||
|
<div className={styles.card}>
|
||||||
|
<div className={styles.sectionTitle}>
|
||||||
|
<span><ShareAltOutlined style={{ color: '#2f54eb', marginRight: 8 }} />来源追溯</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.searchWrapper}>
|
||||||
|
<SearchBar
|
||||||
|
placeholder="搜索来源..."
|
||||||
|
value={sourcesSearchInput}
|
||||||
|
onChange={handleSourcesSearchChange}
|
||||||
|
onClear={() => {
|
||||||
|
setSourcesSearchInput("");
|
||||||
|
setSourcesKeyword("");
|
||||||
|
setSourcesPage(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.journeyList}>
|
||||||
|
{allSources.length > 0 ? (
|
||||||
|
allSources.map((s, index) => (
|
||||||
|
<div key={index} className={styles.journeyItem}>
|
||||||
|
<div className={styles.journeyHeader}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
{s.sourceType === 2 && s.chatroomInfo?.chatroomAvatar ? (
|
||||||
|
<Avatar src={s.chatroomInfo.chatroomAvatar} style={{ '--size': '24px', borderRadius: '4px' }} />
|
||||||
|
) : (s.sourceType === 1 && s.sourceAvatar ? (
|
||||||
|
<Avatar src={s.sourceAvatar} style={{ '--size': '24px', borderRadius: '50%' }} />
|
||||||
|
) : (
|
||||||
|
<div className={styles.sourceIconText}>{SOURCE_TYPE_TEXT[s.sourceType]?.charAt(0)}</div>
|
||||||
|
))}
|
||||||
|
<span className={styles.jTitle}>{SOURCE_TYPE_TEXT[s.sourceType]}</span>
|
||||||
|
</div>
|
||||||
|
<span className={styles.jTime}>{s.createTimeFormatted}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.jContent}>
|
||||||
|
{s.sourceType === 2 ? (
|
||||||
|
<div>
|
||||||
|
<div>群聊:{s.chatroomInfo?.chatroomName}</div>
|
||||||
|
{s.chatroomOwners && s.chatroomOwners.length > 0 && (
|
||||||
|
<div className={styles.ownerList}>
|
||||||
|
<span className={styles.ownerLabel}>归属:</span>
|
||||||
|
{s.chatroomOwners.map((owner: any, idx: number) => (
|
||||||
|
<div key={idx} className={styles.ownerItem}>
|
||||||
|
<Avatar src={owner.ownerAvatar || defaultAvatar} style={{ '--size': '16px', borderRadius: '50%' }} />
|
||||||
|
<span>{owner.ownerNickname}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
s.sourceName
|
||||||
|
)}
|
||||||
|
{s.displayId && <div style={{ fontSize: '11px', color: '#bfbfbf', marginTop: '4px' }}>ID: {s.displayId}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : <Empty description="暂无来源" />}
|
||||||
|
</div>
|
||||||
|
{sourcesTotal > allSources.length && (
|
||||||
|
<MobileButton
|
||||||
|
block
|
||||||
|
className={styles.loadMore}
|
||||||
|
fill='none'
|
||||||
|
color='primary'
|
||||||
|
loading={sourcesLoading}
|
||||||
|
onClick={loadMoreSources}
|
||||||
|
>
|
||||||
|
加载更多
|
||||||
|
</MobileButton>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 标签编辑弹窗 */}
|
||||||
|
<Popup
|
||||||
|
visible={tagModalVisible}
|
||||||
|
onMaskClick={() => setTagModalVisible(false)}
|
||||||
|
bodyStyle={{ borderTopLeftRadius: '16px', borderTopRightRadius: '16px', minHeight: '40vh' }}
|
||||||
|
>
|
||||||
|
<div style={{ padding: '20px' }}>
|
||||||
|
<div className={styles.sectionTitle}>编辑站内标签</div>
|
||||||
|
{tagLoading ? <Spin /> : (
|
||||||
|
<CheckList
|
||||||
|
multiple
|
||||||
|
value={selectedTagIds}
|
||||||
|
onChange={(val) => setSelectedTagIds(val as number[])}
|
||||||
|
>
|
||||||
|
{tagDefines.map(item => (
|
||||||
|
<CheckList.Item key={item.id} value={item.id}>{item.tagName}</CheckList.Item>
|
||||||
|
))}
|
||||||
|
</CheckList>
|
||||||
|
)}
|
||||||
|
<MobileButton
|
||||||
|
block
|
||||||
|
color='primary'
|
||||||
|
style={{ marginTop: '20px' }}
|
||||||
|
loading={tagLoading}
|
||||||
|
onClick={saveTagChanges}
|
||||||
|
>
|
||||||
|
完成
|
||||||
|
</MobileButton>
|
||||||
|
</div>
|
||||||
|
</Popup>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TrafficPoolDetail;
|
||||||
@@ -1,56 +1,75 @@
|
|||||||
import request from "@/api/request";
|
import request from "@/api/request";
|
||||||
|
import { createGroup, type RuleCondition, type RuleConfig } from "../api";
|
||||||
|
|
||||||
// 创建流量包
|
// 创建流量包
|
||||||
export interface CreateTrafficPackageParams {
|
export interface CreateTrafficPackageParams {
|
||||||
name: string;
|
groupName: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
remarks?: string;
|
groupIcon?: string;
|
||||||
filterConditions: any[];
|
groupColor?: string;
|
||||||
userIds: string[];
|
ruleType: number; // 1=动态规则,2=手动添加
|
||||||
|
ruleConfig?: RuleConfig;
|
||||||
|
memberIds?: number[]; // 手动添加时的成员ID列表
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateTrafficPackageResponse {
|
export interface CreateTrafficPackageResponse {
|
||||||
id: string;
|
id: number;
|
||||||
name: string;
|
groupName: string;
|
||||||
success: boolean;
|
|
||||||
message: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createTrafficPackage(
|
export async function createTrafficPackage(
|
||||||
params: CreateTrafficPackageParams,
|
params: CreateTrafficPackageParams,
|
||||||
): Promise<CreateTrafficPackageResponse> {
|
): Promise<CreateTrafficPackageResponse> {
|
||||||
return request("/v1/traffic/pool/create", params, "POST");
|
// 调用V2接口创建分组
|
||||||
|
return createGroup(params);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取用户列表(根据筛选条件)
|
// 获取用户列表(根据筛选条件)
|
||||||
export interface GetUsersByFilterParams {
|
export interface GetUsersByFilterParams {
|
||||||
conditions: any[];
|
ruleConfig: RuleConfig & { keyword?: string };
|
||||||
page?: number;
|
page?: number;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: string;
|
id: number;
|
||||||
name: string;
|
identifier: string;
|
||||||
avatar: string;
|
nickname: string | null;
|
||||||
|
avatar: string | null;
|
||||||
|
wechatId: string | null;
|
||||||
|
wechatAlias: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
region: string;
|
||||||
|
gender: number;
|
||||||
|
rfmScore: {
|
||||||
|
R: number;
|
||||||
|
F: number;
|
||||||
|
M: number;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
tags: string[];
|
tags: string[];
|
||||||
rfmScore: number;
|
lastInteractTime: number;
|
||||||
lastActive: string;
|
totalMsgCount: number;
|
||||||
consumption: number;
|
totalOrderAmount: string;
|
||||||
|
lifecycle: number;
|
||||||
|
intentionLevel: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GetUsersByFilterResponse {
|
export interface GetUsersByFilterResponse {
|
||||||
list: User[];
|
list: User[];
|
||||||
total: number;
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getUsersByFilter(
|
export async function getUsersByFilter(
|
||||||
params: GetUsersByFilterParams,
|
params: GetUsersByFilterParams,
|
||||||
): Promise<GetUsersByFilterResponse> {
|
): Promise<GetUsersByFilterResponse> {
|
||||||
return request("/v1/traffic/pool/users/filter", params, "POST");
|
// 使用 POST 请求,因为 ruleConfig 是复杂对象
|
||||||
|
return request("/v1/traffic/pool/v2/preview-users", params, "POST");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取预设方案列表
|
// 获取预设方案列表(基于系统分组)
|
||||||
export interface PresetScheme {
|
export interface PresetScheme {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -61,70 +80,58 @@ export interface PresetScheme {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getPresetSchemes(): Promise<PresetScheme[]> {
|
export async function getPresetSchemes(): Promise<PresetScheme[]> {
|
||||||
// 模拟数据
|
try {
|
||||||
return new Promise(resolve => {
|
// 获取所有分组作为推荐方案
|
||||||
setTimeout(() => {
|
const groups = await request("/v1/traffic/pool/v2/groups", {}, "GET");
|
||||||
resolve([
|
|
||||||
{
|
// 去重(根据 id)并过滤掉没有规则配置的分组
|
||||||
id: "scheme_1",
|
const uniqueGroups: any[] = [];
|
||||||
name: "高价值客户方案",
|
const seenIds = new Set<number>();
|
||||||
description: "针对高消费、高活跃度的客户群体",
|
|
||||||
conditions: [
|
for (const group of groups) {
|
||||||
{ id: "rfm_high", type: "rfm", label: "RFM评分", value: "high" },
|
if (!seenIds.has(group.id)) {
|
||||||
{
|
seenIds.add(group.id);
|
||||||
id: "consumption_high",
|
uniqueGroups.push(group);
|
||||||
type: "consumption",
|
}
|
||||||
label: "消费能力",
|
}
|
||||||
value: "high",
|
|
||||||
},
|
// 转换为PresetScheme格式,优先显示系统分组
|
||||||
],
|
const sortedGroups = uniqueGroups.sort((a: any, b: any) => {
|
||||||
userCount: 1250,
|
// 系统分组排前面
|
||||||
color: "#ff4d4f",
|
if (a.isSystem !== b.isSystem) return b.isSystem - a.isSystem;
|
||||||
},
|
// 同类型按 id 排序
|
||||||
{
|
return a.id - b.id;
|
||||||
id: "scheme_2",
|
});
|
||||||
name: "新用户激活方案",
|
|
||||||
description: "针对新注册用户的激活策略",
|
return sortedGroups.map((group: any) => ({
|
||||||
conditions: [
|
id: String(group.id),
|
||||||
{ id: "new_user", type: "tag", label: "新用户", value: true },
|
name: group.groupName,
|
||||||
{
|
description: group.description || (group.isSystem ? `系统预设:${group.groupName}` : `自定义:${group.groupName}`),
|
||||||
id: "low_activity",
|
conditions: group.ruleConfig?.conditions || [],
|
||||||
type: "activity",
|
userCount: group.memberCount || 0,
|
||||||
label: "活跃度",
|
color: group.groupColor || (group.isSystem ? "#1677ff" : "#52c41a"),
|
||||||
value: "low",
|
}));
|
||||||
},
|
} catch (error) {
|
||||||
],
|
console.error("获取预设方案失败:", error);
|
||||||
userCount: 890,
|
// 返回空数组或默认方案
|
||||||
color: "#52c41a",
|
return [];
|
||||||
},
|
}
|
||||||
{
|
|
||||||
id: "scheme_3",
|
|
||||||
name: "流失挽回方案",
|
|
||||||
description: "针对流失风险用户的挽回策略",
|
|
||||||
conditions: [
|
|
||||||
{ id: "churn_risk", type: "tag", label: "流失风险", value: true },
|
|
||||||
{
|
|
||||||
id: "last_active",
|
|
||||||
type: "time",
|
|
||||||
label: "最后活跃",
|
|
||||||
value: "30天前",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
userCount: 567,
|
|
||||||
color: "#faad14",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
}, 500);
|
|
||||||
});
|
|
||||||
// return request("/v1/traffic/pool/schemes", {}, "GET");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取行业选项(固定筛选项)
|
// 获取筛选字段元数据
|
||||||
export interface IndustryOption {
|
export interface FilterField {
|
||||||
|
field: string;
|
||||||
label: string;
|
label: string;
|
||||||
value: string | number;
|
type: 'select' | 'input' | 'number' | 'province' | 'friend_search';
|
||||||
|
options?: { label: string; value: any }[];
|
||||||
|
placeholder?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getIndustryOptions(): Promise<IndustryOption[]> {
|
export async function getFilterFields(): Promise<FilterField[]> {
|
||||||
return request("/v1/traffic/pool/industries", {}, "GET");
|
return request("/v1/traffic/pool/v2/filter-fields", {}, "GET");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兼容旧接口名称
|
||||||
|
export async function getIndustryOptions(): Promise<FilterField[]> {
|
||||||
|
return getFilterFields();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,129 +1,123 @@
|
|||||||
.container {
|
.container {
|
||||||
padding: 0;
|
padding: 12px;
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schemeRow {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.addSchemeBtn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
font-size: 12px;
|
|
||||||
padding: 4px 8px;
|
|
||||||
height: 32px;
|
|
||||||
white-space: nowrap;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.section {
|
.section {
|
||||||
margin-bottom: 24px;
|
background: #fff;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||||
|
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.02);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sectionTitle {
|
.sectionTitle {
|
||||||
font-size: 14px;
|
font-size: 16px;
|
||||||
font-weight: 500;
|
font-weight: 700;
|
||||||
color: #333;
|
color: #1a1a1a;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 16px;
|
||||||
}
|
|
||||||
|
|
||||||
.rfmGrid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr 1fr;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rfmItem {
|
|
||||||
background: #f8f9fa;
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 12px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rfmLabel {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #666;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rfmValue {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ageRange {
|
|
||||||
background: #f8f9fa;
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 12px;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 14px;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.consumptionLevel {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
align-items: center;
|
||||||
}
|
|
||||||
|
|
||||||
.levelTag {
|
|
||||||
background: #52c41a;
|
|
||||||
color: white;
|
|
||||||
padding: 4px 12px;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tagGrid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
|
||||||
|
|
||||||
.tag {
|
&::before {
|
||||||
padding: 8px 12px;
|
content: '';
|
||||||
border-radius: 16px;
|
width: 3px;
|
||||||
color: white;
|
height: 14px;
|
||||||
font-size: 12px;
|
background: #007aff;
|
||||||
text-align: center;
|
border-radius: 2px;
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.addConditionBtn {
|
|
||||||
width: 100%;
|
|
||||||
margin: 16px 0;
|
|
||||||
border-style: dashed;
|
|
||||||
border-color: #d9d9d9;
|
|
||||||
color: #666;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border-color: #1677ff;
|
|
||||||
color: #1677ff;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.generateBtn {
|
.selectWrapper {
|
||||||
margin-top: 16px;
|
:global(.ant-select-selector) {
|
||||||
|
border-radius: 12px !important;
|
||||||
|
background: #f9fafb !important;
|
||||||
|
border: 1px solid transparent !important;
|
||||||
|
height: 48px !important;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 16px !important;
|
||||||
|
|
||||||
|
&:hover, &:focus {
|
||||||
|
border-color: #007aff !important;
|
||||||
|
background: #fff !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.ant-select-selection-placeholder) {
|
||||||
|
line-height: 48px !important;
|
||||||
|
color: #9ca3af !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.ant-select-selection-item) {
|
||||||
|
line-height: 48px !important;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagGrid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagItem {
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #4b5563;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background: #e5e7eb;
|
||||||
|
transform: scale(0.96);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonGroup {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addBtn {
|
||||||
|
flex: 1;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px dashed #d1d5db;
|
||||||
|
color: #007aff;
|
||||||
|
background: #f0f7ff;
|
||||||
|
font-weight: 700;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background: #e0efff;
|
||||||
|
border-color: #007aff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.friendBtn {
|
||||||
|
flex: 1;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px dashed #d1d5db;
|
||||||
|
color: #52c41a;
|
||||||
|
background: #f6ffed;
|
||||||
|
font-weight: 700;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background: #e6ffe0;
|
||||||
|
border-color: #52c41a;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,35 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { Card, Button } from "antd-mobile";
|
import { Button } from "antd-mobile";
|
||||||
import { Select } from "antd";
|
import { Select } from "antd";
|
||||||
import { PlusOutlined } from "@ant-design/icons";
|
import { AddOutline, UserAddOutline } from "antd-mobile-icons";
|
||||||
import CustomConditionModal from "./CustomConditionModal";
|
import CustomConditionModal from "./CustomConditionModal";
|
||||||
|
import FriendSearchModal from "./FriendSearchModal";
|
||||||
import ConditionList from "./ConditionList";
|
import ConditionList from "./ConditionList";
|
||||||
import styles from "./AudienceFilter.module.scss";
|
import styles from "./AudienceFilter.module.scss";
|
||||||
import {
|
import {
|
||||||
getIndustryOptions,
|
getIndustryOptions,
|
||||||
getPresetSchemes,
|
getPresetSchemes,
|
||||||
IndustryOption,
|
FilterField,
|
||||||
PresetScheme,
|
PresetScheme,
|
||||||
} from "../api";
|
} from "../api";
|
||||||
|
|
||||||
|
interface Friend {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
avatar: string;
|
||||||
|
wechatId: string;
|
||||||
|
tags: string[];
|
||||||
|
}
|
||||||
|
|
||||||
interface FilterCondition {
|
interface FilterCondition {
|
||||||
id: string;
|
id: string;
|
||||||
type: string;
|
type: string;
|
||||||
label: string;
|
label: string;
|
||||||
value: any;
|
value: any;
|
||||||
operator?: string;
|
operator?: string;
|
||||||
|
field?: string;
|
||||||
|
displayValue?: string;
|
||||||
|
friends?: Friend[]; // 选中的好友列表
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AudienceFilterProps {
|
interface AudienceFilterProps {
|
||||||
@@ -30,165 +42,137 @@ const AudienceFilter: React.FC<AudienceFilterProps> = ({
|
|||||||
onChange,
|
onChange,
|
||||||
}) => {
|
}) => {
|
||||||
const [showCustomModal, setShowCustomModal] = useState(false);
|
const [showCustomModal, setShowCustomModal] = useState(false);
|
||||||
const [industryOptions, setIndustryOptions] = useState<IndustryOption[]>([]);
|
const [showFriendModal, setShowFriendModal] = useState(false);
|
||||||
|
const [industryOptions, setIndustryOptions] = useState<FilterField[]>([]);
|
||||||
const [presetSchemes, setPresetSchemes] = useState<PresetScheme[]>([]);
|
const [presetSchemes, setPresetSchemes] = useState<PresetScheme[]>([]);
|
||||||
const [selectedIndustry, setSelectedIndustry] = useState<
|
const [selectedScheme, setSelectedScheme] = useState<string | undefined>(undefined);
|
||||||
string | number | undefined
|
|
||||||
>(undefined);
|
|
||||||
const [selectedScheme, setSelectedScheme] = useState<string | undefined>(
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 加载行业选项和方案列表
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getIndustryOptions()
|
getIndustryOptions().then(res => setIndustryOptions(res || []));
|
||||||
.then(res => setIndustryOptions(res || []))
|
getPresetSchemes().then(res => setPresetSchemes(res || []));
|
||||||
.catch(() => setIndustryOptions([]));
|
|
||||||
|
|
||||||
getPresetSchemes()
|
|
||||||
.then(res => setPresetSchemes(res || []))
|
|
||||||
.catch(() => setPresetSchemes([]));
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleAddCondition = (condition: FilterCondition) => {
|
|
||||||
const newConditions = [...conditions, condition];
|
|
||||||
onChange(newConditions);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemoveCondition = (id: string) => {
|
|
||||||
const newConditions = conditions.filter(c => c.id !== id);
|
|
||||||
onChange(newConditions);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUpdateCondition = (id: string, value: any) => {
|
|
||||||
const newConditions = conditions.map(c =>
|
|
||||||
c.id === id ? { ...c, value } : c,
|
|
||||||
);
|
|
||||||
onChange(newConditions);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSchemeChange = (schemeId: string) => {
|
const handleSchemeChange = (schemeId: string) => {
|
||||||
setSelectedScheme(schemeId);
|
setSelectedScheme(schemeId);
|
||||||
if (schemeId) {
|
if (schemeId) {
|
||||||
// 找到选中的方案并应用其条件
|
|
||||||
const scheme = presetSchemes.find(s => s.id === schemeId);
|
const scheme = presetSchemes.find(s => s.id === schemeId);
|
||||||
if (scheme) {
|
if (scheme) {
|
||||||
onChange(scheme.conditions);
|
onChange(scheme.conditions);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 清空方案选择时,清空条件
|
|
||||||
onChange([]);
|
onChange([]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddScheme = () => {
|
// 获取已选中的好友(从已有条件中提取)
|
||||||
// 这里可以打开添加方案的弹窗或跳转到方案管理页面
|
const getSelectedFriends = (): Friend[] => {
|
||||||
console.log("添加新方案");
|
const friendCondition = conditions.find(c => c.field === 'friendIds');
|
||||||
|
return friendCondition?.friends || [];
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理好友选择确认
|
||||||
|
const handleFriendsConfirm = (friends: Friend[]) => {
|
||||||
|
// 移除旧的好友条件
|
||||||
|
const newConditions = conditions.filter(c => c.field !== 'friendIds');
|
||||||
|
|
||||||
|
if (friends.length > 0) {
|
||||||
|
// 添加新的好友条件
|
||||||
|
const friendCondition: FilterCondition = {
|
||||||
|
id: 'friendIds',
|
||||||
|
type: 'field',
|
||||||
|
field: 'friendIds',
|
||||||
|
label: '指定好友',
|
||||||
|
operator: 'in',
|
||||||
|
value: friends.map(f => f.id),
|
||||||
|
displayValue: `已选 ${friends.length} 人`,
|
||||||
|
friends: friends,
|
||||||
|
};
|
||||||
|
newConditions.push(friendCondition);
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange(newConditions);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理条件添加(排除好友搜索类型,因为有专门的按钮)
|
||||||
|
const handleAddCondition = (condition: FilterCondition) => {
|
||||||
|
// 如果是搜索好友条件,打开好友选择弹窗
|
||||||
|
if (condition.field === 'keyword' && condition.fieldType === 'friend_search') {
|
||||||
|
setShowFriendModal(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onChange([...conditions, condition]);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<Card className={styles.card}>
|
<div className={styles.section}>
|
||||||
<div className={styles.header}>
|
<div className={styles.sectionTitle}>人群方案</div>
|
||||||
<div className={styles.title}>人群筛选</div>
|
<div className={styles.selectWrapper}>
|
||||||
|
<Select
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
placeholder="选择预设方案"
|
||||||
|
value={selectedScheme}
|
||||||
|
onChange={handleSchemeChange}
|
||||||
|
options={presetSchemes.map(scheme => ({
|
||||||
|
label: `${scheme.name} (${scheme.userCount}人)`,
|
||||||
|
value: scheme.id,
|
||||||
|
}))}
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 方案推荐选择 */}
|
<div className={styles.section}>
|
||||||
<div className={styles.section}>
|
<div className={styles.sectionTitle}>自定义条件</div>
|
||||||
<div className={styles.sectionTitle}>方案推荐</div>
|
<ConditionList
|
||||||
<div className={styles.schemeRow}>
|
conditions={conditions}
|
||||||
<Select
|
onRemove={(condition) => {
|
||||||
style={{ flex: 1 }}
|
const newConditions = conditions.filter(c =>
|
||||||
placeholder="选择预设方案"
|
c.field ? c.field !== condition.field : c.id !== condition.id
|
||||||
value={selectedScheme}
|
);
|
||||||
onChange={handleSchemeChange}
|
onChange(newConditions);
|
||||||
options={presetSchemes.map(scheme => ({
|
}}
|
||||||
label: `${scheme.name} (${scheme.userCount}人)`,
|
/>
|
||||||
value: scheme.id,
|
<div className={styles.buttonGroup}>
|
||||||
}))}
|
<Button
|
||||||
allowClear
|
fill="none"
|
||||||
/>
|
className={styles.addBtn}
|
||||||
<Button
|
onClick={() => setShowCustomModal(true)}
|
||||||
size="small"
|
>
|
||||||
fill="outline"
|
<AddOutline style={{ marginRight: 4 }} /> 添加筛选条件
|
||||||
onClick={handleAddScheme}
|
</Button>
|
||||||
className={styles.addSchemeBtn}
|
<Button
|
||||||
>
|
fill="none"
|
||||||
<PlusOutlined />
|
className={styles.friendBtn}
|
||||||
添加方案
|
onClick={() => setShowFriendModal(true)}
|
||||||
</Button>
|
>
|
||||||
</div>
|
<UserAddOutline style={{ marginRight: 4 }} /> 选择好友
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 条件筛选区域 - 当未选择方案时显示 */}
|
<div className={styles.section}>
|
||||||
{!selectedScheme && (
|
<div className={styles.sectionTitle}>常用业务标签</div>
|
||||||
<>
|
<div className={styles.tagGrid}>
|
||||||
{/* 行业筛选(固定项,接口获取选项) */}
|
{["高价值", "新客", "活跃", "待唤醒", "价格敏感", "忠诚"].map((tag, idx) => (
|
||||||
<div className={styles.section}>
|
<div key={idx} className={styles.tagItem}>{tag}</div>
|
||||||
<div className={styles.sectionTitle}>行业</div>
|
))}
|
||||||
<Select
|
</div>
|
||||||
style={{ width: "100%" }}
|
</div>
|
||||||
placeholder="选择行业"
|
|
||||||
value={selectedIndustry}
|
|
||||||
onChange={value => setSelectedIndustry(value)}
|
|
||||||
options={industryOptions.map(opt => ({
|
|
||||||
label: opt.label,
|
|
||||||
value: opt.value,
|
|
||||||
}))}
|
|
||||||
allowClear
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 标签筛选 */}
|
|
||||||
<div className={styles.section}>
|
|
||||||
<div className={styles.sectionTitle}>标签筛选</div>
|
|
||||||
<div className={styles.tagGrid}>
|
|
||||||
{[
|
|
||||||
{ name: "高价值用户", color: "#1677ff" },
|
|
||||||
{ name: "新用户", color: "#52c41a" },
|
|
||||||
{ name: "活跃用户", color: "#faad14" },
|
|
||||||
{ name: "流失风险", color: "#eb2f96" },
|
|
||||||
{ name: "复购率高", color: "#722ed1" },
|
|
||||||
{ name: "高潜力", color: "#eb2f96" },
|
|
||||||
{ name: "已沉睡", color: "#bfbfbf" },
|
|
||||||
{ name: "价格敏感", color: "#13c2c2" },
|
|
||||||
].map((tag, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className={styles.tag}
|
|
||||||
style={{ backgroundColor: tag.color }}
|
|
||||||
>
|
|
||||||
{tag.name}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 自定义条件列表 */}
|
|
||||||
<ConditionList
|
|
||||||
conditions={conditions}
|
|
||||||
onRemove={handleRemoveCondition}
|
|
||||||
onUpdate={handleUpdateCondition}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 添加自定义条件 */}
|
|
||||||
<Button
|
|
||||||
fill="outline"
|
|
||||||
onClick={() => setShowCustomModal(true)}
|
|
||||||
className={styles.addConditionBtn}
|
|
||||||
>
|
|
||||||
+ 添加自定义条件
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 自定义条件弹窗 */}
|
|
||||||
<CustomConditionModal
|
<CustomConditionModal
|
||||||
visible={showCustomModal}
|
visible={showCustomModal}
|
||||||
onClose={() => setShowCustomModal(false)}
|
onClose={() => setShowCustomModal(false)}
|
||||||
onAdd={handleAddCondition}
|
onAdd={handleAddCondition}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<FriendSearchModal
|
||||||
|
visible={showFriendModal}
|
||||||
|
onClose={() => setShowFriendModal(false)}
|
||||||
|
onConfirm={handleFriendsConfirm}
|
||||||
|
existingConditions={conditions.filter(c => c.field !== 'friendIds')}
|
||||||
|
selectedFriends={getSelectedFriends()}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,60 +1,75 @@
|
|||||||
.container {
|
.container {
|
||||||
padding: 0;
|
padding: 16px;
|
||||||
|
background: #f8fafc;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
border-radius: 8px;
|
background: #fff;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
// 输入框样式覆盖
|
||||||
|
:global(.adm-input), :global(.adm-text-area) {
|
||||||
|
--font-size: 14px;
|
||||||
|
background: #f8fafc !important;
|
||||||
|
border: 1px solid #e2e8f0 !important;
|
||||||
|
border-radius: 8px !important;
|
||||||
|
padding: 10px 12px !important;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:focus-within {
|
||||||
|
border-color: #3b82f6 !important;
|
||||||
|
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1) !important;
|
||||||
|
background: #fff !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.adm-input-element), :global(.adm-text-area-element) {
|
||||||
|
background: transparent !important;
|
||||||
|
border: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.adm-input-element::placeholder), :global(.adm-text-area-element::placeholder) {
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.sectionTitle {
|
||||||
font-size: 16px;
|
font-size: 17px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #333;
|
margin-bottom: 18px;
|
||||||
margin-bottom: 20px;
|
color: #222;
|
||||||
}
|
}
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
color: #333;
|
font-size: 15px;
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: #222;
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.required {
|
.required {
|
||||||
color: #ff4d4f;
|
color: #ef4444;
|
||||||
margin-left: 2px;
|
margin-left: 4px;
|
||||||
}
|
|
||||||
|
|
||||||
.input {
|
|
||||||
border: 1px solid #d9d9d9;
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
font-size: 14px;
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
border-color: #1677ff;
|
|
||||||
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.textarea {
|
|
||||||
border: 1px solid #d9d9d9;
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
font-size: 14px;
|
|
||||||
resize: vertical;
|
|
||||||
min-height: 80px;
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
border-color: #1677ff;
|
|
||||||
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.adm-form-item) {
|
:global(.adm-form-item) {
|
||||||
|
padding: 0;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.adm-form-item-label) {
|
:global(.adm-form-item-label) {
|
||||||
margin-bottom: 8px;
|
padding-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Card, Form, Input } from "antd-mobile";
|
import { Form, Input, TextArea } from "antd-mobile";
|
||||||
import styles from "./BasicInfo.module.scss";
|
import styles from "./BasicInfo.module.scss";
|
||||||
|
|
||||||
interface BasicInfoProps {
|
interface BasicInfoProps {
|
||||||
@@ -18,46 +18,49 @@ const BasicInfo: React.FC<BasicInfoProps> = ({ data, onChange }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<Card className={styles.card}>
|
<div className={styles.card}>
|
||||||
<div className={styles.title}>基本信息</div>
|
<div className={styles.sectionTitle}>计划设置</div>
|
||||||
|
|
||||||
<Form layout="vertical">
|
<Form layout="vertical">
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label={
|
label={
|
||||||
<span className={styles.label}>
|
<span className={styles.label}>
|
||||||
流量包名称<span className={styles.required}>*</span>
|
计划名称<span className={styles.required}>*</span>
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
required
|
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
placeholder="输入流量包名称"
|
placeholder="请输入计划名称"
|
||||||
value={data.name}
|
value={data.name}
|
||||||
onChange={value => handleChange("name", value)}
|
onChange={value => handleChange("name", value)}
|
||||||
className={styles.input}
|
clearable
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item label={<span className={styles.label}>描述</span>}>
|
<Form.Item
|
||||||
|
label={<span className={styles.label}>计划描述</span>}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
placeholder="输入流量包描述"
|
placeholder="请输入计划描述 (可选)"
|
||||||
value={data.description}
|
value={data.description}
|
||||||
onChange={value => handleChange("description", value)}
|
onChange={value => handleChange("description", value)}
|
||||||
className={styles.input}
|
clearable
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item label={<span className={styles.label}>备注</span>}>
|
<Form.Item
|
||||||
<Input
|
label={<span className={styles.label}>详细备注</span>}
|
||||||
placeholder="输入备注信息 (选填)"
|
>
|
||||||
|
<TextArea
|
||||||
|
placeholder="请输入备注说明..."
|
||||||
value={data.remarks}
|
value={data.remarks}
|
||||||
onChange={value => handleChange("remarks", value)}
|
onChange={value => handleChange("remarks", value)}
|
||||||
className={styles.textarea}
|
rows={4}
|
||||||
rows={3}
|
autoSize={{ minRows: 4, maxRows: 8 }}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
</Card>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,52 +1,49 @@
|
|||||||
.container {
|
.container {
|
||||||
margin-bottom: 24px;
|
margin-top: 16px;
|
||||||
}
|
padding: 0 4px;
|
||||||
|
|
||||||
.title {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #333;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.conditionList {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.conditionItem {
|
.conditionItem {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 12px;
|
padding: 14px 16px;
|
||||||
background: #f8f9fa;
|
background: #fff;
|
||||||
border-radius: 6px;
|
border-radius: 12px;
|
||||||
border: 1px solid #e9ecef;
|
border: 1px solid #f3f4f6;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.02);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background: #f9fafb;
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.conditionContent {
|
.conditionContent {
|
||||||
display: flex;
|
flex: 1;
|
||||||
align-items: center;
|
font-size: 15px;
|
||||||
gap: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.conditionLabel {
|
.label {
|
||||||
font-size: 14px;
|
color: #6b7280;
|
||||||
color: #666;
|
margin-right: 10px;
|
||||||
}
|
|
||||||
|
|
||||||
.conditionValue {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #333;
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
color: #111827;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.removeBtn {
|
.removeBtn {
|
||||||
color: #ff4d4f;
|
color: #9ca3af;
|
||||||
padding: 4px;
|
padding: 6px;
|
||||||
|
--font-size: 20px;
|
||||||
|
transition: color 0.2s;
|
||||||
|
|
||||||
&:hover {
|
&:active {
|
||||||
background-color: #fff2f0;
|
color: #ef4444;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,65 +1,44 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Button } from "antd-mobile";
|
import { Button } from "antd-mobile";
|
||||||
import { DeleteOutline } from "antd-mobile-icons";
|
import { CloseCircleOutline } from "antd-mobile-icons";
|
||||||
import styles from "./ConditionList.module.scss";
|
import styles from "./ConditionList.module.scss";
|
||||||
|
|
||||||
interface FilterCondition {
|
interface FilterCondition {
|
||||||
id: string;
|
id?: string;
|
||||||
type: string;
|
type: string;
|
||||||
|
field?: string;
|
||||||
label: string;
|
label: string;
|
||||||
value: any;
|
value: any;
|
||||||
operator?: string;
|
displayValue?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ConditionListProps {
|
interface ConditionListProps {
|
||||||
conditions: FilterCondition[];
|
conditions: FilterCondition[];
|
||||||
onRemove: (id: string) => void;
|
onRemove: (condition: any) => void;
|
||||||
onUpdate: (id: string, value: any) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ConditionList: React.FC<ConditionListProps> = ({
|
const ConditionList: React.FC<ConditionListProps> = ({ conditions, onRemove }) => {
|
||||||
conditions,
|
if (conditions.length === 0) return null;
|
||||||
onRemove,
|
|
||||||
onUpdate,
|
|
||||||
}) => {
|
|
||||||
const formatConditionValue = (condition: FilterCondition) => {
|
|
||||||
switch (condition.type) {
|
|
||||||
case "range":
|
|
||||||
return `${condition.value.min || 0}-${condition.value.max || 0}岁`;
|
|
||||||
case "select":
|
|
||||||
return condition.value;
|
|
||||||
default:
|
|
||||||
return condition.value;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (conditions.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div className={styles.title}>自定义条件</div>
|
{conditions.map((condition, index) => (
|
||||||
<div className={styles.conditionList}>
|
<div key={condition.id || condition.field || index} className={styles.conditionItem}>
|
||||||
{conditions.map(condition => (
|
|
||||||
<div key={condition.id} className={styles.conditionItem}>
|
|
||||||
<div className={styles.conditionContent}>
|
<div className={styles.conditionContent}>
|
||||||
<span className={styles.conditionLabel}>{condition.label}:</span>
|
<span className={styles.label}>{condition.label}</span>
|
||||||
<span className={styles.conditionValue}>
|
<span className={styles.value}>
|
||||||
{formatConditionValue(condition)}
|
{condition.displayValue || condition.value}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
|
||||||
fill="none"
|
fill="none"
|
||||||
onClick={() => onRemove(condition.id)}
|
|
||||||
className={styles.removeBtn}
|
className={styles.removeBtn}
|
||||||
|
onClick={() => onRemove(condition)}
|
||||||
>
|
>
|
||||||
<DeleteOutline />
|
<CloseCircleOutline />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,79 +2,247 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
background: #f4f7f9;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 16px;
|
padding: 20px 24px;
|
||||||
border-bottom: 1px solid #f0f0f0;
|
background: #fff;
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-size: 16px;
|
font-size: 18px;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
color: #333;
|
color: #1a1a1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 16px;
|
padding: 20px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section {
|
.section {
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
|
background: #fff;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid #f3f4f6;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.02);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sectionTitle {
|
.sectionTitle {
|
||||||
font-size: 14px;
|
font-size: 15px;
|
||||||
font-weight: 500;
|
font-weight: 700;
|
||||||
color: #333;
|
color: #4b5563;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
width: 3px;
|
||||||
|
height: 14px;
|
||||||
|
background: #007aff;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.tagList {
|
.tagList {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(2, 1fr);
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tagItem {
|
|
||||||
padding: 12px;
|
|
||||||
border: 1px solid #d9d9d9;
|
|
||||||
border-radius: 6px;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 14px;
|
|
||||||
color: #333;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border-color: #1677ff;
|
|
||||||
color: #1677ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.selected {
|
|
||||||
border-color: #1677ff;
|
|
||||||
background-color: #e6f7ff;
|
|
||||||
color: #1677ff;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.rangeInputs {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rangeSeparator {
|
.tagItem {
|
||||||
color: #666;
|
padding: 14px;
|
||||||
font-weight: 500;
|
border: 1px solid #f3f4f6;
|
||||||
|
border-radius: 12px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #4b5563;
|
||||||
|
background: #f9fafb;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background: #f3f4f6;
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
border-color: #007aff;
|
||||||
|
background-color: #f0f7ff;
|
||||||
|
color: #007aff;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 122, 255, 0.15);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer {
|
.footer {
|
||||||
padding: 16px;
|
padding: 16px 24px;
|
||||||
border-top: 1px solid #f0f0f0;
|
padding-bottom: calc(16px + env(safe-area-inset-bottom));
|
||||||
|
background: #fff;
|
||||||
|
border-top: 1px solid #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submitBtn {
|
||||||
|
height: 50px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: #007aff;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 122, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 钻取式地区选择器
|
||||||
|
.regionDrillDown {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regionDrillHeader {
|
||||||
|
padding: 16px 20px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: 1px solid #f5f5f5;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regionDrillTitle {
|
||||||
|
font-size: 17px;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backBtn {
|
||||||
|
color: #007aff;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 0;
|
||||||
|
--font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.closeBtn {
|
||||||
|
color: #999;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regionDrillContent {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flatList {
|
||||||
|
--adm-font-size-main: 16px;
|
||||||
|
|
||||||
|
:global(.adm-list-item-content) {
|
||||||
|
padding: 14px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.adm-list-item) {
|
||||||
|
--active-background-color: #f9fafb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.allProvinceOption {
|
||||||
|
:global(.adm-list-item-content-main) {
|
||||||
|
color: #007aff;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.listDivider {
|
||||||
|
padding: 10px 20px;
|
||||||
|
background: #f4f7f9;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 统一输入控件样式
|
||||||
|
.inputWrapper {
|
||||||
|
background: #f9fafb;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:focus-within {
|
||||||
|
background: #fff;
|
||||||
|
border-color: #007aff;
|
||||||
|
box-shadow: 0 0 0 4px rgba(0, 122, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.adm-input) {
|
||||||
|
--font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 触发器样式
|
||||||
|
.nativeTrigger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px;
|
||||||
|
background: #f9fafb;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 15px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background: #f1f5f9;
|
||||||
|
transform: scale(0.99);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.triggerLabel {
|
||||||
|
color: #64748b;
|
||||||
|
font-weight: 600;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.triggerValueActive {
|
||||||
|
flex: 1;
|
||||||
|
color: #1e293b;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.triggerPlaceholder {
|
||||||
|
flex: 1;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.triggerArrow {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 20px;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 强制覆盖边框
|
||||||
|
:global(.adm-input-element), :global(.adm-text-area-element) {
|
||||||
|
border: none !important;
|
||||||
|
background: transparent !important;
|
||||||
|
outline: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchTip {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #94a3b8;
|
||||||
|
padding-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.adm-selector) {
|
||||||
|
--border-radius: 12px;
|
||||||
|
--padding: 12px 16px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,144 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { Popup, Form, Input, Selector, Button } from "antd-mobile";
|
import { Popup, Button, Input, Selector, List } from "antd-mobile";
|
||||||
import styles from "./CustomConditionModal.module.scss";
|
import styles from "./CustomConditionModal.module.scss";
|
||||||
|
import { getIndustryOptions, FilterField } from "../api";
|
||||||
|
|
||||||
|
// 省市数据
|
||||||
|
const areaData = [
|
||||||
|
{ label: '北京', value: '110000', children: [{ label: '北京市', value: '110100' }] },
|
||||||
|
{ label: '天津', value: '120000', children: [{ label: '天津市', value: '120100' }] },
|
||||||
|
{ label: '河北', value: '130000', children: [
|
||||||
|
{ label: '石家庄市', value: '130100' }, { label: '唐山市', value: '130200' },
|
||||||
|
{ label: '秦皇岛市', value: '130300' }, { label: '邯郸市', value: '130400' },
|
||||||
|
{ label: '邢台市', value: '130500' }, { label: '保定市', value: '130600' },
|
||||||
|
{ label: '张家口市', value: '130700' }, { label: '承德市', value: '130800' },
|
||||||
|
{ label: '沧州市', value: '130900' }, { label: '廊坊市', value: '131000' },
|
||||||
|
]},
|
||||||
|
{ label: '山西', value: '140000', children: [
|
||||||
|
{ label: '太原市', value: '140100' }, { label: '大同市', value: '140200' },
|
||||||
|
{ label: '阳泉市', value: '140300' }, { label: '长治市', value: '140400' },
|
||||||
|
{ label: '晋城市', value: '140500' }, { label: '朔州市', value: '140600' },
|
||||||
|
]},
|
||||||
|
{ label: '内蒙古', value: '150000', children: [
|
||||||
|
{ label: '呼和浩特市', value: '150100' }, { label: '包头市', value: '150200' },
|
||||||
|
{ label: '乌海市', value: '150300' }, { label: '赤峰市', value: '150400' },
|
||||||
|
]},
|
||||||
|
{ label: '辽宁', value: '210000', children: [
|
||||||
|
{ label: '沈阳市', value: '210100' }, { label: '大连市', value: '210200' },
|
||||||
|
{ label: '鞍山市', value: '210300' }, { label: '抚顺市', value: '210400' },
|
||||||
|
]},
|
||||||
|
{ label: '吉林', value: '220000', children: [
|
||||||
|
{ label: '长春市', value: '220100' }, { label: '吉林市', value: '220200' },
|
||||||
|
{ label: '四平市', value: '220300' }, { label: '辽源市', value: '220400' },
|
||||||
|
]},
|
||||||
|
{ label: '黑龙江', value: '230000', children: [
|
||||||
|
{ label: '哈尔滨市', value: '230100' }, { label: '齐齐哈尔市', value: '230200' },
|
||||||
|
{ label: '鸡西市', value: '230300' }, { label: '鹤岗市', value: '230400' },
|
||||||
|
]},
|
||||||
|
{ label: '上海', value: '310000', children: [{ label: '上海市', value: '310100' }] },
|
||||||
|
{ label: '江苏', value: '320000', children: [
|
||||||
|
{ label: '南京市', value: '320100' }, { label: '无锡市', value: '320200' },
|
||||||
|
{ label: '徐州市', value: '320300' }, { label: '常州市', value: '320400' },
|
||||||
|
{ label: '苏州市', value: '320500' }, { label: '南通市', value: '320600' },
|
||||||
|
]},
|
||||||
|
{ label: '浙江', value: '330000', children: [
|
||||||
|
{ label: '杭州市', value: '330100' }, { label: '宁波市', value: '330200' },
|
||||||
|
{ label: '温州市', value: '330300' }, { label: '嘉兴市', value: '330400' },
|
||||||
|
{ label: '湖州市', value: '330500' }, { label: '绍兴市', value: '330600' },
|
||||||
|
]},
|
||||||
|
{ label: '安徽', value: '340000', children: [
|
||||||
|
{ label: '合肥市', value: '340100' }, { label: '芜湖市', value: '340200' },
|
||||||
|
{ label: '蚌埠市', value: '340300' }, { label: '淮南市', value: '340400' },
|
||||||
|
]},
|
||||||
|
{ label: '福建', value: '350000', children: [
|
||||||
|
{ label: '福州市', value: '350100' }, { label: '厦门市', value: '350200' },
|
||||||
|
{ label: '莆田市', value: '350300' }, { label: '三明市', value: '350400' },
|
||||||
|
{ label: '泉州市', value: '350500' }, { label: '漳州市', value: '350600' },
|
||||||
|
]},
|
||||||
|
{ label: '江西', value: '360000', children: [
|
||||||
|
{ label: '南昌市', value: '360100' }, { label: '景德镇市', value: '360200' },
|
||||||
|
{ label: '萍乡市', value: '360300' }, { label: '九江市', value: '360400' },
|
||||||
|
]},
|
||||||
|
{ label: '山东', value: '370000', children: [
|
||||||
|
{ label: '济南市', value: '370100' }, { label: '青岛市', value: '370200' },
|
||||||
|
{ label: '淄博市', value: '370300' }, { label: '枣庄市', value: '370400' },
|
||||||
|
{ label: '东营市', value: '370500' }, { label: '烟台市', value: '370600' },
|
||||||
|
]},
|
||||||
|
{ label: '河南', value: '410000', children: [
|
||||||
|
{ label: '郑州市', value: '410100' }, { label: '开封市', value: '410200' },
|
||||||
|
{ label: '洛阳市', value: '410300' }, { label: '平顶山市', value: '410400' },
|
||||||
|
{ label: '安阳市', value: '410500' }, { label: '鹤壁市', value: '410600' },
|
||||||
|
{ label: '新乡市', value: '410700' }, { label: '焦作市', value: '410800' },
|
||||||
|
{ label: '濮阳市', value: '410900' }, { label: '许昌市', value: '411000' },
|
||||||
|
{ label: '漯河市', value: '411100' }, { label: '三门峡市', value: '411200' },
|
||||||
|
{ label: '南阳市', value: '411300' }, { label: '商丘市', value: '411400' },
|
||||||
|
{ label: '信阳市', value: '411500' }, { label: '周口市', value: '411600' },
|
||||||
|
{ label: '驻马店市', value: '411700' },
|
||||||
|
]},
|
||||||
|
{ label: '湖北', value: '420000', children: [
|
||||||
|
{ label: '武汉市', value: '420100' }, { label: '黄石市', value: '420200' },
|
||||||
|
{ label: '十堰市', value: '420300' }, { label: '宜昌市', value: '420500' },
|
||||||
|
]},
|
||||||
|
{ label: '湖南', value: '430000', children: [
|
||||||
|
{ label: '长沙市', value: '430100' }, { label: '株洲市', value: '430200' },
|
||||||
|
{ label: '湘潭市', value: '430300' }, { label: '衡阳市', value: '430400' },
|
||||||
|
]},
|
||||||
|
{ label: '广东', value: '440000', children: [
|
||||||
|
{ label: '广州市', value: '440100' }, { label: '韶关市', value: '440200' },
|
||||||
|
{ label: '深圳市', value: '440300' }, { label: '珠海市', value: '440400' },
|
||||||
|
{ label: '汕头市', value: '440500' }, { label: '佛山市', value: '440600' },
|
||||||
|
{ label: '江门市', value: '440700' }, { label: '湛江市', value: '440800' },
|
||||||
|
{ label: '茂名市', value: '440900' }, { label: '肇庆市', value: '441200' },
|
||||||
|
{ label: '惠州市', value: '441300' }, { label: '梅州市', value: '441400' },
|
||||||
|
{ label: '汕尾市', value: '441500' }, { label: '河源市', value: '441600' },
|
||||||
|
{ label: '阳江市', value: '441700' }, { label: '清远市', value: '441800' },
|
||||||
|
{ label: '东莞市', value: '441900' }, { label: '中山市', value: '442000' },
|
||||||
|
]},
|
||||||
|
{ label: '广西', value: '450000', children: [
|
||||||
|
{ label: '南宁市', value: '450100' }, { label: '柳州市', value: '450200' },
|
||||||
|
{ label: '桂林市', value: '450300' }, { label: '梧州市', value: '450400' },
|
||||||
|
]},
|
||||||
|
{ label: '海南', value: '460000', children: [
|
||||||
|
{ label: '海口市', value: '460100' }, { label: '三亚市', value: '460200' },
|
||||||
|
]},
|
||||||
|
{ label: '重庆', value: '500000', children: [{ label: '重庆市', value: '500100' }] },
|
||||||
|
{ label: '四川', value: '510000', children: [
|
||||||
|
{ label: '成都市', value: '510100' }, { label: '自贡市', value: '510300' },
|
||||||
|
{ label: '攀枝花市', value: '510400' }, { label: '泸州市', value: '510500' },
|
||||||
|
{ label: '德阳市', value: '510600' }, { label: '绵阳市', value: '510700' },
|
||||||
|
]},
|
||||||
|
{ label: '贵州', value: '520000', children: [
|
||||||
|
{ label: '贵阳市', value: '520100' }, { label: '六盘水市', value: '520200' },
|
||||||
|
{ label: '遵义市', value: '520300' }, { label: '安顺市', value: '520400' },
|
||||||
|
]},
|
||||||
|
{ label: '云南', value: '530000', children: [
|
||||||
|
{ label: '昆明市', value: '530100' }, { label: '曲靖市', value: '530300' },
|
||||||
|
{ label: '玉溪市', value: '530400' }, { label: '保山市', value: '530500' },
|
||||||
|
]},
|
||||||
|
{ label: '西藏', value: '540000', children: [
|
||||||
|
{ label: '拉萨市', value: '540100' }, { label: '日喀则市', value: '540200' },
|
||||||
|
]},
|
||||||
|
{ label: '陕西', value: '610000', children: [
|
||||||
|
{ label: '西安市', value: '610100' }, { label: '铜川市', value: '610200' },
|
||||||
|
{ label: '宝鸡市', value: '610300' }, { label: '咸阳市', value: '610400' },
|
||||||
|
]},
|
||||||
|
{ label: '甘肃', value: '620000', children: [
|
||||||
|
{ label: '兰州市', value: '620100' }, { label: '嘉峪关市', value: '620200' },
|
||||||
|
{ label: '金昌市', value: '620300' }, { label: '白银市', value: '620400' },
|
||||||
|
]},
|
||||||
|
{ label: '青海', value: '630000', children: [
|
||||||
|
{ label: '西宁市', value: '630100' }, { label: '海东市', value: '630200' },
|
||||||
|
]},
|
||||||
|
{ label: '宁夏', value: '640000', children: [
|
||||||
|
{ label: '银川市', value: '640100' }, { label: '石嘴山市', value: '640200' },
|
||||||
|
]},
|
||||||
|
{ label: '新疆', value: '650000', children: [
|
||||||
|
{ label: '乌鲁木齐市', value: '650100' }, { label: '克拉玛依市', value: '650200' },
|
||||||
|
]},
|
||||||
|
{ label: '台湾', value: '710000', children: [{ label: '台北市', value: '710100' }] },
|
||||||
|
{ label: '香港', value: '810000', children: [{ label: '香港特别行政区', value: '810100' }] },
|
||||||
|
{ label: '澳门', value: '820000', children: [{ label: '澳门特别行政区', value: '820100' }] },
|
||||||
|
];
|
||||||
|
|
||||||
interface CustomConditionModalProps {
|
interface CustomConditionModalProps {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
@@ -8,111 +146,31 @@ interface CustomConditionModalProps {
|
|||||||
onAdd: (condition: any) => void;
|
onAdd: (condition: any) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 模拟标签数据
|
|
||||||
const mockTags = [
|
|
||||||
{ id: "age", name: "年龄层", type: "range", options: [] },
|
|
||||||
{
|
|
||||||
id: "consumption",
|
|
||||||
name: "消费能力",
|
|
||||||
type: "select",
|
|
||||||
options: [
|
|
||||||
{ label: "高", value: "high" },
|
|
||||||
{ label: "中", value: "medium" },
|
|
||||||
{ label: "低", value: "low" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "gender",
|
|
||||||
name: "性别",
|
|
||||||
type: "select",
|
|
||||||
options: [
|
|
||||||
{ label: "男", value: "male" },
|
|
||||||
{ label: "女", value: "female" },
|
|
||||||
{ label: "未知", value: "unknown" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "location",
|
|
||||||
name: "所在地区",
|
|
||||||
type: "select",
|
|
||||||
options: [
|
|
||||||
{ label: "厦门", value: "xiamen" },
|
|
||||||
{ label: "泉州", value: "quanzhou" },
|
|
||||||
{ label: "福州", value: "fuzhou" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "source",
|
|
||||||
name: "客户来源",
|
|
||||||
type: "select",
|
|
||||||
options: [
|
|
||||||
{ label: "抖音", value: "douyin" },
|
|
||||||
{ label: "门店扫码", value: "store" },
|
|
||||||
{ label: "朋友推荐", value: "referral" },
|
|
||||||
{ label: "广告投放", value: "ad" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "frequency",
|
|
||||||
name: "消费频率",
|
|
||||||
type: "select",
|
|
||||||
options: [
|
|
||||||
{ label: "高频(>3次/月)", value: "high" },
|
|
||||||
{ label: "中频", value: "medium" },
|
|
||||||
{ label: "低频", value: "low" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "sensitivity",
|
|
||||||
name: "优惠敏感度",
|
|
||||||
type: "select",
|
|
||||||
options: [
|
|
||||||
{ label: "高", value: "high" },
|
|
||||||
{ label: "中", value: "medium" },
|
|
||||||
{ label: "低", value: "low" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "category",
|
|
||||||
name: "品类偏好",
|
|
||||||
type: "select",
|
|
||||||
options: [
|
|
||||||
{ label: "护肤", value: "skincare" },
|
|
||||||
{ label: "茶饮", value: "tea" },
|
|
||||||
{ label: "宠物", value: "pet" },
|
|
||||||
{ label: "课程", value: "course" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "repurchase",
|
|
||||||
name: "复购行为",
|
|
||||||
type: "select",
|
|
||||||
options: [
|
|
||||||
{ label: "有", value: "yes" },
|
|
||||||
{ label: "无", value: "no" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "satisfaction",
|
|
||||||
name: "售后满意度",
|
|
||||||
type: "select",
|
|
||||||
options: [
|
|
||||||
{ label: "好评", value: "good" },
|
|
||||||
{ label: "一般", value: "average" },
|
|
||||||
{ label: "差评", value: "bad" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const CustomConditionModal: React.FC<CustomConditionModalProps> = ({
|
const CustomConditionModal: React.FC<CustomConditionModalProps> = ({
|
||||||
visible,
|
visible,
|
||||||
onClose,
|
onClose,
|
||||||
onAdd,
|
onAdd,
|
||||||
}) => {
|
}) => {
|
||||||
const [selectedTag, setSelectedTag] = useState<any>(null);
|
const [fields, setFields] = useState<FilterField[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [selectedTag, setSelectedTag] = useState<FilterField | null>(null);
|
||||||
const [conditionValue, setConditionValue] = useState<any>(null);
|
const [conditionValue, setConditionValue] = useState<any>(null);
|
||||||
|
|
||||||
const handleTagSelect = (tag: any) => {
|
// 地区选择器状态
|
||||||
|
const [regionVisible, setRegionVisible] = useState(false);
|
||||||
|
const [regionStep, setRegionStep] = useState<'province' | 'city'>('province');
|
||||||
|
const [tempProvince, setTempProvince] = useState<any>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
setLoading(true);
|
||||||
|
getIndustryOptions()
|
||||||
|
.then(res => setFields(res || []))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
const handleTagSelect = (tag: FilterField) => {
|
||||||
setSelectedTag(tag);
|
setSelectedTag(tag);
|
||||||
setConditionValue(null);
|
setConditionValue(null);
|
||||||
};
|
};
|
||||||
@@ -122,13 +180,29 @@ const CustomConditionModal: React.FC<CustomConditionModalProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
if (!selectedTag || !conditionValue) return;
|
if (!selectedTag || (conditionValue === null || conditionValue === undefined || conditionValue === '')) return;
|
||||||
|
|
||||||
|
let displayValue = conditionValue;
|
||||||
|
let operator = '=';
|
||||||
|
|
||||||
|
if (selectedTag.type === 'select' && selectedTag.options) {
|
||||||
|
const option = selectedTag.options.find((opt: any) => opt.value === conditionValue);
|
||||||
|
displayValue = option ? option.label : conditionValue;
|
||||||
|
} else if (selectedTag.type === 'number') {
|
||||||
|
operator = '>=';
|
||||||
|
} else if (selectedTag.type === 'friend_search') {
|
||||||
|
operator = 'like';
|
||||||
|
displayValue = `包含"${conditionValue}"`;
|
||||||
|
}
|
||||||
|
|
||||||
const condition = {
|
const condition = {
|
||||||
id: `${selectedTag.id}_${Date.now()}`,
|
type: "field",
|
||||||
type: selectedTag.type,
|
field: selectedTag.field,
|
||||||
label: selectedTag.name,
|
operator: operator,
|
||||||
value: conditionValue,
|
value: conditionValue,
|
||||||
|
displayValue: displayValue,
|
||||||
|
label: selectedTag.label,
|
||||||
|
fieldType: selectedTag.type,
|
||||||
};
|
};
|
||||||
|
|
||||||
onAdd(condition);
|
onAdd(condition);
|
||||||
@@ -137,48 +211,161 @@ const CustomConditionModal: React.FC<CustomConditionModalProps> = ({
|
|||||||
setConditionValue(null);
|
setConditionValue(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderRegionPicker = () => {
|
||||||
|
return (
|
||||||
|
<Popup
|
||||||
|
visible={regionVisible}
|
||||||
|
onMaskClick={() => setRegionVisible(false)}
|
||||||
|
position="bottom"
|
||||||
|
bodyStyle={{ height: '70vh', borderTopLeftRadius: '20px', borderTopRightRadius: '20px' }}
|
||||||
|
>
|
||||||
|
<div className={styles.regionDrillDown}>
|
||||||
|
<div className={styles.regionDrillHeader}>
|
||||||
|
{regionStep === 'city' ? (
|
||||||
|
<Button
|
||||||
|
fill="none"
|
||||||
|
size="small"
|
||||||
|
onClick={() => setRegionStep('province')}
|
||||||
|
className={styles.backBtn}
|
||||||
|
>
|
||||||
|
← 返回省份
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<span className={styles.regionDrillTitle}>选择地区</span>
|
||||||
|
)}
|
||||||
|
<Button fill="none" size="small" onClick={() => setRegionVisible(false)} className={styles.closeBtn}>取消</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.regionDrillContent}>
|
||||||
|
{regionStep === 'province' ? (
|
||||||
|
<List className={styles.flatList}>
|
||||||
|
{areaData.map(item => (
|
||||||
|
<List.Item
|
||||||
|
key={item.value}
|
||||||
|
onClick={() => {
|
||||||
|
const provinceName = item.label.replace(/省$/, '');
|
||||||
|
setTempProvince({ ...item, label: provinceName });
|
||||||
|
setRegionStep('city');
|
||||||
|
}}
|
||||||
|
arrow={true}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</List.Item>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
) : (
|
||||||
|
<List className={styles.flatList}>
|
||||||
|
<List.Item
|
||||||
|
onClick={() => {
|
||||||
|
handleValueChange(tempProvince.label);
|
||||||
|
setRegionVisible(false);
|
||||||
|
}}
|
||||||
|
className={styles.allProvinceOption}
|
||||||
|
arrow={false}
|
||||||
|
>
|
||||||
|
<span className={styles.primaryText}>选择全省:{tempProvince.label}</span>
|
||||||
|
</List.Item>
|
||||||
|
|
||||||
|
<div className={styles.listDivider}>选择城市</div>
|
||||||
|
|
||||||
|
{tempProvince.children?.map((item: any) => (
|
||||||
|
<List.Item
|
||||||
|
key={item.value}
|
||||||
|
onClick={() => {
|
||||||
|
const cityName = item.label.replace(/市$/, '');
|
||||||
|
handleValueChange(`${tempProvince.label} ${cityName}`);
|
||||||
|
setRegionVisible(false);
|
||||||
|
}}
|
||||||
|
arrow={false}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</List.Item>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Popup>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const renderValueInput = () => {
|
const renderValueInput = () => {
|
||||||
if (!selectedTag) return null;
|
if (!selectedTag) return null;
|
||||||
|
|
||||||
switch (selectedTag.type) {
|
switch (selectedTag.type) {
|
||||||
case "range":
|
case "friend_search":
|
||||||
return (
|
return (
|
||||||
<div className={styles.rangeInputs}>
|
<div style={{ marginTop: '12px' }}>
|
||||||
<Input
|
<div className={styles.inputWrapper}>
|
||||||
placeholder="最小年龄"
|
<Input
|
||||||
type="number"
|
placeholder={selectedTag.placeholder || "输入昵称、微信号、手机号搜索"}
|
||||||
onChange={value =>
|
value={conditionValue}
|
||||||
setConditionValue(prev => ({ ...prev, min: value }))
|
onChange={handleValueChange}
|
||||||
}
|
clearable
|
||||||
/>
|
/>
|
||||||
<span className={styles.rangeSeparator}>-</span>
|
</div>
|
||||||
<Input
|
<div className={styles.searchTip}>
|
||||||
placeholder="最大年龄"
|
输入关键词后,将筛选出匹配的好友
|
||||||
type="number"
|
</div>
|
||||||
onChange={value =>
|
</div>
|
||||||
setConditionValue(prev => ({ ...prev, max: value }))
|
);
|
||||||
}
|
|
||||||
/>
|
case "province":
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: '12px' }}>
|
||||||
|
<div
|
||||||
|
className={styles.nativeTrigger}
|
||||||
|
onClick={() => {
|
||||||
|
setRegionVisible(true);
|
||||||
|
setRegionStep('province');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className={styles.triggerLabel}>地区:</span>
|
||||||
|
<span className={conditionValue ? styles.triggerValueActive : styles.triggerPlaceholder}>
|
||||||
|
{conditionValue || '请选择'}
|
||||||
|
</span>
|
||||||
|
<span className={styles.triggerArrow}>›</span>
|
||||||
|
</div>
|
||||||
|
{renderRegionPicker()}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
case "select":
|
case "select":
|
||||||
return (
|
return (
|
||||||
<Selector
|
<Selector
|
||||||
options={selectedTag.options}
|
columns={2}
|
||||||
value={conditionValue ? [conditionValue] : []}
|
options={selectedTag.options || []}
|
||||||
onChange={value => handleValueChange(value[0])}
|
value={[conditionValue]}
|
||||||
multiple={false}
|
onChange={v => handleValueChange(v[0])}
|
||||||
|
style={{
|
||||||
|
'--border-radius': '12px',
|
||||||
|
'--padding': '12px 16px',
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
case "number":
|
||||||
|
return (
|
||||||
|
<div className={styles.inputWrapper}>
|
||||||
|
<Input
|
||||||
|
placeholder={`请输入${selectedTag.label}数值`}
|
||||||
|
type="number"
|
||||||
|
value={conditionValue}
|
||||||
|
onChange={handleValueChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "input":
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
<Input
|
<div className={styles.inputWrapper}>
|
||||||
placeholder="请输入值"
|
<Input
|
||||||
value={conditionValue}
|
placeholder={`请输入${selectedTag.label}`}
|
||||||
onChange={handleValueChange}
|
value={conditionValue}
|
||||||
/>
|
onChange={handleValueChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -188,37 +375,43 @@ const CustomConditionModal: React.FC<CustomConditionModalProps> = ({
|
|||||||
visible={visible}
|
visible={visible}
|
||||||
onMaskClick={onClose}
|
onMaskClick={onClose}
|
||||||
position="bottom"
|
position="bottom"
|
||||||
bodyStyle={{ height: "70vh" }}
|
bodyStyle={{ height: "80vh", borderTopLeftRadius: '24px', borderTopRightRadius: '24px' }}
|
||||||
>
|
>
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
<div className={styles.title}>添加自定义条件</div>
|
<div className={styles.title}>添加筛选条件</div>
|
||||||
<Button size="small" fill="none" onClick={onClose}>
|
<Button size="small" fill="none" onClick={onClose} style={{ color: '#666' }}>
|
||||||
取消
|
取消
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
<div className={styles.section}>
|
<div className={styles.section}>
|
||||||
<div className={styles.sectionTitle}>选择标签</div>
|
<div className={styles.sectionTitle}>选择维度</div>
|
||||||
<div className={styles.tagList}>
|
<div className={styles.tagList}>
|
||||||
{mockTags.map(tag => (
|
{loading ? (
|
||||||
<div
|
<div style={{ textAlign: "center", padding: "40px", color: "#9ca3af" }}>
|
||||||
key={tag.id}
|
加载中...
|
||||||
className={`${styles.tagItem} ${
|
|
||||||
selectedTag?.id === tag.id ? styles.selected : ""
|
|
||||||
}`}
|
|
||||||
onClick={() => handleTagSelect(tag)}
|
|
||||||
>
|
|
||||||
{tag.name}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
) : (
|
||||||
|
fields.map(field => (
|
||||||
|
<div
|
||||||
|
key={field.field}
|
||||||
|
className={`${styles.tagItem} ${
|
||||||
|
selectedTag?.field === field.field ? styles.selected : ""
|
||||||
|
}`}
|
||||||
|
onClick={() => handleTagSelect(field)}
|
||||||
|
>
|
||||||
|
{field.label}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedTag && (
|
{selectedTag && (
|
||||||
<div className={styles.section}>
|
<div className={styles.section}>
|
||||||
<div className={styles.sectionTitle}>设置条件</div>
|
<div className={styles.sectionTitle}>设定条件</div>
|
||||||
{renderValueInput()}
|
{renderValueInput()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -228,10 +421,11 @@ const CustomConditionModal: React.FC<CustomConditionModalProps> = ({
|
|||||||
<Button
|
<Button
|
||||||
color="primary"
|
color="primary"
|
||||||
block
|
block
|
||||||
disabled={!selectedTag || !conditionValue}
|
disabled={!selectedTag || (conditionValue === null || conditionValue === undefined || conditionValue === '')}
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
|
className={styles.submitBtn}
|
||||||
>
|
>
|
||||||
添加条件
|
确认添加
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
.container {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchWrapper {
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: #f9fafb;
|
||||||
|
|
||||||
|
:global(.adm-search-bar) {
|
||||||
|
--background: #fff;
|
||||||
|
--border-radius: 12px;
|
||||||
|
--height: 44px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectedBar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: #f0f7ff;
|
||||||
|
border-bottom: 1px solid #e0efff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectedCount {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #007aff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listHeader {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1px solid #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totalCount {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friendList {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loadingContainer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 60px 0;
|
||||||
|
gap: 12px;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friendItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px;
|
||||||
|
gap: 12px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
border: 1px solid #f3f4f6;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
background: #f0f7ff;
|
||||||
|
border-color: #007aff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
--size: 44px;
|
||||||
|
border-radius: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friendInfo {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friendName {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a1a;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wechatId {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #9ca3af;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #6b7280;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
padding: 16px;
|
||||||
|
padding-bottom: calc(16px + env(safe-area-inset-bottom));
|
||||||
|
background: #fff;
|
||||||
|
border-top: 1px solid #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirmBtn {
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
import React, { useState, useRef, useEffect, useCallback } from "react";
|
||||||
|
import { Popup, Button, SearchBar, Checkbox, Avatar, SpinLoading, Empty } from "antd-mobile";
|
||||||
|
import styles from "./FriendSearchModal.module.scss";
|
||||||
|
import { getUsersByFilter } from "../api";
|
||||||
|
|
||||||
|
interface Friend {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
avatar: string;
|
||||||
|
wechatId: string;
|
||||||
|
tags: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FriendSearchModalProps {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: (friends: Friend[]) => void;
|
||||||
|
existingConditions: any[]; // 已有的筛选条件
|
||||||
|
selectedFriends?: Friend[]; // 已选中的好友
|
||||||
|
}
|
||||||
|
|
||||||
|
const FriendSearchModal: React.FC<FriendSearchModalProps> = ({
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
existingConditions,
|
||||||
|
selectedFriends = [],
|
||||||
|
}) => {
|
||||||
|
const [keyword, setKeyword] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [friends, setFriends] = useState<Friend[]>([]);
|
||||||
|
const [selected, setSelected] = useState<Friend[]>(selectedFriends);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const debounceTimer = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
// 初始化已选中的好友
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
setSelected(selectedFriends);
|
||||||
|
// 自动加载一批数据
|
||||||
|
searchFriends("");
|
||||||
|
}
|
||||||
|
}, [visible, selectedFriends]);
|
||||||
|
|
||||||
|
// 搜索好友
|
||||||
|
const searchFriends = useCallback(async (searchKeyword: string) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
// 构造 ruleConfig,包含已有条件
|
||||||
|
const conditions = existingConditions.filter(c => c.field !== 'friendIds'); // 排除已有的好友ID条件
|
||||||
|
|
||||||
|
const ruleConfig: any = {
|
||||||
|
logic: "AND",
|
||||||
|
conditions: conditions,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 添加搜索关键词
|
||||||
|
if (searchKeyword) {
|
||||||
|
ruleConfig.keyword = searchKeyword;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await getUsersByFilter({
|
||||||
|
ruleConfig,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 转换数据格式
|
||||||
|
const friendList: Friend[] = result.list.map((user: any) => ({
|
||||||
|
id: String(user.id),
|
||||||
|
name: user.nickname || user.wechatId || user.identifier,
|
||||||
|
avatar: user.avatar || `https://api.dicebear.com/7.x/avataaars/svg?seed=${user.id}`,
|
||||||
|
wechatId: user.wechatId || user.wechatAlias || "",
|
||||||
|
tags: Array.isArray(user.tags)
|
||||||
|
? user.tags.map((tag: any) => typeof tag === 'string' ? tag : tag.tagName || '').slice(0, 2)
|
||||||
|
: [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
setFriends(friendList);
|
||||||
|
setTotal(result.total);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("搜索好友失败:", error);
|
||||||
|
setFriends([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [existingConditions]);
|
||||||
|
|
||||||
|
// 搜索防抖
|
||||||
|
const handleSearch = (value: string) => {
|
||||||
|
setKeyword(value);
|
||||||
|
|
||||||
|
if (debounceTimer.current) {
|
||||||
|
clearTimeout(debounceTimer.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
debounceTimer.current = setTimeout(() => {
|
||||||
|
searchFriends(value);
|
||||||
|
}, 500);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 切换选中状态
|
||||||
|
const toggleSelect = (friend: Friend) => {
|
||||||
|
setSelected(prev => {
|
||||||
|
const exists = prev.find(f => f.id === friend.id);
|
||||||
|
if (exists) {
|
||||||
|
return prev.filter(f => f.id !== friend.id);
|
||||||
|
} else {
|
||||||
|
return [...prev, friend];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 全选当前页
|
||||||
|
const selectAll = () => {
|
||||||
|
const newSelected = [...selected];
|
||||||
|
friends.forEach(friend => {
|
||||||
|
if (!newSelected.find(f => f.id === friend.id)) {
|
||||||
|
newSelected.push(friend);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setSelected(newSelected);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 取消全选
|
||||||
|
const deselectAll = () => {
|
||||||
|
const friendIds = friends.map(f => f.id);
|
||||||
|
setSelected(prev => prev.filter(f => !friendIds.includes(f.id)));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 确认选择
|
||||||
|
const handleConfirm = () => {
|
||||||
|
onConfirm(selected);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 清理
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (debounceTimer.current) {
|
||||||
|
clearTimeout(debounceTimer.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const allCurrentPageSelected = friends.length > 0 && friends.every(f => selected.find(s => s.id === f.id));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popup
|
||||||
|
visible={visible}
|
||||||
|
onMaskClick={onClose}
|
||||||
|
position="bottom"
|
||||||
|
bodyStyle={{ height: "85vh", borderTopLeftRadius: '24px', borderTopRightRadius: '24px' }}
|
||||||
|
>
|
||||||
|
<div className={styles.container}>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<div className={styles.title}>选择好友</div>
|
||||||
|
<Button size="small" fill="none" onClick={onClose} style={{ color: '#666' }}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.searchWrapper}>
|
||||||
|
<SearchBar
|
||||||
|
placeholder="搜索昵称、微信号、手机号..."
|
||||||
|
value={keyword}
|
||||||
|
onChange={handleSearch}
|
||||||
|
onClear={() => {
|
||||||
|
setKeyword("");
|
||||||
|
searchFriends("");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selected.length > 0 && (
|
||||||
|
<div className={styles.selectedBar}>
|
||||||
|
<span className={styles.selectedCount}>已选 {selected.length} 人</span>
|
||||||
|
<Button size="mini" fill="none" onClick={() => setSelected([])}>
|
||||||
|
清空
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={styles.listHeader}>
|
||||||
|
<Checkbox
|
||||||
|
checked={allCurrentPageSelected}
|
||||||
|
onChange={checked => checked ? selectAll() : deselectAll()}
|
||||||
|
>
|
||||||
|
全选当前页
|
||||||
|
</Checkbox>
|
||||||
|
<span className={styles.totalCount}>共 {total} 人</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.friendList}>
|
||||||
|
{loading ? (
|
||||||
|
<div className={styles.loadingContainer}>
|
||||||
|
<SpinLoading style={{ "--size": "32px" }} />
|
||||||
|
<span>搜索中...</span>
|
||||||
|
</div>
|
||||||
|
) : friends.length === 0 ? (
|
||||||
|
<Empty description={keyword ? "未找到匹配的好友" : "暂无数据"} />
|
||||||
|
) : (
|
||||||
|
friends.map(friend => {
|
||||||
|
const isSelected = !!selected.find(f => f.id === friend.id);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={friend.id}
|
||||||
|
className={`${styles.friendItem} ${isSelected ? styles.selected : ''}`}
|
||||||
|
onClick={() => toggleSelect(friend)}
|
||||||
|
>
|
||||||
|
<Checkbox checked={isSelected} />
|
||||||
|
<Avatar src={friend.avatar} className={styles.avatar} />
|
||||||
|
<div className={styles.friendInfo}>
|
||||||
|
<div className={styles.friendName}>{friend.name}</div>
|
||||||
|
{friend.wechatId && (
|
||||||
|
<div className={styles.wechatId}>{friend.wechatId}</div>
|
||||||
|
)}
|
||||||
|
{friend.tags.length > 0 && (
|
||||||
|
<div className={styles.tags}>
|
||||||
|
{friend.tags.map((tag, idx) => (
|
||||||
|
<span key={idx} className={styles.tag}>{tag}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.footer}>
|
||||||
|
<Button
|
||||||
|
color="primary"
|
||||||
|
block
|
||||||
|
disabled={selected.length === 0}
|
||||||
|
onClick={handleConfirm}
|
||||||
|
className={styles.confirmBtn}
|
||||||
|
>
|
||||||
|
确认选择 ({selected.length})
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Popup>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FriendSearchModal;
|
||||||
@@ -1,49 +1,60 @@
|
|||||||
.container {
|
.container {
|
||||||
padding: 0;
|
padding: 12px;
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
|
padding: 16px 8px;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-size: 16px;
|
font-size: 18px;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
color: #333;
|
color: #1a1a1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.userCount {
|
.userCount {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #1677ff;
|
color: #007aff;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
|
background: rgba(0, 122, 255, 0.1);
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.batchActions {
|
.searchWrapper {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
:global(.adm-search-bar) {
|
||||||
|
--background: #f5f5f5;
|
||||||
|
--border-radius: 12px;
|
||||||
|
--height: 44px;
|
||||||
|
--padding-left: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loadingContainer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 60px 0;
|
||||||
|
gap: 12px;
|
||||||
|
color: #8e8e93;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batchBar {
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: #fff;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 12px 0;
|
border-radius: 12px;
|
||||||
border-bottom: 1px solid #f0f0f0;
|
margin-bottom: 12px;
|
||||||
margin-bottom: 16px;
|
border: 1px solid #f3f4f6;
|
||||||
}
|
|
||||||
|
|
||||||
.selectAllCheckbox {
|
|
||||||
font-size: 14px;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.removeSelectedBtn {
|
|
||||||
font-size: 12px;
|
|
||||||
padding: 4px 8px;
|
|
||||||
height: 28px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.userList {
|
.userList {
|
||||||
@@ -55,18 +66,24 @@
|
|||||||
.userItem {
|
.userItem {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
|
padding: 16px;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 12px;
|
background: #fff;
|
||||||
background: #f8f9fa;
|
border-radius: 16px;
|
||||||
border-radius: 8px;
|
border: 1px solid #f3f4f6;
|
||||||
border: 1px solid #e9ecef;
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.02);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background: #f9fafb;
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.userCheckbox {
|
.avatar {
|
||||||
margin-top: 4px;
|
--size: 48px;
|
||||||
}
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.05);
|
||||||
.userAvatar {
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,52 +92,80 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.userName {
|
.nameRow {
|
||||||
font-size: 16px;
|
display: flex;
|
||||||
font-weight: 600;
|
align-items: center;
|
||||||
color: #333;
|
gap: 8px;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.userId {
|
.userName {
|
||||||
font-size: 12px;
|
font-size: 16px;
|
||||||
color: #666;
|
font-weight: 700;
|
||||||
margin-bottom: 8px;
|
color: #111827;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.userTags {
|
.badge {
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metaRow {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #6b7280;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagsRow {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 4px;
|
gap: 6px;
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tag {
|
.tagItem {
|
||||||
background: #e6f7ff;
|
font-size: 11px;
|
||||||
color: #1677ff;
|
padding: 2px 8px;
|
||||||
padding: 2px 6px;
|
background: #f0f7ff;
|
||||||
border-radius: 8px;
|
color: #007aff;
|
||||||
font-size: 10px;
|
border-radius: 4px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.userStats {
|
.tagMore {
|
||||||
display: flex;
|
font-size: 11px;
|
||||||
flex-wrap: wrap;
|
padding: 2px 8px;
|
||||||
gap: 12px;
|
background: #f5f5f5;
|
||||||
}
|
color: #8e8e93;
|
||||||
|
border-radius: 4px;
|
||||||
.statItem {
|
font-weight: 500;
|
||||||
font-size: 12px;
|
|
||||||
color: #666;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.removeBtn {
|
.removeBtn {
|
||||||
color: #ff4d4f;
|
color: #d1d5db;
|
||||||
padding: 4px;
|
padding: 8px;
|
||||||
|
--font-size: 22px;
|
||||||
|
transition: color 0.2s;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|
||||||
&:hover {
|
&:active {
|
||||||
background-color: #fff2f0;
|
color: #ef4444;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.loadingMore {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 16px 0;
|
||||||
|
color: #8e8e93;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState, useRef, useEffect } from "react";
|
||||||
import { Card, Avatar, Button, Checkbox, Empty } from "antd-mobile";
|
import { Avatar, Button, Checkbox, Empty, SearchBar, InfiniteScroll, SpinLoading } from "antd-mobile";
|
||||||
import { DeleteOutline } from "antd-mobile-icons";
|
import { CloseCircleOutline } from "antd-mobile-icons";
|
||||||
import styles from "./UserListPreview.module.scss";
|
import styles from "./UserListPreview.module.scss";
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
@@ -15,139 +15,176 @@ interface User {
|
|||||||
|
|
||||||
interface UserListPreviewProps {
|
interface UserListPreviewProps {
|
||||||
users: User[];
|
users: User[];
|
||||||
|
total?: number;
|
||||||
|
loading?: boolean;
|
||||||
|
hasMore?: boolean;
|
||||||
onRemoveUser: (userId: string) => void;
|
onRemoveUser: (userId: string) => void;
|
||||||
|
onSearch?: (keyword: string) => void;
|
||||||
|
onLoadMore?: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const UserListPreview: React.FC<UserListPreviewProps> = ({
|
const UserListPreview: React.FC<UserListPreviewProps> = ({
|
||||||
users,
|
users,
|
||||||
|
total = 0,
|
||||||
|
loading = false,
|
||||||
|
hasMore = false,
|
||||||
onRemoveUser,
|
onRemoveUser,
|
||||||
|
onSearch,
|
||||||
|
onLoadMore,
|
||||||
}) => {
|
}) => {
|
||||||
const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
|
const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
|
||||||
|
const [searchKeyword, setSearchKeyword] = useState("");
|
||||||
|
const debounceTimer = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
const handleSelectAll = (checked: boolean) => {
|
// 搜索防抖
|
||||||
if (checked) {
|
const handleSearchChange = (value: string) => {
|
||||||
setSelectedUsers(users.map(user => user.id));
|
setSearchKeyword(value);
|
||||||
} else {
|
|
||||||
setSelectedUsers([]);
|
if (debounceTimer.current) {
|
||||||
|
clearTimeout(debounceTimer.current);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
debounceTimer.current = setTimeout(() => {
|
||||||
|
onSearch?.(value);
|
||||||
|
}, 500);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectUser = (userId: string, checked: boolean) => {
|
// 清理定时器
|
||||||
if (checked) {
|
useEffect(() => {
|
||||||
setSelectedUsers(prev => [...prev, userId]);
|
return () => {
|
||||||
} else {
|
if (debounceTimer.current) {
|
||||||
setSelectedUsers(prev => prev.filter(id => id !== userId));
|
clearTimeout(debounceTimer.current);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getRfmBadge = (score: number) => {
|
||||||
|
if (score >= 12) return { text: "CORE", color: "#ff3b30", bg: "#fff2f1" };
|
||||||
|
if (score >= 8) return { text: "HIGH", color: "#ff9500", bg: "#fff9f2" };
|
||||||
|
return { text: "USER", color: "#8e8e93", bg: "#f2f2f7" };
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveSelected = () => {
|
// 本地筛选(如果没有提供onSearch回调)
|
||||||
selectedUsers.forEach(userId => onRemoveUser(userId));
|
const filteredUsers = onSearch
|
||||||
setSelectedUsers([]);
|
? users
|
||||||
};
|
: users.filter(user => {
|
||||||
|
if (!searchKeyword) return true;
|
||||||
|
const keyword = searchKeyword.toLowerCase();
|
||||||
|
return (
|
||||||
|
user.name?.toLowerCase().includes(keyword) ||
|
||||||
|
user.tags?.some(tag => tag.toLowerCase().includes(keyword))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const getRfmLevel = (score: number) => {
|
const displayTotal = total || filteredUsers.length;
|
||||||
if (score >= 12) return { level: "高价值", color: "#ff4d4f" };
|
|
||||||
if (score >= 8) return { level: "中等价值", color: "#faad14" };
|
|
||||||
if (score >= 4) return { level: "低价值", color: "#52c41a" };
|
|
||||||
return { level: "潜在客户", color: "#bfbfbf" };
|
|
||||||
};
|
|
||||||
|
|
||||||
if (users.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className={styles.container}>
|
|
||||||
<Card className={styles.card}>
|
|
||||||
<Empty description="暂无用户数据" />
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<Card className={styles.card}>
|
<div className={styles.header}>
|
||||||
<div className={styles.header}>
|
<div className={styles.title}>符合条件的用户</div>
|
||||||
<div className={styles.title}>用户列表预览</div>
|
<div className={styles.userCount}>{displayTotal}人</div>
|
||||||
<div className={styles.userCount}>共 {users.length} 个用户</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{users.length > 0 && (
|
{/* 搜索框 */}
|
||||||
<div className={styles.batchActions}>
|
<div className={styles.searchWrapper}>
|
||||||
|
<SearchBar
|
||||||
|
placeholder="搜索昵称、微信号、标签..."
|
||||||
|
value={searchKeyword}
|
||||||
|
onChange={handleSearchChange}
|
||||||
|
onClear={() => {
|
||||||
|
setSearchKeyword("");
|
||||||
|
onSearch?.("");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && users.length === 0 ? (
|
||||||
|
<div className={styles.loadingContainer}>
|
||||||
|
<SpinLoading style={{ "--size": "32px" }} />
|
||||||
|
<span>加载中...</span>
|
||||||
|
</div>
|
||||||
|
) : filteredUsers.length === 0 ? (
|
||||||
|
<div style={{ paddingTop: 40 }}>
|
||||||
|
<Empty description={searchKeyword ? "未找到匹配用户" : "暂无用户数据"} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className={styles.batchBar}>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={
|
checked={selectedUsers.length === filteredUsers.length && filteredUsers.length > 0}
|
||||||
selectedUsers.length === users.length && users.length > 0
|
onChange={checked => setSelectedUsers(checked ? filteredUsers.map(u => u.id) : [])}
|
||||||
}
|
style={{ '--font-size': '14px' }}
|
||||||
onChange={handleSelectAll}
|
|
||||||
className={styles.selectAllCheckbox}
|
|
||||||
>
|
>
|
||||||
全选
|
全选
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
{selectedUsers.length > 0 && (
|
{selectedUsers.length > 0 && (
|
||||||
<Button
|
<Button size="mini" color="danger" fill="none" onClick={() => {
|
||||||
size="small"
|
selectedUsers.forEach(onRemoveUser);
|
||||||
color="danger"
|
setSelectedUsers([]);
|
||||||
fill="outline"
|
}}>
|
||||||
onClick={handleRemoveSelected}
|
批量删除 ({selectedUsers.length})
|
||||||
className={styles.removeSelectedBtn}
|
|
||||||
>
|
|
||||||
移除选中 ({selectedUsers.length})
|
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<div className={styles.userList}>
|
<div className={styles.userList}>
|
||||||
{users.map(user => {
|
{filteredUsers.map(user => {
|
||||||
const rfmInfo = getRfmLevel(user.rfmScore);
|
const badge = getRfmBadge(user.rfmScore);
|
||||||
|
return (
|
||||||
return (
|
<div key={user.id} className={styles.userItem}>
|
||||||
<div key={user.id} className={styles.userItem}>
|
<Checkbox
|
||||||
<Checkbox
|
checked={selectedUsers.includes(user.id)}
|
||||||
checked={selectedUsers.includes(user.id)}
|
onChange={checked => setSelectedUsers(prev => checked ? [...prev, user.id] : prev.filter(id => id !== user.id))}
|
||||||
onChange={checked => handleSelectUser(user.id, checked)}
|
/>
|
||||||
className={styles.userCheckbox}
|
<Avatar src={user.avatar} className={styles.avatar} />
|
||||||
/>
|
<div className={styles.userInfo}>
|
||||||
|
<div className={styles.nameRow}>
|
||||||
<Avatar src={user.avatar} className={styles.userAvatar} />
|
<span className={styles.userName}>{user.name}</span>
|
||||||
|
<span className={styles.badge} style={{ color: badge.color, background: badge.bg }}>
|
||||||
<div className={styles.userInfo}>
|
{badge.text}
|
||||||
<div className={styles.userName}>{user.name}</div>
|
|
||||||
<div className={styles.userId}>ID: {user.id}</div>
|
|
||||||
<div className={styles.userTags}>
|
|
||||||
{user.tags.map((tag, index) => (
|
|
||||||
<span key={index} className={styles.tag}>
|
|
||||||
{tag}
|
|
||||||
</span>
|
</span>
|
||||||
))}
|
</div>
|
||||||
</div>
|
<div className={styles.metaRow}>
|
||||||
<div className={styles.userStats}>
|
消费 ¥{user.consumption} · 活跃 {user.lastActive}
|
||||||
<span className={styles.statItem}>
|
</div>
|
||||||
RFM:{" "}
|
{user.tags && user.tags.length > 0 && (
|
||||||
<span style={{ color: rfmInfo.color }}>
|
<div className={styles.tagsRow}>
|
||||||
{rfmInfo.level}
|
{user.tags.slice(0, 3).map((tag, idx) => (
|
||||||
</span>
|
<span key={idx} className={styles.tagItem}>{tag}</span>
|
||||||
</span>
|
))}
|
||||||
<span className={styles.statItem}>
|
{user.tags.length > 3 && (
|
||||||
活跃: {user.lastActive}
|
<span className={styles.tagMore}>+{user.tags.length - 3}</span>
|
||||||
</span>
|
)}
|
||||||
<span className={styles.statItem}>
|
</div>
|
||||||
消费: ¥{user.consumption}
|
)}
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
fill="none"
|
||||||
|
className={styles.removeBtn}
|
||||||
|
onClick={() => onRemoveUser(user.id)}
|
||||||
|
>
|
||||||
|
<CloseCircleOutline />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
<Button
|
{/* 无限滚动加载更多 */}
|
||||||
size="small"
|
{onLoadMore && (
|
||||||
fill="none"
|
<InfiniteScroll loadMore={onLoadMore} hasMore={hasMore}>
|
||||||
onClick={() => onRemoveUser(user.id)}
|
{hasMore ? (
|
||||||
className={styles.removeBtn}
|
<div className={styles.loadingMore}>
|
||||||
>
|
<SpinLoading style={{ "--size": "24px" }} />
|
||||||
<DeleteOutline />
|
<span>加载中...</span>
|
||||||
</Button>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
);
|
<span>没有更多了</span>
|
||||||
})}
|
)}
|
||||||
</div>
|
</InfiniteScroll>
|
||||||
</Card>
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,49 +1,55 @@
|
|||||||
.tabsContainer {
|
.container {
|
||||||
background: #fff;
|
display: flex;
|
||||||
border-bottom: 1px solid #f0f0f0;
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
background: #f8fafc;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tabs {
|
.mainScroll {
|
||||||
:global(.adm-tabs-header) {
|
flex: 1;
|
||||||
border-bottom: none;
|
overflow-y: auto;
|
||||||
}
|
-webkit-overflow-scrolling: touch;
|
||||||
|
|
||||||
:global(.adm-tabs-tab) {
|
|
||||||
font-size: 14px;
|
|
||||||
padding: 12px 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.adm-tabs-tab-active) {
|
|
||||||
color: #1677ff;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
padding: 16px;
|
padding: 8px 0 24px;
|
||||||
min-height: calc(100vh - 200px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer {
|
.footer {
|
||||||
padding: 16px;
|
padding: 16px 20px;
|
||||||
background: #fff;
|
padding-bottom: calc(16px + env(safe-area-inset-bottom));
|
||||||
border-top: 1px solid #f0f0f0;
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border-top: 1px solid rgba(0, 0, 0, 0.05);
|
||||||
|
box-shadow: 0 -4px 15px rgba(0, 0, 0, 0.03);
|
||||||
}
|
}
|
||||||
|
|
||||||
.buttonGroup {
|
.buttonGroup {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
align-items: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.prevButton {
|
.prevButton {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
color: #4b5563;
|
||||||
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nextButton {
|
.nextButton, .submitButton {
|
||||||
flex: 1;
|
flex: 2;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: #007aff;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 122, 255, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.submitButton {
|
.stepHeader {
|
||||||
flex: 1;
|
background: #fff;
|
||||||
|
border-bottom: 1px solid #f3f4f6;
|
||||||
|
padding-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { Button } from "antd-mobile";
|
import { Button, Toast } from "antd-mobile";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
import Layout from "@/components/Layout/Layout";
|
import Layout from "@/components/Layout/Layout";
|
||||||
import NavCommon from "@/components/NavCommon";
|
import NavCommon from "@/components/NavCommon";
|
||||||
import BasicInfo from "./components/BasicInfo";
|
import BasicInfo from "./components/BasicInfo";
|
||||||
@@ -7,10 +8,16 @@ import AudienceFilter from "./components/AudienceFilter";
|
|||||||
import UserListPreview from "./components/UserListPreview";
|
import UserListPreview from "./components/UserListPreview";
|
||||||
import styles from "./index.module.scss";
|
import styles from "./index.module.scss";
|
||||||
import StepIndicator from "@/components/StepIndicator";
|
import StepIndicator from "@/components/StepIndicator";
|
||||||
|
import { createTrafficPackage, getUsersByFilter } from "./api";
|
||||||
|
|
||||||
const CreateTrafficPackage: React.FC = () => {
|
const CreateTrafficPackage: React.FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
const [currentStep, setCurrentStep] = useState(1); // 1 基础信息 2 人群筛选 3 用户列表
|
const [currentStep, setCurrentStep] = useState(1); // 1 基础信息 2 人群筛选 3 用户列表
|
||||||
const [submitting, setSubmitting] = useState(false); // 添加提交状态
|
const [submitting, setSubmitting] = useState(false); // 添加提交状态
|
||||||
|
const [loading, setLoading] = useState(false); // 加载用户列表状态
|
||||||
|
const [searchKeyword, setSearchKeyword] = useState(""); // 搜索关键词
|
||||||
|
const [currentPage, setCurrentPage] = useState(1); // 当前页
|
||||||
|
const [hasMore, setHasMore] = useState(false); // 是否还有更多数据
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
// 基本信息
|
// 基本信息
|
||||||
name: "",
|
name: "",
|
||||||
@@ -20,6 +27,7 @@ const CreateTrafficPackage: React.FC = () => {
|
|||||||
filterConditions: [],
|
filterConditions: [],
|
||||||
// 用户列表
|
// 用户列表
|
||||||
filteredUsers: [],
|
filteredUsers: [],
|
||||||
|
filteredUsersTotal: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const steps = [
|
const steps = [
|
||||||
@@ -40,59 +48,6 @@ const CreateTrafficPackage: React.FC = () => {
|
|||||||
setFormData(prev => ({ ...prev, filteredUsers: users }));
|
setFormData(prev => ({ ...prev, filteredUsers: users }));
|
||||||
};
|
};
|
||||||
|
|
||||||
// 初始化模拟数据
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (currentStep === 3 && formData.filteredUsers.length === 0) {
|
|
||||||
const mockUsers = [
|
|
||||||
{
|
|
||||||
id: "U00000001",
|
|
||||||
name: "张三",
|
|
||||||
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=1",
|
|
||||||
tags: ["高价值用户", "活跃用户"],
|
|
||||||
rfmScore: 12,
|
|
||||||
lastActive: "7天内",
|
|
||||||
consumption: 2500,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "U00000002",
|
|
||||||
name: "李四",
|
|
||||||
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=2",
|
|
||||||
tags: ["新用户", "价格敏感"],
|
|
||||||
rfmScore: 6,
|
|
||||||
lastActive: "3天内",
|
|
||||||
consumption: 800,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "U00000003",
|
|
||||||
name: "王五",
|
|
||||||
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=3",
|
|
||||||
tags: ["复购率高", "高潜力"],
|
|
||||||
rfmScore: 14,
|
|
||||||
lastActive: "1天内",
|
|
||||||
consumption: 3200,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "U00000004",
|
|
||||||
name: "赵六",
|
|
||||||
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=4",
|
|
||||||
tags: ["已沉睡", "流失风险"],
|
|
||||||
rfmScore: 3,
|
|
||||||
lastActive: "30天内",
|
|
||||||
consumption: 200,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "U00000005",
|
|
||||||
name: "钱七",
|
|
||||||
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=5",
|
|
||||||
tags: ["高价值用户", "复购率高"],
|
|
||||||
rfmScore: 15,
|
|
||||||
lastActive: "2天内",
|
|
||||||
consumption: 4500,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
setFormData(prev => ({ ...prev, filteredUsers: mockUsers }));
|
|
||||||
}
|
|
||||||
}, [currentStep, formData.filteredUsers.length]);
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
// 防止重复提交
|
// 防止重复提交
|
||||||
@@ -100,20 +55,41 @@ const CreateTrafficPackage: React.FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!formData.name) {
|
||||||
|
Toast.show({ content: "请填写流量包名称", icon: "fail" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.filterConditions || formData.filterConditions.length === 0) {
|
||||||
|
Toast.show({ content: "请设置筛选条件", icon: "fail" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
// 提交逻辑
|
// 构造ruleConfig
|
||||||
console.log("提交数据:", formData);
|
const ruleConfig = {
|
||||||
// 这里可以调用实际的 API
|
logic: "AND",
|
||||||
// await createTrafficPackage(formData);
|
conditions: formData.filterConditions,
|
||||||
|
};
|
||||||
|
|
||||||
// 模拟 API 调用
|
// 调用创建接口
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
const result = await createTrafficPackage({
|
||||||
|
groupName: formData.name,
|
||||||
|
description: formData.description || undefined,
|
||||||
|
ruleType: 1, // 动态规则
|
||||||
|
ruleConfig: ruleConfig,
|
||||||
|
});
|
||||||
|
|
||||||
// 提交成功后可以跳转或显示成功消息
|
Toast.show({ content: "创建成功", icon: "success" });
|
||||||
console.log("流量包创建成功");
|
|
||||||
} catch (error) {
|
// 跳转回列表页
|
||||||
|
setTimeout(() => {
|
||||||
|
navigate("/mine/traffic-pool");
|
||||||
|
}, 1000);
|
||||||
|
} catch (error: any) {
|
||||||
console.error("创建流量包失败:", error);
|
console.error("创建流量包失败:", error);
|
||||||
|
Toast.show({ content: error?.message || "创建失败", icon: "fail" });
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -121,24 +97,87 @@ const CreateTrafficPackage: React.FC = () => {
|
|||||||
|
|
||||||
const canSubmit = formData.name && formData.filterConditions.length > 0;
|
const canSubmit = formData.name && formData.filterConditions.length > 0;
|
||||||
|
|
||||||
// 模拟生成用户数据
|
// 转换用户数据格式
|
||||||
const generateMockUsers = (conditions: any[]) => {
|
const formatUserData = (user: any) => ({
|
||||||
const mockUsers = [];
|
id: String(user.id),
|
||||||
const userCount = Math.floor(Math.random() * 1000) + 100; // 100-1100个用户
|
name: user.nickname || user.wechatId || user.identifier,
|
||||||
|
avatar: user.avatar || `https://api.dicebear.com/7.x/avataaars/svg?seed=${user.id}`,
|
||||||
|
tags: Array.isArray(user.tags) ? user.tags.map((tag: any) => typeof tag === 'string' ? tag : tag.tagName || '') : [],
|
||||||
|
rfmScore: user.rfmScore?.total || 0,
|
||||||
|
lastActive: user.lastInteractTime ? `${Math.floor((Date.now() / 1000 - user.lastInteractTime) / 86400)}天前` : "从未",
|
||||||
|
consumption: parseFloat(user.totalOrderAmount || "0"),
|
||||||
|
});
|
||||||
|
|
||||||
for (let i = 1; i <= userCount; i++) {
|
// 根据筛选条件获取真实用户数据
|
||||||
mockUsers.push({
|
const loadUsersByFilter = async (conditions: any[], keyword: string = "", page: number = 1, append: boolean = false) => {
|
||||||
id: `U${String(i).padStart(8, "0")}`,
|
if (!conditions || conditions.length === 0) {
|
||||||
name: `用户${i}`,
|
return [];
|
||||||
avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${i}`,
|
|
||||||
tags: ["高价值用户", "活跃用户"],
|
|
||||||
rfmScore: Math.floor(Math.random() * 15) + 1,
|
|
||||||
lastActive: "7天内",
|
|
||||||
consumption: Math.floor(Math.random() * 5000) + 100,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return mockUsers;
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
// 构造ruleConfig,如果有关键词搜索,添加额外条件
|
||||||
|
let ruleConfig: any = {
|
||||||
|
logic: "AND",
|
||||||
|
conditions: [...conditions],
|
||||||
|
};
|
||||||
|
|
||||||
|
// 如果有搜索关键词,添加搜索条件
|
||||||
|
if (keyword) {
|
||||||
|
ruleConfig.keyword = keyword;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await getUsersByFilter({
|
||||||
|
ruleConfig,
|
||||||
|
page,
|
||||||
|
pageSize: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 转换为组件需要的格式
|
||||||
|
const users = result.list.map(formatUserData);
|
||||||
|
|
||||||
|
// 判断是否还有更多数据
|
||||||
|
const totalLoaded = append ? formData.filteredUsers.length + users.length : users.length;
|
||||||
|
setHasMore(totalLoaded < result.total);
|
||||||
|
setCurrentPage(page);
|
||||||
|
|
||||||
|
if (append) {
|
||||||
|
// 追加模式
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
filteredUsers: [...prev.filteredUsers, ...users],
|
||||||
|
filteredUsersTotal: result.total,
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
// 替换模式
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
filteredUsers: users,
|
||||||
|
filteredUsersTotal: result.total,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return users;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("获取用户列表失败:", error);
|
||||||
|
Toast.show({ content: error?.message || "获取用户列表失败", icon: "fail" });
|
||||||
|
return [];
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 搜索用户
|
||||||
|
const handleSearchUsers = async (keyword: string) => {
|
||||||
|
setSearchKeyword(keyword);
|
||||||
|
setCurrentPage(1);
|
||||||
|
await loadUsersByFilter(formData.filterConditions, keyword, 1, false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 加载更多用户
|
||||||
|
const handleLoadMoreUsers = async () => {
|
||||||
|
if (loading || !hasMore) return;
|
||||||
|
await loadUsersByFilter(formData.filterConditions, searchKeyword, currentPage + 1, true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderFooter = () => {
|
const renderFooter = () => {
|
||||||
@@ -158,19 +197,20 @@ const CreateTrafficPackage: React.FC = () => {
|
|||||||
<Button
|
<Button
|
||||||
color="primary"
|
color="primary"
|
||||||
className={styles.nextButton}
|
className={styles.nextButton}
|
||||||
onClick={() => {
|
onClick={async () => {
|
||||||
if (currentStep === 2) {
|
if (currentStep === 2) {
|
||||||
// 在第二步时生成用户列表
|
// 在第二步时加载真实用户列表
|
||||||
const mockUsers = generateMockUsers(
|
const users = await loadUsersByFilter(
|
||||||
formData.filterConditions,
|
formData.filterConditions,
|
||||||
);
|
);
|
||||||
handleGenerateUsers(mockUsers);
|
handleGenerateUsers(users);
|
||||||
}
|
}
|
||||||
setCurrentStep(s => Math.min(3, s + 1));
|
setCurrentStep(s => Math.min(3, s + 1));
|
||||||
}}
|
}}
|
||||||
disabled={submitting}
|
disabled={submitting || loading}
|
||||||
|
loading={loading}
|
||||||
>
|
>
|
||||||
下一步
|
{currentStep === 2 && loading ? "加载中..." : "下一步"}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
@@ -191,10 +231,10 @@ const CreateTrafficPackage: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<Layout
|
<Layout
|
||||||
header={
|
header={
|
||||||
<>
|
<div className={styles.stepHeader}>
|
||||||
<NavCommon title="新建流量包" />
|
<NavCommon title="新建流量池" />
|
||||||
<StepIndicator currentStep={currentStep} steps={steps} />
|
<StepIndicator currentStep={currentStep} steps={steps} />
|
||||||
</>
|
</div>
|
||||||
}
|
}
|
||||||
footer={renderFooter()}
|
footer={renderFooter()}
|
||||||
>
|
>
|
||||||
@@ -213,14 +253,20 @@ const CreateTrafficPackage: React.FC = () => {
|
|||||||
{currentStep === 3 && (
|
{currentStep === 3 && (
|
||||||
<UserListPreview
|
<UserListPreview
|
||||||
users={formData.filteredUsers}
|
users={formData.filteredUsers}
|
||||||
|
total={formData.filteredUsersTotal}
|
||||||
|
loading={loading}
|
||||||
|
hasMore={hasMore}
|
||||||
onRemoveUser={userId => {
|
onRemoveUser={userId => {
|
||||||
setFormData(prev => ({
|
setFormData(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
filteredUsers: prev.filteredUsers.filter(
|
filteredUsers: prev.filteredUsers.filter(
|
||||||
(user: any) => user.id !== userId,
|
(user: any) => user.id !== userId,
|
||||||
),
|
),
|
||||||
|
filteredUsersTotal: prev.filteredUsersTotal - 1,
|
||||||
}));
|
}));
|
||||||
}}
|
}}
|
||||||
|
onSearch={handleSearchUsers}
|
||||||
|
onLoadMore={handleLoadMoreUsers}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
import request from "@/api/request";
|
import request from "@/api/request";
|
||||||
|
import {
|
||||||
|
getGroups as getGroupsV2,
|
||||||
|
deleteGroup as deleteGroupV2,
|
||||||
|
type TrafficPoolGroup,
|
||||||
|
} from "../api";
|
||||||
|
|
||||||
|
// 兼容旧版 Package 接口
|
||||||
export interface Package {
|
export interface Package {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
pic: string;
|
pic: string;
|
||||||
type: number;
|
type: number; // 0=自定义,1=系统
|
||||||
createTime: string;
|
createTime: string;
|
||||||
num: number;
|
num: number;
|
||||||
R: number;
|
R: number;
|
||||||
@@ -13,19 +19,88 @@ export interface Package {
|
|||||||
M: number;
|
M: number;
|
||||||
RFM: number;
|
RFM: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PackageList {
|
export interface PackageList {
|
||||||
list: Package[];
|
list: Package[];
|
||||||
total: number;
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取流量池分组列表 (V2)
|
||||||
|
* 转换为旧版 Package 格式以保持兼容
|
||||||
|
*/
|
||||||
export async function getPackage(params: {
|
export async function getPackage(params: {
|
||||||
page: number;
|
page: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
keyword: string;
|
keyword: string;
|
||||||
}): Promise<PackageList> {
|
}): Promise<PackageList> {
|
||||||
return request("/v1/traffic/pool/getPackage", params, "GET");
|
try {
|
||||||
|
const groups = await getGroupsV2();
|
||||||
|
|
||||||
|
// 转换为旧版格式
|
||||||
|
let list: Package[] = groups.map((group: TrafficPoolGroup) => {
|
||||||
|
// 处理 createTime:可能是时间戳(数字)或日期字符串
|
||||||
|
let formattedTime = "";
|
||||||
|
if (group.createTime) {
|
||||||
|
if (typeof group.createTime === "number") {
|
||||||
|
// Unix 时间戳(秒)
|
||||||
|
formattedTime = new Date(group.createTime * 1000).toLocaleDateString();
|
||||||
|
} else if (typeof group.createTime === "string") {
|
||||||
|
// 已经是日期字符串,直接取日期部分
|
||||||
|
formattedTime = group.createTime.split(" ")[0] || group.createTime;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: group.id,
|
||||||
|
name: group.groupName,
|
||||||
|
description: group.description || "",
|
||||||
|
pic: group.groupIcon || "",
|
||||||
|
type: group.isSystem, // 1=系统,0=自定义
|
||||||
|
createTime: formattedTime,
|
||||||
|
num: group.memberCount || 0,
|
||||||
|
R: Math.round(group.avgRfmR || 0),
|
||||||
|
F: Math.round(group.avgRfmF || 0),
|
||||||
|
M: Math.round(group.avgRfmM || 0),
|
||||||
|
RFM: group.avgRfmScore || 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// 关键字过滤
|
||||||
|
if (params.keyword) {
|
||||||
|
const keyword = params.keyword.toLowerCase();
|
||||||
|
list = list.filter(
|
||||||
|
item =>
|
||||||
|
item.name.toLowerCase().includes(keyword) ||
|
||||||
|
(item.description && item.description.toLowerCase().includes(keyword)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分页处理
|
||||||
|
const total = list.length;
|
||||||
|
const start = (params.page - 1) * params.pageSize;
|
||||||
|
const end = start + params.pageSize;
|
||||||
|
list = list.slice(start, end);
|
||||||
|
|
||||||
|
return {
|
||||||
|
list,
|
||||||
|
total,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("获取流量池分组列表失败:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 删除数据包
|
/**
|
||||||
|
* 删除分组 (V2)
|
||||||
|
*/
|
||||||
export async function deletePackage(id: number): Promise<{ success: boolean }> {
|
export async function deletePackage(id: number): Promise<{ success: boolean }> {
|
||||||
return request("/v1/traffic/pool/deletePackage", { id }, "POST");
|
try {
|
||||||
|
await deleteGroupV2(id);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("删除分组失败:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,185 +1,205 @@
|
|||||||
.listWrap {
|
.listWrap {
|
||||||
padding: 12px;
|
padding: 16px;
|
||||||
background: #f5f5f5;
|
background: #f8f9fa;
|
||||||
|
min-height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 美团风格卡片样式 */
|
/* Modern Card Style */
|
||||||
.cardCompact {
|
.cardCompact {
|
||||||
margin: 0 0 12px 0;
|
margin-bottom: 16px;
|
||||||
border: none;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
|
||||||
overflow: hidden;
|
|
||||||
background: #fff;
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.04);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
border: 1px solid #f0f0f0;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.02);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.cardBody {
|
.cardBody {
|
||||||
padding: 16px 30px 16px 16px;
|
padding: 16px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 16px;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 三点菜单按钮 */
|
/* Three-dot Menu */
|
||||||
.menuButton {
|
.menuButton {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 8px;
|
right: 12px;
|
||||||
top: 10px;
|
top: 12px;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
|
padding: 4px;
|
||||||
|
color: #bfbfbf;
|
||||||
|
font-size: 18px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
transition: color 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #1890ff;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 左侧图片区域 */
|
/* Icon/Image Box */
|
||||||
.imageBox {
|
.imageBox {
|
||||||
width: 80px;
|
width: 64px;
|
||||||
height: 80px;
|
height: 64px;
|
||||||
border-radius: 6px;
|
border-radius: 10px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.05);
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 右侧内容区域 */
|
/* Content Area */
|
||||||
.contentArea {
|
.contentArea {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 标题行 */
|
|
||||||
.titleRow {
|
.titleRow {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 8px;
|
margin-right: 20px; /* Space for menu button */
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-size: 16px;
|
font-size: 17px;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
color: #1a1a1a;
|
color: #262626;
|
||||||
line-height: 1.3;
|
line-height: 1.4;
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 右侧标签区域 */
|
.countTag {
|
||||||
.rightTags {
|
font-size: 12px;
|
||||||
display: flex;
|
color: #1890ff;
|
||||||
flex-direction: column;
|
background: #e6f7ff;
|
||||||
align-items: flex-end;
|
padding: 2px 8px;
|
||||||
gap: 4px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.deliveryTag {
|
|
||||||
background: #fff7e6;
|
|
||||||
color: #d46b08;
|
|
||||||
font-size: 10px;
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
border: 1px solid #ffd591;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeTag {
|
|
||||||
color: #ff6b35;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 评分和销售信息 */
|
/* RFM Metrics */
|
||||||
.ratingRow {
|
.metricsRow {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rfmMain {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #ff7a45;
|
||||||
|
background: #fff2e8;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #ffbb96;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metricPill {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #8c8c8c;
|
||||||
|
background: #f5f5f5;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Info Row */
|
||||||
|
.infoRow {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
color: #bfbfbf;
|
||||||
|
margin-top: 2px;
|
||||||
|
|
||||||
|
.typeTag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
color: #8c8c8c;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: "";
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.time {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.rating {
|
/* Global styles for search bar in Layout */
|
||||||
color: #ff6b35;
|
:global {
|
||||||
font-weight: 600;
|
.search-bar {
|
||||||
}
|
padding: 12px 16px;
|
||||||
|
background: #fff;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
|
||||||
.sales {
|
.search-input-wrapper {
|
||||||
color: #8c8c8c;
|
flex: 1;
|
||||||
}
|
|
||||||
|
|
||||||
.price {
|
.ant-input-affix-wrapper {
|
||||||
color: #8c8c8c;
|
border-radius: 8px;
|
||||||
}
|
background: #f5f5f5;
|
||||||
|
border: none;
|
||||||
|
padding-left: 12px;
|
||||||
|
|
||||||
/* 配送信息 */
|
input {
|
||||||
.deliveryInfo {
|
background: transparent;
|
||||||
display: flex;
|
}
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
font-size: 11px;
|
|
||||||
color: #8c8c8c;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 中间标签 */
|
.ant-input-prefix {
|
||||||
.middleTag {
|
color: #bfbfbf;
|
||||||
display: flex;
|
}
|
||||||
justify-content: center;
|
}
|
||||||
margin: 4px 0;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.highScoreTag {
|
.ant-btn {
|
||||||
background: linear-gradient(135deg, #ff6b35, #ff8c42);
|
border-radius: 8px;
|
||||||
color: white;
|
display: flex;
|
||||||
font-size: 10px;
|
align-items: center;
|
||||||
padding: 4px 8px;
|
justify-content: center;
|
||||||
border-radius: 4px;
|
}
|
||||||
font-weight: 500;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/* 底部按钮区域 */
|
.pagination-container {
|
||||||
.bottomActions {
|
padding: 12px;
|
||||||
display: flex;
|
background: #fff;
|
||||||
align-items: center;
|
border-top: 1px solid #f0f0f0;
|
||||||
justify-content: space-between;
|
display: flex;
|
||||||
gap: 8px;
|
justify-content: center;
|
||||||
margin-top: 8px;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.dineInBtn {
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid #52c41a;
|
|
||||||
color: #52c41a;
|
|
||||||
font-size: 11px;
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-radius: 4px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.couponBtn {
|
|
||||||
background: linear-gradient(135deg, #ff6b35, #ff8c42);
|
|
||||||
color: white;
|
|
||||||
font-size: 11px;
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-radius: 4px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 2px;
|
|
||||||
flex: 1;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.couponText {
|
|
||||||
font-size: 10px;
|
|
||||||
color: rgba(255, 255, 255, 0.8);
|
|
||||||
margin-left: 4px;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState, useRef, useCallback } from "react";
|
||||||
import Layout from "@/components/Layout/Layout";
|
import Layout from "@/components/Layout/Layout";
|
||||||
import {
|
import {
|
||||||
SearchOutlined,
|
SearchOutlined,
|
||||||
@@ -30,16 +30,28 @@ const getGroupIcon = (type: number, name?: string) => {
|
|||||||
return icons[type] || "👥";
|
return icons[type] || "👥";
|
||||||
};
|
};
|
||||||
|
|
||||||
// 分组颜色映射
|
// 分组颜色映射 - 使用更柔和的现代配色
|
||||||
const getGroupColor = (type: number) => {
|
const getGroupColor = (type: number) => {
|
||||||
const colors = {
|
const colors = {
|
||||||
0: "#f0f0f0", // 灰色 - 自定义分组(使用名称首字符)
|
0: "#F0F2F5", // 浅灰
|
||||||
1: "#ff4d4f", // 红色
|
1: "#FFF1F0", // 浅红
|
||||||
2: "#1890ff", // 蓝色
|
2: "#E6F7FF", // 浅蓝
|
||||||
3: "#52c41a", // 绿色
|
3: "#F6FFED", // 浅绿
|
||||||
4: "#722ed1", // 紫色
|
4: "#F9F0FF", // 浅紫
|
||||||
};
|
};
|
||||||
return colors[type] || "#1890ff";
|
return colors[type] || "#E6F7FF";
|
||||||
|
};
|
||||||
|
|
||||||
|
// 分组文字颜色映射
|
||||||
|
const getGroupTextColor = (type: number) => {
|
||||||
|
const colors = {
|
||||||
|
0: "#8C8C8C",
|
||||||
|
1: "#FF4D4F",
|
||||||
|
2: "#1890FF",
|
||||||
|
3: "#52C41A",
|
||||||
|
4: "#722ED1",
|
||||||
|
};
|
||||||
|
return colors[type] || "#1890FF";
|
||||||
};
|
};
|
||||||
|
|
||||||
const TrafficPoolList: React.FC = () => {
|
const TrafficPoolList: React.FC = () => {
|
||||||
@@ -50,23 +62,49 @@ const TrafficPoolList: React.FC = () => {
|
|||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [pageSize] = useState(10);
|
const [pageSize] = useState(10);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [search, setSearch] = useState("");
|
const [keyword, setKeyword] = useState("");
|
||||||
|
const [searchInput, setSearchInput] = useState("");
|
||||||
|
|
||||||
const handleSearch = (value: string) => {
|
// 防抖定时器
|
||||||
setSearch(value);
|
const debounceTimer = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
// 搜索输入变化时的防抖处理
|
||||||
|
const handleSearchChange = (value: string) => {
|
||||||
|
setSearchInput(value);
|
||||||
|
|
||||||
|
// 清除之前的定时器
|
||||||
|
if (debounceTimer.current) {
|
||||||
|
clearTimeout(debounceTimer.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置新的定时器,500ms 后执行搜索
|
||||||
|
debounceTimer.current = setTimeout(() => {
|
||||||
|
setKeyword(value);
|
||||||
setPage(1);
|
setPage(1);
|
||||||
|
}, 500);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 清理定时器
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (debounceTimer.current) {
|
||||||
|
clearTimeout(debounceTimer.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleRefresh = () => {
|
const handleRefresh = () => {
|
||||||
setPage(1);
|
setPage(1);
|
||||||
// 触发数据重新获取
|
fetchData(1);
|
||||||
const fetchData = async () => {
|
};
|
||||||
|
|
||||||
|
const fetchData = useCallback(async (customPage?: number) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const params = {
|
const params = {
|
||||||
page: 1,
|
page: customPage || page,
|
||||||
pageSize,
|
pageSize,
|
||||||
keyword: search,
|
keyword,
|
||||||
};
|
};
|
||||||
|
|
||||||
const res: PackageList = await getPackage(params);
|
const res: PackageList = await getPackage(params);
|
||||||
@@ -77,10 +115,7 @@ const TrafficPoolList: React.FC = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [page, pageSize, keyword]);
|
||||||
|
|
||||||
fetchData();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (id: number, name: string) => {
|
const handleDelete = async (id: number, name: string) => {
|
||||||
try {
|
try {
|
||||||
@@ -96,27 +131,8 @@ const TrafficPoolList: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const params = {
|
|
||||||
page,
|
|
||||||
pageSize,
|
|
||||||
keyword: search,
|
|
||||||
};
|
|
||||||
|
|
||||||
const res: PackageList = await getPackage(params);
|
|
||||||
setList(res?.list || []);
|
|
||||||
setTotal(res?.total || 0);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("获取列表失败:", error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchData();
|
fetchData();
|
||||||
}, [page, pageSize, search]);
|
}, [fetchData]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout
|
<Layout
|
||||||
@@ -143,8 +159,8 @@ const TrafficPoolList: React.FC = () => {
|
|||||||
<div className="search-input-wrapper">
|
<div className="search-input-wrapper">
|
||||||
<Input
|
<Input
|
||||||
placeholder="搜索分组"
|
placeholder="搜索分组"
|
||||||
value={search}
|
value={searchInput}
|
||||||
onChange={e => handleSearch(e.target.value)}
|
onChange={e => handleSearchChange(e.target.value)}
|
||||||
prefix={<SearchOutlined />}
|
prefix={<SearchOutlined />}
|
||||||
allowClear
|
allowClear
|
||||||
size="large"
|
size="large"
|
||||||
@@ -173,7 +189,9 @@ const TrafficPoolList: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<div className={styles.listWrap}>
|
<div className={styles.listWrap}>
|
||||||
{list.length === 0 && !loading ? (
|
{list.length === 0 && !loading ? (
|
||||||
<Empty description="暂无分组数据" />
|
<div style={{ paddingTop: 60 }}>
|
||||||
|
<Empty description="暂无分组数据" />
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
{list.map(item => (
|
{list.map(item => (
|
||||||
@@ -194,12 +212,13 @@ const TrafficPoolList: React.FC = () => {
|
|||||||
`/mine/traffic-pool/userList/${item.id}`,
|
`/mine/traffic-pool/userList/${item.id}`,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
// 只有非系统分组才显示删除选项
|
||||||
|
...(item.type !== 1 ? [{
|
||||||
key: "delete",
|
key: "delete",
|
||||||
danger: true,
|
danger: true,
|
||||||
label: "删除数据包",
|
label: "删除数据包",
|
||||||
onClick: () => handleDelete(item.id, item.name),
|
onClick: () => handleDelete(item.id, item.name),
|
||||||
},
|
}] : []),
|
||||||
],
|
],
|
||||||
}}
|
}}
|
||||||
trigger={["click"]}
|
trigger={["click"]}
|
||||||
@@ -209,61 +228,51 @@ const TrafficPoolList: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
style={{ display: "flex", gap: 10, flex: 1 }}
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: 16,
|
||||||
|
flex: 1,
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
navigate(`/mine/traffic-pool/userList/${item.id}`)
|
navigate(`/mine/traffic-pool/userList/${item.id}`)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{/* 左侧图片区域(优先展示 pic,缺省时使用假头像) */}
|
{/* 左侧图片区域 */}
|
||||||
<div
|
<div
|
||||||
className={styles.imageBox}
|
className={styles.imageBox}
|
||||||
style={{ background: getGroupColor(item.type) }}
|
style={{ background: getGroupColor(item.type) }}
|
||||||
>
|
>
|
||||||
{item.pic ? (
|
{item.pic ? (
|
||||||
<img
|
<img src={item.pic} alt={item.name} />
|
||||||
src={item.pic}
|
|
||||||
alt={item.name}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
objectFit: "cover",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<span
|
<span style={{ color: getGroupTextColor(item.type) }}>
|
||||||
style={{
|
|
||||||
fontSize: 24,
|
|
||||||
fontWeight: "bold",
|
|
||||||
color: "#333",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{getGroupIcon(item.type, item.name)}
|
{getGroupIcon(item.type, item.name)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 右侧仅展示选中字段 */}
|
{/* 右侧内容区域 */}
|
||||||
<div className={styles.contentArea}>
|
<div className={styles.contentArea}>
|
||||||
{/* 标题与人数 */}
|
|
||||||
<div className={styles.titleRow}>
|
<div className={styles.titleRow}>
|
||||||
<div className={styles.title}>{item.name}</div>
|
<div className={styles.title}>{item.name}</div>
|
||||||
<div className={styles.timeTag}>共{item.num}人</div>
|
<div className={styles.countTag}>{item.num}人</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* RFM 汇总 */}
|
<div className={styles.metricsRow}>
|
||||||
<div className={styles.ratingRow}>
|
<span className={styles.rfmMain}>RFM {item.RFM}</span>
|
||||||
<span className={styles.rating}>RFM:{item.RFM}</span>
|
<span className={styles.metricPill}>R:{item.R}</span>
|
||||||
<span className={styles.sales}>
|
<span className={styles.metricPill}>F:{item.F}</span>
|
||||||
R:{item.R} F:{item.F} M:{item.M}
|
<span className={styles.metricPill}>M:{item.M}</span>
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 类型与创建时间 */}
|
<div className={styles.infoRow}>
|
||||||
<div className={styles.deliveryInfo}>
|
<span className={styles.typeTag}>
|
||||||
<span>
|
{item.type === 0 ? "自定义" : "系统分组"}
|
||||||
类型: {item.type === 0 ? "自定义" : "系统分组"}
|
</span>
|
||||||
|
<span className={styles.time}>
|
||||||
|
{item.createTime?.split(" ")[0] || "-"}
|
||||||
</span>
|
</span>
|
||||||
<span>创建:{item.createTime || "-"}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,34 +1,160 @@
|
|||||||
import request from "@/api/request";
|
import request from "@/api/request";
|
||||||
|
import {
|
||||||
|
getPoolList,
|
||||||
|
getGroups,
|
||||||
|
createGroup,
|
||||||
|
addMembersToGroup,
|
||||||
|
type TrafficPoolGroup,
|
||||||
|
type TrafficPoolMember,
|
||||||
|
type PageResult,
|
||||||
|
} from "../api";
|
||||||
|
|
||||||
// 获取流量池列表
|
// 兼容旧版返回格式
|
||||||
export function fetchTrafficPoolList(params: {
|
export interface PoolListItem {
|
||||||
|
id: number;
|
||||||
|
identifier: string;
|
||||||
|
nickname: string | null;
|
||||||
|
avatar: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
level: number;
|
||||||
|
lifecycle: number;
|
||||||
|
rfmR: number;
|
||||||
|
rfmF: number;
|
||||||
|
rfmM: number;
|
||||||
|
rfmScore: number;
|
||||||
|
totalMsgCount: number;
|
||||||
|
totalOrderAmount: number;
|
||||||
|
tags: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PoolListResult {
|
||||||
|
list: PoolListItem[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取流量池列表 (V2)
|
||||||
|
*/
|
||||||
|
export async function fetchTrafficPoolList(params: {
|
||||||
page?: number;
|
page?: number;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
keyword?: string;
|
keyword?: string;
|
||||||
}) {
|
}): Promise<PoolListResult> {
|
||||||
return request("/v1/traffic/pool", params, "GET");
|
try {
|
||||||
|
const result = await getPoolList({
|
||||||
|
page: params.page || 1,
|
||||||
|
pageSize: params.pageSize || 10,
|
||||||
|
keyword: params.keyword || "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const list: PoolListItem[] = result.list.map(item => ({
|
||||||
|
id: item.id,
|
||||||
|
identifier: item.identifier,
|
||||||
|
nickname: item.nickname,
|
||||||
|
avatar: item.avatar,
|
||||||
|
phone: item.phone || item.mobile || null,
|
||||||
|
level: item.level,
|
||||||
|
lifecycle: item.lifecycle,
|
||||||
|
rfmR: item.rfmR,
|
||||||
|
rfmF: item.rfmF,
|
||||||
|
rfmM: item.rfmM,
|
||||||
|
rfmScore: item.rfmScore?.total || 0,
|
||||||
|
totalMsgCount: item.totalMsgCount,
|
||||||
|
totalOrderAmount: item.totalOrderAmount,
|
||||||
|
tags: item.tags?.map(t => t.tagName) || [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
list,
|
||||||
|
total: result.total,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("获取流量池列表失败:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取获客场景列表
|
||||||
|
*/
|
||||||
export async function fetchScenarioOptions() {
|
export async function fetchScenarioOptions() {
|
||||||
return request("/v1/plan/scenes", {}, "GET");
|
return request("/v1/plan/scenes", {}, "GET");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchPackageOptions() {
|
/**
|
||||||
return request("/v1/traffic/pool/getPackage", {}, "GET");
|
* 获取流量池分组选项 (V2)
|
||||||
|
*/
|
||||||
|
export async function fetchPackageOptions(): Promise<{
|
||||||
|
list: Array<{ id: number; name: string; type: number; num: number }>;
|
||||||
|
total: number;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const groups = await getGroups();
|
||||||
|
const list = groups.map((g: TrafficPoolGroup) => ({
|
||||||
|
id: g.id,
|
||||||
|
name: g.groupName,
|
||||||
|
type: g.isSystem,
|
||||||
|
num: g.memberCount || 0,
|
||||||
|
}));
|
||||||
|
return { list, total: list.length };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("获取流量池分组选项失败:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建分组或添加成员到分组 (V2)
|
||||||
|
*/
|
||||||
export async function addPackage(params: {
|
export async function addPackage(params: {
|
||||||
type: string; // 类型 1搜索 2选择用户 3文件上传
|
type: string; // 类型 1搜索 2选择用户 3文件上传
|
||||||
addPackageId?: number;
|
addPackageId?: number; // 要添加到的分组ID
|
||||||
addStatus?: number;
|
addStatus?: number;
|
||||||
deviceId?: string;
|
deviceId?: string;
|
||||||
keyword?: string;
|
keyword?: string;
|
||||||
packageId?: number;
|
packageId?: number;
|
||||||
packageName?: number; // 添加的流量池名称
|
packageName?: string; // 添加的流量池名称
|
||||||
tableFile?: number;
|
tableFile?: number;
|
||||||
taskId?: number; // 任务id j及场景获客id
|
taskId?: number;
|
||||||
userIds?: number[];
|
userIds?: number[]; // 要添加的用户ID列表
|
||||||
userValue?: number;
|
userValue?: number;
|
||||||
}) {
|
}) {
|
||||||
return request("/v1/traffic/pool/addPackage", params, "POST");
|
try {
|
||||||
|
// 如果是添加成员到现有分组
|
||||||
|
if (params.addPackageId && params.userIds && params.userIds.length > 0) {
|
||||||
|
const result = await addMembersToGroup(params.addPackageId, params.userIds);
|
||||||
|
return { success: true, count: result.count };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果是创建新分组
|
||||||
|
if (params.packageName) {
|
||||||
|
const result = await createGroup({
|
||||||
|
groupName: String(params.packageName),
|
||||||
|
ruleType: 2, // 手动添加类型
|
||||||
|
});
|
||||||
|
|
||||||
|
// 如果有要添加的用户
|
||||||
|
if (params.userIds && params.userIds.length > 0) {
|
||||||
|
await addMembersToGroup(result.id, params.userIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, id: result.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("缺少必要参数");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("创建分组或添加成员失败:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 导出V2 API供直接使用
|
||||||
|
export {
|
||||||
|
getGroups,
|
||||||
|
createGroup,
|
||||||
|
addMembersToGroup,
|
||||||
|
removeMembersFromGroup,
|
||||||
|
getPoolDetail,
|
||||||
|
updatePool,
|
||||||
|
getStatistics,
|
||||||
|
} from "../api";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useCallback, useEffect, useState } from "react";
|
import React, { useCallback, useEffect, useState, useRef } from "react";
|
||||||
import Layout from "@/components/Layout/Layout";
|
import Layout from "@/components/Layout/Layout";
|
||||||
import {
|
import {
|
||||||
SearchOutlined,
|
SearchOutlined,
|
||||||
@@ -29,7 +29,11 @@ const TrafficPoolList: React.FC = () => {
|
|||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [pageSize] = useState(10);
|
const [pageSize] = useState(10);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [search, setSearch] = useState("");
|
const [keyword, setKeyword] = useState("");
|
||||||
|
const [searchInput, setSearchInput] = useState("");
|
||||||
|
|
||||||
|
// 防抖定时器
|
||||||
|
const debounceTimer = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
// 筛选相关
|
// 筛选相关
|
||||||
const [showFilter, setShowFilter] = useState(false);
|
const [showFilter, setShowFilter] = useState(false);
|
||||||
@@ -51,6 +55,31 @@ const TrafficPoolList: React.FC = () => {
|
|||||||
// 数据分析
|
// 数据分析
|
||||||
const [showStats, setShowStats] = useState(false);
|
const [showStats, setShowStats] = useState(false);
|
||||||
|
|
||||||
|
// 搜索输入变化时的防抖处理
|
||||||
|
const handleSearchChange = (value: string) => {
|
||||||
|
setSearchInput(value);
|
||||||
|
|
||||||
|
// 清除之前的定时器
|
||||||
|
if (debounceTimer.current) {
|
||||||
|
clearTimeout(debounceTimer.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置新的定时器,500ms 后执行搜索
|
||||||
|
debounceTimer.current = setTimeout(() => {
|
||||||
|
setKeyword(value);
|
||||||
|
setPage(1);
|
||||||
|
}, 500);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 清理定时器
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (debounceTimer.current) {
|
||||||
|
clearTimeout(debounceTimer.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
// 获取列表
|
// 获取列表
|
||||||
const getList = async (customParams?: any) => {
|
const getList = async (customParams?: any) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -58,7 +87,7 @@ const TrafficPoolList: React.FC = () => {
|
|||||||
const params: any = {
|
const params: any = {
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
keyword: search,
|
keyword,
|
||||||
packageld: filterParams.packageId,
|
packageld: filterParams.packageId,
|
||||||
sceneId: filterParams.scenarioId,
|
sceneId: filterParams.scenarioId,
|
||||||
userValue: filterParams.userValue,
|
userValue: filterParams.userValue,
|
||||||
@@ -87,6 +116,11 @@ const TrafficPoolList: React.FC = () => {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 参数变化时重新获取数据
|
||||||
|
useEffect(() => {
|
||||||
|
getList();
|
||||||
|
}, [page, pageSize, keyword, filterParams]);
|
||||||
|
|
||||||
// 全选/反选
|
// 全选/反选
|
||||||
const handleSelectAll = (checked: boolean) => {
|
const handleSelectAll = (checked: boolean) => {
|
||||||
if (checked) {
|
if (checked) {
|
||||||
@@ -127,7 +161,7 @@ const TrafficPoolList: React.FC = () => {
|
|||||||
...(filterParams.selectedDevices.length > 0 && {
|
...(filterParams.selectedDevices.length > 0 && {
|
||||||
deviceId: filterParams.selectedDevices.map(d => d.id).join(","),
|
deviceId: filterParams.selectedDevices.map(d => d.id).join(","),
|
||||||
}),
|
}),
|
||||||
...(search && { keyword: search }),
|
...(keyword && { keyword }),
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log("批量加入请求参数:", params);
|
console.log("批量加入请求参数:", params);
|
||||||
@@ -155,29 +189,9 @@ const TrafficPoolList: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 搜索防抖处理
|
|
||||||
const [searchInput, setSearchInput] = useState(search);
|
|
||||||
|
|
||||||
const debouncedSearch = useCallback(() => {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
setSearch(searchInput);
|
|
||||||
// 搜索时重置到第一页并请求列表
|
|
||||||
setPage(1);
|
|
||||||
getList({ keyword: searchInput, page: 1 });
|
|
||||||
}, 500); // 500ms 防抖延迟
|
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [searchInput]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const cleanup = debouncedSearch();
|
|
||||||
return cleanup;
|
|
||||||
}, [debouncedSearch]);
|
|
||||||
|
|
||||||
const handSearch = (value: string) => {
|
const handSearch = (value: string) => {
|
||||||
setSearchInput(value);
|
handleSearchChange(value);
|
||||||
setSelectedIds([]);
|
setSelectedIds([]);
|
||||||
debouncedSearch();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,11 +1,136 @@
|
|||||||
import request from "@/api/request";
|
import {
|
||||||
|
getGroupMembers,
|
||||||
|
getPoolList,
|
||||||
|
type TrafficPoolMember,
|
||||||
|
type PageResult,
|
||||||
|
SOURCE_TYPE_TEXT,
|
||||||
|
} from "../api";
|
||||||
|
|
||||||
// 获取流量包用户列表
|
// 用户列表项(兼容前端页面使用的字段)
|
||||||
export function fetchTrafficPoolList(params: {
|
export interface UserListItem {
|
||||||
|
id: number;
|
||||||
|
identifier: string;
|
||||||
|
companyId: number;
|
||||||
|
nickname: string | null;
|
||||||
|
avatar: string | null;
|
||||||
|
gender: number;
|
||||||
|
phone: string | null;
|
||||||
|
wechatId: string | null; // 微信号(用于显示)
|
||||||
|
fromd: string; // 来源(中文)
|
||||||
|
packages: string[]; // 分组名称
|
||||||
|
createTime: string; // 创建时间
|
||||||
|
// 其他字段
|
||||||
|
alias: string | null;
|
||||||
|
tags: string[];
|
||||||
|
region: string;
|
||||||
|
lifecycle: number;
|
||||||
|
intentionLevel: number;
|
||||||
|
R: number;
|
||||||
|
F: number;
|
||||||
|
M: number;
|
||||||
|
RFM: number;
|
||||||
|
money: number;
|
||||||
|
msgCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserListResult {
|
||||||
|
list: UserListItem[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取流量池用户列表(V2版本)
|
||||||
|
* 支持按分组ID或全量查询
|
||||||
|
*/
|
||||||
|
export async function fetchTrafficPoolList(params: {
|
||||||
page?: number;
|
page?: number;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
keyword?: string;
|
keyword?: string;
|
||||||
packageId?: string;
|
packageId?: string; // 分组ID,兼容旧版参数名
|
||||||
}) {
|
groupId?: number; // 新版参数名
|
||||||
return request("/v1/traffic/pool/users", params, "GET");
|
}): Promise<UserListResult> {
|
||||||
|
const page = params.page || 1;
|
||||||
|
const pageSize = params.pageSize || 10;
|
||||||
|
const keyword = params.keyword || "";
|
||||||
|
const groupId = params.groupId || (params.packageId ? parseInt(params.packageId) : 0);
|
||||||
|
|
||||||
|
let result: PageResult<TrafficPoolMember>;
|
||||||
|
|
||||||
|
if (groupId && groupId > 0) {
|
||||||
|
// 按分组查询
|
||||||
|
result = await getGroupMembers({
|
||||||
|
groupId,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
keyword,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 全量查询
|
||||||
|
result = await getPoolList({
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
keyword,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换为前端页面使用的格式
|
||||||
|
const list: UserListItem[] = result.list.map((item: any) => ({
|
||||||
|
id: item.id,
|
||||||
|
identifier: item.identifier,
|
||||||
|
companyId: item.companyId,
|
||||||
|
nickname: item.nickname || null,
|
||||||
|
avatar: item.avatar || null,
|
||||||
|
gender: item.gender || 0,
|
||||||
|
phone: item.phone || item.mobile || null,
|
||||||
|
// 微信号:优先显示 wechatAlias,如果没有则显示 wechatId
|
||||||
|
wechatId: item.wechatAlias || item.wechatId || null,
|
||||||
|
// 来源:将数字转换为中文
|
||||||
|
fromd: SOURCE_TYPE_TEXT[item.firstSourceType] || "未知来源",
|
||||||
|
// 分组:暂时为空,后续可扩展
|
||||||
|
packages: [],
|
||||||
|
// 创建时间
|
||||||
|
createTime: item.createTime || "",
|
||||||
|
// 其他字段
|
||||||
|
alias: item.wechatAlias || null,
|
||||||
|
tags: item.tags?.map((t: any) => t.tagName) || [],
|
||||||
|
region: item.region || "",
|
||||||
|
lifecycle: item.lifecycle || 1,
|
||||||
|
intentionLevel: item.intentionLevel || 0,
|
||||||
|
R: item.rfmScore?.R || 0,
|
||||||
|
F: item.rfmScore?.F || 0,
|
||||||
|
M: item.rfmScore?.M || 0,
|
||||||
|
RFM: item.rfmScore?.total || 0,
|
||||||
|
money: item.totalOrderAmount || 0,
|
||||||
|
msgCount: item.totalMsgCount || 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
list,
|
||||||
|
total: result.total,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户详情
|
||||||
|
*/
|
||||||
|
export { getPoolDetail as fetchUserDetail } from "../api";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新用户信息
|
||||||
|
*/
|
||||||
|
export { updatePool as updateUser } from "../api";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户标签
|
||||||
|
*/
|
||||||
|
export { getPoolTags as fetchUserTags } from "../api";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加用户标签
|
||||||
|
*/
|
||||||
|
export { addTag } from "../api";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移除用户标签
|
||||||
|
*/
|
||||||
|
export { removeTag } from "../api";
|
||||||
|
|||||||
@@ -2,20 +2,27 @@
|
|||||||
export interface TrafficPoolUser {
|
export interface TrafficPoolUser {
|
||||||
id: number;
|
id: number;
|
||||||
identifier: string;
|
identifier: string;
|
||||||
mobile: string;
|
|
||||||
wechatId: string;
|
|
||||||
fromd: string;
|
|
||||||
status: number;
|
|
||||||
createTime: string;
|
|
||||||
companyId: number;
|
companyId: number;
|
||||||
sourceId: string;
|
nickname: string | null;
|
||||||
type: number;
|
avatar: string | null;
|
||||||
nickname: string;
|
|
||||||
avatar: string;
|
|
||||||
gender: number;
|
gender: number;
|
||||||
phone: string;
|
phone: string | null;
|
||||||
packages: string[];
|
wechatId: string | null; // 微信号(用于显示)
|
||||||
|
fromd: string; // 来源(中文)
|
||||||
|
packages: string[]; // 分组名称
|
||||||
|
createTime: string; // 创建时间
|
||||||
|
// 其他字段
|
||||||
|
alias: string | null;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
|
region: string; // 地区
|
||||||
|
lifecycle: number; // 生命周期
|
||||||
|
intentionLevel: number; // 意向等级
|
||||||
|
R: number;
|
||||||
|
F: number;
|
||||||
|
M: number;
|
||||||
|
RFM: number;
|
||||||
|
money: number;
|
||||||
|
msgCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 列表响应类型
|
// 列表响应类型
|
||||||
|
|||||||
@@ -1,40 +1,183 @@
|
|||||||
.listWrap {
|
.listWrap {
|
||||||
padding: 16px;
|
padding: 12px;
|
||||||
|
background-color: #f6f7f9;
|
||||||
|
min-height: calc(100vh - 120px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cardWrap {
|
.cardWrap {
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 8px;
|
border-radius: 12px;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
padding: 16px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.03);
|
||||||
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition: all 0.2s;
|
transition: transform 0.1s;
|
||||||
|
|
||||||
&:hover {
|
&:active {
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
transform: scale(0.98);
|
||||||
|
background-color: #fafafa;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.cardContent {
|
.cardHeader {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
|
||||||
padding: 16px;
|
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mainInfo {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.nicknameRow {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
|
||||||
|
.nickname {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a1a;
|
||||||
|
max-width: 160px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.wechatId {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #8c8c8c;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.statsRow {
|
||||||
font-size: 16px;
|
display: flex;
|
||||||
font-weight: 600;
|
gap: 16px;
|
||||||
color: #333;
|
margin-bottom: 12px;
|
||||||
margin-bottom: 8px;
|
padding: 8px 12px;
|
||||||
|
background: #f8fbff;
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
|
.statItem {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.statLabel {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #8c8c8c;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.statValue {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1677ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.desc {
|
.tagsRow {
|
||||||
font-size: 14px;
|
display: flex;
|
||||||
color: #666;
|
flex-wrap: wrap;
|
||||||
margin-bottom: 4px;
|
gap: 6px;
|
||||||
line-height: 1.4;
|
margin-top: 10px;
|
||||||
|
min-height: 20px;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.tagItem {
|
||||||
|
padding: 2px 8px;
|
||||||
|
background: #f0f5ff;
|
||||||
|
color: #1677ff;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
border: 1px solid #d6e4ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.moreTags {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #bfbfbf;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.noTags {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #d9d9d9;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.footerRow {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 12px;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #bfbfbf;
|
||||||
|
|
||||||
|
.source {
|
||||||
|
color: #52c41a;
|
||||||
|
background: #f6ffed;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 搜索栏样式优化
|
||||||
|
:global {
|
||||||
|
.search-bar {
|
||||||
|
display: flex;
|
||||||
|
padding: 12px 16px;
|
||||||
|
gap: 10px;
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
|
||||||
|
.search-input-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
.ant-input-affix-wrapper {
|
||||||
|
border-radius: 20px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border: none;
|
||||||
|
padding: 6px 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-btn {
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: none;
|
||||||
|
background: #f0f5ff;
|
||||||
|
color: #1677ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-container {
|
||||||
|
padding: 16px;
|
||||||
|
background: #fff;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,82 +1,75 @@
|
|||||||
import React, { useCallback, useEffect, useState } from "react";
|
import React, { useCallback, useEffect, useState, useRef } from "react";
|
||||||
import { useParams, useNavigate } from "react-router-dom";
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
import Layout from "@/components/Layout/Layout";
|
import Layout from "@/components/Layout/Layout";
|
||||||
import { SearchOutlined, ReloadOutlined } from "@ant-design/icons";
|
import {
|
||||||
import { Input, Button, Pagination } from "antd";
|
SearchOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
MessageOutlined,
|
||||||
|
DollarOutlined,
|
||||||
|
AreaChartOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
EnvironmentOutlined,
|
||||||
|
WomanOutlined,
|
||||||
|
ManOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
|
import { Input, Button, Pagination, Tag } from "antd";
|
||||||
import styles from "./index.module.scss";
|
import styles from "./index.module.scss";
|
||||||
import { Empty, Avatar } from "antd-mobile";
|
import { Empty, Avatar } from "antd-mobile";
|
||||||
import NavCommon from "@/components/NavCommon";
|
import NavCommon from "@/components/NavCommon";
|
||||||
import { fetchTrafficPoolList } from "./api";
|
import { fetchTrafficPoolList } from "./api";
|
||||||
|
import { LIFECYCLE_TEXT } from "../api";
|
||||||
import type { TrafficPoolUser } from "./data";
|
import type { TrafficPoolUser } from "./data";
|
||||||
|
|
||||||
const defaultAvatar =
|
const defaultAvatar = "https://cdn.jsdelivr.net/gh/maokaka/static/avatar-default.png";
|
||||||
"https://cdn.jsdelivr.net/gh/maokaka/static/avatar-default.png";
|
|
||||||
|
|
||||||
const TrafficPoolUserList: React.FC = () => {
|
const TrafficPoolUserList: React.FC = () => {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
// 基础状态
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [list, setList] = useState<TrafficPoolUser[]>([]);
|
const [list, setList] = useState<TrafficPoolUser[]>([]);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [pageSize] = useState(10);
|
const [pageSize] = useState(10);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [search, setSearch] = useState("");
|
const [keyword, setKeyword] = useState("");
|
||||||
|
const [searchInput, setSearchInput] = useState("");
|
||||||
|
|
||||||
// 获取列表
|
const debounceTimer = useRef<NodeJS.Timeout | null>(null);
|
||||||
const getList = async (customParams?: any) => {
|
|
||||||
|
const getList = useCallback(async (customParams?: any) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
|
const currentPage = customParams?.page || page;
|
||||||
const params: any = {
|
const params: any = {
|
||||||
page,
|
page: currentPage,
|
||||||
pageSize,
|
pageSize,
|
||||||
keyword: search,
|
keyword,
|
||||||
packageId: id, // 根据流量包ID筛选用户
|
packageId: id,
|
||||||
...customParams, // 允许传入自定义参数覆盖
|
...customParams,
|
||||||
};
|
};
|
||||||
|
|
||||||
const res = await fetchTrafficPoolList(params);
|
const res = await fetchTrafficPoolList(params);
|
||||||
setList(res.list || []);
|
setList(res.list || []);
|
||||||
setTotal(res.total || 0);
|
setTotal(res.total || 0);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// 忽略请求过于频繁的错误,避免页面崩溃
|
|
||||||
if (error !== "请求过于频繁,请稍后再试") {
|
|
||||||
console.error("获取列表失败:", error);
|
console.error("获取列表失败:", error);
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [page, pageSize, keyword, id]);
|
||||||
|
|
||||||
// 搜索防抖处理
|
const handleSearchChange = (value: string) => {
|
||||||
const [searchInput, setSearchInput] = useState(search);
|
|
||||||
|
|
||||||
const debouncedSearch = useCallback(() => {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
setSearch(searchInput);
|
|
||||||
// 搜索时重置到第一页并请求列表
|
|
||||||
setPage(1);
|
|
||||||
getList({ keyword: searchInput, page: 1 });
|
|
||||||
}, 500); // 500ms 防抖延迟
|
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [searchInput]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const cleanup = debouncedSearch();
|
|
||||||
return cleanup;
|
|
||||||
}, [debouncedSearch]);
|
|
||||||
|
|
||||||
const handSearch = (value: string) => {
|
|
||||||
setSearchInput(value);
|
setSearchInput(value);
|
||||||
debouncedSearch();
|
if (debounceTimer.current) clearTimeout(debounceTimer.current);
|
||||||
|
debounceTimer.current = setTimeout(() => {
|
||||||
|
setKeyword(value);
|
||||||
|
setPage(1);
|
||||||
|
}, 500);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 初始加载和参数变化时重新获取数据
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getList();
|
getList({ page: 1 });
|
||||||
}, [page, pageSize, search, id]);
|
}, [keyword, id]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout
|
<Layout
|
||||||
@@ -84,14 +77,13 @@ const TrafficPoolUserList: React.FC = () => {
|
|||||||
header={
|
header={
|
||||||
<>
|
<>
|
||||||
<NavCommon title="用户列表" />
|
<NavCommon title="用户列表" />
|
||||||
{/* 搜索栏 */}
|
|
||||||
<div className="search-bar">
|
<div className="search-bar">
|
||||||
<div className="search-input-wrapper">
|
<div className="search-input-wrapper">
|
||||||
<Input
|
<Input
|
||||||
placeholder="搜索用户"
|
placeholder="搜索用户姓名/微信号"
|
||||||
value={searchInput}
|
value={searchInput}
|
||||||
onChange={e => handSearch(e.target.value)}
|
onChange={e => handleSearchChange(e.target.value)}
|
||||||
prefix={<SearchOutlined />}
|
prefix={<SearchOutlined style={{ color: '#bfbfbf' }} />}
|
||||||
allowClear
|
allowClear
|
||||||
size="large"
|
size="large"
|
||||||
/>
|
/>
|
||||||
@@ -99,7 +91,6 @@ const TrafficPoolUserList: React.FC = () => {
|
|||||||
<Button
|
<Button
|
||||||
onClick={() => getList()}
|
onClick={() => getList()}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
size="large"
|
|
||||||
icon={<ReloadOutlined />}
|
icon={<ReloadOutlined />}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -108,10 +99,10 @@ const TrafficPoolUserList: React.FC = () => {
|
|||||||
footer={
|
footer={
|
||||||
<div className="pagination-container">
|
<div className="pagination-container">
|
||||||
<Pagination
|
<Pagination
|
||||||
|
simple
|
||||||
current={page}
|
current={page}
|
||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
total={total}
|
total={total}
|
||||||
showSizeChanger={false}
|
|
||||||
onChange={newPage => {
|
onChange={newPage => {
|
||||||
setPage(newPage);
|
setPage(newPage);
|
||||||
getList({ page: newPage });
|
getList({ page: newPage });
|
||||||
@@ -124,45 +115,89 @@ const TrafficPoolUserList: React.FC = () => {
|
|||||||
{list.length === 0 && !loading ? (
|
{list.length === 0 && !loading ? (
|
||||||
<Empty description="暂无用户数据" />
|
<Empty description="暂无用户数据" />
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div className={styles.listContainer}>
|
||||||
{list.map(item => (
|
{list.map(item => (
|
||||||
<div key={item.id} className={styles.cardWrap}>
|
<div
|
||||||
<div
|
key={item.id}
|
||||||
className={styles.card}
|
className={styles.cardWrap}
|
||||||
style={{ cursor: "pointer" }}
|
onClick={() => navigate(`/mine/traffic-pool/detail/${item.wechatId}/${item.id}`)}
|
||||||
onClick={() =>
|
|
||||||
navigate(
|
|
||||||
`/mine/traffic-pool/detail/${item.wechatId}/${item.id}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<div className={styles.cardContent}>
|
{/* 顶部:头像与基本信息 */}
|
||||||
|
<div className={styles.cardHeader}>
|
||||||
<Avatar
|
<Avatar
|
||||||
src={item.avatar || defaultAvatar}
|
src={item.avatar || defaultAvatar}
|
||||||
style={{ "--size": "60px" }}
|
className={styles.avatar}
|
||||||
|
style={{ "--size": "52px" }}
|
||||||
/>
|
/>
|
||||||
<div style={{ flex: 1 }}>
|
<div className={styles.mainInfo}>
|
||||||
<div className={styles.title}>
|
<div className={styles.nicknameRow}>
|
||||||
|
<span className={styles.nickname}>
|
||||||
{item.nickname || item.identifier}
|
{item.nickname || item.identifier}
|
||||||
|
{item.gender === 1 ? (
|
||||||
|
<ManOutlined style={{ color: "#1677ff", fontSize: "12px", marginLeft: "4px" }} />
|
||||||
|
) : item.gender === 2 ? (
|
||||||
|
<WomanOutlined style={{ color: "#ff4d4f", fontSize: "12px", marginLeft: "4px" }} />
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
|
||||||
|
<Tag color="blue" style={{ borderRadius: '4px', fontSize: '10px', border: 'none', margin: 0 }}>
|
||||||
|
{LIFECYCLE_TEXT[item.lifecycle] || "新流量"}
|
||||||
|
</Tag>
|
||||||
|
<Tag color="orange" style={{ borderRadius: '4px', fontSize: '10px', border: 'none', margin: 0 }}>
|
||||||
|
RFM: {item.RFM}
|
||||||
|
</Tag>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.desc}>
|
|
||||||
微信号:{item.wechatId || "-"}
|
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.desc}>
|
<div className={styles.wechatId}>
|
||||||
来源:{item.fromd || "-"}
|
<div style={{ flex: 1, display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||||
|
<UserOutlined style={{ fontSize: '12px' }} />
|
||||||
|
{item.wechatId || "未设置微信号"}
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.desc}>
|
{item.region && (
|
||||||
分组:
|
<div style={{ display: 'flex', alignItems: 'center', gap: '2px', color: '#bfbfbf', fontSize: '11px' }}>
|
||||||
{item.packages && item.packages.length
|
<EnvironmentOutlined />
|
||||||
? item.packages.join(",")
|
{item.region}
|
||||||
: "-"}
|
|
||||||
</div>
|
|
||||||
<div className={styles.desc}>
|
|
||||||
创建时间:{item.createTime}
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 中间:互动统计小色块 */}
|
||||||
|
<div className={styles.statsRow}>
|
||||||
|
<div className={styles.statItem}>
|
||||||
|
<span className={styles.statLabel}><MessageOutlined /> 消息</span>
|
||||||
|
<span className={styles.statValue}>{item.msgCount || 0}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.statItem}>
|
||||||
|
<span className={styles.statLabel}><DollarOutlined /> 成交</span>
|
||||||
|
<span className={styles.statValue}>¥{item.money || 0}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.statItem}>
|
||||||
|
<span className={styles.statLabel}><AreaChartOutlined /> 活跃度</span>
|
||||||
|
<span className={styles.statValue}>{item.F || 0}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 底部:用户标签 */}
|
||||||
|
<div className={styles.tagsRow}>
|
||||||
|
{item.tags && item.tags.length > 0 ? (
|
||||||
|
<>
|
||||||
|
{item.tags.slice(0, 3).map((tag, idx) => (
|
||||||
|
<span key={idx} className={styles.tagItem}>{tag}</span>
|
||||||
|
))}
|
||||||
|
{item.tags.length > 3 && <span className={styles.moreTags}>+{item.tags.length - 3}</span>}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className={styles.noTags}>暂无标签</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 脚注:来源与时间 */}
|
||||||
|
<div className={styles.footerRow}>
|
||||||
|
<span className={styles.source}>{item.fromd}</span>
|
||||||
|
<span>{item.createTime?.split(' ')[0]}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ export interface Task {
|
|||||||
total_customers?: number;
|
total_customers?: number;
|
||||||
today_customers?: number;
|
today_customers?: number;
|
||||||
lastUpdated?: string;
|
lastUpdated?: string;
|
||||||
|
planType?: number; // 0-全局计划, 1-独立计划
|
||||||
|
config?: { planType?: number; apiKey?: string; api_key?: string };
|
||||||
stats?: {
|
stats?: {
|
||||||
devices?: number;
|
devices?: number;
|
||||||
acquired?: number;
|
acquired?: number;
|
||||||
@@ -21,6 +23,8 @@ export interface Task {
|
|||||||
addedCount?: number;
|
addedCount?: number;
|
||||||
passRate?: number;
|
passRate?: number;
|
||||||
passCount?: number;
|
passCount?: number;
|
||||||
|
apiKey?: string;
|
||||||
|
textUrl?: { apiKey?: string; api_key?: string; fullUrl?: string; full_url?: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiSettings {
|
export interface ApiSettings {
|
||||||
|
|||||||
@@ -2,6 +2,73 @@
|
|||||||
padding: 0 16px;
|
padding: 0 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.info-box {
|
||||||
|
background: #eff6ff;
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-icon {
|
||||||
|
color: #2563eb;
|
||||||
|
font-size: 20px;
|
||||||
|
margin-top: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #1e40af;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding-left: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-dot {
|
||||||
|
width: 4px;
|
||||||
|
height: 14px;
|
||||||
|
background: #2563eb;
|
||||||
|
border-radius: 2px;
|
||||||
|
box-shadow: 0 0 8px rgba(37, 99, 235, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title-independent {
|
||||||
|
.section-dot {
|
||||||
|
background: #fb923c;
|
||||||
|
box-shadow: 0 0 8px rgba(251, 146, 60, 0.4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-dot-independent {
|
||||||
|
background: #fb923c;
|
||||||
|
box-shadow: 0 0 8px rgba(251, 146, 60, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-list-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.loading {
|
.loading {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import DeviceListModal from "./components/DeviceListModal";
|
|||||||
import AccountListModal from "./components/AccountListModal";
|
import AccountListModal from "./components/AccountListModal";
|
||||||
import OreadyAdd from "./components/OreadyAdd";
|
import OreadyAdd from "./components/OreadyAdd";
|
||||||
import PoolListModal from "./components/PoolListModal";
|
import PoolListModal from "./components/PoolListModal";
|
||||||
|
import { extractApiKeyFromPlanLike, pickTextUrl } from "./planFields";
|
||||||
|
|
||||||
const ScenarioList: React.FC = () => {
|
const ScenarioList: React.FC = () => {
|
||||||
const { scenarioId, scenarioName } = useParams<{
|
const { scenarioId, scenarioName } = useParams<{
|
||||||
@@ -101,17 +102,28 @@ const ScenarioList: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (response && response.list) {
|
if (response && response.list) {
|
||||||
|
// 处理 planType 字段
|
||||||
|
const processedList = response.list.map((task: any) => {
|
||||||
|
const planType = task.planType ?? task.config?.planType ?? 1;
|
||||||
|
const apiKey = extractApiKeyFromPlanLike(task);
|
||||||
|
return {
|
||||||
|
...task,
|
||||||
|
planType,
|
||||||
|
...(apiKey ? { apiKey } : {}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
if (isLoadMore) {
|
if (isLoadMore) {
|
||||||
// 加载更多时,追加数据
|
// 加载更多时,追加数据
|
||||||
setTasks(prev => [...prev, ...response.list]);
|
setTasks(prev => [...prev, ...processedList]);
|
||||||
} else {
|
} else {
|
||||||
// 首次加载或刷新时,替换数据
|
// 首次加载或刷新时,替换数据
|
||||||
setTasks(response.list);
|
setTasks(processedList);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新分页信息
|
// 更新分页信息
|
||||||
setTotal(response.total || 0);
|
setTotal(response.total || 0);
|
||||||
setHasMore(response.list.length === limit);
|
setHasMore(processedList.length === limit);
|
||||||
setCurrentPage(page);
|
setCurrentPage(page);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -208,19 +220,28 @@ const ScenarioList: React.FC = () => {
|
|||||||
const handleOpenApiSettings = async (taskId: string) => {
|
const handleOpenApiSettings = async (taskId: string) => {
|
||||||
try {
|
try {
|
||||||
const response: PlanDetail = await getPlanDetail(taskId);
|
const response: PlanDetail = await getPlanDetail(taskId);
|
||||||
if (response) {
|
if (!response) return;
|
||||||
// 处理webhook URL,使用工具函数构建完整地址
|
|
||||||
const webhookUrl = buildApiUrl(
|
|
||||||
response.textUrl?.fullUrl || `webhook/${taskId}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
setCurrentApiSettings({
|
const apiKey = extractApiKeyFromPlanLike(response);
|
||||||
apiKey: response.apiKey || "demo-api-key-123456",
|
if (!apiKey) {
|
||||||
webhookUrl: webhookUrl,
|
Toast.show({
|
||||||
taskId: taskId,
|
content: "未获取到 API 密钥,请稍后重试或联系管理员",
|
||||||
|
position: "top",
|
||||||
});
|
});
|
||||||
setShowApiDialog(true);
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tu = pickTextUrl(response);
|
||||||
|
const webhookUrl = buildApiUrl(
|
||||||
|
tu?.fullUrl || response.textUrl?.fullUrl || `webhook/${taskId}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
setCurrentApiSettings({
|
||||||
|
apiKey,
|
||||||
|
webhookUrl,
|
||||||
|
taskId,
|
||||||
|
});
|
||||||
|
setShowApiDialog(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Toast.show({
|
Toast.show({
|
||||||
content: "获取计划接口失败",
|
content: "获取计划接口失败",
|
||||||
@@ -309,9 +330,18 @@ const ScenarioList: React.FC = () => {
|
|||||||
await fetchPlanList(1, false);
|
await fetchPlanList(1, false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredTasks = tasks.filter(task =>
|
const normalizedSearch = searchTerm.trim().toLowerCase();
|
||||||
task.name.toLowerCase().includes(searchTerm.toLowerCase()),
|
const filteredTasks = tasks.filter(task => {
|
||||||
);
|
if (!normalizedSearch) return true;
|
||||||
|
const nameMatch = task.name.toLowerCase().includes(normalizedSearch);
|
||||||
|
const keyStr = extractApiKeyFromPlanLike(task).toLowerCase();
|
||||||
|
const keyMatch = keyStr.includes(normalizedSearch);
|
||||||
|
return nameMatch || keyMatch;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 分隔全局计划和独立计划
|
||||||
|
const globalPlans = filteredTasks.filter(task => task.planType === 0);
|
||||||
|
const independentPlans = filteredTasks.filter(task => task.planType === 1 || !task.planType);
|
||||||
|
|
||||||
// 生成操作菜单
|
// 生成操作菜单
|
||||||
const getActionMenu = (task: Task) => [
|
const getActionMenu = (task: Task) => [
|
||||||
@@ -387,7 +417,7 @@ const ScenarioList: React.FC = () => {
|
|||||||
<div className={style["search-bar"]}>
|
<div className={style["search-bar"]}>
|
||||||
<div className={style["search-input-wrapper"]}>
|
<div className={style["search-input-wrapper"]}>
|
||||||
<Input
|
<Input
|
||||||
placeholder="搜索计划名称"
|
placeholder="搜索计划名称或 API Key"
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={e => setSearchTerm(e.target.value)}
|
onChange={e => setSearchTerm(e.target.value)}
|
||||||
prefix={<SearchOutlined />}
|
prefix={<SearchOutlined />}
|
||||||
@@ -409,6 +439,16 @@ const ScenarioList: React.FC = () => {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
>
|
>
|
||||||
<div className={style["scenario-list-page"]}>
|
<div className={style["scenario-list-page"]}>
|
||||||
|
{/* 全局计划提示 */}
|
||||||
|
{globalPlans.length > 0 && (
|
||||||
|
<div className={style["info-box"]}>
|
||||||
|
<span className={style["info-icon"]}>ℹ</span>
|
||||||
|
<p className={style["info-text"]}>
|
||||||
|
全局获客计划将应用于所有设备,包含新添加的设备,请确保设置合理的规则。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 计划列表 */}
|
{/* 计划列表 */}
|
||||||
<div className={style["plan-list"]}>
|
<div className={style["plan-list"]}>
|
||||||
{filteredTasks.length === 0 ? (
|
{filteredTasks.length === 0 ? (
|
||||||
@@ -428,7 +468,119 @@ const ScenarioList: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{filteredTasks.map(task => (
|
{/* 全局获客计划 */}
|
||||||
|
{globalPlans.length > 0 && (
|
||||||
|
<section className={style["section"]}>
|
||||||
|
<h2 className={style["section-title"]}>
|
||||||
|
<div className={style["section-dot"]}></div>
|
||||||
|
全局获客计划
|
||||||
|
</h2>
|
||||||
|
<div className={style["plan-list-group"]}>
|
||||||
|
{globalPlans.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={e => {
|
||||||
|
e.stopPropagation(); // 阻止事件冒泡
|
||||||
|
setShowActionMenu(task.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MoreOutlined />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 统计数据网格 */}
|
||||||
|
<div className={style["stats-grid"]}>
|
||||||
|
<div
|
||||||
|
className={style["stat-item"]}
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation(); // 阻止事件冒泡,避免触发卡片点击
|
||||||
|
handleShowDeviceList(task);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={style["stat-label"]}>设备数</div>
|
||||||
|
<div className={style["stat-value"]}>
|
||||||
|
{deviceCount(task)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={style["stat-item"]}
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation(); // 阻止事件冒泡,避免触发卡片点击
|
||||||
|
handleShowAccountList(task);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={style["stat-label"]}>已获客</div>
|
||||||
|
<div className={style["stat-value"]}>
|
||||||
|
{task?.acquiredCount || 0}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={style["stat-item"]}
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation(); // 阻止事件冒泡,避免触发卡片点击
|
||||||
|
handleShowOreadyAdd(task);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={style["stat-label"]}>已添加</div>
|
||||||
|
<div className={style["stat-value"]}>
|
||||||
|
{task.passCount || 0}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={style["stat-item"]}
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation(); // 阻止事件冒泡,避免触发卡片点击
|
||||||
|
handleShowPoolList(task);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
<QrcodeOutlined
|
||||||
|
onClick={() => {
|
||||||
|
setShowActionMenu(null);
|
||||||
|
handleShowQrCode(task.id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 独立获客计划 */}
|
||||||
|
{independentPlans.length > 0 && (
|
||||||
|
<section className={style["section"]}>
|
||||||
|
<h2 className={`${style["section-title"]} ${style["section-title-independent"]}`}>
|
||||||
|
<div className={`${style["section-dot"]} ${style["section-dot-independent"]}`}></div>
|
||||||
|
独立获客计划
|
||||||
|
</h2>
|
||||||
|
<div className={style["plan-list-group"]}>
|
||||||
|
{independentPlans.map(task => (
|
||||||
<Card key={task.id} className={style["plan-item"]}>
|
<Card key={task.id} className={style["plan-item"]}>
|
||||||
{/* 头部:标题、状态和操作菜单 */}
|
{/* 头部:标题、状态和操作菜单 */}
|
||||||
<div className={style["plan-header"]}>
|
<div className={style["plan-header"]}>
|
||||||
@@ -520,6 +672,10 @@ const ScenarioList: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 上拉加载更多 */}
|
{/* 上拉加载更多 */}
|
||||||
<InfiniteScroll
|
<InfiniteScroll
|
||||||
loadMore={handleLoadMore}
|
loadMore={handleLoadMore}
|
||||||
|
|||||||
30
src/pages/mobile/scenarios/plan/list/planFields.ts
Normal file
30
src/pages/mobile/scenarios/plan/list/planFields.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
/** 兼容后端 camelCase / snake_case 及嵌套字段,避免推广侧拿到错误密钥 */
|
||||||
|
|
||||||
|
export function extractApiKeyFromPlanLike(record: any): string {
|
||||||
|
if (!record || typeof record !== "object") return "";
|
||||||
|
const cfg = record.config;
|
||||||
|
const tu = record.textUrl ?? record.text_url;
|
||||||
|
const candidates = [
|
||||||
|
record.apiKey,
|
||||||
|
record.api_key,
|
||||||
|
tu?.apiKey,
|
||||||
|
tu?.api_key,
|
||||||
|
cfg?.apiKey,
|
||||||
|
cfg?.api_key,
|
||||||
|
];
|
||||||
|
for (const v of candidates) {
|
||||||
|
if (v != null && String(v).trim() !== "") return String(v).trim();
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickTextUrl(record: any):
|
||||||
|
| { fullUrl?: string; apiKey?: string }
|
||||||
|
| undefined {
|
||||||
|
const tu = record?.textUrl ?? record?.text_url;
|
||||||
|
if (!tu || typeof tu !== "object") return undefined;
|
||||||
|
return {
|
||||||
|
fullUrl: tu.fullUrl ?? tu.full_url,
|
||||||
|
apiKey: tu.apiKey ?? tu.api_key,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
// 步骤定义 - 四个步骤
|
// 步骤定义 - 三个步骤
|
||||||
import { DeviceSelectionItem } from "@/components/DeviceSelection/data";
|
import { DeviceSelectionItem } from "@/components/DeviceSelection/data";
|
||||||
import { GroupSelectionItem } from "@/components/GroupSelection/data";
|
import { GroupSelectionItem } from "@/components/GroupSelection/data";
|
||||||
export const steps = [
|
export const steps = [
|
||||||
{ id: 1, title: "步骤一", subtitle: "基础设置" },
|
{ id: 1, title: "步骤一", subtitle: "基础设置" },
|
||||||
{ id: 2, title: "步骤二", subtitle: "好友申请设置" },
|
{ id: 2, title: "步骤二", subtitle: "好友申请设置" },
|
||||||
{ id: 3, title: "步骤三", subtitle: "渠道设置" },
|
{ id: 3, title: "步骤三", subtitle: "消息设置" },
|
||||||
{ id: 4, title: "步骤四", subtitle: "消息设置" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// 类型定义
|
// 类型定义
|
||||||
@@ -31,6 +30,10 @@ export interface FormData {
|
|||||||
wechatGroups: string[];
|
wechatGroups: string[];
|
||||||
wechatGroupsOptions: GroupSelectionItem[];
|
wechatGroupsOptions: GroupSelectionItem[];
|
||||||
messagePlans: any[];
|
messagePlans: any[];
|
||||||
|
// 拉群设置
|
||||||
|
groupInviteEnabled?: boolean;
|
||||||
|
groupName?: string;
|
||||||
|
fixedGroupMembers?: any[]; // 固定群成员,使用好友选择组件结构
|
||||||
// 分销相关
|
// 分销相关
|
||||||
distributionEnabled?: boolean;
|
distributionEnabled?: boolean;
|
||||||
// 选中的分销渠道ID列表(前端使用,提交时转为 distributionChannels)
|
// 选中的分销渠道ID列表(前端使用,提交时转为 distributionChannels)
|
||||||
@@ -45,6 +48,8 @@ export interface FormData {
|
|||||||
distributionCustomerReward?: number;
|
distributionCustomerReward?: number;
|
||||||
// 添加奖励金额(元,前端使用,提交时转为 addFriendRewardAmount)
|
// 添加奖励金额(元,前端使用,提交时转为 addFriendRewardAmount)
|
||||||
distributionAddReward?: number;
|
distributionAddReward?: number;
|
||||||
|
// 计划类型:0-全局计划,1-独立计划(仅管理员可创建)
|
||||||
|
planType?: number;
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
export const defFormData: FormData = {
|
export const defFormData: FormData = {
|
||||||
@@ -72,9 +77,13 @@ export const defFormData: FormData = {
|
|||||||
wechatGroupsOptions: [],
|
wechatGroupsOptions: [],
|
||||||
contentGroups: [],
|
contentGroups: [],
|
||||||
contentGroupsOptions: [],
|
contentGroupsOptions: [],
|
||||||
|
groupInviteEnabled: false,
|
||||||
|
groupName: "",
|
||||||
|
fixedGroupMembers: [],
|
||||||
distributionEnabled: false,
|
distributionEnabled: false,
|
||||||
distributionChannelIds: [],
|
distributionChannelIds: [],
|
||||||
distributionChannelsOptions: [],
|
distributionChannelsOptions: [],
|
||||||
distributionCustomerReward: undefined,
|
distributionCustomerReward: undefined,
|
||||||
distributionAddReward: undefined,
|
distributionAddReward: undefined,
|
||||||
|
planType: 1, // 默认独立计划
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { message, Button, Space } from "antd";
|
|||||||
import NavCommon from "@/components/NavCommon";
|
import NavCommon from "@/components/NavCommon";
|
||||||
import BasicSettings from "./steps/BasicSettings";
|
import BasicSettings from "./steps/BasicSettings";
|
||||||
import FriendRequestSettings from "./steps/FriendRequestSettings";
|
import FriendRequestSettings from "./steps/FriendRequestSettings";
|
||||||
import DistributionSettings from "./steps/DistributionSettings";
|
|
||||||
import MessageSettings from "./steps/MessageSettings";
|
import MessageSettings from "./steps/MessageSettings";
|
||||||
import Layout from "@/components/Layout/Layout";
|
import Layout from "@/components/Layout/Layout";
|
||||||
import StepIndicator from "@/components/StepIndicator";
|
import StepIndicator from "@/components/StepIndicator";
|
||||||
@@ -112,6 +111,17 @@ export default function NewPlan() {
|
|||||||
wechatGroupsOptions: detail.wechatGroupsOptions ?? [],
|
wechatGroupsOptions: detail.wechatGroupsOptions ?? [],
|
||||||
contentGroups: detail.contentGroups ?? [],
|
contentGroups: detail.contentGroups ?? [],
|
||||||
contentGroupsOptions: detail.contentGroupsOptions ?? [],
|
contentGroupsOptions: detail.contentGroupsOptions ?? [],
|
||||||
|
// 拉群设置
|
||||||
|
groupInviteEnabled: detail.groupInviteEnabled ?? false,
|
||||||
|
groupName: detail.groupName ?? "",
|
||||||
|
// 计划类型
|
||||||
|
planType: detail.planType ?? 1,
|
||||||
|
// 优先使用后端返回的 options(完整好友信息),否则退回到 ID 数组或旧字段
|
||||||
|
fixedGroupMembers:
|
||||||
|
detail.groupFixedMembersOptions ??
|
||||||
|
detail.fixedGroupMembers ??
|
||||||
|
detail.groupFixedMembers ??
|
||||||
|
[],
|
||||||
status: detail.status ?? 0,
|
status: detail.status ?? 0,
|
||||||
messagePlans: detail.messagePlans ?? [],
|
messagePlans: detail.messagePlans ?? [],
|
||||||
// 分销相关数据回填
|
// 分销相关数据回填
|
||||||
@@ -196,11 +206,26 @@ export default function NewPlan() {
|
|||||||
submitData.distributionEnabled = false;
|
submitData.distributionEnabled = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 拉群设置字段转换
|
||||||
|
if (formData.groupInviteEnabled) {
|
||||||
|
submitData.groupInviteEnabled = true;
|
||||||
|
submitData.groupName = formData.groupName || "";
|
||||||
|
// 后端期望的字段:groupFixedMembers,使用好友ID数组
|
||||||
|
submitData.groupFixedMembers = (formData.fixedGroupMembers || []).map(
|
||||||
|
(f: any) => f.id,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
submitData.groupInviteEnabled = false;
|
||||||
|
submitData.groupName = "";
|
||||||
|
submitData.groupFixedMembers = [];
|
||||||
|
}
|
||||||
|
|
||||||
// 移除前端使用的字段,避免提交到后端
|
// 移除前端使用的字段,避免提交到后端
|
||||||
delete submitData.distributionChannelIds;
|
delete submitData.distributionChannelIds;
|
||||||
delete submitData.distributionChannelsOptions;
|
delete submitData.distributionChannelsOptions;
|
||||||
delete submitData.distributionCustomerReward;
|
delete submitData.distributionCustomerReward;
|
||||||
delete submitData.distributionAddReward;
|
delete submitData.distributionAddReward;
|
||||||
|
delete submitData.fixedGroupMembers;
|
||||||
|
|
||||||
if (isEdit && planId) {
|
if (isEdit && planId) {
|
||||||
// 编辑:拼接后端需要的完整参数
|
// 编辑:拼接后端需要的完整参数
|
||||||
@@ -257,6 +282,7 @@ export default function NewPlan() {
|
|||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
sceneList={sceneList}
|
sceneList={sceneList}
|
||||||
sceneLoading={sceneLoading}
|
sceneLoading={sceneLoading}
|
||||||
|
planId={planId}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case 2:
|
case 2:
|
||||||
@@ -264,14 +290,6 @@ export default function NewPlan() {
|
|||||||
<FriendRequestSettings formData={formData} onChange={onChange} />
|
<FriendRequestSettings formData={formData} onChange={onChange} />
|
||||||
);
|
);
|
||||||
case 3:
|
case 3:
|
||||||
return (
|
|
||||||
<DistributionSettings
|
|
||||||
formData={formData}
|
|
||||||
onChange={onChange}
|
|
||||||
planId={planId}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case 4:
|
|
||||||
return <MessageSettings formData={formData} onChange={onChange} />;
|
return <MessageSettings formData={formData} onChange={onChange} />;
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect, useRef } from "react";
|
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import { Input, Button, Tag, Switch, Spin, message, Modal } from "antd";
|
import { Input, Button, Tag, Switch, Spin, message, Modal, Radio } from "antd";
|
||||||
import {
|
import {
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
EyeOutlined,
|
EyeOutlined,
|
||||||
@@ -7,13 +7,25 @@ import {
|
|||||||
DownloadOutlined,
|
DownloadOutlined,
|
||||||
SearchOutlined,
|
SearchOutlined,
|
||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
|
QrcodeOutlined,
|
||||||
|
CopyOutlined,
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
|
import { Toast, SpinLoading } from "antd-mobile";
|
||||||
|
import { Checkbox, Popup } from "antd-mobile";
|
||||||
import { uploadFile } from "@/api/common";
|
import { uploadFile } from "@/api/common";
|
||||||
import styles from "./base.module.scss";
|
import styles from "./base.module.scss";
|
||||||
import { posterTemplates } from "./base.data";
|
import { posterTemplates } from "./base.data";
|
||||||
import GroupSelection from "@/components/GroupSelection";
|
import GroupSelection from "@/components/GroupSelection";
|
||||||
import FileUpload from "@/components/Upload/FileUpload";
|
import FileUpload from "@/components/Upload/FileUpload";
|
||||||
|
import FriendSelection from "@/components/FriendSelection";
|
||||||
import { GroupSelectionItem } from "@/components/GroupSelection/data";
|
import { GroupSelectionItem } from "@/components/GroupSelection/data";
|
||||||
|
import { FriendSelectionItem } from "@/components/FriendSelection/data";
|
||||||
|
import { useUserStore } from "@/store/module/user";
|
||||||
|
import { fetchChannelList } from "@/pages/mobile/workspace/distribution-management/api";
|
||||||
|
import Layout from "@/components/Layout/Layout";
|
||||||
|
import PopupHeader from "@/components/PopuLayout/header";
|
||||||
|
import PopupFooter from "@/components/PopuLayout/footer";
|
||||||
|
import request from "@/api/request";
|
||||||
|
|
||||||
interface BasicSettingsProps {
|
interface BasicSettingsProps {
|
||||||
isEdit: boolean;
|
isEdit: boolean;
|
||||||
@@ -21,6 +33,7 @@ interface BasicSettingsProps {
|
|||||||
onChange: (data: any) => void;
|
onChange: (data: any) => void;
|
||||||
sceneList: any[];
|
sceneList: any[];
|
||||||
sceneLoading: boolean;
|
sceneLoading: boolean;
|
||||||
|
planId?: string; // 计划ID,用于生成渠道二维码
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Material {
|
interface Material {
|
||||||
@@ -45,13 +58,51 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({
|
|||||||
onChange,
|
onChange,
|
||||||
sceneList,
|
sceneList,
|
||||||
sceneLoading,
|
sceneLoading,
|
||||||
|
planId,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { user } = useUserStore();
|
||||||
|
const isAdmin = user?.isAdmin === 1; // 判断是否是管理员
|
||||||
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
|
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
|
||||||
const [materials] = useState<Material[]>(generatePosterMaterials());
|
const [materials] = useState<Material[]>(generatePosterMaterials());
|
||||||
const [selectedMaterials, setSelectedMaterials] = useState<Material[]>(
|
const [selectedMaterials, setSelectedMaterials] = useState<Material[]>(
|
||||||
formData.posters?.length > 0 ? formData.posters : [],
|
formData.posters?.length > 0 ? formData.posters : [],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 分销相关状态
|
||||||
|
const [distributionEnabled, setDistributionEnabled] = useState<boolean>(
|
||||||
|
formData.distributionEnabled ?? false,
|
||||||
|
);
|
||||||
|
const [channelModalVisible, setChannelModalVisible] = useState(false);
|
||||||
|
const [channelLoading, setChannelLoading] = useState(false);
|
||||||
|
const [channelList, setChannelList] = useState<any[]>([]);
|
||||||
|
const [tempSelectedChannelIds, setTempSelectedChannelIds] = useState<
|
||||||
|
Array<string | number>
|
||||||
|
>(formData.distributionChannelIds || []);
|
||||||
|
const [channelSearchQuery, setChannelSearchQuery] = useState("");
|
||||||
|
const [channelCurrentPage, setChannelCurrentPage] = useState(1);
|
||||||
|
const [channelTotal, setChannelTotal] = useState(0);
|
||||||
|
const [customerReward, setCustomerReward] = useState<number | undefined>(
|
||||||
|
formData.distributionCustomerReward
|
||||||
|
);
|
||||||
|
const [addReward, setAddReward] = useState<number | undefined>(
|
||||||
|
formData.distributionAddReward
|
||||||
|
);
|
||||||
|
|
||||||
|
// 二维码相关状态
|
||||||
|
const [qrCodeMap, setQrCodeMap] = useState<Record<string | number, {
|
||||||
|
qrCode: string;
|
||||||
|
url: string;
|
||||||
|
loading: boolean;
|
||||||
|
}>>({});
|
||||||
|
const [showQrDialog, setShowQrDialog] = useState(false);
|
||||||
|
const [currentQrChannel, setCurrentQrChannel] = useState<{
|
||||||
|
id: string | number;
|
||||||
|
name: string;
|
||||||
|
code?: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
// 自定义标签相关状态
|
// 自定义标签相关状态
|
||||||
const [customTagInput, setCustomTagInput] = useState("");
|
const [customTagInput, setCustomTagInput] = useState("");
|
||||||
const [customTagsOptions, setCustomTagsOptions] = useState<string[]>(
|
const [customTagsOptions, setCustomTagsOptions] = useState<string[]>(
|
||||||
@@ -240,6 +291,302 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ==================== 渠道设置相关函数 ====================
|
||||||
|
// 生成H5链接
|
||||||
|
const generateH5Url = (channelId: string | number, channelCode?: string): string => {
|
||||||
|
if (planId) {
|
||||||
|
return `https://h5.ckb.quwanzhi.com/#/pages/form/input2?id=${planId}&channelId=${channelId}`;
|
||||||
|
} else if (channelCode) {
|
||||||
|
return `https://h5.ckb.quwanzhi.com/#/pages/form/input2?channelCode=${channelCode}`;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
|
||||||
|
// 生成渠道二维码
|
||||||
|
const generateChannelQRCode = async (channelId: string | number, channelCode?: string) => {
|
||||||
|
const h5Url = generateH5Url(channelId, channelCode);
|
||||||
|
|
||||||
|
if (qrCodeMap[channelId]) {
|
||||||
|
if (!qrCodeMap[channelId].url) {
|
||||||
|
setQrCodeMap(prev => ({
|
||||||
|
...prev,
|
||||||
|
[channelId]: { ...prev[channelId], url: h5Url },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (qrCodeMap[channelId].qrCode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setQrCodeMap(prev => ({
|
||||||
|
...prev,
|
||||||
|
[channelId]: {
|
||||||
|
qrCode: "",
|
||||||
|
url: h5Url,
|
||||||
|
loading: true,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
setQrCodeMap(prev => ({
|
||||||
|
...prev,
|
||||||
|
[channelId]: { ...prev[channelId], loading: true },
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params: any = {};
|
||||||
|
if (planId) {
|
||||||
|
params.taskId = planId;
|
||||||
|
params.channelId = channelId;
|
||||||
|
} else if (channelCode) {
|
||||||
|
params.channelCode = channelCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await request(
|
||||||
|
`/v1/plan/getWxMinAppCode`,
|
||||||
|
params,
|
||||||
|
"GET"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response && typeof response === 'string' && response.startsWith('data:image')) {
|
||||||
|
setQrCodeMap(prev => ({
|
||||||
|
...prev,
|
||||||
|
[channelId]: {
|
||||||
|
qrCode: response,
|
||||||
|
url: h5Url,
|
||||||
|
loading: false,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
throw new Error("二维码生成失败");
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
Toast.show({
|
||||||
|
content: error.message || "生成二维码失败",
|
||||||
|
position: "top",
|
||||||
|
});
|
||||||
|
setQrCodeMap(prev => ({
|
||||||
|
...prev,
|
||||||
|
[channelId]: {
|
||||||
|
...prev[channelId],
|
||||||
|
qrCode: "",
|
||||||
|
url: h5Url,
|
||||||
|
loading: false,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 显示二维码弹窗
|
||||||
|
const handleShowQRCode = async (channel: { id: string | number; name: string; code?: string }) => {
|
||||||
|
setCurrentQrChannel(channel);
|
||||||
|
setShowQrDialog(true);
|
||||||
|
|
||||||
|
if (!qrCodeMap[channel.id]?.qrCode && !qrCodeMap[channel.id]?.loading) {
|
||||||
|
await generateChannelQRCode(channel.id, channel.code);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 同步分销相关的外部表单数据到本地状态
|
||||||
|
useEffect(() => {
|
||||||
|
setDistributionEnabled(formData.distributionEnabled ?? false);
|
||||||
|
setTempSelectedChannelIds(formData.distributionChannelIds || []);
|
||||||
|
setCustomerReward(formData.distributionCustomerReward);
|
||||||
|
setAddReward(formData.distributionAddReward);
|
||||||
|
}, [
|
||||||
|
formData.distributionEnabled,
|
||||||
|
formData.distributionChannelIds,
|
||||||
|
formData.distributionCustomerReward,
|
||||||
|
formData.distributionAddReward,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 加载分销渠道列表
|
||||||
|
const loadDistributionChannels = useCallback(
|
||||||
|
async (keyword: string = "", page: number = 1) => {
|
||||||
|
setChannelLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetchChannelList({
|
||||||
|
page,
|
||||||
|
limit: PAGE_SIZE,
|
||||||
|
keyword: keyword.trim() || undefined,
|
||||||
|
status: "enabled",
|
||||||
|
});
|
||||||
|
setChannelList(res.list || []);
|
||||||
|
setChannelTotal(res.total || 0);
|
||||||
|
} catch (error: any) {
|
||||||
|
// 错误处理
|
||||||
|
} finally {
|
||||||
|
setChannelLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleToggleDistribution = (value: boolean) => {
|
||||||
|
setDistributionEnabled(value);
|
||||||
|
if (!value) {
|
||||||
|
setTempSelectedChannelIds([]);
|
||||||
|
setCustomerReward(undefined);
|
||||||
|
setAddReward(undefined);
|
||||||
|
onChange({
|
||||||
|
...formData,
|
||||||
|
distributionEnabled: false,
|
||||||
|
distributionChannelIds: [],
|
||||||
|
distributionChannelsOptions: [],
|
||||||
|
distributionCustomerReward: undefined,
|
||||||
|
distributionAddReward: undefined,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
onChange({
|
||||||
|
...formData,
|
||||||
|
distributionEnabled: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 打开弹窗时获取第一页
|
||||||
|
useEffect(() => {
|
||||||
|
if (channelModalVisible) {
|
||||||
|
setChannelSearchQuery("");
|
||||||
|
setChannelCurrentPage(1);
|
||||||
|
setTempSelectedChannelIds(formData.distributionChannelIds || []);
|
||||||
|
loadDistributionChannels("", 1);
|
||||||
|
}
|
||||||
|
}, [channelModalVisible, loadDistributionChannels, formData.distributionChannelIds]);
|
||||||
|
|
||||||
|
// 搜索防抖
|
||||||
|
useEffect(() => {
|
||||||
|
if (!channelModalVisible) return;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setChannelCurrentPage(1);
|
||||||
|
loadDistributionChannels(channelSearchQuery, 1);
|
||||||
|
}, 500);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [channelSearchQuery, channelModalVisible, loadDistributionChannels]);
|
||||||
|
|
||||||
|
// 翻页时重新请求
|
||||||
|
useEffect(() => {
|
||||||
|
if (!channelModalVisible) return;
|
||||||
|
loadDistributionChannels(channelSearchQuery, channelCurrentPage);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [channelCurrentPage]);
|
||||||
|
|
||||||
|
const handleOpenChannelModal = () => {
|
||||||
|
setChannelModalVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChannelToggle = (channel: any) => {
|
||||||
|
const id = channel.id;
|
||||||
|
setTempSelectedChannelIds(prev =>
|
||||||
|
prev.includes(id) ? prev.filter(v => v !== id) : [...prev, id],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredChannels = channelList;
|
||||||
|
const channelTotalPages = Math.max(1, Math.ceil(channelTotal / PAGE_SIZE));
|
||||||
|
|
||||||
|
// 全选当前页
|
||||||
|
const handleSelectAllCurrentPage = (checked: boolean) => {
|
||||||
|
if (checked) {
|
||||||
|
const currentPageChannels = filteredChannels.filter(
|
||||||
|
(channel: any) => !tempSelectedChannelIds.includes(channel.id),
|
||||||
|
);
|
||||||
|
setTempSelectedChannelIds(prev => [
|
||||||
|
...prev,
|
||||||
|
...currentPageChannels.map((c: any) => c.id),
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
const currentPageChannelIds = filteredChannels.map((c: any) => c.id);
|
||||||
|
setTempSelectedChannelIds(prev =>
|
||||||
|
prev.filter(id => !currentPageChannelIds.includes(id)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 检查当前页是否全选
|
||||||
|
const isCurrentPageAllSelected =
|
||||||
|
filteredChannels.length > 0 &&
|
||||||
|
filteredChannels.every((channel: any) =>
|
||||||
|
tempSelectedChannelIds.includes(channel.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleConfirmChannels = () => {
|
||||||
|
const selectedOptions =
|
||||||
|
channelList
|
||||||
|
.filter(c => tempSelectedChannelIds.includes(c.id))
|
||||||
|
.map(c => ({
|
||||||
|
id: c.id,
|
||||||
|
name: c.name,
|
||||||
|
code: c.code,
|
||||||
|
})) || [];
|
||||||
|
|
||||||
|
onChange({
|
||||||
|
...formData,
|
||||||
|
distributionEnabled: true,
|
||||||
|
distributionChannelIds: tempSelectedChannelIds,
|
||||||
|
distributionChannelsOptions: selectedOptions,
|
||||||
|
});
|
||||||
|
setDistributionEnabled(true);
|
||||||
|
setChannelModalVisible(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelChannels = () => {
|
||||||
|
setChannelModalVisible(false);
|
||||||
|
setTempSelectedChannelIds(formData.distributionChannelIds || []);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取显示文本
|
||||||
|
const getChannelDisplayText = () => {
|
||||||
|
const selectedChannels = formData.distributionChannelsOptions || [];
|
||||||
|
if (selectedChannels.length === 0) return "";
|
||||||
|
return `已选择 ${selectedChannels.length} 个渠道`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 删除已选渠道
|
||||||
|
const handleRemoveChannel = (id: string | number) => {
|
||||||
|
const newChannelIds = (formData.distributionChannelIds || []).filter(
|
||||||
|
(cid: string | number) => cid !== id
|
||||||
|
);
|
||||||
|
const newChannelOptions = (formData.distributionChannelsOptions || []).filter(
|
||||||
|
(item: { id: string | number; name: string }) => item.id !== id
|
||||||
|
);
|
||||||
|
onChange({
|
||||||
|
...formData,
|
||||||
|
distributionChannelIds: newChannelIds,
|
||||||
|
distributionChannelsOptions: newChannelOptions,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 清除所有已选渠道
|
||||||
|
const handleClearAllChannels = () => {
|
||||||
|
onChange({
|
||||||
|
...formData,
|
||||||
|
distributionChannelIds: [],
|
||||||
|
distributionChannelsOptions: [],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== 拉群设置相关函数 ====================
|
||||||
|
const handleToggleGroupInvite = (value: boolean) => {
|
||||||
|
onChange({
|
||||||
|
...formData,
|
||||||
|
groupInviteEnabled: value,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGroupNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
onChange({
|
||||||
|
...formData,
|
||||||
|
groupName: e.target.value,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFixedMembersSelect = (friends: FriendSelectionItem[]) => {
|
||||||
|
onChange({
|
||||||
|
...formData,
|
||||||
|
fixedGroupMembers: friends,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles["basic-container"]}>
|
<div className={styles["basic-container"]}>
|
||||||
@@ -289,6 +636,22 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{/* 计划类型选择 - 仅管理员可见 */}
|
||||||
|
{isAdmin && (
|
||||||
|
<>
|
||||||
|
<div className={styles["basic-label"]}>计划类型</div>
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<Radio.Group
|
||||||
|
value={formData.planType ?? 1}
|
||||||
|
onChange={e => onChange({ ...formData, planType: e.target.value })}
|
||||||
|
style={{ display: "flex", gap: 24 }}
|
||||||
|
>
|
||||||
|
<Radio value={0}>全局计划</Radio>
|
||||||
|
<Radio value={1}>独立计划</Radio>
|
||||||
|
</Radio.Group>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{/* 计划名称输入区 */}
|
{/* 计划名称输入区 */}
|
||||||
<div className={styles["basic-label"]}>计划名称</div>
|
<div className={styles["basic-label"]}>计划名称</div>
|
||||||
<div className={styles["basic-input-block"]}>
|
<div className={styles["basic-input-block"]}>
|
||||||
@@ -579,6 +942,278 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 分销设置 */}
|
||||||
|
<div className={styles["basic-distribution"]}>
|
||||||
|
<div className={styles["basic-distribution-header"]}>
|
||||||
|
<div>
|
||||||
|
<div className={styles["basic-distribution-title"]}>分销设置</div>
|
||||||
|
<div className={styles["basic-distribution-desc"]}>
|
||||||
|
开启后,可将当前场景的获客用户同步到指定分销渠道
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={distributionEnabled}
|
||||||
|
onChange={handleToggleDistribution}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{distributionEnabled && (
|
||||||
|
<>
|
||||||
|
{/* 输入框 */}
|
||||||
|
<div className={styles["distribution-input-wrapper"]}>
|
||||||
|
<Input
|
||||||
|
placeholder="选择分销渠道"
|
||||||
|
value={getChannelDisplayText()}
|
||||||
|
onClick={handleOpenChannelModal}
|
||||||
|
prefix={<SearchOutlined />}
|
||||||
|
allowClear
|
||||||
|
onClear={handleClearAllChannels}
|
||||||
|
size="large"
|
||||||
|
readOnly
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* 已选渠道列表 */}
|
||||||
|
{formData.distributionChannelsOptions &&
|
||||||
|
formData.distributionChannelsOptions.length > 0 ? (
|
||||||
|
<div
|
||||||
|
className={styles["distribution-selected-list"]}
|
||||||
|
style={{
|
||||||
|
maxHeight: 300,
|
||||||
|
overflowY: "auto",
|
||||||
|
marginTop: 8,
|
||||||
|
border: "1px solid #e5e6eb",
|
||||||
|
borderRadius: 8,
|
||||||
|
background: "#fff",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formData.distributionChannelsOptions.map(
|
||||||
|
(item: { id: string | number; name: string; code?: string }) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className={styles["distribution-selected-item"]}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: "8px 12px",
|
||||||
|
borderBottom: "1px solid #f0f0f0",
|
||||||
|
fontSize: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 渠道图标 */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: "6px",
|
||||||
|
background:
|
||||||
|
"linear-gradient(135deg, #1677ff 0%, #0958d9 100%)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
overflow: "hidden",
|
||||||
|
boxShadow: "0 2px 8px rgba(22, 119, 255, 0.25)",
|
||||||
|
marginRight: "12px",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 16,
|
||||||
|
color: "#fff",
|
||||||
|
fontWeight: 700,
|
||||||
|
textShadow: "0 1px 3px rgba(0,0,0,0.3)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(item.name || "渠")[0]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 500,
|
||||||
|
color: "#1a1a1a",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</div>
|
||||||
|
{item.code && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: "#8c8c8c",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
编码: {item.code}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 4, alignItems: "center" }}>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<QrcodeOutlined />}
|
||||||
|
size="small"
|
||||||
|
style={{
|
||||||
|
color: "#1890ff",
|
||||||
|
border: "none",
|
||||||
|
background: "none",
|
||||||
|
minWidth: 24,
|
||||||
|
height: 24,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
onClick={() => handleShowQRCode(item)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
size="small"
|
||||||
|
style={{
|
||||||
|
color: "#ff4d4f",
|
||||||
|
border: "none",
|
||||||
|
background: "none",
|
||||||
|
minWidth: 24,
|
||||||
|
height: 24,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
onClick={() => handleRemoveChannel(item.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{/* 奖励金额设置 */}
|
||||||
|
<div className={styles["distribution-rewards"]}>
|
||||||
|
<div className={styles["basic-label"]}>获客奖励金额(元)</div>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="请输入获客奖励金额"
|
||||||
|
value={customerReward}
|
||||||
|
onChange={e => {
|
||||||
|
const value = e.target.value ? Number(e.target.value) : undefined;
|
||||||
|
setCustomerReward(value);
|
||||||
|
onChange({
|
||||||
|
...formData,
|
||||||
|
distributionCustomerReward: value,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
min={0}
|
||||||
|
step={0.01}
|
||||||
|
style={{ marginBottom: 12 }}
|
||||||
|
/>
|
||||||
|
<div className={styles["basic-label"]}>添加奖励金额(元)</div>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="请输入添加奖励金额"
|
||||||
|
value={addReward}
|
||||||
|
onChange={e => {
|
||||||
|
const value = e.target.value ? Number(e.target.value) : undefined;
|
||||||
|
setAddReward(value);
|
||||||
|
onChange({
|
||||||
|
...formData,
|
||||||
|
distributionAddReward: value,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
min={0}
|
||||||
|
step={0.01}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 拉群设置 */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: 16,
|
||||||
|
padding: 16,
|
||||||
|
borderRadius: 8,
|
||||||
|
border: "1px solid #f0f0f0",
|
||||||
|
background: "#fff",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
marginBottom: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 500,
|
||||||
|
marginBottom: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
拉群设置
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: "#8c8c8c" }}>
|
||||||
|
开启后,可配置群名称和固定群成员,将用户引导到指定微信群
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={!!formData.groupInviteEnabled}
|
||||||
|
onChange={handleToggleGroupInvite}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formData.groupInviteEnabled && (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||||
|
<Input
|
||||||
|
placeholder="请输入群名称"
|
||||||
|
value={formData.groupName || ""}
|
||||||
|
onChange={handleGroupNameChange}
|
||||||
|
/>
|
||||||
|
{/* 固定成员选择 */}
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
marginBottom: 4,
|
||||||
|
color: "#595959",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
固定群成员
|
||||||
|
</div>
|
||||||
|
<FriendSelection
|
||||||
|
selectedOptions={
|
||||||
|
(formData.fixedGroupMembers || []) as FriendSelectionItem[]
|
||||||
|
}
|
||||||
|
onSelect={handleFixedMembersSelect}
|
||||||
|
placeholder="选择固定群成员"
|
||||||
|
showSelectedList={true}
|
||||||
|
deviceIds={formData.deviceGroups || []}
|
||||||
|
enableDeviceFilter={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className={styles["basic-footer-switch"]}>
|
<div className={styles["basic-footer-switch"]}>
|
||||||
<span>是否启用</span>
|
<span>是否启用</span>
|
||||||
<Switch
|
<Switch
|
||||||
@@ -587,6 +1222,255 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 分销渠道选择弹框 */}
|
||||||
|
<Popup
|
||||||
|
visible={channelModalVisible}
|
||||||
|
onMaskClick={handleCancelChannels}
|
||||||
|
position="bottom"
|
||||||
|
bodyStyle={{ height: "100vh" }}
|
||||||
|
closeOnMaskClick={false}
|
||||||
|
>
|
||||||
|
<Layout
|
||||||
|
header={
|
||||||
|
<PopupHeader
|
||||||
|
title="选择分销渠道"
|
||||||
|
searchQuery={channelSearchQuery}
|
||||||
|
setSearchQuery={setChannelSearchQuery}
|
||||||
|
searchPlaceholder="搜索渠道名称、编码..."
|
||||||
|
loading={channelLoading}
|
||||||
|
onRefresh={() => loadDistributionChannels(channelSearchQuery, channelCurrentPage)}
|
||||||
|
showTabs={false}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
footer={
|
||||||
|
<PopupFooter
|
||||||
|
currentPage={channelCurrentPage}
|
||||||
|
totalPages={channelTotalPages}
|
||||||
|
loading={channelLoading}
|
||||||
|
selectedCount={tempSelectedChannelIds.length}
|
||||||
|
onPageChange={setChannelCurrentPage}
|
||||||
|
onCancel={handleCancelChannels}
|
||||||
|
onConfirm={handleConfirmChannels}
|
||||||
|
isAllSelected={isCurrentPageAllSelected}
|
||||||
|
onSelectAll={handleSelectAllCurrentPage}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={styles["channelList"]}>
|
||||||
|
{channelLoading && channelList.length === 0 ? (
|
||||||
|
<div className={styles["loadingBox"]}>
|
||||||
|
<div className={styles["loadingText"]}>加载中...</div>
|
||||||
|
</div>
|
||||||
|
) : filteredChannels.length === 0 ? (
|
||||||
|
<div className={styles["loadingBox"]}>
|
||||||
|
<div className={styles["loadingText"]}>
|
||||||
|
暂无分销渠道,请先在「分销管理」中创建渠道
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles["channelListInner"]}>
|
||||||
|
{filteredChannels.map((channel: any) => (
|
||||||
|
<div key={channel.id} className={styles["channelItem"]}>
|
||||||
|
<div className={styles["headerRow"]}>
|
||||||
|
<div className={styles["checkboxContainer"]}>
|
||||||
|
<Checkbox
|
||||||
|
checked={tempSelectedChannelIds.includes(channel.id)}
|
||||||
|
onChange={() => handleChannelToggle(channel)}
|
||||||
|
className={styles["channelCheckbox"]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className={styles["codeText"]}>
|
||||||
|
编码: {channel.code}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles["mainContent"]}>
|
||||||
|
<div className={styles["channelContent"]}>
|
||||||
|
<div className={styles["channelInfoRow"]}>
|
||||||
|
<span className={styles["channelName"]}>
|
||||||
|
{channel.name}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
channel.status === "enabled"
|
||||||
|
? styles["statusEnabled"]
|
||||||
|
: styles["statusDisabled"]
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{channel.status === "enabled" ? "启用" : "禁用"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles["channelInfoDetail"]}>
|
||||||
|
{channel.phone && (
|
||||||
|
<div className={styles["infoItem"]}>
|
||||||
|
<span className={styles["infoLabel"]}>手机号:</span>
|
||||||
|
<span className={styles["infoValue"]}>
|
||||||
|
{channel.phone}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{channel.wechatId && (
|
||||||
|
<div className={styles["infoItem"]}>
|
||||||
|
<span className={styles["infoLabel"]}>微信号:</span>
|
||||||
|
<span className={styles["infoValue"]}>
|
||||||
|
{channel.wechatId}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
</Popup>
|
||||||
|
|
||||||
|
{/* 二维码弹窗 */}
|
||||||
|
<Popup
|
||||||
|
visible={showQrDialog}
|
||||||
|
onMaskClick={() => setShowQrDialog(false)}
|
||||||
|
position="bottom"
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
background: "#fff",
|
||||||
|
borderTopLeftRadius: 16,
|
||||||
|
borderTopRightRadius: 16,
|
||||||
|
padding: "20px",
|
||||||
|
maxHeight: "80vh",
|
||||||
|
overflowY: "auto",
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: 20,
|
||||||
|
}}>
|
||||||
|
<h3 style={{ margin: 0, fontSize: 18, fontWeight: 600 }}>
|
||||||
|
{currentQrChannel?.name || "渠道"}二维码
|
||||||
|
</h3>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
onClick={() => setShowQrDialog(false)}
|
||||||
|
>
|
||||||
|
关闭
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 16,
|
||||||
|
}}>
|
||||||
|
{currentQrChannel && (
|
||||||
|
<>
|
||||||
|
{qrCodeMap[currentQrChannel.id]?.loading ? (
|
||||||
|
<div style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 12,
|
||||||
|
padding: "40px 20px",
|
||||||
|
}}>
|
||||||
|
<SpinLoading color="primary" style={{ fontSize: 32 }} />
|
||||||
|
<div style={{ fontSize: 14, color: "#666" }}>生成二维码中...</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* 二维码显示区域 */}
|
||||||
|
{qrCodeMap[currentQrChannel.id]?.qrCode ? (
|
||||||
|
<img
|
||||||
|
src={qrCodeMap[currentQrChannel.id].qrCode}
|
||||||
|
alt="渠道二维码"
|
||||||
|
style={{
|
||||||
|
width: 200,
|
||||||
|
height: 200,
|
||||||
|
border: "1px solid #e5e6eb",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 8,
|
||||||
|
background: "#fff",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 12,
|
||||||
|
padding: "40px 20px",
|
||||||
|
color: "#999",
|
||||||
|
}}>
|
||||||
|
<QrcodeOutlined style={{ fontSize: 48 }} />
|
||||||
|
<div style={{ fontSize: 14 }}>二维码生成失败</div>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
onClick={() => currentQrChannel && generateChannelQRCode(currentQrChannel.id, currentQrChannel.code)}
|
||||||
|
>
|
||||||
|
重新生成
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* H5链接展示 */}
|
||||||
|
{qrCodeMap[currentQrChannel.id]?.url && (
|
||||||
|
<div style={{
|
||||||
|
width: "100%",
|
||||||
|
marginTop: 16,
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
fontSize: 14,
|
||||||
|
color: "#666",
|
||||||
|
marginBottom: 8,
|
||||||
|
fontWeight: 500,
|
||||||
|
}}>
|
||||||
|
H5链接
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: 8,
|
||||||
|
alignItems: "center",
|
||||||
|
}}>
|
||||||
|
<Input
|
||||||
|
value={qrCodeMap[currentQrChannel.id].url}
|
||||||
|
readOnly
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
onClick={() => {
|
||||||
|
const link = qrCodeMap[currentQrChannel.id].url;
|
||||||
|
navigator.clipboard.writeText(link);
|
||||||
|
Toast.show({
|
||||||
|
content: "链接已复制到剪贴板",
|
||||||
|
position: "top",
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CopyOutlined />
|
||||||
|
复制
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Popup>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
110
src/pages/mobile/scenarios/plan/new/steps/GroupSettings.tsx
Normal file
110
src/pages/mobile/scenarios/plan/new/steps/GroupSettings.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Input, Switch } from "antd";
|
||||||
|
import styles from "./base.module.scss";
|
||||||
|
import FriendSelection from "@/components/FriendSelection";
|
||||||
|
import type { FriendSelectionItem } from "@/components/FriendSelection/data";
|
||||||
|
|
||||||
|
interface GroupSettingsProps {
|
||||||
|
formData: any;
|
||||||
|
onChange: (data: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GroupSettings: React.FC<GroupSettingsProps> = ({ formData, onChange }) => {
|
||||||
|
const handleToggleGroupInvite = (value: boolean) => {
|
||||||
|
onChange({
|
||||||
|
...formData,
|
||||||
|
groupInviteEnabled: value,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGroupNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
onChange({
|
||||||
|
...formData,
|
||||||
|
groupName: e.target.value,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFixedMembersSelect = (friends: FriendSelectionItem[]) => {
|
||||||
|
onChange({
|
||||||
|
...formData,
|
||||||
|
fixedGroupMembers: friends,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles["basic-container"]}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: 16,
|
||||||
|
padding: 16,
|
||||||
|
borderRadius: 8,
|
||||||
|
border: "1px solid #f0f0f0",
|
||||||
|
background: "#fff",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
marginBottom: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 500,
|
||||||
|
marginBottom: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
拉群设置
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: "#8c8c8c" }}>
|
||||||
|
开启后,可配置群名称和固定群成员,将用户引导到指定微信群
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={!!formData.groupInviteEnabled}
|
||||||
|
onChange={handleToggleGroupInvite}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formData.groupInviteEnabled && (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||||
|
<Input
|
||||||
|
placeholder="请输入群名称"
|
||||||
|
value={formData.groupName || ""}
|
||||||
|
onChange={handleGroupNameChange}
|
||||||
|
/>
|
||||||
|
{/* 固定成员选择,复用好友选择组件 */}
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
marginBottom: 4,
|
||||||
|
color: "#595959",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
固定群成员
|
||||||
|
</div>
|
||||||
|
<FriendSelection
|
||||||
|
selectedOptions={
|
||||||
|
(formData.fixedGroupMembers || []) as FriendSelectionItem[]
|
||||||
|
}
|
||||||
|
onSelect={handleFixedMembersSelect}
|
||||||
|
placeholder="选择固定群成员"
|
||||||
|
showSelectedList={true}
|
||||||
|
// 根据已选择设备过滤好友列表
|
||||||
|
deviceIds={formData.deviceGroups || []}
|
||||||
|
enableDeviceFilter={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GroupSettings;
|
||||||
@@ -160,31 +160,31 @@ export function deleteContent(contentId: string) {
|
|||||||
return request(`/api/contents/${contentId}`, undefined, "DELETE");
|
return request(`/api/contents/${contentId}`, undefined, "DELETE");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 流量池相关接口 ====================
|
// ==================== 流量池相关接口 (V2) ====================
|
||||||
|
|
||||||
// 获取流量池列表
|
// 获取流量池分组列表
|
||||||
export function getTrafficPools() {
|
export function getTrafficPools() {
|
||||||
return request("/api/traffic-pools", undefined, "GET");
|
return request("/v1/traffic/pool/v2/groups", {}, "GET");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取流量池详情
|
// 获取流量池用户详情
|
||||||
export function getTrafficPoolDetail(poolId: string) {
|
export function getTrafficPoolDetail(poolId: string) {
|
||||||
return request(`/api/traffic-pools/${poolId}`, undefined, "GET");
|
return request("/v1/traffic/pool/v2/detail", { id: poolId }, "GET");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建流量池
|
// 创建流量池分组
|
||||||
export function createTrafficPool(data: any) {
|
export function createTrafficPool(data: { groupName: string; description?: string; ruleType?: number; ruleConfig?: any }) {
|
||||||
return request("/api/traffic-pools", data, "POST");
|
return request("/v1/traffic/pool/v2/group/create", data, "POST");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新流量池
|
// 更新流量池分组
|
||||||
export function updateTrafficPool(poolId: string, data: any) {
|
export function updateTrafficPool(groupId: string, data: any) {
|
||||||
return request(`/api/traffic-pools/${poolId}`, data, "PUT");
|
return request("/v1/traffic/pool/v2/group/update", { groupId, ...data }, "PUT");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 删除流量池
|
// 删除流量池分组
|
||||||
export function deleteTrafficPool(poolId: string) {
|
export function deleteTrafficPool(groupId: string) {
|
||||||
return request(`/api/traffic-pools/${poolId}`, undefined, "DELETE");
|
return request("/v1/traffic/pool/v2/group/delete", { groupId }, "DELETE");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 工作台相关接口 ====================
|
// ==================== 工作台相关接口 ====================
|
||||||
|
|||||||
97
src/pages/mobile/test/api.ts
Normal file
97
src/pages/mobile/test/api.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
import { Toast } from "antd-mobile";
|
||||||
|
import { generateSign } from "./utils/sign";
|
||||||
|
|
||||||
|
// API配置
|
||||||
|
const API_BASE_URL = "https://ckbapi.quwanzhi.com/v1/api";
|
||||||
|
// 默认API Key(用于测试)
|
||||||
|
export const DEFAULT_API_KEY = "v3pzy-zcfkg-96jio-7xgh6-14kio";
|
||||||
|
|
||||||
|
export interface SubmitLeadParams {
|
||||||
|
phone: string;
|
||||||
|
name: string;
|
||||||
|
source: string;
|
||||||
|
remark?: string;
|
||||||
|
wechatId?: string;
|
||||||
|
tags?: string;
|
||||||
|
siteTags?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubmitLeadResponse {
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
data: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提交线索到存客宝
|
||||||
|
* @param params 线索参数
|
||||||
|
* @param apiKey API密钥(必填)
|
||||||
|
*/
|
||||||
|
export async function submitLead(
|
||||||
|
params: SubmitLeadParams,
|
||||||
|
apiKey: string,
|
||||||
|
): Promise<SubmitLeadResponse> {
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new Error("apiKey不能为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 生成时间戳(秒级)
|
||||||
|
const timestamp = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
// 构建请求参数
|
||||||
|
const requestParams: Record<string, any> = {
|
||||||
|
apiKey: apiKey,
|
||||||
|
timestamp,
|
||||||
|
phone: params.phone,
|
||||||
|
name: params.name,
|
||||||
|
source: params.source,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 添加可选字段(只添加非空值)
|
||||||
|
if (params.remark) {
|
||||||
|
requestParams.remark = params.remark;
|
||||||
|
}
|
||||||
|
if (params.wechatId) {
|
||||||
|
requestParams.wechatId = params.wechatId;
|
||||||
|
}
|
||||||
|
if (params.tags) {
|
||||||
|
requestParams.tags = params.tags;
|
||||||
|
}
|
||||||
|
if (params.siteTags) {
|
||||||
|
requestParams.siteTags = params.siteTags;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成签名
|
||||||
|
const sign = generateSign(requestParams, apiKey);
|
||||||
|
requestParams.sign = sign;
|
||||||
|
|
||||||
|
// 发送请求
|
||||||
|
const response = await axios.post<SubmitLeadResponse>(
|
||||||
|
`${API_BASE_URL}/scenarios`,
|
||||||
|
requestParams,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
timeout: 20000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = response.data;
|
||||||
|
|
||||||
|
// 处理响应
|
||||||
|
if (result.code === 200) {
|
||||||
|
return result;
|
||||||
|
} else {
|
||||||
|
throw new Error(result.message || "提交失败");
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
const errorMessage =
|
||||||
|
error.response?.data?.message ||
|
||||||
|
error.message ||
|
||||||
|
"网络请求失败,请稍后重试";
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/pages/mobile/test/components/TestFormModal.module.scss
Normal file
47
src/pages/mobile/test/components/TestFormModal.module.scss
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
.modalContainer {
|
||||||
|
background: #fff;
|
||||||
|
min-height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: #fff;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalTitle {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.closeIcon {
|
||||||
|
font-size: 20px;
|
||||||
|
color: #666;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
transition: color 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalContent {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formFooter {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 24px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
201
src/pages/mobile/test/components/TestFormModal.tsx
Normal file
201
src/pages/mobile/test/components/TestFormModal.tsx
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { Popup, Form, Input, TextArea, Button, Toast } from "antd-mobile";
|
||||||
|
import { CloseOutlined } from "@ant-design/icons";
|
||||||
|
import styles from "./TestFormModal.module.scss";
|
||||||
|
import { submitLead, DEFAULT_API_KEY } from "../api";
|
||||||
|
|
||||||
|
interface TestFormModalProps {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit?: (values: TestFormValues) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TestFormValues {
|
||||||
|
apiKey: string;
|
||||||
|
phone: string;
|
||||||
|
name: string;
|
||||||
|
source: string;
|
||||||
|
remark: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TestFormModal: React.FC<TestFormModalProps> = ({
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
}) => {
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
try {
|
||||||
|
const values = await form.validateFields();
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
// 获取API Key,如果没有输入则使用默认值
|
||||||
|
const apiKey = values.apiKey?.trim() || DEFAULT_API_KEY;
|
||||||
|
|
||||||
|
// 调用API提交数据
|
||||||
|
const result = await submitLead(
|
||||||
|
{
|
||||||
|
phone: values.phone,
|
||||||
|
name: values.name,
|
||||||
|
source: values.source,
|
||||||
|
remark: values.remark || undefined,
|
||||||
|
},
|
||||||
|
apiKey,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 调用提交回调(如果提供)
|
||||||
|
if (onSubmit) {
|
||||||
|
onSubmit(values as TestFormValues);
|
||||||
|
}
|
||||||
|
|
||||||
|
Toast.show({
|
||||||
|
content: result.message || "提交成功",
|
||||||
|
icon: "success",
|
||||||
|
});
|
||||||
|
|
||||||
|
// 重置表单并关闭弹框
|
||||||
|
form.resetFields();
|
||||||
|
onClose();
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("提交失败:", error);
|
||||||
|
Toast.show({
|
||||||
|
content: error.message || "提交失败,请稍后重试",
|
||||||
|
icon: "fail",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
form.resetFields();
|
||||||
|
// 重置时恢复默认API Key
|
||||||
|
form.setFieldsValue({ apiKey: DEFAULT_API_KEY });
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 初始化表单默认值
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
form.setFieldsValue({ apiKey: DEFAULT_API_KEY });
|
||||||
|
}
|
||||||
|
}, [visible, form]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popup
|
||||||
|
visible={visible}
|
||||||
|
onMaskClick={handleClose}
|
||||||
|
position="bottom"
|
||||||
|
bodyStyle={{
|
||||||
|
borderTopLeftRadius: 16,
|
||||||
|
borderTopRightRadius: 16,
|
||||||
|
maxHeight: "90vh",
|
||||||
|
overflowY: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={styles.modalContainer}>
|
||||||
|
{/* 头部 */}
|
||||||
|
<div className={styles.modalHeader}>
|
||||||
|
<h3 className={styles.modalTitle}>测试表单</h3>
|
||||||
|
<CloseOutlined
|
||||||
|
className={styles.closeIcon}
|
||||||
|
onClick={handleClose}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 表单内容 */}
|
||||||
|
<div className={styles.modalContent}>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
footer={
|
||||||
|
<div className={styles.formFooter}>
|
||||||
|
<Button
|
||||||
|
onClick={handleClose}
|
||||||
|
style={{ marginRight: 12 }}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="primary"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
loading={loading}
|
||||||
|
block
|
||||||
|
>
|
||||||
|
提交
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
label="API Key"
|
||||||
|
name="apiKey"
|
||||||
|
rules={[]}
|
||||||
|
extra="留空则使用默认API Key"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
placeholder={`默认: ${DEFAULT_API_KEY}`}
|
||||||
|
defaultValue={DEFAULT_API_KEY}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label="手机号"
|
||||||
|
name="phone"
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: "请输入手机号" },
|
||||||
|
{
|
||||||
|
pattern: /^1[3-9]\d{9}$/,
|
||||||
|
message: "请输入正确的手机号",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
placeholder="请输入手机号"
|
||||||
|
type="tel"
|
||||||
|
maxLength={11}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label="姓名"
|
||||||
|
name="name"
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: "请输入姓名" },
|
||||||
|
{ max: 20, message: "姓名不能超过20个字符" },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input placeholder="请输入姓名" maxLength={20} />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label="来源"
|
||||||
|
name="source"
|
||||||
|
rules={[{ required: true, message: "请输入来源" }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="请输入来源" maxLength={50} />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label="备注"
|
||||||
|
name="remark"
|
||||||
|
rules={[{ max: 200, message: "备注不能超过200个字符" }]}
|
||||||
|
>
|
||||||
|
<TextArea
|
||||||
|
placeholder="请输入备注(选填)"
|
||||||
|
showCount
|
||||||
|
maxLength={200}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Popup>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TestFormModal;
|
||||||
@@ -1,19 +1,28 @@
|
|||||||
import React from "react";
|
import React, { useState } from "react";
|
||||||
import { Card, Button, Space, Typography, Tag } from "antd";
|
import { Card, Button, Space, Typography, Tag } from "antd";
|
||||||
import {
|
import {
|
||||||
MessageOutlined,
|
MessageOutlined,
|
||||||
SelectOutlined,
|
SelectOutlined,
|
||||||
UploadOutlined,
|
UploadOutlined,
|
||||||
|
FormOutlined,
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { isDevelopment } from "@/utils/env";
|
import { isDevelopment } from "@/utils/env";
|
||||||
import Layout from "@/components/Layout/Layout";
|
import Layout from "@/components/Layout/Layout";
|
||||||
import NavCommon from "@/components/NavCommon";
|
import NavCommon from "@/components/NavCommon";
|
||||||
|
import TestFormModal, { TestFormValues } from "./components/TestFormModal";
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
const TestIndex: React.FC = () => {
|
const TestIndex: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [testFormVisible, setTestFormVisible] = useState(false);
|
||||||
|
|
||||||
|
const handleTestFormSubmit = (values: TestFormValues) => {
|
||||||
|
// API调用已在TestFormModal内部完成
|
||||||
|
// 这里可以添加额外的处理逻辑,如日志记录、数据分析等
|
||||||
|
console.log("测试表单提交成功,数据:", values);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout header={<NavCommon title="测试页面" />}>
|
<Layout header={<NavCommon title="测试页面" />}>
|
||||||
@@ -60,11 +69,31 @@ const TestIndex: React.FC = () => {
|
|||||||
</Space>
|
</Space>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card title="功能测试" size="small">
|
||||||
|
<Space direction="vertical" style={{ width: "100%" }}>
|
||||||
|
<Button
|
||||||
|
icon={<FormOutlined />}
|
||||||
|
size="large"
|
||||||
|
block
|
||||||
|
onClick={() => setTestFormVisible(true)}
|
||||||
|
>
|
||||||
|
测试表单
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card title="说明" size="small">
|
<Card title="说明" size="small">
|
||||||
<Text>这里提供各种功能的测试页面,方便开发和调试。</Text>
|
<Text>这里提供各种功能的测试页面,方便开发和调试。</Text>
|
||||||
</Card>
|
</Card>
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 测试表单弹框 */}
|
||||||
|
<TestFormModal
|
||||||
|
visible={testFormVisible}
|
||||||
|
onClose={() => setTestFormVisible(false)}
|
||||||
|
onSubmit={handleTestFormSubmit}
|
||||||
|
/>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
54
src/pages/mobile/test/utils/sign.ts
Normal file
54
src/pages/mobile/test/utils/sign.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* 签名生成工具
|
||||||
|
* 根据API文档的签名规则生成MD5签名
|
||||||
|
*/
|
||||||
|
|
||||||
|
import CryptoJS from "crypto-js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成MD5哈希值
|
||||||
|
*/
|
||||||
|
function md5(text: string): string {
|
||||||
|
return CryptoJS.MD5(text).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成签名
|
||||||
|
* 根据API文档的签名规则生成MD5签名
|
||||||
|
*/
|
||||||
|
export function generateSign(
|
||||||
|
params: Record<string, any>,
|
||||||
|
apiKey: string,
|
||||||
|
): string {
|
||||||
|
// 第一步:移除 sign、apiKey、portrait
|
||||||
|
const filteredParams: Record<string, any> = { ...params };
|
||||||
|
delete filteredParams.sign;
|
||||||
|
delete filteredParams.apiKey;
|
||||||
|
delete filteredParams.portrait;
|
||||||
|
|
||||||
|
// 第二步:移除空值(null 和空字符串)
|
||||||
|
const nonEmptyParams: Record<string, any> = {};
|
||||||
|
for (const key in filteredParams) {
|
||||||
|
const value = filteredParams[key];
|
||||||
|
if (value !== null && value !== "" && value !== undefined) {
|
||||||
|
nonEmptyParams[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 第三步:按参数名升序排序
|
||||||
|
const sortedKeys = Object.keys(nonEmptyParams).sort();
|
||||||
|
|
||||||
|
// 第四步:拼接参数值
|
||||||
|
let stringToSign = "";
|
||||||
|
for (const key of sortedKeys) {
|
||||||
|
stringToSign += String(nonEmptyParams[key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 第五步:第一次MD5
|
||||||
|
const firstMd5 = md5(stringToSign);
|
||||||
|
|
||||||
|
// 第六步:拼接apiKey后第二次MD5
|
||||||
|
const finalSign = md5(firstMd5 + apiKey);
|
||||||
|
|
||||||
|
return finalSign;
|
||||||
|
}
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
|
// 页面容器
|
||||||
|
.auto-like-page {
|
||||||
|
padding: 0 16px 24px;
|
||||||
|
background: #f8fafc;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
.task-list {
|
.task-list {
|
||||||
padding: 0 16px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
@@ -252,6 +258,50 @@
|
|||||||
color: #999;
|
color: #999;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 计划分组(全局 / 独立)
|
||||||
|
.info-box {
|
||||||
|
background: #eff6ff;
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #1e40af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-dot {
|
||||||
|
width: 4px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title-independent {
|
||||||
|
.section-dot {
|
||||||
|
background: #fb923c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-list-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
// 移动端适配
|
// 移动端适配
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.task-info {
|
.task-info {
|
||||||
@@ -272,7 +322,7 @@
|
|||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-list {
|
.auto-like-page {
|
||||||
padding: 0 12px;
|
padding: 0 12px 16px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ const AutoLike: React.FC = () => {
|
|||||||
const Res: any = await fetchAutoLikeTasks();
|
const Res: any = await fetchAutoLikeTasks();
|
||||||
// 数据在 data.list 中
|
// 数据在 data.list 中
|
||||||
const taskList = Res?.data?.list || Res?.list || [];
|
const taskList = Res?.data?.list || Res?.list || [];
|
||||||
const mappedTasks = taskList.map((task: any) => {
|
const mappedTasks: LikeTask[] = taskList.map((task: any) => {
|
||||||
const config = task.config || {};
|
const config = task.config || {};
|
||||||
const friends = config.friends || [];
|
const friends = config.friends || [];
|
||||||
const devices = config.devices || [];
|
const devices = config.devices || [];
|
||||||
@@ -142,6 +142,7 @@ const AutoLike: React.FC = () => {
|
|||||||
updateTime: task.updateTime || "",
|
updateTime: task.updateTime || "",
|
||||||
todayLikeCount: config.todayLikeCount || 0,
|
todayLikeCount: config.todayLikeCount || 0,
|
||||||
totalLikeCount: config.totalLikeCount || 0,
|
totalLikeCount: config.totalLikeCount || 0,
|
||||||
|
planType: config.planType ?? task.planType ?? 1,
|
||||||
// 保留原始数据
|
// 保留原始数据
|
||||||
config: config,
|
config: config,
|
||||||
devices: devices,
|
devices: devices,
|
||||||
@@ -243,73 +244,18 @@ const AutoLike: React.FC = () => {
|
|||||||
task.name.toLowerCase().includes(searchTerm.toLowerCase()),
|
task.name.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
// 按计划类型分组
|
||||||
<Layout
|
const globalTasks = filteredTasks.filter(t => t.planType === 0);
|
||||||
header={
|
const independentTasks = filteredTasks.filter(t => t.planType !== 0);
|
||||||
<>
|
|
||||||
<NavCommon
|
|
||||||
title="自动点赞"
|
|
||||||
backFn={() => navigate("/workspace")}
|
|
||||||
right={
|
|
||||||
<Button size="small" color="primary" onClick={handleCreateNew}>
|
|
||||||
<PlusOutlined /> 新建计划
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 搜索栏 */}
|
const renderTaskCard = (task: LikeTask) => (
|
||||||
<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"]}>
|
<Card key={task.id} className={style["task-card"]}>
|
||||||
<div className={style["task-header"]}>
|
<div className={style["task-header"]}>
|
||||||
<div className={style["task-title-section"]}>
|
<div className={style["task-title-section"]}>
|
||||||
<h3 className={style["task-name"]}>{task.name}</h3>
|
<h3 className={style["task-name"]}>{task.name}</h3>
|
||||||
<span
|
<span
|
||||||
className={`${style["task-status"]} ${
|
className={`${style["task-status"]} ${
|
||||||
Number(task.status) === 1
|
Number(task.status) === 1 ? style["active"] : style["inactive"]
|
||||||
? style["active"]
|
|
||||||
: style["inactive"]
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{Number(task.status) === 1 ? "进行中" : "已暂停"}
|
{Number(task.status) === 1 ? "进行中" : "已暂停"}
|
||||||
@@ -320,9 +266,7 @@ const AutoLike: React.FC = () => {
|
|||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={Number(task.status) === 1}
|
checked={Number(task.status) === 1}
|
||||||
onChange={() =>
|
onChange={() => toggleTaskStatus(task.id, Number(task.status))}
|
||||||
toggleTaskStatus(task.id, Number(task.status))
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<span className={style["slider"]}></span>
|
<span className={style["slider"]}></span>
|
||||||
</label>
|
</label>
|
||||||
@@ -339,15 +283,11 @@ const AutoLike: React.FC = () => {
|
|||||||
<div className={style["info-section"]}>
|
<div className={style["info-section"]}>
|
||||||
<div className={style["info-item"]}>
|
<div className={style["info-item"]}>
|
||||||
<span className={style["info-label"]}>执行设备:</span>
|
<span className={style["info-label"]}>执行设备:</span>
|
||||||
<span className={style["info-value"]}>
|
<span className={style["info-value"]}>{task.deviceCount} 个</span>
|
||||||
{task.deviceCount} 个
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className={style["info-item"]}>
|
<div className={style["info-item"]}>
|
||||||
<span className={style["info-label"]}>目标人群:</span>
|
<span className={style["info-label"]}>目标人群:</span>
|
||||||
<span className={style["info-value"]}>
|
<span className={style["info-value"]}>{task.targetGroup}</span>
|
||||||
{task.targetGroup}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={style["info-section"]}>
|
<div className={style["info-section"]}>
|
||||||
@@ -387,10 +327,109 @@ const AutoLike: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
))
|
);
|
||||||
)}
|
|
||||||
|
let content: React.ReactNode;
|
||||||
|
if (loading) {
|
||||||
|
content = (
|
||||||
|
<div className={style["task-list"]}>
|
||||||
|
<div className={style["loading"]}>
|
||||||
|
<SpinLoading color="primary" />
|
||||||
|
<div className={style["loading-text"]}>加载中...</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
} else if (filteredTasks.length === 0) {
|
||||||
|
content = (
|
||||||
|
<div className={style["task-list"]}>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
content = (
|
||||||
|
<>
|
||||||
|
{globalTasks.length > 0 && (
|
||||||
|
<div className={style["info-box"]}>
|
||||||
|
全局计划将应用于所有设备(包括新添加的设备),请谨慎配置点赞频率和数量,避免账号风险。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{globalTasks.length > 0 && (
|
||||||
|
<section className={style["section"]}>
|
||||||
|
<h3 className={style["section-title"]}>
|
||||||
|
<span className={style["section-dot"]} />
|
||||||
|
全局自动点赞计划
|
||||||
|
</h3>
|
||||||
|
<div className={style["plan-list-group"]}>
|
||||||
|
{globalTasks.map(renderTaskCard)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{independentTasks.length > 0 && (
|
||||||
|
<section className={style["section"]}>
|
||||||
|
<h3
|
||||||
|
className={`${style["section-title"]} ${style["section-title-independent"]}`}
|
||||||
|
>
|
||||||
|
<span className={style["section-dot"]} />
|
||||||
|
独立自动点赞计划
|
||||||
|
</h3>
|
||||||
|
<div className={style["plan-list-group"]}>
|
||||||
|
{independentTasks.map(renderTaskCard)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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"]}>{content}</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ export interface LikeTask {
|
|||||||
todayLikeCount: number;
|
todayLikeCount: number;
|
||||||
totalLikeCount: number;
|
totalLikeCount: number;
|
||||||
updateTime: string;
|
updateTime: string;
|
||||||
|
// 计划类型:0-全局计划,1-独立计划
|
||||||
|
planType?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建任务数据
|
// 创建任务数据
|
||||||
@@ -81,12 +83,15 @@ export interface CreateLikeTaskData {
|
|||||||
contentTypes: ContentType[];
|
contentTypes: ContentType[];
|
||||||
deviceGroups: number[];
|
deviceGroups: number[];
|
||||||
deviceGroupsOptions: DeviceSelectionItem[];
|
deviceGroupsOptions: DeviceSelectionItem[];
|
||||||
friendsGroups: number[];
|
// 实际使用的好友字段(兼容旧字段)
|
||||||
friendsGroupsOptions: FriendSelectionItem[];
|
wechatFriends?: number[];
|
||||||
|
wechatFriendsOptions?: FriendSelectionItem[];
|
||||||
friendMaxLikes: number;
|
friendMaxLikes: number;
|
||||||
friendTags?: string;
|
friendTags?: string;
|
||||||
enableFriendTags: boolean;
|
enableFriendTags: boolean;
|
||||||
targetTags: string[];
|
targetTags: string[];
|
||||||
|
// 计划类型:0-全局计划,1-独立计划
|
||||||
|
planType?: number;
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { PlusOutlined, MinusOutlined } from "@ant-design/icons";
|
import { PlusOutlined, MinusOutlined } from "@ant-design/icons";
|
||||||
import { Button, Input, Switch, Spin, message } from "antd";
|
import { Button, Input, Switch, Spin, message, Radio } from "antd";
|
||||||
import Layout from "@/components/Layout/Layout";
|
import Layout from "@/components/Layout/Layout";
|
||||||
import DeviceSelection from "@/components/DeviceSelection";
|
import DeviceSelection from "@/components/DeviceSelection";
|
||||||
import FriendSelection from "@/components/FriendSelection";
|
import FriendSelection from "@/components/FriendSelection";
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
fetchAutoLikeTaskDetail,
|
fetchAutoLikeTaskDetail,
|
||||||
} from "./api";
|
} from "./api";
|
||||||
import { CreateLikeTaskData, ContentType } from "./data";
|
import { CreateLikeTaskData, ContentType } from "./data";
|
||||||
|
import { useUserStore } from "@/store/module/user";
|
||||||
import style from "./new.module.scss";
|
import style from "./new.module.scss";
|
||||||
|
|
||||||
const contentTypeLabels: Record<ContentType, string> = {
|
const contentTypeLabels: Record<ContentType, string> = {
|
||||||
@@ -32,12 +33,15 @@ const NewAutoLike: React.FC = () => {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const isEditMode = !!id;
|
const isEditMode = !!id;
|
||||||
|
const { user } = useUserStore();
|
||||||
|
const isAdmin = user?.isAdmin === 1;
|
||||||
const [currentStep, setCurrentStep] = useState(1);
|
const [currentStep, setCurrentStep] = useState(1);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(isEditMode);
|
const [isLoading, setIsLoading] = useState(isEditMode);
|
||||||
const [autoEnabled, setAutoEnabled] = useState(false);
|
const [autoEnabled, setAutoEnabled] = useState(false);
|
||||||
const [selectAllFriends, setSelectAllFriends] = useState(false);
|
const [selectAllFriends, setSelectAllFriends] = useState(false);
|
||||||
const [formData, setFormData] = useState<CreateLikeTaskData>({
|
const [formData, setFormData] = useState<CreateLikeTaskData>({
|
||||||
|
planType: 1, // 默认独立计划
|
||||||
name: "",
|
name: "",
|
||||||
interval: 5,
|
interval: 5,
|
||||||
maxLikes: 200,
|
maxLikes: 200,
|
||||||
@@ -67,6 +71,7 @@ const NewAutoLike: React.FC = () => {
|
|||||||
if (taskDetail) {
|
if (taskDetail) {
|
||||||
const config = (taskDetail as any).config || taskDetail;
|
const config = (taskDetail as any).config || taskDetail;
|
||||||
setFormData({
|
setFormData({
|
||||||
|
planType: config.planType ?? (taskDetail as any).planType ?? 1,
|
||||||
name: taskDetail.name || "",
|
name: taskDetail.name || "",
|
||||||
interval: config.likeInterval || config.interval || 5,
|
interval: config.likeInterval || config.interval || 5,
|
||||||
maxLikes: config.maxLikesPerDay || config.maxLikes || 200,
|
maxLikes: config.maxLikesPerDay || config.maxLikes || 200,
|
||||||
@@ -184,7 +189,22 @@ const NewAutoLike: React.FC = () => {
|
|||||||
|
|
||||||
// 步骤1:基础设置
|
// 步骤1:基础设置
|
||||||
const renderBasicSettings = () => (
|
const renderBasicSettings = () => (
|
||||||
<div className={style.basicSection}>
|
<div className={style.container}>
|
||||||
|
{/* 计划类型和任务名称 */}
|
||||||
|
<div className={style.card}>
|
||||||
|
{isAdmin && (
|
||||||
|
<div className={style.formItem}>
|
||||||
|
<div className={style.formLabel}>计划类型</div>
|
||||||
|
<Radio.Group
|
||||||
|
value={formData.planType}
|
||||||
|
onChange={e => handleUpdateFormData({ planType: e.target.value })}
|
||||||
|
className={style.radioGroup}
|
||||||
|
>
|
||||||
|
<Radio value={0}>全局计划</Radio>
|
||||||
|
<Radio value={1}>独立计划</Radio>
|
||||||
|
</Radio.Group>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className={style.formItem}>
|
<div className={style.formItem}>
|
||||||
<div className={style.formLabel}>任务名称</div>
|
<div className={style.formLabel}>任务名称</div>
|
||||||
<Input
|
<Input
|
||||||
@@ -194,6 +214,10 @@ const NewAutoLike: React.FC = () => {
|
|||||||
className={style.input}
|
className={style.input}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 点赞间隔 */}
|
||||||
|
<div className={style.card}>
|
||||||
<div className={style.formItem}>
|
<div className={style.formItem}>
|
||||||
<div className={style.formLabel}>点赞间隔</div>
|
<div className={style.formLabel}>点赞间隔</div>
|
||||||
<div className={style.counterRow}>
|
<div className={style.counterRow}>
|
||||||
@@ -231,6 +255,10 @@ const NewAutoLike: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className={style.counterTip}>设置两次点赞之间的最小时间间隔</div>
|
<div className={style.counterTip}>设置两次点赞之间的最小时间间隔</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 每日最大点赞数 */}
|
||||||
|
<div className={style.card}>
|
||||||
<div className={style.formItem}>
|
<div className={style.formItem}>
|
||||||
<div className={style.formLabel}>每日最大点赞数</div>
|
<div className={style.formLabel}>每日最大点赞数</div>
|
||||||
<div className={style.counterRow}>
|
<div className={style.counterRow}>
|
||||||
@@ -268,6 +296,10 @@ const NewAutoLike: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className={style.counterTip}>设置每天最多点赞的次数</div>
|
<div className={style.counterTip}>设置每天最多点赞的次数</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 点赞时间范围 */}
|
||||||
|
<div className={style.card}>
|
||||||
<div className={style.formItem}>
|
<div className={style.formItem}>
|
||||||
<div className={style.formLabel}>点赞时间范围</div>
|
<div className={style.formLabel}>点赞时间范围</div>
|
||||||
<div className={style.timeRow}>
|
<div className={style.timeRow}>
|
||||||
@@ -287,6 +319,10 @@ const NewAutoLike: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className={style.counterTip}>设置每天可以点赞的时间段</div>
|
<div className={style.counterTip}>设置每天可以点赞的时间段</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 点赞内容类型 */}
|
||||||
|
<div className={style.card}>
|
||||||
<div className={style.formItem}>
|
<div className={style.formItem}>
|
||||||
<div className={style.formLabel}>点赞内容类型</div>
|
<div className={style.formLabel}>点赞内容类型</div>
|
||||||
<div className={style.contentTypes}>
|
<div className={style.contentTypes}>
|
||||||
@@ -311,6 +347,10 @@ const NewAutoLike: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className={style.counterTip}>选择要点赞的内容类型</div>
|
<div className={style.counterTip}>选择要点赞的内容类型</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 好友标签和自动开启 */}
|
||||||
|
<div className={style.card}>
|
||||||
<div className={style.formItem}>
|
<div className={style.formItem}>
|
||||||
<div className={style.switchRow}>
|
<div className={style.switchRow}>
|
||||||
<span className={style.switchLabel}>启用好友标签</span>
|
<span className={style.switchLabel}>启用好友标签</span>
|
||||||
@@ -323,7 +363,7 @@ const NewAutoLike: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{formData.enableFriendTags && (
|
{formData.enableFriendTags && (
|
||||||
<div className={style.formItem}>
|
<div style={{ marginTop: 12 }}>
|
||||||
<Input
|
<Input
|
||||||
placeholder="请输入标签"
|
placeholder="请输入标签"
|
||||||
value={formData.friendTags}
|
value={formData.friendTags}
|
||||||
@@ -346,6 +386,8 @@ const NewAutoLike: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
block
|
block
|
||||||
@@ -361,7 +403,8 @@ const NewAutoLike: React.FC = () => {
|
|||||||
|
|
||||||
// 步骤2:设备选择
|
// 步骤2:设备选择
|
||||||
const renderDeviceSelection = () => (
|
const renderDeviceSelection = () => (
|
||||||
<div className={style.basicSection}>
|
<div className={style.container}>
|
||||||
|
<div className={style.card}>
|
||||||
<div className={style.formItem}>
|
<div className={style.formItem}>
|
||||||
<DeviceSelection
|
<DeviceSelection
|
||||||
selectedOptions={formData.deviceGroupsOptions}
|
selectedOptions={formData.deviceGroupsOptions}
|
||||||
@@ -375,11 +418,13 @@ const NewAutoLike: React.FC = () => {
|
|||||||
showSelectedList={true}
|
showSelectedList={true}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 16, marginTop: 16 }}>
|
||||||
<Button
|
<Button
|
||||||
onClick={handlePrev}
|
onClick={handlePrev}
|
||||||
className={style.prevBtn}
|
className={style.prevBtn}
|
||||||
size="large"
|
size="large"
|
||||||
style={{ marginRight: 16 }}
|
style={{ flex: 1 }}
|
||||||
>
|
>
|
||||||
上一步
|
上一步
|
||||||
</Button>
|
</Button>
|
||||||
@@ -389,15 +434,18 @@ const NewAutoLike: React.FC = () => {
|
|||||||
className={style.nextBtn}
|
className={style.nextBtn}
|
||||||
size="large"
|
size="large"
|
||||||
disabled={formData.deviceGroups.length === 0}
|
disabled={formData.deviceGroups.length === 0}
|
||||||
|
style={{ flex: 1 }}
|
||||||
>
|
>
|
||||||
下一步
|
下一步
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
// 步骤3:好友设置
|
// 步骤3:好友设置
|
||||||
const renderFriendSettings = () => (
|
const renderFriendSettings = () => (
|
||||||
<div className={style.basicSection}>
|
<div className={style.container}>
|
||||||
|
<div className={style.card}>
|
||||||
<div className={style.formItem}>
|
<div className={style.formItem}>
|
||||||
<div className={style.friendSelectionHeader}>
|
<div className={style.friendSelectionHeader}>
|
||||||
<div className={style.formLabel}>选择好友</div>
|
<div className={style.formLabel}>选择好友</div>
|
||||||
@@ -433,11 +481,13 @@ const NewAutoLike: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 16, marginTop: 16 }}>
|
||||||
<Button
|
<Button
|
||||||
onClick={handlePrev}
|
onClick={handlePrev}
|
||||||
className={style.prevBtn}
|
className={style.prevBtn}
|
||||||
size="large"
|
size="large"
|
||||||
style={{ marginRight: 16 }}
|
style={{ flex: 1 }}
|
||||||
>
|
>
|
||||||
上一步
|
上一步
|
||||||
</Button>
|
</Button>
|
||||||
@@ -451,9 +501,11 @@ const NewAutoLike: React.FC = () => {
|
|||||||
!selectAllFriends &&
|
!selectAllFriends &&
|
||||||
(!formData.wechatFriends || formData.wechatFriends.length === 0)
|
(!formData.wechatFriends || formData.wechatFriends.length === 0)
|
||||||
}
|
}
|
||||||
|
style={{ flex: 1 }}
|
||||||
>
|
>
|
||||||
{isEditMode ? "更新任务" : "创建任务"}
|
{isEditMode ? "更新任务" : "创建任务"}
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,39 @@
|
|||||||
.formBg {
|
.formBg {
|
||||||
background: #f8f6f3;
|
background: #f8fafc;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
padding: 0 0 80px 0;
|
padding: 0 0 80px 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
padding: 16px;
|
||||||
|
background: #f8fafc;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding-bottom: 100px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: hidden;
|
||||||
|
|
||||||
|
@media (max-width: 375px) {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
@media (max-width: 375px) {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.basicSection {
|
.basicSection {
|
||||||
background: none;
|
background: none;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
@@ -26,10 +55,31 @@
|
|||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.radioGroup {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
.input {
|
.input {
|
||||||
|
width: 100%;
|
||||||
height: 44px;
|
height: 44px;
|
||||||
|
padding: 10px 12px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
background: #f8fafc;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
|
outline: none;
|
||||||
|
transition: all 0.2s;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: #3b82f6;
|
||||||
|
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeRow {
|
.timeRow {
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export function fetchImportRecords(
|
|||||||
keyword?: string,
|
keyword?: string,
|
||||||
): Promise<PaginatedResponse<ContactImportRecord>> {
|
): Promise<PaginatedResponse<ContactImportRecord>> {
|
||||||
return request(
|
return request(
|
||||||
"/v1/workbench/import-records",
|
"/v1/workbench/import-contact",
|
||||||
{
|
{
|
||||||
workbenchId,
|
workbenchId,
|
||||||
page,
|
page,
|
||||||
|
|||||||
@@ -1,11 +1,58 @@
|
|||||||
|
.formBg {
|
||||||
|
background: #f8fafc;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding-bottom: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
.basicSection {
|
.basicSection {
|
||||||
background: none;
|
padding: 16px;
|
||||||
border-radius: 0;
|
background: #f8fafc;
|
||||||
box-shadow: none;
|
min-height: 100vh;
|
||||||
padding: 24px 16px 0 16px;
|
padding-bottom: 100px;
|
||||||
|
box-sizing: border-box;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 600px;
|
overflow-x: hidden;
|
||||||
margin: 0 auto;
|
|
||||||
|
@media (max-width: 375px) {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
@media (max-width: 375px) {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 输入框样式
|
||||||
|
:global(.ant-input) {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
background: #f8fafc;
|
||||||
|
font-size: 14px;
|
||||||
|
outline: none;
|
||||||
|
transition: all 0.2s;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: #3b82f6;
|
||||||
|
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.formItem {
|
.formItem {
|
||||||
@@ -20,6 +67,12 @@
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.radioGroup {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
.input {
|
.input {
|
||||||
height: 44px;
|
height: 44px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useState, useEffect, useCallback } from "react";
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { PlusOutlined, MinusOutlined } from "@ant-design/icons";
|
import { PlusOutlined, MinusOutlined } from "@ant-design/icons";
|
||||||
import { Button, Input, message, TimePicker, Select, Switch } from "antd";
|
import { Button, Input, message, TimePicker, Select, Switch, Radio, Card } from "antd";
|
||||||
import NavCommon from "@/components/NavCommon";
|
import NavCommon from "@/components/NavCommon";
|
||||||
import Layout from "@/components/Layout/Layout";
|
import Layout from "@/components/Layout/Layout";
|
||||||
import DeviceSelection from "@/components/DeviceSelection";
|
import DeviceSelection from "@/components/DeviceSelection";
|
||||||
@@ -16,14 +16,18 @@ import { PoolSelectionItem } from "@/components/PoolSelection/data";
|
|||||||
import { DeviceSelectionItem } from "@/components/DeviceSelection/data";
|
import { DeviceSelectionItem } from "@/components/DeviceSelection/data";
|
||||||
import style from "./index.module.scss";
|
import style from "./index.module.scss";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
import { useUserStore } from "@/store/module/user";
|
||||||
|
|
||||||
const ContactImportForm: React.FC = () => {
|
const ContactImportForm: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { id } = useParams<{ id?: string }>();
|
const { id } = useParams<{ id?: string }>();
|
||||||
const isEdit = !!id;
|
const isEdit = !!id;
|
||||||
|
const { user } = useUserStore();
|
||||||
|
const isAdmin = user?.isAdmin === 1;
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
|
planType: 1, // 计划类型:0-全局计划,1-独立计划
|
||||||
name: "", // 任务名称
|
name: "", // 任务名称
|
||||||
status: 1, // 是否启用,默认启用
|
status: 1, // 是否启用,默认启用
|
||||||
type: 6, // 任务类型,固定为6
|
type: 6, // 任务类型,固定为6
|
||||||
@@ -89,6 +93,7 @@ const ContactImportForm: React.FC = () => {
|
|||||||
endTime: config.endTime ? dayjs(config.endTime, "HH:mm") : null,
|
endTime: config.endTime ? dayjs(config.endTime, "HH:mm") : null,
|
||||||
deviceGroupsOptions,
|
deviceGroupsOptions,
|
||||||
poolGroupsOptions,
|
poolGroupsOptions,
|
||||||
|
planType: config.planType ?? (data as any).planType ?? 1,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -146,6 +151,7 @@ const ContactImportForm: React.FC = () => {
|
|||||||
remark: formData.remark || null,
|
remark: formData.remark || null,
|
||||||
startTime: formData.startTime?.format("HH:mm") || null,
|
startTime: formData.startTime?.format("HH:mm") || null,
|
||||||
endTime: formData.endTime?.format("HH:mm") || null,
|
endTime: formData.endTime?.format("HH:mm") || null,
|
||||||
|
planType: (formData as any).planType ?? 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isEdit && id) {
|
if (isEdit && id) {
|
||||||
@@ -220,158 +226,191 @@ const ContactImportForm: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<div className={style.formBg}>
|
<div className={style.formBg}>
|
||||||
<div className={style.basicSection}>
|
<div className={style.basicSection}>
|
||||||
<div className={style.formItem}>
|
{/* 计划类型和任务名称 */}
|
||||||
<div className={style.formLabel}>任务名称</div>
|
<Card className={style.card}>
|
||||||
<Input
|
{isAdmin && (
|
||||||
placeholder="请输入任务名称"
|
<div className={style.formItem}>
|
||||||
value={formData.name}
|
<div className={style.formLabel}>计划类型</div>
|
||||||
onChange={e => handleUpdateFormData({ name: e.target.value })}
|
<Radio.Group
|
||||||
className={style.input}
|
value={(formData as any).planType}
|
||||||
/>
|
onChange={e =>
|
||||||
<div className={style.counterTip}>为此导入任务设置一个名称</div>
|
handleUpdateFormData({ planType: e.target.value })
|
||||||
</div>
|
}
|
||||||
|
className={style.radioGroup}
|
||||||
<div className={style.formItem}>
|
>
|
||||||
<div className={style.formLabel}>设备选择</div>
|
<Radio value={0}>全局计划</Radio>
|
||||||
<DeviceSelection
|
<Radio value={1}>独立计划</Radio>
|
||||||
selectedOptions={formData.deviceGroupsOptions}
|
</Radio.Group>
|
||||||
onSelect={handleDeviceSelect}
|
</div>
|
||||||
placeholder="请选择设备"
|
)}
|
||||||
className={style.deviceSelection}
|
|
||||||
/>
|
|
||||||
<div className={style.counterTip}>选择要分配联系人的设备</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={style.formItem}>
|
|
||||||
<div className={style.formLabel}>流量池选择</div>
|
|
||||||
<PoolSelection
|
|
||||||
selectedOptions={formData.poolGroupsOptions}
|
|
||||||
onSelect={handlePoolSelect}
|
|
||||||
placeholder="请选择流量池"
|
|
||||||
className={style.poolSelection}
|
|
||||||
/>
|
|
||||||
<div className={style.counterTip}>选择要导入的流量池</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={style.formItem}>
|
|
||||||
<div className={style.formLabel}>分配数量</div>
|
|
||||||
<div className={style.stepperContainer}>
|
|
||||||
<Button
|
|
||||||
icon={<MinusOutlined />}
|
|
||||||
onClick={() =>
|
|
||||||
handleUpdateFormData({ num: Math.max(1, formData.num - 1) })
|
|
||||||
}
|
|
||||||
disabled={formData.num <= 1}
|
|
||||||
className={style.stepperButton}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
value={formData.num}
|
|
||||||
onChange={e => {
|
|
||||||
const value = parseInt(e.target.value) || 1;
|
|
||||||
handleUpdateFormData({
|
|
||||||
num: Math.min(1000, Math.max(1, value)),
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
className={style.stepperInput}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
icon={<PlusOutlined />}
|
|
||||||
onClick={() =>
|
|
||||||
handleUpdateFormData({
|
|
||||||
num: Math.min(1000, formData.num + 1),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
disabled={formData.num >= 1000}
|
|
||||||
className={style.stepperButton}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className={style.counterTip}>要分配给设备的联系人数量</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={style.formItem}>
|
|
||||||
<div className={style.formLabel}>清除现有联系人</div>
|
|
||||||
<Switch
|
|
||||||
checked={formData.clearContact === 1}
|
|
||||||
onChange={checked =>
|
|
||||||
handleUpdateFormData({ clearContact: checked ? 1 : 0 })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<div className={style.counterTip}>是否清除设备上现有的联系人</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={style.formItem}>
|
|
||||||
<div className={style.formLabel}>备注类型</div>
|
|
||||||
<Select
|
|
||||||
placeholder="请选择备注类型"
|
|
||||||
value={formData.remarkType}
|
|
||||||
onChange={value => handleUpdateFormData({ remarkType: value })}
|
|
||||||
className={style.select}
|
|
||||||
>
|
|
||||||
<Select.Option value={0}>不备注</Select.Option>
|
|
||||||
<Select.Option value={1}>年月日</Select.Option>
|
|
||||||
<Select.Option value={2}>月日</Select.Option>
|
|
||||||
<Select.Option value={3}>自定义</Select.Option>
|
|
||||||
</Select>
|
|
||||||
<div className={style.counterTip}>选择联系人备注的格式</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{formData.remarkType === 3 && (
|
|
||||||
<div className={style.formItem}>
|
<div className={style.formItem}>
|
||||||
<div className={style.formLabel}>自定义备注</div>
|
<div className={style.formLabel}>任务名称</div>
|
||||||
<Input
|
<Input
|
||||||
placeholder="请输入备注内容"
|
placeholder="请输入任务名称"
|
||||||
value={formData.remark}
|
value={formData.name}
|
||||||
onChange={e => handleUpdateFormData({ remark: e.target.value })}
|
onChange={e => handleUpdateFormData({ name: e.target.value })}
|
||||||
className={style.input}
|
className={style.input}
|
||||||
/>
|
/>
|
||||||
<div className={style.counterTip}>输入自定义的备注内容</div>
|
<div className={style.counterTip}>为此导入任务设置一个名称</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</Card>
|
||||||
|
|
||||||
<div className={style.formItem}>
|
{/* 设备选择 */}
|
||||||
<div className={style.formLabel}>开始时间</div>
|
<Card className={style.card}>
|
||||||
<TimePicker
|
<div className={style.formItem}>
|
||||||
value={formData.startTime}
|
<div className={style.formLabel}>设备选择</div>
|
||||||
onChange={time =>
|
<DeviceSelection
|
||||||
handleUpdateFormData({
|
selectedOptions={formData.deviceGroupsOptions}
|
||||||
startTime: time,
|
onSelect={handleDeviceSelect}
|
||||||
})
|
placeholder="请选择设备"
|
||||||
}
|
className={style.deviceSelection}
|
||||||
format="HH:mm"
|
/>
|
||||||
placeholder="请选择开始时间"
|
<div className={style.counterTip}>选择要分配联系人的设备</div>
|
||||||
className={style.timePicker}
|
</div>
|
||||||
/>
|
</Card>
|
||||||
<div className={style.counterTip}>设置每天开始导入的时间</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={style.formItem}>
|
{/* 流量池选择 */}
|
||||||
<div className={style.formLabel}>结束时间</div>
|
<Card className={style.card}>
|
||||||
<TimePicker
|
<div className={style.formItem}>
|
||||||
value={formData.endTime}
|
<div className={style.formLabel}>流量池选择</div>
|
||||||
onChange={time =>
|
<PoolSelection
|
||||||
handleUpdateFormData({
|
selectedOptions={formData.poolGroupsOptions}
|
||||||
endTime: time,
|
onSelect={handlePoolSelect}
|
||||||
})
|
placeholder="请选择流量池"
|
||||||
}
|
className={style.poolSelection}
|
||||||
format="HH:mm"
|
/>
|
||||||
placeholder="请选择结束时间"
|
<div className={style.counterTip}>选择要导入的流量池</div>
|
||||||
className={style.timePicker}
|
</div>
|
||||||
/>
|
</Card>
|
||||||
<div className={style.counterTip}>设置每天结束导入的时间</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
{/* 分配数量 */}
|
||||||
className={style.formItem}
|
<Card className={style.card}>
|
||||||
style={{ display: "flex", justifyContent: "space-between" }}
|
<div className={style.formItem}>
|
||||||
>
|
<div className={style.formLabel}>分配数量</div>
|
||||||
<span>是否启用</span>
|
<div className={style.stepperContainer}>
|
||||||
<Switch
|
<Button
|
||||||
checked={formData.status === 1}
|
icon={<MinusOutlined />}
|
||||||
onChange={check =>
|
onClick={() =>
|
||||||
handleUpdateFormData({ status: check ? 1 : 0 })
|
handleUpdateFormData({ num: Math.max(1, formData.num - 1) })
|
||||||
}
|
}
|
||||||
/>
|
disabled={formData.num <= 1}
|
||||||
</div>
|
className={style.stepperButton}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={formData.num}
|
||||||
|
onChange={e => {
|
||||||
|
const value = parseInt(e.target.value) || 1;
|
||||||
|
handleUpdateFormData({
|
||||||
|
num: Math.min(1000, Math.max(1, value)),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className={style.stepperInput}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={() =>
|
||||||
|
handleUpdateFormData({
|
||||||
|
num: Math.min(1000, formData.num + 1),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={formData.num >= 1000}
|
||||||
|
className={style.stepperButton}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={style.counterTip}>要分配给设备的联系人数量</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 清除现有联系人和备注类型 */}
|
||||||
|
<Card className={style.card}>
|
||||||
|
<div className={style.formItem}>
|
||||||
|
<div className={style.formLabel}>清除现有联系人</div>
|
||||||
|
<Switch
|
||||||
|
checked={formData.clearContact === 1}
|
||||||
|
onChange={checked =>
|
||||||
|
handleUpdateFormData({ clearContact: checked ? 1 : 0 })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className={style.counterTip}>是否清除设备上现有的联系人</div>
|
||||||
|
</div>
|
||||||
|
<div className={style.formItem}>
|
||||||
|
<div className={style.formLabel}>备注类型</div>
|
||||||
|
<Select
|
||||||
|
placeholder="请选择备注类型"
|
||||||
|
value={formData.remarkType}
|
||||||
|
onChange={value => handleUpdateFormData({ remarkType: value })}
|
||||||
|
className={style.select}
|
||||||
|
>
|
||||||
|
<Select.Option value={0}>不备注</Select.Option>
|
||||||
|
<Select.Option value={1}>年月日</Select.Option>
|
||||||
|
<Select.Option value={2}>月日</Select.Option>
|
||||||
|
<Select.Option value={3}>自定义</Select.Option>
|
||||||
|
</Select>
|
||||||
|
<div className={style.counterTip}>选择联系人备注的格式</div>
|
||||||
|
</div>
|
||||||
|
{formData.remarkType === 3 && (
|
||||||
|
<div className={style.formItem}>
|
||||||
|
<div className={style.formLabel}>自定义备注</div>
|
||||||
|
<Input
|
||||||
|
placeholder="请输入备注内容"
|
||||||
|
value={formData.remark}
|
||||||
|
onChange={e => handleUpdateFormData({ remark: e.target.value })}
|
||||||
|
className={style.input}
|
||||||
|
/>
|
||||||
|
<div className={style.counterTip}>输入自定义的备注内容</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 时间设置 */}
|
||||||
|
<Card className={style.card}>
|
||||||
|
<div className={style.formItem}>
|
||||||
|
<div className={style.formLabel}>开始时间</div>
|
||||||
|
<TimePicker
|
||||||
|
value={formData.startTime}
|
||||||
|
onChange={time =>
|
||||||
|
handleUpdateFormData({
|
||||||
|
startTime: time,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
format="HH:mm"
|
||||||
|
placeholder="请选择开始时间"
|
||||||
|
className={style.timePicker}
|
||||||
|
/>
|
||||||
|
<div className={style.counterTip}>设置每天开始导入的时间</div>
|
||||||
|
</div>
|
||||||
|
<div className={style.formItem}>
|
||||||
|
<div className={style.formLabel}>结束时间</div>
|
||||||
|
<TimePicker
|
||||||
|
value={formData.endTime}
|
||||||
|
onChange={time =>
|
||||||
|
handleUpdateFormData({
|
||||||
|
endTime: time,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
format="HH:mm"
|
||||||
|
placeholder="请选择结束时间"
|
||||||
|
className={style.timePicker}
|
||||||
|
/>
|
||||||
|
<div className={style.counterTip}>设置每天结束导入的时间</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 是否启用 */}
|
||||||
|
<Card className={style.card}>
|
||||||
|
<div
|
||||||
|
className={style.formItem}
|
||||||
|
style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}
|
||||||
|
>
|
||||||
|
<span className={style.formLabel}>是否启用</span>
|
||||||
|
<Switch
|
||||||
|
checked={formData.status === 1}
|
||||||
|
onChange={check =>
|
||||||
|
handleUpdateFormData({ status: check ? 1 : 0 })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export function fetchImportRecords(
|
|||||||
keyword?: string,
|
keyword?: string,
|
||||||
): Promise<PaginatedResponse<ContactImportRecord>> {
|
): Promise<PaginatedResponse<ContactImportRecord>> {
|
||||||
return request(
|
return request(
|
||||||
"/v1/workbench/import-records",
|
"/v1/workbench/import-contact",
|
||||||
{
|
{
|
||||||
workbenchId,
|
workbenchId,
|
||||||
page,
|
page,
|
||||||
|
|||||||
@@ -46,6 +46,49 @@
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.infoBox {
|
||||||
|
background: #eff6ff;
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #1e40af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionDot {
|
||||||
|
width: 4px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitleIndependent {
|
||||||
|
.sectionDot {
|
||||||
|
background: #fb923c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.planListGroup {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.loading {
|
.loading {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -115,6 +158,16 @@
|
|||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.planTypeTag {
|
||||||
|
margin-left: 8px;
|
||||||
|
padding: 0 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
color: #1d4ed8;
|
||||||
|
background: #eff6ff;
|
||||||
|
}
|
||||||
|
|
||||||
.taskStatus {
|
.taskStatus {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|||||||
@@ -97,9 +97,13 @@ const ContactImport: React.FC = () => {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetchContactImportTasks();
|
const response = await fetchContactImportTasks();
|
||||||
const data = response?.list || [];
|
const data: ContactImportTask[] = response?.list || [];
|
||||||
setTasks(data);
|
const normalized = data.map(task => ({
|
||||||
setFilteredTasks(data);
|
...task,
|
||||||
|
planType: task.config?.planType ?? (task as any).planType ?? 1,
|
||||||
|
}));
|
||||||
|
setTasks(normalized);
|
||||||
|
setFilteredTasks(normalized);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Toast.show({
|
Toast.show({
|
||||||
content: "获取任务列表失败",
|
content: "获取任务列表失败",
|
||||||
@@ -211,6 +215,11 @@ const ContactImport: React.FC = () => {
|
|||||||
loadTasks();
|
loadTasks();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const globalTasks = filteredTasks.filter(task => (task as any).planType === 0);
|
||||||
|
const independentTasks = filteredTasks.filter(
|
||||||
|
task => (task as any).planType !== 0,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout
|
<Layout
|
||||||
header={
|
header={
|
||||||
@@ -252,92 +261,184 @@ const ContactImport: React.FC = () => {
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className={style.container}>
|
<div className={style.container}>
|
||||||
{/* 任务列表 */}
|
{/* 任务列表 */}
|
||||||
<div className={style.taskList}>
|
<div className={style.taskList}>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className={style.loading}>
|
<div className={style.loading}>
|
||||||
<SpinLoading /> 加载中...
|
<SpinLoading /> 加载中...
|
||||||
</div>
|
|
||||||
) : filteredTasks.length === 0 ? (
|
|
||||||
<div className={style.empty}>
|
|
||||||
<ContactsOutlined className={style.emptyIcon} />
|
|
||||||
<div className={style.emptyText}>
|
|
||||||
{searchKeyword ? "未找到相关任务" : "暂无通讯录导入任务"}
|
|
||||||
</div>
|
</div>
|
||||||
{!searchKeyword && (
|
) : filteredTasks.length === 0 ? (
|
||||||
<Button
|
<div className={style.empty}>
|
||||||
color="primary"
|
<ContactsOutlined className={style.emptyIcon} />
|
||||||
size="small"
|
<div className={style.emptyText}>
|
||||||
onClick={() => navigate("/workspace/contact-import/form")}
|
{searchKeyword ? "未找到相关任务" : "暂无通讯录导入任务"}
|
||||||
>
|
</div>
|
||||||
<PlusOutlined /> 创建第一个任务
|
{!searchKeyword && (
|
||||||
</Button>
|
<Button
|
||||||
)}
|
color="primary"
|
||||||
</div>
|
size="small"
|
||||||
) : (
|
onClick={() => navigate("/workspace/contact-import/form")}
|
||||||
filteredTasks.map(task => (
|
>
|
||||||
<Card key={task.id} className={style.taskCard}>
|
<PlusOutlined /> 创建第一个任务
|
||||||
<div className={style.cardHeader}>
|
</Button>
|
||||||
<div className={style.taskInfo}>
|
)}
|
||||||
<div className={style.taskName}>{task.name}</div>
|
</div>
|
||||||
<div
|
) : (
|
||||||
className={style.taskStatus}
|
<>
|
||||||
style={{ color: getStatusColor(task.status) }}
|
{globalTasks.length > 0 && (
|
||||||
>
|
<div className={style.infoBox}>
|
||||||
{getStatusText(task.status)}
|
全局通讯录导入计划将作用于所有设备,请谨慎配置导入数量与时间。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{globalTasks.length > 0 && (
|
||||||
|
<section className={style.section}>
|
||||||
|
<h3 className={style.sectionTitle}>
|
||||||
|
<span className={style.sectionDot} />
|
||||||
|
全局通讯录导入计划
|
||||||
|
</h3>
|
||||||
|
<div className={style.planListGroup}>
|
||||||
|
{globalTasks.map(task => (
|
||||||
|
<Card key={task.id} className={style.taskCard}>
|
||||||
|
<div className={style.cardHeader}>
|
||||||
|
<div className={style.taskInfo}>
|
||||||
|
<div className={style.taskName}>{task.name}</div>
|
||||||
|
<div
|
||||||
|
className={style.taskStatus}
|
||||||
|
style={{ color: getStatusColor(task.status) }}
|
||||||
|
>
|
||||||
|
{getStatusText(task.status)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CardMenu
|
||||||
|
onView={() => handleView(task.id)}
|
||||||
|
onEdit={() => handleEdit(task.id)}
|
||||||
|
onCopy={() => handleCopy(task.id)}
|
||||||
|
onDelete={() => handleDelete(task.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={style.cardContent}>
|
||||||
|
<div className={style.taskDetail}>
|
||||||
|
<span className={style.label}>备注类型:</span>
|
||||||
|
<span className={style.value}>
|
||||||
|
{task.config?.remarkType === 1 ? "自定义备注" : "其他"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={style.taskDetail}>
|
||||||
|
<span className={style.label}>设备数量:</span>
|
||||||
|
<span className={style.value}>
|
||||||
|
{task.config?.devices?.length || 0}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={style.taskDetail}>
|
||||||
|
<span className={style.label}>导入数量:</span>
|
||||||
|
<span className={style.value}>
|
||||||
|
{task.config?.num || 0}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={style.taskDetail}>
|
||||||
|
<span className={style.label}>创建时间:</span>
|
||||||
|
<span className={style.value}>{task.createTime}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={style.cardActions}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
fill="none"
|
||||||
|
onClick={() => handleToggleStatus(task)}
|
||||||
|
>
|
||||||
|
{task.status === 1 ? "暂停" : "启动"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
fill="none"
|
||||||
|
onClick={() => handleView(task.id)}
|
||||||
|
>
|
||||||
|
查看记录
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
<CardMenu
|
)}
|
||||||
onView={() => handleView(task.id)}
|
|
||||||
onEdit={() => handleEdit(task.id)}
|
{independentTasks.length > 0 && (
|
||||||
onCopy={() => handleCopy(task.id)}
|
<section className={style.section}>
|
||||||
onDelete={() => handleDelete(task.id)}
|
<h3 className={`${style.sectionTitle} ${style.sectionTitleIndependent}`}>
|
||||||
/>
|
<span className={style.sectionDot} />
|
||||||
</div>
|
独立通讯录导入计划
|
||||||
<div className={style.cardContent}>
|
</h3>
|
||||||
<div className={style.taskDetail}>
|
<div className={style.planListGroup}>
|
||||||
<span className={style.label}>备注类型:</span>
|
{independentTasks.map(task => (
|
||||||
<span className={style.value}>
|
<Card key={task.id} className={style.taskCard}>
|
||||||
{task.config?.remarkType === 1 ? "自定义备注" : "其他"}
|
<div className={style.cardHeader}>
|
||||||
</span>
|
<div className={style.taskInfo}>
|
||||||
</div>
|
<div className={style.taskName}>{task.name}</div>
|
||||||
<div className={style.taskDetail}>
|
<div
|
||||||
<span className={style.label}>设备数量:</span>
|
className={style.taskStatus}
|
||||||
<span className={style.value}>
|
style={{ color: getStatusColor(task.status) }}
|
||||||
{task.config?.devices?.length || 0}
|
>
|
||||||
</span>
|
{getStatusText(task.status)}
|
||||||
</div>
|
</div>
|
||||||
<div className={style.taskDetail}>
|
</div>
|
||||||
<span className={style.label}>导入数量:</span>
|
<CardMenu
|
||||||
<span className={style.value}>{task.config?.num || 0}</span>
|
onView={() => handleView(task.id)}
|
||||||
</div>
|
onEdit={() => handleEdit(task.id)}
|
||||||
<div className={style.taskDetail}>
|
onCopy={() => handleCopy(task.id)}
|
||||||
<span className={style.label}>创建时间:</span>
|
onDelete={() => handleDelete(task.id)}
|
||||||
<span className={style.value}>{task.createTime}</span>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className={style.cardContent}>
|
||||||
<div className={style.cardActions}>
|
<div className={style.taskDetail}>
|
||||||
<Button
|
<span className={style.label}>备注类型:</span>
|
||||||
size="small"
|
<span className={style.value}>
|
||||||
fill="none"
|
{task.config?.remarkType === 1 ? "自定义备注" : "其他"}
|
||||||
onClick={() => handleToggleStatus(task)}
|
</span>
|
||||||
>
|
</div>
|
||||||
{task.status === 1 ? "暂停" : "启动"}
|
<div className={style.taskDetail}>
|
||||||
</Button>
|
<span className={style.label}>设备数量:</span>
|
||||||
<Button
|
<span className={style.value}>
|
||||||
size="small"
|
{task.config?.devices?.length || 0}
|
||||||
fill="none"
|
</span>
|
||||||
onClick={() => handleView(task.id)}
|
</div>
|
||||||
>
|
<div className={style.taskDetail}>
|
||||||
查看记录
|
<span className={style.label}>导入数量:</span>
|
||||||
</Button>
|
<span className={style.value}>
|
||||||
</div>
|
{task.config?.num || 0}
|
||||||
</Card>
|
</span>
|
||||||
))
|
</div>
|
||||||
)}
|
<div className={style.taskDetail}>
|
||||||
|
<span className={style.label}>创建时间:</span>
|
||||||
|
<span className={style.value}>{task.createTime}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={style.cardActions}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
fill="none"
|
||||||
|
onClick={() => handleToggleStatus(task)}
|
||||||
|
>
|
||||||
|
{task.status === 1 ? "暂停" : "启动"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
fill="none"
|
||||||
|
onClick={() => handleView(task.id)}
|
||||||
|
>
|
||||||
|
查看记录
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { FriendSelectionItem } from "@/components/FriendSelection/data";
|
|||||||
import { DeviceSelectionItem } from "@/components/DeviceSelection/data";
|
import { DeviceSelectionItem } from "@/components/DeviceSelection/data";
|
||||||
import { GroupCreateFormData } from "../types";
|
import { GroupCreateFormData } from "../types";
|
||||||
import style from "./BasicSettings.module.scss";
|
import style from "./BasicSettings.module.scss";
|
||||||
|
import { useUserStore } from "@/store/module/user";
|
||||||
|
|
||||||
interface BasicSettingsProps {
|
interface BasicSettingsProps {
|
||||||
formData: GroupCreateFormData;
|
formData: GroupCreateFormData;
|
||||||
@@ -22,6 +23,8 @@ export interface BasicSettingsRef {
|
|||||||
|
|
||||||
const BasicSettings = forwardRef<BasicSettingsRef, BasicSettingsProps>(
|
const BasicSettings = forwardRef<BasicSettingsRef, BasicSettingsProps>(
|
||||||
({ formData, onChange }, ref) => {
|
({ formData, onChange }, ref) => {
|
||||||
|
const { user } = useUserStore();
|
||||||
|
const isAdmin = user?.isAdmin === 1;
|
||||||
const [executorSelectionVisible, setExecutorSelectionVisible] = useState(false);
|
const [executorSelectionVisible, setExecutorSelectionVisible] = useState(false);
|
||||||
const [groupAdminSelectionVisible, setGroupAdminSelectionVisible] = useState(false);
|
const [groupAdminSelectionVisible, setGroupAdminSelectionVisible] = useState(false);
|
||||||
const [fixedWechatIdsSelectionVisible, setFixedWechatIdsSelectionVisible] = useState(false);
|
const [fixedWechatIdsSelectionVisible, setFixedWechatIdsSelectionVisible] = useState(false);
|
||||||
@@ -200,6 +203,8 @@ const BasicSettings = forwardRef<BasicSettingsRef, BasicSettingsProps>(
|
|||||||
<div className={style.container}>
|
<div className={style.container}>
|
||||||
{/* 计划类型和计划名称 */}
|
{/* 计划类型和计划名称 */}
|
||||||
<div className={style.card}>
|
<div className={style.card}>
|
||||||
|
{/* 计划类型:去掉 isPlanType 限制,只要当前用户为管理员即可配置 */}
|
||||||
|
{isAdmin && (
|
||||||
<div>
|
<div>
|
||||||
<label className={style.label}>计划类型</label>
|
<label className={style.label}>计划类型</label>
|
||||||
<Radio.Group
|
<Radio.Group
|
||||||
@@ -211,6 +216,7 @@ const BasicSettings = forwardRef<BasicSettingsRef, BasicSettingsProps>(
|
|||||||
<Radio value={1}>独立计划</Radio>
|
<Radio value={1}>独立计划</Radio>
|
||||||
</Radio.Group>
|
</Radio.Group>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div style={{ marginTop: "16px" }}>
|
<div style={{ marginTop: "16px" }}>
|
||||||
<label className={style.label}>
|
<label className={style.label}>
|
||||||
计划名称 <span className={style.labelRequired}>*</span>
|
计划名称 <span className={style.labelRequired}>*</span>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect, useRef } from "react";
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams, useLocation } from "react-router-dom";
|
||||||
import { Toast } from "antd-mobile";
|
import { Toast } from "antd-mobile";
|
||||||
import { Button } from "antd";
|
import { Button } from "antd";
|
||||||
import Layout from "@/components/Layout/Layout";
|
import Layout from "@/components/Layout/Layout";
|
||||||
@@ -19,6 +19,7 @@ const steps = [
|
|||||||
|
|
||||||
const defaultForm: GroupCreateFormData = {
|
const defaultForm: GroupCreateFormData = {
|
||||||
planType: 1, // 默认独立计划
|
planType: 1, // 默认独立计划
|
||||||
|
isPlanType: 0, // 是否支持计划类型配置:默认不支持,依赖接口返回
|
||||||
name: "",
|
name: "",
|
||||||
executorId: undefined,
|
executorId: undefined,
|
||||||
executor: undefined,
|
executor: undefined,
|
||||||
@@ -43,11 +44,26 @@ const defaultForm: GroupCreateFormData = {
|
|||||||
const GroupCreateForm: React.FC = () => {
|
const GroupCreateForm: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
|
const location = useLocation();
|
||||||
|
const routeState = (location.state || {}) as { isPlanType?: number };
|
||||||
const isEdit = Boolean(id);
|
const isEdit = Boolean(id);
|
||||||
const [currentStep, setCurrentStep] = useState(1);
|
const [currentStep, setCurrentStep] = useState(1);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [dataLoaded, setDataLoaded] = useState(!isEdit);
|
const [dataLoaded, setDataLoaded] = useState(!isEdit);
|
||||||
const [formData, setFormData] = useState<GroupCreateFormData>(defaultForm);
|
const [formData, setFormData] = useState<GroupCreateFormData>({
|
||||||
|
...defaultForm,
|
||||||
|
// 新建时,尝试从路由状态中获取 isPlanType(由列表页传入)
|
||||||
|
isPlanType: routeState.isPlanType === 1 ? 1 : 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 调试日志:查看路由传入的 isPlanType 和当前表单中的 planType / isPlanType
|
||||||
|
useEffect(() => {
|
||||||
|
// 仅在开发调试用,后续可以删除
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log("[GroupCreate] routeState.isPlanType =", routeState.isPlanType);
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log("[GroupCreate] formData.planType =", formData.planType, "formData.isPlanType =", formData.isPlanType);
|
||||||
|
}, [routeState.isPlanType, formData.planType, formData.isPlanType]);
|
||||||
|
|
||||||
// 创建子组件的ref
|
// 创建子组件的ref
|
||||||
const basicSettingsRef = useRef<BasicSettingsRef>(null);
|
const basicSettingsRef = useRef<BasicSettingsRef>(null);
|
||||||
@@ -76,6 +92,8 @@ const GroupCreateForm: React.FC = () => {
|
|||||||
...defaultForm,
|
...defaultForm,
|
||||||
id: String(res.id),
|
id: String(res.id),
|
||||||
planType: config.planType ?? res.planType ?? 1,
|
planType: config.planType ?? res.planType ?? 1,
|
||||||
|
// 由接口控制是否展示计划类型配置
|
||||||
|
isPlanType: config.isPlanType ?? res.isPlanType ?? 0,
|
||||||
name: res.name ?? "",
|
name: res.name ?? "",
|
||||||
executorId: config.executorId,
|
executorId: config.executorId,
|
||||||
executor: config.deviceGroupsOptions?.[0], // executor 使用第一个设备(如果需要)
|
executor: config.deviceGroupsOptions?.[0], // executor 使用第一个设备(如果需要)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { PoolSelectionItem } from "@/components/PoolSelection/data";
|
|||||||
export interface GroupCreateFormData {
|
export interface GroupCreateFormData {
|
||||||
id?: string; // 任务ID
|
id?: string; // 任务ID
|
||||||
planType: number; // 计划类型:0-全局计划,1-独立计划
|
planType: number; // 计划类型:0-全局计划,1-独立计划
|
||||||
|
isPlanType?: number; // 是否支持计划类型配置:1-支持,其他/未定义-不支持(接口返回)
|
||||||
name: string; // 计划名称
|
name: string; // 计划名称
|
||||||
executor?: DeviceSelectionItem; // 执行智能体(执行者)- 单个设备(保留用于兼容)
|
executor?: DeviceSelectionItem; // 执行智能体(执行者)- 单个设备(保留用于兼容)
|
||||||
executorId?: number; // 执行智能体ID(设备ID)(保留用于兼容)
|
executorId?: number; // 执行智能体ID(设备ID)(保留用于兼容)
|
||||||
|
|||||||
@@ -28,13 +28,16 @@ const GroupCreateList: React.FC = () => {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [plans, setPlans] = useState<GroupCreatePlan[]>([]);
|
const [plans, setPlans] = useState<GroupCreatePlan[]>([]);
|
||||||
const [menuLoadingId, setMenuLoadingId] = useState<string | null>(null);
|
const [menuLoadingId, setMenuLoadingId] = useState<string | null>(null);
|
||||||
|
const [isPlanTypeEnabled, setIsPlanTypeEnabled] = useState(false);
|
||||||
|
|
||||||
// 获取列表数据
|
// 获取列表数据
|
||||||
const fetchList = async () => {
|
const fetchList = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await getGroupCreateList({ type: 4 });
|
const res = await getGroupCreateList({ type: 4 });
|
||||||
const list = res?.list || res?.data?.list || res?.data || [];
|
const list = res?.list || res?.data?.list || res?.data?.listData || res?.data || [];
|
||||||
|
const isPlanTypeFlag = res?.isPlanType ?? res?.data?.isPlanType;
|
||||||
|
setIsPlanTypeEnabled(isPlanTypeFlag === 1);
|
||||||
const normalized: GroupCreatePlan[] = (list as any[]).map((item: any) => {
|
const normalized: GroupCreatePlan[] = (list as any[]).map((item: any) => {
|
||||||
const stats = item.config?.stats || {};
|
const stats = item.config?.stats || {};
|
||||||
return {
|
return {
|
||||||
@@ -115,12 +118,14 @@ const GroupCreateList: React.FC = () => {
|
|||||||
|
|
||||||
// 创建新计划
|
// 创建新计划
|
||||||
const handleCreate = () => {
|
const handleCreate = () => {
|
||||||
navigate("/workspace/group-create/new");
|
navigate("/workspace/group-create/new", {
|
||||||
|
state: { isPlanType: isPlanTypeEnabled ? 1 : 0 },
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// 分隔全局计划和独立计划
|
// 分隔全局计划和独立计划(仅在 isPlanType 为 1 时启用)
|
||||||
const globalPlans = plans.filter(p => p.planType === 0);
|
const globalPlans = isPlanTypeEnabled ? plans.filter(p => p.planType === 0) : [];
|
||||||
const independentPlans = plans.filter(p => p.planType === 1);
|
const independentPlans = isPlanTypeEnabled ? plans.filter(p => p.planType === 1) : plans;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout
|
<Layout
|
||||||
|
|||||||
@@ -14,7 +14,10 @@ import {
|
|||||||
Select,
|
Select,
|
||||||
Radio,
|
Radio,
|
||||||
} from "antd";
|
} from "antd";
|
||||||
|
|
||||||
|
const { TextArea } = Input;
|
||||||
import { fetchSocialMediaList, fetchPromotionSiteList } from "../index.api";
|
import { fetchSocialMediaList, fetchPromotionSiteList } from "../index.api";
|
||||||
|
import { useUserStore } from "@/store/module/user";
|
||||||
|
|
||||||
interface BasicSettingsProps {
|
interface BasicSettingsProps {
|
||||||
defaultValues?: {
|
defaultValues?: {
|
||||||
@@ -26,6 +29,19 @@ interface BasicSettingsProps {
|
|||||||
isLoop: number; // 0: 否, 1: 是
|
isLoop: number; // 0: 否, 1: 是
|
||||||
pushType: number; // 0: 定时推送, 1: 立即推送
|
pushType: number; // 0: 定时推送, 1: 立即推送
|
||||||
status: number; // 0: 否, 1: 是
|
status: number; // 0: 否, 1: 是
|
||||||
|
isRandomTemplate?: number; // 是否随机模板:0=否,1=是
|
||||||
|
postPushTags?: string[]; // 推送后标签数组
|
||||||
|
targetType?: number; // 1=群推送,2=好友推送
|
||||||
|
groupPushSubType?: number; // 1=群群发,2=群公告
|
||||||
|
// 好友推送间隔设置
|
||||||
|
friendIntervalMin?: number;
|
||||||
|
friendIntervalMax?: number;
|
||||||
|
messageIntervalMin?: number;
|
||||||
|
messageIntervalMax?: number;
|
||||||
|
// 群公告相关
|
||||||
|
announcementContent?: string;
|
||||||
|
enableAiRewrite?: number;
|
||||||
|
aiRewritePrompt?: string;
|
||||||
socialMediaId?: string;
|
socialMediaId?: string;
|
||||||
promotionSiteId?: string;
|
promotionSiteId?: string;
|
||||||
};
|
};
|
||||||
@@ -51,12 +67,16 @@ const BasicSettings = forwardRef<BasicSettingsRef, BasicSettingsProps>(
|
|||||||
isLoop: 0, // 0: 否, 1: 是
|
isLoop: 0, // 0: 否, 1: 是
|
||||||
pushType: 0, // 0: 定时推送, 1: 立即推送
|
pushType: 0, // 0: 定时推送, 1: 立即推送
|
||||||
status: 0, // 0: 否, 1: 是
|
status: 0, // 0: 否, 1: 是
|
||||||
|
targetType: 1, // 默认1=群推送
|
||||||
|
groupPushSubType: 1, // 默认1=群群发
|
||||||
socialMediaId: undefined,
|
socialMediaId: undefined,
|
||||||
promotionSiteId: undefined,
|
promotionSiteId: undefined,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
) => {
|
) => {
|
||||||
|
const { user } = useUserStore();
|
||||||
|
const isAdmin = user?.isAdmin === 1;
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [, forceUpdate] = useState({});
|
const [, forceUpdate] = useState({});
|
||||||
const [socialMediaList, setSocialMediaList] = useState([]);
|
const [socialMediaList, setSocialMediaList] = useState([]);
|
||||||
@@ -69,6 +89,29 @@ const BasicSettings = forwardRef<BasicSettingsRef, BasicSettingsProps>(
|
|||||||
forceUpdate({});
|
forceUpdate({});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 监听 defaultValues 变化,更新表单值(用于编辑模式的数据回填)
|
||||||
|
useEffect(() => {
|
||||||
|
if (defaultValues) {
|
||||||
|
form.setFieldsValue(defaultValues);
|
||||||
|
forceUpdate({}); // 强制更新以刷新按钮状态
|
||||||
|
|
||||||
|
// 如果有社交媒体ID,加载对应的推广站点列表
|
||||||
|
if (defaultValues.socialMediaId && defaultValues.socialMediaId !== "") {
|
||||||
|
const socialMediaIdNum = Number(defaultValues.socialMediaId);
|
||||||
|
if (!isNaN(socialMediaIdNum)) {
|
||||||
|
setLoadingPromotionSite(true);
|
||||||
|
fetchPromotionSiteList(socialMediaIdNum)
|
||||||
|
.then(res => {
|
||||||
|
setPromotionSiteList(res);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setLoadingPromotionSite(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [defaultValues, form]);
|
||||||
|
|
||||||
// 组件挂载时获取社交媒体列表
|
// 组件挂载时获取社交媒体列表
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoadingSocialMedia(true);
|
setLoadingSocialMedia(true);
|
||||||
@@ -121,18 +164,29 @@ const BasicSettings = forwardRef<BasicSettingsRef, BasicSettingsProps>(
|
|||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<div style={{ marginBottom: 24 }}>
|
<div style={{ marginBottom: 24 }}>
|
||||||
<Card>
|
<Form
|
||||||
<Form
|
form={form}
|
||||||
form={form}
|
layout="vertical"
|
||||||
layout="vertical"
|
initialValues={defaultValues}
|
||||||
initialValues={defaultValues}
|
onValuesChange={(changedValues, allValues) => {
|
||||||
onValuesChange={(changedValues, allValues) => {
|
// 当pushOrder值变化时,强制更新组件
|
||||||
// 当pushOrder值变化时,强制更新组件
|
if ("pushOrder" in changedValues) {
|
||||||
if ("pushOrder" in changedValues) {
|
forceUpdate({});
|
||||||
forceUpdate({});
|
}
|
||||||
}
|
}}
|
||||||
}}
|
>
|
||||||
>
|
{/* 计划类型和任务名称 */}
|
||||||
|
<Card style={{ marginBottom: 16 }}>
|
||||||
|
{/* 计划类型:仅管理员可见 */}
|
||||||
|
{isAdmin && (
|
||||||
|
<Form.Item label="计划类型" name="planType" initialValue={1}>
|
||||||
|
<Radio.Group>
|
||||||
|
<Radio value={0}>全局计划</Radio>
|
||||||
|
<Radio value={1}>独立计划</Radio>
|
||||||
|
</Radio.Group>
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 任务名称 */}
|
{/* 任务名称 */}
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label="任务名称"
|
label="任务名称"
|
||||||
@@ -144,6 +198,28 @@ const BasicSettings = forwardRef<BasicSettingsRef, BasicSettingsProps>(
|
|||||||
>
|
>
|
||||||
<Input placeholder="请输入任务名称" />
|
<Input placeholder="请输入任务名称" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 推送目标类型 - 暂时隐藏,但保留默认值 */}
|
||||||
|
<Form.Item
|
||||||
|
name="targetType"
|
||||||
|
hidden
|
||||||
|
initialValue={1}
|
||||||
|
>
|
||||||
|
<Input type="hidden" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
{/* 群推送子类型 - 暂时隐藏,但保留默认值 */}
|
||||||
|
<Form.Item
|
||||||
|
name="groupPushSubType"
|
||||||
|
hidden
|
||||||
|
initialValue={1}
|
||||||
|
>
|
||||||
|
<Input type="hidden" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
{/* 推送类型和时间段 */}
|
||||||
|
<Card style={{ marginBottom: 16 }}>
|
||||||
{/* 推送类型 */}
|
{/* 推送类型 */}
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label="推送类型"
|
label="推送类型"
|
||||||
@@ -189,97 +265,186 @@ const BasicSettings = forwardRef<BasicSettingsRef, BasicSettingsProps>(
|
|||||||
) : null;
|
) : null;
|
||||||
}}
|
}}
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* 每日推送 */}
|
{/* 每日推送和推送顺序 - 群公告时隐藏 */}
|
||||||
|
<Form.Item
|
||||||
|
noStyle
|
||||||
|
shouldUpdate={(prevValues, currentValues) =>
|
||||||
|
prevValues.targetType !== currentValues.targetType ||
|
||||||
|
prevValues.groupPushSubType !== currentValues.groupPushSubType
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{({ getFieldValue }) => {
|
||||||
|
const isGroupAnnouncement = getFieldValue("targetType") === 1 && getFieldValue("groupPushSubType") === 2;
|
||||||
|
return !isGroupAnnouncement ? (
|
||||||
|
<Card style={{ marginBottom: 16 }}>
|
||||||
|
{/* 每日推送 */}
|
||||||
|
<Form.Item
|
||||||
|
label="每日推送"
|
||||||
|
name="maxPerDay"
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: "请输入每日推送数量" },
|
||||||
|
{
|
||||||
|
type: "number",
|
||||||
|
min: 1,
|
||||||
|
max: 100,
|
||||||
|
message: "每日推送数量在1-100之间",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<InputNumber
|
||||||
|
min={1}
|
||||||
|
max={100}
|
||||||
|
style={{ width: 120 }}
|
||||||
|
addonAfter="条内容"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
{/* 推送顺序 */}
|
||||||
|
<Form.Item
|
||||||
|
label="推送顺序"
|
||||||
|
name="pushOrder"
|
||||||
|
rules={[{ required: true, message: "请选择推送顺序" }]}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex" }}>
|
||||||
|
<Button
|
||||||
|
type={
|
||||||
|
form.getFieldValue("pushOrder") == 1 ? "primary" : "default"
|
||||||
|
}
|
||||||
|
style={{ borderRadius: "6px 0 0 6px" }}
|
||||||
|
onClick={() => handlePushOrderChange(1)}
|
||||||
|
>
|
||||||
|
按最早
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type={
|
||||||
|
form.getFieldValue("pushOrder") == 2 ? "primary" : "default"
|
||||||
|
}
|
||||||
|
style={{ borderRadius: "0 6px 6px 0", marginLeft: -1 }}
|
||||||
|
onClick={() => handlePushOrderChange(2)}
|
||||||
|
>
|
||||||
|
按最新
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Form.Item>
|
||||||
|
</Card>
|
||||||
|
) : null;
|
||||||
|
}}
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
{/* 京东联盟和随机模板 - 仅群推送显示,群公告时隐藏 */}
|
||||||
|
<Form.Item
|
||||||
|
noStyle
|
||||||
|
shouldUpdate={(prevValues, currentValues) =>
|
||||||
|
prevValues.targetType !== currentValues.targetType ||
|
||||||
|
prevValues.groupPushSubType !== currentValues.groupPushSubType
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{({ getFieldValue }) => {
|
||||||
|
const isGroupAnnouncement = getFieldValue("targetType") === 1 && getFieldValue("groupPushSubType") === 2;
|
||||||
|
return getFieldValue("targetType") === 1 && !isGroupAnnouncement ? (
|
||||||
|
<Card style={{ marginBottom: 16 }}>
|
||||||
|
{/* 京东联盟 */}
|
||||||
|
<Form.Item label="京东联盟" style={{ marginBottom: 16 }}>
|
||||||
|
<div style={{ display: "flex", gap: 12, alignItems: "flex-end" }}>
|
||||||
|
<Form.Item name="socialMediaId" noStyle>
|
||||||
|
<Select
|
||||||
|
placeholder="请选择社交媒体"
|
||||||
|
style={{ width: 200 }}
|
||||||
|
loading={loadingSocialMedia}
|
||||||
|
onChange={handleSocialMediaChange}
|
||||||
|
options={socialMediaList.map(item => ({
|
||||||
|
label: item.name,
|
||||||
|
value: item.id,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item name="promotionSiteId" noStyle>
|
||||||
|
<Select
|
||||||
|
placeholder="请选择推广站点"
|
||||||
|
style={{ width: 200 }}
|
||||||
|
loading={loadingPromotionSite}
|
||||||
|
disabled={!form.getFieldValue("socialMediaId")}
|
||||||
|
options={promotionSiteList.map(item => ({
|
||||||
|
label: item.name,
|
||||||
|
value: item.id,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
{/* 是否随机模板 */}
|
||||||
|
<Form.Item
|
||||||
|
label="是否随机模板"
|
||||||
|
name="isRandomTemplate"
|
||||||
|
valuePropName="checked"
|
||||||
|
getValueFromEvent={checked => (checked ? 1 : 0)}
|
||||||
|
getValueProps={value => ({ checked: value === 1 })}
|
||||||
|
>
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
</Card>
|
||||||
|
) : null;
|
||||||
|
}}
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
{/* 推送后标签、循环推送和是否启用 */}
|
||||||
|
<Card style={{ marginBottom: 16 }}>
|
||||||
|
{/* 推送后标签 - 仅好友推送显示 */}
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label="每日推送"
|
noStyle
|
||||||
name="maxPerDay"
|
shouldUpdate={(prevValues, currentValues) =>
|
||||||
rules={[
|
prevValues.targetType !== currentValues.targetType
|
||||||
{ required: true, message: "请输入每日推送数量" },
|
}
|
||||||
{
|
|
||||||
type: "number",
|
|
||||||
min: 1,
|
|
||||||
max: 100,
|
|
||||||
message: "每日推送数量在1-100之间",
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
>
|
||||||
<InputNumber
|
{({ getFieldValue }) => {
|
||||||
min={1}
|
return getFieldValue("targetType") === 2 ? (
|
||||||
max={100}
|
<Form.Item
|
||||||
style={{ width: 120 }}
|
label="推送后标签"
|
||||||
addonAfter="条内容"
|
name="postPushTags"
|
||||||
/>
|
tooltip="推送后自动添加的标签,多个标签用逗号分隔"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
placeholder="请输入标签,多个用逗号分隔"
|
||||||
|
onChange={e => {
|
||||||
|
const tags = e.target.value
|
||||||
|
.split(",")
|
||||||
|
.map(tag => tag.trim())
|
||||||
|
.filter(tag => tag.length > 0);
|
||||||
|
form.setFieldValue("postPushTags", tags);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
) : null;
|
||||||
|
}}
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
{/* 推送顺序 */}
|
{/* 是否循环推送 - 群推送和好友推送都显示,群公告时隐藏,好友推送默认为否 */}
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label="推送顺序"
|
noStyle
|
||||||
name="pushOrder"
|
shouldUpdate={(prevValues, currentValues) =>
|
||||||
rules={[{ required: true, message: "请选择推送顺序" }]}
|
prevValues.targetType !== currentValues.targetType ||
|
||||||
|
prevValues.groupPushSubType !== currentValues.groupPushSubType
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div style={{ display: "flex" }}>
|
{({ getFieldValue }) => {
|
||||||
<Button
|
const isGroupAnnouncement = getFieldValue("targetType") === 1 && getFieldValue("groupPushSubType") === 2;
|
||||||
type={
|
return !isGroupAnnouncement ? (
|
||||||
form.getFieldValue("pushOrder") == 1 ? "primary" : "default"
|
<Form.Item
|
||||||
}
|
label="是否循环推送"
|
||||||
style={{ borderRadius: "6px 0 0 6px" }}
|
name="isLoop"
|
||||||
onClick={() => handlePushOrderChange(1)}
|
valuePropName="checked"
|
||||||
>
|
getValueFromEvent={checked => (checked ? 1 : 0)}
|
||||||
按最早
|
getValueProps={value => ({ checked: value === 1 })}
|
||||||
</Button>
|
initialValue={0} // 默认为否
|
||||||
<Button
|
>
|
||||||
type={
|
<Switch />
|
||||||
form.getFieldValue("pushOrder") == 2 ? "primary" : "default"
|
</Form.Item>
|
||||||
}
|
) : null;
|
||||||
style={{ borderRadius: "0 6px 6px 0", marginLeft: -1 }}
|
}}
|
||||||
onClick={() => handlePushOrderChange(2)}
|
|
||||||
>
|
|
||||||
按最新
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
{/* 京东联盟 */}
|
|
||||||
<Form.Item label="京东联盟" style={{ marginBottom: 16 }}>
|
|
||||||
<div style={{ display: "flex", gap: 12, alignItems: "flex-end" }}>
|
|
||||||
<Form.Item name="socialMediaId" noStyle>
|
|
||||||
<Select
|
|
||||||
placeholder="请选择社交媒体"
|
|
||||||
style={{ width: 200 }}
|
|
||||||
loading={loadingSocialMedia}
|
|
||||||
onChange={handleSocialMediaChange}
|
|
||||||
options={socialMediaList.map(item => ({
|
|
||||||
label: item.name,
|
|
||||||
value: item.id,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item name="promotionSiteId" noStyle>
|
|
||||||
<Select
|
|
||||||
placeholder="请选择推广站点"
|
|
||||||
style={{ width: 200 }}
|
|
||||||
loading={loadingPromotionSite}
|
|
||||||
disabled={!form.getFieldValue("socialMediaId")}
|
|
||||||
options={promotionSiteList.map(item => ({
|
|
||||||
label: item.name,
|
|
||||||
value: item.id,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
</div>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
{/* 是否循环推送 */}
|
|
||||||
<Form.Item
|
|
||||||
label="是否循环推送"
|
|
||||||
name="isLoop"
|
|
||||||
valuePropName="checked"
|
|
||||||
getValueFromEvent={checked => (checked ? 1 : 0)}
|
|
||||||
getValueProps={value => ({ checked: value === 1 })}
|
|
||||||
>
|
|
||||||
<Switch />
|
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
{/* 是否启用 */}
|
{/* 是否启用 */}
|
||||||
@@ -292,18 +457,153 @@ const BasicSettings = forwardRef<BasicSettingsRef, BasicSettingsProps>(
|
|||||||
>
|
>
|
||||||
<Switch />
|
<Switch />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* 推送类型提示 */}
|
{/* 推送间隔设置 - 仅好友推送显示 */}
|
||||||
<Form.Item
|
<Form.Item
|
||||||
noStyle
|
noStyle
|
||||||
shouldUpdate={(prevValues, currentValues) =>
|
shouldUpdate={(prevValues, currentValues) =>
|
||||||
prevValues.pushType !== currentValues.pushType
|
prevValues.targetType !== currentValues.targetType
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{({ getFieldValue }) => {
|
{({ getFieldValue }) => {
|
||||||
const pushType = getFieldValue("pushType");
|
return getFieldValue("targetType") === 2 ? (
|
||||||
if (pushType === 1) {
|
<Card style={{ marginBottom: 16 }}>
|
||||||
return (
|
<Form.Item label="目标间间隔(秒)">
|
||||||
|
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||||
|
<Form.Item
|
||||||
|
name="friendIntervalMin"
|
||||||
|
noStyle
|
||||||
|
rules={[{ required: true, message: "请输入最小间隔" }]}
|
||||||
|
>
|
||||||
|
<InputNumber
|
||||||
|
min={1}
|
||||||
|
placeholder="最小"
|
||||||
|
style={{ width: 100 }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<span style={{ color: "#888" }}>至</span>
|
||||||
|
<Form.Item
|
||||||
|
name="friendIntervalMax"
|
||||||
|
noStyle
|
||||||
|
rules={[{ required: true, message: "请输入最大间隔" }]}
|
||||||
|
>
|
||||||
|
<InputNumber
|
||||||
|
min={1}
|
||||||
|
placeholder="最大"
|
||||||
|
style={{ width: 100 }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="消息间间隔(秒)">
|
||||||
|
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||||
|
<Form.Item
|
||||||
|
name="messageIntervalMin"
|
||||||
|
noStyle
|
||||||
|
rules={[{ required: true, message: "请输入最小间隔" }]}
|
||||||
|
>
|
||||||
|
<InputNumber
|
||||||
|
min={1}
|
||||||
|
placeholder="最小"
|
||||||
|
style={{ width: 100 }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<span style={{ color: "#888" }}>至</span>
|
||||||
|
<Form.Item
|
||||||
|
name="messageIntervalMax"
|
||||||
|
noStyle
|
||||||
|
rules={[{ required: true, message: "请输入最大间隔" }]}
|
||||||
|
>
|
||||||
|
<InputNumber
|
||||||
|
min={1}
|
||||||
|
placeholder="最大"
|
||||||
|
style={{ width: 100 }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
</Form.Item>
|
||||||
|
</Card>
|
||||||
|
) : null;
|
||||||
|
}}
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
{/* 群公告设置 - 仅当targetType=1且groupPushSubType=2时显示 */}
|
||||||
|
<Form.Item
|
||||||
|
noStyle
|
||||||
|
shouldUpdate={(prevValues, currentValues) =>
|
||||||
|
prevValues.targetType !== currentValues.targetType ||
|
||||||
|
prevValues.groupPushSubType !== currentValues.groupPushSubType
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{({ getFieldValue }) => {
|
||||||
|
return getFieldValue("targetType") === 1 &&
|
||||||
|
getFieldValue("groupPushSubType") === 2 ? (
|
||||||
|
<Card style={{ marginBottom: 16 }}>
|
||||||
|
<Form.Item
|
||||||
|
label="群公告内容"
|
||||||
|
name="announcementContent"
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: "请输入群公告内容" },
|
||||||
|
{ min: 1, max: 500, message: "群公告内容长度在1-500个字符之间" },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<TextArea
|
||||||
|
rows={4}
|
||||||
|
placeholder="请输入群公告内容"
|
||||||
|
maxLength={500}
|
||||||
|
showCount
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label="是否启用AI改写"
|
||||||
|
name="enableAiRewrite"
|
||||||
|
valuePropName="checked"
|
||||||
|
getValueFromEvent={checked => (checked ? 1 : 0)}
|
||||||
|
getValueProps={value => ({ checked: value === 1 })}
|
||||||
|
>
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
noStyle
|
||||||
|
shouldUpdate={(prevValues, currentValues) =>
|
||||||
|
prevValues.enableAiRewrite !== currentValues.enableAiRewrite
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{({ getFieldValue }) => {
|
||||||
|
return getFieldValue("enableAiRewrite") === 1 ? (
|
||||||
|
<Form.Item
|
||||||
|
label="AI改写提示词"
|
||||||
|
name="aiRewritePrompt"
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: "请输入AI改写提示词" },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<TextArea
|
||||||
|
rows={3}
|
||||||
|
placeholder="请输入AI改写提示词"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
) : null;
|
||||||
|
}}
|
||||||
|
</Form.Item>
|
||||||
|
</Card>
|
||||||
|
) : null;
|
||||||
|
}}
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
{/* 推送类型提示 */}
|
||||||
|
<Form.Item
|
||||||
|
noStyle
|
||||||
|
shouldUpdate={(prevValues, currentValues) =>
|
||||||
|
prevValues.pushType !== currentValues.pushType
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{({ getFieldValue }) => {
|
||||||
|
const pushType = getFieldValue("pushType");
|
||||||
|
if (pushType === 1) {
|
||||||
|
return (
|
||||||
|
<Card style={{ marginBottom: 16 }}>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
background: "#fffbe6",
|
background: "#fffbe6",
|
||||||
@@ -311,18 +611,17 @@ const BasicSettings = forwardRef<BasicSettingsRef, BasicSettingsProps>(
|
|||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
padding: 8,
|
padding: 8,
|
||||||
color: "#ad8b00",
|
color: "#ad8b00",
|
||||||
marginBottom: 16,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
如果启用立即推送,系统会把内容库里所有的内容按顺序推送到指定的社群
|
如果启用立即推送,系统会把内容库里所有的内容按顺序推送到指定的社群
|
||||||
</div>
|
</div>
|
||||||
);
|
</Card>
|
||||||
}
|
);
|
||||||
return null;
|
}
|
||||||
}}
|
return null;
|
||||||
</Form.Item>
|
}}
|
||||||
</Form>
|
</Form.Item>
|
||||||
</Card>
|
</Form>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import React, { useImperativeHandle, forwardRef } from "react";
|
||||||
|
import { Form, Card } from "antd";
|
||||||
|
import DeviceSelection from "@/components/DeviceSelection";
|
||||||
|
import { DeviceSelectionItem } from "@/components/DeviceSelection/data";
|
||||||
|
|
||||||
|
interface DeviceSelectorProps {
|
||||||
|
selectedDevices: DeviceSelectionItem[];
|
||||||
|
onPrevious: () => void;
|
||||||
|
onNext: (data: {
|
||||||
|
deviceGroups: string[];
|
||||||
|
deviceGroupsOptions: DeviceSelectionItem[];
|
||||||
|
}) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceSelectorRef {
|
||||||
|
validate: () => Promise<boolean>;
|
||||||
|
getValues: () => any;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DeviceSelector = forwardRef<DeviceSelectorRef, DeviceSelectorProps>(
|
||||||
|
({ selectedDevices, onNext }, ref) => {
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
|
// 暴露方法给父组件
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
validate: async () => {
|
||||||
|
try {
|
||||||
|
form.setFieldsValue({
|
||||||
|
deviceGroups: selectedDevices.map(item => String(item.id)),
|
||||||
|
});
|
||||||
|
await form.validateFields();
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.log("DeviceSelector 表单验证失败:", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getValues: () => {
|
||||||
|
return form.getFieldsValue();
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 设备选择
|
||||||
|
const handleDeviceSelect = (deviceGroupsOptions: DeviceSelectionItem[]) => {
|
||||||
|
const deviceGroups = deviceGroupsOptions.map(item => String(item.id));
|
||||||
|
form.setFieldValue("deviceGroups", deviceGroups);
|
||||||
|
onNext({ deviceGroups, deviceGroupsOptions });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
initialValues={{ devices: selectedDevices }}
|
||||||
|
>
|
||||||
|
<div style={{ marginBottom: 20 }}>
|
||||||
|
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600 }}>
|
||||||
|
选择设备
|
||||||
|
</h2>
|
||||||
|
<p style={{ margin: "8px 0 0 0", color: "#666", fontSize: 14 }}>
|
||||||
|
请选择要使用的设备
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="deviceGroups"
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
type: "array",
|
||||||
|
min: 1,
|
||||||
|
message: "请选择至少一个设备",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<DeviceSelection
|
||||||
|
selectedOptions={selectedDevices}
|
||||||
|
onSelect={handleDeviceSelect}
|
||||||
|
placeholder="选择设备"
|
||||||
|
readonly={false}
|
||||||
|
showSelectedList={true}
|
||||||
|
selectedListMaxHeight={300}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
DeviceSelector.displayName = "DeviceSelector";
|
||||||
|
|
||||||
|
export default DeviceSelector;
|
||||||
@@ -1,14 +1,25 @@
|
|||||||
import React, { useImperativeHandle, forwardRef } from "react";
|
import React, { useImperativeHandle, forwardRef, useState } from "react";
|
||||||
import { Form, Card } from "antd";
|
import { Form, Card } from "antd";
|
||||||
import GroupSelection from "@/components/GroupSelection";
|
import GroupSelection from "@/components/GroupSelection";
|
||||||
import { GroupSelectionItem } from "@/components/GroupSelection/data";
|
import { GroupSelectionItem } from "@/components/GroupSelection/data";
|
||||||
|
import FriendSelection from "@/components/FriendSelection";
|
||||||
|
import { FriendSelectionItem } from "@/components/FriendSelection/data";
|
||||||
|
import PoolSelection from "@/components/PoolSelection";
|
||||||
|
import { PoolSelectionItem } from "@/components/PoolSelection/data";
|
||||||
|
|
||||||
interface GroupSelectorProps {
|
interface GroupSelectorProps {
|
||||||
selectedGroups: GroupSelectionItem[];
|
selectedGroups: GroupSelectionItem[];
|
||||||
|
targetType: number; // 1=群推送,2=好友推送
|
||||||
|
selectedFriends?: any[];
|
||||||
|
selectedPools?: any[];
|
||||||
onPrevious: () => void;
|
onPrevious: () => void;
|
||||||
onNext: (data: {
|
onNext: (data: {
|
||||||
wechatGroups: string[];
|
wechatGroups?: string[];
|
||||||
wechatGroupsOptions: GroupSelectionItem[];
|
wechatGroupsOptions?: GroupSelectionItem[];
|
||||||
|
wechatFriends?: string[];
|
||||||
|
wechatFriendsOptions?: any[];
|
||||||
|
poolGroups?: string[];
|
||||||
|
poolGroupsOptions?: any[];
|
||||||
}) => void;
|
}) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,17 +29,44 @@ export interface GroupSelectorRef {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const GroupSelector = forwardRef<GroupSelectorRef, GroupSelectorProps>(
|
const GroupSelector = forwardRef<GroupSelectorRef, GroupSelectorProps>(
|
||||||
({ selectedGroups, onNext }, ref) => {
|
({ selectedGroups, targetType, selectedFriends = [], selectedPools = [], onNext }, ref) => {
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
|
const [friendsOptions, setFriendsOptions] = useState<FriendSelectionItem[]>(selectedFriends);
|
||||||
|
const [poolsOptions, setPoolsOptions] = useState<PoolSelectionItem[]>(selectedPools);
|
||||||
|
|
||||||
// 暴露方法给父组件
|
// 暴露方法给父组件
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
validate: async () => {
|
validate: async () => {
|
||||||
try {
|
try {
|
||||||
form.setFieldsValue({
|
if (targetType === 1) {
|
||||||
wechatGroups: selectedGroups.map(item => item.id),
|
// 群推送:必须选择群组
|
||||||
});
|
form.setFieldsValue({
|
||||||
await form.validateFields();
|
wechatGroups: selectedGroups.map(item => item.id),
|
||||||
|
});
|
||||||
|
await form.validateFields(["wechatGroups"]);
|
||||||
|
} else {
|
||||||
|
// 好友推送:wechatFriends可选,但如果为空则必须选择流量池
|
||||||
|
const friends = friendsOptions.map(item => String(item.id));
|
||||||
|
const pools = poolsOptions.map(item => String(item.id));
|
||||||
|
|
||||||
|
form.setFieldsValue({
|
||||||
|
wechatFriends: friends,
|
||||||
|
poolGroups: pools,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 如果好友为空,则流量池必填
|
||||||
|
if (friends.length === 0 && pools.length === 0) {
|
||||||
|
form.setFields([
|
||||||
|
{
|
||||||
|
name: "poolGroups",
|
||||||
|
errors: ["好友为空时,必须选择流量池"],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
throw new Error("好友为空时,必须选择流量池");
|
||||||
|
}
|
||||||
|
|
||||||
|
await form.validateFields(["wechatFriends", "poolGroups"]);
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log("GroupSelector 表单验证失败:", error);
|
console.log("GroupSelector 表单验证失败:", error);
|
||||||
@@ -40,50 +78,138 @@ const GroupSelector = forwardRef<GroupSelectorRef, GroupSelectorProps>(
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// 群组选择
|
// 群组选择(targetType=1)
|
||||||
const handleGroupSelect = (wechatGroupsOptions: GroupSelectionItem[]) => {
|
const handleGroupSelect = (wechatGroupsOptions: GroupSelectionItem[]) => {
|
||||||
const wechatGroups = wechatGroupsOptions.map(item => item.id);
|
const wechatGroups = wechatGroupsOptions.map(item => item.id);
|
||||||
form.setFieldValue("wechatGroups", wechatGroups);
|
form.setFieldValue("wechatGroups", wechatGroups);
|
||||||
onNext({ wechatGroups, wechatGroupsOptions });
|
onNext({ wechatGroups, wechatGroupsOptions });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 好友选择(targetType=2)
|
||||||
|
const handleFriendSelect = (friendsOptions: FriendSelectionItem[]) => {
|
||||||
|
setFriendsOptions(friendsOptions);
|
||||||
|
const wechatFriends = friendsOptions.map(item => String(item.id));
|
||||||
|
form.setFieldValue("wechatFriends", wechatFriends);
|
||||||
|
onNext({
|
||||||
|
wechatFriends,
|
||||||
|
wechatFriendsOptions: friendsOptions,
|
||||||
|
poolGroups: poolsOptions.map(p => String(p.id)),
|
||||||
|
poolGroupsOptions: poolsOptions,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 流量池选择(targetType=2)
|
||||||
|
const handlePoolSelect = (poolsOptions: PoolSelectionItem[]) => {
|
||||||
|
setPoolsOptions(poolsOptions);
|
||||||
|
const poolGroups = poolsOptions.map(item => String(item.id));
|
||||||
|
form.setFieldValue("poolGroups", poolGroups);
|
||||||
|
onNext({
|
||||||
|
wechatFriends: friendsOptions.map(f => String(f.id)),
|
||||||
|
wechatFriendsOptions: friendsOptions,
|
||||||
|
poolGroups,
|
||||||
|
poolGroupsOptions: poolsOptions,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<Form
|
<Form
|
||||||
form={form}
|
form={form}
|
||||||
layout="vertical"
|
layout="vertical"
|
||||||
initialValues={{ groups: selectedGroups }}
|
initialValues={{
|
||||||
|
groups: selectedGroups,
|
||||||
|
friends: friendsOptions,
|
||||||
|
pools: poolsOptions,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ marginBottom: 20 }}>
|
{targetType === 1 ? (
|
||||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600 }}>
|
// 群推送模式
|
||||||
选择推送群组
|
<>
|
||||||
</h2>
|
<div style={{ marginBottom: 20 }}>
|
||||||
<p style={{ margin: "8px 0 0 0", color: "#666", fontSize: 14 }}>
|
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600 }}>
|
||||||
请选择要推送消息的微信群组
|
选择推送群组
|
||||||
</p>
|
</h2>
|
||||||
</div>
|
<p style={{ margin: "8px 0 0 0", color: "#666", fontSize: 14 }}>
|
||||||
|
请选择要推送消息的微信群组
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="wechatGroups"
|
name="wechatGroups"
|
||||||
rules={[
|
rules={[
|
||||||
{
|
{
|
||||||
required: true,
|
required: true,
|
||||||
type: "array",
|
type: "array",
|
||||||
min: 1,
|
min: 1,
|
||||||
message: "请选择至少一个群组",
|
message: "请选择至少一个群组",
|
||||||
},
|
},
|
||||||
{ type: "array", max: 50, message: "最多只能选择50个群组" },
|
{ type: "array", max: 50, message: "最多只能选择50个群组" },
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<GroupSelection
|
<GroupSelection
|
||||||
selectedOptions={selectedGroups}
|
selectedOptions={selectedGroups}
|
||||||
onSelect={handleGroupSelect}
|
onSelect={handleGroupSelect}
|
||||||
placeholder="选择要推送的群组"
|
placeholder="选择要推送的群组"
|
||||||
readonly={false}
|
readonly={false}
|
||||||
showSelectedList={true}
|
showSelectedList={true}
|
||||||
selectedListMaxHeight={300}
|
selectedListMaxHeight={300}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
// 好友推送模式
|
||||||
|
<>
|
||||||
|
<div style={{ marginBottom: 20 }}>
|
||||||
|
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600 }}>
|
||||||
|
选择推送目标
|
||||||
|
</h2>
|
||||||
|
<p style={{ margin: "8px 0 0 0", color: "#666", fontSize: 14 }}>
|
||||||
|
可选择好友或流量池,好友为空时必须选择流量池
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 好友选择(可选) */}
|
||||||
|
<Form.Item
|
||||||
|
name="wechatFriends"
|
||||||
|
label="选择好友(可选)"
|
||||||
|
>
|
||||||
|
<FriendSelection
|
||||||
|
selectedOptions={friendsOptions}
|
||||||
|
onSelect={handleFriendSelect}
|
||||||
|
placeholder="选择要推送的好友"
|
||||||
|
readonly={false}
|
||||||
|
showSelectedList={true}
|
||||||
|
selectedListMaxHeight={300}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
{/* 流量池选择(当好友为空时必选) */}
|
||||||
|
<Form.Item
|
||||||
|
name="poolGroups"
|
||||||
|
label="选择流量池"
|
||||||
|
rules={[
|
||||||
|
({ getFieldValue }) => ({
|
||||||
|
validator: (_, value) => {
|
||||||
|
const friends = getFieldValue("wechatFriends") || [];
|
||||||
|
if (friends.length === 0 && (!value || value.length === 0)) {
|
||||||
|
return Promise.reject(new Error("好友为空时,必须选择流量池"));
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<PoolSelection
|
||||||
|
selectedOptions={poolsOptions}
|
||||||
|
onSelect={handlePoolSelect}
|
||||||
|
placeholder="选择流量池"
|
||||||
|
readonly={false}
|
||||||
|
showSelectedList={true}
|
||||||
|
selectedListMaxHeight={300}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Form>
|
</Form>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
import request from "@/api/request";
|
import request from "@/api/request";
|
||||||
|
|
||||||
export function createGroupPushTask(data) {
|
// 创建群发工作台
|
||||||
return request("/v1/workbench/create", { ...data, type: 3 }, "POST");
|
export function createGroupPushTask(data: any) {
|
||||||
}
|
return request("/v1/workbench/create", data, "POST");
|
||||||
// 获取自动点赞任务详情
|
|
||||||
export function fetchGroupPushTaskDetail(id: string) {
|
|
||||||
return request("/v1/workbench/detail", { id }, "GET");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateGroupPushTask(data) {
|
// 更新群发工作台
|
||||||
return request("/v1/workbench/update", { ...data, type: 3 }, "POST");
|
export function updateGroupPushTask(data: any) {
|
||||||
|
return request("/v1/workbench/update", data, "POST");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取群发工作台详情
|
||||||
|
export function fetchGroupPushTaskDetail(id: string) {
|
||||||
|
return request("/v1/workbench/detail", { id }, "GET");
|
||||||
}
|
}
|
||||||
// 获取京东社交媒体列表
|
// 获取京东社交媒体列表
|
||||||
export const fetchSocialMediaList = async () => {
|
export const fetchSocialMediaList = async () => {
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ export interface ContentLibrary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface FormData {
|
export interface FormData {
|
||||||
|
// 计划类型:0-全局计划,1-独立计划
|
||||||
|
planType?: number;
|
||||||
name: string;
|
name: string;
|
||||||
startTime: string; // 允许推送的开始时间
|
startTime: string; // 允许推送的开始时间
|
||||||
endTime: string; // 允许推送的结束时间
|
endTime: string; // 允许推送的结束时间
|
||||||
@@ -27,9 +29,33 @@ export interface FormData {
|
|||||||
pushOrder: number; // 1: 按最早, 2: 按最新
|
pushOrder: number; // 1: 按最早, 2: 按最新
|
||||||
isLoop: number; // 0: 否, 1: 是
|
isLoop: number; // 0: 否, 1: 是
|
||||||
pushType: number; // 0: 定时推送, 1: 立即推送
|
pushType: number; // 0: 定时推送, 1: 立即推送
|
||||||
status: number; // 0: 否, 1: 是
|
status: number; // 0: 否, 1: 是(同时作为是否自动启动)
|
||||||
|
isRandomTemplate?: number; // 是否随机模板:0=否,1=是
|
||||||
|
postPushTags?: string[]; // 推送后标签数组
|
||||||
contentGroups: string[];
|
contentGroups: string[];
|
||||||
wechatGroups: string[];
|
wechatGroups: string[];
|
||||||
|
// 推送目标类型:1=群推送,2=好友推送
|
||||||
|
targetType: number; // 默认1
|
||||||
|
// 群推送子类型:1=群群发,2=群公告(仅当targetType=1时有效)
|
||||||
|
groupPushSubType?: number; // 默认1
|
||||||
|
// 好友推送相关
|
||||||
|
wechatFriends?: string[]; // 当targetType=2时可选(可以为空)
|
||||||
|
wechatFriendsOptions?: any[]; // 好友选项列表
|
||||||
|
// 流量池(当wechatFriends为空时必须选择)
|
||||||
|
poolGroups?: string[]; // 流量池ID列表
|
||||||
|
poolGroupsOptions?: any[]; // 流量池选项列表
|
||||||
|
// 好友推送间隔设置
|
||||||
|
friendIntervalMin?: number; // 目标间最小间隔(秒)
|
||||||
|
friendIntervalMax?: number; // 目标间最大间隔(秒)
|
||||||
|
messageIntervalMin?: number; // 消息间最小间隔(秒)
|
||||||
|
messageIntervalMax?: number; // 消息间最大间隔(秒)
|
||||||
|
// 群公告相关(仅当targetType=1且groupPushSubType=2时)
|
||||||
|
announcementContent?: string; // 群公告内容
|
||||||
|
enableAiRewrite?: number; // 是否启用AI改写:0=否,1=是
|
||||||
|
aiRewritePrompt?: string; // AI改写提示词
|
||||||
|
// 设备选择
|
||||||
|
deviceGroups?: string[]; // 设备ID列表
|
||||||
|
deviceGroupsOptions?: any[]; // 设备选项列表
|
||||||
// 京东联盟相关字段
|
// 京东联盟相关字段
|
||||||
socialMediaId?: string;
|
socialMediaId?: string;
|
||||||
promotionSiteId?: string;
|
promotionSiteId?: string;
|
||||||
|
|||||||
35
src/pages/mobile/workspace/group-push/form/index.module.scss
Normal file
35
src/pages/mobile/workspace/group-push/form/index.module.scss
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
.form-container {
|
||||||
|
background: #f8fafc;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding-bottom: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-content {
|
||||||
|
padding: 16px;
|
||||||
|
background: #f8fafc;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding-bottom: 100px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: hidden;
|
||||||
|
|
||||||
|
@media (max-width: 375px) {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
@media (max-width: 375px) {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -6,6 +6,7 @@ import { createGroupPushTask, fetchGroupPushTaskDetail } from "./index.api";
|
|||||||
import Layout from "@/components/Layout/Layout";
|
import Layout from "@/components/Layout/Layout";
|
||||||
import StepIndicator from "@/components/StepIndicator";
|
import StepIndicator from "@/components/StepIndicator";
|
||||||
import BasicSettings, { BasicSettingsRef } from "./components/BasicSettings";
|
import BasicSettings, { BasicSettingsRef } from "./components/BasicSettings";
|
||||||
|
import DeviceSelector, { DeviceSelectorRef } from "./components/DeviceSelector";
|
||||||
import GroupSelector, { GroupSelectorRef } from "./components/GroupSelector";
|
import GroupSelector, { GroupSelectorRef } from "./components/GroupSelector";
|
||||||
import ContentSelector, {
|
import ContentSelector, {
|
||||||
ContentSelectorRef,
|
ContentSelectorRef,
|
||||||
@@ -14,17 +15,50 @@ import type { FormData } from "./index.data";
|
|||||||
import NavCommon from "@/components/NavCommon";
|
import NavCommon from "@/components/NavCommon";
|
||||||
import { GroupSelectionItem } from "@/components/GroupSelection/data";
|
import { GroupSelectionItem } from "@/components/GroupSelection/data";
|
||||||
import { ContentItem } from "@/components/ContentSelection/data";
|
import { ContentItem } from "@/components/ContentSelection/data";
|
||||||
const steps = [
|
import { DeviceSelectionItem } from "@/components/DeviceSelection/data";
|
||||||
{ id: 1, title: "步骤 1", subtitle: "基础设置" },
|
import styles from "./index.module.scss";
|
||||||
{ id: 2, title: "步骤 2", subtitle: "选择社群" },
|
|
||||||
{ id: 3, title: "步骤 3", subtitle: "选择内容库" },
|
// 根据targetType和groupPushSubType动态生成步骤
|
||||||
];
|
const getSteps = (targetType: number, groupPushSubType?: number) => {
|
||||||
|
const baseSteps = [
|
||||||
|
{ id: 1, title: "步骤 1", subtitle: "基础设置" },
|
||||||
|
{ id: 2, title: "步骤 2", subtitle: "选择设备" },
|
||||||
|
];
|
||||||
|
|
||||||
|
if (targetType === 2) {
|
||||||
|
// 好友推送:选择好友
|
||||||
|
return [
|
||||||
|
...baseSteps,
|
||||||
|
{ id: 3, title: "步骤 3", subtitle: "选择好友" },
|
||||||
|
{ id: 4, title: "步骤 4", subtitle: "选择内容库" },
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
// 群推送:选择社群
|
||||||
|
const steps = [
|
||||||
|
...baseSteps,
|
||||||
|
{ id: 3, title: "步骤 3", subtitle: "选择社群" },
|
||||||
|
];
|
||||||
|
// 群公告时不显示内容库步骤
|
||||||
|
if (groupPushSubType !== 2) {
|
||||||
|
steps.push({ id: 4, title: "步骤 4", subtitle: "选择内容库" });
|
||||||
|
}
|
||||||
|
return steps;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const NewGroupPush: React.FC = () => {
|
const NewGroupPush: React.FC = () => {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [currentStep, setCurrentStep] = useState(1);
|
const [currentStep, setCurrentStep] = useState(1);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// 从 URL 参数获取推送类型
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const urlTargetType = urlParams.get("targetType");
|
||||||
|
const urlGroupPushSubType = urlParams.get("groupPushSubType");
|
||||||
|
const [deviceGroupsOptions, setDeviceGroupsOptions] = useState<
|
||||||
|
DeviceSelectionItem[]
|
||||||
|
>([]);
|
||||||
const [wechatGroupsOptions, setWechatGroupsOptions] = useState<
|
const [wechatGroupsOptions, setWechatGroupsOptions] = useState<
|
||||||
GroupSelectionItem[]
|
GroupSelectionItem[]
|
||||||
>([]);
|
>([]);
|
||||||
@@ -33,44 +67,173 @@ const NewGroupPush: React.FC = () => {
|
|||||||
>([]);
|
>([]);
|
||||||
|
|
||||||
const [formData, setFormData] = useState<FormData>({
|
const [formData, setFormData] = useState<FormData>({
|
||||||
|
planType: 1, // 默认独立计划
|
||||||
name: "",
|
name: "",
|
||||||
startTime: "06:00", // 允许推送的开始时间
|
startTime: "09:00", // 允许推送的开始时间
|
||||||
dailyPushCount: 0, // 每日已推送次数
|
dailyPushCount: 0, // 每日已推送次数
|
||||||
endTime: "23:59", // 允许推送的结束时间
|
endTime: "21:00", // 允许推送的结束时间
|
||||||
maxPerDay: 20,
|
maxPerDay: 20,
|
||||||
pushOrder: 2, // 2: 按最新
|
pushOrder: 1, // 1: 按最早
|
||||||
isLoop: 0, // 0: 否, 1: 是
|
isLoop: 0, // 0: 否, 1: 是
|
||||||
pushType: 0, // 0: 定时推送, 1: 立即推送
|
pushType: 0, // 0: 定时推送, 1: 立即推送
|
||||||
status: 0, // 0: 否, 1: 是
|
status: 0, // 0: 否, 1: 是(同时作为是否自动启动)
|
||||||
|
isRandomTemplate: 0, // 是否随机模板:0=否,1=是
|
||||||
|
postPushTags: [], // 推送后标签数组
|
||||||
wechatGroups: [],
|
wechatGroups: [],
|
||||||
contentGroups: [],
|
contentGroups: [],
|
||||||
|
targetType: urlTargetType ? Number(urlTargetType) : 1, // 从URL参数获取,默认1=群推送
|
||||||
|
groupPushSubType: urlGroupPushSubType ? Number(urlGroupPushSubType) : 1, // 从URL参数获取,默认1=群群发
|
||||||
|
wechatFriends: [],
|
||||||
|
wechatFriendsOptions: [],
|
||||||
|
poolGroups: [],
|
||||||
|
poolGroupsOptions: [],
|
||||||
|
deviceGroups: [],
|
||||||
|
// 好友推送间隔设置
|
||||||
|
friendIntervalMin: 10, // 目标间最小间隔(秒)
|
||||||
|
friendIntervalMax: 20, // 目标间最大间隔(秒)
|
||||||
|
messageIntervalMin: 1, // 消息间最小间隔(秒)
|
||||||
|
messageIntervalMax: 12, // 消息间最大间隔(秒)
|
||||||
|
// 群公告相关
|
||||||
|
announcementContent: "",
|
||||||
|
enableAiRewrite: 0,
|
||||||
|
aiRewritePrompt: "",
|
||||||
});
|
});
|
||||||
const [isEditMode, setIsEditMode] = useState(false);
|
const [isEditMode, setIsEditMode] = useState(false);
|
||||||
|
|
||||||
// 创建子组件的ref
|
// 创建子组件的ref
|
||||||
const basicSettingsRef = useRef<BasicSettingsRef>(null);
|
const basicSettingsRef = useRef<BasicSettingsRef>(null);
|
||||||
|
const deviceSelectorRef = useRef<DeviceSelectorRef>(null);
|
||||||
const groupSelectorRef = useRef<GroupSelectorRef>(null);
|
const groupSelectorRef = useRef<GroupSelectorRef>(null);
|
||||||
const contentSelectorRef = useRef<ContentSelectorRef>(null);
|
const contentSelectorRef = useRef<ContentSelectorRef>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
setIsEditMode(true);
|
setIsEditMode(true);
|
||||||
|
// 加载编辑数据
|
||||||
|
const loadEditData = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetchGroupPushTaskDetail(id);
|
||||||
|
const data = res?.data || res;
|
||||||
|
const config = data?.config || {};
|
||||||
|
|
||||||
|
// 回填表单数据(支持接口字段名和数据库字段名的映射)
|
||||||
|
// 数据库字段:groups, friends, trafficPools, contentLibraries, ownerWechatIds, devices
|
||||||
|
// 接口字段:wechatGroups, wechatFriends, trafficPools, contentGroups, ownerWechatIds
|
||||||
|
const groups = config.groups || config.wechatGroups || [];
|
||||||
|
const friends = config.friends || config.wechatFriends || [];
|
||||||
|
const trafficPools = config.trafficPools || config.poolGroups || [];
|
||||||
|
const contentLibraries = config.contentLibraries || config.contentGroups || [];
|
||||||
|
const ownerWechatIds = config.ownerWechatIds || config.deviceGroups || [];
|
||||||
|
const devices = config.devices || [];
|
||||||
|
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
planType: config.planType ?? data.planType ?? 1,
|
||||||
|
name: data.name || "",
|
||||||
|
status: data.status ?? config.status ?? config.autoStart ?? 0, // status 和 autoStart 合并为 status
|
||||||
|
targetType: config.targetType ?? 1,
|
||||||
|
groupPushSubType: config.groupPushSubType ?? 1,
|
||||||
|
pushType: config.pushType ?? 0, // 0=定时推送,1=立即推送
|
||||||
|
startTime: config.startTime || "09:00",
|
||||||
|
endTime: config.endTime || "21:00",
|
||||||
|
maxPerDay: config.maxPerDay || 20,
|
||||||
|
pushOrder: config.pushOrder || 1,
|
||||||
|
isLoop: config.isLoop ?? 0,
|
||||||
|
isRandomTemplate: config.isRandomTemplate ?? 0,
|
||||||
|
postPushTags: config.postPushTags || [],
|
||||||
|
// 支持数据库字段名和接口字段名的映射
|
||||||
|
deviceGroups: [...ownerWechatIds, ...devices].map((id: any) => String(id)),
|
||||||
|
wechatGroups: groups.map((id: any) => String(id)),
|
||||||
|
wechatFriends: friends.map((id: any) => String(id)),
|
||||||
|
poolGroups: trafficPools.map((id: any) => String(id)),
|
||||||
|
contentGroups: contentLibraries.map((id: any) => String(id)),
|
||||||
|
friendIntervalMin: config.friendIntervalMin || 10,
|
||||||
|
friendIntervalMax: config.friendIntervalMax || 20,
|
||||||
|
messageIntervalMin: config.messageIntervalMin || 1,
|
||||||
|
messageIntervalMax: config.messageIntervalMax || 12,
|
||||||
|
announcementContent: config.announcementContent || "",
|
||||||
|
enableAiRewrite: config.enableAiRewrite ?? 0,
|
||||||
|
aiRewritePrompt: config.aiRewritePrompt || "",
|
||||||
|
socialMediaId: config.socialMediaId || "",
|
||||||
|
promotionSiteId: config.promotionSiteId || "",
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 回填选项数据(支持多种字段名)
|
||||||
|
// 设备选项:支持 deviceGroupsOptions, devicesOptions, ownerWechatOptions
|
||||||
|
if (config.deviceGroupsOptions || config.devicesOptions || config.ownerWechatOptions) {
|
||||||
|
setDeviceGroupsOptions(
|
||||||
|
config.deviceGroupsOptions ||
|
||||||
|
config.devicesOptions ||
|
||||||
|
config.ownerWechatOptions ||
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 群组选项:支持 wechatGroupsOptions, groupsOptions
|
||||||
|
if (config.wechatGroupsOptions || config.groupsOptions) {
|
||||||
|
setWechatGroupsOptions(config.wechatGroupsOptions || config.groupsOptions || []);
|
||||||
|
}
|
||||||
|
// 内容库选项:支持 contentGroupsOptions, contentLibrariesOptions
|
||||||
|
if (config.contentGroupsOptions || config.contentLibrariesOptions) {
|
||||||
|
setContentGroupsOptions(config.contentGroupsOptions || config.contentLibrariesOptions || []);
|
||||||
|
}
|
||||||
|
// 好友选项:支持 wechatFriendsOptions, friendsOptions
|
||||||
|
if (config.wechatFriendsOptions || config.friendsOptions) {
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
wechatFriendsOptions: config.wechatFriendsOptions || config.friendsOptions || []
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
// 流量池选项:支持 poolGroupsOptions, trafficPoolsOptions
|
||||||
|
if (config.poolGroupsOptions || config.trafficPoolsOptions) {
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
poolGroupsOptions: config.poolGroupsOptions || config.trafficPoolsOptions || []
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("加载编辑数据失败:", error);
|
||||||
|
Toast.show({ content: "加载数据失败", position: "top" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadEditData();
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
const handleBasicSettingsChange = (values: Partial<FormData>) => {
|
const handleBasicSettingsChange = (values: Partial<FormData>) => {
|
||||||
setFormData(prev => ({ ...prev, ...values }));
|
setFormData(prev => ({ ...prev, ...values }));
|
||||||
};
|
};
|
||||||
|
|
||||||
//群组选择
|
//设备选择
|
||||||
const handleGroupsChange = (data: {
|
const handleDevicesChange = (data: {
|
||||||
wechatGroups: string[];
|
deviceGroups: string[];
|
||||||
wechatGroupsOptions: GroupSelectionItem[];
|
deviceGroupsOptions: DeviceSelectionItem[];
|
||||||
}) => {
|
}) => {
|
||||||
setFormData(prev => ({
|
setFormData(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
wechatGroups: data.wechatGroups,
|
deviceGroups: data.deviceGroups,
|
||||||
}));
|
}));
|
||||||
setWechatGroupsOptions(data.wechatGroupsOptions);
|
setDeviceGroupsOptions(data.deviceGroupsOptions);
|
||||||
|
};
|
||||||
|
|
||||||
|
//群组选择(当targetType=1时)或好友/流量池选择(当targetType=2时)
|
||||||
|
const handleGroupsChange = (data: {
|
||||||
|
wechatGroups?: string[];
|
||||||
|
wechatGroupsOptions?: GroupSelectionItem[];
|
||||||
|
wechatFriends?: string[];
|
||||||
|
wechatFriendsOptions?: any[];
|
||||||
|
poolGroups?: string[];
|
||||||
|
poolGroupsOptions?: any[];
|
||||||
|
}) => {
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
wechatGroups: data.wechatGroups || [],
|
||||||
|
wechatFriends: data.wechatFriends || [],
|
||||||
|
poolGroups: data.poolGroups || [],
|
||||||
|
wechatFriendsOptions: data.wechatFriendsOptions || [],
|
||||||
|
poolGroupsOptions: data.poolGroupsOptions || [],
|
||||||
|
}));
|
||||||
|
if (data.wechatGroupsOptions) {
|
||||||
|
setWechatGroupsOptions(data.wechatGroupsOptions);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
//内容库选择
|
//内容库选择
|
||||||
const handleLibrariesChange = (data: {
|
const handleLibrariesChange = (data: {
|
||||||
@@ -83,57 +246,90 @@ const NewGroupPush: React.FC = () => {
|
|||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
try {
|
try {
|
||||||
// 调用 ContentSelector 的表单校验
|
// 群公告时不验证内容库选择器(因为步骤被隐藏了)
|
||||||
const isValid = (await contentSelectorRef.current?.validate()) || false;
|
const isGroupAnnouncement = formData.targetType === 1 && formData.groupPushSubType === 2;
|
||||||
if (!isValid) return;
|
if (!isGroupAnnouncement) {
|
||||||
|
// 调用 ContentSelector 的表单校验
|
||||||
|
const isValid = (await contentSelectorRef.current?.validate()) || false;
|
||||||
|
if (!isValid) return;
|
||||||
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
// 获取基础设置中的京东联盟数据
|
// 获取基础设置中的京东联盟数据
|
||||||
const basicSettingsValues = basicSettingsRef.current?.getValues() || {};
|
const basicSettingsValues = basicSettingsRef.current?.getValues() || {};
|
||||||
|
|
||||||
// 构建 API 请求数据
|
// 构建 API 请求数据(根据接口文档)
|
||||||
const apiData = {
|
const apiData: any = {
|
||||||
name: formData.name,
|
name: formData.name,
|
||||||
startTime: formData.startTime, // 允许推送的开始时间
|
type: 3, // 群发工作台类型固定为3
|
||||||
endTime: formData.endTime, // 允许推送的结束时间
|
status: formData.status, // 0: 否, 1: 是(同时作为是否自动启动)
|
||||||
maxPerDay: formData.maxPerDay,
|
targetType: formData.targetType, // 1=群推送,2=好友推送
|
||||||
pushOrder: formData.pushOrder,
|
pushType: formData.pushType ?? 0, // 推送方式:0=定时推送,1=立即推送
|
||||||
isLoop: formData.isLoop, // 0: 否, 1: 是
|
// 设备参数:参考自动建群的方式,使用deviceGroups
|
||||||
pushType: formData.pushType, // 0: 定时推送, 1: 立即推送
|
deviceGroups: formData.deviceGroups?.map(id => Number(id)) || [], // 设备ID数组
|
||||||
status: formData.status, // 0: 否, 1: 是
|
ownerWechatIds: formData.deviceGroups?.map(id => Number(id)) || [], // 设备ID数组(兼容字段)
|
||||||
wechatGroups: formData.wechatGroups,
|
startTime: formData.startTime || "09:00", // 允许推送的开始时间
|
||||||
contentGroups: formData.contentGroups,
|
endTime: formData.endTime || "21:00", // 允许推送的结束时间
|
||||||
// 京东联盟数据从基础设置中获取
|
maxPerDay: formData.maxPerDay || 0,
|
||||||
socialMediaId: basicSettingsValues.socialMediaId,
|
pushOrder: formData.pushOrder || 1,
|
||||||
promotionSiteId: basicSettingsValues.promotionSiteId,
|
isRandomTemplate: formData.isRandomTemplate || 0,
|
||||||
pushMode:
|
socialMediaId: basicSettingsValues.socialMediaId || "",
|
||||||
formData.pushType === 1
|
promotionSiteId: basicSettingsValues.promotionSiteId || "",
|
||||||
? ("immediate" as const)
|
planType: formData.planType ?? 1,
|
||||||
: ("scheduled" as const),
|
|
||||||
messageType: "text" as const,
|
|
||||||
messageContent: "",
|
|
||||||
targetTags: [],
|
|
||||||
pushInterval: 60,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 打印API请求数据,用于调试
|
// 群推送(targetType = 1)
|
||||||
console.log("发送到API的数据:", apiData);
|
if (formData.targetType === 1) {
|
||||||
|
apiData.groupPushSubType = formData.groupPushSubType || 1;
|
||||||
|
apiData.wechatGroups = formData.wechatGroups.map(id => Number(id)); // 群ID数组
|
||||||
|
apiData.isLoop = formData.isLoop || 0; // 群推送和好友推送都有循环推送
|
||||||
|
// 群推送不打标签,不传递postPushTags
|
||||||
|
// 群推送不传递间隔参数
|
||||||
|
// 群公告不传递内容库,群群发才传递
|
||||||
|
if (formData.groupPushSubType !== 2) {
|
||||||
|
apiData.contentGroups = formData.contentGroups.map(id => Number(id)); // 内容库ID数组
|
||||||
|
}
|
||||||
|
|
||||||
|
// 群公告(groupPushSubType = 2)
|
||||||
|
if (formData.groupPushSubType === 2) {
|
||||||
|
apiData.announcementContent = formData.announcementContent || "";
|
||||||
|
apiData.enableAiRewrite = formData.enableAiRewrite || 0;
|
||||||
|
if (formData.enableAiRewrite === 1) {
|
||||||
|
apiData.aiRewritePrompt = formData.aiRewritePrompt || "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 好友推送(targetType = 2)
|
||||||
|
apiData.wechatFriends = (formData.wechatFriends || []).map(id => Number(id));
|
||||||
|
apiData.trafficPools = (formData.poolGroups || []).map(id => Number(id)); // 流量池ID数组
|
||||||
|
apiData.isLoop = formData.isLoop || 0; // 好友推送默认为否(0)
|
||||||
|
// 好友推送可以打标签
|
||||||
|
apiData.postPushTags = formData.postPushTags || [];
|
||||||
|
// 好友推送需要传递间隔参数
|
||||||
|
apiData.friendIntervalMin = formData.friendIntervalMin || 10;
|
||||||
|
apiData.friendIntervalMax = formData.friendIntervalMax || 20;
|
||||||
|
apiData.messageIntervalMin = formData.messageIntervalMin || 1;
|
||||||
|
apiData.messageIntervalMax = formData.messageIntervalMax || 12;
|
||||||
|
// 好友推送需要传递内容库
|
||||||
|
apiData.contentGroups = formData.contentGroups.map(id => Number(id)); // 内容库ID数组
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新时需要传递id
|
||||||
|
if (id) {
|
||||||
|
apiData.id = Number(id);
|
||||||
|
}
|
||||||
|
|
||||||
// 调用创建或更新 API
|
// 调用创建或更新 API
|
||||||
if (id) {
|
if (id) {
|
||||||
// 更新逻辑将在这里实现
|
const { updateGroupPushTask } = await import("./index.api");
|
||||||
|
await updateGroupPushTask(apiData);
|
||||||
Toast.show({ content: "更新成功", position: "top" });
|
Toast.show({ content: "更新成功", position: "top" });
|
||||||
navigate("/workspace/group-push");
|
navigate("/workspace/group-push");
|
||||||
} else {
|
} else {
|
||||||
createGroupPushTask(apiData)
|
await createGroupPushTask(apiData);
|
||||||
.then(() => {
|
Toast.show({ content: "创建成功", position: "top" });
|
||||||
Toast.show({ content: "创建成功", position: "top" });
|
navigate("/workspace/group-push");
|
||||||
navigate("/workspace/group-push");
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
Toast.show({ content: "创建失败,请稍后重试", position: "top" });
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Toast.show({ content: "保存失败,请稍后重试", position: "top" });
|
Toast.show({ content: "保存失败,请稍后重试", position: "top" });
|
||||||
@@ -149,7 +345,7 @@ const NewGroupPush: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleNext = async () => {
|
const handleNext = async () => {
|
||||||
if (currentStep < 3) {
|
if (currentStep < 4) {
|
||||||
try {
|
try {
|
||||||
let isValid = false;
|
let isValid = false;
|
||||||
|
|
||||||
@@ -167,10 +363,23 @@ const NewGroupPush: React.FC = () => {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 2:
|
case 2:
|
||||||
|
// 调用 DeviceSelector 的表单校验
|
||||||
|
isValid = (await deviceSelectorRef.current?.validate()) || false;
|
||||||
|
if (isValid) {
|
||||||
|
setCurrentStep(3);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 3:
|
||||||
// 调用 GroupSelector 的表单校验
|
// 调用 GroupSelector 的表单校验
|
||||||
isValid = (await groupSelectorRef.current?.validate()) || false;
|
isValid = (await groupSelectorRef.current?.validate()) || false;
|
||||||
if (isValid) {
|
if (isValid) {
|
||||||
setCurrentStep(3);
|
// 群公告时不显示内容库步骤,直接保存
|
||||||
|
if (formData.targetType === 1 && formData.groupPushSubType === 2) {
|
||||||
|
await handleSave();
|
||||||
|
} else {
|
||||||
|
setCurrentStep(4);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -191,15 +400,20 @@ const NewGroupPush: React.FC = () => {
|
|||||||
上一步
|
上一步
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{currentStep === 3 ? (
|
{(() => {
|
||||||
<Button size="large" type="primary" onClick={handleSave}>
|
// 群公告时,步骤3就是最后一步
|
||||||
保存
|
const isGroupAnnouncement = formData.targetType === 1 && formData.groupPushSubType === 2;
|
||||||
</Button>
|
const isLastStep = isGroupAnnouncement ? currentStep === 3 : currentStep === 4;
|
||||||
) : (
|
return isLastStep ? (
|
||||||
<Button size="large" type="primary" onClick={handleNext}>
|
<Button size="large" type="primary" onClick={handleSave}>
|
||||||
下一步
|
保存
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
) : (
|
||||||
|
<Button size="large" type="primary" onClick={handleNext}>
|
||||||
|
下一步
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -209,11 +423,11 @@ const NewGroupPush: React.FC = () => {
|
|||||||
header={<NavCommon title={isEditMode ? "编辑任务" : "新建任务"} />}
|
header={<NavCommon title={isEditMode ? "编辑任务" : "新建任务"} />}
|
||||||
footer={renderFooter()}
|
footer={renderFooter()}
|
||||||
>
|
>
|
||||||
<div style={{ padding: 12 }}>
|
<div className={styles.formContainer}>
|
||||||
<div style={{ marginBottom: 12 }}>
|
<div style={{ marginBottom: 12, padding: "0 16px" }}>
|
||||||
<StepIndicator currentStep={currentStep} steps={steps} />
|
<StepIndicator currentStep={currentStep} steps={getSteps(formData.targetType, formData.groupPushSubType)} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className={styles.formContent}>
|
||||||
{currentStep === 1 && (
|
{currentStep === 1 && (
|
||||||
<BasicSettings
|
<BasicSettings
|
||||||
ref={basicSettingsRef}
|
ref={basicSettingsRef}
|
||||||
@@ -226,6 +440,17 @@ const NewGroupPush: React.FC = () => {
|
|||||||
isLoop: formData.isLoop,
|
isLoop: formData.isLoop,
|
||||||
status: formData.status,
|
status: formData.status,
|
||||||
pushType: formData.pushType,
|
pushType: formData.pushType,
|
||||||
|
targetType: formData.targetType,
|
||||||
|
groupPushSubType: formData.groupPushSubType,
|
||||||
|
isRandomTemplate: formData.isRandomTemplate,
|
||||||
|
postPushTags: formData.postPushTags,
|
||||||
|
friendIntervalMin: formData.friendIntervalMin,
|
||||||
|
friendIntervalMax: formData.friendIntervalMax,
|
||||||
|
messageIntervalMin: formData.messageIntervalMin,
|
||||||
|
messageIntervalMax: formData.messageIntervalMax,
|
||||||
|
announcementContent: formData.announcementContent,
|
||||||
|
enableAiRewrite: formData.enableAiRewrite,
|
||||||
|
aiRewritePrompt: formData.aiRewritePrompt,
|
||||||
}}
|
}}
|
||||||
onNext={handleBasicSettingsChange}
|
onNext={handleBasicSettingsChange}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
@@ -233,18 +458,37 @@ const NewGroupPush: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{currentStep === 2 && (
|
{currentStep === 2 && (
|
||||||
<GroupSelector
|
<DeviceSelector
|
||||||
ref={groupSelectorRef}
|
ref={deviceSelectorRef}
|
||||||
selectedGroups={wechatGroupsOptions}
|
selectedDevices={deviceGroupsOptions}
|
||||||
onPrevious={() => setCurrentStep(1)}
|
onPrevious={() => setCurrentStep(1)}
|
||||||
onNext={handleGroupsChange}
|
onNext={handleDevicesChange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{currentStep === 3 && (
|
{currentStep === 3 && (
|
||||||
|
<GroupSelector
|
||||||
|
ref={groupSelectorRef}
|
||||||
|
selectedGroups={wechatGroupsOptions}
|
||||||
|
targetType={formData.targetType}
|
||||||
|
selectedFriends={formData.wechatFriendsOptions || []}
|
||||||
|
selectedPools={formData.poolGroupsOptions || []}
|
||||||
|
onPrevious={() => setCurrentStep(2)}
|
||||||
|
onNext={handleGroupsChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{currentStep === 4 && formData.targetType === 1 && formData.groupPushSubType !== 2 && (
|
||||||
<ContentSelector
|
<ContentSelector
|
||||||
ref={contentSelectorRef}
|
ref={contentSelectorRef}
|
||||||
selectedOptions={contentGroupsOptions}
|
selectedOptions={contentGroupsOptions}
|
||||||
onPrevious={() => setCurrentStep(2)}
|
onPrevious={() => setCurrentStep(3)}
|
||||||
|
onNext={handleLibrariesChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{currentStep === 4 && formData.targetType === 2 && (
|
||||||
|
<ContentSelector
|
||||||
|
ref={contentSelectorRef}
|
||||||
|
selectedOptions={contentGroupsOptions}
|
||||||
|
onPrevious={() => setCurrentStep(3)}
|
||||||
onNext={handleLibrariesChange}
|
onNext={handleLibrariesChange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -28,6 +28,49 @@
|
|||||||
padding: 0 16px;
|
padding: 0 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.infoBox {
|
||||||
|
background: #eff6ff;
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #1e40af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionDot {
|
||||||
|
width: 4px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitleIndependent {
|
||||||
|
.sectionDot {
|
||||||
|
background: #fb923c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskListGroup {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.emptyCard {
|
.emptyCard {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 48px 0;
|
padding: 48px 0;
|
||||||
|
|||||||
@@ -100,7 +100,12 @@ const GroupPush: React.FC = () => {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const result = await fetchGroupPushTasks();
|
const result = await fetchGroupPushTasks();
|
||||||
setTasks(result.list);
|
const list = (result.list || []) as any[];
|
||||||
|
const normalized = list.map(item => ({
|
||||||
|
...item,
|
||||||
|
planType: item.config?.planType ?? item.planType ?? 1,
|
||||||
|
}));
|
||||||
|
setTasks(normalized);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -138,13 +143,21 @@ const GroupPush: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateNew = () => {
|
const handleCreateNew = () => {
|
||||||
navigate("/workspace/group-push/new");
|
// 直接跳转到群消息推送(targetType=1, groupPushSubType=1)
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
targetType: "1",
|
||||||
|
groupPushSubType: "1",
|
||||||
|
});
|
||||||
|
navigate(`/workspace/group-push/new?${params.toString()}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredTasks = tasks.filter(task =>
|
const filteredTasks = tasks.filter(task =>
|
||||||
task.name.toLowerCase().includes(searchTerm.toLowerCase()),
|
task.name.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const globalTasks = filteredTasks.filter(t => t.planType === 0);
|
||||||
|
const independentTasks = filteredTasks.filter(t => t.planType !== 0);
|
||||||
|
|
||||||
const getStatusColor = (status: number) => {
|
const getStatusColor = (status: number) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 1:
|
case 1:
|
||||||
@@ -167,6 +180,111 @@ const GroupPush: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderTaskCard = (task: any) => (
|
||||||
|
<Card key={task.id} 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 className={styles.taskActions}>
|
||||||
|
<Switch
|
||||||
|
checked={task.status === 1}
|
||||||
|
onChange={() => toggleTaskStatus(task.id)}
|
||||||
|
/>
|
||||||
|
<CardMenu
|
||||||
|
onView={() => handleView(task.id)}
|
||||||
|
onEdit={() => handleEdit(task.id)}
|
||||||
|
onCopy={() => handleCopy(task.id)}
|
||||||
|
onDelete={() => handleDelete(task.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.taskInfoGrid}>
|
||||||
|
<div>
|
||||||
|
<TeamOutlined />
|
||||||
|
推送目标:{task.config?.groups?.length || 0} 个社群
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CarryOutOutlined /> 推送内容:
|
||||||
|
{task.config?.content || 0} 个
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.taskFooter}>
|
||||||
|
<div>
|
||||||
|
<ClockCircleOutlined /> 上次推送:
|
||||||
|
{task.config?.lastPushTime || "暂无"}
|
||||||
|
</div>
|
||||||
|
<div>创建时间:{task.createTime}</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
let content: React.ReactNode;
|
||||||
|
if (filteredTasks.length === 0) {
|
||||||
|
content = (
|
||||||
|
<Card className={styles.emptyCard}>
|
||||||
|
<SendOutlined
|
||||||
|
style={{ fontSize: 48, color: "#ccc", marginBottom: 12 }}
|
||||||
|
/>
|
||||||
|
<div style={{ color: "#888", fontSize: 16, marginBottom: 8 }}>
|
||||||
|
暂无推送任务
|
||||||
|
</div>
|
||||||
|
<div style={{ color: "#bbb", fontSize: 13, marginBottom: 16 }}>
|
||||||
|
创建您的第一个群消息推送任务
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={handleCreateNew}
|
||||||
|
>
|
||||||
|
创建第一个任务
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
content = (
|
||||||
|
<>
|
||||||
|
{globalTasks.length > 0 && (
|
||||||
|
<div className={styles.infoBox}>
|
||||||
|
全局群发计划将应用于所有设备(包括新添加的设备),请谨慎设置推送频率和内容。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{globalTasks.length > 0 && (
|
||||||
|
<section className={styles.section}>
|
||||||
|
<h3 className={styles.sectionTitle}>
|
||||||
|
<span className={styles.sectionDot} />
|
||||||
|
全局群发计划
|
||||||
|
</h3>
|
||||||
|
<div className={styles.taskListGroup}>
|
||||||
|
{globalTasks.map(renderTaskCard)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{independentTasks.length > 0 && (
|
||||||
|
<section className={styles.section}>
|
||||||
|
<h3
|
||||||
|
className={`${styles.sectionTitle} ${styles.sectionTitleIndependent}`}
|
||||||
|
>
|
||||||
|
<span className={styles.sectionDot} />
|
||||||
|
独立群发计划
|
||||||
|
</h3>
|
||||||
|
<div className={styles.taskListGroup}>
|
||||||
|
{independentTasks.map(renderTaskCard)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout
|
<Layout
|
||||||
loading={loading}
|
loading={loading}
|
||||||
@@ -207,73 +325,7 @@ const GroupPush: React.FC = () => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className={styles.bg}>
|
<div className={styles.bg}>
|
||||||
<div className={styles.taskList}>
|
<div className={styles.taskList}>{content}</div>
|
||||||
{filteredTasks.length === 0 ? (
|
|
||||||
<Card className={styles.emptyCard}>
|
|
||||||
<SendOutlined
|
|
||||||
style={{ fontSize: 48, color: "#ccc", marginBottom: 12 }}
|
|
||||||
/>
|
|
||||||
<div style={{ color: "#888", fontSize: 16, marginBottom: 8 }}>
|
|
||||||
暂无推送任务
|
|
||||||
</div>
|
|
||||||
<div style={{ color: "#bbb", fontSize: 13, marginBottom: 16 }}>
|
|
||||||
创建您的第一个群消息推送任务
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
icon={<PlusOutlined />}
|
|
||||||
onClick={handleCreateNew}
|
|
||||||
>
|
|
||||||
创建第一个任务
|
|
||||||
</Button>
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
filteredTasks.map(task => (
|
|
||||||
<Card key={task.id} 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 className={styles.taskActions}>
|
|
||||||
<Switch
|
|
||||||
checked={task.status === 1}
|
|
||||||
onChange={() => toggleTaskStatus(task.id)}
|
|
||||||
/>
|
|
||||||
<CardMenu
|
|
||||||
onView={() => handleView(task.id)}
|
|
||||||
onEdit={() => handleEdit(task.id)}
|
|
||||||
onCopy={() => handleCopy(task.id)}
|
|
||||||
onDelete={() => handleDelete(task.id)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={styles.taskInfoGrid}>
|
|
||||||
<div>
|
|
||||||
<TeamOutlined />
|
|
||||||
推送目标:{task.config?.groups?.length || 0} 个社群
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<CarryOutOutlined /> 推送内容:
|
|
||||||
{task.config?.content || 0} 个
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.taskFooter}>
|
|
||||||
<div>
|
|
||||||
<ClockCircleOutlined /> 上次推送:
|
|
||||||
{task.config?.lastPushTime || "暂无"}
|
|
||||||
</div>
|
|
||||||
<div>创建时间:{task.createTime}</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
.detailContainer {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailCard {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||||
|
|
||||||
|
:global(.ant-card-head) {
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.ant-card-head-title) {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardHeader {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupList,
|
||||||
|
.robotList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #e5e6eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupAvatar {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 8px;
|
||||||
|
object-fit: cover;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupInfo {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupName {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupOwner {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #666;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageItem {
|
||||||
|
border: 1px solid #e5e6eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageHeader {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageContent {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textContent {
|
||||||
|
padding: 12px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 6px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileContent {
|
||||||
|
padding: 12px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.detailContainer {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardHeader {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
245
src/pages/mobile/workspace/group-welcome/detail/index.tsx
Normal file
245
src/pages/mobile/workspace/group-welcome/detail/index.tsx
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { Card, Descriptions, Tag, Badge, Button } from "antd";
|
||||||
|
import {
|
||||||
|
ArrowLeftOutlined,
|
||||||
|
TeamOutlined,
|
||||||
|
MessageOutlined,
|
||||||
|
ClockCircleOutlined,
|
||||||
|
RobotOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
|
import Layout from "@/components/Layout/Layout";
|
||||||
|
import NavCommon from "@/components/NavCommon";
|
||||||
|
import { fetchGroupWelcomeTaskDetail } from "../form/index.api";
|
||||||
|
import { Toast } from "antd-mobile";
|
||||||
|
import styles from "./index.module.scss";
|
||||||
|
|
||||||
|
const GroupWelcomeDetail: React.FC = () => {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [taskData, setTaskData] = useState<any>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id) return;
|
||||||
|
const loadDetail = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const res = await fetchGroupWelcomeTaskDetail(id);
|
||||||
|
const data = res?.data || res;
|
||||||
|
setTaskData(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("加载详情失败:", error);
|
||||||
|
Toast.show({ content: "加载数据失败", position: "top" });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadDetail();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
const typeMap: Record<string, string> = {
|
||||||
|
text: "文本",
|
||||||
|
image: "图片",
|
||||||
|
video: "视频",
|
||||||
|
file: "文件",
|
||||||
|
};
|
||||||
|
return typeMap[type] || type;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Layout
|
||||||
|
header={<NavCommon title="任务详情" backFn={() => navigate("/workspace/group-welcome")} />}
|
||||||
|
>
|
||||||
|
<div style={{ textAlign: "center", padding: "40px 0" }}>加载中...</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!taskData) {
|
||||||
|
return (
|
||||||
|
<Layout
|
||||||
|
header={<NavCommon title="任务详情" backFn={() => navigate("/workspace/group-welcome")} />}
|
||||||
|
>
|
||||||
|
<div style={{ textAlign: "center", padding: "40px 0" }}>暂无数据</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = taskData.config || {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout
|
||||||
|
header={
|
||||||
|
<NavCommon
|
||||||
|
title="任务详情"
|
||||||
|
backFn={() => navigate("/workspace/group-welcome")}
|
||||||
|
right={
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
onClick={() => navigate(`/workspace/group-welcome/edit/${id}`)}
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={styles.detailContainer}>
|
||||||
|
<Card className={styles.detailCard}>
|
||||||
|
<div className={styles.cardHeader}>
|
||||||
|
<h2>{taskData.name}</h2>
|
||||||
|
<Badge
|
||||||
|
color={getStatusColor(taskData.status)}
|
||||||
|
text={getStatusText(taskData.status)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Descriptions column={1} bordered>
|
||||||
|
<Descriptions.Item label="任务名称">{taskData.name}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="任务状态">
|
||||||
|
<Badge
|
||||||
|
color={getStatusColor(taskData.status)}
|
||||||
|
text={getStatusText(taskData.status)}
|
||||||
|
/>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="时间间隔">
|
||||||
|
<ClockCircleOutlined /> {config.interval || 0} 分钟
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="创建时间">
|
||||||
|
{taskData.createTime || "暂无"}
|
||||||
|
</Descriptions.Item>
|
||||||
|
{taskData.updateTime && (
|
||||||
|
<Descriptions.Item label="更新时间">
|
||||||
|
{taskData.updateTime}
|
||||||
|
</Descriptions.Item>
|
||||||
|
)}
|
||||||
|
</Descriptions>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className={styles.detailCard} title={<><TeamOutlined /> 目标群组</>}>
|
||||||
|
<div className={styles.groupList}>
|
||||||
|
{config.wechatGroupsOptions && config.wechatGroupsOptions.length > 0 ? (
|
||||||
|
config.wechatGroupsOptions.map((group: any) => (
|
||||||
|
<div key={group.id} className={styles.groupItem}>
|
||||||
|
{group.groupAvatar && (
|
||||||
|
<img
|
||||||
|
src={group.groupAvatar}
|
||||||
|
alt={group.groupName || "群组头像"}
|
||||||
|
className={styles.groupAvatar}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className={styles.groupInfo}>
|
||||||
|
<div className={styles.groupName}>{group.groupName || `群组 ${group.id}`}</div>
|
||||||
|
{group.nickName && (
|
||||||
|
<div className={styles.groupOwner}>归属:{group.nickName}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div style={{ color: "#999" }}>
|
||||||
|
已选择 {config.wechatGroups?.length || 0} 个群组
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className={styles.detailCard} title={<><RobotOutlined /> 机器人</>}>
|
||||||
|
<div className={styles.robotList}>
|
||||||
|
{config.deviceGroupsOptions && config.deviceGroupsOptions.length > 0 ? (
|
||||||
|
config.deviceGroupsOptions.map((robot: any) => (
|
||||||
|
<Tag key={robot.id} color="green" style={{ marginBottom: 8 }}>
|
||||||
|
{robot.memo || robot.wechatId || robot.nickname || `设备 ${robot.id}`}
|
||||||
|
</Tag>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div style={{ color: "#999" }}>
|
||||||
|
已选择 {config.deviceGroups?.length || 0} 个机器人
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className={styles.detailCard} title={<><MessageOutlined /> 欢迎消息</>}>
|
||||||
|
<div className={styles.messageList}>
|
||||||
|
{config.messages && config.messages.length > 0 ? (
|
||||||
|
config.messages.map((message: any, index: number) => (
|
||||||
|
<div key={message.id || index} className={styles.messageItem}>
|
||||||
|
<div className={styles.messageHeader}>
|
||||||
|
<Tag color="purple">消息 {message.order || index + 1}</Tag>
|
||||||
|
<Tag>{getMessageTypeText(message.type)}</Tag>
|
||||||
|
</div>
|
||||||
|
<div className={styles.messageContent}>
|
||||||
|
{message.type === "text" ? (
|
||||||
|
<div
|
||||||
|
className={styles.textContent}
|
||||||
|
style={{ whiteSpace: "pre-wrap" }}
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: (message.content || "")
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/\n/g, "<br>")
|
||||||
|
.replace(/@\{好友\}/g, '<span style="color: #1677ff; font-weight: 600; background: #e6f7ff; padding: 2px 4px; border-radius: 3px;">@好友</span>')
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : message.type === "image" ? (
|
||||||
|
<img
|
||||||
|
src={message.content}
|
||||||
|
alt="图片"
|
||||||
|
style={{ maxWidth: "100%", borderRadius: 8 }}
|
||||||
|
/>
|
||||||
|
) : message.type === "video" ? (
|
||||||
|
<video
|
||||||
|
src={message.content}
|
||||||
|
controls
|
||||||
|
style={{ maxWidth: "100%", borderRadius: 8 }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className={styles.fileContent}>
|
||||||
|
<a href={message.content} target="_blank" rel="noopener noreferrer">
|
||||||
|
查看文件
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div style={{ color: "#999", textAlign: "center", padding: "20px 0" }}>
|
||||||
|
暂无欢迎消息
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GroupWelcomeDetail;
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import React, {
|
||||||
|
useImperativeHandle,
|
||||||
|
forwardRef,
|
||||||
|
useState,
|
||||||
|
useEffect,
|
||||||
|
} from "react";
|
||||||
|
import { Input, Form, Card, Switch, InputNumber, Radio } from "antd";
|
||||||
|
|
||||||
|
interface BasicSettingsProps {
|
||||||
|
defaultValues?: {
|
||||||
|
name: string;
|
||||||
|
status: number; // 0: 否, 1: 是
|
||||||
|
interval: number; // 时间间隔(分钟)
|
||||||
|
pushType?: number; // 0: 定时推送, 1: 立即推送
|
||||||
|
startTime?: string; // 允许推送的开始时间
|
||||||
|
endTime?: string; // 允许推送的结束时间
|
||||||
|
};
|
||||||
|
onNext: (values: any) => void;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BasicSettingsRef {
|
||||||
|
validate: () => Promise<boolean>;
|
||||||
|
getValues: () => any;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BasicSettings = forwardRef<BasicSettingsRef, BasicSettingsProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
defaultValues = {
|
||||||
|
name: "",
|
||||||
|
status: 1, // 默认开启
|
||||||
|
interval: 1, // 默认1分钟
|
||||||
|
pushType: 0, // 默认定时推送
|
||||||
|
startTime: "09:00", // 默认开始时间
|
||||||
|
endTime: "21:00", // 默认结束时间
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) => {
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (defaultValues) {
|
||||||
|
form.setFieldsValue(defaultValues);
|
||||||
|
}
|
||||||
|
}, [defaultValues, form]);
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
validate: async () => {
|
||||||
|
try {
|
||||||
|
await form.validateFields();
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.log("BasicSettings 表单验证失败:", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getValues: () => {
|
||||||
|
return form.getFieldsValue();
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
initialValues={defaultValues}
|
||||||
|
>
|
||||||
|
<div style={{ marginBottom: 20 }}>
|
||||||
|
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600 }}>
|
||||||
|
基础设置
|
||||||
|
</h2>
|
||||||
|
<p style={{ margin: "8px 0 0 0", color: "#666", fontSize: 14 }}>
|
||||||
|
配置任务的基本信息
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="name"
|
||||||
|
label="任务名称"
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: "请输入任务名称" },
|
||||||
|
{ max: 50, message: "任务名称不能超过50个字符" },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input placeholder="请输入任务名称" size="large" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="pushType"
|
||||||
|
label="推送类型"
|
||||||
|
rules={[{ required: true, message: "请选择推送类型" }]}
|
||||||
|
>
|
||||||
|
<Radio.Group>
|
||||||
|
<Radio value={0}>定时推送</Radio>
|
||||||
|
<Radio value={1}>立即推送</Radio>
|
||||||
|
</Radio.Group>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
{/* 允许推送的时间段 - 只在定时推送时显示 */}
|
||||||
|
<Form.Item
|
||||||
|
noStyle
|
||||||
|
shouldUpdate={(prevValues, currentValues) =>
|
||||||
|
prevValues.pushType !== currentValues.pushType
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{({ getFieldValue }) => {
|
||||||
|
// 只在pushType为0(定时推送)时显示时间段设置
|
||||||
|
return getFieldValue("pushType") === 0 ? (
|
||||||
|
<Form.Item label="允许推送的时间段">
|
||||||
|
<div
|
||||||
|
style={{ display: "flex", gap: 8, alignItems: "center" }}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
name="startTime"
|
||||||
|
noStyle
|
||||||
|
rules={[{ required: true, message: "请选择开始时间" }]}
|
||||||
|
>
|
||||||
|
<Input type="time" style={{ width: 120 }} size="large" />
|
||||||
|
</Form.Item>
|
||||||
|
<span style={{ color: "#888" }}>至</span>
|
||||||
|
<Form.Item
|
||||||
|
name="endTime"
|
||||||
|
noStyle
|
||||||
|
rules={[{ required: true, message: "请选择结束时间" }]}
|
||||||
|
>
|
||||||
|
<Input type="time" style={{ width: 120 }} size="large" />
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
</Form.Item>
|
||||||
|
) : null;
|
||||||
|
}}
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="interval"
|
||||||
|
label="时间间隔(分钟)"
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: "请输入时间间隔" },
|
||||||
|
{ type: "number", min: 1, message: "时间间隔至少为1分钟" },
|
||||||
|
{ type: "number", max: 1440, message: "时间间隔不能超过1440分钟(24小时)" },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<InputNumber
|
||||||
|
placeholder="请输入时间间隔"
|
||||||
|
min={1}
|
||||||
|
max={1440}
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
size="large"
|
||||||
|
addonAfter="分钟"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="status"
|
||||||
|
label="启用状态"
|
||||||
|
valuePropName="checked"
|
||||||
|
getValueFromEvent={(checked) => (checked ? 1 : 0)}
|
||||||
|
getValueProps={(value) => ({ checked: value === 1 })}
|
||||||
|
>
|
||||||
|
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
BasicSettings.displayName = "BasicSettings";
|
||||||
|
|
||||||
|
export default BasicSettings;
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import React, { useImperativeHandle, forwardRef } from "react";
|
||||||
|
import { Form, Card } from "antd";
|
||||||
|
import GroupSelection from "@/components/GroupSelection";
|
||||||
|
import { GroupSelectionItem } from "@/components/GroupSelection/data";
|
||||||
|
|
||||||
|
interface GroupSelectorProps {
|
||||||
|
selectedGroups: GroupSelectionItem[];
|
||||||
|
onPrevious: () => void;
|
||||||
|
onNext: (data: {
|
||||||
|
groups: string[];
|
||||||
|
groupsOptions: GroupSelectionItem[];
|
||||||
|
}) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GroupSelectorRef {
|
||||||
|
validate: () => Promise<boolean>;
|
||||||
|
getValues: () => any;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GroupSelector = forwardRef<GroupSelectorRef, GroupSelectorProps>(
|
||||||
|
({ selectedGroups, onNext }, ref) => {
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
validate: async () => {
|
||||||
|
try {
|
||||||
|
form.setFieldsValue({
|
||||||
|
groups: selectedGroups.map(item => String(item.id)),
|
||||||
|
});
|
||||||
|
await form.validateFields(["groups"]);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.log("GroupSelector 表单验证失败:", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getValues: () => {
|
||||||
|
return form.getFieldsValue();
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const handleGroupSelect = (groupsOptions: GroupSelectionItem[]) => {
|
||||||
|
const groups = groupsOptions.map(item => String(item.id));
|
||||||
|
form.setFieldValue("groups", groups);
|
||||||
|
onNext({ groups, groupsOptions });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
initialValues={{ groups: selectedGroups }}
|
||||||
|
>
|
||||||
|
<div style={{ marginBottom: 20 }}>
|
||||||
|
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600 }}>
|
||||||
|
选择群组
|
||||||
|
</h2>
|
||||||
|
<p style={{ margin: "8px 0 0 0", color: "#666", fontSize: 14 }}>
|
||||||
|
请选择需要设置欢迎语的群组(可多选)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="groups"
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
type: "array",
|
||||||
|
min: 1,
|
||||||
|
message: "请至少选择一个群组",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<GroupSelection
|
||||||
|
selectedOptions={selectedGroups}
|
||||||
|
onSelect={handleGroupSelect}
|
||||||
|
placeholder="选择群组"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
GroupSelector.displayName = "GroupSelector";
|
||||||
|
|
||||||
|
export default GroupSelector;
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
.messageList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageCard {
|
||||||
|
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;
|
||||||
|
border: 1.5px solid #f0f3fa;
|
||||||
|
transition:
|
||||||
|
box-shadow 0.2s,
|
||||||
|
border 0.2s,
|
||||||
|
transform 0.2s;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&: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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageHeader {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageHeaderContent {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageTypeBtns {
|
||||||
|
display: flex;
|
||||||
|
gap: 5px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageTypeBtn {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageContent {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.removeBtn {
|
||||||
|
color: #ff4d4f;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 0 8px;
|
||||||
|
transition: color 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #d9363e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.addMessageButtons {
|
||||||
|
margin-top: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addMessageBtn {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.richTextInput {
|
||||||
|
white-space: pre-wrap; // 保留换行和空格
|
||||||
|
word-wrap: break-word; // 允许长单词换行
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: #1677ff;
|
||||||
|
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:empty:before {
|
||||||
|
content: attr(data-placeholder);
|
||||||
|
color: #bfbfbf;
|
||||||
|
}
|
||||||
|
|
||||||
|
// @好友样式
|
||||||
|
:global(.mention-friend) {
|
||||||
|
color: #1677ff !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
background: #e6f7ff !important;
|
||||||
|
padding: 2px 4px !important;
|
||||||
|
border-radius: 3px !important;
|
||||||
|
display: inline !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.addMessageButtons {
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.addMessageBtn {
|
||||||
|
width: 100%;
|
||||||
|
min-width: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageTypeBtns {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,865 @@
|
|||||||
|
import React, { useImperativeHandle, forwardRef, useState, useRef, useEffect } from "react";
|
||||||
|
import { Form, Card, Button, Input } from "antd";
|
||||||
|
import { PlusOutlined, CloseOutlined, ClockCircleOutlined, UserAddOutlined } from "@ant-design/icons";
|
||||||
|
import {
|
||||||
|
MessageOutlined,
|
||||||
|
PictureOutlined,
|
||||||
|
VideoCameraOutlined,
|
||||||
|
FileOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
|
import { WelcomeMessage } from "../index.data";
|
||||||
|
import ImageUpload from "@/components/Upload/ImageUpload/ImageUpload";
|
||||||
|
import VideoUpload from "@/components/Upload/VideoUpload";
|
||||||
|
import FileUpload from "@/components/Upload/FileUpload";
|
||||||
|
import styles from "./MessageConfig.module.scss";
|
||||||
|
|
||||||
|
const { TextArea } = Input;
|
||||||
|
|
||||||
|
// 富文本编辑器组件
|
||||||
|
interface RichTextEditorProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (text: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
maxLength?: number;
|
||||||
|
onInsertMention?: React.MutableRefObject<{ insertMention: () => void } | null>; // 插入@好友的ref
|
||||||
|
}
|
||||||
|
|
||||||
|
const RichTextEditor: React.FC<RichTextEditorProps> = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder = "",
|
||||||
|
maxLength = 500,
|
||||||
|
onInsertMention,
|
||||||
|
}) => {
|
||||||
|
const editorRef = useRef<HTMLDivElement>(null);
|
||||||
|
const isComposingRef = useRef(false);
|
||||||
|
|
||||||
|
// 暴露插入@好友的方法
|
||||||
|
useEffect(() => {
|
||||||
|
if (onInsertMention && editorRef.current) {
|
||||||
|
onInsertMention.current = {
|
||||||
|
insertMention: () => {
|
||||||
|
if (!editorRef.current) return;
|
||||||
|
|
||||||
|
// 获取当前文本(包含换行符)
|
||||||
|
const currentText = getText(editorRef.current.innerHTML);
|
||||||
|
|
||||||
|
// 检查是否已经存在@好友,如果存在则不允许再插入
|
||||||
|
if (currentText.includes('@{好友}')) {
|
||||||
|
// 已经存在@好友,不允许再插入
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 先保存当前光标位置
|
||||||
|
const saved = saveSelection();
|
||||||
|
const mentionPlaceholder = "@{好友}";
|
||||||
|
|
||||||
|
// 根据光标位置插入@好友
|
||||||
|
let newContent: string;
|
||||||
|
if (!saved) {
|
||||||
|
// 如果没有光标位置,插入到末尾
|
||||||
|
if (!currentText) {
|
||||||
|
newContent = mentionPlaceholder;
|
||||||
|
} else if (currentText.endsWith("\n") || currentText.endsWith(" ")) {
|
||||||
|
newContent = currentText + mentionPlaceholder;
|
||||||
|
} else {
|
||||||
|
newContent = currentText + " " + mentionPlaceholder;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 在光标位置插入@好友
|
||||||
|
const cursorPos = saved.startOffset;
|
||||||
|
const beforeText = currentText.substring(0, cursorPos);
|
||||||
|
const afterText = currentText.substring(cursorPos);
|
||||||
|
|
||||||
|
// 判断光标前是否需要添加空格
|
||||||
|
const needsSpace = beforeText.length > 0
|
||||||
|
&& !beforeText.endsWith(" ")
|
||||||
|
&& !beforeText.endsWith("\n")
|
||||||
|
&& afterText.length > 0
|
||||||
|
&& !afterText.startsWith(" ");
|
||||||
|
|
||||||
|
if (needsSpace) {
|
||||||
|
newContent = beforeText + " " + mentionPlaceholder + afterText;
|
||||||
|
} else {
|
||||||
|
newContent = beforeText + mentionPlaceholder + afterText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 直接更新编辑器内容
|
||||||
|
const formatted = formatContent(newContent);
|
||||||
|
editorRef.current.innerHTML = formatted;
|
||||||
|
|
||||||
|
// 恢复光标位置到插入的@好友后面
|
||||||
|
// 使用双重 requestAnimationFrame 确保 DOM 完全更新
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (editorRef.current) {
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (!selection) return;
|
||||||
|
|
||||||
|
// 计算新插入的@好友在文本中的位置
|
||||||
|
let newCursorPos: number;
|
||||||
|
if (!saved) {
|
||||||
|
// 如果没有保存的光标位置,放在末尾
|
||||||
|
newCursorPos = newContent.length;
|
||||||
|
} else {
|
||||||
|
// 计算插入@好友后的光标位置
|
||||||
|
const cursorPos = saved.startOffset;
|
||||||
|
const beforeText = currentText.substring(0, cursorPos);
|
||||||
|
const needsSpace = beforeText.length > 0
|
||||||
|
&& !beforeText.endsWith(" ")
|
||||||
|
&& !beforeText.endsWith("\n")
|
||||||
|
&& currentText.substring(cursorPos).length > 0
|
||||||
|
&& !currentText.substring(cursorPos).startsWith(" ");
|
||||||
|
|
||||||
|
// @好友的位置 = 原光标位置 + (如果需要空格则+1)
|
||||||
|
const mentionPos = cursorPos + (needsSpace ? 1 : 0);
|
||||||
|
// 光标位置 = @好友位置 + @好友长度
|
||||||
|
newCursorPos = mentionPos + mentionPlaceholder.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用文本位置恢复光标
|
||||||
|
restoreSelection({ startOffset: newCursorPos, endOffset: newCursorPos });
|
||||||
|
|
||||||
|
// 确保编辑器获得焦点
|
||||||
|
editorRef.current.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 更新value
|
||||||
|
onChange(newContent);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [onInsertMention, onChange]);
|
||||||
|
|
||||||
|
// 格式化内容,只将系统插入的@{好友}高亮,手动输入的@好友不高亮
|
||||||
|
const formatContent = (text: string) => {
|
||||||
|
if (!text) return "";
|
||||||
|
// 先转义HTML,但保留@{好友}格式用于替换
|
||||||
|
// 将@{好友}替换为特殊标记,避免被转义
|
||||||
|
const parts = text.split(/(@\{好友\})/g);
|
||||||
|
return parts.map((part, index) => {
|
||||||
|
if (part === '@{好友}') {
|
||||||
|
// 在span后面添加零宽空格,确保可以继续输入
|
||||||
|
return '<span class="mention-friend">@好友</span>\u200B';
|
||||||
|
}
|
||||||
|
// 转义其他部分,保留换行符
|
||||||
|
return part
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/\n/g, "<br>"); // 保留换行符
|
||||||
|
}).join('');
|
||||||
|
};
|
||||||
|
|
||||||
|
// 提取纯文本(将高亮的@好友还原为@{好友}格式)
|
||||||
|
const getText = (html: string) => {
|
||||||
|
// 先处理HTML中的mention-friend元素
|
||||||
|
const tempDiv = document.createElement("div");
|
||||||
|
tempDiv.innerHTML = html;
|
||||||
|
|
||||||
|
// 将所有的mention-friend元素替换为@{好友}格式
|
||||||
|
const mentions = tempDiv.querySelectorAll('.mention-friend');
|
||||||
|
mentions.forEach((mention) => {
|
||||||
|
const replacement = document.createTextNode('@{好友}');
|
||||||
|
mention.parentNode?.replaceChild(replacement, mention);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 将块级元素转换为换行符:在块级元素前后添加换行
|
||||||
|
const blockElements = Array.from(tempDiv.querySelectorAll('div, p, h1, h2, h3, h4, h5, h6'));
|
||||||
|
blockElements.forEach((block) => {
|
||||||
|
// 在块级元素前添加换行符
|
||||||
|
if (block.previousSibling) {
|
||||||
|
const textNode = document.createTextNode('\n');
|
||||||
|
block.parentNode?.insertBefore(textNode, block);
|
||||||
|
}
|
||||||
|
// 在块级元素后添加换行符
|
||||||
|
if (block.nextSibling) {
|
||||||
|
const textNode = document.createTextNode('\n');
|
||||||
|
block.parentNode?.insertBefore(textNode, block.nextSibling);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 手动遍历所有节点,提取文本和<br>标签
|
||||||
|
const walker = document.createTreeWalker(
|
||||||
|
tempDiv,
|
||||||
|
NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
const textParts: string[] = [];
|
||||||
|
let node: Node | null;
|
||||||
|
|
||||||
|
while ((node = walker.nextNode())) {
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
|
const text = node.textContent || '';
|
||||||
|
if (text) {
|
||||||
|
textParts.push(text);
|
||||||
|
}
|
||||||
|
} else if (node.nodeName === 'BR') {
|
||||||
|
textParts.push('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有找到任何内容,使用 textContent 作为后备
|
||||||
|
let text = textParts.length > 0
|
||||||
|
? textParts.join('')
|
||||||
|
: (tempDiv.textContent || "");
|
||||||
|
|
||||||
|
// 移除零宽空格
|
||||||
|
text = text.replace(/\u200B/g, '');
|
||||||
|
|
||||||
|
return text;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 保存和恢复光标位置
|
||||||
|
const saveSelection = () => {
|
||||||
|
if (!editorRef.current) return null;
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (!selection || selection.rangeCount === 0) return null;
|
||||||
|
|
||||||
|
const range = selection.getRangeAt(0);
|
||||||
|
// 检查range是否在editor内部
|
||||||
|
if (!editorRef.current.contains(range.commonAncestorContainer)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取当前文本内容(包含换行符)
|
||||||
|
const currentText = getText(editorRef.current.innerHTML);
|
||||||
|
|
||||||
|
// 创建一个临时范围来计算光标前的文本长度
|
||||||
|
const preCaretRange = range.cloneRange();
|
||||||
|
preCaretRange.selectNodeContents(editorRef.current);
|
||||||
|
preCaretRange.setEnd(range.endContainer, range.endOffset);
|
||||||
|
|
||||||
|
// 使用getText函数获取文本(包含换行符),然后计算长度
|
||||||
|
// 创建一个临时div来保存当前HTML
|
||||||
|
const tempDiv = document.createElement('div');
|
||||||
|
tempDiv.innerHTML = editorRef.current.innerHTML;
|
||||||
|
|
||||||
|
// 克隆光标前的内容
|
||||||
|
const clonedRange = preCaretRange.cloneContents();
|
||||||
|
const beforeDiv = document.createElement('div');
|
||||||
|
beforeDiv.appendChild(clonedRange);
|
||||||
|
|
||||||
|
// 获取光标前的文本(包含换行符)
|
||||||
|
// 需要处理.mention-friend元素,将其转换为@{好友}
|
||||||
|
const beforeText = getText(beforeDiv.innerHTML);
|
||||||
|
const startOffset = beforeText.length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
startOffset,
|
||||||
|
endOffset: startOffset + (range.toString().length),
|
||||||
|
currentText, // 保存当前文本,用于后续计算
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const restoreSelection = (saved: { startOffset: number; endOffset: number } | null) => {
|
||||||
|
if (!saved || !editorRef.current) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (!selection) return;
|
||||||
|
|
||||||
|
// 获取当前文本内容(包含换行符)
|
||||||
|
const currentText = getText(editorRef.current.innerHTML);
|
||||||
|
const targetOffset = Math.min(saved.startOffset, currentText.length);
|
||||||
|
|
||||||
|
const range = document.createRange();
|
||||||
|
let charCount = 0;
|
||||||
|
let found = false;
|
||||||
|
|
||||||
|
// 遍历所有节点,包括文本节点、<br>元素和.mention-friend元素
|
||||||
|
const walker = document.createTreeWalker(
|
||||||
|
editorRef.current,
|
||||||
|
NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
let node: Node | null;
|
||||||
|
while ((node = walker.nextNode()) && !found) {
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
|
// 跳过零宽空格
|
||||||
|
const text = node.textContent || '';
|
||||||
|
const nodeLength = text.replace(/\u200B/g, '').length;
|
||||||
|
if (charCount + nodeLength >= targetOffset) {
|
||||||
|
const offset = targetOffset - charCount;
|
||||||
|
// 计算实际偏移量(考虑零宽空格)
|
||||||
|
let actualOffset = 0;
|
||||||
|
let charIndex = 0;
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
if (text[i] !== '\u200B') {
|
||||||
|
if (charIndex >= offset) break;
|
||||||
|
charIndex++;
|
||||||
|
}
|
||||||
|
actualOffset++;
|
||||||
|
}
|
||||||
|
range.setStart(node, Math.max(0, Math.min(actualOffset, text.length)));
|
||||||
|
range.setEnd(node, Math.max(0, Math.min(actualOffset, text.length)));
|
||||||
|
found = true;
|
||||||
|
}
|
||||||
|
charCount += nodeLength;
|
||||||
|
} else if (node.nodeName === 'BR') {
|
||||||
|
if (charCount >= targetOffset) {
|
||||||
|
// 光标应该在<br>之前
|
||||||
|
range.setStartBefore(node);
|
||||||
|
range.setEndBefore(node);
|
||||||
|
found = true;
|
||||||
|
} else if (charCount + 1 >= targetOffset) {
|
||||||
|
// 光标应该在<br>之后
|
||||||
|
range.setStartAfter(node);
|
||||||
|
range.setEndAfter(node);
|
||||||
|
found = true;
|
||||||
|
}
|
||||||
|
charCount += 1;
|
||||||
|
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
||||||
|
// 处理.mention-friend元素,它代表@{好友},长度为4
|
||||||
|
const element = node as Element;
|
||||||
|
if (element.classList.contains('mention-friend')) {
|
||||||
|
const mentionLength = 4; // @{好友}的长度
|
||||||
|
if (charCount + mentionLength >= targetOffset) {
|
||||||
|
// 光标应该在mention-friend之后(零宽空格后面)
|
||||||
|
// 查找或创建零宽空格
|
||||||
|
let zwspNode: Node | null = null;
|
||||||
|
const nextSibling = node.nextSibling;
|
||||||
|
|
||||||
|
if (nextSibling && nextSibling.nodeType === Node.TEXT_NODE && nextSibling.textContent === '\u200B') {
|
||||||
|
zwspNode = nextSibling;
|
||||||
|
} else {
|
||||||
|
// 如果没有零宽空格,创建一个
|
||||||
|
zwspNode = document.createTextNode('\u200B');
|
||||||
|
node.parentNode?.insertBefore(zwspNode, nextSibling);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将光标放在零宽空格后面
|
||||||
|
if (zwspNode) {
|
||||||
|
range.setStartAfter(zwspNode);
|
||||||
|
range.setEndAfter(zwspNode);
|
||||||
|
} else {
|
||||||
|
range.setStartAfter(node);
|
||||||
|
range.setEndAfter(node);
|
||||||
|
}
|
||||||
|
found = true;
|
||||||
|
}
|
||||||
|
charCount += mentionLength;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (found) {
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
} else {
|
||||||
|
// 如果没找到,放在末尾
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(editorRef.current);
|
||||||
|
range.collapse(false);
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// 如果恢复失败,将光标放在末尾
|
||||||
|
try {
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (selection && editorRef.current) {
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(editorRef.current);
|
||||||
|
range.collapse(false);
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// 忽略错误
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 更新内容(只在外部value变化时更新,不干扰用户输入)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editorRef.current || isComposingRef.current) return;
|
||||||
|
const currentText = getText(editorRef.current.innerHTML);
|
||||||
|
// 只在外部value变化且与当前内容不同时更新
|
||||||
|
if (currentText !== value) {
|
||||||
|
const saved = saveSelection();
|
||||||
|
const formatted = formatContent(value) || "";
|
||||||
|
if (editorRef.current.innerHTML !== formatted) {
|
||||||
|
editorRef.current.innerHTML = formatted;
|
||||||
|
// 延迟恢复光标,确保DOM已更新
|
||||||
|
setTimeout(() => {
|
||||||
|
restoreSelection(saved);
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
const handleInput = (e: React.FormEvent<HTMLDivElement>) => {
|
||||||
|
if (isComposingRef.current) return;
|
||||||
|
|
||||||
|
const text = getText(e.currentTarget.innerHTML);
|
||||||
|
if (text.length <= maxLength) {
|
||||||
|
// 先更新文本内容
|
||||||
|
onChange(text);
|
||||||
|
|
||||||
|
// 不在输入时立即格式化,只在失去焦点时格式化
|
||||||
|
// 这样可以避免干扰用户输入
|
||||||
|
} else {
|
||||||
|
// 超出长度,恢复之前的内容
|
||||||
|
const saved = saveSelection();
|
||||||
|
e.currentTarget.innerHTML = formatContent(value);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
restoreSelection(saved);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
ref={editorRef}
|
||||||
|
contentEditable
|
||||||
|
suppressContentEditableWarning
|
||||||
|
onInput={handleInput}
|
||||||
|
onCompositionStart={() => {
|
||||||
|
isComposingRef.current = true;
|
||||||
|
}}
|
||||||
|
onCompositionEnd={(e) => {
|
||||||
|
isComposingRef.current = false;
|
||||||
|
handleInput(e);
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
const text = getText(e.currentTarget.innerHTML);
|
||||||
|
onChange(text);
|
||||||
|
// 失去焦点时格式化,确保@好友高亮显示
|
||||||
|
if (text.includes('@{好友}')) {
|
||||||
|
const formatted = formatContent(text);
|
||||||
|
if (e.currentTarget.innerHTML !== formatted) {
|
||||||
|
e.currentTarget.innerHTML = formatted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
data-placeholder={placeholder}
|
||||||
|
style={{
|
||||||
|
minHeight: "80px",
|
||||||
|
maxHeight: "200px",
|
||||||
|
padding: "8px 12px",
|
||||||
|
border: "1px solid #d9d9d9",
|
||||||
|
borderRadius: "6px",
|
||||||
|
fontSize: "14px",
|
||||||
|
lineHeight: "1.5",
|
||||||
|
outline: "none",
|
||||||
|
overflowY: "auto",
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
wordBreak: "break-word",
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
}}
|
||||||
|
className={styles.richTextInput}
|
||||||
|
/>
|
||||||
|
<div style={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 8,
|
||||||
|
right: 12,
|
||||||
|
fontSize: 12,
|
||||||
|
color: "#999",
|
||||||
|
pointerEvents: "none",
|
||||||
|
background: "rgba(255, 255, 255, 0.8)",
|
||||||
|
padding: "0 4px"
|
||||||
|
}}>
|
||||||
|
{value.length}/{maxLength}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 消息类型配置
|
||||||
|
const messageTypes = [
|
||||||
|
{ id: "text", icon: MessageOutlined, label: "文本" },
|
||||||
|
{ id: "image", icon: PictureOutlined, label: "图片" },
|
||||||
|
{ id: "video", icon: VideoCameraOutlined, label: "视频" },
|
||||||
|
{ id: "file", icon: FileOutlined, label: "文件" },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface MessageConfigProps {
|
||||||
|
defaultMessages?: WelcomeMessage[];
|
||||||
|
onPrevious: () => void;
|
||||||
|
onNext: (data: { messages: WelcomeMessage[] }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessageConfigRef {
|
||||||
|
validate: () => Promise<boolean>;
|
||||||
|
getValues: () => any;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MessageConfig = forwardRef<MessageConfigRef, MessageConfigProps>(
|
||||||
|
({ defaultMessages = [], onNext }, ref) => {
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [messages, setMessages] = useState<WelcomeMessage[]>(
|
||||||
|
defaultMessages.length > 0
|
||||||
|
? defaultMessages
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
id: Date.now().toString(),
|
||||||
|
type: "text",
|
||||||
|
content: "",
|
||||||
|
order: 1,
|
||||||
|
sendInterval: 5,
|
||||||
|
intervalUnit: "seconds",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
validate: async () => {
|
||||||
|
try {
|
||||||
|
// 验证至少有一条消息
|
||||||
|
if (messages.length === 0) {
|
||||||
|
form.setFields([
|
||||||
|
{
|
||||||
|
name: "messages",
|
||||||
|
errors: ["请至少配置一条欢迎消息"],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证每条消息都有内容
|
||||||
|
for (const msg of messages) {
|
||||||
|
if (!msg.content) {
|
||||||
|
form.setFields([
|
||||||
|
{
|
||||||
|
name: "messages",
|
||||||
|
errors: ["请填写所有消息的内容,消息内容不能为空"],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// 移除@{好友}格式标记和空白字符后检查是否有实际内容
|
||||||
|
const contentWithoutMention = msg.content
|
||||||
|
.replace(/@\{好友\}/g, "")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
if (contentWithoutMention === "") {
|
||||||
|
form.setFields([
|
||||||
|
{
|
||||||
|
name: "messages",
|
||||||
|
errors: ["请填写所有消息的内容,消息内容不能为空"],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
form.setFieldsValue({ messages });
|
||||||
|
await form.validateFields(["messages"]);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.log("MessageConfig 表单验证失败:", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getValues: () => {
|
||||||
|
return { messages };
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 添加消息
|
||||||
|
const handleAddMessage = (type: WelcomeMessage["type"] = "text") => {
|
||||||
|
const newMessage: WelcomeMessage = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
type,
|
||||||
|
content: "",
|
||||||
|
order: messages.length + 1,
|
||||||
|
sendInterval: 5,
|
||||||
|
intervalUnit: "seconds",
|
||||||
|
};
|
||||||
|
setMessages([...messages, newMessage]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 删除消息
|
||||||
|
const handleRemoveMessage = (id: string) => {
|
||||||
|
const newMessages = messages
|
||||||
|
.filter(msg => msg.id !== id)
|
||||||
|
.map((msg, index) => ({ ...msg, order: index + 1 }));
|
||||||
|
setMessages(newMessages);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 更新消息
|
||||||
|
const handleUpdateMessage = (id: string, updates: Partial<WelcomeMessage>) => {
|
||||||
|
setMessages(
|
||||||
|
messages.map(msg => (msg.id === id ? { ...msg, ...updates } : msg)),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 切换时间单位
|
||||||
|
const toggleIntervalUnit = (id: string) => {
|
||||||
|
const message = messages.find(msg => msg.id === id);
|
||||||
|
if (!message) return;
|
||||||
|
const newUnit = message.intervalUnit === "minutes" ? "seconds" : "minutes";
|
||||||
|
handleUpdateMessage(id, { intervalUnit: newUnit });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 存储每个消息的编辑器ref
|
||||||
|
const editorRefs = useRef<Record<string, React.MutableRefObject<{ insertMention: () => void } | null>>>({});
|
||||||
|
|
||||||
|
// 插入@好友占位符(使用特殊格式,只有系统插入的才会高亮)
|
||||||
|
const handleInsertFriendMention = (messageId: string) => {
|
||||||
|
const editorRef = editorRefs.current[messageId];
|
||||||
|
if (editorRef?.current) {
|
||||||
|
editorRef.current.insertMention();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 将纯文本转换为带样式的HTML(用于富文本显示)
|
||||||
|
const formatContentWithMentions = (content: string) => {
|
||||||
|
if (!content) return "";
|
||||||
|
// 转义HTML特殊字符
|
||||||
|
const escaped = content
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">");
|
||||||
|
// 只将@{好友}格式替换为带样式的span,手动输入的@好友不会被高亮
|
||||||
|
return escaped.replace(
|
||||||
|
/@\{好友\}/g,
|
||||||
|
'<span class="mention-friend">@好友</span>'
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 从富文本中提取纯文本
|
||||||
|
const extractTextFromHtml = (html: string) => {
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.innerHTML = html;
|
||||||
|
return div.textContent || div.innerText || "";
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
<div style={{ marginBottom: 20 }}>
|
||||||
|
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600 }}>
|
||||||
|
配置欢迎消息
|
||||||
|
</h2>
|
||||||
|
<p style={{ margin: "8px 0 0 0", color: "#666", fontSize: 14 }}>
|
||||||
|
配置多条欢迎消息,新成员入群时将按顺序发送
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="messages"
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
validator: () => {
|
||||||
|
if (messages.length === 0) {
|
||||||
|
return Promise.reject("请至少配置一条欢迎消息");
|
||||||
|
}
|
||||||
|
const hasEmptyContent = messages.some((msg) => {
|
||||||
|
if (!msg.content) return true;
|
||||||
|
// 移除@{好友}格式标记和空白字符后检查是否有实际内容
|
||||||
|
const contentWithoutMention = msg.content
|
||||||
|
.replace(/@\{好友\}/g, "")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
return contentWithoutMention === "";
|
||||||
|
});
|
||||||
|
if (hasEmptyContent) {
|
||||||
|
return Promise.reject("请填写所有消息的内容,消息内容不能为空");
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<div className={styles.messageList}>
|
||||||
|
{messages.map((message, index) => (
|
||||||
|
<div key={message.id} className={styles.messageCard}>
|
||||||
|
<div className={styles.messageHeader}>
|
||||||
|
{/* 时间间隔设置 */}
|
||||||
|
<div className={styles.messageHeaderContent}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
|
<span style={{ minWidth: 36 }}>间隔</span>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={String(message.sendInterval || 5)}
|
||||||
|
onChange={e =>
|
||||||
|
handleUpdateMessage(message.id, {
|
||||||
|
sendInterval: Number(e.target.value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
style={{ width: 60 }}
|
||||||
|
min={1}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
onClick={() => toggleIntervalUnit(message.id)}
|
||||||
|
>
|
||||||
|
<ClockCircleOutlined />
|
||||||
|
{message.intervalUnit === "minutes" ? "分钟" : "秒"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className={styles.removeBtn}
|
||||||
|
onClick={() => handleRemoveMessage(message.id)}
|
||||||
|
title="删除"
|
||||||
|
>
|
||||||
|
<CloseOutlined />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/* 类型切换按钮 */}
|
||||||
|
<div className={styles.messageTypeBtns}>
|
||||||
|
{messageTypes.map(type => (
|
||||||
|
<Button
|
||||||
|
key={type.id}
|
||||||
|
type={message.type === type.id ? "primary" : "default"}
|
||||||
|
onClick={() =>
|
||||||
|
handleUpdateMessage(message.id, {
|
||||||
|
type: type.id as any,
|
||||||
|
content: "", // 切换类型时清空内容
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className={styles.messageTypeBtn}
|
||||||
|
title={type.label}
|
||||||
|
>
|
||||||
|
<type.icon />
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.messageContent}>
|
||||||
|
{/* 文本消息 */}
|
||||||
|
{message.type === "text" && (
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: 8, display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||||
|
<span style={{ fontSize: 14, color: "#666" }}>消息内容</span>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
icon={<UserAddOutlined />}
|
||||||
|
onClick={() => handleInsertFriendMention(message.id)}
|
||||||
|
disabled={message.content?.includes('@{好友}')}
|
||||||
|
style={{ padding: 0, height: "auto", color: message.content?.includes('@{好友}') ? "#ccc" : "#1677ff" }}
|
||||||
|
>
|
||||||
|
@好友
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div style={{ position: "relative" }}>
|
||||||
|
{/* 富文本输入框 */}
|
||||||
|
<RichTextEditor
|
||||||
|
value={message.content || ""}
|
||||||
|
onChange={(text) => {
|
||||||
|
if (text.length <= 500) {
|
||||||
|
handleUpdateMessage(message.id, {
|
||||||
|
content: text,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="请输入欢迎消息内容,点击@好友按钮可插入@好友占位符"
|
||||||
|
maxLength={500}
|
||||||
|
onInsertMention={(() => {
|
||||||
|
if (!editorRefs.current[message.id]) {
|
||||||
|
editorRefs.current[message.id] = React.createRef();
|
||||||
|
}
|
||||||
|
return editorRefs.current[message.id];
|
||||||
|
})()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 8, fontSize: 12, color: "#999" }}>
|
||||||
|
提示:@好友为占位符,系统会根据实际情况自动@相应好友
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 图片消息 */}
|
||||||
|
{message.type === "image" && (
|
||||||
|
<ImageUpload
|
||||||
|
value={message.content ? [message.content] : []}
|
||||||
|
onChange={(urls) =>
|
||||||
|
handleUpdateMessage(message.id, {
|
||||||
|
content: urls && urls.length > 0 ? urls[0] : "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
count={1}
|
||||||
|
accept="image/*"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 视频消息 */}
|
||||||
|
{message.type === "video" && (
|
||||||
|
<VideoUpload
|
||||||
|
value={message.content || ""}
|
||||||
|
onChange={(url) =>
|
||||||
|
handleUpdateMessage(message.id, {
|
||||||
|
content: typeof url === "string" ? url : (Array.isArray(url) && url.length > 0 ? url[0] : ""),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
maxSize={50}
|
||||||
|
maxCount={1}
|
||||||
|
showPreview={true}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 文件消息 */}
|
||||||
|
{message.type === "file" && (
|
||||||
|
<FileUpload
|
||||||
|
value={message.content || ""}
|
||||||
|
onChange={(url) =>
|
||||||
|
handleUpdateMessage(message.id, {
|
||||||
|
content: typeof url === "string" ? url : (Array.isArray(url) && url.length > 0 ? url[0] : ""),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
maxSize={10}
|
||||||
|
maxCount={1}
|
||||||
|
showPreview={true}
|
||||||
|
acceptTypes={["excel", "word", "ppt"]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<div className={styles.addMessageButtons}>
|
||||||
|
<Button
|
||||||
|
type="dashed"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={() => handleAddMessage("text")}
|
||||||
|
className={styles.addMessageBtn}
|
||||||
|
>
|
||||||
|
添加文本消息
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="dashed"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={() => handleAddMessage("image")}
|
||||||
|
className={styles.addMessageBtn}
|
||||||
|
>
|
||||||
|
添加图片消息
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="dashed"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={() => handleAddMessage("video")}
|
||||||
|
className={styles.addMessageBtn}
|
||||||
|
>
|
||||||
|
添加视频消息
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="dashed"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={() => handleAddMessage("file")}
|
||||||
|
className={styles.addMessageBtn}
|
||||||
|
>
|
||||||
|
添加文件消息
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
MessageConfig.displayName = "MessageConfig";
|
||||||
|
|
||||||
|
export default MessageConfig;
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import React, { useImperativeHandle, forwardRef } from "react";
|
||||||
|
import { Form, Card } from "antd";
|
||||||
|
import DeviceSelection from "@/components/DeviceSelection";
|
||||||
|
import { DeviceSelectionItem } from "@/components/DeviceSelection/data";
|
||||||
|
|
||||||
|
interface RobotSelectorProps {
|
||||||
|
selectedRobots: DeviceSelectionItem[];
|
||||||
|
onPrevious: () => void;
|
||||||
|
onNext: (data: {
|
||||||
|
robots: string[];
|
||||||
|
robotsOptions: DeviceSelectionItem[];
|
||||||
|
}) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RobotSelectorRef {
|
||||||
|
validate: () => Promise<boolean>;
|
||||||
|
getValues: () => any;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RobotSelector = forwardRef<RobotSelectorRef, RobotSelectorProps>(
|
||||||
|
({ selectedRobots, onNext }, ref) => {
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
validate: async () => {
|
||||||
|
try {
|
||||||
|
form.setFieldsValue({
|
||||||
|
robots: selectedRobots.map(item => String(item.id)),
|
||||||
|
});
|
||||||
|
await form.validateFields(["robots"]);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.log("RobotSelector 表单验证失败:", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getValues: () => {
|
||||||
|
return form.getFieldsValue();
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const handleRobotSelect = (robotsOptions: DeviceSelectionItem[]) => {
|
||||||
|
const robots = robotsOptions.map(item => String(item.id));
|
||||||
|
form.setFieldValue("robots", robots);
|
||||||
|
onNext({ robots, robotsOptions });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
initialValues={{ robots: selectedRobots }}
|
||||||
|
>
|
||||||
|
<div style={{ marginBottom: 20 }}>
|
||||||
|
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600 }}>
|
||||||
|
选择机器人
|
||||||
|
</h2>
|
||||||
|
<p style={{ margin: "8px 0 0 0", color: "#666", fontSize: 14 }}>
|
||||||
|
请选择用于发送欢迎消息的机器人
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="robots"
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
type: "array",
|
||||||
|
min: 1,
|
||||||
|
message: "请至少选择一个机器人",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<DeviceSelection
|
||||||
|
selectedOptions={selectedRobots}
|
||||||
|
onSelect={handleRobotSelect}
|
||||||
|
placeholder="选择机器人"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
RobotSelector.displayName = "RobotSelector";
|
||||||
|
|
||||||
|
export default RobotSelector;
|
||||||
16
src/pages/mobile/workspace/group-welcome/form/index.api.ts
Normal file
16
src/pages/mobile/workspace/group-welcome/form/index.api.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import request from "@/api/request";
|
||||||
|
|
||||||
|
// 创建入群欢迎语任务
|
||||||
|
export function createGroupWelcomeTask(data: any) {
|
||||||
|
return request("/v1/workbench/create", data, "POST");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新入群欢迎语任务
|
||||||
|
export function updateGroupWelcomeTask(data: any) {
|
||||||
|
return request("/v1/workbench/update", data, "POST");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取入群欢迎语任务详情
|
||||||
|
export function fetchGroupWelcomeTaskDetail(id: string) {
|
||||||
|
return request("/v1/workbench/detail", { id }, "GET");
|
||||||
|
}
|
||||||
27
src/pages/mobile/workspace/group-welcome/form/index.data.ts
Normal file
27
src/pages/mobile/workspace/group-welcome/form/index.data.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { GroupSelectionItem } from "@/components/GroupSelection/data";
|
||||||
|
import { DeviceSelectionItem } from "@/components/DeviceSelection/data";
|
||||||
|
|
||||||
|
// 欢迎消息类型
|
||||||
|
export interface WelcomeMessage {
|
||||||
|
id: string;
|
||||||
|
type: "text" | "image" | "video" | "file";
|
||||||
|
content: string;
|
||||||
|
order: number; // 消息顺序
|
||||||
|
sendInterval?: number; // 发送间隔
|
||||||
|
intervalUnit?: "seconds" | "minutes"; // 间隔单位
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FormData {
|
||||||
|
name: string;
|
||||||
|
status: number; // 0: 否, 1: 是(开关)
|
||||||
|
interval: number; // 时间间隔(分钟)
|
||||||
|
pushType?: number; // 0: 定时推送, 1: 立即推送
|
||||||
|
startTime?: string; // 允许推送的开始时间
|
||||||
|
endTime?: string; // 允许推送的结束时间
|
||||||
|
groups: string[]; // 群组ID列表
|
||||||
|
groupsOptions: GroupSelectionItem[]; // 群组选项列表
|
||||||
|
robots: string[]; // 机器人(设备)ID列表
|
||||||
|
robotsOptions: DeviceSelectionItem[]; // 机器人选项列表
|
||||||
|
messages: WelcomeMessage[]; // 欢迎消息列表
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
366
src/pages/mobile/workspace/group-welcome/form/index.tsx
Normal file
366
src/pages/mobile/workspace/group-welcome/form/index.tsx
Normal file
@@ -0,0 +1,366 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { Button } from "antd";
|
||||||
|
import { Toast } from "antd-mobile";
|
||||||
|
import {
|
||||||
|
createGroupWelcomeTask,
|
||||||
|
fetchGroupWelcomeTaskDetail,
|
||||||
|
updateGroupWelcomeTask,
|
||||||
|
} from "./index.api";
|
||||||
|
import Layout from "@/components/Layout/Layout";
|
||||||
|
import StepIndicator from "@/components/StepIndicator";
|
||||||
|
import BasicSettings, { BasicSettingsRef } from "./components/BasicSettings";
|
||||||
|
import GroupSelector, { GroupSelectorRef } from "./components/GroupSelector";
|
||||||
|
import RobotSelector, { RobotSelectorRef } from "./components/RobotSelector";
|
||||||
|
import MessageConfig, { MessageConfigRef } from "./components/MessageConfig";
|
||||||
|
import type { FormData, WelcomeMessage } from "./index.data";
|
||||||
|
import NavCommon from "@/components/NavCommon";
|
||||||
|
import { GroupSelectionItem } from "@/components/GroupSelection/data";
|
||||||
|
import { DeviceSelectionItem } from "@/components/DeviceSelection/data";
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
{ id: 1, title: "步骤 1", subtitle: "基础设置" },
|
||||||
|
{ id: 2, title: "步骤 2", subtitle: "选择机器人" },
|
||||||
|
{ id: 3, title: "步骤 3", subtitle: "选择群组" },
|
||||||
|
{ id: 4, title: "步骤 4", subtitle: "配置消息" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const NewGroupWelcome: React.FC = () => {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [currentStep, setCurrentStep] = useState(1);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const [groupsOptions, setGroupsOptions] = useState<GroupSelectionItem[]>([]);
|
||||||
|
const [robotsOptions, setRobotsOptions] = useState<DeviceSelectionItem[]>([]);
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState<FormData>({
|
||||||
|
name: "",
|
||||||
|
status: 1,
|
||||||
|
interval: 1, // 默认1分钟
|
||||||
|
pushType: 0, // 默认定时推送
|
||||||
|
startTime: "09:00",
|
||||||
|
endTime: "21:00",
|
||||||
|
groups: [],
|
||||||
|
groupsOptions: [],
|
||||||
|
robots: [],
|
||||||
|
robotsOptions: [],
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: Date.now().toString(),
|
||||||
|
type: "text",
|
||||||
|
content: "",
|
||||||
|
order: 1,
|
||||||
|
sendInterval: 5,
|
||||||
|
intervalUnit: "seconds",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const [isEditMode, setIsEditMode] = useState(false);
|
||||||
|
|
||||||
|
// 创建子组件的ref
|
||||||
|
const basicSettingsRef = useRef<BasicSettingsRef>(null);
|
||||||
|
const groupSelectorRef = useRef<GroupSelectorRef>(null);
|
||||||
|
const robotSelectorRef = useRef<RobotSelectorRef>(null);
|
||||||
|
const messageConfigRef = useRef<MessageConfigRef>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id) return;
|
||||||
|
setIsEditMode(true);
|
||||||
|
// 加载编辑数据
|
||||||
|
const loadEditData = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetchGroupWelcomeTaskDetail(id);
|
||||||
|
const data = res?.data || res;
|
||||||
|
const config = data?.config || {};
|
||||||
|
|
||||||
|
// 回填表单数据
|
||||||
|
// 处理 groups:可能是字符串数组或字符串
|
||||||
|
let groupsArray: any[] = [];
|
||||||
|
if (config.wechatGroups && Array.isArray(config.wechatGroups)) {
|
||||||
|
groupsArray = config.wechatGroups;
|
||||||
|
} else if (config.groups) {
|
||||||
|
if (Array.isArray(config.groups)) {
|
||||||
|
groupsArray = config.groups;
|
||||||
|
} else if (typeof config.groups === 'string') {
|
||||||
|
try {
|
||||||
|
groupsArray = JSON.parse(config.groups);
|
||||||
|
} catch {
|
||||||
|
groupsArray = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理 robots:可能是字符串数组或字符串
|
||||||
|
let robotsArray: any[] = [];
|
||||||
|
if (config.deviceGroups && Array.isArray(config.deviceGroups)) {
|
||||||
|
robotsArray = config.deviceGroups;
|
||||||
|
} else if (config.robots || config.devices) {
|
||||||
|
const robotsData = config.robots || config.devices;
|
||||||
|
if (Array.isArray(robotsData)) {
|
||||||
|
robotsArray = robotsData;
|
||||||
|
} else if (typeof robotsData === 'string') {
|
||||||
|
try {
|
||||||
|
robotsArray = JSON.parse(robotsData);
|
||||||
|
} catch {
|
||||||
|
robotsArray = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
name: data.name || "",
|
||||||
|
status: data.status ?? config.status ?? 1,
|
||||||
|
interval: config.interval || 1, // 默认1分钟
|
||||||
|
pushType: config.pushType ?? 0,
|
||||||
|
startTime: config.startTime || "09:00",
|
||||||
|
endTime: config.endTime || "21:00",
|
||||||
|
groups: groupsArray.map((id: any) => String(id)),
|
||||||
|
robots: robotsArray.map((id: any) => String(id)),
|
||||||
|
messages: config.messages || [
|
||||||
|
{
|
||||||
|
id: Date.now().toString(),
|
||||||
|
type: "text",
|
||||||
|
content: "",
|
||||||
|
order: 1,
|
||||||
|
sendInterval: 5,
|
||||||
|
intervalUnit: "seconds",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 回填选项数据
|
||||||
|
// 映射群组选项字段:groupAvatar -> avatar, groupName -> name
|
||||||
|
if (config.wechatGroupsOptions) {
|
||||||
|
const mappedGroups = config.wechatGroupsOptions.map((group: any) => ({
|
||||||
|
...group,
|
||||||
|
avatar: group.groupAvatar || group.avatar,
|
||||||
|
name: group.groupName || group.name,
|
||||||
|
ownerNickname: group.nickName || group.ownerNickname,
|
||||||
|
}));
|
||||||
|
setGroupsOptions(mappedGroups);
|
||||||
|
}
|
||||||
|
if (config.deviceGroupsOptions) {
|
||||||
|
setRobotsOptions(config.deviceGroupsOptions);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("加载编辑数据失败:", error);
|
||||||
|
Toast.show({ content: "加载数据失败", position: "top" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadEditData();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const handleBasicSettingsChange = (values: Partial<FormData>) => {
|
||||||
|
setFormData(prev => ({ ...prev, ...values }));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 群组选择
|
||||||
|
const handleGroupsChange = (data: {
|
||||||
|
groups: string[];
|
||||||
|
groupsOptions: GroupSelectionItem[];
|
||||||
|
}) => {
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
groups: data.groups,
|
||||||
|
groupsOptions: data.groupsOptions,
|
||||||
|
}));
|
||||||
|
setGroupsOptions(data.groupsOptions);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 机器人选择
|
||||||
|
const handleRobotsChange = (data: {
|
||||||
|
robots: string[];
|
||||||
|
robotsOptions: DeviceSelectionItem[];
|
||||||
|
}) => {
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
robots: data.robots,
|
||||||
|
robotsOptions: data.robotsOptions,
|
||||||
|
}));
|
||||||
|
setRobotsOptions(data.robotsOptions);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 消息配置
|
||||||
|
const handleMessagesChange = (data: { messages: WelcomeMessage[] }) => {
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
messages: data.messages,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
try {
|
||||||
|
// 调用 MessageConfig 的表单校验
|
||||||
|
const isValid = (await messageConfigRef.current?.validate()) || false;
|
||||||
|
if (!isValid) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
// 获取基础设置中的值
|
||||||
|
const basicSettingsValues = basicSettingsRef.current?.getValues() || {};
|
||||||
|
const messageConfigValues = messageConfigRef.current?.getValues() || {};
|
||||||
|
|
||||||
|
// 构建 API 请求数据
|
||||||
|
const apiData: any = {
|
||||||
|
name: basicSettingsValues.name || formData.name,
|
||||||
|
type: 7, // 入群欢迎语工作台类型固定为7
|
||||||
|
status: basicSettingsValues.status ?? formData.status,
|
||||||
|
interval: basicSettingsValues.interval || formData.interval,
|
||||||
|
pushType: basicSettingsValues.pushType ?? formData.pushType ?? 0,
|
||||||
|
startTime: basicSettingsValues.startTime || formData.startTime || "09:00",
|
||||||
|
endTime: basicSettingsValues.endTime || formData.endTime || "21:00",
|
||||||
|
wechatGroups: formData.groups.map(id => Number(id)), // 使用 wechatGroups
|
||||||
|
deviceGroups: formData.robots.map(id => Number(id)), // 使用 deviceGroups
|
||||||
|
messages: messageConfigValues.messages || formData.messages,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 更新时需要传递id
|
||||||
|
if (id) {
|
||||||
|
apiData.id = Number(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用创建或更新 API
|
||||||
|
if (id) {
|
||||||
|
await updateGroupWelcomeTask(apiData);
|
||||||
|
Toast.show({ content: "更新成功", position: "top" });
|
||||||
|
navigate("/workspace/group-welcome");
|
||||||
|
} else {
|
||||||
|
await createGroupWelcomeTask(apiData);
|
||||||
|
Toast.show({ content: "创建成功", position: "top" });
|
||||||
|
navigate("/workspace/group-welcome");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Toast.show({ content: "保存失败,请稍后重试", position: "top" });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrevious = () => {
|
||||||
|
if (currentStep > 1) {
|
||||||
|
setCurrentStep(currentStep - 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNext = async () => {
|
||||||
|
if (currentStep < 4) {
|
||||||
|
try {
|
||||||
|
let isValid = false;
|
||||||
|
|
||||||
|
switch (currentStep) {
|
||||||
|
case 1:
|
||||||
|
// 调用 BasicSettings 的表单校验
|
||||||
|
isValid = (await basicSettingsRef.current?.validate()) || false;
|
||||||
|
if (isValid) {
|
||||||
|
const values = basicSettingsRef.current?.getValues();
|
||||||
|
if (values) {
|
||||||
|
handleBasicSettingsChange(values);
|
||||||
|
}
|
||||||
|
setCurrentStep(2);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 2:
|
||||||
|
// 调用 RobotSelector 的表单校验
|
||||||
|
isValid = (await robotSelectorRef.current?.validate()) || false;
|
||||||
|
if (isValid) {
|
||||||
|
setCurrentStep(3);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 3:
|
||||||
|
// 调用 GroupSelector 的表单校验
|
||||||
|
isValid = (await groupSelectorRef.current?.validate()) || false;
|
||||||
|
if (isValid) {
|
||||||
|
setCurrentStep(4);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
setCurrentStep(currentStep + 1);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log("表单验证失败:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderFooter = () => {
|
||||||
|
return (
|
||||||
|
<div className="footer-btn-group">
|
||||||
|
{currentStep > 1 && (
|
||||||
|
<Button size="large" onClick={handlePrevious}>
|
||||||
|
上一步
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{currentStep === 4 ? (
|
||||||
|
<Button size="large" type="primary" onClick={handleSave} loading={loading}>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button size="large" type="primary" onClick={handleNext}>
|
||||||
|
下一步
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout
|
||||||
|
header={<NavCommon title={isEditMode ? "编辑任务" : "新建任务"} />}
|
||||||
|
footer={renderFooter()}
|
||||||
|
>
|
||||||
|
<div style={{ padding: 12 }}>
|
||||||
|
<div style={{ marginBottom: 12 }}>
|
||||||
|
<StepIndicator currentStep={currentStep} steps={steps} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{currentStep === 1 && (
|
||||||
|
<BasicSettings
|
||||||
|
ref={basicSettingsRef}
|
||||||
|
defaultValues={{
|
||||||
|
name: formData.name,
|
||||||
|
status: formData.status,
|
||||||
|
interval: formData.interval,
|
||||||
|
pushType: formData.pushType,
|
||||||
|
startTime: formData.startTime,
|
||||||
|
endTime: formData.endTime,
|
||||||
|
}}
|
||||||
|
onNext={handleBasicSettingsChange}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{currentStep === 2 && (
|
||||||
|
<RobotSelector
|
||||||
|
ref={robotSelectorRef}
|
||||||
|
selectedRobots={robotsOptions}
|
||||||
|
onPrevious={() => setCurrentStep(1)}
|
||||||
|
onNext={handleRobotsChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{currentStep === 3 && (
|
||||||
|
<GroupSelector
|
||||||
|
ref={groupSelectorRef}
|
||||||
|
selectedGroups={groupsOptions}
|
||||||
|
onPrevious={() => setCurrentStep(2)}
|
||||||
|
onNext={handleGroupsChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{currentStep === 4 && (
|
||||||
|
<MessageConfig
|
||||||
|
ref={messageConfigRef}
|
||||||
|
defaultMessages={formData.messages}
|
||||||
|
onPrevious={() => setCurrentStep(3)}
|
||||||
|
onNext={handleMessagesChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NewGroupWelcome;
|
||||||
27
src/pages/mobile/workspace/group-welcome/list/index.api.ts
Normal file
27
src/pages/mobile/workspace/group-welcome/list/index.api.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import request from "@/api/request";
|
||||||
|
|
||||||
|
interface ApiResponse<T = any> {
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
data: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取入群欢迎语任务列表
|
||||||
|
export async function fetchGroupWelcomeTasks() {
|
||||||
|
return request("/v1/workbench/list", { type: 7 }, "GET");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除入群欢迎语任务
|
||||||
|
export async function deleteGroupWelcomeTask(id: string): Promise<ApiResponse> {
|
||||||
|
return request("/v1/workbench/delete", { id }, "DELETE");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换任务状态
|
||||||
|
export function toggleGroupWelcomeTask(data: { id: string; status: number }): Promise<any> {
|
||||||
|
return request("/v1/workbench/update-status", { ...data, type: 7 }, "POST");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 复制任务
|
||||||
|
export async function copyGroupWelcomeTask(id: string): Promise<ApiResponse> {
|
||||||
|
return request("/v1/workbench/copy", { id }, "POST");
|
||||||
|
}
|
||||||
154
src/pages/mobile/workspace/group-welcome/list/index.module.scss
Normal file
154
src/pages/mobile/workspace/group-welcome/list/index.module.scss
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
.nav-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.searchBar {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-btn {
|
||||||
|
// 只针对当前模块的refresh-btn按钮进行样式设置
|
||||||
|
&.ant-btn {
|
||||||
|
height: 38px !important;
|
||||||
|
width: 40px !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
border-radius: 8px !important;
|
||||||
|
min-width: 40px !important;
|
||||||
|
flex-shrink: 0 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg {
|
||||||
|
padding-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 0 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 {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
|
||||||
|
> div {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CardMenu 样式
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.taskCard {
|
||||||
|
padding: 12px 6px 8px 6px;
|
||||||
|
}
|
||||||
|
.taskInfoGrid {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
280
src/pages/mobile/workspace/group-welcome/list/index.tsx
Normal file
280
src/pages/mobile/workspace/group-welcome/list/index.tsx
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
TeamOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
SearchOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
MoreOutlined,
|
||||||
|
ClockCircleOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
CopyOutlined,
|
||||||
|
MessageOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
|
import { Card, Button, Input, Badge, Switch } from "antd";
|
||||||
|
import Layout from "@/components/Layout/Layout";
|
||||||
|
import NavCommon from "@/components/NavCommon";
|
||||||
|
import {
|
||||||
|
fetchGroupWelcomeTasks,
|
||||||
|
deleteGroupWelcomeTask,
|
||||||
|
toggleGroupWelcomeTask,
|
||||||
|
copyGroupWelcomeTask,
|
||||||
|
} from "./index.api";
|
||||||
|
import styles from "./index.module.scss";
|
||||||
|
|
||||||
|
// 卡片菜单组件
|
||||||
|
interface CardMenuProps {
|
||||||
|
onView: () => void;
|
||||||
|
onEdit: () => void;
|
||||||
|
onCopy: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CardMenu: React.FC<CardMenuProps> = ({ 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={styles["menu-btn"]}>
|
||||||
|
<MoreOutlined />
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div ref={menuRef} className={styles["menu-dropdown"]}>
|
||||||
|
<div
|
||||||
|
onClick={() => {
|
||||||
|
onEdit();
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
className={styles["menu-item"]}
|
||||||
|
>
|
||||||
|
<EditOutlined />
|
||||||
|
编辑
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
onClick={() => {
|
||||||
|
onCopy();
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
className={styles["menu-item"]}
|
||||||
|
>
|
||||||
|
<CopyOutlined />
|
||||||
|
复制
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
onClick={() => {
|
||||||
|
onDelete();
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
className={`${styles["menu-item"]} ${styles["danger"]}`}
|
||||||
|
>
|
||||||
|
<DeleteOutlined />
|
||||||
|
删除
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const GroupWelcome: React.FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const [tasks, setTasks] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const fetchTasks = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await fetchGroupWelcomeTasks();
|
||||||
|
setTasks(result.list || result || []);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTasks();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDelete = async (taskId: string) => {
|
||||||
|
if (!window.confirm("确定要删除该任务吗?")) return;
|
||||||
|
await deleteGroupWelcomeTask(taskId);
|
||||||
|
fetchTasks();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (taskId: string) => {
|
||||||
|
navigate(`/workspace/group-welcome/edit/${taskId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleView = (taskId: string) => {
|
||||||
|
navigate(`/workspace/group-welcome/${taskId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopy = async (taskId: string) => {
|
||||||
|
await copyGroupWelcomeTask(taskId);
|
||||||
|
fetchTasks();
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleTaskStatus = async (taskId: string) => {
|
||||||
|
const task = tasks.find(t => t.id === taskId);
|
||||||
|
if (!task) return;
|
||||||
|
const newStatus = task.status === 1 ? 2 : 1;
|
||||||
|
await toggleGroupWelcomeTask({ id: taskId, status: newStatus });
|
||||||
|
fetchTasks();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateNew = () => {
|
||||||
|
navigate("/workspace/group-welcome/new");
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredTasks = tasks.filter(task =>
|
||||||
|
task.name?.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||||
|
);
|
||||||
|
|
||||||
|
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 "未知";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout
|
||||||
|
loading={loading}
|
||||||
|
header={
|
||||||
|
<>
|
||||||
|
<NavCommon
|
||||||
|
title="入群欢迎语"
|
||||||
|
backFn={() => navigate("/workspace")}
|
||||||
|
right={
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={handleCreateNew}
|
||||||
|
>
|
||||||
|
创建任务
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className={styles.searchBar}>
|
||||||
|
<Input
|
||||||
|
placeholder="搜索任务名称"
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={e => setSearchTerm(e.target.value)}
|
||||||
|
prefix={<SearchOutlined />}
|
||||||
|
allowClear
|
||||||
|
size="large"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={fetchTasks}
|
||||||
|
size="large"
|
||||||
|
className={styles["refresh-btn"]}
|
||||||
|
>
|
||||||
|
<ReloadOutlined />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={styles.bg}>
|
||||||
|
<div className={styles.taskList}>
|
||||||
|
{filteredTasks.length === 0 ? (
|
||||||
|
<Card className={styles.emptyCard}>
|
||||||
|
<MessageOutlined
|
||||||
|
style={{ fontSize: 48, color: "#ccc", marginBottom: 12 }}
|
||||||
|
/>
|
||||||
|
<div style={{ color: "#888", fontSize: 16, marginBottom: 8 }}>
|
||||||
|
暂无欢迎语任务
|
||||||
|
</div>
|
||||||
|
<div style={{ color: "#bbb", fontSize: 13, marginBottom: 16 }}>
|
||||||
|
创建您的第一个入群欢迎语任务
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={handleCreateNew}
|
||||||
|
>
|
||||||
|
创建第一个任务
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
filteredTasks.map(task => (
|
||||||
|
<Card key={task.id} 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 className={styles.taskActions}>
|
||||||
|
<Switch
|
||||||
|
checked={task.status === 1}
|
||||||
|
onChange={() => toggleTaskStatus(task.id)}
|
||||||
|
/>
|
||||||
|
<CardMenu
|
||||||
|
onView={() => handleView(task.id)}
|
||||||
|
onEdit={() => handleEdit(task.id)}
|
||||||
|
onCopy={() => handleCopy(task.id)}
|
||||||
|
onDelete={() => handleDelete(task.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.taskInfoGrid}>
|
||||||
|
<div>
|
||||||
|
<TeamOutlined />
|
||||||
|
目标群组:{task.config?.wechatGroups?.length || 0} 个群
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<MessageOutlined /> 欢迎消息:
|
||||||
|
{task.config?.messages?.length || 0} 条
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.taskFooter}>
|
||||||
|
<div>
|
||||||
|
<ClockCircleOutlined /> 时间间隔:
|
||||||
|
{task.config?.interval || 0} 分钟
|
||||||
|
</div>
|
||||||
|
<div>创建时间:{task.createTime || "暂无"}</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GroupWelcome;
|
||||||
@@ -60,6 +60,7 @@ interface CommonFunction {
|
|||||||
path: string;
|
path: string;
|
||||||
bgColor?: string;
|
bgColor?: string;
|
||||||
isNew?: boolean;
|
isNew?: boolean;
|
||||||
|
isPlanType?: number; // 是否支持计划类型配置:1-支持,0-不支持
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,8 +74,10 @@ const Workspace: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const res = await getCommonFunctions();
|
const res = await getCommonFunctions();
|
||||||
|
// 兼容不同返回结构:优先使用 data.list,其次 list,最后整体当作数组
|
||||||
|
const list = res?.data?.list || res?.list || res?.data || res || [];
|
||||||
// 处理API返回的数据,映射图标和样式
|
// 处理API返回的数据,映射图标和样式
|
||||||
const features = (res?.list || res || []).map((item: any) => {
|
const features = (list as any[]).map((item: any) => {
|
||||||
const config = featureConfig[item.key];
|
const config = featureConfig[item.key];
|
||||||
|
|
||||||
// icon是远程图片URL,渲染为img标签
|
// icon是远程图片URL,渲染为img标签
|
||||||
@@ -91,7 +94,7 @@ const Workspace: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
return {
|
const feature = {
|
||||||
id: item.id,
|
id: item.id,
|
||||||
key: item.key,
|
key: item.key,
|
||||||
name: item.title || item.name || "",
|
name: item.title || item.name || "",
|
||||||
@@ -101,7 +104,11 @@ const Workspace: React.FC = () => {
|
|||||||
path: item.route || item.path || (config?.path) || `/workspace/${item.key?.replace(/_/g, '-')}`,
|
path: item.route || item.path || (config?.path) || `/workspace/${item.key?.replace(/_/g, '-')}`,
|
||||||
bgColor: item.iconColor || (config?.bgColor) || undefined, // iconColor可以为空
|
bgColor: item.iconColor || (config?.bgColor) || undefined, // iconColor可以为空
|
||||||
isNew: item.isNew || item.is_new || false,
|
isNew: item.isNew || item.is_new || false,
|
||||||
|
isPlanType: item.isPlanType ?? 0, // 保存 isPlanType,用于传递给子页面
|
||||||
};
|
};
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log("[Workspace] feature loaded:", feature.key, "isPlanType =", feature.isPlanType);
|
||||||
|
return feature;
|
||||||
});
|
});
|
||||||
setCommonFeatures(features);
|
setCommonFeatures(features);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -152,6 +159,8 @@ const Workspace: React.FC = () => {
|
|||||||
commonFeatures.map(feature => (
|
commonFeatures.map(feature => (
|
||||||
<Link
|
<Link
|
||||||
to={feature.path}
|
to={feature.path}
|
||||||
|
// 将 isPlanType 透传到对应页面,便于调试和控制计划类型
|
||||||
|
state={{ isPlanType: feature.isPlanType ?? 0 }}
|
||||||
key={feature.key || feature.id}
|
key={feature.key || feature.id}
|
||||||
className={styles.featureLink}
|
className={styles.featureLink}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -21,6 +21,49 @@
|
|||||||
padding: 0 16px;
|
padding: 0 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.infoBox {
|
||||||
|
background: #eff6ff;
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #1e40af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionDot {
|
||||||
|
width: 4px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitleIndependent {
|
||||||
|
.sectionDot {
|
||||||
|
background: #fb923c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskListGroup {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.taskCard {
|
.taskCard {
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ interface MomentsSyncTask {
|
|||||||
createTime: string;
|
createTime: string;
|
||||||
creatorName: string;
|
creatorName: string;
|
||||||
contentLib?: string;
|
contentLib?: string;
|
||||||
|
// 计划类型:0-全局计划,1-独立计划
|
||||||
|
planType?: number;
|
||||||
config?: {
|
config?: {
|
||||||
devices?: string[];
|
devices?: string[];
|
||||||
contentGroups: number[];
|
contentGroups: number[];
|
||||||
@@ -64,7 +66,12 @@ const MomentsSync: React.FC = () => {
|
|||||||
{ type: 2, page: 1, limit: 100 },
|
{ type: 2, page: 1, limit: 100 },
|
||||||
"GET",
|
"GET",
|
||||||
);
|
);
|
||||||
setTasks(res.list || []);
|
const list = (res.list || []) as any[];
|
||||||
|
const normalized: MomentsSyncTask[] = list.map(item => ({
|
||||||
|
...item,
|
||||||
|
planType: item.config?.planType ?? item.planType ?? 1,
|
||||||
|
}));
|
||||||
|
setTasks(normalized);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
message.error("获取任务失败");
|
message.error("获取任务失败");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -118,6 +125,9 @@ const MomentsSync: React.FC = () => {
|
|||||||
task.name.toLowerCase().includes(searchTerm.toLowerCase()),
|
task.name.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const globalTasks = filteredTasks.filter(t => t.planType === 0);
|
||||||
|
const independentTasks = filteredTasks.filter(t => t.planType !== 0);
|
||||||
|
|
||||||
// 菜单
|
// 菜单
|
||||||
const getMenu = (task: MomentsSyncTask) => (
|
const getMenu = (task: MomentsSyncTask) => (
|
||||||
<Menu>
|
<Menu>
|
||||||
@@ -153,64 +163,7 @@ const MomentsSync: React.FC = () => {
|
|||||||
</Menu>
|
</Menu>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
const renderTaskCard = (task: MomentsSyncTask) => (
|
||||||
<Layout
|
|
||||||
header={
|
|
||||||
<>
|
|
||||||
<NavCommon
|
|
||||||
title="朋友圈同步"
|
|
||||||
right={
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
color="primary"
|
|
||||||
onClick={() => navigate("/workspace/moments-sync/new")}
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
loading={loading}
|
|
||||||
>
|
|
||||||
<div className={style.pageBg}>
|
|
||||||
<div className={style.taskList}>
|
|
||||||
{filteredTasks.length === 0 ? (
|
|
||||||
<div className={style.emptyBox}>
|
|
||||||
<span style={{ fontSize: 40, color: "#ddd" }}>
|
|
||||||
<ClockCircleOutlined />
|
|
||||||
</span>
|
|
||||||
<div className={style.emptyText}>暂无同步任务</div>
|
|
||||||
<Button
|
|
||||||
color="primary"
|
|
||||||
onClick={() => navigate("/workspace/moments-sync/new")}
|
|
||||||
>
|
|
||||||
新建第一个任务
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
filteredTasks.map(task => (
|
|
||||||
<div key={task.id} className={style.itemCard}>
|
<div key={task.id} className={style.itemCard}>
|
||||||
<div className={style.itemTop}>
|
<div className={style.itemTop}>
|
||||||
<div className={style.itemTitle}>
|
<div className={style.itemTitle}>
|
||||||
@@ -268,23 +221,115 @@ const MomentsSync: React.FC = () => {
|
|||||||
?.map(c => c.name)
|
?.map(c => c.name)
|
||||||
.join(",") || "默认内容库"}
|
.join(",") || "默认内容库"}
|
||||||
</div>
|
</div>
|
||||||
<div className={style.infoCol}>
|
<div className={style.infoCol}>创建人:{task.creatorName}</div>
|
||||||
创建人:{task.creatorName}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className={style.itemBottom}>
|
<div className={style.itemBottom}>
|
||||||
<div className={style.bottomLeft}>
|
<div className={style.bottomLeft}>
|
||||||
<ClockCircleOutlined className={style.clockIcon} />
|
<ClockCircleOutlined className={style.clockIcon} />
|
||||||
上次同步:{task.lastSyncTime || "无"}
|
上次同步:{task.lastSyncTime || "无"}
|
||||||
</div>
|
</div>
|
||||||
<div className={style.bottomRight}>
|
<div className={style.bottomRight}>创建时间:{task.createTime}</div>
|
||||||
创建时间:{task.createTime}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
let content: React.ReactNode;
|
||||||
|
if (filteredTasks.length === 0) {
|
||||||
|
content = (
|
||||||
|
<div className={style.emptyBox}>
|
||||||
|
<span style={{ fontSize: 40, color: "#ddd" }}>
|
||||||
|
<ClockCircleOutlined />
|
||||||
|
</span>
|
||||||
|
<div className={style.emptyText}>暂无同步任务</div>
|
||||||
|
<Button
|
||||||
|
color="primary"
|
||||||
|
onClick={() => navigate("/workspace/moments-sync/new")}
|
||||||
|
>
|
||||||
|
新建第一个任务
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
content = (
|
||||||
|
<>
|
||||||
|
{globalTasks.length > 0 && (
|
||||||
|
<div className={style.infoBox}>
|
||||||
|
全局同步计划将应用于所有设备(包括新添加的设备),请合理设置同步频率与数量。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{globalTasks.length > 0 && (
|
||||||
|
<section className={style.section}>
|
||||||
|
<h3 className={style.sectionTitle}>
|
||||||
|
<span className={style.sectionDot} />
|
||||||
|
全局朋友圈同步计划
|
||||||
|
</h3>
|
||||||
|
<div className={style.taskListGroup}>
|
||||||
|
{globalTasks.map(renderTaskCard)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{independentTasks.length > 0 && (
|
||||||
|
<section className={style.section}>
|
||||||
|
<h3
|
||||||
|
className={`${style.sectionTitle} ${style.sectionTitleIndependent}`}
|
||||||
|
>
|
||||||
|
<span className={style.sectionDot} />
|
||||||
|
独立朋友圈同步计划
|
||||||
|
</h3>
|
||||||
|
<div className={style.taskListGroup}>
|
||||||
|
{independentTasks.map(renderTaskCard)}
|
||||||
</div>
|
</div>
|
||||||
))
|
</section>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout
|
||||||
|
header={
|
||||||
|
<>
|
||||||
|
<NavCommon
|
||||||
|
title="朋友圈同步"
|
||||||
|
right={
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
onClick={() => navigate("/workspace/moments-sync/new")}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
loading={loading}
|
||||||
|
>
|
||||||
|
<div className={style.pageBg}>
|
||||||
|
<div className={style.taskList}>{content}</div>
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,12 +1,50 @@
|
|||||||
.formBg {
|
.formBg {
|
||||||
|
background: #f8fafc;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding-bottom: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
|
background: #f8fafc;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding-bottom: 100px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: hidden;
|
||||||
|
|
||||||
|
@media (max-width: 375px) {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
@media (max-width: 375px) {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.formStepBtnRow {
|
.formStepBtnRow {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: center;
|
||||||
gap: 12px;
|
gap: 16px;
|
||||||
margin-top: 32px;
|
padding: 16px;
|
||||||
padding: 12px;
|
background: #fff;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 100;
|
||||||
|
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.04);
|
||||||
}
|
}
|
||||||
.formSteps {
|
.formSteps {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -78,10 +116,31 @@
|
|||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.radioGroup {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
.input {
|
.input {
|
||||||
|
width: 100%;
|
||||||
height: 44px;
|
height: 44px;
|
||||||
|
padding: 10px 12px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
background: #f8fafc;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
|
outline: none;
|
||||||
|
transition: all 0.2s;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: #3b82f6;
|
||||||
|
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeRow {
|
.timeRow {
|
||||||
@@ -213,12 +272,6 @@
|
|||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.formStepBtnRow {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 12px;
|
|
||||||
margin-top: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prevBtn {
|
.prevBtn {
|
||||||
height: 44px;
|
height: 44px;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useEffect, useCallback } from "react";
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { Button, Input, Switch, message, Spin } from "antd";
|
import { Button, Input, Switch, message, Spin, Radio } from "antd";
|
||||||
import { MinusOutlined, PlusOutlined } from "@ant-design/icons";
|
import { MinusOutlined, PlusOutlined } from "@ant-design/icons";
|
||||||
|
|
||||||
import Layout from "@/components/Layout/Layout";
|
import Layout from "@/components/Layout/Layout";
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
import DeviceSelection from "@/components/DeviceSelection";
|
import DeviceSelection from "@/components/DeviceSelection";
|
||||||
import ContentSelection from "@/components/ContentSelection";
|
import ContentSelection from "@/components/ContentSelection";
|
||||||
import NavCommon from "@/components/NavCommon";
|
import NavCommon from "@/components/NavCommon";
|
||||||
|
import { useUserStore } from "@/store/module/user";
|
||||||
import { DeviceSelectionItem } from "@/components/DeviceSelection/data";
|
import { DeviceSelectionItem } from "@/components/DeviceSelection/data";
|
||||||
import { ContentItem } from "@/components/ContentSelection/data";
|
import { ContentItem } from "@/components/ContentSelection/data";
|
||||||
|
|
||||||
@@ -38,12 +39,16 @@ const defaultForm = {
|
|||||||
contentTypes: ["text", "image", "video"],
|
contentTypes: ["text", "image", "video"],
|
||||||
targetTags: [] as string[],
|
targetTags: [] as string[],
|
||||||
filterKeywords: [] as string[],
|
filterKeywords: [] as string[],
|
||||||
|
// 计划类型:0-全局计划,1-独立计划
|
||||||
|
planType: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
const NewMomentsSync: React.FC = () => {
|
const NewMomentsSync: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const isEditMode = !!id;
|
const isEditMode = !!id;
|
||||||
|
const { user } = useUserStore();
|
||||||
|
const isAdmin = user?.isAdmin === 1;
|
||||||
const [currentStep, setCurrentStep] = useState(0);
|
const [currentStep, setCurrentStep] = useState(0);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [formData, setFormData] = useState({ ...defaultForm });
|
const [formData, setFormData] = useState({ ...defaultForm });
|
||||||
@@ -76,6 +81,7 @@ const NewMomentsSync: React.FC = () => {
|
|||||||
contentTypes: res.config?.contentTypes || ["text", "image", "video"],
|
contentTypes: res.config?.contentTypes || ["text", "image", "video"],
|
||||||
targetTags: res.config?.targetTags || [],
|
targetTags: res.config?.targetTags || [],
|
||||||
filterKeywords: res.config?.filterKeywords || [],
|
filterKeywords: res.config?.filterKeywords || [],
|
||||||
|
planType: res.config?.planType ?? (res as any).planType ?? 1,
|
||||||
});
|
});
|
||||||
setSelectedDevicesOptions(res.config?.deviceGroupsOptions || []);
|
setSelectedDevicesOptions(res.config?.deviceGroupsOptions || []);
|
||||||
setContentGroupsOptions(res.config?.contentGroupsOptions || []);
|
setContentGroupsOptions(res.config?.contentGroupsOptions || []);
|
||||||
@@ -147,6 +153,7 @@ const NewMomentsSync: React.FC = () => {
|
|||||||
filterKeywords: formData.filterKeywords,
|
filterKeywords: formData.filterKeywords,
|
||||||
type: 2,
|
type: 2,
|
||||||
status: formData.enabled ? 1 : 0,
|
status: formData.enabled ? 1 : 0,
|
||||||
|
planType: (formData as any).planType ?? 1,
|
||||||
};
|
};
|
||||||
if (isEditMode && id) {
|
if (isEditMode && id) {
|
||||||
await updateMomentsSync({ id, ...params });
|
await updateMomentsSync({ id, ...params });
|
||||||
@@ -168,7 +175,22 @@ const NewMomentsSync: React.FC = () => {
|
|||||||
const renderStep = () => {
|
const renderStep = () => {
|
||||||
if (currentStep === 0) {
|
if (currentStep === 0) {
|
||||||
return (
|
return (
|
||||||
<div className={style.formStep}>
|
<div className={style.container}>
|
||||||
|
{/* 计划类型和任务名称 */}
|
||||||
|
<div className={style.card}>
|
||||||
|
{isAdmin && (
|
||||||
|
<div className={style.formItem}>
|
||||||
|
<div className={style.formLabel}>计划类型</div>
|
||||||
|
<Radio.Group
|
||||||
|
value={(formData as any).planType}
|
||||||
|
onChange={e => updateForm({ planType: e.target.value })}
|
||||||
|
className={style.radioGroup}
|
||||||
|
>
|
||||||
|
<Radio value={0}>全局计划</Radio>
|
||||||
|
<Radio value={1}>独立计划</Radio>
|
||||||
|
</Radio.Group>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className={style.formItem}>
|
<div className={style.formItem}>
|
||||||
<div className={style.formLabel}>任务名称</div>
|
<div className={style.formLabel}>任务名称</div>
|
||||||
<Input
|
<Input
|
||||||
@@ -178,8 +200,11 @@ const NewMomentsSync: React.FC = () => {
|
|||||||
maxLength={30}
|
maxLength={30}
|
||||||
className={style.input}
|
className={style.input}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 允许发布时间段 */}
|
||||||
|
<div className={style.card}>
|
||||||
<div className={style.formItem}>
|
<div className={style.formItem}>
|
||||||
<div className={style.formLabel}>允许发布时间段</div>
|
<div className={style.formLabel}>允许发布时间段</div>
|
||||||
<div className={style.timeRow}>
|
<div className={style.timeRow}>
|
||||||
@@ -196,9 +221,12 @@ const NewMomentsSync: React.FC = () => {
|
|||||||
onChange={e => updateForm({ endTime: e.target.value })}
|
onChange={e => updateForm({ endTime: e.target.value })}
|
||||||
className={style.inputTime}
|
className={style.inputTime}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 每日同步数量 */}
|
||||||
|
<div className={style.card}>
|
||||||
<div className={style.formItem}>
|
<div className={style.formItem}>
|
||||||
<div className={style.formLabel}>每日同步数量</div>
|
<div className={style.formLabel}>每日同步数量</div>
|
||||||
<div className={style.counterRow}>
|
<div className={style.counterRow}>
|
||||||
@@ -220,9 +248,12 @@ const NewMomentsSync: React.FC = () => {
|
|||||||
<PlusOutlined />
|
<PlusOutlined />
|
||||||
</button>
|
</button>
|
||||||
<span className={style.counterUnit}>条朋友圈</span>
|
<span className={style.counterUnit}>条朋友圈</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 账号类型和是否启用 */}
|
||||||
|
<div className={style.card}>
|
||||||
<div className={style.formItem}>
|
<div className={style.formItem}>
|
||||||
<div className={style.formLabel}>账号类型</div>
|
<div className={style.formLabel}>账号类型</div>
|
||||||
<div className={style.accountTypeRow}>
|
<div className={style.accountTypeRow}>
|
||||||
@@ -240,7 +271,6 @@ const NewMomentsSync: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={style.formItem}>
|
<div className={style.formItem}>
|
||||||
<div className={style.switchRow}>
|
<div className={style.switchRow}>
|
||||||
<span className={style.switchLabel}>是否启用</span>
|
<span className={style.switchLabel}>是否启用</span>
|
||||||
@@ -249,6 +279,7 @@ const NewMomentsSync: React.FC = () => {
|
|||||||
onChange={checked => updateForm({ enabled: checked })}
|
onChange={checked => updateForm({ enabled: checked })}
|
||||||
className={style.switch}
|
className={style.switch}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -256,7 +287,8 @@ const NewMomentsSync: React.FC = () => {
|
|||||||
}
|
}
|
||||||
if (currentStep === 1) {
|
if (currentStep === 1) {
|
||||||
return (
|
return (
|
||||||
<div className={style.formStep}>
|
<div className={style.container}>
|
||||||
|
<div className={style.card}>
|
||||||
<div className={style.formItem}>
|
<div className={style.formItem}>
|
||||||
<div className={style.formLabel}>选择设备</div>
|
<div className={style.formLabel}>选择设备</div>
|
||||||
<DeviceSelection
|
<DeviceSelection
|
||||||
@@ -266,13 +298,15 @@ const NewMomentsSync: React.FC = () => {
|
|||||||
showSelectedList={true}
|
showSelectedList={true}
|
||||||
selectedListMaxHeight={200}
|
selectedListMaxHeight={200}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (currentStep === 2) {
|
if (currentStep === 2) {
|
||||||
return (
|
return (
|
||||||
<div className={style.formStep}>
|
<div className={style.container}>
|
||||||
|
<div className={style.card}>
|
||||||
<div className={style.formItem}>
|
<div className={style.formItem}>
|
||||||
<div className={style.formLabel}>选择内容库</div>
|
<div className={style.formLabel}>选择内容库</div>
|
||||||
<ContentSelection
|
<ContentSelection
|
||||||
@@ -287,6 +321,7 @@ const NewMomentsSync: React.FC = () => {
|
|||||||
已选内容库: {formData.contentGroups.length}个
|
已选内容库: {formData.contentGroups.length}个
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -315,10 +350,10 @@ const NewMomentsSync: React.FC = () => {
|
|||||||
if (currentStep === 1) {
|
if (currentStep === 1) {
|
||||||
return (
|
return (
|
||||||
<div className={style.formStepBtnRow}>
|
<div className={style.formStepBtnRow}>
|
||||||
<Button onClick={prev} className={style.prevBtn} block>
|
<Button onClick={prev} className={style.prevBtn} style={{ flex: 1 }}>
|
||||||
上一步
|
上一步
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="primary" onClick={next} className={style.nextBtn} block>
|
<Button type="primary" onClick={next} className={style.nextBtn} style={{ flex: 1 }}>
|
||||||
下一步
|
下一步
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -327,7 +362,7 @@ const NewMomentsSync: React.FC = () => {
|
|||||||
if (currentStep === 2) {
|
if (currentStep === 2) {
|
||||||
return (
|
return (
|
||||||
<div className={style.formStepBtnRow}>
|
<div className={style.formStepBtnRow}>
|
||||||
<Button onClick={prev} className={style.prevBtn} block>
|
<Button onClick={prev} className={style.prevBtn} style={{ flex: 1 }}>
|
||||||
上一步
|
上一步
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -335,7 +370,7 @@ const NewMomentsSync: React.FC = () => {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
className={style.completeBtn}
|
className={style.completeBtn}
|
||||||
block
|
style={{ flex: 1 }}
|
||||||
>
|
>
|
||||||
完成
|
完成
|
||||||
</Button>
|
</Button>
|
||||||
@@ -353,7 +388,7 @@ const NewMomentsSync: React.FC = () => {
|
|||||||
footer={renderFooter()}
|
footer={renderFooter()}
|
||||||
>
|
>
|
||||||
<div className={style.formBg}>
|
<div className={style.formBg}>
|
||||||
<div style={{ marginBottom: "15px" }}>
|
<div style={{ marginBottom: "15px", padding: "0 16px" }}>
|
||||||
<StepIndicator currentStep={currentStep + 1} steps={steps} />
|
<StepIndicator currentStep={currentStep + 1} steps={steps} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ export interface TrafficDistributionFormData {
|
|||||||
id?: string;
|
id?: string;
|
||||||
type: number;
|
type: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
// 计划类型:0-全局计划,1-独立计划
|
||||||
|
planType?: number;
|
||||||
source: string;
|
source: string;
|
||||||
sourceIcon: string;
|
sourceIcon: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
.formPage {
|
.formPage {
|
||||||
|
background: #f8fafc;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding-bottom: 100px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.formHeader {
|
.formHeader {
|
||||||
@@ -45,10 +48,54 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.formBody {
|
.formBody {
|
||||||
|
padding: 16px;
|
||||||
|
background: #f8fafc;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding-bottom: 100px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: hidden;
|
||||||
|
|
||||||
|
@media (max-width: 375px) {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
padding: 12px;
|
border-radius: 12px;
|
||||||
border-radius: 10px;
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
padding: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
@media (max-width: 375px) {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 输入框样式
|
||||||
|
:global(.ant-input) {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
background: #f8fafc;
|
||||||
|
font-size: 14px;
|
||||||
|
outline: none;
|
||||||
|
transition: all 0.2s;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: #3b82f6;
|
||||||
|
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.sectionTitle {
|
.sectionTitle {
|
||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
@@ -136,6 +183,13 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.radioGroupHorizontal {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
.radioDesc {
|
.radioDesc {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #888;
|
color: #888;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect, useCallback } from "react";
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
import { Form, Input, Button, Radio, Slider, TimePicker, message } from "antd";
|
import { Form, Input, Button, Radio, Slider, TimePicker, message, Card } from "antd";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import style from "./index.module.scss";
|
import style from "./index.module.scss";
|
||||||
import StepIndicator from "@/components/StepIndicator";
|
import StepIndicator from "@/components/StepIndicator";
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
createTrafficDistribution,
|
createTrafficDistribution,
|
||||||
} from "./api";
|
} from "./api";
|
||||||
import type { TrafficDistributionFormData } from "./data";
|
import type { TrafficDistributionFormData } from "./data";
|
||||||
|
import { useUserStore } from "@/store/module/user";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
const stepList = [
|
const stepList = [
|
||||||
@@ -30,6 +31,8 @@ const TrafficDistributionForm: React.FC = () => {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const isEdit = !!id;
|
const isEdit = !!id;
|
||||||
|
const { user } = useUserStore();
|
||||||
|
const isAdmin = user?.isAdmin === 1;
|
||||||
|
|
||||||
const [current, setCurrent] = useState(0);
|
const [current, setCurrent] = useState(0);
|
||||||
const [selectedDevices, setSelectedDevices] = useState<DeviceSelectionItem[]>(
|
const [selectedDevices, setSelectedDevices] = useState<DeviceSelectionItem[]>(
|
||||||
@@ -181,6 +184,7 @@ const TrafficDistributionForm: React.FC = () => {
|
|||||||
accountGroupsOptions,
|
accountGroupsOptions,
|
||||||
poolGroups: poolGroupsOptions.map(v => v.id),
|
poolGroups: poolGroupsOptions.map(v => v.id),
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
planType: formValues.planType ?? 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isEdit) {
|
if (isEdit) {
|
||||||
@@ -273,6 +277,7 @@ const TrafficDistributionForm: React.FC = () => {
|
|||||||
layout="vertical"
|
layout="vertical"
|
||||||
initialValues={{
|
initialValues={{
|
||||||
name: isEdit ? "" : generateDefaultName(),
|
name: isEdit ? "" : generateDefaultName(),
|
||||||
|
planType: 1,
|
||||||
distributeType: 1,
|
distributeType: 1,
|
||||||
maxPerDay: 50,
|
maxPerDay: 50,
|
||||||
timeType: 1,
|
timeType: 1,
|
||||||
@@ -281,99 +286,124 @@ const TrafficDistributionForm: React.FC = () => {
|
|||||||
onFinish={handleSubmit}
|
onFinish={handleSubmit}
|
||||||
style={{ display: current === 0 ? "block" : "none" }}
|
style={{ display: current === 0 ? "block" : "none" }}
|
||||||
>
|
>
|
||||||
<div className={style.sectionTitle}>基本信息</div>
|
{/* 计划类型和计划名称 */}
|
||||||
<Form.Item
|
<Card className={style.card}>
|
||||||
label="计划名称"
|
{isAdmin && (
|
||||||
name="name"
|
<Form.Item label="计划类型" name="planType">
|
||||||
rules={[{ required: true, message: "请输入计划名称" }]}
|
<Radio.Group className={style.radioGroupHorizontal}>
|
||||||
>
|
<Radio value={0}>全局计划</Radio>
|
||||||
<Input placeholder="流量分发 20250724 1700" maxLength={30} />
|
<Radio value={1}>独立计划</Radio>
|
||||||
</Form.Item>
|
</Radio.Group>
|
||||||
<Form.Item label="分配方式" name="distributeType" required>
|
</Form.Item>
|
||||||
<Radio.Group className={style.radioGroup}>
|
)}
|
||||||
<Radio value={1}>
|
|
||||||
均分配
|
|
||||||
<span className={style.radioDesc}>
|
|
||||||
(流量将均分分配给所有客服)
|
|
||||||
</span>
|
|
||||||
</Radio>
|
|
||||||
<Radio value={2}>
|
|
||||||
优先级分配
|
|
||||||
<span className={style.radioDesc}>
|
|
||||||
(按客服优先级顺序分配)
|
|
||||||
</span>
|
|
||||||
</Radio>
|
|
||||||
<Radio value={3}>
|
|
||||||
比例分配
|
|
||||||
<span className={style.radioDesc}>(按设置比例分配流量)</span>
|
|
||||||
</Radio>
|
|
||||||
</Radio.Group>
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item label="分配限制" required>
|
|
||||||
<div className={style.sliderLabelWrap}>
|
|
||||||
<span>每日最大分配量</span>
|
|
||||||
<span className={style.sliderValue}>
|
|
||||||
{maxPerDay || 0} 人/天
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="maxPerDay"
|
label="计划名称"
|
||||||
noStyle
|
name="name"
|
||||||
rules={[{ required: true, message: "请设置每日最大分配量" }]}
|
rules={[{ required: true, message: "请输入计划名称" }]}
|
||||||
>
|
>
|
||||||
<Slider min={1} max={1000} className={style.slider} />
|
<Input placeholder="流量分发 20250724 1700" maxLength={30} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<div className={style.sliderDesc}>限制每天最多分配的流量数量</div>
|
</Card>
|
||||||
</Form.Item>
|
|
||||||
<Form.Item label="时间限制" name="timeType" required>
|
|
||||||
<Radio.Group className={style.radioGroup}>
|
|
||||||
<Radio value={1}>全天分配</Radio>
|
|
||||||
<Radio value={2}>自定义时间段</Radio>
|
|
||||||
</Radio.Group>
|
|
||||||
</Form.Item>
|
|
||||||
{timeType === 2 && (
|
|
||||||
<Form.Item
|
|
||||||
label=""
|
|
||||||
name="timeRange"
|
|
||||||
required
|
|
||||||
dependencies={["timeType"]}
|
|
||||||
rules={[
|
|
||||||
({ getFieldValue }) => ({
|
|
||||||
validator(_, value) {
|
|
||||||
if (getFieldValue("timeType") === 1) {
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
if (value && value.length === 2) {
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
return Promise.reject(new Error("请选择开始和结束时间"));
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<TimePicker.RangePicker
|
|
||||||
format="HH:mm"
|
|
||||||
style={{ width: "100%" }}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className={style.sectionTitle}>客服选择</div>
|
{/* 分配方式 */}
|
||||||
<div className={style.formBlock}>
|
<Card className={style.card}>
|
||||||
<AccountSelection
|
<Form.Item label="分配方式" name="distributeType" required>
|
||||||
selectedOptions={accountGroupsOptions}
|
<Radio.Group className={style.radioGroup}>
|
||||||
onSelect={accounts => {
|
<Radio value={1}>
|
||||||
setAccountGroupsOptions(accounts);
|
均分配
|
||||||
}}
|
<span className={style.radioDesc}>
|
||||||
placeholder="请选择客服"
|
(流量将均分分配给所有客服)
|
||||||
showSelectedList={true}
|
</span>
|
||||||
selectedListMaxHeight={300}
|
</Radio>
|
||||||
accountGroups={accountGroups}
|
<Radio value={2}>
|
||||||
/>
|
优先级分配
|
||||||
</div>
|
<span className={style.radioDesc}>
|
||||||
|
(按客服优先级顺序分配)
|
||||||
|
</span>
|
||||||
|
</Radio>
|
||||||
|
<Radio value={3}>
|
||||||
|
比例分配
|
||||||
|
<span className={style.radioDesc}>(按设置比例分配流量)</span>
|
||||||
|
</Radio>
|
||||||
|
</Radio.Group>
|
||||||
|
</Form.Item>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 分配限制 */}
|
||||||
|
<Card className={style.card}>
|
||||||
|
<Form.Item label="分配限制" required>
|
||||||
|
<div className={style.sliderLabelWrap}>
|
||||||
|
<span>每日最大分配量</span>
|
||||||
|
<span className={style.sliderValue}>
|
||||||
|
{maxPerDay || 0} 人/天
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Form.Item
|
||||||
|
name="maxPerDay"
|
||||||
|
noStyle
|
||||||
|
rules={[{ required: true, message: "请设置每日最大分配量" }]}
|
||||||
|
>
|
||||||
|
<Slider min={1} max={1000} className={style.slider} />
|
||||||
|
</Form.Item>
|
||||||
|
<div className={style.sliderDesc}>限制每天最多分配的流量数量</div>
|
||||||
|
</Form.Item>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 时间限制 */}
|
||||||
|
<Card className={style.card}>
|
||||||
|
<Form.Item label="时间限制" name="timeType" required>
|
||||||
|
<Radio.Group className={style.radioGroupHorizontal}>
|
||||||
|
<Radio value={1}>全天分配</Radio>
|
||||||
|
<Radio value={2}>自定义时间段</Radio>
|
||||||
|
</Radio.Group>
|
||||||
|
</Form.Item>
|
||||||
|
{timeType === 2 && (
|
||||||
|
<Form.Item
|
||||||
|
label=""
|
||||||
|
name="timeRange"
|
||||||
|
required
|
||||||
|
dependencies={["timeType"]}
|
||||||
|
rules={[
|
||||||
|
({ getFieldValue }) => ({
|
||||||
|
validator(_, value) {
|
||||||
|
if (getFieldValue("timeType") === 1) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
if (value && value.length === 2) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error("请选择开始和结束时间"));
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<TimePicker.RangePicker
|
||||||
|
format="HH:mm"
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 客服选择 */}
|
||||||
|
<Card className={style.card}>
|
||||||
|
<div className={style.sectionTitle}>客服选择</div>
|
||||||
|
<div className={style.formBlock}>
|
||||||
|
<AccountSelection
|
||||||
|
selectedOptions={accountGroupsOptions}
|
||||||
|
onSelect={accounts => {
|
||||||
|
setAccountGroupsOptions(accounts);
|
||||||
|
}}
|
||||||
|
placeholder="请选择客服"
|
||||||
|
showSelectedList={true}
|
||||||
|
selectedListMaxHeight={300}
|
||||||
|
accountGroups={accountGroups}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
</Form>
|
</Form>
|
||||||
{current === 1 && (
|
{current === 1 && (
|
||||||
<div>
|
<Card className={style.card}>
|
||||||
<div className={style.sectionTitle}>目标设置</div>
|
<div className={style.sectionTitle}>目标设置</div>
|
||||||
<div className={style.formBlock}>
|
<div className={style.formBlock}>
|
||||||
<DeviceSelection
|
<DeviceSelection
|
||||||
@@ -388,16 +418,18 @@ const TrafficDistributionForm: React.FC = () => {
|
|||||||
deviceGroups={deviceGroups}
|
deviceGroups={deviceGroups}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
)}
|
)}
|
||||||
{current === 2 && (
|
{current === 2 && (
|
||||||
<PoolSelection
|
<Card className={style.card}>
|
||||||
selectedOptions={poolGroupsOptions}
|
<PoolSelection
|
||||||
onSelect={handlePoolSelect}
|
selectedOptions={poolGroupsOptions}
|
||||||
placeholder="请选择流量池"
|
onSelect={handlePoolSelect}
|
||||||
showSelectedList={true}
|
placeholder="请选择流量池"
|
||||||
selectedListMaxHeight={300}
|
showSelectedList={true}
|
||||||
/>
|
selectedListMaxHeight={300}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,6 +12,43 @@
|
|||||||
padding: 0 16px;
|
padding: 0 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.infoBox {
|
||||||
|
background: #eff6ff;
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #1e40af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionDot {
|
||||||
|
width: 4px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitleIndependent {
|
||||||
|
.sectionDot {
|
||||||
|
background: #fb923c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.ruleCard {
|
.ruleCard {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
|
|||||||
@@ -75,7 +75,12 @@ const TrafficDistributionList: React.FC = () => {
|
|||||||
limit: PAGE_SIZE,
|
limit: PAGE_SIZE,
|
||||||
keyword,
|
keyword,
|
||||||
});
|
});
|
||||||
setList(res?.list || []);
|
const rawList: DistributionRule[] = res?.list || [];
|
||||||
|
const normalized = rawList.map(item => ({
|
||||||
|
...item,
|
||||||
|
planType: (item as any).planType ?? item.config?.planType ?? 1,
|
||||||
|
}));
|
||||||
|
setList(normalized);
|
||||||
setTotal(Number(res?.total) || 0);
|
setTotal(Number(res?.total) || 0);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
message.error("获取流量分发列表失败");
|
message.error("获取流量分发列表失败");
|
||||||
@@ -93,6 +98,9 @@ const TrafficDistributionList: React.FC = () => {
|
|||||||
fetchList(page, searchQuery);
|
fetchList(page, searchQuery);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const globalRules = list.filter(item => item.planType === 0);
|
||||||
|
const independentRules = list.filter(item => item.planType !== 0);
|
||||||
|
|
||||||
// 优化:菜单点击事件,menuLoadingId标记当前item
|
// 优化:菜单点击事件,menuLoadingId标记当前item
|
||||||
const handleMenuClick = async (key: string, item: DistributionRule) => {
|
const handleMenuClick = async (key: string, item: DistributionRule) => {
|
||||||
setMenuLoadingId(item.id);
|
setMenuLoadingId(item.id);
|
||||||
@@ -322,6 +330,45 @@ const TrafficDistributionList: React.FC = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let content: React.ReactNode;
|
||||||
|
if (loading) {
|
||||||
|
content = <Spin />;
|
||||||
|
} else if (list.length === 0) {
|
||||||
|
content = <div className={style.empty}>暂无数据</div>;
|
||||||
|
} else {
|
||||||
|
content = (
|
||||||
|
<>
|
||||||
|
{globalRules.length > 0 && (
|
||||||
|
<div className={style.infoBox}>
|
||||||
|
全局流量分发计划将作用于所有账号和设备,请谨慎配置每日分配量与时间段。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{globalRules.length > 0 && (
|
||||||
|
<section className={style.section}>
|
||||||
|
<h3 className={style.sectionTitle}>
|
||||||
|
<span className={style.sectionDot} />
|
||||||
|
全局流量分发计划
|
||||||
|
</h3>
|
||||||
|
{globalRules.map(renderCard)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{independentRules.length > 0 && (
|
||||||
|
<section className={style.section}>
|
||||||
|
<h3
|
||||||
|
className={`${style.sectionTitle} ${style.sectionTitleIndependent}`}
|
||||||
|
>
|
||||||
|
<span className={style.sectionDot} />
|
||||||
|
独立流量分发计划
|
||||||
|
</h3>
|
||||||
|
{independentRules.map(renderCard)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout
|
<Layout
|
||||||
header={
|
header={
|
||||||
@@ -373,15 +420,7 @@ const TrafficDistributionList: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className={style.ruleList}>
|
<div className={style.ruleList}>{content}</div>
|
||||||
{loading ? (
|
|
||||||
<Spin />
|
|
||||||
) : list.length > 0 ? (
|
|
||||||
list.map(renderCard)
|
|
||||||
) : (
|
|
||||||
<div className={style.empty}>暂无数据</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 账号列表弹窗 */}
|
{/* 账号列表弹窗 */}
|
||||||
<AccountListModal
|
<AccountListModal
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user