mirror of
https://gitcode.com/JianFeeeee/webui4frpc.git
synced 2026-09-21 17:37:56 +00:00
# Conflicts: # internal/httpapi/dist/assets/index-CL42Ur3_.css # internal/httpapi/dist/assets/index-CbqC9m-j.js # internal/httpapi/dist/assets/index-RFHYrCiX.css # internal/httpapi/dist/assets/index-jvouiCgh.css # internal/httpapi/dist/index.html # internal/store/store.go # web/src/api.ts # web/src/views/ClusterView.vue
366 lines
13 KiB
Vue
366 lines
13 KiB
Vue
<!--
|
||
UsersView — account + API key management (admin only).
|
||
Two tables: built-in/manual users (system rows are read-only, managed by
|
||
-user/-password flags) and scoped API keys (plaintext shown once at create).
|
||
Reached only when isAdmin; App.vue hides the nav entry for non-admins.
|
||
-->
|
||
<template>
|
||
<div class="users-view">
|
||
<!-- ---- accounts ---- -->
|
||
<section class="card">
|
||
<header class="card-h">
|
||
<h3>账号</h3>
|
||
<div class="h-actions">
|
||
<el-button size="small" @click="exportCsv('users')">⤓ 导出 CSV</el-button>
|
||
<el-button type="primary" size="small" @click="openUserCreate">+ 新建账号</el-button>
|
||
</div>
|
||
</header>
|
||
<el-table :data="users" size="small" stripe empty-text="暂无账号">
|
||
<el-table-column prop="username" label="用户名" min-width="140" />
|
||
<el-table-column label="角色" width="130">
|
||
<template #default="{ row }">
|
||
<el-tag size="small" :type="row.role === 'superadmin' ? 'danger' : row.role === 'admin' ? 'warning' : 'info'">
|
||
{{ row.role === 'superadmin' ? '超级管理员' : row.role === 'admin' ? '管理员' : '审计(viewer)' }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="状态" width="90">
|
||
<template #default="{ row }">
|
||
<el-tag size="small" :type="row.enabled ? 'success' : 'warning'">
|
||
{{ row.enabled ? '启用' : '停用' }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="来源" width="110">
|
||
<template #default="{ row }">
|
||
<el-tag v-if="row.system" size="small" type="warning">内置(flag)</el-tag>
|
||
<span v-else class="muted">手动</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="创建时间" width="170">
|
||
<template #default="{ row }">{{ fmt(row.createdAt) }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="最后登录" width="170">
|
||
<template #default="{ row }">{{ row.lastLoginAt ? fmt(row.lastLoginAt) : '—' }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" width="180" fixed="right">
|
||
<template #default="{ row }">
|
||
<el-button size="small" @click="openUserEdit(row)" :disabled="row.system">编辑</el-button>
|
||
<el-button size="small" type="danger" plain @click="onDeleteUser(row)" :disabled="row.system">
|
||
删除
|
||
</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</section>
|
||
|
||
<!-- ---- api keys ---- -->
|
||
<section class="card">
|
||
<header class="card-h">
|
||
<h3>API 密钥</h3>
|
||
<div class="h-actions">
|
||
<el-button size="small" @click="exportCsv('apikeys')">⤓ 导出 CSV</el-button>
|
||
<el-button type="primary" size="small" @click="openKeyCreate">+ 新建密钥</el-button>
|
||
</div>
|
||
</header>
|
||
<el-table :data="apiKeys" size="small" stripe empty-text="暂无密钥">
|
||
<el-table-column prop="label" label="标签" min-width="140" />
|
||
<el-table-column label="作用域" width="100">
|
||
<template #default="{ row }">
|
||
<el-tag size="small" :type="scopeType(row.scope)">{{ row.scope }}</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="归属用户" width="140">
|
||
<template #default="{ row }">{{ userName(row.userId) }}</template>
|
||
</el-table-column>
|
||
<el-table-column prop="prefix" label="前缀" width="120" />
|
||
<el-table-column label="创建时间" width="170">
|
||
<template #default="{ row }">{{ fmt(row.createdAt) }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="最近使用" width="170">
|
||
<template #default="{ row }">{{ row.lastUsedAt ? fmt(row.lastUsedAt) : '—' }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" width="110" fixed="right">
|
||
<template #default="{ row }">
|
||
<el-button size="small" type="danger" plain @click="onDeleteKey(row)">吊销</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</section>
|
||
|
||
<!-- create / edit user dialog -->
|
||
<el-dialog v-model="userDialog" :title="editing ? '编辑账号' : '新建账号'" width="420px">
|
||
<el-form label-width="86px" @submit.prevent>
|
||
<el-form-item label="用户名" v-if="!editing">
|
||
<el-input v-model="userForm.username" autocomplete="off" />
|
||
</el-form-item>
|
||
<el-form-item label="用户名" v-else>
|
||
<span class="muted">{{ userForm.username }}</span>
|
||
</el-form-item>
|
||
<el-form-item :label="editing ? '新密码' : '密码'">
|
||
<el-input v-model="userForm.password" type="password" show-password
|
||
:placeholder="editing ? '留空则不改' : ''" autocomplete="new-password" />
|
||
</el-form-item>
|
||
<el-form-item label="角色">
|
||
<el-select v-model="userForm.role" :disabled="editing && editing?.system">
|
||
<el-option label="超级管理员 (superadmin)" value="superadmin" />
|
||
<el-option label="管理员 (admin)" value="admin" />
|
||
<el-option label="审计 (viewer)" value="viewer" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="启用" v-if="editing">
|
||
<el-switch v-model="userForm.enabled" :disabled="editing?.system" />
|
||
</el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="userDialog = false">取消</el-button>
|
||
<el-button type="primary" @click="submitUser" :loading="userSaving">保存</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- create key dialog -->
|
||
<el-dialog v-model="keyDialog" title="新建 API 密钥" width="440px">
|
||
<el-form label-width="86px" @submit.prevent v-if="!createdKey">
|
||
<el-form-item label="归属用户">
|
||
<el-select v-model="keyForm.userId" placeholder="选择用户">
|
||
<el-option v-for="u in users" :key="u.id" :label="u.username" :value="u.id" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="标签">
|
||
<el-input v-model="keyForm.label" placeholder="如 ci-deploy" />
|
||
</el-form-item>
|
||
<el-form-item label="作用域">
|
||
<el-select v-model="keyForm.scope">
|
||
<el-option label="read — 只读 + 导出" value="read" />
|
||
<el-option label="write — 改转发/worker/设置" value="write" />
|
||
<el-option label="admin — 全量 (含账号管理)" value="admin" />
|
||
</el-select>
|
||
</el-form-item>
|
||
</el-form>
|
||
<div v-else class="key-result">
|
||
<p class="warn">密钥仅显示一次,请立即复制保存:</p>
|
||
<el-input v-model="createdKey" readonly>
|
||
<template #append>
|
||
<el-button @click="copyKey">复制</el-button>
|
||
</template>
|
||
</el-input>
|
||
<p class="hint">使用方式:请求头 <code>Authorization: Bearer <key></code></p>
|
||
</div>
|
||
<template #footer>
|
||
<el-button @click="keyDialog = false">{{ createdKey ? '关闭' : '取消' }}</el-button>
|
||
<el-button v-if="!createdKey" type="primary" @click="submitKey" :loading="keySaving">生成</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { onMounted, ref } from 'vue'
|
||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||
import { api, downloadAuditCsv } from '../api'
|
||
import type { ApiKey, ApiKeyCreated, User } from '../types'
|
||
|
||
const users = ref<User[]>([])
|
||
const apiKeys = ref<ApiKey[]>([])
|
||
|
||
// exportCsv streams one of the audit CSV endpoints to a file. Read-level:
|
||
// auditors pull these without any write permission.
|
||
const exportCsv = async (kind: 'users' | 'apikeys') => {
|
||
try {
|
||
await downloadAuditCsv(kind)
|
||
ElMessage.success('已导出 CSV')
|
||
} catch (e: any) {
|
||
ElMessage.error('导出失败: ' + (e?.message || e))
|
||
}
|
||
}
|
||
|
||
const loadUsers = async () => {
|
||
const r = await api.listUsers()
|
||
users.value = r.users
|
||
}
|
||
const loadKeys = async () => {
|
||
const r = await api.listApiKeys()
|
||
apiKeys.value = r.apiKeys
|
||
}
|
||
|
||
onMounted(async () => {
|
||
await Promise.all([loadUsers(), loadKeys()])
|
||
})
|
||
|
||
const fmt = (ts: number) => {
|
||
if (!ts) return '—'
|
||
return new Date(ts * 1000).toLocaleString('zh-CN', { hour12: false })
|
||
}
|
||
const userName = (id: number) => users.value.find((u) => u.id === id)?.username ?? `#${id}`
|
||
const scopeType = (s: string) => (s === 'admin' ? 'danger' : s === 'write' ? 'warning' : 'info')
|
||
|
||
// ---- user create/edit ----
|
||
const userDialog = ref(false)
|
||
const editing = ref<User | null>(null)
|
||
const userSaving = ref(false)
|
||
const userForm = ref({ username: '', password: '', role: 'viewer' as 'admin' | 'viewer' | 'superadmin', enabled: true })
|
||
|
||
const openUserCreate = () => {
|
||
editing.value = null
|
||
userForm.value = { username: '', password: '', role: 'viewer', enabled: true }
|
||
userDialog.value = true
|
||
}
|
||
const openUserEdit = (u: User) => {
|
||
editing.value = u
|
||
userForm.value = { username: u.username, password: '', role: u.role, enabled: u.enabled }
|
||
userDialog.value = true
|
||
}
|
||
|
||
const submitUser = async () => {
|
||
userSaving.value = true
|
||
try {
|
||
if (editing.value) {
|
||
const patch: { password?: string; role: 'admin' | 'viewer' | 'superadmin'; enabled: boolean } = {
|
||
role: userForm.value.role,
|
||
enabled: userForm.value.enabled,
|
||
}
|
||
if (userForm.value.password) patch.password = userForm.value.password
|
||
await api.updateUser(editing.value.username, patch)
|
||
ElMessage.success('已更新')
|
||
} else {
|
||
if (!userForm.value.username || !userForm.value.password) {
|
||
ElMessage.warning('用户名和密码必填')
|
||
userSaving.value = false
|
||
return
|
||
}
|
||
await api.createUser(userForm.value.username, userForm.value.password, userForm.value.role)
|
||
ElMessage.success('已创建')
|
||
}
|
||
userDialog.value = false
|
||
await loadUsers()
|
||
} catch (e: any) {
|
||
ElMessage.error('失败: ' + (e.message || e))
|
||
} finally {
|
||
userSaving.value = false
|
||
}
|
||
}
|
||
|
||
const onDeleteUser = async (u: User) => {
|
||
try {
|
||
await ElMessageBox.confirm(`确认删除账号 ${u.username}?`, '删除账号', { type: 'warning' })
|
||
} catch {
|
||
return
|
||
}
|
||
try {
|
||
await api.deleteUser(u.username)
|
||
ElMessage.success('已删除')
|
||
await loadUsers()
|
||
} catch (e: any) {
|
||
ElMessage.error('删除失败: ' + (e.message || e))
|
||
}
|
||
}
|
||
|
||
// ---- api key create ----
|
||
const keyDialog = ref(false)
|
||
const keySaving = ref(false)
|
||
const createdKey = ref('')
|
||
const keyForm = ref({ userId: 0 as number, label: '', scope: 'read' as 'read' | 'write' | 'admin' })
|
||
|
||
const openKeyCreate = () => {
|
||
// default to the first non-system user (or first user)
|
||
keyForm.value = {
|
||
userId: users.value.find((u) => !u.system)?.id ?? users.value[0]?.id ?? 0,
|
||
label: '',
|
||
scope: 'read',
|
||
}
|
||
createdKey.value = ''
|
||
keyDialog.value = true
|
||
}
|
||
|
||
const submitKey = async () => {
|
||
if (!keyForm.value.userId) {
|
||
ElMessage.warning('请选择归属用户')
|
||
return
|
||
}
|
||
keySaving.value = true
|
||
try {
|
||
const r: ApiKeyCreated = await api.createApiKey(keyForm.value.userId, keyForm.value.label, keyForm.value.scope)
|
||
createdKey.value = r.key
|
||
await loadKeys()
|
||
} catch (e: any) {
|
||
ElMessage.error('创建失败: ' + (e.message || e))
|
||
} finally {
|
||
keySaving.value = false
|
||
}
|
||
}
|
||
|
||
const copyKey = async () => {
|
||
try {
|
||
await navigator.clipboard.writeText(createdKey.value)
|
||
ElMessage.success('已复制')
|
||
} catch {
|
||
ElMessage.warning('复制失败,请手动选择')
|
||
}
|
||
}
|
||
|
||
const onDeleteKey = async (k: ApiKey) => {
|
||
try {
|
||
await ElMessageBox.confirm(`确认吊销密钥 ${k.label} (${k.prefix}…)?`, '吊销密钥', { type: 'warning' })
|
||
} catch {
|
||
return
|
||
}
|
||
try {
|
||
await api.deleteApiKey(k.id)
|
||
ElMessage.success('已吊销')
|
||
await loadKeys()
|
||
} catch (e: any) {
|
||
ElMessage.error('吊销失败: ' + (e.message || e))
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.users-view {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 20px;
|
||
}
|
||
.card {
|
||
background: var(--w4f-card);
|
||
border: 1px solid var(--w4f-line);
|
||
border-radius: 16px;
|
||
padding: 16px 18px;
|
||
box-shadow: var(--w4f-sh-sm);
|
||
}
|
||
.card-h {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 12px;
|
||
}
|
||
.card-h h3 {
|
||
margin: 0;
|
||
font-size: 15px;
|
||
font-weight: 700;
|
||
}
|
||
.muted {
|
||
color: var(--w4f-muted);
|
||
}
|
||
.key-result {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
}
|
||
.key-result .warn {
|
||
color: var(--w4f-primary-h, #d97706);
|
||
font-weight: 600;
|
||
margin: 0;
|
||
}
|
||
.key-result .hint {
|
||
margin: 0;
|
||
font-size: 12px;
|
||
color: var(--w4f-muted);
|
||
}
|
||
.key-result code {
|
||
background: var(--w4f-card-2);
|
||
padding: 1px 5px;
|
||
border-radius: 4px;
|
||
font-size: 12px;
|
||
}
|
||
</style>
|