This commit is contained in:
2026-02-17 13:55:30 +08:00
parent f90afaddca
commit 6623c656f4
17 changed files with 1464 additions and 569 deletions

View File

@@ -85,7 +85,7 @@ class Department extends Controller
{
$validated = $request->validate([
'name' => 'required|string|max:50',
'parent_id' => 'nullable|integer|exists:auth_departments,id',
'parent_id' => 'nullable|integer',
'leader' => 'nullable|string|max:50',
'phone' => 'nullable|string|max:20',
'sort' => 'nullable|integer|min:0',
@@ -108,7 +108,7 @@ class Department extends Controller
{
$validated = $request->validate([
'name' => 'nullable|string|max:50',
'parent_id' => 'nullable|integer|exists:auth_departments,id',
'parent_id' => 'nullable|integer',
'leader' => 'nullable|string|max:50',
'phone' => 'nullable|string|max:20',
'sort' => 'nullable|integer|min:0',

View File

@@ -5,6 +5,7 @@ namespace App\Http\Controllers\System\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\LogRequest;
use App\Services\System\LogService;
use Illuminate\Http\Request;
use Maatwebsite\Excel\Facades\Excel;
use App\Exports\GenericExport;
@@ -30,39 +31,9 @@ class Log extends Controller
public function export(LogRequest $request)
{
$params = $request->validated();
$pageSize = $params['page_size'] ?? 10000; // 导出时默认获取更多数据
$filePath = $this->logService->export($params);
// 获取所有符合条件的日志(不分页)
$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);
return response()->download($filePath)->deleteFileAfterSend(true);
}
public function show(int $id)
@@ -93,7 +64,7 @@ class Log extends Controller
]);
}
public function batchDelete(Request $request)
public function batchDelete(\Illuminate\Http\Request $request)
{
$this->logService->batchDelete($request->input('ids', []));
return response()->json([

View File

@@ -9,7 +9,7 @@ use Illuminate\Http\Exceptions\HttpResponseException;
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
*/
@@ -19,7 +19,7 @@ class LogRequest extends FormRequest
}
/**
* Get the validation rules that apply to the request.
* Get validation rules that apply to request.
*
* @return array
*/
@@ -34,6 +34,7 @@ class LogRequest extends FormRequest
'username' => 'nullable|string|max:50',
'module' => 'nullable|string|max:50',
'action' => 'nullable|string|max:100',
'method' => 'nullable|in:GET,POST,PUT,DELETE,PATCH',
'status' => 'nullable|in:success,error',
'start_date' => 'nullable|date',
'end_date' => 'nullable|date|after_or_equal:start_date',
@@ -77,6 +78,7 @@ class LogRequest extends FormRequest
'username.max' => '用户名最多50个字符',
'module.max' => '模块名最多50个字符',
'action.max' => '操作名最多100个字符',
'method.in' => '请求方式必须是 GET、POST、PUT、DELETE 或 PATCH',
'status.in' => '状态值必须是 success 或 error',
'start_date.date' => '开始日期格式不正确',
'end_date.date' => '结束日期格式不正确',
@@ -121,7 +123,7 @@ class LogRequest extends FormRequest
}
/**
* Prepare the data for validation.
* Prepare for validation.
*
* @return void
*/

View File

@@ -48,7 +48,7 @@ class LogService
*/
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'])) {
$query->where('user_id', $params['user_id']);
@@ -108,6 +108,14 @@ class LogService
{
$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'])) {
$query->whereBetween('created_at', [$params['start_date'], $params['end_date']]);
}
@@ -116,10 +124,74 @@ class LogService
$successCount = (clone $query)->where('status', 'success')->count();
$errorCount = (clone $query)->where('status', 'error')->count();
// 计算平均响应时间
$avgTime = (clone $query)->avg('execution_time');
$avgTime = $avgTime ? round($avgTime, 2) : 0;
return [
'total' => $total,
'success' => $successCount,
'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;
}
}

View File

@@ -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: {
list: {

View File

@@ -52,37 +52,45 @@ export default {
// 操作日志管理
logs: {
list: {
get: async function (params) {
return await request.get('logs', { params })
list: {
get: async function (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: {

View File

@@ -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>

View File

@@ -1,5 +1,5 @@
<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-tree">
<a-tree ref="menuTreeRef" v-model:checkedKeys="checkedPermissionIds" :tree-data="permissionTree"
@@ -12,8 +12,10 @@
</div>
</div>
<template #footer>
<a-button @click="handleCancel"> </a-button>
<a-button type="primary" :loading="isSaveing" @click="submit"> </a-button>
<a-space>
<a-button @click="handleCancel"> </a-button>
<a-button type="primary" :loading="isSaveing" @click="submit"> </a-button>
</a-space>
</template>
</a-modal>
</template>

View File

@@ -1,6 +1,5 @@
<template>
<a-modal :title="titleMap[mode]" :open="visible" :width="500" :destroy-on-close="true" :footer="null"
@cancel="handleCancel">
<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">
@@ -20,8 +19,10 @@
</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-space>
<a-button @click="handleCancel"> </a-button>
<a-button v-if="mode !== 'show'" type="primary" :loading="isSaveing" @click="submit"> </a-button>
</a-space>
</template>
</a-modal>
</template>

View File

@@ -72,13 +72,28 @@
</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="handlePermission(record)">权限</a-button>
<a-button type="link" size="small" @click="handleCopy(record)">复制</a-button>
<a-popconfirm title="确定删除该角色吗?" @confirm="handleDelete(record)">
<a-button type="link" size="small" danger>删除</a-button>
</a-popconfirm>
<a-dropdown>
<a-button type="link" size="small">
更多
<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>
</template>
</scTable>
@@ -100,6 +115,9 @@
<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" />
</template>
<script setup>
@@ -122,6 +140,7 @@ 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'
@@ -160,12 +179,14 @@ const dialog = reactive({
save: false,
permission: false,
import: false,
export: false
export: false,
copy: false
})
// 弹窗引用
const saveDialogRef = ref(null)
const permissionDialogRef = ref(null)
const copyDialogRef = ref(null)
// 行key
const rowKey = 'id'
@@ -178,7 +199,7 @@ const columns = [
{ 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: 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) => {
try {
const res = await authApi.roles.delete.delete(record.id)
if (res.code === 200) {
message.success('删除成功')
refreshTable()
} else {
message.error(res.message || '删除失败')
const handleDelete = (record) => {
Modal.confirm({
title: '确认删除',
content: '确定删除该角色吗?',
okText: '确定',
cancelText: '取消',
okType: 'danger',
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) => {
// TODO: 实现复制角色弹窗
message.info('复制角色功能开发中...')
dialog.copy = true
setTimeout(() => {
copyDialogRef.value?.open(record)
}, 0)
}
// 批量复制角色
@@ -298,8 +330,34 @@ const handleBatchCopy = () => {
message.warning('请选择要复制的角色')
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()
}
// 权限设置

View File

@@ -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>

View File

@@ -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>

View File

@@ -1,444 +1,50 @@
<template>
<div class="dashboard-container">
<!-- 统计卡片 -->
<a-row :gutter="16" class="stats-cards">
<a-col :xs="24" :sm="12" :lg="6">
<a-card :loading="loading" hoverable>
<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>
<!-- 欢迎组件 -->
<div class="welcome-section">
<Welcome />
</div>
<!-- 图表区域 -->
<a-row :gutter="16" class="chart-row">
<a-col :xs="24" :lg="12">
<a-card title="用户分布" :loading="loading">
<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 class="quick-actions-section">
<QuickActions />
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores/modules/user'
import authApi from '@/api/auth'
import { message } from 'ant-design-vue'
import Welcome from './components/Welcome.vue'
import QuickActions from './components/QuickActions.vue'
// 定义组件名称
defineOptions({
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>
<style scoped lang="scss">
.dashboard-container {
padding: 20px;
background: #f0f2f5;
min-height: calc(100vh - 64px);
.stats-cards {
margin-bottom: 16px;
.welcome-section {
margin-bottom: 20px;
}
.quick-actions-section {
:deep(.ant-card) {
border-radius: 8px;
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) {
padding: 10px;
.stats-cards {
.welcome-section {
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>

View File

@@ -25,41 +25,48 @@ const loading = ref(false)
// 表单初始值
const initialValues = computed(() => ({
username: props.userInfo.username || '',
nickname: props.userInfo.nickname || '',
mobile: props.userInfo.mobile || '',
real_name: props.userInfo.real_name || '',
phone: props.userInfo.phone || '',
email: props.userInfo.email || '',
gender: props.userInfo.gender || 0,
birthday: props.userInfo.birthday || null,
bio: props.userInfo.bio || '',
}))
// 表单项配置
const formItems = [
{ field: 'username', label: '用户名', type: 'input' },
{
field: 'nickname', label: '昵称', type: 'input', required: true,
field: 'username',
label: '用户名',
type: 'input',
rules: [
{ required: true, message: '请输入昵称', trigger: 'blur' },
{ min: 2, max: 20, message: '昵称长度在 2 到 20 个字符', trigger: 'blur' },
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 3, max: 20, message: '用户名长度在 3 到 20 个字符', trigger: 'blur' },
],
},
{
field: 'phone', label: '手机号', type: 'input',
rules: [{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }],
},
{
field: 'email', label: '邮箱', type: 'input',
rules: [{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' }],
},
{
field: 'gender', label: '性别', type: 'radio',
options: [
{ label: '男', value: 1 },
{ label: '女', value: 2 },
{ label: '保密', value: 0 },
field: 'real_name',
label: '真实姓名',
type: 'input',
required: true,
rules: [
{ required: true, message: '请输入真实姓名', trigger: 'blur' },
{ min: 2, max: 20, message: '姓名长度在 2 到 20 个字符', trigger: 'blur' },
],
},
{
field: 'phone',
label: '手机号',
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 {
loading.value = true
// 调用更新当前用户信息接口
let res = await api.users.edit.post({
// 获取当前用户ID
const userId = userStore.userInfo?.id
if (!userId) {
throw new Error('用户信息不存在,请重新登录')
}
// 调用更新用户信息接口
const res = await api.users.edit.put(userId, {
username: values.username,
nickname: values.nickname,
mobile: values.mobile,
real_name: values.real_name,
phone: values.phone,
email: values.email,
gender: values.gender,
remark: values.remark,
})
if (!res || res.code !== 1) {
if (!res || res.code !== 200) {
throw new Error(res.message || '保存失败,请重试')
}
// 重新获取用户信息
const response = await api.user.get()
const response = await api.me.get()
if (response && response.data) {
userStore.setUserInfo(response.data)
emit('update', response.data)
}
// 通知父组件更新
emit('update', values)
message.success('保存成功')
} catch (error) {
message.error(error.message || '保存失败,请重试')

View File

@@ -6,30 +6,36 @@
<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'
const emit = defineEmits(['success'])
const router = useRouter()
const userStore = useUserStore()
const loading = ref(false)
// 表单初始值
const initialValues = {
oldPassword: '',
newPassword: '',
confirmPassword: '',
}
const initialValues = ref({
old_password: '',
password: '',
password_confirmation: '',
})
// 表单项配置
const formItems = [
{
field: 'oldPassword',
field: 'old_password',
label: '原密码',
type: 'password',
required: true,
rules: [{ required: true, message: '请输入原密码', trigger: 'blur' }],
rules: [
{ required: true, message: '请输入原密码', trigger: 'blur' },
],
},
{
field: 'newPassword',
field: 'password',
label: '新密码',
type: 'password',
required: true,
@@ -39,15 +45,19 @@ const formItems = [
],
},
{
field: 'confirmPassword',
field: 'password_confirmation',
label: '确认密码',
type: 'password',
required: true,
dependencies: ['password'],
rules: [
{ required: true, message: '请再次输入新密码', trigger: 'blur' },
{
validator: (rule, value) => {
if (value !== initialValues.newPassword) {
validator: (_, value) => {
if (!value || !initialValues.value.password) {
return Promise.resolve()
}
if (value !== initialValues.value.password) {
return Promise.reject('两次输入的密码不一致')
}
return Promise.resolve()
@@ -59,22 +69,47 @@ const formItems = [
]
// 表单提交
const handleFinish = (values) => {
loading.value = true
// 模拟接口请求
setTimeout(() => {
const handleFinish = async (values) => {
try {
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 || '密码修改失败,请重试')
}
message.success('密码修改成功,请重新登录')
// 清除用户信息和token
userStore.logout()
// 延迟跳转到登录页
setTimeout(() => {
router.push('/login')
}, 1500)
emit('success')
handleReset()
} catch (error) {
message.error(error.message || '密码修改失败,请重试')
} finally {
loading.value = false
}, 1000)
}
}
// 重置表单
const handleReset = () => {
initialValues.oldPassword = ''
initialValues.newPassword = ''
initialValues.confirmPassword = ''
initialValues.value = {
old_password: '',
password: '',
password_confirmation: '',
}
}
</script>

View File

@@ -87,7 +87,7 @@ const initUserInfo = async () => {
userInfo.value = storeUserInfo
} else {
// 如果 store 中没有用户信息,则从接口获取
const response = await api.user.get()
const response = await api.me.get()
if (response && response.data) {
userStore.setUserInfo(response.data)
userInfo.value = response.data
@@ -140,21 +140,52 @@ const handleAvatarChange = ({ fileList }) => {
}
// 上传头像
const handleAvatarUpload = () => {
const handleAvatarUpload = async () => {
if (avatarFileList.value.length === 0) {
message.warning('请先选择头像')
return
}
loading.value = true
// 模拟上传
setTimeout(() => {
const file = avatarFileList.value[0]
userInfo.value.avatar = URL.createObjectURL(file.originFileObj)
try {
loading.value = true
const file = avatarFileList.value[0].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('头像更新成功')
showAvatarModal.value = false
avatarFileList.value = []
} catch (error) {
message.error(error.message || '头像上传失败,请重试')
} finally {
loading.value = false
}, 1000)
}
}
onMounted(() => {

View File

@@ -101,12 +101,12 @@ Route::middleware(['auth.check:admin', 'log.request'])->group(function () {
// 系统操作日志
Route::prefix('logs')->group(function () {
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('/{id}', [\App\Http\Controllers\System\Admin\Log::class, 'show']);
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('/clear', [\App\Http\Controllers\System\Admin\Log::class, 'clearLogs']);
Route::post('/export', [\App\Http\Controllers\System\Admin\Log::class, 'export']);
});
// 数据字典管理