Merge branch 'yongpxu-dev' into yongpxu-dev4
This commit is contained in:
@@ -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=存客宝
|
||||
|
||||
@@ -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=存客宝
|
||||
|
||||
26
Cunkebao/dist/.vite/manifest.json
vendored
26
Cunkebao/dist/.vite/manifest.json
vendored
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
10
Cunkebao/dist/index.html
vendored
10
Cunkebao/dist/index.html
vendored
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
352
Cunkebao/src/android-polyfills.ts
Normal file
352
Cunkebao/src/android-polyfills.ts
Normal 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已加载完成");
|
||||
}
|
||||
81
Cunkebao/src/api/request2.ts
Normal file
81
Cunkebao/src/api/request2.ts
Normal 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;
|
||||
@@ -31,4 +31,5 @@ export interface AccountSelectionProps {
|
||||
showSelectedList?: boolean;
|
||||
readonly?: boolean;
|
||||
onConfirm?: (selectedOptions: AccountItem[]) => void;
|
||||
accountGroups?: any[]; // 传递账号组数据
|
||||
}
|
||||
|
||||
228
Cunkebao/src/components/AndroidCompatibilityCheck.tsx
Normal file
228
Cunkebao/src/components/AndroidCompatibilityCheck.tsx
Normal 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;
|
||||
125
Cunkebao/src/components/CompatibilityCheck.tsx
Normal file
125
Cunkebao/src/components/CompatibilityCheck.tsx
Normal 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;
|
||||
@@ -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[]; // 传递设备组数据
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
57
Cunkebao/src/components/LineChart2.tsx
Normal file
57
Cunkebao/src/components/LineChart2.tsx
Normal 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;
|
||||
@@ -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); }
|
||||
|
||||
250
Cunkebao/src/components/WebSocketExample.tsx
Normal file
250
Cunkebao/src/components/WebSocketExample.tsx
Normal 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;
|
||||
@@ -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 />);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// 第三方登录处理
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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 || "");
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
173
Cunkebao/src/pages/mobile/mine/devices/index.module.scss
Normal file
173
Cunkebao/src/pages/mobile/mine/devices/index.module.scss
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -20,6 +20,7 @@ export interface Task {
|
||||
acquiredCount?: number;
|
||||
addedCount?: number;
|
||||
passRate?: number;
|
||||
passCount?: number;
|
||||
}
|
||||
|
||||
export interface ApiSettings {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
179
Cunkebao/src/pages/mobile/test/update-notification.tsx
Normal file
179
Cunkebao/src/pages/mobile/test/update-notification.tsx
Normal 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>点击“立即更新”会刷新页面</li>
|
||||
<li>点击“稍后”会隐藏通知,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;
|
||||
@@ -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}>
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -22,13 +22,4 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.timeRangeRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.groupSizeRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
54
Cunkebao/src/pages/mobile/workspace/auto-group/form/types.ts
Normal file
54
Cunkebao/src/pages/mobile/workspace/auto-group/form/types.ts
Normal 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个字符" }],
|
||||
};
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
176
Cunkebao/src/polyfills.ts
Normal 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 () {},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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 />,
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
};
|
||||
|
||||
51
Cunkebao/src/store/module/ckchat.data.ts
Normal file
51
Cunkebao/src/store/module/ckchat.data.ts
Normal 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;
|
||||
}
|
||||
89
Cunkebao/src/store/module/ckchat.ts
Normal file
89
Cunkebao/src/store/module/ckchat.ts
Normal 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();
|
||||
@@ -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 => {
|
||||
|
||||
376
Cunkebao/src/store/module/websocket.ts
Normal file
376
Cunkebao/src/store/module/websocket.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -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) {
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface Device {
|
||||
nickname?: string;
|
||||
battery?: number;
|
||||
lastActive?: string;
|
||||
avatar?: string;
|
||||
features?: {
|
||||
autoAddFriend?: boolean;
|
||||
autoReply?: boolean;
|
||||
|
||||
67
Cunkebao/src/utils/chartColors.ts
Normal file
67
Cunkebao/src/utils/chartColors.ts
Normal 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
177
Cunkebao/兼容性说明.md
Normal 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
109
ckApp/test.html
Normal 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>
|
||||
|
||||
Reference in New Issue
Block a user