feat: 前端 UI 增强 - 用户搜索筛选、Agent 面板样式优化、导航更新

- UserManagementComponent.vue: 新增用户名/ID/手机号搜索 + 部门/权限筛选
- AgentManagePanel.vue: 优化后端选择器样式、侧边栏条件渲染
- QuerySection.vue: 微调检索结果文字大小与样式
- AppLayout.vue: 更新导航名称与图标(扩展管理→智能体扩展等)
This commit is contained in:
Wenjie Zhang 2026-05-26 17:24:04 +08:00
parent 5b1fd51b27
commit 2ada27b285
4 changed files with 213 additions and 36 deletions

View File

@ -59,7 +59,7 @@
</div> </div>
<div v-else> <div v-else>
<div class="result-summary"> <div class="result-summary">
<strong>检索到 {{ queryResult.length }} 个相关文档块</strong> <span>检索到 {{ queryResult.length }} 个相关文档块</span>
<a-button <a-button
type="text" type="text"
size="small" size="small"
@ -514,7 +514,8 @@ defineExpose({
background-color: var(--main-50); background-color: var(--main-50);
border-radius: 6px; border-radius: 6px;
color: var(--gray-800); color: var(--gray-800);
font-weight: 500; font-size: 13px;
span { font-weight: 500; }
} }
.clear-results-btn { .clear-results-btn {
@ -578,6 +579,7 @@ defineExpose({
.result-content { .result-content {
padding: 8px 0; padding: 8px 0;
line-height: 1.6; line-height: 1.6;
font-size: 13px;
color: var(--gray-900); color: var(--gray-900);
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-word; word-break: break-word;

View File

@ -15,7 +15,9 @@
title="刷新" title="刷新"
class="refresh-btn lucide-icon-btn" class="refresh-btn lucide-icon-btn"
> >
<template #icon><RefreshCw :size="16" :class="{ spin: userManagement.refreshing }" /></template> <template #icon>
<RefreshCw :size="16" :class="{ spin: userManagement.refreshing }" />
</template>
</a-button> </a-button>
<a-button type="primary" @click="showAddUserModal" class="add-btn lucide-icon-btn"> <a-button type="primary" @click="showAddUserModal" class="add-btn lucide-icon-btn">
<template #icon><Plus :size="16" /></template> <template #icon><Plus :size="16" /></template>
@ -24,6 +26,35 @@
</div> </div>
</div> </div>
<div class="filter-section">
<a-input
v-model:value="userManagement.searchKeyword"
class="search-input"
placeholder="搜索用户名 / ID / 手机号"
allow-clear
>
<template #prefix><Search :size="16" /></template>
</a-input>
<div class="filter-actions">
<a-select v-model:value="userManagement.departmentFilter" class="filter-select">
<a-select-option value="">全部部门</a-select-option>
<a-select-option
v-for="dept in departmentFilterOptions"
:key="dept.value"
:value="dept.value"
>
{{ dept.label }}
</a-select-option>
</a-select>
<a-select v-model:value="userManagement.roleFilter" class="filter-select">
<a-select-option value="">全部权限</a-select-option>
<a-select-option value="superadmin">超级管理员</a-select-option>
<a-select-option value="admin">管理员</a-select-option>
<a-select-option value="user">普通用户</a-select-option>
</a-select>
</div>
</div>
<!-- 主内容区域 --> <!-- 主内容区域 -->
<div class="content-section"> <div class="content-section">
<a-spin :spinning="userManagement.loading"> <a-spin :spinning="userManagement.loading">
@ -32,11 +63,13 @@
</div> </div>
<div class="cards-container"> <div class="cards-container">
<div v-if="userManagement.users.length === 0" class="empty-state"> <div v-if="filteredUsers.length === 0" class="empty-state">
<a-empty description="暂无用户数据" /> <a-empty
:description="userManagement.users.length === 0 ? '暂无用户数据' : '没有匹配的用户'"
/>
</div> </div>
<div v-else class="user-cards-grid"> <div v-else class="user-cards-grid">
<div v-for="user in userManagement.users" :key="user.id" class="user-card"> <div v-for="user in filteredUsers" :key="user.id" class="user-card">
<div class="card-header"> <div class="card-header">
<div class="user-info-main"> <div class="user-info-main">
<div class="user-avatar"> <div class="user-avatar">
@ -225,11 +258,11 @@
</template> </template>
<script setup> <script setup>
import { reactive, onMounted, watch } from 'vue' import { reactive, onMounted, watch, computed } from 'vue'
import { message, Modal } from 'ant-design-vue' import { message, Modal } from 'ant-design-vue'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import { departmentApi } from '@/apis' import { departmentApi } from '@/apis'
import { Plus, Pencil, Trash2, User, UserLock, UserStar, RefreshCw } from 'lucide-vue-next' import { Plus, Pencil, Trash2, User, UserLock, UserStar, RefreshCw, Search } from 'lucide-vue-next'
import { formatDateTime } from '@/utils/time' import { formatDateTime } from '@/utils/time'
const userStore = useUserStore() const userStore = useUserStore()
@ -239,6 +272,9 @@ const userManagement = reactive({
loading: false, loading: false,
refreshing: false, refreshing: false,
users: [], users: [],
searchKeyword: '',
departmentFilter: '',
roleFilter: '',
error: null, error: null,
modalVisible: false, modalVisible: false,
modalTitle: '添加用户', modalTitle: '添加用户',
@ -263,6 +299,55 @@ const departmentManagement = reactive({
departments: [] departments: []
}) })
const departmentFilterOptions = computed(() => {
const options = new Map()
departmentManagement.departments.forEach((dept) => {
options.set(String(dept.id), {
value: String(dept.id),
label: dept.name
})
})
userManagement.users.forEach((user) => {
const departmentId = user.department_id
const departmentName = user.department_name
if (departmentId == null && !departmentName) return
const value = String(departmentId ?? departmentName)
if (!options.has(value)) {
options.set(value, {
value,
label: departmentName || `部门 ${departmentId}`
})
}
})
return [...options.values()]
})
const filteredUsers = computed(() => {
const keyword = userManagement.searchKeyword.trim().toLowerCase()
return userManagement.users.filter((user) => {
const matchesKeyword =
!keyword ||
[user.username, user.uid, user.phone_number].some((value) =>
String(value || '')
.toLowerCase()
.includes(keyword)
)
const matchesDepartment =
!userManagement.departmentFilter ||
String(user.department_id ?? user.department_name ?? '') === userManagement.departmentFilter
const matchesRole = !userManagement.roleFilter || user.role === userManagement.roleFilter
return matchesKeyword && matchesDepartment && matchesRole
})
})
// //
const fetchDepartments = async () => { const fetchDepartments = async () => {
if (!userStore.isSuperAdmin) return // if (!userStore.isSuperAdmin) return //
@ -613,6 +698,57 @@ onMounted(async () => {
} }
} }
.filter-section {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
flex-wrap: wrap;
.search-input {
width: 300px;
max-width: 100%;
:deep(.ant-input-prefix) {
color: var(--gray-500);
margin-right: 6px;
}
}
.filter-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
margin-left: auto;
}
.filter-select {
width: 150px;
}
}
@media (max-width: 640px) {
.filter-section {
align-items: stretch;
.search-input,
.filter-actions {
width: 100%;
}
.filter-actions {
margin-left: 0;
}
.filter-select {
flex: 1;
min-width: 0;
}
}
}
.content-section { .content-section {
overflow: hidden; overflow: hidden;

View File

@ -34,6 +34,7 @@ const agentLoading = ref(false)
const saving = ref(false) const saving = ref(false)
const searchQuery = ref('') const searchQuery = ref('')
const DEFAULT_AGENT_BACKEND_ID = 'ChatbotAgent'
const agentBackendOptions = ref([]) const agentBackendOptions = ref([])
const showAgentModal = ref(false) const showAgentModal = ref(false)
@ -46,7 +47,7 @@ const agentShareConfig = ref({ access_level: 'user', department_ids: [], user_ui
const agentForm = reactive({ const agentForm = reactive({
slug: '', slug: '',
name: '', name: '',
backend_id: 'ChatbotAgent', backend_id: DEFAULT_AGENT_BACKEND_ID,
description: '', description: '',
icon: '' icon: ''
}) })
@ -63,6 +64,7 @@ const agentModalMenuItems = computed(() => {
} }
return items return items
}) })
const showAgentModalSidebar = computed(() => agentModalMenuItems.value.length > 1)
const runtimeConfigSegment = computed(() => const runtimeConfigSegment = computed(() =>
runtimeAgentModalTabs.includes(agentModalActiveTab.value) ? agentModalActiveTab.value : 'model' runtimeAgentModalTabs.includes(agentModalActiveTab.value) ? agentModalActiveTab.value : 'model'
) )
@ -91,7 +93,7 @@ const agentStats = computed(() => ({
manageable: agentStore.agents.filter((agent) => agent.can_manage).length, manageable: agentStore.agents.filter((agent) => agent.can_manage).length,
global: agentStore.agents.filter((agent) => agent.share_config?.access_level === 'global').length global: agentStore.agents.filter((agent) => agent.share_config?.access_level === 'global').length
})) }))
const getDefaultBackendId = () => agentBackendOptions.value[0]?.value || 'ChatbotAgent' const getDefaultBackendId = () => DEFAULT_AGENT_BACKEND_ID
const getInitialShareConfig = () => ({ const getInitialShareConfig = () => ({
access_level: userStore.isAdmin ? 'global' : 'user', access_level: userStore.isAdmin ? 'global' : 'user',
@ -183,7 +185,7 @@ const openEditAgentModal = async (agent) => {
Object.assign(agentForm, { Object.assign(agentForm, {
slug: detail.id || detail.slug || '', slug: detail.id || detail.slug || '',
name: detail.name || '', name: detail.name || '',
backend_id: detail.backend_id || 'ChatbotAgent', backend_id: detail.backend_id || DEFAULT_AGENT_BACKEND_ID,
description: detail.description || '', description: detail.description || '',
icon: detail.icon || '' icon: detail.icon || ''
}) })
@ -404,8 +406,8 @@ defineExpose({
</div> </div>
</div> </div>
</template> </template>
<div class="agent-modal-content"> <div class="agent-modal-content" :class="{ 'without-sidebar': !showAgentModalSidebar }">
<aside class="agent-modal-sidebar" aria-label="智能体配置分组"> <aside v-if="showAgentModalSidebar" class="agent-modal-sidebar" aria-label="智能体配置分组">
<button <button
v-for="item in agentModalMenuItems" v-for="item in agentModalMenuItems"
:key="item.key" :key="item.key"
@ -461,15 +463,17 @@ defineExpose({
<span v-else class="agent-inline-slug">{{ agentForm.slug || editingAgentId }}</span> <span v-else class="agent-inline-slug">{{ agentForm.slug || editingAgentId }}</span>
</div> </div>
</div> </div>
<div class="agent-backend-summary" aria-label="智能体后端"> <div class="agent-backend-summary" :class="{ editable: !editingAgentId }" aria-label="智能体后端">
<component :is="selectedBackendIcon" :size="16" /> <span class="agent-backend-icon">
<component :is="selectedBackendIcon" :size="16" />
</span>
<div class="agent-backend-text"> <div class="agent-backend-text">
<span class="agent-backend-label">后端</span> <span class="agent-backend-label">智能体后端</span>
<a-select <a-select
v-if="!editingAgentId" v-if="!editingAgentId"
v-model:value="agentForm.backend_id" v-model:value="agentForm.backend_id"
class="agent-backend-select" class="agent-backend-select"
size="small" :bordered="false"
:options="agentBackendOptions" :options="agentBackendOptions"
/> />
<span v-else class="agent-backend-name">{{ selectedBackendLabel }}</span> <span v-else class="agent-backend-name">{{ selectedBackendLabel }}</span>
@ -611,6 +615,10 @@ defineExpose({
border: 1px solid var(--gray-150); border: 1px solid var(--gray-150);
border-radius: 12px; border-radius: 12px;
background: var(--gray-0); background: var(--gray-0);
&.without-sidebar {
grid-template-columns: minmax(0, 1fr);
}
} }
.agent-modal-sidebar { .agent-modal-sidebar {
@ -656,10 +664,11 @@ defineExpose({
} }
&.active { &.active {
border-color: var(--main-100);
background: var(--gray-0); background: var(--gray-0);
color: var(--main-800); color: var(--main-800);
font-weight: 600; span {
font-weight: 600;
}
} }
} }
@ -862,19 +871,38 @@ defineExpose({
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
flex-shrink: 0; flex-shrink: 0;
gap: 8px; gap: 10px;
min-height: 40px; min-width: 220px;
padding: 7px 10px; min-height: 56px;
border: 1px solid var(--gray-150); padding: 10px 12px;
border: 1px solid var(--main-100);
border-radius: 12px;
background: linear-gradient(135deg, var(--gray-0), var(--main-10));
color: var(--main-700);
&.editable {
padding-right: 8px;
}
}
.agent-backend-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 32px;
height: 32px;
border-radius: 10px; border-radius: 10px;
background: var(--gray-0); background: var(--main-50);
color: var(--main-700); color: var(--main-700);
} }
.agent-backend-text { .agent-backend-text {
display: flex; display: flex;
flex: 1;
flex-direction: column; flex-direction: column;
gap: 2px; min-width: 0;
gap: 3px;
line-height: 1.2; line-height: 1.2;
} }
@ -886,19 +914,30 @@ defineExpose({
.agent-backend-name { .agent-backend-name {
max-width: 180px; max-width: 180px;
overflow: hidden; overflow: hidden;
color: var(--gray-800); color: var(--gray-900);
font-size: 12px; font-size: 13px;
font-weight: 600; font-weight: 600;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.agent-backend-select { .agent-backend-select {
min-width: 160px; width: 170px;
margin: -3px 0 -5px -11px;
:deep(.ant-select-selector) { :deep(.ant-select-selector) {
border-color: var(--gray-150) !important; background: transparent !important;
border-radius: 8px !important; box-shadow: none !important;
}
:deep(.ant-select-selection-item) {
color: var(--gray-900);
font-size: 13px;
font-weight: 600;
}
:deep(.ant-select-arrow) {
color: var(--gray-500);
} }
} }

View File

@ -5,7 +5,7 @@ import { GithubOutlined } from '@ant-design/icons-vue'
import { import {
BarChart3, BarChart3,
ClipboardList, ClipboardList,
Blocks, LibraryBig,
Box, Box,
FolderKanban, FolderKanban,
PanelLeftClose, PanelLeftClose,
@ -137,22 +137,22 @@ const mainList = computed(() => {
if (userStore.isAdmin) { if (userStore.isAdmin) {
items.push({ items.push({
name: '扩展管理', name: '智能体扩展',
path: '/extensions', path: '/extensions',
activePaths: ['/extensions'], activePaths: ['/extensions'],
icon: Blocks, icon: LibraryBig,
activeIcon: Blocks activeIcon: LibraryBig
}) })
items.push({ items.push({
name: '模型管理', name: '智能体管理',
path: '/model-manage', path: '/model-manage',
icon: Box, icon: Box,
activeIcon: Box activeIcon: Box
}) })
items.push({ items.push({
name: 'Dashboard', name: '数据总览',
path: '/dashboard', path: '/dashboard',
icon: BarChart3, icon: BarChart3,
activeIcon: BarChart3 activeIcon: BarChart3