feat: 本次提交更新内容如下
格式更新一下
This commit is contained in:
@@ -25,6 +25,8 @@ module.exports = {
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
'@typescript-eslint/no-unused-vars': 'warn',
|
||||
'react/prop-types': 'off',
|
||||
'linebreak-style': ['error', 'unix'],
|
||||
'eol-last': ['error', 'always'],
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
|
||||
13
nkebao/.prettierrc
Normal file
13
nkebao/.prettierrc
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"semi": true,
|
||||
"trailingComma": "es5",
|
||||
"singleQuote": false,
|
||||
"printWidth": 80,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"endOfLine": "lf",
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "avoid",
|
||||
"jsxSingleQuote": false,
|
||||
"quoteProps": "as-needed"
|
||||
}
|
||||
32
nkebao/.vscode/settings.json
vendored
Normal file
32
nkebao/.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"files.eol": "\n",
|
||||
"files.insertFinalNewline": true,
|
||||
"files.trimFinalNewlines": true,
|
||||
"files.trimTrailingWhitespace": true,
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit"
|
||||
},
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[javascriptreact]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[scss]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[css]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
}
|
||||
}
|
||||
@@ -37,8 +37,11 @@
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint 'src/**/*.{js,jsx,ts,tsx}' --fix"
|
||||
"lint": "eslint src --ext .js,.jsx,.ts,.tsx --fix",
|
||||
"format": "prettier --write \"src/**/*.{js,jsx,ts,tsx,json,scss,css}\"",
|
||||
"lint:check": "eslint src --ext .js,.jsx,.ts,.tsx",
|
||||
"format:check": "prettier --check \"src/**/*.{js,jsx,ts,tsx,json,scss,css}\""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import React from "react";
|
||||
import AppRouter from "@/router";
|
||||
function App() {
|
||||
return (
|
||||
<>
|
||||
<AppRouter />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
import React from "react";
|
||||
import AppRouter from "@/router";
|
||||
function App() {
|
||||
return (
|
||||
<>
|
||||
<AppRouter />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
@@ -1,44 +1,44 @@
|
||||
import request from "./request";
|
||||
|
||||
// 获取设备列表
|
||||
export const fetchDeviceList = (params: {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
keyword?: string;
|
||||
}) => request("/v1/devices", params, "GET");
|
||||
|
||||
// 获取设备详情
|
||||
export const fetchDeviceDetail = (id: string | number) =>
|
||||
request(`/v1/devices/${id}`);
|
||||
|
||||
// 获取设备关联微信账号
|
||||
export const fetchDeviceRelatedAccounts = (id: string | number) =>
|
||||
request(`/v1/wechats/related-device/${id}`);
|
||||
|
||||
// 获取设备操作日志
|
||||
export const fetchDeviceHandleLogs = (
|
||||
id: string | number,
|
||||
page = 1,
|
||||
limit = 10
|
||||
) => request(`/v1/devices/${id}/handle-logs`, { page, limit }, "GET");
|
||||
|
||||
// 更新设备任务配置
|
||||
export const updateDeviceTaskConfig = (config: {
|
||||
deviceId: string | number;
|
||||
autoAddFriend?: boolean;
|
||||
autoReply?: boolean;
|
||||
momentsSync?: boolean;
|
||||
aiChat?: boolean;
|
||||
}) => request("/v1/devices/task-config", config, "POST");
|
||||
|
||||
// 删除设备
|
||||
export const deleteDevice = (id: number) =>
|
||||
request(`/v1/devices/${id}`, undefined, "DELETE");
|
||||
|
||||
// 获取设备二维码
|
||||
export const fetchDeviceQRCode = (accountId: string) =>
|
||||
request("/v1/api/device/add", { accountId }, "POST");
|
||||
|
||||
// 通过IMEI添加设备
|
||||
export const addDeviceByImei = (imei: string, name: string) =>
|
||||
request("/v1/api/device/add-by-imei", { imei, name }, "POST");
|
||||
import request from "./request";
|
||||
|
||||
// 获取设备列表
|
||||
export const fetchDeviceList = (params: {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
keyword?: string;
|
||||
}) => request("/v1/devices", params, "GET");
|
||||
|
||||
// 获取设备详情
|
||||
export const fetchDeviceDetail = (id: string | number) =>
|
||||
request(`/v1/devices/${id}`);
|
||||
|
||||
// 获取设备关联微信账号
|
||||
export const fetchDeviceRelatedAccounts = (id: string | number) =>
|
||||
request(`/v1/wechats/related-device/${id}`);
|
||||
|
||||
// 获取设备操作日志
|
||||
export const fetchDeviceHandleLogs = (
|
||||
id: string | number,
|
||||
page = 1,
|
||||
limit = 10
|
||||
) => request(`/v1/devices/${id}/handle-logs`, { page, limit }, "GET");
|
||||
|
||||
// 更新设备任务配置
|
||||
export const updateDeviceTaskConfig = (config: {
|
||||
deviceId: string | number;
|
||||
autoAddFriend?: boolean;
|
||||
autoReply?: boolean;
|
||||
momentsSync?: boolean;
|
||||
aiChat?: boolean;
|
||||
}) => request("/v1/devices/task-config", config, "POST");
|
||||
|
||||
// 删除设备
|
||||
export const deleteDevice = (id: number) =>
|
||||
request(`/v1/devices/${id}`, undefined, "DELETE");
|
||||
|
||||
// 获取设备二维码
|
||||
export const fetchDeviceQRCode = (accountId: string) =>
|
||||
request("/v1/api/device/add", { accountId }, "POST");
|
||||
|
||||
// 通过IMEI添加设备
|
||||
export const addDeviceByImei = (imei: string, name: string) =>
|
||||
request("/v1/api/device/add-by-imei", { imei, name }, "POST");
|
||||
|
||||
@@ -1,84 +1,90 @@
|
||||
import axios, {
|
||||
AxiosInstance,
|
||||
AxiosRequestConfig,
|
||||
Method,
|
||||
AxiosResponse,
|
||||
} from "axios";
|
||||
import { Toast } from "antd-mobile";
|
||||
|
||||
const DEFAULT_DEBOUNCE_GAP = 1000;
|
||||
const debounceMap = new Map<string, number>();
|
||||
|
||||
const instance: AxiosInstance = axios.create({
|
||||
baseURL: (import.meta as any).env?.VITE_API_BASE_URL || "/api",
|
||||
timeout: 20000,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
instance.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem("token");
|
||||
if (token) {
|
||||
config.headers = config.headers || {};
|
||||
config.headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
instance.interceptors.response.use(
|
||||
(res: AxiosResponse) => {
|
||||
const { code, success, msg } = res.data || {};
|
||||
if (code === 200 || success) {
|
||||
return res.data.data ?? res.data;
|
||||
}
|
||||
Toast.show({ content: msg || "接口错误", position: "top" });
|
||||
if (code === 401) {
|
||||
localStorage.removeItem("token");
|
||||
const currentPath = window.location.pathname + window.location.search;
|
||||
if (currentPath === "/login") {
|
||||
window.location.href = "/login";
|
||||
} else {
|
||||
window.location.href = `/login?redirect=${encodeURIComponent(currentPath)}`;
|
||||
}
|
||||
}
|
||||
return Promise.reject(msg || "接口错误");
|
||||
},
|
||||
(err) => {
|
||||
Toast.show({ content: err.message || "网络异常", position: "top" });
|
||||
return Promise.reject(err);
|
||||
}
|
||||
);
|
||||
|
||||
export function request(
|
||||
url: string,
|
||||
data?: any,
|
||||
method: Method = "GET",
|
||||
config?: AxiosRequestConfig,
|
||||
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: AxiosRequestConfig = {
|
||||
url,
|
||||
method,
|
||||
...config,
|
||||
};
|
||||
if (method.toUpperCase() === "GET") {
|
||||
axiosConfig.params = data;
|
||||
} else {
|
||||
axiosConfig.data = data;
|
||||
}
|
||||
return instance(axiosConfig);
|
||||
}
|
||||
|
||||
export default request;
|
||||
import axios, {
|
||||
AxiosInstance,
|
||||
AxiosRequestConfig,
|
||||
Method,
|
||||
AxiosResponse,
|
||||
} from "axios";
|
||||
import { Toast } from "antd-mobile";
|
||||
|
||||
const DEFAULT_DEBOUNCE_GAP = 1000;
|
||||
const debounceMap = new Map<string, number>();
|
||||
|
||||
const instance: AxiosInstance = axios.create({
|
||||
baseURL: (import.meta as any).env?.VITE_API_BASE_URL || "/api",
|
||||
timeout: 20000,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
instance.interceptors.request.use(config => {
|
||||
const token = localStorage.getItem("token");
|
||||
if (token) {
|
||||
config.headers = config.headers || {};
|
||||
config.headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
instance.interceptors.response.use(
|
||||
(res: AxiosResponse) => {
|
||||
const { code, success, msg } = res.data || {};
|
||||
if (code === 200 || success) {
|
||||
return res.data.data ?? res.data;
|
||||
}
|
||||
Toast.show({ content: msg || "接口错误", position: "top" });
|
||||
if (code === 401) {
|
||||
localStorage.removeItem("token");
|
||||
const currentPath = window.location.pathname + window.location.search;
|
||||
if (currentPath === "/login") {
|
||||
window.location.href = "/login";
|
||||
} else {
|
||||
window.location.href = `/login?redirect=${encodeURIComponent(currentPath)}`;
|
||||
}
|
||||
}
|
||||
return Promise.reject(msg || "接口错误");
|
||||
},
|
||||
err => {
|
||||
Toast.show({ content: err.message || "网络异常", position: "top" });
|
||||
return Promise.reject(err);
|
||||
}
|
||||
);
|
||||
|
||||
export function request(
|
||||
url: string,
|
||||
data?: any,
|
||||
method: Method = "GET",
|
||||
config?: AxiosRequestConfig,
|
||||
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: AxiosRequestConfig = {
|
||||
url,
|
||||
method,
|
||||
...config,
|
||||
};
|
||||
|
||||
// 如果是FormData,不设置Content-Type,让浏览器自动设置
|
||||
if (data instanceof FormData) {
|
||||
delete axiosConfig.headers?.["Content-Type"];
|
||||
}
|
||||
|
||||
if (method.toUpperCase() === "GET") {
|
||||
axiosConfig.params = data;
|
||||
} else {
|
||||
axiosConfig.data = data;
|
||||
}
|
||||
return instance(axiosConfig);
|
||||
}
|
||||
|
||||
export default request;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import request from "@/api/request";
|
||||
|
||||
// 获取好友列表
|
||||
export function getAccountList(params: {
|
||||
page: number;
|
||||
limit: number;
|
||||
keyword?: string;
|
||||
}) {
|
||||
return request("/v1/workbench/account-list", params, "GET");
|
||||
}
|
||||
import request from "@/api/request";
|
||||
|
||||
// 获取好友列表
|
||||
export function getAccountList(params: {
|
||||
page: number;
|
||||
limit: number;
|
||||
keyword?: string;
|
||||
}) {
|
||||
return request("/v1/workbench/account-list", params, "GET");
|
||||
}
|
||||
|
||||
@@ -114,7 +114,7 @@ export default function AccountSelection({
|
||||
|
||||
// 渲染和过滤都依赖内部accountsList
|
||||
const filteredAccounts = accountsList.filter(
|
||||
(acc) =>
|
||||
acc =>
|
||||
acc.userName.includes(searchQuery) ||
|
||||
acc.realName.includes(searchQuery) ||
|
||||
acc.departmentName.includes(searchQuery)
|
||||
@@ -125,7 +125,7 @@ export default function AccountSelection({
|
||||
if (readonly) return;
|
||||
const uniqueValue = [...new Set(value)];
|
||||
const newSelected = uniqueValue.includes(accountId)
|
||||
? uniqueValue.filter((id) => id !== accountId)
|
||||
? uniqueValue.filter(id => id !== accountId)
|
||||
: [...uniqueValue, accountId];
|
||||
onChange(newSelected);
|
||||
};
|
||||
@@ -140,10 +140,10 @@ export default function AccountSelection({
|
||||
// 获取已选账号详细信息 - 去重处理
|
||||
const uniqueValue = [...new Set(value)];
|
||||
const selectedAccountObjs = [
|
||||
...accountsList.filter((acc) => uniqueValue.includes(acc.id)),
|
||||
...accountsList.filter(acc => uniqueValue.includes(acc.id)),
|
||||
...uniqueValue
|
||||
.filter((id) => !accountsList.some((acc) => acc.id === id))
|
||||
.map((id) => ({
|
||||
.filter(id => !accountsList.some(acc => acc.id === id))
|
||||
.map(id => ({
|
||||
id,
|
||||
userName: String(id),
|
||||
realName: "",
|
||||
@@ -155,7 +155,7 @@ export default function AccountSelection({
|
||||
const handleRemoveAccount = (id: number) => {
|
||||
if (readonly) return;
|
||||
const uniqueValue = [...new Set(value)];
|
||||
onChange(uniqueValue.filter((d) => d !== id));
|
||||
onChange(uniqueValue.filter(d => d !== id));
|
||||
};
|
||||
|
||||
// 确认选择
|
||||
@@ -200,7 +200,7 @@ export default function AccountSelection({
|
||||
background: "#fff",
|
||||
}}
|
||||
>
|
||||
{selectedAccountObjs.map((acc) => (
|
||||
{selectedAccountObjs.map(acc => (
|
||||
<div
|
||||
key={acc.id}
|
||||
className={style.selectedListRow}
|
||||
@@ -284,7 +284,7 @@ export default function AccountSelection({
|
||||
</div>
|
||||
) : filteredAccounts.length > 0 ? (
|
||||
<div className={style.friendListInner}>
|
||||
{filteredAccounts.map((acc) => (
|
||||
{filteredAccounts.map(acc => (
|
||||
<label
|
||||
key={acc.id}
|
||||
className={style.friendItem}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import request from "@/api/request";
|
||||
|
||||
export function getContentLibraryList(params: any) {
|
||||
return request("/v1/content/library/list", params, "GET");
|
||||
}
|
||||
import request from "@/api/request";
|
||||
|
||||
export function getContentLibraryList(params: any) {
|
||||
return request("/v1/content/library/list", params, "GET");
|
||||
}
|
||||
|
||||
@@ -1,117 +1,117 @@
|
||||
.inputWrapper {
|
||||
position: relative;
|
||||
}
|
||||
.selectedListWindow {
|
||||
margin-top: 8px;
|
||||
border: 1px solid #e5e6eb;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
.selectedListRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
font-size: 14px;
|
||||
}
|
||||
.libraryList {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.libraryListInner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
}
|
||||
.libraryItem {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #f0f0f0;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
&:hover {
|
||||
background: #f5f6fa;
|
||||
}
|
||||
}
|
||||
.checkboxWrapper {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.checkboxSelected {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 4px;
|
||||
background: #1677ff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.checkboxUnselected {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e5e6eb;
|
||||
background: #fff;
|
||||
}
|
||||
.checkboxDot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 2px;
|
||||
background: #fff;
|
||||
}
|
||||
.libraryInfo {
|
||||
flex: 1;
|
||||
}
|
||||
.libraryHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.libraryName {
|
||||
font-weight: 500;
|
||||
font-size: 16px;
|
||||
color: #222;
|
||||
}
|
||||
.typeTag {
|
||||
font-size: 12px;
|
||||
color: #1677ff;
|
||||
border: 1px solid #1677ff;
|
||||
border-radius: 12px;
|
||||
padding: 2px 10px;
|
||||
margin-left: 8px;
|
||||
background: #f4f8ff;
|
||||
font-weight: 500;
|
||||
}
|
||||
.libraryMeta {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
}
|
||||
.libraryDesc {
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.loadingBox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
.loadingText {
|
||||
color: #888;
|
||||
font-size: 15px;
|
||||
}
|
||||
.emptyBox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100px;
|
||||
}
|
||||
.emptyText {
|
||||
color: #888;
|
||||
font-size: 15px;
|
||||
}
|
||||
.inputWrapper {
|
||||
position: relative;
|
||||
}
|
||||
.selectedListWindow {
|
||||
margin-top: 8px;
|
||||
border: 1px solid #e5e6eb;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
.selectedListRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
font-size: 14px;
|
||||
}
|
||||
.libraryList {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.libraryListInner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
}
|
||||
.libraryItem {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #f0f0f0;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
&:hover {
|
||||
background: #f5f6fa;
|
||||
}
|
||||
}
|
||||
.checkboxWrapper {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.checkboxSelected {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 4px;
|
||||
background: #1677ff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.checkboxUnselected {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e5e6eb;
|
||||
background: #fff;
|
||||
}
|
||||
.checkboxDot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 2px;
|
||||
background: #fff;
|
||||
}
|
||||
.libraryInfo {
|
||||
flex: 1;
|
||||
}
|
||||
.libraryHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.libraryName {
|
||||
font-weight: 500;
|
||||
font-size: 16px;
|
||||
color: #222;
|
||||
}
|
||||
.typeTag {
|
||||
font-size: 12px;
|
||||
color: #1677ff;
|
||||
border: 1px solid #1677ff;
|
||||
border-radius: 12px;
|
||||
padding: 2px 10px;
|
||||
margin-left: 8px;
|
||||
background: #f4f8ff;
|
||||
font-weight: 500;
|
||||
}
|
||||
.libraryMeta {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
}
|
||||
.libraryDesc {
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.loadingBox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
.loadingText {
|
||||
color: #888;
|
||||
font-size: 15px;
|
||||
}
|
||||
.emptyBox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100px;
|
||||
}
|
||||
.emptyText {
|
||||
color: #888;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
@@ -1,343 +1,343 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { SearchOutlined, DeleteOutlined } from "@ant-design/icons";
|
||||
import { Button, Input } from "antd";
|
||||
import { Popup, Checkbox } from "antd-mobile";
|
||||
import style from "./index.module.scss";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import PopupHeader from "@/components/PopuLayout/header";
|
||||
import PopupFooter from "@/components/PopuLayout/footer";
|
||||
import { getContentLibraryList } from "./api";
|
||||
|
||||
// 内容库接口类型
|
||||
interface ContentLibraryItem {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
sourceType?: number; // 1=文本 2=图片 3=视频
|
||||
creatorName?: string;
|
||||
updateTime?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// 类型标签文本
|
||||
const getTypeText = (type?: number) => {
|
||||
if (type === 1) return "文本";
|
||||
if (type === 2) return "图片";
|
||||
if (type === 3) return "视频";
|
||||
return "未知";
|
||||
};
|
||||
|
||||
// 时间格式化
|
||||
const formatDate = (dateStr?: string) => {
|
||||
if (!dateStr) return "-";
|
||||
const d = new Date(dateStr);
|
||||
if (isNaN(d.getTime())) return "-";
|
||||
return `${d.getFullYear()}/${(d.getMonth() + 1)
|
||||
.toString()
|
||||
.padStart(2, "0")}/${d.getDate().toString().padStart(2, "0")} ${d
|
||||
.getHours()
|
||||
.toString()
|
||||
.padStart(2, "0")}:${d.getMinutes().toString().padStart(2, "0")}:${d
|
||||
.getSeconds()
|
||||
.toString()
|
||||
.padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
// 组件属性接口
|
||||
interface ContentLibrarySelectionProps {
|
||||
selectedLibraries: (string | number)[];
|
||||
onSelect: (libraries: string[]) => void;
|
||||
onSelectDetail?: (libraries: ContentLibraryItem[]) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
visible?: boolean;
|
||||
onVisibleChange?: (visible: boolean) => void;
|
||||
selectedListMaxHeight?: number;
|
||||
showInput?: boolean;
|
||||
showSelectedList?: boolean;
|
||||
readonly?: boolean;
|
||||
onConfirm?: (
|
||||
selectedIds: string[],
|
||||
selectedItems: ContentLibraryItem[]
|
||||
) => void;
|
||||
}
|
||||
|
||||
export default function ContentLibrarySelection({
|
||||
selectedLibraries,
|
||||
onSelect,
|
||||
onSelectDetail,
|
||||
placeholder = "选择内容库",
|
||||
className = "",
|
||||
visible,
|
||||
onVisibleChange,
|
||||
selectedListMaxHeight = 300,
|
||||
showInput = true,
|
||||
showSelectedList = true,
|
||||
readonly = false,
|
||||
onConfirm,
|
||||
}: ContentLibrarySelectionProps) {
|
||||
const [popupVisible, setPopupVisible] = useState(false);
|
||||
const [libraries, setLibraries] = useState<ContentLibraryItem[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [totalLibraries, setTotalLibraries] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 获取已选内容库详细信息
|
||||
const selectedLibraryObjs = libraries.filter((item) =>
|
||||
selectedLibraries.includes(item.id)
|
||||
);
|
||||
|
||||
// 删除已选内容库
|
||||
const handleRemoveLibrary = (id: string) => {
|
||||
if (readonly) return;
|
||||
onSelect(selectedLibraries.filter((g) => g !== id));
|
||||
};
|
||||
|
||||
// 受控弹窗逻辑
|
||||
const realVisible = visible !== undefined ? visible : popupVisible;
|
||||
const setRealVisible = (v: boolean) => {
|
||||
if (onVisibleChange) onVisibleChange(v);
|
||||
if (visible === undefined) setPopupVisible(v);
|
||||
};
|
||||
|
||||
// 打开弹窗
|
||||
const openPopup = () => {
|
||||
if (readonly) return;
|
||||
setCurrentPage(1);
|
||||
setSearchQuery("");
|
||||
setRealVisible(true);
|
||||
fetchLibraries(1, "");
|
||||
};
|
||||
|
||||
// 当页码变化时,拉取对应页数据(弹窗已打开时)
|
||||
useEffect(() => {
|
||||
if (realVisible && currentPage !== 1) {
|
||||
fetchLibraries(currentPage, searchQuery);
|
||||
}
|
||||
}, [currentPage, realVisible, searchQuery]);
|
||||
|
||||
// 搜索防抖
|
||||
useEffect(() => {
|
||||
if (!realVisible) return;
|
||||
const timer = setTimeout(() => {
|
||||
setCurrentPage(1);
|
||||
fetchLibraries(1, searchQuery);
|
||||
}, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchQuery, realVisible]);
|
||||
|
||||
// 获取内容库列表API
|
||||
const fetchLibraries = async (page: number, keyword: string = "") => {
|
||||
setLoading(true);
|
||||
try {
|
||||
let params: any = {
|
||||
page,
|
||||
limit: 20,
|
||||
};
|
||||
if (keyword.trim()) {
|
||||
params.keyword = keyword.trim();
|
||||
}
|
||||
const response = await getContentLibraryList(params);
|
||||
if (response && response.list) {
|
||||
setLibraries(response.list);
|
||||
setTotalLibraries(response.total || 0);
|
||||
setTotalPages(Math.ceil((response.total || 0) / 20));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取内容库列表失败:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理内容库选择
|
||||
const handleLibraryToggle = (libraryId: string) => {
|
||||
if (readonly) return;
|
||||
const newSelected = selectedLibraries.includes(libraryId)
|
||||
? selectedLibraries.filter((id) => id !== libraryId)
|
||||
: [...selectedLibraries, libraryId];
|
||||
onSelect(newSelected);
|
||||
if (onSelectDetail) {
|
||||
const selectedObjs = libraries.filter((item) =>
|
||||
newSelected.includes(item.id)
|
||||
);
|
||||
onSelectDetail(selectedObjs);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取显示文本
|
||||
const getDisplayText = () => {
|
||||
if (selectedLibraries.length === 0) return "";
|
||||
return `已选择 ${selectedLibraries.length} 个内容库`;
|
||||
};
|
||||
|
||||
// 确认选择
|
||||
const handleConfirm = () => {
|
||||
if (onConfirm) {
|
||||
onConfirm(selectedLibraries, selectedLibraryObjs);
|
||||
}
|
||||
setRealVisible(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 输入框 */}
|
||||
{showInput && (
|
||||
<div className={`${style.inputWrapper} ${className}`}>
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
value={getDisplayText()}
|
||||
onClick={openPopup}
|
||||
prefix={<SearchOutlined />}
|
||||
allowClear={!readonly}
|
||||
size="large"
|
||||
readOnly={readonly}
|
||||
disabled={readonly}
|
||||
style={
|
||||
readonly ? { background: "#f5f5f5", cursor: "not-allowed" } : {}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* 已选内容库列表窗口 */}
|
||||
{showSelectedList && selectedLibraryObjs.length > 0 && (
|
||||
<div
|
||||
className={style.selectedListWindow}
|
||||
style={{
|
||||
maxHeight: selectedListMaxHeight,
|
||||
overflowY: "auto",
|
||||
marginTop: 8,
|
||||
border: "1px solid #e5e6eb",
|
||||
borderRadius: 8,
|
||||
background: "#fff",
|
||||
}}
|
||||
>
|
||||
{selectedLibraryObjs.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={style.selectedListRow}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
padding: "4px 8px",
|
||||
borderBottom: "1px solid #f0f0f0",
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{item.name || item.id}
|
||||
</div>
|
||||
{!readonly && (
|
||||
<Button
|
||||
type="text"
|
||||
icon={<DeleteOutlined />}
|
||||
size="small"
|
||||
style={{
|
||||
marginLeft: 4,
|
||||
color: "#ff4d4f",
|
||||
border: "none",
|
||||
background: "none",
|
||||
minWidth: 24,
|
||||
height: 24,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
onClick={() => handleRemoveLibrary(item.id)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* 弹窗 */}
|
||||
<Popup
|
||||
visible={realVisible && !readonly}
|
||||
onMaskClick={() => setRealVisible(false)}
|
||||
position="bottom"
|
||||
bodyStyle={{ height: "100vh" }}
|
||||
>
|
||||
<Layout
|
||||
header={
|
||||
<PopupHeader
|
||||
title="选择内容库"
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
searchPlaceholder="搜索内容库"
|
||||
loading={loading}
|
||||
onRefresh={() => fetchLibraries(currentPage, searchQuery)}
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
<PopupFooter
|
||||
total={totalLibraries}
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
loading={loading}
|
||||
selectedCount={selectedLibraries.length}
|
||||
onPageChange={setCurrentPage}
|
||||
onCancel={() => setRealVisible(false)}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className={style.libraryList}>
|
||||
{loading ? (
|
||||
<div className={style.loadingBox}>
|
||||
<div className={style.loadingText}>加载中...</div>
|
||||
</div>
|
||||
) : libraries.length > 0 ? (
|
||||
<div className={style.libraryListInner}>
|
||||
{libraries.map((item) => (
|
||||
<label key={item.id} className={style.libraryItem}>
|
||||
<Checkbox
|
||||
checked={selectedLibraries.includes(item.id)}
|
||||
onChange={() => !readonly && handleLibraryToggle(item.id)}
|
||||
disabled={readonly}
|
||||
className={style.checkboxWrapper}
|
||||
/>
|
||||
<div className={style.libraryInfo}>
|
||||
<div className={style.libraryHeader}>
|
||||
<span className={style.libraryName}>{item.name}</span>
|
||||
<span className={style.typeTag}>
|
||||
{getTypeText(item.sourceType)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={style.libraryMeta}>
|
||||
<div>创建人: {item.creatorName || "-"}</div>
|
||||
<div>更新时间: {formatDate(item.updateTime)}</div>
|
||||
</div>
|
||||
{item.description && (
|
||||
<div className={style.libraryDesc}>
|
||||
{item.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className={style.emptyBox}>
|
||||
<div className={style.emptyText}>
|
||||
{searchQuery
|
||||
? `没有找到包含"${searchQuery}"的内容库`
|
||||
: "没有找到内容库"}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
</Popup>
|
||||
</>
|
||||
);
|
||||
}
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { SearchOutlined, DeleteOutlined } from "@ant-design/icons";
|
||||
import { Button, Input } from "antd";
|
||||
import { Popup, Checkbox } from "antd-mobile";
|
||||
import style from "./index.module.scss";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import PopupHeader from "@/components/PopuLayout/header";
|
||||
import PopupFooter from "@/components/PopuLayout/footer";
|
||||
import { getContentLibraryList } from "./api";
|
||||
|
||||
// 内容库接口类型
|
||||
interface ContentLibraryItem {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
sourceType?: number; // 1=文本 2=图片 3=视频
|
||||
creatorName?: string;
|
||||
updateTime?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// 类型标签文本
|
||||
const getTypeText = (type?: number) => {
|
||||
if (type === 1) return "文本";
|
||||
if (type === 2) return "图片";
|
||||
if (type === 3) return "视频";
|
||||
return "未知";
|
||||
};
|
||||
|
||||
// 时间格式化
|
||||
const formatDate = (dateStr?: string) => {
|
||||
if (!dateStr) return "-";
|
||||
const d = new Date(dateStr);
|
||||
if (isNaN(d.getTime())) return "-";
|
||||
return `${d.getFullYear()}/${(d.getMonth() + 1)
|
||||
.toString()
|
||||
.padStart(2, "0")}/${d.getDate().toString().padStart(2, "0")} ${d
|
||||
.getHours()
|
||||
.toString()
|
||||
.padStart(2, "0")}:${d.getMinutes().toString().padStart(2, "0")}:${d
|
||||
.getSeconds()
|
||||
.toString()
|
||||
.padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
// 组件属性接口
|
||||
interface ContentLibrarySelectionProps {
|
||||
selectedLibraries: (string | number)[];
|
||||
onSelect: (libraries: string[]) => void;
|
||||
onSelectDetail?: (libraries: ContentLibraryItem[]) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
visible?: boolean;
|
||||
onVisibleChange?: (visible: boolean) => void;
|
||||
selectedListMaxHeight?: number;
|
||||
showInput?: boolean;
|
||||
showSelectedList?: boolean;
|
||||
readonly?: boolean;
|
||||
onConfirm?: (
|
||||
selectedIds: string[],
|
||||
selectedItems: ContentLibraryItem[]
|
||||
) => void;
|
||||
}
|
||||
|
||||
export default function ContentLibrarySelection({
|
||||
selectedLibraries,
|
||||
onSelect,
|
||||
onSelectDetail,
|
||||
placeholder = "选择内容库",
|
||||
className = "",
|
||||
visible,
|
||||
onVisibleChange,
|
||||
selectedListMaxHeight = 300,
|
||||
showInput = true,
|
||||
showSelectedList = true,
|
||||
readonly = false,
|
||||
onConfirm,
|
||||
}: ContentLibrarySelectionProps) {
|
||||
const [popupVisible, setPopupVisible] = useState(false);
|
||||
const [libraries, setLibraries] = useState<ContentLibraryItem[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [totalLibraries, setTotalLibraries] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 获取已选内容库详细信息
|
||||
const selectedLibraryObjs = libraries.filter(item =>
|
||||
selectedLibraries.includes(item.id)
|
||||
);
|
||||
|
||||
// 删除已选内容库
|
||||
const handleRemoveLibrary = (id: string) => {
|
||||
if (readonly) return;
|
||||
onSelect(selectedLibraries.filter(g => g !== id));
|
||||
};
|
||||
|
||||
// 受控弹窗逻辑
|
||||
const realVisible = visible !== undefined ? visible : popupVisible;
|
||||
const setRealVisible = (v: boolean) => {
|
||||
if (onVisibleChange) onVisibleChange(v);
|
||||
if (visible === undefined) setPopupVisible(v);
|
||||
};
|
||||
|
||||
// 打开弹窗
|
||||
const openPopup = () => {
|
||||
if (readonly) return;
|
||||
setCurrentPage(1);
|
||||
setSearchQuery("");
|
||||
setRealVisible(true);
|
||||
fetchLibraries(1, "");
|
||||
};
|
||||
|
||||
// 当页码变化时,拉取对应页数据(弹窗已打开时)
|
||||
useEffect(() => {
|
||||
if (realVisible && currentPage !== 1) {
|
||||
fetchLibraries(currentPage, searchQuery);
|
||||
}
|
||||
}, [currentPage, realVisible, searchQuery]);
|
||||
|
||||
// 搜索防抖
|
||||
useEffect(() => {
|
||||
if (!realVisible) return;
|
||||
const timer = setTimeout(() => {
|
||||
setCurrentPage(1);
|
||||
fetchLibraries(1, searchQuery);
|
||||
}, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchQuery, realVisible]);
|
||||
|
||||
// 获取内容库列表API
|
||||
const fetchLibraries = async (page: number, keyword: string = "") => {
|
||||
setLoading(true);
|
||||
try {
|
||||
let params: any = {
|
||||
page,
|
||||
limit: 20,
|
||||
};
|
||||
if (keyword.trim()) {
|
||||
params.keyword = keyword.trim();
|
||||
}
|
||||
const response = await getContentLibraryList(params);
|
||||
if (response && response.list) {
|
||||
setLibraries(response.list);
|
||||
setTotalLibraries(response.total || 0);
|
||||
setTotalPages(Math.ceil((response.total || 0) / 20));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取内容库列表失败:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理内容库选择
|
||||
const handleLibraryToggle = (libraryId: string) => {
|
||||
if (readonly) return;
|
||||
const newSelected = selectedLibraries.includes(libraryId)
|
||||
? selectedLibraries.filter(id => id !== libraryId)
|
||||
: [...selectedLibraries, libraryId];
|
||||
onSelect(newSelected);
|
||||
if (onSelectDetail) {
|
||||
const selectedObjs = libraries.filter(item =>
|
||||
newSelected.includes(item.id)
|
||||
);
|
||||
onSelectDetail(selectedObjs);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取显示文本
|
||||
const getDisplayText = () => {
|
||||
if (selectedLibraries.length === 0) return "";
|
||||
return `已选择 ${selectedLibraries.length} 个内容库`;
|
||||
};
|
||||
|
||||
// 确认选择
|
||||
const handleConfirm = () => {
|
||||
if (onConfirm) {
|
||||
onConfirm(selectedLibraries, selectedLibraryObjs);
|
||||
}
|
||||
setRealVisible(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 输入框 */}
|
||||
{showInput && (
|
||||
<div className={`${style.inputWrapper} ${className}`}>
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
value={getDisplayText()}
|
||||
onClick={openPopup}
|
||||
prefix={<SearchOutlined />}
|
||||
allowClear={!readonly}
|
||||
size="large"
|
||||
readOnly={readonly}
|
||||
disabled={readonly}
|
||||
style={
|
||||
readonly ? { background: "#f5f5f5", cursor: "not-allowed" } : {}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* 已选内容库列表窗口 */}
|
||||
{showSelectedList && selectedLibraryObjs.length > 0 && (
|
||||
<div
|
||||
className={style.selectedListWindow}
|
||||
style={{
|
||||
maxHeight: selectedListMaxHeight,
|
||||
overflowY: "auto",
|
||||
marginTop: 8,
|
||||
border: "1px solid #e5e6eb",
|
||||
borderRadius: 8,
|
||||
background: "#fff",
|
||||
}}
|
||||
>
|
||||
{selectedLibraryObjs.map(item => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={style.selectedListRow}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
padding: "4px 8px",
|
||||
borderBottom: "1px solid #f0f0f0",
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{item.name || item.id}
|
||||
</div>
|
||||
{!readonly && (
|
||||
<Button
|
||||
type="text"
|
||||
icon={<DeleteOutlined />}
|
||||
size="small"
|
||||
style={{
|
||||
marginLeft: 4,
|
||||
color: "#ff4d4f",
|
||||
border: "none",
|
||||
background: "none",
|
||||
minWidth: 24,
|
||||
height: 24,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
onClick={() => handleRemoveLibrary(item.id)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* 弹窗 */}
|
||||
<Popup
|
||||
visible={realVisible && !readonly}
|
||||
onMaskClick={() => setRealVisible(false)}
|
||||
position="bottom"
|
||||
bodyStyle={{ height: "100vh" }}
|
||||
>
|
||||
<Layout
|
||||
header={
|
||||
<PopupHeader
|
||||
title="选择内容库"
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
searchPlaceholder="搜索内容库"
|
||||
loading={loading}
|
||||
onRefresh={() => fetchLibraries(currentPage, searchQuery)}
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
<PopupFooter
|
||||
total={totalLibraries}
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
loading={loading}
|
||||
selectedCount={selectedLibraries.length}
|
||||
onPageChange={setCurrentPage}
|
||||
onCancel={() => setRealVisible(false)}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className={style.libraryList}>
|
||||
{loading ? (
|
||||
<div className={style.loadingBox}>
|
||||
<div className={style.loadingText}>加载中...</div>
|
||||
</div>
|
||||
) : libraries.length > 0 ? (
|
||||
<div className={style.libraryListInner}>
|
||||
{libraries.map(item => (
|
||||
<label key={item.id} className={style.libraryItem}>
|
||||
<Checkbox
|
||||
checked={selectedLibraries.includes(item.id)}
|
||||
onChange={() => !readonly && handleLibraryToggle(item.id)}
|
||||
disabled={readonly}
|
||||
className={style.checkboxWrapper}
|
||||
/>
|
||||
<div className={style.libraryInfo}>
|
||||
<div className={style.libraryHeader}>
|
||||
<span className={style.libraryName}>{item.name}</span>
|
||||
<span className={style.typeTag}>
|
||||
{getTypeText(item.sourceType)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={style.libraryMeta}>
|
||||
<div>创建人: {item.creatorName || "-"}</div>
|
||||
<div>更新时间: {formatDate(item.updateTime)}</div>
|
||||
</div>
|
||||
{item.description && (
|
||||
<div className={style.libraryDesc}>
|
||||
{item.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className={style.emptyBox}>
|
||||
<div className={style.emptyText}>
|
||||
{searchQuery
|
||||
? `没有找到包含"${searchQuery}"的内容库`
|
||||
: "没有找到内容库"}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
</Popup>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import request from "@/api/request";
|
||||
|
||||
// 获取设备列表
|
||||
export function getDeviceList(params: {
|
||||
page: number;
|
||||
limit: number;
|
||||
keyword?: string;
|
||||
}) {
|
||||
return request("/v1/devices", params, "GET");
|
||||
}
|
||||
import request from "@/api/request";
|
||||
|
||||
// 获取设备列表
|
||||
export function getDeviceList(params: {
|
||||
page: number;
|
||||
limit: number;
|
||||
keyword?: string;
|
||||
}) {
|
||||
return request("/v1/devices", params, "GET");
|
||||
}
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
// 设备选择项接口
|
||||
export interface DeviceSelectionItem {
|
||||
id: string;
|
||||
name: string;
|
||||
imei: string;
|
||||
wechatId: string;
|
||||
status: "online" | "offline";
|
||||
wxid?: string;
|
||||
nickname?: string;
|
||||
usedInPlans?: number;
|
||||
}
|
||||
|
||||
// 组件属性接口
|
||||
export interface DeviceSelectionProps {
|
||||
selectedDevices: string[];
|
||||
onSelect: (devices: string[]) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
mode?: "input" | "dialog"; // 新增,默认input
|
||||
open?: boolean; // 仅mode=dialog时生效
|
||||
onOpenChange?: (open: boolean) => void; // 仅mode=dialog时生效
|
||||
selectedListMaxHeight?: number; // 新增,已选列表最大高度,默认500
|
||||
showInput?: boolean; // 新增
|
||||
showSelectedList?: boolean; // 新增
|
||||
readonly?: boolean; // 新增
|
||||
}
|
||||
// 设备选择项接口
|
||||
export interface DeviceSelectionItem {
|
||||
id: string;
|
||||
name: string;
|
||||
imei: string;
|
||||
wechatId: string;
|
||||
status: "online" | "offline";
|
||||
wxid?: string;
|
||||
nickname?: string;
|
||||
usedInPlans?: number;
|
||||
}
|
||||
|
||||
// 组件属性接口
|
||||
export interface DeviceSelectionProps {
|
||||
selectedDevices: string[];
|
||||
onSelect: (devices: string[]) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
mode?: "input" | "dialog"; // 新增,默认input
|
||||
open?: boolean; // 仅mode=dialog时生效
|
||||
onOpenChange?: (open: boolean) => void; // 仅mode=dialog时生效
|
||||
selectedListMaxHeight?: number; // 新增,已选列表最大高度,默认500
|
||||
showInput?: boolean; // 新增
|
||||
showSelectedList?: boolean; // 新增
|
||||
readonly?: boolean; // 新增
|
||||
}
|
||||
|
||||
@@ -1,183 +1,182 @@
|
||||
.inputWrapper {
|
||||
position: relative;
|
||||
}
|
||||
.inputIcon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #bdbdbd;
|
||||
z-index: 10;
|
||||
font-size: 18px;
|
||||
}
|
||||
.input {
|
||||
padding-left: 38px !important;
|
||||
height: 56px;
|
||||
border-radius: 16px !important;
|
||||
border: 1px solid #e5e6eb !important;
|
||||
font-size: 16px;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
|
||||
.popupHeader {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
.popupTitle {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
.popupSearchRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
}
|
||||
.popupSearchInputWrap {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
}
|
||||
.popupSearchInput {
|
||||
padding-left: 36px !important;
|
||||
border-radius: 12px !important;
|
||||
height: 44px;
|
||||
font-size: 15px;
|
||||
border: 1px solid #e5e6eb !important;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
.statusSelect {
|
||||
width: 120px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e5e6eb;
|
||||
font-size: 15px;
|
||||
padding: 0 10px;
|
||||
background: #fff;
|
||||
}
|
||||
.deviceList {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.deviceListInner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
}
|
||||
.deviceItem {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #f0f0f0;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
&:hover {
|
||||
background: #f5f6fa;
|
||||
}
|
||||
}
|
||||
.deviceCheckbox {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.deviceInfo {
|
||||
flex: 1;
|
||||
}
|
||||
.deviceInfoRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.deviceName {
|
||||
font-weight: 500;
|
||||
font-size: 16px;
|
||||
color: #222;
|
||||
}
|
||||
.statusOnline {
|
||||
width: 56px;
|
||||
height: 24px;
|
||||
border-radius: 12px;
|
||||
background: #52c41a;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.statusOffline {
|
||||
width: 56px;
|
||||
height: 24px;
|
||||
border-radius: 12px;
|
||||
background: #e5e6eb;
|
||||
color: #888;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.deviceInfoDetail {
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.loadingBox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
.loadingText {
|
||||
color: #888;
|
||||
font-size: 15px;
|
||||
}
|
||||
.popupFooter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
background: #fff;
|
||||
}
|
||||
.selectedCount {
|
||||
font-size: 14px;
|
||||
color: #888;
|
||||
}
|
||||
.footerBtnGroup {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
.refreshBtn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
.paginationRow {
|
||||
border-top: 1px solid #f0f0f0;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: #fff;
|
||||
}
|
||||
.totalCount {
|
||||
font-size: 14px;
|
||||
color: #888;
|
||||
}
|
||||
.paginationControls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.pageBtn {
|
||||
padding: 0 8px;
|
||||
height: 32px;
|
||||
min-width: 32px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
.pageInfo {
|
||||
font-size: 14px;
|
||||
color: #222;
|
||||
margin: 0 8px;
|
||||
}
|
||||
.inputWrapper {
|
||||
position: relative;
|
||||
}
|
||||
.inputIcon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #bdbdbd;
|
||||
z-index: 10;
|
||||
font-size: 18px;
|
||||
}
|
||||
.input {
|
||||
padding-left: 38px !important;
|
||||
height: 56px;
|
||||
border-radius: 16px !important;
|
||||
border: 1px solid #e5e6eb !important;
|
||||
font-size: 16px;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.popupHeader {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
.popupTitle {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
.popupSearchRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
}
|
||||
.popupSearchInputWrap {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
}
|
||||
.popupSearchInput {
|
||||
padding-left: 36px !important;
|
||||
border-radius: 12px !important;
|
||||
height: 44px;
|
||||
font-size: 15px;
|
||||
border: 1px solid #e5e6eb !important;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
.statusSelect {
|
||||
width: 120px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e5e6eb;
|
||||
font-size: 15px;
|
||||
padding: 0 10px;
|
||||
background: #fff;
|
||||
}
|
||||
.deviceList {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.deviceListInner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
}
|
||||
.deviceItem {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #f0f0f0;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
&:hover {
|
||||
background: #f5f6fa;
|
||||
}
|
||||
}
|
||||
.deviceCheckbox {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.deviceInfo {
|
||||
flex: 1;
|
||||
}
|
||||
.deviceInfoRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.deviceName {
|
||||
font-weight: 500;
|
||||
font-size: 16px;
|
||||
color: #222;
|
||||
}
|
||||
.statusOnline {
|
||||
width: 56px;
|
||||
height: 24px;
|
||||
border-radius: 12px;
|
||||
background: #52c41a;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.statusOffline {
|
||||
width: 56px;
|
||||
height: 24px;
|
||||
border-radius: 12px;
|
||||
background: #e5e6eb;
|
||||
color: #888;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.deviceInfoDetail {
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.loadingBox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
.loadingText {
|
||||
color: #888;
|
||||
font-size: 15px;
|
||||
}
|
||||
.popupFooter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
background: #fff;
|
||||
}
|
||||
.selectedCount {
|
||||
font-size: 14px;
|
||||
color: #888;
|
||||
}
|
||||
.footerBtnGroup {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
.refreshBtn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
.paginationRow {
|
||||
border-top: 1px solid #f0f0f0;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: #fff;
|
||||
}
|
||||
.totalCount {
|
||||
font-size: 14px;
|
||||
color: #888;
|
||||
}
|
||||
.paginationControls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.pageBtn {
|
||||
padding: 0 8px;
|
||||
height: 32px;
|
||||
min-width: 32px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
.pageInfo {
|
||||
font-size: 14px;
|
||||
color: #222;
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ const DeviceSelection: React.FC<DeviceSelectionProps> = ({
|
||||
// 删除已选设备
|
||||
const handleRemoveDevice = (id: string) => {
|
||||
if (readonly) return;
|
||||
onSelect(selectedDevices.filter((d) => d !== id));
|
||||
onSelect(selectedDevices.filter(d => d !== id));
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -79,7 +79,7 @@ const DeviceSelection: React.FC<DeviceSelectionProps> = ({
|
||||
background: "#fff",
|
||||
}}
|
||||
>
|
||||
{selectedDevices.map((deviceId) => (
|
||||
{selectedDevices.map(deviceId => (
|
||||
<div
|
||||
key={deviceId}
|
||||
className={style.selectedListRow}
|
||||
|
||||
@@ -1,207 +1,207 @@
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Checkbox, Popup } from "antd-mobile";
|
||||
import { getDeviceList } from "./api";
|
||||
import style from "./index.module.scss";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import PopupHeader from "@/components/PopuLayout/header";
|
||||
import PopupFooter from "@/components/PopuLayout/footer";
|
||||
|
||||
interface DeviceSelectionItem {
|
||||
id: string;
|
||||
name: string;
|
||||
imei: string;
|
||||
wechatId: string;
|
||||
status: "online" | "offline";
|
||||
wxid?: string;
|
||||
nickname?: string;
|
||||
usedInPlans?: number;
|
||||
}
|
||||
|
||||
interface SelectionPopupProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
selectedDevices: string[];
|
||||
onSelect: (devices: string[]) => void;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
const SelectionPopup: React.FC<SelectionPopupProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
selectedDevices,
|
||||
onSelect,
|
||||
}) => {
|
||||
// 设备数据
|
||||
const [devices, setDevices] = useState<DeviceSelectionItem[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
|
||||
// 获取设备列表,支持keyword和分页
|
||||
const fetchDevices = useCallback(
|
||||
async (keyword: string = "", page: number = 1) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getDeviceList({
|
||||
page,
|
||||
limit: PAGE_SIZE,
|
||||
keyword: keyword.trim() || undefined,
|
||||
});
|
||||
if (res && Array.isArray(res.list)) {
|
||||
setDevices(
|
||||
res.list.map((d: any) => ({
|
||||
id: d.id?.toString() || "",
|
||||
name: d.memo || d.imei || "",
|
||||
imei: d.imei || "",
|
||||
wechatId: d.wechatId || "",
|
||||
status: d.alive === 1 ? "online" : "offline",
|
||||
wxid: d.wechatId || "",
|
||||
nickname: d.nickname || "",
|
||||
usedInPlans: d.usedInPlans || 0,
|
||||
}))
|
||||
);
|
||||
setTotal(res.total || 0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取设备列表失败:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// 打开弹窗时获取第一页
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setSearchQuery("");
|
||||
setCurrentPage(1);
|
||||
fetchDevices("", 1);
|
||||
}
|
||||
}, [visible, fetchDevices]);
|
||||
|
||||
// 搜索防抖
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
const timer = setTimeout(() => {
|
||||
setCurrentPage(1);
|
||||
fetchDevices(searchQuery, 1);
|
||||
}, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchQuery, visible, fetchDevices]);
|
||||
|
||||
// 翻页时重新请求
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
fetchDevices(searchQuery, currentPage);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentPage]);
|
||||
|
||||
// 过滤设备(只保留状态过滤)
|
||||
const filteredDevices = devices.filter((device) => {
|
||||
const matchesStatus =
|
||||
statusFilter === "all" ||
|
||||
(statusFilter === "online" && device.status === "online") ||
|
||||
(statusFilter === "offline" && device.status === "offline");
|
||||
return matchesStatus;
|
||||
});
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
|
||||
// 处理设备选择
|
||||
const handleDeviceToggle = (deviceId: string) => {
|
||||
if (selectedDevices.includes(deviceId)) {
|
||||
onSelect(selectedDevices.filter((id) => id !== deviceId));
|
||||
} else {
|
||||
onSelect([...selectedDevices, deviceId]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Popup
|
||||
visible={visible}
|
||||
onMaskClick={onClose}
|
||||
position="bottom"
|
||||
bodyStyle={{ height: "100vh" }}
|
||||
closeOnMaskClick={false}
|
||||
>
|
||||
<Layout
|
||||
header={
|
||||
<PopupHeader
|
||||
title="选择设备"
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
searchPlaceholder="搜索设备IMEI/备注/微信号"
|
||||
loading={loading}
|
||||
onRefresh={() => fetchDevices(searchQuery, currentPage)}
|
||||
showTabs={true}
|
||||
tabsConfig={{
|
||||
activeKey: statusFilter,
|
||||
onChange: setStatusFilter,
|
||||
tabs: [
|
||||
{ title: "全部", key: "all" },
|
||||
{ title: "在线", key: "online" },
|
||||
{ title: "离线", key: "offline" },
|
||||
],
|
||||
}}
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
<PopupFooter
|
||||
total={total}
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
loading={loading}
|
||||
selectedCount={selectedDevices.length}
|
||||
onPageChange={setCurrentPage}
|
||||
onCancel={onClose}
|
||||
onConfirm={onClose}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className={style.deviceList}>
|
||||
{loading ? (
|
||||
<div className={style.loadingBox}>
|
||||
<div className={style.loadingText}>加载中...</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={style.deviceListInner}>
|
||||
{filteredDevices.map((device) => (
|
||||
<label key={device.id} className={style.deviceItem}>
|
||||
<Checkbox
|
||||
checked={selectedDevices.includes(device.id)}
|
||||
onChange={() => handleDeviceToggle(device.id)}
|
||||
className={style.deviceCheckbox}
|
||||
/>
|
||||
<div className={style.deviceInfo}>
|
||||
<div className={style.deviceInfoRow}>
|
||||
<span className={style.deviceName}>{device.name}</span>
|
||||
<div
|
||||
className={
|
||||
device.status === "online"
|
||||
? style.statusOnline
|
||||
: style.statusOffline
|
||||
}
|
||||
>
|
||||
{device.status === "online" ? "在线" : "离线"}
|
||||
</div>
|
||||
</div>
|
||||
<div className={style.deviceInfoDetail}>
|
||||
<div>IMEI: {device.imei}</div>
|
||||
<div>微信号: {device.wechatId}</div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
</Popup>
|
||||
);
|
||||
};
|
||||
|
||||
export default SelectionPopup;
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Checkbox, Popup } from "antd-mobile";
|
||||
import { getDeviceList } from "./api";
|
||||
import style from "./index.module.scss";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import PopupHeader from "@/components/PopuLayout/header";
|
||||
import PopupFooter from "@/components/PopuLayout/footer";
|
||||
|
||||
interface DeviceSelectionItem {
|
||||
id: string;
|
||||
name: string;
|
||||
imei: string;
|
||||
wechatId: string;
|
||||
status: "online" | "offline";
|
||||
wxid?: string;
|
||||
nickname?: string;
|
||||
usedInPlans?: number;
|
||||
}
|
||||
|
||||
interface SelectionPopupProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
selectedDevices: string[];
|
||||
onSelect: (devices: string[]) => void;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
const SelectionPopup: React.FC<SelectionPopupProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
selectedDevices,
|
||||
onSelect,
|
||||
}) => {
|
||||
// 设备数据
|
||||
const [devices, setDevices] = useState<DeviceSelectionItem[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
|
||||
// 获取设备列表,支持keyword和分页
|
||||
const fetchDevices = useCallback(
|
||||
async (keyword: string = "", page: number = 1) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getDeviceList({
|
||||
page,
|
||||
limit: PAGE_SIZE,
|
||||
keyword: keyword.trim() || undefined,
|
||||
});
|
||||
if (res && Array.isArray(res.list)) {
|
||||
setDevices(
|
||||
res.list.map((d: any) => ({
|
||||
id: d.id?.toString() || "",
|
||||
name: d.memo || d.imei || "",
|
||||
imei: d.imei || "",
|
||||
wechatId: d.wechatId || "",
|
||||
status: d.alive === 1 ? "online" : "offline",
|
||||
wxid: d.wechatId || "",
|
||||
nickname: d.nickname || "",
|
||||
usedInPlans: d.usedInPlans || 0,
|
||||
}))
|
||||
);
|
||||
setTotal(res.total || 0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取设备列表失败:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// 打开弹窗时获取第一页
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setSearchQuery("");
|
||||
setCurrentPage(1);
|
||||
fetchDevices("", 1);
|
||||
}
|
||||
}, [visible, fetchDevices]);
|
||||
|
||||
// 搜索防抖
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
const timer = setTimeout(() => {
|
||||
setCurrentPage(1);
|
||||
fetchDevices(searchQuery, 1);
|
||||
}, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchQuery, visible, fetchDevices]);
|
||||
|
||||
// 翻页时重新请求
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
fetchDevices(searchQuery, currentPage);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentPage]);
|
||||
|
||||
// 过滤设备(只保留状态过滤)
|
||||
const filteredDevices = devices.filter(device => {
|
||||
const matchesStatus =
|
||||
statusFilter === "all" ||
|
||||
(statusFilter === "online" && device.status === "online") ||
|
||||
(statusFilter === "offline" && device.status === "offline");
|
||||
return matchesStatus;
|
||||
});
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
|
||||
// 处理设备选择
|
||||
const handleDeviceToggle = (deviceId: string) => {
|
||||
if (selectedDevices.includes(deviceId)) {
|
||||
onSelect(selectedDevices.filter(id => id !== deviceId));
|
||||
} else {
|
||||
onSelect([...selectedDevices, deviceId]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Popup
|
||||
visible={visible}
|
||||
onMaskClick={onClose}
|
||||
position="bottom"
|
||||
bodyStyle={{ height: "100vh" }}
|
||||
closeOnMaskClick={false}
|
||||
>
|
||||
<Layout
|
||||
header={
|
||||
<PopupHeader
|
||||
title="选择设备"
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
searchPlaceholder="搜索设备IMEI/备注/微信号"
|
||||
loading={loading}
|
||||
onRefresh={() => fetchDevices(searchQuery, currentPage)}
|
||||
showTabs={true}
|
||||
tabsConfig={{
|
||||
activeKey: statusFilter,
|
||||
onChange: setStatusFilter,
|
||||
tabs: [
|
||||
{ title: "全部", key: "all" },
|
||||
{ title: "在线", key: "online" },
|
||||
{ title: "离线", key: "offline" },
|
||||
],
|
||||
}}
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
<PopupFooter
|
||||
total={total}
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
loading={loading}
|
||||
selectedCount={selectedDevices.length}
|
||||
onPageChange={setCurrentPage}
|
||||
onCancel={onClose}
|
||||
onConfirm={onClose}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className={style.deviceList}>
|
||||
{loading ? (
|
||||
<div className={style.loadingBox}>
|
||||
<div className={style.loadingText}>加载中...</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={style.deviceListInner}>
|
||||
{filteredDevices.map(device => (
|
||||
<label key={device.id} className={style.deviceItem}>
|
||||
<Checkbox
|
||||
checked={selectedDevices.includes(device.id)}
|
||||
onChange={() => handleDeviceToggle(device.id)}
|
||||
className={style.deviceCheckbox}
|
||||
/>
|
||||
<div className={style.deviceInfo}>
|
||||
<div className={style.deviceInfoRow}>
|
||||
<span className={style.deviceName}>{device.name}</span>
|
||||
<div
|
||||
className={
|
||||
device.status === "online"
|
||||
? style.statusOnline
|
||||
: style.statusOffline
|
||||
}
|
||||
>
|
||||
{device.status === "online" ? "在线" : "离线"}
|
||||
</div>
|
||||
</div>
|
||||
<div className={style.deviceInfoDetail}>
|
||||
<div>IMEI: {device.imei}</div>
|
||||
<div>微信号: {device.wechatId}</div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
</Popup>
|
||||
);
|
||||
};
|
||||
|
||||
export default SelectionPopup;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import request from "@/api/request";
|
||||
|
||||
// 获取好友列表
|
||||
export function getFriendList(params: {
|
||||
page: number;
|
||||
limit: number;
|
||||
deviceIds?: string; // 逗号分隔
|
||||
keyword?: string;
|
||||
}) {
|
||||
return request("/v1/friend", params, "GET");
|
||||
}
|
||||
import request from "@/api/request";
|
||||
|
||||
// 获取好友列表
|
||||
export function getFriendList(params: {
|
||||
page: number;
|
||||
limit: number;
|
||||
deviceIds?: string; // 逗号分隔
|
||||
keyword?: string;
|
||||
}) {
|
||||
return request("/v1/friend", params, "GET");
|
||||
}
|
||||
|
||||
@@ -1,231 +1,231 @@
|
||||
.inputWrapper {
|
||||
position: relative;
|
||||
}
|
||||
.inputIcon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #bdbdbd;
|
||||
font-size: 20px;
|
||||
}
|
||||
.input {
|
||||
padding-left: 38px !important;
|
||||
height: 48px;
|
||||
border-radius: 16px !important;
|
||||
border: 1px solid #e5e6eb !important;
|
||||
font-size: 16px;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.popupContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background: #fff;
|
||||
}
|
||||
.popupHeader {
|
||||
padding: 24px;
|
||||
}
|
||||
.popupTitle {
|
||||
text-align: center;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.searchWrapper {
|
||||
position: relative;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.searchInput {
|
||||
padding-left: 40px !important;
|
||||
padding-top: 8px !important;
|
||||
padding-bottom: 8px !important;
|
||||
border-radius: 24px !important;
|
||||
border: 1px solid #e5e6eb !important;
|
||||
font-size: 15px;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
.searchIcon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #bdbdbd;
|
||||
font-size: 16px;
|
||||
}
|
||||
.clearBtn {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
border-radius: 50%;
|
||||
min-width: 24px;
|
||||
}
|
||||
|
||||
.friendList {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.friendListInner {
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
.friendItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
&:hover {
|
||||
background: #f5f6fa;
|
||||
}
|
||||
}
|
||||
.radioWrapper {
|
||||
margin-right: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.radioSelected {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #1890ff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.radioUnselected {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #e5e6eb;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.radioDot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: #1890ff;
|
||||
}
|
||||
.friendInfo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
}
|
||||
.friendAvatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
}
|
||||
.avatarImg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.friendDetail {
|
||||
flex: 1;
|
||||
}
|
||||
.friendName {
|
||||
font-weight: 500;
|
||||
font-size: 16px;
|
||||
color: #222;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.friendId {
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.friendCustomer {
|
||||
font-size: 13px;
|
||||
color: #bdbdbd;
|
||||
}
|
||||
|
||||
.loadingBox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
.loadingText {
|
||||
color: #888;
|
||||
font-size: 15px;
|
||||
}
|
||||
.emptyBox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
.emptyText {
|
||||
color: #888;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.paginationRow {
|
||||
border-top: 1px solid #f0f0f0;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: #fff;
|
||||
}
|
||||
.totalCount {
|
||||
font-size: 14px;
|
||||
color: #888;
|
||||
}
|
||||
.paginationControls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.pageBtn {
|
||||
padding: 0 8px;
|
||||
height: 32px;
|
||||
min-width: 32px;
|
||||
}
|
||||
.pageInfo {
|
||||
font-size: 14px;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.popupFooter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
background: #fff;
|
||||
}
|
||||
.selectedCount {
|
||||
font-size: 14px;
|
||||
color: #888;
|
||||
}
|
||||
.footerBtnGroup {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
.cancelBtn {
|
||||
padding: 0 24px;
|
||||
border-radius: 24px;
|
||||
border: 1px solid #e5e6eb;
|
||||
}
|
||||
.confirmBtn {
|
||||
padding: 0 24px;
|
||||
border-radius: 24px;
|
||||
}
|
||||
.inputWrapper {
|
||||
position: relative;
|
||||
}
|
||||
.inputIcon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #bdbdbd;
|
||||
font-size: 20px;
|
||||
}
|
||||
.input {
|
||||
padding-left: 38px !important;
|
||||
height: 48px;
|
||||
border-radius: 16px !important;
|
||||
border: 1px solid #e5e6eb !important;
|
||||
font-size: 16px;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.popupContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background: #fff;
|
||||
}
|
||||
.popupHeader {
|
||||
padding: 24px;
|
||||
}
|
||||
.popupTitle {
|
||||
text-align: center;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.searchWrapper {
|
||||
position: relative;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.searchInput {
|
||||
padding-left: 40px !important;
|
||||
padding-top: 8px !important;
|
||||
padding-bottom: 8px !important;
|
||||
border-radius: 24px !important;
|
||||
border: 1px solid #e5e6eb !important;
|
||||
font-size: 15px;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
.searchIcon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #bdbdbd;
|
||||
font-size: 16px;
|
||||
}
|
||||
.clearBtn {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
border-radius: 50%;
|
||||
min-width: 24px;
|
||||
}
|
||||
|
||||
.friendList {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.friendListInner {
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
.friendItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
&:hover {
|
||||
background: #f5f6fa;
|
||||
}
|
||||
}
|
||||
.radioWrapper {
|
||||
margin-right: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.radioSelected {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #1890ff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.radioUnselected {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #e5e6eb;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.radioDot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: #1890ff;
|
||||
}
|
||||
.friendInfo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
}
|
||||
.friendAvatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
}
|
||||
.avatarImg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.friendDetail {
|
||||
flex: 1;
|
||||
}
|
||||
.friendName {
|
||||
font-weight: 500;
|
||||
font-size: 16px;
|
||||
color: #222;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.friendId {
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.friendCustomer {
|
||||
font-size: 13px;
|
||||
color: #bdbdbd;
|
||||
}
|
||||
|
||||
.loadingBox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
.loadingText {
|
||||
color: #888;
|
||||
font-size: 15px;
|
||||
}
|
||||
.emptyBox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
.emptyText {
|
||||
color: #888;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.paginationRow {
|
||||
border-top: 1px solid #f0f0f0;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: #fff;
|
||||
}
|
||||
.totalCount {
|
||||
font-size: 14px;
|
||||
color: #888;
|
||||
}
|
||||
.paginationControls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.pageBtn {
|
||||
padding: 0 8px;
|
||||
height: 32px;
|
||||
min-width: 32px;
|
||||
}
|
||||
.pageInfo {
|
||||
font-size: 14px;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.popupFooter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
background: #fff;
|
||||
}
|
||||
.selectedCount {
|
||||
font-size: 14px;
|
||||
color: #888;
|
||||
}
|
||||
.footerBtnGroup {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
.cancelBtn {
|
||||
padding: 0 24px;
|
||||
border-radius: 24px;
|
||||
border: 1px solid #e5e6eb;
|
||||
}
|
||||
.confirmBtn {
|
||||
padding: 0 24px;
|
||||
border-radius: 24px;
|
||||
}
|
||||
|
||||
@@ -129,14 +129,14 @@ export default function FriendSelection({
|
||||
if (readonly) return;
|
||||
|
||||
const newSelectedFriends = selectedFriends.includes(friendId)
|
||||
? selectedFriends.filter((id) => id !== friendId)
|
||||
? selectedFriends.filter(id => id !== friendId)
|
||||
: [...selectedFriends, friendId];
|
||||
|
||||
onSelect(newSelectedFriends);
|
||||
|
||||
// 如果有 onSelectDetail 回调,传递完整的好友对象
|
||||
if (onSelectDetail) {
|
||||
const selectedFriendObjs = friends.filter((friend) =>
|
||||
const selectedFriendObjs = friends.filter(friend =>
|
||||
newSelectedFriends.includes(friend.id)
|
||||
);
|
||||
onSelectDetail(selectedFriendObjs);
|
||||
@@ -151,10 +151,10 @@ export default function FriendSelection({
|
||||
|
||||
// 获取已选好友详细信息
|
||||
const selectedFriendObjs = [
|
||||
...friends.filter((friend) => selectedFriends.includes(friend.id)),
|
||||
...friends.filter(friend => selectedFriends.includes(friend.id)),
|
||||
...selectedFriends
|
||||
.filter((id) => !friends.some((friend) => friend.id === id))
|
||||
.map((id) => ({
|
||||
.filter(id => !friends.some(friend => friend.id === id))
|
||||
.map(id => ({
|
||||
id,
|
||||
nickname: id,
|
||||
wechatId: id,
|
||||
@@ -166,7 +166,7 @@ export default function FriendSelection({
|
||||
// 删除已选好友
|
||||
const handleRemoveFriend = (id: string) => {
|
||||
if (readonly) return;
|
||||
onSelect(selectedFriends.filter((d) => d !== id));
|
||||
onSelect(selectedFriends.filter(d => d !== id));
|
||||
};
|
||||
|
||||
// 确认选择
|
||||
@@ -210,7 +210,7 @@ export default function FriendSelection({
|
||||
background: "#fff",
|
||||
}}
|
||||
>
|
||||
{selectedFriendObjs.map((friend) => (
|
||||
{selectedFriendObjs.map(friend => (
|
||||
<div
|
||||
key={friend.id}
|
||||
className={style.selectedListRow}
|
||||
@@ -294,7 +294,7 @@ export default function FriendSelection({
|
||||
</div>
|
||||
) : friends.length > 0 ? (
|
||||
<div className={style.friendListInner}>
|
||||
{friends.map((friend) => (
|
||||
{friends.map(friend => (
|
||||
<label
|
||||
key={friend.id}
|
||||
className={style.friendItem}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import request from "@/api/request";
|
||||
|
||||
// 获取群组列表
|
||||
export function getGroupList(params: {
|
||||
page: number;
|
||||
limit: number;
|
||||
keyword?: string;
|
||||
}) {
|
||||
return request("/v1/chatroom", params, "GET");
|
||||
}
|
||||
import request from "@/api/request";
|
||||
|
||||
// 获取群组列表
|
||||
export function getGroupList(params: {
|
||||
page: number;
|
||||
limit: number;
|
||||
keyword?: string;
|
||||
}) {
|
||||
return request("/v1/chatroom", params, "GET");
|
||||
}
|
||||
|
||||
@@ -1,222 +1,222 @@
|
||||
.inputWrapper {
|
||||
position: relative;
|
||||
}
|
||||
.inputIcon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #bdbdbd;
|
||||
font-size: 20px;
|
||||
}
|
||||
.input {
|
||||
padding-left: 38px !important;
|
||||
height: 48px;
|
||||
border-radius: 16px !important;
|
||||
border: 1px solid #e5e6eb !important;
|
||||
font-size: 16px;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.popupContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background: #fff;
|
||||
}
|
||||
.popupHeader {
|
||||
padding: 24px;
|
||||
}
|
||||
.popupTitle {
|
||||
text-align: center;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.searchWrapper {
|
||||
position: relative;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.searchInput {
|
||||
padding-left: 40px !important;
|
||||
padding-top: 8px !important;
|
||||
padding-bottom: 8px !important;
|
||||
border-radius: 24px !important;
|
||||
border: 1px solid #e5e6eb !important;
|
||||
font-size: 15px;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
.searchIcon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #bdbdbd;
|
||||
font-size: 16px;
|
||||
}
|
||||
.clearBtn {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
border-radius: 50%;
|
||||
min-width: 24px;
|
||||
}
|
||||
|
||||
.groupList {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.groupListInner {
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
.groupItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
&:hover {
|
||||
background: #f5f6fa;
|
||||
}
|
||||
}
|
||||
.radioWrapper {
|
||||
margin-right: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.radioSelected {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #1890ff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.radioUnselected {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #e5e6eb;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.radioDot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: #1890ff;
|
||||
}
|
||||
.groupInfo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
}
|
||||
.groupAvatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
}
|
||||
.avatarImg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.groupDetail {
|
||||
flex: 1;
|
||||
}
|
||||
.groupName {
|
||||
font-weight: 500;
|
||||
font-size: 16px;
|
||||
color: #222;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.groupId {
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.groupOwner {
|
||||
font-size: 13px;
|
||||
color: #bdbdbd;
|
||||
}
|
||||
|
||||
.loadingBox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
.loadingText {
|
||||
color: #888;
|
||||
font-size: 15px;
|
||||
}
|
||||
.emptyBox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
.emptyText {
|
||||
color: #888;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.paginationRow {
|
||||
border-top: 1px solid #f0f0f0;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: #fff;
|
||||
}
|
||||
.totalCount {
|
||||
font-size: 14px;
|
||||
color: #888;
|
||||
}
|
||||
.paginationControls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.pageBtn {
|
||||
padding: 0 8px;
|
||||
height: 32px;
|
||||
min-width: 32px;
|
||||
}
|
||||
.pageInfo {
|
||||
font-size: 14px;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.popupFooter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
background: #fff;
|
||||
}
|
||||
.selectedCount {
|
||||
font-size: 14px;
|
||||
color: #888;
|
||||
}
|
||||
.footerBtnGroup {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
.inputWrapper {
|
||||
position: relative;
|
||||
}
|
||||
.inputIcon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #bdbdbd;
|
||||
font-size: 20px;
|
||||
}
|
||||
.input {
|
||||
padding-left: 38px !important;
|
||||
height: 48px;
|
||||
border-radius: 16px !important;
|
||||
border: 1px solid #e5e6eb !important;
|
||||
font-size: 16px;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.popupContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background: #fff;
|
||||
}
|
||||
.popupHeader {
|
||||
padding: 24px;
|
||||
}
|
||||
.popupTitle {
|
||||
text-align: center;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.searchWrapper {
|
||||
position: relative;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.searchInput {
|
||||
padding-left: 40px !important;
|
||||
padding-top: 8px !important;
|
||||
padding-bottom: 8px !important;
|
||||
border-radius: 24px !important;
|
||||
border: 1px solid #e5e6eb !important;
|
||||
font-size: 15px;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
.searchIcon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #bdbdbd;
|
||||
font-size: 16px;
|
||||
}
|
||||
.clearBtn {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
border-radius: 50%;
|
||||
min-width: 24px;
|
||||
}
|
||||
|
||||
.groupList {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.groupListInner {
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
.groupItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
&:hover {
|
||||
background: #f5f6fa;
|
||||
}
|
||||
}
|
||||
.radioWrapper {
|
||||
margin-right: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.radioSelected {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #1890ff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.radioUnselected {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #e5e6eb;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.radioDot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: #1890ff;
|
||||
}
|
||||
.groupInfo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
}
|
||||
.groupAvatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
}
|
||||
.avatarImg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.groupDetail {
|
||||
flex: 1;
|
||||
}
|
||||
.groupName {
|
||||
font-weight: 500;
|
||||
font-size: 16px;
|
||||
color: #222;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.groupId {
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.groupOwner {
|
||||
font-size: 13px;
|
||||
color: #bdbdbd;
|
||||
}
|
||||
|
||||
.loadingBox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
.loadingText {
|
||||
color: #888;
|
||||
font-size: 15px;
|
||||
}
|
||||
.emptyBox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
.emptyText {
|
||||
color: #888;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.paginationRow {
|
||||
border-top: 1px solid #f0f0f0;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: #fff;
|
||||
}
|
||||
.totalCount {
|
||||
font-size: 14px;
|
||||
color: #888;
|
||||
}
|
||||
.paginationControls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.pageBtn {
|
||||
padding: 0 8px;
|
||||
height: 32px;
|
||||
min-width: 32px;
|
||||
}
|
||||
.pageInfo {
|
||||
font-size: 14px;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.popupFooter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
background: #fff;
|
||||
}
|
||||
.selectedCount {
|
||||
font-size: 14px;
|
||||
color: #888;
|
||||
}
|
||||
.footerBtnGroup {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@@ -1,341 +1,341 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { SearchOutlined, DeleteOutlined } from "@ant-design/icons";
|
||||
import { Button, Input } from "antd";
|
||||
import { Popup } from "antd-mobile";
|
||||
import { getGroupList } from "./api";
|
||||
import style from "./index.module.scss";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import PopupHeader from "@/components/PopuLayout/header";
|
||||
import PopupFooter from "@/components/PopuLayout/footer";
|
||||
|
||||
// 群组接口类型
|
||||
interface WechatGroup {
|
||||
id: string;
|
||||
chatroomId: string;
|
||||
name: string;
|
||||
avatar: string;
|
||||
ownerWechatId: string;
|
||||
ownerNickname: string;
|
||||
ownerAvatar: string;
|
||||
}
|
||||
|
||||
// 组件属性接口
|
||||
interface GroupSelectionProps {
|
||||
selectedGroups: string[];
|
||||
onSelect: (groups: string[]) => void;
|
||||
onSelectDetail?: (groups: WechatGroup[]) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
visible?: boolean;
|
||||
onVisibleChange?: (visible: boolean) => void;
|
||||
selectedListMaxHeight?: number;
|
||||
showInput?: boolean;
|
||||
showSelectedList?: boolean;
|
||||
readonly?: boolean;
|
||||
onConfirm?: (selectedIds: string[], selectedItems: WechatGroup[]) => void; // 新增
|
||||
}
|
||||
|
||||
export default function GroupSelection({
|
||||
selectedGroups,
|
||||
onSelect,
|
||||
onSelectDetail,
|
||||
placeholder = "选择群聊",
|
||||
className = "",
|
||||
visible,
|
||||
onVisibleChange,
|
||||
selectedListMaxHeight = 300,
|
||||
showInput = true,
|
||||
showSelectedList = true,
|
||||
readonly = false,
|
||||
onConfirm,
|
||||
}: GroupSelectionProps) {
|
||||
const [popupVisible, setPopupVisible] = useState(false);
|
||||
const [groups, setGroups] = useState<WechatGroup[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [totalGroups, setTotalGroups] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 获取已选群聊详细信息
|
||||
const selectedGroupObjs = groups.filter((group) =>
|
||||
selectedGroups.includes(group.id)
|
||||
);
|
||||
|
||||
// 删除已选群聊
|
||||
const handleRemoveGroup = (id: string) => {
|
||||
if (readonly) return;
|
||||
onSelect(selectedGroups.filter((g) => g !== id));
|
||||
};
|
||||
|
||||
// 受控弹窗逻辑
|
||||
const realVisible = visible !== undefined ? visible : popupVisible;
|
||||
const setRealVisible = (v: boolean) => {
|
||||
if (onVisibleChange) onVisibleChange(v);
|
||||
if (visible === undefined) setPopupVisible(v);
|
||||
};
|
||||
|
||||
// 打开弹窗
|
||||
const openPopup = () => {
|
||||
if (readonly) return;
|
||||
setCurrentPage(1);
|
||||
setSearchQuery("");
|
||||
setRealVisible(true);
|
||||
fetchGroups(1, "");
|
||||
};
|
||||
|
||||
// 当页码变化时,拉取对应页数据(弹窗已打开时)
|
||||
useEffect(() => {
|
||||
if (realVisible && currentPage !== 1) {
|
||||
fetchGroups(currentPage, searchQuery);
|
||||
}
|
||||
}, [currentPage, realVisible, searchQuery]);
|
||||
|
||||
// 搜索防抖
|
||||
useEffect(() => {
|
||||
if (!realVisible) return;
|
||||
const timer = setTimeout(() => {
|
||||
setCurrentPage(1);
|
||||
fetchGroups(1, searchQuery);
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchQuery, realVisible]);
|
||||
|
||||
// 获取群聊列表API
|
||||
const fetchGroups = async (page: number, keyword: string = "") => {
|
||||
setLoading(true);
|
||||
try {
|
||||
let params: any = {
|
||||
page,
|
||||
limit: 20,
|
||||
};
|
||||
|
||||
if (keyword.trim()) {
|
||||
params.keyword = keyword.trim();
|
||||
}
|
||||
|
||||
const response = await getGroupList(params);
|
||||
if (response && response.list) {
|
||||
setGroups(response.list);
|
||||
setTotalGroups(response.total || 0);
|
||||
setTotalPages(Math.ceil((response.total || 0) / 20));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取群聊列表失败:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理群聊选择
|
||||
const handleGroupToggle = (groupId: string) => {
|
||||
if (readonly) return;
|
||||
|
||||
const newSelectedGroups = selectedGroups.includes(groupId)
|
||||
? selectedGroups.filter((id) => id !== groupId)
|
||||
: [...selectedGroups, groupId];
|
||||
|
||||
onSelect(newSelectedGroups);
|
||||
|
||||
// 如果有 onSelectDetail 回调,传递完整的群聊对象
|
||||
if (onSelectDetail) {
|
||||
const selectedGroupObjs = groups.filter((group) =>
|
||||
newSelectedGroups.includes(group.id)
|
||||
);
|
||||
onSelectDetail(selectedGroupObjs);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取显示文本
|
||||
const getDisplayText = () => {
|
||||
if (selectedGroups.length === 0) return "";
|
||||
return `已选择 ${selectedGroups.length} 个群聊`;
|
||||
};
|
||||
|
||||
// 确认选择
|
||||
const handleConfirm = () => {
|
||||
if (onConfirm) {
|
||||
onConfirm(selectedGroups, selectedGroupObjs);
|
||||
}
|
||||
setRealVisible(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 输入框 */}
|
||||
{showInput && (
|
||||
<div className={`${style.inputWrapper} ${className}`}>
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
value={getDisplayText()}
|
||||
onClick={openPopup}
|
||||
prefix={<SearchOutlined />}
|
||||
allowClear={!readonly}
|
||||
size="large"
|
||||
readOnly={readonly}
|
||||
disabled={readonly}
|
||||
style={
|
||||
readonly ? { background: "#f5f5f5", cursor: "not-allowed" } : {}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* 已选群聊列表窗口 */}
|
||||
{showSelectedList && selectedGroupObjs.length > 0 && (
|
||||
<div
|
||||
className={style.selectedListWindow}
|
||||
style={{
|
||||
maxHeight: selectedListMaxHeight,
|
||||
overflowY: "auto",
|
||||
marginTop: 8,
|
||||
border: "1px solid #e5e6eb",
|
||||
borderRadius: 8,
|
||||
background: "#fff",
|
||||
}}
|
||||
>
|
||||
{selectedGroupObjs.map((group) => (
|
||||
<div
|
||||
key={group.id}
|
||||
className={style.selectedListRow}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
padding: "4px 8px",
|
||||
borderBottom: "1px solid #f0f0f0",
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{group.name || group.chatroomId || group.id}
|
||||
</div>
|
||||
{!readonly && (
|
||||
<Button
|
||||
type="text"
|
||||
icon={<DeleteOutlined />}
|
||||
size="small"
|
||||
style={{
|
||||
marginLeft: 4,
|
||||
color: "#ff4d4f",
|
||||
border: "none",
|
||||
background: "none",
|
||||
minWidth: 24,
|
||||
height: 24,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
onClick={() => handleRemoveGroup(group.id)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* 弹窗 */}
|
||||
<Popup
|
||||
visible={realVisible && !readonly}
|
||||
onMaskClick={() => setRealVisible(false)}
|
||||
position="bottom"
|
||||
bodyStyle={{ height: "100vh" }}
|
||||
>
|
||||
<Layout
|
||||
header={
|
||||
<PopupHeader
|
||||
title="选择群聊"
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
searchPlaceholder="搜索群聊"
|
||||
loading={loading}
|
||||
onRefresh={() => fetchGroups(currentPage, searchQuery)}
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
<PopupFooter
|
||||
total={totalGroups}
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
loading={loading}
|
||||
selectedCount={selectedGroups.length}
|
||||
onPageChange={setCurrentPage}
|
||||
onCancel={() => setRealVisible(false)}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className={style.groupList}>
|
||||
{loading ? (
|
||||
<div className={style.loadingBox}>
|
||||
<div className={style.loadingText}>加载中...</div>
|
||||
</div>
|
||||
) : groups.length > 0 ? (
|
||||
<div className={style.groupListInner}>
|
||||
{groups.map((group) => (
|
||||
<label
|
||||
key={group.id}
|
||||
className={style.groupItem}
|
||||
onClick={() => !readonly && handleGroupToggle(group.id)}
|
||||
>
|
||||
<div className={style.radioWrapper}>
|
||||
<div
|
||||
className={
|
||||
selectedGroups.includes(group.id)
|
||||
? style.radioSelected
|
||||
: style.radioUnselected
|
||||
}
|
||||
>
|
||||
{selectedGroups.includes(group.id) && (
|
||||
<div className={style.radioDot}></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={style.groupInfo}>
|
||||
<div className={style.groupAvatar}>
|
||||
{group.avatar ? (
|
||||
<img
|
||||
src={group.avatar}
|
||||
alt={group.name}
|
||||
className={style.avatarImg}
|
||||
/>
|
||||
) : (
|
||||
group.name.charAt(0)
|
||||
)}
|
||||
</div>
|
||||
<div className={style.groupDetail}>
|
||||
<div className={style.groupName}>{group.name}</div>
|
||||
<div className={style.groupId}>
|
||||
群ID: {group.chatroomId}
|
||||
</div>
|
||||
{group.ownerNickname && (
|
||||
<div className={style.groupOwner}>
|
||||
群主: {group.ownerNickname}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className={style.emptyBox}>
|
||||
<div className={style.emptyText}>
|
||||
{searchQuery
|
||||
? `没有找到包含"${searchQuery}"的群聊`
|
||||
: "没有找到群聊"}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
</Popup>
|
||||
</>
|
||||
);
|
||||
}
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { SearchOutlined, DeleteOutlined } from "@ant-design/icons";
|
||||
import { Button, Input } from "antd";
|
||||
import { Popup } from "antd-mobile";
|
||||
import { getGroupList } from "./api";
|
||||
import style from "./index.module.scss";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import PopupHeader from "@/components/PopuLayout/header";
|
||||
import PopupFooter from "@/components/PopuLayout/footer";
|
||||
|
||||
// 群组接口类型
|
||||
interface WechatGroup {
|
||||
id: string;
|
||||
chatroomId: string;
|
||||
name: string;
|
||||
avatar: string;
|
||||
ownerWechatId: string;
|
||||
ownerNickname: string;
|
||||
ownerAvatar: string;
|
||||
}
|
||||
|
||||
// 组件属性接口
|
||||
interface GroupSelectionProps {
|
||||
selectedGroups: string[];
|
||||
onSelect: (groups: string[]) => void;
|
||||
onSelectDetail?: (groups: WechatGroup[]) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
visible?: boolean;
|
||||
onVisibleChange?: (visible: boolean) => void;
|
||||
selectedListMaxHeight?: number;
|
||||
showInput?: boolean;
|
||||
showSelectedList?: boolean;
|
||||
readonly?: boolean;
|
||||
onConfirm?: (selectedIds: string[], selectedItems: WechatGroup[]) => void; // 新增
|
||||
}
|
||||
|
||||
export default function GroupSelection({
|
||||
selectedGroups,
|
||||
onSelect,
|
||||
onSelectDetail,
|
||||
placeholder = "选择群聊",
|
||||
className = "",
|
||||
visible,
|
||||
onVisibleChange,
|
||||
selectedListMaxHeight = 300,
|
||||
showInput = true,
|
||||
showSelectedList = true,
|
||||
readonly = false,
|
||||
onConfirm,
|
||||
}: GroupSelectionProps) {
|
||||
const [popupVisible, setPopupVisible] = useState(false);
|
||||
const [groups, setGroups] = useState<WechatGroup[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [totalGroups, setTotalGroups] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 获取已选群聊详细信息
|
||||
const selectedGroupObjs = groups.filter(group =>
|
||||
selectedGroups.includes(group.id)
|
||||
);
|
||||
|
||||
// 删除已选群聊
|
||||
const handleRemoveGroup = (id: string) => {
|
||||
if (readonly) return;
|
||||
onSelect(selectedGroups.filter(g => g !== id));
|
||||
};
|
||||
|
||||
// 受控弹窗逻辑
|
||||
const realVisible = visible !== undefined ? visible : popupVisible;
|
||||
const setRealVisible = (v: boolean) => {
|
||||
if (onVisibleChange) onVisibleChange(v);
|
||||
if (visible === undefined) setPopupVisible(v);
|
||||
};
|
||||
|
||||
// 打开弹窗
|
||||
const openPopup = () => {
|
||||
if (readonly) return;
|
||||
setCurrentPage(1);
|
||||
setSearchQuery("");
|
||||
setRealVisible(true);
|
||||
fetchGroups(1, "");
|
||||
};
|
||||
|
||||
// 当页码变化时,拉取对应页数据(弹窗已打开时)
|
||||
useEffect(() => {
|
||||
if (realVisible && currentPage !== 1) {
|
||||
fetchGroups(currentPage, searchQuery);
|
||||
}
|
||||
}, [currentPage, realVisible, searchQuery]);
|
||||
|
||||
// 搜索防抖
|
||||
useEffect(() => {
|
||||
if (!realVisible) return;
|
||||
const timer = setTimeout(() => {
|
||||
setCurrentPage(1);
|
||||
fetchGroups(1, searchQuery);
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchQuery, realVisible]);
|
||||
|
||||
// 获取群聊列表API
|
||||
const fetchGroups = async (page: number, keyword: string = "") => {
|
||||
setLoading(true);
|
||||
try {
|
||||
let params: any = {
|
||||
page,
|
||||
limit: 20,
|
||||
};
|
||||
|
||||
if (keyword.trim()) {
|
||||
params.keyword = keyword.trim();
|
||||
}
|
||||
|
||||
const response = await getGroupList(params);
|
||||
if (response && response.list) {
|
||||
setGroups(response.list);
|
||||
setTotalGroups(response.total || 0);
|
||||
setTotalPages(Math.ceil((response.total || 0) / 20));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取群聊列表失败:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理群聊选择
|
||||
const handleGroupToggle = (groupId: string) => {
|
||||
if (readonly) return;
|
||||
|
||||
const newSelectedGroups = selectedGroups.includes(groupId)
|
||||
? selectedGroups.filter(id => id !== groupId)
|
||||
: [...selectedGroups, groupId];
|
||||
|
||||
onSelect(newSelectedGroups);
|
||||
|
||||
// 如果有 onSelectDetail 回调,传递完整的群聊对象
|
||||
if (onSelectDetail) {
|
||||
const selectedGroupObjs = groups.filter(group =>
|
||||
newSelectedGroups.includes(group.id)
|
||||
);
|
||||
onSelectDetail(selectedGroupObjs);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取显示文本
|
||||
const getDisplayText = () => {
|
||||
if (selectedGroups.length === 0) return "";
|
||||
return `已选择 ${selectedGroups.length} 个群聊`;
|
||||
};
|
||||
|
||||
// 确认选择
|
||||
const handleConfirm = () => {
|
||||
if (onConfirm) {
|
||||
onConfirm(selectedGroups, selectedGroupObjs);
|
||||
}
|
||||
setRealVisible(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 输入框 */}
|
||||
{showInput && (
|
||||
<div className={`${style.inputWrapper} ${className}`}>
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
value={getDisplayText()}
|
||||
onClick={openPopup}
|
||||
prefix={<SearchOutlined />}
|
||||
allowClear={!readonly}
|
||||
size="large"
|
||||
readOnly={readonly}
|
||||
disabled={readonly}
|
||||
style={
|
||||
readonly ? { background: "#f5f5f5", cursor: "not-allowed" } : {}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* 已选群聊列表窗口 */}
|
||||
{showSelectedList && selectedGroupObjs.length > 0 && (
|
||||
<div
|
||||
className={style.selectedListWindow}
|
||||
style={{
|
||||
maxHeight: selectedListMaxHeight,
|
||||
overflowY: "auto",
|
||||
marginTop: 8,
|
||||
border: "1px solid #e5e6eb",
|
||||
borderRadius: 8,
|
||||
background: "#fff",
|
||||
}}
|
||||
>
|
||||
{selectedGroupObjs.map(group => (
|
||||
<div
|
||||
key={group.id}
|
||||
className={style.selectedListRow}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
padding: "4px 8px",
|
||||
borderBottom: "1px solid #f0f0f0",
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{group.name || group.chatroomId || group.id}
|
||||
</div>
|
||||
{!readonly && (
|
||||
<Button
|
||||
type="text"
|
||||
icon={<DeleteOutlined />}
|
||||
size="small"
|
||||
style={{
|
||||
marginLeft: 4,
|
||||
color: "#ff4d4f",
|
||||
border: "none",
|
||||
background: "none",
|
||||
minWidth: 24,
|
||||
height: 24,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
onClick={() => handleRemoveGroup(group.id)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* 弹窗 */}
|
||||
<Popup
|
||||
visible={realVisible && !readonly}
|
||||
onMaskClick={() => setRealVisible(false)}
|
||||
position="bottom"
|
||||
bodyStyle={{ height: "100vh" }}
|
||||
>
|
||||
<Layout
|
||||
header={
|
||||
<PopupHeader
|
||||
title="选择群聊"
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
searchPlaceholder="搜索群聊"
|
||||
loading={loading}
|
||||
onRefresh={() => fetchGroups(currentPage, searchQuery)}
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
<PopupFooter
|
||||
total={totalGroups}
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
loading={loading}
|
||||
selectedCount={selectedGroups.length}
|
||||
onPageChange={setCurrentPage}
|
||||
onCancel={() => setRealVisible(false)}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className={style.groupList}>
|
||||
{loading ? (
|
||||
<div className={style.loadingBox}>
|
||||
<div className={style.loadingText}>加载中...</div>
|
||||
</div>
|
||||
) : groups.length > 0 ? (
|
||||
<div className={style.groupListInner}>
|
||||
{groups.map(group => (
|
||||
<label
|
||||
key={group.id}
|
||||
className={style.groupItem}
|
||||
onClick={() => !readonly && handleGroupToggle(group.id)}
|
||||
>
|
||||
<div className={style.radioWrapper}>
|
||||
<div
|
||||
className={
|
||||
selectedGroups.includes(group.id)
|
||||
? style.radioSelected
|
||||
: style.radioUnselected
|
||||
}
|
||||
>
|
||||
{selectedGroups.includes(group.id) && (
|
||||
<div className={style.radioDot}></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={style.groupInfo}>
|
||||
<div className={style.groupAvatar}>
|
||||
{group.avatar ? (
|
||||
<img
|
||||
src={group.avatar}
|
||||
alt={group.name}
|
||||
className={style.avatarImg}
|
||||
/>
|
||||
) : (
|
||||
group.name.charAt(0)
|
||||
)}
|
||||
</div>
|
||||
<div className={style.groupDetail}>
|
||||
<div className={style.groupName}>{group.name}</div>
|
||||
<div className={style.groupId}>
|
||||
群ID: {group.chatroomId}
|
||||
</div>
|
||||
{group.ownerNickname && (
|
||||
<div className={style.groupOwner}>
|
||||
群主: {group.ownerNickname}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className={style.emptyBox}>
|
||||
<div className={style.emptyText}>
|
||||
{searchQuery
|
||||
? `没有找到包含"${searchQuery}"的群聊`
|
||||
: "没有找到群聊"}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
</Popup>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,87 +1,87 @@
|
||||
.listContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.listItem {
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.loadMoreButtonContainer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.noMoreText {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
padding: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
color: #999;
|
||||
flex: 1;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.emptyIcon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.emptyText {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.pullToRefresh {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
// 自定义滚动条样式
|
||||
.listContainer::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.listContainer::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.listContainer::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.listContainer::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 768px) {
|
||||
.listContainer {
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.loadMoreButtonContainer {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.noMoreText {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
.listContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.listItem {
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.loadMoreButtonContainer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.noMoreText {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
padding: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
color: #999;
|
||||
flex: 1;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.emptyIcon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.emptyText {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.pullToRefresh {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
// 自定义滚动条样式
|
||||
.listContainer::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.listContainer::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.listContainer::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.listContainer::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 768px) {
|
||||
.listContainer {
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.loadMoreButtonContainer {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.noMoreText {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,195 +1,195 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||
import {
|
||||
PullToRefresh,
|
||||
InfiniteScroll,
|
||||
Button,
|
||||
SpinLoading,
|
||||
} from "antd-mobile";
|
||||
import styles from "./InfiniteList.module.scss";
|
||||
|
||||
interface InfiniteListProps<T> {
|
||||
// 数据相关
|
||||
data: T[];
|
||||
loading?: boolean;
|
||||
hasMore?: boolean;
|
||||
loadingText?: string;
|
||||
noMoreText?: string;
|
||||
|
||||
// 渲染相关
|
||||
renderItem: (item: T, index: number) => React.ReactNode;
|
||||
keyExtractor?: (item: T, index: number) => string | number;
|
||||
|
||||
// 事件回调
|
||||
onLoadMore?: () => Promise<void> | void;
|
||||
onRefresh?: () => Promise<void> | void;
|
||||
|
||||
// 样式相关
|
||||
className?: string;
|
||||
itemClassName?: string;
|
||||
containerStyle?: React.CSSProperties;
|
||||
|
||||
// 功能开关
|
||||
enablePullToRefresh?: boolean;
|
||||
enableInfiniteScroll?: boolean;
|
||||
enableLoadMoreButton?: boolean;
|
||||
|
||||
// 自定义高度
|
||||
height?: string | number;
|
||||
minHeight?: string | number;
|
||||
}
|
||||
|
||||
const InfiniteList = <T extends any>({
|
||||
data,
|
||||
loading = false,
|
||||
hasMore = true,
|
||||
loadingText = "加载中...",
|
||||
noMoreText = "没有更多了",
|
||||
|
||||
renderItem,
|
||||
keyExtractor = (_, index) => index,
|
||||
|
||||
onLoadMore,
|
||||
onRefresh,
|
||||
|
||||
className = "",
|
||||
itemClassName = "",
|
||||
containerStyle = {},
|
||||
|
||||
enablePullToRefresh = true,
|
||||
enableInfiniteScroll = true,
|
||||
enableLoadMoreButton = false,
|
||||
|
||||
height = "100%",
|
||||
minHeight = "200px",
|
||||
}: InfiniteListProps<T>) => {
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 处理下拉刷新
|
||||
const handleRefresh = useCallback(async () => {
|
||||
if (!onRefresh) return;
|
||||
|
||||
setRefreshing(true);
|
||||
try {
|
||||
await onRefresh();
|
||||
} catch (error) {
|
||||
console.error("Refresh failed:", error);
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [onRefresh]);
|
||||
|
||||
// 处理加载更多
|
||||
const handleLoadMore = useCallback(async () => {
|
||||
if (!onLoadMore || loadingMore || !hasMore) return;
|
||||
|
||||
setLoadingMore(true);
|
||||
try {
|
||||
await onLoadMore();
|
||||
} catch (error) {
|
||||
console.error("Load more failed:", error);
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, [onLoadMore, loadingMore, hasMore]);
|
||||
|
||||
// 点击加载更多按钮
|
||||
const handleLoadMoreClick = useCallback(() => {
|
||||
handleLoadMore();
|
||||
}, [handleLoadMore]);
|
||||
|
||||
// 容器样式
|
||||
const containerStyles: React.CSSProperties = {
|
||||
height,
|
||||
minHeight,
|
||||
...containerStyle,
|
||||
};
|
||||
|
||||
// 渲染列表项
|
||||
const renderListItems = () => {
|
||||
return data.map((item, index) => (
|
||||
<div
|
||||
key={keyExtractor(item, index)}
|
||||
className={`${styles.listItem} ${itemClassName}`}
|
||||
>
|
||||
{renderItem(item, index)}
|
||||
</div>
|
||||
));
|
||||
};
|
||||
|
||||
// 渲染加载更多按钮
|
||||
const renderLoadMoreButton = () => {
|
||||
if (!enableLoadMoreButton || !hasMore) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.loadMoreButtonContainer}>
|
||||
<Button
|
||||
size="small"
|
||||
loading={loadingMore}
|
||||
onClick={handleLoadMoreClick}
|
||||
disabled={loading || !hasMore}
|
||||
>
|
||||
{loadingMore ? loadingText : "点击加载更多"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染无更多数据提示
|
||||
const renderNoMoreText = () => {
|
||||
if (hasMore || data.length === 0) return null;
|
||||
|
||||
return <div className={styles.noMoreText}>{noMoreText}</div>;
|
||||
};
|
||||
|
||||
// 渲染空状态
|
||||
const renderEmptyState = () => {
|
||||
if (data.length > 0 || loading) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.emptyState}>
|
||||
<div className={styles.emptyIcon}>📝</div>
|
||||
<div className={styles.emptyText}>暂无数据</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const content = (
|
||||
<div
|
||||
className={`${styles.listContainer} ${className}`}
|
||||
style={containerStyles}
|
||||
>
|
||||
{renderListItems()}
|
||||
{renderLoadMoreButton()}
|
||||
{renderNoMoreText()}
|
||||
{renderEmptyState()}
|
||||
|
||||
{/* 无限滚动组件 */}
|
||||
{enableInfiniteScroll && (
|
||||
<InfiniteScroll
|
||||
loadMore={handleLoadMore}
|
||||
hasMore={hasMore}
|
||||
threshold={100}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// 如果启用下拉刷新,包装PullToRefresh
|
||||
if (enablePullToRefresh && onRefresh) {
|
||||
return (
|
||||
<PullToRefresh
|
||||
onRefresh={handleRefresh}
|
||||
refreshing={refreshing}
|
||||
className={styles.pullToRefresh}
|
||||
>
|
||||
{content}
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
};
|
||||
|
||||
export default InfiniteList;
|
||||
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||
import {
|
||||
PullToRefresh,
|
||||
InfiniteScroll,
|
||||
Button,
|
||||
SpinLoading,
|
||||
} from "antd-mobile";
|
||||
import styles from "./InfiniteList.module.scss";
|
||||
|
||||
interface InfiniteListProps<T> {
|
||||
// 数据相关
|
||||
data: T[];
|
||||
loading?: boolean;
|
||||
hasMore?: boolean;
|
||||
loadingText?: string;
|
||||
noMoreText?: string;
|
||||
|
||||
// 渲染相关
|
||||
renderItem: (item: T, index: number) => React.ReactNode;
|
||||
keyExtractor?: (item: T, index: number) => string | number;
|
||||
|
||||
// 事件回调
|
||||
onLoadMore?: () => Promise<void> | void;
|
||||
onRefresh?: () => Promise<void> | void;
|
||||
|
||||
// 样式相关
|
||||
className?: string;
|
||||
itemClassName?: string;
|
||||
containerStyle?: React.CSSProperties;
|
||||
|
||||
// 功能开关
|
||||
enablePullToRefresh?: boolean;
|
||||
enableInfiniteScroll?: boolean;
|
||||
enableLoadMoreButton?: boolean;
|
||||
|
||||
// 自定义高度
|
||||
height?: string | number;
|
||||
minHeight?: string | number;
|
||||
}
|
||||
|
||||
const InfiniteList = <T extends any>({
|
||||
data,
|
||||
loading = false,
|
||||
hasMore = true,
|
||||
loadingText = "加载中...",
|
||||
noMoreText = "没有更多了",
|
||||
|
||||
renderItem,
|
||||
keyExtractor = (_, index) => index,
|
||||
|
||||
onLoadMore,
|
||||
onRefresh,
|
||||
|
||||
className = "",
|
||||
itemClassName = "",
|
||||
containerStyle = {},
|
||||
|
||||
enablePullToRefresh = true,
|
||||
enableInfiniteScroll = true,
|
||||
enableLoadMoreButton = false,
|
||||
|
||||
height = "100%",
|
||||
minHeight = "200px",
|
||||
}: InfiniteListProps<T>) => {
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 处理下拉刷新
|
||||
const handleRefresh = useCallback(async () => {
|
||||
if (!onRefresh) return;
|
||||
|
||||
setRefreshing(true);
|
||||
try {
|
||||
await onRefresh();
|
||||
} catch (error) {
|
||||
console.error("Refresh failed:", error);
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [onRefresh]);
|
||||
|
||||
// 处理加载更多
|
||||
const handleLoadMore = useCallback(async () => {
|
||||
if (!onLoadMore || loadingMore || !hasMore) return;
|
||||
|
||||
setLoadingMore(true);
|
||||
try {
|
||||
await onLoadMore();
|
||||
} catch (error) {
|
||||
console.error("Load more failed:", error);
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, [onLoadMore, loadingMore, hasMore]);
|
||||
|
||||
// 点击加载更多按钮
|
||||
const handleLoadMoreClick = useCallback(() => {
|
||||
handleLoadMore();
|
||||
}, [handleLoadMore]);
|
||||
|
||||
// 容器样式
|
||||
const containerStyles: React.CSSProperties = {
|
||||
height,
|
||||
minHeight,
|
||||
...containerStyle,
|
||||
};
|
||||
|
||||
// 渲染列表项
|
||||
const renderListItems = () => {
|
||||
return data.map((item, index) => (
|
||||
<div
|
||||
key={keyExtractor(item, index)}
|
||||
className={`${styles.listItem} ${itemClassName}`}
|
||||
>
|
||||
{renderItem(item, index)}
|
||||
</div>
|
||||
));
|
||||
};
|
||||
|
||||
// 渲染加载更多按钮
|
||||
const renderLoadMoreButton = () => {
|
||||
if (!enableLoadMoreButton || !hasMore) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.loadMoreButtonContainer}>
|
||||
<Button
|
||||
size="small"
|
||||
loading={loadingMore}
|
||||
onClick={handleLoadMoreClick}
|
||||
disabled={loading || !hasMore}
|
||||
>
|
||||
{loadingMore ? loadingText : "点击加载更多"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染无更多数据提示
|
||||
const renderNoMoreText = () => {
|
||||
if (hasMore || data.length === 0) return null;
|
||||
|
||||
return <div className={styles.noMoreText}>{noMoreText}</div>;
|
||||
};
|
||||
|
||||
// 渲染空状态
|
||||
const renderEmptyState = () => {
|
||||
if (data.length > 0 || loading) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.emptyState}>
|
||||
<div className={styles.emptyIcon}>📝</div>
|
||||
<div className={styles.emptyText}>暂无数据</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const content = (
|
||||
<div
|
||||
className={`${styles.listContainer} ${className}`}
|
||||
style={containerStyles}
|
||||
>
|
||||
{renderListItems()}
|
||||
{renderLoadMoreButton()}
|
||||
{renderNoMoreText()}
|
||||
{renderEmptyState()}
|
||||
|
||||
{/* 无限滚动组件 */}
|
||||
{enableInfiniteScroll && (
|
||||
<InfiniteScroll
|
||||
loadMore={handleLoadMore}
|
||||
hasMore={hasMore}
|
||||
threshold={100}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// 如果启用下拉刷新,包装PullToRefresh
|
||||
if (enablePullToRefresh && onRefresh) {
|
||||
return (
|
||||
<PullToRefresh
|
||||
onRefresh={handleRefresh}
|
||||
refreshing={refreshing}
|
||||
className={styles.pullToRefresh}
|
||||
>
|
||||
{content}
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
};
|
||||
|
||||
export default InfiniteList;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
flex-direction: column;
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
|
||||
}
|
||||
|
||||
|
||||
.container main {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
@@ -25,4 +25,4 @@
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,53 +1,53 @@
|
||||
import React from "react";
|
||||
import ReactECharts from "echarts-for-react";
|
||||
|
||||
interface LineChartProps {
|
||||
title?: string;
|
||||
xData: string[];
|
||||
yData: number[];
|
||||
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: [
|
||||
{
|
||||
data: yData,
|
||||
type: "line",
|
||||
smooth: true,
|
||||
symbol: "circle",
|
||||
lineStyle: { color: "#1677ff" },
|
||||
itemStyle: { color: "#1677ff" },
|
||||
},
|
||||
],
|
||||
grid: { left: 40, right: 24, top: 40, bottom: 32 },
|
||||
};
|
||||
|
||||
return <ReactECharts option={option} style={{ height, width: "100%" }} />;
|
||||
};
|
||||
|
||||
export default LineChart;
|
||||
import React from "react";
|
||||
import ReactECharts from "echarts-for-react";
|
||||
|
||||
interface LineChartProps {
|
||||
title?: string;
|
||||
xData: string[];
|
||||
yData: number[];
|
||||
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: [
|
||||
{
|
||||
data: yData,
|
||||
type: "line",
|
||||
smooth: true,
|
||||
symbol: "circle",
|
||||
lineStyle: { color: "#1677ff" },
|
||||
itemStyle: { color: "#1677ff" },
|
||||
},
|
||||
],
|
||||
grid: { left: 40, right: 24, top: 40, bottom: 32 },
|
||||
};
|
||||
|
||||
return <ReactECharts option={option} style={{ height, width: "100%" }} />;
|
||||
};
|
||||
|
||||
export default LineChart;
|
||||
|
||||
@@ -42,12 +42,12 @@ const MeauMobile: React.FC<MeauMobileProps> = ({ activeKey }) => {
|
||||
<TabBar
|
||||
style={{ background: "#fff" }}
|
||||
activeKey={activeKey}
|
||||
onChange={(key) => {
|
||||
const tab = tabs.find((t) => t.key === key);
|
||||
onChange={key => {
|
||||
const tab = tabs.find(t => t.key === key);
|
||||
if (tab && tab.path) navigate(tab.path);
|
||||
}}
|
||||
>
|
||||
{tabs.map((item) => (
|
||||
{tabs.map(item => (
|
||||
<TabBar.Item key={item.key} icon={item.icon} title={item.title} />
|
||||
))}
|
||||
</TabBar>
|
||||
|
||||
@@ -1,41 +1,41 @@
|
||||
import React from "react";
|
||||
import { NavBar } from "antd-mobile";
|
||||
import { ArrowLeftOutlined } from "@ant-design/icons";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
interface NavCommonProps {
|
||||
title: string;
|
||||
backFn?: () => void;
|
||||
right?: React.ReactNode;
|
||||
}
|
||||
|
||||
const NavCommon: React.FC<NavCommonProps> = ({ title, backFn, right }) => {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<NavBar
|
||||
back={null}
|
||||
style={{ background: "#fff" }}
|
||||
left={
|
||||
<div className="nav-title">
|
||||
<ArrowLeftOutlined
|
||||
twoToneColor="#1677ff"
|
||||
onClick={() => {
|
||||
if (backFn) {
|
||||
backFn();
|
||||
} else {
|
||||
navigate(-1);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
right={right}
|
||||
>
|
||||
<span style={{ color: "var(--primary-color)", fontWeight: 600 }}>
|
||||
{title}
|
||||
</span>
|
||||
</NavBar>
|
||||
);
|
||||
};
|
||||
|
||||
export default NavCommon;
|
||||
import React from "react";
|
||||
import { NavBar } from "antd-mobile";
|
||||
import { ArrowLeftOutlined } from "@ant-design/icons";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
interface NavCommonProps {
|
||||
title: string;
|
||||
backFn?: () => void;
|
||||
right?: React.ReactNode;
|
||||
}
|
||||
|
||||
const NavCommon: React.FC<NavCommonProps> = ({ title, backFn, right }) => {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<NavBar
|
||||
back={null}
|
||||
style={{ background: "#fff" }}
|
||||
left={
|
||||
<div className="nav-title">
|
||||
<ArrowLeftOutlined
|
||||
twoToneColor="#1677ff"
|
||||
onClick={() => {
|
||||
if (backFn) {
|
||||
backFn();
|
||||
} else {
|
||||
navigate(-1);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
right={right}
|
||||
>
|
||||
<span style={{ color: "var(--primary-color)", fontWeight: 600 }}>
|
||||
{title}
|
||||
</span>
|
||||
</NavBar>
|
||||
);
|
||||
};
|
||||
|
||||
export default NavCommon;
|
||||
|
||||
@@ -1,71 +1,71 @@
|
||||
.popupFooter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.selectedCount {
|
||||
font-size: 14px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.footerBtnGroup {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.paginationRow {
|
||||
border-top: 1px solid #f0f0f0;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.totalCount {
|
||||
font-size: 14px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.paginationControls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pageBtn {
|
||||
padding: 0 8px;
|
||||
height: 32px;
|
||||
min-width: 32px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid #d9d9d9;
|
||||
color: #333;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: #1677ff;
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: #f5f5f5;
|
||||
color: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.pageInfo {
|
||||
font-size: 14px;
|
||||
color: #222;
|
||||
margin: 0 8px;
|
||||
min-width: 60px;
|
||||
text-align: center;
|
||||
}
|
||||
.popupFooter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.selectedCount {
|
||||
font-size: 14px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.footerBtnGroup {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.paginationRow {
|
||||
border-top: 1px solid #f0f0f0;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.totalCount {
|
||||
font-size: 14px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.paginationControls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pageBtn {
|
||||
padding: 0 8px;
|
||||
height: 32px;
|
||||
min-width: 32px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid #d9d9d9;
|
||||
color: #333;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: #1677ff;
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: #f5f5f5;
|
||||
color: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.pageInfo {
|
||||
font-size: 14px;
|
||||
color: #222;
|
||||
margin: 0 8px;
|
||||
min-width: 60px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -1,67 +1,67 @@
|
||||
import React from "react";
|
||||
import { Button } from "antd";
|
||||
import style from "./footer.module.scss";
|
||||
import { ArrowLeftOutlined, ArrowRightOutlined } from "@ant-design/icons";
|
||||
|
||||
interface PopupFooterProps {
|
||||
total: number;
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
loading: boolean;
|
||||
selectedCount: number;
|
||||
onPageChange: (page: number) => void;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
const PopupFooter: React.FC<PopupFooterProps> = ({
|
||||
total,
|
||||
currentPage,
|
||||
totalPages,
|
||||
loading,
|
||||
selectedCount,
|
||||
onPageChange,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{/* 分页栏 */}
|
||||
<div className={style.paginationRow}>
|
||||
<div className={style.totalCount}>总计 {total} 个设备</div>
|
||||
<div className={style.paginationControls}>
|
||||
<Button
|
||||
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
|
||||
disabled={currentPage === 1 || loading}
|
||||
className={style.pageBtn}
|
||||
>
|
||||
<ArrowLeftOutlined />
|
||||
</Button>
|
||||
<span className={style.pageInfo}>
|
||||
{currentPage} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
|
||||
disabled={currentPage === totalPages || loading}
|
||||
className={style.pageBtn}
|
||||
>
|
||||
<ArrowRightOutlined />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={style.popupFooter}>
|
||||
<div className={style.selectedCount}>已选择 {selectedCount} 个设备</div>
|
||||
<div className={style.footerBtnGroup}>
|
||||
<Button color="primary" variant="filled" onClick={onCancel}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="primary" onClick={onConfirm}>
|
||||
确定
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PopupFooter;
|
||||
import React from "react";
|
||||
import { Button } from "antd";
|
||||
import style from "./footer.module.scss";
|
||||
import { ArrowLeftOutlined, ArrowRightOutlined } from "@ant-design/icons";
|
||||
|
||||
interface PopupFooterProps {
|
||||
total: number;
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
loading: boolean;
|
||||
selectedCount: number;
|
||||
onPageChange: (page: number) => void;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
const PopupFooter: React.FC<PopupFooterProps> = ({
|
||||
total,
|
||||
currentPage,
|
||||
totalPages,
|
||||
loading,
|
||||
selectedCount,
|
||||
onPageChange,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{/* 分页栏 */}
|
||||
<div className={style.paginationRow}>
|
||||
<div className={style.totalCount}>总计 {total} 个设备</div>
|
||||
<div className={style.paginationControls}>
|
||||
<Button
|
||||
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
|
||||
disabled={currentPage === 1 || loading}
|
||||
className={style.pageBtn}
|
||||
>
|
||||
<ArrowLeftOutlined />
|
||||
</Button>
|
||||
<span className={style.pageInfo}>
|
||||
{currentPage} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
|
||||
disabled={currentPage === totalPages || loading}
|
||||
className={style.pageBtn}
|
||||
>
|
||||
<ArrowRightOutlined />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={style.popupFooter}>
|
||||
<div className={style.selectedCount}>已选择 {selectedCount} 个设备</div>
|
||||
<div className={style.footerBtnGroup}>
|
||||
<Button color="primary" variant="filled" onClick={onCancel}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="primary" onClick={onConfirm}>
|
||||
确定
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PopupFooter;
|
||||
|
||||
@@ -1,52 +1,51 @@
|
||||
.popupHeader {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.popupTitle {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.popupSearchRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.popupSearchInputWrap {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.inputIcon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #bdbdbd;
|
||||
z-index: 10;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
|
||||
.refreshBtn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.loadingIcon {
|
||||
animation: spin 1s linear infinite;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
.popupHeader {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.popupTitle {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.popupSearchRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.popupSearchInputWrap {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.inputIcon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #bdbdbd;
|
||||
z-index: 10;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.refreshBtn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.loadingIcon {
|
||||
animation: spin 1s linear infinite;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,86 +1,86 @@
|
||||
import React from "react";
|
||||
import { SearchOutlined, ReloadOutlined } from "@ant-design/icons";
|
||||
import { Input, Button } from "antd";
|
||||
import { Tabs } from "antd-mobile";
|
||||
import style from "./header.module.scss";
|
||||
|
||||
interface PopupHeaderProps {
|
||||
title: string;
|
||||
searchQuery: string;
|
||||
setSearchQuery: (value: string) => void;
|
||||
searchPlaceholder?: string;
|
||||
loading?: boolean;
|
||||
onRefresh?: () => void;
|
||||
showRefresh?: boolean;
|
||||
showSearch?: boolean;
|
||||
showTabs?: boolean;
|
||||
tabsConfig?: {
|
||||
activeKey: string;
|
||||
onChange: (key: string) => void;
|
||||
tabs: Array<{ title: string; key: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
const PopupHeader: React.FC<PopupHeaderProps> = ({
|
||||
title,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
searchPlaceholder = "搜索...",
|
||||
loading = false,
|
||||
onRefresh,
|
||||
showRefresh = true,
|
||||
showSearch = true,
|
||||
showTabs = false,
|
||||
tabsConfig,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<div className={style.popupHeader}>
|
||||
<div className={style.popupTitle}>{title}</div>
|
||||
</div>
|
||||
|
||||
{showSearch && (
|
||||
<div className={style.popupSearchRow}>
|
||||
<div className={style.popupSearchInputWrap}>
|
||||
<Input
|
||||
placeholder={searchPlaceholder}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
prefix={<SearchOutlined />}
|
||||
size="large"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showRefresh && onRefresh && (
|
||||
<Button
|
||||
type="text"
|
||||
onClick={onRefresh}
|
||||
disabled={loading}
|
||||
className={style.refreshBtn}
|
||||
>
|
||||
{loading ? (
|
||||
<div className={style.loadingIcon}>⟳</div>
|
||||
) : (
|
||||
<ReloadOutlined />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showTabs && tabsConfig && (
|
||||
<Tabs
|
||||
activeKey={tabsConfig.activeKey}
|
||||
onChange={tabsConfig.onChange}
|
||||
style={{ marginTop: 8 }}
|
||||
>
|
||||
{tabsConfig.tabs.map((tab) => (
|
||||
<Tabs.Tab key={tab.key} title={tab.title} />
|
||||
))}
|
||||
</Tabs>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PopupHeader;
|
||||
import React from "react";
|
||||
import { SearchOutlined, ReloadOutlined } from "@ant-design/icons";
|
||||
import { Input, Button } from "antd";
|
||||
import { Tabs } from "antd-mobile";
|
||||
import style from "./header.module.scss";
|
||||
|
||||
interface PopupHeaderProps {
|
||||
title: string;
|
||||
searchQuery: string;
|
||||
setSearchQuery: (value: string) => void;
|
||||
searchPlaceholder?: string;
|
||||
loading?: boolean;
|
||||
onRefresh?: () => void;
|
||||
showRefresh?: boolean;
|
||||
showSearch?: boolean;
|
||||
showTabs?: boolean;
|
||||
tabsConfig?: {
|
||||
activeKey: string;
|
||||
onChange: (key: string) => void;
|
||||
tabs: Array<{ title: string; key: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
const PopupHeader: React.FC<PopupHeaderProps> = ({
|
||||
title,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
searchPlaceholder = "搜索...",
|
||||
loading = false,
|
||||
onRefresh,
|
||||
showRefresh = true,
|
||||
showSearch = true,
|
||||
showTabs = false,
|
||||
tabsConfig,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<div className={style.popupHeader}>
|
||||
<div className={style.popupTitle}>{title}</div>
|
||||
</div>
|
||||
|
||||
{showSearch && (
|
||||
<div className={style.popupSearchRow}>
|
||||
<div className={style.popupSearchInputWrap}>
|
||||
<Input
|
||||
placeholder={searchPlaceholder}
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
prefix={<SearchOutlined />}
|
||||
size="large"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showRefresh && onRefresh && (
|
||||
<Button
|
||||
type="text"
|
||||
onClick={onRefresh}
|
||||
disabled={loading}
|
||||
className={style.refreshBtn}
|
||||
>
|
||||
{loading ? (
|
||||
<div className={style.loadingIcon}>⟳</div>
|
||||
) : (
|
||||
<ReloadOutlined />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showTabs && tabsConfig && (
|
||||
<Tabs
|
||||
activeKey={tabsConfig.activeKey}
|
||||
onChange={tabsConfig.onChange}
|
||||
style={{ marginTop: 8 }}
|
||||
>
|
||||
{tabsConfig.tabs.map(tab => (
|
||||
<Tabs.Tab key={tab.key} title={tab.title} />
|
||||
))}
|
||||
</Tabs>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PopupHeader;
|
||||
|
||||
@@ -1,67 +1,67 @@
|
||||
import React, { useState } from "react";
|
||||
import DeviceSelection from "./DeviceSelection";
|
||||
import FriendSelection from "./FriendSelection";
|
||||
import GroupSelection from "./GroupSelection";
|
||||
import { Button, Space } from "antd-mobile";
|
||||
|
||||
export default function SelectionTest() {
|
||||
// 设备选择
|
||||
const [selectedDevices, setSelectedDevices] = useState<string[]>([]);
|
||||
const [deviceDialogOpen, setDeviceDialogOpen] = useState(false);
|
||||
|
||||
// 好友选择
|
||||
const [selectedFriends, setSelectedFriends] = useState<string[]>([]);
|
||||
const [friendDialogOpen, setFriendDialogOpen] = useState(false);
|
||||
|
||||
// 群组选择
|
||||
const [selectedGroups, setSelectedGroups] = useState<string[]>([]);
|
||||
const [groupDialogOpen, setGroupDialogOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<h2>选择弹窗测试</h2>
|
||||
<Space direction="vertical" block>
|
||||
<div>
|
||||
<b>DeviceSelection(内嵌输入框+弹窗)</b>
|
||||
<DeviceSelection
|
||||
selectedDevices={selectedDevices}
|
||||
onSelect={setSelectedDevices}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<b>FriendSelection</b>
|
||||
<Button color="primary" onClick={() => setFriendDialogOpen(true)}>
|
||||
打开好友选择弹窗
|
||||
</Button>
|
||||
<FriendSelection
|
||||
selectedFriends={selectedFriends}
|
||||
onSelect={setSelectedFriends}
|
||||
placeholder="请选择微信好友"
|
||||
className=""
|
||||
visible={friendDialogOpen}
|
||||
onVisibleChange={setFriendDialogOpen}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<b>GroupSelection</b>
|
||||
<Button color="primary" onClick={() => setGroupDialogOpen(true)}>
|
||||
打开群组选择弹窗
|
||||
</Button>
|
||||
<GroupSelection
|
||||
selectedGroups={selectedGroups}
|
||||
onSelect={setSelectedGroups}
|
||||
placeholder="请选择群聊"
|
||||
className=""
|
||||
visible={groupDialogOpen}
|
||||
onVisibleChange={setGroupDialogOpen}
|
||||
/>
|
||||
</div>
|
||||
</Space>
|
||||
<div style={{ marginTop: 32 }}>
|
||||
<div>已选设备ID: {selectedDevices.join(", ")}</div>
|
||||
<div>已选好友ID: {selectedFriends.join(", ")}</div>
|
||||
<div>已选群组ID: {selectedGroups.join(", ")}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import React, { useState } from "react";
|
||||
import DeviceSelection from "./DeviceSelection";
|
||||
import FriendSelection from "./FriendSelection";
|
||||
import GroupSelection from "./GroupSelection";
|
||||
import { Button, Space } from "antd-mobile";
|
||||
|
||||
export default function SelectionTest() {
|
||||
// 设备选择
|
||||
const [selectedDevices, setSelectedDevices] = useState<string[]>([]);
|
||||
const [deviceDialogOpen, setDeviceDialogOpen] = useState(false);
|
||||
|
||||
// 好友选择
|
||||
const [selectedFriends, setSelectedFriends] = useState<string[]>([]);
|
||||
const [friendDialogOpen, setFriendDialogOpen] = useState(false);
|
||||
|
||||
// 群组选择
|
||||
const [selectedGroups, setSelectedGroups] = useState<string[]>([]);
|
||||
const [groupDialogOpen, setGroupDialogOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<h2>选择弹窗测试</h2>
|
||||
<Space direction="vertical" block>
|
||||
<div>
|
||||
<b>DeviceSelection(内嵌输入框+弹窗)</b>
|
||||
<DeviceSelection
|
||||
selectedDevices={selectedDevices}
|
||||
onSelect={setSelectedDevices}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<b>FriendSelection</b>
|
||||
<Button color="primary" onClick={() => setFriendDialogOpen(true)}>
|
||||
打开好友选择弹窗
|
||||
</Button>
|
||||
<FriendSelection
|
||||
selectedFriends={selectedFriends}
|
||||
onSelect={setSelectedFriends}
|
||||
placeholder="请选择微信好友"
|
||||
className=""
|
||||
visible={friendDialogOpen}
|
||||
onVisibleChange={setFriendDialogOpen}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<b>GroupSelection</b>
|
||||
<Button color="primary" onClick={() => setGroupDialogOpen(true)}>
|
||||
打开群组选择弹窗
|
||||
</Button>
|
||||
<GroupSelection
|
||||
selectedGroups={selectedGroups}
|
||||
onSelect={setSelectedGroups}
|
||||
placeholder="请选择群聊"
|
||||
className=""
|
||||
visible={groupDialogOpen}
|
||||
onVisibleChange={setGroupDialogOpen}
|
||||
/>
|
||||
</div>
|
||||
</Space>
|
||||
<div style={{ marginTop: 32 }}>
|
||||
<div>已选设备ID: {selectedDevices.join(", ")}</div>
|
||||
<div>已选好友ID: {selectedFriends.join(", ")}</div>
|
||||
<div>已选群组ID: {selectedGroups.join(", ")}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,43 +1,43 @@
|
||||
import React from "react";
|
||||
import { Steps } from "antd-mobile";
|
||||
|
||||
interface StepIndicatorProps {
|
||||
currentStep: number;
|
||||
steps: { id: number; title: string; subtitle: string }[];
|
||||
}
|
||||
|
||||
const StepIndicator: React.FC<StepIndicatorProps> = ({
|
||||
currentStep,
|
||||
steps,
|
||||
}) => {
|
||||
return (
|
||||
<div style={{ overflowX: "auto", padding: "30px 0px", background: "#fff" }}>
|
||||
<Steps current={currentStep - 1}>
|
||||
{steps.map((step, idx) => (
|
||||
<Steps.Step
|
||||
key={step.id}
|
||||
title={step.subtitle}
|
||||
icon={
|
||||
<div
|
||||
style={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 12,
|
||||
backgroundColor: idx < currentStep ? "#1677ff" : "#cccccc",
|
||||
color: "#fff",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{step.id}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Steps>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StepIndicator;
|
||||
import React from "react";
|
||||
import { Steps } from "antd-mobile";
|
||||
|
||||
interface StepIndicatorProps {
|
||||
currentStep: number;
|
||||
steps: { id: number; title: string; subtitle: string }[];
|
||||
}
|
||||
|
||||
const StepIndicator: React.FC<StepIndicatorProps> = ({
|
||||
currentStep,
|
||||
steps,
|
||||
}) => {
|
||||
return (
|
||||
<div style={{ overflowX: "auto", padding: "30px 0px", background: "#fff" }}>
|
||||
<Steps current={currentStep - 1}>
|
||||
{steps.map((step, idx) => (
|
||||
<Steps.Step
|
||||
key={step.id}
|
||||
title={step.subtitle}
|
||||
icon={
|
||||
<div
|
||||
style={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 12,
|
||||
backgroundColor: idx < currentStep ? "#1677ff" : "#cccccc",
|
||||
color: "#fff",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{step.id}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Steps>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StepIndicator;
|
||||
|
||||
75
nkebao/src/components/Upload/README.md
Normal file
75
nkebao/src/components/Upload/README.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# Upload 上传组件
|
||||
|
||||
基于 antd Upload 组件封装,简化了 API 请求和文件验证逻辑。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- ✅ 自动处理文件上传 API 请求
|
||||
- ✅ 文件类型和大小验证
|
||||
- ✅ 支持编辑和新增场景
|
||||
- ✅ 支持单文件和多文件上传
|
||||
- ✅ 上传状态显示
|
||||
- ✅ 文件删除功能
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 图片上传组件 (UploadComponent)
|
||||
|
||||
```tsx
|
||||
import UploadComponent from '@/components/Upload';
|
||||
|
||||
// 单图片上传
|
||||
<UploadComponent
|
||||
value={imageUrl ? [imageUrl] : []}
|
||||
onChange={(urls) => setImageUrl(urls[0] || "")}
|
||||
count={1}
|
||||
accept="image/*"
|
||||
/>
|
||||
|
||||
// 多图片上传
|
||||
<UploadComponent
|
||||
value={imageUrls}
|
||||
onChange={setImageUrls}
|
||||
count={9}
|
||||
accept="image/*"
|
||||
listType="picture-card"
|
||||
/>
|
||||
```
|
||||
|
||||
### 视频上传组件 (VideoUpload)
|
||||
|
||||
```tsx
|
||||
import VideoUpload from "@/components/Upload/VideoUpload";
|
||||
|
||||
<VideoUpload value={videoUrl} onChange={setVideoUrl} />;
|
||||
```
|
||||
|
||||
## Props
|
||||
|
||||
### UploadComponent
|
||||
|
||||
| 参数 | 说明 | 类型 | 默认值 |
|
||||
| --------- | -------------- | --------------------------------------- | ---------------- |
|
||||
| value | 文件 URL 数组 | `string[]` | `[]` |
|
||||
| onChange | 文件变化回调 | `(urls: string[]) => void` | - |
|
||||
| count | 最大上传数量 | `number` | `9` |
|
||||
| accept | 接受的文件类型 | `string` | `"image/*"` |
|
||||
| listType | 列表类型 | `"text" \| "picture" \| "picture-card"` | `"picture-card"` |
|
||||
| disabled | 是否禁用 | `boolean` | `false` |
|
||||
| className | 自定义类名 | `string` | - |
|
||||
|
||||
### VideoUpload
|
||||
|
||||
| 参数 | 说明 | 类型 | 默认值 |
|
||||
| --------- | ------------ | ----------------------- | ------- |
|
||||
| value | 视频 URL | `string` | `""` |
|
||||
| onChange | 视频变化回调 | `(url: string) => void` | - |
|
||||
| disabled | 是否禁用 | `boolean` | `false` |
|
||||
| className | 自定义类名 | `string` | - |
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. 组件内部使用 `/v1/attachment/upload` 接口进行文件上传
|
||||
2. 图片文件大小限制为 5MB,视频文件大小限制为 50MB
|
||||
3. 支持编辑场景,传入 `value` 时会自动显示已上传的文件
|
||||
4. 文件上传成功后会自动调用 `onChange` 回调
|
||||
144
nkebao/src/components/Upload/VideoUpload.tsx
Normal file
144
nkebao/src/components/Upload/VideoUpload.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import React, { useState } from "react";
|
||||
import { Upload, message } from "antd";
|
||||
import { LoadingOutlined, PlusOutlined } from "@ant-design/icons";
|
||||
import type { UploadProps, UploadFile } from "antd/es/upload/interface";
|
||||
import request from "@/api/request";
|
||||
import style from "./index.module.scss";
|
||||
|
||||
interface VideoUploadProps {
|
||||
value?: string;
|
||||
onChange?: (url: string) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const VideoUpload: React.FC<VideoUploadProps> = ({
|
||||
value = "",
|
||||
onChange,
|
||||
disabled = false,
|
||||
className,
|
||||
}) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [fileList, setFileList] = useState<UploadFile[]>([]);
|
||||
|
||||
// 将value转换为fileList格式
|
||||
React.useEffect(() => {
|
||||
if (value) {
|
||||
const file: UploadFile = {
|
||||
uid: "-1",
|
||||
name: "video",
|
||||
status: "done",
|
||||
url: value,
|
||||
};
|
||||
setFileList([file]);
|
||||
} else {
|
||||
setFileList([]);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
// 文件验证
|
||||
const beforeUpload = (file: File) => {
|
||||
const isVideo = file.type.startsWith("video/");
|
||||
if (!isVideo) {
|
||||
message.error("只能上传视频文件!");
|
||||
return false;
|
||||
}
|
||||
|
||||
const isLt50M = file.size / 1024 / 1024 < 50;
|
||||
if (!isLt50M) {
|
||||
message.error("视频大小不能超过50MB!");
|
||||
return false;
|
||||
}
|
||||
|
||||
return false; // 阻止自动上传
|
||||
};
|
||||
|
||||
// 处理文件变化
|
||||
const handleChange: UploadProps["onChange"] = info => {
|
||||
if (info.file.status === "uploading") {
|
||||
setLoading(true);
|
||||
return;
|
||||
}
|
||||
if (info.file.status === "done") {
|
||||
setLoading(false);
|
||||
// 更新fileList
|
||||
setFileList([info.file]);
|
||||
// 调用onChange
|
||||
onChange?.(info.file.url || "");
|
||||
}
|
||||
};
|
||||
|
||||
// 自定义上传请求
|
||||
const customRequest: UploadProps["customRequest"] = async ({
|
||||
file,
|
||||
onSuccess,
|
||||
onError,
|
||||
}) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const formData = new FormData();
|
||||
formData.append("file", file as File);
|
||||
|
||||
const response = await request("/v1/attachment/upload", formData, "POST");
|
||||
|
||||
if (response) {
|
||||
const uploadedUrl =
|
||||
typeof response === "string" ? response : response.url || response;
|
||||
onSuccess?.(uploadedUrl);
|
||||
} else {
|
||||
throw new Error("上传失败");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("上传失败:", error);
|
||||
onError?.(error as Error);
|
||||
message.error("上传失败,请重试");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 删除文件
|
||||
const handleRemove = () => {
|
||||
setFileList([]);
|
||||
onChange?.("");
|
||||
return true;
|
||||
};
|
||||
|
||||
const uploadButton = (
|
||||
<div className={style["upload-button"]}>
|
||||
{loading ? (
|
||||
<div className={style["uploading"]}>
|
||||
<LoadingOutlined className={style["upload-icon"]} />
|
||||
<div className={style["upload-text"]}>上传中...</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<PlusOutlined className={style["upload-icon"]} />
|
||||
<div className={style["upload-text"]}>上传视频</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`${style["upload-container"]} ${className || ""}`}>
|
||||
<Upload
|
||||
name="file"
|
||||
multiple={false}
|
||||
fileList={fileList}
|
||||
accept="video/*"
|
||||
listType="text"
|
||||
showUploadList={true}
|
||||
disabled={disabled || loading}
|
||||
beforeUpload={beforeUpload}
|
||||
customRequest={customRequest}
|
||||
onChange={handleChange}
|
||||
onRemove={handleRemove}
|
||||
>
|
||||
{fileList.length >= 1 ? null : uploadButton}
|
||||
</Upload>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VideoUpload;
|
||||
75
nkebao/src/components/Upload/index.module.scss
Normal file
75
nkebao/src/components/Upload/index.module.scss
Normal file
@@ -0,0 +1,75 @@
|
||||
.upload-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.upload-button {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100px;
|
||||
border: 1px dashed #d9d9d9;
|
||||
border-radius: 6px;
|
||||
background: #fafafa;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
border-color: #1677ff;
|
||||
background: #f0f8ff;
|
||||
}
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
font-size: 24px;
|
||||
color: #999;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.upload-text {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.uploading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
.upload-icon {
|
||||
color: #1677ff;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.upload-text {
|
||||
color: #1677ff;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
// 覆盖antd默认样式
|
||||
:global {
|
||||
.ant-upload-list-picture-card {
|
||||
.ant-upload-list-item {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-upload-select-picture-card {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
}
|
||||
}
|
||||
164
nkebao/src/components/Upload/index.tsx
Normal file
164
nkebao/src/components/Upload/index.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import React, { useState } from "react";
|
||||
import { Upload, message } from "antd";
|
||||
import { LoadingOutlined, PlusOutlined } from "@ant-design/icons";
|
||||
import type { UploadProps, UploadFile } from "antd/es/upload/interface";
|
||||
import request from "@/api/request";
|
||||
import style from "./index.module.scss";
|
||||
|
||||
interface UploadComponentProps {
|
||||
value?: string[];
|
||||
onChange?: (urls: string[]) => void;
|
||||
count?: number; // 最大上传数量
|
||||
accept?: string; // 文件类型
|
||||
listType?: "text" | "picture" | "picture-card";
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const UploadComponent: React.FC<UploadComponentProps> = ({
|
||||
value = [],
|
||||
onChange,
|
||||
count = 9,
|
||||
accept = "image/*",
|
||||
listType = "picture-card",
|
||||
disabled = false,
|
||||
className,
|
||||
}) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [fileList, setFileList] = useState<UploadFile[]>([]);
|
||||
|
||||
// 将value转换为fileList格式
|
||||
React.useEffect(() => {
|
||||
if (value && value.length > 0) {
|
||||
const files = value.map((url, index) => ({
|
||||
uid: `-${index}`,
|
||||
name: `file-${index}`,
|
||||
status: "done" as const,
|
||||
url,
|
||||
}));
|
||||
setFileList(files);
|
||||
} else {
|
||||
setFileList([]);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
// 文件验证
|
||||
const beforeUpload = (file: File) => {
|
||||
const isValidType = file.type.startsWith(accept.replace("*", ""));
|
||||
if (!isValidType) {
|
||||
message.error(`只能上传${accept}格式的文件!`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const isLt5M = file.size / 1024 / 1024 < 5;
|
||||
if (!isLt5M) {
|
||||
message.error("文件大小不能超过5MB!");
|
||||
return false;
|
||||
}
|
||||
|
||||
return false; // 阻止自动上传
|
||||
};
|
||||
|
||||
// 处理文件变化
|
||||
const handleChange: UploadProps["onChange"] = info => {
|
||||
if (info.file.status === "uploading") {
|
||||
setLoading(true);
|
||||
return;
|
||||
}
|
||||
if (info.file.status === "done") {
|
||||
setLoading(false);
|
||||
// 更新fileList
|
||||
const newFileList = [...fileList];
|
||||
const fileIndex = newFileList.findIndex(f => f.uid === info.file.uid);
|
||||
if (fileIndex > -1) {
|
||||
newFileList[fileIndex] = info.file;
|
||||
} else {
|
||||
newFileList.push(info.file);
|
||||
}
|
||||
setFileList(newFileList);
|
||||
|
||||
// 调用onChange
|
||||
const urls = newFileList.map(f => f.url).filter(Boolean) as string[];
|
||||
onChange?.(urls);
|
||||
}
|
||||
};
|
||||
|
||||
// 自定义上传请求
|
||||
const customRequest: UploadProps["customRequest"] = async ({
|
||||
file,
|
||||
onSuccess,
|
||||
onError,
|
||||
onProgress,
|
||||
}) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const formData = new FormData();
|
||||
formData.append("file", file as File);
|
||||
|
||||
const response = await request("/v1/attachment/upload", formData, "POST");
|
||||
|
||||
if (response) {
|
||||
const uploadedUrl =
|
||||
typeof response === "string" ? response : response.url || response;
|
||||
onSuccess?.(uploadedUrl);
|
||||
} else {
|
||||
throw new Error("上传失败");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("上传失败:", error);
|
||||
onError?.(error as Error);
|
||||
message.error("上传失败,请重试");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 删除文件
|
||||
const handleRemove = (file: UploadFile) => {
|
||||
const newFileList = fileList.filter(f => f.uid !== file.uid);
|
||||
setFileList(newFileList);
|
||||
const urls = newFileList.map(f => f.url).filter(Boolean) as string[];
|
||||
onChange?.(urls);
|
||||
return true;
|
||||
};
|
||||
|
||||
const uploadButton = (
|
||||
<div className={style["upload-button"]}>
|
||||
{loading ? (
|
||||
<div className={style["uploading"]}>
|
||||
<LoadingOutlined className={style["upload-icon"]} />
|
||||
<div className={style["upload-text"]}>上传中...</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<PlusOutlined className={style["upload-icon"]} />
|
||||
<div className={style["upload-text"]}>
|
||||
{count > 1 ? "上传文件" : "上传文件"}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`${style["upload-container"]} ${className || ""}`}>
|
||||
<Upload
|
||||
name="file"
|
||||
multiple={count > 1}
|
||||
fileList={fileList}
|
||||
accept={accept}
|
||||
listType={listType}
|
||||
showUploadList={true}
|
||||
disabled={disabled || loading}
|
||||
beforeUpload={beforeUpload}
|
||||
customRequest={customRequest}
|
||||
onChange={handleChange}
|
||||
onRemove={handleRemove}
|
||||
>
|
||||
{fileList.length >= count ? null : uploadButton}
|
||||
</Upload>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UploadComponent;
|
||||
@@ -1,8 +1,8 @@
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./styles/global.scss";
|
||||
// import VConsole from "vconsole";
|
||||
// new VConsole();
|
||||
const root = createRoot(document.getElementById("root")!);
|
||||
root.render(<App />);
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./styles/global.scss";
|
||||
// import VConsole from "vconsole";
|
||||
// new VConsole();
|
||||
const root = createRoot(document.getElementById("root")!);
|
||||
root.render(<App />);
|
||||
|
||||
@@ -1,53 +1,53 @@
|
||||
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;
|
||||
member: {
|
||||
id: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
s2_accountId: string;
|
||||
avatar?: string;
|
||||
email?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface SendCodeResponse {
|
||||
code: number;
|
||||
msg: string;
|
||||
}
|
||||
|
||||
// 密码登录
|
||||
export function loginWithPassword(params:any) {
|
||||
return request('/v1/auth/login', params, 'POST');
|
||||
}
|
||||
|
||||
// 验证码登录
|
||||
export function loginWithCode(params:any) {
|
||||
return request('/v1/auth/login-code', params, 'POST');
|
||||
}
|
||||
|
||||
// 发送验证码
|
||||
export function sendVerificationCode(params:any) {
|
||||
return request('/v1/auth/code',params, 'POST');
|
||||
}
|
||||
|
||||
// 退出登录
|
||||
export function logout() {
|
||||
return request('/v1/auth/logout', {}, 'POST');
|
||||
}
|
||||
|
||||
// 获取用户信息
|
||||
export function getUserInfo() {
|
||||
return request('/v1/auth/user-info', {}, 'GET');
|
||||
}
|
||||
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;
|
||||
member: {
|
||||
id: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
s2_accountId: string;
|
||||
avatar?: string;
|
||||
email?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface SendCodeResponse {
|
||||
code: number;
|
||||
msg: string;
|
||||
}
|
||||
|
||||
// 密码登录
|
||||
export function loginWithPassword(params: any) {
|
||||
return request("/v1/auth/login", params, "POST");
|
||||
}
|
||||
|
||||
// 验证码登录
|
||||
export function loginWithCode(params: any) {
|
||||
return request("/v1/auth/login-code", params, "POST");
|
||||
}
|
||||
|
||||
// 发送验证码
|
||||
export function sendVerificationCode(params: any) {
|
||||
return request("/v1/auth/code", params, "POST");
|
||||
}
|
||||
|
||||
// 退出登录
|
||||
export function logout() {
|
||||
return request("/v1/auth/logout", {}, "POST");
|
||||
}
|
||||
|
||||
// 获取用户信息
|
||||
export function getUserInfo() {
|
||||
return request("/v1/auth/user-info", {}, "GET");
|
||||
}
|
||||
|
||||
@@ -1,439 +1,440 @@
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
background: var(--primary-gradient);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 15px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// 背景装饰
|
||||
.bg-decoration {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.bg-circle {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
animation: float 6s ease-in-out infinite;
|
||||
|
||||
&:nth-child(1) {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
top: -100px;
|
||||
right: -100px;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
&:nth-child(2) {
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
bottom: -75px;
|
||||
left: -75px;
|
||||
animation-delay: 2s;
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
top: 50%;
|
||||
right: 10%;
|
||||
animation-delay: 4s;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% {
|
||||
transform: translateY(0px) rotate(0deg);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-20px) rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.login-container {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
background: #ffffff;
|
||||
backdrop-filter: blur(20px);
|
||||
border-radius: 24px;
|
||||
padding: 24px 20px;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.logo-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: var(--primary-gradient);
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 20px;
|
||||
box-shadow: 0 6px 12px var(--primary-shadow);
|
||||
}
|
||||
|
||||
.app-name {
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
background: var(--primary-gradient);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.form-container {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
// 标签页样式
|
||||
.tab-container {
|
||||
display: flex;
|
||||
background: #f8f9fa;
|
||||
border-radius: 10px;
|
||||
padding: 3px;
|
||||
margin-bottom: 24px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
border-radius: 7px;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
|
||||
&.active {
|
||||
color: var(--primary-color);
|
||||
font-weight: 600;
|
||||
background: white;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.tab-indicator {
|
||||
display: none; // 隐藏分割线指示器
|
||||
}
|
||||
|
||||
// 表单样式
|
||||
.login-form {
|
||||
:global(.adm-form) {
|
||||
--adm-font-size-main: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.input-group {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.input-label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: #f8f9fa;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 10px;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:focus-within {
|
||||
border-color: var(--primary-color);
|
||||
background: white;
|
||||
box-shadow: 0 0 0 3px var(--primary-shadow-light);
|
||||
}
|
||||
}
|
||||
|
||||
.input-prefix {
|
||||
padding: 0 12px;
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
border-right: 1px solid #e5e5e5;
|
||||
}
|
||||
|
||||
.phone-input,
|
||||
.password-input,
|
||||
.code-input {
|
||||
flex: 1;
|
||||
border: none !important;
|
||||
background: transparent !important;
|
||||
padding: 12px 14px !important;
|
||||
font-size: 15px !important;
|
||||
color: #333 !important;
|
||||
|
||||
&::placeholder {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.eye-icon {
|
||||
padding: 0 12px;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
transition: color 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
}
|
||||
|
||||
.send-code-btn {
|
||||
padding: 6px 12px;
|
||||
margin-right: 6px;
|
||||
background: var(--primary-gradient);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover:not(.disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 3px 8px var(--primary-shadow);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
background: #e5e5e5;
|
||||
color: #999;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.agreement-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.agreement-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
line-height: 1.3;
|
||||
white-space: nowrap;
|
||||
|
||||
:global(.adm-checkbox) {
|
||||
margin-top: 0;
|
||||
flex-shrink: 0;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
.agreement-text {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
white-space: nowrap;
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.agreement-link {
|
||||
color: var(--primary-color);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
font-size: 11px;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
height: 46px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
border-radius: 10px;
|
||||
background: var(--primary-gradient);
|
||||
border: none;
|
||||
box-shadow: 0 6px 12px var(--primary-shadow);
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 8px 16px var(--primary-shadow-dark);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: #e5e5e5;
|
||||
color: #999;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.divider {
|
||||
position: relative;
|
||||
text-align: center;
|
||||
margin: 24px 0;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: #e5e5e5;
|
||||
}
|
||||
|
||||
span {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
padding: 0 12px;
|
||||
color: #999;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.third-party-login {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.third-party-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
background: #f8f9fa;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.wechat-icon,
|
||||
.apple-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 18px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.wechat-icon {
|
||||
background: #07c160;
|
||||
box-shadow: 0 3px 8px rgba(7, 193, 96, 0.3);
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 12px rgba(7, 193, 96, 0.4);
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.apple-icon {
|
||||
background: #000;
|
||||
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.3);
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 480px) {
|
||||
.login-container {
|
||||
padding: 24px 20px;
|
||||
margin: 0 12px;
|
||||
}
|
||||
|
||||
.app-name {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.third-party-login {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.third-party-item {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.wechat-icon,
|
||||
.apple-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
background: var(--primary-gradient);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 15px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// 背景装饰
|
||||
.bg-decoration {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.bg-circle {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
animation: float 6s ease-in-out infinite;
|
||||
|
||||
&:nth-child(1) {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
top: -100px;
|
||||
right: -100px;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
&:nth-child(2) {
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
bottom: -75px;
|
||||
left: -75px;
|
||||
animation-delay: 2s;
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
top: 50%;
|
||||
right: 10%;
|
||||
animation-delay: 4s;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0px) rotate(0deg);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-20px) rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.login-container {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
background: #ffffff;
|
||||
backdrop-filter: blur(20px);
|
||||
border-radius: 24px;
|
||||
padding: 24px 20px;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.logo-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: var(--primary-gradient);
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 20px;
|
||||
box-shadow: 0 6px 12px var(--primary-shadow);
|
||||
}
|
||||
|
||||
.app-name {
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
background: var(--primary-gradient);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.form-container {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
// 标签页样式
|
||||
.tab-container {
|
||||
display: flex;
|
||||
background: #f8f9fa;
|
||||
border-radius: 10px;
|
||||
padding: 3px;
|
||||
margin-bottom: 24px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
border-radius: 7px;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
|
||||
&.active {
|
||||
color: var(--primary-color);
|
||||
font-weight: 600;
|
||||
background: white;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.tab-indicator {
|
||||
display: none; // 隐藏分割线指示器
|
||||
}
|
||||
|
||||
// 表单样式
|
||||
.login-form {
|
||||
:global(.adm-form) {
|
||||
--adm-font-size-main: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.input-group {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.input-label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: #f8f9fa;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 10px;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:focus-within {
|
||||
border-color: var(--primary-color);
|
||||
background: white;
|
||||
box-shadow: 0 0 0 3px var(--primary-shadow-light);
|
||||
}
|
||||
}
|
||||
|
||||
.input-prefix {
|
||||
padding: 0 12px;
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
border-right: 1px solid #e5e5e5;
|
||||
}
|
||||
|
||||
.phone-input,
|
||||
.password-input,
|
||||
.code-input {
|
||||
flex: 1;
|
||||
border: none !important;
|
||||
background: transparent !important;
|
||||
padding: 12px 14px !important;
|
||||
font-size: 15px !important;
|
||||
color: #333 !important;
|
||||
|
||||
&::placeholder {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.eye-icon {
|
||||
padding: 0 12px;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
transition: color 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
}
|
||||
|
||||
.send-code-btn {
|
||||
padding: 6px 12px;
|
||||
margin-right: 6px;
|
||||
background: var(--primary-gradient);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover:not(.disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 3px 8px var(--primary-shadow);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
background: #e5e5e5;
|
||||
color: #999;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.agreement-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.agreement-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
line-height: 1.3;
|
||||
white-space: nowrap;
|
||||
|
||||
:global(.adm-checkbox) {
|
||||
margin-top: 0;
|
||||
flex-shrink: 0;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
.agreement-text {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
white-space: nowrap;
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.agreement-link {
|
||||
color: var(--primary-color);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
font-size: 11px;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
height: 46px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
border-radius: 10px;
|
||||
background: var(--primary-gradient);
|
||||
border: none;
|
||||
box-shadow: 0 6px 12px var(--primary-shadow);
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 8px 16px var(--primary-shadow-dark);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: #e5e5e5;
|
||||
color: #999;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.divider {
|
||||
position: relative;
|
||||
text-align: center;
|
||||
margin: 24px 0;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: #e5e5e5;
|
||||
}
|
||||
|
||||
span {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
padding: 0 12px;
|
||||
color: #999;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.third-party-login {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.third-party-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
background: #f8f9fa;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.wechat-icon,
|
||||
.apple-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 18px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.wechat-icon {
|
||||
background: #07c160;
|
||||
box-shadow: 0 3px 8px rgba(7, 193, 96, 0.3);
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 12px rgba(7, 193, 96, 0.4);
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.apple-icon {
|
||||
background: #000;
|
||||
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.3);
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 480px) {
|
||||
.login-container {
|
||||
padding: 24px 20px;
|
||||
margin: 0 12px;
|
||||
}
|
||||
|
||||
.app-name {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.third-party-login {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.third-party-item {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.wechat-icon,
|
||||
.apple-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,160 +1,160 @@
|
||||
import React, { useState } from "react";
|
||||
import { NavBar, Tabs } from "antd-mobile";
|
||||
import { ArrowLeftOutlined } from "@ant-design/icons";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import NavCommon from "@/components/NavCommon";
|
||||
import DeviceSelection from "@/components/DeviceSelection";
|
||||
import FriendSelection from "@/components/FriendSelection";
|
||||
import GroupSelection from "@/components/GroupSelection";
|
||||
import ContentLibrarySelection from "@/components/ContentLibrarySelection";
|
||||
import AccountSelection from "@/components/AccountSelection";
|
||||
|
||||
const ComponentTest: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [activeTab, setActiveTab] = useState("devices");
|
||||
|
||||
// 设备选择状态
|
||||
const [selectedDevices, setSelectedDevices] = useState<string[]>([]);
|
||||
|
||||
// 好友选择状态
|
||||
const [selectedFriends, setSelectedFriends] = useState<string[]>([]);
|
||||
|
||||
// 群组选择状态
|
||||
const [selectedGroups, setSelectedGroups] = useState<string[]>([]);
|
||||
|
||||
// 内容库选择状态
|
||||
const [selectedLibraries, setSelectedLibraries] = useState<string[]>([]);
|
||||
|
||||
const [selectedAccounts, setSelectedAccounts] = useState<string[]>([]);
|
||||
|
||||
return (
|
||||
<Layout header={<NavCommon title="组件调试" />}>
|
||||
<div style={{ padding: 16 }}>
|
||||
<Tabs activeKey={activeTab} onChange={setActiveTab}>
|
||||
<Tabs.Tab title="设备选择" key="devices">
|
||||
<div style={{ padding: "16px 0" }}>
|
||||
<h3 style={{ marginBottom: 16 }}>DeviceSelection 组件测试</h3>
|
||||
<DeviceSelection
|
||||
selectedDevices={selectedDevices}
|
||||
onSelect={setSelectedDevices}
|
||||
placeholder="请选择设备"
|
||||
showSelectedList={true}
|
||||
selectedListMaxHeight={300}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 16,
|
||||
padding: 12,
|
||||
background: "#f5f5f5",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<strong>已选设备:</strong> {selectedDevices.length} 个
|
||||
<br />
|
||||
<strong>设备ID:</strong> {selectedDevices.join(", ") || "无"}
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Tab>
|
||||
|
||||
<Tabs.Tab title="好友选择" key="friends">
|
||||
<div style={{ padding: "16px 0" }}>
|
||||
<h3 style={{ marginBottom: 16 }}>FriendSelection 组件测试</h3>
|
||||
<FriendSelection
|
||||
selectedFriends={selectedFriends}
|
||||
onSelect={setSelectedFriends}
|
||||
placeholder="请选择微信好友"
|
||||
showSelectedList={true}
|
||||
selectedListMaxHeight={300}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 16,
|
||||
padding: 12,
|
||||
background: "#f5f5f5",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<strong>已选好友:</strong> {selectedFriends.length} 个
|
||||
<br />
|
||||
<strong>好友ID:</strong> {selectedFriends.join(", ") || "无"}
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Tab>
|
||||
|
||||
<Tabs.Tab title="群组选择" key="groups">
|
||||
<div style={{ padding: "16px 0" }}>
|
||||
<h3 style={{ marginBottom: 16 }}>GroupSelection 组件测试</h3>
|
||||
<GroupSelection
|
||||
selectedGroups={selectedGroups}
|
||||
onSelect={setSelectedGroups}
|
||||
placeholder="请选择微信群组"
|
||||
showSelectedList={true}
|
||||
selectedListMaxHeight={300}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 16,
|
||||
padding: 12,
|
||||
background: "#f5f5f5",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<strong>已选群组:</strong> {selectedGroups.length} 个
|
||||
<br />
|
||||
<strong>群组ID:</strong> {selectedGroups.join(", ") || "无"}
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Tab>
|
||||
|
||||
<Tabs.Tab title="内容库选择" key="libraries">
|
||||
<div style={{ padding: "16px 0" }}>
|
||||
<h3 style={{ marginBottom: 16 }}>
|
||||
ContentLibrarySelection 组件测试
|
||||
</h3>
|
||||
<ContentLibrarySelection
|
||||
selectedLibraries={selectedLibraries}
|
||||
onSelect={setSelectedLibraries}
|
||||
placeholder="请选择内容库"
|
||||
showSelectedList={true}
|
||||
selectedListMaxHeight={300}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 16,
|
||||
padding: 12,
|
||||
background: "#f5f5f5",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<strong>已选内容库:</strong> {selectedLibraries.length} 个
|
||||
<br />
|
||||
<strong>内容库ID:</strong>{" "}
|
||||
{selectedLibraries.join(", ") || "无"}
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Tab>
|
||||
|
||||
<Tabs.Tab title="账号选择" key="accounts">
|
||||
<div style={{ padding: "16px 0" }}>
|
||||
<h3 style={{ marginBottom: 16 }}>AccountSelection 组件测试</h3>
|
||||
<AccountSelection
|
||||
value={selectedAccounts}
|
||||
onChange={setSelectedAccounts}
|
||||
// 可根据实际API和props补充其它参数
|
||||
/>
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<strong>已选账号:</strong>
|
||||
{selectedAccounts.length > 0
|
||||
? selectedAccounts.join(", ")
|
||||
: "无"}
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Tab>
|
||||
</Tabs>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default ComponentTest;
|
||||
import React, { useState } from "react";
|
||||
import { NavBar, Tabs } from "antd-mobile";
|
||||
import { ArrowLeftOutlined } from "@ant-design/icons";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import NavCommon from "@/components/NavCommon";
|
||||
import DeviceSelection from "@/components/DeviceSelection";
|
||||
import FriendSelection from "@/components/FriendSelection";
|
||||
import GroupSelection from "@/components/GroupSelection";
|
||||
import ContentLibrarySelection from "@/components/ContentLibrarySelection";
|
||||
import AccountSelection from "@/components/AccountSelection";
|
||||
|
||||
const ComponentTest: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [activeTab, setActiveTab] = useState("devices");
|
||||
|
||||
// 设备选择状态
|
||||
const [selectedDevices, setSelectedDevices] = useState<string[]>([]);
|
||||
|
||||
// 好友选择状态
|
||||
const [selectedFriends, setSelectedFriends] = useState<string[]>([]);
|
||||
|
||||
// 群组选择状态
|
||||
const [selectedGroups, setSelectedGroups] = useState<string[]>([]);
|
||||
|
||||
// 内容库选择状态
|
||||
const [selectedLibraries, setSelectedLibraries] = useState<string[]>([]);
|
||||
|
||||
const [selectedAccounts, setSelectedAccounts] = useState<string[]>([]);
|
||||
|
||||
return (
|
||||
<Layout header={<NavCommon title="组件调试" />}>
|
||||
<div style={{ padding: 16 }}>
|
||||
<Tabs activeKey={activeTab} onChange={setActiveTab}>
|
||||
<Tabs.Tab title="设备选择" key="devices">
|
||||
<div style={{ padding: "16px 0" }}>
|
||||
<h3 style={{ marginBottom: 16 }}>DeviceSelection 组件测试</h3>
|
||||
<DeviceSelection
|
||||
selectedDevices={selectedDevices}
|
||||
onSelect={setSelectedDevices}
|
||||
placeholder="请选择设备"
|
||||
showSelectedList={true}
|
||||
selectedListMaxHeight={300}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 16,
|
||||
padding: 12,
|
||||
background: "#f5f5f5",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<strong>已选设备:</strong> {selectedDevices.length} 个
|
||||
<br />
|
||||
<strong>设备ID:</strong> {selectedDevices.join(", ") || "无"}
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Tab>
|
||||
|
||||
<Tabs.Tab title="好友选择" key="friends">
|
||||
<div style={{ padding: "16px 0" }}>
|
||||
<h3 style={{ marginBottom: 16 }}>FriendSelection 组件测试</h3>
|
||||
<FriendSelection
|
||||
selectedFriends={selectedFriends}
|
||||
onSelect={setSelectedFriends}
|
||||
placeholder="请选择微信好友"
|
||||
showSelectedList={true}
|
||||
selectedListMaxHeight={300}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 16,
|
||||
padding: 12,
|
||||
background: "#f5f5f5",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<strong>已选好友:</strong> {selectedFriends.length} 个
|
||||
<br />
|
||||
<strong>好友ID:</strong> {selectedFriends.join(", ") || "无"}
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Tab>
|
||||
|
||||
<Tabs.Tab title="群组选择" key="groups">
|
||||
<div style={{ padding: "16px 0" }}>
|
||||
<h3 style={{ marginBottom: 16 }}>GroupSelection 组件测试</h3>
|
||||
<GroupSelection
|
||||
selectedGroups={selectedGroups}
|
||||
onSelect={setSelectedGroups}
|
||||
placeholder="请选择微信群组"
|
||||
showSelectedList={true}
|
||||
selectedListMaxHeight={300}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 16,
|
||||
padding: 12,
|
||||
background: "#f5f5f5",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<strong>已选群组:</strong> {selectedGroups.length} 个
|
||||
<br />
|
||||
<strong>群组ID:</strong> {selectedGroups.join(", ") || "无"}
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Tab>
|
||||
|
||||
<Tabs.Tab title="内容库选择" key="libraries">
|
||||
<div style={{ padding: "16px 0" }}>
|
||||
<h3 style={{ marginBottom: 16 }}>
|
||||
ContentLibrarySelection 组件测试
|
||||
</h3>
|
||||
<ContentLibrarySelection
|
||||
selectedLibraries={selectedLibraries}
|
||||
onSelect={setSelectedLibraries}
|
||||
placeholder="请选择内容库"
|
||||
showSelectedList={true}
|
||||
selectedListMaxHeight={300}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 16,
|
||||
padding: 12,
|
||||
background: "#f5f5f5",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<strong>已选内容库:</strong> {selectedLibraries.length} 个
|
||||
<br />
|
||||
<strong>内容库ID:</strong>{" "}
|
||||
{selectedLibraries.join(", ") || "无"}
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Tab>
|
||||
|
||||
<Tabs.Tab title="账号选择" key="accounts">
|
||||
<div style={{ padding: "16px 0" }}>
|
||||
<h3 style={{ marginBottom: 16 }}>AccountSelection 组件测试</h3>
|
||||
<AccountSelection
|
||||
value={selectedAccounts}
|
||||
onChange={setSelectedAccounts}
|
||||
// 可根据实际API和props补充其它参数
|
||||
/>
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<strong>已选账号:</strong>
|
||||
{selectedAccounts.length > 0
|
||||
? selectedAccounts.join(", ")
|
||||
: "无"}
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Tab>
|
||||
</Tabs>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default ComponentTest;
|
||||
|
||||
@@ -1,61 +1,61 @@
|
||||
// 内容库表单数据类型定义
|
||||
export interface ContentLibrary {
|
||||
id: string;
|
||||
name: string;
|
||||
sourceType: number; // 1=微信好友, 2=聊天群
|
||||
creatorName?: string;
|
||||
updateTime: string;
|
||||
status: number; // 0=未启用, 1=已启用
|
||||
itemCount?: number;
|
||||
createTime: string;
|
||||
sourceFriends?: string[];
|
||||
sourceGroups?: string[];
|
||||
keywordInclude?: string[];
|
||||
keywordExclude?: string[];
|
||||
aiPrompt?: string;
|
||||
timeEnabled?: number;
|
||||
timeStart?: string;
|
||||
timeEnd?: string;
|
||||
selectedFriends?: any[];
|
||||
selectedGroups?: any[];
|
||||
selectedGroupMembers?: WechatGroupMember[];
|
||||
}
|
||||
|
||||
// 微信群成员
|
||||
export interface WechatGroupMember {
|
||||
id: string;
|
||||
nickname: string;
|
||||
wechatId: string;
|
||||
avatar: string;
|
||||
gender?: "male" | "female";
|
||||
role?: "owner" | "admin" | "member";
|
||||
joinTime?: string;
|
||||
}
|
||||
|
||||
// API 响应类型
|
||||
export interface ApiResponse<T = any> {
|
||||
code: number;
|
||||
msg: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
// 创建内容库参数
|
||||
export interface CreateContentLibraryParams {
|
||||
name: string;
|
||||
sourceType: number;
|
||||
sourceFriends?: string[];
|
||||
sourceGroups?: string[];
|
||||
keywordInclude?: string[];
|
||||
keywordExclude?: string[];
|
||||
aiPrompt?: string;
|
||||
timeEnabled?: number;
|
||||
timeStart?: string;
|
||||
timeEnd?: string;
|
||||
}
|
||||
|
||||
// 更新内容库参数
|
||||
export interface UpdateContentLibraryParams
|
||||
extends Partial<CreateContentLibraryParams> {
|
||||
id: string;
|
||||
status?: number;
|
||||
}
|
||||
// 内容库表单数据类型定义
|
||||
export interface ContentLibrary {
|
||||
id: string;
|
||||
name: string;
|
||||
sourceType: number; // 1=微信好友, 2=聊天群
|
||||
creatorName?: string;
|
||||
updateTime: string;
|
||||
status: number; // 0=未启用, 1=已启用
|
||||
itemCount?: number;
|
||||
createTime: string;
|
||||
sourceFriends?: string[];
|
||||
sourceGroups?: string[];
|
||||
keywordInclude?: string[];
|
||||
keywordExclude?: string[];
|
||||
aiPrompt?: string;
|
||||
timeEnabled?: number;
|
||||
timeStart?: string;
|
||||
timeEnd?: string;
|
||||
selectedFriends?: any[];
|
||||
selectedGroups?: any[];
|
||||
selectedGroupMembers?: WechatGroupMember[];
|
||||
}
|
||||
|
||||
// 微信群成员
|
||||
export interface WechatGroupMember {
|
||||
id: string;
|
||||
nickname: string;
|
||||
wechatId: string;
|
||||
avatar: string;
|
||||
gender?: "male" | "female";
|
||||
role?: "owner" | "admin" | "member";
|
||||
joinTime?: string;
|
||||
}
|
||||
|
||||
// API 响应类型
|
||||
export interface ApiResponse<T = any> {
|
||||
code: number;
|
||||
msg: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
// 创建内容库参数
|
||||
export interface CreateContentLibraryParams {
|
||||
name: string;
|
||||
sourceType: number;
|
||||
sourceFriends?: string[];
|
||||
sourceGroups?: string[];
|
||||
keywordInclude?: string[];
|
||||
keywordExclude?: string[];
|
||||
aiPrompt?: string;
|
||||
timeEnabled?: number;
|
||||
timeStart?: string;
|
||||
timeEnd?: string;
|
||||
}
|
||||
|
||||
// 更新内容库参数
|
||||
export interface UpdateContentLibraryParams
|
||||
extends Partial<CreateContentLibraryParams> {
|
||||
id: string;
|
||||
status?: number;
|
||||
}
|
||||
|
||||
@@ -1,138 +1,140 @@
|
||||
.form-page {
|
||||
background: #f7f8fa;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.form-main {
|
||||
max-width: 420px;
|
||||
margin: 0 auto;
|
||||
padding: 16px 0 0 0;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.form-card {
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.06);
|
||||
padding: 24px 18px 18px 18px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
color: #222;
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #222;
|
||||
margin-top: 28px;
|
||||
margin-bottom: 12px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.section-block {
|
||||
padding: 12px 0 8px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.tabs-bar {
|
||||
.adm-tabs-header {
|
||||
background: #f7f8fa;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.adm-tabs-tab {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
padding: 8px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.collapse {
|
||||
margin-top: 12px;
|
||||
.adm-collapse-panel-content {
|
||||
padding-bottom: 8px;
|
||||
background: #f8fafc;
|
||||
border-radius: 10px;
|
||||
padding: 18px 14px 10px 14px;
|
||||
margin-top: 2px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.03);
|
||||
}
|
||||
.form-section {
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
.form-label {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
color: #333;
|
||||
}
|
||||
.adm-input {
|
||||
min-height: 42px;
|
||||
font-size: 15px;
|
||||
border-radius: 7px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.ai-row, .section-block {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.ai-desc {
|
||||
color: #888;
|
||||
font-size: 13px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.date-row, .section-block {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.adm-input {
|
||||
min-height: 44px;
|
||||
font-size: 15px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
margin-top: 32px;
|
||||
height: 48px !important;
|
||||
border-radius: 10px !important;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.form-main {
|
||||
max-width: 100vw;
|
||||
padding: 0;
|
||||
}
|
||||
.form-card {
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
padding: 16px 6px 12px 6px;
|
||||
}
|
||||
.section-title {
|
||||
font-size: 15px;
|
||||
margin-top: 22px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.submit-btn {
|
||||
height: 44px !important;
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
.form-page {
|
||||
background: #f7f8fa;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.form-main {
|
||||
max-width: 420px;
|
||||
margin: 0 auto;
|
||||
padding: 16px 0 0 0;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.form-card {
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.06);
|
||||
padding: 24px 18px 18px 18px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
color: #222;
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #222;
|
||||
margin-top: 28px;
|
||||
margin-bottom: 12px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.section-block {
|
||||
padding: 12px 0 8px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.tabs-bar {
|
||||
.adm-tabs-header {
|
||||
background: #f7f8fa;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.adm-tabs-tab {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
padding: 8px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.collapse {
|
||||
margin-top: 12px;
|
||||
.adm-collapse-panel-content {
|
||||
padding-bottom: 8px;
|
||||
background: #f8fafc;
|
||||
border-radius: 10px;
|
||||
padding: 18px 14px 10px 14px;
|
||||
margin-top: 2px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
.form-section {
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
.form-label {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
color: #333;
|
||||
}
|
||||
.adm-input {
|
||||
min-height: 42px;
|
||||
font-size: 15px;
|
||||
border-radius: 7px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.ai-row,
|
||||
.section-block {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.ai-desc {
|
||||
color: #888;
|
||||
font-size: 13px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.date-row,
|
||||
.section-block {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.adm-input {
|
||||
min-height: 44px;
|
||||
font-size: 15px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
margin-top: 32px;
|
||||
height: 48px !important;
|
||||
border-radius: 10px !important;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.form-main {
|
||||
max-width: 100vw;
|
||||
padding: 0;
|
||||
}
|
||||
.form-card {
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
padding: 16px 6px 12px 6px;
|
||||
}
|
||||
.section-title {
|
||||
font-size: 15px;
|
||||
margin-top: 22px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.submit-btn {
|
||||
height: 44px !important;
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ export default function ContentForm() {
|
||||
if (isEdit && id) {
|
||||
setLoading(true);
|
||||
getContentLibraryDetail(id)
|
||||
.then((data) => {
|
||||
.then(data => {
|
||||
setName(data.name || "");
|
||||
setSourceType(data.sourceType === 1 ? "friends" : "groups");
|
||||
setSelectedFriends(data.sourceFriends || []);
|
||||
@@ -66,7 +66,7 @@ export default function ContentForm() {
|
||||
end ? new Date(end) : null,
|
||||
]);
|
||||
})
|
||||
.catch((e) => {
|
||||
.catch(e => {
|
||||
Toast.show({
|
||||
content: e?.message || "获取详情失败",
|
||||
position: "top",
|
||||
@@ -92,11 +92,11 @@ export default function ContentForm() {
|
||||
groupMembers: {},
|
||||
keywordInclude: keywordsInclude
|
||||
.split(/,|,|\n|\s+/)
|
||||
.map((s) => s.trim())
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean),
|
||||
keywordExclude: keywordsExclude
|
||||
.split(/,|,|\n|\s+/)
|
||||
.map((s) => s.trim())
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean),
|
||||
aiPrompt,
|
||||
timeEnabled: dateRange[0] || dateRange[1] ? 1 : 0,
|
||||
@@ -148,7 +148,7 @@ export default function ContentForm() {
|
||||
<div className={style["form-page"]}>
|
||||
<form
|
||||
className={style["form-main"]}
|
||||
onSubmit={(e) => e.preventDefault()}
|
||||
onSubmit={e => e.preventDefault()}
|
||||
autoComplete="off"
|
||||
>
|
||||
<div className={style["form-section"]}>
|
||||
@@ -159,7 +159,7 @@ export default function ContentForm() {
|
||||
<AntdInput
|
||||
placeholder="请输入内容库名称"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onChange={e => setName(e.target.value)}
|
||||
className={style["input"]}
|
||||
/>
|
||||
</div>
|
||||
@@ -168,7 +168,7 @@ export default function ContentForm() {
|
||||
<div className={style["form-section"]}>
|
||||
<Tabs
|
||||
activeKey={sourceType}
|
||||
onChange={(key) => setSourceType(key as "friends" | "groups")}
|
||||
onChange={key => setSourceType(key as "friends" | "groups")}
|
||||
className={style["tabs-bar"]}
|
||||
>
|
||||
<Tabs.Tab title="选择微信好友" key="friends">
|
||||
@@ -201,7 +201,7 @@ export default function ContentForm() {
|
||||
<TextArea
|
||||
placeholder="多个关键词用逗号分隔"
|
||||
value={keywordsInclude}
|
||||
onChange={(e) => setKeywordsInclude(e.target.value)}
|
||||
onChange={e => setKeywordsInclude(e.target.value)}
|
||||
className={style["input"]}
|
||||
autoSize={{ minRows: 2, maxRows: 4 }}
|
||||
/>
|
||||
@@ -211,7 +211,7 @@ export default function ContentForm() {
|
||||
<TextArea
|
||||
placeholder="多个关键词用逗号分隔"
|
||||
value={keywordsExclude}
|
||||
onChange={(e) => setKeywordsExclude(e.target.value)}
|
||||
onChange={e => setKeywordsExclude(e.target.value)}
|
||||
className={style["input"]}
|
||||
autoSize={{ minRows: 2, maxRows: 4 }}
|
||||
/>
|
||||
@@ -235,7 +235,7 @@ export default function ContentForm() {
|
||||
<AntdInput
|
||||
placeholder="请输入AI提示词"
|
||||
value={aiPrompt}
|
||||
onChange={(e) => setAIPrompt(e.target.value)}
|
||||
onChange={e => setAIPrompt(e.target.value)}
|
||||
className={style["input"]}
|
||||
/>
|
||||
</div>
|
||||
@@ -260,7 +260,7 @@ export default function ContentForm() {
|
||||
title="开始时间"
|
||||
value={dateRange[0]}
|
||||
onClose={() => setShowStartPicker(false)}
|
||||
onConfirm={(val) => {
|
||||
onConfirm={val => {
|
||||
setDateRange([val, dateRange[1]]);
|
||||
setShowStartPicker(false);
|
||||
}}
|
||||
@@ -280,7 +280,7 @@ export default function ContentForm() {
|
||||
title="结束时间"
|
||||
value={dateRange[1]}
|
||||
onClose={() => setShowEndPicker(false)}
|
||||
onConfirm={(val) => {
|
||||
onConfirm={val => {
|
||||
setDateRange([dateRange[0], val]);
|
||||
setShowEndPicker(false);
|
||||
}}
|
||||
|
||||
@@ -1,66 +1,66 @@
|
||||
// 内容库接口类型定义
|
||||
export interface ContentLibrary {
|
||||
id: string;
|
||||
name: string;
|
||||
sourceType: number; // 1=微信好友, 2=聊天群
|
||||
creatorName?: string;
|
||||
updateTime: string;
|
||||
status: number; // 0=未启用, 1=已启用
|
||||
itemCount?: number;
|
||||
createTime: string;
|
||||
sourceFriends?: string[];
|
||||
sourceGroups?: string[];
|
||||
keywordInclude?: string[];
|
||||
keywordExclude?: string[];
|
||||
aiPrompt?: string;
|
||||
timeEnabled?: number;
|
||||
timeStart?: string;
|
||||
timeEnd?: string;
|
||||
selectedFriends?: any[];
|
||||
selectedGroups?: any[];
|
||||
selectedGroupMembers?: WechatGroupMember[];
|
||||
}
|
||||
|
||||
// 微信群成员
|
||||
export interface WechatGroupMember {
|
||||
id: string;
|
||||
nickname: string;
|
||||
wechatId: string;
|
||||
avatar: string;
|
||||
gender?: "male" | "female";
|
||||
role?: "owner" | "admin" | "member";
|
||||
joinTime?: string;
|
||||
}
|
||||
|
||||
// API 响应类型
|
||||
export interface ApiResponse<T = any> {
|
||||
code: number;
|
||||
msg: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
export interface LibraryListResponse {
|
||||
list: ContentLibrary[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
// 创建内容库参数
|
||||
export interface CreateContentLibraryParams {
|
||||
name: string;
|
||||
sourceType: number;
|
||||
sourceFriends?: string[];
|
||||
sourceGroups?: string[];
|
||||
keywordInclude?: string[];
|
||||
keywordExclude?: string[];
|
||||
aiPrompt?: string;
|
||||
timeEnabled?: number;
|
||||
timeStart?: string;
|
||||
timeEnd?: string;
|
||||
}
|
||||
|
||||
// 更新内容库参数
|
||||
export interface UpdateContentLibraryParams
|
||||
extends Partial<CreateContentLibraryParams> {
|
||||
id: string;
|
||||
status?: number;
|
||||
}
|
||||
// 内容库接口类型定义
|
||||
export interface ContentLibrary {
|
||||
id: string;
|
||||
name: string;
|
||||
sourceType: number; // 1=微信好友, 2=聊天群
|
||||
creatorName?: string;
|
||||
updateTime: string;
|
||||
status: number; // 0=未启用, 1=已启用
|
||||
itemCount?: number;
|
||||
createTime: string;
|
||||
sourceFriends?: string[];
|
||||
sourceGroups?: string[];
|
||||
keywordInclude?: string[];
|
||||
keywordExclude?: string[];
|
||||
aiPrompt?: string;
|
||||
timeEnabled?: number;
|
||||
timeStart?: string;
|
||||
timeEnd?: string;
|
||||
selectedFriends?: any[];
|
||||
selectedGroups?: any[];
|
||||
selectedGroupMembers?: WechatGroupMember[];
|
||||
}
|
||||
|
||||
// 微信群成员
|
||||
export interface WechatGroupMember {
|
||||
id: string;
|
||||
nickname: string;
|
||||
wechatId: string;
|
||||
avatar: string;
|
||||
gender?: "male" | "female";
|
||||
role?: "owner" | "admin" | "member";
|
||||
joinTime?: string;
|
||||
}
|
||||
|
||||
// API 响应类型
|
||||
export interface ApiResponse<T = any> {
|
||||
code: number;
|
||||
msg: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
export interface LibraryListResponse {
|
||||
list: ContentLibrary[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
// 创建内容库参数
|
||||
export interface CreateContentLibraryParams {
|
||||
name: string;
|
||||
sourceType: number;
|
||||
sourceFriends?: string[];
|
||||
sourceGroups?: string[];
|
||||
keywordInclude?: string[];
|
||||
keywordExclude?: string[];
|
||||
aiPrompt?: string;
|
||||
timeEnabled?: number;
|
||||
timeStart?: string;
|
||||
timeEnd?: string;
|
||||
}
|
||||
|
||||
// 更新内容库参数
|
||||
export interface UpdateContentLibraryParams
|
||||
extends Partial<CreateContentLibraryParams> {
|
||||
id: string;
|
||||
status?: number;
|
||||
}
|
||||
|
||||
@@ -1,223 +1,217 @@
|
||||
.content-library-page {
|
||||
padding: 16px;
|
||||
background: #f5f5f5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
background: white;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.search-input-wrapper {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #999;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
padding-left: 36px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid #e0e0e0;
|
||||
|
||||
&:focus {
|
||||
border-color: #1677ff;
|
||||
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
.create-btn {
|
||||
border-radius: 20px;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.spinning {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
.tabs {
|
||||
flex:1;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.library-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
text-align: center;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
color: #999;
|
||||
margin-bottom: 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.empty-btn {
|
||||
border-radius: 20px;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.library-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
border: none;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.library-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.library-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.status-tag {
|
||||
font-size: 12px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.menu-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 4px;
|
||||
cursor: pointer;
|
||||
color: #999;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background: #f5f5f5;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-dropdown {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 100%;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
z-index: 1000;
|
||||
min-width: 120px;
|
||||
padding: 4px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
&.danger {
|
||||
color: #ff4d4f;
|
||||
|
||||
&:hover {
|
||||
background: #fff2f0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.card-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: #999;
|
||||
min-width: 70px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.value {
|
||||
color: #333;
|
||||
flex: 1;
|
||||
}
|
||||
.content-library-page {
|
||||
padding: 16px;
|
||||
background: #f5f5f5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
background: white;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.search-input-wrapper {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #999;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
padding-left: 36px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid #e0e0e0;
|
||||
|
||||
&:focus {
|
||||
border-color: #1677ff;
|
||||
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.create-btn {
|
||||
border-radius: 20px;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.spinning {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.tabs {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.library-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
text-align: center;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
color: #999;
|
||||
margin-bottom: 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.empty-btn {
|
||||
border-radius: 20px;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.library-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
border: none;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.library-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.library-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.status-tag {
|
||||
font-size: 12px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.menu-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 4px;
|
||||
cursor: pointer;
|
||||
color: #999;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background: #f5f5f5;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-dropdown {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 100%;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
z-index: 1000;
|
||||
min-width: 120px;
|
||||
padding: 4px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
&.danger {
|
||||
color: #ff4d4f;
|
||||
|
||||
&:hover {
|
||||
background: #fff2f0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.card-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: #999;
|
||||
min-width: 70px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.value {
|
||||
color: #333;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@@ -1,317 +1,317 @@
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Button,
|
||||
Toast,
|
||||
SpinLoading,
|
||||
Dialog,
|
||||
Card,
|
||||
Avatar,
|
||||
Tag,
|
||||
} from "antd-mobile";
|
||||
import { Input } from "antd";
|
||||
import {
|
||||
PlusOutlined,
|
||||
SearchOutlined,
|
||||
ReloadOutlined,
|
||||
EyeOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
MoreOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import NavCommon from "@/components/NavCommon";
|
||||
import { getContentLibraryList, deleteContentLibrary } from "./api";
|
||||
import { ContentLibrary } from "./data";
|
||||
import style from "./index.module.scss";
|
||||
import { Tabs } from "antd-mobile";
|
||||
|
||||
// 卡片菜单组件
|
||||
interface CardMenuProps {
|
||||
onView: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onViewMaterials: () => void;
|
||||
}
|
||||
|
||||
const CardMenu: React.FC<CardMenuProps> = ({
|
||||
onView,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onViewMaterials,
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const menuRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
if (open) document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative" }}>
|
||||
<button onClick={() => setOpen((v) => !v)} className={style["menu-btn"]}>
|
||||
<MoreOutlined />
|
||||
</button>
|
||||
{open && (
|
||||
<div ref={menuRef} className={style["menu-dropdown"]}>
|
||||
<div
|
||||
onClick={() => {
|
||||
onEdit();
|
||||
setOpen(false);
|
||||
}}
|
||||
className={style["menu-item"]}
|
||||
>
|
||||
<EditOutlined />
|
||||
编辑
|
||||
</div>
|
||||
<div
|
||||
onClick={() => {
|
||||
onDelete();
|
||||
setOpen(false);
|
||||
}}
|
||||
className={`${style["menu-item"]} ${style["danger"]}`}
|
||||
>
|
||||
<DeleteOutlined />
|
||||
删除
|
||||
</div>
|
||||
<div
|
||||
onClick={() => {
|
||||
onViewMaterials();
|
||||
setOpen(false);
|
||||
}}
|
||||
className={style["menu-item"]}
|
||||
>
|
||||
<EyeOutlined />
|
||||
查看素材
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ContentLibraryList: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [libraries, setLibraries] = useState<ContentLibrary[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [activeTab, setActiveTab] = useState("all");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 获取内容库列表
|
||||
const fetchLibraries = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getContentLibraryList({
|
||||
page: 1,
|
||||
limit: 100,
|
||||
keyword: searchQuery,
|
||||
sourceType:
|
||||
activeTab !== "all" ? (activeTab === "friends" ? 1 : 2) : undefined,
|
||||
});
|
||||
|
||||
setLibraries(response.list || []);
|
||||
} catch (error: any) {
|
||||
console.error("获取内容库列表失败:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [searchQuery, activeTab]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchLibraries();
|
||||
}, [fetchLibraries]);
|
||||
|
||||
const handleCreateNew = () => {
|
||||
navigate("/content/new");
|
||||
};
|
||||
|
||||
const handleEdit = (id: string) => {
|
||||
navigate(`/content/edit/${id}`);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
const result = await Dialog.confirm({
|
||||
content: "确定要删除这个内容库吗?",
|
||||
confirmText: "删除",
|
||||
cancelText: "取消",
|
||||
});
|
||||
|
||||
if (result) {
|
||||
try {
|
||||
const response = await deleteContentLibrary(id);
|
||||
if (response.code === 200) {
|
||||
Toast.show({
|
||||
content: "删除成功",
|
||||
position: "top",
|
||||
});
|
||||
fetchLibraries();
|
||||
} else {
|
||||
Toast.show({
|
||||
content: response.msg || "删除失败",
|
||||
position: "top",
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("删除内容库失败:", error);
|
||||
Toast.show({
|
||||
content: error?.message || "请检查网络连接",
|
||||
position: "top",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewMaterials = (id: string) => {
|
||||
navigate(`/content/materials/${id}`);
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
fetchLibraries();
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
fetchLibraries();
|
||||
};
|
||||
|
||||
const filteredLibraries = libraries.filter(
|
||||
(library) =>
|
||||
library.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
library.creatorName?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<>
|
||||
<NavCommon
|
||||
title="内容库"
|
||||
right={
|
||||
<Button size="small" color="primary" onClick={handleCreateNew}>
|
||||
<PlusOutlined /> 新建内容库
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 搜索栏 */}
|
||||
<div className="search-bar">
|
||||
<div className="search-input-wrapper">
|
||||
<Input
|
||||
placeholder="搜索内容库"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
prefix={<SearchOutlined />}
|
||||
allowClear
|
||||
size="large"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={handleRefresh}
|
||||
loading={loading}
|
||||
className="refresh-btn"
|
||||
>
|
||||
<ReloadOutlined />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 标签页 */}
|
||||
<div className={style["tabs"]}>
|
||||
<Tabs activeKey={activeTab} onChange={setActiveTab}>
|
||||
<Tabs.Tab title="全部" key="all" />
|
||||
<Tabs.Tab title="微信好友" key="friends" />
|
||||
<Tabs.Tab title="聊天群" key="groups" />
|
||||
</Tabs>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className={style["content-library-page"]}>
|
||||
{/* 内容库列表 */}
|
||||
<div className={style["library-list"]}>
|
||||
{loading ? (
|
||||
<div className={style["loading"]}>
|
||||
<SpinLoading color="primary" style={{ fontSize: 32 }} />
|
||||
</div>
|
||||
) : filteredLibraries.length === 0 ? (
|
||||
<div className={style["empty-state"]}>
|
||||
<div className={style["empty-icon"]}>📚</div>
|
||||
<div className={style["empty-text"]}>
|
||||
暂无内容库,快去新建一个吧!
|
||||
</div>
|
||||
<Button
|
||||
color="primary"
|
||||
size="small"
|
||||
onClick={handleCreateNew}
|
||||
className={style["empty-btn"]}
|
||||
>
|
||||
新建内容库
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
filteredLibraries.map((library) => (
|
||||
<Card key={library.id} className={style["library-card"]}>
|
||||
<div className={style["card-header"]}>
|
||||
<div className={style["library-info"]}>
|
||||
<h3 className={style["library-name"]}>{library.name}</h3>
|
||||
<Tag
|
||||
color={library.status === 1 ? "success" : "default"}
|
||||
className={style["status-tag"]}
|
||||
>
|
||||
{library.status === 1 ? "已启用" : "未启用"}
|
||||
</Tag>
|
||||
</div>
|
||||
<CardMenu
|
||||
onView={() => navigate(`/content/${library.id}`)}
|
||||
onEdit={() => handleEdit(library.id)}
|
||||
onDelete={() => handleDelete(library.id)}
|
||||
onViewMaterials={() => handleViewMaterials(library.id)}
|
||||
/>
|
||||
</div>
|
||||
<div className={style["card-content"]}>
|
||||
<div className={style["info-row"]}>
|
||||
<span className={style["label"]}>来源:</span>
|
||||
<span className={style["value"]}>
|
||||
{library.sourceType === 1 ? "微信好友" : "聊天群"}
|
||||
</span>
|
||||
</div>
|
||||
<div className={style["info-row"]}>
|
||||
<span className={style["label"]}>创建人:</span>
|
||||
<span className={style["value"]}>
|
||||
{library.creatorName || "系统"}
|
||||
</span>
|
||||
</div>
|
||||
<div className={style["info-row"]}>
|
||||
<span className={style["label"]}>内容数量:</span>
|
||||
<span className={style["value"]}>
|
||||
{library.itemCount || 0}
|
||||
</span>
|
||||
</div>
|
||||
<div className={style["info-row"]}>
|
||||
<span className={style["label"]}>更新时间:</span>
|
||||
<span className={style["value"]}>
|
||||
{new Date(library.updateTime).toLocaleString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContentLibraryList;
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Button,
|
||||
Toast,
|
||||
SpinLoading,
|
||||
Dialog,
|
||||
Card,
|
||||
Avatar,
|
||||
Tag,
|
||||
} from "antd-mobile";
|
||||
import { Input } from "antd";
|
||||
import {
|
||||
PlusOutlined,
|
||||
SearchOutlined,
|
||||
ReloadOutlined,
|
||||
EyeOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
MoreOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import NavCommon from "@/components/NavCommon";
|
||||
import { getContentLibraryList, deleteContentLibrary } from "./api";
|
||||
import { ContentLibrary } from "./data";
|
||||
import style from "./index.module.scss";
|
||||
import { Tabs } from "antd-mobile";
|
||||
|
||||
// 卡片菜单组件
|
||||
interface CardMenuProps {
|
||||
onView: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onViewMaterials: () => void;
|
||||
}
|
||||
|
||||
const CardMenu: React.FC<CardMenuProps> = ({
|
||||
onView,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onViewMaterials,
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const menuRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
if (open) document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative" }}>
|
||||
<button onClick={() => setOpen(v => !v)} className={style["menu-btn"]}>
|
||||
<MoreOutlined />
|
||||
</button>
|
||||
{open && (
|
||||
<div ref={menuRef} className={style["menu-dropdown"]}>
|
||||
<div
|
||||
onClick={() => {
|
||||
onEdit();
|
||||
setOpen(false);
|
||||
}}
|
||||
className={style["menu-item"]}
|
||||
>
|
||||
<EditOutlined />
|
||||
编辑
|
||||
</div>
|
||||
<div
|
||||
onClick={() => {
|
||||
onDelete();
|
||||
setOpen(false);
|
||||
}}
|
||||
className={`${style["menu-item"]} ${style["danger"]}`}
|
||||
>
|
||||
<DeleteOutlined />
|
||||
删除
|
||||
</div>
|
||||
<div
|
||||
onClick={() => {
|
||||
onViewMaterials();
|
||||
setOpen(false);
|
||||
}}
|
||||
className={style["menu-item"]}
|
||||
>
|
||||
<EyeOutlined />
|
||||
查看素材
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ContentLibraryList: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [libraries, setLibraries] = useState<ContentLibrary[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [activeTab, setActiveTab] = useState("all");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 获取内容库列表
|
||||
const fetchLibraries = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getContentLibraryList({
|
||||
page: 1,
|
||||
limit: 100,
|
||||
keyword: searchQuery,
|
||||
sourceType:
|
||||
activeTab !== "all" ? (activeTab === "friends" ? 1 : 2) : undefined,
|
||||
});
|
||||
|
||||
setLibraries(response.list || []);
|
||||
} catch (error: any) {
|
||||
console.error("获取内容库列表失败:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [searchQuery, activeTab]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchLibraries();
|
||||
}, [fetchLibraries]);
|
||||
|
||||
const handleCreateNew = () => {
|
||||
navigate("/content/new");
|
||||
};
|
||||
|
||||
const handleEdit = (id: string) => {
|
||||
navigate(`/content/edit/${id}`);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
const result = await Dialog.confirm({
|
||||
content: "确定要删除这个内容库吗?",
|
||||
confirmText: "删除",
|
||||
cancelText: "取消",
|
||||
});
|
||||
|
||||
if (result) {
|
||||
try {
|
||||
const response = await deleteContentLibrary(id);
|
||||
if (response.code === 200) {
|
||||
Toast.show({
|
||||
content: "删除成功",
|
||||
position: "top",
|
||||
});
|
||||
fetchLibraries();
|
||||
} else {
|
||||
Toast.show({
|
||||
content: response.msg || "删除失败",
|
||||
position: "top",
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("删除内容库失败:", error);
|
||||
Toast.show({
|
||||
content: error?.message || "请检查网络连接",
|
||||
position: "top",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewMaterials = (id: string) => {
|
||||
navigate(`/content/materials/${id}`);
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
fetchLibraries();
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
fetchLibraries();
|
||||
};
|
||||
|
||||
const filteredLibraries = libraries.filter(
|
||||
library =>
|
||||
library.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
library.creatorName?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<>
|
||||
<NavCommon
|
||||
title="内容库"
|
||||
right={
|
||||
<Button size="small" color="primary" onClick={handleCreateNew}>
|
||||
<PlusOutlined /> 新建内容库
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 搜索栏 */}
|
||||
<div className="search-bar">
|
||||
<div className="search-input-wrapper">
|
||||
<Input
|
||||
placeholder="搜索内容库"
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
prefix={<SearchOutlined />}
|
||||
allowClear
|
||||
size="large"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={handleRefresh}
|
||||
loading={loading}
|
||||
className="refresh-btn"
|
||||
>
|
||||
<ReloadOutlined />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 标签页 */}
|
||||
<div className={style["tabs"]}>
|
||||
<Tabs activeKey={activeTab} onChange={setActiveTab}>
|
||||
<Tabs.Tab title="全部" key="all" />
|
||||
<Tabs.Tab title="微信好友" key="friends" />
|
||||
<Tabs.Tab title="聊天群" key="groups" />
|
||||
</Tabs>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className={style["content-library-page"]}>
|
||||
{/* 内容库列表 */}
|
||||
<div className={style["library-list"]}>
|
||||
{loading ? (
|
||||
<div className={style["loading"]}>
|
||||
<SpinLoading color="primary" style={{ fontSize: 32 }} />
|
||||
</div>
|
||||
) : filteredLibraries.length === 0 ? (
|
||||
<div className={style["empty-state"]}>
|
||||
<div className={style["empty-icon"]}>📚</div>
|
||||
<div className={style["empty-text"]}>
|
||||
暂无内容库,快去新建一个吧!
|
||||
</div>
|
||||
<Button
|
||||
color="primary"
|
||||
size="small"
|
||||
onClick={handleCreateNew}
|
||||
className={style["empty-btn"]}
|
||||
>
|
||||
新建内容库
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
filteredLibraries.map(library => (
|
||||
<Card key={library.id} className={style["library-card"]}>
|
||||
<div className={style["card-header"]}>
|
||||
<div className={style["library-info"]}>
|
||||
<h3 className={style["library-name"]}>{library.name}</h3>
|
||||
<Tag
|
||||
color={library.status === 1 ? "success" : "default"}
|
||||
className={style["status-tag"]}
|
||||
>
|
||||
{library.status === 1 ? "已启用" : "未启用"}
|
||||
</Tag>
|
||||
</div>
|
||||
<CardMenu
|
||||
onView={() => navigate(`/content/${library.id}`)}
|
||||
onEdit={() => handleEdit(library.id)}
|
||||
onDelete={() => handleDelete(library.id)}
|
||||
onViewMaterials={() => handleViewMaterials(library.id)}
|
||||
/>
|
||||
</div>
|
||||
<div className={style["card-content"]}>
|
||||
<div className={style["info-row"]}>
|
||||
<span className={style["label"]}>来源:</span>
|
||||
<span className={style["value"]}>
|
||||
{library.sourceType === 1 ? "微信好友" : "聊天群"}
|
||||
</span>
|
||||
</div>
|
||||
<div className={style["info-row"]}>
|
||||
<span className={style["label"]}>创建人:</span>
|
||||
<span className={style["value"]}>
|
||||
{library.creatorName || "系统"}
|
||||
</span>
|
||||
</div>
|
||||
<div className={style["info-row"]}>
|
||||
<span className={style["label"]}>内容数量:</span>
|
||||
<span className={style["value"]}>
|
||||
{library.itemCount || 0}
|
||||
</span>
|
||||
</div>
|
||||
<div className={style["info-row"]}>
|
||||
<span className={style["label"]}>更新时间:</span>
|
||||
<span className={style["value"]}>
|
||||
{new Date(library.updateTime).toLocaleString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContentLibraryList;
|
||||
|
||||
@@ -1,32 +1,20 @@
|
||||
import request from "@/api/request";
|
||||
import {
|
||||
ContentItem,
|
||||
ContentLibrary,
|
||||
CreateContentItemParams,
|
||||
UpdateContentItemParams,
|
||||
} from "./data";
|
||||
|
||||
// 获取素材详情
|
||||
export function getContentItemDetail(id: string): Promise<any> {
|
||||
return request("/v1/content/item/detail", { id }, "GET");
|
||||
export function getContentItemDetail(id: string) {
|
||||
return request("/v1/content/library/get-item-detail", { id }, "GET");
|
||||
}
|
||||
|
||||
// 创建素材
|
||||
export function createContentItem(
|
||||
params: CreateContentItemParams
|
||||
): Promise<any> {
|
||||
return request("/v1/content/item/create", params, "POST");
|
||||
export function createContentItem(params: any) {
|
||||
return request("/v1/content/library/create-item", params, "POST");
|
||||
}
|
||||
|
||||
// 更新素材
|
||||
export function updateContentItem(
|
||||
params: UpdateContentItemParams
|
||||
): Promise<any> {
|
||||
const { id, ...data } = params;
|
||||
return request(`/v1/content/item/update`, { id, ...data }, "POST");
|
||||
export function updateContentItem(params: any) {
|
||||
return request(`/v1/content/library/update-item`, params, "POST");
|
||||
}
|
||||
|
||||
// 获取内容库详情
|
||||
export function getContentLibraryDetail(id: string): Promise<any> {
|
||||
export function getContentLibraryDetail(id: string) {
|
||||
return request("/v1/content/library/detail", { id }, "GET");
|
||||
}
|
||||
|
||||
@@ -1,85 +1,85 @@
|
||||
// 素材数据类型定义
|
||||
export interface ContentItem {
|
||||
id: string;
|
||||
libraryId: string;
|
||||
title: string;
|
||||
content: string;
|
||||
contentType: number; // 0=未知, 1=图片, 2=链接, 3=视频, 4=文本, 5=小程序, 6=图文
|
||||
contentTypeName?: string;
|
||||
resUrls?: string[];
|
||||
urls?: string[];
|
||||
comment?: string;
|
||||
sendTime?: string;
|
||||
createTime: string;
|
||||
updateTime: string;
|
||||
wechatId?: string;
|
||||
wechatNickname?: string;
|
||||
wechatAvatar?: string;
|
||||
snsId?: string;
|
||||
msgId?: string;
|
||||
type?: string;
|
||||
contentData?: string;
|
||||
createMomentTime?: string;
|
||||
createMessageTime?: string;
|
||||
createMomentTimeFormatted?: string;
|
||||
createMessageTimeFormatted?: string;
|
||||
}
|
||||
|
||||
// 内容库类型
|
||||
export interface ContentLibrary {
|
||||
id: string;
|
||||
name: string;
|
||||
sourceType: number; // 1=微信好友, 2=聊天群
|
||||
creatorName?: string;
|
||||
updateTime: string;
|
||||
status: number; // 0=未启用, 1=已启用
|
||||
itemCount?: number;
|
||||
createTime: string;
|
||||
sourceFriends?: string[];
|
||||
sourceGroups?: string[];
|
||||
keywordInclude?: string[];
|
||||
keywordExclude?: string[];
|
||||
aiPrompt?: string;
|
||||
timeEnabled?: number;
|
||||
timeStart?: string;
|
||||
timeEnd?: string;
|
||||
selectedFriends?: any[];
|
||||
selectedGroups?: any[];
|
||||
selectedGroupMembers?: WechatGroupMember[];
|
||||
}
|
||||
|
||||
// 微信群成员
|
||||
export interface WechatGroupMember {
|
||||
id: string;
|
||||
nickname: string;
|
||||
wechatId: string;
|
||||
avatar: string;
|
||||
gender?: "male" | "female";
|
||||
role?: "owner" | "admin" | "member";
|
||||
joinTime?: string;
|
||||
}
|
||||
|
||||
// API 响应类型
|
||||
export interface ApiResponse<T = any> {
|
||||
code: number;
|
||||
msg: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
// 创建素材参数
|
||||
export interface CreateContentItemParams {
|
||||
libraryId: string;
|
||||
title: string;
|
||||
content: string;
|
||||
contentType: number;
|
||||
resUrls?: string[];
|
||||
urls?: string[];
|
||||
comment?: string;
|
||||
sendTime?: string;
|
||||
}
|
||||
|
||||
// 更新素材参数
|
||||
export interface UpdateContentItemParams
|
||||
extends Partial<CreateContentItemParams> {
|
||||
id: string;
|
||||
}
|
||||
// 素材数据类型定义
|
||||
export interface ContentItem {
|
||||
id: string;
|
||||
libraryId: string;
|
||||
title: string;
|
||||
content: string;
|
||||
contentType: number; // 0=未知, 1=图片, 2=链接, 3=视频, 4=文本, 5=小程序, 6=图文
|
||||
contentTypeName?: string;
|
||||
resUrls?: string[];
|
||||
urls?: string[];
|
||||
comment?: string;
|
||||
sendTime?: string;
|
||||
createTime: string;
|
||||
updateTime: string;
|
||||
wechatId?: string;
|
||||
wechatNickname?: string;
|
||||
wechatAvatar?: string;
|
||||
snsId?: string;
|
||||
msgId?: string;
|
||||
type?: string;
|
||||
contentData?: string;
|
||||
createMomentTime?: string;
|
||||
createMessageTime?: string;
|
||||
createMomentTimeFormatted?: string;
|
||||
createMessageTimeFormatted?: string;
|
||||
}
|
||||
|
||||
// 内容库类型
|
||||
export interface ContentLibrary {
|
||||
id: string;
|
||||
name: string;
|
||||
sourceType: number; // 1=微信好友, 2=聊天群
|
||||
creatorName?: string;
|
||||
updateTime: string;
|
||||
status: number; // 0=未启用, 1=已启用
|
||||
itemCount?: number;
|
||||
createTime: string;
|
||||
sourceFriends?: string[];
|
||||
sourceGroups?: string[];
|
||||
keywordInclude?: string[];
|
||||
keywordExclude?: string[];
|
||||
aiPrompt?: string;
|
||||
timeEnabled?: number;
|
||||
timeStart?: string;
|
||||
timeEnd?: string;
|
||||
selectedFriends?: any[];
|
||||
selectedGroups?: any[];
|
||||
selectedGroupMembers?: WechatGroupMember[];
|
||||
}
|
||||
|
||||
// 微信群成员
|
||||
export interface WechatGroupMember {
|
||||
id: string;
|
||||
nickname: string;
|
||||
wechatId: string;
|
||||
avatar: string;
|
||||
gender?: "male" | "female";
|
||||
role?: "owner" | "admin" | "member";
|
||||
joinTime?: string;
|
||||
}
|
||||
|
||||
// API 响应类型
|
||||
export interface ApiResponse<T = any> {
|
||||
code: number;
|
||||
msg: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
// 创建素材参数
|
||||
export interface CreateContentItemParams {
|
||||
libraryId: string;
|
||||
title: string;
|
||||
content: string;
|
||||
contentType: number;
|
||||
resUrls?: string[];
|
||||
urls?: string[];
|
||||
comment?: string;
|
||||
sendTime?: string;
|
||||
}
|
||||
|
||||
// 更新素材参数
|
||||
export interface UpdateContentItemParams
|
||||
extends Partial<CreateContentItemParams> {
|
||||
id: string;
|
||||
}
|
||||
|
||||
@@ -1,125 +1,160 @@
|
||||
.form-page {
|
||||
padding: 16px;
|
||||
background: #f5f5f5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.form-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
border: none;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
border-radius: 6px;
|
||||
border: 1px solid #d9d9d9;
|
||||
|
||||
&:focus {
|
||||
border-color: #1677ff;
|
||||
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.time-picker {
|
||||
width: 100%;
|
||||
border-radius: 6px;
|
||||
|
||||
&:focus {
|
||||
border-color: #1677ff;
|
||||
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.select-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.anticon {
|
||||
font-size: 16px;
|
||||
color: #1677ff;
|
||||
}
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
flex: 1;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #d9d9d9;
|
||||
|
||||
&:hover {
|
||||
border-color: #1677ff;
|
||||
color: #1677ff;
|
||||
}
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
flex: 1;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
// 覆盖 antd-mobile 的默认样式
|
||||
:global {
|
||||
.adm-form-item {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.adm-form-item-label {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.adm-input {
|
||||
border-radius: 6px;
|
||||
border: 1px solid #d9d9d9;
|
||||
|
||||
&:focus {
|
||||
border-color: #1677ff;
|
||||
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.adm-select {
|
||||
border-radius: 6px;
|
||||
border: 1px solid #d9d9d9;
|
||||
|
||||
&:focus {
|
||||
border-color: #1677ff;
|
||||
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
.form-page {
|
||||
padding: 16px;
|
||||
background: #f5f5f5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.form-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
border: none;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.form-item {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.required {
|
||||
color: #ff4d4f;
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #d9d9d9;
|
||||
padding: 0 12px;
|
||||
font-size: 14px;
|
||||
|
||||
&:focus {
|
||||
border-color: #1677ff;
|
||||
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.form-select {
|
||||
width: 100%;
|
||||
border-radius: 6px;
|
||||
|
||||
&:focus {
|
||||
border-color: #1677ff;
|
||||
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
width: 100%;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #d9d9d9;
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
resize: vertical;
|
||||
|
||||
&:focus {
|
||||
border-color: #1677ff;
|
||||
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.select-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.anticon {
|
||||
font-size: 16px;
|
||||
color: #1677ff;
|
||||
}
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
flex: 1;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #d9d9d9;
|
||||
|
||||
&:hover {
|
||||
border-color: #1677ff;
|
||||
color: #1677ff;
|
||||
}
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
flex: 1;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
// 覆盖 antd-mobile 的默认样式
|
||||
:global {
|
||||
.adm-form-item {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.adm-form-item-label {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.adm-input {
|
||||
border-radius: 6px;
|
||||
border: 1px solid #d9d9d9;
|
||||
|
||||
&:focus {
|
||||
border-color: #1677ff;
|
||||
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.adm-select {
|
||||
border-radius: 6px;
|
||||
border: 1px solid #d9d9d9;
|
||||
|
||||
&:focus {
|
||||
border-color: #1677ff;
|
||||
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,318 +1,401 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { Button, Toast, SpinLoading, Form, Card } from "antd-mobile";
|
||||
import { Input, TimePicker, Select, Upload } from "antd";
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
SaveOutlined,
|
||||
UploadOutlined,
|
||||
PictureOutlined,
|
||||
LinkOutlined,
|
||||
VideoCameraOutlined,
|
||||
FileTextOutlined,
|
||||
AppstoreOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import NavCommon from "@/components/NavCommon";
|
||||
import {
|
||||
getContentItemDetail,
|
||||
createContentItem,
|
||||
updateContentItem,
|
||||
getContentLibraryDetail,
|
||||
} from "./api";
|
||||
import { ContentItem, ContentLibrary } from "./data";
|
||||
import style from "./index.module.scss";
|
||||
|
||||
const { Option } = Select;
|
||||
const { TextArea } = Input;
|
||||
|
||||
// 内容类型选项
|
||||
const contentTypeOptions = [
|
||||
{ value: 1, label: "图片", icon: <PictureOutlined /> },
|
||||
{ value: 2, label: "链接", icon: <LinkOutlined /> },
|
||||
{ value: 3, label: "视频", icon: <VideoCameraOutlined /> },
|
||||
{ value: 4, label: "文本", icon: <FileTextOutlined /> },
|
||||
{ value: 5, label: "小程序", icon: <AppstoreOutlined /> },
|
||||
{ value: 6, label: "图文", icon: <PictureOutlined /> },
|
||||
];
|
||||
|
||||
const MaterialForm: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { id: libraryId, materialId } = useParams<{
|
||||
id: string;
|
||||
materialId: string;
|
||||
}>();
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [material, setMaterial] = useState<ContentItem | null>(null);
|
||||
const [library, setLibrary] = useState<ContentLibrary | null>(null);
|
||||
const [contentType, setContentType] = useState<number>(4);
|
||||
|
||||
const isEdit = !!materialId;
|
||||
|
||||
// 获取内容库详情
|
||||
useEffect(() => {
|
||||
if (libraryId) {
|
||||
fetchLibraryDetail();
|
||||
}
|
||||
}, [libraryId]);
|
||||
|
||||
// 获取素材详情
|
||||
useEffect(() => {
|
||||
if (isEdit && materialId) {
|
||||
fetchMaterialDetail();
|
||||
}
|
||||
}, [isEdit, materialId]);
|
||||
|
||||
const fetchLibraryDetail = async () => {
|
||||
if (!libraryId) return;
|
||||
try {
|
||||
const response = await getContentLibraryDetail(libraryId);
|
||||
if (response.code === 200 && response.data) {
|
||||
setLibrary(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取内容库详情失败:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchMaterialDetail = async () => {
|
||||
if (!materialId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getContentItemDetail(materialId);
|
||||
if (response.code === 200 && response.data) {
|
||||
setMaterial(response.data);
|
||||
setContentType(response.data.contentType);
|
||||
|
||||
// 填充表单数据
|
||||
form.setFieldsValue({
|
||||
title: response.data.title,
|
||||
content: response.data.content,
|
||||
contentType: response.data.contentType,
|
||||
comment: response.data.comment || "",
|
||||
sendTime: response.data.sendTime || "",
|
||||
resUrls: response.data.resUrls || [],
|
||||
urls: response.data.urls || [],
|
||||
});
|
||||
} else {
|
||||
Toast.show({
|
||||
content: response.msg || "获取素材详情失败",
|
||||
position: "top",
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("获取素材详情失败:", error);
|
||||
Toast.show({
|
||||
content: error?.message || "请检查网络连接",
|
||||
position: "top",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (values: any) => {
|
||||
if (!libraryId) return;
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const params = {
|
||||
libraryId,
|
||||
title: values.title,
|
||||
content: values.content,
|
||||
contentType: values.contentType,
|
||||
comment: values.comment || "",
|
||||
sendTime: values.sendTime || "",
|
||||
resUrls: values.resUrls || [],
|
||||
urls: values.urls || [],
|
||||
};
|
||||
|
||||
let response;
|
||||
if (isEdit) {
|
||||
response = await updateContentItem({
|
||||
id: materialId!,
|
||||
...params,
|
||||
});
|
||||
} else {
|
||||
response = await createContentItem(params);
|
||||
}
|
||||
|
||||
if (response.code === 200) {
|
||||
Toast.show({
|
||||
content: isEdit ? "更新成功" : "创建成功",
|
||||
position: "top",
|
||||
});
|
||||
navigate(`/content/materials/${libraryId}`);
|
||||
} else {
|
||||
Toast.show({
|
||||
content: response.msg || (isEdit ? "更新失败" : "创建失败"),
|
||||
position: "top",
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("保存素材失败:", error);
|
||||
Toast.show({
|
||||
content: error?.message || "请检查网络连接",
|
||||
position: "top",
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
navigate(`/content/materials/${libraryId}`);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Layout header={<NavCommon title={isEdit ? "编辑素材" : "新建素材"} />}>
|
||||
<div className={style["loading"]}>
|
||||
<SpinLoading color="primary" style={{ fontSize: 32 }} />
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout header={<NavCommon title={isEdit ? "编辑素材" : "新建素材"} />}>
|
||||
<div className={style["form-page"]}>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleSubmit}
|
||||
className={style["form"]}
|
||||
initialValues={{
|
||||
contentType: 4,
|
||||
resUrls: [],
|
||||
urls: [],
|
||||
}}
|
||||
>
|
||||
{/* 基本信息 */}
|
||||
<Card className={style["form-card"]}>
|
||||
<div className={style["card-title"]}>基本信息</div>
|
||||
|
||||
<Form.Item
|
||||
name="title"
|
||||
label="素材标题"
|
||||
rules={[{ required: true, message: "请输入素材标题" }]}
|
||||
>
|
||||
<Input placeholder="请输入素材标题" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="contentType"
|
||||
label="内容类型"
|
||||
rules={[{ required: true, message: "请选择内容类型" }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="请选择内容类型"
|
||||
onChange={(value) => setContentType(value)}
|
||||
>
|
||||
{contentTypeOptions.map((option) => (
|
||||
<Option key={option.value} value={option.value}>
|
||||
<div className={style["select-option"]}>
|
||||
{option.icon}
|
||||
<span>{option.label}</span>
|
||||
</div>
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="content"
|
||||
label="内容"
|
||||
rules={[{ required: true, message: "请输入内容" }]}
|
||||
>
|
||||
<TextArea
|
||||
placeholder="请输入内容"
|
||||
rows={6}
|
||||
className={style["textarea"]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Card>
|
||||
|
||||
{/* 资源设置 */}
|
||||
<Card className={style["form-card"]}>
|
||||
<div className={style["card-title"]}>资源设置</div>
|
||||
|
||||
<Form.Item
|
||||
name="resUrls"
|
||||
label="资源链接"
|
||||
extra="图片、视频等资源链接,多个用换行分隔"
|
||||
>
|
||||
<TextArea
|
||||
placeholder="请输入资源链接,多个用换行分隔"
|
||||
rows={4}
|
||||
className={style["textarea"]}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="urls"
|
||||
label="外部链接"
|
||||
extra="外部网页链接,多个用换行分隔"
|
||||
>
|
||||
<TextArea
|
||||
placeholder="请输入外部链接,多个用换行分隔"
|
||||
rows={4}
|
||||
className={style["textarea"]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Card>
|
||||
|
||||
{/* 其他设置 */}
|
||||
<Card className={style["form-card"]}>
|
||||
<div className={style["card-title"]}>其他设置</div>
|
||||
|
||||
<Form.Item name="comment" label="备注" extra="素材备注信息">
|
||||
<TextArea
|
||||
placeholder="请输入备注信息"
|
||||
rows={3}
|
||||
className={style["textarea"]}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="sendTime"
|
||||
label="发送时间"
|
||||
extra="计划发送时间(可选)"
|
||||
>
|
||||
<TimePicker
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
placeholder="选择发送时间"
|
||||
className={style["time-picker"]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Card>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className={style["form-actions"]}>
|
||||
<Button
|
||||
fill="outline"
|
||||
onClick={handleBack}
|
||||
className={style["back-btn"]}
|
||||
>
|
||||
<ArrowLeftOutlined />
|
||||
返回
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
type="submit"
|
||||
loading={saving}
|
||||
className={style["submit-btn"]}
|
||||
>
|
||||
<SaveOutlined />
|
||||
{isEdit ? "更新" : "创建"}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default MaterialForm;
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { Button, Toast, SpinLoading, Card } from "antd-mobile";
|
||||
import { Input, TimePicker, Select } from "antd";
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
SaveOutlined,
|
||||
PictureOutlined,
|
||||
LinkOutlined,
|
||||
VideoCameraOutlined,
|
||||
FileTextOutlined,
|
||||
AppstoreOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import NavCommon from "@/components/NavCommon";
|
||||
import UploadComponent from "@/components/Upload";
|
||||
import VideoUpload from "@/components/Upload/VideoUpload";
|
||||
import {
|
||||
getContentItemDetail,
|
||||
createContentItem,
|
||||
updateContentItem,
|
||||
} from "./api";
|
||||
import { ContentItem } from "./data";
|
||||
import style from "./index.module.scss";
|
||||
|
||||
const { Option } = Select;
|
||||
const { TextArea } = Input;
|
||||
|
||||
// 内容类型选项
|
||||
const contentTypeOptions = [
|
||||
{ value: 1, label: "图片", icon: <PictureOutlined /> },
|
||||
{ value: 2, label: "链接", icon: <LinkOutlined /> },
|
||||
{ value: 3, label: "视频", icon: <VideoCameraOutlined /> },
|
||||
{ value: 4, label: "文本", icon: <FileTextOutlined /> },
|
||||
{ value: 5, label: "小程序", icon: <AppstoreOutlined /> },
|
||||
];
|
||||
|
||||
const MaterialForm: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { id: libraryId, materialId } = useParams<{
|
||||
id: string;
|
||||
materialId: string;
|
||||
}>();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [material, setMaterial] = useState<ContentItem | null>(null);
|
||||
|
||||
// 表单状态
|
||||
const [contentType, setContentType] = useState<number>(4);
|
||||
const [title, setTitle] = useState("");
|
||||
const [content, setContent] = useState("");
|
||||
const [comment, setComment] = useState("");
|
||||
const [sendTime, setSendTime] = useState("");
|
||||
const [resUrls, setResUrls] = useState<string[]>([]);
|
||||
const [urls, setUrls] = useState<
|
||||
{ desc: string; image: string; url: string }[]
|
||||
>([]);
|
||||
|
||||
// 链接相关状态
|
||||
const [linkDesc, setLinkDesc] = useState("");
|
||||
const [linkImage, setLinkImage] = useState("");
|
||||
const [linkUrl, setLinkUrl] = useState("");
|
||||
|
||||
// 小程序相关状态
|
||||
const [appTitle, setAppTitle] = useState("");
|
||||
const [appId, setAppId] = useState("");
|
||||
|
||||
const isEdit = !!materialId;
|
||||
|
||||
// 获取素材详情
|
||||
useEffect(() => {
|
||||
if (isEdit && materialId) {
|
||||
fetchMaterialDetail();
|
||||
}
|
||||
}, [isEdit, materialId]);
|
||||
|
||||
const fetchMaterialDetail = async () => {
|
||||
if (!materialId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getContentItemDetail(materialId);
|
||||
// 填充表单数据
|
||||
setTitle(response.title || "");
|
||||
setContent(response.content || "");
|
||||
setContentType(response.contentType || 4);
|
||||
setComment(response.comment || "");
|
||||
setSendTime(
|
||||
response.sendTime
|
||||
? new Date(response.sendTime * 1000).toISOString().slice(0, 16)
|
||||
: ""
|
||||
);
|
||||
setResUrls(response.resUrls || []);
|
||||
setUrls(response.urls || []);
|
||||
|
||||
// 设置链接相关数据
|
||||
if (response.urls && response.urls.length > 0) {
|
||||
setLinkDesc(response.urls[0].desc || "");
|
||||
setLinkImage(response.urls[0].image || "");
|
||||
setLinkUrl(response.urls[0].url || "");
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error("获取素材详情失败:", error);
|
||||
Toast.show({
|
||||
content: error instanceof Error ? error.message : "请检查网络连接",
|
||||
position: "top",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!libraryId) return;
|
||||
|
||||
if (!content.trim()) {
|
||||
Toast.show({
|
||||
content: "请输入素材内容",
|
||||
position: "top",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
// 构建urls数据
|
||||
let finalUrls: { desc: string; image: string; url: string }[] = [];
|
||||
if (contentType === 2 && linkUrl) {
|
||||
finalUrls = [
|
||||
{
|
||||
desc: linkDesc,
|
||||
image: linkImage,
|
||||
url: linkUrl,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const params = {
|
||||
libraryId,
|
||||
title,
|
||||
content,
|
||||
contentType,
|
||||
comment,
|
||||
sendTime: sendTime
|
||||
? Math.floor(new Date(sendTime).getTime() / 1000)
|
||||
: 0,
|
||||
resUrls,
|
||||
urls: finalUrls,
|
||||
};
|
||||
|
||||
let response;
|
||||
if (isEdit) {
|
||||
response = await updateContentItem({
|
||||
id: materialId!,
|
||||
...params,
|
||||
});
|
||||
} else {
|
||||
response = await createContentItem(params);
|
||||
}
|
||||
|
||||
if (response.code === 200) {
|
||||
Toast.show({
|
||||
content: isEdit ? "更新成功" : "创建成功",
|
||||
position: "top",
|
||||
});
|
||||
navigate(`/content/materials/${libraryId}`);
|
||||
} else {
|
||||
Toast.show({
|
||||
content: response.msg || (isEdit ? "更新失败" : "创建失败"),
|
||||
position: "top",
|
||||
});
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error("保存素材失败:", error);
|
||||
Toast.show({
|
||||
content: error instanceof Error ? error.message : "请检查网络连接",
|
||||
position: "top",
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
navigate(`/content/materials/${libraryId}`);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Layout header={<NavCommon title={isEdit ? "编辑素材" : "新建素材"} />}>
|
||||
<div className={style["loading"]}>
|
||||
<SpinLoading color="primary" style={{ fontSize: 32 }} />
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout header={<NavCommon title={isEdit ? "编辑素材" : "新建素材"} />}>
|
||||
<div className={style["form-page"]}>
|
||||
<div className={style["form"]}>
|
||||
{/* 基础信息 */}
|
||||
<Card className={style["form-card"]}>
|
||||
<div className={style["card-title"]}>基础信息</div>
|
||||
|
||||
<div className={style["form-item"]}>
|
||||
<label className={style["form-label"]}>发布时间</label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={sendTime}
|
||||
onChange={e => setSendTime(e.target.value)}
|
||||
placeholder="请选择发布时间"
|
||||
className={style["form-input"]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={style["form-item"]}>
|
||||
<label className={style["form-label"]}>
|
||||
<span className={style["required"]}>*</span>类型
|
||||
</label>
|
||||
<Select
|
||||
value={contentType}
|
||||
onChange={value => setContentType(value)}
|
||||
placeholder="请选择类型"
|
||||
className={style["form-select"]}
|
||||
>
|
||||
{contentTypeOptions.map(option => (
|
||||
<Option key={option.value} value={option.value}>
|
||||
<div className={style["select-option"]}>
|
||||
{option.icon}
|
||||
<span>{option.label}</span>
|
||||
</div>
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 内容信息 */}
|
||||
<Card className={style["form-card"]}>
|
||||
<div className={style["card-title"]}>内容信息</div>
|
||||
|
||||
<div className={style["form-item"]}>
|
||||
<label className={style["form-label"]}>
|
||||
<span className={style["required"]}>*</span>内容
|
||||
</label>
|
||||
<TextArea
|
||||
value={content}
|
||||
onChange={e => setContent(e.target.value)}
|
||||
placeholder="请输入内容"
|
||||
rows={6}
|
||||
className={style["form-textarea"]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 链接类型特有字段 */}
|
||||
{contentType === 2 && (
|
||||
<>
|
||||
<div className={style["form-item"]}>
|
||||
<label className={style["form-label"]}>
|
||||
<span className={style["required"]}>*</span>描述
|
||||
</label>
|
||||
<Input
|
||||
value={linkDesc}
|
||||
onChange={e => setLinkDesc(e.target.value)}
|
||||
placeholder="请输入描述"
|
||||
className={style["form-input"]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={style["form-item"]}>
|
||||
<label className={style["form-label"]}>封面图</label>
|
||||
<UploadComponent
|
||||
value={linkImage ? [linkImage] : []}
|
||||
onChange={urls => setLinkImage(urls[0] || "")}
|
||||
count={1}
|
||||
listType="picture-card"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={style["form-item"]}>
|
||||
<label className={style["form-label"]}>
|
||||
<span className={style["required"]}>*</span>链接地址
|
||||
</label>
|
||||
<Input
|
||||
value={linkUrl}
|
||||
onChange={e => setLinkUrl(e.target.value)}
|
||||
placeholder="请输入链接地址"
|
||||
className={style["form-input"]}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 视频类型特有字段 */}
|
||||
{contentType === 3 && (
|
||||
<div className={style["form-item"]}>
|
||||
<label className={style["form-label"]}>视频上传</label>
|
||||
<VideoUpload
|
||||
value={resUrls[0] || ""}
|
||||
onChange={url => setResUrls([url])}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 素材上传(仅图片类型和小程序类型) */}
|
||||
{[1, 5].includes(contentType) && (
|
||||
<Card className={style["form-card"]}>
|
||||
<div className={style["card-title"]}>素材上传</div>
|
||||
|
||||
{contentType === 1 && (
|
||||
<div className={style["form-item"]}>
|
||||
<label className={style["form-label"]}>图片上传</label>
|
||||
<UploadComponent
|
||||
value={resUrls}
|
||||
onChange={setResUrls}
|
||||
count={9}
|
||||
listType="picture-card"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{contentType === 5 && (
|
||||
<>
|
||||
<div className={style["form-item"]}>
|
||||
<label className={style["form-label"]}>小程序名称</label>
|
||||
<Input
|
||||
value={appTitle}
|
||||
onChange={e => setAppTitle(e.target.value)}
|
||||
placeholder="请输入小程序名称"
|
||||
className={style["form-input"]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={style["form-item"]}>
|
||||
<label className={style["form-label"]}>AppID</label>
|
||||
<Input
|
||||
value={appId}
|
||||
onChange={e => setAppId(e.target.value)}
|
||||
placeholder="请输入AppID"
|
||||
className={style["form-input"]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={style["form-item"]}>
|
||||
<label className={style["form-label"]}>小程序封面图</label>
|
||||
<UploadComponent
|
||||
value={resUrls}
|
||||
onChange={setResUrls}
|
||||
count={9}
|
||||
listType="picture-card"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 评论/备注 */}
|
||||
<Card className={style["form-card"]}>
|
||||
<div className={style["card-title"]}>评论/备注</div>
|
||||
|
||||
<div className={style["form-item"]}>
|
||||
<label className={style["form-label"]}>备注</label>
|
||||
<TextArea
|
||||
value={comment}
|
||||
onChange={e => setComment(e.target.value)}
|
||||
placeholder="请输入评论或备注"
|
||||
rows={4}
|
||||
className={style["form-textarea"]}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className={style["form-actions"]}>
|
||||
<Button
|
||||
fill="outline"
|
||||
onClick={handleBack}
|
||||
className={style["back-btn"]}
|
||||
>
|
||||
<ArrowLeftOutlined />
|
||||
返回
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
onClick={handleSubmit}
|
||||
loading={saving}
|
||||
className={style["submit-btn"]}
|
||||
>
|
||||
<SaveOutlined />
|
||||
{isEdit ? "保存修改" : "保存素材"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default MaterialForm;
|
||||
|
||||
@@ -1,45 +1,37 @@
|
||||
import request from "@/api/request";
|
||||
import {
|
||||
ContentItem,
|
||||
ContentLibrary,
|
||||
GetContentItemListParams,
|
||||
CreateContentItemParams,
|
||||
UpdateContentItemParams,
|
||||
} from "./data";
|
||||
|
||||
// 获取素材列表
|
||||
export function getContentItemList(
|
||||
params: GetContentItemListParams
|
||||
): Promise<any> {
|
||||
return request("/v1/content/item/list", params, "GET");
|
||||
export function getContentItemList(params: GetContentItemListParams) {
|
||||
return request("/v1/content/library/item-list", params, "GET");
|
||||
}
|
||||
|
||||
// 获取素材详情
|
||||
export function getContentItemDetail(id: string): Promise<any> {
|
||||
export function getContentItemDetail(id: string) {
|
||||
return request("/v1/content/item/detail", { id }, "GET");
|
||||
}
|
||||
|
||||
// 创建素材
|
||||
export function createContentItem(
|
||||
params: CreateContentItemParams
|
||||
): Promise<any> {
|
||||
export function createContentItem(params: CreateContentItemParams) {
|
||||
return request("/v1/content/item/create", params, "POST");
|
||||
}
|
||||
|
||||
// 更新素材
|
||||
export function updateContentItem(
|
||||
params: UpdateContentItemParams
|
||||
): Promise<any> {
|
||||
export function updateContentItem(params: UpdateContentItemParams) {
|
||||
const { id, ...data } = params;
|
||||
return request(`/v1/content/item/update`, { id, ...data }, "POST");
|
||||
}
|
||||
|
||||
// 删除素材
|
||||
export function deleteContentItem(id: string): Promise<any> {
|
||||
export function deleteContentItem(id: string) {
|
||||
return request("/v1/content/item/delete", { id }, "DELETE");
|
||||
}
|
||||
|
||||
// 获取内容库详情
|
||||
export function getContentLibraryDetail(id: string): Promise<any> {
|
||||
export function getContentLibraryDetail(id: string) {
|
||||
return request("/v1/content/library/detail", { id }, "GET");
|
||||
}
|
||||
|
||||
@@ -1,98 +1,106 @@
|
||||
// 素材数据类型定义
|
||||
export interface ContentItem {
|
||||
id: string;
|
||||
libraryId: string;
|
||||
title: string;
|
||||
content: string;
|
||||
contentType: number; // 0=未知, 1=图片, 2=链接, 3=视频, 4=文本, 5=小程序, 6=图文
|
||||
contentTypeName?: string;
|
||||
resUrls?: string[];
|
||||
urls?: string[];
|
||||
comment?: string;
|
||||
sendTime?: string;
|
||||
createTime: string;
|
||||
updateTime: string;
|
||||
wechatId?: string;
|
||||
wechatNickname?: string;
|
||||
wechatAvatar?: string;
|
||||
snsId?: string;
|
||||
msgId?: string;
|
||||
type?: string;
|
||||
contentData?: string;
|
||||
createMomentTime?: string;
|
||||
createMessageTime?: string;
|
||||
createMomentTimeFormatted?: string;
|
||||
createMessageTimeFormatted?: string;
|
||||
}
|
||||
|
||||
// 内容库类型
|
||||
export interface ContentLibrary {
|
||||
id: string;
|
||||
name: string;
|
||||
sourceType: number; // 1=微信好友, 2=聊天群
|
||||
creatorName?: string;
|
||||
updateTime: string;
|
||||
status: number; // 0=未启用, 1=已启用
|
||||
itemCount?: number;
|
||||
createTime: string;
|
||||
sourceFriends?: string[];
|
||||
sourceGroups?: string[];
|
||||
keywordInclude?: string[];
|
||||
keywordExclude?: string[];
|
||||
aiPrompt?: string;
|
||||
timeEnabled?: number;
|
||||
timeStart?: string;
|
||||
timeEnd?: string;
|
||||
selectedFriends?: any[];
|
||||
selectedGroups?: any[];
|
||||
selectedGroupMembers?: WechatGroupMember[];
|
||||
}
|
||||
|
||||
// 微信群成员
|
||||
export interface WechatGroupMember {
|
||||
id: string;
|
||||
nickname: string;
|
||||
wechatId: string;
|
||||
avatar: string;
|
||||
gender?: "male" | "female";
|
||||
role?: "owner" | "admin" | "member";
|
||||
joinTime?: string;
|
||||
}
|
||||
|
||||
// API 响应类型
|
||||
export interface ApiResponse<T = any> {
|
||||
code: number;
|
||||
msg: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
export interface ItemListResponse {
|
||||
list: ContentItem[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
// 获取素材列表参数
|
||||
export interface GetContentItemListParams {
|
||||
libraryId: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
keyword?: string;
|
||||
}
|
||||
|
||||
// 创建素材参数
|
||||
export interface CreateContentItemParams {
|
||||
libraryId: string;
|
||||
title: string;
|
||||
content: string;
|
||||
contentType: number;
|
||||
resUrls?: string[];
|
||||
urls?: string[];
|
||||
comment?: string;
|
||||
sendTime?: string;
|
||||
}
|
||||
|
||||
// 更新素材参数
|
||||
export interface UpdateContentItemParams
|
||||
extends Partial<CreateContentItemParams> {
|
||||
id: string;
|
||||
}
|
||||
// 素材数据类型定义
|
||||
export interface ContentItem {
|
||||
id: number;
|
||||
libraryId: number;
|
||||
type: string;
|
||||
contentType: number; // 0=未知, 1=图片, 2=链接, 3=视频, 4=文本, 5=小程序, 6=图文
|
||||
title: string;
|
||||
content: string;
|
||||
contentAi?: string | null;
|
||||
contentData?: string | null;
|
||||
snsId?: string | null;
|
||||
msgId?: string | null;
|
||||
wechatId?: string | null;
|
||||
friendId?: string | null;
|
||||
createMomentTime: number;
|
||||
createTime: string;
|
||||
updateTime: string;
|
||||
coverImage: string;
|
||||
resUrls: string[];
|
||||
urls: { desc: string; image: string; url: string }[];
|
||||
location?: string | null;
|
||||
lat: string;
|
||||
lng: string;
|
||||
status: number;
|
||||
isDel: number;
|
||||
delTime: number;
|
||||
wechatChatroomId?: string | null;
|
||||
senderNickname: string;
|
||||
createMessageTime?: string | null;
|
||||
comment: string;
|
||||
sendTime: number;
|
||||
sendTimes: number;
|
||||
contentTypeName: string;
|
||||
}
|
||||
|
||||
// 内容库类型
|
||||
export interface ContentLibrary {
|
||||
id: string;
|
||||
name: string;
|
||||
sourceType: number; // 1=微信好友, 2=聊天群
|
||||
creatorName?: string;
|
||||
updateTime: string;
|
||||
status: number; // 0=未启用, 1=已启用
|
||||
itemCount?: number;
|
||||
createTime: string;
|
||||
sourceFriends?: string[];
|
||||
sourceGroups?: string[];
|
||||
keywordInclude?: string[];
|
||||
keywordExclude?: string[];
|
||||
aiPrompt?: string;
|
||||
timeEnabled?: number;
|
||||
timeStart?: string;
|
||||
timeEnd?: string;
|
||||
selectedFriends?: any[];
|
||||
selectedGroups?: any[];
|
||||
selectedGroupMembers?: WechatGroupMember[];
|
||||
}
|
||||
|
||||
// 微信群成员
|
||||
export interface WechatGroupMember {
|
||||
id: string;
|
||||
nickname: string;
|
||||
wechatId: string;
|
||||
avatar: string;
|
||||
gender?: "male" | "female";
|
||||
role?: "owner" | "admin" | "member";
|
||||
joinTime?: string;
|
||||
}
|
||||
|
||||
// API 响应类型
|
||||
export interface ApiResponse<T = any> {
|
||||
code: number;
|
||||
msg: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
export interface ItemListResponse {
|
||||
list: ContentItem[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
// 获取素材列表参数
|
||||
export interface GetContentItemListParams {
|
||||
libraryId: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
keyword?: string;
|
||||
}
|
||||
|
||||
// 创建素材参数
|
||||
export interface CreateContentItemParams {
|
||||
libraryId: string;
|
||||
title: string;
|
||||
content: string;
|
||||
contentType: number;
|
||||
resUrls?: string[];
|
||||
urls?: (string | { desc?: string; image?: string; url: string })[];
|
||||
comment?: string;
|
||||
sendTime?: string;
|
||||
}
|
||||
|
||||
// 更新素材参数
|
||||
export interface UpdateContentItemParams
|
||||
extends Partial<CreateContentItemParams> {
|
||||
id: string;
|
||||
}
|
||||
|
||||
@@ -1,253 +1,340 @@
|
||||
.materials-page {
|
||||
padding: 16px;
|
||||
background: #f5f5f5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
background: white;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.search-input-wrapper {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #999;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
padding-left: 36px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid #e0e0e0;
|
||||
|
||||
&:focus {
|
||||
border-color: #1677ff;
|
||||
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.create-btn {
|
||||
border-radius: 20px;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.spinning {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.materials-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
text-align: center;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
color: #999;
|
||||
margin-bottom: 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.empty-btn {
|
||||
border-radius: 20px;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.material-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
border: none;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.material-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.material-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.content-icon {
|
||||
font-size: 16px;
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
.type-tag {
|
||||
font-size: 12px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.menu-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 4px;
|
||||
cursor: pointer;
|
||||
color: #999;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background: #f5f5f5;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-dropdown {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 100%;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
z-index: 1000;
|
||||
min-width: 120px;
|
||||
padding: 4px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
&.danger {
|
||||
color: #ff4d4f;
|
||||
|
||||
&:hover {
|
||||
background: #fff2f0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.card-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.content-preview {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
max-height: 60px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.material-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pagination-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 20px;
|
||||
padding: 16px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.pagination {
|
||||
:global {
|
||||
.adm-pagination-item {
|
||||
border-radius: 6px;
|
||||
margin: 0 2px;
|
||||
|
||||
&.adm-pagination-item-active {
|
||||
background: #1677ff;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.materials-page {
|
||||
padding: 16px;
|
||||
background: #f5f5f5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
background: white;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.search-input-wrapper {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #999;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
padding-left: 36px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid #e0e0e0;
|
||||
|
||||
&:focus {
|
||||
border-color: #1677ff;
|
||||
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.create-btn {
|
||||
border-radius: 20px;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.spinning {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.materials-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
text-align: center;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
color: #999;
|
||||
margin-bottom: 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.empty-btn {
|
||||
border-radius: 20px;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.material-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
border: none;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.avatar-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: #e6f7ff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.avatar-icon {
|
||||
font-size: 24px;
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
.header-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.creator-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.material-id {
|
||||
background: #e6f7ff;
|
||||
color: #1677ff;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
border-radius: 12px;
|
||||
padding: 2px 8px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.material-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.content-icon {
|
||||
font-size: 16px;
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
.type-tag {
|
||||
font-size: 12px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.menu-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 4px;
|
||||
cursor: pointer;
|
||||
color: #999;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background: #f5f5f5;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-dropdown {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 100%;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
z-index: 1000;
|
||||
min-width: 120px;
|
||||
padding: 4px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
&.danger {
|
||||
color: #ff4d4f;
|
||||
|
||||
&:hover {
|
||||
background: #fff2f0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.link-preview {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid #e9ecef;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: #e9ecef;
|
||||
border-color: #1677ff;
|
||||
}
|
||||
}
|
||||
|
||||
.link-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.link-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.link-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
margin-bottom: 4px;
|
||||
line-height: 1.3;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.link-url {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
margin-top: 16px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.action-btn-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
border-radius: 6px;
|
||||
font-size: 16px;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid #d9d9d9;
|
||||
background: white;
|
||||
color: #333;
|
||||
|
||||
&:hover {
|
||||
border-color: #1677ff;
|
||||
color: #1677ff;
|
||||
}
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
border-radius: 6px;
|
||||
font-size: 16px;
|
||||
padding: 6px 12px;
|
||||
background: #ff4d4f;
|
||||
border-color: #ff4d4f;
|
||||
color: white;
|
||||
|
||||
&:hover {
|
||||
background: #ff7875;
|
||||
border-color: #ff7875;
|
||||
}
|
||||
}
|
||||
|
||||
.pagination-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
background: white;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
@@ -1,387 +1,268 @@
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
Button,
|
||||
Toast,
|
||||
SpinLoading,
|
||||
Dialog,
|
||||
Card,
|
||||
Avatar,
|
||||
Tag,
|
||||
} from "antd-mobile";
|
||||
import { Pagination, Input } from "antd";
|
||||
import {
|
||||
PlusOutlined,
|
||||
SearchOutlined,
|
||||
ReloadOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
EyeOutlined,
|
||||
MoreOutlined,
|
||||
PictureOutlined,
|
||||
LinkOutlined,
|
||||
VideoCameraOutlined,
|
||||
FileTextOutlined,
|
||||
AppstoreOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import NavCommon from "@/components/NavCommon";
|
||||
import {
|
||||
getContentItemList,
|
||||
deleteContentItem,
|
||||
getContentLibraryDetail,
|
||||
} from "./api";
|
||||
import { ContentItem, ContentLibrary } from "./data";
|
||||
import style from "./index.module.scss";
|
||||
|
||||
// 卡片菜单组件
|
||||
interface CardMenuProps {
|
||||
onView: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
const CardMenu: React.FC<CardMenuProps> = ({ onView, onEdit, onDelete }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const menuRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
if (open) document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative" }}>
|
||||
<button onClick={() => setOpen((v) => !v)} className={style["menu-btn"]}>
|
||||
<MoreOutlined />
|
||||
</button>
|
||||
{open && (
|
||||
<div ref={menuRef} className={style["menu-dropdown"]}>
|
||||
<div
|
||||
onClick={() => {
|
||||
onView();
|
||||
setOpen(false);
|
||||
}}
|
||||
className={style["menu-item"]}
|
||||
>
|
||||
<EyeOutlined />
|
||||
查看
|
||||
</div>
|
||||
<div
|
||||
onClick={() => {
|
||||
onEdit();
|
||||
setOpen(false);
|
||||
}}
|
||||
className={style["menu-item"]}
|
||||
>
|
||||
<EditOutlined />
|
||||
编辑
|
||||
</div>
|
||||
<div
|
||||
onClick={() => {
|
||||
onDelete();
|
||||
setOpen(false);
|
||||
}}
|
||||
className={`${style["menu-item"]} ${style["danger"]}`}
|
||||
>
|
||||
<DeleteOutlined />
|
||||
删除
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 内容类型图标映射
|
||||
const getContentTypeIcon = (type: number) => {
|
||||
switch (type) {
|
||||
case 1:
|
||||
return <PictureOutlined className={style["content-icon"]} />;
|
||||
case 2:
|
||||
return <LinkOutlined className={style["content-icon"]} />;
|
||||
case 3:
|
||||
return <VideoCameraOutlined className={style["content-icon"]} />;
|
||||
case 4:
|
||||
return <FileTextOutlined className={style["content-icon"]} />;
|
||||
case 5:
|
||||
return <AppstoreOutlined className={style["content-icon"]} />;
|
||||
default:
|
||||
return <FileTextOutlined className={style["content-icon"]} />;
|
||||
}
|
||||
};
|
||||
|
||||
// 内容类型文字映射
|
||||
const getContentTypeText = (type: number) => {
|
||||
switch (type) {
|
||||
case 1:
|
||||
return "图片";
|
||||
case 2:
|
||||
return "链接";
|
||||
case 3:
|
||||
return "视频";
|
||||
case 4:
|
||||
return "文本";
|
||||
case 5:
|
||||
return "小程序";
|
||||
case 6:
|
||||
return "图文";
|
||||
default:
|
||||
return "未知";
|
||||
}
|
||||
};
|
||||
|
||||
const MaterialsList: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [materials, setMaterials] = useState<ContentItem[]>([]);
|
||||
const [library, setLibrary] = useState<ContentLibrary | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const pageSize = 20;
|
||||
|
||||
// 获取内容库详情
|
||||
const fetchLibraryDetail = useCallback(async () => {
|
||||
if (!id) return;
|
||||
try {
|
||||
const response = await getContentLibraryDetail(id);
|
||||
if (response.code === 200 && response.data) {
|
||||
setLibrary(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取内容库详情失败:", error);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
// 获取素材列表
|
||||
const fetchMaterials = useCallback(async () => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getContentItemList({
|
||||
libraryId: id,
|
||||
page: currentPage,
|
||||
limit: pageSize,
|
||||
keyword: searchQuery,
|
||||
});
|
||||
|
||||
if (response.code === 200 && response.data) {
|
||||
setMaterials(response.data.list || []);
|
||||
setTotal(response.data.total || 0);
|
||||
} else {
|
||||
Toast.show({
|
||||
content: response.msg || "获取素材列表失败",
|
||||
position: "top",
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("获取素材列表失败:", error);
|
||||
Toast.show({
|
||||
content: error?.message || "请检查网络连接",
|
||||
position: "top",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [id, currentPage, searchQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchLibraryDetail();
|
||||
}, [fetchLibraryDetail]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMaterials();
|
||||
}, [fetchMaterials]);
|
||||
|
||||
const handleCreateNew = () => {
|
||||
navigate(`/content/materials/new/${id}`);
|
||||
};
|
||||
|
||||
const handleEdit = (materialId: string) => {
|
||||
navigate(`/content/materials/edit/${id}/${materialId}`);
|
||||
};
|
||||
|
||||
const handleDelete = async (materialId: string) => {
|
||||
const result = await Dialog.confirm({
|
||||
content: "确定要删除这个素材吗?",
|
||||
confirmText: "删除",
|
||||
cancelText: "取消",
|
||||
});
|
||||
|
||||
if (result) {
|
||||
try {
|
||||
const response = await deleteContentItem(materialId);
|
||||
if (response.code === 200) {
|
||||
Toast.show({
|
||||
content: "删除成功",
|
||||
position: "top",
|
||||
});
|
||||
fetchMaterials();
|
||||
} else {
|
||||
Toast.show({
|
||||
content: response.msg || "删除失败",
|
||||
position: "top",
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("删除素材失败:", error);
|
||||
Toast.show({
|
||||
content: error?.message || "请检查网络连接",
|
||||
position: "top",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleView = (materialId: string) => {
|
||||
// 可以跳转到素材详情页面或显示弹窗
|
||||
console.log("查看素材:", materialId);
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
setCurrentPage(1);
|
||||
fetchMaterials();
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
fetchMaterials();
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
setCurrentPage(page);
|
||||
};
|
||||
|
||||
const filteredMaterials = materials.filter(
|
||||
(material) =>
|
||||
material.title?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
material.content?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={<NavCommon title={`${library?.name || "内容库"} - 素材管理`} />}
|
||||
>
|
||||
<div className={style["materials-page"]}>
|
||||
{/* 搜索和操作栏 */}
|
||||
<div className={style["search-bar"]}>
|
||||
<div className={style["search-input-wrapper"]}>
|
||||
<SearchOutlined className={style["search-icon"]} />
|
||||
<Input
|
||||
placeholder="搜索素材..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onPressEnter={handleSearch}
|
||||
className={style["search-input"]}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={handleRefresh}
|
||||
disabled={loading}
|
||||
className={style["refresh-btn"]}
|
||||
>
|
||||
<ReloadOutlined className={loading ? style["spinning"] : ""} />
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
size="small"
|
||||
onClick={handleCreateNew}
|
||||
className={style["create-btn"]}
|
||||
>
|
||||
<PlusOutlined />
|
||||
新建
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 素材列表 */}
|
||||
<div className={style["materials-list"]}>
|
||||
{loading ? (
|
||||
<div className={style["loading"]}>
|
||||
<SpinLoading color="primary" style={{ fontSize: 32 }} />
|
||||
</div>
|
||||
) : filteredMaterials.length === 0 ? (
|
||||
<div className={style["empty-state"]}>
|
||||
<div className={style["empty-icon"]}>📄</div>
|
||||
<div className={style["empty-text"]}>
|
||||
暂无素材,快去新建一个吧!
|
||||
</div>
|
||||
<Button
|
||||
color="primary"
|
||||
size="small"
|
||||
onClick={handleCreateNew}
|
||||
className={style["empty-btn"]}
|
||||
>
|
||||
新建素材
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{filteredMaterials.map((material) => (
|
||||
<Card key={material.id} className={style["material-card"]}>
|
||||
<div className={style["card-header"]}>
|
||||
<div className={style["material-info"]}>
|
||||
<div className={style["material-title"]}>
|
||||
{getContentTypeIcon(material.contentType)}
|
||||
<span>{material.title || "无标题"}</span>
|
||||
</div>
|
||||
<Tag color="blue" className={style["type-tag"]}>
|
||||
{getContentTypeText(material.contentType)}
|
||||
</Tag>
|
||||
</div>
|
||||
<CardMenu
|
||||
onView={() => handleView(material.id)}
|
||||
onEdit={() => handleEdit(material.id)}
|
||||
onDelete={() => handleDelete(material.id)}
|
||||
/>
|
||||
</div>
|
||||
<div className={style["card-content"]}>
|
||||
<div className={style["content-preview"]}>
|
||||
{material.content?.substring(0, 100)}
|
||||
{material.content &&
|
||||
material.content.length > 100 &&
|
||||
"..."}
|
||||
</div>
|
||||
<div className={style["material-meta"]}>
|
||||
<span className={style["meta-item"]}>
|
||||
创建时间:
|
||||
{new Date(material.createTime).toLocaleString("zh-CN")}
|
||||
</span>
|
||||
{material.sendTime && (
|
||||
<span className={style["meta-item"]}>
|
||||
发送时间:
|
||||
{new Date(material.sendTime).toLocaleString("zh-CN")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{/* 分页 */}
|
||||
{total > pageSize && (
|
||||
<div className={style["pagination-wrapper"]}>
|
||||
<Pagination
|
||||
total={total}
|
||||
pageSize={pageSize}
|
||||
current={currentPage}
|
||||
onChange={handlePageChange}
|
||||
className={style["pagination"]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default MaterialsList;
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { Toast, SpinLoading, Dialog, Card } from "antd-mobile";
|
||||
import { Input, Pagination, Button } from "antd";
|
||||
import {
|
||||
PlusOutlined,
|
||||
SearchOutlined,
|
||||
ReloadOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
UserOutlined,
|
||||
BarChartOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import NavCommon from "@/components/NavCommon";
|
||||
import { getContentItemList, deleteContentItem } from "./api";
|
||||
import { ContentItem } from "./data";
|
||||
import style from "./index.module.scss";
|
||||
|
||||
const MaterialsList: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [materials, setMaterials] = useState<ContentItem[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const pageSize = 20;
|
||||
|
||||
// 获取素材列表
|
||||
const fetchMaterials = useCallback(async () => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getContentItemList({
|
||||
libraryId: id,
|
||||
page: currentPage,
|
||||
limit: pageSize,
|
||||
keyword: searchQuery,
|
||||
});
|
||||
|
||||
setMaterials(response.list || []);
|
||||
setTotal(response.total || 0);
|
||||
} catch (error: unknown) {
|
||||
console.error("获取素材列表失败:", error);
|
||||
Toast.show({
|
||||
content: error instanceof Error ? error.message : "请检查网络连接",
|
||||
position: "top",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [id, currentPage, searchQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMaterials();
|
||||
}, [fetchMaterials]);
|
||||
|
||||
const handleCreateNew = () => {
|
||||
navigate(`/content/materials/new/${id}`);
|
||||
};
|
||||
|
||||
const handleEdit = (materialId: number) => {
|
||||
navigate(`/content/materials/edit/${id}/${materialId}`);
|
||||
};
|
||||
|
||||
const handleDelete = async (materialId: number) => {
|
||||
const result = await Dialog.confirm({
|
||||
content: "确定要删除这个素材吗?",
|
||||
confirmText: "删除",
|
||||
cancelText: "取消",
|
||||
});
|
||||
|
||||
if (result) {
|
||||
try {
|
||||
const response = await deleteContentItem(materialId);
|
||||
if (response.code === 200) {
|
||||
Toast.show({
|
||||
content: "删除成功",
|
||||
position: "top",
|
||||
});
|
||||
fetchMaterials();
|
||||
} else {
|
||||
Toast.show({
|
||||
content: response.msg || "删除失败",
|
||||
position: "top",
|
||||
});
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error("删除素材失败:", error);
|
||||
Toast.show({
|
||||
content: error instanceof Error ? error.message : "请检查网络连接",
|
||||
position: "top",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleView = (materialId: number) => {
|
||||
// 可以跳转到素材详情页面或显示弹窗
|
||||
console.log("查看素材:", materialId);
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
setCurrentPage(1);
|
||||
fetchMaterials();
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
fetchMaterials();
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
setCurrentPage(page);
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<>
|
||||
<NavCommon
|
||||
title="素材管理"
|
||||
right={
|
||||
<Button type="primary" onClick={handleCreateNew}>
|
||||
<PlusOutlined /> 新建素材
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{/* 搜索栏 */}
|
||||
<div className="search-bar">
|
||||
<div className="search-input-wrapper">
|
||||
<Input
|
||||
placeholder="搜索计划名称"
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
prefix={<SearchOutlined />}
|
||||
allowClear
|
||||
size="large"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleRefresh}
|
||||
loading={loading}
|
||||
icon={<ReloadOutlined />}
|
||||
size="large"
|
||||
></Button>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
footer={
|
||||
<div className={style["pagination-wrapper"]}>
|
||||
<Pagination
|
||||
current={currentPage}
|
||||
pageSize={pageSize}
|
||||
total={total}
|
||||
onChange={handlePageChange}
|
||||
showSizeChanger={false}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
loading={loading}
|
||||
>
|
||||
<div className={style["materials-page"]}>
|
||||
{/* 素材列表 */}
|
||||
<div className={style["materials-list"]}>
|
||||
{loading ? (
|
||||
<div className={style["loading"]}>
|
||||
<SpinLoading color="primary" style={{ fontSize: 32 }} />
|
||||
</div>
|
||||
) : materials.length === 0 ? (
|
||||
<div className={style["empty-state"]}>
|
||||
<div className={style["empty-icon"]}>📄</div>
|
||||
<div className={style["empty-text"]}>
|
||||
暂无素材,快去新建一个吧!
|
||||
</div>
|
||||
<Button
|
||||
color="primary"
|
||||
onClick={handleCreateNew}
|
||||
className={style["empty-btn"]}
|
||||
>
|
||||
新建素材
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{materials.map(material => (
|
||||
<Card key={material.id} className={style["material-card"]}>
|
||||
{/* 顶部头像+系统创建+ID */}
|
||||
<div className={style["card-header"]}>
|
||||
<div className={style["avatar-section"]}>
|
||||
<div className={style["avatar"]}>
|
||||
<UserOutlined className={style["avatar-icon"]} />
|
||||
</div>
|
||||
<div className={style["header-info"]}>
|
||||
<span className={style["creator-name"]}>
|
||||
{material.senderNickname}
|
||||
</span>
|
||||
<span className={style["material-id"]}>
|
||||
ID: {material.id}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 主标题 */}
|
||||
<div className={style["material-title"]}>
|
||||
{material.content}
|
||||
</div>
|
||||
|
||||
{/* 链接预览 */}
|
||||
{material.urls && material.urls.length > 0 && (
|
||||
<div
|
||||
className={style["link-preview"]}
|
||||
onClick={() => {
|
||||
window.open(material.urls[0].url, "_blank");
|
||||
}}
|
||||
>
|
||||
<div className={style["link-icon"]}>
|
||||
<img src={material.urls[0].image} />
|
||||
</div>
|
||||
<div className={style["link-content"]}>
|
||||
<div className={style["link-title"]}>
|
||||
{material.urls[0].desc}
|
||||
</div>
|
||||
<div className={style["link-url"]}>
|
||||
{material.urls[0].url}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 操作按钮区 */}
|
||||
<div className={style["action-buttons"]}>
|
||||
<div className={style["action-btn-group"]}>
|
||||
<Button
|
||||
onClick={() => handleEdit(material.id)}
|
||||
className={style["action-btn"]}
|
||||
>
|
||||
<EditOutlined />
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleView(material.id)}
|
||||
className={style["action-btn"]}
|
||||
>
|
||||
<BarChartOutlined />
|
||||
AI分析
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
color="danger"
|
||||
onClick={() => handleDelete(material.id)}
|
||||
className={style["delete-btn"]}
|
||||
>
|
||||
<DeleteOutlined />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default MaterialsList;
|
||||
|
||||
@@ -1,31 +1,31 @@
|
||||
import request from '@/api/request';
|
||||
|
||||
// 设备统计
|
||||
export function getDeviceStats() {
|
||||
return request('/v1/dashboard/device-stats', {}, 'GET');
|
||||
}
|
||||
|
||||
// 微信号统计
|
||||
export function getWechatStats() {
|
||||
return request('/v1/dashboard/wechat-stats', {}, 'GET');
|
||||
}
|
||||
|
||||
// 今日数据统计
|
||||
export function getTodayStats() {
|
||||
return request('/v1/dashboard/today-stats', {}, 'GET');
|
||||
}
|
||||
|
||||
// 首页仪表盘总览
|
||||
export function getDashboard() {
|
||||
return request('/v1/dashboard', {}, 'GET');
|
||||
}
|
||||
|
||||
// 获客场景统计
|
||||
export function getPlanStats(params:any) {
|
||||
return request('/v1/dashboard/plan-stats', params, 'GET');
|
||||
}
|
||||
|
||||
// 近七天统计
|
||||
export function getSevenDayStats() {
|
||||
return request('/v1/dashboard/sevenDay-stats', {}, 'GET');
|
||||
}
|
||||
import request from "@/api/request";
|
||||
|
||||
// 设备统计
|
||||
export function getDeviceStats() {
|
||||
return request("/v1/dashboard/device-stats", {}, "GET");
|
||||
}
|
||||
|
||||
// 微信号统计
|
||||
export function getWechatStats() {
|
||||
return request("/v1/dashboard/wechat-stats", {}, "GET");
|
||||
}
|
||||
|
||||
// 今日数据统计
|
||||
export function getTodayStats() {
|
||||
return request("/v1/dashboard/today-stats", {}, "GET");
|
||||
}
|
||||
|
||||
// 首页仪表盘总览
|
||||
export function getDashboard() {
|
||||
return request("/v1/dashboard", {}, "GET");
|
||||
}
|
||||
|
||||
// 获客场景统计
|
||||
export function getPlanStats(params: any) {
|
||||
return request("/v1/dashboard/plan-stats", params, "GET");
|
||||
}
|
||||
|
||||
// 近七天统计
|
||||
export function getSevenDayStats() {
|
||||
return request("/v1/dashboard/sevenDay-stats", {}, "GET");
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
|
||||
&:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
@@ -67,7 +67,7 @@
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.3s ease;
|
||||
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
|
||||
@@ -109,7 +109,7 @@
|
||||
// Loading状态样式
|
||||
.stat-card {
|
||||
.stat-label:empty::before {
|
||||
content: '';
|
||||
content: "";
|
||||
display: block;
|
||||
width: 60px;
|
||||
height: 12px;
|
||||
@@ -117,10 +117,10 @@
|
||||
border-radius: 2px;
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
|
||||
.stat-value {
|
||||
span:empty::before {
|
||||
content: '';
|
||||
content: "";
|
||||
display: block;
|
||||
width: 40px;
|
||||
height: 20px;
|
||||
@@ -128,9 +128,9 @@
|
||||
border-radius: 2px;
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
|
||||
div:empty::before {
|
||||
content: '';
|
||||
content: "";
|
||||
display: block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
@@ -142,7 +142,8 @@
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
@@ -157,7 +158,7 @@
|
||||
padding: 16px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
transition: all 0.3s ease;
|
||||
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
@@ -173,9 +174,9 @@
|
||||
color: #333;
|
||||
position: relative;
|
||||
padding-left: 8px;
|
||||
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
@@ -248,7 +249,7 @@
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
|
||||
|
||||
&:hover {
|
||||
background: #f1f5f9;
|
||||
transform: translateY(-1px);
|
||||
@@ -294,62 +295,62 @@
|
||||
.home-page {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
|
||||
.stats-grid {
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
|
||||
.stat-card {
|
||||
padding: 10px 6px;
|
||||
}
|
||||
|
||||
|
||||
.stat-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
|
||||
.stat-value {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
|
||||
.stat-label {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
|
||||
.section {
|
||||
padding: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
|
||||
.scene-grid,
|
||||
.today-grid {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
|
||||
.scene-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
|
||||
.scene-value {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
|
||||
.scene-label {
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
|
||||
.today-value {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
|
||||
.today-label {
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
|
||||
.chart-container {
|
||||
min-height: 140px;
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,7 +199,7 @@ const Home: React.FC = () => {
|
||||
<h2 className={style["section-title"]}>场景获客统计</h2>
|
||||
</div>
|
||||
<div className={style["scene-grid"]}>
|
||||
{sceneStats.map((scenario) => (
|
||||
{sceneStats.map(scenario => (
|
||||
<div
|
||||
key={scenario.id}
|
||||
className={style["scene-item"]}
|
||||
|
||||
@@ -85,10 +85,10 @@ const DeviceDetail: React.FC = () => {
|
||||
checked: boolean
|
||||
) => {
|
||||
if (!id) return;
|
||||
setFeatureSaving((prev) => ({ ...prev, [feature]: true }));
|
||||
setFeatureSaving(prev => ({ ...prev, [feature]: true }));
|
||||
try {
|
||||
await updateDeviceTaskConfig({ deviceId: id, [feature]: checked });
|
||||
setDevice((prev) =>
|
||||
setDevice(prev =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
@@ -102,7 +102,7 @@ const DeviceDetail: React.FC = () => {
|
||||
} catch (e: any) {
|
||||
Toast.show({ content: e.message || "设置失败", position: "top" });
|
||||
} finally {
|
||||
setFeatureSaving((prev) => ({ ...prev, [feature]: false }));
|
||||
setFeatureSaving(prev => ({ ...prev, [feature]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -199,7 +199,7 @@ const DeviceDetail: React.FC = () => {
|
||||
}}
|
||||
>
|
||||
{["autoAddFriend", "autoReply", "momentsSync", "aiChat"].map(
|
||||
(f) => (
|
||||
f => (
|
||||
<div
|
||||
key={f}
|
||||
style={{
|
||||
@@ -216,7 +216,7 @@ const DeviceDetail: React.FC = () => {
|
||||
!!device.features?.[f as keyof Device["features"]]
|
||||
}
|
||||
loading={!!featureSaving[f]}
|
||||
onChange={(checked) =>
|
||||
onChange={checked =>
|
||||
handleFeatureChange(
|
||||
f as keyof Device["features"],
|
||||
checked
|
||||
@@ -254,7 +254,7 @@ const DeviceDetail: React.FC = () => {
|
||||
<div
|
||||
style={{ display: "flex", flexDirection: "column", gap: 12 }}
|
||||
>
|
||||
{accounts.map((acc) => (
|
||||
{accounts.map(acc => (
|
||||
<div
|
||||
key={acc.id}
|
||||
style={{
|
||||
@@ -334,7 +334,7 @@ const DeviceDetail: React.FC = () => {
|
||||
<div
|
||||
style={{ display: "flex", flexDirection: "column", gap: 12 }}
|
||||
>
|
||||
{logs.map((log) => (
|
||||
{logs.map(log => (
|
||||
<div
|
||||
key={log.id}
|
||||
style={{
|
||||
|
||||
@@ -57,7 +57,7 @@ const Devices: React.FC = () => {
|
||||
if (search) params.keyword = search;
|
||||
const res = await fetchDeviceList(params);
|
||||
const list = Array.isArray(res.list) ? res.list : [];
|
||||
setDevices((prev) => (reset ? list : [...prev, ...list]));
|
||||
setDevices(prev => (reset ? list : [...prev, ...list]));
|
||||
setTotal(res.total || 0);
|
||||
setHasMore(list.length === 20);
|
||||
if (reset) setPage(1);
|
||||
@@ -81,9 +81,9 @@ const Devices: React.FC = () => {
|
||||
useEffect(() => {
|
||||
if (!hasMore || loading) return;
|
||||
const observer = new window.IntersectionObserver(
|
||||
(entries) => {
|
||||
entries => {
|
||||
if (entries[0].isIntersecting && hasMore && !loading) {
|
||||
setPage((p) => p + 1);
|
||||
setPage(p => p + 1);
|
||||
}
|
||||
},
|
||||
{ threshold: 0.5 }
|
||||
@@ -100,7 +100,7 @@ const Devices: React.FC = () => {
|
||||
}, [page]);
|
||||
|
||||
// 状态筛选
|
||||
const filtered = devices.filter((d) => {
|
||||
const filtered = devices.filter(d => {
|
||||
if (status === "all") return true;
|
||||
if (status === "online") return d.status === "online" || d.alive === 1;
|
||||
if (status === "offline") return d.status === "offline" || d.alive === 0;
|
||||
@@ -222,7 +222,7 @@ const Devices: React.FC = () => {
|
||||
<Input
|
||||
placeholder="搜索设备IMEI/备注"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
prefix={<SearchOutlined />}
|
||||
allowClear
|
||||
style={{ flex: 1 }}
|
||||
@@ -238,7 +238,7 @@ const Devices: React.FC = () => {
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<Tabs
|
||||
activeKey={status}
|
||||
onChange={(k) => setStatus(k as any)}
|
||||
onChange={k => setStatus(k as any)}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<Tabs.Tab title="全部" key="all" />
|
||||
@@ -277,7 +277,7 @@ const Devices: React.FC = () => {
|
||||
<div style={{ padding: 12 }}>
|
||||
{/* 设备列表 */}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
{filtered.map((device) => (
|
||||
{filtered.map(device => (
|
||||
<div
|
||||
key={device.id}
|
||||
style={{
|
||||
@@ -296,15 +296,15 @@ const Devices: React.FC = () => {
|
||||
>
|
||||
<Checkbox
|
||||
checked={selected.includes(device.id)}
|
||||
onChange={(e) => {
|
||||
onChange={e => {
|
||||
e.stopPropagation();
|
||||
setSelected((prev) =>
|
||||
setSelected(prev =>
|
||||
e.target.checked
|
||||
? [...prev, device.id!]
|
||||
: prev.filter((id) => id !== device.id)
|
||||
: prev.filter(id => id !== device.id)
|
||||
);
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{ marginRight: 12 }}
|
||||
/>
|
||||
<div style={{ flex: 1 }}>
|
||||
@@ -404,13 +404,13 @@ const Devices: React.FC = () => {
|
||||
<Input
|
||||
placeholder="设备名称"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onChange={e => setName(e.target.value)}
|
||||
allowClear
|
||||
/>
|
||||
<Input
|
||||
placeholder="设备IMEI"
|
||||
value={imei}
|
||||
onChange={(e) => setImei(e.target.value)}
|
||||
onChange={e => setImei(e.target.value)}
|
||||
allowClear
|
||||
/>
|
||||
<Button
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import request from "@/api/request";
|
||||
// 首页仪表盘总览
|
||||
export function getDashboard() {
|
||||
return request("/v1/dashboard", {}, "GET");
|
||||
}
|
||||
import request from "@/api/request";
|
||||
// 首页仪表盘总览
|
||||
export function getDashboard() {
|
||||
return request("/v1/dashboard", {}, "GET");
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
.user-card {
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
margin: 16px 0 12px 0;
|
||||
padding: 0 0 0 0;
|
||||
}
|
||||
@@ -89,7 +89,7 @@
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.icon-setting{
|
||||
.icon-setting {
|
||||
font-size: 26px;
|
||||
color: #666;
|
||||
position: absolute;
|
||||
@@ -139,35 +139,34 @@
|
||||
padding: 0 12px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
|
||||
:global(.adm-list-item-content-prefix) {
|
||||
margin-right: 12px;
|
||||
color: var(--primary-color);
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
|
||||
:global(.adm-list-item-content-main) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
|
||||
:global(.adm-list-item-title) {
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
|
||||
:global(.adm-list-item-description) {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
|
||||
:global(.adm-list-item-content-arrow) {
|
||||
color: #ccc;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.logout-btn {
|
||||
border-radius: 8px;
|
||||
height: 48px;
|
||||
@@ -180,32 +179,32 @@
|
||||
.mine-page {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
|
||||
.user-info {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
|
||||
.user-avatar {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
background: #666;
|
||||
}
|
||||
|
||||
|
||||
.user-name {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
|
||||
.menu-card {
|
||||
:global(.adm-list-item) {
|
||||
padding: 12px;
|
||||
|
||||
|
||||
:global(.adm-list-item-content-prefix) {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
|
||||
:global(.adm-list-item-title) {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,7 +227,7 @@ const Mine: React.FC = () => {
|
||||
{/* 我的功能 */}
|
||||
<Card className={style["menu-card"]}>
|
||||
<List>
|
||||
{functionModules.map((module) => (
|
||||
{functionModules.map(module => (
|
||||
<List.Item
|
||||
key={module.id}
|
||||
prefix={renderModuleIcon(module)}
|
||||
|
||||
@@ -1,127 +1,127 @@
|
||||
.recharge-page {
|
||||
padding: 16px 0 60px 0;
|
||||
background: #f7f8fa;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.balance-card {
|
||||
margin: 16px;
|
||||
background: #f6ffed;
|
||||
border: 1px solid #b7eb8f;
|
||||
border-radius: 12px;
|
||||
padding: 18px 0 18px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
.balance-content {
|
||||
display: flex;
|
||||
color: #16b364;
|
||||
padding-left: 30px;
|
||||
}
|
||||
.wallet-icon {
|
||||
color: #16b364;
|
||||
font-size: 30px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.balance-info {
|
||||
margin-left: 15px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
.balance-label {
|
||||
font-size: 14px;
|
||||
font-weight: normal;
|
||||
color: #666;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.balance-amount {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #16b364;
|
||||
line-height: 1.1;
|
||||
}
|
||||
}
|
||||
|
||||
.quick-card {
|
||||
margin: 16px;
|
||||
.quick-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
justify-content: flex-start;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.desc-card {
|
||||
margin: 16px;
|
||||
background: #fffbe6;
|
||||
border: 1px solid #ffe58f;
|
||||
}
|
||||
|
||||
.warn-card {
|
||||
margin: 16px;
|
||||
background: #fff2e8;
|
||||
border: 1px solid #ffbb96;
|
||||
}
|
||||
|
||||
.quick-title {
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
font-size: 16px;
|
||||
}
|
||||
.quick-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
justify-content: flex-start;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.quick-btn {
|
||||
min-width: 80px;
|
||||
margin: 4px 0;
|
||||
font-size: 16px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.quick-btn-active {
|
||||
@extend .quick-btn;
|
||||
font-weight: 600;
|
||||
}
|
||||
.recharge-main-btn {
|
||||
margin-top: 16px;
|
||||
font-size: 18px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.desc-title {
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
font-size: 16px;
|
||||
}
|
||||
.desc-text {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
.warn-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #faad14;
|
||||
font-size: 14px;
|
||||
}
|
||||
.warn-icon {
|
||||
font-size: 30px;
|
||||
color: #faad14;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.warn-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.warn-title {
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
}
|
||||
.warn-text {
|
||||
color: #faad14;
|
||||
font-size: 14px;
|
||||
}
|
||||
.recharge-page {
|
||||
padding: 16px 0 60px 0;
|
||||
background: #f7f8fa;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.balance-card {
|
||||
margin: 16px;
|
||||
background: #f6ffed;
|
||||
border: 1px solid #b7eb8f;
|
||||
border-radius: 12px;
|
||||
padding: 18px 0 18px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
.balance-content {
|
||||
display: flex;
|
||||
color: #16b364;
|
||||
padding-left: 30px;
|
||||
}
|
||||
.wallet-icon {
|
||||
color: #16b364;
|
||||
font-size: 30px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.balance-info {
|
||||
margin-left: 15px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
.balance-label {
|
||||
font-size: 14px;
|
||||
font-weight: normal;
|
||||
color: #666;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.balance-amount {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #16b364;
|
||||
line-height: 1.1;
|
||||
}
|
||||
}
|
||||
|
||||
.quick-card {
|
||||
margin: 16px;
|
||||
.quick-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
justify-content: flex-start;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.desc-card {
|
||||
margin: 16px;
|
||||
background: #fffbe6;
|
||||
border: 1px solid #ffe58f;
|
||||
}
|
||||
|
||||
.warn-card {
|
||||
margin: 16px;
|
||||
background: #fff2e8;
|
||||
border: 1px solid #ffbb96;
|
||||
}
|
||||
|
||||
.quick-title {
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
font-size: 16px;
|
||||
}
|
||||
.quick-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
justify-content: flex-start;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.quick-btn {
|
||||
min-width: 80px;
|
||||
margin: 4px 0;
|
||||
font-size: 16px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.quick-btn-active {
|
||||
@extend .quick-btn;
|
||||
font-weight: 600;
|
||||
}
|
||||
.recharge-main-btn {
|
||||
margin-top: 16px;
|
||||
font-size: 18px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.desc-title {
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
font-size: 16px;
|
||||
}
|
||||
.desc-text {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
.warn-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #faad14;
|
||||
font-size: 14px;
|
||||
}
|
||||
.warn-icon {
|
||||
font-size: 30px;
|
||||
color: #faad14;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.warn-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.warn-title {
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
}
|
||||
.warn-text {
|
||||
color: #faad14;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@@ -1,101 +1,101 @@
|
||||
import React, { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Card, Button, Toast, NavBar } from "antd-mobile";
|
||||
import { useUserStore } from "@/store/module/user";
|
||||
import style from "./index.module.scss";
|
||||
import { WalletOutlined, WarningOutlined } from "@ant-design/icons";
|
||||
import NavCommon from "@/components/NavCommon";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
|
||||
const quickAmounts = [50, 100, 200, 500, 1000];
|
||||
|
||||
const Recharge: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useUserStore();
|
||||
// 假设余额从后端接口获取,实际可用props或store传递
|
||||
const [balance, setBalance] = useState(0);
|
||||
const [selected, setSelected] = useState<number | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 充值操作
|
||||
const handleRecharge = async () => {
|
||||
if (!selected) {
|
||||
Toast.show({ content: "请选择充值金额", position: "top" });
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setTimeout(() => {
|
||||
setBalance((b) => b + selected);
|
||||
Toast.show({ content: `充值成功,已到账¥${selected}` });
|
||||
setLoading(false);
|
||||
}, 1200);
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout header={<NavCommon title="账户充值" />}>
|
||||
<div className={style["recharge-page"]}>
|
||||
<Card className={style["balance-card"]}>
|
||||
<div className={style["balance-content"]}>
|
||||
<WalletOutlined className={style["wallet-icon"]} />
|
||||
<div className={style["balance-info"]}>
|
||||
<div className={style["balance-label"]}>当前余额</div>
|
||||
<div className={style["balance-amount"]}>
|
||||
¥{balance.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className={style["quick-card"]}>
|
||||
<div className={style["quick-title"]}>快捷充值</div>
|
||||
<div className={style["quick-list"]}>
|
||||
{quickAmounts.map((amt) => (
|
||||
<Button
|
||||
key={amt}
|
||||
color={selected === amt ? "primary" : "default"}
|
||||
className={
|
||||
selected === amt
|
||||
? style["quick-btn-active"]
|
||||
: style["quick-btn"]
|
||||
}
|
||||
onClick={() => setSelected(amt)}
|
||||
>
|
||||
¥{amt}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
block
|
||||
color="primary"
|
||||
size="large"
|
||||
className={style["recharge-main-btn"]}
|
||||
loading={loading}
|
||||
onClick={handleRecharge}
|
||||
>
|
||||
立即充值
|
||||
</Button>
|
||||
</Card>
|
||||
<Card className={style["desc-card"]}>
|
||||
<div className={style["desc-title"]}>服务消耗</div>
|
||||
<div className={style["desc-text"]}>
|
||||
使用以下服务将从余额中扣除相应费用。
|
||||
</div>
|
||||
</Card>
|
||||
{balance < 10 && (
|
||||
<Card className={style["warn-card"]}>
|
||||
<div className={style["warn-content"]}>
|
||||
<WarningOutlined className={style["warn-icon"]} />
|
||||
<div className={style["warn-info"]}>
|
||||
<div className={style["warn-title"]}>余额不足提醒</div>
|
||||
<div className={style["warn-text"]}>
|
||||
当前余额较低,建议及时充值以免影响服务使用
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Recharge;
|
||||
import React, { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Card, Button, Toast, NavBar } from "antd-mobile";
|
||||
import { useUserStore } from "@/store/module/user";
|
||||
import style from "./index.module.scss";
|
||||
import { WalletOutlined, WarningOutlined } from "@ant-design/icons";
|
||||
import NavCommon from "@/components/NavCommon";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
|
||||
const quickAmounts = [50, 100, 200, 500, 1000];
|
||||
|
||||
const Recharge: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useUserStore();
|
||||
// 假设余额从后端接口获取,实际可用props或store传递
|
||||
const [balance, setBalance] = useState(0);
|
||||
const [selected, setSelected] = useState<number | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 充值操作
|
||||
const handleRecharge = async () => {
|
||||
if (!selected) {
|
||||
Toast.show({ content: "请选择充值金额", position: "top" });
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setTimeout(() => {
|
||||
setBalance(b => b + selected);
|
||||
Toast.show({ content: `充值成功,已到账¥${selected}` });
|
||||
setLoading(false);
|
||||
}, 1200);
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout header={<NavCommon title="账户充值" />}>
|
||||
<div className={style["recharge-page"]}>
|
||||
<Card className={style["balance-card"]}>
|
||||
<div className={style["balance-content"]}>
|
||||
<WalletOutlined className={style["wallet-icon"]} />
|
||||
<div className={style["balance-info"]}>
|
||||
<div className={style["balance-label"]}>当前余额</div>
|
||||
<div className={style["balance-amount"]}>
|
||||
¥{balance.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className={style["quick-card"]}>
|
||||
<div className={style["quick-title"]}>快捷充值</div>
|
||||
<div className={style["quick-list"]}>
|
||||
{quickAmounts.map(amt => (
|
||||
<Button
|
||||
key={amt}
|
||||
color={selected === amt ? "primary" : "default"}
|
||||
className={
|
||||
selected === amt
|
||||
? style["quick-btn-active"]
|
||||
: style["quick-btn"]
|
||||
}
|
||||
onClick={() => setSelected(amt)}
|
||||
>
|
||||
¥{amt}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
block
|
||||
color="primary"
|
||||
size="large"
|
||||
className={style["recharge-main-btn"]}
|
||||
loading={loading}
|
||||
onClick={handleRecharge}
|
||||
>
|
||||
立即充值
|
||||
</Button>
|
||||
</Card>
|
||||
<Card className={style["desc-card"]}>
|
||||
<div className={style["desc-title"]}>服务消耗</div>
|
||||
<div className={style["desc-text"]}>
|
||||
使用以下服务将从余额中扣除相应费用。
|
||||
</div>
|
||||
</Card>
|
||||
{balance < 10 && (
|
||||
<Card className={style["warn-card"]}>
|
||||
<div className={style["warn-content"]}>
|
||||
<WarningOutlined className={style["warn-icon"]} />
|
||||
<div className={style["warn-info"]}>
|
||||
<div className={style["warn-title"]}>余额不足提醒</div>
|
||||
<div className={style["warn-text"]}>
|
||||
当前余额较低,建议及时充值以免影响服务使用
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Recharge;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import request from "@/api/request";
|
||||
|
||||
export function getTrafficPoolDetail(id: string): Promise<any> {
|
||||
return request("/v1/workbench/detail", { id }, "GET");
|
||||
}
|
||||
import request from "@/api/request";
|
||||
|
||||
export function getTrafficPoolDetail(id: string): Promise<any> {
|
||||
return request("/v1/workbench/detail", { id }, "GET");
|
||||
}
|
||||
|
||||
@@ -1,32 +1,32 @@
|
||||
// 用户详情类型
|
||||
export interface TrafficPoolUserDetail {
|
||||
id: number;
|
||||
nickname: string;
|
||||
avatar: string;
|
||||
wechatId: string;
|
||||
status: number | string;
|
||||
addTime: string;
|
||||
lastInteraction: string;
|
||||
deviceName?: string;
|
||||
wechatAccountName?: string;
|
||||
customerServiceName?: string;
|
||||
poolNames?: string[];
|
||||
rfmScore?: {
|
||||
recency: number;
|
||||
frequency: number;
|
||||
monetary: number;
|
||||
segment?: string;
|
||||
};
|
||||
totalSpent?: number;
|
||||
interactionCount?: number;
|
||||
conversionRate?: number;
|
||||
tags?: string[];
|
||||
packages?: string[];
|
||||
interactions?: Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
content: string;
|
||||
timestamp: string;
|
||||
value?: number;
|
||||
}>;
|
||||
}
|
||||
// 用户详情类型
|
||||
export interface TrafficPoolUserDetail {
|
||||
id: number;
|
||||
nickname: string;
|
||||
avatar: string;
|
||||
wechatId: string;
|
||||
status: number | string;
|
||||
addTime: string;
|
||||
lastInteraction: string;
|
||||
deviceName?: string;
|
||||
wechatAccountName?: string;
|
||||
customerServiceName?: string;
|
||||
poolNames?: string[];
|
||||
rfmScore?: {
|
||||
recency: number;
|
||||
frequency: number;
|
||||
monetary: number;
|
||||
segment?: string;
|
||||
};
|
||||
totalSpent?: number;
|
||||
interactionCount?: number;
|
||||
conversionRate?: number;
|
||||
tags?: string[];
|
||||
packages?: string[];
|
||||
interactions?: Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
content: string;
|
||||
timestamp: string;
|
||||
value?: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
@@ -1,300 +1,300 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import { getTrafficPoolDetail } from "./api";
|
||||
import type { TrafficPoolUserDetail } from "./data";
|
||||
import { Card, Button, Avatar, Tag, Spin } from "antd";
|
||||
|
||||
const tabList = [
|
||||
{ key: "base", label: "基本信息" },
|
||||
{ key: "journey", label: "用户旅程" },
|
||||
{ key: "tags", label: "用户标签" },
|
||||
];
|
||||
|
||||
const TrafficPoolDetail: React.FC = () => {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [user, setUser] = useState<TrafficPoolUserDetail | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<"base" | "journey" | "tags">(
|
||||
"base"
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
getTrafficPoolDetail(id as string)
|
||||
.then((res) => setUser(res))
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Layout>
|
||||
<div style={{ textAlign: "center", padding: "64px 0" }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
if (!user) {
|
||||
return (
|
||||
<Layout>
|
||||
<div style={{ textAlign: "center", color: "#aaa", padding: "64px 0" }}>
|
||||
未找到该用户
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
height: 48,
|
||||
borderBottom: "1px solid #eee",
|
||||
background: "#fff",
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="link"
|
||||
onClick={() => navigate(-1)}
|
||||
style={{ marginRight: 8 }}
|
||||
>
|
||||
< 返回
|
||||
</Button>
|
||||
<div style={{ fontWeight: 600, fontSize: 18 }}>用户详情</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div style={{ padding: 16 }}>
|
||||
{/* 顶部信息 */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 16,
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<Avatar src={user.avatar} size={64} />
|
||||
<div>
|
||||
<div style={{ fontSize: 20, fontWeight: 600 }}>{user.nickname}</div>
|
||||
<div style={{ color: "#1677ff", fontSize: 14, margin: "4px 0" }}>
|
||||
{user.wechatId}
|
||||
</div>
|
||||
{user.packages &&
|
||||
user.packages.length > 0 &&
|
||||
user.packages.map((pkg) => (
|
||||
<Tag color="purple" key={pkg} style={{ marginRight: 4 }}>
|
||||
{pkg}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Tab栏 */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 24,
|
||||
borderBottom: "1px solid #eee",
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
{tabList.map((tab) => (
|
||||
<div
|
||||
key={tab.key}
|
||||
style={{
|
||||
padding: "8px 0",
|
||||
fontWeight: 500,
|
||||
color: activeTab === tab.key ? "#1677ff" : "#888",
|
||||
borderBottom:
|
||||
activeTab === tab.key ? "2px solid #1677ff" : "none",
|
||||
cursor: "pointer",
|
||||
fontSize: 16,
|
||||
}}
|
||||
onClick={() => setActiveTab(tab.key as any)}
|
||||
>
|
||||
{tab.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Tab内容 */}
|
||||
{activeTab === "base" && (
|
||||
<>
|
||||
<Card style={{ marginBottom: 16 }} title="关键信息">
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 24 }}>
|
||||
<div>设备:{user.deviceName || "--"}</div>
|
||||
<div>微信号:{user.wechatAccountName || "--"}</div>
|
||||
<div>客服:{user.customerServiceName || "--"}</div>
|
||||
<div>添加时间:{user.addTime || "--"}</div>
|
||||
<div>最近互动:{user.lastInteraction || "--"}</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card style={{ marginBottom: 16 }} title="RFM评分">
|
||||
<div style={{ display: "flex", gap: 32 }}>
|
||||
<div>
|
||||
<div
|
||||
style={{ fontSize: 20, fontWeight: 600, color: "#1677ff" }}
|
||||
>
|
||||
{user.rfmScore?.recency ?? "-"}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "#888" }}>最近性(R)</div>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
style={{ fontSize: 20, fontWeight: 600, color: "#52c41a" }}
|
||||
>
|
||||
{user.rfmScore?.frequency ?? "-"}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "#888" }}>频率(F)</div>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
style={{ fontSize: 20, fontWeight: 600, color: "#eb2f96" }}
|
||||
>
|
||||
{user.rfmScore?.monetary ?? "-"}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "#888" }}>金额(M)</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card style={{ marginBottom: 16 }} title="统计数据">
|
||||
<div style={{ display: "flex", gap: 32 }}>
|
||||
<div>
|
||||
<div
|
||||
style={{ fontSize: 18, fontWeight: 600, color: "#52c41a" }}
|
||||
>
|
||||
¥{user.totalSpent ?? "-"}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "#888" }}>总消费</div>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
style={{ fontSize: 18, fontWeight: 600, color: "#1677ff" }}
|
||||
>
|
||||
{user.interactionCount ?? "-"}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "#888" }}>互动次数</div>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
style={{ fontSize: 18, fontWeight: 600, color: "#faad14" }}
|
||||
>
|
||||
{user.conversionRate ?? "-"}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "#888" }}>转化率</div>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
style={{ fontSize: 18, fontWeight: 600, color: "#ff4d4f" }}
|
||||
>
|
||||
{user.status === "failed"
|
||||
? "添加失败"
|
||||
: user.status === "added"
|
||||
? "添加成功"
|
||||
: "未添加"}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "#888" }}>添加状态</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
{activeTab === "journey" && (
|
||||
<Card title="互动记录">
|
||||
{user.interactions && user.interactions.length > 0 ? (
|
||||
user.interactions.slice(0, 4).map((it) => (
|
||||
<div
|
||||
key={it.id}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
borderBottom: "1px solid #f0f0f0",
|
||||
padding: "12px 0",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 22 }}>
|
||||
{it.type === "click" && "📱"}
|
||||
{it.type === "message" && "💬"}
|
||||
{it.type === "purchase" && "💲"}
|
||||
{it.type === "view" && "👁️"}
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontWeight: 500 }}>
|
||||
{it.type === "click" && "点击行为"}
|
||||
{it.type === "message" && "消息互动"}
|
||||
{it.type === "purchase" && "购买行为"}
|
||||
{it.type === "view" && "页面浏览"}
|
||||
</div>
|
||||
<div style={{ color: "#888", fontSize: 13 }}>
|
||||
{it.content}
|
||||
{it.type === "purchase" && it.value && (
|
||||
<span
|
||||
style={{
|
||||
color: "#52c41a",
|
||||
fontWeight: 600,
|
||||
marginLeft: 4,
|
||||
}}
|
||||
>
|
||||
¥{it.value}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "#aaa",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{it.timestamp}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
color: "#aaa",
|
||||
textAlign: "center",
|
||||
padding: "24px 0",
|
||||
}}
|
||||
>
|
||||
暂无互动记录
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
{activeTab === "tags" && (
|
||||
<Card title="用户标签">
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
{user.tags && user.tags.length > 0 ? (
|
||||
user.tags.map((tag) => (
|
||||
<Tag
|
||||
key={tag}
|
||||
color="blue"
|
||||
style={{ marginRight: 8, marginBottom: 8 }}
|
||||
>
|
||||
{tag}
|
||||
</Tag>
|
||||
))
|
||||
) : (
|
||||
<span style={{ color: "#aaa" }}>暂无标签</span>
|
||||
)}
|
||||
</div>
|
||||
<Button type="dashed" block>
|
||||
➕ 添加新标签
|
||||
</Button>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default TrafficPoolDetail;
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import { getTrafficPoolDetail } from "./api";
|
||||
import type { TrafficPoolUserDetail } from "./data";
|
||||
import { Card, Button, Avatar, Tag, Spin } from "antd";
|
||||
|
||||
const tabList = [
|
||||
{ key: "base", label: "基本信息" },
|
||||
{ key: "journey", label: "用户旅程" },
|
||||
{ key: "tags", label: "用户标签" },
|
||||
];
|
||||
|
||||
const TrafficPoolDetail: React.FC = () => {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [user, setUser] = useState<TrafficPoolUserDetail | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<"base" | "journey" | "tags">(
|
||||
"base"
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
getTrafficPoolDetail(id as string)
|
||||
.then(res => setUser(res))
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Layout>
|
||||
<div style={{ textAlign: "center", padding: "64px 0" }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
if (!user) {
|
||||
return (
|
||||
<Layout>
|
||||
<div style={{ textAlign: "center", color: "#aaa", padding: "64px 0" }}>
|
||||
未找到该用户
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
height: 48,
|
||||
borderBottom: "1px solid #eee",
|
||||
background: "#fff",
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="link"
|
||||
onClick={() => navigate(-1)}
|
||||
style={{ marginRight: 8 }}
|
||||
>
|
||||
< 返回
|
||||
</Button>
|
||||
<div style={{ fontWeight: 600, fontSize: 18 }}>用户详情</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div style={{ padding: 16 }}>
|
||||
{/* 顶部信息 */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 16,
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<Avatar src={user.avatar} size={64} />
|
||||
<div>
|
||||
<div style={{ fontSize: 20, fontWeight: 600 }}>{user.nickname}</div>
|
||||
<div style={{ color: "#1677ff", fontSize: 14, margin: "4px 0" }}>
|
||||
{user.wechatId}
|
||||
</div>
|
||||
{user.packages &&
|
||||
user.packages.length > 0 &&
|
||||
user.packages.map(pkg => (
|
||||
<Tag color="purple" key={pkg} style={{ marginRight: 4 }}>
|
||||
{pkg}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Tab栏 */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 24,
|
||||
borderBottom: "1px solid #eee",
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
{tabList.map(tab => (
|
||||
<div
|
||||
key={tab.key}
|
||||
style={{
|
||||
padding: "8px 0",
|
||||
fontWeight: 500,
|
||||
color: activeTab === tab.key ? "#1677ff" : "#888",
|
||||
borderBottom:
|
||||
activeTab === tab.key ? "2px solid #1677ff" : "none",
|
||||
cursor: "pointer",
|
||||
fontSize: 16,
|
||||
}}
|
||||
onClick={() => setActiveTab(tab.key as any)}
|
||||
>
|
||||
{tab.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Tab内容 */}
|
||||
{activeTab === "base" && (
|
||||
<>
|
||||
<Card style={{ marginBottom: 16 }} title="关键信息">
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 24 }}>
|
||||
<div>设备:{user.deviceName || "--"}</div>
|
||||
<div>微信号:{user.wechatAccountName || "--"}</div>
|
||||
<div>客服:{user.customerServiceName || "--"}</div>
|
||||
<div>添加时间:{user.addTime || "--"}</div>
|
||||
<div>最近互动:{user.lastInteraction || "--"}</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card style={{ marginBottom: 16 }} title="RFM评分">
|
||||
<div style={{ display: "flex", gap: 32 }}>
|
||||
<div>
|
||||
<div
|
||||
style={{ fontSize: 20, fontWeight: 600, color: "#1677ff" }}
|
||||
>
|
||||
{user.rfmScore?.recency ?? "-"}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "#888" }}>最近性(R)</div>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
style={{ fontSize: 20, fontWeight: 600, color: "#52c41a" }}
|
||||
>
|
||||
{user.rfmScore?.frequency ?? "-"}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "#888" }}>频率(F)</div>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
style={{ fontSize: 20, fontWeight: 600, color: "#eb2f96" }}
|
||||
>
|
||||
{user.rfmScore?.monetary ?? "-"}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "#888" }}>金额(M)</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card style={{ marginBottom: 16 }} title="统计数据">
|
||||
<div style={{ display: "flex", gap: 32 }}>
|
||||
<div>
|
||||
<div
|
||||
style={{ fontSize: 18, fontWeight: 600, color: "#52c41a" }}
|
||||
>
|
||||
¥{user.totalSpent ?? "-"}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "#888" }}>总消费</div>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
style={{ fontSize: 18, fontWeight: 600, color: "#1677ff" }}
|
||||
>
|
||||
{user.interactionCount ?? "-"}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "#888" }}>互动次数</div>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
style={{ fontSize: 18, fontWeight: 600, color: "#faad14" }}
|
||||
>
|
||||
{user.conversionRate ?? "-"}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "#888" }}>转化率</div>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
style={{ fontSize: 18, fontWeight: 600, color: "#ff4d4f" }}
|
||||
>
|
||||
{user.status === "failed"
|
||||
? "添加失败"
|
||||
: user.status === "added"
|
||||
? "添加成功"
|
||||
: "未添加"}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "#888" }}>添加状态</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
{activeTab === "journey" && (
|
||||
<Card title="互动记录">
|
||||
{user.interactions && user.interactions.length > 0 ? (
|
||||
user.interactions.slice(0, 4).map(it => (
|
||||
<div
|
||||
key={it.id}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
borderBottom: "1px solid #f0f0f0",
|
||||
padding: "12px 0",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 22 }}>
|
||||
{it.type === "click" && "📱"}
|
||||
{it.type === "message" && "💬"}
|
||||
{it.type === "purchase" && "💲"}
|
||||
{it.type === "view" && "👁️"}
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontWeight: 500 }}>
|
||||
{it.type === "click" && "点击行为"}
|
||||
{it.type === "message" && "消息互动"}
|
||||
{it.type === "purchase" && "购买行为"}
|
||||
{it.type === "view" && "页面浏览"}
|
||||
</div>
|
||||
<div style={{ color: "#888", fontSize: 13 }}>
|
||||
{it.content}
|
||||
{it.type === "purchase" && it.value && (
|
||||
<span
|
||||
style={{
|
||||
color: "#52c41a",
|
||||
fontWeight: 600,
|
||||
marginLeft: 4,
|
||||
}}
|
||||
>
|
||||
¥{it.value}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "#aaa",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{it.timestamp}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
color: "#aaa",
|
||||
textAlign: "center",
|
||||
padding: "24px 0",
|
||||
}}
|
||||
>
|
||||
暂无互动记录
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
{activeTab === "tags" && (
|
||||
<Card title="用户标签">
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
{user.tags && user.tags.length > 0 ? (
|
||||
user.tags.map(tag => (
|
||||
<Tag
|
||||
key={tag}
|
||||
color="blue"
|
||||
style={{ marginRight: 8, marginBottom: 8 }}
|
||||
>
|
||||
{tag}
|
||||
</Tag>
|
||||
))
|
||||
) : (
|
||||
<span style={{ color: "#aaa" }}>暂无标签</span>
|
||||
)}
|
||||
</div>
|
||||
<Button type="dashed" block>
|
||||
➕ 添加新标签
|
||||
</Button>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default TrafficPoolDetail;
|
||||
|
||||
@@ -1,47 +1,47 @@
|
||||
import React from "react";
|
||||
import { Modal, Selector } from "antd-mobile";
|
||||
import type { PackageOption } from "./data";
|
||||
|
||||
interface BatchAddModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
packageOptions: PackageOption[];
|
||||
batchTarget: string;
|
||||
setBatchTarget: (v: string) => void;
|
||||
selectedCount: number;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
const BatchAddModal: React.FC<BatchAddModalProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
packageOptions,
|
||||
batchTarget,
|
||||
setBatchTarget,
|
||||
selectedCount,
|
||||
onConfirm,
|
||||
}) => (
|
||||
<Modal
|
||||
visible={visible}
|
||||
title="批量加入分组"
|
||||
onClose={onClose}
|
||||
footer={[
|
||||
{ text: "取消", onClick: onClose },
|
||||
{ text: "确定", onClick: onConfirm },
|
||||
]}
|
||||
>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div>选择目标分组</div>
|
||||
<Selector
|
||||
options={packageOptions.map((p) => ({ label: p.name, value: p.id }))}
|
||||
value={[batchTarget]}
|
||||
onChange={(v) => setBatchTarget(v[0])}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ color: "#888", fontSize: 13 }}>
|
||||
将选中的{selectedCount}个用户加入所选分组
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
export default BatchAddModal;
|
||||
import React from "react";
|
||||
import { Modal, Selector } from "antd-mobile";
|
||||
import type { PackageOption } from "./data";
|
||||
|
||||
interface BatchAddModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
packageOptions: PackageOption[];
|
||||
batchTarget: string;
|
||||
setBatchTarget: (v: string) => void;
|
||||
selectedCount: number;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
const BatchAddModal: React.FC<BatchAddModalProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
packageOptions,
|
||||
batchTarget,
|
||||
setBatchTarget,
|
||||
selectedCount,
|
||||
onConfirm,
|
||||
}) => (
|
||||
<Modal
|
||||
visible={visible}
|
||||
title="批量加入分组"
|
||||
onClose={onClose}
|
||||
footer={[
|
||||
{ text: "取消", onClick: onClose },
|
||||
{ text: "确定", onClick: onConfirm },
|
||||
]}
|
||||
>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div>选择目标分组</div>
|
||||
<Selector
|
||||
options={packageOptions.map(p => ({ label: p.name, value: p.id }))}
|
||||
value={[batchTarget]}
|
||||
onChange={v => setBatchTarget(v[0])}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ color: "#888", fontSize: 13 }}>
|
||||
将选中的{selectedCount}个用户加入所选分组
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
export default BatchAddModal;
|
||||
|
||||
@@ -1,84 +1,84 @@
|
||||
import React from "react";
|
||||
import { Card, Button } from "antd-mobile";
|
||||
|
||||
interface DataAnalysisPanelProps {
|
||||
stats: {
|
||||
total: number;
|
||||
highValue: number;
|
||||
added: number;
|
||||
pending: number;
|
||||
failed: number;
|
||||
addSuccessRate: number;
|
||||
};
|
||||
showStats: boolean;
|
||||
setShowStats: (v: boolean) => void;
|
||||
}
|
||||
|
||||
const DataAnalysisPanel: React.FC<DataAnalysisPanelProps> = ({
|
||||
stats,
|
||||
showStats,
|
||||
setShowStats,
|
||||
}) => {
|
||||
if (!showStats) return null;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: "#fff",
|
||||
padding: "16px",
|
||||
margin: "8px 0",
|
||||
borderRadius: 8,
|
||||
boxShadow: "0 2px 8px rgba(0,0,0,0.04)",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", gap: 16, marginBottom: 12 }}>
|
||||
<Card style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 18, fontWeight: 600, color: "#1677ff" }}>
|
||||
{stats.total}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: "#888" }}>总用户数</div>
|
||||
</Card>
|
||||
<Card style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 18, fontWeight: 600, color: "#eb2f96" }}>
|
||||
{stats.highValue}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: "#888" }}>高价值用户</div>
|
||||
</Card>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 16 }}>
|
||||
<Card style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 18, fontWeight: 600, color: "#52c41a" }}>
|
||||
{stats.addSuccessRate}%
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: "#888" }}>添加成功率</div>
|
||||
</Card>
|
||||
<Card style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 18, fontWeight: 600, color: "#faad14" }}>
|
||||
{stats.added}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: "#888" }}>已添加</div>
|
||||
</Card>
|
||||
<Card style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 18, fontWeight: 600, color: "#bfbfbf" }}>
|
||||
{stats.pending}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: "#888" }}>待添加</div>
|
||||
</Card>
|
||||
<Card style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 18, fontWeight: 600, color: "#ff4d4f" }}>
|
||||
{stats.failed}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: "#888" }}>添加失败</div>
|
||||
</Card>
|
||||
</div>
|
||||
<Button
|
||||
size="small"
|
||||
style={{ marginTop: 12 }}
|
||||
onClick={() => setShowStats(false)}
|
||||
>
|
||||
收起分析
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DataAnalysisPanel;
|
||||
import React from "react";
|
||||
import { Card, Button } from "antd-mobile";
|
||||
|
||||
interface DataAnalysisPanelProps {
|
||||
stats: {
|
||||
total: number;
|
||||
highValue: number;
|
||||
added: number;
|
||||
pending: number;
|
||||
failed: number;
|
||||
addSuccessRate: number;
|
||||
};
|
||||
showStats: boolean;
|
||||
setShowStats: (v: boolean) => void;
|
||||
}
|
||||
|
||||
const DataAnalysisPanel: React.FC<DataAnalysisPanelProps> = ({
|
||||
stats,
|
||||
showStats,
|
||||
setShowStats,
|
||||
}) => {
|
||||
if (!showStats) return null;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: "#fff",
|
||||
padding: "16px",
|
||||
margin: "8px 0",
|
||||
borderRadius: 8,
|
||||
boxShadow: "0 2px 8px rgba(0,0,0,0.04)",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", gap: 16, marginBottom: 12 }}>
|
||||
<Card style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 18, fontWeight: 600, color: "#1677ff" }}>
|
||||
{stats.total}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: "#888" }}>总用户数</div>
|
||||
</Card>
|
||||
<Card style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 18, fontWeight: 600, color: "#eb2f96" }}>
|
||||
{stats.highValue}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: "#888" }}>高价值用户</div>
|
||||
</Card>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 16 }}>
|
||||
<Card style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 18, fontWeight: 600, color: "#52c41a" }}>
|
||||
{stats.addSuccessRate}%
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: "#888" }}>添加成功率</div>
|
||||
</Card>
|
||||
<Card style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 18, fontWeight: 600, color: "#faad14" }}>
|
||||
{stats.added}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: "#888" }}>已添加</div>
|
||||
</Card>
|
||||
<Card style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 18, fontWeight: 600, color: "#bfbfbf" }}>
|
||||
{stats.pending}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: "#888" }}>待添加</div>
|
||||
</Card>
|
||||
<Card style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 18, fontWeight: 600, color: "#ff4d4f" }}>
|
||||
{stats.failed}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: "#888" }}>添加失败</div>
|
||||
</Card>
|
||||
</div>
|
||||
<Button
|
||||
size="small"
|
||||
style={{ marginTop: 12 }}
|
||||
onClick={() => setShowStats(false)}
|
||||
>
|
||||
收起分析
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DataAnalysisPanel;
|
||||
|
||||
@@ -1,118 +1,118 @@
|
||||
import React from "react";
|
||||
import { Popup } from "antd-mobile";
|
||||
import { Select, Button } from "antd";
|
||||
import type {
|
||||
DeviceOption,
|
||||
PackageOption,
|
||||
ValueLevel,
|
||||
UserStatus,
|
||||
} from "./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;
|
||||
}
|
||||
|
||||
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 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>
|
||||
);
|
||||
|
||||
export default FilterModal;
|
||||
import React from "react";
|
||||
import { Popup } from "antd-mobile";
|
||||
import { Select, Button } from "antd";
|
||||
import type {
|
||||
DeviceOption,
|
||||
PackageOption,
|
||||
ValueLevel,
|
||||
UserStatus,
|
||||
} from "./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;
|
||||
}
|
||||
|
||||
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 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>
|
||||
);
|
||||
|
||||
export default FilterModal;
|
||||
|
||||
@@ -1,45 +1,45 @@
|
||||
// 流量池用户类型
|
||||
export interface TrafficPoolUser {
|
||||
id: number;
|
||||
identifier: string;
|
||||
mobile: string;
|
||||
wechatId: string;
|
||||
fromd: string;
|
||||
status: number;
|
||||
createTime: string;
|
||||
companyId: number;
|
||||
sourceId: string;
|
||||
type: number;
|
||||
nickname: string;
|
||||
avatar: string;
|
||||
gender: number;
|
||||
phone: string;
|
||||
packages: string[];
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
// 列表响应类型
|
||||
export interface TrafficPoolUserListResponse {
|
||||
list: TrafficPoolUser[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
// 设备类型
|
||||
export interface DeviceOption {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// 分组类型
|
||||
export interface PackageOption {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// 用户价值类型
|
||||
export type ValueLevel = "all" | "high" | "medium" | "low";
|
||||
|
||||
// 状态类型
|
||||
export type UserStatus = "all" | "added" | "pending" | "failed" | "duplicate";
|
||||
// 流量池用户类型
|
||||
export interface TrafficPoolUser {
|
||||
id: number;
|
||||
identifier: string;
|
||||
mobile: string;
|
||||
wechatId: string;
|
||||
fromd: string;
|
||||
status: number;
|
||||
createTime: string;
|
||||
companyId: number;
|
||||
sourceId: string;
|
||||
type: number;
|
||||
nickname: string;
|
||||
avatar: string;
|
||||
gender: number;
|
||||
phone: string;
|
||||
packages: string[];
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
// 列表响应类型
|
||||
export interface TrafficPoolUserListResponse {
|
||||
list: TrafficPoolUser[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
// 设备类型
|
||||
export interface DeviceOption {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// 分组类型
|
||||
export interface PackageOption {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// 用户价值类型
|
||||
export type ValueLevel = "all" | "high" | "medium" | "low";
|
||||
|
||||
// 状态类型
|
||||
export type UserStatus = "all" | "added" | "pending" | "failed" | "duplicate";
|
||||
|
||||
@@ -1,160 +1,158 @@
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import {
|
||||
fetchTrafficPoolList,
|
||||
fetchDeviceOptions,
|
||||
fetchPackageOptions,
|
||||
} from "./api";
|
||||
import type {
|
||||
TrafficPoolUser,
|
||||
DeviceOption,
|
||||
PackageOption,
|
||||
ValueLevel,
|
||||
UserStatus,
|
||||
} from "./data";
|
||||
import { Toast } from "antd-mobile";
|
||||
|
||||
export function useTrafficPoolListLogic() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [list, setList] = useState<TrafficPoolUser[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize] = useState(10);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
// 筛选相关
|
||||
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 [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||
const [batchModal, setBatchModal] = useState(false);
|
||||
const [batchTarget, setBatchTarget] = useState<string>("");
|
||||
|
||||
// 数据分析
|
||||
const [showStats, setShowStats] = useState(false);
|
||||
const stats = useMemo(() => {
|
||||
const total = list.length;
|
||||
const highValue = list.filter((u) =>
|
||||
u.tags.includes("高价值客户池")
|
||||
).length;
|
||||
const added = list.filter((u) => u.status === 1).length;
|
||||
const pending = list.filter((u) => u.status === 0).length;
|
||||
const failed = list.filter((u) => u.status === -1).length;
|
||||
const addSuccessRate = total ? Math.round((added / total) * 100) : 0;
|
||||
return { total, highValue, added, pending, failed, addSuccessRate };
|
||||
}, [list]);
|
||||
|
||||
// 获取列表
|
||||
const getList = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetchTrafficPoolList({
|
||||
page,
|
||||
pageSize,
|
||||
keyword: search,
|
||||
// deviceId,
|
||||
// packageId,
|
||||
// valueLevel,
|
||||
// userStatus,
|
||||
});
|
||||
setList(res.list || []);
|
||||
setTotal(res.total || 0);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取筛选项
|
||||
useEffect(() => {
|
||||
fetchDeviceOptions().then(setDeviceOptions);
|
||||
fetchPackageOptions().then(setPackageOptions);
|
||||
}, []);
|
||||
|
||||
// 筛选条件变化时刷新列表
|
||||
useEffect(() => {
|
||||
getList();
|
||||
// eslint-disable-next-line
|
||||
}, [page, search /*, deviceId, packageId, valueLevel, userStatus*/]);
|
||||
|
||||
// 全选/反选
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedIds(list.map((item) => item.id));
|
||||
} else {
|
||||
setSelectedIds([]);
|
||||
}
|
||||
};
|
||||
// 单选
|
||||
const handleSelect = (id: number, checked: boolean) => {
|
||||
setSelectedIds((prev) =>
|
||||
checked ? [...prev, id] : prev.filter((i) => i !== id)
|
||||
);
|
||||
};
|
||||
|
||||
// 批量加入分组/流量池
|
||||
const handleBatchAdd = () => {
|
||||
if (!batchTarget) {
|
||||
Toast.show({ content: "请选择目标分组", position: "top" });
|
||||
return;
|
||||
}
|
||||
// TODO: 调用后端批量接口,这里仅模拟
|
||||
Toast.show({
|
||||
content: `已将${selectedIds.length}个用户加入${packageOptions.find((p) => p.id === batchTarget)?.name || ""}`,
|
||||
position: "top",
|
||||
});
|
||||
setBatchModal(false);
|
||||
setSelectedIds([]);
|
||||
setBatchTarget("");
|
||||
// 可刷新列表
|
||||
};
|
||||
|
||||
// 筛选重置
|
||||
const resetFilter = () => {
|
||||
setDeviceId("all");
|
||||
setPackageId("all");
|
||||
setValueLevel("all");
|
||||
setUserStatus("all");
|
||||
};
|
||||
|
||||
return {
|
||||
loading,
|
||||
list,
|
||||
page,
|
||||
setPage,
|
||||
pageSize,
|
||||
total,
|
||||
search,
|
||||
setSearch,
|
||||
showFilter,
|
||||
setShowFilter,
|
||||
deviceOptions,
|
||||
packageOptions,
|
||||
deviceId,
|
||||
setDeviceId,
|
||||
packageId,
|
||||
setPackageId,
|
||||
valueLevel,
|
||||
setValueLevel,
|
||||
userStatus,
|
||||
setUserStatus,
|
||||
selectedIds,
|
||||
setSelectedIds,
|
||||
handleSelectAll,
|
||||
handleSelect,
|
||||
batchModal,
|
||||
setBatchModal,
|
||||
batchTarget,
|
||||
setBatchTarget,
|
||||
handleBatchAdd,
|
||||
showStats,
|
||||
setShowStats,
|
||||
stats,
|
||||
getList,
|
||||
resetFilter,
|
||||
};
|
||||
}
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import {
|
||||
fetchTrafficPoolList,
|
||||
fetchDeviceOptions,
|
||||
fetchPackageOptions,
|
||||
} from "./api";
|
||||
import type {
|
||||
TrafficPoolUser,
|
||||
DeviceOption,
|
||||
PackageOption,
|
||||
ValueLevel,
|
||||
UserStatus,
|
||||
} from "./data";
|
||||
import { Toast } from "antd-mobile";
|
||||
|
||||
export function useTrafficPoolListLogic() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [list, setList] = useState<TrafficPoolUser[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize] = useState(10);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
// 筛选相关
|
||||
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 [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||
const [batchModal, setBatchModal] = useState(false);
|
||||
const [batchTarget, setBatchTarget] = useState<string>("");
|
||||
|
||||
// 数据分析
|
||||
const [showStats, setShowStats] = useState(false);
|
||||
const stats = useMemo(() => {
|
||||
const total = list.length;
|
||||
const highValue = list.filter(u => u.tags.includes("高价值客户池")).length;
|
||||
const added = list.filter(u => u.status === 1).length;
|
||||
const pending = list.filter(u => u.status === 0).length;
|
||||
const failed = list.filter(u => u.status === -1).length;
|
||||
const addSuccessRate = total ? Math.round((added / total) * 100) : 0;
|
||||
return { total, highValue, added, pending, failed, addSuccessRate };
|
||||
}, [list]);
|
||||
|
||||
// 获取列表
|
||||
const getList = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetchTrafficPoolList({
|
||||
page,
|
||||
pageSize,
|
||||
keyword: search,
|
||||
// deviceId,
|
||||
// packageId,
|
||||
// valueLevel,
|
||||
// userStatus,
|
||||
});
|
||||
setList(res.list || []);
|
||||
setTotal(res.total || 0);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取筛选项
|
||||
useEffect(() => {
|
||||
fetchDeviceOptions().then(setDeviceOptions);
|
||||
fetchPackageOptions().then(setPackageOptions);
|
||||
}, []);
|
||||
|
||||
// 筛选条件变化时刷新列表
|
||||
useEffect(() => {
|
||||
getList();
|
||||
// eslint-disable-next-line
|
||||
}, [page, search /*, deviceId, packageId, valueLevel, userStatus*/]);
|
||||
|
||||
// 全选/反选
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedIds(list.map(item => item.id));
|
||||
} else {
|
||||
setSelectedIds([]);
|
||||
}
|
||||
};
|
||||
// 单选
|
||||
const handleSelect = (id: number, checked: boolean) => {
|
||||
setSelectedIds(prev =>
|
||||
checked ? [...prev, id] : prev.filter(i => i !== id)
|
||||
);
|
||||
};
|
||||
|
||||
// 批量加入分组/流量池
|
||||
const handleBatchAdd = () => {
|
||||
if (!batchTarget) {
|
||||
Toast.show({ content: "请选择目标分组", position: "top" });
|
||||
return;
|
||||
}
|
||||
// TODO: 调用后端批量接口,这里仅模拟
|
||||
Toast.show({
|
||||
content: `已将${selectedIds.length}个用户加入${packageOptions.find(p => p.id === batchTarget)?.name || ""}`,
|
||||
position: "top",
|
||||
});
|
||||
setBatchModal(false);
|
||||
setSelectedIds([]);
|
||||
setBatchTarget("");
|
||||
// 可刷新列表
|
||||
};
|
||||
|
||||
// 筛选重置
|
||||
const resetFilter = () => {
|
||||
setDeviceId("all");
|
||||
setPackageId("all");
|
||||
setValueLevel("all");
|
||||
setUserStatus("all");
|
||||
};
|
||||
|
||||
return {
|
||||
loading,
|
||||
list,
|
||||
page,
|
||||
setPage,
|
||||
pageSize,
|
||||
total,
|
||||
search,
|
||||
setSearch,
|
||||
showFilter,
|
||||
setShowFilter,
|
||||
deviceOptions,
|
||||
packageOptions,
|
||||
deviceId,
|
||||
setDeviceId,
|
||||
packageId,
|
||||
setPackageId,
|
||||
valueLevel,
|
||||
setValueLevel,
|
||||
userStatus,
|
||||
setUserStatus,
|
||||
selectedIds,
|
||||
setSelectedIds,
|
||||
handleSelectAll,
|
||||
handleSelect,
|
||||
batchModal,
|
||||
setBatchModal,
|
||||
batchTarget,
|
||||
setBatchTarget,
|
||||
handleBatchAdd,
|
||||
showStats,
|
||||
setShowStats,
|
||||
stats,
|
||||
getList,
|
||||
resetFilter,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
.listWrap {
|
||||
padding: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.cardContent{
|
||||
.cardContent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
position: relative;
|
||||
}
|
||||
.checkbox{
|
||||
.checkbox {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
.cardWrap{
|
||||
.cardWrap {
|
||||
background: #fff;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ const TrafficPoolList: React.FC = () => {
|
||||
title="流量池用户列表"
|
||||
right={
|
||||
<Button
|
||||
onClick={() => setShowStats((s) => !s)}
|
||||
onClick={() => setShowStats(s => !s)}
|
||||
style={{ marginLeft: 8 }}
|
||||
>
|
||||
<BarChartOutlined /> {showStats ? "收起分析" : "数据分析"}
|
||||
@@ -92,7 +92,7 @@ const TrafficPoolList: React.FC = () => {
|
||||
<Input
|
||||
placeholder="搜索计划名称"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
prefix={<SearchOutlined />}
|
||||
allowClear
|
||||
size="large"
|
||||
@@ -124,7 +124,7 @@ const TrafficPoolList: React.FC = () => {
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedIds.length === list.length && list.length > 0}
|
||||
onChange={(e) => handleSelectAll(e.target.checked)}
|
||||
onChange={e => handleSelectAll(e.target.checked)}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
<span>全选</span>
|
||||
@@ -186,7 +186,7 @@ const TrafficPoolList: React.FC = () => {
|
||||
<Empty description="暂无数据" />
|
||||
) : (
|
||||
<div>
|
||||
{list.map((item) => (
|
||||
{list.map(item => (
|
||||
<div key={item.id} className={styles.cardWrap}>
|
||||
<div
|
||||
className={styles.card}
|
||||
@@ -196,9 +196,9 @@ const TrafficPoolList: React.FC = () => {
|
||||
<div className={styles.cardContent}>
|
||||
<Checkbox
|
||||
checked={selectedIds.includes(item.id)}
|
||||
onChange={(e) => handleSelect(item.id, e.target.checked)}
|
||||
onChange={e => handleSelect(item.id, e.target.checked)}
|
||||
style={{ marginRight: 8 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onClick={e => e.stopPropagation()}
|
||||
className={styles.checkbox}
|
||||
/>
|
||||
<Avatar
|
||||
|
||||
@@ -1,115 +1,115 @@
|
||||
.user-set-page {
|
||||
background: #f7f8fa;
|
||||
}
|
||||
.user-card {
|
||||
margin: 18px 16px 0 16px;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
|
||||
}
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 24px 20px 20px 20px;
|
||||
}
|
||||
.avatar {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
background: #f5f5f5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 30px;
|
||||
font-weight: 700;
|
||||
color: #1890ff;
|
||||
margin-right: 22px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.avatar-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 30px;
|
||||
font-weight: 700;
|
||||
color: #1890ff;
|
||||
background: #e6f7ff;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.info-list {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.info-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 16px;
|
||||
}
|
||||
.label {
|
||||
color: #888;
|
||||
min-width: 70px;
|
||||
font-size: 15px;
|
||||
}
|
||||
.value {
|
||||
color: #222;
|
||||
font-weight: 500;
|
||||
font-size: 16px;
|
||||
margin-left: 8px;
|
||||
word-break: break-all;
|
||||
}
|
||||
.avatar-upload {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
background: #f5f5f5;
|
||||
transition: box-shadow 0.2s;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
||||
}
|
||||
.avatar-upload img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.avatar-edit {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0,0,0,0.45);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
padding: 3px 0 2px 0;
|
||||
border-radius: 0 0 32px 32px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
pointer-events: none;
|
||||
}
|
||||
.avatar-upload:hover .avatar-edit {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.edit-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 16px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e5e6eb;
|
||||
padding: 4px 10px;
|
||||
background: #fafbfc;
|
||||
}
|
||||
.save-btn {
|
||||
padding: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
.user-set-page {
|
||||
background: #f7f8fa;
|
||||
}
|
||||
.user-card {
|
||||
margin: 18px 16px 0 16px;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 24px 20px 20px 20px;
|
||||
}
|
||||
.avatar {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
background: #f5f5f5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 30px;
|
||||
font-weight: 700;
|
||||
color: #1890ff;
|
||||
margin-right: 22px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.avatar-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 30px;
|
||||
font-weight: 700;
|
||||
color: #1890ff;
|
||||
background: #e6f7ff;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.info-list {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.info-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 16px;
|
||||
}
|
||||
.label {
|
||||
color: #888;
|
||||
min-width: 70px;
|
||||
font-size: 15px;
|
||||
}
|
||||
.value {
|
||||
color: #222;
|
||||
font-weight: 500;
|
||||
font-size: 16px;
|
||||
margin-left: 8px;
|
||||
word-break: break-all;
|
||||
}
|
||||
.avatar-upload {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
background: #f5f5f5;
|
||||
transition: box-shadow 0.2s;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
.avatar-upload img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.avatar-edit {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
padding: 3px 0 2px 0;
|
||||
border-radius: 0 0 32px 32px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
pointer-events: none;
|
||||
}
|
||||
.avatar-upload:hover .avatar-edit {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.edit-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 16px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e5e6eb;
|
||||
padding: 4px 10px;
|
||||
background: #fafbfc;
|
||||
}
|
||||
.save-btn {
|
||||
padding: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
@@ -1,113 +1,113 @@
|
||||
import React, { useRef, useState } from "react";
|
||||
import { useUserStore } from "@/store/module/user";
|
||||
import { Card, Button, Input, Toast } from "antd-mobile";
|
||||
import style from "./index.module.scss";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import NavCommon from "@/components/NavCommon";
|
||||
|
||||
const UserSetting: React.FC = () => {
|
||||
const { user, setUser } = useUserStore();
|
||||
const navigate = useNavigate();
|
||||
const [nickname, setNickname] = useState(user?.username || "");
|
||||
const [avatar, setAvatar] = useState(user?.avatar || "");
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 头像上传
|
||||
const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (ev) => {
|
||||
setAvatar(ev.target?.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
// 保存
|
||||
const handleSave = async () => {
|
||||
if (!nickname.trim()) {
|
||||
Toast.show({ content: "昵称不能为空", position: "top" });
|
||||
return;
|
||||
}
|
||||
if (!user) return;
|
||||
setUser({ ...user, id: user.id, username: nickname, avatar });
|
||||
Toast.show({ content: "保存成功", position: "top" });
|
||||
navigate(-1);
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={<NavCommon title="用户信息修改" />}
|
||||
footer={
|
||||
<div className={style["save-btn"]}>
|
||||
<Button
|
||||
block
|
||||
color="primary"
|
||||
onClick={handleSave}
|
||||
loading={uploading}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className={style["user-set-page"]}>
|
||||
<Card className={style["user-card"]}>
|
||||
<div className={style["user-info"]}>
|
||||
<div className={style["avatar"]}>
|
||||
<div
|
||||
className={style["avatar-upload"]}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
{avatar ? (
|
||||
<img src={avatar} alt="头像" />
|
||||
) : (
|
||||
<div className={style["avatar-placeholder"]}>卡</div>
|
||||
)}
|
||||
<div className={style["avatar-edit"]}>更换头像</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: "none" }}
|
||||
onChange={handleAvatarChange}
|
||||
disabled={uploading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={style["info-list"]}>
|
||||
<div className={style["info-item"]}>
|
||||
<span className={style["label"]}>昵称</span>
|
||||
<Input
|
||||
className={style["edit-input"]}
|
||||
value={nickname}
|
||||
onChange={setNickname}
|
||||
maxLength={12}
|
||||
placeholder="请输入昵称"
|
||||
/>
|
||||
</div>
|
||||
<div className={style["info-item"]}>
|
||||
<span className={style["label"]}>手机号</span>
|
||||
<span className={style["value"]}>{user?.phone || "-"}</span>
|
||||
</div>
|
||||
<div className={style["info-item"]}>
|
||||
<span className={style["label"]}>账号</span>
|
||||
<span className={style["value"]}>{user?.account || "-"}</span>
|
||||
</div>
|
||||
<div className={style["info-item"]}>
|
||||
<span className={style["label"]}>角色</span>
|
||||
<span className={style["value"]}>
|
||||
{user?.isAdmin === 1 ? "管理员" : "普通用户"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserSetting;
|
||||
import React, { useRef, useState } from "react";
|
||||
import { useUserStore } from "@/store/module/user";
|
||||
import { Card, Button, Input, Toast } from "antd-mobile";
|
||||
import style from "./index.module.scss";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import NavCommon from "@/components/NavCommon";
|
||||
|
||||
const UserSetting: React.FC = () => {
|
||||
const { user, setUser } = useUserStore();
|
||||
const navigate = useNavigate();
|
||||
const [nickname, setNickname] = useState(user?.username || "");
|
||||
const [avatar, setAvatar] = useState(user?.avatar || "");
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 头像上传
|
||||
const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = ev => {
|
||||
setAvatar(ev.target?.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
// 保存
|
||||
const handleSave = async () => {
|
||||
if (!nickname.trim()) {
|
||||
Toast.show({ content: "昵称不能为空", position: "top" });
|
||||
return;
|
||||
}
|
||||
if (!user) return;
|
||||
setUser({ ...user, id: user.id, username: nickname, avatar });
|
||||
Toast.show({ content: "保存成功", position: "top" });
|
||||
navigate(-1);
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={<NavCommon title="用户信息修改" />}
|
||||
footer={
|
||||
<div className={style["save-btn"]}>
|
||||
<Button
|
||||
block
|
||||
color="primary"
|
||||
onClick={handleSave}
|
||||
loading={uploading}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className={style["user-set-page"]}>
|
||||
<Card className={style["user-card"]}>
|
||||
<div className={style["user-info"]}>
|
||||
<div className={style["avatar"]}>
|
||||
<div
|
||||
className={style["avatar-upload"]}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
{avatar ? (
|
||||
<img src={avatar} alt="头像" />
|
||||
) : (
|
||||
<div className={style["avatar-placeholder"]}>卡</div>
|
||||
)}
|
||||
<div className={style["avatar-edit"]}>更换头像</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: "none" }}
|
||||
onChange={handleAvatarChange}
|
||||
disabled={uploading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={style["info-list"]}>
|
||||
<div className={style["info-item"]}>
|
||||
<span className={style["label"]}>昵称</span>
|
||||
<Input
|
||||
className={style["edit-input"]}
|
||||
value={nickname}
|
||||
onChange={setNickname}
|
||||
maxLength={12}
|
||||
placeholder="请输入昵称"
|
||||
/>
|
||||
</div>
|
||||
<div className={style["info-item"]}>
|
||||
<span className={style["label"]}>手机号</span>
|
||||
<span className={style["value"]}>{user?.phone || "-"}</span>
|
||||
</div>
|
||||
<div className={style["info-item"]}>
|
||||
<span className={style["label"]}>账号</span>
|
||||
<span className={style["value"]}>{user?.account || "-"}</span>
|
||||
</div>
|
||||
<div className={style["info-item"]}>
|
||||
<span className={style["label"]}>角色</span>
|
||||
<span className={style["value"]}>
|
||||
{user?.isAdmin === 1 ? "管理员" : "普通用户"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserSetting;
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
import request from "@/api/request";
|
||||
|
||||
// 获取微信号详情
|
||||
export function getWechatAccountDetail(id: string) {
|
||||
return request("/v1/wechats/getWechatInfo", { wechatId: id }, "GET");
|
||||
}
|
||||
|
||||
// 获取微信号好友列表
|
||||
export function getWechatFriends(params: {
|
||||
wechatAccount: string;
|
||||
page: number;
|
||||
limit: number;
|
||||
keyword?: string;
|
||||
}) {
|
||||
return request(
|
||||
`/v1/wechats/${params.wechatAccount}/friends`,
|
||||
{
|
||||
page: params.page,
|
||||
limit: params.limit,
|
||||
keyword: params.keyword,
|
||||
},
|
||||
"GET"
|
||||
);
|
||||
}
|
||||
|
||||
// 获取微信好友详情
|
||||
export function getWechatFriendDetail(id: string) {
|
||||
return request("/v1/WechatFriend/detail", { id }, "GET");
|
||||
}
|
||||
import request from "@/api/request";
|
||||
|
||||
// 获取微信号详情
|
||||
export function getWechatAccountDetail(id: string) {
|
||||
return request("/v1/wechats/getWechatInfo", { wechatId: id }, "GET");
|
||||
}
|
||||
|
||||
// 获取微信号好友列表
|
||||
export function getWechatFriends(params: {
|
||||
wechatAccount: string;
|
||||
page: number;
|
||||
limit: number;
|
||||
keyword?: string;
|
||||
}) {
|
||||
return request(
|
||||
`/v1/wechats/${params.wechatAccount}/friends`,
|
||||
{
|
||||
page: params.page,
|
||||
limit: params.limit,
|
||||
keyword: params.keyword,
|
||||
},
|
||||
"GET"
|
||||
);
|
||||
}
|
||||
|
||||
// 获取微信好友详情
|
||||
export function getWechatFriendDetail(id: string) {
|
||||
return request("/v1/WechatFriend/detail", { id }, "GET");
|
||||
}
|
||||
|
||||
@@ -1,54 +1,54 @@
|
||||
export interface WechatAccountSummary {
|
||||
accountAge: string;
|
||||
activityLevel: {
|
||||
allTimes: number;
|
||||
dayTimes: number;
|
||||
};
|
||||
accountWeight: {
|
||||
scope: number;
|
||||
ageWeight: number;
|
||||
activityWeigth: number;
|
||||
restrictWeight: number;
|
||||
realNameWeight: number;
|
||||
};
|
||||
statistics: {
|
||||
todayAdded: number;
|
||||
addLimit: number;
|
||||
};
|
||||
restrictions: {
|
||||
id: number;
|
||||
level: string;
|
||||
reason: string;
|
||||
date: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface Friend {
|
||||
id: string;
|
||||
avatar: string;
|
||||
nickname: string;
|
||||
wechatId: string;
|
||||
remark: string;
|
||||
addTime: string;
|
||||
lastInteraction: string;
|
||||
tags: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
}>;
|
||||
region: string;
|
||||
source: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export interface WechatFriendDetail {
|
||||
id: number;
|
||||
avatar: string;
|
||||
nickname: string;
|
||||
region: string;
|
||||
wechatId: string;
|
||||
addDate: string;
|
||||
tags: string[];
|
||||
memo: string;
|
||||
source: string;
|
||||
}
|
||||
export interface WechatAccountSummary {
|
||||
accountAge: string;
|
||||
activityLevel: {
|
||||
allTimes: number;
|
||||
dayTimes: number;
|
||||
};
|
||||
accountWeight: {
|
||||
scope: number;
|
||||
ageWeight: number;
|
||||
activityWeigth: number;
|
||||
restrictWeight: number;
|
||||
realNameWeight: number;
|
||||
};
|
||||
statistics: {
|
||||
todayAdded: number;
|
||||
addLimit: number;
|
||||
};
|
||||
restrictions: {
|
||||
id: number;
|
||||
level: string;
|
||||
reason: string;
|
||||
date: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface Friend {
|
||||
id: string;
|
||||
avatar: string;
|
||||
nickname: string;
|
||||
wechatId: string;
|
||||
remark: string;
|
||||
addTime: string;
|
||||
lastInteraction: string;
|
||||
tags: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
}>;
|
||||
region: string;
|
||||
source: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export interface WechatFriendDetail {
|
||||
id: number;
|
||||
avatar: string;
|
||||
nickname: string;
|
||||
region: string;
|
||||
wechatId: string;
|
||||
addDate: string;
|
||||
tags: string[];
|
||||
memo: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -414,7 +414,7 @@ const WechatAccountDetail: React.FC = () => {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{friends.map((friend) => (
|
||||
{friends.map(friend => (
|
||||
<div key={friend.id} className={style["friend-item"]}>
|
||||
<Avatar
|
||||
src={friend.avatar}
|
||||
@@ -490,7 +490,7 @@ const WechatAccountDetail: React.FC = () => {
|
||||
<p className={style["popup-description"]}>每次限制恢复时间为24小时</p>
|
||||
{accountSummary && accountSummary.restrictions && (
|
||||
<div className={style["restrictions-detail"]}>
|
||||
{accountSummary.restrictions.map((restriction) => (
|
||||
{accountSummary.restrictions.map(restriction => (
|
||||
<div
|
||||
key={restriction.id}
|
||||
className={style["restriction-detail-item"]}
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
import request from "@/api/request";
|
||||
|
||||
// 获取微信号列表
|
||||
export function getWechatAccounts(params: {
|
||||
page: number;
|
||||
page_size: number;
|
||||
keyword?: string;
|
||||
}) {
|
||||
return request("v1/wechats", params, "GET");
|
||||
}
|
||||
|
||||
// 获取微信号详情
|
||||
export function getWechatAccountDetail(id: string) {
|
||||
return request("v1/WechatAccount/detail", { id }, "GET");
|
||||
}
|
||||
|
||||
// 获取微信号好友列表
|
||||
export function getWechatFriends(params: {
|
||||
wechatAccountKeyword: string;
|
||||
pageIndex: number;
|
||||
pageSize: number;
|
||||
friendKeyword?: string;
|
||||
}) {
|
||||
return request("v1/WechatFriend/friendlistData", params, "POST");
|
||||
}
|
||||
|
||||
// 获取微信好友详情
|
||||
export function getWechatFriendDetail(id: string) {
|
||||
return request("v1/WechatFriend/detail", { id }, "GET");
|
||||
}
|
||||
import request from "@/api/request";
|
||||
|
||||
// 获取微信号列表
|
||||
export function getWechatAccounts(params: {
|
||||
page: number;
|
||||
page_size: number;
|
||||
keyword?: string;
|
||||
}) {
|
||||
return request("v1/wechats", params, "GET");
|
||||
}
|
||||
|
||||
// 获取微信号详情
|
||||
export function getWechatAccountDetail(id: string) {
|
||||
return request("v1/WechatAccount/detail", { id }, "GET");
|
||||
}
|
||||
|
||||
// 获取微信号好友列表
|
||||
export function getWechatFriends(params: {
|
||||
wechatAccountKeyword: string;
|
||||
pageIndex: number;
|
||||
pageSize: number;
|
||||
friendKeyword?: string;
|
||||
}) {
|
||||
return request("v1/WechatFriend/friendlistData", params, "POST");
|
||||
}
|
||||
|
||||
// 获取微信好友详情
|
||||
export function getWechatFriendDetail(id: string) {
|
||||
return request("v1/WechatFriend/detail", { id }, "GET");
|
||||
}
|
||||
|
||||
@@ -1,171 +1,171 @@
|
||||
.wechat-accounts-page {
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.nav-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.card-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.account-card {
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
||||
padding: 14px 14px 10px 14px;
|
||||
transition: box-shadow 0.2s;
|
||||
cursor: pointer;
|
||||
border: 1px solid #f0f0f0;
|
||||
&:hover {
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.10);
|
||||
border-color: #e6f7ff;
|
||||
}
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.avatar-wrapper {
|
||||
position: relative;
|
||||
margin-right: 12px;
|
||||
}
|
||||
.avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid #e6f0fa;
|
||||
box-shadow: 0 0 0 2px #1677ff33;
|
||||
object-fit: cover;
|
||||
}
|
||||
.status-dot-normal {
|
||||
position: absolute;
|
||||
right: -2px;
|
||||
bottom: -2px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: #52c41a;
|
||||
border: 2px solid #fff;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.status-dot-abnormal {
|
||||
position: absolute;
|
||||
right: -2px;
|
||||
bottom: -2px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: #ff4d4f;
|
||||
border: 2px solid #fff;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.header-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.nickname-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.nickname {
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.status-label-normal {
|
||||
background: #e6fffb;
|
||||
color: #13c2c2;
|
||||
font-size: 12px;
|
||||
border-radius: 8px;
|
||||
padding: 2px 8px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
.status-label-abnormal {
|
||||
background: #fff1f0;
|
||||
color: #ff4d4f;
|
||||
font-size: 12px;
|
||||
border-radius: 8px;
|
||||
padding: 2px 8px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
.wechat-id {
|
||||
color: #888;
|
||||
font-size: 13px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.card-action {
|
||||
margin-left: 8px;
|
||||
}
|
||||
.card-body {
|
||||
margin-top: 2px;
|
||||
}
|
||||
.row-group {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 4px;
|
||||
gap: 8px;
|
||||
}
|
||||
.row-item {
|
||||
font-size: 13px;
|
||||
color: #555;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
.strong {
|
||||
font-weight: 600;
|
||||
color: #222;
|
||||
}
|
||||
.strong-green {
|
||||
font-weight: 600;
|
||||
color: #52c41a;
|
||||
}
|
||||
.progress-bar {
|
||||
margin: 6px 0 8px 0;
|
||||
}
|
||||
.progress-bg {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background: #f0f0f0;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-fill {
|
||||
height: 8px;
|
||||
background: linear-gradient(90deg, #1677ff 0%, #69c0ff 100%);
|
||||
border-radius: 6px;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
.pagination {
|
||||
margin: 16px 0 0 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
.popup-content {
|
||||
padding: 16px 0 8px 0;
|
||||
}
|
||||
.popup-content img {
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
||||
}
|
||||
.loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 200px;
|
||||
}
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
padding: 48px 0 32px 0;
|
||||
font-size: 15px;
|
||||
}
|
||||
.wechat-accounts-page {
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.nav-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.card-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.account-card {
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
padding: 14px 14px 10px 14px;
|
||||
transition: box-shadow 0.2s;
|
||||
cursor: pointer;
|
||||
border: 1px solid #f0f0f0;
|
||||
&:hover {
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||
border-color: #e6f7ff;
|
||||
}
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.avatar-wrapper {
|
||||
position: relative;
|
||||
margin-right: 12px;
|
||||
}
|
||||
.avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid #e6f0fa;
|
||||
box-shadow: 0 0 0 2px #1677ff33;
|
||||
object-fit: cover;
|
||||
}
|
||||
.status-dot-normal {
|
||||
position: absolute;
|
||||
right: -2px;
|
||||
bottom: -2px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: #52c41a;
|
||||
border: 2px solid #fff;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.status-dot-abnormal {
|
||||
position: absolute;
|
||||
right: -2px;
|
||||
bottom: -2px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: #ff4d4f;
|
||||
border: 2px solid #fff;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.header-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.nickname-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.nickname {
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.status-label-normal {
|
||||
background: #e6fffb;
|
||||
color: #13c2c2;
|
||||
font-size: 12px;
|
||||
border-radius: 8px;
|
||||
padding: 2px 8px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
.status-label-abnormal {
|
||||
background: #fff1f0;
|
||||
color: #ff4d4f;
|
||||
font-size: 12px;
|
||||
border-radius: 8px;
|
||||
padding: 2px 8px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
.wechat-id {
|
||||
color: #888;
|
||||
font-size: 13px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.card-action {
|
||||
margin-left: 8px;
|
||||
}
|
||||
.card-body {
|
||||
margin-top: 2px;
|
||||
}
|
||||
.row-group {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 4px;
|
||||
gap: 8px;
|
||||
}
|
||||
.row-item {
|
||||
font-size: 13px;
|
||||
color: #555;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
.strong {
|
||||
font-weight: 600;
|
||||
color: #222;
|
||||
}
|
||||
.strong-green {
|
||||
font-weight: 600;
|
||||
color: #52c41a;
|
||||
}
|
||||
.progress-bar {
|
||||
margin: 6px 0 8px 0;
|
||||
}
|
||||
.progress-bg {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background: #f0f0f0;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-fill {
|
||||
height: 8px;
|
||||
background: linear-gradient(90deg, #1677ff 0%, #69c0ff 100%);
|
||||
border-radius: 6px;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
.pagination {
|
||||
margin: 16px 0 0 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
.popup-content {
|
||||
padding: 16px 0 8px 0;
|
||||
}
|
||||
.popup-content img {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
.loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 200px;
|
||||
}
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
padding: 48px 0 32px 0;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ const WechatAccounts: React.FC = () => {
|
||||
<Input
|
||||
placeholder="搜索微信号/昵称"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
prefix={<SearchOutlined />}
|
||||
allowClear
|
||||
size="large"
|
||||
@@ -121,7 +121,7 @@ const WechatAccounts: React.FC = () => {
|
||||
<div className={style["empty"]}>暂无微信账号数据</div>
|
||||
) : (
|
||||
<div className={style["card-list"]}>
|
||||
{accounts.map((account) => {
|
||||
{accounts.map(account => {
|
||||
const percent =
|
||||
account.times > 0
|
||||
? Math.min((account.addedCount / account.times) * 100, 100)
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
import request from '@/api/request';
|
||||
|
||||
// 获取场景列表
|
||||
export function getScenarios(params: any) {
|
||||
return request('/v1/plan/scenes', params, 'GET');
|
||||
}
|
||||
|
||||
// 获取场景详情
|
||||
export function getScenarioDetail(id: string) {
|
||||
return request(`/v1/scenarios/${id}`, {}, 'GET');
|
||||
}
|
||||
|
||||
// 创建场景
|
||||
export function createScenario(data: any) {
|
||||
return request('/v1/scenarios', data, 'POST');
|
||||
}
|
||||
|
||||
// 更新场景
|
||||
export function updateScenario(id: string, data: any) {
|
||||
return request(`/v1/scenarios/${id}`, data, 'PUT');
|
||||
}
|
||||
|
||||
// 删除场景
|
||||
export function deleteScenario(id: string) {
|
||||
return request(`/v1/scenarios/${id}`, {}, 'DELETE');
|
||||
}
|
||||
import request from "@/api/request";
|
||||
|
||||
// 获取场景列表
|
||||
export function getScenarios(params: any) {
|
||||
return request("/v1/plan/scenes", params, "GET");
|
||||
}
|
||||
|
||||
// 获取场景详情
|
||||
export function getScenarioDetail(id: string) {
|
||||
return request(`/v1/scenarios/${id}`, {}, "GET");
|
||||
}
|
||||
|
||||
// 创建场景
|
||||
export function createScenario(data: any) {
|
||||
return request("/v1/scenarios", data, "POST");
|
||||
}
|
||||
|
||||
// 更新场景
|
||||
export function updateScenario(id: string, data: any) {
|
||||
return request(`/v1/scenarios/${id}`, data, "PUT");
|
||||
}
|
||||
|
||||
// 删除场景
|
||||
export function deleteScenario(id: string) {
|
||||
return request(`/v1/scenarios/${id}`, {}, "DELETE");
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
background: var(--primary-gradient);
|
||||
border: none;
|
||||
box-shadow: 0 2px 8px var(--primary-shadow);
|
||||
|
||||
|
||||
&:active {
|
||||
transform: translateY(1px);
|
||||
box-shadow: 0 1px 4px var(--primary-shadow);
|
||||
@@ -132,11 +132,11 @@
|
||||
.scenario-item {
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
@@ -152,12 +152,14 @@
|
||||
.scenario-card {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
|
||||
transition: box-shadow 0.2s, transform 0.2s;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
transition:
|
||||
box-shadow 0.2s,
|
||||
transform 0.2s;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
&:hover {
|
||||
box-shadow: 0 6px 16px rgba(0,0,0,0.12);
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
|
||||
transform: translateY(-2px) scale(1.02);
|
||||
}
|
||||
}
|
||||
@@ -258,34 +260,33 @@
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 480px) {
|
||||
|
||||
.scenario-card {
|
||||
padding: 14px 16px;
|
||||
min-height: 70px;
|
||||
}
|
||||
|
||||
|
||||
.scenario-icon {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
}
|
||||
|
||||
|
||||
.scenario-image {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
|
||||
.scenario-name {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
|
||||
.stat-text {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
|
||||
.scenario-growth {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
|
||||
.growth-icon {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@@ -123,7 +123,7 @@ const Scene: React.FC = () => {
|
||||
>
|
||||
<div className={style["scene-page"]}>
|
||||
<div className={style["scenarios-grid"]}>
|
||||
{scenarios.map((scenario) => (
|
||||
{scenarios.map(scenario => (
|
||||
<div
|
||||
key={scenario.id}
|
||||
className={style["scenario-card"]}
|
||||
@@ -136,7 +136,7 @@ const Scene: React.FC = () => {
|
||||
src={scenario.image}
|
||||
alt={scenario.name}
|
||||
className={style["card-img"]}
|
||||
onError={(e) => {
|
||||
onError={e => {
|
||||
e.currentTarget.src =
|
||||
"https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-api.png";
|
||||
}}
|
||||
|
||||
@@ -1,32 +1,32 @@
|
||||
import request from "@/api/request";
|
||||
import { PlanDetail, PlanListResponse, ApiResponse } from "./data";
|
||||
|
||||
// ==================== 计划相关接口 ====================
|
||||
// 获取计划列表
|
||||
export function getPlanList(params: {
|
||||
sceneId: string;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}): Promise<PlanListResponse> {
|
||||
return request(`/v1/plan/list`, params, "GET");
|
||||
}
|
||||
|
||||
// 获取计划详情
|
||||
export function getPlanDetail(planId: string): Promise<PlanDetail> {
|
||||
return request(`/v1/plan/detail`, { planId }, "GET");
|
||||
}
|
||||
|
||||
// 复制计划
|
||||
export function copyPlan(planId: string): Promise<ApiResponse<any>> {
|
||||
return request(`/v1/plan/copy`, { planId }, "GET");
|
||||
}
|
||||
|
||||
// 删除计划
|
||||
export function deletePlan(planId: string): Promise<ApiResponse<any>> {
|
||||
return request(`/v1/plan/delete`, { planId }, "DELETE");
|
||||
}
|
||||
|
||||
// 获取小程序二维码
|
||||
export function getWxMinAppCode(planId: string): Promise<ApiResponse<string>> {
|
||||
return request(`/v1/plan/getWxMinAppCode`, { taskId: planId }, "GET");
|
||||
}
|
||||
import request from "@/api/request";
|
||||
import { PlanDetail, PlanListResponse, ApiResponse } from "./data";
|
||||
|
||||
// ==================== 计划相关接口 ====================
|
||||
// 获取计划列表
|
||||
export function getPlanList(params: {
|
||||
sceneId: string;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}): Promise<PlanListResponse> {
|
||||
return request(`/v1/plan/list`, params, "GET");
|
||||
}
|
||||
|
||||
// 获取计划详情
|
||||
export function getPlanDetail(planId: string): Promise<PlanDetail> {
|
||||
return request(`/v1/plan/detail`, { planId }, "GET");
|
||||
}
|
||||
|
||||
// 复制计划
|
||||
export function copyPlan(planId: string): Promise<ApiResponse<any>> {
|
||||
return request(`/v1/plan/copy`, { planId }, "GET");
|
||||
}
|
||||
|
||||
// 删除计划
|
||||
export function deletePlan(planId: string): Promise<ApiResponse<any>> {
|
||||
return request(`/v1/plan/delete`, { planId }, "DELETE");
|
||||
}
|
||||
|
||||
// 获取小程序二维码
|
||||
export function getWxMinAppCode(planId: string): Promise<ApiResponse<string>> {
|
||||
return request(`/v1/plan/getWxMinAppCode`, { taskId: planId }, "GET");
|
||||
}
|
||||
|
||||
@@ -1,59 +1,59 @@
|
||||
export interface Task {
|
||||
id: string;
|
||||
name: string;
|
||||
status: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
enabled: boolean;
|
||||
total_customers?: number;
|
||||
today_customers?: number;
|
||||
lastUpdated?: string;
|
||||
stats?: {
|
||||
devices?: number;
|
||||
acquired?: number;
|
||||
added?: number;
|
||||
};
|
||||
reqConf?: {
|
||||
device?: string[];
|
||||
selectedDevices?: string[];
|
||||
};
|
||||
acquiredCount?: number;
|
||||
addedCount?: number;
|
||||
passRate?: number;
|
||||
}
|
||||
|
||||
export interface ApiSettings {
|
||||
apiKey: string;
|
||||
webhookUrl: string;
|
||||
taskId: string;
|
||||
}
|
||||
|
||||
// API响应相关类型
|
||||
export interface TextUrl {
|
||||
apiKey: string;
|
||||
originalString?: string;
|
||||
sign?: string;
|
||||
fullUrl: string;
|
||||
}
|
||||
|
||||
export interface PlanDetail {
|
||||
id: number;
|
||||
name: string;
|
||||
scenario: number;
|
||||
enabled: boolean;
|
||||
status: number;
|
||||
apiKey: string;
|
||||
textUrl: TextUrl;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
code: number;
|
||||
msg?: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
export interface PlanListResponse {
|
||||
list: Task[];
|
||||
total: number;
|
||||
}
|
||||
export interface Task {
|
||||
id: string;
|
||||
name: string;
|
||||
status: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
enabled: boolean;
|
||||
total_customers?: number;
|
||||
today_customers?: number;
|
||||
lastUpdated?: string;
|
||||
stats?: {
|
||||
devices?: number;
|
||||
acquired?: number;
|
||||
added?: number;
|
||||
};
|
||||
reqConf?: {
|
||||
device?: string[];
|
||||
selectedDevices?: string[];
|
||||
};
|
||||
acquiredCount?: number;
|
||||
addedCount?: number;
|
||||
passRate?: number;
|
||||
}
|
||||
|
||||
export interface ApiSettings {
|
||||
apiKey: string;
|
||||
webhookUrl: string;
|
||||
taskId: string;
|
||||
}
|
||||
|
||||
// API响应相关类型
|
||||
export interface TextUrl {
|
||||
apiKey: string;
|
||||
originalString?: string;
|
||||
sign?: string;
|
||||
fullUrl: string;
|
||||
}
|
||||
|
||||
export interface PlanDetail {
|
||||
id: number;
|
||||
name: string;
|
||||
scenario: number;
|
||||
enabled: boolean;
|
||||
status: number;
|
||||
apiKey: string;
|
||||
textUrl: TextUrl;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
code: number;
|
||||
msg?: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
export interface PlanListResponse {
|
||||
list: Task[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
@@ -1,383 +1,382 @@
|
||||
.scenario-list-page {
|
||||
padding:0 16px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 60vh;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.search-input-wrapper {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
|
||||
.ant-input {
|
||||
border-radius: 8px;
|
||||
height: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.plan-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.plan-item {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.plan-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.plan-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
flex: 1;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.plan-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.more-btn {
|
||||
padding: 4px;
|
||||
min-width: auto;
|
||||
height: 28px;
|
||||
width: 28px;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
border: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-bottom: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.plan-footer {
|
||||
border-top: 1px solid #f0f0f0;
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.last-execution {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
|
||||
svg {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.create-first-btn {
|
||||
height: 40px;
|
||||
padding: 0 24px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
// 加载更多按钮样式
|
||||
.load-more-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.load-more-btn {
|
||||
height: 44px;
|
||||
padding: 0 32px;
|
||||
border-radius: 22px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
// 没有更多数据提示样式
|
||||
.no-more-data {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 20px 0;
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
|
||||
span {
|
||||
position: relative;
|
||||
padding: 0 20px;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 40px;
|
||||
height: 1px;
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: -50px;
|
||||
}
|
||||
|
||||
&::after {
|
||||
right: -50px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.action-menu-dialog {
|
||||
background: white;
|
||||
border-radius: 16px 16px 0 0;
|
||||
padding: 20px;
|
||||
max-height: 60vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.action-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
&.danger {
|
||||
color: #ff4d4f;
|
||||
|
||||
&:hover {
|
||||
background-color: #fff2f0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
font-size: 16px;
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.action-text {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-content {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.qr-dialog {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.qr-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
gap: 16px;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.qr-image {
|
||||
width: 100%;
|
||||
max-width: 200px;
|
||||
height: auto;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.qr-error {
|
||||
text-align: center;
|
||||
color: #ff4d4f;
|
||||
font-size: 14px;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.qr-link-section {
|
||||
margin-top: 20px;
|
||||
width: 100%;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.link-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
margin-bottom: 8px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.link-input-wrapper {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
|
||||
@media (max-width: 480px) {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.link-input {
|
||||
flex: 1;
|
||||
|
||||
.ant-input {
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
background-color: #f8f9fa;
|
||||
border: 1px solid #e9ecef;
|
||||
|
||||
&:focus {
|
||||
border-color: #1890ff;
|
||||
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.copy-button {
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
|
||||
.anticon {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
.scenario-list-page {
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 60vh;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.search-input-wrapper {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
|
||||
.ant-input {
|
||||
border-radius: 8px;
|
||||
height: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.plan-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.plan-item {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.plan-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.plan-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
flex: 1;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.plan-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.more-btn {
|
||||
padding: 4px;
|
||||
min-width: auto;
|
||||
height: 28px;
|
||||
width: 28px;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
border: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-bottom: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.plan-footer {
|
||||
border-top: 1px solid #f0f0f0;
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.last-execution {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
|
||||
svg {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.create-first-btn {
|
||||
height: 40px;
|
||||
padding: 0 24px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
// 加载更多按钮样式
|
||||
.load-more-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.load-more-btn {
|
||||
height: 44px;
|
||||
padding: 0 32px;
|
||||
border-radius: 22px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
// 没有更多数据提示样式
|
||||
.no-more-data {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 20px 0;
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
|
||||
span {
|
||||
position: relative;
|
||||
padding: 0 20px;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 40px;
|
||||
height: 1px;
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: -50px;
|
||||
}
|
||||
|
||||
&::after {
|
||||
right: -50px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.action-menu-dialog {
|
||||
background: white;
|
||||
border-radius: 16px 16px 0 0;
|
||||
padding: 20px;
|
||||
max-height: 60vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.action-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
&.danger {
|
||||
color: #ff4d4f;
|
||||
|
||||
&:hover {
|
||||
background-color: #fff2f0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
font-size: 16px;
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.action-text {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-content {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.qr-dialog {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.qr-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
gap: 16px;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.qr-image {
|
||||
width: 100%;
|
||||
max-width: 200px;
|
||||
height: auto;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.qr-error {
|
||||
text-align: center;
|
||||
color: #ff4d4f;
|
||||
font-size: 14px;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.qr-link-section {
|
||||
margin-top: 20px;
|
||||
width: 100%;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.link-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
margin-bottom: 8px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.link-input-wrapper {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
|
||||
@media (max-width: 480px) {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.link-input {
|
||||
flex: 1;
|
||||
|
||||
.ant-input {
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
background-color: #f8f9fa;
|
||||
border: 1px solid #e9ecef;
|
||||
|
||||
&:focus {
|
||||
border-color: #1890ff;
|
||||
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.copy-button {
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
|
||||
.anticon {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ const ScenarioList: React.FC = () => {
|
||||
if (response && response.list) {
|
||||
if (isLoadMore) {
|
||||
// 加载更多时,追加数据
|
||||
setTasks((prev) => [...prev, ...response.list]);
|
||||
setTasks(prev => [...prev, ...response.list]);
|
||||
} else {
|
||||
// 首次加载或刷新时,替换数据
|
||||
setTasks(response.list);
|
||||
@@ -158,7 +158,7 @@ const ScenarioList: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleCopyPlan = async (taskId: string) => {
|
||||
const taskToCopy = tasks.find((task) => task.id === taskId);
|
||||
const taskToCopy = tasks.find(task => task.id === taskId);
|
||||
if (!taskToCopy) return;
|
||||
|
||||
try {
|
||||
@@ -178,7 +178,7 @@ const ScenarioList: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleDeletePlan = async (taskId: string) => {
|
||||
const taskToDelete = tasks.find((task) => task.id === taskId);
|
||||
const taskToDelete = tasks.find(task => task.id === taskId);
|
||||
if (!taskToDelete) return;
|
||||
|
||||
const result = await Dialog.confirm({
|
||||
@@ -285,7 +285,7 @@ const ScenarioList: React.FC = () => {
|
||||
await fetchPlanList(1, false);
|
||||
};
|
||||
|
||||
const filteredTasks = tasks.filter((task) =>
|
||||
const filteredTasks = tasks.filter(task =>
|
||||
task.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
@@ -371,7 +371,7 @@ const ScenarioList: React.FC = () => {
|
||||
<Input
|
||||
placeholder="搜索计划名称"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
prefix={<SearchOutlined />}
|
||||
allowClear
|
||||
size="large"
|
||||
@@ -408,7 +408,7 @@ const ScenarioList: React.FC = () => {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{filteredTasks.map((task) => (
|
||||
{filteredTasks.map(task => (
|
||||
<Card key={task.id} className={style["plan-item"]}>
|
||||
{/* 头部:标题、状态和操作菜单 */}
|
||||
<div className={style["plan-header"]}>
|
||||
@@ -527,8 +527,8 @@ const ScenarioList: React.FC = () => {
|
||||
</div>
|
||||
<div className={style["dialog-content"]}>
|
||||
{showActionMenu &&
|
||||
getActionMenu(tasks.find((t) => t.id === showActionMenu)!).map(
|
||||
(item) => (
|
||||
getActionMenu(tasks.find(t => t.id === showActionMenu)!).map(
|
||||
item => (
|
||||
<div
|
||||
key={item.key}
|
||||
className={`${style["action-menu-item"]} ${item.danger ? style["danger"] : ""}`}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,437 +1,437 @@
|
||||
import React, { useState, useMemo } from "react";
|
||||
import { Popup, Button, Toast, SpinLoading } from "antd-mobile";
|
||||
import { Modal, Input, Tabs, Card, Tag, Space } from "antd";
|
||||
import {
|
||||
CopyOutlined,
|
||||
CodeOutlined,
|
||||
BookOutlined,
|
||||
ThunderboltOutlined,
|
||||
SettingOutlined,
|
||||
LinkOutlined,
|
||||
SafetyOutlined,
|
||||
CheckCircleOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import style from "./planApi.module.scss";
|
||||
import { buildApiUrl } from "@/utils/apiUrl";
|
||||
|
||||
/**
|
||||
* 计划接口配置弹窗组件
|
||||
*
|
||||
* 使用示例:
|
||||
* ```tsx
|
||||
* const [showApiDialog, setShowApiDialog] = useState(false);
|
||||
* const [apiSettings, setApiSettings] = useState({
|
||||
* apiKey: "your-api-key",
|
||||
* webhookUrl: "https://api.example.com/webhook",
|
||||
* taskId: "task-123"
|
||||
* });
|
||||
*
|
||||
* <PlanApi
|
||||
* visible={showApiDialog}
|
||||
* onClose={() => setShowApiDialog(false)}
|
||||
* apiKey={apiSettings.apiKey}
|
||||
* webhookUrl={apiSettings.webhookUrl}
|
||||
* taskId={apiSettings.taskId}
|
||||
* />
|
||||
* ```
|
||||
*
|
||||
* 特性:
|
||||
* - 移动端使用 Popup,PC端使用 Modal
|
||||
* - 支持四个标签页:接口配置、快速测试、开发文档、代码示例
|
||||
* - 支持多种编程语言的代码示例
|
||||
* - 响应式设计,自适应不同屏幕尺寸
|
||||
* - 支持暗色主题
|
||||
* - 自动拼接API地址前缀
|
||||
*/
|
||||
|
||||
interface PlanApiProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
apiKey: string;
|
||||
webhookUrl: string;
|
||||
taskId: string;
|
||||
}
|
||||
|
||||
interface ApiSettings {
|
||||
apiKey: string;
|
||||
webhookUrl: string;
|
||||
taskId: string;
|
||||
}
|
||||
|
||||
const PlanApi: React.FC<PlanApiProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
apiKey,
|
||||
webhookUrl,
|
||||
taskId,
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState("config");
|
||||
const [activeLanguage, setActiveLanguage] = useState("javascript");
|
||||
|
||||
// 处理webhook URL,确保包含完整的API地址
|
||||
const fullWebhookUrl = useMemo(() => {
|
||||
return buildApiUrl(webhookUrl);
|
||||
}, [webhookUrl]);
|
||||
|
||||
// 生成测试URL
|
||||
const testUrl = useMemo(() => {
|
||||
if (!fullWebhookUrl) return "";
|
||||
return `${fullWebhookUrl}?name=测试客户&phone=13800138000&source=API测试`;
|
||||
}, [fullWebhookUrl]);
|
||||
|
||||
// 检测是否为移动端
|
||||
const isMobile = window.innerWidth <= 768;
|
||||
|
||||
const handleCopy = (text: string, type: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
Toast.show({
|
||||
content: `${type}已复制到剪贴板`,
|
||||
position: "top",
|
||||
});
|
||||
};
|
||||
|
||||
const handleTestInBrowser = () => {
|
||||
window.open(testUrl, "_blank");
|
||||
};
|
||||
|
||||
const renderConfigTab = () => (
|
||||
<div className={style["config-content"]}>
|
||||
{/* API密钥配置 */}
|
||||
<div className={style["config-section"]}>
|
||||
<div className={style["section-header"]}>
|
||||
<div className={style["section-title"]}>
|
||||
<CheckCircleOutlined className={style["section-icon"]} />
|
||||
API密钥
|
||||
</div>
|
||||
<Tag color="green">安全认证</Tag>
|
||||
</div>
|
||||
<div className={style["input-group"]}>
|
||||
<Input value={apiKey} disabled className={style["api-input"]} />
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => handleCopy(apiKey, "API密钥")}
|
||||
className={style["copy-btn"]}
|
||||
>
|
||||
<CopyOutlined />
|
||||
复制
|
||||
</Button>
|
||||
</div>
|
||||
<div className={style["security-tip"]}>
|
||||
<strong>安全提示:</strong>
|
||||
请妥善保管API密钥,不要在客户端代码中暴露。建议在服务器端使用该密钥。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 接口地址配置 */}
|
||||
<div className={style["config-section"]}>
|
||||
<div className={style["section-header"]}>
|
||||
<div className={style["section-title"]}>
|
||||
<LinkOutlined className={style["section-icon"]} />
|
||||
接口地址
|
||||
</div>
|
||||
<Tag color="blue">POST请求</Tag>
|
||||
</div>
|
||||
<div className={style["input-group"]}>
|
||||
<Input
|
||||
value={fullWebhookUrl}
|
||||
disabled
|
||||
className={style["api-input"]}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => handleCopy(fullWebhookUrl, "接口地址")}
|
||||
className={style["copy-btn"]}
|
||||
>
|
||||
<CopyOutlined />
|
||||
复制
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 参数说明 */}
|
||||
<div className={style["params-grid"]}>
|
||||
<div className={style["param-section"]}>
|
||||
<h4>必要参数</h4>
|
||||
<div className={style["param-list"]}>
|
||||
<div>
|
||||
<code>name</code> - 客户姓名
|
||||
</div>
|
||||
<div>
|
||||
<code>phone</code> - 手机号码
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={style["param-section"]}>
|
||||
<h4>可选参数</h4>
|
||||
<div className={style["param-list"]}>
|
||||
<div>
|
||||
<code>source</code> - 来源标识
|
||||
</div>
|
||||
<div>
|
||||
<code>remark</code> - 备注信息
|
||||
</div>
|
||||
<div>
|
||||
<code>tags</code> - 客户标签
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderQuickTestTab = () => (
|
||||
<div className={style["test-content"]}>
|
||||
<div className={style["test-section"]}>
|
||||
<h3>快速测试URL</h3>
|
||||
<div className={style["input-group"]}>
|
||||
<Input value={testUrl} disabled className={style["test-input"]} />
|
||||
</div>
|
||||
<div className={style["test-buttons"]}>
|
||||
<Button
|
||||
onClick={() => handleCopy(testUrl, "测试URL")}
|
||||
className={style["test-btn"]}
|
||||
>
|
||||
<CopyOutlined />
|
||||
复制测试URL
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleTestInBrowser}
|
||||
className={style["test-btn"]}
|
||||
>
|
||||
在浏览器中测试
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderDocsTab = () => (
|
||||
<div className={style["docs-content"]}>
|
||||
<div className={style["docs-grid"]}>
|
||||
<Card className={style["doc-card"]}>
|
||||
<div className={style["doc-icon"]}>
|
||||
<BookOutlined />
|
||||
</div>
|
||||
<h4>完整API文档</h4>
|
||||
<p>详细的接口说明和参数文档</p>
|
||||
</Card>
|
||||
<Card className={style["doc-card"]}>
|
||||
<div className={style["doc-icon"]}>
|
||||
<LinkOutlined />
|
||||
</div>
|
||||
<h4>集成指南</h4>
|
||||
<p>第三方平台集成教程</p>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderCodeTab = () => {
|
||||
const codeExamples = {
|
||||
javascript: `fetch('${fullWebhookUrl}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer ${apiKey}'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: '张三',
|
||||
phone: '13800138000',
|
||||
source: '官网表单',
|
||||
})
|
||||
})`,
|
||||
python: `import requests
|
||||
|
||||
url = '${fullWebhookUrl}'
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer ${apiKey}'
|
||||
}
|
||||
data = {
|
||||
'name': '张三',
|
||||
'phone': '13800138000',
|
||||
'source': '官网表单'
|
||||
}
|
||||
|
||||
response = requests.post(url, json=data, headers=headers)`,
|
||||
php: `<?php
|
||||
$url = '${fullWebhookUrl}';
|
||||
$data = array(
|
||||
'name' => '张三',
|
||||
'phone' => '13800138000',
|
||||
'source' => '官网表单'
|
||||
);
|
||||
|
||||
$options = array(
|
||||
'http' => array(
|
||||
'header' => "Content-type: application/json\\r\\nAuthorization: Bearer ${apiKey}\\r\\n",
|
||||
'method' => 'POST',
|
||||
'content' => json_encode($data)
|
||||
)
|
||||
);
|
||||
|
||||
$context = stream_context_create($options);
|
||||
$result = file_get_contents($url, false, $context);`,
|
||||
java: `import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.net.URI;
|
||||
|
||||
HttpClient client = HttpClient.newHttpClient();
|
||||
String json = "{\\"name\\":\\"张三\\",\\"phone\\":\\"13800138000\\",\\"source\\":\\"官网表单\\"}";
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create("${fullWebhookUrl}"))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Authorization", "Bearer ${apiKey}")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(json))
|
||||
.build();
|
||||
|
||||
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());`,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={style["code-content"]}>
|
||||
<div className={style["language-tabs"]}>
|
||||
{Object.keys(codeExamples).map((lang) => (
|
||||
<button
|
||||
key={lang}
|
||||
className={`${style["lang-tab"]} ${
|
||||
activeLanguage === lang ? style["active"] : ""
|
||||
}`}
|
||||
onClick={() => setActiveLanguage(lang)}
|
||||
>
|
||||
{lang.charAt(0).toUpperCase() + lang.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className={style["code-block"]}>
|
||||
<pre className={style["code"]}>
|
||||
<code>
|
||||
{codeExamples[activeLanguage as keyof typeof codeExamples]}
|
||||
</code>
|
||||
</pre>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() =>
|
||||
handleCopy(
|
||||
codeExamples[activeLanguage as keyof typeof codeExamples],
|
||||
"代码"
|
||||
)
|
||||
}
|
||||
className={style["copy-code-btn"]}
|
||||
>
|
||||
<CopyOutlined />
|
||||
复制代码
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderContent = () => (
|
||||
<div className={style["plan-api-dialog"]}>
|
||||
{/* 头部 */}
|
||||
<div className={style["dialog-header"]}>
|
||||
<div className={style["header-left"]}>
|
||||
<CodeOutlined className={style["header-icon"]} />
|
||||
<div className={style["header-content"]}>
|
||||
<h3>计划接口配置</h3>
|
||||
<p>
|
||||
通过API接口直接导入客资到该获客计划,支持多种编程语言和第三方平台集成
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button size="small" onClick={onClose} className={style["close-btn"]}>
|
||||
×
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 导航标签 */}
|
||||
<div className={style["nav-tabs"]}>
|
||||
<button
|
||||
className={`${style["nav-tab"]} ${activeTab === "config" ? style["active"] : ""}`}
|
||||
onClick={() => setActiveTab("config")}
|
||||
>
|
||||
<SettingOutlined />
|
||||
接口配置
|
||||
</button>
|
||||
<button
|
||||
className={`${style["nav-tab"]} ${activeTab === "test" ? style["active"] : ""}`}
|
||||
onClick={() => setActiveTab("test")}
|
||||
>
|
||||
<ThunderboltOutlined />
|
||||
快速测试
|
||||
</button>
|
||||
<button
|
||||
className={`${style["nav-tab"]} ${activeTab === "docs" ? style["active"] : ""}`}
|
||||
onClick={() => setActiveTab("docs")}
|
||||
>
|
||||
<BookOutlined />
|
||||
开发文档
|
||||
</button>
|
||||
<button
|
||||
className={`${style["nav-tab"]} ${activeTab === "code" ? style["active"] : ""}`}
|
||||
onClick={() => setActiveTab("code")}
|
||||
>
|
||||
<CodeOutlined />
|
||||
代码示例
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 内容区域 */}
|
||||
<div className={style["dialog-content"]}>
|
||||
{activeTab === "config" && renderConfigTab()}
|
||||
{activeTab === "test" && renderQuickTestTab()}
|
||||
{activeTab === "docs" && renderDocsTab()}
|
||||
{activeTab === "code" && renderCodeTab()}
|
||||
</div>
|
||||
|
||||
{/* 底部 */}
|
||||
<div className={style["dialog-footer"]}>
|
||||
<div className={style["security-note"]}>
|
||||
<SafetyOutlined />
|
||||
所有数据传输均采用HTTPS加密
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={onClose}
|
||||
className={style["complete-btn"]}
|
||||
>
|
||||
完成配置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 移动端使用Popup
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Popup
|
||||
visible={visible}
|
||||
onMaskClick={onClose}
|
||||
position="bottom"
|
||||
bodyStyle={{ height: "90vh" }}
|
||||
>
|
||||
{renderContent()}
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
|
||||
// PC端使用Modal
|
||||
return (
|
||||
<Modal
|
||||
open={visible}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
width={800}
|
||||
centered
|
||||
className={style["plan-api-modal"]}
|
||||
>
|
||||
{renderContent()}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlanApi;
|
||||
import React, { useState, useMemo } from "react";
|
||||
import { Popup, Button, Toast, SpinLoading } from "antd-mobile";
|
||||
import { Modal, Input, Tabs, Card, Tag, Space } from "antd";
|
||||
import {
|
||||
CopyOutlined,
|
||||
CodeOutlined,
|
||||
BookOutlined,
|
||||
ThunderboltOutlined,
|
||||
SettingOutlined,
|
||||
LinkOutlined,
|
||||
SafetyOutlined,
|
||||
CheckCircleOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import style from "./planApi.module.scss";
|
||||
import { buildApiUrl } from "@/utils/apiUrl";
|
||||
|
||||
/**
|
||||
* 计划接口配置弹窗组件
|
||||
*
|
||||
* 使用示例:
|
||||
* ```tsx
|
||||
* const [showApiDialog, setShowApiDialog] = useState(false);
|
||||
* const [apiSettings, setApiSettings] = useState({
|
||||
* apiKey: "your-api-key",
|
||||
* webhookUrl: "https://api.example.com/webhook",
|
||||
* taskId: "task-123"
|
||||
* });
|
||||
*
|
||||
* <PlanApi
|
||||
* visible={showApiDialog}
|
||||
* onClose={() => setShowApiDialog(false)}
|
||||
* apiKey={apiSettings.apiKey}
|
||||
* webhookUrl={apiSettings.webhookUrl}
|
||||
* taskId={apiSettings.taskId}
|
||||
* />
|
||||
* ```
|
||||
*
|
||||
* 特性:
|
||||
* - 移动端使用 Popup,PC端使用 Modal
|
||||
* - 支持四个标签页:接口配置、快速测试、开发文档、代码示例
|
||||
* - 支持多种编程语言的代码示例
|
||||
* - 响应式设计,自适应不同屏幕尺寸
|
||||
* - 支持暗色主题
|
||||
* - 自动拼接API地址前缀
|
||||
*/
|
||||
|
||||
interface PlanApiProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
apiKey: string;
|
||||
webhookUrl: string;
|
||||
taskId: string;
|
||||
}
|
||||
|
||||
interface ApiSettings {
|
||||
apiKey: string;
|
||||
webhookUrl: string;
|
||||
taskId: string;
|
||||
}
|
||||
|
||||
const PlanApi: React.FC<PlanApiProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
apiKey,
|
||||
webhookUrl,
|
||||
taskId,
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState("config");
|
||||
const [activeLanguage, setActiveLanguage] = useState("javascript");
|
||||
|
||||
// 处理webhook URL,确保包含完整的API地址
|
||||
const fullWebhookUrl = useMemo(() => {
|
||||
return buildApiUrl(webhookUrl);
|
||||
}, [webhookUrl]);
|
||||
|
||||
// 生成测试URL
|
||||
const testUrl = useMemo(() => {
|
||||
if (!fullWebhookUrl) return "";
|
||||
return `${fullWebhookUrl}?name=测试客户&phone=13800138000&source=API测试`;
|
||||
}, [fullWebhookUrl]);
|
||||
|
||||
// 检测是否为移动端
|
||||
const isMobile = window.innerWidth <= 768;
|
||||
|
||||
const handleCopy = (text: string, type: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
Toast.show({
|
||||
content: `${type}已复制到剪贴板`,
|
||||
position: "top",
|
||||
});
|
||||
};
|
||||
|
||||
const handleTestInBrowser = () => {
|
||||
window.open(testUrl, "_blank");
|
||||
};
|
||||
|
||||
const renderConfigTab = () => (
|
||||
<div className={style["config-content"]}>
|
||||
{/* API密钥配置 */}
|
||||
<div className={style["config-section"]}>
|
||||
<div className={style["section-header"]}>
|
||||
<div className={style["section-title"]}>
|
||||
<CheckCircleOutlined className={style["section-icon"]} />
|
||||
API密钥
|
||||
</div>
|
||||
<Tag color="green">安全认证</Tag>
|
||||
</div>
|
||||
<div className={style["input-group"]}>
|
||||
<Input value={apiKey} disabled className={style["api-input"]} />
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => handleCopy(apiKey, "API密钥")}
|
||||
className={style["copy-btn"]}
|
||||
>
|
||||
<CopyOutlined />
|
||||
复制
|
||||
</Button>
|
||||
</div>
|
||||
<div className={style["security-tip"]}>
|
||||
<strong>安全提示:</strong>
|
||||
请妥善保管API密钥,不要在客户端代码中暴露。建议在服务器端使用该密钥。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 接口地址配置 */}
|
||||
<div className={style["config-section"]}>
|
||||
<div className={style["section-header"]}>
|
||||
<div className={style["section-title"]}>
|
||||
<LinkOutlined className={style["section-icon"]} />
|
||||
接口地址
|
||||
</div>
|
||||
<Tag color="blue">POST请求</Tag>
|
||||
</div>
|
||||
<div className={style["input-group"]}>
|
||||
<Input
|
||||
value={fullWebhookUrl}
|
||||
disabled
|
||||
className={style["api-input"]}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => handleCopy(fullWebhookUrl, "接口地址")}
|
||||
className={style["copy-btn"]}
|
||||
>
|
||||
<CopyOutlined />
|
||||
复制
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 参数说明 */}
|
||||
<div className={style["params-grid"]}>
|
||||
<div className={style["param-section"]}>
|
||||
<h4>必要参数</h4>
|
||||
<div className={style["param-list"]}>
|
||||
<div>
|
||||
<code>name</code> - 客户姓名
|
||||
</div>
|
||||
<div>
|
||||
<code>phone</code> - 手机号码
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={style["param-section"]}>
|
||||
<h4>可选参数</h4>
|
||||
<div className={style["param-list"]}>
|
||||
<div>
|
||||
<code>source</code> - 来源标识
|
||||
</div>
|
||||
<div>
|
||||
<code>remark</code> - 备注信息
|
||||
</div>
|
||||
<div>
|
||||
<code>tags</code> - 客户标签
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderQuickTestTab = () => (
|
||||
<div className={style["test-content"]}>
|
||||
<div className={style["test-section"]}>
|
||||
<h3>快速测试URL</h3>
|
||||
<div className={style["input-group"]}>
|
||||
<Input value={testUrl} disabled className={style["test-input"]} />
|
||||
</div>
|
||||
<div className={style["test-buttons"]}>
|
||||
<Button
|
||||
onClick={() => handleCopy(testUrl, "测试URL")}
|
||||
className={style["test-btn"]}
|
||||
>
|
||||
<CopyOutlined />
|
||||
复制测试URL
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleTestInBrowser}
|
||||
className={style["test-btn"]}
|
||||
>
|
||||
在浏览器中测试
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderDocsTab = () => (
|
||||
<div className={style["docs-content"]}>
|
||||
<div className={style["docs-grid"]}>
|
||||
<Card className={style["doc-card"]}>
|
||||
<div className={style["doc-icon"]}>
|
||||
<BookOutlined />
|
||||
</div>
|
||||
<h4>完整API文档</h4>
|
||||
<p>详细的接口说明和参数文档</p>
|
||||
</Card>
|
||||
<Card className={style["doc-card"]}>
|
||||
<div className={style["doc-icon"]}>
|
||||
<LinkOutlined />
|
||||
</div>
|
||||
<h4>集成指南</h4>
|
||||
<p>第三方平台集成教程</p>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderCodeTab = () => {
|
||||
const codeExamples = {
|
||||
javascript: `fetch('${fullWebhookUrl}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer ${apiKey}'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: '张三',
|
||||
phone: '13800138000',
|
||||
source: '官网表单',
|
||||
})
|
||||
})`,
|
||||
python: `import requests
|
||||
|
||||
url = '${fullWebhookUrl}'
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer ${apiKey}'
|
||||
}
|
||||
data = {
|
||||
'name': '张三',
|
||||
'phone': '13800138000',
|
||||
'source': '官网表单'
|
||||
}
|
||||
|
||||
response = requests.post(url, json=data, headers=headers)`,
|
||||
php: `<?php
|
||||
$url = '${fullWebhookUrl}';
|
||||
$data = array(
|
||||
'name' => '张三',
|
||||
'phone' => '13800138000',
|
||||
'source' => '官网表单'
|
||||
);
|
||||
|
||||
$options = array(
|
||||
'http' => array(
|
||||
'header' => "Content-type: application/json\\r\\nAuthorization: Bearer ${apiKey}\\r\\n",
|
||||
'method' => 'POST',
|
||||
'content' => json_encode($data)
|
||||
)
|
||||
);
|
||||
|
||||
$context = stream_context_create($options);
|
||||
$result = file_get_contents($url, false, $context);`,
|
||||
java: `import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.net.URI;
|
||||
|
||||
HttpClient client = HttpClient.newHttpClient();
|
||||
String json = "{\\"name\\":\\"张三\\",\\"phone\\":\\"13800138000\\",\\"source\\":\\"官网表单\\"}";
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create("${fullWebhookUrl}"))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Authorization", "Bearer ${apiKey}")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(json))
|
||||
.build();
|
||||
|
||||
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());`,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={style["code-content"]}>
|
||||
<div className={style["language-tabs"]}>
|
||||
{Object.keys(codeExamples).map(lang => (
|
||||
<button
|
||||
key={lang}
|
||||
className={`${style["lang-tab"]} ${
|
||||
activeLanguage === lang ? style["active"] : ""
|
||||
}`}
|
||||
onClick={() => setActiveLanguage(lang)}
|
||||
>
|
||||
{lang.charAt(0).toUpperCase() + lang.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className={style["code-block"]}>
|
||||
<pre className={style["code"]}>
|
||||
<code>
|
||||
{codeExamples[activeLanguage as keyof typeof codeExamples]}
|
||||
</code>
|
||||
</pre>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() =>
|
||||
handleCopy(
|
||||
codeExamples[activeLanguage as keyof typeof codeExamples],
|
||||
"代码"
|
||||
)
|
||||
}
|
||||
className={style["copy-code-btn"]}
|
||||
>
|
||||
<CopyOutlined />
|
||||
复制代码
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderContent = () => (
|
||||
<div className={style["plan-api-dialog"]}>
|
||||
{/* 头部 */}
|
||||
<div className={style["dialog-header"]}>
|
||||
<div className={style["header-left"]}>
|
||||
<CodeOutlined className={style["header-icon"]} />
|
||||
<div className={style["header-content"]}>
|
||||
<h3>计划接口配置</h3>
|
||||
<p>
|
||||
通过API接口直接导入客资到该获客计划,支持多种编程语言和第三方平台集成
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button size="small" onClick={onClose} className={style["close-btn"]}>
|
||||
×
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 导航标签 */}
|
||||
<div className={style["nav-tabs"]}>
|
||||
<button
|
||||
className={`${style["nav-tab"]} ${activeTab === "config" ? style["active"] : ""}`}
|
||||
onClick={() => setActiveTab("config")}
|
||||
>
|
||||
<SettingOutlined />
|
||||
接口配置
|
||||
</button>
|
||||
<button
|
||||
className={`${style["nav-tab"]} ${activeTab === "test" ? style["active"] : ""}`}
|
||||
onClick={() => setActiveTab("test")}
|
||||
>
|
||||
<ThunderboltOutlined />
|
||||
快速测试
|
||||
</button>
|
||||
<button
|
||||
className={`${style["nav-tab"]} ${activeTab === "docs" ? style["active"] : ""}`}
|
||||
onClick={() => setActiveTab("docs")}
|
||||
>
|
||||
<BookOutlined />
|
||||
开发文档
|
||||
</button>
|
||||
<button
|
||||
className={`${style["nav-tab"]} ${activeTab === "code" ? style["active"] : ""}`}
|
||||
onClick={() => setActiveTab("code")}
|
||||
>
|
||||
<CodeOutlined />
|
||||
代码示例
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 内容区域 */}
|
||||
<div className={style["dialog-content"]}>
|
||||
{activeTab === "config" && renderConfigTab()}
|
||||
{activeTab === "test" && renderQuickTestTab()}
|
||||
{activeTab === "docs" && renderDocsTab()}
|
||||
{activeTab === "code" && renderCodeTab()}
|
||||
</div>
|
||||
|
||||
{/* 底部 */}
|
||||
<div className={style["dialog-footer"]}>
|
||||
<div className={style["security-note"]}>
|
||||
<SafetyOutlined />
|
||||
所有数据传输均采用HTTPS加密
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={onClose}
|
||||
className={style["complete-btn"]}
|
||||
>
|
||||
完成配置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 移动端使用Popup
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Popup
|
||||
visible={visible}
|
||||
onMaskClick={onClose}
|
||||
position="bottom"
|
||||
bodyStyle={{ height: "90vh" }}
|
||||
>
|
||||
{renderContent()}
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
|
||||
// PC端使用Modal
|
||||
return (
|
||||
<Modal
|
||||
open={visible}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
width={800}
|
||||
centered
|
||||
className={style["plan-api-modal"]}
|
||||
>
|
||||
{renderContent()}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlanApi;
|
||||
|
||||
@@ -1,53 +1,53 @@
|
||||
import request from "@/api/request";
|
||||
// 获取场景类型列表
|
||||
export function getScenarioTypes() {
|
||||
return request("/v1/plan/scenes", undefined, "GET");
|
||||
}
|
||||
|
||||
// 创建计划
|
||||
export function createPlan(data: any) {
|
||||
return request("/v1/plan/create", data, "POST");
|
||||
}
|
||||
|
||||
// 更新计划
|
||||
export function updatePlan(planId: string, data: any) {
|
||||
return request(`/v1/scenarios/plans/${planId}`, data, "PUT");
|
||||
}
|
||||
|
||||
// 获取计划详情
|
||||
export function getPlanDetail(planId: string) {
|
||||
return request(`/v1/scenarios/plans/${planId}`, undefined, "GET");
|
||||
}
|
||||
|
||||
// PlanDetail 类型定义(可根据实际接口返回结构补充字段)
|
||||
export interface PlanDetail {
|
||||
name: string;
|
||||
scenario: number;
|
||||
posters: any[];
|
||||
device: string[];
|
||||
remarkType: string;
|
||||
greeting: string;
|
||||
addInterval: number;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
enabled: boolean;
|
||||
sceneId: string | number;
|
||||
remarkFormat: string;
|
||||
addFriendInterval: number;
|
||||
// 其它字段可扩展
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// 兼容旧代码的接口命名
|
||||
export function getPlanScenes() {
|
||||
return getScenarioTypes();
|
||||
}
|
||||
export function createScenarioPlan(data: any) {
|
||||
return createPlan(data);
|
||||
}
|
||||
export function fetchPlanDetail(planId: string) {
|
||||
return getPlanDetail(planId);
|
||||
}
|
||||
export function updateScenarioPlan(planId: string, data: any) {
|
||||
return updatePlan(planId, data);
|
||||
}
|
||||
import request from "@/api/request";
|
||||
// 获取场景类型列表
|
||||
export function getScenarioTypes() {
|
||||
return request("/v1/plan/scenes", undefined, "GET");
|
||||
}
|
||||
|
||||
// 创建计划
|
||||
export function createPlan(data: any) {
|
||||
return request("/v1/plan/create", data, "POST");
|
||||
}
|
||||
|
||||
// 更新计划
|
||||
export function updatePlan(planId: string, data: any) {
|
||||
return request(`/v1/scenarios/plans/${planId}`, data, "PUT");
|
||||
}
|
||||
|
||||
// 获取计划详情
|
||||
export function getPlanDetail(planId: string) {
|
||||
return request(`/v1/scenarios/plans/${planId}`, undefined, "GET");
|
||||
}
|
||||
|
||||
// PlanDetail 类型定义(可根据实际接口返回结构补充字段)
|
||||
export interface PlanDetail {
|
||||
name: string;
|
||||
scenario: number;
|
||||
posters: any[];
|
||||
device: string[];
|
||||
remarkType: string;
|
||||
greeting: string;
|
||||
addInterval: number;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
enabled: boolean;
|
||||
sceneId: string | number;
|
||||
remarkFormat: string;
|
||||
addFriendInterval: number;
|
||||
// 其它字段可扩展
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// 兼容旧代码的接口命名
|
||||
export function getPlanScenes() {
|
||||
return getScenarioTypes();
|
||||
}
|
||||
export function createScenarioPlan(data: any) {
|
||||
return createPlan(data);
|
||||
}
|
||||
export function fetchPlanDetail(planId: string) {
|
||||
return getPlanDetail(planId);
|
||||
}
|
||||
export function updateScenarioPlan(planId: string, data: any) {
|
||||
return updatePlan(planId, data);
|
||||
}
|
||||
|
||||
@@ -71,10 +71,10 @@ export default function NewPlan() {
|
||||
setSceneLoading(true);
|
||||
//获取场景类型
|
||||
getScenarioTypes()
|
||||
.then((data) => {
|
||||
.then(data => {
|
||||
setSceneList(data || []);
|
||||
})
|
||||
.catch((err) => {
|
||||
.catch(err => {
|
||||
message.error(err.message || "获取场景类型失败");
|
||||
})
|
||||
.finally(() => setSceneLoading(false));
|
||||
@@ -83,7 +83,7 @@ export default function NewPlan() {
|
||||
//获取计划详情
|
||||
|
||||
const detail = await getPlanDetail(planId);
|
||||
setFormData((prev) => ({
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
name: detail.name ?? "",
|
||||
scenario: Number(detail.scenario) || 1,
|
||||
@@ -102,7 +102,7 @@ export default function NewPlan() {
|
||||
}));
|
||||
} else {
|
||||
if (scenarioId) {
|
||||
setFormData((prev) => ({
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
...{ scenario: Number(scenarioId) || 1 },
|
||||
}));
|
||||
@@ -112,7 +112,7 @@ export default function NewPlan() {
|
||||
|
||||
// 更新表单数据
|
||||
const onChange = (data: any) => {
|
||||
setFormData((prev) => ({ ...prev, ...data }));
|
||||
setFormData(prev => ({ ...prev, ...data }));
|
||||
};
|
||||
|
||||
// 处理保存
|
||||
@@ -136,7 +136,7 @@ export default function NewPlan() {
|
||||
result = await createPlan(formData);
|
||||
}
|
||||
message.success(isEdit ? "计划已更新" : "获客计划已创建");
|
||||
const sceneItem = sceneList.find((v) => formData.scenario === v.id);
|
||||
const sceneItem = sceneList.find(v => formData.scenario === v.id);
|
||||
router(`/scenarios/list/${formData.scenario}/${sceneItem.name}`);
|
||||
} catch (error) {
|
||||
message.error(
|
||||
@@ -156,13 +156,13 @@ export default function NewPlan() {
|
||||
if (currentStep === steps.length) {
|
||||
handleSave();
|
||||
} else {
|
||||
setCurrentStep((prev) => prev + 1);
|
||||
setCurrentStep(prev => prev + 1);
|
||||
}
|
||||
};
|
||||
|
||||
// 上一步
|
||||
const handlePrev = () => {
|
||||
setCurrentStep((prev) => Math.max(prev - 1, 1));
|
||||
setCurrentStep(prev => Math.max(prev - 1, 1));
|
||||
};
|
||||
|
||||
// 渲染当前步骤内容
|
||||
|
||||
@@ -82,7 +82,7 @@ const generateRandomAccounts = (count: number): Account[] => {
|
||||
};
|
||||
|
||||
const generatePosterMaterials = (): Material[] => {
|
||||
return posterTemplates.map((template) => ({
|
||||
return posterTemplates.map(template => ({
|
||||
id: template.id,
|
||||
name: template.name,
|
||||
type: "poster",
|
||||
@@ -190,7 +190,7 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({
|
||||
|
||||
useEffect(() => {
|
||||
const today = new Date().toLocaleDateString("zh-CN").replace(/\//g, "");
|
||||
const sceneItem = sceneList.find((v) => formData.scenario === v.id);
|
||||
const sceneItem = sceneList.find(v => formData.scenario === v.id);
|
||||
onChange({ ...formData, name: `${sceneItem?.name || "海报"}${today}` });
|
||||
}, [isEdit]);
|
||||
|
||||
@@ -251,15 +251,15 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({
|
||||
type: "poster",
|
||||
preview: urls[0],
|
||||
};
|
||||
setCustomPosters((prev) => [...prev, newPoster]);
|
||||
setCustomPosters(prev => [...prev, newPoster]);
|
||||
}
|
||||
};
|
||||
|
||||
// 新增:删除自定义海报
|
||||
const handleRemoveCustomPoster = (id: string) => {
|
||||
setCustomPosters((prev) => prev.filter((p) => p.id !== id));
|
||||
setCustomPosters(prev => prev.filter(p => p.id !== id));
|
||||
// 如果选中则取消选中
|
||||
if (selectedMaterials.some((m) => m.id === id)) {
|
||||
if (selectedMaterials.some(m => m.id === id)) {
|
||||
setSelectedMaterials([]);
|
||||
onChange({ ...formData, materials: [] });
|
||||
}
|
||||
@@ -267,7 +267,7 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({
|
||||
|
||||
// 修改:选中/取消选中海报
|
||||
const handleMaterialSelect = (material: Material) => {
|
||||
const isSelected = selectedMaterials.some((m) => m.id === material.id);
|
||||
const isSelected = selectedMaterials.some(m => m.id === material.id);
|
||||
if (isSelected) {
|
||||
setSelectedMaterials([]);
|
||||
onChange({ ...formData, materials: [] });
|
||||
@@ -318,11 +318,11 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
reader.onload = e => {
|
||||
try {
|
||||
const content = e.target?.result as string;
|
||||
const rows = content.split("\n").filter((row) => row.trim());
|
||||
const tags = rows.slice(1).map((row) => {
|
||||
const rows = content.split("\n").filter(row => row.trim());
|
||||
const tags = rows.slice(1).map(row => {
|
||||
const [phone, wechat, source, orderAmount, orderDate] =
|
||||
row.split(",");
|
||||
return {
|
||||
@@ -405,7 +405,7 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({
|
||||
};
|
||||
|
||||
// 当前选中的场景对象
|
||||
const currentScene = sceneList.find((s) => s.id === formData.scenario);
|
||||
const currentScene = sceneList.find(s => s.id === formData.scenario);
|
||||
//打开订单
|
||||
const openOrder =
|
||||
formData.scenario !== 2 ? { display: "none" } : { display: "block" };
|
||||
@@ -430,7 +430,7 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles["basic-scene-grid"]}>
|
||||
{sceneList.map((scene) => {
|
||||
{sceneList.map(scene => {
|
||||
const selected = formData.scenario === scene.id;
|
||||
return (
|
||||
<button
|
||||
@@ -453,7 +453,7 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({
|
||||
<div className={styles["basic-input-block"]}>
|
||||
<Input
|
||||
value={formData.name}
|
||||
onChange={(e) =>
|
||||
onChange={e =>
|
||||
onChange({ ...formData, name: String(e.target.value) })
|
||||
}
|
||||
placeholder="请输入计划名称"
|
||||
@@ -493,7 +493,7 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({
|
||||
<Input
|
||||
type="text"
|
||||
value={customTagInput}
|
||||
onChange={(e) => setCustomTagInput(e.target.value)}
|
||||
onChange={e => setCustomTagInput(e.target.value)}
|
||||
placeholder="添加自定义标签"
|
||||
/>
|
||||
<Button type="primary" onClick={handleAddCustomTag}>
|
||||
@@ -505,7 +505,7 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({
|
||||
<Input
|
||||
type="text"
|
||||
value={tips}
|
||||
onChange={(e) => {
|
||||
onChange={e => {
|
||||
setTips(e.target.value);
|
||||
onChange({ ...formData, tips: e.target.value });
|
||||
}}
|
||||
@@ -516,9 +516,9 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({
|
||||
<div className={styles["basic-materials"]} style={openPoster}>
|
||||
<div className={styles["basic-label"]}>选择海报</div>
|
||||
<div className={styles["basic-materials-grid"]}>
|
||||
{[...materials, ...customPosters].map((material) => {
|
||||
{[...materials, ...customPosters].map(material => {
|
||||
const isSelected = selectedMaterials.some(
|
||||
(m) => m.id === material.id
|
||||
m => m.id === material.id
|
||||
);
|
||||
const isCustom = material.id.startsWith("custom-");
|
||||
return (
|
||||
@@ -533,7 +533,7 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({
|
||||
{/* 预览按钮:自定义海报在左上,内置海报在右上 */}
|
||||
<span
|
||||
className={styles["basic-material-preview"]}
|
||||
onClick={(e) => {
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
handlePreviewImage(material.preview);
|
||||
}}
|
||||
@@ -562,7 +562,7 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({
|
||||
lineHeight: 20,
|
||||
color: "#ffffff",
|
||||
}}
|
||||
onClick={(e) => {
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
handleRemoveCustomPoster(material.id);
|
||||
}}
|
||||
@@ -595,7 +595,7 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: "none" }}
|
||||
onChange={async (e) => {
|
||||
onChange={async e => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
// 直接上传
|
||||
@@ -607,7 +607,7 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({
|
||||
type: "poster",
|
||||
preview: url,
|
||||
};
|
||||
setCustomPosters((prev) => [...prev, newPoster]);
|
||||
setCustomPosters(prev => [...prev, newPoster]);
|
||||
} catch (err) {
|
||||
// 可加toast提示
|
||||
}
|
||||
@@ -693,9 +693,7 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({
|
||||
<span>自动加好友</span>
|
||||
<Switch
|
||||
checked={phoneSettings.autoAdd}
|
||||
onChange={(v) =>
|
||||
setPhoneSettings((s) => ({ ...s, autoAdd: v }))
|
||||
}
|
||||
onChange={v => setPhoneSettings(s => ({ ...s, autoAdd: v }))}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
@@ -708,8 +706,8 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({
|
||||
<span>语音转文字</span>
|
||||
<Switch
|
||||
checked={phoneSettings.speechToText}
|
||||
onChange={(v) =>
|
||||
setPhoneSettings((s) => ({ ...s, speechToText: v }))
|
||||
onChange={v =>
|
||||
setPhoneSettings(s => ({ ...s, speechToText: v }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -723,8 +721,8 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({
|
||||
<span>问题提取</span>
|
||||
<Switch
|
||||
checked={phoneSettings.questionExtraction}
|
||||
onChange={(v) =>
|
||||
setPhoneSettings((s) => ({ ...s, questionExtraction: v }))
|
||||
onChange={v =>
|
||||
setPhoneSettings(s => ({ ...s, questionExtraction: v }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -758,7 +756,7 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({
|
||||
<span>是否启用</span>
|
||||
<Switch
|
||||
checked={formData.enabled}
|
||||
onChange={(value) => onChange({ ...formData, enabled: value })}
|
||||
onChange={value => onChange({ ...formData, enabled: value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ const FriendRequestSettings: React.FC<FriendRequestSettingsProps> = ({
|
||||
<div className={styles["friend-block"]}>
|
||||
<DeviceSelection
|
||||
selectedDevices={selectedDevices}
|
||||
onSelect={(deviceIds) => {
|
||||
onSelect={deviceIds => {
|
||||
setSelectedDevices(deviceIds);
|
||||
onChange({ ...formData, device: deviceIds });
|
||||
}}
|
||||
@@ -107,10 +107,10 @@ const FriendRequestSettings: React.FC<FriendRequestSettingsProps> = ({
|
||||
<div className={styles["friend-block"]} style={{ position: "relative" }}>
|
||||
<Select
|
||||
value={formData.remarkType || "phone"}
|
||||
onChange={(value) => onChange({ ...formData, remarkType: value })}
|
||||
onChange={value => onChange({ ...formData, remarkType: value })}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
{remarkTypes.map((type) => (
|
||||
{remarkTypes.map(type => (
|
||||
<Select.Option key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</Select.Option>
|
||||
@@ -146,7 +146,7 @@ const FriendRequestSettings: React.FC<FriendRequestSettingsProps> = ({
|
||||
<div className={styles["friend-block"]}>
|
||||
<Input
|
||||
value={formData.greeting}
|
||||
onChange={(e) => onChange({ ...formData, greeting: e.target.value })}
|
||||
onChange={e => onChange({ ...formData, greeting: e.target.value })}
|
||||
placeholder="请输入招呼语"
|
||||
suffix={
|
||||
<Button
|
||||
@@ -168,7 +168,7 @@ const FriendRequestSettings: React.FC<FriendRequestSettingsProps> = ({
|
||||
<Input
|
||||
type="number"
|
||||
value={formData.addFriendInterval || 1}
|
||||
onChange={(e) =>
|
||||
onChange={e =>
|
||||
onChange({
|
||||
...formData,
|
||||
addFriendInterval: Number(e.target.value),
|
||||
@@ -185,7 +185,7 @@ const FriendRequestSettings: React.FC<FriendRequestSettingsProps> = ({
|
||||
<Input
|
||||
type="time"
|
||||
value={formData.addFriendTimeStart || "09:00"}
|
||||
onChange={(e) =>
|
||||
onChange={e =>
|
||||
onChange({ ...formData, addFriendTimeStart: e.target.value })
|
||||
}
|
||||
style={{ width: 120 }}
|
||||
@@ -194,7 +194,7 @@ const FriendRequestSettings: React.FC<FriendRequestSettingsProps> = ({
|
||||
<Input
|
||||
type="time"
|
||||
value={formData.addFriendTimeEnd || "18:00"}
|
||||
onChange={(e) =>
|
||||
onChange={e =>
|
||||
onChange({ ...formData, addFriendTimeEnd: e.target.value })
|
||||
}
|
||||
style={{ width: 120 }}
|
||||
|
||||
@@ -181,7 +181,7 @@ const MessageSettings: React.FC<MessageSettingsProps> = ({
|
||||
setSelectedGroupId(groupId);
|
||||
setIsGroupSelectOpen(false);
|
||||
message.success(
|
||||
`已选择群组:${mockGroups.find((g) => g.id === groupId)?.name}`
|
||||
`已选择群组:${mockGroups.find(g => g.id === groupId)?.name}`
|
||||
);
|
||||
};
|
||||
|
||||
@@ -213,7 +213,7 @@ const MessageSettings: React.FC<MessageSettingsProps> = ({
|
||||
try {
|
||||
const url = await uploadFile(file);
|
||||
// 更新对应消息的coverImage
|
||||
setDayPlans((prev) => {
|
||||
setDayPlans(prev => {
|
||||
const newPlans = [...prev];
|
||||
const msg = newPlans[uploadingDay].messages[uploadingMsgIdx];
|
||||
msg.coverImage = url;
|
||||
@@ -248,7 +248,7 @@ const MessageSettings: React.FC<MessageSettingsProps> = ({
|
||||
<Input
|
||||
type="number"
|
||||
value={String(message.sendInterval || 5)}
|
||||
onChange={(e) =>
|
||||
onChange={e =>
|
||||
handleUpdateMessage(dayIndex, messageIndex, {
|
||||
sendInterval: Number(e.target.value),
|
||||
})
|
||||
@@ -273,7 +273,7 @@ const MessageSettings: React.FC<MessageSettingsProps> = ({
|
||||
min={0}
|
||||
max={23}
|
||||
value={String(message.scheduledTime?.hour || 9)}
|
||||
onChange={(e) =>
|
||||
onChange={e =>
|
||||
handleUpdateMessage(dayIndex, messageIndex, {
|
||||
scheduledTime: {
|
||||
...(message.scheduledTime || {
|
||||
@@ -293,7 +293,7 @@ const MessageSettings: React.FC<MessageSettingsProps> = ({
|
||||
min={0}
|
||||
max={59}
|
||||
value={String(message.scheduledTime?.minute || 0)}
|
||||
onChange={(e) =>
|
||||
onChange={e =>
|
||||
handleUpdateMessage(dayIndex, messageIndex, {
|
||||
scheduledTime: {
|
||||
...(message.scheduledTime || {
|
||||
@@ -313,7 +313,7 @@ const MessageSettings: React.FC<MessageSettingsProps> = ({
|
||||
min={0}
|
||||
max={59}
|
||||
value={String(message.scheduledTime?.second || 0)}
|
||||
onChange={(e) =>
|
||||
onChange={e =>
|
||||
handleUpdateMessage(dayIndex, messageIndex, {
|
||||
scheduledTime: {
|
||||
...(message.scheduledTime || {
|
||||
@@ -340,7 +340,7 @@ const MessageSettings: React.FC<MessageSettingsProps> = ({
|
||||
</div>
|
||||
{/* 类型切换按钮 */}
|
||||
<div className={styles["messages-message-type-btns"]}>
|
||||
{messageTypes.map((type) => (
|
||||
{messageTypes.map(type => (
|
||||
<Button
|
||||
key={type.id}
|
||||
type={message.type === type.id ? "primary" : "default"}
|
||||
@@ -361,7 +361,7 @@ const MessageSettings: React.FC<MessageSettingsProps> = ({
|
||||
{message.type === "text" && (
|
||||
<Input.TextArea
|
||||
value={message.content}
|
||||
onChange={(e) =>
|
||||
onChange={e =>
|
||||
handleUpdateMessage(dayIndex, messageIndex, {
|
||||
content: e.target.value,
|
||||
})
|
||||
@@ -375,7 +375,7 @@ const MessageSettings: React.FC<MessageSettingsProps> = ({
|
||||
<>
|
||||
<Input
|
||||
value={message.title}
|
||||
onChange={(e) =>
|
||||
onChange={e =>
|
||||
handleUpdateMessage(dayIndex, messageIndex, {
|
||||
title: e.target.value,
|
||||
})
|
||||
@@ -385,7 +385,7 @@ const MessageSettings: React.FC<MessageSettingsProps> = ({
|
||||
/>
|
||||
<Input
|
||||
value={message.description}
|
||||
onChange={(e) =>
|
||||
onChange={e =>
|
||||
handleUpdateMessage(dayIndex, messageIndex, {
|
||||
description: e.target.value,
|
||||
})
|
||||
@@ -395,7 +395,7 @@ const MessageSettings: React.FC<MessageSettingsProps> = ({
|
||||
/>
|
||||
<Input
|
||||
value={message.address}
|
||||
onChange={(e) =>
|
||||
onChange={e =>
|
||||
handleUpdateMessage(dayIndex, messageIndex, {
|
||||
address: e.target.value,
|
||||
})
|
||||
@@ -449,7 +449,7 @@ const MessageSettings: React.FC<MessageSettingsProps> = ({
|
||||
<>
|
||||
<Input
|
||||
value={message.title}
|
||||
onChange={(e) =>
|
||||
onChange={e =>
|
||||
handleUpdateMessage(dayIndex, messageIndex, {
|
||||
title: e.target.value,
|
||||
})
|
||||
@@ -459,7 +459,7 @@ const MessageSettings: React.FC<MessageSettingsProps> = ({
|
||||
/>
|
||||
<Input
|
||||
value={message.description}
|
||||
onChange={(e) =>
|
||||
onChange={e =>
|
||||
handleUpdateMessage(dayIndex, messageIndex, {
|
||||
description: e.target.value,
|
||||
})
|
||||
@@ -469,7 +469,7 @@ const MessageSettings: React.FC<MessageSettingsProps> = ({
|
||||
/>
|
||||
<Input
|
||||
value={message.linkUrl}
|
||||
onChange={(e) =>
|
||||
onChange={e =>
|
||||
handleUpdateMessage(dayIndex, messageIndex, {
|
||||
linkUrl: e.target.value,
|
||||
})
|
||||
@@ -523,7 +523,7 @@ const MessageSettings: React.FC<MessageSettingsProps> = ({
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<Button onClick={() => setIsGroupSelectOpen(true)}>
|
||||
{selectedGroupId
|
||||
? mockGroups.find((g) => g.id === selectedGroupId)?.name
|
||||
? mockGroups.find(g => g.id === selectedGroupId)?.name
|
||||
: "选择邀请入的群"}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -614,7 +614,7 @@ const MessageSettings: React.FC<MessageSettingsProps> = ({
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
{mockGroups.map((group) => (
|
||||
{mockGroups.map(group => (
|
||||
<div
|
||||
key={group.id}
|
||||
className={
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user