私域操盘手 - 移除微信号账号详情的好友列表的包裹层组件,防止标签过多页面变形

This commit is contained in:
柳清爽
2025-05-16 12:03:25 +08:00
parent 1a9a7e8442
commit b8dbd93c89
8 changed files with 162 additions and 490 deletions

View File

@@ -21,7 +21,7 @@ export default function RootLayout({
}) {
return (
<html lang="zh-CN" suppressHydrationWarning>
<body className="bg-gray-100">
<body className="font-sans">
<AuthProvider>
<AuthCheck>
<ErrorBoundary>

View File

@@ -527,31 +527,33 @@ export default function WechatAccountDetailPage() {
// 获取账号概览数据
const fetchSummaryData = useCallback(async () => {
if (!account?.wechatId || isLoading) return;
try {
setIsLoading(true);
const response = await fetchWechatAccountSummary(account?.wechatId || '');
const response = await fetchWechatAccountSummary(account.wechatId);
if (response.code === 200) {
setAccountSummary(response.data);
} else {
toast({
} else {
toast({
title: "获取账号概览失败",
description: response.msg || "请稍后再试",
variant: "destructive"
});
}
} catch (error) {
console.error("获取账号概览失败:", error);
toast({
title: "获取账号概览失败",
description: "请检查网络连接或稍后再试",
variant: "destructive"
});
}
} catch (error) {
console.error("获取账号概览失败:", error);
toast({
title: "获取账号概览失败",
description: "请检查网络连接或稍后再试",
variant: "destructive"
});
} finally {
} finally {
setIsLoading(false);
}
}, [account]);
}, [account?.wechatId]);
// 在页面加载时获取账号概览数据
// 统一在账号数据加载完成后获取概览数据
useEffect(() => {
if (account?.wechatId) {
fetchSummaryData();
@@ -724,7 +726,7 @@ export default function WechatAccountDetailPage() {
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="overview"></TabsTrigger>
<TabsTrigger value="friends">
{activeTab === "friends" && friendsTotal > 0 ? ` (${friendsTotal})` : ''}
{activeTab === "friends" && friendsTotal > 0 ? ` (${friendsTotal.toLocaleString()})` : ''}
</TabsTrigger>
</TabsList>
@@ -849,7 +851,7 @@ export default function WechatAccountDetailPage() {
</div>
<div className="text-sm text-gray-500">
({accountSummary.accountWeight.scope}){" "}
<span className="font-medium text-blue-600">{accountSummary.statistics.addLimit}</span>{" "}
<span className="font-medium text-blue-600">{accountSummary.statistics.addLimit.toLocaleString()}</span>{" "}
</div>
</div>
@@ -886,110 +888,80 @@ export default function WechatAccountDetailPage() {
</Card>
</TabsContent>
<TabsContent value="friends" className="space-y-4 mt-4">
<Card className="p-4">
<div className="space-y-4">
{/* 搜索栏 */}
<div className="flex items-center space-x-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-gray-400" />
<Input
placeholder="搜索好友昵称/微信号/备注/标签"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
className="pl-9"
/>
</div>
<Button variant="outline" size="icon" onClick={handleSearch}>
<Filter className="h-4 w-4" />
</Button>
<TabsContent value="friends">
<div className="space-y-4">
{/* 搜索栏 */}
<div className="flex items-center space-x-2 bg-white p-4 rounded-lg shadow-sm">
<div className="relative flex-1">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-gray-400" />
<Input
placeholder="搜索好友昵称/微信号/备注/标签"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
className="pl-9 bg-white border-gray-200 focus:border-blue-500"
/>
</div>
<Button variant="outline" size="icon" onClick={handleSearch} className="bg-white hover:bg-gray-50">
<Filter className="h-4 w-4" />
</Button>
</div>
{/* 好友列表 */}
<div
ref={friendsContainerRef}
className="space-y-2 transition-all duration-300"
style={{
minHeight: '80px',
height: `${getFriendsContainerHeight()}px`,
overflowY: 'auto'
}}
>
{isFetchingFriends && friends.length === 0 ? (
<div className="flex justify-center items-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-blue-500" />
</div>
) : friends.length === 0 && hasFriendLoadError ? (
<div className="text-center py-8 text-gray-500">
<p></p>
<Button variant="outline" size="sm" className="mt-2" onClick={() => fetchFriends(1, true)}>
</Button>
</div>
) : friends.length === 0 ? (
<div className="text-center py-8 text-gray-500"></div>
) : (
<>
{friends.map((friend) => (
{/* 好友列表 */}
<div
ref={friendsContainerRef}
className="space-y-2 min-h-[200px]"
style={{ height: getFriendsContainerHeight() }}
>
{isFetchingFriends && friends.length === 0 ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
</div>
) : friends.length === 0 ? (
<div className="text-center py-8 text-gray-500"></div>
) : (
<>
{friends.map((friend) => (
<div
key={friend.id}
className="flex items-center p-3 border rounded-lg hover:bg-gray-50 cursor-pointer"
className="flex items-center p-3 bg-white border rounded-lg hover:bg-gray-50 cursor-pointer transition-colors duration-200"
onClick={() => handleFriendClick(friend)}
>
<Avatar className="h-10 w-10 mr-3">
<AvatarImage src={friend.avatar} />
<AvatarFallback>{friend.nickname?.[0] || 'U'}</AvatarFallback>
<AvatarFallback>{friend.nickname?.[0] || 'U'}</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<div className="font-medium truncate max-w-[180px]">
<div className="font-medium truncate max-w-[180px]">
{friend.nickname}
{friend.remark && <span className="text-gray-500 ml-1 truncate">({friend.remark})</span>}
{friend.remark && <span className="text-gray-500 ml-1 truncate">({friend.remark})</span>}
</div>
<ChevronRight className="h-4 w-4 text-gray-400" />
</div>
<div className="text-sm text-gray-500 truncate">{friend.wechatId}</div>
<div className="flex flex-wrap gap-1 mt-1">
{friend.tags.slice(0, 3).map((tag: FriendTag) => (
<span key={tag.id} className={`text-xs px-2 py-0.5 rounded-full ${tag.color}`}>
{tag.name}
{friend.tags?.map((tag, index) => (
<span
key={index}
className="inline-flex items-center px-2 py-0.5 rounded text-xs bg-blue-100 text-blue-800"
>
{typeof tag === 'string' ? tag : tag.name}
</span>
))}
{friend.tags.length > 3 && (
<span className="text-xs px-2 py-0.5 rounded-full bg-gray-100 text-gray-800">
+{friend.tags.length - 3}
</span>
)}
</div>
</div>
</div>
))}
{/* 懒加载指示器 */}
{hasMoreFriends && (
<div ref={friendsLoadingRef} className="py-4 flex justify-center">
{isFetchingFriends && <Loader2 className="h-6 w-6 animate-spin text-blue-500" />}
</div>
)}
</>
)}
</div>
{/* 显示加载状态和总数 */}
<div className="text-sm text-gray-500 text-center">
{friendsTotal > 0 ? (
<span>
{Math.min(friends.length, friendsTotal)} / {friendsTotal}
</span>
) : !isFetchingFriends && !hasFriendLoadError && account ? (
<span>
{account.friendCount}
</span>
) : null}
</div>
))}
{hasMoreFriends && (
<div ref={friendsLoadingRef} className="flex justify-center py-4">
<Loader2 className="h-6 w-6 animate-spin text-blue-500" />
</div>
)}
</>
)}
</div>
</Card>
</div>
</TabsContent>
</Tabs>

View File

@@ -0,0 +1,43 @@
"use client"
import { useEffect } from 'react'
import { useRouter, usePathname } from 'next/navigation'
import { useAuth } from '@/hooks/useAuth'
// 不需要登录的公共页面路径
const PUBLIC_PATHS = [
'/login',
'/register',
'/forgot-password',
'/reset-password',
'/404',
'/500'
]
export function AuthCheck({ children }: { children: React.ReactNode }) {
const router = useRouter()
const pathname = usePathname()
const { isAuthenticated, isLoading } = useAuth()
useEffect(() => {
if (!isLoading && !isAuthenticated && !PUBLIC_PATHS.includes(pathname)) {
// 保存当前URL登录后可以重定向回来
const returnUrl = encodeURIComponent(window.location.href)
router.push(`/login?returnUrl=${returnUrl}`)
}
}, [isAuthenticated, isLoading, pathname, router])
if (isLoading) {
return (
<div className="flex h-screen w-screen items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-blue-500"></div>
</div>
)
}
if (!isAuthenticated && !PUBLIC_PATHS.includes(pathname)) {
return null
}
return <>{children}</>
}

46
Cunkebao/hooks/useAuth.ts Normal file
View File

@@ -0,0 +1,46 @@
import { useState, useEffect } from 'react'
export interface AuthState {
isAuthenticated: boolean
isLoading: boolean
user: any | null
}
export function useAuth(): AuthState {
const [state, setState] = useState<AuthState>({
isAuthenticated: false,
isLoading: true,
user: null
})
useEffect(() => {
const checkAuth = async () => {
try {
// 检查本地存储的token
const token = localStorage.getItem('token')
if (!token) {
setState({ isAuthenticated: false, isLoading: false, user: null })
return
}
// TODO: 这里可以添加token验证的API调用
// const response = await validateToken(token)
// if (response.valid) {
// setState({ isAuthenticated: true, isLoading: false, user: response.user })
// } else {
// setState({ isAuthenticated: false, isLoading: false, user: null })
// }
// 临时仅检查token存在性
setState({ isAuthenticated: true, isLoading: false, user: { token } })
} catch (error) {
console.error('Auth check failed:', error)
setState({ isAuthenticated: false, isLoading: false, user: null })
}
}
checkAuth()
}, [])
return state
}

View File

@@ -22,6 +22,9 @@ const nextConfig = {
parallelServerCompiles: true,
},
reactStrictMode: false,
compiler: {
styledComponents: true,
},
}
mergeConfig(nextConfig, userConfig)

View File

@@ -1,39 +0,0 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>恭喜,站点创建成功!</title>
<style>
.container {
width: 60%;
margin: 10% auto 0;
background-color: #f0f0f0;
padding: 2% 5%;
border-radius: 10px
}
ul {
padding-left: 20px;
}
ul li {
line-height: 2.3
}
a {
color: #20a53a
}
</style>
</head>
<body>
<div class="container">
<h1>恭喜, 站点创建成功!</h1>
<h3>这是默认index.html本页面由系统自动生成</h3>
<ul>
<li>本页面在FTP根目录下的index.html</li>
<li>您可以修改、删除或覆盖本页面</li>
<li>FTP相关信息请到“面板系统后台 > FTP” 查看</li>
</ul>
</div>
</body>
</html>

View File

@@ -1,180 +0,0 @@
# 定时任务和队列使用说明
## 一、环境准备
### 1. 安装Redis
确保服务器已安装Redis并正常运行。
```bash
# Ubuntu/Debian系统
sudo apt-get update
sudo apt-get install redis-server
# CentOS系统
sudo yum install redis
sudo systemctl start redis
```
### 2. 安装PHP的Redis扩展
```bash
sudo pecl install redis
```
### 3. 修改队列配置
编辑 `config/queue.php` 文件根据实际环境修改Redis连接信息。
## 二、系统组件说明
### 1. 命令行任务
已实现的命令行任务:
- `device:list`: 获取设备列表命令
### 2. 队列任务
已实现的队列任务:
- `DeviceListJob`: 处理设备列表获取并自动翻页的任务
## 三、配置步骤
### 1. 确保API连接信息正确
`.env` 文件中确保以下配置正确:
- `api.wechat_url`: API基础URL地址
- `api.username`: API登录用户名
- `api.password`: API登录密码
## 四、系统架构说明
### 1. 授权服务
系统使用了公共授权服务 `app\common\service\AuthService`用于管理API授权信息
- 提供静态方法 `getSystemAuthorization()`,可被所有定时任务和控制器复用
- 从环境变量(.env文件中读取API连接信息
- 自动缓存授权Token有效期为10分钟避免频繁请求API
- 缓存失效后自动重新获取授权信息
### 2. 队列任务
队列任务统一放置在 `application/job` 目录中:
- 目前已实现 `DeviceListJob` 用于获取设备列表
- 每个任务类需实现 `fire` 方法来处理队列任务
- 任务可以通过 `think queue:work` 命令处理
- 失败任务会自动重试最多3次
### 3. 定时命令
定时命令统一放置在 `application/common/command` 目录中:
- 继承自 `BaseCommand` 基类
- 需要在 `application/command.php` 中注册命令
- 可通过 `php think` 命令调用
## 五、配置定时任务
### 1. 编辑crontab配置
```bash
crontab -e
```
### 2. 直接配置PHP命令执行定时任务
```
# 每5分钟执行一次设备列表获取任务
*/5 * * * * cd /www/wwwroot/yi.54iis.com && php think device:list >> /www/wwwroot/yi.54iis.com/logs/device_list.log 2>&1
```
说明:
- `cd /www/wwwroot/yi.54iis.com`: 切换到项目目录
- `php think device:list`: 执行设备列表命令
- `>> /www/wwwroot/yi.54iis.com/logs/device_list.log 2>&1`: 将输出和错误信息追加到日志文件
### 3. 创建日志目录
```bash
# 确保日志目录存在
mkdir -p /www/wwwroot/yi.54iis.com/logs
chmod 755 /www/wwwroot/yi.54iis.com/logs
```
## 六、配置队列处理进程
### 1. 使用crontab监控队列进程
```
# 每分钟检查队列进程,如果不存在则启动
* * * * * ps aux | grep "php think queue:work" | grep -v grep > /dev/null || (cd /www/wwwroot/yi.54iis.com && nohup php think queue:work --queue device_list --tries 3 --sleep 3 >> /www/wwwroot/yi.54iis.com/logs/queue_worker.log 2>&1 &)
```
说明:
- `ps aux | grep "php think queue:work" | grep -v grep > /dev/null`: 检查队列进程是否存在
- `||`: 如果前面的命令失败(进程不存在),则执行后面的命令
- `(cd /www/wwwroot/yi.54iis.com && nohup...)`: 进入项目目录并启动队列处理进程
### 2. 或者使用supervisor管理队列进程推荐
如果服务器上安装了supervisor可以创建配置文件 `/etc/supervisor/conf.d/device_queue.conf`
```ini
[program:device_queue]
process_name=%(program_name)s_%(process_num)02d
command=php /www/wwwroot/yi.54iis.com/think queue:work --queue device_list --tries 3 --sleep 3
autostart=true
autorestart=true
user=www
numprocs=1
redirect_stderr=true
stdout_logfile=/www/wwwroot/yi.54iis.com/logs/queue_worker.log
```
然后重新加载supervisor配置
```bash
sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl start device_queue:*
```
## 七、测试
### 1. 手动执行命令
```bash
# 进入项目目录
cd /www/wwwroot/yi.54iis.com
# 执行设备列表获取命令
php think device:list
```
### 2. 查看日志
```bash
# 查看定时任务日志
cat /www/wwwroot/yi.54iis.com/logs/device_list.log
# 查看队列处理日志
cat /www/wwwroot/yi.54iis.com/logs/queue_worker.log
```
## 八、新增定时任务流程
如需添加新的定时任务,请按照以下步骤操作:
1.`application/common/command` 目录下创建新的命令类
2.`application/job` 目录下创建对应的队列任务处理类
3.`application/command.php` 文件中注册新命令
4. 更新crontab配置添加新的命令执行计划
示例添加一个每天凌晨2点执行的数据备份任务
```
0 2 * * * cd /www/wwwroot/yi.54iis.com && php think backup:data >> /www/wwwroot/yi.54iis.com/logs/backup_data.log 2>&1
```
新增的定时任务可直接使用 `AuthService::getSystemAuthorization()` 获取授权信息,无需重复实现授权逻辑。
## 九、注意事项
1. 确保PHP命令可以正常执行如果默认PHP版本不匹配可能需要使用完整路径例如 `/www/server/php/74/bin/php`
2. 确保Redis服务正常运行
3. 确保API连接信息配置正确
4. 确保日志目录存在且有写入权限
5. 定时任务执行用户需要有项目目录的读写权限
6. 如果使用宝塔面板可以在【计划任务】中配置上述crontab任务

View File

@@ -1,173 +0,0 @@
<?php
/**
* 菜单表初始化脚本
* 执行该脚本,会创建菜单表并插入初始菜单数据
* 执行方式: php init-menu.php
*/
// 定义应用目录
define('APP_PATH', __DIR__ . '/../application/');
define('RUNTIME_PATH', __DIR__ . '/../runtime/');
define('ROOT_PATH', __DIR__ . '/../');
// 加载框架引导文件
require __DIR__ . '/../thinkphp/base.php';
// 加载环境变量
use think\facade\Env;
$rootPath = realpath(__DIR__ . '/../');
Env::load($rootPath . '/.env');
// 读取数据库配置
$dbConfig = [
'type' => Env::get('database.type', 'mysql'),
'hostname' => Env::get('database.hostname', '127.0.0.1'),
'database' => Env::get('database.database', 'database'),
'username' => Env::get('database.username', 'root'),
'password' => Env::get('database.password', 'root'),
'hostport' => Env::get('database.hostport', '3306'),
'charset' => Env::get('database.charset', 'utf8mb4'),
'prefix' => Env::get('database.prefix', 'tk_'),
];
// 连接数据库
try {
$dsn = "{$dbConfig['type']}:host={$dbConfig['hostname']};port={$dbConfig['hostport']};dbname={$dbConfig['database']};charset={$dbConfig['charset']}";
$pdo = new PDO($dsn, $dbConfig['username'], $dbConfig['password']);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
echo "数据库连接成功!\n";
} catch (PDOException $e) {
die("数据库连接失败: " . $e->getMessage() . "\n");
}
// 创建菜单表SQL
$createTableSql = "
CREATE TABLE IF NOT EXISTS `{$dbConfig['prefix']}menus` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '菜单ID',
`title` varchar(50) NOT NULL COMMENT '菜单名称',
`path` varchar(100) NOT NULL COMMENT '路由路径',
`icon` varchar(50) DEFAULT NULL COMMENT '图标名称',
`parent_id` int(11) NOT NULL DEFAULT '0' COMMENT '父菜单ID0表示顶级菜单',
`status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '状态1启用0禁用',
`sort` int(11) NOT NULL DEFAULT '0' COMMENT '排序,数值越小越靠前',
`create_time` int(11) DEFAULT NULL COMMENT '创建时间',
`update_time` int(11) DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_parent_id` (`parent_id`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统菜单表';
";
// 执行创建表SQL
try {
$pdo->exec($createTableSql);
echo "菜单表创建成功!\n";
} catch (PDOException $e) {
echo "菜单表创建失败: " . $e->getMessage() . "\n";
}
// 检查表中是否已有数据
$checkSql = "SELECT COUNT(*) FROM `{$dbConfig['prefix']}menus`";
try {
$count = $pdo->query($checkSql)->fetchColumn();
if ($count > 0) {
echo "菜单表中已有 {$count} 条数据,跳过数据初始化\n";
exit(0);
}
} catch (PDOException $e) {
echo "检查数据失败: " . $e->getMessage() . "\n";
exit(1);
}
// 插入顶级菜单数据
$topMenus = [
['仪表盘', '/dashboard', 'LayoutDashboard', 0, 1, 10],
['项目管理', '/dashboard/projects', 'FolderKanban', 0, 1, 20],
['客户池', '/dashboard/customers', 'Users', 0, 1, 30],
['管理员权限', '/dashboard/admins', 'Settings', 0, 1, 40],
['系统设置', '/settings', 'Cog', 0, 1, 50],
];
$insertTopMenuSql = "INSERT INTO `{$dbConfig['prefix']}menus`
(`title`, `path`, `icon`, `parent_id`, `status`, `sort`, `create_time`, `update_time`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
$timestamp = time();
$insertStmt = $pdo->prepare($insertTopMenuSql);
$pdo->beginTransaction();
try {
foreach ($topMenus as $index => $menu) {
$insertStmt->execute([
$menu[0], // title
$menu[1], // path
$menu[2], // icon
$menu[3], // parent_id
$menu[4], // status
$menu[5], // sort
$timestamp,
$timestamp
]);
}
$pdo->commit();
echo "顶级菜单数据插入成功!\n";
} catch (PDOException $e) {
$pdo->rollBack();
echo "顶级菜单数据插入失败: " . $e->getMessage() . "\n";
exit(1);
}
// 查询刚插入的顶级菜单ID
$menuIds = [];
$queryTopMenuSql = "SELECT id, title FROM `{$dbConfig['prefix']}menus` WHERE parent_id = 0";
try {
$topMenusResult = $pdo->query($queryTopMenuSql)->fetchAll(PDO::FETCH_ASSOC);
foreach ($topMenusResult as $menu) {
$menuIds[$menu['title']] = $menu['id'];
}
} catch (PDOException $e) {
echo "查询顶级菜单失败: " . $e->getMessage() . "\n";
exit(1);
}
// 插入子菜单数据
$subMenus = [
['项目列表', '/dashboard/projects', 'List', $menuIds['项目管理'], 1, 21],
['新建项目', '/dashboard/projects/new', 'PlusCircle', $menuIds['项目管理'], 1, 22],
['客户管理', '/dashboard/customers', 'Users', $menuIds['客户池'], 1, 31],
['客户分析', '/dashboard/customers/analytics', 'BarChart', $menuIds['客户池'], 1, 32],
['管理员列表', '/dashboard/admins', 'UserCog', $menuIds['管理员权限'], 1, 41],
['角色管理', '/dashboard/admins/roles', 'ShieldCheck', $menuIds['管理员权限'], 1, 42],
['权限设置', '/dashboard/admins/permissions', 'Lock', $menuIds['管理员权限'], 1, 43],
['基本设置', '/settings/general', 'Settings', $menuIds['系统设置'], 1, 51],
['安全设置', '/settings/security', 'Shield', $menuIds['系统设置'], 1, 52],
];
$insertSubMenuSql = "INSERT INTO `{$dbConfig['prefix']}menus`
(`title`, `path`, `icon`, `parent_id`, `status`, `sort`, `create_time`, `update_time`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
$pdo->beginTransaction();
try {
$insertStmt = $pdo->prepare($insertSubMenuSql);
foreach ($subMenus as $menu) {
$insertStmt->execute([
$menu[0], // title
$menu[1], // path
$menu[2], // icon
$menu[3], // parent_id
$menu[4], // status
$menu[5], // sort
$timestamp,
$timestamp
]);
}
$pdo->commit();
echo "子菜单数据插入成功!\n";
} catch (PDOException $e) {
$pdo->rollBack();
echo "子菜单数据插入失败: " . $e->getMessage() . "\n";
exit(1);
}
echo "菜单初始化完成!\n";