chore: 完成全量功能迭代与部署准备
- 移除冗余依赖包 - 新增账号禁用校验与用户管理能力 - 新增文档下载与管理页面文件展示 - 新增API文档页面与用户管理前端页面 - 重构时区处理与docker-compose部署配置 - 完善测试用例与项目文档
This commit is contained in:
@@ -17,3 +17,49 @@ export function login(username, password) {
|
||||
export function me() {
|
||||
return http.get('/api/v1/auth/me')
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出全部用户(admin 专用)
|
||||
* @returns {Promise<Array<{username:string, role:string, enabled:boolean, must_change_password:boolean, created_at:string}>>}
|
||||
*/
|
||||
export function listUsers() {
|
||||
return http.get('/api/v1/auth/users')
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建用户(admin 专用)
|
||||
* @param {string} username
|
||||
* @param {string} password
|
||||
* @param {'admin'|'user'} [role='user']
|
||||
*/
|
||||
export function createUser(username, password, role = 'user') {
|
||||
return http.post('/api/v1/auth/users', { username, password, role })
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户角色 / 启用状态(admin 专用,至少传一项)
|
||||
* @param {string} username
|
||||
* @param {{ role?: 'admin'|'user', enabled?: boolean }} payload
|
||||
*/
|
||||
export function updateUser(username, payload) {
|
||||
return http.patch(`/api/v1/auth/users/${encodeURIComponent(username)}`, payload)
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置指定用户密码(admin 专用)
|
||||
* @param {string} username
|
||||
* @param {string} newPassword
|
||||
*/
|
||||
export function resetPassword(username, newPassword) {
|
||||
return http.post(`/api/v1/auth/users/${encodeURIComponent(username)}/password`, {
|
||||
new_password: newPassword
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除用户(admin 专用)
|
||||
* @param {string} username
|
||||
*/
|
||||
export function deleteUser(username) {
|
||||
return http.delete(`/api/v1/auth/users/${encodeURIComponent(username)}`)
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@ import {
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
LogoutOutlined,
|
||||
DatabaseOutlined
|
||||
DatabaseOutlined,
|
||||
ApiOutlined,
|
||||
TeamOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import { useAuthStore } from '@/stores/useAuthStore'
|
||||
|
||||
@@ -30,14 +32,21 @@ const selectedKeys = computed(() => {
|
||||
|
||||
const openKeys = ref(['main'])
|
||||
|
||||
const menuItems = [
|
||||
{ key: 'overview', icon: () => h(DashboardOutlined), label: '概览' },
|
||||
{ key: 'documents', icon: () => h(FileTextOutlined), label: '文档管理' },
|
||||
{ key: 'ingest', icon: () => h(UploadOutlined), label: '文档入库' },
|
||||
{ key: 'search', icon: () => h(SearchOutlined), label: '检索测试台' },
|
||||
{ key: 'categories', icon: () => h(AppstoreOutlined), label: '类目列表' },
|
||||
{ key: 'settings', icon: () => h(SettingOutlined), label: '设置' }
|
||||
]
|
||||
const menuItems = computed(() => {
|
||||
const items = [
|
||||
{ key: 'overview', icon: () => h(DashboardOutlined), label: '概览' },
|
||||
{ key: 'documents', icon: () => h(FileTextOutlined), label: '文档管理' },
|
||||
{ key: 'ingest', icon: () => h(UploadOutlined), label: '文档入库' },
|
||||
{ key: 'search', icon: () => h(SearchOutlined), label: '检索测试台' },
|
||||
{ key: 'categories', icon: () => h(AppstoreOutlined), 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 && key !== route.name) {
|
||||
|
||||
@@ -49,6 +49,18 @@ const routes = [
|
||||
name: 'settings',
|
||||
component: () => import('@/views/Settings.vue'),
|
||||
meta: { title: '设置', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'api-docs',
|
||||
name: 'api-docs',
|
||||
component: () => import('@/views/ApiDocs.vue'),
|
||||
meta: { title: 'API 说明', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'users',
|
||||
name: 'users',
|
||||
component: () => import('@/views/Users.vue'),
|
||||
meta: { title: '用户管理', requiresAuth: true, requiresAdmin: true }
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -81,6 +93,10 @@ router.beforeEach((to) => {
|
||||
return { name: 'login', query: { redirect: to.fullPath } }
|
||||
}
|
||||
|
||||
if (to.meta?.requiresAdmin && !authStore.isAdmin) {
|
||||
return { name: 'overview' }
|
||||
}
|
||||
|
||||
if (to.name === 'login' && authStore.isAuthenticated) {
|
||||
return { name: 'overview' }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { ApiOutlined, SafetyCertificateOutlined, CodeOutlined } from '@ant-design/icons-vue'
|
||||
|
||||
// 以下内容整理自项目 README.md 的「API 文档」章节
|
||||
const apiList = [
|
||||
{ method: 'GET', path: '/api/v1/health', desc: '健康检查', auth: false },
|
||||
{ method: 'POST', path: '/api/v1/auth/register', desc: '用户注册(可关闭)', auth: false },
|
||||
{ method: 'POST', path: '/api/v1/auth/login', desc: '用户登录,返回 JWT token', auth: false },
|
||||
{ method: 'GET', path: '/api/v1/auth/me', desc: '获取当前用户信息', auth: true },
|
||||
{ method: 'POST', path: '/api/v1/search', desc: '分层检索', auth: true },
|
||||
{ method: 'POST', path: '/api/v1/documents', desc: '文档入库(JSON 文本,202 异步入库)', auth: true },
|
||||
{ method: 'POST', path: '/api/v1/documents/upload', desc: '文件上传入库(multipart,202 异步)', auth: true },
|
||||
{ method: 'GET', path: '/api/v1/documents/tasks/{task_id}', desc: '入库任务状态查询', auth: true },
|
||||
{ method: 'GET', path: '/api/v1/documents', desc: '文档列表(分页)', auth: true },
|
||||
{ method: 'GET', path: '/api/v1/documents/{doc_id}', desc: '文档详情', auth: true },
|
||||
{ method: 'DELETE', path: '/api/v1/documents/{doc_id}', desc: '删除文档(幂等)', auth: true },
|
||||
{ method: 'GET', path: '/api/v1/knowledge/categories', desc: '知识分类类目集', auth: true },
|
||||
{ method: 'GET', path: '/api/v1/knowledge/stats', desc: '统计(四层点数 + 类目分布)', auth: true },
|
||||
{ method: 'GET', path: '/admin', desc: '管理后台(本页)', auth: false }
|
||||
]
|
||||
|
||||
const methodColor = {
|
||||
GET: 'green',
|
||||
POST: 'blue',
|
||||
DELETE: 'red',
|
||||
PUT: 'orange'
|
||||
}
|
||||
|
||||
const baseUrl = computed(() => `${window.location.origin}/admin/`.replace(/\/admin\/$/, ''))
|
||||
|
||||
const loginExample = `curl -X POST ${baseUrl.value}/api/v1/auth/login \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"username": "admin", "password": "your-password"}'`
|
||||
|
||||
const searchExample = `curl -X POST ${baseUrl.value}/api/v1/search \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-H "Authorization: Bearer <token>" \\
|
||||
-d '{"query": "如何配置 Redis 缓存", "top_k": 5}'`
|
||||
|
||||
const uploadExample = `curl -X POST ${baseUrl.value}/api/v1/documents/upload \\
|
||||
-H "Authorization: Bearer <token>" \\
|
||||
-F "file=@document.pdf"`
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="apidocs page-section">
|
||||
<div class="apidocs__header">
|
||||
<h2 class="page-title">
|
||||
<ApiOutlined /> API 使用说明
|
||||
</h2>
|
||||
<span class="text-muted">接口前缀:{{ baseUrl }}/api/v1</span>
|
||||
</div>
|
||||
|
||||
<a-alert
|
||||
class="apidocs__tip"
|
||||
type="info"
|
||||
show-icon
|
||||
message="需要鉴权的接口请在请求头携带 Authorization: Bearer <token>,token 通过 /auth/login 获取。"
|
||||
/>
|
||||
|
||||
<!-- 统一响应格式 -->
|
||||
<section class="apidocs__block">
|
||||
<h3 class="section-subtitle">
|
||||
<CodeOutlined /> 统一响应格式
|
||||
</h3>
|
||||
<ul class="apidocs__ul">
|
||||
<li>所有接口返回统一 JSON 结构:<code>code</code> / <code>data</code> / <code>message</code>。</li>
|
||||
<li>错误码:<code>0</code> 成功,<code>1xxx</code> 客户端错误,<code>2xxx</code> 服务端错误。</li>
|
||||
</ul>
|
||||
<pre class="code-block">{
|
||||
"code": 0,
|
||||
"data": { ... },
|
||||
"message": "ok"
|
||||
}</pre>
|
||||
</section>
|
||||
|
||||
<!-- API 列表 -->
|
||||
<section class="apidocs__block">
|
||||
<h3 class="section-subtitle">接口列表</h3>
|
||||
<a-table
|
||||
:columns="[
|
||||
{ title: '方法', dataIndex: 'method', key: 'method', width: 90, align: 'center' },
|
||||
{ title: '路径', dataIndex: 'path', key: 'path', width: 320, ellipsis: true },
|
||||
{ title: '说明', dataIndex: 'desc', key: 'desc' },
|
||||
{ title: '认证', dataIndex: 'auth', key: 'auth', width: 80, align: 'center' }
|
||||
]"
|
||||
:data-source="apiList"
|
||||
:pagination="false"
|
||||
row-key="path"
|
||||
size="middle"
|
||||
>
|
||||
<template #bodyCell="{ column, text, record }">
|
||||
<template v-if="column.key === 'method'">
|
||||
<a-tag :color="methodColor[record.method]">{{ record.method }}</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'path'">
|
||||
<code class="api-path">{{ text }}</code>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'auth'">
|
||||
<a-tag :color="record.auth ? 'volcano' : 'default'">
|
||||
{{ record.auth ? '需鉴权' : '否' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</section>
|
||||
|
||||
<!-- 认证说明 -->
|
||||
<section class="apidocs__block">
|
||||
<h3 class="section-subtitle">
|
||||
<SafetyCertificateOutlined /> 获取 Token
|
||||
</h3>
|
||||
<p class="apidocs__p">先调用登录接口获取 JWT,再在后续请求的 Header 中携带:</p>
|
||||
<pre class="code-block">{{ loginExample }}</pre>
|
||||
</section>
|
||||
|
||||
<!-- 检索示例 -->
|
||||
<section class="apidocs__block">
|
||||
<h3 class="section-subtitle">
|
||||
<CodeOutlined /> 检索示例
|
||||
</h3>
|
||||
<pre class="code-block">{{ searchExample }}</pre>
|
||||
</section>
|
||||
|
||||
<!-- 文件上传示例 -->
|
||||
<section class="apidocs__block">
|
||||
<h3 class="section-subtitle">
|
||||
<CodeOutlined /> 文件上传示例
|
||||
</h3>
|
||||
<pre class="code-block">{{ uploadExample }}</pre>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.apidocs__header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.page-title :deep(.anticon) {
|
||||
margin-right: 8px;
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
.apidocs__tip {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.apidocs__block {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.section-subtitle :deep(.anticon) {
|
||||
margin-right: 6px;
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
.apidocs__ul {
|
||||
margin: 0 0 12px;
|
||||
padding-left: 20px;
|
||||
color: #4b5563;
|
||||
font-size: 13px;
|
||||
line-height: 1.9;
|
||||
}
|
||||
|
||||
.apidocs__ul code,
|
||||
.api-path {
|
||||
background: #f3f4f6;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 4px;
|
||||
padding: 1px 6px;
|
||||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||
font-size: 12px;
|
||||
color: #c0341d;
|
||||
}
|
||||
|
||||
.api-path {
|
||||
white-space: nowrap;
|
||||
color: #1677ff;
|
||||
background: rgba(22, 119, 255, 0.08);
|
||||
border-color: rgba(22, 119, 255, 0.2);
|
||||
}
|
||||
|
||||
.apidocs__p {
|
||||
color: #4b5563;
|
||||
font-size: 13px;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 16px 18px;
|
||||
overflow-x: auto;
|
||||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.7;
|
||||
margin: 0;
|
||||
white-space: pre;
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import { FileOutlined } from '@ant-design/icons-vue'
|
||||
import {
|
||||
list as fetchDocuments,
|
||||
detail as fetchDocumentDetail,
|
||||
@@ -8,6 +9,13 @@ import {
|
||||
} from '@/api/documents'
|
||||
import { truncate } from '@/utils/format'
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (bytes == null) return ''
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
const isLoading = ref(false)
|
||||
const isLoadingDetail = ref(false)
|
||||
const isDeleting = ref(false)
|
||||
@@ -204,6 +212,15 @@ onMounted(() => {
|
||||
<a-descriptions-item label="chunks_count">
|
||||
{{ detailData.chunks_count ?? 0 }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="原文文件" v-if="detailData.file">
|
||||
<a :href="detailData.file.url" target="_blank" rel="noopener">
|
||||
<FileOutlined />
|
||||
{{ detailData.file.filename }}
|
||||
<span class="text-muted" v-if="detailData.file.size_bytes">
|
||||
({{ formatSize(detailData.file.size_bytes) }})
|
||||
</span>
|
||||
</a>
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<h3 class="documents__section-title">L1 全文</h3>
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import {
|
||||
TeamOutlined,
|
||||
UserAddOutlined,
|
||||
ReloadOutlined,
|
||||
KeyOutlined,
|
||||
DeleteOutlined,
|
||||
StopOutlined,
|
||||
CheckCircleOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import {
|
||||
listUsers,
|
||||
createUser,
|
||||
updateUser,
|
||||
resetPassword,
|
||||
deleteUser
|
||||
} from '@/api/auth'
|
||||
import { useAuthStore } from '@/stores/useAuthStore'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const currentUsername = computed(() => authStore.user?.username || '')
|
||||
|
||||
const isLoading = ref(false)
|
||||
const users = ref([])
|
||||
|
||||
const createVisible = ref(false)
|
||||
const creating = ref(false)
|
||||
const createForm = reactive({ username: '', password: '', role: 'user' })
|
||||
|
||||
const resetVisible = ref(false)
|
||||
const resetting = ref(false)
|
||||
const resetTarget = ref('')
|
||||
const resetForm = reactive({ new_password: '' })
|
||||
|
||||
const columns = [
|
||||
{ title: '用户名', dataIndex: 'username', key: 'username' },
|
||||
{ title: '角色', dataIndex: 'role', key: 'role', width: 100, align: 'center' },
|
||||
{ title: '状态', key: 'enabled', width: 90, align: 'center' },
|
||||
{ title: '强制改密', key: 'must_change_password', width: 90, align: 'center' },
|
||||
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 180 },
|
||||
{ title: '操作', key: 'action', width: 230, fixed: 'right' }
|
||||
]
|
||||
|
||||
async function loadUsers() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
users.value = await listUsers()
|
||||
} catch (err) {
|
||||
message.error(err?.message || '加载用户列表失败')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(ts) {
|
||||
if (!ts) return '-'
|
||||
const d = new Date(ts)
|
||||
if (Number.isNaN(d.getTime())) return String(ts)
|
||||
return d.toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
|
||||
// 不能对当前登录账号做删除/禁用(后端亦会拦截)
|
||||
function isSelf(username) {
|
||||
return username === currentUsername.value
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
createForm.username = ''
|
||||
createForm.password = ''
|
||||
createForm.role = 'user'
|
||||
createVisible.value = true
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (!createForm.username.trim()) {
|
||||
message.warning('请输入用户名')
|
||||
return
|
||||
}
|
||||
if (createForm.password.length < 8) {
|
||||
message.warning('密码至少 8 位')
|
||||
return
|
||||
}
|
||||
creating.value = true
|
||||
try {
|
||||
await createUser(createForm.username.trim(), createForm.password, createForm.role)
|
||||
message.success(`已创建用户 ${createForm.username.trim()}`)
|
||||
createVisible.value = false
|
||||
await loadUsers()
|
||||
} catch (err) {
|
||||
message.error(err?.message || '创建用户失败')
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openReset(username) {
|
||||
resetTarget.value = username
|
||||
resetForm.new_password = ''
|
||||
resetVisible.value = true
|
||||
}
|
||||
|
||||
async function handleReset() {
|
||||
if (resetForm.new_password.length < 8) {
|
||||
message.warning('新密码至少 8 位')
|
||||
return
|
||||
}
|
||||
resetting.value = true
|
||||
try {
|
||||
await resetPassword(resetTarget.value, resetForm.new_password)
|
||||
message.success(`已重置 ${resetTarget.value} 的密码`)
|
||||
resetVisible.value = false
|
||||
} catch (err) {
|
||||
message.error(err?.message || '重置密码失败')
|
||||
} finally {
|
||||
resetting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleEnabled(record) {
|
||||
const enabled = !record.enabled
|
||||
try {
|
||||
await updateUser(record.username, { enabled })
|
||||
message.success(`${record.username} 已${enabled ? '启用' : '禁用'}`)
|
||||
await loadUsers()
|
||||
} catch (err) {
|
||||
message.error(err?.message || '更新状态失败')
|
||||
}
|
||||
}
|
||||
|
||||
function handleDelete(record) {
|
||||
Modal.confirm({
|
||||
title: '确认删除用户',
|
||||
content: `确定删除用户「${record.username}」吗?该操作不可恢复。`,
|
||||
okText: '删除',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
async onOk() {
|
||||
try {
|
||||
await deleteUser(record.username)
|
||||
message.success(`已删除 ${record.username}`)
|
||||
await loadUsers()
|
||||
} catch (err) {
|
||||
message.error(err?.message || '删除用户失败')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadUsers()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="users page-section">
|
||||
<div class="users__header">
|
||||
<h2 class="page-title">
|
||||
<TeamOutlined /> 用户管理
|
||||
</h2>
|
||||
<a-space>
|
||||
<a-button :loading="isLoading" @click="loadUsers">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
<a-button type="primary" @click="openCreate">
|
||||
<template #icon><UserAddOutlined /></template>
|
||||
新建用户
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="users"
|
||||
:pagination="false"
|
||||
:loading="isLoading"
|
||||
row-key="username"
|
||||
size="middle"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'username'">
|
||||
<span>{{ record.username }}</span>
|
||||
<a-tag v-if="isSelf(record.username)" color="gold" style="margin-left: 6px">当前</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'role'">
|
||||
<a-tag :color="record.role === 'admin' ? 'green' : 'blue'">{{ record.role }}</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'enabled'">
|
||||
<a-tag :color="record.enabled ? 'success' : 'default'">
|
||||
{{ record.enabled ? '启用' : '禁用' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'must_change_password'">
|
||||
<a-tag v-if="record.must_change_password" color="orange">需改密</a-tag>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'created_at'">
|
||||
<span class="text-muted">{{ formatTime(record.created_at) }}</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="openReset(record.username)">
|
||||
<template #icon><KeyOutlined /></template>
|
||||
重置密码
|
||||
</a-button>
|
||||
<a-button
|
||||
type="link"
|
||||
size="small"
|
||||
:disabled="isSelf(record.username)"
|
||||
@click="toggleEnabled(record)"
|
||||
>
|
||||
<template #icon>
|
||||
<StopOutlined v-if="record.enabled" />
|
||||
<CheckCircleOutlined v-else />
|
||||
</template>
|
||||
{{ record.enabled ? '禁用' : '启用' }}
|
||||
</a-button>
|
||||
<a-button
|
||||
type="link"
|
||||
size="small"
|
||||
danger
|
||||
:disabled="isSelf(record.username)"
|
||||
@click="handleDelete(record)"
|
||||
>
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
删除
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<!-- 新建用户 -->
|
||||
<a-modal
|
||||
v-model:open="createVisible"
|
||||
title="新建用户"
|
||||
ok-text="创建"
|
||||
cancel-text="取消"
|
||||
:confirm-loading="creating"
|
||||
@ok="handleCreate"
|
||||
>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="用户名" required>
|
||||
<a-input v-model:value="createForm.username" placeholder="登录用户名" />
|
||||
</a-form-item>
|
||||
<a-form-item label="密码" required>
|
||||
<a-input-password v-model:value="createForm.password" placeholder="至少 8 位" />
|
||||
</a-form-item>
|
||||
<a-form-item label="角色">
|
||||
<a-radio-group v-model:value="createForm.role">
|
||||
<a-radio value="user">普通用户 (user)</a-radio>
|
||||
<a-radio value="admin">管理员 (admin)</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<!-- 重置密码 -->
|
||||
<a-modal
|
||||
v-model:open="resetVisible"
|
||||
title="重置密码"
|
||||
ok-text="重置"
|
||||
cancel-text="取消"
|
||||
:confirm-loading="resetting"
|
||||
@ok="handleReset"
|
||||
>
|
||||
<p class="text-muted">目标用户:{{ resetTarget }}</p>
|
||||
<a-form-item label="新密码" required>
|
||||
<a-input-password v-model:value="resetForm.new_password" placeholder="至少 8 位" />
|
||||
</a-form-item>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.users__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.page-title :deep(.anticon) {
|
||||
margin-right: 8px;
|
||||
color: #1677ff;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user