merge: 合并远程 ft_wyt 分支,解决冲突

This commit is contained in:
wangjiming
2026-08-19 17:39:18 +08:00
64 changed files with 4616 additions and 375 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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 })

View File

@@ -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过滤逻辑

View File

@@ -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,
])

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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>

View File

@@ -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" />

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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>

View File

@@ -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));

View 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>

View File

@@ -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;

View File

@@ -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
}

View File

@@ -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,
}
}

View File

@@ -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)

View File

@@ -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,

View File

@@ -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}`"

View File

@@ -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(':')

View 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>

View File

@@ -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>

View 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>

View File

@@ -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

View File

@@ -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="状态">