You've already forked agentic-coding-workflow
feat(mcp): 16 PM tools + chat tool + 6 resource URIs + admin token UI
Phase F: tools_pm.go — list/get/create/update/close/reopen/assign for projects, workspaces, requirements, issues (16 tools total). Phase G: tools_chat.go — list_recent_messages tool; resources.go — 6 PM resource URI templates (projects, project-detail, requirements, issues, workspaces, workspace-detail). Phase H: frontend admin UI — mcpTokens API client, Pinia store, MCPTokenAdminView with create/revoke/list, router entry, NavBar link. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
import { buildQuery, request } from './client'
|
||||
|
||||
export interface MCPTokenScope {
|
||||
tools?: string[] | null
|
||||
project_ids?: string[] | null
|
||||
}
|
||||
|
||||
export interface MCPToken {
|
||||
id: string
|
||||
name: string
|
||||
issuer: 'admin' | 'system'
|
||||
scope: MCPTokenScope
|
||||
expires_at?: string | null
|
||||
last_used_at?: string | null
|
||||
revoked_at?: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface CreateTokenReq {
|
||||
user_id?: string
|
||||
name: string
|
||||
scope?: MCPTokenScope
|
||||
expires_at: string
|
||||
}
|
||||
|
||||
export interface CreateTokenResp {
|
||||
id: string
|
||||
token: string
|
||||
expires_at: string
|
||||
}
|
||||
|
||||
export const mcpTokensApi = {
|
||||
list: (params?: { active_only?: boolean }) =>
|
||||
request<MCPToken[]>(
|
||||
`/api/v1/mcp/tokens${buildQuery(params ?? {})}`,
|
||||
),
|
||||
|
||||
create: (req: CreateTokenReq) =>
|
||||
request<CreateTokenResp>('/api/v1/mcp/tokens', { method: 'POST', body: req }),
|
||||
|
||||
revoke: (id: string) =>
|
||||
request<void>(`/api/v1/mcp/tokens/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
}
|
||||
@@ -57,6 +57,12 @@
|
||||
>
|
||||
全局用量
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
:to="{ name: 'admin-mcp-tokens' }"
|
||||
class="text-sm hover:underline"
|
||||
>
|
||||
MCP Tokens
|
||||
</RouterLink>
|
||||
</template>
|
||||
</nav>
|
||||
<span
|
||||
|
||||
@@ -136,6 +136,12 @@ const routes: RouteRecordRaw[] = [
|
||||
props: { adminMode: true },
|
||||
meta: { requiresAuth: true, requiresAdmin: true },
|
||||
},
|
||||
{
|
||||
path: '/admin/mcp-tokens',
|
||||
name: 'admin-mcp-tokens',
|
||||
component: () => import('@/views/admin/MCPTokenAdminView.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true },
|
||||
},
|
||||
{
|
||||
path: '/admin/acp/agent-kinds',
|
||||
component: () => import('@/views/admin/AgentKindAdminListView.vue'),
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import {
|
||||
mcpTokensApi,
|
||||
type MCPToken,
|
||||
type CreateTokenReq,
|
||||
type CreateTokenResp,
|
||||
} from '@/api/mcpTokens'
|
||||
|
||||
export const useMcpTokensStore = defineStore('mcpTokens', () => {
|
||||
const tokens = ref<MCPToken[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
async function fetch(params?: { active_only?: boolean }) {
|
||||
loading.value = true
|
||||
try {
|
||||
tokens.value = await mcpTokensApi.list(params)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function create(req: CreateTokenReq): Promise<CreateTokenResp> {
|
||||
const resp = await mcpTokensApi.create(req)
|
||||
await fetch()
|
||||
return resp
|
||||
}
|
||||
|
||||
async function revoke(id: string) {
|
||||
await mcpTokensApi.revoke(id)
|
||||
await fetch()
|
||||
}
|
||||
|
||||
return { tokens, loading, fetch, create, revoke }
|
||||
})
|
||||
@@ -0,0 +1,255 @@
|
||||
<template>
|
||||
<AppShell>
|
||||
<div class="space-y-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<h2 class="text-lg font-semibold">
|
||||
MCP Tokens
|
||||
</h2>
|
||||
<div class="flex items-center gap-3">
|
||||
<NCheckbox
|
||||
v-model:checked="activeOnly"
|
||||
@update:checked="reload"
|
||||
>
|
||||
仅显示活跃
|
||||
</NCheckbox>
|
||||
<NButton
|
||||
type="primary"
|
||||
@click="openCreate"
|
||||
>
|
||||
+ 新增
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
<NDataTable
|
||||
:data="store.tokens"
|
||||
:columns="columns"
|
||||
:row-key="rowKey"
|
||||
:loading="store.loading"
|
||||
/>
|
||||
|
||||
<!-- Create modal -->
|
||||
<NModal
|
||||
v-model:show="showCreate"
|
||||
preset="card"
|
||||
title="新增 MCP Token"
|
||||
style="width: 520px"
|
||||
>
|
||||
<NForm>
|
||||
<NFormItem label="名称">
|
||||
<NInput v-model:value="createForm.name" />
|
||||
</NFormItem>
|
||||
<NFormItem label="过期时间">
|
||||
<NDatePicker
|
||||
v-model:value="createForm.expiresAt"
|
||||
type="datetime"
|
||||
:is-date-disabled="isDateDisabled"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</NFormItem>
|
||||
<NFormItem label="允许的工具 (scope)">
|
||||
<NInput
|
||||
v-model:value="toolsInput"
|
||||
placeholder="逗号分隔,留空表示全部"
|
||||
/>
|
||||
</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>
|
||||
|
||||
<!-- Token display dialog -->
|
||||
<NModal
|
||||
v-model:show="showToken"
|
||||
preset="card"
|
||||
title="Token 已创建"
|
||||
style="width: 520px"
|
||||
>
|
||||
<NAlert
|
||||
type="warning"
|
||||
:bordered="false"
|
||||
>
|
||||
请立即复制 Token,关闭后无法再次查看。
|
||||
</NAlert>
|
||||
<NInput
|
||||
:value="createdToken"
|
||||
readonly
|
||||
type="textarea"
|
||||
rows="3"
|
||||
class="mt-3"
|
||||
/>
|
||||
<template #footer>
|
||||
<div class="flex justify-end">
|
||||
<NButton @click="copyToken">
|
||||
复制
|
||||
</NButton>
|
||||
</div>
|
||||
</template>
|
||||
</NModal>
|
||||
</div>
|
||||
</AppShell>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, h, onMounted, ref } from 'vue'
|
||||
import {
|
||||
NAlert,
|
||||
NButton,
|
||||
NCheckbox,
|
||||
NDataTable,
|
||||
NDatePicker,
|
||||
NForm,
|
||||
NFormItem,
|
||||
NInput,
|
||||
NModal,
|
||||
useDialog,
|
||||
useMessage,
|
||||
} from 'naive-ui'
|
||||
import type { DataTableColumns } from 'naive-ui'
|
||||
|
||||
import AppShell from '@/layouts/AppShell.vue'
|
||||
import { useMcpTokensStore } from '@/stores/mcpTokens'
|
||||
import type { MCPToken } from '@/api/mcpTokens'
|
||||
|
||||
const store = useMcpTokensStore()
|
||||
const msg = useMessage()
|
||||
const dlg = useDialog()
|
||||
|
||||
const activeOnly = ref(false)
|
||||
const showCreate = ref(false)
|
||||
const creating = ref(false)
|
||||
const showToken = ref(false)
|
||||
const createdToken = ref('')
|
||||
|
||||
const createForm = ref<{ name: string; expiresAt: number | null }>({
|
||||
name: '',
|
||||
expiresAt: null,
|
||||
})
|
||||
const toolsInput = ref('')
|
||||
|
||||
const canCreate = computed(() => {
|
||||
const f = createForm.value
|
||||
return !!f.name.trim() && f.expiresAt !== null
|
||||
})
|
||||
|
||||
function isDateDisabled(ts: number): boolean {
|
||||
return ts < Date.now()
|
||||
}
|
||||
|
||||
const columns: DataTableColumns<MCPToken> = [
|
||||
{ title: '名称', key: 'name' },
|
||||
{ title: '签发者', key: 'issuer', width: 90 },
|
||||
{
|
||||
title: '过期时间',
|
||||
key: 'expires_at',
|
||||
render: (row) => row.expires_at ? new Date(row.expires_at).toLocaleString() : '永不过期',
|
||||
},
|
||||
{
|
||||
title: '最后使用',
|
||||
key: 'last_used_at',
|
||||
render: (row) => row.last_used_at ? new Date(row.last_used_at).toLocaleString() : '-',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
render: (row) => {
|
||||
if (row.revoked_at) return '已吊销'
|
||||
if (row.expires_at && new Date(row.expires_at) < new Date()) return '已过期'
|
||||
return '活跃'
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
key: 'created_at',
|
||||
render: (row) => new Date(row.created_at).toLocaleString(),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
render: (row) =>
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
size: 'small',
|
||||
type: 'error',
|
||||
disabled: !!row.revoked_at,
|
||||
onClick: () => revoke(row),
|
||||
},
|
||||
{ default: () => '吊销' },
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const rowKey = (row: MCPToken) => row.id
|
||||
|
||||
onMounted(reload)
|
||||
|
||||
async function reload() {
|
||||
await store.fetch(activeOnly.value ? { active_only: true } : undefined)
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
createForm.value = { name: '', expiresAt: null }
|
||||
toolsInput.value = ''
|
||||
showCreate.value = true
|
||||
}
|
||||
|
||||
async function doCreate() {
|
||||
const f = createForm.value
|
||||
if (!f.name.trim() || f.expiresAt === null) return
|
||||
creating.value = true
|
||||
try {
|
||||
const tools = toolsInput.value.trim()
|
||||
? toolsInput.value.split(',').map((s) => s.trim()).filter(Boolean)
|
||||
: undefined
|
||||
const resp = await store.create({
|
||||
name: f.name.trim(),
|
||||
expires_at: new Date(f.expiresAt).toISOString(),
|
||||
scope: tools?.length ? { tools } : undefined,
|
||||
})
|
||||
showCreate.value = false
|
||||
createdToken.value = resp.token
|
||||
showToken.value = true
|
||||
} catch (e: unknown) {
|
||||
msg.error((e as Error).message || '创建失败')
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function revoke(row: MCPToken) {
|
||||
dlg.warning({
|
||||
title: '确认吊销',
|
||||
content: `确定要吊销 Token "${row.name}" 吗?此操作不可撤销。`,
|
||||
positiveText: '吊销',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: async () => {
|
||||
try {
|
||||
await store.revoke(row.id)
|
||||
msg.success('已吊销')
|
||||
} catch (e: unknown) {
|
||||
msg.error((e as Error).message || '吊销失败')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function copyToken() {
|
||||
navigator.clipboard.writeText(createdToken.value).then(
|
||||
() => msg.success('已复制'),
|
||||
() => msg.error('复制失败'),
|
||||
)
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user