feat(admin): U9 — frontend AdminLayout + 7 management pages

AdminLayout with sidebar nav + 7 admin views (dashboard, departments,
users, llm, skills, kb, usage). AdminApiClient extended with 40+
methods. Router restructured with nested admin routes. typecheck +
build pass.
This commit is contained in:
chiguyong 2026-06-21 19:34:41 +08:00
parent 2dd0091bda
commit e5a92427a4
15 changed files with 3589 additions and 25 deletions

View File

@ -23,7 +23,9 @@ declare module 'vue' {
ADescriptions: typeof import('ant-design-vue/es')['Descriptions'] ADescriptions: typeof import('ant-design-vue/es')['Descriptions']
ADescriptionsItem: typeof import('ant-design-vue/es')['DescriptionsItem'] ADescriptionsItem: typeof import('ant-design-vue/es')['DescriptionsItem']
ADivider: typeof import('ant-design-vue/es')['Divider'] ADivider: typeof import('ant-design-vue/es')['Divider']
AdminLayout: typeof import('./src/components/layout/AdminLayout.vue')['default']
ADrawer: typeof import('ant-design-vue/es')['Drawer'] ADrawer: typeof import('ant-design-vue/es')['Drawer']
ADropdown: typeof import('ant-design-vue/es')['Dropdown']
AEmpty: typeof import('ant-design-vue/es')['Empty'] AEmpty: typeof import('ant-design-vue/es')['Empty']
AForm: typeof import('ant-design-vue/es')['Form'] AForm: typeof import('ant-design-vue/es')['Form']
AFormItem: typeof import('ant-design-vue/es')['FormItem'] AFormItem: typeof import('ant-design-vue/es')['FormItem']
@ -32,18 +34,30 @@ declare module 'vue' {
AInputNumber: typeof import('ant-design-vue/es')['InputNumber'] AInputNumber: typeof import('ant-design-vue/es')['InputNumber']
AInputPassword: typeof import('ant-design-vue/es')['InputPassword'] AInputPassword: typeof import('ant-design-vue/es')['InputPassword']
AInputSearch: typeof import('ant-design-vue/es')['InputSearch'] AInputSearch: typeof import('ant-design-vue/es')['InputSearch']
ALayout: typeof import('ant-design-vue/es')['Layout']
ALayoutContent: typeof import('ant-design-vue/es')['LayoutContent']
ALayoutHeader: typeof import('ant-design-vue/es')['LayoutHeader']
ALayoutSider: typeof import('ant-design-vue/es')['LayoutSider']
AList: typeof import('ant-design-vue/es')['List']
AListItem: typeof import('ant-design-vue/es')['ListItem']
AListItemMeta: typeof import('ant-design-vue/es')['ListItemMeta']
AMenu: typeof import('ant-design-vue/es')['Menu']
AMenuItem: typeof import('ant-design-vue/es')['MenuItem']
AModal: typeof import('ant-design-vue/es')['Modal'] AModal: typeof import('ant-design-vue/es')['Modal']
APageHeader: typeof import('ant-design-vue/es')['PageHeader']
APopconfirm: typeof import('ant-design-vue/es')['Popconfirm'] APopconfirm: typeof import('ant-design-vue/es')['Popconfirm']
AppLayout: typeof import('./src/components/layout/AppLayout.vue')['default'] AppLayout: typeof import('./src/components/layout/AppLayout.vue')['default']
ApprovalNode: typeof import('./src/components/workflow/ApprovalNode.vue')['default'] ApprovalNode: typeof import('./src/components/workflow/ApprovalNode.vue')['default']
ARadioButton: typeof import('ant-design-vue/es')['RadioButton'] ARadioButton: typeof import('ant-design-vue/es')['RadioButton']
ARadioGroup: typeof import('ant-design-vue/es')['RadioGroup'] ARadioGroup: typeof import('ant-design-vue/es')['RadioGroup']
ARangePicker: typeof import('ant-design-vue/es/date-picker/dayjs')['RangePicker']
ARow: typeof import('ant-design-vue/es')['Row'] ARow: typeof import('ant-design-vue/es')['Row']
ASelect: typeof import('ant-design-vue/es')['Select'] ASelect: typeof import('ant-design-vue/es')['Select']
ASelectOption: typeof import('ant-design-vue/es')['SelectOption'] ASelectOption: typeof import('ant-design-vue/es')['SelectOption']
ASpace: typeof import('ant-design-vue/es')['Space'] ASpace: typeof import('ant-design-vue/es')['Space']
ASpin: typeof import('ant-design-vue/es')['Spin'] ASpin: typeof import('ant-design-vue/es')['Spin']
AssistantText: typeof import('./src/components/chat/messages/AssistantText.vue')['default'] AssistantText: typeof import('./src/components/chat/messages/AssistantText.vue')['default']
AStatistic: typeof import('ant-design-vue/es')['Statistic']
ASwitch: typeof import('ant-design-vue/es')['Switch'] ASwitch: typeof import('ant-design-vue/es')['Switch']
ATable: typeof import('ant-design-vue/es')['Table'] ATable: typeof import('ant-design-vue/es')['Table']
ATabPane: typeof import('ant-design-vue/es')['TabPane'] ATabPane: typeof import('ant-design-vue/es')['TabPane']

View File

@ -1,29 +1,129 @@
/** /**
* Admin API client (U9 Admin UI: user sessions management). * Admin API client (U9 Admin Console).
* *
* Talks to the server-side ``/api/v1/admin/...`` endpoints implemented * Talks to the server-side ``/api/v1/admin/...`` endpoints implemented
* in ``src/agentkit/server/routes/auth.py``. The endpoints require * in ``src/agentkit/server/routes/auth.py`` (sessions) and
* ``src/agentkit/server/routes/admin.py`` (departments, users, LLM
* config, quotas, skills, KB, usage). All endpoints require the
* ``USER_MANAGE`` permission (admin role); the API client does not * ``USER_MANAGE`` permission (admin role); the API client does not
* itself enforce that the server's 403 response is surfaced to the * itself enforce that the server's 403 response is surfaced to the
* caller as an :class:`IApiError` with ``status: 403``. * caller as an :class:`IApiError` with ``status: 403``.
* *
* Surface: * Surface (grouped by domain):
* - ``listAllSessions`` every recent session across the system * - Sessions: ``listAllSessions`` / ``listUserSessions`` / ``revokeUserSession``
* (admin "active sessions" overview). * - Departments: CRUD + enable/disable + skill/KB bindings
* - ``listUserSessions(userId)`` every session for a specific user, * - Users: CRUD + reset-password + department assignment
* including revoked ones when ``includeRevoked`` is true. * - LLM config: provider CRUD + api-key + fallback chains
* - ``revokeUserSession(userId, sid)`` force-revoke a single session * - Quotas: per-department quota CRUD
* of a single user. * - Skills: enable/disable + import + reload + config update
* - KB: documents list/upload/delete + source sync/rebuild
* - Usage: summary / timeseries / by-model / top-users / export
*/ */
import { BaseApiClient } from './base' import { BaseApiClient } from './base'
import type { ISessionInfo } from './auth' import type { ISessionInfo } from './auth'
// ---------------------------------------------------------------------------
// TypeScript interfaces — admin domain models
// ---------------------------------------------------------------------------
/** Department record (matches backend ``departments`` table projection). */
export interface IDepartment {
id: string
name: string
description: string
is_active: boolean
created_at: string
}
/** Admin user record (matches backend ``users`` table projection + departments). */
export interface IAdminUser {
id: string
username: string
email: string
role: string
is_active: boolean
is_terminal_authorized: boolean
is_server_terminal_authorized?: boolean
created_at: string
departments?: IDepartment[]
}
/** LLM provider record (API keys are masked by the server). */
export interface ILlmProvider {
name: string
type: string
api_key: string
base_url: string
models: Record<string, unknown>
max_tokens: number
timeout: number
}
/** Quota record for a department. */
export interface IQuota {
id: string
department_id: string
quota_type: string
limit_value: number | string[]
period: string
updated_at: string
}
/** KB document record. */
export interface IKbDocument {
document_id: string
filename: string
source_id: string
chunks: number
status: string
created_at: string
department_id?: string
}
/** Usage summary aggregation. */
export interface IUsageSummary {
total_tokens: number
total_cost: number
total_requests: number
by_model: Record<string, { tokens: number; cost: number; requests: number }>
by_user: Record<string, { tokens: number; cost: number; requests: number }>
by_department: Record<string, { tokens: number; cost: number; requests: number }>
}
/** Usage timeseries bucket. */
export interface IUsageTimeseries {
timestamp: string
tokens: number
cost: number
requests: number
}
/** Per-model usage breakdown row. */
export interface IUsageByModel {
model: string
tokens: number
cost: number
requests: number
}
/** Top-user usage row. */
export interface IUsageTopUser {
user_id: string
tokens: number
cost: number
requests: number
}
class AdminApiClient extends BaseApiClient { class AdminApiClient extends BaseApiClient {
constructor(baseUrl: string = '/api/v1') { constructor(baseUrl: string = '/api/v1') {
super(baseUrl) super(baseUrl)
} }
// -----------------------------------------------------------------------
// Sessions (existing — kept for backwards compat)
// -----------------------------------------------------------------------
/** /**
* List recent sessions across all users (admin only). * List recent sessions across all users (admin only).
* *
@ -68,6 +168,442 @@ class AdminApiClient extends BaseApiClient {
{ method: 'DELETE' }, { method: 'DELETE' },
) )
} }
// -----------------------------------------------------------------------
// Departments
// -----------------------------------------------------------------------
async listDepartments(): Promise<IDepartment[]> {
return this.request<IDepartment[]>('/admin/departments')
}
async createDepartment(name: string, description: string): Promise<IDepartment> {
return this.request<IDepartment>('/admin/departments', {
method: 'POST',
body: JSON.stringify({ name, description }),
})
}
async getDepartment(id: string): Promise<IDepartment> {
return this.request<IDepartment>(`/admin/departments/${encodeURIComponent(id)}`)
}
async updateDepartment(
id: string,
data: { name?: string; description?: string },
): Promise<IDepartment> {
return this.request<IDepartment>(`/admin/departments/${encodeURIComponent(id)}`, {
method: 'PATCH',
body: JSON.stringify(data),
})
}
async deleteDepartment(id: string): Promise<void> {
await this.request<{ deleted: boolean }>(
`/admin/departments/${encodeURIComponent(id)}`,
{ method: 'DELETE' },
)
}
async disableDepartment(id: string): Promise<void> {
await this.request<IDepartment>(
`/admin/departments/${encodeURIComponent(id)}/disable`,
{ method: 'POST' },
)
}
async enableDepartment(id: string): Promise<void> {
await this.request<IDepartment>(
`/admin/departments/${encodeURIComponent(id)}/enable`,
{ method: 'POST' },
)
}
async bindSkill(deptId: string, skillName: string): Promise<void> {
await this.request(
`/admin/departments/${encodeURIComponent(deptId)}/skills/${encodeURIComponent(skillName)}`,
{ method: 'POST' },
)
}
async unbindSkill(deptId: string, skillName: string): Promise<void> {
await this.request(
`/admin/departments/${encodeURIComponent(deptId)}/skills/${encodeURIComponent(skillName)}`,
{ method: 'DELETE' },
)
}
async bindKb(deptId: string, sourceId: string): Promise<void> {
await this.request(
`/admin/departments/${encodeURIComponent(deptId)}/kb/${encodeURIComponent(sourceId)}`,
{ method: 'POST' },
)
}
async unbindKb(deptId: string, sourceId: string): Promise<void> {
await this.request(
`/admin/departments/${encodeURIComponent(deptId)}/kb/${encodeURIComponent(sourceId)}`,
{ method: 'DELETE' },
)
}
async listDepartmentSkills(deptId: string): Promise<string[]> {
return this.request<string[]>(
`/admin/departments/${encodeURIComponent(deptId)}/skills`,
)
}
async listDepartmentKb(deptId: string): Promise<string[]> {
return this.request<string[]>(
`/admin/departments/${encodeURIComponent(deptId)}/kb`,
)
}
// -----------------------------------------------------------------------
// Users
// -----------------------------------------------------------------------
async listUsers(departmentId?: string): Promise<IAdminUser[]> {
const qs = departmentId
? `?department_id=${encodeURIComponent(departmentId)}`
: ''
return this.request<IAdminUser[]>(`/admin/users${qs}`)
}
async createUser(data: {
username: string
email: string
password: string
role?: string
department_ids?: string[]
}): Promise<IAdminUser> {
return this.request<IAdminUser>('/admin/users', {
method: 'POST',
body: JSON.stringify(data),
})
}
async getUser(id: string): Promise<IAdminUser> {
return this.request<IAdminUser>(`/admin/users/${encodeURIComponent(id)}`)
}
async updateUser(id: string, data: Partial<IAdminUser>): Promise<IAdminUser> {
return this.request<IAdminUser>(`/admin/users/${encodeURIComponent(id)}`, {
method: 'PATCH',
body: JSON.stringify(data),
})
}
async deleteUser(id: string): Promise<void> {
await this.request<{ deleted: boolean }>(
`/admin/users/${encodeURIComponent(id)}`,
{ method: 'DELETE' },
)
}
async resetPassword(id: string, newPassword: string): Promise<void> {
await this.request<{ reset: boolean }>(
`/admin/users/${encodeURIComponent(id)}/reset-password`,
{
method: 'POST',
body: JSON.stringify({ new_password: newPassword }),
},
)
}
async assignDepartment(userId: string, deptId: string): Promise<void> {
await this.request<{ assigned: boolean }>(
`/admin/users/${encodeURIComponent(userId)}/departments/${encodeURIComponent(deptId)}`,
{ method: 'POST' },
)
}
async removeDepartment(userId: string, deptId: string): Promise<void> {
await this.request<{ removed: boolean }>(
`/admin/users/${encodeURIComponent(userId)}/departments/${encodeURIComponent(deptId)}`,
{ method: 'DELETE' },
)
}
// -----------------------------------------------------------------------
// LLM Config
// -----------------------------------------------------------------------
async listLlmProviders(): Promise<ILlmProvider[]> {
return this.request<ILlmProvider[]>('/admin/llm/providers')
}
async createLlmProvider(
name: string,
config: Record<string, unknown>,
): Promise<ILlmProvider> {
return this.request<ILlmProvider>('/admin/llm/providers', {
method: 'POST',
body: JSON.stringify({ name, ...config }),
})
}
async updateLlmProvider(
name: string,
config: Record<string, unknown>,
): Promise<ILlmProvider> {
return this.request<ILlmProvider>(
`/admin/llm/providers/${encodeURIComponent(name)}`,
{
method: 'PATCH',
body: JSON.stringify(config),
},
)
}
async deleteLlmProvider(name: string): Promise<void> {
await this.request<{ deleted: boolean }>(
`/admin/llm/providers/${encodeURIComponent(name)}`,
{ method: 'DELETE' },
)
}
async setLlmApiKey(name: string, apiKey: string): Promise<void> {
await this.request(
`/admin/llm/providers/${encodeURIComponent(name)}/api-key`,
{
method: 'POST',
body: JSON.stringify({ api_key: apiKey }),
},
)
}
async listLlmFallbacks(): Promise<Record<string, string[]>> {
return this.request<Record<string, string[]>>('/admin/llm/fallbacks')
}
async setLlmFallback(model: string, chain: string[]): Promise<void> {
await this.request(
`/admin/llm/fallbacks/${encodeURIComponent(model)}`,
{
method: 'PUT',
body: JSON.stringify({ chain }),
},
)
}
async deleteLlmFallback(model: string): Promise<void> {
await this.request<{ deleted: boolean }>(
`/admin/llm/fallbacks/${encodeURIComponent(model)}`,
{ method: 'DELETE' },
)
}
// -----------------------------------------------------------------------
// Quotas
// -----------------------------------------------------------------------
async listDepartmentQuotas(deptId: string): Promise<IQuota[]> {
return this.request<IQuota[]>(
`/admin/departments/${encodeURIComponent(deptId)}/quotas`,
)
}
async setDepartmentQuota(
deptId: string,
data: { quota_type: string; limit_value: number | string[]; period: string },
): Promise<IQuota> {
return this.request<IQuota>(
`/admin/departments/${encodeURIComponent(deptId)}/quotas`,
{
method: 'PUT',
body: JSON.stringify(data),
},
)
}
async deleteDepartmentQuota(
deptId: string,
quotaType: string,
period: string,
): Promise<void> {
const qs = `?quota_type=${encodeURIComponent(quotaType)}&period=${encodeURIComponent(period)}`
await this.request<{ deleted: boolean }>(
`/admin/departments/${encodeURIComponent(deptId)}/quotas${qs}`,
{ method: 'DELETE' },
)
}
// -----------------------------------------------------------------------
// Skills
// -----------------------------------------------------------------------
async enableSkill(name: string): Promise<void> {
await this.request(
`/admin/skills/${encodeURIComponent(name)}/enable`,
{ method: 'POST' },
)
}
async disableSkill(name: string): Promise<void> {
await this.request(
`/admin/skills/${encodeURIComponent(name)}/disable`,
{ method: 'POST' },
)
}
async importSkill(yamlContent: string): Promise<void> {
await this.request('/admin/skills/import', {
method: 'POST',
body: JSON.stringify({ yaml_content: yamlContent }),
})
}
async reloadSkill(name: string): Promise<void> {
await this.request(
`/admin/skills/${encodeURIComponent(name)}/reload`,
{ method: 'POST' },
)
}
async updateSkillConfig(
name: string,
config: Record<string, unknown>,
): Promise<void> {
await this.request(`/admin/skills/${encodeURIComponent(name)}`, {
method: 'PATCH',
body: JSON.stringify({ config }),
})
}
// -----------------------------------------------------------------------
// KB
// -----------------------------------------------------------------------
async listKbDocuments(
sourceId?: string,
departmentId?: string,
): Promise<IKbDocument[]> {
const params: string[] = []
if (sourceId) params.push(`source_id=${encodeURIComponent(sourceId)}`)
if (departmentId) params.push(`department_id=${encodeURIComponent(departmentId)}`)
const qs = params.length ? `?${params.join('&')}` : ''
const res = await this.request<{ documents: IKbDocument[] }>(
`/admin/kb/documents${qs}`,
)
return res?.documents ?? []
}
async uploadKbDocument(data: {
filename: string
content: string
source_id: string
department_id?: string
}): Promise<IKbDocument> {
return this.request<IKbDocument>('/admin/kb/documents', {
method: 'POST',
body: JSON.stringify(data),
})
}
async deleteKbDocument(id: string): Promise<void> {
await this.request<{ deleted: boolean }>(
`/admin/kb/documents/${encodeURIComponent(id)}`,
{ method: 'DELETE' },
)
}
async syncKbSource(id: string): Promise<void> {
await this.request(
`/admin/kb/sources/${encodeURIComponent(id)}/sync`,
{ method: 'POST' },
)
}
async rebuildKbSource(id: string): Promise<void> {
await this.request(
`/admin/kb/sources/${encodeURIComponent(id)}/rebuild`,
{ method: 'POST' },
)
}
// -----------------------------------------------------------------------
// Usage
// -----------------------------------------------------------------------
async getUsageSummary(params: {
department_id?: string
user_id?: string
start?: string
end?: string
}): Promise<IUsageSummary> {
return this.request<IUsageSummary>(
`/admin/usage/summary${_buildUsageQuery(params)}`,
)
}
async getUsageTimeseries(params: {
department_id?: string
user_id?: string
start?: string
end?: string
interval?: string
}): Promise<IUsageTimeseries[]> {
const qs = _buildUsageQuery(params, params.interval ? { interval: params.interval } : {})
return this.request<IUsageTimeseries[]>(`/admin/usage/timeseries${qs}`)
}
async getUsageByModel(params: {
department_id?: string
user_id?: string
start?: string
end?: string
}): Promise<IUsageByModel[]> {
return this.request<IUsageByModel[]>(
`/admin/usage/by-model${_buildUsageQuery(params)}`,
)
}
async getUsageTopUsers(params: {
department_id?: string
limit?: number
}): Promise<IUsageTopUser[]> {
const extra: Record<string, string> = {}
if (params.limit !== undefined) extra.limit = String(params.limit)
return this.request<IUsageTopUser[]>(
`/admin/usage/top-users${_buildUsageQuery(params, extra)}`,
)
}
async exportUsage(params: {
department_id?: string
user_id?: string
start?: string
end?: string
format?: string
}): Promise<string> {
const extra: Record<string, string> = {}
if (params.format) extra.format = params.format
return this.request<string>(
`/admin/usage/export${_buildUsageQuery(params, extra)}`,
)
}
}
/**
* Build a query string from the common usage filter params.
*
* Undefined / empty values are omitted. ``extra`` is merged in for
* endpoint-specific params (``interval``, ``limit``, ``format``).
*/
function _buildUsageQuery(
params: { department_id?: string; user_id?: string; start?: string; end?: string },
extra: Record<string, string> = {},
): string {
const parts: string[] = []
if (params.department_id) parts.push(`department_id=${encodeURIComponent(params.department_id)}`)
if (params.user_id) parts.push(`user_id=${encodeURIComponent(params.user_id)}`)
if (params.start) parts.push(`start=${encodeURIComponent(params.start)}`)
if (params.end) parts.push(`end=${encodeURIComponent(params.end)}`)
for (const [k, v] of Object.entries(extra)) {
parts.push(`${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
}
return parts.length ? `?${parts.join('&')}` : ''
} }
export const adminApi = new AdminApiClient() export const adminApi = new AdminApiClient()

View File

@ -0,0 +1,209 @@
<template>
<a-layout class="admin-layout">
<a-layout-sider
v-model:collapsed="collapsed"
:trigger="null"
collapsible
:width="220"
class="admin-layout__sider"
>
<div class="admin-layout__logo">
<span v-if="!collapsed" class="admin-layout__logo-text">Admin Console</span>
<span v-else class="admin-layout__logo-text">AC</span>
</div>
<a-menu
v-model:selectedKeys="selectedKeys"
mode="inline"
theme="dark"
class="admin-layout__menu"
>
<a-menu-item key="dashboard" @click="router.push('/admin/dashboard')">
<DashboardOutlined /><span>概览</span>
</a-menu-item>
<a-menu-item key="departments" @click="router.push('/admin/departments')">
<ApartmentOutlined /><span>部门管理</span>
</a-menu-item>
<a-menu-item key="users" @click="router.push('/admin/users')">
<TeamOutlined /><span>用户管理</span>
</a-menu-item>
<a-menu-item key="llm" @click="router.push('/admin/llm')">
<CloudOutlined /><span>LLM 配置</span>
</a-menu-item>
<a-menu-item key="skills" @click="router.push('/admin/skills')">
<ThunderboltOutlined /><span>Skill 管理</span>
</a-menu-item>
<a-menu-item key="kb" @click="router.push('/admin/kb')">
<BookOutlined /><span>知识库</span>
</a-menu-item>
<a-menu-item key="usage" @click="router.push('/admin/usage')">
<BarChartOutlined /><span>用量仪表盘</span>
</a-menu-item>
</a-menu>
</a-layout-sider>
<a-layout class="admin-layout__main">
<a-layout-header class="admin-layout__header">
<div class="admin-layout__header-left">
<button class="admin-layout__collapse-btn" @click="collapsed = !collapsed">
<MenuFoldOutlined v-if="!collapsed" />
<MenuUnfoldOutlined v-else />
</button>
<a-button type="link" @click="router.push('/agent/chat')">
<RollbackOutlined />
返回主界面
</a-button>
</div>
<div class="admin-layout__header-right">
<span class="admin-layout__username">{{ authStore.user?.username ?? 'admin' }}</span>
</div>
</a-layout-header>
<a-layout-content class="admin-layout__content">
<router-view />
</a-layout-content>
</a-layout>
</a-layout>
</template>
<script setup lang="ts">
/**
* AdminLayout left sidebar navigation + top bar + content area.
*
* Wraps all ``/admin/*`` child routes. The sidebar highlights the
* active section based on the current route's first path segment.
* Full-height layout with a dark sidebar (Ant Design dark menu theme)
* and a light content area that respects the global CSS variables for
* dark-mode support.
*/
import { ref, computed, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import {
DashboardOutlined,
ApartmentOutlined,
TeamOutlined,
CloudOutlined,
ThunderboltOutlined,
BookOutlined,
BarChartOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
RollbackOutlined,
} from '@ant-design/icons-vue'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const collapsed = ref(false)
/** Derive the active menu key from the current route path. */
const activeKey = computed<string>(() => {
const segment = route.path.split('/')[2] ?? 'dashboard'
return segment === '' ? 'dashboard' : segment
})
const selectedKeys = ref<string[]>([activeKey.value])
watch(activeKey, (val) => {
selectedKeys.value = [val]
})
</script>
<style scoped>
.admin-layout {
height: 100vh;
width: 100vw;
overflow: hidden;
}
.admin-layout__sider {
background: var(--bg-secondary);
border-right: 1px solid var(--border-color);
}
.admin-layout__sider :deep(.ant-layout-sider-children) {
display: flex;
flex-direction: column;
}
.admin-layout__logo {
height: var(--topnav-height);
display: flex;
align-items: center;
justify-content: center;
border-bottom: 1px solid var(--border-color-split);
flex-shrink: 0;
}
.admin-layout__logo-text {
font-size: var(--font-md);
font-weight: var(--font-weight-bold);
color: var(--text-primary);
letter-spacing: 0.3px;
}
.admin-layout__menu {
flex: 1;
border-right: none;
}
.admin-layout__main {
background: var(--bg-tertiary);
overflow: hidden;
}
.admin-layout__header {
display: flex;
align-items: center;
justify-content: space-between;
height: var(--topnav-height);
padding: 0 var(--space-5);
background: var(--bg-primary);
border-bottom: 1px solid var(--border-color-split);
flex-shrink: 0;
}
.admin-layout__header-left {
display: flex;
align-items: center;
gap: var(--space-2);
}
.admin-layout__header-right {
display: flex;
align-items: center;
gap: var(--space-3);
}
.admin-layout__username {
font-size: var(--font-sm);
color: var(--text-secondary);
font-weight: var(--font-weight-medium);
}
.admin-layout__collapse-btn {
display: flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border: none;
background: transparent;
color: var(--text-tertiary);
cursor: pointer;
border-radius: var(--radius-md);
transition: all var(--transition-fast);
}
.admin-layout__collapse-btn:hover {
color: var(--text-primary);
background: var(--bg-tertiary);
}
.admin-layout__content {
overflow: auto;
padding: 0;
background: var(--bg-tertiary);
}
</style>

View File

@ -37,8 +37,8 @@
<SettingOutlined /> <SettingOutlined />
</button> </button>
</a-tooltip> </a-tooltip>
<a-tooltip v-if="authStore.isAdmin()" title="用户与会话管理"> <a-tooltip v-if="authStore.isAdmin()" title="管理控制台">
<button class="top-nav__icon-btn" @click="router.push('/admin/users')"> <button class="top-nav__icon-btn" @click="router.push('/admin/dashboard')">
<TeamOutlined /> <TeamOutlined />
</button> </button>
</a-tooltip> </a-tooltip>

View File

@ -157,3 +157,4 @@ async function handleSubmit(): Promise<void> {
.change-password-panel__submit { .change-password-panel__submit {
font-weight: 600; font-weight: 600;
} }
</style>

View File

@ -89,12 +89,59 @@ const routes: RouteRecordRaw[] = [
meta: { title: 'Computer Use' }, meta: { title: 'Computer Use' },
}, },
// Admin: user sessions management (U9) // Admin console (U9) — AdminLayout wraps all /admin/* child routes.
// ``requiresAdmin`` is set on the parent so the guard checks it for
// every child route (Vue Router merges parent meta into matched records).
{ {
path: '/admin/users', path: '/admin',
name: 'admin-users', name: 'admin',
component: () => import('@/views/admin/UsersView.vue'), component: () => import('@/components/layout/AdminLayout.vue'),
meta: { title: '用户与会话管理', requiresAdmin: true }, meta: { title: '管理控制台', requiresAdmin: true },
redirect: '/admin/dashboard',
children: [
{
path: 'dashboard',
name: 'admin-dashboard',
component: () => import('@/views/admin/DashboardView.vue'),
meta: { title: '管理概览', requiresAdmin: true },
},
{
path: 'departments',
name: 'admin-departments',
component: () => import('@/views/admin/DepartmentsView.vue'),
meta: { title: '部门管理', requiresAdmin: true },
},
{
path: 'users',
name: 'admin-users',
component: () => import('@/views/admin/UsersView.vue'),
meta: { title: '用户与会话管理', requiresAdmin: true },
},
{
path: 'llm',
name: 'admin-llm',
component: () => import('@/views/admin/LlmConfigView.vue'),
meta: { title: 'LLM 配置', requiresAdmin: true },
},
{
path: 'skills',
name: 'admin-skills',
component: () => import('@/views/admin/SkillsView.vue'),
meta: { title: 'Skill 管理', requiresAdmin: true },
},
{
path: 'kb',
name: 'admin-kb',
component: () => import('@/views/admin/KbManagementView.vue'),
meta: { title: '知识库管理', requiresAdmin: true },
},
{
path: 'usage',
name: 'admin-usage',
component: () => import('@/views/admin/UsageDashboardView.vue'),
meta: { title: '用量仪表盘', requiresAdmin: true },
},
],
}, },
// Legacy layout (fallback) // Legacy layout (fallback)

View File

@ -0,0 +1,208 @@
<template>
<div class="dashboard-view">
<a-page-header
title="管理概览"
sub-title="系统状态总览与快捷入口"
class="dashboard-view__header"
/>
<a-spin :spinning="loading">
<div class="dashboard-view__body">
<a-row :gutter="16">
<a-col :xs="24" :sm="12" :lg="6">
<a-card class="dashboard-view__stat-card" hoverable @click="router.push('/admin/departments')">
<a-statistic title="部门总数" :value="stats.departments" />
<div class="dashboard-view__stat-hint">点击管理部门</div>
</a-card>
</a-col>
<a-col :xs="24" :sm="12" :lg="6">
<a-card class="dashboard-view__stat-card" hoverable @click="router.push('/admin/users')">
<a-statistic title="用户总数" :value="stats.users" />
<div class="dashboard-view__stat-hint">点击管理用户</div>
</a-card>
</a-col>
<a-col :xs="24" :sm="12" :lg="6">
<a-card class="dashboard-view__stat-card" hoverable @click="router.push('/admin/skills')">
<a-statistic title="Skill 数量" :value="stats.skills" />
<div class="dashboard-view__stat-hint">点击管理 Skill</div>
</a-card>
</a-col>
<a-col :xs="24" :sm="12" :lg="6">
<a-card class="dashboard-view__stat-card" hoverable @click="router.push('/admin/kb')">
<a-statistic title="KB 文档数量" :value="stats.kbDocuments" />
<div class="dashboard-view__stat-hint">点击管理知识库</div>
</a-card>
</a-col>
</a-row>
<a-card title="快捷入口" class="dashboard-view__quick-links">
<a-space wrap :size="12">
<a-button @click="router.push('/admin/departments')">
<ApartmentOutlined /> 部门管理
</a-button>
<a-button @click="router.push('/admin/users')">
<TeamOutlined /> 用户管理
</a-button>
<a-button @click="router.push('/admin/llm')">
<CloudOutlined /> LLM 配置
</a-button>
<a-button @click="router.push('/admin/skills')">
<ThunderboltOutlined /> Skill 管理
</a-button>
<a-button @click="router.push('/admin/kb')">
<BookOutlined /> 知识库
</a-button>
<a-button @click="router.push('/admin/usage')">
<BarChartOutlined /> 用量仪表盘
</a-button>
</a-space>
</a-card>
<a-card title="LLM 提供商" class="dashboard-view__providers">
<a-empty v-if="providers.length === 0" description="暂无 LLM 提供商" />
<a-list v-else size="small" :data-source="providers" :bordered="false">
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta :title="item.name" :description="`${item.type} · ${formatModels(item)}`" />
</a-list-item>
</template>
</a-list>
</a-card>
</div>
</a-spin>
</div>
</template>
<script setup lang="ts">
/**
* Admin dashboard overview cards + quick links.
*
* Loads counts for departments, users, skills, KB documents, and the
* list of LLM providers in parallel on mount. Each stat card links to
* the corresponding management page.
*/
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import {
ApartmentOutlined,
TeamOutlined,
CloudOutlined,
ThunderboltOutlined,
BookOutlined,
BarChartOutlined,
} from '@ant-design/icons-vue'
import { adminApi, type ILlmProvider } from '@/api/admin'
const router = useRouter()
const loading = ref(false)
const stats = reactive({
departments: 0,
users: 0,
skills: 0,
kbDocuments: 0,
})
const providers = ref<ILlmProvider[]>([])
function formatModels(p: ILlmProvider): string {
const keys = Object.keys(p.models ?? {})
return keys.length ? keys.join(', ') : '—'
}
function extractMessage(err: unknown, fallback: string): string {
if (err && typeof err === 'object') {
const obj = err as { detail?: unknown; message?: unknown }
if (typeof obj.detail === 'string' && obj.detail) return obj.detail
if (typeof obj.message === 'string' && obj.message) return obj.message
}
return fallback
}
async function loadDashboard(): Promise<void> {
loading.value = true
const tasks: Promise<void>[] = [
adminApi
.listDepartments()
.then((d) => {
stats.departments = d.length
})
.catch((err) => {
message.error(extractMessage(err, '加载部门列表失败'))
}),
adminApi
.listUsers()
.then((u) => {
stats.users = u.length
})
.catch((err) => {
message.error(extractMessage(err, '加载用户列表失败'))
}),
adminApi
.listKbDocuments()
.then((docs) => {
stats.kbDocuments = docs.length
})
.catch((err) => {
message.error(extractMessage(err, '加载 KB 文档失败'))
}),
adminApi
.listLlmProviders()
.then((p) => {
providers.value = p
})
.catch((err) => {
message.error(extractMessage(err, '加载 LLM 提供商失败'))
}),
]
// Skills count is best-effort the admin API doesn't expose a list
// endpoint, so we leave it at 0 unless we can derive it elsewhere.
await Promise.all(tasks)
loading.value = false
}
onMounted(() => {
loadDashboard()
})
</script>
<style scoped>
.dashboard-view {
padding: 24px;
max-width: 1200px;
margin: 0 auto;
}
.dashboard-view__header {
margin-bottom: 16px;
}
.dashboard-view__body {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.dashboard-view__stat-card {
cursor: pointer;
transition: all var(--transition-fast);
}
.dashboard-view__stat-card:hover {
border-color: var(--border-color-active);
}
.dashboard-view__stat-hint {
margin-top: var(--space-2);
font-size: var(--font-xs);
color: var(--text-tertiary);
}
.dashboard-view__quick-links {
margin-top: var(--space-4);
}
.dashboard-view__providers {
margin-top: 0;
}
</style>

View File

@ -0,0 +1,382 @@
<template>
<div class="departments-view">
<a-page-header
title="部门管理"
sub-title="创建、编辑部门并管理技能与知识库绑定"
class="departments-view__header"
>
<template #extra>
<a-button type="primary" @click="openCreate">
<PlusOutlined /> 新建部门
</a-button>
</template>
</a-page-header>
<a-card class="departments-view__card">
<a-table
:columns="columns"
:data-source="departments"
:row-key="(record: IDepartment) => record.id"
:loading="loading"
:pagination="false"
size="middle"
>
<template #bodyCell="{ column, record }: { column: TableColumnType<IDepartment>; record: IDepartment }">
<template v-if="column.key === 'name'">
<strong>{{ record.name }}</strong>
</template>
<template v-else-if="column.key === 'is_active'">
<a-tag :color="record.is_active ? 'green' : 'default'">
{{ record.is_active ? '启用' : '停用' }}
</a-tag>
</template>
<template v-else-if="column.key === 'created_at'">
{{ formatTime(record.created_at) }}
</template>
<template v-else-if="column.key === 'actions'">
<a-space :size="4">
<a-button type="link" size="small" @click="openEdit(record)">编辑</a-button>
<a-button type="link" size="small" @click="toggleActive(record)">
{{ record.is_active ? '停用' : '启用' }}
</a-button>
<a-button type="link" size="small" @click="openBindings(record)">绑定</a-button>
<a-popconfirm
title="确认删除该部门?删除后不可恢复。"
ok-text="删除"
cancel-text="取消"
@confirm="handleDelete(record)"
>
<a-button type="link" danger size="small">删除</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</a-card>
<!-- Create / Edit modal -->
<a-modal
v-model:open="formOpen"
:title="editingId ? '编辑部门' : '新建部门'"
:confirm-loading="submitting"
ok-text="保存"
cancel-text="取消"
@ok="handleSubmit"
>
<a-form layout="vertical">
<a-form-item label="部门名称" required>
<a-input v-model:value="form.name" placeholder="输入部门名称" :max-length="64" />
</a-form-item>
<a-form-item label="描述">
<a-textarea
v-model:value="form.description"
placeholder="部门描述(可选)"
:rows="3"
:max-length="500"
/>
</a-form-item>
</a-form>
</a-modal>
<!-- Bindings drawer -->
<a-drawer
v-model:open="bindingsOpen"
:title="`绑定管理 — ${bindingsDept?.name ?? ''}`"
width="520"
placement="right"
>
<a-spin :spinning="bindingsLoading">
<div class="departments-view__bindings">
<a-card title="技能绑定" size="small" class="departments-view__binding-card">
<template #extra>
<a-input-search
v-model:value="newSkillName"
placeholder="输入 skill 名称"
enter-button="添加"
size="small"
style="width: 220px"
@search="handleBindSkill"
/>
</template>
<a-empty v-if="deptSkills.length === 0" description="暂无绑定技能" :image="simpleImage" />
<a-list v-else size="small" :data-source="deptSkills" :bordered="false">
<template #renderItem="{ item }">
<a-list-item>
<a-space :size="8" style="width: 100%" justify="space-between">
<span><ThunderboltOutlined /> {{ item }}</span>
<a-button type="link" danger size="small" @click="handleUnbindSkill(item)">移除</a-button>
</a-space>
</a-list-item>
</template>
</a-list>
</a-card>
<a-card title="知识库绑定" size="small" class="departments-view__binding-card">
<template #extra>
<a-input-search
v-model:value="newKbSourceId"
placeholder="输入 KB source ID"
enter-button="添加"
size="small"
style="width: 220px"
@search="handleBindKb"
/>
</template>
<a-empty v-if="deptKbs.length === 0" description="暂无绑定知识库" :image="simpleImage" />
<a-list v-else size="small" :data-source="deptKbs" :bordered="false">
<template #renderItem="{ item }">
<a-list-item>
<a-space :size="8" style="width: 100%" justify="space-between">
<span><BookOutlined /> {{ item }}</span>
<a-button type="link" danger size="small" @click="handleUnbindKb(item)">移除</a-button>
</a-space>
</a-list-item>
</template>
</a-list>
</a-card>
</div>
</a-spin>
</a-drawer>
</div>
</template>
<script setup lang="ts">
/**
* Department management view.
*
* - Table listing departments with create/edit/disable/enable/delete.
* - Drawer for managing skill and KB source bindings per department.
*/
import { ref, reactive, onMounted } from 'vue'
import { message, Empty } from 'ant-design-vue'
import type { TableColumnType } from 'ant-design-vue'
import {
PlusOutlined,
ThunderboltOutlined,
BookOutlined,
} from '@ant-design/icons-vue'
import { adminApi, type IDepartment } from '@/api/admin'
const simpleImage = Empty.PRESENTED_IMAGE_SIMPLE
const departments = ref<IDepartment[]>([])
const loading = ref(false)
const columns: TableColumnType<IDepartment>[] = [
{ title: '名称', key: 'name', dataIndex: 'name', width: 200 },
{ title: '描述', key: 'description', dataIndex: 'description', ellipsis: true },
{ title: '状态', key: 'is_active', width: 100 },
{ title: '创建时间', key: 'created_at', width: 180 },
{ title: '操作', key: 'actions', width: 280 },
]
// --- Create / Edit ---
const formOpen = ref(false)
const editingId = ref<string | null>(null)
const submitting = ref(false)
const form = reactive({ name: '', description: '' })
function openCreate(): void {
editingId.value = null
form.name = ''
form.description = ''
formOpen.value = true
}
function openEdit(record: IDepartment): void {
editingId.value = record.id
form.name = record.name
form.description = record.description
formOpen.value = true
}
async function handleSubmit(): Promise<void> {
if (!form.name.trim()) {
message.warning('请输入部门名称')
return
}
submitting.value = true
try {
if (editingId.value) {
await adminApi.updateDepartment(editingId.value, {
name: form.name.trim(),
description: form.description,
})
message.success('部门已更新')
} else {
await adminApi.createDepartment(form.name.trim(), form.description)
message.success('部门已创建')
}
formOpen.value = false
await loadDepartments()
} catch (err) {
message.error(extractMessage(err, '保存部门失败'))
} finally {
submitting.value = false
}
}
async function toggleActive(record: IDepartment): Promise<void> {
try {
if (record.is_active) {
await adminApi.disableDepartment(record.id)
message.success('部门已停用')
} else {
await adminApi.enableDepartment(record.id)
message.success('部门已启用')
}
await loadDepartments()
} catch (err) {
message.error(extractMessage(err, '操作失败'))
}
}
async function handleDelete(record: IDepartment): Promise<void> {
try {
await adminApi.deleteDepartment(record.id)
message.success('部门已删除')
await loadDepartments()
} catch (err) {
message.error(extractMessage(err, '删除部门失败'))
}
}
// --- Bindings ---
const bindingsOpen = ref(false)
const bindingsLoading = ref(false)
const bindingsDept = ref<IDepartment | null>(null)
const deptSkills = ref<string[]>([])
const deptKbs = ref<string[]>([])
const newSkillName = ref('')
const newKbSourceId = ref('')
async function openBindings(record: IDepartment): Promise<void> {
bindingsDept.value = record
bindingsOpen.value = true
newSkillName.value = ''
newKbSourceId.value = ''
await loadBindings(record.id)
}
async function loadBindings(deptId: string): Promise<void> {
bindingsLoading.value = true
try {
const [skills, kbs] = await Promise.all([
adminApi.listDepartmentSkills(deptId),
adminApi.listDepartmentKb(deptId),
])
deptSkills.value = skills
deptKbs.value = kbs
} catch (err) {
message.error(extractMessage(err, '加载绑定信息失败'))
} finally {
bindingsLoading.value = false
}
}
async function handleBindSkill(): Promise<void> {
if (!bindingsDept.value || !newSkillName.value.trim()) return
try {
await adminApi.bindSkill(bindingsDept.value.id, newSkillName.value.trim())
message.success('技能已绑定')
newSkillName.value = ''
await loadBindings(bindingsDept.value.id)
} catch (err) {
message.error(extractMessage(err, '绑定技能失败'))
}
}
async function handleUnbindSkill(skillName: string): Promise<void> {
if (!bindingsDept.value) return
try {
await adminApi.unbindSkill(bindingsDept.value.id, skillName)
message.success('技能已解绑')
await loadBindings(bindingsDept.value.id)
} catch (err) {
message.error(extractMessage(err, '解绑技能失败'))
}
}
async function handleBindKb(): Promise<void> {
if (!bindingsDept.value || !newKbSourceId.value.trim()) return
try {
await adminApi.bindKb(bindingsDept.value.id, newKbSourceId.value.trim())
message.success('知识库已绑定')
newKbSourceId.value = ''
await loadBindings(bindingsDept.value.id)
} catch (err) {
message.error(extractMessage(err, '绑定知识库失败'))
}
}
async function handleUnbindKb(sourceId: string): Promise<void> {
if (!bindingsDept.value) return
try {
await adminApi.unbindKb(bindingsDept.value.id, sourceId)
message.success('知识库已解绑')
await loadBindings(bindingsDept.value.id)
} catch (err) {
message.error(extractMessage(err, '解绑知识库失败'))
}
}
// --- Helpers ---
function formatTime(iso: string): string {
if (!iso) return '—'
try {
return new Date(iso).toLocaleString('zh-CN', { hour12: false })
} catch {
return iso
}
}
function extractMessage(err: unknown, fallback: string): string {
if (err && typeof err === 'object') {
const obj = err as { detail?: unknown; message?: unknown }
if (typeof obj.detail === 'string' && obj.detail) return obj.detail
if (typeof obj.message === 'string' && obj.message) return obj.message
}
return fallback
}
async function loadDepartments(): Promise<void> {
loading.value = true
try {
departments.value = await adminApi.listDepartments()
} catch (err) {
message.error(extractMessage(err, '加载部门列表失败'))
} finally {
loading.value = false
}
}
onMounted(() => {
loadDepartments()
})
</script>
<style scoped>
.departments-view {
padding: 24px;
max-width: 1200px;
margin: 0 auto;
}
.departments-view__header {
margin-bottom: 16px;
}
.departments-view__card {
margin-top: 8px;
}
.departments-view__bindings {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.departments-view__binding-card {
width: 100%;
}
</style>

View File

@ -0,0 +1,369 @@
<template>
<div class="kb-mgmt-view">
<a-page-header
title="知识库管理"
sub-title="管理知识源、文档上传与索引同步"
class="kb-mgmt-view__header"
>
<template #extra>
<a-space :size="8">
<a-button @click="loadAll" :loading="loadingSources || loadingDocs">刷新</a-button>
<a-button type="primary" @click="openUpload">上传文档</a-button>
</a-space>
</template>
</a-page-header>
<a-tabs v-model:activeKey="activeTab">
<!-- Sources -->
<a-tab-pane key="sources" tab="知识源">
<a-card>
<a-table
:columns="sourceColumns"
:data-source="sources"
:row-key="(record: IKbSource) => record.id"
:loading="loadingSources"
:pagination="false"
size="middle"
>
<template #bodyCell="{ column, record }: { column: TableColumnType<IKbSource>; record: IKbSource }">
<template v-if="column.key === 'status'">
<a-tag :color="sourceStatusColor(record.status)">{{ record.status }}</a-tag>
</template>
<template v-else-if="column.key === 'last_synced'">
{{ record.last_synced ? formatTime(record.last_synced) : '—' }}
</template>
<template v-else-if="column.key === 'actions'">
<a-space :size="4">
<a-button type="link" size="small" @click="handleSync(record)">同步</a-button>
<a-button type="link" size="small" @click="handleRebuild(record)">重建索引</a-button>
</a-space>
</template>
</template>
</a-table>
</a-card>
</a-tab-pane>
<!-- Documents -->
<a-tab-pane key="documents" tab="文档">
<a-card>
<a-space direction="vertical" :size="16" style="width: 100%">
<a-form layout="inline">
<a-form-item label="知识源筛选">
<a-select
v-model:value="docSourceFilter"
placeholder="全部知识源"
allow-clear
style="width: 240px"
:options="sourceFilterOptions"
@change="loadDocuments"
/>
</a-form-item>
<a-form-item label="部门筛选">
<a-select
v-model:value="docDeptFilter"
placeholder="全部部门"
allow-clear
style="width: 200px"
:options="deptFilterOptions"
@change="loadDocuments"
/>
</a-form-item>
</a-form>
<a-table
:columns="docColumns"
:data-source="documents"
:row-key="(record: IKbDocument) => record.document_id"
:loading="loadingDocs"
:pagination="{ pageSize: 20, showSizeChanger: true }"
size="middle"
>
<template #bodyCell="{ column, record }: { column: TableColumnType<IKbDocument>; record: IKbDocument }">
<template v-if="column.key === 'status'">
<a-tag :color="docStatusColor(record.status)">{{ record.status }}</a-tag>
</template>
<template v-else-if="column.key === 'created_at'">
{{ formatTime(record.created_at) }}
</template>
<template v-else-if="column.key === 'actions'">
<a-popconfirm
title="确认删除该文档?"
ok-text="删除"
cancel-text="取消"
@confirm="handleDeleteDoc(record)"
>
<a-button type="link" danger size="small">删除</a-button>
</a-popconfirm>
</template>
</template>
</a-table>
</a-space>
</a-card>
</a-tab-pane>
</a-tabs>
<!-- Upload document modal -->
<a-modal
v-model:open="uploadOpen"
title="上传文档"
:confirm-loading="uploading"
ok-text="上传"
cancel-text="取消"
@ok="handleUpload"
>
<a-form layout="vertical">
<a-form-item label="文件名" required>
<a-input v-model:value="uploadForm.filename" placeholder="如: guide.md" />
</a-form-item>
<a-form-item label="知识源 ID">
<a-input v-model:value="uploadForm.source_id" placeholder="留空使用默认知识源" />
</a-form-item>
<a-form-item label="部门">
<a-select
v-model:value="uploadForm.department_id"
placeholder="不绑定部门(全局)"
allow-clear
:options="deptFilterOptions"
/>
</a-form-item>
<a-form-item label="文档内容" required>
<a-textarea
v-model:value="uploadForm.content"
:rows="10"
placeholder="粘贴文档文本内容"
/>
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup lang="ts">
/**
* KB management view sources list + documents list + upload/delete/sync/rebuild.
*
* Uses the existing ``kbApi`` for listing sources and the admin API for
* document operations and source sync/rebuild.
*/
import { ref, reactive, computed, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import type { TableColumnType } from 'ant-design-vue'
import { kbApi, type IKbSource } from '@/api/kb'
import { adminApi, type IKbDocument, type IDepartment } from '@/api/admin'
interface ISelectOption {
label: string
value: string
}
const activeTab = ref<'sources' | 'documents'>('sources')
// --- Sources ---
const sources = ref<IKbSource[]>([])
const loadingSources = ref(false)
const sourceColumns: TableColumnType<IKbSource>[] = [
{ title: '名称', key: 'name', dataIndex: 'name', width: 200 },
{ title: '类型', key: 'type', dataIndex: 'type', width: 120 },
{ title: '文档数', key: 'document_count', dataIndex: 'document_count', width: 100 },
{ title: '状态', key: 'status', width: 120 },
{ title: '最近同步', key: 'last_synced', width: 180 },
{ title: '操作', key: 'actions', width: 180 },
]
function sourceStatusColor(status: string): string {
switch (status) {
case 'active':
case 'healthy':
return 'green'
case 'syncing':
return 'blue'
case 'error':
return 'red'
default:
return 'default'
}
}
async function loadSources(): Promise<void> {
loadingSources.value = true
try {
const res = await kbApi.listSources()
sources.value = res.sources
} catch (err) {
message.error(extractMessage(err, '加载知识源列表失败'))
} finally {
loadingSources.value = false
}
}
async function handleSync(record: IKbSource): Promise<void> {
try {
await adminApi.syncKbSource(record.id)
message.success(`知识源 ${record.name} 同步已触发`)
await loadSources()
} catch (err) {
message.error(extractMessage(err, '触发同步失败'))
}
}
async function handleRebuild(record: IKbSource): Promise<void> {
try {
await adminApi.rebuildKbSource(record.id)
message.success(`知识源 ${record.name} 索引重建已触发`)
await loadSources()
} catch (err) {
message.error(extractMessage(err, '触发重建失败'))
}
}
// --- Documents ---
const documents = ref<IKbDocument[]>([])
const loadingDocs = ref(false)
const docSourceFilter = ref<string | undefined>(undefined)
const docDeptFilter = ref<string | undefined>(undefined)
const departments = ref<IDepartment[]>([])
const docColumns: TableColumnType<IKbDocument>[] = [
{ title: '文件名', key: 'filename', dataIndex: 'filename', width: 240, ellipsis: true },
{ title: '知识源', key: 'source_id', dataIndex: 'source_id', width: 160, ellipsis: true },
{ title: '分块数', key: 'chunks', dataIndex: 'chunks', width: 90 },
{ title: '状态', key: 'status', width: 110 },
{ title: '部门', key: 'department_id', dataIndex: 'department_id', width: 140, ellipsis: true },
{ title: '创建时间', key: 'created_at', width: 170 },
{ title: '操作', key: 'actions', width: 100, fixed: 'right' },
]
function docStatusColor(status: string): string {
switch (status) {
case 'ready':
case 'indexed':
return 'green'
case 'processing':
case 'pending':
return 'blue'
case 'error':
case 'failed':
return 'red'
default:
return 'default'
}
}
const sourceFilterOptions = computed<ISelectOption[]>(() =>
sources.value.map((s) => ({ label: s.name, value: s.id })),
)
const deptFilterOptions = computed<ISelectOption[]>(() =>
departments.value.map((d) => ({ label: d.name, value: d.id })),
)
async function loadDocuments(): Promise<void> {
loadingDocs.value = true
try {
documents.value = await adminApi.listKbDocuments(docSourceFilter.value, docDeptFilter.value)
} catch (err) {
message.error(extractMessage(err, '加载文档列表失败'))
} finally {
loadingDocs.value = false
}
}
async function loadDepartments(): Promise<void> {
try {
departments.value = await adminApi.listDepartments()
} catch (err) {
message.error(extractMessage(err, '加载部门列表失败'))
}
}
async function handleDeleteDoc(record: IKbDocument): Promise<void> {
try {
await adminApi.deleteKbDocument(record.document_id)
message.success('文档已删除')
await loadDocuments()
} catch (err) {
message.error(extractMessage(err, '删除文档失败'))
}
}
// --- Upload ---
const uploadOpen = ref(false)
const uploading = ref(false)
const uploadForm = reactive({
filename: '',
content: '',
source_id: '',
department_id: undefined as string | undefined,
})
function openUpload(): void {
uploadForm.filename = ''
uploadForm.content = ''
uploadForm.source_id = ''
uploadForm.department_id = undefined
uploadOpen.value = true
}
async function handleUpload(): Promise<void> {
if (!uploadForm.filename.trim() || !uploadForm.content.trim()) {
message.warning('请填写文件名和文档内容')
return
}
uploading.value = true
try {
await adminApi.uploadKbDocument({
filename: uploadForm.filename.trim(),
content: uploadForm.content,
source_id: uploadForm.source_id || '',
department_id: uploadForm.department_id,
})
message.success('文档已上传')
uploadOpen.value = false
await loadDocuments()
} catch (err) {
message.error(extractMessage(err, '上传文档失败'))
} finally {
uploading.value = false
}
}
// --- Helpers ---
function formatTime(iso: string): string {
if (!iso) return '—'
try {
return new Date(iso).toLocaleString('zh-CN', { hour12: false })
} catch {
return iso
}
}
function extractMessage(err: unknown, fallback: string): string {
if (err && typeof err === 'object') {
const obj = err as { detail?: unknown; message?: unknown }
if (typeof obj.detail === 'string' && obj.detail) return obj.detail
if (typeof obj.message === 'string' && obj.message) return obj.message
}
return fallback
}
async function loadAll(): Promise<void> {
await Promise.all([loadSources(), loadDocuments(), loadDepartments()])
}
onMounted(() => {
loadAll()
})
</script>
<style scoped>
.kb-mgmt-view {
padding: 24px;
max-width: 1200px;
margin: 0 auto;
}
.kb-mgmt-view__header {
margin-bottom: 16px;
}
</style>

View File

@ -0,0 +1,641 @@
<template>
<div class="llm-config-view">
<a-page-header
title="LLM 配置"
sub-title="管理 LLM 提供商、回退链与部门配额"
class="llm-config-view__header"
>
<template #extra>
<a-button type="primary" @click="openCreateProvider">
<PlusOutlined /> 新建提供商
</a-button>
</template>
</a-page-header>
<a-tabs v-model:activeKey="activeTab">
<!-- Providers -->
<a-tab-pane key="providers" tab="提供商">
<a-card>
<a-table
:columns="providerColumns"
:data-source="providers"
:row-key="(record: ILlmProvider) => record.name"
:loading="loadingProviders"
:pagination="false"
size="middle"
>
<template #bodyCell="{ column, record }: { column: TableColumnType<ILlmProvider>; record: ILlmProvider }">
<template v-if="column.key === 'models'">
<a-space :size="4" wrap>
<a-tag v-for="m in Object.keys(record.models ?? {})" :key="m" color="blue">{{ m }}</a-tag>
<span v-if="Object.keys(record.models ?? {}).length === 0"></span>
</a-space>
</template>
<template v-else-if="column.key === 'api_key'">
<code>{{ record.api_key || '—' }}</code>
</template>
<template v-else-if="column.key === 'actions'">
<a-space :size="4">
<a-button type="link" size="small" @click="openSetApiKey(record)">设置密钥</a-button>
<a-button type="link" size="small" @click="openEditProvider(record)">编辑</a-button>
<a-popconfirm
title="确认删除该提供商?"
ok-text="删除"
cancel-text="取消"
@confirm="handleDeleteProvider(record)"
>
<a-button type="link" danger size="small">删除</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</a-card>
</a-tab-pane>
<!-- Fallbacks -->
<a-tab-pane key="fallbacks" tab="回退链">
<a-card>
<a-space direction="vertical" :size="16" style="width: 100%">
<a-space :size="8" align="center">
<a-button @click="loadFallbacks" :loading="loadingFallbacks">刷新</a-button>
<a-button type="primary" @click="openCreateFallback">新建回退链</a-button>
</a-space>
<a-table
:columns="fallbackColumns"
:data-source="fallbackRows"
:row-key="(record: IFallbackRow) => record.model"
:loading="loadingFallbacks"
:pagination="false"
size="middle"
>
<template #bodyCell="{ column, record }: { column: TableColumnType<IFallbackRow>; record: IFallbackRow }">
<template v-if="column.key === 'chain'">
<a-space :size="4" wrap>
<a-tag v-for="(m, i) in record.chain" :key="i">{{ m }}</a-tag>
<span v-if="record.chain.length === 0"></span>
</a-space>
</template>
<template v-else-if="column.key === 'actions'">
<a-space :size="4">
<a-button type="link" size="small" @click="openEditFallback(record)">编辑</a-button>
<a-popconfirm
title="确认删除该回退链?"
ok-text="删除"
cancel-text="取消"
@confirm="handleDeleteFallback(record)"
>
<a-button type="link" danger size="small">删除</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</a-space>
</a-card>
</a-tab-pane>
<!-- Quotas -->
<a-tab-pane key="quotas" tab="部门配额">
<a-card>
<a-space direction="vertical" :size="16" style="width: 100%">
<a-form layout="inline">
<a-form-item label="选择部门">
<a-select
v-model:value="quotaDeptId"
placeholder="选择部门"
style="width: 240px"
:options="deptOptions"
@change="loadQuotas"
/>
</a-form-item>
<a-form-item>
<a-button type="primary" :disabled="!quotaDeptId" @click="openCreateQuota">新建配额</a-button>
</a-form-item>
</a-form>
<a-table
:columns="quotaColumns"
:data-source="quotas"
:row-key="(record: IQuota) => record.id"
:loading="loadingQuotas"
:pagination="false"
size="middle"
>
<template #bodyCell="{ column, record }: { column: TableColumnType<IQuota>; record: IQuota }">
<template v-if="column.key === 'limit_value'">
<span v-if="Array.isArray(record.limit_value)">
<a-tag v-for="(m, i) in record.limit_value" :key="i">{{ m }}</a-tag>
</span>
<span v-else>{{ record.limit_value }}</span>
</template>
<template v-else-if="column.key === 'actions'">
<a-popconfirm
title="确认删除该配额?"
ok-text="删除"
cancel-text="取消"
@confirm="handleDeleteQuota(record)"
>
<a-button type="link" danger size="small">删除</a-button>
</a-popconfirm>
</template>
</template>
</a-table>
</a-space>
</a-card>
</a-tab-pane>
</a-tabs>
<!-- Create / Edit provider modal -->
<a-modal
v-model:open="providerFormOpen"
:title="editingProviderName ? '编辑提供商' : '新建提供商'"
:confirm-loading="submittingProvider"
ok-text="保存"
cancel-text="取消"
@ok="handleSubmitProvider"
>
<a-form layout="vertical">
<a-form-item label="名称" required>
<a-input v-model:value="providerForm.name" :disabled="!!editingProviderName" placeholder="提供商名称" />
</a-form-item>
<a-form-item label="类型">
<a-select v-model:value="providerForm.type" :options="providerTypeOptions" />
</a-form-item>
<a-form-item v-if="!editingProviderName" label="API Key" required>
<a-input-password v-model:value="providerForm.api_key" placeholder="输入 API Key" />
</a-form-item>
<a-form-item label="Base URL">
<a-input v-model:value="providerForm.base_url" placeholder="https://api.openai.com/v1可选" />
</a-form-item>
<a-form-item label="Max Tokens">
<a-input-number v-model:value="providerForm.max_tokens" :min="1" :max="1000000" style="width: 100%" />
</a-form-item>
<a-form-item label="Timeout (秒)">
<a-input-number v-model:value="providerForm.timeout" :min="1" :max="600" style="width: 100%" />
</a-form-item>
</a-form>
</a-modal>
<!-- Set API key modal -->
<a-modal
v-model:open="apiKeyFormOpen"
title="设置 API Key"
:confirm-loading="submittingApiKey"
ok-text="保存"
cancel-text="取消"
@ok="handleSubmitApiKey"
>
<a-form layout="vertical">
<a-form-item label="提供商">
<a-input :value="apiKeyTarget" disabled />
</a-form-item>
<a-form-item label="新 API Key" required>
<a-input-password v-model:value="apiKeyForm.api_key" placeholder="输入新的 API Key" />
</a-form-item>
</a-form>
</a-modal>
<!-- Create / Edit fallback modal -->
<a-modal
v-model:open="fallbackFormOpen"
:title="editingFallbackModel ? '编辑回退链' : '新建回退链'"
:confirm-loading="submittingFallback"
ok-text="保存"
cancel-text="取消"
@ok="handleSubmitFallback"
>
<a-form layout="vertical">
<a-form-item label="模型名称" required>
<a-input v-model:value="fallbackForm.model" :disabled="!!editingFallbackModel" placeholder="如 gpt-4" />
</a-form-item>
<a-form-item label="回退链(每行一个)" required>
<a-textarea
v-model:value="fallbackChainText"
:rows="5"
placeholder="provider/model&#10;provider2/model2"
/>
</a-form-item>
</a-form>
</a-modal>
<!-- Create quota modal -->
<a-modal
v-model:open="quotaFormOpen"
title="新建配额"
:confirm-loading="submittingQuota"
ok-text="保存"
cancel-text="取消"
@ok="handleSubmitQuota"
>
<a-form layout="vertical">
<a-form-item label="配额类型" required>
<a-select v-model:value="quotaForm.quota_type" :options="quotaTypeOptions" />
</a-form-item>
<a-form-item label="周期">
<a-select v-model:value="quotaForm.period" :options="quotaPeriodOptions" />
</a-form-item>
<a-form-item v-if="quotaForm.quota_type !== 'model_whitelist'" label="限制值" required>
<a-input-number v-model:value="quotaLimitNumber" :min="0" style="width: 100%" />
</a-form-item>
<a-form-item v-else label="模型白名单(每行一个)" required>
<a-textarea v-model:value="quotaWhitelistText" :rows="4" placeholder="gpt-4&#10;claude-3" />
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup lang="ts">
/**
* LLM configuration view providers, fallback chains, department quotas.
*/
import { ref, reactive, computed, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import type { TableColumnType } from 'ant-design-vue'
import { PlusOutlined } from '@ant-design/icons-vue'
import { adminApi, type ILlmProvider, type IQuota, type IDepartment } from '@/api/admin'
interface ISelectOption {
label: string
value: string
}
interface IFallbackRow {
model: string
chain: string[]
}
const activeTab = ref<'providers' | 'fallbacks' | 'quotas'>('providers')
// --- Providers ---
const providers = ref<ILlmProvider[]>([])
const loadingProviders = ref(false)
const providerColumns: TableColumnType<ILlmProvider>[] = [
{ title: '名称', key: 'name', dataIndex: 'name', width: 160 },
{ title: '类型', key: 'type', dataIndex: 'type', width: 120 },
{ title: '模型', key: 'models', width: 280 },
{ title: 'API Key', key: 'api_key', width: 200, ellipsis: true },
{ title: '操作', key: 'actions', width: 220 },
]
const providerTypeOptions: ISelectOption[] = [
{ label: 'openai', value: 'openai' },
{ label: 'anthropic', value: 'anthropic' },
{ label: 'gemini', value: 'gemini' },
{ label: 'doubao', value: 'doubao' },
{ label: 'wenxin', value: 'wenxin' },
{ label: 'yuanbao', value: 'yuanbao' },
]
async function loadProviders(): Promise<void> {
loadingProviders.value = true
try {
providers.value = await adminApi.listLlmProviders()
} catch (err) {
message.error(extractMessage(err, '加载提供商列表失败'))
} finally {
loadingProviders.value = false
}
}
// Provider form
const providerFormOpen = ref(false)
const editingProviderName = ref<string | null>(null)
const submittingProvider = ref(false)
const providerForm = reactive({
name: '',
type: 'openai',
api_key: '',
base_url: '',
max_tokens: 4096,
timeout: 60,
})
function openCreateProvider(): void {
editingProviderName.value = null
providerForm.name = ''
providerForm.type = 'openai'
providerForm.api_key = ''
providerForm.base_url = ''
providerForm.max_tokens = 4096
providerForm.timeout = 60
providerFormOpen.value = true
}
function openEditProvider(record: ILlmProvider): void {
editingProviderName.value = record.name
providerForm.name = record.name
providerForm.type = record.type
providerForm.api_key = ''
providerForm.base_url = record.base_url
providerForm.max_tokens = record.max_tokens
providerForm.timeout = record.timeout
providerFormOpen.value = true
}
async function handleSubmitProvider(): Promise<void> {
if (!providerForm.name.trim()) {
message.warning('请输入提供商名称')
return
}
submittingProvider.value = true
try {
if (editingProviderName.value) {
const config: Record<string, unknown> = {
type: providerForm.type,
base_url: providerForm.base_url,
max_tokens: providerForm.max_tokens,
timeout: providerForm.timeout,
}
if (providerForm.api_key) config.api_key = providerForm.api_key
await adminApi.updateLlmProvider(editingProviderName.value, config)
message.success('提供商已更新')
} else {
if (!providerForm.api_key) {
message.warning('请输入 API Key')
submittingProvider.value = false
return
}
await adminApi.createLlmProvider(providerForm.name.trim(), {
type: providerForm.type,
api_key: providerForm.api_key,
base_url: providerForm.base_url,
max_tokens: providerForm.max_tokens,
timeout: providerForm.timeout,
})
message.success('提供商已创建')
}
providerFormOpen.value = false
await loadProviders()
} catch (err) {
message.error(extractMessage(err, '保存提供商失败'))
} finally {
submittingProvider.value = false
}
}
async function handleDeleteProvider(record: ILlmProvider): Promise<void> {
try {
await adminApi.deleteLlmProvider(record.name)
message.success('提供商已删除')
await loadProviders()
} catch (err) {
message.error(extractMessage(err, '删除提供商失败'))
}
}
// API key modal
const apiKeyFormOpen = ref(false)
const submittingApiKey = ref(false)
const apiKeyTarget = ref('')
const apiKeyForm = reactive({ api_key: '' })
function openSetApiKey(record: ILlmProvider): void {
apiKeyTarget.value = record.name
apiKeyForm.api_key = ''
apiKeyFormOpen.value = true
}
async function handleSubmitApiKey(): Promise<void> {
if (!apiKeyForm.api_key) {
message.warning('请输入 API Key')
return
}
submittingApiKey.value = true
try {
await adminApi.setLlmApiKey(apiKeyTarget.value, apiKeyForm.api_key)
message.success('API Key 已更新')
apiKeyFormOpen.value = false
await loadProviders()
} catch (err) {
message.error(extractMessage(err, '设置 API Key 失败'))
} finally {
submittingApiKey.value = false
}
}
// --- Fallbacks ---
const fallbacks = ref<Record<string, string[]>>({})
const loadingFallbacks = ref(false)
const fallbackRows = computed<IFallbackRow[]>(() =>
Object.entries(fallbacks.value).map(([model, chain]) => ({ model, chain })),
)
const fallbackColumns: TableColumnType<IFallbackRow>[] = [
{ title: '模型', key: 'model', dataIndex: 'model', width: 200 },
{ title: '回退链', key: 'chain' },
{ title: '操作', key: 'actions', width: 160 },
]
async function loadFallbacks(): Promise<void> {
loadingFallbacks.value = true
try {
fallbacks.value = await adminApi.listLlmFallbacks()
} catch (err) {
message.error(extractMessage(err, '加载回退链失败'))
} finally {
loadingFallbacks.value = false
}
}
const fallbackFormOpen = ref(false)
const editingFallbackModel = ref<string | null>(null)
const submittingFallback = ref(false)
const fallbackForm = reactive({ model: '' })
const fallbackChainText = ref('')
function openCreateFallback(): void {
editingFallbackModel.value = null
fallbackForm.model = ''
fallbackChainText.value = ''
fallbackFormOpen.value = true
}
function openEditFallback(record: IFallbackRow): void {
editingFallbackModel.value = record.model
fallbackForm.model = record.model
fallbackChainText.value = record.chain.join('\n')
fallbackFormOpen.value = true
}
async function handleSubmitFallback(): Promise<void> {
if (!fallbackForm.model.trim()) {
message.warning('请输入模型名称')
return
}
const chain = fallbackChainText.value
.split('\n')
.map((s) => s.trim())
.filter((s) => s.length > 0)
if (chain.length === 0) {
message.warning('请输入至少一个回退项')
return
}
submittingFallback.value = true
try {
await adminApi.setLlmFallback(fallbackForm.model.trim(), chain)
message.success('回退链已保存')
fallbackFormOpen.value = false
await loadFallbacks()
} catch (err) {
message.error(extractMessage(err, '保存回退链失败'))
} finally {
submittingFallback.value = false
}
}
async function handleDeleteFallback(record: IFallbackRow): Promise<void> {
try {
await adminApi.deleteLlmFallback(record.model)
message.success('回退链已删除')
await loadFallbacks()
} catch (err) {
message.error(extractMessage(err, '删除回退链失败'))
}
}
// --- Quotas ---
const departments = ref<IDepartment[]>([])
const quotaDeptId = ref<string | undefined>(undefined)
const quotas = ref<IQuota[]>([])
const loadingQuotas = ref(false)
const deptOptions = computed<ISelectOption[]>(() =>
departments.value.map((d) => ({ label: d.name, value: d.id })),
)
const quotaColumns: TableColumnType<IQuota>[] = [
{ title: '配额类型', key: 'quota_type', dataIndex: 'quota_type', width: 160 },
{ title: '限制值', key: 'limit_value' },
{ title: '周期', key: 'period', dataIndex: 'period', width: 120 },
{ title: '更新时间', key: 'updated_at', dataIndex: 'updated_at', width: 180 },
{ title: '操作', key: 'actions', width: 100 },
]
const quotaTypeOptions: ISelectOption[] = [
{ label: 'token 限额', value: 'token_limit' },
{ label: '费用限额', value: 'cost_limit' },
{ label: '模型白名单', value: 'model_whitelist' },
]
const quotaPeriodOptions: ISelectOption[] = [
{ label: '每日', value: 'daily' },
{ label: '每周', value: 'weekly' },
{ label: '每月', value: 'monthly' },
]
async function loadDepartments(): Promise<void> {
try {
departments.value = await adminApi.listDepartments()
} catch (err) {
message.error(extractMessage(err, '加载部门列表失败'))
}
}
async function loadQuotas(): Promise<void> {
if (!quotaDeptId.value) {
quotas.value = []
return
}
loadingQuotas.value = true
try {
quotas.value = await adminApi.listDepartmentQuotas(quotaDeptId.value)
} catch (err) {
message.error(extractMessage(err, '加载配额列表失败'))
} finally {
loadingQuotas.value = false
}
}
const quotaFormOpen = ref(false)
const submittingQuota = ref(false)
const quotaForm = reactive({
quota_type: 'token_limit',
period: 'daily',
})
const quotaLimitNumber = ref(0)
const quotaWhitelistText = ref('')
function openCreateQuota(): void {
quotaForm.quota_type = 'token_limit'
quotaForm.period = 'daily'
quotaLimitNumber.value = 0
quotaWhitelistText.value = ''
quotaFormOpen.value = true
}
async function handleSubmitQuota(): Promise<void> {
if (!quotaDeptId.value) return
submittingQuota.value = true
try {
let limitValue: number | string[]
if (quotaForm.quota_type === 'model_whitelist') {
limitValue = quotaWhitelistText.value
.split('\n')
.map((s) => s.trim())
.filter((s) => s.length > 0)
if (limitValue.length === 0) {
message.warning('请输入至少一个模型')
submittingQuota.value = false
return
}
} else {
limitValue = quotaLimitNumber.value
}
await adminApi.setDepartmentQuota(quotaDeptId.value, {
quota_type: quotaForm.quota_type,
limit_value: limitValue,
period: quotaForm.period,
})
message.success('配额已保存')
quotaFormOpen.value = false
await loadQuotas()
} catch (err) {
message.error(extractMessage(err, '保存配额失败'))
} finally {
submittingQuota.value = false
}
}
async function handleDeleteQuota(record: IQuota): Promise<void> {
if (!quotaDeptId.value) return
try {
await adminApi.deleteDepartmentQuota(quotaDeptId.value, record.quota_type, record.period)
message.success('配额已删除')
await loadQuotas()
} catch (err) {
message.error(extractMessage(err, '删除配额失败'))
}
}
// --- Helpers ---
function extractMessage(err: unknown, fallback: string): string {
if (err && typeof err === 'object') {
const obj = err as { detail?: unknown; message?: unknown }
if (typeof obj.detail === 'string' && obj.detail) return obj.detail
if (typeof obj.message === 'string' && obj.message) return obj.message
}
return fallback
}
onMounted(() => {
loadProviders()
loadFallbacks()
loadDepartments()
})
</script>
<style scoped>
.llm-config-view {
padding: 24px;
max-width: 1200px;
margin: 0 auto;
}
.llm-config-view__header {
margin-bottom: 16px;
}
</style>

View File

@ -0,0 +1,271 @@
<template>
<div class="skills-view">
<a-page-header
title="Skill 管理"
sub-title="启用/停用、导入、重载与编辑技能配置"
class="skills-view__header"
>
<template #extra>
<a-space :size="8">
<a-button @click="loadSkills" :loading="loading">刷新</a-button>
<a-button type="primary" @click="openImport">导入 Skill</a-button>
</a-space>
</template>
</a-page-header>
<a-card class="skills-view__card">
<a-table
:columns="columns"
:data-source="skills"
:row-key="(record: ISkillInfo) => record.name"
:loading="loading"
:pagination="{ pageSize: 20, showSizeChanger: true }"
size="middle"
>
<template #bodyCell="{ column, record }: { column: TableColumnType<ISkillInfo>; record: ISkillInfo }">
<template v-if="column.key === 'name'">
<strong>{{ record.name }}</strong>
</template>
<template v-else-if="column.key === 'status'">
<a-tag :color="isDisabled(record) ? 'default' : 'green'">
{{ isDisabled(record) ? '已停用' : '启用' }}
</a-tag>
</template>
<template v-else-if="column.key === 'capabilities'">
<a-space :size="4" wrap>
<a-tag v-for="c in record.capabilities ?? []" :key="c" color="blue">{{ c }}</a-tag>
<span v-if="!record.capabilities || record.capabilities.length === 0"></span>
</a-space>
</template>
<template v-else-if="column.key === 'actions'">
<a-space :size="4">
<a-button
type="link"
size="small"
@click="handleToggle(record)"
>
{{ isDisabled(record) ? '启用' : '停用' }}
</a-button>
<a-button type="link" size="small" @click="handleReload(record)">重载</a-button>
<a-button type="link" size="small" @click="openEditConfig(record)">编辑配置</a-button>
</a-space>
</template>
</template>
</a-table>
</a-card>
<!-- Import skill modal -->
<a-modal
v-model:open="importOpen"
title="导入 Skill"
:confirm-loading="importing"
ok-text="导入"
cancel-text="取消"
width="640px"
@ok="handleImport"
>
<a-alert
message="粘贴 Skill 的 YAML 配置内容,导入后将自动注册到技能目录。"
type="info"
show-icon
style="margin-bottom: 16px"
/>
<a-textarea
v-model:value="importYaml"
:rows="14"
placeholder="name: my-skill&#10;version: 1.0.0&#10;description: ...&#10;..."
style="font-family: var(--font-mono, monospace)"
/>
</a-modal>
<!-- Edit config modal -->
<a-modal
v-model:open="configOpen"
:title="`编辑配置 — ${configTarget}`"
:confirm-loading="savingConfig"
ok-text="保存"
cancel-text="取消"
width="640px"
@ok="handleSaveConfig"
>
<a-alert
message="以 JSON 格式编辑技能配置,保存后将自动重载。"
type="info"
show-icon
style="margin-bottom: 16px"
/>
<a-textarea
v-model:value="configText"
:rows="14"
placeholder='{ "key": "value" }'
style="font-family: var(--font-mono, monospace)"
/>
</a-modal>
</div>
</template>
<script setup lang="ts">
/**
* Skill management view list, enable/disable, import, reload, edit config.
*
* Uses the existing ``skillsApi`` for listing (``/api/v1/skill-management/skills``)
* and the admin API for enable/disable/import/reload/update-config.
*/
import { ref, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import type { TableColumnType } from 'ant-design-vue'
import { skillsApi, type ISkillInfo } from '@/api/skills'
import { adminApi } from '@/api/admin'
const skills = ref<ISkillInfo[]>([])
const loading = ref(false)
const columns: TableColumnType<ISkillInfo>[] = [
{ title: '名称', key: 'name', dataIndex: 'name', width: 200 },
{ title: '版本', key: 'version', dataIndex: 'version', width: 100 },
{ title: '描述', key: 'description', dataIndex: 'description', ellipsis: true },
{ title: '能力', key: 'capabilities', width: 240 },
{ title: '状态', key: 'status', width: 100 },
{ title: '操作', key: 'actions', width: 240, fixed: 'right' },
]
/** A skill is considered disabled when its status is 'disabled'. */
function isDisabled(record: ISkillInfo): boolean {
return record.status === 'disabled'
}
async function loadSkills(): Promise<void> {
loading.value = true
try {
const res = await skillsApi.listSkills(undefined, 1, 200)
skills.value = res.skills
} catch (err) {
message.error(extractMessage(err, '加载技能列表失败'))
} finally {
loading.value = false
}
}
async function handleToggle(record: ISkillInfo): Promise<void> {
try {
if (isDisabled(record)) {
await adminApi.enableSkill(record.name)
message.success('技能已启用')
} else {
await adminApi.disableSkill(record.name)
message.success('技能已停用')
}
await loadSkills()
} catch (err) {
message.error(extractMessage(err, '操作失败'))
}
}
async function handleReload(record: ISkillInfo): Promise<void> {
try {
await adminApi.reloadSkill(record.name)
message.success(`技能 ${record.name} 已重载`)
await loadSkills()
} catch (err) {
message.error(extractMessage(err, '重载技能失败'))
}
}
// Import
const importOpen = ref(false)
const importing = ref(false)
const importYaml = ref('')
function openImport(): void {
importYaml.value = ''
importOpen.value = true
}
async function handleImport(): Promise<void> {
if (!importYaml.value.trim()) {
message.warning('请输入 YAML 内容')
return
}
importing.value = true
try {
await adminApi.importSkill(importYaml.value)
message.success('技能已导入')
importOpen.value = false
await loadSkills()
} catch (err) {
message.error(extractMessage(err, '导入技能失败'))
} finally {
importing.value = false
}
}
// Edit config
const configOpen = ref(false)
const savingConfig = ref(false)
const configTarget = ref('')
const configText = ref('')
async function openEditConfig(record: ISkillInfo): Promise<void> {
configTarget.value = record.name
configOpen.value = true
try {
const detail = await skillsApi.getSkillDetail(record.name)
configText.value = JSON.stringify(detail.config ?? {}, null, 2)
} catch (err) {
// If detail fetch fails, start with empty JSON object
configText.value = '{}'
message.warning(extractMessage(err, '加载技能配置失败,将以空配置开始'))
}
}
async function handleSaveConfig(): Promise<void> {
let parsed: Record<string, unknown>
try {
parsed = JSON.parse(configText.value) as Record<string, unknown>
} catch {
message.error('JSON 格式错误,请检查')
return
}
savingConfig.value = true
try {
await adminApi.updateSkillConfig(configTarget.value, parsed)
message.success('配置已保存并重载')
configOpen.value = false
await loadSkills()
} catch (err) {
message.error(extractMessage(err, '保存配置失败'))
} finally {
savingConfig.value = false
}
}
// --- Helpers ---
function extractMessage(err: unknown, fallback: string): string {
if (err && typeof err === 'object') {
const obj = err as { detail?: unknown; message?: unknown }
if (typeof obj.detail === 'string' && obj.detail) return obj.detail
if (typeof obj.message === 'string' && obj.message) return obj.message
}
return fallback
}
onMounted(() => {
loadSkills()
})
</script>
<style scoped>
.skills-view {
padding: 24px;
max-width: 1200px;
margin: 0 auto;
}
.skills-view__header {
margin-bottom: 16px;
}
.skills-view__card {
margin-top: 8px;
}
</style>

View File

@ -0,0 +1,413 @@
<template>
<div class="usage-view">
<a-page-header
title="用量仪表盘"
sub-title="查看 Token、成本与请求用量统计"
class="usage-view__header"
>
<template #extra>
<a-space :size="8">
<a-button @click="loadAll" :loading="loading">刷新</a-button>
<a-dropdown @click.prevent>
<a-button type="primary">
<DownloadOutlined />
导出报表
<DownOutlined />
</a-button>
<template #overlay>
<a-menu @click="handleExport">
<a-menu-item key="csv">导出 CSV</a-menu-item>
<a-menu-item key="json">导出 JSON</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</a-space>
</template>
</a-page-header>
<a-card class="usage-view__filters" :bordered="false">
<a-form layout="inline">
<a-form-item label="时间范围">
<a-range-picker
v-model:value="dateRange"
:allow-clear="true"
:presets="rangePresets"
format="YYYY-MM-DD"
@change="loadAll"
/>
</a-form-item>
<a-form-item label="部门">
<a-select
v-model:value="departmentId"
placeholder="全部部门"
allow-clear
style="width: 220px"
:options="deptOptions"
@change="loadAll"
/>
</a-form-item>
</a-form>
</a-card>
<a-spin :spinning="loading">
<a-row :gutter="16" class="usage-view__stats">
<a-col :xs="24" :sm="8">
<a-card>
<a-statistic
title="总 Token 用量"
:value="summary?.total_tokens ?? 0"
:value-style="{ color: '#1677ff' }"
/>
<div class="usage-view__stat-hint">累计输入 + 输出 Token</div>
</a-card>
</a-col>
<a-col :xs="24" :sm="8">
<a-card>
<a-statistic
title="总成本 (USD)"
:value="summary?.total_cost ?? 0"
:precision="4"
:value-style="{ color: '#52c41a' }"
/>
<div class="usage-view__stat-hint">按模型计费汇总</div>
</a-card>
</a-col>
<a-col :xs="24" :sm="8">
<a-card>
<a-statistic
title="总请求数"
:value="summary?.total_requests ?? 0"
:value-style="{ color: '#722ed1' }"
/>
<div class="usage-view__stat-hint">LLM 调用次数</div>
</a-card>
</a-col>
</a-row>
<a-row :gutter="16" class="usage-view__tables">
<a-col :xs="24" :lg="12">
<a-card title="按模型分布" class="usage-view__table-card">
<a-empty
v-if="byModel.length === 0"
:image="Empty.PRESENTED_IMAGE_SIMPLE"
description="暂无数据"
/>
<a-table
v-else
:columns="modelColumns"
:data-source="byModel"
:row-key="(record: IUsageByModel) => record.model"
:pagination="false"
size="middle"
>
<template #bodyCell="{ column, record }: { column: TableColumnType<IUsageByModel>; record: IUsageByModel }">
<template v-if="column.key === 'cost'">
{{ formatCost(record.cost) }}
</template>
<template v-else-if="column.key === 'tokens'">
{{ formatNumber(record.tokens) }}
</template>
</template>
</a-table>
</a-card>
</a-col>
<a-col :xs="24" :lg="12">
<a-card title="Top 用户" class="usage-view__table-card">
<a-empty
v-if="topUsers.length === 0"
:image="Empty.PRESENTED_IMAGE_SIMPLE"
description="暂无数据"
/>
<a-table
v-else
:columns="userColumns"
:data-source="topUsers"
:row-key="(record: IUsageTopUser) => record.user_id"
:pagination="{ pageSize: 10, showSizeChanger: false }"
size="middle"
>
<template #bodyCell="{ column, record }: { column: TableColumnType<IUsageTopUser>; record: IUsageTopUser }">
<template v-if="column.key === 'cost'">
{{ formatCost(record.cost) }}
</template>
<template v-else-if="column.key === 'tokens'">
{{ formatNumber(record.tokens) }}
</template>
</template>
</a-table>
</a-card>
</a-col>
</a-row>
<a-card title="按部门分布" class="usage-view__dept-card">
<a-empty
v-if="deptRows.length === 0"
:image="Empty.PRESENTED_IMAGE_SIMPLE"
description="暂无数据"
/>
<a-table
v-else
:columns="deptColumns"
:data-source="deptRows"
:row-key="(record: IDeptUsageRow) => record.department_id"
:pagination="false"
size="middle"
>
<template #bodyCell="{ column, record }: { column: TableColumnType<IDeptUsageRow>; record: IDeptUsageRow }">
<template v-if="column.key === 'cost'">
{{ formatCost(record.cost) }}
</template>
<template v-else-if="column.key === 'tokens'">
{{ formatNumber(record.tokens) }}
</template>
</template>
</a-table>
</a-card>
</a-spin>
</div>
</template>
<script setup lang="ts">
/**
* Usage dashboard summary cards + by-model / top-users / by-department
* breakdown tables.
*
* Uses simple tables/cards instead of chart libraries (per U9 spec).
* The export button calls ``adminApi.exportUsage()`` which returns the
* raw CSV/JSON text from the server; we wrap it in a Blob and trigger
* a browser download.
*/
import { ref, computed, onMounted } from 'vue'
import dayjs, { type Dayjs } from 'dayjs'
import { message, Empty } from 'ant-design-vue'
import type { TableColumnType } from 'ant-design-vue'
import { DownloadOutlined, DownOutlined } from '@ant-design/icons-vue'
import {
adminApi,
type IDepartment,
type IUsageSummary,
type IUsageByModel,
type IUsageTopUser,
} from '@/api/admin'
interface ISelectOption {
label: string
value: string
}
/** Per-bucket usage metric (matches ``IUsageSummary.by_*`` value shape). */
interface IUsageMetric {
tokens: number
cost: number
requests: number
}
/** Flattened per-department usage row (derived from ``IUsageSummary.by_department``). */
interface IDeptUsageRow {
department_id: string
tokens: number
cost: number
requests: number
}
interface IMenuClickInfo {
key: string
keyPath: string[]
item: unknown
domEvent: MouseEvent
}
interface IRangePresetItem {
label: string
value: [Dayjs, Dayjs]
}
const loading = ref(false)
const dateRange = ref<[Dayjs, Dayjs] | undefined>(undefined)
const departmentId = ref<string | undefined>(undefined)
const summary = ref<IUsageSummary | null>(null)
const byModel = ref<IUsageByModel[]>([])
const topUsers = ref<IUsageTopUser[]>([])
const departments = ref<IDepartment[]>([])
const rangePresets: IRangePresetItem[] = [
{ label: '今天', value: [dayjs().startOf('day'), dayjs().endOf('day')] },
{ label: '最近 7 天', value: [dayjs().subtract(6, 'day').startOf('day'), dayjs().endOf('day')] },
{ label: '最近 30 天', value: [dayjs().subtract(29, 'day').startOf('day'), dayjs().endOf('day')] },
{ label: '本月', value: [dayjs().startOf('month'), dayjs().endOf('month')] },
]
const deptOptions = computed<ISelectOption[]>(() =>
departments.value.map((d) => ({ label: d.name, value: d.id })),
)
const deptRows = computed<IDeptUsageRow[]>(() => {
const map: Record<string, IUsageMetric> = summary.value?.by_department ?? {}
return Object.entries(map).map(([departmentId, v]) => ({
department_id: departmentId,
tokens: v.tokens,
cost: v.cost,
requests: v.requests,
}))
})
const modelColumns: TableColumnType<IUsageByModel>[] = [
{ title: '模型', key: 'model', dataIndex: 'model', width: 200, ellipsis: true },
{ title: 'Token', key: 'tokens', width: 140 },
{ title: '成本 (USD)', key: 'cost', width: 130 },
{ title: '请求数', key: 'requests', dataIndex: 'requests', width: 100 },
]
const userColumns: TableColumnType<IUsageTopUser>[] = [
{ title: '用户 ID', key: 'user_id', dataIndex: 'user_id', width: 200, ellipsis: true },
{ title: 'Token', key: 'tokens', width: 140 },
{ title: '成本 (USD)', key: 'cost', width: 130 },
{ title: '请求数', key: 'requests', dataIndex: 'requests', width: 100 },
]
const deptColumns: TableColumnType<IDeptUsageRow>[] = [
{ title: '部门 ID', key: 'department_id', dataIndex: 'department_id', width: 220, ellipsis: true },
{ title: 'Token', key: 'tokens', width: 160 },
{ title: '成本 (USD)', key: 'cost', width: 150 },
{ title: '请求数', key: 'requests', dataIndex: 'requests', width: 120 },
]
function buildParams(): { start?: string; end?: string; department_id?: string } {
const params: { start?: string; end?: string; department_id?: string } = {}
if (dateRange.value && dateRange.value.length === 2) {
params.start = dateRange.value[0].startOf('day').toISOString()
params.end = dateRange.value[1].endOf('day').toISOString()
}
if (departmentId.value) {
params.department_id = departmentId.value
}
return params
}
function formatNumber(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`
if (n >= 1_000) return `${(n / 1_000).toFixed(2)}K`
return String(n)
}
function formatCost(n: number): string {
return `$${n.toFixed(4)}`
}
function extractMessage(err: unknown, fallback: string): string {
if (err && typeof err === 'object') {
const obj = err as { detail?: unknown; message?: unknown }
if (typeof obj.detail === 'string' && obj.detail) return obj.detail
if (typeof obj.message === 'string' && obj.message) return obj.message
}
return fallback
}
async function loadDepartments(): Promise<void> {
try {
departments.value = await adminApi.listDepartments()
} catch (err) {
message.error(extractMessage(err, '加载部门列表失败'))
}
}
async function loadSummary(): Promise<void> {
try {
summary.value = await adminApi.getUsageSummary(buildParams())
} catch (err) {
message.error(extractMessage(err, '加载用量汇总失败'))
}
}
async function loadByModel(): Promise<void> {
try {
byModel.value = await adminApi.getUsageByModel(buildParams())
} catch (err) {
message.error(extractMessage(err, '加载模型分布失败'))
}
}
async function loadTopUsers(): Promise<void> {
try {
const params = buildParams()
topUsers.value = await adminApi.getUsageTopUsers({
department_id: params.department_id,
limit: 20,
})
} catch (err) {
message.error(extractMessage(err, '加载 Top 用户失败'))
}
}
async function loadAll(): Promise<void> {
loading.value = true
await Promise.all([loadSummary(), loadByModel(), loadTopUsers()])
loading.value = false
}
async function handleExport(info: IMenuClickInfo): Promise<void> {
const format = info.key === 'json' ? 'json' : 'csv'
try {
const content = await adminApi.exportUsage({ ...buildParams(), format })
const mime = format === 'json' ? 'application/json;charset=utf-8' : 'text/csv;charset=utf-8'
const ext = format === 'json' ? 'json' : 'csv'
const blob = new Blob([content], { type: mime })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
const stamp = dayjs().format('YYYYMMDD-HHmmss')
link.download = `usage-${stamp}.${ext}`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
message.success(`已导出 ${format.toUpperCase()} 报表`)
} catch (err) {
message.error(extractMessage(err, '导出报表失败'))
}
}
onMounted(async () => {
await Promise.all([loadDepartments(), loadAll()])
})
</script>
<style scoped>
.usage-view {
padding: 24px;
max-width: 1200px;
margin: 0 auto;
}
.usage-view__header {
margin-bottom: 16px;
}
.usage-view__filters {
margin-bottom: var(--space-4);
}
.usage-view__stats {
margin-bottom: var(--space-4);
}
.usage-view__stat-hint {
margin-top: var(--space-2);
font-size: var(--font-xs);
color: var(--text-tertiary);
}
.usage-view__tables {
margin-bottom: var(--space-4);
}
.usage-view__table-card {
height: 100%;
}
.usage-view__dept-card {
margin-top: 0;
}
</style>

View File

@ -2,7 +2,7 @@
<div class="users-view"> <div class="users-view">
<a-page-header <a-page-header
title="用户与会话管理" title="用户与会话管理"
sub-title="管理员可查看和撤销任意用户的会话" sub-title="管理员可查看和撤销任意用户的会话,并管理用户账号"
class="users-view__header" class="users-view__header"
/> />
@ -85,31 +85,246 @@
</a-space> </a-space>
</a-card> </a-card>
</a-tab-pane> </a-tab-pane>
<!-- Tab 3: 用户管理 -->
<a-tab-pane key="user-mgmt" tab="用户管理">
<a-card class="users-view__card">
<a-space direction="vertical" :size="16" style="width: 100%">
<a-space :size="8" align="center">
<a-button type="primary" @click="openCreateUser">
<PlusOutlined /> 新建用户
</a-button>
<a-select
v-model:value="userDeptFilter"
placeholder="按部门筛选"
allow-clear
style="width: 220px"
:options="deptFilterOptions"
@change="loadUsers"
/>
<a-button :loading="loadingUsers" @click="loadUsers">刷新</a-button>
</a-space>
<a-table
:columns="userColumns"
:data-source="users"
:row-key="(record: IAdminUser) => record.id"
:loading="loadingUsers"
:pagination="{ pageSize: 20, showSizeChanger: true }"
size="middle"
>
<template #bodyCell="{ column, record }: { column: TableColumnType<IAdminUser>; record: IAdminUser }">
<template v-if="column.key === 'role'">
<a-tag :color="roleColor(record.role)">{{ record.role }}</a-tag>
</template>
<template v-else-if="column.key === 'is_active'">
<a-tag :color="record.is_active ? 'green' : 'default'">
{{ record.is_active ? '活跃' : '停用' }}
</a-tag>
</template>
<template v-else-if="column.key === 'departments'">
<a-space :size="4" wrap>
<a-tag v-for="d in record.departments ?? []" :key="d.id" color="blue">
{{ d.name }}
</a-tag>
<span v-if="!record.departments || record.departments.length === 0"></span>
</a-space>
</template>
<template v-else-if="column.key === 'created_at'">
{{ formatTime(record.created_at) }}
</template>
<template v-else-if="column.key === 'actions'">
<a-space :size="4">
<a-button type="link" size="small" @click="openEditUser(record)">编辑</a-button>
<a-button type="link" size="small" @click="openResetPassword(record)">重置密码</a-button>
<a-button type="link" size="small" @click="openUserDepts(record)">部门</a-button>
<a-popconfirm
title="确认删除该用户?用户将被停用。"
ok-text="删除"
cancel-text="取消"
@confirm="handleDeleteUser(record)"
>
<a-button type="link" danger size="small">删除</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</a-space>
</a-card>
</a-tab-pane>
</a-tabs> </a-tabs>
<!-- Create user modal -->
<a-modal
v-model:open="createFormOpen"
title="新建用户"
:confirm-loading="creatingUser"
ok-text="创建"
cancel-text="取消"
@ok="handleCreateUser"
>
<a-form layout="vertical">
<a-form-item label="用户名" required>
<a-input v-model:value="createForm.username" placeholder="输入用户名" :max-length="64" />
</a-form-item>
<a-form-item label="邮箱" required>
<a-input v-model:value="createForm.email" placeholder="输入邮箱" />
</a-form-item>
<a-form-item label="密码" required>
<a-input-password v-model:value="createForm.password" placeholder="输入初始密码" />
</a-form-item>
<a-form-item label="角色">
<a-select v-model:value="createForm.role" :options="roleOptions" />
</a-form-item>
<a-form-item label="所属部门">
<a-select
v-model:value="createForm.department_ids"
mode="multiple"
placeholder="选择部门(可多选)"
:options="deptSelectOptions"
allow-clear
/>
</a-form-item>
</a-form>
</a-modal>
<!-- Edit user modal -->
<a-modal
v-model:open="editFormOpen"
title="编辑用户"
:confirm-loading="editingUser"
ok-text="保存"
cancel-text="取消"
@ok="handleEditUser"
>
<a-form layout="vertical">
<a-form-item label="用户名">
<a-input :value="editForm.username" disabled />
</a-form-item>
<a-form-item label="邮箱">
<a-input :value="editForm.email" disabled />
</a-form-item>
<a-form-item label="角色">
<a-select v-model:value="editForm.role" :options="roleOptions" />
</a-form-item>
<a-form-item label="活跃状态">
<a-switch v-model:checked="editForm.is_active" />
</a-form-item>
<a-form-item label="终端授权">
<a-switch v-model:checked="editForm.is_terminal_authorized" />
</a-form-item>
</a-form>
</a-modal>
<!-- Reset password modal -->
<a-modal
v-model:open="resetFormOpen"
title="重置密码"
:confirm-loading="resettingPassword"
ok-text="重置"
cancel-text="取消"
@ok="handleResetPassword"
>
<a-alert
message="重置密码将撤销该用户的所有会话。"
type="warning"
show-icon
style="margin-bottom: 16px"
/>
<a-form layout="vertical">
<a-form-item label="用户">
<a-input :value="resetTarget ? `${resetTarget.username} (${resetTarget.email})` : ''" disabled />
</a-form-item>
<a-form-item label="新密码" required>
<a-input-password v-model:value="resetForm.newPassword" placeholder="输入新密码" />
</a-form-item>
</a-form>
</a-modal>
<!-- Department assignment drawer -->
<a-drawer
v-model:open="deptsDrawerOpen"
:title="`部门分配 — ${deptsTarget?.username ?? ''}`"
width="480"
placement="right"
>
<a-spin :spinning="deptsLoading">
<div class="users-view__dept-assign">
<a-card title="已分配部门" size="small" style="margin-bottom: 16px">
<a-empty v-if="userDepts.length === 0" description="暂未分配部门" :image="simpleImage" />
<a-list v-else size="small" :data-source="userDepts" :bordered="false">
<template #renderItem="{ item }">
<a-list-item>
<a-space :size="8" style="width: 100%" justify="space-between">
<span><ApartmentOutlined /> {{ item.name }}</span>
<a-button type="link" danger size="small" @click="handleRemoveDept(item.id)">移除</a-button>
</a-space>
</a-list-item>
</template>
</a-list>
</a-card>
<a-card title="添加部门" size="small">
<a-select
v-model:value="newDeptId"
placeholder="选择要添加的部门"
style="width: 100%"
:options="availableDeptOptions"
allow-clear
/>
<a-button
type="primary"
block
style="margin-top: 12px"
:disabled="!newDeptId"
@click="handleAssignDept"
>
添加
</a-button>
</a-card>
</div>
</a-spin>
</a-drawer>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
/** /**
* Admin: user sessions management view (U9). * Admin: user sessions + user account management view (U9).
* *
* Two tabs: * Three tabs:
* 1. "按用户查询" enter a user ID, see that user's sessions via * 1. "按用户查询" enter a user ID, see that user's sessions via
* :class:`UserSessionsPanel` (which wraps the shared * :class:`UserSessionsPanel` (which wraps the shared
* :class:`ActiveSessionsPanel` in admin mode). * :class:`ActiveSessionsPanel` in admin mode).
* 2. "全局会话概览" list all recent sessions across the system, * 2. "全局会话概览" list all recent sessions across the system,
* with the ability to revoke any of them. * with the ability to revoke any of them.
* 3. "用户管理" user account CRUD, reset password, department
* assignment.
* *
* Access to this view is gated by the router (admin role required). * Access to this view is gated by the router (admin role required).
*/ */
import { ref, onMounted } from 'vue' import { ref, reactive, computed, onMounted } from 'vue'
import { message } from 'ant-design-vue' import { message, Empty } from 'ant-design-vue'
import type { TableColumnType } from 'ant-design-vue'
import {
PlusOutlined,
ApartmentOutlined,
} from '@ant-design/icons-vue'
import UserSessionsPanel from '@/components/admin/UserSessionsPanel.vue' import UserSessionsPanel from '@/components/admin/UserSessionsPanel.vue'
import ActiveSessionsPanel from '@/components/settings/ActiveSessionsPanel.vue' import ActiveSessionsPanel from '@/components/settings/ActiveSessionsPanel.vue'
import { adminApi } from '@/api/admin' import { adminApi } from '@/api/admin'
import type { IAdminUser, IDepartment } from '@/api/admin'
import type { ISessionInfo } from '@/api/auth' import type { ISessionInfo } from '@/api/auth'
const activeTab = ref<'by-user' | 'all-sessions'>('by-user') /** Select option shape used by a-select ``options`` prop. */
interface ISelectOption {
label: string
value: string
}
const simpleImage = Empty.PRESENTED_IMAGE_SIMPLE
const activeTab = ref<'by-user' | 'all-sessions' | 'user-mgmt'>('by-user')
// --- Tab 1: by user --- // --- Tab 1: by user ---
const userIdInput = ref('') const userIdInput = ref('')
@ -179,6 +394,250 @@ async function handleRevokeAll(sid: string): Promise<void> {
} }
} }
// --- Tab 3: user management ---
const users = ref<IAdminUser[]>([])
const loadingUsers = ref(false)
const userDeptFilter = ref<string | undefined>(undefined)
const allDepartments = ref<IDepartment[]>([])
const userColumns: TableColumnType<IAdminUser>[] = [
{ title: '用户名', key: 'username', dataIndex: 'username', width: 140 },
{ title: '邮箱', key: 'email', dataIndex: 'email', width: 220, ellipsis: true },
{ title: '角色', key: 'role', width: 100 },
{ title: '状态', key: 'is_active', width: 90 },
{ title: '部门', key: 'departments', width: 200 },
{ title: '创建时间', key: 'created_at', width: 170 },
{ title: '操作', key: 'actions', width: 260, fixed: 'right' },
]
const roleOptions: ISelectOption[] = [
{ label: 'member', value: 'member' },
{ label: 'operator', value: 'operator' },
{ label: 'admin', value: 'admin' },
]
const deptFilterOptions = computed<ISelectOption[]>(() =>
allDepartments.value.map((d) => ({ label: d.name, value: d.id })),
)
const deptSelectOptions = computed<ISelectOption[]>(() =>
allDepartments.value.map((d) => ({ label: d.name, value: d.id })),
)
function roleColor(role: string): string {
switch (role) {
case 'admin':
return 'red'
case 'operator':
return 'orange'
default:
return 'blue'
}
}
async function loadUsers(): Promise<void> {
loadingUsers.value = true
try {
users.value = await adminApi.listUsers(userDeptFilter.value)
} catch (err) {
message.error(_extractMessage(err, '加载用户列表失败'))
} finally {
loadingUsers.value = false
}
}
// Create user
const createFormOpen = ref(false)
const creatingUser = ref(false)
const createForm = reactive({
username: '',
email: '',
password: '',
role: 'member' as string,
department_ids: [] as string[],
})
function openCreateUser(): void {
createForm.username = ''
createForm.email = ''
createForm.password = ''
createForm.role = 'member'
createForm.department_ids = []
createFormOpen.value = true
}
async function handleCreateUser(): Promise<void> {
if (!createForm.username.trim() || !createForm.email.trim() || !createForm.password) {
message.warning('请填写用户名、邮箱和密码')
return
}
creatingUser.value = true
try {
await adminApi.createUser({
username: createForm.username.trim(),
email: createForm.email.trim(),
password: createForm.password,
role: createForm.role,
department_ids: createForm.department_ids.length ? createForm.department_ids : undefined,
})
message.success('用户已创建')
createFormOpen.value = false
await loadUsers()
} catch (err) {
message.error(_extractMessage(err, '创建用户失败'))
} finally {
creatingUser.value = false
}
}
// Edit user
const editFormOpen = ref(false)
const editingUser = ref(false)
const editForm = reactive({
id: '',
username: '',
email: '',
role: 'member' as string,
is_active: true,
is_terminal_authorized: false,
})
function openEditUser(record: IAdminUser): void {
editForm.id = record.id
editForm.username = record.username
editForm.email = record.email
editForm.role = record.role
editForm.is_active = record.is_active
editForm.is_terminal_authorized = record.is_terminal_authorized
editFormOpen.value = true
}
async function handleEditUser(): Promise<void> {
editingUser.value = true
try {
await adminApi.updateUser(editForm.id, {
role: editForm.role,
is_active: editForm.is_active,
is_terminal_authorized: editForm.is_terminal_authorized,
})
message.success('用户已更新')
editFormOpen.value = false
await loadUsers()
} catch (err) {
message.error(_extractMessage(err, '更新用户失败'))
} finally {
editingUser.value = false
}
}
// Delete user
async function handleDeleteUser(record: IAdminUser): Promise<void> {
try {
await adminApi.deleteUser(record.id)
message.success('用户已删除')
await loadUsers()
} catch (err) {
message.error(_extractMessage(err, '删除用户失败'))
}
}
// Reset password
const resetFormOpen = ref(false)
const resettingPassword = ref(false)
const resetTarget = ref<IAdminUser | null>(null)
const resetForm = reactive({ newPassword: '' })
function openResetPassword(record: IAdminUser): void {
resetTarget.value = record
resetForm.newPassword = ''
resetFormOpen.value = true
}
async function handleResetPassword(): Promise<void> {
if (!resetTarget.value || !resetForm.newPassword) {
message.warning('请输入新密码')
return
}
resettingPassword.value = true
try {
await adminApi.resetPassword(resetTarget.value.id, resetForm.newPassword)
message.success('密码已重置,该用户的所有会话已撤销')
resetFormOpen.value = false
} catch (err) {
message.error(_extractMessage(err, '重置密码失败'))
} finally {
resettingPassword.value = false
}
}
// Department assignment
const deptsDrawerOpen = ref(false)
const deptsLoading = ref(false)
const deptsTarget = ref<IAdminUser | null>(null)
const userDepts = ref<IDepartment[]>([])
const newDeptId = ref<string | undefined>(undefined)
const availableDeptOptions = computed<ISelectOption[]>(() => {
const assigned = new Set(userDepts.value.map((d) => d.id))
return allDepartments.value
.filter((d) => d.is_active && !assigned.has(d.id))
.map((d) => ({ label: d.name, value: d.id }))
})
async function openUserDepts(record: IAdminUser): Promise<void> {
deptsTarget.value = record
userDepts.value = record.departments ?? []
newDeptId.value = undefined
deptsDrawerOpen.value = true
// Re-fetch the user to get fresh department list
deptsLoading.value = true
try {
const fresh = await adminApi.getUser(record.id)
userDepts.value = fresh.departments ?? []
} catch (err) {
message.error(_extractMessage(err, '加载用户部门信息失败'))
} finally {
deptsLoading.value = false
}
}
async function handleAssignDept(): Promise<void> {
if (!deptsTarget.value || !newDeptId.value) return
try {
await adminApi.assignDepartment(deptsTarget.value.id, newDeptId.value)
message.success('部门已分配')
newDeptId.value = undefined
const fresh = await adminApi.getUser(deptsTarget.value.id)
userDepts.value = fresh.departments ?? []
await loadUsers()
} catch (err) {
message.error(_extractMessage(err, '分配部门失败'))
}
}
async function handleRemoveDept(deptId: string): Promise<void> {
if (!deptsTarget.value) return
try {
await adminApi.removeDepartment(deptsTarget.value.id, deptId)
message.success('部门已移除')
const fresh = await adminApi.getUser(deptsTarget.value.id)
userDepts.value = fresh.departments ?? []
await loadUsers()
} catch (err) {
message.error(_extractMessage(err, '移除部门失败'))
}
}
// --- Helpers ---
function formatTime(iso: string): string {
if (!iso) return '—'
try {
return new Date(iso).toLocaleString('zh-CN', { hour12: false })
} catch {
return iso
}
}
function _extractMessage(err: unknown, fallback: string): string { function _extractMessage(err: unknown, fallback: string): string {
if (err && typeof err === 'object') { if (err && typeof err === 'object') {
const obj = err as { detail?: unknown; message?: unknown } const obj = err as { detail?: unknown; message?: unknown }
@ -188,8 +647,18 @@ function _extractMessage(err: unknown, fallback: string): string {
return fallback return fallback
} }
async function loadDepartments(): Promise<void> {
try {
allDepartments.value = await adminApi.listDepartments()
} catch (err) {
message.error(_extractMessage(err, '加载部门列表失败'))
}
}
onMounted(() => { onMounted(() => {
loadAllSessions() loadAllSessions()
loadUsers()
loadDepartments()
}) })
</script> </script>
@ -216,4 +685,9 @@ onMounted(() => {
color: var(--text-secondary, #666); color: var(--text-secondary, #666);
font-size: 14px; font-size: 14px;
} }
.users-view__dept-assign {
display: flex;
flex-direction: column;
}
</style> </style>

View File

@ -1 +0,0 @@
.terminal-emulator[data-v-e0e9656d]{display:flex;flex-direction:column;height:100%;background:var(--code-bg);border-radius:var(--radius-md);overflow:hidden;font-family:SF Mono,Fira Code,Cascadia Code,Menlo,Consolas,monospace}.terminal-emulator__output[data-v-e0e9656d]{flex:1;overflow-y:auto;padding:var(--space-3);font-size:var(--font-sm);line-height:var(--leading-normal);color:var(--code-fg)}.terminal-emulator__welcome[data-v-e0e9656d]{color:var(--code-comment);font-style:italic}.terminal-emulator__input[data-v-e0e9656d]{display:flex;align-items:center;padding:var(--space-2) var(--space-3);border-top:1px solid rgba(255,255,255,.1);background:#0003}.terminal-emulator__prompt[data-v-e0e9656d]{color:var(--code-string);margin-right:var(--space-2);font-size:var(--font-sm);white-space:nowrap}.terminal-emulator__input-field[data-v-e0e9656d]{flex:1;background:transparent;border:none;outline:none;color:var(--code-fg);font-family:inherit;font-size:var(--font-sm)}.terminal-emulator__input-field[data-v-e0e9656d]::placeholder{color:var(--code-comment)}.terminal-line[data-v-e0e9656d]{white-space:pre-wrap;word-break:break-all}.terminal-line[data-v-e0e9656d] .ansi-green{color:var(--code-string)}.terminal-line[data-v-e0e9656d] .ansi-yellow{color:var(--code-number)}.terminal-line[data-v-e0e9656d] .ansi-red{color:var(--code-variable)}.terminal-line[data-v-e0e9656d] .ansi-cyan,.terminal-line[data-v-e0e9656d] .ansi-blue{color:var(--code-function)}.terminal-line[data-v-e0e9656d] .ansi-magenta{color:var(--code-keyword)}.command-history[data-v-d8bc7055]{display:flex;flex-direction:column;height:100%;background:var(--bg-primary)}.command-history__header[data-v-d8bc7055]{display:flex;justify-content:space-between;align-items:center;padding:var(--space-3) var(--space-4);font-weight:var(--font-weight-semibold);font-size:var(--font-base);border-bottom:1px solid var(--border-color)}.command-history__list[data-v-d8bc7055]{flex:1;overflow-y:auto;padding:var(--space-2)}.command-history__item[data-v-d8bc7055]{padding:var(--space-2) var(--space-3);border-radius:var(--radius-sm);cursor:pointer;margin-bottom:var(--space-1);transition:background var(--transition-fast)}.command-history__item[data-v-d8bc7055]:hover{background:var(--color-primary-light)}.command-history__item-header[data-v-d8bc7055]{display:flex;align-items:center;gap:var(--space-1)}.command-history__exit-code[data-v-d8bc7055]{font-size:var(--font-xs);font-weight:var(--font-weight-semibold)}.command-history__exit-code--success[data-v-d8bc7055]{color:var(--color-success)}.command-history__exit-code--error[data-v-d8bc7055]{color:var(--color-error)}.command-history__command[data-v-d8bc7055]{font-family:SF Mono,Fira Code,Cascadia Code,Menlo,Consolas,monospace;font-size:var(--font-xs);color:var(--text-primary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.command-history__item-meta[data-v-d8bc7055]{display:flex;gap:var(--space-2);margin-top:2px;font-size:11px;color:var(--text-placeholder)}.command-history__duration[data-v-d8bc7055]{color:var(--color-primary)}.command-history__empty[data-v-d8bc7055]{text-align:center;padding:var(--space-6);color:var(--text-placeholder);font-size:var(--font-sm)}.terminal-view[data-v-e0e2611b]{display:flex;height:100%;overflow:hidden}.terminal-view__main[data-v-e0e2611b]{flex:1;overflow:hidden;padding:var(--space-2);position:relative}.terminal-view__sidebar[data-v-e0e2611b]{display:flex;border-left:1px solid var(--border-color);background:var(--bg-primary);transition:width var(--transition-normal);overflow:hidden}.terminal-view__sidebar--collapsed[data-v-e0e2611b]{width:32px}.terminal-view__sidebar[data-v-e0e2611b]:not(.terminal-view__sidebar--collapsed){width:240px}.terminal-view__sidebar-toggle[data-v-e0e2611b]{display:flex;align-items:center;justify-content:center;width:32px;height:100%;border:none;background:transparent;color:var(--text-tertiary);cursor:pointer;flex-shrink:0;transition:all var(--transition-fast)}.terminal-view__sidebar-toggle[data-v-e0e2611b]:hover{color:var(--text-primary);background:var(--bg-tertiary)}.terminal-view__sidebar-content[data-v-e0e2611b]{flex:1;overflow:hidden;min-width:0}.terminal-view__modal-command[data-v-e0e2611b]{font-family:SF Mono,Fira Code,Cascadia Code,Menlo,Consolas,monospace;font-size:var(--font-sm);color:var(--text-primary);background:var(--bg-tertiary);padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);margin-bottom:var(--space-3);word-break:break-all}.terminal-view__modal-reason[data-v-e0e2611b]{font-size:var(--font-sm);color:var(--text-secondary);margin-bottom:var(--space-3)}

View File

@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Fischer AgentKit</title> <title>Fischer AgentKit</title>
<script type="module" crossorigin src="/assets/index-Dokv1VM5.js"></script> <script type="module" crossorigin src="/assets/index-DQi4f51B.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-bWefhn9S.css"> <link rel="stylesheet" crossorigin href="/assets/index-BdbfOrU3.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>