Template
格式化代码,websocket功能完善
This commit is contained in:
@@ -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,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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],
|
||||
|
||||
@@ -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. 缓存一致性
|
||||
|
||||
- 后端缓存使用 Redis,TTL 为 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)正常运行
|
||||
@@ -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
@@ -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
@@ -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 模块现已完全集成到项目中,提供了完整的系统管理功能和优秀的用户体验。
|
||||
|
||||
+1092
-574
File diff suppressed because it is too large
Load Diff
+61
-51
@@ -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
@@ -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
@@ -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" },
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 | - | 300000(5分钟) |
|
||||
| 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 | - | 300000(5分钟) |
|
||||
| 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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,6 +2,6 @@
|
||||
* 静态路由配置
|
||||
* 这些路由会根据用户角色进行过滤后添加到路由中
|
||||
*/
|
||||
const userRoutes = []
|
||||
const userRoutes = [];
|
||||
|
||||
export default userRoutes
|
||||
export default userRoutes;
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"> 支持 JPG、PNG 格式,文件大小不超过 2MB </a-typography-text>
|
||||
<a-typography-text type="secondary">
|
||||
支持 JPG、PNG 格式,文件大小不超过 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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
);
|
||||
|
||||
@@ -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 方法
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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: [], // 不自动持久化,通知数据从服务器获取
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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)
|
||||
// }
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user