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:
parent
2dd0091bda
commit
e5a92427a4
|
|
@ -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']
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -157,3 +157,4 @@ async function handleSubmit(): Promise<void> {
|
||||||
.change-password-panel__submit {
|
.change-password-panel__submit {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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 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 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>
|
||||||
|
|
@ -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 version: 1.0.0 description: ... ..."
|
||||||
|
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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue