merge: 合并远程 ft_wyt 分支,解决冲突
This commit is contained in:
@@ -20,6 +20,8 @@ export interface AuditQuery {
|
||||
actor_id?: string
|
||||
action?: string
|
||||
target_type?: string
|
||||
target_id?: string
|
||||
keyword?: string
|
||||
start_time?: string
|
||||
end_time?: string
|
||||
limit?: number
|
||||
|
||||
@@ -20,6 +20,8 @@ import type {
|
||||
DataProcessPublishResult,
|
||||
DataProcessQualityScore,
|
||||
DataProcessResult,
|
||||
DataProcessResultBatchEvaluatePayload,
|
||||
DataProcessResultBatchEvaluateResult,
|
||||
DataProcessResultBatchRegeneratePayload,
|
||||
DataProcessResultBatchRegenerateResult,
|
||||
DataProcessResultRegeneratePayload,
|
||||
@@ -320,5 +322,14 @@ export const regenerateDataProcessResults = (
|
||||
{ timeout: 240_000 },
|
||||
)
|
||||
|
||||
export const evaluateDataProcessResults = (
|
||||
taskId: string | number,
|
||||
payload: DataProcessResultBatchEvaluatePayload,
|
||||
) => post<DataProcessResultBatchEvaluateResult>(
|
||||
`/data-process/${encodeURIComponent(taskId)}/results/evaluate-batch`,
|
||||
payload,
|
||||
{ timeout: 240_000 },
|
||||
)
|
||||
|
||||
export const publishDataProcess = (taskId: string | number, payload: DataProcessPublishPayload) =>
|
||||
post<DataProcessPublishResult>(`/data-process/${encodeURIComponent(taskId)}/publish`, payload)
|
||||
|
||||
@@ -42,12 +42,23 @@ export interface FineTunePreflightResult {
|
||||
sync_results?: Array<Record<string, unknown>>
|
||||
}
|
||||
|
||||
export interface FineTuneGpuStatus {
|
||||
source: string
|
||||
items: Array<Record<string, unknown>>
|
||||
selected_gpus: number[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
/** 训练任务列表 */
|
||||
export const getFineTuneList = () => get<FineTuneTask[]>('/fine-tune')
|
||||
|
||||
/** 训练任务详情 */
|
||||
export const getFineTune = (id: string | number) => get<FineTuneTask>(`/fine-tune/${id}`)
|
||||
|
||||
/** 获取任务所在 Compute 节点的实时 GPU 指标 */
|
||||
export const getFineTuneGpuStatus = (id: string | number) =>
|
||||
get<FineTuneGpuStatus>(`/fine-tune/${id}/gpu-status`)
|
||||
|
||||
/** 任务名查重 */
|
||||
export const checkFineTuneName = (name: string) =>
|
||||
get<{ exists: boolean }>('/fine-tune/check-name', { name })
|
||||
|
||||
@@ -15,6 +15,10 @@ const activeMenu = computed(() => {
|
||||
if (seg === 'training-log') return 'fine-tune'
|
||||
// 维度管理归到模型评测
|
||||
if (route.path.includes('model-eval/dimension')) return 'model-eval'
|
||||
// 组织与权限承接用户、租户和历史治理入口
|
||||
if (route.path.startsWith('/organization') || route.path.startsWith('/user-settings') || route.path.startsWith('/tenants')) return 'organization'
|
||||
// 运行日志承接审计和操作诊断两个历史入口
|
||||
if (route.path.startsWith('/logs') || route.path.startsWith('/audit-logs') || route.path.startsWith('/operation-logs')) return 'logs'
|
||||
// 对比对话归到模型推理
|
||||
if (route.path.startsWith('/model-compare/chat')) return 'model-inference'
|
||||
// 合并权重归到模型管理
|
||||
@@ -77,21 +81,16 @@ const menuGroups: MenuGroup[] = [
|
||||
{
|
||||
title: '平台治理',
|
||||
items: [
|
||||
{ key: 'tenants', label: '租户管理', icon: 'fa-building', to: '/tenants', permission: 'user-settings' },
|
||||
{ key: 'projects', label: '项目空间', icon: 'fa-folder', to: '/projects', permission: 'user-settings' },
|
||||
{ key: 'organization', label: '组织与权限', icon: 'fa-users', to: '/organization', permission: 'user-settings' },
|
||||
{ key: 'resource-acl', label: '资源授权', icon: 'fa-key', to: '/resource-acl', permission: 'user-settings' },
|
||||
{ key: 'audit-logs', label: '审计日志', icon: 'fa-history', to: '/audit-logs', permission: 'user-settings' },
|
||||
{ key: 'operation-logs', label: '操作日志', icon: 'fa-list', to: '/operation-logs', permission: 'user-settings' },
|
||||
{ key: 'approval-templates', label: '审批模板', icon: 'fa-list-alt', to: '/approval-templates', permission: 'user-settings' },
|
||||
{ key: 'approval-instances', label: '审批中心', icon: 'fa-check-square', to: '/approval-instances', permission: 'user-settings' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '系统设置',
|
||||
items: [
|
||||
{ key: 'user-settings', label: '用户设置', icon: 'fa-users', to: '/user-settings', permission: 'user-settings' },
|
||||
{ key: 'hardware', label: '平台性能', icon: 'fa-bar-chart', to: '/hardware', permission: 'hardware' },
|
||||
{ key: 'logs', label: '查看日志', icon: 'fa-file-text', to: '/logs', permission: 'logs' },
|
||||
{ key: 'logs', label: '运行日志', icon: 'fa-file-text', to: '/logs', permission: 'logs' },
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -101,9 +100,9 @@ const menuGroups: MenuGroup[] = [
|
||||
*
|
||||
* 1. admin 用户:可以看到所有菜单
|
||||
* 2. 非 admin 用户:
|
||||
* - 默认可见所有业务菜单(模型训练、评测、推理、数据集、数据处理、转换、性能、日志等)
|
||||
* - 默认可见所有业务菜单(模型训练、评测、推理、数据集、数据处理、转换、性能、运行日志等)
|
||||
* - 仅以下菜单对非 admin 不可见:
|
||||
* - user-settings(用户设置、租户管理、项目空间、审批模板/中心、审计日志)
|
||||
* - user-settings(组织与权限、资源授权、审批;运行日志中的审计/诊断页签)
|
||||
* - compute(算力节点/GPU 分配)
|
||||
*
|
||||
* 注意:移除了旧的权限码(permission code)过滤逻辑,
|
||||
|
||||
@@ -3,18 +3,21 @@
|
||||
*/
|
||||
import { use } from 'echarts/core'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import { BarChart, PieChart } from 'echarts/charts'
|
||||
import { BarChart, PieChart, RadarChart } from 'echarts/charts'
|
||||
import {
|
||||
GridComponent,
|
||||
TooltipComponent,
|
||||
LegendComponent,
|
||||
RadarComponent,
|
||||
} from 'echarts/components'
|
||||
|
||||
use([
|
||||
CanvasRenderer,
|
||||
BarChart,
|
||||
PieChart,
|
||||
RadarChart,
|
||||
GridComponent,
|
||||
TooltipComponent,
|
||||
LegendComponent,
|
||||
RadarComponent,
|
||||
])
|
||||
|
||||
@@ -32,11 +32,17 @@ const routes: RouteRecordRaw[] = [
|
||||
meta: { title: '服务看板' },
|
||||
},
|
||||
// 平台治理
|
||||
{
|
||||
path: 'organization',
|
||||
name: 'organization',
|
||||
component: () => import('@/views/governance/OrganizationPermissionView.vue'),
|
||||
meta: { title: '组织与权限', pageSurface: 'self', permission: 'user-settings' },
|
||||
},
|
||||
{
|
||||
path: 'tenants',
|
||||
name: 'tenants',
|
||||
component: () => import('@/views/tenants/TenantListView.vue'),
|
||||
meta: { title: '租户管理', permission: 'user-settings' },
|
||||
redirect: '/organization?tab=tenants',
|
||||
meta: { title: '租户与配额', permission: 'user-settings' },
|
||||
},
|
||||
{
|
||||
path: 'tenants/:id',
|
||||
@@ -47,37 +53,37 @@ const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: 'projects',
|
||||
name: 'projects',
|
||||
component: () => import('@/views/projects/ProjectListView.vue'),
|
||||
meta: { title: '项目空间', permission: 'user-settings' },
|
||||
redirect: '/organization?tab=users',
|
||||
meta: { title: '组织与权限', permission: 'user-settings' },
|
||||
},
|
||||
{
|
||||
path: 'projects/:id',
|
||||
name: 'project-detail',
|
||||
component: () => import('@/views/projects/ProjectDetailView.vue'),
|
||||
meta: { title: '项目详情', permission: 'user-settings' },
|
||||
redirect: '/organization?tab=users',
|
||||
meta: { title: '组织与权限', permission: 'user-settings' },
|
||||
},
|
||||
{
|
||||
path: 'audit-logs',
|
||||
name: 'audit-logs',
|
||||
component: () => import('@/views/audit/AuditLogView.vue'),
|
||||
meta: { title: '审计日志', permission: 'user-settings' },
|
||||
redirect: '/logs?tab=audit',
|
||||
meta: { title: '运行日志', permission: 'user-settings' },
|
||||
},
|
||||
{
|
||||
path: 'operation-logs',
|
||||
name: 'operation-logs',
|
||||
component: () => import('@/views/audit/OperationLogView.vue'),
|
||||
meta: { title: '操作日志', permission: 'user-settings' },
|
||||
redirect: '/logs?tab=operations',
|
||||
meta: { title: '运行日志', permission: 'user-settings' },
|
||||
},
|
||||
{
|
||||
path: 'approval-templates',
|
||||
name: 'approval-templates',
|
||||
component: () => import('@/views/approvals/ApprovalTemplateView.vue'),
|
||||
meta: { title: '审批模板', permission: 'user-settings' },
|
||||
redirect: '/approval-instances?tab=strategies',
|
||||
meta: { title: '审批中心', permission: 'user-settings' },
|
||||
},
|
||||
{
|
||||
path: 'approval-instances',
|
||||
name: 'approval-instances',
|
||||
component: () => import('@/views/approvals/ApprovalInstanceView.vue'),
|
||||
component: () => import('@/views/approvals/ApprovalCenterView.vue'),
|
||||
meta: { title: '审批中心', permission: 'user-settings' },
|
||||
},
|
||||
{
|
||||
@@ -302,14 +308,14 @@ const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: 'logs',
|
||||
name: 'logs',
|
||||
component: () => import('@/views/system/LogsView.vue'),
|
||||
meta: { title: '查看日志' },
|
||||
component: () => import('@/views/system/RuntimeLogsView.vue'),
|
||||
meta: { title: '运行日志', pageSurface: 'self', permission: 'logs' },
|
||||
},
|
||||
{
|
||||
path: 'user-settings',
|
||||
name: 'user-settings',
|
||||
component: () => import('@/views/system/UserSettingsView.vue'),
|
||||
meta: { title: '用户设置', pageSurface: 'self', permission: 'user-settings' },
|
||||
redirect: '/organization?tab=users',
|
||||
meta: { title: '组织与权限', pageSurface: 'self', permission: 'user-settings' },
|
||||
},
|
||||
{
|
||||
path: 'user-settings/create',
|
||||
@@ -357,6 +363,7 @@ const permissionBySegment: Record<string, PermissionCode> = {
|
||||
tools: 'data-convert',
|
||||
hardware: 'hardware',
|
||||
logs: 'logs',
|
||||
organization: 'user-settings',
|
||||
'user-settings': 'user-settings',
|
||||
tenants: 'user-settings',
|
||||
projects: 'user-settings',
|
||||
@@ -379,7 +386,7 @@ function requiredPermission(path: string, explicit?: unknown) {
|
||||
// 权限控制规则(基于 governance-user-guide.md 设计):
|
||||
// - admin 用户:可以访问所有页面
|
||||
// - 非 admin 用户:默认可访问所有业务页面(训练、评测、推理、数据等)
|
||||
// 仅以下页面限制 admin 访问:user-settings、compute(算力节点)
|
||||
// 仅治理与资源管理页面限制 admin 访问:organization、user-settings、compute
|
||||
router.beforeEach((to, _from, next) => {
|
||||
if (!to.meta.public) routeLoading.value = true
|
||||
const auth = useAuthStore()
|
||||
@@ -404,7 +411,7 @@ router.beforeEach((to, _from, next) => {
|
||||
if (!to.meta.skipPermission) {
|
||||
const permission = requiredPermission(to.path, to.meta.permission)
|
||||
// 仅限制管理员专属页面的访问权限
|
||||
// user-settings(用户设置、租户管理、项目空间、审批、审计日志)仅 admin 可访问
|
||||
// user-settings(组织与权限、资源授权、审批中心、运行日志)仅 admin 可访问
|
||||
if (permission === 'user-settings' && !auth.isAdmin) {
|
||||
next({ name: 'permission-denied', replace: true })
|
||||
return
|
||||
|
||||
@@ -398,6 +398,52 @@ export interface DataProcessResultBatchRegenerateResult {
|
||||
failures: DataProcessResultBatchRegenerateFailure[]
|
||||
}
|
||||
|
||||
export interface DataProcessResultBatchEvaluateItem {
|
||||
result_id: string
|
||||
expected_updated_at: string
|
||||
}
|
||||
|
||||
export interface DataProcessResultBatchEvaluatePayload {
|
||||
items: DataProcessResultBatchEvaluateItem[]
|
||||
}
|
||||
|
||||
export interface DataProcessResultBatchEvaluateFailure {
|
||||
result_id: string
|
||||
code: 'conflict' | 'skipped' | 'evaluation_failed' | 'internal_error'
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface DataProcessResultBatchEvaluateResult {
|
||||
batch_id: string
|
||||
total: number
|
||||
succeeded: number
|
||||
failed: number
|
||||
duration_ms: number
|
||||
items: DataProcessResult[]
|
||||
failures: DataProcessResultBatchEvaluateFailure[]
|
||||
}
|
||||
|
||||
export interface DataProcessQualitySemantic {
|
||||
question_answer?: number
|
||||
answer_source?: number
|
||||
overall?: number
|
||||
}
|
||||
|
||||
export interface DataProcessQualityJudge {
|
||||
scores?: Record<string, number>
|
||||
overall?: number
|
||||
reason?: string
|
||||
issues?: string[]
|
||||
model?: string
|
||||
output_type?: string
|
||||
}
|
||||
|
||||
export interface DataProcessQualityLayers {
|
||||
rule?: number | null
|
||||
semantic?: number | null
|
||||
judge?: number | null
|
||||
}
|
||||
|
||||
export interface DataProcessQualityScore {
|
||||
overall?: number
|
||||
completeness?: number
|
||||
@@ -408,6 +454,11 @@ export interface DataProcessQualityScore {
|
||||
is_valid?: boolean
|
||||
flags?: string[]
|
||||
fingerprint?: string
|
||||
semantic?: DataProcessQualitySemantic | null
|
||||
judge?: DataProcessQualityJudge | null
|
||||
layers?: DataProcessQualityLayers | null
|
||||
evaluated?: boolean
|
||||
evaluated_at?: string | null
|
||||
source_pages?: number[]
|
||||
heading_path?: string[]
|
||||
source_locator?: DataProcessSourceLocator
|
||||
|
||||
@@ -218,6 +218,8 @@ export interface LoadedModel {
|
||||
port?: number
|
||||
node_id?: string
|
||||
node_name?: string
|
||||
gpu_indices?: number[]
|
||||
gpus?: number[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
@@ -239,6 +241,8 @@ export interface CompareModelRef {
|
||||
gpu_id: number
|
||||
node_id?: string
|
||||
node_name?: string
|
||||
gpu_indices?: number[]
|
||||
gpus?: number[]
|
||||
source?: string
|
||||
port?: number
|
||||
}
|
||||
@@ -282,7 +286,9 @@ export interface StartEvalPayload {
|
||||
eval_task_name: string
|
||||
eval_type: EvalType
|
||||
model_id: string | number
|
||||
gpu_id: string | number
|
||||
gpu_id: string | number | string[]
|
||||
gpu_indices?: number[]
|
||||
gpus?: number[]
|
||||
compute_node_id?: string
|
||||
dataset_id: string | number
|
||||
dimension_id: string | number
|
||||
|
||||
66
frontend/src/views/approvals/ApprovalCenterView.vue
Normal file
66
frontend/src/views/approvals/ApprovalCenterView.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import ApprovalInstanceView from './ApprovalInstanceView.vue'
|
||||
import ApprovalTemplateView from './ApprovalTemplateView.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const activeTab = computed<'instances' | 'mine' | 'strategies'>({
|
||||
get: () => route.query.tab === 'strategies' ? 'strategies' : route.query.tab === 'mine' ? 'mine' : 'instances',
|
||||
set: (value: string) => {
|
||||
void router.replace({ query: value === 'instances' ? {} : { tab: value } })
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="approval-center">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h2>审批中心</h2>
|
||||
<p>集中处理审批申请、审批历史和审批策略。</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane label="审批申请" name="instances">
|
||||
<ApprovalInstanceView v-if="activeTab === 'instances'" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="我的申请" name="mine">
|
||||
<ApprovalInstanceView v-if="activeTab === 'mine'" mine />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="审批策略" name="strategies">
|
||||
<ApprovalTemplateView v-if="activeTab === 'strategies'" />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.approval-center {
|
||||
min-height: 100%;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 4px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
color: #1f2937;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 6px 0 0;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.page) {
|
||||
padding: 16px 0 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,11 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import DataTablePage from '@/components/DataTablePage.vue'
|
||||
import { getApprovalInstances, decideApproval, type ApprovalInstance } from '@/api/modules/approval'
|
||||
import { getUsers } from '@/api/modules/system'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { SystemUser } from '@/types'
|
||||
|
||||
const props = defineProps<{ mine?: boolean }>()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const instances = ref<ApprovalInstance[]>([])
|
||||
const users = ref<SystemUser[]>([])
|
||||
@@ -14,6 +18,14 @@ const showDecide = ref(false)
|
||||
const current = ref<ApprovalInstance | null>(null)
|
||||
const decision = ref({ step_index: 0, approver_id: '', approved: true, comment: '' })
|
||||
|
||||
const visibleInstances = computed(() => {
|
||||
if (!props.mine) return instances.value
|
||||
const currentUserId = auth.currentUser?.id
|
||||
return currentUserId
|
||||
? instances.value.filter((item) => item.applicant_id === currentUserId)
|
||||
: []
|
||||
})
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '待审批', value: 'pending' },
|
||||
{ label: '已通过', value: 'approved' },
|
||||
@@ -77,7 +89,7 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<DataTablePage title="审批实例" :data="instances" :loading="loading" searchable :search-fields="['resource_type', 'resource_id']">
|
||||
<DataTablePage :title="props.mine ? '我的申请' : '审批申请'" :data="visibleInstances" :loading="loading" searchable :search-fields="['resource_type', 'resource_id']">
|
||||
<template #toolbar-extra>
|
||||
<el-select v-model="statusFilter" placeholder="状态" clearable style="width: 140px" @change="load">
|
||||
<el-option v-for="s in statusOptions" :key="s.value" :label="s.label" :value="s.value" />
|
||||
|
||||
@@ -2,16 +2,21 @@
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getAuditLogs, exportAuditLogs, type AuditLog, type AuditQuery } from '@/api/modules/audit'
|
||||
import { getUsers, type SystemUser } from '@/api/modules/system'
|
||||
import { getTenants, type Tenant } from '@/api/modules/tenant'
|
||||
|
||||
const loading = ref(false)
|
||||
const logs = ref<AuditLog[]>([])
|
||||
const total = ref(0)
|
||||
const users = ref<SystemUser[]>([])
|
||||
const tenants = ref<Tenant[]>([])
|
||||
const query = reactive<AuditQuery>({
|
||||
tenant_id: '',
|
||||
project_id: '',
|
||||
actor_id: '',
|
||||
action: '',
|
||||
target_type: '',
|
||||
target_id: '',
|
||||
keyword: '',
|
||||
start_time: '',
|
||||
end_time: '',
|
||||
limit: 50,
|
||||
@@ -21,6 +26,87 @@ const query = reactive<AuditQuery>({
|
||||
// 时间范围(el-date-picker 双向绑定数组 [start, end])
|
||||
const timeRange = ref<[string, string] | null>(null)
|
||||
|
||||
const actionOptions = [
|
||||
{ value: 'create_dataset', label: '创建数据集' },
|
||||
{ value: 'update_dataset', label: '修改数据集' },
|
||||
{ value: 'delete_dataset', label: '删除数据集' },
|
||||
{ value: 'create_model', label: '创建模型' },
|
||||
{ value: 'update_model', label: '修改模型' },
|
||||
{ value: 'delete_model', label: '删除模型' },
|
||||
{ value: 'create_fine_tune', label: '创建训练任务' },
|
||||
{ value: 'update_fine_tune', label: '修改训练任务' },
|
||||
{ value: 'delete_fine_tune', label: '删除训练任务' },
|
||||
{ value: 'create_inference', label: '创建推理任务' },
|
||||
{ value: 'update_inference', label: '修改推理任务' },
|
||||
{ value: 'delete_inference', label: '删除推理任务' },
|
||||
{ value: 'create_user', label: '创建用户' },
|
||||
{ value: 'update_user', label: '修改用户' },
|
||||
{ value: 'delete_user', label: '删除用户' },
|
||||
{ value: 'tenant.create', label: '创建租户' },
|
||||
{ value: 'tenant.update', label: '修改租户' },
|
||||
{ value: 'tenant.delete', label: '删除租户' },
|
||||
{ value: 'tenant.quota.set', label: '设置租户配额' },
|
||||
{ value: 'tenant.retention.set', label: '设置留存策略' },
|
||||
{ value: 'grant_acl', label: '授予资源权限' },
|
||||
{ value: 'revoke_acl', label: '撤销资源权限' },
|
||||
{ value: 'gpu.assign', label: '分配算力卡' },
|
||||
{ value: 'gpu.release', label: '释放算力卡' },
|
||||
{ value: 'create', label: '创建' },
|
||||
{ value: 'update', label: '修改' },
|
||||
{ value: 'delete', label: '删除' },
|
||||
{ value: 'start', label: '启动' },
|
||||
{ value: 'stop', label: '停止' },
|
||||
{ value: 'upload', label: '上传' },
|
||||
{ value: 'download', label: '下载' },
|
||||
{ value: 'convert', label: '转换' },
|
||||
{ value: 'merge', label: '合并权重' },
|
||||
{ value: 'publish', label: '发布' },
|
||||
{ value: 'retry', label: '重试' },
|
||||
{ value: 'login', label: '登录' },
|
||||
{ value: 'logout', label: '退出登录' },
|
||||
]
|
||||
|
||||
const targetTypeOptions = [
|
||||
{ value: 'dataset', label: '数据集' },
|
||||
{ value: 'model', label: '模型' },
|
||||
{ value: 'fine_tune_task', label: '训练任务' },
|
||||
{ value: 'fine_tune', label: '训练任务' },
|
||||
{ value: 'inference_task', label: '推理任务' },
|
||||
{ value: 'inference', label: '推理任务' },
|
||||
{ value: 'eval_task', label: '评测任务' },
|
||||
{ value: 'trained_model', label: '训练模型' },
|
||||
{ value: 'convert_task', label: '数据转换任务' },
|
||||
{ value: 'tenant', label: '租户' },
|
||||
{ value: 'user', label: '用户' },
|
||||
{ value: 'resource_acl', label: '资源权限' },
|
||||
{ value: 'gpu', label: '算力卡' },
|
||||
{ value: 'retention_policy', label: '留存策略' },
|
||||
{ value: 'module', label: '业务模块' },
|
||||
{ value: 'api', label: '接口' },
|
||||
]
|
||||
|
||||
const actionLabels = Object.fromEntries(actionOptions.map((item) => [item.value, item.label]))
|
||||
const targetTypeLabels = Object.fromEntries(targetTypeOptions.map((item) => [item.value, item.label]))
|
||||
|
||||
function userName(id?: string) {
|
||||
if (!id) return '系统'
|
||||
const user = users.value.find((item) => item.id === id)
|
||||
return user ? `${user.display_name || user.username}(${user.username})` : id
|
||||
}
|
||||
|
||||
function tenantName(id?: string) {
|
||||
if (!id) return '未关联租户'
|
||||
return tenants.value.find((item) => item.id === id)?.name || id
|
||||
}
|
||||
|
||||
function actionName(action?: string) {
|
||||
return action ? actionLabels[action] || action : '未记录'
|
||||
}
|
||||
|
||||
function targetTypeName(type?: string) {
|
||||
return type ? targetTypeLabels[type] || type : '未指定'
|
||||
}
|
||||
|
||||
function applyTimeRange() {
|
||||
if (timeRange.value && timeRange.value.length === 2) {
|
||||
query.start_time = timeRange.value[0]
|
||||
@@ -42,6 +128,25 @@ async function load() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFilterOptions() {
|
||||
const [userResult, tenantResult] = await Promise.allSettled([getUsers(), getTenants()])
|
||||
if (userResult.status === 'fulfilled') users.value = userResult.value
|
||||
if (tenantResult.status === 'fulfilled') tenants.value = tenantResult.value
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
query.tenant_id = ''
|
||||
query.actor_id = ''
|
||||
query.action = ''
|
||||
query.target_type = ''
|
||||
query.target_id = ''
|
||||
query.keyword = ''
|
||||
query.start_time = ''
|
||||
query.end_time = ''
|
||||
timeRange.value = null
|
||||
void load()
|
||||
}
|
||||
|
||||
async function handleExport() {
|
||||
try {
|
||||
const blob = await exportAuditLogs({ ...query, limit: 10000, offset: 0 })
|
||||
@@ -56,7 +161,9 @@ async function handleExport() {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
onMounted(() => {
|
||||
void Promise.all([loadFilterOptions(), load()])
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -66,21 +173,32 @@ onMounted(load)
|
||||
<el-button @click="handleExport">导出 CSV</el-button>
|
||||
</div>
|
||||
<el-card class="filter-card">
|
||||
<el-form :inline="true">
|
||||
<el-form :inline="true" class="filter-form">
|
||||
<el-form-item label="租户">
|
||||
<el-input v-model="query.tenant_id" placeholder="tenant_id" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="项目">
|
||||
<el-input v-model="query.project_id" placeholder="project_id" clearable />
|
||||
<el-select v-model="query.tenant_id" placeholder="全部租户" clearable filterable style="width: 190px">
|
||||
<el-option v-for="tenant in tenants" :key="tenant.id" :label="tenant.name" :value="tenant.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="操作人">
|
||||
<el-input v-model="query.actor_id" placeholder="actor_id" clearable />
|
||||
<el-select v-model="query.actor_id" placeholder="全部用户" clearable filterable style="width: 210px">
|
||||
<el-option v-for="user in users" :key="user.id" :label="`${user.display_name || user.username}(${user.username})`" :value="user.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="动作">
|
||||
<el-input v-model="query.action" placeholder="action" clearable />
|
||||
<el-select v-model="query.action" placeholder="全部动作" clearable filterable style="width: 180px">
|
||||
<el-option v-for="item in actionOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="目标类型">
|
||||
<el-input v-model="query.target_type" placeholder="target_type" clearable />
|
||||
<el-select v-model="query.target_type" placeholder="全部资源" clearable filterable style="width: 160px">
|
||||
<el-option v-for="item in targetTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="关键词">
|
||||
<el-input v-model="query.keyword" placeholder="资源 ID 或详情" clearable style="width: 220px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="目标 ID">
|
||||
<el-input v-model="query.target_id" placeholder="精确查询,可选" clearable style="width: 180px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="时间范围">
|
||||
<el-date-picker
|
||||
@@ -97,16 +215,24 @@ onMounted(load)
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="load">查询</el-button>
|
||||
<el-button @click="resetFilters">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
<el-table :data="logs" v-loading="loading" border stripe class="log-table">
|
||||
<el-table-column prop="time" label="时间" min-width="180" />
|
||||
<el-table-column prop="tenant_id" label="租户" min-width="120" />
|
||||
<el-table-column prop="project_id" label="项目" min-width="120" />
|
||||
<el-table-column prop="actor_id" label="操作人" min-width="120" />
|
||||
<el-table-column prop="action" label="动作" min-width="140" />
|
||||
<el-table-column prop="target_type" label="目标类型" min-width="120" />
|
||||
<el-table-column label="租户" min-width="140">
|
||||
<template #default="{ row }">{{ tenantName(row.tenant_id) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作人" min-width="180">
|
||||
<template #default="{ row }">{{ userName(row.actor_id) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="动作" min-width="140">
|
||||
<template #default="{ row }">{{ actionName(row.action) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="目标类型" min-width="120">
|
||||
<template #default="{ row }">{{ targetTypeName(row.target_type) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="target_id" label="目标 ID" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="detail" label="详情" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="client_ip" label="IP" min-width="120" />
|
||||
@@ -120,6 +246,7 @@ onMounted(load)
|
||||
.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
|
||||
.page-title { margin: 0; font-size: 18px; }
|
||||
.filter-card { margin-bottom: 16px; }
|
||||
.filter-form { display: flex; flex-wrap: wrap; }
|
||||
.log-table { margin-top: 8px; }
|
||||
.pager { margin-top: 12px; text-align: right; color: #909399; }
|
||||
</style>
|
||||
|
||||
@@ -313,7 +313,7 @@ onMounted(() => {
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="showDetail(row)">详情</el-button>
|
||||
<el-button link type="primary" size="small" @click="showDetail(row as OperationLog)">详情</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Delete, Refresh } from '@element-plus/icons-vue'
|
||||
import type { TagProps, UploadRequestOptions, UploadFile } from 'element-plus'
|
||||
import type { TagProps, UploadRequestOptions, UploadFile, UploadRawFile } from 'element-plus'
|
||||
import PageCard from '@/components/PageCard.vue'
|
||||
import {
|
||||
getDataConvertTasks,
|
||||
@@ -30,9 +30,9 @@ async function load() {
|
||||
}
|
||||
|
||||
// 文件上传前的校验(仅校验文件格式)
|
||||
function beforeUpload(file: UploadFile) {
|
||||
function beforeUpload(file: UploadRawFile) {
|
||||
// 检查文件类型
|
||||
const isJson = file.name.endsWith('.json') || file.raw?.type === 'application/json'
|
||||
const isJson = file.name.endsWith('.json') || file.type === 'application/json'
|
||||
if (!isJson) {
|
||||
ElMessage.error('只能上传 .json 格式的文件')
|
||||
return false
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
previewAffectingOptionsFor,
|
||||
} from './create/dataProcessCreateState'
|
||||
import { useDataProcessGeneration } from './create/useDataProcessGeneration'
|
||||
import { useDataProcessEvaluation } from './create/useDataProcessEvaluation'
|
||||
import { useDataProcessPreviewBuild } from './create/useDataProcessPreviewBuild'
|
||||
import { useDataProcessRegeneration } from './create/useDataProcessRegeneration'
|
||||
import { createDefaultExternalSource, externalSourcePayload, restoreExternalSourceConfig, sourceConfigForBackend } from './create/externalSourceConfig'
|
||||
@@ -126,6 +127,19 @@ const {
|
||||
outputType: activeOutputType,
|
||||
beforeGenerate: beforeStartGeneration,
|
||||
})
|
||||
const {
|
||||
evaluation,
|
||||
evaluateAllResults,
|
||||
resetEvaluation,
|
||||
} = useDataProcessEvaluation({
|
||||
taskId,
|
||||
results,
|
||||
selectedResultId,
|
||||
})
|
||||
// 生成结果被重置(重新切分/上传/重新生成配置)时同步清空评测进度。
|
||||
watch(results, (items) => {
|
||||
if (!items.length) resetEvaluation()
|
||||
})
|
||||
const { enqueueSourceUpload, sourceUploading } = useDataProcessSourceUpload({
|
||||
taskId,
|
||||
uploadedFiles,
|
||||
@@ -1156,10 +1170,12 @@ onMounted(() => {
|
||||
:preview-items="previewItems"
|
||||
:regenerating-result-id="regeneratingResultId"
|
||||
:bulk-regeneration="bulkRegeneration"
|
||||
:evaluation="evaluation"
|
||||
:output-type="activeOutputType"
|
||||
@update:field="updateResultField"
|
||||
@regenerate:all="regenerateAllResults"
|
||||
@regenerate:item="regenerateResult"
|
||||
@evaluate:all="evaluateAllResults"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import PageCard from '@/components/PageCard.vue'
|
||||
import { usePolling } from '@/composables/usePolling'
|
||||
import {
|
||||
evaluateDataProcessResults,
|
||||
getDataProcessProgress,
|
||||
getDataProcessResults,
|
||||
getDataProcessTask,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
restoreDataProcessResult,
|
||||
updateDataProcessResult,
|
||||
} from '@/api/modules/dataProcess'
|
||||
import QualityRadarPopover from './create/QualityRadarPopover.vue'
|
||||
import type {
|
||||
DataProcessDatasetSplit,
|
||||
DataProcessPublishPayload,
|
||||
@@ -456,13 +458,101 @@ function resultStatusType(status: DataProcessResultStatus) {
|
||||
}
|
||||
|
||||
function qualityScoreLabel(value: DataProcessResult['quality_score']) {
|
||||
if (value == null) return '-'
|
||||
if (value == null || !value.evaluated) return '-'
|
||||
const score = value.overall
|
||||
return Number.isFinite(score) ? Number(score).toFixed(1) : '-'
|
||||
}
|
||||
|
||||
function qualityFlagsLabel(value: DataProcessResult['quality_score']) {
|
||||
return value?.flags?.length ? value.flags.join('、') : '未命中质量规则'
|
||||
function qualityScoreTone(value: DataProcessResult['quality_score']) {
|
||||
const score = Number(value?.overall)
|
||||
if (!value?.evaluated || !Number.isFinite(score)) return ''
|
||||
return score >= 80 ? 'is-success' : score >= 60 ? 'is-warning' : 'is-danger'
|
||||
}
|
||||
|
||||
function qualityScoreEvaluated(value: DataProcessResult['quality_score']) {
|
||||
return Boolean(value?.evaluated && Number.isFinite(Number(value?.overall)))
|
||||
}
|
||||
|
||||
const evaluationRunning = ref(false)
|
||||
const evaluationProgress = reactive({
|
||||
visible: false,
|
||||
total: 0,
|
||||
completed: 0,
|
||||
succeeded: 0,
|
||||
failed: 0,
|
||||
})
|
||||
// 与批量重生成一致的分块大小,单批在接口 240 秒超时预算内。
|
||||
const EVALUATION_CHUNK_SIZE = 12
|
||||
const canEvaluate = computed(() => (
|
||||
detail.value?.status === 'completed' && !hasCurrentPublishedDataset.value
|
||||
))
|
||||
const evaluationPercentage = computed(() => (
|
||||
evaluationProgress.total
|
||||
? Math.round((evaluationProgress.completed / evaluationProgress.total) * 100)
|
||||
: 0
|
||||
))
|
||||
|
||||
async function loadAllResultIds() {
|
||||
const first = await getDataProcessResults(taskId.value, { page: 1, page_size: 500 })
|
||||
const items = [...first.items]
|
||||
const pages = Math.ceil(first.total / first.page_size)
|
||||
for (let page = 2; page <= pages; page += 1) {
|
||||
const next = await getDataProcessResults(taskId.value, { page, page_size: 500 })
|
||||
items.push(...next.items)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
async function runResultEvaluation() {
|
||||
if (evaluationRunning.value || !canEvaluate.value) return
|
||||
evaluationRunning.value = true
|
||||
Object.assign(evaluationProgress, {
|
||||
visible: true,
|
||||
total: 0,
|
||||
completed: 0,
|
||||
succeeded: 0,
|
||||
failed: 0,
|
||||
})
|
||||
try {
|
||||
const candidates = (await loadAllResultIds()).filter((item) => item.updated_at)
|
||||
if (!candidates.length) {
|
||||
ElMessage.info('当前没有可评测的结果')
|
||||
return
|
||||
}
|
||||
evaluationProgress.total = candidates.length
|
||||
for (let offset = 0; offset < candidates.length; offset += EVALUATION_CHUNK_SIZE) {
|
||||
const chunk = candidates.slice(offset, offset + EVALUATION_CHUNK_SIZE)
|
||||
try {
|
||||
const evaluated = await evaluateDataProcessResults(taskId.value, {
|
||||
items: chunk.map((item) => ({
|
||||
result_id: String(item.id),
|
||||
expected_updated_at: item.updated_at as string,
|
||||
})),
|
||||
})
|
||||
evaluationProgress.completed += evaluated.total
|
||||
evaluationProgress.succeeded += evaluated.succeeded
|
||||
evaluationProgress.failed += evaluated.failed
|
||||
} catch {
|
||||
evaluationProgress.completed = evaluationProgress.total
|
||||
evaluationProgress.failed += candidates.length - offset
|
||||
break
|
||||
}
|
||||
}
|
||||
await loadResults()
|
||||
if (evaluationProgress.failed === 0) {
|
||||
ElMessage.success(`数据评测完成:成功 ${evaluationProgress.succeeded} 条`)
|
||||
} else if (evaluationProgress.succeeded > 0) {
|
||||
ElMessage.warning(
|
||||
`数据评测完成:成功 ${evaluationProgress.succeeded} 条,失败 ${evaluationProgress.failed} 条`,
|
||||
)
|
||||
} else {
|
||||
ElMessage.error(`数据评测失败:${evaluationProgress.failed} 条结果未完成评测`)
|
||||
}
|
||||
} catch {
|
||||
ElMessage.error('数据评测中断,已完成的评分保持不变')
|
||||
} finally {
|
||||
evaluationRunning.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function replaceResult(updated: DataProcessResult) {
|
||||
@@ -824,10 +914,33 @@ onBeforeUnmount(() => {
|
||||
<el-option label="已修改" value="modified" />
|
||||
<el-option label="无效" value="invalid" />
|
||||
</el-select>
|
||||
<el-button
|
||||
v-if="canEvaluate"
|
||||
type="primary"
|
||||
plain
|
||||
:loading="evaluationRunning"
|
||||
:disabled="resultLoading"
|
||||
@click="runResultEvaluation"
|
||||
>
|
||||
<i v-if="!evaluationRunning" class="fa fa-check-square-o" aria-hidden="true" /> 数据评测
|
||||
</el-button>
|
||||
<el-button :loading="resultLoading" @click="loadResults"><i class="fa fa-refresh" /></el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="evaluationProgress.visible" class="evaluation-progress">
|
||||
<span>
|
||||
数据评测 {{ evaluationProgress.completed }} / {{ evaluationProgress.total }}
|
||||
· 成功 {{ evaluationProgress.succeeded }} · 失败 {{ evaluationProgress.failed }}
|
||||
</span>
|
||||
<el-progress
|
||||
:percentage="evaluationPercentage"
|
||||
:show-text="false"
|
||||
:stroke-width="5"
|
||||
:color="evaluationProgress.failed > 0 ? '#d97706' : '#5b50f2'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
v-if="results.length"
|
||||
:data="results"
|
||||
@@ -845,9 +958,26 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
<el-table-column label="质量分" width="88" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip :content="qualityFlagsLabel((row as DataProcessResult).quality_score)">
|
||||
<span>{{ qualityScoreLabel((row as DataProcessResult).quality_score) }}</span>
|
||||
</el-tooltip>
|
||||
<el-popover
|
||||
v-if="qualityScoreEvaluated((row as DataProcessResult).quality_score)"
|
||||
placement="top"
|
||||
:width="296"
|
||||
trigger="hover"
|
||||
:show-after="150"
|
||||
popper-class="quality-radar-popper"
|
||||
>
|
||||
<template #reference>
|
||||
<span
|
||||
class="detail-quality-score"
|
||||
:class="qualityScoreTone((row as DataProcessResult).quality_score)"
|
||||
>{{ qualityScoreLabel((row as DataProcessResult).quality_score) }}</span>
|
||||
</template>
|
||||
<QualityRadarPopover
|
||||
:quality="(row as DataProcessResult).quality_score!"
|
||||
:score="Number((row as DataProcessResult).quality_score?.overall)"
|
||||
/>
|
||||
</el-popover>
|
||||
<span v-else class="detail-quality-empty">{{ qualityScoreLabel((row as DataProcessResult).quality_score) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="90" align="center">
|
||||
@@ -1099,6 +1229,35 @@ onBeforeUnmount(() => {
|
||||
.result-filters :deep(.el-select) { width: 120px; }
|
||||
.result-section :deep(.el-table) { border-radius: 0; }
|
||||
|
||||
.evaluation-progress {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 220px;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 10px 18px;
|
||||
color: #667085;
|
||||
background: #f8f9fc;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.detail-quality-score {
|
||||
display: inline-block;
|
||||
min-width: 44px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
color: #475467;
|
||||
background: #f2f4f7;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
cursor: default;
|
||||
|
||||
&.is-success { color: #067647; background: #e6f4ee; }
|
||||
&.is-warning { color: #b54708; background: #fef0c7; }
|
||||
&.is-danger { color: #b42318; background: #fee4e2; }
|
||||
}
|
||||
|
||||
.detail-quality-empty { color: #98a2b3; }
|
||||
|
||||
:global(.data-process-result-tooltip) {
|
||||
box-sizing: border-box;
|
||||
max-width: min(520px, calc(100vw - 32px));
|
||||
|
||||
252
frontend/src/views/data-process/create/QualityRadarPopover.vue
Normal file
252
frontend/src/views/data-process/create/QualityRadarPopover.vue
Normal file
@@ -0,0 +1,252 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import VChart from 'vue-echarts'
|
||||
import '@/plugins/echarts'
|
||||
import type { EChartsOption } from 'echarts'
|
||||
import type { ResultQualityDetails } from './types'
|
||||
|
||||
const props = defineProps<{
|
||||
quality: ResultQualityDetails
|
||||
score?: number
|
||||
}>()
|
||||
|
||||
// 轴标签按词意预置断行,避免长中文标签把雷达网格挤偏或被机械切词。
|
||||
const JUDGE_DIMENSION_LABELS: Record<string, string> = {
|
||||
faithfulness: '忠实度',
|
||||
correctness: '正确性',
|
||||
clarity: '问题\n清晰度',
|
||||
completeness: '回答\n完整性',
|
||||
alignment: '指令\n对齐',
|
||||
reasoning_validity: '推理\n有效性',
|
||||
chosen_quality: 'chosen\n质量',
|
||||
rejected_quality: 'rejected\n质量',
|
||||
preference_reasonableness: '偏好\n区分',
|
||||
}
|
||||
|
||||
const SEMANTIC_DIMENSION_LABELS: Record<string, string> = {
|
||||
question_answer: '问答\n相关',
|
||||
answer_source: '来源\n覆盖',
|
||||
}
|
||||
|
||||
interface RadarDimension {
|
||||
name: string
|
||||
value: number
|
||||
}
|
||||
|
||||
const radarDimensions = computed<RadarDimension[]>(() => {
|
||||
const dimensions: RadarDimension[] = []
|
||||
for (const [key, value] of Object.entries(props.quality?.judge?.scores ?? {})) {
|
||||
dimensions.push({
|
||||
name: JUDGE_DIMENSION_LABELS[key] ?? key,
|
||||
value: Math.round(value * 20),
|
||||
})
|
||||
}
|
||||
for (const [key, value] of Object.entries(props.quality?.semantic ?? {})) {
|
||||
if (key === 'overall' || typeof value !== 'number') continue
|
||||
dimensions.push({
|
||||
name: SEMANTIC_DIMENSION_LABELS[key] ?? key,
|
||||
value: Math.round(value),
|
||||
})
|
||||
}
|
||||
return dimensions
|
||||
})
|
||||
|
||||
// 可用维度太少时雷达图失去意义,降级为分层分数展示。
|
||||
const showRadar = computed(() => radarDimensions.value.length >= 3)
|
||||
|
||||
const radarOption = computed<EChartsOption>(() => ({
|
||||
radar: {
|
||||
indicator: radarDimensions.value.map((dimension) => ({
|
||||
name: dimension.name,
|
||||
max: 100,
|
||||
})),
|
||||
radius: '56%',
|
||||
center: ['50%', '50%'],
|
||||
splitNumber: 4,
|
||||
axisName: {
|
||||
color: '#667085',
|
||||
fontSize: 10,
|
||||
lineHeight: 13,
|
||||
},
|
||||
splitArea: { areaStyle: { color: ['#fbfbfd', '#f2f4f8'] } },
|
||||
splitLine: { lineStyle: { color: '#e4e7ec' } },
|
||||
axisLine: { lineStyle: { color: '#e4e7ec' } },
|
||||
},
|
||||
series: [{
|
||||
type: 'radar',
|
||||
symbol: 'circle',
|
||||
symbolSize: 3,
|
||||
data: [{
|
||||
value: radarDimensions.value.map((dimension) => dimension.value),
|
||||
name: '质量维度',
|
||||
areaStyle: { color: 'rgba(91, 80, 242, 0.18)' },
|
||||
lineStyle: { color: '#5b50f2', width: 1.5 },
|
||||
itemStyle: { color: '#5b50f2' },
|
||||
}],
|
||||
}],
|
||||
}))
|
||||
|
||||
const layerScores = computed(() => {
|
||||
const layers = props.quality?.layers ?? {}
|
||||
return [
|
||||
{ label: '规则层', value: layers.rule },
|
||||
{ label: '语义层', value: layers.semantic },
|
||||
{ label: '评审层', value: layers.judge },
|
||||
].filter((layer): layer is { label: string; value: number } => (
|
||||
typeof layer.value === 'number'
|
||||
))
|
||||
})
|
||||
|
||||
const displayScore = computed(() => {
|
||||
if (typeof props.score === 'number' && !isNaN(props.score)) {
|
||||
return props.score.toFixed(1)
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const scoreTone = computed(() => {
|
||||
const numScore = props.score ?? 0
|
||||
return numScore >= 80 ? 'is-success' : numScore >= 60 ? 'is-warning' : 'is-danger'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="quality-popover">
|
||||
<div v-if="displayScore !== null" class="popover-header">
|
||||
<strong>质量评测</strong>
|
||||
<span class="popover-score" :class="scoreTone">{{ displayScore }}</span>
|
||||
</div>
|
||||
|
||||
<VChart
|
||||
v-if="showRadar"
|
||||
class="quality-radar"
|
||||
:option="radarOption"
|
||||
autoresize
|
||||
/>
|
||||
<div v-else class="radar-fallback">
|
||||
维度数据不足,已评测维度少于 3 个时以分层分数为准。
|
||||
</div>
|
||||
|
||||
<div class="layer-scores">
|
||||
<div v-for="layer in layerScores" :key="layer.label" class="layer-item">
|
||||
<span>{{ layer.label }}</span>
|
||||
<el-progress
|
||||
:percentage="Math.round(layer.value)"
|
||||
:stroke-width="6"
|
||||
:show-text="false"
|
||||
:color="layer.value >= 80 ? '#12b76a' : layer.value >= 60 ? '#f0b429' : '#d92d20'"
|
||||
/>
|
||||
<em>{{ layer.value.toFixed(0) }}</em>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="quality?.judge?.reason" class="judge-reason">{{ quality.judge.reason }}</p>
|
||||
<div v-if="quality?.judge?.issues?.length" class="judge-issues">
|
||||
<span v-for="issue in quality.judge.issues" :key="issue" class="issue-tag">{{ issue }}</span>
|
||||
</div>
|
||||
<div v-if="quality?.judge?.model" class="judge-model">评审模型:{{ quality.judge.model }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.quality-popover {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.popover-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px solid #f2f4f7;
|
||||
|
||||
strong {
|
||||
color: #344054;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.popover-score {
|
||||
color: #344054;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
||||
&.is-success { color: #12b76a; }
|
||||
&.is-warning { color: #d99b0b; }
|
||||
&.is-danger { color: #d92d20; }
|
||||
}
|
||||
|
||||
.quality-radar {
|
||||
width: 100%;
|
||||
height: 220px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.radar-fallback {
|
||||
padding: 18px 10px;
|
||||
color: #98a2b3;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.layer-scores {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.layer-item {
|
||||
display: grid;
|
||||
grid-template-columns: 44px 1fr 28px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #667085;
|
||||
font-size: 11px;
|
||||
|
||||
em {
|
||||
color: #344054;
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
.judge-reason {
|
||||
margin: 0;
|
||||
color: #475467;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.judge-issues {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.issue-tag {
|
||||
padding: 2px 8px;
|
||||
color: #b54708;
|
||||
background: #fef0c7;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.judge-model {
|
||||
color: #98a2b3;
|
||||
font-size: 11px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
.quality-radar-popper {
|
||||
padding: 14px 16px !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import type { BulkResultRegenerationState, PreviewItem, ResultItem } from './types'
|
||||
import QualityRadarPopover from './QualityRadarPopover.vue'
|
||||
import type { BulkResultRegenerationState, PreviewItem, ResultEvaluationState, ResultItem } from './types'
|
||||
import type { DataProcessOutputType } from '@/types/dataProcess'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -9,6 +10,7 @@ const props = defineProps<{
|
||||
selectedId: string | null
|
||||
regeneratingResultId: string | null
|
||||
bulkRegeneration: BulkResultRegenerationState
|
||||
evaluation: ResultEvaluationState
|
||||
outputType: DataProcessOutputType
|
||||
}>()
|
||||
|
||||
@@ -17,6 +19,7 @@ const emit = defineEmits<{
|
||||
'update:field': [id: string, field: 'instruction' | 'input' | 'output' | 'chosen' | 'rejected', value: string]
|
||||
'regenerate:item': [id: string]
|
||||
'regenerate:all': []
|
||||
'evaluate:all': []
|
||||
}>()
|
||||
|
||||
const search = ref('')
|
||||
@@ -30,6 +33,15 @@ const bulkRegenerationActive = computed(() => props.bulkRegeneration.status ===
|
||||
const bulkRegenerationVisible = computed(() => (
|
||||
props.bulkRegeneration.status !== 'idle' && props.bulkRegeneration.total > 0
|
||||
))
|
||||
const evaluationActive = computed(() => props.evaluation.status === 'running')
|
||||
const evaluationVisible = computed(() => (
|
||||
props.evaluation.status !== 'idle' && props.evaluation.total > 0
|
||||
))
|
||||
const evaluationPercentage = computed(() => {
|
||||
if (!props.evaluation.total) return 0
|
||||
return Math.round((props.evaluation.completed / props.evaluation.total) * 100)
|
||||
})
|
||||
const evaluatedCount = computed(() => props.items.filter((item) => item.qualityDetails?.evaluated).length)
|
||||
const bulkRegenerationPercentage = computed(() => (
|
||||
props.bulkRegeneration.total > 0
|
||||
? Math.round((props.bulkRegeneration.completed / props.bulkRegeneration.total) * 100)
|
||||
@@ -98,21 +110,47 @@ function selectRelative(offset: number) {
|
||||
<template>
|
||||
<section class="result-step">
|
||||
<div class="result-workspace">
|
||||
<aside class="result-list-pane" :class="{ 'has-bulk-progress': bulkRegenerationVisible }">
|
||||
<aside class="result-list-pane" :class="{ 'has-bulk-progress': bulkRegenerationVisible || evaluationVisible }">
|
||||
<div class="pane-header result-list-header">
|
||||
<div class="result-list-title"><strong>生成结果</strong><span>共 {{ items.length }} 条</span></div>
|
||||
<el-button
|
||||
v-if="invalidCount > 0"
|
||||
size="small"
|
||||
plain
|
||||
type="primary"
|
||||
:loading="bulkRegenerationActive"
|
||||
:disabled="Boolean(regeneratingResultId) || bulkRegenerationActive"
|
||||
@click="emit('regenerate:all')"
|
||||
>
|
||||
<i v-if="!bulkRegenerationActive" class="fa fa-refresh" style="margin-right: 4px;" />
|
||||
{{ bulkRegenerationActive ? '重新生成中' : `全部重新生成(${invalidCount})` }}
|
||||
</el-button>
|
||||
<div class="result-list-title">
|
||||
<strong>生成结果</strong><span>共 {{ items.length }} 条<template v-if="evaluatedCount"> · 已评测 {{ evaluatedCount }}</template></span>
|
||||
</div>
|
||||
<div class="result-list-actions">
|
||||
<el-button
|
||||
size="small"
|
||||
plain
|
||||
:loading="evaluationActive"
|
||||
:disabled="!items.length || bulkRegenerationActive || Boolean(regeneratingResultId)"
|
||||
@click="emit('evaluate:all')"
|
||||
>
|
||||
<i v-if="!evaluationActive" class="fa fa-check-square-o" style="margin-right: 4px;" />
|
||||
数据评测
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="invalidCount > 0"
|
||||
size="small"
|
||||
plain
|
||||
type="primary"
|
||||
:loading="bulkRegenerationActive"
|
||||
:disabled="Boolean(regeneratingResultId) || bulkRegenerationActive || evaluationActive"
|
||||
@click="emit('regenerate:all')"
|
||||
>
|
||||
<i v-if="!bulkRegenerationActive" class="fa fa-refresh" style="margin-right: 4px;" />
|
||||
{{ bulkRegenerationActive ? '重新生成中' : `全部重新生成(${invalidCount})` }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="evaluationVisible" class="bulk-regeneration-progress">
|
||||
<div>
|
||||
<span>数据评测 {{ evaluation.completed }} / {{ evaluation.total }}</span>
|
||||
<span>成功 {{ evaluation.succeeded }} · 失败 {{ evaluation.failed }}</span>
|
||||
</div>
|
||||
<el-progress
|
||||
:percentage="evaluationPercentage"
|
||||
:show-text="false"
|
||||
:stroke-width="5"
|
||||
:color="evaluation.failed > 0 ? '#d97706' : '#5b50f2'"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="bulkRegenerationVisible" class="bulk-regeneration-progress">
|
||||
<div>
|
||||
@@ -146,6 +184,23 @@ function selectRelative(offset: number) {
|
||||
<strong>{{ item.instruction || '未填写指令' }}</strong>
|
||||
<small>{{ outputType === 'dpo' ? (item.chosen || '未填写 Chosen') : (item.output || '未填写输出') }}</small>
|
||||
</span>
|
||||
<el-popover
|
||||
v-if="item.qualityScore != null && item.qualityDetails"
|
||||
placement="right"
|
||||
:width="296"
|
||||
trigger="hover"
|
||||
:show-after="150"
|
||||
popper-class="quality-radar-popper"
|
||||
>
|
||||
<template #reference>
|
||||
<span
|
||||
class="result-score"
|
||||
:class="item.qualityScore >= 80 ? 'is-success' : item.qualityScore >= 60 ? 'is-warning' : 'is-danger'"
|
||||
@click.stop
|
||||
>{{ item.qualityScore.toFixed(0) }}</span>
|
||||
</template>
|
||||
<QualityRadarPopover :quality="item.qualityDetails" :score="item.qualityScore" />
|
||||
</el-popover>
|
||||
<i v-if="itemRegenerating(item.id)" class="css-spinner" />
|
||||
<i
|
||||
v-else
|
||||
@@ -303,6 +358,42 @@ function selectRelative(offset: number) {
|
||||
}
|
||||
}
|
||||
|
||||
.result-list-actions {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.result-score {
|
||||
flex: none;
|
||||
min-width: 34px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
color: #475467;
|
||||
background: #f2f4f7;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: center;
|
||||
cursor: default;
|
||||
|
||||
&.is-success {
|
||||
color: #067647;
|
||||
background: #e6f4ee;
|
||||
}
|
||||
|
||||
&.is-warning {
|
||||
color: #b54708;
|
||||
background: #fef0c7;
|
||||
}
|
||||
|
||||
&.is-danger {
|
||||
color: #b42318;
|
||||
background: #fee4e2;
|
||||
}
|
||||
}
|
||||
|
||||
.pane-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type {
|
||||
DataProcessOutputType,
|
||||
DataProcessPreviewFileStatus,
|
||||
DataProcessQualityJudge,
|
||||
DataProcessQualityLayers,
|
||||
DataProcessQualitySemantic,
|
||||
DataProcessReasoningDetail,
|
||||
} from '@/types/dataProcess'
|
||||
|
||||
@@ -165,6 +168,14 @@ export interface GenerationState {
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface ResultQualityDetails {
|
||||
semantic?: DataProcessQualitySemantic | null
|
||||
judge?: DataProcessQualityJudge | null
|
||||
layers?: DataProcessQualityLayers | null
|
||||
evaluated?: boolean
|
||||
flags?: string[]
|
||||
}
|
||||
|
||||
export interface ResultItem {
|
||||
id: string
|
||||
previewItemId: string | null
|
||||
@@ -188,7 +199,7 @@ export interface ResultItem {
|
||||
error?: string
|
||||
split?: 'train' | 'validation' | 'test'
|
||||
qualityScore?: number
|
||||
qualityDetails?: Record<string, number>
|
||||
qualityDetails?: ResultQualityDetails
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
@@ -201,3 +212,11 @@ export interface BulkResultRegenerationState {
|
||||
targetIds: string[]
|
||||
failedIds: string[]
|
||||
}
|
||||
|
||||
export interface ResultEvaluationState {
|
||||
status: 'idle' | 'running' | 'completed' | 'partial' | 'failed'
|
||||
total: number
|
||||
completed: number
|
||||
succeeded: number
|
||||
failed: number
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { computed, reactive, type Ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { evaluateDataProcessResults } from '@/api/modules/dataProcess'
|
||||
import { mapResult } from './useDataProcessGeneration'
|
||||
import type { ResultEvaluationState, ResultItem } from './types'
|
||||
|
||||
interface EvaluationBindings {
|
||||
taskId: Ref<string | null>
|
||||
results: Ref<ResultItem[]>
|
||||
selectedResultId: Ref<string | null>
|
||||
}
|
||||
|
||||
// 与批量重新生成一致的分块大小:4 个后端 worker 消费三轮,
|
||||
// 单条评测最长 60 秒,12 条在批量接口 240 秒超时预算内。
|
||||
const EVALUATION_CHUNK_SIZE = 12
|
||||
|
||||
function hasUnsavedChanges(item: ResultItem) {
|
||||
return item.instruction !== item.savedInstruction
|
||||
|| item.input !== item.savedInput
|
||||
|| item.output !== item.savedOutput
|
||||
|| item.chosen !== item.savedChosen
|
||||
|| item.rejected !== item.savedRejected
|
||||
}
|
||||
|
||||
export function useDataProcessEvaluation(bindings: EvaluationBindings) {
|
||||
const evaluation = reactive<ResultEvaluationState>({
|
||||
status: 'idle',
|
||||
total: 0,
|
||||
completed: 0,
|
||||
succeeded: 0,
|
||||
failed: 0,
|
||||
})
|
||||
const evaluationBusy = computed(() => evaluation.status === 'running')
|
||||
|
||||
function resetEvaluation() {
|
||||
Object.assign(evaluation, {
|
||||
status: 'idle',
|
||||
total: 0,
|
||||
completed: 0,
|
||||
succeeded: 0,
|
||||
failed: 0,
|
||||
})
|
||||
}
|
||||
|
||||
async function evaluateAllResults() {
|
||||
const taskId = bindings.taskId.value
|
||||
if (!taskId) return false
|
||||
if (evaluationBusy.value) {
|
||||
ElMessage.warning('请等待当前数据评测完成')
|
||||
return false
|
||||
}
|
||||
|
||||
const candidates = bindings.results.value.filter((item) => item.updatedAt)
|
||||
if (!candidates.length) {
|
||||
ElMessage.info('当前没有可评测的结果')
|
||||
return false
|
||||
}
|
||||
const unsaved = candidates.find(hasUnsavedChanges)
|
||||
if (unsaved) {
|
||||
bindings.selectedResultId.value = unsaved.id
|
||||
ElMessage.warning('存在未保存的修改,请先保存后再进行数据评测')
|
||||
return false
|
||||
}
|
||||
|
||||
Object.assign(evaluation, {
|
||||
status: 'running',
|
||||
total: candidates.length,
|
||||
completed: 0,
|
||||
succeeded: 0,
|
||||
failed: 0,
|
||||
})
|
||||
|
||||
let interrupted = false
|
||||
try {
|
||||
for (let offset = 0; offset < candidates.length; offset += EVALUATION_CHUNK_SIZE) {
|
||||
const chunk = candidates.slice(offset, offset + EVALUATION_CHUNK_SIZE)
|
||||
try {
|
||||
const evaluated = await evaluateDataProcessResults(taskId, {
|
||||
items: chunk.map((item) => ({
|
||||
result_id: item.id,
|
||||
expected_updated_at: item.updatedAt as string,
|
||||
})),
|
||||
})
|
||||
for (const item of evaluated.items) {
|
||||
const index = bindings.results.value.findIndex((entry) => entry.id === String(item.id))
|
||||
if (index >= 0) bindings.results.value[index] = mapResult(item)
|
||||
}
|
||||
evaluation.completed += evaluated.total
|
||||
evaluation.succeeded += evaluated.succeeded
|
||||
evaluation.failed += evaluated.failed
|
||||
} catch {
|
||||
const remaining = candidates.slice(offset)
|
||||
evaluation.completed = evaluation.total
|
||||
evaluation.failed += remaining.length
|
||||
interrupted = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!interrupted && evaluation.failed === 0) {
|
||||
evaluation.status = 'completed'
|
||||
ElMessage.success(`数据评测完成:成功 ${evaluation.succeeded} 条`)
|
||||
} else if (evaluation.succeeded > 0) {
|
||||
evaluation.status = 'partial'
|
||||
ElMessage.warning(
|
||||
`数据评测完成:成功 ${evaluation.succeeded} 条,失败 ${evaluation.failed} 条`,
|
||||
)
|
||||
} else {
|
||||
evaluation.status = 'failed'
|
||||
ElMessage.error(`数据评测失败:${evaluation.failed} 条结果未完成评测`)
|
||||
}
|
||||
return evaluation.failed === 0
|
||||
} catch {
|
||||
evaluation.status = 'failed'
|
||||
ElMessage.error('批量数据评测意外中断,已完成的评分保持不变')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
evaluation,
|
||||
evaluationBusy,
|
||||
evaluateAllResults,
|
||||
resetEvaluation,
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ const POLL_INTERVAL_MS = 1500
|
||||
const BULK_REGENERATION_CHUNK_SIZE = 12
|
||||
|
||||
function mapResult(item: DataProcessResult): ResultItem {
|
||||
const quality = item.quality_score
|
||||
return {
|
||||
id: String(item.id),
|
||||
previewItemId: item.preview_item_id == null ? null : String(item.preview_item_id),
|
||||
@@ -50,11 +51,21 @@ function mapResult(item: DataProcessResult): ResultItem {
|
||||
status: item.status,
|
||||
error: item.error || undefined,
|
||||
split: item.split || undefined,
|
||||
qualityScore: item.quality_score?.overall,
|
||||
// 生成阶段只有内部规则分,界面不展示;数据评测完成后才显示组合分。
|
||||
qualityScore: quality?.evaluated ? quality.overall : undefined,
|
||||
qualityDetails: {
|
||||
semantic: quality?.semantic ?? null,
|
||||
judge: quality?.judge ?? null,
|
||||
layers: quality?.layers ?? null,
|
||||
evaluated: Boolean(quality?.evaluated),
|
||||
flags: quality?.flags ?? [],
|
||||
},
|
||||
updatedAt: item.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
export { mapResult }
|
||||
|
||||
export function useDataProcessGeneration(bindings: GenerationBindings) {
|
||||
const results = ref<ResultItem[]>([])
|
||||
const selectedResultId = ref<string | null>(null)
|
||||
|
||||
@@ -39,7 +39,7 @@ const createdDimensionId = ref<string | number>('')
|
||||
const taskForm = ref<EvalTaskSetupDraft>({
|
||||
eval_task_name: '',
|
||||
model_id: '',
|
||||
gpu_id: '',
|
||||
gpu_id: [],
|
||||
data_source: 'dataset',
|
||||
dataset_id: '',
|
||||
leaderboard: false,
|
||||
@@ -145,12 +145,24 @@ async function handleSubmit() {
|
||||
const dimensionId = await resolveDimensionId()
|
||||
// GPU 选择为「节点:GPU序号」复合值,解析出节点与 GPU 序号,
|
||||
// 多算力节点时必须把节点信息传给后端,否则会派发到错误的算力节点
|
||||
const [gpuNodeId, gpuIndex] = String(taskForm.value.gpu_id).split(':')
|
||||
const selectedGpuKeys = Array.isArray(taskForm.value.gpu_id)
|
||||
? taskForm.value.gpu_id
|
||||
: [String(taskForm.value.gpu_id)]
|
||||
const gpuSelections = selectedGpuKeys
|
||||
.map((key) => {
|
||||
const [nodeId, gpuIndex] = String(key).split(':')
|
||||
return { nodeId, gpuIndex: Number(gpuIndex) }
|
||||
})
|
||||
.filter((item) => item.nodeId && Number.isInteger(item.gpuIndex) && item.gpuIndex >= 0)
|
||||
const gpuNodeId = gpuSelections[0]?.nodeId || ''
|
||||
const gpuIndices = gpuSelections.map((item) => item.gpuIndex)
|
||||
const evalResult: any = await startEval({
|
||||
eval_task_name: taskForm.value.eval_task_name,
|
||||
eval_type: 'custom',
|
||||
model_id: taskForm.value.model_id,
|
||||
gpu_id: Number(gpuIndex) || 0,
|
||||
gpu_id: gpuIndices[0] ?? 0,
|
||||
gpu_indices: gpuIndices,
|
||||
gpus: gpuIndices,
|
||||
compute_node_id: gpuNodeId || '',
|
||||
dataset_id: taskForm.value.data_source === 'dataset' ? taskForm.value.dataset_id : '',
|
||||
dimension_id: dimensionId,
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { DatasetItem, GpuInfo, TrainedModel } from '@/types'
|
||||
export interface EvalTaskSetupDraft {
|
||||
eval_task_name: string
|
||||
model_id: string | number
|
||||
gpu_id: string | number
|
||||
gpu_id: string | number | string[]
|
||||
data_source: 'dataset' | 'inference'
|
||||
dataset_id: string | number
|
||||
leaderboard: boolean
|
||||
@@ -23,6 +23,13 @@ defineProps<{
|
||||
const form = defineModel<EvalTaskSetupDraft>({ required: true })
|
||||
const formRef = ref<FormInstance>()
|
||||
|
||||
function handleGpuChange(value: string | number | string[]) {
|
||||
const keys = Array.isArray(value) ? value.map(String) : [String(value || '')]
|
||||
const nodeId = keys[0]?.split(':', 1)[0]
|
||||
if (!nodeId || !Array.isArray(form.value.gpu_id)) return
|
||||
form.value.gpu_id = keys.filter((key) => key.split(':', 1)[0] === nodeId)
|
||||
}
|
||||
|
||||
const rules: FormRules<EvalTaskSetupDraft> = {
|
||||
eval_task_name: [
|
||||
{ required: true, message: '请输入任务名称', trigger: 'blur' },
|
||||
@@ -101,7 +108,16 @@ defineExpose({ validate })
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="选择 GPU" prop="gpu_id">
|
||||
<el-select v-model="form.gpu_id" placeholder="请选择 GPU" style="width: 100%" :loading="loading">
|
||||
<el-select
|
||||
v-model="form.gpu_id"
|
||||
multiple
|
||||
collapse-tags
|
||||
collapse-tags-tooltip
|
||||
placeholder="请选择同一算力节点内的一张或多张 GPU"
|
||||
style="width: 100%"
|
||||
:loading="loading"
|
||||
@change="handleGpuChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="gpu in gpus"
|
||||
:key="`${gpu.node_id || ''}:${gpu.id ?? 0}`"
|
||||
|
||||
@@ -21,7 +21,7 @@ function nameOf<T extends { id: string | number; name?: string }>(items: T[], id
|
||||
|
||||
/** GPU 选择为「节点:GPU序号」复合值,解析并展示为可读标签 */
|
||||
const gpuLabel = computed(() => {
|
||||
const key = String(props.task.gpu_id || '')
|
||||
const key = Array.isArray(props.task.gpu_id) ? String(props.task.gpu_id[0] || '') : String(props.task.gpu_id || '')
|
||||
const gpu = props.gpus.find((g) => `${g.node_id || ''}:${g.id ?? 0}` === key)
|
||||
if (gpu) return `${gpu.node_name || gpu.node_code || '算力节点'} / GPU ${gpu.id ?? 0}`
|
||||
const [nodeId, idx] = key.split(':')
|
||||
|
||||
68
frontend/src/views/governance/OrganizationPermissionView.vue
Normal file
68
frontend/src/views/governance/OrganizationPermissionView.vue
Normal file
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import UserSettingsView from '@/views/system/UserSettingsView.vue'
|
||||
import TenantListView from '@/views/tenants/TenantListView.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const activeTab = computed({
|
||||
get: () => route.query.tab === 'tenants' ? 'tenants' : 'users',
|
||||
set: (value: string) => {
|
||||
void router.replace({ query: { tab: value === 'tenants' ? 'tenants' : 'users' } })
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="organization-page">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h2>组织与权限</h2>
|
||||
<p>统一管理平台用户、角色、租户和资源配额。</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane label="用户与角色" name="users">
|
||||
<UserSettingsView v-if="activeTab === 'users'" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="租户与配额" name="tenants">
|
||||
<TenantListView v-if="activeTab === 'tenants'" />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.organization-page {
|
||||
min-height: 100%;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 4px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
color: #1f2937;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 6px 0 0;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.user-settings),
|
||||
:deep(.page) {
|
||||
padding: 16px 0 0;
|
||||
}
|
||||
|
||||
:deep(.user-settings .page-header > div:first-child) {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
@@ -4,8 +4,7 @@ import { useRouter } from 'vue-router'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import PageCard from '@/components/PageCard.vue'
|
||||
import { getModelList, getTrainedModels } from '@/api/modules/model'
|
||||
import { getSystemInfo } from '@/api/modules/system'
|
||||
import { getComputeNodes, type ComputeNode } from '@/api/modules/compute'
|
||||
import { getComputeGpus, getComputeNodes, type ComputeNode } from '@/api/modules/compute'
|
||||
import { createCompare, loadCompare } from '@/api/modules/compare'
|
||||
import type { ModelItem, TrainedModel, GpuInfo } from '@/types'
|
||||
|
||||
@@ -83,8 +82,8 @@ const form = reactive({
|
||||
description: '',
|
||||
/** 选中的模型 key(单选) */
|
||||
model_key: '',
|
||||
/** 使用的 GPU */
|
||||
gpu_key: '',
|
||||
/** 使用的 GPU(同一节点内可多选) */
|
||||
gpu_keys: [] as string[],
|
||||
})
|
||||
|
||||
const rules: FormRules = {
|
||||
@@ -94,12 +93,26 @@ const rules: FormRules = {
|
||||
|
||||
/** 当前选中的模型对象 */
|
||||
const selectedModel = computed(() => modelMap.value[form.model_key])
|
||||
const selectedGpu = computed(() => idleGpus.value.find((g) => `${g.node_id || ''}:${g.id ?? 0}` === form.gpu_key))
|
||||
const selectedGpus = computed(() => idleGpus.value.filter((gpu) => form.gpu_keys.includes(gpuKey(gpu))))
|
||||
|
||||
function gpuKey(gpu: GpuInfo) {
|
||||
return `${gpu.node_id || ''}:${gpu.id ?? 0}`
|
||||
}
|
||||
|
||||
function handleGpuChange(keys: string[]) {
|
||||
const nodeId = keys[0]?.split(':', 1)[0]
|
||||
if (!nodeId) return
|
||||
const filtered = keys.filter((key) => key.split(':', 1)[0] === nodeId)
|
||||
if (filtered.length !== keys.length) {
|
||||
ElMessage.info('一次推理只能使用同一算力节点内的 GPU,已忽略其它节点的选择')
|
||||
}
|
||||
form.gpu_keys = filtered
|
||||
}
|
||||
|
||||
watch(selectedModel, (model) => {
|
||||
if (!model?.compute_node_id) return
|
||||
const gpu = idleGpus.value.find((item) => item.node_id === model.compute_node_id)
|
||||
if (gpu) form.gpu_key = `${gpu.node_id || ''}:${gpu.id ?? 0}`
|
||||
if (gpu) form.gpu_keys = [gpuKey(gpu)]
|
||||
})
|
||||
|
||||
async function handleSubmit() {
|
||||
@@ -111,6 +124,10 @@ async function handleSubmit() {
|
||||
ElMessage.warning('请选择模型')
|
||||
return
|
||||
}
|
||||
if (!selectedGpus.value.length) {
|
||||
ElMessage.warning('请至少选择一张空闲 GPU')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
startupStatus.value = '正在创建推理任务...'
|
||||
try {
|
||||
@@ -130,9 +147,11 @@ async function handleSubmit() {
|
||||
model_name: m.name,
|
||||
model_path: m.model_path,
|
||||
source: m.source,
|
||||
gpu_id: selectedGpu.value?.id ?? 0,
|
||||
node_id: selectedGpu.value?.node_id || m.compute_node_id,
|
||||
node_name: selectedGpu.value?.node_name || m.compute_node_name,
|
||||
gpu_id: selectedGpus.value[0]?.id ?? 0,
|
||||
gpu_indices: selectedGpus.value.map((gpu) => Number(gpu.id ?? 0)),
|
||||
gpus: selectedGpus.value.map((gpu) => Number(gpu.id ?? 0)),
|
||||
node_id: selectedGpus.value[0]?.node_id || m.compute_node_id,
|
||||
node_name: selectedGpus.value[0]?.node_name || m.compute_node_name,
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -169,17 +188,17 @@ async function loadData() {
|
||||
const [db, trained, sys, nodes] = await Promise.all([
|
||||
getModelList(),
|
||||
getTrainedModels(),
|
||||
getSystemInfo(),
|
||||
getComputeGpus(),
|
||||
getComputeNodes(),
|
||||
])
|
||||
dbModels.value = db || []
|
||||
trainedModels.value = trained?.models || []
|
||||
gpus.value = sys?.gpu || []
|
||||
gpus.value = (sys || []) as unknown as GpuInfo[]
|
||||
computeNodes.value = nodes || []
|
||||
// 默认选中第一个空闲 GPU
|
||||
if (idleGpus.value.length > 0) {
|
||||
const firstGpu = idleGpus.value[0]
|
||||
form.gpu_key = `${firstGpu.node_id || ''}:${firstGpu.id ?? 0}`
|
||||
form.gpu_keys = [gpuKey(firstGpu)]
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
@@ -228,12 +247,20 @@ onMounted(loadData)
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="GPU">
|
||||
<el-select v-model="form.gpu_key" style="width: 400px">
|
||||
<el-select
|
||||
v-model="form.gpu_keys"
|
||||
multiple
|
||||
collapse-tags
|
||||
collapse-tags-tooltip
|
||||
style="width: 400px"
|
||||
placeholder="请选择同一算力节点内的一张或多张 GPU"
|
||||
@change="handleGpuChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="g in idleGpus"
|
||||
:key="`${g.node_id || ''}:${g.id ?? 0}`"
|
||||
:key="gpuKey(g)"
|
||||
:label="`${g.node_name || g.node_code || '算力节点'} / ${g.name} (GPU${g.id ?? 0}) [空闲]`"
|
||||
:value="`${g.node_id || ''}:${g.id ?? 0}`"
|
||||
:value="gpuKey(g)"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
78
frontend/src/views/system/RuntimeLogsView.vue
Normal file
78
frontend/src/views/system/RuntimeLogsView.vue
Normal file
@@ -0,0 +1,78 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import LogsView from './LogsView.vue'
|
||||
import AuditLogView from '@/views/audit/AuditLogView.vue'
|
||||
import OperationLogView from '@/views/audit/OperationLogView.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const activeTab = computed({
|
||||
get: () => {
|
||||
if (!auth.isAdmin) return 'runtime'
|
||||
if (route.query.tab === 'audit') return 'audit'
|
||||
if (route.query.tab === 'operations') return 'operations'
|
||||
return 'runtime'
|
||||
},
|
||||
set: (value: string) => {
|
||||
void router.replace({ query: value === 'runtime' ? {} : { tab: value } })
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="runtime-logs">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h2>运行日志</h2>
|
||||
<p>查看系统运行、训练任务、审计记录和操作诊断信息。</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane label="运行日志" name="runtime">
|
||||
<LogsView v-if="activeTab === 'runtime'" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane v-if="auth.isAdmin" label="审计记录" name="audit">
|
||||
<AuditLogView v-if="activeTab === 'audit'" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane v-if="auth.isAdmin" label="操作诊断" name="operations">
|
||||
<OperationLogView v-if="activeTab === 'operations'" />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.runtime-logs {
|
||||
min-height: 100%;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 4px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
color: #1f2937;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 6px 0 0;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.page) {
|
||||
padding: 16px 0 0;
|
||||
}
|
||||
|
||||
:deep(.page-header .page-title) {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
@@ -8,10 +8,9 @@ import TrainingTaskOverview from './training-log/TrainingTaskOverview.vue'
|
||||
import { usePolling } from '@/composables/usePolling'
|
||||
import '@/plugins/echarts-training-log'
|
||||
import { useModelsStore } from '@/stores/models'
|
||||
import { getFineTune, getFineTuneDiagnostics, getFineTuneLogs, getFineTuneMetrics, type TrainingDiagnostic } from '@/api/modules/fineTune'
|
||||
import { getFineTune, getFineTuneDiagnostics, getFineTuneGpuStatus, getFineTuneLogs, getFineTuneMetrics, type TrainingDiagnostic } from '@/api/modules/fineTune'
|
||||
import { getTrainingLogFiles, getTrainingLogContent } from '@/api/modules/log'
|
||||
import { getDataset } from '@/api/modules/dataset'
|
||||
import { getSystemInfo } from '@/api/modules/system'
|
||||
import { TRAIN_TYPE_MAP, TRAIN_METHOD_MAP } from '@/constants'
|
||||
import {
|
||||
buildMetricChartOption,
|
||||
@@ -205,15 +204,20 @@ async function loadDataset(datasetId: string | number) {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadGpuStatus() {
|
||||
async function loadGpuStatus(currentTask: FineTuneTask) {
|
||||
try {
|
||||
const systemInfo = await getSystemInfo()
|
||||
gpuPool.value = systemInfo.gpu ?? []
|
||||
gpuUpdatedAt.value = new Date()
|
||||
gpuLoadError.value = ''
|
||||
const live = await getFineTuneGpuStatus(currentTask.id)
|
||||
if (live.source === 'compute' && live.items.length) {
|
||||
gpuPool.value = live.items as unknown as GpuInfo[]
|
||||
gpuUpdatedAt.value = new Date()
|
||||
gpuLoadError.value = ''
|
||||
return
|
||||
}
|
||||
gpuPool.value = []
|
||||
gpuLoadError.value = live.error || 'Compute 节点暂未返回实时 GPU 指标'
|
||||
} catch {
|
||||
gpuLoadError.value = 'GPU 监控数据暂时不可用'
|
||||
if (!gpuUpdatedAt.value) gpuPool.value = []
|
||||
gpuPool.value = []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,7 +310,7 @@ async function refreshAll() {
|
||||
const datasetPromise = currentTask.train_dataset_id
|
||||
? loadDataset(currentTask.train_dataset_id)
|
||||
: Promise.resolve()
|
||||
await Promise.all([datasetPromise, loadLog(currentTask), loadGpuStatus(), loadDiagnostics(currentTask)])
|
||||
await Promise.all([datasetPromise, loadLog(currentTask), loadGpuStatus(currentTask), loadDiagnostics(currentTask)])
|
||||
await loadMetrics(currentTask)
|
||||
} finally {
|
||||
loading.value = false
|
||||
|
||||
@@ -12,7 +12,7 @@ const form = reactive<CreateUserPayload>({
|
||||
username: '',
|
||||
display_name: '',
|
||||
password: 'platform123',
|
||||
role: 'user',
|
||||
role: 'operator',
|
||||
status: 'active',
|
||||
permissions: [],
|
||||
})
|
||||
@@ -45,7 +45,7 @@ async function submit() {
|
||||
<el-form-item label="角色">
|
||||
<el-select v-model="form.role" style="width: 100%">
|
||||
<el-option label="管理员" value="admin" />
|
||||
<el-option label="普通用户" value="user" />
|
||||
<el-option label="普通用户" value="operator" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
|
||||
Reference in New Issue
Block a user