Merge branch 'ft_wyt' of http://www.caoxiaozhu.com:13001/YG-Soft/YG_FT into ft_wyt
# Conflicts: # backend/app/api/v1/endpoints/platform.py # compute/requirements.txt
This commit is contained in:
@@ -1,9 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { routeLoading } from '@/router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { SESSION_TIMEOUT } from '@/constants'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
/**
|
||||
* 离开页面超时:
|
||||
* - 标签页切走/最小化(document.hidden)时记录时间
|
||||
* - 切回来时若超过 SESSION_TIMEOUT(5分钟),强制跳登录
|
||||
* - 不管是否在操作,只要离开页面超过 5 分钟就跳
|
||||
*/
|
||||
let hiddenAt = 0
|
||||
|
||||
function handleVisibility() {
|
||||
if (document.hidden) {
|
||||
hiddenAt = Date.now()
|
||||
} else {
|
||||
if (hiddenAt > 0 && Date.now() - hiddenAt >= SESSION_TIMEOUT) {
|
||||
auth.logout()
|
||||
ElMessage.warning('登录已过期,请重新登录')
|
||||
router.push('/login')
|
||||
}
|
||||
hiddenAt = 0
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('visibilitychange', handleVisibility)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('visibilitychange', handleVisibility)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-config-provider :locale="zhCn">
|
||||
<router-view />
|
||||
<div v-loading="routeLoading" element-loading-text="加载中..." class="app-root">
|
||||
<router-view />
|
||||
</div>
|
||||
</el-config-provider>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
.app-root {
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
||||
|
||||
15
frontend/src/api/modules/acl.ts
Normal file
15
frontend/src/api/modules/acl.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { get, put } from '../request'
|
||||
|
||||
export interface AclEntry {
|
||||
subject_type: string
|
||||
subject_id: string
|
||||
permissions: string[]
|
||||
}
|
||||
|
||||
/** 资源 ACL 查询 */
|
||||
export const getAcl = (resourceType: string, resourceId: string) =>
|
||||
get<AclEntry[]>(`/resources/${resourceType}/${resourceId}/acl`)
|
||||
|
||||
/** 资源 ACL 设置 */
|
||||
export const setAcl = (resourceType: string, resourceId: string, entries: AclEntry[]) =>
|
||||
put<AclEntry[]>(`/resources/${resourceType}/${resourceId}/acl`, { entries })
|
||||
50
frontend/src/api/modules/approval.ts
Normal file
50
frontend/src/api/modules/approval.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { get, post } from '../request'
|
||||
|
||||
export interface ApprovalStep {
|
||||
approver_id?: string | null
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface ApprovalTemplate {
|
||||
id: string
|
||||
name: string
|
||||
steps: ApprovalStep[]
|
||||
create_time?: string
|
||||
}
|
||||
|
||||
export interface ApprovalInstance {
|
||||
id: string
|
||||
template_id?: string | null
|
||||
resource_type: string
|
||||
resource_id: string
|
||||
applicant_id: string
|
||||
status: string
|
||||
current_step: number
|
||||
create_time?: string
|
||||
steps: Array<ApprovalStep & { step_index: number; comment?: string | null; time?: string | null }>
|
||||
}
|
||||
|
||||
export const getApprovalTemplates = () =>
|
||||
get<ApprovalTemplate[]>('/approvals/templates')
|
||||
|
||||
export const createApprovalTemplate = (payload: { name: string; steps: ApprovalStep[] }) =>
|
||||
post<ApprovalTemplate>('/approvals/templates', payload)
|
||||
|
||||
export const getApprovalInstances = (status?: string) =>
|
||||
get<ApprovalInstance[]>('/approvals', { status })
|
||||
|
||||
export const createApprovalInstance = (payload: {
|
||||
template_id?: string
|
||||
resource_type: string
|
||||
resource_id: string
|
||||
applicant_id: string
|
||||
}) => post<ApprovalInstance>('/approvals', payload)
|
||||
|
||||
export const getApprovalInstance = (id: string) =>
|
||||
get<ApprovalInstance>(`/approvals/${id}`)
|
||||
|
||||
export const decideApproval = (
|
||||
id: string,
|
||||
step_index: number,
|
||||
payload: { approver_id: string; approved: boolean; comment?: string },
|
||||
) => post<ApprovalInstance>(`/approvals/${id}/steps/${step_index}/decision`, payload)
|
||||
40
frontend/src/api/modules/audit.ts
Normal file
40
frontend/src/api/modules/audit.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { get } from '../request'
|
||||
import request from '../request'
|
||||
|
||||
export interface AuditLog {
|
||||
id: string
|
||||
tenant_id?: string
|
||||
project_id?: string
|
||||
actor_id?: string
|
||||
action?: string
|
||||
target_type?: string
|
||||
target_id?: string
|
||||
detail?: string
|
||||
client_ip?: string
|
||||
time?: string
|
||||
}
|
||||
|
||||
export interface AuditQuery {
|
||||
tenant_id?: string
|
||||
project_id?: string
|
||||
actor_id?: string
|
||||
action?: string
|
||||
target_type?: string
|
||||
start_time?: string
|
||||
end_time?: string
|
||||
limit?: number
|
||||
offset?: number
|
||||
}
|
||||
|
||||
/** 审计日志查询:使用 get 辅助函数,拦截器已解包,直接返回 { items, total } */
|
||||
export const getAuditLogs = (query: AuditQuery = {}) =>
|
||||
get<{ items: AuditLog[]; total: number }>('/system/audit-logs', query)
|
||||
|
||||
/** 审计日志导出 CSV:blob 响应走完整 axios response,需手动取 data */
|
||||
export const exportAuditLogs = (query: AuditQuery = {}) =>
|
||||
request<Blob>({
|
||||
url: '/system/audit-logs/export',
|
||||
method: 'get',
|
||||
params: query,
|
||||
responseType: 'blob',
|
||||
}).then((res) => res.data)
|
||||
35
frontend/src/api/modules/dashboard.ts
Normal file
35
frontend/src/api/modules/dashboard.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { get } from '../request'
|
||||
|
||||
export interface ServiceStatusStat {
|
||||
type: string
|
||||
status: 'normal' | 'busy' | 'error'
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface TrainingTaskStat {
|
||||
id: string
|
||||
name: string
|
||||
status: string
|
||||
train_type: string
|
||||
train_method: string
|
||||
base_model: string
|
||||
progress: number
|
||||
accuracy: number | null
|
||||
started_at: string
|
||||
}
|
||||
|
||||
export interface DashboardStats {
|
||||
online_services: number
|
||||
running_tasks: number
|
||||
pending_alerts: number
|
||||
training_7d: { date: string; train: number; gpu: number; accuracy: number | null }[]
|
||||
service_status: ServiceStatusStat[]
|
||||
training_tasks: TrainingTaskStat[]
|
||||
operation_distribution: { name: string; value: number }[]
|
||||
login_duration_rank: { user: string; role: string; duration: number }[]
|
||||
recent_login_users: { user: string; role: string; last_login: string }[]
|
||||
}
|
||||
|
||||
export function getDashboardStats() {
|
||||
return get<DashboardStats>('/dashboard/stats')
|
||||
}
|
||||
@@ -14,6 +14,8 @@ import type {
|
||||
DataProcessProgress,
|
||||
DataProcessRegeneratePayload,
|
||||
DataProcessRegenerateResult,
|
||||
DataProcessRepeatPayload,
|
||||
DataProcessRepeatResult,
|
||||
DataProcessPublishPayload,
|
||||
DataProcessPublishResult,
|
||||
DataProcessQualityScore,
|
||||
@@ -56,6 +58,8 @@ export type {
|
||||
DataProcessProgress,
|
||||
DataProcessRegeneratePayload,
|
||||
DataProcessRegenerateResult,
|
||||
DataProcessRepeatPayload,
|
||||
DataProcessRepeatResult,
|
||||
DataProcessPublishPayload,
|
||||
DataProcessPublishResult,
|
||||
DataProcessQualityScore,
|
||||
@@ -117,6 +121,15 @@ export const regenerateDataProcessTask = (
|
||||
payload,
|
||||
)
|
||||
|
||||
export const repeatDataProcessTask = (
|
||||
taskId: string | number,
|
||||
payload: DataProcessRepeatPayload,
|
||||
) => post<DataProcessRepeatResult>(
|
||||
`/data-process/${encodeURIComponent(taskId)}/repeat`,
|
||||
payload,
|
||||
{ timeout: 5 * 60 * 1000 },
|
||||
)
|
||||
|
||||
export const deleteDataProcessTask = (taskId: string | number) =>
|
||||
del<{ deleted: string | number }>(`/data-process/${encodeURIComponent(taskId)}`)
|
||||
|
||||
@@ -150,7 +163,12 @@ export const deleteDataProcessSourceFile = (taskId: string | number, fileId: str
|
||||
export const getDataProcessSourceContent = (
|
||||
taskId: string | number,
|
||||
fileId: string | number,
|
||||
params: { start_line?: number; line_count?: number } = {},
|
||||
params: {
|
||||
start_line?: number
|
||||
line_count?: number
|
||||
offset?: number
|
||||
limit?: number
|
||||
} = {},
|
||||
) => get<DataProcessSourceContent>(
|
||||
`/data-process/${encodeURIComponent(taskId)}/source-files/${encodeURIComponent(fileId)}/content`,
|
||||
params,
|
||||
|
||||
55
frontend/src/api/modules/project.ts
Normal file
55
frontend/src/api/modules/project.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { del, get, post, put } from '../request'
|
||||
|
||||
export interface Project {
|
||||
id: string
|
||||
tenant_id: string
|
||||
name: string
|
||||
code: string
|
||||
description?: string
|
||||
status: string
|
||||
quota?: Record<string, unknown>
|
||||
member_count?: number
|
||||
task_count?: number
|
||||
create_time?: string
|
||||
}
|
||||
|
||||
export interface ProjectMember {
|
||||
user_id: string
|
||||
role: string
|
||||
joined_at?: string
|
||||
}
|
||||
|
||||
/** 项目列表(按租户过滤,默认 default) */
|
||||
export const getProjects = (tenantId = 'default') =>
|
||||
get<Project[]>('/projects', { tenant_id: tenantId })
|
||||
|
||||
/** 项目详情 */
|
||||
export const getProject = (id: string) => get<Project>(`/projects/${id}`)
|
||||
|
||||
/** 创建项目 */
|
||||
export const createProject = (payload: Partial<Project>) =>
|
||||
post<Project>('/projects', payload)
|
||||
|
||||
/** 更新项目 */
|
||||
export const updateProject = (id: string, payload: Partial<Project>) =>
|
||||
put<Project>(`/projects/${id}`, payload)
|
||||
|
||||
/** 归档项目 */
|
||||
export const archiveProject = (id: string) =>
|
||||
post<Project>(`/projects/${id}/archive`)
|
||||
|
||||
/** 项目成员列表 */
|
||||
export const getProjectMembers = (id: string) =>
|
||||
get<ProjectMember[]>(`/projects/${id}/members`)
|
||||
|
||||
/** 添加成员 */
|
||||
export const addProjectMember = (id: string, payload: { user_id: string; role: string }) =>
|
||||
post<ProjectMember>(`/projects/${id}/members`, payload)
|
||||
|
||||
/** 更新成员角色 */
|
||||
export const updateProjectMember = (id: string, userId: string, role: string) =>
|
||||
put<ProjectMember>(`/projects/${id}/members/${userId}`, { role })
|
||||
|
||||
/** 移除成员 */
|
||||
export const removeProjectMember = (id: string, userId: string) =>
|
||||
del(`/projects/${id}/members/${userId}`)
|
||||
29
frontend/src/api/modules/retention.ts
Normal file
29
frontend/src/api/modules/retention.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { del, get, post, put } from '../request'
|
||||
|
||||
export interface RetentionPolicy {
|
||||
id: string
|
||||
name: string
|
||||
scope?: string | null
|
||||
rule?: string | null
|
||||
status: string
|
||||
create_time?: string
|
||||
create_by?: string | null
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
/** 留存策略列表 */
|
||||
export const getRetentionPolicies = () => get<RetentionPolicy[]>('/retention-policies')
|
||||
|
||||
/** 留存策略详情 */
|
||||
export const getRetentionPolicy = (id: string) => get<RetentionPolicy>(`/retention-policies/${id}`)
|
||||
|
||||
/** 创建留存策略 */
|
||||
export const createRetentionPolicy = (payload: Partial<RetentionPolicy>) =>
|
||||
post<RetentionPolicy>('/retention-policies', payload)
|
||||
|
||||
/** 更新留存策略 */
|
||||
export const updateRetentionPolicy = (id: string, payload: Partial<RetentionPolicy>) =>
|
||||
put<RetentionPolicy>(`/retention-policies/${id}`, payload)
|
||||
|
||||
/** 删除留存策略 */
|
||||
export const deleteRetentionPolicy = (id: string) => del(`/retention-policies/${id}`)
|
||||
@@ -25,12 +25,14 @@ export const getUsers = () => get<SystemUser[]>('/users')
|
||||
export const createUser = (payload: CreateUserPayload) =>
|
||||
post<SystemUser>('/users', payload)
|
||||
|
||||
/** 删除用户,currentUsername 用于防止删除当前登录账号 */
|
||||
export const deleteUser = (id: string, currentUsername: string) =>
|
||||
del<{ deleted: string }>(`/users/${encodeURIComponent(id)}`, {
|
||||
current_username: currentUsername,
|
||||
})
|
||||
|
||||
/** 更新用户角色、状态及页面权限 */
|
||||
export const updateUserAccess = (id: string, payload: UpdateUserAccessPayload) =>
|
||||
put<SystemUser>(`/users/${encodeURIComponent(id)}`, payload)
|
||||
|
||||
/** 重置用户密码 */
|
||||
export const resetUserPassword = (id: string, password?: string) =>
|
||||
post<{ reset: string }>(`/users/${encodeURIComponent(id)}/reset-password`, { password })
|
||||
|
||||
/** 删除用户(protected 管理员账号不允许删除) */
|
||||
export const deleteUser = (id: string) =>
|
||||
del<{ deleted: string }>(`/users/${encodeURIComponent(id)}`)
|
||||
|
||||
34
frontend/src/api/modules/tenant.ts
Normal file
34
frontend/src/api/modules/tenant.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { get, post, put } from '../request'
|
||||
|
||||
export interface Tenant {
|
||||
id: string
|
||||
name: string
|
||||
code: string
|
||||
status: string
|
||||
owner_user_id?: string | null
|
||||
quota: Record<string, unknown>
|
||||
retention_policy_id?: string | null
|
||||
create_time?: string
|
||||
}
|
||||
|
||||
/** 租户列表 */
|
||||
export const getTenants = () => get<Tenant[]>('/tenants')
|
||||
|
||||
/** 租户详情 */
|
||||
export const getTenant = (id: string) => get<Tenant>(`/tenants/${id}`)
|
||||
|
||||
/** 创建租户 */
|
||||
export const createTenant = (payload: Partial<Tenant>) =>
|
||||
post<Tenant>('/tenants', payload)
|
||||
|
||||
/** 更新租户 */
|
||||
export const updateTenant = (id: string, payload: Partial<Tenant>) =>
|
||||
put<Tenant>(`/tenants/${id}`, payload)
|
||||
|
||||
/** 设置租户配额 */
|
||||
export const setTenantQuota = (id: string, quota: Record<string, unknown>) =>
|
||||
put<Tenant>(`/tenants/${id}/quota`, { quota })
|
||||
|
||||
/** 设置租户留存策略 */
|
||||
export const setTenantRetention = (id: string, retention_policy_id: string) =>
|
||||
put<Tenant>(`/tenants/${id}/retention-policy`, { retention_policy_id })
|
||||
@@ -1,6 +1,5 @@
|
||||
import axios, { type AxiosInstance, type AxiosRequestConfig } from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { touchSessionActivity } from '@/utils/sessionActivity'
|
||||
|
||||
/**
|
||||
* 后端统一响应格式
|
||||
@@ -18,9 +17,37 @@ const service: AxiosInstance = axios.create({
|
||||
timeout: 30000,
|
||||
})
|
||||
|
||||
// 请求拦截器
|
||||
/**
|
||||
* 从 localStorage 取当前用户 token(登录时后端返回 platform-token-{user_id})。
|
||||
* 后端鉴权中间件依赖此 header 解析当前用户身份。
|
||||
*/
|
||||
function getAuthToken(): string | null {
|
||||
const USER_STORAGE_KEY = 'currentUser'
|
||||
const raw = localStorage.getItem(USER_STORAGE_KEY)
|
||||
if (raw) {
|
||||
try {
|
||||
const user = JSON.parse(raw)
|
||||
// 后端 login 返回的 token 格式为 platform-token-{user.id}
|
||||
if (user?.id) return `platform-token-${user.id}`
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
// 兼容改造前 admin 会话
|
||||
if (localStorage.getItem('username') === 'admin') return 'platform-token-admin'
|
||||
return null
|
||||
}
|
||||
|
||||
// 请求拦截器:注入 Authorization header
|
||||
service.interceptors.request.use(
|
||||
(config) => config,
|
||||
(config) => {
|
||||
const token = getAuthToken()
|
||||
if (token) {
|
||||
config.headers = config.headers || {}
|
||||
config.headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => Promise.reject(error),
|
||||
)
|
||||
|
||||
@@ -30,12 +57,9 @@ service.interceptors.response.use(
|
||||
const res = response.data as ApiResult
|
||||
// 二进制流等非 JSON 响应直接返回
|
||||
if (response.config.responseType === 'blob' || response.config.responseType === 'arraybuffer') {
|
||||
touchSessionActivity()
|
||||
return response
|
||||
}
|
||||
if (res.code === 0) {
|
||||
// 生成进度轮询也属于用户正在使用系统,避免长任务结束后被误判为会话过期。
|
||||
touchSessionActivity()
|
||||
return res.data
|
||||
}
|
||||
// 业务错误
|
||||
|
||||
79
frontend/src/components/AclDialog.vue
Normal file
79
frontend/src/components/AclDialog.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getAcl, setAcl, type AclEntry } from '@/api/modules/acl'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
resourceType: string
|
||||
resourceId: string
|
||||
}>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [boolean] }>()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v),
|
||||
})
|
||||
const entries = ref<AclEntry[]>([])
|
||||
const loading = ref(false)
|
||||
const ALL_PERMS = ['read', 'write', 'execute', 'download', 'delete', 'share']
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
entries.value = await getAcl(props.resourceType, props.resourceId)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(visible, (v) => { if (v) load() })
|
||||
|
||||
function addEntry() {
|
||||
entries.value.push({ subject_type: 'user', subject_id: '', permissions: [] })
|
||||
}
|
||||
|
||||
function removeEntry(idx: number) {
|
||||
entries.value.splice(idx, 1)
|
||||
}
|
||||
|
||||
async function save() {
|
||||
await setAcl(props.resourceType, props.resourceId, entries.value)
|
||||
ElMessage.success('ACL 已保存')
|
||||
visible.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog v-model="visible" title="资源授权 (ACL)" width="640px">
|
||||
<div v-loading="loading">
|
||||
<el-button type="primary" size="small" @click="addEntry">添加授权项</el-button>
|
||||
<div v-for="(entry, idx) in entries" :key="idx" class="acl-row">
|
||||
<el-select v-model="entry.subject_type" style="width: 140px">
|
||||
<el-option label="用户" value="user" />
|
||||
<el-option label="项目角色" value="project_role" />
|
||||
</el-select>
|
||||
<el-input v-model="entry.subject_id" placeholder="subject ID" style="width: 200px" />
|
||||
<el-checkbox-group v-model="entry.permissions">
|
||||
<el-checkbox v-for="p in ALL_PERMS" :key="p" :value="p">{{ p }}</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
<el-button link type="danger" @click="removeEntry(idx)">删除</el-button>
|
||||
</div>
|
||||
<el-empty v-if="entries.length === 0" description="暂无授权" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<el-button type="primary" @click="save">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.acl-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
</style>
|
||||
@@ -74,6 +74,16 @@ const menuGroups: MenuGroup[] = [
|
||||
{ key: 'compute', label: '算力节点', icon: 'fa-microchip', to: '/compute', permission: 'compute' },
|
||||
],
|
||||
},
|
||||
{
|
||||
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: 'audit-logs', label: '审计日志', icon: 'fa-history', to: '/audit-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: [
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||
import { ref } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { PermissionCode } from '@/types'
|
||||
|
||||
/** 路由切换时的全局加载态,供 App.vue 显示全屏转圈遮罩,消除懒加载时的空白卡顿感 */
|
||||
export const routeLoading = ref(false)
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/login',
|
||||
@@ -27,6 +31,49 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/dashboard/DashboardView.vue'),
|
||||
meta: { title: '服务看板' },
|
||||
},
|
||||
// 平台治理
|
||||
{
|
||||
path: 'tenants',
|
||||
name: 'tenants',
|
||||
component: () => import('@/views/tenants/TenantListView.vue'),
|
||||
meta: { title: '租户管理', permission: 'user-settings' },
|
||||
},
|
||||
{
|
||||
path: 'tenants/:id',
|
||||
name: 'tenant-detail',
|
||||
component: () => import('@/views/tenants/TenantDetailView.vue'),
|
||||
meta: { title: '租户详情', permission: 'user-settings' },
|
||||
},
|
||||
{
|
||||
path: 'projects',
|
||||
name: 'projects',
|
||||
component: () => import('@/views/projects/ProjectListView.vue'),
|
||||
meta: { title: '项目空间', permission: 'user-settings' },
|
||||
},
|
||||
{
|
||||
path: 'projects/:id',
|
||||
name: 'project-detail',
|
||||
component: () => import('@/views/projects/ProjectDetailView.vue'),
|
||||
meta: { title: '项目详情', permission: 'user-settings' },
|
||||
},
|
||||
{
|
||||
path: 'audit-logs',
|
||||
name: 'audit-logs',
|
||||
component: () => import('@/views/audit/AuditLogView.vue'),
|
||||
meta: { title: '审计日志', permission: 'user-settings' },
|
||||
},
|
||||
{
|
||||
path: 'approval-templates',
|
||||
name: 'approval-templates',
|
||||
component: () => import('@/views/approvals/ApprovalTemplateView.vue'),
|
||||
meta: { title: '审批模板', permission: 'user-settings' },
|
||||
},
|
||||
{
|
||||
path: 'approval-instances',
|
||||
name: 'approval-instances',
|
||||
component: () => import('@/views/approvals/ApprovalInstanceView.vue'),
|
||||
meta: { title: '审批中心', permission: 'user-settings' },
|
||||
},
|
||||
// 模型调优
|
||||
{
|
||||
path: 'fine-tune',
|
||||
@@ -299,6 +346,11 @@ const permissionBySegment: Record<string, PermissionCode> = {
|
||||
hardware: 'hardware',
|
||||
logs: 'logs',
|
||||
'user-settings': 'user-settings',
|
||||
tenants: 'user-settings',
|
||||
projects: 'user-settings',
|
||||
'audit-logs': 'user-settings',
|
||||
'approval-templates': 'user-settings',
|
||||
'approval-instances': 'user-settings',
|
||||
}
|
||||
|
||||
function requiredPermission(path: string, explicit?: unknown) {
|
||||
@@ -307,10 +359,11 @@ function requiredPermission(path: string, explicit?: unknown) {
|
||||
return permissionBySegment[segment]
|
||||
}
|
||||
|
||||
// 全局守卫:登录校验 + 会话超时
|
||||
// 全局守卫:登录校验
|
||||
// 离开页面超时由 App.vue 的 visibilitychange 监听接管
|
||||
router.beforeEach((to, _from, next) => {
|
||||
if (!to.meta.public) routeLoading.value = true
|
||||
const auth = useAuthStore()
|
||||
auth.syncSession()
|
||||
document.title = to.meta.title ? `${to.meta.title} - 远光软件微调平台` : '远光软件微调平台'
|
||||
|
||||
if (to.meta.public) {
|
||||
@@ -324,6 +377,7 @@ router.beforeEach((to, _from, next) => {
|
||||
}
|
||||
|
||||
if (!auth.isLoggedIn) {
|
||||
auth.logout()
|
||||
next({ name: 'login' })
|
||||
return
|
||||
}
|
||||
@@ -336,9 +390,11 @@ router.beforeEach((to, _from, next) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 续期会话
|
||||
auth.refresh()
|
||||
next()
|
||||
})
|
||||
|
||||
router.afterEach(() => {
|
||||
routeLoading.value = false
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { login as loginApi } from '@/api/modules/system'
|
||||
import { SESSION_TIMEOUT } from '@/constants'
|
||||
import type { PermissionCode, SystemUser } from '@/types'
|
||||
import {
|
||||
clearSessionActivity,
|
||||
sessionActivityTime,
|
||||
startSessionActivity,
|
||||
syncSessionActivity,
|
||||
touchSessionActivity,
|
||||
} from '@/utils/sessionActivity'
|
||||
|
||||
const USER_STORAGE_KEY = 'currentUser'
|
||||
|
||||
@@ -56,7 +48,8 @@ function restoreUser(): SystemUser | null {
|
||||
|
||||
/**
|
||||
* 认证 store
|
||||
* 沿用原项目 localStorage 的登录时间戳 + 5 分钟会话超时机制
|
||||
* 登录态管理:有 currentUser 即视为已登录。
|
||||
* 离开页面超时由 App.vue 的 visibilitychange 监听接管。
|
||||
*/
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const currentUser = ref<SystemUser | null>(restoreUser())
|
||||
@@ -67,18 +60,13 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
if (currentUser.value?.role === 'operator') return '操作员'
|
||||
return '观察员'
|
||||
})
|
||||
const loginTime = sessionActivityTime
|
||||
|
||||
const isLoggedIn = computed(() => {
|
||||
if (!loginTime.value) return false
|
||||
return Date.now() - loginTime.value < SESSION_TIMEOUT
|
||||
})
|
||||
const isLoggedIn = computed(() => currentUser.value !== null)
|
||||
|
||||
/** 登录 */
|
||||
async function login(user: string, password: string) {
|
||||
const response = await loginApi(user, password)
|
||||
currentUser.value = response.user
|
||||
startSessionActivity()
|
||||
localStorage.setItem('username', response.user.username)
|
||||
localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(response.user))
|
||||
}
|
||||
@@ -89,20 +77,9 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
return currentUser.value?.permissions.includes(permission) ?? false
|
||||
}
|
||||
|
||||
/** 续期会话(活跃时刷新) */
|
||||
function refresh() {
|
||||
if (currentUser.value) touchSessionActivity()
|
||||
}
|
||||
|
||||
/** 在路由判断前吸收其他标签页写入的最后活跃时间。 */
|
||||
function syncSession() {
|
||||
syncSessionActivity()
|
||||
}
|
||||
|
||||
/** 退出 */
|
||||
function logout() {
|
||||
currentUser.value = null
|
||||
clearSessionActivity()
|
||||
localStorage.removeItem('username')
|
||||
localStorage.removeItem(USER_STORAGE_KEY)
|
||||
}
|
||||
@@ -112,12 +89,9 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
username,
|
||||
displayName,
|
||||
roleLabel,
|
||||
loginTime,
|
||||
isLoggedIn,
|
||||
hasPermission,
|
||||
login,
|
||||
refresh,
|
||||
syncSession,
|
||||
logout,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -99,6 +99,20 @@ export interface DataProcessRegenerateResult {
|
||||
published_outputs_preserved: boolean
|
||||
}
|
||||
|
||||
export interface DataProcessRepeatPayload {
|
||||
expected_updated_at: string
|
||||
request_id: string
|
||||
}
|
||||
|
||||
export interface DataProcessRepeatResult {
|
||||
task: DataProcessTask
|
||||
source_task_id: string
|
||||
created: boolean
|
||||
copied_source_file_count: number
|
||||
copied_preview_count: number
|
||||
progress: DataProcessProgress
|
||||
}
|
||||
|
||||
export type DataProcessTaskUpdatePayload = Partial<DataProcessTaskCreatePayload>
|
||||
|
||||
export interface DataProcessSourceFile {
|
||||
@@ -232,6 +246,22 @@ export interface DataProcessPreviewItem {
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
export type DataProcessSourceLocatorKind = 'json' | 'jsonl' | 'csv' | 'xlsx'
|
||||
|
||||
export interface DataProcessSourceLocator {
|
||||
kind: DataProcessSourceLocatorKind
|
||||
record_index?: number | null
|
||||
start_line?: number | null
|
||||
end_line?: number | null
|
||||
source_start?: number | null
|
||||
source_end?: number | null
|
||||
json_pointer?: string | null
|
||||
sheet_index?: number | null
|
||||
sheet_name?: string | null
|
||||
row_number?: number | null
|
||||
sheet_record_index?: number | null
|
||||
}
|
||||
|
||||
export interface DataProcessPreviewBuildPayload {
|
||||
replace_existing?: true
|
||||
source_file_ids?: Array<string | number>
|
||||
@@ -369,6 +399,9 @@ export interface DataProcessQualityScore {
|
||||
is_valid?: boolean
|
||||
flags?: string[]
|
||||
fingerprint?: string
|
||||
source_pages?: number[]
|
||||
heading_path?: string[]
|
||||
source_locator?: DataProcessSourceLocator
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ function storedActivityTime() {
|
||||
|
||||
/**
|
||||
* 会话按“最后活跃时间”计算,而不是从首次登录起固定倒计时。
|
||||
* 该 ref 被认证 store 与请求层共享,确保 API 活动可以立即影响路由守卫。
|
||||
* 该 ref 被认证 store 与路由守卫共享,确保真实用户活动可以立即影响超时判断。
|
||||
*/
|
||||
export const sessionActivityTime = ref(storedActivityTime())
|
||||
|
||||
@@ -30,3 +30,45 @@ export function clearSessionActivity() {
|
||||
sessionActivityTime.value = 0
|
||||
localStorage.removeItem(LOGIN_TIME_STORAGE_KEY)
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅在用户真实活跃时续期会话:
|
||||
* - 鼠标移动 / 键盘 / 点击 / 触摸(说明用户正在操作)
|
||||
* - 标签页切回可见(说明用户回到界面)
|
||||
* 页面后台轮询接口、切走标签页不会续期,从而“无操作”或“不在当前界面”
|
||||
* 超过空闲时长才会被判定为会话过期并跳回登录。
|
||||
*/
|
||||
let userActivityBound = false
|
||||
let lastTouch = 0
|
||||
const ACTIVITY_THROTTLE = 5000 // 5s 内最多续期一次,避免 mousemove 过于频繁
|
||||
|
||||
const activityEvents = ['mousemove', 'mousedown', 'keydown', 'click', 'touchstart'] as const
|
||||
|
||||
function handleUserActivity() {
|
||||
const now = Date.now()
|
||||
if (now - lastTouch < ACTIVITY_THROTTLE) return
|
||||
lastTouch = now
|
||||
touchSessionActivity()
|
||||
}
|
||||
|
||||
function handleVisibility() {
|
||||
if (!document.hidden) {
|
||||
touchSessionActivity()
|
||||
}
|
||||
}
|
||||
|
||||
export function bindUserActivityListeners() {
|
||||
if (userActivityBound) return
|
||||
userActivityBound = true
|
||||
activityEvents.forEach((evt) =>
|
||||
window.addEventListener(evt, handleUserActivity, { passive: true })
|
||||
)
|
||||
document.addEventListener('visibilitychange', handleVisibility)
|
||||
}
|
||||
|
||||
export function unbindUserActivityListeners() {
|
||||
if (!userActivityBound) return
|
||||
userActivityBound = false
|
||||
activityEvents.forEach((evt) => window.removeEventListener(evt, handleUserActivity))
|
||||
document.removeEventListener('visibilitychange', handleVisibility)
|
||||
}
|
||||
|
||||
128
frontend/src/views/approvals/ApprovalInstanceView.vue
Normal file
128
frontend/src/views/approvals/ApprovalInstanceView.vue
Normal file
@@ -0,0 +1,128 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, 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, type SystemUser } from '@/api/modules/system'
|
||||
|
||||
const loading = ref(false)
|
||||
const instances = ref<ApprovalInstance[]>([])
|
||||
const users = ref<SystemUser[]>([])
|
||||
const statusFilter = ref<string | undefined>(undefined)
|
||||
const showDecide = ref(false)
|
||||
const current = ref<ApprovalInstance | null>(null)
|
||||
const decision = ref({ step_index: 0, approver_id: '', approved: true, comment: '' })
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '待审批', value: 'pending' },
|
||||
{ label: '已通过', value: 'approved' },
|
||||
{ label: '已拒绝', value: 'rejected' },
|
||||
]
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
instances.value = await getApprovalInstances(statusFilter.value)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
try {
|
||||
users.value = await getUsers()
|
||||
} catch {
|
||||
users.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function userName(id?: string) {
|
||||
if (!id) return '—'
|
||||
return users.value.find((u) => u.id === id)?.username || id
|
||||
}
|
||||
|
||||
function openDecide(inst: ApprovalInstance) {
|
||||
current.value = inst
|
||||
const step = inst.steps.find((s) => s.status === 'pending')
|
||||
decision.value = { step_index: step ? step.step_index : 0, approver_id: '', approved: true, comment: '' }
|
||||
showDecide.value = true
|
||||
}
|
||||
|
||||
async function submitDecision() {
|
||||
if (!current.value) return
|
||||
if (!decision.value.approver_id) {
|
||||
ElMessage.warning('请选择审批人')
|
||||
return
|
||||
}
|
||||
await decideApproval(current.value.id, decision.value.step_index, {
|
||||
approver_id: decision.value.approver_id,
|
||||
approved: decision.value.approved,
|
||||
comment: decision.value.comment,
|
||||
})
|
||||
ElMessage.success('审批已提交')
|
||||
showDecide.value = false
|
||||
load()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadUsers()
|
||||
load()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<DataTablePage title="审批实例" :data="instances" :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" />
|
||||
</el-select>
|
||||
</template>
|
||||
<template #columns>
|
||||
<el-table-column prop="resource_type" label="资源类型" min-width="120" />
|
||||
<el-table-column prop="resource_id" label="资源 ID" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="applicant_id" label="申请人" min-width="120">
|
||||
<template #default="{ row }">{{ userName(row.applicant_id) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" min-width="100" />
|
||||
<el-table-column prop="current_step" label="当前步骤" min-width="100" />
|
||||
<el-table-column prop="create_time" label="创建时间" min-width="180" />
|
||||
</template>
|
||||
<template #actions="{ row }">
|
||||
<el-button v-if="row.status === 'pending'" link type="primary" @click="openDecide(row)">审批</el-button>
|
||||
</template>
|
||||
</DataTablePage>
|
||||
<el-dialog v-model="showDecide" title="审批决策" width="480px">
|
||||
<el-form label-width="80px" v-if="current">
|
||||
<el-form-item label="实例">
|
||||
{{ current.resource_type }} / {{ current.resource_id }}
|
||||
</el-form-item>
|
||||
<el-form-item label="步骤">
|
||||
第 {{ decision.step_index + 1 }} 步
|
||||
</el-form-item>
|
||||
<el-form-item label="审批人" required>
|
||||
<el-select v-model="decision.approver_id" filterable style="width: 100%">
|
||||
<el-option v-for="u in users" :key="u.id" :label="u.username" :value="u.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="结果">
|
||||
<el-radio-group v-model="decision.approved">
|
||||
<el-radio :value="true">通过</el-radio>
|
||||
<el-radio :value="false">拒绝</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="意见">
|
||||
<el-input v-model="decision.comment" type="textarea" :rows="3" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showDecide = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitDecision">提交</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page { padding: 16px; }
|
||||
</style>
|
||||
77
frontend/src/views/approvals/ApprovalTemplateView.vue
Normal file
77
frontend/src/views/approvals/ApprovalTemplateView.vue
Normal file
@@ -0,0 +1,77 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import DataTablePage from '@/components/DataTablePage.vue'
|
||||
import { createApprovalTemplate, getApprovalTemplates, type ApprovalTemplate } from '@/api/modules/approval'
|
||||
|
||||
const loading = ref(false)
|
||||
const templates = ref<ApprovalTemplate[]>([])
|
||||
const showCreate = ref(false)
|
||||
const form = ref({ name: '', stepsText: '[]' })
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
templates.value = await getApprovalTemplates()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitCreate() {
|
||||
if (!form.value.name) {
|
||||
ElMessage.warning('请填写模板名称')
|
||||
return
|
||||
}
|
||||
let steps: unknown[] = []
|
||||
try {
|
||||
steps = JSON.parse(form.value.stepsText || '[]')
|
||||
} catch {
|
||||
ElMessage.error('步骤需为合法 JSON 数组')
|
||||
return
|
||||
}
|
||||
await createApprovalTemplate({ name: form.value.name, steps: steps as any })
|
||||
ElMessage.success('模板创建成功')
|
||||
showCreate.value = false
|
||||
form.value = { name: '', stepsText: '[]' }
|
||||
load()
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<DataTablePage title="审批模板" :data="templates" :loading="loading">
|
||||
<template #toolbar-extra>
|
||||
<el-button type="primary" :icon="Plus" @click="showCreate = true">新建模板</el-button>
|
||||
</template>
|
||||
<template #columns>
|
||||
<el-table-column prop="name" label="模板名" min-width="160" />
|
||||
<el-table-column label="步骤数" min-width="100">
|
||||
<template #default="{ row }">{{ (row.steps || []).length }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="create_time" label="创建时间" min-width="180" />
|
||||
</template>
|
||||
</DataTablePage>
|
||||
<el-dialog v-model="showCreate" title="新建审批模板" width="560px">
|
||||
<el-form label-width="90px">
|
||||
<el-form-item label="名称" required>
|
||||
<el-input v-model="form.name" placeholder="模板名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="步骤 JSON">
|
||||
<el-input v-model="form.stepsText" type="textarea" :rows="5" placeholder='[{"approver_id":"u1"},{"approver_id":"u2"}]' />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showCreate = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitCreate">创建</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page { padding: 16px; }
|
||||
</style>
|
||||
125
frontend/src/views/audit/AuditLogView.vue
Normal file
125
frontend/src/views/audit/AuditLogView.vue
Normal file
@@ -0,0 +1,125 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getAuditLogs, exportAuditLogs, type AuditLog, type AuditQuery } from '@/api/modules/audit'
|
||||
|
||||
const loading = ref(false)
|
||||
const logs = ref<AuditLog[]>([])
|
||||
const total = ref(0)
|
||||
const query = reactive<AuditQuery>({
|
||||
tenant_id: '',
|
||||
project_id: '',
|
||||
actor_id: '',
|
||||
action: '',
|
||||
target_type: '',
|
||||
start_time: '',
|
||||
end_time: '',
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
})
|
||||
|
||||
// 时间范围(el-date-picker 双向绑定数组 [start, end])
|
||||
const timeRange = ref<[string, string] | null>(null)
|
||||
|
||||
function applyTimeRange() {
|
||||
if (timeRange.value && timeRange.value.length === 2) {
|
||||
query.start_time = timeRange.value[0]
|
||||
query.end_time = timeRange.value[1]
|
||||
} else {
|
||||
query.start_time = ''
|
||||
query.end_time = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getAuditLogs({ ...query })
|
||||
logs.value = res.items
|
||||
total.value = res.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExport() {
|
||||
try {
|
||||
const blob = await exportAuditLogs({ ...query, limit: 10000, offset: 0 })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `audit_logs_${Date.now()}.csv`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch {
|
||||
ElMessage.error('导出失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">审计日志</h2>
|
||||
<el-button @click="handleExport">导出 CSV</el-button>
|
||||
</div>
|
||||
<el-card class="filter-card">
|
||||
<el-form :inline="true">
|
||||
<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-form-item>
|
||||
<el-form-item label="操作人">
|
||||
<el-input v-model="query.actor_id" placeholder="actor_id" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="动作">
|
||||
<el-input v-model="query.action" placeholder="action" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="目标类型">
|
||||
<el-input v-model="query.target_type" placeholder="target_type" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="时间范围">
|
||||
<el-date-picker
|
||||
v-model="timeRange"
|
||||
type="datetimerange"
|
||||
value-format="YYYY-MM-DDTHH:mm:ss"
|
||||
range-separator="至"
|
||||
start-placeholder="开始时间"
|
||||
end-placeholder="结束时间"
|
||||
clearable
|
||||
style="width: 360px"
|
||||
@change="applyTimeRange"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="load">查询</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 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" />
|
||||
</el-table>
|
||||
<div class="pager">共 {{ total }} 条</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page { padding: 16px; }
|
||||
.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; }
|
||||
.log-table { margin-top: 8px; }
|
||||
.pager { margin-top: 12px; text-align: right; color: #909399; }
|
||||
</style>
|
||||
@@ -1,9 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import VChart from 'vue-echarts'
|
||||
import '@/plugins/echarts'
|
||||
import type { EChartsOption } from 'echarts'
|
||||
import { getDashboardStats } from '@/api/modules/dashboard'
|
||||
|
||||
type ServiceState = 'normal' | 'busy' | 'error'
|
||||
type TaskState = 'running' | 'pending' | 'completed' | 'failed'
|
||||
@@ -16,7 +17,7 @@ interface ServiceStatus {
|
||||
}
|
||||
|
||||
interface DashboardTask {
|
||||
id: number
|
||||
id: string
|
||||
name: string
|
||||
state: TaskState
|
||||
trainType: string
|
||||
@@ -43,59 +44,37 @@ interface RecentLoginUser {
|
||||
const router = useRouter()
|
||||
const period = ref('7d')
|
||||
|
||||
const serviceStatuses: ServiceStatus[] = [
|
||||
{ name: '模型推理', icon: 'fa-cube', state: 'normal', instances: '6 / 6' },
|
||||
{ name: '模型微调', icon: 'fa-sliders', state: 'busy', instances: '4 / 6' },
|
||||
{ name: '模型评测', icon: 'fa-bar-chart', state: 'normal', instances: '3 / 3' },
|
||||
{ name: '数据处理', icon: 'fa-filter', state: 'error', instances: '1 / 3' },
|
||||
]
|
||||
const onlineServices = ref(0)
|
||||
const runningTasks = ref(0)
|
||||
const pendingAlerts = ref(0)
|
||||
|
||||
const trainingTasks: DashboardTask[] = [
|
||||
{
|
||||
id: 103942,
|
||||
name: 'finance-sft-003',
|
||||
state: 'running',
|
||||
trainType: 'SFT',
|
||||
trainMethod: 'LoRA',
|
||||
baseModel: 'Qwen2.5-7B-Instruct',
|
||||
progress: 68,
|
||||
accuracy: 89.2,
|
||||
startedAt: '今天 09:18',
|
||||
},
|
||||
{
|
||||
id: 593021,
|
||||
name: 'legal-eval-008',
|
||||
state: 'pending',
|
||||
trainType: 'DPO',
|
||||
trainMethod: 'LoRA',
|
||||
baseModel: 'Qwen2.5-7B-Instruct',
|
||||
progress: 0,
|
||||
accuracy: null,
|
||||
startedAt: '今天 08:55',
|
||||
},
|
||||
{
|
||||
id: 849301,
|
||||
name: 'medical-cpt-002',
|
||||
state: 'completed',
|
||||
trainType: 'CPT',
|
||||
trainMethod: 'Full',
|
||||
baseModel: 'Qwen2.5-14B-Instruct',
|
||||
progress: 100,
|
||||
accuracy: 91.6,
|
||||
startedAt: '07/10 16:20',
|
||||
},
|
||||
{
|
||||
id: 201948,
|
||||
name: 'finance-sft-002',
|
||||
state: 'failed',
|
||||
trainType: 'SFT',
|
||||
trainMethod: 'LoRA',
|
||||
baseModel: 'Qwen2.5-7B-Instruct',
|
||||
progress: 42,
|
||||
accuracy: null,
|
||||
startedAt: '07/10 11:08',
|
||||
},
|
||||
]
|
||||
const serviceStatuses = ref<ServiceStatus[]>([])
|
||||
const trainingTasks = ref<DashboardTask[]>([])
|
||||
const loginDurationStats = ref<LoginDurationStat[]>([])
|
||||
const recentLoginUsers = ref<RecentLoginUser[]>([])
|
||||
const training7d = ref<{ date: string; train: number; gpu: number; accuracy: number | null }[]>([])
|
||||
|
||||
const onlineServicesHint = computed(() => {
|
||||
if (onlineServices.value === 0) return '暂无在线服务'
|
||||
const abnormal = serviceStatuses.value.filter(
|
||||
(s) => s.state === 'busy' || s.state === 'error'
|
||||
).length
|
||||
return abnormal > 0 ? `${abnormal} 个异常` : '全部在线'
|
||||
})
|
||||
const operationDistribution = ref<{ name: string; value: number }[]>([])
|
||||
|
||||
const serviceIcon: Record<string, string> = {
|
||||
'模型推理': 'fa-cube',
|
||||
'模型微调': 'fa-sliders',
|
||||
'模型评测': 'fa-bar-chart',
|
||||
'数据处理': 'fa-filter',
|
||||
}
|
||||
const roleLabel: Record<string, string> = {
|
||||
admin: '超级管理员',
|
||||
operator: '操作员',
|
||||
observer: '观察员',
|
||||
guest: '访客',
|
||||
}
|
||||
|
||||
const serviceStateMeta: Record<ServiceState, { label: string; className: string }> = {
|
||||
normal: { label: '正常', className: 'is-normal' },
|
||||
@@ -140,7 +119,7 @@ const chartOption = computed<EChartsOption>(() => ({
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: ['07/05', '07/06', '07/07', '07/08', '07/09', '07/10', '07/11\n今天'],
|
||||
data: training7d.value.map((d) => d.date),
|
||||
axisLine: { lineStyle: { color: '#e2e8f0' } },
|
||||
axisTick: { show: false },
|
||||
axisLabel: { color: '#64748b', fontSize: 11, lineHeight: 16, margin: 12 },
|
||||
@@ -175,7 +154,7 @@ const chartOption = computed<EChartsOption>(() => ({
|
||||
{
|
||||
name: '训练次数(次)',
|
||||
type: 'bar',
|
||||
data: [8, 12, 10, 15, 13, 18, 11],
|
||||
data: training7d.value.map((d) => d.train),
|
||||
barMaxWidth: 16,
|
||||
itemStyle: { borderRadius: [3, 3, 0, 0] },
|
||||
label: { show: true, position: 'top', color: '#64748b', fontSize: 10 },
|
||||
@@ -183,7 +162,7 @@ const chartOption = computed<EChartsOption>(() => ({
|
||||
{
|
||||
name: 'GPU 使用数(个)',
|
||||
type: 'bar',
|
||||
data: [3, 4, 4, 6, 5, 7, 5],
|
||||
data: training7d.value.map((d) => d.gpu),
|
||||
barMaxWidth: 16,
|
||||
itemStyle: { borderRadius: [3, 3, 0, 0] },
|
||||
label: { show: true, position: 'top', color: '#64748b', fontSize: 10 },
|
||||
@@ -192,7 +171,7 @@ const chartOption = computed<EChartsOption>(() => ({
|
||||
name: '平均准确率(%)',
|
||||
type: 'bar',
|
||||
yAxisIndex: 1,
|
||||
data: [82, 85, 84, 88, 87, 91, 89],
|
||||
data: training7d.value.map((d) => d.accuracy ?? null),
|
||||
barMaxWidth: 16,
|
||||
itemStyle: { borderRadius: [3, 3, 0, 0] },
|
||||
label: { show: true, position: 'top', color: '#d97706', fontSize: 10 },
|
||||
@@ -200,58 +179,61 @@ const chartOption = computed<EChartsOption>(() => ({
|
||||
],
|
||||
}))
|
||||
|
||||
const operationChartOption = computed<EChartsOption>(() => ({
|
||||
animationDuration: 500,
|
||||
tooltip: { trigger: 'item' },
|
||||
color: ['#4f46e5', '#10b981', '#f59e0b', '#3b82f6', '#ec4899'],
|
||||
series: [
|
||||
{
|
||||
name: '操作分类',
|
||||
type: 'pie',
|
||||
radius: ['40%', '64%'],
|
||||
center: ['50%', '50%'],
|
||||
avoidLabelOverlap: true,
|
||||
itemStyle: {
|
||||
borderRadius: 6,
|
||||
borderColor: '#fff',
|
||||
borderWidth: 2
|
||||
// 模块固定配色,保证每个模块颜色不同
|
||||
const OPERATION_COLORS = ['#4f46e5', '#10b981', '#f59e0b', '#3b82f6', '#ec4899', '#8b5cf6', '#ef4444', '#14b8a6']
|
||||
const operationChartOption = computed<EChartsOption>(() => {
|
||||
const items = operationDistribution.value
|
||||
const total = items.reduce((s, d) => s + (d.value || 0), 0)
|
||||
// 完全没有操作数据时,用等分灰色占位扇区,保证 6 个模块都可见
|
||||
const data =
|
||||
total > 0
|
||||
? items.map((d) => ({ value: d.value || 0, name: d.name }))
|
||||
: items.map((d) => ({ value: 1, name: d.name, itemStyle: { color: '#e2e8f0' } }))
|
||||
return {
|
||||
animationDuration: 500,
|
||||
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
||||
color: OPERATION_COLORS,
|
||||
legend: {
|
||||
type: 'scroll',
|
||||
bottom: 0,
|
||||
textStyle: { color: '#64748b', fontSize: 11 },
|
||||
itemWidth: 10,
|
||||
itemHeight: 10,
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '操作分类',
|
||||
type: 'pie',
|
||||
radius: ['38%', '60%'],
|
||||
center: ['50%', '42%'],
|
||||
avoidLabelOverlap: true,
|
||||
itemStyle: {
|
||||
borderRadius: 6,
|
||||
borderColor: '#fff',
|
||||
borderWidth: 2,
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
position: 'outside',
|
||||
formatter: '{b}\n{d}%',
|
||||
color: '#475569',
|
||||
fontSize: 11,
|
||||
lineHeight: 15,
|
||||
},
|
||||
emphasis: {
|
||||
label: { show: true, fontSize: 12, fontWeight: 'bold', color: '#1e293b' },
|
||||
},
|
||||
labelLine: {
|
||||
show: true,
|
||||
length: 8,
|
||||
length2: 8,
|
||||
lineStyle: { color: '#94a3b8', width: 1 },
|
||||
},
|
||||
data,
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
position: 'outside',
|
||||
formatter: '{b}',
|
||||
color: '#475569',
|
||||
fontSize: 11,
|
||||
lineHeight: 16,
|
||||
width: 70,
|
||||
overflow: 'truncate',
|
||||
},
|
||||
emphasis: {
|
||||
label: { show: true, fontSize: 12, fontWeight: 'bold', color: '#1e293b' }
|
||||
},
|
||||
labelLine: {
|
||||
show: true,
|
||||
length: 10,
|
||||
length2: 8,
|
||||
lineStyle: { color: '#94a3b8', width: 1 },
|
||||
},
|
||||
data: [
|
||||
{ value: 1048, name: '模型训练' },
|
||||
{ value: 735, name: '数据处理' },
|
||||
{ value: 580, name: '模型评测' },
|
||||
{ value: 484, name: '模型推理' },
|
||||
{ value: 300, name: '系统设置' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}))
|
||||
|
||||
const loginDurationStats: LoginDurationStat[] = [
|
||||
{ id: 1, username: 'admin', duration: 124 },
|
||||
{ id: 2, username: 'zhangsan', duration: 86 },
|
||||
{ id: 3, username: 'lisi', duration: 42 },
|
||||
{ id: 4, username: 'wangwu', duration: 18 },
|
||||
]
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
const loginDurationChartOption = computed<EChartsOption>(() => ({
|
||||
animationDuration: 500,
|
||||
@@ -263,7 +245,7 @@ const loginDurationChartOption = computed<EChartsOption>(() => ({
|
||||
},
|
||||
xAxis: {
|
||||
type: 'value',
|
||||
max: Math.ceil(Math.max(...loginDurationStats.map((user) => user.duration)) * 1.15 / 10) * 10,
|
||||
max: Math.max(10, Math.ceil(Math.max(...loginDurationStats.value.map((user) => user.duration), 0) * 1.15 / 10) * 10),
|
||||
splitNumber: 4,
|
||||
axisLabel: { color: '#94a3b8', fontSize: 11, formatter: '{value}h' },
|
||||
axisLine: { show: false },
|
||||
@@ -273,7 +255,7 @@ const loginDurationChartOption = computed<EChartsOption>(() => ({
|
||||
yAxis: {
|
||||
type: 'category',
|
||||
inverse: true,
|
||||
data: loginDurationStats.map((user) => user.username),
|
||||
data: loginDurationStats.value.map((user) => user.username),
|
||||
axisLabel: { color: '#475569', fontSize: 12 },
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
@@ -282,7 +264,7 @@ const loginDurationChartOption = computed<EChartsOption>(() => ({
|
||||
{
|
||||
name: '登录时长',
|
||||
type: 'bar',
|
||||
data: loginDurationStats.map((user) => user.duration),
|
||||
data: loginDurationStats.value.map((user) => user.duration),
|
||||
barMaxWidth: 18,
|
||||
barCategoryGap: '34%',
|
||||
itemStyle: { color: '#4f46e5', borderRadius: [0, 4, 4, 0] },
|
||||
@@ -291,19 +273,51 @@ const loginDurationChartOption = computed<EChartsOption>(() => ({
|
||||
],
|
||||
}))
|
||||
|
||||
const recentLoginUsers: RecentLoginUser[] = [
|
||||
{ id: 1, username: 'admin', role: '超级管理员', lastLogin: '10 分钟前' },
|
||||
{ id: 2, username: 'zhangsan', role: '操作员', lastLogin: '2 小时前' },
|
||||
{ id: 5, username: 'zhaoliu', role: '观察员', lastLogin: '5 小时前' },
|
||||
{ id: 3, username: 'lisi', role: '操作员', lastLogin: '昨天 15:30' },
|
||||
]
|
||||
|
||||
const roleTagType: Record<string, 'danger' | 'primary' | 'info'> = {
|
||||
'超级管理员': 'danger',
|
||||
'操作员': 'primary',
|
||||
'观察员': 'info',
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
const stats = await getDashboardStats()
|
||||
onlineServices.value = stats.online_services
|
||||
runningTasks.value = stats.running_tasks
|
||||
pendingAlerts.value = stats.pending_alerts
|
||||
serviceStatuses.value = stats.service_status.map((s) => ({
|
||||
name: s.type,
|
||||
icon: serviceIcon[s.type] || 'fa-cube',
|
||||
state: s.status as ServiceState,
|
||||
instances: String(s.count),
|
||||
}))
|
||||
trainingTasks.value = stats.training_tasks.map((t) => ({
|
||||
id: String(t.id),
|
||||
name: t.name,
|
||||
state: t.status as TaskState,
|
||||
trainType: t.train_type,
|
||||
trainMethod: t.train_method,
|
||||
baseModel: t.base_model,
|
||||
progress: t.progress,
|
||||
accuracy: t.accuracy,
|
||||
startedAt: t.started_at,
|
||||
}))
|
||||
loginDurationStats.value = stats.login_duration_rank.map((u, i) => ({
|
||||
id: i + 1,
|
||||
username: u.user,
|
||||
duration: u.duration,
|
||||
}))
|
||||
recentLoginUsers.value = stats.recent_login_users.map((u, i) => ({
|
||||
id: i + 1,
|
||||
username: u.user,
|
||||
role: roleLabel[u.role] || u.role,
|
||||
lastLogin: u.last_login,
|
||||
}))
|
||||
training7d.value = stats.training_7d
|
||||
operationDistribution.value = stats.operation_distribution
|
||||
}
|
||||
|
||||
onMounted(loadStats)
|
||||
|
||||
function viewAllTasks() {
|
||||
router.push('/fine-tune')
|
||||
}
|
||||
@@ -329,17 +343,17 @@ function viewTask(task: DashboardTask) {
|
||||
<div class="overview-metrics">
|
||||
<div class="overview-metric">
|
||||
<span>在线服务</span>
|
||||
<strong>12</strong>
|
||||
<small>全部在线</small>
|
||||
<strong>{{ onlineServices }}</strong>
|
||||
<small>{{ onlineServicesHint }}</small>
|
||||
</div>
|
||||
<div class="overview-metric">
|
||||
<span>运行中任务</span>
|
||||
<strong>5</strong>
|
||||
<strong>{{ runningTasks }}</strong>
|
||||
<small>较昨日 +1</small>
|
||||
</div>
|
||||
<div class="overview-metric is-alert">
|
||||
<span>待处理告警</span>
|
||||
<strong>2</strong>
|
||||
<strong>{{ pendingAlerts }}</strong>
|
||||
<small>较昨日 -1</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -391,7 +405,10 @@ function viewTask(task: DashboardTask) {
|
||||
|
||||
<section class="stat-card" aria-labelledby="login-dur-title">
|
||||
<h2 id="login-dur-title" class="section-title">登录时长排行 (本月)</h2>
|
||||
<VChart class="duration-chart" :option="loginDurationChartOption" autoresize />
|
||||
<div v-if="loginDurationStats.length" class="chart-container">
|
||||
<VChart class="duration-chart" :option="loginDurationChartOption" autoresize />
|
||||
</div>
|
||||
<div v-else class="empty-hint">暂无数据</div>
|
||||
</section>
|
||||
|
||||
<section class="stat-card" aria-labelledby="recent-login-title">
|
||||
@@ -515,6 +532,15 @@ function viewTask(task: DashboardTask) {
|
||||
height: 224px;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
flex: 1 1 auto;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 224px;
|
||||
color: #94a3b8;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.duration-chart {
|
||||
width: 100%;
|
||||
height: 224px;
|
||||
|
||||
@@ -10,7 +10,7 @@ import SourceUploadStep from './create/SourceUploadStep.vue'
|
||||
import PreviewCompareStep from './create/PreviewCompareStep.vue'
|
||||
import GenerationStep from './create/GenerationStep.vue'
|
||||
import ResultEditorStep from './create/ResultEditorStep.vue'
|
||||
import { DEFAULT_SOURCE_TEXT, estimateTokenCount } from './create/previewModel'
|
||||
import { DEFAULT_SOURCE_TEXT, estimateTokenCount, isManualPreviewItem } from './create/previewModel'
|
||||
import {
|
||||
createDefaultStructuredOptions,
|
||||
createDefaultUnstructuredOptions,
|
||||
@@ -21,6 +21,7 @@ import { useDataProcessGeneration } from './create/useDataProcessGeneration'
|
||||
import { useDataProcessPreviewBuild } from './create/useDataProcessPreviewBuild'
|
||||
import { useDataProcessRegeneration } from './create/useDataProcessRegeneration'
|
||||
import {
|
||||
loadCanonicalSourceContent,
|
||||
mapDataProcessSourceFile,
|
||||
useDataProcessSourceUpload,
|
||||
validateSourceFileSelection,
|
||||
@@ -33,7 +34,6 @@ import {
|
||||
deleteDataProcessPreview,
|
||||
deleteDataProcessSourceFile,
|
||||
getDataProcessPreview,
|
||||
getDataProcessSourceContent,
|
||||
pullDataProcessExternalSource,
|
||||
testDataProcessExternalSource,
|
||||
updateDataProcessPreview,
|
||||
@@ -116,7 +116,9 @@ const modelSubmitLoading = ref(false)
|
||||
let allowLeave = false
|
||||
const {
|
||||
bulkRegeneration,
|
||||
canReturnFromGeneration,
|
||||
generation,
|
||||
generationStarting,
|
||||
regeneratingResultId,
|
||||
resultRegenerationBusy,
|
||||
results,
|
||||
@@ -189,7 +191,6 @@ const primaryActionIcon = computed(() => {
|
||||
if (currentStepId.value === 'generate' && generation.status !== 'success') return 'fa-play'
|
||||
return 'fa-arrow-right'
|
||||
})
|
||||
|
||||
const previousStepLabel = computed(() => currentStep.value > 0
|
||||
? WIZARD_STEPS[currentStep.value - 1].title
|
||||
: '')
|
||||
@@ -290,21 +291,26 @@ function externalPayload(): DataProcessExternalSourcePayload {
|
||||
}
|
||||
|
||||
function mapPreviewItem(item: DataProcessPreviewItem): PreviewItem {
|
||||
const sourceLocator = item.quality_score?.source_locator
|
||||
return {
|
||||
id: String(item.id),
|
||||
sourceFileId: String(item.source_file_id),
|
||||
originalContent: item.original_content,
|
||||
editedContent: item.edited_content,
|
||||
savedEditedContent: item.edited_content,
|
||||
sourceStart: item.source_start,
|
||||
sourceEnd: item.source_end,
|
||||
sourceStartLine: item.source_start_line,
|
||||
sourceEndLine: item.source_end_line,
|
||||
sourceStart: item.source_start ?? sourceLocator?.source_start ?? null,
|
||||
sourceEnd: item.source_end ?? sourceLocator?.source_end ?? null,
|
||||
sourceStartLine: item.source_start_line ?? sourceLocator?.start_line ?? null,
|
||||
sourceEndLine: item.source_end_line ?? sourceLocator?.end_line ?? null,
|
||||
tokenCount: item.token_count,
|
||||
status: item.status,
|
||||
sourcePages: Array.isArray(item.quality_score?.source_pages)
|
||||
? item.quality_score.source_pages.filter((value): value is number => typeof value === 'number')
|
||||
: [],
|
||||
sourceLocator,
|
||||
headingPath: Array.isArray(item.quality_score?.heading_path)
|
||||
? item.quality_score.heading_path.filter((value): value is string => typeof value === 'string')
|
||||
: [],
|
||||
updatedAt: item.updated_at,
|
||||
}
|
||||
}
|
||||
@@ -419,7 +425,6 @@ function handleFileChange(uploadFile: UploadFile) {
|
||||
const localUid = `local-${uploadFile.uid}-${Date.now()}-${uploadedFiles.value.length}`
|
||||
uploadedFiles.value.push({
|
||||
uid: localUid,
|
||||
rawFile: raw,
|
||||
name: raw.name,
|
||||
size: raw.size,
|
||||
count: 0,
|
||||
@@ -431,7 +436,7 @@ function handleFileChange(uploadFile: UploadFile) {
|
||||
previewProgress: 0,
|
||||
})
|
||||
dirty.value = true
|
||||
enqueueSourceUpload({ uid: localUid, file: raw, extension: validation.extension })
|
||||
enqueueSourceUpload({ uid: localUid, file: raw })
|
||||
}
|
||||
|
||||
async function useSampleFile() {
|
||||
@@ -488,11 +493,8 @@ async function handlePullData() {
|
||||
const response = await pullDataProcessExternalSource(taskId.value, externalPayload())
|
||||
const newFiles: UploadedDataFile[] = []
|
||||
for (const file of response.files) {
|
||||
const source = await getDataProcessSourceContent(taskId.value, file.id, {
|
||||
start_line: 1,
|
||||
line_count: 5000,
|
||||
})
|
||||
newFiles.push(mapDataProcessSourceFile(file, source.content))
|
||||
const content = await loadCanonicalSourceContent(taskId.value, file.id)
|
||||
newFiles.push(mapDataProcessSourceFile(file, content))
|
||||
}
|
||||
uploadedFiles.value.push(...newFiles)
|
||||
externalConnected.value = true
|
||||
@@ -734,9 +736,14 @@ function selectPreviewItem(id: string) {
|
||||
function updatePreviewContent(id: string, value: string) {
|
||||
const item = previewItems.value.find((entry) => entry.id === id)
|
||||
if (!item) return
|
||||
const isManual = isManualPreviewItem(item)
|
||||
item.editedContent = value
|
||||
item.tokenCount = estimateTokenCount(value)
|
||||
item.status = value === item.originalContent ? 'original' : item.sourceStart == null ? 'manual' : 'modified'
|
||||
item.status = !value.trim()
|
||||
? 'invalid'
|
||||
: value === item.originalContent
|
||||
? 'original'
|
||||
: isManual ? 'manual' : 'modified'
|
||||
resetDownstream()
|
||||
dirty.value = true
|
||||
}
|
||||
@@ -758,7 +765,7 @@ async function syncPreviewChanges() {
|
||||
|
||||
function restorePreviewItem(id: string) {
|
||||
const item = previewItems.value.find((entry) => entry.id === id)
|
||||
if (!item || item.sourceStart == null) return
|
||||
if (!item || isManualPreviewItem(item)) return
|
||||
item.editedContent = item.originalContent
|
||||
item.tokenCount = estimateTokenCount(item.originalContent)
|
||||
item.status = 'original'
|
||||
@@ -897,7 +904,7 @@ async function handleBack() {
|
||||
ElMessage.warning('请等待当前文件切分完成')
|
||||
return
|
||||
}
|
||||
if (currentStepId.value === 'generate') return
|
||||
if (currentStepId.value === 'generate' && !canReturnFromGeneration.value) return
|
||||
if (currentStep.value > 0) {
|
||||
const targetStep = WIZARD_STEPS[currentStep.value - 1]?.id
|
||||
if (!targetStep) return
|
||||
@@ -1014,8 +1021,13 @@ async function initializeExistingWorkflow() {
|
||||
if (sourceTask.status === 'running') resumeStep = 'generate'
|
||||
if (resumeStep === 'preview' && !previewItems.value.length) resumeStep = 'upload'
|
||||
if (resumeStep === 'results' && sourceTask.status !== 'completed') resumeStep = 'generate'
|
||||
if (resumeStep === 'generate' || resumeStep === 'results') {
|
||||
const resume = resumeGeneration()
|
||||
goToStep(resumeStep)
|
||||
await resume
|
||||
return
|
||||
}
|
||||
goToStep(resumeStep)
|
||||
if (resumeStep === 'generate' || resumeStep === 'results') await resumeGeneration()
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
@@ -1154,7 +1166,7 @@ onMounted(() => {
|
||||
<div class="footer-left">
|
||||
<el-button
|
||||
v-if="currentStep > 0"
|
||||
:disabled="currentStepId === 'generate' || previewBuilding || sourceUploading"
|
||||
:disabled="(currentStepId === 'generate' && !canReturnFromGeneration) || previewBuilding || sourceUploading"
|
||||
@click="handleBack"
|
||||
>
|
||||
<i class="fa fa-arrow-left" style="margin-right: 6px;" /> 返回:{{ previousStepLabel }}
|
||||
@@ -1167,8 +1179,8 @@ onMounted(() => {
|
||||
<el-button
|
||||
class="wizard-primary-action"
|
||||
type="primary"
|
||||
:loading="modelSubmitLoading || generation.status === 'running' || resultRegenerationBusy || (currentStepId === 'upload' && (sourceUploading || previewBuilding))"
|
||||
:disabled="hydrating || modelSubmitLoading || Boolean(initializationError) || resultRegenerationBusy || (currentStepId === 'generate' && generation.status === 'running') || previewBuilding || sourceUploading || (currentStepId === 'upload' && hasUnfinishedUploads)"
|
||||
:loading="modelSubmitLoading || generationStarting || generation.status === 'running' || resultRegenerationBusy || (currentStepId === 'upload' && (sourceUploading || previewBuilding))"
|
||||
:disabled="hydrating || modelSubmitLoading || generationStarting || Boolean(initializationError) || resultRegenerationBusy || (currentStepId === 'generate' && generation.status === 'running') || previewBuilding || sourceUploading || (currentStepId === 'upload' && hasUnfinishedUploads)"
|
||||
@click="handlePrimaryAction"
|
||||
>
|
||||
{{ primaryActionLabel }} <i class="fa" :class="primaryActionIcon" style="margin-left: 6px;" />
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
getDataProcessResults,
|
||||
getDataProcessTask,
|
||||
publishDataProcess,
|
||||
repeatDataProcessTask,
|
||||
restoreDataProcessResult,
|
||||
updateDataProcessResult,
|
||||
} from '@/api/modules/dataProcess'
|
||||
@@ -42,6 +43,8 @@ const savingResult = ref(false)
|
||||
const restoringResultId = ref<string | number | null>(null)
|
||||
const publishDialogVisible = ref(false)
|
||||
const publishing = ref(false)
|
||||
const repeatGenerating = ref(false)
|
||||
const repeatRequestId = ref('')
|
||||
const configExpanded = ref(false)
|
||||
const resultCellTooltipOptions = {
|
||||
popperClass: 'data-process-result-tooltip',
|
||||
@@ -113,6 +116,51 @@ const preprocessOptionLabelMap: Record<string, string> = {
|
||||
preserve_context: '保留上下文',
|
||||
}
|
||||
|
||||
const structuredPreprocessOptionKeys = new Set([
|
||||
'clean_invalid',
|
||||
'deduplicate',
|
||||
'detect_structure',
|
||||
'normalize_format',
|
||||
'desensitize',
|
||||
'filter_anomaly',
|
||||
])
|
||||
|
||||
function formatStructuredPreprocessOptions(value: unknown[]) {
|
||||
const options = [...new Set(value.map((item) => String(item)))]
|
||||
const selected = new Set(options)
|
||||
const consumed = new Set<string>()
|
||||
const labels: string[] = []
|
||||
|
||||
function appendGroup(values: string[], groupLabel: string) {
|
||||
const selectedValues = values.filter((item) => selected.has(item))
|
||||
selectedValues.forEach((item) => consumed.add(item))
|
||||
if (selectedValues.length === values.length) {
|
||||
labels.push(groupLabel)
|
||||
return
|
||||
}
|
||||
selectedValues.forEach((item) => {
|
||||
labels.push(`${preprocessOptionLabelMap[item] || item}(历史部分配置)`)
|
||||
})
|
||||
}
|
||||
|
||||
appendGroup(['clean_invalid', 'deduplicate'], '数据清洗')
|
||||
appendGroup(['detect_structure', 'normalize_format'], '结构标准化')
|
||||
|
||||
if (selected.has('desensitize')) {
|
||||
consumed.add('desensitize')
|
||||
labels.push('敏感信息脱敏')
|
||||
}
|
||||
if (selected.has('filter_anomaly')) {
|
||||
consumed.add('filter_anomaly')
|
||||
labels.push('异常数据过滤(历史规则)')
|
||||
}
|
||||
|
||||
options.forEach((item) => {
|
||||
if (!consumed.has(item)) labels.push(preprocessOptionLabelMap[item] || item)
|
||||
})
|
||||
return labels.length ? labels.join('、') : '-'
|
||||
}
|
||||
|
||||
function numeric(value: unknown) {
|
||||
const parsed = typeof value === 'number' ? value : Number(value)
|
||||
return Number.isFinite(parsed) ? parsed : 0
|
||||
@@ -199,6 +247,11 @@ const canRegenerate = computed(() => {
|
||||
|| status === 'stopped'
|
||||
|| (status === 'completed' && (Boolean(outputDatasetId.value) || hasPublishedOutputs.value))
|
||||
})
|
||||
const canRepeatGeneration = computed(() => (
|
||||
detail.value?.status === 'completed'
|
||||
&& detail.value.results_confirmed !== false
|
||||
&& previewCount.value > 0
|
||||
))
|
||||
const creatorName = computed(() => detail.value?.creator_name || detail.value?.creator || '-')
|
||||
const createTime = computed(() => detail.value?.create_time || detail.value?.created_at)
|
||||
const startTime = computed(() => detail.value?.start_time || detail.value?.started_at)
|
||||
@@ -263,9 +316,14 @@ function formatConfigValue(key: string, value: unknown) {
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
if (key === 'preprocess_options') {
|
||||
return value.length
|
||||
? value.map((item) => preprocessOptionLabelMap[String(item)] || String(item)).join('、')
|
||||
: '-'
|
||||
const containsStructuredOption = value.some((item) => (
|
||||
structuredPreprocessOptionKeys.has(String(item))
|
||||
))
|
||||
return containsStructuredOption
|
||||
? formatStructuredPreprocessOptions(value)
|
||||
: value.length
|
||||
? value.map((item) => preprocessOptionLabelMap[String(item)] || String(item)).join('、')
|
||||
: '-'
|
||||
}
|
||||
return value.length ? value.join('、') : '-'
|
||||
}
|
||||
@@ -503,6 +561,48 @@ function startRegeneration() {
|
||||
void router.push({ name: 'data-process-regenerate', params: { id: taskId.value } })
|
||||
}
|
||||
|
||||
function createRepeatRequestId() {
|
||||
if (typeof globalThis.crypto?.randomUUID === 'function') {
|
||||
return globalThis.crypto.randomUUID()
|
||||
}
|
||||
return `${Date.now()}_${Math.random().toString(36).slice(2, 14)}`
|
||||
}
|
||||
|
||||
async function repeatGeneration() {
|
||||
if (!detail.value?.updated_at || repeatGenerating.value) return
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'系统会复制当前配置、源文件和切分结果,创建一个独立的新任务并在后台生成。原任务和原结果不会被修改。',
|
||||
'按原配置再生成一批?',
|
||||
{
|
||||
confirmButtonText: '创建并开始生成',
|
||||
cancelButtonText: '取消',
|
||||
type: 'info',
|
||||
},
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
repeatGenerating.value = true
|
||||
repeatRequestId.value ||= createRepeatRequestId()
|
||||
try {
|
||||
const repeated = await repeatDataProcessTask(taskId.value, {
|
||||
expected_updated_at: detail.value.updated_at,
|
||||
request_id: repeatRequestId.value,
|
||||
})
|
||||
ElMessage.success(repeated.created ? '已创建新任务,正在后台生成' : '已恢复此前创建的新任务')
|
||||
await router.push({
|
||||
name: 'data-process-workflow',
|
||||
params: { id: repeated.task.id },
|
||||
})
|
||||
} catch {
|
||||
// 保留幂等请求 ID;网络超时后再次点击不会重复创建任务。
|
||||
} finally {
|
||||
repeatGenerating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch([currentPage, pageSize], () => void loadResults())
|
||||
|
||||
onMounted(loadPage)
|
||||
@@ -520,22 +620,32 @@ onBeforeUnmount(() => {
|
||||
<el-tag :type="displayStatus.type" size="small" effect="light">
|
||||
{{ displayStatus.label }}
|
||||
</el-tag>
|
||||
<el-button
|
||||
v-if="detail.status === 'completed' && !hasCurrentPublishedDataset"
|
||||
class="publish-button"
|
||||
type="primary"
|
||||
@click="openPublishDialog"
|
||||
>
|
||||
<i class="fa fa-database" style="margin-right: 4px;" />发布为三个数据集
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="canRegenerate"
|
||||
class="publish-button"
|
||||
type="primary"
|
||||
@click="startRegeneration"
|
||||
>
|
||||
<i class="fa fa-refresh" style="margin-right: 4px;" />重新生成
|
||||
</el-button>
|
||||
<div class="heading-actions">
|
||||
<el-button
|
||||
v-if="detail.status === 'completed' && !hasCurrentPublishedDataset"
|
||||
type="primary"
|
||||
@click="openPublishDialog"
|
||||
>
|
||||
<i class="fa fa-database" style="margin-right: 4px;" />发布为三个数据集
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="canRepeatGeneration"
|
||||
type="primary"
|
||||
:loading="repeatGenerating"
|
||||
:disabled="repeatGenerating"
|
||||
@click="repeatGeneration"
|
||||
>
|
||||
<i class="fa fa-clone" style="margin-right: 4px;" />按原配置再生成一批
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="canRegenerate"
|
||||
type="warning"
|
||||
plain
|
||||
@click="startRegeneration"
|
||||
>
|
||||
<i class="fa fa-refresh" style="margin-right: 4px;" />覆盖当前任务重新生成
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<p>{{ detail.description || '暂无任务描述' }}</p>
|
||||
<dl class="heading-meta">
|
||||
@@ -804,7 +914,15 @@ onBeforeUnmount(() => {
|
||||
> p { margin: 8px 0 0; color: #64748b; font-size: 13px; }
|
||||
}
|
||||
|
||||
.publish-button { margin-left: auto; }
|
||||
.heading-actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
|
||||
:deep(.el-button + .el-button) { margin-left: 0; }
|
||||
}
|
||||
.load-state-actions { display: flex; gap: 10px; }
|
||||
.compact-empty { padding: 28px 18px; color: #94a3b8; font-size: 13px; text-align: center; }
|
||||
.publish-form-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
|
||||
@@ -968,7 +1086,8 @@ onBeforeUnmount(() => {
|
||||
@media (max-width: 720px) {
|
||||
.metric-grid, .config-grid { grid-template-columns: 1fr; }
|
||||
.detail-heading .heading-row { align-items: flex-start; flex-wrap: wrap; }
|
||||
.publish-button { width: 100%; margin-left: 0; }
|
||||
.heading-actions { width: 100%; margin-left: 0; }
|
||||
.heading-actions :deep(.el-button) { width: 100%; }
|
||||
.publish-form-grid { grid-template-columns: 1fr; gap: 0; }
|
||||
.result-toolbar { align-items: stretch; flex-direction: column; }
|
||||
.result-filters { padding: 0 16px 16px; flex-direction: column; }
|
||||
|
||||
@@ -46,6 +46,13 @@ const sourceUrl = computed(() => (
|
||||
? getDataProcessSourceRawUrl(props.taskId, props.sourceFileId)
|
||||
: ''
|
||||
))
|
||||
const selectedXlsxLocator = computed(() => {
|
||||
const locator = props.selectedItem?.sourceLocator
|
||||
if (!locator) return null
|
||||
const hasSheet = locator.sheet_index != null || Boolean(locator.sheet_name)
|
||||
const hasRow = locator.row_number != null || locator.sheet_record_index != null
|
||||
return hasSheet && hasRow ? locator : null
|
||||
})
|
||||
const visibleRowRange = computed(() => {
|
||||
const sheet = xlsxPreview.value?.active_sheet
|
||||
if (!sheet || !sheet.rows.length) return '当前工作表没有可预览记录'
|
||||
@@ -95,6 +102,16 @@ const selectedRecordKey = computed(() => {
|
||||
})
|
||||
|
||||
function xlsxRowHighlighted(row: DataProcessXlsxPreviewRow) {
|
||||
const locator = selectedXlsxLocator.value
|
||||
const sheet = xlsxPreview.value?.active_sheet
|
||||
if (locator && sheet) {
|
||||
const sheetMatches = locator.sheet_index != null
|
||||
? sheet.index === locator.sheet_index
|
||||
: sheet.name === locator.sheet_name
|
||||
if (!sheetMatches) return false
|
||||
if (locator.row_number != null) return row.row_number === locator.row_number
|
||||
return row.record_index === locator.sheet_record_index
|
||||
}
|
||||
return Boolean(selectedRecordKey.value && recordKey(row.record) === selectedRecordKey.value)
|
||||
}
|
||||
|
||||
@@ -119,8 +136,11 @@ async function locateSelectedItem() {
|
||||
async function loadPreview(options: { reset?: boolean } = {}) {
|
||||
const sequence = ++loadSequence
|
||||
if (options.reset) {
|
||||
activeSheetIndex.value = 0
|
||||
pageOffset.value = 0
|
||||
const locator = selectedXlsxLocator.value
|
||||
activeSheetIndex.value = locator?.sheet_index ?? 0
|
||||
pageOffset.value = locator?.sheet_record_index == null
|
||||
? 0
|
||||
: Math.floor(locator.sheet_record_index / XLSX_PAGE_SIZE) * XLSX_PAGE_SIZE
|
||||
preview.value = null
|
||||
}
|
||||
errorMessage.value = ''
|
||||
@@ -178,8 +198,34 @@ watch(
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.selectedItem?.id,
|
||||
() => void locateSelectedItem(),
|
||||
() => [
|
||||
props.selectedItem?.id,
|
||||
props.selectedItem?.sourceLocator?.sheet_index,
|
||||
props.selectedItem?.sourceLocator?.sheet_record_index,
|
||||
props.selectedItem?.sourceLocator?.row_number,
|
||||
],
|
||||
() => {
|
||||
const locator = selectedXlsxLocator.value
|
||||
if (!locator || isDocx.value) {
|
||||
void locateSelectedItem()
|
||||
return
|
||||
}
|
||||
const targetSheet = locator.sheet_index ?? activeSheetIndex.value
|
||||
const targetOffset = locator.sheet_record_index == null
|
||||
? pageOffset.value
|
||||
: Math.floor(locator.sheet_record_index / XLSX_PAGE_SIZE) * XLSX_PAGE_SIZE
|
||||
const activeSheet = xlsxPreview.value?.active_sheet
|
||||
if (
|
||||
activeSheet?.index === targetSheet
|
||||
&& activeSheet.offset === targetOffset
|
||||
) {
|
||||
void locateSelectedItem()
|
||||
return
|
||||
}
|
||||
activeSheetIndex.value = targetSheet
|
||||
pageOffset.value = targetOffset
|
||||
void loadPreview()
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -301,6 +347,8 @@ watch(
|
||||
:key="row.row_number"
|
||||
class="xlsx-row"
|
||||
:class="{ 'is-highlighted': xlsxRowHighlighted(row) }"
|
||||
:data-row-number="row.row_number"
|
||||
:data-record-index="row.record_index"
|
||||
>
|
||||
<th class="row-number-cell">{{ row.row_number }}</th>
|
||||
<td
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import OfficeSourceViewer from './OfficeSourceViewer.vue'
|
||||
import PdfSourceViewer from './PdfSourceViewer.vue'
|
||||
import { sourceLines } from './previewModel'
|
||||
import {
|
||||
isManualPreviewItem,
|
||||
sourceLineNumberAtOffset,
|
||||
sourceLineWindow,
|
||||
} from './previewModel'
|
||||
import type { PreviewItem, ProcessType } from './types'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -31,9 +35,11 @@ const sourceViewerRef = ref<HTMLElement | null>(null)
|
||||
const search = ref('')
|
||||
const currentPage = ref(1)
|
||||
const PREVIEW_PAGE_SIZE = 10
|
||||
const SOURCE_LINE_RENDER_LIMIT = 240
|
||||
const SOURCE_LINE_CHARACTER_LIMIT = 4_000
|
||||
const sourceWindowStartLine = ref(1)
|
||||
const editingItemId = ref<string | null>(null)
|
||||
const editorDraft = ref('')
|
||||
const lines = computed(() => sourceLines(props.sourceText))
|
||||
const selectedItem = computed(() => props.items.find((item) => item.id === props.selectedId) ?? props.items[0])
|
||||
const editingItem = computed(() => props.items.find((item) => item.id === editingItemId.value))
|
||||
const normalizedFileFormat = computed(() => (
|
||||
@@ -43,6 +49,27 @@ const normalizedFileFormat = computed(() => (
|
||||
))
|
||||
const isPdfSource = computed(() => normalizedFileFormat.value === 'pdf')
|
||||
const isOfficeSource = computed(() => ['docx', 'xlsx'].includes(normalizedFileFormat.value))
|
||||
const selectedSourceOffset = computed(() => {
|
||||
const item = selectedItem.value
|
||||
return item ? sourceOffsetRange(item)?.start ?? null : null
|
||||
})
|
||||
const selectedSourceLine = computed(() => {
|
||||
const item = selectedItem.value
|
||||
if (!item) return null
|
||||
return sourceLineRange(item)?.start
|
||||
?? (selectedSourceOffset.value == null
|
||||
? null
|
||||
: sourceLineNumberAtOffset(props.sourceText, selectedSourceOffset.value))
|
||||
})
|
||||
const visibleSourceWindow = computed(() => sourceLineWindow(
|
||||
props.sourceText,
|
||||
sourceWindowStartLine.value,
|
||||
SOURCE_LINE_RENDER_LIMIT,
|
||||
SOURCE_LINE_CHARACTER_LIMIT,
|
||||
selectedSourceLine.value,
|
||||
selectedSourceOffset.value,
|
||||
))
|
||||
const lines = computed(() => visibleSourceWindow.value.lines)
|
||||
|
||||
const filteredItems = computed(() => props.items.filter((item, index) => {
|
||||
const matchesSearch = !search.value.trim()
|
||||
@@ -58,10 +85,27 @@ const pagedItems = computed(() => {
|
||||
|
||||
const selectedIndex = computed(() => props.items.findIndex((item) => item.id === selectedItem.value?.id))
|
||||
|
||||
function isLineHighlighted(lineStart: number, lineEnd: number) {
|
||||
function sourceLineRange(item: PreviewItem) {
|
||||
const start = item.sourceLocator?.start_line ?? item.sourceStartLine
|
||||
const end = item.sourceLocator?.end_line ?? item.sourceEndLine ?? start
|
||||
return start == null ? null : { start, end: end ?? start }
|
||||
}
|
||||
|
||||
function sourceOffsetRange(item: PreviewItem) {
|
||||
const start = item.sourceLocator?.source_start ?? item.sourceStart
|
||||
const end = item.sourceLocator?.source_end ?? item.sourceEnd ?? start
|
||||
return start == null ? null : { start, end: Math.max(start, end ?? start) }
|
||||
}
|
||||
|
||||
function isLineHighlighted(lineNumber: number, lineStart: number, lineEnd: number) {
|
||||
const item = selectedItem.value
|
||||
if (!item || item.sourceStart == null || item.sourceEnd == null) return false
|
||||
return lineEnd >= item.sourceStart && lineStart <= item.sourceEnd
|
||||
if (!item) return false
|
||||
const lineRange = sourceLineRange(item)
|
||||
if (lineRange) return lineNumber >= lineRange.start && lineNumber <= lineRange.end
|
||||
const offsetRange = sourceOffsetRange(item)
|
||||
if (!offsetRange) return false
|
||||
const effectiveEnd = Math.max(offsetRange.start + 1, offsetRange.end)
|
||||
return lineEnd >= offsetRange.start && lineStart < effectiveEnd
|
||||
}
|
||||
|
||||
function selectItem(id: string) {
|
||||
@@ -107,34 +151,88 @@ watch(search, () => {
|
||||
|
||||
watch(() => props.selectedFileId, closeEditor)
|
||||
|
||||
watch(selectedItem, async (item) => {
|
||||
watch([selectedItem, () => props.sourceText], async ([item]) => {
|
||||
if (!item) return
|
||||
const visibleIndex = filteredItems.value.findIndex((entry) => entry.id === item.id)
|
||||
if (visibleIndex >= 0) {
|
||||
currentPage.value = Math.floor(visibleIndex / PREVIEW_PAGE_SIZE) + 1
|
||||
}
|
||||
|
||||
if (isPdfSource.value || isOfficeSource.value || item.sourceStart == null) return
|
||||
if (isPdfSource.value || isOfficeSource.value) return
|
||||
const itemLineRange = sourceLineRange(item)
|
||||
const itemOffsetRange = sourceOffsetRange(item)
|
||||
if (!itemLineRange && !itemOffsetRange) {
|
||||
sourceWindowStartLine.value = 1
|
||||
return
|
||||
}
|
||||
const targetLine = selectedSourceLine.value
|
||||
?? sourceLineNumberAtOffset(props.sourceText, itemOffsetRange?.start ?? 0)
|
||||
sourceWindowStartLine.value = Math.max(1, targetLine - Math.floor(SOURCE_LINE_RENDER_LIMIT / 3))
|
||||
await nextTick()
|
||||
const target = sourceViewerRef.value?.querySelector<HTMLElement>(`[data-source-start="${item.sourceStart}"]`)
|
||||
const exactTarget = sourceViewerRef.value
|
||||
?.querySelector<HTMLElement>(`[data-line-number="${targetLine}"]`)
|
||||
const target = exactTarget
|
||||
?? sourceViewerRef.value?.querySelector<HTMLElement>('.source-line.is-highlighted')
|
||||
target?.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||
}, { immediate: true })
|
||||
|
||||
async function showPreviousSourceWindow() {
|
||||
sourceWindowStartLine.value = Math.max(1, sourceWindowStartLine.value - SOURCE_LINE_RENDER_LIMIT)
|
||||
await nextTick()
|
||||
if (sourceViewerRef.value) sourceViewerRef.value.scrollTop = 0
|
||||
}
|
||||
|
||||
async function showNextSourceWindow() {
|
||||
if (!visibleSourceWindow.value.hasMore) return
|
||||
sourceWindowStartLine.value = visibleSourceWindow.value.endLine + 1
|
||||
await nextTick()
|
||||
if (sourceViewerRef.value) sourceViewerRef.value.scrollTop = 0
|
||||
}
|
||||
|
||||
function itemNumber(item: PreviewItem) {
|
||||
return props.items.findIndex((entry) => entry.id === item.id) + 1
|
||||
}
|
||||
|
||||
function lineRange(item: PreviewItem) {
|
||||
if (item.sourcePages?.length) {
|
||||
const first = item.sourcePages[0]
|
||||
const last = item.sourcePages[item.sourcePages.length - 1]
|
||||
return first === last ? `来源:第 ${first} 页` : `来源:第 ${first}–${last} 页`
|
||||
if (isManualPreviewItem(item)) return '手动新增,无源文件定位'
|
||||
|
||||
const locator = item.sourceLocator
|
||||
const locatedLines = sourceLineRange(item)
|
||||
if (props.processType === 'unstructured') {
|
||||
const parts: string[] = []
|
||||
if (item.sourcePages?.length) {
|
||||
const first = item.sourcePages[0]
|
||||
const last = item.sourcePages[item.sourcePages.length - 1]
|
||||
parts.push(first === last ? `第 ${first} 页` : `第 ${first}–${last} 页`)
|
||||
}
|
||||
if (locatedLines) {
|
||||
parts.push(
|
||||
locatedLines.start === locatedLines.end
|
||||
? `第 ${locatedLines.start} 行`
|
||||
: `第 ${locatedLines.start}–${locatedLines.end} 行`,
|
||||
)
|
||||
}
|
||||
if (item.headingPath?.length) parts.push(`章节:${item.headingPath.join(' / ')}`)
|
||||
return parts.length ? `来源:${parts.join(' · ')}` : '来源:源文件内容(无精确定位)'
|
||||
}
|
||||
if (item.sourceStartLine == null || item.sourceEndLine == null) return '手动新增,无源文件定位'
|
||||
return item.sourceStartLine === item.sourceEndLine
|
||||
? `来源:第 ${item.sourceStartLine} 行`
|
||||
: `来源:第 ${item.sourceStartLine}–${item.sourceEndLine} 行`
|
||||
|
||||
if (locator?.kind === 'xlsx') {
|
||||
const sheet = locator.sheet_name || `工作表 ${Number(locator.sheet_index ?? 0) + 1}`
|
||||
return locator.row_number != null
|
||||
? `来源:${sheet} · 第 ${locator.row_number} 行`
|
||||
: `来源:${sheet}`
|
||||
}
|
||||
if (locator?.kind === 'json') {
|
||||
return locator.json_pointer
|
||||
? `来源:JSON 路径 ${locator.json_pointer}`
|
||||
: '来源:JSON 根对象'
|
||||
}
|
||||
if (locatedLines) {
|
||||
return locatedLines.start === locatedLines.end
|
||||
? `来源:第 ${locatedLines.start} 行`
|
||||
: `来源:第 ${locatedLines.start}–${locatedLines.end} 行`
|
||||
}
|
||||
return '来源:源文件记录'
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -175,6 +273,31 @@ function lineRange(item: PreviewItem) {
|
||||
<div>
|
||||
<strong>源文件 · {{ fileName }}</strong>
|
||||
</div>
|
||||
<div
|
||||
v-if="!isPdfSource && !isOfficeSource && lines.length"
|
||||
class="source-window-controls"
|
||||
aria-label="源文件行窗口"
|
||||
>
|
||||
<span>第 {{ visibleSourceWindow.startLine }}–{{ visibleSourceWindow.endLine }} 行</span>
|
||||
<el-button
|
||||
link
|
||||
size="small"
|
||||
aria-label="查看上一段源文件"
|
||||
:disabled="!visibleSourceWindow.hasPrevious"
|
||||
@click="showPreviousSourceWindow"
|
||||
>
|
||||
上一段
|
||||
</el-button>
|
||||
<el-button
|
||||
link
|
||||
size="small"
|
||||
aria-label="查看下一段源文件"
|
||||
:disabled="!visibleSourceWindow.hasMore"
|
||||
@click="showNextSourceWindow"
|
||||
>
|
||||
下一段
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PdfSourceViewer
|
||||
@@ -197,8 +320,9 @@ function lineRange(item: PreviewItem) {
|
||||
v-for="line in lines"
|
||||
:key="line.number"
|
||||
class="source-line"
|
||||
:class="{ 'is-highlighted': isLineHighlighted(line.start, line.end) }"
|
||||
:class="{ 'is-highlighted': isLineHighlighted(line.number, line.start, line.end) }"
|
||||
:data-source-start="line.start"
|
||||
:data-line-number="line.number"
|
||||
>
|
||||
<span class="line-number">{{ line.number }}</span>
|
||||
<span class="line-content">{{ line.content || ' ' }}</span>
|
||||
@@ -282,7 +406,7 @@ function lineRange(item: PreviewItem) {
|
||||
/>
|
||||
<div class="editor-actions">
|
||||
<el-button
|
||||
v-if="editingItem.sourceStart != null"
|
||||
v-if="!isManualPreviewItem(editingItem)"
|
||||
link
|
||||
@click="restoreItem"
|
||||
>
|
||||
@@ -431,6 +555,22 @@ function lineRange(item: PreviewItem) {
|
||||
}
|
||||
}
|
||||
|
||||
.source-window-controls {
|
||||
flex: none;
|
||||
gap: 2px !important;
|
||||
|
||||
> span {
|
||||
margin-right: 4px;
|
||||
color: #8a93a3;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
:deep(.el-button) {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.source-viewer {
|
||||
flex: 1;
|
||||
height: 538px;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type {
|
||||
GenerationControlOptions,
|
||||
PreprocessOption,
|
||||
@@ -21,27 +22,32 @@ const emit = defineEmits<{
|
||||
'update:options': [value: StructuredProcessOptions]
|
||||
}>()
|
||||
|
||||
const PREPROCESS_OPTIONS: Array<{
|
||||
value: PreprocessOption
|
||||
const PREPROCESS_GROUPS: Array<{
|
||||
values: PreprocessOption[]
|
||||
label: string
|
||||
description: string
|
||||
}> = [
|
||||
{ value: 'clean_invalid', label: '清理无效数据', description: '清理全空列,并剔除关键字段残缺的数据行' },
|
||||
{
|
||||
value: 'detect_structure',
|
||||
label: '嵌套结构展平',
|
||||
description: '展平嵌套对象和可解析的 JSON 字段;Excel 表头与合并单元格在上传时自动解析',
|
||||
values: ['clean_invalid', 'deduplicate'],
|
||||
label: '数据清洗',
|
||||
description: '清理全空列和空记录,并删除内容完全相同的记录;不会猜测可空字段是否必填',
|
||||
},
|
||||
{
|
||||
value: 'deduplicate',
|
||||
label: '重复记录去重',
|
||||
description: '按整行内容或 id、uuid、key、code、*_id 等身份字段去重,暂不支持自定义组合字段',
|
||||
values: ['detect_structure', 'normalize_format'],
|
||||
label: '结构标准化',
|
||||
description: '展平嵌套对象和可解析的 JSON 字段,并统一编码、空白、字段名和 JSON 序列化格式',
|
||||
},
|
||||
{
|
||||
values: ['desensitize'],
|
||||
label: '敏感信息脱敏',
|
||||
description: '识别并脱敏姓名、手机号、邮箱和身份证号',
|
||||
},
|
||||
{ value: 'normalize_format', label: '数据格式标准化', description: '按所选规则统一编码、空白、字段名及 JSON 序列化格式' },
|
||||
{ value: 'filter_anomaly', label: '异常数据过滤', description: '使用 IQR 识别数值离群值,并过滤乱码等异常记录' },
|
||||
{ value: 'desensitize', label: '敏感信息脱敏', description: '识别并脱敏姓名、手机号、邮箱和身份证号' },
|
||||
]
|
||||
|
||||
const legacyAnomalyFilterEnabled = computed(() => (
|
||||
props.options.preprocessOptions.includes('filter_anomaly')
|
||||
))
|
||||
|
||||
function updateField<K extends keyof StructuredProcessOptions>(
|
||||
field: K,
|
||||
value: StructuredProcessOptions[K],
|
||||
@@ -57,14 +63,26 @@ function updateQaPairsPerRow(value: number | undefined) {
|
||||
updateField('qaPairsPerRow', normalizeQaPairsGenerationCount(value))
|
||||
}
|
||||
|
||||
function updatePreprocessOptions(value: Array<string | number | boolean>) {
|
||||
const allowedValues = new Set(PREPROCESS_OPTIONS.map((option) => option.value))
|
||||
const preprocessOptions = Array.from(new Set(value.filter(
|
||||
(option): option is PreprocessOption => (
|
||||
typeof option === 'string' && allowedValues.has(option as PreprocessOption)
|
||||
),
|
||||
)))
|
||||
updateField('preprocessOptions', preprocessOptions)
|
||||
function selectedCount(values: PreprocessOption[]) {
|
||||
return values.filter((value) => props.options.preprocessOptions.includes(value)).length
|
||||
}
|
||||
|
||||
function groupSelected(values: PreprocessOption[]) {
|
||||
return selectedCount(values) === values.length
|
||||
}
|
||||
|
||||
function groupIndeterminate(values: PreprocessOption[]) {
|
||||
const count = selectedCount(values)
|
||||
return count > 0 && count < values.length
|
||||
}
|
||||
|
||||
function updatePreprocessGroup(values: PreprocessOption[], checked: string | number | boolean) {
|
||||
const next = new Set(props.options.preprocessOptions)
|
||||
values.forEach((value) => {
|
||||
if (Boolean(checked)) next.add(value)
|
||||
else next.delete(value)
|
||||
})
|
||||
updateField('preprocessOptions', [...next])
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -73,26 +91,34 @@ function updatePreprocessOptions(value: Array<string | number | boolean>) {
|
||||
<div class="section-title-row">
|
||||
<div>
|
||||
<h3>预处理选项</h3>
|
||||
<p>选择在生成问答对之前需要执行的数据处理方式</p>
|
||||
<p>默认不执行预处理,请按数据情况自行选择</p>
|
||||
</div>
|
||||
</div>
|
||||
<el-checkbox-group
|
||||
:model-value="options.preprocessOptions"
|
||||
class="preprocess-option-grid"
|
||||
@update:model-value="updatePreprocessOptions"
|
||||
>
|
||||
<el-checkbox
|
||||
v-for="option in PREPROCESS_OPTIONS"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
<div class="preprocess-option-grid">
|
||||
<label
|
||||
v-for="group in PREPROCESS_GROUPS"
|
||||
:key="group.label"
|
||||
class="preprocess-option"
|
||||
:class="{ 'is-checked': groupSelected(group.values) }"
|
||||
>
|
||||
<el-checkbox
|
||||
:model-value="groupSelected(group.values)"
|
||||
:indeterminate="groupIndeterminate(group.values)"
|
||||
@update:model-value="updatePreprocessGroup(group.values, $event)"
|
||||
/>
|
||||
<span class="preprocess-option-copy">
|
||||
<strong>{{ option.label }}</strong>
|
||||
<small>{{ option.description }}</small>
|
||||
<strong>{{ group.label }}</strong>
|
||||
<small>{{ group.description }}</small>
|
||||
</span>
|
||||
</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</label>
|
||||
</div>
|
||||
<el-alert
|
||||
v-if="legacyAnomalyFilterEnabled"
|
||||
class="legacy-preprocess-alert"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
title="该历史任务仍启用了已停用的“异常数据过滤”;为保证结果可复现,本次继续保留"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-section generation-options-section">
|
||||
|
||||
@@ -119,7 +119,7 @@ defineExpose({ revealValidation })
|
||||
<div class="section-title-row">
|
||||
<div>
|
||||
<h3>预处理选项</h3>
|
||||
<p>默认启用结构感知的推荐策略,只需决定是否需要脱敏</p>
|
||||
<p>默认不执行预处理,请按文档情况自行选择</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="preprocess-option-grid">
|
||||
|
||||
@@ -97,7 +97,7 @@ export function isBuiltInGenerationPrompt(value: string) {
|
||||
|
||||
export function createDefaultStructuredOptions(): StructuredProcessOptions {
|
||||
return {
|
||||
preprocessOptions: ['clean_invalid', 'detect_structure', 'deduplicate', 'normalize_format'],
|
||||
preprocessOptions: [],
|
||||
semanticEnrichment: false,
|
||||
qaPairsPerRow: 1,
|
||||
datasetSplit: { train: 80, validation: 10, test: 10 },
|
||||
@@ -117,22 +117,15 @@ export function createDefaultStructuredOptions(): StructuredProcessOptions {
|
||||
|
||||
export function createDefaultUnstructuredOptions(): UnstructuredProcessOptions {
|
||||
return {
|
||||
preprocessOptions: [
|
||||
'clean_invalid_content',
|
||||
'detect_document_structure',
|
||||
'merge_short_content',
|
||||
'filter_low_quality',
|
||||
'deduplicate_content',
|
||||
'preserve_context',
|
||||
],
|
||||
preprocessOptions: [],
|
||||
chunkMethod: 'layout_hybrid',
|
||||
chunkSize: 800,
|
||||
chunkOverlap: 100,
|
||||
minChunkSize: 100,
|
||||
semanticBreakpointPercentile: 95,
|
||||
preserveTables: true,
|
||||
preserveCodeBlocks: true,
|
||||
preserveLists: true,
|
||||
preserveTables: false,
|
||||
preserveCodeBlocks: false,
|
||||
preserveLists: false,
|
||||
semanticEnrichment: false,
|
||||
qaPairsPerChunk: 1,
|
||||
datasetSplit: { train: 80, validation: 10, test: 10 },
|
||||
@@ -218,11 +211,21 @@ function generationOptionsFromConfig(
|
||||
export function createStructuredOptionsFromConfig(config: DataProcessConfig): StructuredProcessOptions {
|
||||
const defaults = createDefaultStructuredOptions()
|
||||
const preprocessOptions = configValue<unknown>(config, 'preprocess_options', [])
|
||||
const supportedPreprocessOptions = new Set<PreprocessOption>([
|
||||
'clean_invalid',
|
||||
'deduplicate',
|
||||
'detect_structure',
|
||||
'normalize_format',
|
||||
'desensitize',
|
||||
'filter_anomaly',
|
||||
])
|
||||
return {
|
||||
...defaults,
|
||||
...generationOptionsFromConfig(config, defaults),
|
||||
preprocessOptions: Array.isArray(preprocessOptions)
|
||||
? preprocessOptions.map(String) as PreprocessOption[]
|
||||
? Array.from(new Set(preprocessOptions.map(String).filter(
|
||||
(option): option is PreprocessOption => supportedPreprocessOptions.has(option as PreprocessOption),
|
||||
)))
|
||||
: defaults.preprocessOptions,
|
||||
semanticEnrichment: Boolean(configValue(
|
||||
config,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { SourceLine } from './types'
|
||||
import type { PreviewItem, SourceLine } from './types'
|
||||
|
||||
/** 仅用于“使用示例”上传;正式预览和切片全部由后端生成。 */
|
||||
export const DEFAULT_SOURCE_TEXT = [
|
||||
@@ -12,19 +12,141 @@ export const DEFAULT_SOURCE_TEXT = [
|
||||
'答:复利是将上一期利息加入本金,再计算下一期利息。',
|
||||
].join('\n')
|
||||
|
||||
/**
|
||||
* 把后端返回的字符偏移映射为源文件行,仅负责界面高亮,不参与切片。
|
||||
*/
|
||||
export function sourceLines(sourceText: string): SourceLine[] {
|
||||
const rawLines = sourceText.split('\n')
|
||||
let cursor = 0
|
||||
export interface SourceLineWindow {
|
||||
lines: SourceLine[]
|
||||
startLine: number
|
||||
endLine: number
|
||||
hasPrevious: boolean
|
||||
hasMore: boolean
|
||||
}
|
||||
|
||||
return rawLines.map((content, index) => {
|
||||
const start = cursor
|
||||
const end = start + content.length
|
||||
cursor = end + (index < rawLines.length - 1 ? 1 : 0)
|
||||
return { number: index + 1, content, start, end }
|
||||
})
|
||||
function unicodeCodePointLength(value: string, start = 0, end = value.length) {
|
||||
let length = 0
|
||||
let index = start
|
||||
while (index < end) {
|
||||
const codePoint = value.codePointAt(index)
|
||||
index += codePoint != null && codePoint > 0xffff ? 2 : 1
|
||||
length += 1
|
||||
}
|
||||
return length
|
||||
}
|
||||
|
||||
function advanceCodePoints(value: string, start: number, end: number, count: number) {
|
||||
let index = start
|
||||
let remaining = Math.max(0, count)
|
||||
while (index < end && remaining > 0) {
|
||||
const codePoint = value.codePointAt(index)
|
||||
index += codePoint != null && codePoint > 0xffff ? 2 : 1
|
||||
remaining -= 1
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
/**
|
||||
* 只扫描并返回当前可见行窗口,不对全文 split,避免大文件生成巨量字符串数组。
|
||||
* 字符定位场景可开启 code point 偏移,以与后端 Python 的字符计数保持一致。
|
||||
*/
|
||||
export function sourceLineWindow(
|
||||
sourceText: string,
|
||||
requestedStartLine: number,
|
||||
maxLines: number,
|
||||
maxCharactersPerLine: number,
|
||||
focusLine: number | null = null,
|
||||
focusOffset: number | null = null,
|
||||
): SourceLineWindow {
|
||||
const startLine = Math.max(1, Math.trunc(requestedStartLine) || 1)
|
||||
const limit = Math.max(1, Math.trunc(maxLines) || 1)
|
||||
const characterLimit = Math.max(1, Math.trunc(maxCharactersPerLine) || 1)
|
||||
const trackUnicodeOffsets = focusOffset != null
|
||||
const lines: SourceLine[] = []
|
||||
let lineNumber = 1
|
||||
let jsCursor = 0
|
||||
let sourceCursor = 0
|
||||
|
||||
while (jsCursor <= sourceText.length && lineNumber < startLine) {
|
||||
const newlineIndex = sourceText.indexOf('\n', jsCursor)
|
||||
const jsEnd = newlineIndex >= 0 ? newlineIndex : sourceText.length
|
||||
sourceCursor = trackUnicodeOffsets
|
||||
? sourceCursor + unicodeCodePointLength(sourceText, jsCursor, jsEnd) + (newlineIndex >= 0 ? 1 : 0)
|
||||
: (newlineIndex >= 0 ? newlineIndex + 1 : sourceText.length + 1)
|
||||
jsCursor = newlineIndex >= 0 ? newlineIndex + 1 : sourceText.length + 1
|
||||
lineNumber += 1
|
||||
}
|
||||
|
||||
while (jsCursor <= sourceText.length && lines.length < limit) {
|
||||
const newlineIndex = sourceText.indexOf('\n', jsCursor)
|
||||
const jsEnd = newlineIndex >= 0 ? newlineIndex : sourceText.length
|
||||
const fullSourceEnd = trackUnicodeOffsets
|
||||
? sourceCursor + unicodeCodePointLength(sourceText, jsCursor, jsEnd)
|
||||
: jsEnd
|
||||
const focusedStart = focusLine === lineNumber && focusOffset != null
|
||||
? Math.max(sourceCursor, focusOffset - Math.floor(characterLimit / 3))
|
||||
: sourceCursor
|
||||
const segmentSourceStart = Math.min(
|
||||
focusedStart,
|
||||
Math.max(sourceCursor, fullSourceEnd - characterLimit),
|
||||
)
|
||||
const relativeSegmentStart = trackUnicodeOffsets
|
||||
? segmentSourceStart - sourceCursor
|
||||
: Math.max(0, segmentSourceStart - jsCursor)
|
||||
const segmentJsStart = advanceCodePoints(
|
||||
sourceText,
|
||||
jsCursor,
|
||||
jsEnd,
|
||||
relativeSegmentStart,
|
||||
)
|
||||
const segmentJsEnd = advanceCodePoints(
|
||||
sourceText,
|
||||
segmentJsStart,
|
||||
jsEnd,
|
||||
characterLimit,
|
||||
)
|
||||
const segmentLength = trackUnicodeOffsets
|
||||
? unicodeCodePointLength(sourceText, segmentJsStart, segmentJsEnd)
|
||||
: segmentJsEnd - segmentJsStart
|
||||
const start = trackUnicodeOffsets ? segmentSourceStart : segmentJsStart
|
||||
const end = start + segmentLength
|
||||
const content = `${segmentJsStart > jsCursor ? '… ' : ''}${sourceText.slice(segmentJsStart, segmentJsEnd)}${segmentJsEnd < jsEnd ? ' …' : ''}`
|
||||
lines.push({ number: lineNumber, content, start, end })
|
||||
sourceCursor = fullSourceEnd + (newlineIndex >= 0 ? 1 : 0)
|
||||
jsCursor = newlineIndex >= 0 ? newlineIndex + 1 : sourceText.length + 1
|
||||
lineNumber += 1
|
||||
}
|
||||
|
||||
return {
|
||||
lines,
|
||||
startLine: lines[0]?.number ?? startLine,
|
||||
endLine: lines[lines.length - 1]?.number ?? startLine,
|
||||
hasPrevious: startLine > 1,
|
||||
hasMore: jsCursor <= sourceText.length,
|
||||
}
|
||||
}
|
||||
|
||||
/** 根据后端 code point 偏移查找物理行号,不构建全文行数组。 */
|
||||
export function sourceLineNumberAtOffset(sourceText: string, targetOffset: number) {
|
||||
const normalizedOffset = Math.max(0, Math.trunc(targetOffset) || 0)
|
||||
let offset = 0
|
||||
let lineNumber = 1
|
||||
for (const character of sourceText) {
|
||||
if (offset >= normalizedOffset) break
|
||||
if (character === '\n') lineNumber += 1
|
||||
offset += 1
|
||||
}
|
||||
return lineNumber
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动新增项可能先以空内容保存为 invalid,编辑后又由后端标记为 modified,
|
||||
* 因此不能只依赖可变的 status;空原文且完全没有来源定位才是稳定兜底。
|
||||
*/
|
||||
export function isManualPreviewItem(item: PreviewItem): boolean {
|
||||
const hasSourceLocation = item.sourceStart != null
|
||||
|| item.sourceEnd != null
|
||||
|| item.sourceStartLine != null
|
||||
|| item.sourceEndLine != null
|
||||
|| Boolean(item.sourcePages?.length)
|
||||
|| Boolean(item.sourceLocator)
|
||||
return item.status === 'manual' || (!item.originalContent && !hasSourceLocation)
|
||||
}
|
||||
|
||||
/** 与后端预览 token 估算规则一致,仅用于编辑中的即时计数。 */
|
||||
|
||||
@@ -24,6 +24,7 @@ export type PreprocessOption =
|
||||
| 'detect_structure'
|
||||
| 'deduplicate'
|
||||
| 'normalize_format'
|
||||
/** 仅用于恢复历史任务,新任务界面不再提供。 */
|
||||
| 'filter_anomaly'
|
||||
| 'desensitize'
|
||||
|
||||
@@ -94,7 +95,6 @@ export interface ExternalDataSource {
|
||||
export interface UploadedDataFile {
|
||||
uid: string | number
|
||||
sourceFileId?: string
|
||||
rawFile?: File
|
||||
name: string
|
||||
size: number
|
||||
count: number
|
||||
@@ -118,6 +118,22 @@ export interface SourceLine {
|
||||
end: number
|
||||
}
|
||||
|
||||
export type PreviewSourceLocatorKind = 'json' | 'jsonl' | 'csv' | 'xlsx'
|
||||
|
||||
export interface PreviewSourceLocator {
|
||||
kind: PreviewSourceLocatorKind
|
||||
record_index?: number | null
|
||||
start_line?: number | null
|
||||
end_line?: number | null
|
||||
source_start?: number | null
|
||||
source_end?: number | null
|
||||
json_pointer?: string | null
|
||||
sheet_index?: number | null
|
||||
sheet_name?: string | null
|
||||
row_number?: number | null
|
||||
sheet_record_index?: number | null
|
||||
}
|
||||
|
||||
export interface PreviewItem {
|
||||
id: string
|
||||
sourceFileId: string
|
||||
@@ -129,6 +145,8 @@ export interface PreviewItem {
|
||||
sourceStartLine: number | null
|
||||
sourceEndLine: number | null
|
||||
sourcePages?: number[]
|
||||
sourceLocator?: PreviewSourceLocator
|
||||
headingPath?: string[]
|
||||
tokenCount: number
|
||||
status: 'original' | 'modified' | 'manual' | 'invalid'
|
||||
qualityScore?: number
|
||||
|
||||
@@ -71,7 +71,11 @@ export function useDataProcessGeneration(bindings: GenerationBindings) {
|
||||
let generationTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let generationRun = 0
|
||||
let pollFailureCount = 0
|
||||
let generationStarting = false
|
||||
const generationStarting = ref(false)
|
||||
const generationRestoring = ref(false)
|
||||
const canReturnFromGeneration = computed(() => (
|
||||
generation.status === 'idle' && !generationStarting.value && !generationRestoring.value
|
||||
))
|
||||
|
||||
function stopGenerationTimer() {
|
||||
generationRun += 1
|
||||
@@ -170,14 +174,14 @@ export function useDataProcessGeneration(bindings: GenerationBindings) {
|
||||
}
|
||||
|
||||
async function startGeneration() {
|
||||
if (generationStarting || generation.status === 'running') return false
|
||||
if (generationStarting.value || generation.status === 'running') return false
|
||||
const taskId = bindings.taskId.value
|
||||
if (!taskId) {
|
||||
ElMessage.error('任务尚未创建,请返回上一步重试')
|
||||
return false
|
||||
}
|
||||
|
||||
generationStarting = true
|
||||
generationStarting.value = true
|
||||
let runId: number | null = null
|
||||
try {
|
||||
const canStart = await bindings.beforeGenerate?.()
|
||||
@@ -204,17 +208,18 @@ export function useDataProcessGeneration(bindings: GenerationBindings) {
|
||||
generation.message = error instanceof Error ? error.message : '启动数据处理失败,请重试。'
|
||||
return false
|
||||
} finally {
|
||||
generationStarting = false
|
||||
generationStarting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function resumeGeneration() {
|
||||
const taskId = bindings.taskId.value
|
||||
if (!taskId) return
|
||||
stopGenerationTimer()
|
||||
const activeRunId = generationRun
|
||||
pollFailureCount = 0
|
||||
generationRestoring.value = true
|
||||
try {
|
||||
stopGenerationTimer()
|
||||
const activeRunId = generationRun
|
||||
pollFailureCount = 0
|
||||
const progress = await getDataProcessProgress(taskId)
|
||||
if (activeRunId !== generationRun) return
|
||||
if (progress.status === 'running') {
|
||||
@@ -236,6 +241,8 @@ export function useDataProcessGeneration(bindings: GenerationBindings) {
|
||||
} catch (error) {
|
||||
generation.status = 'failed'
|
||||
generation.message = error instanceof Error ? error.message : '查询任务进度失败,请重试。'
|
||||
} finally {
|
||||
generationRestoring.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -430,7 +437,9 @@ export function useDataProcessGeneration(bindings: GenerationBindings) {
|
||||
|
||||
return {
|
||||
bulkRegeneration,
|
||||
canReturnFromGeneration,
|
||||
generation,
|
||||
generationStarting,
|
||||
regeneratingResultId,
|
||||
resultRegenerationBusy,
|
||||
results,
|
||||
|
||||
@@ -2,7 +2,6 @@ import { computed, nextTick, ref, type Reactive, type Ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import {
|
||||
getDataProcessPreview,
|
||||
getDataProcessSourceContent,
|
||||
getDataProcessTask,
|
||||
regenerateDataProcessTask,
|
||||
} from '@/api/modules/dataProcess'
|
||||
@@ -15,7 +14,10 @@ import {
|
||||
createStructuredOptionsFromConfig,
|
||||
createUnstructuredOptionsFromConfig,
|
||||
} from './dataProcessCreateState'
|
||||
import { mapDataProcessSourceFile } from './useDataProcessSourceUpload'
|
||||
import {
|
||||
loadCanonicalSourceContent,
|
||||
mapDataProcessSourceFile,
|
||||
} from './useDataProcessSourceUpload'
|
||||
import type {
|
||||
PreviewItem,
|
||||
ProcessType,
|
||||
@@ -50,24 +52,6 @@ interface RegenerationBindings {
|
||||
resetDownstream: () => void
|
||||
}
|
||||
|
||||
async function loadSourceContent(taskId: string, fileId: string | number) {
|
||||
const chunks: string[] = []
|
||||
let startLine = 1
|
||||
while (true) {
|
||||
const source = await getDataProcessSourceContent(taskId, fileId, {
|
||||
start_line: startLine,
|
||||
line_count: 10_000,
|
||||
})
|
||||
chunks.push(source.content || '')
|
||||
if (!source.has_more) break
|
||||
const nextLine = Number(source.end_line || startLine) + 1
|
||||
if (nextLine <= startLine) break
|
||||
startLine = nextLine
|
||||
}
|
||||
// source_content_lines 已保留原始换行;分页之间直接拼接,避免凭空增加空行并破坏偏移。
|
||||
return chunks.join('')
|
||||
}
|
||||
|
||||
async function loadAllPreviews(taskId: string, mapPreviewItem: RegenerationBindings['mapPreviewItem']) {
|
||||
const first = await getDataProcessPreview(taskId, { page: 1, page_size: 500 })
|
||||
const items = [...first.items]
|
||||
@@ -97,7 +81,7 @@ export function useDataProcessRegeneration(bindings: RegenerationBindings) {
|
||||
async function hydrateWorkspace(task: DataProcessTask, preservePreviews: boolean) {
|
||||
const taskId = String(task.id)
|
||||
bindings.uploadedFiles.value = await Promise.all((task.source_files || []).map(async (file) => (
|
||||
mapDataProcessSourceFile(file, await loadSourceContent(taskId, file.id))
|
||||
mapDataProcessSourceFile(file, await loadCanonicalSourceContent(taskId, file.id))
|
||||
)))
|
||||
bindings.previewItems.value = preservePreviews
|
||||
? await loadAllPreviews(taskId, bindings.mapPreviewItem)
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
} from '@/api/modules/dataProcess'
|
||||
import type { ProcessType, UploadedDataFile } from './types'
|
||||
|
||||
const BINARY_FILE_EXTENSIONS = new Set(['xlsx', 'pdf', 'docx', 'pptx'])
|
||||
const STRUCTURED_FILE_EXTENSIONS = new Set(['json', 'jsonl', 'ndjson', 'csv', 'tsv', 'xlsx'])
|
||||
const UNSTRUCTURED_FILE_EXTENSIONS = new Set([
|
||||
'txt', 'md', 'markdown', 'pdf', 'docx', 'pptx', 'json', 'jsonl', 'ndjson',
|
||||
@@ -15,11 +14,11 @@ const LEGACY_OFFICE_EXTENSIONS = new Set(['doc', 'xls', 'ppt'])
|
||||
const MAX_SOURCE_FILE_BYTES = 200 * 1024 * 1024
|
||||
const MAX_SOURCE_FILE_COUNT = 20
|
||||
const MAX_SOURCE_BATCH_BYTES = 500 * 1024 * 1024
|
||||
const SOURCE_CONTENT_PAGE_CHARS = 1_000_000
|
||||
|
||||
interface SourceUploadJob {
|
||||
uid: string
|
||||
file: File
|
||||
extension: string
|
||||
}
|
||||
|
||||
interface SourceUploadOptions {
|
||||
@@ -60,9 +59,6 @@ export function validateSourceFileSelection(
|
||||
: '结构化数据支持 JSON、JSONL、NDJSON、CSV、TSV、XLSX',
|
||||
}
|
||||
}
|
||||
if (selectedFiles.some((file) => file.name === raw.name && file.size === raw.size)) {
|
||||
return { valid: false, severity: 'warning', message: '同名且同大小的文件已经选择' }
|
||||
}
|
||||
if (selectedFiles.length >= MAX_SOURCE_FILE_COUNT) {
|
||||
return { valid: false, severity: 'warning', message: `每个任务最多选择 ${MAX_SOURCE_FILE_COUNT} 个文件` }
|
||||
}
|
||||
@@ -73,6 +69,34 @@ export function validateSourceFileSelection(
|
||||
return { valid: true, extension }
|
||||
}
|
||||
|
||||
function unicodeCodePointLength(value: string) {
|
||||
let length = 0
|
||||
for (const _character of value) length += 1
|
||||
return length
|
||||
}
|
||||
|
||||
/** 分页读取服务端保存的规范化正文,避免重新使用浏览器本地解码结果。 */
|
||||
export async function loadCanonicalSourceContent(
|
||||
taskId: string | number,
|
||||
fileId: string | number,
|
||||
) {
|
||||
const chunks: string[] = []
|
||||
let offset = 0
|
||||
while (true) {
|
||||
const source = await getDataProcessSourceContent(taskId, fileId, {
|
||||
offset,
|
||||
limit: SOURCE_CONTENT_PAGE_CHARS,
|
||||
})
|
||||
const content = source.content || ''
|
||||
chunks.push(content)
|
||||
if (!source.has_more) break
|
||||
const nextOffset = Number(source.offset ?? offset) + unicodeCodePointLength(content)
|
||||
if (nextOffset <= offset) throw new Error('服务端规范化内容分页异常,请删除文件后重试')
|
||||
offset = nextOffset
|
||||
}
|
||||
return chunks.join('')
|
||||
}
|
||||
|
||||
export function mapDataProcessSourceFile(
|
||||
file: DataProcessSourceFile,
|
||||
content = '',
|
||||
@@ -126,16 +150,6 @@ export function useDataProcessSourceUpload(options: SourceUploadOptions) {
|
||||
pending.uploadError = undefined
|
||||
|
||||
try {
|
||||
let content = ''
|
||||
if (!BINARY_FILE_EXTENSIONS.has(job.extension)) {
|
||||
try {
|
||||
content = new TextDecoder('utf-8', { fatal: true }).decode(await job.file.arrayBuffer())
|
||||
} catch {
|
||||
throw new Error('文本文件不是有效的 UTF-8 编码,请转换编码后重试')
|
||||
}
|
||||
if (!content.trim()) throw new Error('不能上传空文件')
|
||||
}
|
||||
|
||||
const uploaded = await uploadDataProcessSourceFiles(currentTaskId, [job.file], (progress) => {
|
||||
pending.uploadProgress = progress
|
||||
})
|
||||
@@ -144,22 +158,13 @@ export function useDataProcessSourceUpload(options: SourceUploadOptions) {
|
||||
|
||||
// 先登记后端 ID,确保正文读取失败时仍可正确删除已落库的文件。
|
||||
Object.assign(pending, mapDataProcessSourceFile(source), {
|
||||
rawFile: job.file,
|
||||
status: 'uploading',
|
||||
uploadProgress: 99,
|
||||
})
|
||||
if (BINARY_FILE_EXTENSIONS.has(job.extension)) {
|
||||
try {
|
||||
const parsed = await getDataProcessSourceContent(currentTaskId, source.id, {
|
||||
start_line: 1,
|
||||
line_count: 10_000,
|
||||
})
|
||||
pending.content = parsed.content
|
||||
} catch {
|
||||
// 原文件已经成功落库,正文稍后仍可由预览构建接口读取,不重复上传。
|
||||
}
|
||||
} else {
|
||||
pending.content = content
|
||||
try {
|
||||
pending.content = await loadCanonicalSourceContent(currentTaskId, source.id)
|
||||
} catch {
|
||||
throw new Error('文件已上传,但服务端规范化内容读取失败,请删除文件后重试')
|
||||
}
|
||||
|
||||
pending.status = 'ready'
|
||||
|
||||
129
frontend/src/views/projects/ProjectDetailView.vue
Normal file
129
frontend/src/views/projects/ProjectDetailView.vue
Normal file
@@ -0,0 +1,129 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import DataTablePage from '@/components/DataTablePage.vue'
|
||||
import AclDialog from '@/components/AclDialog.vue'
|
||||
import { getProject, getProjectMembers, addProjectMember, removeProjectMember, type Project, type ProjectMember } from '@/api/modules/project'
|
||||
import { getUsers, type SystemUser } from '@/api/modules/system'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const project = ref<Project | null>(null)
|
||||
const members = ref<ProjectMember[]>([])
|
||||
const users = ref<SystemUser[]>([])
|
||||
const loading = ref(false)
|
||||
const aclVisible = ref(false)
|
||||
const showAddMember = ref(false)
|
||||
const addForm = ref({ user_id: '', role: 'member' })
|
||||
|
||||
async function load() {
|
||||
const id = route.params.id as string
|
||||
loading.value = true
|
||||
try {
|
||||
project.value = await getProject(id)
|
||||
members.value = await getProjectMembers(id)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
try {
|
||||
users.value = await getUsers()
|
||||
} catch {
|
||||
users.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function submitAddMember() {
|
||||
if (!project.value) return
|
||||
if (!addForm.value.user_id) {
|
||||
ElMessage.warning('请选择用户')
|
||||
return
|
||||
}
|
||||
await addProjectMember(project.value.id, { ...addForm.value })
|
||||
ElMessage.success('成员已添加')
|
||||
showAddMember.value = false
|
||||
addForm.value = { user_id: '', role: 'member' }
|
||||
load()
|
||||
}
|
||||
|
||||
async function removeMember(userId: string) {
|
||||
if (!project.value) return
|
||||
await removeProjectMember(project.value.id, userId)
|
||||
ElMessage.success('已移除成员')
|
||||
load()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadUsers()
|
||||
load()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<el-page-header title="返回" @back="router.back()">
|
||||
<template #content>
|
||||
<span class="page-title">项目详情:{{ project?.name }}</span>
|
||||
</template>
|
||||
</el-page-header>
|
||||
<el-card class="section" v-loading="loading">
|
||||
<template #header>基本信息</template>
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="名称">{{ project?.name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="编码">{{ project?.code }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">{{ project?.status }}</el-descriptions-item>
|
||||
<el-descriptions-item label="租户">{{ project?.tenant_id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="描述" :span="2">{{ project?.description }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<el-divider />
|
||||
<el-button @click="aclVisible = true">资源授权 (ACL)</el-button>
|
||||
</el-card>
|
||||
<el-card class="section">
|
||||
<template #header>
|
||||
项目成员
|
||||
<el-button type="primary" size="small" style="float: right" @click="showAddMember = true">添加成员</el-button>
|
||||
</template>
|
||||
<DataTablePage title="项目成员" :data="members">
|
||||
<template #columns>
|
||||
<el-table-column prop="username" label="用户名" min-width="140" />
|
||||
<el-table-column prop="display_name" label="显示名" min-width="120" />
|
||||
<el-table-column prop="role" label="角色" min-width="100" />
|
||||
<el-table-column prop="create_time" label="加入时间" min-width="180" />
|
||||
</template>
|
||||
<template #actions="{ row }">
|
||||
<el-button link type="danger" @click="removeMember(row.user_id)">移除</el-button>
|
||||
</template>
|
||||
</DataTablePage>
|
||||
</el-card>
|
||||
<AclDialog v-model="aclVisible" resource-type="project" :resource-id="(route.params.id as string)" />
|
||||
<el-dialog v-model="showAddMember" title="添加成员" width="420px">
|
||||
<el-form label-width="80px">
|
||||
<el-form-item label="用户" required>
|
||||
<el-select v-model="addForm.user_id" filterable style="width: 100%">
|
||||
<el-option v-for="u in users" :key="u.id" :label="u.username" :value="u.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="角色">
|
||||
<el-select v-model="addForm.role" style="width: 100%">
|
||||
<el-option label="member" value="member" />
|
||||
<el-option label="admin" value="admin" />
|
||||
<el-option label="viewer" value="viewer" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showAddMember = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitAddMember">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page { padding: 16px; }
|
||||
.section { margin-top: 16px; }
|
||||
.page-title { font-size: 16px; font-weight: 600; }
|
||||
</style>
|
||||
109
frontend/src/views/projects/ProjectListView.vue
Normal file
109
frontend/src/views/projects/ProjectListView.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import DataTablePage from '@/components/DataTablePage.vue'
|
||||
import { createProject, getProjects, type Project } from '@/api/modules/project'
|
||||
import { getTenants, type Tenant } from '@/api/modules/tenant'
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const projects = ref<Project[]>([])
|
||||
const tenants = ref<Tenant[]>([])
|
||||
const tenantId = ref('default')
|
||||
const showCreate = ref(false)
|
||||
const form = ref({ name: '', code: '', description: '', tenant_id: 'default' })
|
||||
|
||||
const tenantOptions = computed(() => [
|
||||
{ label: 'default', value: 'default' },
|
||||
...tenants.value.map((t) => ({ label: t.name, value: t.id })),
|
||||
])
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
projects.value = await getProjects(tenantId.value)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTenants() {
|
||||
try {
|
||||
tenants.value = await getTenants()
|
||||
} catch {
|
||||
tenants.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function openDetail(id: string) {
|
||||
router.push(`/projects/${id}`)
|
||||
}
|
||||
|
||||
async function submitCreate() {
|
||||
if (!form.value.name || !form.value.code) {
|
||||
ElMessage.warning('请填写项目名与编码')
|
||||
return
|
||||
}
|
||||
await createProject({ ...form.value })
|
||||
ElMessage.success('项目创建成功')
|
||||
showCreate.value = false
|
||||
form.value = { name: '', code: '', description: '', tenant_id: 'default' }
|
||||
load()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadTenants()
|
||||
load()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<DataTablePage title="项目空间" :data="projects" :loading="loading" searchable search-fields="name,code">
|
||||
<template #toolbar-extra>
|
||||
<el-select v-model="tenantId" placeholder="租户" style="width: 160px" @change="load">
|
||||
<el-option v-for="t in tenantOptions" :key="t.value" :label="t.label" :value="t.value" />
|
||||
</el-select>
|
||||
<el-button type="primary" :icon="Plus" @click="showCreate = true">新建项目</el-button>
|
||||
</template>
|
||||
<template #columns>
|
||||
<el-table-column prop="name" label="项目名" min-width="140" />
|
||||
<el-table-column prop="code" label="编码" min-width="100" />
|
||||
<el-table-column prop="status" label="状态" min-width="100" />
|
||||
<el-table-column prop="description" label="描述" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="create_time" label="创建时间" min-width="180" />
|
||||
</template>
|
||||
<template #actions="{ row }">
|
||||
<el-button link type="primary" @click="openDetail(row.id)">详情</el-button>
|
||||
</template>
|
||||
</DataTablePage>
|
||||
<el-dialog v-model="showCreate" title="新建项目" width="520px">
|
||||
<el-form label-width="90px">
|
||||
<el-form-item label="名称" required>
|
||||
<el-input v-model="form.name" placeholder="项目名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="编码" required>
|
||||
<el-input v-model="form.code" placeholder="project code" />
|
||||
</el-form-item>
|
||||
<el-form-item label="租户">
|
||||
<el-select v-model="form.tenant_id" style="width: 100%">
|
||||
<el-option v-for="t in tenantOptions" :key="t.value" :label="t.label" :value="t.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="描述">
|
||||
<el-input v-model="form.description" type="textarea" :rows="3" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showCreate = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitCreate">创建</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page { padding: 16px; }
|
||||
</style>
|
||||
@@ -26,6 +26,7 @@ const trainContent = ref('')
|
||||
|
||||
// 搜索
|
||||
const keyword = ref('')
|
||||
const level = ref('') // 日志级别筛选:INFO/WARN/ERROR/空=全部
|
||||
const fullContent = ref('')
|
||||
|
||||
// 自动刷新
|
||||
@@ -33,11 +34,16 @@ const refreshInterval = ref(10)
|
||||
const { remaining, start: startCountdown, stop: stopCountdown } = useCountdown(10)
|
||||
|
||||
const filteredLog = computed(() => {
|
||||
if (!keyword.value.trim()) return { content: fullContent.value, count: 0 }
|
||||
const kw = keyword.value.toLowerCase().trim()
|
||||
const lines = fullContent.value
|
||||
.split('\n')
|
||||
.filter((line) => line.toLowerCase().includes(kw))
|
||||
let lines = fullContent.value.split('\n')
|
||||
// 级别筛选
|
||||
if (level.value) {
|
||||
lines = lines.filter((line) => line.toUpperCase().includes(level.value.toUpperCase()))
|
||||
}
|
||||
// 关键词筛选
|
||||
if (keyword.value.trim()) {
|
||||
const kw = keyword.value.toLowerCase().trim()
|
||||
lines = lines.filter((line) => line.toLowerCase().includes(kw))
|
||||
}
|
||||
return { content: lines.join('\n'), count: lines.length }
|
||||
})
|
||||
|
||||
@@ -176,12 +182,19 @@ onMounted(() => {
|
||||
|
||||
<!-- 日志内容 -->
|
||||
<div class="log-content-box">
|
||||
<div class="log-toolbar">
|
||||
<el-input v-model="keyword" placeholder="搜索日志..." size="small" clearable style="width: 240px">
|
||||
<template #prefix><i class="fa fa-search" /></template>
|
||||
</el-input>
|
||||
<span v-if="keyword" class="match-count">{{ matchCount }} 条匹配</span>
|
||||
</div>
|
||||
<div class="log-toolbar">
|
||||
<el-input v-model="keyword" placeholder="搜索日志..." size="small" clearable style="width: 240px">
|
||||
<template #prefix><i class="fa fa-search" /></template>
|
||||
</el-input>
|
||||
<el-select v-model="level" placeholder="日志级别" size="small" clearable style="width: 120px">
|
||||
<el-option value="" label="全部级别" />
|
||||
<el-option value="INFO" label="INFO" />
|
||||
<el-option value="WARN" label="WARN" />
|
||||
<el-option value="ERROR" label="ERROR" />
|
||||
<el-option value="DEBUG" label="DEBUG" />
|
||||
</el-select>
|
||||
<span v-if="keyword || level" class="match-count">{{ matchCount }} 条匹配</span>
|
||||
</div>
|
||||
<pre class="log-pre">{{ filteredContent || (activeTab === 'system' ? sysContent : trainContent) || '日志内容将在这里显示...' }}</pre>
|
||||
</div>
|
||||
</PageCard>
|
||||
|
||||
@@ -1,12 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { getUsers } from '@/api/modules/system'
|
||||
import type { SystemUser } from '@/types'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
deleteUser,
|
||||
getUsers,
|
||||
resetUserPassword,
|
||||
updateUserAccess,
|
||||
} from '@/api/modules/system'
|
||||
import type { PermissionCode, SystemUser, UserStatus } from '@/types'
|
||||
import { statusLabel, statusTagType } from '@/utils/status'
|
||||
|
||||
const loading = ref(false)
|
||||
const users = ref<SystemUser[]>([])
|
||||
|
||||
// 权限码 -> 中文名(与路由模块一一对应)
|
||||
const PERMISSION_LABELS: Record<PermissionCode, string> = {
|
||||
dashboard: '服务看板',
|
||||
'fine-tune': '模型训练',
|
||||
'model-eval': '模型评测',
|
||||
'model-inference': '模型推理',
|
||||
'model-manage': '模型管理',
|
||||
dataset: '数据集管理',
|
||||
'data-process': '数据处理',
|
||||
'data-convert': '数据转换',
|
||||
compute: '计算资源',
|
||||
hardware: '硬件监控',
|
||||
logs: '日志中心',
|
||||
'user-settings': '用户与权限',
|
||||
}
|
||||
|
||||
const ALL_PERMISSIONS = Object.keys(PERMISSION_LABELS) as PermissionCode[]
|
||||
|
||||
async function loadUsers() {
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -17,6 +41,109 @@ async function loadUsers() {
|
||||
}
|
||||
|
||||
onMounted(loadUsers)
|
||||
|
||||
// 当前登录用户,用于禁止操作自身(避免误锁自己)
|
||||
const currentUsername = ref<string>('')
|
||||
try {
|
||||
currentUsername.value = JSON.parse(localStorage.getItem('currentUser') || '{}').username || ''
|
||||
} catch {
|
||||
currentUsername.value = ''
|
||||
}
|
||||
|
||||
function isSelf(row: SystemUser) {
|
||||
return row.username === currentUsername.value
|
||||
}
|
||||
|
||||
// ---------- 启停 ----------
|
||||
async function toggleStatus(row: SystemUser, next: boolean) {
|
||||
const nextStatus: UserStatus = next ? 'active' : 'disabled'
|
||||
const prev = row.status
|
||||
row.status = nextStatus
|
||||
try {
|
||||
await updateUserAccess(row.id, { status: nextStatus })
|
||||
ElMessage.success(`${row.display_name} 已${next ? '启用' : '停用'}`)
|
||||
await loadUsers()
|
||||
} catch {
|
||||
row.status = prev
|
||||
ElMessage.error('状态更新失败')
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 重置密码 ----------
|
||||
const pwdDialog = reactive({ visible: false, id: '', name: '', password: '', saving: false })
|
||||
function openResetPwd(row: SystemUser) {
|
||||
pwdDialog.id = row.id
|
||||
pwdDialog.name = row.display_name
|
||||
pwdDialog.password = 'Platform@123'
|
||||
pwdDialog.visible = true
|
||||
}
|
||||
async function confirmResetPwd() {
|
||||
if (!pwdDialog.password.trim()) {
|
||||
ElMessage.warning('请输入新密码')
|
||||
return
|
||||
}
|
||||
pwdDialog.saving = true
|
||||
try {
|
||||
await resetUserPassword(pwdDialog.id, pwdDialog.password.trim())
|
||||
ElMessage.success(`已重置 ${pwdDialog.name} 的密码`)
|
||||
pwdDialog.visible = false
|
||||
} catch {
|
||||
ElMessage.error('重置密码失败')
|
||||
} finally {
|
||||
pwdDialog.saving = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 页面权限 ----------
|
||||
const permDialog = reactive({
|
||||
visible: false,
|
||||
id: '',
|
||||
name: '',
|
||||
checked: [] as PermissionCode[],
|
||||
saving: false,
|
||||
})
|
||||
function openPerms(row: SystemUser) {
|
||||
permDialog.id = row.id
|
||||
permDialog.name = row.display_name
|
||||
permDialog.checked = [...(row.permissions || [])]
|
||||
permDialog.visible = true
|
||||
}
|
||||
async function confirmPerms() {
|
||||
permDialog.saving = true
|
||||
try {
|
||||
await updateUserAccess(permDialog.id, { permissions: permDialog.checked })
|
||||
ElMessage.success(`已更新 ${permDialog.name} 的页面权限`)
|
||||
permDialog.visible = false
|
||||
await loadUsers()
|
||||
} catch {
|
||||
ElMessage.error('权限更新失败')
|
||||
} finally {
|
||||
permDialog.saving = false
|
||||
}
|
||||
}
|
||||
|
||||
const permColumns = computed(() => ALL_PERMISSIONS)
|
||||
|
||||
// ---------- 删除 ----------
|
||||
async function removeUser(row: SystemUser) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定删除用户 “${row.display_name}(${row.username})” 吗?该操作不可恢复。`,
|
||||
'删除用户',
|
||||
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' },
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await deleteUser(row.id)
|
||||
ElMessage.success(`已删除 ${row.display_name}`)
|
||||
await loadUsers()
|
||||
} catch (err: any) {
|
||||
const msg = err?.response?.data?.message || '删除失败'
|
||||
ElMessage.error(msg)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -24,25 +151,93 @@ onMounted(loadUsers)
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>用户设置</h1>
|
||||
<p>管理平台账号、角色状态和页面权限。</p>
|
||||
<p>管理平台账号、角色状态、登录密码与页面权限。</p>
|
||||
</div>
|
||||
<el-button type="primary" @click="$router.push('/user-settings/create')">创建用户</el-button>
|
||||
</header>
|
||||
|
||||
<el-table :data="users">
|
||||
<el-table :data="users" border>
|
||||
<el-table-column prop="username" label="账号" min-width="140" />
|
||||
<el-table-column prop="display_name" label="显示名称" min-width="160" />
|
||||
<el-table-column prop="role" label="角色" width="120" />
|
||||
<el-table-column label="状态" width="120">
|
||||
<el-table-column label="状态" width="130">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusTagType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="权限数" width="120">
|
||||
<template #default="{ row }">{{ row.permissions?.length || 0 }}</template>
|
||||
<el-table-column label="页面权限" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<el-tag
|
||||
v-for="p in (row.permissions || []).slice(0, 3)"
|
||||
:key="p"
|
||||
size="small"
|
||||
type="info"
|
||||
class="perm-tag"
|
||||
>{{ PERMISSION_LABELS[p] || p }}</el-tag>
|
||||
<span v-if="(row.permissions || []).length > 3" class="perm-more">
|
||||
+{{ (row.permissions || []).length - 3 }}
|
||||
</span>
|
||||
<span v-if="!(row.permissions || []).length" class="perm-more">无</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="create_time" label="创建时间" min-width="180" />
|
||||
<el-table-column label="操作" width="260" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-switch
|
||||
:model-value="row.status === 'active'"
|
||||
:disabled="row.protected || isSelf(row)"
|
||||
@change="(v: any) => toggleStatus(row, v)"
|
||||
inline-prompt
|
||||
active-text="启用"
|
||||
inactive-text="停用"
|
||||
/>
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
:disabled="row.protected"
|
||||
@click="openResetPwd(row)"
|
||||
>重置密码</el-button>
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
@click="openPerms(row)"
|
||||
>页面权限</el-button>
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
:disabled="row.protected || isSelf(row)"
|
||||
@click="removeUser(row)"
|
||||
>删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 重置密码 -->
|
||||
<el-dialog v-model="pwdDialog.visible" title="重置密码" width="420px">
|
||||
<p class="dlg-tip">为 <b>{{ pwdDialog.name }}</b> 设置新密码:</p>
|
||||
<el-input v-model="pwdDialog.password" placeholder="请输入新密码" show-password />
|
||||
<template #footer>
|
||||
<el-button @click="pwdDialog.visible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="pwdDialog.saving" @click="confirmResetPwd">确定重置</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 页面权限 -->
|
||||
<el-dialog v-model="permDialog.visible" title="页面权限" width="540px">
|
||||
<p class="dlg-tip">为 <b>{{ permDialog.name }}</b> 分配可访问的页面模块:</p>
|
||||
<el-checkbox-group v-model="permDialog.checked" class="perm-group">
|
||||
<el-checkbox
|
||||
v-for="code in permColumns"
|
||||
:key="code"
|
||||
:value="code"
|
||||
:label="PERMISSION_LABELS[code]"
|
||||
/>
|
||||
</el-checkbox-group>
|
||||
<template #footer>
|
||||
<el-button @click="permDialog.visible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="permDialog.saving" @click="confirmPerms">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -67,4 +262,25 @@ onMounted(loadUsers)
|
||||
margin: 8px 0 0;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.perm-tag {
|
||||
margin-right: 4px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.perm-more {
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.dlg-tip {
|
||||
margin: 0 0 12px;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.perm-group {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
92
frontend/src/views/tenants/TenantDetailView.vue
Normal file
92
frontend/src/views/tenants/TenantDetailView.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import DataTablePage from '@/components/DataTablePage.vue'
|
||||
import { getTenant, setTenantQuota, type Tenant } from '@/api/modules/tenant'
|
||||
import { getProjects, type Project } from '@/api/modules/project'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const tenant = ref<Tenant | null>(null)
|
||||
const projects = ref<Project[]>([])
|
||||
const loading = ref(false)
|
||||
const quotaText = ref('')
|
||||
|
||||
async function load() {
|
||||
const id = route.params.id as string
|
||||
loading.value = true
|
||||
try {
|
||||
tenant.value = await getTenant(id)
|
||||
projects.value = await getProjects(id)
|
||||
quotaText.value = JSON.stringify(tenant.value?.quota || {})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveQuota() {
|
||||
if (!tenant.value) return
|
||||
try {
|
||||
const q = JSON.parse(quotaText.value || '{}')
|
||||
await setTenantQuota(tenant.value.id, q)
|
||||
ElMessage.success('配额已保存')
|
||||
load()
|
||||
} catch {
|
||||
ElMessage.error('配额需为合法 JSON')
|
||||
}
|
||||
}
|
||||
|
||||
function openProject(id: string) {
|
||||
router.push(`/projects/${id}`)
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<el-page-header title="返回" @back="router.back()">
|
||||
<template #content>
|
||||
<span class="page-title">租户详情:{{ tenant?.name }}</span>
|
||||
</template>
|
||||
</el-page-header>
|
||||
<el-card class="section" v-loading="loading">
|
||||
<template #header>基本信息</template>
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="名称">{{ tenant?.name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="编码">{{ tenant?.code }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">{{ tenant?.status }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ tenant?.create_time }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<el-divider />
|
||||
<div class="quota-edit">
|
||||
<span class="label">配额 JSON</span>
|
||||
<el-input v-model="quotaText" type="textarea" :rows="3" />
|
||||
<el-button type="primary" @click="saveQuota">保存配额</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
<el-card class="section">
|
||||
<template #header>项目空间</template>
|
||||
<DataTablePage title="项目空间" :data="projects">
|
||||
<template #columns>
|
||||
<el-table-column prop="name" label="项目名" min-width="140" />
|
||||
<el-table-column prop="code" label="编码" min-width="100" />
|
||||
<el-table-column prop="status" label="状态" min-width="100" />
|
||||
<el-table-column prop="create_time" label="创建时间" min-width="180" />
|
||||
</template>
|
||||
<template #actions="{ row }">
|
||||
<el-button link type="primary" @click="openProject(row.id)">打开</el-button>
|
||||
</template>
|
||||
</DataTablePage>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page { padding: 16px; }
|
||||
.section { margin-top: 16px; }
|
||||
.page-title { font-size: 16px; font-weight: 600; }
|
||||
.quota-edit { display: flex; flex-direction: column; gap: 12px; max-width: 480px; }
|
||||
.label { font-size: 13px; color: #606266; }
|
||||
</style>
|
||||
111
frontend/src/views/tenants/TenantListView.vue
Normal file
111
frontend/src/views/tenants/TenantListView.vue
Normal file
@@ -0,0 +1,111 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import DataTablePage from '@/components/DataTablePage.vue'
|
||||
import { createTenant, getTenants, setTenantQuota, type Tenant } from '@/api/modules/tenant'
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const tenants = ref<Tenant[]>([])
|
||||
const showCreate = ref(false)
|
||||
const form = ref({ name: '', code: '', quota: '' as string })
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
tenants.value = await getTenants()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openDetail(id: string) {
|
||||
router.push(`/tenants/${id}`)
|
||||
}
|
||||
|
||||
async function submitCreate() {
|
||||
if (!form.value.name) {
|
||||
ElMessage.warning('请填写租户名称')
|
||||
return
|
||||
}
|
||||
let quota: Record<string, unknown> = {}
|
||||
if (form.value.quota) {
|
||||
try {
|
||||
quota = JSON.parse(form.value.quota)
|
||||
} catch {
|
||||
ElMessage.error('配额需为合法 JSON')
|
||||
return
|
||||
}
|
||||
}
|
||||
await createTenant({ name: form.value.name, code: form.value.code, quota })
|
||||
ElMessage.success('租户创建成功')
|
||||
showCreate.value = false
|
||||
form.value = { name: '', code: '', quota: '' }
|
||||
load()
|
||||
}
|
||||
|
||||
async function setQuota(row: Tenant) {
|
||||
const input = await ElMessageBox.prompt('输入租户配额 JSON', '设置配额', {
|
||||
inputValue: JSON.stringify(row.quota || {}),
|
||||
}).catch(() => null)
|
||||
if (!input) return
|
||||
try {
|
||||
const q = JSON.parse(input.value)
|
||||
await setTenantQuota(row.id, q)
|
||||
ElMessage.success('配额已更新')
|
||||
load()
|
||||
} catch {
|
||||
ElMessage.error('无效的 JSON')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<DataTablePage title="租户管理" :data="tenants" :loading="loading">
|
||||
<template #toolbar-extra>
|
||||
<el-button type="primary" :icon="Plus" @click="showCreate = true">新建租户</el-button>
|
||||
</template>
|
||||
<template #columns>
|
||||
<el-table-column prop="name" label="租户名称" min-width="140" />
|
||||
<el-table-column prop="code" label="编码" min-width="100" />
|
||||
<el-table-column label="配额" min-width="160">
|
||||
<template #default="{ row }">
|
||||
{{ Object.keys(row.quota || {}).length ? JSON.stringify(row.quota) : '—' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" min-width="100" />
|
||||
<el-table-column prop="create_time" label="创建时间" min-width="180" />
|
||||
</template>
|
||||
<template #actions="{ row }">
|
||||
<el-button link type="primary" @click="openDetail(row.id)">详情</el-button>
|
||||
<el-button link type="primary" @click="setQuota(row)">配额</el-button>
|
||||
</template>
|
||||
</DataTablePage>
|
||||
<el-dialog v-model="showCreate" title="新建租户" width="520px">
|
||||
<el-form label-width="90px">
|
||||
<el-form-item label="名称" required>
|
||||
<el-input v-model="form.name" placeholder="租户名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="编码">
|
||||
<el-input v-model="form.code" placeholder="tenant code" />
|
||||
</el-form-item>
|
||||
<el-form-item label="配额 JSON">
|
||||
<el-input v-model="form.quota" type="textarea" :rows="3" placeholder='{"gpu": 8}' />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showCreate = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitCreate">创建</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page { padding: 16px; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user