35b7decd49
- 概览与检索合并为单一概览页 - 类目/文档管理/入库整合为树形目录知识库页(Library),支持文档搜索 - 入库进度改为右侧 Drawer,支持任务详情、重试、删除 - 后端新增任务重试与删除接口
361 lines
8.9 KiB
Vue
361 lines
8.9 KiB
Vue
<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>
|