Merge branch 'yongpxu-dev' into yongpxu-dev2

# Conflicts:
#	nkebao/src/pages/mobile/mine/traffic-pool/detail/api.ts   resolved by yongpxu-dev version
#	nkebao/src/pages/mobile/mine/traffic-pool/detail/data.ts   resolved by yongpxu-dev version
#	nkebao/src/pages/mobile/mine/traffic-pool/detail/index.tsx   resolved by yongpxu-dev version
This commit is contained in:
超级老白兔
2025-07-29 19:24:44 +08:00
184 changed files with 24833 additions and 16917 deletions

View File

@@ -1,6 +1,4 @@
# 基础环境变量示例 # 基础环境变量示例
VITE_API_BASE_URL=http://www.yishi.com # VITE_API_BASE_URL=http://www.yishi.com
# VITE_API_BASE_URL=https://ckbapi.quwanzhi.com VITE_API_BASE_URL=https://ckbapi.quwanzhi.com
VITE_APP_TITLE=Nkebao Base VITE_APP_TITLE=Nkebao Base

View File

@@ -1,21 +1,64 @@
module.exports = { module.exports = {
root: true, root: true,
env: { env: {
browser: true, browser: true,
es2021: true, es2021: true,
node: true, node: true,
},
extends: [
"eslint:recommended",
"plugin:react/recommended",
"plugin:react-hooks/recommended",
"plugin:@typescript-eslint/recommended",
"plugin:prettier/recommended", // 这个配置会自动处理大部分冲突
],
parser: "@typescript-eslint/parser",
parserOptions: {
ecmaFeatures: {
jsx: true,
}, },
extends: [ ecmaVersion: 12,
'react-app', sourceType: "module",
'plugin:react/recommended', },
'plugin:@typescript-eslint/recommended', plugins: ["react", "react-hooks", "@typescript-eslint", "prettier"],
'plugin:prettier/recommended', rules: {
], "prettier/prettier": "error",
parser: '@typescript-eslint/parser', "react/react-in-jsx-scope": "off",
plugins: ['react', '@typescript-eslint', 'prettier'], "@typescript-eslint/no-unused-vars": "warn",
rules: { "@typescript-eslint/no-explicit-any": "off",
'prettier/prettier': 'warn', "@typescript-eslint/no-unnecessary-type-constraint": "warn",
'react/react-in-jsx-scope': 'off', "react/prop-types": "off",
'@typescript-eslint/no-unused-vars': 'warn', "linebreak-style": "off",
"eol-last": "off",
"no-empty": "warn",
"prefer-const": "warn",
// 确保与 Prettier 完全兼容
"comma-dangle": "off",
"comma-spacing": "off",
"comma-style": "off",
"object-curly-spacing": "off",
"array-bracket-spacing": "off",
indent: "off",
quotes: "off",
semi: "off",
"arrow-parens": "off",
"no-multiple-empty-lines": "off",
"max-len": "off",
"space-before-function-paren": "off",
"space-before-blocks": "off",
"keyword-spacing": "off",
"space-infix-ops": "off",
"space-in-parens": "off",
"space-in-brackets": "off",
"object-property-newline": "off",
"array-element-newline": "off",
"function-paren-newline": "off",
"object-curly-newline": "off",
"array-bracket-newline": "off",
},
settings: {
react: {
version: "detect",
}, },
}; },
};

27
nkebao/.gitattributes vendored Normal file
View File

@@ -0,0 +1,27 @@
# 设置默认行为如果core.autocrlf没有设置Git会自动处理行尾符
* text=auto
# 明确指定文本文件使用LF
*.js text eol=lf
*.jsx text eol=lf
*.ts text eol=lf
*.tsx text eol=lf
*.json text eol=lf
*.css text eol=lf
*.scss text eol=lf
*.html text eol=lf
*.md text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
# 二进制文件
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.svg binary
*.woff binary
*.woff2 binary
*.ttf binary
*.eot binary

13
nkebao/.prettierrc Normal file
View File

@@ -0,0 +1,13 @@
{
"semi": true,
"trailingComma": "all",
"singleQuote": false,
"printWidth": 80,
"tabWidth": 2,
"useTabs": false,
"endOfLine": "lf",
"bracketSpacing": true,
"arrowParens": "avoid",
"jsxSingleQuote": false,
"quoteProps": "as-needed"
}

11
nkebao/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,11 @@
{
"recommendations": [
"esbenp.prettier-vscode",
"dbaeumer.vscode-eslint",
"bradlc.vscode-tailwindcss",
"ms-vscode.vscode-typescript-next",
"formulahendry.auto-rename-tag",
"christian-kohler.path-intellisense",
"ms-vscode.vscode-json"
]
}

45
nkebao/.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,45 @@
{
"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",
"source.organizeImports": "never"
},
"eslint.validate": [
"javascript",
"javascriptreact",
"typescript",
"typescriptreact"
],
"eslint.format.enable": false,
"[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"
},
"typescript.preferences.importModuleSpecifier": "relative",
"typescript.suggest.autoImports": true,
"editor.tabSize": 2,
"editor.insertSpaces": true,
"editor.detectIndentation": false
}

6459
nkebao/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,43 +1,49 @@
{ {
"name": "cunkebao", "name": "cunkebao",
"version": "3.0.0", "version": "3.0.0",
"license": "MIT", "license": "MIT",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@ant-design/icons": "^5.6.1", "@ant-design/icons": "^5.6.1",
"antd": "^5.13.1", "antd": "^5.13.1",
"antd-mobile": "^5.39.1", "antd-mobile": "^5.39.1",
"axios": "^1.6.7", "axios": "^1.6.7",
"echarts": "^5.6.0", "dayjs": "^1.11.13",
"echarts-for-react": "^3.0.2", "echarts": "^5.6.0",
"react": "^18.2.0", "echarts-for-react": "^3.0.2",
"react-dom": "^18.2.0", "react": "^18.2.0",
"react-router-dom": "^6.20.0", "react-dom": "^18.2.0",
"vconsole": "^3.15.1", "react-router-dom": "^6.20.0",
"zustand": "^5.0.6" "vconsole": "^3.15.1",
}, "zustand": "^5.0.6"
"devDependencies": { },
"@types/node": "^24.0.14", "devDependencies": {
"@types/react": "^19.1.8", "@types/node": "^24.0.14",
"@types/react-dom": "^19.1.6", "@types/react": "^19.1.8",
"@typescript-eslint/eslint-plugin": "^7.7.0", "@types/react-dom": "^19.1.6",
"@typescript-eslint/parser": "^7.7.0", "@typescript-eslint/eslint-plugin": "^7.7.0",
"@vitejs/plugin-react": "^4.6.0", "@typescript-eslint/parser": "^7.7.0",
"eslint": "^8.57.0", "@vitejs/plugin-react": "^4.6.0",
"eslint-config-prettier": "^9.1.0", "eslint": "^8.57.0",
"eslint-plugin-prettier": "^5.1.3", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-react": "^7.34.1", "eslint-plugin-prettier": "^5.1.3",
"postcss": "^8.4.38", "eslint-plugin-react": "^7.34.1",
"postcss-pxtorem": "^6.0.0", "eslint-plugin-react-hooks": "^5.2.0",
"prettier": "^3.2.5", "postcss": "^8.4.38",
"sass": "^1.75.0", "postcss-pxtorem": "^6.0.0",
"typescript": "^5.4.5", "prettier": "^3.2.5",
"vite": "^7.0.5" "sass": "^1.75.0",
}, "typescript": "^5.4.5",
"scripts": { "vite": "^7.0.5"
"dev": "vite", },
"build": "vite build", "scripts": {
"preview": "vite preview", "dev": "vite",
"lint": "eslint 'src/**/*.{js,jsx,ts,tsx}' --fix" "build": "vite build",
} "build:check": "tsc && vite build",
} "preview": "vite preview",
"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 React from "react";
import AppRouter from "@/router"; import AppRouter from "@/router";
function App() { function App() {
return ( return (
<> <>
<AppRouter /> <AppRouter />
</> </>
); );
} }
export default App; export default App;

View File

@@ -1,44 +1,44 @@
import request from "./request"; import request from "./request";
// 获取设备列表 // 获取设备列表
export const fetchDeviceList = (params: { export const fetchDeviceList = (params: {
page?: number; page?: number;
limit?: number; limit?: number;
keyword?: string; keyword?: string;
}) => request("/v1/devices", params, "GET"); }) => request("/v1/devices", params, "GET");
// 获取设备详情 // 获取设备详情
export const fetchDeviceDetail = (id: string | number) => export const fetchDeviceDetail = (id: string | number) =>
request(`/v1/devices/${id}`); request(`/v1/devices/${id}`);
// 获取设备关联微信账号 // 获取设备关联微信账号
export const fetchDeviceRelatedAccounts = (id: string | number) => export const fetchDeviceRelatedAccounts = (id: string | number) =>
request(`/v1/wechats/related-device/${id}`); request(`/v1/wechats/related-device/${id}`);
// 获取设备操作日志 // 获取设备操作日志
export const fetchDeviceHandleLogs = ( export const fetchDeviceHandleLogs = (
id: string | number, id: string | number,
page = 1, page = 1,
limit = 10 limit = 10
) => request(`/v1/devices/${id}/handle-logs`, { page, limit }, "GET"); ) => request(`/v1/devices/${id}/handle-logs`, { page, limit }, "GET");
// 更新设备任务配置 // 更新设备任务配置
export const updateDeviceTaskConfig = (config: { export const updateDeviceTaskConfig = (config: {
deviceId: string | number; deviceId: string | number;
autoAddFriend?: boolean; autoAddFriend?: boolean;
autoReply?: boolean; autoReply?: boolean;
momentsSync?: boolean; momentsSync?: boolean;
aiChat?: boolean; aiChat?: boolean;
}) => request("/v1/devices/task-config", config, "POST"); }) => request("/v1/devices/task-config", config, "POST");
// 删除设备 // 删除设备
export const deleteDevice = (id: number) => export const deleteDevice = (id: number) =>
request(`/v1/devices/${id}`, undefined, "DELETE"); request(`/v1/devices/${id}`, undefined, "DELETE");
// 获取设备二维码 // 获取设备二维码
export const fetchDeviceQRCode = (accountId: string) => export const fetchDeviceQRCode = (accountId: string) =>
request("/v1/api/device/add", { accountId }, "POST"); request("/v1/api/device/add", { accountId }, "POST");
// 通过IMEI添加设备 // 通过IMEI添加设备
export const addDeviceByImei = (imei: string, name: string) => export const addDeviceByImei = (imei: string, name: string) =>
request("/v1/api/device/add-by-imei", { imei, name }, "POST"); request("/v1/api/device/add-by-imei", { imei, name }, "POST");

View File

@@ -1,84 +1,90 @@
import axios, { import axios, {
AxiosInstance, AxiosInstance,
AxiosRequestConfig, AxiosRequestConfig,
Method, Method,
AxiosResponse, AxiosResponse,
} from "axios"; } from "axios";
import { Toast } from "antd-mobile"; import { Toast } from "antd-mobile";
const DEFAULT_DEBOUNCE_GAP = 1000; const DEFAULT_DEBOUNCE_GAP = 1000;
const debounceMap = new Map<string, number>(); const debounceMap = new Map<string, number>();
const instance: AxiosInstance = axios.create({ const instance: AxiosInstance = axios.create({
baseURL: (import.meta as any).env?.VITE_API_BASE_URL || "/api", baseURL: (import.meta as any).env?.VITE_API_BASE_URL || "/api",
timeout: 20000, timeout: 20000,
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
}); });
instance.interceptors.request.use((config) => { instance.interceptors.request.use((config: any) => {
const token = localStorage.getItem("token"); const token = localStorage.getItem("token");
if (token) { if (token) {
config.headers = config.headers || {}; config.headers = config.headers || {};
config.headers["Authorization"] = `Bearer ${token}`; config.headers["Authorization"] = `Bearer ${token}`;
} }
return config; return config;
}); });
instance.interceptors.response.use( instance.interceptors.response.use(
(res: AxiosResponse) => { (res: AxiosResponse) => {
const { code, success, msg } = res.data || {}; const { code, success, msg } = res.data || {};
if (code === 200 || success) { if (code === 200 || success) {
return res.data.data ?? res.data; return res.data.data ?? res.data;
} }
Toast.show({ content: msg || "接口错误", position: "top" }); Toast.show({ content: msg || "接口错误", position: "top" });
if (code === 401) { if (code === 401) {
localStorage.removeItem("token"); localStorage.removeItem("token");
const currentPath = window.location.pathname + window.location.search; const currentPath = window.location.pathname + window.location.search;
if (currentPath === "/login") { if (currentPath === "/login") {
window.location.href = "/login"; window.location.href = "/login";
} else { } else {
window.location.href = `/login?redirect=${encodeURIComponent(currentPath)}`; window.location.href = `/login?redirect=${encodeURIComponent(currentPath)}`;
} }
} }
return Promise.reject(msg || "接口错误"); return Promise.reject(msg || "接口错误");
}, },
(err) => { err => {
Toast.show({ content: err.message || "网络异常", position: "top" }); Toast.show({ content: err.message || "网络异常", position: "top" });
return Promise.reject(err); return Promise.reject(err);
} }
); );
export function request( export function request(
url: string, url: string,
data?: any, data?: any,
method: Method = "GET", method: Method = "GET",
config?: AxiosRequestConfig, config?: AxiosRequestConfig,
debounceGap?: number debounceGap?: number
): Promise<any> { ): Promise<any> {
const gap = const gap =
typeof debounceGap === "number" ? debounceGap : DEFAULT_DEBOUNCE_GAP; typeof debounceGap === "number" ? debounceGap : DEFAULT_DEBOUNCE_GAP;
const key = `${method}_${url}_${JSON.stringify(data)}`; const key = `${method}_${url}_${JSON.stringify(data)}`;
const now = Date.now(); const now = Date.now();
const last = debounceMap.get(key) || 0; const last = debounceMap.get(key) || 0;
if (gap > 0 && now - last < gap) { if (gap > 0 && now - last < gap) {
// Toast.show({ content: '请求过于频繁,请稍后再试', position: 'top' }); // Toast.show({ content: '请求过于频繁,请稍后再试', position: 'top' });
return Promise.reject("请求过于频繁,请稍后再试"); return Promise.reject("请求过于频繁,请稍后再试");
} }
debounceMap.set(key, now); debounceMap.set(key, now);
const axiosConfig: AxiosRequestConfig = { const axiosConfig: AxiosRequestConfig = {
url, url,
method, method,
...config, ...config,
}; };
if (method.toUpperCase() === "GET") {
axiosConfig.params = data; // 如果是FormData不设置Content-Type让浏览器自动设置
} else { if (data instanceof FormData) {
axiosConfig.data = data; delete axiosConfig.headers?.["Content-Type"];
} }
return instance(axiosConfig);
} if (method.toUpperCase() === "GET") {
axiosConfig.params = data;
export default request; } else {
axiosConfig.data = data;
}
return instance(axiosConfig);
}
export default request;

View File

@@ -1,10 +1,10 @@
import request from "@/api/request"; import request from "@/api/request";
// 获取好友列表 // 获取好友列表
export function getAccountList(params: { export function getAccountList(params: {
page: number; page: number;
limit: number; limit: number;
keyword?: string; keyword?: string;
}) { }) {
return request("/v1/workbench/account-list", params, "GET"); return request("/v1/workbench/account-list", params, "GET");
} }

View File

@@ -114,7 +114,7 @@ export default function AccountSelection({
// 渲染和过滤都依赖内部accountsList // 渲染和过滤都依赖内部accountsList
const filteredAccounts = accountsList.filter( const filteredAccounts = accountsList.filter(
(acc) => acc =>
acc.userName.includes(searchQuery) || acc.userName.includes(searchQuery) ||
acc.realName.includes(searchQuery) || acc.realName.includes(searchQuery) ||
acc.departmentName.includes(searchQuery) acc.departmentName.includes(searchQuery)
@@ -123,24 +123,27 @@ export default function AccountSelection({
// 处理账号选择 // 处理账号选择
const handleAccountToggle = (accountId: number) => { const handleAccountToggle = (accountId: number) => {
if (readonly) return; if (readonly) return;
const newSelected = value.includes(accountId) const uniqueValue = [...new Set(value)];
? value.filter((id) => id !== accountId) const newSelected = uniqueValue.includes(accountId)
: [...value, accountId]; ? uniqueValue.filter(id => id !== accountId)
: [...uniqueValue, accountId];
onChange(newSelected); onChange(newSelected);
}; };
// 获取显示文本 // 获取显示文本
const getDisplayText = () => { const getDisplayText = () => {
if (value.length === 0) return ""; const uniqueValue = [...new Set(value)];
return `已选择 ${value.length} 个账号`; if (uniqueValue.length === 0) return "";
return `已选择 ${uniqueValue.length} 个账号`;
}; };
// 获取已选账号详细信息 // 获取已选账号详细信息 - 去重处理
const uniqueValue = [...new Set(value)];
const selectedAccountObjs = [ const selectedAccountObjs = [
...accountsList.filter((acc) => value.includes(acc.id)), ...accountsList.filter(acc => uniqueValue.includes(acc.id)),
...value ...uniqueValue
.filter((id) => !accountsList.some((acc) => acc.id === id)) .filter(id => !accountsList.some(acc => acc.id === id))
.map((id) => ({ .map(id => ({
id, id,
userName: String(id), userName: String(id),
realName: "", realName: "",
@@ -151,13 +154,15 @@ export default function AccountSelection({
// 删除已选账号 // 删除已选账号
const handleRemoveAccount = (id: number) => { const handleRemoveAccount = (id: number) => {
if (readonly) return; if (readonly) return;
onChange(value.filter((d) => d !== id)); const uniqueValue = [...new Set(value)];
onChange(uniqueValue.filter(d => d !== id));
}; };
// 确认选择 // 确认选择
const handleConfirm = () => { const handleConfirm = () => {
if (onConfirm) { if (onConfirm) {
onConfirm(value, selectedAccountObjs); const uniqueValue = [...new Set(value)];
onConfirm(uniqueValue, selectedAccountObjs);
} }
setRealVisible(false); setRealVisible(false);
}; };
@@ -195,7 +200,7 @@ export default function AccountSelection({
background: "#fff", background: "#fff",
}} }}
> >
{selectedAccountObjs.map((acc) => ( {selectedAccountObjs.map(acc => (
<div <div
key={acc.id} key={acc.id}
className={style.selectedListRow} className={style.selectedListRow}
@@ -265,7 +270,7 @@ export default function AccountSelection({
currentPage={currentPage} currentPage={currentPage}
totalPages={1} totalPages={1}
loading={loading} loading={loading}
selectedCount={value.length} selectedCount={uniqueValue.length}
onPageChange={setCurrentPage} onPageChange={setCurrentPage}
onCancel={() => setRealVisible(false)} onCancel={() => setRealVisible(false)}
onConfirm={handleConfirm} onConfirm={handleConfirm}
@@ -279,7 +284,7 @@ export default function AccountSelection({
</div> </div>
) : filteredAccounts.length > 0 ? ( ) : filteredAccounts.length > 0 ? (
<div className={style.friendListInner}> <div className={style.friendListInner}>
{filteredAccounts.map((acc) => ( {filteredAccounts.map(acc => (
<label <label
key={acc.id} key={acc.id}
className={style.friendItem} className={style.friendItem}
@@ -288,12 +293,12 @@ export default function AccountSelection({
<div className={style.radioWrapper}> <div className={style.radioWrapper}>
<div <div
className={ className={
value.includes(acc.id) uniqueValue.includes(acc.id)
? style.radioSelected ? style.radioSelected
: style.radioUnselected : style.radioUnselected
} }
> >
{value.includes(acc.id) && ( {uniqueValue.includes(acc.id) && (
<div className={style.radioDot}></div> <div className={style.radioDot}></div>
)} )}
</div> </div>

View File

@@ -1,5 +1,5 @@
import request from "@/api/request"; import request from "@/api/request";
export function getContentLibraryList(params: any) { export function getContentLibraryList(params: any) {
return request("/v1/content/library/list", params, "GET"); return request("/v1/content/library/list", params, "GET");
} }

View File

@@ -1,117 +1,117 @@
.inputWrapper { .inputWrapper {
position: relative; position: relative;
} }
.selectedListWindow { .selectedListWindow {
margin-top: 8px; margin-top: 8px;
border: 1px solid #e5e6eb; border: 1px solid #e5e6eb;
border-radius: 8px; border-radius: 8px;
background: #fff; background: #fff;
} }
.selectedListRow { .selectedListRow {
display: flex; display: flex;
align-items: center; align-items: center;
padding: 4px 8px; padding: 4px 8px;
border-bottom: 1px solid #f0f0f0; border-bottom: 1px solid #f0f0f0;
font-size: 14px; font-size: 14px;
} }
.libraryList { .libraryList {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
} }
.libraryListInner { .libraryListInner {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
padding: 16px; padding: 16px;
} }
.libraryItem { .libraryItem {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
gap: 12px; gap: 12px;
padding: 16px; padding: 16px;
border-radius: 12px; border-radius: 12px;
border: 1px solid #f0f0f0; border: 1px solid #f0f0f0;
background: #fff; background: #fff;
cursor: pointer; cursor: pointer;
transition: background 0.2s; transition: background 0.2s;
&:hover { &:hover {
background: #f5f6fa; background: #f5f6fa;
} }
} }
.checkboxWrapper { .checkboxWrapper {
margin-top: 4px; margin-top: 4px;
} }
.checkboxSelected { .checkboxSelected {
width: 20px; width: 20px;
height: 20px; height: 20px;
border-radius: 4px; border-radius: 4px;
background: #1677ff; background: #1677ff;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
.checkboxUnselected { .checkboxUnselected {
width: 20px; width: 20px;
height: 20px; height: 20px;
border-radius: 4px; border-radius: 4px;
border: 1px solid #e5e6eb; border: 1px solid #e5e6eb;
background: #fff; background: #fff;
} }
.checkboxDot { .checkboxDot {
width: 12px; width: 12px;
height: 12px; height: 12px;
border-radius: 2px; border-radius: 2px;
background: #fff; background: #fff;
} }
.libraryInfo { .libraryInfo {
flex: 1; flex: 1;
} }
.libraryHeader { .libraryHeader {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
} }
.libraryName { .libraryName {
font-weight: 500; font-weight: 500;
font-size: 16px; font-size: 16px;
color: #222; color: #222;
} }
.typeTag { .typeTag {
font-size: 12px; font-size: 12px;
color: #1677ff; color: #1677ff;
border: 1px solid #1677ff; border: 1px solid #1677ff;
border-radius: 12px; border-radius: 12px;
padding: 2px 10px; padding: 2px 10px;
margin-left: 8px; margin-left: 8px;
background: #f4f8ff; background: #f4f8ff;
font-weight: 500; font-weight: 500;
} }
.libraryMeta { .libraryMeta {
font-size: 12px; font-size: 12px;
color: #888; color: #888;
} }
.libraryDesc { .libraryDesc {
font-size: 13px; font-size: 13px;
color: #888; color: #888;
margin-top: 4px; margin-top: 4px;
} }
.loadingBox { .loadingBox {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 100%; height: 100%;
} }
.loadingText { .loadingText {
color: #888; color: #888;
font-size: 15px; font-size: 15px;
} }
.emptyBox { .emptyBox {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 100px; height: 100px;
} }
.emptyText { .emptyText {
color: #888; color: #888;
font-size: 15px; font-size: 15px;
} }

View File

@@ -1,343 +1,343 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { SearchOutlined, DeleteOutlined } from "@ant-design/icons"; import { SearchOutlined, DeleteOutlined } from "@ant-design/icons";
import { Button, Input } from "antd"; import { Button, Input } from "antd";
import { Popup, Checkbox } from "antd-mobile"; import { Popup, Checkbox } from "antd-mobile";
import style from "./index.module.scss"; import style from "./index.module.scss";
import Layout from "@/components/Layout/Layout"; import Layout from "@/components/Layout/Layout";
import PopupHeader from "@/components/PopuLayout/header"; import PopupHeader from "@/components/PopuLayout/header";
import PopupFooter from "@/components/PopuLayout/footer"; import PopupFooter from "@/components/PopuLayout/footer";
import { getContentLibraryList } from "./api"; import { getContentLibraryList } from "./api";
// 内容库接口类型 // 内容库接口类型
interface ContentLibraryItem { interface ContentLibraryItem {
id: string; id: string;
name: string; name: string;
description?: string; description?: string;
sourceType?: number; // 1=文本 2=图片 3=视频 sourceType?: number; // 1=文本 2=图片 3=视频
creatorName?: string; creatorName?: string;
updateTime?: string; updateTime?: string;
[key: string]: any; [key: string]: any;
} }
// 类型标签文本 // 类型标签文本
const getTypeText = (type?: number) => { const getTypeText = (type?: number) => {
if (type === 1) return "文本"; if (type === 1) return "文本";
if (type === 2) return "图片"; if (type === 2) return "图片";
if (type === 3) return "视频"; if (type === 3) return "视频";
return "未知"; return "未知";
}; };
// 时间格式化 // 时间格式化
const formatDate = (dateStr?: string) => { const formatDate = (dateStr?: string) => {
if (!dateStr) return "-"; if (!dateStr) return "-";
const d = new Date(dateStr); const d = new Date(dateStr);
if (isNaN(d.getTime())) return "-"; if (isNaN(d.getTime())) return "-";
return `${d.getFullYear()}/${(d.getMonth() + 1) return `${d.getFullYear()}/${(d.getMonth() + 1)
.toString() .toString()
.padStart(2, "0")}/${d.getDate().toString().padStart(2, "0")} ${d .padStart(2, "0")}/${d.getDate().toString().padStart(2, "0")} ${d
.getHours() .getHours()
.toString() .toString()
.padStart(2, "0")}:${d.getMinutes().toString().padStart(2, "0")}:${d .padStart(2, "0")}:${d.getMinutes().toString().padStart(2, "0")}:${d
.getSeconds() .getSeconds()
.toString() .toString()
.padStart(2, "0")}`; .padStart(2, "0")}`;
}; };
// 组件属性接口 // 组件属性接口
interface ContentLibrarySelectionProps { interface ContentLibrarySelectionProps {
selectedLibraries: (string | number)[]; selectedLibraries: (string | number)[];
onSelect: (libraries: string[]) => void; onSelect: (libraries: string[]) => void;
onSelectDetail?: (libraries: ContentLibraryItem[]) => void; onSelectDetail?: (libraries: ContentLibraryItem[]) => void;
placeholder?: string; placeholder?: string;
className?: string; className?: string;
visible?: boolean; visible?: boolean;
onVisibleChange?: (visible: boolean) => void; onVisibleChange?: (visible: boolean) => void;
selectedListMaxHeight?: number; selectedListMaxHeight?: number;
showInput?: boolean; showInput?: boolean;
showSelectedList?: boolean; showSelectedList?: boolean;
readonly?: boolean; readonly?: boolean;
onConfirm?: ( onConfirm?: (
selectedIds: string[], selectedIds: string[],
selectedItems: ContentLibraryItem[] selectedItems: ContentLibraryItem[]
) => void; ) => void;
} }
export default function ContentLibrarySelection({ export default function ContentLibrarySelection({
selectedLibraries, selectedLibraries,
onSelect, onSelect,
onSelectDetail, onSelectDetail,
placeholder = "选择内容库", placeholder = "选择内容库",
className = "", className = "",
visible, visible,
onVisibleChange, onVisibleChange,
selectedListMaxHeight = 300, selectedListMaxHeight = 300,
showInput = true, showInput = true,
showSelectedList = true, showSelectedList = true,
readonly = false, readonly = false,
onConfirm, onConfirm,
}: ContentLibrarySelectionProps) { }: ContentLibrarySelectionProps) {
const [popupVisible, setPopupVisible] = useState(false); const [popupVisible, setPopupVisible] = useState(false);
const [libraries, setLibraries] = useState<ContentLibraryItem[]>([]); const [libraries, setLibraries] = useState<ContentLibraryItem[]>([]);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1); const [totalPages, setTotalPages] = useState(1);
const [totalLibraries, setTotalLibraries] = useState(0); const [totalLibraries, setTotalLibraries] = useState(0);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
// 获取已选内容库详细信息 // 获取已选内容库详细信息
const selectedLibraryObjs = libraries.filter((item) => const selectedLibraryObjs = libraries.filter(item =>
selectedLibraries.includes(item.id) selectedLibraries.includes(item.id)
); );
// 删除已选内容库 // 删除已选内容库
const handleRemoveLibrary = (id: string) => { const handleRemoveLibrary = (id: string) => {
if (readonly) return; if (readonly) return;
onSelect(selectedLibraries.filter((g) => g !== id)); onSelect(selectedLibraries.filter(g => g !== id));
}; };
// 受控弹窗逻辑 // 受控弹窗逻辑
const realVisible = visible !== undefined ? visible : popupVisible; const realVisible = visible !== undefined ? visible : popupVisible;
const setRealVisible = (v: boolean) => { const setRealVisible = (v: boolean) => {
if (onVisibleChange) onVisibleChange(v); if (onVisibleChange) onVisibleChange(v);
if (visible === undefined) setPopupVisible(v); if (visible === undefined) setPopupVisible(v);
}; };
// 打开弹窗 // 打开弹窗
const openPopup = () => { const openPopup = () => {
if (readonly) return; if (readonly) return;
setCurrentPage(1); setCurrentPage(1);
setSearchQuery(""); setSearchQuery("");
setRealVisible(true); setRealVisible(true);
fetchLibraries(1, ""); fetchLibraries(1, "");
}; };
// 当页码变化时,拉取对应页数据(弹窗已打开时) // 当页码变化时,拉取对应页数据(弹窗已打开时)
useEffect(() => { useEffect(() => {
if (realVisible && currentPage !== 1) { if (realVisible && currentPage !== 1) {
fetchLibraries(currentPage, searchQuery); fetchLibraries(currentPage, searchQuery);
} }
}, [currentPage, realVisible, searchQuery]); }, [currentPage, realVisible, searchQuery]);
// 搜索防抖 // 搜索防抖
useEffect(() => { useEffect(() => {
if (!realVisible) return; if (!realVisible) return;
const timer = setTimeout(() => { const timer = setTimeout(() => {
setCurrentPage(1); setCurrentPage(1);
fetchLibraries(1, searchQuery); fetchLibraries(1, searchQuery);
}, 500); }, 500);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [searchQuery, realVisible]); }, [searchQuery, realVisible]);
// 获取内容库列表API // 获取内容库列表API
const fetchLibraries = async (page: number, keyword: string = "") => { const fetchLibraries = async (page: number, keyword: string = "") => {
setLoading(true); setLoading(true);
try { try {
let params: any = { const params: any = {
page, page,
limit: 20, limit: 20,
}; };
if (keyword.trim()) { if (keyword.trim()) {
params.keyword = keyword.trim(); params.keyword = keyword.trim();
} }
const response = await getContentLibraryList(params); const response = await getContentLibraryList(params);
if (response && response.list) { if (response && response.list) {
setLibraries(response.list); setLibraries(response.list);
setTotalLibraries(response.total || 0); setTotalLibraries(response.total || 0);
setTotalPages(Math.ceil((response.total || 0) / 20)); setTotalPages(Math.ceil((response.total || 0) / 20));
} }
} catch (error) { } catch (error) {
console.error("获取内容库列表失败:", error); console.error("获取内容库列表失败:", error);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
// 处理内容库选择 // 处理内容库选择
const handleLibraryToggle = (libraryId: string) => { const handleLibraryToggle = (libraryId: string) => {
if (readonly) return; if (readonly) return;
const newSelected = selectedLibraries.includes(libraryId) const newSelected = selectedLibraries.includes(libraryId)
? selectedLibraries.filter((id) => id !== libraryId) ? selectedLibraries.filter(id => id !== libraryId)
: [...selectedLibraries, libraryId]; : [...selectedLibraries, libraryId];
onSelect(newSelected); onSelect(newSelected);
if (onSelectDetail) { if (onSelectDetail) {
const selectedObjs = libraries.filter((item) => const selectedObjs = libraries.filter(item =>
newSelected.includes(item.id) newSelected.includes(item.id)
); );
onSelectDetail(selectedObjs); onSelectDetail(selectedObjs);
} }
}; };
// 获取显示文本 // 获取显示文本
const getDisplayText = () => { const getDisplayText = () => {
if (selectedLibraries.length === 0) return ""; if (selectedLibraries.length === 0) return "";
return `已选择 ${selectedLibraries.length} 个内容库`; return `已选择 ${selectedLibraries.length} 个内容库`;
}; };
// 确认选择 // 确认选择
const handleConfirm = () => { const handleConfirm = () => {
if (onConfirm) { if (onConfirm) {
onConfirm(selectedLibraries, selectedLibraryObjs); onConfirm(selectedLibraries, selectedLibraryObjs);
} }
setRealVisible(false); setRealVisible(false);
}; };
return ( return (
<> <>
{/* 输入框 */} {/* 输入框 */}
{showInput && ( {showInput && (
<div className={`${style.inputWrapper} ${className}`}> <div className={`${style.inputWrapper} ${className}`}>
<Input <Input
placeholder={placeholder} placeholder={placeholder}
value={getDisplayText()} value={getDisplayText()}
onClick={openPopup} onClick={openPopup}
prefix={<SearchOutlined />} prefix={<SearchOutlined />}
allowClear={!readonly} allowClear={!readonly}
size="large" size="large"
readOnly={readonly} readOnly={readonly}
disabled={readonly} disabled={readonly}
style={ style={
readonly ? { background: "#f5f5f5", cursor: "not-allowed" } : {} readonly ? { background: "#f5f5f5", cursor: "not-allowed" } : {}
} }
/> />
</div> </div>
)} )}
{/* 已选内容库列表窗口 */} {/* 已选内容库列表窗口 */}
{showSelectedList && selectedLibraryObjs.length > 0 && ( {showSelectedList && selectedLibraryObjs.length > 0 && (
<div <div
className={style.selectedListWindow} className={style.selectedListWindow}
style={{ style={{
maxHeight: selectedListMaxHeight, maxHeight: selectedListMaxHeight,
overflowY: "auto", overflowY: "auto",
marginTop: 8, marginTop: 8,
border: "1px solid #e5e6eb", border: "1px solid #e5e6eb",
borderRadius: 8, borderRadius: 8,
background: "#fff", background: "#fff",
}} }}
> >
{selectedLibraryObjs.map((item) => ( {selectedLibraryObjs.map(item => (
<div <div
key={item.id} key={item.id}
className={style.selectedListRow} className={style.selectedListRow}
style={{ style={{
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
padding: "4px 8px", padding: "4px 8px",
borderBottom: "1px solid #f0f0f0", borderBottom: "1px solid #f0f0f0",
fontSize: 14, fontSize: 14,
}} }}
> >
<div <div
style={{ style={{
flex: 1, flex: 1,
minWidth: 0, minWidth: 0,
whiteSpace: "nowrap", whiteSpace: "nowrap",
overflow: "hidden", overflow: "hidden",
textOverflow: "ellipsis", textOverflow: "ellipsis",
}} }}
> >
{item.name || item.id} {item.name || item.id}
</div> </div>
{!readonly && ( {!readonly && (
<Button <Button
type="text" type="text"
icon={<DeleteOutlined />} icon={<DeleteOutlined />}
size="small" size="small"
style={{ style={{
marginLeft: 4, marginLeft: 4,
color: "#ff4d4f", color: "#ff4d4f",
border: "none", border: "none",
background: "none", background: "none",
minWidth: 24, minWidth: 24,
height: 24, height: 24,
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
}} }}
onClick={() => handleRemoveLibrary(item.id)} onClick={() => handleRemoveLibrary(item.id)}
/> />
)} )}
</div> </div>
))} ))}
</div> </div>
)} )}
{/* 弹窗 */} {/* 弹窗 */}
<Popup <Popup
visible={realVisible && !readonly} visible={realVisible && !readonly}
onMaskClick={() => setRealVisible(false)} onMaskClick={() => setRealVisible(false)}
position="bottom" position="bottom"
bodyStyle={{ height: "100vh" }} bodyStyle={{ height: "100vh" }}
> >
<Layout <Layout
header={ header={
<PopupHeader <PopupHeader
title="选择内容库" title="选择内容库"
searchQuery={searchQuery} searchQuery={searchQuery}
setSearchQuery={setSearchQuery} setSearchQuery={setSearchQuery}
searchPlaceholder="搜索内容库" searchPlaceholder="搜索内容库"
loading={loading} loading={loading}
onRefresh={() => fetchLibraries(currentPage, searchQuery)} onRefresh={() => fetchLibraries(currentPage, searchQuery)}
/> />
} }
footer={ footer={
<PopupFooter <PopupFooter
total={totalLibraries} total={totalLibraries}
currentPage={currentPage} currentPage={currentPage}
totalPages={totalPages} totalPages={totalPages}
loading={loading} loading={loading}
selectedCount={selectedLibraries.length} selectedCount={selectedLibraries.length}
onPageChange={setCurrentPage} onPageChange={setCurrentPage}
onCancel={() => setRealVisible(false)} onCancel={() => setRealVisible(false)}
onConfirm={handleConfirm} onConfirm={handleConfirm}
/> />
} }
> >
<div className={style.libraryList}> <div className={style.libraryList}>
{loading ? ( {loading ? (
<div className={style.loadingBox}> <div className={style.loadingBox}>
<div className={style.loadingText}>...</div> <div className={style.loadingText}>...</div>
</div> </div>
) : libraries.length > 0 ? ( ) : libraries.length > 0 ? (
<div className={style.libraryListInner}> <div className={style.libraryListInner}>
{libraries.map((item) => ( {libraries.map(item => (
<label key={item.id} className={style.libraryItem}> <label key={item.id} className={style.libraryItem}>
<Checkbox <Checkbox
checked={selectedLibraries.includes(item.id)} checked={selectedLibraries.includes(item.id)}
onChange={() => !readonly && handleLibraryToggle(item.id)} onChange={() => !readonly && handleLibraryToggle(item.id)}
disabled={readonly} disabled={readonly}
className={style.checkboxWrapper} className={style.checkboxWrapper}
/> />
<div className={style.libraryInfo}> <div className={style.libraryInfo}>
<div className={style.libraryHeader}> <div className={style.libraryHeader}>
<span className={style.libraryName}>{item.name}</span> <span className={style.libraryName}>{item.name}</span>
<span className={style.typeTag}> <span className={style.typeTag}>
{getTypeText(item.sourceType)} {getTypeText(item.sourceType)}
</span> </span>
</div> </div>
<div className={style.libraryMeta}> <div className={style.libraryMeta}>
<div>: {item.creatorName || "-"}</div> <div>: {item.creatorName || "-"}</div>
<div>: {formatDate(item.updateTime)}</div> <div>: {formatDate(item.updateTime)}</div>
</div> </div>
{item.description && ( {item.description && (
<div className={style.libraryDesc}> <div className={style.libraryDesc}>
{item.description} {item.description}
</div> </div>
)} )}
</div> </div>
</label> </label>
))} ))}
</div> </div>
) : ( ) : (
<div className={style.emptyBox}> <div className={style.emptyBox}>
<div className={style.emptyText}> <div className={style.emptyText}>
{searchQuery {searchQuery
? `没有找到包含"${searchQuery}"的内容库` ? `没有找到包含"${searchQuery}"的内容库`
: "没有找到内容库"} : "没有找到内容库"}
</div> </div>
</div> </div>
)} )}
</div> </div>
</Layout> </Layout>
</Popup> </Popup>
</> </>
); );
} }

View File

@@ -1,10 +1,10 @@
import request from "@/api/request"; import request from "@/api/request";
// 获取设备列表 // 获取设备列表
export function getDeviceList(params: { export function getDeviceList(params: {
page: number; page: number;
limit: number; limit: number;
keyword?: string; keyword?: string;
}) { }) {
return request("/v1/devices", params, "GET"); return request("/v1/devices", params, "GET");
} }

View File

@@ -1,26 +1,26 @@
// 设备选择项接口 // 设备选择项接口
export interface DeviceSelectionItem { export interface DeviceSelectionItem {
id: string; id: string;
name: string; name: string;
imei: string; imei: string;
wechatId: string; wechatId: string;
status: "online" | "offline"; status: "online" | "offline";
wxid?: string; wxid?: string;
nickname?: string; nickname?: string;
usedInPlans?: number; usedInPlans?: number;
} }
// 组件属性接口 // 组件属性接口
export interface DeviceSelectionProps { export interface DeviceSelectionProps {
selectedDevices: string[]; selectedDevices: string[];
onSelect: (devices: string[]) => void; onSelect: (devices: string[]) => void;
placeholder?: string; placeholder?: string;
className?: string; className?: string;
mode?: "input" | "dialog"; // 新增默认input mode?: "input" | "dialog"; // 新增默认input
open?: boolean; // 仅mode=dialog时生效 open?: boolean; // 仅mode=dialog时生效
onOpenChange?: (open: boolean) => void; // 仅mode=dialog时生效 onOpenChange?: (open: boolean) => void; // 仅mode=dialog时生效
selectedListMaxHeight?: number; // 新增已选列表最大高度默认500 selectedListMaxHeight?: number; // 新增已选列表最大高度默认500
showInput?: boolean; // 新增 showInput?: boolean; // 新增
showSelectedList?: boolean; // 新增 showSelectedList?: boolean; // 新增
readonly?: boolean; // 新增 readonly?: boolean; // 新增
} }

View File

@@ -1,183 +1,182 @@
.inputWrapper { .inputWrapper {
position: relative; position: relative;
} }
.inputIcon { .inputIcon {
position: absolute; position: absolute;
left: 12px; left: 12px;
top: 50%; top: 50%;
transform: translateY(-50%); transform: translateY(-50%);
color: #bdbdbd; color: #bdbdbd;
z-index: 10; z-index: 10;
font-size: 18px; font-size: 18px;
} }
.input { .input {
padding-left: 38px !important; padding-left: 38px !important;
height: 56px; height: 56px;
border-radius: 16px !important; border-radius: 16px !important;
border: 1px solid #e5e6eb !important; border: 1px solid #e5e6eb !important;
font-size: 16px; font-size: 16px;
background: #f8f9fa; background: #f8f9fa;
} }
.popupHeader {
.popupHeader { padding: 16px;
padding: 16px; border-bottom: 1px solid #f0f0f0;
border-bottom: 1px solid #f0f0f0; }
} .popupTitle {
.popupTitle { font-size: 20px;
font-size: 20px; font-weight: 600;
font-weight: 600; text-align: center;
text-align: center; }
} .popupSearchRow {
.popupSearchRow { display: flex;
display: flex; align-items: center;
align-items: center; gap: 16px;
gap: 16px; padding: 16px;
padding: 16px; }
} .popupSearchInputWrap {
.popupSearchInputWrap { position: relative;
position: relative; flex: 1;
flex: 1; }
} .popupSearchInput {
.popupSearchInput { padding-left: 36px !important;
padding-left: 36px !important; border-radius: 12px !important;
border-radius: 12px !important; height: 44px;
height: 44px; font-size: 15px;
font-size: 15px; border: 1px solid #e5e6eb !important;
border: 1px solid #e5e6eb !important; background: #f8f9fa;
background: #f8f9fa; }
} .statusSelect {
.statusSelect { width: 120px;
width: 120px; height: 40px;
height: 40px; border-radius: 8px;
border-radius: 8px; border: 1px solid #e5e6eb;
border: 1px solid #e5e6eb; font-size: 15px;
font-size: 15px; padding: 0 10px;
padding: 0 10px; background: #fff;
background: #fff; }
} .deviceList {
.deviceList { flex: 1;
flex: 1; overflow-y: auto;
overflow-y: auto; }
} .deviceListInner {
.deviceListInner { display: flex;
display: flex; flex-direction: column;
flex-direction: column; gap: 12px;
gap: 12px; padding: 16px;
padding: 16px; }
} .deviceItem {
.deviceItem { display: flex;
display: flex; align-items: flex-start;
align-items: flex-start; gap: 12px;
gap: 12px; padding: 16px;
padding: 16px; border-radius: 12px;
border-radius: 12px; border: 1px solid #f0f0f0;
border: 1px solid #f0f0f0; background: #fff;
background: #fff; cursor: pointer;
cursor: pointer; transition: background 0.2s;
transition: background 0.2s; &:hover {
&:hover { background: #f5f6fa;
background: #f5f6fa; }
} }
} .deviceCheckbox {
.deviceCheckbox { margin-top: 4px;
margin-top: 4px; }
} .deviceInfo {
.deviceInfo { flex: 1;
flex: 1; }
} .deviceInfoRow {
.deviceInfoRow { display: flex;
display: flex; align-items: center;
align-items: center; justify-content: space-between;
justify-content: space-between; }
} .deviceName {
.deviceName { font-weight: 500;
font-weight: 500; font-size: 16px;
font-size: 16px; color: #222;
color: #222; }
} .statusOnline {
.statusOnline { width: 56px;
width: 56px; height: 24px;
height: 24px; border-radius: 12px;
border-radius: 12px; background: #52c41a;
background: #52c41a; color: #fff;
color: #fff; font-size: 13px;
font-size: 13px; display: flex;
display: flex; align-items: center;
align-items: center; justify-content: center;
justify-content: center; }
} .statusOffline {
.statusOffline { width: 56px;
width: 56px; height: 24px;
height: 24px; border-radius: 12px;
border-radius: 12px; background: #e5e6eb;
background: #e5e6eb; color: #888;
color: #888; font-size: 13px;
font-size: 13px; display: flex;
display: flex; align-items: center;
align-items: center; justify-content: center;
justify-content: center; }
} .deviceInfoDetail {
.deviceInfoDetail { font-size: 13px;
font-size: 13px; color: #888;
color: #888; margin-top: 4px;
margin-top: 4px; }
} .loadingBox {
.loadingBox { display: flex;
display: flex; align-items: center;
align-items: center; justify-content: center;
justify-content: center; height: 100%;
height: 100%; }
} .loadingText {
.loadingText { color: #888;
color: #888; font-size: 15px;
font-size: 15px; }
} .popupFooter {
.popupFooter { display: flex;
display: flex; align-items: center;
align-items: center; justify-content: space-between;
justify-content: space-between; padding: 16px;
padding: 16px; border-top: 1px solid #f0f0f0;
border-top: 1px solid #f0f0f0; background: #fff;
background: #fff; }
} .selectedCount {
.selectedCount { font-size: 14px;
font-size: 14px; color: #888;
color: #888; }
} .footerBtnGroup {
.footerBtnGroup { display: flex;
display: flex; gap: 12px;
gap: 12px; }
} .refreshBtn {
.refreshBtn { width: 36px;
width: 36px; height: 36px;
height: 36px; }
} .paginationRow {
.paginationRow { border-top: 1px solid #f0f0f0;
border-top: 1px solid #f0f0f0; padding: 16px;
padding: 16px; display: flex;
display: flex; align-items: center;
align-items: center; justify-content: space-between;
justify-content: space-between; background: #fff;
background: #fff; }
} .totalCount {
.totalCount { font-size: 14px;
font-size: 14px; color: #888;
color: #888; }
} .paginationControls {
.paginationControls { display: flex;
display: flex; align-items: center;
align-items: center; gap: 8px;
gap: 8px; }
} .pageBtn {
.pageBtn { padding: 0 8px;
padding: 0 8px; height: 32px;
height: 32px; min-width: 32px;
min-width: 32px; border-radius: 16px;
border-radius: 16px; }
} .pageInfo {
.pageInfo { font-size: 14px;
font-size: 14px; color: #222;
color: #222; margin: 0 8px;
margin: 0 8px; }
}

View File

@@ -43,7 +43,7 @@ const DeviceSelection: React.FC<DeviceSelectionProps> = ({
// 删除已选设备 // 删除已选设备
const handleRemoveDevice = (id: string) => { const handleRemoveDevice = (id: string) => {
if (readonly) return; if (readonly) return;
onSelect(selectedDevices.filter((d) => d !== id)); onSelect(selectedDevices.filter(d => d !== id));
}; };
return ( return (
@@ -79,7 +79,7 @@ const DeviceSelection: React.FC<DeviceSelectionProps> = ({
background: "#fff", background: "#fff",
}} }}
> >
{selectedDevices.map((deviceId) => ( {selectedDevices.map(deviceId => (
<div <div
key={deviceId} key={deviceId}
className={style.selectedListRow} className={style.selectedListRow}

View File

@@ -1,207 +1,207 @@
import React, { useState, useEffect, useCallback } from "react"; import React, { useState, useEffect, useCallback } from "react";
import { Checkbox, Popup } from "antd-mobile"; import { Checkbox, Popup } from "antd-mobile";
import { getDeviceList } from "./api"; import { getDeviceList } from "./api";
import style from "./index.module.scss"; import style from "./index.module.scss";
import Layout from "@/components/Layout/Layout"; import Layout from "@/components/Layout/Layout";
import PopupHeader from "@/components/PopuLayout/header"; import PopupHeader from "@/components/PopuLayout/header";
import PopupFooter from "@/components/PopuLayout/footer"; import PopupFooter from "@/components/PopuLayout/footer";
interface DeviceSelectionItem { interface DeviceSelectionItem {
id: string; id: string;
name: string; name: string;
imei: string; imei: string;
wechatId: string; wechatId: string;
status: "online" | "offline"; status: "online" | "offline";
wxid?: string; wxid?: string;
nickname?: string; nickname?: string;
usedInPlans?: number; usedInPlans?: number;
} }
interface SelectionPopupProps { interface SelectionPopupProps {
visible: boolean; visible: boolean;
onClose: () => void; onClose: () => void;
selectedDevices: string[]; selectedDevices: string[];
onSelect: (devices: string[]) => void; onSelect: (devices: string[]) => void;
} }
const PAGE_SIZE = 20; const PAGE_SIZE = 20;
const SelectionPopup: React.FC<SelectionPopupProps> = ({ const SelectionPopup: React.FC<SelectionPopupProps> = ({
visible, visible,
onClose, onClose,
selectedDevices, selectedDevices,
onSelect, onSelect,
}) => { }) => {
// 设备数据 // 设备数据
const [devices, setDevices] = useState<DeviceSelectionItem[]>([]); const [devices, setDevices] = useState<DeviceSelectionItem[]>([]);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [statusFilter, setStatusFilter] = useState("all"); const [statusFilter, setStatusFilter] = useState("all");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
// 获取设备列表支持keyword和分页 // 获取设备列表支持keyword和分页
const fetchDevices = useCallback( const fetchDevices = useCallback(
async (keyword: string = "", page: number = 1) => { async (keyword: string = "", page: number = 1) => {
setLoading(true); setLoading(true);
try { try {
const res = await getDeviceList({ const res = await getDeviceList({
page, page,
limit: PAGE_SIZE, limit: PAGE_SIZE,
keyword: keyword.trim() || undefined, keyword: keyword.trim() || undefined,
}); });
if (res && Array.isArray(res.list)) { if (res && Array.isArray(res.list)) {
setDevices( setDevices(
res.list.map((d: any) => ({ res.list.map((d: any) => ({
id: d.id?.toString() || "", id: d.id?.toString() || "",
name: d.memo || d.imei || "", name: d.memo || d.imei || "",
imei: d.imei || "", imei: d.imei || "",
wechatId: d.wechatId || "", wechatId: d.wechatId || "",
status: d.alive === 1 ? "online" : "offline", status: d.alive === 1 ? "online" : "offline",
wxid: d.wechatId || "", wxid: d.wechatId || "",
nickname: d.nickname || "", nickname: d.nickname || "",
usedInPlans: d.usedInPlans || 0, usedInPlans: d.usedInPlans || 0,
})) }))
); );
setTotal(res.total || 0); setTotal(res.total || 0);
} }
} catch (error) { } catch (error) {
console.error("获取设备列表失败:", error); console.error("获取设备列表失败:", error);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, },
[] []
); );
// 打开弹窗时获取第一页 // 打开弹窗时获取第一页
useEffect(() => { useEffect(() => {
if (visible) { if (visible) {
setSearchQuery(""); setSearchQuery("");
setCurrentPage(1); setCurrentPage(1);
fetchDevices("", 1); fetchDevices("", 1);
} }
}, [visible, fetchDevices]); }, [visible, fetchDevices]);
// 搜索防抖 // 搜索防抖
useEffect(() => { useEffect(() => {
if (!visible) return; if (!visible) return;
const timer = setTimeout(() => { const timer = setTimeout(() => {
setCurrentPage(1); setCurrentPage(1);
fetchDevices(searchQuery, 1); fetchDevices(searchQuery, 1);
}, 500); }, 500);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [searchQuery, visible, fetchDevices]); }, [searchQuery, visible, fetchDevices]);
// 翻页时重新请求 // 翻页时重新请求
useEffect(() => { useEffect(() => {
if (!visible) return; if (!visible) return;
fetchDevices(searchQuery, currentPage); fetchDevices(searchQuery, currentPage);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentPage]); }, [currentPage]);
// 过滤设备(只保留状态过滤) // 过滤设备(只保留状态过滤)
const filteredDevices = devices.filter((device) => { const filteredDevices = devices.filter(device => {
const matchesStatus = const matchesStatus =
statusFilter === "all" || statusFilter === "all" ||
(statusFilter === "online" && device.status === "online") || (statusFilter === "online" && device.status === "online") ||
(statusFilter === "offline" && device.status === "offline"); (statusFilter === "offline" && device.status === "offline");
return matchesStatus; return matchesStatus;
}); });
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
// 处理设备选择 // 处理设备选择
const handleDeviceToggle = (deviceId: string) => { const handleDeviceToggle = (deviceId: string) => {
if (selectedDevices.includes(deviceId)) { if (selectedDevices.includes(deviceId)) {
onSelect(selectedDevices.filter((id) => id !== deviceId)); onSelect(selectedDevices.filter(id => id !== deviceId));
} else { } else {
onSelect([...selectedDevices, deviceId]); onSelect([...selectedDevices, deviceId]);
} }
}; };
return ( return (
<Popup <Popup
visible={visible} visible={visible}
onMaskClick={onClose} onMaskClick={onClose}
position="bottom" position="bottom"
bodyStyle={{ height: "100vh" }} bodyStyle={{ height: "100vh" }}
closeOnMaskClick={false} closeOnMaskClick={false}
> >
<Layout <Layout
header={ header={
<PopupHeader <PopupHeader
title="选择设备" title="选择设备"
searchQuery={searchQuery} searchQuery={searchQuery}
setSearchQuery={setSearchQuery} setSearchQuery={setSearchQuery}
searchPlaceholder="搜索设备IMEI/备注/微信号" searchPlaceholder="搜索设备IMEI/备注/微信号"
loading={loading} loading={loading}
onRefresh={() => fetchDevices(searchQuery, currentPage)} onRefresh={() => fetchDevices(searchQuery, currentPage)}
showTabs={true} showTabs={true}
tabsConfig={{ tabsConfig={{
activeKey: statusFilter, activeKey: statusFilter,
onChange: setStatusFilter, onChange: setStatusFilter,
tabs: [ tabs: [
{ title: "全部", key: "all" }, { title: "全部", key: "all" },
{ title: "在线", key: "online" }, { title: "在线", key: "online" },
{ title: "离线", key: "offline" }, { title: "离线", key: "offline" },
], ],
}} }}
/> />
} }
footer={ footer={
<PopupFooter <PopupFooter
total={total} total={total}
currentPage={currentPage} currentPage={currentPage}
totalPages={totalPages} totalPages={totalPages}
loading={loading} loading={loading}
selectedCount={selectedDevices.length} selectedCount={selectedDevices.length}
onPageChange={setCurrentPage} onPageChange={setCurrentPage}
onCancel={onClose} onCancel={onClose}
onConfirm={onClose} onConfirm={onClose}
/> />
} }
> >
<div className={style.deviceList}> <div className={style.deviceList}>
{loading ? ( {loading ? (
<div className={style.loadingBox}> <div className={style.loadingBox}>
<div className={style.loadingText}>...</div> <div className={style.loadingText}>...</div>
</div> </div>
) : ( ) : (
<div className={style.deviceListInner}> <div className={style.deviceListInner}>
{filteredDevices.map((device) => ( {filteredDevices.map(device => (
<label key={device.id} className={style.deviceItem}> <label key={device.id} className={style.deviceItem}>
<Checkbox <Checkbox
checked={selectedDevices.includes(device.id)} checked={selectedDevices.includes(device.id)}
onChange={() => handleDeviceToggle(device.id)} onChange={() => handleDeviceToggle(device.id)}
className={style.deviceCheckbox} className={style.deviceCheckbox}
/> />
<div className={style.deviceInfo}> <div className={style.deviceInfo}>
<div className={style.deviceInfoRow}> <div className={style.deviceInfoRow}>
<span className={style.deviceName}>{device.name}</span> <span className={style.deviceName}>{device.name}</span>
<div <div
className={ className={
device.status === "online" device.status === "online"
? style.statusOnline ? style.statusOnline
: style.statusOffline : style.statusOffline
} }
> >
{device.status === "online" ? "在线" : "离线"} {device.status === "online" ? "在线" : "离线"}
</div> </div>
</div> </div>
<div className={style.deviceInfoDetail}> <div className={style.deviceInfoDetail}>
<div>IMEI: {device.imei}</div> <div>IMEI: {device.imei}</div>
<div>: {device.wechatId}</div> <div>: {device.wechatId}</div>
</div> </div>
</div> </div>
</label> </label>
))} ))}
</div> </div>
)} )}
</div> </div>
</Layout> </Layout>
</Popup> </Popup>
); );
}; };
export default SelectionPopup; export default SelectionPopup;

View File

@@ -1,11 +1,11 @@
import request from "@/api/request"; import request from "@/api/request";
// 获取好友列表 // 获取好友列表
export function getFriendList(params: { export function getFriendList(params: {
page: number; page: number;
limit: number; limit: number;
deviceIds?: string; // 逗号分隔 deviceIds?: string; // 逗号分隔
keyword?: string; keyword?: string;
}) { }) {
return request("/v1/friend", params, "GET"); return request("/v1/friend", params, "GET");
} }

View File

@@ -1,231 +1,231 @@
.inputWrapper { .inputWrapper {
position: relative; position: relative;
} }
.inputIcon { .inputIcon {
position: absolute; position: absolute;
left: 12px; left: 12px;
top: 50%; top: 50%;
transform: translateY(-50%); transform: translateY(-50%);
color: #bdbdbd; color: #bdbdbd;
font-size: 20px; font-size: 20px;
} }
.input { .input {
padding-left: 38px !important; padding-left: 38px !important;
height: 48px; height: 48px;
border-radius: 16px !important; border-radius: 16px !important;
border: 1px solid #e5e6eb !important; border: 1px solid #e5e6eb !important;
font-size: 16px; font-size: 16px;
background: #f8f9fa; background: #f8f9fa;
} }
.popupContainer { .popupContainer {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100vh; height: 100vh;
background: #fff; background: #fff;
} }
.popupHeader { .popupHeader {
padding: 24px; padding: 24px;
} }
.popupTitle { .popupTitle {
text-align: center; text-align: center;
font-size: 20px; font-size: 20px;
font-weight: 600; font-weight: 600;
margin-bottom: 24px; margin-bottom: 24px;
} }
.searchWrapper { .searchWrapper {
position: relative; position: relative;
margin-bottom: 16px; margin-bottom: 16px;
} }
.searchInput { .searchInput {
padding-left: 40px !important; padding-left: 40px !important;
padding-top: 8px !important; padding-top: 8px !important;
padding-bottom: 8px !important; padding-bottom: 8px !important;
border-radius: 24px !important; border-radius: 24px !important;
border: 1px solid #e5e6eb !important; border: 1px solid #e5e6eb !important;
font-size: 15px; font-size: 15px;
background: #f8f9fa; background: #f8f9fa;
} }
.searchIcon { .searchIcon {
position: absolute; position: absolute;
left: 12px; left: 12px;
top: 50%; top: 50%;
transform: translateY(-50%); transform: translateY(-50%);
color: #bdbdbd; color: #bdbdbd;
font-size: 16px; font-size: 16px;
} }
.clearBtn { .clearBtn {
position: absolute; position: absolute;
right: 8px; right: 8px;
top: 50%; top: 50%;
transform: translateY(-50%); transform: translateY(-50%);
height: 24px; height: 24px;
width: 24px; width: 24px;
border-radius: 50%; border-radius: 50%;
min-width: 24px; min-width: 24px;
} }
.friendList { .friendList {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
} }
.friendListInner { .friendListInner {
border-top: 1px solid #f0f0f0; border-top: 1px solid #f0f0f0;
} }
.friendItem { .friendItem {
display: flex; display: flex;
align-items: center; align-items: center;
padding: 16px 24px; padding: 16px 24px;
border-bottom: 1px solid #f0f0f0; border-bottom: 1px solid #f0f0f0;
cursor: pointer; cursor: pointer;
transition: background 0.2s; transition: background 0.2s;
&:hover { &:hover {
background: #f5f6fa; background: #f5f6fa;
} }
} }
.radioWrapper { .radioWrapper {
margin-right: 12px; margin-right: 12px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
.radioSelected { .radioSelected {
width: 20px; width: 20px;
height: 20px; height: 20px;
border-radius: 50%; border-radius: 50%;
border: 2px solid #1890ff; border: 2px solid #1890ff;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
.radioUnselected { .radioUnselected {
width: 20px; width: 20px;
height: 20px; height: 20px;
border-radius: 50%; border-radius: 50%;
border: 2px solid #e5e6eb; border: 2px solid #e5e6eb;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
.radioDot { .radioDot {
width: 12px; width: 12px;
height: 12px; height: 12px;
border-radius: 50%; border-radius: 50%;
background: #1890ff; background: #1890ff;
} }
.friendInfo { .friendInfo {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
flex: 1; flex: 1;
} }
.friendAvatar { .friendAvatar {
width: 40px; width: 40px;
height: 40px; height: 40px;
border-radius: 50%; border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: #fff; color: #fff;
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
overflow: hidden; overflow: hidden;
} }
.avatarImg { .avatarImg {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
} }
.friendDetail { .friendDetail {
flex: 1; flex: 1;
} }
.friendName { .friendName {
font-weight: 500; font-weight: 500;
font-size: 16px; font-size: 16px;
color: #222; color: #222;
margin-bottom: 2px; margin-bottom: 2px;
} }
.friendId { .friendId {
font-size: 13px; font-size: 13px;
color: #888; color: #888;
margin-bottom: 2px; margin-bottom: 2px;
} }
.friendCustomer { .friendCustomer {
font-size: 13px; font-size: 13px;
color: #bdbdbd; color: #bdbdbd;
} }
.loadingBox { .loadingBox {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 100%; height: 100%;
} }
.loadingText { .loadingText {
color: #888; color: #888;
font-size: 15px; font-size: 15px;
} }
.emptyBox { .emptyBox {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 100%; height: 100%;
} }
.emptyText { .emptyText {
color: #888; color: #888;
font-size: 15px; font-size: 15px;
} }
.paginationRow { .paginationRow {
border-top: 1px solid #f0f0f0; border-top: 1px solid #f0f0f0;
padding: 16px; padding: 16px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
background: #fff; background: #fff;
} }
.totalCount { .totalCount {
font-size: 14px; font-size: 14px;
color: #888; color: #888;
} }
.paginationControls { .paginationControls {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
} }
.pageBtn { .pageBtn {
padding: 0 8px; padding: 0 8px;
height: 32px; height: 32px;
min-width: 32px; min-width: 32px;
} }
.pageInfo { .pageInfo {
font-size: 14px; font-size: 14px;
color: #222; color: #222;
} }
.popupFooter { .popupFooter {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 16px; padding: 16px;
border-top: 1px solid #f0f0f0; border-top: 1px solid #f0f0f0;
background: #fff; background: #fff;
} }
.selectedCount { .selectedCount {
font-size: 14px; font-size: 14px;
color: #888; color: #888;
} }
.footerBtnGroup { .footerBtnGroup {
display: flex; display: flex;
gap: 12px; gap: 12px;
} }
.cancelBtn { .cancelBtn {
padding: 0 24px; padding: 0 24px;
border-radius: 24px; border-radius: 24px;
border: 1px solid #e5e6eb; border: 1px solid #e5e6eb;
} }
.confirmBtn { .confirmBtn {
padding: 0 24px; padding: 0 24px;
border-radius: 24px; border-radius: 24px;
} }

View File

@@ -98,7 +98,7 @@ export default function FriendSelection({
const fetchFriends = async (page: number, keyword: string = "") => { const fetchFriends = async (page: number, keyword: string = "") => {
setLoading(true); setLoading(true);
try { try {
let params: any = { const params: any = {
page, page,
limit: 20, limit: 20,
}; };
@@ -129,14 +129,14 @@ export default function FriendSelection({
if (readonly) return; if (readonly) return;
const newSelectedFriends = selectedFriends.includes(friendId) const newSelectedFriends = selectedFriends.includes(friendId)
? selectedFriends.filter((id) => id !== friendId) ? selectedFriends.filter(id => id !== friendId)
: [...selectedFriends, friendId]; : [...selectedFriends, friendId];
onSelect(newSelectedFriends); onSelect(newSelectedFriends);
// 如果有 onSelectDetail 回调,传递完整的好友对象 // 如果有 onSelectDetail 回调,传递完整的好友对象
if (onSelectDetail) { if (onSelectDetail) {
const selectedFriendObjs = friends.filter((friend) => const selectedFriendObjs = friends.filter(friend =>
newSelectedFriends.includes(friend.id) newSelectedFriends.includes(friend.id)
); );
onSelectDetail(selectedFriendObjs); onSelectDetail(selectedFriendObjs);
@@ -151,10 +151,10 @@ export default function FriendSelection({
// 获取已选好友详细信息 // 获取已选好友详细信息
const selectedFriendObjs = [ const selectedFriendObjs = [
...friends.filter((friend) => selectedFriends.includes(friend.id)), ...friends.filter(friend => selectedFriends.includes(friend.id)),
...selectedFriends ...selectedFriends
.filter((id) => !friends.some((friend) => friend.id === id)) .filter(id => !friends.some(friend => friend.id === id))
.map((id) => ({ .map(id => ({
id, id,
nickname: id, nickname: id,
wechatId: id, wechatId: id,
@@ -166,7 +166,7 @@ export default function FriendSelection({
// 删除已选好友 // 删除已选好友
const handleRemoveFriend = (id: string) => { const handleRemoveFriend = (id: string) => {
if (readonly) return; 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", background: "#fff",
}} }}
> >
{selectedFriendObjs.map((friend) => ( {selectedFriendObjs.map(friend => (
<div <div
key={friend.id} key={friend.id}
className={style.selectedListRow} className={style.selectedListRow}
@@ -294,7 +294,7 @@ export default function FriendSelection({
</div> </div>
) : friends.length > 0 ? ( ) : friends.length > 0 ? (
<div className={style.friendListInner}> <div className={style.friendListInner}>
{friends.map((friend) => ( {friends.map(friend => (
<label <label
key={friend.id} key={friend.id}
className={style.friendItem} className={style.friendItem}

View File

@@ -1,10 +1,10 @@
import request from "@/api/request"; import request from "@/api/request";
// 获取群组列表 // 获取群组列表
export function getGroupList(params: { export function getGroupList(params: {
page: number; page: number;
limit: number; limit: number;
keyword?: string; keyword?: string;
}) { }) {
return request("/v1/chatroom", params, "GET"); return request("/v1/chatroom", params, "GET");
} }

View File

@@ -1,222 +1,222 @@
.inputWrapper { .inputWrapper {
position: relative; position: relative;
} }
.inputIcon { .inputIcon {
position: absolute; position: absolute;
left: 12px; left: 12px;
top: 50%; top: 50%;
transform: translateY(-50%); transform: translateY(-50%);
color: #bdbdbd; color: #bdbdbd;
font-size: 20px; font-size: 20px;
} }
.input { .input {
padding-left: 38px !important; padding-left: 38px !important;
height: 48px; height: 48px;
border-radius: 16px !important; border-radius: 16px !important;
border: 1px solid #e5e6eb !important; border: 1px solid #e5e6eb !important;
font-size: 16px; font-size: 16px;
background: #f8f9fa; background: #f8f9fa;
} }
.popupContainer { .popupContainer {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100vh; height: 100vh;
background: #fff; background: #fff;
} }
.popupHeader { .popupHeader {
padding: 24px; padding: 24px;
} }
.popupTitle { .popupTitle {
text-align: center; text-align: center;
font-size: 20px; font-size: 20px;
font-weight: 600; font-weight: 600;
margin-bottom: 24px; margin-bottom: 24px;
} }
.searchWrapper { .searchWrapper {
position: relative; position: relative;
margin-bottom: 16px; margin-bottom: 16px;
} }
.searchInput { .searchInput {
padding-left: 40px !important; padding-left: 40px !important;
padding-top: 8px !important; padding-top: 8px !important;
padding-bottom: 8px !important; padding-bottom: 8px !important;
border-radius: 24px !important; border-radius: 24px !important;
border: 1px solid #e5e6eb !important; border: 1px solid #e5e6eb !important;
font-size: 15px; font-size: 15px;
background: #f8f9fa; background: #f8f9fa;
} }
.searchIcon { .searchIcon {
position: absolute; position: absolute;
left: 12px; left: 12px;
top: 50%; top: 50%;
transform: translateY(-50%); transform: translateY(-50%);
color: #bdbdbd; color: #bdbdbd;
font-size: 16px; font-size: 16px;
} }
.clearBtn { .clearBtn {
position: absolute; position: absolute;
right: 8px; right: 8px;
top: 50%; top: 50%;
transform: translateY(-50%); transform: translateY(-50%);
height: 24px; height: 24px;
width: 24px; width: 24px;
border-radius: 50%; border-radius: 50%;
min-width: 24px; min-width: 24px;
} }
.groupList { .groupList {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
} }
.groupListInner { .groupListInner {
border-top: 1px solid #f0f0f0; border-top: 1px solid #f0f0f0;
} }
.groupItem { .groupItem {
display: flex; display: flex;
align-items: center; align-items: center;
padding: 16px 24px; padding: 16px 24px;
border-bottom: 1px solid #f0f0f0; border-bottom: 1px solid #f0f0f0;
cursor: pointer; cursor: pointer;
transition: background 0.2s; transition: background 0.2s;
&:hover { &:hover {
background: #f5f6fa; background: #f5f6fa;
} }
} }
.radioWrapper { .radioWrapper {
margin-right: 12px; margin-right: 12px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
.radioSelected { .radioSelected {
width: 20px; width: 20px;
height: 20px; height: 20px;
border-radius: 50%; border-radius: 50%;
border: 2px solid #1890ff; border: 2px solid #1890ff;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
.radioUnselected { .radioUnselected {
width: 20px; width: 20px;
height: 20px; height: 20px;
border-radius: 50%; border-radius: 50%;
border: 2px solid #e5e6eb; border: 2px solid #e5e6eb;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
.radioDot { .radioDot {
width: 12px; width: 12px;
height: 12px; height: 12px;
border-radius: 50%; border-radius: 50%;
background: #1890ff; background: #1890ff;
} }
.groupInfo { .groupInfo {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
flex: 1; flex: 1;
} }
.groupAvatar { .groupAvatar {
width: 40px; width: 40px;
height: 40px; height: 40px;
border-radius: 50%; border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: #fff; color: #fff;
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
overflow: hidden; overflow: hidden;
} }
.avatarImg { .avatarImg {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
} }
.groupDetail { .groupDetail {
flex: 1; flex: 1;
} }
.groupName { .groupName {
font-weight: 500; font-weight: 500;
font-size: 16px; font-size: 16px;
color: #222; color: #222;
margin-bottom: 2px; margin-bottom: 2px;
} }
.groupId { .groupId {
font-size: 13px; font-size: 13px;
color: #888; color: #888;
margin-bottom: 2px; margin-bottom: 2px;
} }
.groupOwner { .groupOwner {
font-size: 13px; font-size: 13px;
color: #bdbdbd; color: #bdbdbd;
} }
.loadingBox { .loadingBox {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 100%; height: 100%;
} }
.loadingText { .loadingText {
color: #888; color: #888;
font-size: 15px; font-size: 15px;
} }
.emptyBox { .emptyBox {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 100%; height: 100%;
} }
.emptyText { .emptyText {
color: #888; color: #888;
font-size: 15px; font-size: 15px;
} }
.paginationRow { .paginationRow {
border-top: 1px solid #f0f0f0; border-top: 1px solid #f0f0f0;
padding: 16px; padding: 16px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
background: #fff; background: #fff;
} }
.totalCount { .totalCount {
font-size: 14px; font-size: 14px;
color: #888; color: #888;
} }
.paginationControls { .paginationControls {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
} }
.pageBtn { .pageBtn {
padding: 0 8px; padding: 0 8px;
height: 32px; height: 32px;
min-width: 32px; min-width: 32px;
} }
.pageInfo { .pageInfo {
font-size: 14px; font-size: 14px;
color: #222; color: #222;
} }
.popupFooter { .popupFooter {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 16px; padding: 16px;
border-top: 1px solid #f0f0f0; border-top: 1px solid #f0f0f0;
background: #fff; background: #fff;
} }
.selectedCount { .selectedCount {
font-size: 14px; font-size: 14px;
color: #888; color: #888;
} }
.footerBtnGroup { .footerBtnGroup {
display: flex; display: flex;
gap: 12px; gap: 12px;
} }

View File

@@ -1,341 +1,341 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { SearchOutlined, DeleteOutlined } from "@ant-design/icons"; import { SearchOutlined, DeleteOutlined } from "@ant-design/icons";
import { Button, Input } from "antd"; import { Button, Input } from "antd";
import { Popup } from "antd-mobile"; import { Popup } from "antd-mobile";
import { getGroupList } from "./api"; import { getGroupList } from "./api";
import style from "./index.module.scss"; import style from "./index.module.scss";
import Layout from "@/components/Layout/Layout"; import Layout from "@/components/Layout/Layout";
import PopupHeader from "@/components/PopuLayout/header"; import PopupHeader from "@/components/PopuLayout/header";
import PopupFooter from "@/components/PopuLayout/footer"; import PopupFooter from "@/components/PopuLayout/footer";
// 群组接口类型 // 群组接口类型
interface WechatGroup { interface WechatGroup {
id: string; id: string;
chatroomId: string; chatroomId: string;
name: string; name: string;
avatar: string; avatar: string;
ownerWechatId: string; ownerWechatId: string;
ownerNickname: string; ownerNickname: string;
ownerAvatar: string; ownerAvatar: string;
} }
// 组件属性接口 // 组件属性接口
interface GroupSelectionProps { interface GroupSelectionProps {
selectedGroups: string[]; selectedGroups: string[];
onSelect: (groups: string[]) => void; onSelect: (groups: string[]) => void;
onSelectDetail?: (groups: WechatGroup[]) => void; onSelectDetail?: (groups: WechatGroup[]) => void;
placeholder?: string; placeholder?: string;
className?: string; className?: string;
visible?: boolean; visible?: boolean;
onVisibleChange?: (visible: boolean) => void; onVisibleChange?: (visible: boolean) => void;
selectedListMaxHeight?: number; selectedListMaxHeight?: number;
showInput?: boolean; showInput?: boolean;
showSelectedList?: boolean; showSelectedList?: boolean;
readonly?: boolean; readonly?: boolean;
onConfirm?: (selectedIds: string[], selectedItems: WechatGroup[]) => void; // 新增 onConfirm?: (selectedIds: string[], selectedItems: WechatGroup[]) => void; // 新增
} }
export default function GroupSelection({ export default function GroupSelection({
selectedGroups, selectedGroups,
onSelect, onSelect,
onSelectDetail, onSelectDetail,
placeholder = "选择群聊", placeholder = "选择群聊",
className = "", className = "",
visible, visible,
onVisibleChange, onVisibleChange,
selectedListMaxHeight = 300, selectedListMaxHeight = 300,
showInput = true, showInput = true,
showSelectedList = true, showSelectedList = true,
readonly = false, readonly = false,
onConfirm, onConfirm,
}: GroupSelectionProps) { }: GroupSelectionProps) {
const [popupVisible, setPopupVisible] = useState(false); const [popupVisible, setPopupVisible] = useState(false);
const [groups, setGroups] = useState<WechatGroup[]>([]); const [groups, setGroups] = useState<WechatGroup[]>([]);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1); const [totalPages, setTotalPages] = useState(1);
const [totalGroups, setTotalGroups] = useState(0); const [totalGroups, setTotalGroups] = useState(0);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
// 获取已选群聊详细信息 // 获取已选群聊详细信息
const selectedGroupObjs = groups.filter((group) => const selectedGroupObjs = groups.filter(group =>
selectedGroups.includes(group.id) selectedGroups.includes(group.id)
); );
// 删除已选群聊 // 删除已选群聊
const handleRemoveGroup = (id: string) => { const handleRemoveGroup = (id: string) => {
if (readonly) return; if (readonly) return;
onSelect(selectedGroups.filter((g) => g !== id)); onSelect(selectedGroups.filter(g => g !== id));
}; };
// 受控弹窗逻辑 // 受控弹窗逻辑
const realVisible = visible !== undefined ? visible : popupVisible; const realVisible = visible !== undefined ? visible : popupVisible;
const setRealVisible = (v: boolean) => { const setRealVisible = (v: boolean) => {
if (onVisibleChange) onVisibleChange(v); if (onVisibleChange) onVisibleChange(v);
if (visible === undefined) setPopupVisible(v); if (visible === undefined) setPopupVisible(v);
}; };
// 打开弹窗 // 打开弹窗
const openPopup = () => { const openPopup = () => {
if (readonly) return; if (readonly) return;
setCurrentPage(1); setCurrentPage(1);
setSearchQuery(""); setSearchQuery("");
setRealVisible(true); setRealVisible(true);
fetchGroups(1, ""); fetchGroups(1, "");
}; };
// 当页码变化时,拉取对应页数据(弹窗已打开时) // 当页码变化时,拉取对应页数据(弹窗已打开时)
useEffect(() => { useEffect(() => {
if (realVisible && currentPage !== 1) { if (realVisible && currentPage !== 1) {
fetchGroups(currentPage, searchQuery); fetchGroups(currentPage, searchQuery);
} }
}, [currentPage, realVisible, searchQuery]); }, [currentPage, realVisible, searchQuery]);
// 搜索防抖 // 搜索防抖
useEffect(() => { useEffect(() => {
if (!realVisible) return; if (!realVisible) return;
const timer = setTimeout(() => { const timer = setTimeout(() => {
setCurrentPage(1); setCurrentPage(1);
fetchGroups(1, searchQuery); fetchGroups(1, searchQuery);
}, 500); }, 500);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [searchQuery, realVisible]); }, [searchQuery, realVisible]);
// 获取群聊列表API // 获取群聊列表API
const fetchGroups = async (page: number, keyword: string = "") => { const fetchGroups = async (page: number, keyword: string = "") => {
setLoading(true); setLoading(true);
try { try {
let params: any = { const params: any = {
page, page,
limit: 20, limit: 20,
}; };
if (keyword.trim()) { if (keyword.trim()) {
params.keyword = keyword.trim(); params.keyword = keyword.trim();
} }
const response = await getGroupList(params); const response = await getGroupList(params);
if (response && response.list) { if (response && response.list) {
setGroups(response.list); setGroups(response.list);
setTotalGroups(response.total || 0); setTotalGroups(response.total || 0);
setTotalPages(Math.ceil((response.total || 0) / 20)); setTotalPages(Math.ceil((response.total || 0) / 20));
} }
} catch (error) { } catch (error) {
console.error("获取群聊列表失败:", error); console.error("获取群聊列表失败:", error);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
// 处理群聊选择 // 处理群聊选择
const handleGroupToggle = (groupId: string) => { const handleGroupToggle = (groupId: string) => {
if (readonly) return; if (readonly) return;
const newSelectedGroups = selectedGroups.includes(groupId) const newSelectedGroups = selectedGroups.includes(groupId)
? selectedGroups.filter((id) => id !== groupId) ? selectedGroups.filter(id => id !== groupId)
: [...selectedGroups, groupId]; : [...selectedGroups, groupId];
onSelect(newSelectedGroups); onSelect(newSelectedGroups);
// 如果有 onSelectDetail 回调,传递完整的群聊对象 // 如果有 onSelectDetail 回调,传递完整的群聊对象
if (onSelectDetail) { if (onSelectDetail) {
const selectedGroupObjs = groups.filter((group) => const selectedGroupObjs = groups.filter(group =>
newSelectedGroups.includes(group.id) newSelectedGroups.includes(group.id)
); );
onSelectDetail(selectedGroupObjs); onSelectDetail(selectedGroupObjs);
} }
}; };
// 获取显示文本 // 获取显示文本
const getDisplayText = () => { const getDisplayText = () => {
if (selectedGroups.length === 0) return ""; if (selectedGroups.length === 0) return "";
return `已选择 ${selectedGroups.length} 个群聊`; return `已选择 ${selectedGroups.length} 个群聊`;
}; };
// 确认选择 // 确认选择
const handleConfirm = () => { const handleConfirm = () => {
if (onConfirm) { if (onConfirm) {
onConfirm(selectedGroups, selectedGroupObjs); onConfirm(selectedGroups, selectedGroupObjs);
} }
setRealVisible(false); setRealVisible(false);
}; };
return ( return (
<> <>
{/* 输入框 */} {/* 输入框 */}
{showInput && ( {showInput && (
<div className={`${style.inputWrapper} ${className}`}> <div className={`${style.inputWrapper} ${className}`}>
<Input <Input
placeholder={placeholder} placeholder={placeholder}
value={getDisplayText()} value={getDisplayText()}
onClick={openPopup} onClick={openPopup}
prefix={<SearchOutlined />} prefix={<SearchOutlined />}
allowClear={!readonly} allowClear={!readonly}
size="large" size="large"
readOnly={readonly} readOnly={readonly}
disabled={readonly} disabled={readonly}
style={ style={
readonly ? { background: "#f5f5f5", cursor: "not-allowed" } : {} readonly ? { background: "#f5f5f5", cursor: "not-allowed" } : {}
} }
/> />
</div> </div>
)} )}
{/* 已选群聊列表窗口 */} {/* 已选群聊列表窗口 */}
{showSelectedList && selectedGroupObjs.length > 0 && ( {showSelectedList && selectedGroupObjs.length > 0 && (
<div <div
className={style.selectedListWindow} className={style.selectedListWindow}
style={{ style={{
maxHeight: selectedListMaxHeight, maxHeight: selectedListMaxHeight,
overflowY: "auto", overflowY: "auto",
marginTop: 8, marginTop: 8,
border: "1px solid #e5e6eb", border: "1px solid #e5e6eb",
borderRadius: 8, borderRadius: 8,
background: "#fff", background: "#fff",
}} }}
> >
{selectedGroupObjs.map((group) => ( {selectedGroupObjs.map(group => (
<div <div
key={group.id} key={group.id}
className={style.selectedListRow} className={style.selectedListRow}
style={{ style={{
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
padding: "4px 8px", padding: "4px 8px",
borderBottom: "1px solid #f0f0f0", borderBottom: "1px solid #f0f0f0",
fontSize: 14, fontSize: 14,
}} }}
> >
<div <div
style={{ style={{
flex: 1, flex: 1,
minWidth: 0, minWidth: 0,
whiteSpace: "nowrap", whiteSpace: "nowrap",
overflow: "hidden", overflow: "hidden",
textOverflow: "ellipsis", textOverflow: "ellipsis",
}} }}
> >
{group.name || group.chatroomId || group.id} {group.name || group.chatroomId || group.id}
</div> </div>
{!readonly && ( {!readonly && (
<Button <Button
type="text" type="text"
icon={<DeleteOutlined />} icon={<DeleteOutlined />}
size="small" size="small"
style={{ style={{
marginLeft: 4, marginLeft: 4,
color: "#ff4d4f", color: "#ff4d4f",
border: "none", border: "none",
background: "none", background: "none",
minWidth: 24, minWidth: 24,
height: 24, height: 24,
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
}} }}
onClick={() => handleRemoveGroup(group.id)} onClick={() => handleRemoveGroup(group.id)}
/> />
)} )}
</div> </div>
))} ))}
</div> </div>
)} )}
{/* 弹窗 */} {/* 弹窗 */}
<Popup <Popup
visible={realVisible && !readonly} visible={realVisible && !readonly}
onMaskClick={() => setRealVisible(false)} onMaskClick={() => setRealVisible(false)}
position="bottom" position="bottom"
bodyStyle={{ height: "100vh" }} bodyStyle={{ height: "100vh" }}
> >
<Layout <Layout
header={ header={
<PopupHeader <PopupHeader
title="选择群聊" title="选择群聊"
searchQuery={searchQuery} searchQuery={searchQuery}
setSearchQuery={setSearchQuery} setSearchQuery={setSearchQuery}
searchPlaceholder="搜索群聊" searchPlaceholder="搜索群聊"
loading={loading} loading={loading}
onRefresh={() => fetchGroups(currentPage, searchQuery)} onRefresh={() => fetchGroups(currentPage, searchQuery)}
/> />
} }
footer={ footer={
<PopupFooter <PopupFooter
total={totalGroups} total={totalGroups}
currentPage={currentPage} currentPage={currentPage}
totalPages={totalPages} totalPages={totalPages}
loading={loading} loading={loading}
selectedCount={selectedGroups.length} selectedCount={selectedGroups.length}
onPageChange={setCurrentPage} onPageChange={setCurrentPage}
onCancel={() => setRealVisible(false)} onCancel={() => setRealVisible(false)}
onConfirm={handleConfirm} onConfirm={handleConfirm}
/> />
} }
> >
<div className={style.groupList}> <div className={style.groupList}>
{loading ? ( {loading ? (
<div className={style.loadingBox}> <div className={style.loadingBox}>
<div className={style.loadingText}>...</div> <div className={style.loadingText}>...</div>
</div> </div>
) : groups.length > 0 ? ( ) : groups.length > 0 ? (
<div className={style.groupListInner}> <div className={style.groupListInner}>
{groups.map((group) => ( {groups.map(group => (
<label <label
key={group.id} key={group.id}
className={style.groupItem} className={style.groupItem}
onClick={() => !readonly && handleGroupToggle(group.id)} onClick={() => !readonly && handleGroupToggle(group.id)}
> >
<div className={style.radioWrapper}> <div className={style.radioWrapper}>
<div <div
className={ className={
selectedGroups.includes(group.id) selectedGroups.includes(group.id)
? style.radioSelected ? style.radioSelected
: style.radioUnselected : style.radioUnselected
} }
> >
{selectedGroups.includes(group.id) && ( {selectedGroups.includes(group.id) && (
<div className={style.radioDot}></div> <div className={style.radioDot}></div>
)} )}
</div> </div>
</div> </div>
<div className={style.groupInfo}> <div className={style.groupInfo}>
<div className={style.groupAvatar}> <div className={style.groupAvatar}>
{group.avatar ? ( {group.avatar ? (
<img <img
src={group.avatar} src={group.avatar}
alt={group.name} alt={group.name}
className={style.avatarImg} className={style.avatarImg}
/> />
) : ( ) : (
group.name.charAt(0) group.name.charAt(0)
)} )}
</div> </div>
<div className={style.groupDetail}> <div className={style.groupDetail}>
<div className={style.groupName}>{group.name}</div> <div className={style.groupName}>{group.name}</div>
<div className={style.groupId}> <div className={style.groupId}>
ID: {group.chatroomId} ID: {group.chatroomId}
</div> </div>
{group.ownerNickname && ( {group.ownerNickname && (
<div className={style.groupOwner}> <div className={style.groupOwner}>
: {group.ownerNickname} : {group.ownerNickname}
</div> </div>
)} )}
</div> </div>
</div> </div>
</label> </label>
))} ))}
</div> </div>
) : ( ) : (
<div className={style.emptyBox}> <div className={style.emptyBox}>
<div className={style.emptyText}> <div className={style.emptyText}>
{searchQuery {searchQuery
? `没有找到包含"${searchQuery}"的群聊` ? `没有找到包含"${searchQuery}"的群聊`
: "没有找到群聊"} : "没有找到群聊"}
</div> </div>
</div> </div>
)} )}
</div> </div>
</Layout> </Layout>
</Popup> </Popup>
</> </>
); );
} }

View File

@@ -1,87 +1,87 @@
.listContainer { .listContainer {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
position: relative; position: relative;
} }
.listItem { .listItem {
flex-shrink: 0; flex-shrink: 0;
width: 100%; width: 100%;
} }
.loadMoreButtonContainer { .loadMoreButtonContainer {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
padding: 16px; padding: 16px;
flex-shrink: 0; flex-shrink: 0;
} }
.noMoreText { .noMoreText {
text-align: center; text-align: center;
color: #999; color: #999;
font-size: 14px; font-size: 14px;
padding: 16px; padding: 16px;
flex-shrink: 0; flex-shrink: 0;
} }
.emptyState { .emptyState {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 60px 20px; padding: 60px 20px;
color: #999; color: #999;
flex: 1; flex: 1;
min-height: 200px; min-height: 200px;
} }
.emptyIcon { .emptyIcon {
font-size: 48px; font-size: 48px;
margin-bottom: 16px; margin-bottom: 16px;
opacity: 0.5; opacity: 0.5;
} }
.emptyText { .emptyText {
font-size: 14px; font-size: 14px;
color: #999; color: #999;
} }
.pullToRefresh { .pullToRefresh {
height: 100%; height: 100%;
overflow: auto; overflow: auto;
} }
// 自定义滚动条样式 // 自定义滚动条样式
.listContainer::-webkit-scrollbar { .listContainer::-webkit-scrollbar {
width: 4px; width: 4px;
} }
.listContainer::-webkit-scrollbar-track { .listContainer::-webkit-scrollbar-track {
background: transparent; background: transparent;
} }
.listContainer::-webkit-scrollbar-thumb { .listContainer::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.1); background: rgba(0, 0, 0, 0.1);
border-radius: 2px; border-radius: 2px;
} }
.listContainer::-webkit-scrollbar-thumb:hover { .listContainer::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.2); background: rgba(0, 0, 0, 0.2);
} }
// 响应式设计 // 响应式设计
@media (max-width: 768px) { @media (max-width: 768px) {
.listContainer { .listContainer {
padding: 0 8px; padding: 0 8px;
} }
.loadMoreButtonContainer { .loadMoreButtonContainer {
padding: 12px; padding: 12px;
} }
.noMoreText { .noMoreText {
padding: 12px; padding: 12px;
} }
} }

View File

@@ -1,195 +1,195 @@
import React, { useState, useEffect, useRef, useCallback } from "react"; import React, { useState, useEffect, useRef, useCallback } from "react";
import { import {
PullToRefresh, PullToRefresh,
InfiniteScroll, InfiniteScroll,
Button, Button,
SpinLoading, SpinLoading,
} from "antd-mobile"; } from "antd-mobile";
import styles from "./InfiniteList.module.scss"; import styles from "./InfiniteList.module.scss";
interface InfiniteListProps<T> { interface InfiniteListProps<T> {
// 数据相关 // 数据相关
data: T[]; data: T[];
loading?: boolean; loading?: boolean;
hasMore?: boolean; hasMore?: boolean;
loadingText?: string; loadingText?: string;
noMoreText?: string; noMoreText?: string;
// 渲染相关 // 渲染相关
renderItem: (item: T, index: number) => React.ReactNode; renderItem: (item: T, index: number) => React.ReactNode;
keyExtractor?: (item: T, index: number) => string | number; keyExtractor?: (item: T, index: number) => string | number;
// 事件回调 // 事件回调
onLoadMore?: () => Promise<void> | void; onLoadMore?: () => Promise<void> | void;
onRefresh?: () => Promise<void> | void; onRefresh?: () => Promise<void> | void;
// 样式相关 // 样式相关
className?: string; className?: string;
itemClassName?: string; itemClassName?: string;
containerStyle?: React.CSSProperties; containerStyle?: React.CSSProperties;
// 功能开关 // 功能开关
enablePullToRefresh?: boolean; enablePullToRefresh?: boolean;
enableInfiniteScroll?: boolean; enableInfiniteScroll?: boolean;
enableLoadMoreButton?: boolean; enableLoadMoreButton?: boolean;
// 自定义高度 // 自定义高度
height?: string | number; height?: string | number;
minHeight?: string | number; minHeight?: string | number;
} }
const InfiniteList = <T extends any>({ const InfiniteList = <T extends any>({
data, data,
loading = false, loading = false,
hasMore = true, hasMore = true,
loadingText = "加载中...", loadingText = "加载中...",
noMoreText = "没有更多了", noMoreText = "没有更多了",
renderItem, renderItem,
keyExtractor = (_, index) => index, keyExtractor = (_, index) => index,
onLoadMore, onLoadMore,
onRefresh, onRefresh,
className = "", className = "",
itemClassName = "", itemClassName = "",
containerStyle = {}, containerStyle = {},
enablePullToRefresh = true, enablePullToRefresh = true,
enableInfiniteScroll = true, enableInfiniteScroll = true,
enableLoadMoreButton = false, enableLoadMoreButton = false,
height = "100%", height = "100%",
minHeight = "200px", minHeight = "200px",
}: InfiniteListProps<T>) => { }: InfiniteListProps<T>) => {
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const [loadingMore, setLoadingMore] = useState(false); const [loadingMore, setLoadingMore] = useState(false);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
// 处理下拉刷新 // 处理下拉刷新
const handleRefresh = useCallback(async () => { const handleRefresh = useCallback(async () => {
if (!onRefresh) return; if (!onRefresh) return;
setRefreshing(true); setRefreshing(true);
try { try {
await onRefresh(); await onRefresh();
} catch (error) { } catch (error) {
console.error("Refresh failed:", error); console.error("Refresh failed:", error);
} finally { } finally {
setRefreshing(false); setRefreshing(false);
} }
}, [onRefresh]); }, [onRefresh]);
// 处理加载更多 // 处理加载更多
const handleLoadMore = useCallback(async () => { const handleLoadMore = useCallback(async () => {
if (!onLoadMore || loadingMore || !hasMore) return; if (!onLoadMore || loadingMore || !hasMore) return;
setLoadingMore(true); setLoadingMore(true);
try { try {
await onLoadMore(); await onLoadMore();
} catch (error) { } catch (error) {
console.error("Load more failed:", error); console.error("Load more failed:", error);
} finally { } finally {
setLoadingMore(false); setLoadingMore(false);
} }
}, [onLoadMore, loadingMore, hasMore]); }, [onLoadMore, loadingMore, hasMore]);
// 点击加载更多按钮 // 点击加载更多按钮
const handleLoadMoreClick = useCallback(() => { const handleLoadMoreClick = useCallback(() => {
handleLoadMore(); handleLoadMore();
}, [handleLoadMore]); }, [handleLoadMore]);
// 容器样式 // 容器样式
const containerStyles: React.CSSProperties = { const containerStyles: React.CSSProperties = {
height, height,
minHeight, minHeight,
...containerStyle, ...containerStyle,
}; };
// 渲染列表项 // 渲染列表项
const renderListItems = () => { const renderListItems = () => {
return data.map((item, index) => ( return data.map((item, index) => (
<div <div
key={keyExtractor(item, index)} key={keyExtractor(item, index)}
className={`${styles.listItem} ${itemClassName}`} className={`${styles.listItem} ${itemClassName}`}
> >
{renderItem(item, index)} {renderItem(item, index)}
</div> </div>
)); ));
}; };
// 渲染加载更多按钮 // 渲染加载更多按钮
const renderLoadMoreButton = () => { const renderLoadMoreButton = () => {
if (!enableLoadMoreButton || !hasMore) return null; if (!enableLoadMoreButton || !hasMore) return null;
return ( return (
<div className={styles.loadMoreButtonContainer}> <div className={styles.loadMoreButtonContainer}>
<Button <Button
size="small" size="small"
loading={loadingMore} loading={loadingMore}
onClick={handleLoadMoreClick} onClick={handleLoadMoreClick}
disabled={loading || !hasMore} disabled={loading || !hasMore}
> >
{loadingMore ? loadingText : "点击加载更多"} {loadingMore ? loadingText : "点击加载更多"}
</Button> </Button>
</div> </div>
); );
}; };
// 渲染无更多数据提示 // 渲染无更多数据提示
const renderNoMoreText = () => { const renderNoMoreText = () => {
if (hasMore || data.length === 0) return null; if (hasMore || data.length === 0) return null;
return <div className={styles.noMoreText}>{noMoreText}</div>; return <div className={styles.noMoreText}>{noMoreText}</div>;
}; };
// 渲染空状态 // 渲染空状态
const renderEmptyState = () => { const renderEmptyState = () => {
if (data.length > 0 || loading) return null; if (data.length > 0 || loading) return null;
return ( return (
<div className={styles.emptyState}> <div className={styles.emptyState}>
<div className={styles.emptyIcon}>📝</div> <div className={styles.emptyIcon}>📝</div>
<div className={styles.emptyText}></div> <div className={styles.emptyText}></div>
</div> </div>
); );
}; };
const content = ( const content = (
<div <div
className={`${styles.listContainer} ${className}`} className={`${styles.listContainer} ${className}`}
style={containerStyles} style={containerStyles}
> >
{renderListItems()} {renderListItems()}
{renderLoadMoreButton()} {renderLoadMoreButton()}
{renderNoMoreText()} {renderNoMoreText()}
{renderEmptyState()} {renderEmptyState()}
{/* 无限滚动组件 */} {/* 无限滚动组件 */}
{enableInfiniteScroll && ( {enableInfiniteScroll && (
<InfiniteScroll <InfiniteScroll
loadMore={handleLoadMore} loadMore={handleLoadMore}
hasMore={hasMore} hasMore={hasMore}
threshold={100} threshold={100}
/> />
)} )}
</div> </div>
); );
// 如果启用下拉刷新包装PullToRefresh // 如果启用下拉刷新包装PullToRefresh
if (enablePullToRefresh && onRefresh) { if (enablePullToRefresh && onRefresh) {
return ( return (
<PullToRefresh <PullToRefresh
onRefresh={handleRefresh} onRefresh={handleRefresh}
refreshing={refreshing} refreshing={refreshing}
className={styles.pullToRefresh} className={styles.pullToRefresh}
> >
{content} {content}
</PullToRefresh> </PullToRefresh>
); );
} }
return content; return content;
}; };
export default InfiniteList; export default InfiniteList;

View File

@@ -4,7 +4,7 @@
flex-direction: column; flex-direction: column;
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%); background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
} }
.container main { .container main {
flex: 1; flex: 1;
overflow: auto; overflow: auto;
@@ -25,4 +25,4 @@
color: #666; color: #666;
font-size: 14px; font-size: 14px;
text-align: center; text-align: center;
} }

View File

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

View File

@@ -42,12 +42,12 @@ const MeauMobile: React.FC<MeauMobileProps> = ({ activeKey }) => {
<TabBar <TabBar
style={{ background: "#fff" }} style={{ background: "#fff" }}
activeKey={activeKey} activeKey={activeKey}
onChange={(key) => { onChange={key => {
const tab = tabs.find((t) => t.key === key); const tab = tabs.find(t => t.key === key);
if (tab && tab.path) navigate(tab.path); 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.Item key={item.key} icon={item.icon} title={item.title} />
))} ))}
</TabBar> </TabBar>

View File

@@ -1,41 +1,41 @@
import React from "react"; import React from "react";
import { NavBar } from "antd-mobile"; import { NavBar } from "antd-mobile";
import { ArrowLeftOutlined } from "@ant-design/icons"; import { ArrowLeftOutlined } from "@ant-design/icons";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
interface NavCommonProps { interface NavCommonProps {
title: string; title: string;
backFn?: () => void; backFn?: () => void;
right?: React.ReactNode; right?: React.ReactNode;
} }
const NavCommon: React.FC<NavCommonProps> = ({ title, backFn, right }) => { const NavCommon: React.FC<NavCommonProps> = ({ title, backFn, right }) => {
const navigate = useNavigate(); const navigate = useNavigate();
return ( return (
<NavBar <NavBar
back={null} back={null}
style={{ background: "#fff" }} style={{ background: "#fff" }}
left={ left={
<div className="nav-title"> <div className="nav-title">
<ArrowLeftOutlined <ArrowLeftOutlined
twoToneColor="#1677ff" twoToneColor="#1677ff"
onClick={() => { onClick={() => {
if (backFn) { if (backFn) {
backFn(); backFn();
} else { } else {
navigate(-1); navigate(-1);
} }
}} }}
/> />
</div> </div>
} }
right={right} right={right}
> >
<span style={{ color: "var(--primary-color)", fontWeight: 600 }}> <span style={{ color: "var(--primary-color)", fontWeight: 600 }}>
{title} {title}
</span> </span>
</NavBar> </NavBar>
); );
}; };
export default NavCommon; export default NavCommon;

View File

@@ -1,71 +1,71 @@
.popupFooter { .popupFooter {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 16px; padding: 16px;
border-top: 1px solid #f0f0f0; border-top: 1px solid #f0f0f0;
background: #fff; background: #fff;
} }
.selectedCount { .selectedCount {
font-size: 14px; font-size: 14px;
color: #888; color: #888;
} }
.footerBtnGroup { .footerBtnGroup {
display: flex; display: flex;
gap: 12px; gap: 12px;
} }
.paginationRow { .paginationRow {
border-top: 1px solid #f0f0f0; border-top: 1px solid #f0f0f0;
padding: 16px; padding: 16px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
background: #fff; background: #fff;
} }
.totalCount { .totalCount {
font-size: 14px; font-size: 14px;
color: #888; color: #888;
} }
.paginationControls { .paginationControls {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
} }
.pageBtn { .pageBtn {
padding: 0 8px; padding: 0 8px;
height: 32px; height: 32px;
min-width: 32px; min-width: 32px;
border-radius: 16px; border-radius: 16px;
border: 1px solid #d9d9d9; border: 1px solid #d9d9d9;
color: #333; color: #333;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
&:hover:not(:disabled) { &:hover:not(:disabled) {
border-color: #1677ff; border-color: #1677ff;
color: #1677ff; color: #1677ff;
} }
&:disabled { &:disabled {
background: #f5f5f5; background: #f5f5f5;
color: #ccc; color: #ccc;
cursor: not-allowed; cursor: not-allowed;
} }
} }
.pageInfo { .pageInfo {
font-size: 14px; font-size: 14px;
color: #222; color: #222;
margin: 0 8px; margin: 0 8px;
min-width: 60px; min-width: 60px;
text-align: center; text-align: center;
} }

View File

@@ -1,67 +1,67 @@
import React from "react"; import React from "react";
import { Button } from "antd"; import { Button } from "antd";
import style from "./footer.module.scss"; import style from "./footer.module.scss";
import { ArrowLeftOutlined, ArrowRightOutlined } from "@ant-design/icons"; import { ArrowLeftOutlined, ArrowRightOutlined } from "@ant-design/icons";
interface PopupFooterProps { interface PopupFooterProps {
total: number; total: number;
currentPage: number; currentPage: number;
totalPages: number; totalPages: number;
loading: boolean; loading: boolean;
selectedCount: number; selectedCount: number;
onPageChange: (page: number) => void; onPageChange: (page: number) => void;
onCancel: () => void; onCancel: () => void;
onConfirm: () => void; onConfirm: () => void;
} }
const PopupFooter: React.FC<PopupFooterProps> = ({ const PopupFooter: React.FC<PopupFooterProps> = ({
total, total,
currentPage, currentPage,
totalPages, totalPages,
loading, loading,
selectedCount, selectedCount,
onPageChange, onPageChange,
onCancel, onCancel,
onConfirm, onConfirm,
}) => { }) => {
return ( return (
<> <>
{/* 分页栏 */} {/* 分页栏 */}
<div className={style.paginationRow}> <div className={style.paginationRow}>
<div className={style.totalCount}> {total} </div> <div className={style.totalCount}> {total} </div>
<div className={style.paginationControls}> <div className={style.paginationControls}>
<Button <Button
onClick={() => onPageChange(Math.max(1, currentPage - 1))} onClick={() => onPageChange(Math.max(1, currentPage - 1))}
disabled={currentPage === 1 || loading} disabled={currentPage === 1 || loading}
className={style.pageBtn} className={style.pageBtn}
> >
<ArrowLeftOutlined /> <ArrowLeftOutlined />
</Button> </Button>
<span className={style.pageInfo}> <span className={style.pageInfo}>
{currentPage} / {totalPages} {currentPage} / {totalPages}
</span> </span>
<Button <Button
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))} onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages || loading} disabled={currentPage === totalPages || loading}
className={style.pageBtn} className={style.pageBtn}
> >
<ArrowRightOutlined /> <ArrowRightOutlined />
</Button> </Button>
</div> </div>
</div> </div>
<div className={style.popupFooter}> <div className={style.popupFooter}>
<div className={style.selectedCount}> {selectedCount} </div> <div className={style.selectedCount}> {selectedCount} </div>
<div className={style.footerBtnGroup}> <div className={style.footerBtnGroup}>
<Button color="primary" variant="filled" onClick={onCancel}> <Button color="primary" variant="filled" onClick={onCancel}>
</Button> </Button>
<Button type="primary" onClick={onConfirm}> <Button type="primary" onClick={onConfirm}>
</Button> </Button>
</div> </div>
</div> </div>
</> </>
); );
}; };
export default PopupFooter; export default PopupFooter;

View File

@@ -1,52 +1,51 @@
.popupHeader { .popupHeader {
padding: 16px; padding: 16px;
border-bottom: 1px solid #f0f0f0; border-bottom: 1px solid #f0f0f0;
} }
.popupTitle { .popupTitle {
font-size: 20px; font-size: 20px;
font-weight: 600; font-weight: 600;
text-align: center; text-align: center;
} }
.popupSearchRow { .popupSearchRow {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 5px; gap: 5px;
padding: 16px; padding: 16px;
} }
.popupSearchInputWrap { .popupSearchInputWrap {
position: relative; position: relative;
flex: 1; flex: 1;
} }
.inputIcon { .inputIcon {
position: absolute; position: absolute;
left: 12px; left: 12px;
top: 50%; top: 50%;
transform: translateY(-50%); transform: translateY(-50%);
color: #bdbdbd; color: #bdbdbd;
z-index: 10; z-index: 10;
font-size: 18px; font-size: 18px;
} }
.refreshBtn {
.refreshBtn { width: 36px;
width: 36px; height: 36px;
height: 36px; }
}
.loadingIcon {
.loadingIcon { animation: spin 1s linear infinite;
animation: spin 1s linear infinite; font-size: 16px;
font-size: 16px; }
}
@keyframes spin {
@keyframes spin { from {
from { transform: rotate(0deg);
transform: rotate(0deg); }
} to {
to { transform: rotate(360deg);
transform: rotate(360deg); }
} }
}

View File

@@ -1,86 +1,86 @@
import React from "react"; import React from "react";
import { SearchOutlined, ReloadOutlined } from "@ant-design/icons"; import { SearchOutlined, ReloadOutlined } from "@ant-design/icons";
import { Input, Button } from "antd"; import { Input, Button } from "antd";
import { Tabs } from "antd-mobile"; import { Tabs } from "antd-mobile";
import style from "./header.module.scss"; import style from "./header.module.scss";
interface PopupHeaderProps { interface PopupHeaderProps {
title: string; title: string;
searchQuery: string; searchQuery: string;
setSearchQuery: (value: string) => void; setSearchQuery: (value: string) => void;
searchPlaceholder?: string; searchPlaceholder?: string;
loading?: boolean; loading?: boolean;
onRefresh?: () => void; onRefresh?: () => void;
showRefresh?: boolean; showRefresh?: boolean;
showSearch?: boolean; showSearch?: boolean;
showTabs?: boolean; showTabs?: boolean;
tabsConfig?: { tabsConfig?: {
activeKey: string; activeKey: string;
onChange: (key: string) => void; onChange: (key: string) => void;
tabs: Array<{ title: string; key: string }>; tabs: Array<{ title: string; key: string }>;
}; };
} }
const PopupHeader: React.FC<PopupHeaderProps> = ({ const PopupHeader: React.FC<PopupHeaderProps> = ({
title, title,
searchQuery, searchQuery,
setSearchQuery, setSearchQuery,
searchPlaceholder = "搜索...", searchPlaceholder = "搜索...",
loading = false, loading = false,
onRefresh, onRefresh,
showRefresh = true, showRefresh = true,
showSearch = true, showSearch = true,
showTabs = false, showTabs = false,
tabsConfig, tabsConfig,
}) => { }) => {
return ( return (
<> <>
<div className={style.popupHeader}> <div className={style.popupHeader}>
<div className={style.popupTitle}>{title}</div> <div className={style.popupTitle}>{title}</div>
</div> </div>
{showSearch && ( {showSearch && (
<div className={style.popupSearchRow}> <div className={style.popupSearchRow}>
<div className={style.popupSearchInputWrap}> <div className={style.popupSearchInputWrap}>
<Input <Input
placeholder={searchPlaceholder} placeholder={searchPlaceholder}
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={e => setSearchQuery(e.target.value)}
prefix={<SearchOutlined />} prefix={<SearchOutlined />}
size="large" size="large"
/> />
</div> </div>
{showRefresh && onRefresh && ( {showRefresh && onRefresh && (
<Button <Button
type="text" type="text"
onClick={onRefresh} onClick={onRefresh}
disabled={loading} disabled={loading}
className={style.refreshBtn} className={style.refreshBtn}
> >
{loading ? ( {loading ? (
<div className={style.loadingIcon}></div> <div className={style.loadingIcon}></div>
) : ( ) : (
<ReloadOutlined /> <ReloadOutlined />
)} )}
</Button> </Button>
)} )}
</div> </div>
)} )}
{showTabs && tabsConfig && ( {showTabs && tabsConfig && (
<Tabs <Tabs
activeKey={tabsConfig.activeKey} activeKey={tabsConfig.activeKey}
onChange={tabsConfig.onChange} onChange={tabsConfig.onChange}
style={{ marginTop: 8 }} style={{ marginTop: 8 }}
> >
{tabsConfig.tabs.map((tab) => ( {tabsConfig.tabs.map(tab => (
<Tabs.Tab key={tab.key} title={tab.title} /> <Tabs.Tab key={tab.key} title={tab.title} />
))} ))}
</Tabs> </Tabs>
)} )}
</> </>
); );
}; };
export default PopupHeader; export default PopupHeader;

View File

@@ -1,67 +1,67 @@
import React, { useState } from "react"; import React, { useState } from "react";
import DeviceSelection from "./DeviceSelection"; import DeviceSelection from "./DeviceSelection";
import FriendSelection from "./FriendSelection"; import FriendSelection from "./FriendSelection";
import GroupSelection from "./GroupSelection"; import GroupSelection from "./GroupSelection";
import { Button, Space } from "antd-mobile"; import { Button, Space } from "antd-mobile";
export default function SelectionTest() { export default function SelectionTest() {
// 设备选择 // 设备选择
const [selectedDevices, setSelectedDevices] = useState<string[]>([]); const [selectedDevices, setSelectedDevices] = useState<string[]>([]);
const [deviceDialogOpen, setDeviceDialogOpen] = useState(false); const [deviceDialogOpen, setDeviceDialogOpen] = useState(false);
// 好友选择 // 好友选择
const [selectedFriends, setSelectedFriends] = useState<string[]>([]); const [selectedFriends, setSelectedFriends] = useState<string[]>([]);
const [friendDialogOpen, setFriendDialogOpen] = useState(false); const [friendDialogOpen, setFriendDialogOpen] = useState(false);
// 群组选择 // 群组选择
const [selectedGroups, setSelectedGroups] = useState<string[]>([]); const [selectedGroups, setSelectedGroups] = useState<string[]>([]);
const [groupDialogOpen, setGroupDialogOpen] = useState(false); const [groupDialogOpen, setGroupDialogOpen] = useState(false);
return ( return (
<div style={{ padding: 24 }}> <div style={{ padding: 24 }}>
<h2></h2> <h2></h2>
<Space direction="vertical" block> <Space direction="vertical" block>
<div> <div>
<b>DeviceSelection+</b> <b>DeviceSelection+</b>
<DeviceSelection <DeviceSelection
selectedDevices={selectedDevices} selectedDevices={selectedDevices}
onSelect={setSelectedDevices} onSelect={setSelectedDevices}
/> />
</div> </div>
<div> <div>
<b>FriendSelection</b> <b>FriendSelection</b>
<Button color="primary" onClick={() => setFriendDialogOpen(true)}> <Button color="primary" onClick={() => setFriendDialogOpen(true)}>
</Button> </Button>
<FriendSelection <FriendSelection
selectedFriends={selectedFriends} selectedFriends={selectedFriends}
onSelect={setSelectedFriends} onSelect={setSelectedFriends}
placeholder="请选择微信好友" placeholder="请选择微信好友"
className="" className=""
visible={friendDialogOpen} visible={friendDialogOpen}
onVisibleChange={setFriendDialogOpen} onVisibleChange={setFriendDialogOpen}
/> />
</div> </div>
<div> <div>
<b>GroupSelection</b> <b>GroupSelection</b>
<Button color="primary" onClick={() => setGroupDialogOpen(true)}> <Button color="primary" onClick={() => setGroupDialogOpen(true)}>
</Button> </Button>
<GroupSelection <GroupSelection
selectedGroups={selectedGroups} selectedGroups={selectedGroups}
onSelect={setSelectedGroups} onSelect={setSelectedGroups}
placeholder="请选择群聊" placeholder="请选择群聊"
className="" className=""
visible={groupDialogOpen} visible={groupDialogOpen}
onVisibleChange={setGroupDialogOpen} onVisibleChange={setGroupDialogOpen}
/> />
</div> </div>
</Space> </Space>
<div style={{ marginTop: 32 }}> <div style={{ marginTop: 32 }}>
<div>ID: {selectedDevices.join(", ")}</div> <div>ID: {selectedDevices.join(", ")}</div>
<div>ID: {selectedFriends.join(", ")}</div> <div>ID: {selectedFriends.join(", ")}</div>
<div>ID: {selectedGroups.join(", ")}</div> <div>ID: {selectedGroups.join(", ")}</div>
</div> </div>
</div> </div>
); );
} }

View File

@@ -1,43 +1,43 @@
import React from "react"; import React from "react";
import { Steps } from "antd-mobile"; import { Steps } from "antd-mobile";
interface StepIndicatorProps { interface StepIndicatorProps {
currentStep: number; currentStep: number;
steps: { id: number; title: string; subtitle: string }[]; steps: { id: number; title: string; subtitle: string }[];
} }
const StepIndicator: React.FC<StepIndicatorProps> = ({ const StepIndicator: React.FC<StepIndicatorProps> = ({
currentStep, currentStep,
steps, steps,
}) => { }) => {
return ( return (
<div style={{ overflowX: "auto", padding: "30px 0px", background: "#fff" }}> <div style={{ overflowX: "auto", padding: "30px 0px", background: "#fff" }}>
<Steps current={currentStep - 1}> <Steps current={currentStep - 1}>
{steps.map((step, idx) => ( {steps.map((step, idx) => (
<Steps.Step <Steps.Step
key={step.id} key={step.id}
title={step.subtitle} title={step.subtitle}
icon={ icon={
<div <div
style={{ style={{
width: 24, width: 24,
height: 24, height: 24,
borderRadius: 12, borderRadius: 12,
backgroundColor: idx < currentStep ? "#1677ff" : "#cccccc", backgroundColor: idx < currentStep ? "#1677ff" : "#cccccc",
color: "#fff", color: "#fff",
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
}} }}
> >
{step.id} {step.id}
</div> </div>
} }
/> />
))} ))}
</Steps> </Steps>
</div> </div>
); );
}; };
export default StepIndicator; export default StepIndicator;

View File

@@ -0,0 +1,112 @@
# Upload 上传组件
基于 antd-mobile 的 ImageUploader 组件封装的上传组件,支持图片上传、预览、删除等功能。
## 功能特性
- ✅ 支持单张/多张图片上传
- ✅ 文件类型和大小验证
- ✅ 上传进度显示
- ✅ 图片预览功能
- ✅ 删除确认
- ✅ 数量限制
- ✅ 编辑和新增状态支持
- ✅ 响应式设计
## 使用方法
### 基础用法
```tsx
import React, { useState } from "react";
import UploadComponent from "@/components/Upload";
const MyComponent = () => {
const [images, setImages] = useState<string[]>([]);
return (
<UploadComponent
value={images}
onChange={setImages}
count={5}
accept="image/*"
/>
);
};
```
### 编辑模式
```tsx
const EditComponent = () => {
const [images, setImages] = useState<string[]>([
"https://example.com/image1.jpg",
"https://example.com/image2.jpg",
]);
return (
<UploadComponent
value={images}
onChange={setImages}
count={9}
disabled={false}
/>
);
};
```
### 禁用状态
```tsx
<UploadComponent value={images} onChange={setImages} disabled={true} />
```
## API
### Props
| 参数 | 说明 | 类型 | 默认值 |
| --------- | -------------- | -------------------------- | ----------- |
| value | 图片URL数组 | `string[]` | `[]` |
| onChange | 图片变化回调 | `(urls: string[]) => void` | - |
| count | 最大上传数量 | `number` | `9` |
| accept | 接受的文件类型 | `string` | `"image/*"` |
| disabled | 是否禁用 | `boolean` | `false` |
| className | 自定义类名 | `string` | - |
### 事件
| 事件名 | 说明 | 回调参数 |
| -------- | ------------------ | -------------------------- |
| onChange | 图片列表变化时触发 | `(urls: string[]) => void` |
## 注意事项
1. **文件大小限制**: 默认限制为 5MB
2. **文件类型**: 默认只接受图片文件
3. **上传接口**: 使用 `/v1/attachment/upload` 接口
4. **认证**: 自动携带 token 进行认证
5. **预览**: 点击图片可预览
6. **删除**: 删除图片会有确认提示
## 样式定制
组件支持通过 CSS 模块进行样式定制:
```scss
.uploadContainer {
// 自定义样式
:global {
.adm-image-uploader {
// 覆盖 antd-mobile 默认样式
}
}
}
```
## 错误处理
- 文件类型不匹配时会显示错误提示
- 文件大小超限时会显示错误提示
- 上传失败时会显示错误提示
- 网络错误时会显示错误提示

View File

@@ -0,0 +1,145 @@
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 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[]>([]);
React.useEffect(() => {
if (value) {
const file: UploadFile = {
uid: "-1",
name: "video",
status: "done",
url: value || "", // 确保 URL 不为 undefined
};
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 true; // 允许上传
};
// 处理文件变化
const handleChange: UploadProps["onChange"] = info => {
console.log("VideoUpload handleChange info:", info);
// 更新 fileList确保所有 URL 都是字符串
const updatedFileList = info.fileList.map(file => ({
...file,
url:
file.url ||
file.response?.data ||
file.response?.url ||
file.response ||
"",
}));
setFileList(updatedFileList);
// 处理上传状态
if (info.file.status === "uploading") {
setLoading(true);
} else if (info.file.status === "done") {
setLoading(false);
message.success("上传成功");
// 从响应中获取上传后的URL
const uploadedUrl =
info.file.response?.data ||
info.file.response?.url ||
info.file.response ||
"";
if (uploadedUrl) {
// 调用onChange
onChange?.(uploadedUrl);
}
} else if (info.file.status === "error") {
setLoading(false);
message.error("上传失败,请重试");
} else if (info.file.status === "removed") {
// 文件被删除
onChange?.("");
}
};
// 删除文件
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>
);
const action = import.meta.env.VITE_API_BASE_URL + "/v1/attachment/upload";
return (
<div className={`${style["upload-container"]} ${className || ""}`}>
<Upload
name="file"
headers={{
Authorization: `Bearer ${localStorage.getItem("token")}`,
}}
action={action}
multiple={false}
fileList={fileList}
accept="video/*"
listType="text"
showUploadList={true}
disabled={disabled || loading}
beforeUpload={beforeUpload}
onChange={handleChange}
onRemove={handleRemove}
maxCount={1}
>
{fileList.length >= 1 ? null : uploadButton}
</Upload>
</div>
);
};
export default VideoUpload;

View File

@@ -0,0 +1,108 @@
.uploadContainer {
width: 100%;
// 自定义上传组件样式
:global {
.adm-image-uploader {
.adm-image-uploader-upload-button {
width: 100px;
height: 100px;
border: 1px dashed #d9d9d9;
border-radius: 8px;
background: #fafafa;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s;
&:hover {
border-color: #1677ff;
background: #f0f8ff;
}
.adm-image-uploader-upload-button-icon {
font-size: 32px;
color: #999;
}
}
.adm-image-uploader-item {
width: 100px;
height: 100px;
border-radius: 8px;
overflow: hidden;
position: relative;
.adm-image-uploader-item-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.adm-image-uploader-item-delete {
position: absolute;
top: 4px;
right: 4px;
width: 24px;
height: 24px;
background: rgba(0, 0, 0, 0.6);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 14px;
cursor: pointer;
}
.adm-image-uploader-item-loading {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(255, 255, 255, 0.8);
display: flex;
align-items: center;
justify-content: center;
}
}
}
}
}
// 禁用状态
.uploadContainer.disabled {
opacity: 0.6;
pointer-events: none;
}
// 错误状态
.uploadContainer.error {
:global {
.adm-image-uploader-upload-button {
border-color: #ff4d4f;
background: #fff2f0;
}
}
}
// 响应式设计
@media (max-width: 768px) {
.uploadContainer {
:global {
.adm-image-uploader {
.adm-image-uploader-upload-button,
.adm-image-uploader-item {
width: 80px;
height: 80px;
}
.adm-image-uploader-upload-button-icon {
font-size: 28px;
}
}
}
}
}

View File

@@ -0,0 +1,134 @@
import React, { useState, useEffect } from "react";
import { ImageUploader, Toast, Dialog } from "antd-mobile";
import type { ImageUploadItem } from "antd-mobile/es/components/image-uploader";
import style from "./index.module.scss";
interface UploadComponentProps {
value?: string[];
onChange?: (urls: string[]) => void;
count?: number; // 最大上传数量
accept?: string; // 文件类型
disabled?: boolean;
className?: string;
}
const UploadComponent: React.FC<UploadComponentProps> = ({
value = [],
onChange,
count = 9,
accept = "image/*",
disabled = false,
className,
}) => {
const [fileList, setFileList] = useState<ImageUploadItem[]>([]);
// 将value转换为fileList格式
useEffect(() => {
if (value && value.length > 0) {
const files = value.map((url, index) => ({
url: url || "",
uid: `file-${index}`,
}));
setFileList(files);
} else {
setFileList([]);
}
}, [value]);
// 文件验证
const beforeUpload = (file: File) => {
// 检查文件类型
const isValidType = file.type.startsWith(accept.replace("*", ""));
if (!isValidType) {
Toast.show(`只能上传${accept}格式的文件!`);
return null;
}
// 检查文件大小 (5MB)
const isLt5M = file.size / 1024 / 1024 < 5;
if (!isLt5M) {
Toast.show("文件大小不能超过5MB");
return null;
}
return file;
};
// 上传函数
const upload = async (file: File): Promise<{ url: string }> => {
const formData = new FormData();
formData.append("file", file);
try {
const response = await fetch(
`${import.meta.env.VITE_API_BASE_URL}/v1/attachment/upload`,
{
method: "POST",
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
body: formData,
}
);
if (!response.ok) {
throw new Error("上传失败");
}
const result = await response.json();
if (result.code === 200) {
Toast.show("上传成功");
return { url: result.data.url || result.data };
} else {
throw new Error(result.msg || "上传失败");
}
} catch (error) {
Toast.show("上传失败,请重试");
throw error;
}
};
// 处理文件变化
const handleChange = (files: ImageUploadItem[]) => {
setFileList(files);
// 提取URL数组并传递给父组件
const urls = files
.map(file => file.url)
.filter(url => Boolean(url)) as string[];
onChange?.(urls);
};
// 删除确认
const handleDelete = () => {
return Dialog.confirm({
content: "确定要删除这张图片吗?",
});
};
// 数量超出限制
const handleCountExceed = (exceed: number) => {
Toast.show(`最多选择 ${count} 张图片,你多选了 ${exceed}`);
};
return (
<div className={`${style.uploadContainer} ${className || ""}`}>
<ImageUploader
value={fileList}
onChange={handleChange}
upload={upload}
beforeUpload={beforeUpload}
onDelete={handleDelete}
onCountExceed={handleCountExceed}
multiple={count > 1}
maxCount={count}
showUpload={fileList.length < count && !disabled}
accept={accept}
/>
</div>
);
};
export default UploadComponent;

View File

@@ -1,8 +1,8 @@
import React from "react"; import React from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import App from "./App"; import App from "./App";
import "./styles/global.scss"; import "./styles/global.scss";
// import VConsole from "vconsole"; // import VConsole from "vconsole";
// new VConsole(); // new VConsole();
const root = createRoot(document.getElementById("root")!); const root = createRoot(document.getElementById("root")!);
root.render(<App />); root.render(<App />);

View File

@@ -1,53 +1,53 @@
import request from '@/api/request'; import request from "@/api/request";
export interface LoginParams { export interface LoginParams {
phone: string; phone: string;
password?: string; password?: string;
verificationCode?: string; verificationCode?: string;
} }
export interface LoginResponse { export interface LoginResponse {
code: number; code: number;
msg: string; msg: string;
data: { data: {
token: string; token: string;
token_expired: string; token_expired: string;
member: { member: {
id: string; id: string;
name: string; name: string;
phone: string; phone: string;
s2_accountId: string; s2_accountId: string;
avatar?: string; avatar?: string;
email?: string; email?: string;
}; };
}; };
} }
export interface SendCodeResponse { export interface SendCodeResponse {
code: number; code: number;
msg: string; msg: string;
} }
// 密码登录 // 密码登录
export function loginWithPassword(params:any) { export function loginWithPassword(params: any) {
return request('/v1/auth/login', params, 'POST'); return request("/v1/auth/login", params, "POST");
} }
// 验证码登录 // 验证码登录
export function loginWithCode(params:any) { export function loginWithCode(params: any) {
return request('/v1/auth/login-code', params, 'POST'); return request("/v1/auth/login-code", params, "POST");
} }
// 发送验证码 // 发送验证码
export function sendVerificationCode(params:any) { export function sendVerificationCode(params: any) {
return request('/v1/auth/code',params, 'POST'); return request("/v1/auth/code", params, "POST");
} }
// 退出登录 // 退出登录
export function logout() { export function logout() {
return request('/v1/auth/logout', {}, 'POST'); return request("/v1/auth/logout", {}, "POST");
} }
// 获取用户信息 // 获取用户信息
export function getUserInfo() { export function getUserInfo() {
return request('/v1/auth/user-info', {}, 'GET'); return request("/v1/auth/user-info", {}, "GET");
} }

View File

@@ -1,439 +1,440 @@
.login-page { .login-page {
min-height: 100vh; min-height: 100vh;
background: var(--primary-gradient); background: var(--primary-gradient);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 15px; padding: 15px;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
} }
// 背景装饰 // 背景装饰
.bg-decoration { .bg-decoration {
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
pointer-events: none; pointer-events: none;
z-index: 0; z-index: 0;
} }
.bg-circle { .bg-circle {
position: absolute; position: absolute;
border-radius: 50%; border-radius: 50%;
background: rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.1);
animation: float 6s ease-in-out infinite; animation: float 6s ease-in-out infinite;
&:nth-child(1) { &:nth-child(1) {
width: 200px; width: 200px;
height: 200px; height: 200px;
top: -100px; top: -100px;
right: -100px; right: -100px;
animation-delay: 0s; animation-delay: 0s;
} }
&:nth-child(2) { &:nth-child(2) {
width: 150px; width: 150px;
height: 150px; height: 150px;
bottom: -75px; bottom: -75px;
left: -75px; left: -75px;
animation-delay: 2s; animation-delay: 2s;
} }
&:nth-child(3) { &:nth-child(3) {
width: 100px; width: 100px;
height: 100px; height: 100px;
top: 50%; top: 50%;
right: 10%; right: 10%;
animation-delay: 4s; animation-delay: 4s;
} }
} }
@keyframes float { @keyframes float {
0%, 100% { 0%,
transform: translateY(0px) rotate(0deg); 100% {
} transform: translateY(0px) rotate(0deg);
50% { }
transform: translateY(-20px) rotate(180deg); 50% {
} transform: translateY(-20px) rotate(180deg);
} }
}
.login-container {
width: 100%; .login-container {
max-width: 420px; width: 100%;
background: #ffffff; max-width: 420px;
backdrop-filter: blur(20px); background: #ffffff;
border-radius: 24px; backdrop-filter: blur(20px);
padding: 24px 20px; border-radius: 24px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1); padding: 24px 20px;
position: relative; box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
z-index: 1; position: relative;
border: 1px solid rgba(255, 255, 255, 0.2); z-index: 1;
} border: 1px solid rgba(255, 255, 255, 0.2);
}
.login-header {
text-align: center; .login-header {
margin-bottom: 24px; text-align: center;
} margin-bottom: 24px;
}
.logo-section {
display: flex; .logo-section {
align-items: center; display: flex;
justify-content: center; align-items: center;
gap: 12px; justify-content: center;
margin-bottom: 16px; gap: 12px;
} margin-bottom: 16px;
}
.logo-icon {
width: 40px; .logo-icon {
height: 40px; width: 40px;
background: var(--primary-gradient); height: 40px;
border-radius: 10px; background: var(--primary-gradient);
display: flex; border-radius: 10px;
align-items: center; display: flex;
justify-content: center; align-items: center;
color: white; justify-content: center;
font-size: 20px; color: white;
box-shadow: 0 6px 12px var(--primary-shadow); font-size: 20px;
} box-shadow: 0 6px 12px var(--primary-shadow);
}
.app-name {
font-size: 24px; .app-name {
font-weight: 800; font-size: 24px;
background: var(--primary-gradient); font-weight: 800;
-webkit-background-clip: text; background: var(--primary-gradient);
-webkit-text-fill-color: transparent; -webkit-background-clip: text;
background-clip: text; -webkit-text-fill-color: transparent;
margin: 0; background-clip: text;
} margin: 0;
}
.subtitle {
font-size: 13px; .subtitle {
color: #666; font-size: 13px;
margin: 0; color: #666;
} margin: 0;
}
.form-container {
margin-bottom: 20px; .form-container {
} margin-bottom: 20px;
}
// 标签页样式
.tab-container { // 标签页样式
display: flex; .tab-container {
background: #f8f9fa; display: flex;
border-radius: 10px; background: #f8f9fa;
padding: 3px; border-radius: 10px;
margin-bottom: 24px; padding: 3px;
position: relative; margin-bottom: 24px;
} position: relative;
}
.tab-item {
flex: 1; .tab-item {
text-align: center; flex: 1;
padding: 10px 12px; text-align: center;
font-size: 13px; padding: 10px 12px;
font-weight: 500; font-size: 13px;
color: #666; font-weight: 500;
cursor: pointer; color: #666;
border-radius: 7px; cursor: pointer;
transition: all 0.3s ease; border-radius: 7px;
position: relative; transition: all 0.3s ease;
z-index: 2; position: relative;
z-index: 2;
&.active {
color: var(--primary-color); &.active {
font-weight: 600; color: var(--primary-color);
background: white; font-weight: 600;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1); background: white;
} box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
} }
}
.tab-indicator {
display: none; // 隐藏分割线指示器 .tab-indicator {
} display: none; // 隐藏分割线指示器
}
// 表单样式
.login-form { // 表单样式
:global(.adm-form) { .login-form {
--adm-font-size-main: 14px; :global(.adm-form) {
} --adm-font-size-main: 14px;
} }
}
.input-group {
margin-bottom: 18px; .input-group {
} margin-bottom: 18px;
}
.input-label {
display: block; .input-label {
font-size: 13px; display: block;
font-weight: 600; font-size: 13px;
color: #333; font-weight: 600;
margin-bottom: 6px; color: #333;
} margin-bottom: 6px;
}
.input-wrapper {
position: relative; .input-wrapper {
display: flex; position: relative;
align-items: center; display: flex;
background: #f8f9fa; align-items: center;
border: 2px solid transparent; background: #f8f9fa;
border-radius: 10px; border: 2px solid transparent;
transition: all 0.3s ease; border-radius: 10px;
transition: all 0.3s ease;
&:focus-within {
border-color: var(--primary-color); &:focus-within {
background: white; border-color: var(--primary-color);
box-shadow: 0 0 0 3px var(--primary-shadow-light); background: white;
} box-shadow: 0 0 0 3px var(--primary-shadow-light);
} }
}
.input-prefix {
padding: 0 12px; .input-prefix {
color: #666; padding: 0 12px;
font-size: 13px; color: #666;
font-weight: 500; font-size: 13px;
border-right: 1px solid #e5e5e5; font-weight: 500;
} border-right: 1px solid #e5e5e5;
}
.phone-input,
.password-input, .phone-input,
.code-input { .password-input,
flex: 1; .code-input {
border: none !important; flex: 1;
background: transparent !important; border: none !important;
padding: 12px 14px !important; background: transparent !important;
font-size: 15px !important; padding: 12px 14px !important;
color: #333 !important; font-size: 15px !important;
color: #333 !important;
&::placeholder {
color: #999; &::placeholder {
} color: #999;
}
&:focus {
box-shadow: none !important; &:focus {
} box-shadow: none !important;
} }
}
.eye-icon {
padding: 0 12px; .eye-icon {
color: #666; padding: 0 12px;
cursor: pointer; color: #666;
transition: color 0.3s ease; cursor: pointer;
transition: color 0.3s ease;
&:hover {
color: var(--primary-color); &:hover {
} color: var(--primary-color);
} }
}
.send-code-btn {
padding: 6px 12px; .send-code-btn {
margin-right: 6px; padding: 6px 12px;
background: var(--primary-gradient); margin-right: 6px;
color: white; background: var(--primary-gradient);
border: none; color: white;
border-radius: 6px; border: none;
font-size: 11px; border-radius: 6px;
font-weight: 500; font-size: 11px;
cursor: pointer; font-weight: 500;
transition: all 0.3s ease; cursor: pointer;
transition: all 0.3s ease;
&:hover:not(.disabled) {
transform: translateY(-1px); &:hover:not(.disabled) {
box-shadow: 0 3px 8px var(--primary-shadow); transform: translateY(-1px);
} box-shadow: 0 3px 8px var(--primary-shadow);
}
&.disabled {
background: #e5e5e5; &.disabled {
color: #999; background: #e5e5e5;
cursor: not-allowed; color: #999;
transform: none; cursor: not-allowed;
box-shadow: none; transform: none;
} box-shadow: none;
} }
}
.agreement-section {
margin-bottom: 24px; .agreement-section {
} margin-bottom: 24px;
}
.agreement-checkbox {
display: flex; .agreement-checkbox {
align-items: center; display: flex;
gap: 6px; align-items: center;
font-size: 12px; gap: 6px;
color: #666; font-size: 12px;
line-height: 1.3; color: #666;
white-space: nowrap; line-height: 1.3;
white-space: nowrap;
:global(.adm-checkbox) {
margin-top: 0; :global(.adm-checkbox) {
flex-shrink: 0; margin-top: 0;
transform: scale(0.8); flex-shrink: 0;
} transform: scale(0.8);
} }
}
.agreement-text {
flex: 1; .agreement-text {
display: flex; flex: 1;
align-items: center; display: flex;
flex-wrap: nowrap; align-items: center;
white-space: nowrap; flex-wrap: nowrap;
overflow: visible; white-space: nowrap;
text-overflow: clip; overflow: visible;
font-size: 13px; text-overflow: clip;
} font-size: 13px;
}
.agreement-link {
color: var(--primary-color); .agreement-link {
cursor: pointer; color: var(--primary-color);
text-decoration: none; cursor: pointer;
white-space: nowrap; text-decoration: none;
font-size: 11px; white-space: nowrap;
font-size: 11px;
&:hover {
text-decoration: underline; &:hover {
} text-decoration: underline;
} }
}
.login-btn {
height: 46px; .login-btn {
font-size: 15px; height: 46px;
font-weight: 600; font-size: 15px;
border-radius: 10px; font-weight: 600;
background: var(--primary-gradient); border-radius: 10px;
border: none; background: var(--primary-gradient);
box-shadow: 0 6px 12px var(--primary-shadow); border: none;
transition: all 0.3s ease; box-shadow: 0 6px 12px var(--primary-shadow);
transition: all 0.3s ease;
&:hover:not(:disabled) {
transform: translateY(-1px); &:hover:not(:disabled) {
box-shadow: 0 8px 16px var(--primary-shadow-dark); transform: translateY(-1px);
} box-shadow: 0 8px 16px var(--primary-shadow-dark);
}
&:disabled {
background: #e5e5e5; &:disabled {
color: #999; background: #e5e5e5;
transform: none; color: #999;
box-shadow: none; transform: none;
} box-shadow: none;
} }
}
.divider {
position: relative; .divider {
text-align: center; position: relative;
margin: 24px 0; text-align: center;
margin: 24px 0;
&::before {
content: ''; &::before {
position: absolute; content: "";
top: 50%; position: absolute;
left: 0; top: 50%;
right: 0; left: 0;
height: 1px; right: 0;
background: #e5e5e5; height: 1px;
} background: #e5e5e5;
}
span {
background: rgba(255, 255, 255, 0.95); span {
padding: 0 12px; background: rgba(255, 255, 255, 0.95);
color: #999; padding: 0 12px;
font-size: 11px; color: #999;
font-weight: 500; font-size: 11px;
} font-weight: 500;
} }
}
.third-party-login {
display: flex; .third-party-login {
justify-content: center; display: flex;
gap: 20px; justify-content: center;
} gap: 20px;
}
.third-party-item {
display: flex; .third-party-item {
flex-direction: column; display: flex;
align-items: center; flex-direction: column;
gap: 6px; align-items: center;
cursor: pointer; gap: 6px;
padding: 12px; cursor: pointer;
border-radius: 10px; padding: 12px;
transition: all 0.3s ease; border-radius: 10px;
transition: all 0.3s ease;
&:hover {
background: #f8f9fa; &:hover {
transform: translateY(-1px); background: #f8f9fa;
} transform: translateY(-1px);
}
span {
font-size: 11px; span {
color: #666; font-size: 11px;
font-weight: 500; color: #666;
} font-weight: 500;
} }
}
.wechat-icon,
.apple-icon { .wechat-icon,
width: 36px; .apple-icon {
height: 36px; width: 36px;
border-radius: 10px; height: 36px;
display: flex; border-radius: 10px;
align-items: center; display: flex;
justify-content: center; align-items: center;
color: white; justify-content: center;
font-size: 18px; color: white;
transition: all 0.3s ease; font-size: 18px;
} transition: all 0.3s ease;
}
.wechat-icon {
background: #07c160; .wechat-icon {
box-shadow: 0 3px 8px rgba(7, 193, 96, 0.3); 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); &:hover {
} box-shadow: 0 4px 12px rgba(7, 193, 96, 0.4);
}
svg {
width: 20px; svg {
height: 20px; width: 20px;
} height: 20px;
} }
}
.apple-icon {
background: #000; .apple-icon {
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.3); 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); &:hover {
} box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
svg {
width: 20px; svg {
height: 20px; width: 20px;
} height: 20px;
} }
}
// 响应式设计
@media (max-width: 480px) { // 响应式设计
.login-container { @media (max-width: 480px) {
padding: 24px 20px; .login-container {
margin: 0 12px; padding: 24px 20px;
} margin: 0 12px;
}
.app-name {
font-size: 22px; .app-name {
} font-size: 22px;
}
.third-party-login {
gap: 16px; .third-party-login {
} gap: 16px;
}
.third-party-item {
padding: 10px; .third-party-item {
} padding: 10px;
}
.wechat-icon,
.apple-icon { .wechat-icon,
width: 32px; .apple-icon {
height: 32px; width: 32px;
} height: 32px;
} }
}

View File

@@ -1,160 +1,160 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { NavBar, Tabs } from "antd-mobile"; import { NavBar, Tabs } from "antd-mobile";
import { ArrowLeftOutlined } from "@ant-design/icons"; import { ArrowLeftOutlined } from "@ant-design/icons";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import Layout from "@/components/Layout/Layout"; import Layout from "@/components/Layout/Layout";
import NavCommon from "@/components/NavCommon"; import NavCommon from "@/components/NavCommon";
import DeviceSelection from "@/components/DeviceSelection"; import DeviceSelection from "@/components/DeviceSelection";
import FriendSelection from "@/components/FriendSelection"; import FriendSelection from "@/components/FriendSelection";
import GroupSelection from "@/components/GroupSelection"; import GroupSelection from "@/components/GroupSelection";
import ContentLibrarySelection from "@/components/ContentLibrarySelection"; import ContentLibrarySelection from "@/components/ContentLibrarySelection";
import AccountSelection from "@/components/AccountSelection"; import AccountSelection from "@/components/AccountSelection";
const ComponentTest: React.FC = () => { const ComponentTest: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const [activeTab, setActiveTab] = useState("devices"); const [activeTab, setActiveTab] = useState("devices");
// 设备选择状态 // 设备选择状态
const [selectedDevices, setSelectedDevices] = useState<string[]>([]); const [selectedDevices, setSelectedDevices] = useState<string[]>([]);
// 好友选择状态 // 好友选择状态
const [selectedFriends, setSelectedFriends] = useState<string[]>([]); const [selectedFriends, setSelectedFriends] = useState<string[]>([]);
// 群组选择状态 // 群组选择状态
const [selectedGroups, setSelectedGroups] = useState<string[]>([]); const [selectedGroups, setSelectedGroups] = useState<string[]>([]);
// 内容库选择状态 // 内容库选择状态
const [selectedLibraries, setSelectedLibraries] = useState<string[]>([]); const [selectedLibraries, setSelectedLibraries] = useState<string[]>([]);
const [selectedAccounts, setSelectedAccounts] = useState<string[]>([]); const [selectedAccounts, setSelectedAccounts] = useState<string[]>([]);
return ( return (
<Layout header={<NavCommon title="组件调试" />}> <Layout header={<NavCommon title="组件调试" />}>
<div style={{ padding: 16 }}> <div style={{ padding: 16 }}>
<Tabs activeKey={activeTab} onChange={setActiveTab}> <Tabs activeKey={activeTab} onChange={setActiveTab}>
<Tabs.Tab title="设备选择" key="devices"> <Tabs.Tab title="设备选择" key="devices">
<div style={{ padding: "16px 0" }}> <div style={{ padding: "16px 0" }}>
<h3 style={{ marginBottom: 16 }}>DeviceSelection </h3> <h3 style={{ marginBottom: 16 }}>DeviceSelection </h3>
<DeviceSelection <DeviceSelection
selectedDevices={selectedDevices} selectedDevices={selectedDevices}
onSelect={setSelectedDevices} onSelect={setSelectedDevices}
placeholder="请选择设备" placeholder="请选择设备"
showSelectedList={true} showSelectedList={true}
selectedListMaxHeight={300} selectedListMaxHeight={300}
/> />
<div <div
style={{ style={{
marginTop: 16, marginTop: 16,
padding: 12, padding: 12,
background: "#f5f5f5", background: "#f5f5f5",
borderRadius: 8, borderRadius: 8,
}} }}
> >
<strong>:</strong> {selectedDevices.length} <strong>:</strong> {selectedDevices.length}
<br /> <br />
<strong>ID:</strong> {selectedDevices.join(", ") || "无"} <strong>ID:</strong> {selectedDevices.join(", ") || "无"}
</div> </div>
</div> </div>
</Tabs.Tab> </Tabs.Tab>
<Tabs.Tab title="好友选择" key="friends"> <Tabs.Tab title="好友选择" key="friends">
<div style={{ padding: "16px 0" }}> <div style={{ padding: "16px 0" }}>
<h3 style={{ marginBottom: 16 }}>FriendSelection </h3> <h3 style={{ marginBottom: 16 }}>FriendSelection </h3>
<FriendSelection <FriendSelection
selectedFriends={selectedFriends} selectedFriends={selectedFriends}
onSelect={setSelectedFriends} onSelect={setSelectedFriends}
placeholder="请选择微信好友" placeholder="请选择微信好友"
showSelectedList={true} showSelectedList={true}
selectedListMaxHeight={300} selectedListMaxHeight={300}
/> />
<div <div
style={{ style={{
marginTop: 16, marginTop: 16,
padding: 12, padding: 12,
background: "#f5f5f5", background: "#f5f5f5",
borderRadius: 8, borderRadius: 8,
}} }}
> >
<strong>:</strong> {selectedFriends.length} <strong>:</strong> {selectedFriends.length}
<br /> <br />
<strong>ID:</strong> {selectedFriends.join(", ") || "无"} <strong>ID:</strong> {selectedFriends.join(", ") || "无"}
</div> </div>
</div> </div>
</Tabs.Tab> </Tabs.Tab>
<Tabs.Tab title="群组选择" key="groups"> <Tabs.Tab title="群组选择" key="groups">
<div style={{ padding: "16px 0" }}> <div style={{ padding: "16px 0" }}>
<h3 style={{ marginBottom: 16 }}>GroupSelection </h3> <h3 style={{ marginBottom: 16 }}>GroupSelection </h3>
<GroupSelection <GroupSelection
selectedGroups={selectedGroups} selectedGroups={selectedGroups}
onSelect={setSelectedGroups} onSelect={setSelectedGroups}
placeholder="请选择微信群组" placeholder="请选择微信群组"
showSelectedList={true} showSelectedList={true}
selectedListMaxHeight={300} selectedListMaxHeight={300}
/> />
<div <div
style={{ style={{
marginTop: 16, marginTop: 16,
padding: 12, padding: 12,
background: "#f5f5f5", background: "#f5f5f5",
borderRadius: 8, borderRadius: 8,
}} }}
> >
<strong>:</strong> {selectedGroups.length} <strong>:</strong> {selectedGroups.length}
<br /> <br />
<strong>ID:</strong> {selectedGroups.join(", ") || "无"} <strong>ID:</strong> {selectedGroups.join(", ") || "无"}
</div> </div>
</div> </div>
</Tabs.Tab> </Tabs.Tab>
<Tabs.Tab title="内容库选择" key="libraries"> <Tabs.Tab title="内容库选择" key="libraries">
<div style={{ padding: "16px 0" }}> <div style={{ padding: "16px 0" }}>
<h3 style={{ marginBottom: 16 }}> <h3 style={{ marginBottom: 16 }}>
ContentLibrarySelection ContentLibrarySelection
</h3> </h3>
<ContentLibrarySelection <ContentLibrarySelection
selectedLibraries={selectedLibraries} selectedLibraries={selectedLibraries}
onSelect={setSelectedLibraries} onSelect={setSelectedLibraries}
placeholder="请选择内容库" placeholder="请选择内容库"
showSelectedList={true} showSelectedList={true}
selectedListMaxHeight={300} selectedListMaxHeight={300}
/> />
<div <div
style={{ style={{
marginTop: 16, marginTop: 16,
padding: 12, padding: 12,
background: "#f5f5f5", background: "#f5f5f5",
borderRadius: 8, borderRadius: 8,
}} }}
> >
<strong>:</strong> {selectedLibraries.length} <strong>:</strong> {selectedLibraries.length}
<br /> <br />
<strong>ID:</strong>{" "} <strong>ID:</strong>{" "}
{selectedLibraries.join(", ") || "无"} {selectedLibraries.join(", ") || "无"}
</div> </div>
</div> </div>
</Tabs.Tab> </Tabs.Tab>
<Tabs.Tab title="账号选择" key="accounts"> <Tabs.Tab title="账号选择" key="accounts">
<div style={{ padding: "16px 0" }}> <div style={{ padding: "16px 0" }}>
<h3 style={{ marginBottom: 16 }}>AccountSelection </h3> <h3 style={{ marginBottom: 16 }}>AccountSelection </h3>
<AccountSelection <AccountSelection
value={selectedAccounts} value={selectedAccounts}
onChange={setSelectedAccounts} onChange={setSelectedAccounts}
// 可根据实际API和props补充其它参数 // 可根据实际API和props补充其它参数
/> />
<div style={{ marginTop: 16 }}> <div style={{ marginTop: 16 }}>
<strong></strong> <strong></strong>
{selectedAccounts.length > 0 {selectedAccounts.length > 0
? selectedAccounts.join(", ") ? selectedAccounts.join(", ")
: "无"} : "无"}
</div> </div>
</div> </div>
</Tabs.Tab> </Tabs.Tab>
</Tabs> </Tabs>
</div> </div>
</Layout> </Layout>
); );
}; };
export default ComponentTest; export default ComponentTest;

View File

@@ -1,61 +1,61 @@
// 内容库表单数据类型定义 // 内容库表单数据类型定义
export interface ContentLibrary { export interface ContentLibrary {
id: string; id: string;
name: string; name: string;
sourceType: number; // 1=微信好友, 2=聊天群 sourceType: number; // 1=微信好友, 2=聊天群
creatorName?: string; creatorName?: string;
updateTime: string; updateTime: string;
status: number; // 0=未启用, 1=已启用 status: number; // 0=未启用, 1=已启用
itemCount?: number; itemCount?: number;
createTime: string; createTime: string;
sourceFriends?: string[]; sourceFriends?: string[];
sourceGroups?: string[]; sourceGroups?: string[];
keywordInclude?: string[]; keywordInclude?: string[];
keywordExclude?: string[]; keywordExclude?: string[];
aiPrompt?: string; aiPrompt?: string;
timeEnabled?: number; timeEnabled?: number;
timeStart?: string; timeStart?: string;
timeEnd?: string; timeEnd?: string;
selectedFriends?: any[]; selectedFriends?: any[];
selectedGroups?: any[]; selectedGroups?: any[];
selectedGroupMembers?: WechatGroupMember[]; selectedGroupMembers?: WechatGroupMember[];
} }
// 微信群成员 // 微信群成员
export interface WechatGroupMember { export interface WechatGroupMember {
id: string; id: string;
nickname: string; nickname: string;
wechatId: string; wechatId: string;
avatar: string; avatar: string;
gender?: "male" | "female"; gender?: "male" | "female";
role?: "owner" | "admin" | "member"; role?: "owner" | "admin" | "member";
joinTime?: string; joinTime?: string;
} }
// API 响应类型 // API 响应类型
export interface ApiResponse<T = any> { export interface ApiResponse<T = any> {
code: number; code: number;
msg: string; msg: string;
data: T; data: T;
} }
// 创建内容库参数 // 创建内容库参数
export interface CreateContentLibraryParams { export interface CreateContentLibraryParams {
name: string; name: string;
sourceType: number; sourceType: number;
sourceFriends?: string[]; sourceFriends?: string[];
sourceGroups?: string[]; sourceGroups?: string[];
keywordInclude?: string[]; keywordInclude?: string[];
keywordExclude?: string[]; keywordExclude?: string[];
aiPrompt?: string; aiPrompt?: string;
timeEnabled?: number; timeEnabled?: number;
timeStart?: string; timeStart?: string;
timeEnd?: string; timeEnd?: string;
} }
// 更新内容库参数 // 更新内容库参数
export interface UpdateContentLibraryParams export interface UpdateContentLibraryParams
extends Partial<CreateContentLibraryParams> { extends Partial<CreateContentLibraryParams> {
id: string; id: string;
status?: number; status?: number;
} }

View File

@@ -1,138 +1,140 @@
.form-page { .form-page {
background: #f7f8fa; background: #f7f8fa;
padding: 16px; padding: 16px;
} }
.form-main { .form-main {
max-width: 420px; max-width: 420px;
margin: 0 auto; margin: 0 auto;
padding: 16px 0 0 0; padding: 16px 0 0 0;
} }
.form-section { .form-section {
margin-bottom: 18px; margin-bottom: 18px;
} }
.form-card { .form-card {
border-radius: 16px; border-radius: 16px;
box-shadow: 0 4px 24px rgba(0,0,0,0.06); box-shadow: 0 4px 24px rgba(0, 0, 0, 0.06);
padding: 24px 18px 18px 18px; padding: 24px 18px 18px 18px;
background: #fff; background: #fff;
} }
.form-label { .form-label {
font-weight: 600; font-weight: 600;
font-size: 16px; font-size: 16px;
color: #222; color: #222;
display: block; display: block;
margin-bottom: 6px; margin-bottom: 6px;
} }
.section-title { .section-title {
font-size: 16px; font-size: 16px;
font-weight: 700; font-weight: 700;
color: #222; color: #222;
margin-top: 28px; margin-top: 28px;
margin-bottom: 12px; margin-bottom: 12px;
letter-spacing: 0.5px; letter-spacing: 0.5px;
} }
.section-block { .section-block {
padding: 12px 0 8px 0; padding: 12px 0 8px 0;
border-bottom: 1px solid #f0f0f0; border-bottom: 1px solid #f0f0f0;
margin-bottom: 8px; margin-bottom: 8px;
} }
.tabs-bar { .tabs-bar {
.adm-tabs-header { .adm-tabs-header {
background: #f7f8fa; background: #f7f8fa;
border-radius: 8px; border-radius: 8px;
margin-bottom: 8px; margin-bottom: 8px;
} }
.adm-tabs-tab { .adm-tabs-tab {
font-size: 15px; font-size: 15px;
font-weight: 500; font-weight: 500;
padding: 8px 0; padding: 8px 0;
} }
} }
.collapse { .collapse {
margin-top: 12px; margin-top: 12px;
.adm-collapse-panel-content { .adm-collapse-panel-content {
padding-bottom: 8px; padding-bottom: 8px;
background: #f8fafc; background: #f8fafc;
border-radius: 10px; border-radius: 10px;
padding: 18px 14px 10px 14px; padding: 18px 14px 10px 14px;
margin-top: 2px; margin-top: 2px;
box-shadow: 0 2px 8px rgba(0,0,0,0.03); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.03);
} }
.form-section { .form-section {
margin-bottom: 22px; margin-bottom: 22px;
} }
.form-label { .form-label {
font-size: 15px; font-size: 15px;
font-weight: 500; font-weight: 500;
margin-bottom: 4px; margin-bottom: 4px;
color: #333; color: #333;
} }
.adm-input { .adm-input {
min-height: 42px; min-height: 42px;
font-size: 15px; font-size: 15px;
border-radius: 7px; border-radius: 7px;
margin-bottom: 2px; margin-bottom: 2px;
} }
} }
.ai-row, .section-block { .ai-row,
display: flex; .section-block {
align-items: center; display: flex;
gap: 12px; align-items: center;
} gap: 12px;
}
.ai-desc {
color: #888; .ai-desc {
font-size: 13px; color: #888;
flex: 1; font-size: 13px;
} flex: 1;
}
.date-row, .section-block {
display: flex; .date-row,
gap: 12px; .section-block {
align-items: center; display: flex;
} gap: 12px;
align-items: center;
.adm-input { }
min-height: 44px;
font-size: 15px; .adm-input {
border-radius: 8px; min-height: 44px;
} font-size: 15px;
border-radius: 8px;
.submit-btn { }
margin-top: 32px;
height: 48px !important; .submit-btn {
border-radius: 10px !important; margin-top: 32px;
font-size: 17px; height: 48px !important;
font-weight: 600; border-radius: 10px !important;
letter-spacing: 1px; font-size: 17px;
} font-weight: 600;
letter-spacing: 1px;
@media (max-width: 600px) { }
.form-main {
max-width: 100vw; @media (max-width: 600px) {
padding: 0; .form-main {
} max-width: 100vw;
.form-card { padding: 0;
border-radius: 0; }
box-shadow: none; .form-card {
padding: 16px 6px 12px 6px; border-radius: 0;
} box-shadow: none;
.section-title { padding: 16px 6px 12px 6px;
font-size: 15px; }
margin-top: 22px; .section-title {
margin-bottom: 8px; font-size: 15px;
} margin-top: 22px;
.submit-btn { margin-bottom: 8px;
height: 44px !important; }
font-size: 15px; .submit-btn {
} height: 44px !important;
} font-size: 15px;
}
}

View File

@@ -48,7 +48,7 @@ export default function ContentForm() {
if (isEdit && id) { if (isEdit && id) {
setLoading(true); setLoading(true);
getContentLibraryDetail(id) getContentLibraryDetail(id)
.then((data) => { .then(data => {
setName(data.name || ""); setName(data.name || "");
setSourceType(data.sourceType === 1 ? "friends" : "groups"); setSourceType(data.sourceType === 1 ? "friends" : "groups");
setSelectedFriends(data.sourceFriends || []); setSelectedFriends(data.sourceFriends || []);
@@ -59,14 +59,14 @@ export default function ContentForm() {
setUseAI(!!data.aiPrompt); setUseAI(!!data.aiPrompt);
setEnabled(data.status === 1); setEnabled(data.status === 1);
// 时间范围 // 时间范围
let start = data.timeStart || data.startTime; const start = data.timeStart || data.startTime;
let end = data.timeEnd || data.endTime; const end = data.timeEnd || data.endTime;
setDateRange([ setDateRange([
start ? new Date(start) : null, start ? new Date(start) : null,
end ? new Date(end) : null, end ? new Date(end) : null,
]); ]);
}) })
.catch((e) => { .catch(e => {
Toast.show({ Toast.show({
content: e?.message || "获取详情失败", content: e?.message || "获取详情失败",
position: "top", position: "top",
@@ -92,11 +92,11 @@ export default function ContentForm() {
groupMembers: {}, groupMembers: {},
keywordInclude: keywordsInclude keywordInclude: keywordsInclude
.split(/,||\n|\s+/) .split(/,||\n|\s+/)
.map((s) => s.trim()) .map(s => s.trim())
.filter(Boolean), .filter(Boolean),
keywordExclude: keywordsExclude keywordExclude: keywordsExclude
.split(/,||\n|\s+/) .split(/,||\n|\s+/)
.map((s) => s.trim()) .map(s => s.trim())
.filter(Boolean), .filter(Boolean),
aiPrompt, aiPrompt,
timeEnabled: dateRange[0] || dateRange[1] ? 1 : 0, timeEnabled: dateRange[0] || dateRange[1] ? 1 : 0,
@@ -148,7 +148,7 @@ export default function ContentForm() {
<div className={style["form-page"]}> <div className={style["form-page"]}>
<form <form
className={style["form-main"]} className={style["form-main"]}
onSubmit={(e) => e.preventDefault()} onSubmit={e => e.preventDefault()}
autoComplete="off" autoComplete="off"
> >
<div className={style["form-section"]}> <div className={style["form-section"]}>
@@ -159,7 +159,7 @@ export default function ContentForm() {
<AntdInput <AntdInput
placeholder="请输入内容库名称" placeholder="请输入内容库名称"
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={e => setName(e.target.value)}
className={style["input"]} className={style["input"]}
/> />
</div> </div>
@@ -168,7 +168,7 @@ export default function ContentForm() {
<div className={style["form-section"]}> <div className={style["form-section"]}>
<Tabs <Tabs
activeKey={sourceType} activeKey={sourceType}
onChange={(key) => setSourceType(key as "friends" | "groups")} onChange={key => setSourceType(key as "friends" | "groups")}
className={style["tabs-bar"]} className={style["tabs-bar"]}
> >
<Tabs.Tab title="选择微信好友" key="friends"> <Tabs.Tab title="选择微信好友" key="friends">
@@ -201,7 +201,7 @@ export default function ContentForm() {
<TextArea <TextArea
placeholder="多个关键词用逗号分隔" placeholder="多个关键词用逗号分隔"
value={keywordsInclude} value={keywordsInclude}
onChange={(e) => setKeywordsInclude(e.target.value)} onChange={e => setKeywordsInclude(e.target.value)}
className={style["input"]} className={style["input"]}
autoSize={{ minRows: 2, maxRows: 4 }} autoSize={{ minRows: 2, maxRows: 4 }}
/> />
@@ -211,7 +211,7 @@ export default function ContentForm() {
<TextArea <TextArea
placeholder="多个关键词用逗号分隔" placeholder="多个关键词用逗号分隔"
value={keywordsExclude} value={keywordsExclude}
onChange={(e) => setKeywordsExclude(e.target.value)} onChange={e => setKeywordsExclude(e.target.value)}
className={style["input"]} className={style["input"]}
autoSize={{ minRows: 2, maxRows: 4 }} autoSize={{ minRows: 2, maxRows: 4 }}
/> />
@@ -235,7 +235,7 @@ export default function ContentForm() {
<AntdInput <AntdInput
placeholder="请输入AI提示词" placeholder="请输入AI提示词"
value={aiPrompt} value={aiPrompt}
onChange={(e) => setAIPrompt(e.target.value)} onChange={e => setAIPrompt(e.target.value)}
className={style["input"]} className={style["input"]}
/> />
</div> </div>
@@ -260,7 +260,7 @@ export default function ContentForm() {
title="开始时间" title="开始时间"
value={dateRange[0]} value={dateRange[0]}
onClose={() => setShowStartPicker(false)} onClose={() => setShowStartPicker(false)}
onConfirm={(val) => { onConfirm={val => {
setDateRange([val, dateRange[1]]); setDateRange([val, dateRange[1]]);
setShowStartPicker(false); setShowStartPicker(false);
}} }}
@@ -280,7 +280,7 @@ export default function ContentForm() {
title="结束时间" title="结束时间"
value={dateRange[1]} value={dateRange[1]}
onClose={() => setShowEndPicker(false)} onClose={() => setShowEndPicker(false)}
onConfirm={(val) => { onConfirm={val => {
setDateRange([dateRange[0], val]); setDateRange([dateRange[0], val]);
setShowEndPicker(false); setShowEndPicker(false);
}} }}

View File

@@ -1,66 +1,66 @@
// 内容库接口类型定义 // 内容库接口类型定义
export interface ContentLibrary { export interface ContentLibrary {
id: string; id: string;
name: string; name: string;
sourceType: number; // 1=微信好友, 2=聊天群 sourceType: number; // 1=微信好友, 2=聊天群
creatorName?: string; creatorName?: string;
updateTime: string; updateTime: string;
status: number; // 0=未启用, 1=已启用 status: number; // 0=未启用, 1=已启用
itemCount?: number; itemCount?: number;
createTime: string; createTime: string;
sourceFriends?: string[]; sourceFriends?: string[];
sourceGroups?: string[]; sourceGroups?: string[];
keywordInclude?: string[]; keywordInclude?: string[];
keywordExclude?: string[]; keywordExclude?: string[];
aiPrompt?: string; aiPrompt?: string;
timeEnabled?: number; timeEnabled?: number;
timeStart?: string; timeStart?: string;
timeEnd?: string; timeEnd?: string;
selectedFriends?: any[]; selectedFriends?: any[];
selectedGroups?: any[]; selectedGroups?: any[];
selectedGroupMembers?: WechatGroupMember[]; selectedGroupMembers?: WechatGroupMember[];
} }
// 微信群成员 // 微信群成员
export interface WechatGroupMember { export interface WechatGroupMember {
id: string; id: string;
nickname: string; nickname: string;
wechatId: string; wechatId: string;
avatar: string; avatar: string;
gender?: "male" | "female"; gender?: "male" | "female";
role?: "owner" | "admin" | "member"; role?: "owner" | "admin" | "member";
joinTime?: string; joinTime?: string;
} }
// API 响应类型 // API 响应类型
export interface ApiResponse<T = any> { export interface ApiResponse<T = any> {
code: number; code: number;
msg: string; msg: string;
data: T; data: T;
} }
export interface LibraryListResponse { export interface LibraryListResponse {
list: ContentLibrary[]; list: ContentLibrary[];
total: number; total: number;
} }
// 创建内容库参数 // 创建内容库参数
export interface CreateContentLibraryParams { export interface CreateContentLibraryParams {
name: string; name: string;
sourceType: number; sourceType: number;
sourceFriends?: string[]; sourceFriends?: string[];
sourceGroups?: string[]; sourceGroups?: string[];
keywordInclude?: string[]; keywordInclude?: string[];
keywordExclude?: string[]; keywordExclude?: string[];
aiPrompt?: string; aiPrompt?: string;
timeEnabled?: number; timeEnabled?: number;
timeStart?: string; timeStart?: string;
timeEnd?: string; timeEnd?: string;
} }
// 更新内容库参数 // 更新内容库参数
export interface UpdateContentLibraryParams export interface UpdateContentLibraryParams
extends Partial<CreateContentLibraryParams> { extends Partial<CreateContentLibraryParams> {
id: string; id: string;
status?: number; status?: number;
} }

View File

@@ -1,223 +1,217 @@
.content-library-page { .content-library-page {
padding: 16px; padding: 16px;
background: #f5f5f5; background: #f5f5f5;
min-height: 100vh; min-height: 100vh;
} }
.search-bar { .search-bar {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
margin-bottom: 16px; margin-bottom: 16px;
background: white; background: white;
padding: 12px; padding: 12px;
border-radius: 8px; border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
} }
.search-input-wrapper { .search-input-wrapper {
position: relative; position: relative;
flex: 1; flex: 1;
} }
.search-icon { .search-icon {
position: absolute; position: absolute;
left: 12px; left: 12px;
top: 50%; top: 50%;
transform: translateY(-50%); transform: translateY(-50%);
color: #999; color: #999;
z-index: 1; z-index: 1;
} }
.search-input { .search-input {
padding-left: 36px; padding-left: 36px;
border-radius: 20px; border-radius: 20px;
border: 1px solid #e0e0e0; border: 1px solid #e0e0e0;
&:focus { &:focus {
border-color: #1677ff; border-color: #1677ff;
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1); box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
} }
} }
.create-btn {
border-radius: 20px;
.create-btn { padding: 0 16px;
border-radius: 20px; }
padding: 0 16px;
} .spinning {
animation: spin 1s linear infinite;
.spinning { }
animation: spin 1s linear infinite;
} @keyframes spin {
from {
@keyframes spin { transform: rotate(0deg);
from { }
transform: rotate(0deg); to {
} transform: rotate(360deg);
to { }
transform: rotate(360deg); }
}
} .tabs {
flex: 1;
}
.tabs { .library-list {
flex:1; display: flex;
} flex-direction: column;
gap: 12px;
}
.library-list { .loading {
display: flex; display: flex;
flex-direction: column; justify-content: center;
gap: 12px; align-items: center;
} padding: 40px 0;
}
.loading {
display: flex; .empty-state {
justify-content: center; display: flex;
align-items: center; flex-direction: column;
padding: 40px 0; align-items: center;
} justify-content: center;
padding: 60px 20px;
.empty-state { text-align: center;
display: flex; background: white;
flex-direction: column; border-radius: 8px;
align-items: center; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
justify-content: center; }
padding: 60px 20px;
text-align: center; .empty-icon {
background: white; font-size: 48px;
border-radius: 8px; margin-bottom: 16px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); opacity: 0.6;
} }
.empty-icon { .empty-text {
font-size: 48px; color: #999;
margin-bottom: 16px; margin-bottom: 20px;
opacity: 0.6; font-size: 14px;
} }
.empty-text { .empty-btn {
color: #999; border-radius: 20px;
margin-bottom: 20px; padding: 0 20px;
font-size: 14px; }
}
.library-card {
.empty-btn { background: white;
border-radius: 20px; border-radius: 8px;
padding: 0 20px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
} border: none;
.library-card { &:hover {
background: white; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
border-radius: 8px; }
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); }
border: none;
.card-header {
&:hover { display: flex;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); justify-content: space-between;
} align-items: flex-start;
} margin-bottom: 12px;
}
.card-header {
display: flex; .library-info {
justify-content: space-between; display: flex;
align-items: flex-start; align-items: center;
margin-bottom: 12px; gap: 8px;
} flex: 1;
}
.library-info {
display: flex; .library-name {
align-items: center; font-size: 16px;
gap: 8px; font-weight: 600;
flex: 1; color: #333;
} margin: 0;
}
.library-name {
font-size: 16px; .status-tag {
font-weight: 600; font-size: 12px;
color: #333; padding: 2px 8px;
margin: 0; border-radius: 10px;
} }
.status-tag { .menu-btn {
font-size: 12px; background: none;
padding: 2px 8px; border: none;
border-radius: 10px; padding: 4px;
} cursor: pointer;
color: #999;
.menu-btn { border-radius: 4px;
background: none;
border: none; &:hover {
padding: 4px; background: #f5f5f5;
cursor: pointer; color: #666;
color: #999; }
border-radius: 4px; }
&:hover { .menu-dropdown {
background: #f5f5f5; position: absolute;
color: #666; right: 0;
} top: 100%;
} background: white;
border-radius: 8px;
.menu-dropdown { box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
position: absolute; z-index: 1000;
right: 0; min-width: 120px;
top: 100%; padding: 4px;
background: white; margin-top: 4px;
border-radius: 8px; }
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 1000; .menu-item {
min-width: 120px; display: flex;
padding: 4px; align-items: center;
margin-top: 4px; gap: 8px;
} padding: 8px 12px;
cursor: pointer;
.menu-item { border-radius: 4px;
display: flex; font-size: 14px;
align-items: center; color: #333;
gap: 8px; transition: background 0.2s;
padding: 8px 12px;
cursor: pointer; &:hover {
border-radius: 4px; background: #f5f5f5;
font-size: 14px; }
color: #333;
transition: background 0.2s; &.danger {
color: #ff4d4f;
&:hover {
background: #f5f5f5; &:hover {
} background: #fff2f0;
}
&.danger { }
color: #ff4d4f; }
&:hover { .card-content {
background: #fff2f0; display: flex;
} flex-direction: column;
} gap: 6px;
} }
.card-content { .info-row {
display: flex; display: flex;
flex-direction: column; align-items: center;
gap: 6px; font-size: 13px;
} }
.info-row { .label {
display: flex; color: #999;
align-items: center; min-width: 70px;
font-size: 13px; margin-right: 8px;
} }
.label { .value {
color: #999; color: #333;
min-width: 70px; flex: 1;
margin-right: 8px; }
}
.value {
color: #333;
flex: 1;
}

View File

@@ -1,317 +1,317 @@
import React, { useState, useEffect, useCallback } from "react"; import React, { useState, useEffect, useCallback } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { import {
Button, Button,
Toast, Toast,
SpinLoading, SpinLoading,
Dialog, Dialog,
Card, Card,
Avatar, Avatar,
Tag, Tag,
} from "antd-mobile"; } from "antd-mobile";
import { Input } from "antd"; import { Input } from "antd";
import { import {
PlusOutlined, PlusOutlined,
SearchOutlined, SearchOutlined,
ReloadOutlined, ReloadOutlined,
EyeOutlined, EyeOutlined,
EditOutlined, EditOutlined,
DeleteOutlined, DeleteOutlined,
MoreOutlined, MoreOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import Layout from "@/components/Layout/Layout"; import Layout from "@/components/Layout/Layout";
import NavCommon from "@/components/NavCommon"; import NavCommon from "@/components/NavCommon";
import { getContentLibraryList, deleteContentLibrary } from "./api"; import { getContentLibraryList, deleteContentLibrary } from "./api";
import { ContentLibrary } from "./data"; import { ContentLibrary } from "./data";
import style from "./index.module.scss"; import style from "./index.module.scss";
import { Tabs } from "antd-mobile"; import { Tabs } from "antd-mobile";
// 卡片菜单组件 // 卡片菜单组件
interface CardMenuProps { interface CardMenuProps {
onView: () => void; onView: () => void;
onEdit: () => void; onEdit: () => void;
onDelete: () => void; onDelete: () => void;
onViewMaterials: () => void; onViewMaterials: () => void;
} }
const CardMenu: React.FC<CardMenuProps> = ({ const CardMenu: React.FC<CardMenuProps> = ({
onView, onView,
onEdit, onEdit,
onDelete, onDelete,
onViewMaterials, onViewMaterials,
}) => { }) => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const menuRef = React.useRef<HTMLDivElement>(null); const menuRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => { React.useEffect(() => {
function handleClickOutside(event: MouseEvent) { function handleClickOutside(event: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) { if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setOpen(false); setOpen(false);
} }
} }
if (open) document.addEventListener("mousedown", handleClickOutside); if (open) document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside);
}, [open]); }, [open]);
return ( return (
<div style={{ position: "relative" }}> <div style={{ position: "relative" }}>
<button onClick={() => setOpen((v) => !v)} className={style["menu-btn"]}> <button onClick={() => setOpen(v => !v)} className={style["menu-btn"]}>
<MoreOutlined /> <MoreOutlined />
</button> </button>
{open && ( {open && (
<div ref={menuRef} className={style["menu-dropdown"]}> <div ref={menuRef} className={style["menu-dropdown"]}>
<div <div
onClick={() => { onClick={() => {
onEdit(); onEdit();
setOpen(false); setOpen(false);
}} }}
className={style["menu-item"]} className={style["menu-item"]}
> >
<EditOutlined /> <EditOutlined />
</div> </div>
<div <div
onClick={() => { onClick={() => {
onDelete(); onDelete();
setOpen(false); setOpen(false);
}} }}
className={`${style["menu-item"]} ${style["danger"]}`} className={`${style["menu-item"]} ${style["danger"]}`}
> >
<DeleteOutlined /> <DeleteOutlined />
</div> </div>
<div <div
onClick={() => { onClick={() => {
onViewMaterials(); onViewMaterials();
setOpen(false); setOpen(false);
}} }}
className={style["menu-item"]} className={style["menu-item"]}
> >
<EyeOutlined /> <EyeOutlined />
</div> </div>
</div> </div>
)} )}
</div> </div>
); );
}; };
const ContentLibraryList: React.FC = () => { const ContentLibraryList: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const [libraries, setLibraries] = useState<ContentLibrary[]>([]); const [libraries, setLibraries] = useState<ContentLibrary[]>([]);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [activeTab, setActiveTab] = useState("all"); const [activeTab, setActiveTab] = useState("all");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
// 获取内容库列表 // 获取内容库列表
const fetchLibraries = useCallback(async () => { const fetchLibraries = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
const response = await getContentLibraryList({ const response = await getContentLibraryList({
page: 1, page: 1,
limit: 100, limit: 100,
keyword: searchQuery, keyword: searchQuery,
sourceType: sourceType:
activeTab !== "all" ? (activeTab === "friends" ? 1 : 2) : undefined, activeTab !== "all" ? (activeTab === "friends" ? 1 : 2) : undefined,
}); });
setLibraries(response.list || []); setLibraries(response.list || []);
} catch (error: any) { } catch (error: any) {
console.error("获取内容库列表失败:", error); console.error("获取内容库列表失败:", error);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [searchQuery, activeTab]); }, [searchQuery, activeTab]);
useEffect(() => { useEffect(() => {
fetchLibraries(); fetchLibraries();
}, [fetchLibraries]); }, [fetchLibraries]);
const handleCreateNew = () => { const handleCreateNew = () => {
navigate("/content/new"); navigate("/content/new");
}; };
const handleEdit = (id: string) => { const handleEdit = (id: string) => {
navigate(`/content/edit/${id}`); navigate(`/content/edit/${id}`);
}; };
const handleDelete = async (id: string) => { const handleDelete = async (id: string) => {
const result = await Dialog.confirm({ const result = await Dialog.confirm({
content: "确定要删除这个内容库吗?", content: "确定要删除这个内容库吗?",
confirmText: "删除", confirmText: "删除",
cancelText: "取消", cancelText: "取消",
}); });
if (result) { if (result) {
try { try {
const response = await deleteContentLibrary(id); const response = await deleteContentLibrary(id);
if (response.code === 200) { if (response.code === 200) {
Toast.show({ Toast.show({
content: "删除成功", content: "删除成功",
position: "top", position: "top",
}); });
fetchLibraries(); fetchLibraries();
} else { } else {
Toast.show({ Toast.show({
content: response.msg || "删除失败", content: response.msg || "删除失败",
position: "top", position: "top",
}); });
} }
} catch (error: any) { } catch (error: any) {
console.error("删除内容库失败:", error); console.error("删除内容库失败:", error);
Toast.show({ Toast.show({
content: error?.message || "请检查网络连接", content: error?.message || "请检查网络连接",
position: "top", position: "top",
}); });
} }
} }
}; };
const handleViewMaterials = (id: string) => { const handleViewMaterials = (id: string) => {
navigate(`/content/materials/${id}`); navigate(`/content/materials/${id}`);
}; };
const handleSearch = () => { const handleSearch = () => {
fetchLibraries(); fetchLibraries();
}; };
const handleRefresh = () => { const handleRefresh = () => {
fetchLibraries(); fetchLibraries();
}; };
const filteredLibraries = libraries.filter( const filteredLibraries = libraries.filter(
(library) => library =>
library.name.toLowerCase().includes(searchQuery.toLowerCase()) || library.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
library.creatorName?.toLowerCase().includes(searchQuery.toLowerCase()) library.creatorName?.toLowerCase().includes(searchQuery.toLowerCase())
); );
return ( return (
<Layout <Layout
header={ header={
<> <>
<NavCommon <NavCommon
title="内容库" title="内容库"
right={ right={
<Button size="small" color="primary" onClick={handleCreateNew}> <Button size="small" color="primary" onClick={handleCreateNew}>
<PlusOutlined /> <PlusOutlined />
</Button> </Button>
} }
/> />
{/* 搜索栏 */} {/* 搜索栏 */}
<div className="search-bar"> <div className="search-bar">
<div className="search-input-wrapper"> <div className="search-input-wrapper">
<Input <Input
placeholder="搜索内容库" placeholder="搜索内容库"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={e => setSearchQuery(e.target.value)}
prefix={<SearchOutlined />} prefix={<SearchOutlined />}
allowClear allowClear
size="large" size="large"
/> />
</div> </div>
<Button <Button
size="small" size="small"
onClick={handleRefresh} onClick={handleRefresh}
loading={loading} loading={loading}
className="refresh-btn" className="refresh-btn"
> >
<ReloadOutlined /> <ReloadOutlined />
</Button> </Button>
</div> </div>
{/* 标签页 */} {/* 标签页 */}
<div className={style["tabs"]}> <div className={style["tabs"]}>
<Tabs activeKey={activeTab} onChange={setActiveTab}> <Tabs activeKey={activeTab} onChange={setActiveTab}>
<Tabs.Tab title="全部" key="all" /> <Tabs.Tab title="全部" key="all" />
<Tabs.Tab title="微信好友" key="friends" /> <Tabs.Tab title="微信好友" key="friends" />
<Tabs.Tab title="聊天群" key="groups" /> <Tabs.Tab title="聊天群" key="groups" />
</Tabs> </Tabs>
</div> </div>
</> </>
} }
> >
<div className={style["content-library-page"]}> <div className={style["content-library-page"]}>
{/* 内容库列表 */} {/* 内容库列表 */}
<div className={style["library-list"]}> <div className={style["library-list"]}>
{loading ? ( {loading ? (
<div className={style["loading"]}> <div className={style["loading"]}>
<SpinLoading color="primary" style={{ fontSize: 32 }} /> <SpinLoading color="primary" style={{ fontSize: 32 }} />
</div> </div>
) : filteredLibraries.length === 0 ? ( ) : filteredLibraries.length === 0 ? (
<div className={style["empty-state"]}> <div className={style["empty-state"]}>
<div className={style["empty-icon"]}>📚</div> <div className={style["empty-icon"]}>📚</div>
<div className={style["empty-text"]}> <div className={style["empty-text"]}>
</div> </div>
<Button <Button
color="primary" color="primary"
size="small" size="small"
onClick={handleCreateNew} onClick={handleCreateNew}
className={style["empty-btn"]} className={style["empty-btn"]}
> >
</Button> </Button>
</div> </div>
) : ( ) : (
filteredLibraries.map((library) => ( filteredLibraries.map(library => (
<Card key={library.id} className={style["library-card"]}> <Card key={library.id} className={style["library-card"]}>
<div className={style["card-header"]}> <div className={style["card-header"]}>
<div className={style["library-info"]}> <div className={style["library-info"]}>
<h3 className={style["library-name"]}>{library.name}</h3> <h3 className={style["library-name"]}>{library.name}</h3>
<Tag <Tag
color={library.status === 1 ? "success" : "default"} color={library.status === 1 ? "success" : "default"}
className={style["status-tag"]} className={style["status-tag"]}
> >
{library.status === 1 ? "已启用" : "未启用"} {library.status === 1 ? "已启用" : "未启用"}
</Tag> </Tag>
</div> </div>
<CardMenu <CardMenu
onView={() => navigate(`/content/${library.id}`)} onView={() => navigate(`/content/${library.id}`)}
onEdit={() => handleEdit(library.id)} onEdit={() => handleEdit(library.id)}
onDelete={() => handleDelete(library.id)} onDelete={() => handleDelete(library.id)}
onViewMaterials={() => handleViewMaterials(library.id)} onViewMaterials={() => handleViewMaterials(library.id)}
/> />
</div> </div>
<div className={style["card-content"]}> <div className={style["card-content"]}>
<div className={style["info-row"]}> <div className={style["info-row"]}>
<span className={style["label"]}></span> <span className={style["label"]}></span>
<span className={style["value"]}> <span className={style["value"]}>
{library.sourceType === 1 ? "微信好友" : "聊天群"} {library.sourceType === 1 ? "微信好友" : "聊天群"}
</span> </span>
</div> </div>
<div className={style["info-row"]}> <div className={style["info-row"]}>
<span className={style["label"]}></span> <span className={style["label"]}></span>
<span className={style["value"]}> <span className={style["value"]}>
{library.creatorName || "系统"} {library.creatorName || "系统"}
</span> </span>
</div> </div>
<div className={style["info-row"]}> <div className={style["info-row"]}>
<span className={style["label"]}></span> <span className={style["label"]}></span>
<span className={style["value"]}> <span className={style["value"]}>
{library.itemCount || 0} {library.itemCount || 0}
</span> </span>
</div> </div>
<div className={style["info-row"]}> <div className={style["info-row"]}>
<span className={style["label"]}></span> <span className={style["label"]}></span>
<span className={style["value"]}> <span className={style["value"]}>
{new Date(library.updateTime).toLocaleString("zh-CN", { {new Date(library.updateTime).toLocaleString("zh-CN", {
year: "numeric", year: "numeric",
month: "2-digit", month: "2-digit",
day: "2-digit", day: "2-digit",
hour: "2-digit", hour: "2-digit",
minute: "2-digit", minute: "2-digit",
})} })}
</span> </span>
</div> </div>
</div> </div>
</Card> </Card>
)) ))
)} )}
</div> </div>
</div> </div>
</Layout> </Layout>
); );
}; };
export default ContentLibraryList; export default ContentLibraryList;

View File

@@ -1,32 +1,20 @@
import request from "@/api/request"; import request from "@/api/request";
import {
ContentItem,
ContentLibrary,
CreateContentItemParams,
UpdateContentItemParams,
} from "./data";
// 获取素材详情 // 获取素材详情
export function getContentItemDetail(id: string): Promise<any> { export function getContentItemDetail(id: string) {
return request("/v1/content/item/detail", { id }, "GET"); return request("/v1/content/library/get-item-detail", { id }, "GET");
} }
// 创建素材 // 创建素材
export function createContentItem( export function createContentItem(params: any) {
params: CreateContentItemParams return request("/v1/content/library/create-item", params, "POST");
): Promise<any> {
return request("/v1/content/item/create", params, "POST");
} }
// 更新素材 // 更新素材
export function updateContentItem( export function updateContentItem(params: any) {
params: UpdateContentItemParams return request(`/v1/content/library/update-item`, params, "POST");
): Promise<any> {
const { id, ...data } = params;
return request(`/v1/content/item/update`, { id, ...data }, "POST");
} }
// 获取内容库详情 // 获取内容库详情
export function getContentLibraryDetail(id: string): Promise<any> { export function getContentLibraryDetail(id: string) {
return request("/v1/content/library/detail", { id }, "GET"); return request("/v1/content/library/detail", { id }, "GET");
} }

View File

@@ -1,85 +1,93 @@
// 素材数据类型定义 // 素材数据类型定义
export interface ContentItem { export interface ContentItem {
id: string; id: number; // 修改为number类型
libraryId: string; libraryId: number; // 修改为number类型
title: string; type?: string;
content: string; contentType: number; // 0=未知, 1=图片, 2=链接, 3=视频, 4=文本, 5=小程序, 6=图文
contentType: number; // 0=未知, 1=图片, 2=链接, 3=视频, 4=文本, 5=小程序, 6=图文 title: string;
contentTypeName?: string; content: string;
resUrls?: string[]; contentAi?: string;
urls?: string[]; contentData?: any;
comment?: string; snsId?: string | null;
sendTime?: string; msgId?: string | null;
createTime: string; wechatId?: string | null;
updateTime: string; friendId?: string | null;
wechatId?: string; createMomentTime?: number;
wechatNickname?: string; createTime: string;
wechatAvatar?: string; updateTime: string;
snsId?: string; coverImage?: string;
msgId?: string; resUrls?: string[];
type?: string; urls?: any[];
contentData?: string; location?: string | null;
createMomentTime?: string; lat?: string;
createMessageTime?: string; lng?: string;
createMomentTimeFormatted?: string; status?: number;
createMessageTimeFormatted?: string; isDel?: number;
} delTime?: number;
wechatChatroomId?: string | null;
// 内容库类型 senderNickname?: string;
export interface ContentLibrary { createMessageTime?: string | null;
id: string; comment?: string;
name: string; sendTime?: string; // 字符串格式的时间
sourceType: number; // 1=微信好友, 2=聊天群 sendTimes?: number;
creatorName?: string; contentTypeName?: string;
updateTime: string; }
status: number; // 0=未启用, 1=已启用
itemCount?: number; // 内容库类型
createTime: string; export interface ContentLibrary {
sourceFriends?: string[]; id: string;
sourceGroups?: string[]; name: string;
keywordInclude?: string[]; sourceType: number; // 1=微信好友, 2=聊天群
keywordExclude?: string[]; creatorName?: string;
aiPrompt?: string; updateTime: string;
timeEnabled?: number; status: number; // 0=未启用, 1=已启用
timeStart?: string; itemCount?: number;
timeEnd?: string; createTime: string;
selectedFriends?: any[]; sourceFriends?: string[];
selectedGroups?: any[]; sourceGroups?: string[];
selectedGroupMembers?: WechatGroupMember[]; keywordInclude?: string[];
} keywordExclude?: string[];
aiPrompt?: string;
// 微信群成员 timeEnabled?: number;
export interface WechatGroupMember { timeStart?: string;
id: string; timeEnd?: string;
nickname: string; selectedFriends?: any[];
wechatId: string; selectedGroups?: any[];
avatar: string; selectedGroupMembers?: WechatGroupMember[];
gender?: "male" | "female"; }
role?: "owner" | "admin" | "member";
joinTime?: string; // 微信群成员
} export interface WechatGroupMember {
id: string;
// API 响应类型 nickname: string;
export interface ApiResponse<T = any> { wechatId: string;
code: number; avatar: string;
msg: string; gender?: "male" | "female";
data: T; role?: "owner" | "admin" | "member";
} joinTime?: string;
}
// 创建素材参数
export interface CreateContentItemParams { // API 响应类型
libraryId: string; export interface ApiResponse<T = any> {
title: string; code: number;
content: string; msg: string;
contentType: number; data: T;
resUrls?: string[]; }
urls?: string[];
comment?: string; // 创建素材参数
sendTime?: string; export interface CreateContentItemParams {
} libraryId: string;
title: string;
// 更新素材参数 content: string;
export interface UpdateContentItemParams contentType: number;
extends Partial<CreateContentItemParams> { resUrls?: string[];
id: string; urls?: string[];
} comment?: string;
sendTime?: string;
}
// 更新素材参数
export interface UpdateContentItemParams
extends Partial<CreateContentItemParams> {
id: string;
}

View File

@@ -1,125 +1,160 @@
.form-page { .form-page {
padding: 16px; padding: 16px;
background: #f5f5f5; background: #f5f5f5;
min-height: 100vh; min-height: 100vh;
} }
.loading { .loading {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
padding: 40px 0; padding: 40px 0;
} }
.form { .form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 16px;
} }
.form-card { .form-card {
background: white; background: white;
border-radius: 8px; border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
border: none; border: none;
padding: 16px; padding: 16px;
} }
.card-title { .card-title {
font-size: 16px; font-size: 16px;
font-weight: 600; font-weight: 600;
color: #333; color: #333;
margin-bottom: 16px; margin-bottom: 16px;
padding-bottom: 8px; padding-bottom: 8px;
border-bottom: 1px solid #f0f0f0; border-bottom: 1px solid #f0f0f0;
} }
.textarea { .form-item {
border-radius: 6px; margin-bottom: 16px;
border: 1px solid #d9d9d9; }
&:focus { .form-label {
border-color: #1677ff; display: block;
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1); font-size: 14px;
} font-weight: 500;
} color: #333;
margin-bottom: 8px;
.time-picker {
width: 100%; .required {
border-radius: 6px; color: #ff4d4f;
margin-right: 4px;
&:focus { }
border-color: #1677ff; }
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
} .form-input {
} width: 100%;
height: 40px;
.select-option { border-radius: 6px;
display: flex; border: 1px solid #d9d9d9;
align-items: center; padding: 0 12px;
gap: 8px; font-size: 14px;
.anticon { &:focus {
font-size: 16px; border-color: #1677ff;
color: #1677ff; box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
} }
} }
.form-actions { .form-select {
display: flex; width: 100%;
gap: 12px; border-radius: 6px;
padding: 16px;
background: white; &:focus {
border-radius: 8px; border-color: #1677ff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
margin-top: 16px; }
} }
.back-btn { .form-textarea {
flex: 1; width: 100%;
border-radius: 6px; border-radius: 6px;
border: 1px solid #d9d9d9; border: 1px solid #d9d9d9;
padding: 8px 12px;
&:hover { font-size: 14px;
border-color: #1677ff; resize: vertical;
color: #1677ff;
} &:focus {
} border-color: #1677ff;
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
.submit-btn { }
flex: 1; }
border-radius: 6px;
} .select-option {
display: flex;
// 覆盖 antd-mobile 的默认样式 align-items: center;
:global { gap: 8px;
.adm-form-item {
margin-bottom: 16px; .anticon {
} font-size: 16px;
color: #1677ff;
.adm-form-item-label { }
font-size: 14px; }
color: #333;
font-weight: 500; .form-actions {
} display: flex;
gap: 12px;
.adm-input { padding: 16px;
border-radius: 6px; background: white;
border: 1px solid #d9d9d9; border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
&:focus { margin-top: 16px;
border-color: #1677ff; }
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
} .back-btn {
} flex: 1;
border-radius: 6px;
.adm-select { border: 1px solid #d9d9d9;
border-radius: 6px;
border: 1px solid #d9d9d9; &:hover {
border-color: #1677ff;
&:focus { color: #1677ff;
border-color: #1677ff; }
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1); }
}
} .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,403 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect, useCallback } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { Button, Toast, SpinLoading, Form, Card } from "antd-mobile"; import { Button, Toast, SpinLoading, Card } from "antd-mobile";
import { Input, TimePicker, Select, Upload } from "antd"; import { Input, Select } from "antd";
import { import {
ArrowLeftOutlined, ArrowLeftOutlined,
SaveOutlined, SaveOutlined,
UploadOutlined, PictureOutlined,
PictureOutlined, LinkOutlined,
LinkOutlined, VideoCameraOutlined,
VideoCameraOutlined, FileTextOutlined,
FileTextOutlined, AppstoreOutlined,
AppstoreOutlined, } from "@ant-design/icons";
} from "@ant-design/icons"; import Layout from "@/components/Layout/Layout";
import Layout from "@/components/Layout/Layout"; import NavCommon from "@/components/NavCommon";
import NavCommon from "@/components/NavCommon"; import UploadComponent from "@/components/Upload";
import { import VideoUpload from "@/components/Upload/VideoUpload";
getContentItemDetail, import {
createContentItem, getContentItemDetail,
updateContentItem, createContentItem,
getContentLibraryDetail, updateContentItem,
} from "./api"; } from "./api";
import { ContentItem, ContentLibrary } from "./data"; import style from "./index.module.scss";
import style from "./index.module.scss";
const { Option } = Select;
const { Option } = Select; const { TextArea } = Input;
const { TextArea } = Input;
// 内容类型选项
// 内容类型选项 const contentTypeOptions = [
const contentTypeOptions = [ { value: 1, label: "图片", icon: <PictureOutlined /> },
{ value: 1, label: "图片", icon: <PictureOutlined /> }, { value: 2, label: "链接", icon: <LinkOutlined /> },
{ value: 2, label: "链接", icon: <LinkOutlined /> }, { value: 3, label: "视频", icon: <VideoCameraOutlined /> },
{ value: 3, label: "视频", icon: <VideoCameraOutlined /> }, { value: 4, label: "文本", icon: <FileTextOutlined /> },
{ value: 4, label: "文本", icon: <FileTextOutlined /> }, { value: 5, label: "小程序", icon: <AppstoreOutlined /> },
{ value: 5, label: "小程序", icon: <AppstoreOutlined /> }, ];
{ value: 6, label: "图文", icon: <PictureOutlined /> },
]; const MaterialForm: React.FC = () => {
const navigate = useNavigate();
const MaterialForm: React.FC = () => { const { id: libraryId, materialId } = useParams<{
const navigate = useNavigate(); id: string;
const { id: libraryId, materialId } = useParams<{ materialId: string;
id: string; }>();
materialId: string; const [loading, setLoading] = useState(false);
}>(); const [saving, setSaving] = useState(false);
const [form] = Form.useForm();
const [loading, setLoading] = useState(false); // 表单状态
const [saving, setSaving] = useState(false); const [contentType, setContentType] = useState<number>(4);
const [material, setMaterial] = useState<ContentItem | null>(null); const [title, setTitle] = useState("");
const [library, setLibrary] = useState<ContentLibrary | null>(null); const [content, setContent] = useState("");
const [contentType, setContentType] = useState<number>(4); const [comment, setComment] = useState("");
const [sendTime, setSendTime] = useState("");
const isEdit = !!materialId; const [resUrls, setResUrls] = useState<string[]>([]);
// 获取内容库详情 // 链接相关状态
useEffect(() => { const [linkDesc, setLinkDesc] = useState("");
if (libraryId) { const [linkImage, setLinkImage] = useState("");
fetchLibraryDetail(); const [linkUrl, setLinkUrl] = useState("");
}
}, [libraryId]); // 小程序相关状态
const [appTitle, setAppTitle] = useState("");
// 获取素材详情 const [appId, setAppId] = useState("");
useEffect(() => {
if (isEdit && materialId) { const isEdit = !!materialId;
fetchMaterialDetail();
} // 获取素材详情
}, [isEdit, materialId]); const fetchMaterialDetail = useCallback(async () => {
if (!materialId) return;
const fetchLibraryDetail = async () => { setLoading(true);
if (!libraryId) return; try {
try { const response = await getContentItemDetail(materialId);
const response = await getContentLibraryDetail(libraryId); // 填充表单数据
if (response.code === 200 && response.data) { setTitle(response.title || "");
setLibrary(response.data); setContent(response.content || "");
} setContentType(response.contentType || 4);
} catch (error) { setComment(response.comment || "");
console.error("获取内容库详情失败:", error);
} // 处理时间格式 - sendTime是字符串格式需要转换为datetime-local格式
}; if (response.sendTime) {
// 将 "2025-07-28 16:11:00" 转换为 "2025-07-28T16:11"
const fetchMaterialDetail = async () => { const dateTime = new Date(response.sendTime);
if (!materialId) return; setSendTime(dateTime.toISOString().slice(0, 16));
setLoading(true); } else {
try { setSendTime("");
const response = await getContentItemDetail(materialId); }
if (response.code === 200 && response.data) {
setMaterial(response.data); setResUrls(response.resUrls || []);
setContentType(response.data.contentType);
// 设置链接相关数据
// 填充表单数据 if (response.urls && response.urls.length > 0) {
form.setFieldsValue({ const firstUrl = response.urls[0];
title: response.data.title, if (typeof firstUrl === "object" && firstUrl !== null) {
content: response.data.content, setLinkDesc(firstUrl.desc || "");
contentType: response.data.contentType, setLinkImage(firstUrl.image || "");
comment: response.data.comment || "", setLinkUrl(firstUrl.url || "");
sendTime: response.data.sendTime || "", }
resUrls: response.data.resUrls || [], }
urls: response.data.urls || [], } catch (error: unknown) {
}); console.error("获取素材详情失败:", error);
} else { } finally {
Toast.show({ setLoading(false);
content: response.msg || "获取素材详情失败", }
position: "top", }, [materialId]);
});
} useEffect(() => {
} catch (error: any) { if (isEdit && materialId) {
console.error("获取素材详情失败:", error); fetchMaterialDetail();
Toast.show({ }
content: error?.message || "请检查网络连接", }, [isEdit, materialId, fetchMaterialDetail]);
position: "top",
}); const handleSubmit = async () => {
} finally { if (!libraryId) return;
setLoading(false);
} if (!content.trim()) {
}; Toast.show({
content: "请输入素材内容",
const handleSubmit = async (values: any) => { position: "top",
if (!libraryId) return; });
return;
setSaving(true); }
try {
const params = { setSaving(true);
libraryId, try {
title: values.title, // 构建urls数据
content: values.content, let finalUrls: { desc: string; image: string; url: string }[] = [];
contentType: values.contentType, if (contentType === 2 && linkUrl) {
comment: values.comment || "", finalUrls = [
sendTime: values.sendTime || "", {
resUrls: values.resUrls || [], desc: linkDesc,
urls: values.urls || [], image: linkImage,
}; url: linkUrl,
},
let response; ];
if (isEdit) { }
response = await updateContentItem({
id: materialId!, const params = {
...params, libraryId,
}); title,
} else { content,
response = await createContentItem(params); contentType,
} comment,
sendTime: sendTime || "",
if (response.code === 200) { resUrls,
Toast.show({ urls: finalUrls,
content: isEdit ? "更新成功" : "创建成功", type: contentType,
position: "top", };
});
navigate(`/content/materials/${libraryId}`); if (isEdit) {
} else { await updateContentItem({
Toast.show({ id: materialId!,
content: response.msg || (isEdit ? "更新失败" : "创建失败"), ...params,
position: "top", });
}); } else {
} await createContentItem(params);
} catch (error: any) { }
console.error("保存素材失败:", error);
Toast.show({ // 直接使用返回数据无需判断code
content: error?.message || "请检查网络连接", Toast.show({
position: "top", content: isEdit ? "更新成功" : "创建成功",
}); position: "top",
} finally { });
setSaving(false); navigate(`/content/materials/${libraryId}`);
} } catch (error: unknown) {
}; console.error("保存素材失败:", error);
Toast.show({
const handleBack = () => { content: error instanceof Error ? error.message : "请检查网络连接",
navigate(`/content/materials/${libraryId}`); position: "top",
}; });
} finally {
if (loading) { setSaving(false);
return ( }
<Layout header={<NavCommon title={isEdit ? "编辑素材" : "新建素材"} />}> };
<div className={style["loading"]}>
<SpinLoading color="primary" style={{ fontSize: 32 }} /> const handleBack = () => {
</div> navigate(`/content/materials/${libraryId}`);
</Layout> };
);
} if (loading) {
return (
return ( <Layout header={<NavCommon title={isEdit ? "编辑素材" : "新建素材"} />}>
<Layout header={<NavCommon title={isEdit ? "编辑素材" : "新建素材"} />}> <div className={style["loading"]}>
<div className={style["form-page"]}> <SpinLoading color="primary" style={{ fontSize: 32 }} />
<Form </div>
form={form} </Layout>
layout="vertical" );
onFinish={handleSubmit} }
className={style["form"]}
initialValues={{ return (
contentType: 4, <Layout
resUrls: [], header={<NavCommon title={isEdit ? "编辑素材" : "新建素材"} />}
urls: [], footer={
}} <div className={style["form-actions"]}>
> <Button
{/* 基本信息 */} fill="outline"
<Card className={style["form-card"]}> onClick={handleBack}
<div className={style["card-title"]}></div> className={style["back-btn"]}
>
<Form.Item <ArrowLeftOutlined />
name="title"
label="素材标题" </Button>
rules={[{ required: true, message: "请输入素材标题" }]} <Button
> color="primary"
<Input placeholder="请输入素材标题" /> onClick={handleSubmit}
</Form.Item> loading={saving}
className={style["submit-btn"]}
<Form.Item >
name="contentType" <SaveOutlined />
label="内容类型" {isEdit ? " 保存修改" : " 保存素材"}
rules={[{ required: true, message: "请选择内容类型" }]} </Button>
> </div>
<Select }
placeholder="请选择内容类型" >
onChange={(value) => setContentType(value)} <div className={style["form-page"]}>
> <div className={style["form"]}>
{contentTypeOptions.map((option) => ( {/* 基础信息 */}
<Option key={option.value} value={option.value}> <Card className={style["form-card"]}>
<div className={style["select-option"]}> <div className={style["card-title"]}></div>
{option.icon}
<span>{option.label}</span> <div className={style["form-item"]}>
</div> <label className={style["form-label"]}></label>
</Option> <Input
))} type="datetime-local"
</Select> value={sendTime}
</Form.Item> onChange={e => setSendTime(e.target.value)}
placeholder="请选择发布时间"
<Form.Item className={style["form-input"]}
name="content" />
label="内容" </div>
rules={[{ required: true, message: "请输入内容" }]}
> <div className={style["form-item"]}>
<TextArea <label className={style["form-label"]}>
placeholder="请输入内容" <span className={style["required"]}>*</span>
rows={6} </label>
className={style["textarea"]} <Select
/> value={contentType}
</Form.Item> onChange={value => setContentType(value)}
</Card> placeholder="请选择类型"
className={style["form-select"]}
{/* 资源设置 */} >
<Card className={style["form-card"]}> {contentTypeOptions.map(option => (
<div className={style["card-title"]}></div> <Option key={option.value} value={option.value}>
<div className={style["select-option"]}>
<Form.Item {option.icon}
name="resUrls" <span>{option.label}</span>
label="资源链接" </div>
extra="图片、视频等资源链接,多个用换行分隔" </Option>
> ))}
<TextArea </Select>
placeholder="请输入资源链接,多个用换行分隔" </div>
rows={4} </Card>
className={style["textarea"]}
/> {/* 内容信息 */}
</Form.Item> <Card className={style["form-card"]}>
<div className={style["card-title"]}></div>
<Form.Item
name="urls" <div className={style["form-item"]}>
label="外部链接" <label className={style["form-label"]}>
extra="外部网页链接,多个用换行分隔" <span className={style["required"]}>*</span>
> </label>
<TextArea <TextArea
placeholder="请输入外部链接,多个用换行分隔" value={content}
rows={4} onChange={e => setContent(e.target.value)}
className={style["textarea"]} placeholder="请输入内容"
/> rows={6}
</Form.Item> className={style["form-textarea"]}
</Card> />
</div>
{/* 其他设置 */}
<Card className={style["form-card"]}> {/* 链接类型特有字段 */}
<div className={style["card-title"]}></div> {contentType === 2 && (
<>
<Form.Item name="comment" label="备注" extra="素材备注信息"> <div className={style["form-item"]}>
<TextArea <label className={style["form-label"]}>
placeholder="请输入备注信息" <span className={style["required"]}>*</span>
rows={3} </label>
className={style["textarea"]} <Input
/> value={linkDesc}
</Form.Item> onChange={e => setLinkDesc(e.target.value)}
placeholder="请输入描述"
<Form.Item className={style["form-input"]}
name="sendTime" />
label="发送时间" </div>
extra="计划发送时间(可选)"
> <div className={style["form-item"]}>
<TimePicker <label className={style["form-label"]}></label>
format="YYYY-MM-DD HH:mm:ss" <UploadComponent
placeholder="选择发送时间" value={linkImage ? [linkImage] : []}
className={style["time-picker"]} onChange={urls => setLinkImage(urls[0] || "")}
/> count={1}
</Form.Item> />
</Card> </div>
{/* 操作按钮 */} <div className={style["form-item"]}>
<div className={style["form-actions"]}> <label className={style["form-label"]}>
<Button <span className={style["required"]}>*</span>
fill="outline" </label>
onClick={handleBack} <Input
className={style["back-btn"]} value={linkUrl}
> onChange={e => setLinkUrl(e.target.value)}
<ArrowLeftOutlined /> placeholder="请输入链接地址"
className={style["form-input"]}
</Button> />
<Button </div>
color="primary" </>
type="submit" )}
loading={saving}
className={style["submit-btn"]} {/* 视频类型特有字段 */}
> {contentType === 3 && (
<SaveOutlined /> <div className={style["form-item"]}>
{isEdit ? "更新" : "创建"} <label className={style["form-label"]}></label>
</Button> <VideoUpload
</div> value={resUrls[0] || ""}
</Form> onChange={url => setResUrls([url])}
</div> />
</Layout> </div>
); )}
}; </Card>
export default MaterialForm; {/* 素材上传(仅图片类型和小程序类型) */}
{[1, 5].includes(contentType) && (
<Card className={style["form-card"]}>
<div className={style["card-title"]}>
(: {contentType})
</div>
{contentType === 1 && (
<div className={style["form-item"]}>
<label className={style["form-label"]}></label>
<div>
<UploadComponent
value={resUrls}
onChange={setResUrls}
count={9}
/>
</div>
<div
style={{
fontSize: "12px",
color: "#666",
marginTop: "4px",
}}
>
: {contentType}, : {resUrls.length}
</div>
</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}
/>
</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>
</div>
</Layout>
);
};
export default MaterialForm;

View File

@@ -1,45 +1,37 @@
import request from "@/api/request"; import request from "@/api/request";
import { import {
ContentItem,
ContentLibrary,
GetContentItemListParams, GetContentItemListParams,
CreateContentItemParams, CreateContentItemParams,
UpdateContentItemParams, UpdateContentItemParams,
} from "./data"; } from "./data";
// 获取素材列表 // 获取素材列表
export function getContentItemList( export function getContentItemList(params: GetContentItemListParams) {
params: GetContentItemListParams return request("/v1/content/library/item-list", params, "GET");
): Promise<any> {
return request("/v1/content/item/list", params, "GET");
} }
// 获取素材详情 // 获取素材详情
export function getContentItemDetail(id: string): Promise<any> { export function getContentItemDetail(id: string) {
return request("/v1/content/item/detail", { id }, "GET"); return request("/v1/content/item/detail", { id }, "GET");
} }
// 创建素材 // 创建素材
export function createContentItem( export function createContentItem(params: CreateContentItemParams) {
params: CreateContentItemParams
): Promise<any> {
return request("/v1/content/item/create", params, "POST"); return request("/v1/content/item/create", params, "POST");
} }
// 更新素材 // 更新素材
export function updateContentItem( export function updateContentItem(params: UpdateContentItemParams) {
params: UpdateContentItemParams
): Promise<any> {
const { id, ...data } = params; const { id, ...data } = params;
return request(`/v1/content/item/update`, { id, ...data }, "POST"); 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"); return request("/v1/content/library/delete-item", { id }, "DELETE");
} }
// 获取内容库详情 // 获取内容库详情
export function getContentLibraryDetail(id: string): Promise<any> { export function getContentLibraryDetail(id: string) {
return request("/v1/content/library/detail", { id }, "GET"); return request("/v1/content/library/detail", { id }, "GET");
} }

View File

@@ -1,98 +1,106 @@
// 素材数据类型定义 // 素材数据类型定义
export interface ContentItem { export interface ContentItem {
id: string; id: number;
libraryId: string; libraryId: number;
title: string; type: string;
content: string; contentType: number; // 0=未知, 1=图片, 2=链接, 3=视频, 4=文本, 5=小程序, 6=图文
contentType: number; // 0=未知, 1=图片, 2=链接, 3=视频, 4=文本, 5=小程序, 6=图文 title: string;
contentTypeName?: string; content: string;
resUrls?: string[]; contentAi?: string | null;
urls?: string[]; contentData?: string | null;
comment?: string; snsId?: string | null;
sendTime?: string; msgId?: string | null;
createTime: string; wechatId?: string | null;
updateTime: string; friendId?: string | null;
wechatId?: string; createMomentTime: number;
wechatNickname?: string; createTime: string;
wechatAvatar?: string; updateTime: string;
snsId?: string; coverImage: string;
msgId?: string; resUrls: string[];
type?: string; urls: { desc: string; image: string; url: string }[];
contentData?: string; location?: string | null;
createMomentTime?: string; lat: string;
createMessageTime?: string; lng: string;
createMomentTimeFormatted?: string; status: number;
createMessageTimeFormatted?: string; isDel: number;
} delTime: number;
wechatChatroomId?: string | null;
// 内容库类型 senderNickname: string;
export interface ContentLibrary { createMessageTime?: string | null;
id: string; comment: string;
name: string; sendTime: number;
sourceType: number; // 1=微信好友, 2=聊天群 sendTimes: number;
creatorName?: string; contentTypeName: string;
updateTime: string; }
status: number; // 0=未启用, 1=已启用
itemCount?: number; // 内容库类型
createTime: string; export interface ContentLibrary {
sourceFriends?: string[]; id: string;
sourceGroups?: string[]; name: string;
keywordInclude?: string[]; sourceType: number; // 1=微信好友, 2=聊天群
keywordExclude?: string[]; creatorName?: string;
aiPrompt?: string; updateTime: string;
timeEnabled?: number; status: number; // 0=未启用, 1=已启用
timeStart?: string; itemCount?: number;
timeEnd?: string; createTime: string;
selectedFriends?: any[]; sourceFriends?: string[];
selectedGroups?: any[]; sourceGroups?: string[];
selectedGroupMembers?: WechatGroupMember[]; keywordInclude?: string[];
} keywordExclude?: string[];
aiPrompt?: string;
// 微信群成员 timeEnabled?: number;
export interface WechatGroupMember { timeStart?: string;
id: string; timeEnd?: string;
nickname: string; selectedFriends?: any[];
wechatId: string; selectedGroups?: any[];
avatar: string; selectedGroupMembers?: WechatGroupMember[];
gender?: "male" | "female"; }
role?: "owner" | "admin" | "member";
joinTime?: string; // 微信群成员
} export interface WechatGroupMember {
id: string;
// API 响应类型 nickname: string;
export interface ApiResponse<T = any> { wechatId: string;
code: number; avatar: string;
msg: string; gender?: "male" | "female";
data: T; role?: "owner" | "admin" | "member";
} joinTime?: string;
}
export interface ItemListResponse {
list: ContentItem[]; // API 响应类型
total: number; export interface ApiResponse<T = any> {
} code: number;
msg: string;
// 获取素材列表参数 data: T;
export interface GetContentItemListParams { }
libraryId: string;
page?: number; export interface ItemListResponse {
limit?: number; list: ContentItem[];
keyword?: string; total: number;
} }
// 创建素材参数 // 获取素材列表参数
export interface CreateContentItemParams { export interface GetContentItemListParams {
libraryId: string; libraryId: string;
title: string; page?: number;
content: string; limit?: number;
contentType: number; keyword?: string;
resUrls?: string[]; }
urls?: string[];
comment?: string; // 创建素材参数
sendTime?: string; export interface CreateContentItemParams {
} libraryId: string;
title: string;
// 更新素材参数 content: string;
export interface UpdateContentItemParams contentType: number;
extends Partial<CreateContentItemParams> { resUrls?: string[];
id: 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,615 @@
.materials-page { .materials-page {
padding: 16px; padding: 16px;
background: #f5f5f5; }
min-height: 100vh;
} .search-bar {
display: flex;
.search-bar { align-items: center;
display: flex; gap: 8px;
align-items: center; margin-bottom: 16px;
gap: 8px; background: white;
margin-bottom: 16px; padding: 12px;
background: white; border-radius: 8px;
padding: 12px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
border-radius: 8px; }
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
} .search-input-wrapper {
position: relative;
.search-input-wrapper { flex: 1;
position: relative; }
flex: 1;
} .search-icon {
position: absolute;
.search-icon { left: 12px;
position: absolute; top: 50%;
left: 12px; transform: translateY(-50%);
top: 50%; color: #999;
transform: translateY(-50%); z-index: 1;
color: #999; }
z-index: 1;
} .search-input {
padding-left: 36px;
.search-input { border-radius: 20px;
padding-left: 36px; border: 1px solid #e0e0e0;
border-radius: 20px;
border: 1px solid #e0e0e0; &:focus {
border-color: #1677ff;
&:focus { box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
border-color: #1677ff; }
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1); }
}
} .create-btn {
border-radius: 20px;
padding: 0 16px;
.create-btn { }
border-radius: 20px;
padding: 0 16px; .spinning {
} animation: spin 1s linear infinite;
}
.spinning {
animation: spin 1s linear infinite; @keyframes spin {
} from {
transform: rotate(0deg);
@keyframes spin { }
from { to {
transform: rotate(0deg); transform: rotate(360deg);
} }
to { }
transform: rotate(360deg);
} .materials-list {
} display: flex;
flex-direction: column;
.materials-list { gap: 12px;
display: flex; }
flex-direction: column;
gap: 12px; .loading {
} display: flex;
justify-content: center;
.loading { align-items: center;
display: flex; padding: 40px 0;
justify-content: center; }
align-items: center;
padding: 40px 0; .empty-state {
} display: flex;
flex-direction: column;
.empty-state { align-items: center;
display: flex; justify-content: center;
flex-direction: column; padding: 60px 20px;
align-items: center; text-align: center;
justify-content: center; background: white;
padding: 60px 20px; border-radius: 8px;
text-align: center; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
background: white; }
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); .empty-icon {
} font-size: 48px;
margin-bottom: 16px;
.empty-icon { opacity: 0.6;
font-size: 48px; }
margin-bottom: 16px;
opacity: 0.6; .empty-text {
} color: #999;
margin-bottom: 20px;
.empty-text { font-size: 14px;
color: #999; }
margin-bottom: 20px;
font-size: 14px; .empty-btn {
} border-radius: 20px;
padding: 0 20px;
.empty-btn { }
border-radius: 20px;
padding: 0 20px; .material-card {
} background: white;
border-radius: 8px;
.material-card { box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
background: white; border: none;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); &:hover {
border: none; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
&:hover { }
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
} .card-header {
} display: flex;
justify-content: space-between;
.card-header { align-items: flex-start;
display: flex; margin-bottom: 16px;
justify-content: space-between; }
align-items: flex-start;
margin-bottom: 12px; .avatar-section {
} display: flex;
align-items: center;
.material-info { gap: 12px;
display: flex; flex: 1;
align-items: center; }
gap: 8px;
flex: 1; .avatar {
} width: 48px;
height: 48px;
.material-title { border-radius: 50%;
display: flex; background: #e6f7ff;
align-items: center; display: flex;
gap: 6px; align-items: center;
font-size: 16px; justify-content: center;
font-weight: 600; }
color: #333;
} .avatar-icon {
font-size: 24px;
.content-icon { color: #1677ff;
font-size: 16px; }
color: #1677ff;
} .header-info {
display: flex;
.type-tag { flex-direction: column;
font-size: 12px; gap: 4px;
padding: 2px 8px; }
border-radius: 10px;
} .creator-name {
font-size: 16px;
.menu-btn { font-weight: 600;
background: none; color: #333;
border: none; line-height: 1.2;
padding: 4px; }
cursor: pointer;
color: #999; .material-id {
border-radius: 4px; background: #e6f7ff;
color: #1677ff;
&:hover { font-size: 12px;
background: #f5f5f5; font-weight: 600;
color: #666; border-radius: 12px;
} padding: 2px 8px;
} display: inline-block;
}
.menu-dropdown {
position: absolute; .material-title {
right: 0; font-size: 16px;
top: 100%; font-weight: 600;
background: white; color: #333;
border-radius: 8px; margin-bottom: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); line-height: 1.4;
z-index: 1000; }
min-width: 120px;
padding: 4px; .content-icon {
margin-top: 4px; font-size: 16px;
} color: #1677ff;
}
.menu-item {
display: flex; .type-tag {
align-items: center; font-size: 12px;
gap: 8px; padding: 2px 8px;
padding: 8px 12px; border-radius: 10px;
cursor: pointer; }
border-radius: 4px;
font-size: 14px; .menu-btn {
color: #333; background: none;
transition: background 0.2s; border: none;
padding: 4px;
&:hover { cursor: pointer;
background: #f5f5f5; color: #999;
} border-radius: 4px;
&.danger { &:hover {
color: #ff4d4f; background: #f5f5f5;
color: #666;
&:hover { }
background: #fff2f0; }
}
} .menu-dropdown {
} position: absolute;
right: 0;
.card-content { top: 100%;
display: flex; background: white;
flex-direction: column; border-radius: 8px;
gap: 8px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
} z-index: 1000;
min-width: 120px;
.content-preview { padding: 4px;
color: #666; margin-top: 4px;
font-size: 14px; }
line-height: 1.5;
max-height: 60px; .menu-item {
overflow: hidden; display: flex;
text-overflow: ellipsis; align-items: center;
display: -webkit-box; gap: 8px;
-webkit-line-clamp: 3; padding: 8px 12px;
-webkit-box-orient: vertical; cursor: pointer;
} border-radius: 4px;
font-size: 14px;
.material-meta { color: #333;
display: flex; transition: background 0.2s;
flex-direction: column;
gap: 4px; &:hover {
font-size: 12px; background: #f5f5f5;
color: #999; }
}
&.danger {
.meta-item { color: #ff4d4f;
display: flex;
align-items: center; &:hover {
} background: #fff2f0;
}
.pagination-wrapper { }
display: flex; }
justify-content: center;
margin-top: 20px; .link-preview {
padding: 16px; display: flex;
background: white; align-items: flex-start;
border-radius: 8px; gap: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); padding: 12px;
} background: #f8f9fa;
border-radius: 8px;
.pagination { margin-bottom: 16px;
:global { border: 1px solid #e9ecef;
.adm-pagination-item { cursor: pointer;
border-radius: 6px; transition: all 0.2s ease;
margin: 0 2px;
&:hover {
&.adm-pagination-item-active { background: #e9ecef;
background: #1677ff; border-color: #1677ff;
color: white; }
} }
}
} .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;
}
// 内容类型标签样式
.content-type-tag {
display: inline-flex;
align-items: center;
padding: 4px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
border: 1px solid currentColor;
}
// 图片类型预览样式
.material-image-preview {
margin: 12px 0;
.image-grid {
display: grid;
gap: 8px;
width: 100%;
// 1张图片宽度拉伸高度自适应
&.single {
grid-template-columns: 1fr;
img {
width: 100%;
height: auto;
object-fit: cover;
border-radius: 8px;
}
}
// 2张图片左右并列
&.double {
grid-template-columns: 1fr 1fr;
img {
width: 100%;
height: 120px;
object-fit: cover;
border-radius: 8px;
}
}
// 3张图片三张并列
&.triple {
grid-template-columns: 1fr 1fr 1fr;
img {
width: 100%;
height: 100px;
object-fit: cover;
border-radius: 8px;
}
}
// 4张图片2x2网格布局
&.quad {
grid-template-columns: repeat(2, 1fr);
img {
width: 100%;
height: 140px;
object-fit: cover;
border-radius: 8px;
}
}
// 5张及以上网格布局
&.grid {
grid-template-columns: repeat(3, 1fr);
img {
width: 100%;
height: 100px;
object-fit: cover;
border-radius: 8px;
}
.image-more {
display: flex;
align-items: center;
justify-content: center;
background: #f5f5f5;
border-radius: 8px;
color: #666;
font-size: 12px;
font-weight: 500;
height: 100px;
}
}
}
.no-image {
display: flex;
align-items: center;
justify-content: center;
height: 80px;
background: #f5f5f5;
border-radius: 8px;
color: #999;
font-size: 14px;
}
}
// 链接类型预览样式
.material-link-preview {
margin: 12px 0;
.link-card {
display: flex;
background: #e9f8ff;
border-radius: 8px;
padding: 12px;
cursor: pointer;
transition: all 0.2s;
border: 1px solid #cde6ff;
&:hover {
background: #cde6ff;
}
.link-image {
width: 60px;
height: 60px;
margin-right: 12px;
flex-shrink: 0;
img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 6px;
}
}
.link-content {
flex: 1;
min-width: 0;
.link-title {
font-weight: 500;
margin-bottom: 4px;
color: #333;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.link-url {
font-size: 12px;
color: #666;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
}
// 视频类型预览样式
.material-video-preview {
margin: 12px 0;
.video-thumbnail {
video {
width: 100%;
max-height: 200px;
border-radius: 8px;
}
}
.no-video {
display: flex;
align-items: center;
justify-content: center;
height: 120px;
background: #f5f5f5;
border-radius: 8px;
color: #999;
font-size: 14px;
}
}
// 文本类型预览样式
.material-text-preview {
margin: 12px 0;
.text-content {
background: #f8f9fa;
padding: 12px;
border-radius: 8px;
line-height: 1.6;
color: #333;
font-size: 14px;
}
}
// 小程序类型预览样式
.material-miniprogram-preview {
margin: 12px 0;
.miniprogram-card {
display: flex;
background: #f8f9fa;
border-radius: 8px;
padding: 12px;
width: 100%;
img {
width: 60px;
height: 60px;
border-radius: 8px;
margin-right: 12px;
flex-shrink: 0;
object-fit: cover;
}
.miniprogram-info {
flex: 1;
min-width: 0;
.miniprogram-title {
font-weight: 500;
color: #333;
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
}
// 图文类型预览样式
.material-article-preview {
margin: 12px 0;
.article-image {
margin-bottom: 12px;
img {
width: 100%;
height: 120px;
object-fit: cover;
border-radius: 8px;
}
}
.article-content {
.article-title {
font-weight: 500;
color: #333;
margin-bottom: 8px;
font-size: 16px;
}
.article-text {
color: #666;
line-height: 1.6;
font-size: 14px;
}
}
}
// 默认预览样式
.material-default-preview {
margin: 12px 0;
.default-content {
background: #f8f9fa;
padding: 12px;
border-radius: 8px;
color: #333;
line-height: 1.6;
}
}

View File

@@ -1,387 +1,413 @@
import React, { useState, useEffect, useCallback } from "react"; import React, { useState, useEffect, useCallback } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { import { Toast, SpinLoading, Dialog, Card } from "antd-mobile";
Button, import { Input, Pagination, Button } from "antd";
Toast, import {
SpinLoading, PlusOutlined,
Dialog, SearchOutlined,
Card, ReloadOutlined,
Avatar, EditOutlined,
Tag, DeleteOutlined,
} from "antd-mobile"; UserOutlined,
import { Pagination, Input } from "antd"; BarChartOutlined,
import { PictureOutlined,
PlusOutlined, LinkOutlined,
SearchOutlined, VideoCameraOutlined,
ReloadOutlined, FileTextOutlined,
EditOutlined, AppstoreOutlined,
DeleteOutlined, } from "@ant-design/icons";
EyeOutlined, import Layout from "@/components/Layout/Layout";
MoreOutlined, import NavCommon from "@/components/NavCommon";
PictureOutlined, import { getContentItemList, deleteContentItem } from "./api";
LinkOutlined, import { ContentItem } from "./data";
VideoCameraOutlined, import style from "./index.module.scss";
FileTextOutlined,
AppstoreOutlined, // 内容类型配置
} from "@ant-design/icons"; const contentTypeConfig = {
import Layout from "@/components/Layout/Layout"; 1: { label: "图片", icon: PictureOutlined, color: "#52c41a" },
import NavCommon from "@/components/NavCommon"; 2: { label: "链接", icon: LinkOutlined, color: "#1890ff" },
import { 3: { label: "视频", icon: VideoCameraOutlined, color: "#722ed1" },
getContentItemList, 4: { label: "文本", icon: FileTextOutlined, color: "#fa8c16" },
deleteContentItem, 5: { label: "小程序", icon: AppstoreOutlined, color: "#eb2f96" },
getContentLibraryDetail, 6: { label: "图文", icon: PictureOutlined, color: "#13c2c2" },
} from "./api"; };
import { ContentItem, ContentLibrary } from "./data";
import style from "./index.module.scss"; const MaterialsList: React.FC = () => {
const navigate = useNavigate();
// 卡片菜单组件 const { id } = useParams<{ id: string }>();
interface CardMenuProps { const [materials, setMaterials] = useState<ContentItem[]>([]);
onView: () => void; const [searchQuery, setSearchQuery] = useState("");
onEdit: () => void; const [loading, setLoading] = useState(false);
onDelete: () => void; const [currentPage, setCurrentPage] = useState(1);
} const [total, setTotal] = useState(0);
const pageSize = 20;
const CardMenu: React.FC<CardMenuProps> = ({ onView, onEdit, onDelete }) => {
const [open, setOpen] = useState(false); // 获取素材列表
const menuRef = React.useRef<HTMLDivElement>(null); const fetchMaterials = useCallback(async () => {
if (!id) return;
React.useEffect(() => { setLoading(true);
function handleClickOutside(event: MouseEvent) { try {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) { const response = await getContentItemList({
setOpen(false); libraryId: id,
} page: currentPage,
} limit: pageSize,
if (open) document.addEventListener("mousedown", handleClickOutside); keyword: searchQuery,
return () => document.removeEventListener("mousedown", handleClickOutside); });
}, [open]);
setMaterials(response.list || []);
return ( setTotal(response.total || 0);
<div style={{ position: "relative" }}> } catch (error: unknown) {
<button onClick={() => setOpen((v) => !v)} className={style["menu-btn"]}> console.error("获取素材列表失败:", error);
<MoreOutlined /> Toast.show({
</button> content: error instanceof Error ? error.message : "请检查网络连接",
{open && ( position: "top",
<div ref={menuRef} className={style["menu-dropdown"]}> });
<div } finally {
onClick={() => { setLoading(false);
onView(); }
setOpen(false); }, [id, currentPage, searchQuery]);
}}
className={style["menu-item"]} useEffect(() => {
> fetchMaterials();
<EyeOutlined /> }, [fetchMaterials]);
</div> const handleCreateNew = () => {
<div navigate(`/content/materials/new/${id}`);
onClick={() => { };
onEdit();
setOpen(false); const handleEdit = (materialId: number) => {
}} navigate(`/content/materials/edit/${id}/${materialId}`);
className={style["menu-item"]} };
>
<EditOutlined /> const handleDelete = async (materialId: number) => {
const result = await Dialog.confirm({
</div> content: "确定要删除这个素材吗?",
<div confirmText: "删除",
onClick={() => { cancelText: "取消",
onDelete(); });
setOpen(false);
}} if (result) {
className={`${style["menu-item"]} ${style["danger"]}`} try {
> await deleteContentItem(materialId.toString());
<DeleteOutlined /> Toast.show({
content: "删除成功",
</div> position: "top",
</div> });
)} fetchMaterials();
</div> } catch (error: unknown) {
); console.error("删除素材失败:", error);
}; Toast.show({
content: error instanceof Error ? error.message : "请检查网络连接",
// 内容类型图标映射 position: "top",
const getContentTypeIcon = (type: number) => { });
switch (type) { }
case 1: }
return <PictureOutlined className={style["content-icon"]} />; };
case 2:
return <LinkOutlined className={style["content-icon"]} />; const handleView = (materialId: number) => {
case 3: // 可以跳转到素材详情页面或显示弹窗
return <VideoCameraOutlined className={style["content-icon"]} />; console.log("查看素材:", materialId);
case 4: };
return <FileTextOutlined className={style["content-icon"]} />;
case 5: const handleSearch = () => {
return <AppstoreOutlined className={style["content-icon"]} />; setCurrentPage(1);
default: fetchMaterials();
return <FileTextOutlined className={style["content-icon"]} />; };
}
}; const handleRefresh = () => {
fetchMaterials();
// 内容类型文字映射 };
const getContentTypeText = (type: number) => {
switch (type) { const handlePageChange = (page: number) => {
case 1: setCurrentPage(page);
return "图片"; };
case 2:
return "链接"; // 渲染内容类型标签
case 3: const renderContentTypeTag = (contentType: number) => {
return "视频"; const config =
case 4: contentTypeConfig[contentType as keyof typeof contentTypeConfig];
return "文本"; if (!config) return null;
case 5:
return "小程序"; const IconComponent = config.icon;
case 6: return (
return "图文"; <div
default: className={style["content-type-tag"]}
return "未知"; style={{ backgroundColor: config.color + "20", color: config.color }}
} >
}; <IconComponent style={{ fontSize: 12, marginRight: 4 }} />
{config.label}
const MaterialsList: React.FC = () => { </div>
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 renderContentPreview = (material: ContentItem) => {
const [loading, setLoading] = useState(false); const { contentType, content, resUrls, urls, coverImage } = material;
const [currentPage, setCurrentPage] = useState(1);
const [total, setTotal] = useState(0); switch (contentType) {
const pageSize = 20; case 1: // 图片
return (
// 获取内容库详情 <div className={style["material-image-preview"]}>
const fetchLibraryDetail = useCallback(async () => { {resUrls && resUrls.length > 0 ? (
if (!id) return; <div
try { className={`${style["image-grid"]} ${
const response = await getContentLibraryDetail(id); resUrls.length === 1
if (response.code === 200 && response.data) { ? style.single
setLibrary(response.data); : resUrls.length === 2
} ? style.double
} catch (error) { : resUrls.length === 3
console.error("获取内容库详情失败:", error); ? style.triple
} : resUrls.length === 4
}, [id]); ? style.quad
: style.grid
// 获取素材列表 }`}
const fetchMaterials = useCallback(async () => { >
if (!id) return; {resUrls.slice(0, 9).map((url, index) => (
setLoading(true); <img key={index} src={url} alt={`图片${index + 1}`} />
try { ))}
const response = await getContentItemList({ {resUrls.length > 9 && (
libraryId: id, <div className={style["image-more"]}>
page: currentPage, +{resUrls.length - 9}
limit: pageSize, </div>
keyword: searchQuery, )}
}); </div>
) : coverImage ? (
if (response.code === 200 && response.data) { <div className={`${style["image-grid"]} ${style.single}`}>
setMaterials(response.data.list || []); <img src={coverImage} alt="封面图" />
setTotal(response.data.total || 0); </div>
} else { ) : (
Toast.show({ <div className={style["no-image"]}></div>
content: response.msg || "获取素材列表失败", )}
position: "top", </div>
}); );
}
} catch (error: any) { case 2: // 链接
console.error("获取素材列表失败:", error); return (
Toast.show({ <div className={style["material-link-preview"]}>
content: error?.message || "请检查网络连接", {urls && urls.length > 0 && (
position: "top", <div
}); className={style["link-card"]}
} finally { onClick={() => {
setLoading(false); window.open(urls[0].url, "_blank");
} }}
}, [id, currentPage, searchQuery]); >
{urls[0].image && (
useEffect(() => { <div className={style["link-image"]}>
fetchLibraryDetail(); <img src={urls[0].image} alt="链接预览" />
}, [fetchLibraryDetail]); </div>
)}
useEffect(() => { <div className={style["link-content"]}>
fetchMaterials(); <div className={style["link-title"]}>
}, [fetchMaterials]); {urls[0].desc || "链接"}
</div>
const handleCreateNew = () => { <div className={style["link-url"]}>{urls[0].url}</div>
navigate(`/content/materials/new/${id}`); </div>
}; </div>
)}
const handleEdit = (materialId: string) => { </div>
navigate(`/content/materials/edit/${id}/${materialId}`); );
};
case 3: // 视频
const handleDelete = async (materialId: string) => { return (
const result = await Dialog.confirm({ <div className={style["material-video-preview"]}>
content: "确定要删除这个素材吗?", {resUrls && resUrls.length > 0 ? (
confirmText: "删除", <div className={style["video-thumbnail"]}>
cancelText: "取消", <video src={resUrls[0]} controls />
}); </div>
) : (
if (result) { <div className={style["no-video"]}></div>
try { )}
const response = await deleteContentItem(materialId); </div>
if (response.code === 200) { );
Toast.show({
content: "删除成功", case 4: // 文本
position: "top", return (
}); <div className={style["material-text-preview"]}>
fetchMaterials(); <div className={style["text-content"]}>
} else { {content.length > 100
Toast.show({ ? `${content.substring(0, 100)}...`
content: response.msg || "删除失败", : content}
position: "top", </div>
}); </div>
} );
} catch (error: any) {
console.error("删除素材失败:", error); case 5: // 小程序
Toast.show({ return (
content: error?.message || "请检查网络连接", <div className={style["material-miniprogram-preview"]}>
position: "top", {resUrls && resUrls.length > 0 && (
}); <div className={style["miniprogram-card"]}>
} <img src={resUrls[0]} alt="小程序封面" />
} <div className={style["miniprogram-info"]}>
}; <div className={style["miniprogram-title"]}>
{material.title || "小程序"}
const handleView = (materialId: string) => { </div>
// 可以跳转到素材详情页面或显示弹窗 </div>
console.log("查看素材:", materialId); </div>
}; )}
</div>
const handleSearch = () => { );
setCurrentPage(1);
fetchMaterials(); case 6: // 图文
}; return (
<div className={style["material-article-preview"]}>
const handleRefresh = () => { {coverImage && (
fetchMaterials(); <div className={style["article-image"]}>
}; <img src={coverImage} alt="文章封面" />
</div>
const handlePageChange = (page: number) => { )}
setCurrentPage(page); <div className={style["article-content"]}>
}; <div className={style["article-title"]}>
{material.title || "图文内容"}
const filteredMaterials = materials.filter( </div>
(material) => <div className={style["article-text"]}>
material.title?.toLowerCase().includes(searchQuery.toLowerCase()) || {content.length > 80
material.content?.toLowerCase().includes(searchQuery.toLowerCase()) ? `${content.substring(0, 80)}...`
); : content}
</div>
return ( </div>
<Layout </div>
header={<NavCommon title={`${library?.name || "内容库"} - 素材管理`} />} );
>
<div className={style["materials-page"]}> default:
{/* 搜索和操作栏 */} return (
<div className={style["search-bar"]}> <div className={style["material-default-preview"]}>
<div className={style["search-input-wrapper"]}> <div className={style["default-content"]}>{content}</div>
<SearchOutlined className={style["search-icon"]} /> </div>
<Input );
placeholder="搜索素材..." }
value={searchQuery} };
onChange={(e) => setSearchQuery(e.target.value)}
onPressEnter={handleSearch} return (
className={style["search-input"]} <Layout
/> header={
</div> <>
<Button <NavCommon
size="small" title="素材管理"
onClick={handleRefresh} right={
disabled={loading} <Button type="primary" onClick={handleCreateNew}>
className={style["refresh-btn"]} <PlusOutlined />
> </Button>
<ReloadOutlined className={loading ? style["spinning"] : ""} /> }
</Button> />
<Button {/* 搜索栏 */}
color="primary" <div className="search-bar">
size="small" <div className="search-input-wrapper">
onClick={handleCreateNew} <Input
className={style["create-btn"]} placeholder="搜索素材内容"
> value={searchQuery}
<PlusOutlined /> onChange={e => setSearchQuery(e.target.value)}
prefix={<SearchOutlined />}
</Button> allowClear
</div> size="large"
/>
{/* 素材列表 */} </div>
<div className={style["materials-list"]}> <Button
{loading ? ( onClick={handleRefresh}
<div className={style["loading"]}> loading={loading}
<SpinLoading color="primary" style={{ fontSize: 32 }} /> icon={<ReloadOutlined />}
</div> size="large"
) : filteredMaterials.length === 0 ? ( ></Button>
<div className={style["empty-state"]}> </div>
<div className={style["empty-icon"]}>📄</div> </>
<div className={style["empty-text"]}> }
footer={
</div> <div className={style["pagination-wrapper"]}>
<Button <Pagination
color="primary" current={currentPage}
size="small" pageSize={pageSize}
onClick={handleCreateNew} total={total}
className={style["empty-btn"]} onChange={handlePageChange}
> showSizeChanger={false}
/>
</Button> </div>
</div> }
) : ( loading={loading}
<> >
{filteredMaterials.map((material) => ( <div className={style["materials-page"]}>
<Card key={material.id} className={style["material-card"]}> {/* 素材列表 */}
<div className={style["card-header"]}> <div className={style["materials-list"]}>
<div className={style["material-info"]}> {loading ? (
<div className={style["material-title"]}> <div className={style["loading"]}>
{getContentTypeIcon(material.contentType)} <SpinLoading color="primary" style={{ fontSize: 32 }} />
<span>{material.title || "无标题"}</span> </div>
</div> ) : materials.length === 0 ? (
<Tag color="blue" className={style["type-tag"]}> <div className={style["empty-state"]}>
{getContentTypeText(material.contentType)} <div className={style["empty-icon"]}>📄</div>
</Tag> <div className={style["empty-text"]}>
</div>
<CardMenu </div>
onView={() => handleView(material.id)} <Button
onEdit={() => handleEdit(material.id)} color="primary"
onDelete={() => handleDelete(material.id)} onClick={handleCreateNew}
/> className={style["empty-btn"]}
</div> >
<div className={style["card-content"]}>
<div className={style["content-preview"]}> </Button>
{material.content?.substring(0, 100)} </div>
{material.content && ) : (
material.content.length > 100 && <>
"..."} {materials.map(material => (
</div> <Card key={material.id} className={style["material-card"]}>
<div className={style["material-meta"]}> {/* 顶部信息 */}
<span className={style["meta-item"]}> <div className={style["card-header"]}>
<div className={style["avatar-section"]}>
{new Date(material.createTime).toLocaleString("zh-CN")} <div className={style["avatar"]}>
</span> <UserOutlined className={style["avatar-icon"]} />
{material.sendTime && ( </div>
<span className={style["meta-item"]}> <div className={style["header-info"]}>
<span className={style["creator-name"]}>
{new Date(material.sendTime).toLocaleString("zh-CN")} {material.senderNickname || "系统创建"}
</span> </span>
)} <span className={style["material-id"]}>
</div> ID: {material.id}
</div> </span>
</Card> </div>
))} </div>
{renderContentTypeTag(material.contentType)}
{/* 分页 */} </div>
{total > pageSize && ( {/* 标题 */}
<div className={style["pagination-wrapper"]}> {material.contentType != 4 && (
<Pagination <div className={style["card-title"]}>
total={total} {material.content}
pageSize={pageSize} </div>
current={currentPage} )}
onChange={handlePageChange} {/* 内容预览 */}
className={style["pagination"]} {renderContentPreview(material)}
/>
</div> {/* 操作按钮区 */}
)} <div className={style["action-buttons"]}>
</> <div className={style["action-btn-group"]}>
)} <Button
</div> onClick={() => handleEdit(material.id)}
</div> className={style["action-btn"]}
</Layout> >
); <EditOutlined />
};
</Button>
export default MaterialsList; <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'; import request from "@/api/request";
// 设备统计 // 设备统计
export function getDeviceStats() { export function getDeviceStats() {
return request('/v1/dashboard/device-stats', {}, 'GET'); return request("/v1/dashboard/device-stats", {}, "GET");
} }
// 微信号统计 // 微信号统计
export function getWechatStats() { export function getWechatStats() {
return request('/v1/dashboard/wechat-stats', {}, 'GET'); return request("/v1/dashboard/wechat-stats", {}, "GET");
} }
// 今日数据统计 // 今日数据统计
export function getTodayStats() { export function getTodayStats() {
return request('/v1/dashboard/today-stats', {}, 'GET'); return request("/v1/dashboard/today-stats", {}, "GET");
} }
// 首页仪表盘总览 // 首页仪表盘总览
export function getDashboard() { export function getDashboard() {
return request('/v1/dashboard', {}, 'GET'); return request("/v1/dashboard", {}, "GET");
} }
// 获客场景统计 // 获客场景统计
export function getPlanStats(params:any) { export function getPlanStats(params: any) {
return request('/v1/dashboard/plan-stats', params, 'GET'); return request("/v1/dashboard/plan-stats", params, "GET");
} }
// 近七天统计 // 近七天统计
export function getSevenDayStats() { export function getSevenDayStats() {
return request('/v1/dashboard/sevenDay-stats', {}, 'GET'); return request("/v1/dashboard/sevenDay-stats", {}, "GET");
} }

View File

@@ -44,7 +44,7 @@
background: transparent; background: transparent;
cursor: pointer; cursor: pointer;
transition: background-color 0.2s; transition: background-color 0.2s;
&:hover { &:hover {
background: #f3f4f6; background: #f3f4f6;
} }
@@ -67,7 +67,7 @@
cursor: pointer; cursor: pointer;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease; transition: all 0.3s ease;
&:hover { &:hover {
transform: translateY(-1px); transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15); box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
@@ -109,7 +109,7 @@
// Loading状态样式 // Loading状态样式
.stat-card { .stat-card {
.stat-label:empty::before { .stat-label:empty::before {
content: ''; content: "";
display: block; display: block;
width: 60px; width: 60px;
height: 12px; height: 12px;
@@ -117,10 +117,10 @@
border-radius: 2px; border-radius: 2px;
animation: pulse 1.5s ease-in-out infinite; animation: pulse 1.5s ease-in-out infinite;
} }
.stat-value { .stat-value {
span:empty::before { span:empty::before {
content: ''; content: "";
display: block; display: block;
width: 40px; width: 40px;
height: 20px; height: 20px;
@@ -128,9 +128,9 @@
border-radius: 2px; border-radius: 2px;
animation: pulse 1.5s ease-in-out infinite; animation: pulse 1.5s ease-in-out infinite;
} }
div:empty::before { div:empty::before {
content: ''; content: "";
display: block; display: block;
width: 20px; width: 20px;
height: 20px; height: 20px;
@@ -142,7 +142,8 @@
} }
@keyframes pulse { @keyframes pulse {
0%, 100% { 0%,
100% {
opacity: 1; opacity: 1;
} }
50% { 50% {
@@ -157,7 +158,7 @@
padding: 16px; padding: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
transition: all 0.3s ease; transition: all 0.3s ease;
&:hover { &:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
} }
@@ -173,9 +174,9 @@
color: #333; color: #333;
position: relative; position: relative;
padding-left: 8px; padding-left: 8px;
&::before { &::before {
content: ''; content: "";
position: absolute; position: absolute;
left: 0; left: 0;
top: 50%; top: 50%;
@@ -248,7 +249,7 @@
border-radius: 8px; border-radius: 8px;
transition: all 0.3s ease; transition: all 0.3s ease;
cursor: pointer; cursor: pointer;
&:hover { &:hover {
background: #f1f5f9; background: #f1f5f9;
transform: translateY(-1px); transform: translateY(-1px);
@@ -294,62 +295,62 @@
.home-page { .home-page {
padding: 8px; padding: 8px;
} }
.stats-grid { .stats-grid {
gap: 6px; gap: 6px;
margin-bottom: 12px; margin-bottom: 12px;
} }
.stat-card { .stat-card {
padding: 10px 6px; padding: 10px 6px;
} }
.stat-icon { .stat-icon {
width: 28px; width: 28px;
height: 28px; height: 28px;
} }
.stat-value { .stat-value {
font-size: 16px; font-size: 16px;
} }
.stat-label { .stat-label {
font-size: 10px; font-size: 10px;
} }
.section { .section {
padding: 12px; padding: 12px;
margin-bottom: 8px; margin-bottom: 8px;
} }
.scene-grid, .scene-grid,
.today-grid { .today-grid {
gap: 6px; gap: 6px;
} }
.scene-icon { .scene-icon {
width: 32px; width: 32px;
height: 32px; height: 32px;
} }
.scene-value { .scene-value {
font-size: 14px; font-size: 14px;
} }
.scene-label { .scene-label {
font-size: 9px; font-size: 9px;
} }
.today-value { .today-value {
font-size: 12px; font-size: 12px;
} }
.today-label { .today-label {
font-size: 9px; font-size: 9px;
} }
.chart-container { .chart-container {
min-height: 140px; min-height: 140px;
padding: 8px; padding: 8px;
} }
} }

View File

@@ -199,7 +199,7 @@ const Home: React.FC = () => {
<h2 className={style["section-title"]}></h2> <h2 className={style["section-title"]}></h2>
</div> </div>
<div className={style["scene-grid"]}> <div className={style["scene-grid"]}>
{sceneStats.map((scenario) => ( {sceneStats.map(scenario => (
<div <div
key={scenario.id} key={scenario.id}
className={style["scene-item"]} className={style["scene-item"]}

View File

@@ -85,10 +85,10 @@ const DeviceDetail: React.FC = () => {
checked: boolean checked: boolean
) => { ) => {
if (!id) return; if (!id) return;
setFeatureSaving((prev) => ({ ...prev, [feature]: true })); setFeatureSaving(prev => ({ ...prev, [feature]: true }));
try { try {
await updateDeviceTaskConfig({ deviceId: id, [feature]: checked }); await updateDeviceTaskConfig({ deviceId: id, [feature]: checked });
setDevice((prev) => setDevice(prev =>
prev prev
? { ? {
...prev, ...prev,
@@ -102,7 +102,7 @@ const DeviceDetail: React.FC = () => {
} catch (e: any) { } catch (e: any) {
Toast.show({ content: e.message || "设置失败", position: "top" }); Toast.show({ content: e.message || "设置失败", position: "top" });
} finally { } finally {
setFeatureSaving((prev) => ({ ...prev, [feature]: false })); setFeatureSaving(prev => ({ ...prev, [feature]: false }));
} }
}; };
@@ -199,7 +199,7 @@ const DeviceDetail: React.FC = () => {
}} }}
> >
{["autoAddFriend", "autoReply", "momentsSync", "aiChat"].map( {["autoAddFriend", "autoReply", "momentsSync", "aiChat"].map(
(f) => ( f => (
<div <div
key={f} key={f}
style={{ style={{
@@ -216,7 +216,7 @@ const DeviceDetail: React.FC = () => {
!!device.features?.[f as keyof Device["features"]] !!device.features?.[f as keyof Device["features"]]
} }
loading={!!featureSaving[f]} loading={!!featureSaving[f]}
onChange={(checked) => onChange={checked =>
handleFeatureChange( handleFeatureChange(
f as keyof Device["features"], f as keyof Device["features"],
checked checked
@@ -254,7 +254,7 @@ const DeviceDetail: React.FC = () => {
<div <div
style={{ display: "flex", flexDirection: "column", gap: 12 }} style={{ display: "flex", flexDirection: "column", gap: 12 }}
> >
{accounts.map((acc) => ( {accounts.map(acc => (
<div <div
key={acc.id} key={acc.id}
style={{ style={{
@@ -334,7 +334,7 @@ const DeviceDetail: React.FC = () => {
<div <div
style={{ display: "flex", flexDirection: "column", gap: 12 }} style={{ display: "flex", flexDirection: "column", gap: 12 }}
> >
{logs.map((log) => ( {logs.map(log => (
<div <div
key={log.id} key={log.id}
style={{ style={{

View File

@@ -57,7 +57,7 @@ const Devices: React.FC = () => {
if (search) params.keyword = search; if (search) params.keyword = search;
const res = await fetchDeviceList(params); const res = await fetchDeviceList(params);
const list = Array.isArray(res.list) ? res.list : []; const list = Array.isArray(res.list) ? res.list : [];
setDevices((prev) => (reset ? list : [...prev, ...list])); setDevices(prev => (reset ? list : [...prev, ...list]));
setTotal(res.total || 0); setTotal(res.total || 0);
setHasMore(list.length === 20); setHasMore(list.length === 20);
if (reset) setPage(1); if (reset) setPage(1);
@@ -81,9 +81,9 @@ const Devices: React.FC = () => {
useEffect(() => { useEffect(() => {
if (!hasMore || loading) return; if (!hasMore || loading) return;
const observer = new window.IntersectionObserver( const observer = new window.IntersectionObserver(
(entries) => { entries => {
if (entries[0].isIntersecting && hasMore && !loading) { if (entries[0].isIntersecting && hasMore && !loading) {
setPage((p) => p + 1); setPage(p => p + 1);
} }
}, },
{ threshold: 0.5 } { threshold: 0.5 }
@@ -100,7 +100,7 @@ const Devices: React.FC = () => {
}, [page]); }, [page]);
// 状态筛选 // 状态筛选
const filtered = devices.filter((d) => { const filtered = devices.filter(d => {
if (status === "all") return true; if (status === "all") return true;
if (status === "online") return d.status === "online" || d.alive === 1; if (status === "online") return d.status === "online" || d.alive === 1;
if (status === "offline") return d.status === "offline" || d.alive === 0; if (status === "offline") return d.status === "offline" || d.alive === 0;
@@ -222,7 +222,7 @@ const Devices: React.FC = () => {
<Input <Input
placeholder="搜索设备IMEI/备注" placeholder="搜索设备IMEI/备注"
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={e => setSearch(e.target.value)}
prefix={<SearchOutlined />} prefix={<SearchOutlined />}
allowClear allowClear
style={{ flex: 1 }} style={{ flex: 1 }}
@@ -238,7 +238,7 @@ const Devices: React.FC = () => {
<div style={{ display: "flex", gap: 8 }}> <div style={{ display: "flex", gap: 8 }}>
<Tabs <Tabs
activeKey={status} activeKey={status}
onChange={(k) => setStatus(k as any)} onChange={k => setStatus(k as any)}
style={{ flex: 1 }} style={{ flex: 1 }}
> >
<Tabs.Tab title="全部" key="all" /> <Tabs.Tab title="全部" key="all" />
@@ -277,7 +277,7 @@ const Devices: React.FC = () => {
<div style={{ padding: 12 }}> <div style={{ padding: 12 }}>
{/* 设备列表 */} {/* 设备列表 */}
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}> <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
{filtered.map((device) => ( {filtered.map(device => (
<div <div
key={device.id} key={device.id}
style={{ style={{
@@ -296,15 +296,15 @@ const Devices: React.FC = () => {
> >
<Checkbox <Checkbox
checked={selected.includes(device.id)} checked={selected.includes(device.id)}
onChange={(e) => { onChange={e => {
e.stopPropagation(); e.stopPropagation();
setSelected((prev) => setSelected(prev =>
e.target.checked e.target.checked
? [...prev, device.id!] ? [...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 }} style={{ marginRight: 12 }}
/> />
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
@@ -404,13 +404,13 @@ const Devices: React.FC = () => {
<Input <Input
placeholder="设备名称" placeholder="设备名称"
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={e => setName(e.target.value)}
allowClear allowClear
/> />
<Input <Input
placeholder="设备IMEI" placeholder="设备IMEI"
value={imei} value={imei}
onChange={(e) => setImei(e.target.value)} onChange={e => setImei(e.target.value)}
allowClear allowClear
/> />
<Button <Button

View File

@@ -1,5 +1,5 @@
import request from "@/api/request"; import request from "@/api/request";
// 首页仪表盘总览 // 首页仪表盘总览
export function getDashboard() { export function getDashboard() {
return request("/v1/dashboard", {}, "GET"); return request("/v1/dashboard", {}, "GET");
} }

View File

@@ -4,7 +4,7 @@
.user-card { .user-card {
border-radius: 16px; 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; margin: 16px 0 12px 0;
padding: 0 0 0 0; padding: 0 0 0 0;
} }
@@ -89,7 +89,7 @@
border-radius: 8px; border-radius: 8px;
} }
.icon-setting{ .icon-setting {
font-size: 26px; font-size: 26px;
color: #666; color: #666;
position: absolute; position: absolute;
@@ -139,35 +139,34 @@
padding: 0 12px; padding: 0 12px;
border-radius: 12px; border-radius: 12px;
} }
:global(.adm-list-item-content-prefix) { :global(.adm-list-item-content-prefix) {
margin-right: 12px; margin-right: 12px;
color: var(--primary-color); color: var(--primary-color);
font-size: 20px; font-size: 20px;
} }
:global(.adm-list-item-content-main) { :global(.adm-list-item-content-main) {
flex: 1; flex: 1;
} }
:global(.adm-list-item-title) { :global(.adm-list-item-title) {
font-size: 16px; font-size: 16px;
color: #333; color: #333;
margin-bottom: 4px; margin-bottom: 4px;
} }
:global(.adm-list-item-description) { :global(.adm-list-item-description) {
font-size: 12px; font-size: 12px;
color: #666; color: #666;
} }
:global(.adm-list-item-content-arrow) { :global(.adm-list-item-content-arrow) {
color: #ccc; color: #ccc;
} }
} }
} }
.logout-btn { .logout-btn {
border-radius: 8px; border-radius: 8px;
height: 48px; height: 48px;
@@ -180,32 +179,32 @@
.mine-page { .mine-page {
padding: 12px; padding: 12px;
} }
.user-info { .user-info {
gap: 12px; gap: 12px;
} }
.user-avatar { .user-avatar {
width: 50px; width: 50px;
height: 50px; height: 50px;
background: #666; background: #666;
} }
.user-name { .user-name {
font-size: 16px; font-size: 16px;
} }
.menu-card { .menu-card {
:global(.adm-list-item) { :global(.adm-list-item) {
padding: 12px; padding: 12px;
:global(.adm-list-item-content-prefix) { :global(.adm-list-item-content-prefix) {
font-size: 18px; font-size: 18px;
} }
:global(.adm-list-item-title) { :global(.adm-list-item-title) {
font-size: 14px; font-size: 14px;
} }
} }
} }
} }

View File

@@ -227,7 +227,7 @@ const Mine: React.FC = () => {
{/* 我的功能 */} {/* 我的功能 */}
<Card className={style["menu-card"]}> <Card className={style["menu-card"]}>
<List> <List>
{functionModules.map((module) => ( {functionModules.map(module => (
<List.Item <List.Item
key={module.id} key={module.id}
prefix={renderModuleIcon(module)} prefix={renderModuleIcon(module)}

View File

@@ -1,127 +1,127 @@
.recharge-page { .recharge-page {
padding: 16px 0 60px 0; padding: 16px 0 60px 0;
background: #f7f8fa; background: #f7f8fa;
min-height: 100vh; min-height: 100vh;
} }
.balance-card { .balance-card {
margin: 16px; margin: 16px;
background: #f6ffed; background: #f6ffed;
border: 1px solid #b7eb8f; border: 1px solid #b7eb8f;
border-radius: 12px; border-radius: 12px;
padding: 18px 0 18px 0; padding: 18px 0 18px 0;
display: flex; display: flex;
align-items: center; align-items: center;
.balance-content { .balance-content {
display: flex; display: flex;
color: #16b364; color: #16b364;
padding-left: 30px; padding-left: 30px;
} }
.wallet-icon { .wallet-icon {
color: #16b364; color: #16b364;
font-size: 30px; font-size: 30px;
flex-shrink: 0; flex-shrink: 0;
} }
.balance-info { .balance-info {
margin-left: 15px; margin-left: 15px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
} }
.balance-label { .balance-label {
font-size: 14px; font-size: 14px;
font-weight: normal; font-weight: normal;
color: #666; color: #666;
margin-bottom: 2px; margin-bottom: 2px;
} }
.balance-amount { .balance-amount {
font-size: 24px; font-size: 24px;
font-weight: 700; font-weight: 700;
color: #16b364; color: #16b364;
line-height: 1.1; line-height: 1.1;
} }
} }
.quick-card { .quick-card {
margin: 16px; margin: 16px;
.quick-list { .quick-list {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 8px; gap: 8px;
justify-content: flex-start; justify-content: flex-start;
margin-bottom: 8px; margin-bottom: 8px;
} }
} }
.desc-card { .desc-card {
margin: 16px; margin: 16px;
background: #fffbe6; background: #fffbe6;
border: 1px solid #ffe58f; border: 1px solid #ffe58f;
} }
.warn-card { .warn-card {
margin: 16px; margin: 16px;
background: #fff2e8; background: #fff2e8;
border: 1px solid #ffbb96; border: 1px solid #ffbb96;
} }
.quick-title { .quick-title {
font-weight: 500; font-weight: 500;
margin-bottom: 8px; margin-bottom: 8px;
font-size: 16px; font-size: 16px;
} }
.quick-list { .quick-list {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 8px; gap: 8px;
justify-content: flex-start; justify-content: flex-start;
margin-bottom: 8px; margin-bottom: 8px;
} }
.quick-btn { .quick-btn {
min-width: 80px; min-width: 80px;
margin: 4px 0; margin: 4px 0;
font-size: 16px; font-size: 16px;
border-radius: 8px; border-radius: 8px;
} }
.quick-btn-active { .quick-btn-active {
@extend .quick-btn; @extend .quick-btn;
font-weight: 600; font-weight: 600;
} }
.recharge-main-btn { .recharge-main-btn {
margin-top: 16px; margin-top: 16px;
font-size: 18px; font-size: 18px;
border-radius: 8px; border-radius: 8px;
} }
.desc-title { .desc-title {
font-weight: 500; font-weight: 500;
margin-bottom: 8px; margin-bottom: 8px;
font-size: 16px; font-size: 16px;
} }
.desc-text { .desc-text {
color: #666; color: #666;
font-size: 14px; font-size: 14px;
} }
.warn-content { .warn-content {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
color: #faad14; color: #faad14;
font-size: 14px; font-size: 14px;
} }
.warn-icon { .warn-icon {
font-size: 30px; font-size: 30px;
color: #faad14; color: #faad14;
flex-shrink: 0; flex-shrink: 0;
} }
.warn-info { .warn-info {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.warn-title { .warn-title {
font-weight: 600; font-weight: 600;
font-size: 15px; font-size: 15px;
} }
.warn-text { .warn-text {
color: #faad14; color: #faad14;
font-size: 14px; font-size: 14px;
} }

View File

@@ -1,101 +1,101 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { Card, Button, Toast, NavBar } from "antd-mobile"; import { Card, Button, Toast, NavBar } from "antd-mobile";
import { useUserStore } from "@/store/module/user"; import { useUserStore } from "@/store/module/user";
import style from "./index.module.scss"; import style from "./index.module.scss";
import { WalletOutlined, WarningOutlined } from "@ant-design/icons"; import { WalletOutlined, WarningOutlined } from "@ant-design/icons";
import NavCommon from "@/components/NavCommon"; import NavCommon from "@/components/NavCommon";
import Layout from "@/components/Layout/Layout"; import Layout from "@/components/Layout/Layout";
const quickAmounts = [50, 100, 200, 500, 1000]; const quickAmounts = [50, 100, 200, 500, 1000];
const Recharge: React.FC = () => { const Recharge: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { user } = useUserStore(); const { user } = useUserStore();
// 假设余额从后端接口获取实际可用props或store传递 // 假设余额从后端接口获取实际可用props或store传递
const [balance, setBalance] = useState(0); const [balance, setBalance] = useState(0);
const [selected, setSelected] = useState<number | null>(null); const [selected, setSelected] = useState<number | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
// 充值操作 // 充值操作
const handleRecharge = async () => { const handleRecharge = async () => {
if (!selected) { if (!selected) {
Toast.show({ content: "请选择充值金额", position: "top" }); Toast.show({ content: "请选择充值金额", position: "top" });
return; return;
} }
setLoading(true); setLoading(true);
setTimeout(() => { setTimeout(() => {
setBalance((b) => b + selected); setBalance(b => b + selected);
Toast.show({ content: `充值成功,已到账¥${selected}` }); Toast.show({ content: `充值成功,已到账¥${selected}` });
setLoading(false); setLoading(false);
}, 1200); }, 1200);
}; };
return ( return (
<Layout header={<NavCommon title="账户充值" />}> <Layout header={<NavCommon title="账户充值" />}>
<div className={style["recharge-page"]}> <div className={style["recharge-page"]}>
<Card className={style["balance-card"]}> <Card className={style["balance-card"]}>
<div className={style["balance-content"]}> <div className={style["balance-content"]}>
<WalletOutlined className={style["wallet-icon"]} /> <WalletOutlined className={style["wallet-icon"]} />
<div className={style["balance-info"]}> <div className={style["balance-info"]}>
<div className={style["balance-label"]}></div> <div className={style["balance-label"]}></div>
<div className={style["balance-amount"]}> <div className={style["balance-amount"]}>
{balance.toFixed(2)} {balance.toFixed(2)}
</div> </div>
</div> </div>
</div> </div>
</Card> </Card>
<Card className={style["quick-card"]}> <Card className={style["quick-card"]}>
<div className={style["quick-title"]}></div> <div className={style["quick-title"]}></div>
<div className={style["quick-list"]}> <div className={style["quick-list"]}>
{quickAmounts.map((amt) => ( {quickAmounts.map(amt => (
<Button <Button
key={amt} key={amt}
color={selected === amt ? "primary" : "default"} color={selected === amt ? "primary" : "default"}
className={ className={
selected === amt selected === amt
? style["quick-btn-active"] ? style["quick-btn-active"]
: style["quick-btn"] : style["quick-btn"]
} }
onClick={() => setSelected(amt)} onClick={() => setSelected(amt)}
> >
{amt} {amt}
</Button> </Button>
))} ))}
</div> </div>
<Button <Button
block block
color="primary" color="primary"
size="large" size="large"
className={style["recharge-main-btn"]} className={style["recharge-main-btn"]}
loading={loading} loading={loading}
onClick={handleRecharge} onClick={handleRecharge}
> >
</Button> </Button>
</Card> </Card>
<Card className={style["desc-card"]}> <Card className={style["desc-card"]}>
<div className={style["desc-title"]}></div> <div className={style["desc-title"]}></div>
<div className={style["desc-text"]}> <div className={style["desc-text"]}>
使 使
</div> </div>
</Card> </Card>
{balance < 10 && ( {balance < 10 && (
<Card className={style["warn-card"]}> <Card className={style["warn-card"]}>
<div className={style["warn-content"]}> <div className={style["warn-content"]}>
<WarningOutlined className={style["warn-icon"]} /> <WarningOutlined className={style["warn-icon"]} />
<div className={style["warn-info"]}> <div className={style["warn-info"]}>
<div className={style["warn-title"]}></div> <div className={style["warn-title"]}></div>
<div className={style["warn-text"]}> <div className={style["warn-text"]}>
使 使
</div> </div>
</div> </div>
</div> </div>
</Card> </Card>
)} )}
</div> </div>
</Layout> </Layout>
); );
}; };
export default Recharge; export default Recharge;

View File

@@ -1,5 +1,5 @@
import request from "@/api/request"; import request from "@/api/request";
export function getTrafficPoolDetail(wechatId: string): Promise<any> { export function getTrafficPoolDetail(id: string): Promise<any> {
return request("/v1/wechats/getWechatInfo", { wechatId }, "GET"); return request("/v1/workbench/detail", { id }, "GET");
} }

View File

@@ -1,46 +1,32 @@
// 用户详情类型 // 用户详情类型
export interface TrafficPoolUserDetail { export interface TrafficPoolUserDetail {
userInfo: { id: number;
wechatId: string; nickname: string;
weight: number | null; avatar: string;
activity: { wechatId: string;
totalMsgCount: number; status: number | string;
sevenDayMsgCount: number; addTime: string;
thirtyDayMsgCount: number; lastInteraction: string;
yesterdayMsgCount: number; deviceName?: string;
}; wechatAccountName?: string;
friendShip: { customerServiceName?: string;
maleFriend: number; poolNames?: string[];
groupNumber: number; rfmScore?: {
totalFriend: number; recency: number;
femaleFriend: number; frequency: number;
unknowFriend: number; monetary: number;
}; segment?: string;
nickname: string; };
alias: string; totalSpent?: number;
avatar: string; interactionCount?: number;
gender: number; // 0-未知, 1-男, 2-女 conversionRate?: number;
}; tags?: string[];
accountAge: string; packages?: string[];
activityLevel: { interactions?: Array<{
allTimes: number; id: string;
dayTimes: number; type: string;
}; content: string;
accountWeight: { timestamp: string;
ageWeight: number; value?: number;
activityWeigth: number; }>;
restrictWeight: number; }
realNameWeight: number;
scope: number;
};
statistics: {
todayAdded: number;
addLimit: number;
};
restrictions: Array<{
id: number;
date: number | null;
level: number; // 1-轻微, 2-中等, 3-严重
reason: string;
}>;
}

View File

@@ -1,379 +1,300 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router-dom"; import { useParams, useNavigate } from "react-router-dom";
import { Card, Button, Avatar, Tag, Tabs, List, Badge } from "antd-mobile"; import Layout from "@/components/Layout/Layout";
import { import { getTrafficPoolDetail } from "./api";
UserOutlined, import type { TrafficPoolUserDetail } from "./data";
MessageOutlined, import { Card, Button, Avatar, Tag, Spin } from "antd";
TeamOutlined,
ClockCircleOutlined, const tabList = [
ExclamationCircleOutlined, { key: "base", label: "基本信息" },
PlusOutlined, { key: "journey", label: "用户旅程" },
} from "@ant-design/icons"; { key: "tags", label: "用户标签" },
import Layout from "@/components/Layout/Layout"; ];
import NavCommon from "@/components/NavCommon";
import { getTrafficPoolDetail } from "./api"; const TrafficPoolDetail: React.FC = () => {
import type { TrafficPoolUserDetail } from "./data"; const { id } = useParams();
import styles from "./index.module.scss"; const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const TrafficPoolDetail: React.FC = () => { const [user, setUser] = useState<TrafficPoolUserDetail | null>(null);
const { id } = useParams(); const [activeTab, setActiveTab] = useState<"base" | "journey" | "tags">(
const navigate = useNavigate(); "base"
const [loading, setLoading] = useState(true); );
const [user, setUser] = useState<TrafficPoolUserDetail | null>(null);
useEffect(() => {
useEffect(() => { if (!id) return;
if (!id) return; setLoading(true);
setLoading(true); getTrafficPoolDetail(id as string)
getTrafficPoolDetail(id as string) .then(res => setUser(res))
.then((res) => setUser(res)) .finally(() => setLoading(false));
.finally(() => setLoading(false)); }, [id]);
}, [id]);
if (loading) {
const getGenderText = (gender: number) => { return (
switch (gender) { <Layout>
case 1: <div style={{ textAlign: "center", padding: "64px 0" }}>
return "男"; <Spin size="large" />
case 2: </div>
return "女"; </Layout>
default: );
return "未知"; }
} if (!user) {
}; return (
<Layout>
const getGenderColor = (gender: number) => { <div style={{ textAlign: "center", color: "#aaa", padding: "64px 0" }}>
switch (gender) {
case 1: </div>
return "#1677ff"; </Layout>
case 2: );
return "#eb2f96"; }
default:
return "#999"; return (
} <Layout
}; header={
<div
const getRestrictionLevelText = (level: number) => { style={{
switch (level) { display: "flex",
case 1: alignItems: "center",
return "轻微"; height: 48,
case 2: borderBottom: "1px solid #eee",
return "中等"; background: "#fff",
case 3: }}
return "严重"; >
default: <Button
return "未知"; type="link"
} onClick={() => navigate(-1)}
}; style={{ marginRight: 8 }}
>
const getRestrictionLevelColor = (level: number) => { &lt;
switch (level) { </Button>
case 1: <div style={{ fontWeight: 600, fontSize: 18 }}></div>
return "warning"; </div>
case 2: }
return "danger"; >
case 3: <div style={{ padding: 16 }}>
return "danger"; {/* 顶部信息 */}
default: <div
return "default"; style={{
} display: "flex",
}; alignItems: "center",
gap: 16,
const formatDate = (timestamp: number | null) => { marginBottom: 16,
if (!timestamp) return "--"; }}
try { >
const date = new Date(timestamp * 1000); <Avatar src={user.avatar} size={64} />
return date.toLocaleDateString("zh-CN"); <div>
} catch (error) { <div style={{ fontSize: 20, fontWeight: 600 }}>{user.nickname}</div>
return "--"; <div style={{ color: "#1677ff", fontSize: 14, margin: "4px 0" }}>
} {user.wechatId}
}; </div>
{user.packages &&
const formatAccountAge = (dateString: string) => { user.packages.length > 0 &&
if (!dateString) return "--"; user.packages.map(pkg => (
try { <Tag color="purple" key={pkg} style={{ marginRight: 4 }}>
const date = new Date(dateString); {pkg}
return date.toLocaleDateString("zh-CN"); </Tag>
} catch (error) { ))}
return dateString; </div>
} </div>
}; {/* Tab栏 */}
<div
if (!user) { style={{
return ( display: "flex",
<Layout header={<NavCommon title="用户详情" />} loading={loading}> gap: 24,
<div className={styles.emptyState}> borderBottom: "1px solid #eee",
<div className={styles.emptyText}></div> marginBottom: 16,
</div> }}
</Layout> >
); {tabList.map(tab => (
} <div
key={tab.key}
return ( style={{
<Layout header={<NavCommon title="用户详情" />} loading={loading}> padding: "8px 0",
<div className={styles.container}> fontWeight: 500,
{/* 用户基本信息 */} color: activeTab === tab.key ? "#1677ff" : "#888",
<Card className={styles.userCard}> borderBottom:
<div className={styles.userInfo}> activeTab === tab.key ? "2px solid #1677ff" : "none",
<Avatar cursor: "pointer",
src={user.userInfo.avatar} fontSize: 16,
className={styles.avatar} }}
fallback={<UserOutlined />} onClick={() => setActiveTab(tab.key as any)}
/> >
<div className={styles.userDetails}> {tab.label}
<div className={styles.nickname}>{user.userInfo.nickname}</div> </div>
<div className={styles.wechatId}>{user.userInfo.wechatId}</div> ))}
<div className={styles.alias}>{user.userInfo.alias}</div> </div>
<div className={styles.tags}> {/* Tab内容 */}
<Tag {activeTab === "base" && (
color="primary" <>
fill="outline" <Card style={{ marginBottom: 16 }} title="关键信息">
className={styles.genderTag} <div style={{ display: "flex", flexWrap: "wrap", gap: 24 }}>
style={{ color: getGenderColor(user.userInfo.gender) }} <div>{user.deviceName || "--"}</div>
> <div>{user.wechatAccountName || "--"}</div>
{getGenderText(user.userInfo.gender)} <div>{user.customerServiceName || "--"}</div>
</Tag> <div>{user.addTime || "--"}</div>
{user.userInfo.weight && ( <div>{user.lastInteraction || "--"}</div>
<Tag </div>
color="success" </Card>
fill="outline" <Card style={{ marginBottom: 16 }} title="RFM评分">
className={styles.weightTag} <div style={{ display: "flex", gap: 32 }}>
> <div>
: {user.userInfo.weight} <div
</Tag> style={{ fontSize: 20, fontWeight: 600, color: "#1677ff" }}
)} >
</div> {user.rfmScore?.recency ?? "-"}
</div> </div>
</div> <div style={{ fontSize: 12, color: "#888" }}>(R)</div>
</Card> </div>
<div>
{/* Tab内容 */} <div
<Tabs className={styles.tabs}> style={{ fontSize: 20, fontWeight: 600, color: "#52c41a" }}
<Tabs.Tab title="基本信息" key="base"> >
<div className={styles.tabContent}> {user.rfmScore?.frequency ?? "-"}
{/* 账户信息 */} </div>
<Card title="账户信息" className={styles.infoCard}> <div style={{ fontSize: 12, color: "#888" }}>(F)</div>
<List> </div>
<List.Item extra={formatAccountAge(user.accountAge)}> <div>
<div
</List.Item> style={{ fontSize: 20, fontWeight: 600, color: "#eb2f96" }}
<List.Item >
extra={`${user.statistics.todayAdded}/${user.statistics.addLimit}`} {user.rfmScore?.monetary ?? "-"}
> </div>
<div style={{ fontSize: 12, color: "#888" }}>(M)</div>
</List.Item> </div>
<List.Item extra={user.activityLevel.allTimes}> </div>
</Card>
</List.Item> <Card style={{ marginBottom: 16 }} title="统计数据">
<List.Item extra={user.activityLevel.dayTimes}> <div style={{ display: "flex", gap: 32 }}>
<div>
</List.Item> <div
</List> style={{ fontSize: 18, fontWeight: 600, color: "#52c41a" }}
</Card> >
¥{user.totalSpent ?? "-"}
{/* 好友统计 */} </div>
<Card title="好友统计" className={styles.infoCard}> <div style={{ fontSize: 12, color: "#888" }}></div>
<div className={styles.statsGrid}> </div>
<div className={styles.statItem}> <div>
<div <div
className={styles.statValue} style={{ fontSize: 18, fontWeight: 600, color: "#1677ff" }}
style={{ color: "#1677ff" }} >
> {user.interactionCount ?? "-"}
{user.userInfo.friendShip.totalFriend} </div>
</div> <div style={{ fontSize: 12, color: "#888" }}></div>
<div className={styles.statLabel}></div> </div>
</div> <div>
<div className={styles.statItem}> <div
<div style={{ fontSize: 18, fontWeight: 600, color: "#faad14" }}
className={styles.statValue} >
style={{ color: "#1677ff" }} {user.conversionRate ?? "-"}
> </div>
{user.userInfo.friendShip.maleFriend} <div style={{ fontSize: 12, color: "#888" }}></div>
</div> </div>
<div className={styles.statLabel}></div> <div>
</div> <div
<div className={styles.statItem}> style={{ fontSize: 18, fontWeight: 600, color: "#ff4d4f" }}
<div >
className={styles.statValue} {user.status === "failed"
style={{ color: "#eb2f96" }} ? "添加失败"
> : user.status === "added"
{user.userInfo.friendShip.femaleFriend} ? "添加成功"
</div> : "未添加"}
<div className={styles.statLabel}></div> </div>
</div> <div style={{ fontSize: 12, color: "#888" }}></div>
<div className={styles.statItem}> </div>
<div className={styles.statValue} style={{ color: "#999" }}> </div>
{user.userInfo.friendShip.unknowFriend} </Card>
</div> </>
<div className={styles.statLabel}></div> )}
</div> {activeTab === "journey" && (
<div className={styles.statItem}> <Card title="互动记录">
<div {user.interactions && user.interactions.length > 0 ? (
className={styles.statValue} user.interactions.slice(0, 4).map(it => (
style={{ color: "#52c41a" }} <div
> key={it.id}
{user.userInfo.friendShip.groupNumber} style={{
</div> display: "flex",
<div className={styles.statLabel}></div> alignItems: "center",
</div> gap: 12,
</div> borderBottom: "1px solid #f0f0f0",
</Card> padding: "12px 0",
}}
{/* 活跃度统计 */} >
<Card title="活跃度统计" className={styles.infoCard}> <div style={{ fontSize: 22 }}>
<div className={styles.statsGrid}> {it.type === "click" && "📱"}
<div className={styles.statItem}> {it.type === "message" && "💬"}
<div {it.type === "purchase" && "💲"}
className={styles.statValue} {it.type === "view" && "👁️"}
style={{ color: "#52c41a" }} </div>
> <div style={{ flex: 1 }}>
{user.userInfo.activity.totalMsgCount} <div style={{ fontWeight: 500 }}>
</div> {it.type === "click" && "点击行为"}
<div className={styles.statLabel}></div> {it.type === "message" && "消息互动"}
</div> {it.type === "purchase" && "购买行为"}
<div className={styles.statItem}> {it.type === "view" && "页面浏览"}
<div </div>
className={styles.statValue} <div style={{ color: "#888", fontSize: 13 }}>
style={{ color: "#faad14" }} {it.content}
> {it.type === "purchase" && it.value && (
{user.userInfo.activity.sevenDayMsgCount} <span
</div> style={{
<div className={styles.statLabel}>7</div> color: "#52c41a",
</div> fontWeight: 600,
<div className={styles.statItem}> marginLeft: 4,
<div }}
className={styles.statValue} >
style={{ color: "#722ed1" }} ¥{it.value}
> </span>
{user.userInfo.activity.thirtyDayMsgCount} )}
</div> </div>
<div className={styles.statLabel}>30</div> </div>
</div> <div
<div className={styles.statItem}> style={{
<div fontSize: 12,
className={styles.statValue} color: "#aaa",
style={{ color: "#13c2c2" }} whiteSpace: "nowrap",
> }}
{user.userInfo.activity.yesterdayMsgCount} >
</div> {it.timestamp}
<div className={styles.statLabel}></div> </div>
</div> </div>
</div> ))
</Card> ) : (
<div
{/* 账户权重 */} style={{
<Card title="账户权重" className={styles.infoCard}> color: "#aaa",
<div className={styles.statsGrid}> textAlign: "center",
<div className={styles.statItem}> padding: "24px 0",
<div }}
className={styles.statValue} >
style={{ color: "#1677ff" }}
> </div>
{user.accountWeight.ageWeight} )}
</div> </Card>
<div className={styles.statLabel}></div> )}
</div> {activeTab === "tags" && (
<div className={styles.statItem}> <Card title="用户标签">
<div <div style={{ marginBottom: 12 }}>
className={styles.statValue} {user.tags && user.tags.length > 0 ? (
style={{ color: "#52c41a" }} user.tags.map(tag => (
> <Tag
{user.accountWeight.activityWeigth} key={tag}
</div> color="blue"
<div className={styles.statLabel}></div> style={{ marginRight: 8, marginBottom: 8 }}
</div> >
<div className={styles.statItem}> {tag}
<div </Tag>
className={styles.statValue} ))
style={{ color: "#faad14" }} ) : (
> <span style={{ color: "#aaa" }}></span>
{user.accountWeight.restrictWeight} )}
</div> </div>
<div className={styles.statLabel}></div> <Button type="dashed" block>
</div>
<div className={styles.statItem}> </Button>
<div </Card>
className={styles.statValue} )}
style={{ color: "#722ed1" }} </div>
> </Layout>
{user.accountWeight.realNameWeight} );
</div> };
<div className={styles.statLabel}></div>
</div> export default TrafficPoolDetail;
<div className={styles.statItem}>
<div
className={styles.statValue}
style={{ color: "#13c2c2" }}
>
{user.accountWeight.scope}
</div>
<div className={styles.statLabel}></div>
</div>
</div>
</Card>
</div>
</Tabs.Tab>
<Tabs.Tab title="限制记录" key="restrictions">
<div className={styles.tabContent}>
<Card title="限制记录" className={styles.infoCard}>
{user.restrictions && user.restrictions.length > 0 ? (
<List>
{user.restrictions.map((restriction) => (
<List.Item
key={restriction.id}
prefix={
<ExclamationCircleOutlined
style={{ color: "#ff4d4f" }}
/>
}
title={
<div className={styles.restrictionTitle}>
<span>{restriction.reason || "未知原因"}</span>
<Tag
color={getRestrictionLevelColor(
restriction.level
)}
fill="outline"
className={styles.restrictionLevel}
>
{getRestrictionLevelText(restriction.level)}
</Tag>
</div>
}
description={
<div className={styles.restrictionContent}>
<span>ID: {restriction.id}</span>
{restriction.date && (
<span>
: {formatDate(restriction.date)}
</span>
)}
</div>
}
/>
))}
</List>
) : (
<div className={styles.emptyState}>
<div className={styles.emptyText}></div>
</div>
)}
</Card>
</div>
</Tabs.Tab>
<Tabs.Tab title="操作记录" key="actions">
<div className={styles.tabContent}>
<Card title="操作记录" className={styles.infoCard}>
<div className={styles.emptyState}>
<div className={styles.emptyText}></div>
</div>
</Card>
</div>
</Tabs.Tab>
</Tabs>
</div>
</Layout>
);
};
export default TrafficPoolDetail;

View File

@@ -1,47 +1,47 @@
import React from "react"; import React from "react";
import { Modal, Selector } from "antd-mobile"; import { Modal, Selector } from "antd-mobile";
import type { PackageOption } from "./data"; import type { PackageOption } from "./data";
interface BatchAddModalProps { interface BatchAddModalProps {
visible: boolean; visible: boolean;
onClose: () => void; onClose: () => void;
packageOptions: PackageOption[]; packageOptions: PackageOption[];
batchTarget: string; batchTarget: string;
setBatchTarget: (v: string) => void; setBatchTarget: (v: string) => void;
selectedCount: number; selectedCount: number;
onConfirm: () => void; onConfirm: () => void;
} }
const BatchAddModal: React.FC<BatchAddModalProps> = ({ const BatchAddModal: React.FC<BatchAddModalProps> = ({
visible, visible,
onClose, onClose,
packageOptions, packageOptions,
batchTarget, batchTarget,
setBatchTarget, setBatchTarget,
selectedCount, selectedCount,
onConfirm, onConfirm,
}) => ( }) => (
<Modal <Modal
visible={visible} visible={visible}
title="批量加入分组" title="批量加入分组"
onClose={onClose} onClose={onClose}
footer={[ footer={[
{ text: "取消", onClick: onClose }, { text: "取消", onClick: onClose },
{ text: "确定", onClick: onConfirm }, { text: "确定", onClick: onConfirm },
]} ]}
> >
<div style={{ marginBottom: 12 }}> <div style={{ marginBottom: 12 }}>
<div></div> <div></div>
<Selector <Selector
options={packageOptions.map((p) => ({ label: p.name, value: p.id }))} options={packageOptions.map(p => ({ label: p.name, value: p.id }))}
value={[batchTarget]} value={[batchTarget]}
onChange={(v) => setBatchTarget(v[0])} onChange={v => setBatchTarget(v[0])}
/> />
</div> </div>
<div style={{ color: "#888", fontSize: 13 }}> <div style={{ color: "#888", fontSize: 13 }}>
{selectedCount} {selectedCount}
</div> </div>
</Modal> </Modal>
); );
export default BatchAddModal; export default BatchAddModal;

View File

@@ -1,84 +1,84 @@
import React from "react"; import React from "react";
import { Card, Button } from "antd-mobile"; import { Card, Button } from "antd-mobile";
interface DataAnalysisPanelProps { interface DataAnalysisPanelProps {
stats: { stats: {
total: number; total: number;
highValue: number; highValue: number;
added: number; added: number;
pending: number; pending: number;
failed: number; failed: number;
addSuccessRate: number; addSuccessRate: number;
}; };
showStats: boolean; showStats: boolean;
setShowStats: (v: boolean) => void; setShowStats: (v: boolean) => void;
} }
const DataAnalysisPanel: React.FC<DataAnalysisPanelProps> = ({ const DataAnalysisPanel: React.FC<DataAnalysisPanelProps> = ({
stats, stats,
showStats, showStats,
setShowStats, setShowStats,
}) => { }) => {
if (!showStats) return null; if (!showStats) return null;
return ( return (
<div <div
style={{ style={{
background: "#fff", background: "#fff",
padding: "16px", padding: "16px",
margin: "8px 0", margin: "8px 0",
borderRadius: 8, borderRadius: 8,
boxShadow: "0 2px 8px rgba(0,0,0,0.04)", boxShadow: "0 2px 8px rgba(0,0,0,0.04)",
}} }}
> >
<div style={{ display: "flex", gap: 16, marginBottom: 12 }}> <div style={{ display: "flex", gap: 16, marginBottom: 12 }}>
<Card style={{ flex: 1 }}> <Card style={{ flex: 1 }}>
<div style={{ fontSize: 18, fontWeight: 600, color: "#1677ff" }}> <div style={{ fontSize: 18, fontWeight: 600, color: "#1677ff" }}>
{stats.total} {stats.total}
</div> </div>
<div style={{ fontSize: 13, color: "#888" }}></div> <div style={{ fontSize: 13, color: "#888" }}></div>
</Card> </Card>
<Card style={{ flex: 1 }}> <Card style={{ flex: 1 }}>
<div style={{ fontSize: 18, fontWeight: 600, color: "#eb2f96" }}> <div style={{ fontSize: 18, fontWeight: 600, color: "#eb2f96" }}>
{stats.highValue} {stats.highValue}
</div> </div>
<div style={{ fontSize: 13, color: "#888" }}></div> <div style={{ fontSize: 13, color: "#888" }}></div>
</Card> </Card>
</div> </div>
<div style={{ display: "flex", gap: 16 }}> <div style={{ display: "flex", gap: 16 }}>
<Card style={{ flex: 1 }}> <Card style={{ flex: 1 }}>
<div style={{ fontSize: 18, fontWeight: 600, color: "#52c41a" }}> <div style={{ fontSize: 18, fontWeight: 600, color: "#52c41a" }}>
{stats.addSuccessRate}% {stats.addSuccessRate}%
</div> </div>
<div style={{ fontSize: 13, color: "#888" }}></div> <div style={{ fontSize: 13, color: "#888" }}></div>
</Card> </Card>
<Card style={{ flex: 1 }}> <Card style={{ flex: 1 }}>
<div style={{ fontSize: 18, fontWeight: 600, color: "#faad14" }}> <div style={{ fontSize: 18, fontWeight: 600, color: "#faad14" }}>
{stats.added} {stats.added}
</div> </div>
<div style={{ fontSize: 13, color: "#888" }}></div> <div style={{ fontSize: 13, color: "#888" }}></div>
</Card> </Card>
<Card style={{ flex: 1 }}> <Card style={{ flex: 1 }}>
<div style={{ fontSize: 18, fontWeight: 600, color: "#bfbfbf" }}> <div style={{ fontSize: 18, fontWeight: 600, color: "#bfbfbf" }}>
{stats.pending} {stats.pending}
</div> </div>
<div style={{ fontSize: 13, color: "#888" }}></div> <div style={{ fontSize: 13, color: "#888" }}></div>
</Card> </Card>
<Card style={{ flex: 1 }}> <Card style={{ flex: 1 }}>
<div style={{ fontSize: 18, fontWeight: 600, color: "#ff4d4f" }}> <div style={{ fontSize: 18, fontWeight: 600, color: "#ff4d4f" }}>
{stats.failed} {stats.failed}
</div> </div>
<div style={{ fontSize: 13, color: "#888" }}></div> <div style={{ fontSize: 13, color: "#888" }}></div>
</Card> </Card>
</div> </div>
<Button <Button
size="small" size="small"
style={{ marginTop: 12 }} style={{ marginTop: 12 }}
onClick={() => setShowStats(false)} onClick={() => setShowStats(false)}
> >
</Button> </Button>
</div> </div>
); );
}; };
export default DataAnalysisPanel; export default DataAnalysisPanel;

View File

@@ -1,118 +1,118 @@
import React from "react"; import React from "react";
import { Popup } from "antd-mobile"; import { Popup } from "antd-mobile";
import { Select, Button } from "antd"; import { Select, Button } from "antd";
import type { import type {
DeviceOption, DeviceOption,
PackageOption, PackageOption,
ValueLevel, ValueLevel,
UserStatus, UserStatus,
} from "./data"; } from "./data";
interface FilterModalProps { interface FilterModalProps {
visible: boolean; visible: boolean;
onClose: () => void; onClose: () => void;
deviceOptions: DeviceOption[]; deviceOptions: DeviceOption[];
packageOptions: PackageOption[]; packageOptions: PackageOption[];
deviceId: string; deviceId: string;
setDeviceId: (v: string) => void; setDeviceId: (v: string) => void;
packageId: string; packageId: string;
setPackageId: (v: string) => void; setPackageId: (v: string) => void;
valueLevel: ValueLevel; valueLevel: ValueLevel;
setValueLevel: (v: ValueLevel) => void; setValueLevel: (v: ValueLevel) => void;
userStatus: UserStatus; userStatus: UserStatus;
setUserStatus: (v: UserStatus) => void; setUserStatus: (v: UserStatus) => void;
onReset: () => void; onReset: () => void;
} }
const valueLevelOptions = [ const valueLevelOptions = [
{ label: "全部价值", value: "all" }, { label: "全部价值", value: "all" },
{ label: "高价值", value: "high" }, { label: "高价值", value: "high" },
{ label: "中价值", value: "medium" }, { label: "中价值", value: "medium" },
{ label: "低价值", value: "low" }, { label: "低价值", value: "low" },
]; ];
const statusOptions = [ const statusOptions = [
{ label: "全部状态", value: "all" }, { label: "全部状态", value: "all" },
{ label: "已添加", value: "added" }, { label: "已添加", value: "added" },
{ label: "待添加", value: "pending" }, { label: "待添加", value: "pending" },
{ label: "添加失败", value: "failed" }, { label: "添加失败", value: "failed" },
{ label: "重复", value: "duplicate" }, { label: "重复", value: "duplicate" },
]; ];
const FilterModal: React.FC<FilterModalProps> = ({ const FilterModal: React.FC<FilterModalProps> = ({
visible, visible,
onClose, onClose,
deviceOptions, deviceOptions,
packageOptions, packageOptions,
deviceId, deviceId,
setDeviceId, setDeviceId,
packageId, packageId,
setPackageId, setPackageId,
valueLevel, valueLevel,
setValueLevel, setValueLevel,
userStatus, userStatus,
setUserStatus, setUserStatus,
onReset, onReset,
}) => ( }) => (
<Popup <Popup
visible={visible} visible={visible}
onMaskClick={onClose} onMaskClick={onClose}
position="right" position="right"
bodyStyle={{ width: "80vw", maxWidth: 360, padding: 24 }} bodyStyle={{ width: "80vw", maxWidth: 360, padding: 24 }}
> >
<div style={{ fontWeight: 600, fontSize: 18, marginBottom: 20 }}> <div style={{ fontWeight: 600, fontSize: 18, marginBottom: 20 }}>
</div> </div>
<div style={{ marginBottom: 20 }}> <div style={{ marginBottom: 20 }}>
<div style={{ marginBottom: 6 }}></div> <div style={{ marginBottom: 6 }}></div>
<Select <Select
style={{ width: "100%" }} style={{ width: "100%" }}
value={deviceId} value={deviceId}
onChange={setDeviceId} onChange={setDeviceId}
options={[ options={[
{ label: "全部设备", value: "all" }, { label: "全部设备", value: "all" },
...deviceOptions.map((d) => ({ label: d.name, value: d.id })), ...deviceOptions.map(d => ({ label: d.name, value: d.id })),
]} ]}
/> />
</div> </div>
<div style={{ marginBottom: 20 }}> <div style={{ marginBottom: 20 }}>
<div style={{ marginBottom: 6 }}></div> <div style={{ marginBottom: 6 }}></div>
<Select <Select
style={{ width: "100%" }} style={{ width: "100%" }}
value={packageId} value={packageId}
onChange={setPackageId} onChange={setPackageId}
options={[ options={[
{ label: "全部流量池", value: "all" }, { label: "全部流量池", value: "all" },
...packageOptions.map((p) => ({ label: p.name, value: p.id })), ...packageOptions.map(p => ({ label: p.name, value: p.id })),
]} ]}
/> />
</div> </div>
<div style={{ marginBottom: 20 }}> <div style={{ marginBottom: 20 }}>
<div style={{ marginBottom: 6 }}></div> <div style={{ marginBottom: 6 }}></div>
<Select <Select
style={{ width: "100%" }} style={{ width: "100%" }}
value={valueLevel} value={valueLevel}
onChange={(v) => setValueLevel(v as ValueLevel)} onChange={v => setValueLevel(v as ValueLevel)}
options={valueLevelOptions} options={valueLevelOptions}
/> />
</div> </div>
<div style={{ marginBottom: 20 }}> <div style={{ marginBottom: 20 }}>
<div style={{ marginBottom: 6 }}></div> <div style={{ marginBottom: 6 }}></div>
<Select <Select
style={{ width: "100%" }} style={{ width: "100%" }}
value={userStatus} value={userStatus}
onChange={(v) => setUserStatus(v as UserStatus)} onChange={v => setUserStatus(v as UserStatus)}
options={statusOptions} options={statusOptions}
/> />
</div> </div>
<div style={{ display: "flex", gap: 12, marginTop: 32 }}> <div style={{ display: "flex", gap: 12, marginTop: 32 }}>
<Button onClick={onReset} style={{ flex: 1 }}> <Button onClick={onReset} style={{ flex: 1 }}>
</Button> </Button>
<Button type="primary" onClick={onClose} style={{ flex: 1 }}> <Button type="primary" onClick={onClose} style={{ flex: 1 }}>
</Button> </Button>
</div> </div>
</Popup> </Popup>
); );
export default FilterModal; export default FilterModal;

View File

@@ -1,45 +1,45 @@
// 流量池用户类型 // 流量池用户类型
export interface TrafficPoolUser { export interface TrafficPoolUser {
id: number; id: number;
identifier: string; identifier: string;
mobile: string; mobile: string;
wechatId: string; wechatId: string;
fromd: string; fromd: string;
status: number; status: number;
createTime: string; createTime: string;
companyId: number; companyId: number;
sourceId: string; sourceId: string;
type: number; type: number;
nickname: string; nickname: string;
avatar: string; avatar: string;
gender: number; gender: number;
phone: string; phone: string;
packages: string[]; packages: string[];
tags: string[]; tags: string[];
} }
// 列表响应类型 // 列表响应类型
export interface TrafficPoolUserListResponse { export interface TrafficPoolUserListResponse {
list: TrafficPoolUser[]; list: TrafficPoolUser[];
total: number; total: number;
page: number; page: number;
pageSize: number; pageSize: number;
} }
// 设备类型 // 设备类型
export interface DeviceOption { export interface DeviceOption {
id: string; id: string;
name: string; name: string;
} }
// 分组类型 // 分组类型
export interface PackageOption { export interface PackageOption {
id: string; id: string;
name: string; name: string;
} }
// 用户价值类型 // 用户价值类型
export type ValueLevel = "all" | "high" | "medium" | "low"; export type ValueLevel = "all" | "high" | "medium" | "low";
// 状态类型 // 状态类型
export type UserStatus = "all" | "added" | "pending" | "failed" | "duplicate"; export type UserStatus = "all" | "added" | "pending" | "failed" | "duplicate";

View File

@@ -1,160 +1,158 @@
import { useState, useEffect, useMemo } from "react"; import { useState, useEffect, useMemo } from "react";
import { import {
fetchTrafficPoolList, fetchTrafficPoolList,
fetchDeviceOptions, fetchDeviceOptions,
fetchPackageOptions, fetchPackageOptions,
} from "./api"; } from "./api";
import type { import type {
TrafficPoolUser, TrafficPoolUser,
DeviceOption, DeviceOption,
PackageOption, PackageOption,
ValueLevel, ValueLevel,
UserStatus, UserStatus,
} from "./data"; } from "./data";
import { Toast } from "antd-mobile"; import { Toast } from "antd-mobile";
export function useTrafficPoolListLogic() { export function useTrafficPoolListLogic() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [list, setList] = useState<TrafficPoolUser[]>([]); const [list, setList] = useState<TrafficPoolUser[]>([]);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [pageSize] = useState(10); const [pageSize] = useState(10);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
// 筛选相关 // 筛选相关
const [showFilter, setShowFilter] = useState(false); const [showFilter, setShowFilter] = useState(false);
const [deviceOptions, setDeviceOptions] = useState<DeviceOption[]>([]); const [deviceOptions, setDeviceOptions] = useState<DeviceOption[]>([]);
const [packageOptions, setPackageOptions] = useState<PackageOption[]>([]); const [packageOptions, setPackageOptions] = useState<PackageOption[]>([]);
const [deviceId, setDeviceId] = useState<string>("all"); const [deviceId, setDeviceId] = useState<string>("all");
const [packageId, setPackageId] = useState<string>("all"); const [packageId, setPackageId] = useState<string>("all");
const [valueLevel, setValueLevel] = useState<ValueLevel>("all"); const [valueLevel, setValueLevel] = useState<ValueLevel>("all");
const [userStatus, setUserStatus] = useState<UserStatus>("all"); const [userStatus, setUserStatus] = useState<UserStatus>("all");
// 批量相关 // 批量相关
const [selectedIds, setSelectedIds] = useState<number[]>([]); const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [batchModal, setBatchModal] = useState(false); const [batchModal, setBatchModal] = useState(false);
const [batchTarget, setBatchTarget] = useState<string>(""); const [batchTarget, setBatchTarget] = useState<string>("");
// 数据分析 // 数据分析
const [showStats, setShowStats] = useState(false); const [showStats, setShowStats] = useState(false);
const stats = useMemo(() => { const stats = useMemo(() => {
const total = list.length; const total = list.length;
const highValue = list.filter((u) => const highValue = list.filter(u => u.tags.includes("高价值客户池")).length;
u.tags.includes("高价值客户池") const added = list.filter(u => u.status === 1).length;
).length; const pending = list.filter(u => u.status === 0).length;
const added = list.filter((u) => u.status === 1).length; const failed = list.filter(u => u.status === -1).length;
const pending = list.filter((u) => u.status === 0).length; const addSuccessRate = total ? Math.round((added / total) * 100) : 0;
const failed = list.filter((u) => u.status === -1).length; return { total, highValue, added, pending, failed, addSuccessRate };
const addSuccessRate = total ? Math.round((added / total) * 100) : 0; }, [list]);
return { total, highValue, added, pending, failed, addSuccessRate };
}, [list]); // 获取列表
const getList = async () => {
// 获取列表 setLoading(true);
const getList = async () => { try {
setLoading(true); const res = await fetchTrafficPoolList({
try { page,
const res = await fetchTrafficPoolList({ pageSize,
page, keyword: search,
pageSize, // deviceId,
keyword: search, // packageId,
// deviceId, // valueLevel,
// packageId, // userStatus,
// valueLevel, });
// userStatus, setList(res.list || []);
}); setTotal(res.total || 0);
setList(res.list || []); } finally {
setTotal(res.total || 0); setLoading(false);
} finally { }
setLoading(false); };
}
}; // 获取筛选项
useEffect(() => {
// 获取筛选项 fetchDeviceOptions().then(setDeviceOptions);
useEffect(() => { fetchPackageOptions().then(setPackageOptions);
fetchDeviceOptions().then(setDeviceOptions); }, []);
fetchPackageOptions().then(setPackageOptions);
}, []); // 筛选条件变化时刷新列表
useEffect(() => {
// 筛选条件变化时刷新列表 getList();
useEffect(() => { // eslint-disable-next-line
getList(); }, [page, search /*, deviceId, packageId, valueLevel, userStatus*/]);
// eslint-disable-next-line
}, [page, search /*, deviceId, packageId, valueLevel, userStatus*/]); // 全选/反选
const handleSelectAll = (checked: boolean) => {
// 全选/反选 if (checked) {
const handleSelectAll = (checked: boolean) => { setSelectedIds(list.map(item => item.id));
if (checked) { } else {
setSelectedIds(list.map((item) => item.id)); setSelectedIds([]);
} else { }
setSelectedIds([]); };
} // 单选
}; const handleSelect = (id: number, checked: boolean) => {
// 单选 setSelectedIds(prev =>
const handleSelect = (id: number, checked: boolean) => { checked ? [...prev, id] : prev.filter(i => i !== id)
setSelectedIds((prev) => );
checked ? [...prev, id] : prev.filter((i) => i !== id) };
);
}; // 批量加入分组/流量池
const handleBatchAdd = () => {
// 批量加入分组/流量池 if (!batchTarget) {
const handleBatchAdd = () => { Toast.show({ content: "请选择目标分组", position: "top" });
if (!batchTarget) { return;
Toast.show({ content: "请选择目标分组", position: "top" }); }
return; // TODO: 调用后端批量接口,这里仅模拟
} Toast.show({
// TODO: 调用后端批量接口,这里仅模拟 content: `已将${selectedIds.length}个用户加入${packageOptions.find(p => p.id === batchTarget)?.name || ""}`,
Toast.show({ position: "top",
content: `已将${selectedIds.length}个用户加入${packageOptions.find((p) => p.id === batchTarget)?.name || ""}`, });
position: "top", setBatchModal(false);
}); setSelectedIds([]);
setBatchModal(false); setBatchTarget("");
setSelectedIds([]); // 可刷新列表
setBatchTarget(""); };
// 可刷新列表
}; // 筛选重置
const resetFilter = () => {
// 筛选重置 setDeviceId("all");
const resetFilter = () => { setPackageId("all");
setDeviceId("all"); setValueLevel("all");
setPackageId("all"); setUserStatus("all");
setValueLevel("all"); };
setUserStatus("all");
}; return {
loading,
return { list,
loading, page,
list, setPage,
page, pageSize,
setPage, total,
pageSize, search,
total, setSearch,
search, showFilter,
setSearch, setShowFilter,
showFilter, deviceOptions,
setShowFilter, packageOptions,
deviceOptions, deviceId,
packageOptions, setDeviceId,
deviceId, packageId,
setDeviceId, setPackageId,
packageId, valueLevel,
setPackageId, setValueLevel,
valueLevel, userStatus,
setValueLevel, setUserStatus,
userStatus, selectedIds,
setUserStatus, setSelectedIds,
selectedIds, handleSelectAll,
setSelectedIds, handleSelect,
handleSelectAll, batchModal,
handleSelect, setBatchModal,
batchModal, batchTarget,
setBatchModal, setBatchTarget,
batchTarget, handleBatchAdd,
setBatchTarget, showStats,
handleBatchAdd, setShowStats,
showStats, stats,
setShowStats, getList,
stats, resetFilter,
getList, };
resetFilter, }
};
}

View File

@@ -1,23 +1,23 @@
.listWrap { .listWrap {
padding: 12px; padding: 12px;
} }
.cardContent{ .cardContent {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
position: relative; position: relative;
} }
.checkbox{ .checkbox {
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
} }
.cardWrap{ .cardWrap {
background: #fff; background: #fff;
padding: 16px; padding: 16px;
border-radius: 8px; 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; margin-bottom: 12px;
} }

View File

@@ -79,7 +79,7 @@ const TrafficPoolList: React.FC = () => {
title="流量池用户列表" title="流量池用户列表"
right={ right={
<Button <Button
onClick={() => setShowStats((s) => !s)} onClick={() => setShowStats(s => !s)}
style={{ marginLeft: 8 }} style={{ marginLeft: 8 }}
> >
<BarChartOutlined /> {showStats ? "收起分析" : "数据分析"} <BarChartOutlined /> {showStats ? "收起分析" : "数据分析"}
@@ -92,7 +92,7 @@ const TrafficPoolList: React.FC = () => {
<Input <Input
placeholder="搜索计划名称" placeholder="搜索计划名称"
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={e => setSearch(e.target.value)}
prefix={<SearchOutlined />} prefix={<SearchOutlined />}
allowClear allowClear
size="large" size="large"
@@ -124,7 +124,7 @@ const TrafficPoolList: React.FC = () => {
> >
<Checkbox <Checkbox
checked={selectedIds.length === list.length && list.length > 0} checked={selectedIds.length === list.length && list.length > 0}
onChange={(e) => handleSelectAll(e.target.checked)} onChange={e => handleSelectAll(e.target.checked)}
style={{ marginRight: 8 }} style={{ marginRight: 8 }}
/> />
<span></span> <span></span>
@@ -186,7 +186,7 @@ const TrafficPoolList: React.FC = () => {
<Empty description="暂无数据" /> <Empty description="暂无数据" />
) : ( ) : (
<div> <div>
{list.map((item) => ( {list.map(item => (
<div key={item.id} className={styles.cardWrap}> <div key={item.id} className={styles.cardWrap}>
<div <div
className={styles.card} className={styles.card}
@@ -198,9 +198,9 @@ const TrafficPoolList: React.FC = () => {
<div className={styles.cardContent}> <div className={styles.cardContent}>
<Checkbox <Checkbox
checked={selectedIds.includes(item.id)} checked={selectedIds.includes(item.id)}
onChange={(e) => handleSelect(item.id, e.target.checked)} onChange={e => handleSelect(item.id, e.target.checked)}
style={{ marginRight: 8 }} style={{ marginRight: 8 }}
onClick={(e) => e.stopPropagation()} onClick={e => e.stopPropagation()}
className={styles.checkbox} className={styles.checkbox}
/> />
<Avatar <Avatar

View File

@@ -1,115 +1,115 @@
.user-set-page { .user-set-page {
background: #f7f8fa; background: #f7f8fa;
} }
.user-card { .user-card {
margin: 18px 16px 0 16px; margin: 18px 16px 0 16px;
border-radius: 14px; border-radius: 14px;
box-shadow: 0 2px 8px rgba(0,0,0,0.06); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
} }
.user-info { .user-info {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
padding: 24px 20px 20px 20px; padding: 24px 20px 20px 20px;
} }
.avatar { .avatar {
width: 64px; width: 64px;
height: 64px; height: 64px;
border-radius: 50%; border-radius: 50%;
background: #f5f5f5; background: #f5f5f5;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 30px; font-size: 30px;
font-weight: 700; font-weight: 700;
color: #1890ff; color: #1890ff;
margin-right: 22px; margin-right: 22px;
overflow: hidden; overflow: hidden;
} }
.avatar-placeholder { .avatar-placeholder {
width: 100%; width: 100%;
height: 100%; height: 100%;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 30px; font-size: 30px;
font-weight: 700; font-weight: 700;
color: #1890ff; color: #1890ff;
background: #e6f7ff; background: #e6f7ff;
border-radius: 50%; border-radius: 50%;
} }
.info-list { .info-list {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 14px; gap: 14px;
} }
.info-item { .info-item {
display: flex; display: flex;
align-items: center; align-items: center;
font-size: 16px; font-size: 16px;
} }
.label { .label {
color: #888; color: #888;
min-width: 70px; min-width: 70px;
font-size: 15px; font-size: 15px;
} }
.value { .value {
color: #222; color: #222;
font-weight: 500; font-weight: 500;
font-size: 16px; font-size: 16px;
margin-left: 8px; margin-left: 8px;
word-break: break-all; word-break: break-all;
} }
.avatar-upload { .avatar-upload {
position: relative; position: relative;
cursor: pointer; cursor: pointer;
width: 64px; width: 64px;
height: 64px; height: 64px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border-radius: 50%; border-radius: 50%;
overflow: hidden; overflow: hidden;
background: #f5f5f5; background: #f5f5f5;
transition: box-shadow 0.2s; transition: box-shadow 0.2s;
box-shadow: 0 2px 8px rgba(0,0,0,0.04); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
} }
.avatar-upload img { .avatar-upload img {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
border-radius: 50%; border-radius: 50%;
} }
.avatar-edit { .avatar-edit {
position: absolute; position: absolute;
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
background: rgba(0,0,0,0.45); background: rgba(0, 0, 0, 0.45);
color: #fff; color: #fff;
font-size: 13px; font-size: 13px;
text-align: center; text-align: center;
padding: 3px 0 2px 0; padding: 3px 0 2px 0;
border-radius: 0 0 32px 32px; border-radius: 0 0 32px 32px;
opacity: 0; opacity: 0;
transition: opacity 0.2s; transition: opacity 0.2s;
pointer-events: none; pointer-events: none;
} }
.avatar-upload:hover .avatar-edit { .avatar-upload:hover .avatar-edit {
opacity: 1; opacity: 1;
pointer-events: auto; pointer-events: auto;
} }
.edit-input { .edit-input {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
font-size: 16px; font-size: 16px;
border-radius: 8px; border-radius: 8px;
border: 1px solid #e5e6eb; border: 1px solid #e5e6eb;
padding: 4px 10px; padding: 4px 10px;
background: #fafbfc; background: #fafbfc;
} }
.save-btn { .save-btn {
padding: 12px; padding: 12px;
background: #fff; background: #fff;
} }

View File

@@ -1,113 +1,113 @@
import React, { useRef, useState } from "react"; import React, { useRef, useState } from "react";
import { useUserStore } from "@/store/module/user"; import { useUserStore } from "@/store/module/user";
import { Card, Button, Input, Toast } from "antd-mobile"; import { Card, Button, Input, Toast } from "antd-mobile";
import style from "./index.module.scss"; import style from "./index.module.scss";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import Layout from "@/components/Layout/Layout"; import Layout from "@/components/Layout/Layout";
import NavCommon from "@/components/NavCommon"; import NavCommon from "@/components/NavCommon";
const UserSetting: React.FC = () => { const UserSetting: React.FC = () => {
const { user, setUser } = useUserStore(); const { user, setUser } = useUserStore();
const navigate = useNavigate(); const navigate = useNavigate();
const [nickname, setNickname] = useState(user?.username || ""); const [nickname, setNickname] = useState(user?.username || "");
const [avatar, setAvatar] = useState(user?.avatar || ""); const [avatar, setAvatar] = useState(user?.avatar || "");
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
// 头像上传 // 头像上传
const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return; if (!file) return;
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (ev) => { reader.onload = ev => {
setAvatar(ev.target?.result as string); setAvatar(ev.target?.result as string);
}; };
reader.readAsDataURL(file); reader.readAsDataURL(file);
}; };
// 保存 // 保存
const handleSave = async () => { const handleSave = async () => {
if (!nickname.trim()) { if (!nickname.trim()) {
Toast.show({ content: "昵称不能为空", position: "top" }); Toast.show({ content: "昵称不能为空", position: "top" });
return; return;
} }
if (!user) return; if (!user) return;
setUser({ ...user, id: user.id, username: nickname, avatar }); setUser({ ...user, id: user.id, username: nickname, avatar });
Toast.show({ content: "保存成功", position: "top" }); Toast.show({ content: "保存成功", position: "top" });
navigate(-1); navigate(-1);
}; };
return ( return (
<Layout <Layout
header={<NavCommon title="用户信息修改" />} header={<NavCommon title="用户信息修改" />}
footer={ footer={
<div className={style["save-btn"]}> <div className={style["save-btn"]}>
<Button <Button
block block
color="primary" color="primary"
onClick={handleSave} onClick={handleSave}
loading={uploading} loading={uploading}
> >
</Button> </Button>
</div> </div>
} }
> >
<div className={style["user-set-page"]}> <div className={style["user-set-page"]}>
<Card className={style["user-card"]}> <Card className={style["user-card"]}>
<div className={style["user-info"]}> <div className={style["user-info"]}>
<div className={style["avatar"]}> <div className={style["avatar"]}>
<div <div
className={style["avatar-upload"]} className={style["avatar-upload"]}
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
> >
{avatar ? ( {avatar ? (
<img src={avatar} alt="头像" /> <img src={avatar} alt="头像" />
) : ( ) : (
<div className={style["avatar-placeholder"]}></div> <div className={style["avatar-placeholder"]}></div>
)} )}
<div className={style["avatar-edit"]}></div> <div className={style["avatar-edit"]}></div>
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
accept="image/*" accept="image/*"
style={{ display: "none" }} style={{ display: "none" }}
onChange={handleAvatarChange} onChange={handleAvatarChange}
disabled={uploading} disabled={uploading}
/> />
</div> </div>
</div> </div>
<div className={style["info-list"]}> <div className={style["info-list"]}>
<div className={style["info-item"]}> <div className={style["info-item"]}>
<span className={style["label"]}></span> <span className={style["label"]}></span>
<Input <Input
className={style["edit-input"]} className={style["edit-input"]}
value={nickname} value={nickname}
onChange={setNickname} onChange={setNickname}
maxLength={12} maxLength={12}
placeholder="请输入昵称" placeholder="请输入昵称"
/> />
</div> </div>
<div className={style["info-item"]}> <div className={style["info-item"]}>
<span className={style["label"]}></span> <span className={style["label"]}></span>
<span className={style["value"]}>{user?.phone || "-"}</span> <span className={style["value"]}>{user?.phone || "-"}</span>
</div> </div>
<div className={style["info-item"]}> <div className={style["info-item"]}>
<span className={style["label"]}></span> <span className={style["label"]}></span>
<span className={style["value"]}>{user?.account || "-"}</span> <span className={style["value"]}>{user?.account || "-"}</span>
</div> </div>
<div className={style["info-item"]}> <div className={style["info-item"]}>
<span className={style["label"]}></span> <span className={style["label"]}></span>
<span className={style["value"]}> <span className={style["value"]}>
{user?.isAdmin === 1 ? "管理员" : "普通用户"} {user?.isAdmin === 1 ? "管理员" : "普通用户"}
</span> </span>
</div> </div>
</div> </div>
</div> </div>
</Card> </Card>
</div> </div>
</Layout> </Layout>
); );
}; };
export default UserSetting; export default UserSetting;

View File

@@ -1,29 +1,29 @@
import request from "@/api/request"; import request from "@/api/request";
// 获取微信号详情 // 获取微信号详情
export function getWechatAccountDetail(id: string) { export function getWechatAccountDetail(id: string) {
return request("/v1/wechats/getWechatInfo", { wechatId: id }, "GET"); return request("/v1/wechats/getWechatInfo", { wechatId: id }, "GET");
} }
// 获取微信号好友列表 // 获取微信号好友列表
export function getWechatFriends(params: { export function getWechatFriends(params: {
wechatAccount: string; wechatAccount: string;
page: number; page: number;
limit: number; limit: number;
keyword?: string; keyword?: string;
}) { }) {
return request( return request(
`/v1/wechats/${params.wechatAccount}/friends`, `/v1/wechats/${params.wechatAccount}/friends`,
{ {
page: params.page, page: params.page,
limit: params.limit, limit: params.limit,
keyword: params.keyword, keyword: params.keyword,
}, },
"GET" "GET"
); );
} }
// 获取微信好友详情 // 获取微信好友详情
export function getWechatFriendDetail(id: string) { export function getWechatFriendDetail(id: string) {
return request("/v1/WechatFriend/detail", { id }, "GET"); return request("/v1/WechatFriend/detail", { id }, "GET");
} }

View File

@@ -1,54 +1,54 @@
export interface WechatAccountSummary { export interface WechatAccountSummary {
accountAge: string; accountAge: string;
activityLevel: { activityLevel: {
allTimes: number; allTimes: number;
dayTimes: number; dayTimes: number;
}; };
accountWeight: { accountWeight: {
scope: number; scope: number;
ageWeight: number; ageWeight: number;
activityWeigth: number; activityWeigth: number;
restrictWeight: number; restrictWeight: number;
realNameWeight: number; realNameWeight: number;
}; };
statistics: { statistics: {
todayAdded: number; todayAdded: number;
addLimit: number; addLimit: number;
}; };
restrictions: { restrictions: {
id: number; id: number;
level: string; level: string;
reason: string; reason: string;
date: string; date: string;
}[]; }[];
} }
export interface Friend { export interface Friend {
id: string; id: string;
avatar: string; avatar: string;
nickname: string; nickname: string;
wechatId: string; wechatId: string;
remark: string; remark: string;
addTime: string; addTime: string;
lastInteraction: string; lastInteraction: string;
tags: Array<{ tags: Array<{
id: string; id: string;
name: string; name: string;
color: string; color: string;
}>; }>;
region: string; region: string;
source: string; source: string;
notes: string; notes: string;
} }
export interface WechatFriendDetail { export interface WechatFriendDetail {
id: number; id: number;
avatar: string; avatar: string;
nickname: string; nickname: string;
region: string; region: string;
wechatId: string; wechatId: string;
addDate: string; addDate: string;
tags: string[]; tags: string[];
memo: string; memo: string;
source: string; source: string;
} }

View File

@@ -414,7 +414,7 @@ const WechatAccountDetail: React.FC = () => {
</div> </div>
) : ( ) : (
<> <>
{friends.map((friend) => ( {friends.map(friend => (
<div key={friend.id} className={style["friend-item"]}> <div key={friend.id} className={style["friend-item"]}>
<Avatar <Avatar
src={friend.avatar} src={friend.avatar}
@@ -490,7 +490,7 @@ const WechatAccountDetail: React.FC = () => {
<p className={style["popup-description"]}>24</p> <p className={style["popup-description"]}>24</p>
{accountSummary && accountSummary.restrictions && ( {accountSummary && accountSummary.restrictions && (
<div className={style["restrictions-detail"]}> <div className={style["restrictions-detail"]}>
{accountSummary.restrictions.map((restriction) => ( {accountSummary.restrictions.map(restriction => (
<div <div
key={restriction.id} key={restriction.id}
className={style["restriction-detail-item"]} className={style["restriction-detail-item"]}

View File

@@ -1,30 +1,30 @@
import request from "@/api/request"; import request from "@/api/request";
// 获取微信号列表 // 获取微信号列表
export function getWechatAccounts(params: { export function getWechatAccounts(params: {
page: number; page: number;
page_size: number; page_size: number;
keyword?: string; keyword?: string;
}) { }) {
return request("v1/wechats", params, "GET"); return request("v1/wechats", params, "GET");
} }
// 获取微信号详情 // 获取微信号详情
export function getWechatAccountDetail(id: string) { export function getWechatAccountDetail(id: string) {
return request("v1/WechatAccount/detail", { id }, "GET"); return request("v1/WechatAccount/detail", { id }, "GET");
} }
// 获取微信号好友列表 // 获取微信号好友列表
export function getWechatFriends(params: { export function getWechatFriends(params: {
wechatAccountKeyword: string; wechatAccountKeyword: string;
pageIndex: number; pageIndex: number;
pageSize: number; pageSize: number;
friendKeyword?: string; friendKeyword?: string;
}) { }) {
return request("v1/WechatFriend/friendlistData", params, "POST"); return request("v1/WechatFriend/friendlistData", params, "POST");
} }
// 获取微信好友详情 // 获取微信好友详情
export function getWechatFriendDetail(id: string) { export function getWechatFriendDetail(id: string) {
return request("v1/WechatFriend/detail", { id }, "GET"); return request("v1/WechatFriend/detail", { id }, "GET");
} }

View File

@@ -1,171 +1,171 @@
.wechat-accounts-page { .wechat-accounts-page {
padding: 0 12px; padding: 0 12px;
} }
.nav-title { .nav-title {
font-size: 18px; font-size: 18px;
font-weight: 600; font-weight: 600;
color: #222; color: #222;
} }
.card-list { .card-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 14px; gap: 14px;
} }
.account-card { .account-card {
background: #fff; background: #fff;
border-radius: 14px; border-radius: 14px;
box-shadow: 0 2px 8px rgba(0,0,0,0.04); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
padding: 14px 14px 10px 14px; padding: 14px 14px 10px 14px;
transition: box-shadow 0.2s; transition: box-shadow 0.2s;
cursor: pointer; cursor: pointer;
border: 1px solid #f0f0f0; border: 1px solid #f0f0f0;
&:hover { &:hover {
box-shadow: 0 4px 16px rgba(0,0,0,0.10); box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
border-color: #e6f7ff; border-color: #e6f7ff;
} }
} }
.card-header { .card-header {
display: flex; display: flex;
align-items: center; align-items: center;
margin-bottom: 8px; margin-bottom: 8px;
} }
.avatar-wrapper { .avatar-wrapper {
position: relative; position: relative;
margin-right: 12px; margin-right: 12px;
} }
.avatar { .avatar {
width: 48px; width: 48px;
height: 48px; height: 48px;
border-radius: 50%; border-radius: 50%;
border: 3px solid #e6f0fa; border: 3px solid #e6f0fa;
box-shadow: 0 0 0 2px #1677ff33; box-shadow: 0 0 0 2px #1677ff33;
object-fit: cover; object-fit: cover;
} }
.status-dot-normal { .status-dot-normal {
position: absolute; position: absolute;
right: -2px; right: -2px;
bottom: -2px; bottom: -2px;
width: 14px; width: 14px;
height: 14px; height: 14px;
background: #52c41a; background: #52c41a;
border: 2px solid #fff; border: 2px solid #fff;
border-radius: 50%; border-radius: 50%;
} }
.status-dot-abnormal { .status-dot-abnormal {
position: absolute; position: absolute;
right: -2px; right: -2px;
bottom: -2px; bottom: -2px;
width: 14px; width: 14px;
height: 14px; height: 14px;
background: #ff4d4f; background: #ff4d4f;
border: 2px solid #fff; border: 2px solid #fff;
border-radius: 50%; border-radius: 50%;
} }
.header-info { .header-info {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
} }
.nickname-row { .nickname-row {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
} }
.nickname { .nickname {
font-weight: 600; font-weight: 600;
font-size: 16px; font-size: 16px;
max-width: 120px; max-width: 120px;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.status-label-normal { .status-label-normal {
background: #e6fffb; background: #e6fffb;
color: #13c2c2; color: #13c2c2;
font-size: 12px; font-size: 12px;
border-radius: 8px; border-radius: 8px;
padding: 2px 8px; padding: 2px 8px;
margin-left: 4px; margin-left: 4px;
} }
.status-label-abnormal { .status-label-abnormal {
background: #fff1f0; background: #fff1f0;
color: #ff4d4f; color: #ff4d4f;
font-size: 12px; font-size: 12px;
border-radius: 8px; border-radius: 8px;
padding: 2px 8px; padding: 2px 8px;
margin-left: 4px; margin-left: 4px;
} }
.wechat-id { .wechat-id {
color: #888; color: #888;
font-size: 13px; font-size: 13px;
margin-top: 2px; margin-top: 2px;
} }
.card-action { .card-action {
margin-left: 8px; margin-left: 8px;
} }
.card-body { .card-body {
margin-top: 2px; margin-top: 2px;
} }
.row-group { .row-group {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
margin-bottom: 4px; margin-bottom: 4px;
gap: 8px; gap: 8px;
} }
.row-item { .row-item {
font-size: 13px; font-size: 13px;
color: #555; color: #555;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 2px; gap: 2px;
} }
.strong { .strong {
font-weight: 600; font-weight: 600;
color: #222; color: #222;
} }
.strong-green { .strong-green {
font-weight: 600; font-weight: 600;
color: #52c41a; color: #52c41a;
} }
.progress-bar { .progress-bar {
margin: 6px 0 8px 0; margin: 6px 0 8px 0;
} }
.progress-bg { .progress-bg {
width: 100%; width: 100%;
height: 8px; height: 8px;
background: #f0f0f0; background: #f0f0f0;
border-radius: 6px; border-radius: 6px;
overflow: hidden; overflow: hidden;
} }
.progress-fill { .progress-fill {
height: 8px; height: 8px;
background: linear-gradient(90deg, #1677ff 0%, #69c0ff 100%); background: linear-gradient(90deg, #1677ff 0%, #69c0ff 100%);
border-radius: 6px; border-radius: 6px;
transition: width 0.3s; transition: width 0.3s;
} }
.pagination { .pagination {
margin: 16px 0 0 0; margin: 16px 0 0 0;
display: flex; display: flex;
justify-content: center; justify-content: center;
} }
.popup-content { .popup-content {
padding: 16px 0 8px 0; padding: 16px 0 8px 0;
} }
.popup-content img { .popup-content img {
box-shadow: 0 2px 8px rgba(0,0,0,0.08); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
} }
.loading { .loading {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
min-height: 200px; min-height: 200px;
} }
.empty { .empty {
text-align: center; text-align: center;
color: #999; color: #999;
padding: 48px 0 32px 0; padding: 48px 0 32px 0;
font-size: 15px; font-size: 15px;
} }

View File

@@ -93,7 +93,7 @@ const WechatAccounts: React.FC = () => {
<Input <Input
placeholder="搜索微信号/昵称" placeholder="搜索微信号/昵称"
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={e => setSearchTerm(e.target.value)}
prefix={<SearchOutlined />} prefix={<SearchOutlined />}
allowClear allowClear
size="large" size="large"
@@ -121,7 +121,7 @@ const WechatAccounts: React.FC = () => {
<div className={style["empty"]}></div> <div className={style["empty"]}></div>
) : ( ) : (
<div className={style["card-list"]}> <div className={style["card-list"]}>
{accounts.map((account) => { {accounts.map(account => {
const percent = const percent =
account.times > 0 account.times > 0
? Math.min((account.addedCount / account.times) * 100, 100) ? Math.min((account.addedCount / account.times) * 100, 100)

View File

@@ -1,26 +1,26 @@
import request from '@/api/request'; import request from "@/api/request";
// 获取场景列表 // 获取场景列表
export function getScenarios(params: any) { export function getScenarios(params: any) {
return request('/v1/plan/scenes', params, 'GET'); return request("/v1/plan/scenes", params, "GET");
} }
// 获取场景详情 // 获取场景详情
export function getScenarioDetail(id: string) { export function getScenarioDetail(id: string) {
return request(`/v1/scenarios/${id}`, {}, 'GET'); return request(`/v1/scenarios/${id}`, {}, "GET");
} }
// 创建场景 // 创建场景
export function createScenario(data: any) { export function createScenario(data: any) {
return request('/v1/scenarios', data, 'POST'); return request("/v1/scenarios", data, "POST");
} }
// 更新场景 // 更新场景
export function updateScenario(id: string, data: any) { export function updateScenario(id: string, data: any) {
return request(`/v1/scenarios/${id}`, data, 'PUT'); return request(`/v1/scenarios/${id}`, data, "PUT");
} }
// 删除场景 // 删除场景
export function deleteScenario(id: string) { export function deleteScenario(id: string) {
return request(`/v1/scenarios/${id}`, {}, 'DELETE'); return request(`/v1/scenarios/${id}`, {}, "DELETE");
} }

View File

@@ -22,7 +22,7 @@
background: var(--primary-gradient); background: var(--primary-gradient);
border: none; border: none;
box-shadow: 0 2px 8px var(--primary-shadow); box-shadow: 0 2px 8px var(--primary-shadow);
&:active { &:active {
transform: translateY(1px); transform: translateY(1px);
box-shadow: 0 1px 4px var(--primary-shadow); box-shadow: 0 1px 4px var(--primary-shadow);
@@ -132,11 +132,11 @@
.scenario-item { .scenario-item {
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: all 0.2s ease;
&:hover { &:hover {
transform: translateY(-1px); transform: translateY(-1px);
} }
&:active { &:active {
transform: translateY(0); transform: translateY(0);
} }
@@ -152,12 +152,14 @@
.scenario-card { .scenario-card {
background: #fff; background: #fff;
border-radius: 16px; 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);
transition: box-shadow 0.2s, transform 0.2s; transition:
box-shadow 0.2s,
transform 0.2s;
cursor: pointer; cursor: pointer;
overflow: hidden; overflow: hidden;
&:hover { &: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); transform: translateY(-2px) scale(1.02);
} }
} }
@@ -258,34 +260,33 @@
// 响应式设计 // 响应式设计
@media (max-width: 480px) { @media (max-width: 480px) {
.scenario-card { .scenario-card {
padding: 14px 16px; padding: 14px 16px;
min-height: 70px; min-height: 70px;
} }
.scenario-icon { .scenario-icon {
width: 46px; width: 46px;
height: 46px; height: 46px;
} }
.scenario-image { .scenario-image {
width: 28px; width: 28px;
height: 28px; height: 28px;
} }
.scenario-name { .scenario-name {
font-size: 15px; font-size: 15px;
} }
.stat-text { .stat-text {
font-size: 12px; font-size: 12px;
} }
.scenario-growth { .scenario-growth {
font-size: 15px; font-size: 15px;
} }
.growth-icon { .growth-icon {
font-size: 13px; font-size: 13px;
} }

View File

@@ -123,7 +123,7 @@ const Scene: React.FC = () => {
> >
<div className={style["scene-page"]}> <div className={style["scene-page"]}>
<div className={style["scenarios-grid"]}> <div className={style["scenarios-grid"]}>
{scenarios.map((scenario) => ( {scenarios.map(scenario => (
<div <div
key={scenario.id} key={scenario.id}
className={style["scenario-card"]} className={style["scenario-card"]}
@@ -136,7 +136,7 @@ const Scene: React.FC = () => {
src={scenario.image} src={scenario.image}
alt={scenario.name} alt={scenario.name}
className={style["card-img"]} className={style["card-img"]}
onError={(e) => { onError={e => {
e.currentTarget.src = e.currentTarget.src =
"https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-api.png"; "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-api.png";
}} }}

View File

@@ -1,32 +1,32 @@
import request from "@/api/request"; import request from "@/api/request";
import { PlanDetail, PlanListResponse, ApiResponse } from "./data"; import { PlanDetail, PlanListResponse, ApiResponse } from "./data";
// ==================== 计划相关接口 ==================== // ==================== 计划相关接口 ====================
// 获取计划列表 // 获取计划列表
export function getPlanList(params: { export function getPlanList(params: {
sceneId: string; sceneId: string;
page: number; page: number;
pageSize: number; pageSize: number;
}): Promise<PlanListResponse> { }): Promise<PlanListResponse> {
return request(`/v1/plan/list`, params, "GET"); return request(`/v1/plan/list`, params, "GET");
} }
// 获取计划详情 // 获取计划详情
export function getPlanDetail(planId: string): Promise<PlanDetail> { export function getPlanDetail(planId: string): Promise<PlanDetail> {
return request(`/v1/plan/detail`, { planId }, "GET"); return request(`/v1/plan/detail`, { planId }, "GET");
} }
// 复制计划 // 复制计划
export function copyPlan(planId: string): Promise<ApiResponse<any>> { export function copyPlan(planId: string): Promise<ApiResponse<any>> {
return request(`/v1/plan/copy`, { planId }, "GET"); return request(`/v1/plan/copy`, { planId }, "GET");
} }
// 删除计划 // 删除计划
export function deletePlan(planId: string): Promise<ApiResponse<any>> { export function deletePlan(planId: string): Promise<ApiResponse<any>> {
return request(`/v1/plan/delete`, { planId }, "DELETE"); return request(`/v1/plan/delete`, { planId }, "DELETE");
} }
// 获取小程序二维码 // 获取小程序二维码
export function getWxMinAppCode(planId: string): Promise<ApiResponse<string>> { export function getWxMinAppCode(planId: string): Promise<ApiResponse<string>> {
return request(`/v1/plan/getWxMinAppCode`, { taskId: planId }, "GET"); return request(`/v1/plan/getWxMinAppCode`, { taskId: planId }, "GET");
} }

View File

@@ -1,59 +1,59 @@
export interface Task { export interface Task {
id: string; id: string;
name: string; name: string;
status: number; status: number;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
enabled: boolean; enabled: boolean;
total_customers?: number; total_customers?: number;
today_customers?: number; today_customers?: number;
lastUpdated?: string; lastUpdated?: string;
stats?: { stats?: {
devices?: number; devices?: number;
acquired?: number; acquired?: number;
added?: number; added?: number;
}; };
reqConf?: { reqConf?: {
device?: string[]; device?: string[];
selectedDevices?: string[]; selectedDevices?: string[];
}; };
acquiredCount?: number; acquiredCount?: number;
addedCount?: number; addedCount?: number;
passRate?: number; passRate?: number;
} }
export interface ApiSettings { export interface ApiSettings {
apiKey: string; apiKey: string;
webhookUrl: string; webhookUrl: string;
taskId: string; taskId: string;
} }
// API响应相关类型 // API响应相关类型
export interface TextUrl { export interface TextUrl {
apiKey: string; apiKey: string;
originalString?: string; originalString?: string;
sign?: string; sign?: string;
fullUrl: string; fullUrl: string;
} }
export interface PlanDetail { export interface PlanDetail {
id: number; id: number;
name: string; name: string;
scenario: number; scenario: number;
enabled: boolean; enabled: boolean;
status: number; status: number;
apiKey: string; apiKey: string;
textUrl: TextUrl; textUrl: TextUrl;
[key: string]: any; [key: string]: any;
} }
export interface ApiResponse<T> { export interface ApiResponse<T> {
code: number; code: number;
msg?: string; msg?: string;
data: T; data: T;
} }
export interface PlanListResponse { export interface PlanListResponse {
list: Task[]; list: Task[];
total: number; total: number;
} }

View File

@@ -1,383 +1,382 @@
.scenario-list-page { .scenario-list-page {
padding:0 16px; padding: 0 16px;
} }
.loading { .loading {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 60vh; height: 60vh;
gap: 16px; gap: 16px;
} }
.loading-text { .loading-text {
color: #666; color: #666;
font-size: 14px; font-size: 14px;
} }
.search-bar { .search-bar {
display: flex; display: flex;
gap: 12px; gap: 12px;
align-items: center; align-items: center;
padding: 16px; padding: 16px;
} }
.search-input-wrapper { .search-input-wrapper {
position: relative; position: relative;
flex: 1; flex: 1;
.ant-input { .ant-input {
border-radius: 8px; border-radius: 8px;
height: 40px; height: 40px;
} }
} }
.plan-list {
.plan-list { display: flex;
display: flex; flex-direction: column;
flex-direction: column; gap: 12px;
gap: 12px; }
}
.plan-item {
.plan-item { background: white;
background: white; border-radius: 12px;
border-radius: 12px; padding: 16px;
padding: 16px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); transition: all 0.2s ease;
transition: all 0.2s ease;
&:hover {
&:hover { transform: translateY(-2px);
transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); }
} }
}
.plan-header {
.plan-header { display: flex;
display: flex; justify-content: space-between;
justify-content: space-between; align-items: center;
align-items: center; margin-bottom: 16px;
margin-bottom: 16px; }
}
.plan-name {
.plan-name { font-size: 16px;
font-size: 16px; font-weight: 600;
font-weight: 600; color: #333;
color: #333; flex: 1;
flex: 1; margin-right: 12px;
margin-right: 12px; }
}
.plan-header-right {
.plan-header-right { display: flex;
display: flex; align-items: center;
align-items: center; gap: 8px;
gap: 8px; }
}
.more-btn {
.more-btn { padding: 4px;
padding: 4px; min-width: auto;
min-width: auto; height: 28px;
height: 28px; width: 28px;
width: 28px; border-radius: 4px;
border-radius: 4px;
&:hover {
&:hover { background-color: #f5f5f5;
background-color: #f5f5f5; }
} }
}
.stats-grid {
.stats-grid { display: grid;
display: grid; grid-template-columns: 1fr 1fr;
grid-template-columns: 1fr 1fr; gap: 12px;
gap: 12px; margin-bottom: 16px;
margin-bottom: 16px; }
}
.stat-item {
.stat-item { background: #f8f9fa;
background: #f8f9fa; border-radius: 8px;
border-radius: 8px; padding: 12px;
padding: 12px; text-align: center;
text-align: center; border: 1px solid #e9ecef;
border: 1px solid #e9ecef; }
}
.stat-label {
.stat-label { font-size: 12px;
font-size: 12px; color: #666;
color: #666; margin-bottom: 4px;
margin-bottom: 4px; font-weight: 500;
font-weight: 500; }
}
.stat-value {
.stat-value { font-size: 18px;
font-size: 18px; font-weight: 600;
font-weight: 600; color: #333;
color: #333; line-height: 1.2;
line-height: 1.2; }
}
.plan-footer {
.plan-footer { border-top: 1px solid #f0f0f0;
border-top: 1px solid #f0f0f0; padding-top: 12px;
padding-top: 12px; }
}
.last-execution {
.last-execution { display: flex;
display: flex; align-items: center;
align-items: center; gap: 6px;
gap: 6px; font-size: 12px;
font-size: 12px; color: #999;
color: #999;
svg {
svg { font-size: 14px;
font-size: 14px; color: #999;
color: #999; }
} }
}
.empty-state {
.empty-state { display: flex;
display: flex; flex-direction: column;
flex-direction: column; align-items: center;
align-items: center; justify-content: center;
justify-content: center; padding: 60px 20px;
padding: 60px 20px; text-align: center;
text-align: center; }
}
.empty-text {
.empty-text { color: #999;
color: #999; font-size: 14px;
font-size: 14px; margin-bottom: 20px;
margin-bottom: 20px; }
}
.create-first-btn {
.create-first-btn { height: 40px;
height: 40px; padding: 0 24px;
padding: 0 24px; border-radius: 20px;
border-radius: 20px; }
}
// 加载更多按钮样式
// 加载更多按钮样式 .load-more-container {
.load-more-container { display: flex;
display: flex; justify-content: center;
justify-content: center; padding: 20px 0;
padding: 20px 0; }
}
.load-more-btn {
.load-more-btn { height: 44px;
height: 44px; padding: 0 32px;
padding: 0 32px; border-radius: 22px;
border-radius: 22px; font-size: 16px;
font-size: 16px; font-weight: 500;
font-weight: 500; display: flex;
display: flex; align-items: center;
align-items: center; gap: 8px;
gap: 8px; transition: all 0.2s ease;
transition: all 0.2s ease;
&:hover {
&:hover { transform: translateY(-1px);
transform: translateY(-1px); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); }
}
&:active {
&:active { transform: translateY(0);
transform: translateY(0); }
} }
}
// 没有更多数据提示样式
// 没有更多数据提示样式 .no-more-data {
.no-more-data { display: flex;
display: flex; justify-content: center;
justify-content: center; align-items: center;
align-items: center; padding: 20px 0;
padding: 20px 0; color: #999;
color: #999; font-size: 14px;
font-size: 14px;
span {
span { position: relative;
position: relative; padding: 0 20px;
padding: 0 20px;
&::before,
&::before, &::after {
&::after { content: "";
content: ''; position: absolute;
position: absolute; top: 50%;
top: 50%; width: 40px;
width: 40px; height: 1px;
height: 1px; background-color: #e0e0e0;
background-color: #e0e0e0; }
}
&::before {
&::before { left: -50px;
left: -50px; }
}
&::after {
&::after { right: -50px;
right: -50px; }
} }
} }
}
.action-menu-dialog {
.action-menu-dialog { background: white;
background: white; border-radius: 16px 16px 0 0;
border-radius: 16px 16px 0 0; padding: 20px;
padding: 20px; max-height: 60vh;
max-height: 60vh; display: flex;
display: flex; flex-direction: column;
flex-direction: column; }
}
.action-menu-item {
.action-menu-item { display: flex;
display: flex; align-items: center;
align-items: center; gap: 12px;
gap: 12px; padding: 16px;
padding: 16px; border-radius: 8px;
border-radius: 8px; cursor: pointer;
cursor: pointer; transition: background-color 0.2s ease;
transition: background-color 0.2s ease;
&:hover {
&:hover { background-color: #f5f5f5;
background-color: #f5f5f5; }
}
&.danger {
&.danger { color: #ff4d4f;
color: #ff4d4f;
&:hover {
&:hover { background-color: #fff2f0;
background-color: #fff2f0; }
} }
} }
}
.action-icon {
.action-icon { font-size: 16px;
font-size: 16px; width: 20px;
width: 20px; text-align: center;
text-align: center; }
}
.action-text {
.action-text { font-size: 16px;
font-size: 16px; font-weight: 500;
font-weight: 500; }
}
.dialog-header {
.dialog-header { display: flex;
display: flex; justify-content: space-between;
justify-content: space-between; align-items: center;
align-items: center; margin-bottom: 20px;
margin-bottom: 20px; padding-bottom: 16px;
padding-bottom: 16px; border-bottom: 1px solid #f0f0f0;
border-bottom: 1px solid #f0f0f0;
h3 {
h3 { margin: 0;
margin: 0; font-size: 18px;
font-size: 18px; font-weight: 600;
font-weight: 600; color: #333;
color: #333; }
} }
}
.dialog-content {
.dialog-content { flex: 1;
flex: 1; text-align: center;
text-align: center; display: flex;
display: flex; flex-direction: column;
flex-direction: column; align-items: center;
align-items: center; justify-content: center;
justify-content: center; }
}
.qr-dialog {
.qr-dialog { background: white;
background: white; border-radius: 16px;
border-radius: 16px; padding: 20px;
padding: 20px; width: 100%;
width: 100%; }
}
.qr-loading {
.qr-loading { display: flex;
display: flex; flex-direction: column;
flex-direction: column; align-items: center;
align-items: center; justify-content: center;
justify-content: center; padding: 40px 20px;
padding: 40px 20px; gap: 16px;
gap: 16px; color: #666;
color: #666; font-size: 14px;
font-size: 14px; }
}
.qr-image {
.qr-image { width: 100%;
width: 100%; max-width: 200px;
max-width: 200px; height: auto;
height: auto; border-radius: 8px;
border-radius: 8px; }
}
.qr-error {
.qr-error { text-align: center;
text-align: center; color: #ff4d4f;
color: #ff4d4f; font-size: 14px;
font-size: 14px; padding: 40px 20px;
padding: 40px 20px; }
}
.qr-link-section {
.qr-link-section { margin-top: 20px;
margin-top: 20px; width: 100%;
width: 100%; padding: 0 10px;
padding: 0 10px; }
}
.link-label {
.link-label { font-size: 14px;
font-size: 14px; font-weight: 500;
font-weight: 500; color: #333;
color: #333; margin-bottom: 8px;
margin-bottom: 8px; text-align: left;
text-align: left; }
}
.link-input-wrapper {
.link-input-wrapper { display: flex;
display: flex; gap: 8px;
gap: 8px; align-items: center;
align-items: center; width: 100%;
width: 100%;
@media (max-width: 480px) {
@media (max-width: 480px) { flex-direction: column;
flex-direction: column; gap: 12px;
gap: 12px; }
} }
}
.link-input {
.link-input { flex: 1;
flex: 1;
.ant-input {
.ant-input { border-radius: 8px;
border-radius: 8px; font-size: 12px;
font-size: 12px; color: #666;
color: #666; background-color: #f8f9fa;
background-color: #f8f9fa; border: 1px solid #e9ecef;
border: 1px solid #e9ecef;
&:focus {
&:focus { border-color: #1890ff;
border-color: #1890ff; box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2); }
} }
}
@media (max-width: 480px) {
@media (max-width: 480px) { width: 100%;
width: 100%; }
} }
}
.copy-button {
.copy-button { height: 32px;
height: 32px; padding: 0 12px;
padding: 0 12px; border-radius: 8px;
border-radius: 8px; font-size: 12px;
font-size: 12px; display: flex;
display: flex; align-items: center;
align-items: center; gap: 4px;
gap: 4px; white-space: nowrap;
white-space: nowrap; flex-shrink: 0;
flex-shrink: 0;
.anticon {
.anticon { font-size: 12px;
font-size: 12px; }
}
@media (max-width: 480px) {
@media (max-width: 480px) { width: 100%;
width: 100%; justify-content: center;
justify-content: center; }
} }
}

View File

@@ -104,7 +104,7 @@ const ScenarioList: React.FC = () => {
if (response && response.list) { if (response && response.list) {
if (isLoadMore) { if (isLoadMore) {
// 加载更多时,追加数据 // 加载更多时,追加数据
setTasks((prev) => [...prev, ...response.list]); setTasks(prev => [...prev, ...response.list]);
} else { } else {
// 首次加载或刷新时,替换数据 // 首次加载或刷新时,替换数据
setTasks(response.list); setTasks(response.list);
@@ -158,7 +158,7 @@ const ScenarioList: React.FC = () => {
}; };
const handleCopyPlan = async (taskId: string) => { const handleCopyPlan = async (taskId: string) => {
const taskToCopy = tasks.find((task) => task.id === taskId); const taskToCopy = tasks.find(task => task.id === taskId);
if (!taskToCopy) return; if (!taskToCopy) return;
try { try {
@@ -178,7 +178,7 @@ const ScenarioList: React.FC = () => {
}; };
const handleDeletePlan = async (taskId: string) => { const handleDeletePlan = async (taskId: string) => {
const taskToDelete = tasks.find((task) => task.id === taskId); const taskToDelete = tasks.find(task => task.id === taskId);
if (!taskToDelete) return; if (!taskToDelete) return;
const result = await Dialog.confirm({ const result = await Dialog.confirm({
@@ -285,7 +285,7 @@ const ScenarioList: React.FC = () => {
await fetchPlanList(1, false); await fetchPlanList(1, false);
}; };
const filteredTasks = tasks.filter((task) => const filteredTasks = tasks.filter(task =>
task.name.toLowerCase().includes(searchTerm.toLowerCase()) task.name.toLowerCase().includes(searchTerm.toLowerCase())
); );
@@ -371,7 +371,7 @@ const ScenarioList: React.FC = () => {
<Input <Input
placeholder="搜索计划名称" placeholder="搜索计划名称"
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={e => setSearchTerm(e.target.value)}
prefix={<SearchOutlined />} prefix={<SearchOutlined />}
allowClear allowClear
size="large" size="large"
@@ -408,7 +408,7 @@ const ScenarioList: React.FC = () => {
</div> </div>
) : ( ) : (
<> <>
{filteredTasks.map((task) => ( {filteredTasks.map(task => (
<Card key={task.id} className={style["plan-item"]}> <Card key={task.id} className={style["plan-item"]}>
{/* 头部:标题、状态和操作菜单 */} {/* 头部:标题、状态和操作菜单 */}
<div className={style["plan-header"]}> <div className={style["plan-header"]}>
@@ -527,8 +527,8 @@ const ScenarioList: React.FC = () => {
</div> </div>
<div className={style["dialog-content"]}> <div className={style["dialog-content"]}>
{showActionMenu && {showActionMenu &&
getActionMenu(tasks.find((t) => t.id === showActionMenu)!).map( getActionMenu(tasks.find(t => t.id === showActionMenu)!).map(
(item) => ( item => (
<div <div
key={item.key} key={item.key}
className={`${style["action-menu-item"]} ${item.danger ? style["danger"] : ""}`} 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 React, { useState, useMemo } from "react";
import { Popup, Button, Toast, SpinLoading } from "antd-mobile"; import { Popup, Button, Toast, SpinLoading } from "antd-mobile";
import { Modal, Input, Tabs, Card, Tag, Space } from "antd"; import { Modal, Input, Tabs, Card, Tag, Space } from "antd";
import { import {
CopyOutlined, CopyOutlined,
CodeOutlined, CodeOutlined,
BookOutlined, BookOutlined,
ThunderboltOutlined, ThunderboltOutlined,
SettingOutlined, SettingOutlined,
LinkOutlined, LinkOutlined,
SafetyOutlined, SafetyOutlined,
CheckCircleOutlined, CheckCircleOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import style from "./planApi.module.scss"; import style from "./planApi.module.scss";
import { buildApiUrl } from "@/utils/apiUrl"; import { buildApiUrl } from "@/utils/apiUrl";
/** /**
* 计划接口配置弹窗组件 * 计划接口配置弹窗组件
* *
* 使用示例: * 使用示例:
* ```tsx * ```tsx
* const [showApiDialog, setShowApiDialog] = useState(false); * const [showApiDialog, setShowApiDialog] = useState(false);
* const [apiSettings, setApiSettings] = useState({ * const [apiSettings, setApiSettings] = useState({
* apiKey: "your-api-key", * apiKey: "your-api-key",
* webhookUrl: "https://api.example.com/webhook", * webhookUrl: "https://api.example.com/webhook",
* taskId: "task-123" * taskId: "task-123"
* }); * });
* *
* <PlanApi * <PlanApi
* visible={showApiDialog} * visible={showApiDialog}
* onClose={() => setShowApiDialog(false)} * onClose={() => setShowApiDialog(false)}
* apiKey={apiSettings.apiKey} * apiKey={apiSettings.apiKey}
* webhookUrl={apiSettings.webhookUrl} * webhookUrl={apiSettings.webhookUrl}
* taskId={apiSettings.taskId} * taskId={apiSettings.taskId}
* /> * />
* ``` * ```
* *
* 特性: * 特性:
* - 移动端使用 PopupPC端使用 Modal * - 移动端使用 PopupPC端使用 Modal
* - 支持四个标签页:接口配置、快速测试、开发文档、代码示例 * - 支持四个标签页:接口配置、快速测试、开发文档、代码示例
* - 支持多种编程语言的代码示例 * - 支持多种编程语言的代码示例
* - 响应式设计,自适应不同屏幕尺寸 * - 响应式设计,自适应不同屏幕尺寸
* - 支持暗色主题 * - 支持暗色主题
* - 自动拼接API地址前缀 * - 自动拼接API地址前缀
*/ */
interface PlanApiProps { interface PlanApiProps {
visible: boolean; visible: boolean;
onClose: () => void; onClose: () => void;
apiKey: string; apiKey: string;
webhookUrl: string; webhookUrl: string;
taskId: string; taskId: string;
} }
interface ApiSettings { interface ApiSettings {
apiKey: string; apiKey: string;
webhookUrl: string; webhookUrl: string;
taskId: string; taskId: string;
} }
const PlanApi: React.FC<PlanApiProps> = ({ const PlanApi: React.FC<PlanApiProps> = ({
visible, visible,
onClose, onClose,
apiKey, apiKey,
webhookUrl, webhookUrl,
taskId, taskId,
}) => { }) => {
const [activeTab, setActiveTab] = useState("config"); const [activeTab, setActiveTab] = useState("config");
const [activeLanguage, setActiveLanguage] = useState("javascript"); const [activeLanguage, setActiveLanguage] = useState("javascript");
// 处理webhook URL确保包含完整的API地址 // 处理webhook URL确保包含完整的API地址
const fullWebhookUrl = useMemo(() => { const fullWebhookUrl = useMemo(() => {
return buildApiUrl(webhookUrl); return buildApiUrl(webhookUrl);
}, [webhookUrl]); }, [webhookUrl]);
// 生成测试URL // 生成测试URL
const testUrl = useMemo(() => { const testUrl = useMemo(() => {
if (!fullWebhookUrl) return ""; if (!fullWebhookUrl) return "";
return `${fullWebhookUrl}?name=测试客户&phone=13800138000&source=API测试`; return `${fullWebhookUrl}?name=测试客户&phone=13800138000&source=API测试`;
}, [fullWebhookUrl]); }, [fullWebhookUrl]);
// 检测是否为移动端 // 检测是否为移动端
const isMobile = window.innerWidth <= 768; const isMobile = window.innerWidth <= 768;
const handleCopy = (text: string, type: string) => { const handleCopy = (text: string, type: string) => {
navigator.clipboard.writeText(text); navigator.clipboard.writeText(text);
Toast.show({ Toast.show({
content: `${type}已复制到剪贴板`, content: `${type}已复制到剪贴板`,
position: "top", position: "top",
}); });
}; };
const handleTestInBrowser = () => { const handleTestInBrowser = () => {
window.open(testUrl, "_blank"); window.open(testUrl, "_blank");
}; };
const renderConfigTab = () => ( const renderConfigTab = () => (
<div className={style["config-content"]}> <div className={style["config-content"]}>
{/* API密钥配置 */} {/* API密钥配置 */}
<div className={style["config-section"]}> <div className={style["config-section"]}>
<div className={style["section-header"]}> <div className={style["section-header"]}>
<div className={style["section-title"]}> <div className={style["section-title"]}>
<CheckCircleOutlined className={style["section-icon"]} /> <CheckCircleOutlined className={style["section-icon"]} />
API密钥 API密钥
</div> </div>
<Tag color="green"></Tag> <Tag color="green"></Tag>
</div> </div>
<div className={style["input-group"]}> <div className={style["input-group"]}>
<Input value={apiKey} disabled className={style["api-input"]} /> <Input value={apiKey} disabled className={style["api-input"]} />
<Button <Button
size="small" size="small"
onClick={() => handleCopy(apiKey, "API密钥")} onClick={() => handleCopy(apiKey, "API密钥")}
className={style["copy-btn"]} className={style["copy-btn"]}
> >
<CopyOutlined /> <CopyOutlined />
</Button> </Button>
</div> </div>
<div className={style["security-tip"]}> <div className={style["security-tip"]}>
<strong></strong> <strong></strong>
API密钥使 API密钥使
</div> </div>
</div> </div>
{/* 接口地址配置 */} {/* 接口地址配置 */}
<div className={style["config-section"]}> <div className={style["config-section"]}>
<div className={style["section-header"]}> <div className={style["section-header"]}>
<div className={style["section-title"]}> <div className={style["section-title"]}>
<LinkOutlined className={style["section-icon"]} /> <LinkOutlined className={style["section-icon"]} />
</div> </div>
<Tag color="blue">POST请求</Tag> <Tag color="blue">POST请求</Tag>
</div> </div>
<div className={style["input-group"]}> <div className={style["input-group"]}>
<Input <Input
value={fullWebhookUrl} value={fullWebhookUrl}
disabled disabled
className={style["api-input"]} className={style["api-input"]}
/> />
<Button <Button
size="small" size="small"
onClick={() => handleCopy(fullWebhookUrl, "接口地址")} onClick={() => handleCopy(fullWebhookUrl, "接口地址")}
className={style["copy-btn"]} className={style["copy-btn"]}
> >
<CopyOutlined /> <CopyOutlined />
</Button> </Button>
</div> </div>
{/* 参数说明 */} {/* 参数说明 */}
<div className={style["params-grid"]}> <div className={style["params-grid"]}>
<div className={style["param-section"]}> <div className={style["param-section"]}>
<h4></h4> <h4></h4>
<div className={style["param-list"]}> <div className={style["param-list"]}>
<div> <div>
<code>name</code> - <code>name</code> -
</div> </div>
<div> <div>
<code>phone</code> - <code>phone</code> -
</div> </div>
</div> </div>
</div> </div>
<div className={style["param-section"]}> <div className={style["param-section"]}>
<h4></h4> <h4></h4>
<div className={style["param-list"]}> <div className={style["param-list"]}>
<div> <div>
<code>source</code> - <code>source</code> -
</div> </div>
<div> <div>
<code>remark</code> - <code>remark</code> -
</div> </div>
<div> <div>
<code>tags</code> - <code>tags</code> -
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
); );
const renderQuickTestTab = () => ( const renderQuickTestTab = () => (
<div className={style["test-content"]}> <div className={style["test-content"]}>
<div className={style["test-section"]}> <div className={style["test-section"]}>
<h3>URL</h3> <h3>URL</h3>
<div className={style["input-group"]}> <div className={style["input-group"]}>
<Input value={testUrl} disabled className={style["test-input"]} /> <Input value={testUrl} disabled className={style["test-input"]} />
</div> </div>
<div className={style["test-buttons"]}> <div className={style["test-buttons"]}>
<Button <Button
onClick={() => handleCopy(testUrl, "测试URL")} onClick={() => handleCopy(testUrl, "测试URL")}
className={style["test-btn"]} className={style["test-btn"]}
> >
<CopyOutlined /> <CopyOutlined />
URL URL
</Button> </Button>
<Button <Button
type="primary" type="primary"
onClick={handleTestInBrowser} onClick={handleTestInBrowser}
className={style["test-btn"]} className={style["test-btn"]}
> >
</Button> </Button>
</div> </div>
</div> </div>
</div> </div>
); );
const renderDocsTab = () => ( const renderDocsTab = () => (
<div className={style["docs-content"]}> <div className={style["docs-content"]}>
<div className={style["docs-grid"]}> <div className={style["docs-grid"]}>
<Card className={style["doc-card"]}> <Card className={style["doc-card"]}>
<div className={style["doc-icon"]}> <div className={style["doc-icon"]}>
<BookOutlined /> <BookOutlined />
</div> </div>
<h4>API文档</h4> <h4>API文档</h4>
<p></p> <p></p>
</Card> </Card>
<Card className={style["doc-card"]}> <Card className={style["doc-card"]}>
<div className={style["doc-icon"]}> <div className={style["doc-icon"]}>
<LinkOutlined /> <LinkOutlined />
</div> </div>
<h4></h4> <h4></h4>
<p></p> <p></p>
</Card> </Card>
</div> </div>
</div> </div>
); );
const renderCodeTab = () => { const renderCodeTab = () => {
const codeExamples = { const codeExamples = {
javascript: `fetch('${fullWebhookUrl}', { javascript: `fetch('${fullWebhookUrl}', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Authorization': 'Bearer ${apiKey}' 'Authorization': 'Bearer ${apiKey}'
}, },
body: JSON.stringify({ body: JSON.stringify({
name: '张三', name: '张三',
phone: '13800138000', phone: '13800138000',
source: '官网表单', source: '官网表单',
}) })
})`, })`,
python: `import requests python: `import requests
url = '${fullWebhookUrl}' url = '${fullWebhookUrl}'
headers = { headers = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Authorization': 'Bearer ${apiKey}' 'Authorization': 'Bearer ${apiKey}'
} }
data = { data = {
'name': '张三', 'name': '张三',
'phone': '13800138000', 'phone': '13800138000',
'source': '官网表单' 'source': '官网表单'
} }
response = requests.post(url, json=data, headers=headers)`, response = requests.post(url, json=data, headers=headers)`,
php: `<?php php: `<?php
$url = '${fullWebhookUrl}'; $url = '${fullWebhookUrl}';
$data = array( $data = array(
'name' => '张三', 'name' => '张三',
'phone' => '13800138000', 'phone' => '13800138000',
'source' => '官网表单' 'source' => '官网表单'
); );
$options = array( $options = array(
'http' => array( 'http' => array(
'header' => "Content-type: application/json\\r\\nAuthorization: Bearer ${apiKey}\\r\\n", 'header' => "Content-type: application/json\\r\\nAuthorization: Bearer ${apiKey}\\r\\n",
'method' => 'POST', 'method' => 'POST',
'content' => json_encode($data) 'content' => json_encode($data)
) )
); );
$context = stream_context_create($options); $context = stream_context_create($options);
$result = file_get_contents($url, false, $context);`, $result = file_get_contents($url, false, $context);`,
java: `import java.net.http.HttpClient; java: `import java.net.http.HttpClient;
import java.net.http.HttpRequest; import java.net.http.HttpRequest;
import java.net.http.HttpResponse; import java.net.http.HttpResponse;
import java.net.URI; import java.net.URI;
HttpClient client = HttpClient.newHttpClient(); HttpClient client = HttpClient.newHttpClient();
String json = "{\\"name\\":\\"张三\\",\\"phone\\":\\"13800138000\\",\\"source\\":\\"官网表单\\"}"; String json = "{\\"name\\":\\"张三\\",\\"phone\\":\\"13800138000\\",\\"source\\":\\"官网表单\\"}";
HttpRequest request = HttpRequest.newBuilder() HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("${fullWebhookUrl}")) .uri(URI.create("${fullWebhookUrl}"))
.header("Content-Type", "application/json") .header("Content-Type", "application/json")
.header("Authorization", "Bearer ${apiKey}") .header("Authorization", "Bearer ${apiKey}")
.POST(HttpRequest.BodyPublishers.ofString(json)) .POST(HttpRequest.BodyPublishers.ofString(json))
.build(); .build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());`, HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());`,
}; };
return ( return (
<div className={style["code-content"]}> <div className={style["code-content"]}>
<div className={style["language-tabs"]}> <div className={style["language-tabs"]}>
{Object.keys(codeExamples).map((lang) => ( {Object.keys(codeExamples).map(lang => (
<button <button
key={lang} key={lang}
className={`${style["lang-tab"]} ${ className={`${style["lang-tab"]} ${
activeLanguage === lang ? style["active"] : "" activeLanguage === lang ? style["active"] : ""
}`} }`}
onClick={() => setActiveLanguage(lang)} onClick={() => setActiveLanguage(lang)}
> >
{lang.charAt(0).toUpperCase() + lang.slice(1)} {lang.charAt(0).toUpperCase() + lang.slice(1)}
</button> </button>
))} ))}
</div> </div>
<div className={style["code-block"]}> <div className={style["code-block"]}>
<pre className={style["code"]}> <pre className={style["code"]}>
<code> <code>
{codeExamples[activeLanguage as keyof typeof codeExamples]} {codeExamples[activeLanguage as keyof typeof codeExamples]}
</code> </code>
</pre> </pre>
<Button <Button
size="small" size="small"
onClick={() => onClick={() =>
handleCopy( handleCopy(
codeExamples[activeLanguage as keyof typeof codeExamples], codeExamples[activeLanguage as keyof typeof codeExamples],
"代码" "代码"
) )
} }
className={style["copy-code-btn"]} className={style["copy-code-btn"]}
> >
<CopyOutlined /> <CopyOutlined />
</Button> </Button>
</div> </div>
</div> </div>
); );
}; };
const renderContent = () => ( const renderContent = () => (
<div className={style["plan-api-dialog"]}> <div className={style["plan-api-dialog"]}>
{/* 头部 */} {/* 头部 */}
<div className={style["dialog-header"]}> <div className={style["dialog-header"]}>
<div className={style["header-left"]}> <div className={style["header-left"]}>
<CodeOutlined className={style["header-icon"]} /> <CodeOutlined className={style["header-icon"]} />
<div className={style["header-content"]}> <div className={style["header-content"]}>
<h3></h3> <h3></h3>
<p> <p>
API接口直接导入客资到该获客计划 API接口直接导入客资到该获客计划
</p> </p>
</div> </div>
</div> </div>
<Button size="small" onClick={onClose} className={style["close-btn"]}> <Button size="small" onClick={onClose} className={style["close-btn"]}>
× ×
</Button> </Button>
</div> </div>
{/* 导航标签 */} {/* 导航标签 */}
<div className={style["nav-tabs"]}> <div className={style["nav-tabs"]}>
<button <button
className={`${style["nav-tab"]} ${activeTab === "config" ? style["active"] : ""}`} className={`${style["nav-tab"]} ${activeTab === "config" ? style["active"] : ""}`}
onClick={() => setActiveTab("config")} onClick={() => setActiveTab("config")}
> >
<SettingOutlined /> <SettingOutlined />
</button> </button>
<button <button
className={`${style["nav-tab"]} ${activeTab === "test" ? style["active"] : ""}`} className={`${style["nav-tab"]} ${activeTab === "test" ? style["active"] : ""}`}
onClick={() => setActiveTab("test")} onClick={() => setActiveTab("test")}
> >
<ThunderboltOutlined /> <ThunderboltOutlined />
</button> </button>
<button <button
className={`${style["nav-tab"]} ${activeTab === "docs" ? style["active"] : ""}`} className={`${style["nav-tab"]} ${activeTab === "docs" ? style["active"] : ""}`}
onClick={() => setActiveTab("docs")} onClick={() => setActiveTab("docs")}
> >
<BookOutlined /> <BookOutlined />
</button> </button>
<button <button
className={`${style["nav-tab"]} ${activeTab === "code" ? style["active"] : ""}`} className={`${style["nav-tab"]} ${activeTab === "code" ? style["active"] : ""}`}
onClick={() => setActiveTab("code")} onClick={() => setActiveTab("code")}
> >
<CodeOutlined /> <CodeOutlined />
</button> </button>
</div> </div>
{/* 内容区域 */} {/* 内容区域 */}
<div className={style["dialog-content"]}> <div className={style["dialog-content"]}>
{activeTab === "config" && renderConfigTab()} {activeTab === "config" && renderConfigTab()}
{activeTab === "test" && renderQuickTestTab()} {activeTab === "test" && renderQuickTestTab()}
{activeTab === "docs" && renderDocsTab()} {activeTab === "docs" && renderDocsTab()}
{activeTab === "code" && renderCodeTab()} {activeTab === "code" && renderCodeTab()}
</div> </div>
{/* 底部 */} {/* 底部 */}
<div className={style["dialog-footer"]}> <div className={style["dialog-footer"]}>
<div className={style["security-note"]}> <div className={style["security-note"]}>
<SafetyOutlined /> <SafetyOutlined />
HTTPS加密 HTTPS加密
</div> </div>
<Button <Button
type="primary" type="primary"
onClick={onClose} onClick={onClose}
className={style["complete-btn"]} className={style["complete-btn"]}
> >
</Button> </Button>
</div> </div>
</div> </div>
); );
// 移动端使用Popup // 移动端使用Popup
if (isMobile) { if (isMobile) {
return ( return (
<Popup <Popup
visible={visible} visible={visible}
onMaskClick={onClose} onMaskClick={onClose}
position="bottom" position="bottom"
bodyStyle={{ height: "90vh" }} bodyStyle={{ height: "90vh" }}
> >
{renderContent()} {renderContent()}
</Popup> </Popup>
); );
} }
// PC端使用Modal // PC端使用Modal
return ( return (
<Modal <Modal
open={visible} open={visible}
onCancel={onClose} onCancel={onClose}
footer={null} footer={null}
width={800} width={800}
centered centered
className={style["plan-api-modal"]} className={style["plan-api-modal"]}
> >
{renderContent()} {renderContent()}
</Modal> </Modal>
); );
}; };
export default PlanApi; export default PlanApi;

View File

@@ -1,53 +1,53 @@
import request from "@/api/request"; import request from "@/api/request";
// 获取场景类型列表 // 获取场景类型列表
export function getScenarioTypes() { export function getScenarioTypes() {
return request("/v1/plan/scenes", undefined, "GET"); return request("/v1/plan/scenes", undefined, "GET");
} }
// 创建计划 // 创建计划
export function createPlan(data: any) { export function createPlan(data: any) {
return request("/v1/plan/create", data, "POST"); return request("/v1/plan/create", data, "POST");
} }
// 更新计划 // 更新计划
export function updatePlan(planId: string, data: any) { export function updatePlan(planId: string, data: any) {
return request(`/v1/scenarios/plans/${planId}`, data, "PUT"); return request(`/v1/scenarios/plans/${planId}`, data, "PUT");
} }
// 获取计划详情 // 获取计划详情
export function getPlanDetail(planId: string) { export function getPlanDetail(planId: string) {
return request(`/v1/scenarios/plans/${planId}`, undefined, "GET"); return request(`/v1/scenarios/plans/${planId}`, undefined, "GET");
} }
// PlanDetail 类型定义(可根据实际接口返回结构补充字段) // PlanDetail 类型定义(可根据实际接口返回结构补充字段)
export interface PlanDetail { export interface PlanDetail {
name: string; name: string;
scenario: number; scenario: number;
posters: any[]; posters: any[];
device: string[]; device: string[];
remarkType: string; remarkType: string;
greeting: string; greeting: string;
addInterval: number; addInterval: number;
startTime: string; startTime: string;
endTime: string; endTime: string;
enabled: boolean; enabled: boolean;
sceneId: string | number; sceneId: string | number;
remarkFormat: string; remarkFormat: string;
addFriendInterval: number; addFriendInterval: number;
// 其它字段可扩展 // 其它字段可扩展
[key: string]: any; [key: string]: any;
} }
// 兼容旧代码的接口命名 // 兼容旧代码的接口命名
export function getPlanScenes() { export function getPlanScenes() {
return getScenarioTypes(); return getScenarioTypes();
} }
export function createScenarioPlan(data: any) { export function createScenarioPlan(data: any) {
return createPlan(data); return createPlan(data);
} }
export function fetchPlanDetail(planId: string) { export function fetchPlanDetail(planId: string) {
return getPlanDetail(planId); return getPlanDetail(planId);
} }
export function updateScenarioPlan(planId: string, data: any) { export function updateScenarioPlan(planId: string, data: any) {
return updatePlan(planId, data); return updatePlan(planId, data);
} }

Some files were not shown because too many files have changed in this diff Show More