Merge branch 'yongpxu-dev' into yongpxu-dev4

This commit is contained in:
超级老白兔
2025-08-20 09:41:25 +08:00
73 changed files with 6963 additions and 912 deletions

View File

@@ -1,4 +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=存客宝

View File

@@ -1,4 +1,6 @@
# 基础环境变量示例
VITE_API_BASE_URL=https://ckbapi.quwanzhi.com
VITE_API_BASE_URL2=https://kf.quwanzhi.com:9991
VITE_API_WS_URL=wss://kf.quwanzhi.com:9993
# VITE_API_BASE_URL=http://www.yishi.com
VITE_APP_TITLE=存客宝

View File

@@ -1,18 +1,14 @@
{
"_charts-TuAbbBZ5.js": {
"file": "assets/charts-TuAbbBZ5.js",
"_charts-CLRTJ7Uf.js": {
"file": "assets/charts-CLRTJ7Uf.js",
"name": "charts",
"imports": [
"_ui-D1w-jetn.js",
"_ui-BFvqeNzU.js",
"_vendor-2vc8h_ct.js"
]
},
"_ui-D0C0OGrH.css": {
"file": "assets/ui-D0C0OGrH.css",
"src": "_ui-D0C0OGrH.css"
},
"_ui-D1w-jetn.js": {
"file": "assets/ui-D1w-jetn.js",
"_ui-BFvqeNzU.js": {
"file": "assets/ui-BFvqeNzU.js",
"name": "ui",
"imports": [
"_vendor-2vc8h_ct.js"
@@ -21,6 +17,10 @@
"assets/ui-D0C0OGrH.css"
]
},
"_ui-D0C0OGrH.css": {
"file": "assets/ui-D0C0OGrH.css",
"src": "_ui-D0C0OGrH.css"
},
"_utils-6WF66_dS.js": {
"file": "assets/utils-6WF66_dS.js",
"name": "utils",
@@ -33,18 +33,18 @@
"name": "vendor"
},
"index.html": {
"file": "assets/index-D3HSx5Yt.js",
"file": "assets/index-C48GlG01.js",
"name": "index",
"src": "index.html",
"isEntry": true,
"imports": [
"_vendor-2vc8h_ct.js",
"_ui-D1w-jetn.js",
"_ui-BFvqeNzU.js",
"_utils-6WF66_dS.js",
"_charts-TuAbbBZ5.js"
"_charts-CLRTJ7Uf.js"
],
"css": [
"assets/index-B0SB167P.css"
"assets/index-Ta4vyxDJ.css"
]
}
}

View File

@@ -10,14 +10,14 @@
}
</style>
<!-- 引入 uni-app web-view SDK必须 -->
<script type="text/javascript" src="./websdk.js"></script>
<script type="module" crossorigin src="/assets/index-D3HSx5Yt.js"></script>
<script type="text/javascript" src="/websdk.js"></script>
<script type="module" crossorigin src="/assets/index-C48GlG01.js"></script>
<link rel="modulepreload" crossorigin href="/assets/vendor-2vc8h_ct.js">
<link rel="modulepreload" crossorigin href="/assets/ui-D1w-jetn.js">
<link rel="modulepreload" crossorigin href="/assets/ui-BFvqeNzU.js">
<link rel="modulepreload" crossorigin href="/assets/utils-6WF66_dS.js">
<link rel="modulepreload" crossorigin href="/assets/charts-TuAbbBZ5.js">
<link rel="modulepreload" crossorigin href="/assets/charts-CLRTJ7Uf.js">
<link rel="stylesheet" crossorigin href="/assets/ui-D0C0OGrH.css">
<link rel="stylesheet" crossorigin href="/assets/index-B0SB167P.css">
<link rel="stylesheet" crossorigin href="/assets/index-Ta4vyxDJ.css">
</head>
<body>
<div id="root"></div>

View File

@@ -10,7 +10,7 @@
}
</style>
<!-- 引入 uni-app web-view SDK必须 -->
<script type="text/javascript" src="./websdk.js"></script>
<script type="text/javascript" src="/websdk.js"></script>
</head>
<body>
<div id="root"></div>

View File

@@ -0,0 +1,352 @@
// Android 专用 polyfill - 解决Android 7等低版本系统的兼容性问题
// 检测是否为Android设备
const isAndroid = () => {
return /Android/i.test(navigator.userAgent);
};
// 检测Android版本
const getAndroidVersion = () => {
const match = navigator.userAgent.match(/Android\s+(\d+)/);
return match ? parseInt(match[1]) : 0;
};
// 检测是否为低版本Android
const isLowVersionAndroid = () => {
const version = getAndroidVersion();
return version <= 7; // Android 7及以下版本
};
// 只在Android设备上执行polyfill
if (isAndroid() && isLowVersionAndroid()) {
console.log("检测到低版本Android系统启用兼容性polyfill");
// 修复Array.prototype.includes在Android WebView中的问题
if (!Array.prototype.includes) {
Array.prototype.includes = function (searchElement, fromIndex) {
if (this == null) {
throw new TypeError('"this" is null or not defined');
}
var o = Object(this);
var len = o.length >>> 0;
if (len === 0) {
return false;
}
var n = fromIndex | 0;
var k = Math.max(n >= 0 ? n : len + n, 0);
while (k < len) {
if (o[k] === searchElement) {
return true;
}
k++;
}
return false;
};
}
// 修复String.prototype.includes在Android WebView中的问题
if (!String.prototype.includes) {
String.prototype.includes = function (search, start) {
if (typeof start !== "number") {
start = 0;
}
if (start + search.length > this.length) {
return false;
} else {
return this.indexOf(search, start) !== -1;
}
};
}
// 修复String.prototype.startsWith在Android WebView中的问题
if (!String.prototype.startsWith) {
String.prototype.startsWith = function (searchString, position) {
position = position || 0;
return this.substr(position, searchString.length) === searchString;
};
}
// 修复String.prototype.endsWith在Android WebView中的问题
if (!String.prototype.endsWith) {
String.prototype.endsWith = function (searchString, length) {
if (length === undefined || length > this.length) {
length = this.length;
}
return (
this.substring(length - searchString.length, length) === searchString
);
};
}
// 修复Array.prototype.find在Android WebView中的问题
if (!Array.prototype.find) {
Array.prototype.find = function (predicate) {
if (this == null) {
throw new TypeError("Array.prototype.find called on null or undefined");
}
if (typeof predicate !== "function") {
throw new TypeError("predicate must be a function");
}
var list = Object(this);
var length = parseInt(list.length) || 0;
var thisArg = arguments[1];
for (var i = 0; i < length; i++) {
var element = list[i];
if (predicate.call(thisArg, element, i, list)) {
return element;
}
}
return undefined;
};
}
// 修复Array.prototype.findIndex在Android WebView中的问题
if (!Array.prototype.findIndex) {
Array.prototype.findIndex = function (predicate) {
if (this == null) {
throw new TypeError(
"Array.prototype.findIndex called on null or undefined",
);
}
if (typeof predicate !== "function") {
throw new TypeError("predicate must be a function");
}
var list = Object(this);
var length = parseInt(list.length) || 0;
var thisArg = arguments[1];
for (var i = 0; i < length; i++) {
var element = list[i];
if (predicate.call(thisArg, element, i, list)) {
return i;
}
}
return -1;
};
}
// 修复Object.assign在Android WebView中的问题
if (typeof Object.assign !== "function") {
Object.assign = function (target) {
if (target == null) {
throw new TypeError("Cannot convert undefined or null to object");
}
var to = Object(target);
for (var index = 1; index < arguments.length; index++) {
var nextSource = arguments[index];
if (nextSource != null) {
for (var nextKey in nextSource) {
if (Object.prototype.hasOwnProperty.call(nextSource, nextKey)) {
to[nextKey] = nextSource[nextKey];
}
}
}
}
return to;
};
}
// 修复Array.from在Android WebView中的问题
if (!Array.from) {
Array.from = (function () {
var toStr = Object.prototype.toString;
var isCallable = function (fn) {
return (
typeof fn === "function" || toStr.call(fn) === "[object Function]"
);
};
var toInteger = function (value) {
var number = Number(value);
if (isNaN(number)) {
return 0;
}
if (number === 0 || !isFinite(number)) {
return number;
}
return (number > 0 ? 1 : -1) * Math.floor(Math.abs(number));
};
var maxSafeInteger = Math.pow(2, 53) - 1;
var toLength = function (value) {
var len = toInteger(value);
return Math.min(Math.max(len, 0), maxSafeInteger);
};
return function from(arrayLike) {
var C = this;
var items = Object(arrayLike);
if (arrayLike == null) {
throw new TypeError(
"Array.from requires an array-like object - not null or undefined",
);
}
var mapFunction = arguments.length > 1 ? arguments[1] : void undefined;
var T;
if (typeof mapFunction !== "undefined") {
if (typeof mapFunction !== "function") {
throw new TypeError(
"Array.from: when provided, the second argument must be a function",
);
}
if (arguments.length > 2) {
T = arguments[2];
}
}
var len = toLength(items.length);
var A = isCallable(C) ? Object(new C(len)) : new Array(len);
var k = 0;
var kValue;
while (k < len) {
kValue = items[k];
if (mapFunction) {
A[k] =
typeof T === "undefined"
? mapFunction(kValue, k)
: mapFunction.call(T, kValue, k);
} else {
A[k] = kValue;
}
k += 1;
}
A.length = len;
return A;
};
})();
}
// 修复requestAnimationFrame在Android WebView中的问题
if (!window.requestAnimationFrame) {
window.requestAnimationFrame = function (callback) {
return setTimeout(function () {
callback(Date.now());
}, 1000 / 60);
};
}
if (!window.cancelAnimationFrame) {
window.cancelAnimationFrame = function (id) {
clearTimeout(id);
};
}
// 修复IntersectionObserver在Android WebView中的问题
if (!window.IntersectionObserver) {
window.IntersectionObserver = function (callback, options) {
this.callback = callback;
this.options = options || {};
this.observers = [];
this.observe = function (element) {
this.observers.push(element);
// 简单的实现,实际项目中可能需要更复杂的逻辑
setTimeout(() => {
this.callback([
{
target: element,
isIntersecting: true,
intersectionRatio: 1,
},
]);
}, 100);
};
this.unobserve = function (element) {
var index = this.observers.indexOf(element);
if (index > -1) {
this.observers.splice(index, 1);
}
};
this.disconnect = function () {
this.observers = [];
};
};
}
// 修复ResizeObserver在Android WebView中的问题
if (!window.ResizeObserver) {
window.ResizeObserver = function (callback) {
this.callback = callback;
this.observers = [];
this.observe = function (element) {
this.observers.push(element);
};
this.unobserve = function (element) {
var index = this.observers.indexOf(element);
if (index > -1) {
this.observers.splice(index, 1);
}
};
this.disconnect = function () {
this.observers = [];
};
};
}
// 修复URLSearchParams在Android WebView中的问题
if (!window.URLSearchParams) {
window.URLSearchParams = function (init) {
this.params = {};
if (init) {
if (typeof init === "string") {
if (init.charAt(0) === "?") {
init = init.slice(1);
}
var pairs = init.split("&");
for (var i = 0; i < pairs.length; i++) {
var pair = pairs[i].split("=");
var key = decodeURIComponent(pair[0]);
var value = decodeURIComponent(pair[1] || "");
this.append(key, value);
}
}
}
this.append = function (name, value) {
if (!this.params[name]) {
this.params[name] = [];
}
this.params[name].push(value);
};
this.get = function (name) {
return this.params[name] ? this.params[name][0] : null;
};
this.getAll = function (name) {
return this.params[name] || [];
};
this.has = function (name) {
return !!this.params[name];
};
this.set = function (name, value) {
this.params[name] = [value];
};
this.delete = function (name) {
delete this.params[name];
};
this.toString = function () {
var pairs = [];
for (var key in this.params) {
if (this.params.hasOwnProperty(key)) {
for (var i = 0; i < this.params[key].length; i++) {
pairs.push(
encodeURIComponent(key) +
"=" +
encodeURIComponent(this.params[key][i]),
);
}
}
}
return pairs.join("&");
};
};
}
console.log("Android兼容性polyfill已加载完成");
}

View File

@@ -0,0 +1,81 @@
import axios, {
AxiosInstance,
AxiosRequestConfig,
Method,
AxiosResponse,
} from "axios";
import { Toast } from "antd-mobile";
import { useUserStore } from "@/store/module/user";
const DEFAULT_DEBOUNCE_GAP = 1000;
const debounceMap = new Map<string, number>();
interface RequestConfig extends AxiosRequestConfig {
headers: {
Client?: string;
"Content-Type"?: string;
};
}
const instance: AxiosInstance = axios.create({
baseURL: (import.meta as any).env?.VITE_API_BASE_URL2 || "/api",
timeout: 20000,
headers: {
"Content-Type": "application/json",
Client: "kefu-client",
},
});
instance.interceptors.request.use((config: any) => {
// 在每次请求时动态获取最新的 token2
const { token2 } = useUserStore.getState();
if (token2) {
config.headers = config.headers || {};
config.headers["Authorization"] = `bearer ${token2}`;
}
return config;
});
instance.interceptors.response.use(
(res: AxiosResponse) => {
return res.data;
},
err => {
Toast.show({ content: err.message || "网络异常", position: "top" });
return Promise.reject(err);
},
);
export function request(
url: string,
data?: any,
method: Method = "GET",
config?: RequestConfig,
debounceGap?: number,
): Promise<any> {
const gap =
typeof debounceGap === "number" ? debounceGap : DEFAULT_DEBOUNCE_GAP;
const key = `${method}_${url}_${JSON.stringify(data)}`;
const now = Date.now();
const last = debounceMap.get(key) || 0;
if (gap > 0 && now - last < gap) {
// Toast.show({ content: '请求过于频繁,请稍后再试', position: 'top' });
return Promise.reject("请求过于频繁,请稍后再试");
}
debounceMap.set(key, now);
const axiosConfig: RequestConfig = {
url,
method,
...config,
};
if (method.toUpperCase() === "GET") {
axiosConfig.params = data;
} else {
axiosConfig.data = data;
}
return instance(axiosConfig);
}
export default request;

View File

@@ -31,4 +31,5 @@ export interface AccountSelectionProps {
showSelectedList?: boolean;
readonly?: boolean;
onConfirm?: (selectedOptions: AccountItem[]) => void;
accountGroups?: any[]; // 传递账号组数据
}

View File

@@ -0,0 +1,228 @@
import React, { useEffect, useState } from "react";
interface AndroidCompatibilityInfo {
isAndroid: boolean;
androidVersion: number;
chromeVersion: number;
webViewVersion: number;
issues: string[];
suggestions: string[];
}
const AndroidCompatibilityCheck: React.FC = () => {
const [compatibility, setCompatibility] = useState<AndroidCompatibilityInfo>({
isAndroid: false,
androidVersion: 0,
chromeVersion: 0,
webViewVersion: 0,
issues: [],
suggestions: [],
});
useEffect(() => {
const checkAndroidCompatibility = () => {
const ua = navigator.userAgent;
const issues: string[] = [];
const suggestions: string[] = [];
let isAndroid = false;
let androidVersion = 0;
let chromeVersion = 0;
let webViewVersion = 0;
// 检测Android系统
if (ua.indexOf("Android") > -1) {
isAndroid = true;
const androidMatch = ua.match(/Android\s+(\d+)/);
if (androidMatch) {
androidVersion = parseInt(androidMatch[1]);
}
// 检测Chrome版本
const chromeMatch = ua.match(/Chrome\/(\d+)/);
if (chromeMatch) {
chromeVersion = parseInt(chromeMatch[1]);
}
// 检测WebView版本
const webViewMatch = ua.match(/Version\/\d+\.\d+/);
if (webViewMatch) {
const versionMatch = webViewMatch[0].match(/\d+/);
if (versionMatch) {
webViewVersion = parseInt(versionMatch[0]);
}
}
// Android 7 (API 24) 兼容性检查
if (androidVersion === 7) {
issues.push("Android 7 系统对ES6+特性支持不完整");
suggestions.push("建议升级到Android 8+或使用最新版Chrome");
}
// Android 6 (API 23) 兼容性检查
if (androidVersion === 6) {
issues.push("Android 6 系统对现代Web特性支持有限");
suggestions.push("强烈建议升级系统或使用最新版Chrome");
}
// Chrome版本检查
if (chromeVersion > 0 && chromeVersion < 50) {
issues.push(`Chrome版本过低 (${chromeVersion})建议升级到50+`);
suggestions.push("请在Google Play商店更新Chrome浏览器");
}
// WebView版本检查
if (webViewVersion > 0 && webViewVersion < 50) {
issues.push(`WebView版本过低 (${webViewVersion}),可能影响应用功能`);
suggestions.push("建议使用Chrome浏览器或更新系统WebView");
}
// 检测特定问题
const features = {
Promise: typeof Promise !== "undefined",
fetch: typeof fetch !== "undefined",
"Array.from": typeof Array.from !== "undefined",
"Object.assign": typeof Object.assign !== "undefined",
"String.includes": typeof String.prototype.includes !== "undefined",
"Array.includes": typeof Array.prototype.includes !== "undefined",
requestAnimationFrame: typeof requestAnimationFrame !== "undefined",
IntersectionObserver: typeof IntersectionObserver !== "undefined",
ResizeObserver: typeof ResizeObserver !== "undefined",
URLSearchParams: typeof URLSearchParams !== "undefined",
TextEncoder: typeof TextEncoder !== "undefined",
AbortController: typeof AbortController !== "undefined",
};
Object.entries(features).forEach(([feature, supported]) => {
if (!supported) {
issues.push(`${feature} 特性不支持`);
}
});
// 微信内置浏览器检测
if (ua.indexOf("MicroMessenger") > -1) {
issues.push("微信内置浏览器对某些Web特性支持有限");
suggestions.push("建议在系统浏览器中打开以获得最佳体验");
}
// QQ内置浏览器检测
if (ua.indexOf("QQ/") > -1) {
issues.push("QQ内置浏览器对某些Web特性支持有限");
suggestions.push("建议在系统浏览器中打开以获得最佳体验");
}
}
setCompatibility({
isAndroid,
androidVersion,
chromeVersion,
webViewVersion,
issues,
suggestions,
});
};
checkAndroidCompatibility();
}, []);
if (!compatibility.isAndroid || compatibility.issues.length === 0) {
return null;
}
return (
<div
style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
backgroundColor: "#fff3cd",
border: "1px solid #ffeaa7",
padding: "15px",
zIndex: 9999,
textAlign: "center",
fontSize: "14px",
maxHeight: "50vh",
overflowY: "auto",
}}
>
<div
style={{ fontWeight: "bold", marginBottom: "10px", color: "#856404" }}
>
🚨 Android
</div>
<div style={{ marginBottom: "8px", fontSize: "12px" }}>
系统版本: Android {compatibility.androidVersion}
{compatibility.chromeVersion > 0 &&
` | Chrome: ${compatibility.chromeVersion}`}
{compatibility.webViewVersion > 0 &&
` | WebView: ${compatibility.webViewVersion}`}
</div>
<div style={{ marginBottom: "10px" }}>
<div
style={{ fontWeight: "bold", marginBottom: "5px", color: "#856404" }}
>
:
</div>
<div style={{ color: "#856404", fontSize: "12px" }}>
{compatibility.issues.map((issue, index) => (
<div key={index} style={{ marginBottom: "3px" }}>
{issue}
</div>
))}
</div>
</div>
{compatibility.suggestions.length > 0 && (
<div style={{ marginBottom: "10px" }}>
<div
style={{
fontWeight: "bold",
marginBottom: "5px",
color: "#155724",
}}
>
:
</div>
<div style={{ color: "#155724", fontSize: "12px" }}>
{compatibility.suggestions.map((suggestion, index) => (
<div key={index} style={{ marginBottom: "3px" }}>
{suggestion}
</div>
))}
</div>
</div>
)}
<div style={{ fontSize: "11px", color: "#6c757d", marginTop: "10px" }}>
💡
</div>
<button
onClick={() => {
const element = document.querySelector(
'[style*="position: fixed"][style*="top: 0"]',
) as HTMLElement;
if (element) {
element.style.display = "none";
}
}}
style={{
position: "absolute",
top: "5px",
right: "10px",
background: "none",
border: "none",
fontSize: "18px",
cursor: "pointer",
color: "#856404",
}}
>
×
</button>
</div>
);
};
export default AndroidCompatibilityCheck;

View File

@@ -0,0 +1,125 @@
import React, { useEffect, useState } from "react";
interface CompatibilityInfo {
isCompatible: boolean;
browser: string;
version: string;
issues: string[];
}
const CompatibilityCheck: React.FC = () => {
const [compatibility, setCompatibility] = useState<CompatibilityInfo>({
isCompatible: true,
browser: "",
version: "",
issues: [],
});
useEffect(() => {
const checkCompatibility = () => {
const ua = navigator.userAgent;
const issues: string[] = [];
let browser = "Unknown";
let version = "Unknown";
// 检测浏览器类型和版本
if (ua.indexOf("Chrome") > -1) {
browser = "Chrome";
const match = ua.match(/Chrome\/(\d+)/);
version = match ? match[1] : "Unknown";
if (parseInt(version) < 50) {
issues.push("Chrome版本过低建议升级到50+");
}
} else if (ua.indexOf("Firefox") > -1) {
browser = "Firefox";
const match = ua.match(/Firefox\/(\d+)/);
version = match ? match[1] : "Unknown";
if (parseInt(version) < 50) {
issues.push("Firefox版本过低建议升级到50+");
}
} else if (ua.indexOf("Safari") > -1 && ua.indexOf("Chrome") === -1) {
browser = "Safari";
const match = ua.match(/Version\/(\d+)/);
version = match ? match[1] : "Unknown";
if (parseInt(version) < 10) {
issues.push("Safari版本过低建议升级到10+");
}
} else if (ua.indexOf("MSIE") > -1 || ua.indexOf("Trident") > -1) {
browser = "Internet Explorer";
const match = ua.match(/(?:MSIE |rv:)(\d+)/);
version = match ? match[1] : "Unknown";
issues.push("Internet Explorer不受支持建议使用现代浏览器");
} else if (ua.indexOf("Edge") > -1) {
browser = "Edge";
const match = ua.match(/Edge\/(\d+)/);
version = match ? match[1] : "Unknown";
if (parseInt(version) < 12) {
issues.push("Edge版本过低建议升级到12+");
}
}
// 检测ES6+特性支持
const features = {
Promise: typeof Promise !== "undefined",
fetch: typeof fetch !== "undefined",
"Array.from": typeof Array.from !== "undefined",
"Object.assign": typeof Object.assign !== "undefined",
"String.includes": typeof String.prototype.includes !== "undefined",
"Array.includes": typeof Array.prototype.includes !== "undefined",
};
Object.entries(features).forEach(([feature, supported]) => {
if (!supported) {
issues.push(`${feature} 特性不支持`);
}
});
setCompatibility({
isCompatible: issues.length === 0,
browser,
version,
issues,
});
};
checkCompatibility();
}, []);
if (compatibility.isCompatible) {
return null; // 兼容时不需要显示
}
return (
<div
style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
backgroundColor: "#fff3cd",
border: "1px solid #ffeaa7",
padding: "10px",
zIndex: 9999,
textAlign: "center",
fontSize: "14px",
}}
>
<div style={{ fontWeight: "bold", marginBottom: "5px" }}>
</div>
<div style={{ marginBottom: "5px" }}>
: {compatibility.browser} {compatibility.version}
</div>
<div style={{ color: "#856404" }}>
{compatibility.issues.map((issue, index) => (
<div key={index}>{issue}</div>
))}
</div>
<div style={{ marginTop: "10px", fontSize: "12px" }}>
使 Chrome 50+Firefox 50+Safari 10+ Edge 12+
</div>
</div>
);
};
export default CompatibilityCheck;

View File

@@ -8,6 +8,8 @@ export interface DeviceSelectionItem {
wxid?: string;
nickname?: string;
usedInPlans?: number;
avatar?: string;
totalFriend?: number;
}
// 组件属性接口
@@ -23,4 +25,5 @@ export interface DeviceSelectionProps {
showInput?: boolean; // 新增
showSelectedList?: boolean; // 新增
readonly?: boolean; // 新增
deviceGroups?: any[]; // 传递设备组数据
}

View File

@@ -67,60 +67,152 @@
}
.deviceItem {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 16px;
border-radius: 12px;
border: 1px solid #f0f0f0;
flex-direction: column;
padding: 12px;
background: #fff;
cursor: pointer;
transition: background 0.2s;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
transition: all 0.2s ease;
border: 1px solid #f5f5f5;
&:hover {
background: #f5f6fa;
transform: translateY(-1px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
}
}
.headerRow {
display: flex;
align-items: center;
gap: 8px;
}
.checkboxContainer {
flex-shrink: 0;
}
.imeiText {
font-size: 13px;
color: #666;
font-family: monospace;
flex: 1;
}
.mainContent {
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
padding: 8px;
border-radius: 8px;
transition: background-color 0.2s ease;
&:hover {
background-color: #f8f9fa;
}
}
.deviceCheckbox {
margin-top: 4px;
flex-shrink: 0;
}
.deviceInfo {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 12px;
}
.deviceAvatar {
width: 64px;
height: 64px;
border-radius: 6px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.25);
flex-shrink: 0;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.avatarText {
font-size: 18px;
color: #fff;
font-weight: 700;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
}
.deviceContent {
flex: 1;
min-width: 0;
}
.deviceInfoRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
margin-bottom: 6px;
}
.deviceName {
font-weight: 500;
font-size: 16px;
color: #222;
font-weight: 600;
color: #1a1a1a;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.statusOnline {
width: 56px;
height: 24px;
border-radius: 12px;
background: #52c41a;
color: #fff;
font-size: 13px;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
padding: 1px 6px;
border-radius: 8px;
color: #52c41a;
background: #f6ffed;
border: 1px solid #b7eb8f;
font-weight: 500;
}
.statusOffline {
width: 56px;
height: 24px;
border-radius: 12px;
background: #e5e6eb;
color: #888;
font-size: 13px;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
padding: 1px 6px;
border-radius: 8px;
color: #ff4d4f;
background: #fff2f0;
border: 1px solid #ffccc7;
font-weight: 500;
}
.deviceInfoDetail {
display: flex;
flex-direction: column;
gap: 4px;
}
.infoItem {
display: flex;
align-items: center;
gap: 8px;
}
.infoLabel {
font-size: 13px;
color: #888;
margin-top: 4px;
color: #666;
min-width: 50px;
}
.infoValue {
font-size: 13px;
color: #333;
&.imei {
font-family: monospace;
}
&.friendCount {
font-weight: 500;
}
}
.loadingBox {
display: flex;

View File

@@ -46,6 +46,12 @@ const DeviceSelection: React.FC<DeviceSelectionProps> = ({
onSelect(selectedOptions.filter(v => v.id !== id));
};
// 清除所有已选设备
const handleClearAll = () => {
if (readonly) return;
onSelect([]);
};
return (
<>
{/* mode=input 显示输入框mode=dialog不显示 */}
@@ -57,6 +63,7 @@ const DeviceSelection: React.FC<DeviceSelectionProps> = ({
onClick={openPopup}
prefix={<SearchOutlined />}
allowClear={!readonly}
onClear={handleClearAll}
size="large"
readOnly={readonly}
disabled={readonly}
@@ -86,11 +93,52 @@ const DeviceSelection: React.FC<DeviceSelectionProps> = ({
style={{
display: "flex",
alignItems: "center",
padding: "4px 8px",
padding: "8px 12px",
borderBottom: "1px solid #f0f0f0",
fontSize: 14,
}}
>
{/* 头像 */}
<div
style={{
width: 40,
height: 40,
borderRadius: "6px",
background:
"linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
display: "flex",
alignItems: "center",
justifyContent: "center",
overflow: "hidden",
boxShadow: "0 2px 8px rgba(102, 126, 234, 0.25)",
marginRight: "12px",
flexShrink: 0,
}}
>
{device.avatar ? (
<img
src={device.avatar}
alt="头像"
style={{
width: "100%",
height: "100%",
objectFit: "cover",
}}
/>
) : (
<span
style={{
fontSize: 16,
color: "#fff",
fontWeight: 700,
textShadow: "0 1px 3px rgba(0,0,0,0.3)",
}}
>
{(device.memo || device.wechatId || "设")[0]}
</span>
)}
</div>
<div
style={{
flex: 1,
@@ -100,7 +148,7 @@ const DeviceSelection: React.FC<DeviceSelectionProps> = ({
textOverflow: "ellipsis",
}}
>
{device.memo} - {device.wechatId}
{device.memo} - {device.wechatId}
</div>
{!readonly && (
<Button

View File

@@ -51,6 +51,8 @@ const SelectionPopup: React.FC<SelectionPopupProps> = ({
wxid: d.wechatId || "",
nickname: d.nickname || "",
usedInPlans: d.usedInPlans || 0,
avatar: d.avatar || "",
totalFriend: d.totalFriend || 0,
})),
);
setTotal(res.total || 0);
@@ -161,31 +163,67 @@ const SelectionPopup: React.FC<SelectionPopupProps> = ({
) : (
<div className={style.deviceListInner}>
{filteredDevices.map(device => (
<label key={device.id} className={style.deviceItem}>
<Checkbox
checked={selectedOptions.some(v => v.id === device.id)}
onChange={() => handleDeviceToggle(device)}
className={style.deviceCheckbox}
/>
<div className={style.deviceInfo}>
<div className={style.deviceInfoRow}>
<span className={style.deviceName}>{device.memo}</span>
<div
className={
device.status === "online"
? style.statusOnline
: style.statusOffline
}
>
{device.status === "online" ? "在线" : "离线"}
<div key={device.id} className={style.deviceItem}>
{/* 顶部行选择框和IMEI */}
<div className={style.headerRow}>
<div className={style.checkboxContainer}>
<Checkbox
checked={selectedOptions.some(v => v.id === device.id)}
onChange={() => handleDeviceToggle(device)}
className={style.deviceCheckbox}
/>
</div>
<span className={style.imeiText}>
IMEI: {device.imei?.toUpperCase()}
</span>
</div>
{/* 主要内容区域:头像和详细信息 */}
<div className={style.mainContent}>
{/* 头像 */}
<div className={style.deviceAvatar}>
{device.avatar ? (
<img src={device.avatar} alt="头像" />
) : (
<span className={style.avatarText}>
{(device.memo || device.wechatId || "设")[0]}
</span>
)}
</div>
{/* 设备信息 */}
<div className={style.deviceContent}>
<div className={style.deviceInfoRow}>
<span className={style.deviceName}>{device.memo}</span>
<div
className={
device.status === "online"
? style.statusOnline
: style.statusOffline
}
>
{device.status === "online" ? "在线" : "离线"}
</div>
</div>
<div className={style.deviceInfoDetail}>
<div className={style.infoItem}>
<span className={style.infoLabel}>:</span>
<span className={style.infoValue}>
{device.wechatId}
</span>
</div>
<div className={style.infoItem}>
<span className={style.infoLabel}>:</span>
<span
className={`${style.infoValue} ${style.friendCount}`}
>
{device.totalFriend ?? "-"}
</span>
</div>
</div>
</div>
<div className={style.deviceInfoDetail}>
<div>IMEI: {device.imei}</div>
<div>: {device.wechatId}</div>
</div>
</div>
</label>
</div>
))}
</div>
)}

View File

@@ -0,0 +1,57 @@
import React from "react";
import ReactECharts from "echarts-for-react";
import { getChartColor } from "@/utils/chartColors";
interface LineChartProps {
title?: string;
xData: string[];
yData: any[];
height?: number | string;
}
const LineChart: React.FC<LineChartProps> = ({
title = "",
xData,
yData,
height = 200,
}) => {
const option = {
title: {
text: title,
left: "center",
textStyle: { fontSize: 16 },
},
tooltip: { trigger: "axis" },
xAxis: {
type: "category",
data: xData,
boundaryGap: false,
},
yAxis: {
type: "value",
boundaryGap: ["10%", "10%"], // 上下留白
min: (value: any) => value.min - 10, // 下方多留一点空间
max: (value: any) => value.max + 10, // 上方多留一点空间
minInterval: 1,
axisLabel: { margin: 12 },
},
series: [
...yData.map((item, index) => {
const color = getChartColor(index);
return {
data: item,
type: "line",
smooth: true,
symbol: "circle",
lineStyle: { color },
itemStyle: { color },
};
}),
],
grid: { left: 40, right: 24, top: 40, bottom: 32 },
};
return <ReactECharts option={option} style={{ height, width: "100%" }} />;
};
export default LineChart;

View File

@@ -1,22 +1,22 @@
import React, { useState, useEffect } from "react";
import { Button } from "antd-mobile";
import { updateChecker } from "@/utils/updateChecker";
import {
ReloadOutlined,
CloudDownloadOutlined,
RocketOutlined,
} from "@ant-design/icons";
import { ReloadOutlined } from "@ant-design/icons";
interface UpdateNotificationProps {
position?: "top" | "bottom";
autoReload?: boolean;
showToast?: boolean;
forceShow?: boolean;
onClose?: () => void;
}
const UpdateNotification: React.FC<UpdateNotificationProps> = ({
position = "top",
autoReload = false,
showToast = true,
forceShow = false,
onClose,
}) => {
const [hasUpdate, setHasUpdate] = useState(false);
const [isVisible, setIsVisible] = useState(false);
@@ -51,7 +51,19 @@ const UpdateNotification: React.FC<UpdateNotificationProps> = ({
updateChecker.forceReload();
};
if (!isVisible || !hasUpdate) {
const handleLater = () => {
setIsVisible(false);
onClose?.();
// 10分钟后再次检查
setTimeout(
() => {
updateChecker.start();
},
10 * 60 * 1000,
);
};
if ((!isVisible || !hasUpdate) && !forceShow) {
return null;
}
@@ -62,110 +74,135 @@ const UpdateNotification: React.FC<UpdateNotificationProps> = ({
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 99999,
background: "linear-gradient(135deg, #1890ff 0%, #096dd9 100%)",
background: "linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%)",
color: "white",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
padding: "20px",
textAlign: "center",
padding: "16px 16px",
paddingTop: "calc(16px + env(safe-area-inset-top))",
boxShadow: "0 4px 20px rgba(0, 0, 0, 0.5)",
borderBottom: "1px solid rgba(255, 255, 255, 0.1)",
animation: "slideDownBar 0.3s ease-out",
}}
>
{/* 背景装饰 */}
<div
style={{
position: "absolute",
top: "10%",
left: "50%",
transform: "translateX(-50%)",
fontSize: "120px",
opacity: 0.1,
animation: "float 3s ease-in-out infinite",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
maxWidth: "1200px",
margin: "0 auto",
}}
>
<RocketOutlined />
</div>
{/* 主要内容 */}
<div style={{ position: "relative", zIndex: 1 }}>
{/* 图标 */}
{/* 左侧内容 */}
<div
style={{
fontSize: "80px",
marginBottom: "20px",
animation: "pulse 2s ease-in-out infinite",
display: "flex",
alignItems: "center",
gap: "16px",
flex: 1,
}}
>
<CloudDownloadOutlined />
{/* 更新图标 */}
<div
style={{
width: "36px",
height: "36px",
background: "linear-gradient(135deg, #188eee 0%, #188eee 100%)",
borderRadius: "8px",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "18px",
animation: "pulse 2s ease-in-out infinite",
}}
>
<ReloadOutlined />
</div>
{/* 文本信息 */}
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: "14px",
fontWeight: "600",
marginBottom: "2px",
lineHeight: "1.2",
}}
>
</div>
<div
style={{
fontSize: "12px",
opacity: 0.8,
lineHeight: "1.3",
}}
>
</div>
</div>
</div>
{/* 标题 */}
{/* 右侧按钮组 */}
<div
style={{
fontSize: "28px",
fontWeight: "bold",
marginBottom: "12px",
textShadow: "0 2px 4px rgba(0,0,0,0.3)",
display: "flex",
alignItems: "center",
gap: "12px",
flexShrink: 0,
}}
>
</div>
{/* 描述 */}
<div
style={{
fontSize: "16px",
opacity: 0.9,
marginBottom: "40px",
lineHeight: "1.5",
maxWidth: "300px",
}}
>
</div>
{/* 更新按钮 */}
<Button
size="large"
style={{
background: "rgba(255,255,255,0.9)",
border: "2px solid rgba(255,255,255,0.5)",
color: "#1890ff",
fontSize: "18px",
fontWeight: "bold",
padding: "12px 40px",
borderRadius: "50px",
backdropFilter: "blur(10px)",
boxShadow: "0 8px 32px rgba(24,144,255,0.3)",
transition: "all 0.3s ease",
}}
onClick={handleReload}
>
<ReloadOutlined style={{ marginRight: "8px" }} />
</Button>
{/* 提示文字 */}
<div
style={{
fontSize: "12px",
opacity: 0.7,
marginTop: "20px",
}}
>
<Button
size="small"
style={{
background: "rgba(255, 255, 255, 0.1)",
border: "1px solid rgba(255, 255, 255, 0.2)",
color: "rgba(255, 255, 255, 0.8)",
fontSize: "12px",
fontWeight: "500",
borderRadius: "6px",
height: "32px",
minHeight: "32px",
padding: "0 12px",
minWidth: "56px",
}}
onClick={handleLater}
>
</Button>
<Button
size="small"
style={{
background: "linear-gradient(135deg, #1890ff 0%, #096dd9 100%)",
border: "none",
color: "white",
fontSize: "12px",
fontWeight: "600",
borderRadius: "6px",
height: "32px",
minHeight: "32px",
padding: "0 16px",
minWidth: "64px",
boxShadow: "0 2px 8px rgba(24, 144, 255, 0.3)",
}}
onClick={handleReload}
>
</Button>
</div>
</div>
{/* 动画样式 */}
<style>
{`
@keyframes float {
0%, 100% { transform: translateX(-50%) translateY(0px); }
50% { transform: translateX(-50%) translateY(-20px); }
@keyframes slideDownBar {
0% {
transform: translateY(-100%);
opacity: 0;
}
100% {
transform: translateY(0);
opacity: 1;
}
}
@keyframes pulse {
0%, 100% { transform: scale(1); }

View File

@@ -0,0 +1,250 @@
import React, { useEffect, useState } from "react";
import { Button, Card, List, Badge, Toast } from "antd-mobile";
import {
useWebSocketStore,
WebSocketStatus,
WebSocketMessage,
} from "@/store/module/websocket";
/**
* WebSocket使用示例组件
* 展示如何使用WebSocket store进行消息收发
*/
const WebSocketExample: React.FC = () => {
const [messageInput, setMessageInput] = useState("");
// 使用WebSocket store
const {
status,
messages,
unreadCount,
connect,
disconnect,
sendMessage,
sendCommand,
clearMessages,
markAsRead,
reconnect,
} = useWebSocketStore();
// 连接状态显示
const getStatusText = () => {
switch (status) {
case WebSocketStatus.DISCONNECTED:
return "未连接";
case WebSocketStatus.CONNECTING:
return "连接中...";
case WebSocketStatus.CONNECTED:
return "已连接";
case WebSocketStatus.RECONNECTING:
return "重连中...";
case WebSocketStatus.ERROR:
return "连接错误";
default:
return "未知状态";
}
};
// 获取状态颜色
const getStatusColor = () => {
switch (status) {
case WebSocketStatus.CONNECTED:
return "success";
case WebSocketStatus.CONNECTING:
case WebSocketStatus.RECONNECTING:
return "warning";
case WebSocketStatus.ERROR:
return "danger";
default:
return "default";
}
};
// 发送消息
const handleSendMessage = () => {
if (!messageInput.trim()) {
Toast.show({ content: "请输入消息内容", position: "top" });
return;
}
sendMessage({
type: "chat",
content: {
text: messageInput,
timestamp: Date.now(),
},
sender: "user",
receiver: "all",
});
setMessageInput("");
};
// 发送命令
const handleSendCommand = (cmdType: string) => {
sendCommand(cmdType, {
data: "示例数据",
timestamp: Date.now(),
});
};
// 格式化时间
const formatTime = (timestamp: number) => {
return new Date(timestamp).toLocaleTimeString();
};
return (
<div style={{ padding: "16px" }}>
<Card title="WebSocket 连接状态">
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<Badge color={getStatusColor()}>
<div style={{ width: "8px", height: "8px", borderRadius: "50%" }} />
</Badge>
<span>{getStatusText()}</span>
</div>
<div
style={{
marginTop: "16px",
display: "flex",
gap: "8px",
flexWrap: "wrap",
}}
>
<Button
size="small"
color="primary"
onClick={() =>
connect({
client: "kefu-client",
autoReconnect: true,
})
}
disabled={
status === WebSocketStatus.CONNECTING ||
status === WebSocketStatus.CONNECTED
}
>
</Button>
<Button
size="small"
color="danger"
onClick={disconnect}
disabled={status === WebSocketStatus.DISCONNECTED}
>
</Button>
<Button
size="small"
color="warning"
onClick={reconnect}
disabled={status === WebSocketStatus.CONNECTED}
>
</Button>
</div>
</Card>
<Card
title={`消息列表 ${unreadCount > 0 ? `(${unreadCount} 条未读)` : ""}`}
extra={
<div style={{ display: "flex", gap: "8px" }}>
<Button size="small" onClick={markAsRead}>
</Button>
<Button size="small" onClick={clearMessages}>
</Button>
</div>
}
style={{ marginTop: "16px" }}
>
<List style={{ maxHeight: "300px", overflowY: "auto" }}>
{messages.length === 0 ? (
<List.Item></List.Item>
) : (
messages.map((message: WebSocketMessage) => (
<List.Item key={message.id}>
<div style={{ fontSize: "12px", color: "#666" }}>
{formatTime(message.timestamp)} - {message.type}
</div>
<div style={{ marginTop: "4px" }}>
{typeof message.content === "string"
? message.content
: JSON.stringify(message.content, null, 2)}
</div>
</List.Item>
))
)}
</List>
</Card>
<Card title="发送消息" style={{ marginTop: "16px" }}>
<div style={{ display: "flex", gap: "8px", marginBottom: "12px" }}>
<input
type="text"
value={messageInput}
onChange={e => setMessageInput(e.target.value)}
placeholder="输入消息内容"
style={{
flex: 1,
padding: "8px 12px",
border: "1px solid #d9d9d9",
borderRadius: "4px",
fontSize: "14px",
}}
onKeyPress={e => e.key === "Enter" && handleSendMessage()}
/>
<Button
color="primary"
onClick={handleSendMessage}
disabled={status !== WebSocketStatus.CONNECTED}
>
</Button>
</div>
<div style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
<Button
size="small"
onClick={() => handleSendCommand("CmdHeartbeat")}
disabled={status !== WebSocketStatus.CONNECTED}
>
</Button>
<Button
size="small"
onClick={() => handleSendCommand("CmdGetStatus")}
disabled={status !== WebSocketStatus.CONNECTED}
>
</Button>
<Button
size="small"
onClick={() => handleSendCommand("CmdSignIn")}
disabled={status !== WebSocketStatus.CONNECTED}
>
</Button>
</div>
</Card>
<Card title="使用说明" style={{ marginTop: "16px" }}>
<div style={{ fontSize: "14px", lineHeight: "1.6", color: "#666" }}>
<p>1. "连接"WebSocket连接</p>
<p>2. </p>
<p>3. </p>
<p>4. </p>
<p>5. </p>
</div>
</Card>
</div>
);
};
export default WebSocketExample;

View File

@@ -2,7 +2,9 @@ import React from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import "./styles/global.scss";
// 引入错误处理器来抑制findDOMNode警告
// import VConsole from "vconsole";
// new VConsole();
const root = createRoot(document.getElementById("root")!);
root.render(<App />);

View File

@@ -7,7 +7,15 @@ import {
UserOutline,
} from "antd-mobile-icons";
import { useUserStore } from "@/store/module/user";
import { loginWithPassword, loginWithCode, sendVerificationCode } from "./api";
import { useCkChatStore } from "@/store/module/ckchat";
import { useWebSocketStore } from "@/store/module/websocket";
import {
loginWithPassword,
loginWithCode,
sendVerificationCode,
loginWithToken,
getChuKeBaoUserInfo,
} from "./api";
import style from "./login.module.scss";
const Login: React.FC = () => {
@@ -18,7 +26,8 @@ const Login: React.FC = () => {
const [showPassword, setShowPassword] = useState(false);
const [agreeToTerms, setAgreeToTerms] = useState(false);
const { login } = useUserStore();
const { login, login2 } = useUserStore();
const { setUserInfo, getAccountId } = useCkChatStore();
// 倒计时效果
useEffect(() => {
@@ -66,32 +75,70 @@ const Login: React.FC = () => {
Toast.show({ content: "请同意用户协议和隐私政策", position: "top" });
return;
}
getToken(values).then(() => {
getChuKeBaoUserInfo().then(res => {
setUserInfo(res);
getToken2().then(Token => {
// // 使用WebSocket store连接
// const { connect } = useWebSocketStore.getState();
// connect({
// accessToken: Token,
// accountId: getAccountId()?.toString() || "",
// client: "kefu-client",
// autoReconnect: true,
// reconnectInterval: 3000,
// maxReconnectAttempts: 5,
// });
});
});
setLoading(false);
});
};
setLoading(true);
try {
const getToken = (values: any) => {
return new Promise((resolve, reject) => {
// 添加typeId参数
const loginParams = {
...values,
typeId: activeTab as number,
};
let response;
if (activeTab === 1) {
response = await loginWithPassword(loginParams);
} else {
response = await loginWithCode(loginParams);
}
const response =
activeTab === 1
? loginWithPassword(loginParams)
: loginWithCode(loginParams);
// 获取设备总数
const deviceTotal = response.deviceTotal || 0;
response
.then(res => {
// 获取设备总数
const deviceTotal = res.deviceTotal || 0;
// 更新状态管理token会自动存储到localStorage用户信息存储在状态管理中
login(response.token, response.member, deviceTotal);
} catch (error: any) {
// 错误已在request中处理这里不需要额外处理
} finally {
setLoading(false);
}
// 更新状态管理token会自动存储到localStorage用户信息存储在状态管理中
login(res.token, res.member, deviceTotal);
resolve(res);
})
.catch(err => {
reject(err);
});
});
};
const getToken2 = () => {
return new Promise((resolve, reject) => {
const params = {
grant_type: "password",
password: "kr123456",
username: "kr_xf3",
};
const response = loginWithToken(params);
response.then(res => {
login2(res.access_token);
resolve(res.access_token);
});
response.catch(err => {
reject(err);
});
});
};
// 第三方登录处理

View File

@@ -1,33 +1,5 @@
import request from "@/api/request";
export interface LoginParams {
phone: string;
password?: string;
verificationCode?: string;
}
export interface LoginResponse {
code: number;
msg: string;
data: {
token: string;
token_expired: string;
deviceTotal: number; // 设备总数
member: {
id: string;
name: string;
phone: string;
s2_accountId: string;
avatar?: string;
email?: string;
};
};
}
export interface SendCodeResponse {
code: number;
msg: string;
}
import request2 from "@/api/request2";
// 密码登录
export function loginWithPassword(params: any) {
return request("/v1/auth/login", params, "POST");
@@ -52,3 +24,28 @@ export function logout() {
export function getUserInfo() {
return request("/v1/auth/user-info", {}, "GET");
}
// ==================================================================
// 触客宝接口; 2025年8月16日 17:19:15
// 开发yongpxu
// ==================================================================
//触客宝登陆
export function loginWithToken(params: any) {
return request2(
"/token",
params,
"POST",
{
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
},
1000,
);
}
// 获取触客宝用户信息
export function getChuKeBaoUserInfo() {
return request2("/api/account/self", {}, "GET");
}

View File

@@ -62,9 +62,7 @@ export default function ContentForm() {
setSelectedFriends(data.sourceFriends || []);
setSelectedGroups(data.selectedGroups || []);
setSelectedGroupsOptions(data.selectedGroupsOptions || []);
setSelectedFriendsOptions(data.sourceFriendsOptions || []);
setSelectedFriendsOptions(data.friendsGroupsOptions || []);
setKeywordsInclude((data.keywordInclude || []).join(","));
setKeywordsExclude((data.keywordExclude || []).join(","));
setAIPrompt(data.aiPrompt || "");

View File

@@ -132,7 +132,7 @@ const ContentLibraryList: React.FC = () => {
};
const handleEdit = (id: string) => {
navigate(`/content/edit/${id}`);
navigate(`/mine/content/edit/${id}`);
};
const handleDelete = async (id: string) => {

View File

@@ -0,0 +1,173 @@
.deviceList {
display: flex;
flex-direction: column;
gap: 12px;
}
.deviceCard {
background: #fff;
border-radius: 12px;
padding: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
transition: all 0.2s ease;
position: relative;
overflow: hidden;
&:hover {
transform: translateY(-1px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
}
&.selected {
border: 2px solid #1677ff;
}
&:not(.selected) {
border: 1px solid #f5f5f5;
}
}
.headerRow {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.checkboxContainer {
flex-shrink: 0;
}
.imeiText {
font-size: 13px;
color: #666;
font-family: monospace;
flex: 1;
}
.mainContent {
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
border-radius: 8px;
transition: background-color 0.2s ease;
&:hover {
background-color: #f8f9fa;
}
}
.avatar {
width: 64px;
height: 64px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.25);
flex-shrink: 0;
border-radius: 6px;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.avatarText {
font-size: 18px;
color: #fff;
font-weight: 700;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
}
.deviceInfo {
flex: 1;
min-width: 0;
}
.deviceHeader {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
}
.deviceName {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #1a1a1a;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.statusBadge {
font-size: 11px;
padding: 1px 6px;
border-radius: 8px;
font-weight: 500;
&.online {
color: #52c41a;
background: #f6ffed;
border: 1px solid #b7eb8f;
}
&.offline {
color: #ff4d4f;
background: #fff2f0;
border: 1px solid #ffccc7;
}
}
.infoList {
display: flex;
flex-direction: column;
gap: 4px;
}
.infoItem {
display: flex;
align-items: center;
gap: 8px;
}
.infoLabel {
font-size: 13px;
color: #666;
min-width: 50px;
}
.infoValue {
font-size: 13px;
color: #333;
&.friendCount {
font-weight: 500;
}
}
.arrowIcon {
color: #999;
font-size: 14px;
margin-left: auto;
transition: transform 0.2s ease;
}
.mainContent:hover .arrowIcon {
transform: translateX(3px);
color: #1677ff;
}
.paginationContainer {
padding: 16px;
background: #fff;
border-top: 1px solid #f0f0f0;
display: flex;
justify-content: center;
}

View File

@@ -7,6 +7,7 @@ import {
ReloadOutlined,
SearchOutlined,
QrcodeOutlined,
RightOutlined,
} from "@ant-design/icons";
import Layout from "@/components/Layout/Layout";
import {
@@ -19,6 +20,7 @@ import type { Device } from "@/types/device";
import { comfirm } from "@/utils/common";
import { useUserStore } from "@/store/module/user";
import NavCommon from "@/components/NavCommon";
import styles from "./index.module.scss";
const Devices: React.FC = () => {
// 设备列表相关
@@ -250,7 +252,7 @@ const Devices: React.FC = () => {
</>
}
footer={
<div className="pagination-container">
<div className={styles.paginationContainer}>
<Pagination
current={page}
pageSize={20}
@@ -264,65 +266,86 @@ const Devices: React.FC = () => {
>
<div style={{ padding: 12 }}>
{/* 设备列表 */}
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<div className={styles.deviceList}>
{filtered.map(device => (
<div
key={device.id}
style={{
background: "#fff",
borderRadius: 12,
padding: 12,
boxShadow: "0 1px 4px #eee",
display: "flex",
alignItems: "center",
cursor: "pointer",
border: selected.includes(device.id)
? "1.5px solid #1677ff"
: "1px solid #f0f0f0",
}}
onClick={() => goDetail(device.id!)}
>
<Checkbox
checked={selected.includes(device.id)}
onChange={e => {
e.stopPropagation();
setSelected(prev =>
e.target.checked
? [...prev, device.id!]
: prev.filter(id => id !== device.id),
);
}}
onClick={e => e.stopPropagation()}
style={{ marginRight: 12 }}
/>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 600, fontSize: 16 }}>
{device.memo || "未命名设备"}
</div>
<div style={{ fontSize: 14, color: "#999", marginTop: 2 }}>
IMEI: {device.imei}
</div>
<div style={{ fontSize: 14, color: "#999", marginTop: 2 }}>
: {device.wechatId || "未绑定"}
</div>
<div style={{ fontSize: 14, color: "#999", marginTop: 2 }}>
: {device.totalFriend ?? "-"}
<div key={device.id} className={styles.deviceCard}>
{/* 顶部行选择框和IMEI */}
<div className={styles.headerRow}>
<div className={styles.checkboxContainer}>
<Checkbox
checked={selected.includes(device.id)}
onChange={e => {
e.stopPropagation();
setSelected(prev =>
e.target.checked
? [...prev, device.id!]
: prev.filter(id => id !== device.id),
);
}}
onClick={e => e.stopPropagation()}
/>
</div>
<span className={styles.imeiText}>
IMEI: {device.imei?.toUpperCase()}
</span>
</div>
{/* 主要内容区域:头像和详细信息 */}
<div className={styles.mainContent}>
{/* 头像 */}
<div className={styles.avatar}>
{device.avatar ? (
<img src={device.avatar} alt="头像" />
) : (
<span className={styles.avatarText}>
{(device.memo || device.wechatId || "设")[0]}
</span>
)}
</div>
{/* 设备信息 */}
<div className={styles.deviceInfo}>
<div className={styles.deviceHeader}>
<h3 className={styles.deviceName}>
{device.memo || "未命名设备"}
</h3>
<span
className={`${styles.statusBadge} ${
device.status === "online" || device.alive === 1
? styles.online
: styles.offline
}`}
>
{device.status === "online" || device.alive === 1
? "在线"
: "离线"}
</span>
</div>
<div className={styles.infoList}>
<div className={styles.infoItem}>
<span className={styles.infoLabel}>:</span>
<span className={styles.infoValue}>
{device.wechatId || "未绑定"}
</span>
</div>
<div className={styles.infoItem}>
<span className={styles.infoLabel}>:</span>
<span
className={`${styles.infoValue} ${styles.friendCount}`}
>
{device.totalFriend ?? "-"}
</span>
</div>
</div>
</div>
{/* 箭头图标 */}
<RightOutlined
className={styles.arrowIcon}
onClick={() => goDetail(device.id!)}
/>
</div>
<span
style={{
fontSize: 12,
color:
device.status === "online" || device.alive === 1
? "#52c41a"
: "#aaa",
marginLeft: 8,
}}
>
{device.status === "online" || device.alive === 1
? "在线"
: "离线"}
</span>
</div>
))}

View File

@@ -3,3 +3,7 @@ import request from "@/api/request";
export function getDashboard() {
return request("/v1/dashboard", {}, "GET");
}
// 用户信息统计
export function getUserInfoStats() {
return request("/v1/dashboard/userInfoStats", {}, "GET");
}

View File

@@ -12,7 +12,7 @@ import MeauMobile from "@/components/MeauMobile/MeauMoible";
import Layout from "@/components/Layout/Layout";
import style from "./index.module.scss";
import { useUserStore } from "@/store/module/user";
import { getDashboard } from "./api";
import { getDashboard, getUserInfoStats } from "./api";
import NavCommon from "@/components/NavCommon";
const Mine: React.FC = () => {
const navigate = useNavigate();
@@ -24,6 +24,12 @@ const Mine: React.FC = () => {
content: 156,
balance: 0,
});
const [userInfoStats, setUserInfoStats] = useState({
contentLibraryNum: 0,
deviceNum: 0,
userNum: 0,
wechatNum: 0,
});
// 用户信息
const currentUserInfo = {
@@ -43,7 +49,7 @@ const Mine: React.FC = () => {
title: "设备管理",
description: "管理您的设备和微信账号",
icon: <PhoneOutlined />,
count: stats.devices,
count: userInfoStats.deviceNum,
path: "/mine/devices",
bgColor: "#e6f7ff",
iconColor: "#1890ff",
@@ -53,7 +59,7 @@ const Mine: React.FC = () => {
title: "微信号管理",
description: "管理微信账号和好友",
icon: <MessageOutlined />,
count: stats.wechat,
count: userInfoStats.wechatNum,
path: "/wechat-accounts",
bgColor: "#f6ffed",
iconColor: "#52c41a",
@@ -63,8 +69,8 @@ const Mine: React.FC = () => {
title: "流量池",
description: "管理用户流量池和分组",
icon: <DatabaseOutlined />,
count: stats.traffic,
path: "/traffic-pool",
count: userInfoStats.userNum,
path: "/mine/traffic-pool",
bgColor: "#f9f0ff",
iconColor: "#722ed1",
},
@@ -73,7 +79,7 @@ const Mine: React.FC = () => {
title: "内容库",
description: "管理营销内容和素材",
icon: <FolderOpenOutlined />,
count: stats.content,
count: userInfoStats.contentLibraryNum,
path: "/mine/content",
bgColor: "#fff7e6",
iconColor: "#fa8c16",
@@ -83,7 +89,7 @@ const Mine: React.FC = () => {
title: "触客宝",
description: "触客宝",
icon: <PhoneOutlined />,
count: stats.content,
count: 0,
path: "/mine/ckbox",
bgColor: "#fff7e6",
iconColor: "#fa8c16",
@@ -101,6 +107,8 @@ const Mine: React.FC = () => {
content: 999,
balance: res.balance || 0,
});
const res2 = await getUserInfoStats();
setUserInfoStats(res2);
} catch (error) {
console.error("加载统计数据失败:", error);
}

View File

@@ -1,118 +1,158 @@
import React from "react";
import React, { useState, useEffect } from "react";
import { Popup } from "antd-mobile";
import { Select, Button } from "antd";
import type {
DeviceOption,
PackageOption,
ValueLevel,
UserStatus,
} from "./data";
import DeviceSelection from "@/components/DeviceSelection";
import type { UserStatus, ScenarioOption } from "./data";
import { fetchScenarioOptions, fetchPackageOptions } from "./api";
import { DeviceSelectionItem } from "@/components/DeviceSelection/data";
interface FilterModalProps {
visible: boolean;
onClose: () => void;
deviceOptions: DeviceOption[];
packageOptions: PackageOption[];
deviceId: string;
setDeviceId: (v: string) => void;
packageId: string;
setPackageId: (v: string) => void;
valueLevel: ValueLevel;
setValueLevel: (v: ValueLevel) => void;
userStatus: UserStatus;
setUserStatus: (v: UserStatus) => void;
onReset: () => void;
onConfirm: (filters: {
deviceIds: string[];
packageId: string;
scenarioId: string;
userValue: number;
userStatus: number;
}) => void;
scenarioOptions: ScenarioOption[];
}
const valueLevelOptions = [
{ label: "全部价值", value: "all" },
{ label: "高价值", value: "high" },
{ label: "中价值", value: "medium" },
{ label: "低价值", value: "low" },
{ label: "全部价值", value: 0 },
{ label: "高价值", value: 1 },
{ label: "中价值", value: 2 },
{ label: "低价值", value: 3 },
];
const statusOptions = [
{ label: "全部状态", value: "all" },
{ label: "已添加", value: "added" },
{ label: "待添加", value: "pending" },
{ label: "添加失败", value: "failed" },
{ label: "重复", value: "duplicate" },
{ label: "全部状态", value: 0 },
{ label: "已添加", value: 1 },
{ label: "待添加", value: 2 },
{ label: "重复", value: 3 },
{ label: "添加失败", value: -1 },
];
const FilterModal: React.FC<FilterModalProps> = ({
visible,
onClose,
deviceOptions,
packageOptions,
deviceId,
setDeviceId,
packageId,
setPackageId,
valueLevel,
setValueLevel,
userStatus,
setUserStatus,
onReset,
}) => (
<Popup
visible={visible}
onMaskClick={onClose}
position="right"
bodyStyle={{ width: "80vw", maxWidth: 360, padding: 24 }}
>
<div style={{ fontWeight: 600, fontSize: 18, marginBottom: 20 }}>
</div>
<div style={{ marginBottom: 20 }}>
<div style={{ marginBottom: 6 }}></div>
<Select
style={{ width: "100%" }}
value={deviceId}
onChange={setDeviceId}
options={[
{ label: "全部设备", value: "all" },
...deviceOptions.map(d => ({ label: d.name, value: d.id })),
]}
/>
</div>
<div style={{ marginBottom: 20 }}>
<div style={{ marginBottom: 6 }}></div>
<Select
style={{ width: "100%" }}
value={packageId}
onChange={setPackageId}
options={[
{ label: "全部流量池", value: "all" },
...packageOptions.map(p => ({ label: p.name, value: p.id })),
]}
/>
</div>
<div style={{ marginBottom: 20 }}>
<div style={{ marginBottom: 6 }}></div>
<Select
style={{ width: "100%" }}
value={valueLevel}
onChange={v => setValueLevel(v as ValueLevel)}
options={valueLevelOptions}
/>
</div>
<div style={{ marginBottom: 20 }}>
<div style={{ marginBottom: 6 }}></div>
<Select
style={{ width: "100%" }}
value={userStatus}
onChange={v => setUserStatus(v as UserStatus)}
options={statusOptions}
/>
</div>
<div style={{ display: "flex", gap: 12, marginTop: 32 }}>
<Button onClick={onReset} style={{ flex: 1 }}>
</Button>
<Button type="primary" onClick={onClose} style={{ flex: 1 }}>
</Button>
</div>
</Popup>
);
onConfirm,
}) => {
const [selectedDevices, setSelectedDevices] = useState<DeviceSelectionItem[]>(
[],
);
const [packageId, setPackageId] = useState<string>("");
const [scenarioId, setScenarioId] = useState<string>("");
const [userValue, setUserValue] = useState<number>(0);
const [userStatus, setUserStatus] = useState<number>(0);
const [scenarioOptions, setScenarioOptions] = useState<any[]>([]);
const [packageOptions, setPackageOptions] = useState<any[]>([]);
useEffect(() => {
if (visible) {
fetchScenarioOptions().then(res => {
setScenarioOptions(res);
});
fetchPackageOptions().then(res => {
setPackageOptions(res);
});
}
}, [visible]);
const handleApply = () => {
const params = {
deviceIds: selectedDevices.map(d => d.id.toString()),
packageId,
scenarioId,
userValue,
userStatus,
};
console.log(params);
onConfirm(params);
onClose();
};
const handleReset = () => {
setSelectedDevices([]);
setPackageId("");
setScenarioId("");
setUserValue(0);
setUserStatus(0);
};
return (
<Popup
visible={visible}
onMaskClick={onClose}
position="right"
bodyStyle={{ width: "80vw", maxWidth: 360, padding: 24 }}
>
<div style={{ fontWeight: 600, fontSize: 18, marginBottom: 20 }}>
</div>
<div style={{ marginBottom: 20 }}>
<div style={{ marginBottom: 6 }}></div>
<DeviceSelection
selectedOptions={selectedDevices}
onSelect={setSelectedDevices}
placeholder="选择设备"
showSelectedList={false}
selectedListMaxHeight={120}
/>
</div>
<div style={{ marginBottom: 20 }}>
<div style={{ marginBottom: 6 }}></div>
<Select
style={{ width: "100%" }}
value={packageId}
onChange={setPackageId}
options={[
{ label: "全部流量池", value: "" },
...packageOptions.map(p => ({ label: p.name, value: p.id })),
]}
/>
</div>
<div style={{ marginBottom: 20 }}>
<div style={{ marginBottom: 6 }}></div>
<Select
style={{ width: "100%" }}
value={scenarioId}
onChange={setScenarioId}
options={[
{ label: "全部场景", value: "" },
...scenarioOptions.map(s => ({ label: s.name, value: s.id })),
]}
/>
</div>
<div style={{ marginBottom: 20 }}>
<div style={{ marginBottom: 6 }}></div>
<Select
style={{ width: "100%" }}
value={userValue}
onChange={v => setUserValue(v as number)}
options={valueLevelOptions}
/>
</div>
<div style={{ marginBottom: 20 }}>
<div style={{ marginBottom: 6 }}></div>
<Select
style={{ width: "100%" }}
value={userStatus}
onChange={v => setUserStatus(v as UserStatus)}
options={statusOptions}
/>
</div>
<div style={{ display: "flex", gap: 12, marginTop: 32 }}>
<Button onClick={handleReset} style={{ flex: 1 }}>
</Button>
<Button type="primary" onClick={handleApply} style={{ flex: 1 }}>
</Button>
</div>
</Popup>
);
};
export default FilterModal;

View File

@@ -9,11 +9,10 @@ export function fetchTrafficPoolList(params: {
return request("/v1/traffic/pool", params, "GET");
}
// 获取分组列表如无真实接口可用mock
export async function fetchPackageOptions(): Promise<any[]> {
// TODO: 替换为真实接口
return [
{ id: "pkg-1", name: "高价值客户池" },
{ id: "pkg-2", name: "测试流量池" },
];
export async function fetchScenarioOptions(): Promise<any[]> {
return request("/v1/plan/scenes", {}, "GET");
}
export async function fetchPackageOptions(): Promise<any[]> {
return request("/v1/traffic/pool/getPackage", {}, "GET");
}

View File

@@ -43,3 +43,9 @@ export type ValueLevel = "all" | "high" | "medium" | "low";
// 状态类型
export type UserStatus = "all" | "added" | "pending" | "failed" | "duplicate";
// 获客场景类型
export interface ScenarioOption {
id: string;
name: string;
}

View File

@@ -1,11 +1,16 @@
import { useState, useEffect, useMemo } from "react";
import { fetchTrafficPoolList, fetchPackageOptions } from "./api";
import {
fetchTrafficPoolList,
fetchPackageOptions,
fetchScenarioOptions,
} from "./api";
import type {
TrafficPoolUser,
DeviceOption,
PackageOption,
ValueLevel,
UserStatus,
ScenarioOption,
} from "./data";
import { Toast } from "antd-mobile";
@@ -19,12 +24,13 @@ export function useTrafficPoolListLogic() {
// 筛选相关
const [showFilter, setShowFilter] = useState(false);
const [deviceOptions, setDeviceOptions] = useState<DeviceOption[]>([]);
const [packageOptions, setPackageOptions] = useState<PackageOption[]>([]);
const [deviceId, setDeviceId] = useState<string>("all");
const [packageId, setPackageId] = useState<string>("all");
const [valueLevel, setValueLevel] = useState<ValueLevel>("all");
const [userStatus, setUserStatus] = useState<UserStatus>("all");
const [scenarioOptions, setScenarioOptions] = useState<ScenarioOption[]>([]);
const [selectedDevices, setSelectedDevices] = useState<any[]>([]);
const [packageId, setPackageId] = useState<number>(0);
const [scenarioId, setScenarioId] = useState<number>(0);
const [userValue, setUserValue] = useState<number>(0);
const [userStatus, setUserStatus] = useState<number>(0);
// 批量相关
const [selectedIds, setSelectedIds] = useState<number[]>([]);
@@ -47,15 +53,22 @@ export function useTrafficPoolListLogic() {
const getList = async () => {
setLoading(true);
try {
const res = await fetchTrafficPoolList({
const params: any = {
page,
pageSize,
keyword: search,
// deviceId,
// packageId,
// valueLevel,
// userStatus,
});
packageId,
taskId: scenarioId,
userValue,
addStatus: userStatus,
};
// 添加筛选参数
if (selectedDevices.length > 0) {
params.deviceId = selectedDevices.map(d => d.id).join(",");
}
const res = await fetchTrafficPoolList(params);
setList(res.list || []);
setTotal(res.total || 0);
} finally {
@@ -66,13 +79,22 @@ export function useTrafficPoolListLogic() {
// 获取筛选项
useEffect(() => {
fetchPackageOptions().then(setPackageOptions);
fetchScenarioOptions().then(setScenarioOptions);
}, []);
// 筛选条件变化时刷新列表
useEffect(() => {
getList();
// eslint-disable-next-line
}, [page, search /*, deviceId, packageId, valueLevel, userStatus*/]);
}, [
page,
search,
selectedDevices,
packageId,
scenarioId,
userValue,
userStatus,
]);
// 全选/反选
const handleSelectAll = (checked: boolean) => {
@@ -108,10 +130,11 @@ export function useTrafficPoolListLogic() {
// 筛选重置
const resetFilter = () => {
setDeviceId("all");
setPackageId("all");
setValueLevel("all");
setUserStatus("all");
setSelectedDevices([]);
setPackageId(0);
setScenarioId(0);
setUserValue(0);
setUserStatus(0);
};
return {
@@ -125,14 +148,16 @@ export function useTrafficPoolListLogic() {
setSearch,
showFilter,
setShowFilter,
deviceOptions,
packageOptions,
deviceId,
setDeviceId,
scenarioOptions,
selectedDevices,
setSelectedDevices,
packageId,
setPackageId,
valueLevel,
setValueLevel,
scenarioId,
setScenarioId,
userValue,
setUserValue,
userStatus,
setUserStatus,
selectedIds,

View File

@@ -5,9 +5,9 @@ import {
ReloadOutlined,
BarChartOutlined,
} from "@ant-design/icons";
import { Input, Button, Checkbox } from "antd";
import { Input, Button, Checkbox, Pagination } from "antd";
import styles from "./index.module.scss";
import { List, Empty, Avatar, Modal, Selector, Toast, Card } from "antd-mobile";
import { Empty, Avatar } from "antd-mobile";
import { useNavigate } from "react-router-dom";
import NavCommon from "@/components/NavCommon";
import { useTrafficPoolListLogic } from "./dataAnyx";
@@ -18,20 +18,6 @@ import BatchAddModal from "./BatchAddModal";
const defaultAvatar =
"https://cdn.jsdelivr.net/gh/maokaka/static/avatar-default.png";
const valueLevelOptions = [
{ label: "全部", value: "all" },
{ label: "高价值", value: "high" },
{ label: "中价值", value: "medium" },
{ label: "低价值", value: "low" },
];
const statusOptions = [
{ label: "全部", value: "all" },
{ label: "已添加", value: "added" },
{ label: "待添加", value: "pending" },
{ label: "添加失败", value: "failed" },
{ label: "重复", value: "duplicate" },
];
const TrafficPoolList: React.FC = () => {
const navigate = useNavigate();
const {
@@ -45,15 +31,12 @@ const TrafficPoolList: React.FC = () => {
setSearch,
showFilter,
setShowFilter,
deviceOptions,
packageOptions,
deviceId,
setDeviceId,
packageId,
scenarioOptions,
setSelectedDevices,
setPackageId,
valueLevel,
setValueLevel,
userStatus,
setScenarioId,
setUserValue,
setUserStatus,
selectedIds,
handleSelectAll,
@@ -67,7 +50,6 @@ const TrafficPoolList: React.FC = () => {
setShowStats,
stats,
getList,
resetFilter,
} = useTrafficPoolListLogic();
return (
@@ -154,6 +136,17 @@ const TrafficPoolList: React.FC = () => {
</div>
</>
}
footer={
<div className="pagination-container">
<Pagination
current={page}
pageSize={20}
total={total}
showSizeChanger={false}
onChange={setPage}
/>
</div>
}
>
{/* 批量加入分组弹窗 */}
<BatchAddModal
@@ -169,17 +162,25 @@ const TrafficPoolList: React.FC = () => {
<FilterModal
visible={showFilter}
onClose={() => setShowFilter(false)}
deviceOptions={deviceOptions}
packageOptions={packageOptions}
deviceId={deviceId}
setDeviceId={setDeviceId}
packageId={packageId}
setPackageId={setPackageId}
valueLevel={valueLevel}
setValueLevel={setValueLevel}
userStatus={userStatus}
setUserStatus={setUserStatus}
onReset={resetFilter}
onConfirm={filters => {
// 更新筛选条件
setSelectedDevices(
filters.deviceIds.map(id => ({
id: parseInt(id),
memo: "",
imei: "",
wechatId: "",
status: "offline" as const,
})),
);
setPackageId(filters.packageId);
setScenarioId(filters.scenarioId);
setUserValue(filters.userValue);
setUserStatus(filters.userStatus);
// 重新获取列表
getList();
}}
scenarioOptions={scenarioOptions}
/>
<div className={styles.listWrap}>
{list.length === 0 && !loading ? (
@@ -192,7 +193,9 @@ const TrafficPoolList: React.FC = () => {
className={styles.card}
style={{ cursor: "pointer" }}
onClick={() =>
navigate(`/traffic-pool/detail/${item.sourceId}/${item.id}`)
navigate(
`/mine/traffic-pool/detail/${item.sourceId}/${item.id}`,
)
}
>
<div className={styles.cardContent}>
@@ -235,23 +238,6 @@ const TrafficPoolList: React.FC = () => {
</div>
)}
</div>
{/* 分页 */}
{total > pageSize && (
<div className={styles.pagination}>
<button disabled={page === 1} onClick={() => setPage(page - 1)}>
</button>
<span>
{page} / {Math.ceil(total / pageSize)}
</span>
<button
disabled={page === Math.ceil(total / pageSize)}
onClick={() => setPage(page + 1)}
>
</button>
</div>
)}
</Layout>
);
};

View File

@@ -30,3 +30,12 @@ export function deletePlan(planId: string): Promise<ApiResponse<any>> {
export function getWxMinAppCode(planId: string): Promise<ApiResponse<string>> {
return request(`/v1/plan/getWxMinAppCode`, { taskId: planId }, "GET");
}
//获客列表
export function getUserList(planId: string, type: number) {
return request(`/v1/plan/getUserList`, { planId, type }, "GET");
}
//获客列表
export function getFriendRequestTaskStats(taskId: string) {
return request(`/v1/dashboard/friendRequestTaskStats`, { taskId }, "GET");
}

View File

@@ -0,0 +1,175 @@
import React, { useEffect, useState } from "react";
import { Popup, Avatar, SpinLoading } from "antd-mobile";
import { Button, message } from "antd";
import { CloseOutlined } from "@ant-design/icons";
import style from "./Popups.module.scss";
import { getUserList } from "../api";
interface AccountItem {
id: string | number;
nickname?: string;
wechatId?: string;
avatar?: string;
status?: string;
userinfo: {
alias: string;
nickname: string;
avatar: string;
wechatId: string;
};
phone?: string;
}
interface AccountListModalProps {
visible: boolean;
onClose: () => void;
ruleId?: number;
ruleName?: string;
}
const AccountListModal: React.FC<AccountListModalProps> = ({
visible,
onClose,
ruleId,
ruleName,
}) => {
const [accounts, setAccounts] = useState<AccountItem[]>([]);
const [loading, setLoading] = useState(false);
// 获取账号数据
const fetchAccounts = async () => {
if (!ruleId) return;
setLoading(true);
try {
const detailRes = await getUserList(ruleId.toString(), 1);
const accountData = detailRes?.list || [];
setAccounts(accountData);
} catch (error) {
console.error("获取账号详情失败:", error);
message.error("获取账号详情失败");
} finally {
setLoading(false);
}
};
// 当弹窗打开且有ruleId时获取数据
useEffect(() => {
if (visible && ruleId) {
fetchAccounts();
}
}, [visible, ruleId]);
const title = ruleName ? `${ruleName} - 已添加账号列表` : "已添加账号列表";
const getStatusColor = (status?: string) => {
switch (status) {
case "normal":
return "#52c41a";
case "limited":
return "#faad14";
case "blocked":
return "#ff4d4f";
default:
return "#d9d9d9";
}
};
const getStatusText = (status?: string) => {
switch (status) {
case "normal":
return "正常";
case "limited":
return "受限";
case "blocked":
return "封禁";
default:
return "未知";
}
};
return (
<Popup
visible={visible}
onMaskClick={onClose}
position="bottom"
bodyStyle={{
height: "70vh",
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
}}
>
<div className={style.accountModal}>
{/* 头部 */}
<div className={style.accountModalHeader}>
<h3 className={style.accountModalTitle}>{title}</h3>
<Button
type="text"
icon={<CloseOutlined />}
onClick={onClose}
className={style.accountModalClose}
/>
</div>
{/* 账号列表 */}
<div className={style.accountList}>
{loading ? (
<div className={style.accountLoading}>
<SpinLoading color="primary" />
<div className={style.accountLoadingText}>
...
</div>
</div>
) : accounts.length > 0 ? (
accounts.map((account, index) => (
<div key={account.id || index} className={style.accountItem}>
<div className={style.accountAvatar}>
<Avatar
src={account.userinfo.avatar}
style={{ "--size": "48px" }}
fallback={
(account.userinfo.nickname ||
account.userinfo.alias ||
"账号")[0]
}
/>
</div>
<div className={style.accountInfo}>
<div className={style.accountName}>
{account.userinfo.nickname ||
account.userinfo.alias ||
`账号${account.id}`}
</div>
<div className={style.accountWechatId}>
{account.userinfo.wechatId || "未绑定微信号"}
</div>
</div>
<div className={style.accountStatus}>
<span
className={style.statusDot}
style={{ backgroundColor: getStatusColor(account.status) }}
/>
<span className={style.statusText}>
{getStatusText(account.status)}
</span>
</div>
</div>
))
) : (
<div className={style.accountEmpty}>
<div className={style.accountEmptyText}></div>
</div>
)}
</div>
{/* 底部统计 */}
<div className={style.accountModalFooter}>
<div className={style.accountStats}>
<span> {accounts.length} </span>
</div>
</div>
</div>
</Popup>
);
};
export default AccountListModal;

View File

@@ -0,0 +1,175 @@
import React, { useEffect, useState } from "react";
import { Popup, Avatar, SpinLoading } from "antd-mobile";
import { Button, message } from "antd";
import { CloseOutlined } from "@ant-design/icons";
import style from "./Popups.module.scss";
import { getPlanDetail } from "../api";
interface DeviceItem {
id: string | number;
memo?: string;
imei?: string;
wechatId?: string;
status?: "online" | "offline";
avatar?: string;
totalFriend?: number;
}
interface DeviceListModalProps {
visible: boolean;
onClose: () => void;
ruleId?: number;
ruleName?: string;
}
const DeviceListModal: React.FC<DeviceListModalProps> = ({
visible,
onClose,
ruleId,
ruleName,
}) => {
const [devices, setDevices] = useState<DeviceItem[]>([]);
const [loading, setLoading] = useState(false);
// 获取设备数据
const fetchDevices = async () => {
if (!ruleId) return;
setLoading(true);
try {
const detailRes = await getPlanDetail(ruleId.toString());
const deviceData = detailRes?.deveiceGroupsOptions || [];
setDevices(deviceData);
} catch (error) {
console.error("获取设备详情失败:", error);
message.error("获取设备详情失败");
} finally {
setLoading(false);
}
};
// 当弹窗打开且有ruleId时获取数据
useEffect(() => {
if (visible && ruleId) {
fetchDevices();
}
}, [visible, ruleId]);
const title = ruleName ? `${ruleName} - 分发设备列表` : "分发设备列表";
const getStatusColor = (status?: string) => {
return status === "online" ? "#52c41a" : "#ff4d4f";
};
const getStatusText = (status?: string) => {
return status === "online" ? "在线" : "离线";
};
return (
<Popup
visible={visible}
onMaskClick={onClose}
position="bottom"
bodyStyle={{
height: "70vh",
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
}}
>
<div className={style.deviceModal}>
{/* 头部 */}
<div className={style.deviceModalHeader}>
<h3 className={style.deviceModalTitle}>{title}</h3>
<Button
type="text"
icon={<CloseOutlined />}
onClick={onClose}
className={style.deviceModalClose}
/>
</div>
{/* 设备列表 */}
<div className={style.deviceList}>
{loading ? (
<div className={style.deviceLoading}>
<SpinLoading color="primary" />
<div className={style.deviceLoadingText}>...</div>
</div>
) : devices.length > 0 ? (
devices.map((device, index) => (
<div key={device.id || index} className={style.deviceItem}>
{/* 顶部行IMEI */}
<div className={style.deviceHeaderRow}>
<span className={style.deviceImeiText}>
IMEI: {device.imei?.toUpperCase() || "-"}
</span>
</div>
{/* 主要内容区域:头像和详细信息 */}
<div className={style.deviceMainContent}>
{/* 头像 */}
<div className={style.deviceAvatar}>
{device.avatar ? (
<img src={device.avatar} alt="头像" />
) : (
<span className={style.deviceAvatarText}>
{(device.memo || device.wechatId || "设")[0]}
</span>
)}
</div>
{/* 设备信息 */}
<div className={style.deviceInfo}>
<div className={style.deviceInfoHeader}>
<h3 className={style.deviceName}>
{device.memo || "未命名设备"}
</h3>
<span
className={`${style.deviceStatusBadge} ${
device.status === "online"
? style.deviceStatusOnline
: style.deviceStatusOffline
}`}
>
{getStatusText(device.status)}
</span>
</div>
<div className={style.deviceInfoList}>
<div className={style.deviceInfoItem}>
<span className={style.deviceInfoLabel}>:</span>
<span className={style.deviceInfoValue}>
{device.wechatId || "未绑定"}
</span>
</div>
<div className={style.deviceInfoItem}>
<span className={style.deviceInfoLabel}>:</span>
<span
className={`${style.deviceInfoValue} ${style.deviceFriendCount}`}
>
{device.totalFriend ?? "-"}
</span>
</div>
</div>
</div>
</div>
</div>
))
) : (
<div className={style.deviceEmpty}>
<div className={style.deviceEmptyText}></div>
</div>
)}
</div>
{/* 底部统计 */}
<div className={style.deviceModalFooter}>
<div className={style.deviceStats}>
<span> {devices.length} </span>
</div>
</div>
</div>
</Popup>
);
};
export default DeviceListModal;

View File

@@ -0,0 +1,175 @@
import React, { useEffect, useState } from "react";
import { Popup, Avatar, SpinLoading } from "antd-mobile";
import { Button, message } from "antd";
import { CloseOutlined } from "@ant-design/icons";
import style from "./Popups.module.scss";
import { getUserList } from "../api";
interface AccountItem {
id: string | number;
nickname?: string;
wechatId?: string;
avatar?: string;
status?: string;
userinfo: {
alias: string;
nickname: string;
avatar: string;
wechatId: string;
};
phone?: string;
}
interface AccountListModalProps {
visible: boolean;
onClose: () => void;
ruleId?: number;
ruleName?: string;
}
const AccountListModal: React.FC<AccountListModalProps> = ({
visible,
onClose,
ruleId,
ruleName,
}) => {
const [accounts, setAccounts] = useState<AccountItem[]>([]);
const [loading, setLoading] = useState(false);
// 获取账号数据
const fetchAccounts = async () => {
if (!ruleId) return;
setLoading(true);
try {
const detailRes = await getUserList(ruleId.toString(), 2);
const accountData = detailRes?.list || [];
setAccounts(accountData);
} catch (error) {
console.error("获取账号详情失败:", error);
message.error("获取账号详情失败");
} finally {
setLoading(false);
}
};
// 当弹窗打开且有ruleId时获取数据
useEffect(() => {
if (visible && ruleId) {
fetchAccounts();
}
}, [visible, ruleId]);
const title = ruleName ? `${ruleName} - 已添加账号列表` : "已添加账号列表";
const getStatusColor = (status?: string) => {
switch (status) {
case "normal":
return "#52c41a";
case "limited":
return "#faad14";
case "blocked":
return "#ff4d4f";
default:
return "#d9d9d9";
}
};
const getStatusText = (status?: string) => {
switch (status) {
case "normal":
return "正常";
case "limited":
return "受限";
case "blocked":
return "封禁";
default:
return "未知";
}
};
return (
<Popup
visible={visible}
onMaskClick={onClose}
position="bottom"
bodyStyle={{
height: "70vh",
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
}}
>
<div className={style.accountModal}>
{/* 头部 */}
<div className={style.accountModalHeader}>
<h3 className={style.accountModalTitle}>{title}</h3>
<Button
type="text"
icon={<CloseOutlined />}
onClick={onClose}
className={style.accountModalClose}
/>
</div>
{/* 账号列表 */}
<div className={style.accountList}>
{loading ? (
<div className={style.accountLoading}>
<SpinLoading color="primary" />
<div className={style.accountLoadingText}>
...
</div>
</div>
) : accounts.length > 0 ? (
accounts.map((account, index) => (
<div key={account.id || index} className={style.accountItem}>
<div className={style.accountAvatar}>
<Avatar
src={account.userinfo.avatar}
style={{ "--size": "48px" }}
fallback={
(account.userinfo.nickname ||
account.userinfo.alias ||
"账号")[0]
}
/>
</div>
<div className={style.accountInfo}>
<div className={style.accountName}>
{account.userinfo.nickname ||
account.userinfo.alias ||
`账号${account.id}`}
</div>
<div className={style.accountWechatId}>
{account.userinfo.wechatId || "未绑定微信号"}
</div>
</div>
<div className={style.accountStatus}>
<span
className={style.statusDot}
style={{ backgroundColor: getStatusColor(account.status) }}
/>
<span className={style.statusText}>
{getStatusText(account.status)}
</span>
</div>
</div>
))
) : (
<div className={style.accountEmpty}>
<div className={style.accountEmptyText}></div>
</div>
)}
</div>
{/* 底部统计 */}
<div className={style.accountModalFooter}>
<div className={style.accountStats}>
<span> {accounts.length} </span>
</div>
</div>
</div>
</Popup>
);
};
export default AccountListModal;

View File

@@ -0,0 +1,161 @@
import React, { useEffect, useState } from "react";
import { Popup, SpinLoading } from "antd-mobile";
import { Button, message } from "antd";
import { CloseOutlined } from "@ant-design/icons";
import style from "./Popups.module.scss";
import { getFriendRequestTaskStats } from "../api";
import LineChart2 from "@/components/LineChart2";
interface StatisticsData {
totalAll: number;
totalError: number;
totalPass: number;
totalPassRate: number;
totalSuccess: number;
totalSuccessRate: number;
}
interface PoolListModalProps {
visible: boolean;
onClose: () => void;
ruleId?: number;
ruleName?: string;
}
const PoolListModal: React.FC<PoolListModalProps> = ({
visible,
onClose,
ruleId,
ruleName,
}) => {
const [statistics, setStatistics] = useState<StatisticsData>({
totalAll: 0,
totalError: 0,
totalPass: 0,
totalPassRate: 0,
totalSuccess: 0,
totalSuccessRate: 0,
});
const [xData, setXData] = useState<any[]>([]);
const [yData, setYData] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
// 当弹窗打开且有ruleId时获取数据
useEffect(() => {
if (visible && ruleId) {
setLoading(true);
getFriendRequestTaskStats(ruleId.toString())
.then(res => {
console.log(res);
setXData(res.dateArray);
setYData([
res.allNumArray,
res.errorNumArray,
res.passNumArray,
res.passRateArray,
res.successNumArray,
res.successRateArray,
]);
setStatistics(res.totalStats);
setLoading(false);
})
.finally(() => {
setLoading(false);
});
}
}, [visible, ruleId]);
const title = ruleName ? `${ruleName} - 累计统计数据` : "累计统计数据";
return (
<Popup
visible={visible}
onMaskClick={onClose}
position="bottom"
bodyStyle={{
height: "70vh",
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
}}
>
<div className={style.poolModal}>
{/* 头部 */}
<div className={style.poolModalHeader}>
<h3 className={style.poolModalTitle}>{title}</h3>
<Button
type="text"
icon={<CloseOutlined />}
onClick={onClose}
className={style.poolModalClose}
/>
</div>
{/* 统计数据表格 */}
<div className={style.statisticsContent}>
{loading ? (
<div className={style.statisticsLoading}>
<SpinLoading color="primary" />
<div className={style.statisticsLoadingText}>
...
</div>
</div>
) : (
<div className={style.statisticsTable}>
<div className={style.statisticsRow}>
<div className={style.statisticsLabel}></div>
<div className={style.statisticsValue}>
{statistics.totalAll}
</div>
</div>
<div className={style.statisticsRow}>
<div className={style.statisticsLabel}></div>
<div className={style.statisticsValue}>
{statistics.totalError}
</div>
</div>
<div className={style.statisticsRow}>
<div className={style.statisticsLabel}></div>
<div className={style.statisticsValue}>
{statistics.totalSuccess}
</div>
</div>
<div className={style.statisticsRow}>
<div className={style.statisticsLabel}></div>
<div className={style.statisticsValue}>
{statistics.totalError}
</div>
</div>
<div className={style.statisticsRow}>
<div className={style.statisticsLabel}></div>
<div className={style.statisticsValue}>
{statistics.totalPass}
</div>
</div>
<div className={style.statisticsRow}>
<div className={style.statisticsLabel}></div>
<div className={style.statisticsValue}>
{statistics.totalSuccessRate}%
</div>
</div>
<div className={style.statisticsRow}>
<div className={style.statisticsLabel}></div>
<div className={style.statisticsValue}>
{statistics.totalPassRate}%
</div>
</div>
</div>
)}
</div>
{/* 趋势图占位 */}
<div className={style.trendChart}>
<div className={style.chartTitle}></div>
<div className={style.chartPlaceholder}>
<LineChart2 title="趋势图" xData={xData} yData={yData} />
</div>
</div>
</div>
</Popup>
);
};
export default PoolListModal;

View File

@@ -0,0 +1,744 @@
.listToolbar {
display: flex;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid #f0f0f0;
background: #fff;
font-size: 16px;
color: #222;
}
.ruleList {
padding: 0 16px;
}
.ruleCard {
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
margin-bottom: 20px;
padding: 16px;
border: 1px solid #ececec;
transition:
box-shadow 0.2s,
border-color 0.2s;
position: relative;
}
.ruleCard:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
border-color: #b3e5fc;
}
.ruleHeader {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.ruleName {
font-size: 17px;
font-weight: 600;
color: #222;
}
.ruleStatus {
display: flex;
align-items: center;
gap: 8px;
}
.ruleSwitch {
margin-left: 4px;
}
.ruleMenu {
margin-left: 8px;
cursor: pointer;
color: #888;
font-size: 18px;
}
.ruleMeta {
display: flex;
justify-content: space-between;
margin-bottom: 10px;
font-size: 15px;
color: #444;
font-weight: 500;
}
.ruleMetaItem {
flex: 1;
text-align: center;
transition: background-color 0.2s ease;
}
.ruleMetaItem:not(:last-child) {
border-right: 1px solid #f0f0f0;
}
.ruleMetaItem:hover {
background-color: #f8f9fa;
border-radius: 6px;
}
.ruleDivider {
border-top: 1px solid #f0f0f0;
margin: 12px 0 10px 0;
}
.ruleStats {
display: flex;
justify-content: space-between;
font-size: 16px;
color: #222;
font-weight: 600;
margin-bottom: 8px;
}
.ruleStatsItem {
flex: 1;
text-align: center;
}
.ruleStatsItem:not(:last-child) {
border-right: 1px solid #f0f0f0;
}
.ruleFooter {
display: flex;
justify-content: space-between;
font-size: 12px;
color: #888;
margin-top: 6px;
align-items: center;
}
.ruleFooterIcon {
margin-right: 4px;
vertical-align: middle;
font-size: 15px;
position: relative;
top: -2px;
}
.empty {
text-align: center;
color: #bbb;
padding: 40px 0;
}
.pagination {
display: flex;
justify-content: center;
padding: 16px 0;
background: #fff;
}
// 账号列表弹窗样式
.accountModal {
height: 100%;
display: flex;
flex-direction: column;
}
.accountModalHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid #f0f0f0;
background: #fff;
}
.accountModalTitle {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #222;
}
.accountModalClose {
border: none;
background: none;
color: #888;
font-size: 16px;
}
.accountList {
flex: 1;
overflow-y: auto;
padding: 0 20px;
}
.accountItem {
display: flex;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid #f5f5f5;
}
.accountItem:last-child {
border-bottom: none;
}
.accountAvatar {
margin-right: 12px;
flex-shrink: 0;
}
.accountInfo {
flex: 1;
min-width: 0;
}
.accountName {
font-size: 16px;
font-weight: 500;
color: #222;
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.accountWechatId {
font-size: 14px;
color: #888;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.accountStatus {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.statusDot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.statusText {
font-size: 13px;
color: #666;
}
.accountEmpty {
display: flex;
align-items: center;
justify-content: center;
height: 200px;
color: #888;
}
.accountEmptyText {
font-size: 16px;
}
.accountLoading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 200px;
gap: 16px;
padding: 20px;
}
.accountLoadingText {
font-size: 15px;
color: #666;
font-weight: 500;
}
.accountModalFooter {
padding: 16px 20px;
border-top: 1px solid #f0f0f0;
background: #fff;
}
.accountStats {
text-align: center;
font-size: 14px;
color: #666;
}
// 设备列表弹窗样式
.deviceModal {
height: 100%;
display: flex;
flex-direction: column;
}
.deviceModalHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid #f0f0f0;
background: #fff;
}
.deviceModalTitle {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #222;
}
.deviceModalClose {
border: none;
background: none;
color: #888;
font-size: 16px;
}
.deviceList {
flex: 1;
overflow-y: auto;
padding: 0 20px;
}
.deviceItem {
background: #fff;
border-radius: 12px;
padding: 12px;
margin-bottom: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
border: 1px solid #ececec;
transition: all 0.2s ease;
}
.deviceItem:hover {
transform: translateY(-1px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
}
.deviceHeaderRow {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.deviceImeiText {
font-size: 13px;
color: #888;
font-weight: 500;
}
.deviceMainContent {
display: flex;
align-items: center;
}
.deviceAvatar {
width: 48px;
height: 48px;
border-radius: 12px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
margin-right: 12px;
flex-shrink: 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.deviceAvatar img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 12px;
}
.deviceAvatarText {
color: #fff;
font-size: 18px;
font-weight: 600;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
.deviceInfo {
flex: 1;
min-width: 0;
}
.deviceInfoHeader {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.deviceName {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #222;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
margin-right: 8px;
}
.deviceStatusBadge {
padding: 2px 8px;
border-radius: 10px;
font-size: 12px;
font-weight: 500;
flex-shrink: 0;
}
.deviceStatusOnline {
background: rgba(82, 196, 26, 0.1);
color: #52c41a;
}
.deviceStatusOffline {
background: rgba(255, 77, 79, 0.1);
color: #ff4d4f;
}
.deviceInfoList {
display: flex;
flex-direction: column;
gap: 4px;
}
.deviceInfoItem {
display: flex;
align-items: center;
font-size: 13px;
}
.deviceInfoLabel {
color: #888;
margin-right: 6px;
min-width: 50px;
}
.deviceInfoValue {
color: #444;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.deviceFriendCount {
color: #1890ff;
font-weight: 500;
}
.deviceEmpty {
display: flex;
align-items: center;
justify-content: center;
height: 200px;
color: #888;
}
.deviceEmptyText {
font-size: 16px;
}
.deviceLoading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 200px;
gap: 16px;
padding: 20px;
}
.deviceLoadingText {
font-size: 15px;
color: #666;
font-weight: 500;
}
.deviceModalFooter {
padding: 16px 20px;
border-top: 1px solid #f0f0f0;
background: #fff;
}
.deviceStats {
text-align: center;
font-size: 14px;
color: #666;
}
// 流量池列表弹窗样式
.poolModal {
height: 100%;
display: flex;
flex-direction: column;
}
.poolModalHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid #f0f0f0;
background: #fff;
}
.poolModalTitle {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #222;
}
.poolModalClose {
border: none;
background: none;
color: #888;
font-size: 16px;
}
.poolList {
flex: 1;
overflow-y: auto;
padding: 0 20px;
}
.poolItem {
background: #fff;
border-radius: 12px;
padding: 12px;
margin-bottom: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
border: 1px solid #ececec;
transition: all 0.2s ease;
}
.poolItem:hover {
transform: translateY(-1px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
}
.poolMainContent {
display: flex;
align-items: flex-start;
}
.poolIcon {
width: 48px;
height: 48px;
border-radius: 12px;
background: linear-gradient(135deg, #1890ff 0%, #722ed1 100%);
display: flex;
align-items: center;
justify-content: center;
margin-right: 12px;
flex-shrink: 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.poolIconText {
color: #fff;
font-size: 18px;
font-weight: 600;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
.poolInfo {
flex: 1;
min-width: 0;
}
.poolInfoHeader {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.poolName {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #222;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
margin-right: 8px;
}
.poolUserCount {
padding: 2px 8px;
border-radius: 10px;
font-size: 12px;
font-weight: 500;
background: rgba(24, 144, 255, 0.1);
color: #1890ff;
flex-shrink: 0;
}
.poolInfoList {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 8px;
}
.poolInfoItem {
display: flex;
align-items: center;
font-size: 13px;
}
.poolInfoLabel {
color: #888;
margin-right: 6px;
min-width: 60px;
}
.poolInfoValue {
color: #444;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.poolTags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.poolTag {
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
background: rgba(0, 0, 0, 0.05);
color: #666;
border: 1px solid rgba(0, 0, 0, 0.1);
}
.poolLoading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 200px;
gap: 16px;
padding: 20px;
}
.poolLoadingText {
font-size: 15px;
color: #666;
font-weight: 500;
}
.poolEmpty {
display: flex;
align-items: center;
justify-content: center;
height: 200px;
color: #888;
}
.poolEmptyText {
font-size: 16px;
}
.poolModalFooter {
padding: 16px 20px;
border-top: 1px solid #f0f0f0;
background: #fff;
}
.poolStats {
text-align: center;
font-size: 14px;
color: #666;
}
// 统计数据弹窗样式
.statisticsContent {
flex: 1;
overflow-y: auto;
padding: 0 20px;
}
.statisticsLoading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 200px;
gap: 16px;
padding: 20px;
}
.statisticsLoadingText {
font-size: 15px;
color: #666;
font-weight: 500;
}
.statisticsTable {
padding: 16px 0;
}
.statisticsRow {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 0;
border-bottom: 1px solid #f5f5f5;
}
.statisticsRow:last-child {
border-bottom: none;
}
.statisticsLabel {
font-size: 15px;
color: #666;
font-weight: 500;
}
.statisticsValue {
font-size: 16px;
color: #222;
font-weight: 600;
}
.trendChart {
padding: 20px;
border-top: 1px solid #f0f0f0;
background: #fff;
}
.chartTitle {
font-size: 16px;
font-weight: 600;
color: #222;
margin-bottom: 16px;
}
.chartPlaceholder {
height: 200px;
background: #f8f9fa;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
border: 2px dashed #d9d9d9;
}
.chartNote {
font-size: 14px;
color: #888;
text-align: center;
}

View File

@@ -20,6 +20,7 @@ export interface Task {
acquiredCount?: number;
addedCount?: number;
passRate?: number;
passCount?: number;
}
export interface ApiSettings {

View File

@@ -139,6 +139,25 @@
padding: 12px;
text-align: center;
border: 1px solid #e9ecef;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
&:hover {
background: #e6f7ff;
border-color: #91d5ff;
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(24, 144, 255, 0.15);
}
&:active {
transform: translateY(0);
box-shadow: 0 1px 4px rgba(24, 144, 255, 0.1);
}
&:hover::after {
opacity: 1;
}
}
.stat-label {
@@ -158,6 +177,8 @@
.plan-footer {
border-top: 1px solid #f0f0f0;
padding-top: 12px;
display: flex;
justify-content: space-between;
}
.last-execution {

View File

@@ -35,6 +35,10 @@ import style from "./index.module.scss";
import { Task, ApiSettings, PlanDetail } from "./data";
import PlanApi from "./planApi";
import { buildApiUrl } from "@/utils/apiUrl";
import DeviceListModal from "./components/DeviceListModal";
import AccountListModal from "./components/AccountListModal";
import OreadyAdd from "./components/OreadyAdd";
import PoolListModal from "./components/PoolListModal";
const ScenarioList: React.FC = () => {
const { scenarioId, scenarioName } = useParams<{
@@ -58,6 +62,19 @@ const ScenarioList: React.FC = () => {
const [currentTaskId, setCurrentTaskId] = useState<string>("");
const [showActionMenu, setShowActionMenu] = useState<string | null>(null);
// 设备列表弹窗状态
const [showDeviceList, setShowDeviceList] = useState(false);
const [currentTask, setCurrentTask] = useState<Task | null>(null);
// 账号列表弹窗状态
const [showAccountList, setShowAccountList] = useState(false);
// 已添加弹窗状态
const [showOreadyAdd, setShowOreadyAdd] = useState(false);
// 通过率弹窗状态
const [showPoolList, setShowPoolList] = useState(false);
// 分页相关状态
const [currentPage, setCurrentPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
@@ -233,19 +250,28 @@ const ScenarioList: React.FC = () => {
}
};
// 卡片点击处理 - 执行二维码动作
const handleCardClick = (taskId: string, event: React.MouseEvent) => {
// 检查点击是否在更多按钮区域内
const target = event.target as HTMLElement;
const moreButton = target.closest(`.${style["more-btn"]}`);
// 处理设备列表弹窗
const handleShowDeviceList = (task: Task) => {
setCurrentTask(task);
setShowDeviceList(true);
};
// 如果点击的是更多按钮或其子元素,不执行卡片点击动作
if (moreButton) {
return;
}
// 处理账号列表弹窗
const handleShowAccountList = (task: Task) => {
setCurrentTask(task);
setShowAccountList(true);
};
// 执行二维码动作
handleShowQrCode(taskId);
// 处理已添加弹窗
const handleShowOreadyAdd = (task: Task) => {
setCurrentTask(task);
setShowOreadyAdd(true);
};
// 处理通过率弹窗
const handleShowPoolList = (task: Task) => {
setCurrentTask(task);
setShowPoolList(true);
};
const getStatusColor = (status: number) => {
@@ -314,15 +340,7 @@ const ScenarioList: React.FC = () => {
handleOpenApiSettings(task.id);
},
},
{
key: "qrcode",
text: "二维码",
icon: <QrcodeOutlined />,
onClick: () => {
setShowActionMenu(null);
handleShowQrCode(task.id);
},
},
{
key: "delete",
text: "删除计划",
@@ -417,11 +435,7 @@ const ScenarioList: React.FC = () => {
) : (
<>
{filteredTasks.map(task => (
<Card
key={task.id}
className={style["plan-item"]}
onClick={e => handleCardClick(task.id, e)}
>
<Card key={task.id} className={style["plan-item"]}>
{/* 头部:标题、状态和操作菜单 */}
<div className={style["plan-header"]}>
<div className={style["plan-name"]}>{task.name}</div>
@@ -445,25 +459,49 @@ const ScenarioList: React.FC = () => {
{/* 统计数据网格 */}
<div className={style["stats-grid"]}>
<div className={style["stat-item"]}>
<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"]}>
<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"]}>
<div
className={style["stat-item"]}
onClick={e => {
e.stopPropagation(); // 阻止事件冒泡,避免触发卡片点击
handleShowOreadyAdd(task);
}}
>
<div className={style["stat-label"]}></div>
<div className={style["stat-value"]}>
{task.addedCount || 0}
{task.passCount || 0}
</div>
</div>
<div className={style["stat-item"]}>
<div
className={style["stat-item"]}
onClick={e => {
e.stopPropagation(); // 阻止事件冒泡,避免触发卡片点击
handleShowPoolList(task);
}}
>
<div className={style["stat-label"]}></div>
<div className={style["stat-value"]}>
{task.passRate}%
@@ -477,6 +515,14 @@ const ScenarioList: React.FC = () => {
<ClockCircleOutlined />
<span>: {task.lastUpdated || "--"}</span>
</div>
<div>
<QrcodeOutlined
onClick={() => {
setShowActionMenu(null);
handleShowQrCode(task.id);
}}
/>
</div>
</div>
</Card>
))}
@@ -585,6 +631,38 @@ const ScenarioList: React.FC = () => {
</div>
</div>
</Popup>
{/* 设备列表弹窗 */}
<DeviceListModal
visible={showDeviceList}
onClose={() => setShowDeviceList(false)}
ruleId={currentTask?.id ? parseInt(currentTask.id) : undefined}
ruleName={currentTask?.name}
/>
{/* 账号列表弹窗 */}
<AccountListModal
visible={showAccountList}
onClose={() => setShowAccountList(false)}
ruleId={currentTask?.id ? parseInt(currentTask.id) : undefined}
ruleName={currentTask?.name}
/>
{/* 已添加弹窗 */}
<OreadyAdd
visible={showOreadyAdd}
onClose={() => setShowOreadyAdd(false)}
ruleId={currentTask?.id ? parseInt(currentTask.id) : undefined}
ruleName={currentTask?.name}
/>
{/* 通过率弹窗 */}
<PoolListModal
visible={showPoolList}
onClose={() => setShowPoolList(false)}
ruleId={currentTask?.id ? parseInt(currentTask.id) : undefined}
ruleName={currentTask?.name}
/>
</div>
</Layout>
);

View File

@@ -0,0 +1,179 @@
import React from "react";
import UpdateNotification from "@/components/UpdateNotification";
const UpdateNotificationTest: React.FC = () => {
return (
<div
style={{
minHeight: "100vh",
backgroundColor: "#f5f5f5",
position: "relative",
}}
>
{/* 更新通知组件 */}
<UpdateNotification forceShow={true} />
{/* 页面内容 */}
<div
style={{
paddingTop: "calc(80px + env(safe-area-inset-top))", // 为通知栏留出空间
padding: "20px",
maxWidth: "800px",
margin: "0 auto",
}}
>
<div
style={{
backgroundColor: "white",
borderRadius: "12px",
padding: "24px",
boxShadow: "0 2px 8px rgba(0, 0, 0, 0.1)",
marginBottom: "20px",
}}
>
<h2
style={{
fontSize: "20px",
fontWeight: "600",
marginBottom: "16px",
color: "#333",
}}
>
UpdateNotification
</h2>
<div style={{ marginBottom: "16px" }}>
<h3
style={{
fontSize: "16px",
fontWeight: "500",
marginBottom: "8px",
color: "#666",
}}
>
</h3>
<ul
style={{
paddingLeft: "20px",
lineHeight: "1.6",
color: "#666",
}}
>
<li></li>
<li></li>
<li></li>
<li></li>
<li></li>
<li></li>
<li></li>
</ul>
</div>
<div style={{ marginBottom: "16px" }}>
<h3
style={{
fontSize: "16px",
fontWeight: "500",
marginBottom: "8px",
color: "#666",
}}
>
</h3>
<ul
style={{
paddingLeft: "20px",
lineHeight: "1.6",
color: "#666",
}}
>
<li>&ldquo;&rdquo;</li>
<li>&ldquo;&rdquo;10</li>
<li></li>
<li></li>
<li></li>
</ul>
</div>
<div
style={{
padding: "16px",
backgroundColor: "#f8f9fa",
borderRadius: "8px",
border: "1px solid #e9ecef",
}}
>
<p
style={{
margin: 0,
fontSize: "14px",
color: "#666",
lineHeight: "1.5",
}}
>
<strong></strong>
使
</p>
</div>
</div>
{/* 模拟页面内容 */}
<div
style={{
backgroundColor: "white",
borderRadius: "12px",
padding: "24px",
boxShadow: "0 2px 8px rgba(0, 0, 0, 0.1)",
}}
>
<h3
style={{
fontSize: "18px",
fontWeight: "500",
marginBottom: "16px",
color: "#333",
}}
>
</h3>
<p
style={{
lineHeight: "1.6",
color: "#666",
marginBottom: "16px",
}}
>
</p>
<p
style={{
lineHeight: "1.6",
color: "#666",
marginBottom: "16px",
}}
>
</p>
<div
style={{
height: "200px",
backgroundColor: "#f8f9fa",
borderRadius: "8px",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#999",
fontSize: "14px",
}}
>
</div>
</div>
</div>
</div>
);
};
export default UpdateNotificationTest;

View File

@@ -1,17 +1,10 @@
import React, { useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import {
Card,
Button,
Toast,
ProgressBar,
Tag,
SpinLoading,
} from "antd-mobile";
import { Card, Button, Toast, ProgressBar, Tag } from "antd-mobile";
import { TeamOutline, LeftOutline } from "antd-mobile-icons";
import { AlertOutlined } from "@ant-design/icons";
import Layout from "@/components/Layout/Layout";
import MeauMobile from "@/components/MeauMobile/MeauMoible";
import NavCommon from "@/components/NavCommon/index";
import style from "./index.module.scss";
interface GroupMember {
@@ -280,37 +273,10 @@ const AutoGroupDetail: React.FC = () => {
Toast.show({ content: "所有群组已创建完成" });
};
if (loading) {
return (
<Layout
header={
<div className={style.headerBar}>
<Button fill="none" size="small" onClick={() => navigate(-1)}>
<LeftOutline />
</Button>
<div className={style.title}></div>
</div>
}
footer={<MeauMobile />}
loading={true}
>
<div style={{ minHeight: 300 }} />
</Layout>
);
}
if (!taskDetail) {
return (
<Layout
header={
<div className={style.headerBar}>
<Button fill="none" size="small" onClick={() => navigate(-1)}>
<LeftOutline />
</Button>
<div className={style.title}></div>
</div>
}
footer={<MeauMobile />}
header={<NavCommon title="建群详情" backFn={() => navigate(-1)} />}
>
<Card className={style.emptyCard}>
<AlertOutlined style={{ fontSize: 48, color: "#ccc" }} />
@@ -330,14 +296,12 @@ const AutoGroupDetail: React.FC = () => {
return (
<Layout
header={
<div className={style.headerBar}>
<Button fill="none" size="small" onClick={() => navigate(-1)}>
<LeftOutline />
</Button>
<div className={style.title}>{taskDetail.name} - </div>
</div>
<NavCommon
title={taskDetail.name + " - 建群详情"}
backFn={() => navigate(-1)}
/>
}
footer={<MeauMobile />}
loading={loading}
>
<div className={style.autoGroupDetail}>
<Card className={style.infoCard}>

View File

@@ -1,11 +1,17 @@
import request from "@/api/request";
// 新建自动建群任务
export function createAutoGroup(data: any) {
return request("/api/auto-group/create", data, "POST");
}
// 创建朋友圈同步任务
export const createAutoGroup = (params: any) =>
request("/v1/workbench/create", params, "POST");
// 编辑自动建群任务
export function updateAutoGroup(id: string, data: any) {
return request(`/api/auto-group/update/${id}`, data, "POST");
}
// 更新朋友圈同步任务
export const updateAutoGroup = (params: any) =>
request("/v1/workbench/update", params, "POST");
// 获取朋友圈同步任务详情
export const getAutoGroupDetail = (id: string) =>
request("/v1/workbench/detail", { id }, "GET");
// 获取朋友圈同步任务列表
export const getAutoGroupList = (params: any) =>
request("/v1/workbench/list", params, "GET");

View File

@@ -22,13 +22,4 @@
text-align: center;
}
.timeRangeRow {
display: flex;
align-items: center;
gap: 8px;
}
.groupSizeRow {
display: flex;
align-items: center;
gap: 8px;
}

View File

@@ -1,71 +1,66 @@
import React, { useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
Form,
Input,
Button,
Toast,
Switch,
Selector,
TextArea,
NavBar,
} from "antd-mobile";
import { ArrowLeftOutlined } from "@ant-design/icons";
import { Form, Toast, TextArea } from "antd-mobile";
import { Input, InputNumber, Button, Switch } from "antd";
import Layout from "@/components/Layout/Layout";
import style from "./index.module.scss";
import { createAutoGroup, updateAutoGroup } from "./api";
import { createAutoGroup, updateAutoGroup, getAutoGroupDetail } from "./api";
import { AutoGroupFormData } from "./types";
import DeviceSelection from "@/components/DeviceSelection/index";
import NavCommon from "@/components/NavCommon/index";
import dayjs from "dayjs";
import { DeviceSelectionItem } from "@/components/DeviceSelection/data";
const defaultForm = {
const defaultForm: AutoGroupFormData = {
name: "",
deviceCount: 1,
targetFriends: 0,
createInterval: 300,
maxGroupsPerDay: 10,
timeRange: { start: "09:00", end: "21:00" },
groupSize: { min: 20, max: 50 },
targetTags: [],
groupNameTemplate: "VIP客户交流群{序号}",
groupDescription: "",
type: 4,
deveiceGroups: [], // 设备组
deveiceGroupsOptions: [], // 设备组选项
startTime: dayjs().format("HH:mm"), // 开始时间 (HH:mm)
endTime: dayjs().add(1, "hour").format("HH:mm"), // 结束时间 (HH:mm)
groupSizeMin: 20, // 群组最小人数
groupSizeMax: 50, // 群组最大人数
maxGroupsPerDay: 10, // 每日最大建群数
groupNameTemplate: "", // 群名称模板
groupDescription: "", // 群描述
status: 1, // 是否启用 (1: 启用, 0: 禁用)
};
const tagOptions = [
{ label: "VIP客户", value: "VIP客户" },
{ label: "高价值", value: "高价值" },
{ label: "潜在客户", value: "潜在客户" },
{ label: "中意向", value: "中意向" },
];
const AutoGroupForm: React.FC = () => {
const navigate = useNavigate();
const { id } = useParams();
const isEdit = Boolean(id);
const [form, setForm] = useState<any>(defaultForm);
const [form, setForm] = useState<AutoGroupFormData>(defaultForm);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (isEdit) {
// 这里应请求详情接口回填表单演示用mock
// 这里应请求详情接口回填表单演示用mock
getAutoGroupDetail(id).then(res => {
setForm({
...defaultForm,
name: "VIP客户建群",
deviceCount: 2,
targetFriends: 156,
createInterval: 300,
maxGroupsPerDay: 20,
timeRange: { start: "09:00", end: "21:00" },
groupSize: { min: 20, max: 50 },
targetTags: ["VIP客户", "高价值"],
groupNameTemplate: "VIP客户交流群{序号}",
groupDescription: "VIP客户专属交流群提供优质服务",
name: res.name,
deveiceGroups: res.config.deveiceGroups || [],
deveiceGroupsOptions: res.config.deveiceGroupsOptions || [],
startTime: res.config.startTime,
endTime: res.config.endTime,
groupSizeMin: res.config.groupSizeMin,
groupSizeMax: res.config.groupSizeMax,
maxGroupsPerDay: res.config.maxGroupsPerDay,
groupNameTemplate: res.config.groupNameTemplate,
groupDescription: res.config.groupDescription,
status: res.status,
type: res.type,
id: res.id,
});
}
}, [isEdit, id]);
console.log(form);
});
}, [id]);
const handleSubmit = async () => {
setLoading(true);
try {
if (isEdit) {
await updateAutoGroup(id as string, form);
await updateAutoGroup(form);
Toast.show({ content: "编辑成功" });
} else {
await createAutoGroup(form);
@@ -79,25 +74,24 @@ const AutoGroupForm: React.FC = () => {
}
};
const setTaskName = (val: string) => {
setForm((f: any) => ({ ...f, name: val }));
};
const setDeviceGroups = (val: DeviceSelectionItem[]) => {
console.log(val);
setForm((f: any) => ({
...f,
deveiceGroups: val.map(item => item.id),
deveiceGroupsOptions: val,
}));
};
return (
<Layout
header={
<NavBar
back={null}
style={{ background: "#fff" }}
left={
<div className="nav-title">
<ArrowLeftOutlined
twoToneColor="#1677ff"
onClick={() => navigate(-1)}
/>
</div>
}
>
<span className="nav-title">
{isEdit ? "编辑建群任务" : "新建建群任务"}
</span>
</NavBar>
<NavCommon
title={isEdit ? "编辑建群任务" : "新建建群任务"}
backFn={() => navigate(-1)}
/>
}
>
<div className={style.autoGroupForm}>
@@ -106,7 +100,7 @@ const AutoGroupForm: React.FC = () => {
footer={
<Button
block
color="primary"
type="primary"
loading={loading}
onClick={handleSubmit}
>
@@ -114,118 +108,158 @@ const AutoGroupForm: React.FC = () => {
</Button>
}
>
<Form.Item label="任务名称" name="name" required>
<Form.Item label="任务名称" required>
<Input
value={form.name}
onChange={val => setForm((f: any) => ({ ...f, name: val }))}
onChange={val => setTaskName(val.target.value)}
placeholder="请输入任务名称"
/>
</Form.Item>
<Form.Item label="执行设备数量" name="deviceCount" required>
<Input
type="number"
value={form.deviceCount}
onChange={val =>
setForm((f: any) => ({ ...f, deviceCount: Number(val) }))
}
placeholder="请输入设备数量"
/>
</Form.Item>
<Form.Item label="目标好友数" name="targetFriends" required>
<Input
type="number"
value={form.targetFriends}
onChange={val =>
setForm((f: any) => ({ ...f, targetFriends: Number(val) }))
}
placeholder="请输入目标好友数"
/>
</Form.Item>
<Form.Item label="建群间隔(秒)" name="createInterval" required>
<Input
type="number"
value={form.createInterval}
onChange={val =>
setForm((f: any) => ({ ...f, createInterval: Number(val) }))
}
placeholder="请输入建群间隔"
<Form.Item label="设备组" required>
<DeviceSelection
selectedOptions={form.deveiceGroupsOptions}
onSelect={setDeviceGroups}
/>
</Form.Item>
<Form.Item label="每日最大建群数" name="maxGroupsPerDay" required>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<Button
htmlType="button"
onClick={() => {
const newValue = Math.max(1, (form.maxGroupsPerDay || 1) - 1);
setForm((f: any) => ({ ...f, maxGroupsPerDay: newValue }));
}}
>
-
</Button>
<InputNumber
min={1}
max={100}
value={form.maxGroupsPerDay}
onChange={val =>
setForm((f: any) => ({ ...f, maxGroupsPerDay: val || 1 }))
}
placeholder="请输入最大建群数"
step={1}
style={{ flex: 1 }}
/>
<Button
htmlType="button"
onClick={() => {
const newValue = Math.min(
100,
(form.maxGroupsPerDay || 1) + 1,
);
setForm((f: any) => ({ ...f, maxGroupsPerDay: newValue }));
}}
>
+
</Button>
</div>
</Form.Item>
<Form.Item label="开始时间" required>
<Input
type="number"
value={form.maxGroupsPerDay}
onChange={val =>
setForm((f: any) => ({ ...f, maxGroupsPerDay: Number(val) }))
}
placeholder="请输入最大建群数"
type="time"
style={{ width: 120 }}
value={form.startTime || ""}
onChange={e => {
setForm((f: any) => ({ ...f, startTime: e.target.value }));
}}
/>
</Form.Item>
<Form.Item label="执行时间段" name="timeRange" required>
<div className={style.timeRangeRow}>
<Input
value={form.timeRange.start}
onChange={val =>
setForm((f: any) => ({
...f,
timeRange: { ...f.timeRange, start: val },
}))
}
placeholder="开始时间"
/>
<span style={{ margin: "0 8px" }}>-</span>
<Input
value={form.timeRange.end}
onChange={val =>
setForm((f: any) => ({
...f,
timeRange: { ...f.timeRange, end: val },
}))
}
placeholder="结束时间"
/>
</div>
</Form.Item>
<Form.Item label="群组规模" name="groupSize" required>
<div className={style.groupSizeRow}>
<Input
type="number"
value={form.groupSize.min}
onChange={val =>
setForm((f: any) => ({
...f,
groupSize: { ...f.groupSize, min: Number(val) },
}))
}
placeholder="最小人数"
/>
<span style={{ margin: "0 8px" }}>-</span>
<Input
type="number"
value={form.groupSize.max}
onChange={val =>
setForm((f: any) => ({
...f,
groupSize: { ...f.groupSize, max: Number(val) },
}))
}
placeholder="最大人数"
/>
</div>
</Form.Item>
<Form.Item label="目标标签" name="targetTags">
<Selector
options={tagOptions}
multiple
value={form.targetTags}
onChange={val => setForm((f: any) => ({ ...f, targetTags: val }))}
<Form.Item label="结束时间" required>
<Input
type="time"
style={{ width: 120 }}
value={form.endTime || ""}
onChange={e => {
setForm((f: any) => ({ ...f, endTime: e.target.value }));
}}
/>
</Form.Item>
<Form.Item label="群名称模板" name="groupNameTemplate" required>
<Form.Item label="群组最小人数" name="groupSizeMin" required>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<Button
htmlType="button"
onClick={() => {
const newValue = Math.max(1, (form.groupSizeMin || 1) - 1);
setForm((f: any) => ({ ...f, groupSizeMin: newValue }));
}}
>
-
</Button>
<InputNumber
min={1}
max={500}
value={form.groupSizeMin}
onChange={val => {
const newValue = val || 1;
setForm((f: any) => ({
...f,
groupSizeMin: Math.min(newValue, f.groupSizeMax),
}));
}}
placeholder="请输入最小人数"
step={1}
style={{ flex: 1 }}
/>
<Button
htmlType="button"
onClick={() => {
const newValue = Math.min(500, (form.groupSizeMin || 1) + 1);
setForm((f: any) => ({ ...f, groupSizeMin: newValue }));
}}
>
+
</Button>
</div>
</Form.Item>
<Form.Item label="群组最大人数" name="groupSizeMax" required>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<Button
htmlType="button"
onClick={() => {
const newValue = Math.max(1, (form.groupSizeMax || 1) - 1);
setForm((f: any) => ({ ...f, groupSizeMax: newValue }));
}}
>
-
</Button>
<InputNumber
min={1}
max={500}
value={form.groupSizeMax}
onChange={val => {
const newValue = val || 1;
setForm((f: any) => ({
...f,
groupSizeMax: Math.max(newValue, f.groupSizeMin),
}));
}}
placeholder="请输入最大人数"
step={1}
style={{ flex: 1 }}
/>
<Button
htmlType="button"
onClick={() => {
const newValue = Math.min(500, (form.groupSizeMax || 1) + 1);
setForm((f: any) => ({ ...f, groupSizeMax: newValue }));
}}
>
+
</Button>
</div>
</Form.Item>
<Form.Item label="群名称模板" required>
<Input
value={form.groupNameTemplate}
onChange={val =>
setForm((f: any) => ({ ...f, groupNameTemplate: val }))
setForm((f: any) => ({
...f,
groupNameTemplate: val.target.value,
}))
}
placeholder="请输入群名称模板"
/>
@@ -242,6 +276,23 @@ const AutoGroupForm: React.FC = () => {
showCount
/>
</Form.Item>
<Form.Item label="是否开启" name="status">
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<span></span>
<Switch
checked={form.status === 1}
onChange={checked =>
setForm((f: any) => ({ ...f, status: checked ? 1 : 0 }))
}
/>
</div>
</Form.Item>
</Form>
</div>
</Layout>

View File

@@ -0,0 +1,54 @@
import { DeviceSelectionItem } from "@/components/DeviceSelection/data";
// 自动建群表单数据类型定义
export interface AutoGroupFormData {
id?: string; // 任务ID
type: number; // 任务类型
name: string; // 任务名称
deveiceGroups: string[]; // 设备组
deveiceGroupsOptions: DeviceSelectionItem[]; // 设备组选项
startTime: string; // 开始时间 (YYYY-MM-DD HH:mm:ss)
endTime: string; // 结束时间 (YYYY-MM-DD HH:mm:ss)
groupSizeMin: number; // 群组最小人数
groupSizeMax: number; // 群组最大人数
maxGroupsPerDay: number; // 每日最大建群数
groupNameTemplate: string; // 群名称模板
groupDescription: string; // 群描述
status: number; // 是否启用 (1: 启用, 0: 禁用)
[key: string]: any;
}
// 表单验证规则
export const formValidationRules = {
name: [
{ required: true, message: "请输入任务名称" },
{ min: 2, max: 50, message: "任务名称长度应在2-50个字符之间" },
],
deveiceGroups: [
{ required: true, message: "请选择设备组" },
{ type: "array", min: 1, message: "至少选择一个设备组" },
],
startTime: [{ required: true, message: "请选择开始时间" }],
endTime: [{ required: true, message: "请选择结束时间" }],
groupSizeMin: [
{ required: true, message: "请输入群组最小人数" },
{ type: "number", min: 1, max: 500, message: "群组最小人数应在1-500之间" },
],
groupSizeMax: [
{ required: true, message: "请输入群组最大人数" },
{ type: "number", min: 1, max: 500, message: "群组最大人数应在1-500之间" },
],
maxGroupsPerDay: [
{ required: true, message: "请输入每日最大建群数" },
{
type: "number",
min: 1,
max: 100,
message: "每日最大建群数应在1-100之间",
},
],
groupNameTemplate: [
{ required: true, message: "请输入群名称模板" },
{ min: 2, max: 100, message: "群名称模板长度应在2-100个字符之间" },
],
groupDescription: [{ max: 200, message: "群描述不能超过200个字符" }],
};

View File

@@ -1,8 +1,11 @@
import request from "@/api/request";
// 获取自动建群任务列表
export function getAutoGroupList(params?: any) {
return request("/api/auto-group/list", params, "GET");
}
// 获取朋友圈同步任务列表
export const getAutoGroupList = (params: any) =>
request("/v1/workbench/list", params, "GET");
// 其他相关API可按需添加
// 复制自动建群任务
export function copyAutoGroupTask(id: string): Promise<any> {
return request("/v1/workbench/copy", { id }, "POST");
}

View File

@@ -1,7 +1,7 @@
import React, { useState } from "react";
import React, { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Button, Card, Popover, Toast } from "antd-mobile";
import { Input, Switch } from "antd";
import { Input, Switch, Pagination } from "antd";
import {
MoreOutline,
AddCircleOutline,
@@ -14,7 +14,7 @@ import {
PlusOutlined,
SearchOutlined,
} from "@ant-design/icons";
import { getAutoGroupList, copyAutoGroupTask } from "./api";
import Layout from "@/components/Layout/Layout";
import style from "./index.module.scss";
import NavCommon from "@/components/NavCommon";
@@ -22,91 +22,93 @@ import NavCommon from "@/components/NavCommon";
interface GroupTask {
id: string;
name: string;
status: "running" | "paused" | "completed";
deviceCount: number;
targetFriends: number;
createdGroups: number;
lastCreateTime: string;
createTime: string;
creator: string;
createInterval: number;
maxGroupsPerDay: number;
timeRange: { start: string; end: string };
groupSize: { min: number; max: number };
targetTags: string[];
groupNameTemplate: string;
groupDescription: string;
status: number; // 1 开启, 0 关闭
deviceCount?: number;
targetFriends?: number;
createdGroups?: number;
lastCreateTime?: string;
createTime?: string;
creator?: string;
createInterval?: number;
maxGroupsPerDay?: number;
timeRange?: { start: string; end: string };
groupSize?: { min: number; max: number };
targetTags?: string[];
groupNameTemplate?: string;
groupDescription?: string;
}
const mockTasks: GroupTask[] = [
{
id: "1",
name: "VIP客户建群",
deviceCount: 2,
targetFriends: 156,
createdGroups: 12,
lastCreateTime: "2025-02-06 13:12:35",
createTime: "2024-11-20 19:04:14",
creator: "admin",
status: "running",
createInterval: 300,
maxGroupsPerDay: 20,
timeRange: { start: "09:00", end: "21:00" },
groupSize: { min: 20, max: 50 },
targetTags: ["VIP客户", "高价值"],
groupNameTemplate: "VIP客户交流群{序号}",
groupDescription: "VIP客户专属交流群提供优质服务",
},
{
id: "2",
name: "产品推广建群",
deviceCount: 1,
targetFriends: 89,
createdGroups: 8,
lastCreateTime: "2024-03-04 14:09:35",
createTime: "2024-03-04 14:29:04",
creator: "manager",
status: "paused",
createInterval: 600,
maxGroupsPerDay: 10,
timeRange: { start: "10:00", end: "20:00" },
groupSize: { min: 15, max: 30 },
targetTags: ["潜在客户", "中意向"],
groupNameTemplate: "产品推广群{序号}",
groupDescription: "产品推广交流群,了解最新产品信息",
},
];
const getStatusColor = (status: string) => {
const getStatusColor = (status: number) => {
switch (status) {
case "running":
case 1:
return style.statusRunning;
case "paused":
case 0:
return style.statusPaused;
case "completed":
return style.statusCompleted;
default:
return style.statusPaused;
}
};
const getStatusText = (status: string) => {
const getStatusText = (status: number) => {
switch (status) {
case "running":
return "进行中";
case "paused":
return "已暂停";
case "completed":
return "已完成";
case 1:
return "开启";
case 0:
return "关闭";
default:
return "未知";
return "关闭";
}
};
const AutoGroupList: React.FC = () => {
const navigate = useNavigate();
const [searchTerm, setSearchTerm] = useState("");
const [tasks, setTasks] = useState<GroupTask[]>(mockTasks);
const [tasks, setTasks] = useState<GroupTask[]>([]);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const refreshTasks = async (p = page, ps = pageSize) => {
setLoading(true);
try {
const res: any = await getAutoGroupList({ type: 4, page: p, limit: ps });
// 兼容不同返回结构
const list = res?.list || res?.records || res?.data || [];
const totalCount = res?.total || res?.totalCount || list.length;
const normalized: GroupTask[] = (list as any[]).map(item => ({
id: String(item.id),
name: item.name,
status: Number(item.status) === 1 ? 1 : 0,
deviceCount: Array.isArray(item.config?.devices)
? item.config.devices.length
: 0,
maxGroupsPerDay: item.config?.maxGroupsPerDay ?? 0,
timeRange: {
start: item.config?.startTime ?? "-",
end: item.config?.endTime ?? "-",
},
groupSize: {
min: item.config?.groupSizeMin ?? 0,
max: item.config?.groupSizeMax ?? 0,
},
creator: item.creatorName ?? "",
createTime: item.createTime ?? "",
lastCreateTime: item.updateTime ?? "",
}));
setTasks(normalized);
setTotal(totalCount);
} catch (e) {
Toast.show({ content: "获取列表失败" });
} finally {
setLoading(false);
}
};
useEffect(() => {
refreshTasks(1, pageSize);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleDelete = (taskId: string) => {
const taskToDelete = tasks.find(task => task.id === taskId);
@@ -124,17 +126,20 @@ const AutoGroupList: React.FC = () => {
navigate(`/workspace/auto-group/${taskId}`);
};
const handleCopy = (taskId: string) => {
const taskToCopy = tasks.find(task => task.id === taskId);
if (taskToCopy) {
const newTask = {
...taskToCopy,
id: `${Date.now()}`,
name: `${taskToCopy.name} (复制)`,
createTime: new Date().toISOString().replace("T", " ").substring(0, 19),
};
setTasks([...tasks, newTask]);
Toast.show({ content: "复制成功" });
// 复制任务
const handleCopy = async (id: string) => {
try {
await copyAutoGroupTask(id);
Toast.show({
content: "复制成功",
position: "top",
});
refreshTasks(); // 重新获取列表
} catch (error) {
Toast.show({
content: "复制失败",
position: "top",
});
}
};
@@ -144,7 +149,7 @@ const AutoGroupList: React.FC = () => {
task.id === taskId
? {
...task,
status: task.status === "running" ? "paused" : "running",
status: task.status === 1 ? 0 : 1,
}
: task,
),
@@ -187,7 +192,7 @@ const AutoGroupList: React.FC = () => {
</div>
<Button
size="small"
onClick={() => {}}
onClick={() => refreshTasks()}
loading={false}
className="refresh-btn"
>
@@ -196,6 +201,23 @@ const AutoGroupList: React.FC = () => {
</div>
</>
}
footer={
<div className="pagination-container">
<Pagination
current={page}
pageSize={pageSize}
total={total}
onChange={(p, ps) => {
setPage(p);
setPageSize(ps);
refreshTasks(p, ps);
}}
showSizeChanger
showTotal={t => `${t}`}
/>
</div>
}
loading={loading}
>
<div className={style.autoGroupList}>
<div className={style.taskList}>
@@ -217,9 +239,9 @@ const AutoGroupList: React.FC = () => {
{getStatusText(task.status)}
</span>
<Switch
checked={task.status === "running"}
checked={task.status === 1}
onChange={() => toggleTaskStatus(task.id)}
disabled={task.status === "completed"}
disabled={false}
style={{ marginLeft: 8 }}
/>
<Popover
@@ -259,23 +281,26 @@ const AutoGroupList: React.FC = () => {
<div className={style.taskInfoGrid}>
<div>
<div className={style.infoLabel}></div>
<div className={style.infoValue}>{task.deviceCount} </div>
</div>
<div>
<div className={style.infoLabel}></div>
<div className={style.infoValue}>
{task.targetFriends}
{task.deviceCount ?? 0}
</div>
</div>
{/* 该字段暂无,预留位 */}
<div>
<div className={style.infoLabel}></div>
<div className={style.infoValue}>
{task.timeRange?.start} - {task.timeRange?.end}
</div>
</div>
<div>
<div className={style.infoLabel}></div>
<div className={style.infoLabel}></div>
<div className={style.infoValue}>
{task.createdGroups}
{task.maxGroupsPerDay ?? 0}
</div>
</div>
<div>
<div className={style.infoLabel}></div>
<div className={style.infoValue}>{task.creator}</div>
<div className={style.infoValue}>{task.creator ?? ""}</div>
</div>
</div>
<div className={style.taskFooter}>

View File

@@ -23,8 +23,10 @@ export interface TrafficDistributionConfig {
timeType: number;
startTime: string;
endTime: string;
account: (string | number)[];
devices: string[];
accountGroups: any[];
accountGroupsOptions: any[];
deviceGroups: any[];
deviceGroupsOptions: any[];
pools: any[];
exp: number;
createTime: string;
@@ -52,8 +54,10 @@ export interface TrafficDistributionFormData {
timeType: number;
startTime: string;
endTime: string;
devices: string[];
account: (string | number)[];
deviceGroups: any[];
deviceGroupsOptions: any[];
accountGroups: any[];
accountGroupsOptions: any[];
pools: any[];
enabled: boolean;
}

View File

@@ -60,6 +60,51 @@
.accountSelectItem {
margin-bottom: 0 !important;
}
.deviceSelectItem {
margin-bottom: 0 !important;
}
.searchWrapper {
margin-bottom: 16px;
}
.tabWrapper {
margin-bottom: 16px;
}
.tabList {
display: flex;
background: #f5f5f5;
border-radius: 8px;
padding: 4px;
}
.tabItem {
flex: 1;
text-align: center;
padding: 8px 16px;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
color: #666;
cursor: pointer;
transition: all 0.2s ease;
}
.tabItem:hover {
color: #1890ff;
}
.tabActive {
background: #fff;
color: #1890ff;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.tabContent {
min-height: 200px;
}
.accountListWrap {
display: flex;
flex-wrap: wrap;
@@ -191,6 +236,19 @@
margin-top: 16px;
}
.formFooter {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
background: #fff;
border-top: 1px solid #f0f0f0;
}
.footerBtn {
min-width: 80px;
}
// 步骤条美化
.formSteps :global(.ant-steps-item-title) {
font-size: 15px;

View File

@@ -9,6 +9,7 @@ import {
message,
Checkbox,
} from "antd";
import { SearchOutlined } from "@ant-design/icons";
import { useNavigate, useParams } from "react-router-dom";
import style from "./index.module.scss";
import StepIndicator from "@/components/StepIndicator";
@@ -16,6 +17,8 @@ import Layout from "@/components/Layout/Layout";
import NavCommon from "@/components/NavCommon";
import AccountSelection from "@/components/AccountSelection";
import { AccountItem } from "@/components/AccountSelection/data";
import DeviceSelection from "@/components/DeviceSelection";
import { DeviceSelectionItem } from "@/components/DeviceSelection/data";
import {
getTrafficDistributionDetail,
updateTrafficDistribution,
@@ -62,6 +65,14 @@ const TrafficDistributionForm: React.FC = () => {
const [current, setCurrent] = useState(0);
const [selectedAccounts, setSelectedAccounts] = useState<AccountItem[]>([]);
const [selectedDevices, setSelectedDevices] = useState<DeviceSelectionItem[]>(
[],
);
// 设备组和账号组数据
const [deviceGroups, setDeviceGroups] = useState<any[]>([]);
const [deviceGroupsOptions, setDeviceGroupsOptions] = useState<any[]>([]);
const [accountGroups, setAccountGroups] = useState<any[]>([]);
const [accountGroupsOptions, setAccountGroupsOptions] = useState<any[]>([]);
const [distributeType, setDistributeType] = useState(1);
const [maxPerDay, setMaxPerDay] = useState(50);
const [timeType, setTimeType] = useState(1);
@@ -69,11 +80,11 @@ const TrafficDistributionForm: React.FC = () => {
const [loading, setLoading] = useState(false);
const [detailLoading, setDetailLoading] = useState(false);
const [targetUserCount, setTargetUserCount] = useState(100);
const [targetTypes, setTargetTypes] = useState<string[]>([]);
const [targetScenarios, setTargetScenarios] = useState<string[]>([]);
const [selectedPools, setSelectedPools] = useState<string[]>([]);
const [poolSearch, setPoolSearch] = useState("");
const [targetSelectionTab, setTargetSelectionTab] = useState<
"device" | "account"
>("device");
// 编辑时的详情数据
const [detailData, setDetailData] =
@@ -116,7 +127,15 @@ const TrafficDistributionForm: React.FC = () => {
setMaxPerDay(config.maxPerDay);
setTimeType(config.timeType);
setSelectedAccounts(config.accountGroupsOptions);
// 设置账号组数据
setAccountGroups(config.accountGroups || []);
setAccountGroupsOptions(config.accountGroupsOptions || []);
setSelectedAccounts(config.accountGroupsOptions || []);
// 设置设备组数据
setDeviceGroups(config.deviceGroups || []);
setDeviceGroupsOptions(config.deviceGroupsOptions || []);
setSelectedDevices(config.deviceGroupsOptions || []);
// 设置时间范围 - 使用dayjs格式
if (config.timeType === 2 && config.startTime && config.endTime) {
@@ -161,8 +180,10 @@ const TrafficDistributionForm: React.FC = () => {
timeType === 2 && timeRange?.[0] ? timeRange[0].format("HH:mm") : "",
endTime:
timeType === 2 && timeRange?.[1] ? timeRange[1].format("HH:mm") : "",
devices: detailData?.config.devices || [],
account: selectedAccounts.map(acc => acc.id),
deviceGroups: deviceGroups,
deviceGroupsOptions: deviceGroupsOptions,
accountGroups: accountGroups,
accountGroupsOptions: accountGroupsOptions,
pools: selectedPools,
enabled: true,
};
@@ -184,7 +205,21 @@ const TrafficDistributionForm: React.FC = () => {
};
// 步骤切换
const next = () => setCurrent(cur => cur + 1);
const next = () => {
if (current === 0) {
// 第一步需要验证表单
form
.validateFields()
.then(() => {
setCurrent(cur => cur + 1);
})
.catch(() => {
// 验证失败,不进行下一步
});
} else {
setCurrent(cur => cur + 1);
}
};
const prev = () => setCurrent(cur => cur - 1);
// 过滤流量池
@@ -201,6 +236,29 @@ const TrafficDistributionForm: React.FC = () => {
</>
}
loading={detailLoading}
footer={
<div className="footer-btn-group">
{current > 0 && (
<Button size="large" onClick={prev}>
</Button>
)}
{current < 2 ? (
<Button size="large" type="primary" onClick={next}>
</Button>
) : (
<Button
type="primary"
size="large"
onClick={handleFinish}
loading={loading}
>
</Button>
)}
</div>
}
>
<div className={style.formPage}>
<div className={style.formBody}>
@@ -208,7 +266,6 @@ const TrafficDistributionForm: React.FC = () => {
<Form
form={form}
layout="vertical"
onFinish={() => next()}
initialValues={{
name: isEdit ? "" : generateDefaultName(),
distributeType: 1,
@@ -225,19 +282,6 @@ const TrafficDistributionForm: React.FC = () => {
>
<Input placeholder="流量分发 20250724 1700" maxLength={30} />
</Form.Item>
<Form.Item
label="选择账号"
required
className={style.accountSelectItem}
>
<AccountSelection
selectedOptions={selectedAccounts}
onSelect={setSelectedAccounts}
placeholder="请选择账号"
showSelectedList={true}
selectedListMaxHeight={300}
/>
</Form.Item>
<Form.Item label="分配方式" name="distributeType" required>
<Radio.Group
value={distributeType}
@@ -314,60 +358,66 @@ const TrafficDistributionForm: React.FC = () => {
</div>
</Form.Item>
)}
<Form.Item>
<Button
type="primary"
htmlType="submit"
loading={loading}
block
>
</Button>
</Form.Item>
</Form>
)}
{current === 1 && (
<div>
<div className={style.sectionTitle}></div>
<div className={style.formBlock}>
<div className={style.formLabel}></div>
<Slider
min={1}
max={1000}
value={targetUserCount}
onChange={setTargetUserCount}
className={style.slider}
/>
<div className={style.sliderValue}>{targetUserCount} </div>
{/* Tab 切换 */}
<div className={style.tabWrapper}>
<div className={style.tabList}>
<div
className={`${style.tabItem} ${
targetSelectionTab === "device" ? style.tabActive : ""
}`}
onClick={() => setTargetSelectionTab("device")}
>
</div>
<div
className={`${style.tabItem} ${
targetSelectionTab === "account" ? style.tabActive : ""
}`}
onClick={() => setTargetSelectionTab("account")}
>
</div>
</div>
</div>
<div className={style.formBlock}>
<div className={style.formLabel}></div>
<Checkbox.Group
options={["高价值客户", "新用户", "潜在客户", "流失预警"]}
value={targetTypes}
onChange={setTargetTypes}
className={style.checkboxGroup}
/>
</div>
<div className={style.formBlock}>
<div className={style.formLabel}></div>
<Checkbox.Group
options={scenarioList.map(s => ({
label: s.label,
value: s.value,
}))}
value={targetScenarios}
onChange={setTargetScenarios}
className={style.checkboxGroup}
/>
</div>
<div className={style.formStepBtns}>
<Button onClick={prev} style={{ marginRight: 12 }}>
</Button>
<Button type="primary" onClick={next}>
</Button>
{/* Tab 内容 */}
<div className={style.tabContent}>
{targetSelectionTab === "device" && (
<div className={style.formBlock}>
<DeviceSelection
selectedOptions={selectedDevices}
onSelect={devices => {
setSelectedDevices(devices);
setDeviceGroupsOptions(devices);
}}
placeholder="请选择设备"
showSelectedList={true}
selectedListMaxHeight={300}
deviceGroups={deviceGroups}
/>
</div>
)}
{targetSelectionTab === "account" && (
<div className={style.formBlock}>
<AccountSelection
selectedOptions={selectedAccounts}
onSelect={accounts => {
setSelectedAccounts(accounts);
setAccountGroupsOptions(accounts);
}}
placeholder="请选择客服"
showSelectedList={true}
selectedListMaxHeight={300}
accountGroups={accountGroups}
/>
</div>
)}
</div>
</div>
)}
@@ -409,14 +459,6 @@ const TrafficDistributionForm: React.FC = () => {
<span>{selectedPools.length}</span>
</div>
</div>
<div className={style.formStepBtns}>
<Button onClick={prev} style={{ marginRight: 12 }}>
</Button>
<Button type="primary" onClick={handleFinish} loading={loading}>
</Button>
</div>
</div>
)}
</div>

View File

@@ -26,3 +26,18 @@ export function toggleDistributionRuleStatus(
export function deleteDistributionRule(id: number): Promise<any> {
return request("/v1/workbench/delete", { id }, "POST");
}
// 获取流量分发规则详情
export function fetchDistributionRuleDetail(id: number): Promise<any> {
return request(`/v1/workbench/detail?id=${id}`, {}, "GET");
}
//流量分发记录
export function fetchTransferFriends(params: {
page?: number;
limit?: number;
keyword?: string;
workbenchId: number;
}) {
return request("/v1/workbench/transfer-friends", params, "GET");
}

View File

@@ -0,0 +1,166 @@
import React, { useEffect, useState } from "react";
import { Popup, Avatar, SpinLoading } from "antd-mobile";
import { Button, message } from "antd";
import { CloseOutlined } from "@ant-design/icons";
import style from "../index.module.scss";
import { fetchDistributionRuleDetail } from "../api";
interface AccountItem {
id: string | number;
nickname?: string;
wechatId?: string;
avatar?: string;
status?: string;
}
interface AccountListModalProps {
visible: boolean;
onClose: () => void;
ruleId?: number;
ruleName?: string;
}
const AccountListModal: React.FC<AccountListModalProps> = ({
visible,
onClose,
ruleId,
ruleName,
}) => {
const [accounts, setAccounts] = useState<AccountItem[]>([]);
const [loading, setLoading] = useState(false);
// 获取账号数据
const fetchAccounts = async () => {
if (!ruleId) return;
setLoading(true);
try {
const detailRes = await fetchDistributionRuleDetail(ruleId);
const accountData = detailRes?.config?.accountGroupsOptions || [];
setAccounts(accountData);
} catch (error) {
console.error("获取账号详情失败:", error);
message.error("获取账号详情失败");
} finally {
setLoading(false);
}
};
// 当弹窗打开且有ruleId时获取数据
useEffect(() => {
if (visible && ruleId) {
fetchAccounts();
}
}, [visible, ruleId]);
const title = ruleName ? `${ruleName} - 分发账号列表` : "分发账号列表";
const getStatusColor = (status?: string) => {
switch (status) {
case "normal":
return "#52c41a";
case "limited":
return "#faad14";
case "blocked":
return "#ff4d4f";
default:
return "#d9d9d9";
}
};
const getStatusText = (status?: string) => {
switch (status) {
case "normal":
return "正常";
case "limited":
return "受限";
case "blocked":
return "封禁";
default:
return "未知";
}
};
return (
<Popup
visible={visible}
onMaskClick={onClose}
position="bottom"
bodyStyle={{
height: "70vh",
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
}}
>
<div className={style.accountModal}>
{/* 头部 */}
<div className={style.accountModalHeader}>
<h3 className={style.accountModalTitle}>{title}</h3>
<Button
type="text"
icon={<CloseOutlined />}
onClick={onClose}
className={style.accountModalClose}
/>
</div>
{/* 账号列表 */}
<div className={style.accountList}>
{loading ? (
<div className={style.accountLoading}>
<SpinLoading color="primary" />
<div className={style.accountLoadingText}>
...
</div>
</div>
) : accounts.length > 0 ? (
accounts.map((account, index) => (
<div key={account.id || index} className={style.accountItem}>
<div className={style.accountAvatar}>
<Avatar
src={account.avatar}
style={{ "--size": "48px" }}
fallback={
(account.nickname || account.wechatId || "账号")[0]
}
/>
</div>
<div className={style.accountInfo}>
<div className={style.accountName}>
{account.nickname ||
account.wechatId ||
`账号${account.id}`}
</div>
<div className={style.accountWechatId}>
{account.wechatId || "未绑定微信号"}
</div>
</div>
<div className={style.accountStatus}>
<span
className={style.statusDot}
style={{ backgroundColor: getStatusColor(account.status) }}
/>
<span className={style.statusText}>
{getStatusText(account.status)}
</span>
</div>
</div>
))
) : (
<div className={style.accountEmpty}>
<div className={style.accountEmptyText}></div>
</div>
)}
</div>
{/* 底部统计 */}
<div className={style.accountModalFooter}>
<div className={style.accountStats}>
<span> {accounts.length} </span>
</div>
</div>
</div>
</Popup>
);
};
export default AccountListModal;

View File

@@ -0,0 +1,175 @@
import React, { useEffect, useState } from "react";
import { Popup, Avatar, SpinLoading } from "antd-mobile";
import { Button, message } from "antd";
import { CloseOutlined } from "@ant-design/icons";
import style from "../index.module.scss";
import { fetchDistributionRuleDetail } from "../api";
interface DeviceItem {
id: string | number;
memo?: string;
imei?: string;
wechatId?: string;
status?: "online" | "offline";
avatar?: string;
totalFriend?: number;
}
interface DeviceListModalProps {
visible: boolean;
onClose: () => void;
ruleId?: number;
ruleName?: string;
}
const DeviceListModal: React.FC<DeviceListModalProps> = ({
visible,
onClose,
ruleId,
ruleName,
}) => {
const [devices, setDevices] = useState<DeviceItem[]>([]);
const [loading, setLoading] = useState(false);
// 获取设备数据
const fetchDevices = async () => {
if (!ruleId) return;
setLoading(true);
try {
const detailRes = await fetchDistributionRuleDetail(ruleId);
const deviceData = detailRes?.config?.deveiceGroupsOptions || [];
setDevices(deviceData);
} catch (error) {
console.error("获取设备详情失败:", error);
message.error("获取设备详情失败");
} finally {
setLoading(false);
}
};
// 当弹窗打开且有ruleId时获取数据
useEffect(() => {
if (visible && ruleId) {
fetchDevices();
}
}, [visible, ruleId]);
const title = ruleName ? `${ruleName} - 分发设备列表` : "分发设备列表";
const getStatusColor = (status?: string) => {
return status === "online" ? "#52c41a" : "#ff4d4f";
};
const getStatusText = (status?: string) => {
return status === "online" ? "在线" : "离线";
};
return (
<Popup
visible={visible}
onMaskClick={onClose}
position="bottom"
bodyStyle={{
height: "70vh",
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
}}
>
<div className={style.deviceModal}>
{/* 头部 */}
<div className={style.deviceModalHeader}>
<h3 className={style.deviceModalTitle}>{title}</h3>
<Button
type="text"
icon={<CloseOutlined />}
onClick={onClose}
className={style.deviceModalClose}
/>
</div>
{/* 设备列表 */}
<div className={style.deviceList}>
{loading ? (
<div className={style.deviceLoading}>
<SpinLoading color="primary" />
<div className={style.deviceLoadingText}>...</div>
</div>
) : devices.length > 0 ? (
devices.map((device, index) => (
<div key={device.id || index} className={style.deviceItem}>
{/* 顶部行IMEI */}
<div className={style.deviceHeaderRow}>
<span className={style.deviceImeiText}>
IMEI: {device.imei?.toUpperCase() || "-"}
</span>
</div>
{/* 主要内容区域:头像和详细信息 */}
<div className={style.deviceMainContent}>
{/* 头像 */}
<div className={style.deviceAvatar}>
{device.avatar ? (
<img src={device.avatar} alt="头像" />
) : (
<span className={style.deviceAvatarText}>
{(device.memo || device.wechatId || "设")[0]}
</span>
)}
</div>
{/* 设备信息 */}
<div className={style.deviceInfo}>
<div className={style.deviceInfoHeader}>
<h3 className={style.deviceName}>
{device.memo || "未命名设备"}
</h3>
<span
className={`${style.deviceStatusBadge} ${
device.status === "online"
? style.deviceStatusOnline
: style.deviceStatusOffline
}`}
>
{getStatusText(device.status)}
</span>
</div>
<div className={style.deviceInfoList}>
<div className={style.deviceInfoItem}>
<span className={style.deviceInfoLabel}>:</span>
<span className={style.deviceInfoValue}>
{device.wechatId || "未绑定"}
</span>
</div>
<div className={style.deviceInfoItem}>
<span className={style.deviceInfoLabel}>:</span>
<span
className={`${style.deviceInfoValue} ${style.deviceFriendCount}`}
>
{device.totalFriend ?? "-"}
</span>
</div>
</div>
</div>
</div>
</div>
))
) : (
<div className={style.deviceEmpty}>
<div className={style.deviceEmptyText}></div>
</div>
)}
</div>
{/* 底部统计 */}
<div className={style.deviceModalFooter}>
<div className={style.deviceStats}>
<span> {devices.length} </span>
</div>
</div>
</div>
</Popup>
);
};
export default DeviceListModal;

View File

@@ -0,0 +1,170 @@
import React, { useEffect, useState } from "react";
import { Popup, Avatar, SpinLoading } from "antd-mobile";
import { Button, message } from "antd";
import { CloseOutlined } from "@ant-design/icons";
import style from "../index.module.scss";
import { fetchDistributionRuleDetail } from "../api";
interface PoolItem {
id: string | number;
name?: string;
description?: string;
userCount?: number;
tags?: string[];
createdAt?: string;
deviceIds?: string[];
}
interface PoolListModalProps {
visible: boolean;
onClose: () => void;
ruleId?: number;
ruleName?: string;
}
const PoolListModal: React.FC<PoolListModalProps> = ({
visible,
onClose,
ruleId,
ruleName,
}) => {
const [pools, setPools] = useState<PoolItem[]>([]);
const [loading, setLoading] = useState(false);
// 获取流量池数据
const fetchPools = async () => {
if (!ruleId) return;
setLoading(true);
try {
const detailRes = await fetchDistributionRuleDetail(ruleId);
const poolData = detailRes?.config?.pools || [];
const formattedPools = poolData.map((pool: any) => ({
id: pool.id || pool.poolId,
name: pool.name || pool.poolName || `流量池${pool.id}`,
description: pool.description || pool.desc || "",
userCount: pool.userCount || pool.count || 0,
tags: pool.tags || [],
createdAt: pool.createdAt || pool.createTime || "",
deviceIds: pool.deviceIds || [],
}));
setPools(formattedPools);
} catch (error) {
console.error("获取流量池详情失败:", error);
message.error("获取流量池详情失败");
} finally {
setLoading(false);
}
};
// 当弹窗打开且有ruleId时获取数据
useEffect(() => {
if (visible && ruleId) {
fetchPools();
}
}, [visible, ruleId]);
const title = ruleName ? `${ruleName} - 流量池列表` : "流量池列表";
return (
<Popup
visible={visible}
onMaskClick={onClose}
position="bottom"
bodyStyle={{
height: "70vh",
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
}}
>
<div className={style.poolModal}>
{/* 头部 */}
<div className={style.poolModalHeader}>
<h3 className={style.poolModalTitle}>{title}</h3>
<Button
type="text"
icon={<CloseOutlined />}
onClick={onClose}
className={style.poolModalClose}
/>
</div>
{/* 流量池列表 */}
<div className={style.poolList}>
{loading ? (
<div className={style.poolLoading}>
<SpinLoading color="primary" />
<div className={style.poolLoadingText}>...</div>
</div>
) : pools.length > 0 ? (
pools.map((pool, index) => (
<div key={pool.id || index} className={style.poolItem}>
{/* 流量池信息 */}
<div className={style.poolMainContent}>
{/* 图标 */}
<div className={style.poolIcon}>
<span className={style.poolIconText}>
{(pool.name || "池")[0]}
</span>
</div>
{/* 流量池信息 */}
<div className={style.poolInfo}>
<div className={style.poolInfoHeader}>
<h3 className={style.poolName}>
{pool.name || `流量池${pool.id}`}
</h3>
<span className={style.poolUserCount}>
{pool.userCount || 0}
</span>
</div>
<div className={style.poolInfoList}>
<div className={style.poolInfoItem}>
<span className={style.poolInfoLabel}>:</span>
<span className={style.poolInfoValue}>
{pool.description || "暂无描述"}
</span>
</div>
<div className={style.poolInfoItem}>
<span className={style.poolInfoLabel}>:</span>
<span className={style.poolInfoValue}>
{pool.createdAt || "-"}
</span>
</div>
</div>
{/* 标签 */}
{pool.tags && pool.tags.length > 0 && (
<div className={style.poolTags}>
{pool.tags.map((tag, tagIndex) => (
<span key={tagIndex} className={style.poolTag}>
{tag}
</span>
))}
</div>
)}
</div>
</div>
</div>
))
) : (
<div className={style.poolEmpty}>
<div className={style.poolEmptyText}></div>
</div>
)}
</div>
{/* 底部统计 */}
<div className={style.poolModalFooter}>
<div className={style.poolStats}>
<span> {pools.length} </span>
</div>
</div>
</div>
</Popup>
);
};
export default PoolListModal;

View File

@@ -0,0 +1,232 @@
import React, { useEffect, useState } from "react";
import { Popup, Avatar, SpinLoading, Input } from "antd-mobile";
import { Button, message, Pagination } from "antd";
import { CloseOutlined, SearchOutlined } from "@ant-design/icons";
import style from "../index.module.scss";
import { fetchTransferFriends } from "../api";
interface SendRecordItem {
id: string | number;
nickname?: string;
wechatId?: string;
avatar?: string;
status?: string;
isRecycle?: number;
sendTime?: string;
sendCount?: number;
}
interface SendRcrodModalProps {
visible: boolean;
onClose: () => void;
ruleId?: number;
ruleName?: string;
}
const SendRcrodModal: React.FC<SendRcrodModalProps> = ({
visible,
onClose,
ruleId,
ruleName,
}) => {
const [sendRecords, setSendRecords] = useState<SendRecordItem[]>([]);
const [loading, setLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [searchKeyword, setSearchKeyword] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const [total, setTotal] = useState(0);
const pageSize = 20;
// 获取分发记录数据
const fetchSendRecords = async (page = 1, keyword = "") => {
if (!ruleId) return;
setLoading(true);
try {
const detailRes = await fetchTransferFriends({
workbenchId: ruleId,
page,
limit: pageSize,
keyword,
});
console.log(detailRes);
const recordData = detailRes.list || [];
setSendRecords(recordData);
setTotal(detailRes.total || 0);
} catch (error) {
console.error("获取分发记录失败:", error);
message.error("获取分发记录失败");
} finally {
setLoading(false);
}
};
// 当弹窗打开且有ruleId时获取数据
useEffect(() => {
if (visible && ruleId) {
setCurrentPage(1);
setSearchQuery("");
setSearchKeyword("");
fetchSendRecords(1, "");
}
}, [visible, ruleId]);
// 搜索关键词变化时触发搜索
useEffect(() => {
if (!visible || !ruleId || searchKeyword === "") return;
setCurrentPage(1);
fetchSendRecords(1, searchKeyword);
}, [searchKeyword]);
// 页码变化
useEffect(() => {
if (!visible || !ruleId || currentPage === 1) return;
fetchSendRecords(currentPage, searchKeyword);
}, [currentPage]);
// 处理页码变化
const handlePageChange = (page: number) => {
setCurrentPage(page);
};
// 处理搜索回车
const handleSearchEnter = () => {
setSearchKeyword(searchQuery);
};
// 处理搜索输入
const handleSearchChange = (value: string) => {
setSearchQuery(value);
};
const title = ruleName ? `${ruleName} - 分发统计` : "分发统计";
const getRecycleColor = (isRecycle?: number) => {
switch (isRecycle) {
case 0:
return "#52c41a"; // 绿色 - 未回收
case 1:
return "#ff4d4f"; // 红色 - 已回收
default:
return "#d9d9d9"; // 灰色 - 未知状态
}
};
const getRecycleText = (isRecycle?: number) => {
switch (isRecycle) {
case 0:
return "未回收";
case 1:
return "已回收";
default:
return "未知";
}
};
return (
<Popup
visible={visible}
onMaskClick={onClose}
position="bottom"
bodyStyle={{
height: "100vh",
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
}}
>
<div className={style.accountModal}>
{/* 头部 */}
<div className={style.accountModalHeader}>
<h3 className={style.accountModalTitle}>{title}</h3>
<Button
type="text"
icon={<CloseOutlined />}
onClick={onClose}
className={style.accountModalClose}
/>
</div>
{/* 搜索栏 */}
<div className={style.searchBar}>
<div className={style.searchInputWrapper}>
<SearchOutlined className={style.searchIcon} />
<Input
placeholder="搜索分发记录(回车搜索)"
value={searchQuery}
onChange={handleSearchChange}
onEnterPress={handleSearchEnter}
clearable
/>
</div>
</div>
{/* 分发记录列表 */}
<div className={style.accountList}>
{loading ? (
<div className={style.accountLoading}>
<SpinLoading color="primary" />
<div className={style.accountLoadingText}>
...
</div>
</div>
) : sendRecords.length > 0 ? (
sendRecords.map((record, index) => (
<div key={record.id || index} className={style.accountItem}>
<div className={style.accountAvatar}>
<Avatar
src={record.avatar}
style={{ "--size": "48px" }}
fallback={(record.nickname || record.wechatId || "账号")[0]}
/>
</div>
<div className={style.accountInfo}>
<div className={style.accountName}>
{record.nickname || record.wechatId || `账号${record.id}`}
</div>
<div className={style.accountWechatId}>
{record.wechatId || "未绑定微信号"}
</div>
</div>
<div className={style.accountStatus}>
<span
className={style.statusDot}
style={{
backgroundColor: getRecycleColor(record.isRecycle),
}}
/>
<span className={style.statusText}>
{getRecycleText(record.isRecycle)}
</span>
</div>
</div>
))
) : (
<div className={style.accountEmpty}>
<div className={style.accountEmptyText}></div>
</div>
)}
</div>
{/* 底部统计和分页 */}
<div className={style.accountModalFooter}>
<div className={style.accountStats}>
<span> {total} </span>
</div>
<div className={style.paginationContainer}>
<Pagination
current={currentPage}
pageSize={pageSize}
total={total}
onChange={handlePageChange}
showSizeChanger={false}
showQuickJumper={false}
size="small"
/>
</div>
</div>
</div>
</Popup>
);
};
export default SendRcrodModal;

View File

@@ -69,10 +69,15 @@
.ruleMetaItem {
flex: 1;
text-align: center;
transition: background-color 0.2s ease;
}
.ruleMetaItem:not(:last-child) {
border-right: 1px solid #f0f0f0;
}
.ruleMetaItem:hover {
background-color: #f8f9fa;
border-radius: 6px;
}
.ruleDivider {
border-top: 1px solid #f0f0f0;
@@ -124,3 +129,572 @@
padding: 16px 0;
background: #fff;
}
// 账号列表弹窗样式
.accountModal {
height: 100%;
display: flex;
flex-direction: column;
}
.accountModalHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid #f0f0f0;
background: #fff;
}
.accountModalTitle {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #222;
}
.accountModalClose {
border: none;
background: none;
color: #888;
font-size: 16px;
}
.accountList {
flex: 1;
overflow-y: auto;
padding: 0 20px;
}
.accountItem {
display: flex;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid #f5f5f5;
}
.accountItem:last-child {
border-bottom: none;
}
.accountAvatar {
margin-right: 12px;
flex-shrink: 0;
}
.accountInfo {
flex: 1;
min-width: 0;
}
.accountName {
font-size: 16px;
font-weight: 500;
color: #222;
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.accountWechatId {
font-size: 14px;
color: #888;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.accountStatus {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.statusDot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.statusText {
font-size: 13px;
color: #666;
}
.accountEmpty {
display: flex;
align-items: center;
justify-content: center;
height: 200px;
color: #888;
}
.accountEmptyText {
font-size: 16px;
}
.accountLoading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 200px;
gap: 16px;
padding: 20px;
}
.accountLoadingText {
font-size: 15px;
color: #666;
font-weight: 500;
}
.accountModalFooter {
padding: 16px 20px;
border-top: 1px solid #f0f0f0;
background: #fff;
display: flex;
justify-content: space-between;
align-items: center;
}
.accountStats {
text-align: center;
font-size: 14px;
color: #666;
}
.searchBar {
padding: 16px 20px;
border-bottom: 1px solid #f0f0f0;
background: #fff;
}
.searchInputWrapper {
position: relative;
display: flex;
align-items: center;
}
.searchIcon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: #999;
font-size: 16px;
z-index: 1;
}
.searchInputWrapper :global(.adm-input) {
padding-left: 40px;
border-radius: 8px;
height: 40px;
}
.paginationContainer {
display: flex;
justify-content: center;
}
// 设备列表弹窗样式
.deviceModal {
height: 100%;
display: flex;
flex-direction: column;
}
.deviceModalHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid #f0f0f0;
background: #fff;
}
.deviceModalTitle {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #222;
}
.deviceModalClose {
border: none;
background: none;
color: #888;
font-size: 16px;
}
.deviceList {
flex: 1;
overflow-y: auto;
padding: 0 20px;
}
.deviceItem {
background: #fff;
border-radius: 12px;
padding: 12px;
margin-bottom: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
border: 1px solid #ececec;
transition: all 0.2s ease;
}
.deviceItem:hover {
transform: translateY(-1px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
}
.deviceHeaderRow {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.deviceImeiText {
font-size: 13px;
color: #888;
font-weight: 500;
}
.deviceMainContent {
display: flex;
align-items: center;
}
.deviceAvatar {
width: 48px;
height: 48px;
border-radius: 12px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
margin-right: 12px;
flex-shrink: 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.deviceAvatar img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 12px;
}
.deviceAvatarText {
color: #fff;
font-size: 18px;
font-weight: 600;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
.deviceInfo {
flex: 1;
min-width: 0;
}
.deviceInfoHeader {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.deviceName {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #222;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
margin-right: 8px;
}
.deviceStatusBadge {
padding: 2px 8px;
border-radius: 10px;
font-size: 12px;
font-weight: 500;
flex-shrink: 0;
}
.deviceStatusOnline {
background: rgba(82, 196, 26, 0.1);
color: #52c41a;
}
.deviceStatusOffline {
background: rgba(255, 77, 79, 0.1);
color: #ff4d4f;
}
.deviceInfoList {
display: flex;
flex-direction: column;
gap: 4px;
}
.deviceInfoItem {
display: flex;
align-items: center;
font-size: 13px;
}
.deviceInfoLabel {
color: #888;
margin-right: 6px;
min-width: 50px;
}
.deviceInfoValue {
color: #444;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.deviceFriendCount {
color: #1890ff;
font-weight: 500;
}
.deviceEmpty {
display: flex;
align-items: center;
justify-content: center;
height: 200px;
color: #888;
}
.deviceEmptyText {
font-size: 16px;
}
.deviceLoading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 200px;
gap: 16px;
padding: 20px;
}
.deviceLoadingText {
font-size: 15px;
color: #666;
font-weight: 500;
}
.deviceModalFooter {
padding: 16px 20px;
border-top: 1px solid #f0f0f0;
background: #fff;
}
.deviceStats {
text-align: center;
font-size: 14px;
color: #666;
}
// 流量池列表弹窗样式
.poolModal {
height: 100%;
display: flex;
flex-direction: column;
}
.poolModalHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid #f0f0f0;
background: #fff;
}
.poolModalTitle {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #222;
}
.poolModalClose {
border: none;
background: none;
color: #888;
font-size: 16px;
}
.poolList {
flex: 1;
overflow-y: auto;
padding: 0 20px;
}
.poolItem {
background: #fff;
border-radius: 12px;
padding: 12px;
margin-bottom: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
border: 1px solid #ececec;
transition: all 0.2s ease;
}
.poolItem:hover {
transform: translateY(-1px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
}
.poolMainContent {
display: flex;
align-items: flex-start;
}
.poolIcon {
width: 48px;
height: 48px;
border-radius: 12px;
background: linear-gradient(135deg, #1890ff 0%, #722ed1 100%);
display: flex;
align-items: center;
justify-content: center;
margin-right: 12px;
flex-shrink: 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.poolIconText {
color: #fff;
font-size: 18px;
font-weight: 600;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
.poolInfo {
flex: 1;
min-width: 0;
}
.poolInfoHeader {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.poolName {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #222;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
margin-right: 8px;
}
.poolUserCount {
padding: 2px 8px;
border-radius: 10px;
font-size: 12px;
font-weight: 500;
background: rgba(24, 144, 255, 0.1);
color: #1890ff;
flex-shrink: 0;
}
.poolInfoList {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 8px;
}
.poolInfoItem {
display: flex;
align-items: center;
font-size: 13px;
}
.poolInfoLabel {
color: #888;
margin-right: 6px;
min-width: 60px;
}
.poolInfoValue {
color: #444;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.poolTags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.poolTag {
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
background: rgba(0, 0, 0, 0.05);
color: #666;
border: 1px solid rgba(0, 0, 0, 0.1);
}
.poolLoading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 200px;
gap: 16px;
padding: 20px;
}
.poolLoadingText {
font-size: 15px;
color: #666;
font-weight: 500;
}
.poolEmpty {
display: flex;
align-items: center;
justify-content: center;
height: 200px;
color: #888;
}
.poolEmptyText {
font-size: 16px;
}
.poolModalFooter {
padding: 16px 20px;
border-top: 1px solid #f0f0f0;
background: #fff;
}
.poolStats {
text-align: center;
font-size: 14px;
color: #666;
}

View File

@@ -14,7 +14,6 @@ import {
import NavCommon from "@/components/NavCommon";
import {
fetchDistributionRuleList,
updateDistributionRule,
toggleDistributionRuleStatus,
deleteDistributionRule,
} from "./api";
@@ -32,6 +31,10 @@ import {
} from "@ant-design/icons";
import style from "./index.module.scss";
import { useNavigate } from "react-router-dom";
import AccountListModal from "./components/AccountListModal";
import DeviceListModal from "./components/DeviceListModal";
import PoolListModal from "./components/PoolListModal";
import SendRcrodModal from "./components/SendRcrodModal";
const PAGE_SIZE = 10;
@@ -51,6 +54,12 @@ const TrafficDistributionList: React.FC = () => {
const [searchQuery, setSearchQuery] = useState("");
// 优化用menuLoadingId标记当前操作的item
const [menuLoadingId, setMenuLoadingId] = useState<number | null>(null);
// 弹窗控制
const [accountModalVisible, setAccountModalVisible] = useState(false);
const [deviceModalVisible, setDeviceModalVisible] = useState(false);
const [poolModalVisible, setPoolModalVisible] = useState(false);
const [sendRecordModalVisible, setSendRecordModalVisible] = useState(false);
const [currentRule, setCurrentRule] = useState<DistributionRule | null>(null);
const navigate = useNavigate();
useEffect(() => {
@@ -128,6 +137,30 @@ const TrafficDistributionList: React.FC = () => {
}
};
// 显示账号列表弹窗
const showAccountList = (item: DistributionRule) => {
setCurrentRule(item);
setAccountModalVisible(true);
};
// 显示设备列表弹窗
const showDeviceList = (item: DistributionRule) => {
setCurrentRule(item);
setDeviceModalVisible(true);
};
// 显示流量池列表弹窗
const showPoolList = (item: DistributionRule) => {
setCurrentRule(item);
setPoolModalVisible(true);
};
// 显示分发统计弹窗
const showSendRecord = (item: DistributionRule) => {
setCurrentRule(item);
setSendRecordModalVisible(true);
};
const renderCard = (item: DistributionRule) => {
const menu = (
<Menu onClick={({ key }) => handleMenuClick(key, item)}>
@@ -213,19 +246,31 @@ const TrafficDistributionList: React.FC = () => {
</div>
</div>
<div className={style.ruleMeta}>
<div className={style.ruleMetaItem}>
<div
className={style.ruleMetaItem}
style={{ cursor: "pointer" }}
onClick={() => showAccountList(item)}
>
<div style={{ fontSize: 18, fontWeight: 600 }}>
{item.config?.account?.length || 0}
</div>
<div style={{ fontSize: 13, color: "#888" }}></div>
</div>
<div className={style.ruleMetaItem}>
<div
className={style.ruleMetaItem}
style={{ cursor: "pointer" }}
onClick={() => showDeviceList(item)}
>
<div style={{ fontSize: 18, fontWeight: 600 }}>
{item.config?.devices?.length || 0}
</div>
<div style={{ fontSize: 13, color: "#888" }}></div>
</div>
<div className={style.ruleMetaItem}>
<div
className={style.ruleMetaItem}
style={{ cursor: "pointer" }}
onClick={() => showPoolList(item)}
>
<div style={{ fontSize: 18, fontWeight: 600 }}>
{item.config?.pools?.length || 0}
</div>
@@ -250,6 +295,18 @@ const TrafficDistributionList: React.FC = () => {
</div>
</div>
<div
className={style.ruleStatsItem}
style={{ cursor: "pointer" }}
onClick={() => showSendRecord(item)}
>
<span style={{ fontSize: 16, fontWeight: 600 }}>
{item.config?.total?.totalUsers || 0}
</span>
<div style={{ fontSize: 13, color: "#888", marginTop: 2 }}>
</div>
</div>
</div>
<div className={style.ruleFooter}>
<span>
@@ -305,7 +362,7 @@ const TrafficDistributionList: React.FC = () => {
}
loading={loading}
footer={
<div className={style.pagination}>
<div className="pagination-container">
<Pagination
current={page}
pageSize={PAGE_SIZE}
@@ -325,6 +382,38 @@ const TrafficDistributionList: React.FC = () => {
<div className={style.empty}></div>
)}
</div>
{/* 账号列表弹窗 */}
<AccountListModal
visible={accountModalVisible}
onClose={() => setAccountModalVisible(false)}
ruleId={currentRule?.id}
ruleName={currentRule?.name}
/>
{/* 设备列表弹窗 */}
<DeviceListModal
visible={deviceModalVisible}
onClose={() => setDeviceModalVisible(false)}
ruleId={currentRule?.id}
ruleName={currentRule?.name}
/>
{/* 流量池列表弹窗 */}
<PoolListModal
visible={poolModalVisible}
onClose={() => setPoolModalVisible(false)}
ruleId={currentRule?.id}
ruleName={currentRule?.name}
/>
{/* 分发统计弹窗 */}
<SendRcrodModal
visible={sendRecordModalVisible}
onClose={() => setSendRecordModalVisible(false)}
ruleId={currentRule?.id}
ruleName={currentRule?.name}
/>
</Layout>
);
};

View File

@@ -21,32 +21,47 @@
height: 64px;
min-height: 64px;
flex-shrink: 0;
gap: 16px; // 确保信息区域和按钮区域有足够间距
.chatInfo {
.chatHeaderInfo {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
min-width: 0; // 防止flex子元素溢出
.chatDetails {
.chatName {
.chatHeaderDetails {
flex: 1;
display: flex;
align-items: center;
.chatHeaderName {
font-size: 16px;
font-weight: 600;
color: #262626;
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-right: 30px;
.onlineStatus {
.chatHeaderOnlineStatus {
font-size: 12px;
color: #52c41a;
font-weight: normal;
flex-shrink: 0; // 防止在线状态被压缩
}
}
.chatStatus {
.chatHeaderType {
font-size: 12px;
color: #8c8c8c;
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
@@ -447,9 +462,9 @@
height: 56px;
min-height: 56px;
.chatInfo {
.chatDetails {
.chatName {
.chatHeaderInfo {
.chatHeaderDetails {
.chatHeaderName {
font-size: 14px;
}
}

View File

@@ -263,22 +263,24 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
<Layout className={styles.chatMain}>
{/* 聊天头部 */}
<Header className={styles.chatHeader}>
<div className={styles.chatInfo}>
<div className={styles.chatHeaderInfo}>
<Avatar
size={40}
src={chat.avatar}
icon={chat.type === "group" ? <TeamOutlined /> : <UserOutlined />}
/>
<div className={styles.chatDetails}>
<div className={styles.chatName}>
<div
className={styles.chatHeaderDetails}
style={{
display: "flex",
}}
>
<div className={styles.chatHeaderName}>
{chat.name}
{chat.online && (
<span className={styles.onlineStatus}>线</span>
<span className={styles.chatHeaderOnlineStatus}>线</span>
)}
</div>
<div className={styles.chatStatus}>
{chat.type === "group" ? "群聊" : "私聊"}
</div>
</div>
</div>
<Space>

176
Cunkebao/src/polyfills.ts Normal file
View File

@@ -0,0 +1,176 @@
// ES5兼容性polyfill - 确保在低版本浏览器中正常运行
// 特别针对Android 7等低版本内核优化
// 基础polyfill
import "core-js/stable";
import "regenerator-runtime/runtime";
// Promise支持
import "core-js/features/promise";
// Array方法支持
import "core-js/features/array/from";
import "core-js/features/array/find";
import "core-js/features/array/includes";
import "core-js/features/array/find-index";
import "core-js/features/array/fill";
import "core-js/features/array/copy-within";
// Object方法支持
import "core-js/features/object/assign";
import "core-js/features/object/entries";
import "core-js/features/object/values";
import "core-js/features/object/keys";
// String方法支持
import "core-js/features/string/includes";
import "core-js/features/string/starts-with";
import "core-js/features/string/ends-with";
import "core-js/features/string/pad-start";
import "core-js/features/string/pad-end";
import "core-js/features/string/trim-start";
import "core-js/features/string/trim-end";
import "core-js/features/string/repeat";
// Number方法支持
import "core-js/features/number/is-finite";
import "core-js/features/number/is-integer";
import "core-js/features/number/is-nan";
import "core-js/features/number/is-safe-integer";
// Math方法支持
import "core-js/features/math/sign";
import "core-js/features/math/trunc";
import "core-js/features/math/cbrt";
import "core-js/features/math/clz32";
import "core-js/features/math/imul";
import "core-js/features/math/fround";
import "core-js/features/math/hypot";
// Map和Set支持
import "core-js/features/map";
import "core-js/features/set";
import "core-js/features/weak-map";
import "core-js/features/weak-set";
// Symbol支持
import "core-js/features/symbol";
import "core-js/features/symbol/for";
import "core-js/features/symbol/key-for";
// 正则表达式支持
import "core-js/features/regexp/flags";
import "core-js/features/regexp/sticky";
// 函数支持
import "core-js/features/function/name";
import "core-js/features/function/has-instance";
// 全局对象支持
import "core-js/features/global-this";
// 确保全局对象可用
if (typeof window !== "undefined") {
// 确保Promise在全局可用
if (!window.Promise) {
window.Promise = require("core-js/features/promise");
}
// 确保fetch在全局可用
if (!window.fetch) {
window.fetch = require("whatwg-fetch");
}
// 确保requestAnimationFrame在全局可用
if (!window.requestAnimationFrame) {
window.requestAnimationFrame = function (callback) {
return setTimeout(callback, 1000 / 60);
};
}
if (!window.cancelAnimationFrame) {
window.cancelAnimationFrame = function (id) {
clearTimeout(id);
};
}
// 确保IntersectionObserver在全局可用
if (!window.IntersectionObserver) {
window.IntersectionObserver = function (callback, options) {
return {
observe: function () {},
unobserve: function () {},
disconnect: function () {},
};
};
}
// 确保ResizeObserver在全局可用
if (!window.ResizeObserver) {
window.ResizeObserver = function (callback) {
return {
observe: function () {},
unobserve: function () {},
disconnect: function () {},
};
};
}
// 确保MutationObserver在全局可用
if (!window.MutationObserver) {
window.MutationObserver = function (callback) {
return {
observe: function () {},
disconnect: function () {},
};
};
}
// 确保Performance API在全局可用
if (!window.performance) {
window.performance = {
now: function () {
return Date.now();
},
};
}
// 确保URLSearchParams在全局可用
if (!window.URLSearchParams) {
window.URLSearchParams = require("core-js/features/url-search-params");
}
// 确保URL在全局可用
if (!window.URL) {
window.URL = require("core-js/features/url");
}
// 确保AbortController在全局可用
if (!window.AbortController) {
window.AbortController = function () {
return {
signal: {
aborted: false,
addEventListener: function () {},
removeEventListener: function () {},
},
abort: function () {
this.signal.aborted = true;
},
};
};
}
// 确保AbortSignal在全局可用
if (!window.AbortSignal) {
window.AbortSignal = {
abort: function () {
return {
aborted: true,
addEventListener: function () {},
removeEventListener: function () {},
};
},
};
}
}

View File

@@ -36,12 +36,12 @@ const routes = [
auth: true,
},
{
path: "/traffic-pool",
path: "/mine/traffic-pool",
element: <TrafficPool />,
auth: true,
},
{
path: "/traffic-pool/detail/:wxid/:userId",
path: "/mine/traffic-pool/detail/:wxid/:userId",
element: <TrafficPoolDetail />,
auth: true,
},

View File

@@ -1,6 +1,7 @@
import SelectTest from "@/pages/mobile/test/select";
import TestIndex from "@/pages/mobile/test/index";
import UploadTest from "@/pages/mobile/test/upload";
import UpdateNotificationTest from "@/pages/mobile/test/update-notification";
import IframeDebugPage from "@/pages/iframe";
import { DEV_FEATURES } from "@/utils/env";
@@ -22,6 +23,11 @@ const componentTestRoutes = DEV_FEATURES.SHOW_TEST_PAGES
element: <UploadTest />,
auth: true,
},
{
path: "/test/update-notification",
element: <UpdateNotificationTest />,
auth: true,
},
{
path: "/test/iframe",
element: <IframeDebugPage />,

View File

@@ -2,11 +2,13 @@
export * from "./module/user";
export * from "./module/app";
export * from "./module/settings";
export * from "./module/websocket";
// 导入store实例
import { useUserStore } from "./module/user";
import { useAppStore } from "./module/app";
import { useSettingsStore } from "./module/settings";
import { useWebSocketStore } from "./module/websocket";
// 导出持久化store创建函数
export {
@@ -32,6 +34,7 @@ export interface StoreState {
user: ReturnType<typeof useUserStore.getState>;
app: ReturnType<typeof useAppStore.getState>;
settings: ReturnType<typeof useSettingsStore.getState>;
websocket: ReturnType<typeof useWebSocketStore.getState>;
}
// 便利的store访问函数
@@ -39,12 +42,14 @@ export const getStores = (): StoreState => ({
user: useUserStore.getState(),
app: useAppStore.getState(),
settings: useSettingsStore.getState(),
websocket: useWebSocketStore.getState(),
});
// 获取特定store状态
export const getUserStore = () => useUserStore.getState();
export const getAppStore = () => useAppStore.getState();
export const getSettingsStore = () => useSettingsStore.getState();
export const getWebSocketStore = () => useWebSocketStore.getState();
// 清除所有持久化数据(使用工具函数)
export const clearAllPersistedData = clearAllData;
@@ -56,6 +61,7 @@ export const getPersistKeys = () => Object.values(PERSIST_KEYS);
export const subscribeToUserStore = useUserStore.subscribe;
export const subscribeToAppStore = useAppStore.subscribe;
export const subscribeToSettingsStore = useSettingsStore.subscribe;
export const subscribeToWebSocketStore = useWebSocketStore.subscribe;
// 组合订阅函数
export const subscribeToAllStores = (callback: (state: StoreState) => void) => {
@@ -68,10 +74,14 @@ export const subscribeToAllStores = (callback: (state: StoreState) => void) => {
const unsubscribeSettings = useSettingsStore.subscribe(() => {
callback(getStores());
});
const unsubscribeWebSocket = useWebSocketStore.subscribe(() => {
callback(getStores());
});
return () => {
unsubscribeUser();
unsubscribeApp();
unsubscribeSettings();
unsubscribeWebSocket();
};
};

View File

@@ -0,0 +1,51 @@
// 账户信息接口
export interface CkAccount {
id: number;
realName: string;
nickname: string | null;
memo: string | null;
avatar: string;
userName: string;
secret: string;
accountType: number;
departmentId: number;
useGoogleSecretKey: boolean;
hasVerifyGoogleSecret: boolean;
}
// 权限片段接口
export interface PrivilegeFrag {
// 根据实际数据结构补充
[key: string]: any;
}
// 租户信息接口
export interface CkTenant {
id: number;
name: string;
guid: string;
thirdParty: string | null;
tenantType: number;
deployName: string;
}
// 触客宝用户信息接口
export interface CkUserInfo {
account: CkAccount;
privilegeFrags: PrivilegeFrag[];
tenant: CkTenant;
}
// 状态接口
export interface CkChatState {
userInfo: CkUserInfo | null;
isLoggedIn: boolean;
setUserInfo: (userInfo: CkUserInfo) => void;
clearUserInfo: () => void;
updateAccount: (account: Partial<CkAccount>) => void;
updateTenant: (tenant: Partial<CkTenant>) => void;
getAccountId: () => number | null;
getTenantId: () => number | null;
getAccountName: () => string | null;
getTenantName: () => string | null;
}

View File

@@ -0,0 +1,89 @@
import { createPersistStore } from "@/store/createPersistStore";
import { CkChatState, CkUserInfo, CkAccount, CkTenant } from "./ckchat.data";
export const useCkChatStore = createPersistStore<CkChatState>(
set => ({
userInfo: null,
isLoggedIn: false,
// 设置用户信息
setUserInfo: (userInfo: CkUserInfo) => {
set({ userInfo, isLoggedIn: true });
},
// 清除用户信息
clearUserInfo: () => {
set({ userInfo: null, isLoggedIn: false });
},
// 更新账户信息
updateAccount: (account: Partial<CkAccount>) => {
set(state => ({
userInfo: state.userInfo
? {
...state.userInfo,
account: { ...state.userInfo.account, ...account },
}
: null,
}));
},
// 更新租户信息
updateTenant: (tenant: Partial<CkTenant>) => {
set(state => ({
userInfo: state.userInfo
? {
...state.userInfo,
tenant: { ...state.userInfo.tenant, ...tenant },
}
: null,
}));
},
// 获取账户ID
getAccountId: () => {
const state = useCkChatStore.getState();
return state.userInfo?.account?.id || null;
},
// 获取租户ID
getTenantId: () => {
const state = useCkChatStore.getState();
return state.userInfo?.tenant?.id || null;
},
// 获取账户名称
getAccountName: () => {
const state = useCkChatStore.getState();
return (
state.userInfo?.account?.realName ||
state.userInfo?.account?.userName ||
null
);
},
// 获取租户名称
getTenantName: () => {
const state = useCkChatStore.getState();
return state.userInfo?.tenant?.name || null;
},
}),
{
name: "ckchat-store",
partialize: state => ({
userInfo: state.userInfo,
isLoggedIn: state.isLoggedIn,
}),
onRehydrateStorage: () => state => {
// console.log("CkChat store hydrated:", state);
},
},
);
// 导出便捷的获取方法
export const getCkAccountId = () => useCkChatStore.getState().getAccountId();
export const getCkTenantId = () => useCkChatStore.getState().getTenantId();
export const getCkAccountName = () =>
useCkChatStore.getState().getAccountName();
export const getCkTenantName = () => useCkChatStore.getState().getTenantName();

View File

@@ -22,11 +22,14 @@ export interface User {
interface UserState {
user: User | null;
token: string | null;
token2: string | null;
isLoggedIn: boolean;
setUser: (user: User) => void;
setToken: (token: string) => void;
setToken2: (token2: string) => void;
clearUser: () => void;
login: (token: string, userInfo: User, deviceTotal: number) => void;
login2: (token2: string) => void;
logout: () => void;
}
@@ -34,10 +37,13 @@ export const useUserStore = createPersistStore<UserState>(
set => ({
user: null,
token: null,
token2: null,
isLoggedIn: false,
setUser: user => set({ user, isLoggedIn: true }),
setToken: token => set({ token }),
clearUser: () => set({ user: null, token: null, isLoggedIn: false }),
setToken2: token2 => set({ token2 }),
clearUser: () =>
set({ user: null, token: null, token2: null, isLoggedIn: false }),
login: (token, userInfo, deviceTotal) => {
// 只将token存储到localStorage
localStorage.setItem("token", token);
@@ -72,10 +78,15 @@ export const useUserStore = createPersistStore<UserState>(
window.location.href = "/guide";
}
},
login2: token2 => {
localStorage.setItem("token2", token2);
set({ token2, isLoggedIn: true });
},
logout: () => {
// 清除localStorage中的token
localStorage.removeItem("token");
set({ user: null, token: null, isLoggedIn: false });
localStorage.removeItem("token2");
set({ user: null, token: null, token2: null, isLoggedIn: false });
},
}),
{
@@ -83,6 +94,7 @@ export const useUserStore = createPersistStore<UserState>(
partialize: state => ({
user: state.user,
token: state.token,
token2: state.token2,
isLoggedIn: state.isLoggedIn,
}),
onRehydrateStorage: () => state => {

View File

@@ -0,0 +1,376 @@
import { createPersistStore } from "@/store/createPersistStore";
import { Toast } from "antd-mobile";
import { useUserStore } from "./user";
// WebSocket消息类型
export interface WebSocketMessage {
id: string;
type: string;
content: any;
timestamp: number;
sender?: string;
receiver?: string;
}
// WebSocket连接状态
export enum WebSocketStatus {
DISCONNECTED = "disconnected",
CONNECTING = "connecting",
CONNECTED = "connected",
RECONNECTING = "reconnecting",
ERROR = "error",
}
// WebSocket配置
interface WebSocketConfig {
url: string;
client: string;
accountId: string;
accessToken: string;
autoReconnect: boolean;
reconnectInterval: number;
maxReconnectAttempts: number;
}
interface WebSocketState {
// 连接状态
status: WebSocketStatus;
ws: WebSocket | null;
// 配置信息
config: WebSocketConfig | null;
// 消息相关
messages: WebSocketMessage[];
unreadCount: number;
// 重连相关
reconnectAttempts: number;
reconnectTimer: NodeJS.Timeout | null;
// 方法
connect: (config: Partial<WebSocketConfig>) => void;
disconnect: () => void;
sendMessage: (message: Omit<WebSocketMessage, "id" | "timestamp">) => void;
sendCommand: (cmdType: string, data?: any) => void;
clearMessages: () => void;
markAsRead: () => void;
reconnect: () => void;
// 内部方法
_handleOpen: () => void;
_handleMessage: (event: MessageEvent) => void;
_handleClose: (event: CloseEvent) => void;
_handleError: (event: Event) => void;
_startReconnectTimer: () => void;
_stopReconnectTimer: () => void;
}
// 默认配置
const DEFAULT_CONFIG: WebSocketConfig = {
url: (import.meta as any).env?.VITE_API_WS_URL || "ws://localhost:8080",
client: "kefu-client",
accountId: "",
accessToken: "",
autoReconnect: true,
reconnectInterval: 3000,
maxReconnectAttempts: 5,
};
export const useWebSocketStore = createPersistStore<WebSocketState>(
(set, get) => ({
status: WebSocketStatus.DISCONNECTED,
ws: null,
config: null,
messages: [],
unreadCount: 0,
reconnectAttempts: 0,
reconnectTimer: null,
// 连接WebSocket
connect: (config: Partial<WebSocketConfig>) => {
const currentState = get();
// 如果已经连接,先断开
if (currentState.ws) {
currentState.disconnect();
}
// 合并配置
const fullConfig: WebSocketConfig = {
...DEFAULT_CONFIG,
...config,
};
// 获取用户信息
const { token, token2, user } = useUserStore.getState();
const accessToken = fullConfig.accessToken || token2 || token;
if (!accessToken) {
Toast.show({ content: "未找到有效的访问令牌", position: "top" });
return;
}
// 构建WebSocket URL
const params = {
client: fullConfig.client,
accountId: fullConfig.accountId || user?.s2_accountId || "",
accessToken: accessToken,
t: Date.now().toString(),
};
const wsUrl =
fullConfig.url + "?" + new URLSearchParams(params).toString();
set({
status: WebSocketStatus.CONNECTING,
config: fullConfig,
});
try {
const ws = new WebSocket(wsUrl);
// 绑定事件处理器
ws.onopen = () => get()._handleOpen();
ws.onmessage = event => get()._handleMessage(event);
ws.onclose = event => get()._handleClose(event);
ws.onerror = event => get()._handleError(event);
set({ ws });
console.log("WebSocket连接创建成功", wsUrl);
} catch (error) {
console.error("WebSocket连接失败:", error);
set({ status: WebSocketStatus.ERROR });
Toast.show({ content: "WebSocket连接失败", position: "top" });
}
},
// 断开连接
disconnect: () => {
const currentState = get();
if (currentState.ws) {
currentState.ws.close();
}
currentState._stopReconnectTimer();
set({
status: WebSocketStatus.DISCONNECTED,
ws: null,
reconnectAttempts: 0,
});
console.log("WebSocket连接已断开");
},
// 发送消息
sendMessage: (message: Omit<WebSocketMessage, "id" | "timestamp">) => {
const currentState = get();
if (
currentState.status !== WebSocketStatus.CONNECTED ||
!currentState.ws
) {
Toast.show({ content: "WebSocket未连接", position: "top" });
return;
}
const fullMessage: WebSocketMessage = {
...message,
id: Date.now().toString(),
timestamp: Date.now(),
};
try {
currentState.ws.send(JSON.stringify(fullMessage));
console.log("消息发送成功:", fullMessage);
} catch (error) {
console.error("消息发送失败:", error);
Toast.show({ content: "消息发送失败", position: "top" });
}
},
// 发送命令
sendCommand: (cmdType: string, data?: any) => {
const currentState = get();
if (
currentState.status !== WebSocketStatus.CONNECTED ||
!currentState.ws
) {
Toast.show({ content: "WebSocket未连接", position: "top" });
return;
}
const { user } = useUserStore.getState();
const { token, token2 } = useUserStore.getState();
const accessToken = token2 || token;
const command = {
accessToken: accessToken,
accountId: user?.s2_accountId,
client: currentState.config?.client || "kefu-client",
cmdType: cmdType,
seq: Date.now(),
...data,
};
try {
currentState.ws.send(JSON.stringify(command));
console.log("命令发送成功:", command);
} catch (error) {
console.error("命令发送失败:", error);
Toast.show({ content: "命令发送失败", position: "top" });
}
},
// 清除消息
clearMessages: () => {
set({ messages: [], unreadCount: 0 });
},
// 标记为已读
markAsRead: () => {
set({ unreadCount: 0 });
},
// 重连
reconnect: () => {
const currentState = get();
if (currentState.config) {
currentState.connect(currentState.config);
}
},
// 内部方法:处理连接打开
_handleOpen: () => {
const currentState = get();
set({
status: WebSocketStatus.CONNECTED,
reconnectAttempts: 0,
});
console.log("WebSocket连接成功");
// 发送登录命令
if (currentState.config) {
currentState.sendCommand("CmdSignIn");
}
Toast.show({ content: "WebSocket连接成功", position: "top" });
},
// 内部方法:处理消息接收
_handleMessage: (event: MessageEvent) => {
try {
const data = JSON.parse(event.data);
console.log("收到WebSocket消息:", data);
const currentState = get();
const newMessage: WebSocketMessage = {
id: Date.now().toString(),
type: data.type || "message",
content: data,
timestamp: Date.now(),
sender: data.sender,
receiver: data.receiver,
};
set({
messages: [...currentState.messages, newMessage],
unreadCount: currentState.unreadCount + 1,
});
// 可以在这里添加消息处理逻辑
// 比如播放提示音、显示通知等
} catch (error) {
console.error("解析WebSocket消息失败:", error);
}
},
// 内部方法:处理连接关闭
_handleClose: (event: CloseEvent) => {
const currentState = get();
console.log("WebSocket连接关闭:", event.code, event.reason);
set({
status: WebSocketStatus.DISCONNECTED,
ws: null,
});
// 自动重连逻辑
if (
currentState.config?.autoReconnect &&
currentState.reconnectAttempts <
(currentState.config?.maxReconnectAttempts || 5)
) {
currentState._startReconnectTimer();
}
},
// 内部方法:处理连接错误
_handleError: (event: Event) => {
console.error("WebSocket连接错误:", event);
set({ status: WebSocketStatus.ERROR });
Toast.show({ content: "WebSocket连接错误", position: "top" });
},
// 内部方法:启动重连定时器
_startReconnectTimer: () => {
const currentState = get();
currentState._stopReconnectTimer();
set({
status: WebSocketStatus.RECONNECTING,
reconnectAttempts: currentState.reconnectAttempts + 1,
});
const timer = setTimeout(() => {
console.log(
`尝试重连 (${currentState.reconnectAttempts + 1}/${currentState.config?.maxReconnectAttempts})`,
);
currentState.reconnect();
}, currentState.config?.reconnectInterval || 3000);
set({ reconnectTimer: timer });
},
// 内部方法:停止重连定时器
_stopReconnectTimer: () => {
const currentState = get();
if (currentState.reconnectTimer) {
clearTimeout(currentState.reconnectTimer);
set({ reconnectTimer: null });
}
},
}),
{
name: "websocket-store",
partialize: state => ({
// 只持久化必要的状态不持久化WebSocket实例
status: state.status,
config: state.config,
messages: state.messages.slice(-100), // 只保留最近100条消息
unreadCount: state.unreadCount,
reconnectAttempts: state.reconnectAttempts,
}),
onRehydrateStorage: () => state => {
// 页面刷新后,如果之前是连接状态,尝试重新连接
if (state && state.status === WebSocketStatus.CONNECTED && state.config) {
// 延迟一下再重连,确保页面完全加载
setTimeout(() => {
state.connect(state.config);
}, 1000);
}
},
},
);

View File

@@ -270,7 +270,6 @@ button {
padding: 14px 0;
background: white;
border-radius: 12px;
margin-top: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
:global(.ant-pagination) {

View File

@@ -11,6 +11,7 @@ export interface Device {
nickname?: string;
battery?: number;
lastActive?: string;
avatar?: string;
features?: {
autoAddFriend?: boolean;
autoReply?: boolean;

View File

@@ -0,0 +1,67 @@
// 预定义的颜色数组,确保颜色不重复且美观
const CHART_COLORS = [
"#1677ff", // 蓝色
"#52c41a", // 绿色
"#fa8c16", // 橙色
"#eb2f96", // 粉色
"#722ed1", // 紫色
"#13c2c2", // 青色
"#fa541c", // 红色
"#2f54eb", // 深蓝色
"#faad14", // 黄色
"#a0d911", // 青绿色
"#f5222d", // 红色
"#1890ff", // 天蓝色
"#52c41a", // 绿色
"#fa8c16", // 橙色
"#eb2f96", // 粉色
];
/**
* 获取图表颜色
* @param index 颜色索引
* @returns 颜色值
*/
export const getChartColor = (index: number): string => {
return CHART_COLORS[index % CHART_COLORS.length];
};
/**
* 获取多个图表颜色
* @param count 需要的颜色数量
* @returns 颜色数组
*/
export const getChartColors = (count: number): string[] => {
return Array.from({ length: count }, (_, index) => getChartColor(index));
};
/**
* 获取随机图表颜色
* @returns 随机颜色值
*/
export const getRandomChartColor = (): string => {
const randomIndex = Math.floor(Math.random() * CHART_COLORS.length);
return CHART_COLORS[randomIndex];
};
/**
* 获取渐变色数组
* @param baseColor 基础颜色
* @param count 渐变数量
* @returns 渐变色数组
*/
export const getGradientColors = (
baseColor: string,
count: number,
): string[] => {
// 这里可以实现颜色渐变逻辑
// 暂时返回相同颜色的数组
return Array.from({ length: count }, () => baseColor);
};
export default {
getChartColor,
getChartColors,
getRandomChartColor,
getGradientColors,
};

177
Cunkebao/兼容性说明.md Normal file
View File

@@ -0,0 +1,177 @@
# 存客宝项目 - 浏览器兼容性说明
## 🎯 **兼容性目标**
本项目已配置为支持以下浏览器版本:
- **Chrome**: 50+
- **Firefox**: 50+
- **Safari**: 10+
- **Edge**: 12+
- **Internet Explorer**: 11+ (部分功能受限)
- **Android**: 4.4+ (特别优化Android 7)
- **iOS**: 9+
## 🔧 **兼容性配置**
### 1. **Polyfill 支持**
项目已集成以下 polyfill 来确保低版本浏览器兼容性:
- **core-js**: ES6+ 特性支持
- **regenerator-runtime**: async/await 支持
- **whatwg-fetch**: fetch API 支持
- **Android专用polyfill**: 针对Android 7等低版本系统优化
### 2. **构建配置**
- 使用 **terser** 进行代码压缩
- 配置了 **browserslist** 目标浏览器
- 添加了兼容性检测组件
- 特别针对Android设备优化
### 3. **特性支持**
项目通过 polyfill 支持以下 ES6+ 特性:
- ✅ Promise
- ✅ fetch API
- ✅ Array.from, Array.find, Array.includes, Array.findIndex
- ✅ Object.assign, Object.entries, Object.values, Object.keys
- ✅ String.includes, String.startsWith, String.endsWith
- ✅ Map, Set, WeakMap, WeakSet
- ✅ Symbol
- ✅ requestAnimationFrame
- ✅ IntersectionObserver
- ✅ ResizeObserver
- ✅ URLSearchParams
- ✅ AbortController
## 🚀 **使用方法**
### 开发环境
```bash
pnpm dev
```
### 生产构建
```bash
pnpm build
```
### 预览构建结果
```bash
pnpm preview
```
## 📱 **Android 特别优化**
### **Android 7 兼容性**
Android 7 (API 24) 系统对ES6+特性支持不完整,项目已特别优化:
#### **问题解决:**
- ✅ Array.prototype.includes 方法缺失
- ✅ String.prototype.includes 方法缺失
- ✅ Object.assign 方法缺失
- ✅ Array.from 方法缺失
- ✅ requestAnimationFrame 缺失
- ✅ IntersectionObserver 缺失
- ✅ URLSearchParams 缺失
#### **解决方案:**
- 使用自定义polyfill补充缺失方法
- 提供降级实现确保功能可用
- 自动检测Android版本并启用相应polyfill
### **Android WebView 优化**
- 针对系统WebView进行特别优化
- 支持微信、QQ等内置浏览器
- 提供降级方案确保基本功能可用
## ⚠️ **注意事项**
1. **Android 7 支持**
- 已启用兼容模式,基本功能可用
- 建议升级到Android 8+或使用最新版Chrome
- 部分高级特性可能受限
2. **Android 6 及以下**
- 支持有限,建议升级系统
- 使用最新版Chrome浏览器
- 部分功能可能不可用
3. **移动端兼容性**
- iOS Safari 10+
- Android Chrome 50+
- 微信内置浏览器 (部分功能受限)
- QQ内置浏览器 (部分功能受限)
4. **性能考虑**
- polyfill 会增加包体积
- 现代浏览器会自动忽略不需要的 polyfill
- Android设备上会有额外的兼容性检测
## 🔍 **兼容性检测**
项目包含自动兼容性检测功能:
### **通用检测**
- 在低版本浏览器中会显示警告提示
- 控制台会输出兼容性信息
- 建议用户升级浏览器
### **Android专用检测**
- 自动检测Android系统版本
- 检测Chrome和WebView版本
- 识别微信、QQ等内置浏览器
- 提供针对性的解决方案建议
## 📝 **更新日志**
### v3.0.0
- ✅ 添加 ES5 兼容性支持
- ✅ 集成 core-js polyfill
- ✅ 添加兼容性检测组件
- ✅ 优化构建配置
-**新增Android 7专用polyfill**
-**新增Android兼容性检测**
-**优化移动端体验**
## 🛠️ **故障排除**
如果遇到兼容性问题:
1. **Android设备问题**
- 检查Android系统版本
- 确认Chrome浏览器版本
- 查看控制台错误信息
- 尝试使用系统浏览器而非内置浏览器
2. **通用问题**
- 检查浏览器版本是否在支持范围内
- 查看控制台是否有错误信息
- 确认 polyfill 是否正确加载
- 尝试清除浏览器缓存
3. **性能问题**
- 在低版本设备上可能加载较慢
- 建议使用WiFi网络
- 关闭不必要的浏览器扩展
## 📞 **技术支持**
如有兼容性问题,请联系开发团队。
### **特别说明**
本项目已针对Android 7等低版本系统进行了特别优化通过代码弥补了系统内核对ES6+特性支持不完整的问题。虽然不能完全替代系统升级但可以确保应用在低版本Android设备上正常运行。

109
ckApp/test.html Normal file
View File

@@ -0,0 +1,109 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<!-- iOS 图标和应用配置 -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="My PWA">
<!-- iOS 专用图标 -->
<link rel="apple-touch-icon" sizes="76x76" href="icons/76x76.png">
<link rel="apple-touch-icon" sizes="120x120" href="icons/120x120.png">
<link rel="apple-touch-icon" sizes="152x152" href="icons/152x152.png">
<link rel="apple-touch-icon" sizes="167x167" href="icons/167x167.png">
<link rel="apple-touch-icon" sizes="180x180" href="icons/180x180.png">
<!-- 通用图标配置 -->
<link rel="icon" type="image/png" sizes="40x40" href="icons/40x40.png">
<link rel="icon" type="image/png" sizes="20x20" href="icons/20x20.png">
<link rel="manifest" href="manifest.json">
<link rel="mask-icon" href="icons/safari-pinned-tab.svg" color="#2196f3">
<meta name="msapplication-TileColor" content="#2196f3">
<meta name="theme-color" content="#2196f3">
<title>My PWA with Custom Icons</title>
<style>
/* 保持之前的样式不变 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
body {
padding-top: constant(safe-area-inset-top);
padding-top: env(safe-area-inset-top);
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom);
background: linear-gradient(135deg, #2196f3 0%, #0d47a1 100%);
color: white;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
}
.container {
max-width: 600px;
padding: 2rem;
}
h1 {
font-size: 2.5rem;
margin-bottom: 1rem;
}
.icon-preview {
width: 120px;
height: 120px;
margin: 2rem auto;
border-radius: 24px; /* 模拟iOS图标圆角 */
overflow: hidden;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
.icon-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
</style>
</head>
<body>
<div class="container">
<h1>Custom PWA Icons</h1>
<p>Your app will now have proper icons when added to home screen</p>
<div class="icon-preview">
<img src="icons/icon-180x180.png" alt="App Icon Preview">
</div>
<p>Make sure to use all required icon sizes for best results across devices</p>
</div>
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('sw.js')
.then(registration => {
console.log('ServiceWorker registered successfully');
})
.catch(err => {
console.log('ServiceWorker registration failed:', err);
});
});
}
</script>
</body>
</html>