This commit is contained in:
2026-06-09 21:19:30 +08:00
parent 8b41ff9360
commit 0484e79978
66 changed files with 4043 additions and 533 deletions
+27
View File
@@ -10,6 +10,7 @@ export interface AgentKindAdmin {
args: string[]
env_keys: string[]
enabled: boolean
tool_allowlist: string[]
created_by: string
created_at: string
updated_at: string
@@ -36,6 +37,7 @@ export interface CreateAgentKindReq {
args?: string[]
env?: Record<string, string>
enabled?: boolean
tool_allowlist?: string[]
}
export interface UpdateAgentKindReq {
@@ -45,6 +47,18 @@ export interface UpdateAgentKindReq {
args?: string[]
env?: Record<string, string>
enabled?: boolean
tool_allowlist?: string[]
}
// PermissionRequest mirrors backend PermissionRequestDTO.
export interface PermissionRequest {
id: string
session_id: string
tool_name: string
tool_call: unknown
options: { id: string; name?: string }[]
status: string
created_at: string
}
// AcpSession mirrors the backend session DTO returned by /api/v1/acp/sessions.
@@ -90,6 +104,8 @@ export interface AcpEvent {
| 'session_terminated'
| 'slow_consumer_disconnect'
| 'client_error'
| 'permission_request'
| 'permission_resolved'
method?: string
payload: unknown
truncated: boolean
@@ -121,6 +137,17 @@ export const acpApi = {
request<void>(`/api/v1/acp/sessions/${enc(id)}`, { method: 'DELETE' }),
events: (id: string, since: number) =>
request<AcpEvent[]>(`/api/v1/acp/sessions/${enc(id)}/events?since=${since}`),
permissions: (id: string) =>
request<PermissionRequest[]>(`/api/v1/acp/sessions/${enc(id)}/permissions`),
},
permissions: {
approve: (reqID: string, optionId?: string) =>
request<void>(`/api/v1/acp/permissions/${enc(reqID)}/approve`, {
method: 'POST',
body: optionId ? { option_id: optionId } : {},
}),
deny: (reqID: string) =>
request<void>(`/api/v1/acp/permissions/${enc(reqID)}/deny`, { method: 'POST' }),
},
}
+5
View File
@@ -14,4 +14,9 @@ export const authApi = {
}),
me: () => request<User>('/api/v1/auth/me'),
logout: () => request<void>('/api/v1/auth/logout', { method: 'POST' }),
changePassword: (oldPassword: string, newPassword: string) =>
request<void>('/api/v1/auth/change-password', {
method: 'POST',
body: { old_password: oldPassword, new_password: newPassword },
}),
}
+53
View File
@@ -0,0 +1,53 @@
import { request } from './client'
// AdminUser 对应后端 adminUserDTO:比登录用的 User 多 enabled 与时间戳。
export interface AdminUser {
id: string
email: string
display_name: string
is_admin: boolean
enabled: boolean
created_at: string
updated_at: string
}
export interface CreateUserBody {
email: string
display_name: string
password: string
is_admin: boolean
}
export interface ResetPasswordResponse {
temp_password: string
}
const BASE = '/api/v1/admin/users'
export const userAdminApi = {
list: () => request<AdminUser[]>(BASE),
get: (id: string) => request<AdminUser>(`${BASE}/${id}`),
create: (body: CreateUserBody) =>
request<AdminUser>(BASE, { method: 'POST', body }),
updateProfile: (id: string, displayName: string) =>
request<AdminUser>(`${BASE}/${id}`, {
method: 'PATCH',
body: { display_name: displayName },
}),
setRole: (id: string, isAdmin: boolean) =>
request<AdminUser>(`${BASE}/${id}/role`, {
method: 'PATCH',
body: { is_admin: isAdmin },
}),
setEnabled: (id: string, enabled: boolean) =>
request<AdminUser>(`${BASE}/${id}/enabled`, {
method: 'PATCH',
body: { enabled },
}),
resetPassword: (id: string) =>
request<ResetPasswordResponse>(`${BASE}/${id}/reset-password`, {
method: 'POST',
}),
remove: (id: string) =>
request<void>(`${BASE}/${id}`, { method: 'DELETE' }),
}
+19
View File
@@ -11,6 +11,7 @@ const props = defineProps<{
args: string[]
env: Record<string, string>
enabled: boolean
tool_allowlist: string[]
}
isEdit: boolean
existingEnvKeys?: string[]
@@ -38,6 +39,13 @@ const argsText = computed({
},
})
const allowlistText = computed({
get: () => (local.value.tool_allowlist ?? []).join('\n'),
set: (v: string) => {
local.value.tool_allowlist = v.split('\n').map((x) => x.trim()).filter((x) => x !== '')
},
})
interface EnvRow {
k: string
v: string
@@ -157,6 +165,17 @@ function removeEnv(idx: number) {
+ Add
</NButton>
</div>
<div>
<label class="block text-sm font-medium mb-1">
工具白名单每行一个命中则自动放行 * 全部放行留空则全部需人工审批
</label>
<NInput
v-model:value="allowlistText"
type="textarea"
:autosize="{ minRows: 2, maxRows: 8 }"
placeholder="fs/read_text_file&#10;safe.tool"
/>
</div>
<div class="flex items-center gap-2">
<NCheckbox v-model:checked="local.enabled">
Enabled
@@ -0,0 +1,89 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { PermissionRequest } from '@/api/acp'
const props = defineProps<{
requests: PermissionRequest[]
canDecide: boolean
}>()
const emit = defineEmits<{
approve: [reqID: string, optionId?: string]
deny: [reqID: string]
}>()
const list = computed(() => props.requests)
// allowOptions 过滤出“批准”类选项,作为可点击的批准按钮;其余统一走 Deny。
function allowOptions(r: PermissionRequest) {
return r.options.filter((o) => {
const id = o.id.toLowerCase()
return id.includes('allow') || id.includes('yes') || id.includes('approve') || id.includes('accept')
})
}
function summarize(toolCall: unknown): string {
try {
return JSON.stringify(toolCall)
} catch {
return ''
}
}
</script>
<template>
<div
v-if="list.length"
class="border-b bg-amber-50 px-4 py-3 space-y-3"
>
<div class="text-sm font-semibold text-amber-800">
待审批的工具调用{{ list.length }}
</div>
<div
v-for="r in list"
:key="r.id"
class="rounded border border-amber-300 bg-white px-3 py-2"
>
<div class="flex items-center justify-between gap-2">
<span class="font-mono text-sm">{{ r.tool_name || '(未命名工具)' }}</span>
<div
v-if="canDecide"
class="flex items-center gap-2"
>
<template v-if="allowOptions(r).length">
<button
v-for="opt in allowOptions(r)"
:key="opt.id"
class="px-2 py-1 text-xs rounded border text-green-700 border-green-300 hover:bg-green-50"
@click="emit('approve', r.id, opt.id)"
>
{{ opt.name || opt.id }}
</button>
</template>
<button
v-else
class="px-2 py-1 text-xs rounded border text-green-700 border-green-300 hover:bg-green-50"
@click="emit('approve', r.id)"
>
批准
</button>
<button
class="px-2 py-1 text-xs rounded border text-red-600 border-red-300 hover:bg-red-50"
@click="emit('deny', r.id)"
>
拒绝
</button>
</div>
<span
v-else
class="text-xs text-gray-400"
>
等待 owner 审批
</span>
</div>
<pre
class="mt-1 text-xs text-gray-500 whitespace-pre-wrap break-all max-h-24 overflow-y-auto"
>{{ summarize(r.tool_call) }}</pre>
</div>
</div>
</template>
+11
View File
@@ -39,6 +39,12 @@
</RouterLink>
<template v-if="auth.isAdmin">
<span class="text-neutral-300">|</span>
<RouterLink
:to="{ name: 'admin-users' }"
class="text-sm hover:underline"
>
用户
</RouterLink>
<RouterLink
:to="{ name: 'admin-llm-endpoints' }"
class="text-sm hover:underline"
@@ -94,10 +100,15 @@ const auth = useAuthStore()
const router = useRouter()
const menu = computed(() => [
{ label: '修改密码', key: 'change-password' },
{ label: '退出登录', key: 'logout' },
])
async function onSelect(key: string) {
if (key === 'change-password') {
router.push({ name: 'me-change-password' }).catch(() => {})
return
}
if (key === 'logout') {
try {
await authApi.logout()
+55 -2
View File
@@ -5,7 +5,7 @@
// - accumulate events into a ref array
// - expose sessionStatus + connection-state status
import { ref, onScopeDispose, type Ref } from 'vue'
import { openSessionWS, type AcpEvent } from '@/api/acp'
import { openSessionWS, acpApi, type AcpEvent, type PermissionRequest } from '@/api/acp'
import { useAuthStore } from '@/stores/auth'
export type StreamStatus = 'idle' | 'connecting' | 'streaming' | 'closed' | 'error'
@@ -15,12 +15,26 @@ export interface UseAcpStreamReturn {
status: Ref<StreamStatus>
sessionStatus: Ref<SessionStatus>
events: Ref<AcpEvent[]>
pendingPermissions: Ref<PermissionRequest[]>
prompt: (text: string, agentSessionID: string) => void
cancel: (agentSessionID: string) => void
reconnect: () => void
close: () => void
}
// 把控制帧(ID=0)的 payload 归一为对象,无论 WS 传来字符串还是已解析对象。
function decodePayload(raw: unknown): Record<string, unknown> {
if (typeof raw === 'string') {
try {
return JSON.parse(raw) as Record<string, unknown>
} catch {
return {}
}
}
if (raw && typeof raw === 'object') return raw as Record<string, unknown>
return {}
}
interface ServerStateEvent {
kind: 'state'
status: SessionStatus
@@ -34,8 +48,18 @@ export function useAcpStream(sessionId: string): UseAcpStreamReturn {
const status = ref<StreamStatus>('idle')
const sessionStatus = ref<SessionStatus>('starting')
const events = ref<AcpEvent[]>([])
const pendingPermissions = ref<PermissionRequest[]>([])
const lastEventID = ref(0)
function upsertPermission(p: PermissionRequest) {
if (!pendingPermissions.value.some((x) => x.id === p.id)) {
pendingPermissions.value.push(p)
}
}
function removePermission(reqID: string) {
pendingPermissions.value = pendingPermissions.value.filter((x) => x.id !== reqID)
}
let ws: WebSocket | null = null
let retried = 0
let manualClose = false
@@ -53,6 +77,13 @@ export function useAcpStream(sessionId: string): UseAcpStreamReturn {
sock.onopen = () => {
status.value = 'streaming'
retried = 0
// 控制帧(permission_*)不落 acp_events,重连不补发;连上后主动拉取当前待审。
acpApi.sessions
.permissions(sessionId)
.then((list) => {
for (const p of list) upsertPermission(p)
})
.catch(() => {})
}
sock.onmessage = (ev: MessageEvent) => {
@@ -65,6 +96,28 @@ export function useAcpStream(sessionId: string): UseAcpStreamReturn {
if (!parsed || typeof parsed !== 'object') return
const obj = parsed as Record<string, unknown>
// 权限请求/决议控制帧(不进 events 流,单独维护 pendingPermissions)。
if (obj.kind === 'permission_request') {
const p = decodePayload(obj.payload)
upsertPermission({
id: String(p.request_id ?? ''),
session_id: String(p.session_id ?? ''),
tool_name: String(p.tool_name ?? ''),
tool_call: p.tool_call,
options: Array.isArray(p.options)
? (p.options as { id: string; name?: string }[])
: [],
status: String(p.status ?? 'pending'),
created_at: String(p.created_at ?? ''),
})
return
}
if (obj.kind === 'permission_resolved') {
const p = decodePayload(obj.payload)
removePermission(String(p.request_id ?? ''))
return
}
// Initial state event from server
if (obj.kind === 'state') {
const s = (parsed as ServerStateEvent).status
@@ -156,5 +209,5 @@ export function useAcpStream(sessionId: string): UseAcpStreamReturn {
connect()
onScopeDispose(close)
return { status, sessionStatus, events, prompt, cancel, reconnect, close }
return { status, sessionStatus, events, pendingPermissions, prompt, cancel, reconnect, close }
}
+12
View File
@@ -117,6 +117,18 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/admin/UsageView.vue'),
meta: { requiresAuth: true },
},
{
path: '/me/change-password',
name: 'me-change-password',
component: () => import('@/views/ChangePasswordView.vue'),
meta: { requiresAuth: true },
},
{
path: '/admin/users',
name: 'admin-users',
component: () => import('@/views/admin/UserListView.vue'),
meta: { requiresAuth: true, requiresAdmin: true },
},
{
path: '/admin/llm-endpoints',
name: 'admin-llm-endpoints',
+82
View File
@@ -0,0 +1,82 @@
<template>
<AppShell>
<div class="max-w-md space-y-4">
<h2 class="text-lg font-semibold">
修改密码
</h2>
<NForm>
<NFormItem label="当前密码">
<NInput
v-model:value="oldPassword"
type="password"
show-password-on="click"
/>
</NFormItem>
<NFormItem label="新密码(至少 8 位)">
<NInput
v-model:value="newPassword"
type="password"
show-password-on="click"
/>
</NFormItem>
<NFormItem label="确认新密码">
<NInput
v-model:value="confirm"
type="password"
show-password-on="click"
/>
</NFormItem>
</NForm>
<NButton
type="primary"
:loading="saving"
:disabled="!canSubmit"
@click="submit"
>
修改密码
</NButton>
</div>
</AppShell>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { NButton, NForm, NFormItem, NInput, useMessage } from 'naive-ui'
import { useRouter } from 'vue-router'
import AppShell from '@/layouts/AppShell.vue'
import { authApi } from '@/api/auth'
import { useAuthStore } from '@/stores/auth'
const msg = useMessage()
const router = useRouter()
const auth = useAuthStore()
const oldPassword = ref('')
const newPassword = ref('')
const confirm = ref('')
const saving = ref(false)
const canSubmit = computed(
() =>
!!oldPassword.value &&
newPassword.value.length >= 8 &&
newPassword.value === confirm.value,
)
async function submit() {
if (!canSubmit.value) return
saving.value = true
try {
await authApi.changePassword(oldPassword.value, newPassword.value)
msg.success('密码已修改,请重新登录')
// 后端已撤销全部会话,本地清理并跳登录页。
auth.clearSession()
router.replace({ name: 'login' }).catch(() => {})
} catch (e: unknown) {
msg.error((e as Error).message || '修改失败')
} finally {
saving.value = false
}
}
</script>
+155 -5
View File
@@ -1,16 +1,166 @@
<template>
<AppShell>
<div class="space-y-4">
<h2 class="text-xl font-semibold">
欢迎{{ auth.user?.display_name }}
</h2>
<p>这是占位 dashboard后续 plan 会接入项目/工作区/Issue</p>
<div class="space-y-6">
<div class="flex items-center justify-between">
<h2 class="text-xl font-semibold">
欢迎{{ auth.user?.display_name }}
</h2>
<NButton
type="primary"
@click="router.push({ name: 'project-list' })"
>
新建 / 管理项目
</NButton>
</div>
<!-- 统计卡片 -->
<NGrid
:cols="3"
:x-gap="12"
>
<NGi>
<NCard>
<NStatistic
label="活跃项目"
:value="activeCount"
/>
</NCard>
</NGi>
<NGi>
<NCard>
<NStatistic
label="归档项目"
:value="archivedCount"
/>
</NCard>
</NGi>
<NGi>
<NCard>
<NStatistic
label="分配给我的待办议题"
:value="myIssues.length"
/>
</NCard>
</NGi>
</NGrid>
<!-- 分配给我的议题 -->
<NCard title="分配给我的议题(未关闭)">
<NSpin :show="loading">
<div
v-if="myIssues.length === 0"
class="text-sm text-gray-400 py-2"
>
暂无分配给你的未关闭议题
</div>
<ul
v-else
class="divide-y"
>
<li
v-for="row in myIssues"
:key="row.issue.id"
class="py-2 flex items-center justify-between cursor-pointer hover:bg-gray-50 px-1"
@click="goIssue(row)"
>
<span class="text-sm">
<span class="font-mono text-gray-500 mr-2">{{ row.projectSlug }}#{{ row.issue.number }}</span>
{{ row.issue.title }}
</span>
<span class="text-xs text-gray-400">{{ row.projectName }}</span>
</li>
</ul>
</NSpin>
</NCard>
<!-- 最近项目 -->
<NCard title="项目">
<NSpin :show="loading">
<div
v-if="activeProjects.length === 0"
class="text-sm text-gray-400 py-2"
>
还没有项目去创建一个吧
</div>
<ul
v-else
class="divide-y"
>
<li
v-for="p in activeProjects"
:key="p.id"
class="py-2 flex items-center justify-between cursor-pointer hover:bg-gray-50 px-1"
@click="router.push({ name: 'project-home', params: { slug: p.slug } })"
>
<span class="text-sm font-medium">{{ p.name }}</span>
<span class="font-mono text-xs text-gray-400">{{ p.slug }}</span>
</li>
</ul>
</NSpin>
</NCard>
</div>
</AppShell>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { NButton, NCard, NGrid, NGi, NStatistic, NSpin } from 'naive-ui'
import AppShell from '@/layouts/AppShell.vue'
import { useAuthStore } from '@/stores/auth'
import { projectsApi, type Project, type Issue } from '@/api/projects'
const auth = useAuthStore()
const router = useRouter()
const projects = ref<Project[]>([])
const loading = ref(false)
interface MyIssueRow {
issue: Issue
projectSlug: string
projectName: string
}
const myIssues = ref<MyIssueRow[]>([])
const activeProjects = computed(() => projects.value.filter((p) => !p.archived_at))
const activeCount = computed(() => activeProjects.value.length)
const archivedCount = computed(() => projects.value.filter((p) => p.archived_at).length)
onMounted(async () => {
loading.value = true
try {
const out = await projectsApi.list(true)
projects.value = out.items
const uid = auth.user?.id
if (uid) {
// 跨活跃项目聚合“分配给我且未关闭”的议题。项目数通常较小,直接并发查询。
const lists = await Promise.all(
activeProjects.value.map(async (p) => {
try {
const res = await projectsApi.listIssues(p.slug, { assignee: uid, status: 'open' })
return res.items.map((issue) => ({
issue,
projectSlug: p.slug,
projectName: p.name,
}))
} catch {
return []
}
}),
)
myIssues.value = lists.flat()
}
} finally {
loading.value = false
}
})
function goIssue(row: MyIssueRow) {
router.push({
name: 'issue-detail',
params: { slug: row.projectSlug, number: row.issue.number },
})
}
</script>
+78 -1
View File
@@ -3,13 +3,18 @@ import { ref, shallowRef, computed, onMounted, onUnmounted, watch, nextTick } fr
import { useRoute } from 'vue-router'
import { useAcpStore } from '@/stores/acp'
import { useAcpStream, type UseAcpStreamReturn } from '@/composables/useAcpStream'
import type { AcpEvent } from '@/api/acp'
import { acpApi, type AcpEvent, type PermissionRequest } from '@/api/acp'
import { useAuthStore } from '@/stores/auth'
import { workspacesApi } from '@/api/workspaces'
import SessionStatusBadge from '@/components/acp/SessionStatusBadge.vue'
import AgentEventCard from '@/components/acp/AgentEventCard.vue'
import PromptInput from '@/components/acp/PromptInput.vue'
import PermissionApprovalPanel from '@/components/acp/PermissionApprovalPanel.vue'
import CommitDialog from '@/views/workspaces/components/CommitDialog.vue'
const route = useRoute()
const store = useAcpStore()
const auth = useAuthStore()
const sessionId = computed(() => route.params.sessionId as string)
@@ -18,6 +23,29 @@ const eventsContainer = ref<HTMLElement | null>(null)
const errorMsg = ref<string | null>(null)
const events = computed<AcpEvent[]>(() => stream.value?.events.value ?? [])
const pendingPermissions = computed<PermissionRequest[]>(
() => stream.value?.pendingPermissions.value ?? [],
)
// 仅 session owner(或 admin)可决策;后端同样强制。
const canDecide = computed(
() => auth.user?.is_admin === true || store.currentSession?.user_id === auth.user?.id,
)
async function approvePermission(reqID: string, optionId?: string) {
try {
await acpApi.permissions.approve(reqID, optionId)
} catch (e) {
errorMsg.value = e instanceof Error ? e.message : 'approve failed'
}
}
async function denyPermission(reqID: string) {
try {
await acpApi.permissions.deny(reqID)
} catch (e) {
errorMsg.value = e instanceof Error ? e.message : 'deny failed'
}
}
onMounted(async () => {
try {
@@ -86,6 +114,33 @@ async function terminate() {
const sessionStatus = computed(
() => stream.value?.sessionStatus.value ?? store.currentSession?.status ?? 'starting',
)
// ===== 提交代码:复用 workspace 的 CommitDialog =====
// session 行只存 is_main_worktree + branch(无 worktree_id),因此:
// - main worktree → target=main,targetId=workspace_id
// - 子 worktree → 按 branch 在该 workspace 的 worktrees 中解析出 id
const commitTarget = ref<{ target: 'main' | 'worktree'; targetId: string } | null>(null)
async function openCommit() {
const sess = store.currentSession
if (!sess) return
errorMsg.value = null
if (sess.is_main_worktree) {
commitTarget.value = { target: 'main', targetId: sess.workspace_id }
return
}
try {
const worktrees = await workspacesApi.listWorktrees(sess.workspace_id)
const wt = worktrees.find((w) => w.branch === sess.branch)
if (!wt) {
errorMsg.value = '未找到该会话对应的 worktree'
return
}
commitTarget.value = { target: 'worktree', targetId: wt.id }
} catch (e) {
errorMsg.value = e instanceof Error ? e.message : '加载 worktree 失败'
}
}
</script>
<template>
@@ -103,6 +158,12 @@ const sessionStatus = computed(
</div>
<div class="flex items-center gap-2">
<SessionStatusBadge :status="sessionStatus" />
<button
class="px-2 py-1 text-xs rounded border text-blue-600 hover:bg-blue-50"
@click="openCommit"
>
提交代码
</button>
<button
v-if="sessionStatus === 'starting' || sessionStatus === 'running'"
class="px-2 py-1 text-xs rounded border text-red-600 hover:bg-red-50"
@@ -117,6 +178,14 @@ const sessionStatus = computed(
{{ errorMsg }}
</p>
<!-- 待审批工具调用 -->
<PermissionApprovalPanel
:requests="pendingPermissions"
:can-decide="canDecide"
@approve="approvePermission"
@deny="denyPermission"
/>
<!-- Events stream -->
<div
ref="eventsContainer"
@@ -144,5 +213,13 @@ const sessionStatus = computed(
@cancel="onCancel"
/>
</div>
<CommitDialog
v-if="commitTarget"
:target="commitTarget.target"
:target-id="commitTarget.targetId"
@close="commitTarget = null"
@committed="commitTarget = null"
/>
</div>
</template>
@@ -21,6 +21,7 @@ const form = ref({
args: [] as string[],
env: {} as Record<string, string>,
enabled: true,
tool_allowlist: [] as string[],
})
const existingEnvKeys = ref<string[]>([])
@@ -35,6 +36,7 @@ onMounted(async () => {
args: [...k.args],
env: {}, // Edit mode: do not prefill values; user must replace entirely if they want to change.
enabled: k.enabled,
tool_allowlist: [...(k.tool_allowlist ?? [])],
}
existingEnvKeys.value = [...k.env_keys]
}
@@ -49,6 +51,7 @@ async function submit() {
binary_path: form.value.binary_path,
args: form.value.args,
enabled: form.value.enabled,
tool_allowlist: form.value.tool_allowlist,
}
// Only send env if user actually entered some values; an empty map would
// wipe existing env on the backend (per service patch semantics).
@@ -63,6 +66,7 @@ async function submit() {
args: form.value.args,
env: form.value.env,
enabled: form.value.enabled,
tool_allowlist: form.value.tool_allowlist,
})
}
router.push('/admin/acp/agent-kinds')
+355
View File
@@ -0,0 +1,355 @@
<template>
<AppShell>
<div class="space-y-4">
<div class="flex justify-between items-center">
<h2 class="text-lg font-semibold">
用户管理
</h2>
<NButton
type="primary"
@click="openCreate"
>
+ 新增用户
</NButton>
</div>
<NDataTable
:data="users"
:columns="columns"
:row-key="(row: AdminUser) => row.id"
:loading="loading"
/>
<!-- 新增用户 -->
<NModal
v-model:show="showCreate"
preset="card"
title="新增用户"
style="width: 480px"
>
<NForm>
<NFormItem label="邮箱">
<NInput
v-model:value="createForm.email"
placeholder="user@example.com"
/>
</NFormItem>
<NFormItem label="显示名">
<NInput v-model:value="createForm.display_name" />
</NFormItem>
<NFormItem label="初始密码至少 8 ">
<NInput
v-model:value="createForm.password"
type="password"
show-password-on="click"
/>
</NFormItem>
<NFormItem label="管理员">
<NSwitch v-model:value="createForm.is_admin" />
</NFormItem>
</NForm>
<template #footer>
<div class="flex justify-end gap-2">
<NButton @click="showCreate = false">
取消
</NButton>
<NButton
type="primary"
:loading="creating"
:disabled="!canCreate"
@click="doCreate"
>
创建
</NButton>
</div>
</template>
</NModal>
<!-- 编辑显示名 -->
<NModal
v-model:show="showEdit"
preset="card"
title="编辑用户"
style="width: 420px"
>
<NForm>
<NFormItem label="显示名">
<NInput v-model:value="editName" />
</NFormItem>
</NForm>
<template #footer>
<div class="flex justify-end gap-2">
<NButton @click="showEdit = false">
取消
</NButton>
<NButton
type="primary"
:loading="saving"
@click="doEdit"
>
保存
</NButton>
</div>
</template>
</NModal>
<!-- 临时密码展示 -->
<NModal
v-model:show="showTemp"
preset="card"
title="密码已重置"
style="width: 480px"
>
<NAlert
type="warning"
:bordered="false"
>
请立即复制临时密码并交给用户,关闭后无法再次查看。用户登录后应尽快自助改密。
</NAlert>
<NInput
:value="tempPassword"
readonly
class="mt-3"
/>
<template #footer>
<div class="flex justify-end">
<NButton @click="copyTemp">
复制
</NButton>
</div>
</template>
</NModal>
</div>
</AppShell>
</template>
<script setup lang="ts">
import { computed, h, onMounted, ref } from 'vue'
import {
NAlert,
NButton,
NDataTable,
NForm,
NFormItem,
NInput,
NModal,
NSpace,
NSwitch,
NTag,
useDialog,
useMessage,
} from 'naive-ui'
import type { DataTableColumns } from 'naive-ui'
import AppShell from '@/layouts/AppShell.vue'
import { useAuthStore } from '@/stores/auth'
import { userAdminApi, type AdminUser } from '@/api/usersAdmin'
const auth = useAuthStore()
const msg = useMessage()
const dlg = useDialog()
const users = ref<AdminUser[]>([])
const loading = ref(false)
const showCreate = ref(false)
const creating = ref(false)
const createForm = ref({ email: '', display_name: '', password: '', is_admin: false })
const canCreate = computed(() => {
const f = createForm.value
return !!f.email.trim() && !!f.display_name.trim() && f.password.length >= 8
})
const showEdit = ref(false)
const saving = ref(false)
const editName = ref('')
const editTarget = ref<AdminUser | null>(null)
const showTemp = ref(false)
const tempPassword = ref('')
// isSelf 用于禁用对自己的危险操作(降级/禁用/删除),与后端护栏一致。
function isSelf(row: AdminUser): boolean {
return row.id === auth.user?.id
}
const columns: DataTableColumns<AdminUser> = [
{ title: '邮箱', key: 'email' },
{ title: '显示名', key: 'display_name' },
{
title: '角色',
key: 'is_admin',
width: 90,
render: (row) =>
h(
NTag,
{ type: row.is_admin ? 'success' : 'default', size: 'small' },
{ default: () => (row.is_admin ? '管理员' : '用户') },
),
},
{
title: '状态',
key: 'enabled',
width: 90,
render: (row) =>
h(
NTag,
{ type: row.enabled ? 'info' : 'error', size: 'small' },
{ default: () => (row.enabled ? '启用' : '禁用') },
),
},
{
title: '创建时间',
key: 'created_at',
render: (row) => new Date(row.created_at).toLocaleString(),
},
{
title: '操作',
key: 'actions',
width: 360,
render: (row) =>
h(NSpace, { size: 'small' }, {
default: () => [
h(NButton, { size: 'small', onClick: () => openEdit(row) }, { default: () => '编辑' }),
h(
NButton,
{ size: 'small', disabled: isSelf(row), onClick: () => toggleRole(row) },
{ default: () => (row.is_admin ? '取消管理员' : '设为管理员') },
),
h(
NButton,
{ size: 'small', disabled: isSelf(row), onClick: () => toggleEnabled(row) },
{ default: () => (row.enabled ? '禁用' : '启用') },
),
h(NButton, { size: 'small', onClick: () => resetPassword(row) }, { default: () => '重置密码' }),
h(
NButton,
{ size: 'small', type: 'error', disabled: isSelf(row), onClick: () => removeUser(row) },
{ default: () => '删除' },
),
],
}),
},
]
onMounted(reload)
async function reload() {
loading.value = true
try {
users.value = await userAdminApi.list()
} catch (e: unknown) {
msg.error((e as Error).message || '加载失败')
} finally {
loading.value = false
}
}
function openCreate() {
createForm.value = { email: '', display_name: '', password: '', is_admin: false }
showCreate.value = true
}
async function doCreate() {
if (!canCreate.value) return
creating.value = true
try {
await userAdminApi.create({
email: createForm.value.email.trim(),
display_name: createForm.value.display_name.trim(),
password: createForm.value.password,
is_admin: createForm.value.is_admin,
})
showCreate.value = false
msg.success('已创建')
await reload()
} catch (e: unknown) {
msg.error((e as Error).message || '创建失败')
} finally {
creating.value = false
}
}
function openEdit(row: AdminUser) {
editTarget.value = row
editName.value = row.display_name
showEdit.value = true
}
async function doEdit() {
if (!editTarget.value || !editName.value.trim()) return
saving.value = true
try {
await userAdminApi.updateProfile(editTarget.value.id, editName.value.trim())
showEdit.value = false
msg.success('已保存')
await reload()
} catch (e: unknown) {
msg.error((e as Error).message || '保存失败')
} finally {
saving.value = false
}
}
async function toggleRole(row: AdminUser) {
try {
await userAdminApi.setRole(row.id, !row.is_admin)
msg.success('已更新角色')
await reload()
} catch (e: unknown) {
msg.error((e as Error).message || '操作失败')
}
}
async function toggleEnabled(row: AdminUser) {
try {
await userAdminApi.setEnabled(row.id, !row.enabled)
msg.success(row.enabled ? '已禁用' : '已启用')
await reload()
} catch (e: unknown) {
msg.error((e as Error).message || '操作失败')
}
}
function resetPassword(row: AdminUser) {
dlg.warning({
title: '重置密码',
content: `确定要重置 "${row.email}" 的密码吗?将生成新的临时密码并使该用户的全部会话失效。`,
positiveText: '重置',
negativeText: '取消',
onPositiveClick: async () => {
try {
const resp = await userAdminApi.resetPassword(row.id)
tempPassword.value = resp.temp_password
showTemp.value = true
} catch (e: unknown) {
msg.error((e as Error).message || '重置失败')
}
},
})
}
function removeUser(row: AdminUser) {
dlg.error({
title: '删除用户',
content: `确定要删除 "${row.email}" 此操作不可撤销`,
positiveText: '删除',
negativeText: '取消',
onPositiveClick: async () => {
try {
await userAdminApi.remove(row.id)
msg.success('已删除')
await reload()
} catch (e: unknown) {
msg.error((e as Error).message || '删除失败')
}
},
})
}
function copyTemp() {
navigator.clipboard.writeText(tempPassword.value).then(
() => msg.success('已复制'),
() => msg.error('复制失败'),
)
}
</script>