稳定版本构建完成
This commit is contained in:
6
Cunkebao/.env.development
Normal file
6
Cunkebao/.env.development
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# 基础环境变量示例
|
||||||
|
VITE_API_BASE_URL=http://www.yishi.com
|
||||||
|
VITE_API_BASE_URL2=https://kf.quwanzhi.com:9991
|
||||||
|
VITE_API_WS_URL=wss://kf.quwanzhi.com:9993
|
||||||
|
# VITE_API_BASE_URL=https://ckbapi.quwanzhi.com
|
||||||
|
VITE_APP_TITLE=存客宝
|
||||||
1
Cunkebao/.env.local
Normal file
1
Cunkebao/.env.local
Normal file
@@ -0,0 +1 @@
|
|||||||
|
NEXT_PUBLIC_API_BASE_URL= http://yishi.com
|
||||||
6
Cunkebao/.env.production
Normal file
6
Cunkebao/.env.production
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# 基础环境变量示例
|
||||||
|
VITE_API_BASE_URL=https://ckbapi.quwanzhi.com
|
||||||
|
VITE_API_BASE_URL2=https://kf.quwanzhi.com:9991
|
||||||
|
VITE_API_WS_URL=wss://kf.quwanzhi.com:9993
|
||||||
|
# VITE_API_BASE_URL=http://www.yishi.com
|
||||||
|
VITE_APP_TITLE=存客宝
|
||||||
64
Cunkebao/.eslintrc.js
Normal file
64
Cunkebao/.eslintrc.js
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
module.exports = {
|
||||||
|
root: true,
|
||||||
|
env: {
|
||||||
|
browser: true,
|
||||||
|
es2021: 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,
|
||||||
|
},
|
||||||
|
ecmaVersion: 12,
|
||||||
|
sourceType: "module",
|
||||||
|
},
|
||||||
|
plugins: ["react", "react-hooks", "@typescript-eslint", "prettier"],
|
||||||
|
rules: {
|
||||||
|
"prettier/prettier": "error",
|
||||||
|
"react/react-in-jsx-scope": "off",
|
||||||
|
"@typescript-eslint/no-unused-vars": "warn",
|
||||||
|
"@typescript-eslint/no-explicit-any": "off",
|
||||||
|
"@typescript-eslint/no-unnecessary-type-constraint": "warn",
|
||||||
|
"react/prop-types": "off",
|
||||||
|
"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
Cunkebao/.gitattributes
vendored
Normal file
27
Cunkebao/.gitattributes
vendored
Normal 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
|
||||||
11
Cunkebao/.gitignore
vendored
Normal file
11
Cunkebao/.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
yarn.lock
|
||||||
|
.env
|
||||||
|
.DS_Store
|
||||||
|
dist/*
|
||||||
|
.cursorindexingignore
|
||||||
|
*.zip
|
||||||
|
.idea/
|
||||||
|
.next/
|
||||||
13
Cunkebao/.prettierrc
Normal file
13
Cunkebao/.prettierrc
Normal 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"
|
||||||
|
}
|
||||||
8
Cunkebao/.vite/deps/_metadata.json
Normal file
8
Cunkebao/.vite/deps/_metadata.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"hash": "efe0acf4",
|
||||||
|
"configHash": "2bed34b3",
|
||||||
|
"lockfileHash": "ef01d341",
|
||||||
|
"browserHash": "91bd3b2c",
|
||||||
|
"optimized": {},
|
||||||
|
"chunks": {}
|
||||||
|
}
|
||||||
3
Cunkebao/.vite/deps/package.json
Normal file
3
Cunkebao/.vite/deps/package.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"type": "module"
|
||||||
|
}
|
||||||
11
Cunkebao/.vscode/extensions.json
vendored
Normal file
11
Cunkebao/.vscode/extensions.json
vendored
Normal 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
Cunkebao/.vscode/settings.json
vendored
Normal file
45
Cunkebao/.vscode/settings.json
vendored
Normal 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
|
||||||
|
}
|
||||||
95
Cunkebao/devlop.py
Normal file
95
Cunkebao/devlop.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import os
|
||||||
|
import zipfile
|
||||||
|
import paramiko
|
||||||
|
|
||||||
|
# 配置
|
||||||
|
local_dir = './dist' # 本地要打包的目录
|
||||||
|
zip_name = 'dist.zip'
|
||||||
|
# 上传到服务器的 zip 路径
|
||||||
|
remote_path = '/www/wwwroot/auto-devlop/ckb-operation/dist.zip' # 服务器上的临时zip路径
|
||||||
|
server_ip = '42.194.245.239'
|
||||||
|
server_port = 6523
|
||||||
|
server_user = 'yongpxu'
|
||||||
|
server_pwd = 'Aa123456789.'
|
||||||
|
# 服务器 dist 相关目录
|
||||||
|
remote_base_dir = '/www/wwwroot/auto-devlop/ckb-operation'
|
||||||
|
dist_dir = f'{remote_base_dir}/dist'
|
||||||
|
dist1_dir = f'{remote_base_dir}/dist1'
|
||||||
|
dist2_dir = f'{remote_base_dir}/dist2'
|
||||||
|
|
||||||
|
# 美化输出用的函数
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
def info(msg):
|
||||||
|
print(f"\033[36m[INFO {datetime.now().strftime('%H:%M:%S')}] {msg}\033[0m")
|
||||||
|
|
||||||
|
def success(msg):
|
||||||
|
print(f"\033[32m[SUCCESS] {msg}\033[0m")
|
||||||
|
|
||||||
|
def error(msg):
|
||||||
|
print(f"\033[31m[ERROR] {msg}\033[0m")
|
||||||
|
|
||||||
|
def step(msg):
|
||||||
|
print(f"\n\033[35m==== {msg} ====" + "\033[0m")
|
||||||
|
|
||||||
|
# 1. 先运行 pnpm build
|
||||||
|
step('Step 1: 构建项目 (pnpm build)')
|
||||||
|
info('开始执行 pnpm build...')
|
||||||
|
ret = os.system('pnpm build')
|
||||||
|
if ret != 0:
|
||||||
|
error('pnpm build 失败,终止部署!')
|
||||||
|
exit(1)
|
||||||
|
success('pnpm build 完成')
|
||||||
|
|
||||||
|
# 2. 打包
|
||||||
|
step('Step 2: 打包 dist 目录为 zip')
|
||||||
|
info('开始打包 dist 目录...')
|
||||||
|
with zipfile.ZipFile(zip_name, 'w', zipfile.ZIP_DEFLATED) as zipf:
|
||||||
|
for root, dirs, files in os.walk(local_dir):
|
||||||
|
for file in files:
|
||||||
|
filepath = os.path.join(root, file)
|
||||||
|
arcname = os.path.relpath(filepath, local_dir)
|
||||||
|
zipf.write(filepath, arcname)
|
||||||
|
success('本地打包完成')
|
||||||
|
|
||||||
|
# 3. 上传
|
||||||
|
step('Step 3: 上传 zip 包到服务器')
|
||||||
|
info('开始上传 zip 包...')
|
||||||
|
transport = paramiko.Transport((server_ip, server_port))
|
||||||
|
transport.connect(username=server_user, password=server_pwd)
|
||||||
|
sftp = paramiko.SFTPClient.from_transport(transport)
|
||||||
|
sftp.put(zip_name, remote_path)
|
||||||
|
sftp.close()
|
||||||
|
transport.close()
|
||||||
|
success('上传到服务器完成')
|
||||||
|
|
||||||
|
# 删除本地 dist.zip
|
||||||
|
try:
|
||||||
|
os.remove(zip_name)
|
||||||
|
success('本地 dist.zip 已删除')
|
||||||
|
except Exception as e:
|
||||||
|
error(f'本地 dist.zip 删除失败: {e}')
|
||||||
|
|
||||||
|
# 4. 远程解压并覆盖
|
||||||
|
step('Step 4: 服务器端解压、切换目录')
|
||||||
|
ssh = paramiko.SSHClient()
|
||||||
|
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
ssh.connect(server_ip, server_port, server_user, server_pwd)
|
||||||
|
commands = [
|
||||||
|
f'unzip -oq {remote_path} -d {dist2_dir}', # 静默解压
|
||||||
|
f'rm {remote_path}',
|
||||||
|
f'if [ -d {dist_dir} ]; then mv {dist_dir} {dist1_dir}; fi',
|
||||||
|
f'mv {dist2_dir} {dist_dir}',
|
||||||
|
f'rm -rf {dist1_dir}'
|
||||||
|
]
|
||||||
|
for i, cmd in enumerate(commands, 1):
|
||||||
|
info(f'执行第{i}步: {cmd}')
|
||||||
|
stdin, stdout, stderr = ssh.exec_command(cmd)
|
||||||
|
out, err = stdout.read().decode(), stderr.read().decode()
|
||||||
|
# 只打印非 unzip 命令的输出
|
||||||
|
if i != 1 and out.strip():
|
||||||
|
print(out.strip())
|
||||||
|
if err.strip():
|
||||||
|
error(err.strip())
|
||||||
|
ssh.close()
|
||||||
|
success('服务器解压并覆盖完成,部署成功!')
|
||||||
BIN
Cunkebao/favicon.ico
Normal file
BIN
Cunkebao/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
19
Cunkebao/index.html
Normal file
19
Cunkebao/index.html
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>存客宝</title>
|
||||||
|
<style>
|
||||||
|
html {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<!-- 引入 uni-app web-view SDK(必须) -->
|
||||||
|
<script type="text/javascript" src="/websdk.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
52
Cunkebao/package.json
Normal file
52
Cunkebao/package.json
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
{
|
||||||
|
"name": "cunkebao",
|
||||||
|
"version": "3.0.0",
|
||||||
|
"license": "MIT",
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@ant-design/icons": "^5.6.1",
|
||||||
|
"antd": "^5.13.1",
|
||||||
|
"antd-mobile": "^5.39.1",
|
||||||
|
"antd-mobile-icons": "^0.3.0",
|
||||||
|
"axios": "^1.6.7",
|
||||||
|
"dayjs": "^1.11.13",
|
||||||
|
"echarts": "^5.6.0",
|
||||||
|
"echarts-for-react": "^3.0.2",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-router-dom": "^6.20.0",
|
||||||
|
"react-window": "^1.8.11",
|
||||||
|
"vconsole": "^3.15.1",
|
||||||
|
"xmldom": "^0.6.0",
|
||||||
|
"zustand": "^5.0.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.0.14",
|
||||||
|
"@types/react": "^19.1.8",
|
||||||
|
"@types/react-dom": "^19.1.6",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^7.7.0",
|
||||||
|
"@typescript-eslint/parser": "^7.7.0",
|
||||||
|
"@vitejs/plugin-react": "^4.6.0",
|
||||||
|
"eslint": "^8.57.0",
|
||||||
|
"eslint-config-prettier": "^9.1.0",
|
||||||
|
"eslint-plugin-prettier": "^5.1.3",
|
||||||
|
"eslint-plugin-react": "^7.34.1",
|
||||||
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
|
"postcss": "^8.4.38",
|
||||||
|
"postcss-pxtorem": "^6.0.0",
|
||||||
|
"prettier": "^3.2.5",
|
||||||
|
"sass": "^1.75.0",
|
||||||
|
"typescript": "^5.4.5",
|
||||||
|
"vite": "^7.0.5"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "pnpm vite",
|
||||||
|
"build": "pnpm vite build",
|
||||||
|
"build:check": "tsc && pnpm vite build",
|
||||||
|
"preview": "pnpm 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}\""
|
||||||
|
}
|
||||||
|
}
|
||||||
4990
Cunkebao/pnpm-lock.yaml
generated
Normal file
4990
Cunkebao/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
8
Cunkebao/postcss.config.js
Normal file
8
Cunkebao/postcss.config.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
'postcss-pxtorem': {
|
||||||
|
rootValue: 16,
|
||||||
|
propList: ['*'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
BIN
Cunkebao/public/logo.png
Normal file
BIN
Cunkebao/public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 488 KiB |
30
Cunkebao/public/manifest.json
Normal file
30
Cunkebao/public/manifest.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "Cunkebao",
|
||||||
|
"short_name": "Cunkebao",
|
||||||
|
"description": "Cunkebao Mobile App",
|
||||||
|
"theme_color": "#ffffff",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"scope": "/",
|
||||||
|
"start_url": "/",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "favicon.ico",
|
||||||
|
"sizes": "64x64 32x32 24x24 16x16",
|
||||||
|
"type": "image/x-icon"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "logo.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "logo.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
308
Cunkebao/public/websdk.js
Normal file
308
Cunkebao/public/websdk.js
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
!(function (e, n) {
|
||||||
|
"object" == typeof exports && "undefined" != typeof module
|
||||||
|
? (module.exports = n())
|
||||||
|
: "function" == typeof define && define.amd
|
||||||
|
? define(n)
|
||||||
|
: ((e = e || self).uni = n());
|
||||||
|
})(this, function () {
|
||||||
|
"use strict";
|
||||||
|
try {
|
||||||
|
var e = {};
|
||||||
|
(Object.defineProperty(e, "passive", {
|
||||||
|
get: function () {
|
||||||
|
!0;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
window.addEventListener("test-passive", null, e));
|
||||||
|
} catch (e) {}
|
||||||
|
var n = Object.prototype.hasOwnProperty;
|
||||||
|
function i(e, i) {
|
||||||
|
return n.call(e, i);
|
||||||
|
}
|
||||||
|
var t = [];
|
||||||
|
function o() {
|
||||||
|
return window.__dcloud_weex_postMessage || window.__dcloud_weex_;
|
||||||
|
}
|
||||||
|
function a() {
|
||||||
|
return window.__uniapp_x_postMessage || window.__uniapp_x_;
|
||||||
|
}
|
||||||
|
var r = function (e, n) {
|
||||||
|
var i = { options: { timestamp: +new Date() }, name: e, arg: n };
|
||||||
|
if (a()) {
|
||||||
|
if ("postMessage" === e) {
|
||||||
|
var r = { data: n };
|
||||||
|
return window.__uniapp_x_postMessage
|
||||||
|
? window.__uniapp_x_postMessage(r)
|
||||||
|
: window.__uniapp_x_.postMessage(JSON.stringify(r));
|
||||||
|
}
|
||||||
|
var d = {
|
||||||
|
type: "WEB_INVOKE_APPSERVICE",
|
||||||
|
args: { data: i, webviewIds: t },
|
||||||
|
};
|
||||||
|
window.__uniapp_x_postMessage
|
||||||
|
? window.__uniapp_x_postMessageToService(d)
|
||||||
|
: window.__uniapp_x_.postMessageToService(JSON.stringify(d));
|
||||||
|
} else if (o()) {
|
||||||
|
if ("postMessage" === e) {
|
||||||
|
var s = { data: [n] };
|
||||||
|
return window.__dcloud_weex_postMessage
|
||||||
|
? window.__dcloud_weex_postMessage(s)
|
||||||
|
: window.__dcloud_weex_.postMessage(JSON.stringify(s));
|
||||||
|
}
|
||||||
|
var w = {
|
||||||
|
type: "WEB_INVOKE_APPSERVICE",
|
||||||
|
args: { data: i, webviewIds: t },
|
||||||
|
};
|
||||||
|
window.__dcloud_weex_postMessage
|
||||||
|
? window.__dcloud_weex_postMessageToService(w)
|
||||||
|
: window.__dcloud_weex_.postMessageToService(JSON.stringify(w));
|
||||||
|
} else {
|
||||||
|
if (!window.plus)
|
||||||
|
return window.parent.postMessage(
|
||||||
|
{ type: "WEB_INVOKE_APPSERVICE", data: i, pageId: "" },
|
||||||
|
"*",
|
||||||
|
);
|
||||||
|
if (0 === t.length) {
|
||||||
|
var u = plus.webview.currentWebview();
|
||||||
|
if (!u) throw new Error("plus.webview.currentWebview() is undefined");
|
||||||
|
var g = u.parent(),
|
||||||
|
v = "";
|
||||||
|
((v = g ? g.id : u.id), t.push(v));
|
||||||
|
}
|
||||||
|
if (plus.webview.getWebviewById("__uniapp__service"))
|
||||||
|
plus.webview.postMessageToUniNView(
|
||||||
|
{ type: "WEB_INVOKE_APPSERVICE", args: { data: i, webviewIds: t } },
|
||||||
|
"__uniapp__service",
|
||||||
|
);
|
||||||
|
else {
|
||||||
|
var c = JSON.stringify(i);
|
||||||
|
plus.webview
|
||||||
|
.getLaunchWebview()
|
||||||
|
.evalJS(
|
||||||
|
'UniPlusBridge.subscribeHandler("'
|
||||||
|
.concat("WEB_INVOKE_APPSERVICE", '",')
|
||||||
|
.concat(c, ",")
|
||||||
|
.concat(JSON.stringify(t), ");"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
d = {
|
||||||
|
navigateTo: function () {
|
||||||
|
var e =
|
||||||
|
arguments.length > 0 && void 0 !== arguments[0] ? arguments[0] : {},
|
||||||
|
n = e.url;
|
||||||
|
r("navigateTo", { url: encodeURI(n) });
|
||||||
|
},
|
||||||
|
navigateBack: function () {
|
||||||
|
var e =
|
||||||
|
arguments.length > 0 && void 0 !== arguments[0] ? arguments[0] : {},
|
||||||
|
n = e.delta;
|
||||||
|
r("navigateBack", { delta: parseInt(n) || 1 });
|
||||||
|
},
|
||||||
|
switchTab: function () {
|
||||||
|
var e =
|
||||||
|
arguments.length > 0 && void 0 !== arguments[0] ? arguments[0] : {},
|
||||||
|
n = e.url;
|
||||||
|
r("switchTab", { url: encodeURI(n) });
|
||||||
|
},
|
||||||
|
reLaunch: function () {
|
||||||
|
var e =
|
||||||
|
arguments.length > 0 && void 0 !== arguments[0] ? arguments[0] : {},
|
||||||
|
n = e.url;
|
||||||
|
r("reLaunch", { url: encodeURI(n) });
|
||||||
|
},
|
||||||
|
redirectTo: function () {
|
||||||
|
var e =
|
||||||
|
arguments.length > 0 && void 0 !== arguments[0] ? arguments[0] : {},
|
||||||
|
n = e.url;
|
||||||
|
r("redirectTo", { url: encodeURI(n) });
|
||||||
|
},
|
||||||
|
getEnv: function (e) {
|
||||||
|
a()
|
||||||
|
? e({ uvue: !0 })
|
||||||
|
: o()
|
||||||
|
? e({ nvue: !0 })
|
||||||
|
: window.plus
|
||||||
|
? e({ plus: !0 })
|
||||||
|
: e({ h5: !0 });
|
||||||
|
},
|
||||||
|
postMessage: function () {
|
||||||
|
var e =
|
||||||
|
arguments.length > 0 && void 0 !== arguments[0] ? arguments[0] : {};
|
||||||
|
r("postMessage", e.data || {});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
s = /uni-app/i.test(navigator.userAgent),
|
||||||
|
w = /Html5Plus/i.test(navigator.userAgent),
|
||||||
|
u = /complete|loaded|interactive/;
|
||||||
|
var g =
|
||||||
|
window.my &&
|
||||||
|
navigator.userAgent.indexOf(
|
||||||
|
["t", "n", "e", "i", "l", "C", "y", "a", "p", "i", "l", "A"]
|
||||||
|
.reverse()
|
||||||
|
.join(""),
|
||||||
|
) > -1;
|
||||||
|
var v =
|
||||||
|
window.swan && window.swan.webView && /swan/i.test(navigator.userAgent);
|
||||||
|
var c =
|
||||||
|
window.qq &&
|
||||||
|
window.qq.miniProgram &&
|
||||||
|
/QQ/i.test(navigator.userAgent) &&
|
||||||
|
/miniProgram/i.test(navigator.userAgent);
|
||||||
|
var p =
|
||||||
|
window.tt &&
|
||||||
|
window.tt.miniProgram &&
|
||||||
|
/toutiaomicroapp/i.test(navigator.userAgent);
|
||||||
|
var _ =
|
||||||
|
window.wx &&
|
||||||
|
window.wx.miniProgram &&
|
||||||
|
/micromessenger/i.test(navigator.userAgent) &&
|
||||||
|
/miniProgram/i.test(navigator.userAgent);
|
||||||
|
var m = window.qa && /quickapp/i.test(navigator.userAgent);
|
||||||
|
var f =
|
||||||
|
window.ks &&
|
||||||
|
window.ks.miniProgram &&
|
||||||
|
/micromessenger/i.test(navigator.userAgent) &&
|
||||||
|
/miniProgram/i.test(navigator.userAgent);
|
||||||
|
var l =
|
||||||
|
window.tt &&
|
||||||
|
window.tt.miniProgram &&
|
||||||
|
/Lark|Feishu/i.test(navigator.userAgent);
|
||||||
|
var E =
|
||||||
|
window.jd && window.jd.miniProgram && /jdmp/i.test(navigator.userAgent);
|
||||||
|
var x =
|
||||||
|
window.xhs &&
|
||||||
|
window.xhs.miniProgram &&
|
||||||
|
/xhsminiapp/i.test(navigator.userAgent);
|
||||||
|
for (
|
||||||
|
var S,
|
||||||
|
h = function () {
|
||||||
|
((window.UniAppJSBridge = !0),
|
||||||
|
document.dispatchEvent(
|
||||||
|
new CustomEvent("UniAppJSBridgeReady", {
|
||||||
|
bubbles: !0,
|
||||||
|
cancelable: !0,
|
||||||
|
}),
|
||||||
|
));
|
||||||
|
},
|
||||||
|
y = [
|
||||||
|
function (e) {
|
||||||
|
if (s || w)
|
||||||
|
return (
|
||||||
|
window.__uniapp_x_postMessage ||
|
||||||
|
window.__uniapp_x_ ||
|
||||||
|
window.__dcloud_weex_postMessage ||
|
||||||
|
window.__dcloud_weex_
|
||||||
|
? document.addEventListener("DOMContentLoaded", e)
|
||||||
|
: window.plus && u.test(document.readyState)
|
||||||
|
? setTimeout(e, 0)
|
||||||
|
: document.addEventListener("plusready", e),
|
||||||
|
d
|
||||||
|
);
|
||||||
|
},
|
||||||
|
function (e) {
|
||||||
|
if (_)
|
||||||
|
return (
|
||||||
|
window.WeixinJSBridge && window.WeixinJSBridge.invoke
|
||||||
|
? setTimeout(e, 0)
|
||||||
|
: document.addEventListener("WeixinJSBridgeReady", e),
|
||||||
|
window.wx.miniProgram
|
||||||
|
);
|
||||||
|
},
|
||||||
|
function (e) {
|
||||||
|
if (c)
|
||||||
|
return (
|
||||||
|
window.QQJSBridge && window.QQJSBridge.invoke
|
||||||
|
? setTimeout(e, 0)
|
||||||
|
: document.addEventListener("QQJSBridgeReady", e),
|
||||||
|
window.qq.miniProgram
|
||||||
|
);
|
||||||
|
},
|
||||||
|
function (e) {
|
||||||
|
if (g) {
|
||||||
|
document.addEventListener("DOMContentLoaded", e);
|
||||||
|
var n = window.my;
|
||||||
|
return {
|
||||||
|
navigateTo: n.navigateTo,
|
||||||
|
navigateBack: n.navigateBack,
|
||||||
|
switchTab: n.switchTab,
|
||||||
|
reLaunch: n.reLaunch,
|
||||||
|
redirectTo: n.redirectTo,
|
||||||
|
postMessage: n.postMessage,
|
||||||
|
getEnv: n.getEnv,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
function (e) {
|
||||||
|
if (v)
|
||||||
|
return (
|
||||||
|
document.addEventListener("DOMContentLoaded", e),
|
||||||
|
window.swan.webView
|
||||||
|
);
|
||||||
|
},
|
||||||
|
function (e) {
|
||||||
|
if (p)
|
||||||
|
return (
|
||||||
|
document.addEventListener("DOMContentLoaded", e),
|
||||||
|
window.tt.miniProgram
|
||||||
|
);
|
||||||
|
},
|
||||||
|
function (e) {
|
||||||
|
if (m) {
|
||||||
|
window.QaJSBridge && window.QaJSBridge.invoke
|
||||||
|
? setTimeout(e, 0)
|
||||||
|
: document.addEventListener("QaJSBridgeReady", e);
|
||||||
|
var n = window.qa;
|
||||||
|
return {
|
||||||
|
navigateTo: n.navigateTo,
|
||||||
|
navigateBack: n.navigateBack,
|
||||||
|
switchTab: n.switchTab,
|
||||||
|
reLaunch: n.reLaunch,
|
||||||
|
redirectTo: n.redirectTo,
|
||||||
|
postMessage: n.postMessage,
|
||||||
|
getEnv: n.getEnv,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
function (e) {
|
||||||
|
if (f)
|
||||||
|
return (
|
||||||
|
window.WeixinJSBridge && window.WeixinJSBridge.invoke
|
||||||
|
? setTimeout(e, 0)
|
||||||
|
: document.addEventListener("WeixinJSBridgeReady", e),
|
||||||
|
window.ks.miniProgram
|
||||||
|
);
|
||||||
|
},
|
||||||
|
function (e) {
|
||||||
|
if (l)
|
||||||
|
return (
|
||||||
|
document.addEventListener("DOMContentLoaded", e),
|
||||||
|
window.tt.miniProgram
|
||||||
|
);
|
||||||
|
},
|
||||||
|
function (e) {
|
||||||
|
if (E)
|
||||||
|
return (
|
||||||
|
window.JDJSBridgeReady && window.JDJSBridgeReady.invoke
|
||||||
|
? setTimeout(e, 0)
|
||||||
|
: document.addEventListener("JDJSBridgeReady", e),
|
||||||
|
window.jd.miniProgram
|
||||||
|
);
|
||||||
|
},
|
||||||
|
function (e) {
|
||||||
|
if (x) return window.xhs.miniProgram;
|
||||||
|
},
|
||||||
|
function (e) {
|
||||||
|
return (document.addEventListener("DOMContentLoaded", e), d);
|
||||||
|
},
|
||||||
|
],
|
||||||
|
M = 0;
|
||||||
|
M < y.length && !(S = y[M](h));
|
||||||
|
M++
|
||||||
|
);
|
||||||
|
S || (S = {});
|
||||||
|
var P = "undefined" != typeof uni ? uni : {};
|
||||||
|
if (!P.navigateTo) for (var b in S) i(S, b) && (P[b] = S[b]);
|
||||||
|
return ((P.webView = S), P);
|
||||||
|
});
|
||||||
14
Cunkebao/src/App.tsx
Normal file
14
Cunkebao/src/App.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import React from "react";
|
||||||
|
import AppRouter from "@/router";
|
||||||
|
import UpdateNotification from "@/components/UpdateNotification";
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AppRouter />
|
||||||
|
<UpdateNotification position="top" autoReload={false} showToast={true} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
352
Cunkebao/src/android-polyfills.ts
Normal file
352
Cunkebao/src/android-polyfills.ts
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
// Android 专用 polyfill - 解决Android 7等低版本系统的兼容性问题
|
||||||
|
|
||||||
|
// 检测是否为Android设备
|
||||||
|
const isAndroid = () => {
|
||||||
|
return /Android/i.test(navigator.userAgent);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 检测Android版本
|
||||||
|
const getAndroidVersion = () => {
|
||||||
|
const match = navigator.userAgent.match(/Android\s+(\d+)/);
|
||||||
|
return match ? parseInt(match[1]) : 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 检测是否为低版本Android
|
||||||
|
const isLowVersionAndroid = () => {
|
||||||
|
const version = getAndroidVersion();
|
||||||
|
return version <= 7; // Android 7及以下版本
|
||||||
|
};
|
||||||
|
|
||||||
|
// 只在Android设备上执行polyfill
|
||||||
|
if (isAndroid() && isLowVersionAndroid()) {
|
||||||
|
console.log("检测到低版本Android系统,启用兼容性polyfill");
|
||||||
|
|
||||||
|
// 修复Array.prototype.includes在Android WebView中的问题
|
||||||
|
if (!Array.prototype.includes) {
|
||||||
|
Array.prototype.includes = function (searchElement, fromIndex) {
|
||||||
|
if (this == null) {
|
||||||
|
throw new TypeError('"this" is null or not defined');
|
||||||
|
}
|
||||||
|
var o = Object(this);
|
||||||
|
var len = o.length >>> 0;
|
||||||
|
if (len === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
var n = fromIndex | 0;
|
||||||
|
var k = Math.max(n >= 0 ? n : len + n, 0);
|
||||||
|
while (k < len) {
|
||||||
|
if (o[k] === searchElement) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
k++;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 修复String.prototype.includes在Android WebView中的问题
|
||||||
|
if (!String.prototype.includes) {
|
||||||
|
String.prototype.includes = function (search, start) {
|
||||||
|
if (typeof start !== "number") {
|
||||||
|
start = 0;
|
||||||
|
}
|
||||||
|
if (start + search.length > this.length) {
|
||||||
|
return false;
|
||||||
|
} else {
|
||||||
|
return this.indexOf(search, start) !== -1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 修复String.prototype.startsWith在Android WebView中的问题
|
||||||
|
if (!String.prototype.startsWith) {
|
||||||
|
String.prototype.startsWith = function (searchString, position) {
|
||||||
|
position = position || 0;
|
||||||
|
return this.substr(position, searchString.length) === searchString;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 修复String.prototype.endsWith在Android WebView中的问题
|
||||||
|
if (!String.prototype.endsWith) {
|
||||||
|
String.prototype.endsWith = function (searchString, length) {
|
||||||
|
if (length === undefined || length > this.length) {
|
||||||
|
length = this.length;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
this.substring(length - searchString.length, length) === searchString
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 修复Array.prototype.find在Android WebView中的问题
|
||||||
|
if (!Array.prototype.find) {
|
||||||
|
Array.prototype.find = function (predicate) {
|
||||||
|
if (this == null) {
|
||||||
|
throw new TypeError("Array.prototype.find called on null or undefined");
|
||||||
|
}
|
||||||
|
if (typeof predicate !== "function") {
|
||||||
|
throw new TypeError("predicate must be a function");
|
||||||
|
}
|
||||||
|
var list = Object(this);
|
||||||
|
var length = parseInt(list.length) || 0;
|
||||||
|
var thisArg = arguments[1];
|
||||||
|
for (var i = 0; i < length; i++) {
|
||||||
|
var element = list[i];
|
||||||
|
if (predicate.call(thisArg, element, i, list)) {
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 修复Array.prototype.findIndex在Android WebView中的问题
|
||||||
|
if (!Array.prototype.findIndex) {
|
||||||
|
Array.prototype.findIndex = function (predicate) {
|
||||||
|
if (this == null) {
|
||||||
|
throw new TypeError(
|
||||||
|
"Array.prototype.findIndex called on null or undefined",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (typeof predicate !== "function") {
|
||||||
|
throw new TypeError("predicate must be a function");
|
||||||
|
}
|
||||||
|
var list = Object(this);
|
||||||
|
var length = parseInt(list.length) || 0;
|
||||||
|
var thisArg = arguments[1];
|
||||||
|
for (var i = 0; i < length; i++) {
|
||||||
|
var element = list[i];
|
||||||
|
if (predicate.call(thisArg, element, i, list)) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 修复Object.assign在Android WebView中的问题
|
||||||
|
if (typeof Object.assign !== "function") {
|
||||||
|
Object.assign = function (target) {
|
||||||
|
if (target == null) {
|
||||||
|
throw new TypeError("Cannot convert undefined or null to object");
|
||||||
|
}
|
||||||
|
var to = Object(target);
|
||||||
|
for (var index = 1; index < arguments.length; index++) {
|
||||||
|
var nextSource = arguments[index];
|
||||||
|
if (nextSource != null) {
|
||||||
|
for (var nextKey in nextSource) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(nextSource, nextKey)) {
|
||||||
|
to[nextKey] = nextSource[nextKey];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return to;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 修复Array.from在Android WebView中的问题
|
||||||
|
if (!Array.from) {
|
||||||
|
Array.from = (function () {
|
||||||
|
var toStr = Object.prototype.toString;
|
||||||
|
var isCallable = function (fn) {
|
||||||
|
return (
|
||||||
|
typeof fn === "function" || toStr.call(fn) === "[object Function]"
|
||||||
|
);
|
||||||
|
};
|
||||||
|
var toInteger = function (value) {
|
||||||
|
var number = Number(value);
|
||||||
|
if (isNaN(number)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (number === 0 || !isFinite(number)) {
|
||||||
|
return number;
|
||||||
|
}
|
||||||
|
return (number > 0 ? 1 : -1) * Math.floor(Math.abs(number));
|
||||||
|
};
|
||||||
|
var maxSafeInteger = Math.pow(2, 53) - 1;
|
||||||
|
var toLength = function (value) {
|
||||||
|
var len = toInteger(value);
|
||||||
|
return Math.min(Math.max(len, 0), maxSafeInteger);
|
||||||
|
};
|
||||||
|
return function from(arrayLike) {
|
||||||
|
var C = this;
|
||||||
|
var items = Object(arrayLike);
|
||||||
|
if (arrayLike == null) {
|
||||||
|
throw new TypeError(
|
||||||
|
"Array.from requires an array-like object - not null or undefined",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
var mapFunction = arguments.length > 1 ? arguments[1] : void undefined;
|
||||||
|
var T;
|
||||||
|
if (typeof mapFunction !== "undefined") {
|
||||||
|
if (typeof mapFunction !== "function") {
|
||||||
|
throw new TypeError(
|
||||||
|
"Array.from: when provided, the second argument must be a function",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (arguments.length > 2) {
|
||||||
|
T = arguments[2];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var len = toLength(items.length);
|
||||||
|
var A = isCallable(C) ? Object(new C(len)) : new Array(len);
|
||||||
|
var k = 0;
|
||||||
|
var kValue;
|
||||||
|
while (k < len) {
|
||||||
|
kValue = items[k];
|
||||||
|
if (mapFunction) {
|
||||||
|
A[k] =
|
||||||
|
typeof T === "undefined"
|
||||||
|
? mapFunction(kValue, k)
|
||||||
|
: mapFunction.call(T, kValue, k);
|
||||||
|
} else {
|
||||||
|
A[k] = kValue;
|
||||||
|
}
|
||||||
|
k += 1;
|
||||||
|
}
|
||||||
|
A.length = len;
|
||||||
|
return A;
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 修复requestAnimationFrame在Android WebView中的问题
|
||||||
|
if (!window.requestAnimationFrame) {
|
||||||
|
window.requestAnimationFrame = function (callback) {
|
||||||
|
return setTimeout(function () {
|
||||||
|
callback(Date.now());
|
||||||
|
}, 1000 / 60);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!window.cancelAnimationFrame) {
|
||||||
|
window.cancelAnimationFrame = function (id) {
|
||||||
|
clearTimeout(id);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 修复IntersectionObserver在Android WebView中的问题
|
||||||
|
if (!window.IntersectionObserver) {
|
||||||
|
window.IntersectionObserver = function (callback, options) {
|
||||||
|
this.callback = callback;
|
||||||
|
this.options = options || {};
|
||||||
|
this.observers = [];
|
||||||
|
|
||||||
|
this.observe = function (element) {
|
||||||
|
this.observers.push(element);
|
||||||
|
// 简单的实现,实际项目中可能需要更复杂的逻辑
|
||||||
|
setTimeout(() => {
|
||||||
|
this.callback([
|
||||||
|
{
|
||||||
|
target: element,
|
||||||
|
isIntersecting: true,
|
||||||
|
intersectionRatio: 1,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}, 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.unobserve = function (element) {
|
||||||
|
var index = this.observers.indexOf(element);
|
||||||
|
if (index > -1) {
|
||||||
|
this.observers.splice(index, 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.disconnect = function () {
|
||||||
|
this.observers = [];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 修复ResizeObserver在Android WebView中的问题
|
||||||
|
if (!window.ResizeObserver) {
|
||||||
|
window.ResizeObserver = function (callback) {
|
||||||
|
this.callback = callback;
|
||||||
|
this.observers = [];
|
||||||
|
|
||||||
|
this.observe = function (element) {
|
||||||
|
this.observers.push(element);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.unobserve = function (element) {
|
||||||
|
var index = this.observers.indexOf(element);
|
||||||
|
if (index > -1) {
|
||||||
|
this.observers.splice(index, 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.disconnect = function () {
|
||||||
|
this.observers = [];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 修复URLSearchParams在Android WebView中的问题
|
||||||
|
if (!window.URLSearchParams) {
|
||||||
|
window.URLSearchParams = function (init) {
|
||||||
|
this.params = {};
|
||||||
|
|
||||||
|
if (init) {
|
||||||
|
if (typeof init === "string") {
|
||||||
|
if (init.charAt(0) === "?") {
|
||||||
|
init = init.slice(1);
|
||||||
|
}
|
||||||
|
var pairs = init.split("&");
|
||||||
|
for (var i = 0; i < pairs.length; i++) {
|
||||||
|
var pair = pairs[i].split("=");
|
||||||
|
var key = decodeURIComponent(pair[0]);
|
||||||
|
var value = decodeURIComponent(pair[1] || "");
|
||||||
|
this.append(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.append = function (name, value) {
|
||||||
|
if (!this.params[name]) {
|
||||||
|
this.params[name] = [];
|
||||||
|
}
|
||||||
|
this.params[name].push(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.get = function (name) {
|
||||||
|
return this.params[name] ? this.params[name][0] : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
this.getAll = function (name) {
|
||||||
|
return this.params[name] || [];
|
||||||
|
};
|
||||||
|
|
||||||
|
this.has = function (name) {
|
||||||
|
return !!this.params[name];
|
||||||
|
};
|
||||||
|
|
||||||
|
this.set = function (name, value) {
|
||||||
|
this.params[name] = [value];
|
||||||
|
};
|
||||||
|
|
||||||
|
this.delete = function (name) {
|
||||||
|
delete this.params[name];
|
||||||
|
};
|
||||||
|
|
||||||
|
this.toString = function () {
|
||||||
|
var pairs = [];
|
||||||
|
for (var key in this.params) {
|
||||||
|
if (this.params.hasOwnProperty(key)) {
|
||||||
|
for (var i = 0; i < this.params[key].length; i++) {
|
||||||
|
pairs.push(
|
||||||
|
encodeURIComponent(key) +
|
||||||
|
"=" +
|
||||||
|
encodeURIComponent(this.params[key][i]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pairs.join("&");
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Android兼容性polyfill已加载完成");
|
||||||
|
}
|
||||||
37
Cunkebao/src/api/common.ts
Normal file
37
Cunkebao/src/api/common.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
import { useUserStore } from "@/store/module/user";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通用文件上传方法(支持图片、文件)
|
||||||
|
* @param {File} file - 要上传的文件对象
|
||||||
|
* @param {string} [uploadUrl='/v1/attachment/upload'] - 上传接口地址
|
||||||
|
* @returns {Promise<string>} - 上传成功后返回文件url
|
||||||
|
*/
|
||||||
|
export async function uploadFile(
|
||||||
|
file: File,
|
||||||
|
uploadUrl: string = "/v1/attachment/upload",
|
||||||
|
): Promise<string> {
|
||||||
|
try {
|
||||||
|
// 创建 FormData 对象用于文件上传
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
|
||||||
|
// 获取用户token
|
||||||
|
const { token } = useUserStore.getState();
|
||||||
|
|
||||||
|
const fullUrl = `${(import.meta as any).env?.VITE_API_BASE_URL || "/api"}${uploadUrl}`;
|
||||||
|
|
||||||
|
// 直接使用 axios 上传文件
|
||||||
|
const response = await axios.post(fullUrl, formData, {
|
||||||
|
headers: {
|
||||||
|
Authorization: token ? `Bearer ${token}` : undefined,
|
||||||
|
},
|
||||||
|
timeout: 20000,
|
||||||
|
});
|
||||||
|
return response?.data?.data?.url || "";
|
||||||
|
} catch (e: any) {
|
||||||
|
const errorMessage =
|
||||||
|
e.response?.data?.message || e.message || "文件上传失败";
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
90
Cunkebao/src/api/request.ts
Normal file
90
Cunkebao/src/api/request.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import axios, {
|
||||||
|
AxiosInstance,
|
||||||
|
AxiosRequestConfig,
|
||||||
|
Method,
|
||||||
|
AxiosResponse,
|
||||||
|
} from "axios";
|
||||||
|
import { Toast } from "antd-mobile";
|
||||||
|
import { useUserStore } from "@/store/module/user";
|
||||||
|
const { token } = useUserStore.getState();
|
||||||
|
const DEFAULT_DEBOUNCE_GAP = 1000;
|
||||||
|
const debounceMap = new Map<string, number>();
|
||||||
|
|
||||||
|
const instance: AxiosInstance = axios.create({
|
||||||
|
baseURL: (import.meta as any).env?.VITE_API_BASE_URL || "/api",
|
||||||
|
timeout: 20000,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
instance.interceptors.request.use((config: any) => {
|
||||||
|
if (token) {
|
||||||
|
config.headers = config.headers || {};
|
||||||
|
config.headers["Authorization"] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
instance.interceptors.response.use(
|
||||||
|
(res: AxiosResponse) => {
|
||||||
|
const { code, success, msg } = res.data || {};
|
||||||
|
if (code === 200 || success) {
|
||||||
|
return res.data.data ?? res.data;
|
||||||
|
}
|
||||||
|
Toast.show({ content: msg || "接口错误", position: "top" });
|
||||||
|
if (code === 401) {
|
||||||
|
localStorage.removeItem("token");
|
||||||
|
const currentPath = window.location.pathname + window.location.search;
|
||||||
|
if (currentPath === "/login") {
|
||||||
|
window.location.href = "/login";
|
||||||
|
} else {
|
||||||
|
window.location.href = `/login?redirect=${encodeURIComponent(currentPath)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.reject(msg || "接口错误");
|
||||||
|
},
|
||||||
|
err => {
|
||||||
|
Toast.show({ content: err.message || "网络异常", position: "top" });
|
||||||
|
return Promise.reject(err);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export function request(
|
||||||
|
url: string,
|
||||||
|
data?: any,
|
||||||
|
method: Method = "GET",
|
||||||
|
config?: AxiosRequestConfig,
|
||||||
|
debounceGap?: number,
|
||||||
|
): Promise<any> {
|
||||||
|
const gap =
|
||||||
|
typeof debounceGap === "number" ? debounceGap : DEFAULT_DEBOUNCE_GAP;
|
||||||
|
const key = `${method}_${url}_${JSON.stringify(data)}`;
|
||||||
|
const now = Date.now();
|
||||||
|
const last = debounceMap.get(key) || 0;
|
||||||
|
if (gap > 0 && now - last < gap) {
|
||||||
|
// Toast.show({ content: '请求过于频繁,请稍后再试', position: 'top' });
|
||||||
|
return Promise.reject("请求过于频繁,请稍后再试");
|
||||||
|
}
|
||||||
|
debounceMap.set(key, now);
|
||||||
|
|
||||||
|
const axiosConfig: AxiosRequestConfig = {
|
||||||
|
url,
|
||||||
|
method,
|
||||||
|
...config,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 如果是FormData,不设置Content-Type,让浏览器自动设置
|
||||||
|
if (data instanceof FormData) {
|
||||||
|
delete axiosConfig.headers?.["Content-Type"];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method.toUpperCase() === "GET") {
|
||||||
|
axiosConfig.params = data;
|
||||||
|
} else {
|
||||||
|
axiosConfig.data = data;
|
||||||
|
}
|
||||||
|
return instance(axiosConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default request;
|
||||||
89
Cunkebao/src/api/request2.ts
Normal file
89
Cunkebao/src/api/request2.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import axios, {
|
||||||
|
AxiosInstance,
|
||||||
|
AxiosRequestConfig,
|
||||||
|
Method,
|
||||||
|
AxiosResponse,
|
||||||
|
} from "axios";
|
||||||
|
import { Toast } from "antd-mobile";
|
||||||
|
import { useUserStore } from "@/store/module/user";
|
||||||
|
const DEFAULT_DEBOUNCE_GAP = 1000;
|
||||||
|
const debounceMap = new Map<string, number>();
|
||||||
|
|
||||||
|
interface RequestConfig extends AxiosRequestConfig {
|
||||||
|
headers: {
|
||||||
|
Client?: string;
|
||||||
|
"Content-Type"?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const instance: AxiosInstance = axios.create({
|
||||||
|
baseURL: (import.meta as any).env?.VITE_API_BASE_URL2 || "/api",
|
||||||
|
timeout: 20000,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Client: "kefu-client",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
instance.interceptors.request.use((config: any) => {
|
||||||
|
// 在每次请求时动态获取最新的 token2
|
||||||
|
const { token2 } = useUserStore.getState();
|
||||||
|
if (token2) {
|
||||||
|
config.headers = config.headers || {};
|
||||||
|
config.headers["Authorization"] = `bearer ${token2}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
instance.interceptors.response.use(
|
||||||
|
(res: AxiosResponse) => {
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
err => {
|
||||||
|
// 处理401错误,跳转到登录页面
|
||||||
|
if (err.response && err.response.status === 401) {
|
||||||
|
Toast.show({ content: "登录已过期,请重新登录", position: "top" });
|
||||||
|
// 获取当前路径,用于登录后跳回
|
||||||
|
const currentPath = window.location.pathname + window.location.search;
|
||||||
|
window.location.href = `/login?returnUrl=${encodeURIComponent(currentPath)}`;
|
||||||
|
return Promise.reject(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
Toast.show({ content: err.message || "网络异常", position: "top" });
|
||||||
|
return Promise.reject(err);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export function request(
|
||||||
|
url: string,
|
||||||
|
data?: any,
|
||||||
|
method: Method = "GET",
|
||||||
|
config?: RequestConfig,
|
||||||
|
debounceGap?: number,
|
||||||
|
): Promise<any> {
|
||||||
|
const gap =
|
||||||
|
typeof debounceGap === "number" ? debounceGap : DEFAULT_DEBOUNCE_GAP;
|
||||||
|
const key = `${method}_${url}_${JSON.stringify(data)}`;
|
||||||
|
const now = Date.now();
|
||||||
|
const last = debounceMap.get(key) || 0;
|
||||||
|
if (gap > 0 && now - last < gap) {
|
||||||
|
// Toast.show({ content: '请求过于频繁,请稍后再试', position: 'top' });
|
||||||
|
return Promise.reject("请求过于频繁,请稍后再试");
|
||||||
|
}
|
||||||
|
debounceMap.set(key, now);
|
||||||
|
|
||||||
|
const axiosConfig: RequestConfig = {
|
||||||
|
url,
|
||||||
|
method,
|
||||||
|
...config,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (method.toUpperCase() === "GET") {
|
||||||
|
axiosConfig.params = data;
|
||||||
|
} else {
|
||||||
|
axiosConfig.data = data;
|
||||||
|
}
|
||||||
|
return instance(axiosConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default request;
|
||||||
10
Cunkebao/src/components/AccountSelection/api.ts
Normal file
10
Cunkebao/src/components/AccountSelection/api.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import request from "@/api/request";
|
||||||
|
|
||||||
|
// 获取好友列表
|
||||||
|
export function getAccountList(params: {
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
keyword?: string;
|
||||||
|
}) {
|
||||||
|
return request("/v1/workbench/account-list", params, "GET");
|
||||||
|
}
|
||||||
35
Cunkebao/src/components/AccountSelection/data.ts
Normal file
35
Cunkebao/src/components/AccountSelection/data.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
// 账号对象类型
|
||||||
|
export interface AccountItem {
|
||||||
|
id: number;
|
||||||
|
userName: string;
|
||||||
|
realName: string;
|
||||||
|
departmentName: string;
|
||||||
|
avatar?: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
//弹窗的
|
||||||
|
export interface SelectionPopupProps {
|
||||||
|
visible: boolean;
|
||||||
|
onVisibleChange: (visible: boolean) => void;
|
||||||
|
selectedOptions: AccountItem[];
|
||||||
|
onSelect: (options: AccountItem[]) => void;
|
||||||
|
readonly?: boolean;
|
||||||
|
onConfirm?: (selectedOptions: AccountItem[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 组件属性接口
|
||||||
|
export interface AccountSelectionProps {
|
||||||
|
selectedOptions: AccountItem[];
|
||||||
|
onSelect: (options: AccountItem[]) => void;
|
||||||
|
accounts?: AccountItem[]; // 可选:用于在外层显示已选账号详情
|
||||||
|
placeholder?: string;
|
||||||
|
className?: string;
|
||||||
|
visible?: boolean;
|
||||||
|
onVisibleChange?: (visible: boolean) => void;
|
||||||
|
selectedListMaxHeight?: number;
|
||||||
|
showInput?: boolean;
|
||||||
|
showSelectedList?: boolean;
|
||||||
|
readonly?: boolean;
|
||||||
|
onConfirm?: (selectedOptions: AccountItem[]) => void;
|
||||||
|
accountGroups?: any[]; // 传递账号组数据
|
||||||
|
}
|
||||||
231
Cunkebao/src/components/AccountSelection/index.module.scss
Normal file
231
Cunkebao/src/components/AccountSelection/index.module.scss
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
.inputWrapper {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.inputIcon {
|
||||||
|
position: absolute;
|
||||||
|
left: 12px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: #bdbdbd;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
.input {
|
||||||
|
padding-left: 38px !important;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 16px !important;
|
||||||
|
border: 1px solid #e5e6eb !important;
|
||||||
|
font-size: 16px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popupContainer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.popupHeader {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
.popupTitle {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.searchWrapper {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.searchInput {
|
||||||
|
padding-left: 40px !important;
|
||||||
|
padding-top: 8px !important;
|
||||||
|
padding-bottom: 8px !important;
|
||||||
|
border-radius: 24px !important;
|
||||||
|
border: 1px solid #e5e6eb !important;
|
||||||
|
font-size: 15px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
.searchIcon {
|
||||||
|
position: absolute;
|
||||||
|
left: 12px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: #bdbdbd;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.clearBtn {
|
||||||
|
position: absolute;
|
||||||
|
right: 8px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
height: 24px;
|
||||||
|
width: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
min-width: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friendList {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.friendListInner {
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
.friendItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 24px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
&:hover {
|
||||||
|
background: #f5f6fa;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.radioWrapper {
|
||||||
|
margin-right: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.radioSelected {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid #1890ff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.radioUnselected {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid #e5e6eb;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.radioDot {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #1890ff;
|
||||||
|
}
|
||||||
|
.friendInfo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.friendAvatar {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.avatarImg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.friendDetail {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.friendName {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #222;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.friendId {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #888;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.friendCustomer {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #bdbdbd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loadingBox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.loadingText {
|
||||||
|
color: #888;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
.emptyBox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.emptyText {
|
||||||
|
color: #888;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paginationRow {
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.totalCount {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
.paginationControls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.pageBtn {
|
||||||
|
padding: 0 8px;
|
||||||
|
height: 32px;
|
||||||
|
min-width: 32px;
|
||||||
|
}
|
||||||
|
.pageInfo {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popupFooter {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.selectedCount {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
.footerBtnGroup {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.cancelBtn {
|
||||||
|
padding: 0 24px;
|
||||||
|
border-radius: 24px;
|
||||||
|
border: 1px solid #e5e6eb;
|
||||||
|
}
|
||||||
|
.confirmBtn {
|
||||||
|
padding: 0 24px;
|
||||||
|
border-radius: 24px;
|
||||||
|
}
|
||||||
139
Cunkebao/src/components/AccountSelection/index.tsx
Normal file
139
Cunkebao/src/components/AccountSelection/index.tsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { SearchOutlined, DeleteOutlined } from "@ant-design/icons";
|
||||||
|
import { Button, Input } from "antd";
|
||||||
|
import style from "./index.module.scss";
|
||||||
|
import SelectionPopup from "./selectionPopup";
|
||||||
|
import { AccountItem, AccountSelectionProps } from "./data";
|
||||||
|
|
||||||
|
export default function AccountSelection({
|
||||||
|
selectedOptions,
|
||||||
|
onSelect,
|
||||||
|
accounts: propAccounts = [],
|
||||||
|
placeholder = "选择账号",
|
||||||
|
className = "",
|
||||||
|
visible,
|
||||||
|
onVisibleChange,
|
||||||
|
selectedListMaxHeight = 300,
|
||||||
|
showInput = true,
|
||||||
|
showSelectedList = true,
|
||||||
|
readonly = false,
|
||||||
|
onConfirm,
|
||||||
|
}: AccountSelectionProps) {
|
||||||
|
const [popupVisible, setPopupVisible] = useState(false);
|
||||||
|
|
||||||
|
// 受控弹窗逻辑
|
||||||
|
const realVisible = visible !== undefined ? visible : popupVisible;
|
||||||
|
const setRealVisible = (v: boolean) => {
|
||||||
|
if (onVisibleChange) onVisibleChange(v);
|
||||||
|
if (visible === undefined) setPopupVisible(v);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 打开弹窗
|
||||||
|
const openPopup = () => {
|
||||||
|
if (readonly) return;
|
||||||
|
setRealVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取显示文本
|
||||||
|
const getDisplayText = () => {
|
||||||
|
if (selectedOptions.length === 0) return "";
|
||||||
|
return `已选择 ${selectedOptions.length} 个账号`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 删除已选账号
|
||||||
|
const handleRemoveAccount = (id: number) => {
|
||||||
|
if (readonly) return;
|
||||||
|
onSelect(selectedOptions.filter(d => d.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* 输入框 */}
|
||||||
|
{showInput && (
|
||||||
|
<div className={`${style.inputWrapper} ${className}`}>
|
||||||
|
<Input
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={getDisplayText()}
|
||||||
|
onClick={openPopup}
|
||||||
|
prefix={<SearchOutlined />}
|
||||||
|
allowClear={!readonly}
|
||||||
|
size="large"
|
||||||
|
readOnly={readonly}
|
||||||
|
disabled={readonly}
|
||||||
|
style={
|
||||||
|
readonly ? { background: "#f5f5f5", cursor: "not-allowed" } : {}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* 已选账号列表窗口 */}
|
||||||
|
{showSelectedList && selectedOptions.length > 0 && (
|
||||||
|
<div
|
||||||
|
className={style.selectedListWindow}
|
||||||
|
style={{
|
||||||
|
maxHeight: selectedListMaxHeight,
|
||||||
|
overflowY: "auto",
|
||||||
|
marginTop: 8,
|
||||||
|
border: "1px solid #e5e6eb",
|
||||||
|
borderRadius: 8,
|
||||||
|
background: "#fff",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selectedOptions.map(acc => (
|
||||||
|
<div
|
||||||
|
key={acc.id}
|
||||||
|
className={style.selectedListRow}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: "4px 8px",
|
||||||
|
borderBottom: "1px solid #f0f0f0",
|
||||||
|
fontSize: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
【{acc.realName}】 {acc.userName}
|
||||||
|
</div>
|
||||||
|
{!readonly && (
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
size="small"
|
||||||
|
style={{
|
||||||
|
marginLeft: 4,
|
||||||
|
color: "#ff4d4f",
|
||||||
|
border: "none",
|
||||||
|
background: "none",
|
||||||
|
minWidth: 24,
|
||||||
|
height: 24,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
onClick={() => handleRemoveAccount(acc.id)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* 弹窗 */}
|
||||||
|
<SelectionPopup
|
||||||
|
visible={realVisible}
|
||||||
|
onVisibleChange={setRealVisible}
|
||||||
|
selectedOptions={selectedOptions}
|
||||||
|
onSelect={onSelect}
|
||||||
|
readonly={readonly}
|
||||||
|
onConfirm={onConfirm}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
237
Cunkebao/src/components/AccountSelection/selectionPopup.tsx
Normal file
237
Cunkebao/src/components/AccountSelection/selectionPopup.tsx
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { Popup } from "antd-mobile";
|
||||||
|
import Layout from "@/components/Layout/Layout";
|
||||||
|
import PopupHeader from "@/components/PopuLayout/header";
|
||||||
|
import PopupFooter from "@/components/PopuLayout/footer";
|
||||||
|
import style from "./index.module.scss";
|
||||||
|
import { getAccountList } from "./api";
|
||||||
|
import { AccountItem, SelectionPopupProps } from "./data";
|
||||||
|
|
||||||
|
export default function SelectionPopup({
|
||||||
|
visible,
|
||||||
|
onVisibleChange,
|
||||||
|
selectedOptions,
|
||||||
|
onSelect,
|
||||||
|
readonly = false,
|
||||||
|
onConfirm,
|
||||||
|
}: SelectionPopupProps) {
|
||||||
|
const [accounts, setAccounts] = useState<AccountItem[]>([]);
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
|
const [totalAccounts, setTotalAccounts] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [tempSelectedOptions, setTempSelectedOptions] = useState<AccountItem[]>(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 累积已加载过的账号,确保确认时能返回更完整的对象
|
||||||
|
const loadedAccountMapRef = useRef<Map<number, AccountItem>>(new Map());
|
||||||
|
|
||||||
|
const pageSize = 20;
|
||||||
|
|
||||||
|
const fetchAccounts = async (page: number, keyword: string = "") => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const params: any = { page, limit: pageSize };
|
||||||
|
if (keyword.trim()) params.keyword = keyword.trim();
|
||||||
|
|
||||||
|
const response = await getAccountList(params);
|
||||||
|
if (response && response.list) {
|
||||||
|
setAccounts(response.list);
|
||||||
|
const total: number = response.total || response.list.length || 0;
|
||||||
|
setTotalAccounts(total);
|
||||||
|
setTotalPages(Math.max(1, Math.ceil(total / pageSize)));
|
||||||
|
|
||||||
|
// 累积到映射表
|
||||||
|
response.list.forEach((acc: AccountItem) => {
|
||||||
|
loadedAccountMapRef.current.set(acc.id, acc);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setAccounts([]);
|
||||||
|
setTotalAccounts(0);
|
||||||
|
setTotalPages(1);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("获取账号列表失败:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAccountToggle = (account: AccountItem) => {
|
||||||
|
if (readonly || !onSelect) return;
|
||||||
|
const isSelected = tempSelectedOptions.some(opt => opt.id === account.id);
|
||||||
|
const next = isSelected
|
||||||
|
? tempSelectedOptions.filter(opt => opt.id !== account.id)
|
||||||
|
: tempSelectedOptions.concat(account);
|
||||||
|
setTempSelectedOptions(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 全选当前页
|
||||||
|
const handleSelectAllCurrentPage = (checked: boolean) => {
|
||||||
|
if (readonly) return;
|
||||||
|
|
||||||
|
if (checked) {
|
||||||
|
// 全选:添加当前页面所有未选中的账号
|
||||||
|
const currentPageAccounts = accounts.filter(
|
||||||
|
account => !tempSelectedOptions.some(a => a.id === account.id),
|
||||||
|
);
|
||||||
|
setTempSelectedOptions(prev => [...prev, ...currentPageAccounts]);
|
||||||
|
} else {
|
||||||
|
// 取消全选:移除当前页面的所有账号
|
||||||
|
const currentPageAccountIds = accounts.map(a => a.id);
|
||||||
|
setTempSelectedOptions(prev =>
|
||||||
|
prev.filter(a => !currentPageAccountIds.includes(a.id)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 检查当前页是否全选
|
||||||
|
const isCurrentPageAllSelected =
|
||||||
|
accounts.length > 0 &&
|
||||||
|
accounts.every(account =>
|
||||||
|
tempSelectedOptions.some(a => a.id === account.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
if (onConfirm) {
|
||||||
|
onConfirm(tempSelectedOptions);
|
||||||
|
}
|
||||||
|
if (onSelect) {
|
||||||
|
onSelect(tempSelectedOptions);
|
||||||
|
}
|
||||||
|
onVisibleChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 弹窗打开时初始化数据
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
setCurrentPage(1);
|
||||||
|
setSearchQuery("");
|
||||||
|
loadedAccountMapRef.current.clear();
|
||||||
|
// 复制一份selectedOptions到临时变量
|
||||||
|
setTempSelectedOptions([...selectedOptions]);
|
||||||
|
fetchAccounts(1, "");
|
||||||
|
}
|
||||||
|
}, [visible, selectedOptions]);
|
||||||
|
|
||||||
|
// 搜索防抖
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) return;
|
||||||
|
if (searchQuery === "") return;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setCurrentPage(1);
|
||||||
|
fetchAccounts(1, searchQuery);
|
||||||
|
}, 500);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [searchQuery, visible]);
|
||||||
|
|
||||||
|
// 页码变化
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) return;
|
||||||
|
fetchAccounts(currentPage, searchQuery);
|
||||||
|
}, [currentPage, visible, searchQuery]);
|
||||||
|
|
||||||
|
const selectedIdSet = useMemo(
|
||||||
|
() => new Set(tempSelectedOptions.map(opt => opt.id)),
|
||||||
|
[tempSelectedOptions],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popup
|
||||||
|
visible={visible && !readonly}
|
||||||
|
onMaskClick={() => onVisibleChange(false)}
|
||||||
|
position="bottom"
|
||||||
|
bodyStyle={{ height: "100vh" }}
|
||||||
|
>
|
||||||
|
<Layout
|
||||||
|
header={
|
||||||
|
<PopupHeader
|
||||||
|
title="选择账号"
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
setSearchQuery={setSearchQuery}
|
||||||
|
searchPlaceholder="搜索账号"
|
||||||
|
loading={loading}
|
||||||
|
onRefresh={() => fetchAccounts(currentPage, searchQuery)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
footer={
|
||||||
|
<PopupFooter
|
||||||
|
currentPage={currentPage}
|
||||||
|
totalPages={totalPages}
|
||||||
|
loading={loading}
|
||||||
|
selectedCount={tempSelectedOptions.length}
|
||||||
|
onPageChange={setCurrentPage}
|
||||||
|
onCancel={() => onVisibleChange(false)}
|
||||||
|
onConfirm={handleConfirm}
|
||||||
|
isAllSelected={isCurrentPageAllSelected}
|
||||||
|
onSelectAll={handleSelectAllCurrentPage}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={style.friendList}>
|
||||||
|
{loading ? (
|
||||||
|
<div className={style.loadingBox}>
|
||||||
|
<div className={style.loadingText}>加载中...</div>
|
||||||
|
</div>
|
||||||
|
) : accounts.length > 0 ? (
|
||||||
|
<div className={style.friendListInner}>
|
||||||
|
{accounts.map(acc => (
|
||||||
|
<label
|
||||||
|
key={acc.id}
|
||||||
|
className={style.friendItem}
|
||||||
|
onClick={() => !readonly && handleAccountToggle(acc)}
|
||||||
|
>
|
||||||
|
<div className={style.radioWrapper}>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
selectedIdSet.has(acc.id)
|
||||||
|
? style.radioSelected
|
||||||
|
: style.radioUnselected
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{selectedIdSet.has(acc.id) && (
|
||||||
|
<div className={style.radioDot}></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={style.friendInfo}>
|
||||||
|
<div className={style.friendAvatar}>
|
||||||
|
{acc.avatar ? (
|
||||||
|
<img
|
||||||
|
src={acc.avatar}
|
||||||
|
alt={acc.userName}
|
||||||
|
className={style.avatarImg}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
(acc.userName?.charAt(0) ?? "?")
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={style.friendDetail}>
|
||||||
|
<div className={style.friendName}>{acc.userName}</div>
|
||||||
|
<div className={style.friendId}>
|
||||||
|
真实姓名: {acc.realName}
|
||||||
|
</div>
|
||||||
|
<div className={style.friendId}>
|
||||||
|
部门: {acc.departmentName}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={style.emptyBox}>
|
||||||
|
<div className={style.emptyText}>
|
||||||
|
{searchQuery
|
||||||
|
? `没有找到包含"${searchQuery}"的账号`
|
||||||
|
: "没有找到账号"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
</Popup>
|
||||||
|
);
|
||||||
|
}
|
||||||
228
Cunkebao/src/components/AndroidCompatibilityCheck.tsx
Normal file
228
Cunkebao/src/components/AndroidCompatibilityCheck.tsx
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
interface AndroidCompatibilityInfo {
|
||||||
|
isAndroid: boolean;
|
||||||
|
androidVersion: number;
|
||||||
|
chromeVersion: number;
|
||||||
|
webViewVersion: number;
|
||||||
|
issues: string[];
|
||||||
|
suggestions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const AndroidCompatibilityCheck: React.FC = () => {
|
||||||
|
const [compatibility, setCompatibility] = useState<AndroidCompatibilityInfo>({
|
||||||
|
isAndroid: false,
|
||||||
|
androidVersion: 0,
|
||||||
|
chromeVersion: 0,
|
||||||
|
webViewVersion: 0,
|
||||||
|
issues: [],
|
||||||
|
suggestions: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const checkAndroidCompatibility = () => {
|
||||||
|
const ua = navigator.userAgent;
|
||||||
|
const issues: string[] = [];
|
||||||
|
const suggestions: string[] = [];
|
||||||
|
let isAndroid = false;
|
||||||
|
let androidVersion = 0;
|
||||||
|
let chromeVersion = 0;
|
||||||
|
let webViewVersion = 0;
|
||||||
|
|
||||||
|
// 检测Android系统
|
||||||
|
if (ua.indexOf("Android") > -1) {
|
||||||
|
isAndroid = true;
|
||||||
|
const androidMatch = ua.match(/Android\s+(\d+)/);
|
||||||
|
if (androidMatch) {
|
||||||
|
androidVersion = parseInt(androidMatch[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测Chrome版本
|
||||||
|
const chromeMatch = ua.match(/Chrome\/(\d+)/);
|
||||||
|
if (chromeMatch) {
|
||||||
|
chromeVersion = parseInt(chromeMatch[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测WebView版本
|
||||||
|
const webViewMatch = ua.match(/Version\/\d+\.\d+/);
|
||||||
|
if (webViewMatch) {
|
||||||
|
const versionMatch = webViewMatch[0].match(/\d+/);
|
||||||
|
if (versionMatch) {
|
||||||
|
webViewVersion = parseInt(versionMatch[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Android 7 (API 24) 兼容性检查
|
||||||
|
if (androidVersion === 7) {
|
||||||
|
issues.push("Android 7 系统对ES6+特性支持不完整");
|
||||||
|
suggestions.push("建议升级到Android 8+或使用最新版Chrome");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Android 6 (API 23) 兼容性检查
|
||||||
|
if (androidVersion === 6) {
|
||||||
|
issues.push("Android 6 系统对现代Web特性支持有限");
|
||||||
|
suggestions.push("强烈建议升级系统或使用最新版Chrome");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chrome版本检查
|
||||||
|
if (chromeVersion > 0 && chromeVersion < 50) {
|
||||||
|
issues.push(`Chrome版本过低 (${chromeVersion}),建议升级到50+`);
|
||||||
|
suggestions.push("请在Google Play商店更新Chrome浏览器");
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebView版本检查
|
||||||
|
if (webViewVersion > 0 && webViewVersion < 50) {
|
||||||
|
issues.push(`WebView版本过低 (${webViewVersion}),可能影响应用功能`);
|
||||||
|
suggestions.push("建议使用Chrome浏览器或更新系统WebView");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测特定问题
|
||||||
|
const features = {
|
||||||
|
Promise: typeof Promise !== "undefined",
|
||||||
|
fetch: typeof fetch !== "undefined",
|
||||||
|
"Array.from": typeof Array.from !== "undefined",
|
||||||
|
"Object.assign": typeof Object.assign !== "undefined",
|
||||||
|
"String.includes": typeof String.prototype.includes !== "undefined",
|
||||||
|
"Array.includes": typeof Array.prototype.includes !== "undefined",
|
||||||
|
requestAnimationFrame: typeof requestAnimationFrame !== "undefined",
|
||||||
|
IntersectionObserver: typeof IntersectionObserver !== "undefined",
|
||||||
|
ResizeObserver: typeof ResizeObserver !== "undefined",
|
||||||
|
URLSearchParams: typeof URLSearchParams !== "undefined",
|
||||||
|
TextEncoder: typeof TextEncoder !== "undefined",
|
||||||
|
AbortController: typeof AbortController !== "undefined",
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.entries(features).forEach(([feature, supported]) => {
|
||||||
|
if (!supported) {
|
||||||
|
issues.push(`${feature} 特性不支持`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 微信内置浏览器检测
|
||||||
|
if (ua.indexOf("MicroMessenger") > -1) {
|
||||||
|
issues.push("微信内置浏览器对某些Web特性支持有限");
|
||||||
|
suggestions.push("建议在系统浏览器中打开以获得最佳体验");
|
||||||
|
}
|
||||||
|
|
||||||
|
// QQ内置浏览器检测
|
||||||
|
if (ua.indexOf("QQ/") > -1) {
|
||||||
|
issues.push("QQ内置浏览器对某些Web特性支持有限");
|
||||||
|
suggestions.push("建议在系统浏览器中打开以获得最佳体验");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setCompatibility({
|
||||||
|
isAndroid,
|
||||||
|
androidVersion,
|
||||||
|
chromeVersion,
|
||||||
|
webViewVersion,
|
||||||
|
issues,
|
||||||
|
suggestions,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
checkAndroidCompatibility();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!compatibility.isAndroid || compatibility.issues.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
backgroundColor: "#fff3cd",
|
||||||
|
border: "1px solid #ffeaa7",
|
||||||
|
padding: "15px",
|
||||||
|
zIndex: 9999,
|
||||||
|
textAlign: "center",
|
||||||
|
fontSize: "14px",
|
||||||
|
maxHeight: "50vh",
|
||||||
|
overflowY: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{ fontWeight: "bold", marginBottom: "10px", color: "#856404" }}
|
||||||
|
>
|
||||||
|
🚨 Android 兼容性警告
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: "8px", fontSize: "12px" }}>
|
||||||
|
系统版本: Android {compatibility.androidVersion}
|
||||||
|
{compatibility.chromeVersion > 0 &&
|
||||||
|
` | Chrome: ${compatibility.chromeVersion}`}
|
||||||
|
{compatibility.webViewVersion > 0 &&
|
||||||
|
` | WebView: ${compatibility.webViewVersion}`}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: "10px" }}>
|
||||||
|
<div
|
||||||
|
style={{ fontWeight: "bold", marginBottom: "5px", color: "#856404" }}
|
||||||
|
>
|
||||||
|
检测到的问题:
|
||||||
|
</div>
|
||||||
|
<div style={{ color: "#856404", fontSize: "12px" }}>
|
||||||
|
{compatibility.issues.map((issue, index) => (
|
||||||
|
<div key={index} style={{ marginBottom: "3px" }}>
|
||||||
|
• {issue}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{compatibility.suggestions.length > 0 && (
|
||||||
|
<div style={{ marginBottom: "10px" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontWeight: "bold",
|
||||||
|
marginBottom: "5px",
|
||||||
|
color: "#155724",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
建议解决方案:
|
||||||
|
</div>
|
||||||
|
<div style={{ color: "#155724", fontSize: "12px" }}>
|
||||||
|
{compatibility.suggestions.map((suggestion, index) => (
|
||||||
|
<div key={index} style={{ marginBottom: "3px" }}>
|
||||||
|
• {suggestion}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ fontSize: "11px", color: "#6c757d", marginTop: "10px" }}>
|
||||||
|
💡 应用已启用兼容模式,但建议升级系统以获得最佳体验
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const element = document.querySelector(
|
||||||
|
'[style*="position: fixed"][style*="top: 0"]',
|
||||||
|
) as HTMLElement;
|
||||||
|
if (element) {
|
||||||
|
element.style.display = "none";
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "5px",
|
||||||
|
right: "10px",
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
fontSize: "18px",
|
||||||
|
cursor: "pointer",
|
||||||
|
color: "#856404",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AndroidCompatibilityCheck;
|
||||||
125
Cunkebao/src/components/CompatibilityCheck.tsx
Normal file
125
Cunkebao/src/components/CompatibilityCheck.tsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
interface CompatibilityInfo {
|
||||||
|
isCompatible: boolean;
|
||||||
|
browser: string;
|
||||||
|
version: string;
|
||||||
|
issues: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const CompatibilityCheck: React.FC = () => {
|
||||||
|
const [compatibility, setCompatibility] = useState<CompatibilityInfo>({
|
||||||
|
isCompatible: true,
|
||||||
|
browser: "",
|
||||||
|
version: "",
|
||||||
|
issues: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const checkCompatibility = () => {
|
||||||
|
const ua = navigator.userAgent;
|
||||||
|
const issues: string[] = [];
|
||||||
|
let browser = "Unknown";
|
||||||
|
let version = "Unknown";
|
||||||
|
|
||||||
|
// 检测浏览器类型和版本
|
||||||
|
if (ua.indexOf("Chrome") > -1) {
|
||||||
|
browser = "Chrome";
|
||||||
|
const match = ua.match(/Chrome\/(\d+)/);
|
||||||
|
version = match ? match[1] : "Unknown";
|
||||||
|
if (parseInt(version) < 50) {
|
||||||
|
issues.push("Chrome版本过低,建议升级到50+");
|
||||||
|
}
|
||||||
|
} else if (ua.indexOf("Firefox") > -1) {
|
||||||
|
browser = "Firefox";
|
||||||
|
const match = ua.match(/Firefox\/(\d+)/);
|
||||||
|
version = match ? match[1] : "Unknown";
|
||||||
|
if (parseInt(version) < 50) {
|
||||||
|
issues.push("Firefox版本过低,建议升级到50+");
|
||||||
|
}
|
||||||
|
} else if (ua.indexOf("Safari") > -1 && ua.indexOf("Chrome") === -1) {
|
||||||
|
browser = "Safari";
|
||||||
|
const match = ua.match(/Version\/(\d+)/);
|
||||||
|
version = match ? match[1] : "Unknown";
|
||||||
|
if (parseInt(version) < 10) {
|
||||||
|
issues.push("Safari版本过低,建议升级到10+");
|
||||||
|
}
|
||||||
|
} else if (ua.indexOf("MSIE") > -1 || ua.indexOf("Trident") > -1) {
|
||||||
|
browser = "Internet Explorer";
|
||||||
|
const match = ua.match(/(?:MSIE |rv:)(\d+)/);
|
||||||
|
version = match ? match[1] : "Unknown";
|
||||||
|
issues.push("Internet Explorer不受支持,建议使用现代浏览器");
|
||||||
|
} else if (ua.indexOf("Edge") > -1) {
|
||||||
|
browser = "Edge";
|
||||||
|
const match = ua.match(/Edge\/(\d+)/);
|
||||||
|
version = match ? match[1] : "Unknown";
|
||||||
|
if (parseInt(version) < 12) {
|
||||||
|
issues.push("Edge版本过低,建议升级到12+");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测ES6+特性支持
|
||||||
|
const features = {
|
||||||
|
Promise: typeof Promise !== "undefined",
|
||||||
|
fetch: typeof fetch !== "undefined",
|
||||||
|
"Array.from": typeof Array.from !== "undefined",
|
||||||
|
"Object.assign": typeof Object.assign !== "undefined",
|
||||||
|
"String.includes": typeof String.prototype.includes !== "undefined",
|
||||||
|
"Array.includes": typeof Array.prototype.includes !== "undefined",
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.entries(features).forEach(([feature, supported]) => {
|
||||||
|
if (!supported) {
|
||||||
|
issues.push(`${feature} 特性不支持`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setCompatibility({
|
||||||
|
isCompatible: issues.length === 0,
|
||||||
|
browser,
|
||||||
|
version,
|
||||||
|
issues,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
checkCompatibility();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (compatibility.isCompatible) {
|
||||||
|
return null; // 兼容时不需要显示
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
backgroundColor: "#fff3cd",
|
||||||
|
border: "1px solid #ffeaa7",
|
||||||
|
padding: "10px",
|
||||||
|
zIndex: 9999,
|
||||||
|
textAlign: "center",
|
||||||
|
fontSize: "14px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: "bold", marginBottom: "5px" }}>
|
||||||
|
浏览器兼容性警告
|
||||||
|
</div>
|
||||||
|
<div style={{ marginBottom: "5px" }}>
|
||||||
|
当前浏览器: {compatibility.browser} {compatibility.version}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: "#856404" }}>
|
||||||
|
{compatibility.issues.map((issue, index) => (
|
||||||
|
<div key={index}>{issue}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: "10px", fontSize: "12px" }}>
|
||||||
|
建议使用 Chrome 50+、Firefox 50+、Safari 10+ 或 Edge 12+
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CompatibilityCheck;
|
||||||
5
Cunkebao/src/components/ContentSelection/api.ts
Normal file
5
Cunkebao/src/components/ContentSelection/api.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import request from "@/api/request";
|
||||||
|
|
||||||
|
export function getContentLibraryList(params: any) {
|
||||||
|
return request("/v1/content/library/list", { ...params, formType: 0 }, "GET");
|
||||||
|
}
|
||||||
21
Cunkebao/src/components/ContentSelection/data.ts
Normal file
21
Cunkebao/src/components/ContentSelection/data.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
// 内容库接口类型
|
||||||
|
export interface ContentItem {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 组件属性接口
|
||||||
|
export interface ContentSelectionProps {
|
||||||
|
selectedOptions: ContentItem[];
|
||||||
|
onSelect: (selectedItems: ContentItem[]) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
className?: string;
|
||||||
|
visible?: boolean;
|
||||||
|
onVisibleChange?: (visible: boolean) => void;
|
||||||
|
selectedListMaxHeight?: number;
|
||||||
|
showInput?: boolean;
|
||||||
|
showSelectedList?: boolean;
|
||||||
|
readonly?: boolean;
|
||||||
|
onConfirm?: (selectedItems: ContentItem[]) => void;
|
||||||
|
}
|
||||||
117
Cunkebao/src/components/ContentSelection/index.module.scss
Normal file
117
Cunkebao/src/components/ContentSelection/index.module.scss
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
.inputWrapper {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.selectedListWindow {
|
||||||
|
margin-top: 8px;
|
||||||
|
border: 1px solid #e5e6eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.selectedListRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.libraryList {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.libraryListInner {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.libraryItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid #f0f0f0;
|
||||||
|
background: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
&:hover {
|
||||||
|
background: #f5f6fa;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.checkboxWrapper {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.checkboxSelected {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #1677ff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.checkboxUnselected {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #e5e6eb;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.checkboxDot {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.libraryInfo {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.libraryHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.libraryName {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #222;
|
||||||
|
}
|
||||||
|
.typeTag {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #1677ff;
|
||||||
|
border: 1px solid #1677ff;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 2px 10px;
|
||||||
|
margin-left: 8px;
|
||||||
|
background: #f4f8ff;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.libraryMeta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
.libraryDesc {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #888;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.loadingBox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.loadingText {
|
||||||
|
color: #888;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
.emptyBox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100px;
|
||||||
|
}
|
||||||
|
.emptyText {
|
||||||
|
color: #888;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
145
Cunkebao/src/components/ContentSelection/index.tsx
Normal file
145
Cunkebao/src/components/ContentSelection/index.tsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { SearchOutlined, DeleteOutlined } from "@ant-design/icons";
|
||||||
|
import { Button, Input } from "antd";
|
||||||
|
import style from "./index.module.scss";
|
||||||
|
import { ContentItem, ContentSelectionProps } from "./data";
|
||||||
|
import SelectionPopup from "./selectionPopup";
|
||||||
|
|
||||||
|
const ContentSelection: React.FC<ContentSelectionProps> = ({
|
||||||
|
selectedOptions,
|
||||||
|
onSelect,
|
||||||
|
placeholder = "选择内容库",
|
||||||
|
className = "",
|
||||||
|
visible,
|
||||||
|
onVisibleChange,
|
||||||
|
selectedListMaxHeight = 300,
|
||||||
|
showInput = true,
|
||||||
|
showSelectedList = true,
|
||||||
|
readonly = false,
|
||||||
|
onConfirm,
|
||||||
|
}) => {
|
||||||
|
// 弹窗控制
|
||||||
|
const [popupVisible, setPopupVisible] = useState(false);
|
||||||
|
const realVisible = visible !== undefined ? visible : popupVisible;
|
||||||
|
const setRealVisible = (v: boolean) => {
|
||||||
|
if (onVisibleChange) onVisibleChange(v);
|
||||||
|
if (visible === undefined) setPopupVisible(v);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 打开弹窗
|
||||||
|
const openPopup = () => {
|
||||||
|
if (readonly) return;
|
||||||
|
setRealVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取显示文本
|
||||||
|
const getDisplayText = () => {
|
||||||
|
if (selectedOptions.length === 0) return "";
|
||||||
|
return `已选择 ${selectedOptions.length} 个内容库`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 删除已选内容库
|
||||||
|
const handleRemoveLibrary = (id: number) => {
|
||||||
|
if (readonly) return;
|
||||||
|
onSelect(selectedOptions.filter(c => c.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 清除所有已选内容库
|
||||||
|
const handleClearAll = () => {
|
||||||
|
if (readonly) return;
|
||||||
|
onSelect([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* 输入框 */}
|
||||||
|
{showInput && (
|
||||||
|
<div className={`${style.inputWrapper} ${className}`}>
|
||||||
|
<Input
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={getDisplayText()}
|
||||||
|
onClick={openPopup}
|
||||||
|
prefix={<SearchOutlined />}
|
||||||
|
allowClear={!readonly}
|
||||||
|
onClear={handleClearAll}
|
||||||
|
size="large"
|
||||||
|
readOnly={readonly}
|
||||||
|
disabled={readonly}
|
||||||
|
style={
|
||||||
|
readonly ? { background: "#f5f5f5", cursor: "not-allowed" } : {}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* 已选内容库列表窗口 */}
|
||||||
|
{showSelectedList && selectedOptions.length > 0 && (
|
||||||
|
<div
|
||||||
|
className={style.selectedListWindow}
|
||||||
|
style={{
|
||||||
|
maxHeight: selectedListMaxHeight,
|
||||||
|
overflowY: "auto",
|
||||||
|
marginTop: 8,
|
||||||
|
border: "1px solid #e5e6eb",
|
||||||
|
borderRadius: 8,
|
||||||
|
background: "#fff",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selectedOptions.map(item => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className={style.selectedListRow}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: "4px 8px",
|
||||||
|
borderBottom: "1px solid #f0f0f0",
|
||||||
|
fontSize: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.name || item.id}
|
||||||
|
</div>
|
||||||
|
{!readonly && (
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
size="small"
|
||||||
|
style={{
|
||||||
|
marginLeft: 4,
|
||||||
|
color: "#ff4d4f",
|
||||||
|
border: "none",
|
||||||
|
background: "none",
|
||||||
|
minWidth: 24,
|
||||||
|
height: 24,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
onClick={() => handleRemoveLibrary(item.id)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* 弹窗 */}
|
||||||
|
<SelectionPopup
|
||||||
|
visible={realVisible && !readonly}
|
||||||
|
onClose={() => setRealVisible(false)}
|
||||||
|
selectedOptions={selectedOptions}
|
||||||
|
onSelect={onSelect}
|
||||||
|
onConfirm={onConfirm}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ContentSelection;
|
||||||
257
Cunkebao/src/components/ContentSelection/selectionPopup.tsx
Normal file
257
Cunkebao/src/components/ContentSelection/selectionPopup.tsx
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { Checkbox, Popup } from "antd-mobile";
|
||||||
|
import { getContentLibraryList } from "./api";
|
||||||
|
import style from "./index.module.scss";
|
||||||
|
import Layout from "@/components/Layout/Layout";
|
||||||
|
import PopupHeader from "@/components/PopuLayout/header";
|
||||||
|
import PopupFooter from "@/components/PopuLayout/footer";
|
||||||
|
import { ContentItem } from "./data";
|
||||||
|
|
||||||
|
interface SelectionPopupProps {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
selectedOptions: ContentItem[];
|
||||||
|
onSelect: (libraries: ContentItem[]) => void;
|
||||||
|
onConfirm?: (libraries: ContentItem[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
|
// 类型标签文本
|
||||||
|
const getTypeText = (type?: number) => {
|
||||||
|
if (type === 1) return "文本";
|
||||||
|
if (type === 2) return "图片";
|
||||||
|
if (type === 3) return "视频";
|
||||||
|
return "未知";
|
||||||
|
};
|
||||||
|
|
||||||
|
// 时间格式化
|
||||||
|
const formatDate = (dateStr?: string) => {
|
||||||
|
if (!dateStr) return "-";
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
if (isNaN(d.getTime())) return "-";
|
||||||
|
return `${d.getFullYear()}/${(d.getMonth() + 1)
|
||||||
|
.toString()
|
||||||
|
.padStart(2, "0")}/${d.getDate().toString().padStart(2, "0")} ${d
|
||||||
|
.getHours()
|
||||||
|
.toString()
|
||||||
|
.padStart(2, "0")}:${d.getMinutes().toString().padStart(2, "0")}:${d
|
||||||
|
.getSeconds()
|
||||||
|
.toString()
|
||||||
|
.padStart(2, "0")}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SelectionPopup: React.FC<SelectionPopupProps> = ({
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
selectedOptions,
|
||||||
|
onSelect,
|
||||||
|
onConfirm,
|
||||||
|
}) => {
|
||||||
|
// 内容库数据
|
||||||
|
const [libraries, setLibraries] = useState<ContentItem[]>([]);
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [loading, setLoading] = useState(true); // 默认设置为加载中状态
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
|
const [totalLibraries, setTotalLibraries] = useState(0);
|
||||||
|
const [tempSelectedOptions, setTempSelectedOptions] = useState<ContentItem[]>(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 获取内容库列表,支持keyword和分页
|
||||||
|
const fetchLibraries = async (page: number, keyword: string = "") => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const params: any = {
|
||||||
|
page,
|
||||||
|
limit: PAGE_SIZE,
|
||||||
|
};
|
||||||
|
if (keyword.trim()) {
|
||||||
|
params.keyword = keyword.trim();
|
||||||
|
}
|
||||||
|
const response = await getContentLibraryList(params);
|
||||||
|
if (response && response.list) {
|
||||||
|
setLibraries(response.list);
|
||||||
|
setTotalLibraries(response.total || 0);
|
||||||
|
setTotalPages(Math.ceil((response.total || 0) / PAGE_SIZE));
|
||||||
|
} else {
|
||||||
|
// 如果没有返回列表数据,设置为空数组
|
||||||
|
setLibraries([]);
|
||||||
|
setTotalLibraries(0);
|
||||||
|
setTotalPages(1);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("获取内容库列表失败:", error);
|
||||||
|
// 请求失败时,设置为空数组
|
||||||
|
setLibraries([]);
|
||||||
|
setTotalLibraries(0);
|
||||||
|
setTotalPages(1);
|
||||||
|
} finally {
|
||||||
|
setTimeout(() => {
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 打开弹窗时获取第一页
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
setSearchQuery("");
|
||||||
|
setCurrentPage(1);
|
||||||
|
// 复制一份selectedOptions到临时变量
|
||||||
|
setTempSelectedOptions([...selectedOptions]);
|
||||||
|
// 设置loading状态,避免显示空内容
|
||||||
|
setLoading(true);
|
||||||
|
fetchLibraries(1, "");
|
||||||
|
} else {
|
||||||
|
// 关闭弹窗时重置加载状态,确保下次打开时显示加载中
|
||||||
|
setLoading(true);
|
||||||
|
}
|
||||||
|
}, [visible, selectedOptions]);
|
||||||
|
|
||||||
|
// 搜索处理函数
|
||||||
|
const handleSearch = (query: string) => {
|
||||||
|
if (!visible) return;
|
||||||
|
setCurrentPage(1);
|
||||||
|
fetchLibraries(1, query);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 搜索输入变化时的处理
|
||||||
|
const handleSearchChange = (query: string) => {
|
||||||
|
setSearchQuery(query);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 翻页处理函数
|
||||||
|
const handlePageChange = (page: number) => {
|
||||||
|
if (!visible || page === currentPage) return;
|
||||||
|
setCurrentPage(page);
|
||||||
|
fetchLibraries(page, searchQuery);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理内容库选择
|
||||||
|
const handleLibraryToggle = (library: ContentItem) => {
|
||||||
|
const newSelected = tempSelectedOptions.some(c => c.id === library.id)
|
||||||
|
? tempSelectedOptions.filter(c => c.id !== library.id)
|
||||||
|
: [...tempSelectedOptions, library];
|
||||||
|
setTempSelectedOptions(newSelected);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 全选当前页
|
||||||
|
const handleSelectAllCurrentPage = (checked: boolean) => {
|
||||||
|
if (checked) {
|
||||||
|
// 全选:添加当前页面所有未选中的内容库
|
||||||
|
const currentPageLibraries = libraries.filter(
|
||||||
|
library => !tempSelectedOptions.some(l => l.id === library.id),
|
||||||
|
);
|
||||||
|
setTempSelectedOptions(prev => [...prev, ...currentPageLibraries]);
|
||||||
|
} else {
|
||||||
|
// 取消全选:移除当前页面的所有内容库
|
||||||
|
const currentPageLibraryIds = libraries.map(l => l.id);
|
||||||
|
setTempSelectedOptions(prev =>
|
||||||
|
prev.filter(l => !currentPageLibraryIds.includes(l.id)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 检查当前页是否全选
|
||||||
|
const isCurrentPageAllSelected =
|
||||||
|
libraries.length > 0 &&
|
||||||
|
libraries.every(library =>
|
||||||
|
tempSelectedOptions.some(l => l.id === library.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 确认选择
|
||||||
|
const handleConfirm = () => {
|
||||||
|
// 用户点击确认时,才更新实际的selectedOptions
|
||||||
|
onSelect(tempSelectedOptions);
|
||||||
|
if (onConfirm) {
|
||||||
|
onConfirm(tempSelectedOptions);
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
// 渲染内容库列表或空状态提示
|
||||||
|
const OptionsList = () => {
|
||||||
|
return libraries.length > 0 ? (
|
||||||
|
<div className={style.libraryListInner}>
|
||||||
|
{libraries.map(item => (
|
||||||
|
<label key={item.id} className={style.libraryItem}>
|
||||||
|
<Checkbox
|
||||||
|
checked={tempSelectedOptions.map(c => c.id).includes(item.id)}
|
||||||
|
onChange={() => handleLibraryToggle(item)}
|
||||||
|
className={style.checkboxWrapper}
|
||||||
|
/>
|
||||||
|
<div className={style.libraryInfo}>
|
||||||
|
<div className={style.libraryHeader}>
|
||||||
|
<span className={style.libraryName}>{item.name}</span>
|
||||||
|
<span className={style.typeTag}>
|
||||||
|
{getTypeText(item.sourceType)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={style.libraryMeta}>
|
||||||
|
<div>创建人: {item.creatorName || "-"}</div>
|
||||||
|
<div>更新时间: {formatDate(item.updateTime)}</div>
|
||||||
|
</div>
|
||||||
|
{item.description && (
|
||||||
|
<div className={style.libraryDesc}>{item.description}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={style.emptyBox}>
|
||||||
|
<div className={style.emptyText}>数据为空</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popup
|
||||||
|
visible={visible}
|
||||||
|
onMaskClick={onClose}
|
||||||
|
position="bottom"
|
||||||
|
bodyStyle={{ height: "100vh" }}
|
||||||
|
closeOnMaskClick={false}
|
||||||
|
>
|
||||||
|
<Layout
|
||||||
|
header={
|
||||||
|
<PopupHeader
|
||||||
|
title="选择内容库"
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
setSearchQuery={handleSearchChange}
|
||||||
|
searchPlaceholder="搜索内容库"
|
||||||
|
loading={loading}
|
||||||
|
onSearch={handleSearch}
|
||||||
|
onRefresh={() => fetchLibraries(currentPage, searchQuery)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
footer={
|
||||||
|
<PopupFooter
|
||||||
|
currentPage={currentPage}
|
||||||
|
totalPages={totalPages}
|
||||||
|
loading={loading}
|
||||||
|
selectedCount={tempSelectedOptions.length}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
onCancel={onClose}
|
||||||
|
onConfirm={handleConfirm}
|
||||||
|
isAllSelected={isCurrentPageAllSelected}
|
||||||
|
onSelectAll={handleSelectAllCurrentPage}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={style.libraryList}>
|
||||||
|
{loading ? (
|
||||||
|
<div className={style.loadingBox}>
|
||||||
|
<div className={style.loadingText}>加载中...</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
OptionsList()
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
</Popup>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SelectionPopup;
|
||||||
10
Cunkebao/src/components/DeviceSelection/api.ts
Normal file
10
Cunkebao/src/components/DeviceSelection/api.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import request from "@/api/request";
|
||||||
|
|
||||||
|
// 获取设备列表
|
||||||
|
export function getDeviceList(params: {
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
keyword?: string;
|
||||||
|
}) {
|
||||||
|
return request("/v1/devices", params, "GET");
|
||||||
|
}
|
||||||
30
Cunkebao/src/components/DeviceSelection/data.ts
Normal file
30
Cunkebao/src/components/DeviceSelection/data.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
// 设备选择项接口
|
||||||
|
export interface DeviceSelectionItem {
|
||||||
|
id: number;
|
||||||
|
memo: string;
|
||||||
|
imei: string;
|
||||||
|
wechatId: string;
|
||||||
|
status: "online" | "offline";
|
||||||
|
wxid?: string;
|
||||||
|
nickname?: string;
|
||||||
|
usedInPlans?: number;
|
||||||
|
avatar?: string;
|
||||||
|
totalFriend?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 组件属性接口
|
||||||
|
export interface DeviceSelectionProps {
|
||||||
|
selectedOptions: DeviceSelectionItem[];
|
||||||
|
onSelect: (devices: DeviceSelectionItem[]) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
className?: string;
|
||||||
|
mode?: "input" | "dialog"; // 新增,默认input
|
||||||
|
open?: boolean; // 仅mode=dialog时生效
|
||||||
|
onOpenChange?: (open: boolean) => void; // 仅mode=dialog时生效
|
||||||
|
selectedListMaxHeight?: number; // 新增,已选列表最大高度,默认500
|
||||||
|
showInput?: boolean; // 新增
|
||||||
|
showSelectedList?: boolean; // 新增
|
||||||
|
readonly?: boolean; // 新增
|
||||||
|
deviceGroups?: any[]; // 传递设备组数据
|
||||||
|
singleSelect?: boolean; // 新增,是否单选模式
|
||||||
|
}
|
||||||
274
Cunkebao/src/components/DeviceSelection/index.module.scss
Normal file
274
Cunkebao/src/components/DeviceSelection/index.module.scss
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
.inputWrapper {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.inputIcon {
|
||||||
|
position: absolute;
|
||||||
|
left: 12px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: #bdbdbd;
|
||||||
|
z-index: 10;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
.input {
|
||||||
|
padding-left: 38px !important;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 16px !important;
|
||||||
|
border: 1px solid #e5e6eb !important;
|
||||||
|
font-size: 16px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popupHeader {
|
||||||
|
padding: 16px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
.popupTitle {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.popupSearchRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.popupSearchInputWrap {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.popupSearchInput {
|
||||||
|
padding-left: 36px !important;
|
||||||
|
border-radius: 12px !important;
|
||||||
|
height: 44px;
|
||||||
|
font-size: 15px;
|
||||||
|
border: 1px solid #e5e6eb !important;
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
.statusSelect {
|
||||||
|
width: 120px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #e5e6eb;
|
||||||
|
font-size: 15px;
|
||||||
|
padding: 0 10px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.deviceList {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.deviceListInner {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.deviceItem {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 12px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
border: 1px solid #f5f5f5;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkboxContainer {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imeiText {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #666;
|
||||||
|
font-family: monospace;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mainContent {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.deviceCheckbox {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.deviceInfo {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.deviceAvatar {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.25);
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatarText {
|
||||||
|
font-size: 18px;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 700;
|
||||||
|
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.deviceContent {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deviceInfoRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.deviceName {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a1a;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.statusOnline {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #52c41a;
|
||||||
|
background: #f6ffed;
|
||||||
|
border: 1px solid #b7eb8f;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.statusOffline {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #ff4d4f;
|
||||||
|
background: #fff2f0;
|
||||||
|
border: 1px solid #ffccc7;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.deviceInfoDetail {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infoItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infoLabel {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #666;
|
||||||
|
min-width: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infoValue {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #333;
|
||||||
|
|
||||||
|
&.imei {
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.friendCount {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.loadingBox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.loadingText {
|
||||||
|
color: #888;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
.popupFooter {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.selectedCount {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
.footerBtnGroup {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.refreshBtn {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
.paginationRow {
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.totalCount {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
.paginationControls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.pageBtn {
|
||||||
|
padding: 0 8px;
|
||||||
|
height: 32px;
|
||||||
|
min-width: 32px;
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
.pageInfo {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #222;
|
||||||
|
margin: 0 8px;
|
||||||
|
}
|
||||||
192
Cunkebao/src/components/DeviceSelection/index.tsx
Normal file
192
Cunkebao/src/components/DeviceSelection/index.tsx
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { SearchOutlined } from "@ant-design/icons";
|
||||||
|
import { Input, Button } from "antd";
|
||||||
|
import { DeleteOutlined } from "@ant-design/icons";
|
||||||
|
import { DeviceSelectionProps } from "./data";
|
||||||
|
import SelectionPopup from "./selectionPopup";
|
||||||
|
import style from "./index.module.scss";
|
||||||
|
|
||||||
|
const DeviceSelection: React.FC<DeviceSelectionProps> = ({
|
||||||
|
selectedOptions,
|
||||||
|
onSelect,
|
||||||
|
placeholder = "选择设备",
|
||||||
|
className = "",
|
||||||
|
mode = "input",
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
selectedListMaxHeight = 300, // 默认300
|
||||||
|
showInput = true,
|
||||||
|
showSelectedList = true,
|
||||||
|
readonly = false,
|
||||||
|
singleSelect = false,
|
||||||
|
}) => {
|
||||||
|
// 弹窗控制
|
||||||
|
const [popupVisible, setPopupVisible] = useState(false);
|
||||||
|
const isDialog = mode === "dialog";
|
||||||
|
const realVisible = isDialog ? !!open : popupVisible;
|
||||||
|
const setRealVisible = (v: boolean) => {
|
||||||
|
if (isDialog && onOpenChange) onOpenChange(v);
|
||||||
|
if (!isDialog) setPopupVisible(v);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 打开弹窗
|
||||||
|
const openPopup = () => {
|
||||||
|
if (readonly) return;
|
||||||
|
setRealVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取显示文本
|
||||||
|
const getDisplayText = () => {
|
||||||
|
if (selectedOptions.length === 0) return "";
|
||||||
|
if (singleSelect && selectedOptions.length > 0) {
|
||||||
|
return selectedOptions[0].memo || selectedOptions[0].wechatId || "已选择设备";
|
||||||
|
}
|
||||||
|
return `已选择 ${selectedOptions.length} 个设备`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 删除已选设备
|
||||||
|
const handleRemoveDevice = (id: number) => {
|
||||||
|
if (readonly) return;
|
||||||
|
onSelect(selectedOptions.filter(v => v.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 清除所有已选设备
|
||||||
|
const handleClearAll = () => {
|
||||||
|
if (readonly) return;
|
||||||
|
onSelect([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* mode=input 显示输入框,mode=dialog不显示 */}
|
||||||
|
{mode === "input" && showInput && (
|
||||||
|
<div className={`${style.inputWrapper} ${className}`}>
|
||||||
|
<Input
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={getDisplayText()}
|
||||||
|
onClick={openPopup}
|
||||||
|
prefix={<SearchOutlined />}
|
||||||
|
allowClear={!readonly}
|
||||||
|
onClear={handleClearAll}
|
||||||
|
size="large"
|
||||||
|
readOnly={readonly}
|
||||||
|
disabled={readonly}
|
||||||
|
style={
|
||||||
|
readonly ? { background: "#f5f5f5", cursor: "not-allowed" } : {}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* 已选设备列表窗口 */}
|
||||||
|
{mode === "input" && showSelectedList && selectedOptions.length > 0 && (
|
||||||
|
<div
|
||||||
|
className={style.selectedListWindow}
|
||||||
|
style={{
|
||||||
|
maxHeight: selectedListMaxHeight,
|
||||||
|
overflowY: "auto",
|
||||||
|
marginTop: 8,
|
||||||
|
border: "1px solid #e5e6eb",
|
||||||
|
borderRadius: 8,
|
||||||
|
background: "#fff",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selectedOptions.map(device => (
|
||||||
|
<div
|
||||||
|
key={device.id}
|
||||||
|
className={style.selectedListRow}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: "8px 12px",
|
||||||
|
borderBottom: "1px solid #f0f0f0",
|
||||||
|
fontSize: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 头像 */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: "6px",
|
||||||
|
background:
|
||||||
|
"linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
overflow: "hidden",
|
||||||
|
boxShadow: "0 2px 8px rgba(102, 126, 234, 0.25)",
|
||||||
|
marginRight: "12px",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{device.avatar ? (
|
||||||
|
<img
|
||||||
|
src={device.avatar}
|
||||||
|
alt="头像"
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
objectFit: "cover",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 16,
|
||||||
|
color: "#fff",
|
||||||
|
fontWeight: 700,
|
||||||
|
textShadow: "0 1px 3px rgba(0,0,0,0.3)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(device.memo || device.wechatId || "设")[0]}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{device.memo} - {device.wechatId}
|
||||||
|
</div>
|
||||||
|
{!readonly && (
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
size="small"
|
||||||
|
style={{
|
||||||
|
marginLeft: 4,
|
||||||
|
color: "#ff4d4f",
|
||||||
|
border: "none",
|
||||||
|
background: "none",
|
||||||
|
minWidth: 24,
|
||||||
|
height: 24,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
onClick={() => handleRemoveDevice(device.id)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* 弹窗 */}
|
||||||
|
<SelectionPopup
|
||||||
|
visible={realVisible && !readonly}
|
||||||
|
onClose={() => setRealVisible(false)}
|
||||||
|
selectedOptions={selectedOptions}
|
||||||
|
onSelect={onSelect}
|
||||||
|
singleSelect={singleSelect}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DeviceSelection;
|
||||||
287
Cunkebao/src/components/DeviceSelection/selectionPopup.tsx
Normal file
287
Cunkebao/src/components/DeviceSelection/selectionPopup.tsx
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import { Checkbox, Popup } from "antd-mobile";
|
||||||
|
import { getDeviceList } from "./api";
|
||||||
|
import style from "./index.module.scss";
|
||||||
|
import Layout from "@/components/Layout/Layout";
|
||||||
|
import PopupHeader from "@/components/PopuLayout/header";
|
||||||
|
import PopupFooter from "@/components/PopuLayout/footer";
|
||||||
|
import { DeviceSelectionItem } from "./data";
|
||||||
|
|
||||||
|
interface SelectionPopupProps {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
selectedOptions: DeviceSelectionItem[];
|
||||||
|
onSelect: (devices: DeviceSelectionItem[]) => void;
|
||||||
|
singleSelect?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
|
const SelectionPopup: React.FC<SelectionPopupProps> = ({
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
selectedOptions,
|
||||||
|
onSelect,
|
||||||
|
singleSelect = false,
|
||||||
|
}) => {
|
||||||
|
// 设备数据
|
||||||
|
const [devices, setDevices] = useState<DeviceSelectionItem[]>([]);
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [statusFilter, setStatusFilter] = useState("all");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [tempSelectedOptions, setTempSelectedOptions] = useState<
|
||||||
|
DeviceSelectionItem[]
|
||||||
|
>([]);
|
||||||
|
|
||||||
|
// 获取设备列表,支持keyword和分页
|
||||||
|
const fetchDevices = useCallback(
|
||||||
|
async (keyword: string = "", page: number = 1) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await getDeviceList({
|
||||||
|
page,
|
||||||
|
limit: PAGE_SIZE,
|
||||||
|
keyword: keyword.trim() || undefined,
|
||||||
|
});
|
||||||
|
if (res && Array.isArray(res.list)) {
|
||||||
|
setDevices(
|
||||||
|
res.list.map((d: any) => ({
|
||||||
|
id: d.id?.toString() || "",
|
||||||
|
memo: d.memo || d.imei || "",
|
||||||
|
imei: d.imei || "",
|
||||||
|
wechatId: d.wechatId || "",
|
||||||
|
status: d.alive === 1 ? "online" : "offline",
|
||||||
|
wxid: d.wechatId || "",
|
||||||
|
nickname: d.nickname || "",
|
||||||
|
usedInPlans: d.usedInPlans || 0,
|
||||||
|
avatar: d.avatar || "",
|
||||||
|
totalFriend: d.totalFriend || 0,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
setTotal(res.total || 0);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("获取设备列表失败:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 打开弹窗时获取第一页
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
setSearchQuery("");
|
||||||
|
setCurrentPage(1);
|
||||||
|
// 复制一份selectedOptions到临时变量
|
||||||
|
setTempSelectedOptions([...selectedOptions]);
|
||||||
|
fetchDevices("", 1);
|
||||||
|
}
|
||||||
|
}, [visible, fetchDevices, selectedOptions]);
|
||||||
|
|
||||||
|
// 搜索防抖
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) return;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setCurrentPage(1);
|
||||||
|
fetchDevices(searchQuery, 1);
|
||||||
|
}, 500);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [searchQuery, visible, fetchDevices]);
|
||||||
|
|
||||||
|
// 翻页时重新请求
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) return;
|
||||||
|
fetchDevices(searchQuery, currentPage);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [currentPage]);
|
||||||
|
|
||||||
|
// 过滤设备(只保留状态过滤)
|
||||||
|
const filteredDevices = devices.filter(device => {
|
||||||
|
const matchesStatus =
|
||||||
|
statusFilter === "all" ||
|
||||||
|
(statusFilter === "online" && device.status === "online") ||
|
||||||
|
(statusFilter === "offline" && device.status === "offline");
|
||||||
|
return matchesStatus;
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||||
|
|
||||||
|
// 处理设备选择
|
||||||
|
const handleDeviceToggle = (device: DeviceSelectionItem) => {
|
||||||
|
if (singleSelect) {
|
||||||
|
// 单选模式:如果已选中,则取消选择;否则替换为当前设备
|
||||||
|
if (tempSelectedOptions.some(v => v.id === device.id)) {
|
||||||
|
setTempSelectedOptions([]);
|
||||||
|
} else {
|
||||||
|
setTempSelectedOptions([device]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 多选模式:原有的逻辑
|
||||||
|
if (tempSelectedOptions.some(v => v.id === device.id)) {
|
||||||
|
setTempSelectedOptions(
|
||||||
|
tempSelectedOptions.filter(v => v.id !== device.id),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const newSelectedOptions = [...tempSelectedOptions, device];
|
||||||
|
setTempSelectedOptions(newSelectedOptions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 全选当前页
|
||||||
|
const handleSelectAllCurrentPage = (checked: boolean) => {
|
||||||
|
if (checked) {
|
||||||
|
// 全选:添加当前页面所有未选中的设备
|
||||||
|
const currentPageDevices = filteredDevices.filter(
|
||||||
|
device => !tempSelectedOptions.some(d => d.id === device.id),
|
||||||
|
);
|
||||||
|
setTempSelectedOptions(prev => [...prev, ...currentPageDevices]);
|
||||||
|
} else {
|
||||||
|
// 取消全选:移除当前页面的所有设备
|
||||||
|
const currentPageDeviceIds = filteredDevices.map(d => d.id);
|
||||||
|
setTempSelectedOptions(prev =>
|
||||||
|
prev.filter(d => !currentPageDeviceIds.includes(d.id)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 检查当前页是否全选
|
||||||
|
const isCurrentPageAllSelected =
|
||||||
|
filteredDevices.length > 0 &&
|
||||||
|
filteredDevices.every(device =>
|
||||||
|
tempSelectedOptions.some(d => d.id === device.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popup
|
||||||
|
visible={visible}
|
||||||
|
onMaskClick={onClose}
|
||||||
|
position="bottom"
|
||||||
|
bodyStyle={{ height: "100vh" }}
|
||||||
|
closeOnMaskClick={false}
|
||||||
|
>
|
||||||
|
<Layout
|
||||||
|
header={
|
||||||
|
<PopupHeader
|
||||||
|
title="选择设备"
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
setSearchQuery={setSearchQuery}
|
||||||
|
searchPlaceholder="搜索设备IMEI/备注/微信号"
|
||||||
|
loading={loading}
|
||||||
|
onRefresh={() => fetchDevices(searchQuery, currentPage)}
|
||||||
|
showTabs={true}
|
||||||
|
tabsConfig={{
|
||||||
|
activeKey: statusFilter,
|
||||||
|
onChange: setStatusFilter,
|
||||||
|
tabs: [
|
||||||
|
{ title: "全部", key: "all" },
|
||||||
|
{ title: "在线", key: "online" },
|
||||||
|
{ title: "离线", key: "offline" },
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
footer={
|
||||||
|
<PopupFooter
|
||||||
|
currentPage={currentPage}
|
||||||
|
totalPages={totalPages}
|
||||||
|
loading={loading}
|
||||||
|
selectedCount={tempSelectedOptions.length}
|
||||||
|
singleSelect={singleSelect}
|
||||||
|
onPageChange={setCurrentPage}
|
||||||
|
onCancel={onClose}
|
||||||
|
onConfirm={() => {
|
||||||
|
// 用户点击确认时,才更新实际的selectedOptions
|
||||||
|
onSelect(tempSelectedOptions);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
isAllSelected={isCurrentPageAllSelected}
|
||||||
|
onSelectAll={singleSelect ? undefined : handleSelectAllCurrentPage}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={style.deviceList}>
|
||||||
|
{loading ? (
|
||||||
|
<div className={style.loadingBox}>
|
||||||
|
<div className={style.loadingText}>加载中...</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={style.deviceListInner}>
|
||||||
|
{filteredDevices.map(device => (
|
||||||
|
<div key={device.id} className={style.deviceItem}>
|
||||||
|
{/* 顶部行:选择框和IMEI */}
|
||||||
|
<div className={style.headerRow}>
|
||||||
|
<div className={style.checkboxContainer}>
|
||||||
|
<Checkbox
|
||||||
|
checked={tempSelectedOptions.some(
|
||||||
|
v => v.id === device.id,
|
||||||
|
)}
|
||||||
|
onChange={() => handleDeviceToggle(device)}
|
||||||
|
className={style.deviceCheckbox}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className={style.imeiText}>
|
||||||
|
IMEI: {device.imei?.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 主要内容区域:头像和详细信息 */}
|
||||||
|
<div className={style.mainContent}>
|
||||||
|
{/* 头像 */}
|
||||||
|
<div className={style.deviceAvatar}>
|
||||||
|
{device.avatar ? (
|
||||||
|
<img src={device.avatar} alt="头像" />
|
||||||
|
) : (
|
||||||
|
<span className={style.avatarText}>
|
||||||
|
{(device.memo || device.wechatId || "设")[0]}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 设备信息 */}
|
||||||
|
<div className={style.deviceContent}>
|
||||||
|
<div className={style.deviceInfoRow}>
|
||||||
|
<span className={style.deviceName}>{device.memo}</span>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
device.status === "online"
|
||||||
|
? style.statusOnline
|
||||||
|
: style.statusOffline
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{device.status === "online" ? "在线" : "离线"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={style.deviceInfoDetail}>
|
||||||
|
<div className={style.infoItem}>
|
||||||
|
<span className={style.infoLabel}>微信号:</span>
|
||||||
|
<span className={style.infoValue}>
|
||||||
|
{device.wechatId}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={style.infoItem}>
|
||||||
|
<span className={style.infoLabel}>好友数:</span>
|
||||||
|
<span
|
||||||
|
className={`${style.infoValue} ${style.friendCount}`}
|
||||||
|
>
|
||||||
|
{device.totalFriend ?? "-"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
</Popup>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SelectionPopup;
|
||||||
167
Cunkebao/src/components/EmojiSeclection/EmojiPicker.css
Normal file
167
Cunkebao/src/components/EmojiSeclection/EmojiPicker.css
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
/* 表情选择器容器 */
|
||||||
|
.emoji-picker-container {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 默认触发器按钮 */
|
||||||
|
.emoji-picker-trigger {
|
||||||
|
background: none;
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-picker-trigger:hover {
|
||||||
|
background-color: #e9e9e9;
|
||||||
|
border-color: #d0d0d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表情选择器面板 */
|
||||||
|
.emoji-picker-panel {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 100%;
|
||||||
|
left: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
width: 320px;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 分类标签栏 */
|
||||||
|
.emoji-categories {
|
||||||
|
display: flex;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-bottom: 1px solid #e0e0e0;
|
||||||
|
padding: 8px;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-btn:hover {
|
||||||
|
background-color: #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-btn.active {
|
||||||
|
background-color: #007bff;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表情网格 */
|
||||||
|
.emoji-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(8, 1fr);
|
||||||
|
gap: 4px;
|
||||||
|
padding: 12px;
|
||||||
|
max-height: 280px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表情项 */
|
||||||
|
.emoji-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-item:hover {
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-image {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 空状态 */
|
||||||
|
.emoji-empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
color: #999;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 滚动条样式 */
|
||||||
|
.emoji-grid::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-grid::-webkit-scrollbar-track {
|
||||||
|
background: #f1f1f1;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-grid::-webkit-scrollbar-thumb {
|
||||||
|
background: #c1c1c1;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-grid::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #a8a8a8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式设计 */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.emoji-picker-panel {
|
||||||
|
width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-grid {
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 深色模式支持 */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.emoji-picker-panel {
|
||||||
|
background: #2d3748;
|
||||||
|
border-color: #4a5568;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-categories {
|
||||||
|
background-color: #1a202c;
|
||||||
|
border-bottom-color: #4a5568;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-btn:hover {
|
||||||
|
background-color: #4a5568;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-item:hover {
|
||||||
|
background-color: #4a5568;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-picker-trigger {
|
||||||
|
border-color: #4a5568;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-picker-trigger:hover {
|
||||||
|
background-color: #4a5568;
|
||||||
|
}
|
||||||
|
}
|
||||||
115
Cunkebao/src/components/EmojiSeclection/EmojiPicker.tsx
Normal file
115
Cunkebao/src/components/EmojiSeclection/EmojiPicker.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import React, { useState, useRef, useEffect } from "react";
|
||||||
|
import { EmojiCategory, EmojiInfo, getEmojisByCategory } from "./wechatEmoji";
|
||||||
|
import "./EmojiPicker.css";
|
||||||
|
|
||||||
|
interface EmojiPickerProps {
|
||||||
|
onEmojiSelect: (emoji: EmojiInfo) => void;
|
||||||
|
trigger?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EmojiPicker: React.FC<EmojiPickerProps> = ({
|
||||||
|
onEmojiSelect,
|
||||||
|
trigger,
|
||||||
|
className = "",
|
||||||
|
}) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [activeCategory, setActiveCategory] = useState<EmojiCategory>(
|
||||||
|
EmojiCategory.FACE,
|
||||||
|
);
|
||||||
|
const pickerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// 分类配置
|
||||||
|
const categories = [
|
||||||
|
{ key: EmojiCategory.FACE, label: "😊", title: "人脸" },
|
||||||
|
{ key: EmojiCategory.GESTURE, label: "👋", title: "手势" },
|
||||||
|
{ key: EmojiCategory.ANIMAL, label: "🐷", title: "动物" },
|
||||||
|
{ key: EmojiCategory.BLESSING, label: "🎉", title: "祝福" },
|
||||||
|
{ key: EmojiCategory.OTHER, label: "❤️", title: "其他" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 获取当前分类的表情
|
||||||
|
const currentEmojis = getEmojisByCategory(activeCategory);
|
||||||
|
|
||||||
|
// 点击外部关闭
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (
|
||||||
|
pickerRef.current &&
|
||||||
|
!pickerRef.current.contains(event.target as Node)
|
||||||
|
) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
};
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
// 处理表情选择
|
||||||
|
const handleEmojiClick = (emoji: EmojiInfo) => {
|
||||||
|
onEmojiSelect(emoji);
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 默认触发器
|
||||||
|
const defaultTrigger = <span className="emoji-picker-trigger">😊</span>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`emoji-picker-container ${className}`} ref={pickerRef}>
|
||||||
|
{/* 触发器 */}
|
||||||
|
<div onClick={() => setIsOpen(!isOpen)}>{trigger || defaultTrigger}</div>
|
||||||
|
|
||||||
|
{/* 表情选择器面板 */}
|
||||||
|
{isOpen && (
|
||||||
|
<div className="emoji-picker-panel">
|
||||||
|
{/* 分类标签 */}
|
||||||
|
<div className="emoji-categories">
|
||||||
|
{categories.map(category => (
|
||||||
|
<button
|
||||||
|
key={category.key}
|
||||||
|
className={`category-btn ${
|
||||||
|
activeCategory === category.key ? "active" : ""
|
||||||
|
}`}
|
||||||
|
onClick={() => setActiveCategory(category.key)}
|
||||||
|
title={category.title}
|
||||||
|
>
|
||||||
|
{category.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 表情网格 */}
|
||||||
|
<div className="emoji-grid">
|
||||||
|
{currentEmojis.map(emoji => (
|
||||||
|
<div
|
||||||
|
key={emoji.name}
|
||||||
|
className="emoji-item"
|
||||||
|
onClick={() => handleEmojiClick(emoji)}
|
||||||
|
title={emoji.name}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={emoji.path}
|
||||||
|
alt={emoji.name}
|
||||||
|
className="emoji-image"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 空状态 */}
|
||||||
|
{currentEmojis.length === 0 && (
|
||||||
|
<div className="emoji-empty">暂无表情</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EmojiPicker;
|
||||||
18
Cunkebao/src/components/EmojiSeclection/index.ts
Normal file
18
Cunkebao/src/components/EmojiSeclection/index.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
// 导出主要组件
|
||||||
|
export { default as EmojiPicker } from "./EmojiPicker";
|
||||||
|
|
||||||
|
// 导出表情数据和类型
|
||||||
|
export {
|
||||||
|
EmojiCategory,
|
||||||
|
type EmojiInfo,
|
||||||
|
type EmojiName,
|
||||||
|
getAllEmojis,
|
||||||
|
getEmojisByCategory,
|
||||||
|
getEmojiInfo,
|
||||||
|
getEmojiPath,
|
||||||
|
searchEmojis,
|
||||||
|
EMOJI_CATEGORIES,
|
||||||
|
} from "./wechatEmoji";
|
||||||
|
|
||||||
|
// 默认导出
|
||||||
|
export { default } from "./EmojiPicker";
|
||||||
902
Cunkebao/src/components/EmojiSeclection/wechatEmoji.ts
Normal file
902
Cunkebao/src/components/EmojiSeclection/wechatEmoji.ts
Normal file
@@ -0,0 +1,902 @@
|
|||||||
|
/**
|
||||||
|
* 微信表情包 TypeScript 模块
|
||||||
|
* 提供类型安全的表情访问和图片路径获取功能
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 表情类别枚举
|
||||||
|
*/
|
||||||
|
export enum EmojiCategory {
|
||||||
|
/** 人脸表情 */
|
||||||
|
FACE = "face",
|
||||||
|
/** 手势表情 */
|
||||||
|
GESTURE = "gesture",
|
||||||
|
/** 动物表情 */
|
||||||
|
ANIMAL = "animal",
|
||||||
|
/** 祝福表情 */
|
||||||
|
BLESSING = "blessing",
|
||||||
|
/** 其他表情 */
|
||||||
|
OTHER = "other",
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 表情信息接口
|
||||||
|
*/
|
||||||
|
export interface EmojiInfo {
|
||||||
|
/** 表情名称 */
|
||||||
|
name: string;
|
||||||
|
/** 表情类别 */
|
||||||
|
category: EmojiCategory;
|
||||||
|
/** 图片文件路径 */
|
||||||
|
path: string;
|
||||||
|
/** 英文名称(可选) */
|
||||||
|
englishName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 表情名称类型
|
||||||
|
*/
|
||||||
|
export type EmojiName =
|
||||||
|
// 人脸表情
|
||||||
|
| "微笑"
|
||||||
|
| "撇嘴"
|
||||||
|
| "色"
|
||||||
|
| "发呆"
|
||||||
|
| "得意"
|
||||||
|
| "流泪"
|
||||||
|
| "害羞"
|
||||||
|
| "闭嘴"
|
||||||
|
| "睡"
|
||||||
|
| "大哭"
|
||||||
|
| "尴尬"
|
||||||
|
| "发怒"
|
||||||
|
| "调皮"
|
||||||
|
| "呲牙"
|
||||||
|
| "惊讶"
|
||||||
|
| "难过"
|
||||||
|
| "囧"
|
||||||
|
| "抓狂"
|
||||||
|
| "吐"
|
||||||
|
| "偷笑"
|
||||||
|
| "愉快"
|
||||||
|
| "白眼"
|
||||||
|
| "傲慢"
|
||||||
|
| "困"
|
||||||
|
| "惊恐"
|
||||||
|
| "憨笑"
|
||||||
|
| "悠闲"
|
||||||
|
| "咒骂"
|
||||||
|
| "疑问"
|
||||||
|
| "嘘"
|
||||||
|
| "晕"
|
||||||
|
| "衰"
|
||||||
|
| "骷髅"
|
||||||
|
| "敲打"
|
||||||
|
| "再见"
|
||||||
|
| "擦汗"
|
||||||
|
| "抠鼻"
|
||||||
|
| "鼓掌"
|
||||||
|
| "坏笑"
|
||||||
|
| "右哼哼"
|
||||||
|
| "鄙视"
|
||||||
|
| "委屈"
|
||||||
|
| "快哭了"
|
||||||
|
| "阴险"
|
||||||
|
| "亲亲"
|
||||||
|
| "可怜"
|
||||||
|
| "笑脸"
|
||||||
|
| "生病"
|
||||||
|
| "脸红"
|
||||||
|
| "破涕为笑"
|
||||||
|
| "恐惧"
|
||||||
|
| "失望"
|
||||||
|
| "无语"
|
||||||
|
| "嘿哈"
|
||||||
|
| "捂脸"
|
||||||
|
| "机智"
|
||||||
|
| "皱眉"
|
||||||
|
| "耶"
|
||||||
|
| "吃瓜"
|
||||||
|
| "加油"
|
||||||
|
| "汗"
|
||||||
|
| "天啊"
|
||||||
|
| "Emm"
|
||||||
|
| "社会社会"
|
||||||
|
| "旺柴"
|
||||||
|
| "好的"
|
||||||
|
| "打脸"
|
||||||
|
| "哇"
|
||||||
|
| "翻白眼"
|
||||||
|
| "666"
|
||||||
|
| "让我看看"
|
||||||
|
| "叹气"
|
||||||
|
| "苦涩"
|
||||||
|
| "裂开"
|
||||||
|
| "奸笑"
|
||||||
|
// 手势表情
|
||||||
|
| "握手"
|
||||||
|
| "胜利"
|
||||||
|
| "抱拳"
|
||||||
|
| "勾引"
|
||||||
|
| "拳头"
|
||||||
|
| "OK"
|
||||||
|
| "合十"
|
||||||
|
| "强"
|
||||||
|
| "拥抱"
|
||||||
|
| "弱"
|
||||||
|
// 动物表情
|
||||||
|
| "猪头"
|
||||||
|
| "跳跳"
|
||||||
|
| "发抖"
|
||||||
|
| "转圈"
|
||||||
|
// 祝福表情
|
||||||
|
| "庆祝"
|
||||||
|
| "礼物"
|
||||||
|
| "红包"
|
||||||
|
| "發"
|
||||||
|
| "福"
|
||||||
|
| "烟花"
|
||||||
|
| "爆竹"
|
||||||
|
// 其他表情
|
||||||
|
| "嘴唇"
|
||||||
|
| "爱心"
|
||||||
|
| "心碎"
|
||||||
|
| "啤酒"
|
||||||
|
| "咖啡"
|
||||||
|
| "蛋糕"
|
||||||
|
| "凋谢"
|
||||||
|
| "菜刀"
|
||||||
|
| "炸弹"
|
||||||
|
| "便便"
|
||||||
|
| "太阳"
|
||||||
|
| "月亮"
|
||||||
|
| "玫瑰";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 表情数据映射
|
||||||
|
* 将表情名称映射到完整的表情信息
|
||||||
|
*/
|
||||||
|
const EMOJI_DATA: Record<EmojiName, EmojiInfo> = {
|
||||||
|
// 人脸表情
|
||||||
|
微笑: {
|
||||||
|
name: "微笑",
|
||||||
|
category: EmojiCategory.FACE,
|
||||||
|
path: "/assets/face/smile.png",
|
||||||
|
},
|
||||||
|
撇嘴: {
|
||||||
|
name: "撇嘴",
|
||||||
|
category: EmojiCategory.FACE,
|
||||||
|
path: "/assets/face/pout.png",
|
||||||
|
},
|
||||||
|
色: {
|
||||||
|
name: "色",
|
||||||
|
category: EmojiCategory.FACE,
|
||||||
|
path: "/assets/face/lustful.png",
|
||||||
|
},
|
||||||
|
发呆: {
|
||||||
|
name: "发呆",
|
||||||
|
category: EmojiCategory.FACE,
|
||||||
|
path: "/assets/face/daze.png",
|
||||||
|
},
|
||||||
|
得意: {
|
||||||
|
name: "得意",
|
||||||
|
category: EmojiCategory.FACE,
|
||||||
|
path: "/assets/face/smug.png",
|
||||||
|
},
|
||||||
|
流泪: {
|
||||||
|
name: "流泪",
|
||||||
|
category: EmojiCategory.FACE,
|
||||||
|
path: "/assets/face/crying.png",
|
||||||
|
},
|
||||||
|
害羞: {
|
||||||
|
name: "害羞",
|
||||||
|
category: EmojiCategory.FACE,
|
||||||
|
path: "/assets/face/shy.png",
|
||||||
|
},
|
||||||
|
闭嘴: {
|
||||||
|
name: "闭嘴",
|
||||||
|
category: EmojiCategory.FACE,
|
||||||
|
path: "/assets/face/shut-up.png",
|
||||||
|
},
|
||||||
|
睡: {
|
||||||
|
name: "睡",
|
||||||
|
category: EmojiCategory.FACE,
|
||||||
|
path: "/assets/face/sleep.png",
|
||||||
|
},
|
||||||
|
大哭: {
|
||||||
|
name: "大哭",
|
||||||
|
category: EmojiCategory.FACE,
|
||||||
|
path: "/assets/face/wail.png",
|
||||||
|
},
|
||||||
|
尴尬: {
|
||||||
|
name: "尴尬",
|
||||||
|
category: EmojiCategory.FACE,
|
||||||
|
path: "/assets/face/awkward.png",
|
||||||
|
},
|
||||||
|
发怒: {
|
||||||
|
name: "发怒",
|
||||||
|
category: EmojiCategory.FACE,
|
||||||
|
path: "/assets/face/angry.png",
|
||||||
|
},
|
||||||
|
调皮: {
|
||||||
|
name: "调皮",
|
||||||
|
category: EmojiCategory.FACE,
|
||||||
|
path: "/assets/face/naughty.png",
|
||||||
|
},
|
||||||
|
呲牙: {
|
||||||
|
name: "呲牙",
|
||||||
|
category: EmojiCategory.FACE,
|
||||||
|
path: "/assets/face/grin.png",
|
||||||
|
},
|
||||||
|
惊讶: {
|
||||||
|
name: "惊讶",
|
||||||
|
category: EmojiCategory.FACE,
|
||||||
|
path: "/assets/face/surprised.png",
|
||||||
|
},
|
||||||
|
难过: {
|
||||||
|
name: "难过",
|
||||||
|
category: EmojiCategory.FACE,
|
||||||
|
path: "/assets/face/sad.png",
|
||||||
|
},
|
||||||
|
囧: {
|
||||||
|
name: "囧",
|
||||||
|
category: EmojiCategory.FACE,
|
||||||
|
path: "/assets/face/embarrassed.png",
|
||||||
|
},
|
||||||
|
抓狂: {
|
||||||
|
name: "抓狂",
|
||||||
|
category: EmojiCategory.FACE,
|
||||||
|
path: "/assets/face/crazy.png",
|
||||||
|
},
|
||||||
|
吐: {
|
||||||
|
name: "吐",
|
||||||
|
category: EmojiCategory.FACE,
|
||||||
|
path: "/assets/face/vomit.png",
|
||||||
|
},
|
||||||
|
偷笑: {
|
||||||
|
name: "偷笑",
|
||||||
|
category: EmojiCategory.FACE,
|
||||||
|
path: "/assets/face/snicker.png",
|
||||||
|
},
|
||||||
|
愉快: {
|
||||||
|
name: "愉快",
|
||||||
|
category: EmojiCategory.FACE,
|
||||||
|
path: "/assets/face/happy.png",
|
||||||
|
},
|
||||||
|
白眼: {
|
||||||
|
name: "白眼",
|
||||||
|
category: EmojiCategory.FACE,
|
||||||
|
path: "/assets/face/roll-eyes.png",
|
||||||
|
},
|
||||||
|
傲慢: {
|
||||||
|
name: "傲慢",
|
||||||
|
category: EmojiCategory.FACE,
|
||||||
|
path: "/assets/face/arrogant.png",
|
||||||
|
},
|
||||||
|
困: {
|
||||||
|
name: "困",
|
||||||
|
category: EmojiCategory.FACE,
|
||||||
|
path: "/assets/face/sleepy.png",
|
||||||
|
},
|
||||||
|
惊恐: {
|
||||||
|
name: "惊恐",
|
||||||
|
category: EmojiCategory.FACE,
|
||||||
|
path: "/assets/face/panic.png",
|
||||||
|
},
|
||||||
|
憨笑: {
|
||||||
|
name: "憨笑",
|
||||||
|
category: EmojiCategory.FACE,
|
||||||
|
path: "/assets/face/silly-smile.png",
|
||||||
|
},
|
||||||
|
悠闲: {
|
||||||
|
name: "悠闲",
|
||||||
|
category: EmojiCategory.FACE,
|
||||||
|
path: "/assets/face/leisurely.png",
|
||||||
|
},
|
||||||
|
咒骂: {
|
||||||
|
name: "咒骂",
|
||||||
|
category: EmojiCategory.FACE,
|
||||||
|
path: "/assets/face/curse.png",
|
||||||
|
},
|
||||||
|
疑问: {
|
||||||
|
name: "疑问",
|
||||||
|
category: EmojiCategory.FACE,
|
||||||
|
path: "/assets/face/question.png",
|
||||||
|
},
|
||||||
|
嘘: {
|
||||||
|
name: "嘘",
|
||||||
|
category: EmojiCategory.FACE,
|
||||||
|
path: "/assets/face/shush.png",
|
||||||
|
},
|
||||||
|
晕: {
|
||||||
|
name: "晕",
|
||||||
|
category: EmojiCategory.FACE,
|
||||||
|
path: "/assets/face/dizzy.png",
|
||||||
|
},
|
||||||
|
衰: {
|
||||||
|
name: "衰",
|
||||||
|
category: EmojiCategory.FACE,
|
||||||
|
path: "/assets/face/unlucky.png",
|
||||||
|
},
|
||||||
|
骷髅: {
|
||||||
|
name: "骷髅",
|
||||||
|
category: EmojiCategory.FACE,
|
||||||
|
path: "/assets/face/skull.png",
|
||||||
|
},
|
||||||
|
敲打: {
|
||||||
|
name: "敲打",
|
||||||
|
category: EmojiCategory.FACE,
|
||||||
|
path: "/assets/face/knock.png",
|
||||||
|
},
|
||||||
|
再见: {
|
||||||
|
name: "再见",
|
||||||
|
category: EmojiCategory.FACE,
|
||||||
|
path: "/assets/face/goodbye.png",
|
||||||
|
},
|
||||||
|
擦汗: {
|
||||||
|
name: "擦汗",
|
||||||
|
category: EmojiCategory.FACE,
|
||||||
|
path: "/assets/face/wipe-sweat.png",
|
||||||
|
},
|
||||||
|
抠鼻: {
|
||||||
|
name: "抠鼻",
|
||||||
|
category: EmojiCategory.FACE,
|
||||||
|
path: "/assets/face/pick-nose.png",
|
||||||
|
},
|
||||||
|
鼓掌: {
|
||||||
|
name: "鼓掌",
|
||||||
|
category: EmojiCategory.FACE,
|
||||||
|
path: "/assets/face/clap.png",
|
||||||
|
},
|
||||||
|
坏笑: {
|
||||||
|
name: "坏笑",
|
||||||
|
category: EmojiCategory.FACE,
|
||||||
|
path: "/assets/face/evil-smile.png",
|
||||||
|
},
|
||||||
|
右哼哼: {
|
||||||
|
name: "右哼哼",
|
||||||
|
category: EmojiCategory.FACE,
|
||||||
|
path: "/assets/face/right-hum.png",
|
||||||
|
},
|
||||||
|
鄙视: {
|
||||||
|
name: "鄙视",
|
||||||
|
category: EmojiCategory.FACE,
|
||||||
|
path: "/assets/face/despise.png",
|
||||||
|
},
|
||||||
|
委屈: {
|
||||||
|
name: "委屈",
|
||||||
|
category: EmojiCategory.FACE,
|
||||||
|
path: "/assets/face/wronged.png",
|
||||||
|
},
|
||||||
|
快哭了: {
|
||||||
|
name: "快哭了",
|
||||||
|
category: EmojiCategory.FACE,
|
||||||
|
path: "/assets/face/about-to-cry.png",
|
||||||
|
},
|
||||||
|
阴险: {
|
||||||
|
name: "阴险",
|
||||||
|
category: EmojiCategory.FACE,
|
||||||
|
path: "/assets/face/sinister.png",
|
||||||
|
},
|
||||||
|
亲亲: {
|
||||||
|
name: "亲亲",
|
||||||
|
category: EmojiCategory.FACE,
|
||||||
|
path: "/assets/face/kiss.png",
|
||||||
|
},
|
||||||
|
可怜: {
|
||||||
|
name: "可怜",
|
||||||
|
category: EmojiCategory.FACE,
|
||||||
|
path: "/assets/face/pitiful.png",
|
||||||
|
},
|
||||||
|
笑脸: {
|
||||||
|
name: "笑脸",
|
||||||
|
category: EmojiCategory.FACE,
|
||||||
|
path: "/assets/face/smiley.png",
|
||||||
|
},
|
||||||
|
生病: {
|
||||||
|
name: "生病",
|
||||||
|
category: EmojiCategory.FACE,
|
||||||
|
path: "/assets/face/sick.png",
|
||||||
|
},
|
||||||
|
脸红: {
|
||||||
|
name: "脸红",
|
||||||
|
category: EmojiCategory.FACE,
|
||||||
|
path: "/assets/face/blush.png",
|
||||||
|
},
|
||||||
|
破涕为笑: {
|
||||||
|
name: "破涕为笑",
|
||||||
|
category: EmojiCategory.FACE,
|
||||||
|
path: "/assets/face/tears-to-smile.png",
|
||||||
|
},
|
||||||
|
恐惧: {
|
||||||
|
name: "恐惧",
|
||||||
|
category: EmojiCategory.FACE,
|
||||||
|
path: "/assets/face/fear.png",
|
||||||
|
},
|
||||||
|
失望: {
|
||||||
|
name: "失望",
|
||||||
|
category: EmojiCategory.FACE,
|
||||||
|
path: "/assets/face/disappointed.png",
|
||||||
|
},
|
||||||
|
无语: {
|
||||||
|
name: "无语",
|
||||||
|
category: EmojiCategory.FACE,
|
||||||
|
path: "/assets/face/speechless.png",
|
||||||
|
},
|
||||||
|
嘿哈: {
|
||||||
|
name: "嘿哈",
|
||||||
|
category: EmojiCategory.FACE,
|
||||||
|
path: "/assets/face/hey-ha.png",
|
||||||
|
},
|
||||||
|
捂脸: {
|
||||||
|
name: "捂脸",
|
||||||
|
category: EmojiCategory.FACE,
|
||||||
|
path: "/assets/face/facepalm.png",
|
||||||
|
},
|
||||||
|
机智: {
|
||||||
|
name: "机智",
|
||||||
|
category: EmojiCategory.FACE,
|
||||||
|
path: "/assets/face/smart.png",
|
||||||
|
},
|
||||||
|
皱眉: {
|
||||||
|
name: "皱眉",
|
||||||
|
category: EmojiCategory.FACE,
|
||||||
|
path: "/assets/face/frown.png",
|
||||||
|
},
|
||||||
|
耶: {
|
||||||
|
name: "耶",
|
||||||
|
category: EmojiCategory.FACE,
|
||||||
|
path: "/assets/face/yeah.png",
|
||||||
|
},
|
||||||
|
吃瓜: {
|
||||||
|
name: "吃瓜",
|
||||||
|
category: EmojiCategory.FACE,
|
||||||
|
path: "/assets/face/eat-melon.png",
|
||||||
|
},
|
||||||
|
加油: {
|
||||||
|
name: "加油",
|
||||||
|
category: EmojiCategory.FACE,
|
||||||
|
path: "/assets/face/cheer-up.png",
|
||||||
|
},
|
||||||
|
|
||||||
|
汗: {
|
||||||
|
name: "汗",
|
||||||
|
category: EmojiCategory.FACE,
|
||||||
|
path: "/assets/face/sweat.png",
|
||||||
|
},
|
||||||
|
天啊: {
|
||||||
|
name: "天啊",
|
||||||
|
category: EmojiCategory.FACE,
|
||||||
|
path: "/assets/face/oh-my.png",
|
||||||
|
},
|
||||||
|
Emm: {
|
||||||
|
name: "Emm",
|
||||||
|
category: EmojiCategory.FACE,
|
||||||
|
path: "/assets/face/Emm.png",
|
||||||
|
},
|
||||||
|
社会社会: {
|
||||||
|
name: "社会社会",
|
||||||
|
category: EmojiCategory.FACE,
|
||||||
|
path: "/assets/face/social.png",
|
||||||
|
},
|
||||||
|
旺柴: {
|
||||||
|
name: "旺柴",
|
||||||
|
category: EmojiCategory.FACE,
|
||||||
|
path: "/assets/face/doge.png",
|
||||||
|
},
|
||||||
|
好的: {
|
||||||
|
name: "好的",
|
||||||
|
category: EmojiCategory.FACE,
|
||||||
|
path: "/assets/face/good.png",
|
||||||
|
},
|
||||||
|
打脸: {
|
||||||
|
name: "打脸",
|
||||||
|
category: EmojiCategory.FACE,
|
||||||
|
path: "/assets/face/slap-face.png",
|
||||||
|
},
|
||||||
|
哇: {
|
||||||
|
name: "哇",
|
||||||
|
category: EmojiCategory.FACE,
|
||||||
|
path: "/assets/face/wow.png",
|
||||||
|
},
|
||||||
|
翻白眼: {
|
||||||
|
name: "翻白眼",
|
||||||
|
category: EmojiCategory.FACE,
|
||||||
|
path: "/assets/face/eye-roll.png",
|
||||||
|
},
|
||||||
|
"666": {
|
||||||
|
name: "666",
|
||||||
|
category: EmojiCategory.FACE,
|
||||||
|
path: "/assets/face/666.png",
|
||||||
|
},
|
||||||
|
让我看看: {
|
||||||
|
name: "让我看看",
|
||||||
|
category: EmojiCategory.FACE,
|
||||||
|
path: "/assets/face/let-me-see.png",
|
||||||
|
},
|
||||||
|
叹气: {
|
||||||
|
name: "叹气",
|
||||||
|
category: EmojiCategory.FACE,
|
||||||
|
path: "/assets/face/sigh.png",
|
||||||
|
},
|
||||||
|
苦涩: {
|
||||||
|
name: "苦涩",
|
||||||
|
category: EmojiCategory.FACE,
|
||||||
|
path: "/assets/face/bitter.png",
|
||||||
|
},
|
||||||
|
裂开: {
|
||||||
|
name: "裂开",
|
||||||
|
category: EmojiCategory.FACE,
|
||||||
|
path: "/assets/face/crack.png",
|
||||||
|
},
|
||||||
|
奸笑: {
|
||||||
|
name: "奸笑",
|
||||||
|
category: EmojiCategory.FACE,
|
||||||
|
path: "/assets/face/sly-smile.png",
|
||||||
|
},
|
||||||
|
|
||||||
|
// 手势表情
|
||||||
|
握手: {
|
||||||
|
name: "握手",
|
||||||
|
category: EmojiCategory.GESTURE,
|
||||||
|
path: "/assets/gesture/handshake.png",
|
||||||
|
},
|
||||||
|
胜利: {
|
||||||
|
name: "胜利",
|
||||||
|
category: EmojiCategory.GESTURE,
|
||||||
|
path: "/assets/gesture/victory.png",
|
||||||
|
},
|
||||||
|
抱拳: {
|
||||||
|
name: "抱拳",
|
||||||
|
category: EmojiCategory.GESTURE,
|
||||||
|
path: "/assets/gesture/fist-salute.png",
|
||||||
|
},
|
||||||
|
勾引: {
|
||||||
|
name: "勾引",
|
||||||
|
category: EmojiCategory.GESTURE,
|
||||||
|
path: "/assets/gesture/beckon.png",
|
||||||
|
},
|
||||||
|
拳头: {
|
||||||
|
name: "拳头",
|
||||||
|
category: EmojiCategory.GESTURE,
|
||||||
|
path: "/assets/gesture/fist.png",
|
||||||
|
},
|
||||||
|
OK: {
|
||||||
|
name: "OK",
|
||||||
|
category: EmojiCategory.GESTURE,
|
||||||
|
path: "/assets/gesture/OK.png",
|
||||||
|
},
|
||||||
|
合十: {
|
||||||
|
name: "合十",
|
||||||
|
category: EmojiCategory.GESTURE,
|
||||||
|
path: "/assets/gesture/pray.png",
|
||||||
|
},
|
||||||
|
强: {
|
||||||
|
name: "强",
|
||||||
|
category: EmojiCategory.GESTURE,
|
||||||
|
path: "/assets/gesture/strong.png",
|
||||||
|
},
|
||||||
|
拥抱: {
|
||||||
|
name: "拥抱",
|
||||||
|
category: EmojiCategory.GESTURE,
|
||||||
|
path: "/assets/gesture/hug.png",
|
||||||
|
},
|
||||||
|
弱: {
|
||||||
|
name: "弱",
|
||||||
|
category: EmojiCategory.GESTURE,
|
||||||
|
path: "/assets/gesture/weak.png",
|
||||||
|
},
|
||||||
|
|
||||||
|
// 动物表情
|
||||||
|
猪头: {
|
||||||
|
name: "猪头",
|
||||||
|
category: EmojiCategory.ANIMAL,
|
||||||
|
path: "/assets/animal/pig.png",
|
||||||
|
},
|
||||||
|
跳跳: {
|
||||||
|
name: "跳跳",
|
||||||
|
category: EmojiCategory.ANIMAL,
|
||||||
|
path: "/assets/animal/jump.png",
|
||||||
|
},
|
||||||
|
发抖: {
|
||||||
|
name: "发抖",
|
||||||
|
category: EmojiCategory.ANIMAL,
|
||||||
|
path: "/assets/animal/tremble.png",
|
||||||
|
},
|
||||||
|
转圈: {
|
||||||
|
name: "转圈",
|
||||||
|
category: EmojiCategory.ANIMAL,
|
||||||
|
path: "/assets/animal/circle.png",
|
||||||
|
},
|
||||||
|
|
||||||
|
// 祝福表情
|
||||||
|
庆祝: {
|
||||||
|
name: "庆祝",
|
||||||
|
category: EmojiCategory.BLESSING,
|
||||||
|
path: "/assets/blessing/celebrate.png",
|
||||||
|
},
|
||||||
|
礼物: {
|
||||||
|
name: "礼物",
|
||||||
|
category: EmojiCategory.BLESSING,
|
||||||
|
path: "/assets/blessing/gift.png",
|
||||||
|
},
|
||||||
|
红包: {
|
||||||
|
name: "红包",
|
||||||
|
category: EmojiCategory.BLESSING,
|
||||||
|
path: "/assets/blessing/red-envelope.png",
|
||||||
|
},
|
||||||
|
發: {
|
||||||
|
name: "發",
|
||||||
|
category: EmojiCategory.BLESSING,
|
||||||
|
path: "/assets/blessing/get-rich.png",
|
||||||
|
},
|
||||||
|
福: {
|
||||||
|
name: "福",
|
||||||
|
category: EmojiCategory.BLESSING,
|
||||||
|
path: "/assets/blessing/fortune.png",
|
||||||
|
},
|
||||||
|
烟花: {
|
||||||
|
name: "烟花",
|
||||||
|
category: EmojiCategory.BLESSING,
|
||||||
|
path: "/assets/blessing/fireworks.png",
|
||||||
|
},
|
||||||
|
爆竹: {
|
||||||
|
name: "爆竹",
|
||||||
|
category: EmojiCategory.BLESSING,
|
||||||
|
path: "/assets/blessing/firecrackers.png",
|
||||||
|
},
|
||||||
|
|
||||||
|
// 其他表情
|
||||||
|
嘴唇: {
|
||||||
|
name: "嘴唇",
|
||||||
|
category: EmojiCategory.OTHER,
|
||||||
|
path: "/assets/other/lips.png",
|
||||||
|
},
|
||||||
|
爱心: {
|
||||||
|
name: "爱心",
|
||||||
|
category: EmojiCategory.OTHER,
|
||||||
|
path: "/assets/other/heart.png",
|
||||||
|
},
|
||||||
|
心碎: {
|
||||||
|
name: "心碎",
|
||||||
|
category: EmojiCategory.OTHER,
|
||||||
|
path: "/assets/other/broken-heart.png",
|
||||||
|
},
|
||||||
|
啤酒: {
|
||||||
|
name: "啤酒",
|
||||||
|
category: EmojiCategory.OTHER,
|
||||||
|
path: "/assets/other/beer.png",
|
||||||
|
},
|
||||||
|
咖啡: {
|
||||||
|
name: "咖啡",
|
||||||
|
category: EmojiCategory.OTHER,
|
||||||
|
path: "/assets/other/coffee.png",
|
||||||
|
},
|
||||||
|
蛋糕: {
|
||||||
|
name: "蛋糕",
|
||||||
|
category: EmojiCategory.OTHER,
|
||||||
|
path: "/assets/other/cake.png",
|
||||||
|
},
|
||||||
|
凋谢: {
|
||||||
|
name: "凋谢",
|
||||||
|
category: EmojiCategory.OTHER,
|
||||||
|
path: "/assets/other/wither.png",
|
||||||
|
},
|
||||||
|
菜刀: {
|
||||||
|
name: "菜刀",
|
||||||
|
category: EmojiCategory.OTHER,
|
||||||
|
path: "/assets/other/knife.png",
|
||||||
|
},
|
||||||
|
炸弹: {
|
||||||
|
name: "炸弹",
|
||||||
|
category: EmojiCategory.OTHER,
|
||||||
|
path: "/assets/other/bomb.png",
|
||||||
|
},
|
||||||
|
便便: {
|
||||||
|
name: "便便",
|
||||||
|
category: EmojiCategory.OTHER,
|
||||||
|
path: "/assets/other/poop.png",
|
||||||
|
},
|
||||||
|
太阳: {
|
||||||
|
name: "太阳",
|
||||||
|
category: EmojiCategory.OTHER,
|
||||||
|
path: "/assets/other/sun.png",
|
||||||
|
},
|
||||||
|
月亮: {
|
||||||
|
name: "月亮",
|
||||||
|
category: EmojiCategory.OTHER,
|
||||||
|
path: "/assets/other/moon.png",
|
||||||
|
},
|
||||||
|
玫瑰: {
|
||||||
|
name: "玫瑰",
|
||||||
|
category: EmojiCategory.OTHER,
|
||||||
|
path: "/assets/other/rose.png",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有表情数据的辅助函数
|
||||||
|
*/
|
||||||
|
function getAllEmojiData(): EmojiInfo[] {
|
||||||
|
const result: EmojiInfo[] = [];
|
||||||
|
for (const key in EMOJI_DATA) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(EMOJI_DATA, key)) {
|
||||||
|
result.push(EMOJI_DATA[key as EmojiName]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按类别分组的表情数据
|
||||||
|
*/
|
||||||
|
export const EMOJI_CATEGORIES = {
|
||||||
|
[EmojiCategory.FACE]: getAllEmojiData().filter(
|
||||||
|
emoji => emoji.category === EmojiCategory.FACE,
|
||||||
|
),
|
||||||
|
[EmojiCategory.GESTURE]: getAllEmojiData().filter(
|
||||||
|
emoji => emoji.category === EmojiCategory.GESTURE,
|
||||||
|
),
|
||||||
|
[EmojiCategory.ANIMAL]: getAllEmojiData().filter(
|
||||||
|
emoji => emoji.category === EmojiCategory.ANIMAL,
|
||||||
|
),
|
||||||
|
[EmojiCategory.BLESSING]: getAllEmojiData().filter(
|
||||||
|
emoji => emoji.category === EmojiCategory.BLESSING,
|
||||||
|
),
|
||||||
|
[EmojiCategory.OTHER]: getAllEmojiData().filter(
|
||||||
|
emoji => emoji.category === EmojiCategory.OTHER,
|
||||||
|
),
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取表情图片路径
|
||||||
|
* @param name 表情名称
|
||||||
|
* @returns 图片路径,如果表情不存在则返回 null
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const path = getEmojiPath('微笑'); // 'assets/face/微笑.png'
|
||||||
|
* const invalidPath = getEmojiPath('不存在'); // null
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function getEmojiPath(name: EmojiName): string | null {
|
||||||
|
const emoji = EMOJI_DATA[name];
|
||||||
|
return emoji ? emoji.path : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取表情信息
|
||||||
|
* @param name 表情名称
|
||||||
|
* @returns 表情信息对象,如果表情不存在则返回 null
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const emoji = getEmojiInfo('微笑');
|
||||||
|
* // { name: '微笑', category: EmojiCategory.FACE, path: 'assets/face/微笑.png' }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function getEmojiInfo(name: EmojiName): EmojiInfo | null {
|
||||||
|
return EMOJI_DATA[name] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据类别获取表情列表
|
||||||
|
* @param category 表情类别
|
||||||
|
* @returns 该类别下的所有表情信息
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const faceEmojis = getEmojisByCategory(EmojiCategory.FACE);
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function getEmojisByCategory(category: EmojiCategory): EmojiInfo[] {
|
||||||
|
return EMOJI_CATEGORIES[category];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有表情信息
|
||||||
|
* @returns 所有表情的信息数组
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const allEmojis = getAllEmojis();
|
||||||
|
* console.log(`总共有 ${allEmojis.length} 个表情`);
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function getAllEmojis(): EmojiInfo[] {
|
||||||
|
return getAllEmojiData();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 搜索表情
|
||||||
|
* @param keyword 搜索关键词
|
||||||
|
* @returns 匹配的表情信息数组
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const results = searchEmojis('笑');
|
||||||
|
* // 返回包含 '微笑', '偷笑', '坏笑' 等的表情
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function searchEmojis(keyword: string): EmojiInfo[] {
|
||||||
|
return getAllEmojiData().filter(emoji => emoji.name.indexOf(keyword) !== -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查表情是否存在
|
||||||
|
* @param name 表情名称
|
||||||
|
* @returns 是否存在该表情
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const exists = hasEmoji('微笑'); // true
|
||||||
|
* const notExists = hasEmoji('不存在的表情'); // false
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function hasEmoji(name: EmojiName): boolean {
|
||||||
|
return name in EMOJI_DATA;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取表情名称列表
|
||||||
|
* @param category 可选的类别筛选
|
||||||
|
* @returns 表情名称数组
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const allNames = getEmojiNames();
|
||||||
|
* const faceNames = getEmojiNames(EmojiCategory.FACE);
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function getEmojiNames(category?: EmojiCategory): string[] {
|
||||||
|
if (category) {
|
||||||
|
return getEmojisByCategory(category).map(emoji => emoji.name);
|
||||||
|
}
|
||||||
|
const names: string[] = [];
|
||||||
|
for (const key in EMOJI_DATA) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(EMOJI_DATA, key)) {
|
||||||
|
names.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 随机获取表情
|
||||||
|
* @param category 可选的类别筛选
|
||||||
|
* @returns 随机表情信息
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const randomEmoji = getRandomEmoji();
|
||||||
|
* const randomFaceEmoji = getRandomEmoji(EmojiCategory.FACE);
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function getRandomEmoji(category?: EmojiCategory): EmojiInfo {
|
||||||
|
const emojis = category ? getEmojisByCategory(category) : getAllEmojis();
|
||||||
|
const randomIndex = Math.floor(Math.random() * emojis.length);
|
||||||
|
return emojis[randomIndex];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 默认导出对象,包含所有主要功能
|
||||||
|
*/
|
||||||
|
const WeChatEmojis = {
|
||||||
|
// 枚举和类型
|
||||||
|
EmojiCategory,
|
||||||
|
|
||||||
|
// 数据
|
||||||
|
EMOJI_CATEGORIES,
|
||||||
|
|
||||||
|
// 工具函数
|
||||||
|
getEmojiPath,
|
||||||
|
getEmojiInfo,
|
||||||
|
getEmojisByCategory,
|
||||||
|
getAllEmojis,
|
||||||
|
searchEmojis,
|
||||||
|
hasEmoji,
|
||||||
|
getEmojiNames,
|
||||||
|
getRandomEmoji,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export default WeChatEmojis;
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
.modalMask {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
z-index: 9999;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
animation: fadeIn 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.videoContainer {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 90vw;
|
||||||
|
max-height: 90vh;
|
||||||
|
background: #000;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
animation: slideUp 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
color: #fff;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.closeButton {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.videoWrapper {
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
padding-top: 56.25%; // 16:9 比例
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 动画
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from {
|
||||||
|
transform: translateY(20px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移动端适配
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.modalMask {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.videoContainer {
|
||||||
|
max-width: 100vw;
|
||||||
|
max-height: 100vh;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
padding: 10px 12px;
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
101
Cunkebao/src/components/FloatingVideoHelp/VideoPlayer.tsx
Normal file
101
Cunkebao/src/components/FloatingVideoHelp/VideoPlayer.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import React, { useRef, useEffect } from "react";
|
||||||
|
import { CloseOutlined } from "@ant-design/icons";
|
||||||
|
import styles from "./VideoPlayer.module.scss";
|
||||||
|
|
||||||
|
interface VideoPlayerProps {
|
||||||
|
/** 视频URL */
|
||||||
|
videoUrl: string;
|
||||||
|
/** 是否显示播放器 */
|
||||||
|
visible: boolean;
|
||||||
|
/** 关闭回调 */
|
||||||
|
onClose: () => void;
|
||||||
|
/** 视频标题 */
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||||
|
videoUrl,
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
title = "操作视频",
|
||||||
|
}) => {
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible && videoRef.current) {
|
||||||
|
// 播放器打开时播放视频
|
||||||
|
videoRef.current.play().catch(err => {
|
||||||
|
console.error("视频播放失败:", err);
|
||||||
|
});
|
||||||
|
// 阻止背景滚动
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
} else if (videoRef.current) {
|
||||||
|
// 播放器关闭时暂停视频
|
||||||
|
videoRef.current.pause();
|
||||||
|
document.body.style.overflow = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = "";
|
||||||
|
};
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
// 点击遮罩层关闭
|
||||||
|
const handleMaskClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
// 如果点击的是遮罩层本身(不是视频容器),则关闭
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.pause();
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 阻止事件冒泡
|
||||||
|
const handleContentClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!visible) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={styles.modalMask}
|
||||||
|
onClick={handleMaskClick}
|
||||||
|
>
|
||||||
|
<div className={styles.videoContainer} onClick={handleContentClick}>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<span className={styles.title}>{title}</span>
|
||||||
|
<button className={styles.closeButton} onClick={handleClose}>
|
||||||
|
<CloseOutlined />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className={styles.videoWrapper}>
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
src={videoUrl}
|
||||||
|
controls
|
||||||
|
className={styles.video}
|
||||||
|
playsInline
|
||||||
|
webkit-playsinline="true"
|
||||||
|
x5-playsinline="true"
|
||||||
|
x5-video-player-type="h5"
|
||||||
|
x5-video-player-fullscreen="true"
|
||||||
|
>
|
||||||
|
您的浏览器不支持视频播放
|
||||||
|
</video>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VideoPlayer;
|
||||||
56
Cunkebao/src/components/FloatingVideoHelp/index.module.scss
Normal file
56
Cunkebao/src/components/FloatingVideoHelp/index.module.scss
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
.floatingButton {
|
||||||
|
position: fixed;
|
||||||
|
right: 20px;
|
||||||
|
bottom: 80px;
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%);
|
||||||
|
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.4);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 9998;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
animation: float 3s ease-in-out infinite;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
box-shadow: 0 6px 16px rgba(24, 144, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
font-size: 28px;
|
||||||
|
color: #ffffff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移动端适配
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
right: 16px;
|
||||||
|
bottom: 70px;
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes float {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
}
|
||||||
68
Cunkebao/src/components/FloatingVideoHelp/index.tsx
Normal file
68
Cunkebao/src/components/FloatingVideoHelp/index.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { useLocation } from "react-router-dom";
|
||||||
|
import { PlayCircleOutlined } from "@ant-design/icons";
|
||||||
|
import VideoPlayer from "./VideoPlayer";
|
||||||
|
import { getVideoUrlByRoute } from "./videoConfig";
|
||||||
|
import styles from "./index.module.scss";
|
||||||
|
|
||||||
|
interface FloatingVideoHelpProps {
|
||||||
|
/** 是否显示悬浮窗,默认为 true */
|
||||||
|
visible?: boolean;
|
||||||
|
/** 自定义样式类名 */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FloatingVideoHelp: React.FC<FloatingVideoHelpProps> = ({
|
||||||
|
visible = true,
|
||||||
|
className,
|
||||||
|
}) => {
|
||||||
|
const location = useLocation();
|
||||||
|
const [showPlayer, setShowPlayer] = useState(false);
|
||||||
|
const [currentVideoUrl, setCurrentVideoUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 根据当前路由获取视频URL
|
||||||
|
useEffect(() => {
|
||||||
|
const videoUrl = getVideoUrlByRoute(location.pathname);
|
||||||
|
setCurrentVideoUrl(videoUrl);
|
||||||
|
}, [location.pathname]);
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (currentVideoUrl) {
|
||||||
|
setShowPlayer(true);
|
||||||
|
} else {
|
||||||
|
// 如果没有对应的视频,可以显示提示
|
||||||
|
console.warn("当前路由没有对应的操作视频");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setShowPlayer(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 如果没有视频URL,不显示悬浮窗
|
||||||
|
if (!visible || !currentVideoUrl) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={`${styles.floatingButton} ${className || ""}`}
|
||||||
|
onClick={handleClick}
|
||||||
|
title="查看操作视频"
|
||||||
|
>
|
||||||
|
<PlayCircleOutlined className={styles.icon} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showPlayer && currentVideoUrl && (
|
||||||
|
<VideoPlayer
|
||||||
|
videoUrl={currentVideoUrl}
|
||||||
|
visible={showPlayer}
|
||||||
|
onClose={handleClose}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FloatingVideoHelp;
|
||||||
110
Cunkebao/src/components/FloatingVideoHelp/videoConfig.ts
Normal file
110
Cunkebao/src/components/FloatingVideoHelp/videoConfig.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
/**
|
||||||
|
* 路由到视频URL的映射配置
|
||||||
|
* key: 路由路径(支持正则表达式)
|
||||||
|
* value: 视频URL
|
||||||
|
*/
|
||||||
|
interface VideoConfig {
|
||||||
|
[route: string]: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 视频URL配置
|
||||||
|
const videoConfig: VideoConfig = {
|
||||||
|
// 首页
|
||||||
|
"/": "/videos/home.mp4",
|
||||||
|
"/mobile/home": "/videos/home.mp4",
|
||||||
|
|
||||||
|
// 工作台
|
||||||
|
"/workspace": "/videos/workspace.mp4",
|
||||||
|
"/workspace/auto-like": "/videos/auto-like-list.mp4",
|
||||||
|
"/workspace/auto-like/new": "/videos/auto-like-new.mp4",
|
||||||
|
"/workspace/auto-like/record": "/videos/auto-like-record.mp4",
|
||||||
|
"/workspace/auto-group": "/videos/auto-group-list.mp4",
|
||||||
|
"/workspace/auto-group/new": "/videos/auto-group-new.mp4",
|
||||||
|
"/workspace/group-push": "/videos/group-push-list.mp4",
|
||||||
|
"/workspace/group-push/new": "/videos/group-push-new.mp4",
|
||||||
|
"/workspace/moments-sync": "/videos/moments-sync-list.mp4",
|
||||||
|
"/workspace/moments-sync/new": "/videos/moments-sync-new.mp4",
|
||||||
|
"/workspace/ai-assistant": "/videos/ai-assistant.mp4",
|
||||||
|
"/workspace/ai-analyzer": "/videos/ai-analyzer.mp4",
|
||||||
|
"/workspace/traffic-distribution": "/videos/traffic-distribution-list.mp4",
|
||||||
|
"/workspace/traffic-distribution/new": "/videos/traffic-distribution-new.mp4",
|
||||||
|
"/workspace/contact-import": "/videos/contact-import-list.mp4",
|
||||||
|
"/workspace/contact-import/form": "/videos/contact-import-form.mp4",
|
||||||
|
"/workspace/ai-knowledge": "/videos/ai-knowledge-list.mp4",
|
||||||
|
"/workspace/ai-knowledge/new": "/videos/ai-knowledge-new.mp4",
|
||||||
|
|
||||||
|
// 我的
|
||||||
|
"/mobile/mine": "/videos/mine.mp4",
|
||||||
|
"/mobile/mine/devices": "/videos/devices.mp4",
|
||||||
|
"/mobile/mine/wechat-accounts": "/videos/wechat-accounts.mp4",
|
||||||
|
"/mobile/mine/content": "/videos/content.mp4",
|
||||||
|
"/mobile/mine/traffic-pool": "/videos/traffic-pool.mp4",
|
||||||
|
"/mobile/mine/recharge": "/videos/recharge.mp4",
|
||||||
|
"/mobile/mine/setting": "/videos/setting.mp4",
|
||||||
|
|
||||||
|
// 场景
|
||||||
|
"/mobile/scenarios": "/videos/scenarios.mp4",
|
||||||
|
"/mobile/scenarios/plan": "/videos/scenarios-plan.mp4",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据路由路径获取对应的视频URL
|
||||||
|
* @param routePath 当前路由路径
|
||||||
|
* @returns 视频URL,如果没有匹配则返回 null
|
||||||
|
*/
|
||||||
|
export function getVideoUrlByRoute(routePath: string): string | null {
|
||||||
|
// 精确匹配
|
||||||
|
if (videoConfig[routePath]) {
|
||||||
|
return videoConfig[routePath];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 模糊匹配(支持动态路由参数)
|
||||||
|
// 例如:/workspace/auto-like/edit/123 会匹配 /workspace/auto-like/edit/:id
|
||||||
|
const routeKeys = Object.keys(videoConfig);
|
||||||
|
for (const key of routeKeys) {
|
||||||
|
// 将配置中的 :id 等参数转换为正则表达式
|
||||||
|
const regexPattern = key.replace(/:\w+/g, "[^/]+");
|
||||||
|
const regex = new RegExp(`^${regexPattern}$`);
|
||||||
|
if (regex.test(routePath)) {
|
||||||
|
return videoConfig[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 前缀匹配(作为兜底方案)
|
||||||
|
// 例如:/workspace/auto-like/edit/123 会匹配 /workspace/auto-like
|
||||||
|
const sortedKeys = routeKeys.sort((a, b) => b.length - a.length); // 按长度降序排列
|
||||||
|
for (const key of sortedKeys) {
|
||||||
|
if (routePath.startsWith(key)) {
|
||||||
|
return videoConfig[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加或更新视频配置
|
||||||
|
* @param route 路由路径
|
||||||
|
* @param videoUrl 视频URL
|
||||||
|
*/
|
||||||
|
export function setVideoConfig(route: string, videoUrl: string): void {
|
||||||
|
videoConfig[route] = videoUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量添加视频配置
|
||||||
|
* @param config 视频配置对象
|
||||||
|
*/
|
||||||
|
export function setVideoConfigs(config: VideoConfig): void {
|
||||||
|
Object.assign(videoConfig, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有视频配置
|
||||||
|
* @returns 视频配置对象
|
||||||
|
*/
|
||||||
|
export function getAllVideoConfigs(): VideoConfig {
|
||||||
|
return { ...videoConfig };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default videoConfig;
|
||||||
11
Cunkebao/src/components/FriendSelection/api.ts
Normal file
11
Cunkebao/src/components/FriendSelection/api.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import request from "@/api/request";
|
||||||
|
|
||||||
|
// 获取好友列表
|
||||||
|
export function getFriendList(params: {
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
deviceIds?: string; // 逗号分隔
|
||||||
|
keyword?: string;
|
||||||
|
}) {
|
||||||
|
return request("/v1/friend", params, "GET");
|
||||||
|
}
|
||||||
27
Cunkebao/src/components/FriendSelection/data.ts
Normal file
27
Cunkebao/src/components/FriendSelection/data.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
export interface FriendSelectionItem {
|
||||||
|
id: number;
|
||||||
|
wechatId: string;
|
||||||
|
nickname: string;
|
||||||
|
avatar: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 组件属性接口
|
||||||
|
export interface FriendSelectionProps {
|
||||||
|
selectedOptions?: FriendSelectionItem[];
|
||||||
|
onSelect: (friends: FriendSelectionItem[]) => void;
|
||||||
|
deviceIds?: number[];
|
||||||
|
enableDeviceFilter?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
className?: string;
|
||||||
|
visible?: boolean; // 新增
|
||||||
|
onVisibleChange?: (visible: boolean) => void; // 新增
|
||||||
|
selectedListMaxHeight?: number;
|
||||||
|
showInput?: boolean;
|
||||||
|
showSelectedList?: boolean;
|
||||||
|
readonly?: boolean;
|
||||||
|
onConfirm?: (
|
||||||
|
selectedIds: number[],
|
||||||
|
selectedItems: FriendSelectionItem[],
|
||||||
|
) => void; // 新增
|
||||||
|
}
|
||||||
246
Cunkebao/src/components/FriendSelection/index.module.scss
Normal file
246
Cunkebao/src/components/FriendSelection/index.module.scss
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
.inputWrapper {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.selectedListRow {
|
||||||
|
padding: 8px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.selectedListRowContent {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.selectedListRowContentText {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.inputIcon {
|
||||||
|
position: absolute;
|
||||||
|
left: 12px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: #bdbdbd;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
.input {
|
||||||
|
padding-left: 38px !important;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 16px !important;
|
||||||
|
border: 1px solid #e5e6eb !important;
|
||||||
|
font-size: 16px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popupContainer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.popupHeader {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
.popupTitle {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.searchWrapper {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.searchInput {
|
||||||
|
padding-left: 40px !important;
|
||||||
|
padding-top: 8px !important;
|
||||||
|
padding-bottom: 8px !important;
|
||||||
|
border-radius: 24px !important;
|
||||||
|
border: 1px solid #e5e6eb !important;
|
||||||
|
font-size: 15px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
.searchIcon {
|
||||||
|
position: absolute;
|
||||||
|
left: 12px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: #bdbdbd;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.clearBtn {
|
||||||
|
position: absolute;
|
||||||
|
right: 8px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
height: 24px;
|
||||||
|
width: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
min-width: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friendList {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.friendListInner {
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
.friendItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 24px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
&:hover {
|
||||||
|
background: #f5f6fa;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.radioWrapper {
|
||||||
|
margin-right: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.radioSelected {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid #1890ff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.radioUnselected {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid #e5e6eb;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.radioDot {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #1890ff;
|
||||||
|
}
|
||||||
|
.friendInfo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.friendAvatar {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.avatarImg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.friendDetail {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.friendName {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #222;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.friendId {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #888;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.friendCustomer {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #bdbdbd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loadingBox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.loadingText {
|
||||||
|
color: #888;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
.emptyBox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.emptyText {
|
||||||
|
color: #888;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paginationRow {
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.totalCount {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
.paginationControls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.pageBtn {
|
||||||
|
padding: 0 8px;
|
||||||
|
height: 32px;
|
||||||
|
min-width: 32px;
|
||||||
|
}
|
||||||
|
.pageInfo {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popupFooter {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.selectedCount {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
.footerBtnGroup {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.cancelBtn {
|
||||||
|
padding: 0 24px;
|
||||||
|
border-radius: 24px;
|
||||||
|
border: 1px solid #e5e6eb;
|
||||||
|
}
|
||||||
|
.confirmBtn {
|
||||||
|
padding: 0 24px;
|
||||||
|
border-radius: 24px;
|
||||||
|
}
|
||||||
140
Cunkebao/src/components/FriendSelection/index.tsx
Normal file
140
Cunkebao/src/components/FriendSelection/index.tsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { SearchOutlined, DeleteOutlined } from "@ant-design/icons";
|
||||||
|
import { Button, Input } from "antd";
|
||||||
|
import { Avatar } from "antd-mobile";
|
||||||
|
import style from "./index.module.scss";
|
||||||
|
import { FriendSelectionProps } from "./data";
|
||||||
|
import SelectionPopup from "./selectionPopup";
|
||||||
|
|
||||||
|
export default function FriendSelection({
|
||||||
|
selectedOptions = [],
|
||||||
|
onSelect,
|
||||||
|
deviceIds = [],
|
||||||
|
enableDeviceFilter = true,
|
||||||
|
placeholder = "选择微信好友",
|
||||||
|
className = "",
|
||||||
|
visible,
|
||||||
|
onVisibleChange,
|
||||||
|
selectedListMaxHeight = 300,
|
||||||
|
showInput = true,
|
||||||
|
showSelectedList = true,
|
||||||
|
readonly = false,
|
||||||
|
onConfirm,
|
||||||
|
}: FriendSelectionProps) {
|
||||||
|
const [popupVisible, setPopupVisible] = useState(false);
|
||||||
|
// 内部弹窗交给 selectionPopup 处理
|
||||||
|
|
||||||
|
// 受控弹窗逻辑
|
||||||
|
const realVisible = visible !== undefined ? visible : popupVisible;
|
||||||
|
const setRealVisible = (v: boolean) => {
|
||||||
|
if (onVisibleChange) onVisibleChange(v);
|
||||||
|
if (visible === undefined) setPopupVisible(v);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 打开弹窗
|
||||||
|
const openPopup = () => {
|
||||||
|
if (readonly) return;
|
||||||
|
setRealVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取显示文本
|
||||||
|
const getDisplayText = () => {
|
||||||
|
if (!selectedOptions || selectedOptions.length === 0) return "";
|
||||||
|
return `已选择 ${selectedOptions.length} 个好友`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 删除已选好友
|
||||||
|
const handleRemoveFriend = (id: number) => {
|
||||||
|
if (readonly) return;
|
||||||
|
onSelect((selectedOptions || []).filter(v => v.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 弹窗确认回调
|
||||||
|
const handleConfirm = (
|
||||||
|
selectedIds: number[],
|
||||||
|
selectedItems: typeof selectedOptions,
|
||||||
|
) => {
|
||||||
|
onSelect(selectedItems);
|
||||||
|
if (onConfirm) onConfirm(selectedIds, selectedItems);
|
||||||
|
setRealVisible(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* 输入框 */}
|
||||||
|
{showInput && (
|
||||||
|
<div className={`${style.inputWrapper} ${className}`}>
|
||||||
|
<Input
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={getDisplayText()}
|
||||||
|
onClick={openPopup}
|
||||||
|
prefix={<SearchOutlined />}
|
||||||
|
allowClear={!readonly}
|
||||||
|
size="large"
|
||||||
|
readOnly={readonly}
|
||||||
|
disabled={readonly}
|
||||||
|
style={
|
||||||
|
readonly ? { background: "#f5f5f5", cursor: "not-allowed" } : {}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* 已选好友列表窗口 */}
|
||||||
|
{showSelectedList && (selectedOptions || []).length > 0 && (
|
||||||
|
<div
|
||||||
|
className={style.selectedListWindow}
|
||||||
|
style={{
|
||||||
|
maxHeight: selectedListMaxHeight,
|
||||||
|
overflowY: "auto",
|
||||||
|
marginTop: 8,
|
||||||
|
border: "1px solid #e5e6eb",
|
||||||
|
borderRadius: 8,
|
||||||
|
background: "#fff",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(selectedOptions || []).map(friend => (
|
||||||
|
<div key={friend.id} className={style.selectedListRow}>
|
||||||
|
<div className={style.selectedListRowContent}>
|
||||||
|
<Avatar src={friend.avatar || friend.friendAvatar} />
|
||||||
|
<div className={style.selectedListRowContentText}>
|
||||||
|
<div>{friend.nickname || friend.friendName}</div>
|
||||||
|
<div>{friend.wechatId}</div>
|
||||||
|
</div>
|
||||||
|
{!readonly && (
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
size="small"
|
||||||
|
style={{
|
||||||
|
marginLeft: 4,
|
||||||
|
color: "#ff4d4f",
|
||||||
|
border: "none",
|
||||||
|
background: "none",
|
||||||
|
minWidth: 24,
|
||||||
|
height: 24,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
onClick={() => handleRemoveFriend(friend.id)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* 弹窗 */}
|
||||||
|
<SelectionPopup
|
||||||
|
visible={realVisible && !readonly}
|
||||||
|
onVisibleChange={setRealVisible}
|
||||||
|
selectedOptions={selectedOptions || []}
|
||||||
|
onSelect={onSelect}
|
||||||
|
deviceIds={deviceIds}
|
||||||
|
enableDeviceFilter={enableDeviceFilter}
|
||||||
|
readonly={readonly}
|
||||||
|
onConfirm={handleConfirm}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
245
Cunkebao/src/components/FriendSelection/selectionPopup.tsx
Normal file
245
Cunkebao/src/components/FriendSelection/selectionPopup.tsx
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
|
import { Popup, Checkbox } from "antd-mobile";
|
||||||
|
import Layout from "@/components/Layout/Layout";
|
||||||
|
import PopupHeader from "@/components/PopuLayout/header";
|
||||||
|
import PopupFooter from "@/components/PopuLayout/footer";
|
||||||
|
import { getFriendList } from "./api";
|
||||||
|
import style from "./index.module.scss";
|
||||||
|
import type { FriendSelectionItem } from "./data";
|
||||||
|
|
||||||
|
interface SelectionPopupProps {
|
||||||
|
visible: boolean;
|
||||||
|
onVisibleChange: (visible: boolean) => void;
|
||||||
|
selectedOptions: FriendSelectionItem[];
|
||||||
|
onSelect: (friends: FriendSelectionItem[]) => void;
|
||||||
|
deviceIds?: number[];
|
||||||
|
enableDeviceFilter?: boolean;
|
||||||
|
readonly?: boolean;
|
||||||
|
onConfirm?: (
|
||||||
|
selectedIds: number[],
|
||||||
|
selectedItems: FriendSelectionItem[],
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SelectionPopup: React.FC<SelectionPopupProps> = ({
|
||||||
|
visible,
|
||||||
|
onVisibleChange,
|
||||||
|
selectedOptions,
|
||||||
|
onSelect,
|
||||||
|
deviceIds = [],
|
||||||
|
enableDeviceFilter = true,
|
||||||
|
readonly = false,
|
||||||
|
onConfirm,
|
||||||
|
}) => {
|
||||||
|
const [friends, setFriends] = useState<FriendSelectionItem[]>([]);
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
|
const [totalFriends, setTotalFriends] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [tempSelectedOptions, setTempSelectedOptions] = useState<
|
||||||
|
FriendSelectionItem[]
|
||||||
|
>([]);
|
||||||
|
|
||||||
|
// 获取好友列表API
|
||||||
|
const fetchFriends = useCallback(
|
||||||
|
async (page: number, keyword: string = "") => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const params: any = {
|
||||||
|
page,
|
||||||
|
limit: 20,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (keyword.trim()) {
|
||||||
|
params.keyword = keyword.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enableDeviceFilter && deviceIds.length > 0) {
|
||||||
|
params.deviceIds = deviceIds.join(",");
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await getFriendList(params);
|
||||||
|
if (response && response.list) {
|
||||||
|
setFriends(response.list);
|
||||||
|
setTotalFriends(response.total || 0);
|
||||||
|
setTotalPages(Math.ceil((response.total || 0) / 20));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("获取好友列表失败:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[deviceIds, enableDeviceFilter],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 处理好友选择
|
||||||
|
const handleFriendToggle = (friend: FriendSelectionItem) => {
|
||||||
|
if (readonly) return;
|
||||||
|
|
||||||
|
const newSelectedFriends = tempSelectedOptions.some(f => f.id === friend.id)
|
||||||
|
? tempSelectedOptions.filter(f => f.id !== friend.id)
|
||||||
|
: tempSelectedOptions.concat(friend);
|
||||||
|
|
||||||
|
setTempSelectedOptions(newSelectedFriends);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 全选当前页
|
||||||
|
const handleSelectAllCurrentPage = (checked: boolean) => {
|
||||||
|
if (readonly) return;
|
||||||
|
|
||||||
|
if (checked) {
|
||||||
|
// 全选:添加当前页面所有未选中的好友
|
||||||
|
const currentPageFriends = friends.filter(
|
||||||
|
friend => !tempSelectedOptions.some(f => f.id === friend.id),
|
||||||
|
);
|
||||||
|
setTempSelectedOptions(prev => [...prev, ...currentPageFriends]);
|
||||||
|
} else {
|
||||||
|
// 取消全选:移除当前页面的所有好友
|
||||||
|
const currentPageFriendIds = friends.map(f => f.id);
|
||||||
|
setTempSelectedOptions(prev =>
|
||||||
|
prev.filter(f => !currentPageFriendIds.includes(f.id)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 检查当前页是否全选
|
||||||
|
const isCurrentPageAllSelected =
|
||||||
|
friends.length > 0 &&
|
||||||
|
friends.every(friend => tempSelectedOptions.some(f => f.id === friend.id));
|
||||||
|
|
||||||
|
// 确认选择
|
||||||
|
const handleConfirm = () => {
|
||||||
|
if (onConfirm) {
|
||||||
|
onConfirm(
|
||||||
|
tempSelectedOptions.map(v => v.id),
|
||||||
|
tempSelectedOptions,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 更新实际选中的选项
|
||||||
|
onSelect(tempSelectedOptions);
|
||||||
|
onVisibleChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 弹窗打开时初始化
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
setCurrentPage(1);
|
||||||
|
setSearchQuery("");
|
||||||
|
// 复制一份selectedOptions到临时变量
|
||||||
|
setTempSelectedOptions([...selectedOptions]);
|
||||||
|
fetchFriends(1, "");
|
||||||
|
}
|
||||||
|
}, [visible, selectedOptions]); // 只在弹窗开启时请求
|
||||||
|
|
||||||
|
// 搜索防抖(只在弹窗打开且搜索词变化时执行)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible || searchQuery === "") return; // 弹窗关闭或搜索词为空时不请求
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setCurrentPage(1);
|
||||||
|
fetchFriends(1, searchQuery);
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [searchQuery, visible]);
|
||||||
|
|
||||||
|
// 页码变化时请求数据(只在弹窗打开且页码不是1时执行)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) return; // 弹窗关闭或第一页时不请求
|
||||||
|
fetchFriends(currentPage, searchQuery);
|
||||||
|
}, [currentPage, visible, searchQuery]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popup
|
||||||
|
visible={visible && !readonly}
|
||||||
|
onMaskClick={() => onVisibleChange(false)}
|
||||||
|
position="bottom"
|
||||||
|
bodyStyle={{ height: "100vh" }}
|
||||||
|
>
|
||||||
|
<Layout
|
||||||
|
header={
|
||||||
|
<PopupHeader
|
||||||
|
title="选择微信好友"
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
setSearchQuery={setSearchQuery}
|
||||||
|
searchPlaceholder="搜索好友"
|
||||||
|
loading={loading}
|
||||||
|
onRefresh={() => fetchFriends(currentPage, searchQuery)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
footer={
|
||||||
|
<PopupFooter
|
||||||
|
currentPage={currentPage}
|
||||||
|
totalPages={totalPages}
|
||||||
|
loading={loading}
|
||||||
|
selectedCount={tempSelectedOptions.length}
|
||||||
|
onPageChange={setCurrentPage}
|
||||||
|
onCancel={() => onVisibleChange(false)}
|
||||||
|
onConfirm={handleConfirm}
|
||||||
|
isAllSelected={isCurrentPageAllSelected}
|
||||||
|
onSelectAll={handleSelectAllCurrentPage}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={style.friendList}>
|
||||||
|
{loading ? (
|
||||||
|
<div className={style.loadingBox}>
|
||||||
|
<div className={style.loadingText}>加载中...</div>
|
||||||
|
</div>
|
||||||
|
) : friends.length > 0 ? (
|
||||||
|
<div className={style.friendListInner}>
|
||||||
|
{friends.map(friend => (
|
||||||
|
<div key={friend.id} className={style.friendItem}>
|
||||||
|
<Checkbox
|
||||||
|
checked={tempSelectedOptions.some(f => f.id === friend.id)}
|
||||||
|
onChange={() => !readonly && handleFriendToggle(friend)}
|
||||||
|
disabled={readonly}
|
||||||
|
style={{ marginRight: 12 }}
|
||||||
|
/>
|
||||||
|
<div className={style.friendInfo}>
|
||||||
|
<div className={style.friendAvatar}>
|
||||||
|
{friend.avatar ? (
|
||||||
|
<img
|
||||||
|
src={friend.avatar}
|
||||||
|
alt={friend.nickname}
|
||||||
|
className={style.avatarImg}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
friend.nickname.charAt(0)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={style.friendDetail}>
|
||||||
|
<div className={style.friendName}>{friend.nickname}</div>
|
||||||
|
<div className={style.friendId}>
|
||||||
|
微信ID: {friend.wechatId}
|
||||||
|
</div>
|
||||||
|
{friend.customer && (
|
||||||
|
<div className={style.friendCustomer}>
|
||||||
|
归属客户: {friend.customer}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={style.emptyBox}>
|
||||||
|
<div className={style.emptyText}>
|
||||||
|
{deviceIds.length === 0
|
||||||
|
? "请先选择设备"
|
||||||
|
: searchQuery
|
||||||
|
? `没有找到包含"${searchQuery}"的好友`
|
||||||
|
: "没有找到好友"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
</Popup>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SelectionPopup;
|
||||||
10
Cunkebao/src/components/GroupSelection/api.ts
Normal file
10
Cunkebao/src/components/GroupSelection/api.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import request from "@/api/request";
|
||||||
|
|
||||||
|
// 获取群组列表
|
||||||
|
export function getGroupList(params: {
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
keyword?: string;
|
||||||
|
}) {
|
||||||
|
return request("/v1/chatroom", params, "GET");
|
||||||
|
}
|
||||||
43
Cunkebao/src/components/GroupSelection/data.ts
Normal file
43
Cunkebao/src/components/GroupSelection/data.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
// 群组接口类型
|
||||||
|
export interface WechatGroup {
|
||||||
|
id: string;
|
||||||
|
chatroomId: string;
|
||||||
|
name: string;
|
||||||
|
avatar: string;
|
||||||
|
ownerWechatId: string;
|
||||||
|
ownerNickname: string;
|
||||||
|
ownerAvatar: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GroupSelectionItem {
|
||||||
|
id: string;
|
||||||
|
avatar: string;
|
||||||
|
chatroomId?: string;
|
||||||
|
createTime?: number;
|
||||||
|
identifier?: string;
|
||||||
|
name: string;
|
||||||
|
ownerAlias?: string;
|
||||||
|
ownerAvatar?: string;
|
||||||
|
ownerNickname?: string;
|
||||||
|
ownerWechatId?: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 组件属性接口
|
||||||
|
export interface GroupSelectionProps {
|
||||||
|
selectedOptions: GroupSelectionItem[];
|
||||||
|
onSelect: (groups: GroupSelectionItem[]) => void;
|
||||||
|
onSelectDetail?: (groups: WechatGroup[]) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
className?: string;
|
||||||
|
visible?: boolean;
|
||||||
|
onVisibleChange?: (visible: boolean) => void;
|
||||||
|
selectedListMaxHeight?: number;
|
||||||
|
showInput?: boolean;
|
||||||
|
showSelectedList?: boolean;
|
||||||
|
readonly?: boolean;
|
||||||
|
onConfirm?: (
|
||||||
|
selectedIds: string[],
|
||||||
|
selectedItems: GroupSelectionItem[],
|
||||||
|
) => void; // 新增
|
||||||
|
}
|
||||||
206
Cunkebao/src/components/GroupSelection/index.module.scss
Normal file
206
Cunkebao/src/components/GroupSelection/index.module.scss
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
.inputWrapper {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.inputIcon {
|
||||||
|
position: absolute;
|
||||||
|
left: 12px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: #bdbdbd;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
.input {
|
||||||
|
padding-left: 38px !important;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 16px !important;
|
||||||
|
border: 1px solid #e5e6eb !important;
|
||||||
|
font-size: 16px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
.selectedListRow {
|
||||||
|
padding: 8px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.selectedListRowContent {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.selectedListRowContentText {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popupContainer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.popupHeader {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
.popupTitle {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.searchWrapper {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.searchInput {
|
||||||
|
padding-left: 40px !important;
|
||||||
|
padding-top: 8px !important;
|
||||||
|
padding-bottom: 8px !important;
|
||||||
|
border-radius: 24px !important;
|
||||||
|
border: 1px solid #e5e6eb !important;
|
||||||
|
font-size: 15px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
.searchIcon {
|
||||||
|
position: absolute;
|
||||||
|
left: 12px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: #bdbdbd;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.clearBtn {
|
||||||
|
position: absolute;
|
||||||
|
right: 8px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
height: 24px;
|
||||||
|
width: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
min-width: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupList {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.groupListInner {
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
.groupItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 24px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
transition: background 0.2s;
|
||||||
|
&:hover {
|
||||||
|
background: #f5f6fa;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.groupInfo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.groupAvatar {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.avatarImg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.groupDetail {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.groupName {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #222;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.groupId {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #888;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.groupOwner {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #bdbdbd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loadingBox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.loadingText {
|
||||||
|
color: #888;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
.emptyBox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.emptyText {
|
||||||
|
color: #888;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paginationRow {
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.totalCount {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
.paginationControls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.pageBtn {
|
||||||
|
padding: 0 8px;
|
||||||
|
height: 32px;
|
||||||
|
min-width: 32px;
|
||||||
|
}
|
||||||
|
.pageInfo {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popupFooter {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.selectedCount {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
.footerBtnGroup {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
126
Cunkebao/src/components/GroupSelection/index.tsx
Normal file
126
Cunkebao/src/components/GroupSelection/index.tsx
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { SearchOutlined, DeleteOutlined } from "@ant-design/icons";
|
||||||
|
import { Button, Input } from "antd";
|
||||||
|
import { Avatar } from "antd-mobile";
|
||||||
|
import style from "./index.module.scss";
|
||||||
|
import SelectionPopup from "./selectionPopup";
|
||||||
|
import { GroupSelectionProps } from "./data";
|
||||||
|
export default function GroupSelection({
|
||||||
|
selectedOptions,
|
||||||
|
onSelect,
|
||||||
|
onSelectDetail,
|
||||||
|
placeholder = "选择群聊",
|
||||||
|
className = "",
|
||||||
|
visible,
|
||||||
|
onVisibleChange,
|
||||||
|
selectedListMaxHeight = 300,
|
||||||
|
showInput = true,
|
||||||
|
showSelectedList = true,
|
||||||
|
readonly = false,
|
||||||
|
onConfirm,
|
||||||
|
}: GroupSelectionProps) {
|
||||||
|
const [popupVisible, setPopupVisible] = useState(false);
|
||||||
|
|
||||||
|
// 删除已选群聊
|
||||||
|
const handleRemoveGroup = (id: string) => {
|
||||||
|
if (readonly) return;
|
||||||
|
onSelect(selectedOptions.filter(g => g.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 受控弹窗逻辑
|
||||||
|
const realVisible = visible !== undefined ? visible : popupVisible;
|
||||||
|
const setRealVisible = (v: boolean) => {
|
||||||
|
if (onVisibleChange) onVisibleChange(v);
|
||||||
|
if (visible === undefined) setPopupVisible(v);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 打开弹窗
|
||||||
|
const openPopup = () => {
|
||||||
|
if (readonly) return;
|
||||||
|
setRealVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取显示文本
|
||||||
|
const getDisplayText = () => {
|
||||||
|
if (selectedOptions.length === 0) return "";
|
||||||
|
return `已选择 ${selectedOptions.length} 个群聊`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* 输入框 */}
|
||||||
|
{showInput && (
|
||||||
|
<div className={`${style.inputWrapper} ${className}`}>
|
||||||
|
<Input
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={getDisplayText()}
|
||||||
|
onClick={openPopup}
|
||||||
|
prefix={<SearchOutlined />}
|
||||||
|
allowClear={!readonly}
|
||||||
|
size="large"
|
||||||
|
readOnly={readonly}
|
||||||
|
disabled={readonly}
|
||||||
|
style={
|
||||||
|
readonly ? { background: "#f5f5f5", cursor: "not-allowed" } : {}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* 已选群聊列表窗口 */}
|
||||||
|
{showSelectedList && selectedOptions.length > 0 && (
|
||||||
|
<div
|
||||||
|
className={style.selectedListWindow}
|
||||||
|
style={{
|
||||||
|
maxHeight: selectedListMaxHeight,
|
||||||
|
overflowY: "auto",
|
||||||
|
marginTop: 8,
|
||||||
|
border: "1px solid #e5e6eb",
|
||||||
|
borderRadius: 8,
|
||||||
|
background: "#fff",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selectedOptions.map(group => (
|
||||||
|
<div key={group.id} className={style.selectedListRow}>
|
||||||
|
<div className={style.selectedListRowContent}>
|
||||||
|
<Avatar src={group.avatar} />
|
||||||
|
<div className={style.selectedListRowContentText}>
|
||||||
|
<div>{group.name}</div>
|
||||||
|
<div>{group.chatroomId}</div>
|
||||||
|
</div>
|
||||||
|
{!readonly && (
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
size="small"
|
||||||
|
style={{
|
||||||
|
marginLeft: 4,
|
||||||
|
color: "#ff4d4f",
|
||||||
|
border: "none",
|
||||||
|
background: "none",
|
||||||
|
minWidth: 24,
|
||||||
|
height: 24,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
onClick={() => handleRemoveGroup(group.id)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* 弹窗 */}
|
||||||
|
<SelectionPopup
|
||||||
|
visible={realVisible}
|
||||||
|
onVisibleChange={setRealVisible}
|
||||||
|
selectedOptions={selectedOptions}
|
||||||
|
onSelect={onSelect}
|
||||||
|
onSelectDetail={onSelectDetail}
|
||||||
|
readonly={readonly}
|
||||||
|
onConfirm={onConfirm}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
257
Cunkebao/src/components/GroupSelection/selectionPopup.tsx
Normal file
257
Cunkebao/src/components/GroupSelection/selectionPopup.tsx
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { Popup, Checkbox } from "antd-mobile";
|
||||||
|
|
||||||
|
import { getGroupList } from "./api";
|
||||||
|
import style from "./index.module.scss";
|
||||||
|
import Layout from "@/components/Layout/Layout";
|
||||||
|
import PopupHeader from "@/components/PopuLayout/header";
|
||||||
|
import PopupFooter from "@/components/PopuLayout/footer";
|
||||||
|
import { GroupSelectionItem } from "./data";
|
||||||
|
// 群组接口类型
|
||||||
|
interface WechatGroup {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
avatar: string;
|
||||||
|
chatroomId?: string;
|
||||||
|
ownerWechatId?: string;
|
||||||
|
ownerNickname?: string;
|
||||||
|
ownerAvatar?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 弹窗属性接口
|
||||||
|
interface SelectionPopupProps {
|
||||||
|
visible: boolean;
|
||||||
|
onVisibleChange: (visible: boolean) => void;
|
||||||
|
selectedOptions: GroupSelectionItem[];
|
||||||
|
onSelect: (groups: GroupSelectionItem[]) => void;
|
||||||
|
onSelectDetail?: (groups: WechatGroup[]) => void;
|
||||||
|
readonly?: boolean;
|
||||||
|
onConfirm?: (
|
||||||
|
selectedIds: string[],
|
||||||
|
selectedItems: GroupSelectionItem[],
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SelectionPopup({
|
||||||
|
visible,
|
||||||
|
onVisibleChange,
|
||||||
|
selectedOptions,
|
||||||
|
onSelect,
|
||||||
|
onSelectDetail,
|
||||||
|
readonly = false,
|
||||||
|
onConfirm,
|
||||||
|
}: SelectionPopupProps) {
|
||||||
|
const [groups, setGroups] = useState<WechatGroup[]>([]);
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
|
const [totalGroups, setTotalGroups] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [tempSelectedOptions, setTempSelectedOptions] = useState<
|
||||||
|
GroupSelectionItem[]
|
||||||
|
>([]);
|
||||||
|
|
||||||
|
// 获取群聊列表API
|
||||||
|
const fetchGroups = async (page: number, keyword: string = "") => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const params: any = {
|
||||||
|
page,
|
||||||
|
limit: 20,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (keyword.trim()) {
|
||||||
|
params.keyword = keyword.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await getGroupList(params);
|
||||||
|
if (response && response.list) {
|
||||||
|
setGroups(response.list);
|
||||||
|
setTotalGroups(response.total || 0);
|
||||||
|
setTotalPages(Math.ceil((response.total || 0) / 20));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("获取群聊列表失败:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理群聊选择
|
||||||
|
const handleGroupToggle = (group: GroupSelectionItem) => {
|
||||||
|
if (readonly) return;
|
||||||
|
|
||||||
|
const newSelectedGroups = tempSelectedOptions.some(g => g.id === group.id)
|
||||||
|
? tempSelectedOptions.filter(g => g.id !== group.id)
|
||||||
|
: tempSelectedOptions.concat(group);
|
||||||
|
|
||||||
|
setTempSelectedOptions(newSelectedGroups);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 全选当前页
|
||||||
|
const handleSelectAllCurrentPage = (checked: boolean) => {
|
||||||
|
if (readonly) return;
|
||||||
|
|
||||||
|
if (checked) {
|
||||||
|
// 全选:添加当前页面所有未选中的群组
|
||||||
|
const currentPageGroups = groups.filter(
|
||||||
|
group => !tempSelectedOptions.some(g => g.id === group.id),
|
||||||
|
);
|
||||||
|
setTempSelectedOptions(prev => [...prev, ...currentPageGroups]);
|
||||||
|
} else {
|
||||||
|
// 取消全选:移除当前页面的所有群组
|
||||||
|
const currentPageGroupIds = groups.map(g => g.id);
|
||||||
|
setTempSelectedOptions(prev =>
|
||||||
|
prev.filter(g => !currentPageGroupIds.includes(g.id)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 检查当前页是否全选
|
||||||
|
const isCurrentPageAllSelected =
|
||||||
|
groups.length > 0 &&
|
||||||
|
groups.every(group => tempSelectedOptions.some(g => g.id === group.id));
|
||||||
|
|
||||||
|
// 确认选择
|
||||||
|
const handleConfirm = () => {
|
||||||
|
// 用户点击确认时,才更新实际的selectedOptions
|
||||||
|
onSelect(tempSelectedOptions);
|
||||||
|
|
||||||
|
// 如果有 onSelectDetail 回调,传递完整的群聊对象
|
||||||
|
if (onSelectDetail) {
|
||||||
|
const selectedGroupObjs = groups.filter(group =>
|
||||||
|
tempSelectedOptions.some(g => g.id === group.id),
|
||||||
|
);
|
||||||
|
onSelectDetail(selectedGroupObjs);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onConfirm) {
|
||||||
|
onConfirm(
|
||||||
|
tempSelectedOptions.map(g => g.id),
|
||||||
|
tempSelectedOptions,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
onVisibleChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 弹窗打开时初始化数据(只执行一次)
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
setCurrentPage(1);
|
||||||
|
setSearchQuery("");
|
||||||
|
// 复制一份selectedOptions到临时变量
|
||||||
|
setTempSelectedOptions([...selectedOptions]);
|
||||||
|
fetchGroups(1, "");
|
||||||
|
} else {
|
||||||
|
// 弹窗关闭时重置状态
|
||||||
|
setTempSelectedOptions([]);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
// 搜索防抖(只在弹窗打开且搜索词变化时执行)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible || searchQuery === "") return;
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setCurrentPage(1);
|
||||||
|
fetchGroups(1, searchQuery);
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [searchQuery, visible]);
|
||||||
|
|
||||||
|
// 页码变化时请求数据(只在弹窗打开且页码不是1时执行)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) return;
|
||||||
|
fetchGroups(currentPage, searchQuery);
|
||||||
|
}, [currentPage, visible, searchQuery]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popup
|
||||||
|
visible={visible && !readonly}
|
||||||
|
onMaskClick={() => onVisibleChange(false)}
|
||||||
|
position="bottom"
|
||||||
|
bodyStyle={{ height: "100vh" }}
|
||||||
|
>
|
||||||
|
<Layout
|
||||||
|
header={
|
||||||
|
<PopupHeader
|
||||||
|
title="选择群聊"
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
setSearchQuery={setSearchQuery}
|
||||||
|
searchPlaceholder="搜索群聊"
|
||||||
|
loading={loading}
|
||||||
|
onRefresh={() => fetchGroups(currentPage, searchQuery)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
footer={
|
||||||
|
<PopupFooter
|
||||||
|
currentPage={currentPage}
|
||||||
|
totalPages={totalPages}
|
||||||
|
loading={loading}
|
||||||
|
selectedCount={tempSelectedOptions.length}
|
||||||
|
onPageChange={setCurrentPage}
|
||||||
|
onCancel={() => onVisibleChange(false)}
|
||||||
|
onConfirm={handleConfirm}
|
||||||
|
isAllSelected={isCurrentPageAllSelected}
|
||||||
|
onSelectAll={handleSelectAllCurrentPage}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={style.groupList}>
|
||||||
|
{loading ? (
|
||||||
|
<div className={style.loadingBox}>
|
||||||
|
<div className={style.loadingText}>加载中...</div>
|
||||||
|
</div>
|
||||||
|
) : groups.length > 0 ? (
|
||||||
|
<div className={style.groupListInner}>
|
||||||
|
{groups.map(group => (
|
||||||
|
<div key={group.id} className={style.groupItem}>
|
||||||
|
<Checkbox
|
||||||
|
checked={tempSelectedOptions.some(g => g.id === group.id)}
|
||||||
|
onChange={() => !readonly && handleGroupToggle(group)}
|
||||||
|
disabled={readonly}
|
||||||
|
style={{ marginRight: 12 }}
|
||||||
|
/>
|
||||||
|
<div className={style.groupInfo}>
|
||||||
|
<div className={style.groupAvatar}>
|
||||||
|
{group.avatar ? (
|
||||||
|
<img
|
||||||
|
src={group.avatar}
|
||||||
|
alt={group.name}
|
||||||
|
className={style.avatarImg}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
group.name.charAt(0)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={style.groupDetail}>
|
||||||
|
<div className={style.groupName}>{group.name}</div>
|
||||||
|
<div className={style.groupId}>
|
||||||
|
群ID: {group.chatroomId}
|
||||||
|
</div>
|
||||||
|
{group.ownerNickname && (
|
||||||
|
<div className={style.groupOwner}>
|
||||||
|
群主: {group.ownerNickname}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={style.emptyBox}>
|
||||||
|
<div className={style.emptyText}>
|
||||||
|
{searchQuery
|
||||||
|
? `没有找到包含"${searchQuery}"的群聊`
|
||||||
|
: "没有找到群聊"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
</Popup>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
.container {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inputWrapper {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
.inputIcon {
|
||||||
|
position: absolute;
|
||||||
|
left: 12px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: #bdbdbd;
|
||||||
|
font-size: 20px;
|
||||||
|
z-index: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
padding-left: 38px !important;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 16px !important;
|
||||||
|
border: 1px solid #e5e6eb !important;
|
||||||
|
font-size: 16px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clearBtn {
|
||||||
|
position: absolute;
|
||||||
|
right: 12px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: #999;
|
||||||
|
font-size: 16px;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectedGroupsList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupCard {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid #e5e6eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupInfo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupAvatar {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupDetails {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupName {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #222;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupId {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #888;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deleteGroupBtn {
|
||||||
|
color: #ff4d4f;
|
||||||
|
font-size: 18px;
|
||||||
|
padding: 4px;
|
||||||
|
min-width: auto;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.membersSection {
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.membersLabel {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.membersList {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.memberItem {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
position: relative;
|
||||||
|
width: 70px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.memberAvatar {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 50%;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.removeMemberBtn {
|
||||||
|
position: absolute;
|
||||||
|
top: -4px;
|
||||||
|
right: -4px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
min-width: 20px;
|
||||||
|
padding: 0;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e5e6eb;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: #ff4d4f;
|
||||||
|
font-size: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.memberName {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #222;
|
||||||
|
text-align: center;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addMemberBtn {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border: 1px dashed #d9d9d9;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fafafa;
|
||||||
|
color: #999;
|
||||||
|
font-size: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background: #f0f0f0;
|
||||||
|
border-color: #1677ff;
|
||||||
|
color: #1677ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.memberSelectionPopup {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popupHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popupTitle {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.closeBtn {
|
||||||
|
color: #1677ff;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchBox {
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
background: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchInputWrapper {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchInput {
|
||||||
|
flex: 1;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clearSearchBtn {
|
||||||
|
position: absolute;
|
||||||
|
right: 8px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
min-width: 20px;
|
||||||
|
padding: 0;
|
||||||
|
color: #999;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchBtn {
|
||||||
|
min-width: 60px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.memberList {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.memberListItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 0;
|
||||||
|
border-bottom: 1px solid #f5f5f5;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
.memberListItemName {
|
||||||
|
color: #1677ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.memberListItemAvatar {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.memberListItemName {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkmark {
|
||||||
|
color: #1677ff;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loadingBox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loadingText {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyBox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyText {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
438
Cunkebao/src/components/GroupSelectionWithMembers/index.tsx
Normal file
438
Cunkebao/src/components/GroupSelectionWithMembers/index.tsx
Normal file
@@ -0,0 +1,438 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { SearchOutlined, DeleteOutlined, PlusOutlined, CloseOutlined } from "@ant-design/icons";
|
||||||
|
import { Button, Input, Popup } from "antd-mobile";
|
||||||
|
import { Avatar } from "antd-mobile";
|
||||||
|
import style from "./index.module.scss";
|
||||||
|
import GroupSelection from "../GroupSelection";
|
||||||
|
import { GroupSelectionItem } from "../GroupSelection/data";
|
||||||
|
import request from "@/api/request";
|
||||||
|
|
||||||
|
// 群成员接口
|
||||||
|
export interface GroupMember {
|
||||||
|
id: string;
|
||||||
|
nickname: string;
|
||||||
|
wechatId: string;
|
||||||
|
avatar: string;
|
||||||
|
gender?: "male" | "female";
|
||||||
|
role?: "owner" | "admin" | "member";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 带成员的群选项
|
||||||
|
export interface GroupWithMembers extends GroupSelectionItem {
|
||||||
|
members?: GroupMember[];
|
||||||
|
groupId?: string; // 用于关联成员和群
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GroupSelectionWithMembersProps {
|
||||||
|
selectedGroups: GroupWithMembers[];
|
||||||
|
onSelect: (groups: GroupWithMembers[]) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
className?: string;
|
||||||
|
readonly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取群成员列表
|
||||||
|
const getGroupMembers = async (
|
||||||
|
groupId: string,
|
||||||
|
page: number = 1,
|
||||||
|
limit: number = 100,
|
||||||
|
keyword: string = "",
|
||||||
|
): Promise<GroupMember[]> => {
|
||||||
|
try {
|
||||||
|
const params: any = {
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
groupId,
|
||||||
|
};
|
||||||
|
if (keyword.trim()) {
|
||||||
|
params.keyword = keyword.trim();
|
||||||
|
}
|
||||||
|
const response = await request("/v1/kefu/wechatChatroom/members", params, "GET");
|
||||||
|
// request 拦截器会返回 res.data.data ?? res.data
|
||||||
|
// 对于 { code: 200, data: { list: [...] } } 的返回,拦截器会返回 { list: [...] }
|
||||||
|
const memberList = response?.list || response?.data?.list || [];
|
||||||
|
|
||||||
|
// 映射接口返回的数据结构到我们的接口
|
||||||
|
return memberList.map((item: any) => ({
|
||||||
|
id: String(item.id),
|
||||||
|
nickname: item.nickname || "",
|
||||||
|
wechatId: item.wechatId || "",
|
||||||
|
avatar: item.avatar || "",
|
||||||
|
gender: undefined, // 接口未返回,暂时设为 undefined
|
||||||
|
role: undefined, // 接口未返回,暂时设为 undefined
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("获取群成员失败:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const GroupSelectionWithMembers: React.FC<GroupSelectionWithMembersProps> = ({
|
||||||
|
selectedGroups,
|
||||||
|
onSelect,
|
||||||
|
placeholder = "选择聊天群",
|
||||||
|
className = "",
|
||||||
|
readonly = false,
|
||||||
|
}) => {
|
||||||
|
const [groupSelectionVisible, setGroupSelectionVisible] = useState(false);
|
||||||
|
const [memberSelectionVisible, setMemberSelectionVisible] = useState<{
|
||||||
|
visible: boolean;
|
||||||
|
groupId: string;
|
||||||
|
}>({ visible: false, groupId: "" });
|
||||||
|
const [allMembers, setAllMembers] = useState<Record<string, GroupMember[]>>({});
|
||||||
|
const [selectedMembers, setSelectedMembers] = useState<Record<string, GroupMember[]>>({});
|
||||||
|
const [loadingMembers, setLoadingMembers] = useState(false);
|
||||||
|
const [memberSearchKeyword, setMemberSearchKeyword] = useState("");
|
||||||
|
// 存储完整成员列表(用于搜索时切换回完整列表)
|
||||||
|
const [fullMembersCache, setFullMembersCache] = useState<Record<string, GroupMember[]>>({});
|
||||||
|
|
||||||
|
// 处理群选择
|
||||||
|
const handleGroupSelect = (groups: GroupSelectionItem[]) => {
|
||||||
|
const groupsWithMembers: GroupWithMembers[] = groups.map(group => {
|
||||||
|
const existing = selectedGroups.find(g => g.id === group.id);
|
||||||
|
return {
|
||||||
|
...group,
|
||||||
|
members: existing?.members || [],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
onSelect(groupsWithMembers);
|
||||||
|
setGroupSelectionVisible(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 删除群
|
||||||
|
const handleRemoveGroup = (groupId: string) => {
|
||||||
|
if (readonly) return;
|
||||||
|
const newGroups = selectedGroups.filter(g => g.id !== groupId);
|
||||||
|
const newSelectedMembers = { ...selectedMembers };
|
||||||
|
delete newSelectedMembers[groupId];
|
||||||
|
setSelectedMembers(newSelectedMembers);
|
||||||
|
onSelect(newGroups);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 打开成员选择弹窗
|
||||||
|
const handleOpenMemberSelection = async (groupId: string) => {
|
||||||
|
if (readonly) return;
|
||||||
|
setMemberSelectionVisible({ visible: true, groupId });
|
||||||
|
setMemberSearchKeyword(""); // 重置搜索关键词
|
||||||
|
|
||||||
|
// 如果还没有加载过该群的成员列表,则加载所有成员(不使用搜索关键词)
|
||||||
|
if (!allMembers[groupId] && !fullMembersCache[groupId]) {
|
||||||
|
setLoadingMembers(true);
|
||||||
|
try {
|
||||||
|
const members = await getGroupMembers(groupId, 1, 100, "");
|
||||||
|
setAllMembers(prev => ({ ...prev, [groupId]: members }));
|
||||||
|
setFullMembersCache(prev => ({ ...prev, [groupId]: members })); // 缓存完整列表
|
||||||
|
} catch (error) {
|
||||||
|
console.error("加载群成员失败:", error);
|
||||||
|
} finally {
|
||||||
|
setLoadingMembers(false);
|
||||||
|
}
|
||||||
|
} else if (fullMembersCache[groupId] && !allMembers[groupId]) {
|
||||||
|
// 如果有缓存但没有显示列表,恢复完整列表
|
||||||
|
setAllMembers(prev => ({ ...prev, [groupId]: fullMembersCache[groupId] }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 关闭成员选择弹窗
|
||||||
|
const handleCloseMemberSelection = () => {
|
||||||
|
setMemberSelectionVisible({ visible: false, groupId: "" });
|
||||||
|
setMemberSearchKeyword(""); // 重置搜索关键词
|
||||||
|
};
|
||||||
|
|
||||||
|
// 手动触发搜索
|
||||||
|
const handleSearchMembers = async () => {
|
||||||
|
const groupId = memberSelectionVisible.groupId;
|
||||||
|
if (!groupId) return;
|
||||||
|
|
||||||
|
const keyword = memberSearchKeyword.trim();
|
||||||
|
|
||||||
|
// 如果搜索关键词为空,使用缓存的完整列表
|
||||||
|
if (!keyword) {
|
||||||
|
if (fullMembersCache[groupId] && fullMembersCache[groupId].length > 0) {
|
||||||
|
setAllMembers(prev => ({ ...prev, [groupId]: fullMembersCache[groupId] }));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 有搜索关键词时,调用 API 搜索
|
||||||
|
setLoadingMembers(true);
|
||||||
|
try {
|
||||||
|
const members = await getGroupMembers(groupId, 1, 100, keyword);
|
||||||
|
setAllMembers(prev => ({ ...prev, [groupId]: members }));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("搜索群成员失败:", error);
|
||||||
|
} finally {
|
||||||
|
setLoadingMembers(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 清空搜索
|
||||||
|
const handleClearSearch = () => {
|
||||||
|
setMemberSearchKeyword("");
|
||||||
|
const groupId = memberSelectionVisible.groupId;
|
||||||
|
if (groupId && fullMembersCache[groupId] && fullMembersCache[groupId].length > 0) {
|
||||||
|
setAllMembers(prev => ({ ...prev, [groupId]: fullMembersCache[groupId] }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 选择成员
|
||||||
|
const handleSelectMember = (groupId: string, member: GroupMember) => {
|
||||||
|
if (readonly) return;
|
||||||
|
const currentMembers = selectedMembers[groupId] || [];
|
||||||
|
const isSelected = currentMembers.some(m => m.id === member.id);
|
||||||
|
|
||||||
|
let newSelectedMembers = { ...selectedMembers };
|
||||||
|
if (isSelected) {
|
||||||
|
newSelectedMembers[groupId] = currentMembers.filter(m => m.id !== member.id);
|
||||||
|
} else {
|
||||||
|
newSelectedMembers[groupId] = [...currentMembers, member];
|
||||||
|
}
|
||||||
|
setSelectedMembers(newSelectedMembers);
|
||||||
|
|
||||||
|
// 更新群数据
|
||||||
|
const updatedGroups = selectedGroups.map(group => {
|
||||||
|
if (group.id === groupId) {
|
||||||
|
return {
|
||||||
|
...group,
|
||||||
|
members: newSelectedMembers[groupId] || [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return group;
|
||||||
|
});
|
||||||
|
onSelect(updatedGroups);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 移除成员
|
||||||
|
const handleRemoveMember = (groupId: string, memberId: string) => {
|
||||||
|
if (readonly) return;
|
||||||
|
const currentMembers = selectedMembers[groupId] || [];
|
||||||
|
const newMembers = currentMembers.filter(m => m.id !== memberId);
|
||||||
|
|
||||||
|
const newSelectedMembers = { ...selectedMembers };
|
||||||
|
newSelectedMembers[groupId] = newMembers;
|
||||||
|
setSelectedMembers(newSelectedMembers);
|
||||||
|
|
||||||
|
// 更新群数据
|
||||||
|
const updatedGroups = selectedGroups.map(group => {
|
||||||
|
if (group.id === groupId) {
|
||||||
|
return {
|
||||||
|
...group,
|
||||||
|
members: newMembers,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return group;
|
||||||
|
});
|
||||||
|
onSelect(updatedGroups);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 同步 selectedGroups 到 selectedMembers
|
||||||
|
useEffect(() => {
|
||||||
|
const membersMap: Record<string, GroupMember[]> = {};
|
||||||
|
selectedGroups.forEach(group => {
|
||||||
|
if (group.members && group.members.length > 0) {
|
||||||
|
membersMap[group.id] = group.members;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setSelectedMembers(membersMap);
|
||||||
|
}, [selectedGroups.length]);
|
||||||
|
|
||||||
|
// 获取显示文本
|
||||||
|
const getDisplayText = () => {
|
||||||
|
if (selectedGroups.length === 0) return "";
|
||||||
|
return `已选择${selectedGroups.length}个群聊`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentGroupMembers = allMembers[memberSelectionVisible.groupId] || [];
|
||||||
|
const currentSelectedMembers = selectedMembers[memberSelectionVisible.groupId] || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${style.container} ${className}`}>
|
||||||
|
{/* 输入框 */}
|
||||||
|
<div
|
||||||
|
className={style.inputWrapper}
|
||||||
|
onClick={() => !readonly && setGroupSelectionVisible(true)}
|
||||||
|
>
|
||||||
|
<SearchOutlined className={style.inputIcon} />
|
||||||
|
<Input
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={getDisplayText()}
|
||||||
|
readOnly
|
||||||
|
className={style.input}
|
||||||
|
/>
|
||||||
|
{!readonly && selectedGroups.length > 0 && (
|
||||||
|
<Button
|
||||||
|
fill="none"
|
||||||
|
size="small"
|
||||||
|
className={style.clearBtn}
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setSelectedMembers({});
|
||||||
|
onSelect([]);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DeleteOutlined />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 已选群列表 */}
|
||||||
|
{selectedGroups.length > 0 && (
|
||||||
|
<div className={style.selectedGroupsList}>
|
||||||
|
{selectedGroups.map(group => (
|
||||||
|
<div key={group.id} className={style.groupCard}>
|
||||||
|
{/* 群信息 */}
|
||||||
|
<div className={style.groupHeader}>
|
||||||
|
<div className={style.groupInfo}>
|
||||||
|
<Avatar src={group.avatar} className={style.groupAvatar} />
|
||||||
|
<div className={style.groupDetails}>
|
||||||
|
<div className={style.groupName}>{group.name}</div>
|
||||||
|
<div className={style.groupId}>ID: {group.chatroomId || group.id}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!readonly && (
|
||||||
|
<Button
|
||||||
|
fill="none"
|
||||||
|
size="small"
|
||||||
|
className={style.deleteGroupBtn}
|
||||||
|
onClick={() => handleRemoveGroup(group.id)}
|
||||||
|
>
|
||||||
|
<DeleteOutlined />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 成员选择区域 */}
|
||||||
|
<div className={style.membersSection}>
|
||||||
|
<div className={style.membersLabel}>
|
||||||
|
采集群内指定成员 ({group.members?.length || 0}人)
|
||||||
|
</div>
|
||||||
|
<div className={style.membersList}>
|
||||||
|
{group.members?.map(member => (
|
||||||
|
<div key={member.id} className={style.memberItem}>
|
||||||
|
<Avatar src={member.avatar} className={style.memberAvatar} />
|
||||||
|
<div className={style.memberName}>{member.nickname}</div>
|
||||||
|
{!readonly && (
|
||||||
|
<Button
|
||||||
|
fill="none"
|
||||||
|
size="small"
|
||||||
|
className={style.removeMemberBtn}
|
||||||
|
onClick={() => handleRemoveMember(group.id, member.id)}
|
||||||
|
>
|
||||||
|
<DeleteOutlined />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!readonly && (
|
||||||
|
<div
|
||||||
|
className={style.addMemberBtn}
|
||||||
|
onClick={() => handleOpenMemberSelection(group.id)}
|
||||||
|
>
|
||||||
|
<PlusOutlined />
|
||||||
|
<span>添加</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 群选择弹窗 */}
|
||||||
|
<GroupSelection
|
||||||
|
selectedOptions={selectedGroups as GroupSelectionItem[]}
|
||||||
|
onSelect={handleGroupSelect}
|
||||||
|
placeholder={placeholder}
|
||||||
|
visible={groupSelectionVisible}
|
||||||
|
onVisibleChange={setGroupSelectionVisible}
|
||||||
|
showInput={false}
|
||||||
|
showSelectedList={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 成员选择弹窗 */}
|
||||||
|
<Popup
|
||||||
|
visible={memberSelectionVisible.visible}
|
||||||
|
onMaskClick={handleCloseMemberSelection}
|
||||||
|
position="bottom"
|
||||||
|
bodyStyle={{
|
||||||
|
height: "70vh",
|
||||||
|
borderTopLeftRadius: 16,
|
||||||
|
borderTopRightRadius: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={style.memberSelectionPopup}>
|
||||||
|
<div className={style.popupHeader}>
|
||||||
|
<div className={style.popupTitle}>选择成员</div>
|
||||||
|
<Button
|
||||||
|
fill="none"
|
||||||
|
size="small"
|
||||||
|
onClick={handleCloseMemberSelection}
|
||||||
|
className={style.closeBtn}
|
||||||
|
>
|
||||||
|
完成
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className={style.searchBox}>
|
||||||
|
<div className={style.searchInputWrapper}>
|
||||||
|
<Input
|
||||||
|
placeholder="搜索成员昵称或微信号"
|
||||||
|
value={memberSearchKeyword}
|
||||||
|
onChange={val => setMemberSearchKeyword(val)}
|
||||||
|
onEnterPress={handleSearchMembers}
|
||||||
|
className={style.searchInput}
|
||||||
|
/>
|
||||||
|
{memberSearchKeyword && (
|
||||||
|
<Button
|
||||||
|
fill="none"
|
||||||
|
size="small"
|
||||||
|
className={style.clearSearchBtn}
|
||||||
|
onClick={handleClearSearch}
|
||||||
|
>
|
||||||
|
<CloseOutlined />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
color="primary"
|
||||||
|
size="small"
|
||||||
|
onClick={handleSearchMembers}
|
||||||
|
loading={loadingMembers}
|
||||||
|
className={style.searchBtn}
|
||||||
|
>
|
||||||
|
搜索
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className={style.memberList}>
|
||||||
|
{loadingMembers ? (
|
||||||
|
<div className={style.loadingBox}>
|
||||||
|
<div className={style.loadingText}>加载中...</div>
|
||||||
|
</div>
|
||||||
|
) : currentGroupMembers.length > 0 ? (
|
||||||
|
currentGroupMembers.map(member => {
|
||||||
|
const isSelected = currentSelectedMembers.some(m => m.id === member.id);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={member.id}
|
||||||
|
className={`${style.memberListItem} ${isSelected ? style.selected : ""}`}
|
||||||
|
onClick={() => handleSelectMember(memberSelectionVisible.groupId, member)}
|
||||||
|
>
|
||||||
|
<Avatar src={member.avatar} className={style.memberListItemAvatar} />
|
||||||
|
<div className={style.memberListItemName}>{member.nickname}</div>
|
||||||
|
{isSelected && <div className={style.checkmark}>✓</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<div className={style.emptyBox}>
|
||||||
|
<div className={style.emptyText}>暂无成员数据</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Popup>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GroupSelectionWithMembers;
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
.listContainer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listItem {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loadMoreButtonContainer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.noMoreText {
|
||||||
|
text-align: center;
|
||||||
|
color: #999;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyState {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
color: #999;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyIcon {
|
||||||
|
font-size: 48px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyText {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pullToRefresh {
|
||||||
|
height: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自定义滚动条样式
|
||||||
|
.listContainer::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listContainer::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listContainer::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(0, 0, 0, 0.1);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listContainer::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 响应式设计
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.listContainer {
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loadMoreButtonContainer {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.noMoreText {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
195
Cunkebao/src/components/InfiniteList/InfiniteList.tsx
Normal file
195
Cunkebao/src/components/InfiniteList/InfiniteList.tsx
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||||
|
import {
|
||||||
|
PullToRefresh,
|
||||||
|
InfiniteScroll,
|
||||||
|
Button,
|
||||||
|
SpinLoading,
|
||||||
|
} from "antd-mobile";
|
||||||
|
import styles from "./InfiniteList.module.scss";
|
||||||
|
|
||||||
|
interface InfiniteListProps<T> {
|
||||||
|
// 数据相关
|
||||||
|
data: T[];
|
||||||
|
loading?: boolean;
|
||||||
|
hasMore?: boolean;
|
||||||
|
loadingText?: string;
|
||||||
|
noMoreText?: string;
|
||||||
|
|
||||||
|
// 渲染相关
|
||||||
|
renderItem: (item: T, index: number) => React.ReactNode;
|
||||||
|
keyExtractor?: (item: T, index: number) => string | number;
|
||||||
|
|
||||||
|
// 事件回调
|
||||||
|
onLoadMore?: () => Promise<void> | void;
|
||||||
|
onRefresh?: () => Promise<void> | void;
|
||||||
|
|
||||||
|
// 样式相关
|
||||||
|
className?: string;
|
||||||
|
itemClassName?: string;
|
||||||
|
containerStyle?: React.CSSProperties;
|
||||||
|
|
||||||
|
// 功能开关
|
||||||
|
enablePullToRefresh?: boolean;
|
||||||
|
enableInfiniteScroll?: boolean;
|
||||||
|
enableLoadMoreButton?: boolean;
|
||||||
|
|
||||||
|
// 自定义高度
|
||||||
|
height?: string | number;
|
||||||
|
minHeight?: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const InfiniteList = <T extends any>({
|
||||||
|
data,
|
||||||
|
loading = false,
|
||||||
|
hasMore = true,
|
||||||
|
loadingText = "加载中...",
|
||||||
|
noMoreText = "没有更多了",
|
||||||
|
|
||||||
|
renderItem,
|
||||||
|
keyExtractor = (_, index) => index,
|
||||||
|
|
||||||
|
onLoadMore,
|
||||||
|
onRefresh,
|
||||||
|
|
||||||
|
className = "",
|
||||||
|
itemClassName = "",
|
||||||
|
containerStyle = {},
|
||||||
|
|
||||||
|
enablePullToRefresh = true,
|
||||||
|
enableInfiniteScroll = true,
|
||||||
|
enableLoadMoreButton = false,
|
||||||
|
|
||||||
|
height = "100%",
|
||||||
|
minHeight = "200px",
|
||||||
|
}: InfiniteListProps<T>) => {
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// 处理下拉刷新
|
||||||
|
const handleRefresh = useCallback(async () => {
|
||||||
|
if (!onRefresh) return;
|
||||||
|
|
||||||
|
setRefreshing(true);
|
||||||
|
try {
|
||||||
|
await onRefresh();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Refresh failed:", error);
|
||||||
|
} finally {
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
}, [onRefresh]);
|
||||||
|
|
||||||
|
// 处理加载更多
|
||||||
|
const handleLoadMore = useCallback(async () => {
|
||||||
|
if (!onLoadMore || loadingMore || !hasMore) return;
|
||||||
|
|
||||||
|
setLoadingMore(true);
|
||||||
|
try {
|
||||||
|
await onLoadMore();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Load more failed:", error);
|
||||||
|
} finally {
|
||||||
|
setLoadingMore(false);
|
||||||
|
}
|
||||||
|
}, [onLoadMore, loadingMore, hasMore]);
|
||||||
|
|
||||||
|
// 点击加载更多按钮
|
||||||
|
const handleLoadMoreClick = useCallback(() => {
|
||||||
|
handleLoadMore();
|
||||||
|
}, [handleLoadMore]);
|
||||||
|
|
||||||
|
// 容器样式
|
||||||
|
const containerStyles: React.CSSProperties = {
|
||||||
|
height,
|
||||||
|
minHeight,
|
||||||
|
...containerStyle,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 渲染列表项
|
||||||
|
const renderListItems = () => {
|
||||||
|
return data.map((item, index) => (
|
||||||
|
<div
|
||||||
|
key={keyExtractor(item, index)}
|
||||||
|
className={`${styles.listItem} ${itemClassName}`}
|
||||||
|
>
|
||||||
|
{renderItem(item, index)}
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 渲染加载更多按钮
|
||||||
|
const renderLoadMoreButton = () => {
|
||||||
|
if (!enableLoadMoreButton || !hasMore) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.loadMoreButtonContainer}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
loading={loadingMore}
|
||||||
|
onClick={handleLoadMoreClick}
|
||||||
|
disabled={loading || !hasMore}
|
||||||
|
>
|
||||||
|
{loadingMore ? loadingText : "点击加载更多"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 渲染无更多数据提示
|
||||||
|
const renderNoMoreText = () => {
|
||||||
|
if (hasMore || data.length === 0) return null;
|
||||||
|
|
||||||
|
return <div className={styles.noMoreText}>{noMoreText}</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 渲染空状态
|
||||||
|
const renderEmptyState = () => {
|
||||||
|
if (data.length > 0 || loading) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.emptyState}>
|
||||||
|
<div className={styles.emptyIcon}>📝</div>
|
||||||
|
<div className={styles.emptyText}>暂无数据</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<div
|
||||||
|
className={`${styles.listContainer} ${className}`}
|
||||||
|
style={containerStyles}
|
||||||
|
>
|
||||||
|
{renderListItems()}
|
||||||
|
{renderLoadMoreButton()}
|
||||||
|
{renderNoMoreText()}
|
||||||
|
{renderEmptyState()}
|
||||||
|
|
||||||
|
{/* 无限滚动组件 */}
|
||||||
|
{enableInfiniteScroll && (
|
||||||
|
<InfiniteScroll
|
||||||
|
loadMore={handleLoadMore}
|
||||||
|
hasMore={hasMore}
|
||||||
|
threshold={100}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 如果启用下拉刷新,包装PullToRefresh
|
||||||
|
if (enablePullToRefresh && onRefresh) {
|
||||||
|
return (
|
||||||
|
<PullToRefresh
|
||||||
|
onRefresh={handleRefresh}
|
||||||
|
refreshing={refreshing}
|
||||||
|
className={styles.pullToRefresh}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</PullToRefresh>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return content;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default InfiniteList;
|
||||||
52
Cunkebao/src/components/Layout/Layout.tsx
Normal file
52
Cunkebao/src/components/Layout/Layout.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import React, { useEffect } from "react";
|
||||||
|
import { SpinLoading } from "antd-mobile";
|
||||||
|
import styles from "./layout.module.scss";
|
||||||
|
|
||||||
|
interface LayoutProps {
|
||||||
|
loading?: boolean;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
header?: React.ReactNode;
|
||||||
|
footer?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Layout: React.FC<LayoutProps> = ({
|
||||||
|
children,
|
||||||
|
header,
|
||||||
|
footer,
|
||||||
|
loading = false,
|
||||||
|
}) => {
|
||||||
|
// 移动端100vh兼容
|
||||||
|
useEffect(() => {
|
||||||
|
const setRealHeight = () => {
|
||||||
|
document.documentElement.style.setProperty(
|
||||||
|
"--real-vh",
|
||||||
|
`${window.innerHeight * 0.01}px`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
setRealHeight();
|
||||||
|
window.addEventListener("resize", setRealHeight);
|
||||||
|
return () => window.removeEventListener("resize", setRealHeight);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={styles.container}
|
||||||
|
style={{ height: "calc(var(--real-vh, 1vh) * 100)" }}
|
||||||
|
>
|
||||||
|
{header && <header>{header}</header>}
|
||||||
|
<main>
|
||||||
|
{loading ? (
|
||||||
|
<div className={styles.loadingContainer}>
|
||||||
|
<SpinLoading color="primary" style={{ fontSize: 32 }} />
|
||||||
|
<div className={styles.loadingText}>加载中...</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
children
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
{footer && <footer>{footer}</footer>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Layout;
|
||||||
48
Cunkebao/src/components/Layout/LayoutFiexd.tsx
Normal file
48
Cunkebao/src/components/Layout/LayoutFiexd.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { SpinLoading } from "antd-mobile";
|
||||||
|
import styles from "./layout.module.scss";
|
||||||
|
|
||||||
|
interface LayoutProps {
|
||||||
|
loading?: boolean;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
header?: React.ReactNode;
|
||||||
|
footer?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LayoutFiexd: React.FC<LayoutProps> = ({
|
||||||
|
header,
|
||||||
|
children,
|
||||||
|
footer,
|
||||||
|
loading = false,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: "100%",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="header">{header}</div>
|
||||||
|
<div
|
||||||
|
className="content"
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
overflow: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className={styles.loadingContainer}>
|
||||||
|
<SpinLoading color="primary" style={{ fontSize: 32 }} />
|
||||||
|
<div className={styles.loadingText}>加载中...</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
children
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="footer">{footer}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LayoutFiexd;
|
||||||
28
Cunkebao/src/components/Layout/layout.module.scss
Normal file
28
Cunkebao/src/components/Layout/layout.module.scss
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
height: 100vh;
|
||||||
|
flex-direction: column;
|
||||||
|
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.container main {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loadingContainer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 300px;
|
||||||
|
background: rgba(255, 255, 255, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loadingText {
|
||||||
|
margin-top: 16px;
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
53
Cunkebao/src/components/LineChart.tsx
Normal file
53
Cunkebao/src/components/LineChart.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ReactECharts from "echarts-for-react";
|
||||||
|
|
||||||
|
interface LineChartProps {
|
||||||
|
title?: string;
|
||||||
|
xData: string[];
|
||||||
|
yData: number[];
|
||||||
|
height?: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LineChart: React.FC<LineChartProps> = ({
|
||||||
|
title = "",
|
||||||
|
xData,
|
||||||
|
yData,
|
||||||
|
height = 200,
|
||||||
|
}) => {
|
||||||
|
const option = {
|
||||||
|
title: {
|
||||||
|
text: title,
|
||||||
|
left: "center",
|
||||||
|
textStyle: { fontSize: 16 },
|
||||||
|
},
|
||||||
|
tooltip: { trigger: "axis" },
|
||||||
|
xAxis: {
|
||||||
|
type: "category",
|
||||||
|
data: xData,
|
||||||
|
boundaryGap: false,
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: "value",
|
||||||
|
boundaryGap: ["10%", "10%"], // 上下留白
|
||||||
|
min: (value: any) => value.min - 10, // 下方多留一点空间
|
||||||
|
max: (value: any) => value.max + 10, // 上方多留一点空间
|
||||||
|
minInterval: 1,
|
||||||
|
axisLabel: { margin: 12 },
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
data: yData,
|
||||||
|
type: "line",
|
||||||
|
smooth: true,
|
||||||
|
symbol: "circle",
|
||||||
|
lineStyle: { color: "#1677ff" },
|
||||||
|
itemStyle: { color: "#1677ff" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
grid: { left: 40, right: 24, top: 40, bottom: 32 },
|
||||||
|
};
|
||||||
|
|
||||||
|
return <ReactECharts option={option} style={{ height, width: "100%" }} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LineChart;
|
||||||
57
Cunkebao/src/components/LineChart2.tsx
Normal file
57
Cunkebao/src/components/LineChart2.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ReactECharts from "echarts-for-react";
|
||||||
|
import { getChartColor } from "@/utils/chartColors";
|
||||||
|
|
||||||
|
interface LineChartProps {
|
||||||
|
title?: string;
|
||||||
|
xData: string[];
|
||||||
|
yData: any[];
|
||||||
|
height?: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LineChart: React.FC<LineChartProps> = ({
|
||||||
|
title = "",
|
||||||
|
xData,
|
||||||
|
yData,
|
||||||
|
height = 200,
|
||||||
|
}) => {
|
||||||
|
const option = {
|
||||||
|
title: {
|
||||||
|
text: title,
|
||||||
|
left: "center",
|
||||||
|
textStyle: { fontSize: 16 },
|
||||||
|
},
|
||||||
|
tooltip: { trigger: "axis" },
|
||||||
|
xAxis: {
|
||||||
|
type: "category",
|
||||||
|
data: xData,
|
||||||
|
boundaryGap: false,
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: "value",
|
||||||
|
boundaryGap: ["10%", "10%"], // 上下留白
|
||||||
|
min: (value: any) => value.min - 10, // 下方多留一点空间
|
||||||
|
max: (value: any) => value.max + 10, // 上方多留一点空间
|
||||||
|
minInterval: 1,
|
||||||
|
axisLabel: { margin: 12 },
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
...yData.map((item, index) => {
|
||||||
|
const color = getChartColor(index);
|
||||||
|
return {
|
||||||
|
data: item,
|
||||||
|
type: "line",
|
||||||
|
smooth: true,
|
||||||
|
symbol: "circle",
|
||||||
|
lineStyle: { color },
|
||||||
|
itemStyle: { color },
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
grid: { left: 40, right: 24, top: 40, bottom: 32 },
|
||||||
|
};
|
||||||
|
|
||||||
|
return <ReactECharts option={option} style={{ height, width: "100%" }} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LineChart;
|
||||||
57
Cunkebao/src/components/MeauMobile/MeauMoible.tsx
Normal file
57
Cunkebao/src/components/MeauMobile/MeauMoible.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { TabBar } from "antd-mobile";
|
||||||
|
import { PieOutline, UserOutline } from "antd-mobile-icons";
|
||||||
|
import { HomeOutlined, TeamOutlined } from "@ant-design/icons";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{
|
||||||
|
key: "home",
|
||||||
|
title: "首页",
|
||||||
|
icon: <HomeOutlined />,
|
||||||
|
path: "/",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "scenarios",
|
||||||
|
title: "场景获客",
|
||||||
|
icon: <TeamOutlined />,
|
||||||
|
path: "/scenarios",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "workspace",
|
||||||
|
title: "工作台",
|
||||||
|
icon: <PieOutline />,
|
||||||
|
path: "/workspace",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "mine",
|
||||||
|
title: "我的",
|
||||||
|
icon: <UserOutline />,
|
||||||
|
path: "/mine",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
interface MeauMobileProps {
|
||||||
|
activeKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MeauMobile: React.FC<MeauMobileProps> = ({ activeKey }) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TabBar
|
||||||
|
style={{ background: "#fff" }}
|
||||||
|
activeKey={activeKey}
|
||||||
|
onChange={key => {
|
||||||
|
const tab = tabs.find(t => t.key === key);
|
||||||
|
if (tab && tab.path) navigate(tab.path);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tabs.map(item => (
|
||||||
|
<TabBar.Item key={item.key} icon={item.icon} title={item.title} />
|
||||||
|
))}
|
||||||
|
</TabBar>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MeauMobile;
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
.twoColumnModal {
|
||||||
|
.ant-modal-body {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
height: 500px;
|
||||||
|
border: 1px solid #e8e8e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leftColumn {
|
||||||
|
flex: 1;
|
||||||
|
border-right: 1px solid #e8e8e8;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rightColumn {
|
||||||
|
width: 300px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchWrapper {
|
||||||
|
padding: 16px;
|
||||||
|
border-bottom: 1px solid #e8e8e8;
|
||||||
|
|
||||||
|
.ant-input {
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.memberList {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.memberItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
background-color: #e6f7ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-checkbox {
|
||||||
|
margin-right: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.memberInfo {
|
||||||
|
margin-left: 12px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.memberName {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.memberId {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectedHeader {
|
||||||
|
padding: 16px;
|
||||||
|
border-bottom: 1px solid #e8e8e8;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
background: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.singleTip {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectedList {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectedItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: #fff;
|
||||||
|
margin: 4px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #e8e8e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectedInfo {
|
||||||
|
margin-left: 8px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectedName {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.removeBtn {
|
||||||
|
color: #999;
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 0;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #ff4d4f;
|
||||||
|
background: #fff2f0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100px;
|
||||||
|
color: #999;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptySelected {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100px;
|
||||||
|
color: #999;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Modal, Input, Avatar, Button, Checkbox } from 'antd';
|
||||||
|
import { SearchOutlined } from '@ant-design/icons';
|
||||||
|
import styles from './TwoColumnMemberSelection.module.scss';
|
||||||
|
|
||||||
|
interface Member {
|
||||||
|
id: string;
|
||||||
|
nickname: string;
|
||||||
|
avatar: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TwoColumnMemberSelectionProps {
|
||||||
|
visible: boolean;
|
||||||
|
members: Member[];
|
||||||
|
onCancel: () => void;
|
||||||
|
onConfirm: (selectedIds: string[]) => void;
|
||||||
|
title?: string;
|
||||||
|
allowMultiple?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TwoColumnMemberSelection: React.FC<TwoColumnMemberSelectionProps> = ({
|
||||||
|
visible,
|
||||||
|
members,
|
||||||
|
onCancel,
|
||||||
|
onConfirm,
|
||||||
|
title = '选择成员',
|
||||||
|
allowMultiple = true,
|
||||||
|
}) => {
|
||||||
|
const [selectedMembers, setSelectedMembers] = useState<Member[]>([]);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
|
// 过滤成员
|
||||||
|
const filteredMembers = members.filter(member =>
|
||||||
|
member.nickname.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
member.id.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
// 处理搜索
|
||||||
|
const handleSearch = (value: string) => {
|
||||||
|
setSearchQuery(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 选择成员
|
||||||
|
const handleSelectMember = (member: Member) => {
|
||||||
|
const isSelected = selectedMembers.some(m => m.id === member.id);
|
||||||
|
|
||||||
|
if (allowMultiple) {
|
||||||
|
if (isSelected) {
|
||||||
|
setSelectedMembers(selectedMembers.filter(m => m.id !== member.id));
|
||||||
|
} else {
|
||||||
|
setSelectedMembers([...selectedMembers, member]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 单选模式
|
||||||
|
if (isSelected) {
|
||||||
|
setSelectedMembers([]);
|
||||||
|
} else {
|
||||||
|
setSelectedMembers([member]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 移除已选成员
|
||||||
|
const handleRemoveMember = (memberId: string) => {
|
||||||
|
setSelectedMembers(selectedMembers.filter(m => m.id !== memberId));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 确认选择
|
||||||
|
const handleConfirmSelection = () => {
|
||||||
|
const selectedIds = selectedMembers.map(m => m.id);
|
||||||
|
onConfirm(selectedIds);
|
||||||
|
setSelectedMembers([]);
|
||||||
|
setSearchQuery('');
|
||||||
|
};
|
||||||
|
|
||||||
|
// 取消选择
|
||||||
|
const handleCancelSelection = () => {
|
||||||
|
setSelectedMembers([]);
|
||||||
|
setSearchQuery('');
|
||||||
|
onCancel();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={title}
|
||||||
|
open={visible}
|
||||||
|
onCancel={handleCancelSelection}
|
||||||
|
width={800}
|
||||||
|
footer={[
|
||||||
|
<Button key="cancel" onClick={handleCancelSelection}>
|
||||||
|
取消
|
||||||
|
</Button>,
|
||||||
|
<Button
|
||||||
|
key="confirm"
|
||||||
|
type="primary"
|
||||||
|
onClick={handleConfirmSelection}
|
||||||
|
disabled={selectedMembers.length === 0}
|
||||||
|
>
|
||||||
|
确定
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
className={styles.twoColumnModal}
|
||||||
|
>
|
||||||
|
<div className={styles.container}>
|
||||||
|
{/* 左侧:成员列表 */}
|
||||||
|
<div className={styles.leftColumn}>
|
||||||
|
<div className={styles.searchWrapper}>
|
||||||
|
<Input
|
||||||
|
placeholder="请输入昵称或微信号"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => handleSearch(e.target.value)}
|
||||||
|
prefix={<SearchOutlined />}
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.memberList}>
|
||||||
|
{filteredMembers.length > 0 ? (
|
||||||
|
filteredMembers.map(member => {
|
||||||
|
const isSelected = selectedMembers.some(m => m.id === member.id);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={member.id}
|
||||||
|
className={`${styles.memberItem} ${isSelected ? styles.selected : ''}`}
|
||||||
|
onClick={() => handleSelectMember(member)}
|
||||||
|
>
|
||||||
|
<Checkbox checked={isSelected} />
|
||||||
|
<Avatar src={member.avatar} size={40}>
|
||||||
|
{member.nickname?.charAt(0)}
|
||||||
|
</Avatar>
|
||||||
|
<div className={styles.memberInfo}>
|
||||||
|
<div className={styles.memberName}>{member.nickname}</div>
|
||||||
|
<div className={styles.memberId}>{member.id}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<div className={styles.empty}>
|
||||||
|
{searchQuery ? `没有找到包含"${searchQuery}"的成员` : '暂无成员'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右侧:已选成员 */}
|
||||||
|
<div className={styles.rightColumn}>
|
||||||
|
<div className={styles.selectedHeader}>
|
||||||
|
已选成员 ({selectedMembers.length})
|
||||||
|
{!allowMultiple && <span className={styles.singleTip}>(单选)</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.selectedList}>
|
||||||
|
{selectedMembers.length > 0 ? (
|
||||||
|
selectedMembers.map(member => (
|
||||||
|
<div key={member.id} className={styles.selectedItem}>
|
||||||
|
<Avatar src={member.avatar} size={32}>
|
||||||
|
{member.nickname?.charAt(0)}
|
||||||
|
</Avatar>
|
||||||
|
<div className={styles.selectedInfo}>
|
||||||
|
<div className={styles.selectedName}>{member.nickname}</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
onClick={() => handleRemoveMember(member.id)}
|
||||||
|
className={styles.removeBtn}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className={styles.emptySelected}>
|
||||||
|
暂无选择
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TwoColumnMemberSelection;
|
||||||
51
Cunkebao/src/components/MemberSelection/index.tsx
Normal file
51
Cunkebao/src/components/MemberSelection/index.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Modal, Checkbox, Avatar, List, Button } from 'antd';
|
||||||
|
|
||||||
|
interface MemberSelectionProps {
|
||||||
|
visible: boolean;
|
||||||
|
members: { id: string; nickname: string; avatar: string }[];
|
||||||
|
onCancel: () => void;
|
||||||
|
onConfirm: (selectedIds: string[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MemberSelection: React.FC<MemberSelectionProps> = ({ visible, members, onCancel, onConfirm }) => {
|
||||||
|
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const handleToggle = (id: string) => {
|
||||||
|
const newSelectedIds = selectedIds.includes(id)
|
||||||
|
? selectedIds.filter(memberId => memberId !== id)
|
||||||
|
: [...selectedIds, id];
|
||||||
|
setSelectedIds(newSelectedIds);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
onConfirm(selectedIds);
|
||||||
|
setSelectedIds([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title="选择要删除的成员"
|
||||||
|
visible={visible}
|
||||||
|
onCancel={onCancel}
|
||||||
|
onOk={handleConfirm}
|
||||||
|
okText="删除"
|
||||||
|
cancelText="取消"
|
||||||
|
>
|
||||||
|
<List
|
||||||
|
dataSource={members}
|
||||||
|
renderItem={member => (
|
||||||
|
<List.Item key={member.id} onClick={() => handleToggle(member.id)} style={{ cursor: 'pointer' }}>
|
||||||
|
<List.Item.Meta
|
||||||
|
avatar={<Avatar src={member.avatar} />}
|
||||||
|
title={member.nickname}
|
||||||
|
/>
|
||||||
|
<Checkbox checked={selectedIds.includes(member.id)} />
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MemberSelection;
|
||||||
62
Cunkebao/src/components/NavCommon/index.tsx
Normal file
62
Cunkebao/src/components/NavCommon/index.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { NavBar } from "antd-mobile";
|
||||||
|
import { ArrowLeftOutlined } from "@ant-design/icons";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { getSafeAreaHeight } from "@/utils/common";
|
||||||
|
interface NavCommonProps {
|
||||||
|
title: string | React.ReactNode;
|
||||||
|
backFn?: () => void;
|
||||||
|
right?: React.ReactNode;
|
||||||
|
left?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NavCommon: React.FC<NavCommonProps> = ({
|
||||||
|
title,
|
||||||
|
backFn,
|
||||||
|
right,
|
||||||
|
left,
|
||||||
|
}) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [paddingTop, setPaddingTop] = useState("0px");
|
||||||
|
useEffect(() => {
|
||||||
|
setPaddingTop(getSafeAreaHeight() + "px");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
paddingTop: paddingTop,
|
||||||
|
background: "#fff",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<NavBar
|
||||||
|
back={null}
|
||||||
|
left={
|
||||||
|
left ? (
|
||||||
|
left
|
||||||
|
) : (
|
||||||
|
<div className="nav-title">
|
||||||
|
<ArrowLeftOutlined
|
||||||
|
twoToneColor="#1677ff"
|
||||||
|
onClick={() => {
|
||||||
|
if (backFn) {
|
||||||
|
backFn();
|
||||||
|
} else {
|
||||||
|
navigate(-1);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
right={right}
|
||||||
|
>
|
||||||
|
<span style={{ color: "var(--primary-color)", fontWeight: 600 }}>
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
</NavBar>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NavCommon;
|
||||||
52
Cunkebao/src/components/PlaceholderPage.tsx
Normal file
52
Cunkebao/src/components/PlaceholderPage.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { NavBar, Button } from "antd-mobile";
|
||||||
|
import { PlusOutlined } from "@ant-design/icons";
|
||||||
|
import Layout from "@/components/Layout/Layout";
|
||||||
|
|
||||||
|
interface PlaceholderPageProps {
|
||||||
|
title: string;
|
||||||
|
showBack?: boolean;
|
||||||
|
showAddButton?: boolean;
|
||||||
|
addButtonText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PlaceholderPage: React.FC<PlaceholderPageProps> = ({
|
||||||
|
title,
|
||||||
|
showBack = true,
|
||||||
|
showAddButton = false,
|
||||||
|
addButtonText = "新建",
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Layout
|
||||||
|
header={
|
||||||
|
<NavBar
|
||||||
|
back={showBack}
|
||||||
|
style={{ background: "#fff" }}
|
||||||
|
onBack={showBack ? () => window.history.back() : undefined}
|
||||||
|
left={
|
||||||
|
<div style={{ color: "var(--primary-color)", fontWeight: 600 }}>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
right={
|
||||||
|
showAddButton ? (
|
||||||
|
<Button size="small" color="primary">
|
||||||
|
<PlusOutlined />
|
||||||
|
<span style={{ marginLeft: 4, fontSize: 12 }}>
|
||||||
|
{addButtonText}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div style={{ padding: 20, textAlign: "center", color: "#666" }}>
|
||||||
|
<h3>{title}页面</h3>
|
||||||
|
<p>此页面正在开发中...</p>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PlaceholderPage;
|
||||||
34
Cunkebao/src/components/PoolSelection/api.ts
Normal file
34
Cunkebao/src/components/PoolSelection/api.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import request from "@/api/request";
|
||||||
|
|
||||||
|
// 请求参数接口
|
||||||
|
export interface Request {
|
||||||
|
keyword: string;
|
||||||
|
/**
|
||||||
|
* 条数
|
||||||
|
*/
|
||||||
|
limit: string;
|
||||||
|
/**
|
||||||
|
* 分页
|
||||||
|
*/
|
||||||
|
page: string;
|
||||||
|
[property: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取流量池包列表
|
||||||
|
export function getPoolPackages(params: Request) {
|
||||||
|
return request("/v1/traffic/pool/getPackage", params, "GET");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保留原接口以兼容现有代码
|
||||||
|
export function getPoolList(params: {
|
||||||
|
page?: string;
|
||||||
|
pageSize?: string;
|
||||||
|
keyword?: string;
|
||||||
|
addStatus?: string;
|
||||||
|
deviceId?: string;
|
||||||
|
packageId?: string;
|
||||||
|
userValue?: string;
|
||||||
|
[property: string]: any;
|
||||||
|
}) {
|
||||||
|
return request("/v1/traffic/pool", params, "GET");
|
||||||
|
}
|
||||||
61
Cunkebao/src/components/PoolSelection/data.ts
Normal file
61
Cunkebao/src/components/PoolSelection/data.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
// 流量池包接口类型
|
||||||
|
export interface PoolPackageItem {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
createTime: string;
|
||||||
|
num: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 原流量池接口类型(保留以兼容现有代码)
|
||||||
|
export interface PoolItem {
|
||||||
|
id: number;
|
||||||
|
identifier: string;
|
||||||
|
mobile: string;
|
||||||
|
wechatId: string;
|
||||||
|
fromd: string;
|
||||||
|
status: number;
|
||||||
|
createTime: string;
|
||||||
|
companyId: number;
|
||||||
|
sourceId: string;
|
||||||
|
type: number;
|
||||||
|
nickname: string;
|
||||||
|
avatar: string;
|
||||||
|
gender: number;
|
||||||
|
phone: string;
|
||||||
|
alias: string;
|
||||||
|
packages: any[];
|
||||||
|
tags: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PoolSelectionItem {
|
||||||
|
id: string;
|
||||||
|
avatar?: string;
|
||||||
|
name: string;
|
||||||
|
wechatId?: string;
|
||||||
|
mobile?: string;
|
||||||
|
nickname?: string;
|
||||||
|
createTime?: string;
|
||||||
|
description?: string;
|
||||||
|
num?: number;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 组件属性接口
|
||||||
|
export interface PoolSelectionProps {
|
||||||
|
selectedOptions: PoolSelectionItem[];
|
||||||
|
onSelect: (Pools: PoolSelectionItem[]) => void;
|
||||||
|
onSelectDetail?: (Pools: PoolPackageItem[]) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
className?: string;
|
||||||
|
visible?: boolean;
|
||||||
|
onVisibleChange?: (visible: boolean) => void;
|
||||||
|
selectedListMaxHeight?: number;
|
||||||
|
showInput?: boolean;
|
||||||
|
showSelectedList?: boolean;
|
||||||
|
readonly?: boolean;
|
||||||
|
onConfirm?: (
|
||||||
|
selectedIds: string[],
|
||||||
|
selectedItems: PoolSelectionItem[],
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
206
Cunkebao/src/components/PoolSelection/index.module.scss
Normal file
206
Cunkebao/src/components/PoolSelection/index.module.scss
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
.inputWrapper {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.inputIcon {
|
||||||
|
position: absolute;
|
||||||
|
left: 12px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: #bdbdbd;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
.input {
|
||||||
|
padding-left: 38px !important;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 16px !important;
|
||||||
|
border: 1px solid #e5e6eb !important;
|
||||||
|
font-size: 16px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
.selectedListRow {
|
||||||
|
padding: 8px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.selectedListRowContent {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.selectedListRowContentText {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popupContainer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.popupHeader {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
.popupTitle {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.searchWrapper {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.searchInput {
|
||||||
|
padding-left: 40px !important;
|
||||||
|
padding-top: 8px !important;
|
||||||
|
padding-bottom: 8px !important;
|
||||||
|
border-radius: 24px !important;
|
||||||
|
border: 1px solid #e5e6eb !important;
|
||||||
|
font-size: 15px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
.searchIcon {
|
||||||
|
position: absolute;
|
||||||
|
left: 12px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: #bdbdbd;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.clearBtn {
|
||||||
|
position: absolute;
|
||||||
|
right: 8px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
height: 24px;
|
||||||
|
width: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
min-width: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupList {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.groupListInner {
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
.groupItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 24px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
transition: background 0.2s;
|
||||||
|
&:hover {
|
||||||
|
background: #f5f6fa;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.groupInfo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.groupAvatar {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.avatarImg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.groupDetail {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.groupName {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #222;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.groupId {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #888;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.groupOwner {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #bdbdbd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loadingBox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.loadingText {
|
||||||
|
color: #888;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
.emptyBox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.emptyText {
|
||||||
|
color: #888;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paginationRow {
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.totalCount {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
.paginationControls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.pageBtn {
|
||||||
|
padding: 0 8px;
|
||||||
|
height: 32px;
|
||||||
|
min-width: 32px;
|
||||||
|
}
|
||||||
|
.pageInfo {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popupFooter {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.selectedCount {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
.footerBtnGroup {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
127
Cunkebao/src/components/PoolSelection/index.tsx
Normal file
127
Cunkebao/src/components/PoolSelection/index.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { SearchOutlined, DeleteOutlined } from "@ant-design/icons";
|
||||||
|
import { Button, Input } from "antd";
|
||||||
|
import style from "./index.module.scss";
|
||||||
|
import SelectionPopup from "./selectionPopup";
|
||||||
|
import { PoolSelectionProps } from "./data";
|
||||||
|
export default function PoolSelection({
|
||||||
|
selectedOptions,
|
||||||
|
onSelect,
|
||||||
|
onSelectDetail,
|
||||||
|
placeholder = "选择流量池",
|
||||||
|
className = "",
|
||||||
|
visible,
|
||||||
|
onVisibleChange,
|
||||||
|
selectedListMaxHeight = 300,
|
||||||
|
showInput = true,
|
||||||
|
showSelectedList = true,
|
||||||
|
readonly = false,
|
||||||
|
onConfirm,
|
||||||
|
}: PoolSelectionProps) {
|
||||||
|
const [popupVisible, setPopupVisible] = useState(false);
|
||||||
|
|
||||||
|
// 删除已选流量池项
|
||||||
|
const handleRemoveItem = (id: string) => {
|
||||||
|
if (readonly) return;
|
||||||
|
onSelect(selectedOptions.filter(item => item.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 受控弹窗逻辑
|
||||||
|
const realVisible = visible !== undefined ? visible : popupVisible;
|
||||||
|
const setRealVisible = (v: boolean) => {
|
||||||
|
if (onVisibleChange) onVisibleChange(v);
|
||||||
|
if (visible === undefined) setPopupVisible(v);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 打开弹窗
|
||||||
|
const openPopup = () => {
|
||||||
|
if (readonly) return;
|
||||||
|
setRealVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取显示文本
|
||||||
|
const getDisplayText = () => {
|
||||||
|
if (selectedOptions.length === 0) return "";
|
||||||
|
return `已选择 ${selectedOptions.length} 个流量池项`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* 输入框 */}
|
||||||
|
{showInput && (
|
||||||
|
<div className={`${style.inputWrapper} ${className}`}>
|
||||||
|
<Input
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={getDisplayText()}
|
||||||
|
onClick={openPopup}
|
||||||
|
prefix={<SearchOutlined />}
|
||||||
|
allowClear={!readonly}
|
||||||
|
size="large"
|
||||||
|
readOnly={readonly}
|
||||||
|
disabled={readonly}
|
||||||
|
style={
|
||||||
|
readonly ? { background: "#f5f5f5", cursor: "not-allowed" } : {}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* 已选流量池列表窗口 */}
|
||||||
|
{showSelectedList && selectedOptions.length > 0 && (
|
||||||
|
<div
|
||||||
|
className={style.selectedListWindow}
|
||||||
|
style={{
|
||||||
|
maxHeight: selectedListMaxHeight,
|
||||||
|
overflowY: "auto",
|
||||||
|
marginTop: 8,
|
||||||
|
border: "1px solid #e5e6eb",
|
||||||
|
borderRadius: 8,
|
||||||
|
background: "#fff",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selectedOptions.map(item => (
|
||||||
|
<div key={item.id} className={style.selectedListRow}>
|
||||||
|
<div className={style.selectedListRowContent}>
|
||||||
|
<div className={style.groupAvatar}>
|
||||||
|
{(item.nickname || item.name || "").charAt(0)}
|
||||||
|
</div>
|
||||||
|
<div className={style.selectedListRowContentText}>
|
||||||
|
<div>{item.nickname || item.name}</div>
|
||||||
|
<div>{item.wechatId || item.mobile}</div>
|
||||||
|
</div>
|
||||||
|
{!readonly && (
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
size="small"
|
||||||
|
style={{
|
||||||
|
marginLeft: 4,
|
||||||
|
color: "#ff4d4f",
|
||||||
|
border: "none",
|
||||||
|
background: "none",
|
||||||
|
minWidth: 24,
|
||||||
|
height: 24,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
onClick={() => handleRemoveItem(item.id)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* 弹窗 */}
|
||||||
|
<SelectionPopup
|
||||||
|
visible={realVisible}
|
||||||
|
onVisibleChange={setRealVisible}
|
||||||
|
selectedOptions={selectedOptions}
|
||||||
|
onSelect={onSelect}
|
||||||
|
onSelectDetail={onSelectDetail}
|
||||||
|
readonly={readonly}
|
||||||
|
onConfirm={onConfirm}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
258
Cunkebao/src/components/PoolSelection/selectionPopup.tsx
Normal file
258
Cunkebao/src/components/PoolSelection/selectionPopup.tsx
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { Popup, Checkbox } from "antd-mobile";
|
||||||
|
|
||||||
|
import { getPoolPackages, Request } from "./api";
|
||||||
|
import style from "./index.module.scss";
|
||||||
|
import Layout from "@/components/Layout/Layout";
|
||||||
|
import PopupHeader from "@/components/PopuLayout/header";
|
||||||
|
import PopupFooter from "@/components/PopuLayout/footer";
|
||||||
|
import { PoolSelectionItem, PoolPackageItem } from "./data";
|
||||||
|
|
||||||
|
// 弹窗属性接口
|
||||||
|
interface SelectionPopupProps {
|
||||||
|
visible: boolean;
|
||||||
|
onVisibleChange: (visible: boolean) => void;
|
||||||
|
selectedOptions: PoolSelectionItem[];
|
||||||
|
onSelect: (items: PoolSelectionItem[]) => void;
|
||||||
|
onSelectDetail?: (items: PoolPackageItem[]) => void;
|
||||||
|
readonly?: boolean;
|
||||||
|
onConfirm?: (
|
||||||
|
selectedIds: string[],
|
||||||
|
selectedItems: PoolSelectionItem[],
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SelectionPopup({
|
||||||
|
visible,
|
||||||
|
onVisibleChange,
|
||||||
|
selectedOptions,
|
||||||
|
onSelect,
|
||||||
|
onSelectDetail,
|
||||||
|
readonly = false,
|
||||||
|
onConfirm,
|
||||||
|
}: SelectionPopupProps) {
|
||||||
|
const [poolPackages, setPoolPackages] = useState<PoolPackageItem[]>([]);
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
|
const [totalItems, setTotalItems] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [tempSelectedOptions, setTempSelectedOptions] = useState<
|
||||||
|
PoolSelectionItem[]
|
||||||
|
>([]);
|
||||||
|
|
||||||
|
// 获取流量池包列表API
|
||||||
|
const fetchPoolPackages = async (page: number, keyword: string = "") => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const params: Request = {
|
||||||
|
page: String(page),
|
||||||
|
limit: "20",
|
||||||
|
keyword: keyword.trim(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await getPoolPackages(params);
|
||||||
|
if (response && response.list) {
|
||||||
|
setPoolPackages(response.list);
|
||||||
|
setTotalItems(response.total || 0);
|
||||||
|
setTotalPages(Math.ceil((response.total || 0) / 20));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("获取流量池包列表失败:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理流量池包选择
|
||||||
|
const handlePackageToggle = (item: PoolPackageItem) => {
|
||||||
|
if (readonly) return;
|
||||||
|
|
||||||
|
// 将PoolPackageItem转换为GroupSelectionItem格式
|
||||||
|
const selectionItem: PoolSelectionItem = {
|
||||||
|
id: String(item.id),
|
||||||
|
name: item.name,
|
||||||
|
description: item.description,
|
||||||
|
createTime: item.createTime,
|
||||||
|
num: item.num,
|
||||||
|
// 保留原始数据
|
||||||
|
originalData: item,
|
||||||
|
};
|
||||||
|
|
||||||
|
const newSelectedItems = tempSelectedOptions.some(
|
||||||
|
g => g.id === String(item.id),
|
||||||
|
)
|
||||||
|
? tempSelectedOptions.filter(g => g.id !== String(item.id))
|
||||||
|
: tempSelectedOptions.concat(selectionItem);
|
||||||
|
|
||||||
|
setTempSelectedOptions(newSelectedItems);
|
||||||
|
|
||||||
|
// 如果有 onSelectDetail 回调,传递完整的流量池包对象
|
||||||
|
if (onSelectDetail) {
|
||||||
|
const selectedItemObjs = poolPackages.filter(packageItem =>
|
||||||
|
newSelectedItems.some(g => g.id === String(packageItem.id)),
|
||||||
|
);
|
||||||
|
onSelectDetail(selectedItemObjs);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 全选当前页
|
||||||
|
const handleSelectAllCurrentPage = (checked: boolean) => {
|
||||||
|
if (readonly) return;
|
||||||
|
|
||||||
|
if (checked) {
|
||||||
|
// 全选:添加当前页面所有未选中的流量池包
|
||||||
|
const currentPagePackages = poolPackages.filter(
|
||||||
|
packageItem =>
|
||||||
|
!tempSelectedOptions.some(p => p.id === String(packageItem.id)),
|
||||||
|
);
|
||||||
|
const newSelectionItems = currentPagePackages.map(item => ({
|
||||||
|
id: String(item.id),
|
||||||
|
name: item.name,
|
||||||
|
description: item.description,
|
||||||
|
createTime: item.createTime,
|
||||||
|
num: item.num,
|
||||||
|
originalData: item,
|
||||||
|
}));
|
||||||
|
setTempSelectedOptions(prev => [...prev, ...newSelectionItems]);
|
||||||
|
} else {
|
||||||
|
// 取消全选:移除当前页面的所有流量池包
|
||||||
|
const currentPagePackageIds = poolPackages.map(p => String(p.id));
|
||||||
|
setTempSelectedOptions(prev =>
|
||||||
|
prev.filter(p => !currentPagePackageIds.includes(p.id)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 检查当前页是否全选
|
||||||
|
const isCurrentPageAllSelected =
|
||||||
|
poolPackages.length > 0 &&
|
||||||
|
poolPackages.every(packageItem =>
|
||||||
|
tempSelectedOptions.some(p => p.id === String(packageItem.id)),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 确认选择
|
||||||
|
const handleConfirm = () => {
|
||||||
|
if (onConfirm) {
|
||||||
|
onConfirm(
|
||||||
|
tempSelectedOptions.map(item => item.id),
|
||||||
|
tempSelectedOptions,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 更新实际选中的选项
|
||||||
|
onSelect(tempSelectedOptions);
|
||||||
|
onVisibleChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 弹窗打开时初始化数据(只执行一次)
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
setCurrentPage(1);
|
||||||
|
setSearchQuery("");
|
||||||
|
// 复制一份selectedOptions到临时变量
|
||||||
|
setTempSelectedOptions([...selectedOptions]);
|
||||||
|
fetchPoolPackages(1, "");
|
||||||
|
}
|
||||||
|
}, [visible, selectedOptions]);
|
||||||
|
|
||||||
|
// 搜索防抖(只在弹窗打开且搜索词变化时执行)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible || searchQuery === "") return;
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setCurrentPage(1);
|
||||||
|
fetchPoolPackages(1, searchQuery);
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [searchQuery, visible]);
|
||||||
|
|
||||||
|
// 页码变化时请求数据(只在弹窗打开且页码不是1时执行)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) return;
|
||||||
|
fetchPoolPackages(currentPage, searchQuery);
|
||||||
|
}, [currentPage, visible, searchQuery]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popup
|
||||||
|
visible={visible}
|
||||||
|
onMaskClick={() => onVisibleChange(false)}
|
||||||
|
position="bottom"
|
||||||
|
bodyStyle={{ height: "100vh" }}
|
||||||
|
>
|
||||||
|
<Layout
|
||||||
|
header={
|
||||||
|
<PopupHeader
|
||||||
|
title="选择流量池包"
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
setSearchQuery={setSearchQuery}
|
||||||
|
searchPlaceholder="搜索流量池包"
|
||||||
|
loading={loading}
|
||||||
|
onRefresh={() => fetchPoolPackages(currentPage, searchQuery)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
footer={
|
||||||
|
<PopupFooter
|
||||||
|
currentPage={currentPage}
|
||||||
|
totalPages={totalPages}
|
||||||
|
loading={loading}
|
||||||
|
selectedCount={tempSelectedOptions.length}
|
||||||
|
onPageChange={setCurrentPage}
|
||||||
|
onCancel={() => onVisibleChange(false)}
|
||||||
|
onConfirm={handleConfirm}
|
||||||
|
isAllSelected={isCurrentPageAllSelected}
|
||||||
|
onSelectAll={handleSelectAllCurrentPage}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={style.groupList}>
|
||||||
|
{loading ? (
|
||||||
|
<div className={style.loadingBox}>
|
||||||
|
<div className={style.loadingText}>加载中...</div>
|
||||||
|
</div>
|
||||||
|
) : poolPackages.length > 0 ? (
|
||||||
|
<div className={style.groupListInner}>
|
||||||
|
{poolPackages.map(item => (
|
||||||
|
<div key={item.id} className={style.groupItem}>
|
||||||
|
<Checkbox
|
||||||
|
checked={tempSelectedOptions.some(
|
||||||
|
g => g.id === String(item.id),
|
||||||
|
)}
|
||||||
|
onChange={() => !readonly && handlePackageToggle(item)}
|
||||||
|
disabled={readonly}
|
||||||
|
style={{ marginRight: 12 }}
|
||||||
|
/>
|
||||||
|
<div className={style.groupInfo}>
|
||||||
|
<div className={style.groupAvatar}>
|
||||||
|
{item.name ? item.name.charAt(0) : "?"}
|
||||||
|
</div>
|
||||||
|
<div className={style.groupDetail}>
|
||||||
|
<div className={style.groupName}>{item.name}</div>
|
||||||
|
<div className={style.groupId}>
|
||||||
|
描述: {item.description || "无描述"}
|
||||||
|
</div>
|
||||||
|
<div className={style.groupOwner}>
|
||||||
|
创建时间: {item.createTime}
|
||||||
|
</div>
|
||||||
|
<div className={style.groupOwner}>
|
||||||
|
包含数量: {item.num}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={style.emptyBox}>
|
||||||
|
<div className={style.emptyText}>
|
||||||
|
{searchQuery
|
||||||
|
? `没有找到包含"${searchQuery}"的流量池包`
|
||||||
|
: "没有找到流量池包"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
</Popup>
|
||||||
|
);
|
||||||
|
}
|
||||||
88
Cunkebao/src/components/PopuLayout/footer.module.scss
Normal file
88
Cunkebao/src/components/PopuLayout/footer.module.scss
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
.popupFooter {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectedCount {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectAllCheckbox {
|
||||||
|
margin-right: 0;
|
||||||
|
|
||||||
|
.ant-checkbox-wrapper {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.ant-checkbox-wrapper-disabled {
|
||||||
|
.ant-checkbox-disabled + span {
|
||||||
|
color: #d9d9d9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.footerBtnGroup {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paginationRow {
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totalCount {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paginationControls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageBtn {
|
||||||
|
padding: 0 8px;
|
||||||
|
height: 32px;
|
||||||
|
min-width: 32px;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
color: #333;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
border-color: #1677ff;
|
||||||
|
color: #1677ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
background: #f5f5f5;
|
||||||
|
color: #ccc;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageInfo {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #222;
|
||||||
|
margin: 0 8px;
|
||||||
|
min-width: 60px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
88
Cunkebao/src/components/PopuLayout/footer.tsx
Normal file
88
Cunkebao/src/components/PopuLayout/footer.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Button, Checkbox } from "antd";
|
||||||
|
import style from "./footer.module.scss";
|
||||||
|
import { ArrowLeftOutlined, ArrowRightOutlined } from "@ant-design/icons";
|
||||||
|
|
||||||
|
interface PopupFooterProps {
|
||||||
|
currentPage: number;
|
||||||
|
totalPages: number;
|
||||||
|
loading: boolean;
|
||||||
|
selectedCount: number;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
// 全选功能相关
|
||||||
|
isAllSelected?: boolean;
|
||||||
|
onSelectAll?: (checked: boolean) => void;
|
||||||
|
singleSelect?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PopupFooter: React.FC<PopupFooterProps> = ({
|
||||||
|
currentPage,
|
||||||
|
totalPages,
|
||||||
|
loading,
|
||||||
|
selectedCount,
|
||||||
|
onPageChange,
|
||||||
|
onCancel,
|
||||||
|
onConfirm,
|
||||||
|
isAllSelected = false,
|
||||||
|
onSelectAll,
|
||||||
|
singleSelect = false,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* 分页栏 */}
|
||||||
|
<div className={style.paginationRow}>
|
||||||
|
{onSelectAll && (
|
||||||
|
<div className={style.totalCount}>
|
||||||
|
<Checkbox
|
||||||
|
checked={isAllSelected}
|
||||||
|
onChange={e => onSelectAll(e.target.checked)}
|
||||||
|
className={style.selectAllCheckbox}
|
||||||
|
>
|
||||||
|
全选当前页
|
||||||
|
</Checkbox>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={style.paginationControls}>
|
||||||
|
<Button
|
||||||
|
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
|
||||||
|
disabled={currentPage === 1 || loading}
|
||||||
|
className={style.pageBtn}
|
||||||
|
>
|
||||||
|
<ArrowLeftOutlined />
|
||||||
|
</Button>
|
||||||
|
<span className={style.pageInfo}>
|
||||||
|
{currentPage} / {totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
|
||||||
|
disabled={currentPage === totalPages || loading}
|
||||||
|
className={style.pageBtn}
|
||||||
|
>
|
||||||
|
<ArrowRightOutlined />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={style.popupFooter}>
|
||||||
|
<div className={style.selectedCount}>
|
||||||
|
{singleSelect
|
||||||
|
? selectedCount > 0
|
||||||
|
? "已选择设备"
|
||||||
|
: "未选择设备"
|
||||||
|
: `已选择 ${selectedCount} 条记录`}
|
||||||
|
</div>
|
||||||
|
<div className={style.footerBtnGroup}>
|
||||||
|
<Button color="primary" variant="filled" onClick={onCancel}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button type="primary" onClick={onConfirm}>
|
||||||
|
确定
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PopupFooter;
|
||||||
51
Cunkebao/src/components/PopuLayout/header.module.scss
Normal file
51
Cunkebao/src/components/PopuLayout/header.module.scss
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
.popupHeader {
|
||||||
|
padding: 16px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popupTitle {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popupSearchRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popupSearchInputWrap {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inputIcon {
|
||||||
|
position: absolute;
|
||||||
|
left: 12px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: #bdbdbd;
|
||||||
|
z-index: 10;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refreshBtn {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loadingIcon {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
89
Cunkebao/src/components/PopuLayout/header.tsx
Normal file
89
Cunkebao/src/components/PopuLayout/header.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { SearchOutlined, ReloadOutlined } from "@ant-design/icons";
|
||||||
|
import { Input, Button } from "antd";
|
||||||
|
import { Tabs } from "antd-mobile";
|
||||||
|
import style from "./header.module.scss";
|
||||||
|
|
||||||
|
interface PopupHeaderProps {
|
||||||
|
title: string;
|
||||||
|
searchQuery: string;
|
||||||
|
setSearchQuery: (value: string) => void;
|
||||||
|
searchPlaceholder?: string;
|
||||||
|
loading?: boolean;
|
||||||
|
onRefresh?: () => void;
|
||||||
|
onSearch?: (query: string) => void;
|
||||||
|
showRefresh?: boolean;
|
||||||
|
showSearch?: boolean;
|
||||||
|
showTabs?: boolean;
|
||||||
|
tabsConfig?: {
|
||||||
|
activeKey: string;
|
||||||
|
onChange: (key: string) => void;
|
||||||
|
tabs: Array<{ title: string; key: string }>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const PopupHeader: React.FC<PopupHeaderProps> = ({
|
||||||
|
title,
|
||||||
|
searchQuery,
|
||||||
|
setSearchQuery,
|
||||||
|
searchPlaceholder = "搜索...",
|
||||||
|
loading = false,
|
||||||
|
onRefresh,
|
||||||
|
onSearch,
|
||||||
|
showRefresh = true,
|
||||||
|
showSearch = true,
|
||||||
|
showTabs = false,
|
||||||
|
tabsConfig,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={style.popupHeader}>
|
||||||
|
<div className={style.popupTitle}>{title}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showSearch && (
|
||||||
|
<div className={style.popupSearchRow}>
|
||||||
|
<div className={style.popupSearchInputWrap}>
|
||||||
|
<Input.Search
|
||||||
|
placeholder={searchPlaceholder}
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={e => setSearchQuery(e.target.value)}
|
||||||
|
onSearch={() => onSearch && onSearch(searchQuery)}
|
||||||
|
prefix={<SearchOutlined />}
|
||||||
|
size="large"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showRefresh && onRefresh && (
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
onClick={onRefresh}
|
||||||
|
disabled={loading}
|
||||||
|
className={style.refreshBtn}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className={style.loadingIcon}>⟳</div>
|
||||||
|
) : (
|
||||||
|
<ReloadOutlined />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showTabs && tabsConfig && (
|
||||||
|
<Tabs
|
||||||
|
activeKey={tabsConfig.activeKey}
|
||||||
|
onChange={tabsConfig.onChange}
|
||||||
|
style={{ marginTop: 8 }}
|
||||||
|
>
|
||||||
|
{tabsConfig.tabs.map(tab => (
|
||||||
|
<Tabs.Tab key={tab.key} title={tab.title} />
|
||||||
|
))}
|
||||||
|
</Tabs>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PopupHeader;
|
||||||
43
Cunkebao/src/components/StepIndicator/index.tsx
Normal file
43
Cunkebao/src/components/StepIndicator/index.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Steps } from "antd-mobile";
|
||||||
|
|
||||||
|
interface StepIndicatorProps {
|
||||||
|
currentStep: number;
|
||||||
|
steps: { id: number; title: string; subtitle: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const StepIndicator: React.FC<StepIndicatorProps> = ({
|
||||||
|
currentStep,
|
||||||
|
steps,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div style={{ overflowX: "auto", padding: "30px 0px", background: "#fff" }}>
|
||||||
|
<Steps current={currentStep - 1}>
|
||||||
|
{steps.map((step, idx) => (
|
||||||
|
<Steps.Step
|
||||||
|
key={step.id}
|
||||||
|
title={step.subtitle}
|
||||||
|
icon={
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: idx < currentStep ? "#1677ff" : "#cccccc",
|
||||||
|
color: "#fff",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{step.id}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Steps>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StepIndicator;
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
.twoColumnModal {
|
||||||
|
.ant-modal-body {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
height: 500px;
|
||||||
|
border: 1px solid #e8e8e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leftColumn {
|
||||||
|
flex: 1;
|
||||||
|
border-right: 1px solid #e8e8e8;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rightColumn {
|
||||||
|
width: 300px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchWrapper {
|
||||||
|
padding: 16px;
|
||||||
|
border-bottom: 1px solid #e8e8e8;
|
||||||
|
|
||||||
|
.ant-input {
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.friendList {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friendItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
background-color: #e6f7ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-checkbox {
|
||||||
|
margin-right: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.friendInfo {
|
||||||
|
margin-left: 12px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friendName {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friendId {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectedHeader {
|
||||||
|
padding: 16px;
|
||||||
|
border-bottom: 1px solid #e8e8e8;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectedList {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectedItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: #fff;
|
||||||
|
margin: 4px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #e8e8e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectedInfo {
|
||||||
|
margin-left: 8px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectedName {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.removeBtn {
|
||||||
|
color: #999;
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 0;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #ff4d4f;
|
||||||
|
background: #fff2f0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100px;
|
||||||
|
color: #999;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptySelected {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100px;
|
||||||
|
color: #999;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
import React, { useState, useCallback, useEffect, useMemo, memo } from "react";
|
||||||
|
import { Modal, Input, Avatar, Button, Checkbox, message } from "antd";
|
||||||
|
import { SearchOutlined } from "@ant-design/icons";
|
||||||
|
import { getFriendList } from "../FriendSelection/api";
|
||||||
|
import type { FriendSelectionItem } from "../FriendSelection/data";
|
||||||
|
import styles from "./TwoColumnSelection.module.scss";
|
||||||
|
|
||||||
|
// 使用 React.memo 优化好友列表项组件
|
||||||
|
const FriendListItem = memo<{
|
||||||
|
friend: FriendSelectionItem;
|
||||||
|
isSelected: boolean;
|
||||||
|
onSelect: (friend: FriendSelectionItem) => void;
|
||||||
|
}>(({ friend, isSelected, onSelect }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${styles.friendItem} ${isSelected ? styles.selected : ""}`}
|
||||||
|
onClick={() => onSelect(friend)}
|
||||||
|
>
|
||||||
|
<Checkbox checked={isSelected} />
|
||||||
|
<Avatar src={friend.avatar} size={40}>
|
||||||
|
{friend.nickname?.charAt(0)}
|
||||||
|
</Avatar>
|
||||||
|
<div className={styles.friendInfo}>
|
||||||
|
<div className={styles.friendName}>{friend.nickname}</div>
|
||||||
|
<div className={styles.friendId}>{friend.wechatId}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
FriendListItem.displayName = "FriendListItem";
|
||||||
|
|
||||||
|
interface TwoColumnSelectionProps {
|
||||||
|
visible: boolean;
|
||||||
|
onCancel: () => void;
|
||||||
|
onConfirm: (
|
||||||
|
selectedIds: string[],
|
||||||
|
selectedItems: FriendSelectionItem[],
|
||||||
|
) => void;
|
||||||
|
title?: string;
|
||||||
|
deviceIds?: number[];
|
||||||
|
enableDeviceFilter?: boolean;
|
||||||
|
dataSource?: FriendSelectionItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const TwoColumnSelection: React.FC<TwoColumnSelectionProps> = ({
|
||||||
|
visible,
|
||||||
|
onCancel,
|
||||||
|
onConfirm,
|
||||||
|
title = "选择好友",
|
||||||
|
deviceIds = [],
|
||||||
|
enableDeviceFilter = true,
|
||||||
|
dataSource,
|
||||||
|
}) => {
|
||||||
|
const [rawFriends, setRawFriends] = useState<FriendSelectionItem[]>([]);
|
||||||
|
const [selectedFriends, setSelectedFriends] = useState<FriendSelectionItem[]>(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
|
|
||||||
|
// 使用 useMemo 缓存过滤结果,避免每次渲染都重新计算
|
||||||
|
const filteredFriends = useMemo(() => {
|
||||||
|
const sourceData = dataSource || rawFriends;
|
||||||
|
if (!searchQuery.trim()) {
|
||||||
|
return sourceData;
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
return sourceData.filter(
|
||||||
|
item =>
|
||||||
|
item.name?.toLowerCase().includes(query) ||
|
||||||
|
item.nickname?.toLowerCase().includes(query),
|
||||||
|
);
|
||||||
|
}, [dataSource, rawFriends, searchQuery]);
|
||||||
|
|
||||||
|
// 分页显示好友列表,避免一次性渲染太多项目
|
||||||
|
const ITEMS_PER_PAGE = 50;
|
||||||
|
const [displayPage, setDisplayPage] = useState(1);
|
||||||
|
|
||||||
|
const friends = useMemo(() => {
|
||||||
|
const startIndex = 0;
|
||||||
|
const endIndex = displayPage * ITEMS_PER_PAGE;
|
||||||
|
return filteredFriends.slice(startIndex, endIndex);
|
||||||
|
}, [filteredFriends, displayPage]);
|
||||||
|
|
||||||
|
const hasMoreFriends = filteredFriends.length > friends.length;
|
||||||
|
|
||||||
|
// 使用 useMemo 缓存选中状态映射,避免每次渲染都重新计算
|
||||||
|
const selectedFriendsMap = useMemo(() => {
|
||||||
|
const map = new Map();
|
||||||
|
selectedFriends.forEach(friend => {
|
||||||
|
map.set(friend.id, true);
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [selectedFriends]);
|
||||||
|
|
||||||
|
// 获取好友列表
|
||||||
|
const fetchFriends = useCallback(
|
||||||
|
async (page: number, keyword: string = "") => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const params: any = {
|
||||||
|
page,
|
||||||
|
pageSize: 20,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (keyword) {
|
||||||
|
params.keyword = keyword;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enableDeviceFilter && deviceIds.length > 0) {
|
||||||
|
params.deviceIds = deviceIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await getFriendList(params);
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
setRawFriends(response.data.list || []);
|
||||||
|
setTotalPages(Math.ceil((response.data.total || 0) / 20));
|
||||||
|
} else {
|
||||||
|
setRawFriends([]);
|
||||||
|
message.error(response.message || "获取好友列表失败");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("获取好友列表失败:", error);
|
||||||
|
message.error("获取好友列表失败");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[deviceIds, enableDeviceFilter],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 初始化数据加载
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible && !dataSource) {
|
||||||
|
// 只有在没有外部数据源时才调用 API
|
||||||
|
fetchFriends(1);
|
||||||
|
setCurrentPage(1);
|
||||||
|
}
|
||||||
|
}, [visible, dataSource, fetchFriends]);
|
||||||
|
|
||||||
|
// 重置搜索状态
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
setSearchQuery("");
|
||||||
|
setSelectedFriends([]);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
// 防抖搜索处理
|
||||||
|
const handleSearch = useCallback(() => {
|
||||||
|
let timeoutId: NodeJS.Timeout;
|
||||||
|
return (value: string) => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
setDisplayPage(1); // 重置分页
|
||||||
|
if (!dataSource) {
|
||||||
|
fetchFriends(1, value);
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
};
|
||||||
|
}, [dataSource, fetchFriends])();
|
||||||
|
|
||||||
|
// API搜索处理(当没有外部数据源时)
|
||||||
|
const handleApiSearch = useCallback(
|
||||||
|
async (keyword: string) => {
|
||||||
|
if (!dataSource) {
|
||||||
|
await fetchFriends(1, keyword);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[dataSource, fetchFriends],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 加载更多好友
|
||||||
|
const handleLoadMore = useCallback(() => {
|
||||||
|
setDisplayPage(prev => prev + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 防抖搜索
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dataSource && searchQuery.trim()) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
handleApiSearch(searchQuery);
|
||||||
|
}, 300);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [searchQuery, dataSource, handleApiSearch]);
|
||||||
|
|
||||||
|
// 选择好友 - 使用 useCallback 优化性能
|
||||||
|
const handleSelectFriend = useCallback((friend: FriendSelectionItem) => {
|
||||||
|
setSelectedFriends(prev => {
|
||||||
|
const isSelected = prev.some(f => f.id === friend.id);
|
||||||
|
if (isSelected) {
|
||||||
|
return prev.filter(f => f.id !== friend.id);
|
||||||
|
} else {
|
||||||
|
return [...prev, friend];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 移除已选好友 - 使用 useCallback 优化性能
|
||||||
|
const handleRemoveFriend = useCallback((friendId: number) => {
|
||||||
|
setSelectedFriends(prev => prev.filter(f => f.id !== friendId));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 确认选择 - 使用 useCallback 优化性能
|
||||||
|
const handleConfirmSelection = useCallback(() => {
|
||||||
|
const selectedIds = selectedFriends.map(f => f.id.toString());
|
||||||
|
onConfirm(selectedIds, selectedFriends);
|
||||||
|
setSelectedFriends([]);
|
||||||
|
setSearchQuery("");
|
||||||
|
}, [selectedFriends, onConfirm]);
|
||||||
|
|
||||||
|
// 取消选择 - 使用 useCallback 优化性能
|
||||||
|
const handleCancel = useCallback(() => {
|
||||||
|
setSelectedFriends([]);
|
||||||
|
setSearchQuery("");
|
||||||
|
onCancel();
|
||||||
|
}, [onCancel]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={title}
|
||||||
|
open={visible}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
width={800}
|
||||||
|
footer={[
|
||||||
|
<Button key="cancel" onClick={handleCancel}>
|
||||||
|
取消
|
||||||
|
</Button>,
|
||||||
|
<Button key="confirm" type="primary" onClick={handleConfirmSelection}>
|
||||||
|
确定
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
className={styles.twoColumnModal}
|
||||||
|
>
|
||||||
|
<div className={styles.container}>
|
||||||
|
{/* 左侧:好友列表 */}
|
||||||
|
<div className={styles.leftColumn}>
|
||||||
|
<div className={styles.searchWrapper}>
|
||||||
|
<Input
|
||||||
|
placeholder="请输入昵称或微信号"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={e => {
|
||||||
|
const value = e.target.value;
|
||||||
|
setSearchQuery(value); // 立即更新显示
|
||||||
|
handleSearch(value); // 防抖处理搜索
|
||||||
|
}}
|
||||||
|
prefix={<SearchOutlined />}
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.friendList}>
|
||||||
|
{loading ? (
|
||||||
|
<div className={styles.loading}>加载中...</div>
|
||||||
|
) : friends.length > 0 ? (
|
||||||
|
// 使用 React.memo 优化列表项渲染
|
||||||
|
friends.map(friend => {
|
||||||
|
const isSelected = selectedFriendsMap.has(friend.id);
|
||||||
|
return (
|
||||||
|
<FriendListItem
|
||||||
|
key={friend.id}
|
||||||
|
friend={friend}
|
||||||
|
isSelected={isSelected}
|
||||||
|
onSelect={handleSelectFriend}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<div className={styles.empty}>
|
||||||
|
{searchQuery
|
||||||
|
? `没有找到包含"${searchQuery}"的好友`
|
||||||
|
: "暂无好友"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasMoreFriends && (
|
||||||
|
<div className={styles.loadMoreWrapper}>
|
||||||
|
<Button type="link" onClick={handleLoadMore} loading={loading}>
|
||||||
|
加载更多
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右侧:已选好友 */}
|
||||||
|
<div className={styles.rightColumn}>
|
||||||
|
<div className={styles.selectedHeader}>
|
||||||
|
已选联系人 ({selectedFriends.length})
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.selectedList}>
|
||||||
|
{selectedFriends.length > 0 ? (
|
||||||
|
selectedFriends.map(friend => (
|
||||||
|
<div key={friend.id} className={styles.selectedItem}>
|
||||||
|
<Avatar src={friend.avatar} size={32}>
|
||||||
|
{friend.nickname?.charAt(0)}
|
||||||
|
</Avatar>
|
||||||
|
<div className={styles.selectedInfo}>
|
||||||
|
<div className={styles.selectedName}>{friend.nickname}</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
onClick={() => handleRemoveFriend(friend.id)}
|
||||||
|
className={styles.removeBtn}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className={styles.emptySelected}>暂无选择</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TwoColumnSelection;
|
||||||
217
Cunkebao/src/components/UpdateNotification/index.tsx
Normal file
217
Cunkebao/src/components/UpdateNotification/index.tsx
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { Button } from "antd-mobile";
|
||||||
|
import { updateChecker } from "@/utils/updateChecker";
|
||||||
|
import { ReloadOutlined } from "@ant-design/icons";
|
||||||
|
|
||||||
|
interface UpdateNotificationProps {
|
||||||
|
position?: "top" | "bottom";
|
||||||
|
autoReload?: boolean;
|
||||||
|
showToast?: boolean;
|
||||||
|
forceShow?: boolean;
|
||||||
|
onClose?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UpdateNotification: React.FC<UpdateNotificationProps> = ({
|
||||||
|
position = "top",
|
||||||
|
autoReload = false,
|
||||||
|
showToast = true,
|
||||||
|
forceShow = false,
|
||||||
|
onClose,
|
||||||
|
}) => {
|
||||||
|
const [hasUpdate, setHasUpdate] = useState(false);
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 注册更新检测回调
|
||||||
|
const handleUpdate = (info: { hasUpdate: boolean }) => {
|
||||||
|
if (info.hasUpdate) {
|
||||||
|
setHasUpdate(true);
|
||||||
|
setIsVisible(true);
|
||||||
|
|
||||||
|
if (autoReload) {
|
||||||
|
// 自动刷新
|
||||||
|
setTimeout(() => {
|
||||||
|
updateChecker.forceReload();
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
updateChecker.onUpdate(handleUpdate);
|
||||||
|
|
||||||
|
// 启动更新检测
|
||||||
|
updateChecker.start();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
updateChecker.offUpdate(handleUpdate);
|
||||||
|
updateChecker.stop();
|
||||||
|
};
|
||||||
|
}, [autoReload, showToast]);
|
||||||
|
const handleReload = () => {
|
||||||
|
updateChecker.forceReload();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLater = () => {
|
||||||
|
setIsVisible(false);
|
||||||
|
onClose?.();
|
||||||
|
// 10分钟后再次检查
|
||||||
|
setTimeout(
|
||||||
|
() => {
|
||||||
|
updateChecker.start();
|
||||||
|
},
|
||||||
|
10 * 60 * 1000,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if ((!isVisible || !hasUpdate) && !forceShow) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
zIndex: 99999,
|
||||||
|
background: "linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%)",
|
||||||
|
color: "white",
|
||||||
|
padding: "16px 16px",
|
||||||
|
paddingTop: "calc(16px + env(safe-area-inset-top))",
|
||||||
|
boxShadow: "0 4px 20px rgba(0, 0, 0, 0.5)",
|
||||||
|
borderBottom: "1px solid rgba(255, 255, 255, 0.1)",
|
||||||
|
animation: "slideDownBar 0.3s ease-out",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
maxWidth: "1200px",
|
||||||
|
margin: "0 auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 左侧内容 */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "16px",
|
||||||
|
flex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 更新图标 */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "36px",
|
||||||
|
height: "36px",
|
||||||
|
background: "linear-gradient(135deg, #188eee 0%, #188eee 100%)",
|
||||||
|
borderRadius: "8px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
fontSize: "18px",
|
||||||
|
animation: "pulse 2s ease-in-out infinite",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ReloadOutlined />
|
||||||
|
</div>
|
||||||
|
{/* 文本信息 */}
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "14px",
|
||||||
|
fontWeight: "600",
|
||||||
|
marginBottom: "2px",
|
||||||
|
lineHeight: "1.2",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
发现新版本
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "12px",
|
||||||
|
opacity: 0.8,
|
||||||
|
lineHeight: "1.3",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
建议立即更新获得更好体验
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右侧按钮组 */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "12px",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
style={{
|
||||||
|
background: "rgba(255, 255, 255, 0.1)",
|
||||||
|
border: "1px solid rgba(255, 255, 255, 0.2)",
|
||||||
|
color: "rgba(255, 255, 255, 0.8)",
|
||||||
|
fontSize: "12px",
|
||||||
|
fontWeight: "500",
|
||||||
|
borderRadius: "6px",
|
||||||
|
height: "32px",
|
||||||
|
minHeight: "32px",
|
||||||
|
padding: "0 12px",
|
||||||
|
minWidth: "56px",
|
||||||
|
}}
|
||||||
|
onClick={handleLater}
|
||||||
|
>
|
||||||
|
稍后
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
style={{
|
||||||
|
background: "linear-gradient(135deg, #1890ff 0%, #096dd9 100%)",
|
||||||
|
border: "none",
|
||||||
|
color: "white",
|
||||||
|
fontSize: "12px",
|
||||||
|
fontWeight: "600",
|
||||||
|
borderRadius: "6px",
|
||||||
|
height: "32px",
|
||||||
|
minHeight: "32px",
|
||||||
|
padding: "0 16px",
|
||||||
|
minWidth: "64px",
|
||||||
|
boxShadow: "0 2px 8px rgba(24, 144, 255, 0.3)",
|
||||||
|
}}
|
||||||
|
onClick={handleReload}
|
||||||
|
>
|
||||||
|
立即更新
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 动画样式 */}
|
||||||
|
<style>
|
||||||
|
{`
|
||||||
|
@keyframes slideDownBar {
|
||||||
|
0% {
|
||||||
|
transform: translateY(-100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { transform: scale(1); }
|
||||||
|
50% { transform: scale(1.05); }
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UpdateNotification;
|
||||||
411
Cunkebao/src/components/Upload/AudioRecorder/index.tsx
Normal file
411
Cunkebao/src/components/Upload/AudioRecorder/index.tsx
Normal file
@@ -0,0 +1,411 @@
|
|||||||
|
import React, { useState, useRef, useCallback } from "react";
|
||||||
|
import { Button, message, Modal } from "antd";
|
||||||
|
import {
|
||||||
|
AudioOutlined,
|
||||||
|
PlayCircleOutlined,
|
||||||
|
PauseCircleOutlined,
|
||||||
|
SendOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
|
import { uploadFile } from "@/api/common";
|
||||||
|
|
||||||
|
interface AudioRecorderProps {
|
||||||
|
onAudioUploaded: (audioData: { url: string; durationMs: number }) => void;
|
||||||
|
className?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
maxDuration?: number; // 最大录音时长(秒)
|
||||||
|
}
|
||||||
|
|
||||||
|
type RecordingState =
|
||||||
|
| "idle"
|
||||||
|
| "recording"
|
||||||
|
| "recorded"
|
||||||
|
| "playing"
|
||||||
|
| "uploading";
|
||||||
|
|
||||||
|
const AudioRecorder: React.FC<AudioRecorderProps> = ({
|
||||||
|
onAudioUploaded,
|
||||||
|
className,
|
||||||
|
disabled = false,
|
||||||
|
maxDuration = 60,
|
||||||
|
}) => {
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const [state, setState] = useState<RecordingState>("idle");
|
||||||
|
const [recordingTime, setRecordingTime] = useState(0);
|
||||||
|
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
|
||||||
|
const [audioUrl, setAudioUrl] = useState<string>("");
|
||||||
|
|
||||||
|
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||||
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const chunksRef = useRef<Blob[]>([]);
|
||||||
|
|
||||||
|
// 打开弹窗
|
||||||
|
const openRecorder = () => {
|
||||||
|
setVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 关闭弹窗并重置状态
|
||||||
|
const closeRecorder = () => {
|
||||||
|
if (state === "recording") {
|
||||||
|
stopRecording();
|
||||||
|
}
|
||||||
|
if (state === "playing") {
|
||||||
|
pauseAudio();
|
||||||
|
}
|
||||||
|
deleteRecording();
|
||||||
|
setVisible(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 开始录音
|
||||||
|
const startRecording = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
|
||||||
|
// 尝试使用MP3格式,如果不支持则回退到WebM
|
||||||
|
const mp3Types = [
|
||||||
|
"audio/mpeg",
|
||||||
|
"audio/mp3",
|
||||||
|
"audio/mpeg; codecs=mp3",
|
||||||
|
"audio/mp4",
|
||||||
|
"audio/mp4; codecs=mp4a.40.2",
|
||||||
|
];
|
||||||
|
|
||||||
|
let mimeType = "audio/webm"; // 默认回退格式
|
||||||
|
|
||||||
|
// 检测并选择支持的MP3格式
|
||||||
|
for (const type of mp3Types) {
|
||||||
|
if (MediaRecorder.isTypeSupported(type)) {
|
||||||
|
mimeType = type;
|
||||||
|
console.log(`使用音频格式: ${type}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mimeType === "audio/webm") {
|
||||||
|
console.log("浏览器不支持MP3格式,使用WebM格式");
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaRecorder = new MediaRecorder(stream, { mimeType });
|
||||||
|
mediaRecorderRef.current = mediaRecorder;
|
||||||
|
chunksRef.current = [];
|
||||||
|
|
||||||
|
mediaRecorder.ondataavailable = event => {
|
||||||
|
if (event.data.size > 0) {
|
||||||
|
chunksRef.current.push(event.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mediaRecorder.onstop = () => {
|
||||||
|
const blob = new Blob(chunksRef.current, { type: mimeType });
|
||||||
|
setAudioBlob(blob);
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
setAudioUrl(url);
|
||||||
|
setState("recorded");
|
||||||
|
|
||||||
|
// 停止所有音频轨道
|
||||||
|
stream.getTracks().forEach(track => track.stop());
|
||||||
|
};
|
||||||
|
|
||||||
|
mediaRecorder.start();
|
||||||
|
setState("recording");
|
||||||
|
setRecordingTime(0);
|
||||||
|
|
||||||
|
// 开始计时
|
||||||
|
timerRef.current = setInterval(() => {
|
||||||
|
setRecordingTime(prev => {
|
||||||
|
const newTime = prev + 1;
|
||||||
|
if (newTime >= maxDuration) {
|
||||||
|
stopRecording();
|
||||||
|
}
|
||||||
|
return newTime;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("录音失败:", error);
|
||||||
|
message.error("无法访问麦克风,请检查权限设置");
|
||||||
|
}
|
||||||
|
}, [maxDuration]);
|
||||||
|
|
||||||
|
// 停止录音
|
||||||
|
const stopRecording = useCallback(() => {
|
||||||
|
if (
|
||||||
|
mediaRecorderRef.current &&
|
||||||
|
mediaRecorderRef.current.state === "recording"
|
||||||
|
) {
|
||||||
|
mediaRecorderRef.current.stop();
|
||||||
|
}
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearInterval(timerRef.current);
|
||||||
|
timerRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 播放录音
|
||||||
|
const playAudio = useCallback(() => {
|
||||||
|
if (audioRef.current && audioUrl) {
|
||||||
|
audioRef.current.play();
|
||||||
|
setState("playing");
|
||||||
|
}
|
||||||
|
}, [audioUrl]);
|
||||||
|
|
||||||
|
// 暂停播放
|
||||||
|
const pauseAudio = useCallback(() => {
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.pause();
|
||||||
|
setState("recorded");
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 删除录音
|
||||||
|
const deleteRecording = useCallback(() => {
|
||||||
|
if (audioUrl) {
|
||||||
|
URL.revokeObjectURL(audioUrl);
|
||||||
|
}
|
||||||
|
setAudioBlob(null);
|
||||||
|
setAudioUrl("");
|
||||||
|
setRecordingTime(0);
|
||||||
|
setState("idle");
|
||||||
|
}, [audioUrl]);
|
||||||
|
|
||||||
|
// 发送录音
|
||||||
|
const sendAudio = useCallback(async () => {
|
||||||
|
if (!audioBlob) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setState("uploading");
|
||||||
|
|
||||||
|
// 创建文件对象
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const fileExtension =
|
||||||
|
audioBlob.type.includes("mp3") ||
|
||||||
|
audioBlob.type.includes("mpeg") ||
|
||||||
|
audioBlob.type.includes("mp4")
|
||||||
|
? "mp3"
|
||||||
|
: "webm";
|
||||||
|
const audioFile = new File(
|
||||||
|
[audioBlob],
|
||||||
|
`audio_${timestamp}.${fileExtension}`,
|
||||||
|
{
|
||||||
|
type: audioBlob.type,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// 打印文件格式信息
|
||||||
|
console.log("音频文件信息:", {
|
||||||
|
fileName: audioFile.name,
|
||||||
|
fileType: audioFile.type,
|
||||||
|
fileSize: audioFile.size,
|
||||||
|
fileExtension: fileExtension,
|
||||||
|
blobType: audioBlob.type,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 上传文件
|
||||||
|
const filePath = await uploadFile(audioFile);
|
||||||
|
|
||||||
|
// 调用回调函数,传递音频URL和时长(毫秒)
|
||||||
|
onAudioUploaded({
|
||||||
|
url: filePath,
|
||||||
|
durationMs: recordingTime * 1000, // 将秒转换为毫秒
|
||||||
|
});
|
||||||
|
|
||||||
|
// 重置状态并关闭弹窗
|
||||||
|
deleteRecording();
|
||||||
|
setVisible(false);
|
||||||
|
message.success("语音发送成功");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("语音上传失败:", error);
|
||||||
|
message.error("语音发送失败,请重试");
|
||||||
|
setState("recorded");
|
||||||
|
}
|
||||||
|
}, [audioBlob, onAudioUploaded, deleteRecording]);
|
||||||
|
|
||||||
|
// 格式化时间显示
|
||||||
|
const formatTime = (seconds: number) => {
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = seconds % 60;
|
||||||
|
return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 渲染弹窗内容
|
||||||
|
const renderModalContent = () => {
|
||||||
|
switch (state) {
|
||||||
|
case "idle":
|
||||||
|
return (
|
||||||
|
<div style={{ textAlign: "center", padding: "40px 20px" }}>
|
||||||
|
<div
|
||||||
|
style={{ marginBottom: "20px", fontSize: "16px", color: "#666" }}
|
||||||
|
>
|
||||||
|
点击下方按钮开始录音
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
icon={<AudioOutlined />}
|
||||||
|
onClick={startRecording}
|
||||||
|
style={{
|
||||||
|
borderRadius: "50%",
|
||||||
|
width: "80px",
|
||||||
|
height: "80px",
|
||||||
|
fontSize: "24px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "recording":
|
||||||
|
return (
|
||||||
|
<div style={{ textAlign: "center", padding: "40px 20px" }}>
|
||||||
|
<div style={{ marginBottom: "20px" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "24px",
|
||||||
|
color: "#ff4d4f",
|
||||||
|
fontWeight: "bold",
|
||||||
|
marginBottom: "10px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatTime(recordingTime)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: "14px", color: "#999" }}>
|
||||||
|
正在录音中...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
danger
|
||||||
|
size="large"
|
||||||
|
onClick={stopRecording}
|
||||||
|
style={{
|
||||||
|
borderRadius: "50%",
|
||||||
|
width: "80px",
|
||||||
|
height: "80px",
|
||||||
|
fontSize: "24px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
⏹
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "recorded":
|
||||||
|
case "playing":
|
||||||
|
return (
|
||||||
|
<div style={{ padding: "20px" }}>
|
||||||
|
<div style={{ textAlign: "center", marginBottom: "20px" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "18px",
|
||||||
|
fontWeight: "bold",
|
||||||
|
marginBottom: "10px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
录音时长: {formatTime(recordingTime)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: "14px", color: "#666" }}>
|
||||||
|
{state === "playing"
|
||||||
|
? "正在播放..."
|
||||||
|
: "录音完成,可以试听或发送"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: "12px",
|
||||||
|
marginBottom: "20px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="large"
|
||||||
|
icon={
|
||||||
|
state === "playing" ? (
|
||||||
|
<PauseCircleOutlined />
|
||||||
|
) : (
|
||||||
|
<PlayCircleOutlined />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onClick={state === "playing" ? pauseAudio : playAudio}
|
||||||
|
title={state === "playing" ? "暂停播放" : "播放预览"}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="large"
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
onClick={deleteRecording}
|
||||||
|
title="删除重录"
|
||||||
|
danger
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ textAlign: "center" }}>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
icon={<SendOutlined />}
|
||||||
|
onClick={sendAudio}
|
||||||
|
loading={state === ("uploading" as RecordingState)}
|
||||||
|
style={{ minWidth: "120px" }}
|
||||||
|
>
|
||||||
|
发送录音
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "uploading":
|
||||||
|
return (
|
||||||
|
<div style={{ textAlign: "center", padding: "40px 20px" }}>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
loading
|
||||||
|
size="large"
|
||||||
|
style={{ minWidth: "120px" }}
|
||||||
|
>
|
||||||
|
发送中...
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<AudioOutlined />}
|
||||||
|
onClick={openRecorder}
|
||||||
|
className={className}
|
||||||
|
disabled={disabled}
|
||||||
|
title="点击录音"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="录音"
|
||||||
|
open={visible}
|
||||||
|
onCancel={closeRecorder}
|
||||||
|
footer={null}
|
||||||
|
width={400}
|
||||||
|
centered
|
||||||
|
maskClosable={state === "idle"}
|
||||||
|
>
|
||||||
|
{renderModalContent()}
|
||||||
|
{audioUrl && (
|
||||||
|
<audio
|
||||||
|
ref={audioRef}
|
||||||
|
src={audioUrl}
|
||||||
|
onEnded={() => setState("recorded")}
|
||||||
|
style={{ display: "none" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AudioRecorder;
|
||||||
484
Cunkebao/src/components/Upload/AvatarUpload/index.module.scss
Normal file
484
Cunkebao/src/components/Upload/AvatarUpload/index.module.scss
Normal file
@@ -0,0 +1,484 @@
|
|||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 头像上传组件样式
|
||||||
|
.avatarUploadContainer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
.avatarWrapper {
|
||||||
|
position: relative;
|
||||||
|
border-radius: 50%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #f0f0f0;
|
||||||
|
border: 2px solid #e0e0e0;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
box-shadow: 0 4px 12px rgba(24, 142, 238, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatarImage {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatarPlaceholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
font-size: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatarUploadOverlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
font-size: 24px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uploadLoading {
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatarDeleteBtn {
|
||||||
|
position: absolute;
|
||||||
|
top: -8px;
|
||||||
|
right: -8px;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
background: #ff4d4f;
|
||||||
|
color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
z-index: 10;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #ff7875;
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover .avatarUploadOverlay {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatarTip {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.4;
|
||||||
|
max-width: 200px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 视频上传组件样式
|
||||||
|
.videoUploadContainer {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.videoUploadButton {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 120px;
|
||||||
|
border: 2px dashed #d9d9d9;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #1890ff;
|
||||||
|
background: linear-gradient(135deg, #f0f8ff 0%, #e6f7ff 100%);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 25px rgba(24, 144, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.uploadingContainer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 20px;
|
||||||
|
|
||||||
|
.uploadingIcon {
|
||||||
|
font-size: 32px;
|
||||||
|
color: #1890ff;
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uploadingText {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uploadProgress {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 200px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.uploadContent {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
.uploadIcon {
|
||||||
|
font-size: 48px;
|
||||||
|
color: #1890ff;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uploadText {
|
||||||
|
.uploadTitle {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uploadSubtitle {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover .uploadIcon {
|
||||||
|
transform: scale(1.1);
|
||||||
|
color: #40a9ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.videoItem {
|
||||||
|
width: 100%;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #f0f0f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #1890ff;
|
||||||
|
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.videoItemContent {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
.videoIcon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
background: linear-gradient(135deg, #1890ff 0%, #40a9ff 100%);
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
font-size: 18px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.videoInfo {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
.videoName {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.videoSize {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.videoActions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
.previewBtn,
|
||||||
|
.deleteBtn {
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.previewBtn {
|
||||||
|
color: #1890ff;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #40a9ff;
|
||||||
|
background: #e6f7ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.deleteBtn {
|
||||||
|
color: #ff4d4f;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #ff7875;
|
||||||
|
background: #fff2f0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemProgress {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.videoPreview {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
background: #000;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
video {
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 动画效果
|
||||||
|
@keyframes pulse {
|
||||||
|
0% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 暗色主题支持
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.videoUploadContainer {
|
||||||
|
.videoUploadButton {
|
||||||
|
background: linear-gradient(135deg, #2a2a2a 0%, #1f1f1f 100%);
|
||||||
|
border-color: #434343;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: linear-gradient(135deg, #1a365d 0%, #2d3748 100%);
|
||||||
|
border-color: #40a9ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uploadingContainer {
|
||||||
|
.uploadingText {
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.uploadContent {
|
||||||
|
.uploadText {
|
||||||
|
.uploadTitle {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uploadSubtitle {
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.videoItem {
|
||||||
|
background: #2a2a2a;
|
||||||
|
border-color: #434343;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #40a9ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.videoItemContent {
|
||||||
|
.videoInfo {
|
||||||
|
.videoName {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.videoSize {
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.videoActions {
|
||||||
|
.previewBtn,
|
||||||
|
.deleteBtn {
|
||||||
|
&:hover {
|
||||||
|
background: #434343;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
188
Cunkebao/src/components/Upload/AvatarUpload/index.tsx
Normal file
188
Cunkebao/src/components/Upload/AvatarUpload/index.tsx
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { Toast, Dialog } from "antd-mobile";
|
||||||
|
import { UserOutlined, CameraOutlined } from "@ant-design/icons";
|
||||||
|
import style from "./index.module.scss";
|
||||||
|
|
||||||
|
interface AvatarUploadProps {
|
||||||
|
value?: string;
|
||||||
|
onChange?: (url: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
size?: number; // 头像尺寸
|
||||||
|
}
|
||||||
|
|
||||||
|
const AvatarUpload: React.FC<AvatarUploadProps> = ({
|
||||||
|
value = "",
|
||||||
|
onChange,
|
||||||
|
disabled = false,
|
||||||
|
className,
|
||||||
|
size = 100,
|
||||||
|
}) => {
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [avatarUrl, setAvatarUrl] = useState(value);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setAvatarUrl(value);
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
// 文件验证
|
||||||
|
const beforeUpload = (file: File) => {
|
||||||
|
// 检查文件类型
|
||||||
|
const isValidType = file.type.startsWith("image/");
|
||||||
|
if (!isValidType) {
|
||||||
|
Toast.show("只能上传图片文件!");
|
||||||
|
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("头像上传成功");
|
||||||
|
// 确保返回的是字符串URL
|
||||||
|
let url = "";
|
||||||
|
if (typeof result.data === "string") {
|
||||||
|
url = result.data;
|
||||||
|
} else if (result.data && typeof result.data === "object") {
|
||||||
|
url = result.data.url || "";
|
||||||
|
}
|
||||||
|
return { url };
|
||||||
|
} else {
|
||||||
|
throw new Error(result.msg || "上传失败");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Toast.show("头像上传失败,请重试");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理头像上传
|
||||||
|
const handleAvatarChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file || disabled || uploading) return;
|
||||||
|
|
||||||
|
const validatedFile = beforeUpload(file);
|
||||||
|
if (!validatedFile) return;
|
||||||
|
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
const result = await upload(validatedFile);
|
||||||
|
setAvatarUrl(result.url);
|
||||||
|
onChange?.(result.url);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("头像上传失败:", error);
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 删除头像
|
||||||
|
const handleDelete = () => {
|
||||||
|
return Dialog.confirm({
|
||||||
|
content: "确定要删除头像吗?",
|
||||||
|
onConfirm: () => {
|
||||||
|
setAvatarUrl("");
|
||||||
|
onChange?.("");
|
||||||
|
Toast.show("头像已删除");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${style.avatarUploadContainer} ${className || ""}`}>
|
||||||
|
<div
|
||||||
|
className={style.avatarWrapper}
|
||||||
|
style={{ width: size, height: size }}
|
||||||
|
>
|
||||||
|
{avatarUrl ? (
|
||||||
|
<img
|
||||||
|
src={avatarUrl}
|
||||||
|
alt="头像"
|
||||||
|
className={style.avatarImage}
|
||||||
|
style={{ width: size, height: size }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className={style.avatarPlaceholder}
|
||||||
|
style={{ width: size, height: size }}
|
||||||
|
>
|
||||||
|
<UserOutlined />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 上传覆盖层 */}
|
||||||
|
<div
|
||||||
|
className={style.avatarUploadOverlay}
|
||||||
|
onClick={() =>
|
||||||
|
!disabled && !uploading && fileInputRef.current?.click()
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{uploading ? (
|
||||||
|
<div className={style.uploadLoading}>上传中...</div>
|
||||||
|
) : (
|
||||||
|
<CameraOutlined />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 删除按钮 */}
|
||||||
|
{avatarUrl && !disabled && (
|
||||||
|
<div className={style.avatarDeleteBtn} onClick={handleDelete}>
|
||||||
|
×
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 隐藏的文件输入 */}
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
style={{ display: "none" }}
|
||||||
|
onChange={handleAvatarChange}
|
||||||
|
disabled={disabled || uploading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 提示文字 */}
|
||||||
|
<div className={style.avatarTip}>
|
||||||
|
{uploading
|
||||||
|
? "正在上传头像..."
|
||||||
|
: "点击头像可更换,支持JPG、PNG格式,大小不超过5MB"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 创建 ref
|
||||||
|
const fileInputRef = React.createRef<HTMLInputElement>();
|
||||||
|
|
||||||
|
export default AvatarUpload;
|
||||||
254
Cunkebao/src/components/Upload/ChatFileUpload/example.tsx
Normal file
254
Cunkebao/src/components/Upload/ChatFileUpload/example.tsx
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { Input, Button, Card, Space, Typography, Divider } from "antd";
|
||||||
|
import { SendOutlined } from "@ant-design/icons";
|
||||||
|
import ChatFileUpload from "./index";
|
||||||
|
|
||||||
|
const { TextArea } = Input;
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
interface ChatMessage {
|
||||||
|
id: string;
|
||||||
|
type: "text" | "file";
|
||||||
|
content: string;
|
||||||
|
timestamp: Date;
|
||||||
|
fileInfo?: {
|
||||||
|
url: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
size: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChatFileUploadExample: React.FC = () => {
|
||||||
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||||
|
const [inputValue, setInputValue] = useState("");
|
||||||
|
|
||||||
|
// 处理文件上传
|
||||||
|
const handleFileUploaded = (fileInfo: {
|
||||||
|
url: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
size: number;
|
||||||
|
}) => {
|
||||||
|
const newMessage: ChatMessage = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
type: "file",
|
||||||
|
content: `文件: ${fileInfo.name}`,
|
||||||
|
timestamp: new Date(),
|
||||||
|
fileInfo,
|
||||||
|
};
|
||||||
|
|
||||||
|
setMessages(prev => [...prev, newMessage]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理文本发送
|
||||||
|
const handleSendText = () => {
|
||||||
|
if (!inputValue.trim()) return;
|
||||||
|
|
||||||
|
const newMessage: ChatMessage = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
type: "text",
|
||||||
|
content: inputValue,
|
||||||
|
timestamp: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
setMessages(prev => [...prev, newMessage]);
|
||||||
|
setInputValue("");
|
||||||
|
};
|
||||||
|
|
||||||
|
// 格式化文件大小
|
||||||
|
const formatFileSize = (bytes: number) => {
|
||||||
|
if (bytes === 0) return "0 B";
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ["B", "KB", "MB", "GB"];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取文件类型图标
|
||||||
|
const getFileTypeIcon = (type: string, name: string) => {
|
||||||
|
const lowerType = type.toLowerCase();
|
||||||
|
const lowerName = name.toLowerCase();
|
||||||
|
|
||||||
|
if (lowerType.startsWith("image/")) {
|
||||||
|
return "🖼️";
|
||||||
|
} else if (lowerType.startsWith("video/")) {
|
||||||
|
return "🎥";
|
||||||
|
} else if (lowerType.startsWith("audio/")) {
|
||||||
|
return "🎵";
|
||||||
|
} else if (lowerType === "application/pdf") {
|
||||||
|
return "📄";
|
||||||
|
} else if (lowerName.endsWith(".doc") || lowerName.endsWith(".docx")) {
|
||||||
|
return "📝";
|
||||||
|
} else if (lowerName.endsWith(".xls") || lowerName.endsWith(".xlsx")) {
|
||||||
|
return "📊";
|
||||||
|
} else if (lowerName.endsWith(".ppt") || lowerName.endsWith(".pptx")) {
|
||||||
|
return "📈";
|
||||||
|
} else {
|
||||||
|
return "📎";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 600, margin: "0 auto", padding: 20 }}>
|
||||||
|
<Card title="聊天文件上传示例" style={{ marginBottom: 20 }}>
|
||||||
|
<Space direction="vertical" style={{ width: "100%" }}>
|
||||||
|
<Text>功能特点:</Text>
|
||||||
|
<ul>
|
||||||
|
<li>点击文件按钮直接唤醒文件选择框</li>
|
||||||
|
<li>选择文件后自动上传</li>
|
||||||
|
<li>上传成功后自动发送到聊天框</li>
|
||||||
|
<li>支持各种文件类型和大小限制</li>
|
||||||
|
<li>显示文件图标和大小信息</li>
|
||||||
|
</ul>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 聊天消息区域 */}
|
||||||
|
<Card
|
||||||
|
title="聊天记录"
|
||||||
|
style={{
|
||||||
|
height: 400,
|
||||||
|
marginBottom: 20,
|
||||||
|
overflowY: "auto",
|
||||||
|
}}
|
||||||
|
bodyStyle={{ height: 320, overflowY: "auto" }}
|
||||||
|
>
|
||||||
|
{messages.length === 0 ? (
|
||||||
|
<div style={{ textAlign: "center", color: "#999", marginTop: 100 }}>
|
||||||
|
暂无消息,开始聊天吧!
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
{messages.map(message => (
|
||||||
|
<div key={message.id} style={{ marginBottom: 16 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "#f0f0f0",
|
||||||
|
padding: 12,
|
||||||
|
borderRadius: 8,
|
||||||
|
maxWidth: "80%",
|
||||||
|
wordBreak: "break-word",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{message.type === "text" ? (
|
||||||
|
<div>{message.content}</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
marginBottom: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{getFileTypeIcon(
|
||||||
|
message.fileInfo!.type,
|
||||||
|
message.fileInfo!.name,
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<Text strong>{message.fileInfo!.name}</Text>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: "#666" }}>
|
||||||
|
大小: {formatFileSize(message.fileInfo!.size)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: "#666" }}>
|
||||||
|
类型: {message.fileInfo!.type}
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href={message.fileInfo!.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{ fontSize: 12, color: "#1890ff" }}
|
||||||
|
>
|
||||||
|
查看文件
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: "#999",
|
||||||
|
marginTop: 4,
|
||||||
|
textAlign: "right",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{message.timestamp.toLocaleTimeString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 输入区域 */}
|
||||||
|
<Card title="发送消息">
|
||||||
|
<Space direction="vertical" style={{ width: "100%" }}>
|
||||||
|
<TextArea
|
||||||
|
value={inputValue}
|
||||||
|
onChange={e => setInputValue(e.target.value)}
|
||||||
|
placeholder="输入消息内容..."
|
||||||
|
autoSize={{ minRows: 2, maxRows: 4 }}
|
||||||
|
onPressEnter={e => {
|
||||||
|
if (!e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSendText();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Space>
|
||||||
|
{/* 文件上传组件 */}
|
||||||
|
<ChatFileUpload
|
||||||
|
onFileUploaded={handleFileUploaded}
|
||||||
|
maxSize={50} // 最大50MB
|
||||||
|
accept="*/*" // 接受所有文件类型
|
||||||
|
buttonText="文件"
|
||||||
|
buttonIcon={<span>📎</span>}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 图片上传组件 */}
|
||||||
|
<ChatFileUpload
|
||||||
|
onFileUploaded={handleFileUploaded}
|
||||||
|
maxSize={10} // 最大10MB
|
||||||
|
accept="image/*" // 只接受图片
|
||||||
|
buttonText="图片"
|
||||||
|
buttonIcon={<span>🖼️</span>}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 文档上传组件 */}
|
||||||
|
<ChatFileUpload
|
||||||
|
onFileUploaded={handleFileUploaded}
|
||||||
|
maxSize={20} // 最大20MB
|
||||||
|
accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx" // 只接受文档
|
||||||
|
buttonText="文档"
|
||||||
|
buttonIcon={<span>📄</span>}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<SendOutlined />}
|
||||||
|
onClick={handleSendText}
|
||||||
|
disabled={!inputValue.trim()}
|
||||||
|
>
|
||||||
|
发送
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ChatFileUploadExample;
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
.chatFileUpload {
|
||||||
|
display: inline-block;
|
||||||
|
|
||||||
|
.uploadButton {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #1890ff;
|
||||||
|
background-color: rgba(24, 144, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
color: #ccc;
|
||||||
|
cursor: not-allowed;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.anticon {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移动端适配
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.chatFileUpload {
|
||||||
|
.uploadButton {
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
.anticon {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
189
Cunkebao/src/components/Upload/ChatFileUpload/index.tsx
Normal file
189
Cunkebao/src/components/Upload/ChatFileUpload/index.tsx
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
import React, { useRef, useState } from "react";
|
||||||
|
import { Button, message } from "antd";
|
||||||
|
import {
|
||||||
|
PaperClipOutlined,
|
||||||
|
LoadingOutlined,
|
||||||
|
FileOutlined,
|
||||||
|
FileImageOutlined,
|
||||||
|
FileVideoOutlined,
|
||||||
|
FileAudioOutlined,
|
||||||
|
FilePdfOutlined,
|
||||||
|
FileWordOutlined,
|
||||||
|
FileExcelOutlined,
|
||||||
|
FilePptOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
|
import { uploadFile } from "@/api/common";
|
||||||
|
import style from "./index.module.scss";
|
||||||
|
|
||||||
|
interface ChatFileUploadProps {
|
||||||
|
onFileUploaded?: (fileInfo: {
|
||||||
|
url: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
size: number;
|
||||||
|
}) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
maxSize?: number; // 最大文件大小(MB)
|
||||||
|
accept?: string; // 接受的文件类型
|
||||||
|
buttonText?: string;
|
||||||
|
buttonIcon?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChatFileUpload: React.FC<ChatFileUploadProps> = ({
|
||||||
|
onFileUploaded,
|
||||||
|
disabled = false,
|
||||||
|
className,
|
||||||
|
maxSize = 50, // 默认50MB
|
||||||
|
accept = "*/*", // 默认接受所有文件类型
|
||||||
|
buttonText = "发送文件",
|
||||||
|
buttonIcon = <PaperClipOutlined />,
|
||||||
|
}) => {
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
|
||||||
|
// 获取文件图标
|
||||||
|
const getFileIcon = (file: File) => {
|
||||||
|
const type = file.type.toLowerCase();
|
||||||
|
const name = file.name.toLowerCase();
|
||||||
|
|
||||||
|
if (type.startsWith("image/")) {
|
||||||
|
return <FileImageOutlined />;
|
||||||
|
} else if (type.startsWith("video/")) {
|
||||||
|
return <FileVideoOutlined />;
|
||||||
|
} else if (type.startsWith("audio/")) {
|
||||||
|
return <FileAudioOutlined />;
|
||||||
|
} else if (type === "application/pdf") {
|
||||||
|
return <FilePdfOutlined />;
|
||||||
|
} else if (name.endsWith(".doc") || name.endsWith(".docx")) {
|
||||||
|
return <FileWordOutlined />;
|
||||||
|
} else if (name.endsWith(".xls") || name.endsWith(".xlsx")) {
|
||||||
|
return <FileExcelOutlined />;
|
||||||
|
} else if (name.endsWith(".ppt") || name.endsWith(".pptx")) {
|
||||||
|
return <FilePptOutlined />;
|
||||||
|
} else {
|
||||||
|
return <FileOutlined />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 格式化文件大小
|
||||||
|
const formatFileSize = (bytes: number) => {
|
||||||
|
if (bytes === 0) return "0 B";
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ["B", "KB", "MB", "GB"];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
||||||
|
};
|
||||||
|
|
||||||
|
// 验证文件
|
||||||
|
const validateFile = (file: File): boolean => {
|
||||||
|
// 检查文件大小
|
||||||
|
if (file.size > maxSize * 1024 * 1024) {
|
||||||
|
message.error(`文件大小不能超过 ${maxSize}MB`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查文件类型(如果指定了accept)
|
||||||
|
if (accept !== "*/*") {
|
||||||
|
const acceptTypes = accept.split(",").map(type => type.trim());
|
||||||
|
const fileType = file.type;
|
||||||
|
const fileName = file.name.toLowerCase();
|
||||||
|
|
||||||
|
const isValidType = acceptTypes.some(type => {
|
||||||
|
if (type.startsWith(".")) {
|
||||||
|
// 扩展名匹配
|
||||||
|
return fileName.endsWith(type);
|
||||||
|
} else if (type.includes("*")) {
|
||||||
|
// MIME类型通配符匹配
|
||||||
|
const baseType = type.replace("*", "");
|
||||||
|
return fileType.startsWith(baseType);
|
||||||
|
} else {
|
||||||
|
// 精确MIME类型匹配
|
||||||
|
return fileType === type;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isValidType) {
|
||||||
|
message.error(`不支持的文件类型: ${file.type}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理文件选择
|
||||||
|
const handleFileSelect = async (
|
||||||
|
event: React.ChangeEvent<HTMLInputElement>,
|
||||||
|
) => {
|
||||||
|
const files = event.target.files;
|
||||||
|
if (!files || files.length === 0) return;
|
||||||
|
|
||||||
|
const file = files[0];
|
||||||
|
|
||||||
|
// 验证文件
|
||||||
|
if (!validateFile(file)) {
|
||||||
|
// 清空input值,允许重新选择同一文件
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = "";
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUploading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 上传文件
|
||||||
|
const fileUrl = await uploadFile(file);
|
||||||
|
|
||||||
|
// 调用回调函数,传递文件信息
|
||||||
|
onFileUploaded?.({
|
||||||
|
url: fileUrl,
|
||||||
|
name: file.name,
|
||||||
|
type: file.type,
|
||||||
|
size: file.size,
|
||||||
|
});
|
||||||
|
|
||||||
|
message.success("文件上传成功");
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.message || "文件上传失败");
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
// 清空input值,允许重新选择同一文件
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 触发文件选择
|
||||||
|
const handleClick = () => {
|
||||||
|
if (disabled || uploading) return;
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${style.chatFileUpload} ${className || ""}`}>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept={accept}
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
style={{ display: "none" }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={uploading ? <LoadingOutlined /> : buttonIcon}
|
||||||
|
onClick={handleClick}
|
||||||
|
disabled={disabled || uploading}
|
||||||
|
className={style.uploadButton}
|
||||||
|
title={buttonText}
|
||||||
|
>
|
||||||
|
{buttonText}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ChatFileUpload;
|
||||||
265
Cunkebao/src/components/Upload/FileUpload/index.module.scss
Normal file
265
Cunkebao/src/components/Upload/FileUpload/index.module.scss
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
.fileUploadContainer {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
// 覆盖 antd Upload 组件的默认样式
|
||||||
|
:global {
|
||||||
|
.ant-upload {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-upload-list {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-upload-list-text {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-upload-list-text .ant-upload-list-item {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileUploadButton {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
min-height: clamp(90px, 20vw, 180px);
|
||||||
|
border: 2px dashed #d9d9d9;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #1890ff;
|
||||||
|
background: linear-gradient(135deg, #f0f8ff 0%, #e6f7ff 100%);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 25px rgba(24, 144, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.uploadingContainer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 20px;
|
||||||
|
|
||||||
|
.uploadingIcon {
|
||||||
|
font-size: clamp(24px, 4vw, 32px);
|
||||||
|
color: #1890ff;
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uploadingText {
|
||||||
|
font-size: clamp(11px, 2vw, 14px);
|
||||||
|
color: #666;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uploadProgress {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 200px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.uploadContent {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
.uploadIcon {
|
||||||
|
font-size: clamp(50px, 6vw, 48px);
|
||||||
|
color: #1890ff;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uploadText {
|
||||||
|
.uploadTitle {
|
||||||
|
font-size: clamp(14px, 2.5vw, 16px);
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uploadSubtitle {
|
||||||
|
font-size: clamp(10px, 1.5vw, 14px);
|
||||||
|
color: #666;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover .uploadIcon {
|
||||||
|
transform: scale(1.1);
|
||||||
|
color: #40a9ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileItem {
|
||||||
|
width: 100%;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #f0f0f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #1890ff;
|
||||||
|
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileItemContent {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
.fileIcon {
|
||||||
|
width: clamp(28px, 5vw, 40px);
|
||||||
|
height: clamp(28px, 5vw, 40px);
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: clamp(14px, 2.5vw, 18px);
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
// Excel文件图标样式
|
||||||
|
:global(.anticon-file-excel) {
|
||||||
|
color: #217346;
|
||||||
|
background: rgba(33, 115, 70, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Word文件图标样式
|
||||||
|
:global(.anticon-file-word) {
|
||||||
|
color: #2b579a;
|
||||||
|
background: rgba(43, 87, 154, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// PPT文件图标样式
|
||||||
|
:global(.anticon-file-ppt) {
|
||||||
|
color: #d24726;
|
||||||
|
background: rgba(210, 71, 38, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认文件图标样式
|
||||||
|
:global(.anticon-file) {
|
||||||
|
color: #666;
|
||||||
|
background: rgba(102, 102, 102, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileInfo {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
.fileName {
|
||||||
|
font-size: clamp(11px, 2vw, 14px);
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileSize {
|
||||||
|
font-size: clamp(10px, 1.5vw, 12px);
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileActions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
.previewBtn,
|
||||||
|
.deleteBtn {
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.previewBtn {
|
||||||
|
color: #1890ff;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #40a9ff;
|
||||||
|
background: #e6f7ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.deleteBtn {
|
||||||
|
color: #ff4d4f;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #ff7875;
|
||||||
|
background: #fff2f0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemProgress {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.filePreview {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
iframe {
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 禁用状态
|
||||||
|
.fileUploadContainer.disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 错误状态
|
||||||
|
.fileUploadContainer.error {
|
||||||
|
.fileUploadButton {
|
||||||
|
border-color: #ff4d4f;
|
||||||
|
background: #fff2f0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 动画效果
|
||||||
|
@keyframes pulse {
|
||||||
|
0% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
531
Cunkebao/src/components/Upload/FileUpload/index.tsx
Normal file
531
Cunkebao/src/components/Upload/FileUpload/index.tsx
Normal file
@@ -0,0 +1,531 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { Upload, message, Progress, Button, Modal } from "antd";
|
||||||
|
import {
|
||||||
|
LoadingOutlined,
|
||||||
|
FileOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
EyeOutlined,
|
||||||
|
CloudUploadOutlined,
|
||||||
|
FileExcelOutlined,
|
||||||
|
FileWordOutlined,
|
||||||
|
FilePptOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
|
import type { UploadProps, UploadFile } from "antd/es/upload/interface";
|
||||||
|
import style from "./index.module.scss";
|
||||||
|
|
||||||
|
interface FileUploadProps {
|
||||||
|
value?: string | string[]; // 支持单个字符串或字符串数组
|
||||||
|
onChange?: (url: string | string[]) => void; // 支持单个字符串或字符串数组
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
maxSize?: number; // 最大文件大小(MB)
|
||||||
|
showPreview?: boolean; // 是否显示预览
|
||||||
|
maxCount?: number; // 最大上传数量,默认为1
|
||||||
|
acceptTypes?: string[]; // 接受的文件类型
|
||||||
|
}
|
||||||
|
|
||||||
|
const FileUpload: React.FC<FileUploadProps> = ({
|
||||||
|
value = "",
|
||||||
|
onChange,
|
||||||
|
disabled = false,
|
||||||
|
className,
|
||||||
|
maxSize = 10,
|
||||||
|
showPreview = true,
|
||||||
|
maxCount = 1,
|
||||||
|
acceptTypes = ["excel", "word", "ppt"],
|
||||||
|
}) => {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [fileList, setFileList] = useState<UploadFile[]>([]);
|
||||||
|
const [uploadProgress, setUploadProgress] = useState(0);
|
||||||
|
const [previewVisible, setPreviewVisible] = useState(false);
|
||||||
|
const [previewUrl, setPreviewUrl] = useState("");
|
||||||
|
|
||||||
|
// 文件类型配置
|
||||||
|
const fileTypeConfig = {
|
||||||
|
excel: {
|
||||||
|
accept: ".xlsx,.xls",
|
||||||
|
mimeTypes: [
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
"application/vnd.ms-excel",
|
||||||
|
],
|
||||||
|
icon: FileExcelOutlined,
|
||||||
|
name: "Excel文件",
|
||||||
|
extensions: ["xlsx", "xls"],
|
||||||
|
},
|
||||||
|
word: {
|
||||||
|
accept: ".docx,.doc",
|
||||||
|
mimeTypes: [
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
"application/msword",
|
||||||
|
],
|
||||||
|
icon: FileWordOutlined,
|
||||||
|
name: "Word文件",
|
||||||
|
extensions: ["docx", "doc"],
|
||||||
|
},
|
||||||
|
ppt: {
|
||||||
|
accept: ".pptx,.ppt",
|
||||||
|
mimeTypes: [
|
||||||
|
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||||
|
"application/vnd.ms-powerpoint",
|
||||||
|
],
|
||||||
|
icon: FilePptOutlined,
|
||||||
|
name: "PPT文件",
|
||||||
|
extensions: ["pptx", "ppt"],
|
||||||
|
},
|
||||||
|
pdf: {
|
||||||
|
accept: ".pdf",
|
||||||
|
mimeTypes: ["application/pdf"],
|
||||||
|
icon: FileOutlined,
|
||||||
|
name: "PDF文件",
|
||||||
|
extensions: ["pdf"],
|
||||||
|
},
|
||||||
|
txt: {
|
||||||
|
accept: ".txt",
|
||||||
|
mimeTypes: ["text/plain"],
|
||||||
|
icon: FileOutlined,
|
||||||
|
name: "文本文件",
|
||||||
|
extensions: ["txt"],
|
||||||
|
},
|
||||||
|
md: {
|
||||||
|
accept: ".md",
|
||||||
|
mimeTypes: ["text/markdown"],
|
||||||
|
icon: FileOutlined,
|
||||||
|
name: "Markdown文件",
|
||||||
|
extensions: ["md"],
|
||||||
|
},
|
||||||
|
mp4: {
|
||||||
|
accept: ".mp4",
|
||||||
|
mimeTypes: ["video/mp4"],
|
||||||
|
icon: FileOutlined,
|
||||||
|
name: "MP4视频",
|
||||||
|
extensions: ["mp4"],
|
||||||
|
},
|
||||||
|
avi: {
|
||||||
|
accept: ".avi",
|
||||||
|
mimeTypes: ["video/x-msvideo"],
|
||||||
|
icon: FileOutlined,
|
||||||
|
name: "AVI视频",
|
||||||
|
extensions: ["avi"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// 生成accept字符串
|
||||||
|
const generateAcceptString = () => {
|
||||||
|
const accepts: string[] = [];
|
||||||
|
|
||||||
|
for (const type of acceptTypes) {
|
||||||
|
// 如果是配置中的类型键(如 "word", "pdf")
|
||||||
|
const config = fileTypeConfig[type as keyof typeof fileTypeConfig];
|
||||||
|
if (config) {
|
||||||
|
accepts.push(config.accept);
|
||||||
|
} else {
|
||||||
|
// 如果是扩展名(如 "doc", "docx"),直接添加
|
||||||
|
accepts.push(`.${type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return accepts.filter(Boolean).join(",");
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取文件类型信息
|
||||||
|
const getFileTypeInfo = (file: File) => {
|
||||||
|
const extension = file.name.split(".").pop()?.toLowerCase();
|
||||||
|
if (!extension) return null;
|
||||||
|
|
||||||
|
// 首先尝试通过 acceptTypes 中指定的类型键来查找
|
||||||
|
for (const type of acceptTypes) {
|
||||||
|
const config = fileTypeConfig[type as keyof typeof fileTypeConfig];
|
||||||
|
if (config && config.extensions.includes(extension)) {
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果 acceptTypes 中包含扩展名本身(如 "doc", "docx"),查找所有包含该扩展名的配置
|
||||||
|
if (acceptTypes.includes(extension)) {
|
||||||
|
for (const [key, config] of Object.entries(fileTypeConfig)) {
|
||||||
|
if (config.extensions.includes(extension)) {
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取文件图标
|
||||||
|
const getFileIcon = (file: File) => {
|
||||||
|
const typeInfo = getFileTypeInfo(file);
|
||||||
|
return typeInfo ? typeInfo.icon : FileOutlined;
|
||||||
|
};
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (value) {
|
||||||
|
// 处理单个字符串或字符串数组
|
||||||
|
const urls = Array.isArray(value) ? value : [value];
|
||||||
|
const files: UploadFile[] = urls.map((url, index) => ({
|
||||||
|
uid: `file-${index}`,
|
||||||
|
name: `document-${index + 1}`,
|
||||||
|
status: "done",
|
||||||
|
url: url || "",
|
||||||
|
}));
|
||||||
|
setFileList(files);
|
||||||
|
} else {
|
||||||
|
setFileList([]);
|
||||||
|
}
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
// 获取类型名称
|
||||||
|
const getTypeName = (type: string) => {
|
||||||
|
const config = fileTypeConfig[type as keyof typeof fileTypeConfig];
|
||||||
|
if (config) return config.name;
|
||||||
|
// 如果是扩展名,返回友好的名称
|
||||||
|
const extensionNames: Record<string, string> = {
|
||||||
|
doc: "Word文件",
|
||||||
|
docx: "Word文件",
|
||||||
|
pdf: "PDF文件",
|
||||||
|
txt: "文本文件",
|
||||||
|
md: "Markdown文件",
|
||||||
|
mp4: "MP4视频",
|
||||||
|
avi: "AVI视频",
|
||||||
|
};
|
||||||
|
return extensionNames[type] || `${type.toUpperCase()}文件`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 文件验证
|
||||||
|
const beforeUpload = (file: File) => {
|
||||||
|
const typeInfo = getFileTypeInfo(file);
|
||||||
|
if (!typeInfo) {
|
||||||
|
const allowedTypes = acceptTypes
|
||||||
|
.map(type => getTypeName(type))
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("、");
|
||||||
|
message.error(`只能上传${allowedTypes}!`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLtMaxSize = file.size / 1024 / 1024 < maxSize;
|
||||||
|
if (!isLtMaxSize) {
|
||||||
|
message.error(`文件大小不能超过${maxSize}MB!`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理文件变化
|
||||||
|
const handleChange: UploadProps["onChange"] = info => {
|
||||||
|
// 更新 fileList,确保所有 URL 都是字符串
|
||||||
|
const updatedFileList = info.fileList.map(file => {
|
||||||
|
let url = "";
|
||||||
|
|
||||||
|
if (file.url) {
|
||||||
|
url = file.url;
|
||||||
|
} else if (file.response) {
|
||||||
|
// 处理响应对象
|
||||||
|
if (typeof file.response === "string") {
|
||||||
|
url = file.response;
|
||||||
|
} else if (file.response.data) {
|
||||||
|
url =
|
||||||
|
typeof file.response.data === "string"
|
||||||
|
? file.response.data
|
||||||
|
: file.response.data.url || "";
|
||||||
|
} else if (file.response.url) {
|
||||||
|
url = file.response.url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...file,
|
||||||
|
url: url,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
setFileList(updatedFileList);
|
||||||
|
|
||||||
|
// 处理上传状态
|
||||||
|
if (info.file.status === "uploading") {
|
||||||
|
setLoading(true);
|
||||||
|
// 模拟上传进度
|
||||||
|
const progress = Math.min(99, Math.random() * 100);
|
||||||
|
setUploadProgress(progress);
|
||||||
|
} else if (info.file.status === "done") {
|
||||||
|
setLoading(false);
|
||||||
|
setUploadProgress(100);
|
||||||
|
message.success("文件上传成功!");
|
||||||
|
|
||||||
|
// 从响应中获取上传后的URL
|
||||||
|
let uploadedUrl = "";
|
||||||
|
|
||||||
|
if (info.file.response) {
|
||||||
|
if (typeof info.file.response === "string") {
|
||||||
|
uploadedUrl = info.file.response;
|
||||||
|
} else if (info.file.response.data) {
|
||||||
|
uploadedUrl =
|
||||||
|
typeof info.file.response.data === "string"
|
||||||
|
? info.file.response.data
|
||||||
|
: info.file.response.data.url || "";
|
||||||
|
} else if (info.file.response.url) {
|
||||||
|
uploadedUrl = info.file.response.url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uploadedUrl) {
|
||||||
|
if (maxCount === 1) {
|
||||||
|
// 单个文件模式
|
||||||
|
onChange?.(uploadedUrl);
|
||||||
|
} else {
|
||||||
|
// 多个文件模式
|
||||||
|
const currentUrls = Array.isArray(value)
|
||||||
|
? value
|
||||||
|
: value
|
||||||
|
? [value]
|
||||||
|
: [];
|
||||||
|
const newUrls = [...currentUrls, uploadedUrl];
|
||||||
|
onChange?.(newUrls);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (info.file.status === "error") {
|
||||||
|
setLoading(false);
|
||||||
|
setUploadProgress(0);
|
||||||
|
message.error("上传失败,请重试");
|
||||||
|
} else if (info.file.status === "removed") {
|
||||||
|
if (maxCount === 1) {
|
||||||
|
onChange?.("");
|
||||||
|
} else {
|
||||||
|
// 多个文件模式,移除对应的文件
|
||||||
|
const currentUrls = Array.isArray(value) ? value : value ? [value] : [];
|
||||||
|
const removedIndex = info.fileList.findIndex(
|
||||||
|
f => f.uid === info.file.uid,
|
||||||
|
);
|
||||||
|
if (removedIndex !== -1) {
|
||||||
|
const newUrls = currentUrls.filter(
|
||||||
|
(_, index) => index !== removedIndex,
|
||||||
|
);
|
||||||
|
onChange?.(newUrls);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 删除文件
|
||||||
|
const handleRemove = (file?: UploadFile) => {
|
||||||
|
Modal.confirm({
|
||||||
|
title: "确认删除",
|
||||||
|
content: "确定要删除这个文件吗?",
|
||||||
|
okText: "确定",
|
||||||
|
cancelText: "取消",
|
||||||
|
onOk: () => {
|
||||||
|
if (maxCount === 1) {
|
||||||
|
setFileList([]);
|
||||||
|
onChange?.("");
|
||||||
|
} else if (file) {
|
||||||
|
// 多个文件模式,删除指定文件
|
||||||
|
const currentUrls = Array.isArray(value)
|
||||||
|
? value
|
||||||
|
: value
|
||||||
|
? [value]
|
||||||
|
: [];
|
||||||
|
const fileIndex = fileList.findIndex(f => f.uid === file.uid);
|
||||||
|
if (fileIndex !== -1) {
|
||||||
|
const newUrls = currentUrls.filter(
|
||||||
|
(_, index) => index !== fileIndex,
|
||||||
|
);
|
||||||
|
onChange?.(newUrls);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
message.success("文件已删除");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 预览文件
|
||||||
|
const handlePreview = (url: string) => {
|
||||||
|
setPreviewUrl(url);
|
||||||
|
setPreviewVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取文件大小显示
|
||||||
|
const formatFileSize = (bytes: number) => {
|
||||||
|
if (bytes === 0) return "0 B";
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ["B", "KB", "MB", "GB"];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
||||||
|
};
|
||||||
|
|
||||||
|
// 自定义上传按钮
|
||||||
|
const uploadButton = (
|
||||||
|
<div className={style.fileUploadButton}>
|
||||||
|
{loading ? (
|
||||||
|
<div className={style.uploadingContainer}>
|
||||||
|
<div className={style.uploadingIcon}>
|
||||||
|
<LoadingOutlined spin />
|
||||||
|
</div>
|
||||||
|
<div className={style.uploadingText}>上传中...</div>
|
||||||
|
<Progress
|
||||||
|
percent={uploadProgress}
|
||||||
|
size="small"
|
||||||
|
showInfo={false}
|
||||||
|
strokeColor="#1890ff"
|
||||||
|
className={style.uploadProgress}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={style.uploadContent}>
|
||||||
|
<div className={style.uploadIcon}>
|
||||||
|
<CloudUploadOutlined />
|
||||||
|
</div>
|
||||||
|
<div className={style.uploadText}>
|
||||||
|
<div className={style.uploadTitle}>
|
||||||
|
{maxCount === 1
|
||||||
|
? "上传文档"
|
||||||
|
: `上传文档 (${fileList.length}/${maxCount})`}
|
||||||
|
</div>
|
||||||
|
<div className={style.uploadSubtitle}>
|
||||||
|
支持{" "}
|
||||||
|
{acceptTypes
|
||||||
|
.map(type => getTypeName(type))
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("、")}
|
||||||
|
,最大 {maxSize}MB
|
||||||
|
{maxCount > 1 && `,最多上传 ${maxCount} 个文件`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 自定义文件列表项
|
||||||
|
const customItemRender = (
|
||||||
|
originNode: React.ReactElement,
|
||||||
|
file: UploadFile,
|
||||||
|
) => {
|
||||||
|
const FileIcon = file.originFileObj
|
||||||
|
? getFileIcon(file.originFileObj)
|
||||||
|
: FileOutlined;
|
||||||
|
|
||||||
|
if (file.status === "uploading") {
|
||||||
|
return (
|
||||||
|
<div className={style.fileItem}>
|
||||||
|
<div className={style.fileItemContent}>
|
||||||
|
<div className={style.fileIcon}>
|
||||||
|
<FileIcon />
|
||||||
|
</div>
|
||||||
|
<div className={style.fileInfo}>
|
||||||
|
<div className={style.fileName}>{file.name}</div>
|
||||||
|
<div className={style.fileSize}>
|
||||||
|
{file.size ? formatFileSize(file.size) : "计算中..."}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={style.fileActions}>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
onClick={() => handleRemove(file)}
|
||||||
|
className={style.deleteBtn}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Progress
|
||||||
|
percent={uploadProgress}
|
||||||
|
size="small"
|
||||||
|
strokeColor="#1890ff"
|
||||||
|
className={style.itemProgress}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.status === "done") {
|
||||||
|
return (
|
||||||
|
<div className={style.fileItem}>
|
||||||
|
<div className={style.fileItemContent}>
|
||||||
|
<div className={style.fileIcon}>
|
||||||
|
<FileIcon />
|
||||||
|
</div>
|
||||||
|
<div className={style.fileInfo}>
|
||||||
|
<div className={style.fileName}>{file.name}</div>
|
||||||
|
<div className={style.fileSize}>
|
||||||
|
{file.size ? formatFileSize(file.size) : "未知大小"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={style.fileActions}>
|
||||||
|
{showPreview && (
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<EyeOutlined />}
|
||||||
|
onClick={() => handlePreview(file.url || "")}
|
||||||
|
className={style.previewBtn}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
onClick={() => handleRemove(file)}
|
||||||
|
className={style.deleteBtn}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return originNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
const action = import.meta.env.VITE_API_BASE_URL + "/v1/attachment/upload";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${style.fileUploadContainer} ${className || ""}`}>
|
||||||
|
<Upload
|
||||||
|
name="file"
|
||||||
|
headers={{
|
||||||
|
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||||
|
}}
|
||||||
|
action={action}
|
||||||
|
multiple={maxCount > 1}
|
||||||
|
fileList={fileList}
|
||||||
|
accept={generateAcceptString()}
|
||||||
|
listType="text"
|
||||||
|
showUploadList={{
|
||||||
|
showPreviewIcon: false,
|
||||||
|
showRemoveIcon: false,
|
||||||
|
showDownloadIcon: false,
|
||||||
|
}}
|
||||||
|
disabled={disabled || loading}
|
||||||
|
beforeUpload={beforeUpload}
|
||||||
|
onChange={handleChange}
|
||||||
|
onRemove={handleRemove}
|
||||||
|
maxCount={maxCount}
|
||||||
|
itemRender={customItemRender}
|
||||||
|
>
|
||||||
|
{fileList.length >= maxCount ? null : uploadButton}
|
||||||
|
</Upload>
|
||||||
|
|
||||||
|
{/* 文件预览模态框 */}
|
||||||
|
<Modal
|
||||||
|
title="文件预览"
|
||||||
|
open={previewVisible}
|
||||||
|
onCancel={() => setPreviewVisible(false)}
|
||||||
|
footer={null}
|
||||||
|
width={800}
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<div className={style.filePreview}>
|
||||||
|
<iframe
|
||||||
|
src={previewUrl}
|
||||||
|
style={{ width: "100%", height: "500px", border: "none" }}
|
||||||
|
title="文件预览"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FileUpload;
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
.uploadButtonWrapper {
|
||||||
|
// 使用 :global() 包装 Ant Design 的全局类名
|
||||||
|
:global {
|
||||||
|
.ant-upload-select {
|
||||||
|
// 这里可以修改 .ant-upload-select 的样式
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
span {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
282
Cunkebao/src/components/Upload/FileUploadButton/index.tsx
Normal file
282
Cunkebao/src/components/Upload/FileUploadButton/index.tsx
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { Upload, message, Button } from "antd";
|
||||||
|
import {
|
||||||
|
LoadingOutlined,
|
||||||
|
CloudUploadOutlined,
|
||||||
|
FileExcelOutlined,
|
||||||
|
FileWordOutlined,
|
||||||
|
FilePptOutlined,
|
||||||
|
FileOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
|
import type { UploadProps } from "antd/es/upload/interface";
|
||||||
|
import style from "./index.module.scss";
|
||||||
|
|
||||||
|
export interface FileUploadResult {
|
||||||
|
fileName: string; // 文件名
|
||||||
|
fileUrl: string; // 文件URL
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileUploadProps {
|
||||||
|
onChange?: (result: FileUploadResult) => void; // 上传成功后的回调,返回文件名和URL
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
maxSize?: number; // 最大文件大小(MB)
|
||||||
|
acceptTypes?: string[]; // 接受的文件类型
|
||||||
|
buttonText?: string; // 按钮文本
|
||||||
|
buttonType?: "default" | "primary" | "dashed" | "text" | "link"; // 按钮类型
|
||||||
|
block?: boolean;
|
||||||
|
size?: "small" | "middle" | "large";
|
||||||
|
showSuccessMessage?: boolean; // 是否显示上传成功提示,默认不显示
|
||||||
|
}
|
||||||
|
|
||||||
|
const FileUpload: React.FC<FileUploadProps> = ({
|
||||||
|
onChange,
|
||||||
|
disabled = false,
|
||||||
|
className,
|
||||||
|
maxSize = 10,
|
||||||
|
acceptTypes = ["excel", "word", "ppt"],
|
||||||
|
buttonText = "上传文件",
|
||||||
|
buttonType = "primary",
|
||||||
|
block = false,
|
||||||
|
size = "middle",
|
||||||
|
showSuccessMessage = false,
|
||||||
|
}) => {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [fileName, setFileName] = useState<string>(""); // 保存文件名
|
||||||
|
|
||||||
|
// 文件类型配置
|
||||||
|
const fileTypeConfig = {
|
||||||
|
excel: {
|
||||||
|
accept: ".xlsx,.xls",
|
||||||
|
mimeTypes: [
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
"application/vnd.ms-excel",
|
||||||
|
],
|
||||||
|
icon: FileExcelOutlined,
|
||||||
|
name: "Excel文件",
|
||||||
|
extensions: ["xlsx", "xls"],
|
||||||
|
},
|
||||||
|
word: {
|
||||||
|
accept: ".docx,.doc",
|
||||||
|
mimeTypes: [
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
"application/msword",
|
||||||
|
],
|
||||||
|
icon: FileWordOutlined,
|
||||||
|
name: "Word文件",
|
||||||
|
extensions: ["docx", "doc"],
|
||||||
|
},
|
||||||
|
ppt: {
|
||||||
|
accept: ".pptx,.ppt",
|
||||||
|
mimeTypes: [
|
||||||
|
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||||
|
"application/vnd.ms-powerpoint",
|
||||||
|
],
|
||||||
|
icon: FilePptOutlined,
|
||||||
|
name: "PPT文件",
|
||||||
|
extensions: ["pptx", "ppt"],
|
||||||
|
},
|
||||||
|
pdf: {
|
||||||
|
accept: ".pdf",
|
||||||
|
mimeTypes: ["application/pdf"],
|
||||||
|
icon: FileOutlined,
|
||||||
|
name: "PDF文件",
|
||||||
|
extensions: ["pdf"],
|
||||||
|
},
|
||||||
|
txt: {
|
||||||
|
accept: ".txt",
|
||||||
|
mimeTypes: ["text/plain"],
|
||||||
|
icon: FileOutlined,
|
||||||
|
name: "文本文件",
|
||||||
|
extensions: ["txt"],
|
||||||
|
},
|
||||||
|
doc: {
|
||||||
|
accept: ".doc,.docx",
|
||||||
|
mimeTypes: [
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
"application/msword",
|
||||||
|
],
|
||||||
|
icon: FileWordOutlined,
|
||||||
|
name: "Word文件",
|
||||||
|
extensions: ["doc", "docx"],
|
||||||
|
},
|
||||||
|
docx: {
|
||||||
|
accept: ".docx,.doc",
|
||||||
|
mimeTypes: [
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
"application/msword",
|
||||||
|
],
|
||||||
|
icon: FileWordOutlined,
|
||||||
|
name: "Word文件",
|
||||||
|
extensions: ["docx", "doc"],
|
||||||
|
},
|
||||||
|
md: {
|
||||||
|
accept: ".md",
|
||||||
|
mimeTypes: ["text/markdown"],
|
||||||
|
icon: FileOutlined,
|
||||||
|
name: "Markdown文件",
|
||||||
|
extensions: ["md"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// 生成accept字符串
|
||||||
|
const generateAcceptString = () => {
|
||||||
|
const accepts: string[] = [];
|
||||||
|
|
||||||
|
for (const type of acceptTypes) {
|
||||||
|
// 如果是配置中的类型键(如 "word", "pdf")
|
||||||
|
const config = fileTypeConfig[type as keyof typeof fileTypeConfig];
|
||||||
|
if (config) {
|
||||||
|
accepts.push(config.accept);
|
||||||
|
} else {
|
||||||
|
// 如果是扩展名(如 "doc", "docx"),直接添加
|
||||||
|
accepts.push(`.${type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return accepts.filter(Boolean).join(",");
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取文件类型信息
|
||||||
|
const getFileTypeInfo = (file: File) => {
|
||||||
|
const extension = file.name.split(".").pop()?.toLowerCase();
|
||||||
|
if (!extension) return null;
|
||||||
|
|
||||||
|
// 首先尝试通过 acceptTypes 中指定的类型键来查找
|
||||||
|
for (const type of acceptTypes) {
|
||||||
|
const config = fileTypeConfig[type as keyof typeof fileTypeConfig];
|
||||||
|
if (config && config.extensions.includes(extension)) {
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果 acceptTypes 中包含扩展名本身(如 "doc", "docx"),查找所有包含该扩展名的配置
|
||||||
|
if (acceptTypes.includes(extension)) {
|
||||||
|
for (const [, config] of Object.entries(fileTypeConfig)) {
|
||||||
|
if (config.extensions.includes(extension)) {
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取类型名称
|
||||||
|
const getTypeName = (type: string) => {
|
||||||
|
const config = fileTypeConfig[type as keyof typeof fileTypeConfig];
|
||||||
|
if (config) return config.name;
|
||||||
|
// 如果是扩展名,返回友好的名称
|
||||||
|
const extensionNames: Record<string, string> = {
|
||||||
|
doc: "Word文件",
|
||||||
|
docx: "Word文件",
|
||||||
|
pdf: "PDF文件",
|
||||||
|
txt: "文本文件",
|
||||||
|
md: "Markdown文件",
|
||||||
|
};
|
||||||
|
return extensionNames[type] || `${type.toUpperCase()}文件`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 文件验证
|
||||||
|
const beforeUpload = (file: File) => {
|
||||||
|
// 保存文件名
|
||||||
|
setFileName(file.name);
|
||||||
|
|
||||||
|
const typeInfo = getFileTypeInfo(file);
|
||||||
|
if (!typeInfo) {
|
||||||
|
const allowedTypes = acceptTypes
|
||||||
|
.map(type => getTypeName(type))
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("、");
|
||||||
|
message.error(`只能上传${allowedTypes}!`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLtMaxSize = file.size / 1024 / 1024 < maxSize;
|
||||||
|
if (!isLtMaxSize) {
|
||||||
|
message.error(`文件大小不能超过${maxSize}MB!`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理文件变化
|
||||||
|
const handleChange: UploadProps["onChange"] = info => {
|
||||||
|
// 处理上传状态
|
||||||
|
if (info.file.status === "uploading") {
|
||||||
|
setLoading(true);
|
||||||
|
} else if (info.file.status === "done") {
|
||||||
|
setLoading(false);
|
||||||
|
if (showSuccessMessage) {
|
||||||
|
message.success("文件上传成功!");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从响应中获取上传后的URL
|
||||||
|
let uploadedUrl = "";
|
||||||
|
|
||||||
|
if (info.file.response) {
|
||||||
|
if (typeof info.file.response === "string") {
|
||||||
|
uploadedUrl = info.file.response;
|
||||||
|
} else if (info.file.response.data) {
|
||||||
|
uploadedUrl =
|
||||||
|
typeof info.file.response.data === "string"
|
||||||
|
? info.file.response.data
|
||||||
|
: info.file.response.data.url || "";
|
||||||
|
} else if (info.file.response.url) {
|
||||||
|
uploadedUrl = info.file.response.url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取文件名,优先使用保存的文件名,如果没有则使用文件对象的名称
|
||||||
|
const finalFileName = fileName || info.file.name || "";
|
||||||
|
|
||||||
|
if (uploadedUrl && finalFileName) {
|
||||||
|
onChange?.({
|
||||||
|
fileName: finalFileName,
|
||||||
|
fileUrl: uploadedUrl,
|
||||||
|
});
|
||||||
|
// 清空保存的文件名,为下次上传做准备
|
||||||
|
setFileName("");
|
||||||
|
}
|
||||||
|
} else if (info.file.status === "error") {
|
||||||
|
setLoading(false);
|
||||||
|
message.error("上传失败,请重试");
|
||||||
|
// 清空保存的文件名
|
||||||
|
setFileName("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const action = import.meta.env.VITE_API_BASE_URL + "/v1/attachment/upload";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={style.uploadButtonWrapper}>
|
||||||
|
<Upload
|
||||||
|
name="file"
|
||||||
|
headers={{
|
||||||
|
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||||
|
}}
|
||||||
|
action={action}
|
||||||
|
accept={generateAcceptString()}
|
||||||
|
showUploadList={false}
|
||||||
|
disabled={disabled || loading}
|
||||||
|
beforeUpload={beforeUpload}
|
||||||
|
onChange={handleChange}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type={buttonType}
|
||||||
|
icon={loading ? <LoadingOutlined /> : <CloudUploadOutlined />}
|
||||||
|
loading={loading}
|
||||||
|
disabled={disabled}
|
||||||
|
className={style.uploadButton}
|
||||||
|
block
|
||||||
|
size={size}
|
||||||
|
>
|
||||||
|
{buttonText}
|
||||||
|
</Button>
|
||||||
|
</Upload>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FileUpload;
|
||||||
141
Cunkebao/src/components/Upload/ImageUpload/ImageUpload.tsx
Normal file
141
Cunkebao/src/components/Upload/ImageUpload/ImageUpload.tsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
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("上传成功");
|
||||||
|
// 确保返回的是字符串URL
|
||||||
|
let url = "";
|
||||||
|
if (typeof result.data === "string") {
|
||||||
|
url = result.data;
|
||||||
|
} else if (result.data && typeof result.data === "object") {
|
||||||
|
url = result.data.url || "";
|
||||||
|
}
|
||||||
|
return { url };
|
||||||
|
} 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;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user