Files
QMDSearch/frontend/src/layouts/MainLayout.vue
T
kplam 35b7decd49 feat: 前端页面优化与入库任务重试/删除
- 概览与检索合并为单一概览页
- 类目/文档管理/入库整合为树形目录知识库页(Library),支持文档搜索
- 入库进度改为右侧 Drawer,支持任务详情、重试、删除
- 后端新增任务重试与删除接口
2026-08-04 12:58:11 +08:00

361 lines
8.9 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import { computed, h, reactive, ref, watch } from 'vue'
import { useRoute, useRouter, RouterView } from 'vue-router'
import { storeToRefs } from 'pinia'
import { Modal, message } from 'ant-design-vue'
import {
DashboardOutlined,
AppstoreOutlined,
SettingOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
LogoutOutlined,
DatabaseOutlined,
ApiOutlined,
TeamOutlined,
ClockCircleOutlined
} from '@ant-design/icons-vue'
import { useAuthStore } from '@/stores/useAuthStore'
import { changeMyPassword } from '@/api/auth'
import { callApi } from '@/api/client'
import TasksDrawer from '@/views/TasksDrawer.vue'
import { useTasksDrawer } from '@/composables/useTasksDrawer'
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const { user, displayName } = storeToRefs(authStore)
const tasksDrawer = useTasksDrawer()
const collapsed = ref(false)
const selectedKeys = computed(() => {
return [route.name ? String(route.name) : '']
})
const openKeys = ref(['main'])
const menuItems = computed(() => {
const items = [
{ key: 'overview', icon: () => h(DashboardOutlined), label: '概览与检索' },
{ key: 'library', icon: () => h(AppstoreOutlined), label: '知识库' },
{ key: 'tasks', icon: () => h(ClockCircleOutlined), label: '入库进度' },
{ key: 'settings', icon: () => h(SettingOutlined), label: '设置' },
{ key: 'api-docs', icon: () => h(ApiOutlined), label: 'API 说明' }
]
if (authStore.isAdmin) {
items.push({ key: 'users', icon: () => h(TeamOutlined), label: '用户管理' })
}
return items
})
function handleMenuClick({ key }) {
if (key === 'tasks') {
tasksDrawer.open()
return
}
if (key && key !== route.name) {
router.push({ name: key })
}
}
function handleLogout() {
Modal.confirm({
title: '确认登出',
content: '确定要退出登录吗?',
okText: '登出',
cancelText: '取消',
onOk() {
authStore.logout()
router.replace({ name: 'login' })
}
})
}
/* ---------- 强制改密弹窗(must_change_password 用户) ---------- */
const passwordModalVisible = ref(false)
const passwordSubmitting = ref(false)
const passwordFormRef = ref(null)
const passwordForm = reactive({
old_password: '',
new_password: '',
confirm_password: ''
})
const passwordRules = {
old_password: [{ required: true, message: '请输入当前密码', trigger: 'blur' }],
new_password: [
{ required: true, message: '请输入新密码', trigger: 'blur' },
{ min: 8, message: '密码至少 8 位', trigger: 'blur' }
],
confirm_password: [
{ required: true, message: '请确认新密码', trigger: 'blur' },
{
validator: (_rule, value) =>
value === passwordForm.new_password
? Promise.resolve()
: Promise.reject(new Error('两次输入的密码不一致')),
trigger: 'blur'
}
]
}
// must_change_password 为 true 时自动弹出改密弹窗(不可关闭)
watch(
() => user.value?.must_change_password,
(val) => {
passwordModalVisible.value = val === true
},
{ immediate: true }
)
async function handleSubmitPassword() {
try {
await passwordFormRef.value.validate()
} catch {
return
}
passwordSubmitting.value = true
try {
await callApi(
() => changeMyPassword(passwordForm.old_password, passwordForm.new_password),
{ errorText: '修改密码失败' }
)
authStore.markPasswordChanged()
message.success('密码修改成功')
passwordModalVisible.value = false
passwordForm.old_password = ''
passwordForm.new_password = ''
passwordForm.confirm_password = ''
} catch {
// 错误已由 callApi 弹出
} finally {
passwordSubmitting.value = false
}
}
</script>
<template>
<a-layout class="main-layout">
<a-layout-sider
v-model:collapsed="collapsed"
collapsible
:trigger="null"
breakpoint="lg"
class="main-layout__sider"
>
<div class="main-layout__logo">
<span class="main-layout__logo-icon">
<DatabaseOutlined />
</span>
<span v-if="!collapsed" class="main-layout__logo-text">QMDSearch</span>
</div>
<a-menu
theme="dark"
mode="inline"
:selected-keys="selectedKeys"
:open-keys="openKeys"
:items="menuItems"
@click="handleMenuClick"
/>
</a-layout-sider>
<a-layout>
<a-layout-header class="main-layout__header">
<a-button
type="text"
class="main-layout__collapse"
@click="collapsed = !collapsed"
>
<MenuUnfoldOutlined v-if="collapsed" />
<MenuFoldOutlined v-else />
</a-button>
<div class="main-layout__title">知识库管理后台</div>
<div class="main-layout__user">
<span class="main-layout__user-name">{{ displayName }}</span>
<a-divider type="vertical" />
<a-button
type="text"
size="small"
class="main-layout__logout"
@click="handleLogout"
>
<template #icon><LogoutOutlined /></template>
登出
</a-button>
</div>
</a-layout-header>
<a-layout-content class="main-layout__content">
<RouterView />
</a-layout-content>
<a-layout-footer class="main-layout__footer">
QMDSearch Admin · 当前用户{{ user?.username || '-' }}
</a-layout-footer>
</a-layout>
<!-- 入库进度右侧 Drawer -->
<TasksDrawer />
<!-- 强制改密弹窗must_change_password 用户不可关闭 -->
<a-modal
v-model:open="passwordModalVisible"
:mask-closable="false"
:closable="false"
:keyboard="false"
:destroy-on-close="true"
title="首次登录须修改密码"
:confirm-loading="passwordSubmitting"
ok-text="确认修改"
:cancel-button-props="{ style: { display: 'none' } }"
@ok="handleSubmitPassword"
>
<a-alert
type="warning"
show-icon
message="检测到首次登录或管理员重置密码,请立即修改密码后方可使用系统。"
style="margin-bottom: 16px"
/>
<a-form
ref="passwordFormRef"
:model="passwordForm"
:rules="passwordRules"
layout="vertical"
>
<a-form-item label="当前密码" name="old_password">
<a-input-password
v-model:value="passwordForm.old_password"
placeholder="请输入当前密码"
autocomplete="current-password"
/>
</a-form-item>
<a-form-item label="新密码" name="new_password">
<a-input-password
v-model:value="passwordForm.new_password"
placeholder="至少 8 位"
autocomplete="new-password"
/>
</a-form-item>
<a-form-item label="确认新密码" name="confirm_password">
<a-input-password
v-model:value="passwordForm.confirm_password"
placeholder="再次输入新密码"
autocomplete="new-password"
/>
</a-form-item>
</a-form>
</a-modal>
</a-layout>
</template>
<style scoped>
.main-layout {
min-height: 100vh;
}
.main-layout__sider {
position: sticky;
top: 0;
height: 100vh;
overflow: auto;
box-shadow: 2px 0 8px rgba(0, 21, 41, 0.15);
}
.main-layout__logo {
height: 52px;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 0 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
background: linear-gradient(135deg, #001529 0%, #002140 100%);
}
.main-layout__logo-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 6px;
background: linear-gradient(135deg, #1677ff 0%, #4096ff 100%);
color: #fff;
font-size: 16px;
box-shadow: 0 2px 6px rgba(22, 119, 255, 0.4);
}
.main-layout__logo-text {
color: #fff;
font-size: 17px;
font-weight: 700;
letter-spacing: 0.5px;
white-space: nowrap;
}
.main-layout__header {
display: flex;
align-items: center;
background: #fff;
padding: 0 20px;
height: 52px;
line-height: 52px;
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
z-index: 10;
}
.main-layout__collapse {
flex: 0 0 auto;
font-size: 16px;
color: #4b5563;
}
.main-layout__collapse:hover {
color: #1677ff;
}
.main-layout__title {
flex: 1 1 auto;
margin-left: 12px;
font-size: 16px;
font-weight: 600;
color: #1f2937;
}
.main-layout__user {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 4px;
}
.main-layout__user-name {
color: #4b5563;
font-size: 13px;
}
.main-layout__logout {
color: #6b7280;
}
.main-layout__logout:hover {
color: #ff4d4f;
}
.main-layout__content {
margin: 16px;
padding: 0;
background: transparent;
overflow: auto;
}
.main-layout__footer {
text-align: center;
color: #6b7280;
font-size: 12px;
background: transparent;
padding: 12px 16px;
}
</style>