feat: 本次提交更新内容如下

格式更新一下
This commit is contained in:
笔记本里的永平
2025-07-28 16:53:18 +08:00
parent c8742de888
commit a9306bb8ba
178 changed files with 17535 additions and 16780 deletions

View File

@@ -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
View 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
View 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"
}
}

View File

@@ -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}\""
}
}

View File

@@ -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;

View File

@@ -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");

View File

@@ -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;

View File

@@ -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");
}

View File

@@ -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}

View File

@@ -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");
}

View File

@@ -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;
}

View File

@@ -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>
</>
);
}

View File

@@ -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");
}

View File

@@ -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; // 新增
}

View File

@@ -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;
}

View File

@@ -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}

View File

@@ -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;

View File

@@ -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");
}

View File

@@ -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;
}

View File

@@ -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}

View File

@@ -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");
}

View File

@@ -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;
}

View File

@@ -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>
</>
);
}

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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>

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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);
}
}

View File

@@ -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;

View File

@@ -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>
);
}

View File

@@ -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;

View 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` 回调

View 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;

View 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;
}
}

View 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;

View File

@@ -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 />);

View File

@@ -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");
}

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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);
}}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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");
}

View File

@@ -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;
}

View File

@@ -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);
}
}
}

View File

@@ -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;

View File

@@ -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");
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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");
}

View File

@@ -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;
}
}
}

View File

@@ -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"]}

View File

@@ -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={{

View File

@@ -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

View File

@@ -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");
}

View File

@@ -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;
}
}
}
}
}

View File

@@ -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)}

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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");
}

View File

@@ -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;
}>;
}

View File

@@ -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 }}
>
&lt;
</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 }}
>
&lt;
</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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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";

View File

@@ -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,
};
}

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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");
}

View File

@@ -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;
}

View File

@@ -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"]}

View File

@@ -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");
}

View File

@@ -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;
}

View File

@@ -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)

View File

@@ -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");
}

View File

@@ -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;
}

View File

@@ -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";
}}

View File

@@ -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");
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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

View File

@@ -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}
* />
* ```
*
* 特性:
* - 移动端使用 PopupPC端使用 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}
* />
* ```
*
* 特性:
* - 移动端使用 PopupPC端使用 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;

View File

@@ -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);
}

View File

@@ -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));
};
// 渲染当前步骤内容

View File

@@ -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>

View File

@@ -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 }}

View File

@@ -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