格式化代码,websocket功能完善

This commit is contained in:
2026-02-18 21:50:05 +08:00
parent 6543e2ccdd
commit b6c133952b
101 changed files with 15829 additions and 10739 deletions
+60 -1
View File
@@ -3,6 +3,8 @@
namespace App\Services\Auth;
use App\Models\Auth\User;
use App\Models\System\Notification;
use App\Services\System\NotificationService;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
@@ -16,10 +18,12 @@
class UserService
{
protected $departmentService;
protected $notificationService;
public function __construct(DepartmentService $departmentService)
public function __construct(DepartmentService $departmentService, NotificationService $notificationService)
{
$this->departmentService = $departmentService;
$this->notificationService = $notificationService;
}
/**
@@ -29,6 +33,7 @@ protected function getCurrentUserId(): int
{
return Auth::guard('admin')->id();
}
/**
* 获取用户列表
*/
@@ -215,6 +220,12 @@ public function update(int $id, array $data): User
}
DB::commit();
// 发送更新通知(如果更新的是其他用户)
// if ($id !== $this->getCurrentUserId()) {
$this->sendUserUpdateNotification($user, $data);
// }
return $user;
} catch (\Exception $e) {
DB::rollBack();
@@ -352,4 +363,52 @@ private function formatUserInfo(User $user): array
'updated_at' => $user->updated_at->toDateTimeString(),
];
}
/**
* 发送用户更新通知
*/
private function sendUserUpdateNotification(User $user, array $data): void
{
// 收集被更新的字段
$changes = [];
$fieldLabels = [
'username' => '用户名',
'real_name' => '姓名',
'email' => '邮箱',
'phone' => '手机号',
'department_id' => '所属部门',
'avatar' => '头像',
'status' => '状态',
'password' => '密码',
'role_ids' => '角色',
];
foreach ($data as $key => $value) {
if (isset($fieldLabels[$key])) {
$changes[] = $fieldLabels[$key];
}
}
if (empty($changes)) {
return;
}
// 生成通知内容
$content = '您的账户信息已被管理员更新,更新的内容:' . implode('、', $changes);
// 发送通知
$this->notificationService->sendToUser(
$user->id,
'个人信息已更新',
$content,
Notification::TYPE_INFO,
Notification::CATEGORY_SYSTEM,
[
'user_id' => $user->id,
'updated_fields' => $changes,
'action_type' => Notification::ACTION_NONE,
]
);
}
}
+442 -425
View File
@@ -3,513 +3,530 @@
namespace App\Services\WebSocket;
use Hhxsv5\LaravelS\Swoole\WebSocketHandlerInterface;
use Illuminate\Support\Facades\Log;
use Swoole\Http\Request;
use Swoole\Http\Response;
use Swoole\WebSocket\Frame;
use Swoole\WebSocket\Server;
use Illuminate\Support\Facades\Log;
use App\Services\Auth\UserOnlineService;
use Tymon\JWTAuth\Facades\JWTAuth;
/**
* WebSocket Handler
* WebSocket 处理器
*
* Handles WebSocket connections, messages, and disconnections
* 处理 WebSocket 连接事件:onOpen, onMessage, onClose
*/
class WebSocketHandler implements WebSocketHandlerInterface
{
/**
* @var UserOnlineService
*/
protected $userOnlineService;
/**
* Get wsTable instance
*
* @return \Swoole\Table
*/
protected function getWsTable(): \Swoole\Table
{
return app('swoole')->wsTable;
}
/**
* WebSocketHandler constructor
* WebSocketHandlerInterface 需要的构造函数
* wsTable 直接从 handler 方法的 $server 参数中访问
*/
public function __construct()
{
$this->userOnlineService = app(UserOnlineService::class);
// 空构造函数 - wsTable 从 server 参数中访问
}
/**
* Handle WebSocket connection open event
* 处理 WebSocket 握手(可选)
*
* @param Server $server
* @param Request $request
* @param Request $request 请求对象
* @param Response $response 响应对象
* @return void
*/
// public function onHandShake(Request $request, Response $response)
// {
// // 自定义握手逻辑(如果需要)
// // 握手成功后,onOpen 事件会自动触发
// }
/**
* 处理连接打开事件
*
* @param Server $server WebSocket 服务器对象
* @param Request $request 请求对象
* @return void
*/
public function onOpen(Server $server, Request $request): void
{
try {
$fd = $request->fd;
$path = $request->server['path_info'] ?? $request->server['request_uri'] ?? '/';
// 从服务器获取 wsTable
$wsTable = $server->wsTable;
Log::info('WebSocket connection opened', [
'fd' => $fd,
'path' => $path,
'ip' => $request->server['remote_addr'] ?? 'unknown'
]);
// 从查询字符串获取 user_id 和 token
$userId = (int)($request->get['user_id'] ?? 0);
$token = $request->get['token'] ?? '';
// Extract user ID from query parameters if provided
$userId = $request->get['user_id'] ?? null;
$token = $request->get['token'] ?? null;
if ($userId && $token) {
// Store user connection mapping
$this->getWsTable()->set('uid:' . $userId, [
'value' => $fd,
'expiry' => time() + 3600, // 1 hour expiry
]);
$this->getWsTable()->set('fd:' . $fd, [
'value' => $userId,
'expiry' => time() + 3600
]);
// Update user online status
$this->userOnlineService->updateUserOnlineStatus($userId, $fd, true);
Log::info('User connected to WebSocket', [
'user_id' => $userId,
'fd' => $fd
]);
// Send welcome message to client
$server->push($fd, json_encode([
'type' => 'welcome',
'data' => [
'message' => 'WebSocket connection established',
'user_id' => $userId,
'timestamp' => time()
]
]));
} else {
Log::warning('WebSocket connection without authentication', [
'fd' => $fd
]);
// Send error message
$server->push($fd, json_encode([
// 用户认证
if (!$userId || !$token) {
$server->push($request->fd, json_encode([
'type' => 'error',
'data' => [
'message' => 'Authentication required. Please provide user_id and token.',
'message' => '认证失败:缺少 user_id token',
'code' => 401
]
]));
$server->disconnect($request->fd);
return;
}
} catch (\Exception $e) {
Log::error('WebSocket onOpen error', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
// 验证 JWT token
try {
$payload = JWTAuth::setToken($token)->getPayload();
// 验证 token 中的用户 ID 是否匹配
$tokenUserId = $payload['sub'] ?? null;
if ($tokenUserId != $userId) {
Log::warning('WebSocket 认证失败:用户 ID 不匹配', [
'fd' => $request->fd,
'token_user_id' => $tokenUserId,
'query_user_id' => $userId
]);
$server->push($request->fd, json_encode([
'type' => 'error',
'data' => [
'message' => '认证失败:用户 ID 不匹配',
'code' => 401
]
]));
$server->disconnect($request->fd);
return;
}
// 验证 token 是否过期
if (isset($payload['exp']) && $payload['exp'] < time()) {
Log::warning('WebSocket 认证失败:token 已过期', [
'fd' => $request->fd,
'user_id' => $userId,
'exp' => $payload['exp'],
'current_time' => time()
]);
$server->push($request->fd, json_encode([
'type' => 'error',
'data' => [
'message' => '认证失败:token 已过期',
'code' => 401
]
]));
$server->disconnect($request->fd);
return;
}
Log::info('WebSocket 认证成功', [
'fd' => $request->fd,
'user_id' => $userId
]);
} catch (\Exception $e) {
Log::warning('WebSocket 认证失败:无效的 token', [
'fd' => $request->fd,
'user_id' => $userId,
'error' => $e->getMessage()
]);
$server->push($request->fd, json_encode([
'type' => 'error',
'data' => [
'message' => '认证失败:无效的 token',
'code' => 401
]
]));
$server->disconnect($request->fd);
return;
}
// 存储连接映射:uid:{userId} -> fd
$wsTable->set('uid:' . $userId, [
'value' => $request->fd,
'expiry' => time() + 3600 // 1 小时过期
]);
// 存储反向映射:fd:{fd} -> userId
$wsTable->set('fd:' . $request->fd, [
'value' => $userId,
'expiry' => time() + 3600
]);
// 发送欢迎消息
$server->push($request->fd, json_encode([
'type' => 'connected',
'data' => [
'message' => '欢迎连接到 LaravelS WebSocket',
'user_id' => $userId,
'fd' => $request->fd,
'timestamp' => time()
]
]));
Log::info('WebSocket 连接已打开', [
'fd' => $request->fd,
'user_id' => $userId,
'ip' => $request->server['remote_addr']
]);
} catch (\Exception $e) {
Log::error('WebSocket onOpen 错误', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
'fd' => $request->fd
]);
$server->push($request->fd, json_encode([
'type' => 'error',
'data' => [
'message' => '连接错误:' . $e->getMessage(),
'code' => 500
]
]));
$server->disconnect($request->fd);
}
}
/**
* Handle WebSocket message event
* 处理接收消息事件
*
* @param Server $server
* @param Frame $frame
* @param Server $server WebSocket 服务器对象
* @param Frame $frame WebSocket 帧对象
* @return void
*/
public function onMessage(Server $server, Frame $frame): void
{
try {
$fd = $frame->fd;
$data = $frame->data;
// 从服务器获取 wsTable
$wsTable = $server->wsTable;
Log::info('WebSocket message received', [
'fd' => $fd,
'data' => $data,
'opcode' => $frame->opcode
]);
// 从 fd 映射获取 user_id
$fdInfo = $wsTable->get('fd:' . $frame->fd);
if ($fdInfo === false) {
$server->disconnect($frame->fd);
return;
}
// Parse incoming message
$message = json_decode($data, true);
$userId = (int)$fdInfo['value'];
if (!$message) {
$server->push($fd, json_encode([
// 解析消息
$message = json_decode($frame->data, true);
if (!$message || !isset($message['type'])) {
$server->push($frame->fd, json_encode([
'type' => 'error',
'data' => [
'message' => 'Invalid JSON format',
'message' => '无效的消息格式',
'code' => 400
]
]));
return;
}
// Handle different message types
$this->handleMessage($server, $fd, $message);
} catch (\Exception $e) {
Log::error('WebSocket onMessage error', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
$type = $message['type'];
$data = $message['data'] ?? [];
Log::info('收到 WebSocket 消息', [
'fd' => $frame->fd,
'user_id' => $userId,
'type' => $type
]);
}
}
/**
* Handle WebSocket message based on type
*
* @param Server $server
* @param int $fd
* @param array $message
* @return void
*/
protected function handleMessage(Server $server, int $fd, array $message): void
{
$type = $message['type'] ?? 'unknown';
$data = $message['data'] ?? [];
// 处理不同类型的消息
switch ($type) {
case 'ping':
// 响应 ping
$server->push($frame->fd, json_encode([
'type' => 'pong',
'data' => $data
]));
break;
switch ($type) {
case 'auth':
// Handle authentication confirmation
$this->handleAuth($server, $fd, $data);
break;
case 'heartbeat':
// 心跳确认
$server->push($frame->fd, json_encode([
'type' => 'heartbeat_ack',
'data' => array_merge($data, [
'timestamp' => time()
])
]));
break;
case 'ping':
// Respond to ping with pong
$server->push($fd, json_encode([
'type' => 'pong',
'data' => [
'timestamp' => time()
]
]));
break;
case 'chat':
// 私聊消息
$this->handleChatMessage($server, $wsTable, $frame, $userId, $data);
break;
case 'heartbeat':
// Handle heartbeat
$server->push($fd, json_encode([
'type' => 'heartbeat_ack',
'data' => [
'timestamp' => time()
]
]));
break;
case 'broadcast':
// 广播消息给所有用户
$this->handleBroadcast($server, $wsTable, $userId, $data);
break;
case 'chat':
// Handle chat message
$this->handleChatMessage($server, $fd, $data);
break;
case 'subscribe':
// 订阅频道
$this->handleSubscribe($server, $wsTable, $frame, $userId, $data);
break;
case 'broadcast':
// Handle broadcast message (admin only)
$this->handleBroadcast($server, $fd, $data);
break;
case 'unsubscribe':
// 取消订阅频道
$this->handleUnsubscribe($server, $wsTable, $frame, $userId, $data);
break;
case 'subscribe':
// Handle channel subscription
$this->handleSubscribe($server, $fd, $data);
break;
case 'unsubscribe':
// Handle channel unsubscription
$this->handleUnsubscribe($server, $fd, $data);
break;
default:
$server->push($fd, json_encode([
'type' => 'error',
'data' => [
'message' => 'Unknown message type: ' . $type,
'code' => 400
]
]));
break;
}
}
/**
* Handle authentication confirmation
*
* @param Server $server
* @param int $fd
* @param array $data
* @return void
*/
protected function handleAuth(Server $server, int $fd, array $data): void
{
$userId = $data['user_id'] ?? null;
$token = $data['token'] ?? null;
// Get the user ID from wsTable (set during connection)
$storedUserId = $this->getWsTable()->get('fd:' . $fd)['value'] ?? null;
if ($storedUserId && $storedUserId == $userId) {
// Authentication confirmed, send success response
$server->push($fd, json_encode([
'type' => 'connected',
'data' => [
'user_id' => $storedUserId,
'message' => 'Authentication confirmed',
'timestamp' => time()
]
]));
Log::info('WebSocket authentication confirmed', [
'fd' => $fd,
'user_id' => $userId
]);
} else {
// Authentication failed
$server->push($fd, json_encode([
'type' => 'error',
'data' => [
'message' => 'Authentication failed. User ID mismatch.',
'code' => 401
]
]));
Log::warning('WebSocket authentication failed', [
'fd' => $fd,
'stored_user_id' => $storedUserId,
'provided_user_id' => $userId
]);
}
}
/**
* Handle chat message
*
* @param Server $server
* @param int $fd
* @param array $data
* @return void
*/
protected function handleChatMessage(Server $server, int $fd, array $data): void
{
$toUserId = $data['to_user_id'] ?? null;
$content = $data['content'] ?? '';
if (!$toUserId || !$content) {
$server->push($fd, json_encode([
'type' => 'error',
'data' => [
'message' => 'Missing required fields: to_user_id and content',
'code' => 400
]
]));
return;
}
// Get target user's connection
$targetFd = $this->getWsTable()->get('uid:' . $toUserId);
if ($targetFd && $targetFd['value']) {
$server->push((int)$targetFd['value'], json_encode([
'type' => 'chat',
'data' => [
'from_user_id' => $this->getWsTable()->get('fd:' . $fd)['value'] ?? null,
'content' => $content,
'timestamp' => time()
]
]));
// Send delivery receipt to sender
$server->push($fd, json_encode([
'type' => 'message_delivered',
'data' => [
'to_user_id' => $toUserId,
'content' => $content,
'timestamp' => time()
]
]));
} else {
$server->push($fd, json_encode([
'type' => 'error',
'data' => [
'message' => 'Target user is not online',
'code' => 404
]
]));
}
}
/**
* Handle broadcast message
*
* @param Server $server
* @param int $fd
* @param array $data
* @return void
*/
protected function handleBroadcast(Server $server, int $fd, array $data): void
{
$message = $data['message'] ?? '';
$userId = $this->getWsTable()->get('fd:' . $fd)['value'] ?? null;
// TODO: Check if user has admin permission to broadcast
// For now, allow any authenticated user
if (!$message) {
$server->push($fd, json_encode([
'type' => 'error',
'data' => [
'message' => 'Message content is required',
'code' => 400
]
]));
return;
}
// Broadcast to all connected clients except sender
$broadcastData = json_encode([
'type' => 'broadcast',
'data' => [
'from_user_id' => $userId,
'message' => $message,
'timestamp' => time()
]
]);
foreach ($server->connections as $connectionFd) {
if ($server->isEstablished($connectionFd) && $connectionFd !== $fd) {
$server->push($connectionFd, $broadcastData);
default:
// 未知消息类型
$server->push($frame->fd, json_encode([
'type' => 'error',
'data' => [
'message' => '未知的消息类型:' . $type,
'code' => 400
]
]));
break;
}
} catch (\Exception $e) {
Log::error('WebSocket onMessage 错误', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
'fd' => $frame->fd
]);
}
// Send confirmation to sender
$server->push($fd, json_encode([
'type' => 'broadcast_sent',
'data' => [
'message' => $message,
'timestamp' => time()
]
]));
}
/**
* Handle channel subscription
* 处理连接关闭事件
*
* @param Server $server
* @param int $fd
* @param array $data
* @return void
*/
protected function handleSubscribe(Server $server, int $fd, array $data): void
{
$channel = $data['channel'] ?? '';
if (!$channel) {
$server->push($fd, json_encode([
'type' => 'error',
'data' => [
'message' => 'Channel name is required',
'code' => 400
]
]));
return;
}
// Store subscription in wsTable
$this->getWsTable()->set('channel:' . $channel . ':fd:' . $fd, [
'value' => 1,
'expiry' => time() + 7200 // 2 hours
]);
$server->push($fd, json_encode([
'type' => 'subscribed',
'data' => [
'channel' => $channel,
'timestamp' => time()
]
]));
Log::info('User subscribed to channel', [
'fd' => $fd,
'channel' => $channel
]);
}
/**
* Handle channel unsubscription
*
* @param Server $server
* @param int $fd
* @param array $data
* @return void
*/
protected function handleUnsubscribe(Server $server, int $fd, array $data): void
{
$channel = $data['channel'] ?? '';
if (!$channel) {
$server->push($fd, json_encode([
'type' => 'error',
'data' => [
'message' => 'Channel name is required',
'code' => 400
]
]));
return;
}
// Remove subscription from wsTable
$this->getWsTable()->del('channel:' . $channel . ':fd:' . $fd);
$server->push($fd, json_encode([
'type' => 'unsubscribed',
'data' => [
'channel' => $channel,
'timestamp' => time()
]
]));
Log::info('User unsubscribed from channel', [
'fd' => $fd,
'channel' => $channel
]);
}
/**
* Handle WebSocket connection close event
*
* @param Server $server
* @param $fd
* @param $reactorId
* @param Server $server WebSocket 服务器对象
* @param int $fd 文件描述符
* @param int $reactorId 反应器 ID
* @return void
*/
public function onClose(Server $server, $fd, $reactorId): void
{
try {
Log::info('WebSocket connection closed', [
'fd' => $fd,
'reactor_id' => $reactorId
]);
// 从服务器获取 wsTable
$wsTable = $server->wsTable;
// Get user ID from wsTable
$userId = $this->getWsTable()->get('fd:' . $fd)['value'] ?? null;
// 从 fd 映射获取 user_id
$fdInfo = $wsTable->get('fd:' . $fd);
if ($userId) {
// Remove user connection mapping
$this->getWsTable()->del('uid:' . $userId);
$this->getWsTable()->del('fd:' . $fd);
if ($fdInfo !== false) {
$userId = (int)$fdInfo['value'];
// Update user online status
$this->userOnlineService->updateUserOnlineStatus($userId, $fd, false);
// 删除 uid 映射
$wsTable->del('uid:' . $userId);
Log::info('User disconnected from WebSocket', [
// 删除该用户的所有频道订阅
$this->removeUserFromAllChannels($wsTable, $userId, $fd);
Log::info('WebSocket 连接已关闭', [
'fd' => $fd,
'user_id' => $userId,
'fd' => $fd
'reactor_id' => $reactorId
]);
}
// Clean up channel subscriptions
// Note: In production, you might want to iterate through all channel keys
// and remove the ones associated with this fd
// 删除 fd 映射
$wsTable->del('fd:' . $fd);
} catch (\Exception $e) {
Log::error('WebSocket onClose error', [
Log::error('WebSocket onClose 错误', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
'trace' => $e->getTraceAsString(),
'fd' => $fd
]);
}
}
/**
* 处理私聊消息
*
* @param Server $server WebSocket 服务器对象
* @param \Swoole\Table $wsTable WebSocket
* @param Frame $frame WebSocket 帧对象
* @param int $fromUserId 发送者用户 ID
* @param array $data 消息数据
* @return void
*/
protected function handleChatMessage(Server $server, \Swoole\Table $wsTable, Frame $frame, int $fromUserId, array $data): void
{
$toUserId = $data['to_user_id'] ?? 0;
if (!$toUserId) {
$server->push($frame->fd, json_encode([
'type' => 'error',
'data' => [
'message' => '缺少 to_user_id',
'code' => 400
]
]));
return;
}
// 获取接收者的 fd
$recipientInfo = $wsTable->get('uid:' . $toUserId);
if ($recipientInfo === false) {
$server->push($frame->fd, json_encode([
'type' => 'error',
'data' => [
'message' => '用户不在线',
'to_user_id' => $toUserId,
'code' => 404
]
]));
return;
}
$toFd = (int)$recipientInfo['value'];
// 发送消息给接收者
$server->push($toFd, json_encode([
'type' => 'chat',
'data' => array_merge($data, [
'from_user_id' => $fromUserId,
'timestamp' => time()
])
]));
}
/**
* 处理广播消息
*
* @param Server $server WebSocket 服务器对象
* @param \Swoole\Table $wsTable WebSocket
* @param int $userId 用户 ID
* @param array $data 消息数据
* @return void
*/
protected function handleBroadcast(Server $server, \Swoole\Table $wsTable, int $userId, array $data): void
{
$excludeUserId = $data['exclude_user_id'] ?? null;
$message = json_encode([
'type' => 'broadcast',
'data' => array_merge($data, [
'from_user_id' => $userId,
'timestamp' => time()
])
]);
// 发送消息给所有连接的用户
foreach ($wsTable as $key => $row) {
if (strpos($key, 'uid:') === 0) {
$targetUserId = (int)substr($key, 4); // 移除 'uid:' 前缀
$fd = (int)$row['value'];
// 跳过排除的用户
if ($excludeUserId && $targetUserId == $excludeUserId) {
continue;
}
if ($server->isEstablished($fd)) {
$server->push($fd, $message);
}
}
}
}
/**
* 处理频道订阅
*
* @param Server $server WebSocket 服务器对象
* @param \Swoole\Table $wsTable WebSocket
* @param Frame $frame WebSocket 帧对象
* @param int $userId 用户 ID
* @param array $data 消息数据
* @return void
*/
protected function handleSubscribe(Server $server, \Swoole\Table $wsTable, Frame $frame, int $userId, array $data): void
{
$channel = $data['channel'] ?? '';
if (!$channel) {
$server->push($frame->fd, json_encode([
'type' => 'error',
'data' => [
'message' => '缺少频道名称',
'code' => 400
]
]));
return;
}
// 存储频道订阅
$channelKey = 'channel:' . $channel . ':fd:' . $frame->fd;
$wsTable->set($channelKey, [
'value' => $userId,
'expiry' => time() + 3600
]);
$server->push($frame->fd, json_encode([
'type' => 'subscribed',
'data' => [
'channel' => $channel,
'message' => '成功订阅频道:' . $channel,
'timestamp' => time()
]
]));
Log::info('用户订阅频道', [
'user_id' => $userId,
'channel' => $channel,
'fd' => $frame->fd
]);
}
/**
* 处理频道取消订阅
*
* @param Server $server WebSocket 服务器对象
* @param \Swoole\Table $wsTable WebSocket
* @param Frame $frame WebSocket 帧对象
* @param int $userId 用户 ID
* @param array $data 消息数据
* @return void
*/
protected function handleUnsubscribe(Server $server, \Swoole\Table $wsTable, Frame $frame, int $userId, array $data): void
{
$channel = $data['channel'] ?? '';
if (!$channel) {
$server->push($frame->fd, json_encode([
'type' => 'error',
'data' => [
'message' => '缺少频道名称',
'code' => 400
]
]));
return;
}
// 删除频道订阅
$channelKey = 'channel:' . $channel . ':fd:' . $frame->fd;
$wsTable->del($channelKey);
$server->push($frame->fd, json_encode([
'type' => 'unsubscribed',
'data' => [
'channel' => $channel,
'message' => '成功取消订阅频道:' . $channel,
'timestamp' => time()
]
]));
Log::info('用户取消订阅频道', [
'user_id' => $userId,
'channel' => $channel,
'fd' => $frame->fd
]);
}
/**
* 从所有频道中移除用户
*
* @param \Swoole\Table $wsTable WebSocket
* @param int $userId 用户 ID
* @param int $fd 文件描述符
* @return void
*/
protected function removeUserFromAllChannels(\Swoole\Table $wsTable, int $userId, int $fd): void
{
foreach ($wsTable as $key => $row) {
if (strpos($key, 'channel:') === 0 && strpos($key, ':fd:' . $fd) !== false) {
$wsTable->del($key);
}
}
}
}
+294 -265
View File
@@ -6,91 +6,90 @@
use Swoole\WebSocket\Server;
/**
* WebSocket Service
* WebSocket 服务
*
* Provides helper functions for WebSocket operations
* 提供 WebSocket 操作的便捷方法
*/
class WebSocketService
{
/**
* Get Swoole WebSocket Server instance
* 获取 Swoole Server 实例
*
* @return Server|null
* @return Server
*/
public function getServer(): ?Server
protected function getServer(): Server
{
// Check if Laravel-S is running
if (!class_exists('Hhxsv5\LaravelS\Illuminate\Laravel') || !defined('IN_LARAVELS')) {
return null;
}
try {
// Try to get the Swoole server from the Laravel-S container
$laravelS = \Hhxsv5\LaravelS\Illuminate\Laravel::getInstance();
if ($laravelS && $laravelS->getSwooleServer()) {
return $laravelS->getSwooleServer();
}
} catch (\Exception $e) {
Log::warning('Failed to get Swoole server instance', [
'error' => $e->getMessage()
]);
}
return null;
/** @var Server $server */
$server = app('swoole');
return $server;
}
/**
* Send message to a specific user
* 获取 WebSocket
*
* @param int $userId
* @param array $data
* @return \Swoole\Table
*/
protected function getWsTable(): \Swoole\Table
{
return app('swoole')->wsTable;
}
/**
* 发送消息给指定用户
*
* @param int $userId 用户 ID
* @param array $data 消息数据
* @return bool
*/
public function sendToUser(int $userId, array $data): bool
{
$server = $this->getServer();
try {
$wsTable = $this->getWsTable();
$server = $this->getServer();
if (!$server) {
Log::warning('WebSocket server not available', ['user_id' => $userId]);
// 获取用户的 fd
$fdInfo = $wsTable->get('uid:' . $userId);
if ($fdInfo === false) {
return false;
}
$fd = (int)$fdInfo['value'];
// 检查连接是否仍然建立
if (!$server->isEstablished($fd)) {
// 删除过期连接
$wsTable->del('uid:' . $userId);
$wsTable->del('fd:' . $fd);
return false;
}
// 发送消息
$result = $server->push($fd, json_encode($data));
Log::info('消息已发送给用户', [
'user_id' => $userId,
'fd' => $fd,
'success' => $result
]);
return $result;
} catch (\Exception $e) {
Log::error('发送消息给用户失败', [
'user_id' => $userId,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
return false;
}
$wsTable = app('swoole')->wsTable;
$fdInfo = $wsTable->get('uid:' . $userId);
if (!$fdInfo || !$fdInfo['value']) {
Log::info('User not connected to WebSocket', ['user_id' => $userId]);
return false;
}
$fd = (int)$fdInfo['value'];
if (!$server->isEstablished($fd)) {
Log::info('WebSocket connection not established', ['user_id' => $userId, 'fd' => $fd]);
// Clean up stale connection
$wsTable->del('uid:' . $userId);
$wsTable->del('fd:' . $fd);
return false;
}
$server->push($fd, json_encode($data));
Log::info('Message sent to user via WebSocket', [
'user_id' => $userId,
'fd' => $fd,
'data' => $data
]);
return true;
}
/**
* Send message to multiple users
* 发送消息给多个用户
*
* @param array $userIds
* @param array $data
* @return array Array of user IDs who received the message
* @param array $userIds 用户 ID 数组
* @param array $data 消息数据
* @return array 成功发送的用户 ID 数组
*/
public function sendToUsers(array $userIds, array $data): array
{
@@ -106,247 +105,263 @@ public function sendToUsers(array $userIds, array $data): array
}
/**
* Broadcast message to all connected clients
* 广播消息给所有用户
*
* @param array $data
* @param int|null $excludeUserId User ID to exclude from broadcast
* @return int Number of clients the message was sent to
* @param array $data 消息数据
* @param int|null $excludeUserId 要排除的用户 ID
* @return int 成功发送的用户数量
*/
public function broadcast(array $data, ?int $excludeUserId = null): int
{
$server = $this->getServer();
try {
$wsTable = $this->getWsTable();
$server = $this->getServer();
if (!$server) {
Log::warning('WebSocket server not available for broadcast');
return 0;
}
$message = json_encode($data);
$count = 0;
$wsTable = app('swoole')->wsTable;
$message = json_encode($data);
$count = 0;
foreach ($server->connections as $fd) {
if (!$server->isEstablished($fd)) {
continue;
}
// Check if we should exclude this user
if ($excludeUserId) {
$fdInfo = $wsTable->get('fd:' . $fd);
if ($fdInfo && $fdInfo['value'] == $excludeUserId) {
foreach ($wsTable as $key => $row) {
// 只处理用户映射(uid:*
if (strpos($key, 'uid:') !== 0) {
continue;
}
$userId = (int)substr($key, 4); // 移除 'uid:' 前缀
$fd = (int)$row['value'];
// 跳过排除的用户
if ($excludeUserId && $userId == $excludeUserId) {
continue;
}
// 检查连接是否已建立并发送
if ($server->isEstablished($fd)) {
if ($server->push($fd, $message)) {
$count++;
}
} else {
// 删除过期连接
$wsTable->del('uid:' . $userId);
$wsTable->del('fd:' . $fd);
}
}
$server->push($fd, $message);
$count++;
Log::info('广播消息已发送', [
'exclude_user_id' => $excludeUserId,
'sent_to' => $count
]);
return $count;
} catch (\Exception $e) {
Log::error('广播消息失败', [
'exclude_user_id' => $excludeUserId,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
return 0;
}
Log::info('Broadcast sent via WebSocket', [
'data' => $data,
'exclude_user_id' => $excludeUserId,
'count' => $count
]);
return $count;
}
/**
* Send message to all subscribers of a channel
* 发送消息到频道
*
* @param string $channel
* @param array $data
* @return int Number of subscribers who received the message
* @param string $channel 频道名称
* @param array $data 消息数据
* @return int 成功发送的订阅者数量
*/
public function sendToChannel(string $channel, array $data): int
{
$server = $this->getServer();
try {
$wsTable = $this->getWsTable();
$server = $this->getServer();
if (!$server) {
Log::warning('WebSocket server not available for channel broadcast', ['channel' => $channel]);
$message = json_encode($data);
$count = 0;
$channelPrefix = 'channel:' . $channel . ':fd:';
foreach ($wsTable as $key => $row) {
// 只处理该频道的订阅
if (strpos($key, $channelPrefix) !== 0) {
continue;
}
$fd = (int)substr($key, strlen($channelPrefix));
// 检查连接是否已建立并发送
if ($server->isEstablished($fd)) {
if ($server->push($fd, $message)) {
$count++;
}
} else {
// 删除过期订阅
$wsTable->del($key);
}
}
Log::info('消息已发送到频道', [
'channel' => $channel,
'sent_to' => $count
]);
return $count;
} catch (\Exception $e) {
Log::error('发送消息到频道失败', [
'channel' => $channel,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
return 0;
}
$wsTable = app('swoole')->wsTable;
$count = 0;
$message = json_encode($data);
// Iterate through all connections and check if they're subscribed to the channel
foreach ($server->connections as $fd) {
if (!$server->isEstablished($fd)) {
continue;
}
$subscription = $wsTable->get('channel:' . $channel . ':fd:' . $fd);
if ($subscription) {
$server->push($fd, $message);
$count++;
}
}
Log::info('Channel message sent via WebSocket', [
'channel' => $channel,
'data' => $data,
'count' => $count
]);
return $count;
}
/**
* Get online user count
* 获取在线用户数量
*
* @return int
*/
public function getOnlineUserCount(): int
{
$server = $this->getServer();
try {
$wsTable = $this->getWsTable();
$count = 0;
if (!$server || !isset($server->wsTable)) {
foreach ($wsTable as $key => $row) {
if (strpos($key, 'uid:') === 0) {
$count++;
}
}
return $count;
} catch (\Exception $e) {
Log::error('获取在线用户数量失败', [
'error' => $e->getMessage()
]);
return 0;
}
// Count established connections
$count = 0;
foreach ($server->connections as $fd) {
if ($server->isEstablished($fd)) {
$count++;
}
}
return $count;
}
/**
* Check if a user is online
* 检查用户是否在线
*
* @param int $userId
* @param int $userId 用户 ID
* @return bool
*/
public function isUserOnline(int $userId): bool
{
$server = $this->getServer();
try {
$wsTable = $this->getWsTable();
$fdInfo = $wsTable->get('uid:' . $userId);
if (!$server) {
return false;
}
if ($fdInfo === false) {
return false;
}
$wsTable = app('swoole')->wsTable;
$server = $this->getServer();
$fd = (int)$fdInfo['value'];
$fdInfo = $wsTable->get('uid:' . $userId);
if (!$fdInfo || !$fdInfo['value']) {
return false;
}
$fd = (int)$fdInfo['value'];
return $server->isEstablished($fd);
}
/**
* Disconnect a user from WebSocket
*
* @param int $userId
* @return bool
*/
public function disconnectUser(int $userId): bool
{
$server = $this->getServer();
if (!$server) {
return false;
}
$wsTable = app('swoole')->wsTable;
$fdInfo = $wsTable->get('uid:' . $userId);
if (!$fdInfo || !$fdInfo['value']) {
return false;
}
$fd = (int)$fdInfo['value'];
if ($server->isEstablished($fd)) {
$server->push($fd, json_encode([
'type' => 'disconnect',
'data' => [
'message' => 'You have been disconnected',
'timestamp' => time()
]
]));
// Close the connection
$server->disconnect($fd);
// Clean up
$wsTable->del('uid:' . $userId);
$wsTable->del('fd:' . $fd);
Log::info('User disconnected from WebSocket by server', [
return $server->isEstablished($fd);
} catch (\Exception $e) {
Log::error('检查用户在线状态失败', [
'user_id' => $userId,
'fd' => $fd
'error' => $e->getMessage()
]);
return true;
return false;
}
return false;
}
/**
* Get all online user IDs
* 获取在线用户 ID 列表
*
* @return array
*/
public function getOnlineUserIds(): array
{
$server = $this->getServer();
try {
$wsTable = $this->getWsTable();
$userIds = [];
if (!$server) {
foreach ($wsTable as $key => $row) {
if (strpos($key, 'uid:') === 0) {
$userId = (int)substr($key, 4); // 移除 'uid:' 前缀
$userIds[] = $userId;
}
}
return $userIds;
} catch (\Exception $e) {
Log::error('获取在线用户 ID 列表失败', [
'error' => $e->getMessage()
]);
return [];
}
$wsTable = app('swoole')->wsTable;
$userIds = [];
foreach ($server->connections as $fd) {
if (!$server->isEstablished($fd)) {
continue;
}
$fdInfo = $wsTable->get('fd:' . $fd);
if ($fdInfo && $fdInfo['value']) {
$userIds[] = (int)$fdInfo['value'];
}
}
return array_unique($userIds);
}
/**
* Send system notification to all online users
* 断开用户 WebSocket 连接
*
* @param string $title
* @param string $message
* @param string $type
* @param array $extraData
* @return int
* @param int $userId 用户 ID
* @return bool
*/
public function sendSystemNotification(string $title, string $message, string $type = 'info', array $extraData = []): int
public function disconnectUser(int $userId): bool
{
try {
$wsTable = $this->getWsTable();
$server = $this->getServer();
// 获取用户的 fd
$fdInfo = $wsTable->get('uid:' . $userId);
if ($fdInfo === false) {
return false;
}
$fd = (int)$fdInfo['value'];
// 断开连接
$server->disconnect($fd);
// 删除映射
$wsTable->del('uid:' . $userId);
$wsTable->del('fd:' . $fd);
Log::info('用户已断开连接', [
'user_id' => $userId,
'fd' => $fd
]);
return true;
} catch (\Exception $e) {
Log::error('断开用户连接失败', [
'user_id' => $userId,
'error' => $e->getMessage()
]);
return false;
}
}
/**
* 发送系统通知
*
* @param string $title 标题
* @param string $message 消息内容
* @param string $type 类型
* @param array $extraData 额外数据
* @return int 成功发送的用户数量
*/
public function sendSystemNotification(
string $title,
string $message,
string $type = 'info',
array $extraData = []
): int {
$data = [
'type' => 'notification',
'data' => [
'title' => $title,
'message' => $message,
'type' => $type, // info, success, warning, error
'timestamp' => time(),
...$extraData
'type' => $type,
'data' => $extraData,
'timestamp' => time()
]
];
@@ -354,47 +369,57 @@ public function sendSystemNotification(string $title, string $message, string $t
}
/**
* Send notification to specific users
* 发送通知给指定用户
*
* @param array $userIds
* @param string $title
* @param string $message
* @param string $type
* @param array $extraData
* @return array
* @param array $userIds 用户 ID 数组
* @param string $title 标题
* @param string $message 消息内容
* @param string $type 类型
* @param array $extraData 额外数据
* @return int 成功发送的用户数量
*/
public function sendNotificationToUsers(array $userIds, string $title, string $message, string $type = 'info', array $extraData = []): array
{
public function sendNotificationToUsers(
array $userIds,
string $title,
string $message,
string $type = 'info',
array $extraData = []
): int {
$data = [
'type' => 'notification',
'data' => [
'title' => $title,
'message' => $message,
'type' => $type,
'timestamp' => time(),
...$extraData
'data' => $extraData,
'timestamp' => time()
]
];
return $this->sendToUsers($userIds, $data);
$sentTo = $this->sendToUsers($userIds, $data);
return count($sentTo);
}
/**
* Push data update to specific users
* 推送数据更新
*
* @param array $userIds
* @param string $resourceType
* @param string $action
* @param array $data
* @return array
* @param array $userIds 用户 ID 数组
* @param string $resourceType 资源类型
* @param string $action 操作
* @param array $data 数据
* @return array 成功推送的用户 ID 数组
*/
public function pushDataUpdate(array $userIds, string $resourceType, string $action, array $data): array
{
public function pushDataUpdate(
array $userIds,
string $resourceType,
string $action,
array $data
): array {
$message = [
'type' => 'data_update',
'data' => [
'resource_type' => $resourceType, // e.g., 'user', 'order', 'product'
'action' => $action, // create, update, delete
'resource_type' => $resourceType,
'action' => $action,
'data' => $data,
'timestamp' => time()
]
@@ -404,16 +429,20 @@ public function pushDataUpdate(array $userIds, string $resourceType, string $act
}
/**
* Push data update to a channel
* 推送数据更新到频道
*
* @param string $channel
* @param string $resourceType
* @param string $action
* @param array $data
* @return int
* @param string $channel 频道名称
* @param string $resourceType 资源类型
* @param string $action 操作
* @param array $data 数据
* @return int 成功推送的订阅者数量
*/
public function pushDataUpdateToChannel(string $channel, string $resourceType, string $action, array $data): int
{
public function pushDataUpdateToChannel(
string $channel,
string $resourceType,
string $action,
array $data
): int {
$message = [
'type' => 'data_update',
'data' => [
+1 -1
View File
@@ -221,7 +221,7 @@
'swoole_tables' => [
// WebSocket table for storing user connections
'wsTable' => [
'ws' => [
'size' => 102400, // Maximum number of rows
'column' => [
['name' => 'value', 'type' => \Swoole\Table::TYPE_STRING, 'size' => 1024],
-415
View File
@@ -1,415 +0,0 @@
# 字典缓存更新机制
## 概述
本文档说明前后端字典缓存的更新逻辑,确保在字典分类和字典项的增删改等操作后,前端字典缓存能够自动更新。
## 技术实现
### 1. 后端实现
#### 1.1 DictionaryService 更新
`app/Services/System/DictionaryService.php` 中添加了 WebSocket 通知功能:
**依赖注入:**
```php
protected $webSocketService;
public function __construct(WebSocketService $webSocketService)
{
$this->webSocketService = $webSocketService;
}
```
**通知方法:**
1. **字典分类更新通知** (`notifyDictionaryUpdate`)
- 触发时机:创建、更新、删除、批量删除、批量更新状态
- 消息类型:`dictionary_update`
2. **字典项更新通知** (`notifyDictionaryItemUpdate`)
- 触发时机:创建、更新、删除、批量删除、批量更新状态
- 消息类型:`dictionary_item_update`
**修改的方法列表:**
- `create()` - 创建字典分类后发送通知
- `update()` - 更新字典分类后发送通知
- `delete()` - 删除字典分类后发送通知
- `batchDelete()` - 批量删除字典分类后发送通知
- `batchUpdateStatus()` - 批量更新状态后发送通知
- `createItem()` - 创建字典项后发送通知
- `updateItem()` - 更新字典项后发送通知
- `deleteItem()` - 删除字典项后发送通知
- `batchDeleteItems()` - 批量删除字典项后发送通知
- `batchUpdateItemsStatus()` - 批量更新字典项状态后发送通知
#### 1.2 WebSocket 消息格式
**字典分类更新消息:**
```json
{
"type": "dictionary_update",
"data": {
"action": "create|update|delete|batch_delete|batch_update_status",
"resource_type": "dictionary",
"data": {
// 字典分类数据
},
"timestamp": 1234567890
}
}
```
**字典项更新消息:**
```json
{
"type": "dictionary_item_update",
"data": {
"action": "create|update|delete|batch_delete|batch_update_status",
"resource_type": "dictionary_item",
"data": {
// 字典项数据
},
"timestamp": 1234567890
}
}
```
### 2. 前端实现
#### 2.1 WebSocket Composable
创建了 `resources/admin/src/composables/useWebSocket.js` 来处理 WebSocket 连接和消息监听:
**主要功能:**
1. **初始化 WebSocket 连接**
- 检查用户登录状态
- 验证用户信息完整性
- 建立连接并注册消息处理器
2. **消息处理器**
- `handleDictionaryUpdate` - 处理字典分类更新
- `handleDictionaryItemUpdate` - 处理字典项更新
3. **缓存刷新**
- 接收到更新通知后,自动刷新字典缓存
- 显示成功提示消息
#### 2.2 App.vue 集成
`resources/admin/src/App.vue` 中集成了 WebSocket
**生命周期钩子:**
```javascript
onMounted(async () => {
// ... 其他初始化代码
// 初始化 WebSocket 连接
if (userStore.isLoggedIn()) {
initWebSocket()
}
})
onUnmounted(() => {
// 关闭 WebSocket 连接
closeWebSocket()
})
```
## 工作流程
### 完整流程图
```
用户操作(增删改字典)
后端 Controller 调用 Service
Service 执行数据库操作
Service 清理后端缓存(Redis
Service 发送 WebSocket 广播通知
WebSocket 推送消息到所有在线客户端
前端接收 WebSocket 消息
触发相应的消息处理器
刷新前端字典缓存
显示成功提示
```
### 详细步骤
1. **用户操作**
- 管理员在后台管理界面进行字典分类或字典项的增删改操作
- 例如:创建新字典分类、修改字典项、批量删除等
2. **后端处理**
- 接收请求并验证数据
- 执行数据库操作(INSERT/UPDATE/DELETE
- 清理 Redis 缓存(`DictionaryService::clearCache()`
- 通过 WebSocket 广播更新通知
3. **WebSocket 通知**
- 服务端向所有连接的 WebSocket 客户端广播消息
- 消息包含操作类型、资源类型和更新的数据
4. **前端接收**
- App.vue 在 onMounted 时初始化 WebSocket 连接
- 注册消息处理器监听 `dictionary_update``dictionary_item_update` 事件
- 接收到消息后调用对应的处理器
5. **缓存刷新**
- 处理器调用 `dictionaryStore.refresh(true)` 强制刷新缓存
- 从后端 API 重新加载所有字典数据
- 更新 Pinia store 中的字典数据
- 持久化到本地存储
6. **用户反馈**
- 显示 "字典数据已更新" 的成功提示
- 页面上的字典数据自动更新,无需手动刷新
## 使用示例
### 示例 1:创建新字典分类
```php
// 后端代码
$dictionary = $dictionaryService->create([
'name' => '订单状态',
'code' => 'order_status',
'description' => '订单状态字典',
'value_type' => 'string',
'sort' => 1,
'status' => true
]);
// 自动触发:
// 1. 清理 Redis 缓存
// 2. 广播 WebSocket 消息
```
前端自动刷新缓存并显示提示。
### 示例 2:更新字典项
```php
// 后端代码
$item = $dictionaryService->updateItem(1, [
'label' => '已付款',
'value' => 'paid',
'sort' => 2
]);
// 自动触发:
// 1. 清理对应字典的 Redis 缓存
// 2. 广播 WebSocket 消息
```
前端自动刷新缓存并显示提示。
### 示例 3:批量操作
```php
// 后端代码
$dictionaryService->batchUpdateStatus([1, 2, 3], false);
// 自动触发:
// 1. 清理所有相关字典的 Redis 缓存
// 2. 广播 WebSocket 消息(包含批量更新的 ID)
```
前端自动刷新缓存并显示提示。
## 注意事项
### 1. WebSocket 连接
- WebSocket 仅在用户登录后建立连接
- 连接失败会自动重试(最多 5 次)
- 页面卸载时会自动关闭连接
### 2. 缓存一致性
- 后端缓存使用 RedisTTL 为 3600 秒(1 小时)
- 前端缓存使用 Pinia + 本地存储持久化
- WebSocket 通知确保前后端缓存同步更新
### 3. 错误处理
- WebSocket 连接失败不影响页面正常使用
- 缓存刷新失败会在控制台输出错误日志
- 不会阻塞用户操作
### 4. 性能考虑
- 批量操作会一次性清理相关缓存
- WebSocket 广播向所有在线用户推送
- 前端刷新时会重新加载所有字典数据
## 扩展建议
### 1. 细粒度缓存更新
当前实现是全量刷新,未来可以优化为增量更新:
```javascript
// 只更新受影响的字典
async function handleDictionaryUpdate(data) {
const { action, data: dictData } = data
if (action === 'update' && dictData.code) {
// 只更新特定的字典
await dictionaryStore.getDictionary(dictData.code, true)
} else {
// 全量刷新
await dictionaryStore.refresh(true)
}
}
```
### 2. 权限控制
可以只向有权限的用户发送通知:
```php
// 后端只向有字典管理权限的用户发送
$adminUserIds = User::whereHas('roles', function($query) {
$query->where('name', 'admin');
})->pluck('id')->toArray();
$this->webSocketService->sendToUsers($adminUserIds, $message);
```
### 3. 消息队列
对于高并发场景,可以使用消息队列异步发送 WebSocket 通知:
```php
// 使用 Laravel 队列
UpdateDictionaryCacheJob::dispatch($action, $data);
```
## 测试建议
### 1. 单元测试
测试后端 WebSocket 通知是否正确发送:
```php
public function testDictionaryUpdateSendsWebSocketNotification()
{
$this->mockWebSocketService();
$dictionary = DictionaryService::create([
'name' => 'Test',
'code' => 'test'
]);
// 验证 WebSocket 广播被调用
}
```
### 2. 集成测试
1. 启动后端服务(Laravel-S
2. 启动前端开发服务器
3. 在浏览器中登录系统
4. 打开开发者工具的 Network -> WS 标签查看 WebSocket 消息
5. 执行字典增删改操作
6. 验证:
- WebSocket 消息是否正确接收
- 缓存是否自动刷新
- 页面数据是否更新
- 提示消息是否显示
### 3. 并发测试
1. 打开多个浏览器窗口并登录
2. 在一个窗口中进行字典操作
3. 验证所有窗口的缓存是否同步更新
## 故障排查
### 问题 1:前端未收到 WebSocket 消息
**可能原因:**
- WebSocket 服务未启动
- 网络连接问题
- 用户未登录
- Laravel-S 未运行(使用普通 PHP 运行时)
**解决方法:**
1. 检查 Laravel-S 服务是否启动:`php bin/laravels status`
2. 检查浏览器控制台是否有 WebSocket 错误
3. 确认用户已登录且有 token
4. 确认是否在 Laravel-S 环境下运行(WebSocket 通知仅在 Laravel-S 环境下有效)
**注意:**
- WebSocket 通知功能依赖于 Laravel-S (Swoole) 环境
- 在普通 PHP 环境下运行时,WebSocket 通知会优雅降级(不发送通知,但不影响功能)
- 仍需手动刷新页面或使用 API 轮询来获取最新数据
### 问题 2:后端 WebSocket 通知发送失败
**可能原因:**
- Laravel-S 未运行
- Swoole 服务器未启动
- WebSocket 服务实例获取失败
**解决方法:**
1. 确认在 Laravel-S 环境下运行:`php bin/laravels start`
2. 检查 Laravel-S 配置文件 `config/laravels.php`
3. 查看后端日志:`tail -f storage/logs/laravel.log`
**注意:**
- 如果未在 Laravel-S 环境下运行,后端会记录警告日志,但不会报错
- 字典数据仍会正常更新到数据库和 Redis 缓存
- 只是前端不会收到自动更新通知
### 问题 3:缓存未更新
**可能原因:**
- WebSocket 消息处理失败
- API 请求失败
- 前端未连接 WebSocket
**解决方法:**
1. 查看浏览器控制台错误日志
2. 检查网络请求是否成功
3. 手动刷新页面验证 API 是否正常
4. 确认 WebSocket 连接状态(浏览器开发者工具 Network -> WS
### 问题 3:通知频繁弹出
**可能原因:**
- 批量操作触发了多次通知
**解决方法:**
1. 优化后端批量操作,只发送一次通知
2. 前端添加防抖/节流逻辑
## 总结
通过 WebSocket 实现的字典缓存自动更新机制,确保了前后端数据的一致性,提升了用户体验。用户无需手动刷新页面即可获取最新的字典数据。
### 优势
- ✅ 实时更新,无需手动刷新
- ✅ 多端同步,所有在线用户自动更新
- ✅ 操作透明,用户有明确的反馈
- ✅ 易于扩展,可应用于其他数据类型
### 限制
- 需要稳定的 WebSocket 连接
- 当前实现为全量刷新,可以优化为增量更新
- 依赖后端服务(Laravel-S)正常运行
-337
View File
@@ -1,337 +0,0 @@
# 日志模块实现总结
## 实现概述
本次优化完善了后端日志模块,实现了自动化的请求日志记录功能,所有后台管理 API 请求都会被自动记录到数据库中。
## 实现内容
### 1. 新增文件
#### 中间件
- **app/Http/Middleware/LogRequestMiddleware.php**
- 自动拦截所有经过的请求
- 记录请求和响应信息
- 计算请求执行时间
- 提取用户信息和操作详情
- 自动过滤敏感参数(密码、token等)
- 获取客户端真实 IP(支持代理)
#### 请求验证
- **app/Http/Requests/LogRequest.php**
- 统一的请求参数验证
- 支持列表查询、批量删除、清理等操作的参数验证
- 自定义错误消息
- 自动设置默认值
#### 文档
- **docs/README_LOG.md**
- 完整的模块文档
- API 接口说明
- 数据库表结构
- 使用示例
- 前端集成代码
- 常见问题解答
### 2. 修改文件
#### 控制器
- **app/Http/Controllers/System/Admin/Log.php**
- 添加 `export` 方法:支持导出日志数据为 Excel
- 使用 `LogRequest` 进行参数验证
- 优化响应格式
#### 服务层
- **app/Services/System/LogService.php**
- 添加 `getListQuery` 方法:提供查询构建器(用于导出等场景)
- 新增 `buildQuery` 方法:统一的查询构建逻辑
- 代码重构,减少重复代码
#### 路由配置
- **routes/admin.php**
- 添加 `POST /admin/logs/export` 导出路由
- 在所有需要认证的路由组中应用 `log.request` 中间件
#### 中间件配置
- **bootstrap/app.php**
- 注册 `log.request` 中间件别名
- 创建 `admin.log` 中间件组
## 功能特性
### 自动日志记录
- ✅ 所有后台管理 API 请求自动记录
- ✅ 记录用户信息(ID、用户名)
- ✅ 记录请求信息(方法、URL、参数)
- ✅ 记录响应信息(状态码、执行时间)
- ✅ 记录客户端信息(IP、User-Agent
- ✅ 错误请求记录详细错误信息
### 敏感信息保护
- ✅ 自动过滤密码字段
- ✅ 自动过滤 token 字段
- ✅ 自动过滤 secret 字段
- ✅ 自动过滤 key 字段
### 日志管理功能
- ✅ 多维度查询(用户、模块、操作、状态、时间、IP)
- ✅ 分页查询
- ✅ 日志详情查看
- ✅ 日志统计(总数、成功数、失败数)
- ✅ 单条删除
- ✅ 批量删除
- ✅ 定期清理(按天数)
- ✅ 导出为 Excel
### 性能优化
- ✅ 日志记录在请求处理后执行
- ✅ 不影响业务响应速度
- ✅ 异常处理,记录失败不影响业务
- ✅ 支持分页查询,避免一次性加载过多数据
## API 接口列表
| 接口 | 方法 | 说明 |
|------|------|------|
| `/admin/logs` | GET | 获取日志列表 |
| `/admin/logs/{id}` | GET | 获取日志详情 |
| `/admin/logs/statistics` | GET | 获取日志统计 |
| `/admin/logs/export` | POST | 导出日志(Excel |
| `/admin/logs/{id}` | DELETE | 删除单条日志 |
| `/admin/logs/batch-delete` | POST | 批量删除日志 |
| `/admin/logs/clear` | POST | 清理历史日志 |
## 数据库表结构
### system_logs 表
已存在的表结构,包含以下字段:
- id: 主键
- user_id: 用户 ID
- username: 用户名
- module: 模块名称
- action: 操作名称
- method: 请求方法
- url: 请求 URL
- ip: 客户端 IP
- user_agent: 用户代理
- params: 请求参数(JSON
- result: 响应结果
- status_code: HTTP 状态码
- status: 状态(success/error
- error_message: 错误信息
- execution_time: 执行时间(毫秒)
- created_at: 创建时间
- updated_at: 更新时间
## 中间件应用范围
### 已应用的路由
- ✅ 所有 `/admin/*` 路由(除登录接口)
- ✅ 认证相关(登出、刷新、个人信息、修改密码)
- ✅ 用户管理
- ✅ 角色管理
- ✅ 权限管理
- ✅ 部门管理
- ✅ 在线用户管理
- ✅ 系统配置管理
- ✅ 数据字典管理
- ✅ 任务管理
- ✅ 城市数据管理
- ✅ 文件上传管理
### 未应用的路由
- ❌ 登录接口(`POST /admin/auth/login`
- ❌ 健康检查接口(`GET /up`
## 使用示例
### 后端使用
中间件会自动记录所有请求,无需手动调用:
```php
// 任何经过 log.request 中间件的请求都会被自动记录
Route::middleware(['auth.check:admin', 'log.request'])->group(function () {
Route::apiResource('users', UserController::class);
// 其他路由...
});
```
### 前端调用示例
```javascript
// 获取日志列表
const response = await request.get('/admin/logs', {
params: {
username: 'admin',
module: 'users',
status: 'success',
page: 1,
page_size: 20
}
})
// 导出日志
await request.post('/admin/logs/export', {
username: 'admin',
status: 'error'
}, {
responseType: 'blob'
})
// 批量删除
await request.post('/admin/logs/batch-delete', {
ids: [1, 2, 3, 4, 5]
})
// 清理历史日志
await request.post('/admin/logs/clear', {
days: 30
})
```
## 日志记录示例
### 成功请求日志
```json
{
"id": 1,
"user_id": 1,
"username": "admin",
"module": "users",
"action": "创建 users",
"method": "POST",
"url": "http://example.com/admin/users",
"ip": "192.168.1.1",
"user_agent": "Mozilla/5.0...",
"params": {
"name": "test",
"email": "test@example.com",
"password": "******"
},
"result": null,
"status_code": 200,
"status": "success",
"error_message": null,
"execution_time": 125,
"created_at": "2024-01-01 12:00:00"
}
```
### 失败请求日志
```json
{
"id": 2,
"user_id": 1,
"username": "admin",
"module": "users",
"action": "删除 users",
"method": "DELETE",
"url": "http://example.com/admin/users/999",
"ip": "192.168.1.1",
"user_agent": "Mozilla/5.0...",
"params": {},
"result": "{\"code\":404,\"message\":\"用户不存在\"}",
"status_code": 404,
"status": "error",
"error_message": "用户不存在",
"execution_time": 45,
"created_at": "2024-01-01 12:01:00"
}
```
## 注意事项
### 1. 性能考虑
- 日志记录在请求处理后执行,不影响响应速度
- 大量日志会增加数据库写入压力
- 建议定期清理历史日志
### 2. 数据安全
- 敏感信息已自动过滤
- 日志数据应妥善保管
- 建议定期备份重要日志
### 3. 权限控制
- 日志管理接口需要相应权限
- 建议只允许管理员查看和操作日志
### 4. 数据库优化
- 确保查询字段有索引
- 使用分页查询避免加载过多数据
- 定期清理历史日志
## 后续优化建议
### 1. 异步队列
考虑使用 Laravel 队列异步处理日志记录,进一步减少对响应时间的影响。
### 2. 日志归档
实现日志归档功能,将历史日志移动到归档表或文件存储。
### 3. 日志分析
集成日志分析工具,提供可视化仪表盘和趋势分析。
### 4. 定时清理
配置 Laravel 任务调度器,自动清理指定天数前的日志:
```php
// app/Console/Kernel.php
$schedule->call(function () {
app(LogService::class)->clearLogs(90);
})->dailyAt('02:00');
```
### 5. 日志级别
增加日志级别(info、warning、error、critical),便于分类管理。
## 测试建议
### 功能测试
1. 测试各种请求是否被正确记录
2. 测试敏感信息是否被正确过滤
3. 测试日志查询和筛选功能
4. 测试日志导出功能
5. 测试批量删除和清理功能
### 性能测试
1. 测试日志记录对响应时间的影响
2. 测试大量日志数据的查询性能
3. 测试并发写入的性能
### 边界测试
1. 测试异常情况下的日志记录
2. 测试超长参数的处理
3. 测试特殊字符的处理
## 文件清单
### 新增文件
```
app/Http/Middleware/LogRequestMiddleware.php
app/Http/Requests/LogRequest.php
docs/README_LOG.md
docs/LOG_IMPLEMENTATION_SUMMARY.md
```
### 修改文件
```
app/Http/Controllers/System/Admin/Log.php
app/Services/System/LogService.php
routes/admin.php
bootstrap/app.php
```
## 总结
本次日志模块优化完善实现了:
- ✅ 全自动化的请求日志记录
- ✅ 完善的日志管理功能
- ✅ 敏感信息保护
- ✅ 多维度查询和筛选
- ✅ 数据导出功能
- ✅ 批量操作支持
- ✅ 完整的文档说明
日志模块现已完全集成到项目中,所有后台管理 API 请求都会被自动记录,管理员可以通过日志管理功能进行系统监控、审计和问题排查。
File diff suppressed because it is too large Load Diff
-608
View File
@@ -1,608 +0,0 @@
# 系统操作日志模块文档
## 概述
系统操作日志模块用于记录后台管理系统的所有操作请求,包括用户操作、API 调用、错误信息等,方便管理员进行系统监控、审计和问题排查。
## 技术特性
- **自动记录**: 通过中间件自动记录所有请求,无需手动调用
- **详细信息**: 记录用户信息、请求参数、响应结果、执行时间等
- **敏感信息保护**: 自动过滤密码等敏感信息
- **性能优化**: 不影响业务响应速度
- **多维度查询**: 支持按用户、模块、操作、状态、时间等多维度筛选
- **数据导出**: 支持导出日志数据为 Excel 文件
- **批量操作**: 支持批量删除和定期清理
## 数据库表结构
### system_logs 表
| 字段名 | 类型 | 说明 |
|--------|------|------|
| id | bigint | 主键 ID |
| user_id | bigint | 用户 ID |
| username | varchar(100) | 用户名 |
| module | varchar(50) | 模块名称 |
| action | varchar(100) | 操作名称 |
| method | varchar(10) | 请求方法 (GET/POST/PUT/DELETE) |
| url | text | 请求 URL |
| ip | varchar(45) | 客户端 IP 地址 |
| user_agent | text | 用户代理 |
| params | json | 请求参数 |
| result | text | 响应结果(仅错误时记录) |
| status_code | int | HTTP 状态码 |
| status | varchar(20) | 状态 (success/error) |
| error_message | text | 错误信息 |
| execution_time | int | 执行时间(毫秒) |
| created_at | timestamp | 创建时间 |
| updated_at | timestamp | 更新时间 |
## 核心组件
### 1. 中间件 (Middleware)
**LogRequestMiddleware**
位置: `app/Http/Middleware/LogRequestMiddleware.php`
功能:
- 自动拦截所有经过的请求
- 记录请求和响应信息
- 计算请求执行时间
- 提取用户信息和操作详情
- 过滤敏感参数
- 处理异常情况
使用方式:
```php
// 在路由中应用
Route::middleware(['log.request'])->group(function () {
// 需要记录日志的路由
});
```
### 2. 服务层 (Service)
**LogService**
位置: `app/Services/System/LogService.php`
主要方法:
- `create(array $data)`: 创建日志记录
- `getList(array $params)`: 获取日志列表(分页)
- `getListQuery(array $params)`: 获取日志查询构建器
- `getById(int $id)`: 根据 ID 获取日志详情
- `delete(int $id)`: 删除单条日志
- `batchDelete(array $ids)`: 批量删除日志
- `clearLogs(string $days)`: 清理指定天数前的日志
- `getStatistics(array $params)`: 获取日志统计信息
### 3. 控制器 (Controller)
**Log Controller**
位置: `app/Http/Controllers/System/Admin/Log.php`
接口列表:
- `GET /admin/logs`: 获取日志列表
- `GET /admin/logs/{id}`: 获取日志详情
- `GET /admin/logs/statistics`: 获取日志统计
- `POST /admin/logs/export`: 导出日志
- `DELETE /admin/logs/{id}`: 删除单条日志
- `POST /admin/logs/batch-delete`: 批量删除日志
- `POST /admin/logs/clear`: 清理历史日志
### 4. 请求验证 (Request Validation)
**LogRequest**
位置: `app/Http/Requests/LogRequest.php`
验证规则:
- `user_id`: 用户 ID(可选)
- `username`: 用户名(模糊查询,可选)
- `module`: 模块名称(可选)
- `action`: 操作名称(可选)
- `status`: 状态(success/error,可选)
- `start_date`: 开始日期(可选)
- `end_date`: 结束日期(可选)
- `ip`: IP 地址(可选)
- `page`: 页码(默认 1
- `page_size`: 每页数量(默认 20,最大 100)
## API 接口文档
### 1. 获取日志列表
**接口**: `GET /admin/logs`
**请求参数**:
```json
{
"user_id": 1,
"username": "admin",
"module": "users",
"action": "创建 users",
"status": "success",
"start_date": "2024-01-01",
"end_date": "2024-12-31",
"ip": "192.168.1.1",
"page": 1,
"page_size": 20
}
```
**响应示例**:
```json
{
"code": 200,
"message": "success",
"data": {
"list": [
{
"id": 1,
"user_id": 1,
"username": "admin",
"module": "users",
"action": "创建 users",
"method": "POST",
"url": "http://example.com/admin/users",
"ip": "192.168.1.1",
"user_agent": "Mozilla/5.0...",
"params": {
"name": "test",
"email": "test@example.com"
},
"result": null,
"status_code": 200,
"status": "success",
"error_message": null,
"execution_time": 125,
"created_at": "2024-01-01 12:00:00",
"user": {
"id": 1,
"name": "管理员",
"username": "admin"
}
}
],
"total": 100,
"page": 1,
"page_size": 20
}
}
```
### 2. 获取日志详情
**接口**: `GET /admin/logs/{id}`
**响应示例**:
```json
{
"code": 200,
"message": "success",
"data": {
"id": 1,
"user_id": 1,
"username": "admin",
"module": "users",
"action": "创建 users",
"method": "POST",
"url": "http://example.com/admin/users",
"ip": "192.168.1.1",
"user_agent": "Mozilla/5.0...",
"params": {
"name": "test",
"email": "test@example.com"
},
"result": null,
"status_code": 200,
"status": "success",
"error_message": null,
"execution_time": 125,
"created_at": "2024-01-01 12:00:00",
"user": {
"id": 1,
"name": "管理员",
"username": "admin",
"email": "admin@example.com",
"created_at": "2024-01-01 10:00:00"
}
}
}
```
### 3. 获取日志统计
**接口**: `GET /admin/logs/statistics`
**请求参数**:
```json
{
"start_date": "2024-01-01",
"end_date": "2024-12-31"
}
```
**响应示例**:
```json
{
"code": 200,
"message": "success",
"data": {
"total": 1000,
"success": 950,
"error": 50
}
}
```
### 4. 导出日志
**接口**: `POST /admin/logs/export`
**请求参数**: 与获取日志列表相同的查询参数
**响应**: Excel 文件下载
文件名格式: `系统操作日志_YYYYMMDDHHmmss.xlsx`
包含字段:
- ID
- 用户名
- 模块
- 操作
- 请求方法
- URL
- IP 地址
- 状态码
- 状态
- 错误信息
- 执行时间(ms)
- 创建时间
### 5. 删除单条日志
**接口**: `DELETE /admin/logs/{id}`
**响应示例**:
```json
{
"code": 200,
"message": "删除成功",
"data": null
}
```
### 6. 批量删除日志
**接口**: `POST /admin/logs/batch-delete`
**请求参数**:
```json
{
"ids": [1, 2, 3, 4, 5]
}
```
**响应示例**:
```json
{
"code": 200,
"message": "批量删除成功",
"data": null
}
```
### 7. 清理历史日志
**接口**: `POST /admin/logs/clear`
**请求参数**:
```json
{
"days": 30
}
```
**说明**: 清理指定天数前的所有日志记录,默认清理 30 天前的数据。
**响应示例**:
```json
{
"code": 200,
"message": "清理成功",
"data": null
}
```
## 日志记录规则
### 1. 自动记录的请求
所有经过 `log.request` 中间件的请求都会被自动记录,包括:
- 用户管理操作
- 角色管理操作
- 权限管理操作
- 部门管理操作
- 系统配置操作
- 其他所有后台管理操作
### 2. 不记录的请求
- 登录接口 (`POST /admin/auth/login`)
- 健康检查接口 (`GET /up`)
- 其他明确排除的路由
### 3. 敏感信息过滤
以下字段会被自动过滤,记录为 `******`:
- `password`
- `password_confirmation`
- `token`
- `secret`
- `key`
### 4. 错误日志处理
- 成功请求 (HTTP 状态码 < 400): `status` = `success`
- 失败请求 (HTTP 状态码 >= 400): `status` = `error`
- 错误时记录响应内容和错误消息
- 同时写入 Laravel 日志文件 (`storage/logs/laravel.log`)
## 模块和操作名称解析
### 模块名称
从 URL 路径中解析,例如:
- `/admin/users` → 模块: `users`
- `/admin/roles` → 模块: `roles`
- `/admin/configs` → 模块: `configs`
### 操作名称
根据 HTTP 方法和资源名称生成:
- `GET /admin/users` → 操作: `查询 users`
- `POST /admin/users` → 操作: `创建 users`
- `PUT /admin/users/1` → 操作: `更新 users`
- `DELETE /admin/users/1` → 操作: `删除 users`
## 性能优化建议
### 1. 定期清理日志
建议使用 Laravel 任务调度器定期清理历史日志:
```php
// app/Console/Kernel.php
protected function schedule(Schedule $schedule)
{
// 每天凌晨 2 点清理 90 天前的日志
$schedule->call(function () {
app(LogService::class)->clearLogs(90);
})->dailyAt('02:00');
}
```
### 2. 数据库索引
确保以下字段有索引:
- `user_id`
- `username`
- `module`
- `status`
- `created_at`
### 3. 分页查询
列表查询必须使用分页,避免一次加载过多数据。
### 4. 异步记录
日志记录操作应放在请求处理后,不影响响应速度。
## 前端集成示例
### Vue3 + Ant Design Vue
```vue
<template>
<a-card title="操作日志">
<!-- 搜索表单 -->
<a-form layout="inline" :model="searchParams">
<a-form-item label="用户名">
<a-input v-model:value="searchParams.username" placeholder="请输入用户名" />
</a-form-item>
<a-form-item label="模块">
<a-input v-model:value="searchParams.module" placeholder="请输入模块名" />
</a-form-item>
<a-form-item label="状态">
<a-select v-model:value="searchParams.status" placeholder="请选择状态">
<a-select-option value="success">成功</a-select-option>
<a-select-option value="error">失败</a-select-option>
</a-select>
</a-form-item>
<a-form-item>
<a-button type="primary" @click="handleSearch">查询</a-button>
<a-button @click="handleReset">重置</a-button>
<a-button @click="handleExport">导出</a-button>
</a-form-item>
</a-form>
<!-- 数据表格 -->
<a-table
:columns="columns"
:data-source="logs"
:loading="loading"
:pagination="pagination"
@change="handleTableChange"
>
<template #status="{ record }">
<a-tag :color="record.status === 'success' ? 'green' : 'red'">
{{ record.status === 'success' ? '成功' : '失败' }}
</a-tag>
</template>
<template #action="{ record }">
<a-button type="link" @click="handleView(record)">查看</a-button>
<a-button type="link" danger @click="handleDelete(record.id)">删除</a-button>
</template>
</a-table>
</a-card>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import request from '@/utils/request'
const logs = ref([])
const loading = ref(false)
const searchParams = reactive({
username: '',
module: '',
status: null,
page: 1,
page_size: 20
})
const pagination = reactive({
total: 0,
current: 1,
pageSize: 20
})
const columns = [
{ title: 'ID', dataIndex: 'id', width: 80 },
{ title: '用户名', dataIndex: 'username', width: 120 },
{ title: '模块', dataIndex: 'module', width: 100 },
{ title: '操作', dataIndex: 'action', width: 150 },
{ title: '请求方法', dataIndex: 'method', width: 100 },
{ title: 'IP 地址', dataIndex: 'ip', width: 150 },
{ title: '状态', dataIndex: 'status', slots: { customRender: 'status' }, width: 100 },
{ title: '执行时间', dataIndex: 'execution_time', width: 100 },
{ title: '创建时间', dataIndex: 'created_at', width: 180 },
{ title: '操作', slots: { customRender: 'action' }, width: 150, fixed: 'right' }
]
// 获取日志列表
const fetchLogs = async () => {
loading.value = true
try {
const res = await request.get('/admin/logs', { params: searchParams })
logs.value = res.data.list
pagination.total = res.data.total
pagination.current = res.data.page
pagination.pageSize = res.data.page_size
} catch (error) {
message.error('获取日志失败')
} finally {
loading.value = false
}
}
// 查询
const handleSearch = () => {
searchParams.page = 1
fetchLogs()
}
// 重置
const handleReset = () => {
searchParams.username = ''
searchParams.module = ''
searchParams.status = null
searchParams.page = 1
fetchLogs()
}
// 导出
const handleExport = async () => {
try {
const res = await request.post('/admin/logs/export', searchParams, {
responseType: 'blob'
})
const url = window.URL.createObjectURL(new Blob([res]))
const link = document.createElement('a')
link.href = url
link.setAttribute('download', `操作日志_${new Date().getTime()}.xlsx`)
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
message.success('导出成功')
} catch (error) {
message.error('导出失败')
}
}
// 查看详情
const handleView = (record) => {
// 打开详情对话框
console.log('查看日志', record)
}
// 删除
const handleDelete = async (id) => {
try {
await request.delete(`/admin/logs/${id}`)
message.success('删除成功')
fetchLogs()
} catch (error) {
message.error('删除失败')
}
}
// 表格分页变化
const handleTableChange = (pag) => {
searchParams.page = pag.current
searchParams.page_size = pag.pageSize
fetchLogs()
}
onMounted(() => {
fetchLogs()
})
</script>
```
## 注意事项
1. **权限控制**: 日志管理接口需要相应的权限才能访问
2. **数据安全**: 敏感信息已自动过滤,但仍需注意日志数据的安全存储
3. **性能影响**: 虽然日志记录不影响响应速度,但大量日志会增加数据库负载
4. **定期备份**: 重要日志数据建议定期备份
5. **日志分析**: 可结合 BI 工具对日志数据进行深度分析
## 常见问题
### Q1: 为什么某些请求没有被记录?
A: 检查路由是否应用了 `log.request` 中间件,或者在中间件中是否被排除了。
### Q2: 日志数据过多怎么办?
A: 使用 `clearLogs` 方法定期清理历史日志,或设置任务调度器自动清理。
### Q3: 如何自定义日志记录规则?
A: 修改 `LogRequestMiddleware` 中的 `parseModule``parseAction` 方法。
### Q4: 日志记录会影响性能吗?
A: 日志记录在请求处理后执行,不影响响应速度。但大量日志会增加数据库写入压力。
### Q5: 如何查看完整的请求参数?
A: 在日志详情接口中,`params` 字段包含了完整的请求参数(敏感信息已过滤)。
## 更新日志
### v1.0.0 (2024-01-01)
- 初始版本
- 实现基础日志记录功能
- 支持多维度查询和筛选
- 支持数据导出
- 支持批量删除和清理
+788 -70
View File
@@ -44,6 +44,8 @@ ## 技术栈
- Laravel 11
- Redis 缓存
- Intervention Image (图像处理)
- Laravel-S / Swoole (WebSocket 通知)
- WebSocket (实时消息推送)
## 数据库表结构
@@ -84,15 +86,18 @@ ### system_logs (操作日志表)
- `username`: 用户名
- `module`: 模块
- `action`: 操作
- `method`: 请求方法
- `method`: 请求方法 (GET/POST/PUT/DELETE)
- `url`: 请求URL
- `ip`: IP地址
- `user_agent`: 用户代理
- `request_data`: 请求数JSON
- `response_data`: 响应数据(JSON
- `duration`: 执行时间(毫秒)
- `status_code`: 状态
- `params`: 请求数(JSON
- `result`: 响应结果(仅错误时记录
- `status_code`: HTTP状态码
- `status`: 状态 (success/error)
- `error_message`: 错误信息
- `execution_time`: 执行时间(毫秒)
- `created_at`: 创建时间
- `updated_at`: 更新时间
### system_tasks (任务表)
- `id`: 主键
@@ -197,34 +202,220 @@ #### 批量更新配置状态
### 操作日志管理
操作日志模块通过中间件自动记录所有后台管理 API 请求,实现全自动化的日志记录功能。
#### 中间件说明
**LogRequestMiddleware**
位置: `app/Http/Middleware/LogRequestMiddleware.php`
功能:
- 自动拦截所有经过的请求
- 记录请求和响应信息
- 计算请求执行时间
- 提取用户信息和操作详情
- 过滤敏感参数(password、token、secret、key
- 获取客户端真实 IP(支持代理)
- 异常处理,记录失败不影响业务
使用方式:
```php
// 在路由中应用
Route::middleware(['auth.check:admin', 'log.request'])->group(function () {
// 需要记录日志的路由
});
```
#### 日志记录规则
**自动记录的请求:**
所有经过 `log.request` 中间件的请求都会被自动记录,包括:
- 用户管理操作
- 角色管理操作
- 权限管理操作
- 部门管理操作
- 系统配置操作
- 其他所有后台管理操作
**不记录的请求:**
- 登录接口 (`POST /admin/auth/login`)
- 健康检查接口 (`GET /up`)
- 其他明确排除的路由
**敏感信息过滤:**
以下字段会被自动过滤,记录为 `******`:
- `password`
- `password_confirmation`
- `token`
- `secret`
- `key`
**错误日志处理:**
- 成功请求 (HTTP 状态码 < 400): `status` = `success`
- 失败请求 (HTTP 状态码 >= 400): `status` = `error`
- 错误时记录响应内容和错误消息
- 同时写入 Laravel 日志文件 (`storage/logs/laravel.log`)
#### 获取日志列表
- **接口**: `GET /admin/logs`
- **参数**:
- `page`, `page_size`
- `keyword`: 搜索关键词(用户名/模块/操作)
- `module`: 模块
- `action`: 操作
- `user_id`: 用户ID
- `start_date`: 开始日期
- `end_date`: 结束日期
- `order_by`, `order_direction`
```json
{
"user_id": 1,
"username": "admin",
"module": "users",
"action": "创建 users",
"status": "success",
"start_date": "2024-01-01",
"end_date": "2024-12-31",
"ip": "192.168.1.1",
"page": 1,
"page_size": 20
}
```
- **响应示例**:
```json
{
"code": 200,
"message": "success",
"data": {
"list": [
{
"id": 1,
"user_id": 1,
"username": "admin",
"module": "users",
"action": "创建 users",
"method": "POST",
"url": "http://example.com/admin/users",
"ip": "192.168.1.1",
"user_agent": "Mozilla/5.0...",
"params": {
"name": "test",
"email": "test@example.com"
},
"result": null,
"status_code": 200,
"status": "success",
"error_message": null,
"execution_time": 125,
"created_at": "2024-01-01 12:00:00"
}
],
"total": 100,
"page": 1,
"page_size": 20
}
}
```
#### 获取日志详情
- **接口**: `GET /admin/logs/{id}`
- **响应示例**:
```json
{
"code": 200,
"message": "success",
"data": {
"id": 1,
"user_id": 1,
"username": "admin",
"module": "users",
"action": "创建 users",
"method": "POST",
"url": "http://example.com/admin/users",
"ip": "192.168.1.1",
"user_agent": "Mozilla/5.0...",
"params": {
"name": "test",
"email": "test@example.com"
},
"result": null,
"status_code": 200,
"status": "success",
"error_message": null,
"execution_time": 125,
"created_at": "2024-01-01 12:00:00",
"user": {
"id": 1,
"name": "管理员",
"username": "admin"
}
}
}
```
#### 删除日志
#### 获取日志统计
- **接口**: `GET /admin/logs/statistics`
- **参数**:
```json
{
"start_date": "2024-01-01",
"end_date": "2024-12-31"
}
```
- **响应示例**:
```json
{
"code": 200,
"message": "success",
"data": {
"total": 1000,
"success": 950,
"error": 50
}
}
```
#### 导出日志
- **接口**: `POST /admin/logs/export`
- **参数**: 与获取日志列表相同的查询参数
- **响应**: Excel 文件下载
- **文件名格式**: `系统操作日志_YYYYMMDDHHmmss.xlsx`
- **包含字段**:
- ID
- 用户名
- 模块
- 操作
- 请求方法
- URL
- IP 地址
- 状态码
- 状态
- 错误信息
- 执行时间(ms)
- 创建时间
#### 删除单条日志
- **接口**: `DELETE /admin/logs/{id}`
- **响应示例**:
```json
{
"code": 200,
"message": "删除成功",
"data": null
}
```
#### 批量删除日志
- **接口**: `POST /admin/logs/batch-delete`
- **参数**:
```json
{
"ids": [1, 2, 3]
"ids": [1, 2, 3, 4, 5]
}
```
- **响应示例**:
```json
{
"code": 200,
"message": "批量删除成功",
"data": null
}
```
#### 清理日志
#### 清理历史日志
- **接口**: `POST /admin/logs/clear`
- **参数**:
```json
@@ -232,39 +423,129 @@ #### 清理日志
"days": 30
}
```
- **说明**: 删除指定天数前的日志记录
#### 获取日志统计
- **接口**: `GET /admin/logs/statistics`
- **参数**:
- `start_date`: 开始日期
- `end_date`: 结束日期
- **返回**:
- **说明**: 清理指定天数前的所有日志记录,默认清理 30 天前的数据
- **响应示例**:
```json
{
"code": 200,
"message": "success",
"data": {
"total_count": 1000,
"module_stats": [
{
"module": "user",
"count": 500
}
],
"user_stats": [
{
"user_id": 1,
"username": "admin",
"count": 800
}
]
}
"message": "清理成功",
"data": null
}
```
### 数据字典管理
数据字典模块提供了完整的字典管理功能,包括字典分类和字典项的 CRUD 操作。通过 WebSocket 实现了前后端缓存的实时同步更新。
#### 字典缓存更新机制
**概述**
字典缓存更新机制通过 WebSocket 实现前后端字典缓存的实时同步,确保在字典分类和字典项的增删改等操作后,前端字典缓存能够自动更新。
**技术实现**
1. **后端实现**
`app/Services/System/DictionaryService.php` 中添加了 WebSocket 通知功能:
**通知方法:**
- `notifyDictionaryUpdate` - 字典分类更新通知
- 触发时机:创建、更新、删除、批量删除、批量更新状态
- 消息类型:`dictionary_update`
- `notifyDictionaryItemUpdate` - 字典项更新通知
- 触发时机:创建、更新、删除、批量删除、批量更新状态
- 消息类型:`dictionary_item_update`
**WebSocket 消息格式:**
字典分类更新消息:
```json
{
"type": "dictionary_update",
"data": {
"action": "create|update|delete|batch_delete|batch_update_status",
"resource_type": "dictionary",
"data": {
// 字典分类数据
},
"timestamp": 1234567890
}
}
```
字典项更新消息:
```json
{
"type": "dictionary_item_update",
"data": {
"action": "create|update|delete|batch_delete|batch_update_status",
"resource_type": "dictionary_item",
"data": {
// 字典项数据
},
"timestamp": 1234567890
}
}
```
2. **前端实现**
创建了 `resources/admin/src/composables/useWebSocket.js` 来处理 WebSocket 连接和消息监听:
**主要功能:**
- 初始化 WebSocket 连接(检查用户登录状态、验证用户信息完整性)
- 消息处理器:`handleDictionaryUpdate``handleDictionaryItemUpdate`
- 缓存刷新:接收到更新通知后,自动刷新字典缓存并显示成功提示
**App.vue 集成:**
```javascript
onMounted(async () => {
// 初始化 WebSocket 连接
if (userStore.isLoggedIn()) {
initWebSocket()
}
})
onUnmounted(() => {
// 关闭 WebSocket 连接
closeWebSocket()
})
```
**工作流程:**
```
用户操作(增删改字典)
后端 Controller 调用 Service
Service 执行数据库操作
Service 清理后端缓存(Redis
Service 发送 WebSocket 广播通知
WebSocket 推送消息到所有在线客户端
前端接收 WebSocket 消息
触发相应的消息处理器
刷新前端字典缓存
显示成功提示
```
**注意事项:**
- WebSocket 仅在用户登录后建立连接
- 连接失败会自动重试(最多 5 次)
- 页面卸载时会自动关闭连接
- WebSocket 通知功能依赖于 Laravel-S (Swoole) 环境
- 在普通 PHP 环境下运行时,WebSocket 通知会优雅降级(不发送通知,但不影响功能)
#### 获取字典列表
- **接口**: `GET /admin/dictionaries`
- **参数**:
@@ -658,8 +939,17 @@ ### 系统配置缓存
### 数据字典缓存
- **缓存键**: `dictionary:all``dictionary:code:{code}`
- **过期时间**: 60分钟
- **更新时机**: 字典数据增删改时自动清除
- **过期时间**: 3600秒(1小时)
- **更新时机**:
- 字典数据增删改时自动清除后端缓存(Redis)
- 通过 WebSocket 通知前端自动刷新缓存
**字典缓存同步流程:**
1. 后端执行字典操作(增删改)
2. 清理 Redis 缓存
3. 发送 WebSocket 广播通知
4. 前端接收通知并自动刷新缓存
5. 显示成功提示
## 服务层说明
@@ -681,13 +971,24 @@ ### LogService
**主要方法**:
- `getList()`: 获取日志列表
- `getById()`: 根据 ID 获取日志详情
- `getStatistics()`: 获取统计数据
- `getListQuery()`: 获取日志查询构建器
- `clearLogs()`: 清理过期日志
- `delete()`: 删除单条日志
- `batchDelete()`: 批量删除日志
- `record()`: 记录日志(由中间件自动调用)
**特性**:
- 自动记录所有经过中间件的请求
- 计算请求执行时间
- 过滤敏感参数
- 获取客户端真实 IP(支持代理)
- 异常处理,记录失败不影响业务
### DictionaryService
提供数据字典和字典项的管理功能。
提供数据字典和字典项的管理功能,包括 WebSocket 通知机制
**主要方法**:
- `getList()`: 获取字典列表
@@ -696,6 +997,13 @@ ### DictionaryService
- `createItem()`: 创建字典项
- `update()`: 更新字典
- `updateItem()`: 更新字典项
- `notifyDictionaryUpdate()`: 发送字典更新通知
- `notifyDictionaryItemUpdate()`: 发送字典项更新通知
**缓存机制**:
- Redis 缓存字典数据(TTL: 3600秒)
- WebSocket 实时通知前端更新
- 前端 Pinia + 本地存储持久化
### TaskService
@@ -744,49 +1052,459 @@ # 填充初始数据
- 常用数据字典
- 全国省市区数据
## 前端集成示例
### 日志管理页面
```vue
<template>
<a-card title="操作日志">
<!-- 搜索表单 -->
<a-form layout="inline" :model="searchParams">
<a-form-item label="用户名">
<a-input v-model:value="searchParams.username" placeholder="请输入用户名" />
</a-form-item>
<a-form-item label="模块">
<a-input v-model:value="searchParams.module" placeholder="请输入模块名" />
</a-form-item>
<a-form-item label="状态">
<a-select v-model:value="searchParams.status" placeholder="请选择状态">
<a-select-option value="success">成功</a-select-option>
<a-select-option value="error">失败</a-select-option>
</a-select>
</a-form-item>
<a-form-item>
<a-button type="primary" @click="handleSearch">查询</a-button>
<a-button @click="handleReset">重置</a-button>
<a-button @click="handleExport">导出</a-button>
</a-form-item>
</a-form>
<!-- 数据表格 -->
<a-table
:columns="columns"
:data-source="logs"
:loading="loading"
:pagination="pagination"
@change="handleTableChange"
>
<template #status="{ record }">
<a-tag :color="record.status === 'success' ? 'green' : 'red'">
{{ record.status === 'success' ? '成功' : '失败' }}
</a-tag>
</template>
<template #action="{ record }">
<a-button type="link" @click="handleView(record)">查看</a-button>
<a-button type="link" danger @click="handleDelete(record.id)">删除</a-button>
</template>
</a-table>
</a-card>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import request from '@/utils/request'
const logs = ref([])
const loading = ref(false)
const searchParams = reactive({
username: '',
module: '',
status: null,
page: 1,
page_size: 20
})
const pagination = reactive({
total: 0,
current: 1,
pageSize: 20
})
const columns = [
{ title: 'ID', dataIndex: 'id', width: 80 },
{ title: '用户名', dataIndex: 'username', width: 120 },
{ title: '模块', dataIndex: 'module', width: 100 },
{ title: '操作', dataIndex: 'action', width: 150 },
{ title: '请求方法', dataIndex: 'method', width: 100 },
{ title: 'IP 地址', dataIndex: 'ip', width: 150 },
{ title: '状态', dataIndex: 'status', slots: { customRender: 'status' }, width: 100 },
{ title: '执行时间', dataIndex: 'execution_time', width: 100 },
{ title: '创建时间', dataIndex: 'created_at', width: 180 },
{ title: '操作', slots: { customRender: 'action' }, width: 150, fixed: 'right' }
]
// 获取日志列表
const fetchLogs = async () => {
loading.value = true
try {
const res = await request.get('/admin/logs', { params: searchParams })
logs.value = res.data.list
pagination.total = res.data.total
pagination.current = res.data.page
pagination.pageSize = res.data.page_size
} catch (error) {
message.error('获取日志失败')
} finally {
loading.value = false
}
}
// 查询
const handleSearch = () => {
searchParams.page = 1
fetchLogs()
}
// 重置
const handleReset = () => {
searchParams.username = ''
searchParams.module = ''
searchParams.status = null
searchParams.page = 1
fetchLogs()
}
// 导出
const handleExport = async () => {
try {
const res = await request.post('/admin/logs/export', searchParams, {
responseType: 'blob'
})
const url = window.URL.createObjectURL(new Blob([res]))
const link = document.createElement('a')
link.href = url
link.setAttribute('download', `操作日志_${new Date().getTime()}.xlsx`)
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
message.success('导出成功')
} catch (error) {
message.error('导出失败')
}
}
// 查看详情
const handleView = (record) => {
// 打开详情对话框
console.log('查看日志', record)
}
// 删除
const handleDelete = async (id) => {
try {
await request.delete(`/admin/logs/${id}`)
message.success('删除成功')
fetchLogs()
} catch (error) {
message.error('删除失败')
}
}
// 表格分页变化
const handleTableChange = (pag) => {
searchParams.page = pag.current
searchParams.page_size = pag.pageSize
fetchLogs()
}
onMounted(() => {
fetchLogs()
})
</script>
```
## 注意事项
1. **Swoole环境注意事项**:
- 文件上传时注意临时文件清理
- 使用Redis缓存避免内存泄漏
- 图片压缩使用协程安全的方式
### 1. Swoole环境注意事项
- 文件上传时注意临时文件清理
- 使用Redis缓存避免内存泄漏
- 图片压缩使用协程安全的方式
- WebSocket 通知依赖于 Laravel-S 环境
2. **安全注意事项**:
- 文件上传必须验证文件类型和大小
- 敏感操作必须记录日志
- 配置数据不要存储密码等敏感信息
### 2. 安全注意事项
- 文件上传必须验证文件类型和大小
- 敏感操作必须记录日志
- 配置数据不要存储密码等敏感信息
- 日志敏感信息已自动过滤
3. **性能优化**:
- 城市数据使用Redis缓存
- 大量日志数据定期清理
- 图片上传时进行压缩处理
### 3. 性能优化
- 城市数据使用Redis缓存
- 大量日志数据定期清理
- 图片上传时进行压缩处理
- 日志记录在请求处理后执行,不影响响应速度
4. **文件上传**:
- 限制文件上传大小
- 验证文件MIME类型
- 定期清理临时文件
### 4. 文件上传
- 限制文件上传大小
- 验证文件MIME类型
- 定期清理临时文件
### 5. 日志管理
- 定期清理历史日志(建议使用任务调度器)
- 确保查询字段有索引
- 使用分页查询避免加载过多数据
## 性能优化建议
### 1. 定期清理日志
建议使用 Laravel 任务调度器定期清理历史日志:
```php
// app/Console/Kernel.php
protected function schedule(Schedule $schedule)
{
// 每天凌晨 2 点清理 90 天前的日志
$schedule->call(function () {
app(LogService::class)->clearLogs(90);
})->dailyAt('02:00');
}
```
### 2. 数据库索引
确保以下字段有索引:
- `system_logs`: `user_id`, `username`, `module`, `status`, `created_at`
- `system_dictionaries`: `code`, `status`
- `system_dictionary_items`: `dictionary_id`, `status`
- `system_configs`: `group`, `key`, `status`
### 3. 分页查询
列表查询必须使用分页,避免一次加载过多数据。
### 4. 异步记录
日志记录操作应放在请求处理后,不影响响应速度。
### 5. 细粒度缓存更新
字典缓存当前实现为全量刷新,未来可以优化为增量更新:
```javascript
// 只更新受影响的字典
async function handleDictionaryUpdate(data) {
const { action, data: dictData } = data
if (action === 'update' && dictData.code) {
// 只更新特定的字典
await dictionaryStore.getDictionary(dictData.code, true)
} else {
// 全量刷新
await dictionaryStore.refresh(true)
}
}
```
## 扩展建议
1. **日志告警**: 添加日志异常告警功能
2. **配置加密**: 敏感配置数据加密存储
3. **多语言**: 支持配置数据的多语言
4. **任务监控**: 添加任务执行监控和通知
5. **CDN集成**: 文件上传支持CDN分发
### 1. 日志告警
添加日志异常告警功能,当出现大量错误日志时自动通知管理员。
### 2. 配置加密
敏感配置数据加密存储,提高安全性。
### 3. 多语言
支持配置数据的多语言,便于国际化部署。
### 4. 任务监控
添加任务执行监控和通知,实时掌握任务运行状态。
### 5. CDN集成
文件上传支持CDN分发,提高访问速度。
### 6. WebSocket 权限控制
可以只向有权限的用户发送通知:
```php
// 后端只向有字典管理权限的用户发送
$adminUserIds = User::whereHas('roles', function($query) {
$query->where('name', 'admin');
})->pluck('id')->toArray();
$this->webSocketService->sendToUsers($adminUserIds, $message);
```
### 7. 消息队列
对于高并发场景,可以使用消息队列异步发送 WebSocket 通知:
```php
// 使用 Laravel 队列
UpdateDictionaryCacheJob::dispatch($action, $data);
```
## 常见问题
### Q: 如何清除城市数据缓存?
### Q1: 如何清除城市数据缓存?
A: 调用 `CityService::clearCache()` 方法或运行 `php artisan cache:forget city:tree`
### Q: 图片上传后如何压缩?
### Q2: 图片上传后如何压缩?
A: 上传时设置 `compress=true``quality` 参数,系统会自动压缩。
### Q: 如何配置定时任务?
### Q3: 如何配置定时任务?
A: 在Admin后台创建任务,设置Cron表达式,系统会自动调度执行。
### Q: 数据字典如何使用?
### Q4: 数据字典如何使用?
A: 通过Public API获取字典数据,前端根据数据渲染下拉框等组件。
### Q: 日志数据过多如何处理?
### Q5: 日志数据过多如何处理?
A: 定期使用 `/admin/logs/clear` 接口清理过期日志,或在后台设置自动清理任务。
### Q6: 为什么某些请求没有被记录?
A: 检查路由是否应用了 `log.request` 中间件,或者在中间件中是否被排除了。
### Q7: 字典缓存未更新怎么办?
A: 检查以下几点:
- 确认 Laravel-S 服务是否启动:`php bin/laravels status`
- 检查浏览器控制台是否有 WebSocket 错误
- 确认用户已登录且有 token
- 手动刷新页面验证 API 是否正常
### Q8: WebSocket 连接失败会影响功能吗?
A: 不会。WebSocket 连接失败不影响页面正常使用,只是不会收到自动更新通知。字典数据仍会正常更新到数据库和 Redis 缓存,只是前端不会收到实时通知,需要手动刷新页面。
## 测试建议
### 1. 功能测试
1. 测试各种请求是否被正确记录
2. 测试敏感信息是否被正确过滤
3. 测试日志查询和筛选功能
4. 测试日志导出功能
5. 测试批量删除和清理功能
6. 测试字典 WebSocket 通知是否正确
### 2. 性能测试
1. 测试日志记录对响应时间的影响
2. 测试大量日志数据的查询性能
3. 测试并发写入的性能
4. 测试 WebSocket 广播性能
### 3. 集成测试(字典 WebSocket
1. 启动后端服务(Laravel-S
2. 启动前端开发服务器
3. 在浏览器中登录系统
4. 打开开发者工具的 Network -> WS 标签查看 WebSocket 消息
5. 执行字典增删改操作
6. 验证:
- WebSocket 消息是否正确接收
- 缓存是否自动刷新
- 页面数据是否更新
- 提示消息是否显示
### 4. 并发测试
1. 打开多个浏览器窗口并登录
2. 在一个窗口中进行字典操作
3. 验证所有窗口的缓存是否同步更新
### 5. 边界测试
1. 测试异常情况下的日志记录
2. 测试超长参数的处理
3. 测试特殊字符的处理
4. 测试 WebSocket 断连重连机制
## 文件清单
### 核心文件
**控制器:**
```
app/Http/Controllers/System/Admin/Config.php
app/Http/Controllers/System/Admin/Log.php
app/Http/Controllers/System/Admin/Dictionary.php
app/Http/Controllers/System/Admin/Task.php
app/Http/Controllers/System/Admin/City.php
app/Http/Controllers/System/Admin/Upload.php
app/Http/Controllers/System/WebSocket.php
```
**中间件:**
```
app/Http/Middleware/LogRequestMiddleware.php
```
**请求验证:**
```
app/Http/Requests/LogRequest.php
```
**服务层:**
```
app/Services/System/ConfigService.php
app/Services/System/LogService.php
app/Services/System/DictionaryService.php
app/Services/System/TaskService.php
app/Services/System/CityService.php
app/Services/System/UploadService.php
app/Services/WebSocket/WebSocketService.php
```
**模型:**
```
app/Models/System/Config.php
app/Models/System/Log.php
app/Models/System/Dictionary.php
app/Models/System/DictionaryItem.php
app/Models/System/Task.php
app/Models/System/City.php
```
**路由:**
```
routes/admin.php (后台管理路由)
routes/api.php (公共 API 路由)
```
**前端:**
```
resources/admin/src/composables/useWebSocket.js
resources/admin/src/App.vue (集成 WebSocket)
```
**文档:**
```
docs/README_SYSTEM.md (本文档)
```
## 总结
System 基础模块提供了完整的系统管理功能,包括:
### 核心功能
- ✅ 系统配置管理(多分组、多类型支持)
- ✅ 数据字典管理(分类 + 字典项)
- ✅ 操作日志管理(自动记录、多维度查询、导出)
- ✅ 任务管理(定时任务、手动执行、统计)
- ✅ 城市数据管理(三级联动、缓存优化)
- ✅ 文件上传管理(单文件、多文件、Base64、压缩)
### 高级特性
- ✅ WebSocket 实时通知(字典缓存自动更新)
- ✅ Redis 缓存机制(性能优化)
- ✅ 自动化日志记录(中间件拦截)
- ✅ 敏感信息保护(自动过滤)
- ✅ 数据导出功能(Excel 导出)
- ✅ 批量操作支持
- ✅ 完整的 API 文档
### 性能优化
- ✅ Redis 缓存(城市数据、系统配置、数据字典)
- ✅ 日志异步记录(不影响响应速度)
- ✅ 分页查询(避免加载过多数据)
- ✅ 图片压缩(减少存储空间)
- ✅ WebSocket 实时更新(减少不必要的请求)
### 安全特性
- ✅ 敏感信息过滤
- ✅ 文件类型验证
- ✅ 请求日志记录
- ✅ IP 地址记录
- ✅ 异常处理机制
System 模块现已完全集成到项目中,提供了完整的系统管理功能和优秀的用户体验。
File diff suppressed because it is too large Load Diff
+61 -51
View File
@@ -1,131 +1,141 @@
<script setup>
import { onMounted, onUnmounted, computed, watch, nextTick } from 'vue'
import { storeToRefs } from 'pinia'
import { useI18nStore } from './stores/modules/i18n'
import { useLayoutStore } from './stores/modules/layout'
import { useUserStore } from './stores/modules/user'
import { useMessageStore } from './stores/modules/message'
import { useWebSocket } from './composables/useWebSocket'
import { theme } from 'ant-design-vue'
import i18n from './i18n'
import zhCN from 'ant-design-vue/es/locale/zh_CN'
import enUS from 'ant-design-vue/es/locale/en_US'
import dayjs from 'dayjs'
import 'dayjs/locale/zh-cn'
import 'dayjs/locale/en'
import { onMounted, onUnmounted, computed, watch, nextTick } from "vue";
import { storeToRefs } from "pinia";
import { useI18nStore } from "./stores/modules/i18n";
import { useLayoutStore } from "./stores/modules/layout";
import { useUserStore } from "./stores/modules/user";
import { useMessageStore } from "./stores/modules/message";
import { useWebSocket } from "./composables/useWebSocket";
import { theme } from "ant-design-vue";
import i18n from "./i18n";
import zhCN from "ant-design-vue/es/locale/zh_CN";
import enUS from "ant-design-vue/es/locale/en_US";
import dayjs from "dayjs";
import "dayjs/locale/zh-cn";
import "dayjs/locale/en";
// 定义组件名称
defineOptions({
name: 'App'
})
name: "App",
});
// i18n store
const i18nStore = useI18nStore()
const i18nStore = useI18nStore();
// layout store
const layoutStore = useLayoutStore()
const layoutStore = useLayoutStore();
// user store
const userStore = useUserStore()
const userStore = useUserStore();
// message store
const messageStore = useMessageStore()
const messageStore = useMessageStore();
// WebSocket
const { initWebSocket, closeWebSocket } = useWebSocket()
const { initWebSocket, closeWebSocket } = useWebSocket();
// 解构 themeColor 以确保响应式
const { themeColor } = storeToRefs(layoutStore)
const { themeColor } = storeToRefs(layoutStore);
// Ant Design Vue 语言配置
const antLocale = computed(() => {
return i18nStore.currentLocale === 'zh-CN' ? zhCN : enUS
})
return i18nStore.currentLocale === "zh-CN" ? zhCN : enUS;
});
// 获取弹出容器
const getPopupContainer = () => {
return document.body
}
return document.body;
};
// Ant Design Vue 主题配置
const antdTheme = computed(() => {
return {
algorithm: theme.defaultAlgorithm,
token: {
colorPrimary: themeColor.value || '#1890ff',
colorPrimary: themeColor.value || "#1890ff",
borderRadius: 6,
fontSize: 14,
},
components: {
Layout: {
headerBg: '#fff',
siderBg: '#001529',
headerBg: "#fff",
siderBg: "#001529",
},
Menu: {
darkItemBg: '#001529',
darkItemSelectedBg: themeColor.value || '#1890ff',
darkItemHoverBg: '#002140',
darkItemBg: "#001529",
darkItemSelectedBg: themeColor.value || "#1890ff",
darkItemHoverBg: "#002140",
},
},
}
})
};
});
// 监听主题颜色变化,更新 CSS 变量
watch(
themeColor,
(newColor) => {
if (newColor) {
document.documentElement.style.setProperty('--primary-color', newColor)
document.documentElement.style.setProperty(
"--primary-color",
newColor,
);
}
},
{ immediate: true }
)
{ immediate: true },
);
// 监听用户信息变化,当用户信息完整时初始化 WebSocket
watch(
() => [userStore.token, userStore.userInfo],
() => {
if (userStore.isUserInfoComplete()) {
initWebSocket()
initWebSocket();
} else if (!userStore.isLoggedIn()) {
// 用户未登录,关闭 WebSocket
closeWebSocket()
closeWebSocket();
}
},
{ deep: true }
)
{ deep: true },
);
onMounted(async () => {
await nextTick()
await nextTick();
// 恢复消息数据
messageStore.restoreMessages()
messageStore.restoreMessages();
// 从持久化的 store 中读取语言设置并同步到 i18n
i18n.global.locale.value = i18nStore.currentLocale
i18n.global.locale.value = i18nStore.currentLocale;
// 同步 dayjs 语言
dayjs.locale(i18nStore.currentLocale === 'zh-CN' ? 'zh-cn' : 'en')
dayjs.locale(i18nStore.currentLocale === "zh-CN" ? "zh-cn" : "en");
// 初始化主题颜色到 CSS 变量
if (layoutStore.themeColor) {
document.documentElement.style.setProperty('--primary-color', layoutStore.themeColor)
document.documentElement.style.setProperty(
"--primary-color",
layoutStore.themeColor,
);
}
// 尝试初始化 WebSocket 连接
if (userStore.isUserInfoComplete()) {
initWebSocket()
initWebSocket();
}
})
});
onUnmounted(() => {
// 关闭 WebSocket 连接
closeWebSocket()
})
closeWebSocket();
});
</script>
<template>
<a-config-provider :locale="antLocale" :theme="antdTheme" :getPopupContainer="getPopupContainer">
<a-config-provider
:locale="antLocale"
:theme="antdTheme"
:getPopupContainer="getPopupContainer"
>
<router-view />
</a-config-provider>
</template>
+124 -83
View File
@@ -1,36 +1,36 @@
import request from '@/utils/request'
import request from "@/utils/request";
export default {
// 认证相关
login: {
post: async function (params) {
return await request.post('auth/login', params)
return await request.post("auth/login", params);
},
},
logout: {
post: async function () {
return await request.post('auth/logout')
return await request.post("auth/logout");
},
},
me: {
get: async function () {
return await request.get('auth/me')
return await request.get("auth/me");
},
},
changePassword: {
post: async function (params) {
return await request.post('auth/change-password', params)
return await request.post("auth/change-password", params);
},
},
// 文件上传
upload: {
post: async function (file) {
const formData = new FormData()
formData.append('file', file)
return await request.post('system/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
const formData = new FormData();
formData.append("file", file);
return await request.post("system/upload", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
},
},
@@ -38,64 +38,71 @@ export default {
users: {
list: {
get: async function (params) {
return await request.get('auth/users', { params })
return await request.get("auth/users", { params });
},
},
detail: {
get: async function (id) {
return await request.get(`auth/users/${id}`)
return await request.get(`auth/users/${id}`);
},
},
add: {
post: async function (params) {
return await request.post('auth/users', params)
return await request.post("auth/users", params);
},
},
edit: {
put: async function (id, params) {
return await request.put(`auth/users/${id}`, params)
return await request.put(`auth/users/${id}`, params);
},
},
delete: {
delete: async function (id) {
return await request.delete(`auth/users/${id}`)
return await request.delete(`auth/users/${id}`);
},
},
batchDelete: {
post: async function (params) {
return await request.post('auth/users/batch-delete', params)
return await request.post("auth/users/batch-delete", params);
},
},
batchStatus: {
post: async function (params) {
return await request.post('auth/users/batch-status', params)
return await request.post("auth/users/batch-status", params);
},
},
batchDepartment: {
post: async function (params) {
return await request.post('auth/users/batch-department', params)
return await request.post(
"auth/users/batch-department",
params,
);
},
},
batchRoles: {
post: async function (params) {
return await request.post('auth/users/batch-roles', params)
return await request.post("auth/users/batch-roles", params);
},
},
export: {
post: async function (params) {
return await request.post('auth/users/export', params, { responseType: 'blob' })
return await request.post("auth/users/export", params, {
responseType: "blob",
});
},
},
import: {
post: async function (formData) {
return await request.post('auth/users/import', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
return await request.post("auth/users/import", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
},
},
downloadTemplate: {
get: async function () {
return await request.get('auth/users/download-template', { responseType: 'blob' })
return await request.get("auth/users/download-template", {
responseType: "blob",
});
},
},
},
@@ -104,27 +111,34 @@ export default {
onlineUsers: {
count: {
get: async function () {
return await request.get('auth/online-users/count')
return await request.get("auth/online-users/count");
},
},
list: {
get: async function (params) {
return await request.get('auth/online-users', { params })
return await request.get("auth/online-users", { params });
},
},
sessions: {
get: async function (userId) {
return await request.get(`auth/online-users/${userId}/sessions`)
return await request.get(
`auth/online-users/${userId}/sessions`,
);
},
},
offline: {
post: async function (userId, params) {
return await request.post(`auth/online-users/${userId}/offline`, params)
return await request.post(
`auth/online-users/${userId}/offline`,
params,
);
},
},
offlineAll: {
post: async function (userId) {
return await request.post(`auth/online-users/${userId}/offline-all`)
return await request.post(
`auth/online-users/${userId}/offline-all`,
);
},
},
},
@@ -133,77 +147,84 @@ export default {
roles: {
list: {
get: async function (params) {
return await request.get('auth/roles', { params })
return await request.get("auth/roles", { params });
},
},
all: {
get: async function () {
return await request.get('auth/roles/all')
return await request.get("auth/roles/all");
},
},
detail: {
get: async function (id) {
return await request.get(`auth/roles/${id}`)
return await request.get(`auth/roles/${id}`);
},
},
add: {
post: async function (params) {
return await request.post('auth/roles', params)
return await request.post("auth/roles", params);
},
},
edit: {
put: async function (id, params) {
return await request.put(`auth/roles/${id}`, params)
return await request.put(`auth/roles/${id}`, params);
},
},
delete: {
delete: async function (id) {
return await request.delete(`auth/roles/${id}`)
return await request.delete(`auth/roles/${id}`);
},
},
batchDelete: {
post: async function (params) {
return await request.post('auth/roles/batch-delete', params)
return await request.post("auth/roles/batch-delete", params);
},
},
batchStatus: {
post: async function (params) {
return await request.post('auth/roles/batch-status', params)
return await request.post("auth/roles/batch-status", params);
},
},
permissions: {
get: async function (id) {
return await request.get(`auth/roles/${id}/permissions`)
return await request.get(`auth/roles/${id}/permissions`);
},
post: async function (id, params) {
return await request.post(`auth/roles/${id}/permissions`, params)
return await request.post(
`auth/roles/${id}/permissions`,
params,
);
},
},
copy: {
post: async function (id, params) {
return await request.post(`auth/roles/${id}/copy`, params)
return await request.post(`auth/roles/${id}/copy`, params);
},
},
batchCopy: {
post: async function (params) {
return await request.post('auth/roles/batch-copy', params)
return await request.post("auth/roles/batch-copy", params);
},
},
export: {
post: async function (params) {
return await request.post('auth/roles/export', params, { responseType: 'blob' })
return await request.post("auth/roles/export", params, {
responseType: "blob",
});
},
},
import: {
post: async function (formData) {
return await request.post('auth/roles/import', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
return await request.post("auth/roles/import", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
},
},
downloadTemplate: {
get: async function () {
return await request.get('auth/roles/download-template', { responseType: 'blob' })
return await request.get("auth/roles/download-template", {
responseType: "blob",
});
},
},
},
@@ -212,131 +233,151 @@ export default {
permissions: {
list: {
get: async function (params) {
return await request.get('auth/permissions', { params })
return await request.get("auth/permissions", { params });
},
},
tree: {
get: async function () {
return await request.get('auth/permissions/tree')
return await request.get("auth/permissions/tree");
},
},
menu: {
get: async function () {
return await request.get('auth/permissions/menu')
return await request.get("auth/permissions/menu");
},
},
detail: {
get: async function (id) {
return await request.get(`auth/permissions/${id}`)
return await request.get(`auth/permissions/${id}`);
},
},
add: {
post: async function (params) {
return await request.post('auth/permissions', params)
return await request.post("auth/permissions", params);
},
},
edit: {
put: async function (id, params) {
return await request.put(`auth/permissions/${id}`, params)
return await request.put(`auth/permissions/${id}`, params);
},
},
delete: {
delete: async function (id) {
return await request.delete(`auth/permissions/${id}`)
return await request.delete(`auth/permissions/${id}`);
},
},
batchDelete: {
post: async function (params) {
return await request.post('auth/permissions/batch-delete', params)
return await request.post(
"auth/permissions/batch-delete",
params,
);
},
},
batchStatus: {
post: async function (params) {
return await request.post('auth/permissions/batch-status', params)
return await request.post(
"auth/permissions/batch-status",
params,
);
},
},
export: {
post: async function (params) {
return await request.post('auth/permissions/export', params, { responseType: 'blob' })
return await request.post("auth/permissions/export", params, {
responseType: "blob",
});
},
},
import: {
post: async function (formData) {
return await request.post('auth/permissions/import', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
return await request.post("auth/permissions/import", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
},
},
downloadTemplate: {
get: async function () {
return await request.get('auth/permissions/download-template', { responseType: 'blob' })
return await request.get("auth/permissions/download-template", {
responseType: "blob",
});
},
},
},
// 部门管理
departments: {
list: {
get: async function (params) {
return await request.get('auth/departments', { params })
},
// 部门管理
departments: {
list: {
get: async function (params) {
return await request.get("auth/departments", { params });
},
tree: {
get: async function (params) {
return await request.get('auth/departments/tree', { params })
},
},
tree: {
get: async function (params) {
return await request.get("auth/departments/tree", { params });
},
},
all: {
get: async function () {
return await request.get('auth/departments/all')
return await request.get("auth/departments/all");
},
},
detail: {
get: async function (id) {
return await request.get(`auth/departments/${id}`)
return await request.get(`auth/departments/${id}`);
},
},
add: {
post: async function (params) {
return await request.post('auth/departments', params)
return await request.post("auth/departments", params);
},
},
edit: {
put: async function (id, params) {
return await request.put(`auth/departments/${id}`, params)
return await request.put(`auth/departments/${id}`, params);
},
},
delete: {
delete: async function (id) {
return await request.delete(`auth/departments/${id}`)
return await request.delete(`auth/departments/${id}`);
},
},
batchDelete: {
post: async function (params) {
return await request.post('auth/departments/batch-delete', params)
return await request.post(
"auth/departments/batch-delete",
params,
);
},
},
batchStatus: {
post: async function (params) {
return await request.post('auth/departments/batch-status', params)
return await request.post(
"auth/departments/batch-status",
params,
);
},
},
export: {
post: async function (params) {
return await request.post('auth/departments/export', params, { responseType: 'blob' })
return await request.post("auth/departments/export", params, {
responseType: "blob",
});
},
},
import: {
post: async function (formData) {
return await request.post('auth/departments/import', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
return await request.post("auth/departments/import", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
},
},
downloadTemplate: {
get: async function () {
return await request.get('auth/departments/download-template', { responseType: 'blob' })
return await request.get("auth/departments/download-template", {
responseType: "blob",
});
},
},
},
}
};
+201 -159
View File
@@ -1,188 +1,211 @@
import request from '@/utils/request'
import request from "@/utils/request";
export default {
// 系统配置管理
configs: {
list: {
get: async function (params) {
return await request.get('system/configs', { params })
return await request.get("system/configs", { params });
},
},
groups: {
get: async function () {
return await request.get('system/configs/groups')
return await request.get("system/configs/groups");
},
},
all: {
get: async function (params) {
return await request.get('system/configs/all', { params })
return await request.get("system/configs/all", { params });
},
},
detail: {
get: async function (id) {
return await request.get(`system/configs/${id}`)
return await request.get(`system/configs/${id}`);
},
},
add: {
post: async function (params) {
return await request.post('system/configs', params)
return await request.post("system/configs", params);
},
},
edit: {
put: async function (id, params) {
return await request.put(`system/configs/${id}`, params)
return await request.put(`system/configs/${id}`, params);
},
},
delete: {
delete: async function (id) {
return await request.delete(`system/configs/${id}`)
return await request.delete(`system/configs/${id}`);
},
},
batchDelete: {
post: async function (params) {
return await request.post('system/configs/batch-delete', params)
return await request.post(
"system/configs/batch-delete",
params,
);
},
},
batchStatus: {
post: async function (params) {
return await request.post('system/configs/batch-status', params)
return await request.post(
"system/configs/batch-status",
params,
);
},
},
},
// 操作日志管理
logs: {
list: {
get: async function (params) {
return await request.get('system/logs', { params })
},
},
detail: {
get: async function (id) {
return await request.get(`system/logs/${id}`)
},
},
delete: {
delete: async function (id) {
return await request.delete(`system/logs/${id}`)
},
},
batchDelete: {
post: async function (params) {
return await request.post('system/logs/batch-delete', params)
},
},
clear: {
post: async function (params) {
return await request.post('system/logs/clear', params)
},
},
export: {
get: async function (params) {
return await request.get('system/logs/export', {
params,
responseType: 'blob'
})
},
},
statistics: {
get: async function (params) {
return await request.get('system/logs/statistics', { params })
},
list: {
get: async function (params) {
return await request.get("system/logs", { params });
},
},
detail: {
get: async function (id) {
return await request.get(`system/logs/${id}`);
},
},
delete: {
delete: async function (id) {
return await request.delete(`system/logs/${id}`);
},
},
batchDelete: {
post: async function (params) {
return await request.post("system/logs/batch-delete", params);
},
},
clear: {
post: async function (params) {
return await request.post("system/logs/clear", params);
},
},
export: {
get: async function (params) {
return await request.get("system/logs/export", {
params,
responseType: "blob",
});
},
},
statistics: {
get: async function (params) {
return await request.get("system/logs/statistics", { params });
},
},
},
// 数据字典管理
dictionaries: {
list: {
get: async function (params) {
return await request.get('system/dictionaries', { params })
},
// 数据字典管理
dictionaries: {
list: {
get: async function (params) {
return await request.get("system/dictionaries", { params });
},
},
all: {
get: async function () {
return await request.get("system/dictionaries/all");
},
},
detail: {
get: async function (id) {
return await request.get(`system/dictionaries/${id}`);
},
},
add: {
post: async function (params) {
return await request.post("system/dictionaries", params);
},
},
edit: {
put: async function (id, params) {
return await request.put(`system/dictionaries/${id}`, params);
},
},
delete: {
delete: async function (id) {
return await request.delete(`system/dictionaries/${id}`);
},
},
batchDelete: {
post: async function (params) {
return await request.post(
"system/dictionaries/batch-delete",
params,
);
},
},
batchStatus: {
post: async function (params) {
return await request.post(
"system/dictionaries/batch-status",
params,
);
},
},
items: {
all: {
get: async function () {
return await request.get('system/dictionaries/all')
},
},
detail: {
get: async function (id) {
return await request.get(`system/dictionaries/${id}`)
},
},
add: {
post: async function (params) {
return await request.post('system/dictionaries', params)
},
},
edit: {
put: async function (id, params) {
return await request.put(`system/dictionaries/${id}`, params)
},
},
delete: {
delete: async function (id) {
return await request.delete(`system/dictionaries/${id}`)
},
},
batchDelete: {
post: async function (params) {
return await request.post('system/dictionaries/batch-delete', params)
},
},
batchStatus: {
post: async function (params) {
return await request.post('system/dictionaries/batch-status', params)
},
},
items: {
all: {
get: async function (code) {
return await request.get(`system/dictionaries/code`, { params: { code } })
},
get: async function (code) {
return await request.get(`system/dictionaries/code`, {
params: { code },
});
},
},
},
},
// 数据字典项管理
dictionaryItems: {
list: {
get: async function (params) {
return await request.get('system/dictionary-items', { params })
return await request.get("system/dictionary-items", { params });
},
},
all: {
get: async function () {
return await request.get('system/dictionary-items/all')
return await request.get("system/dictionary-items/all");
},
},
detail: {
get: async function (id) {
return await request.get(`system/dictionary-items/${id}`)
return await request.get(`system/dictionary-items/${id}`);
},
},
add: {
post: async function (params) {
return await request.post('system/dictionary-items', params)
return await request.post("system/dictionary-items", params);
},
},
edit: {
put: async function (id, params) {
return await request.put(`system/dictionary-items/${id}`, params)
return await request.put(
`system/dictionary-items/${id}`,
params,
);
},
},
delete: {
delete: async function (id) {
return await request.delete(`system/dictionary-items/${id}`)
return await request.delete(`system/dictionary-items/${id}`);
},
},
batchDelete: {
post: async function (params) {
return await request.post('system/dictionary-items/batch-delete', params)
return await request.post(
"system/dictionary-items/batch-delete",
params,
);
},
},
batchStatus: {
post: async function (params) {
return await request.post('system/dictionary-items/batch-status', params)
return await request.post(
"system/dictionary-items/batch-status",
params,
);
},
},
},
@@ -191,52 +214,52 @@ export default {
tasks: {
list: {
get: async function (params) {
return await request.get('system/tasks', { params })
return await request.get("system/tasks", { params });
},
},
all: {
get: async function () {
return await request.get('system/tasks/all')
return await request.get("system/tasks/all");
},
},
detail: {
get: async function (id) {
return await request.get(`system/tasks/${id}`)
return await request.get(`system/tasks/${id}`);
},
},
add: {
post: async function (params) {
return await request.post('system/tasks', params)
return await request.post("system/tasks", params);
},
},
edit: {
put: async function (id, params) {
return await request.put(`system/tasks/${id}`, params)
return await request.put(`system/tasks/${id}`, params);
},
},
delete: {
delete: async function (id) {
return await request.delete(`system/tasks/${id}`)
return await request.delete(`system/tasks/${id}`);
},
},
batchDelete: {
post: async function (params) {
return await request.post('system/tasks/batch-delete', params)
return await request.post("system/tasks/batch-delete", params);
},
},
batchStatus: {
post: async function (params) {
return await request.post('system/tasks/batch-status', params)
return await request.post("system/tasks/batch-status", params);
},
},
run: {
post: async function (id) {
return await request.post(`system/tasks/${id}/run`)
return await request.post(`system/tasks/${id}/run`);
},
},
statistics: {
get: async function () {
return await request.get('system/tasks/statistics')
return await request.get("system/tasks/statistics");
},
},
},
@@ -245,62 +268,62 @@ export default {
cities: {
list: {
get: async function (params) {
return await request.get('system/cities', { params })
return await request.get("system/cities", { params });
},
},
tree: {
get: async function () {
return await request.get('system/cities/tree')
return await request.get("system/cities/tree");
},
},
detail: {
get: async function (id) {
return await request.get(`system/cities/${id}`)
return await request.get(`system/cities/${id}`);
},
},
children: {
get: async function (id) {
return await request.get(`system/cities/${id}/children`)
return await request.get(`system/cities/${id}/children`);
},
},
provinces: {
get: async function () {
return await request.get('system/cities/provinces')
return await request.get("system/cities/provinces");
},
},
cities: {
get: async function (provinceId) {
return await request.get(`system/cities/${provinceId}/cities`)
return await request.get(`system/cities/${provinceId}/cities`);
},
},
districts: {
get: async function (cityId) {
return await request.get(`system/cities/${cityId}/districts`)
return await request.get(`system/cities/${cityId}/districts`);
},
},
add: {
post: async function (params) {
return await request.post('system/cities', params)
return await request.post("system/cities", params);
},
},
edit: {
put: async function (id, params) {
return await request.put(`system/cities/${id}`, params)
return await request.put(`system/cities/${id}`, params);
},
},
delete: {
delete: async function (id) {
return await request.delete(`system/cities/${id}`)
return await request.delete(`system/cities/${id}`);
},
},
batchDelete: {
post: async function (params) {
return await request.post('system/cities/batch-delete', params)
return await request.post("system/cities/batch-delete", params);
},
},
batchStatus: {
post: async function (params) {
return await request.post('system/cities/batch-status', params)
return await request.post("system/cities/batch-status", params);
},
},
},
@@ -309,31 +332,31 @@ export default {
upload: {
single: {
post: async function (formData) {
return await request.post('system/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
return await request.post("system/upload", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
},
},
multiple: {
post: async function (formData) {
return await request.post('system/upload/multiple', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
return await request.post("system/upload/multiple", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
},
},
base64: {
post: async function (params) {
return await request.post('system/upload/base64', params)
return await request.post("system/upload/base64", params);
},
},
delete: {
post: async function (params) {
return await request.post('system/upload/delete', params)
return await request.post("system/upload/delete", params);
},
},
batchDelete: {
post: async function (params) {
return await request.post('system/upload/batch-delete', params)
return await request.post("system/upload/batch-delete", params);
},
},
},
@@ -342,67 +365,78 @@ export default {
notifications: {
list: {
get: async function (params) {
return await request.get('system/notifications', { params })
return await request.get("system/notifications", { params });
},
},
unread: {
get: async function (params) {
return await request.get('system/notifications/unread', { params })
return await request.get("system/notifications/unread", {
params,
});
},
},
unreadCount: {
get: async function () {
return await request.get('system/notifications/unread-count')
return await request.get("system/notifications/unread-count");
},
},
detail: {
get: async function (id) {
return await request.get(`system/notifications/${id}`)
return await request.get(`system/notifications/${id}`);
},
},
markAsRead: {
post: async function (id) {
return await request.post(`system/notifications/${id}/read`)
return await request.post(`system/notifications/${id}/read`);
},
},
batchMarkAsRead: {
post: async function (params) {
return await request.post('system/notifications/batch-read', params)
return await request.post(
"system/notifications/batch-read",
params,
);
},
},
markAllAsRead: {
post: async function () {
return await request.post('system/notifications/read-all')
return await request.post("system/notifications/read-all");
},
},
delete: {
delete: async function (id) {
return await request.delete(`system/notifications/${id}`)
return await request.delete(`system/notifications/${id}`);
},
},
batchDelete: {
post: async function (params) {
return await request.post('system/notifications/batch-delete', params)
return await request.post(
"system/notifications/batch-delete",
params,
);
},
},
clearRead: {
post: async function () {
return await request.post('system/notifications/clear-read')
return await request.post("system/notifications/clear-read");
},
},
statistics: {
get: async function () {
return await request.get('system/notifications/statistics')
return await request.get("system/notifications/statistics");
},
},
send: {
post: async function (params) {
return await request.post('system/notifications/send', params)
return await request.post("system/notifications/send", params);
},
},
retryUnsent: {
post: async function (params) {
return await request.post('system/notifications/retry-unsent', params)
return await request.post(
"system/notifications/retry-unsent",
params,
);
},
},
},
@@ -412,70 +446,78 @@ export default {
configs: {
all: {
get: async function () {
return await request.get('system/configs')
return await request.get("system/configs");
},
},
group: {
get: async function (params) {
return await request.get('system/configs/group', { params })
return await request.get("system/configs/group", {
params,
});
},
},
key: {
get: async function (params) {
return await request.get('system/configs/key', { params })
return await request.get("system/configs/key", { params });
},
},
},
dictionaries: {
all: {
get: async function () {
return await request.get('system/dictionaries')
return await request.get("system/dictionaries");
},
},
code: {
get: async function (params) {
return await request.get('system/dictionaries/code', { params })
return await request.get("system/dictionaries/code", {
params,
});
},
},
detail: {
get: async function (id) {
return await request.get(`system/dictionaries/${id}`)
return await request.get(`system/dictionaries/${id}`);
},
},
},
cities: {
tree: {
get: async function () {
return await request.get('system/cities/tree')
return await request.get("system/cities/tree");
},
},
provinces: {
get: async function () {
return await request.get('system/cities/provinces')
return await request.get("system/cities/provinces");
},
},
cities: {
get: async function (provinceId) {
return await request.get(`system/cities/${provinceId}/cities`)
return await request.get(
`system/cities/${provinceId}/cities`,
);
},
},
districts: {
get: async function (cityId) {
return await request.get(`system/cities/${cityId}/districts`)
return await request.get(
`system/cities/${cityId}/districts`,
);
},
},
detail: {
get: async function (id) {
return await request.get(`system/cities/${id}`)
return await request.get(`system/cities/${id}`);
},
},
},
upload: {
post: async function (formData) {
return await request.post('system/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
return await request.post("system/upload", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
},
},
},
}
};
+3 -1
View File
@@ -5,7 +5,9 @@
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
+49 -13
View File
@@ -34,32 +34,52 @@
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--bg-gradient-start) 0%, var(--bg-gradient-end) 100%);
background: linear-gradient(
135deg,
var(--bg-gradient-start) 0%,
var(--bg-gradient-end) 100%
);
position: relative;
overflow: hidden;
// Tech pattern background
&::before {
content: '';
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image:
radial-gradient(circle at 20% 50%, rgba(255, 107, 53, 0.03) 0%, transparent 50%),
radial-gradient(circle at 80% 20%, rgba(255, 179, 71, 0.05) 0%, transparent 40%),
radial-gradient(circle at 40% 80%, rgba(255, 127, 80, 0.04) 0%, transparent 40%);
radial-gradient(
circle at 20% 50%,
rgba(255, 107, 53, 0.03) 0%,
transparent 50%
),
radial-gradient(
circle at 80% 20%,
rgba(255, 179, 71, 0.05) 0%,
transparent 40%
),
radial-gradient(
circle at 40% 80%,
rgba(255, 127, 80, 0.04) 0%,
transparent 40%
);
pointer-events: none;
}
// Animated tech elements
&::after {
content: '';
content: "";
position: absolute;
width: 600px;
height: 600px;
background: radial-gradient(circle, rgba(255, 107, 53, 0.08) 0%, transparent 70%);
background: radial-gradient(
circle,
rgba(255, 107, 53, 0.08) 0%,
transparent 70%
);
border-radius: 50%;
top: -200px;
right: -200px;
@@ -94,14 +114,18 @@
// Tech accent line
&::before {
content: '';
content: "";
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
width: 80px;
height: 4px;
background: linear-gradient(90deg, var(--auth-primary), var(--auth-secondary));
background: linear-gradient(
90deg,
var(--auth-primary),
var(--auth-secondary)
);
border-radius: 0 0 4px 4px;
}
}
@@ -115,7 +139,11 @@
font-weight: 700;
color: var(--text-primary);
margin-bottom: 8px;
background: linear-gradient(135deg, var(--auth-primary-dark), var(--auth-primary));
background: linear-gradient(
135deg,
var(--auth-primary-dark),
var(--auth-primary)
);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
@@ -192,12 +220,20 @@
transition: all 0.3s ease;
&.ant-btn-primary {
background: linear-gradient(135deg, var(--auth-primary), var(--auth-primary-dark));
background: linear-gradient(
135deg,
var(--auth-primary),
var(--auth-primary-dark)
);
border: none;
box-shadow: 0 8px 24px rgba(255, 107, 53, 0.35);
&:hover {
background: linear-gradient(135deg, var(--auth-primary-light), var(--auth-primary));
background: linear-gradient(
135deg,
var(--auth-primary-light),
var(--auth-primary)
);
transform: translateY(-2px);
box-shadow: 0 12px 32px rgba(255, 107, 53, 0.45);
}
@@ -255,7 +291,7 @@
&::before,
&::after {
content: '';
content: "";
flex: 1;
height: 1px;
background: var(--border-color);
+6 -7
View File
@@ -1,15 +1,14 @@
import * as AIcons from '@ant-design/icons-vue'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import * as AIcons from "@ant-design/icons-vue";
import * as ElementPlusIconsVue from "@element-plus/icons-vue";
export default {
install(app) {
for (let icon in AIcons) {
app.component(`${icon}`, AIcons[icon])
app.component(`${icon}`, AIcons[icon]);
}
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(`El${key}`, component)
app.component(`El${key}`, component);
}
}
}
},
};
+48 -30
View File
@@ -1,63 +1,81 @@
<template>
<div class="sc-cron">
<a-input v-model:value="innerValue" placeholder="请输入Cron表达式" allow-clear @change="handleChange" />
<a-input
v-model:value="innerValue"
placeholder="请输入Cron表达式"
allow-clear
@change="handleChange"
/>
<div class="cron-tips">
<div class="tip-title">快捷设置:</div>
<a-space>
<a-button size="small" @click="setCron('0 0 * * *')">每天零点</a-button>
<a-button size="small" @click="setCron('0 0 * * 0')">每周零点</a-button>
<a-button size="small" @click="setCron('0 0 1 * *')">每月1号零点</a-button>
<a-button size="small" @click="setCron('0 0/6 * * *')">每6小时</a-button>
<a-button size="small" @click="setCron('0 * * * *')">每小时</a-button>
<a-button size="small" @click="setCron('0 */30 * * *')">每30分钟</a-button>
<a-button size="small" @click="setCron('0 0 * * *')"
>每天零点</a-button
>
<a-button size="small" @click="setCron('0 0 * * 0')"
>每周零点</a-button
>
<a-button size="small" @click="setCron('0 0 1 * *')"
>每月1号零点</a-button
>
<a-button size="small" @click="setCron('0 0/6 * * *')"
>每6小时</a-button
>
<a-button size="small" @click="setCron('0 * * * *')"
>每小时</a-button
>
<a-button size="small" @click="setCron('0 */30 * * *')"
>每30分钟</a-button
>
</a-space>
</div>
<div class="cron-description">
格式:
</div>
<div class="cron-description">格式: </div>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
import { ref, watch } from "vue";
defineOptions({
name: 'scCron'
})
name: "scCron",
});
const props = defineProps({
modelValue: {
type: String,
default: ''
default: "",
},
shortcuts: {
type: Array,
default: () => []
}
})
default: () => [],
},
});
const emit = defineEmits(['update:modelValue', 'change'])
const emit = defineEmits(["update:modelValue", "change"]);
const innerValue = ref(props.modelValue)
const innerValue = ref(props.modelValue);
// 监听外部值变化
watch(() => props.modelValue, (newVal) => {
innerValue.value = newVal
})
watch(
() => props.modelValue,
(newVal) => {
innerValue.value = newVal;
},
);
// 设置Cron表达式
const setCron = (cron) => {
innerValue.value = cron
emit('update:modelValue', cron)
emit('change', cron)
}
innerValue.value = cron;
emit("update:modelValue", cron);
emit("change", cron);
};
// 处理输入变化
const handleChange = (e) => {
const value = e.target.value
emit('update:modelValue', value)
emit('change', value)
}
const value = e.target.value;
emit("update:modelValue", value);
emit("change", value);
};
</script>
<style scoped lang="scss">
@@ -49,7 +49,10 @@ export default class UploadAdapter {
});
xhr.addEventListener("timeout", () => {
console.error("[UploadAdapter] Upload timeout for file:", file.name);
console.error(
"[UploadAdapter] Upload timeout for file:",
file.name,
);
reject(`Upload timeout: ${file.name}. Please try again.`);
});
@@ -59,7 +62,10 @@ export default class UploadAdapter {
// 检查响应状态码
if (xhr.status >= 200 && xhr.status < 300) {
if (!response) {
console.error("[UploadAdapter] Empty response for file:", file.name);
console.error(
"[UploadAdapter] Empty response for file:",
file.name,
);
reject(genericErrorText);
return;
}
@@ -68,18 +74,32 @@ export default class UploadAdapter {
if (response.code == 1 || response.code == undefined) {
const url = response.data?.url || response.data?.src;
if (!url) {
console.error("[UploadAdapter] No URL in response for file:", file.name, response);
console.error(
"[UploadAdapter] No URL in response for file:",
file.name,
response,
);
reject("Upload succeeded but no URL returned");
return;
}
resolve({ default: url });
} else {
const errorMessage = response.message || genericErrorText;
console.error("[UploadAdapter] Upload failed for file:", file.name, "Error:", errorMessage);
console.error(
"[UploadAdapter] Upload failed for file:",
file.name,
"Error:",
errorMessage,
);
reject(errorMessage);
}
} else {
console.error("[UploadAdapter] HTTP error for file:", file.name, "Status:", xhr.status);
console.error(
"[UploadAdapter] HTTP error for file:",
file.name,
"Status:",
xhr.status,
);
reject(`Server error (${xhr.status}): ${file.name}`);
}
});
@@ -1,7 +1,13 @@
<template>
<div :style="{ '--editor-height': editorHeight }">
<ckeditor :editor="editor" v-model="editorData" :config="editorConfig" :disabled="disabled" @blur="onBlur"
@focus="onFocus"></ckeditor>
<ckeditor
:editor="editor"
v-model="editorData"
:config="editorConfig"
:disabled="disabled"
@blur="onBlur"
@focus="onFocus"
></ckeditor>
</div>
</template>
@@ -83,7 +89,7 @@ const { proxy } = useCurrentInstance();
// 组件名称
defineOptions({
name: "scCkeditor"
name: "scCkeditor",
});
// Props 定义
@@ -301,9 +307,13 @@ const editorConfig = computed(() => ({
{
name: "mp4",
url: /\.(mp4|avi|mov|flv|wmv|mkv)$/i,
html: match => {
html: (match) => {
const url = match["input"];
return ('<video controls width="100%" height="100%" src="' + url + '"></video>')
return (
'<video controls width="100%" height="100%" src="' +
url +
'"></video>'
);
},
},
],
@@ -341,7 +351,7 @@ watch(
(newVal) => {
editorData.value = newVal ?? "";
},
{ immediate: true }
{ immediate: true },
);
// 监听 height 变化
@@ -349,7 +359,7 @@ watch(
() => props.height,
(newVal) => {
editorHeight.value = newVal;
}
},
);
// 移除图片宽高的正则替换函数
@@ -21,44 +21,44 @@ ## 基本使用
</template>
<script setup>
import { ref } from 'vue'
import authApi from '@/api/auth'
import { ref } from "vue";
import authApi from "@/api/auth";
const showExport = ref(false)
const showExport = ref(false);
const handleExportSuccess = (data) => {
console.log('导出成功', data)
}
console.log("导出成功", data);
};
const handleExportError = (message) => {
console.log('导出失败', message)
}
console.log("导出失败", message);
};
</script>
```
## Props
| 参数 | 说明 | 类型 | 默认值 |
|------|------|------|--------|
| open | 是否显示弹窗 | Boolean | false |
| title | 弹窗标题 | String | '导出数据' |
| api | 导出API接口 | Function | 必填 |
| showOptions | 是否显示导出选项 | Boolean | true |
| showFieldSelect | 是否显示字段选择 | Boolean | false |
| fieldOptions | 字段选项 | Array | [] |
| showFormatSelect | 是否显示格式选择 | Boolean | false |
| defaultFormat | 默认导出格式 | String | 'xlsx' |
| defaultFilename | 默认文件名 | String | '' |
| tip | 提示信息 | String | '' |
| 参数 | 说明 | 类型 | 默认值 |
| ---------------- | ---------------- | -------- | ---------- |
| open | 是否显示弹窗 | Boolean | false |
| title | 弹窗标题 | String | '导出数据' |
| api | 导出API接口 | Function | 必填 |
| showOptions | 是否显示导出选项 | Boolean | true |
| showFieldSelect | 是否显示字段选择 | Boolean | false |
| fieldOptions | 字段选项 | Array | [] |
| showFormatSelect | 是否显示格式选择 | Boolean | false |
| defaultFormat | 默认导出格式 | String | 'xlsx' |
| defaultFilename | 默认文件名 | String | '' |
| tip | 提示信息 | String | '' |
## Events
| 事件名 | 说明 | 回调参数 |
|--------|------|----------|
| 事件名 | 说明 | 回调参数 |
| ----------- | ---------------- | ------------------ |
| update:open | 弹窗显示状态变化 | (visible: Boolean) |
| success | 导出成功 | (exportParams) |
| error | 导出失败 | (message, error) |
| change | 导出参数变化 | (params) |
| success | 导出成功 | (exportParams) |
| error | 导出失败 | (message, error) |
| change | 导出参数变化 | (params) |
## Slots
@@ -117,24 +117,24 @@ ### 示例1:简单导出
</template>
<script setup>
import { ref } from 'vue'
import { message } from 'ant-design-vue'
import { ExportOutlined } from '@ant-design/icons-vue'
import authApi from '@/api/auth'
import { ref } from "vue";
import { message } from "ant-design-vue";
import { ExportOutlined } from "@ant-design/icons-vue";
import authApi from "@/api/auth";
const exportVisible = ref(false)
const exportVisible = ref(false);
const handleExport = () => {
exportVisible.value = true
}
exportVisible.value = true;
};
const handleExportSuccess = (params) => {
message.success('导出成功')
}
message.success("导出成功");
};
const handleExportError = (errorMessage) => {
message.error('导出失败:' + errorMessage)
}
message.error("导出失败:" + errorMessage);
};
</script>
```
@@ -182,7 +182,7 @@ ### 示例2:带表单参数的导出
placeholder="请选择状态"
:options="[
{ label: '启用', value: 1 },
{ label: '禁用', value: 0 }
{ label: '禁用', value: 0 },
]"
allow-clear
/>
@@ -202,41 +202,41 @@ ### 示例2:带表单参数的导出
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import { ExportOutlined } from '@ant-design/icons-vue'
import authApi from '@/api/auth'
import { ref, onMounted } from "vue";
import { message } from "ant-design-vue";
import { ExportOutlined } from "@ant-design/icons-vue";
import authApi from "@/api/auth";
const exportVisible = ref(false)
const departmentOptions = ref([])
const roleOptions = ref([])
const exportVisible = ref(false);
const departmentOptions = ref([]);
const roleOptions = ref([]);
// 加载选项数据
onMounted(async () => {
try {
const [deptRes, roleRes] = await Promise.all([
authApi.departments.all.get(),
authApi.roles.all.get()
])
departmentOptions.value = deptRes.data || []
roleOptions.value = roleRes.data || []
authApi.roles.all.get(),
]);
departmentOptions.value = deptRes.data || [];
roleOptions.value = roleRes.data || [];
} catch (error) {
console.error('加载选项失败', error)
console.error("加载选项失败", error);
}
})
});
const handleExport = () => {
exportVisible.value = true
}
exportVisible.value = true;
};
const handleExportSuccess = (params) => {
message.success('导出成功')
console.log('导出参数', params)
}
message.success("导出成功");
console.log("导出参数", params);
};
const handleExportError = (errorMessage) => {
message.error('导出失败:' + errorMessage)
}
message.error("导出失败:" + errorMessage);
};
</script>
```
@@ -277,46 +277,46 @@ ### 示例3:带字段和格式选择的导出
</template>
<script setup>
import { ref } from 'vue'
import { message } from 'ant-design-vue'
import { ExportOutlined } from '@ant-design/icons-vue'
import { ref } from "vue";
import { message } from "ant-design-vue";
import { ExportOutlined } from "@ant-design/icons-vue";
const exportVisible = ref(false)
const exportVisible = ref(false);
// 字段选项
const fieldOptions = ref([
{ label: '用户名', value: 'username' },
{ label: '姓名', value: 'name' },
{ label: '邮箱', value: 'email' },
{ label: '手机号', value: 'phone' },
{ label: '部门', value: 'department' },
{ label: '角色', value: 'roles' },
{ label: '状态', value: 'status' },
{ label: '创建时间', value: 'created_at' },
{ label: '最后登录', value: 'last_login_at' }
])
{ label: "用户名", value: "username" },
{ label: "姓名", value: "name" },
{ label: "邮箱", value: "email" },
{ label: "手机号", value: "phone" },
{ label: "部门", value: "department" },
{ label: "角色", value: "roles" },
{ label: "状态", value: "status" },
{ label: "创建时间", value: "created_at" },
{ label: "最后登录", value: "last_login_at" },
]);
// 导出API
const exportApi = async (params) => {
// 这里调用实际的导出接口
// 示例:
// return await authApi.users.export.post(params)
console.log('导出参数', params)
console.log("导出参数", params);
// 返回一个 blob 对象
return new Blob(['test data'], { type: 'application/vnd.ms-excel' })
}
return new Blob(["test data"], { type: "application/vnd.ms-excel" });
};
const handleExport = () => {
exportVisible.value = true
}
exportVisible.value = true;
};
const handleExportSuccess = (params) => {
message.success('导出成功')
}
message.success("导出成功");
};
const handleExportError = (errorMessage) => {
message.error('导出失败:' + errorMessage)
}
message.error("导出失败:" + errorMessage);
};
</script>
```
@@ -374,21 +374,22 @@ ## 与表格组件结合使用
</template>
<script setup>
import { ref } from 'vue'
import { ExportOutlined } from '@ant-design/icons-vue'
import authApi from '@/api/auth'
import { ref } from "vue";
import { ExportOutlined } from "@ant-design/icons-vue";
import authApi from "@/api/auth";
const exportVisible = ref(false)
const exportVisible = ref(false);
const handleExport = () => {
exportVisible.value = true
}
exportVisible.value = true;
};
const exportApi = async (params) => {
return await authApi.users.export.post(params)
}
return await authApi.users.export.post(params);
};
const handleExportSuccess = () => {
// 导出成功后的处理
}
};
</script>
```
@@ -28,8 +28,18 @@
<v-nodes :vnodes="menu" />
<a-divider style="margin: 4px 0" />
<div style="padding: 4px 8px">
<a-button type="text" size="small" @click="selectAllFields">全选</a-button>
<a-button type="text" size="small" @click="clearAllFields">清空</a-button>
<a-button
type="text"
size="small"
@click="selectAllFields"
>全选</a-button
>
<a-button
type="text"
size="small"
@click="clearAllFields"
>清空</a-button
>
</div>
</template>
</a-select>
@@ -44,7 +54,12 @@
</a-form-item>
<a-form-item label="文件名">
<a-input v-model:value="filename" placeholder="请输入文件名" :max-length="50" show-count />
<a-input
v-model:value="filename"
placeholder="请输入文件名"
:max-length="50"
show-count
/>
</a-form-item>
</a-form>
</div>
@@ -60,192 +75,192 @@
</template>
<script setup>
import { ref, watch } from 'vue'
import { message } from 'ant-design-vue'
import { ref, watch } from "vue";
import { message } from "ant-design-vue";
const props = defineProps({
// 是否显示弹窗
open: {
type: Boolean,
default: false
default: false,
},
// 弹窗标题
title: {
type: String,
default: '导出数据'
default: "导出数据",
},
// 导出API接口
api: {
type: Function,
required: true
required: true,
},
// 是否显示导出选项
showOptions: {
type: Boolean,
default: true
default: true,
},
// 是否显示字段选择
showFieldSelect: {
type: Boolean,
default: false
default: false,
},
// 字段选项
fieldOptions: {
type: Array,
default: () => []
default: () => [],
},
// 是否显示格式选择
showFormatSelect: {
type: Boolean,
default: false
default: false,
},
// 默认导出格式
defaultFormat: {
type: String,
default: 'xlsx'
default: "xlsx",
},
// 默认文件名
defaultFilename: {
type: String,
default: ''
default: "",
},
// 提示信息
tip: {
type: String,
default: ''
}
})
default: "",
},
});
const emit = defineEmits(['update:open', 'success', 'error', 'change'])
const emit = defineEmits(["update:open", "success", "error", "change"]);
// 弹窗显示状态
const visible = ref(false)
const visible = ref(false);
// 加载状态
const loading = ref(false)
const loading = ref(false);
// 表单数据(用于插槽)
const formData = ref({})
const formData = ref({});
// 导出选项
const selectedFields = ref([])
const exportFormat = ref(props.defaultFormat)
const filename = ref(props.defaultFilename || '导出数据')
const selectedFields = ref([]);
const exportFormat = ref(props.defaultFormat);
const filename = ref(props.defaultFilename || "导出数据");
// VNodes 组件(用于 dropdownRender
const VNodes = (_, { attrs }) => {
return attrs.vnodes
}
return attrs.vnodes;
};
// 监听外部 open 变化
watch(
() => props.open,
(val) => {
visible.value = val
visible.value = val;
if (val) {
// 打开时重置表单
formData.value = {}
selectedFields.value = []
exportFormat.value = props.defaultFormat
filename.value = props.defaultFilename || '导出数据'
formData.value = {};
selectedFields.value = [];
exportFormat.value = props.defaultFormat;
filename.value = props.defaultFilename || "导出数据";
}
},
{ immediate: true }
)
{ immediate: true },
);
// 监听内部 visible 变化,同步到外部
watch(visible, (val) => {
emit('update:open', val)
})
emit("update:open", val);
});
// 全选字段
const selectAllFields = () => {
selectedFields.value = props.fieldOptions.map((item) => item.value)
}
selectedFields.value = props.fieldOptions.map((item) => item.value);
};
// 清空字段
const clearAllFields = () => {
selectedFields.value = []
}
selectedFields.value = [];
};
// 确认导出
const handleOk = async () => {
// 构建导出参数
const exportParams = {}
const exportParams = {};
// 添加表单参数(如果有)
if (Object.keys(formData.value).length > 0) {
Object.assign(exportParams, formData.value)
Object.assign(exportParams, formData.value);
}
// 添加字段选择(如果有)
if (props.showFieldSelect && selectedFields.value.length > 0) {
exportParams.fields = selectedFields.value
exportParams.fields = selectedFields.value;
}
// 添加格式选择(如果有)
if (props.showFormatSelect) {
exportParams.format = exportFormat.value
exportParams.format = exportFormat.value;
}
try {
loading.value = true
loading.value = true;
// 调用导出接口
const blob = await props.api(exportParams)
const blob = await props.api(exportParams);
// 确定文件扩展名
let ext = exportFormat.value
let ext = exportFormat.value;
if (!props.showFormatSelect && blob.type) {
// 如果没有格式选择,根据 blob 类型判断
if (blob.type.includes('sheet') || blob.type.includes('excel')) {
ext = 'xlsx'
} else if (blob.type.includes('csv')) {
ext = 'csv'
if (blob.type.includes("sheet") || blob.type.includes("excel")) {
ext = "xlsx";
} else if (blob.type.includes("csv")) {
ext = "csv";
}
}
// 创建下载链接
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `${filename.value}.${ext}`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${filename.value}.${ext}`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
message.success('导出成功')
emit('success', exportParams)
handleCancel()
message.success("导出成功");
emit("success", exportParams);
handleCancel();
} catch (error) {
message.error('导出失败:' + error.message)
emit('error', error.message, error)
message.error("导出失败:" + error.message);
emit("error", error.message, error);
} finally {
loading.value = false
loading.value = false;
}
}
};
// 取消导出
const handleCancel = () => {
visible.value = false
formData.value = {}
selectedFields.value = []
filename.value = props.defaultFilename || '导出数据'
}
visible.value = false;
formData.value = {};
selectedFields.value = [];
filename.value = props.defaultFilename || "导出数据";
};
</script>
<script>
export default {
name: 'ScExport',
name: "ScExport",
components: {
VNodes: {
render() {
return this.$slots.default ? this.$slots.default() : null
}
}
}
}
return this.$slots.default ? this.$slots.default() : null;
},
},
},
};
</script>
<style scoped>
+257 -99
View File
@@ -1,65 +1,136 @@
<template>
<a-form :model="formData" :rules="rules" :label-col="labelCol" :wrapper-col="wrapperCol" :layout="layout"
@finish="handleFinish" @finish-failed="handleFinishFailed">
<a-form-item v-for="item in formItems" :key="item.field" :label="item.label" :name="item.field"
:required="item.required" :colon="item.colon">
<a-form
:model="formData"
:rules="rules"
:label-col="labelCol"
:wrapper-col="wrapperCol"
:layout="layout"
@finish="handleFinish"
@finish-failed="handleFinishFailed"
>
<a-form-item
v-for="item in formItems"
:key="item.field"
:label="item.label"
:name="item.field"
:required="item.required"
:colon="item.colon"
>
<!-- 输入框 -->
<template v-if="item.type === 'input'">
<a-input v-model:value="formData[item.field]" :placeholder="item.placeholder || `请输入${item.label}`"
:disabled="item.disabled" :allow-clear="item.allowClear !== false" :max-length="item.maxLength"
:type="item.inputType || 'text'" :prefix="item.prefix" :suffix="item.suffix"
@change="item.onChange && item.onChange(formData[item.field])" />
<a-input
v-model:value="formData[item.field]"
:placeholder="item.placeholder || `请输入${item.label}`"
:disabled="item.disabled"
:allow-clear="item.allowClear !== false"
:max-length="item.maxLength"
:type="item.inputType || 'text'"
:prefix="item.prefix"
:suffix="item.suffix"
@change="
item.onChange && item.onChange(formData[item.field])
"
/>
</template>
<!-- 文本域 -->
<template v-else-if="item.type === 'textarea'">
<a-textarea v-model:value="formData[item.field]" :placeholder="item.placeholder || `请输入${item.label}`"
:disabled="item.disabled" :allow-clear="item.allowClear !== false" :rows="item.rows || 4"
:max-length="item.maxLength" :show-count="item.showCount"
@change="item.onChange && item.onChange(formData[item.field])" />
<a-textarea
v-model:value="formData[item.field]"
:placeholder="item.placeholder || `请输入${item.label}`"
:disabled="item.disabled"
:allow-clear="item.allowClear !== false"
:rows="item.rows || 4"
:max-length="item.maxLength"
:show-count="item.showCount"
@change="
item.onChange && item.onChange(formData[item.field])
"
/>
</template>
<!-- 密码输入框 -->
<template v-else-if="item.type === 'password'">
<a-input-password v-model:value="formData[item.field]"
:placeholder="item.placeholder || `请输入${item.label}`" :disabled="item.disabled"
:max-length="item.maxLength" @change="item.onChange && item.onChange(formData[item.field])" />
<a-input-password
v-model:value="formData[item.field]"
:placeholder="item.placeholder || `请输入${item.label}`"
:disabled="item.disabled"
:max-length="item.maxLength"
@change="
item.onChange && item.onChange(formData[item.field])
"
/>
</template>
<!-- 数字输入框 -->
<template v-else-if="item.type === 'number'">
<a-input-number v-model:value="formData[item.field]"
:placeholder="item.placeholder || `请输入${item.label}`" :disabled="item.disabled" :min="item.min"
:max="item.max" :step="item.step || 1" :precision="item.precision"
:controls="item.controls !== false" style="width: 100%"
@change="item.onChange && item.onChange(formData[item.field])" />
<a-input-number
v-model:value="formData[item.field]"
:placeholder="item.placeholder || `请输入${item.label}`"
:disabled="item.disabled"
:min="item.min"
:max="item.max"
:step="item.step || 1"
:precision="item.precision"
:controls="item.controls !== false"
style="width: 100%"
@change="
item.onChange && item.onChange(formData[item.field])
"
/>
</template>
<!-- 下拉选择 -->
<template v-else-if="item.type === 'select'">
<a-select v-model:value="formData[item.field]" :placeholder="item.placeholder || `请选择${item.label}`"
:disabled="item.disabled" :allow-clear="item.allowClear !== false" :mode="item.mode"
:options="item.options" :field-names="item.fieldNames" style="width: 100%"
@change="item.onChange && item.onChange(formData[item.field])">
<a-select
v-model:value="formData[item.field]"
:placeholder="item.placeholder || `请选择${item.label}`"
:disabled="item.disabled"
:allow-clear="item.allowClear !== false"
:mode="item.mode"
:options="item.options"
:field-names="item.fieldNames"
style="width: 100%"
@change="
item.onChange && item.onChange(formData[item.field])
"
>
<template v-if="!item.options" #notFoundContent>
<a-empty :image="Empty.PRESENTED_IMAGE_SIMPLE" description="暂无数据" />
<a-empty
:image="Empty.PRESENTED_IMAGE_SIMPLE"
description="暂无数据"
/>
</template>
</a-select>
</template>
<!-- 单选框 -->
<template v-else-if="item.type === 'radio'">
<a-radio-group v-model:value="formData[item.field]" :disabled="item.disabled"
:button-style="item.buttonStyle" @change="item.onChange && item.onChange(formData[item.field])">
<a-radio-group
v-model:value="formData[item.field]"
:disabled="item.disabled"
:button-style="item.buttonStyle"
@change="
item.onChange && item.onChange(formData[item.field])
"
>
<template v-if="item.options">
<a-radio v-for="opt in item.options" :key="opt.value" :value="opt.value"
:disabled="opt.disabled">
<a-radio
v-for="opt in item.options"
:key="opt.value"
:value="opt.value"
:disabled="opt.disabled"
>
{{ opt.label }}
</a-radio>
</template>
<template v-else-if="item.buttonStyle === 'solid'">
<a-radio-button v-for="opt in item.options" :key="opt.value" :value="opt.value"
:disabled="opt.disabled">
<a-radio-button
v-for="opt in item.options"
:key="opt.value"
:value="opt.value"
:disabled="opt.disabled"
>
{{ opt.label }}
</a-radio-button>
</template>
@@ -68,11 +139,20 @@
<!-- 多选框 -->
<template v-else-if="item.type === 'checkbox'">
<a-checkbox-group v-model:value="formData[item.field]" :disabled="item.disabled"
@change="item.onChange && item.onChange(formData[item.field])">
<a-checkbox-group
v-model:value="formData[item.field]"
:disabled="item.disabled"
@change="
item.onChange && item.onChange(formData[item.field])
"
>
<template v-if="item.options">
<a-checkbox v-for="opt in item.options" :key="opt.value" :value="opt.value"
:disabled="opt.disabled">
<a-checkbox
v-for="opt in item.options"
:key="opt.value"
:value="opt.value"
:disabled="opt.disabled"
>
{{ opt.label }}
</a-checkbox>
</template>
@@ -81,42 +161,79 @@
<!-- 开关 -->
<template v-else-if="item.type === 'switch'">
<a-switch v-model:checked="formData[item.field]" :disabled="item.disabled"
:checked-children="item.checkedChildren || ''" :un-checked-children="item.unCheckedChildren || ''"
@change="item.onChange && item.onChange(formData[item.field])" />
<a-switch
v-model:checked="formData[item.field]"
:disabled="item.disabled"
:checked-children="item.checkedChildren || ''"
:un-checked-children="item.unCheckedChildren || ''"
@change="
item.onChange && item.onChange(formData[item.field])
"
/>
</template>
<!-- 日期选择 -->
<template v-else-if="item.type === 'date'">
<a-date-picker v-model:value="formData[item.field]"
:placeholder="item.placeholder || `请选择${item.label}`" :disabled="item.disabled"
:format="item.format || 'YYYY-MM-DD'" :value-format="item.valueFormat || 'YYYY-MM-DD'"
style="width: 100%" @change="item.onChange && item.onChange(formData[item.field])" />
<a-date-picker
v-model:value="formData[item.field]"
:placeholder="item.placeholder || `请选择${item.label}`"
:disabled="item.disabled"
:format="item.format || 'YYYY-MM-DD'"
:value-format="item.valueFormat || 'YYYY-MM-DD'"
style="width: 100%"
@change="
item.onChange && item.onChange(formData[item.field])
"
/>
</template>
<!-- 日期范围选择 -->
<template v-else-if="item.type === 'dateRange'">
<a-range-picker v-model:value="formData[item.field]" :placeholder="item.placeholder || ['开始日期', '结束日期']"
:disabled="item.disabled" :format="item.format || 'YYYY-MM-DD'"
:value-format="item.valueFormat || 'YYYY-MM-DD'" style="width: 100%"
@change="item.onChange && item.onChange(formData[item.field])" />
<a-range-picker
v-model:value="formData[item.field]"
:placeholder="item.placeholder || ['开始日期', '结束日期']"
:disabled="item.disabled"
:format="item.format || 'YYYY-MM-DD'"
:value-format="item.valueFormat || 'YYYY-MM-DD'"
style="width: 100%"
@change="
item.onChange && item.onChange(formData[item.field])
"
/>
</template>
<!-- 时间选择 -->
<template v-else-if="item.type === 'time'">
<a-time-picker v-model:value="formData[item.field]"
:placeholder="item.placeholder || `请选择${item.label}`" :disabled="item.disabled"
:format="item.format || 'HH:mm:ss'" :value-format="item.valueFormat || 'HH:mm:ss'"
style="width: 100%" @change="item.onChange && item.onChange(formData[item.field])" />
<a-time-picker
v-model:value="formData[item.field]"
:placeholder="item.placeholder || `请选择${item.label}`"
:disabled="item.disabled"
:format="item.format || 'HH:mm:ss'"
:value-format="item.valueFormat || 'HH:mm:ss'"
style="width: 100%"
@change="
item.onChange && item.onChange(formData[item.field])
"
/>
</template>
<!-- 上传 -->
<template v-else-if="item.type === 'upload'">
<a-upload v-model:file-list="formData[item.field]" :list-type="item.listType || 'text'"
:action="item.action" :max-count="item.maxCount" :before-upload="item.beforeUpload"
:custom-request="item.customRequest" :accept="item.accept" :disabled="item.disabled"
@change="(info) => item.onChange && item.onChange(info)">
<a-button v-if="item.listType !== 'picture-card'" type="primary">
<a-upload
v-model:file-list="formData[item.field]"
:list-type="item.listType || 'text'"
:action="item.action"
:max-count="item.maxCount"
:before-upload="item.beforeUpload"
:custom-request="item.customRequest"
:accept="item.accept"
:disabled="item.disabled"
@change="(info) => item.onChange && item.onChange(info)"
>
<a-button
v-if="item.listType !== 'picture-card'"
type="primary"
>
<UploadOutlined />
点击上传
</a-button>
@@ -129,28 +246,56 @@
<!-- 评分 -->
<template v-else-if="item.type === 'rate'">
<a-rate v-model:value="formData[item.field]" :disabled="item.disabled" :count="item.count || 5"
:allow-half="item.allowHalf" @change="item.onChange && item.onChange(formData[item.field])" />
<a-rate
v-model:value="formData[item.field]"
:disabled="item.disabled"
:count="item.count || 5"
:allow-half="item.allowHalf"
@change="
item.onChange && item.onChange(formData[item.field])
"
/>
</template>
<!-- 滑块 -->
<template v-else-if="item.type === 'slider'">
<a-slider v-model:value="formData[item.field]" :disabled="item.disabled" :min="item.min || 0"
:max="item.max || 100" :step="item.step || 1" :marks="item.marks" :range="item.range"
@change="item.onChange && item.onChange(formData[item.field])" />
<a-slider
v-model:value="formData[item.field]"
:disabled="item.disabled"
:min="item.min || 0"
:max="item.max || 100"
:step="item.step || 1"
:marks="item.marks"
:range="item.range"
@change="
item.onChange && item.onChange(formData[item.field])
"
/>
</template>
<!-- 级联选择 -->
<template v-else-if="item.type === 'cascader'">
<a-cascader v-model:value="formData[item.field]" :options="item.options"
:placeholder="item.placeholder || `请选择${item.label}`" :disabled="item.disabled"
:change-on-select="item.changeOnSelect" :field-names="item.fieldNames" style="width: 100%"
@change="item.onChange && item.onChange(formData[item.field])" />
<a-cascader
v-model:value="formData[item.field]"
:options="item.options"
:placeholder="item.placeholder || `请选择${item.label}`"
:disabled="item.disabled"
:change-on-select="item.changeOnSelect"
:field-names="item.fieldNames"
style="width: 100%"
@change="
item.onChange && item.onChange(formData[item.field])
"
/>
</template>
<!-- 自定义插槽 -->
<template v-else-if="item.type === 'slot'">
<slot :name="item.slotName || item.field" :field="item.field" :value="formData[item.field]"></slot>
<slot
:name="item.slotName || item.field"
:field="item.field"
:value="formData[item.field]"
></slot>
</template>
<!-- 提示信息 -->
@@ -162,14 +307,27 @@
<!-- 表单操作按钮 -->
<a-form-item v-if="showActions" :wrapper-col="actionWrapperCol">
<a-space>
<a-button type="primary" html-type="submit" :loading="loading" :size="buttonSize">
{{ submitText || '提交' }}
<a-button
type="primary"
html-type="submit"
:loading="loading"
:size="buttonSize"
>
{{ submitText || "提交" }}
</a-button>
<a-button v-if="showReset" @click="handleReset" :size="buttonSize">
{{ resetText || '重置' }}
<a-button
v-if="showReset"
@click="handleReset"
:size="buttonSize"
>
{{ resetText || "重置" }}
</a-button>
<a-button v-if="showCancel" @click="handleCancel" :size="buttonSize">
{{ cancelText || '取消' }}
<a-button
v-if="showCancel"
@click="handleCancel"
:size="buttonSize"
>
{{ cancelText || "取消" }}
</a-button>
<slot name="actions"></slot>
</a-space>
@@ -181,9 +339,9 @@
</template>
<script setup>
import { ref, reactive, computed, watch } from 'vue'
import { Empty } from 'ant-design-vue'
import { UploadOutlined, PlusOutlined } from '@ant-design/icons-vue'
import { ref, reactive, computed, watch } from "vue";
import { Empty } from "ant-design-vue";
import { UploadOutlined, PlusOutlined } from "@ant-design/icons-vue";
const props = defineProps({
// 表单项配置
@@ -200,7 +358,7 @@ const props = defineProps({
// 表单布局
layout: {
type: String,
default: 'horizontal', // horizontal, vertical, inline
default: "horizontal", // horizontal, vertical, inline
},
// 标签宽度
labelCol: {
@@ -239,75 +397,75 @@ const props = defineProps({
// 按钮大小
buttonSize: {
type: String,
default: 'middle',
default: "middle",
},
// 加载状态
loading: {
type: Boolean,
default: false,
},
})
});
const emit = defineEmits(['finish', 'finish-failed', 'reset', 'cancel'])
const emit = defineEmits(["finish", "finish-failed", "reset", "cancel"]);
// 表单数据
const formData = reactive({ ...props.initialValues })
const formData = reactive({ ...props.initialValues });
// 表单验证规则
const rules = computed(() => {
const result = {}
const result = {};
props.formItems.forEach((item) => {
if (item.rules && item.rules.length > 0) {
result[item.field] = item.rules
result[item.field] = item.rules;
}
})
return result
})
});
return result;
});
// 监听初始值变化
watch(
() => props.initialValues,
(newVal) => {
Object.assign(formData, newVal)
Object.assign(formData, newVal);
},
{ deep: true },
)
);
// 表单提交
const handleFinish = (values) => {
emit('finish', values)
}
emit("finish", values);
};
// 表单验证失败
const handleFinishFailed = (errorInfo) => {
emit('finish-failed', errorInfo)
}
emit("finish-failed", errorInfo);
};
// 重置表单
const handleReset = () => {
Object.assign(formData, props.initialValues)
emit('reset', formData)
}
Object.assign(formData, props.initialValues);
emit("reset", formData);
};
// 取消操作
const handleCancel = () => {
emit('cancel')
}
emit("cancel");
};
// 暴露方法给父组件
defineExpose({
formData,
resetForm: handleReset,
setFieldValue: (field, value) => {
formData[field] = value
formData[field] = value;
},
getFieldValue: (field) => {
return formData[field]
return formData[field];
},
setFieldsValue: (values) => {
Object.assign(formData, values)
Object.assign(formData, values);
},
})
});
</script>
<style scoped lang="scss">
@@ -1,6 +1,11 @@
<template>
<div class="sc-icon-picker">
<a-input :value="selectedIcon || ''" :placeholder="placeholder" readonly @click="handleOpenPicker">
<a-input
:value="selectedIcon || ''"
:placeholder="placeholder"
readonly
@click="handleOpenPicker"
>
<template #prefix v-if="selectedIcon">
<component :is="selectedIcon" />
</template>
@@ -10,7 +15,13 @@
</template>
</a-input>
<a-modal v-model:open="visible" title="选择图标" :width="900" :footer="null" @cancel="handleCancel">
<a-modal
v-model:open="visible"
title="选择图标"
:width="900"
:footer="null"
@cancel="handleCancel"
>
<div class="icon-picker-content">
<!-- 搜索框 -->
<div class="icon-search">
@@ -31,7 +42,10 @@
<a-tag
v-for="category in iconCategories"
:key="category.key"
:class="['category-tag', { active: activeCategory === category.key }]"
:class="[
'category-tag',
{ active: activeCategory === category.key },
]"
@click="handleCategoryChange(category.key)"
>
{{ category.label }}
@@ -39,24 +53,45 @@
</div>
<!-- 最近使用 -->
<div v-if="!searchValue && activeCategory === 'recent' && recentIcons.length > 0" class="recent-section">
<div
v-if="
!searchValue &&
activeCategory === 'recent' &&
recentIcons.length > 0
"
class="recent-section"
>
<div class="section-title">最近使用</div>
<div class="icon-list">
<div
v-for="icon in recentIcons"
:key="icon"
:class="['icon-item', { active: tempIcon === icon }]"
:class="[
'icon-item',
{ active: tempIcon === icon },
]"
@click="handleSelectIcon(icon)"
>
<component :is="icon" />
<div class="icon-name">{{ icon }}</div>
<CloseCircleFilled class="recent-remove" @click.stop="handleRemoveRecent(icon)" />
<CloseCircleFilled
class="recent-remove"
@click.stop="handleRemoveRecent(icon)"
/>
</div>
</div>
</div>
<!-- 图标列表 -->
<div class="icon-list" v-show="activeCategory !== 'recent' || searchValue || (activeCategory === 'recent' && recentIcons.length === 0)">
<div
class="icon-list"
v-show="
activeCategory !== 'recent' ||
searchValue ||
(activeCategory === 'recent' &&
recentIcons.length === 0)
"
>
<div
v-for="icon in filteredIcons"
:key="icon"
@@ -66,7 +101,11 @@
<component :is="icon" />
<div class="icon-name">{{ icon }}</div>
</div>
<a-empty v-if="filteredIcons.length === 0" description="暂无图标" :image="Empty.PRESENTED_IMAGE_SIMPLE" />
<a-empty
v-if="filteredIcons.length === 0"
description="暂无图标"
:image="Empty.PRESENTED_IMAGE_SIMPLE"
/>
</div>
</div>
</a-modal>
@@ -78,321 +117,579 @@
* @component scIconPicker
* @description 图标选择器组件,支持 Ant Design Vue 图标库
*/
import { ref, computed, watch, onMounted } from 'vue'
import { Empty } from 'ant-design-vue'
import { SearchOutlined, CloseCircleFilled } from '@ant-design/icons-vue'
import { ref, computed, watch, onMounted } from "vue";
import { Empty } from "ant-design-vue";
import { SearchOutlined, CloseCircleFilled } from "@ant-design/icons-vue";
const props = defineProps({
modelValue: {
type: String,
default: '',
default: "",
},
placeholder: {
type: String,
default: '请选择图标',
default: "请选择图标",
},
})
});
const emit = defineEmits(['update:modelValue', 'change'])
const emit = defineEmits(["update:modelValue", "change"]);
const visible = ref(false)
const activeCategory = ref('all')
const searchValue = ref('')
const tempIcon = ref('')
const recentIcons = ref([])
const RECENT_ICONS_KEY = 'sc-icon-picker-recent'
const MAX_RECENT_ICONS = 12
const visible = ref(false);
const activeCategory = ref("all");
const searchValue = ref("");
const tempIcon = ref("");
const recentIcons = ref([]);
const RECENT_ICONS_KEY = "sc-icon-picker-recent";
const MAX_RECENT_ICONS = 12;
// 图标分类
const iconCategories = [
{ key: 'recent', label: '最近使用' },
{ key: 'all', label: '全部' },
{ key: 'direction', label: '方向' },
{ key: 'edit', label: '编辑' },
{ key: 'data', label: '数据' },
{ key: 'media', label: '媒体' },
{ key: 'user', label: '用户' },
{ key: 'system', label: '系统' },
{ key: 'commerce', label: '商务' },
]
{ key: "recent", label: "最近使用" },
{ key: "all", label: "全部" },
{ key: "direction", label: "方向" },
{ key: "edit", label: "编辑" },
{ key: "data", label: "数据" },
{ key: "media", label: "媒体" },
{ key: "user", label: "用户" },
{ key: "system", label: "系统" },
{ key: "commerce", label: "商务" },
];
// Ant Design 图标分类列表(经过验证存在的图标)
const iconCategoriesMap = {
direction: [
'ArrowLeftOutlined', 'ArrowRightOutlined', 'ArrowUpOutlined', 'ArrowDownOutlined',
'LeftOutlined', 'RightOutlined', 'UpOutlined', 'DownOutlined',
'CaretLeftOutlined', 'CaretRightOutlined', 'CaretUpOutlined', 'CaretDownOutlined',
'RollbackOutlined', 'EnterOutlined', 'RetweetOutlined', 'SwapOutlined',
'SwapLeftOutlined', 'SwapRightOutlined', 'UpCircleOutlined', 'DownCircleOutlined',
'LeftCircleOutlined', 'RightCircleOutlined', 'DoubleRightOutlined', 'DoubleLeftOutlined',
'VerticalAlignTopOutlined', 'VerticalAlignBottomOutlined', 'VerticalAlignMiddleOutlined',
'FullscreenOutlined', 'FullscreenExitOutlined', 'CompressOutlined', 'ExpandOutlined',
"ArrowLeftOutlined",
"ArrowRightOutlined",
"ArrowUpOutlined",
"ArrowDownOutlined",
"LeftOutlined",
"RightOutlined",
"UpOutlined",
"DownOutlined",
"CaretLeftOutlined",
"CaretRightOutlined",
"CaretUpOutlined",
"CaretDownOutlined",
"RollbackOutlined",
"EnterOutlined",
"RetweetOutlined",
"SwapOutlined",
"SwapLeftOutlined",
"SwapRightOutlined",
"UpCircleOutlined",
"DownCircleOutlined",
"LeftCircleOutlined",
"RightCircleOutlined",
"DoubleRightOutlined",
"DoubleLeftOutlined",
"VerticalAlignTopOutlined",
"VerticalAlignBottomOutlined",
"VerticalAlignMiddleOutlined",
"FullscreenOutlined",
"FullscreenExitOutlined",
"CompressOutlined",
"ExpandOutlined",
],
edit: [
'EditOutlined', 'DeleteOutlined', 'PlusOutlined', 'MinusOutlined',
'CheckOutlined', 'CloseOutlined', 'FormOutlined', 'CopyOutlined',
'ScissorOutlined', 'SnippetsOutlined', 'DiffOutlined', 'HighlightOutlined',
'AlignLeftOutlined', 'AlignCenterOutlined', 'AlignRightOutlined',
'BoldOutlined', 'ItalicOutlined', 'UnderlineOutlined',
'RedoOutlined', 'UndoOutlined', 'FileSyncOutlined', 'ExportOutlined',
'ImportOutlined', 'FileAddOutlined', 'FolderAddOutlined',
"EditOutlined",
"DeleteOutlined",
"PlusOutlined",
"MinusOutlined",
"CheckOutlined",
"CloseOutlined",
"FormOutlined",
"CopyOutlined",
"ScissorOutlined",
"SnippetsOutlined",
"DiffOutlined",
"HighlightOutlined",
"AlignLeftOutlined",
"AlignCenterOutlined",
"AlignRightOutlined",
"BoldOutlined",
"ItalicOutlined",
"UnderlineOutlined",
"RedoOutlined",
"UndoOutlined",
"FileSyncOutlined",
"ExportOutlined",
"ImportOutlined",
"FileAddOutlined",
"FolderAddOutlined",
],
data: [
'DatabaseOutlined', 'CloudOutlined', 'CloudUploadOutlined',
'CloudDownloadOutlined', 'HddOutlined', 'ServerOutlined',
'ReconciliationOutlined', 'AccountBookOutlined', 'AuditOutlined',
'BarChartOutlined', 'AreaChartOutlined', 'DotChartOutlined',
'LineChartOutlined', 'PieChartOutlined', 'FundOutlined',
'SlidersOutlined', 'ControlOutlined', 'ExperimentOutlined',
'ProjectOutlined', 'PartitionOutlined', 'ApartmentOutlined',
'BlockOutlined', 'FunctionOutlined',
"DatabaseOutlined",
"CloudOutlined",
"CloudUploadOutlined",
"CloudDownloadOutlined",
"HddOutlined",
"ServerOutlined",
"ReconciliationOutlined",
"AccountBookOutlined",
"AuditOutlined",
"BarChartOutlined",
"AreaChartOutlined",
"DotChartOutlined",
"LineChartOutlined",
"PieChartOutlined",
"FundOutlined",
"SlidersOutlined",
"ControlOutlined",
"ExperimentOutlined",
"ProjectOutlined",
"PartitionOutlined",
"ApartmentOutlined",
"BlockOutlined",
"FunctionOutlined",
],
media: [
'PictureOutlined', 'VideoCameraOutlined', 'AudioOutlined',
'FileImageOutlined', 'FilePdfOutlined', 'FileWordOutlined',
'FileExcelOutlined', 'FileZipOutlined', 'FilePptOutlined',
'FolderOutlined', 'FolderOpenOutlined', 'FileTextOutlined', 'FileOutlined',
'FileMarkdownOutlined', 'FileUnknownOutlined',
'CameraOutlined', 'QrcodeOutlined', 'BarcodeOutlined',
'SoundOutlined', 'CustomerServiceOutlined',
"PictureOutlined",
"VideoCameraOutlined",
"AudioOutlined",
"FileImageOutlined",
"FilePdfOutlined",
"FileWordOutlined",
"FileExcelOutlined",
"FileZipOutlined",
"FilePptOutlined",
"FolderOutlined",
"FolderOpenOutlined",
"FileTextOutlined",
"FileOutlined",
"FileMarkdownOutlined",
"FileUnknownOutlined",
"CameraOutlined",
"QrcodeOutlined",
"BarcodeOutlined",
"SoundOutlined",
"CustomerServiceOutlined",
],
user: [
'UserOutlined', 'UsergroupAddOutlined', 'UsergroupDeleteOutlined', 'TeamOutlined',
'SolutionOutlined', 'ContactsOutlined', 'IdcardOutlined', 'ProfileOutlined',
'SmileOutlined', 'MehOutlined', 'FrownOutlined',
'HeartOutlined', 'StarOutlined', 'LikeOutlined', 'DislikeOutlined',
'ThumbUpOutlined', 'MessageOutlined', 'MailOutlined', 'PhoneOutlined',
"UserOutlined",
"UsergroupAddOutlined",
"UsergroupDeleteOutlined",
"TeamOutlined",
"SolutionOutlined",
"ContactsOutlined",
"IdcardOutlined",
"ProfileOutlined",
"SmileOutlined",
"MehOutlined",
"FrownOutlined",
"HeartOutlined",
"StarOutlined",
"LikeOutlined",
"DislikeOutlined",
"ThumbUpOutlined",
"MessageOutlined",
"MailOutlined",
"PhoneOutlined",
],
system: [
'SettingOutlined', 'HomeOutlined', 'DashboardOutlined', 'AppstoreOutlined',
'MenuFoldOutlined', 'MenuUnfoldOutlined', 'BarsOutlined', 'MoreOutlined',
'BellOutlined', 'AlertOutlined', 'WarningOutlined',
'CheckCircleOutlined', 'CloseCircleOutlined', 'ExclamationCircleOutlined',
'InfoCircleOutlined', 'QuestionCircleOutlined', 'StopOutlined',
'SafetyOutlined', 'LockOutlined', 'UnlockOutlined', 'KeyOutlined',
'EyeOutlined', 'EyeInvisibleOutlined',
'LogoutOutlined', 'LoginOutlined', 'MobileOutlined',
'ThunderboltOutlined', 'WifiOutlined', 'ApiOutlined',
'BugOutlined', 'BuildOutlined', 'CodeOutlined',
'PoweroffOutlined', 'HourglassOutlined', 'SyncOutlined',
"SettingOutlined",
"HomeOutlined",
"DashboardOutlined",
"AppstoreOutlined",
"MenuFoldOutlined",
"MenuUnfoldOutlined",
"BarsOutlined",
"MoreOutlined",
"BellOutlined",
"AlertOutlined",
"WarningOutlined",
"CheckCircleOutlined",
"CloseCircleOutlined",
"ExclamationCircleOutlined",
"InfoCircleOutlined",
"QuestionCircleOutlined",
"StopOutlined",
"SafetyOutlined",
"LockOutlined",
"UnlockOutlined",
"KeyOutlined",
"EyeOutlined",
"EyeInvisibleOutlined",
"LogoutOutlined",
"LoginOutlined",
"MobileOutlined",
"ThunderboltOutlined",
"WifiOutlined",
"ApiOutlined",
"BugOutlined",
"BuildOutlined",
"CodeOutlined",
"PoweroffOutlined",
"HourglassOutlined",
"SyncOutlined",
],
commerce: [
'ShoppingCartOutlined', 'ShoppingOutlined', 'GiftOutlined', 'GoldOutlined',
'CrownOutlined', 'MedalOutlined', 'TrophyOutlined', 'DiamondOutlined',
'BankOutlined', 'CreditCardOutlined', 'PayCircleOutlined', 'WalletOutlined',
'MoneyCollectOutlined', 'DollarOutlined', 'EuroOutlined', 'PoundOutlined',
'YenOutlined', 'GiftFilledOutlined', 'RocketOutlined',
'FireOutlined', 'BulbOutlined', 'SafetyCertificateOutlined',
'ShopOutlined', 'ShoppingBagOutlined', 'InsuranceOutlined',
"ShoppingCartOutlined",
"ShoppingOutlined",
"GiftOutlined",
"GoldOutlined",
"CrownOutlined",
"MedalOutlined",
"TrophyOutlined",
"DiamondOutlined",
"BankOutlined",
"CreditCardOutlined",
"PayCircleOutlined",
"WalletOutlined",
"MoneyCollectOutlined",
"DollarOutlined",
"EuroOutlined",
"PoundOutlined",
"YenOutlined",
"GiftFilledOutlined",
"RocketOutlined",
"FireOutlined",
"BulbOutlined",
"SafetyCertificateOutlined",
"ShopOutlined",
"ShoppingBagOutlined",
"InsuranceOutlined",
],
all: [
// 导航和布局
'HomeOutlined', 'DashboardOutlined', 'AppstoreOutlined', 'BarsOutlined',
'MenuFoldOutlined', 'MenuUnfoldOutlined', 'MoreOutlined', 'EllipsisOutlined',
"HomeOutlined",
"DashboardOutlined",
"AppstoreOutlined",
"BarsOutlined",
"MenuFoldOutlined",
"MenuUnfoldOutlined",
"MoreOutlined",
"EllipsisOutlined",
// 用户
'UserOutlined', 'TeamOutlined', 'UsergroupAddOutlined', 'UsergroupDeleteOutlined',
'SolutionOutlined', 'ContactsOutlined', 'IdcardOutlined', 'ProfileOutlined',
"UserOutlined",
"TeamOutlined",
"UsergroupAddOutlined",
"UsergroupDeleteOutlined",
"SolutionOutlined",
"ContactsOutlined",
"IdcardOutlined",
"ProfileOutlined",
// 设置
'SettingOutlined', 'ControlOutlined', 'BuildOutlined', 'ToolOutlined',
"SettingOutlined",
"ControlOutlined",
"BuildOutlined",
"ToolOutlined",
// 搜索和过滤
'SearchOutlined', 'FilterOutlined', 'SortAscendingOutlined', 'SortDescendingOutlined',
'ReloadOutlined', 'SyncOutlined', 'RedoOutlined', 'UndoOutlined',
"SearchOutlined",
"FilterOutlined",
"SortAscendingOutlined",
"SortDescendingOutlined",
"ReloadOutlined",
"SyncOutlined",
"RedoOutlined",
"UndoOutlined",
// 编辑
'EditOutlined', 'DeleteOutlined', 'PlusOutlined', 'MinusOutlined',
'CheckOutlined', 'CloseOutlined', 'CheckCircleOutlined', 'CloseCircleOutlined',
'FormOutlined', 'CopyOutlined', 'ScissorOutlined',
"EditOutlined",
"DeleteOutlined",
"PlusOutlined",
"MinusOutlined",
"CheckOutlined",
"CloseOutlined",
"CheckCircleOutlined",
"CloseCircleOutlined",
"FormOutlined",
"CopyOutlined",
"ScissorOutlined",
// 方向
'ArrowLeftOutlined', 'ArrowRightOutlined', 'ArrowUpOutlined', 'ArrowDownOutlined',
'LeftOutlined', 'RightOutlined', 'UpOutlined', 'DownOutlined',
'CaretLeftOutlined', 'CaretRightOutlined', 'CaretUpOutlined', 'CaretDownOutlined',
'RollbackOutlined', 'EnterOutlined', 'SwapOutlined',
'UpCircleOutlined', 'DownCircleOutlined', 'LeftCircleOutlined', 'RightCircleOutlined',
"ArrowLeftOutlined",
"ArrowRightOutlined",
"ArrowUpOutlined",
"ArrowDownOutlined",
"LeftOutlined",
"RightOutlined",
"UpOutlined",
"DownOutlined",
"CaretLeftOutlined",
"CaretRightOutlined",
"CaretUpOutlined",
"CaretDownOutlined",
"RollbackOutlined",
"EnterOutlined",
"SwapOutlined",
"UpCircleOutlined",
"DownCircleOutlined",
"LeftCircleOutlined",
"RightCircleOutlined",
// 文件
'FileTextOutlined', 'FileOutlined', 'FileAddOutlined', 'FileExcelOutlined',
'FilePdfOutlined', 'FileWordOutlined', 'FilePptOutlined', 'FileImageOutlined',
'FileUnknownOutlined', 'FileMarkdownOutlined', 'FileZipOutlined',
'FolderOutlined', 'FolderAddOutlined', 'FolderOpenOutlined',
"FileTextOutlined",
"FileOutlined",
"FileAddOutlined",
"FileExcelOutlined",
"FilePdfOutlined",
"FileWordOutlined",
"FilePptOutlined",
"FileImageOutlined",
"FileUnknownOutlined",
"FileMarkdownOutlined",
"FileZipOutlined",
"FolderOutlined",
"FolderAddOutlined",
"FolderOpenOutlined",
// 媒体
'PictureOutlined', 'VideoCameraOutlined', 'AudioOutlined', 'CameraOutlined',
'SoundOutlined', 'QrcodeOutlined', 'BarcodeOutlined',
"PictureOutlined",
"VideoCameraOutlined",
"AudioOutlined",
"CameraOutlined",
"SoundOutlined",
"QrcodeOutlined",
"BarcodeOutlined",
// 时间
'CalendarOutlined', 'ClockCircleOutlined', 'HistoryOutlined', 'FieldTimeOutlined',
'HourglassOutlined', 'CarryOutOutlined',
"CalendarOutlined",
"ClockCircleOutlined",
"HistoryOutlined",
"FieldTimeOutlined",
"HourglassOutlined",
"CarryOutOutlined",
// 社交
'HeartOutlined', 'StarOutlined', 'LikeOutlined', 'DislikeOutlined',
'ThumbUpOutlined', 'MessageOutlined', 'MailOutlined', 'PhoneOutlined',
'SmileOutlined', 'MehOutlined', 'FrownOutlined',
"HeartOutlined",
"StarOutlined",
"LikeOutlined",
"DislikeOutlined",
"ThumbUpOutlined",
"MessageOutlined",
"MailOutlined",
"PhoneOutlined",
"SmileOutlined",
"MehOutlined",
"FrownOutlined",
// 位置
'EnvironmentOutlined', 'GlobalOutlined', 'CompassOutlined', 'MapOutlined',
"EnvironmentOutlined",
"GlobalOutlined",
"CompassOutlined",
"MapOutlined",
// 安全
'LockOutlined', 'UnlockOutlined', 'KeyOutlined', 'SafetyOutlined',
'EyeOutlined', 'EyeInvisibleOutlined', 'SafetyCertificateOutlined',
"LockOutlined",
"UnlockOutlined",
"KeyOutlined",
"SafetyOutlined",
"EyeOutlined",
"EyeInvisibleOutlined",
"SafetyCertificateOutlined",
// 通知
'BellOutlined', 'AlertOutlined', 'WarningOutlined',
'InfoCircleOutlined', 'QuestionCircleOutlined', 'ExclamationCircleOutlined', 'StopOutlined',
"BellOutlined",
"AlertOutlined",
"WarningOutlined",
"InfoCircleOutlined",
"QuestionCircleOutlined",
"ExclamationCircleOutlined",
"StopOutlined",
// 云和数据
'DatabaseOutlined', 'CloudOutlined', 'HddOutlined', 'ServerOutlined',
'CloudUploadOutlined', 'CloudDownloadOutlined',
"DatabaseOutlined",
"CloudOutlined",
"HddOutlined",
"ServerOutlined",
"CloudUploadOutlined",
"CloudDownloadOutlined",
// 设备
'WifiOutlined', 'ApiOutlined', 'CodeOutlined', 'LaptopOutlined',
'MobileOutlined', 'TabletOutlined', 'DesktopOutlined',
"WifiOutlined",
"ApiOutlined",
"CodeOutlined",
"LaptopOutlined",
"MobileOutlined",
"TabletOutlined",
"DesktopOutlined",
// 状态
'ThunderboltOutlined', 'BulbOutlined', 'FireOutlined', 'ExperimentOutlined',
'CheckSquareOutlined', 'MinusSquareOutlined', 'CloseSquareOutlined',
"ThunderboltOutlined",
"BulbOutlined",
"FireOutlined",
"ExperimentOutlined",
"CheckSquareOutlined",
"MinusSquareOutlined",
"CloseSquareOutlined",
// 商务
'ShoppingCartOutlined', 'ShoppingOutlined', 'ShoppingBagOutlined', 'ShopOutlined',
'GiftOutlined', 'GiftFilledOutlined', 'WalletOutlined',
'CreditCardOutlined', 'BankOutlined', 'PayCircleOutlined',
'MoneyCollectOutlined', 'DollarOutlined', 'EuroOutlined', 'PoundOutlined', 'YenOutlined',
"ShoppingCartOutlined",
"ShoppingOutlined",
"ShoppingBagOutlined",
"ShopOutlined",
"GiftOutlined",
"GiftFilledOutlined",
"WalletOutlined",
"CreditCardOutlined",
"BankOutlined",
"PayCircleOutlined",
"MoneyCollectOutlined",
"DollarOutlined",
"EuroOutlined",
"PoundOutlined",
"YenOutlined",
// 奖项
'MedalOutlined', 'TrophyOutlined', 'CrownOutlined', 'GoldOutlined', 'DiamondOutlined',
"MedalOutlined",
"TrophyOutlined",
"CrownOutlined",
"GoldOutlined",
"DiamondOutlined",
// 其他
'RocketOutlined', 'FundOutlined', 'PieChartOutlined', 'BarChartOutlined',
'AreaChartOutlined', 'LineChartOutlined', 'DotChartOutlined',
'UploadOutlined', 'DownloadOutlined', 'ImportOutlined', 'ExportOutlined',
'ScanOutlined', 'PrinterOutlined', 'RetweetOutlined', 'SwapOutlined',
'ShareAltOutlined', 'PoweroffOutlined', 'LoginOutlined', 'LogoutOutlined',
'BugOutlined', 'HourglassOutlined',
"RocketOutlined",
"FundOutlined",
"PieChartOutlined",
"BarChartOutlined",
"AreaChartOutlined",
"LineChartOutlined",
"DotChartOutlined",
"UploadOutlined",
"DownloadOutlined",
"ImportOutlined",
"ExportOutlined",
"ScanOutlined",
"PrinterOutlined",
"RetweetOutlined",
"SwapOutlined",
"ShareAltOutlined",
"PoweroffOutlined",
"LoginOutlined",
"LogoutOutlined",
"BugOutlined",
"HourglassOutlined",
],
}
};
// 加载最近使用的图标
const loadRecentIcons = () => {
try {
const stored = localStorage.getItem(RECENT_ICONS_KEY)
const stored = localStorage.getItem(RECENT_ICONS_KEY);
if (stored) {
recentIcons.value = JSON.parse(stored)
recentIcons.value = JSON.parse(stored);
}
} catch (error) {
console.error('加载最近使用图标失败:', error)
console.error("加载最近使用图标失败:", error);
}
}
};
// 保存最近使用的图标
const saveRecentIcons = (icon) => {
if (!icon) return
if (!icon) return;
// 移除已存在的相同图标
const index = recentIcons.value.indexOf(icon)
const index = recentIcons.value.indexOf(icon);
if (index > -1) {
recentIcons.value.splice(index, 1)
recentIcons.value.splice(index, 1);
}
// 添加到开头
recentIcons.value.unshift(icon)
recentIcons.value.unshift(icon);
// 限制数量
if (recentIcons.value.length > MAX_RECENT_ICONS) {
recentIcons.value = recentIcons.value.slice(0, MAX_RECENT_ICONS)
recentIcons.value = recentIcons.value.slice(0, MAX_RECENT_ICONS);
}
// 保存到本地存储
try {
localStorage.setItem(RECENT_ICONS_KEY, JSON.stringify(recentIcons.value))
localStorage.setItem(
RECENT_ICONS_KEY,
JSON.stringify(recentIcons.value),
);
} catch (error) {
console.error('保存最近使用图标失败:', error)
console.error("保存最近使用图标失败:", error);
}
}
};
// 移除最近使用的图标
const handleRemoveRecent = (icon) => {
const index = recentIcons.value.indexOf(icon)
const index = recentIcons.value.indexOf(icon);
if (index > -1) {
recentIcons.value.splice(index, 1)
recentIcons.value.splice(index, 1);
try {
localStorage.setItem(RECENT_ICONS_KEY, JSON.stringify(recentIcons.value))
localStorage.setItem(
RECENT_ICONS_KEY,
JSON.stringify(recentIcons.value),
);
} catch (error) {
console.error('保存最近使用图标失败:', error)
console.error("保存最近使用图标失败:", error);
}
}
}
};
// 当前选中的图标
const selectedIcon = ref(props.modelValue)
const selectedIcon = ref(props.modelValue);
// 获取当前分类的图标
const currentCategoryIcons = computed(() => {
if (activeCategory.value === 'all') {
return iconCategoriesMap.all
if (activeCategory.value === "all") {
return iconCategoriesMap.all;
}
return iconCategoriesMap[activeCategory.value] || []
})
return iconCategoriesMap[activeCategory.value] || [];
});
// 过滤后的图标
const filteredIcons = computed(() => {
let icons = []
let icons = [];
if (searchValue.value) {
// 搜索时从所有图标中筛选
const allIcons = iconCategoriesMap.all
const allIcons = iconCategoriesMap.all;
icons = allIcons.filter((icon) =>
icon.toLowerCase().includes(searchValue.value.toLowerCase())
)
} else if (activeCategory.value === 'recent') {
icon.toLowerCase().includes(searchValue.value.toLowerCase()),
);
} else if (activeCategory.value === "recent") {
// 最近使用时不显示其他图标
icons = []
icons = [];
} else {
// 根据分类显示
icons = currentCategoryIcons.value
icons = currentCategoryIcons.value;
}
return icons
})
return icons;
});
// 打开选择器
const handleOpenPicker = () => {
tempIcon.value = props.modelValue
visible.value = true
}
tempIcon.value = props.modelValue;
visible.value = true;
};
// 清除选择
const handleClear = () => {
emit('update:modelValue', '')
emit('change', '')
}
emit("update:modelValue", "");
emit("change", "");
};
// 切换分类
const handleCategoryChange = (key) => {
activeCategory.value = key
}
activeCategory.value = key;
};
// 搜索变化
const handleSearchChange = () => {
// 搜索时重置分类
if (searchValue.value) {
activeCategory.value = 'all'
activeCategory.value = "all";
}
}
};
// 选择图标(直接确认并关闭)
const handleSelectIcon = (icon) => {
emit('update:modelValue', icon)
emit('change', icon)
selectedIcon.value = icon
emit("update:modelValue", icon);
emit("change", icon);
selectedIcon.value = icon;
// 添加到最近使用
saveRecentIcons(icon)
saveRecentIcons(icon);
visible.value = false
}
visible.value = false;
};
// 取消选择
const handleCancel = () => {
visible.value = false
}
visible.value = false;
};
// 监听 props 变化,更新本地状态
watch(
() => props.modelValue,
(newVal) => {
selectedIcon.value = newVal
}
)
selectedIcon.value = newVal;
},
);
// 组件挂载时加载最近使用的图标
onMounted(() => {
loadRecentIcons()
})
loadRecentIcons();
});
</script>
<style scoped lang="scss">
@@ -22,44 +22,44 @@ ## 基本使用
</template>
<script setup>
import { ref } from 'vue'
import authApi from '@/api/auth'
import { ref } from "vue";
import authApi from "@/api/auth";
const showImport = ref(false)
const showImport = ref(false);
const handleImportSuccess = (data) => {
console.log('导入成功', data)
console.log("导入成功", data);
// 刷新列表等操作
}
};
const handleImportError = (message) => {
console.log('导入失败', message)
}
console.log("导入失败", message);
};
</script>
```
## Props
| 参数 | 说明 | 类型 | 默认值 |
|------|------|------|--------|
| open | 是否显示弹窗 | Boolean | false |
| title | 弹窗标题 | String | '导入数据' |
| api | 导入API接口 | Function | 必填 |
| templateApi | 下载模板API接口 | Function | null |
| accept | 接受的文件类型 | String | '.xlsx,.xls,.csv' |
| maxSize | 文件大小限制(MB | Number | 10 |
| showTemplate | 是否显示下载模板 | Boolean | true |
| tip | 提示信息 | String | '' |
| filename | 文件名(用于下载) | String | '导入数据' |
| 参数 | 说明 | 类型 | 默认值 |
| ------------ | ------------------ | -------- | ----------------- |
| open | 是否显示弹窗 | Boolean | false |
| title | 弹窗标题 | String | '导入数据' |
| api | 导入API接口 | Function | 必填 |
| templateApi | 下载模板API接口 | Function | null |
| accept | 接受的文件类型 | String | '.xlsx,.xls,.csv' |
| maxSize | 文件大小限制(MB | Number | 10 |
| showTemplate | 是否显示下载模板 | Boolean | true |
| tip | 提示信息 | String | '' |
| filename | 文件名(用于下载) | String | '导入数据' |
## Events
| 事件名 | 说明 | 回调参数 |
|--------|------|----------|
| 事件名 | 说明 | 回调参数 |
| ----------- | ---------------- | ------------------ |
| update:open | 弹窗显示状态变化 | (visible: Boolean) |
| success | 导入成功 | (data, response) |
| error | 导出失败 | (message, error) |
| change | 文件列表变化 | (fileList) |
| success | 导入成功 | (data, response) |
| error | 导出失败 | (message, error) |
| change | 文件列表变化 | (fileList) |
## Slots
@@ -134,7 +134,11 @@ ## 完整示例
/>
</a-form-item>
<a-form-item label="是否激活">
<a-switch v-model:checked="formData.is_active" checked-children="是" un-checked-children="否" />
<a-switch
v-model:checked="formData.is_active"
checked-children="是"
un-checked-children="否"
/>
</a-form-item>
</a-form>
</template>
@@ -142,45 +146,45 @@ ## 完整示例
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import { ImportOutlined } from '@ant-design/icons-vue'
import authApi from '@/api/auth'
import { ref, onMounted } from "vue";
import { message } from "ant-design-vue";
import { ImportOutlined } from "@ant-design/icons-vue";
import authApi from "@/api/auth";
const importVisible = ref(false)
const departmentOptions = ref([])
const roleOptions = ref([])
const importVisible = ref(false);
const departmentOptions = ref([]);
const roleOptions = ref([]);
// 加载选项数据
onMounted(async () => {
try {
const [deptRes, roleRes] = await Promise.all([
authApi.departments.all.get(),
authApi.roles.all.get()
])
departmentOptions.value = deptRes.data || []
roleOptions.value = roleRes.data || []
authApi.roles.all.get(),
]);
departmentOptions.value = deptRes.data || [];
roleOptions.value = roleRes.data || [];
} catch (error) {
console.error('加载选项失败', error)
console.error("加载选项失败", error);
}
})
});
const handleImport = () => {
importVisible.value = true
}
importVisible.value = true;
};
const handleImportSuccess = (data) => {
message.success('导入成功')
message.success("导入成功");
// 刷新列表或执行其他操作
}
};
const handleImportError = (errorMessage) => {
message.error('导入失败:' + errorMessage)
}
message.error("导入失败:" + errorMessage);
};
const handleFileChange = (fileList) => {
console.log('文件列表变化', fileList)
}
console.log("文件列表变化", fileList);
};
</script>
```
@@ -43,7 +43,12 @@
<span>请先下载模板按照模板格式填写数据后上传</span>
</template>
<template #action>
<a-button type="link" size="small" :loading="templateLoading" @click="handleDownloadTemplate">
<a-button
type="link"
size="small"
:loading="templateLoading"
@click="handleDownloadTemplate"
>
<download-outlined /> 下载模板
</a-button>
</template>
@@ -61,225 +66,227 @@
</template>
<script setup>
import { ref, watch } from 'vue'
import { message } from 'ant-design-vue'
import { InboxOutlined, DownloadOutlined } from '@ant-design/icons-vue'
import { ref, watch } from "vue";
import { message } from "ant-design-vue";
import { InboxOutlined, DownloadOutlined } from "@ant-design/icons-vue";
const props = defineProps({
// 是否显示弹窗
open: {
type: Boolean,
default: false
default: false,
},
// 弹窗标题
title: {
type: String,
default: '导入数据'
default: "导入数据",
},
// 导入API接口
api: {
type: Function,
required: true
required: true,
},
// 下载模板API接口
templateApi: {
type: Function,
default: null
default: null,
},
// 接受的文件类型
accept: {
type: String,
default: '.xlsx,.xls,.csv'
default: ".xlsx,.xls,.csv",
},
// 文件大小限制(MB
maxSize: {
type: Number,
default: 10
default: 10,
},
// 是否显示下载模板
showTemplate: {
type: Boolean,
default: true
default: true,
},
// 提示信息
tip: {
type: String,
default: ''
default: "",
},
// 文件名(用于下载)
filename: {
type: String,
default: '导入数据'
}
})
default: "导入数据",
},
});
const emit = defineEmits(['update:open', 'success', 'error', 'change'])
const emit = defineEmits(["update:open", "success", "error", "change"]);
// 弹窗显示状态
const visible = ref(false)
const visible = ref(false);
// 文件列表
const fileList = ref([])
const fileList = ref([]);
// 加载状态
const loading = ref(false)
const templateLoading = ref(false)
const loading = ref(false);
const templateLoading = ref(false);
// 表单数据(用于插槽)
const formData = ref({})
const formData = ref({});
// 文件类型提示
const acceptTip = ref(props.accept || '支持 .xlsx, .xls, .csv 格式文件')
const acceptTip = ref(props.accept || "支持 .xlsx, .xls, .csv 格式文件");
// 监听外部 open 变化
watch(
() => props.open,
(val) => {
visible.value = val
visible.value = val;
if (val) {
// 打开时重置表单
fileList.value = []
formData.value = {}
fileList.value = [];
formData.value = {};
}
},
{ immediate: true }
)
{ immediate: true },
);
// 监听内部 visible 变化,同步到外部
watch(visible, (val) => {
emit('update:open', val)
})
emit("update:open", val);
});
// 自定义上传
const customUpload = (options) => {
const { onSuccess } = options
const { onSuccess } = options;
// 这里不直接上传,只是标记为准备上传
onSuccess({}, options.file)
}
onSuccess({}, options.file);
};
// 上传前校验
const beforeUpload = (file) => {
// 文件类型校验
const acceptTypes = props.accept.split(',').map((type) => type.trim().toLowerCase())
const fileName = file.name.toLowerCase()
const isValidType = acceptTypes.some((type) => fileName.endsWith(type))
const acceptTypes = props.accept
.split(",")
.map((type) => type.trim().toLowerCase());
const fileName = file.name.toLowerCase();
const isValidType = acceptTypes.some((type) => fileName.endsWith(type));
if (!isValidType) {
message.error(`文件格式不正确,仅支持 ${props.accept} 格式`)
return false
message.error(`文件格式不正确,仅支持 ${props.accept} 格式`);
return false;
}
// 文件大小校验
const maxSizeBytes = props.maxSize * 1024 * 1024
const maxSizeBytes = props.maxSize * 1024 * 1024;
if (file.size > maxSizeBytes) {
message.error(`文件大小不能超过 ${props.maxSize}MB`)
return false
message.error(`文件大小不能超过 ${props.maxSize}MB`);
return false;
}
return true
}
return true;
};
// 处理文件变化
const handleChange = ({ fileList: newFileList }) => {
fileList.value = newFileList
emit('change', newFileList)
}
fileList.value = newFileList;
emit("change", newFileList);
};
// 拖拽相关
const handleDrop = (e) => {
e.preventDefault()
}
e.preventDefault();
};
// 下载模板
const handleDownloadTemplate = async () => {
if (!props.templateApi) {
message.error('未配置模板下载接口')
return
message.error("未配置模板下载接口");
return;
}
try {
templateLoading.value = true
const blob = await props.templateApi()
templateLoading.value = true;
const blob = await props.templateApi();
// 创建下载链接
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `${props.filename}-模板.xlsx`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${props.filename}-模板.xlsx`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
message.success('模板下载成功')
message.success("模板下载成功");
} catch (error) {
message.error('模板下载失败:' + error.message)
message.error("模板下载失败:" + error.message);
} finally {
templateLoading.value = false
templateLoading.value = false;
}
}
};
// 确认导入
const handleOk = async () => {
if (fileList.value.length === 0) {
message.error('请选择要导入的文件')
return
message.error("请选择要导入的文件");
return;
}
const file = fileList.value[0]
const formDataObj = new FormData()
const file = fileList.value[0];
const formDataObj = new FormData();
// 添加文件
if (file.originFileObj) {
formDataObj.append('file', file.originFileObj)
formDataObj.append("file", file.originFileObj);
} else if (file.url) {
// 如果是已有文件,可能需要重新处理
message.error('请重新选择文件')
return
message.error("请重新选择文件");
return;
}
// 添加表单参数(如果有)
if (Object.keys(formData.value).length > 0) {
Object.keys(formData.value).forEach((key) => {
const value = formData.value[key]
if (value !== null && value !== undefined && value !== '') {
const value = formData.value[key];
if (value !== null && value !== undefined && value !== "") {
if (Array.isArray(value)) {
formDataObj.append(key, JSON.stringify(value))
} else if (typeof value === 'object') {
formDataObj.append(key, JSON.stringify(value))
formDataObj.append(key, JSON.stringify(value));
} else if (typeof value === "object") {
formDataObj.append(key, JSON.stringify(value));
} else {
formDataObj.append(key, value)
formDataObj.append(key, value);
}
}
})
});
}
try {
loading.value = true
const res = await props.api(formDataObj)
loading.value = true;
const res = await props.api(formDataObj);
if (res.code === 200 || res.success) {
message.success('导入成功')
emit('success', res.data, res)
handleCancel()
message.success("导入成功");
emit("success", res.data, res);
handleCancel();
} else {
message.error(res.message || '导入失败')
emit('error', res.message, res)
message.error(res.message || "导入失败");
emit("error", res.message, res);
}
} catch (error) {
message.error('导入失败:' + error.message)
emit('error', error.message, error)
message.error("导入失败:" + error.message);
emit("error", error.message, error);
} finally {
loading.value = false
loading.value = false;
}
}
};
// 取消导入
const handleCancel = () => {
visible.value = false
fileList.value = []
formData.value = {}
}
visible.value = false;
fileList.value = [];
formData.value = {};
};
</script>
<style scoped>
+262 -254
View File
@@ -22,35 +22,35 @@ ## 安装
```vue
<template>
<sc-select v-model:value="value" source-type="data" :data="options" />
<sc-select v-model:value="value" source-type="data" :data="options" />
</template>
<script setup>
import { ref } from 'vue'
import scSelect from '@/components/scSelect/index.vue'
import { ref } from "vue";
import scSelect from "@/components/scSelect/index.vue";
const value = ref('')
const value = ref("");
const options = ref([
{ label: '选项1', value: '1' },
{ label: '选项2', value: '2' },
])
{ label: "选项1", value: "1" },
{ label: "选项2", value: "2" },
]);
</script>
```
## Props
| 参数 | 说明 | 类型 | 可选值 | 默认值 |
|------|------|------|--------|--------|
| sourceType | 数据源类型 | String | 'data' \| 'api' \| 'dictionary' | 'data' |
| data | 直接数据(当 sourceType 为 data 时使用) | Array | - | [] |
| api | API 接口地址(当 sourceType 为 api 时使用) | String | - | '' |
| apiParams | API 请求参数(当 sourceType 为 api 时使用) | Object | - | {} |
| dictionaryCode | 字典编码(当 sourceType 为 dictionary 时使用) | String | - | '' |
| enableApiCache | 是否启用 API 数据缓存 | Boolean | - | true |
| apiCacheTime | API 缓存时间(毫秒) | Number | - | 3000005分钟) |
| fieldNames | 字段映射配置 | Object | - | { label: 'label', value: 'value' } |
| immediate | 是否在组件挂载时立即加载数据 | Boolean | - | false |
| dataProcessor | 数据处理函数 | Function | - | null |
| 参数 | 说明 | 类型 | 可选值 | 默认值 |
| -------------- | ---------------------------------------------- | -------- | ------------------------------- | ---------------------------------- |
| sourceType | 数据源类型 | String | 'data' \| 'api' \| 'dictionary' | 'data' |
| data | 直接数据(当 sourceType 为 data 时使用) | Array | - | [] |
| api | API 接口地址(当 sourceType 为 api 时使用) | String | - | '' |
| apiParams | API 请求参数(当 sourceType 为 api 时使用) | Object | - | {} |
| dictionaryCode | 字典编码(当 sourceType 为 dictionary 时使用) | String | - | '' |
| enableApiCache | 是否启用 API 数据缓存 | Boolean | - | true |
| apiCacheTime | API 缓存时间(毫秒) | Number | - | 3000005分钟) |
| fieldNames | 字段映射配置 | Object | - | { label: 'label', value: 'value' } |
| immediate | 是否在组件挂载时立即加载数据 | Boolean | - | false |
| dataProcessor | 数据处理函数 | Function | - | null |
## Events
@@ -64,11 +64,11 @@ ## Methods
通过 ref 可以调用以下方法:
| 方法名 | 说明 | 参数 |
|--------|------|------|
| refresh | 强制刷新数据 | - |
| loadApiData | 手动加载 API 数据 | - |
| loadDictionaryData | 手动加载字典数据 | - |
| 方法名 | 说明 | 参数 |
| ------------------ | ----------------- | ---- |
| refresh | 强制刷新数据 | - |
| loadApiData | 手动加载 API 数据 | - |
| loadDictionaryData | 手动加载字典数据 | - |
## 使用示例
@@ -78,30 +78,30 @@ ### 1. 使用 data 数据源
```vue
<template>
<a-form>
<a-form-item label="状态">
<sc-select
v-model:value="form.status"
source-type="data"
:data="statusOptions"
placeholder="请选择状态"
/>
</a-form-item>
</a-form>
<a-form>
<a-form-item label="状态">
<sc-select
v-model:value="form.status"
source-type="data"
:data="statusOptions"
placeholder="请选择状态"
/>
</a-form-item>
</a-form>
</template>
<script setup>
import { reactive } from 'vue'
import scSelect from '@/components/scSelect/index.vue'
import { reactive } from "vue";
import scSelect from "@/components/scSelect/index.vue";
const form = reactive({
status: ''
})
status: "",
});
const statusOptions = [
{ label: '启用', value: 1 },
{ label: '禁用', value: 0 },
]
{ label: "启用", value: 1 },
{ label: "禁用", value: 0 },
];
</script>
```
@@ -111,29 +111,29 @@ ### 2. 使用 api 数据源
```vue
<template>
<a-form>
<a-form-item label="用户">
<sc-select
v-model:value="form.userId"
source-type="api"
api="users"
:api-params="{ page: 1, page_size: 100 }"
:enable-api-cache="true"
:api-cache-time="600000"
placeholder="请选择用户"
:field-names="{ label: 'username', value: 'id' }"
/>
</a-form-item>
</a-form>
<a-form>
<a-form-item label="用户">
<sc-select
v-model:value="form.userId"
source-type="api"
api="users"
:api-params="{ page: 1, page_size: 100 }"
:enable-api-cache="true"
:api-cache-time="600000"
placeholder="请选择用户"
:field-names="{ label: 'username', value: 'id' }"
/>
</a-form-item>
</a-form>
</template>
<script setup>
import { reactive } from 'vue'
import scSelect from '@/components/scSelect/index.vue'
import { reactive } from "vue";
import scSelect from "@/components/scSelect/index.vue";
const form = reactive({
userId: ''
})
userId: "",
});
</script>
```
@@ -143,35 +143,35 @@ ### 3. 使用 dictionary 数据源(推荐)
```vue
<template>
<a-form>
<a-form-item label="性别">
<sc-select
v-model:value="form.gender"
source-type="dictionary"
dictionary-code="gender"
placeholder="请选择性别"
/>
</a-form-item>
<a-form>
<a-form-item label="性别">
<sc-select
v-model:value="form.gender"
source-type="dictionary"
dictionary-code="gender"
placeholder="请选择性别"
/>
</a-form-item>
<a-form-item label="用户状态">
<sc-select
v-model:value="form.userStatus"
source-type="dictionary"
dictionary-code="user_status"
placeholder="请选择用户状态"
/>
</a-form-item>
</a-form>
<a-form-item label="用户状态">
<sc-select
v-model:value="form.userStatus"
source-type="dictionary"
dictionary-code="user_status"
placeholder="请选择用户状态"
/>
</a-form-item>
</a-form>
</template>
<script setup>
import { reactive } from 'vue'
import scSelect from '@/components/scSelect/index.vue'
import { reactive } from "vue";
import scSelect from "@/components/scSelect/index.vue";
const form = reactive({
gender: '',
userStatus: ''
})
gender: "",
userStatus: "",
});
</script>
```
@@ -181,20 +181,20 @@ ### 4. 立即加载数据
```vue
<template>
<sc-select
v-model:value="value"
source-type="api"
api="roles/all"
:immediate="true"
placeholder="请选择角色"
/>
<sc-select
v-model:value="value"
source-type="api"
api="roles/all"
:immediate="true"
placeholder="请选择角色"
/>
</template>
<script setup>
import { ref } from 'vue'
import scSelect from '@/components/scSelect/index.vue'
import { ref } from "vue";
import scSelect from "@/components/scSelect/index.vue";
const value = ref('')
const value = ref("");
</script>
```
@@ -204,28 +204,28 @@ ### 5. 自定义数据处理
```vue
<template>
<sc-select
v-model:value="value"
source-type="api"
api="roles"
:data-processor="processData"
placeholder="请选择角色"
/>
<sc-select
v-model:value="value"
source-type="api"
api="roles"
:data-processor="processData"
placeholder="请选择角色"
/>
</template>
<script setup>
import { ref } from 'vue'
import scSelect from '@/components/scSelect/index.vue'
import { ref } from "vue";
import scSelect from "@/components/scSelect/index.vue";
const value = ref('')
const value = ref("");
// 自定义数据处理函数
function processData(data) {
return data.map(item => ({
label: `${item.name} (${item.description})`,
value: item.id,
extra: item.description
}))
return data.map((item) => ({
label: `${item.name} (${item.description})`,
value: item.id,
extra: item.description,
}));
}
</script>
```
@@ -234,32 +234,32 @@ ### 6. 使用 ref 调用方法
```vue
<template>
<div>
<sc-select
ref="selectRef"
v-model:value="value"
source-type="api"
api="users"
placeholder="请选择用户"
/>
<a-button @click="handleRefresh">刷新数据</a-button>
</div>
<div>
<sc-select
ref="selectRef"
v-model:value="value"
source-type="api"
api="users"
placeholder="请选择用户"
/>
<a-button @click="handleRefresh">刷新数据</a-button>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { message } from 'ant-design-vue'
import scSelect from '@/components/scSelect/index.vue'
import { ref } from "vue";
import { message } from "ant-design-vue";
import scSelect from "@/components/scSelect/index.vue";
const selectRef = ref(null)
const value = ref('')
const selectRef = ref(null);
const value = ref("");
// 刷新数据
function handleRefresh() {
if (selectRef.value) {
selectRef.value.refresh()
message.success('数据已刷新')
}
if (selectRef.value) {
selectRef.value.refresh();
message.success("数据已刷新");
}
}
</script>
```
@@ -268,13 +268,13 @@ ### 7. 禁用 API 缓存
```vue
<template>
<sc-select
v-model:value="value"
source-type="api"
api="users"
:enable-api-cache="false"
placeholder="请选择用户"
/>
<sc-select
v-model:value="value"
source-type="api"
api="users"
:enable-api-cache="false"
placeholder="请选择用户"
/>
</template>
```
@@ -282,20 +282,20 @@ ### 8. 多选模式
```vue
<template>
<sc-select
v-model:value="value"
mode="multiple"
source-type="dictionary"
dictionary-code="tags"
placeholder="请选择标签"
/>
<sc-select
v-model:value="value"
mode="multiple"
source-type="dictionary"
dictionary-code="tags"
placeholder="请选择标签"
/>
</template>
<script setup>
import { ref } from 'vue'
import scSelect from '@/components/scSelect/index.vue'
import { ref } from "vue";
import scSelect from "@/components/scSelect/index.vue";
const value = ref([])
const value = ref([]);
</script>
```
@@ -303,33 +303,36 @@ ### 9. 自定义插槽
```vue
<template>
<sc-select
v-model:value="value"
source-type="data"
:data="options"
placeholder="请选择"
>
<template #suffixIcon>
<SearchOutlined />
</template>
<template #notFoundContent>
<a-empty :image="Empty.PRESENTED_IMAGE_SIMPLE" description="暂无数据" />
</template>
</sc-select>
<sc-select
v-model:value="value"
source-type="data"
:data="options"
placeholder="请选择"
>
<template #suffixIcon>
<SearchOutlined />
</template>
<template #notFoundContent>
<a-empty
:image="Empty.PRESENTED_IMAGE_SIMPLE"
description="暂无数据"
/>
</template>
</sc-select>
</template>
<script setup>
import { ref } from 'vue'
import { SearchOutlined } from '@ant-design/icons-vue'
import { Empty } from 'ant-design-vue'
import scSelect from '@/components/scSelect/index.vue'
import { ref } from "vue";
import { SearchOutlined } from "@ant-design/icons-vue";
import { Empty } from "ant-design-vue";
import scSelect from "@/components/scSelect/index.vue";
const value = ref('')
const value = ref("");
const options = ref([
{ label: '选项1', value: '1' },
{ label: '选项2', value: '2' },
])
{ label: "选项1", value: "1" },
{ label: "选项2", value: "2" },
]);
</script>
```
@@ -340,20 +343,22 @@ ### data 数据源
支持以下格式:
1. 字符串/数字数组
```javascript
['选项1', '选项2', '选项3']
[1, 2, 3]
["选项1", "选项2", "选项3"][(1, 2, 3)];
```
2. 对象数组(标准格式)
```javascript
[
{ label: '选项1', value: '1' },
{ label: '选项2', value: '2' },
]
{ label: "选项1", value: "1" },
{ label: "选项2", value: "2" },
];
```
3. 对象数组(自定义字段)
```javascript
[
{ name: '选项1', id: 1 },
@@ -393,6 +398,7 @@ ### dictionary 数据源
字典数据通过后台管理系统的"数据字典管理"模块配置,登录后会自动缓存到前端。
字典数据格式:
```javascript
{
code: 'user_status',
@@ -405,11 +411,12 @@ ### dictionary 数据源
```
组件会自动转换为:
```javascript
[
{ label: '启用', value: '1', name: '启用', sort: 1, status: 1 },
{ label: '禁用', value: '0', name: '禁用', sort: 2, status: 1 },
]
{ label: "启用", value: "1", name: "启用", sort: 1, status: 1 },
{ label: "禁用", value: "0", name: "禁用", sort: 2, status: 1 },
];
```
## 字典数据缓存机制
@@ -424,25 +431,25 @@ ### 缓存流程
### 清空缓存
```javascript
import { useDictionaryStore } from '@/stores/modules/dictionary'
import { useDictionaryStore } from "@/stores/modules/dictionary";
const dictionaryStore = useDictionaryStore()
const dictionaryStore = useDictionaryStore();
// 清空字典缓存
dictionaryStore.clearCache()
dictionaryStore.clearCache();
```
### 获取缓存信息
```javascript
import { useDictionaryStore } from '@/stores/modules/dictionary'
import { useDictionaryStore } from "@/stores/modules/dictionary";
const dictionaryStore = useDictionaryStore()
const dictionaryStore = useDictionaryStore();
// 获取缓存信息
const info = dictionaryStore.getCacheInfo()
console.log('字典数量:', info.count)
console.log('最后加载时间:', new Date(info.lastLoadTime))
const info = dictionaryStore.getCacheInfo();
console.log("字典数量:", info.count);
console.log("最后加载时间:", new Date(info.lastLoadTime));
```
## API 数据缓存机制
@@ -488,6 +495,7 @@ ## 常见问题
### Q: 字典数据不显示?
A: 检查以下几点:
1. 后台管理系统中是否配置了对应的字典
2. 字典编码是否正确
3. 字典状态是否为启用
@@ -496,6 +504,7 @@ ### Q: 字典数据不显示?
### Q: API 数据加载失败?
A: 检查以下几点:
1. API 地址是否正确
2. API 是否需要认证
3. 返回数据格式是否符合要求
@@ -504,103 +513,102 @@ ### Q: API 数据加载失败?
### Q: 如何刷新字典数据?
A: 使用 `refresh()` 方法:
```javascript
const selectRef = ref(null)
selectRef.value?.refresh()
const selectRef = ref(null);
selectRef.value?.refresh();
```
### Q: 如何禁用缓存?
A: 设置 `enableApiCache``false`
```vue
<sc-select
source-type="api"
api="users"
:enable-api-cache="false"
/>
<sc-select source-type="api" api="users" :enable-api-cache="false" />
```
## 完整示例
```vue
<template>
<a-form :model="form" :label-col="{ span: 6 }" :wrapper-col="{ span: 18 }">
<!-- 使用数据源 -->
<a-form-item label="数据源示例">
<sc-select
v-model:value="form.dataSource"
source-type="data"
:data="[
{ label: '字典数据', value: 'dictionary' },
{ label: 'API数据', value: 'api' },
{ label: '直接数据', value: 'data' },
]"
placeholder="请选择数据源"
/>
</a-form-item>
<a-form :model="form" :label-col="{ span: 6 }" :wrapper-col="{ span: 18 }">
<!-- 使用数据源 -->
<a-form-item label="数据源示例">
<sc-select
v-model:value="form.dataSource"
source-type="data"
:data="[
{ label: '字典数据', value: 'dictionary' },
{ label: 'API数据', value: 'api' },
{ label: '直接数据', value: 'data' },
]"
placeholder="请选择数据源"
/>
</a-form-item>
<!-- 使用字典数据 -->
<a-form-item label="用户状态">
<sc-select
v-model:value="form.status"
source-type="dictionary"
dictionary-code="user_status"
placeholder="请选择用户状态"
/>
</a-form-item>
<!-- 使用字典数据 -->
<a-form-item label="用户状态">
<sc-select
v-model:value="form.status"
source-type="dictionary"
dictionary-code="user_status"
placeholder="请选择用户状态"
/>
</a-form-item>
<!-- 使用 API 数据 -->
<a-form-item label="所属角色">
<sc-select
v-model:value="form.roleId"
source-type="api"
api="roles/all"
:enable-api-cache="true"
:immediate="true"
:field-names="{ label: 'name', value: 'id' }"
placeholder="请选择角色"
/>
</a-form-item>
<!-- 使用 API 数据 -->
<a-form-item label="所属角色">
<sc-select
v-model:value="form.roleId"
source-type="api"
api="roles/all"
:enable-api-cache="true"
:immediate="true"
:field-names="{ label: 'name', value: 'id' }"
placeholder="请选择角色"
/>
</a-form-item>
<!-- 多选模式 -->
<a-form-item label="标签">
<sc-select
v-model:value="form.tags"
mode="multiple"
source-type="dictionary"
dictionary-code="tags"
placeholder="请选择标签"
/>
</a-form-item>
<!-- 多选模式 -->
<a-form-item label="标签">
<sc-select
v-model:value="form.tags"
mode="multiple"
source-type="dictionary"
dictionary-code="tags"
placeholder="请选择标签"
/>
</a-form-item>
<!-- 带搜索 -->
<a-form-item label="省份">
<sc-select
v-model:value="form.province"
source-type="api"
api="cities/provinces"
show-search
:filter-option="filterOption"
placeholder="请选择省份"
/>
</a-form-item>
</a-form>
<!-- 带搜索 -->
<a-form-item label="省份">
<sc-select
v-model:value="form.province"
source-type="api"
api="cities/provinces"
show-search
:filter-option="filterOption"
placeholder="请选择省份"
/>
</a-form-item>
</a-form>
</template>
<script setup>
import { reactive } from 'vue'
import scSelect from '@/components/scSelect/index.vue'
import { reactive } from "vue";
import scSelect from "@/components/scSelect/index.vue";
const form = reactive({
dataSource: '',
status: '',
roleId: '',
tags: [],
province: '',
})
dataSource: "",
status: "",
roleId: "",
tags: [],
province: "",
});
// 搜索过滤
function filterOption(input, option) {
return option.label.toLowerCase().includes(input.toLowerCase())
return option.label.toLowerCase().includes(input.toLowerCase());
}
</script>
```
@@ -13,21 +13,21 @@
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { useDictionaryStore } from '@/stores/modules/dictionary'
import request from '@/utils/request'
import { ref, computed, watch } from "vue";
import { useDictionaryStore } from "@/stores/modules/dictionary";
import request from "@/utils/request";
defineOptions({
name: 'ScSelect',
name: "ScSelect",
inheritAttrs: false,
})
});
const props = defineProps({
// 数据源类型:data(直接数据)、api(接口数据)、dictionary(字典数据)
sourceType: {
type: String,
default: 'data',
validator: (value) => ['data', 'api', 'dictionary'].includes(value),
default: "data",
validator: (value) => ["data", "api", "dictionary"].includes(value),
},
// 直接数据(当 sourceType 为 data 时使用)
data: {
@@ -37,7 +37,7 @@ const props = defineProps({
// API 接口地址(当 sourceType 为 api 时使用)
api: {
type: String,
default: '',
default: "",
},
// API 请求参数(当 sourceType 为 api 时使用)
apiParams: {
@@ -47,7 +47,7 @@ const props = defineProps({
// 字典编码(当 sourceType 为 dictionary 时使用)
dictionaryCode: {
type: String,
default: '',
default: "",
},
// 是否启用 API 数据缓存(当 sourceType 为 api 时使用)
enableApiCache: {
@@ -63,8 +63,8 @@ const props = defineProps({
fieldNames: {
type: Object,
default: () => ({
label: 'label',
value: 'value',
label: "label",
value: "value",
}),
},
// 是否在组件挂载时立即加载数据
@@ -77,62 +77,62 @@ const props = defineProps({
type: Function,
default: null,
},
})
});
const dictionaryStore = useDictionaryStore()
const loading = ref(false)
const apiData = ref(null)
const apiCacheTime = ref(null)
const dictionaryStore = useDictionaryStore();
const loading = ref(false);
const apiData = ref(null);
const apiCacheTime = ref(null);
const options = computed(() => {
switch (props.sourceType) {
case 'data':
return processData(props.data)
case 'api':
return processData(apiData.value || [])
case 'dictionary':
return processData(getDictionaryData())
case "data":
return processData(props.data);
case "api":
return processData(apiData.value || []);
case "dictionary":
return processData(getDictionaryData());
default:
return []
return [];
}
})
});
// API 数据缓存
const apiCache = new Map()
const apiCache = new Map();
/**
* 处理数据格式
*/
function processData(data) {
if (!data || !Array.isArray(data)) {
return []
return [];
}
// 如果有自定义处理函数,使用自定义处理
if (props.dataProcessor && typeof props.dataProcessor === 'function') {
return props.dataProcessor(data)
if (props.dataProcessor && typeof props.dataProcessor === "function") {
return props.dataProcessor(data);
}
// 默认处理:确保数据有 label 和 value 字段
return data.map((item) => {
if (typeof item === 'string' || typeof item === 'number') {
if (typeof item === "string" || typeof item === "number") {
return {
label: item,
value: item,
}
};
}
// 如果已经有 label 和 value 字段,直接返回
if (item.label !== undefined && item.value !== undefined) {
return item
return item;
}
// 尝试使用 fieldNames 映射
const labelKey = props.fieldNames.label || 'label'
const valueKey = props.fieldNames.value || 'value'
const labelKey = props.fieldNames.label || "label";
const valueKey = props.fieldNames.value || "value";
return {
label: item[labelKey] || item.name || item.title || item.id,
value: item[valueKey] !== undefined ? item[valueKey] : item.id,
...item,
}
})
};
});
}
/**
@@ -140,9 +140,9 @@ function processData(data) {
*/
function getDictionaryData() {
if (!props.dictionaryCode) {
return []
return [];
}
return dictionaryStore.dictionaries[props.dictionaryCode] || []
return dictionaryStore.dictionaries[props.dictionaryCode] || [];
}
/**
@@ -150,42 +150,42 @@ function getDictionaryData() {
*/
async function loadApiData() {
if (!props.api) {
return
return;
}
// 检查缓存
if (props.enableApiCache) {
const cacheKey = props.api + JSON.stringify(props.apiParams)
const cached = apiCache.get(cacheKey)
const cacheKey = props.api + JSON.stringify(props.apiParams);
const cached = apiCache.get(cacheKey);
if (cached && Date.now() - cached.time < props.apiCacheTime) {
apiData.value = cached.data
return
apiData.value = cached.data;
return;
}
}
loading.value = true
loading.value = true;
try {
const res = await request.get(props.api, { params: props.apiParams })
const res = await request.get(props.api, { params: props.apiParams });
if (res.code === 200) {
const data = res.data || res.list || []
apiData.value = data
const data = res.data || res.list || [];
apiData.value = data;
// 缓存数据
if (props.enableApiCache) {
const cacheKey = props.api + JSON.stringify(props.apiParams)
const cacheKey = props.api + JSON.stringify(props.apiParams);
apiCache.set(cacheKey, {
data,
time: Date.now(),
})
});
}
}
} catch (error) {
console.error('加载 API 数据失败:', error)
apiData.value = []
console.error("加载 API 数据失败:", error);
apiData.value = [];
} finally {
loading.value = false
loading.value = false;
}
}
@@ -194,21 +194,21 @@ async function loadApiData() {
*/
async function loadDictionaryData() {
if (!props.dictionaryCode) {
return
return;
}
// 检查缓存
if (dictionaryStore.dictionaries[props.dictionaryCode]) {
return
return;
}
loading.value = true
loading.value = true;
try {
await dictionaryStore.getDictionary(props.dictionaryCode)
await dictionaryStore.getDictionary(props.dictionaryCode);
} catch (error) {
console.error('加载字典数据失败:', error)
console.error("加载字典数据失败:", error);
} finally {
loading.value = false
loading.value = false;
}
}
@@ -217,16 +217,16 @@ async function loadDictionaryData() {
*/
async function handleFocus() {
switch (props.sourceType) {
case 'api':
case "api":
if (!apiData.value || apiData.value.length === 0) {
await loadApiData()
await loadApiData();
}
break
case 'dictionary':
break;
case "dictionary":
if (!dictionaryStore.dictionaries[props.dictionaryCode]) {
await loadDictionaryData()
await loadDictionaryData();
}
break
break;
}
}
@@ -235,12 +235,12 @@ async function handleFocus() {
*/
async function refresh() {
switch (props.sourceType) {
case 'api':
await loadApiData()
break
case 'dictionary':
await dictionaryStore.getDictionary(props.dictionaryCode, true)
break
case "api":
await loadApiData();
break;
case "dictionary":
await dictionaryStore.getDictionary(props.dictionaryCode, true);
break;
}
}
@@ -248,50 +248,50 @@ async function refresh() {
watch(
() => props.data,
(newVal) => {
if (props.sourceType === 'data') {
if (props.sourceType === "data") {
// data 类型不需要特殊处理,计算属性会自动更新
}
},
{ immediate: true }
)
{ immediate: true },
);
watch(
() => props.api,
() => {
if (props.sourceType === 'api') {
apiData.value = null
if (props.sourceType === "api") {
apiData.value = null;
}
}
)
},
);
watch(
() => props.apiParams,
() => {
if (props.sourceType === 'api') {
apiData.value = null
if (props.sourceType === "api") {
apiData.value = null;
}
},
{ deep: true }
)
{ deep: true },
);
watch(
() => props.dictionaryCode,
() => {
if (props.sourceType === 'dictionary') {
if (props.sourceType === "dictionary") {
// dictionary 变化时,数据会自动从 store 获取
}
}
)
},
);
// 组件挂载时立即加载数据
if (props.immediate) {
switch (props.sourceType) {
case 'api':
loadApiData()
break
case 'dictionary':
loadDictionaryData()
break
case "api":
loadApiData();
break;
case "dictionary":
loadDictionaryData();
break;
}
}
@@ -300,7 +300,7 @@ defineExpose({
refresh,
loadApiData,
loadDictionaryData,
})
});
</script>
<style scoped lang="scss">
+241 -133
View File
@@ -2,10 +2,24 @@
<div class="sc-table" ref="tableWrapper">
<!-- 表格内容 -->
<div class="sc-table-content" ref="tableContent">
<a-table v-if="dataSource.length > 0" :columns="tableColumns" :data-source="dataSource" :loading="loading" :pagination="false"
:row-key="rowKey" :row-selection="rowSelection" :scroll="scroll" :bordered="tableSettings.bordered"
:size="tableSettings.size" :show-header="showHeader" :locale="locale" :expanded-row-keys="expandedRowKeys"
:expand-row-by-click="expandRowByClick" @change="handleTableChange" @resizeColumn="handleResizeColumn">
<a-table
v-if="dataSource.length > 0"
:columns="tableColumns"
:data-source="dataSource"
:loading="loading"
:pagination="false"
:row-key="rowKey"
:row-selection="rowSelection"
:scroll="scroll"
:bordered="tableSettings.bordered"
:size="tableSettings.size"
:show-header="showHeader"
:locale="locale"
:expanded-row-keys="expandedRowKeys"
:expand-row-by-click="expandRowByClick"
@change="handleTableChange"
@resizeColumn="handleResizeColumn"
>
<!-- 自定义单元格内容 -->
<template #bodyCell="{ text, record, index, column }">
<!-- 序号列 -->
@@ -14,14 +28,22 @@
</template>
<!-- 自定义插槽 -->
<template v-else-if="column.slot">
<slot :name="column.slot || column.dataIndex" :text="text" :record="record" :index="index"
:column="column"></slot>
<slot
:name="column.slot || column.dataIndex"
:text="text"
:record="record"
:index="index"
:column="column"
></slot>
</template>
</template>
<!-- 空状态 -->
<template #emptyText>
<a-empty :image="Empty.PRESENTED_IMAGE_SIMPLE" :description="emptyText" />
<a-empty
:image="Empty.PRESENTED_IMAGE_SIMPLE"
:description="emptyText"
/>
</template>
</a-table>
</div>
@@ -29,15 +51,22 @@
<!-- 工具栏 -->
<div v-if="showToolbar" class="sc-table-tool">
<div class="tool-left">
<a-pagination v-bind="pagination" @change="handlePaginationChange"
@showSizeChange="handlePaginationChange" />
<a-pagination
v-bind="pagination"
@change="handlePaginationChange"
@showSizeChange="handlePaginationChange"
/>
</div>
<div class="tool-right">
<!-- 右侧工具栏插槽 -->
<slot name="toolRight"></slot>
<!-- 刷新按钮 -->
<a-tooltip v-if="showRefresh" title="刷新">
<a-button shape="circle" :loading="loading" @click="handleRefresh">
<a-button
shape="circle"
:loading="loading"
@click="handleRefresh"
>
<template #icon>
<SyncOutlined />
</template>
@@ -46,7 +75,12 @@
<!-- 表格设置按钮 -->
<a-tooltip v-if="showColumnSetting" title="表格设置">
<a-popover v-model:open="tableSettingVisible" placement="topRight" trigger="click" :width="240">
<a-popover
v-model:open="tableSettingVisible"
placement="topRight"
trigger="click"
:width="240"
>
<template #content>
<div class="table-setting">
<div class="table-setting-header">
@@ -55,17 +89,35 @@
<div class="table-setting-body">
<!-- 边框设置 -->
<div class="setting-item">
<span class="setting-label">显示边框</span>
<a-switch v-model:checked="tableSettings.bordered" size="small" />
<span class="setting-label"
>显示边框</span
>
<a-switch
v-model:checked="
tableSettings.bordered
"
size="small"
/>
</div>
<!-- 表格大小 -->
<div class="setting-item">
<span class="setting-label">表格大小</span>
<a-radio-group v-model:value="tableSettings.size" size="small"
button-style="solid">
<a-radio-button value="small"></a-radio-button>
<a-radio-button value="middle"></a-radio-button>
<a-radio-button value="large"></a-radio-button>
<span class="setting-label"
>表格大小</span
>
<a-radio-group
v-model:value="tableSettings.size"
size="small"
button-style="solid"
>
<a-radio-button value="small"
></a-radio-button
>
<a-radio-button value="middle"
></a-radio-button
>
<a-radio-button value="large"
></a-radio-button
>
</a-radio-group>
</div>
</div>
@@ -81,21 +133,47 @@
<!-- 列设置按钮 -->
<a-tooltip v-if="showColumnSetting" title="列设置">
<a-popover v-model:open="columnSettingVisible" placement="topRight" trigger="click">
<a-popover
v-model:open="columnSettingVisible"
placement="topRight"
trigger="click"
>
<template #content>
<div class="column-setting">
<div class="column-setting-header">
<span>显示与排序</span>
</div>
<div class="column-setting-list">
<div v-for="(colKey, index) in sortedColumns" :key="colKey"
class="column-setting-item" :class="{ dragging: draggingIndex === index }"
draggable="true" @dragstart="handleDragStart(index, $event)"
@dragover="handleDragOver(index, $event)" @dragend="handleDragEnd"
@drop="handleDrop(index)">
<div
v-for="(colKey, index) in sortedColumns"
:key="colKey"
class="column-setting-item"
:class="{
dragging: draggingIndex === index,
}"
draggable="true"
@dragstart="
handleDragStart(index, $event)
"
@dragover="
handleDragOver(index, $event)
"
@dragend="handleDragEnd"
@drop="handleDrop(index)"
>
<HolderOutlined class="drag-handle" />
<a-checkbox :checked="visibleColumns.includes(colKey)"
@change="(e) => toggleColumn(colKey, e.target.checked)">
<a-checkbox
:checked="
visibleColumns.includes(colKey)
"
@change="
(e) =>
toggleColumn(
colKey,
e.target.checked,
)
"
>
{{ getColumnTitle(colKey) }}
</a-checkbox>
</div>
@@ -115,12 +193,20 @@
</template>
<script setup>
import { ref, computed, watch, reactive, useTemplateRef, onMounted, onBeforeUnmount } from 'vue'
import { Empty } from 'ant-design-vue'
import {
ref,
computed,
watch,
reactive,
useTemplateRef,
onMounted,
onBeforeUnmount,
} from "vue";
import { Empty } from "ant-design-vue";
defineOptions({
name: 'scTable',
})
name: "scTable",
});
const props = defineProps({
// 数据源
@@ -137,7 +223,7 @@ const props = defineProps({
// 行的唯一标识
rowKey: {
type: [String, Function],
default: 'id',
default: "id",
},
// 加载状态
loading: {
@@ -153,7 +239,7 @@ const props = defineProps({
total: 0,
showSizeChanger: true,
showTotal: (total) => `${total}`,
pageSizeOptions: ['20', '50', '100', '200'],
pageSizeOptions: ["20", "50", "100", "200"],
}),
},
// 行选择配置
@@ -164,7 +250,7 @@ const props = defineProps({
// 表格大小
size: {
type: String,
default: 'middle', // large, middle, small
default: "middle", // large, middle, small
},
// 是否显示边框
bordered: {
@@ -194,7 +280,7 @@ const props = defineProps({
// 序号列标题
indexTitle: {
type: String,
default: '序号',
default: "序号",
},
// 是否显示工具栏
showToolbar: {
@@ -214,7 +300,7 @@ const props = defineProps({
// 空状态文字
emptyText: {
type: String,
default: '暂无数据',
default: "暂无数据",
},
// 树形表格配置
defaultExpandAll: {
@@ -225,59 +311,62 @@ const props = defineProps({
type: Boolean,
default: false,
},
})
});
// 展开的行keys
const expandedRowKeys = ref([])
const expandedRowKeys = ref([]);
const tableContent = useTemplateRef('tableContent')
const tableWrapper = useTemplateRef('tableWrapper')
const tableContent = useTemplateRef("tableContent");
const tableWrapper = useTemplateRef("tableWrapper");
let scroll = ref({
scrollToFirstRowOnChange: true,
x: 'max-content',
x: "max-content",
y: true,
})
});
// 递归获取所有节点的key
const getAllNodeKeys = (nodes) => {
const keys = []
const keys = [];
const traverse = (list) => {
list.forEach(node => {
list.forEach((node) => {
// 如果节点有children且不为空,则该节点需要展开
if (node.children && node.children.length > 0) {
const key = typeof props.rowKey === 'function' ? props.rowKey(node) : node[props.rowKey]
keys.push(key)
traverse(node.children)
const key =
typeof props.rowKey === "function"
? props.rowKey(node)
: node[props.rowKey];
keys.push(key);
traverse(node.children);
}
})
}
traverse(nodes)
return keys
}
});
};
traverse(nodes);
return keys;
};
// 监听数据变化,自动展开所有节点
watch(
() => props.dataSource,
(newData) => {
if (props.defaultExpandAll && newData && newData.length > 0) {
expandedRowKeys.value = getAllNodeKeys(newData)
expandedRowKeys.value = getAllNodeKeys(newData);
} else {
expandedRowKeys.value = []
expandedRowKeys.value = [];
}
},
{ immediate: true, deep: true }
)
{ immediate: true, deep: true },
);
onMounted(() => {
updateTableHeight()
})
updateTableHeight();
});
const updateTableHeight = () => {
if (tableContent.value) {
const tableHeight = tableContent.value.clientHeight - 56
scroll.value.y = tableHeight > 0 ? tableHeight : 400
const tableHeight = tableContent.value.clientHeight - 56;
scroll.value.y = tableHeight > 0 ? tableHeight : 400;
}
}
};
// 根据表格宽度优化横向滚动配置
watch(
@@ -285,191 +374,210 @@ watch(
() => {
// 如果列有固定宽度且总宽度较大,使用max-content
// 否则使用true让表格自适应
const hasFixedColumns = props.columns.some((col) => col.width)
const hasFixedColumns = props.columns.some((col) => col.width);
if (hasFixedColumns || props.showIndex) {
scroll.value.x = 'max-content'
scroll.value.x = "max-content";
} else {
scroll.value.x = true
scroll.value.x = true;
}
},
{ immediate: true, deep: true },
)
);
// 表格设置状态
const tableSettings = reactive({
bordered: props.bordered,
size: props.size,
})
});
// 监听props变化
watch(
() => props.bordered,
(val) => {
tableSettings.bordered = val
tableSettings.bordered = val;
},
)
);
watch(
() => props.size,
(val) => {
tableSettings.size = val
tableSettings.size = val;
},
)
);
const emit = defineEmits(['refresh', 'change', 'resizeColumn', 'select', 'selectAll', 'selectNone', 'paginationChange'])
const emit = defineEmits([
"refresh",
"change",
"resizeColumn",
"select",
"selectAll",
"selectNone",
"paginationChange",
]);
// 列设置相关
const columnSettingVisible = ref(false)
const tableSettingVisible = ref(false)
const visibleColumns = ref([])
const sortedColumns = ref([]) // 排序后的列key数组
const draggingIndex = ref(-1) // 当前拖拽的索引
const columnSettingVisible = ref(false);
const tableSettingVisible = ref(false);
const visibleColumns = ref([]);
const sortedColumns = ref([]); // 排序后的列key数组
const draggingIndex = ref(-1); // 当前拖拽的索引
// 所有列
const allColumns = computed(() => {
return props.columns.filter((col) => col.dataIndex && col.dataIndex !== '_index')
})
return props.columns.filter(
(col) => col.dataIndex && col.dataIndex !== "_index",
);
});
// 获取列标题
const getColumnTitle = (colKey) => {
const col = allColumns.value.find((c) => (c.dataIndex || c.key) === colKey)
return col ? col.title : colKey
}
const col = allColumns.value.find((c) => (c.dataIndex || c.key) === colKey);
return col ? col.title : colKey;
};
// 初始化可见列和排序
watch(
() => props.columns,
(newColumns) => {
const columnKeys = newColumns.filter((col) => col.dataIndex && col.dataIndex !== '_index').map((col) => col.dataIndex || col.key)
const columnKeys = newColumns
.filter((col) => col.dataIndex && col.dataIndex !== "_index")
.map((col) => col.dataIndex || col.key);
// 如果是首次初始化,使用原始顺序
if (sortedColumns.value.length === 0) {
sortedColumns.value = [...columnKeys]
sortedColumns.value = [...columnKeys];
} else {
// 保留已存在的顺序,添加新列
const existingKeys = sortedColumns.value.filter((key) => columnKeys.includes(key))
const newKeys = columnKeys.filter((key) => !existingKeys.includes(key))
sortedColumns.value = [...existingKeys, ...newKeys]
const existingKeys = sortedColumns.value.filter((key) =>
columnKeys.includes(key),
);
const newKeys = columnKeys.filter(
(key) => !existingKeys.includes(key),
);
sortedColumns.value = [...existingKeys, ...newKeys];
}
visibleColumns.value = [...sortedColumns.value]
visibleColumns.value = [...sortedColumns.value];
},
{ immediate: true, deep: true },
)
);
// 切换列的显示状态
const toggleColumn = (colKey, checked) => {
if (checked) {
if (!visibleColumns.value.includes(colKey)) {
visibleColumns.value.push(colKey)
visibleColumns.value.push(colKey);
}
} else {
visibleColumns.value = visibleColumns.value.filter((key) => key !== colKey)
visibleColumns.value = visibleColumns.value.filter(
(key) => key !== colKey,
);
}
}
};
// 拖拽开始
const handleDragStart = (index, event) => {
draggingIndex.value = index
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('text/plain', index.toString())
}
draggingIndex.value = index;
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", index.toString());
};
// 拖拽经过
const handleDragOver = (index, event) => {
event.preventDefault()
event.dataTransfer.dropEffect = 'move'
}
event.preventDefault();
event.dataTransfer.dropEffect = "move";
};
// 拖拽结束
const handleDragEnd = () => {
draggingIndex.value = -1
}
draggingIndex.value = -1;
};
// 拖拽放置
const handleDrop = (dropIndex) => {
if (draggingIndex.value === dropIndex) return
if (draggingIndex.value === dropIndex) return;
const draggedKey = sortedColumns.value[draggingIndex.value]
const newColumns = [...sortedColumns.value]
const draggedKey = sortedColumns.value[draggingIndex.value];
const newColumns = [...sortedColumns.value];
// 移除被拖拽的项
newColumns.splice(draggingIndex.value, 1)
newColumns.splice(draggingIndex.value, 1);
// 插入到新位置
newColumns.splice(dropIndex, 0, draggedKey)
newColumns.splice(dropIndex, 0, draggedKey);
sortedColumns.value = newColumns
draggingIndex.value = -1
}
sortedColumns.value = newColumns;
draggingIndex.value = -1;
};
// 处理刷新
const handleRefresh = () => {
emit('refresh')
}
emit("refresh");
};
// 处理分页变化
const handlePaginationChange = (page, pageSize) => {
emit('paginationChange', { page, pageSize })
}
emit("paginationChange", { page, pageSize });
};
// 处理表格变化(排序、筛选)
const handleTableChange = (filters, sorter, extra) => {
emit('change', { filters, sorter, extra })
}
emit("change", { filters, sorter, extra });
};
// 处理列宽调整
const handleResizeColumn = (width, column) => {
emit('resizeColumn', { width, column })
}
emit("resizeColumn", { width, column });
};
// 获取表格序号
const getTableIndex = (index) => {
const { current = 1, pageSize = 10 } = props.pagination || {}
return (current - 1) * pageSize + index + 1
}
const { current = 1, pageSize = 10 } = props.pagination || {};
return (current - 1) * pageSize + index + 1;
};
// 表格列配置
const tableColumns = computed(() => {
let columns = []
let columns = [];
// 添加序号列
if (props.showIndex) {
columns.push({
title: props.indexTitle,
dataIndex: '_index',
key: '_index',
dataIndex: "_index",
key: "_index",
width: props.indexColumnWidth,
align: 'center',
fixed: 'left',
})
align: "center",
fixed: "left",
});
}
// 添加数据列(按排序顺序)
sortedColumns.value.forEach((colKey) => {
// 过滤掉未显示的列
if (!visibleColumns.value.includes(colKey)) {
return
return;
}
const col = props.columns.find((c) => (c.dataIndex || c.key) === colKey)
const col = props.columns.find(
(c) => (c.dataIndex || c.key) === colKey,
);
if (col) {
columns.push({
...col,
customRender: col.slot ? undefined : col.customRender,
})
});
}
})
});
return columns
})
return columns;
});
// 暴露方法给父组件
defineExpose({
refresh: handleRefresh,
getTableIndex,
})
});
</script>
<style scoped lang="scss">
@@ -20,163 +20,169 @@
</template>
<script setup>
import { ref, watch } from 'vue'
import { message } from 'ant-design-vue'
import { UploadOutlined } from '@ant-design/icons-vue'
import uploadConfig from '@/config/upload'
import { ref, watch } from "vue";
import { message } from "ant-design-vue";
import { UploadOutlined } from "@ant-design/icons-vue";
import uploadConfig from "@/config/upload";
const props = defineProps({
// 文件列表
modelValue: {
type: [Array, String],
default: () => []
default: () => [],
},
// 最大上传数量,默认1为单文件上传
maxCount: {
type: Number,
default: 1
default: 1,
},
// 接受的文件类型,例如 '.pdf,.doc,.docx' 或 '*'
accept: {
type: String,
default: '*'
default: "*",
},
// 是否禁用
disabled: {
type: Boolean,
default: false
default: false,
},
// 是否支持多选
multiple: {
type: Boolean,
default: false
default: false,
},
// 是否返回URL字符串(单文件)或URL数组(多文件)
returnUrl: {
type: Boolean,
default: true
}
})
default: true,
},
});
const emit = defineEmits(['update:modelValue', 'change', 'remove'])
const emit = defineEmits(["update:modelValue", "change", "remove"]);
// 文件列表
const fileList = ref([])
const fileList = ref([]);
// 初始化文件列表
const initFileList = () => {
if (props.modelValue) {
if (typeof props.modelValue === 'string') {
if (typeof props.modelValue === "string") {
// 单文件上传,字符串格式
fileList.value = props.modelValue
? [
{
uid: '-1',
name: 'file',
status: 'done',
uid: "-1",
name: "file",
status: "done",
url: props.modelValue,
response: {
src: props.modelValue
}
}
]
: []
src: props.modelValue,
},
},
]
: [];
} else if (Array.isArray(props.modelValue)) {
// 多文件上传,数组格式
fileList.value = props.modelValue.map((url, index) => ({
uid: `-${index}`,
name: `file${index}`,
status: 'done',
status: "done",
url: url,
response: {
src: url
}
}))
src: url,
},
}));
}
} else {
fileList.value = []
fileList.value = [];
}
}
};
// 监听外部值变化
watch(
() => props.modelValue,
() => {
initFileList()
initFileList();
},
{ immediate: true }
)
{ immediate: true },
);
// 自定义上传
const customUpload = (options) => {
const { file, onProgress, onSuccess, onError } = options
const formData = new FormData()
formData.append(uploadConfig.filename || 'file', file)
const { file, onProgress, onSuccess, onError } = options;
const formData = new FormData();
formData.append(uploadConfig.filename || "file", file);
// 使用文件上传API对象
const apiObj = uploadConfig.apiObjFile || uploadConfig.apiObj
const apiObj = uploadConfig.apiObjFile || uploadConfig.apiObj;
apiObj(formData, {
onUploadProgress: (progressEvent) => {
const percent = Math.round((progressEvent.loaded / progressEvent.total) * 100)
onProgress({ percent }, file)
}
const percent = Math.round(
(progressEvent.loaded / progressEvent.total) * 100,
);
onProgress({ percent }, file);
},
})
.then((res) => {
const data = uploadConfig.parseData(res)
const data = uploadConfig.parseData(res);
if (data.code === uploadConfig.successCode) {
onSuccess(data, file)
message.success('上传成功')
onSuccess(data, file);
message.success("上传成功");
} else {
onError(new Error(data.msg || '上传失败'))
message.error(data.msg || '上传失败')
onError(new Error(data.msg || "上传失败"));
message.error(data.msg || "上传失败");
}
})
.catch((error) => {
onError(error)
message.error('上传失败:' + error.message)
})
}
onError(error);
message.error("上传失败:" + error.message);
});
};
// 上传前校验
const beforeUpload = (file) => {
const maxSizeMB = uploadConfig.maxSizeFile || uploadConfig.maxSize || 10
const maxSizeBytes = maxSizeMB * 1024 * 1024
const maxSizeMB = uploadConfig.maxSizeFile || uploadConfig.maxSize || 10;
const maxSizeBytes = maxSizeMB * 1024 * 1024;
if (file.size > maxSizeBytes) {
message.error(`文件大小不能超过 ${maxSizeMB}MB`)
return false
message.error(`文件大小不能超过 ${maxSizeMB}MB`);
return false;
}
return true
}
return true;
};
// 处理文件列表变化
const handleChange = ({ fileList: newFileList }) => {
fileList.value = newFileList
fileList.value = newFileList;
// 提取成功的文件URL
const successFiles = newFileList
.filter((file) => file.status === 'done' && (file.url || file.response?.src))
.map((file) => file.url || file.response?.src)
.filter(
(file) =>
file.status === "done" && (file.url || file.response?.src),
)
.map((file) => file.url || file.response?.src);
// 触发更新事件
if (props.returnUrl) {
// 返回URL字符串或数组
const value = props.maxCount === 1 ? successFiles[0] || '' : successFiles
emit('update:modelValue', value)
emit('change', value, newFileList)
const value =
props.maxCount === 1 ? successFiles[0] || "" : successFiles;
emit("update:modelValue", value);
emit("change", value, newFileList);
} else {
// 返回完整文件列表
emit('update:modelValue', newFileList)
emit('change', newFileList)
emit("update:modelValue", newFileList);
emit("change", newFileList);
}
}
};
// 处理文件移除
const handleRemove = (file) => {
emit('remove', file)
return true
}
emit("remove", file);
return true;
};
</script>
<style scoped>
+170 -137
View File
@@ -8,7 +8,10 @@
:accept="accept"
:max-count="maxCount"
:disabled="disabled"
:show-upload-list="{ showPreviewIcon: true, showRemoveIcon: !disabled }"
:show-upload-list="{
showPreviewIcon: true,
showRemoveIcon: !disabled,
}"
@preview="handlePreview"
@change="handleChange"
@drop="handleDrop"
@@ -17,10 +20,15 @@
class="custom-upload"
:class="{ 'drag-over': isDragOver }"
>
<div v-if="fileList.length < maxCount && !disabled" class="upload-area">
<div
v-if="fileList.length < maxCount && !disabled"
class="upload-area"
>
<loading-outlined v-if="uploading" class="upload-icon" />
<plus-outlined v-else class="upload-icon" />
<div class="ant-upload-text">{{ uploading ? '上传中...' : uploadText }}</div>
<div class="ant-upload-text">
{{ uploading ? "上传中..." : uploadText }}
</div>
<div v-if="tip" class="ant-upload-tip">{{ tip }}</div>
</div>
</a-upload>
@@ -31,314 +39,339 @@
:width="800"
@cancel="handleCancel"
>
<img alt="图片预览" style="width: 100%; max-height: 600px; object-fit: contain;" :src="previewImage" />
<img
alt="图片预览"
style="width: 100%; max-height: 600px; object-fit: contain"
:src="previewImage"
/>
</a-modal>
</div>
</template>
<script setup>
import { ref, watch, computed } from 'vue'
import { message, Modal } from 'ant-design-vue'
import { PlusOutlined, LoadingOutlined } from '@ant-design/icons-vue'
import uploadConfig from '@/config/upload'
import { ref, watch, computed } from "vue";
import { message, Modal } from "ant-design-vue";
import { PlusOutlined, LoadingOutlined } from "@ant-design/icons-vue";
import uploadConfig from "@/config/upload";
const props = defineProps({
// 图片列表
modelValue: {
type: [Array, String],
default: () => []
default: () => [],
},
// 最大上传数量,默认1为单图上传
maxCount: {
type: Number,
default: 1
default: 1,
},
// 接受的文件类型
accept: {
type: String,
default: 'image/*'
default: "image/*",
},
// 是否禁用
disabled: {
type: Boolean,
default: false
default: false,
},
// 是否返回URL字符串(单图)或URL数组(多图)
returnUrl: {
type: Boolean,
default: true
default: true,
},
// 上传按钮文字
uploadText: {
type: String,
default: '上传图片'
default: "上传图片",
},
// 提示文字
tip: {
type: String,
default: ''
default: "",
},
// 最小宽度(像素)
minWidth: {
type: Number,
default: 0
default: 0,
},
// 最大宽度(像素)
maxWidth: {
type: Number,
default: 0
default: 0,
},
// 最小高度(像素)
minHeight: {
type: Number,
default: 0
default: 0,
},
// 最大高度(像素)
maxHeight: {
type: Number,
default: 0
default: 0,
},
// 是否删除前确认
confirmBeforeRemove: {
type: Boolean,
default: false
default: false,
},
// 自定义上传按钮内容
customUploadBtn: {
type: Function,
default: null
}
})
default: null,
},
});
const emit = defineEmits(['update:modelValue', 'change', 'preview', 'remove', 'uploadSuccess', 'uploadError'])
const emit = defineEmits([
"update:modelValue",
"change",
"preview",
"remove",
"uploadSuccess",
"uploadError",
]);
// 文件列表
const fileList = ref([])
const fileList = ref([]);
// 预览相关
const previewVisible = ref(false)
const previewImage = ref('')
const previewVisible = ref(false);
const previewImage = ref("");
const previewTitle = computed(() => {
return previewImage.value ? '图片预览' : ''
})
return previewImage.value ? "图片预览" : "";
});
// 上传状态
const uploading = ref(false)
const uploading = ref(false);
// 拖拽状态
const isDragOver = ref(false)
const isDragOver = ref(false);
// 初始化文件列表
const initFileList = () => {
if (props.modelValue) {
if (typeof props.modelValue === 'string') {
if (typeof props.modelValue === "string") {
// 单图上传,字符串格式
fileList.value = props.modelValue
? [
{
uid: '-1',
name: 'image.png',
status: 'done',
url: props.modelValue
}
]
: []
uid: "-1",
name: "image.png",
status: "done",
url: props.modelValue,
},
]
: [];
} else if (Array.isArray(props.modelValue)) {
// 多图上传,数组格式
fileList.value = props.modelValue.map((url, index) => ({
uid: `-${index}`,
name: `image${index}.png`,
status: 'done',
url: url
}))
status: "done",
url: url,
}));
}
} else {
fileList.value = []
fileList.value = [];
}
}
};
// 监听外部值变化
watch(
() => props.modelValue,
() => {
initFileList()
initFileList();
},
{ immediate: true }
)
{ immediate: true },
);
// 自定义上传
const customUpload = (options) => {
const { file, onProgress, onSuccess, onError } = options
const formData = new FormData()
formData.append(uploadConfig.filename || 'file', file)
const { file, onProgress, onSuccess, onError } = options;
const formData = new FormData();
formData.append(uploadConfig.filename || "file", file);
uploading.value = true
uploading.value = true;
uploadConfig.apiObj(formData, {
onUploadProgress: (progressEvent) => {
const percent = Math.round((progressEvent.loaded / progressEvent.total) * 100)
onProgress({ percent }, file)
}
})
uploadConfig
.apiObj(formData, {
onUploadProgress: (progressEvent) => {
const percent = Math.round(
(progressEvent.loaded / progressEvent.total) * 100,
);
onProgress({ percent }, file);
},
})
.then((res) => {
const data = uploadConfig.parseData(res)
const data = uploadConfig.parseData(res);
if (data.code === uploadConfig.successCode) {
onSuccess(data, file)
message.success('上传成功')
emit('uploadSuccess', data, file)
onSuccess(data, file);
message.success("上传成功");
emit("uploadSuccess", data, file);
} else {
onError(new Error(data.msg || '上传失败'))
message.error(data.msg || '上传失败')
emit('uploadError', data.msg || '上传失败', file)
onError(new Error(data.msg || "上传失败"));
message.error(data.msg || "上传失败");
emit("uploadError", data.msg || "上传失败", file);
}
})
.catch((error) => {
onError(error)
message.error('上传失败:' + error.message)
emit('uploadError', error.message, file)
onError(error);
message.error("上传失败:" + error.message);
emit("uploadError", error.message, file);
})
.finally(() => {
uploading.value = false
})
}
uploading.value = false;
});
};
// 上传前校验
const beforeUpload = async (file) => {
// 文件大小校验
const maxSizeMB = uploadConfig.maxSize || 10
const maxSizeBytes = maxSizeMB * 1024 * 1024
const maxSizeMB = uploadConfig.maxSize || 10;
const maxSizeBytes = maxSizeMB * 1024 * 1024;
if (file.size > maxSizeBytes) {
message.error(`图片大小不能超过 ${maxSizeMB}MB`)
return false
message.error(`图片大小不能超过 ${maxSizeMB}MB`);
return false;
}
// 图片尺寸校验
if (props.minWidth || props.maxWidth || props.minHeight || props.maxHeight) {
if (
props.minWidth ||
props.maxWidth ||
props.minHeight ||
props.maxHeight
) {
try {
const dimensions = await getImageDimensions(file)
const { width, height } = dimensions
const dimensions = await getImageDimensions(file);
const { width, height } = dimensions;
if (props.minWidth && width < props.minWidth) {
message.error(`图片宽度不能小于 ${props.minWidth}px`)
return false
message.error(`图片宽度不能小于 ${props.minWidth}px`);
return false;
}
if (props.maxWidth && width > props.maxWidth) {
message.error(`图片宽度不能大于 ${props.maxWidth}px`)
return false
message.error(`图片宽度不能大于 ${props.maxWidth}px`);
return false;
}
if (props.minHeight && height < props.minHeight) {
message.error(`图片高度不能小于 ${props.minHeight}px`)
return false
message.error(`图片高度不能小于 ${props.minHeight}px`);
return false;
}
if (props.maxHeight && height > props.maxHeight) {
message.error(`图片高度不能大于 ${props.maxHeight}px`)
return false
message.error(`图片高度不能大于 ${props.maxHeight}px`);
return false;
}
} catch (error) {
message.error('图片尺寸校验失败')
return false
message.error("图片尺寸校验失败");
return false;
}
}
return true
}
return true;
};
// 获取图片尺寸
const getImageDimensions = (file) => {
return new Promise((resolve, reject) => {
const img = new Image()
const reader = new FileReader()
const img = new Image();
const reader = new FileReader();
reader.onload = (e) => {
img.src = e.target.result
img.src = e.target.result;
img.onload = () => {
resolve({ width: img.width, height: img.height })
}
img.onerror = reject
}
reader.onerror = reject
reader.readAsDataURL(file)
})
}
resolve({ width: img.width, height: img.height });
};
img.onerror = reject;
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
};
// 处理预览
const handlePreview = async (file) => {
if (!file.url && !file.preview) {
file.preview = await getBase64(file.originFileObj)
file.preview = await getBase64(file.originFileObj);
}
previewImage.value = file.url || file.preview
previewVisible.value = true
emit('preview', file)
}
previewImage.value = file.url || file.preview;
previewVisible.value = true;
emit("preview", file);
};
// 获取Base64
const getBase64 = (file) => {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.readAsDataURL(file)
reader.onload = () => resolve(reader.result)
reader.onerror = (error) => reject(error)
})
}
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => resolve(reader.result);
reader.onerror = (error) => reject(error);
});
};
// 处理文件列表变化
const handleChange = ({ fileList: newFileList }) => {
// 更新文件列表,确保上传成功的文件有正确的 url
const updatedFileList = newFileList.map((file) => {
// 如果文件上传成功且有响应数据但没有 url,则设置 url
if (file.status === 'done' && file.response?.src && !file.url) {
if (file.status === "done" && file.response?.src && !file.url) {
return {
...file,
url: file.response.src
}
url: file.response.src,
};
}
return file
})
return file;
});
fileList.value = updatedFileList
fileList.value = updatedFileList;
// 过滤掉失败的文件
const validFileList = updatedFileList.filter((file) => file.status !== 'error')
const validFileList = updatedFileList.filter(
(file) => file.status !== "error",
);
// 提取成功的文件URL
const successFiles = validFileList
.filter((file) => file.status === 'done' && (file.url || file.response?.src))
.map((file) => file.url || file.response?.src)
.filter(
(file) =>
file.status === "done" && (file.url || file.response?.src),
)
.map((file) => file.url || file.response?.src);
// 触发更新事件
if (props.returnUrl) {
// 返回URL字符串或数组
const value = props.maxCount === 1 ? successFiles[0] || '' : successFiles
emit('update:modelValue', value)
emit('change', value, validFileList)
const value =
props.maxCount === 1 ? successFiles[0] || "" : successFiles;
emit("update:modelValue", value);
emit("change", value, validFileList);
} else {
// 返回完整文件列表
emit('update:modelValue', validFileList)
emit('change', validFileList)
emit("update:modelValue", validFileList);
emit("change", validFileList);
}
}
};
// 拖拽相关
const handleDragEnter = (e) => {
e.preventDefault()
isDragOver.value = true
}
e.preventDefault();
isDragOver.value = true;
};
const handleDragLeave = (e) => {
e.preventDefault()
isDragOver.value = false
}
e.preventDefault();
isDragOver.value = false;
};
const handleDrop = (e) => {
e.preventDefault()
isDragOver.value = false
}
e.preventDefault();
isDragOver.value = false;
};
// 取消预览
const handleCancel = () => {
previewVisible.value = false
}
previewVisible.value = false;
};
</script>
<style scoped>
+288 -284
View File
@@ -1,11 +1,11 @@
import { ref } from 'vue'
import { getWebSocket } from '@/utils/websocket'
import { useUserStore } from '@/stores/modules/user'
import { useMessageStore } from '@/stores/modules/message'
import { useDictionaryStore } from '@/stores/modules/dictionary'
import { useNotificationStore } from '@/stores/modules/notification'
import { message, notification } from 'ant-design-vue'
import config from '@/config'
import { ref } from "vue";
import { getWebSocket } from "@/utils/websocket";
import { useUserStore } from "@/stores/modules/user";
import { useMessageStore } from "@/stores/modules/message";
import { useDictionaryStore } from "@/stores/modules/dictionary";
import { useNotificationStore } from "@/stores/modules/notification";
import { message, notification } from "ant-design-vue";
import config from "@/config";
/**
* WebSocket Composable
@@ -13,327 +13,331 @@ import config from '@/config'
* 处理 WebSocket 连接和消息监听
*/
export function useWebSocket() {
const ws = ref(null)
const userStore = useUserStore()
const messageStore = useMessageStore()
const dictionaryStore = useDictionaryStore()
const notificationStore = useNotificationStore()
const reconnectTimer = ref(null)
const ws = ref(null);
const userStore = useUserStore();
const messageStore = useMessageStore();
const dictionaryStore = useDictionaryStore();
const notificationStore = useNotificationStore();
const reconnectTimer = ref(null);
const isInitialized = ref(false);
/**
* 初始化 WebSocket 连接
*/
function initWebSocket() {
// 检查用户信息是否完整
if (!userStore.isUserInfoComplete()) {
console.warn('用户信息不完整,等待用户信息加载...')
/**
* 初始化 WebSocket 连接
*/
function initWebSocket() {
// 如果已经初始化,不再重复初始化
if (isInitialized.value) {
console.log("WebSocket 已初始化,跳过重复初始化");
return;
}
// 延迟重试,等待用户信息加载
if (reconnectTimer.value) {
clearTimeout(reconnectTimer.value)
}
reconnectTimer.value = setTimeout(() => {
initWebSocket()
}, 1000)
return
}
// 检查用户信息是否完整
if (!userStore.isUserInfoComplete()) {
console.warn("用户信息不完整,等待用户信息加载...");
// 如果已经连接,不再重复连接
if (ws.value && ws.value.isConnected) {
console.log('WebSocket 已连接')
return
}
// 延迟重试,等待用户信息加载
if (reconnectTimer.value) {
clearTimeout(reconnectTimer.value);
}
reconnectTimer.value = setTimeout(() => {
initWebSocket();
}, 1000);
return;
}
try {
console.log('开始初始化 WebSocket...', {
userId: userStore.userInfo.id,
username: userStore.userInfo.username
})
try {
console.log("开始初始化 WebSocket...", {
userId: userStore.userInfo.id,
username: userStore.userInfo.username,
});
// 使用配置文件中的 WS_URL
ws.value = getWebSocket(userStore.userInfo.id, userStore.token, {
wsUrl: config.WS_URL,
onOpen: handleOpen,
onMessage: handleMessage,
onError: handleError,
onClose: handleClose
})
// 使用配置文件中的 WS_URL
// getWebSocket 会自动处理重复连接问题
ws.value = getWebSocket(userStore.userInfo.id, userStore.token, {
wsUrl: config.WS_URL,
onOpen: handleOpen,
onMessage: handleMessage,
onError: handleError,
onClose: handleClose,
});
// 注册消息处理器
registerMessageHandlers()
// 注册消息处理器
registerMessageHandlers();
// 连接
ws.value.connect()
} catch (error) {
console.error('初始化 WebSocket 失败:', error)
}
}
// 标记为已初始化
isInitialized.value = true;
/**
* 注册所有消息处理器
*/
function registerMessageHandlers() {
if (!ws.value) return
// 连接(如果还未连接)
if (ws.value && !ws.value.isConnected) {
ws.value.connect();
}
} catch (error) {
console.error("初始化 WebSocket 失败:", error);
isInitialized.value = false;
}
}
// 字典更新
ws.value.on('dictionary_update', handleDictionaryUpdate)
ws.value.on('dictionary_item_update', handleDictionaryItemUpdate)
/**
* 注册所有消息处理器
*/
function registerMessageHandlers() {
if (!ws.value) return;
// 系统通知
ws.value.on('notification', handleNotification)
// 字典更新
ws.value.on("dictionary_update", handleDictionaryUpdate);
ws.value.on("dictionary_item_update", handleDictionaryItemUpdate);
// 数据更新
ws.value.on('data_update', handleDataUpdate)
// 系统通知
ws.value.on("notification", handleNotification);
// 心跳响应
ws.value.on('heartbeat_response', handleHeartbeatResponse)
// 数据更新
ws.value.on("data_update", handleDataUpdate);
// 连接确认
ws.value.on('connected', handleConnected)
}
// 心跳响应
ws.value.on("heartbeat_response", handleHeartbeatResponse);
/**
* 取消注册所有消息处理器
*/
function unregisterMessageHandlers() {
if (!ws.value) return
// 连接确认
ws.value.on("connected", handleConnected);
}
ws.value.off('dictionary_update')
ws.value.off('dictionary_item_update')
ws.value.off('notification')
ws.value.off('data_update')
ws.value.off('heartbeat_response')
ws.value.off('connected')
}
/**
* 取消注册所有消息处理器
*/
function unregisterMessageHandlers() {
if (!ws.value) return;
/**
* 处理连接打开
*/
function handleOpen(event) {
console.log('WebSocket 连接已建立', event)
ws.value.off("dictionary_update");
ws.value.off("dictionary_item_update");
ws.value.off("notification");
ws.value.off("data_update");
ws.value.off("heartbeat_response");
ws.value.off("connected");
}
// 发送连接确认
if (ws.value) {
ws.value.send('auth', {
token: userStore.token,
user_id: userStore.userInfo.id
})
}
}
/**
* 处理连接打开
* 注意:认证已在连接时通过 URL 参数完成,无需单独发送 auth 事件
*/
function handleOpen(event) {
console.log("WebSocket 连接已建立(已通过 URL 参数完成认证)", event);
}
/**
* 处理连接确认
*/
function handleConnected(data) {
console.log('WebSocket 连接已确认', data)
message.success('实时连接已建立')
}
/**
* 处理连接确认
*/
function handleConnected(data) {
console.log("WebSocket 连接已确认", data);
message.success("实时连接已建立");
}
/**
* 处理接收消息
*/
function handleMessage(message, event) {
console.log('收到 WebSocket 消息:', message)
}
/**
* 处理接收消息
*/
function handleMessage(message, event) {
console.log("收到 WebSocket 消息:", message);
}
/**
* 处理错误
*/
function handleError(error) {
console.error('WebSocket 错误:', error)
message.error('实时连接出现错误,正在重连...')
}
/**
* 处理错误
*/
function handleError(error) {
console.error("WebSocket 错误:", error);
message.error("实时连接出现错误,正在重连...");
}
/**
* 处理连接关闭
*/
function handleClose(event) {
console.log('WebSocket 连接已关闭', event)
/**
* 处理连接关闭
*/
function handleClose(event) {
console.log("WebSocket 连接已关闭", event);
// 如果不是手动关闭,显示提示
if (event.code !== 1000) {
message.warning('实时连接已断开')
}
}
// 如果不是手动关闭,显示提示
if (event.code !== 1000) {
message.warning("实时连接已断开");
}
}
/**
* 处理心跳响应
*/
function handleHeartbeatResponse(data) {
console.log('收到心跳响应:', data)
}
/**
* 处理心跳响应
*/
function handleHeartbeatResponse(data) {
console.log("收到心跳响应:", data);
}
/**
* 处理系统通知
*/
function handleNotification(data) {
console.log('收到系统通知:', data)
/**
* 处理系统通知
*/
function handleNotification(data) {
console.log("收到系统通知:", data);
const { title, message: content, type, timestamp, ...rest } = data
const { title, message: content, type, timestamp, ...rest } = data;
// 构建通知数据格式
const notificationData = {
id: rest.id || Date.now(),
title: title || '系统通知',
content: content || '',
type: type || 'info',
category: rest.category || 'system',
is_read: false,
created_at: rest.created_at || new Date().toISOString(),
...rest
}
// 构建通知数据格式
const notificationData = {
id: rest.id || Date.now(),
title: title || "系统通知",
content: content || "",
type: type || "info",
category: rest.category || "system",
is_read: false,
created_at: rest.created_at || new Date().toISOString(),
...rest,
};
// 更新通知 store
notificationStore.handleWebSocketMessage({
type: 'notification',
data: notificationData
})
// 更新通知 store
notificationStore.handleWebSocketMessage({
type: "notification",
data: notificationData,
});
// 添加到消息 store(保持兼容性)
messageStore.addMessage({
type: type || 'notification',
title: title || '系统通知',
content: content || '',
timestamp: timestamp || Date.now()
})
// 添加到消息 store(保持兼容性)
messageStore.addMessage({
type: type || "notification",
title: title || "系统通知",
content: content || "",
timestamp: timestamp || Date.now(),
});
// 显示通知(根据类型)
const notificationConfig = {
message: title || '系统通知',
description: content,
duration: 4.5,
placement: 'topRight'
}
// 显示通知(根据类型)
const notificationConfig = {
message: title || "系统通知",
description: content,
duration: 4.5,
placement: "topRight",
};
// 根据类型设置不同样式
switch (type) {
case 'success':
notification.success(notificationConfig)
break
case 'error':
notification.error(notificationConfig)
break
case 'warning':
notification.warning(notificationConfig)
break
default:
notification.info(notificationConfig)
}
}
// 根据类型设置不同样式
switch (type) {
case "success":
notification.success(notificationConfig);
break;
case "error":
notification.error(notificationConfig);
break;
case "warning":
notification.warning(notificationConfig);
break;
default:
notification.info(notificationConfig);
}
}
/**
* 处理数据更新
*/
async function handleDataUpdate(data) {
console.log('收到数据更新:', data)
/**
* 处理数据更新
*/
async function handleDataUpdate(data) {
console.log("收到数据更新:", data);
const { resource_type, action, timestamp } = data
const { resource_type, action, timestamp } = data;
// 添加到消息 store
messageStore.handleDataUpdate(data)
// 添加到消息 store
messageStore.handleDataUpdate(data);
// 根据资源类型执行相应操作
switch (resource_type) {
case 'dictionary':
case 'dictionary_item':
// 刷新字典缓存
try {
await dictionaryStore.refresh(true)
} catch (error) {
console.error('刷新字典缓存失败:', error)
}
break
// 可以添加其他资源类型的处理
}
}
// 根据资源类型执行相应操作
switch (resource_type) {
case "dictionary":
case "dictionary_item":
// 刷新字典缓存
try {
await dictionaryStore.refresh(true);
} catch (error) {
console.error("刷新字典缓存失败:", error);
}
break;
// 可以添加其他资源类型的处理
}
}
/**
* 处理字典分类更新
*/
async function handleDictionaryUpdate(data) {
console.log('字典分类已更新:', data)
/**
* 处理字典分类更新
*/
async function handleDictionaryUpdate(data) {
console.log("字典分类已更新:", data);
try {
// 刷新字典缓存
await dictionaryStore.refresh(true)
try {
// 刷新字典缓存
await dictionaryStore.refresh(true);
// 显示通知
message.success('字典数据已更新')
} catch (error) {
console.error('刷新字典缓存失败:', error)
}
}
// 显示通知
message.success("字典数据已更新");
} catch (error) {
console.error("刷新字典缓存失败:", error);
}
}
/**
* 处理字典项更新
*/
async function handleDictionaryItemUpdate(data) {
console.log('字典项已更新:', data)
/**
* 处理字典项更新
*/
async function handleDictionaryItemUpdate(data) {
console.log("字典项已更新:", data);
try {
// 刷新字典缓存
await dictionaryStore.refresh(true)
try {
// 刷新字典缓存
await dictionaryStore.refresh(true);
// 显示通知
message.success('字典数据已更新')
} catch (error) {
console.error('刷新字典缓存失败:', error)
}
}
// 显示通知
message.success("字典数据已更新");
} catch (error) {
console.error("刷新字典缓存失败:", error);
}
}
/**
* 发送消息到服务器
*/
function send(type, data) {
if (ws.value && ws.value.isConnected) {
ws.value.send(type, data)
} else {
console.warn('WebSocket 未连接,无法发送消息')
}
}
/**
* 发送消息到服务器
*/
function send(type, data) {
if (ws.value && ws.value.isConnected) {
ws.value.send(type, data);
} else {
console.warn("WebSocket 未连接,无法发送消息");
}
}
/**
* 关闭 WebSocket 连接
*/
function closeWebSocket() {
// 清除重试定时器
if (reconnectTimer.value) {
clearTimeout(reconnectTimer.value)
reconnectTimer.value = null
}
/**
* 关闭 WebSocket 连接
*/
function closeWebSocket() {
// 清除重试定时器
if (reconnectTimer.value) {
clearTimeout(reconnectTimer.value);
reconnectTimer.value = null;
}
if (ws.value) {
// 取消注册消息处理器
unregisterMessageHandlers()
if (ws.value) {
// 取消注册消息处理器
unregisterMessageHandlers();
ws.value.disconnect()
ws.value = null
}
}
ws.value.disconnect();
ws.value = null;
}
/**
* 重新连接 WebSocket
*/
function reconnect() {
closeWebSocket()
setTimeout(() => {
initWebSocket()
}, 1000)
}
// 重置初始化标记
isInitialized.value = false;
}
/**
* 检查连接状态
*/
function isConnected() {
return ws.value && ws.value.isConnected
}
/**
* 重新连接 WebSocket
*/
function reconnect() {
closeWebSocket();
setTimeout(() => {
initWebSocket();
}, 1000);
}
return {
ws,
initWebSocket,
closeWebSocket,
reconnect,
isConnected,
send
}
/**
* 检查连接状态
*/
function isConnected() {
return ws.value && ws.value.isConnected;
}
return {
ws,
initWebSocket,
closeWebSocket,
reconnect,
isConnected,
send,
};
}
+20 -20
View File
@@ -1,28 +1,28 @@
// 默认配置
const defaultConfig = {
APP_NAME: 'vueadmin',
DASHBOARD_URL: '/dashboard',
APP_NAME: "vueadmin",
DASHBOARD_URL: "/dashboard",
// 白名单路由(不需要登录即可访问)
whiteList: ['/login', '/register', '/reset-password'],
whiteList: ["/login", "/register", "/reset-password"],
//版本号
APP_VER: '1.6.6',
APP_VER: "1.6.6",
//内核版本号
CORE_VER: '1.6.6',
CORE_VER: "1.6.6",
//接口地址
API_URL: 'http://127.0.0.1:8000/admin/',
WS_URL: '127.0.0.1:8000',
API_URL: "http://127.0.0.1:8000/admin/",
WS_URL: "127.0.0.1:8000",
//请求超时
TIMEOUT: 50000,
//TokenName
TOKEN_NAME: 'authorization',
TOKEN_NAME: "authorization",
//Token前缀,注意最后有个空格,如不需要需设置空字符串
TOKEN_PREFIX: 'Bearer ',
TOKEN_PREFIX: "Bearer ",
//追加其他头
HEADERS: {},
@@ -30,9 +30,9 @@ const defaultConfig = {
//请求是否开启缓存
REQUEST_CACHE: false,
//语言
LANG: 'zh-cn',
LANG: "zh-cn",
DASHBOARD_LAYOUT: 'widgets', //控制台首页默认布局
DASHBOARD_LAYOUT: "widgets", //控制台首页默认布局
DEFAULT_GRID: {
//默认分栏数量和宽度 例如 [24] [18,6] [8,8,8] [6,12,6]
layout: [24, 12, 12],
@@ -42,27 +42,27 @@ const defaultConfig = {
//是否加密localStorage, 为空不加密
//支持多种加密方式: 'AES', 'BASE64', 'DES'
LS_ENCRYPTION: '',
LS_ENCRYPTION: "",
//localStorage加密秘钥,位数建议填写8的倍数
LS_ENCRYPTION_key: '2XNN4K8LC0ELVWN4',
LS_ENCRYPTION_key: "2XNN4K8LC0ELVWN4",
//localStorage加密模式,AES支持: 'ECB', 'CBC', 'CTR', 'OFB', 'CFB'
LS_ENCRYPTION_mode: 'ECB',
LS_ENCRYPTION_mode: "ECB",
//localStorage加密填充方式,AES支持: 'Pkcs7', 'ZeroPadding', 'Iso10126', 'Iso97971'
LS_ENCRYPTION_padding: 'Pkcs7',
LS_ENCRYPTION_padding: "Pkcs7",
//localStorage默认过期时间(单位:小时),0表示永不过期
LS_DEFAULT_EXPIRE: 720, // 30天
//DES加密秘钥,必须是8字节
LS_DES_key: '12345678',
}
LS_DES_key: "12345678",
};
// 合并 public/config.js 中的覆盖配置
if (typeof window !== 'undefined' && window.SY_CONFIG) {
Object.assign(defaultConfig, window.SY_CONFIG)
if (typeof window !== "undefined" && window.SY_CONFIG) {
Object.assign(defaultConfig, window.SY_CONFIG);
}
export default defaultConfig
export default defaultConfig;
+2 -2
View File
@@ -2,6 +2,6 @@
* 静态路由配置
* 这些路由会根据用户角色进行过滤后添加到路由中
*/
const userRoutes = []
const userRoutes = [];
export default userRoutes
export default userRoutes;
+12 -12
View File
@@ -3,18 +3,18 @@ import systemApi from "@/api/system";
//上传配置
export default {
apiObj: systemApi.upload.post, //上传请求API对象
filename: "file", //form请求时文件的key
successCode: 1, //请求完成代码
maxSize: 10, //最大文件大小 默认10MB
apiObj: systemApi.upload.post, //上传请求API对象
filename: "file", //form请求时文件的key
successCode: 1, //请求完成代码
maxSize: 10, //最大文件大小 默认10MB
parseData: function (res) {
return {
code: res.code, //分析状态字段结构
fileName: res.data.name,//分析文件名称
src: res.data.url, //分析图片远程地址结构
msg: res.message //分析描述字段结构
}
code: res.code, //分析状态字段结构
fileName: res.data.name, //分析文件名称
src: res.data.url, //分析图片远程地址结构
msg: res.message, //分析描述字段结构
};
},
apiObjFile: systemApi.upload.post, //附件上传请求API对象
maxSizeFile: 10 //最大文件大小 默认10MB
}
apiObjFile: systemApi.upload.post, //附件上传请求API对象
maxSizeFile: 10, //最大文件大小 默认10MB
};
+6 -6
View File
@@ -1,9 +1,9 @@
import { useI18n as useVueI18n } from 'vue-i18n'
import { useI18nStore } from '@/stores/modules/i18n'
import { useI18n as useVueI18n } from "vue-i18n";
import { useI18nStore } from "@/stores/modules/i18n";
export function useI18n() {
const { t, locale, availableLocales } = useVueI18n()
const i18nStore = useI18nStore()
const { t, locale, availableLocales } = useVueI18n();
const i18nStore = useI18nStore();
return {
t,
@@ -11,6 +11,6 @@ export function useI18n() {
availableLocales,
setLocale: i18nStore.setLocale,
currentLocale: i18nStore.currentLocale,
localeLabel: i18nStore.localeLabel
}
localeLabel: i18nStore.localeLabel,
};
}
+95 -84
View File
@@ -1,5 +1,5 @@
import { ref, reactive, computed, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import { ref, reactive, computed, onMounted } from "vue";
import { message } from "ant-design-vue";
/**
* 表格通用hooks
@@ -19,30 +19,32 @@ export function useTable(options = {}) {
api,
searchForm: initialSearchForm = {},
columns = [],
rowKey = 'id',
rowKey = "id",
needPagination = true,
paginationConfig = {},
needSelection = false,
immediateLoad = true
} = options
immediateLoad = true,
} = options;
// 表格引用
const tableRef = ref(null)
const tableRef = ref(null);
// 搜索表单
const searchForm = reactive({ ...initialSearchForm })
const searchForm = reactive({ ...initialSearchForm });
// 表格数据
const tableData = ref([])
const tableData = ref([]);
// 加载状态
const loading = ref(false)
const loading = ref(false);
// 选中的行数据
const selectedRows = ref([])
const selectedRows = ref([]);
// 选中的行keys
const selectedRowKeys = computed(() => selectedRows.value.map(item => item[rowKey]))
const selectedRowKeys = computed(() =>
selectedRows.value.map((item) => item[rowKey]),
);
// 分页配置
const defaultPaginationConfig = {
@@ -51,171 +53,180 @@ export function useTable(options = {}) {
total: 0,
showSizeChanger: true,
showTotal: (total) => `${total}`,
pageSizeOptions: ['20', '50', '100', '200']
}
pageSizeOptions: ["20", "50", "100", "200"],
};
const pagination = reactive({
...defaultPaginationConfig,
...paginationConfig
})
...paginationConfig,
});
// 行选择配置
const rowSelection = computed(() => {
if (!needSelection) return null
if (!needSelection) return null;
return {
selectedRowKeys: selectedRowKeys.value,
onChange: (keys, rows) => {
selectedRows.value = rows
}
}
})
selectedRows.value = rows;
},
};
});
// 行选择事件处理(用于scTable的@select事件)
const handleSelectChange = (record, selected, selectedRows) => {
if (!needSelection) return
if (!needSelection) return;
if (selected) {
selectedRows.value.push(record)
selectedRows.value.push(record);
} else {
const index = selectedRows.value.findIndex(item => item[rowKey] === record[rowKey])
const index = selectedRows.value.findIndex(
(item) => item[rowKey] === record[rowKey],
);
if (index > -1) {
selectedRows.value.splice(index, 1)
selectedRows.value.splice(index, 1);
}
}
}
};
// 全选/取消全选处理(用于scTable的@selectAll事件)
const handleSelectAll = (selected, selectedRows, changeRows) => {
if (!needSelection) return
if (!needSelection) return;
if (selected) {
changeRows.forEach(record => {
if (!selectedRows.value.find(item => item[rowKey] === record[rowKey])) {
selectedRows.value.push(record)
changeRows.forEach((record) => {
if (
!selectedRows.value.find(
(item) => item[rowKey] === record[rowKey],
)
) {
selectedRows.value.push(record);
}
})
});
} else {
changeRows.forEach(record => {
const index = selectedRows.value.findIndex(item => item[rowKey] === record[rowKey])
changeRows.forEach((record) => {
const index = selectedRows.value.findIndex(
(item) => item[rowKey] === record[rowKey],
);
if (index > -1) {
selectedRows.value.splice(index, 1)
selectedRows.value.splice(index, 1);
}
})
});
}
}
};
// 加载数据
const loadData = async (params = {}) => {
if (!api) {
console.warn('useTable: 未提供api函数,无法加载数据')
return
console.warn("useTable: 未提供api函数,无法加载数据");
return;
}
loading.value = true
loading.value = true;
try {
const requestParams = {
...searchForm,
...params
}
...params,
};
// 如果需要分页,添加分页参数
if (needPagination) {
requestParams.page = pagination.current
requestParams.limit = pagination.pageSize
requestParams.page = pagination.current;
requestParams.limit = pagination.pageSize;
}
// 调用API函数,确保this上下文正确
const res = await api(requestParams)
const res = await api(requestParams);
if (res.code === 200) {
// 如果是分页数据
if (needPagination) {
tableData.value = res.data?.list || []
pagination.total = res.data?.total || 0
tableData.value = res.data?.list || [];
pagination.total = res.data?.total || 0;
} else {
// 非分页数据(如树形数据)
// 确保数据是数组,如果不是数组则包装成数组
const data = res.data
const data = res.data;
if (Array.isArray(data)) {
tableData.value = data
} else if (data && typeof data === 'object') {
tableData.value = data;
} else if (data && typeof data === "object") {
// 如果返回的是对象,可能包含 list 或 items 等字段
tableData.value = data.list || data.items || data.data || []
tableData.value =
data.list || data.items || data.data || [];
} else {
tableData.value = []
tableData.value = [];
}
}
} else {
message.error(res.message || '加载数据失败')
message.error(res.message || "加载数据失败");
}
} catch (error) {
console.error('加载数据失败:', error)
message.error('加载数据失败')
console.error("加载数据失败:", error);
message.error("加载数据失败");
} finally {
loading.value = false
loading.value = false;
}
}
};
// 分页变化处理
const handlePaginationChange = ({ page, pageSize }) => {
if (!needPagination) return
pagination.current = page
pagination.pageSize = pageSize
loadData()
}
if (!needPagination) return;
pagination.current = page;
pagination.pageSize = pageSize;
loadData();
};
// 搜索
const handleSearch = () => {
if (needPagination) {
pagination.current = 1
pagination.current = 1;
}
loadData()
}
loadData();
};
// 重置
const handleReset = () => {
// 重置搜索表单为初始值
Object.keys(searchForm).forEach(key => {
searchForm[key] = initialSearchForm[key]
})
Object.keys(searchForm).forEach((key) => {
searchForm[key] = initialSearchForm[key];
});
// 清空选择
selectedRows.value = []
selectedRows.value = [];
// 重置分页
if (needPagination) {
pagination.current = 1
pagination.current = 1;
}
// 重新加载数据
loadData()
}
loadData();
};
// 刷新表格
const refreshTable = () => {
loadData()
}
loadData();
};
// 清空选择
const clearSelection = () => {
selectedRows.value = []
}
selectedRows.value = [];
};
// 设置选中行
const setSelectedRows = (rows) => {
selectedRows.value = rows
}
selectedRows.value = rows;
};
// 更新搜索表单
const setSearchForm = (data) => {
Object.assign(searchForm, data)
}
Object.assign(searchForm, data);
};
// 直接设置表格数据(用于特殊场景)
const setTableData = (data) => {
tableData.value = data
}
tableData.value = data;
};
// 组件挂载时自动加载数据
if (immediateLoad) {
onMounted(() => {
loadData()
})
loadData();
});
}
return {
@@ -243,6 +254,6 @@ export function useTable(options = {}) {
clearSelection,
setSelectedRows,
setSearchForm,
setTableData
}
setTableData,
};
}
+10 -10
View File
@@ -1,15 +1,15 @@
import { createI18n } from 'vue-i18n'
import zh from './locales/zh-CN'
import en from './locales/en-US'
import { createI18n } from "vue-i18n";
import zh from "./locales/zh-CN";
import en from "./locales/en-US";
const i18n = createI18n({
legacy: false,
locale: 'zh-CN',
fallbackLocale: 'en-US',
locale: "zh-CN",
fallbackLocale: "en-US",
messages: {
'zh-CN': zh,
'en-US': en
}
})
"zh-CN": zh,
"en-US": en,
},
});
export default i18n
export default i18n;
+244 -243
View File
@@ -1,262 +1,263 @@
export default {
common: {
welcome: 'Welcome',
login: 'Login',
logout: 'Logout',
register: 'Register',
searchMenu: 'Search Menu',
searchPlaceholder: 'Please enter menu name to search',
noResults: 'No matching menus found',
searchTips: 'Keyboard Shortcuts Tips',
navigateResults: 'Use up/down arrows to navigate',
selectResult: 'Press Enter to select',
closeSearch: 'Press ESC to close',
taskCenter: 'Task Center',
totalTasks: 'Total Tasks',
pendingTasks: 'Pending',
completedTasks: 'Completed',
searchTasks: 'Search tasks...',
all: 'All',
pending: 'Pending',
completed: 'Completed',
taskTitle: 'Task Title',
enterTaskTitle: 'Please enter task title',
taskPriority: 'Task Priority',
priorityHigh: 'High',
priorityMedium: 'Medium',
priorityLow: 'Low',
confirmDelete: 'Confirm Delete',
addTask: 'Add Task',
pleaseEnterTaskTitle: 'Please enter task title',
added: 'Added',
deleted: 'Deleted',
justNow: 'Just now',
clearCache: 'Clear Cache',
confirmClearCache: 'Confirm Clear Cache',
clearCacheConfirm: 'Are you sure you want to clear all cache? This will clear local storage, session storage and cached data.',
cacheCleared: 'Cache cleared',
clearCacheFailed: 'Failed to clear cache',
messages: 'Messages',
tasks: 'Tasks',
notification: 'Notification',
task: 'Task',
warning: 'Warning',
markAllAsRead: 'Mark All as Read',
clearAll: 'Clear All',
noMessages: 'No Messages',
noTasks: 'No Tasks',
confirmClear: 'Confirm Clear',
confirmClearMessages: 'Are you sure you want to clear all messages?',
markedAsRead: 'Marked as Read',
realtimeConnected: 'Real-time connection established',
realtimeDisconnected: 'Real-time connection disconnected',
realtimeError: 'Real-time connection error, reconnecting...',
dataUpdated: 'Data Updated',
dataCreated: 'Data Created',
dataDeleted: 'Data Deleted',
fullscreen: 'Fullscreen',
personalCenter: 'Personal Center',
systemSettings: 'System Settings',
searchEmpty: 'Please enter search content',
searching: 'Searching: ',
cleared: 'Cleared',
languageChanged: 'Language Changed',
settingsDeveloping: 'System settings feature is under development',
logoutSuccess: 'Logout Successful',
logoutFailed: 'Logout Failed',
confirmLogout: 'Confirm Logout',
logoutConfirm: 'Are you sure you want to logout?',
username: 'Username',
password: 'Password',
confirmPassword: 'Confirm Password',
email: 'Email',
phone: 'Phone',
rememberMe: 'Remember Me',
forgotPassword: 'Forgot Password?',
submit: 'Submit',
cancel: 'Cancel',
save: 'Save',
edit: 'Edit',
delete: 'Delete',
add: 'Add',
search: 'Search',
reset: 'Reset',
confirm: 'Confirm',
back: 'Back',
next: 'Next',
previous: 'Previous',
refresh: 'Refresh',
export: 'Export',
import: 'Import',
download: 'Download',
upload: 'Upload',
view: 'View',
detail: 'Detail',
settings: 'Settings',
profile: 'Profile',
language: 'Language',
theme: 'Theme',
dark: 'Dark',
light: 'Light',
loading: 'Loading...',
noData: 'No Data',
success: 'Operation Successful',
error: 'Operation Failed',
warning: 'Warning',
info: 'Info',
confirmDelete: 'Are you sure you want to delete?',
confirmLogout: 'Are you sure you want to logout?',
addConfig: 'Add Config',
editConfig: 'Edit Config',
configCategory: 'Config Category',
configName: 'Config Name',
configTitle: 'Config Title',
configType: 'Config Type',
configValue: 'Config Value',
configTip: 'Config Tip',
typeText: 'Text',
typeTextarea: 'Textarea',
typeNumber: 'Number',
typeSwitch: 'Switch',
typeSelect: 'Select',
typeMultiselect: 'Multiselect',
typeDatetime: 'Datetime',
typeColor: 'Color',
pleaseSelect: 'Please Select',
pleaseEnter: 'Please Enter',
noConfig: 'No Config',
fetchConfigFailed: 'Failed to fetch config',
addSuccess: 'Added Successfully',
addFailed: 'Failed to Add',
editSuccess: 'Edited Successfully',
editFailed: 'Failed to Edit',
saveSuccess: 'Saved Successfully',
saveFailed: 'Failed to Save',
resetSuccess: 'Reset Successfully',
required: 'This field is required',
operation: 'Operation',
time: 'Time',
status: 'Status',
enabled: 'Enabled',
disabled: 'Disabled',
yes: 'Yes',
no: 'No',
areaManage: 'Area Management',
areaName: 'Area Name',
areaCode: 'Area Code',
areaLevel: 'Area Level',
parentArea: 'Parent Area',
province: 'Province',
city: 'City',
district: 'District',
street: 'Street',
unknown: 'Unknown',
addArea: 'Add Area',
editArea: 'Edit Area',
remark: 'Remark',
sort: 'Sort',
createTime: 'Create Time',
action: 'Action',
batchDelete: 'Batch Delete',
confirmBatchDelete: 'Confirm Batch Delete',
batchDeleteConfirm: 'Are you sure you want to delete the selected',
items: 'items?',
deleteConfirm: 'Are you sure you want to delete',
selectDataFirst: 'Please select data to operate first',
pleaseEnterNumber: 'Please enter a valid number',
exitFullScreen: 'Exit Fullscreen',
columns: 'Columns',
columnSettings: 'Column Settings',
selectAll: 'Select All',
unselectAll: 'Unselect All',
retry: 'Retry',
fetchDataFailed: 'Failed to fetch data'
welcome: "Welcome",
login: "Login",
logout: "Logout",
register: "Register",
searchMenu: "Search Menu",
searchPlaceholder: "Please enter menu name to search",
noResults: "No matching menus found",
searchTips: "Keyboard Shortcuts Tips",
navigateResults: "Use up/down arrows to navigate",
selectResult: "Press Enter to select",
closeSearch: "Press ESC to close",
taskCenter: "Task Center",
totalTasks: "Total Tasks",
pendingTasks: "Pending",
completedTasks: "Completed",
searchTasks: "Search tasks...",
all: "All",
pending: "Pending",
completed: "Completed",
taskTitle: "Task Title",
enterTaskTitle: "Please enter task title",
taskPriority: "Task Priority",
priorityHigh: "High",
priorityMedium: "Medium",
priorityLow: "Low",
confirmDelete: "Confirm Delete",
addTask: "Add Task",
pleaseEnterTaskTitle: "Please enter task title",
added: "Added",
deleted: "Deleted",
justNow: "Just now",
clearCache: "Clear Cache",
confirmClearCache: "Confirm Clear Cache",
clearCacheConfirm:
"Are you sure you want to clear all cache? This will clear local storage, session storage and cached data.",
cacheCleared: "Cache cleared",
clearCacheFailed: "Failed to clear cache",
messages: "Messages",
tasks: "Tasks",
notification: "Notification",
task: "Task",
warning: "Warning",
markAllAsRead: "Mark All as Read",
clearAll: "Clear All",
noMessages: "No Messages",
noTasks: "No Tasks",
confirmClear: "Confirm Clear",
confirmClearMessages: "Are you sure you want to clear all messages?",
markedAsRead: "Marked as Read",
realtimeConnected: "Real-time connection established",
realtimeDisconnected: "Real-time connection disconnected",
realtimeError: "Real-time connection error, reconnecting...",
dataUpdated: "Data Updated",
dataCreated: "Data Created",
dataDeleted: "Data Deleted",
fullscreen: "Fullscreen",
personalCenter: "Personal Center",
systemSettings: "System Settings",
searchEmpty: "Please enter search content",
searching: "Searching: ",
cleared: "Cleared",
languageChanged: "Language Changed",
settingsDeveloping: "System settings feature is under development",
logoutSuccess: "Logout Successful",
logoutFailed: "Logout Failed",
confirmLogout: "Confirm Logout",
logoutConfirm: "Are you sure you want to logout?",
username: "Username",
password: "Password",
confirmPassword: "Confirm Password",
email: "Email",
phone: "Phone",
rememberMe: "Remember Me",
forgotPassword: "Forgot Password?",
submit: "Submit",
cancel: "Cancel",
save: "Save",
edit: "Edit",
delete: "Delete",
add: "Add",
search: "Search",
reset: "Reset",
confirm: "Confirm",
back: "Back",
next: "Next",
previous: "Previous",
refresh: "Refresh",
export: "Export",
import: "Import",
download: "Download",
upload: "Upload",
view: "View",
detail: "Detail",
settings: "Settings",
profile: "Profile",
language: "Language",
theme: "Theme",
dark: "Dark",
light: "Light",
loading: "Loading...",
noData: "No Data",
success: "Operation Successful",
error: "Operation Failed",
warning: "Warning",
info: "Info",
confirmDelete: "Are you sure you want to delete?",
confirmLogout: "Are you sure you want to logout?",
addConfig: "Add Config",
editConfig: "Edit Config",
configCategory: "Config Category",
configName: "Config Name",
configTitle: "Config Title",
configType: "Config Type",
configValue: "Config Value",
configTip: "Config Tip",
typeText: "Text",
typeTextarea: "Textarea",
typeNumber: "Number",
typeSwitch: "Switch",
typeSelect: "Select",
typeMultiselect: "Multiselect",
typeDatetime: "Datetime",
typeColor: "Color",
pleaseSelect: "Please Select",
pleaseEnter: "Please Enter",
noConfig: "No Config",
fetchConfigFailed: "Failed to fetch config",
addSuccess: "Added Successfully",
addFailed: "Failed to Add",
editSuccess: "Edited Successfully",
editFailed: "Failed to Edit",
saveSuccess: "Saved Successfully",
saveFailed: "Failed to Save",
resetSuccess: "Reset Successfully",
required: "This field is required",
operation: "Operation",
time: "Time",
status: "Status",
enabled: "Enabled",
disabled: "Disabled",
yes: "Yes",
no: "No",
areaManage: "Area Management",
areaName: "Area Name",
areaCode: "Area Code",
areaLevel: "Area Level",
parentArea: "Parent Area",
province: "Province",
city: "City",
district: "District",
street: "Street",
unknown: "Unknown",
addArea: "Add Area",
editArea: "Edit Area",
remark: "Remark",
sort: "Sort",
createTime: "Create Time",
action: "Action",
batchDelete: "Batch Delete",
confirmBatchDelete: "Confirm Batch Delete",
batchDeleteConfirm: "Are you sure you want to delete the selected",
items: "items?",
deleteConfirm: "Are you sure you want to delete",
selectDataFirst: "Please select data to operate first",
pleaseEnterNumber: "Please enter a valid number",
exitFullScreen: "Exit Fullscreen",
columns: "Columns",
columnSettings: "Column Settings",
selectAll: "Select All",
unselectAll: "Unselect All",
retry: "Retry",
fetchDataFailed: "Failed to fetch data",
},
menu: {
dashboard: 'Dashboard',
userManagement: 'User Management',
roleManagement: 'Role Management',
permissionManagement: 'Permission Management',
systemSettings: 'System Settings',
logManagement: 'Log Management'
dashboard: "Dashboard",
userManagement: "User Management",
roleManagement: "Role Management",
permissionManagement: "Permission Management",
systemSettings: "System Settings",
logManagement: "Log Management",
},
login: {
title: 'User Login',
subtitle: 'Welcome back, please login to your account',
loginButton: 'Login',
loginSuccess: 'Login Successful',
loginFailed: 'Login Failed',
usernamePlaceholder: 'Please enter username',
passwordPlaceholder: 'Please enter password',
title: "User Login",
subtitle: "Welcome back, please login to your account",
loginButton: "Login",
loginSuccess: "Login Successful",
loginFailed: "Login Failed",
usernamePlaceholder: "Please enter username",
passwordPlaceholder: "Please enter password",
noAccount: "Don't have an account?",
registerNow: 'Register Now',
forgotPassword: 'Forgot Password?',
rememberMe: 'Remember Me'
registerNow: "Register Now",
forgotPassword: "Forgot Password?",
rememberMe: "Remember Me",
},
register: {
title: 'User Registration',
subtitle: 'Create your account and get started',
registerButton: 'Register',
registerSuccess: 'Registration Successful',
registerFailed: 'Registration Failed',
usernamePlaceholder: 'Please enter username',
emailPlaceholder: 'Please enter email address',
passwordPlaceholder: 'Please enter password',
confirmPasswordPlaceholder: 'Please enter password again',
usernameRule: 'Username length between 3 to 20 characters',
emailRule: 'Please enter a valid email address',
passwordRule: 'Password length between 6 to 20 characters',
agreeRule: 'Please agree to the user agreement',
agreeTerms: 'I have read and agree to the',
terms: 'User Agreement',
hasAccount: 'Already have an account?',
loginNow: 'Login Now'
title: "User Registration",
subtitle: "Create your account and get started",
registerButton: "Register",
registerSuccess: "Registration Successful",
registerFailed: "Registration Failed",
usernamePlaceholder: "Please enter username",
emailPlaceholder: "Please enter email address",
passwordPlaceholder: "Please enter password",
confirmPasswordPlaceholder: "Please enter password again",
usernameRule: "Username length between 3 to 20 characters",
emailRule: "Please enter a valid email address",
passwordRule: "Password length between 6 to 20 characters",
agreeRule: "Please agree to the user agreement",
agreeTerms: "I have read and agree to the",
terms: "User Agreement",
hasAccount: "Already have an account?",
loginNow: "Login Now",
},
resetPassword: {
title: 'Reset Password',
subtitle: 'Reset your password via email verification code',
resetButton: 'Reset Password',
resetSuccess: 'Password reset successful',
resetFailed: 'Reset failed',
emailPlaceholder: 'Please enter email address',
codePlaceholder: 'Please enter verification code',
newPasswordPlaceholder: 'Please enter new password',
confirmPasswordPlaceholder: 'Please enter new password again',
emailRule: 'Please enter a valid email address',
codeRule: 'Verification code must be 6 characters',
passwordRule: 'Password length between 6 to 20 characters',
sendCode: 'Send Code',
codeSent: 'Verification code has been sent to your email',
resendCode: 'Resend in {seconds} seconds',
sendCodeFirst: 'Please enter email address first',
backToLogin: 'Back to Login'
title: "Reset Password",
subtitle: "Reset your password via email verification code",
resetButton: "Reset Password",
resetSuccess: "Password reset successful",
resetFailed: "Reset failed",
emailPlaceholder: "Please enter email address",
codePlaceholder: "Please enter verification code",
newPasswordPlaceholder: "Please enter new password",
confirmPasswordPlaceholder: "Please enter new password again",
emailRule: "Please enter a valid email address",
codeRule: "Verification code must be 6 characters",
passwordRule: "Password length between 6 to 20 characters",
sendCode: "Send Code",
codeSent: "Verification code has been sent to your email",
resendCode: "Resend in {seconds} seconds",
sendCodeFirst: "Please enter email address first",
backToLogin: "Back to Login",
},
layout: {
toggleSidebar: 'Toggle Sidebar',
collapse: 'Collapse',
expand: 'Expand',
logout: 'Logout'
toggleSidebar: "Toggle Sidebar",
collapse: "Collapse",
expand: "Expand",
logout: "Logout",
},
table: {
total: 'Total {total} items',
selected: '{selected} items selected',
actions: 'Actions',
noData: 'No Data',
sort: 'Sort',
filter: 'Filter'
total: "Total {total} items",
selected: "{selected} items selected",
actions: "Actions",
noData: "No Data",
sort: "Sort",
filter: "Filter",
},
pagination: {
goTo: 'Go to',
page: 'Page',
total: 'Total {total} items',
itemsPerPage: '{size} items per page'
goTo: "Go to",
page: "Page",
total: "Total {total} items",
itemsPerPage: "{size} items per page",
},
form: {
required: 'This field is required',
invalidEmail: 'Please enter a valid email address',
invalidPhone: 'Please enter a valid phone number',
passwordMismatch: 'Passwords do not match',
minLength: 'Minimum {min} characters required',
maxLength: 'Maximum {max} characters allowed'
}
}
required: "This field is required",
invalidEmail: "Please enter a valid email address",
invalidPhone: "Please enter a valid phone number",
passwordMismatch: "Passwords do not match",
minLength: "Minimum {min} characters required",
maxLength: "Maximum {max} characters allowed",
},
};
+244 -243
View File
@@ -1,261 +1,262 @@
export default {
common: {
welcome: '欢迎使用',
login: '登录',
logout: '退出登录',
register: '注册',
searchMenu: '搜索菜单',
searchPlaceholder: '请输入菜单名称进行搜索',
noResults: '未找到匹配的菜单',
searchTips: '快捷键操作提示',
navigateResults: '使用上下键导航',
selectResult: '按回车键选择',
closeSearch: '按 ESC 关闭',
taskCenter: '任务中心',
totalTasks: '总任务',
pendingTasks: '待完成',
completedTasks: '已完成',
searchTasks: '搜索任务...',
all: '全部',
pending: '待完成',
completed: '已完成',
taskTitle: '任务标题',
enterTaskTitle: '请输入任务标题',
taskPriority: '任务优先级',
priorityHigh: '高',
priorityMedium: '中',
priorityLow: '低',
confirmDelete: '确认删除',
addTask: '添加任务',
pleaseEnterTaskTitle: '请输入任务标题',
added: '已添加',
deleted: '已删除',
justNow: '刚刚',
clearCache: '清除缓存',
confirmClearCache: '确认清除缓存',
clearCacheConfirm: '确定要清除所有缓存吗?这将清除本地存储、会话存储和缓存数据。',
cacheCleared: '缓存已清除',
clearCacheFailed: '清除缓存失败',
messages: '消息',
tasks: '任务',
notification: '通知',
task: '任务',
warning: '警告',
markAllAsRead: '全部标为已读',
clearAll: '清空全部',
noMessages: '暂无消息',
noTasks: '暂无任务',
confirmClear: '确认清空',
confirmClearMessages: '确定要清空所有消息吗?',
markedAsRead: '已标记为已读',
realtimeConnected: '实时连接已建立',
realtimeDisconnected: '实时连接已断开',
realtimeError: '实时连接出现错误,正在重连...',
dataUpdated: '数据已更新',
dataCreated: '数据已创建',
dataDeleted: '数据已删除',
fullscreen: '全屏',
personalCenter: '个人中心',
systemSettings: '系统设置',
searchEmpty: '请输入搜索内容',
searching: '正在搜索:',
cleared: '已清空',
languageChanged: '语言已切换',
settingsDeveloping: '系统设置功能开发中',
logoutSuccess: '退出成功',
logoutFailed: '退出失败',
confirmLogout: '确认退出',
logoutConfirm: '确定要退出登录吗?',
username: '用户名',
password: '密码',
confirmPassword: '确认密码',
email: '邮箱',
phone: '手机号',
rememberMe: '记住我',
forgotPassword: '忘记密码?',
submit: '提交',
cancel: '取消',
save: '保存',
edit: '编辑',
delete: '删除',
add: '添加',
search: '搜索',
reset: '重置',
confirm: '确认',
back: '返回',
next: '下一步',
previous: '上一步',
refresh: '刷新',
export: '导出',
import: '导入',
download: '下载',
upload: '上传',
view: '查看',
detail: '详情',
settings: '设置',
profile: '个人资料',
language: '语言',
theme: '主题',
dark: '暗色',
light: '亮色',
loading: '加载中...',
noData: '暂无数据',
success: '操作成功',
error: '操作失败',
warning: '警告',
info: '提示',
confirmDelete: '确定要删除吗?',
confirmLogout: '确定要退出登录吗?',
addConfig: '添加配置',
editConfig: '编辑配置',
configCategory: '配置分类',
configName: '配置名称',
configTitle: '配置标题',
configType: '配置类型',
configValue: '配置值',
configTip: '配置提示',
typeText: '文本',
typeTextarea: '文本域',
typeNumber: '数字',
typeSwitch: '开关',
typeSelect: '下拉选择',
typeMultiselect: '多选',
typeDatetime: '日期时间',
typeColor: '颜色',
pleaseSelect: '请选择',
pleaseEnter: '请输入',
noConfig: '暂无配置',
fetchConfigFailed: '获取配置失败',
addSuccess: '添加成功',
addFailed: '添加失败',
editSuccess: '编辑成功',
editFailed: '编辑失败',
saveSuccess: '保存成功',
saveFailed: '保存失败',
resetSuccess: '重置成功',
required: '此项为必填项',
operation: '操作',
time: '时间',
status: '状态',
enabled: '启用',
disabled: '禁用',
yes: '是',
no: '否',
areaManage: '地区管理',
areaName: '地区名称',
areaCode: '地区编码',
areaLevel: '地区级别',
parentArea: '上级地区',
province: '省份',
city: '城市',
district: '区县',
street: '街道',
unknown: '未知',
addArea: '添加地区',
editArea: '编辑地区',
remark: '备注',
sort: '排序',
createTime: '创建时间',
action: '操作',
batchDelete: '批量删除',
confirmBatchDelete: '确认批量删除',
batchDeleteConfirm: '确定要删除选中的',
items: '条数据吗?',
deleteConfirm: '确定要删除',
selectDataFirst: '请先选择要操作的数据',
pleaseEnterNumber: '请输入有效的数字',
exitFullScreen: '退出全屏',
columns: '列设置',
columnSettings: '列显示设置',
selectAll: '全选',
unselectAll: '取消全选',
retry: '重试'
welcome: "欢迎使用",
login: "登录",
logout: "退出登录",
register: "注册",
searchMenu: "搜索菜单",
searchPlaceholder: "请输入菜单名称进行搜索",
noResults: "未找到匹配的菜单",
searchTips: "快捷键操作提示",
navigateResults: "使用上下键导航",
selectResult: "按回车键选择",
closeSearch: "按 ESC 关闭",
taskCenter: "任务中心",
totalTasks: "总任务",
pendingTasks: "待完成",
completedTasks: "已完成",
searchTasks: "搜索任务...",
all: "全部",
pending: "待完成",
completed: "已完成",
taskTitle: "任务标题",
enterTaskTitle: "请输入任务标题",
taskPriority: "任务优先级",
priorityHigh: "高",
priorityMedium: "中",
priorityLow: "低",
confirmDelete: "确认删除",
addTask: "添加任务",
pleaseEnterTaskTitle: "请输入任务标题",
added: "已添加",
deleted: "已删除",
justNow: "刚刚",
clearCache: "清除缓存",
confirmClearCache: "确认清除缓存",
clearCacheConfirm:
"确定要清除所有缓存吗?这将清除本地存储、会话存储和缓存数据。",
cacheCleared: "缓存已清除",
clearCacheFailed: "清除缓存失败",
messages: "消息",
tasks: "任务",
notification: "通知",
task: "任务",
warning: "警告",
markAllAsRead: "全部标为已读",
clearAll: "清空全部",
noMessages: "暂无消息",
noTasks: "暂无任务",
confirmClear: "确认清空",
confirmClearMessages: "确定要清空所有消息吗?",
markedAsRead: "已标记为已读",
realtimeConnected: "实时连接已建立",
realtimeDisconnected: "实时连接已断开",
realtimeError: "实时连接出现错误,正在重连...",
dataUpdated: "数据已更新",
dataCreated: "数据已创建",
dataDeleted: "数据已删除",
fullscreen: "全屏",
personalCenter: "个人中心",
systemSettings: "系统设置",
searchEmpty: "请输入搜索内容",
searching: "正在搜索:",
cleared: "已清空",
languageChanged: "语言已切换",
settingsDeveloping: "系统设置功能开发中",
logoutSuccess: "退出成功",
logoutFailed: "退出失败",
confirmLogout: "确认退出",
logoutConfirm: "确定要退出登录吗?",
username: "用户名",
password: "密码",
confirmPassword: "确认密码",
email: "邮箱",
phone: "手机号",
rememberMe: "记住我",
forgotPassword: "忘记密码?",
submit: "提交",
cancel: "取消",
save: "保存",
edit: "编辑",
delete: "删除",
add: "添加",
search: "搜索",
reset: "重置",
confirm: "确认",
back: "返回",
next: "下一步",
previous: "上一步",
refresh: "刷新",
export: "导出",
import: "导入",
download: "下载",
upload: "上传",
view: "查看",
detail: "详情",
settings: "设置",
profile: "个人资料",
language: "语言",
theme: "主题",
dark: "暗色",
light: "亮色",
loading: "加载中...",
noData: "暂无数据",
success: "操作成功",
error: "操作失败",
warning: "警告",
info: "提示",
confirmDelete: "确定要删除吗?",
confirmLogout: "确定要退出登录吗?",
addConfig: "添加配置",
editConfig: "编辑配置",
configCategory: "配置分类",
configName: "配置名称",
configTitle: "配置标题",
configType: "配置类型",
configValue: "配置值",
configTip: "配置提示",
typeText: "文本",
typeTextarea: "文本域",
typeNumber: "数字",
typeSwitch: "开关",
typeSelect: "下拉选择",
typeMultiselect: "多选",
typeDatetime: "日期时间",
typeColor: "颜色",
pleaseSelect: "请选择",
pleaseEnter: "请输入",
noConfig: "暂无配置",
fetchConfigFailed: "获取配置失败",
addSuccess: "添加成功",
addFailed: "添加失败",
editSuccess: "编辑成功",
editFailed: "编辑失败",
saveSuccess: "保存成功",
saveFailed: "保存失败",
resetSuccess: "重置成功",
required: "此项为必填项",
operation: "操作",
time: "时间",
status: "状态",
enabled: "启用",
disabled: "禁用",
yes: "是",
no: "否",
areaManage: "地区管理",
areaName: "地区名称",
areaCode: "地区编码",
areaLevel: "地区级别",
parentArea: "上级地区",
province: "省份",
city: "城市",
district: "区县",
street: "街道",
unknown: "未知",
addArea: "添加地区",
editArea: "编辑地区",
remark: "备注",
sort: "排序",
createTime: "创建时间",
action: "操作",
batchDelete: "批量删除",
confirmBatchDelete: "确认批量删除",
batchDeleteConfirm: "确定要删除选中的",
items: "条数据吗?",
deleteConfirm: "确定要删除",
selectDataFirst: "请先选择要操作的数据",
pleaseEnterNumber: "请输入有效的数字",
exitFullScreen: "退出全屏",
columns: "列设置",
columnSettings: "列显示设置",
selectAll: "全选",
unselectAll: "取消全选",
retry: "重试",
},
menu: {
dashboard: '仪表板',
userManagement: '用户管理',
roleManagement: '角色管理',
permissionManagement: '权限管理',
systemSettings: '系统设置',
logManagement: '日志管理'
dashboard: "仪表板",
userManagement: "用户管理",
roleManagement: "角色管理",
permissionManagement: "权限管理",
systemSettings: "系统设置",
logManagement: "日志管理",
},
login: {
title: '用户登录',
subtitle: '欢迎回来,请登录您的账户',
loginButton: '登录',
loginSuccess: '登录成功',
loginFailed: '登录失败',
usernamePlaceholder: '请输入用户名',
passwordPlaceholder: '请输入密码',
noAccount: '还没有账户?',
registerNow: '立即注册',
forgotPassword: '忘记密码?',
rememberMe: '记住我'
title: "用户登录",
subtitle: "欢迎回来,请登录您的账户",
loginButton: "登录",
loginSuccess: "登录成功",
loginFailed: "登录失败",
usernamePlaceholder: "请输入用户名",
passwordPlaceholder: "请输入密码",
noAccount: "还没有账户?",
registerNow: "立即注册",
forgotPassword: "忘记密码?",
rememberMe: "记住我",
},
register: {
title: '用户注册',
subtitle: '创建您的账户,开始使用',
registerButton: '注册',
registerSuccess: '注册成功',
registerFailed: '注册失败',
usernamePlaceholder: '请输入用户名',
emailPlaceholder: '请输入邮箱地址',
passwordPlaceholder: '请输入密码',
confirmPasswordPlaceholder: '请再次输入密码',
usernameRule: '用户名长度在 3 到 20 个字符',
emailRule: '请输入正确的邮箱地址',
passwordRule: '密码长度在 6 到 20 个字符',
agreeRule: '请同意用户协议',
agreeTerms: '我已阅读并同意',
terms: '用户协议',
hasAccount: '已有账户?',
loginNow: '立即登录'
title: "用户注册",
subtitle: "创建您的账户,开始使用",
registerButton: "注册",
registerSuccess: "注册成功",
registerFailed: "注册失败",
usernamePlaceholder: "请输入用户名",
emailPlaceholder: "请输入邮箱地址",
passwordPlaceholder: "请输入密码",
confirmPasswordPlaceholder: "请再次输入密码",
usernameRule: "用户名长度在 3 到 20 个字符",
emailRule: "请输入正确的邮箱地址",
passwordRule: "密码长度在 6 到 20 个字符",
agreeRule: "请同意用户协议",
agreeTerms: "我已阅读并同意",
terms: "用户协议",
hasAccount: "已有账户?",
loginNow: "立即登录",
},
resetPassword: {
title: '重置密码',
subtitle: '通过邮箱验证码重置您的密码',
resetButton: '重置密码',
resetSuccess: '密码重置成功',
resetFailed: '重置失败',
emailPlaceholder: '请输入邮箱地址',
codePlaceholder: '请输入验证码',
newPasswordPlaceholder: '请输入新密码',
confirmPasswordPlaceholder: '请再次输入新密码',
emailRule: '请输入正确的邮箱地址',
codeRule: '验证码长度为6位',
passwordRule: '密码长度在 6 到 20 个字符',
sendCode: '发送验证码',
codeSent: '验证码已发送到您的邮箱',
resendCode: '{seconds}秒后重新发送',
sendCodeFirst: '请先输入邮箱地址',
backToLogin: '返回登录'
title: "重置密码",
subtitle: "通过邮箱验证码重置您的密码",
resetButton: "重置密码",
resetSuccess: "密码重置成功",
resetFailed: "重置失败",
emailPlaceholder: "请输入邮箱地址",
codePlaceholder: "请输入验证码",
newPasswordPlaceholder: "请输入新密码",
confirmPasswordPlaceholder: "请再次输入新密码",
emailRule: "请输入正确的邮箱地址",
codeRule: "验证码长度为6位",
passwordRule: "密码长度在 6 到 20 个字符",
sendCode: "发送验证码",
codeSent: "验证码已发送到您的邮箱",
resendCode: "{seconds}秒后重新发送",
sendCodeFirst: "请先输入邮箱地址",
backToLogin: "返回登录",
},
layout: {
toggleSidebar: '切换侧边栏',
collapse: '折叠',
expand: '展开',
logout: '退出登录'
toggleSidebar: "切换侧边栏",
collapse: "折叠",
expand: "展开",
logout: "退出登录",
},
table: {
total: '共 {total} 条',
selected: '已选择 {selected} 项',
actions: '操作',
noData: '暂无数据',
sort: '排序',
filter: '筛选'
total: "共 {total} 条",
selected: "已选择 {selected} 项",
actions: "操作",
noData: "暂无数据",
sort: "排序",
filter: "筛选",
},
pagination: {
goTo: '前往',
page: '页',
total: '共 {total} 条',
itemsPerPage: '每页 {size} 条'
goTo: "前往",
page: "页",
total: "共 {total} 条",
itemsPerPage: "每页 {size} 条",
},
form: {
required: '此项为必填项',
invalidEmail: '请输入有效的邮箱地址',
invalidPhone: '请输入有效的手机号',
passwordMismatch: '两次输入的密码不一致',
minLength: '最少需要 {min} 个字符',
maxLength: '最多允许 {max} 个字符'
}
}
required: "此项为必填项",
invalidEmail: "请输入有效的邮箱地址",
invalidPhone: "请输入有效的手机号",
passwordMismatch: "两次输入的密码不一致",
minLength: "最少需要 {min} 个字符",
maxLength: "最多允许 {max} 个字符",
},
};
@@ -1,7 +1,13 @@
<template>
<a-breadcrumb class="breadcrumb">
<a-breadcrumb-item v-for="(item, index) in breadcrumbList" :key="item.path">
<span v-if="index === breadcrumbList.length - 1" class="no-redirect">
<a-breadcrumb-item
v-for="(item, index) in breadcrumbList"
:key="item.path"
>
<span
v-if="index === breadcrumbList.length - 1"
class="no-redirect"
>
<component :is="item.meta?.icon || 'FileTextOutlined'" />
{{ item.meta.title }}
</span>
@@ -14,42 +20,47 @@
</template>
<script setup>
import { ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import config from '@/config'
import { ref, watch } from "vue";
import { useRoute } from "vue-router";
import config from "@/config";
// 定义组件名称(多词命名)
defineOptions({
name: 'LayoutBreadcrumb'
})
name: "LayoutBreadcrumb",
});
const route = useRoute()
const breadcrumbList = ref([])
const route = useRoute();
const breadcrumbList = ref([]);
// 获取面包屑列表
const getBreadcrumb = () => {
let matched = route.matched.filter(item => item.meta && item.meta.title)
let matched = route.matched.filter((item) => item.meta && item.meta.title);
// 如果第一个不是首页,添加首页
const first = matched[0]
const first = matched[0];
if (first && first.path !== config.DASHBOARD_URL) {
matched = [{ path: config.DASHBOARD_URL, meta: { title: '', icon: 'HomeOutlined' } }].concat(matched)
matched = [
{
path: config.DASHBOARD_URL,
meta: { title: "", icon: "HomeOutlined" },
},
].concat(matched);
}
breadcrumbList.value = matched
}
breadcrumbList.value = matched;
};
// 处理点击面包屑
const handleLink = () => {
return
}
return;
};
// 监听路由变化
watch(
() => route.path,
() => {
getBreadcrumb()
getBreadcrumb();
},
{ immediate: true }
)
{ immediate: true },
);
</script>
@@ -1,16 +1,27 @@
<template>
<template v-for="item in menuItems" :key="item.path || item.name">
<!-- 有子菜单 - 使用递归 -->
<a-sub-menu v-if="item.children && item.children.length > 0" :key="`${item.path}`">
<a-sub-menu
v-if="item.children && item.children.length > 0"
:key="`${item.path}`"
>
<template #icon v-if="item.meta?.icon">
<component :is="getIconComponent(item.meta.icon)" />
</template>
<template #title>{{ item.title || item.name }}</template>
<navMenu :menu-items="item.children" :active-path="activePath" :parent-path="item.path" />
<navMenu
:menu-items="item.children"
:active-path="activePath"
:parent-path="item.path"
/>
</a-sub-menu>
<!-- 无子菜单的菜单项 -->
<a-menu-item v-else :key="item.path" :class="{ 'ant-menu-item-selected': item.path === activePath }"
@click="handleMenuClick(item)">
<a-menu-item
v-else
:key="item.path"
:class="{ 'ant-menu-item-selected': item.path === activePath }"
@click="handleMenuClick(item)"
>
<template #icon v-if="item.meta?.icon">
<component :is="getIconComponent(item.meta.icon)" />
</template>
@@ -20,35 +31,35 @@
</template>
<script setup>
import { useRouter } from 'vue-router'
import * as icons from '@ant-design/icons-vue'
import { useRouter } from "vue-router";
import * as icons from "@ant-design/icons-vue";
defineProps({
menuItems: {
type: Array,
default: () => []
default: () => [],
},
activePath: {
type: String,
default: ''
default: "",
},
parentPath: {
type: String,
default: ''
}
})
default: "",
},
});
const router = useRouter()
const router = useRouter();
// 获取图标组件
const getIconComponent = (iconName) => {
return icons[iconName] || icons.FileTextOutlined
}
return icons[iconName] || icons.FileTextOutlined;
};
// 处理菜单点击
const handleMenuClick = (item) => {
if (item.path) {
router.push(item.path)
router.push(item.path);
}
}
};
</script>
@@ -36,7 +36,9 @@
</div>
<div class="result-content">
<div class="result-title">{{ item.title }}</div>
<div v-if="item.breadcrumbs" class="result-path">{{ item.breadcrumbs }}</div>
<div v-if="item.breadcrumbs" class="result-path">
{{ item.breadcrumbs }}
</div>
</div>
</div>
</div>
@@ -46,20 +48,20 @@
</div>
<div v-else class="search-tips">
<div class="tip-title">{{ $t('common.searchTips') }}</div>
<div class="tip-title">{{ $t("common.searchTips") }}</div>
<div class="tip-list">
<div class="tip-item">
<kbd></kbd>
<kbd></kbd>
<span>{{ $t('common.navigateResults') }}</span>
<span>{{ $t("common.navigateResults") }}</span>
</div>
<div class="tip-item">
<kbd>Enter</kbd>
<span>{{ $t('common.selectResult') }}</span>
<span>{{ $t("common.selectResult") }}</span>
</div>
<div class="tip-item">
<kbd>Esc</kbd>
<span>{{ $t('common.closeSearch') }}</span>
<span>{{ $t("common.closeSearch") }}</span>
</div>
</div>
</div>
@@ -68,133 +70,137 @@
</template>
<script setup>
import { ref, computed, watch, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { SearchOutlined, MenuOutlined } from '@ant-design/icons-vue'
import { useUserStore } from '@/stores/modules/user'
import { useI18n } from 'vue-i18n'
import { ref, computed, watch, nextTick } from "vue";
import { useRouter } from "vue-router";
import { SearchOutlined, MenuOutlined } from "@ant-design/icons-vue";
import { useUserStore } from "@/stores/modules/user";
import { useI18n } from "vue-i18n";
// 定义组件名称
defineOptions({
name: 'MenuSearch',
})
name: "MenuSearch",
});
const { t } = useI18n()
const router = useRouter()
const userStore = useUserStore()
const { t } = useI18n();
const router = useRouter();
const userStore = useUserStore();
const visible = defineModel('visible', { type: Boolean, default: false })
const searchKeyword = ref('')
const searchResults = ref([])
const selectedIndex = ref(0)
const searchInputRef = ref(null)
const visible = defineModel("visible", { type: Boolean, default: false });
const searchKeyword = ref("");
const searchResults = ref([]);
const selectedIndex = ref(0);
const searchInputRef = ref(null);
// 将扁平化的菜单数据转换为可搜索格式
function flattenMenus(menus, breadcrumbs = []) {
const result = []
const result = [];
menus.forEach((menu) => {
if (menu.hidden) return
if (menu.hidden) return;
const currentBreadcrumbs = [...breadcrumbs, menu.title]
const currentBreadcrumbs = [...breadcrumbs, menu.title];
// 如果有路径且不是外部链接,添加到搜索结果
if (menu.path && !menu.path.startsWith('http')) {
if (menu.path && !menu.path.startsWith("http")) {
result.push({
title: menu.title,
path: menu.path,
icon: menu.icon,
breadcrumbs: currentBreadcrumbs.join(' / '),
})
breadcrumbs: currentBreadcrumbs.join(" / "),
});
}
// 递归处理子菜单
if (menu.children && menu.children.length > 0) {
const children = flattenMenus(menu.children, currentBreadcrumbs)
result.push(...children)
const children = flattenMenus(menu.children, currentBreadcrumbs);
result.push(...children);
}
})
});
return result
return result;
}
// 获取所有菜单项
const allMenus = computed(() => {
const menus = userStore.menu || []
return flattenMenus(menus)
})
const menus = userStore.menu || [];
return flattenMenus(menus);
});
// 执行搜索
function handleSearch() {
if (!searchKeyword.value.trim()) {
searchResults.value = []
selectedIndex.value = 0
return
searchResults.value = [];
selectedIndex.value = 0;
return;
}
const keyword = searchKeyword.value.toLowerCase().trim()
const keyword = searchKeyword.value.toLowerCase().trim();
searchResults.value = allMenus.value.filter((menu) => {
return menu.title.toLowerCase().includes(keyword) ||
return (
menu.title.toLowerCase().includes(keyword) ||
menu.breadcrumbs.toLowerCase().includes(keyword)
})
);
});
selectedIndex.value = 0
selectedIndex.value = 0;
}
// 键盘导航
function handleKeydown(e) {
if (!searchResults.value.length) return
if (!searchResults.value.length) return;
switch (e.key) {
case 'ArrowUp':
e.preventDefault()
selectedIndex.value = selectedIndex.value > 0
? selectedIndex.value - 1
: searchResults.value.length - 1
break
case 'ArrowDown':
e.preventDefault()
selectedIndex.value = selectedIndex.value < searchResults.value.length - 1
? selectedIndex.value + 1
: 0
break
case 'Enter':
e.preventDefault()
case "ArrowUp":
e.preventDefault();
selectedIndex.value =
selectedIndex.value > 0
? selectedIndex.value - 1
: searchResults.value.length - 1;
break;
case "ArrowDown":
e.preventDefault();
selectedIndex.value =
selectedIndex.value < searchResults.value.length - 1
? selectedIndex.value + 1
: 0;
break;
case "Enter":
e.preventDefault();
if (searchResults.value[selectedIndex.value]) {
handleSelect(searchResults.value[selectedIndex.value])
handleSelect(searchResults.value[selectedIndex.value]);
}
break
case 'Escape':
e.preventDefault()
handleClose()
break
break;
case "Escape":
e.preventDefault();
handleClose();
break;
}
}
// 选择菜单项
function handleSelect(item) {
visible.value = false
router.push(item.path)
visible.value = false;
router.push(item.path);
}
// 关闭搜索弹窗
function handleClose() {
visible.value = false
searchKeyword.value = ''
searchResults.value = []
selectedIndex.value = 0
visible.value = false;
searchKeyword.value = "";
searchResults.value = [];
selectedIndex.value = 0;
}
// 监听弹窗显示,自动聚焦输入框
watch(visible, (newVal) => {
if (newVal) {
nextTick(() => {
searchInputRef.value?.focus()
})
searchInputRef.value?.focus();
});
} else {
handleClose()
handleClose();
}
})
});
</script>
<style scoped lang="scss">
@@ -1,22 +1,42 @@
<template>
<a-drawer v-model:open="open" title="布局配置" placement="right" :width="420">
<a-drawer
v-model:open="open"
title="布局配置"
placement="right"
:width="420"
>
<div class="setting-content">
<div class="setting-item">
<div class="setting-title">布局模式</div>
<div class="layout-mode-list">
<div v-for="mode in layoutModes" :key="mode.value" class="layout-mode-item"
:class="{ active: layoutStore.layoutMode === mode.value }"
@click="handleLayoutChange(mode.value)">
<div class="layout-preview" :class="`preview-${mode.value}`">
<div
v-for="mode in layoutModes"
:key="mode.value"
class="layout-mode-item"
:class="{
active: layoutStore.layoutMode === mode.value,
}"
@click="handleLayoutChange(mode.value)"
>
<div
class="layout-preview"
:class="`preview-${mode.value}`"
>
<div class="preview-sidebar"></div>
<div v-if="mode.value === 'default'" class="preview-sidebar-2"></div>
<div
v-if="mode.value === 'default'"
class="preview-sidebar-2"
></div>
<div class="preview-content">
<div class="preview-header"></div>
<div class="preview-body"></div>
</div>
</div>
<div class="layout-name">{{ mode.label }}</div>
<CheckOutlined v-if="layoutStore.layoutMode === mode.value" class="check-icon" />
<CheckOutlined
v-if="layoutStore.layoutMode === mode.value"
class="check-icon"
/>
</div>
</div>
</div>
@@ -24,9 +44,14 @@
<div class="setting-item">
<div class="setting-title">主题颜色</div>
<div class="color-list">
<div v-for="color in themeColors" :key="color" class="color-item"
:class="{ active: themeColor === color }" :style="{ backgroundColor: color }"
@click="changeThemeColor(color)">
<div
v-for="color in themeColors"
:key="color"
class="color-item"
:class="{ active: themeColor === color }"
:style="{ backgroundColor: color }"
@click="changeThemeColor(color)"
>
<CheckOutlined v-if="themeColor === color" />
</div>
</div>
@@ -37,11 +62,17 @@
<div class="toggle-list">
<div class="toggle-item">
<span>显示标签栏</span>
<a-switch v-model:checked="showTags" @change="handleShowTagsChange" />
<a-switch
v-model:checked="showTags"
@change="handleShowTagsChange"
/>
</div>
<div class="toggle-item">
<span>显示面包屑</span>
<a-switch v-model:checked="showBreadcrumb" @change="handleShowBreadcrumbChange" />
<a-switch
v-model:checked="showBreadcrumb"
@change="handleShowBreadcrumbChange"
/>
</div>
</div>
</div>
@@ -60,108 +91,126 @@
</template>
<script setup>
import { ref, watch, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import { useLayoutStore } from '@/stores/modules/layout'
import { CheckOutlined, ReloadOutlined } from '@ant-design/icons-vue'
import { ref, watch, onMounted } from "vue";
import { message } from "ant-design-vue";
import { useLayoutStore } from "@/stores/modules/layout";
import { CheckOutlined, ReloadOutlined } from "@ant-design/icons-vue";
// 定义组件名称(多词命名)
defineOptions({
name: 'LayoutSetting',
})
name: "LayoutSetting",
});
const layoutStore = useLayoutStore()
const layoutStore = useLayoutStore();
const open = ref(false)
const themeColor = ref('#1890ff')
const showTags = ref(true)
const showBreadcrumb = ref(true)
const open = ref(false);
const themeColor = ref("#1890ff");
const showTags = ref(true);
const showBreadcrumb = ref(true);
const layoutModes = [
{ value: 'default', label: '默认布局' },
{ value: 'menu', label: '菜单布局' },
{ value: 'top', label: '顶部布局' },
]
{ value: "default", label: "默认布局" },
{ value: "menu", label: "菜单布局" },
{ value: "top", label: "顶部布局" },
];
const themeColors = ['#1890ff', '#f5222d', '#fa541c', '#faad14', '#13c2c2', '#52c41a', '#2f54eb', '#722ed1']
const themeColors = [
"#1890ff",
"#f5222d",
"#fa541c",
"#faad14",
"#13c2c2",
"#52c41a",
"#2f54eb",
"#722ed1",
];
const openDrawer = () => {
open.value = true
}
open.value = true;
};
const closeDrawer = () => {
open.value = false
}
open.value = false;
};
defineExpose({
openDrawer,
closeDrawer,
})
});
// 切换布局
const handleLayoutChange = (mode) => {
layoutStore.setLayoutMode(mode)
const modeLabel = layoutModes.find((m) => m.value === mode)?.label || mode
message.success(`已切换到${modeLabel}`)
}
layoutStore.setLayoutMode(mode);
const modeLabel = layoutModes.find((m) => m.value === mode)?.label || mode;
message.success(`已切换到${modeLabel}`);
};
// 切换主题颜色
const changeThemeColor = (color) => {
themeColor.value = color
themeColor.value = color;
// 更新 CSS 变量
document.documentElement.style.setProperty('--primary-color', color)
message.success('主题颜色已更新')
}
document.documentElement.style.setProperty("--primary-color", color);
message.success("主题颜色已更新");
};
// 切换标签栏显示
const handleShowTagsChange = (checked) => {
showTags.value = checked
showTags.value = checked;
// 触发自定义事件或更新状态
document.documentElement.style.setProperty('--show-tags', checked ? 'block' : 'none')
message.success(checked ? '标签栏已显示' : '标签栏已隐藏')
}
document.documentElement.style.setProperty(
"--show-tags",
checked ? "block" : "none",
);
message.success(checked ? "标签栏已显示" : "标签栏已隐藏");
};
// 切换面包屑显示
const handleShowBreadcrumbChange = (checked) => {
showBreadcrumb.value = checked
message.success(checked ? '面包屑已显示' : '面包屑已隐藏')
}
showBreadcrumb.value = checked;
message.success(checked ? "面包屑已显示" : "面包屑已隐藏");
};
// 重置设置
const handleResetSettings = () => {
themeColor.value = '#1890ff'
showTags.value = true
showBreadcrumb.value = true
layoutStore.setLayoutMode('default')
document.documentElement.style.setProperty('--primary-color', '#1890ff')
document.documentElement.style.setProperty('--show-tags', 'block')
message.success('设置已重置')
}
themeColor.value = "#1890ff";
showTags.value = true;
showBreadcrumb.value = true;
layoutStore.setLayoutMode("default");
document.documentElement.style.setProperty("--primary-color", "#1890ff");
document.documentElement.style.setProperty("--show-tags", "block");
message.success("设置已重置");
};
// 初始化
onMounted(() => {
// 从本地存储或其他地方恢复设置
const savedThemeColor = localStorage.getItem('themeColor')
const savedThemeColor = localStorage.getItem("themeColor");
if (savedThemeColor) {
themeColor.value = savedThemeColor
document.documentElement.style.setProperty('--primary-color', savedThemeColor)
themeColor.value = savedThemeColor;
document.documentElement.style.setProperty(
"--primary-color",
savedThemeColor,
);
}
const savedShowTags = localStorage.getItem('showTags')
const savedShowTags = localStorage.getItem("showTags");
if (savedShowTags !== null) {
showTags.value = savedShowTags === 'true'
document.documentElement.style.setProperty('--show-tags', savedShowTags === 'true' ? 'block' : 'none')
showTags.value = savedShowTags === "true";
document.documentElement.style.setProperty(
"--show-tags",
savedShowTags === "true" ? "block" : "none",
);
}
})
});
// 监听设置变化并保存到本地存储
watch(themeColor, (newVal) => {
localStorage.setItem('themeColor', newVal)
})
localStorage.setItem("themeColor", newVal);
});
watch(showTags, (newVal) => {
localStorage.setItem('showTags', String(newVal))
})
localStorage.setItem("showTags", String(newVal));
});
</script>
<style scoped lang="scss">
@@ -1,29 +1,57 @@
<template>
<a-menu mode="inline" :theme="theme" :collapsed="collapsed" :selected-keys="selectedKeys" :open-keys="openKeys"
@select="handleSelect" @open-change="handleOpenChange" class="side-menu">
<a-menu
mode="inline"
:theme="theme"
:collapsed="collapsed"
:selected-keys="selectedKeys"
:open-keys="openKeys"
@select="handleSelect"
@open-change="handleOpenChange"
class="side-menu"
>
<template v-for="item in menuList">
<!-- 有子菜单 -->
<a-sub-menu v-if="item.children && item.children.length > 0" :key="item.path + '-submenu'">
<a-sub-menu
v-if="item.children && item.children.length > 0"
:key="item.path + '-submenu'"
>
<template #icon>
<component :is="item.meta?.icon || 'MenuOutlined'" />
</template>
<template #title>{{ item.meta?.title || item.name }}</template>
<a-menu-item v-for="child in item.children.filter(sub => !sub.children || sub.children.length === 0)"
:key="child.path">
<a-menu-item
v-for="child in item.children.filter(
(sub) => !sub.children || sub.children.length === 0,
)"
:key="child.path"
>
<template #icon>
<component :is="child.meta?.icon || 'FileOutlined'" />
</template>
{{ child.meta?.title || child.name }}
</a-menu-item>
<a-sub-menu v-for="child in item.children.filter(sub => sub.children && sub.children.length > 0)"
:key="child.path">
<a-sub-menu
v-for="child in item.children.filter(
(sub) => sub.children && sub.children.length > 0,
)"
:key="child.path"
>
<template #icon>
<component :is="child.meta?.icon || 'AppstoreOutlined'" />
<component
:is="child.meta?.icon || 'AppstoreOutlined'"
/>
</template>
<template #title>{{ child.meta?.title || child.name }}</template>
<a-menu-item v-for="grandChild in child.children" :key="grandChild.path">
<template #title>{{
child.meta?.title || child.name
}}</template>
<a-menu-item
v-for="grandChild in child.children"
:key="grandChild.path"
>
<template #icon>
<component :is="grandChild.meta?.icon || 'FileOutlined'" />
<component
:is="grandChild.meta?.icon || 'FileOutlined'"
/>
</template>
{{ grandChild.meta?.title || grandChild.name }}
</a-menu-item>
@@ -41,112 +69,119 @@
</template>
<script setup>
import { ref, watch, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getUserMenu } from '@/api/menu'
import { ref, watch, onMounted } from "vue";
import { useRoute, useRouter } from "vue-router";
import { getUserMenu } from "@/api/menu";
const props = defineProps({
collapsed: {
type: Boolean,
default: false
default: false,
},
theme: {
type: String,
default: 'light'
}
})
default: "light",
},
});
const route = useRoute()
const router = useRouter()
const route = useRoute();
const router = useRouter();
const menuList = ref([])
const selectedKeys = ref([])
const openKeys = ref([])
const menuList = ref([]);
const selectedKeys = ref([]);
const openKeys = ref([]);
// 获取菜单数据
const getMenuList = async () => {
try {
const res = await getUserMenu()
const res = await getUserMenu();
if (res.code === 200) {
menuList.value = res.data || []
menuList.value = res.data || [];
}
} catch (error) {
console.error('获取菜单失败:', error)
console.error("获取菜单失败:", error);
// 模拟数据
menuList.value = [
{
path: '/home',
name: 'Home',
meta: { title: '首页', icon: 'HomeOutlined' }
path: "/home",
name: "Home",
meta: { title: "首页", icon: "HomeOutlined" },
},
{
path: '/system',
name: 'System',
meta: { title: '系统管理', icon: 'SettingOutlined' },
path: "/system",
name: "System",
meta: { title: "系统管理", icon: "SettingOutlined" },
children: [
{
path: '/system/user',
name: 'User',
meta: { title: '用户管理', icon: 'UserOutlined' }
path: "/system/user",
name: "User",
meta: { title: "用户管理", icon: "UserOutlined" },
},
{
path: '/system/role',
name: 'Role',
meta: { title: '角色管理', icon: 'TeamOutlined' }
path: "/system/role",
name: "Role",
meta: { title: "角色管理", icon: "TeamOutlined" },
},
{
path: '/system/menu',
name: 'Menu',
meta: { title: '菜单管理', icon: 'MenuOutlined' }
}
]
}
]
path: "/system/menu",
name: "Menu",
meta: { title: "菜单管理", icon: "MenuOutlined" },
},
],
},
];
}
}
};
// 更新选中的菜单
const updateSelectedKeys = () => {
selectedKeys.value = [route.path]
selectedKeys.value = [route.path];
// 获取父级菜单路径
const matched = route.matched
.filter(item => item.path !== '/' && item.path !== route.path)
.map(item => item.path)
.filter((item) => item.path !== "/" && item.path !== route.path)
.map((item) => item.path);
// 折叠时不自动展开
if (!props.collapsed) {
openKeys.value = matched
openKeys.value = matched;
}
}
};
// 处理菜单选择
const handleSelect = ({ key }) => {
router.push(key)
}
router.push(key);
};
// 处理菜单展开/收起
const handleOpenChange = (keys) => {
openKeys.value = keys
}
openKeys.value = keys;
};
// 监听路由变化
watch(() => route.path, () => {
updateSelectedKeys()
}, { immediate: true })
watch(
() => route.path,
() => {
updateSelectedKeys();
},
{ immediate: true },
);
// 监听折叠状态
watch(() => props.collapsed, (val) => {
if (val) {
openKeys.value = []
} else {
updateSelectedKeys()
}
})
watch(
() => props.collapsed,
(val) => {
if (val) {
openKeys.value = [];
} else {
updateSelectedKeys();
}
},
);
onMounted(() => {
getMenuList()
})
getMenuList();
});
</script>
<style scoped lang="scss">
+144 -121
View File
@@ -7,10 +7,14 @@
:key="tag.fullPath"
:closable="!tag.meta?.affix"
class="tag-item"
:class="{ active: isActive(tag), 'tag-affix': tag.meta?.affix }"
:class="{
active: isActive(tag),
'tag-affix': tag.meta?.affix,
}"
@click="clickTag(tag)"
@close="closeSelectedTag(tag)"
@contextmenu.prevent="handleContextMenu($event, tag)">
@contextmenu.prevent="handleContextMenu($event, tag)"
>
<template #icon v-if="tag.meta?.affix">
<PushpinFilled />
</template>
@@ -20,7 +24,11 @@
</div>
<div class="tags-actions">
<a-dropdown v-model:open="actionMenuVisible" trigger="click" placement="bottomRight">
<a-dropdown
v-model:open="actionMenuVisible"
trigger="click"
placement="bottomRight"
>
<a-button size="small" type="text">
<MoreOutlined />
</a-button>
@@ -59,16 +67,20 @@
position: 'fixed',
left: contextMenu.x + 'px',
top: contextMenu.y + 'px',
zIndex: 9999
zIndex: 9999,
}"
class="context-menu"
@click="closeContextMenu">
@click="closeContextMenu"
>
<a-menu @click="handleMenuClick">
<a-menu-item key="refresh">
<ReloadOutlined />
<span>刷新</span>
</a-menu-item>
<a-menu-item v-if="selectedTag && !selectedTag.meta?.affix" key="close">
<a-menu-item
v-if="selectedTag && !selectedTag.meta?.affix"
key="close"
>
<CloseOutlined />
<span>关闭</span>
</a-menu-item>
@@ -87,39 +99,39 @@
</template>
<script setup>
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useLayoutStore } from '@/stores/modules/layout'
import config from '@/config'
import { ref, computed, watch, onMounted, onBeforeUnmount } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useLayoutStore } from "@/stores/modules/layout";
import config from "@/config";
defineOptions({
name: 'TagsView',
})
name: "TagsView",
});
const route = useRoute()
const router = useRouter()
const layoutStore = useLayoutStore()
const route = useRoute();
const router = useRouter();
const layoutStore = useLayoutStore();
const showTags = ref(true)
const selectedTag = ref(null)
const visitedViews = computed(() => layoutStore.viewTags)
const showTags = ref(true);
const selectedTag = ref(null);
const visitedViews = computed(() => layoutStore.viewTags);
// 右键菜单状态
const contextMenu = ref({
visible: false,
x: 0,
y: 0
})
y: 0,
});
// 顶部操作菜单状态
const actionMenuVisible = ref(false)
const actionMenuVisible = ref(false);
// 判断是否是当前激活的标签
const isActive = (tag) => {
return tag.fullPath === route.fullPath
}
return tag.fullPath === route.fullPath;
};
// 添加标签
const addTags = () => {
const { name } = route
const { name } = route;
if (name && !route.meta?.noCache) {
layoutStore.updateViewTags({
fullPath: route.fullPath,
@@ -127,223 +139,232 @@ const addTags = () => {
name: name,
query: route.query,
params: route.params,
meta: route.meta
})
meta: route.meta,
});
}
}
};
// 移除标签
const closeSelectedTag = (view) => {
// 如果是固定标签,不允许关闭
if (view.meta?.affix) {
return
return;
}
layoutStore.removeViewTags(view.fullPath)
layoutStore.removeViewTags(view.fullPath);
// 如果关闭的是当前激活的标签,需要跳转
if (isActive(view)) {
const nextTag = visitedViews.value.find((tag) => tag.fullPath !== view.fullPath)
const nextTag = visitedViews.value.find(
(tag) => tag.fullPath !== view.fullPath,
);
if (nextTag) {
router.push(nextTag.fullPath)
router.push(nextTag.fullPath);
} else {
// 如果没有其他标签,跳转到首页
router.push(config.DASHBOARD_URL)
router.push(config.DASHBOARD_URL);
}
}
}
};
// 关闭其他标签
const closeOthersTags = () => {
if (!selectedTag.value || !selectedTag.value.fullPath) {
return
return;
}
// 保留固定标签和当前选中的标签
const tagsToKeep = visitedViews.value.filter(
(tag) => tag.meta?.affix || tag.fullPath === selectedTag.value.fullPath
)
(tag) => tag.meta?.affix || tag.fullPath === selectedTag.value.fullPath,
);
// 更新标签列表
layoutStore.viewTags = tagsToKeep
layoutStore.viewTags = tagsToKeep;
// 如果当前不在选中的标签页,跳转到选中的标签
if (!isActive(selectedTag.value)) {
router.push(selectedTag.value.fullPath)
router.push(selectedTag.value.fullPath);
}
}
};
// 关闭所有标签
const closeAllTags = () => {
// 只保留固定标签
const affixTags = visitedViews.value.filter((tag) => tag.meta?.affix)
layoutStore.viewTags = affixTags
const affixTags = visitedViews.value.filter((tag) => tag.meta?.affix);
layoutStore.viewTags = affixTags;
// 如果还有固定标签,跳转到第一个固定标签
if (affixTags.length > 0) {
router.push(affixTags[0].fullPath)
router.push(affixTags[0].fullPath);
} else {
// 如果没有固定标签,跳转到首页
router.push(config.DASHBOARD_URL)
router.push(config.DASHBOARD_URL);
}
}
};
// 关闭左侧标签
const closeLeftTags = () => {
const currentTag = selectedTag.value || visitedViews.value.find((tag) => isActive(tag))
if (!currentTag) return
const currentTag =
selectedTag.value || visitedViews.value.find((tag) => isActive(tag));
if (!currentTag) return;
const currentIndex = visitedViews.value.findIndex((tag) => tag.fullPath === currentTag.fullPath)
if (currentIndex === -1) return
const currentIndex = visitedViews.value.findIndex(
(tag) => tag.fullPath === currentTag.fullPath,
);
if (currentIndex === -1) return;
// 保留当前标签及其右侧的标签,以及所有固定标签
const tagsToKeep = visitedViews.value.filter((tag, index) => {
return tag.meta?.affix || index >= currentIndex
})
return tag.meta?.affix || index >= currentIndex;
});
layoutStore.viewTags = tagsToKeep
}
layoutStore.viewTags = tagsToKeep;
};
// 关闭右侧标签
const closeRightTags = () => {
const currentTag = selectedTag.value || visitedViews.value.find((tag) => isActive(tag))
if (!currentTag) return
const currentTag =
selectedTag.value || visitedViews.value.find((tag) => isActive(tag));
if (!currentTag) return;
const currentIndex = visitedViews.value.findIndex((tag) => tag.fullPath === currentTag.fullPath)
if (currentIndex === -1) return
const currentIndex = visitedViews.value.findIndex(
(tag) => tag.fullPath === currentTag.fullPath,
);
if (currentIndex === -1) return;
// 保留当前标签及其左侧的标签,以及所有固定标签
const tagsToKeep = visitedViews.value.filter((tag, index) => {
return tag.meta?.affix || index <= currentIndex
})
return tag.meta?.affix || index <= currentIndex;
});
layoutStore.viewTags = tagsToKeep
}
layoutStore.viewTags = tagsToKeep;
};
// 点击标签
const clickTag = (tag) => {
if (!isActive(tag)) {
router.push(tag.fullPath)
router.push(tag.fullPath);
}
}
};
// 刷新指定标签
const refreshTag = (tag) => {
// 如果刷新的是当前激活的标签
if (isActive(tag)) {
// 调用 store 的刷新方法,触发组件重新渲染
layoutStore.refreshTag()
layoutStore.refreshTag();
} else {
// 如果刷新的是其他标签,先跳转到该标签
router.push(tag.fullPath)
router.push(tag.fullPath);
}
}
};
// 刷新当前选中的标签(用于顶部操作按钮)
const refreshSelectedTag = () => {
// 找到当前激活的标签
const currentTag = visitedViews.value.find((tag) => isActive(tag))
const currentTag = visitedViews.value.find((tag) => isActive(tag));
if (currentTag) {
refreshTag(currentTag)
refreshTag(currentTag);
}
}
};
// 右键菜单处理
const handleContextMenu = (event, tag) => {
event.preventDefault()
event.stopPropagation()
event.preventDefault();
event.stopPropagation();
selectedTag.value = tag
selectedTag.value = tag;
contextMenu.value = {
visible: true,
x: event.clientX,
y: event.clientY
}
}
y: event.clientY,
};
};
// 关闭右键菜单
const closeContextMenu = () => {
contextMenu.value.visible = false
}
contextMenu.value.visible = false;
};
// 菜单点击处理
const handleMenuClick = ({ key }) => {
switch (key) {
case 'refresh':
case "refresh":
if (selectedTag.value) {
refreshTag(selectedTag.value)
refreshTag(selectedTag.value);
}
break
case 'close':
break;
case "close":
if (selectedTag.value && !selectedTag.value.meta?.affix) {
closeSelectedTag(selectedTag.value)
closeSelectedTag(selectedTag.value);
}
break
case 'closeOthers':
closeOthersTags()
break
case 'closeAll':
closeAllTags()
break
break;
case "closeOthers":
closeOthersTags();
break;
case "closeAll":
closeAllTags();
break;
}
closeContextMenu()
}
closeContextMenu();
};
// 顶部操作菜单点击处理
const handleActionMenuClick = ({ key }) => {
switch (key) {
case 'refresh':
refreshSelectedTag()
break
case 'closeOthers':
closeOthersTags()
break
case 'closeLeft':
closeLeftTags()
break
case 'closeRight':
closeRightTags()
break
case 'closeAll':
closeAllTags()
break
case "refresh":
refreshSelectedTag();
break;
case "closeOthers":
closeOthersTags();
break;
case "closeLeft":
closeLeftTags();
break;
case "closeRight":
closeRightTags();
break;
case "closeAll":
closeAllTags();
break;
}
actionMenuVisible.value = false
}
actionMenuVisible.value = false;
};
// 点击其他地方关闭右键菜单
const handleClickOutside = (event) => {
if (contextMenu.value.visible) {
const menuElement = document.querySelector('.context-menu')
const menuElement = document.querySelector(".context-menu");
if (menuElement && !menuElement.contains(event.target)) {
closeContextMenu()
closeContextMenu();
}
}
}
};
// 监听路由变化,自动添加标签
watch(
() => route.fullPath,
() => {
addTags()
addTags();
// 更新当前选中的标签
selectedTag.value = visitedViews.value.find((tag) => isActive(tag)) || null
selectedTag.value =
visitedViews.value.find((tag) => isActive(tag)) || null;
},
{ immediate: true }
)
{ immediate: true },
);
onMounted(() => {
addTags()
addTags();
// 初始化选中的标签
selectedTag.value = visitedViews.value.find((tag) => isActive(tag)) || null
selectedTag.value = visitedViews.value.find((tag) => isActive(tag)) || null;
// 添加点击事件监听器
document.addEventListener('click', handleClickOutside)
})
document.addEventListener("click", handleClickOutside);
});
onBeforeUnmount(() => {
// 移除点击事件监听器
document.removeEventListener('click', handleClickOutside)
})
document.removeEventListener("click", handleClickOutside);
});
</script>
<style scoped lang="scss">
@@ -460,7 +481,9 @@ onBeforeUnmount(() => {
.context-menu {
background: #ffffff;
border-radius: 2px;
box-shadow: 0 3px 6px -4px rgba(0, 0, 0, 0.12), 0 6px 16px 0 rgba(0, 0, 0, 0.08),
box-shadow:
0 3px 6px -4px rgba(0, 0, 0, 0.12),
0 6px 16px 0 rgba(0, 0, 0, 0.08),
0 9px 28px 8px rgba(0, 0, 0, 0.05);
border: 1px solid #f0f0f0;
padding: 4px 0;
+102 -72
View File
@@ -11,15 +11,21 @@
<div class="task-stats">
<div class="stat-item">
<div class="stat-number">{{ totalTasks }}</div>
<div class="stat-label">{{ $t('common.totalTasks') }}</div>
<div class="stat-label">{{ $t("common.totalTasks") }}</div>
</div>
<div class="stat-item">
<div class="stat-number pending">{{ pendingTasks }}</div>
<div class="stat-label">{{ $t('common.pendingTasks') }}</div>
<div class="stat-label">
{{ $t("common.pendingTasks") }}
</div>
</div>
<div class="stat-item">
<div class="stat-number completed">{{ completedTasks }}</div>
<div class="stat-label">{{ $t('common.completedTasks') }}</div>
<div class="stat-number completed">
{{ completedTasks }}
</div>
<div class="stat-label">
{{ $t("common.completedTasks") }}
</div>
</div>
</div>
@@ -41,21 +47,23 @@
size="small"
@click="setFilter('all')"
>
{{ $t('common.all') }}
{{ $t("common.all") }}
</a-button>
<a-button
:type="filterType === 'pending' ? 'primary' : 'default'"
size="small"
@click="setFilter('pending')"
>
{{ $t('common.pending') }}
{{ $t("common.pending") }}
</a-button>
<a-button
:type="filterType === 'completed' ? 'primary' : 'default'"
:type="
filterType === 'completed' ? 'primary' : 'default'
"
size="small"
@click="setFilter('completed')"
>
{{ $t('common.completed') }}
{{ $t("common.completed") }}
</a-button>
</div>
</div>
@@ -78,8 +86,15 @@
<div class="task-content">
<div class="task-title">{{ task.title }}</div>
<div class="task-meta">
<span class="task-priority" :class="task.priority">
{{ $t(`common.priority${task.priority.charAt(0).toUpperCase() + task.priority.slice(1)}`) }}
<span
class="task-priority"
:class="task.priority"
>
{{
$t(
`common.priority${task.priority.charAt(0).toUpperCase() + task.priority.slice(1)}`,
)
}}
</span>
<span class="task-time">{{ task.time }}</span>
</div>
@@ -103,10 +118,10 @@
<div class="drawer-footer">
<a-button @click="showAddTask">
<PlusOutlined />
{{ $t('common.addTask') }}
{{ $t("common.addTask") }}
</a-button>
<a-button danger @click="clearAllTasks">
{{ $t('common.clearAll') }}
{{ $t("common.clearAll") }}
</a-button>
</div>
</div>
@@ -121,13 +136,22 @@
>
<a-form layout="vertical">
<a-form-item :label="$t('common.taskTitle')">
<a-input v-model:value="newTask.title" :placeholder="$t('common.enterTaskTitle')" />
<a-input
v-model:value="newTask.title"
:placeholder="$t('common.enterTaskTitle')"
/>
</a-form-item>
<a-form-item :label="$t('common.taskPriority')">
<a-select v-model:value="newTask.priority">
<a-select-option value="low">{{ $t('common.priorityLow') }}</a-select-option>
<a-select-option value="medium">{{ $t('common.priorityMedium') }}</a-select-option>
<a-select-option value="high">{{ $t('common.priorityHigh') }}</a-select-option>
<a-select-option value="low">{{
$t("common.priorityLow")
}}</a-select-option>
<a-select-option value="medium">{{
$t("common.priorityMedium")
}}</a-select-option>
<a-select-option value="high">{{
$t("common.priorityHigh")
}}</a-select-option>
</a-select>
</a-form-item>
</a-form>
@@ -136,95 +160,101 @@
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { message } from 'ant-design-vue'
import { SearchOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons-vue'
import { useI18n } from 'vue-i18n'
import { ref, computed, watch } from "vue";
import { message } from "ant-design-vue";
import {
SearchOutlined,
DeleteOutlined,
PlusOutlined,
} from "@ant-design/icons-vue";
import { useI18n } from "vue-i18n";
// 定义组件名称
defineOptions({
name: 'TaskDrawer',
})
name: "TaskDrawer",
});
const { t } = useI18n()
const { t } = useI18n();
const visible = defineModel('visible', { type: Boolean, default: false })
const visible = defineModel("visible", { type: Boolean, default: false });
const tasks = defineModel('tasks', { type: Array, default: () => [] })
const tasks = defineModel("tasks", { type: Array, default: () => [] });
// 搜索关键词
const searchKeyword = ref('')
const searchKeyword = ref("");
// 筛选类型:all, pending, completed
const filterType = ref('all')
const filterType = ref("all");
// 添加任务弹窗
const addTaskVisible = ref(false)
const addTaskVisible = ref(false);
const newTask = ref({
title: '',
priority: 'medium',
})
title: "",
priority: "medium",
});
// 统计数据
const totalTasks = computed(() => tasks.value.length)
const pendingTasks = computed(() => tasks.value.filter(t => !t.completed).length)
const completedTasks = computed(() => tasks.value.filter(t => t.completed).length)
const totalTasks = computed(() => tasks.value.length);
const pendingTasks = computed(
() => tasks.value.filter((t) => !t.completed).length,
);
const completedTasks = computed(
() => tasks.value.filter((t) => t.completed).length,
);
// 筛选后的任务列表
const filteredTasks = computed(() => {
let result = [...tasks.value]
let result = [...tasks.value];
// 按状态筛选
if (filterType.value === 'pending') {
result = result.filter(t => !t.completed)
} else if (filterType.value === 'completed') {
result = result.filter(t => t.completed)
if (filterType.value === "pending") {
result = result.filter((t) => !t.completed);
} else if (filterType.value === "completed") {
result = result.filter((t) => t.completed);
}
// 按关键词搜索
if (searchKeyword.value.trim()) {
const keyword = searchKeyword.value.toLowerCase()
result = result.filter(t =>
t.title.toLowerCase().includes(keyword)
)
const keyword = searchKeyword.value.toLowerCase();
result = result.filter((t) => t.title.toLowerCase().includes(keyword));
}
return result
})
return result;
});
// 切换任务状态
const toggleTask = (task) => {
task.completed = !task.completed
}
task.completed = !task.completed;
};
// 删除任务
const deleteTask = (id) => {
const index = tasks.value.findIndex(t => t.id === id)
const index = tasks.value.findIndex((t) => t.id === id);
if (index > -1) {
tasks.value.splice(index, 1)
message.success(t('common.deleted'))
tasks.value.splice(index, 1);
message.success(t("common.deleted"));
}
}
};
// 清空所有任务
const clearAllTasks = () => {
tasks.value = []
message.success(t('common.cleared'))
}
tasks.value = [];
message.success(t("common.cleared"));
};
// 显示添加任务弹窗
const showAddTask = () => {
newTask.value = {
title: '',
priority: 'medium',
}
addTaskVisible.value = true
}
title: "",
priority: "medium",
};
addTaskVisible.value = true;
};
// 确认添加任务
const confirmAddTask = () => {
if (!newTask.value.title.trim()) {
message.warning(t('common.pleaseEnterTaskTitle'))
return
message.warning(t("common.pleaseEnterTaskTitle"));
return;
}
tasks.value.unshift({
@@ -232,30 +262,30 @@ const confirmAddTask = () => {
title: newTask.value.title,
priority: newTask.value.priority,
completed: false,
time: t('common.justNow'),
})
time: t("common.justNow"),
});
addTaskVisible.value = false
message.success(t('common.added'))
}
addTaskVisible.value = false;
message.success(t("common.added"));
};
// 设置筛选类型
const setFilter = (type) => {
filterType.value = type
}
filterType.value = type;
};
// 搜索处理
const handleSearch = () => {
// 搜索逻辑在 computed 中自动处理
}
};
// 监听抽窗关闭,重置搜索和筛选
watch(visible, (newVal) => {
if (!newVal) {
searchKeyword.value = ''
filterType.value = 'all'
searchKeyword.value = "";
filterType.value = "all";
}
})
});
</script>
<style scoped lang="scss">
File diff suppressed because it is too large Load Diff
+171 -105
View File
@@ -5,12 +5,21 @@
<!-- 第一个侧边栏显示一级菜单 -->
<a-layout-sider theme="dark" width="70" class="left-sidebar">
<div class="logo-box">
<img src="@/assets/images/logo.png" alt="logo" class="logo-image" />
<img
src="@/assets/images/logo.png"
alt="logo"
class="logo-image"
/>
</div>
<ul class="left-nav">
<li v-for="(item, index) in menuList" :key="index"
:class="{ active: selectedParentMenu?.path === item.path }"
@click="handleParentMenuClick(item)">
<li
v-for="(item, index) in menuList"
:key="index"
:class="{
active: selectedParentMenu?.path === item.path,
}"
@click="handleParentMenuClick(item)"
>
<component :is="getIconComponent(item.meta?.icon)" />
<span>{{ item.title }}</span>
</li>
@@ -19,16 +28,37 @@
<!-- 第二个侧边栏显示选中的父菜单的子菜单 -->
<a-layout-sider
v-if="selectedParentMenu && selectedParentMenu.children && selectedParentMenu.children.length > 0"
theme="light" :collapsed="sidebarCollapsed" :collapsible="true" @collapse="handleCollapse" width="200"
:collapsed-width="64" class="right-sidebar">
v-if="
selectedParentMenu &&
selectedParentMenu.children &&
selectedParentMenu.children.length > 0
"
theme="light"
:collapsed="sidebarCollapsed"
:collapsible="true"
@collapse="handleCollapse"
width="200"
:collapsed-width="64"
class="right-sidebar"
>
<div class="parent-title">
<component :is="getIconComponent(selectedParentMenu.meta?.icon)" />
<span v-if="!sidebarCollapsed">{{ selectedParentMenu.title }}</span>
<component
:is="getIconComponent(selectedParentMenu.meta?.icon)"
/>
<span v-if="!sidebarCollapsed">{{
selectedParentMenu.title
}}</span>
</div>
<a-menu v-model:openKeys="openKeys" v-model:selectedKeys="selectedKeys" mode="inline"
:selected-keys="[route.path]">
<navMenu :menu-items="selectedParentMenu.children" :active-path="route.path" />
<a-menu
v-model:openKeys="openKeys"
v-model:selectedKeys="selectedKeys"
mode="inline"
:selected-keys="[route.path]"
>
<navMenu
:menu-items="selectedParentMenu.children"
:active-path="route.path"
/>
</a-menu>
</a-layout-sider>
@@ -52,15 +82,32 @@
<!-- Menu布局:左侧菜单栏布局 -->
<template v-else-if="layoutMode === 'menu'">
<a-layout-sider theme="light" style="border-right: 1px solid #f0f0f0" :collapsed="sidebarCollapsed"
:collapsible="true" @collapse="handleCollapse" class="full-menu-sidebar" width="200"
:collapsed-width="64">
<a-layout-sider
theme="light"
style="border-right: 1px solid #f0f0f0"
:collapsed="sidebarCollapsed"
:collapsible="true"
@collapse="handleCollapse"
class="full-menu-sidebar"
width="200"
:collapsed-width="64"
>
<div class="logo-box-full">
<img src="@/assets/images/logo.png" alt="logo" class="logo-image" />
<span v-if="!sidebarCollapsed" class="app-name">{{ config.APP_NAME }}</span>
<img
src="@/assets/images/logo.png"
alt="logo"
class="logo-image"
/>
<span v-if="!sidebarCollapsed" class="app-name">{{
config.APP_NAME
}}</span>
</div>
<a-menu v-model:openKeys="openKeys" v-model:selectedKeys="selectedKeys" mode="inline"
:selected-keys="[route.path]">
<a-menu
v-model:openKeys="openKeys"
v-model:selectedKeys="selectedKeys"
mode="inline"
:selected-keys="[route.path]"
>
<navMenu :menu-items="menuList" :active-path="route.path" />
</a-menu>
</a-layout-sider>
@@ -87,12 +134,23 @@
<a-layout-header class="app-header top-header">
<div class="top-header-left">
<div class="logo-box-top">
<img src="@/assets/images/logo.png" alt="logo" class="logo-image" />
<img
src="@/assets/images/logo.png"
alt="logo"
class="logo-image"
/>
<span class="app-name">{{ config.APP_NAME }}</span>
</div>
<a-menu v-model:selectedKeys="selectedKeys" mode="horizontal" :selected-keys="[route.path]"
style="line-height: 60px">
<navMenu :menu-items="menuList" :active-path="route.path" />
<a-menu
v-model:selectedKeys="selectedKeys"
mode="horizontal"
:selected-keys="[route.path]"
style="line-height: 60px"
>
<navMenu
:menu-items="menuList"
:active-path="route.path"
/>
</a-menu>
</div>
<userbar />
@@ -120,139 +178,143 @@
</template>
<script setup>
import { computed, ref, watch, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useLayoutStore } from '@/stores/modules/layout'
import { useUserStore } from '@/stores/modules/user'
import { SettingOutlined } from '@ant-design/icons-vue'
import * as icons from '@ant-design/icons-vue'
import config from '@/config/index.js'
import { computed, ref, watch, onMounted } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useLayoutStore } from "@/stores/modules/layout";
import { useUserStore } from "@/stores/modules/user";
import { SettingOutlined } from "@ant-design/icons-vue";
import * as icons from "@ant-design/icons-vue";
import config from "@/config/index.js";
import userbar from './components/userbar.vue'
import navMenu from './components/navMenu.vue'
import breadcrumb from './components/breadcrumb.vue'
import tags from './components/tags.vue'
import setting from './components/setting.vue'
import userbar from "./components/userbar.vue";
import navMenu from "./components/navMenu.vue";
import breadcrumb from "./components/breadcrumb.vue";
import tags from "./components/tags.vue";
import setting from "./components/setting.vue";
// 定义组件名称(多词命名)
defineOptions({
name: 'AppLayouts',
})
name: "AppLayouts",
});
const route = useRoute()
const router = useRouter()
const layoutStore = useLayoutStore()
const userStore = useUserStore()
const route = useRoute();
const router = useRouter();
const layoutStore = useLayoutStore();
const userStore = useUserStore();
const settingRef = ref(null)
const settingRef = ref(null);
const layoutMode = computed(() => layoutStore.layoutMode)
const sidebarCollapsed = computed(() => layoutStore.sidebarCollapsed)
const selectedParentMenu = computed(() => layoutStore.selectedParentMenu)
const layoutMode = computed(() => layoutStore.layoutMode);
const sidebarCollapsed = computed(() => layoutStore.sidebarCollapsed);
const selectedParentMenu = computed(() => layoutStore.selectedParentMenu);
// 缓存的视图列表
const cachedViews = computed(() => {
return layoutStore.viewTags.filter((tag) => !tag.meta?.noCache).map((tag) => tag.name)
})
return layoutStore.viewTags
.filter((tag) => !tag.meta?.noCache)
.map((tag) => tag.name);
});
// 布局类名
const layoutClass = computed(() => {
return {
'layout-default': layoutMode.value === 'default',
'layout-menu': layoutMode.value === 'menu',
'layout-top': layoutMode.value === 'top',
'is-collapse': sidebarCollapsed.value,
}
})
"layout-default": layoutMode.value === "default",
"layout-menu": layoutMode.value === "menu",
"layout-top": layoutMode.value === "top",
"is-collapse": sidebarCollapsed.value,
};
});
// 获取刷新 key
const refreshKey = computed(() => layoutStore.refreshKey)
const refreshKey = computed(() => layoutStore.refreshKey);
const openKeys = ref([])
const selectedKeys = ref([])
const openKeys = ref([]);
const selectedKeys = ref([]);
const menuList = computed(() => {
return userStore.menu
})
return userStore.menu;
});
// 获取图标组件
const getIconComponent = (iconName) => {
return icons[iconName] || icons.FileTextOutlined
}
return icons[iconName] || icons.FileTextOutlined;
};
// 处理父菜单点击(默认布局的第一级菜单)
const handleParentMenuClick = (item) => {
// 设置选中的父菜单
layoutStore.setSelectedParentMenu(item)
layoutStore.setSelectedParentMenu(item);
// 如果没有子菜单,直接跳转
if (!item.children || item.children.length === 0) {
if (item.path) {
router.push(item.path)
router.push(item.path);
}
} else {
// 默认展开第一个子菜单
if (item.children.length > 0 && item.children[0].path) {
router.push(item.children[0].path)
router.push(item.children[0].path);
}
}
}
};
// 处理折叠
const handleCollapse = (collapsed) => {
layoutStore.sidebarCollapsed = collapsed
}
layoutStore.sidebarCollapsed = collapsed;
};
// 打开设置抽屉
const openSetting = () => {
settingRef.value?.openDrawer()
}
settingRef.value?.openDrawer();
};
// 更新选中的菜单和展开的菜单
const updateMenuState = () => {
selectedKeys.value = [route.path]
selectedKeys.value = [route.path];
// 获取所有父级路径
const matched = route.matched.filter((item) => item.path !== '/' && item.path !== route.path)
const parentPaths = matched.map((item) => item.path)
const matched = route.matched.filter(
(item) => item.path !== "/" && item.path !== route.path,
);
const parentPaths = matched.map((item) => item.path);
// 对于不同的布局模式,处理方式不同
if (layoutMode.value === 'default') {
if (layoutMode.value === "default") {
// 默认布局:找到当前路由对应的父菜单
const currentMenu = findMenuByPath(menuList.value, route.path)
const currentMenu = findMenuByPath(menuList.value, route.path);
if (currentMenu) {
// 如果当前菜单有子菜单,设置为选中的父菜单
if (currentMenu.children && currentMenu.children.length > 0) {
layoutStore.setSelectedParentMenu(currentMenu)
layoutStore.setSelectedParentMenu(currentMenu);
} else {
// 如果当前菜单是子菜单,找到它的父菜单
const parentMenu = findParentMenu(menuList.value, route.path)
const parentMenu = findParentMenu(menuList.value, route.path);
if (parentMenu) {
layoutStore.setSelectedParentMenu(parentMenu)
layoutStore.setSelectedParentMenu(parentMenu);
} else {
layoutStore.setSelectedParentMenu(currentMenu)
layoutStore.setSelectedParentMenu(currentMenu);
}
}
}
} else if (!sidebarCollapsed.value) {
// 其他布局模式:展开所有父级菜单
openKeys.value = parentPaths
openKeys.value = parentPaths;
}
}
};
// 根据路径查找菜单
const findMenuByPath = (menus, path) => {
for (const menu of menus) {
if (menu.path === path) {
return menu
return menu;
}
if (menu.children && menu.children.length > 0) {
const found = findMenuByPath(menu.children, path)
const found = findMenuByPath(menu.children, path);
if (found) {
return found
return found;
}
}
}
return null
}
return null;
};
// 查找父菜单
const findParentMenu = (menus, path) => {
@@ -260,58 +322,62 @@ const findParentMenu = (menus, path) => {
if (menu.children && menu.children.length > 0) {
for (const child of menu.children) {
if (child.path === path) {
return menu
return menu;
}
if (child.children && child.children.length > 0) {
const found = findParentMenu([child], path)
const found = findParentMenu([child], path);
if (found) {
return menu
return menu;
}
}
}
}
}
return null
}
return null;
};
// 监听路由变化,更新菜单状态
watch(
() => route.path,
(newPath) => {
console.log('路由变化:', newPath)
updateMenuState()
console.log("路由变化:", newPath);
updateMenuState();
},
{ immediate: true },
)
);
// 监听布局模式变化,确保菜单状态正确
watch(
() => layoutMode.value,
() => {
updateMenuState()
updateMenuState();
},
)
);
// 监听折叠状态
watch(
() => sidebarCollapsed.value,
(val) => {
if (val) {
openKeys.value = []
openKeys.value = [];
} else {
updateMenuState()
updateMenuState();
}
},
)
);
// 初始化
onMounted(() => {
// 如果还没有选中的父菜单,默认选中第一个
if (layoutMode.value === 'default' && !selectedParentMenu.value && menuList.value.length > 0) {
layoutStore.setSelectedParentMenu(menuList.value[0])
if (
layoutMode.value === "default" &&
!selectedParentMenu.value &&
menuList.value.length > 0
) {
layoutStore.setSelectedParentMenu(menuList.value[0]);
}
updateMenuState()
})
updateMenuState();
});
</script>
<style scoped lang="scss">
@@ -467,14 +533,14 @@ onMounted(() => {
}
.ant-menu-submenu {
>.ant-menu-submenu-title {
> .ant-menu-submenu-title {
&:hover {
color: #1890ff;
}
}
&.ant-menu-submenu-open {
>.ant-menu-submenu-title {
> .ant-menu-submenu-title {
color: #1890ff;
}
}
@@ -569,7 +635,7 @@ onMounted(() => {
}
.ant-menu-submenu {
>.ant-menu-submenu-title {
> .ant-menu-submenu-title {
height: 44px;
line-height: 44px;
margin: 0;
@@ -581,7 +647,7 @@ onMounted(() => {
}
&.ant-menu-submenu-open {
>.ant-menu-submenu-title {
> .ant-menu-submenu-title {
color: #1890ff;
}
}
+31 -13
View File
@@ -9,7 +9,9 @@
<div class="not-found-content">
<div class="error-code">404</div>
<div class="error-title">页面未找到</div>
<div class="error-description">抱歉您访问的页面不存在或已被移除</div>
<div class="error-description">
抱歉您访问的页面不存在或已被移除
</div>
<div class="action-buttons">
<a-button type="primary" size="large" @click="goBack">
@@ -30,21 +32,21 @@
</template>
<script setup>
import { useRouter } from 'vue-router'
import { ArrowLeftOutlined, HomeOutlined } from '@ant-design/icons-vue'
import '@/assets/style/auth.scss'
import { useRouter } from "vue-router";
import { ArrowLeftOutlined, HomeOutlined } from "@ant-design/icons-vue";
import "@/assets/style/auth.scss";
const router = useRouter()
const router = useRouter();
// Go back to previous page
const goBack = () => {
router.back()
}
router.back();
};
// Go to home page
const goHome = () => {
router.push('/')
}
router.push("/");
};
</script>
<style scoped lang="scss">
@@ -54,7 +56,11 @@ const goHome = () => {
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--bg-gradient-start) 0%, var(--bg-gradient-end) 100%);
background: linear-gradient(
135deg,
var(--bg-gradient-start) 0%,
var(--bg-gradient-end) 100%
);
position: relative;
overflow: hidden;
@@ -122,7 +128,11 @@ const goHome = () => {
.error-code {
font-size: 120px;
font-weight: 700;
background: linear-gradient(135deg, var(--auth-primary-dark), var(--auth-primary));
background: linear-gradient(
135deg,
var(--auth-primary-dark),
var(--auth-primary)
);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
@@ -170,13 +180,21 @@ const goHome = () => {
border-radius: 12px;
&.ant-btn-primary {
background: linear-gradient(135deg, var(--auth-primary), var(--auth-primary-dark));
background: linear-gradient(
135deg,
var(--auth-primary),
var(--auth-primary-dark)
);
border: none;
box-shadow: 0 8px 24px rgba(255, 107, 53, 0.35);
transition: all 0.3s ease;
&:hover {
background: linear-gradient(135deg, var(--auth-primary-light), var(--auth-primary));
background: linear-gradient(
135deg,
var(--auth-primary-light),
var(--auth-primary)
);
transform: translateY(-2px);
box-shadow: 0 12px 32px rgba(255, 107, 53, 0.45);
}
+38 -19
View File
@@ -8,38 +8,45 @@
<div class="empty-content">
<div class="empty-icon">
<InboxOutlined :style="{ fontSize: '120px', color: '#ff6b35' }" />
<InboxOutlined
:style="{ fontSize: '120px', color: '#ff6b35' }"
/>
</div>
<div class="empty-title">暂无数据</div>
<div class="empty-description">
{{ description || '当前页面暂无数据,请稍后再试' }}
{{ description || "当前页面暂无数据,请稍后再试" }}
</div>
<a-button v-if="showButton" type="primary" size="large" @click="handleAction">
<a-button
v-if="showButton"
type="primary"
size="large"
@click="handleAction"
>
<template #icon v-if="buttonIcon">
<component :is="buttonIcon" />
</template>
{{ buttonText || '刷新页面' }}
{{ buttonText || "刷新页面" }}
</a-button>
</div>
</div>
</template>
<script setup>
import { InboxOutlined } from '@ant-design/icons-vue'
import { useRouter } from 'vue-router'
import { InboxOutlined } from "@ant-design/icons-vue";
import { useRouter } from "vue-router";
defineOptions({
name: 'EmptyPage',
})
name: "EmptyPage",
});
const router = useRouter()
const router = useRouter();
defineProps({
description: {
type: String,
default: '当前页面暂无数据,请稍后再试',
default: "当前页面暂无数据,请稍后再试",
},
showButton: {
type: Boolean,
@@ -47,21 +54,21 @@ defineProps({
},
buttonText: {
type: String,
default: '刷新页面',
default: "刷新页面",
},
buttonIcon: {
type: [String, Object],
default: null,
},
})
});
const emit = defineEmits(['action'])
const emit = defineEmits(["action"]);
const handleAction = () => {
emit('action')
emit("action");
// Default behavior: refresh page
router.go(0)
}
router.go(0);
};
</script>
<style scoped lang="scss">
@@ -71,7 +78,11 @@ const handleAction = () => {
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--bg-gradient-start) 0%, var(--bg-gradient-end) 100%);
background: linear-gradient(
135deg,
var(--bg-gradient-start) 0%,
var(--bg-gradient-end) 100%
);
position: relative;
overflow: hidden;
@@ -171,14 +182,22 @@ const handleAction = () => {
padding: 0 40px;
font-size: 16px;
font-weight: 600;
background: linear-gradient(135deg, var(--auth-primary), var(--auth-primary-dark));
background: linear-gradient(
135deg,
var(--auth-primary),
var(--auth-primary-dark)
);
border: none;
border-radius: 12px;
box-shadow: 0 8px 24px rgba(255, 107, 53, 0.35);
transition: all 0.3s ease;
&:hover {
background: linear-gradient(135deg, var(--auth-primary-light), var(--auth-primary));
background: linear-gradient(
135deg,
var(--auth-primary-light),
var(--auth-primary)
);
transform: translateY(-2px);
box-shadow: 0 12px 32px rgba(255, 107, 53, 0.45);
}
+16 -16
View File
@@ -1,20 +1,20 @@
import { createApp } from 'vue'
import { createApp } from "vue";
import Antd from 'ant-design-vue'
import 'ant-design-vue/dist/reset.css'
import '@/assets/style/app.scss'
import App from './App.vue'
import router from './router'
import pinia from './stores'
import i18n from './i18n'
import boot from './boot'
import Antd from "ant-design-vue";
import "ant-design-vue/dist/reset.css";
import "@/assets/style/app.scss";
import App from "./App.vue";
import router from "./router";
import pinia from "./stores";
import i18n from "./i18n";
import boot from "./boot";
const app = createApp(App)
const app = createApp(App);
app.use(Antd)
app.use(router)
app.use(pinia)
app.use(i18n)
app.use(boot)
app.use(Antd);
app.use(router);
app.use(pinia);
app.use(i18n);
app.use(boot);
app.mount('#app')
app.mount("#app");
@@ -1,25 +1,61 @@
<template>
<a-modal :title="titleMap[mode]" :open="visible" :width="500" :destroy-on-close="true" :footer="null"
@cancel="handleCancel">
<a-form :model="form" :rules="rules" :disabled="mode === 'show'" ref="dialogForm" :label-col="{ span: 5 }"
:wrapper-col="{ span: 18 }">
<a-modal
:title="titleMap[mode]"
:open="visible"
:width="500"
:destroy-on-close="true"
:footer="null"
@cancel="handleCancel"
>
<a-form
:model="form"
:rules="rules"
:disabled="mode === 'show'"
ref="dialogForm"
:label-col="{ span: 5 }"
:wrapper-col="{ span: 18 }"
>
<a-form-item label="上级部门" name="parent_id">
<a-tree-select v-model:value="form.parent_id" :tree-data="filteredDepartments"
:field-names="departmentFieldNames" :tree-default-expand-all="false" placeholder="请选择上级部门"
allow-clear tree-node-filter-prop="name"
:dropdown-style="{ maxHeight: '400px', overflow: 'auto' }" />
<a-tree-select
v-model:value="form.parent_id"
:tree-data="filteredDepartments"
:field-names="departmentFieldNames"
:tree-default-expand-all="false"
placeholder="请选择上级部门"
allow-clear
tree-node-filter-prop="name"
:dropdown-style="{ maxHeight: '400px', overflow: 'auto' }"
/>
</a-form-item>
<a-form-item label="部门名称" name="name">
<a-input v-model:value="form.name" placeholder="请输入部门名称" allow-clear></a-input>
<a-input
v-model:value="form.name"
placeholder="请输入部门名称"
allow-clear
></a-input>
</a-form-item>
<a-form-item label="负责人" name="leader">
<a-input v-model:value="form.leader" placeholder="请输入负责人" allow-clear></a-input>
<a-input
v-model:value="form.leader"
placeholder="请输入负责人"
allow-clear
></a-input>
</a-form-item>
<a-form-item label="联系电话" name="phone">
<a-input v-model:value="form.phone" placeholder="请输入联系电话" allow-clear></a-input>
<a-input
v-model:value="form.phone"
placeholder="请输入联系电话"
allow-clear
></a-input>
</a-form-item>
<a-form-item label="排序" name="sort">
<a-input-number v-model:value="form.sort" :min="0" :max="10000" style="width: 100%" placeholder="请输入排序" />
<a-input-number
v-model:value="form.sort"
:min="0"
:max="10000"
style="width: 100%"
placeholder="请输入排序"
/>
</a-form-item>
<a-form-item label="状态" name="status">
<a-radio-group v-model:value="form.status">
@@ -29,7 +65,13 @@
</a-form-item>
<a-form-item :wrapper-col="{ offset: 5 }">
<div style="display: flex; gap: 10px">
<a-button v-if="mode !== 'show'" type="primary" :loading="isSaveing" @click="submit"> </a-button>
<a-button
v-if="mode !== 'show'"
type="primary"
:loading="isSaveing"
@click="submit"
> </a-button
>
<a-button @click="handleCancel"> </a-button>
</div>
</a-form-item>
@@ -38,161 +80,163 @@
</template>
<script setup>
import { ref, reactive, computed } from 'vue'
import { message } from 'ant-design-vue'
import authApi from '@/api/auth'
import { ref, reactive, computed } from "vue";
import { message } from "ant-design-vue";
import authApi from "@/api/auth";
defineOptions({
name: 'DepartmentSaveDialog'
})
name: "DepartmentSaveDialog",
});
const emit = defineEmits(['success', 'closed'])
const emit = defineEmits(["success", "closed"]);
const mode = ref('add')
const mode = ref("add");
const titleMap = {
add: '新增部门',
edit: '编辑部门',
show: '查看部门'
}
const visible = ref(false)
const isSaveing = ref(false)
add: "新增部门",
edit: "编辑部门",
show: "查看部门",
};
const visible = ref(false);
const isSaveing = ref(false);
// 表单数据
const form = reactive({
id: '',
name: '',
leader: '',
phone: '',
id: "",
name: "",
leader: "",
phone: "",
sort: 0,
parent_id: null,
status: 1
})
status: 1,
});
// 表单引用
const dialogForm = ref()
const dialogForm = ref();
// 验证规则
const rules = {
name: [{ required: true, message: '请输入部门名称', trigger: 'blur' }],
name: [{ required: true, message: "请输入部门名称", trigger: "blur" }],
sort: [
{ required: true, message: '请输入排序', trigger: 'change' },
{ type: 'number', message: '排序必须为数字', trigger: 'change' }
]
}
{ required: true, message: "请输入排序", trigger: "change" },
{ type: "number", message: "排序必须为数字", trigger: "change" },
],
};
// 部门数据
const departments = ref([])
const departments = ref([]);
const departmentFieldNames = {
label: 'name',
value: 'id',
children: 'children'
}
label: "name",
value: "id",
children: "children",
};
// 当前编辑的部门ID(用于过滤树)
const currentEditId = ref(null)
const currentEditId = ref(null);
// 过滤后的部门树(编辑时排除自己和子部门)
const filteredDepartments = computed(() => {
if (mode.value === 'add') {
return departments.value
if (mode.value === "add") {
return departments.value;
}
return filterDepartments(departments.value, currentEditId.value)
})
return filterDepartments(departments.value, currentEditId.value);
});
// 递归过滤部门树
const filterDepartments = (tree, excludeId) => {
return tree
.filter(item => item.id !== excludeId)
.map(item => ({
.filter((item) => item.id !== excludeId)
.map((item) => ({
...item,
children: item.children ? filterDepartments(item.children, excludeId) : undefined
}))
}
children: item.children
? filterDepartments(item.children, excludeId)
: undefined,
}));
};
// 显示对话框
const open = (openMode = 'add') => {
mode.value = openMode
visible.value = true
const open = (openMode = "add") => {
mode.value = openMode;
visible.value = true;
return {
setData,
open,
close
}
}
close,
};
};
// 关闭对话框
const close = () => {
visible.value = false
}
visible.value = false;
};
// 处理取消
const handleCancel = () => {
emit('closed')
visible.value = false
}
emit("closed");
visible.value = false;
};
// 表单提交方法
const submit = async () => {
try {
await dialogForm.value.validate()
isSaveing.value = true
let res = {}
form.parent_id = form.parent_id || 0
await dialogForm.value.validate();
isSaveing.value = true;
let res = {};
form.parent_id = form.parent_id || 0;
if (mode.value === 'add') {
res = await authApi.departments.add.post(form)
if (mode.value === "add") {
res = await authApi.departments.add.post(form);
} else {
res = await authApi.departments.edit.put(form.id, form)
res = await authApi.departments.edit.put(form.id, form);
}
isSaveing.value = false
isSaveing.value = false;
if (res.code === 200) {
emit('success', form, mode.value)
visible.value = false
message.success('操作成功')
emit("success", form, mode.value);
visible.value = false;
message.success("操作成功");
} else {
message.error(res.message || '操作失败')
message.error(res.message || "操作失败");
}
} catch (error) {
console.error('表单验证失败', error)
isSaveing.value = false
console.error("表单验证失败", error);
isSaveing.value = false;
}
}
};
// 加载部门树数据
const loadDepartments = async () => {
try {
const res = await authApi.departments.tree.get()
const res = await authApi.departments.tree.get();
if (res.code === 200) {
departments.value = res.data || []
departments.value = res.data || [];
}
} catch (error) {
console.error('加载部门树失败:', error)
message.error('加载部门树失败')
console.error("加载部门树失败:", error);
message.error("加载部门树失败");
}
}
};
// 表单注入数据
const setData = (data) => {
form.id = data.id
currentEditId.value = data.id
form.name = data.name
form.leader = data.leader || ''
form.phone = data.phone || ''
form.sort = data.sort || 0
form.parent_id = data.parent_id || null
form.status = data.status !== undefined ? data.status : 1
}
form.id = data.id;
currentEditId.value = data.id;
form.name = data.name;
form.leader = data.leader || "";
form.phone = data.phone || "";
form.sort = data.sort || 0;
form.parent_id = data.parent_id || null;
form.status = data.status !== undefined ? data.status : 1;
};
// 组件挂载时加载数据
loadDepartments()
loadDepartments();
// 暴露方法给父组件
defineExpose({
open,
setData,
close
})
close,
});
</script>
<style></style>
@@ -4,8 +4,18 @@
<div class="tool-bar">
<div class="left-panel">
<a-space>
<a-input v-model:value="searchForm.keyword" placeholder="部门名称" allow-clear style="width: 200px" />
<a-select v-model:value="searchForm.status" placeholder="状态" allow-clear style="width: 120px">
<a-input
v-model:value="searchForm.keyword"
placeholder="部门名称"
allow-clear
style="width: 200px"
/>
<a-select
v-model:value="searchForm.status"
placeholder="状态"
allow-clear
style="width: 120px"
>
<a-select-option :value="1">正常</a-select-option>
<a-select-option :value="0">禁用</a-select-option>
</a-select>
@@ -25,11 +35,17 @@
<template #icon><plus-outlined /></template>
新增
</a-button>
<a-button :disabled="selectedRows.length === 0" @click="handleBatchStatus(1)">
<a-button
:disabled="selectedRows.length === 0"
@click="handleBatchStatus(1)"
>
<template #icon><check-circle-outlined /></template>
启用
</a-button>
<a-button :disabled="selectedRows.length === 0" @click="handleBatchStatus(0)">
<a-button
:disabled="selectedRows.length === 0"
@click="handleBatchStatus(0)"
>
<template #icon><stop-outlined /></template>
禁用
</a-button>
@@ -41,16 +57,22 @@
<template #overlay>
<a-menu>
<a-menu-item @click="handleExport">
<template #icon><download-outlined /></template>
<template #icon
><download-outlined
/></template>
导出
</a-menu-item>
<a-menu-item @click="handleImport">
<template #icon><upload-outlined /></template>
<template #icon
><upload-outlined
/></template>
导入
</a-menu-item>
<a-menu-divider />
<a-menu-item danger @click="handleBatchDelete">
<template #icon><delete-outlined /></template>
<template #icon
><delete-outlined
/></template>
批量删除
</a-menu-item>
</a-menu>
@@ -77,20 +99,31 @@
>
<template #status="{ record }">
<a-tag :color="record.status === 1 ? 'green' : 'red'">
{{ record.status === 1 ? '正常' : '禁用' }}
{{ record.status === 1 ? "正常" : "禁用" }}
</a-tag>
</template>
<template #action="{ record }">
<a-space>
<a-button type="link" size="small" @click="handleView(record)">
<a-button
type="link"
size="small"
@click="handleView(record)"
>
<template #icon><eye-outlined /></template>
查看
</a-button>
<a-button type="link" size="small" @click="handleEdit(record)">
<a-button
type="link"
size="small"
@click="handleEdit(record)"
>
<template #icon><edit-outlined /></template>
编辑
</a-button>
<a-popconfirm title="确定删除该部门吗?如果该部门下有子部门或用户,将无法删除" @confirm="handleDelete(record)">
<a-popconfirm
title="确定删除该部门吗?如果该部门下有子部门或用户,将无法删除"
@confirm="handleDelete(record)"
>
<a-button type="link" size="small" danger>
<template #icon><delete-outlined /></template>
删除
@@ -103,31 +136,48 @@
</div>
<!-- 新增/编辑/查看部门弹窗 -->
<save-dialog v-if="dialog.save" ref="saveDialogRef" @success="handleSaveSuccess" @closed="dialog.save = false" />
<save-dialog
v-if="dialog.save"
ref="saveDialogRef"
@success="handleSaveSuccess"
@closed="dialog.save = false"
/>
<!-- 导入部门弹窗 -->
<sc-import v-model:open="dialog.import" title="导入部门" :api="authApi.departments.import.post"
:template-api="authApi.departments.downloadTemplate.get" filename="部门" @success="handleImportSuccess" />
<sc-import
v-model:open="dialog.import"
title="导入部门"
:api="authApi.departments.import.post"
:template-api="authApi.departments.downloadTemplate.get"
filename="部门"
@success="handleImportSuccess"
/>
<!-- 导出部门弹窗 -->
<sc-export v-model:open="dialog.export" title="导出部门" :api="handleExportApi"
:default-filename="`部门列表_${Date.now()}`" :show-options="false" tip="导出当前选中或所有部门数据"
@success="handleExportSuccess" />
<sc-export
v-model:open="dialog.export"
title="导出部门"
:api="handleExportApi"
:default-filename="`部门列表_${Date.now()}`"
:show-options="false"
tip="导出当前选中或所有部门数据"
@success="handleExportSuccess"
/>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { message, Modal } from 'ant-design-vue'
import scTable from '@/components/scTable/index.vue'
import scImport from '@/components/scImport/index.vue'
import scExport from '@/components/scExport/index.vue'
import saveDialog from './components/SaveDialog.vue'
import authApi from '@/api/auth'
import { useTable } from '@/hooks/useTable'
import { ref, reactive, onMounted } from "vue";
import { message, Modal } from "ant-design-vue";
import scTable from "@/components/scTable/index.vue";
import scImport from "@/components/scImport/index.vue";
import scExport from "@/components/scExport/index.vue";
import saveDialog from "./components/SaveDialog.vue";
import authApi from "@/api/auth";
import { useTable } from "@/hooks/useTable";
defineOptions({
name: 'authDepartment'
})
name: "authDepartment",
});
// 使用useTable hooks
const {
@@ -141,207 +191,239 @@ const {
handleReset,
handleSelectChange,
handleSelectAll,
refreshTable
refreshTable,
} = useTable({
api: authApi.departments.tree.get,
searchForm: {
keyword: '',
status: null
keyword: "",
status: null,
},
columns: [],
needPagination: false,
needSelection: true,
immediateLoad: false
})
immediateLoad: false,
});
// 对话框状态
const dialog = reactive({
save: false,
import: false,
export: false
})
export: false,
});
// 弹窗引用
const saveDialogRef = ref(null)
const saveDialogRef = ref(null);
// 行key
const rowKey = 'id'
const rowKey = "id";
// 表格列配置
const columns = [
{ title: '#', dataIndex: '_index', key: '_index', width: 60, align: 'center' },
{ title: '部门名称', dataIndex: 'name', key: 'name', width: 300 },
{ title: '负责人', dataIndex: 'leader', key: 'leader', width: 120 },
{ title: '联系电话', dataIndex: 'phone', key: 'phone', width: 150 },
{ title: '排序', dataIndex: 'sort', key: 'sort', width: 100, align: 'center' },
{ title: '状态', dataIndex: 'status', key: 'status', width: 100, align: 'center', slot: 'status' },
{ title: '操作', dataIndex: 'action', key: 'action', width: 220, align: 'center', slot: 'action', fixed: 'right' }
]
{
title: "#",
dataIndex: "_index",
key: "_index",
width: 60,
align: "center",
},
{ title: "部门名称", dataIndex: "name", key: "name", width: 300 },
{ title: "负责人", dataIndex: "leader", key: "leader", width: 120 },
{ title: "联系电话", dataIndex: "phone", key: "phone", width: 150 },
{
title: "排序",
dataIndex: "sort",
key: "sort",
width: 100,
align: "center",
},
{
title: "状态",
dataIndex: "status",
key: "status",
width: 100,
align: "center",
slot: "status",
},
{
title: "操作",
dataIndex: "action",
key: "action",
width: 220,
align: "center",
slot: "action",
fixed: "right",
},
];
// 新增部门
const handleAdd = () => {
dialog.save = true
dialog.save = true;
setTimeout(() => {
saveDialogRef.value?.open('add')
}, 0)
}
saveDialogRef.value?.open("add");
}, 0);
};
// 查看部门
const handleView = (record) => {
dialog.save = true
dialog.save = true;
setTimeout(() => {
saveDialogRef.value?.open('show').setData(record)
}, 0)
}
saveDialogRef.value?.open("show").setData(record);
}, 0);
};
// 编辑部门
const handleEdit = (record) => {
dialog.save = true
dialog.save = true;
setTimeout(() => {
saveDialogRef.value?.open('edit').setData(record)
}, 0)
}
saveDialogRef.value?.open("edit").setData(record);
}, 0);
};
// 删除部门
const handleDelete = async (record) => {
try {
const res = await authApi.departments.delete.delete(record.id)
const res = await authApi.departments.delete.delete(record.id);
if (res.code === 200) {
message.success('删除成功')
refreshTable()
message.success("删除成功");
refreshTable();
} else {
message.error(res.message || '删除失败')
message.error(res.message || "删除失败");
}
} catch (error) {
console.error('删除部门失败:', error)
console.error("删除部门失败:", error);
// 如果是验证错误,显示具体错误信息
if (error.response?.data?.message) {
message.error(error.response.data.message)
message.error(error.response.data.message);
} else {
message.error('删除失败')
message.error("删除失败");
}
}
}
};
// 批量删除
const handleBatchDelete = () => {
if (selectedRows.value.length === 0) {
message.warning('请选择要删除的部门')
return
message.warning("请选择要删除的部门");
return;
}
Modal.confirm({
title: '确认删除',
title: "确认删除",
content: `确定删除选中的 ${selectedRows.value.length} 个部门吗?如果删除项中含有子集或用户,将会被一并删除`,
okText: '确定',
cancelText: '取消',
okType: 'danger',
okText: "确定",
cancelText: "取消",
okType: "danger",
onOk: async () => {
try {
const ids = selectedRows.value.map(item => item.id)
const res = await authApi.departments.batchDelete.post({ ids })
const ids = selectedRows.value.map((item) => item.id);
const res = await authApi.departments.batchDelete.post({ ids });
if (res.code === 200) {
message.success(res.message || '删除成功')
selectedRows.value = []
refreshTable()
message.success(res.message || "删除成功");
selectedRows.value = [];
refreshTable();
} else {
message.error(res.message || '删除失败')
message.error(res.message || "删除失败");
}
} catch (error) {
console.error('批量删除部门失败:', error)
console.error("批量删除部门失败:", error);
if (error.response?.data?.message) {
message.error(error.response.data.message)
message.error(error.response.data.message);
} else {
message.error('删除失败')
message.error("删除失败");
}
}
}
})
}
},
});
};
// 批量更新状态
const handleBatchStatus = (status) => {
if (selectedRows.value.length === 0) {
message.warning('请选择要操作的部门')
return
message.warning("请选择要操作的部门");
return;
}
Modal.confirm({
title: '确认操作',
content: `确定${status === 1 ? '启用' : '禁用'}选中的 ${selectedRows.value.length} 个部门吗?`,
okText: '确定',
cancelText: '取消',
title: "确认操作",
content: `确定${status === 1 ? "启用" : "禁用"}选中的 ${selectedRows.value.length} 个部门吗?`,
okText: "确定",
cancelText: "取消",
onOk: async () => {
try {
const ids = selectedRows.value.map(item => item.id)
const res = await authApi.departments.batchStatus.post({ ids, status })
const ids = selectedRows.value.map((item) => item.id);
const res = await authApi.departments.batchStatus.post({
ids,
status,
});
if (res.code === 200) {
message.success(res.message || '操作成功')
selectedRows.value = []
refreshTable()
message.success(res.message || "操作成功");
selectedRows.value = [];
refreshTable();
} else {
message.error(res.message || '操作失败')
message.error(res.message || "操作失败");
}
} catch (error) {
console.error('批量更新状态失败:', error)
message.error('操作失败')
console.error("批量更新状态失败:", error);
message.error("操作失败");
}
}
})
}
},
});
};
// 导出部门
const handleExport = () => {
dialog.export = true
}
dialog.export = true;
};
// 导出API封装
const handleExportApi = async () => {
const ids = selectedRows.value.map(item => item.id)
return await authApi.departments.export.post({ ids: ids.length > 0 ? ids : undefined })
}
const ids = selectedRows.value.map((item) => item.id);
return await authApi.departments.export.post({
ids: ids.length > 0 ? ids : undefined,
});
};
// 导出成功回调
const handleExportSuccess = () => {
selectedRows.value = []
}
selectedRows.value = [];
};
// 导入部门
const handleImport = () => {
dialog.import = true
}
dialog.import = true;
};
// 导入成功回调
const handleImportSuccess = () => {
refreshTable()
}
refreshTable();
};
// 下载模板
const handleDownloadTemplate = async () => {
try {
const blob = await authApi.departments.downloadTemplate.get()
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = '部门导入模板.xlsx'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
message.success('下载成功')
const blob = await authApi.departments.downloadTemplate.get();
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "部门导入模板.xlsx";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
message.success("下载成功");
} catch (error) {
console.error('下载模板失败:', error)
message.error('下载失败')
console.error("下载模板失败:", error);
message.error("下载失败");
}
}
};
// 保存成功回调
const handleSaveSuccess = () => {
refreshTable()
}
refreshTable();
};
// 初始化
onMounted(() => {
refreshTable()
})
refreshTable();
});
</script>
@@ -5,7 +5,11 @@
<a-row :gutter="16">
<a-col :span="6">
<a-card>
<a-statistic title="在线用户总数" :value="onlineCount" :value-style="{ color: '#3f8600' }">
<a-statistic
title="在线用户总数"
:value="onlineCount"
:value-style="{ color: '#3f8600' }"
>
<template #prefix>
<UserOutlined style="font-size: 24px" />
</template>
@@ -16,22 +20,44 @@
<a-card>
<a-form layout="inline" :model="searchForm">
<a-form-item label="刷新间隔">
<a-select v-model:value="refreshInterval" style="width: 150px" @change="handleRefreshIntervalChange">
<a-select-option :value="0">不自动刷新</a-select-option>
<a-select-option :value="5000">5</a-select-option>
<a-select-option :value="10000">10</a-select-option>
<a-select-option :value="30000">30</a-select-option>
<a-select-option :value="60000">60</a-select-option>
<a-select
v-model:value="refreshInterval"
style="width: 150px"
@change="handleRefreshIntervalChange"
>
<a-select-option :value="0"
>不自动刷新</a-select-option
>
<a-select-option :value="5000"
>5</a-select-option
>
<a-select-option :value="10000"
>10</a-select-option
>
<a-select-option :value="30000"
>30</a-select-option
>
<a-select-option :value="60000"
>60</a-select-option
>
</a-select>
</a-form-item>
<a-form-item>
<a-space>
<a-button type="primary" @click="handleRefresh" :loading="loading">
<template #icon><ReloadOutlined /></template>
<a-button
type="primary"
@click="handleRefresh"
:loading="loading"
>
<template #icon
><ReloadOutlined
/></template>
刷新
</a-button>
<a-button @click="handleRefreshAllOffline">
<template #icon><StopOutlined /></template>
<template #icon
><StopOutlined
/></template>
全部下线
</a-button>
</a-space>
@@ -46,7 +72,12 @@
<div class="tool-bar">
<div class="left-panel">
<a-space>
<a-input v-model:value="searchForm.keyword" placeholder="用户名" allow-clear style="width: 200px" />
<a-input
v-model:value="searchForm.keyword"
placeholder="用户名"
allow-clear
style="width: 200px"
/>
<a-button type="primary" @click="handleSearch">
<template #icon><SearchOutlined /></template>
搜索
@@ -72,7 +103,7 @@
>
<template #status="{ record }">
<a-tag :color="record.is_online ? 'success' : 'default'">
{{ record.is_online ? '在线' : '离线' }}
{{ record.is_online ? "在线" : "离线" }}
</a-tag>
</template>
<template #lastActive="{ record }">
@@ -80,7 +111,11 @@
</template>
<template #action="{ record }">
<a-space>
<a-button type="link" size="small" @click="handleViewSessions(record)">
<a-button
type="link"
size="small"
@click="handleViewSessions(record)"
>
查看会话
</a-button>
<a-popconfirm
@@ -115,251 +150,292 @@
</template>
<script setup>
import { ref, reactive, onMounted, onUnmounted } from 'vue'
import { message, Modal } from 'ant-design-vue'
import { UserOutlined, SearchOutlined, RedoOutlined, ReloadOutlined, StopOutlined } from '@ant-design/icons-vue'
import scTable from '@/components/scTable/index.vue'
import sessionsDialog from './sessions.vue'
import authApi from '@/api/auth'
import { ref, reactive, onMounted, onUnmounted } from "vue";
import { message, Modal } from "ant-design-vue";
import {
UserOutlined,
SearchOutlined,
RedoOutlined,
ReloadOutlined,
StopOutlined,
} from "@ant-design/icons-vue";
import scTable from "@/components/scTable/index.vue";
import sessionsDialog from "./sessions.vue";
import authApi from "@/api/auth";
defineOptions({
name: 'authOnlineUsers'
})
name: "authOnlineUsers",
});
// 表格引用
const tableRef = ref(null)
const tableRef = ref(null);
// 搜索表单
const searchForm = reactive({
keyword: ''
})
keyword: "",
});
// 表格数据
const tableData = ref([])
const loading = ref(false)
const tableData = ref([]);
const loading = ref(false);
const pagination = reactive({
current: 1,
pageSize: 20,
total: 0,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `${total}`
})
showTotal: (total) => `${total}`,
});
// 行key
const rowKey = 'id'
const rowKey = "id";
// 在线用户数量
const onlineCount = ref(0)
const onlineCount = ref(0);
// 刷新定时器
const refreshInterval = ref(30000) // 默认30秒
let refreshTimer = null
const refreshInterval = ref(30000); // 默认30秒
let refreshTimer = null;
// 对话框状态
const dialog = reactive({
sessions: false
})
sessions: false,
});
// 弹窗引用
const sessionsDialogRef = ref(null)
const sessionsDialogRef = ref(null);
// 表格列配置
const columns = [
{ title: '#', dataIndex: '_index', key: '_index', width: 60, align: 'center' },
{ title: '用户名', dataIndex: 'username', key: 'username', width: 150 },
{ title: '真实姓名', dataIndex: 'real_name', key: 'real_name', width: 150 },
{ title: '邮箱', dataIndex: 'email', key: 'email', width: 200 },
{ title: '手机号', dataIndex: 'phone', key: 'phone', width: 150 },
{ title: '状态', dataIndex: 'status', key: 'status', width: 100, align: 'center', slot: 'status' },
{ title: '最后活跃时间', dataIndex: 'last_active_at', key: 'last_active_at', width: 180, slot: 'lastActive' },
{ title: '最后登录IP', dataIndex: 'last_login_ip', key: 'last_login_ip', width: 150 },
{ title: '操作', dataIndex: 'action', key: 'action', width: 200, align: 'center', slot: 'action', fixed: 'right' }
]
{
title: "#",
dataIndex: "_index",
key: "_index",
width: 60,
align: "center",
},
{ title: "用户名", dataIndex: "username", key: "username", width: 150 },
{ title: "真实姓名", dataIndex: "real_name", key: "real_name", width: 150 },
{ title: "邮箱", dataIndex: "email", key: "email", width: 200 },
{ title: "手机号", dataIndex: "phone", key: "phone", width: 150 },
{
title: "状态",
dataIndex: "status",
key: "status",
width: 100,
align: "center",
slot: "status",
},
{
title: "最后活跃时间",
dataIndex: "last_active_at",
key: "last_active_at",
width: 180,
slot: "lastActive",
},
{
title: "最后登录IP",
dataIndex: "last_login_ip",
key: "last_login_ip",
width: 150,
},
{
title: "操作",
dataIndex: "action",
key: "action",
width: 200,
align: "center",
slot: "action",
fixed: "right",
},
];
// 加载在线用户数量
const loadOnlineCount = async () => {
try {
const res = await authApi.onlineUsers.count.get()
const res = await authApi.onlineUsers.count.get();
if (res.code === 200) {
onlineCount.value = res.data || 0
onlineCount.value = res.data || 0;
}
} catch (error) {
console.error('获取在线用户数量失败:', error)
console.error("获取在线用户数量失败:", error);
}
}
};
// 加载在线用户列表
const loadOnlineUsers = async () => {
try {
loading.value = true
loading.value = true;
const params = {
...searchForm,
limit: pagination.pageSize
}
const res = await authApi.onlineUsers.list.get(params)
loading.value = false
limit: pagination.pageSize,
};
const res = await authApi.onlineUsers.list.get(params);
loading.value = false;
if (res.code === 200) {
// 添加序号
const list = res.data?.list || []
const list = res.data?.list || [];
tableData.value = list.map((item, index) => ({
...item,
_index: (pagination.current - 1) * pagination.pageSize + index + 1
}))
pagination.total = res.data?.total || 0
_index:
(pagination.current - 1) * pagination.pageSize + index + 1,
}));
pagination.total = res.data?.total || 0;
}
} catch (error) {
console.error('加载在线用户列表失败:', error)
loading.value = false
console.error("加载在线用户列表失败:", error);
loading.value = false;
}
}
};
// 刷新表格
const refreshTable = () => {
loadOnlineCount()
loadOnlineUsers()
}
loadOnlineCount();
loadOnlineUsers();
};
// 搜索
const handleSearch = () => {
pagination.current = 1
refreshTable()
}
pagination.current = 1;
refreshTable();
};
// 重置
const handleReset = () => {
searchForm.keyword = ''
pagination.current = 1
refreshTable()
}
searchForm.keyword = "";
pagination.current = 1;
refreshTable();
};
// 刷新按钮
const handleRefresh = () => {
refreshTable()
message.success('刷新成功')
}
refreshTable();
message.success("刷新成功");
};
// 刷新间隔变化
const handleRefreshIntervalChange = (value) => {
clearRefreshTimer()
clearRefreshTimer();
if (value > 0) {
startRefreshTimer(value)
startRefreshTimer(value);
}
}
};
// 启动刷新定时器
const startRefreshTimer = (interval) => {
refreshTimer = setInterval(() => {
refreshTable()
}, interval)
}
refreshTable();
}, interval);
};
// 清除刷新定时器
const clearRefreshTimer = () => {
if (refreshTimer) {
clearInterval(refreshTimer)
refreshTimer = null
clearInterval(refreshTimer);
refreshTimer = null;
}
}
};
// 查看用户会话
const handleViewSessions = (record) => {
dialog.sessions = true
dialog.sessions = true;
setTimeout(() => {
sessionsDialogRef.value?.open().setData(record)
}, 0)
}
sessionsDialogRef.value?.open().setData(record);
}, 0);
};
// 强制用户下线(单个)
const handleOffline = async (record) => {
try {
const res = await authApi.onlineUsers.offline.post(record.id, {})
const res = await authApi.onlineUsers.offline.post(record.id, {});
if (res.code === 200) {
message.success('强制下线成功')
refreshTable()
message.success("强制下线成功");
refreshTable();
} else {
message.error(res.message || '操作失败')
message.error(res.message || "操作失败");
}
} catch (error) {
console.error('强制下线失败:', error)
message.error('操作失败')
console.error("强制下线失败:", error);
message.error("操作失败");
}
}
};
// 强制用户所有设备下线
const handleOfflineAll = async (record) => {
try {
const res = await authApi.onlineUsers.offlineAll.post(record.id)
const res = await authApi.onlineUsers.offlineAll.post(record.id);
if (res.code === 200) {
message.success('全部下线成功')
refreshTable()
message.success("全部下线成功");
refreshTable();
} else {
message.error(res.message || '操作失败')
message.error(res.message || "操作失败");
}
} catch (error) {
console.error('全部下线失败:', error)
message.error('操作失败')
console.error("全部下线失败:", error);
message.error("操作失败");
}
}
};
// 全部下线
const handleRefreshAllOffline = () => {
Modal.confirm({
title: '确认操作',
content: '确定要强制所有在线用户下线吗?',
okText: '确定',
cancelText: '取消',
okType: 'danger',
title: "确认操作",
content: "确定要强制所有在线用户下线吗?",
okText: "确定",
cancelText: "取消",
okType: "danger",
onOk: async () => {
try {
// 这里需要遍历所有在线用户并下线
const onlineUsers = tableData.value.filter(user => user.is_online)
const onlineUsers = tableData.value.filter(
(user) => user.is_online,
);
for (const user of onlineUsers) {
await authApi.onlineUsers.offlineAll.post(user.id)
await authApi.onlineUsers.offlineAll.post(user.id);
}
message.success('全部下线成功')
refreshTable()
message.success("全部下线成功");
refreshTable();
} catch (error) {
console.error('全部下线失败:', error)
message.error('操作失败')
console.error("全部下线失败:", error);
message.error("操作失败");
}
}
})
}
},
});
};
// 会话操作成功回调
const handleSessionsSuccess = () => {
refreshTable()
}
refreshTable();
};
// 格式化日期
const formatDate = (date) => {
if (!date) return '-'
const d = new Date(date)
return d.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
}
if (!date) return "-";
const d = new Date(date);
return d.toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
};
// 初始化
onMounted(() => {
refreshTable()
refreshTable();
// 启动自动刷新
if (refreshInterval.value > 0) {
startRefreshTimer(refreshInterval.value)
startRefreshTimer(refreshInterval.value);
}
})
});
// 组件卸载时清除定时器
onUnmounted(() => {
clearRefreshTimer()
})
clearRefreshTimer();
});
</script>
<style scoped lang="scss">
@@ -1,18 +1,35 @@
<template>
<a-modal title="用户会话详情" :open="visible" :width="800" :destroy-on-close="true" :footer="null" @cancel="handleCancel">
<a-modal
title="用户会话详情"
:open="visible"
:width="800"
:destroy-on-close="true"
:footer="null"
@cancel="handleCancel"
>
<div class="sessions-content">
<!-- 用户信息 -->
<div class="user-info">
<a-descriptions :column="3" bordered size="small">
<a-descriptions-item label="用户名">{{ userInfo.username }}</a-descriptions-item>
<a-descriptions-item label="真实姓名">{{ userInfo.real_name }}</a-descriptions-item>
<a-descriptions-item label="用户名">{{
userInfo.username
}}</a-descriptions-item>
<a-descriptions-item label="真实姓名">{{
userInfo.real_name
}}</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag :color="userInfo.is_online ? 'success' : 'default'">
{{ userInfo.is_online ? '在线' : '离线' }}
<a-tag
:color="userInfo.is_online ? 'success' : 'default'"
>
{{ userInfo.is_online ? "在线" : "离线" }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="邮箱" :span="2">{{ userInfo.email }}</a-descriptions-item>
<a-descriptions-item label="手机号">{{ userInfo.phone }}</a-descriptions-item>
<a-descriptions-item label="邮箱" :span="2">{{
userInfo.email
}}</a-descriptions-item>
<a-descriptions-item label="手机号">{{
userInfo.phone
}}</a-descriptions-item>
</a-descriptions>
</div>
@@ -20,7 +37,12 @@
<div class="sessions-list">
<div class="list-header">
<span>会话列表{{ sessions.length }} </span>
<a-button type="link" size="small" danger @click="handleOfflineAll">
<a-button
type="link"
size="small"
danger
@click="handleOfflineAll"
>
全部下线
</a-button>
</div>
@@ -29,22 +51,54 @@
<a-list-item>
<a-list-item-meta>
<template #avatar>
<a-avatar shape="square" :icon="item.device_type === 'pc' ? 'DesktopOutlined' : 'MobileOutlined'" />
<a-avatar
shape="square"
:icon="
item.device_type === 'pc'
? 'DesktopOutlined'
: 'MobileOutlined'
"
/>
</template>
<template #title>
<div class="session-title">
<span>{{ getDeviceName(item.device_type) }}</span>
<a-tag :color="item.is_online ? 'success' : 'default'" size="small">
{{ item.is_online ? '活跃' : '过期' }}
<span>{{
getDeviceName(item.device_type)
}}</span>
<a-tag
:color="
item.is_online
? 'success'
: 'default'
"
size="small"
>
{{
item.is_online ? "活跃" : "过期"
}}
</a-tag>
</div>
</template>
<template #description>
<div class="session-info">
<div><span class="label">IP地址</span>{{ item.ip_address }}</div>
<div><span class="label">登录时间</span>{{ formatDate(item.created_at) }}</div>
<div><span class="label">最后活跃</span>{{ formatDate(item.last_active_at) }}</div>
<div v-if="item.user_agent"><span class="label">浏览器</span>{{ item.user_agent }}</div>
<div>
<span class="label">IP地址</span
>{{ item.ip_address }}
</div>
<div>
<span class="label">登录时间</span
>{{ formatDate(item.created_at) }}
</div>
<div>
<span class="label">最后活跃</span
>{{
formatDate(item.last_active_at)
}}
</div>
<div v-if="item.user_agent">
<span class="label">浏览器</span
>{{ item.user_agent }}
</div>
</div>
</template>
</a-list-item-meta>
@@ -58,12 +112,18 @@
强制下线
</a-button>
</a-popconfirm>
<a-tag v-else color="default" size="small">已过期</a-tag>
<a-tag v-else color="default" size="small"
>已过期</a-tag
>
</template>
</a-list-item>
</template>
</a-list>
<a-empty v-if="!loading && sessions.length === 0" description="暂无会话数据" :image-size="80" />
<a-empty
v-if="!loading && sessions.length === 0"
description="暂无会话数据"
:image-size="80"
/>
</div>
</div>
<template #footer>
@@ -73,150 +133,152 @@
</template>
<script setup>
import { ref, reactive } from 'vue'
import { message } from 'ant-design-vue'
import authApi from '@/api/auth'
import { ref, reactive } from "vue";
import { message } from "ant-design-vue";
import authApi from "@/api/auth";
defineOptions({
name: 'OnlineUserSessions'
})
name: "OnlineUserSessions",
});
const emit = defineEmits(['success', 'closed'])
const emit = defineEmits(["success", "closed"]);
const visible = ref(false)
const loading = ref(false)
const visible = ref(false);
const loading = ref(false);
// 用户信息
const userInfo = reactive({
id: '',
username: '',
real_name: '',
email: '',
phone: '',
is_online: false
})
id: "",
username: "",
real_name: "",
email: "",
phone: "",
is_online: false,
});
// 会话列表
const sessions = ref([])
const sessions = ref([]);
// 打开对话框
const open = () => {
visible.value = true
visible.value = true;
return {
open,
setData,
close
}
}
close,
};
};
// 关闭对话框
const close = () => {
visible.value = false
}
visible.value = false;
};
// 处理取消
const handleCancel = () => {
emit('closed')
visible.value = false
}
emit("closed");
visible.value = false;
};
// 加载用户会话
const loadUserSessions = async (userId) => {
try {
loading.value = true
const res = await authApi.onlineUsers.sessions.get(userId)
loading.value = false
loading.value = true;
const res = await authApi.onlineUsers.sessions.get(userId);
loading.value = false;
if (res.code === 200) {
sessions.value = res.data || []
sessions.value = res.data || [];
}
} catch (error) {
console.error('加载用户会话失败:', error)
loading.value = false
message.error('加载会话失败')
console.error("加载用户会话失败:", error);
loading.value = false;
message.error("加载会话失败");
}
}
};
// 设置数据
const setData = (data) => {
userInfo.id = data.id
userInfo.username = data.username
userInfo.real_name = data.real_name
userInfo.email = data.email
userInfo.phone = data.phone
userInfo.is_online = data.is_online
userInfo.id = data.id;
userInfo.username = data.username;
userInfo.real_name = data.real_name;
userInfo.email = data.email;
userInfo.phone = data.phone;
userInfo.is_online = data.is_online;
// 加载会话列表
loadUserSessions(data.id)
}
loadUserSessions(data.id);
};
// 强制会话下线
const handleOffline = async (session) => {
try {
const res = await authApi.onlineUsers.offline.post(userInfo.id, { token: session.token })
const res = await authApi.onlineUsers.offline.post(userInfo.id, {
token: session.token,
});
if (res.code === 200) {
message.success('强制下线成功')
emit('success')
message.success("强制下线成功");
emit("success");
// 重新加载会话列表
loadUserSessions(userInfo.id)
loadUserSessions(userInfo.id);
} else {
message.error(res.message || '操作失败')
message.error(res.message || "操作失败");
}
} catch (error) {
console.error('强制下线失败:', error)
message.error('操作失败')
console.error("强制下线失败:", error);
message.error("操作失败");
}
}
};
// 全部下线
const handleOfflineAll = async () => {
try {
const res = await authApi.onlineUsers.offlineAll.post(userInfo.id)
const res = await authApi.onlineUsers.offlineAll.post(userInfo.id);
if (res.code === 200) {
message.success('全部下线成功')
emit('success')
message.success("全部下线成功");
emit("success");
// 重新加载会话列表
loadUserSessions(userInfo.id)
loadUserSessions(userInfo.id);
} else {
message.error(res.message || '操作失败')
message.error(res.message || "操作失败");
}
} catch (error) {
console.error('全部下线失败:', error)
message.error('操作失败')
console.error("全部下线失败:", error);
message.error("操作失败");
}
}
};
// 获取设备名称
const getDeviceName = (deviceType) => {
const deviceMap = {
pc: '电脑端',
mobile: '手机端',
tablet: '平板端',
unknown: '未知设备'
}
return deviceMap[deviceType] || deviceMap.unknown
}
pc: "电脑端",
mobile: "手机端",
tablet: "平板端",
unknown: "未知设备",
};
return deviceMap[deviceType] || deviceMap.unknown;
};
// 格式化日期
const formatDate = (date) => {
if (!date) return '-'
const d = new Date(date)
return d.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
}
if (!date) return "-";
const d = new Date(date);
return d.toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
};
// 暴露方法给父组件
defineExpose({
open,
setData,
close
})
close,
});
</script>
<style scoped lang="scss">
@@ -1,15 +1,37 @@
<template>
<a-form :model="form" :rules="rules" ref="formRef" :label-col="{ span: 5 }" :wrapper-col="{ span: 18 }">
<a-form
:model="form"
:rules="rules"
ref="formRef"
:label-col="{ span: 5 }"
:wrapper-col="{ span: 18 }"
>
<!-- 第一行权限名称和类型 -->
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="权限名称" name="title" required>
<a-input v-model:value="form.title" placeholder="如:用户管理" allow-clear maxlength="50" show-count />
<a-input
v-model:value="form.title"
placeholder="如:用户管理"
allow-clear
maxlength="50"
show-count
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="权限类型" name="type" required :label-col="{ span: 6 }" :wrapper-col="{ span: 16 }">
<a-radio-group v-model:value="form.type" button-style="solid" @change="handleTypeChange">
<a-form-item
label="权限类型"
name="type"
required
:label-col="{ span: 6 }"
:wrapper-col="{ span: 16 }"
>
<a-radio-group
v-model:value="form.type"
button-style="solid"
@change="handleTypeChange"
>
<a-radio-button value="menu">菜单</a-radio-button>
<a-radio-button value="api">接口</a-radio-button>
<a-radio-button value="button">按钮</a-radio-button>
@@ -23,15 +45,29 @@
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="上级权限" name="parent_id">
<a-tree-select v-model:value="form.parent_id" :tree-data="menuOptions"
:field-names="menuFieldNames" :tree-default-expand-all="false" show-icon placeholder="顶级权限"
allow-clear tree-node-filter-prop="title" :disabled="!!menuId" />
<a-tree-select
v-model:value="form.parent_id"
:tree-data="menuOptions"
:field-names="menuFieldNames"
:tree-default-expand-all="false"
show-icon
placeholder="顶级权限"
allow-clear
tree-node-filter-prop="title"
:disabled="!!menuId"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="权限编码" name="name" required>
<a-input v-model:value="form.name" placeholder="如:system.users.index" allow-clear />
<div class="form-tip">格式模块.功能.操作系统唯一标识用于权限验证</div>
<a-input
v-model:value="form.name"
placeholder="如:system.users.index"
allow-clear
/>
<div class="form-tip">
格式模块.功能.操作系统唯一标识用于权限验证
</div>
</a-form-item>
</a-col>
</a-row>
@@ -39,18 +75,38 @@
<!-- 第三行路由地址和组件路径菜单类型才显示 -->
<a-row v-if="form.type === 'menu'" :gutter="16">
<a-col :span="12">
<a-form-item label="路由地址" name="path" :required="isLeafNode">
<a-input v-model:value="form.path" placeholder="/system/users" allow-clear />
<a-form-item
label="路由地址"
name="path"
:required="isLeafNode"
>
<a-input
v-model:value="form.path"
placeholder="/system/users"
allow-clear
/>
<div class="form-tip">前端路由路径 /system/users</div>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="组件路径" name="component" :required="isLeafNode">
<a-input v-model:value="form.component" placeholder="system/users/index" allow-clear>
<a-form-item
label="组件路径"
name="component"
:required="isLeafNode"
>
<a-input
v-model:value="form.component"
placeholder="system/users/index"
allow-clear
>
<template #addonBefore>pages/</template>
</a-input>
<div class="form-tip" v-if="!isLeafNode">父级菜单或包含子菜单时不需要填写</div>
<div class="form-tip" v-else>最后一级菜单必须填写 system/users/index</div>
<div class="form-tip" v-if="!isLeafNode">
父级菜单或包含子菜单时不需要填写
</div>
<div class="form-tip" v-else>
最后一级菜单必须填写 system/users/index
</div>
</a-form-item>
</a-col>
</a-row>
@@ -59,8 +115,14 @@
<a-row v-if="form.type === 'api'" :gutter="16">
<a-col :span="12">
<a-form-item label="API路由" name="path" required>
<a-input v-model:value="form.path" placeholder="如:users.index" allow-clear />
<div class="form-tip">后端 API 路由名称用于接口权限验证</div>
<a-input
v-model:value="form.path"
placeholder="如:users.index"
allow-clear
/>
<div class="form-tip">
后端 API 路由名称用于接口权限验证
</div>
</a-form-item>
</a-col>
</a-row>
@@ -69,7 +131,11 @@
<a-row v-if="form.type === 'url'" :gutter="16">
<a-col :span="12">
<a-form-item label="链接地址" name="path" required>
<a-input v-model:value="form.path" placeholder="https://example.com" allow-clear />
<a-input
v-model:value="form.path"
placeholder="https://example.com"
allow-clear
/>
<div class="form-tip">外部链接地址</div>
</a-form-item>
</a-col>
@@ -79,12 +145,20 @@
<a-row v-if="form.type === 'menu'" :gutter="16">
<a-col :span="12">
<a-form-item label="菜单图标" name="icon">
<sc-icon-picker v-model:value="form.icon" placeholder="请选择图标" />
<sc-icon-picker
v-model:value="form.icon"
placeholder="请选择图标"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="排序" name="sort">
<a-input-number v-model:value="form.sort" :min="0" :max="10000" style="width: 100%" />
<a-input-number
v-model:value="form.sort"
:min="0"
:max="10000"
style="width: 100%"
/>
<div class="form-tip">数值越小越靠前</div>
</a-form-item>
</a-col>
@@ -98,16 +172,36 @@
<h4>选项设置</h4>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="显示选项" :label-col="{ span: 6 }" :wrapper-col="{ span: 18 }">
<a-checkbox v-model:checked="form.hidden">隐藏菜单</a-checkbox>
<a-checkbox v-model:checked="form.hiddenBreadcrumb">隐藏面包屑</a-checkbox>
<a-checkbox v-model:checked="form.affix">固定标签页</a-checkbox>
<a-form-item
label="显示选项"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 18 }"
>
<a-checkbox v-model:checked="form.hidden"
>隐藏菜单</a-checkbox
>
<a-checkbox v-model:checked="form.hiddenBreadcrumb"
>隐藏面包屑</a-checkbox
>
<a-checkbox v-model:checked="form.affix"
>固定标签页</a-checkbox
>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="页面缓存" :label-col="{ span: 6 }" :wrapper-col="{ span: 18 }">
<a-switch v-model:checked="form.keepAlive" checked-children="启用" un-checked-children="禁用" />
<div class="form-tip">启用后页面会被缓存切换回来时保留状态</div>
<a-form-item
label="页面缓存"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 18 }"
>
<a-switch
v-model:checked="form.keepAlive"
checked-children="启用"
un-checked-children="禁用"
/>
<div class="form-tip">
启用后页面会被缓存切换回来时保留状态
</div>
</a-form-item>
</a-col>
</a-row>
@@ -134,17 +228,34 @@
<!-- 状态 -->
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="启用状态" name="status" :label-col="{ span: 6 }" :wrapper-col="{ span: 16 }">
<a-switch v-model:checked="statusChecked" checked-children="启用" un-checked-children="禁用" />
<a-form-item
label="启用状态"
name="status"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 16 }"
>
<a-switch
v-model:checked="statusChecked"
checked-children="启用"
un-checked-children="禁用"
/>
<div class="form-tip">禁用后该权限将不生效</div>
</a-form-item>
</a-col>
</a-row>
<!-- 操作按钮 -->
<a-form-item :wrapper-col="{ span: 18, offset: 5 }" style="margin-top: 32px">
<a-form-item
:wrapper-col="{ span: 18, offset: 5 }"
style="margin-top: 32px"
>
<a-space>
<a-button type="primary" @click="handleSave" :loading="loading" size="large">
<a-button
type="primary"
@click="handleSave"
:loading="loading"
size="large"
>
<template #icon><CheckOutlined /></template>
保存
</a-button>
@@ -158,152 +269,159 @@
</template>
<script setup>
import { ref, reactive, watch, computed, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import { CheckOutlined, CloseOutlined } from '@ant-design/icons-vue'
import authApi from '@/api/auth'
import scIconPicker from '@/components/scIconPicker/index.vue'
import { ref, reactive, watch, computed, onMounted } from "vue";
import { message } from "ant-design-vue";
import { CheckOutlined, CloseOutlined } from "@ant-design/icons-vue";
import authApi from "@/api/auth";
import scIconPicker from "@/components/scIconPicker/index.vue";
defineOptions({
name: 'PermissionSaveForm'
})
name: "PermissionSaveForm",
});
const props = defineProps({
menu: { type: [Object, Array], default: () => [] },
menuId: { type: [Number, String], default: null },
parentId: { type: [Number, String], default: null }
})
parentId: { type: [Number, String], default: null },
});
const emit = defineEmits(['success', 'cancel'])
const emit = defineEmits(["success", "cancel"]);
// 表单数据
const form = reactive({
id: '',
id: "",
parent_id: 0,
name: '',
title: '',
path: '',
component: '',
icon: '',
name: "",
title: "",
path: "",
component: "",
icon: "",
sort: 0,
type: 'menu',
type: "menu",
status: 1,
target: '_self',
target: "_self",
// meta 字段内容
hidden: false,
hiddenBreadcrumb: false,
keepAlive: false,
affix: false
})
affix: false,
});
// 表单引用
const formRef = ref()
const loading = ref(false)
const formRef = ref();
const loading = ref(false);
// 验证规则
const rules = {
title: [
{ required: true, message: '请输入权限名称', trigger: 'blur' },
{ max: 50, message: '权限名称不能超过50个字符', trigger: 'blur' }
{ required: true, message: "请输入权限名称", trigger: "blur" },
{ max: 50, message: "权限名称不能超过50个字符", trigger: "blur" },
],
name: [
{ required: true, message: '请输入权限编码', trigger: 'blur' },
{ required: true, message: "请输入权限编码", trigger: "blur" },
{
pattern: /^[a-zA-Z][a-zA-Z0-9_.]*$/,
message: '权限编码格式不正确,格式:模块.功能.操作',
trigger: 'blur'
}
message: "权限编码格式不正确,格式:模块.功能.操作",
trigger: "blur",
},
],
type: [{ required: true, message: '请选择权限类型', trigger: 'change' }],
type: [{ required: true, message: "请选择权限类型", trigger: "change" }],
path: (rule, value) => {
// 根据类型动态验证
if (form.type === 'menu' || form.type === 'api' || form.type === 'url') {
if (
form.type === "menu" ||
form.type === "api" ||
form.type === "url"
) {
if (!value || !value.trim()) {
return Promise.reject('请输入' + getPathLabel(form.type))
return Promise.reject("请输入" + getPathLabel(form.type));
}
}
return Promise.resolve()
return Promise.resolve();
},
component: (rule, value) => {
// 仅在菜单类型且为叶子节点时验证
if (form.type === 'menu' && isLeafNode.value) {
if (form.type === "menu" && isLeafNode.value) {
if (!value || !value.trim()) {
return Promise.reject('请输入组件路径')
return Promise.reject("请输入组件路径");
}
}
return Promise.resolve()
}
}
return Promise.resolve();
},
};
// 路径字段标签
const getPathLabel = (type) => {
const labelMap = {
menu: '路由地址',
api: 'API路由',
url: '链接地址'
}
return labelMap[type] || '路径'
}
menu: "路由地址",
api: "API路由",
url: "链接地址",
};
return labelMap[type] || "路径";
};
// 状态开关计算属性
const statusChecked = computed({
get: () => form.status === 1,
set: (val) => {
form.status = val ? 1 : 0
}
})
form.status = val ? 1 : 0;
},
});
// 判断是否为叶子节点(没有子节点的节点)
const isLeafNode = computed(() => {
// 这里需要根据当前节点是否有子节点来判断
// 暂时返回 false,需要根据实际数据判断
return !hasChildren.value
})
return !hasChildren.value;
});
// 判断是否有子节点
const hasChildren = computed(() => {
if (!form.id || !props.menu) return false
const node = findMenuNode(props.menu, form.id)
return node && node.children && node.children.length > 0
})
if (!form.id || !props.menu) return false;
const node = findMenuNode(props.menu, form.id);
return node && node.children && node.children.length > 0;
});
// 菜单选项
const menuOptions = ref([])
const menuOptions = ref([]);
const menuFieldNames = {
value: 'id',
label: 'title',
children: 'children'
}
value: "id",
label: "title",
children: "children",
};
// 筛单化菜单树,排除自己和子节点
const treeToMap = (tree, excludeId = null) => {
const map = []
tree.forEach(item => {
if (item.id === excludeId) return // 排除自己
const map = [];
tree.forEach((item) => {
if (item.id === excludeId) return; // 排除自己
const obj = {
id: item.id,
parent_id: item.parent_id,
title: item.title,
children: item.children && item.children.length > 0 ? treeToMap(item.children, excludeId) : null
}
map.push(obj)
})
return map
}
children:
item.children && item.children.length > 0
? treeToMap(item.children, excludeId)
: null,
};
map.push(obj);
});
return map;
};
// 查找权限节点
const findMenuNode = (tree, id) => {
for (const node of tree) {
if (node.id === id) {
return node
return node;
}
if (node.children && node.children.length > 0) {
const found = findMenuNode(node.children, id)
if (found) return found
const found = findMenuNode(node.children, id);
if (found) return found;
}
}
return null
}
return null;
};
// 监听菜单树变化
watch(
@@ -311,85 +429,85 @@ watch(
(newVal) => {
if (newVal) {
// 排除当前编辑的节点,避免选择自己作为父节点
menuOptions.value = treeToMap(newVal, props.menuId)
menuOptions.value = treeToMap(newVal, props.menuId);
}
},
{ deep: true, immediate: true }
)
{ deep: true, immediate: true },
);
// 监听 menuId 变化,从菜单树中查找并赋值
watch(
() => props.menuId,
(newVal) => {
if (newVal && props.menu && props.menu.length > 0) {
const menuNode = findMenuNode(props.menu, newVal)
const menuNode = findMenuNode(props.menu, newVal);
if (menuNode) {
setData(menuNode, props.parentId)
setData(menuNode, props.parentId);
}
} else if (!newVal) {
// 清空表单
resetForm()
resetForm();
}
}
)
},
);
// 类型切换处理
const handleTypeChange = () => {
// 类型切换时清空一些字段
form.path = ''
form.component = ''
form.icon = ''
form.path = "";
form.component = "";
form.icon = "";
// 非菜单类型时清空meta相关字段
if (form.type !== 'menu') {
form.hidden = false
form.hiddenBreadcrumb = false
form.keepAlive = false
form.affix = false
if (form.type !== "menu") {
form.hidden = false;
form.hiddenBreadcrumb = false;
form.keepAlive = false;
form.affix = false;
}
// 非链接类型时重置target
if (form.type !== 'url') {
form.target = '_self'
if (form.type !== "url") {
form.target = "_self";
}
}
};
// 重置表单
const resetForm = () => {
Object.assign(form, {
id: '',
id: "",
parent_id: props.parentId || 0,
name: '',
title: '',
path: '',
component: '',
icon: '',
name: "",
title: "",
path: "",
component: "",
icon: "",
sort: 0,
type: 'menu',
type: "menu",
status: 1,
target: '_self',
target: "_self",
hidden: false,
hiddenBreadcrumb: false,
keepAlive: false,
affix: false
})
}
affix: false,
});
};
// 加载权限详情
const loadMenuDetail = async (id) => {
try {
const res = await authApi.permissions.detail.get(id)
const res = await authApi.permissions.detail.get(id);
if (res.code === 200 && res.data) {
setData(res.data, props.parentId)
setData(res.data, props.parentId);
}
} catch (error) {
console.error('加载权限详情失败:', error)
console.error("加载权限详情失败:", error);
}
}
};
// 保存
const handleSave = async () => {
try {
await formRef.value.validate()
loading.value = true
await formRef.value.validate();
loading.value = true;
// 构建提交数据
const submitData = {
@@ -404,98 +522,105 @@ const handleSave = async () => {
type: form.type,
status: form.status,
target: form.target,
meta: null
}
meta: null,
};
// 仅菜单类型才有meta字段
if (form.type === 'menu') {
if (form.type === "menu") {
submitData.meta = {
hidden: form.hidden,
hiddenBreadcrumb: form.hiddenBreadcrumb,
keepAlive: form.keepAlive,
affix: form.affix
}
affix: form.affix,
};
}
// 根据类型处理空值
if (form.type === 'button' || form.type === 'api') {
submitData.component = ''
submitData.icon = ''
if (form.type === "button" || form.type === "api") {
submitData.component = "";
submitData.icon = "";
}
if (form.type === 'button') {
submitData.path = ''
if (form.type === "button") {
submitData.path = "";
}
if (form.type === 'api' || form.type === 'button' || form.type === 'url') {
submitData.meta = null
if (
form.type === "api" ||
form.type === "button" ||
form.type === "url"
) {
submitData.meta = null;
}
let res = {}
let res = {};
if (form.id) {
res = await authApi.permissions.edit.put(form.id, submitData)
res = await authApi.permissions.edit.put(form.id, submitData);
} else {
res = await authApi.permissions.add.post(submitData)
res = await authApi.permissions.add.post(submitData);
}
loading.value = false
loading.value = false;
if (res.code === 200) {
message.success('保存成功')
emit('success')
message.success("保存成功");
emit("success");
} else {
message.error(res.message || '保存失败')
message.error(res.message || "保存失败");
}
} catch (error) {
console.error('表单验证失败', error)
loading.value = false
console.error("表单验证失败", error);
loading.value = false;
if (error?.errorFields) {
// 表单验证失败
return
return;
}
message.error('保存失败')
message.error("保存失败");
}
}
};
// 表单注入数据
const setData = (data, pid) => {
form.id = data.id || ''
form.parent_id = data.parent_id !== undefined ? data.parent_id : (pid || 0)
form.name = data.name || ''
form.title = data.title || ''
form.path = data.path || ''
form.component = data.component || ''
form.icon = data.icon || ''
form.sort = data.sort || 0
form.type = data.type || 'menu'
form.status = data.status !== undefined ? data.status : 1
form.target = data.target || '_self'
form.id = data.id || "";
form.parent_id = data.parent_id !== undefined ? data.parent_id : pid || 0;
form.name = data.name || "";
form.title = data.title || "";
form.path = data.path || "";
form.component = data.component || "";
form.icon = data.icon || "";
form.sort = data.sort || 0;
form.type = data.type || "menu";
form.status = data.status !== undefined ? data.status : 1;
form.target = data.target || "_self";
// 解析 meta 字段
const meta = data.meta && typeof data.meta === 'string' ? JSON.parse(data.meta) : (data.meta || {})
form.hidden = meta.hidden || false
form.hiddenBreadcrumb = meta.hiddenBreadcrumb || false
form.keepAlive = meta.keepAlive || false
form.affix = meta.affix || false
}
const meta =
data.meta && typeof data.meta === "string"
? JSON.parse(data.meta)
: data.meta || {};
form.hidden = meta.hidden || false;
form.hiddenBreadcrumb = meta.hiddenBreadcrumb || false;
form.keepAlive = meta.keepAlive || false;
form.affix = meta.affix || false;
};
// 初始化
onMounted(() => {
if (props.menuId) {
loadMenuDetail(props.menuId)
loadMenuDetail(props.menuId);
} else if (props.parentId) {
form.parent_id = props.parentId
form.parent_id = props.parentId;
}
})
});
// 清空表单验证
const clearValidate = () => {
formRef.value?.clearValidate()
}
formRef.value?.clearValidate();
};
// 暴露方法给父组件
defineExpose({
setData,
clearValidate,
resetForm
})
resetForm,
});
</script>
<style scoped lang="scss">
@@ -3,25 +3,43 @@
<div class="left-box">
<div class="header">
<div class="search-wrapper">
<a-input v-model:value="menuFilterText" placeholder="搜索权限名称或编码..." allow-clear
@change="handleMenuSearch">
<a-input
v-model:value="menuFilterText"
placeholder="搜索权限名称或编码..."
allow-clear
@change="handleMenuSearch"
>
<template #prefix>
<SearchOutlined style="color: rgba(0, 0, 0, 0.45)" />
<SearchOutlined
style="color: rgba(0, 0, 0, 0.45)"
/>
</template>
</a-input>
</div>
<div class="actions">
<a-space size="small">
<a-tooltip :title="isAllExpanded ? '折叠全部' : '展开全部'">
<a-button type="text" size="small" @click="handleToggleExpand">
<a-tooltip
:title="isAllExpanded ? '折叠全部' : '展开全部'"
>
<a-button
type="text"
size="small"
@click="handleToggleExpand"
>
<template #icon>
<UnorderedListOutlined v-if="!isAllExpanded" />
<UnorderedListOutlined
v-if="!isAllExpanded"
/>
<OrderedListOutlined v-else />
</template>
</a-button>
</a-tooltip>
<a-tooltip title="添加根权限">
<a-button type="text" size="small" @click="handleAdd(null)">
<a-button
type="text"
size="small"
@click="handleAdd(null)"
>
<template #icon><PlusOutlined /></template>
</a-button>
</a-tooltip>
@@ -36,27 +54,53 @@
v-model:checkedKeys="checkedMenuKeys"
v-model:expandedKeys="expandedKeys"
:tree-data="filteredMenuTree"
:field-names="{ title: 'title', key: 'id', children: 'children' }"
:field-names="{
title: 'title',
key: 'id',
children: 'children',
}"
show-line
checkable
:check-strictly="false"
:expand-on-click-node="false"
@select="onMenuSelect"
@check="onMenuCheck">
@check="onMenuCheck"
>
<template #icon="{ dataRef }">
<FolderOutlined v-if="dataRef.type === 'menu' && dataRef.children?.length" />
<FolderOpenOutlined v-else-if="dataRef.type === 'menu'" />
<FolderOutlined
v-if="
dataRef.type === 'menu' &&
dataRef.children?.length
"
/>
<FolderOpenOutlined
v-else-if="dataRef.type === 'menu'"
/>
<ApiOutlined v-else-if="dataRef.type === 'api'" />
<ControlOutlined v-else />
</template>
<template #title="{ dataRef }">
<span class="tree-node-content">
<span class="tree-node-title">{{ dataRef.title }}</span>
<a-tag v-if="dataRef.name" class="tree-node-code" size="small">{{ dataRef.name }}</a-tag>
<a-tag v-if="dataRef.type !== 'menu'" :color="getTypeColor(dataRef.type)" size="small">
<span class="tree-node-title">{{
dataRef.title
}}</span>
<a-tag
v-if="dataRef.name"
class="tree-node-code"
size="small"
>{{ dataRef.name }}</a-tag
>
<a-tag
v-if="dataRef.type !== 'menu'"
:color="getTypeColor(dataRef.type)"
size="small"
>
{{ getTypeLabel(dataRef.type) }}
</a-tag>
<span v-if="!dataRef.status" class="tree-node-disabled">
<span
v-if="!dataRef.status"
class="tree-node-disabled"
>
<StopOutlined />
</span>
</span>
@@ -68,13 +112,24 @@
<div class="right-box">
<div class="header">
<div class="title-wrapper">
<span class="title">{{ selectedMenu?.title || '请选择权限节点' }}</span>
<a-tag v-if="selectedMenu" :color="getTypeColor(selectedMenu.type)" size="small">
<span class="title">{{
selectedMenu?.title || "请选择权限节点"
}}</span>
<a-tag
v-if="selectedMenu"
:color="getTypeColor(selectedMenu.type)"
size="small"
>
{{ getTypeLabel(selectedMenu.type) }}
</a-tag>
</div>
<a-space>
<a-button v-if="checkedMenuKeys.length > 0" danger size="small" @click="handleDeleteBatch">
<a-button
v-if="checkedMenuKeys.length > 0"
danger
size="small"
@click="handleDeleteBatch"
>
<template #icon><DeleteOutlined /></template>
批量删除 ({{ checkedMenuKeys.length }})
</a-button>
@@ -105,27 +160,48 @@
</div>
<div class="body">
<a-spin :spinning="detailLoading" :delay="200">
<save-form v-if="selectedMenu" :menu="menuTree" :menu-id="selectedMenu.id" :parent-id="parentId"
@success="handleSaveSuccess" />
<a-empty v-else description="请选择左侧权限节点后操作" :image-size="100" />
<save-form
v-if="selectedMenu"
:menu="menuTree"
:menu-id="selectedMenu.id"
:parent-id="parentId"
@success="handleSaveSuccess"
/>
<a-empty
v-else
description="请选择左侧权限节点后操作"
:image-size="100"
/>
</a-spin>
</div>
</div>
</div>
<!-- 导入权限弹窗 -->
<sc-import v-model:open="dialog.import" title="导入权限" :api="authApi.permissions.import.post"
:template-api="authApi.permissions.downloadTemplate.get" filename="权限" @success="handleImportSuccess" />
<sc-import
v-model:open="dialog.import"
title="导入权限"
:api="authApi.permissions.import.post"
:template-api="authApi.permissions.downloadTemplate.get"
filename="权限"
@success="handleImportSuccess"
/>
<!-- 导出权限弹窗 -->
<sc-export v-model:open="dialog.export" title="导出权限" :api="handleExportApi"
:default-filename="`权限列表_${Date.now()}`" :show-options="false" tip="导出当前选中或所有权限数据"
@success="handleExportSuccess" />
<sc-export
v-model:open="dialog.export"
title="导出权限"
:api="handleExportApi"
:default-filename="`权限列表_${Date.now()}`"
:show-options="false"
tip="导出当前选中或所有权限数据"
@success="handleExportSuccess"
/>
</template>
<script setup>
import { ref, onMounted, nextTick } from 'vue'
import { message, Modal } from 'ant-design-vue'
import { ref, onMounted, nextTick } from "vue";
import { message, Modal } from "ant-design-vue";
import {
SearchOutlined,
ReloadOutlined,
@@ -141,337 +217,354 @@ import {
ImportOutlined,
ExportOutlined,
DownloadOutlined,
MoreOutlined
} from '@ant-design/icons-vue'
import { computed } from 'vue'
import saveForm from './components/SaveForm.vue'
import scImport from '@/components/scImport/index.vue'
import scExport from '@/components/scExport/index.vue'
import authApi from '@/api/auth'
MoreOutlined,
} from "@ant-design/icons-vue";
import { computed } from "vue";
import saveForm from "./components/SaveForm.vue";
import scImport from "@/components/scImport/index.vue";
import scExport from "@/components/scExport/index.vue";
import authApi from "@/api/auth";
defineOptions({
name: 'authPermission'
})
name: "authPermission",
});
// 菜单树数据
const menuTree = ref([])
const filteredMenuTree = ref([])
const selectedMenuKeys = ref([])
const checkedMenuKeys = ref([])
const expandedKeys = ref([])
const menuFilterText = ref('')
const menuTree = ref([]);
const filteredMenuTree = ref([]);
const selectedMenuKeys = ref([]);
const checkedMenuKeys = ref([]);
const expandedKeys = ref([]);
const menuFilterText = ref("");
// 当前选中的菜单
const selectedMenu = ref(null)
const parentId = ref(null)
const selectedMenu = ref(null);
const parentId = ref(null);
// 加载状态
const loading = ref(false)
const detailLoading = ref(false)
const loading = ref(false);
const detailLoading = ref(false);
// 对话框状态
const dialog = ref({
import: false,
export: false
})
export: false,
});
// 树引用
const treeRef = ref()
const treeRef = ref();
// 是否全部展开
const isAllExpanded = computed(() => {
const allKeys = getAllKeys(filteredMenuTree.value)
return allKeys.length > 0 && expandedKeys.value.length === allKeys.length
})
const allKeys = getAllKeys(filteredMenuTree.value);
return allKeys.length > 0 && expandedKeys.value.length === allKeys.length;
});
// 切换展开/折叠
const handleToggleExpand = () => {
if (isAllExpanded.value) {
handleCollapseAll()
handleCollapseAll();
} else {
handleExpandAll()
handleExpandAll();
}
}
};
// 加载权限树
const loadMenuTree = async () => {
try {
loading.value = true
const res = await authApi.permissions.tree.get()
loading.value = true;
const res = await authApi.permissions.tree.get();
if (res.code === 200) {
menuTree.value = res.data || []
filteredMenuTree.value = res.data || []
menuTree.value = res.data || [];
filteredMenuTree.value = res.data || [];
// 默认展开第一层
expandAllKeys(menuTree.value, 1)
expandAllKeys(menuTree.value, 1);
} else {
message.error(res.message || '加载权限树失败')
message.error(res.message || "加载权限树失败");
}
} catch (error) {
console.error('加载权限树失败:', error)
message.error('加载权限树失败')
console.error("加载权限树失败:", error);
message.error("加载权限树失败");
} finally {
loading.value = false
loading.value = false;
}
}
};
// 刷新
const handleRefresh = () => {
loadMenuTree()
loadMenuTree();
if (selectedMenu.value) {
// 重新获取当前选中的权限详情
const menuNode = findMenuNode(menuTree.value, selectedMenu.value.id)
const menuNode = findMenuNode(menuTree.value, selectedMenu.value.id);
if (menuNode) {
selectedMenu.value = menuNode
selectedMenu.value = menuNode;
}
}
}
};
// 搜索权限
const handleMenuSearch = (e) => {
const keyword = (e.target?.value || '').trim()
menuFilterText.value = keyword
const keyword = (e.target?.value || "").trim();
menuFilterText.value = keyword;
if (!keyword) {
filteredMenuTree.value = menuTree.value
return
filteredMenuTree.value = menuTree.value;
return;
}
// 递归过滤权限树(支持搜索名称和编码)
const filterTree = (nodes) => {
return nodes.reduce((acc, node) => {
const titleMatch = node.title && node.title.toLowerCase().includes(keyword.toLowerCase())
const nameMatch = node.name && node.name.toLowerCase().includes(keyword.toLowerCase())
const isMatch = titleMatch || nameMatch
const filteredChildren = node.children ? filterTree(node.children) : []
const titleMatch =
node.title &&
node.title.toLowerCase().includes(keyword.toLowerCase());
const nameMatch =
node.name &&
node.name.toLowerCase().includes(keyword.toLowerCase());
const isMatch = titleMatch || nameMatch;
const filteredChildren = node.children
? filterTree(node.children)
: [];
if (isMatch || filteredChildren.length > 0) {
acc.push({
...node,
children: filteredChildren.length > 0 ? filteredChildren : undefined
})
children:
filteredChildren.length > 0
? filteredChildren
: undefined,
});
}
return acc
}, [])
}
return acc;
}, []);
};
filteredMenuTree.value = filterTree(menuTree.value)
filteredMenuTree.value = filterTree(menuTree.value);
// 搜索时展开所有匹配节点
expandAllKeys(filteredMenuTree.value)
}
expandAllKeys(filteredMenuTree.value);
};
// 查找权限节点
const findMenuNode = (tree, id) => {
for (const node of tree) {
if (node.id === id) {
return node
return node;
}
if (node.children && node.children.length > 0) {
const found = findMenuNode(node.children, id)
if (found) return found
const found = findMenuNode(node.children, id);
if (found) return found;
}
}
return null
}
return null;
};
// 查找父节点ID
const findParentId = (tree, id) => {
for (const node of tree) {
if (node.children && node.children.length > 0) {
const child = node.children.find(child => child.id === id)
const child = node.children.find((child) => child.id === id);
if (child) {
return node.id
return node.id;
}
const found = findParentId(node.children, id)
if (found !== null) return found
const found = findParentId(node.children, id);
if (found !== null) return found;
}
}
return null
}
return null;
};
// 限制选择事件
const onMenuSelect = (selectedKeys, { selected }) => {
if (selected) {
const menuId = selectedKeys[0]
const menuNode = findMenuNode(menuTree.value, menuId)
selectedMenu.value = menuNode
parentId.value = findParentId(menuTree.value, menuId)
const menuId = selectedKeys[0];
const menuNode = findMenuNode(menuTree.value, menuId);
selectedMenu.value = menuNode;
parentId.value = findParentId(menuTree.value, menuId);
} else {
selectedMenu.value = null
parentId.value = null
selectedMenu.value = null;
parentId.value = null;
}
}
};
// 限制勾选事件
const onMenuCheck = (checkedKeys, info) => {
console.log('checkedKeys:', checkedKeys, 'info:', info)
}
console.log("checkedKeys:", checkedKeys, "info:", info);
};
// 获取所有节点ID(用于展开/折叠)
const getAllKeys = (nodes) => {
const keys = []
const keys = [];
const traverse = (items) => {
items.forEach(item => {
keys.push(item.id)
items.forEach((item) => {
keys.push(item.id);
if (item.children?.length) {
traverse(item.children)
traverse(item.children);
}
})
}
traverse(nodes)
return keys
}
});
};
traverse(nodes);
return keys;
};
// 展开全部
const handleExpandAll = () => {
expandedKeys.value = getAllKeys(filteredMenuTree.value)
}
expandedKeys.value = getAllKeys(filteredMenuTree.value);
};
// 折叠全部
const handleCollapseAll = () => {
expandedKeys.value = []
}
expandedKeys.value = [];
};
// 自动展开指定层级的节点
const expandAllKeys = (nodes, maxLevel = 3) => {
const keys = []
const keys = [];
const traverse = (items, level = 1) => {
items.forEach(item => {
items.forEach((item) => {
if (level < maxLevel && item.children?.length) {
keys.push(item.id)
traverse(item.children, level + 1)
keys.push(item.id);
traverse(item.children, level + 1);
}
})
}
traverse(nodes)
expandedKeys.value = keys
}
});
};
traverse(nodes);
expandedKeys.value = keys;
};
// 获取权限类型标签
const getTypeLabel = (type) => {
const typeMap = {
menu: '菜单',
api: '接口',
button: '按钮',
url: '链接'
}
return typeMap[type] || type
}
menu: "菜单",
api: "接口",
button: "按钮",
url: "链接",
};
return typeMap[type] || type;
};
// 获取权限类型颜色
const getTypeColor = (type) => {
const colorMap = {
menu: 'blue',
api: 'green',
button: 'orange',
url: 'purple'
}
return colorMap[type] || 'default'
}
menu: "blue",
api: "green",
button: "orange",
url: "purple",
};
return colorMap[type] || "default";
};
// 批量删除权限
const handleDeleteBatch = async () => {
if (checkedMenuKeys.value.length === 0) {
message.warning('请选择需要删除的权限')
return
message.warning("请选择需要删除的权限");
return;
}
Modal.confirm({
title: '确认删除',
title: "确认删除",
content: `确定删除已选择的 ${checkedMenuKeys.value.length} 个权限吗?`,
okText: '删除',
okType: 'danger',
cancelText: '取消',
okText: "删除",
okType: "danger",
cancelText: "取消",
onOk: async () => {
try {
const res = await authApi.permissions.batchDelete.post({ ids: checkedMenuKeys.value })
const res = await authApi.permissions.batchDelete.post({
ids: checkedMenuKeys.value,
});
if (res.code === 200) {
message.success('删除成功')
message.success("删除成功");
// 如果当前选中的权限被删除了,清空选择
if (selectedMenu.value && checkedMenuKeys.value.includes(selectedMenu.value.id)) {
selectedMenu.value = null
selectedMenuKeys.value = []
if (
selectedMenu.value &&
checkedMenuKeys.value.includes(selectedMenu.value.id)
) {
selectedMenu.value = null;
selectedMenuKeys.value = [];
}
checkedMenuKeys.value = []
await loadMenuTree()
checkedMenuKeys.value = [];
await loadMenuTree();
} else {
message.error(res.message || '删除失败')
message.error(res.message || "删除失败");
}
} catch (error) {
console.error('删除权限失败:', error)
message.error('删除失败')
console.error("删除权限失败:", error);
message.error("删除失败");
}
}
})
}
},
});
};
// 保存成功回调
const handleSaveSuccess = async () => {
await loadMenuTree()
await loadMenuTree();
// 重新设置当前选中的权限
if (selectedMenu.value) {
const menuNode = findMenuNode(menuTree.value, selectedMenu.value.id)
selectedMenu.value = menuNode
const menuNode = findMenuNode(menuTree.value, selectedMenu.value.id);
selectedMenu.value = menuNode;
}
message.success('保存成功')
}
message.success("保存成功");
};
// 导出权限
const handleExport = () => {
dialog.value.export = true
}
dialog.value.export = true;
};
// 导出API封装
const handleExportApi = async () => {
return await authApi.permissions.export.post({
ids: checkedMenuKeys.value.length > 0 ? checkedMenuKeys.value : undefined
})
}
ids:
checkedMenuKeys.value.length > 0
? checkedMenuKeys.value
: undefined,
});
};
// 导出成功回调
const handleExportSuccess = () => {
checkedMenuKeys.value = []
}
checkedMenuKeys.value = [];
};
// 导入权限
const handleImport = () => {
dialog.value.import = true
}
dialog.value.import = true;
};
// 导入成功回调
const handleImportSuccess = () => {
loadMenuTree()
}
loadMenuTree();
};
// 下载模板
const handleDownloadTemplate = async () => {
try {
const blob = await authApi.permissions.downloadTemplate.get()
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = '权限导入模板.xlsx'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
message.success('下载成功')
const blob = await authApi.permissions.downloadTemplate.get();
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "权限导入模板.xlsx";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
message.success("下载成功");
} catch (error) {
console.error('下载模板失败:', error)
message.error('下载失败')
console.error("下载模板失败:", error);
message.error("下载失败");
}
}
};
// 初始化
onMounted(() => {
loadMenuTree()
})
loadMenuTree();
});
defineExpose({
loadMenuTree,
handleExpandAll,
handleCollapseAll
})
handleCollapseAll,
});
</script>
<style scoped lang="scss">
@@ -547,7 +640,7 @@ defineExpose({
}
.tree-node-code {
font-family: 'Consolas', 'Monaco', monospace;
font-family: "Consolas", "Monaco", monospace;
font-size: 11px;
background: #f0f0f0;
border: none;
@@ -1,125 +1,151 @@
<template>
<a-modal :title="title" :open="visible" :width="500" :destroy-on-close="true" @cancel="handleCancel">
<a-form :model="form" :rules="rules" ref="dialogForm" :label-col="{ span: 5 }" :wrapper-col="{ span: 18 }">
<a-modal
:title="title"
:open="visible"
:width="500"
:destroy-on-close="true"
@cancel="handleCancel"
>
<a-form
:model="form"
:rules="rules"
ref="dialogForm"
:label-col="{ span: 5 }"
:wrapper-col="{ span: 18 }"
>
<a-form-item label="角色名称" name="name">
<a-input v-model:value="form.name" placeholder="请输入新角色名称" allow-clear />
<a-input
v-model:value="form.name"
placeholder="请输入新角色名称"
allow-clear
/>
</a-form-item>
<a-form-item label="角色编码" name="code">
<a-input v-model:value="form.code" placeholder="请输入新角色编码" allow-clear />
<a-input
v-model:value="form.code"
placeholder="请输入新角色编码"
allow-clear
/>
</a-form-item>
</a-form>
<template #footer>
<a-space>
<a-button @click="handleCancel"> </a-button>
<a-button type="primary" :loading="loading" @click="handleOk"> </a-button>
<a-button type="primary" :loading="loading" @click="handleOk"
> </a-button
>
</a-space>
</template>
</a-modal>
</template>
<script setup>
import { ref, reactive, computed } from 'vue'
import { message } from 'ant-design-vue'
import authApi from '@/api/auth'
import { ref, reactive, computed } from "vue";
import { message } from "ant-design-vue";
import authApi from "@/api/auth";
const emit = defineEmits(['success', 'closed'])
const emit = defineEmits(["success", "closed"]);
const visible = ref(false)
const loading = ref(false)
const sourceId = ref(null)
const sourceName = ref('')
const sourceCode = ref('')
const visible = ref(false);
const loading = ref(false);
const sourceId = ref(null);
const sourceName = ref("");
const sourceCode = ref("");
// 表单数据
const form = reactive({
name: '',
code: ''
})
name: "",
code: "",
});
// 标题
const title = computed(() => sourceId.value ? '复制角色' : '批量复制')
const title = computed(() => (sourceId.value ? "复制角色" : "批量复制"));
// 表单引用
const dialogForm = ref()
const dialogForm = ref();
// 验证规则
const rules = {
name: [{ required: true, message: '请输入角色名称', trigger: 'blur' }],
name: [{ required: true, message: "请输入角色名称", trigger: "blur" }],
code: [
{ required: true, message: '请输入角色编码', trigger: 'blur' },
{ pattern: /^[a-zA-Z0-9_]+$/, message: '角色编码只能包含字母、数字和下划线', trigger: 'blur' }
]
}
{ required: true, message: "请输入角色编码", trigger: "blur" },
{
pattern: /^[a-zA-Z0-9_]+$/,
message: "角色编码只能包含字母、数字和下划线",
trigger: "blur",
},
],
};
// 打开对话框
const open = (data = null) => {
if (data && data.id) {
// 单个复制
sourceId.value = data.id
sourceName.value = data.name
sourceCode.value = data.code
form.name = `${data.name}_副本`
form.code = `${data.code}_copy`
sourceId.value = data.id;
sourceName.value = data.name;
sourceCode.value = data.code;
form.name = `${data.name}_副本`;
form.code = `${data.code}_copy`;
} else {
// 批量复制(暂不支持自定义名称,直接在后端处理)
sourceId.value = null
sourceName.value = ''
sourceCode.value = ''
form.name = ''
form.code = ''
sourceId.value = null;
sourceName.value = "";
sourceCode.value = "";
form.name = "";
form.code = "";
}
visible.value = true
visible.value = true;
return {
open,
close
}
}
close,
};
};
// 关闭对话框
const close = () => {
visible.value = false
}
visible.value = false;
};
// 处理取消
const handleCancel = () => {
emit('closed')
visible.value = false
}
emit("closed");
visible.value = false;
};
// 处理确定
const handleOk = async () => {
try {
if (sourceId.value) {
// 单个复制
await dialogForm.value.validate()
loading.value = true
await dialogForm.value.validate();
loading.value = true;
const res = await authApi.roles.copy.post(sourceId.value, {
name: form.name,
code: form.code
})
loading.value = false
code: form.code,
});
loading.value = false;
if (res.code === 200) {
emit('success')
visible.value = false
message.success('复制成功')
emit("success");
visible.value = false;
message.success("复制成功");
} else {
message.error(res.message || '复制失败')
message.error(res.message || "复制失败");
}
} else {
// 批量复制(通过外部传入的 ids)
emit('success')
visible.value = false
emit("success");
visible.value = false;
}
} catch (error) {
console.error('复制失败:', error)
loading.value = false
console.error("复制失败:", error);
loading.value = false;
}
}
};
// 暴露方法给父组件
defineExpose({
open,
close
})
close,
});
</script>
@@ -1,10 +1,23 @@
<template>
<a-modal title="角色权限设置" :open="visible" :width="600" :destroy-on-close="true" @cancel="handleCancel">
<a-modal
title="角色权限设置"
:open="visible"
:width="600"
:destroy-on-close="true"
@cancel="handleCancel"
>
<div class="permission-content">
<div class="permission-tree">
<a-tree ref="menuTreeRef" v-model:checkedKeys="checkedPermissionIds" :tree-data="permissionTree"
:field-names="fieldNames" :checkable="true" :default-expand-all="true"
:check-strictly="false" :selectable="false">
<a-tree
ref="menuTreeRef"
v-model:checkedKeys="checkedPermissionIds"
:tree-data="permissionTree"
:field-names="fieldNames"
:checkable="true"
:default-expand-all="true"
:check-strictly="false"
:selectable="false"
>
<template #title="{ title }">
{{ title }}
</template>
@@ -14,127 +27,131 @@
<template #footer>
<a-space>
<a-button @click="handleCancel"> </a-button>
<a-button type="primary" :loading="isSaveing" @click="submit"> </a-button>
<a-button type="primary" :loading="isSaveing" @click="submit"
> </a-button
>
</a-space>
</template>
</a-modal>
</template>
<script setup>
import { ref, reactive } from 'vue'
import { message } from 'ant-design-vue'
import authApi from '@/api/auth'
import { ref, reactive } from "vue";
import { message } from "ant-design-vue";
import authApi from "@/api/auth";
const emit = defineEmits(['success', 'closed'])
const emit = defineEmits(["success", "closed"]);
const visible = ref(false)
const isSaveing = ref(false)
const menuTreeRef = ref()
const visible = ref(false);
const isSaveing = ref(false);
const menuTreeRef = ref();
// 权限树数据
const permissionTree = ref([])
const checkedPermissionIds = ref([])
const permissionTree = ref([]);
const checkedPermissionIds = ref([]);
// 树字段映射
const fieldNames = {
title: 'title',
key: 'id',
children: 'children'
}
title: "title",
key: "id",
children: "children",
};
// 表单数据
const form = reactive({
role_id: '',
permission_ids: []
})
role_id: "",
permission_ids: [],
});
// 打开对话框
const open = () => {
visible.value = true
visible.value = true;
return {
open,
setData,
close
}
}
close,
};
};
// 关闭对话框
const close = () => {
visible.value = false
}
visible.value = false;
};
// 处理取消
const handleCancel = () => {
emit('closed')
visible.value = false
}
emit("closed");
visible.value = false;
};
// 提交保存
const submit = async () => {
try {
isSaveing.value = true
isSaveing.value = true;
// 获取选中的权限 ID
form.permission_ids = checkedPermissionIds.value || []
form.permission_ids = checkedPermissionIds.value || [];
const res = await authApi.roles.permissions.post(form.role_id, { permission_ids: form.permission_ids })
const res = await authApi.roles.permissions.post(form.role_id, {
permission_ids: form.permission_ids,
});
isSaveing.value = false
isSaveing.value = false;
if (res.code === 200) {
emit('success', form)
visible.value = false
message.success('操作成功')
emit("success", form);
visible.value = false;
message.success("操作成功");
} else {
message.error(res.message || '操作失败')
message.error(res.message || "操作失败");
}
} catch (error) {
console.error('保存权限失败:', error)
isSaveing.value = false
message.error('操作失败')
console.error("保存权限失败:", error);
isSaveing.value = false;
message.error("操作失败");
}
}
};
// 获取权限树
const loadPermissionTree = async () => {
try {
const res = await authApi.permissions.tree.get()
permissionTree.value = res.data || []
const res = await authApi.permissions.tree.get();
permissionTree.value = res.data || [];
} catch (error) {
console.error('获取权限树失败:', error)
message.error('获取权限树失败')
console.error("获取权限树失败:", error);
message.error("获取权限树失败");
}
}
};
// 获取角色已有权限
const loadRolePermissions = async (roleId) => {
try {
const res = await authApi.roles.permissions.get(roleId)
const res = await authApi.roles.permissions.get(roleId);
if (res.code === 200 && res.data) {
checkedPermissionIds.value = res.data.map(item => item.id)
checkedPermissionIds.value = res.data.map((item) => item.id);
}
} catch (error) {
console.error('获取角色权限失败:', error)
console.error("获取角色权限失败:", error);
}
}
};
// 设置数据
const setData = async (data) => {
form.role_id = data.id
checkedPermissionIds.value = []
form.role_id = data.id;
checkedPermissionIds.value = [];
// 加载角色已有的权限
await loadRolePermissions(data.id)
}
await loadRolePermissions(data.id);
};
// 组件挂载时加载数据
loadPermissionTree()
loadPermissionTree();
// 暴露方法给父组件
defineExpose({
open,
setData,
close
})
close,
});
</script>
<style scoped>
@@ -1,139 +1,187 @@
<template>
<a-modal :title="titleMap[mode]" :open="visible" :width="500" :destroy-on-close="true" @cancel="handleCancel">
<a-form :model="form" :rules="rules" :disabled="mode === 'show'" ref="dialogForm" :label-col="{ span: 5 }"
:wrapper-col="{ span: 18 }">
<a-modal
:title="titleMap[mode]"
:open="visible"
:width="500"
:destroy-on-close="true"
@cancel="handleCancel"
>
<a-form
:model="form"
:rules="rules"
:disabled="mode === 'show'"
ref="dialogForm"
:label-col="{ span: 5 }"
:wrapper-col="{ span: 18 }"
>
<a-form-item label="角色名称" name="name">
<a-input v-model:value="form.name" placeholder="请输入角色名称" allow-clear></a-input>
<a-input
v-model:value="form.name"
placeholder="请输入角色名称"
allow-clear
></a-input>
</a-form-item>
<a-form-item label="角色编码" name="code">
<a-input v-model:value="form.code" placeholder="请输入角色编码" allow-clear :disabled="mode === 'edit'"></a-input>
<a-input
v-model:value="form.code"
placeholder="请输入角色编码"
allow-clear
:disabled="mode === 'edit'"
></a-input>
</a-form-item>
<a-form-item label="角色描述" name="description">
<a-textarea v-model:value="form.description" placeholder="请输入角色描述" :rows="4" allow-clear></a-textarea>
<a-textarea
v-model:value="form.description"
placeholder="请输入角色描述"
:rows="4"
allow-clear
></a-textarea>
</a-form-item>
<a-form-item label="排序" name="sort">
<a-input-number v-model:value="form.sort" :min="0" :step="1" style="width: 100%" placeholder="请输入排序" />
<a-input-number
v-model:value="form.sort"
:min="0"
:step="1"
style="width: 100%"
placeholder="请输入排序"
/>
</a-form-item>
<a-form-item label="状态" name="status">
<sc-select v-model:value="form.status" source-type="dictionary" dictionary-code="role_status" placeholder="请选择状态" allow-clear />
<sc-select
v-model:value="form.status"
source-type="dictionary"
dictionary-code="role_status"
placeholder="请选择状态"
allow-clear
/>
</a-form-item>
</a-form>
<template #footer>
<a-space>
<a-button @click="handleCancel"> </a-button>
<a-button v-if="mode !== 'show'" type="primary" :loading="isSaveing" @click="submit"> </a-button>
<a-button
v-if="mode !== 'show'"
type="primary"
:loading="isSaveing"
@click="submit"
> </a-button
>
</a-space>
</template>
</a-modal>
</template>
<script setup>
import { ref, reactive, computed } from 'vue'
import { message } from 'ant-design-vue'
import scSelect from '@/components/scSelect/index.vue'
import authApi from '@/api/auth'
import { ref, reactive, computed } from "vue";
import { message } from "ant-design-vue";
import scSelect from "@/components/scSelect/index.vue";
import authApi from "@/api/auth";
const emit = defineEmits(['success', 'closed'])
const emit = defineEmits(["success", "closed"]);
const mode = ref('add')
const mode = ref("add");
const titleMap = {
add: '新增角色',
edit: '编辑角色',
show: '查看角色'
}
const visible = ref(false)
const isSaveing = ref(false)
add: "新增角色",
edit: "编辑角色",
show: "查看角色",
};
const visible = ref(false);
const isSaveing = ref(false);
// 表单数据
const form = reactive({
id: '',
name: '',
code: '',
description: '',
id: "",
name: "",
code: "",
description: "",
sort: 1,
status: null
})
status: null,
});
// 表单引用
const dialogForm = ref()
const dialogForm = ref();
// 验证规则
const rules = {
name: [{ required: true, message: '请输入角色名称', trigger: 'blur' }],
name: [{ required: true, message: "请输入角色名称", trigger: "blur" }],
code: [
{ required: true, message: '请输入角色编码', trigger: 'blur' },
{ pattern: /^[a-zA-Z0-9_]+$/, message: '角色编码只能包含字母、数字和下划线', trigger: 'blur' }
{ required: true, message: "请输入角色编码", trigger: "blur" },
{
pattern: /^[a-zA-Z0-9_]+$/,
message: "角色编码只能包含字母、数字和下划线",
trigger: "blur",
},
],
sort: [
{ required: true, message: '请输入排序', trigger: 'change' },
{ type: 'number', message: '排序必须为数字', trigger: 'change' }
]
}
{ required: true, message: "请输入排序", trigger: "change" },
{ type: "number", message: "排序必须为数字", trigger: "change" },
],
};
// 显示对话框
const open = (openMode = 'add') => {
mode.value = openMode
visible.value = true
const open = (openMode = "add") => {
mode.value = openMode;
visible.value = true;
return {
setData,
open,
close
}
}
close,
};
};
// 关闭对话框
const close = () => {
visible.value = false
}
visible.value = false;
};
// 处理取消
const handleCancel = () => {
emit('closed')
visible.value = false
}
emit("closed");
visible.value = false;
};
// 表单提交方法
const submit = async () => {
try {
await dialogForm.value.validate()
isSaveing.value = true
let res = {}
if (mode.value === 'add') {
res = await authApi.roles.add.post(form)
await dialogForm.value.validate();
isSaveing.value = true;
let res = {};
if (mode.value === "add") {
res = await authApi.roles.add.post(form);
} else {
res = await authApi.roles.edit.put(form.id, form)
res = await authApi.roles.edit.put(form.id, form);
}
isSaveing.value = false
isSaveing.value = false;
if (res.code === 200) {
emit('success', form, mode.value)
visible.value = false
message.success('操作成功')
emit("success", form, mode.value);
visible.value = false;
message.success("操作成功");
} else {
message.error(res.message || '操作失败')
message.error(res.message || "操作失败");
}
} catch (error) {
console.error('表单验证失败', error)
isSaveing.value = false
console.error("表单验证失败", error);
isSaveing.value = false;
}
}
};
// 表单注入数据
const setData = (data) => {
form.id = data.id
form.name = data.name
form.code = data.code
form.description = data.description || ''
form.sort = data.sort
form.status = data.status !== undefined ? data.status : null
}
form.id = data.id;
form.name = data.name;
form.code = data.code;
form.description = data.description || "";
form.sort = data.sort;
form.status = data.status !== undefined ? data.status : null;
};
// 暴露方法给父组件
defineExpose({
open,
setData,
close
})
close,
});
</script>
<style></style>
+260 -175
View File
@@ -3,8 +3,12 @@
<div class="tool-bar">
<div class="left-panel">
<a-space>
<a-input v-model:value="searchForm.keyword" placeholder="角色名称" allow-clear
style="width: 180px" />
<a-input
v-model:value="searchForm.keyword"
placeholder="角色名称"
allow-clear
style="width: 180px"
/>
<a-button type="primary" @click="handleSearch">
<template #icon><SearchOutlined /></template>
搜索
@@ -62,18 +66,38 @@
</div>
</div>
<div class="table-content">
<scTable ref="tableRef" :columns="columns" :data-source="tableData" :loading="loading"
:pagination="pagination" :row-key="rowKey" :row-selection="rowSelection" @refresh="refreshTable"
@paginationChange="handlePaginationChange" @select="handleSelectChange" @selectAll="handleSelectAll">
<scTable
ref="tableRef"
:columns="columns"
:data-source="tableData"
:loading="loading"
:pagination="pagination"
:row-key="rowKey"
:row-selection="rowSelection"
@refresh="refreshTable"
@paginationChange="handlePaginationChange"
@select="handleSelectChange"
@selectAll="handleSelectAll"
>
<template #status="{ record }">
<a-tag :color="record.status === 1 ? 'success' : 'error'">
{{ record.status === 1 ? '正常' : '禁用' }}
{{ record.status === 1 ? "正常" : "禁用" }}
</a-tag>
</template>
<template #action="{ record }">
<a-space>
<a-button type="link" size="small" @click="handleEdit(record)">编辑</a-button>
<a-button type="link" size="small" @click="handleCopy(record)">复制</a-button>
<a-button
type="link"
size="small"
@click="handleEdit(record)"
>编辑</a-button
>
<a-button
type="link"
size="small"
@click="handleCopy(record)"
>复制</a-button
>
<a-dropdown>
<a-button type="link" size="small">
更多
@@ -84,11 +108,16 @@
<a-menu-item @click="handleView(record)">
<SearchOutlined />查看
</a-menu-item>
<a-menu-item @click="handlePermission(record)">
<a-menu-item
@click="handlePermission(record)"
>
<ImportOutlined />权限
</a-menu-item>
<a-menu-divider />
<a-menu-item @click="handleDelete(record)" danger>
<a-menu-item
@click="handleDelete(record)"
danger
>
<DeleteOutlined />删除
</a-menu-item>
</a-menu>
@@ -101,28 +130,54 @@
</div>
<!-- 新增/编辑角色弹窗 -->
<save-dialog v-if="dialog.save" ref="saveDialogRef" @success="handleSaveSuccess" @closed="dialog.save = false" />
<save-dialog
v-if="dialog.save"
ref="saveDialogRef"
@success="handleSaveSuccess"
@closed="dialog.save = false"
/>
<!-- 权限设置弹窗 -->
<permission-dialog v-if="dialog.permission" ref="permissionDialogRef" @success="permissionSuccess"
@closed="dialog.permission = false" />
<permission-dialog
v-if="dialog.permission"
ref="permissionDialogRef"
@success="permissionSuccess"
@closed="dialog.permission = false"
/>
<!-- 导入角色弹窗 -->
<sc-import v-model:open="dialog.import" title="导入角色" :api="authApi.roles.import.post"
:template-api="authApi.roles.downloadTemplate.get" filename="角色" @success="handleImportSuccess" />
<sc-import
v-model:open="dialog.import"
title="导入角色"
:api="authApi.roles.import.post"
:template-api="authApi.roles.downloadTemplate.get"
filename="角色"
@success="handleImportSuccess"
/>
<!-- 导出角色弹窗 -->
<sc-export v-model:open="dialog.export" title="导出角色" :api="handleExportApi"
:default-filename="`角色列表_${Date.now()}`" :show-options="false" tip="导出当前选中或所有角色数据"
@success="handleExportSuccess" />
<sc-export
v-model:open="dialog.export"
title="导出角色"
:api="handleExportApi"
:default-filename="`角色列表_${Date.now()}`"
:show-options="false"
tip="导出当前选中或所有角色数据"
@success="handleExportSuccess"
/>
<!-- 复制角色弹窗 -->
<copy-dialog v-if="dialog.copy" ref="copyDialogRef" @success="handleCopySuccess" @closed="dialog.copy = false" />
<copy-dialog
v-if="dialog.copy"
ref="copyDialogRef"
@success="handleCopySuccess"
@closed="dialog.copy = false"
/>
</template>
<script setup>
import { ref, reactive } from 'vue'
import { message, Modal } from 'ant-design-vue'
import { ref, reactive } from "vue";
import { message, Modal } from "ant-design-vue";
import {
SearchOutlined,
RedoOutlined,
@@ -133,20 +188,20 @@ import {
DeleteOutlined,
ImportOutlined,
ExportOutlined,
DownloadOutlined
} from '@ant-design/icons-vue'
import scTable from '@/components/scTable/index.vue'
import scImport from '@/components/scImport/index.vue'
import scExport from '@/components/scExport/index.vue'
import saveDialog from './components/SaveDialog.vue'
import permissionDialog from './components/PermissionDialog.vue'
import copyDialog from './components/CopyDialog.vue'
import authApi from '@/api/auth'
import { useTable } from '@/hooks/useTable'
DownloadOutlined,
} from "@ant-design/icons-vue";
import scTable from "@/components/scTable/index.vue";
import scImport from "@/components/scImport/index.vue";
import scExport from "@/components/scExport/index.vue";
import saveDialog from "./components/SaveDialog.vue";
import permissionDialog from "./components/PermissionDialog.vue";
import copyDialog from "./components/CopyDialog.vue";
import authApi from "@/api/auth";
import { useTable } from "@/hooks/useTable";
defineOptions({
name: 'authRole'
})
name: "authRole",
});
// 使用useTable hooks
const {
@@ -162,17 +217,17 @@ const {
handlePaginationChange,
handleSelectChange,
handleSelectAll,
refreshTable
refreshTable,
} = useTable({
api: authApi.roles.list.get,
searchForm: {
keyword: '',
status: null
keyword: "",
status: null,
},
columns: [],
needPagination: true,
needSelection: true
})
needSelection: true,
});
// 对话框状态
const dialog = reactive({
@@ -180,258 +235,288 @@ const dialog = reactive({
permission: false,
import: false,
export: false,
copy: false
})
copy: false,
});
// 弹窗引用
const saveDialogRef = ref(null)
const permissionDialogRef = ref(null)
const copyDialogRef = ref(null)
const saveDialogRef = ref(null);
const permissionDialogRef = ref(null);
const copyDialogRef = ref(null);
// 行key
const rowKey = 'id'
const rowKey = "id";
// 表格列配置
const columns = [
{ title: 'ID', dataIndex: 'id', key: 'id', width: 80, align: 'center' },
{ title: '角色名称', dataIndex: 'name', key: 'name', width: 200 },
{ title: '角色编码', dataIndex: 'code', key: 'code', width: 200 },
{ title: '描述', dataIndex: 'description', key: 'description', ellipsis: true },
{ title: '排序', dataIndex: 'sort', key: 'sort', width: 100, align: 'center' },
{ title: '状态', dataIndex: 'status', key: 'status', width: 100, align: 'center', slot: 'status' },
{ title: '操作', dataIndex: 'action', key: 'action', width: 180, align: 'center', slot: 'action', fixed: 'right' }
]
{ title: "ID", dataIndex: "id", key: "id", width: 80, align: "center" },
{ title: "角色名称", dataIndex: "name", key: "name", width: 200 },
{ title: "角色编码", dataIndex: "code", key: "code", width: 200 },
{
title: "描述",
dataIndex: "description",
key: "description",
ellipsis: true,
},
{
title: "排序",
dataIndex: "sort",
key: "sort",
width: 100,
align: "center",
},
{
title: "状态",
dataIndex: "status",
key: "status",
width: 100,
align: "center",
slot: "status",
},
{
title: "操作",
dataIndex: "action",
key: "action",
width: 180,
align: "center",
slot: "action",
fixed: "right",
},
];
// 新增角色
const handleAdd = () => {
dialog.save = true
dialog.save = true;
setTimeout(() => {
saveDialogRef.value?.open('add')
}, 0)
}
saveDialogRef.value?.open("add");
}, 0);
};
// 查看角色
const handleView = (record) => {
dialog.save = true
dialog.save = true;
setTimeout(() => {
saveDialogRef.value?.open('show').setData(record)
}, 0)
}
saveDialogRef.value?.open("show").setData(record);
}, 0);
};
// 编辑角色
const handleEdit = (record) => {
dialog.save = true
dialog.save = true;
setTimeout(() => {
saveDialogRef.value?.open('edit').setData(record)
}, 0)
}
saveDialogRef.value?.open("edit").setData(record);
}, 0);
};
// 删除角色
const handleDelete = (record) => {
Modal.confirm({
title: '确认删除',
content: '确定删除该角色吗?',
okText: '确定',
cancelText: '取消',
okType: 'danger',
title: "确认删除",
content: "确定删除该角色吗?",
okText: "确定",
cancelText: "取消",
okType: "danger",
onOk: async () => {
try {
const res = await authApi.roles.delete.delete(record.id)
const res = await authApi.roles.delete.delete(record.id);
if (res.code === 200) {
message.success('删除成功')
refreshTable()
message.success("删除成功");
refreshTable();
} else {
message.error(res.message || '删除失败')
message.error(res.message || "删除失败");
}
} catch (error) {
console.error('删除角色失败:', error)
message.error('删除失败')
console.error("删除角色失败:", error);
message.error("删除失败");
}
}
})
}
},
});
};
// 批量删除
const handleBatchDelete = () => {
if (selectedRows.value.length === 0) {
message.warning('请选择要删除的角色')
return
message.warning("请选择要删除的角色");
return;
}
Modal.confirm({
title: '确认删除',
title: "确认删除",
content: `确定删除选中的 ${selectedRows.value.length} 个角色吗?`,
okText: '确定',
cancelText: '取消',
okType: 'danger',
okText: "确定",
cancelText: "取消",
okType: "danger",
onOk: async () => {
try {
const ids = selectedRows.value.map(item => item.id)
const res = await authApi.roles.batchDelete.post({ ids })
const ids = selectedRows.value.map((item) => item.id);
const res = await authApi.roles.batchDelete.post({ ids });
if (res.code === 200) {
message.success('删除成功')
selectedRows.value = []
refreshTable()
message.success("删除成功");
selectedRows.value = [];
refreshTable();
} else {
message.error(res.message || '删除失败')
message.error(res.message || "删除失败");
}
} catch (error) {
console.error('批量删除角色失败:', error)
message.error('删除失败')
console.error("批量删除角色失败:", error);
message.error("删除失败");
}
}
})
}
},
});
};
// 批量更新状态
const handleBatchStatus = () => {
if (selectedRows.value.length === 0) {
message.warning('请选择要操作的角色')
return
message.warning("请选择要操作的角色");
return;
}
Modal.confirm({
title: '确认操作',
content: '确定要批量启用/禁用选中的角色吗?',
okText: '确定',
cancelText: '取消',
title: "确认操作",
content: "确定要批量启用/禁用选中的角色吗?",
okText: "确定",
cancelText: "取消",
onOk: async () => {
try {
const ids = selectedRows.value.map(item => item.id)
const status = selectedRows.value[0].status === 1 ? 0 : 1
const res = await authApi.roles.batchStatus.post({ ids, status })
const ids = selectedRows.value.map((item) => item.id);
const status = selectedRows.value[0].status === 1 ? 0 : 1;
const res = await authApi.roles.batchStatus.post({
ids,
status,
});
if (res.code === 200) {
message.success('操作成功')
selectedRows.value = []
refreshTable()
message.success("操作成功");
selectedRows.value = [];
refreshTable();
} else {
message.error(res.message || '操作失败')
message.error(res.message || "操作失败");
}
} catch (error) {
console.error('批量更新状态失败:', error)
message.error('操作失败')
console.error("批量更新状态失败:", error);
message.error("操作失败");
}
}
})
}
},
});
};
// 复制角色
const handleCopy = (record) => {
dialog.copy = true
dialog.copy = true;
setTimeout(() => {
copyDialogRef.value?.open(record)
}, 0)
}
copyDialogRef.value?.open(record);
}, 0);
};
// 批量复制角色
const handleBatchCopy = () => {
if (selectedRows.value.length === 0) {
message.warning('请选择要复制的角色')
return
message.warning("请选择要复制的角色");
return;
}
Modal.confirm({
title: '确认批量复制',
title: "确认批量复制",
content: `确定复制选中的 ${selectedRows.value.length} 个角色吗?`,
okText: '确定',
cancelText: '取消',
okText: "确定",
cancelText: "取消",
onOk: async () => {
try {
const ids = selectedRows.value.map(item => item.id)
const res = await authApi.roles.batchCopy.post({ ids })
const ids = selectedRows.value.map((item) => item.id);
const res = await authApi.roles.batchCopy.post({ ids });
if (res.code === 200) {
message.success('批量复制成功')
selectedRows.value = []
refreshTable()
message.success("批量复制成功");
selectedRows.value = [];
refreshTable();
} else {
message.error(res.message || '批量复制失败')
message.error(res.message || "批量复制失败");
}
} catch (error) {
console.error('批量复制角色失败:', error)
message.error('批量复制失败')
console.error("批量复制角色失败:", error);
message.error("批量复制失败");
}
}
})
}
},
});
};
// 复制成功回调
const handleCopySuccess = () => {
refreshTable()
}
refreshTable();
};
// 权限设置
const handlePermission = (record) => {
if (!record && selectedRows.value.length !== 1) {
message.error('请选择一个角色进行权限设置')
return
message.error("请选择一个角色进行权限设置");
return;
}
const roleData = record || selectedRows.value[0]
dialog.permission = true
const roleData = record || selectedRows.value[0];
dialog.permission = true;
setTimeout(() => {
permissionDialogRef.value?.open().setData(roleData)
}, 0)
}
permissionDialogRef.value?.open().setData(roleData);
}, 0);
};
// 重置
const handleUserReset = () => {
searchForm.keyword = ''
searchForm.status = null
handleSearch()
}
searchForm.keyword = "";
searchForm.status = null;
handleSearch();
};
// 保存成功回调
const handleSaveSuccess = () => {
refreshTable()
}
refreshTable();
};
// 权限设置成功回调
const permissionSuccess = () => {
refreshTable()
}
refreshTable();
};
// 导出角色
const handleExport = async () => {
dialog.export = true
}
dialog.export = true;
};
// 导出API封装
const handleExportApi = async () => {
const ids = selectedRows.value.map(item => item.id)
return await authApi.roles.export.post({ ids: ids.length > 0 ? ids : undefined })
}
const ids = selectedRows.value.map((item) => item.id);
return await authApi.roles.export.post({
ids: ids.length > 0 ? ids : undefined,
});
};
// 导出成功回调
const handleExportSuccess = () => {
selectedRows.value = []
}
selectedRows.value = [];
};
// 导入角色
const handleImport = () => {
dialog.import = true
}
dialog.import = true;
};
// 导入成功回调
const handleImportSuccess = () => {
refreshTable()
}
refreshTable();
};
// 下载模板
const handleDownloadTemplate = async () => {
try {
const blob = await authApi.roles.downloadTemplate.get()
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = '角色导入模板.xlsx'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
message.success('下载成功')
const blob = await authApi.roles.downloadTemplate.get();
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "角色导入模板.xlsx";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
message.success("下载成功");
} catch (error) {
console.error('下载模板失败:', error)
message.error('下载失败')
console.error("下载模板失败:", error);
message.error("下载失败");
}
}
};
</script>
@@ -1,5 +1,11 @@
<template>
<a-modal v-model:open="visible" title="批量分配角色" :confirm-loading="loading" @ok="handleOk" @cancel="handleCancel">
<a-modal
v-model:open="visible"
title="批量分配角色"
:confirm-loading="loading"
@ok="handleOk"
@cancel="handleCancel"
>
<a-form :label-col="{ span: 4 }" :wrapper-col="{ span: 20 }">
<a-form-item label="角色">
<a-select
@@ -19,86 +25,86 @@
</template>
<script setup>
import { ref } from 'vue'
import { message } from 'ant-design-vue'
import authApi from '@/api/auth'
import { ref } from "vue";
import { message } from "ant-design-vue";
import authApi from "@/api/auth";
const visible = ref(false)
const loading = ref(false)
const roleLoading = ref(false)
const roleOptions = ref([])
const userIds = ref([])
const selectedRoleIds = ref([])
const visible = ref(false);
const loading = ref(false);
const roleLoading = ref(false);
const roleOptions = ref([]);
const userIds = ref([]);
const selectedRoleIds = ref([]);
// 打开弹窗
const open = async (ids) => {
visible.value = true
userIds.value = ids
selectedRoleIds.value = []
await loadRoles()
}
visible.value = true;
userIds.value = ids;
selectedRoleIds.value = [];
await loadRoles();
};
// 加载角色列表
const loadRoles = async () => {
try {
roleLoading.value = true
const res = await authApi.roles.list.get({ page_size: 1000 })
roleLoading.value = true;
const res = await authApi.roles.list.get({ page_size: 1000 });
if (res.code === 200) {
roleOptions.value = (res.data.list || []).map(role => ({
roleOptions.value = (res.data.list || []).map((role) => ({
id: role.id,
name: role.name,
code: role.code
}))
code: role.code,
}));
}
} catch (error) {
console.error('加载角色列表失败:', error)
console.error("加载角色列表失败:", error);
} finally {
roleLoading.value = false
roleLoading.value = false;
}
}
};
// 角色过滤
const filterOption = (input, option) => {
const name = option?.name?.toLowerCase() || ''
const code = option?.code?.toLowerCase() || ''
const keyword = input?.toLowerCase() || ''
return name.includes(keyword) || code.includes(keyword)
}
const name = option?.name?.toLowerCase() || "";
const code = option?.code?.toLowerCase() || "";
const keyword = input?.toLowerCase() || "";
return name.includes(keyword) || code.includes(keyword);
};
// 确认
const handleOk = async () => {
try {
loading.value = true
loading.value = true;
const res = await authApi.users.batchRoles.post({
ids: userIds.value,
role_ids: selectedRoleIds.value
})
role_ids: selectedRoleIds.value,
});
if (res.code === 200) {
message.success('分配成功')
emit('success')
handleCancel()
message.success("分配成功");
emit("success");
handleCancel();
} else {
message.error(res.message || '分配失败')
message.error(res.message || "分配失败");
}
} catch (error) {
console.error('批量分配角色失败:', error)
message.error(error.message || '分配失败')
console.error("批量分配角色失败:", error);
message.error(error.message || "分配失败");
} finally {
loading.value = false
loading.value = false;
}
}
};
// 取消
const handleCancel = () => {
visible.value = false
loading.value = false
selectedRoleIds.value = []
}
visible.value = false;
loading.value = false;
selectedRoleIds.value = [];
};
const emit = defineEmits(['success'])
const emit = defineEmits(["success"]);
defineExpose({
open
})
open,
});
</script>
@@ -1,13 +1,27 @@
<template>
<a-modal v-model:open="visible" title="批量分配部门" :confirm-loading="loading" @ok="handleOk" @cancel="handleCancel">
<a-form :model="formState" :label-col="{ span: 4 }" :wrapper-col="{ span: 20 }">
<a-modal
v-model:open="visible"
title="批量分配部门"
:confirm-loading="loading"
@ok="handleOk"
@cancel="handleCancel"
>
<a-form
:model="formState"
:label-col="{ span: 4 }"
:wrapper-col="{ span: 20 }"
>
<a-form-item label="部门">
<a-tree-select
v-model:value="formState.department_id"
:tree-data="departmentTree"
placeholder="请选择部门"
allow-clear
:field-names="{ label: 'name', value: 'id', children: 'children' }"
:field-names="{
label: 'name',
value: 'id',
children: 'children',
}"
tree-default-expand-all
show-search
:filter-tree-node="filterTreeNode"
@@ -18,84 +32,84 @@
</template>
<script setup>
import { ref, reactive } from 'vue'
import { message } from 'ant-design-vue'
import authApi from '@/api/auth'
import { ref, reactive } from "vue";
import { message } from "ant-design-vue";
import authApi from "@/api/auth";
const visible = ref(false)
const loading = ref(false)
const departmentTree = ref([])
const userIds = ref([])
const visible = ref(false);
const loading = ref(false);
const departmentTree = ref([]);
const userIds = ref([]);
const formState = reactive({
department_id: undefined
})
department_id: undefined,
});
// 打开弹窗
const open = (ids) => {
visible.value = true
userIds.value = ids
formState.department_id = undefined
loadDepartmentTree()
}
visible.value = true;
userIds.value = ids;
formState.department_id = undefined;
loadDepartmentTree();
};
// 加载部门树
const loadDepartmentTree = async () => {
try {
const res = await authApi.departments.tree.get()
const res = await authApi.departments.tree.get();
if (res.code === 200) {
departmentTree.value = res.data || []
departmentTree.value = res.data || [];
}
} catch (error) {
console.error('加载部门树失败:', error)
console.error("加载部门树失败:", error);
}
}
};
// 树节点过滤
const filterTreeNode = (inputValue, treeNode) => {
const name = treeNode.dataRef.name
return name ? name.toLowerCase().includes(inputValue.toLowerCase()) : false
}
const name = treeNode.dataRef.name;
return name ? name.toLowerCase().includes(inputValue.toLowerCase()) : false;
};
// 确认
const handleOk = async () => {
if (!formState.department_id) {
message.warning('请选择部门')
return
message.warning("请选择部门");
return;
}
try {
loading.value = true
loading.value = true;
const res = await authApi.users.batchDepartment.post({
ids: userIds.value,
department_id: formState.department_id
})
department_id: formState.department_id,
});
if (res.code === 200) {
message.success('分配成功')
emit('success')
handleCancel()
message.success("分配成功");
emit("success");
handleCancel();
} else {
message.error(res.message || '分配失败')
message.error(res.message || "分配失败");
}
} catch (error) {
console.error('批量分配部门失败:', error)
message.error(error.message || '分配失败')
console.error("批量分配部门失败:", error);
message.error(error.message || "分配失败");
} finally {
loading.value = false
loading.value = false;
}
}
};
// 取消
const handleCancel = () => {
visible.value = false
loading.value = false
formState.department_id = undefined
}
visible.value = false;
loading.value = false;
formState.department_id = undefined;
};
const emit = defineEmits(['success'])
const emit = defineEmits(["success"]);
defineExpose({
open
})
open,
});
</script>
@@ -1,5 +1,11 @@
<template>
<a-modal v-model:open="visible" title="设置角色" :confirm-loading="loading" @ok="handleOk" @cancel="handleCancel">
<a-modal
v-model:open="visible"
title="设置角色"
:confirm-loading="loading"
@ok="handleOk"
@cancel="handleCancel"
>
<a-form :label-col="{ span: 4 }" :wrapper-col="{ span: 20 }">
<a-form-item label="用户">
<a-input :value="userForm.username" disabled />
@@ -22,104 +28,104 @@
</template>
<script setup>
import { ref, reactive } from 'vue'
import { message } from 'ant-design-vue'
import authApi from '@/api/auth'
import { ref, reactive } from "vue";
import { message } from "ant-design-vue";
import authApi from "@/api/auth";
const visible = ref(false)
const loading = ref(false)
const roleLoading = ref(false)
const roleOptions = ref([])
const selectedRoleIds = ref([])
const userId = ref(null)
const visible = ref(false);
const loading = ref(false);
const roleLoading = ref(false);
const roleOptions = ref([]);
const selectedRoleIds = ref([]);
const userId = ref(null);
const userForm = reactive({
username: ''
})
username: "",
});
// 打开弹窗
const open = () => {
visible.value = true
loadRoles()
}
visible.value = true;
loadRoles();
};
// 设置用户数据
const setData = (user) => {
userId.value = user.id
userForm.username = user.username
userId.value = user.id;
userForm.username = user.username;
// 设置已选择的角色
selectedRoleIds.value = (user.roles || []).map(role => role.id)
}
selectedRoleIds.value = (user.roles || []).map((role) => role.id);
};
// 加载角色列表
const loadRoles = async () => {
try {
roleLoading.value = true
const res = await authApi.roles.list.get({ page_size: 1000 })
roleLoading.value = true;
const res = await authApi.roles.list.get({ page_size: 1000 });
if (res.code === 200) {
roleOptions.value = (res.data.list || []).map(role => ({
roleOptions.value = (res.data.list || []).map((role) => ({
id: role.id,
name: role.name,
code: role.code
}))
code: role.code,
}));
}
} catch (error) {
console.error('加载角色列表失败:', error)
console.error("加载角色列表失败:", error);
} finally {
roleLoading.value = false
roleLoading.value = false;
}
}
};
// 角色过滤
const filterOption = (input, option) => {
const name = option?.name?.toLowerCase() || ''
const code = option?.code?.toLowerCase() || ''
const keyword = input?.toLowerCase() || ''
return name.includes(keyword) || code.includes(keyword)
}
const name = option?.name?.toLowerCase() || "";
const code = option?.code?.toLowerCase() || "";
const keyword = input?.toLowerCase() || "";
return name.includes(keyword) || code.includes(keyword);
};
// 确认
const handleOk = async () => {
if (!userId.value) {
message.warning('用户ID不能为空')
return
message.warning("用户ID不能为空");
return;
}
try {
loading.value = true
loading.value = true;
const res = await authApi.users.batchRoles.post({
ids: [userId.value],
role_ids: selectedRoleIds.value
})
role_ids: selectedRoleIds.value,
});
if (res.code === 200) {
message.success('设置成功')
emit('success')
handleCancel()
message.success("设置成功");
emit("success");
handleCancel();
} else {
message.error(res.message || '设置失败')
message.error(res.message || "设置失败");
}
} catch (error) {
console.error('设置角色失败:', error)
message.error(error.message || '设置失败')
console.error("设置角色失败:", error);
message.error(error.message || "设置失败");
} finally {
loading.value = false
loading.value = false;
}
}
};
// 取消
const handleCancel = () => {
visible.value = false
loading.value = false
userId.value = null
userForm.username = ''
selectedRoleIds.value = []
}
visible.value = false;
loading.value = false;
userId.value = null;
userForm.username = "";
selectedRoleIds.value = [];
};
const emit = defineEmits(['success'])
const emit = defineEmits(["success"]);
defineExpose({
open,
setData
})
setData,
});
</script>
@@ -1,197 +1,293 @@
<template>
<a-modal :title="titleMap[mode]" :open="visible" :width="500" :destroy-on-close="true" :mask-closable="false"
:footer="null" @cancel="handleCancel">
<a-form :model="form" :rules="rules" :disabled="mode === 'show'" ref="dialogForm" :label-col="{ span: 5 }"
:wrapper-col="{ span: 18 }">
<a-form-item label="头像" name="avatar">
<sc-upload v-model="form.avatar" :cropper="true" :aspectRatio="1" title="上传头像"></sc-upload>
</a-form-item>
<a-form-item label="用户名" name="username">
<a-input v-model:value="form.username" placeholder="请输入用户名" allow-clear :disabled="mode === 'edit'" />
</a-form-item>
<a-form-item label="真实姓名" name="real_name">
<a-input v-model:value="form.real_name" placeholder="请输入真实姓名" allow-clear />
</a-form-item>
<a-form-item label="邮箱" name="email">
<a-input v-model:value="form.email" placeholder="请输入邮箱" allow-clear />
</a-form-item>
<a-form-item label="手机号" name="phone">
<a-input v-model:value="form.phone" placeholder="请输入手机号" allow-clear />
</a-form-item>
<template v-if="mode === 'add'">
<a-form-item label="登录密码" name="password">
<a-input-password v-model:value="form.password" placeholder="请输入登录密码" allow-clear />
<a-modal
:title="titleMap[mode]"
:open="visible"
:width="500"
:destroy-on-close="true"
:mask-closable="false"
@cancel="handleCancel"
>
<a-form
:model="form"
:rules="rules"
:disabled="mode === 'show'"
ref="dialogForm"
:label-col="{ span: 5 }"
:wrapper-col="{ span: 18 }"
>
<a-form-item label="头像" name="avatar">
<sc-upload
v-model="form.avatar"
:cropper="true"
:aspectRatio="1"
title="上传头像"
></sc-upload>
</a-form-item>
<a-form-item label="确认密码" name="password2">
<a-input-password v-model:value="form.password2" placeholder="请再次输入密码" allow-clear />
<a-form-item label="用户名" name="username">
<a-input
v-model:value="form.username"
placeholder="请输入用户名"
allow-clear
:disabled="mode === 'edit'"
/>
</a-form-item>
</template>
<a-form-item label="所属部门" name="department_id">
<a-tree-select v-model:value="form.department_id" :tree-data="department"
:field-names="departmentFieldNames" :tree-default-expand-all="false" show-icon placeholder="请选择部门" allow-clear
tree-node-filter-prop="name" />
</a-form-item>
<a-form-item label="所属角色" name="role_ids">
<a-select v-model:value="form.role_ids" mode="multiple" placeholder="请选择角色" allow-clear style="width: 100%">
<a-select-option v-for="role in rolesList" :key="role.id" :value="role.id">
{{ role.name }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="性别" name="gender">
<sc-select v-model:value="form.gender" source-type="dictionary" dictionary-code="gender" placeholder="请选择性别" allow-clear />
</a-form-item>
<a-form-item label="状态" name="status">
<sc-select v-model:value="form.status" source-type="dictionary" dictionary-code="user_status" placeholder="请选择状态" allow-clear />
</a-form-item>
</a-form>
<a-form-item label="真实姓名" name="real_name">
<a-input
v-model:value="form.real_name"
placeholder="请输入真实姓名"
allow-clear
/>
</a-form-item>
<a-form-item label="邮箱" name="email">
<a-input
v-model:value="form.email"
placeholder="请输入邮箱"
allow-clear
/>
</a-form-item>
<a-form-item label="手机号" name="phone">
<a-input
v-model:value="form.phone"
placeholder="请输入手机号"
allow-clear
/>
</a-form-item>
<template v-if="mode === 'add'">
<a-form-item label="登录密码" name="password">
<a-input-password
v-model:value="form.password"
placeholder="请输入登录密码"
allow-clear
/>
</a-form-item>
<a-form-item label="确认密码" name="password2">
<a-input-password
v-model:value="form.password2"
placeholder="请再次输入密码"
allow-clear
/>
</a-form-item>
</template>
<a-form-item label="所属部门" name="department_id">
<a-tree-select
v-model:value="form.department_id"
:tree-data="department"
:field-names="departmentFieldNames"
:tree-default-expand-all="false"
show-icon
placeholder="请选择部门"
allow-clear
tree-node-filter-prop="name"
/>
</a-form-item>
<a-form-item label="所属角色" name="role_ids">
<a-select
v-model:value="form.role_ids"
mode="multiple"
placeholder="请选择角色"
allow-clear
style="width: 100%"
>
<a-select-option
v-for="role in rolesList"
:key="role.id"
:value="role.id"
>
{{ role.name }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="性别" name="gender">
<sc-select
v-model:value="form.gender"
source-type="dictionary"
dictionary-code="gender"
placeholder="请选择性别"
allow-clear
/>
</a-form-item>
<a-form-item label="状态" name="status">
<sc-select
v-model:value="form.status"
source-type="dictionary"
dictionary-code="user_status"
placeholder="请选择状态"
allow-clear
/>
</a-form-item>
</a-form>
<template #footer>
<a-button @click="handleCancel"> </a-button>
<a-button v-if="mode !== 'show'" type="primary" :loading="isSaveing" @click="submit"> </a-button>
<a-button
v-if="mode !== 'show'"
type="primary"
:loading="isSaveing"
@click="submit"
> </a-button
>
</template>
</a-modal>
</template>
<script setup>
import { ref, reactive, computed } from 'vue'
import { message } from 'ant-design-vue'
import scUpload from '@/components/scUpload/index.vue'
import scSelect from '@/components/scSelect/index.vue'
import authApi from '@/api/auth'
import { ref, reactive, computed } from "vue";
import { message } from "ant-design-vue";
import scUpload from "@/components/scUpload/index.vue";
import scSelect from "@/components/scSelect/index.vue";
import authApi from "@/api/auth";
const emit = defineEmits(['success', 'closed'])
const emit = defineEmits(["success", "closed"]);
const mode = ref('add')
const mode = ref("add");
const titleMap = {
add: '新增用户',
edit: '编辑用户',
show: '查看用户'
}
const visible = ref(false)
const isSaveing = ref(false)
add: "新增用户",
edit: "编辑用户",
show: "查看用户",
};
const visible = ref(false);
const isSaveing = ref(false);
// 表单数据
const form = reactive({
id: '',
username: '',
avatar: '',
real_name: '',
email: '',
phone: '',
id: "",
username: "",
avatar: "",
real_name: "",
email: "",
phone: "",
department_id: null,
role_ids: [],
gender: null,
status: null
})
status: null,
});
// 表单引用
const dialogForm = ref()
const dialogForm = ref();
// 验证规则
const rules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 3, max: 50, message: '用户名长度在 3 到 50 个字符', trigger: 'blur' }
{ required: true, message: "请输入用户名", trigger: "blur" },
{
min: 3,
max: 50,
message: "用户名长度在 3 到 50 个字符",
trigger: "blur",
},
],
real_name: [
{ required: true, message: '请输入真实姓名', trigger: 'blur' },
{ min: 2, max: 50, message: '真实姓名长度在 2 到 50 个字符', trigger: 'blur' }
{ required: true, message: "请输入真实姓名", trigger: "blur" },
{
min: 2,
max: 50,
message: "真实姓名长度在 2 到 50 个字符",
trigger: "blur",
},
],
email: [
{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' }
{ type: "email", message: "请输入正确的邮箱地址", trigger: "blur" },
],
phone: [
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }
{
pattern: /^1[3-9]\d{9}$/,
message: "请输入正确的手机号",
trigger: "blur",
},
],
password: [
{ required: true, message: '请输入登录密码', trigger: 'blur' },
{ min: 6, max: 20, message: '密码长度在 6 到 20 个字符', trigger: 'blur' },
{ required: true, message: "请输入登录密码", trigger: "blur" },
{
min: 6,
max: 20,
message: "密码长度在 6 到 20 个字符",
trigger: "blur",
},
{
validator: (rule, value) => {
if (form.password2 !== '') {
dialogForm.value?.validateFields('password2')
if (form.password2 !== "") {
dialogForm.value?.validateFields("password2");
}
return Promise.resolve()
return Promise.resolve();
},
trigger: 'change'
}
trigger: "change",
},
],
password2: [
{ required: true, message: '请再次输入密码', trigger: 'blur' },
{ required: true, message: "请再次输入密码", trigger: "blur" },
{
validator: (rule, value) => {
if (value !== form.password) {
return Promise.reject(new Error('两次输入密码不一致!'))
return Promise.reject(new Error("两次输入密码不一致!"));
}
return Promise.resolve()
return Promise.resolve();
},
trigger: 'blur'
}
]
}
trigger: "blur",
},
],
};
// 部门数据
const department = ref([])
const department = ref([]);
const departmentFieldNames = {
value: 'id',
label: 'name',
children: 'children'
}
value: "id",
label: "name",
children: "children",
};
// 角色列表
const rolesList = ref([])
const rolesList = ref([]);
// 显示对话框
const open = (openMode = 'add') => {
mode.value = openMode
visible.value = true
const open = (openMode = "add") => {
mode.value = openMode;
visible.value = true;
return {
setData,
open,
close
}
}
close,
};
};
// 关闭对话框
const close = () => {
visible.value = false
}
visible.value = false;
};
// 处理取消
const handleCancel = () => {
emit('closed')
visible.value = false
}
emit("closed");
visible.value = false;
};
// 加载部门树数据
const loadDepartment = async () => {
try {
const res = await authApi.departments.tree.get()
const res = await authApi.departments.tree.get();
if (res.code === 200) {
department.value = res.data || []
department.value = res.data || [];
}
} catch (error) {
console.error('加载部门树失败:', error)
console.error("加载部门树失败:", error);
}
}
};
// 加载角色列表
const loadRoles = async () => {
try {
const res = await authApi.roles.all.get()
const res = await authApi.roles.all.get();
if (res.code === 200) {
rolesList.value = res.data || []
rolesList.value = res.data || [];
}
} catch (error) {
console.error('加载角色列表失败:', error)
console.error("加载角色列表失败:", error);
}
}
};
// 表单提交方法
const submit = async () => {
try {
await dialogForm.value.validate()
isSaveing.value = true
await dialogForm.value.validate();
isSaveing.value = true;
const submitData = {
username: form.username,
@@ -202,58 +298,58 @@ const submit = async () => {
department_id: form.department_id,
role_ids: form.role_ids,
gender: form.gender,
status: form.status
status: form.status,
};
if (mode.value === "add") {
submitData.password = form.password;
}
if (mode.value === 'add') {
submitData.password = form.password
}
let res = {}
if (mode.value === 'add') {
res = await authApi.users.add.post(submitData)
let res = {};
if (mode.value === "add") {
res = await authApi.users.add.post(submitData);
} else {
res = await authApi.users.edit.put(form.id, submitData)
res = await authApi.users.edit.put(form.id, submitData);
}
isSaveing.value = false
isSaveing.value = false;
if (res.code === 200) {
emit('success', form, mode.value)
visible.value = false
message.success('操作成功')
emit("success", form, mode.value);
visible.value = false;
message.success("操作成功");
} else {
message.error(res.message || '操作失败')
message.error(res.message || "操作失败");
}
} catch (error) {
console.error('表单验证失败', error)
isSaveing.value = false
console.error("表单验证失败", error);
isSaveing.value = false;
}
}
};
// 表单注入数据
const setData = (data) => {
form.id = data.id
form.username = data.username
form.avatar = data.avatar
form.real_name = data.real_name
form.email = data.email
form.phone = data.phone
form.department_id = data.department_id
form.role_ids = data.roles ? data.roles.map(item => item.id) : []
form.gender = data.gender !== undefined ? data.gender : null
form.status = data.status !== undefined ? data.status : null
}
form.id = data.id;
form.username = data.username;
form.avatar = data.avatar;
form.real_name = data.real_name;
form.email = data.email;
form.phone = data.phone;
form.department_id = data.department_id;
form.role_ids = data.roles ? data.roles.map((item) => item.id) : [];
form.gender = data.gender !== undefined ? data.gender : null;
form.status = data.status !== undefined ? data.status : null;
};
// 组件挂载时加载数据
loadDepartment()
loadRoles()
loadDepartment();
loadRoles();
// 暴露方法给父组件
defineExpose({
open,
setData,
close
})
close,
});
</script>
<style></style>
+397 -237
View File
@@ -2,17 +2,37 @@
<div class="pages-sidebar-layout user-page">
<div class="left-box">
<div class="header">
<a-input v-model:value="departmentKeyword" placeholder="搜索部门..." allow-clear @change="handleDeptSearch">
<a-input
v-model:value="departmentKeyword"
placeholder="搜索部门..."
allow-clear
@change="handleDeptSearch"
>
<template #prefix>
<SearchOutlined style="color: rgba(0, 0, 0, 0.45)" />
</template>
</a-input>
</div>
<div class="body">
<a-tree v-if="filteredDepartmentTree.length > 0" v-model:selectedKeys="selectedDeptKeys" v-model:expandedKeys="expandedDeptKeys" :tree-data="filteredDepartmentTree"
:field-names="{ title: 'name', key: 'id', children: 'children' }" show-line @select="onDeptSelect">
<a-tree
v-if="filteredDepartmentTree.length > 0"
v-model:selectedKeys="selectedDeptKeys"
v-model:expandedKeys="expandedDeptKeys"
:tree-data="filteredDepartmentTree"
:field-names="{
title: 'name',
key: 'id',
children: 'children',
}"
show-line
@select="onDeptSelect"
>
<template #icon="{ dataRef }">
<ApartmentOutlined v-if="dataRef.children && dataRef.children.length > 0" />
<ApartmentOutlined
v-if="
dataRef.children && dataRef.children.length > 0
"
/>
<UserOutlined v-else />
</template>
</a-tree>
@@ -23,8 +43,12 @@
<div class="tool-bar">
<div class="left-panel">
<a-space>
<a-input v-model:value="searchForm.username" placeholder="用户名" allow-clear
style="width: 140px" />
<a-input
v-model:value="searchForm.username"
placeholder="用户名"
allow-clear
style="width: 140px"
/>
<a-button type="primary" @click="handleSearch">
<template #icon><SearchOutlined /></template>
搜索
@@ -85,35 +109,76 @@
</div>
</div>
<div class="table-content">
<scTable ref="tableRef" :columns="columns" :data-source="tableData" :loading="loading"
:pagination="pagination" :row-key="rowKey" :row-selection="rowSelection" @refresh="refreshTable"
@paginationChange="handlePaginationChange" @select="handleSelectChange" @selectAll="handleSelectAll">
<scTable
ref="tableRef"
:columns="columns"
:data-source="tableData"
:loading="loading"
:pagination="pagination"
:row-key="rowKey"
:row-selection="rowSelection"
@refresh="refreshTable"
@paginationChange="handlePaginationChange"
@select="handleSelectChange"
@selectAll="handleSelectAll"
>
<template #avatar="{ record }">
<a-avatar :src="record.avatar" :size="32">
<template #icon><UserOutlined /></template>
</a-avatar>
</template>
<template #status="{ record }">
<a-tag :color="record.status === 1 ? 'success' : 'error'">
{{ record.status === 1 ? '正常' : '禁用' }}
<a-tag
:color="record.status === 1 ? 'success' : 'error'"
>
{{ record.status === 1 ? "正常" : "禁用" }}
</a-tag>
</template>
<template #department="{ record }">
{{ record.department?.name || '-' }}
{{ record.department?.name || "-" }}
</template>
<template #roles="{ record }">
<a-tag v-for="role in record.roles" :key="role.id" color="blue">
<a-tag
v-for="role in record.roles"
:key="role.id"
color="blue"
>
{{ role.name }}
</a-tag>
</template>
<template #action="{ record }">
<a-space>
<a-button type="link" size="small" @click="handleView(record)">查看</a-button>
<a-button type="link" size="small" @click="handleEdit(record)">编辑</a-button>
<a-button type="link" size="small" @click="handleRole(record)">角色</a-button>
<a-button type="link" size="small" @click="handleResetPassword(record)">重置密码</a-button>
<a-popconfirm title="确定删除该用户吗?" @confirm="handleDelete(record)">
<a-button type="link" size="small" danger>删除</a-button>
<a-button
type="link"
size="small"
@click="handleView(record)"
>查看</a-button
>
<a-button
type="link"
size="small"
@click="handleEdit(record)"
>编辑</a-button
>
<a-button
type="link"
size="small"
@click="handleRole(record)"
>角色</a-button
>
<a-button
type="link"
size="small"
@click="handleResetPassword(record)"
>重置密码</a-button
>
<a-popconfirm
title="确定删除该用户吗?"
@confirm="handleDelete(record)"
>
<a-button type="link" size="small" danger
>删除</a-button
>
</a-popconfirm>
</a-space>
</template>
@@ -123,30 +188,62 @@
</div>
<!-- 新增/编辑用户弹窗 -->
<save-dialog v-if="dialog.save" ref="saveDialogRef" @success="handleSaveSuccess" @closed="dialog.save = false" />
<save-dialog
v-if="dialog.save"
ref="saveDialogRef"
@success="handleSaveSuccess"
@closed="dialog.save = false"
/>
<!-- 角色设置弹窗 -->
<role-dialog v-if="dialog.role" ref="roleDialogRef" @success="handleRoleSuccess" @closed="dialog.role = false" />
<role-dialog
v-if="dialog.role"
ref="roleDialogRef"
@success="handleRoleSuccess"
@closed="dialog.role = false"
/>
<!-- 批量分配部门弹窗 -->
<department-dialog v-if="dialog.department" ref="departmentDialogRef" @success="handleDepartmentSuccess" @closed="dialog.department = false" />
<department-dialog
v-if="dialog.department"
ref="departmentDialogRef"
@success="handleDepartmentSuccess"
@closed="dialog.department = false"
/>
<!-- 批量分配角色弹窗 -->
<batch-role-dialog v-if="dialog.batchRole" ref="batchRoleDialogRef" @success="handleBatchRoleSuccess" @closed="dialog.batchRole = false" />
<batch-role-dialog
v-if="dialog.batchRole"
ref="batchRoleDialogRef"
@success="handleBatchRoleSuccess"
@closed="dialog.batchRole = false"
/>
<!-- 导入用户弹窗 -->
<sc-import v-model:open="dialog.import" title="导入用户" :api="authApi.users.import.post"
:template-api="authApi.users.downloadTemplate.get" filename="用户" @success="handleImportSuccess" />
<sc-import
v-model:open="dialog.import"
title="导入用户"
:api="authApi.users.import.post"
:template-api="authApi.users.downloadTemplate.get"
filename="用户"
@success="handleImportSuccess"
/>
<!-- 导出用户弹窗 -->
<sc-export v-model:open="dialog.export" title="导出用户" :api="handleExportApi"
:default-filename="`用户列表_${Date.now()}`" :show-options="false" tip="导出当前选中或所有用户数据"
@success="handleExportSuccess" />
<sc-export
v-model:open="dialog.export"
title="导出用户"
:api="handleExportApi"
:default-filename="`用户列表_${Date.now()}`"
:show-options="false"
tip="导出当前选中或所有用户数据"
@success="handleExportSuccess"
/>
</template>
<script setup>
import { ref, reactive, onMounted, watch } from 'vue'
import { message, Modal } from 'ant-design-vue'
import { ref, reactive, onMounted, watch } from "vue";
import { message, Modal } from "ant-design-vue";
import {
SearchOutlined,
RedoOutlined,
@@ -159,21 +256,21 @@ import {
ImportOutlined,
ExportOutlined,
DownloadOutlined,
UserOutlined
} from '@ant-design/icons-vue'
import scTable from '@/components/scTable/index.vue'
import scImport from '@/components/scImport/index.vue'
import scExport from '@/components/scExport/index.vue'
import saveDialog from './components/SaveDialog.vue'
import roleDialog from './components/RoleDialog.vue'
import departmentDialog from './components/DepartmentDialog.vue'
import batchRoleDialog from './components/BatchRoleDialog.vue'
import authApi from '@/api/auth'
import { useTable } from '@/hooks/useTable'
UserOutlined,
} from "@ant-design/icons-vue";
import scTable from "@/components/scTable/index.vue";
import scImport from "@/components/scImport/index.vue";
import scExport from "@/components/scExport/index.vue";
import saveDialog from "./components/SaveDialog.vue";
import roleDialog from "./components/RoleDialog.vue";
import departmentDialog from "./components/DepartmentDialog.vue";
import batchRoleDialog from "./components/BatchRoleDialog.vue";
import authApi from "@/api/auth";
import { useTable } from "@/hooks/useTable";
defineOptions({
name: 'authUser'
})
name: "authUser",
});
// 使用useTable hooks
const {
@@ -189,21 +286,21 @@ const {
handlePaginationChange,
handleSelectChange,
handleSelectAll,
refreshTable
refreshTable,
} = useTable({
api: authApi.users.list.get,
searchForm: {
username: '',
real_name: '',
email: '',
phone: '',
username: "",
real_name: "",
email: "",
phone: "",
department_id: null,
status: null
status: null,
},
columns: [],
needPagination: true,
needSelection: true
})
needSelection: true,
});
// 对话框状态
const dialog = reactive({
@@ -212,360 +309,423 @@ const dialog = reactive({
department: false,
batchRole: false,
import: false,
export: false
})
export: false,
});
// 弹窗引用
const saveDialogRef = ref(null)
const roleDialogRef = ref(null)
const departmentDialogRef = ref(null)
const batchRoleDialogRef = ref(null)
const saveDialogRef = ref(null);
const roleDialogRef = ref(null);
const departmentDialogRef = ref(null);
const batchRoleDialogRef = ref(null);
// 部门树数据
const departmentTree = ref([])
const filteredDepartmentTree = ref([])
const selectedDeptKeys = ref([])
const departmentKeyword = ref('')
const expandedDeptKeys = ref([])
const departmentTree = ref([]);
const filteredDepartmentTree = ref([]);
const selectedDeptKeys = ref([]);
const departmentKeyword = ref("");
const expandedDeptKeys = ref([]);
// 行key
const rowKey = 'id'
const rowKey = "id";
// 递归获取所有部门节点的key
const getAllDepartmentKeys = (nodes) => {
const keys = []
const keys = [];
const traverse = (list) => {
list.forEach(node => {
list.forEach((node) => {
// 如果节点有children且不为空,则该节点需要展开
if (node.children && node.children.length > 0) {
keys.push(node.id)
traverse(node.children)
keys.push(node.id);
traverse(node.children);
}
})
}
traverse(nodes)
return keys
}
});
};
traverse(nodes);
return keys;
};
// 监听部门树数据变化,自动展开所有节点
watch(
() => filteredDepartmentTree.value,
(newData) => {
if (newData && newData.length > 0) {
expandedDeptKeys.value = getAllDepartmentKeys(newData)
expandedDeptKeys.value = getAllDepartmentKeys(newData);
} else {
expandedDeptKeys.value = []
expandedDeptKeys.value = [];
}
},
{ immediate: true, deep: true }
)
{ immediate: true, deep: true },
);
// 表格列配置
const columns = [
{ title: '头像', dataIndex: 'avatar', key: 'avatar', width: 80, align: 'center', slot: 'avatar' },
{ title: '用户名', dataIndex: 'username', key: 'username', width: 150 },
{ title: '姓名', dataIndex: 'real_name', key: 'real_name', width: 150 },
{ title: '邮箱', dataIndex: 'email', key: 'email', width: 180, ellipsis: true },
{ title: '手机号', dataIndex: 'phone', key: 'phone', width: 130 },
{ title: '部门', dataIndex: 'department', key: 'department', slot: 'department', width: 150, ellipsis: true },
{ title: '角色', dataIndex: 'roles', key: 'roles', width: 200, slot: 'roles', ellipsis: true },
{ title: '状态', dataIndex: 'status', key: 'status', width: 100, align: 'center', slot: 'status' },
{ title: '最后登录', dataIndex: 'last_login_at', key: 'last_login_at', width: 180 },
{ title: '操作', dataIndex: 'action', key: 'action', width: 280, align: 'center', slot: 'action', fixed: 'right' }
]
{
title: "头像",
dataIndex: "avatar",
key: "avatar",
width: 80,
align: "center",
slot: "avatar",
},
{ title: "用户名", dataIndex: "username", key: "username", width: 150 },
{ title: "姓名", dataIndex: "real_name", key: "real_name", width: 150 },
{
title: "邮箱",
dataIndex: "email",
key: "email",
width: 180,
ellipsis: true,
},
{ title: "手机号", dataIndex: "phone", key: "phone", width: 130 },
{
title: "部门",
dataIndex: "department",
key: "department",
slot: "department",
width: 150,
ellipsis: true,
},
{
title: "角色",
dataIndex: "roles",
key: "roles",
width: 200,
slot: "roles",
ellipsis: true,
},
{
title: "状态",
dataIndex: "status",
key: "status",
width: 100,
align: "center",
slot: "status",
},
{
title: "最后登录",
dataIndex: "last_login_at",
key: "last_login_at",
width: 180,
},
{
title: "操作",
dataIndex: "action",
key: "action",
width: 280,
align: "center",
slot: "action",
fixed: "right",
},
];
// 加载部门树
const loadDepartmentTree = async () => {
try {
const res = await authApi.departments.tree.get()
const res = await authApi.departments.tree.get();
if (res.code === 200) {
departmentTree.value = res.data || []
filteredDepartmentTree.value = res.data || []
departmentTree.value = res.data || [];
filteredDepartmentTree.value = res.data || [];
}
} catch (error) {
console.error('加载部门树失败:', error)
console.error("加载部门树失败:", error);
}
}
};
// 部门搜索
const handleDeptSearch = (e) => {
const keyword = e.target?.value || ''
departmentKeyword.value = keyword
const keyword = e.target?.value || "";
departmentKeyword.value = keyword;
if (!keyword) {
filteredDepartmentTree.value = departmentTree.value
return
filteredDepartmentTree.value = departmentTree.value;
return;
}
// 递归过滤部门树
const filterTree = (nodes) => {
return nodes.reduce((acc, node) => {
const isMatch = node.name && node.name.toLowerCase().includes(keyword.toLowerCase())
const filteredChildren = node.children ? filterTree(node.children) : []
const isMatch =
node.name &&
node.name.toLowerCase().includes(keyword.toLowerCase());
const filteredChildren = node.children
? filterTree(node.children)
: [];
if (isMatch || filteredChildren.length > 0) {
acc.push({
...node,
children: filteredChildren.length > 0 ? filteredChildren : undefined
})
children:
filteredChildren.length > 0
? filteredChildren
: undefined,
});
}
return acc
}, [])
}
return acc;
}, []);
};
filteredDepartmentTree.value = filterTree(departmentTree.value)
}
filteredDepartmentTree.value = filterTree(departmentTree.value);
};
// 重置 - 覆盖useTable的handleReset以添加额外逻辑
const handleUserReset = () => {
searchForm.username = ''
searchForm.real_name = ''
searchForm.email = ''
searchForm.phone = ''
searchForm.status = null
searchForm.department_id = null
selectedDeptKeys.value = []
departmentKeyword.value = ''
filteredDepartmentTree.value = departmentTree.value
handleSearch()
}
searchForm.username = "";
searchForm.real_name = "";
searchForm.email = "";
searchForm.phone = "";
searchForm.status = null;
searchForm.department_id = null;
selectedDeptKeys.value = [];
departmentKeyword.value = "";
filteredDepartmentTree.value = departmentTree.value;
handleSearch();
};
// 部门选择事件
const onDeptSelect = (selectedKeys) => {
if (selectedKeys && selectedKeys.length > 0) {
searchForm.department_id = selectedKeys[0]
searchForm.department_id = selectedKeys[0];
} else {
searchForm.department_id = null
searchForm.department_id = null;
}
handleSearch()
}
handleSearch();
};
// 批量删除
const handleBatchDelete = () => {
if (selectedRows.value.length === 0) {
message.warning('请选择要删除的用户')
return
message.warning("请选择要删除的用户");
return;
}
Modal.confirm({
title: '确认删除',
title: "确认删除",
content: `确定删除选中的 ${selectedRows.value.length} 个用户吗?`,
okText: '删除',
okType: 'danger',
cancelText: '取消',
okText: "删除",
okType: "danger",
cancelText: "取消",
onOk: async () => {
try {
const ids = selectedRows.value.map(item => item.id)
const res = await authApi.users.batchDelete.post({ ids })
const ids = selectedRows.value.map((item) => item.id);
const res = await authApi.users.batchDelete.post({ ids });
if (res.code === 200) {
message.success('删除成功')
selectedRows.value = []
refreshTable()
message.success("删除成功");
selectedRows.value = [];
refreshTable();
} else {
message.error(res.message || '删除失败')
message.error(res.message || "删除失败");
}
} catch (error) {
console.error('批量删除用户失败:', error)
message.error('删除失败')
console.error("批量删除用户失败:", error);
message.error("删除失败");
}
}
})
}
},
});
};
// 批量更新状态
const handleBatchStatus = () => {
if (selectedRows.value.length === 0) {
message.warning('请选择要操作的用户')
return
message.warning("请选择要操作的用户");
return;
}
Modal.confirm({
title: '确认操作',
content: '确定要批量启用/禁用选中的用户吗?',
okText: '确定',
cancelText: '取消',
title: "确认操作",
content: "确定要批量启用/禁用选中的用户吗?",
okText: "确定",
cancelText: "取消",
onOk: async () => {
try {
const ids = selectedRows.value.map(item => item.id)
const status = selectedRows.value[0].status === 1 ? 0 : 1
const res = await authApi.users.batchStatus.post({ ids, status })
const ids = selectedRows.value.map((item) => item.id);
const status = selectedRows.value[0].status === 1 ? 0 : 1;
const res = await authApi.users.batchStatus.post({
ids,
status,
});
if (res.code === 200) {
message.success('操作成功')
selectedRows.value = []
refreshTable()
message.success("操作成功");
selectedRows.value = [];
refreshTable();
} else {
message.error(res.message || '操作失败')
message.error(res.message || "操作失败");
}
} catch (error) {
console.error('批量更新状态失败:', error)
message.error('操作失败')
console.error("批量更新状态失败:", error);
message.error("操作失败");
}
}
})
}
},
});
};
// 批量分配部门
const handleBatchDepartment = () => {
if (selectedRows.value.length === 0) {
message.warning('请选择要分配部门的用户')
return
message.warning("请选择要分配部门的用户");
return;
}
dialog.department = true
dialog.department = true;
setTimeout(() => {
departmentDialogRef.value?.open(selectedRows.value.map(item => item.id))
}, 0)
}
departmentDialogRef.value?.open(
selectedRows.value.map((item) => item.id),
);
}, 0);
};
// 批量分配角色
const handleBatchRoles = () => {
if (selectedRows.value.length === 0) {
message.warning('请选择要分配角色的用户')
return
message.warning("请选择要分配角色的用户");
return;
}
dialog.batchRole = true
dialog.batchRole = true;
setTimeout(() => {
batchRoleDialogRef.value?.open(selectedRows.value.map(item => item.id))
}, 0)
}
batchRoleDialogRef.value?.open(
selectedRows.value.map((item) => item.id),
);
}, 0);
};
// 导出数据
const handleExport = () => {
dialog.export = true
}
dialog.export = true;
};
// 导出API封装
const handleExportApi = async () => {
const ids = selectedRows.value.map(item => item.id)
return await authApi.users.export.post({ ids: ids.length > 0 ? ids : undefined })
}
const ids = selectedRows.value.map((item) => item.id);
return await authApi.users.export.post({
ids: ids.length > 0 ? ids : undefined,
});
};
// 导出成功回调
const handleExportSuccess = () => {
selectedRows.value = []
}
selectedRows.value = [];
};
// 导入用户
const handleImport = () => {
dialog.import = true
}
dialog.import = true;
};
// 导入成功回调
const handleImportSuccess = () => {
refreshTable()
}
refreshTable();
};
// 下载模板
const handleDownloadTemplate = async () => {
try {
const blob = await authApi.users.downloadTemplate.get()
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = '用户导入模板.xlsx'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
message.success('下载成功')
const blob = await authApi.users.downloadTemplate.get();
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "用户导入模板.xlsx";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
message.success("下载成功");
} catch (error) {
console.error('下载模板失败:', error)
message.error('下载失败')
console.error("下载模板失败:", error);
message.error("下载失败");
}
}
};
// 重置密码
const handleResetPassword = (record) => {
Modal.confirm({
title: '重置密码',
content: '确定要重置该用户的密码吗?重置后密码为: 123456',
okText: '确定',
cancelText: '取消',
title: "重置密码",
content: "确定要重置该用户的密码吗?重置后密码为: 123456",
okText: "确定",
cancelText: "取消",
onOk: async () => {
try {
// TODO: 实现重置密码接口
message.success('密码重置成功')
message.success("密码重置成功");
} catch (error) {
console.error('重置密码失败:', error)
message.error('重置密码失败')
console.error("重置密码失败:", error);
message.error("重置密码失败");
}
}
})
}
},
});
};
// 新增用户
const handleAdd = () => {
dialog.save = true
dialog.save = true;
setTimeout(() => {
saveDialogRef.value?.open('add')
}, 0)
}
saveDialogRef.value?.open("add");
}, 0);
};
// 查看用户
const handleView = (record) => {
dialog.save = true
dialog.save = true;
setTimeout(() => {
saveDialogRef.value?.open('show').setData(record)
}, 0)
}
saveDialogRef.value?.open("show").setData(record);
}, 0);
};
// 编辑用户
const handleEdit = (record) => {
dialog.save = true
dialog.save = true;
setTimeout(() => {
saveDialogRef.value?.open('edit').setData(record)
}, 0)
}
saveDialogRef.value?.open("edit").setData(record);
}, 0);
};
// 设置角色
const handleRole = (record) => {
dialog.role = true
dialog.role = true;
setTimeout(() => {
if (roleDialogRef.value) {
roleDialogRef.value.open()
roleDialogRef.value.setData(record)
roleDialogRef.value.open();
roleDialogRef.value.setData(record);
}
}, 0)
}
}, 0);
};
// 删除用户
const handleDelete = async (record) => {
try {
const res = await authApi.users.delete.delete(record.id)
const res = await authApi.users.delete.delete(record.id);
if (res.code === 200) {
message.success('删除成功')
refreshTable()
message.success("删除成功");
refreshTable();
} else {
message.error(res.message || '删除失败')
message.error(res.message || "删除失败");
}
} catch (error) {
console.error('删除用户失败:', error)
message.error('删除失败')
console.error("删除用户失败:", error);
message.error("删除失败");
}
}
};
// 保存成功回调
const handleSaveSuccess = () => {
refreshTable()
}
refreshTable();
};
// 角色设置成功回调
const handleRoleSuccess = () => {
refreshTable()
}
refreshTable();
};
// 批量分配部门成功回调
const handleDepartmentSuccess = () => {
selectedRows.value = []
refreshTable()
}
selectedRows.value = [];
refreshTable();
};
// 批量分配角色成功回调
const handleBatchRoleSuccess = () => {
selectedRows.value = []
refreshTable()
}
selectedRows.value = [];
refreshTable();
};
// 初始化
onMounted(() => {
loadDepartmentTree()
})
loadDepartmentTree();
});
</script>
@@ -8,7 +8,11 @@
</div>
</template>
<template #extra>
<a-button type="primary" size="small" @click="showCustomizeModal = true">
<a-button
type="primary"
size="small"
@click="showCustomizeModal = true"
>
<SettingOutlined />
自定义
</a-button>
@@ -23,8 +27,18 @@
:class="{ 'no-actions': displayActions.length === 0 }"
@click="handleActionClick(action)"
>
<div class="action-icon-wrapper" :style="{ background: action.gradient || getGradient(action.color) }">
<component :is="action.icon || 'AppstoreOutlined'" class="action-icon" />
<div
class="action-icon-wrapper"
:style="{
background:
action.gradient ||
getGradient(action.color),
}"
>
<component
:is="action.icon || 'AppstoreOutlined'"
class="action-icon"
/>
</div>
<div class="action-content">
<div class="action-title">{{ action.title }}</div>
@@ -55,7 +69,11 @@
<div class="section">
<div class="section-header">
<h3 class="section-title">已添加的操作</h3>
<a-tag :color="tempCustomActions.length >= 8 ? 'error' : 'blue'">
<a-tag
:color="
tempCustomActions.length >= 8 ? 'error' : 'blue'
"
>
{{ tempCustomActions.length }}/8
</a-tag>
</div>
@@ -67,14 +85,29 @@
class="action-tag"
:style="{ borderColor: action.color }"
>
<div class="tag-icon" :style="{ background: getGradient(action.color) }">
<component :is="action.icon || 'AppstoreOutlined'" />
<div
class="tag-icon"
:style="{
background: getGradient(action.color),
}"
>
<component
:is="action.icon || 'AppstoreOutlined'"
/>
</div>
<span class="tag-title">{{ action.title }}</span>
<CloseOutlined class="tag-close" @click="handleRemoveAction(action)" />
<span class="tag-title">{{
action.title
}}</span>
<CloseOutlined
class="tag-close"
@click="handleRemoveAction(action)"
/>
</div>
</div>
<a-empty v-if="tempCustomActions.length === 0" description="从下方列表选择添加" />
<a-empty
v-if="tempCustomActions.length === 0"
description="从下方列表选择添加"
/>
</div>
</div>
@@ -82,7 +115,9 @@
<div class="section">
<div class="section-header">
<h3 class="section-title">可选操作</h3>
<a-tag color="cyan">{{ availableMenus.length }}个可用</a-tag>
<a-tag color="cyan"
>{{ availableMenus.length }}个可用</a-tag
>
</div>
<a-input-search
v-model:value="searchKeyword"
@@ -103,15 +138,35 @@
:class="{ disabled: isAdded(menu) }"
@click="handleAddAction(menu)"
>
<div class="menu-icon" :style="{ background: getGradient(menu.meta?.iconColor) }">
<component :is="menu.meta?.icon || 'AppstoreOutlined'" />
<div
class="menu-icon"
:style="{
background: getGradient(
menu.meta?.iconColor,
),
}"
>
<component
:is="menu.meta?.icon || 'AppstoreOutlined'"
/>
</div>
<div class="menu-info">
<div class="menu-title">{{ menu.meta?.title || menu.title || menu.name }}</div>
<div class="menu-title">
{{
menu.meta?.title ||
menu.title ||
menu.name
}}
</div>
<div class="menu-path">{{ menu.path }}</div>
</div>
<div class="menu-action">
<a-button v-if="!isAdded(menu)" type="primary" size="small" ghost>
<a-button
v-if="!isAdded(menu)"
type="primary"
size="small"
ghost
>
<PlusOutlined />
添加
</a-button>
@@ -121,7 +176,10 @@
</a-tag>
</div>
</div>
<a-empty v-if="filteredMenus.length === 0" description="未找到匹配的操作" />
<a-empty
v-if="filteredMenus.length === 0"
description="未找到匹配的操作"
/>
</div>
</div>
</div>
@@ -130,7 +188,11 @@
<div class="modal-footer">
<a-space>
<a-button @click="handleCancelCustomize">取消</a-button>
<a-button type="primary" @click="handleSaveCustomActions" :loading="saving">
<a-button
type="primary"
@click="handleSaveCustomActions"
:loading="saving"
>
<SaveOutlined />
保存配置
</a-button>
@@ -141,9 +203,9 @@
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores/modules/user'
import { ref, computed, onMounted, watch } from "vue";
import { useRouter } from "vue-router";
import { useUserStore } from "@/stores/modules/user";
import {
SettingOutlined,
PlusOutlined,
@@ -152,212 +214,240 @@ import {
ThunderboltOutlined,
CloseOutlined,
SearchOutlined,
SaveOutlined
} from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
SaveOutlined,
} from "@ant-design/icons-vue";
import { message } from "ant-design-vue";
defineOptions({
name: 'QuickActions',
})
name: "QuickActions",
});
const router = useRouter()
const userStore = useUserStore()
const router = useRouter();
const userStore = useUserStore();
const loading = ref(false)
const showCustomizeModal = ref(false)
const searchKeyword = ref('')
const saving = ref(false)
const customActions = ref([])
const tempCustomActions = ref([])
const loading = ref(false);
const showCustomizeModal = ref(false);
const searchKeyword = ref("");
const saving = ref(false);
const customActions = ref([]);
const tempCustomActions = ref([]);
// 默认快速操作
const defaultActions = ref([])
const defaultActions = ref([]);
// 渐变色映射
const gradientColors = {
'#1890ff': 'linear-gradient(135deg, #1890ff 0%, #36cfc9 100%)',
'#52c41a': 'linear-gradient(135deg, #52c41a 0%, #95de64 100%)',
'#faad14': 'linear-gradient(135deg, #faad14 0%, #ffc53d 100%)',
'#f5222d': 'linear-gradient(135deg, #f5222d 0%, #ff4d4f 100%)',
'#722ed1': 'linear-gradient(135deg, #722ed1 0%, #9254de 100%)',
'#eb2f96': 'linear-gradient(135deg, #eb2f96 0%, #f759ab 100%)',
'#13c2c2': 'linear-gradient(135deg, #13c2c2 0%, #36cfc9 100%)',
'#fa8c16': 'linear-gradient(135deg, #fa8c16 0%, #ffa940 100%)',
}
"#1890ff": "linear-gradient(135deg, #1890ff 0%, #36cfc9 100%)",
"#52c41a": "linear-gradient(135deg, #52c41a 0%, #95de64 100%)",
"#faad14": "linear-gradient(135deg, #faad14 0%, #ffc53d 100%)",
"#f5222d": "linear-gradient(135deg, #f5222d 0%, #ff4d4f 100%)",
"#722ed1": "linear-gradient(135deg, #722ed1 0%, #9254de 100%)",
"#eb2f96": "linear-gradient(135deg, #eb2f96 0%, #f759ab 100%)",
"#13c2c2": "linear-gradient(135deg, #13c2c2 0%, #36cfc9 100%)",
"#fa8c16": "linear-gradient(135deg, #fa8c16 0%, #ffa940 100%)",
};
// 获取渐变色
const getGradient = (color) => {
if (!color) return gradientColors['#1890ff']
return gradientColors[color] || gradientColors['#1890ff']
}
if (!color) return gradientColors["#1890ff"];
return gradientColors[color] || gradientColors["#1890ff"];
};
// 可用的菜单列表(扁平化所有菜单项)
const availableMenus = computed(() => {
const menus = userStore.menu || []
const flattenMenus = []
const menus = userStore.menu || [];
const flattenMenus = [];
const traverse = (menuList) => {
menuList.forEach(menu => {
menuList.forEach((menu) => {
// 兼容不同的菜单数据结构
const menuType = menu.type || (menu.path ? 'menu' : '')
if (menuType === 'menu' && menu.path && !menu.meta?.hidden) {
const menuType = menu.type || (menu.path ? "menu" : "");
if (menuType === "menu" && menu.path && !menu.meta?.hidden) {
flattenMenus.push({
...menu,
title: menu.meta?.title || menu.title || menu.name,
icon: menu.meta?.icon || 'AppstoreOutlined'
})
icon: menu.meta?.icon || "AppstoreOutlined",
});
}
if (menu.children && menu.children.length > 0) {
traverse(menu.children)
traverse(menu.children);
}
})
}
});
};
traverse(menus)
return flattenMenus
})
traverse(menus);
return flattenMenus;
});
// 显示的快速操作
const displayActions = computed(() => {
if (customActions.value.length > 0) {
return customActions.value
return customActions.value;
}
return defaultActions.value
})
return defaultActions.value;
});
// 过滤后的菜单
const filteredMenus = computed(() => {
if (!searchKeyword.value) {
return availableMenus.value.slice(0, 50)
return availableMenus.value.slice(0, 50);
}
const keyword = searchKeyword.value.toLowerCase()
return availableMenus.value.filter(menu => {
const title = (menu.meta?.title || menu.title || menu.name || '').toLowerCase()
const path = (menu.path || '').toLowerCase()
return title.includes(keyword) || path.includes(keyword)
})
})
const keyword = searchKeyword.value.toLowerCase();
return availableMenus.value.filter((menu) => {
const title = (
menu.meta?.title ||
menu.title ||
menu.name ||
""
).toLowerCase();
const path = (menu.path || "").toLowerCase();
return title.includes(keyword) || path.includes(keyword);
});
});
// 检查是否已添加
const isAdded = (menu) => {
return tempCustomActions.value.some(action => action.path === menu.path)
}
return tempCustomActions.value.some((action) => action.path === menu.path);
};
// 加载自定义操作
const loadCustomActions = () => {
try {
const saved = localStorage.getItem('quick-actions')
const saved = localStorage.getItem("quick-actions");
if (saved) {
customActions.value = JSON.parse(saved)
customActions.value = JSON.parse(saved);
} else {
setDefaultActions()
setDefaultActions();
}
} catch (error) {
console.error('加载自定义操作失败:', error)
setDefaultActions()
console.error("加载自定义操作失败:", error);
setDefaultActions();
}
}
};
// 设置默认快速操作
const setDefaultActions = () => {
const menus = availableMenus.value
const commonPaths = ['/system/users', '/system/roles', '/system/permissions', '/system/config']
const menus = availableMenus.value;
const commonPaths = [
"/system/users",
"/system/roles",
"/system/permissions",
"/system/config",
];
defaultActions.value = menus
.filter(menu => commonPaths.includes(menu.path))
.map(menu => ({
.filter((menu) => commonPaths.includes(menu.path))
.map((menu) => ({
...menu,
color: getRandomColor()
color: getRandomColor(),
}))
.slice(0, 8)
}
.slice(0, 8);
};
// 获取随机颜色
const getRandomColor = () => {
const colors = ['#1890ff', '#52c41a', '#faad14', '#f5222d', '#722ed1', '#eb2f96', '#13c2c2', '#fa8c16']
return colors[Math.floor(Math.random() * colors.length)]
}
const colors = [
"#1890ff",
"#52c41a",
"#faad14",
"#f5222d",
"#722ed1",
"#eb2f96",
"#13c2c2",
"#fa8c16",
];
return colors[Math.floor(Math.random() * colors.length)];
};
// 添加操作
const handleAddAction = (menu) => {
if (tempCustomActions.value.length >= 8) {
message.warning('最多添加8个快速操作')
return
message.warning("最多添加8个快速操作");
return;
}
if (isAdded(menu)) {
message.info('该操作已添加')
return
message.info("该操作已添加");
return;
}
tempCustomActions.value.push({
path: menu.path,
title: menu.title,
icon: menu.icon,
color: getRandomColor()
})
message.success(`已添加: ${menu.title}`)
}
color: getRandomColor(),
});
message.success(`已添加: ${menu.title}`);
};
// 移除操作
const handleRemoveAction = (action) => {
const index = tempCustomActions.value.findIndex(item => item.path === action.path)
const index = tempCustomActions.value.findIndex(
(item) => item.path === action.path,
);
if (index > -1) {
tempCustomActions.value.splice(index, 1)
message.success(`已移除: ${action.title}`)
tempCustomActions.value.splice(index, 1);
message.success(`已移除: ${action.title}`);
}
}
};
// 保存自定义操作
const handleSaveCustomActions = () => {
if (tempCustomActions.value.length === 0) {
message.warning('请至少添加一个快速操作')
return
message.warning("请至少添加一个快速操作");
return;
}
saving.value = true
saving.value = true;
try {
customActions.value = [...tempCustomActions.value]
localStorage.setItem('quick-actions', JSON.stringify(customActions.value))
customActions.value = [...tempCustomActions.value];
localStorage.setItem(
"quick-actions",
JSON.stringify(customActions.value),
);
setTimeout(() => {
saving.value = false
showCustomizeModal.value = false
message.success('保存成功,快速操作已更新')
}, 500)
saving.value = false;
showCustomizeModal.value = false;
message.success("保存成功,快速操作已更新");
}, 500);
} catch (error) {
saving.value = false
message.error('保存失败,请重试')
saving.value = false;
message.error("保存失败,请重试");
}
}
};
// 取消自定义
const handleCancelCustomize = () => {
showCustomizeModal.value = false
}
showCustomizeModal.value = false;
};
// 点击操作项
const handleActionClick = (action) => {
if (action.path) {
router.push(action.path)
router.push(action.path);
}
}
};
// 监听弹窗打开
watch(showCustomizeModal, (newVal) => {
if (newVal) {
tempCustomActions.value = [...customActions.value]
searchKeyword.value = ''
tempCustomActions.value = [...customActions.value];
searchKeyword.value = "";
}
})
});
// 监听菜单变化
watch(() => userStore.menu, (newMenu) => {
if (newMenu && newMenu.length > 0 && customActions.value.length === 0) {
setDefaultActions()
}
}, { immediate: true })
watch(
() => userStore.menu,
(newMenu) => {
if (newMenu && newMenu.length > 0 && customActions.value.length === 0) {
setDefaultActions();
}
},
{ immediate: true },
);
onMounted(() => {
loading.value = true
loadCustomActions()
loading.value = false
})
loading.value = true;
loadCustomActions();
loading.value = false;
});
</script>
<style scoped lang="scss">
@@ -676,7 +766,7 @@ onMounted(() => {
.menu-path {
font-size: 13px;
color: rgba(0, 0, 0, 0.45);
font-family: 'Courier New', monospace;
font-family: "Courier New", monospace;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -4,7 +4,9 @@
<div class="welcome-content">
<div class="greeting">
<h2 class="greeting-text">{{ greetingText }}</h2>
<p class="welcome-subtitle">{{ userInfo?.username || '管理员' }}欢迎回来</p>
<p class="welcome-subtitle">
{{ userInfo?.username || "管理员" }}欢迎回来
</p>
</div>
<div class="welcome-info">
<a-space direction="vertical" :size="8">
@@ -28,64 +30,68 @@
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useUserStore } from '@/stores/modules/user'
import { CalendarOutlined, ClockCircleOutlined, EnvironmentOutlined } from '@ant-design/icons-vue'
import { ref, computed, onMounted, onUnmounted } from "vue";
import { useUserStore } from "@/stores/modules/user";
import {
CalendarOutlined,
ClockCircleOutlined,
EnvironmentOutlined,
} from "@ant-design/icons-vue";
defineOptions({
name: 'Welcome',
})
name: "Welcome",
});
const userStore = useUserStore()
const userInfo = computed(() => userStore.userInfo)
const userStore = useUserStore();
const userInfo = computed(() => userStore.userInfo);
const currentDate = ref('')
const currentTime = ref('')
const weatherText = ref('天气晴朗,适合工作')
let timer = null
const currentDate = ref("");
const currentTime = ref("");
const weatherText = ref("天气晴朗,适合工作");
let timer = null;
// 根据时间段返回问候语
const greetingText = computed(() => {
const hour = new Date().getHours()
const hour = new Date().getHours();
if (hour >= 5 && hour < 12) {
return '早上好'
return "早上好";
} else if (hour >= 12 && hour < 14) {
return '中午好'
return "中午好";
} else if (hour >= 14 && hour < 18) {
return '下午好'
return "下午好";
} else if (hour >= 18 && hour < 23) {
return '晚上好'
return "晚上好";
} else {
return '夜深了'
return "夜深了";
}
})
});
// 更新日期和时间
const updateTime = () => {
const now = new Date()
currentDate.value = now.toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric',
weekday: 'long'
})
currentTime.value = now.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
}
const now = new Date();
currentDate.value = now.toLocaleDateString("zh-CN", {
year: "numeric",
month: "long",
day: "numeric",
weekday: "long",
});
currentTime.value = now.toLocaleTimeString("zh-CN", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
};
onMounted(() => {
updateTime()
timer = setInterval(updateTime, 1000)
})
updateTime();
timer = setInterval(updateTime, 1000);
});
onUnmounted(() => {
if (timer) {
clearInterval(timer)
clearInterval(timer);
}
})
});
</script>
<style scoped lang="scss">
+4 -4
View File
@@ -13,13 +13,13 @@
</template>
<script setup>
import Welcome from './components/Welcome.vue'
import QuickActions from './components/QuickActions.vue'
import Welcome from "./components/Welcome.vue";
import QuickActions from "./components/QuickActions.vue";
// 定义组件名称
defineOptions({
name: 'HomePage',
})
name: "HomePage",
});
</script>
<style scoped lang="scss">
+91 -56
View File
@@ -12,9 +12,20 @@
<p class="auth-subtitle">登录您的账户继续探索科技世界</p>
</div>
<a-form ref="loginFormRef" :model="loginForm" :rules="loginRules" class="auth-form" @finish="handleLogin" layout="vertical">
<a-form
ref="loginFormRef"
:model="loginForm"
:rules="loginRules"
class="auth-form"
@finish="handleLogin"
layout="vertical"
>
<a-form-item name="username">
<a-input v-model:value="loginForm.username" placeholder="请输入用户名/邮箱" size="large">
<a-input
v-model:value="loginForm.username"
placeholder="请输入用户名/邮箱"
size="large"
>
<template #prefix>
<UserOutlined />
</template>
@@ -22,7 +33,12 @@
</a-form-item>
<a-form-item name="password">
<a-input-password v-model:value="loginForm.password" placeholder="请输入密码" size="large" @pressEnter="handleLogin">
<a-input-password
v-model:value="loginForm.password"
placeholder="请输入密码"
size="large"
@pressEnter="handleLogin"
>
<template #prefix>
<LockOutlined />
</template>
@@ -30,13 +46,26 @@
</a-form-item>
<div class="auth-links">
<a-checkbox v-model:checked="loginForm.rememberMe" class="remember-me"> 记住我 </a-checkbox>
<router-link to="/reset-password" class="forgot-password"> 忘记密码 </router-link>
<a-checkbox
v-model:checked="loginForm.rememberMe"
class="remember-me"
>
记住我
</a-checkbox>
<router-link to="/reset-password" class="forgot-password">
忘记密码
</router-link>
</div>
<a-form-item>
<a-button type="primary" :loading="loading" size="large" html-type="submit" block>
{{ loading ? '登录中...' : '登录' }}
<a-button
type="primary"
:loading="loading"
size="large"
html-type="submit"
block
>
{{ loading ? "登录中..." : "登录" }}
</a-button>
</a-form-item>
</a-form>
@@ -44,7 +73,9 @@
<div class="auth-footer">
<p class="auth-footer-text">
还没有账户
<router-link to="/userRegister" class="auth-link"> 立即注册 </router-link>
<router-link to="/userRegister" class="auth-link">
立即注册
</router-link>
</p>
</div>
</div>
@@ -52,119 +83,123 @@
</template>
<script setup>
import { reactive, ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { message } from 'ant-design-vue'
import { UserOutlined, LockOutlined } from '@ant-design/icons-vue'
import { useUserStore } from '@/stores/modules/user'
import { useDictionaryStore } from '@/stores/modules/dictionary'
import auth from '@/api/auth'
import config from '@/config'
import '@/assets/style/auth.scss'
import { reactive, ref } from "vue";
import { useRouter, useRoute } from "vue-router";
import { message } from "ant-design-vue";
import { UserOutlined, LockOutlined } from "@ant-design/icons-vue";
import { useUserStore } from "@/stores/modules/user";
import { useDictionaryStore } from "@/stores/modules/dictionary";
import auth from "@/api/auth";
import config from "@/config";
import "@/assets/style/auth.scss";
defineOptions({
name: 'LoginPage',
})
name: "LoginPage",
});
const router = useRouter()
const route = useRoute()
const loginFormRef = ref(null)
const loading = ref(false)
const router = useRouter();
const route = useRoute();
const loginFormRef = ref(null);
const loading = ref(false);
const userStore = useUserStore()
const dictionaryStore = useDictionaryStore()
const userStore = useUserStore();
const dictionaryStore = useDictionaryStore();
// Login form data
const loginForm = reactive({
username: '',
password: '',
username: "",
password: "",
rememberMe: false,
})
});
// Form validation rules
const loginRules = {
username: [
{ required: true, message: '请输入用户名或邮箱' },
{ min: 3, max: 50, message: '长度在 3 到 50 个字符' },
{ required: true, message: "请输入用户名或邮箱" },
{ min: 3, max: 50, message: "长度在 3 到 50 个字符" },
],
password: [
{ required: true, message: '请输入密码' },
{ min: 6, message: '密码长度不能少于 6 位' },
{ required: true, message: "请输入密码" },
{ min: 6, message: "密码长度不能少于 6 位" },
],
}
};
// Handle login
const handleLogin = async () => {
if (!loginFormRef.value) return
if (!loginFormRef.value) return;
try {
// Validate form
await loginFormRef.value.validate()
loading.value = true
await loginFormRef.value.validate();
loading.value = true;
// 1. Call login API
const loginResponse = await auth.login.post({
username: loginForm.username,
password: loginForm.password,
})
});
// Check if login was successful
if (loginResponse.code !== 200) {
throw new Error(loginResponse.message || '登录失败')
throw new Error(loginResponse.message || "登录失败");
}
const loginData = loginResponse.data
const loginData = loginResponse.data;
// 2. Store token persistently (接口返回的是 token 字段)
if (loginData.token) {
userStore.setToken(loginData.token)
userStore.setToken(loginData.token);
}
// Store user information if available (登录接口已返回用户信息)
if (loginData.user) {
userStore.setUserInfo(loginData.user)
userStore.setUserInfo(loginData.user);
}
// 3. Store menu and permissions from login response
// 根据接口文档,登录接口已返回 menu 和 permissions 数据
if (loginData.menu) {
userStore.setMenu(loginData.menu)
userStore.setMenu(loginData.menu);
}
if (loginData.permissions) {
userStore.setPermissions(loginData.permissions)
userStore.setPermissions(loginData.permissions);
}
// 4. Load dictionary data (缓存字典数据)
dictionaryStore.loadAllDictionaries().catch(error => {
console.error('加载字典数据失败:', error)
})
dictionaryStore.loadAllDictionaries().catch((error) => {
console.error("加载字典数据失败:", error);
});
// Success message
message.success('登录成功!')
message.success("登录成功!");
// Redirect to dashboard or redirect parameter
setTimeout(() => {
// Get redirect from query parameter
const redirect = route.query.redirect
const redirect = route.query.redirect;
if (redirect) {
// If there's a redirect parameter, go there
router.push(redirect)
router.push(redirect);
} else {
// Otherwise, go to configured dashboard URL
router.push(config.DASHBOARD_URL)
router.push(config.DASHBOARD_URL);
}
}, 500)
}, 500);
} catch (error) {
// Clear user data on login failure
userStore.logout()
userStore.logout();
// Show error message
const errorMsg = error.response?.data?.msg || error.msg || error.message || '登录失败,请检查用户名和密码'
message.error(errorMsg)
const errorMsg =
error.response?.data?.msg ||
error.msg ||
error.message ||
"登录失败,请检查用户名和密码";
message.error(errorMsg);
} finally {
loading.value = false
loading.value = false;
}
}
};
</script>
@@ -9,12 +9,26 @@
<div class="auth-card">
<div class="auth-header">
<h1 class="auth-title">找回密码</h1>
<p class="auth-subtitle">输入您的邮箱我们将发送重置密码链接</p>
<p class="auth-subtitle">
输入您的邮箱我们将发送重置密码链接
</p>
</div>
<a-form ref="forgotFormRef" :model="forgotForm" :rules="forgotRules" class="auth-form" @finish="handleSubmit" layout="vertical">
<a-form
ref="forgotFormRef"
:model="forgotForm"
:rules="forgotRules"
class="auth-form"
@finish="handleSubmit"
layout="vertical"
>
<a-form-item name="email">
<a-input v-model:value="forgotForm.email" placeholder="请输入注册邮箱" size="large" @pressEnter="handleSubmit">
<a-input
v-model:value="forgotForm.email"
placeholder="请输入注册邮箱"
size="large"
@pressEnter="handleSubmit"
>
<template #prefix>
<MailOutlined />
</template>
@@ -23,20 +37,36 @@
<a-form-item name="captcha" v-if="showCaptcha">
<div style="display: flex; gap: 12px">
<a-input v-model:value="forgotForm.captcha" placeholder="请输入验证码" size="large" style="flex: 1">
<a-input
v-model:value="forgotForm.captcha"
placeholder="请输入验证码"
size="large"
style="flex: 1"
>
<template #prefix>
<SafetyOutlined />
</template>
</a-input>
<a-button type="default" size="large" :disabled="captchaDisabled" @click="sendCaptcha">
<a-button
type="default"
size="large"
:disabled="captchaDisabled"
@click="sendCaptcha"
>
{{ captchaButtonText }}
</a-button>
</div>
</a-form-item>
<a-form-item>
<a-button type="primary" :loading="loading" size="large" html-type="submit" block>
{{ loading ? '提交中...' : '发送重置链接' }}
<a-button
type="primary"
:loading="loading"
size="large"
html-type="submit"
block
>
{{ loading ? "提交中..." : "发送重置链接" }}
</a-button>
</a-form-item>
</a-form>
@@ -44,7 +74,9 @@
<div class="auth-footer">
<p class="auth-footer-text">
想起密码了
<router-link to="/login" class="auth-link"> 返回登录 </router-link>
<router-link to="/login" class="auth-link">
返回登录
</router-link>
</p>
</div>
</div>
@@ -52,113 +84,113 @@
</template>
<script setup>
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import { MailOutlined, SafetyOutlined } from '@ant-design/icons-vue'
import '@/assets/style/auth.scss'
import { reactive, ref } from "vue";
import { useRouter } from "vue-router";
import { message } from "ant-design-vue";
import { MailOutlined, SafetyOutlined } from "@ant-design/icons-vue";
import "@/assets/style/auth.scss";
defineOptions({
name: 'ResetPasswordPage',
})
name: "ResetPasswordPage",
});
const router = useRouter()
const forgotFormRef = ref(null)
const loading = ref(false)
const showCaptcha = ref(false)
const captchaDisabled = ref(false)
const countdown = ref(60)
const router = useRouter();
const forgotFormRef = ref(null);
const loading = ref(false);
const showCaptcha = ref(false);
const captchaDisabled = ref(false);
const countdown = ref(60);
// Forgot password form data
const forgotForm = reactive({
email: '',
captcha: '',
})
email: "",
captcha: "",
});
// Captcha button text
const captchaButtonText = ref('获取验证码')
const captchaButtonText = ref("获取验证码");
// Form validation rules
const forgotRules = {
email: [
{ required: true, message: '请输入邮箱地址' },
{ type: 'email', message: '请输入正确的邮箱地址' },
{ required: true, message: "请输入邮箱地址" },
{ type: "email", message: "请输入正确的邮箱地址" },
],
captcha: [
{ required: true, message: '请输入验证码' },
{ len: 6, message: '验证码为6位数字' },
{ required: true, message: "请输入验证码" },
{ len: 6, message: "验证码为6位数字" },
],
}
};
// Send captcha code
const sendCaptcha = async () => {
if (!forgotForm.email) {
message.warning('请先输入邮箱地址')
return
message.warning("请先输入邮箱地址");
return;
}
// Validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(forgotForm.email)) {
message.warning('请输入正确的邮箱地址')
return
message.warning("请输入正确的邮箱地址");
return;
}
try {
// Simulate API call - Replace with actual API call
// Example: const response = await sendCaptchaApi(forgotForm.email)
await new Promise((resolve) => setTimeout(resolve, 500))
await new Promise((resolve) => setTimeout(resolve, 500));
message.success('验证码已发送至您的邮箱')
message.success("验证码已发送至您的邮箱");
// Start countdown
captchaDisabled.value = true
captchaDisabled.value = true;
const timer = setInterval(() => {
countdown.value--
captchaButtonText.value = `${countdown.value}秒后重试`
countdown.value--;
captchaButtonText.value = `${countdown.value}秒后重试`;
if (countdown.value <= 0) {
clearInterval(timer)
captchaDisabled.value = false
captchaButtonText.value = '获取验证码'
countdown.value = 60
clearInterval(timer);
captchaDisabled.value = false;
captchaButtonText.value = "获取验证码";
countdown.value = 60;
}
}, 1000)
}, 1000);
showCaptcha.value = true
showCaptcha.value = true;
} catch (error) {
console.error('Send captcha failed:', error)
message.error('发送验证码失败,请稍后重试')
console.error("Send captcha failed:", error);
message.error("发送验证码失败,请稍后重试");
}
}
};
// Handle submit
const handleSubmit = async () => {
if (!forgotFormRef.value) return
if (!forgotFormRef.value) return;
try {
await forgotFormRef.value.validate()
loading.value = true
await forgotFormRef.value.validate();
loading.value = true;
// Simulate API call - Replace with actual API call
// Example: const response = await forgotPasswordApi(forgotForm)
// Simulated delay
await new Promise((resolve) => setTimeout(resolve, 1500))
await new Promise((resolve) => setTimeout(resolve, 1500));
// Success message
message.success('密码重置链接已发送至您的邮箱,请注意查收')
message.success("密码重置链接已发送至您的邮箱,请注意查收");
// Redirect to login page
setTimeout(() => {
router.push('/login')
}, 2000)
router.push("/login");
}, 2000);
} catch (error) {
console.error('Forgot password failed:', error)
message.error('提交失败,请检查邮箱地址和验证码')
console.error("Forgot password failed:", error);
message.error("提交失败,请检查邮箱地址和验证码");
} finally {
loading.value = false
loading.value = false;
}
}
};
</script>
@@ -12,9 +12,20 @@
<p class="auth-subtitle">加入我们开启科技之旅</p>
</div>
<a-form ref="registerFormRef" :model="registerForm" :rules="registerRules" class="auth-form" @finish="handleRegister" layout="vertical">
<a-form
ref="registerFormRef"
:model="registerForm"
:rules="registerRules"
class="auth-form"
@finish="handleRegister"
layout="vertical"
>
<a-form-item name="username">
<a-input v-model:value="registerForm.username" placeholder="请输入用户名" size="large">
<a-input
v-model:value="registerForm.username"
placeholder="请输入用户名"
size="large"
>
<template #prefix>
<UserOutlined />
</template>
@@ -22,7 +33,11 @@
</a-form-item>
<a-form-item name="email">
<a-input v-model:value="registerForm.email" placeholder="请输入邮箱地址" size="large">
<a-input
v-model:value="registerForm.email"
placeholder="请输入邮箱地址"
size="large"
>
<template #prefix>
<MailOutlined />
</template>
@@ -30,7 +45,11 @@
</a-form-item>
<a-form-item name="password">
<a-input-password v-model:value="registerForm.password" placeholder="请输入密码(至少6位)" size="large">
<a-input-password
v-model:value="registerForm.password"
placeholder="请输入密码(至少6位)"
size="large"
>
<template #prefix>
<LockOutlined />
</template>
@@ -38,7 +57,12 @@
</a-form-item>
<a-form-item name="confirmPassword">
<a-input-password v-model:value="registerForm.confirmPassword" placeholder="请再次输入密码" size="large" @pressEnter="handleRegister">
<a-input-password
v-model:value="registerForm.confirmPassword"
placeholder="请再次输入密码"
size="large"
@pressEnter="handleRegister"
>
<template #prefix>
<LockOutlined />
</template>
@@ -46,7 +70,10 @@
</a-form-item>
<a-form-item name="agreeTerms">
<a-checkbox v-model:checked="registerForm.agreeTerms" class="remember-me">
<a-checkbox
v-model:checked="registerForm.agreeTerms"
class="remember-me"
>
我已阅读并同意
<a href="#" class="auth-link">服务条款</a>
@@ -55,8 +82,14 @@
</a-form-item>
<a-form-item>
<a-button type="primary" :loading="loading" size="large" html-type="submit" block>
{{ loading ? '注册中...' : '注册' }}
<a-button
type="primary"
:loading="loading"
size="large"
html-type="submit"
block
>
{{ loading ? "注册中..." : "注册" }}
</a-button>
</a-form-item>
</a-form>
@@ -64,7 +97,9 @@
<div class="auth-footer">
<p class="auth-footer-text">
已有账户
<router-link to="/login" class="auth-link"> 立即登录 </router-link>
<router-link to="/login" class="auth-link">
立即登录
</router-link>
</p>
</div>
</div>
@@ -72,90 +107,94 @@
</template>
<script setup>
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import { UserOutlined, MailOutlined, LockOutlined } from '@ant-design/icons-vue'
import '@/assets/style/auth.scss'
import { reactive, ref } from "vue";
import { useRouter } from "vue-router";
import { message } from "ant-design-vue";
import {
UserOutlined,
MailOutlined,
LockOutlined,
} from "@ant-design/icons-vue";
import "@/assets/style/auth.scss";
defineOptions({
name: 'RegisterPage',
})
name: "RegisterPage",
});
const router = useRouter()
const registerFormRef = ref(null)
const loading = ref(false)
const router = useRouter();
const registerFormRef = ref(null);
const loading = ref(false);
// Register form data
const registerForm = reactive({
username: '',
email: '',
password: '',
confirmPassword: '',
username: "",
email: "",
password: "",
confirmPassword: "",
agreeTerms: false,
})
});
// Form validation rules
const registerRules = {
username: [
{ required: true, message: '请输入用户名' },
{ min: 3, max: 20, message: '长度在 3 到 20 个字符' },
{ required: true, message: "请输入用户名" },
{ min: 3, max: 20, message: "长度在 3 到 20 个字符" },
],
email: [
{ required: true, message: '请输入邮箱地址' },
{ type: 'email', message: '请输入正确的邮箱地址' },
{ required: true, message: "请输入邮箱地址" },
{ type: "email", message: "请输入正确的邮箱地址" },
],
password: [
{ required: true, message: '请输入密码' },
{ min: 6, message: '密码长度不能少于 6 位' },
{ required: true, message: "请输入密码" },
{ min: 6, message: "密码长度不能少于 6 位" },
],
confirmPassword: [
{ required: true, message: '请再次输入密码' },
{ required: true, message: "请再次输入密码" },
{
validator: (rule, value) => {
if (value !== registerForm.password) {
return Promise.reject('两次输入的密码不一致')
return Promise.reject("两次输入的密码不一致");
}
return Promise.resolve()
return Promise.resolve();
},
},
],
agreeTerms: [
{
type: 'enum',
type: "enum",
enum: [true],
message: '请阅读并同意服务条款和隐私政策',
message: "请阅读并同意服务条款和隐私政策",
transform: (value) => value || false,
},
],
}
};
// Handle register
const handleRegister = async () => {
if (!registerFormRef.value) return
if (!registerFormRef.value) return;
try {
await registerFormRef.value.validate()
loading.value = true
await registerFormRef.value.validate();
loading.value = true;
// Simulate API call - Replace with actual API call
// Example: const response = await registerApi(registerForm)
// Simulated delay
await new Promise((resolve) => setTimeout(resolve, 1500))
await new Promise((resolve) => setTimeout(resolve, 1500));
// Success message
message.success('注册成功!正在跳转到登录页面...')
message.success("注册成功!正在跳转到登录页面...");
// Redirect to login page
setTimeout(() => {
router.push('/login')
}, 1500)
router.push("/login");
}, 1500);
} catch (error) {
console.error('Register failed:', error)
message.error('注册失败,请稍后重试')
console.error("Register failed:", error);
message.error("注册失败,请稍后重试");
} finally {
loading.value = false
loading.value = false;
}
}
};
</script>
@@ -1,25 +1,56 @@
<template>
<a-modal :title="title" :open="visible" :confirm-loading="isSaving" :footer="null" @cancel="handleCancel" width="700px">
<a-form ref="formRef" :model="form" :rules="rules" :label-col="{ span: 5 }" :wrapper-col="{ span: 18 }">
<a-modal
:title="title"
:open="visible"
:confirm-loading="isSaving"
:footer="null"
@cancel="handleCancel"
width="700px"
>
<a-form
ref="formRef"
:model="form"
:rules="rules"
:label-col="{ span: 5 }"
:wrapper-col="{ span: 18 }"
>
<!-- 配置名称 -->
<a-form-item label="配置名称" name="name" required>
<a-input v-model:value="form.name" placeholder="如:网站名称" allow-clear />
<a-input
v-model:value="form.name"
placeholder="如:网站名称"
allow-clear
/>
</a-form-item>
<!-- 配置键名 -->
<a-form-item label="配置键名" name="key" required>
<a-input v-model:value="form.key" placeholder="如:site_name" allow-clear :disabled="isEdit" />
<div class="form-tip">系统唯一标识只能包含字母数字下划线</div>
<a-input
v-model:value="form.key"
placeholder="如:site_name"
allow-clear
:disabled="isEdit"
/>
<div class="form-tip">
系统唯一标识只能包含字母数字下划线
</div>
</a-form-item>
<!-- 配置分组 -->
<a-form-item label="配置分组" name="group" required>
<a-select v-model:value="form.group" placeholder="请选择配置分组" :options="groupOptions" />
<a-select
v-model:value="form.group"
placeholder="请选择配置分组"
:options="groupOptions"
/>
</a-form-item>
<!-- 配置类型 -->
<a-form-item label="配置类型" name="type" required>
<a-select v-model:value="form.type" placeholder="请选择配置类型">
<a-select
v-model:value="form.type"
placeholder="请选择配置类型"
>
<a-select-option value="string">字符串</a-select-option>
<a-select-option value="number">数字</a-select-option>
<a-select-option value="boolean">布尔值</a-select-option>
@@ -34,41 +65,88 @@
<a-form-item label="配置值" name="value">
<!-- 字符串/文本 -->
<template v-if="['string', 'text'].includes(form.type)">
<a-input v-if="form.type === 'string'" v-model:value="form.value" placeholder="请输入配置值" allow-clear />
<a-textarea v-else v-model:value="form.value" placeholder="请输入配置值" :rows="4" />
<a-input
v-if="form.type === 'string'"
v-model:value="form.value"
placeholder="请输入配置值"
allow-clear
/>
<a-textarea
v-else
v-model:value="form.value"
placeholder="请输入配置值"
:rows="4"
/>
</template>
<!-- 数字 -->
<a-input-number v-else-if="form.type === 'number'" v-model:value="form.value" :min="0" style="width: 100%" />
<a-input-number
v-else-if="form.type === 'number'"
v-model:value="form.value"
:min="0"
style="width: 100%"
/>
<!-- 布尔值 -->
<a-switch v-else-if="form.type === 'boolean'" v-model:checked="valueChecked" checked-children="启用" un-checked-children="禁用" />
<a-switch
v-else-if="form.type === 'boolean'"
v-model:checked="valueChecked"
checked-children="启用"
un-checked-children="禁用"
/>
<!-- 文件/图片 -->
<a-input v-else-if="['file', 'image'].includes(form.type)" v-model:value="form.value" placeholder="请输入文件地址" />
<a-input
v-else-if="['file', 'image'].includes(form.type)"
v-model:value="form.value"
placeholder="请输入文件地址"
/>
<!-- 选择框 -->
<a-input v-else v-model:value="form.optionsText" placeholder="选项值,用逗号分隔,如:选项1,选项2,选项3" />
<a-input
v-else
v-model:value="form.optionsText"
placeholder="选项值,用逗号分隔,如:选项1,选项2,选项3"
/>
</a-form-item>
<!-- 默认值 -->
<a-form-item label="默认值" name="default_value">
<a-input v-model:value="form.default_value" placeholder="默认值(可选)" allow-clear />
<a-input
v-model:value="form.default_value"
placeholder="默认值(可选)"
allow-clear
/>
</a-form-item>
<!-- 排序 -->
<a-form-item label="排序" name="sort">
<a-input-number v-model:value="form.sort" :min="0" :max="10000" style="width: 100%" />
<a-input-number
v-model:value="form.sort"
:min="0"
:max="10000"
style="width: 100%"
/>
</a-form-item>
<!-- 状态 -->
<a-form-item label="状态" name="status">
<a-switch v-model:checked="statusChecked" checked-children="启用" un-checked-children="禁用" />
<a-switch
v-model:checked="statusChecked"
checked-children="启用"
un-checked-children="禁用"
/>
</a-form-item>
<!-- 描述 -->
<a-form-item label="描述" name="description">
<a-textarea v-model:value="form.description" placeholder="请输入配置描述" :rows="3" maxlength="200" show-count />
<a-textarea
v-model:value="form.description"
placeholder="请输入配置描述"
:rows="3"
maxlength="200"
show-count
/>
</a-form-item>
</a-form>
@@ -76,215 +154,228 @@
<div class="dialog-footer">
<a-space>
<a-button @click="handleCancel">取消</a-button>
<a-button type="primary" :loading="isSaving" @click="handleSubmit">保存</a-button>
<a-button
type="primary"
:loading="isSaving"
@click="handleSubmit"
>保存</a-button
>
</a-space>
</div>
</a-modal>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { message } from 'ant-design-vue'
import systemApi from '@/api/system'
import { useDictionaryStore } from '@/stores/modules/dictionary'
import { ref, computed, watch } from "vue";
import { message } from "ant-design-vue";
import systemApi from "@/api/system";
import { useDictionaryStore } from "@/stores/modules/dictionary";
const props = defineProps({
visible: {
type: Boolean,
default: false
default: false,
},
record: {
type: Object,
default: null
}
})
default: null,
},
});
const emit = defineEmits(['update:visible', 'success'])
const emit = defineEmits(["update:visible", "success"]);
const formRef = ref(null)
const isSaving = ref(false)
const isEdit = computed(() => !!props.record?.id)
const formRef = ref(null);
const isSaving = ref(false);
const isEdit = computed(() => !!props.record?.id);
const title = computed(() => {
return isEdit.value ? '编辑配置' : '新增配置'
})
return isEdit.value ? "编辑配置" : "新增配置";
});
// 配置分组选项
const groupOptions = ref([])
const groupOptions = ref([]);
// 表单数据
const form = ref({
id: '',
name: '',
key: '',
group: '',
type: 'string',
value: '',
default_value: '',
description: '',
id: "",
name: "",
key: "",
group: "",
type: "string",
value: "",
default_value: "",
description: "",
status: true,
sort: 0,
options: []
})
options: [],
});
// 选项文本(用于选择框类型)
const optionsText = ref('')
const optionsText = ref("");
// 计算属性:状态开关
const statusChecked = computed({
get: () => form.value.status === true,
set: (val) => {
form.value.status = val ? true : false
}
})
form.value.status = val ? true : false;
},
});
// 计算属性:值开关(布尔类型)
const valueChecked = computed({
get: () => form.value.value === '1' || form.value.value === true,
get: () => form.value.value === "1" || form.value.value === true,
set: (val) => {
form.value.value = val ? '1' : '0'
}
})
form.value.value = val ? "1" : "0";
},
});
// 初始化字典 store
const dictionaryStore = useDictionaryStore()
const dictionaryStore = useDictionaryStore();
// 加载配置分组
const loadGroups = async () => {
const groups = await dictionaryStore.getDictionary('config_group')
groupOptions.value = groups.map(item => ({
const groups = await dictionaryStore.getDictionary("config_group");
groupOptions.value = groups.map((item) => ({
label: item.label,
value: item.value
}))
}
value: item.value,
}));
};
// 验证规则
const rules = {
name: [
{ required: true, message: '请输入配置名称', trigger: 'blur' },
{ min: 2, max: 50, message: '长度在 2 到 50 个字符', trigger: 'blur' }
{ required: true, message: "请输入配置名称", trigger: "blur" },
{ min: 2, max: 50, message: "长度在 2 到 50 个字符", trigger: "blur" },
],
key: [
{ required: true, message: '请输入配置键名', trigger: 'blur' },
{ pattern: /^[a-zA-Z][a-zA-Z0-9_]*$/, message: '格式不正确', trigger: 'blur' }
{ required: true, message: "请输入配置键名", trigger: "blur" },
{
pattern: /^[a-zA-Z][a-zA-Z0-9_]*$/,
message: "格式不正确",
trigger: "blur",
},
],
group: [
{ required: true, message: '请选择配置分组', trigger: 'change' }
],
type: [
{ required: true, message: '请选择配置类型', trigger: 'change' }
]
}
group: [{ required: true, message: "请选择配置分组", trigger: "change" }],
type: [{ required: true, message: "请选择配置类型", trigger: "change" }],
};
// 重置表单
const resetForm = () => {
form.value = {
id: '',
name: '',
key: '',
group: '',
type: 'string',
value: '',
default_value: '',
description: '',
id: "",
name: "",
key: "",
group: "",
type: "string",
value: "",
default_value: "",
description: "",
status: true,
sort: 0,
options: []
}
optionsText.value = ''
formRef.value?.clearValidate()
}
options: [],
};
optionsText.value = "";
formRef.value?.clearValidate();
};
// 设置数据
const setData = (data) => {
if (data) {
form.value = {
id: data.id || '',
name: data.name || '',
key: data.key || '',
group: data.group || '',
type: data.type || 'string',
value: data.value || '',
default_value: data.default_value || '',
description: data.description || '',
id: data.id || "",
name: data.name || "",
key: data.key || "",
group: data.group || "",
type: data.type || "string",
value: data.value || "",
default_value: data.default_value || "",
description: data.description || "",
status: data.status !== undefined ? data.status : true,
sort: data.sort !== undefined ? data.sort : 0,
options: data.options || []
}
options: data.options || [],
};
// 如果是选择框类型,设置选项文本
if (data.type === 'select' && data.options) {
optionsText.value = Array.isArray(data.options) ? data.options.join(',') : data.options
if (data.type === "select" && data.options) {
optionsText.value = Array.isArray(data.options)
? data.options.join(",")
: data.options;
}
}
}
};
// 提交表单
const handleSubmit = async () => {
try {
await formRef.value.validate()
await formRef.value.validate();
isSaving.value = true
isSaving.value = true;
// 处理选项
if (form.value.type === 'select') {
form.value.options = optionsText.value ? optionsText.value.split(',').map(s => s.trim()) : []
if (form.value.type === "select") {
form.value.options = optionsText.value
? optionsText.value.split(",").map((s) => s.trim())
: [];
}
// 处理值
let submitValue = form.value.value
if (form.value.type === 'boolean') {
submitValue = valueChecked.value ? '1' : '0'
let submitValue = form.value.value;
if (form.value.type === "boolean") {
submitValue = valueChecked.value ? "1" : "0";
}
const submitData = {
...form.value,
value: submitValue
}
value: submitValue,
};
let res = {}
let res = {};
if (isEdit.value) {
res = await systemApi.config.update.put(form.value.id, submitData)
res = await systemApi.config.update.put(form.value.id, submitData);
} else {
res = await systemApi.config.add.post(submitData)
res = await systemApi.config.add.post(submitData);
}
if (res.code === 200) {
message.success(isEdit.value ? '编辑成功' : '新增成功')
emit('success')
handleCancel()
message.success(isEdit.value ? "编辑成功" : "新增成功");
emit("success");
handleCancel();
} else {
message.error(res.message || '操作失败')
message.error(res.message || "操作失败");
}
} catch (error) {
if (error.errorFields) {
console.log('表单验证失败:', error)
console.log("表单验证失败:", error);
} else {
console.error('提交失败:', error)
message.error('操作失败')
console.error("提交失败:", error);
message.error("操作失败");
}
} finally {
isSaving.value = false
isSaving.value = false;
}
}
};
// 取消
const handleCancel = () => {
resetForm()
emit('update:visible', false)
}
resetForm();
emit("update:visible", false);
};
// 监听 visible 变化
watch(() => props.visible, (newVal) => {
if (newVal) {
loadGroups()
if (props.record) {
setData(props.record)
} else {
resetForm()
watch(
() => props.visible,
(newVal) => {
if (newVal) {
loadGroups();
if (props.record) {
setData(props.record);
} else {
resetForm();
}
}
}
}, { immediate: true })
},
{ immediate: true },
);
</script>
<style scoped lang="scss">
@@ -1,59 +1,141 @@
<template>
<div class="config-form-container">
<a-form ref="formRef" :model="formData" :label-col="{ span: 6 }" :wrapper-col="{ span: 16 }">
<a-form
ref="formRef"
:model="formData"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 16 }"
>
<!-- 根据配置类型动态渲染表单项 -->
<template v-for="config in configs" :key="config.id">
<!-- 字符串类型 -->
<a-form-item v-if="config.type === 'string'" :label="config.name" :name="config.key">
<a-input v-model:value="formData[config.key]" :placeholder="`请输入${config.name}`" />
<div v-if="config.description" class="form-tip">{{ config.description }}</div>
<a-form-item
v-if="config.type === 'string'"
:label="config.name"
:name="config.key"
>
<a-input
v-model:value="formData[config.key]"
:placeholder="`请输入${config.name}`"
/>
<div v-if="config.description" class="form-tip">
{{ config.description }}
</div>
</a-form-item>
<!-- 数字类型 -->
<a-form-item v-else-if="config.type === 'number'" :label="config.name" :name="config.key">
<a-input-number v-model:value="formData[config.key]" :min="config.options?.min || 0"
:max="config.options?.max || 100000" style="width: 100%" />
<div v-if="config.description" class="form-tip">{{ config.description }}</div>
<a-form-item
v-else-if="config.type === 'number'"
:label="config.name"
:name="config.key"
>
<a-input-number
v-model:value="formData[config.key]"
:min="config.options?.min || 0"
:max="config.options?.max || 100000"
style="width: 100%"
/>
<div v-if="config.description" class="form-tip">
{{ config.description }}
</div>
</a-form-item>
<!-- 布尔类型 -->
<a-form-item v-else-if="config.type === 'boolean'" :label="config.name" :name="config.key">
<a-switch v-model:checked="formData[config.key]" checked-children="启用" un-checked-children="禁用" />
<div v-if="config.description" class="form-tip">{{ config.description }}</div>
<a-form-item
v-else-if="config.type === 'boolean'"
:label="config.name"
:name="config.key"
>
<a-switch
v-model:checked="formData[config.key]"
checked-children="启用"
un-checked-children="禁用"
/>
<div v-if="config.description" class="form-tip">
{{ config.description }}
</div>
</a-form-item>
<!-- 文件类型 -->
<a-form-item v-else-if="config.type === 'file'" :label="config.name" :name="config.key">
<a-input v-model:value="formData[config.key]" :placeholder="`请输入${config.name}地址`">
<a-form-item
v-else-if="config.type === 'file'"
:label="config.name"
:name="config.key"
>
<a-input
v-model:value="formData[config.key]"
:placeholder="`请输入${config.name}地址`"
>
<template #suffix>
<a-button type="link" size="small" @click="handleUpload(config)">上传</a-button>
<a-button
type="link"
size="small"
@click="handleUpload(config)"
>上传</a-button
>
</template>
</a-input>
<div v-if="config.description" class="form-tip">{{ config.description }}</div>
<div v-if="config.description" class="form-tip">
{{ config.description }}
</div>
</a-form-item>
<!-- 图片类型 -->
<a-form-item v-else-if="config.type === 'image'" :label="config.name" :name="config.key">
<a-form-item
v-else-if="config.type === 'image'"
:label="config.name"
:name="config.key"
>
<div class="image-upload-wrapper">
<a-input v-model:value="formData[config.key]" :placeholder="`请输入${config.name}地址`" />
<a-input
v-model:value="formData[config.key]"
:placeholder="`请输入${config.name}地址`"
/>
<div v-if="formData[config.key]" class="image-preview">
<img :src="formData[config.key]" :alt="config.name" />
<img
:src="formData[config.key]"
:alt="config.name"
/>
</div>
<a-button type="link" @click="handleUpload(config)">选择图片</a-button>
<a-button type="link" @click="handleUpload(config)"
>选择图片</a-button
>
</div>
<div v-if="config.description" class="form-tip">
{{ config.description }}
</div>
<div v-if="config.description" class="form-tip">{{ config.description }}</div>
</a-form-item>
<!-- 文本类型 -->
<a-form-item v-else-if="config.type === 'text'" :label="config.name" :name="config.key">
<a-textarea v-model:value="formData[config.key]" :rows="4" :placeholder="`请输入${config.name}`" />
<div v-if="config.description" class="form-tip">{{ config.description }}</div>
<a-form-item
v-else-if="config.type === 'text'"
:label="config.name"
:name="config.key"
>
<a-textarea
v-model:value="formData[config.key]"
:rows="4"
:placeholder="`请输入${config.name}`"
/>
<div v-if="config.description" class="form-tip">
{{ config.description }}
</div>
</a-form-item>
<!-- 选择框类型 -->
<a-form-item v-else-if="config.type === 'select'" :label="config.name" :name="config.key">
<a-select v-model:value="formData[config.key]" :placeholder="`请选择${config.name}`" :options="config.options || []" />
<div v-if="config.description" class="form-tip">{{ config.description }}</div>
<a-form-item
v-else-if="config.type === 'select'"
:label="config.name"
:name="config.key"
>
<a-select
v-model:value="formData[config.key]"
:placeholder="`请选择${config.name}`"
:options="config.options || []"
/>
<div v-if="config.description" class="form-tip">
{{ config.description }}
</div>
</a-form-item>
</template>
</a-form>
@@ -74,102 +156,105 @@
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import { SaveOutlined, RedoOutlined } from '@ant-design/icons-vue'
import systemApi from '@/api/system'
import { ref, reactive, onMounted } from "vue";
import { message } from "ant-design-vue";
import { SaveOutlined, RedoOutlined } from "@ant-design/icons-vue";
import systemApi from "@/api/system";
const props = defineProps({
group: {
type: String,
required: true
}
})
required: true,
},
});
const emit = defineEmits(['refresh'])
const emit = defineEmits(["refresh"]);
const formRef = ref(null)
const saving = ref(false)
const configs = ref([])
const formData = reactive({})
const formRef = ref(null);
const saving = ref(false);
const configs = ref([]);
const formData = reactive({});
// 加载配置
const loadConfigs = async () => {
try {
const res = await systemApi.config.list.get({ group: props.group })
const res = await systemApi.config.list.get({ group: props.group });
if (res.code === 200) {
configs.value = res.data.list || []
configs.value = res.data.list || [];
// 初始化表单数据
configs.value.forEach(config => {
configs.value.forEach((config) => {
// 根据类型转换值
if (config.type === 'boolean') {
formData[config.key] = config.value === '1' || config.value === true
} else if (config.type === 'number') {
formData[config.key] = Number(config.value) || 0
if (config.type === "boolean") {
formData[config.key] =
config.value === "1" || config.value === true;
} else if (config.type === "number") {
formData[config.key] = Number(config.value) || 0;
} else {
formData[config.key] = config.value || ''
formData[config.key] = config.value || "";
}
})
});
}
} catch (error) {
console.error('加载配置失败:', error)
message.error('加载配置失败')
console.error("加载配置失败:", error);
message.error("加载配置失败");
}
}
};
// 保存配置
const handleSave = async () => {
try {
saving.value = true
saving.value = true;
// 准备提交数据
const updates = []
configs.value.forEach(config => {
let value = formData[config.key]
const updates = [];
configs.value.forEach((config) => {
let value = formData[config.key];
// 根据类型转换值
if (config.type === 'boolean') {
value = value ? '1' : '0'
} else if (config.type === 'number') {
value = String(value)
if (config.type === "boolean") {
value = value ? "1" : "0";
} else if (config.type === "number") {
value = String(value);
}
updates.push({
key: config.key,
value: value
})
})
value: value,
});
});
const res = await systemApi.config.batchUpdate.post({ configs: updates })
const res = await systemApi.config.batchUpdate.post({
configs: updates,
});
if (res.code === 200) {
message.success('保存成功')
emit('refresh')
message.success("保存成功");
emit("refresh");
} else {
message.error(res.message || '保存失败')
message.error(res.message || "保存失败");
}
} catch (error) {
console.error('保存配置失败:', error)
message.error('保存失败')
console.error("保存配置失败:", error);
message.error("保存失败");
} finally {
saving.value = false
saving.value = false;
}
}
};
// 重置配置
const handleReset = () => {
loadConfigs()
message.info('已重置')
}
loadConfigs();
message.info("已重置");
};
// 上传处理(占位)
const handleUpload = (config) => {
message.info(`${config.name}上传功能待实现`)
}
message.info(`${config.name}上传功能待实现`);
};
onMounted(() => {
loadConfigs()
})
loadConfigs();
});
</script>
<style scoped lang="scss">
@@ -7,9 +7,19 @@
@ok="handleOk"
@cancel="handleCancel"
>
<a-form ref="formRef" :model="formData" :rules="rules" :label-col="{ span: 5 }" :wrapper-col="{ span: 18 }">
<a-form
ref="formRef"
:model="formData"
:rules="rules"
:label-col="{ span: 5 }"
:wrapper-col="{ span: 18 }"
>
<a-form-item label="配置分组" name="group">
<a-select v-model:value="formData.group" placeholder="请选择配置分组" allow-show-search>
<a-select
v-model:value="formData.group"
placeholder="请选择配置分组"
allow-show-search
>
<a-select-option value="system">系统设置</a-select-option>
<a-select-option value="site">站点配置</a-select-option>
<a-select-option value="upload">上传配置</a-select-option>
@@ -18,24 +28,45 @@
<a-select-option value="other">其他</a-select-option>
<template #notFoundContent>
<div style="text-align: center">
<a-input v-model:value="customGroup" placeholder="输入新分组" style="margin-bottom: 8px" />
<a-button type="primary" size="small" @click="handleAddCustomGroup">添加</a-button>
<a-input
v-model:value="customGroup"
placeholder="输入新分组"
style="margin-bottom: 8px"
/>
<a-button
type="primary"
size="small"
@click="handleAddCustomGroup"
>添加</a-button
>
</div>
</template>
</a-select>
</a-form-item>
<a-form-item label="配置键" name="key">
<a-input v-model:value="formData.key" placeholder="请输入配置键,如:site_name" :disabled="isEdit && formData.is_system" />
<div style="color: #999; font-size: 12px; margin-top: 4px">唯一标识建议使用英文下划线命名</div>
<a-input
v-model:value="formData.key"
placeholder="请输入配置键,如:site_name"
:disabled="isEdit && formData.is_system"
/>
<div style="color: #999; font-size: 12px; margin-top: 4px">
唯一标识建议使用英文下划线命名
</div>
</a-form-item>
<a-form-item label="配置名称" name="name">
<a-input v-model:value="formData.name" placeholder="请输入配置名称" />
<a-input
v-model:value="formData.name"
placeholder="请输入配置名称"
/>
</a-form-item>
<a-form-item label="数据类型" name="type">
<a-select v-model:value="formData.type" placeholder="请选择数据类型">
<a-select
v-model:value="formData.type"
placeholder="请选择数据类型"
>
<a-select-option value="string">字符串</a-select-option>
<a-select-option value="text">文本</a-select-option>
<a-select-option value="number">数字</a-select-option>
@@ -99,7 +130,11 @@
placeholder="请选择配置值"
allow-clear
>
<a-select-option v-for="opt in parsedOptions" :key="opt.value" :value="opt.value">
<a-select-option
v-for="opt in parsedOptions"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</a-select-option>
</a-select>
@@ -108,7 +143,11 @@
v-else-if="formData.type === 'checkbox'"
v-model:value="formData.value"
>
<a-checkbox v-for="opt in parsedOptions" :key="opt.value" :value="opt.value">
<a-checkbox
v-for="opt in parsedOptions"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</a-checkbox>
</a-checkbox-group>
@@ -132,7 +171,12 @@
</a-form-item>
<a-form-item label="排序" name="sort">
<a-input-number v-model:value="formData.sort" placeholder="请输入排序" :min="0" style="width: 100%" />
<a-input-number
v-model:value="formData.sort"
placeholder="请输入排序"
:min="0"
style="width: 100%"
/>
</a-form-item>
<a-form-item label="状态" name="status">
@@ -143,113 +187,117 @@
</a-form-item>
<a-form-item label="描述" name="description">
<a-textarea v-model:value="formData.description" placeholder="请输入配置描述" :rows="2" />
<a-textarea
v-model:value="formData.description"
placeholder="请输入配置描述"
:rows="2"
/>
</a-form-item>
</a-form>
</a-modal>
</template>
<script setup>
import { ref, reactive, computed, watch } from 'vue'
import { message } from 'ant-design-vue'
import systemApi from '@/api/system'
import { ref, reactive, computed, watch } from "vue";
import { message } from "ant-design-vue";
import systemApi from "@/api/system";
const props = defineProps({
visible: Boolean,
record: {
type: Object,
default: null
}
})
default: null,
},
});
const emit = defineEmits(['update:visible', 'success'])
const emit = defineEmits(["update:visible", "success"]);
const formRef = ref(null)
const loading = ref(false)
const customGroup = ref('')
const optionsText = ref('')
const formRef = ref(null);
const loading = ref(false);
const customGroup = ref("");
const optionsText = ref("");
// 是否编辑模式
const isEdit = computed(() => !!props.record?.id)
const isEdit = computed(() => !!props.record?.id);
// 表单数据
const formData = reactive({
group: 'system',
key: '',
name: '',
type: 'string',
value: '',
group: "system",
key: "",
name: "",
type: "string",
value: "",
options: null,
sort: 0,
status: 1,
description: '',
is_system: false
})
description: "",
is_system: false,
});
// 表单验证规则
const rules = {
group: [{ required: true, message: '请选择配置分组', trigger: 'change' }],
key: [{ required: true, message: '请输入配置键', trigger: 'blur' }],
name: [{ required: true, message: '请输入配置名称', trigger: 'blur' }],
type: [{ required: true, message: '请选择数据类型', trigger: 'change' }],
value: [{ required: true, message: '请输入配置值', trigger: 'change' }]
}
group: [{ required: true, message: "请选择配置分组", trigger: "change" }],
key: [{ required: true, message: "请输入配置键", trigger: "blur" }],
name: [{ required: true, message: "请输入配置名称", trigger: "blur" }],
type: [{ required: true, message: "请选择数据类型", trigger: "change" }],
value: [{ required: true, message: "请输入配置值", trigger: "change" }],
};
// 解析选项
const parsedOptions = computed(() => {
if (!optionsText.value) return []
if (!optionsText.value) return [];
try {
return optionsText.value
.split('\n')
.split("\n")
.filter((line) => line.trim())
.map((line) => {
const [label, value] = line.split(':').map((s) => s.trim())
return { label, value: value || label }
})
const [label, value] = line.split(":").map((s) => s.trim());
return { label, value: value || label };
});
} catch {
return []
return [];
}
})
});
// 根据类型解析值
const parseValueByType = (value, type) => {
if (!value) return value
if (!value) return value;
switch (type) {
case 'number':
return Number(value)
case 'boolean':
return value === 'true' || value === true || value === 1
case 'checkbox':
if (typeof value === 'string') {
case "number":
return Number(value);
case "boolean":
return value === "true" || value === true || value === 1;
case "checkbox":
if (typeof value === "string") {
try {
return JSON.parse(value)
return JSON.parse(value);
} catch {
return value.split(',')
return value.split(",");
}
}
return value
return value;
default:
return value
return value;
}
}
};
// 重置表单
const resetForm = () => {
Object.assign(formData, {
group: 'system',
key: '',
name: '',
type: 'string',
value: '',
group: "system",
key: "",
name: "",
type: "string",
value: "",
options: null,
sort: 0,
status: 1,
description: '',
is_system: false
})
optionsText.value = ''
formRef.value?.clearValidate()
}
description: "",
is_system: false,
});
optionsText.value = "";
formRef.value?.clearValidate();
};
// 监听record变化,初始化表单
watch(
@@ -258,102 +306,108 @@ watch(
if (newRecord) {
// 编辑模式
Object.assign(formData, {
group: newRecord.group || 'system',
key: newRecord.key || '',
name: newRecord.name || '',
type: newRecord.type || 'string',
group: newRecord.group || "system",
key: newRecord.key || "",
name: newRecord.name || "",
type: newRecord.type || "string",
value: parseValueByType(newRecord.value, newRecord.type),
options: newRecord.options,
sort: newRecord.sort || 0,
status: newRecord.status ?? 1,
description: newRecord.description || '',
is_system: newRecord.is_system || false
})
description: newRecord.description || "",
is_system: newRecord.is_system || false,
});
// 解析选项
if (newRecord.options && typeof newRecord.options === 'object') {
if (newRecord.options && typeof newRecord.options === "object") {
optionsText.value = newRecord.options
.map((opt) => {
if (typeof opt === 'object') {
return `${opt.label}:${opt.value}`
if (typeof opt === "object") {
return `${opt.label}:${opt.value}`;
}
return opt
return opt;
})
.join('\n')
.join("\n");
}
} else {
// 新增模式,重置表单
resetForm()
resetForm();
}
},
{ immediate: true }
)
{ immediate: true },
);
// 添加自定义分组
const handleAddCustomGroup = () => {
if (!customGroup.value) {
message.warning('请输入分组名称')
return
message.warning("请输入分组名称");
return;
}
formData.group = customGroup.value
customGroup.value = ''
message.success('分组已添加')
}
formData.group = customGroup.value;
customGroup.value = "";
message.success("分组已添加");
};
// 处理确定按钮
const handleOk = async () => {
try {
await formRef.value.validate()
loading.value = true
await formRef.value.validate();
loading.value = true;
const submitData = { ...formData }
const submitData = { ...formData };
// 处理选项
if (['select', 'radio', 'checkbox'].includes(formData.type) && optionsText.value) {
submitData.options = parsedOptions.value
if (
["select", "radio", "checkbox"].includes(formData.type) &&
optionsText.value
) {
submitData.options = parsedOptions.value;
}
// 处理值类型转换
if (formData.type === 'number') {
submitData.value = Number(submitData.value)
} else if (formData.type === 'boolean') {
submitData.value = submitData.value ? '1' : '0'
} else if (formData.type === 'checkbox' && Array.isArray(submitData.value)) {
submitData.value = submitData.value.join(',')
} else if (formData.type === 'json') {
if (formData.type === "number") {
submitData.value = Number(submitData.value);
} else if (formData.type === "boolean") {
submitData.value = submitData.value ? "1" : "0";
} else if (
formData.type === "checkbox" &&
Array.isArray(submitData.value)
) {
submitData.value = submitData.value.join(",");
} else if (formData.type === "json") {
// 验证JSON格式
try {
JSON.parse(submitData.value)
JSON.parse(submitData.value);
} catch (e) {
throw new Error('JSON格式不正确')
throw new Error("JSON格式不正确");
}
}
if (isEdit.value) {
await systemApi.configs.edit.put(formData.id, submitData)
message.success('更新成功')
await systemApi.configs.edit.put(formData.id, submitData);
message.success("更新成功");
} else {
await systemApi.configs.add.post(submitData)
message.success('创建成功')
await systemApi.configs.add.post(submitData);
message.success("创建成功");
}
emit('success')
emit('update:visible', false)
resetForm()
emit("success");
emit("update:visible", false);
resetForm();
} catch (error) {
if (error.message) {
message.error(error.message || '操作失败')
message.error(error.message || "操作失败");
}
} finally {
loading.value = false
loading.value = false;
}
}
};
// 处理取消按钮
const handleCancel = () => {
resetForm()
emit('update:visible', false)
}
resetForm();
emit("update:visible", false);
};
</script>
<style scoped lang="scss">
+222 -172
View File
@@ -70,27 +70,57 @@
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'type'">
<a-tag :color="getTypeColor(record.type)">{{ getTypeText(record.type) }}</a-tag>
<a-tag :color="getTypeColor(record.type)">{{
getTypeText(record.type)
}}</a-tag>
</template>
<template v-if="column.key === 'value'">
<span v-if="['string', 'number', 'boolean'].includes(record.type)" class="value-text">
<span
v-if="
['string', 'number', 'boolean'].includes(
record.type,
)
"
class="value-text"
>
{{ formatValue(record.value, record.type) }}
</span>
<span v-else-if="record.type === 'file'" class="file-value">
<span
v-else-if="record.type === 'file'"
class="file-value"
>
<file-outlined />
{{ record.value }}
</span>
<a v-else-if="record.type === 'image'" :href="record.value" target="_blank" class="image-value">
<img :src="record.value" alt="预览" class="config-image" />
<a
v-else-if="record.type === 'image'"
:href="record.value"
target="_blank"
class="image-value"
>
<img
:src="record.value"
alt="预览"
class="config-image"
/>
</a>
<span v-else class="json-value">{{ record.value }}</span>
<span v-else class="json-value">{{
record.value
}}</span>
</template>
<template v-if="column.key === 'status'">
<a-badge :status="record.status ? 'success' : 'default'" :text="record.status ? '启用' : '禁用'" />
<a-badge
:status="record.status ? 'success' : 'default'"
:text="record.status ? '启用' : '禁用'"
/>
</template>
<template v-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="handleEdit(record)">
<a-button
type="link"
size="small"
@click="handleEdit(record)"
>
<edit-outlined />
编辑
</a-button>
@@ -111,13 +141,17 @@
</div>
<!-- 新增/编辑弹窗 -->
<SaveDialog v-model:visible="showSaveDialog" :record="currentRecord" @success="handleSaveSuccess" />
<SaveDialog
v-model:visible="showSaveDialog"
:record="currentRecord"
@success="handleSaveSuccess"
/>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, h } from 'vue'
import { message, Modal } from 'ant-design-vue'
import { ref, reactive, onMounted, h } from "vue";
import { message, Modal } from "ant-design-vue";
import {
SearchOutlined,
RedoOutlined,
@@ -127,256 +161,272 @@ import {
CheckOutlined,
StopOutlined,
EditOutlined,
FileOutlined
} from '@ant-design/icons-vue'
import scTable from '@/components/scTable/index.vue'
import { useTable } from '@/hooks/useTable'
import systemApi from '@/api/system'
import SaveDialog from './components/SaveDialog.vue'
FileOutlined,
} from "@ant-design/icons-vue";
import scTable from "@/components/scTable/index.vue";
import { useTable } from "@/hooks/useTable";
import systemApi from "@/api/system";
import SaveDialog from "./components/SaveDialog.vue";
// 表格引用
const tableRef = ref(null)
const tableRef = ref(null);
// 搜索表单
const searchForm = reactive({
keyword: '',
group: undefined
})
keyword: "",
group: undefined,
});
// 分组选项
const groupOptions = ref([])
const groupOptions = ref([]);
// 当前记录
const currentRecord = ref(null)
const currentRecord = ref(null);
// 显示新增/编辑弹窗
const showSaveDialog = ref(false)
const showSaveDialog = ref(false);
// 使用 useTable Hook
const { tableData, loading, pagination, rowSelection, handleSearch, handleReset, handlePaginationChange, refreshTable } =
useTable({
api: systemApi.configs.list.get,
searchForm,
needPagination: true
})
const {
tableData,
loading,
pagination,
rowSelection,
handleSearch,
handleReset,
handlePaginationChange,
refreshTable,
} = useTable({
api: systemApi.configs.list.get,
searchForm,
needPagination: true,
});
// 表格列配置
const columns = [
{
title: 'ID',
dataIndex: 'id',
key: 'id',
width: 80
},
{
title: '配置分组',
dataIndex: 'group',
key: 'group',
width: 120
},
{
title: '配置键',
dataIndex: 'key',
key: 'key',
width: 200,
ellipsis: true
},
{
title: '配置名称',
dataIndex: 'name',
key: 'name',
width: 180
},
{
title: '配置值',
dataIndex: 'value',
key: 'value',
ellipsis: true,
width: 250
},
{
title: '类型',
dataIndex: 'type',
key: 'type',
width: 100,
align: 'center'
},
{
title: '排序',
dataIndex: 'sort',
key: 'sort',
title: "ID",
dataIndex: "id",
key: "id",
width: 80,
align: 'center'
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
title: "配置分组",
dataIndex: "group",
key: "group",
width: 120,
},
{
title: "配置键",
dataIndex: "key",
key: "key",
width: 200,
ellipsis: true,
},
{
title: "配置名称",
dataIndex: "name",
key: "name",
width: 180,
},
{
title: "配置值",
dataIndex: "value",
key: "value",
ellipsis: true,
width: 250,
},
{
title: "类型",
dataIndex: "type",
key: "type",
width: 100,
align: 'center'
align: "center",
},
{
title: '描述',
dataIndex: 'description',
key: 'description',
ellipsis: true
title: "排序",
dataIndex: "sort",
key: "sort",
width: 80,
align: "center",
},
{
title: '操作',
key: 'action',
title: "状态",
dataIndex: "status",
key: "status",
width: 100,
align: "center",
},
{
title: "描述",
dataIndex: "description",
key: "description",
ellipsis: true,
},
{
title: "操作",
key: "action",
width: 150,
fixed: 'right'
}
]
fixed: "right",
},
];
// 获取类型颜色
const getTypeColor = (type) => {
const colors = {
string: 'blue',
text: 'cyan',
number: 'green',
boolean: 'orange',
select: 'purple',
radio: 'purple',
checkbox: 'purple',
file: 'pink',
json: 'geekblue'
}
return colors[type] || 'default'
}
string: "blue",
text: "cyan",
number: "green",
boolean: "orange",
select: "purple",
radio: "purple",
checkbox: "purple",
file: "pink",
json: "geekblue",
};
return colors[type] || "default";
};
// 获取类型文本
const getTypeText = (type) => {
const texts = {
string: '字符串',
text: '文本',
number: '数字',
boolean: '布尔值',
select: '下拉框',
radio: '单选框',
checkbox: '多选框',
file: '文件',
json: 'JSON'
}
return texts[type] || type
}
string: "字符串",
text: "文本",
number: "数字",
boolean: "布尔值",
select: "下拉框",
radio: "单选框",
checkbox: "多选框",
file: "文件",
json: "JSON",
};
return texts[type] || type;
};
// 格式化值
const formatValue = (value, type) => {
if (type === 'boolean') {
return value === 'true' || value === true ? '是' : '否'
if (type === "boolean") {
return value === "true" || value === true ? "" : "";
}
if (type === 'number') {
return Number(value)
if (type === "number") {
return Number(value);
}
return value
}
return value;
};
// 获取分组列表
const loadGroups = async () => {
try {
const res = await systemApi.configs.groups.get()
groupOptions.value = res.data.map((item) => ({ label: item, value: item }))
const res = await systemApi.configs.groups.get();
groupOptions.value = res.data.map((item) => ({
label: item,
value: item,
}));
} catch (error) {
console.error('获取分组列表失败:', error)
console.error("获取分组列表失败:", error);
}
}
};
// 新增
const handleAdd = () => {
currentRecord.value = null
showSaveDialog.value = true
}
currentRecord.value = null;
showSaveDialog.value = true;
};
// 编辑
const handleEdit = (record) => {
currentRecord.value = { ...record }
showSaveDialog.value = true
}
currentRecord.value = { ...record };
showSaveDialog.value = true;
};
// 删除
const handleDelete = (record) => {
if (record.is_system) {
message.warning('系统配置不能删除')
return
message.warning("系统配置不能删除");
return;
}
Modal.confirm({
title: '确认删除',
title: "确认删除",
content: `确定要删除配置"${record.name}"吗?`,
okText: '确定',
cancelText: '取消',
okText: "确定",
cancelText: "取消",
onOk: async () => {
try {
await systemApi.configs.delete.delete(record.id)
message.success('删除成功')
refreshTable()
await systemApi.configs.delete.delete(record.id);
message.success("删除成功");
refreshTable();
} catch (error) {
message.error(error.message || '删除失败')
message.error(error.message || "删除失败");
}
}
})
}
},
});
};
// 批量删除
const handleBatchDelete = () => {
const selectedRowKeys = rowSelection.selectedRowKeys
const selectedRowKeys = rowSelection.selectedRowKeys;
if (selectedRowKeys.length === 0) {
message.warning('请先选择要删除的配置')
return
message.warning("请先选择要删除的配置");
return;
}
Modal.confirm({
title: '确认删除',
title: "确认删除",
content: `确定要删除选中的 ${selectedRowKeys.length} 条配置吗?`,
okText: '确定',
cancelText: '取消',
okText: "确定",
cancelText: "取消",
onOk: async () => {
try {
await systemApi.configs.batchDelete.post({ ids: selectedRowKeys })
message.success('批量删除成功')
rowSelection.selectedRowKeys = []
refreshTable()
await systemApi.configs.batchDelete.post({
ids: selectedRowKeys,
});
message.success("批量删除成功");
rowSelection.selectedRowKeys = [];
refreshTable();
} catch (error) {
message.error(error.message || '批量删除失败')
message.error(error.message || "批量删除失败");
}
}
})
}
},
});
};
// 批量更新状态
const handleBatchStatus = (status) => {
const selectedRowKeys = rowSelection.selectedRowKeys
const selectedRowKeys = rowSelection.selectedRowKeys;
if (selectedRowKeys.length === 0) {
message.warning('请先选择要操作的配置')
return
message.warning("请先选择要操作的配置");
return;
}
Modal.confirm({
title: status === 1 ? '确认启用' : '确认禁用',
content: `确定要${status === 1 ? '启用' : '禁用'}选中的 ${selectedRowKeys.length} 条配置吗?`,
okText: '确定',
cancelText: '取消',
title: status === 1 ? "确认启用" : "确认禁用",
content: `确定要${status === 1 ? "启用" : "禁用"}选中的 ${selectedRowKeys.length} 条配置吗?`,
okText: "确定",
cancelText: "取消",
onOk: async () => {
try {
await systemApi.configs.batchStatus.post({ ids: selectedRowKeys, status })
message.success(`${status === 1 ? '启用' : '禁用'}成功`)
rowSelection.selectedRowKeys = []
refreshTable()
await systemApi.configs.batchStatus.post({
ids: selectedRowKeys,
status,
});
message.success(`${status === 1 ? "启用" : "禁用"}成功`);
rowSelection.selectedRowKeys = [];
refreshTable();
} catch (error) {
message.error(error.message || '操作失败')
message.error(error.message || "操作失败");
}
}
})
}
},
});
};
// 保存成功
const handleSaveSuccess = () => {
showSaveDialog.value = false
refreshTable()
}
showSaveDialog.value = false;
refreshTable();
};
// 初始化
onMounted(() => {
loadGroups()
})
loadGroups();
});
</script>
<style scoped lang="scss">
@@ -1,43 +1,91 @@
<template>
<a-modal :title="title" :open="visible" :confirm-loading="isSaving" :footer="null" @cancel="handleCancel" width="600px">
<a-form ref="formRef" :model="form" :rules="rules" :label-col="{ span: 5 }" :wrapper-col="{ span: 18 }">
<a-modal
:title="title"
:open="visible"
:confirm-loading="isSaving"
:footer="null"
@cancel="handleCancel"
width="600px"
>
<a-form
ref="formRef"
:model="form"
:rules="rules"
:label-col="{ span: 5 }"
:wrapper-col="{ span: 18 }"
>
<!-- 字典名称 -->
<a-form-item label="字典名称" name="name" required>
<a-input v-model:value="form.name" placeholder="如:用户状态" allow-clear maxlength="50" show-count />
<a-input
v-model:value="form.name"
placeholder="如:用户状态"
allow-clear
maxlength="50"
show-count
/>
</a-form-item>
<!-- 字典编码 -->
<a-form-item label="字典编码" name="code" required>
<a-input v-model:value="form.code" placeholder="如:user_status" allow-clear :disabled="isEdit" />
<div class="form-tip">系统唯一标识只能包含字母数字下划线且必须以字母开头</div>
<a-input
v-model:value="form.code"
placeholder="如:user_status"
allow-clear
:disabled="isEdit"
/>
<div class="form-tip">
系统唯一标识只能包含字母数字下划线且必须以字母开头
</div>
</a-form-item>
<!-- 值类型 -->
<a-form-item label="值类型" name="value_type" required>
<a-select v-model:value="form.value_type" placeholder="请选择值类型" allow-clear>
<a-select
v-model:value="form.value_type"
placeholder="请选择值类型"
allow-clear
>
<a-select-option value="string">字符串</a-select-option>
<a-select-option value="number">数字</a-select-option>
<a-select-option value="boolean">布尔值</a-select-option>
<a-select-option value="json">JSON</a-select-option>
</a-select>
<div class="form-tip">指定字典项值的类型系统会根据类型自动格式化返回数据</div>
<div class="form-tip">
指定字典项值的类型系统会根据类型自动格式化返回数据
</div>
</a-form-item>
<!-- 排序 -->
<a-form-item label="排序" name="sort">
<a-input-number v-model:value="form.sort" :min="0" :max="10000" style="width: 100%" />
<a-input-number
v-model:value="form.sort"
:min="0"
:max="10000"
style="width: 100%"
/>
<div class="form-tip">数值越小越靠前</div>
</a-form-item>
<!-- 状态 -->
<a-form-item label="状态" name="status">
<sc-select v-model:value="form.status" source-type="dictionary" dictionary-code="dictionary_status" placeholder="请选择状态" allow-clear />
<sc-select
v-model:value="form.status"
source-type="dictionary"
dictionary-code="dictionary_status"
placeholder="请选择状态"
allow-clear
/>
</a-form-item>
<!-- 描述 -->
<a-form-item label="描述" name="description">
<a-textarea v-model:value="form.description" placeholder="请输入字典描述" :rows="3" maxlength="200"
show-count />
<a-textarea
v-model:value="form.description"
placeholder="请输入字典描述"
:rows="3"
maxlength="200"
show-count
/>
</a-form-item>
</a-form>
@@ -45,129 +93,140 @@
<div class="dialog-footer">
<a-space>
<a-button @click="handleCancel">取消</a-button>
<a-button type="primary" :loading="isSaving" @click="handleSubmit">保存</a-button>
<a-button
type="primary"
:loading="isSaving"
@click="handleSubmit"
>保存</a-button
>
</a-space>
</div>
</a-modal>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { message } from 'ant-design-vue'
import scSelect from '@/components/scSelect/index.vue'
import systemApi from '@/api/system'
import { ref, computed, watch } from "vue";
import { message } from "ant-design-vue";
import scSelect from "@/components/scSelect/index.vue";
import systemApi from "@/api/system";
// ===== Props =====
const props = defineProps({
visible: {
type: Boolean,
default: false
default: false,
},
record: {
type: Object,
default: null
default: null,
},
dictionaryList: {
type: Array,
default: () => []
}
})
default: () => [],
},
});
// ===== Emits =====
const emit = defineEmits(['update:visible', 'success'])
const emit = defineEmits(["update:visible", "success"]);
// ===== 状态 =====
const formRef = ref(null)
const isSaving = ref(false)
const isEdit = computed(() => !!props.record?.id)
const formRef = ref(null);
const isSaving = ref(false);
const isEdit = computed(() => !!props.record?.id);
const title = computed(() => {
return isEdit.value ? '编辑字典类型' : '新增字典类型'
})
return isEdit.value ? "编辑字典类型" : "新增字典类型";
});
// ===== 表单数据 =====
const form = ref({
id: '',
name: '',
code: '',
value_type: 'string',
description: '',
id: "",
name: "",
code: "",
value_type: "string",
description: "",
status: null,
sort: 0
})
sort: 0,
});
// ===== 验证规则 =====
// 编码唯一性验证
const validateCodeUnique = async (rule, value) => {
if (!value) return Promise.resolve()
if (!value) return Promise.resolve();
// 检查编码是否已存在(编辑时排除自己)
const exists = props.dictionaryList.some(
item => item.code === value && item.id !== props.record?.id
)
(item) => item.code === value && item.id !== props.record?.id,
);
if (exists) {
return Promise.reject('该编码已存在,请使用其他编码')
return Promise.reject("该编码已存在,请使用其他编码");
}
return Promise.resolve()
}
return Promise.resolve();
};
const rules = {
name: [
{ required: true, message: '请输入字典名称', trigger: 'blur' },
{ min: 2, max: 50, message: '字典名称长度在 2 到 50 个字符', trigger: 'blur' }
{ required: true, message: "请输入字典名称", trigger: "blur" },
{
min: 2,
max: 50,
message: "字典名称长度在 2 到 50 个字符",
trigger: "blur",
},
],
code: [
{ required: true, message: '请输入字典编码', trigger: 'blur' },
{ required: true, message: "请输入字典编码", trigger: "blur" },
{
pattern: /^[a-zA-Z][a-zA-Z0-9_]*$/,
message: '编码格式不正确,只能包含字母、数字、下划线,且必须以字母开头',
trigger: 'blur'
message:
"编码格式不正确,只能包含字母、数字、下划线,且必须以字母开头",
trigger: "blur",
},
{ validator: validateCodeUnique, trigger: 'blur' }
{ validator: validateCodeUnique, trigger: "blur" },
],
value_type: [
{ required: true, message: '请选择值类型', trigger: 'change' }
]
}
{ required: true, message: "请选择值类型", trigger: "change" },
],
};
// ===== 方法:重置表单 =====
const resetForm = () => {
form.value = {
id: '',
name: '',
code: '',
value_type: 'string',
description: '',
id: "",
name: "",
code: "",
value_type: "string",
description: "",
status: null,
sort: 0
}
formRef.value?.clearValidate()
}
sort: 0,
};
formRef.value?.clearValidate();
};
// ===== 方法:设置数据(编辑时) =====
const setData = (data) => {
if (data) {
form.value = {
id: data.id || '',
name: data.name || '',
code: data.code || '',
value_type: data.value_type || 'string',
description: data.description || '',
id: data.id || "",
name: data.name || "",
code: data.code || "",
value_type: data.value_type || "string",
description: data.description || "",
status: data.status !== undefined ? data.status : null,
sort: data.sort !== undefined ? data.sort : 0
}
sort: data.sort !== undefined ? data.sort : 0,
};
}
}
};
// ===== 方法:提交表单 =====
const handleSubmit = async () => {
try {
// 验证表单
await formRef.value.validate()
await formRef.value.validate();
isSaving.value = true
isSaving.value = true;
const submitData = {
name: form.value.name,
@@ -175,56 +234,63 @@ const handleSubmit = async () => {
value_type: form.value.value_type,
description: form.value.description,
status: form.value.status,
sort: form.value.sort
}
sort: form.value.sort,
};
let res = {}
let res = {};
if (isEdit.value) {
// 编辑
res = await systemApi.dictionaries.edit.put(form.value.id, submitData)
res = await systemApi.dictionaries.edit.put(
form.value.id,
submitData,
);
} else {
// 新增
res = await systemApi.dictionaries.add.post(submitData)
res = await systemApi.dictionaries.add.post(submitData);
}
if (res.code === 200) {
message.success(isEdit.value ? '编辑成功' : '新增成功')
emit('success')
handleCancel()
message.success(isEdit.value ? "编辑成功" : "新增成功");
emit("success");
handleCancel();
} else {
message.error(res.message || '操作失败')
message.error(res.message || "操作失败");
}
} catch (error) {
if (error.errorFields) {
// 表单验证失败
console.log('表单验证失败:', error)
console.log("表单验证失败:", error);
} else {
// API 调用失败
console.error('提交失败:', error)
message.error('操作失败')
console.error("提交失败:", error);
message.error("操作失败");
}
} finally {
isSaving.value = false
isSaving.value = false;
}
}
};
// ===== 方法:取消 =====
const handleCancel = () => {
resetForm()
emit('update:visible', false)
}
resetForm();
emit("update:visible", false);
};
// ===== 监听 visible 变化 =====
watch(() => props.visible, (newVal) => {
if (newVal) {
// 打开弹窗时,如果有 record 则设置数据
if (props.record) {
setData(props.record)
} else {
resetForm()
watch(
() => props.visible,
(newVal) => {
if (newVal) {
// 打开弹窗时,如果有 record 则设置数据
if (props.record) {
setData(props.record);
} else {
resetForm();
}
}
}
}, { immediate: true })
},
{ immediate: true },
);
</script>
<style scoped lang="scss">
@@ -1,50 +1,103 @@
<template>
<a-modal :title="title" :open="visible" :confirm-loading="isSaving" :footer="null" @cancel="handleCancel" width="600px">
<a-form ref="formRef" :model="form" :rules="rules" :label-col="{ span: 5 }" :wrapper-col="{ span: 18 }">
<a-modal
:title="title"
:open="visible"
:confirm-loading="isSaving"
:footer="null"
@cancel="handleCancel"
width="600px"
>
<a-form
ref="formRef"
:model="form"
:rules="rules"
:label-col="{ span: 5 }"
:wrapper-col="{ span: 18 }"
>
<!-- 标签名称 -->
<a-form-item label="标签名称" name="label" required>
<a-input v-model:value="form.label" placeholder="如:正常" allow-clear maxlength="50" show-count />
<a-input
v-model:value="form.label"
placeholder="如:正常"
allow-clear
maxlength="50"
show-count
/>
<div class="form-tip">用于前端显示的文本</div>
</a-form-item>
<!-- 数据值 -->
<a-form-item label="数据值" name="value" required>
<a-input v-model:value="form.value" placeholder="如:1" allow-clear />
<a-input
v-model:value="form.value"
placeholder="如:1"
allow-clear
/>
<div class="form-tip">实际使用的值同一字典内必须唯一</div>
</a-form-item>
<!-- 颜色标记 -->
<a-form-item label="颜色标记" name="color">
<div style="display: flex; align-items: center">
<input v-model="form.color" type="color" style="width: 60px; height: 32px; cursor: pointer" />
<a-input v-model:value="form.color" placeholder="#1890ff" allow-clear
style="flex: 1; margin-left: 10px" />
<input
v-model="form.color"
type="color"
style="width: 60px; height: 32px; cursor: pointer"
/>
<a-input
v-model:value="form.color"
placeholder="#1890ff"
allow-clear
style="flex: 1; margin-left: 10px"
/>
</div>
<div class="form-tip">用于前端展示的颜色标记</div>
</a-form-item>
<!-- 是否默认 -->
<a-form-item label="默认项" name="is_default">
<a-switch v-model:checked="isDefaultChecked" checked-children="" un-checked-children="" />
<div class="form-tip" v-if="form.is_default" style="color: #faad14">
<a-switch
v-model:checked="isDefaultChecked"
checked-children=""
un-checked-children=""
/>
<div
class="form-tip"
v-if="form.is_default"
style="color: #faad14"
>
设置为默认项后同一字典内的其他默认项将自动取消
</div>
</a-form-item>
<!-- 排序 -->
<a-form-item label="排序" name="sort">
<a-input-number v-model:value="form.sort" :min="0" :max="10000" style="width: 100%" />
<a-input-number
v-model:value="form.sort"
:min="0"
:max="10000"
style="width: 100%"
/>
</a-form-item>
<!-- 状态 -->
<a-form-item label="状态" name="status">
<a-switch v-model:checked="statusChecked" checked-children="启用" un-checked-children="禁用" />
<a-switch
v-model:checked="statusChecked"
checked-children="启用"
un-checked-children="禁用"
/>
</a-form-item>
<!-- 描述 -->
<a-form-item label="描述" name="description">
<a-textarea v-model:value="form.description" placeholder="请输入描述" :rows="3" maxlength="200"
show-count />
<a-textarea
v-model:value="form.description"
placeholder="请输入描述"
:rows="3"
maxlength="200"
show-count
/>
</a-form-item>
</a-form>
@@ -52,146 +105,156 @@
<div class="dialog-footer">
<a-space>
<a-button @click="handleCancel">取消</a-button>
<a-button type="primary" :loading="isSaving" @click="handleSubmit">保存</a-button>
<a-button
type="primary"
:loading="isSaving"
@click="handleSubmit"
>保存</a-button
>
</a-space>
</div>
</a-modal>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { message } from 'ant-design-vue'
import systemApi from '@/api/system'
import { ref, computed, watch } from "vue";
import { message } from "ant-design-vue";
import systemApi from "@/api/system";
// ===== Props =====
const props = defineProps({
visible: {
type: Boolean,
default: false
default: false,
},
record: {
type: Object,
default: null
default: null,
},
dictionaryId: {
type: Number,
default: null
default: null,
},
itemList: {
type: Array,
default: () => []
}
})
default: () => [],
},
});
// ===== Emits =====
const emit = defineEmits(['update:visible', 'success'])
const emit = defineEmits(["update:visible", "success"]);
// ===== 状态 =====
const formRef = ref(null)
const isSaving = ref(false)
const isEdit = computed(() => !!props.record?.id)
const formRef = ref(null);
const isSaving = ref(false);
const isEdit = computed(() => !!props.record?.id);
const title = computed(() => {
return isEdit.value ? '编辑字典项' : '新增字典项'
})
return isEdit.value ? "编辑字典项" : "新增字典项";
});
// ===== 表单数据 =====
const form = ref({
id: '',
label: '',
value: '',
color: '',
description: '',
id: "",
label: "",
value: "",
color: "",
description: "",
is_default: false,
status: true,
sort: 0,
dictionary_id: null
})
dictionary_id: null,
});
// ===== 计算属性:状态开关 =====
const statusChecked = computed({
get: () => form.value.status === true,
set: (val) => {
form.value.status = val ? true : false
}
})
form.value.status = val ? true : false;
},
});
// ===== 计算属性:默认项开关 =====
const isDefaultChecked = computed({
get: () => form.value.is_default === true,
set: (val) => {
form.value.is_default = val ? true : false
}
})
form.value.is_default = val ? true : false;
},
});
// ===== 验证规则 =====
// 数据值唯一性验证
const validateValueUnique = async (rule, value) => {
if (!value) return Promise.resolve()
if (!value) return Promise.resolve();
// 检查数据值是否已存在(同一字典内,编辑时排除自己)
const exists = props.itemList.some(
item => item.value === value && item.id !== props.record?.id
)
(item) => item.value === value && item.id !== props.record?.id,
);
if (exists) {
return Promise.reject('该数据值已存在,请使用其他值')
return Promise.reject("该数据值已存在,请使用其他值");
}
return Promise.resolve()
}
return Promise.resolve();
};
const rules = {
label: [
{ required: true, message: '请输入标签名称', trigger: 'blur' },
{ min: 1, max: 50, message: '标签名称长度在 1 到 50 个字符', trigger: 'blur' }
{ required: true, message: "请输入标签名称", trigger: "blur" },
{
min: 1,
max: 50,
message: "标签名称长度在 1 到 50 个字符",
trigger: "blur",
},
],
value: [
{ required: true, message: '请输入数据值', trigger: 'blur' },
{ validator: validateValueUnique, trigger: 'blur' }
]
}
{ required: true, message: "请输入数据值", trigger: "blur" },
{ validator: validateValueUnique, trigger: "blur" },
],
};
// ===== 方法:重置表单 =====
const resetForm = () => {
form.value = {
id: '',
label: '',
value: '',
color: '',
description: '',
id: "",
label: "",
value: "",
color: "",
description: "",
is_default: false,
status: true,
sort: 0,
dictionary_id: null
}
formRef.value?.clearValidate()
}
dictionary_id: null,
};
formRef.value?.clearValidate();
};
// ===== 方法:设置数据(编辑时) =====
const setData = (data) => {
if (data) {
form.value = {
id: data.id || '',
label: data.label || '',
value: data.value || '',
color: data.color || '',
description: data.description || '',
id: data.id || "",
label: data.label || "",
value: data.value || "",
color: data.color || "",
description: data.description || "",
is_default: data.is_default || false,
status: data.status !== undefined ? data.status : true,
sort: data.sort !== undefined ? data.sort : 0,
dictionary_id: data.dictionary_id || props.dictionaryId
}
dictionary_id: data.dictionary_id || props.dictionaryId,
};
}
}
};
// ===== 方法:提交表单 =====
const handleSubmit = async () => {
try {
// 验证表单
await formRef.value.validate()
await formRef.value.validate();
isSaving.value = true
isSaving.value = true;
const submitData = {
label: form.value.label,
@@ -201,58 +264,65 @@ const handleSubmit = async () => {
is_default: form.value.is_default,
status: form.value.status,
sort: form.value.sort,
dictionary_id: props.dictionaryId
}
dictionary_id: props.dictionaryId,
};
let res = {}
let res = {};
if (isEdit.value) {
// 编辑
res = await systemApi.dictionaryItems.edit.put(form.value.id, submitData)
res = await systemApi.dictionaryItems.edit.put(
form.value.id,
submitData,
);
} else {
// 新增
res = await systemApi.dictionaryItems.add.post(submitData)
res = await systemApi.dictionaryItems.add.post(submitData);
}
if (res.code === 200) {
message.success(isEdit.value ? '编辑成功' : '新增成功')
emit('success')
handleCancel()
message.success(isEdit.value ? "编辑成功" : "新增成功");
emit("success");
handleCancel();
} else {
message.error(res.message || '操作失败')
message.error(res.message || "操作失败");
}
} catch (error) {
if (error.errorFields) {
// 表单验证失败
console.log('表单验证失败:', error)
console.log("表单验证失败:", error);
} else {
// API 调用失败
console.error('提交失败:', error)
message.error('操作失败')
console.error("提交失败:", error);
message.error("操作失败");
}
} finally {
isSaving.value = false
isSaving.value = false;
}
}
};
// ===== 方法:取消 =====
const handleCancel = () => {
resetForm()
emit('update:visible', false)
}
resetForm();
emit("update:visible", false);
};
// ===== 监听 visible 变化 =====
watch(() => props.visible, (newVal) => {
if (newVal) {
// 打开弹窗时,如果有 record 则设置数据
if (props.record) {
setData(props.record)
} else {
resetForm()
// 新增时设置 dictionary_id
form.value.dictionary_id = props.dictionaryId
watch(
() => props.visible,
(newVal) => {
if (newVal) {
// 打开弹窗时,如果有 record 则设置数据
if (props.record) {
setData(props.record);
} else {
resetForm();
// 新增时设置 dictionary_id
form.value.dictionary_id = props.dictionaryId;
}
}
}
}, { immediate: true })
},
{ immediate: true },
);
</script>
<style scoped lang="scss">
@@ -3,31 +3,55 @@
<!-- 左侧字典类型列表 -->
<div class="left-box">
<div class="header">
<a-input v-model:value="dictionaryKeyword" placeholder="搜索字典..." allow-clear @change="handleDictionarySearch">
<a-input
v-model:value="dictionaryKeyword"
placeholder="搜索字典..."
allow-clear
@change="handleDictionarySearch"
>
<template #prefix>
<SearchOutlined style="color: rgba(0, 0, 0, 0.45)" />
</template>
</a-input>
<a-button type="primary" size="small" style="margin-top: 12px; width: 100%" @click="handleAddDictionary">
<a-button
type="primary"
size="small"
style="margin-top: 12px; width: 100%"
@click="handleAddDictionary"
>
<PlusOutlined /> 新增字典
</a-button>
</div>
<div class="body">
<!-- 字典列表 -->
<div v-if="filteredDictionaries.length > 0" class="dictionary-list">
<div v-for="item in filteredDictionaries" :key="item.id"
:class="['dictionary-item', { 'active': selectedDictionaryId === item.id }]"
@click="handleSelectDictionary(item)">
<div
v-if="filteredDictionaries.length > 0"
class="dictionary-list"
>
<div
v-for="item in filteredDictionaries"
:key="item.id"
:class="[
'dictionary-item',
{ active: selectedDictionaryId === item.id },
]"
@click="handleSelectDictionary(item)"
>
<div class="item-main">
<div class="item-name">{{ item.name }}</div>
<div class="item-code">{{ item.code }}</div>
</div>
<div class="item-meta">
<a-tag :color="item.status ? 'success' : 'default'" size="small">
{{ item.status ? '启用' : '禁用' }}
<a-tag
:color="item.status ? 'success' : 'default'"
size="small"
>
{{ item.status ? "启用" : "禁用" }}
</a-tag>
<span class="item-count">{{ item.items_count || 0 }} </span>
<span class="item-count"
>{{ item.items_count || 0 }} </span
>
</div>
<div class="item-actions" @click.stop>
<a-dropdown>
@@ -36,10 +60,17 @@
</a-button>
<template #overlay>
<a-menu>
<a-menu-item @click="handleEditDictionary(item)">
<a-menu-item
@click="handleEditDictionary(item)"
>
<EditOutlined />编辑
</a-menu-item>
<a-menu-item @click="handleDeleteDictionary(item)" danger>
<a-menu-item
@click="
handleDeleteDictionary(item)
"
danger
>
<DeleteOutlined />删除
</a-menu-item>
</a-menu>
@@ -50,7 +81,11 @@
</div>
<!-- 空状态 -->
<a-empty v-else-if="!dictionaryLoading" description="暂无字典类型" :image-size="80">
<a-empty
v-else-if="!dictionaryLoading"
description="暂无字典类型"
:image-size="80"
>
<a-button type="primary" @click="handleAddDictionary">
创建第一个字典
</a-button>
@@ -64,11 +99,30 @@
<div class="tool-bar">
<div class="left-panel">
<a-space>
<a-input v-model:value="searchForm.label" placeholder="标签名称" allow-clear style="width: 140px" />
<a-input v-model:value="searchForm.value" placeholder="数据值" allow-clear style="width: 140px" />
<a-select v-model:value="searchForm.status" placeholder="状态" allow-clear style="width: 100px">
<a-select-option :value="true">启用</a-select-option>
<a-select-option :value="false">禁用</a-select-option>
<a-input
v-model:value="searchForm.label"
placeholder="标签名称"
allow-clear
style="width: 140px"
/>
<a-input
v-model:value="searchForm.value"
placeholder="数据值"
allow-clear
style="width: 140px"
/>
<a-select
v-model:value="searchForm.status"
placeholder="状态"
allow-clear
style="width: 100px"
>
<a-select-option :value="true"
>启用</a-select-option
>
<a-select-option :value="false"
>禁用</a-select-option
>
</a-select>
<a-button type="primary" @click="handleItemSearch">
<template #icon><SearchOutlined /></template>
@@ -99,7 +153,11 @@
</a-menu>
</template>
</a-dropdown>
<a-button type="primary" :disabled="!selectedDictionaryId" @click="handleAddItem">
<a-button
type="primary"
:disabled="!selectedDictionaryId"
@click="handleAddItem"
>
<template #icon><PlusOutlined /></template>
新增
</a-button>
@@ -110,15 +168,33 @@
<div class="table-content">
<!-- 空状态未选择字典 -->
<div v-if="!selectedDictionaryId" class="empty-state">
<a-empty description="请选择左侧字典类型后操作" :image-size="120" />
<a-empty
description="请选择左侧字典类型后操作"
:image-size="120"
/>
</div>
<!-- 字典项表格 -->
<scTable v-else ref="tableRef" :columns="columns" :data-source="tableData" :loading="loading"
:pagination="pagination" :row-key="rowKey" :row-selection="rowSelection" @refresh="refreshTable"
@paginationChange="handlePaginationChange" @select="handleSelectChange" @selectAll="handleSelectAll">
<scTable
v-else
ref="tableRef"
:columns="columns"
:data-source="tableData"
:loading="loading"
:pagination="pagination"
:row-key="rowKey"
:row-selection="rowSelection"
@refresh="refreshTable"
@paginationChange="handlePaginationChange"
@select="handleSelectChange"
@selectAll="handleSelectAll"
>
<template #color="{ record }">
<span v-if="record.color" class="color-cell" :style="{ backgroundColor: record.color }"></span>
<span
v-if="record.color"
class="color-cell"
:style="{ backgroundColor: record.color }"
></span>
<span v-else>-</span>
</template>
@@ -131,17 +207,26 @@
<template #status="{ record }">
<a-tag :color="record.status ? 'success' : 'error'">
{{ record.status ? '启用' : '禁用' }}
{{ record.status ? "启用" : "禁用" }}
</a-tag>
</template>
<template #action="{ record }">
<a-space>
<a-button type="link" size="small" @click="handleEditItem(record)">
<a-button
type="link"
size="small"
@click="handleEditItem(record)"
>
编辑
</a-button>
<a-popconfirm title="确定删除该字典项吗?" @confirm="handleDeleteItem(record)">
<a-button type="link" size="small" danger>删除</a-button>
<a-popconfirm
title="确定删除该字典项吗?"
@confirm="handleDeleteItem(record)"
>
<a-button type="link" size="small" danger
>删除</a-button
>
</a-popconfirm>
</a-space>
</template>
@@ -151,17 +236,28 @@
</div>
<!-- 字典类型弹窗 -->
<DictionaryDialog v-if="dialog.dictionary" v-model:visible="dialog.dictionary" :record="currentDictionary"
:dictionary-list="dictionaryList" @success="handleDictionarySuccess" />
<DictionaryDialog
v-if="dialog.dictionary"
v-model:visible="dialog.dictionary"
:record="currentDictionary"
:dictionary-list="dictionaryList"
@success="handleDictionarySuccess"
/>
<!-- 字典项弹窗 -->
<ItemDialog v-if="dialog.item" v-model:visible="dialog.item" :record="currentItem"
:dictionary-id="selectedDictionaryId" :item-list="tableData" @success="handleItemSuccess" />
<ItemDialog
v-if="dialog.item"
v-model:visible="dialog.item"
:record="currentItem"
:dictionary-id="selectedDictionaryId"
:item-list="tableData"
@success="handleItemSuccess"
/>
</template>
<script setup>
import { ref, reactive, onMounted, h } from 'vue'
import { message, Modal } from 'ant-design-vue'
import { ref, reactive, onMounted, h } from "vue";
import { message, Modal } from "ant-design-vue";
import {
SearchOutlined,
RedoOutlined,
@@ -171,22 +267,22 @@ import {
MoreOutlined,
CheckCircleOutlined,
StarOutlined,
ExclamationCircleOutlined
} from '@ant-design/icons-vue'
import { useTable } from '@/hooks/useTable'
import systemApi from '@/api/system'
import scTable from '@/components/scTable/index.vue'
import DictionaryDialog from './components/DictionaryDialog.vue'
import ItemDialog from './components/ItemDialog.vue'
import { useDictionaryStore } from '@/stores/modules/dictionary'
ExclamationCircleOutlined,
} from "@ant-design/icons-vue";
import { useTable } from "@/hooks/useTable";
import systemApi from "@/api/system";
import scTable from "@/components/scTable/index.vue";
import DictionaryDialog from "./components/DictionaryDialog.vue";
import ItemDialog from "./components/ItemDialog.vue";
import { useDictionaryStore } from "@/stores/modules/dictionary";
// ===== 字典列表相关 =====
const dictionaryList = ref([])
const filteredDictionaries = ref([])
const selectedDictionary = ref(null)
const selectedDictionaryId = ref(null)
const dictionaryKeyword = ref('')
const dictionaryLoading = ref(false)
const dictionaryList = ref([]);
const filteredDictionaries = ref([]);
const selectedDictionary = ref(null);
const selectedDictionaryId = ref(null);
const dictionaryKeyword = ref("");
const dictionaryLoading = ref(false);
// ===== 字典项相关(使用 useTable Hook=====
const {
@@ -202,308 +298,367 @@ const {
handlePaginationChange,
handleSelectChange,
handleSelectAll,
refreshTable
refreshTable,
} = useTable({
api: systemApi.dictionaryItems.list.get,
searchForm: {
dictionary_id: null,
label: '',
value: '',
status: undefined
label: "",
value: "",
status: undefined,
},
columns: [],
needPagination: true,
needSelection: true,
immediateLoad: false // 不自动加载,等待选择字典
})
immediateLoad: false, // 不自动加载,等待选择字典
});
// 表格列配置
const columns = [
{ title: 'ID', dataIndex: 'id', key: 'id', width: 80, align: 'center' },
{ title: '标签名称', dataIndex: 'label', key: 'label', width: 150, ellipsis: true },
{ title: '数据值', dataIndex: 'value', key: 'value', width: 120, ellipsis: true },
{ title: '颜色', dataIndex: 'color', key: 'color', width: 100, align: 'center', slot: 'color' },
{ title: '默认项', dataIndex: 'is_default', key: 'is_default', width: 100, align: 'center', slot: 'is_default' },
{ title: '排序', dataIndex: 'sort', key: 'sort', width: 80, align: 'center' },
{ title: '状态', dataIndex: 'status', key: 'status', width: 100, align: 'center', slot: 'status' },
{ title: '描述', dataIndex: 'description', key: 'description', ellipsis: true },
{ title: '操作', dataIndex: 'action', key: 'action', width: 150, align: 'center', fixed: 'right', slot: 'action' }
]
{ title: "ID", dataIndex: "id", key: "id", width: 80, align: "center" },
{
title: "标签名称",
dataIndex: "label",
key: "label",
width: 150,
ellipsis: true,
},
{
title: "数据值",
dataIndex: "value",
key: "value",
width: 120,
ellipsis: true,
},
{
title: "颜色",
dataIndex: "color",
key: "color",
width: 100,
align: "center",
slot: "color",
},
{
title: "默认项",
dataIndex: "is_default",
key: "is_default",
width: 100,
align: "center",
slot: "is_default",
},
{
title: "排序",
dataIndex: "sort",
key: "sort",
width: 80,
align: "center",
},
{
title: "状态",
dataIndex: "status",
key: "status",
width: 100,
align: "center",
slot: "status",
},
{
title: "描述",
dataIndex: "description",
key: "description",
ellipsis: true,
},
{
title: "操作",
dataIndex: "action",
key: "action",
width: 150,
align: "center",
fixed: "right",
slot: "action",
},
];
const rowKey = 'id'
const rowKey = "id";
// ===== 弹窗状态 =====
const dialog = reactive({
dictionary: false,
item: false
})
item: false,
});
// ===== 当前操作的数据 =====
const currentDictionary = ref(null)
const currentItem = ref(null)
const currentDictionary = ref(null);
const currentItem = ref(null);
// ===== 方法:加载字典列表 =====
const loadDictionaryList = async () => {
try {
dictionaryLoading.value = true
const res = await systemApi.dictionaries.all.get()
dictionaryLoading.value = true;
const res = await systemApi.dictionaries.all.get();
if (res.code === 200) {
dictionaryList.value = res.data || []
filteredDictionaries.value = res.data || []
dictionaryList.value = res.data || [];
filteredDictionaries.value = res.data || [];
// 建立字典ID到Code的映射
dictionaryStore.buildIdToCodeMap(res.data || [])
dictionaryStore.buildIdToCodeMap(res.data || []);
} else {
message.error(res.message || '加载字典列表失败')
message.error(res.message || "加载字典列表失败");
}
} catch (error) {
console.error('加载字典列表失败:', error)
message.error('加载字典列表失败')
console.error("加载字典列表失败:", error);
message.error("加载字典列表失败");
} finally {
dictionaryLoading.value = false
dictionaryLoading.value = false;
}
}
};
// ===== 方法:搜索过滤字典 =====
const handleDictionarySearch = (e) => {
const keyword = e.target?.value || ''
dictionaryKeyword.value = keyword
const keyword = e.target?.value || "";
dictionaryKeyword.value = keyword;
if (!keyword) {
filteredDictionaries.value = dictionaryList.value
return
filteredDictionaries.value = dictionaryList.value;
return;
}
// 过滤字典列表(支持搜索名称和编码)
filteredDictionaries.value = dictionaryList.value.filter(dict => {
return dict.name.toLowerCase().includes(keyword.toLowerCase()) ||
filteredDictionaries.value = dictionaryList.value.filter((dict) => {
return (
dict.name.toLowerCase().includes(keyword.toLowerCase()) ||
dict.code.toLowerCase().includes(keyword.toLowerCase())
})
}
);
});
};
// ===== 方法:选择字典 =====
const handleSelectDictionary = (dictionary) => {
selectedDictionary.value = dictionary
selectedDictionaryId.value = dictionary.id
selectedDictionary.value = dictionary;
selectedDictionaryId.value = dictionary.id;
// 重置右侧搜索条件
searchForm.label = ''
searchForm.value = ''
searchForm.status = undefined
searchForm.label = "";
searchForm.value = "";
searchForm.status = undefined;
// 更新 dictionary_id
searchForm.dictionary_id = dictionary.id
searchForm.dictionary_id = dictionary.id;
// 加载字典项列表
handleItemSearch()
}
handleItemSearch();
};
// ===== 方法:字典项搜索 =====
const handleItemSearch = () => {
if (!selectedDictionaryId.value) {
message.warning('请先选择字典类型')
return
message.warning("请先选择字典类型");
return;
}
searchForm.dictionary_id = selectedDictionaryId.value
handleSearch()
}
searchForm.dictionary_id = selectedDictionaryId.value;
handleSearch();
};
// ===== 方法:字典项重置 =====
const handleItemReset = () => {
searchForm.label = ''
searchForm.value = ''
searchForm.status = undefined
searchForm.dictionary_id = selectedDictionaryId.value
handleSearch()
}
searchForm.label = "";
searchForm.value = "";
searchForm.status = undefined;
searchForm.dictionary_id = selectedDictionaryId.value;
handleSearch();
};
// ===== 方法:新增字典 =====
const handleAddDictionary = () => {
currentDictionary.value = null
dialog.dictionary = true
}
currentDictionary.value = null;
dialog.dictionary = true;
};
// ===== 方法:编辑字典 =====
const handleEditDictionary = (dictionary) => {
currentDictionary.value = { ...dictionary }
dialog.dictionary = true
}
currentDictionary.value = { ...dictionary };
dialog.dictionary = true;
};
// ===== 方法:删除字典 =====
const handleDeleteDictionary = (dictionary) => {
const itemCount = dictionary.items_count || 0
const itemCount = dictionary.items_count || 0;
Modal.confirm({
title: '确认删除',
content: itemCount > 0
? `确定删除字典类型"${dictionary.name}"吗?删除后该字典下的 ${itemCount} 个字典项也会被删除!`
: `确定删除字典类型"${dictionary.name}"吗?`,
okText: '删除',
okType: 'danger',
cancelText: '取消',
title: "确认删除",
content:
itemCount > 0
? `确定删除字典类型"${dictionary.name}"吗?删除后该字典下的 ${itemCount} 个字典项也会被删除!`
: `确定删除字典类型"${dictionary.name}"吗?`,
okText: "删除",
okType: "danger",
cancelText: "取消",
icon: h(ExclamationCircleOutlined),
onOk: async () => {
try {
const res = await systemApi.dictionaries.delete.delete(dictionary.id)
const res = await systemApi.dictionaries.delete.delete(
dictionary.id,
);
if (res.code === 200) {
message.success('删除成功')
message.success("删除成功");
// 如果删除的是当前选中的字典,清空右侧
if (selectedDictionaryId.value === dictionary.id) {
selectedDictionary.value = null
selectedDictionaryId.value = null
tableData.value = []
selectedDictionary.value = null;
selectedDictionaryId.value = null;
tableData.value = [];
}
// 刷新字典列表
await loadDictionaryList()
await loadDictionaryList();
} else {
message.error(res.message || '删除失败')
message.error(res.message || "删除失败");
}
} catch (error) {
console.error('删除字典失败:', error)
message.error('删除失败')
console.error("删除字典失败:", error);
message.error("删除失败");
}
}
})
}
},
});
};
// ===== 方法:新增字典项 =====
const handleAddItem = () => {
if (!selectedDictionaryId.value) {
message.warning('请先选择字典类型')
return
message.warning("请先选择字典类型");
return;
}
currentItem.value = null
dialog.item = true
}
currentItem.value = null;
dialog.item = true;
};
// ===== 方法:编辑字典项 =====
const handleEditItem = (record) => {
currentItem.value = { ...record }
dialog.item = true
}
currentItem.value = { ...record };
dialog.item = true;
};
// ===== 方法:删除字典项 =====
const handleDeleteItem = async (record) => {
try {
const res = await systemApi.dictionaryItems.delete.delete(record.id)
const res = await systemApi.dictionaryItems.delete.delete(record.id);
if (res.code === 200) {
message.success('删除成功')
message.success("删除成功");
// 清除字典缓存
dictionaryStore.clearDictionary(selectedDictionaryId.value)
refreshTable()
dictionaryStore.clearDictionary(selectedDictionaryId.value);
refreshTable();
// 刷新字典列表以更新项数量
loadDictionaryList()
loadDictionaryList();
} else {
message.error(res.message || '删除失败')
message.error(res.message || "删除失败");
}
} catch (error) {
console.error('删除字典项失败:', error)
message.error('删除失败')
console.error("删除字典项失败:", error);
message.error("删除失败");
}
}
};
// ===== 方法:批量删除字典项 =====
const handleBatchDelete = () => {
if (selectedRows.value.length === 0) {
message.warning('请选择要删除的字典项')
return
message.warning("请选择要删除的字典项");
return;
}
Modal.confirm({
title: '确认删除',
title: "确认删除",
content: `确定删除选中的 ${selectedRows.value.length} 个字典项吗?`,
okText: '删除',
okType: 'danger',
cancelText: '取消',
okText: "删除",
okType: "danger",
cancelText: "取消",
icon: h(ExclamationCircleOutlined),
onOk: async () => {
try {
const ids = selectedRows.value.map(item => item.id)
const res = await systemApi.dictionaryItems.batchDelete.post({ ids })
const ids = selectedRows.value.map((item) => item.id);
const res = await systemApi.dictionaryItems.batchDelete.post({
ids,
});
if (res.code === 200) {
message.success('删除成功')
message.success("删除成功");
// 清除字典缓存
dictionaryStore.clearDictionary(selectedDictionaryId.value)
selectedRows.value = []
refreshTable()
loadDictionaryList()
dictionaryStore.clearDictionary(selectedDictionaryId.value);
selectedRows.value = [];
refreshTable();
loadDictionaryList();
} else {
message.error(res.message || '删除失败')
message.error(res.message || "删除失败");
}
} catch (error) {
console.error('批量删除失败:', error)
message.error('删除失败')
console.error("批量删除失败:", error);
message.error("删除失败");
}
}
})
}
},
});
};
// ===== 方法:批量启用/禁用字典项 =====
const handleBatchStatus = () => {
if (selectedRows.value.length === 0) {
message.warning('请选择要操作的字典项')
return
message.warning("请选择要操作的字典项");
return;
}
const newStatus = selectedRows.value[0].status ? false : true
const statusText = newStatus === 1 ? '启用' : '禁用'
const newStatus = selectedRows.value[0].status ? false : true;
const statusText = newStatus === 1 ? "启用" : "禁用";
Modal.confirm({
title: `确认${statusText}`,
content: `确定要${statusText}选中的 ${selectedRows.value.length} 个字典项吗?`,
okText: '确定',
cancelText: '取消',
okText: "确定",
cancelText: "取消",
onOk: async () => {
try {
const ids = selectedRows.value.map(item => item.id)
const ids = selectedRows.value.map((item) => item.id);
const res = await systemApi.dictionaryItems.batchStatus.post({
ids,
status: newStatus
})
status: newStatus,
});
if (res.code === 200) {
message.success(`${statusText}成功`)
message.success(`${statusText}成功`);
// 清除字典缓存
dictionaryStore.clearDictionary(selectedDictionaryId.value)
selectedRows.value = []
refreshTable()
dictionaryStore.clearDictionary(selectedDictionaryId.value);
selectedRows.value = [];
refreshTable();
} else {
message.error(res.message || '操作失败')
message.error(res.message || "操作失败");
}
} catch (error) {
console.error('批量操作失败:', error)
message.error('操作失败')
console.error("批量操作失败:", error);
message.error("操作失败");
}
}
})
}
},
});
};
// 初始化字典 store
const dictionaryStore = useDictionaryStore()
const dictionaryStore = useDictionaryStore();
// ===== 方法:字典操作成功回调 =====
const handleDictionarySuccess = () => {
dialog.dictionary = false
dialog.dictionary = false;
// 清理字典缓存
dictionaryStore.clearCache()
loadDictionaryList()
}
dictionaryStore.clearCache();
loadDictionaryList();
};
// ===== 方法:字典项操作成功回调 =====
const handleItemSuccess = () => {
dialog.item = false
dialog.item = false;
// 清理字典缓存
dictionaryStore.clearDictionary(selectedDictionaryId.value)
refreshTable()
loadDictionaryList()
}
dictionaryStore.clearDictionary(selectedDictionaryId.value);
refreshTable();
loadDictionaryList();
};
// ===== 生命周期 =====
onMounted(() => {
// 加载字典列表
loadDictionaryList()
})
loadDictionaryList();
});
</script>
<style scoped lang="scss">
@@ -557,7 +712,7 @@ onMounted(() => {
.item-code {
font-size: 12px;
color: #8c8c8c;
font-family: 'Consolas', 'Monaco', monospace;
font-family: "Consolas", "Monaco", monospace;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -3,7 +3,12 @@
<div class="tool-bar">
<div class="left-panel">
<a-space>
<a-input v-model:value="searchForm.keyword" placeholder="通知标题/内容" allow-clear style="width: 180px" />
<a-input
v-model:value="searchForm.keyword"
placeholder="通知标题/内容"
allow-clear
style="width: 180px"
/>
<a-select
v-model:value="searchForm.is_read"
placeholder="阅读状态"
@@ -39,7 +44,11 @@
</div>
<div class="right-panel">
<a-space>
<a-badge :count="unreadCount" :offset="[-5, 5]" :number-style="{ backgroundColor: '#f5222d' }">
<a-badge
:count="unreadCount"
:offset="[-5, 5]"
:number-style="{ backgroundColor: '#f5222d' }"
>
<a-button @click="handleMarkAllRead">
<template #icon><check-outlined /></template>
全部已读
@@ -85,29 +94,48 @@
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'title'">
<div class="notification-title" :class="{ unread: !record.is_read }" @click="handleViewDetail(record)">
<WarningOutlined v-if="!record.is_read" class="unread-icon" />
<div
class="notification-title"
:class="{ unread: !record.is_read }"
@click="handleViewDetail(record)"
>
<WarningOutlined
v-if="!record.is_read"
class="unread-icon"
/>
<span>{{ record.title }}</span>
</div>
</template>
<template v-if="column.key === 'type'">
<a-tag :color="getTypeColor(record.type)">
<component :is="getTypeIcon(record.type)" class="type-icon" />
<component
:is="getTypeIcon(record.type)"
class="type-icon"
/>
{{ getTypeText(record.type) }}
</a-tag>
</template>
<template v-if="column.key === 'category'">
<a-tag color="blue">{{ getCategoryText(record.category) }}</a-tag>
<a-tag color="blue">{{
getCategoryText(record.category)
}}</a-tag>
</template>
<template v-if="column.key === 'is_read'">
<a-badge :status="record.is_read ? 'default' : 'processing'" :text="record.is_read ? '已读' : '未读'" />
<a-badge
:status="record.is_read ? 'default' : 'processing'"
:text="record.is_read ? '已读' : '未读'"
/>
</template>
<template v-if="column.key === 'created_at'">
<span>{{ formatTime(record.created_at) }}</span>
</template>
<template v-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="handleViewDetail(record)">
<a-button
type="link"
size="small"
@click="handleViewDetail(record)"
>
<eye-outlined />
查看
</a-button>
@@ -120,7 +148,12 @@
<check-outlined />
标为已读
</a-button>
<a-button type="link" size="small" danger @click="handleDelete(record)">
<a-button
type="link"
size="small"
danger
@click="handleDelete(record)"
>
<delete-outlined />
删除
</a-button>
@@ -131,7 +164,12 @@
</div>
<!-- 通知详情弹窗 -->
<a-drawer v-model:open="showDetailDrawer" title="通知详情" placement="right" width="600">
<a-drawer
v-model:open="showDetailDrawer"
title="通知详情"
placement="right"
width="600"
>
<template v-if="currentNotification">
<a-descriptions :column="1" bordered>
<a-descriptions-item label="标题">
@@ -139,38 +177,68 @@
</a-descriptions-item>
<a-descriptions-item label="类型">
<a-tag :color="getTypeColor(currentNotification.type)">
<component :is="getTypeIcon(currentNotification.type)" class="type-icon" />
<component
:is="getTypeIcon(currentNotification.type)"
class="type-icon"
/>
{{ getTypeText(currentNotification.type) }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="分类">
<a-tag color="blue">{{ getCategoryText(currentNotification.category) }}</a-tag>
<a-tag color="blue">{{
getCategoryText(currentNotification.category)
}}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="状态">
<a-badge
:status="currentNotification.is_read ? 'default' : 'processing'"
:text="currentNotification.is_read ? '已读' : '未读'"
:status="
currentNotification.is_read
? 'default'
: 'processing'
"
:text="
currentNotification.is_read ? '已读' : '未读'
"
/>
</a-descriptions-item>
<a-descriptions-item label="创建时间">
{{ formatTime(currentNotification.created_at) }}
</a-descriptions-item>
<a-descriptions-item v-if="currentNotification.read_at" label="阅读时间">
<a-descriptions-item
v-if="currentNotification.read_at"
label="阅读时间"
>
{{ formatTime(currentNotification.read_at) }}
</a-descriptions-item>
</a-descriptions>
<div class="notification-content">
<div class="content-label">通知内容:</div>
<div class="content-text">{{ currentNotification.content }}</div>
<div class="content-text">
{{ currentNotification.content }}
</div>
</div>
<div v-if="currentNotification.data && Object.keys(currentNotification.data).length > 0" class="notification-data">
<div
v-if="
currentNotification.data &&
Object.keys(currentNotification.data).length > 0
"
class="notification-data"
>
<div class="data-label">附加数据:</div>
<pre class="data-text">{{ JSON.stringify(currentNotification.data, null, 2) }}</pre>
<pre class="data-text">{{
JSON.stringify(currentNotification.data, null, 2)
}}</pre>
</div>
<div v-if="currentNotification.action_type && currentNotification.action_type !== 'none'" class="notification-action">
<div
v-if="
currentNotification.action_type &&
currentNotification.action_type !== 'none'
"
class="notification-action"
>
<a-button type="primary" @click="handleAction">
<template #icon><arrow-right-outlined /></template>
{{ getActionText(currentNotification.action_type) }}
@@ -182,8 +250,8 @@
</template>
<script setup>
import { ref, reactive, onMounted, onUnmounted, computed, h } from 'vue'
import { message, Modal } from 'ant-design-vue'
import { ref, reactive, onMounted, onUnmounted, computed, h } from "vue";
import { message, Modal } from "ant-design-vue";
import {
SearchOutlined,
RedoOutlined,
@@ -200,120 +268,127 @@ import {
MessageOutlined,
ClockCircleOutlined,
BulbOutlined,
ArrowRightOutlined
} from '@ant-design/icons-vue'
import scTable from '@/components/scTable/index.vue'
import { useTable } from '@/hooks/useTable'
import systemApi from '@/api/system'
import { useWebSocket } from '@/composables/useWebSocket'
ArrowRightOutlined,
} from "@ant-design/icons-vue";
import scTable from "@/components/scTable/index.vue";
import { useTable } from "@/hooks/useTable";
import systemApi from "@/api/system";
// 表格引用
const tableRef = ref(null)
const tableRef = ref(null);
// WebSocket
const ws = useWebSocket()
let unreadCountInterval = null
// 注意:WebSocket 已在 App.vue 中统一初始化,这里不需要重复连接
// 通知会通过 notification store 自动更新
let unreadCountInterval = null;
// 搜索表单
const searchForm = reactive({
keyword: '',
keyword: "",
is_read: undefined,
type: undefined,
category: undefined
})
category: undefined,
});
// 未读数量
const unreadCount = ref(0)
const unreadCount = ref(0);
// 当前通知
const currentNotification = ref(null)
const currentNotification = ref(null);
// 显示详情抽屉
const showDetailDrawer = ref(false)
const showDetailDrawer = ref(false);
// 通知类型选项
const typeOptions = [
{ label: '信息', value: 'info' },
{ label: '成功', value: 'success' },
{ label: '警告', value: 'warning' },
{ label: '错误', value: 'error' },
{ label: '任务', value: 'task' },
{ label: '系统', value: 'system' }
]
{ label: "信息", value: "info" },
{ label: "成功", value: "success" },
{ label: "警告", value: "warning" },
{ label: "错误", value: "error" },
{ label: "任务", value: "task" },
{ label: "系统", value: "system" },
];
// 通知分类选项
const categoryOptions = [
{ label: '系统通知', value: 'system' },
{ label: '任务通知', value: 'task' },
{ label: '消息通知', value: 'message' },
{ label: '提醒通知', value: 'reminder' },
{ label: '公告通知', value: 'announcement' }
]
{ label: "系统通知", value: "system" },
{ label: "任务通知", value: "task" },
{ label: "消息通知", value: "message" },
{ label: "提醒通知", value: "reminder" },
{ label: "公告通知", value: "announcement" },
];
// 使用 useTable Hook
const { tableData, loading, pagination, rowSelection, handleSearch, handleReset, handlePaginationChange, refreshTable } =
useTable({
api: systemApi.notifications.list.get,
searchForm,
needPagination: true
})
const {
tableData,
loading,
pagination,
rowSelection,
handleSearch,
handleReset,
handlePaginationChange,
refreshTable,
} = useTable({
api: systemApi.notifications.list.get,
searchForm,
needPagination: true,
});
// 表格列配置
const columns = [
{
title: '通知标题',
dataIndex: 'title',
key: 'title',
title: "通知标题",
dataIndex: "title",
key: "title",
width: 300,
ellipsis: true
ellipsis: true,
},
{
title: '类型',
dataIndex: 'type',
key: 'type',
title: "类型",
dataIndex: "type",
key: "type",
width: 120,
align: 'center'
align: "center",
},
{
title: '分类',
dataIndex: 'category',
key: 'category',
title: "分类",
dataIndex: "category",
key: "category",
width: 120,
align: 'center'
align: "center",
},
{
title: '状态',
dataIndex: 'is_read',
key: 'is_read',
title: "状态",
dataIndex: "is_read",
key: "is_read",
width: 100,
align: 'center'
align: "center",
},
{
title: '创建时间',
dataIndex: 'created_at',
key: 'created_at',
width: 180
},
{
title: '操作',
key: 'action',
title: "创建时间",
dataIndex: "created_at",
key: "created_at",
width: 180,
fixed: 'right'
}
]
},
{
title: "操作",
key: "action",
width: 180,
fixed: "right",
},
];
// 获取类型颜色
const getTypeColor = (type) => {
const colors = {
info: 'blue',
success: 'green',
warning: 'orange',
error: 'red',
task: 'purple',
system: 'cyan'
}
return colors[type] || 'default'
}
info: "blue",
success: "green",
warning: "orange",
error: "red",
task: "purple",
system: "cyan",
};
return colors[type] || "default";
};
// 获取类型图标
const getTypeIcon = (type) => {
@@ -323,251 +398,255 @@ const getTypeIcon = (type) => {
warning: ExclamationCircleOutlined,
error: CloseCircleOutlined,
task: BellOutlined,
system: MessageOutlined
}
return icons[type] || InfoCircleOutlined
}
system: MessageOutlined,
};
return icons[type] || InfoCircleOutlined;
};
// 获取类型文本
const getTypeText = (type) => {
const texts = {
info: '信息',
success: '成功',
warning: '警告',
error: '错误',
task: '任务',
system: '系统'
}
return texts[type] || type
}
info: "信息",
success: "成功",
warning: "警告",
error: "错误",
task: "任务",
system: "系统",
};
return texts[type] || type;
};
// 获取分类文本
const getCategoryText = (category) => {
const texts = {
system: '系统通知',
task: '任务通知',
message: '消息通知',
reminder: '提醒通知',
announcement: '公告通知'
}
return texts[category] || category
}
system: "系统通知",
task: "任务通知",
message: "消息通知",
reminder: "提醒通知",
announcement: "公告通知",
};
return texts[category] || category;
};
// 获取操作文本
const getActionText = (actionType) => {
const texts = {
link: '查看详情',
modal: '打开弹窗',
none: ''
}
return texts[actionType] || '查看'
}
link: "查看详情",
modal: "打开弹窗",
none: "",
};
return texts[actionType] || "查看";
};
// 格式化时间
const formatTime = (time) => {
if (!time) return '-'
return new Date(time).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
}
if (!time) return "-";
return new Date(time).toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
};
// 获取未读数量
const loadUnreadCount = async () => {
try {
const res = await systemApi.notifications.unreadCount.get()
unreadCount.value = res.data.count
const res = await systemApi.notifications.unreadCount.get();
unreadCount.value = res.data.count;
} catch (error) {
console.error('获取未读数量失败:', error)
console.error("获取未读数量失败:", error);
}
}
};
// 查看详情
const handleViewDetail = async (record) => {
currentNotification.value = { ...record }
showDetailDrawer.value = true
currentNotification.value = { ...record };
showDetailDrawer.value = true;
// 自动标记为已读
if (!record.is_read) {
await handleMarkRead(record)
await handleMarkRead(record);
}
}
};
// 标记已读
const handleMarkRead = async (record) => {
try {
await systemApi.notifications.markAsRead.post(record.id)
message.success('已标记为已读')
await systemApi.notifications.markAsRead.post(record.id);
message.success("已标记为已读");
if (!record.is_read) {
unreadCount.value = Math.max(0, unreadCount.value - 1)
unreadCount.value = Math.max(0, unreadCount.value - 1);
}
refreshTable()
refreshTable();
} catch (error) {
message.error(error.message || '操作失败')
message.error(error.message || "操作失败");
}
}
};
// 批量标记已读
const handleBatchMarkRead = () => {
const selectedRowKeys = rowSelection.selectedRowKeys
const selectedRowKeys = rowSelection.selectedRowKeys;
if (selectedRowKeys.length === 0) {
message.warning('请先选择要操作的通知')
return
message.warning("请先选择要操作的通知");
return;
}
Modal.confirm({
title: '确认标记为已读',
title: "确认标记为已读",
content: `确定要将选中的 ${selectedRowKeys.length} 条通知标记为已读吗?`,
okText: '确定',
cancelText: '取消',
okText: "确定",
cancelText: "取消",
onOk: async () => {
try {
const res = await systemApi.notifications.batchMarkAsRead.post({ ids: selectedRowKeys })
message.success('批量标记成功')
rowSelection.selectedRowKeys = []
unreadCount.value = Math.max(0, unreadCount.value - res.data.count)
refreshTable()
const res = await systemApi.notifications.batchMarkAsRead.post({
ids: selectedRowKeys,
});
message.success("批量标记成功");
rowSelection.selectedRowKeys = [];
unreadCount.value = Math.max(
0,
unreadCount.value - res.data.count,
);
refreshTable();
} catch (error) {
message.error(error.message || '操作失败')
message.error(error.message || "操作失败");
}
}
})
}
},
});
};
// 标记全部已读
const handleMarkAllRead = () => {
Modal.confirm({
title: '确认全部已读',
content: '确定要将所有未读通知标记为已读吗',
okText: '确定',
cancelText: '取消',
title: "确认全部已读",
content: "确定要将所有未读通知标记为已读吗",
okText: "确定",
cancelText: "取消",
onOk: async () => {
try {
const res = await systemApi.notifications.markAllAsRead.post()
message.success('已标记全部为已读')
unreadCount.value = 0
refreshTable()
const res = await systemApi.notifications.markAllAsRead.post();
message.success("已标记全部为已读");
unreadCount.value = 0;
refreshTable();
} catch (error) {
message.error(error.message || '操作失败')
message.error(error.message || "操作失败");
}
}
})
}
},
});
};
// 删除
const handleDelete = (record) => {
Modal.confirm({
title: '确认删除',
title: "确认删除",
content: `确定要删除通知"${record.title}"吗?`,
okText: '确定',
cancelText: '取消',
okText: "确定",
cancelText: "取消",
onOk: async () => {
try {
await systemApi.notifications.delete.delete(record.id)
message.success('删除成功')
await systemApi.notifications.delete.delete(record.id);
message.success("删除成功");
if (!record.is_read) {
unreadCount.value = Math.max(0, unreadCount.value - 1)
unreadCount.value = Math.max(0, unreadCount.value - 1);
}
refreshTable()
refreshTable();
} catch (error) {
message.error(error.message || '删除失败')
message.error(error.message || "删除失败");
}
}
})
}
},
});
};
// 批量删除
const handleBatchDelete = () => {
const selectedRowKeys = rowSelection.selectedRowKeys
const selectedRowKeys = rowSelection.selectedRowKeys;
if (selectedRowKeys.length === 0) {
message.warning('请先选择要删除的通知')
return
message.warning("请先选择要删除的通知");
return;
}
Modal.confirm({
title: '确认删除',
title: "确认删除",
content: `确定要删除选中的 ${selectedRowKeys.length} 条通知吗?`,
okText: '确定',
cancelText: '取消',
okText: "确定",
cancelText: "取消",
onOk: async () => {
try {
const res = await systemApi.notifications.batchDelete.post({ ids: selectedRowKeys })
message.success('批量删除成功')
rowSelection.selectedRowKeys = []
loadUnreadCount()
refreshTable()
const res = await systemApi.notifications.batchDelete.post({
ids: selectedRowKeys,
});
message.success("批量删除成功");
rowSelection.selectedRowKeys = [];
loadUnreadCount();
refreshTable();
} catch (error) {
message.error(error.message || '批量删除失败')
message.error(error.message || "批量删除失败");
}
}
})
}
},
});
};
// 清空已读
const handleClearRead = () => {
Modal.confirm({
title: '确认清空',
content: '确定要清空所有已读通知吗此操作不可恢复。',
okText: '确定',
cancelText: '取消',
title: "确认清空",
content: "确定要清空所有已读通知吗此操作不可恢复",
okText: "确定",
cancelText: "取消",
onOk: async () => {
try {
await systemApi.notifications.clearRead.post()
message.success('已清空已读通知')
refreshTable()
await systemApi.notifications.clearRead.post();
message.success("已清空已读通知");
refreshTable();
} catch (error) {
message.error(error.message || '操作失败')
message.error(error.message || "操作失败");
}
}
})
}
},
});
};
// 处理操作
const handleAction = () => {
const notification = currentNotification.value
if (!notification) return
const notification = currentNotification.value;
if (!notification) return;
if (notification.action_type === 'link' && notification.action_data?.url) {
window.open(notification.action_data.url, '_blank')
} else if (notification.action_type === 'modal') {
if (notification.action_type === "link" && notification.action_data?.url) {
window.open(notification.action_data.url, "_blank");
} else if (notification.action_type === "modal") {
// 打开弹窗的逻辑
message.info('打开弹窗功能')
message.info("打开弹窗功能");
}
}
};
// WebSocket 消息处理
const handleWebSocketMessage = (msg) => {
if (msg.type === 'notification') {
if (msg.type === "notification") {
// 收到新通知
const notification = msg.data
message.info(`新通知: ${notification.title}`)
unreadCount.value++
refreshTable()
const notification = msg.data;
message.info(`新通知: ${notification.title}`);
unreadCount.value++;
refreshTable();
}
}
};
// 初始化
onMounted(() => {
loadUnreadCount()
// 连接 WebSocket
ws.connect()
ws.onMessage(handleWebSocketMessage)
loadUnreadCount();
// 定时刷新未读数量(每30秒)
// 注意:通知也会通过 WebSocket 实时推送,这是作为备用刷新机制
unreadCountInterval = setInterval(() => {
loadUnreadCount()
}, 30000)
})
loadUnreadCount();
}, 30000);
});
// 清理
onUnmounted(() => {
if (unreadCountInterval) {
clearInterval(unreadCountInterval)
clearInterval(unreadCountInterval);
}
})
});
</script>
<style scoped lang="scss">
@@ -1,9 +1,19 @@
<template>
<a-modal title="任务详情" :open="visible" :footer="null" @cancel="handleCancel" width="800px">
<a-modal
title="任务详情"
:open="visible"
:footer="null"
@cancel="handleCancel"
width="800px"
>
<a-descriptions bordered :column="2" v-if="task">
<a-descriptions-item label="任务名称">{{ task.name }}</a-descriptions-item>
<a-descriptions-item label="任务名称">{{
task.name
}}</a-descriptions-item>
<a-descriptions-item label="任务类型">
<a-tag :color="getTypeColor(task.type)">{{ getTypeText(task.type) }}</a-tag>
<a-tag :color="getTypeColor(task.type)">{{
getTypeText(task.type)
}}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="命令/类" :span="2">
<code class="command-code">{{ task.command }}</code>
@@ -11,17 +21,19 @@
<a-descriptions-item label="Cron表达式">
<code class="cron-code">{{ task.expression }}</code>
</a-descriptions-item>
<a-descriptions-item label="时区">{{ task.timezone }}</a-descriptions-item>
<a-descriptions-item label="时区">{{
task.timezone
}}</a-descriptions-item>
<a-descriptions-item label="状态" :span="2">
<a-tag :color="task.is_active ? 'success' : 'error'">
{{ task.is_active ? '启用' : '禁用' }}
{{ task.is_active ? "启用" : "禁用" }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="上次运行时间" :span="2">
{{ task.last_run_at ? formatDate(task.last_run_at) : '未运行' }}
{{ task.last_run_at ? formatDate(task.last_run_at) : "未运行" }}
</a-descriptions-item>
<a-descriptions-item label="下次运行时间" :span="2">
{{ task.next_run_at ? formatDate(task.next_run_at) : '-' }}
{{ task.next_run_at ? formatDate(task.next_run_at) : "-" }}
</a-descriptions-item>
<a-descriptions-item label="运行次数">
<a-tag color="success">成功: {{ task.run_count || 0 }}</a-tag>
@@ -32,10 +44,10 @@
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="后台运行" :span="2">
{{ task.run_in_background ? '是' : '否' }}
{{ task.run_in_background ? "是" : "否" }}
</a-descriptions-item>
<a-descriptions-item label="描述" :span="2">
{{ task.description || '-' }}
{{ task.description || "-" }}
</a-descriptions-item>
</a-descriptions>
@@ -59,75 +71,75 @@
</template>
<script setup>
import { ref, computed } from 'vue'
import { message } from 'ant-design-vue'
import { PlayCircleOutlined } from '@ant-design/icons-vue'
import systemApi from '@/api/system'
import { ref, computed } from "vue";
import { message } from "ant-design-vue";
import { PlayCircleOutlined } from "@ant-design/icons-vue";
import systemApi from "@/api/system";
const props = defineProps({
visible: {
type: Boolean,
default: false
default: false,
},
record: {
type: Object,
default: null
}
})
default: null,
},
});
const emit = defineEmits(['update:visible', 'refresh'])
const emit = defineEmits(["update:visible", "refresh"]);
const task = computed(() => props.record)
const task = computed(() => props.record);
// 获取任务类型文本
const getTypeText = (type) => {
const typeMap = {
command: '命令',
job: '任务',
closure: '闭包'
}
return typeMap[type] || type
}
command: "命令",
job: "任务",
closure: "闭包",
};
return typeMap[type] || type;
};
// 获取任务类型颜色
const getTypeColor = (type) => {
const colorMap = {
command: 'blue',
job: 'green',
closure: 'orange'
}
return colorMap[type] || 'default'
}
command: "blue",
job: "green",
closure: "orange",
};
return colorMap[type] || "default";
};
// 格式化日期
const formatDate = (dateStr) => {
if (!dateStr) return '-'
const date = new Date(dateStr)
return date.toLocaleString('zh-CN')
}
if (!dateStr) return "-";
const date = new Date(dateStr);
return date.toLocaleString("zh-CN");
};
// 执行任务
const handleRun = async () => {
if (!props.record) return
if (!props.record) return;
try {
const res = await systemApi.tasks.run.post(props.record.id)
const res = await systemApi.tasks.run.post(props.record.id);
if (res.code === 200) {
message.success('任务执行成功')
emit('refresh')
handleCancel()
message.success("任务执行成功");
emit("refresh");
handleCancel();
} else {
message.error(res.message || '任务执行失败')
message.error(res.message || "任务执行失败");
}
} catch (error) {
message.error('任务执行失败')
message.error("任务执行失败");
}
}
};
// 取消
const handleCancel = () => {
emit('update:visible', false)
}
emit("update:visible", false);
};
</script>
<style scoped lang="scss">
@@ -135,7 +147,7 @@ const handleCancel = () => {
padding: 4px 8px;
background: #f5f5f5;
border-radius: 3px;
font-family: 'Consolas', 'Monaco', monospace;
font-family: "Consolas", "Monaco", monospace;
font-size: 12px;
word-break: break-all;
}
@@ -144,7 +156,7 @@ const handleCancel = () => {
padding: 2px 6px;
background: #f5f5f5;
border-radius: 3px;
font-family: 'Consolas', 'Monaco', monospace;
font-family: "Consolas", "Monaco", monospace;
font-size: 12px;
}
@@ -157,7 +169,7 @@ const handleCancel = () => {
border-radius: 4px;
max-height: 200px;
overflow-y: auto;
font-family: 'Consolas', 'Monaco', monospace;
font-family: "Consolas", "Monaco", monospace;
font-size: 12px;
white-space: pre-wrap;
word-break: break-all;
@@ -1,14 +1,34 @@
<template>
<a-modal :title="title" :open="visible" :confirm-loading="isSaving" :footer="null" @cancel="handleCancel" width="700px">
<a-form ref="formRef" :model="form" :rules="rules" :label-col="{ span: 5 }" :wrapper-col="{ span: 18 }">
<a-modal
:title="title"
:open="visible"
:confirm-loading="isSaving"
:footer="null"
@cancel="handleCancel"
width="700px"
>
<a-form
ref="formRef"
:model="form"
:rules="rules"
:label-col="{ span: 5 }"
:wrapper-col="{ span: 18 }"
>
<!-- 任务名称 -->
<a-form-item label="任务名称" name="name" required>
<a-input v-model:value="form.name" placeholder="如:清理日志" allow-clear />
<a-input
v-model:value="form.name"
placeholder="如:清理日志"
allow-clear
/>
</a-form-item>
<!-- 任务类型 -->
<a-form-item label="任务类型" name="type" required>
<a-select v-model:value="form.type" placeholder="请选择任务类型">
<a-select
v-model:value="form.type"
placeholder="请选择任务类型"
>
<a-select-option value="command">命令</a-select-option>
<a-select-option value="job">任务</a-select-option>
<a-select-option value="closure">闭包</a-select-option>
@@ -17,38 +37,74 @@
<!-- 命令/类名 -->
<a-form-item label="命令/类" name="command" required>
<a-textarea v-if="form.type === 'command'" v-model:value="form.command" placeholder="如:php artisan schedule:run"
:rows="3" />
<a-input v-else v-model:value="form.command" placeholder="如:App\Jobs\SendEmailJob" allow-clear />
<a-textarea
v-if="form.type === 'command'"
v-model:value="form.command"
placeholder="如:php artisan schedule:run"
:rows="3"
/>
<a-input
v-else
v-model:value="form.command"
placeholder="如:App\Jobs\SendEmailJob"
allow-clear
/>
<div class="form-tip">
<span v-if="form.type === 'command'">Shell命令或Artisan命令</span>
<span v-else-if="form.type === 'job'">任务类的完整命名空间</span>
<span v-if="form.type === 'command'"
>Shell命令或Artisan命令</span
>
<span v-else-if="form.type === 'job'"
>任务类的完整命名空间</span
>
<span v-else>闭包函数的代码</span>
</div>
</a-form-item>
<!-- Cron表达式 -->
<a-form-item label="Cron表达式" name="expression" required>
<a-input v-model:value="form.expression" placeholder="* * * * *" allow-clear />
<a-input
v-model:value="form.expression"
placeholder="* * * * *"
allow-clear
/>
<div class="form-tip">
0 0 * * * (每天0点执行)
<a-link href="https://crontab.guru/" target="_blank" style="color: #1890ff">在线生成工具</a-link>
<a-link
href="https://crontab.guru/"
target="_blank"
style="color: #1890ff"
>在线生成工具</a-link
>
</div>
</a-form-item>
<!-- 时区 -->
<a-form-item label="时区" name="timezone">
<a-select v-model:value="form.timezone" placeholder="请选择时区" show-search :filter-option="filterOption">
<a-select-option value="Asia/Shanghai">Asia/Shanghai (中国)</a-select-option>
<a-select
v-model:value="form.timezone"
placeholder="请选择时区"
show-search
:filter-option="filterOption"
>
<a-select-option value="Asia/Shanghai"
>Asia/Shanghai (中国)</a-select-option
>
<a-select-option value="UTC">UTC</a-select-option>
<a-select-option value="America/New_York">America/New_York</a-select-option>
<a-select-option value="America/New_York"
>America/New_York</a-select-option
>
</a-select>
</a-form-item>
<!-- 描述 -->
<a-form-item label="任务描述" name="description">
<a-textarea v-model:value="form.description" placeholder="请输入任务描述" :rows="3" maxlength="200"
show-count />
<a-textarea
v-model:value="form.description"
placeholder="请输入任务描述"
:rows="3"
maxlength="200"
show-count
/>
</a-form-item>
<!-- 高级选项 -->
@@ -71,291 +127,326 @@
<!-- 启用状态 -->
<a-form-item label="启用状态" name="is_active">
<sc-select v-model:value="form.is_active" source-type="dictionary" dictionary-code="yes_no" placeholder="请选择状态" allow-clear />
<sc-select
v-model:value="form.is_active"
source-type="dictionary"
dictionary-code="yes_no"
placeholder="请选择状态"
allow-clear
/>
</a-form-item>
<!-- 排序 -->
<a-form-item label="排序" name="sort">
<a-input-number v-model:value="form.sort" :min="0" :max="10000" style="width: 100%" />
<a-input-number
v-model:value="form.sort"
:min="0"
:max="10000"
style="width: 100%"
/>
</a-form-item>
</a-form>
<!-- 底部按钮 -->
<div class="dialog-footer">
<a-space>
<a-button @click="handleCancel">取消</a-button>
<a-button type="primary" :loading="isSaving" @click="handleSubmit">保存</a-button>
<a-button
type="primary"
:loading="isSaving"
@click="handleSubmit"
>保存</a-button
>
</a-space>
</div>
</a-modal>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { message } from 'ant-design-vue'
import scSelect from '@/components/scSelect/index.vue'
import systemApi from '@/api/system'
import { ref, computed, watch } from "vue";
import { message } from "ant-design-vue";
import scSelect from "@/components/scSelect/index.vue";
import systemApi from "@/api/system";
const props = defineProps({
visible: {
type: Boolean,
default: false
default: false,
},
record: {
type: Object,
default: null
}
})
default: null,
},
});
const emit = defineEmits(['update:visible', 'success'])
const emit = defineEmits(["update:visible", "success"]);
const formRef = ref(null)
const isSaving = ref(false)
const isEdit = computed(() => !!props.record?.id)
const formRef = ref(null);
const isSaving = ref(false);
const isEdit = computed(() => !!props.record?.id);
const title = computed(() => {
return isEdit.value ? '编辑定时任务' : '新增定时任务'
})
return isEdit.value ? "编辑定时任务" : "新增定时任务";
});
// 表单数据
const form = ref({
id: '',
name: '',
command: '',
type: 'command',
expression: '* * * * *',
timezone: 'Asia/Shanghai',
description: '',
id: "",
name: "",
command: "",
type: "command",
expression: "* * * * *",
timezone: "Asia/Shanghai",
description: "",
is_active: null,
run_in_background: false,
without_overlapping: false,
only_one: false,
sort: 0
})
sort: 0,
});
// Cron 表达式验证函数
const validateCronExpression = (rule, value) => {
if (!value || !value.trim()) {
return Promise.reject('请输入Cron表达式')
return Promise.reject("请输入Cron表达式");
}
const parts = value.trim().split(/\s+/)
const parts = value.trim().split(/\s+/);
if (parts.length !== 5) {
return Promise.reject('Cron表达式应由5部分组成:分 时 日 月 周')
return Promise.reject("Cron表达式应由5部分组成:分 时 日 月 周");
}
const [minute, hour, day, month, weekday] = parts
const [minute, hour, day, month, weekday] = parts;
// 验证分钟 (0-59)
if (!validateCronPart(minute, 0, 59)) {
return Promise.reject('分钟部分格式不正确 (0-59)')
return Promise.reject("分钟部分格式不正确 (0-59)");
}
// 验证小时 (0-23)
if (!validateCronPart(hour, 0, 23)) {
return Promise.reject('小时部分格式不正确 (0-23)')
return Promise.reject("小时部分格式不正确 (0-23)");
}
// 验证日 (1-31)
if (!validateCronPart(day, 1, 31)) {
return Promise.reject('日部分格式不正确 (1-31)')
return Promise.reject("日部分格式不正确 (1-31)");
}
// 验证月 (1-12)
if (!validateCronPart(month, 1, 12)) {
return Promise.reject('月部分格式不正确 (1-12)')
return Promise.reject("月部分格式不正确 (1-12)");
}
// 验证周 (0-6 或 SUN-SAT)
if (!validateCronPart(weekday, 0, 6, true)) {
return Promise.reject('周部分格式不正确 (0-6 或 SUN-SAT)')
return Promise.reject("周部分格式不正确 (0-6 或 SUN-SAT)");
}
return Promise.resolve()
}
return Promise.resolve();
};
// 验证 Cron 单个部分
const validateCronPart = (part, min, max, allowDayNames = false) => {
// 支持 * (所有值)
if (part === '*') return true
if (part === "*") return true;
// 支持逗号分隔的列表
const listItems = part.split(',')
const listItems = part.split(",");
for (const item of listItems) {
// 支持步长 (step),如 */5 或 0-10/2
if (item.includes('/')) {
const [range, step] = item.split('/')
if (!range || !step) return false
if (!validateCronRange(range, min, max, allowDayNames)) return false
if (!/^\d+$/.test(step)) return false
continue
if (item.includes("/")) {
const [range, step] = item.split("/");
if (!range || !step) return false;
if (!validateCronRange(range, min, max, allowDayNames))
return false;
if (!/^\d+$/.test(step)) return false;
continue;
}
// 支持范围,如 1-5
if (item.includes('-')) {
if (!validateCronRange(item, min, max, allowDayNames)) return false
continue
if (item.includes("-")) {
if (!validateCronRange(item, min, max, allowDayNames)) return false;
continue;
}
// 支持星期名称
if (allowDayNames) {
const dayNames = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT']
const dayNamesLower = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']
const dayNames = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"];
const dayNamesLower = [
"sun",
"mon",
"tue",
"wed",
"thu",
"fri",
"sat",
];
if (dayNames.includes(item) || dayNamesLower.includes(item)) {
continue
continue;
}
}
// 检查是否为有效数字
if (!/^\d+$/.test(item)) return false
const num = parseInt(item, 10)
if (num < min || num > max) return false
if (!/^\d+$/.test(item)) return false;
const num = parseInt(item, 10);
if (num < min || num > max) return false;
}
return true
}
return true;
};
// 验证 Cron 范围
const validateCronRange = (range, min, max, allowDayNames = false) => {
// 处理 * 范围
if (range === '*') return true
if (range === "*") return true;
const parts = range.split('-')
if (parts.length !== 2) return false
const parts = range.split("-");
if (parts.length !== 2) return false;
const [start, end] = parts
const [start, end] = parts;
// 支持星期名称范围
if (allowDayNames) {
const dayNames = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT']
const startIdx = dayNames.indexOf(start)
const endIdx = dayNames.indexOf(end)
const dayNames = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"];
const startIdx = dayNames.indexOf(start);
const endIdx = dayNames.indexOf(end);
if (startIdx !== -1 && endIdx !== -1) {
return startIdx <= endIdx
return startIdx <= endIdx;
}
}
// 数字范围验证
if (!/^\d+$/.test(start) || !/^\d+$/.test(end)) return false
const startNum = parseInt(start, 10)
const endNum = parseInt(end, 10)
if (!/^\d+$/.test(start) || !/^\d+$/.test(end)) return false;
const startNum = parseInt(start, 10);
const endNum = parseInt(end, 10);
return startNum >= min && startNum <= max && endNum >= min && endNum <= max && startNum <= endNum
}
return (
startNum >= min &&
startNum <= max &&
endNum >= min &&
endNum <= max &&
startNum <= endNum
);
};
// 验证规则
const rules = {
name: [
{ required: true, message: '请输入任务名称', trigger: 'blur' },
{ min: 2, max: 100, message: '长度在 2 到 100 个字符', trigger: 'blur' }
],
type: [
{ required: true, message: '请选择任务类型', trigger: 'change' }
],
command: [
{ required: true, message: '请输入命令或类名', trigger: 'blur' }
{ required: true, message: "请输入任务名称", trigger: "blur" },
{
min: 2,
max: 100,
message: "长度在 2 到 100 个字符",
trigger: "blur",
},
],
type: [{ required: true, message: "请选择任务类型", trigger: "change" }],
command: [{ required: true, message: "请输入命令或类名", trigger: "blur" }],
expression: [
{ required: true, message: '请输入Cron表达式', trigger: 'blur' },
{ validator: validateCronExpression, trigger: 'blur' }
]
}
{ required: true, message: "请输入Cron表达式", trigger: "blur" },
{ validator: validateCronExpression, trigger: "blur" },
],
};
// 下拉筛选
const filterOption = (input, option) => {
return option.value.toLowerCase().includes(input.toLowerCase())
}
return option.value.toLowerCase().includes(input.toLowerCase());
};
// 重置表单
const resetForm = () => {
form.value = {
id: '',
name: '',
command: '',
type: 'command',
expression: '* * * * *',
timezone: 'Asia/Shanghai',
description: '',
id: "",
name: "",
command: "",
type: "command",
expression: "* * * * *",
timezone: "Asia/Shanghai",
description: "",
is_active: null,
run_in_background: false,
without_overlapping: false,
only_one: false,
sort: 0
}
formRef.value?.clearValidate()
}
sort: 0,
};
formRef.value?.clearValidate();
};
// 设置数据
const setData = (data) => {
if (data) {
form.value = {
id: data.id || '',
name: data.name || '',
command: data.command || '',
type: data.type || 'command',
expression: data.expression || '* * * * *',
timezone: data.timezone || 'Asia/Shanghai',
description: data.description || '',
id: data.id || "",
name: data.name || "",
command: data.command || "",
type: data.type || "command",
expression: data.expression || "* * * * *",
timezone: data.timezone || "Asia/Shanghai",
description: data.description || "",
is_active: data.is_active !== undefined ? data.is_active : null,
run_in_background: data.run_in_background || false,
without_overlapping: data.without_overlapping || false,
only_one: data.only_one || false,
sort: data.sort !== undefined ? data.sort : 0
}
sort: data.sort !== undefined ? data.sort : 0,
};
}
}
};
// 提交表单
const handleSubmit = async () => {
try {
await formRef.value.validate()
await formRef.value.validate();
isSaving.value = true
isSaving.value = true;
const submitData = { ...form.value }
const submitData = { ...form.value };
let res = {}
let res = {};
if (isEdit.value) {
res = await systemApi.tasks.edit.put(form.value.id, submitData)
res = await systemApi.tasks.edit.put(form.value.id, submitData);
} else {
res = await systemApi.tasks.add.post(submitData)
res = await systemApi.tasks.add.post(submitData);
}
if (res.code === 200) {
message.success(isEdit.value ? '编辑成功' : '新增成功')
emit('success')
handleCancel()
message.success(isEdit.value ? "编辑成功" : "新增成功");
emit("success");
handleCancel();
} else {
message.error(res.message || '操作失败')
message.error(res.message || "操作失败");
}
} catch (error) {
if (error.errorFields) {
console.log('表单验证失败:', error)
console.log("表单验证失败:", error);
} else {
console.error('提交失败:', error)
message.error('操作失败')
console.error("提交失败:", error);
message.error("操作失败");
}
} finally {
isSaving.value = false
isSaving.value = false;
}
}
};
// 取消
const handleCancel = () => {
resetForm()
emit('update:visible', false)
}
resetForm();
emit("update:visible", false);
};
// 监听 visible 变化
watch(() => props.visible, (newVal) => {
if (newVal) {
if (props.record) {
setData(props.record)
} else {
resetForm()
watch(
() => props.visible,
(newVal) => {
if (newVal) {
if (props.record) {
setData(props.record);
} else {
resetForm();
}
}
}
}, { immediate: true })
},
{ immediate: true },
);
</script>
<style scoped lang="scss">
+245 -122
View File
@@ -3,13 +3,28 @@
<div class="tool-bar">
<div class="left-panel">
<a-space>
<a-input v-model:value="searchForm.keyword" placeholder="任务名称/命令" allow-clear style="width: 180px" />
<a-select v-model:value="searchForm.type" placeholder="任务类型" allow-clear style="width: 120px">
<a-input
v-model:value="searchForm.keyword"
placeholder="任务名称/命令"
allow-clear
style="width: 180px"
/>
<a-select
v-model:value="searchForm.type"
placeholder="任务类型"
allow-clear
style="width: 120px"
>
<a-select-option value="command">命令</a-select-option>
<a-select-option value="job">任务</a-select-option>
<a-select-option value="closure">闭包</a-select-option>
</a-select>
<a-select v-model:value="searchForm.is_active" placeholder="状态" allow-clear style="width: 100px">
<a-select
v-model:value="searchForm.is_active"
placeholder="状态"
allow-clear
style="width: 100px"
>
<a-select-option :value="true">启用</a-select-option>
<a-select-option :value="false">禁用</a-select-option>
</a-select>
@@ -52,15 +67,29 @@
</div>
<div class="table-content">
<scTable ref="tableRef" :columns="columns" :data-source="tableData" :loading="loading"
:pagination="pagination" :row-selection="rowSelection" :row-key="rowKey" @refresh="refreshTable"
@paginationChange="handlePaginationChange">
<scTable
ref="tableRef"
:columns="columns"
:data-source="tableData"
:loading="loading"
:pagination="pagination"
:row-selection="rowSelection"
:row-key="rowKey"
@refresh="refreshTable"
@paginationChange="handlePaginationChange"
>
<template #type="{ record }">
<a-tag :color="getTypeColor(record.type)">{{ getTypeText(record.type) }}</a-tag>
<a-tag :color="getTypeColor(record.type)">{{
getTypeText(record.type)
}}</a-tag>
</template>
<template #is_active="{ record }">
<a-switch :checked="record.is_active" :disabled="!canEdit" @change="handleToggleStatus(record)" />
<a-switch
:checked="record.is_active"
:disabled="!canEdit"
@change="handleToggleStatus(record)"
/>
</template>
<template #expression="{ record }">
@@ -68,7 +97,11 @@
</template>
<template #last_run_at="{ record }">
{{ record.last_run_at ? formatDate(record.last_run_at) : '-' }}
{{
record.last_run_at
? formatDate(record.last_run_at)
: "-"
}}
</template>
<template #next_run_at="{ record }">
@@ -83,12 +116,20 @@
<a-space>
<a-tooltip title="成功次数">
<a-tag color="success">
<CheckCircleOutlined /> {{ record.run_count || 0 }}
<CheckCircleOutlined />
{{ record.run_count || 0 }}
</a-tag>
</a-tooltip>
<a-tooltip title="失败次数">
<a-tag :color="record.failed_count > 0 ? 'error' : 'default'">
<CloseCircleOutlined /> {{ record.failed_count || 0 }}
<a-tag
:color="
record.failed_count > 0
? 'error'
: 'default'
"
>
<CloseCircleOutlined />
{{ record.failed_count || 0 }}
</a-tag>
</a-tooltip>
</a-space>
@@ -96,18 +137,32 @@
<template #action="{ record }">
<a-space>
<a-button type="link" size="small" @click="handleView(record)">
<a-button
type="link"
size="small"
@click="handleView(record)"
>
<EyeOutlined />查看
</a-button>
<a-button type="link" size="small" @click="handleEdit(record)">
<a-button
type="link"
size="small"
@click="handleEdit(record)"
>
<EditOutlined />编辑
</a-button>
<a-popconfirm title="确定立即执行该任务吗?" @confirm="handleRun(record)">
<a-popconfirm
title="确定立即执行该任务吗?"
@confirm="handleRun(record)"
>
<a-button type="link" size="small">
<PlayCircleOutlined />执行
</a-button>
</a-popconfirm>
<a-popconfirm title="确定删除该任务吗?" @confirm="handleDelete(record)">
<a-popconfirm
title="确定删除该任务吗?"
@confirm="handleDelete(record)"
>
<a-button type="link" size="small" danger>
<DeleteOutlined />删除
</a-button>
@@ -119,15 +174,24 @@
</div>
<!-- 新增/编辑弹窗 -->
<TaskDialog v-if="dialog.save" v-model:visible="dialog.save" :record="currentRecord" @success="handleSaveSuccess" />
<TaskDialog
v-if="dialog.save"
v-model:visible="dialog.save"
:record="currentRecord"
@success="handleSaveSuccess"
/>
<!-- 查看详情弹窗 -->
<TaskDetailDialog v-if="dialog.detail" v-model:visible="dialog.detail" :record="currentRecord" />
<TaskDetailDialog
v-if="dialog.detail"
v-model:visible="dialog.detail"
:record="currentRecord"
/>
</template>
<script setup>
import { ref, reactive } from 'vue'
import { message, Modal } from 'ant-design-vue'
import { ref, reactive } from "vue";
import { message, Modal } from "ant-design-vue";
import {
SearchOutlined,
RedoOutlined,
@@ -140,13 +204,13 @@ import {
EyeOutlined,
PlayCircleOutlined,
ClockCircleOutlined,
CloseCircleOutlined
} from '@ant-design/icons-vue'
import { useTable } from '@/hooks/useTable'
import systemApi from '@/api/system'
import scTable from '@/components/scTable/index.vue'
import TaskDialog from './components/TaskDialog.vue'
import TaskDetailDialog from './components/TaskDetailDialog.vue'
CloseCircleOutlined,
} from "@ant-design/icons-vue";
import { useTable } from "@/hooks/useTable";
import systemApi from "@/api/system";
import scTable from "@/components/scTable/index.vue";
import TaskDialog from "./components/TaskDialog.vue";
import TaskDetailDialog from "./components/TaskDetailDialog.vue";
// ===== useTable Hook =====
const {
@@ -160,202 +224,261 @@ const {
handleSearch,
handleReset,
handlePaginationChange,
refreshTable
refreshTable,
} = useTable({
api: systemApi.tasks.list.get,
searchForm: {
keyword: '',
keyword: "",
type: undefined,
is_active: undefined
is_active: undefined,
},
columns: [],
needPagination: true,
needSelection: true
})
needSelection: true,
});
// ===== 表格列配置 =====
const rowKey = 'id'
const rowKey = "id";
const columns = [
{ title: 'ID', dataIndex: 'id', key: 'id', width: 80, align: 'center' },
{ title: '任务名称', dataIndex: 'name', key: 'name', width: 150, ellipsis: true },
{ title: '任务类型', dataIndex: 'type', key: 'type', width: 100, align: 'center', slot: 'type' },
{ title: '命令/类', dataIndex: 'command', key: 'command', ellipsis: true },
{ title: 'Cron表达式', dataIndex: 'expression', key: 'expression', width: 120, align: 'center', slot: 'expression' },
{ title: '状态', dataIndex: 'is_active', key: 'is_active', width: 80, align: 'center', slot: 'is_active' },
{ title: '上次运行', dataIndex: 'last_run_at', key: 'last_run_at', width: 160, align: 'center', slot: 'last_run_at' },
{ title: '下次运行', dataIndex: 'next_run_at', key: 'next_run_at', width: 160, align: 'center', slot: 'next_run_at' },
{ title: '统计', dataIndex: 'statistics', key: 'statistics', width: 120, align: 'center', slot: 'statistics' },
{ title: '操作', dataIndex: 'action', key: 'action', width: 200, align: 'center', fixed: 'right', slot: 'action' }
]
{ title: "ID", dataIndex: "id", key: "id", width: 80, align: "center" },
{
title: "任务名称",
dataIndex: "name",
key: "name",
width: 150,
ellipsis: true,
},
{
title: "任务类型",
dataIndex: "type",
key: "type",
width: 100,
align: "center",
slot: "type",
},
{ title: "命令/类", dataIndex: "command", key: "command", ellipsis: true },
{
title: "Cron表达式",
dataIndex: "expression",
key: "expression",
width: 120,
align: "center",
slot: "expression",
},
{
title: "状态",
dataIndex: "is_active",
key: "is_active",
width: 80,
align: "center",
slot: "is_active",
},
{
title: "上次运行",
dataIndex: "last_run_at",
key: "last_run_at",
width: 160,
align: "center",
slot: "last_run_at",
},
{
title: "下次运行",
dataIndex: "next_run_at",
key: "next_run_at",
width: 160,
align: "center",
slot: "next_run_at",
},
{
title: "统计",
dataIndex: "statistics",
key: "statistics",
width: 120,
align: "center",
slot: "statistics",
},
{
title: "操作",
dataIndex: "action",
key: "action",
width: 200,
align: "center",
fixed: "right",
slot: "action",
},
];
// ===== 弹窗状态 =====
const dialog = reactive({
save: false,
detail: false
})
detail: false,
});
const currentRecord = ref(null)
const currentRecord = ref(null);
// ===== 权限控制 =====
const canEdit = ref(true)
const canEdit = ref(true);
// ===== 方法:获取任务类型文本 =====
const getTypeText = (type) => {
const typeMap = {
command: '命令',
job: '任务',
closure: '闭包'
}
return typeMap[type] || type
}
command: "命令",
job: "任务",
closure: "闭包",
};
return typeMap[type] || type;
};
// ===== 方法:获取任务类型颜色 =====
const getTypeColor = (type) => {
const colorMap = {
command: 'blue',
job: 'green',
closure: 'orange'
}
return colorMap[type] || 'default'
}
command: "blue",
job: "green",
closure: "orange",
};
return colorMap[type] || "default";
};
// ===== 方法:格式化日期 =====
const formatDate = (dateStr) => {
if (!dateStr) return '-'
const date = new Date(dateStr)
return date.toLocaleString('zh-CN')
}
if (!dateStr) return "-";
const date = new Date(dateStr);
return date.toLocaleString("zh-CN");
};
// ===== 方法:切换状态 =====
const handleToggleStatus = async (record) => {
try {
const res = await systemApi.tasks.batchStatus.post({
ids: [record.id],
status: record.is_active
})
status: record.is_active,
});
if (res.code === 200) {
message.success(record.is_active ? '已启用' : '已禁用')
refreshTable()
message.success(record.is_active ? "已启用" : "已禁用");
refreshTable();
} else {
message.error(res.message || '操作失败')
message.error(res.message || "操作失败");
}
} catch (error) {
message.error('操作失败')
message.error("操作失败");
}
}
};
// ===== 方法:新增 =====
const handleAdd = () => {
currentRecord.value = null
dialog.save = true
}
currentRecord.value = null;
dialog.save = true;
};
// ===== 方法:编辑 =====
const handleEdit = (record) => {
currentRecord.value = { ...record }
dialog.save = true
}
currentRecord.value = { ...record };
dialog.save = true;
};
// ===== 方法:查看 =====
const handleView = (record) => {
currentRecord.value = { ...record }
dialog.detail = true
}
currentRecord.value = { ...record };
dialog.detail = true;
};
// ===== 方法:删除 =====
const handleDelete = async (record) => {
try {
const res = await systemApi.tasks.delete.delete(record.id)
const res = await systemApi.tasks.delete.delete(record.id);
if (res.code === 200) {
message.success('删除成功')
refreshTable()
message.success("删除成功");
refreshTable();
} else {
message.error(res.message || '删除失败')
message.error(res.message || "删除失败");
}
} catch (error) {
message.error('删除失败')
message.error("删除失败");
}
}
};
// ===== 方法:执行任务 =====
const handleRun = async (record) => {
try {
const res = await systemApi.tasks.run.post(record.id)
const res = await systemApi.tasks.run.post(record.id);
if (res.code === 200) {
message.success('任务执行成功')
refreshTable()
message.success("任务执行成功");
refreshTable();
} else {
message.error(res.message || '任务执行失败')
message.error(res.message || "任务执行失败");
}
} catch (error) {
message.error('任务执行失败')
message.error("任务执行失败");
}
}
};
// ===== 方法:批量删除 =====
const handleBatchDelete = () => {
if (selectedRows.value.length === 0) {
message.warning('请选择要删除的任务')
return
message.warning("请选择要删除的任务");
return;
}
Modal.confirm({
title: '确认删除',
title: "确认删除",
content: `确定删除选中的 ${selectedRows.value.length} 个任务吗?`,
onOk: async () => {
try {
const ids = selectedRows.value.map(item => item.id)
const res = await systemApi.tasks.batchDelete.post({ ids })
const ids = selectedRows.value.map((item) => item.id);
const res = await systemApi.tasks.batchDelete.post({ ids });
if (res.code === 200) {
message.success('删除成功')
selectedRows.value = []
refreshTable()
message.success("删除成功");
selectedRows.value = [];
refreshTable();
} else {
message.error(res.message || '删除失败')
message.error(res.message || "删除失败");
}
} catch (error) {
message.error('删除失败')
message.error("删除失败");
}
}
})
}
},
});
};
// ===== 方法:批量更新状态 =====
const handleBatchStatus = (status) => {
if (selectedRows.value.length === 0) {
message.warning('请选择要操作的任务')
return
message.warning("请选择要操作的任务");
return;
}
const statusText = status ? '启用' : '禁用'
const statusText = status ? "启用" : "禁用";
Modal.confirm({
title: `确认${statusText}`,
content: `确定要${statusText}选中的 ${selectedRows.value.length} 个任务吗?`,
onOk: async () => {
try {
const ids = selectedRows.value.map(item => item.id)
const res = await systemApi.tasks.batchStatus.post({ ids, status })
const ids = selectedRows.value.map((item) => item.id);
const res = await systemApi.tasks.batchStatus.post({
ids,
status,
});
if (res.code === 200) {
message.success(`${statusText}成功`)
selectedRows.value = []
refreshTable()
message.success(`${statusText}成功`);
selectedRows.value = [];
refreshTable();
} else {
message.error(res.message || '操作失败')
message.error(res.message || "操作失败");
}
} catch (error) {
message.error('操作失败')
message.error("操作失败");
}
}
})
}
},
});
};
// ===== 方法:保存成功回调 =====
const handleSaveSuccess = () => {
dialog.save = false
refreshTable()
}
dialog.save = false;
refreshTable();
};
</script>
<style scoped lang="scss">
@@ -364,7 +487,7 @@ const handleSaveSuccess = () => {
padding: 2px 6px;
background: #f5f5f5;
border-radius: 3px;
font-family: 'Consolas', 'Monaco', monospace;
font-family: "Consolas", "Monaco", monospace;
font-size: 12px;
}
@@ -1,84 +1,103 @@
<template>
<scForm :form-items="formItems" :initial-values="initialValues" :loading="loading" @finish="handleFinish"
@reset="handleReset" />
<scForm
:form-items="formItems"
:initial-values="initialValues"
:loading="loading"
@finish="handleFinish"
@reset="handleReset"
/>
</template>
<script setup>
import { ref, computed } from 'vue'
import { message } from 'ant-design-vue'
import scForm from '@/components/scForm/index.vue'
import api from '@/api/auth'
import { useUserStore } from '@/stores/modules/user'
import { ref, computed } from "vue";
import { message } from "ant-design-vue";
import scForm from "@/components/scForm/index.vue";
import api from "@/api/auth";
import { useUserStore } from "@/stores/modules/user";
const props = defineProps({
userInfo: {
type: Object,
default: () => ({}),
},
})
});
const emit = defineEmits(['update'])
const emit = defineEmits(["update"]);
const userStore = useUserStore()
const loading = ref(false)
const userStore = useUserStore();
const loading = ref(false);
// 表单初始值
const initialValues = computed(() => ({
username: props.userInfo.username || '',
real_name: props.userInfo.real_name || '',
phone: props.userInfo.phone || '',
email: props.userInfo.email || '',
}))
username: props.userInfo.username || "",
real_name: props.userInfo.real_name || "",
phone: props.userInfo.phone || "",
email: props.userInfo.email || "",
}));
// 表单项配置
const formItems = [
{
field: 'username',
label: '用户名',
type: 'input',
field: "username",
label: "用户名",
type: "input",
rules: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 3, max: 20, message: '用户名长度在 3 到 20 个字符', trigger: 'blur' },
{ required: true, message: "请输入用户名", trigger: "blur" },
{
min: 3,
max: 20,
message: "用户名长度在 3 到 20 个字符",
trigger: "blur",
},
],
},
{
field: 'real_name',
label: '真实姓名',
type: 'input',
field: "real_name",
label: "真实姓名",
type: "input",
required: true,
rules: [
{ required: true, message: '请输入真实姓名', trigger: 'blur' },
{ min: 2, max: 20, message: '姓名长度在 2 到 20 个字符', trigger: 'blur' },
{ required: true, message: "请输入真实姓名", trigger: "blur" },
{
min: 2,
max: 20,
message: "姓名长度在 2 到 20 个字符",
trigger: "blur",
},
],
},
{
field: 'phone',
label: '手机号',
type: 'input',
field: "phone",
label: "手机号",
type: "input",
rules: [
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' },
{
pattern: /^1[3-9]\d{9}$/,
message: "请输入正确的手机号",
trigger: "blur",
},
],
},
{
field: 'email',
label: '邮箱',
type: 'input',
field: "email",
label: "邮箱",
type: "input",
rules: [
{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' },
{ type: "email", message: "请输入正确的邮箱地址", trigger: "blur" },
],
},
]
];
// 表单提交
const handleFinish = async (values) => {
try {
loading.value = true
loading.value = true;
// 获取当前用户ID
const userId = userStore.userInfo?.id
const userId = userStore.userInfo?.id;
if (!userId) {
throw new Error('用户信息不存在,请重新登录')
throw new Error("用户信息不存在,请重新登录");
}
// 调用更新用户信息接口
@@ -87,31 +106,31 @@ const handleFinish = async (values) => {
real_name: values.real_name,
phone: values.phone,
email: values.email,
})
});
if (!res || res.code !== 200) {
throw new Error(res.message || '保存失败,请重试')
throw new Error(res.message || "保存失败,请重试");
}
// 重新获取用户信息
const response = await api.me.get()
const response = await api.me.get();
if (response && response.data) {
userStore.setUserInfo(response.data)
emit('update', response.data)
userStore.setUserInfo(response.data);
emit("update", response.data);
}
message.success('保存成功')
message.success("保存成功");
} catch (error) {
message.error(error.message || '保存失败,请重试')
message.error(error.message || "保存失败,请重试");
} finally {
loading.value = false
loading.value = false;
}
}
};
// 重置表单
const handleReset = () => {
message.info('已重置')
}
message.info("已重置");
};
</script>
<style scoped lang="scss"></style>
@@ -1,116 +1,125 @@
<template>
<scForm :form-items="formItems" :initial-values="initialValues" :loading="loading" submit-text="修改密码"
@finish="handleFinish" @reset="handleReset" />
<scForm
:form-items="formItems"
:initial-values="initialValues"
:loading="loading"
submit-text="修改密码"
@finish="handleFinish"
@reset="handleReset"
/>
</template>
<script setup>
import { ref } from 'vue'
import { message } from 'ant-design-vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores/modules/user'
import scForm from '@/components/scForm/index.vue'
import api from '@/api/auth'
import { ref } from "vue";
import { message } from "ant-design-vue";
import { useRouter } from "vue-router";
import { useUserStore } from "@/stores/modules/user";
import scForm from "@/components/scForm/index.vue";
import api from "@/api/auth";
const emit = defineEmits(['success'])
const router = useRouter()
const userStore = useUserStore()
const loading = ref(false)
const emit = defineEmits(["success"]);
const router = useRouter();
const userStore = useUserStore();
const loading = ref(false);
// 表单初始值
const initialValues = ref({
old_password: '',
password: '',
password_confirmation: '',
})
old_password: "",
password: "",
password_confirmation: "",
});
// 表单项配置
const formItems = [
{
field: 'old_password',
label: '原密码',
type: 'password',
field: "old_password",
label: "原密码",
type: "password",
required: true,
rules: [
{ required: true, message: '请输入原密码', trigger: 'blur' },
],
rules: [{ required: true, message: "请输入原密码", trigger: "blur" }],
},
{
field: 'password',
label: '新密码',
type: 'password',
field: "password",
label: "新密码",
type: "password",
required: true,
rules: [
{ required: true, message: '请输入新密码', trigger: 'blur' },
{ min: 6, max: 20, message: '密码长度在 6 到 20 个字符', trigger: 'blur' },
],
},
{
field: 'password_confirmation',
label: '确认密码',
type: 'password',
required: true,
dependencies: ['password'],
rules: [
{ required: true, message: '请再次输入新密码', trigger: 'blur' },
{ required: true, message: "请输入新密码", trigger: "blur" },
{
validator: (_, value) => {
if (!value || !initialValues.value.password) {
return Promise.resolve()
}
if (value !== initialValues.value.password) {
return Promise.reject('两次输入的密码不一致')
}
return Promise.resolve()
},
trigger: 'blur',
min: 6,
max: 20,
message: "密码长度在 6 到 20 个字符",
trigger: "blur",
},
],
},
]
{
field: "password_confirmation",
label: "确认密码",
type: "password",
required: true,
dependencies: ["password"],
rules: [
{ required: true, message: "请再次输入新密码", trigger: "blur" },
{
validator: (_, value) => {
if (!value || !initialValues.value.password) {
return Promise.resolve();
}
if (value !== initialValues.value.password) {
return Promise.reject("两次输入的密码不一致");
}
return Promise.resolve();
},
trigger: "blur",
},
],
},
];
// 表单提交
const handleFinish = async (values) => {
try {
loading.value = true
loading.value = true;
// 调用修改密码接口
const res = await api.changePassword.post({
old_password: values.old_password,
password: values.password,
password_confirmation: values.password_confirmation,
})
});
if (!res || res.code !== 200) {
throw new Error(res.message || '密码修改失败,请重试')
throw new Error(res.message || "密码修改失败,请重试");
}
message.success('密码修改成功,请重新登录')
message.success("密码修改成功,请重新登录");
// 清除用户信息和token
userStore.logout()
userStore.logout();
// 延迟跳转到登录页
setTimeout(() => {
router.push('/login')
}, 1500)
router.push("/login");
}, 1500);
emit('success')
handleReset()
emit("success");
handleReset();
} catch (error) {
message.error(error.message || '密码修改失败,请重试')
message.error(error.message || "密码修改失败,请重试");
} finally {
loading.value = false
loading.value = false;
}
}
};
// 重置表单
const handleReset = () => {
initialValues.value = {
old_password: '',
password: '',
password_confirmation: '',
}
}
old_password: "",
password: "",
password_confirmation: "",
};
};
</script>
<style scoped lang="scss"></style>
@@ -1,13 +1,19 @@
<template>
<div class="profile-info">
<div class="avatar-wrapper">
<a-avatar :size="100" :src="userInfo.avatar" @click="handleAvatarClick">
<a-avatar
:size="100"
:src="userInfo.avatar"
@click="handleAvatarClick"
>
{{ userInfo.nickname?.charAt(0) }}
</a-avatar>
</div>
<div class="user-name">{{ userInfo.nickname || userInfo.username }}</div>
<div class="user-name">
{{ userInfo.nickname || userInfo.username }}
</div>
<a-tag :color="userInfo.status === 1 ? 'green' : 'red'">
{{ userInfo.status === 1 ? '正常' : '禁用' }}
{{ userInfo.status === 1 ? "正常" : "禁用" }}
</a-tag>
</div>
</template>
@@ -18,13 +24,13 @@ const props = defineProps({
type: Object,
default: () => ({}),
},
})
});
const emit = defineEmits(['avatar-click'])
const emit = defineEmits(["avatar-click"]);
const handleAvatarClick = () => {
emit('avatar-click')
}
emit("avatar-click");
};
</script>
<style scoped lang="scss">
@@ -11,7 +11,11 @@
</template>
</a-list-item-meta>
<template #actions>
<a-button type="primary" size="small" @click="handleAction(item.action)">
<a-button
type="primary"
size="small"
@click="handleAction(item.action)"
>
{{ item.buttonText }}
</a-button>
</template>
@@ -21,56 +25,56 @@
</template>
<script setup>
import { ref } from 'vue'
import { message } from 'ant-design-vue'
import { ref } from "vue";
import { message } from "ant-design-vue";
const emit = defineEmits(['change-password'])
const emit = defineEmits(["change-password"]);
const securityList = ref([
{
title: '登录密码',
description: '用于登录系统的密码,建议定期更换',
buttonText: '修改',
action: 'password',
title: "登录密码",
description: "用于登录系统的密码,建议定期更换",
buttonText: "修改",
action: "password",
},
{
title: '手机验证',
description: '用于接收重要通知和安全验证',
buttonText: '已绑定',
action: 'phone',
title: "手机验证",
description: "用于接收重要通知和安全验证",
buttonText: "已绑定",
action: "phone",
},
{
title: '邮箱验证',
description: '用于接收重要通知和账号找回',
buttonText: '已绑定',
action: 'email',
title: "邮箱验证",
description: "用于接收重要通知和账号找回",
buttonText: "已绑定",
action: "email",
},
{
title: '登录设备',
description: '查看和管理已登录的设备',
buttonText: '查看',
action: 'device',
title: "登录设备",
description: "查看和管理已登录的设备",
buttonText: "查看",
action: "device",
},
])
]);
const handleAction = (action) => {
switch (action) {
case 'password':
emit('change-password')
break
case 'phone':
message.info('手机绑定功能开发中')
break
case 'email':
message.info('邮箱绑定功能开发中')
break
case 'device':
message.info('登录设备管理功能开发中')
break
case "password":
emit("change-password");
break;
case "phone":
message.info("手机绑定功能开发中");
break;
case "email":
message.info("邮箱绑定功能开发中");
break;
case "device":
message.info("登录设备管理功能开发中");
break;
default:
break
break;
}
}
};
</script>
<style scoped lang="scss">
+107 -74
View File
@@ -3,8 +3,15 @@
<a-card>
<a-row :gutter="24">
<a-col :span="6">
<ProfileInfo :user-info="userInfo" @avatar-click="showAvatarModal = true" />
<a-menu v-model:selectedKeys="selectedKeys" mode="inline" class="menu">
<ProfileInfo
:user-info="userInfo"
@avatar-click="showAvatarModal = true"
/>
<a-menu
v-model:selectedKeys="selectedKeys"
mode="inline"
class="menu"
>
<a-menu-item key="basic">
<UserOutlined />
基本信息
@@ -21,28 +28,49 @@
</a-col>
<a-col :span="18">
<div class="content-wrapper">
<BasicInfo v-if="selectedKeys[0] === 'basic'" :user-info="userInfo"
@update="handleUpdateUserInfo" />
<Password v-else-if="selectedKeys[0] === 'password'" @success="handlePasswordSuccess" />
<Security v-else-if="selectedKeys[0] === 'security'" @change-password="handleChangePassword" />
<BasicInfo
v-if="selectedKeys[0] === 'basic'"
:user-info="userInfo"
@update="handleUpdateUserInfo"
/>
<Password
v-else-if="selectedKeys[0] === 'password'"
@success="handlePasswordSuccess"
/>
<Security
v-else-if="selectedKeys[0] === 'security'"
@change-password="handleChangePassword"
/>
</div>
</a-col>
</a-row>
</a-card>
<!-- 头像上传弹窗 -->
<a-modal v-model:open="showAvatarModal" title="更换头像" :confirm-loading="loading" @ok="handleAvatarUpload"
@cancel="showAvatarModal = false">
<a-modal
v-model:open="showAvatarModal"
title="更换头像"
:confirm-loading="loading"
@ok="handleAvatarUpload"
@cancel="showAvatarModal = false"
>
<div class="avatar-upload">
<a-upload list-type="picture-card" :max-count="1" :before-upload="beforeUpload"
@change="handleAvatarChange" :file-list="avatarFileList">
<a-upload
list-type="picture-card"
:max-count="1"
:before-upload="beforeUpload"
@change="handleAvatarChange"
:file-list="avatarFileList"
>
<div v-if="avatarFileList.length === 0">
<PlusOutlined />
<div class="ant-upload-text">上传头像</div>
</div>
</a-upload>
<div class="upload-tip">
<a-typography-text type="secondary"> 支持 JPGPNG 格式文件大小不超过 2MB </a-typography-text>
<a-typography-text type="secondary">
支持 JPGPNG 格式文件大小不超过 2MB
</a-typography-text>
</div>
</div>
</a-modal>
@@ -50,147 +78,152 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import { PlusOutlined, UserOutlined, LockOutlined, SafetyOutlined } from '@ant-design/icons-vue'
import dayjs from 'dayjs'
import ProfileInfo from './components/ProfileInfo.vue'
import BasicInfo from './components/BasicInfo.vue'
import Password from './components/Password.vue'
import Security from './components/Security.vue'
import { useUserStore } from '@/stores/modules/user'
import api from '@/api/auth'
import { ref, onMounted } from "vue";
import { message } from "ant-design-vue";
import {
PlusOutlined,
UserOutlined,
LockOutlined,
SafetyOutlined,
} from "@ant-design/icons-vue";
import dayjs from "dayjs";
import ProfileInfo from "./components/ProfileInfo.vue";
import BasicInfo from "./components/BasicInfo.vue";
import Password from "./components/Password.vue";
import Security from "./components/Security.vue";
import { useUserStore } from "@/stores/modules/user";
import api from "@/api/auth";
defineOptions({
name: 'UserCenter',
})
name: "UserCenter",
});
const userStore = useUserStore()
const userStore = useUserStore();
// 用户信息
const userInfo = ref({})
const userInfo = ref({});
// 选中的菜单
const selectedKeys = ref(['basic'])
const selectedKeys = ref(["basic"]);
// 头像上传
const showAvatarModal = ref(false)
const avatarFileList = ref([])
const loading = ref(false)
const showAvatarModal = ref(false);
const avatarFileList = ref([]);
const loading = ref(false);
// 初始化用户信息
const initUserInfo = async () => {
try {
// 从 store 获取用户信息
const storeUserInfo = userStore.userInfo
const storeUserInfo = userStore.userInfo;
if (storeUserInfo) {
userInfo.value = storeUserInfo
userInfo.value = storeUserInfo;
} else {
// 如果 store 中没有用户信息,则从接口获取
const response = await api.me.get()
const response = await api.me.get();
if (response && response.data) {
userStore.setUserInfo(response.data)
userInfo.value = response.data
userStore.setUserInfo(response.data);
userInfo.value = response.data;
}
}
} catch (err) {
message.error(err.message || '获取用户信息失败')
message.error(err.message || "获取用户信息失败");
}
}
};
// 更新用户信息
const handleUpdateUserInfo = (data) => {
// 更新本地用户信息
Object.assign(userInfo.value, data)
Object.assign(userInfo.value, data);
// 如果 birthday 有值,转换为 dayjs 对象
if (data.birthday && typeof data.birthday === 'string') {
userInfo.value.birthday = dayjs(data.birthday)
if (data.birthday && typeof data.birthday === "string") {
userInfo.value.birthday = dayjs(data.birthday);
}
}
};
// 密码修改成功
const handlePasswordSuccess = () => {
// 密码修改成功后的处理
}
};
// 切换到密码修改页面
const handleChangePassword = () => {
selectedKeys.value = ['password']
}
selectedKeys.value = ["password"];
};
// 头像上传前校验
const beforeUpload = (file) => {
const isJpgOrPng = file.type === 'image/jpeg' || file.type === 'image/png'
const isJpgOrPng = file.type === "image/jpeg" || file.type === "image/png";
if (!isJpgOrPng) {
message.error('只能上传 JPG/PNG 格式的文件!')
return false
message.error("只能上传 JPG/PNG 格式的文件!");
return false;
}
const isLt2M = file.size / 1024 / 1024 < 2
const isLt2M = file.size / 1024 / 1024 < 2;
if (!isLt2M) {
message.error('图片大小不能超过 2MB!')
return false
message.error("图片大小不能超过 2MB!");
return false;
}
return false // 阻止自动上传
}
return false; // 阻止自动上传
};
// 头像文件变化
const handleAvatarChange = ({ fileList }) => {
avatarFileList.value = fileList
}
avatarFileList.value = fileList;
};
// 上传头像
const handleAvatarUpload = async () => {
if (avatarFileList.value.length === 0) {
message.warning('请先选择头像')
return
message.warning("请先选择头像");
return;
}
try {
loading.value = true
const file = avatarFileList.value[0].originFileObj
loading.value = true;
const file = avatarFileList.value[0].originFileObj;
// 调用上传接口
const uploadRes = await api.upload.post(file)
const uploadRes = await api.upload.post(file);
if (!uploadRes || uploadRes.code !== 200) {
throw new Error(uploadRes.message || '头像上传失败')
throw new Error(uploadRes.message || "头像上传失败");
}
// 获取当前用户ID
const userId = userStore.userInfo?.id
const userId = userStore.userInfo?.id;
if (!userId) {
throw new Error('用户信息不存在,请重新登录')
throw new Error("用户信息不存在,请重新登录");
}
// 更新用户头像
const updateRes = await api.users.edit.put(userId, {
avatar: uploadRes.data.url,
})
});
if (!updateRes || updateRes.code !== 200) {
throw new Error(updateRes.message || '头像更新失败')
throw new Error(updateRes.message || "头像更新失败");
}
// 重新获取用户信息
const response = await api.me.get()
const response = await api.me.get();
if (response && response.data) {
userStore.setUserInfo(response.data)
userInfo.value = response.data
userStore.setUserInfo(response.data);
userInfo.value = response.data;
}
message.success('头像更新成功')
showAvatarModal.value = false
avatarFileList.value = []
message.success("头像更新成功");
showAvatarModal.value = false;
avatarFileList.value = [];
} catch (error) {
message.error(error.message || '头像上传失败,请重试')
message.error(error.message || "头像上传失败,请重试");
} finally {
loading.value = false
loading.value = false;
}
}
};
onMounted(() => {
initUserInfo()
})
initUserInfo();
});
</script>
<style scoped lang="scss">
+75 -75
View File
@@ -1,40 +1,40 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'
import config from '../config'
import { useUserStore } from '../stores/modules/user'
import systemRoutes from './systemRoutes'
import { createRouter, createWebHashHistory } from "vue-router";
import NProgress from "nprogress";
import "nprogress/nprogress.css";
import config from "../config";
import { useUserStore } from "../stores/modules/user";
import systemRoutes from "./systemRoutes";
// 配置 NProgress
NProgress.configure({
showSpinner: false,
trickleSpeed: 200,
minimum: 0.3
})
minimum: 0.3,
});
/**
* 404 路由
*/
const notFoundRoute = {
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: () => import('../layouts/other/404.vue'),
path: "/:pathMatch(.*)*",
name: "NotFound",
component: () => import("../layouts/other/404.vue"),
meta: {
title: '404',
hidden: true
}
}
title: "404",
hidden: true,
},
};
// 创建路由实例
const router = createRouter({
history: createWebHashHistory(),
routes: systemRoutes
})
routes: systemRoutes,
});
/**
* 组件导入映射
*/
const modules = import.meta.glob('../pages/**/*.vue')
const modules = import.meta.glob("../pages/**/*.vue");
/**
* 动态加载组件
@@ -43,16 +43,16 @@ const modules = import.meta.glob('../pages/**/*.vue')
*/
function loadComponent(componentPath) {
// 如果组件路径以 'views/' 或 'pages/' 开头,则从相应目录加载
if (componentPath.startsWith('views/')) {
const path = componentPath.replace('views/', '../pages/')
return modules[`${path}.vue`]
if (componentPath.startsWith("views/")) {
const path = componentPath.replace("views/", "../pages/");
return modules[`${path}.vue`];
}
// 如果是简单的组件名称,从 pages 目录加载
if (componentPath.endsWith('index')){
return modules[`../pages/${componentPath}.vue`]
if (componentPath.endsWith("index")) {
return modules[`../pages/${componentPath}.vue`];
} else {
return modules[`../pages/${componentPath}/index.vue`]
return modules[`../pages/${componentPath}/index.vue`];
}
}
@@ -63,147 +63,147 @@ function loadComponent(componentPath) {
*/
function transformMenusToRoutes(menus) {
if (!menus || !Array.isArray(menus)) {
return []
return [];
}
return menus
.filter(menu => menu && menu.path)
.map(menu => {
.filter((menu) => menu && menu.path)
.map((menu) => {
const route = {
path: menu.path,
name: menu.name || menu.path.replace(/\//g, '-'),
name: menu.name || menu.path.replace(/\//g, "-"),
meta: {
title: menu.meta?.title || menu.title,
icon: menu.meta?.icon || menu.icon,
hidden: menu.hidden || menu.meta?.hidden,
keepAlive: menu.meta?.keepAlive || false,
affix: menu.meta?.affix || 0,
role: menu.meta?.role || []
}
}
role: menu.meta?.role || [],
},
};
// 处理组件
if (menu.component) {
route.component = loadComponent(menu.component)
route.component = loadComponent(menu.component);
}
// 处理子路由
if (menu.children && menu.children.length > 0) {
route.children = transformMenusToRoutes(menu.children)
route.children = transformMenusToRoutes(menu.children);
}
// 处理重定向
if (menu.redirect) {
route.redirect = menu.redirect
route.redirect = menu.redirect;
}
return route
})
return route;
});
}
/**
* 路由守卫
*/
let isDynamicRouteLoaded = false
let isDynamicRouteLoaded = false;
router.beforeEach(async (to, from, next) => {
// 开始进度条
NProgress.start()
NProgress.start();
// 设置页面标题
document.title = to.meta.title
? `${to.meta.title} - ${config.APP_NAME}`
: config.APP_NAME
: config.APP_NAME;
const userStore = useUserStore()
const isLoggedIn = userStore.isLoggedIn()
const whiteList = config.whiteList || []
const userStore = useUserStore();
const isLoggedIn = userStore.isLoggedIn();
const whiteList = config.whiteList || [];
// 1. 如果在白名单中,直接放行
if (whiteList.includes(to.path)) {
next()
return
next();
return;
}
// 2. 如果未登录,跳转到登录页
if (!isLoggedIn) {
// 保存目标路由,登录后跳转
next({
path: '/login',
query: { redirect: to.fullPath }
})
return
path: "/login",
query: { redirect: to.fullPath },
});
return;
}
// 3. 已登录情况
// 如果访问登录页,重定向到首页
if (to.path === '/login') {
next({ path: config.DASHBOARD_URL })
return
if (to.path === "/login") {
next({ path: config.DASHBOARD_URL });
return;
}
// 4. 动态路由加载
if (!isDynamicRouteLoaded) {
try {
// 获取后端返回的用户菜单
const mergedMenus = userStore.getMenu()
const mergedMenus = userStore.getMenu();
if (mergedMenus && mergedMenus.length > 0) {
// 将合并后的菜单转换为路由
const dynamicRoutes = transformMenusToRoutes(mergedMenus)
const dynamicRoutes = transformMenusToRoutes(mergedMenus);
// 添加动态路由到 Layout 的子路由
dynamicRoutes.forEach(route => {
router.addRoute('Layout', route)
})
dynamicRoutes.forEach((route) => {
router.addRoute("Layout", route);
});
// 添加 404 路由(必须在最后添加)
router.addRoute(notFoundRoute)
router.addRoute(notFoundRoute);
isDynamicRouteLoaded = true
isDynamicRouteLoaded = true;
// 重新导航,确保新添加的路由被正确匹配
next({ ...to, replace: true })
next({ ...to, replace: true });
} else {
// 如果没有菜单数据,重置并跳转到登录页
userStore.logout()
next({ path: '/login', query: { redirect: to.fullPath } })
userStore.logout();
next({ path: "/login", query: { redirect: to.fullPath } });
}
} catch (error) {
console.error('动态路由加载失败:', error)
console.error("动态路由加载失败:", error);
// 加载失败,清除用户信息并跳转到登录页
userStore.logout()
userStore.logout();
next({
path: '/login',
query: { redirect: to.fullPath }
})
path: "/login",
query: { redirect: to.fullPath },
});
}
} else {
// 动态路由已加载,直接放行
next()
next();
}
})
});
router.afterEach(() => {
// 结束进度条
NProgress.done()
})
NProgress.done();
});
/**
* 重置路由(用于登出时)
*/
export function resetRouter() {
// 移除所有动态添加的路由
isDynamicRouteLoaded = false
isDynamicRouteLoaded = false;
// 重置为初始路由
const newRouter = createRouter({
history: createWebHashHistory(),
routes: systemRoutes
})
routes: systemRoutes,
});
router.matcher = newRouter.matcher
router.matcher = newRouter.matcher;
}
export default router
export default router;
+18 -18
View File
@@ -1,43 +1,43 @@
import config from '@/config'
import config from "@/config";
/**
* 基础路由(不需要登录)
*/
const systemRoutes = [
{
path: '/login',
name: 'Login',
component: () => import('../pages/login/index.vue'),
path: "/login",
name: "Login",
component: () => import("../pages/login/index.vue"),
meta: {
title: 'login',
title: "login",
hidden: true,
},
},
{
path: '/register',
name: 'Register',
component: () => import('../pages/login/userRegister.vue'),
path: "/register",
name: "Register",
component: () => import("../pages/login/userRegister.vue"),
meta: {
title: 'register',
title: "register",
hidden: true,
},
},
{
path: '/reset-password',
name: 'ResetPassword',
component: () => import('../pages/login/resetPassword.vue'),
path: "/reset-password",
name: "ResetPassword",
component: () => import("../pages/login/resetPassword.vue"),
meta: {
title: 'resetPassword',
title: "resetPassword",
hidden: true,
},
},
{
path: '/',
name: 'Layout',
component: () => import('@/layouts/index.vue'),
path: "/",
name: "Layout",
component: () => import("@/layouts/index.vue"),
redirect: config.DASHBOARD_URL,
children: [],
},
]
];
export default systemRoutes
export default systemRoutes;
+5 -5
View File
@@ -1,9 +1,9 @@
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
import { createPinia } from "pinia";
import piniaPluginPersistedstate from "pinia-plugin-persistedstate";
const pinia = createPinia()
const pinia = createPinia();
// 注册持久化插件
pinia.use(piniaPluginPersistedstate)
pinia.use(piniaPluginPersistedstate);
export default pinia
export default pinia;
@@ -1,21 +1,21 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { customStorage } from '../persist'
import systemApi from '@/api/system'
import { ref } from "vue";
import { defineStore } from "pinia";
import { customStorage } from "../persist";
import systemApi from "@/api/system";
export const useDictionaryStore = defineStore(
'dictionary',
"dictionary",
() => {
// 字典数据缓存(按 code 缓存字典项列表)
const dictionaries = ref({})
const dictionaries = ref({});
// 字典元数据缓存(按 code 缓存字典信息)
const dictionaryMeta = ref({})
const dictionaryMeta = ref({});
// 字典ID到Code的映射(用于通过ID清除缓存)
const dictionaryIdToCodeMap = ref({})
const dictionaryIdToCodeMap = ref({});
// 字典数据加载状态
const loading = ref(false)
const loading = ref(false);
// 最后加载时间
const lastLoadTime = ref(null)
const lastLoadTime = ref(null);
/**
* 加载所有字典数据
@@ -23,41 +23,41 @@ export const useDictionaryStore = defineStore(
async function loadAllDictionaries(forceRefresh = false) {
// 如果已加载且不是强制刷新,直接返回
if (!forceRefresh && Object.keys(dictionaries.value).length > 0) {
return
return;
}
if (loading.value) return
if (loading.value) return;
loading.value = true
loading.value = true;
try {
const res = await systemApi.dictionaryItems.all.get()
const res = await systemApi.dictionaryItems.all.get();
if (res.code === 200 && res.data) {
// 将字典数据按 code 缓存
const dictMap = {}
const metaMap = {}
const dictMap = {};
const metaMap = {};
res.data.forEach(dict => {
res.data.forEach((dict) => {
if (dict.code) {
// 缓存字典项列表
dictMap[dict.code] = dict.items || []
dictMap[dict.code] = dict.items || [];
// 缓存字典元数据(包含id以便通过id清除缓存)
metaMap[dict.code] = {
id: dict.code, // 使用code作为唯一标识
name: dict.name,
code: dict.code,
description: dict.description
}
description: dict.description,
};
}
})
});
dictionaries.value = dictMap
dictionaryMeta.value = metaMap
lastLoadTime.value = new Date().getTime()
dictionaries.value = dictMap;
dictionaryMeta.value = metaMap;
lastLoadTime.value = new Date().getTime();
}
} catch (error) {
console.error('加载字典数据失败:', error)
console.error("加载字典数据失败:", error);
} finally {
loading.value = false
loading.value = false;
}
}
@@ -69,11 +69,15 @@ export const useDictionaryStore = defineStore(
*/
async function getDictionary(code, forceRefresh = false) {
// 如果缓存为空或强制刷新,则重新加载所有字典
if (!dictionaries.value || Object.keys(dictionaries.value).length === 0 || forceRefresh) {
await loadAllDictionaries()
if (
!dictionaries.value ||
Object.keys(dictionaries.value).length === 0 ||
forceRefresh
) {
await loadAllDictionaries();
}
return dictionaries.value[code] || []
return dictionaries.value[code] || [];
}
/**
@@ -84,15 +88,19 @@ export const useDictionaryStore = defineStore(
*/
async function getDictionaries(codes, forceRefresh = false) {
// 如果缓存为空或强制刷新,则重新加载所有字典
if (!dictionaries.value || Object.keys(dictionaries.value).length === 0 || forceRefresh) {
await loadAllDictionaries()
if (
!dictionaries.value ||
Object.keys(dictionaries.value).length === 0 ||
forceRefresh
) {
await loadAllDictionaries();
}
const result = {}
codes.forEach(code => {
result[code] = dictionaries.value[code] || []
})
return result
const result = {};
codes.forEach((code) => {
result[code] = dictionaries.value[code] || [];
});
return result;
}
/**
@@ -102,21 +110,21 @@ export const useDictionaryStore = defineStore(
* @returns {string} 字典标签
*/
function getLabelByValue(code, value) {
const dict = dictionaries.value[code]
if (!dict) return value
const dict = dictionaries.value[code];
if (!dict) return value;
const item = dict.find(item => item.value === value)
return item ? item.label : value
const item = dict.find((item) => item.value === value);
return item ? item.label : value;
}
/**
* 清空字典缓存
*/
function clearCache() {
dictionaries.value = {}
dictionaryMeta.value = {}
dictionaryIdToCodeMap.value = {}
lastLoadTime.value = null
dictionaries.value = {};
dictionaryMeta.value = {};
dictionaryIdToCodeMap.value = {};
lastLoadTime.value = null;
}
/**
@@ -125,19 +133,22 @@ export const useDictionaryStore = defineStore(
*/
function clearDictionary(dictionaryIdOrCode) {
// 如果传入的是字典ID,需要先找到对应的code
let code = dictionaryIdOrCode
let code = dictionaryIdOrCode;
if (typeof dictionaryIdOrCode === 'number') {
if (typeof dictionaryIdOrCode === "number") {
// 直接从ID映射表中查找
code = dictionaryIdToCodeMap.value[dictionaryIdOrCode]
code = dictionaryIdToCodeMap.value[dictionaryIdOrCode];
}
// 删除对应字典的缓存
if (code && dictionaries.value[code]) {
delete dictionaries.value[code]
delete dictionaryMeta.value[code]
if (dictionaryIdOrCode && typeof dictionaryIdOrCode === 'number') {
delete dictionaryIdToCodeMap.value[dictionaryIdOrCode]
delete dictionaries.value[code];
delete dictionaryMeta.value[code];
if (
dictionaryIdOrCode &&
typeof dictionaryIdOrCode === "number"
) {
delete dictionaryIdToCodeMap.value[dictionaryIdOrCode];
}
}
}
@@ -147,11 +158,11 @@ export const useDictionaryStore = defineStore(
* @param {Array} dictionaryList 字典列表(包含id和code
*/
function buildIdToCodeMap(dictionaryList) {
dictionaryList.forEach(dict => {
dictionaryList.forEach((dict) => {
if (dict.id && dict.code) {
dictionaryIdToCodeMap.value[dict.id] = dict.code
dictionaryIdToCodeMap.value[dict.id] = dict.code;
}
})
});
}
/**
@@ -159,7 +170,7 @@ export const useDictionaryStore = defineStore(
* @param {boolean} force 是否强制刷新
*/
async function refresh(force = true) {
await loadAllDictionaries(force)
await loadAllDictionaries(force);
}
/**
@@ -168,8 +179,8 @@ export const useDictionaryStore = defineStore(
function getCacheInfo() {
return {
count: Object.keys(dictionaries.value).length,
lastLoadTime: lastLoadTime.value
}
lastLoadTime: lastLoadTime.value,
};
}
return {
@@ -186,14 +197,14 @@ export const useDictionaryStore = defineStore(
clearDictionary,
buildIdToCodeMap,
refresh,
getCacheInfo
}
getCacheInfo,
};
},
{
persist: {
key: 'dictionary-store',
key: "dictionary-store",
storage: customStorage,
pick: ['dictionaries', 'lastLoadTime']
}
}
)
pick: ["dictionaries", "lastLoadTime"],
},
},
);
+29 -30
View File
@@ -1,36 +1,35 @@
import { defineStore } from 'pinia'
import i18n from '@/i18n'
import { customStorage } from '../persist'
import { defineStore } from "pinia";
import i18n from "@/i18n";
import { customStorage } from "../persist";
export const useI18nStore = defineStore(
'i18n',
{
state: () => ({
currentLocale: 'zh-CN',
availableLocales: [
{ label: '简体中文', value: 'zh-CN' },
{ label: 'English', value: 'en-US' }
]
}),
export const useI18nStore = defineStore("i18n", {
state: () => ({
currentLocale: "zh-CN",
availableLocales: [
{ label: "简体中文", value: "zh-CN" },
{ label: "English", value: "en-US" },
],
}),
getters: {
localeLabel: (state) => {
const locale = state.availableLocales.find((item) => item.value === state.currentLocale)
return locale ? locale.label : ''
}
getters: {
localeLabel: (state) => {
const locale = state.availableLocales.find(
(item) => item.value === state.currentLocale,
);
return locale ? locale.label : "";
},
},
actions: {
setLocale(locale) {
this.currentLocale = locale
i18n.global.locale.value = locale
}
actions: {
setLocale(locale) {
this.currentLocale = locale;
i18n.global.locale.value = locale;
},
},
persist: {
key: 'i18n-store',
storage: customStorage,
pick: ['currentLocale']
}
}
)
persist: {
key: "i18n-store",
storage: customStorage,
pick: ["currentLocale"],
},
});
+67 -47
View File
@@ -1,102 +1,115 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { customStorage } from '../persist'
import { defineStore } from "pinia";
import { ref } from "vue";
import { customStorage } from "../persist";
export const useLayoutStore = defineStore(
'layout',
"layout",
() => {
// 布局模式:'default', 'menu', 'top'
const layoutMode = ref('default')
const layoutMode = ref("default");
// 侧边栏折叠状态
const sidebarCollapsed = ref(false)
const sidebarCollapsed = ref(false);
// 主题颜色
const themeColor = ref('#1890ff')
const themeColor = ref("#1890ff");
// 显示标签栏
const showTags = ref(true)
const showTags = ref(true);
// 显示面包屑
const showBreadcrumb = ref(true)
const showBreadcrumb = ref(true);
// 当前选中的父菜单(用于双栏布局)
const selectedParentMenu = ref(null)
const selectedParentMenu = ref(null);
// 视图标签页(用于记录页面滚动位置)
const viewTags = ref([])
const viewTags = ref([]);
// 刷新标签的 key,用于触发组件刷新
const refreshKey = ref(0)
const refreshKey = ref(0);
// 切换侧边栏折叠
const toggleSidebar = () => {
sidebarCollapsed.value = !sidebarCollapsed.value
}
sidebarCollapsed.value = !sidebarCollapsed.value;
};
// 设置选中的父菜单
const setSelectedParentMenu = (menu) => {
selectedParentMenu.value = menu
}
selectedParentMenu.value = menu;
};
// 设置布局模式
const setLayoutMode = (mode) => {
layoutMode.value = mode
}
layoutMode.value = mode;
};
// 更新视图标签
const updateViewTags = (tag) => {
const index = viewTags.value.findIndex((item) => item.fullPath === tag.fullPath)
const index = viewTags.value.findIndex(
(item) => item.fullPath === tag.fullPath,
);
if (index !== -1) {
viewTags.value[index] = tag
viewTags.value[index] = tag;
} else {
viewTags.value.push(tag)
viewTags.value.push(tag);
}
}
};
// 移除视图标签
const removeViewTags = (fullPath) => {
const index = viewTags.value.findIndex((item) => item.fullPath === fullPath)
const index = viewTags.value.findIndex(
(item) => item.fullPath === fullPath,
);
if (index !== -1) {
viewTags.value.splice(index, 1)
viewTags.value.splice(index, 1);
}
}
};
// 清空视图标签
const clearViewTags = () => {
viewTags.value = []
}
viewTags.value = [];
};
// 设置主题颜色
const setThemeColor = (color) => {
themeColor.value = color
document.documentElement.style.setProperty('--primary-color', color)
}
themeColor.value = color;
document.documentElement.style.setProperty(
"--primary-color",
color,
);
};
// 设置标签栏显示
const setShowTags = (show) => {
showTags.value = show
document.documentElement.style.setProperty('--show-tags', show ? 'block' : 'none')
}
showTags.value = show;
document.documentElement.style.setProperty(
"--show-tags",
show ? "block" : "none",
);
};
// 设置面包屑显示
const setShowBreadcrumb = (show) => {
showBreadcrumb.value = show
}
showBreadcrumb.value = show;
};
// 刷新标签
const refreshTag = () => {
refreshKey.value++
}
refreshKey.value++;
};
// 重置主题设置
const resetTheme = () => {
themeColor.value = '#1890ff'
showTags.value = true
showBreadcrumb.value = true
document.documentElement.style.setProperty('--primary-color', '#1890ff')
document.documentElement.style.setProperty('--show-tags', 'block')
}
themeColor.value = "#1890ff";
showTags.value = true;
showBreadcrumb.value = true;
document.documentElement.style.setProperty(
"--primary-color",
"#1890ff",
);
document.documentElement.style.setProperty("--show-tags", "block");
};
return {
layoutMode,
@@ -118,13 +131,20 @@ export const useLayoutStore = defineStore(
setShowBreadcrumb,
resetTheme,
refreshTag,
}
};
},
{
persist: {
key: 'layout-store',
key: "layout-store",
storage: customStorage,
pick: ['layoutMode', 'sidebarCollapsed', 'themeColor', 'showTags', 'showBreadcrumb', 'viewTags'],
pick: [
"layoutMode",
"sidebarCollapsed",
"themeColor",
"showTags",
"showBreadcrumb",
"viewTags",
],
},
},
)
);
+113 -105
View File
@@ -1,52 +1,52 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import { customStorage } from '../persist'
import { ref, computed } from "vue";
import { defineStore } from "pinia";
import { customStorage } from "../persist";
/**
* 消息类型枚举
*/
export const MessageType = {
NOTIFICATION: 'notification', // 系统通知
TASK: 'task', // 任务提醒
WARNING: 'warning', // 警告
ERROR: 'error', // 错误
SUCCESS: 'success', // 成功
INFO: 'info' // 信息
}
NOTIFICATION: "notification", // 系统通知
TASK: "task", // 任务提醒
WARNING: "warning", // 警告
ERROR: "error", // 错误
SUCCESS: "success", // 成功
INFO: "info", // 信息
};
/**
* 消息优先级枚举
*/
export const MessagePriority = {
LOW: 'low',
MEDIUM: 'medium',
HIGH: 'high',
URGENT: 'urgent'
}
LOW: "low",
MEDIUM: "medium",
HIGH: "high",
URGENT: "urgent",
};
export const useMessageStore = defineStore(
'message',
"message",
() => {
// 消息列表
const messages = ref([])
const messages = ref([]);
// 最大消息数量
const maxMessages = 100
const maxMessages = 100;
// 获取未读消息数量
const unreadCount = computed(() => {
return messages.value.filter((m) => !m.read).length
})
return messages.value.filter((m) => !m.read).length;
});
// 获取所有消息数量
const totalCount = computed(() => {
return messages.value.length
})
return messages.value.length;
});
// 根据类型获取消息数量
const getCountByType = (type) => {
return messages.value.filter((m) => m.type === type).length
}
return messages.value.filter((m) => m.type === type).length;
};
// 添加消息
function addMessage(message) {
@@ -54,193 +54,201 @@ export const useMessageStore = defineStore(
id: Date.now() + Math.random(),
type: MessageType.NOTIFICATION,
priority: MessagePriority.MEDIUM,
title: '',
content: '',
title: "",
content: "",
read: false,
timestamp: Date.now(),
...message
}
...message,
};
// 添加到列表开头
messages.value.unshift(newMessage)
messages.value.unshift(newMessage);
// 限制消息数量
if (messages.value.length > maxMessages) {
messages.value = messages.value.slice(0, maxMessages)
messages.value = messages.value.slice(0, maxMessages);
}
// 持久化到 localStorage
persistMessages()
persistMessages();
return newMessage
return newMessage;
}
// 批量添加消息
function addMessages(newMessages) {
newMessages.forEach((msg) => addMessage(msg))
newMessages.forEach((msg) => addMessage(msg));
}
// 标记消息为已读
function markAsRead(messageId) {
const message = messages.value.find((m) => m.id === messageId)
const message = messages.value.find((m) => m.id === messageId);
if (message) {
message.read = true
persistMessages()
message.read = true;
persistMessages();
}
}
// 标记所有消息为已读
function markAllAsRead() {
messages.value.forEach((m) => {
m.read = true
})
persistMessages()
m.read = true;
});
persistMessages();
}
// 删除消息
function removeMessage(messageId) {
const index = messages.value.findIndex((m) => m.id === messageId)
const index = messages.value.findIndex((m) => m.id === messageId);
if (index !== -1) {
messages.value.splice(index, 1)
persistMessages()
messages.value.splice(index, 1);
persistMessages();
}
}
// 清空所有消息
function clearAll() {
messages.value = []
persistMessages()
messages.value = [];
persistMessages();
}
// 清空已读消息
function clearRead() {
messages.value = messages.value.filter((m) => !m.read)
persistMessages()
messages.value = messages.value.filter((m) => !m.read);
persistMessages();
}
// 根据类型清空消息
function clearByType(type) {
messages.value = messages.value.filter((m) => m.type !== type)
persistMessages()
messages.value = messages.value.filter((m) => m.type !== type);
persistMessages();
}
// 格式化消息时间
function formatMessageTime(timestamp) {
const now = Date.now()
const diff = now - timestamp
const now = Date.now();
const diff = now - timestamp;
const minute = 60 * 1000
const hour = 60 * minute
const day = 24 * hour
const minute = 60 * 1000;
const hour = 60 * minute;
const day = 24 * hour;
if (diff < minute) {
return '刚刚'
return "刚刚";
} else if (diff < hour) {
return `${Math.floor(diff / minute)}分钟前`
return `${Math.floor(diff / minute)}分钟前`;
} else if (diff < day) {
return `${Math.floor(diff / hour)}小时前`
return `${Math.floor(diff / hour)}小时前`;
} else if (diff < 7 * day) {
return `${Math.floor(diff / day)}天前`
return `${Math.floor(diff / day)}天前`;
} else {
const date = new Date(timestamp)
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
const date = new Date(timestamp);
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
}
}
// 持久化消息到 localStorage
function persistMessages() {
try {
localStorage.setItem('message-store', JSON.stringify(messages.value))
localStorage.setItem(
"message-store",
JSON.stringify(messages.value),
);
} catch (error) {
console.error('持久化消息失败:', error)
console.error("持久化消息失败:", error);
}
}
// 从 localStorage 恢复消息
function restoreMessages() {
try {
const stored = localStorage.getItem('message-store')
const stored = localStorage.getItem("message-store");
if (stored) {
messages.value = JSON.parse(stored)
messages.value = JSON.parse(stored);
}
} catch (error) {
console.error('恢复消息失败:', error)
messages.value = []
console.error("恢复消息失败:", error);
messages.value = [];
}
}
// 处理 WebSocket 消息
function handleWebSocketMessage(data) {
const { type, title, message, content, ...extra } = data
const { type, title, message, content, ...extra } = data;
addMessage({
type: type || MessageType.NOTIFICATION,
title: title || '系统通知',
content: message || content || '',
...extra
})
title: title || "系统通知",
content: message || content || "",
...extra,
});
}
// 处理数据更新消息
function handleDataUpdate(data) {
const { resource_type, action } = data
const { resource_type, action } = data;
let title = '数据更新'
let content = ''
let title = "数据更新";
let content = "";
switch (action) {
case 'create':
title = '新建成功'
content = `新的${resource_type}已创建`
break
case 'update':
title = '更新成功'
content = `${resource_type}数据已更新`
break
case 'delete':
title = '删除成功'
content = `${resource_type}数据已删除`
break
case "create":
title = "新建成功";
content = `新的${resource_type}已创建`;
break;
case "update":
title = "更新成功";
content = `${resource_type}数据已更新`;
break;
case "delete":
title = "删除成功";
content = `${resource_type}数据已删除`;
break;
default:
content = `${resource_type}数据已${action}`
content = `${resource_type}数据已${action}`;
}
addMessage({
type: MessageType.SUCCESS,
title,
content
})
content,
});
}
// 获取消息列表(带分页)
function getMessages(options = {}) {
const { page = 1, pageSize = 20, type = null, read = null } = options
const {
page = 1,
pageSize = 20,
type = null,
read = null,
} = options;
let filtered = [...messages.value]
let filtered = [...messages.value];
// 按类型过滤
if (type) {
filtered = filtered.filter((m) => m.type === type)
filtered = filtered.filter((m) => m.type === type);
}
// 按已读状态过滤
if (read !== null) {
filtered = filtered.filter((m) => m.read === read)
filtered = filtered.filter((m) => m.read === read);
}
// 分页
const start = (page - 1) * pageSize
const end = start + pageSize
const list = filtered.slice(start, end)
const total = filtered.length
const start = (page - 1) * pageSize;
const end = start + pageSize;
const list = filtered.slice(start, end);
const total = filtered.length;
return {
list,
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize)
}
totalPages: Math.ceil(total / pageSize),
};
}
return {
@@ -262,14 +270,14 @@ export const useMessageStore = defineStore(
handleDataUpdate,
getMessages,
getCountByType,
restoreMessages
}
restoreMessages,
};
},
{
persist: {
key: 'message-store',
key: "message-store",
storage: customStorage,
pick: [] // 不自动持久化,使用自定义 persistMessages 方法
}
}
)
pick: [], // 不自动持久化,使用自定义 persistMessages 方法
},
},
);
+199 -180
View File
@@ -1,391 +1,410 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import { customStorage } from '../persist'
import systemApi from '@/api/system'
import { message } from 'ant-design-vue'
import { ref, computed } from "vue";
import { defineStore } from "pinia";
import { customStorage } from "../persist";
import systemApi from "@/api/system";
import { message } from "ant-design-vue";
/**
* 通知类型枚举
*/
export const NotificationType = {
INFO: 'info', // 信息
SUCCESS: 'success', // 成功
WARNING: 'warning', // 警告
ERROR: 'error', // 错误
TASK: 'task', // 任务
SYSTEM: 'system' // 系统
}
INFO: "info", // 信息
SUCCESS: "success", // 成功
WARNING: "warning", // 警告
ERROR: "error", // 错误
TASK: "task", // 任务
SYSTEM: "system", // 系统
};
/**
* 通知分类枚举
*/
export const NotificationCategory = {
SYSTEM: 'system', // 系统通知
TASK: 'task', // 任务通知
MESSAGE: 'message', // 消息通知
REMINDER: 'reminder', // 提醒通知
ANNOUNCEMENT: 'announcement' // 公告通知
}
SYSTEM: "system", // 系统通知
TASK: "task", // 任务通知
MESSAGE: "message", // 消息通知
REMINDER: "reminder", // 提醒通知
ANNOUNCEMENT: "announcement", // 公告通知
};
/**
* 通知操作类型枚举
*/
export const NotificationActionType = {
NONE: 'none', // 无操作
LINK: 'link', // 跳转链接
MODAL: 'modal' // 打开弹窗
}
NONE: "none", // 无操作
LINK: "link", // 跳转链接
MODAL: "modal", // 打开弹窗
};
export const useNotificationStore = defineStore(
'notification',
"notification",
() => {
// 通知列表
const notifications = ref([])
const notifications = ref([]);
// 未读数量
const unreadCount = ref(0)
const unreadCount = ref(0);
// 加载状态
const loading = ref(false)
const loading = ref(false);
// 当前页码
const currentPage = ref(1)
const currentPage = ref(1);
// 每页数量
const pageSize = ref(20)
const pageSize = ref(20);
// 总数量
const total = ref(0)
const total = ref(0);
// 获取未读数量(计算属性)
const unreadCountComputed = computed(() => unreadCount.value)
const unreadCountComputed = computed(() => unreadCount.value);
// 获取已读数量
const readCount = computed(() => total.value - unreadCount.value)
const readCount = computed(() => total.value - unreadCount.value);
// 获取通知列表
async function fetchNotifications(params = {}) {
try {
loading.value = true
loading.value = true;
const res = await systemApi.notifications.list.get({
page: params.page || currentPage.value,
page_size: params.page_size || pageSize.value,
...params
})
...params,
});
notifications.value = res.data.list || []
total.value = res.data.total || 0
currentPage.value = res.data.page || 1
pageSize.value = res.data.page_size || 20
notifications.value = res.data.list || [];
total.value = res.data.total || 0;
currentPage.value = res.data.page || 1;
pageSize.value = res.data.page_size || 20;
return res.data
return res.data;
} catch (error) {
message.error(error.message || '获取通知列表失败')
throw error
message.error(error.message || "获取通知列表失败");
throw error;
} finally {
loading.value = false
loading.value = false;
}
}
// 获取未读通知列表
async function fetchUnreadNotifications(params = {}) {
try {
loading.value = true
loading.value = true;
const res = await systemApi.notifications.unread.get({
page: params.page || 1,
page_size: params.page_size || 10,
...params
})
...params,
});
return res.data
return res.data;
} catch (error) {
message.error(error.message || '获取未读通知失败')
throw error
message.error(error.message || "获取未读通知失败");
throw error;
} finally {
loading.value = false
loading.value = false;
}
}
// 获取未读数量
async function fetchUnreadCount() {
try {
const res = await systemApi.notifications.unreadCount.get()
unreadCount.value = res.data.count || 0
return unreadCount.value
const res = await systemApi.notifications.unreadCount.get();
unreadCount.value = res.data.count || 0;
return unreadCount.value;
} catch (error) {
console.error('获取未读数量失败:', error)
return 0
console.error("获取未读数量失败:", error);
return 0;
}
}
// 标记为已读
async function markAsRead(id) {
try {
await systemApi.notifications.markAsRead.post(id)
await systemApi.notifications.markAsRead.post(id);
// 更新本地状态
const notification = notifications.value.find(n => n.id === id)
const notification = notifications.value.find(
(n) => n.id === id,
);
if (notification && !notification.is_read) {
notification.is_read = true
notification.read_at = new Date().toISOString()
unreadCount.value = Math.max(0, unreadCount.value - 1)
notification.is_read = true;
notification.read_at = new Date().toISOString();
unreadCount.value = Math.max(0, unreadCount.value - 1);
}
return true
return true;
} catch (error) {
message.error(error.message || '标记已读失败')
throw error
message.error(error.message || "标记已读失败");
throw error;
}
}
// 批量标记为已读
async function batchMarkAsRead(ids) {
try {
const res = await systemApi.notifications.batchMarkAsRead.post({ ids })
const res = await systemApi.notifications.batchMarkAsRead.post({
ids,
});
// 更新本地状态
ids.forEach(id => {
const notification = notifications.value.find(n => n.id === id)
ids.forEach((id) => {
const notification = notifications.value.find(
(n) => n.id === id,
);
if (notification && !notification.is_read) {
notification.is_read = true
notification.read_at = new Date().toISOString()
notification.is_read = true;
notification.read_at = new Date().toISOString();
}
})
});
// 更新未读数量
unreadCount.value = Math.max(0, unreadCount.value - (res.data.count || ids.length))
unreadCount.value = Math.max(
0,
unreadCount.value - (res.data.count || ids.length),
);
return res.data
return res.data;
} catch (error) {
message.error(error.message || '批量标记已读失败')
throw error
message.error(error.message || "批量标记已读失败");
throw error;
}
}
// 标记全部为已读
async function markAllAsRead() {
try {
await systemApi.notifications.markAllAsRead.post()
await systemApi.notifications.markAllAsRead.post();
// 更新本地状态
notifications.value.forEach(n => {
n.is_read = true
n.read_at = n.read_at || new Date().toISOString()
})
unreadCount.value = 0
notifications.value.forEach((n) => {
n.is_read = true;
n.read_at = n.read_at || new Date().toISOString();
});
unreadCount.value = 0;
return true
return true;
} catch (error) {
message.error(error.message || '标记全部已读失败')
throw error
message.error(error.message || "标记全部已读失败");
throw error;
}
}
// 删除通知
async function deleteNotification(id) {
try {
await systemApi.notifications.delete.delete(id)
await systemApi.notifications.delete.delete(id);
// 更新本地状态
const index = notifications.value.findIndex(n => n.id === id)
const index = notifications.value.findIndex((n) => n.id === id);
if (index !== -1) {
const notification = notifications.value[index]
const notification = notifications.value[index];
if (!notification.is_read) {
unreadCount.value = Math.max(0, unreadCount.value - 1)
unreadCount.value = Math.max(0, unreadCount.value - 1);
}
notifications.value.splice(index, 1)
total.value = Math.max(0, total.value - 1)
notifications.value.splice(index, 1);
total.value = Math.max(0, total.value - 1);
}
return true
return true;
} catch (error) {
message.error(error.message || '删除通知失败')
throw error
message.error(error.message || "删除通知失败");
throw error;
}
}
// 批量删除通知
async function batchDeleteNotification(ids) {
try {
const res = await systemApi.notifications.batchDelete.post({ ids })
const res = await systemApi.notifications.batchDelete.post({
ids,
});
// 更新本地状态
const deletedCount = 0
ids.forEach(id => {
const index = notifications.value.findIndex(n => n.id === id)
const deletedCount = 0;
ids.forEach((id) => {
const index = notifications.value.findIndex(
(n) => n.id === id,
);
if (index !== -1) {
const notification = notifications.value[index]
const notification = notifications.value[index];
if (!notification.is_read) {
unreadCount.value = Math.max(0, unreadCount.value - 1)
unreadCount.value = Math.max(
0,
unreadCount.value - 1,
);
}
notifications.value.splice(index, 1)
notifications.value.splice(index, 1);
}
})
total.value = Math.max(0, total.value - ids.length)
});
total.value = Math.max(0, total.value - ids.length);
return res.data
return res.data;
} catch (error) {
message.error(error.message || '批量删除通知失败')
throw error
message.error(error.message || "批量删除通知失败");
throw error;
}
}
// 清空已读通知
async function clearReadNotifications() {
try {
await systemApi.notifications.clearRead.post()
await systemApi.notifications.clearRead.post();
// 更新本地状态
notifications.value = notifications.value.filter(n => !n.is_read)
total.value = notifications.value.length
notifications.value = notifications.value.filter(
(n) => !n.is_read,
);
total.value = notifications.value.length;
return true
return true;
} catch (error) {
message.error(error.message || '清空已读通知失败')
throw error
message.error(error.message || "清空已读通知失败");
throw error;
}
}
// 获取通知详情
async function getNotificationDetail(id) {
try {
const res = await systemApi.notifications.detail.get(id)
return res.data
const res = await systemApi.notifications.detail.get(id);
return res.data;
} catch (error) {
message.error(error.message || '获取通知详情失败')
throw error
message.error(error.message || "获取通知详情失败");
throw error;
}
}
// 发送通知(管理员功能)
async function sendNotification(params) {
try {
const res = await systemApi.notifications.send.post(params)
message.success('发送通知成功')
return res.data
const res = await systemApi.notifications.send.post(params);
message.success("发送通知成功");
return res.data;
} catch (error) {
message.error(error.message || '发送通知失败')
throw error
message.error(error.message || "发送通知失败");
throw error;
}
}
// 重试发送失败的通知(管理员功能)
async function retryUnsentNotifications(params = {}) {
try {
const res = await systemApi.notifications.retryUnsent.post(params)
message.success('重试发送成功')
return res.data
const res =
await systemApi.notifications.retryUnsent.post(params);
message.success("重试发送成功");
return res.data;
} catch (error) {
message.error(error.message || '重试发送失败')
throw error
message.error(error.message || "重试发送失败");
throw error;
}
}
// 获取通知统计信息
async function fetchStatistics() {
try {
const res = await systemApi.notifications.statistics.get()
return res.data
const res = await systemApi.notifications.statistics.get();
return res.data;
} catch (error) {
message.error(error.message || '获取统计信息失败')
throw error
message.error(error.message || "获取统计信息失败");
throw error;
}
}
// 处理 WebSocket 消息
function handleWebSocketMessage(data) {
if (data.type === 'notification') {
const notification = data.data
if (data.type === "notification") {
const notification = data.data;
// 添加到通知列表顶部
notifications.value.unshift(notification)
total.value++
notifications.value.unshift(notification);
total.value++;
// 如果是未读,增加未读数量
if (!notification.is_read) {
unreadCount.value++
unreadCount.value++;
}
// 限制本地存储的通知数量
if (notifications.value.length > 100) {
notifications.value = notifications.value.slice(0, 100)
notifications.value = notifications.value.slice(0, 100);
}
// 显示通知提示
message.info(`新通知: ${notification.title}`)
message.info(`新通知: ${notification.title}`);
}
}
// 格式化通知时间
function formatNotificationTime(timestamp) {
if (!timestamp) return '-'
if (!timestamp) return "-";
const now = Date.now()
const diff = now - new Date(timestamp).getTime()
const now = Date.now();
const diff = now - new Date(timestamp).getTime();
const minute = 60 * 1000
const hour = 60 * minute
const day = 24 * hour
const minute = 60 * 1000;
const hour = 60 * minute;
const day = 24 * hour;
if (diff < minute) {
return '刚刚'
return "刚刚";
} else if (diff < hour) {
return `${Math.floor(diff / minute)}分钟前`
return `${Math.floor(diff / minute)}分钟前`;
} else if (diff < day) {
return `${Math.floor(diff / hour)}小时前`
return `${Math.floor(diff / hour)}小时前`;
} else if (diff < 7 * day) {
return `${Math.floor(diff / day)}天前`
return `${Math.floor(diff / day)}天前`;
} else {
const date = new Date(timestamp)
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
const date = new Date(timestamp);
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
}
}
// 获取通知类型文本
function getNotificationTypeText(type) {
const texts = {
info: '信息',
success: '成功',
warning: '警告',
error: '错误',
task: '任务',
system: '系统'
}
return texts[type] || type
info: "信息",
success: "成功",
warning: "警告",
error: "错误",
task: "任务",
system: "系统",
};
return texts[type] || type;
}
// 获取通知类型颜色
function getNotificationTypeColor(type) {
const colors = {
info: 'blue',
success: 'green',
warning: 'orange',
error: 'red',
task: 'purple',
system: 'cyan'
}
return colors[type] || 'default'
info: "blue",
success: "green",
warning: "orange",
error: "red",
task: "purple",
system: "cyan",
};
return colors[type] || "default";
}
// 获取通知分类文本
function getNotificationCategoryText(category) {
const texts = {
system: '系统通知',
task: '任务通知',
message: '消息通知',
reminder: '提醒通知',
announcement: '公告通知'
}
return texts[category] || category
system: "系统通知",
task: "任务通知",
message: "消息通知",
reminder: "提醒通知",
announcement: "公告通知",
};
return texts[category] || category;
}
// 重置状态
function reset() {
notifications.value = []
unreadCount.value = 0
currentPage.value = 1
total.value = 0
loading.value = false
notifications.value = [];
unreadCount.value = 0;
currentPage.value = 1;
total.value = 0;
loading.value = false;
}
return {
@@ -418,14 +437,14 @@ export const useNotificationStore = defineStore(
reset,
NotificationType,
NotificationCategory,
NotificationActionType
}
NotificationActionType,
};
},
{
persist: {
key: 'notification-store',
key: "notification-store",
storage: customStorage,
pick: [] // 不自动持久化,通知数据从服务器获取
}
}
)
pick: [], // 不自动持久化,通知数据从服务器获取
},
},
);
+40 -40
View File
@@ -1,93 +1,93 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { resetRouter } from '../../router'
import { customStorage } from '../persist'
import userRoutes from '@/config/routes'
import { ref } from "vue";
import { defineStore } from "pinia";
import { resetRouter } from "../../router";
import { customStorage } from "../persist";
import userRoutes from "@/config/routes";
export const useUserStore = defineStore(
'user',
export const useUserStore = defineStore(
"user",
() => {
const token = ref('')
const userInfo = ref(null)
const menu = ref([])
const permissions = ref([])
const token = ref("");
const userInfo = ref(null);
const menu = ref([]);
const permissions = ref([]);
// 设置 token
function setToken(newToken) {
token.value = newToken
token.value = newToken;
}
// 设置用户信息
function setUserInfo(info) {
userInfo.value = info
userInfo.value = info;
}
// 设置菜单
function setMenu(newMenu) {
const staticMenus = userRoutes || []
const staticMenus = userRoutes || [];
// 合并静态菜单和后端菜单
// 如果后端菜单为空,只使用静态菜单
// 如果后端菜单不为空,合并两个菜单,后端菜单优先
let mergedMenus = [...staticMenus]
let mergedMenus = [...staticMenus];
if (newMenu && newMenu.length > 0) {
// 创建菜单映射,用于去重(以路径为唯一标识)
const menuMap = new Map()
const menuMap = new Map();
// 先添加静态菜单
staticMenus.forEach(menu => {
staticMenus.forEach((menu) => {
if (menu.path) {
menuMap.set(menu.path, menu)
menuMap.set(menu.path, menu);
}
})
});
// 添加后端菜单,如果路径重复则覆盖
newMenu.forEach(menu => {
newMenu.forEach((menu) => {
if (menu.path) {
menuMap.set(menu.path, menu)
menuMap.set(menu.path, menu);
}
})
});
// 转换为数组
mergedMenus = Array.from(menuMap.values())
mergedMenus = Array.from(menuMap.values());
}
menu.value = mergedMenus
menu.value = mergedMenus;
}
// 获取菜单
function getMenu() {
return menu.value
return menu.value;
}
// 清除菜单
function clearMenu() {
menu.value = []
menu.value = [];
}
// 设置权限
function setPermissions(data){
permissions.value = data
function setPermissions(data) {
permissions.value = data;
}
// 登出
function logout() {
token.value = ''
userInfo.value = null
menu.value = []
token.value = "";
userInfo.value = null;
menu.value = [];
// 重置路由
resetRouter()
resetRouter();
}
// 检查是否已登录
function isLoggedIn() {
return !!token.value
return !!token.value;
}
// 检查用户信息是否完整(用于 WebSocket 初始化)
function isUserInfoComplete() {
return !!(token.value && userInfo.value && userInfo.value.id)
return !!(token.value && userInfo.value && userInfo.value.id);
}
return {
@@ -103,13 +103,13 @@ import userRoutes from '@/config/routes'
logout,
isLoggedIn,
isUserInfoComplete,
}
};
},
{
persist: {
key: 'user-store',
key: "user-store",
storage: customStorage,
pick: ['token', 'userInfo', 'menu']
}
}
)
pick: ["token", "userInfo", "menu"],
},
},
);
+7 -7
View File
@@ -3,7 +3,7 @@
* @version: 1.0
*/
import tool from '@/utils/tool'
import tool from "@/utils/tool";
/**
* 自定义存储适配器
@@ -16,7 +16,7 @@ export const customStorage = {
* @returns {any} - 存储的数据
*/
getItem: (key) => {
return tool.data.get(key)
return tool.data.get(key);
},
/**
@@ -25,7 +25,7 @@ export const customStorage = {
* @param {any} value - 要存储的值
*/
setItem: (key, value) => {
tool.data.set(key, value)
tool.data.set(key, value);
},
/**
@@ -33,9 +33,9 @@ export const customStorage = {
* @param {string} key - 存储键
*/
removeItem: (key) => {
tool.data.remove(key)
}
}
tool.data.remove(key);
},
};
/**
* 默认持久化配置
@@ -47,4 +47,4 @@ export const defaultPersistConfig = {
// serialize: (state) => JSON.stringify(state),
// deserialize: (value) => JSON.parse(value)
// }
}
};
+47 -47
View File
@@ -1,79 +1,79 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
outline: 4px auto -webkit-focus-ring-color;
}
.card {
padding: 2em;
padding: 2em;
}
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}
+63 -63
View File
@@ -5,88 +5,88 @@ import { message } from "ant-design-vue";
import router from "@/router";
const request = axios.create({
timeout: 30000,
baseURL: config.API_URL,
timeout: 30000,
baseURL: config.API_URL,
});
// 请求拦截器
request.interceptors.request.use(
(config) => {
const userStore = useUserStore();
const token = userStore.token;
(config) => {
const userStore = useUserStore();
const token = userStore.token;
// 如果有 token,添加到请求头
if (token) {
config.headers["Authorization"] = `Bearer ${token}`;
}
// 如果有 token,添加到请求头
if (token) {
config.headers["Authorization"] = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
},
return config;
},
(error) => {
return Promise.reject(error);
},
);
// 响应拦截器
request.interceptors.response.use(
(response) => {
// 根据后端返回的数据结构进行处理
// 后端返回格式为 { code, message, data }
const { code, data, message: msg } = response.data;
(response) => {
// 根据后端返回的数据结构进行处理
// 后端返回格式为 { code, message, data }
const { code, data, message: msg } = response.data;
// 请求成功
if (code === 200 || code === 1) {
return { code, data, message: msg };
}
// 请求成功
if (code === 200 || code === 1) {
return { code, data, message: msg };
}
// 其他错误码处理
message.error(msg || "请求失败");
return Promise.reject(new Error(msg || "请求失败"));
},
async (error) => {
const userStore = useUserStore();
const { response } = error;
// 其他错误码处理
message.error(msg || "请求失败");
return Promise.reject(new Error(msg || "请求失败"));
},
async (error) => {
const userStore = useUserStore();
const { response } = error;
// 无响应(网络错误、超时等)
if (!response) {
message.error("网络错误,请检查网络连接");
return Promise.reject(error);
}
// 无响应(网络错误、超时等)
if (!response) {
message.error("网络错误,请检查网络连接");
return Promise.reject(error);
}
const { status, data } = response;
const { status, data } = response;
// 401 未授权 - token 过期或无效
if (status === 401) {
// 直接登出并跳转到登录页
userStore.logout();
router.push("/login");
message.error("登录已过期,请重新登录");
return Promise.reject(error);
}
// 401 未授权 - token 过期或无效
if (status === 401) {
// 直接登出并跳转到登录页
userStore.logout();
router.push("/login");
message.error("登录已过期,请重新登录");
return Promise.reject(error);
}
// 403 禁止访问
if (status === 403) {
message.error("没有权限访问该资源");
return Promise.reject(error);
}
// 403 禁止访问
if (status === 403) {
message.error("没有权限访问该资源");
return Promise.reject(error);
}
// 404 资源不存在
if (status === 404) {
message.error("请求的资源不存在");
return Promise.reject(error);
}
// 404 资源不存在
if (status === 404) {
message.error("请求的资源不存在");
return Promise.reject(error);
}
// 500 服务器错误
if (status >= 500) {
message.error("服务器错误,请稍后重试");
return Promise.reject(error);
}
// 500 服务器错误
if (status >= 500) {
message.error("服务器错误,请稍后重试");
return Promise.reject(error);
}
// 其他错误
const errorMessage = data?.message || error.message || "请求失败";
message.error(errorMessage);
return Promise.reject(error);
},
// 其他错误
const errorMessage = data?.message || error.message || "请求失败";
message.error(errorMessage);
return Promise.reject(error);
},
);
export default request;

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