You've already forked agentic-coding-workflow
修改
This commit is contained in:
@@ -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' }),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -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' }),
|
||||
}
|
||||
@@ -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 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>
|
||||
@@ -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()
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user