This commit is contained in:
gr
2026-09-24 09:45:44 +08:00
parent dbf474ddd7
commit bd22e5f476
38 changed files with 19152 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
import request from '@/utils/request'
/** AI 助手(MCP)后台接口:挂在 /mcp/admin 下,沿用后台登录令牌 */
const opts = { urlPrefix: 'mcp' }
/** AI 授权列表(有 ai.grant/lists 看全部,否则只看自己的) */
export function aiGrantLists(params: any) {
return request.get({ url: '/admin/grants', params }, opts)
}
/** 撤销 AI 授权 */
export function aiGrantRevoke(params: { id: number }) {
return request.post({ url: '/admin/revoke', params }, opts)
}
/** AI 访问日志 */
export function aiAccessLogLists(params: any) {
return request.get({ url: '/admin/logs', params }, opts)
}
/** AI 数据目录与覆盖情况 */
export function aiCatalogLists(params: any) {
return request.get({ url: '/admin/catalog', params }, opts)
}
+135
View File
@@ -0,0 +1,135 @@
<template>
<div class="ai-access-log-page">
<el-card class="!border-none" shadow="never">
<el-form :inline="true">
<el-form-item label="时间">
<el-date-picker
v-model="timeRange"
type="datetimerange"
range-separator="至"
start-placeholder="开始时间"
end-placeholder="结束时间"
value-format="YYYY-MM-DD HH:mm:ss"
clearable
@change="resetPage"
/>
</el-form-item>
<el-form-item label="结果">
<el-select v-model="queryParams.status" clearable placeholder="全部" class="w-[120px]" @change="resetPage">
<el-option v-for="(label, value) in STATUS" :key="value" :label="label" :value="value" />
</el-select>
</el-form-item>
<el-form-item label="数据资源">
<el-input v-model="queryParams.resource" placeholder="如 tcm.diagnosis" clearable class="w-[180px]" @keyup.enter="resetPage" @clear="resetPage" />
</el-form-item>
<el-form-item label="记录ID">
<el-input v-model="queryParams.record_id" placeholder="如诊单ID" clearable class="w-[130px]" @keyup.enter="resetPage" @clear="resetPage" />
</el-form-item>
<el-form-item label="任务号">
<el-input v-model="queryParams.client_task_id" placeholder="行知任务号" clearable class="w-[200px]" @keyup.enter="resetPage" @clear="resetPage" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="resetPage">查询</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
<div class="page-hint">
记录每一次 AI 查询:谁、通过哪个客户端任务、查了哪个数据资源、返回了哪些记录。按“记录ID”可以查某个诊单被哪些账号通过 AI 看过。
</div>
</el-card>
<el-card class="!border-none mt-3" shadow="never">
<el-table :data="pager.lists" v-loading="pager.loading" size="default" stripe>
<el-table-column label="时间" width="165" prop="create_time_text" />
<el-table-column label="账号" min-width="120">
<template #default="{ row }">
<div>{{ row.admin_name || (row.admin_id ? '#' + row.admin_id : '—') }}</div>
<div class="cell-sub">{{ row.ip }}</div>
</template>
</el-table-column>
<el-table-column label="工具" width="170" prop="tool" />
<el-table-column label="数据资源" min-width="200">
<template #default="{ row }">
<div>{{ row.resource_name || row.resource || '—' }}</div>
<div class="cell-sub">{{ row.resource }}</div>
</template>
</el-table-column>
<el-table-column label="结果" width="90" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 'ok' ? 'success' : 'warning'" size="small">{{ STATUS[row.status] || row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column label="条数" width="70" align="right" prop="result_rows" />
<el-table-column label="记录ID / 原因" min-width="200">
<template #default="{ row }">
<el-tooltip v-if="row.record_ids" :content="row.record_ids" placement="top">
<span class="ellipsis">{{ row.record_ids }}</span>
</el-tooltip>
<span v-else class="cell-sub">{{ row.message }}</span>
</template>
</el-table-column>
<el-table-column label="参数" min-width="220">
<template #default="{ row }">
<el-tooltip v-if="row.arguments" :content="row.arguments" placement="top">
<span class="ellipsis">{{ row.arguments }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="耗时" width="80" align="right">
<template #default="{ row }">{{ row.duration_ms }}ms</template>
</el-table-column>
<el-table-column label="任务号" min-width="160" prop="client_task_id" />
</el-table>
<div class="flex justify-end mt-4">
<pagination v-model="pager" @change="getLists" />
</div>
</el-card>
</div>
</template>
<script setup lang="ts" name="aiMcpAccessLog">
import { aiAccessLogLists } from '@/api/ai_mcp'
import { usePaging } from '@/hooks/usePaging'
import { onMounted, reactive, ref, watch } from 'vue'
const STATUS: Record<string, string> = { ok: '成功', denied: '拒绝', invalid: '参数错误', limited: '超限', error: '失败' }
const timeRange = ref<[string, string] | null>(null)
const queryParams = reactive({ start_time: '', end_time: '', status: '', resource: '', record_id: '', client_task_id: '' })
watch(timeRange, (val) => {
queryParams.start_time = val?.[0] ?? ''
queryParams.end_time = val?.[1] ?? ''
})
const { pager, getLists, resetPage, resetParams } = usePaging({
fetchFun: aiAccessLogLists,
params: queryParams
})
const handleReset = () => {
timeRange.value = null
resetParams()
}
onMounted(() => getLists())
</script>
<style scoped>
.page-hint {
color: var(--el-text-color-secondary);
font-size: 13px;
line-height: 1.6;
}
.cell-sub {
color: var(--el-text-color-secondary);
font-size: 12px;
}
.ellipsis {
display: inline-block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: bottom;
}
</style>
+123
View File
@@ -0,0 +1,123 @@
<template>
<div class="ai-catalog-page">
<el-card class="!border-none" shadow="never">
<div class="summary">
<div class="summary-item">
<div class="summary-value success">{{ counts.open ?? 0 }}</div>
<div class="summary-label">已开放</div>
</div>
<div class="summary-item">
<div class="summary-value warning">{{ counts.pending ?? 0 }}</div>
<div class="summary-label">待整改</div>
</div>
<div class="summary-item">
<div class="summary-value">{{ counts.excluded ?? 0 }}</div>
<div class="summary-label">不开放</div>
</div>
</div>
<el-form :inline="true" class="mt-3">
<el-form-item label="状态">
<el-select v-model="queryParams.status" clearable placeholder="全部" class="w-[120px]" @change="resetPage">
<el-option label="已开放" value="open" />
<el-option label="待整改" value="pending" />
<el-option label="不开放" value="excluded" />
</el-select>
</el-form-item>
<el-form-item label="业务分组">
<el-select v-model="queryParams.domain" clearable filterable placeholder="全部" class="w-[180px]" @change="resetPage">
<el-option v-for="d in domains" :key="d" :label="d" :value="d" />
</el-select>
</el-form-item>
<el-form-item label="关键词">
<el-input v-model="queryParams.keyword" placeholder="名称或资源标识" clearable class="w-[200px]" @keyup.enter="resetPage" @clear="resetPage" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="resetPage">查询</el-button>
<el-button @click="resetParams">重置</el-button>
</el-form-item>
</el-form>
<div class="page-hint">
后台每个只读接口都是一个“数据资源”。已开放的资源由 AI 以调用账号自己的权限和数据范围执行原有接口;
待整改的资源写明了原因(如缺少逐条权限校验、会调用外部接口、权限点未登记),整改或审核通过后开放。
</div>
</el-card>
<el-card class="!border-none mt-3" shadow="never">
<el-table :data="pager.lists" v-loading="pager.loading" size="default" stripe>
<el-table-column label="资源" min-width="220">
<template #default="{ row }">
<div>{{ row.name }}</div>
<div class="cell-sub">{{ row.resource }}</div>
</template>
</el-table-column>
<el-table-column label="业务分组" width="150" prop="domain" />
<el-table-column label="类型" width="80">
<template #default="{ row }">{{ KIND[row.kind] || row.kind }}</template>
</el-table-column>
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="STATUS[row.status]?.type" size="small">{{ STATUS[row.status]?.label || row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column label="原因" min-width="260" prop="reason" />
<el-table-column label="已审核" width="80" align="center">
<template #default="{ row }">{{ row.reviewed ? '是' : '自动' }}</template>
</el-table-column>
</el-table>
<div class="flex justify-end mt-4">
<pagination v-model="pager" @change="getLists" />
</div>
</el-card>
</div>
</template>
<script setup lang="ts" name="aiMcpCatalog">
import { aiCatalogLists } from '@/api/ai_mcp'
import { usePaging } from '@/hooks/usePaging'
import { computed, onMounted, reactive } from 'vue'
const KIND: Record<string, string> = { list: '列表', detail: '详情', report: '统计', other: '其他', write: '写操作' }
const STATUS: Record<string, { label: string; type: 'success' | 'warning' | 'info' }> = {
open: { label: '已开放', type: 'success' },
pending: { label: '待整改', type: 'warning' },
excluded: { label: '不开放', type: 'info' }
}
const queryParams = reactive({ status: '', domain: '', keyword: '' })
const { pager, getLists, resetPage, resetParams } = usePaging({
fetchFun: aiCatalogLists,
params: queryParams,
size: 50
})
const counts = computed<Record<string, number>>(() => pager.extend?.counts || {})
const domains = computed<string[]>(() => pager.extend?.domains || [])
onMounted(() => getLists())
</script>
<style scoped>
.summary {
display: flex;
gap: 48px;
}
.summary-value {
font-size: 26px;
font-weight: 600;
}
.summary-value.success {
color: var(--el-color-success);
}
.summary-value.warning {
color: var(--el-color-warning);
}
.summary-label,
.page-hint,
.cell-sub {
color: var(--el-text-color-secondary);
font-size: 13px;
}
.page-hint {
line-height: 1.6;
}
</style>
+135
View File
@@ -0,0 +1,135 @@
<template>
<div class="ai-grant-page">
<el-card class="!border-none" shadow="never">
<el-alert
v-if="pager.extend && pager.extend.enabled === false"
type="warning"
:closable="false"
show-icon
class="mb-3"
title="AI 助手接口未启用:服务器 .env 的 [AI_MCP] ENABLED 为 false,客户端暂时无法绑定和查询。"
/>
<el-form :inline="true">
<el-form-item label="状态">
<el-select v-model="queryParams.status" clearable placeholder="全部" class="w-[120px]" @change="resetPage">
<el-option label="有效" value="1" />
<el-option label="已撤销" value="2" />
<el-option label="已过期" value="3" />
</el-select>
</el-form-item>
<el-form-item label="关键词">
<el-input
v-model="queryParams.keyword"
placeholder="姓名 / 账号 / 备注"
clearable
class="w-[220px]"
@keyup.enter="resetPage"
@clear="resetPage"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="resetPage">查询</el-button>
<el-button @click="resetParams">重置</el-button>
</el-form-item>
</el-form>
<div class="page-hint">
员工在行知等 AI 助手里用甄养堂账号密码绑定后,会在这里生成一条只读授权。AI 只能按该账号自己的权限和数据范围查询;
撤销后立即失效。账号改密、停用、失去“允许 AI 助手查询”权限时,授权也会自动失效。
</div>
</el-card>
<el-card class="!border-none mt-3" shadow="never">
<el-table :data="pager.lists" v-loading="pager.loading" size="default" stripe>
<el-table-column label="账号" min-width="140">
<template #default="{ row }">
<div>{{ row.admin_name || '—' }}</div>
<div class="cell-sub">{{ row.admin_account }}</div>
</template>
</el-table-column>
<el-table-column label="客户端" min-width="150">
<template #default="{ row }">
<div>{{ row.client }}<span v-if="row.client_instance"> · {{ row.client_instance }}</span></div>
<div class="cell-sub">{{ row.label }}</div>
</template>
</el-table-column>
<el-table-column label="令牌" width="130" prop="token_prefix" />
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="row.status_text === '有效' ? 'success' : 'info'" size="small">{{ row.status_text }}</el-tag>
</template>
</el-table-column>
<el-table-column label="最近使用" min-width="150">
<template #default="{ row }">
<div>{{ row.last_used_time_text || '—' }}</div>
<div class="cell-sub">{{ row.last_used_ip }}</div>
</template>
</el-table-column>
<el-table-column label="到期时间" width="150" prop="expire_time_text" />
<el-table-column label="签发时间" width="150" prop="create_time_text" />
<el-table-column label="撤销" min-width="140">
<template #default="{ row }">
<template v-if="row.revoke_time_text">
<div>{{ row.revoke_time_text }}</div>
<div class="cell-sub">{{ reasonText(row.revoke_reason) }}</div>
</template>
<span v-else>—</span>
</template>
</el-table-column>
<el-table-column label="操作" width="90" fixed="right">
<template #default="{ row }">
<el-button v-if="row.can_revoke" type="danger" link @click="handleRevoke(row)">撤销</el-button>
</template>
</el-table-column>
</el-table>
<div class="flex justify-end mt-4">
<pagination v-model="pager" @change="getLists" />
</div>
</el-card>
</div>
</template>
<script setup lang="ts" name="aiMcpGrant">
import { aiGrantLists, aiGrantRevoke } from '@/api/ai_mcp'
import { usePaging } from '@/hooks/usePaging'
import feedback from '@/utils/feedback'
import { onMounted, reactive } from 'vue'
const queryParams = reactive({ status: '', keyword: '' })
const { pager, getLists, resetPage, resetParams } = usePaging({
fetchFun: aiGrantLists,
params: queryParams
})
const REASONS: Record<string, string> = {
client_revoke: '客户端解绑',
admin_revoke: '后台撤销',
rebind: '重新绑定',
expired: '到期/闲置失效',
password_changed: '账号改密',
admin_disabled: '账号停用',
admin_deleted: '账号删除'
}
const reasonText = (reason: string) => REASONS[reason] || reason || ''
const handleRevoke = async (row: any) => {
await feedback.confirm(`确认撤销 ${row.admin_name || row.admin_account} 的这条 AI 授权?撤销后该客户端需要重新绑定。`)
await aiGrantRevoke({ id: row.id })
feedback.msgSuccess('已撤销')
getLists()
}
onMounted(() => getLists())
</script>
<style scoped>
.page-hint {
color: var(--el-text-color-secondary);
font-size: 13px;
line-height: 1.6;
}
.cell-sub {
color: var(--el-text-color-secondary);
font-size: 12px;
}
</style>