更新
This commit is contained in:
@@ -85,7 +85,7 @@ public function store(Request $request)
|
|||||||
{
|
{
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'name' => 'required|string|max:50',
|
'name' => 'required|string|max:50',
|
||||||
'parent_id' => 'nullable|integer|exists:auth_departments,id',
|
'parent_id' => 'nullable|integer',
|
||||||
'leader' => 'nullable|string|max:50',
|
'leader' => 'nullable|string|max:50',
|
||||||
'phone' => 'nullable|string|max:20',
|
'phone' => 'nullable|string|max:20',
|
||||||
'sort' => 'nullable|integer|min:0',
|
'sort' => 'nullable|integer|min:0',
|
||||||
@@ -108,7 +108,7 @@ public function update(Request $request, $id)
|
|||||||
{
|
{
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'name' => 'nullable|string|max:50',
|
'name' => 'nullable|string|max:50',
|
||||||
'parent_id' => 'nullable|integer|exists:auth_departments,id',
|
'parent_id' => 'nullable|integer',
|
||||||
'leader' => 'nullable|string|max:50',
|
'leader' => 'nullable|string|max:50',
|
||||||
'phone' => 'nullable|string|max:20',
|
'phone' => 'nullable|string|max:20',
|
||||||
'sort' => 'nullable|integer|min:0',
|
'sort' => 'nullable|integer|min:0',
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\LogRequest;
|
use App\Http\Requests\LogRequest;
|
||||||
use App\Services\System\LogService;
|
use App\Services\System\LogService;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
use Maatwebsite\Excel\Facades\Excel;
|
use Maatwebsite\Excel\Facades\Excel;
|
||||||
use App\Exports\GenericExport;
|
use App\Exports\GenericExport;
|
||||||
|
|
||||||
@@ -30,39 +31,9 @@ public function index(LogRequest $request)
|
|||||||
public function export(LogRequest $request)
|
public function export(LogRequest $request)
|
||||||
{
|
{
|
||||||
$params = $request->validated();
|
$params = $request->validated();
|
||||||
$pageSize = $params['page_size'] ?? 10000; // 导出时默认获取更多数据
|
$filePath = $this->logService->export($params);
|
||||||
|
|
||||||
// 获取所有符合条件的日志(不分页)
|
return response()->download($filePath)->deleteFileAfterSend(true);
|
||||||
$query = $this->logService->getListQuery($params);
|
|
||||||
$logs = $query->limit($pageSize)->get();
|
|
||||||
|
|
||||||
// 准备导出数据
|
|
||||||
$headers = [
|
|
||||||
'ID', '用户名', '模块', '操作', '请求方法', 'URL', 'IP地址',
|
|
||||||
'状态码', '状态', '错误信息', '执行时间(ms)', '创建时间'
|
|
||||||
];
|
|
||||||
|
|
||||||
$data = [];
|
|
||||||
foreach ($logs as $log) {
|
|
||||||
$data[] = [
|
|
||||||
$log->id,
|
|
||||||
$log->username,
|
|
||||||
$log->module,
|
|
||||||
$log->action,
|
|
||||||
$log->method,
|
|
||||||
$log->url,
|
|
||||||
$log->ip,
|
|
||||||
$log->status_code,
|
|
||||||
$log->status === 'success' ? '成功' : '失败',
|
|
||||||
$log->error_message ?? '-',
|
|
||||||
$log->execution_time,
|
|
||||||
$log->created_at->format('Y-m-d H:i:s'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$filename = '系统操作日志_' . date('YmdHis') . '.xlsx';
|
|
||||||
|
|
||||||
return Excel::download(new GenericExport($headers, $data), $filename);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function show(int $id)
|
public function show(int $id)
|
||||||
@@ -93,7 +64,7 @@ public function destroy(int $id)
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function batchDelete(Request $request)
|
public function batchDelete(\Illuminate\Http\Request $request)
|
||||||
{
|
{
|
||||||
$this->logService->batchDelete($request->input('ids', []));
|
$this->logService->batchDelete($request->input('ids', []));
|
||||||
return response()->json([
|
return response()->json([
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
class LogRequest extends FormRequest
|
class LogRequest extends FormRequest
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Determine if the user is authorized to make this request.
|
* Determine if user is authorized to make this request.
|
||||||
*
|
*
|
||||||
* @return bool
|
* @return bool
|
||||||
*/
|
*/
|
||||||
@@ -19,7 +19,7 @@ public function authorize(): bool
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the validation rules that apply to the request.
|
* Get validation rules that apply to request.
|
||||||
*
|
*
|
||||||
* @return array
|
* @return array
|
||||||
*/
|
*/
|
||||||
@@ -34,6 +34,7 @@ public function rules(): array
|
|||||||
'username' => 'nullable|string|max:50',
|
'username' => 'nullable|string|max:50',
|
||||||
'module' => 'nullable|string|max:50',
|
'module' => 'nullable|string|max:50',
|
||||||
'action' => 'nullable|string|max:100',
|
'action' => 'nullable|string|max:100',
|
||||||
|
'method' => 'nullable|in:GET,POST,PUT,DELETE,PATCH',
|
||||||
'status' => 'nullable|in:success,error',
|
'status' => 'nullable|in:success,error',
|
||||||
'start_date' => 'nullable|date',
|
'start_date' => 'nullable|date',
|
||||||
'end_date' => 'nullable|date|after_or_equal:start_date',
|
'end_date' => 'nullable|date|after_or_equal:start_date',
|
||||||
@@ -77,6 +78,7 @@ public function messages(): array
|
|||||||
'username.max' => '用户名最多50个字符',
|
'username.max' => '用户名最多50个字符',
|
||||||
'module.max' => '模块名最多50个字符',
|
'module.max' => '模块名最多50个字符',
|
||||||
'action.max' => '操作名最多100个字符',
|
'action.max' => '操作名最多100个字符',
|
||||||
|
'method.in' => '请求方式必须是 GET、POST、PUT、DELETE 或 PATCH',
|
||||||
'status.in' => '状态值必须是 success 或 error',
|
'status.in' => '状态值必须是 success 或 error',
|
||||||
'start_date.date' => '开始日期格式不正确',
|
'start_date.date' => '开始日期格式不正确',
|
||||||
'end_date.date' => '结束日期格式不正确',
|
'end_date.date' => '结束日期格式不正确',
|
||||||
@@ -121,7 +123,7 @@ protected function failedValidation(Validator $validator): void
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prepare the data for validation.
|
* Prepare for validation.
|
||||||
*
|
*
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ public function getListQuery(array $params)
|
|||||||
*/
|
*/
|
||||||
protected function buildQuery(array $params)
|
protected function buildQuery(array $params)
|
||||||
{
|
{
|
||||||
$query = Log::query()->with('user:id,name,username');
|
$query = Log::query()->with('user:id,username');
|
||||||
|
|
||||||
if (!empty($params['user_id'])) {
|
if (!empty($params['user_id'])) {
|
||||||
$query->where('user_id', $params['user_id']);
|
$query->where('user_id', $params['user_id']);
|
||||||
@@ -108,6 +108,14 @@ public function getStatistics(array $params = []): array
|
|||||||
{
|
{
|
||||||
$query = Log::query();
|
$query = Log::query();
|
||||||
|
|
||||||
|
if (!empty($params['method'])) {
|
||||||
|
$query->where('method', $params['method']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($params['status'])) {
|
||||||
|
$query->where('status', $params['status']);
|
||||||
|
}
|
||||||
|
|
||||||
if (!empty($params['start_date']) && !empty($params['end_date'])) {
|
if (!empty($params['start_date']) && !empty($params['end_date'])) {
|
||||||
$query->whereBetween('created_at', [$params['start_date'], $params['end_date']]);
|
$query->whereBetween('created_at', [$params['start_date'], $params['end_date']]);
|
||||||
}
|
}
|
||||||
@@ -116,10 +124,74 @@ public function getStatistics(array $params = []): array
|
|||||||
$successCount = (clone $query)->where('status', 'success')->count();
|
$successCount = (clone $query)->where('status', 'success')->count();
|
||||||
$errorCount = (clone $query)->where('status', 'error')->count();
|
$errorCount = (clone $query)->where('status', 'error')->count();
|
||||||
|
|
||||||
|
// 计算平均响应时间
|
||||||
|
$avgTime = (clone $query)->avg('execution_time');
|
||||||
|
$avgTime = $avgTime ? round($avgTime, 2) : 0;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'total' => $total,
|
'total' => $total,
|
||||||
'success' => $successCount,
|
'success' => $successCount,
|
||||||
'error' => $errorCount,
|
'error' => $errorCount,
|
||||||
|
'avg_time' => $avgTime,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出日志
|
||||||
|
*
|
||||||
|
* @param array $params
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function export(array $params): string
|
||||||
|
{
|
||||||
|
$query = $this->buildQuery($params);
|
||||||
|
$query->orderBy('created_at', 'desc');
|
||||||
|
|
||||||
|
$logs = $query->limit(10000)->get();
|
||||||
|
|
||||||
|
// 创建导出数据
|
||||||
|
$data = [];
|
||||||
|
foreach ($logs as $log) {
|
||||||
|
$data[] = [
|
||||||
|
'ID' => $log->id,
|
||||||
|
'用户名' => $log->username,
|
||||||
|
'模块' => $log->module,
|
||||||
|
'操作' => $log->action,
|
||||||
|
'请求方式' => $log->method,
|
||||||
|
'URL' => $log->url,
|
||||||
|
'IP地址' => $log->ip,
|
||||||
|
'状态码' => $log->status_code,
|
||||||
|
'状态' => $log->status === 'success' ? '成功' : '失败',
|
||||||
|
'执行时间' => $log->execution_time . 'ms',
|
||||||
|
'创建时间' => $log->created_at,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成CSV文件
|
||||||
|
$filename = '系统日志_' . date('YmdHis') . '.csv';
|
||||||
|
$filepath = storage_path('app/exports/' . $filename);
|
||||||
|
|
||||||
|
// 确保目录存在
|
||||||
|
if (!file_exists(dirname($filepath))) {
|
||||||
|
mkdir(dirname($filepath), 0755, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$file = fopen($filepath, 'w');
|
||||||
|
// 添加BOM以支持Excel中文显示
|
||||||
|
fprintf($file, chr(0xEF) . chr(0xBB) . chr(0xBF));
|
||||||
|
|
||||||
|
// 写入表头
|
||||||
|
if (!empty($data)) {
|
||||||
|
fputcsv($file, array_keys($data[0]));
|
||||||
|
|
||||||
|
// 写入数据
|
||||||
|
foreach ($data as $row) {
|
||||||
|
fputcsv($file, $row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fclose($file);
|
||||||
|
|
||||||
|
return $filepath;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,17 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 文件上传
|
||||||
|
upload: {
|
||||||
|
post: async function (file) {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
return await request.post('upload', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
// 用户管理
|
// 用户管理
|
||||||
users: {
|
users: {
|
||||||
list: {
|
list: {
|
||||||
|
|||||||
@@ -52,37 +52,45 @@ export default {
|
|||||||
|
|
||||||
// 操作日志管理
|
// 操作日志管理
|
||||||
logs: {
|
logs: {
|
||||||
list: {
|
list: {
|
||||||
get: async function (params) {
|
get: async function (params) {
|
||||||
return await request.get('logs', { params })
|
return await request.get('logs', { params })
|
||||||
|
},
|
||||||
|
},
|
||||||
|
detail: {
|
||||||
|
get: async function (id) {
|
||||||
|
return await request.get(`logs/${id}`)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
delete: {
|
||||||
|
delete: async function (id) {
|
||||||
|
return await request.delete(`logs/${id}`)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
batchDelete: {
|
||||||
|
post: async function (params) {
|
||||||
|
return await request.post('logs/batch-delete', params)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
clear: {
|
||||||
|
post: async function (params) {
|
||||||
|
return await request.post('logs/clear', params)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
export: {
|
||||||
|
get: async function (params) {
|
||||||
|
return await request.get('logs/export', {
|
||||||
|
params,
|
||||||
|
responseType: 'blob'
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
statistics: {
|
||||||
|
get: async function (params) {
|
||||||
|
return await request.get('logs/statistics', { params })
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
detail: {
|
|
||||||
get: async function (id) {
|
|
||||||
return await request.get(`logs/${id}`)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
delete: {
|
|
||||||
delete: async function (id) {
|
|
||||||
return await request.delete(`logs/${id}`)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
batchDelete: {
|
|
||||||
post: async function (params) {
|
|
||||||
return await request.post('logs/batch-delete', params)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
clear: {
|
|
||||||
post: async function (params) {
|
|
||||||
return await request.post('logs/clear', params)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
statistics: {
|
|
||||||
get: async function (params) {
|
|
||||||
return await request.get('logs/statistics', { params })
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// 数据字典管理
|
// 数据字典管理
|
||||||
dictionaries: {
|
dictionaries: {
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
<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-form-item label="角色名称" name="name">
|
||||||
|
<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-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-space>
|
||||||
|
</template>
|
||||||
|
</a-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, computed } from 'vue'
|
||||||
|
import { message } from 'ant-design-vue'
|
||||||
|
import authApi from '@/api/auth'
|
||||||
|
|
||||||
|
const emit = defineEmits(['success', 'closed'])
|
||||||
|
|
||||||
|
const visible = ref(false)
|
||||||
|
const loading = ref(false)
|
||||||
|
const sourceId = ref(null)
|
||||||
|
const sourceName = ref('')
|
||||||
|
const sourceCode = ref('')
|
||||||
|
|
||||||
|
// 表单数据
|
||||||
|
const form = reactive({
|
||||||
|
name: '',
|
||||||
|
code: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
// 标题
|
||||||
|
const title = computed(() => sourceId.value ? '复制角色' : '批量复制')
|
||||||
|
|
||||||
|
// 表单引用
|
||||||
|
const dialogForm = ref()
|
||||||
|
|
||||||
|
// 验证规则
|
||||||
|
const rules = {
|
||||||
|
name: [{ required: true, message: '请输入角色名称', trigger: 'blur' }],
|
||||||
|
code: [
|
||||||
|
{ 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`
|
||||||
|
} else {
|
||||||
|
// 批量复制(暂不支持自定义名称,直接在后端处理)
|
||||||
|
sourceId.value = null
|
||||||
|
sourceName.value = ''
|
||||||
|
sourceCode.value = ''
|
||||||
|
form.name = ''
|
||||||
|
form.code = ''
|
||||||
|
}
|
||||||
|
visible.value = true
|
||||||
|
return {
|
||||||
|
open,
|
||||||
|
close
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭对话框
|
||||||
|
const close = () => {
|
||||||
|
visible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理取消
|
||||||
|
const handleCancel = () => {
|
||||||
|
emit('closed')
|
||||||
|
visible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理确定
|
||||||
|
const handleOk = async () => {
|
||||||
|
try {
|
||||||
|
if (sourceId.value) {
|
||||||
|
// 单个复制
|
||||||
|
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
|
||||||
|
|
||||||
|
if (res.code === 200) {
|
||||||
|
emit('success')
|
||||||
|
visible.value = false
|
||||||
|
message.success('复制成功')
|
||||||
|
} else {
|
||||||
|
message.error(res.message || '复制失败')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 批量复制(通过外部传入的 ids)
|
||||||
|
emit('success')
|
||||||
|
visible.value = false
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('复制失败:', error)
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 暴露方法给父组件
|
||||||
|
defineExpose({
|
||||||
|
open,
|
||||||
|
close
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<a-modal title="角色权限设置" :open="visible" :width="600" :destroy-on-close="true" :footer="null" @cancel="handleCancel">
|
<a-modal title="角色权限设置" :open="visible" :width="600" :destroy-on-close="true" @cancel="handleCancel">
|
||||||
<div class="permission-content">
|
<div class="permission-content">
|
||||||
<div class="permission-tree">
|
<div class="permission-tree">
|
||||||
<a-tree ref="menuTreeRef" v-model:checkedKeys="checkedPermissionIds" :tree-data="permissionTree"
|
<a-tree ref="menuTreeRef" v-model:checkedKeys="checkedPermissionIds" :tree-data="permissionTree"
|
||||||
@@ -12,8 +12,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<a-button @click="handleCancel">取 消</a-button>
|
<a-space>
|
||||||
<a-button type="primary" :loading="isSaveing" @click="submit">保 存</a-button>
|
<a-button @click="handleCancel">取 消</a-button>
|
||||||
|
<a-button type="primary" :loading="isSaveing" @click="submit">保 存</a-button>
|
||||||
|
</a-space>
|
||||||
</template>
|
</template>
|
||||||
</a-modal>
|
</a-modal>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<a-modal :title="titleMap[mode]" :open="visible" :width="500" :destroy-on-close="true" :footer="null"
|
<a-modal :title="titleMap[mode]" :open="visible" :width="500" :destroy-on-close="true" @cancel="handleCancel">
|
||||||
@cancel="handleCancel">
|
|
||||||
<a-form :model="form" :rules="rules" :disabled="mode === 'show'" ref="dialogForm" :label-col="{ span: 5 }"
|
<a-form :model="form" :rules="rules" :disabled="mode === 'show'" ref="dialogForm" :label-col="{ span: 5 }"
|
||||||
:wrapper-col="{ span: 18 }">
|
:wrapper-col="{ span: 18 }">
|
||||||
<a-form-item label="角色名称" name="name">
|
<a-form-item label="角色名称" name="name">
|
||||||
@@ -20,8 +19,10 @@
|
|||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-form>
|
</a-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<a-button @click="handleCancel">取 消</a-button>
|
<a-space>
|
||||||
<a-button v-if="mode !== 'show'" type="primary" :loading="isSaveing" @click="submit">保 存</a-button>
|
<a-button @click="handleCancel">取 消</a-button>
|
||||||
|
<a-button v-if="mode !== 'show'" type="primary" :loading="isSaveing" @click="submit">保 存</a-button>
|
||||||
|
</a-space>
|
||||||
</template>
|
</template>
|
||||||
</a-modal>
|
</a-modal>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -72,13 +72,28 @@
|
|||||||
</template>
|
</template>
|
||||||
<template #action="{ record }">
|
<template #action="{ record }">
|
||||||
<a-space>
|
<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="handleEdit(record)">编辑</a-button>
|
||||||
<a-button type="link" size="small" @click="handlePermission(record)">权限</a-button>
|
|
||||||
<a-button type="link" size="small" @click="handleCopy(record)">复制</a-button>
|
<a-button type="link" size="small" @click="handleCopy(record)">复制</a-button>
|
||||||
<a-popconfirm title="确定删除该角色吗?" @confirm="handleDelete(record)">
|
<a-dropdown>
|
||||||
<a-button type="link" size="small" danger>删除</a-button>
|
<a-button type="link" size="small">
|
||||||
</a-popconfirm>
|
更多
|
||||||
|
<DownOutlined />
|
||||||
|
</a-button>
|
||||||
|
<template #overlay>
|
||||||
|
<a-menu>
|
||||||
|
<a-menu-item @click="handleView(record)">
|
||||||
|
<SearchOutlined />查看
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item @click="handlePermission(record)">
|
||||||
|
<ImportOutlined />权限
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-divider />
|
||||||
|
<a-menu-item @click="handleDelete(record)" danger>
|
||||||
|
<DeleteOutlined />删除
|
||||||
|
</a-menu-item>
|
||||||
|
</a-menu>
|
||||||
|
</template>
|
||||||
|
</a-dropdown>
|
||||||
</a-space>
|
</a-space>
|
||||||
</template>
|
</template>
|
||||||
</scTable>
|
</scTable>
|
||||||
@@ -100,6 +115,9 @@
|
|||||||
<sc-export v-model:open="dialog.export" title="导出角色" :api="handleExportApi"
|
<sc-export v-model:open="dialog.export" title="导出角色" :api="handleExportApi"
|
||||||
:default-filename="`角色列表_${Date.now()}`" :show-options="false" tip="导出当前选中或所有角色数据"
|
:default-filename="`角色列表_${Date.now()}`" :show-options="false" tip="导出当前选中或所有角色数据"
|
||||||
@success="handleExportSuccess" />
|
@success="handleExportSuccess" />
|
||||||
|
|
||||||
|
<!-- 复制角色弹窗 -->
|
||||||
|
<copy-dialog v-if="dialog.copy" ref="copyDialogRef" @success="handleCopySuccess" @closed="dialog.copy = false" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
@@ -122,6 +140,7 @@ import scImport from '@/components/scImport/index.vue'
|
|||||||
import scExport from '@/components/scExport/index.vue'
|
import scExport from '@/components/scExport/index.vue'
|
||||||
import saveDialog from './components/SaveDialog.vue'
|
import saveDialog from './components/SaveDialog.vue'
|
||||||
import permissionDialog from './components/PermissionDialog.vue'
|
import permissionDialog from './components/PermissionDialog.vue'
|
||||||
|
import copyDialog from './components/CopyDialog.vue'
|
||||||
import authApi from '@/api/auth'
|
import authApi from '@/api/auth'
|
||||||
import { useTable } from '@/hooks/useTable'
|
import { useTable } from '@/hooks/useTable'
|
||||||
|
|
||||||
@@ -160,12 +179,14 @@ const dialog = reactive({
|
|||||||
save: false,
|
save: false,
|
||||||
permission: false,
|
permission: false,
|
||||||
import: false,
|
import: false,
|
||||||
export: false
|
export: false,
|
||||||
|
copy: false
|
||||||
})
|
})
|
||||||
|
|
||||||
// 弹窗引用
|
// 弹窗引用
|
||||||
const saveDialogRef = ref(null)
|
const saveDialogRef = ref(null)
|
||||||
const permissionDialogRef = ref(null)
|
const permissionDialogRef = ref(null)
|
||||||
|
const copyDialogRef = ref(null)
|
||||||
|
|
||||||
// 行key
|
// 行key
|
||||||
const rowKey = 'id'
|
const rowKey = 'id'
|
||||||
@@ -178,7 +199,7 @@ const columns = [
|
|||||||
{ title: '描述', dataIndex: 'description', key: 'description', ellipsis: true },
|
{ title: '描述', dataIndex: 'description', key: 'description', ellipsis: true },
|
||||||
{ title: '排序', dataIndex: 'sort', key: 'sort', width: 100, align: 'center' },
|
{ title: '排序', dataIndex: 'sort', key: 'sort', width: 100, align: 'center' },
|
||||||
{ title: '状态', dataIndex: 'status', key: 'status', width: 100, align: 'center', slot: 'status' },
|
{ title: '状态', dataIndex: 'status', key: 'status', width: 100, align: 'center', slot: 'status' },
|
||||||
{ title: '操作', dataIndex: 'action', key: 'action', width: 260, align: 'center', slot: 'action', fixed: 'right' }
|
{ title: '操作', dataIndex: 'action', key: 'action', width: 180, align: 'center', slot: 'action', fixed: 'right' }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -207,19 +228,28 @@ const handleEdit = (record) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 删除角色
|
// 删除角色
|
||||||
const handleDelete = async (record) => {
|
const handleDelete = (record) => {
|
||||||
try {
|
Modal.confirm({
|
||||||
const res = await authApi.roles.delete.delete(record.id)
|
title: '确认删除',
|
||||||
if (res.code === 200) {
|
content: '确定删除该角色吗?',
|
||||||
message.success('删除成功')
|
okText: '确定',
|
||||||
refreshTable()
|
cancelText: '取消',
|
||||||
} else {
|
okType: 'danger',
|
||||||
message.error(res.message || '删除失败')
|
onOk: async () => {
|
||||||
|
try {
|
||||||
|
const res = await authApi.roles.delete.delete(record.id)
|
||||||
|
if (res.code === 200) {
|
||||||
|
message.success('删除成功')
|
||||||
|
refreshTable()
|
||||||
|
} else {
|
||||||
|
message.error(res.message || '删除失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('删除角色失败:', error)
|
||||||
|
message.error('删除失败')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
})
|
||||||
console.error('删除角色失败:', error)
|
|
||||||
message.error('删除失败')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 批量删除
|
// 批量删除
|
||||||
@@ -288,8 +318,10 @@ const handleBatchStatus = () => {
|
|||||||
|
|
||||||
// 复制角色
|
// 复制角色
|
||||||
const handleCopy = (record) => {
|
const handleCopy = (record) => {
|
||||||
// TODO: 实现复制角色弹窗
|
dialog.copy = true
|
||||||
message.info('复制角色功能开发中...')
|
setTimeout(() => {
|
||||||
|
copyDialogRef.value?.open(record)
|
||||||
|
}, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 批量复制角色
|
// 批量复制角色
|
||||||
@@ -298,8 +330,34 @@ const handleBatchCopy = () => {
|
|||||||
message.warning('请选择要复制的角色')
|
message.warning('请选择要复制的角色')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// TODO: 实现批量复制角色弹窗
|
|
||||||
message.info('批量复制角色功能开发中...')
|
Modal.confirm({
|
||||||
|
title: '确认批量复制',
|
||||||
|
content: `确定复制选中的 ${selectedRows.value.length} 个角色吗?`,
|
||||||
|
okText: '确定',
|
||||||
|
cancelText: '取消',
|
||||||
|
onOk: async () => {
|
||||||
|
try {
|
||||||
|
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()
|
||||||
|
} else {
|
||||||
|
message.error(res.message || '批量复制失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('批量复制角色失败:', error)
|
||||||
|
message.error('批量复制失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 复制成功回调
|
||||||
|
const handleCopySuccess = () => {
|
||||||
|
refreshTable()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 权限设置
|
// 权限设置
|
||||||
|
|||||||
@@ -0,0 +1,804 @@
|
|||||||
|
<template>
|
||||||
|
<div class="quick-actions-component">
|
||||||
|
<a-card :bordered="false" class="quick-actions-card">
|
||||||
|
<template #title>
|
||||||
|
<div class="card-title">
|
||||||
|
<ThunderboltOutlined class="title-icon" />
|
||||||
|
<span>快速操作</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #extra>
|
||||||
|
<a-button type="primary" size="small" @click="showCustomizeModal = true">
|
||||||
|
<SettingOutlined />
|
||||||
|
自定义
|
||||||
|
</a-button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="actions-container">
|
||||||
|
<transition-group name="list" tag="div" class="actions-grid">
|
||||||
|
<div
|
||||||
|
v-for="action in displayActions"
|
||||||
|
:key="action.path"
|
||||||
|
class="action-item"
|
||||||
|
: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>
|
||||||
|
<div class="action-content">
|
||||||
|
<div class="action-title">{{ action.title }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition-group>
|
||||||
|
|
||||||
|
<div v-if="displayActions.length === 0" class="empty-state">
|
||||||
|
<a-empty description="暂无快速操作" />
|
||||||
|
<a-button type="link" @click="showCustomizeModal = true">
|
||||||
|
<PlusOutlined />
|
||||||
|
添加操作
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a-card>
|
||||||
|
|
||||||
|
<!-- 自定义快速操作弹窗 -->
|
||||||
|
<a-modal
|
||||||
|
v-model:open="showCustomizeModal"
|
||||||
|
title="自定义快速操作"
|
||||||
|
:width="700"
|
||||||
|
:footer="null"
|
||||||
|
class="customize-modal"
|
||||||
|
>
|
||||||
|
<div class="customize-content">
|
||||||
|
<!-- 已添加的操作 -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h3 class="section-title">已添加的操作</h3>
|
||||||
|
<a-tag :color="tempCustomActions.length >= 8 ? 'error' : 'blue'">
|
||||||
|
{{ tempCustomActions.length }}/8
|
||||||
|
</a-tag>
|
||||||
|
</div>
|
||||||
|
<div class="selected-actions">
|
||||||
|
<div class="drag-container">
|
||||||
|
<div
|
||||||
|
v-for="(action, index) in tempCustomActions"
|
||||||
|
:key="action.path"
|
||||||
|
class="action-tag"
|
||||||
|
:style="{ borderColor: action.color }"
|
||||||
|
>
|
||||||
|
<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)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a-empty v-if="tempCustomActions.length === 0" description="从下方列表选择添加" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 可选操作 -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h3 class="section-title">可选操作</h3>
|
||||||
|
<a-tag color="cyan">{{ availableMenus.length }}个可用</a-tag>
|
||||||
|
</div>
|
||||||
|
<a-input-search
|
||||||
|
v-model:value="searchKeyword"
|
||||||
|
placeholder="搜索菜单名称或路径..."
|
||||||
|
allow-clear
|
||||||
|
size="large"
|
||||||
|
class="search-input"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<SearchOutlined />
|
||||||
|
</template>
|
||||||
|
</a-input-search>
|
||||||
|
<div class="available-actions">
|
||||||
|
<div
|
||||||
|
v-for="menu in filteredMenus"
|
||||||
|
:key="menu.path"
|
||||||
|
class="menu-item"
|
||||||
|
:class="{ disabled: isAdded(menu) }"
|
||||||
|
@click="handleAddAction(menu)"
|
||||||
|
>
|
||||||
|
<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-path">{{ menu.path }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="menu-action">
|
||||||
|
<a-button v-if="!isAdded(menu)" type="primary" size="small" ghost>
|
||||||
|
<PlusOutlined />
|
||||||
|
添加
|
||||||
|
</a-button>
|
||||||
|
<a-tag v-else color="success">
|
||||||
|
<CheckOutlined />
|
||||||
|
已添加
|
||||||
|
</a-tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a-empty v-if="filteredMenus.length === 0" description="未找到匹配的操作" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 底部操作按钮 -->
|
||||||
|
<div class="modal-footer">
|
||||||
|
<a-space>
|
||||||
|
<a-button @click="handleCancelCustomize">取消</a-button>
|
||||||
|
<a-button type="primary" @click="handleSaveCustomActions" :loading="saving">
|
||||||
|
<SaveOutlined />
|
||||||
|
保存配置
|
||||||
|
</a-button>
|
||||||
|
</a-space>
|
||||||
|
</div>
|
||||||
|
</a-modal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useUserStore } from '@/stores/modules/user'
|
||||||
|
import {
|
||||||
|
SettingOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
CheckOutlined,
|
||||||
|
AppstoreOutlined,
|
||||||
|
ThunderboltOutlined,
|
||||||
|
CloseOutlined,
|
||||||
|
SearchOutlined,
|
||||||
|
SaveOutlined
|
||||||
|
} from '@ant-design/icons-vue'
|
||||||
|
import { message } from 'ant-design-vue'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'QuickActions',
|
||||||
|
})
|
||||||
|
|
||||||
|
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 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%)',
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取渐变色
|
||||||
|
const getGradient = (color) => {
|
||||||
|
if (!color) return gradientColors['#1890ff']
|
||||||
|
return gradientColors[color] || gradientColors['#1890ff']
|
||||||
|
}
|
||||||
|
|
||||||
|
// 可用的菜单列表(扁平化所有菜单项)
|
||||||
|
const availableMenus = computed(() => {
|
||||||
|
const menus = userStore.menu || []
|
||||||
|
const flattenMenus = []
|
||||||
|
|
||||||
|
const traverse = (menuList) => {
|
||||||
|
menuList.forEach(menu => {
|
||||||
|
// 兼容不同的菜单数据结构
|
||||||
|
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'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (menu.children && menu.children.length > 0) {
|
||||||
|
traverse(menu.children)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
traverse(menus)
|
||||||
|
return flattenMenus
|
||||||
|
})
|
||||||
|
|
||||||
|
// 显示的快速操作
|
||||||
|
const displayActions = computed(() => {
|
||||||
|
if (customActions.value.length > 0) {
|
||||||
|
return customActions.value
|
||||||
|
}
|
||||||
|
return defaultActions.value
|
||||||
|
})
|
||||||
|
|
||||||
|
// 过滤后的菜单
|
||||||
|
const filteredMenus = computed(() => {
|
||||||
|
if (!searchKeyword.value) {
|
||||||
|
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 isAdded = (menu) => {
|
||||||
|
return tempCustomActions.value.some(action => action.path === menu.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载自定义操作
|
||||||
|
const loadCustomActions = () => {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem('quick-actions')
|
||||||
|
if (saved) {
|
||||||
|
customActions.value = JSON.parse(saved)
|
||||||
|
} else {
|
||||||
|
setDefaultActions()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载自定义操作失败:', error)
|
||||||
|
setDefaultActions()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置默认快速操作
|
||||||
|
const setDefaultActions = () => {
|
||||||
|
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 => ({
|
||||||
|
...menu,
|
||||||
|
color: getRandomColor()
|
||||||
|
}))
|
||||||
|
.slice(0, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取随机颜色
|
||||||
|
const getRandomColor = () => {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
if (isAdded(menu)) {
|
||||||
|
message.info('该操作已添加')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tempCustomActions.value.push({
|
||||||
|
path: menu.path,
|
||||||
|
title: menu.title,
|
||||||
|
icon: menu.icon,
|
||||||
|
color: getRandomColor()
|
||||||
|
})
|
||||||
|
message.success(`已添加: ${menu.title}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移除操作
|
||||||
|
const handleRemoveAction = (action) => {
|
||||||
|
const index = tempCustomActions.value.findIndex(item => item.path === action.path)
|
||||||
|
if (index > -1) {
|
||||||
|
tempCustomActions.value.splice(index, 1)
|
||||||
|
message.success(`已移除: ${action.title}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存自定义操作
|
||||||
|
const handleSaveCustomActions = () => {
|
||||||
|
if (tempCustomActions.value.length === 0) {
|
||||||
|
message.warning('请至少添加一个快速操作')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
customActions.value = [...tempCustomActions.value]
|
||||||
|
localStorage.setItem('quick-actions', JSON.stringify(customActions.value))
|
||||||
|
setTimeout(() => {
|
||||||
|
saving.value = false
|
||||||
|
showCustomizeModal.value = false
|
||||||
|
message.success('保存成功,快速操作已更新')
|
||||||
|
}, 500)
|
||||||
|
} catch (error) {
|
||||||
|
saving.value = false
|
||||||
|
message.error('保存失败,请重试')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 取消自定义
|
||||||
|
const handleCancelCustomize = () => {
|
||||||
|
showCustomizeModal.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 点击操作项
|
||||||
|
const handleActionClick = (action) => {
|
||||||
|
if (action.path) {
|
||||||
|
router.push(action.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听弹窗打开
|
||||||
|
watch(showCustomizeModal, (newVal) => {
|
||||||
|
if (newVal) {
|
||||||
|
tempCustomActions.value = [...customActions.value]
|
||||||
|
searchKeyword.value = ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听菜单变化
|
||||||
|
watch(() => userStore.menu, (newMenu) => {
|
||||||
|
if (newMenu && newMenu.length > 0 && customActions.value.length === 0) {
|
||||||
|
setDefaultActions()
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loading.value = true
|
||||||
|
loadCustomActions()
|
||||||
|
loading.value = false
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.quick-actions-component {
|
||||||
|
.quick-actions-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||||
|
|
||||||
|
:deep(.ant-card-head) {
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
padding: 16px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-card-body) {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgba(0, 0, 0, 0.85);
|
||||||
|
|
||||||
|
.title-icon {
|
||||||
|
color: #1890ff;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-container {
|
||||||
|
min-height: 200px;
|
||||||
|
|
||||||
|
.actions-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20px 16px;
|
||||||
|
background: #fafafa;
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
border: 2px solid transparent;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||||
|
transform: translateY(-4px);
|
||||||
|
|
||||||
|
.action-icon-wrapper {
|
||||||
|
transform: scale(1.1) rotate(5deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-icon-wrapper {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 16px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
|
||||||
|
.action-icon {
|
||||||
|
font-size: 28px;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-content {
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
.action-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: rgba(0, 0, 0, 0.85);
|
||||||
|
line-height: 1.4;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 200px;
|
||||||
|
padding: 40px;
|
||||||
|
|
||||||
|
:deep(.ant-empty) {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-enter-active,
|
||||||
|
.list-leave-active {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-enter-from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自定义弹窗样式
|
||||||
|
:deep(.customize-modal) {
|
||||||
|
.ant-modal-content {
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-modal-header {
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
padding: 20px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-modal-body {
|
||||||
|
padding: 24px;
|
||||||
|
max-height: 70vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.customize-content {
|
||||||
|
.section {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgba(0, 0, 0, 0.85);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-actions {
|
||||||
|
min-height: 80px;
|
||||||
|
padding: 16px;
|
||||||
|
background: #fafafa;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 2px dashed #d9d9d9;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #1890ff;
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-container {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-tag {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: #fff;
|
||||||
|
border: 2px solid #e8e8e8;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: move;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
|
||||||
|
.tag-close {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-icon {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 6px;
|
||||||
|
|
||||||
|
:deep(.anticon) {
|
||||||
|
font-size: 16px;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: rgba(0, 0, 0, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-close {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
color: rgba(0, 0, 0, 0.45);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #f5222d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-empty) {
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
:deep(.ant-input) {
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.available-actions {
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid #f0f0f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
|
.menu-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-right: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
:deep(.anticon) {
|
||||||
|
font-size: 24px;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
.menu-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: rgba(0, 0, 0, 0.85);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-path {
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgba(0, 0, 0, 0.45);
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-action {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background: #d9d9d9;
|
||||||
|
border-radius: 3px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #bfbfbf;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-empty) {
|
||||||
|
margin: 40px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding-top: 24px;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 响应式设计
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.quick-actions-component {
|
||||||
|
.actions-container {
|
||||||
|
.actions-grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-item {
|
||||||
|
padding: 16px 12px;
|
||||||
|
|
||||||
|
.action-icon-wrapper {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
|
||||||
|
.action-icon {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-title {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.customize-modal) {
|
||||||
|
.ant-modal {
|
||||||
|
max-width: 95vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-modal-body {
|
||||||
|
max-height: 60vh;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.customize-content {
|
||||||
|
.selected-actions {
|
||||||
|
.action-tag {
|
||||||
|
padding: 6px 12px;
|
||||||
|
|
||||||
|
.tag-icon {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
|
||||||
|
:deep(.anticon) {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-title {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.available-actions {
|
||||||
|
.menu-item {
|
||||||
|
padding: 12px;
|
||||||
|
|
||||||
|
.menu-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
margin-right: 12px;
|
||||||
|
|
||||||
|
:deep(.anticon) {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-info {
|
||||||
|
.menu-title {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-path {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
<template>
|
||||||
|
<div class="welcome-component">
|
||||||
|
<a-card>
|
||||||
|
<div class="welcome-content">
|
||||||
|
<div class="greeting">
|
||||||
|
<h2 class="greeting-text">{{ greetingText }}</h2>
|
||||||
|
<p class="welcome-subtitle">{{ userInfo?.username || '管理员' }},欢迎回来!</p>
|
||||||
|
</div>
|
||||||
|
<div class="welcome-info">
|
||||||
|
<a-space direction="vertical" :size="8">
|
||||||
|
<div class="info-item">
|
||||||
|
<CalendarOutlined class="info-icon" />
|
||||||
|
<span>{{ currentDate }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<ClockCircleOutlined class="info-icon" />
|
||||||
|
<span>{{ currentTime }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<EnvironmentOutlined class="info-icon" />
|
||||||
|
<span>{{ weatherText }}</span>
|
||||||
|
</div>
|
||||||
|
</a-space>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a-card>
|
||||||
|
</div>
|
||||||
|
</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'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'Welcome',
|
||||||
|
})
|
||||||
|
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const userInfo = computed(() => userStore.userInfo)
|
||||||
|
|
||||||
|
const currentDate = ref('')
|
||||||
|
const currentTime = ref('')
|
||||||
|
const weatherText = ref('天气晴朗,适合工作')
|
||||||
|
let timer = null
|
||||||
|
|
||||||
|
// 根据时间段返回问候语
|
||||||
|
const greetingText = computed(() => {
|
||||||
|
const hour = new Date().getHours()
|
||||||
|
if (hour >= 5 && hour < 12) {
|
||||||
|
return '早上好'
|
||||||
|
} else if (hour >= 12 && hour < 14) {
|
||||||
|
return '中午好'
|
||||||
|
} else if (hour >= 14 && hour < 18) {
|
||||||
|
return '下午好'
|
||||||
|
} else if (hour >= 18 && hour < 23) {
|
||||||
|
return '晚上好'
|
||||||
|
} else {
|
||||||
|
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'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
updateTime()
|
||||||
|
timer = setInterval(updateTime, 1000)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (timer) {
|
||||||
|
clearInterval(timer)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.welcome-component {
|
||||||
|
:deep(.ant-card) {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 4px 20px rgba(102, 126, 234, 0.3);
|
||||||
|
|
||||||
|
.ant-card-body {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-content {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
.greeting {
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
.greeting-text {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-subtitle {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-info {
|
||||||
|
.info-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
opacity: 0.95;
|
||||||
|
|
||||||
|
.info-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.welcome-content {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16px;
|
||||||
|
|
||||||
|
.greeting-text {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-subtitle {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,444 +1,50 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="dashboard-container">
|
<div class="dashboard-container">
|
||||||
<!-- 统计卡片 -->
|
<!-- 欢迎组件 -->
|
||||||
<a-row :gutter="16" class="stats-cards">
|
<div class="welcome-section">
|
||||||
<a-col :xs="24" :sm="12" :lg="6">
|
<Welcome />
|
||||||
<a-card :loading="loading" hoverable>
|
</div>
|
||||||
<a-statistic
|
|
||||||
title="用户总数"
|
|
||||||
:value="stats.userCount"
|
|
||||||
:prefix="UserOutlined"
|
|
||||||
:value-style="{ color: '#1890ff' }"
|
|
||||||
>
|
|
||||||
<template #suffix>
|
|
||||||
<a-tag color="blue">活跃</a-tag>
|
|
||||||
</template>
|
|
||||||
</a-statistic>
|
|
||||||
</a-card>
|
|
||||||
</a-col>
|
|
||||||
<a-col :xs="24" :sm="12" :lg="6">
|
|
||||||
<a-card :loading="loading" hoverable>
|
|
||||||
<a-statistic
|
|
||||||
title="角色数量"
|
|
||||||
:value="stats.roleCount"
|
|
||||||
:prefix="TeamOutlined"
|
|
||||||
:value-style="{ color: '#52c41a' }"
|
|
||||||
>
|
|
||||||
<template #suffix>
|
|
||||||
<a-tag color="green">配置</a-tag>
|
|
||||||
</template>
|
|
||||||
</a-statistic>
|
|
||||||
</a-card>
|
|
||||||
</a-col>
|
|
||||||
<a-col :xs="24" :sm="12" :lg="6">
|
|
||||||
<a-card :loading="loading" hoverable>
|
|
||||||
<a-statistic
|
|
||||||
title="权限节点"
|
|
||||||
:value="stats.permissionCount"
|
|
||||||
:prefix="KeyOutlined"
|
|
||||||
:value-style="{ color: '#722ed1' }"
|
|
||||||
>
|
|
||||||
<template #suffix>
|
|
||||||
<a-tag color="purple">权限</a-tag>
|
|
||||||
</template>
|
|
||||||
</a-statistic>
|
|
||||||
</a-card>
|
|
||||||
</a-col>
|
|
||||||
<a-col :xs="24" :sm="12" :lg="6">
|
|
||||||
<a-card :loading="loading" hoverable>
|
|
||||||
<a-statistic
|
|
||||||
title="在线用户"
|
|
||||||
:value="stats.onlineUserCount"
|
|
||||||
:prefix="ClockCircleOutlined"
|
|
||||||
:value-style="{ color: '#fa8c16' }"
|
|
||||||
>
|
|
||||||
<template #suffix>
|
|
||||||
<a-tag color="orange">在线</a-tag>
|
|
||||||
</template>
|
|
||||||
</a-statistic>
|
|
||||||
</a-card>
|
|
||||||
</a-col>
|
|
||||||
</a-row>
|
|
||||||
|
|
||||||
<!-- 图表区域 -->
|
<!-- 快速操作组件 -->
|
||||||
<a-row :gutter="16" class="chart-row">
|
<div class="quick-actions-section">
|
||||||
<a-col :xs="24" :lg="12">
|
<QuickActions />
|
||||||
<a-card title="用户分布" :loading="loading">
|
</div>
|
||||||
<div class="chart-container">
|
|
||||||
<div class="pie-chart">
|
|
||||||
<div class="chart-item" v-for="(item, index) in userDistribution" :key="index">
|
|
||||||
<div class="chart-bar" :style="{ width: item.percent + '%' }"></div>
|
|
||||||
<div class="chart-label">{{ item.label }}: {{ item.value }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a-card>
|
|
||||||
</a-col>
|
|
||||||
<a-col :xs="24" :lg="12">
|
|
||||||
<a-card title="系统状态" :loading="loading">
|
|
||||||
<a-descriptions :column="1" bordered>
|
|
||||||
<a-descriptions-item label="系统版本">{{ systemInfo.version }}</a-descriptions-item>
|
|
||||||
<a-descriptions-item label="PHP 版本">{{ systemInfo.phpVersion }}</a-descriptions-item>
|
|
||||||
<a-descriptions-item label="Laravel 版本">{{ systemInfo.laravelVersion }}</a-descriptions-item>
|
|
||||||
<a-descriptions-item label="服务器时间">{{ systemInfo.serverTime }}</a-descriptions-item>
|
|
||||||
<a-descriptions-item label="运行环境">{{ systemInfo.environment }}</a-descriptions-item>
|
|
||||||
</a-descriptions>
|
|
||||||
</a-card>
|
|
||||||
</a-col>
|
|
||||||
</a-row>
|
|
||||||
|
|
||||||
<!-- 快速操作和最近操作 -->
|
|
||||||
<a-row :gutter="16" class="action-row">
|
|
||||||
<a-col :xs="24" :lg="12">
|
|
||||||
<a-card title="快速操作">
|
|
||||||
<a-row :gutter="[8, 8]">
|
|
||||||
<a-col :xs="12" :sm="8">
|
|
||||||
<a-button type="primary" block @click="goToUser">
|
|
||||||
<UserOutlined />
|
|
||||||
用户管理
|
|
||||||
</a-button>
|
|
||||||
</a-col>
|
|
||||||
<a-col :xs="12" :sm="8">
|
|
||||||
<a-button type="primary" block @click="goToRole">
|
|
||||||
<TeamOutlined />
|
|
||||||
角色管理
|
|
||||||
</a-button>
|
|
||||||
</a-col>
|
|
||||||
<a-col :xs="12" :sm="8">
|
|
||||||
<a-button type="primary" block @click="goToPermission">
|
|
||||||
<KeyOutlined />
|
|
||||||
权限管理
|
|
||||||
</a-button>
|
|
||||||
</a-col>
|
|
||||||
<a-col :xs="12" :sm="8">
|
|
||||||
<a-button type="primary" block @click="goToDepartment">
|
|
||||||
<ApartmentOutlined />
|
|
||||||
部门管理
|
|
||||||
</a-button>
|
|
||||||
</a-col>
|
|
||||||
<a-col :xs="12" :sm="8">
|
|
||||||
<a-button type="primary" block @click="goToOnlineUsers">
|
|
||||||
<WifiOutlined />
|
|
||||||
在线用户
|
|
||||||
</a-button>
|
|
||||||
</a-col>
|
|
||||||
<a-col :xs="12" :sm="8">
|
|
||||||
<a-button type="primary" block @click="goToConfig">
|
|
||||||
<SettingOutlined />
|
|
||||||
系统配置
|
|
||||||
</a-button>
|
|
||||||
</a-col>
|
|
||||||
</a-row>
|
|
||||||
</a-card>
|
|
||||||
</a-col>
|
|
||||||
<a-col :xs="24" :lg="12">
|
|
||||||
<a-card title="最近日志" :loading="loading">
|
|
||||||
<a-list :data-source="recentLogs" size="small">
|
|
||||||
<template #renderItem="{ item }">
|
|
||||||
<a-list-item>
|
|
||||||
<a-list-item-meta>
|
|
||||||
<template #title>
|
|
||||||
<a-tag :color="getLogColor(item.level)">{{ item.level }}</a-tag>
|
|
||||||
{{ item.message }}
|
|
||||||
</template>
|
|
||||||
<template #description>
|
|
||||||
{{ item.created_at }}
|
|
||||||
</template>
|
|
||||||
</a-list-item-meta>
|
|
||||||
</a-list-item>
|
|
||||||
</template>
|
|
||||||
</a-list>
|
|
||||||
</a-card>
|
|
||||||
</a-col>
|
|
||||||
</a-row>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import Welcome from './components/Welcome.vue'
|
||||||
import { useRouter } from 'vue-router'
|
import QuickActions from './components/QuickActions.vue'
|
||||||
import { useUserStore } from '@/stores/modules/user'
|
|
||||||
import authApi from '@/api/auth'
|
|
||||||
import { message } from 'ant-design-vue'
|
|
||||||
|
|
||||||
// 定义组件名称
|
// 定义组件名称
|
||||||
defineOptions({
|
defineOptions({
|
||||||
name: 'HomePage',
|
name: 'HomePage',
|
||||||
})
|
})
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const userStore = useUserStore()
|
|
||||||
|
|
||||||
const loading = ref(false)
|
|
||||||
const stats = ref({
|
|
||||||
userCount: 0,
|
|
||||||
roleCount: 0,
|
|
||||||
permissionCount: 0,
|
|
||||||
onlineUserCount: 0
|
|
||||||
})
|
|
||||||
|
|
||||||
const userDistribution = ref([
|
|
||||||
{ label: '已激活', value: 0, percent: 0 },
|
|
||||||
{ label: '未激活', value: 0, percent: 0 },
|
|
||||||
{ label: '已禁用', value: 0, percent: 0 }
|
|
||||||
])
|
|
||||||
|
|
||||||
const systemInfo = ref({
|
|
||||||
version: '1.0.0',
|
|
||||||
phpVersion: '8.1.0',
|
|
||||||
laravelVersion: '10.0.0',
|
|
||||||
serverTime: '',
|
|
||||||
environment: 'production'
|
|
||||||
})
|
|
||||||
|
|
||||||
const recentLogs = ref([])
|
|
||||||
|
|
||||||
// 获取统计数据
|
|
||||||
const fetchStats = async () => {
|
|
||||||
try {
|
|
||||||
loading.value = true
|
|
||||||
|
|
||||||
// 获取用户列表统计
|
|
||||||
const userRes = await authApi.users.list.get({ page_size: 1 })
|
|
||||||
if (userRes.code === 200) {
|
|
||||||
stats.value.userCount = userRes.data.total || 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取角色列表统计
|
|
||||||
const roleRes = await authApi.roles.list.get({ page_size: 1 })
|
|
||||||
if (roleRes.code === 200) {
|
|
||||||
stats.value.roleCount = roleRes.data.total || 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取权限列表统计
|
|
||||||
const permRes = await authApi.permissions.list.get({ page_size: 1 })
|
|
||||||
if (permRes.code === 200) {
|
|
||||||
stats.value.permissionCount = permRes.data.total || 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取在线用户统计
|
|
||||||
const onlineRes = await authApi.onlineUsers.count.get()
|
|
||||||
if (onlineRes.code === 200) {
|
|
||||||
stats.value.onlineUserCount = onlineRes.data.count || 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取用户分布数据(这里使用模拟数据,实际项目中应从后端获取)
|
|
||||||
const activeCount = Math.floor(stats.value.userCount * 0.7)
|
|
||||||
const inactiveCount = Math.floor(stats.value.userCount * 0.2)
|
|
||||||
const disabledCount = stats.value.userCount - activeCount - inactiveCount
|
|
||||||
|
|
||||||
userDistribution.value = [
|
|
||||||
{ label: '已激活', value: activeCount, percent: stats.value.userCount > 0 ? Math.round((activeCount / stats.value.userCount) * 100) : 0 },
|
|
||||||
{ label: '未激活', value: inactiveCount, percent: stats.value.userCount > 0 ? Math.round((inactiveCount / stats.value.userCount) * 100) : 0 },
|
|
||||||
{ label: '已禁用', value: disabledCount, percent: stats.value.userCount > 0 ? Math.round((disabledCount / stats.value.userCount) * 100) : 0 }
|
|
||||||
]
|
|
||||||
|
|
||||||
// 更新系统信息
|
|
||||||
systemInfo.value.serverTime = new Date().toLocaleString('zh-CN')
|
|
||||||
systemInfo.value.environment = import.meta.env.VITE_APP_ENV || 'production'
|
|
||||||
|
|
||||||
// 模拟最近日志数据(实际项目中应从后端获取)
|
|
||||||
recentLogs.value = [
|
|
||||||
{ level: 'info', message: '用户登录成功', created_at: formatTime(new Date()) },
|
|
||||||
{ level: 'info', message: '系统配置更新', created_at: formatTime(new Date(Date.now() - 300000)) },
|
|
||||||
{ level: 'warning', message: '检测到异常访问', created_at: formatTime(new Date(Date.now() - 600000)) },
|
|
||||||
{ level: 'error', message: '数据库连接失败', created_at: formatTime(new Date(Date.now() - 900000)) },
|
|
||||||
{ level: 'info', message: '定时任务执行完成', created_at: formatTime(new Date(Date.now() - 1200000)) }
|
|
||||||
]
|
|
||||||
} catch (error) {
|
|
||||||
message.error('获取统计数据失败')
|
|
||||||
console.error(error)
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 格式化时间
|
|
||||||
const formatTime = (date) => {
|
|
||||||
const now = new Date()
|
|
||||||
const diff = now - date
|
|
||||||
const minutes = Math.floor(diff / 60000)
|
|
||||||
|
|
||||||
if (minutes < 1) {
|
|
||||||
return '刚刚'
|
|
||||||
} else if (minutes < 60) {
|
|
||||||
return `${minutes}分钟前`
|
|
||||||
} else if (minutes < 1440) {
|
|
||||||
return `${Math.floor(minutes / 60)}小时前`
|
|
||||||
} else {
|
|
||||||
return `${Math.floor(minutes / 1440)}天前`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取日志颜色
|
|
||||||
const getLogColor = (level) => {
|
|
||||||
const colors = {
|
|
||||||
info: 'blue',
|
|
||||||
warning: 'orange',
|
|
||||||
error: 'red',
|
|
||||||
success: 'green'
|
|
||||||
}
|
|
||||||
return colors[level] || 'default'
|
|
||||||
}
|
|
||||||
|
|
||||||
// 路由跳转方法
|
|
||||||
const goToUser = () => {
|
|
||||||
router.push('/system/users')
|
|
||||||
}
|
|
||||||
|
|
||||||
const goToRole = () => {
|
|
||||||
router.push('/system/roles')
|
|
||||||
}
|
|
||||||
|
|
||||||
const goToPermission = () => {
|
|
||||||
router.push('/system/permissions')
|
|
||||||
}
|
|
||||||
|
|
||||||
const goToDepartment = () => {
|
|
||||||
router.push('/system/departments')
|
|
||||||
}
|
|
||||||
|
|
||||||
const goToOnlineUsers = () => {
|
|
||||||
router.push('/system/online-users')
|
|
||||||
}
|
|
||||||
|
|
||||||
const goToConfig = () => {
|
|
||||||
router.push('/system/config')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 生命周期
|
|
||||||
onMounted(() => {
|
|
||||||
fetchStats()
|
|
||||||
|
|
||||||
// 每分钟更新一次服务器时间
|
|
||||||
setInterval(() => {
|
|
||||||
systemInfo.value.serverTime = new Date().toLocaleString('zh-CN')
|
|
||||||
}, 60000)
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.dashboard-container {
|
.dashboard-container {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
background: #f0f2f5;
|
background: #f0f2f5;
|
||||||
min-height: calc(100vh - 64px);
|
|
||||||
|
|
||||||
.stats-cards {
|
.welcome-section {
|
||||||
margin-bottom: 16px;
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-actions-section {
|
||||||
:deep(.ant-card) {
|
:deep(.ant-card) {
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
|
||||||
.ant-statistic-title {
|
|
||||||
font-size: 14px;
|
|
||||||
color: rgba(0, 0, 0, 0.65);
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ant-statistic-content {
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ant-statistic-content-prefix {
|
|
||||||
margin-right: 8px;
|
|
||||||
font-size: 20px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart-row,
|
|
||||||
.action-row {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
|
|
||||||
:deep(.ant-card) {
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
|
||||||
height: 100%;
|
|
||||||
|
|
||||||
.ant-card-head {
|
|
||||||
border-bottom: 1px solid #f0f0f0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.chart-container {
|
|
||||||
padding: 20px 0;
|
|
||||||
|
|
||||||
.pie-chart {
|
|
||||||
.chart-item {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chart-bar {
|
|
||||||
height: 8px;
|
|
||||||
background: linear-gradient(90deg, #1890ff 0%, #36cfc9 100%);
|
|
||||||
border-radius: 4px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
transition: width 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chart-label {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
font-size: 14px;
|
|
||||||
color: rgba(0, 0, 0, 0.65);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.ant-btn) {
|
|
||||||
height: 80px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 8px;
|
|
||||||
|
|
||||||
.anticon {
|
|
||||||
font-size: 24px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.ant-list-item) {
|
|
||||||
padding: 12px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.ant-descriptions-item-label) {
|
|
||||||
width: 100px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 响应式调整
|
// 响应式调整
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
|
|
||||||
.stats-cards {
|
.welcome-section {
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart-row,
|
|
||||||
.action-row {
|
|
||||||
margin-bottom: 10px;
|
|
||||||
|
|
||||||
.ant-col {
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.ant-btn) {
|
|
||||||
height: 60px;
|
|
||||||
|
|
||||||
.anticon {
|
|
||||||
font-size: 20px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -25,41 +25,48 @@ const loading = ref(false)
|
|||||||
// 表单初始值
|
// 表单初始值
|
||||||
const initialValues = computed(() => ({
|
const initialValues = computed(() => ({
|
||||||
username: props.userInfo.username || '',
|
username: props.userInfo.username || '',
|
||||||
nickname: props.userInfo.nickname || '',
|
real_name: props.userInfo.real_name || '',
|
||||||
mobile: props.userInfo.mobile || '',
|
phone: props.userInfo.phone || '',
|
||||||
email: props.userInfo.email || '',
|
email: props.userInfo.email || '',
|
||||||
gender: props.userInfo.gender || 0,
|
|
||||||
birthday: props.userInfo.birthday || null,
|
|
||||||
bio: props.userInfo.bio || '',
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// 表单项配置
|
// 表单项配置
|
||||||
const formItems = [
|
const formItems = [
|
||||||
{ field: 'username', label: '用户名', type: 'input' },
|
|
||||||
{
|
{
|
||||||
field: 'nickname', label: '昵称', type: 'input', required: true,
|
field: 'username',
|
||||||
|
label: '用户名',
|
||||||
|
type: 'input',
|
||||||
rules: [
|
rules: [
|
||||||
{ required: true, message: '请输入昵称', trigger: 'blur' },
|
{ required: true, message: '请输入用户名', trigger: 'blur' },
|
||||||
{ min: 2, max: 20, message: '昵称长度在 2 到 20 个字符', trigger: 'blur' },
|
{ min: 3, max: 20, message: '用户名长度在 3 到 20 个字符', trigger: 'blur' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'phone', label: '手机号', type: 'input',
|
field: 'real_name',
|
||||||
rules: [{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }],
|
label: '真实姓名',
|
||||||
},
|
type: 'input',
|
||||||
{
|
required: true,
|
||||||
field: 'email', label: '邮箱', type: 'input',
|
rules: [
|
||||||
rules: [{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' }],
|
{ required: true, message: '请输入真实姓名', trigger: 'blur' },
|
||||||
},
|
{ min: 2, max: 20, message: '姓名长度在 2 到 20 个字符', trigger: 'blur' },
|
||||||
{
|
],
|
||||||
field: 'gender', label: '性别', type: 'radio',
|
},
|
||||||
options: [
|
{
|
||||||
{ label: '男', value: 1 },
|
field: 'phone',
|
||||||
{ label: '女', value: 2 },
|
label: '手机号',
|
||||||
{ label: '保密', value: 0 },
|
type: 'input',
|
||||||
|
rules: [
|
||||||
|
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'email',
|
||||||
|
label: '邮箱',
|
||||||
|
type: 'input',
|
||||||
|
rules: [
|
||||||
|
{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ field: 'remark', label: '个人简介', type: 'textarea', rows: 4, maxLength: 200, showCount: true, },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
// 表单提交
|
// 表单提交
|
||||||
@@ -67,27 +74,32 @@ const handleFinish = async (values) => {
|
|||||||
try {
|
try {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
|
||||||
// 调用更新当前用户信息接口
|
// 获取当前用户ID
|
||||||
let res = await api.users.edit.post({
|
const userId = userStore.userInfo?.id
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
throw new Error('用户信息不存在,请重新登录')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用更新用户信息接口
|
||||||
|
const res = await api.users.edit.put(userId, {
|
||||||
username: values.username,
|
username: values.username,
|
||||||
nickname: values.nickname,
|
real_name: values.real_name,
|
||||||
mobile: values.mobile,
|
phone: values.phone,
|
||||||
email: values.email,
|
email: values.email,
|
||||||
gender: values.gender,
|
|
||||||
remark: values.remark,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!res || res.code !== 1) {
|
if (!res || res.code !== 200) {
|
||||||
throw new Error(res.message || '保存失败,请重试')
|
throw new Error(res.message || '保存失败,请重试')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 重新获取用户信息
|
// 重新获取用户信息
|
||||||
const response = await api.user.get()
|
const response = await api.me.get()
|
||||||
if (response && response.data) {
|
if (response && response.data) {
|
||||||
userStore.setUserInfo(response.data)
|
userStore.setUserInfo(response.data)
|
||||||
|
emit('update', response.data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 通知父组件更新
|
|
||||||
emit('update', values)
|
|
||||||
message.success('保存成功')
|
message.success('保存成功')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error(error.message || '保存失败,请重试')
|
message.error(error.message || '保存失败,请重试')
|
||||||
|
|||||||
@@ -6,30 +6,36 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { message } from 'ant-design-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 scForm from '@/components/scForm/index.vue'
|
||||||
|
import api from '@/api/auth'
|
||||||
|
|
||||||
const emit = defineEmits(['success'])
|
const emit = defineEmits(['success'])
|
||||||
|
const router = useRouter()
|
||||||
|
const userStore = useUserStore()
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
// 表单初始值
|
// 表单初始值
|
||||||
const initialValues = {
|
const initialValues = ref({
|
||||||
oldPassword: '',
|
old_password: '',
|
||||||
newPassword: '',
|
password: '',
|
||||||
confirmPassword: '',
|
password_confirmation: '',
|
||||||
}
|
})
|
||||||
|
|
||||||
// 表单项配置
|
// 表单项配置
|
||||||
const formItems = [
|
const formItems = [
|
||||||
{
|
{
|
||||||
field: 'oldPassword',
|
field: 'old_password',
|
||||||
label: '原密码',
|
label: '原密码',
|
||||||
type: 'password',
|
type: 'password',
|
||||||
required: true,
|
required: true,
|
||||||
rules: [{ required: true, message: '请输入原密码', trigger: 'blur' }],
|
rules: [
|
||||||
|
{ required: true, message: '请输入原密码', trigger: 'blur' },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'newPassword',
|
field: 'password',
|
||||||
label: '新密码',
|
label: '新密码',
|
||||||
type: 'password',
|
type: 'password',
|
||||||
required: true,
|
required: true,
|
||||||
@@ -39,15 +45,19 @@ const formItems = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'confirmPassword',
|
field: 'password_confirmation',
|
||||||
label: '确认密码',
|
label: '确认密码',
|
||||||
type: 'password',
|
type: 'password',
|
||||||
required: true,
|
required: true,
|
||||||
|
dependencies: ['password'],
|
||||||
rules: [
|
rules: [
|
||||||
{ required: true, message: '请再次输入新密码', trigger: 'blur' },
|
{ required: true, message: '请再次输入新密码', trigger: 'blur' },
|
||||||
{
|
{
|
||||||
validator: (rule, value) => {
|
validator: (_, value) => {
|
||||||
if (value !== initialValues.newPassword) {
|
if (!value || !initialValues.value.password) {
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
if (value !== initialValues.value.password) {
|
||||||
return Promise.reject('两次输入的密码不一致')
|
return Promise.reject('两次输入的密码不一致')
|
||||||
}
|
}
|
||||||
return Promise.resolve()
|
return Promise.resolve()
|
||||||
@@ -59,22 +69,47 @@ const formItems = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
// 表单提交
|
// 表单提交
|
||||||
const handleFinish = (values) => {
|
const handleFinish = async (values) => {
|
||||||
loading.value = true
|
try {
|
||||||
// 模拟接口请求
|
loading.value = true
|
||||||
setTimeout(() => {
|
|
||||||
|
// 调用修改密码接口
|
||||||
|
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 || '密码修改失败,请重试')
|
||||||
|
}
|
||||||
|
|
||||||
message.success('密码修改成功,请重新登录')
|
message.success('密码修改成功,请重新登录')
|
||||||
|
|
||||||
|
// 清除用户信息和token
|
||||||
|
userStore.logout()
|
||||||
|
|
||||||
|
// 延迟跳转到登录页
|
||||||
|
setTimeout(() => {
|
||||||
|
router.push('/login')
|
||||||
|
}, 1500)
|
||||||
|
|
||||||
emit('success')
|
emit('success')
|
||||||
handleReset()
|
handleReset()
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error.message || '密码修改失败,请重试')
|
||||||
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}, 1000)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 重置表单
|
// 重置表单
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
initialValues.oldPassword = ''
|
initialValues.value = {
|
||||||
initialValues.newPassword = ''
|
old_password: '',
|
||||||
initialValues.confirmPassword = ''
|
password: '',
|
||||||
|
password_confirmation: '',
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ const initUserInfo = async () => {
|
|||||||
userInfo.value = storeUserInfo
|
userInfo.value = storeUserInfo
|
||||||
} else {
|
} else {
|
||||||
// 如果 store 中没有用户信息,则从接口获取
|
// 如果 store 中没有用户信息,则从接口获取
|
||||||
const response = await api.user.get()
|
const response = await api.me.get()
|
||||||
if (response && response.data) {
|
if (response && response.data) {
|
||||||
userStore.setUserInfo(response.data)
|
userStore.setUserInfo(response.data)
|
||||||
userInfo.value = response.data
|
userInfo.value = response.data
|
||||||
@@ -140,21 +140,52 @@ const handleAvatarChange = ({ fileList }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 上传头像
|
// 上传头像
|
||||||
const handleAvatarUpload = () => {
|
const handleAvatarUpload = async () => {
|
||||||
if (avatarFileList.value.length === 0) {
|
if (avatarFileList.value.length === 0) {
|
||||||
message.warning('请先选择头像')
|
message.warning('请先选择头像')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
loading.value = true
|
|
||||||
// 模拟上传
|
try {
|
||||||
setTimeout(() => {
|
loading.value = true
|
||||||
const file = avatarFileList.value[0]
|
const file = avatarFileList.value[0].originFileObj
|
||||||
userInfo.value.avatar = URL.createObjectURL(file.originFileObj)
|
|
||||||
|
// 调用上传接口
|
||||||
|
const uploadRes = await api.upload.post(file)
|
||||||
|
if (!uploadRes || uploadRes.code !== 200) {
|
||||||
|
throw new Error(uploadRes.message || '头像上传失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取当前用户ID
|
||||||
|
const userId = userStore.userInfo?.id
|
||||||
|
if (!userId) {
|
||||||
|
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 || '头像更新失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重新获取用户信息
|
||||||
|
const response = await api.me.get()
|
||||||
|
if (response && response.data) {
|
||||||
|
userStore.setUserInfo(response.data)
|
||||||
|
userInfo.value = response.data
|
||||||
|
}
|
||||||
|
|
||||||
message.success('头像更新成功')
|
message.success('头像更新成功')
|
||||||
showAvatarModal.value = false
|
showAvatarModal.value = false
|
||||||
avatarFileList.value = []
|
avatarFileList.value = []
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error.message || '头像上传失败,请重试')
|
||||||
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}, 1000)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
|||||||
+1
-1
@@ -101,12 +101,12 @@
|
|||||||
// 系统操作日志
|
// 系统操作日志
|
||||||
Route::prefix('logs')->group(function () {
|
Route::prefix('logs')->group(function () {
|
||||||
Route::get('/', [\App\Http\Controllers\System\Admin\Log::class, 'index']);
|
Route::get('/', [\App\Http\Controllers\System\Admin\Log::class, 'index']);
|
||||||
|
Route::get('/export', [\App\Http\Controllers\System\Admin\Log::class, 'export']);
|
||||||
Route::get('/statistics', [\App\Http\Controllers\System\Admin\Log::class, 'getStatistics']);
|
Route::get('/statistics', [\App\Http\Controllers\System\Admin\Log::class, 'getStatistics']);
|
||||||
Route::get('/{id}', [\App\Http\Controllers\System\Admin\Log::class, 'show']);
|
Route::get('/{id}', [\App\Http\Controllers\System\Admin\Log::class, 'show']);
|
||||||
Route::delete('/{id}', [\App\Http\Controllers\System\Admin\Log::class, 'destroy']);
|
Route::delete('/{id}', [\App\Http\Controllers\System\Admin\Log::class, 'destroy']);
|
||||||
Route::post('/batch-delete', [\App\Http\Controllers\System\Admin\Log::class, 'batchDelete']);
|
Route::post('/batch-delete', [\App\Http\Controllers\System\Admin\Log::class, 'batchDelete']);
|
||||||
Route::post('/clear', [\App\Http\Controllers\System\Admin\Log::class, 'clearLogs']);
|
Route::post('/clear', [\App\Http\Controllers\System\Admin\Log::class, 'clearLogs']);
|
||||||
Route::post('/export', [\App\Http\Controllers\System\Admin\Log::class, 'export']);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 数据字典管理
|
// 数据字典管理
|
||||||
|
|||||||
Reference in New Issue
Block a user