删除一些不需要的功能
This commit is contained in:
parent
59041b57d0
commit
6be9ce2d10
@ -171,13 +171,6 @@ const breadcrumbItems = computed(() => {
|
|||||||
label: id === 'new' ? t('Create') : id
|
label: id === 'new' ? t('Create') : id
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} else if (route.name === 'WorkspacePage') {
|
|
||||||
const name = route.params.name as string
|
|
||||||
if (name) {
|
|
||||||
items.push({
|
|
||||||
label: pathSegmentToLabel(name)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// 其他页面的标题映射(保留向后兼容)
|
// 其他页面的标题映射(保留向后兼容)
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
@ -186,11 +179,7 @@ const breadcrumbItems = computed(() => {
|
|||||||
AgentDetail: t('Agent Detail'),
|
AgentDetail: t('Agent Detail'),
|
||||||
NodeList: t('Node Management'),
|
NodeList: t('Node Management'),
|
||||||
NodeDetail: t('Node Detail'),
|
NodeDetail: t('Node Detail'),
|
||||||
LocalJobList: t('Local Jobs'),
|
|
||||||
LocalJobDetail: t('Local Job Detail'),
|
|
||||||
FlowBuilder: t('Flow Builder'),
|
FlowBuilder: t('Flow Builder'),
|
||||||
ScheduledJobList: t('Scheduled Jobs'),
|
|
||||||
ScheduledJobDetail: t('Scheduled Job Detail'),
|
|
||||||
MenuManager: t('Menu Manager'),
|
MenuManager: t('Menu Manager'),
|
||||||
Settings: t('Settings'),
|
Settings: t('Settings'),
|
||||||
SearchResults: t('Search Results')
|
SearchResults: t('Search Results')
|
||||||
|
|||||||
@ -127,12 +127,6 @@ const handleMenuSelect = (key: string) => {
|
|||||||
router.push(menuItem.url)
|
router.push(menuItem.url)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 新增:Workspace 类型
|
|
||||||
if (menuItem.type === 'workspace' && menuItem.workspaceName) {
|
|
||||||
const slug = pageTypeToSlug(menuItem.workspaceName)
|
|
||||||
router.push(`/workspace/${slug}`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// 兼容旧版本:优先使用url字段,如果没有则使用routeName
|
// 兼容旧版本:优先使用url字段,如果没有则使用routeName
|
||||||
if (menuItem.url) {
|
if (menuItem.url) {
|
||||||
if (menuItem.url.startsWith('http://') || menuItem.url.startsWith('https://')) {
|
if (menuItem.url.startsWith('http://') || menuItem.url.startsWith('https://')) {
|
||||||
|
|||||||
@ -33,38 +33,11 @@ const router = createRouter({
|
|||||||
name: 'Dashboard',
|
name: 'Dashboard',
|
||||||
component: () => import('../../views/Dashboard.vue')
|
component: () => import('../../views/Dashboard.vue')
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: 'local-jobs',
|
|
||||||
name: 'LocalJobList',
|
|
||||||
component: () => import('../../views/localJobs/LocalJobList.vue')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'local-jobs/:id',
|
|
||||||
name: 'LocalJobDetail',
|
|
||||||
// @ts-ignore
|
|
||||||
component: () => import('../../views/localJobs/LocalJobDetail.vue')
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: 'flows',
|
path: 'flows',
|
||||||
name: 'FlowBuilder',
|
name: 'FlowBuilder',
|
||||||
component: () => import('../../views/flows/FlowBuilder.vue')
|
component: () => import('../../views/flows/FlowBuilder.vue')
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: 'scheduled-jobs',
|
|
||||||
name: 'ScheduledJobList',
|
|
||||||
component: () => import('../../views/scheduledJobs/ScheduledJobList.vue')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'scheduled-jobs/:id',
|
|
||||||
name: 'ScheduledJobDetail',
|
|
||||||
component: () => import('../../views/scheduledJobs/ScheduledJobDetail.vue')
|
|
||||||
},
|
|
||||||
// Workspace 页面
|
|
||||||
{
|
|
||||||
path: 'workspace/:name',
|
|
||||||
name: 'WorkspacePage',
|
|
||||||
component: () => import('../../views/workspace/WorkspacePage.vue')
|
|
||||||
},
|
|
||||||
// 页面类型 pagetype 列表/详情 - 支持多种格式
|
// 页面类型 pagetype 列表/详情 - 支持多种格式
|
||||||
{
|
{
|
||||||
path: 'app/:entity',
|
path: 'app/:entity',
|
||||||
|
|||||||
@ -46,14 +46,8 @@ export const loginApi = async (username: string, password: string): Promise<void
|
|||||||
const errorData = await response.json().catch(() => ({}))
|
const errorData = await response.json().catch(() => ({}))
|
||||||
throw new Error(errorData.detail || errorData.message || errorData.exc || '登录请求失败')
|
throw new Error(errorData.detail || errorData.message || errorData.exc || '登录请求失败')
|
||||||
}
|
}
|
||||||
|
|
||||||
// dashboard 登录接口成功后会设置 cookie,不需要返回数据
|
|
||||||
// 登录状态通过 cookie 判断
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取用户信息 - 已删除,使用 cookie 中的 user_id 判断登录状态
|
|
||||||
|
|
||||||
// 登出
|
|
||||||
export const logoutApi = async (): Promise<void> => {
|
export const logoutApi = async (): Promise<void> => {
|
||||||
const response = await fetch(`/api/action/logout`, {
|
const response = await fetch(`/api/action/logout`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@ -70,9 +64,6 @@ export const logoutApi = async (): Promise<void> => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 注册接口 - 已删除,/jingrow/signup API 无效
|
|
||||||
|
|
||||||
// 仅使用会话Cookie的最小鉴权头部(不影响现有API Key逻辑)
|
|
||||||
export function get_session_api_headers() {
|
export function get_session_api_headers() {
|
||||||
return {
|
return {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|||||||
@ -3,11 +3,6 @@ import { get_session_api_headers } from './auth'
|
|||||||
|
|
||||||
// 统一使用相对路径,通过 Vite 代理转发到后端
|
// 统一使用相对路径,通过 Vite 代理转发到后端
|
||||||
|
|
||||||
// 删除记录的通用函数 - /jingrow/bulk-delete API 已删除
|
|
||||||
export const deleteRecords = async (pagetype: string, names: string[]): Promise<{ success: boolean; message?: string }> => {
|
|
||||||
// 批量删除功能已移除,/jingrow/bulk-delete API 无效
|
|
||||||
return { success: false, message: '批量删除功能已移除' }
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建记录的通用函数
|
// 创建记录的通用函数
|
||||||
export const createRecord = async (pagetype: string, data: Record<string, any>): Promise<{ success: boolean; data?: any; message?: string }> => {
|
export const createRecord = async (pagetype: string, data: Record<string, any>): Promise<{ success: boolean; data?: any; message?: string }> => {
|
||||||
@ -86,16 +81,6 @@ export const getRecordAttachments = async (pagetype: string, name: string): Prom
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 上传附件 - /jingrow/upload_file API 已删除
|
|
||||||
export const uploadAttachment = async (
|
|
||||||
file: File,
|
|
||||||
pagetype: string,
|
|
||||||
docname: string,
|
|
||||||
isPrivate: boolean = false
|
|
||||||
): Promise<{ success: boolean; data?: any; message?: string }> => {
|
|
||||||
// 上传附件功能已移除,/jingrow/upload_file API 无效
|
|
||||||
return { success: false, message: '上传附件功能已移除' }
|
|
||||||
}
|
|
||||||
|
|
||||||
// 删除附件
|
// 删除附件
|
||||||
export const deleteAttachment = async (attachmentName: string): Promise<{ success: boolean; message?: string }> => {
|
export const deleteAttachment = async (attachmentName: string): Promise<{ success: boolean; message?: string }> => {
|
||||||
@ -113,15 +98,8 @@ export const deleteAttachment = async (attachmentName: string): Promise<{ succes
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取 Workspace 配置
|
|
||||||
export const getWorkspace = async (name: string): Promise<{ success: boolean; data?: any; message?: string }> => {
|
|
||||||
return getRecord('Workspace', name)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取记录总数的通用函数 - /jingrow/get-count API 已删除
|
|
||||||
export const getCount = async (pagetype: string): Promise<{ success: boolean; count?: number; message?: string }> => {
|
export const getCount = async (pagetype: string): Promise<{ success: boolean; count?: number; message?: string }> => {
|
||||||
// 获取记录总数功能已移除,/jingrow/get-count API 无效
|
|
||||||
// 可以通过 getRecords 获取总数
|
|
||||||
try {
|
try {
|
||||||
const result = await getRecords(pagetype, [], [], 'modified desc', 0, 1)
|
const result = await getRecords(pagetype, [], [], 'modified desc', 0, 1)
|
||||||
return { success: true, count: result.total || 0 }
|
return { success: true, count: result.total || 0 }
|
||||||
@ -130,11 +108,6 @@ export const getCount = async (pagetype: string): Promise<{ success: boolean; co
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取Local Job总数的专用函数 - /jingrow/local-job-count API 已删除
|
|
||||||
export const getLocalJobCount = async (): Promise<{ success: boolean; count?: number; message?: string }> => {
|
|
||||||
// 通过 getRecords 获取 Local Job 总数
|
|
||||||
return getCount('Local Job')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取记录列表的通用函数
|
// 获取记录列表的通用函数
|
||||||
export const getRecords = async (pagetype: string, filters: any[] = [], fields: string[] = [], orderBy: string = 'modified desc', limitStart: number = 0, limitPageLength: number = 20): Promise<{ success: boolean; data?: any[]; total?: number; message?: string }> => {
|
export const getRecords = async (pagetype: string, filters: any[] = [], fields: string[] = [], orderBy: string = 'modified desc', limitStart: number = 0, limitPageLength: number = 20): Promise<{ success: boolean; data?: any[]; total?: number; message?: string }> => {
|
||||||
@ -211,14 +184,4 @@ export const downloadImageToLocal = async (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 上传文件到 Jingrow 服务器 - /jingrow/upload_file API 已删除
|
|
||||||
export const uploadFileToJingrow = async (
|
|
||||||
file: File,
|
|
||||||
attachedToPagetype?: string,
|
|
||||||
attachedToName?: string,
|
|
||||||
attachedToField?: string
|
|
||||||
): Promise<{ success: boolean; file_url?: string; file_name?: string; local_path?: string; error?: string }> => {
|
|
||||||
// 上传文件功能已移除,/jingrow/upload_file API 无效
|
|
||||||
return { success: false, error: '上传文件功能已移除' }
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@ -1,73 +0,0 @@
|
|||||||
import axios from 'axios'
|
|
||||||
import { get_session_api_headers } from './auth'
|
|
||||||
|
|
||||||
// 内部函数:调用文本转向量API - /jingrow/embedding/batch API 已删除
|
|
||||||
const callEmbeddingApi = async (texts: string[]) => {
|
|
||||||
console.warn('callEmbeddingApi: /jingrow/embedding/batch API 已删除')
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: 'Embedding API 已移除'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 内部函数:向量搜索 - /jingrow/embedding/search API 已删除
|
|
||||||
const searchVectors = async (
|
|
||||||
queryVector: number[],
|
|
||||||
collectionName: string = 'knowledge_base',
|
|
||||||
limit: number = 10,
|
|
||||||
scoreThreshold: number = 0.7
|
|
||||||
) => {
|
|
||||||
console.warn('searchVectors: /jingrow/embedding/search API 已删除')
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: '向量搜索 API 已移除'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 文本搜索接口(先转换为向量再搜索)
|
|
||||||
*
|
|
||||||
* @param queryText 查询文本
|
|
||||||
* @param collectionName 集合名称
|
|
||||||
* @param limit 返回结果数量限制
|
|
||||||
* @param scoreThreshold 相似度阈值
|
|
||||||
* @returns 搜索结果
|
|
||||||
*/
|
|
||||||
export const searchText = async (
|
|
||||||
queryText: string,
|
|
||||||
collectionName: string = 'knowledge_base',
|
|
||||||
limit: number = 10,
|
|
||||||
scoreThreshold: number = 0.7
|
|
||||||
): Promise<{
|
|
||||||
success: boolean
|
|
||||||
data?: Array<{
|
|
||||||
id: string
|
|
||||||
score: number
|
|
||||||
payload: Record<string, any>
|
|
||||||
}>
|
|
||||||
message?: string
|
|
||||||
}> => {
|
|
||||||
try {
|
|
||||||
// 先将查询文本转换为向量
|
|
||||||
const embeddingResult = await callEmbeddingApi([queryText])
|
|
||||||
if (!embeddingResult.success || !embeddingResult.data?.[0]?.embedding) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: 'Text to vector conversion failed'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 使用向量进行搜索
|
|
||||||
return await searchVectors(
|
|
||||||
embeddingResult.data[0].embedding,
|
|
||||||
collectionName,
|
|
||||||
limit,
|
|
||||||
scoreThreshold
|
|
||||||
)
|
|
||||||
} catch (error: any) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: error.message || ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,101 +0,0 @@
|
|||||||
import axios from 'axios'
|
|
||||||
|
|
||||||
const API_BASE_URL = '/jingrow'
|
|
||||||
|
|
||||||
export interface LocalJob {
|
|
||||||
name: string
|
|
||||||
job_id: string
|
|
||||||
queue: string
|
|
||||||
job_name: string
|
|
||||||
status: 'queued' | 'started' | 'finished' | 'failed' | 'deferred' | 'scheduled' | 'canceled'
|
|
||||||
started_at: string
|
|
||||||
ended_at: string
|
|
||||||
time_taken: string
|
|
||||||
exc_info: string
|
|
||||||
arguments: string
|
|
||||||
timeout: string
|
|
||||||
creation: string
|
|
||||||
modified: string
|
|
||||||
_comment_count: number
|
|
||||||
owner: string
|
|
||||||
modified_by: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LocalJobListResponse {
|
|
||||||
success: boolean
|
|
||||||
items: LocalJob[]
|
|
||||||
total: number
|
|
||||||
page: number
|
|
||||||
page_length: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LocalJobDetailResponse {
|
|
||||||
success: boolean
|
|
||||||
data: LocalJob
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BatchDeleteResponse {
|
|
||||||
success: boolean
|
|
||||||
message: string
|
|
||||||
message_params?: Record<string, number>
|
|
||||||
success_count: number
|
|
||||||
failed_jobs: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取Local Job列表
|
|
||||||
*/
|
|
||||||
export async function getLocalJobList(
|
|
||||||
page: number = 1,
|
|
||||||
pageLength: number = 20,
|
|
||||||
orderBy: string = 'modified desc',
|
|
||||||
filters?: string
|
|
||||||
): Promise<LocalJobListResponse> {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
page: page.toString(),
|
|
||||||
page_length: pageLength.toString(),
|
|
||||||
order_by: orderBy
|
|
||||||
})
|
|
||||||
|
|
||||||
if (filters) {
|
|
||||||
params.append('filters', filters)
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await axios.get(`${API_BASE_URL}/local-jobs?${params}`)
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取Local Job详情
|
|
||||||
*/
|
|
||||||
export async function getLocalJobDetail(jobId: string): Promise<LocalJobDetailResponse> {
|
|
||||||
const response = await axios.get(`${API_BASE_URL}/local-jobs/${jobId}`)
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 停止Local Job
|
|
||||||
*/
|
|
||||||
export async function stopLocalJob(jobId: string): Promise<{ success: boolean; message: string }> {
|
|
||||||
const response = await axios.post(`${API_BASE_URL}/local-jobs/${jobId}/stop`)
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 删除Local Job
|
|
||||||
*/
|
|
||||||
export async function deleteLocalJob(jobId: string): Promise<{ success: boolean; message: string }> {
|
|
||||||
const response = await axios.delete(`${API_BASE_URL}/local-jobs/${jobId}`)
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 批量删除Local Jobs
|
|
||||||
*/
|
|
||||||
export async function batchDeleteLocalJobs(jobIds: string[]): Promise<BatchDeleteResponse> {
|
|
||||||
const response = await axios.post(`${API_BASE_URL}/local-jobs/batch-delete`, {
|
|
||||||
job_ids: jobIds
|
|
||||||
})
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,39 +0,0 @@
|
|||||||
import axios from 'axios'
|
|
||||||
import { get_session_api_headers } from './auth'
|
|
||||||
|
|
||||||
// 统一使用相对路径,通过 Vite 代理转发到后端
|
|
||||||
|
|
||||||
// 所有 /jingrow/node/* API 已删除,以下函数已失效
|
|
||||||
|
|
||||||
// 获取节点Schema字段 - /jingrow/node/schema-fields API 已删除
|
|
||||||
export const getNodeSchemaFields = async (nodeType: string): Promise<any[]> => {
|
|
||||||
console.warn('getNodeSchemaFields: /jingrow/node/schema-fields API 已删除')
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
// 一键导入本地节点 - /jingrow/node/import-local API 已删除
|
|
||||||
export const importLocalNodes = async (): Promise<{ success: boolean; matched: number; imported: number; skipped_existing: number; errors?: string[] }> => {
|
|
||||||
console.warn('importLocalNodes: /jingrow/node/import-local API 已删除')
|
|
||||||
throw new Error('导入本地节点功能已移除')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 打包节点为zip文件 - /jingrow/node/package API 已删除
|
|
||||||
export const packageNode = async (nodeType: string): Promise<{ blob: Blob; filename: string }> => {
|
|
||||||
console.warn('packageNode: /jingrow/node/package API 已删除')
|
|
||||||
throw new Error('打包节点功能已移除')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 发布节点到节点市场 - /jingrow/node/publish API 已删除
|
|
||||||
export const publishNodeToMarketplace = async (data: {
|
|
||||||
node_type: string
|
|
||||||
title: string
|
|
||||||
subtitle?: string
|
|
||||||
description?: string
|
|
||||||
file_url: string
|
|
||||||
repository_url?: string
|
|
||||||
node_image?: string
|
|
||||||
}): Promise<{ success: boolean; message?: string }> => {
|
|
||||||
console.warn('publishNodeToMarketplace: /jingrow/node/publish API 已删除')
|
|
||||||
return { success: false, message: '发布节点功能已移除' }
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,84 +0,0 @@
|
|||||||
import { deleteRecords, createRecord, updateRecord, getRecord, getRecords } from './common'
|
|
||||||
|
|
||||||
// 获取 Scheduled Job 列表 - 使用通用函数
|
|
||||||
export const getScheduledJobs = async (page: number = 1, pageSize: number = 10, filters: any[] = []): Promise<any> => {
|
|
||||||
const fields = [
|
|
||||||
'name', 'method', 'frequency', 'cron_format',
|
|
||||||
'stopped', 'create_log', 'last_execution',
|
|
||||||
'server_script', 'scheduler_event', 'creation', 'modified'
|
|
||||||
]
|
|
||||||
|
|
||||||
const result = await getRecords(
|
|
||||||
'Local Scheduled Job',
|
|
||||||
filters,
|
|
||||||
fields,
|
|
||||||
'modified desc',
|
|
||||||
(page - 1) * pageSize,
|
|
||||||
pageSize
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
throw new Error(result.message || '获取 Scheduled Job 列表失败')
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
items: result.data || [],
|
|
||||||
total: result.total || 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取单个 Scheduled Job 详情 - 使用通用函数
|
|
||||||
export const getScheduledJobDetail = async (name: string): Promise<any> => {
|
|
||||||
const result = await getRecord('Local Scheduled Job', name)
|
|
||||||
if (!result.success) {
|
|
||||||
throw new Error(result.message || '获取 Scheduled Job 详情失败')
|
|
||||||
}
|
|
||||||
return result.data
|
|
||||||
}
|
|
||||||
|
|
||||||
// 切换 Scheduled Job 状态 - 使用通用函数
|
|
||||||
export const toggleScheduledJobStatus = async (name: string): Promise<{ success: boolean; message?: string }> => {
|
|
||||||
try {
|
|
||||||
// 先获取当前状态
|
|
||||||
const currentData = await getRecord('Local Scheduled Job', name)
|
|
||||||
if (!currentData.success) {
|
|
||||||
throw new Error('获取当前状态失败')
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentStopped = currentData.data.stopped || 0
|
|
||||||
const newStopped = currentStopped ? 0 : 1
|
|
||||||
|
|
||||||
// 更新状态 - 使用通用函数
|
|
||||||
const result = await updateRecord('Local Scheduled Job', name, { stopped: newStopped })
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: `状态已更新为${newStopped ? '停止' : '运行'}`
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw new Error(result.message || '更新状态失败')
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Error in toggleScheduledJobStatus:', error)
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: error.message || '更新状态失败'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建 Scheduled Job - 使用通用函数
|
|
||||||
export const createScheduledJob = async (data: Record<string, any>): Promise<{ success: boolean; data?: any; message?: string }> => {
|
|
||||||
return createRecord('Local Scheduled Job', data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新 Scheduled Job - 使用通用函数
|
|
||||||
export const updateScheduledJob = async (name: string, data: Record<string, any>): Promise<{ success: boolean; data?: any; message?: string }> => {
|
|
||||||
return updateRecord('Local Scheduled Job', name, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 删除 Scheduled Job - 使用通用函数
|
|
||||||
export const deleteScheduledJobs = async (names: string[]): Promise<{ success: boolean; message?: string }> => {
|
|
||||||
return deleteRecords('Local Scheduled Job', names)
|
|
||||||
}
|
|
||||||
@ -7,11 +7,10 @@ export interface AppMenuItem {
|
|||||||
key: string
|
key: string
|
||||||
label: string
|
label: string
|
||||||
icon?: string
|
icon?: string
|
||||||
type: 'pagetype' | 'route' | 'url' | 'workspace' | 'group' // 菜单类型,新增 group
|
type: 'pagetype' | 'route' | 'url' | 'group' // 菜单类型,新增 group
|
||||||
pagetype?: string // 页面类型名称(如:Local AI Agent)
|
pagetype?: string // 页面类型名称(如:Local AI Agent)
|
||||||
routeName?: string // 路由名
|
routeName?: string // 路由名
|
||||||
url?: string // URL路径
|
url?: string // URL路径
|
||||||
workspaceName?: string // Workspace 名称(工作区文档名)
|
|
||||||
// 层级关系
|
// 层级关系
|
||||||
parentId?: string | null
|
parentId?: string | null
|
||||||
order?: number
|
order?: number
|
||||||
@ -45,19 +44,14 @@ function saveToStorage(items: AppMenuItem[]) {
|
|||||||
|
|
||||||
// 默认菜单,与现有路由对应
|
// 默认菜单,与现有路由对应
|
||||||
// 注意:非 System User 用户只能看到部分菜单项(通过 visibleItems 过滤)
|
// 注意:非 System User 用户只能看到部分菜单项(通过 visibleItems 过滤)
|
||||||
// - pagetype 和 workspace 类型菜单仅对 System User 可见
|
// - pagetype 类型菜单仅对 System User 可见
|
||||||
// - 开发分组下的应用市场、节点市场、智能体市场仅对 System User 可见
|
// - 开发分组下的应用市场、节点市场、智能体市场仅对 System User 可见
|
||||||
// - 非 System User 只能看到工具市场
|
// - 非 System User 只能看到工具市场
|
||||||
function getDefaultMenus(): AppMenuItem[] {
|
function getDefaultMenus(): AppMenuItem[] {
|
||||||
return [
|
return [
|
||||||
{ id: 'dashboard', key: 'Dashboard', label: 'Dashboard', icon: 'tabler:dashboard', routeName: 'Dashboard', order: 1, type: 'route' },
|
{ id: 'dashboard', key: 'Dashboard', label: 'Dashboard', icon: 'tabler:dashboard', routeName: 'Dashboard', order: 1, type: 'route' },
|
||||||
{ id: 'work', key: 'work', label: 'Work', icon: 'tabler:device-desktop', type: 'workspace', workspaceName: 'work', url: '/workspace/work', order: 2 },
|
|
||||||
{ id: 'design', key: 'design', label: 'Design', icon: 'tabler:pencil', type: 'workspace', workspaceName: 'design', url: '/workspace/design', order: 3 },
|
|
||||||
{ id: 'website', key: 'website', label: 'Website', icon: 'tabler:world', type: 'workspace', workspaceName: 'jsite', url: '/workspace/jsite', order: 4 },
|
|
||||||
{ id: 'agents', key: 'local-ai-agent', label: 'Agents', icon: 'hugeicons:robotic', type: 'pagetype', pagetype: 'Local Ai Agent', order: 5 },
|
{ id: 'agents', key: 'local-ai-agent', label: 'Agents', icon: 'hugeicons:robotic', type: 'pagetype', pagetype: 'Local Ai Agent', order: 5 },
|
||||||
{ id: 'nodes', key: 'local-ai-node', label: 'Nodes', icon: 'carbon:add-child-node', type: 'pagetype', pagetype: 'Local Ai Node', order: 6 },
|
{ id: 'nodes', key: 'local-ai-node', label: 'Nodes', icon: 'carbon:add-child-node', type: 'pagetype', pagetype: 'Local Ai Node', order: 6 },
|
||||||
{ id: 'localJobs', key: 'LocalJobList', label: 'Task Queue', icon: 'iconoir:task-list', type: 'route', routeName: 'LocalJobList', order: 7 },
|
|
||||||
{ id: 'scheduledJobs', key: 'ScheduledJobList', label: 'Scheduled Jobs', icon: 'carbon:event-schedule', type: 'route', routeName: 'ScheduledJobList', order: 8 },
|
|
||||||
{ id: 'tools', key: 'Tools', label: 'Tools', icon: 'tabler:tool', type: 'route', routeName: 'Tools', order: 9 },
|
{ id: 'tools', key: 'Tools', label: 'Tools', icon: 'tabler:tool', type: 'route', routeName: 'Tools', order: 9 },
|
||||||
{ id: 'dev-group', key: 'dev-group', label: 'Development', icon: 'tabler:code', type: 'group', order: 10 },
|
{ id: 'dev-group', key: 'dev-group', label: 'Development', icon: 'tabler:code', type: 'group', order: 10 },
|
||||||
{ id: 'dev-template', key: 'dev-template', label: 'PageType Template', icon: 'tabler:file-code', type: 'route', routeName: 'CreatePagetypeTemplate', parentId: 'dev-group', order: 1 },
|
{ id: 'dev-template', key: 'dev-template', label: 'PageType Template', icon: 'tabler:file-code', type: 'route', routeName: 'CreatePagetypeTemplate', parentId: 'dev-group', order: 1 },
|
||||||
@ -173,7 +167,7 @@ export const useMenuStore = defineStore('menu', () => {
|
|||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const userType = authStore.user?.user_type
|
const userType = authStore.user?.user_type
|
||||||
|
|
||||||
// 非 System User 用户类型不显示 pagetype 和 workspace 类型的菜单项
|
// 非 System User 用户类型不显示 pagetype 类型的菜单项
|
||||||
const isSystemUser = userType === 'System User'
|
const isSystemUser = userType === 'System User'
|
||||||
|
|
||||||
return items.value.filter(m => {
|
return items.value.filter(m => {
|
||||||
@ -182,8 +176,8 @@ export const useMenuStore = defineStore('menu', () => {
|
|||||||
|
|
||||||
// 非 System User 的过滤逻辑
|
// 非 System User 的过滤逻辑
|
||||||
if (!isSystemUser) {
|
if (!isSystemUser) {
|
||||||
// 过滤掉 pagetype 和 workspace 类型
|
// 过滤掉 pagetype 类型
|
||||||
if (m.type === 'pagetype' || m.type === 'workspace') {
|
if (m.type === 'pagetype') {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
<!-- 统计卡片 -->
|
<!-- 统计卡片 -->
|
||||||
<n-grid :cols="4" :x-gap="16" :y-gap="16" :responsive="'screen'" :item-responsive="true" class="stats-grid">
|
<n-grid :cols="4" :x-gap="16" :y-gap="16" :responsive="'screen'" :item-responsive="true" class="stats-grid">
|
||||||
<!-- 原来的4个统计 -->
|
<!-- 原来的2个统计 -->
|
||||||
<n-grid-item>
|
<n-grid-item>
|
||||||
<n-card>
|
<n-card>
|
||||||
<n-statistic :label="t('Total Agents')" :value="stats.agents" />
|
<n-statistic :label="t('Total Agents')" :value="stats.agents" />
|
||||||
@ -17,17 +17,6 @@
|
|||||||
<n-statistic :label="t('Total Nodes')" :value="stats.nodes" />
|
<n-statistic :label="t('Total Nodes')" :value="stats.nodes" />
|
||||||
</n-card>
|
</n-card>
|
||||||
</n-grid-item>
|
</n-grid-item>
|
||||||
<n-grid-item>
|
|
||||||
<n-card>
|
|
||||||
<n-statistic :label="t('Task Queue')" :value="stats.taskQueue" />
|
|
||||||
</n-card>
|
|
||||||
</n-grid-item>
|
|
||||||
<n-grid-item>
|
|
||||||
<n-card>
|
|
||||||
<n-statistic :label="t('Scheduled Tasks')" :value="stats.scheduledTasks" />
|
|
||||||
</n-card>
|
|
||||||
</n-grid-item>
|
|
||||||
|
|
||||||
<!-- 新增的5个统计 -->
|
<!-- 新增的5个统计 -->
|
||||||
<n-grid-item>
|
<n-grid-item>
|
||||||
<n-card>
|
<n-card>
|
||||||
@ -68,13 +57,11 @@ import {
|
|||||||
NStatistic
|
NStatistic
|
||||||
} from 'naive-ui'
|
} from 'naive-ui'
|
||||||
import { t } from '../shared/i18n'
|
import { t } from '../shared/i18n'
|
||||||
import { getCount, getLocalJobCount } from '../shared/api/common'
|
import { getCount } from '../shared/api/common'
|
||||||
|
|
||||||
const stats = reactive({
|
const stats = reactive({
|
||||||
agents: 0,
|
agents: 0,
|
||||||
nodes: 0,
|
nodes: 0,
|
||||||
taskQueue: 0,
|
|
||||||
scheduledTasks: 0,
|
|
||||||
knowledgeBase: 0,
|
knowledgeBase: 0,
|
||||||
note: 0,
|
note: 0,
|
||||||
event: 0,
|
event: 0,
|
||||||
@ -85,7 +72,7 @@ const stats = reactive({
|
|||||||
// 加载统计数据
|
// 加载统计数据
|
||||||
const loadStats = async () => {
|
const loadStats = async () => {
|
||||||
try {
|
try {
|
||||||
// 第一行:原来的4个统计
|
// 第一行:原来的2个统计
|
||||||
// 获取智能体总数
|
// 获取智能体总数
|
||||||
const agentsResult = await getCount('Local Ai Agent')
|
const agentsResult = await getCount('Local Ai Agent')
|
||||||
if (agentsResult.success) {
|
if (agentsResult.success) {
|
||||||
@ -98,18 +85,6 @@ const loadStats = async () => {
|
|||||||
stats.nodes = nodesResult.count || 0
|
stats.nodes = nodesResult.count || 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取任务队列数量 - 使用Local Job (虚拟pagetype,使用专用API)
|
|
||||||
const taskQueueResult = await getLocalJobCount()
|
|
||||||
if (taskQueueResult.success) {
|
|
||||||
stats.taskQueue = taskQueueResult.count || 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取定时任务数量
|
|
||||||
const scheduledTasksResult = await getCount('Local Scheduled Job')
|
|
||||||
if (scheduledTasksResult.success) {
|
|
||||||
stats.scheduledTasks = scheduledTasksResult.count || 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// 第二行:新增的5个统计
|
// 第二行:新增的5个统计
|
||||||
// 获取知识库总数
|
// 获取知识库总数
|
||||||
const knowledgeBaseResult = await getCount('Knowledge Base')
|
const knowledgeBaseResult = await getCount('Knowledge Base')
|
||||||
|
|||||||
@ -133,8 +133,6 @@ onMounted(async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 服务器配置检查已移除,/jingrow/server-config API 无效
|
|
||||||
// 默认不显示注册链接
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@ -113,7 +113,6 @@ import { NForm, NFormItem, NInput, NButton, NText, useMessage } from 'naive-ui'
|
|||||||
import { Icon } from '@iconify/vue'
|
import { Icon } from '@iconify/vue'
|
||||||
import { useAuthStore } from '../../shared/stores/auth'
|
import { useAuthStore } from '../../shared/stores/auth'
|
||||||
import { t, getCurrentLocale } from '../../shared/i18n'
|
import { t, getCurrentLocale } from '../../shared/i18n'
|
||||||
// signupApi 已删除,/jingrow/signup API 无效
|
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
@ -218,17 +217,8 @@ const handleSignup = async () => {
|
|||||||
await formRef.value?.validate()
|
await formRef.value?.validate()
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
|
||||||
// signupApi 已删除,/jingrow/signup API 无效
|
message.error(t('注册功能已移除'))
|
||||||
message.error(t('注册功能已移除,/jingrow/signup API 无效'))
|
|
||||||
return
|
return
|
||||||
/* 原代码已删除
|
|
||||||
const result = await signupApi({
|
|
||||||
username: formData.username,
|
|
||||||
password: formData.password,
|
|
||||||
email: formData.email || undefined,
|
|
||||||
phone_number: isEnglish.value ? (formData.phoneNumber || undefined) : formData.phoneNumber
|
|
||||||
})
|
|
||||||
*/
|
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
message.success(t('Sign up successful'))
|
message.success(t('Sign up successful'))
|
||||||
|
|||||||
@ -1,462 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="page">
|
|
||||||
<!-- 页面头部 - 与pagetype详情页保持一致 -->
|
|
||||||
<div class="page-header">
|
|
||||||
<n-space justify="space-between" align="center">
|
|
||||||
<div>
|
|
||||||
<h1 class="page-title">{{ job ? (job.job_name || job.job_id) : t('Job Details') }}</h1>
|
|
||||||
</div>
|
|
||||||
<n-space align="center">
|
|
||||||
<!-- 刷新按钮 -->
|
|
||||||
<n-button
|
|
||||||
type="default"
|
|
||||||
size="medium"
|
|
||||||
@click="refresh"
|
|
||||||
:disabled="loading || !job"
|
|
||||||
:title="t('Refresh')"
|
|
||||||
class="header-action-btn"
|
|
||||||
>
|
|
||||||
<template #icon>
|
|
||||||
<n-icon>
|
|
||||||
<Icon icon="tabler:refresh" />
|
|
||||||
</n-icon>
|
|
||||||
</template>
|
|
||||||
</n-button>
|
|
||||||
|
|
||||||
<!-- 删除按钮 -->
|
|
||||||
<n-button
|
|
||||||
type="default"
|
|
||||||
size="medium"
|
|
||||||
@click="deleteJob"
|
|
||||||
:disabled="loading || !job || deleting"
|
|
||||||
:title="t('Delete')"
|
|
||||||
class="header-action-btn delete-btn"
|
|
||||||
>
|
|
||||||
<template #icon>
|
|
||||||
<n-icon>
|
|
||||||
<Icon icon="tabler:trash" />
|
|
||||||
</n-icon>
|
|
||||||
</template>
|
|
||||||
</n-button>
|
|
||||||
|
|
||||||
<!-- 返回按钮 -->
|
|
||||||
<n-button type="default" size="medium" @click="goBack" :disabled="loading">
|
|
||||||
<template #icon>
|
|
||||||
<n-icon><Icon icon="tabler:arrow-left" /></n-icon>
|
|
||||||
</template>
|
|
||||||
{{ t('Back') }}
|
|
||||||
</n-button>
|
|
||||||
</n-space>
|
|
||||||
</n-space>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="main-layout">
|
|
||||||
<!-- 主内容区域 -->
|
|
||||||
<div class="main-content">
|
|
||||||
<div v-if="loading" class="loading">
|
|
||||||
<i class="fa fa-spinner fa-spin"></i> {{ t('Loading...') }}
|
|
||||||
</div>
|
|
||||||
<div v-else-if="!job" class="error">
|
|
||||||
<i class="fa fa-exclamation-triangle"></i> {{ t('Job not found') }}
|
|
||||||
</div>
|
|
||||||
<div v-else class="content-grid">
|
|
||||||
<!-- 基本信息卡片 -->
|
|
||||||
<div class="info-card">
|
|
||||||
<h4>{{ t('Job Details') }}</h4>
|
|
||||||
<div class="info-grid">
|
|
||||||
<div class="info-item">
|
|
||||||
<label>{{ t('Job ID') }}</label>
|
|
||||||
<div class="value">{{ job.job_id }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="info-item">
|
|
||||||
<label>{{ t('Job Name') }}</label>
|
|
||||||
<div class="value">{{ job.job_name || '—' }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="info-item">
|
|
||||||
<label>{{ t('Queue') }}</label>
|
|
||||||
<div class="value">
|
|
||||||
<span class="queue-badge">{{ t(job.queue) }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="info-item">
|
|
||||||
<label>{{ t('Job Status') }}</label>
|
|
||||||
<div class="value">
|
|
||||||
<span class="status-badge" :class="job.status">{{ t(job.status) }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="info-item">
|
|
||||||
<label>{{ t('Creation') }}</label>
|
|
||||||
<div class="value">{{ formatDateTime(job.creation) }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="info-item">
|
|
||||||
<label>{{ t('Modified') }}</label>
|
|
||||||
<div class="value">{{ formatDateTime(job.modified) }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="info-item">
|
|
||||||
<label>{{ t('Owner') }}</label>
|
|
||||||
<div class="value">{{ job.owner || '—' }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="info-item">
|
|
||||||
<label>{{ t('Modified By') }}</label>
|
|
||||||
<div class="value">{{ job.modified_by || '—' }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 执行时间卡片 -->
|
|
||||||
<div class="time-card">
|
|
||||||
<h4>{{ t('Execution Time') }}</h4>
|
|
||||||
<div class="time-grid">
|
|
||||||
<div class="time-item">
|
|
||||||
<label>{{ t('Started At') }}</label>
|
|
||||||
<div class="value">{{ formatDateTime(job.started_at) }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="time-item">
|
|
||||||
<label>{{ t('Ended At') }}</label>
|
|
||||||
<div class="value">{{ formatDateTime(job.ended_at) }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="time-item">
|
|
||||||
<label>{{ t('Time Taken') }}</label>
|
|
||||||
<div class="value">{{ formatDuration(job.time_taken) }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="time-item">
|
|
||||||
<label>{{ t('Timeout') }}</label>
|
|
||||||
<div class="value">{{ job.timeout || '—' }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 任务参数卡片 -->
|
|
||||||
<div class="arguments-card">
|
|
||||||
<h4>{{ t('Job Arguments') }}</h4>
|
|
||||||
<div class="arguments-container">
|
|
||||||
<pre v-if="job.arguments">{{ formatArguments(job.arguments) }}</pre>
|
|
||||||
<div v-else class="no-data">{{ t('No arguments') }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 异常信息卡片 -->
|
|
||||||
<div v-if="job.exc_info" class="exception-card">
|
|
||||||
<h4>{{ t('Exception Info') }}</h4>
|
|
||||||
<div class="exception-container">
|
|
||||||
<pre>{{ job.exc_info }}</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, onMounted } from 'vue'
|
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
|
||||||
import { t } from '@/shared/i18n'
|
|
||||||
import { NSpace, NButton, NIcon, useDialog, useMessage } from 'naive-ui'
|
|
||||||
import { Icon } from '@iconify/vue'
|
|
||||||
import { getLocalJobDetail, deleteLocalJob } from '@/shared/api/localJobs'
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const router = useRouter()
|
|
||||||
const dialog = useDialog()
|
|
||||||
const message = useMessage()
|
|
||||||
|
|
||||||
const loading = ref(true)
|
|
||||||
const deleting = ref(false)
|
|
||||||
const job = ref<any>(null)
|
|
||||||
|
|
||||||
async function fetchJobDetail() {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const jobId = route.params.id as string
|
|
||||||
const result = await getLocalJobDetail(jobId)
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
job.value = result.data
|
|
||||||
} else {
|
|
||||||
job.value = null
|
|
||||||
message.error(t('Failed to load job detail'))
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Fetch job detail error:', error)
|
|
||||||
job.value = null
|
|
||||||
message.error(t('Failed to load job detail'))
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteJob() {
|
|
||||||
if (!job.value) return
|
|
||||||
|
|
||||||
dialog.error({
|
|
||||||
title: t('Confirm Delete'),
|
|
||||||
content: t('Are you sure you want to delete this job?'),
|
|
||||||
positiveText: t('Delete'),
|
|
||||||
negativeText: t('Cancel'),
|
|
||||||
onPositiveClick: async () => {
|
|
||||||
deleting.value = true
|
|
||||||
try {
|
|
||||||
const result = await deleteLocalJob(job.value.job_id)
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
message.success(t('Job deleted successfully'))
|
|
||||||
goBack()
|
|
||||||
} else {
|
|
||||||
message.error(t('Failed to delete job'))
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Delete error:', error)
|
|
||||||
message.error(t('Failed to delete job'))
|
|
||||||
} finally {
|
|
||||||
deleting.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function refresh() {
|
|
||||||
fetchJobDetail()
|
|
||||||
}
|
|
||||||
|
|
||||||
function goBack() {
|
|
||||||
router.push({ name: 'LocalJobList' })
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDateTime(dateStr: string) {
|
|
||||||
if (!dateStr) return '—'
|
|
||||||
try {
|
|
||||||
return new Date(dateStr).toLocaleString()
|
|
||||||
} catch {
|
|
||||||
return dateStr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDuration(duration: string | number) {
|
|
||||||
if (!duration) return '—'
|
|
||||||
if (typeof duration === 'number') {
|
|
||||||
return `${duration}s`
|
|
||||||
}
|
|
||||||
return duration
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatArguments(argumentsStr: string) {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(argumentsStr)
|
|
||||||
return JSON.stringify(parsed, null, 2)
|
|
||||||
} catch {
|
|
||||||
return argumentsStr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
fetchJobDetail()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.page {
|
|
||||||
padding: 16px;
|
|
||||||
width: 100%;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 页面头部 - 与pagetype详情页保持一致 */
|
|
||||||
.page-header {
|
|
||||||
margin-bottom: 24px;
|
|
||||||
padding-bottom: 16px;
|
|
||||||
border-bottom: 1px solid #e5e7eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-title {
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #1f2937;
|
|
||||||
margin: 0 0 4px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-description {
|
|
||||||
font-size: 14px;
|
|
||||||
color: #6b7280;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 头部操作按钮统一样式 */
|
|
||||||
.header-action-btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 删除按钮悬浮时使用红色 */
|
|
||||||
.header-action-btn.delete-btn:hover:not(:disabled) {
|
|
||||||
background: #ef4444 !important;
|
|
||||||
border-color: #ef4444 !important;
|
|
||||||
color: white !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-action-btn.delete-btn:hover:not(:disabled) :deep(.n-button__border),
|
|
||||||
.header-action-btn.delete-btn:hover:not(:disabled) :deep(.n-button__state-border) {
|
|
||||||
border-color: #ef4444 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-layout {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-content {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 24px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-card, .time-card, .arguments-card, .exception-card {
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid #e5e7eb;
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 20px;
|
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-card h4, .time-card h4, .arguments-card h4, .exception-card h4 {
|
|
||||||
margin: 0 0 16px 0;
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #111827;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-grid, .time-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-item, .time-item {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-item label, .time-item label {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #6b7280;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-item .value, .time-item .value {
|
|
||||||
font-size: 14px;
|
|
||||||
color: #111827;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge {
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 500;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge.queued {
|
|
||||||
background: #fef3c7;
|
|
||||||
color: #92400e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge.started {
|
|
||||||
background: #dbeafe;
|
|
||||||
color: #1e40af;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge.finished {
|
|
||||||
background: #dcfce7;
|
|
||||||
color: #166534;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge.failed {
|
|
||||||
background: #fee2e2;
|
|
||||||
color: #991b1b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge.deferred {
|
|
||||||
background: #f3e8ff;
|
|
||||||
color: #7c3aed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge.scheduled {
|
|
||||||
background: #e0f2fe;
|
|
||||||
color: #0369a1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge.canceled {
|
|
||||||
background: #f1f5f9;
|
|
||||||
color: #475569;
|
|
||||||
}
|
|
||||||
|
|
||||||
.queue-badge {
|
|
||||||
background: #eef2ff;
|
|
||||||
color: #334155;
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 500;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.arguments-container, .exception-container {
|
|
||||||
background: #f8fafc;
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 16px;
|
|
||||||
max-height: 300px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.arguments-container pre, .exception-container pre {
|
|
||||||
margin: 0;
|
|
||||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.5;
|
|
||||||
color: #374151;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.no-data {
|
|
||||||
color: #6b7280;
|
|
||||||
font-style: italic;
|
|
||||||
text-align: center;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading, .error {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 40px;
|
|
||||||
color: #6b7280;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error {
|
|
||||||
color: #ef4444;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 响应式布局 */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.content-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-grid, .time-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@ -1,645 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="page">
|
|
||||||
<div class="page-header">
|
|
||||||
<div class="header-left">
|
|
||||||
<h2>{{ t('Local Jobs') }}</h2>
|
|
||||||
</div>
|
|
||||||
<div class="header-right">
|
|
||||||
<!-- 搜索 / 筛选 -->
|
|
||||||
<div class="filters">
|
|
||||||
<n-input
|
|
||||||
v-model:value="searchQuery"
|
|
||||||
:placeholder="t('Search')"
|
|
||||||
clearable
|
|
||||||
style="width: 220px"
|
|
||||||
/>
|
|
||||||
<n-select
|
|
||||||
v-model:value="statusFilter"
|
|
||||||
:options="statusOptions"
|
|
||||||
style="width: 160px"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button class="refresh-btn" @click="reload" :disabled="loading">
|
|
||||||
<i :class="loading ? 'fa fa-spinner fa-spin' : 'fa fa-refresh'"></i>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="selectedJobs.length > 0"
|
|
||||||
class="delete-btn"
|
|
||||||
@click="handleDeleteSelected"
|
|
||||||
:disabled="deleting || loading"
|
|
||||||
>
|
|
||||||
<i class="fa fa-trash"></i>
|
|
||||||
{{ t('Delete Selected Jobs') }} ({{ selectedJobs.length }})
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="page-content">
|
|
||||||
<div v-if="loading" class="loading">
|
|
||||||
<i class="fa fa-spinner fa-spin"></i> {{ t('Loading...') }}
|
|
||||||
</div>
|
|
||||||
<div v-else>
|
|
||||||
<!-- 列表视图 -->
|
|
||||||
<div class="job-list">
|
|
||||||
<div class="list-header">
|
|
||||||
<div class="col-checkbox">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
:checked="selectedJobs.length === jobs.length && jobs.length > 0"
|
|
||||||
:indeterminate="selectedJobs.length > 0 && selectedJobs.length < jobs.length"
|
|
||||||
@change="toggleSelectAll"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="col-status">{{ t('Status') }}</div>
|
|
||||||
<div class="col-job-name">{{ t('Job Name') }}</div>
|
|
||||||
<div class="col-queue">{{ t('Queue') }}</div>
|
|
||||||
<div class="col-started">{{ t('Started At') }}</div>
|
|
||||||
<div class="col-ended">{{ t('Ended At') }}</div>
|
|
||||||
<div class="col-duration">{{ t('Time Taken') }}</div>
|
|
||||||
<div class="col-actions">{{ t('Actions') }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="list-body">
|
|
||||||
<div
|
|
||||||
v-for="job in jobs"
|
|
||||||
:key="job.job_id"
|
|
||||||
class="job-list-item"
|
|
||||||
:class="{ selected: selectedJobs.includes(job.job_id) }"
|
|
||||||
@click="openDetail(job.job_id)"
|
|
||||||
>
|
|
||||||
<div class="col-checkbox">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
:checked="selectedJobs.includes(job.job_id)"
|
|
||||||
@click.stop="toggleJobSelection(job.job_id)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="col-status" @click.stop="openDetail(job.job_id)">
|
|
||||||
<span class="status-badge" :class="job.status">{{ t(job.status) }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="col-job-name" @click.stop="openDetail(job.job_id)">
|
|
||||||
<div class="job-name">{{ job.job_name || job.job_id }}</div>
|
|
||||||
<div class="job-id">{{ job.job_id }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-queue" @click.stop="openDetail(job.job_id)">
|
|
||||||
<span class="queue-badge">{{ t(job.queue) }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="col-started" @click.stop="openDetail(job.job_id)">
|
|
||||||
{{ formatDateTime(job.started_at) }}
|
|
||||||
</div>
|
|
||||||
<div class="col-ended" @click.stop="openDetail(job.job_id)">
|
|
||||||
{{ formatDateTime(job.ended_at) }}
|
|
||||||
</div>
|
|
||||||
<div class="col-duration" @click.stop="openDetail(job.job_id)">
|
|
||||||
{{ formatDuration(job.time_taken) }}
|
|
||||||
</div>
|
|
||||||
<div class="col-actions" @click.stop>
|
|
||||||
<button
|
|
||||||
v-if="job.status === 'started' || job.status === 'queued'"
|
|
||||||
class="action-btn stop-btn"
|
|
||||||
@click="stopJob(job.job_id)"
|
|
||||||
:title="t('Stop Job')"
|
|
||||||
>
|
|
||||||
<i class="fa fa-stop"></i>
|
|
||||||
</button>
|
|
||||||
<button class="action-btn" @click="openDetail(job.job_id)" :title="t('View Details')">
|
|
||||||
<i class="fa fa-eye"></i>
|
|
||||||
</button>
|
|
||||||
<button class="action-btn delete-btn" @click="deleteJob(job.job_id)" :title="t('Delete Job')">
|
|
||||||
<i class="fa fa-trash"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- 分页 -->
|
|
||||||
<div class="list-pagination">
|
|
||||||
<n-pagination v-model:page="page" :page-count="pageCount" size="small" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, onMounted, computed, watch } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { t } from '@/shared/i18n'
|
|
||||||
import { NInput, NSelect, NPagination, useDialog, useMessage } from 'naive-ui'
|
|
||||||
import { getLocalJobList, deleteLocalJob, batchDeleteLocalJobs, stopLocalJob } from '@/shared/api/localJobs'
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const dialog = useDialog()
|
|
||||||
const message = useMessage()
|
|
||||||
|
|
||||||
const loading = ref(true)
|
|
||||||
const deleting = ref(false)
|
|
||||||
const jobs = ref<any[]>([])
|
|
||||||
const allJobs = ref<any[]>([]) // 保存所有原始数据
|
|
||||||
const total = ref(0)
|
|
||||||
const selectedJobs = ref<string[]>([])
|
|
||||||
const searchQuery = ref('')
|
|
||||||
const statusFilter = ref('all')
|
|
||||||
const page = ref(1)
|
|
||||||
const pageSize = ref(parseInt(localStorage.getItem('itemsPerPage') || '20'))
|
|
||||||
|
|
||||||
const statusOptions = computed(() => [
|
|
||||||
{ label: t('All'), value: 'all' },
|
|
||||||
{ label: t('Queued'), value: 'queued' },
|
|
||||||
{ label: t('Started'), value: 'started' },
|
|
||||||
{ label: t('Finished'), value: 'finished' },
|
|
||||||
{ label: t('Failed'), value: 'failed' },
|
|
||||||
{ label: t('Deferred'), value: 'deferred' },
|
|
||||||
{ label: t('Scheduled'), value: 'scheduled' },
|
|
||||||
{ label: t('Canceled'), value: 'canceled' }
|
|
||||||
])
|
|
||||||
|
|
||||||
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
|
|
||||||
|
|
||||||
async function fetchJobs() {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const result = await getLocalJobList(page.value, pageSize.value)
|
|
||||||
|
|
||||||
allJobs.value = result.items || []
|
|
||||||
|
|
||||||
// 处理数据(搜索和过滤)
|
|
||||||
processJobs()
|
|
||||||
total.value = result.total || 0
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Fetch jobs error:', error)
|
|
||||||
jobs.value = []
|
|
||||||
allJobs.value = []
|
|
||||||
total.value = 0
|
|
||||||
message.error(t('Failed to load jobs'))
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function processJobs() {
|
|
||||||
let filteredJobs = [...allJobs.value]
|
|
||||||
|
|
||||||
// 前端搜索和过滤
|
|
||||||
if (searchQuery.value) {
|
|
||||||
const query = searchQuery.value.toLowerCase()
|
|
||||||
filteredJobs = filteredJobs.filter(job =>
|
|
||||||
job.job_name?.toLowerCase().includes(query) ||
|
|
||||||
job.job_id?.toLowerCase().includes(query) ||
|
|
||||||
job.queue?.toLowerCase().includes(query)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (statusFilter.value !== 'all') {
|
|
||||||
filteredJobs = filteredJobs.filter(job => job.status === statusFilter.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
jobs.value = filteredJobs
|
|
||||||
}
|
|
||||||
|
|
||||||
function reload() {
|
|
||||||
fetchJobs()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 选择相关函数
|
|
||||||
function toggleJobSelection(jobId: string) {
|
|
||||||
const index = selectedJobs.value.indexOf(jobId)
|
|
||||||
if (index > -1) {
|
|
||||||
selectedJobs.value.splice(index, 1)
|
|
||||||
} else {
|
|
||||||
selectedJobs.value.push(jobId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleSelectAll() {
|
|
||||||
if (selectedJobs.value.length === jobs.value.length) {
|
|
||||||
selectedJobs.value = []
|
|
||||||
} else {
|
|
||||||
selectedJobs.value = jobs.value.map(job => job.job_id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDeleteSelected() {
|
|
||||||
if (selectedJobs.value.length === 0) return
|
|
||||||
|
|
||||||
dialog.error({
|
|
||||||
title: t('Confirm Delete'),
|
|
||||||
content: t('Are you sure you want to delete the selected jobs? This action cannot be undone.'),
|
|
||||||
positiveText: t('Delete'),
|
|
||||||
negativeText: t('Cancel'),
|
|
||||||
onPositiveClick: async () => {
|
|
||||||
deleting.value = true
|
|
||||||
try {
|
|
||||||
const result = await batchDeleteLocalJobs(selectedJobs.value)
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
// 使用翻译键和参数进行翻译
|
|
||||||
let translatedMessage = t(result.message)
|
|
||||||
if (result.message_params) {
|
|
||||||
// 替换参数占位符
|
|
||||||
Object.entries(result.message_params).forEach(([key, value]) => {
|
|
||||||
translatedMessage = translatedMessage.replace(`{${key}}`, String(value))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
message.success(translatedMessage)
|
|
||||||
selectedJobs.value = []
|
|
||||||
await fetchJobs()
|
|
||||||
} else {
|
|
||||||
message.error(t('Delete failed'))
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Delete error:', error)
|
|
||||||
message.error(t('Delete failed'))
|
|
||||||
} finally {
|
|
||||||
deleting.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteJob(jobId: string) {
|
|
||||||
dialog.error({
|
|
||||||
title: t('Confirm Delete'),
|
|
||||||
content: t('Are you sure you want to delete this job?'),
|
|
||||||
positiveText: t('Delete'),
|
|
||||||
negativeText: t('Cancel'),
|
|
||||||
onPositiveClick: async () => {
|
|
||||||
try {
|
|
||||||
const result = await deleteLocalJob(jobId)
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
message.success(t('Job deleted successfully'))
|
|
||||||
await fetchJobs()
|
|
||||||
} else {
|
|
||||||
message.error(t('Failed to delete job'))
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Delete error:', error)
|
|
||||||
message.error(t('Failed to delete job'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function stopJob(jobId: string) {
|
|
||||||
dialog.warning({
|
|
||||||
title: t('Confirm Stop'),
|
|
||||||
content: t('Are you sure you want to stop this job?'),
|
|
||||||
positiveText: t('Stop'),
|
|
||||||
negativeText: t('Cancel'),
|
|
||||||
onPositiveClick: async () => {
|
|
||||||
try {
|
|
||||||
const result = await stopLocalJob(jobId)
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
message.success(t('Job stopped successfully'))
|
|
||||||
await fetchJobs()
|
|
||||||
} else {
|
|
||||||
message.error(t('Failed to stop job'))
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Stop error:', error)
|
|
||||||
message.error(t('Failed to stop job'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDateTime(dateStr: string) {
|
|
||||||
if (!dateStr) return '—'
|
|
||||||
try {
|
|
||||||
return new Date(dateStr).toLocaleString()
|
|
||||||
} catch {
|
|
||||||
return dateStr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDuration(duration: string | number) {
|
|
||||||
if (!duration) return '—'
|
|
||||||
if (typeof duration === 'number') {
|
|
||||||
return `${duration}s`
|
|
||||||
}
|
|
||||||
return duration
|
|
||||||
}
|
|
||||||
|
|
||||||
// 监听搜索和筛选变化
|
|
||||||
watch([searchQuery, statusFilter], () => {
|
|
||||||
page.value = 1 // 重置到第一页
|
|
||||||
processJobs()
|
|
||||||
}, { deep: true })
|
|
||||||
|
|
||||||
// 监听分页变化
|
|
||||||
watch([page], () => {
|
|
||||||
fetchJobs()
|
|
||||||
})
|
|
||||||
|
|
||||||
// 监听每页数量变化(从系统设置)
|
|
||||||
watch(() => localStorage.getItem('itemsPerPage'), (newValue) => {
|
|
||||||
if (newValue) {
|
|
||||||
pageSize.value = parseInt(newValue)
|
|
||||||
page.value = 1 // 重置到第一页
|
|
||||||
fetchJobs()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
function openDetail(jobId: string) {
|
|
||||||
router.push({ name: 'LocalJobDetail', params: { id: jobId } })
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
fetchJobs()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.page {
|
|
||||||
padding: 16px;
|
|
||||||
width: 100%;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-left h2 {
|
|
||||||
font-size: 28px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #1f2937;
|
|
||||||
margin: 0 0 8px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-description {
|
|
||||||
font-size: 16px;
|
|
||||||
color: #6b7280;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-right {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filters {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 刷新按钮 */
|
|
||||||
.refresh-btn {
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #f8fafc;
|
|
||||||
color: #64748b;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 14px;
|
|
||||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.refresh-btn:hover {
|
|
||||||
background: #e2e8f0;
|
|
||||||
color: #475569;
|
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.refresh-btn:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: not-allowed;
|
|
||||||
transform: none;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 删除按钮 */
|
|
||||||
.delete-btn {
|
|
||||||
background: #ef4444;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
padding: 8px 16px;
|
|
||||||
border-radius: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.delete-btn:hover {
|
|
||||||
background: #dc2626;
|
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.delete-btn:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 列表视图样式 */
|
|
||||||
.job-list {
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid #e5e7eb;
|
|
||||||
border-radius: 12px;
|
|
||||||
overflow: hidden;
|
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-header {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 40px 100px 2fr 100px 150px 150px 100px 120px;
|
|
||||||
gap: 16px;
|
|
||||||
padding: 16px 20px;
|
|
||||||
background: #f9fafb;
|
|
||||||
border-bottom: 1px solid #e5e7eb;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #374151;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-header > div {
|
|
||||||
color: #374151;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-body {
|
|
||||||
max-height: 600px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-pagination {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 12px 16px;
|
|
||||||
border-top: 1px solid #f3f4f6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.job-list-item {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 40px 100px 2fr 100px 150px 150px 100px 120px;
|
|
||||||
gap: 16px;
|
|
||||||
padding: 16px 20px;
|
|
||||||
border-bottom: 1px solid #f3f4f6;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.job-list-item:hover {
|
|
||||||
background: #f8fafc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.job-list-item:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.col-job-name {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.job-name {
|
|
||||||
font-weight: 600;
|
|
||||||
color: #111827;
|
|
||||||
font-size: 14px;
|
|
||||||
margin-bottom: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.job-id {
|
|
||||||
color: #6b7280;
|
|
||||||
font-size: 12px;
|
|
||||||
font-family: monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.col-status {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.col-queue {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.col-started,
|
|
||||||
.col-ended,
|
|
||||||
.col-duration {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
color: #6b7280;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge {
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge.queued {
|
|
||||||
background: #fef3c7;
|
|
||||||
color: #92400e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge.started {
|
|
||||||
background: #dbeafe;
|
|
||||||
color: #1e40af;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge.finished {
|
|
||||||
background: #dcfce7;
|
|
||||||
color: #166534;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge.failed {
|
|
||||||
background: #fee2e2;
|
|
||||||
color: #991b1b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge.deferred {
|
|
||||||
background: #f3e8ff;
|
|
||||||
color: #7c3aed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge.scheduled {
|
|
||||||
background: #e0f2fe;
|
|
||||||
color: #0369a1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge.canceled {
|
|
||||||
background: #f1f5f9;
|
|
||||||
color: #475569;
|
|
||||||
}
|
|
||||||
|
|
||||||
.queue-badge {
|
|
||||||
background: #eef2ff;
|
|
||||||
color: #334155;
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.col-actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn {
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
border: none;
|
|
||||||
background: #f3f4f6;
|
|
||||||
color: #6b7280;
|
|
||||||
border-radius: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 12px;
|
|
||||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn:hover {
|
|
||||||
background: #3b82f6;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn.stop-btn:hover {
|
|
||||||
background: #f59e0b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn.delete-btn:hover {
|
|
||||||
background: #ef4444;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 选择相关样式 */
|
|
||||||
.col-checkbox {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.job-list-item.selected {
|
|
||||||
background: #f0f9ff;
|
|
||||||
border-color: #3b82f6;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@ -1,582 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="job-detail-page">
|
|
||||||
<div class="page-header">
|
|
||||||
<n-space justify="space-between" align="center">
|
|
||||||
<div>
|
|
||||||
<h1 class="page-title">{{ job?.method || t('Scheduled Job Detail') }}</h1>
|
|
||||||
</div>
|
|
||||||
<n-space :size="8">
|
|
||||||
<n-button
|
|
||||||
v-if="job"
|
|
||||||
type="default"
|
|
||||||
size="medium"
|
|
||||||
:disabled="loading"
|
|
||||||
@click="toggleJobStatus"
|
|
||||||
:class="['toolbar-btn', job.stopped ? 'execute-btn' : 'stop-btn']"
|
|
||||||
>
|
|
||||||
<template #icon>
|
|
||||||
<n-icon>
|
|
||||||
<Icon icon="tabler:play" v-if="job.stopped" />
|
|
||||||
<Icon icon="tabler:pause" v-else />
|
|
||||||
</n-icon>
|
|
||||||
</template>
|
|
||||||
{{ job.stopped ? t('Start Job') : t('Stop Job') }}
|
|
||||||
</n-button>
|
|
||||||
<n-button type="default" size="medium" @click="goBack">
|
|
||||||
<template #icon>
|
|
||||||
<n-icon><Icon icon="tabler:arrow-left" /></n-icon>
|
|
||||||
</template>
|
|
||||||
{{ t('Back') }}
|
|
||||||
</n-button>
|
|
||||||
</n-space>
|
|
||||||
</n-space>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="loading" class="loading">
|
|
||||||
<i class="fa fa-spinner fa-spin"></i> {{ t('Loading...') }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="job" class="job-content">
|
|
||||||
<!-- 基本信息 -->
|
|
||||||
<n-card :title="t('Basic Information')">
|
|
||||||
<div class="properties-grid">
|
|
||||||
<div class="property-group">
|
|
||||||
<div class="property-item">
|
|
||||||
<label>{{ t('Method') }}</label>
|
|
||||||
<input type="text" v-model="job.method" readonly />
|
|
||||||
</div>
|
|
||||||
<div class="property-item">
|
|
||||||
<label>{{ t('Frequency') }}</label>
|
|
||||||
<input type="text" v-model="job.frequency" readonly />
|
|
||||||
</div>
|
|
||||||
<div class="property-item">
|
|
||||||
<label>{{ t('Status') }}</label>
|
|
||||||
<div class="status-display">
|
|
||||||
<n-checkbox v-model:checked="job.stopped" @update:checked="handleStatusChange">
|
|
||||||
</n-checkbox>
|
|
||||||
<span :class="['status-badge', job.stopped ? 'stopped' : 'running']">
|
|
||||||
{{ job.stopped ? t('Stopped') : t('Running') }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="property-item">
|
|
||||||
<label>{{ t('Create Log') }}</label>
|
|
||||||
<div class="status-display">
|
|
||||||
<span v-if="job.create_log" class="status-badge enabled">{{ t('Enabled') }}</span>
|
|
||||||
<span v-else class="status-badge disabled">{{ t('Disabled') }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="property-group">
|
|
||||||
<div class="property-item">
|
|
||||||
<label>{{ t('Server Script') }}</label>
|
|
||||||
<input type="text" v-model="job.server_script" readonly />
|
|
||||||
</div>
|
|
||||||
<div class="property-item">
|
|
||||||
<label>{{ t('Scheduler Event') }}</label>
|
|
||||||
<input type="text" v-model="job.scheduler_event" readonly />
|
|
||||||
</div>
|
|
||||||
<div class="property-item">
|
|
||||||
<label>{{ t('Created At') }}</label>
|
|
||||||
<input type="text" v-model="job.creation" readonly />
|
|
||||||
</div>
|
|
||||||
<div class="property-item">
|
|
||||||
<label>{{ t('Updated At') }}</label>
|
|
||||||
<input type="text" v-model="job.modified" readonly />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</n-card>
|
|
||||||
|
|
||||||
<!-- Cron 格式信息 -->
|
|
||||||
<n-card v-if="job.frequency === 'Cron' && job.cron_format" :title="t('Cron Format')">
|
|
||||||
<div class="cron-section">
|
|
||||||
<div class="cron-display">
|
|
||||||
<code class="cron-code">{{ job.cron_format }}</code>
|
|
||||||
</div>
|
|
||||||
<div class="cron-description">
|
|
||||||
<h4>{{ t('Cron Format Description') }}</h4>
|
|
||||||
<pre class="cron-help">{{ cronHelpText }}</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</n-card>
|
|
||||||
|
|
||||||
<!-- 执行历史 -->
|
|
||||||
<n-card :title="t('Execution History')">
|
|
||||||
<div class="execution-section">
|
|
||||||
<div class="execution-item">
|
|
||||||
<label>{{ t('Last Execution') }}</label>
|
|
||||||
<div class="execution-value">
|
|
||||||
<span v-if="job.last_execution" class="datetime-text">{{ formatDateTime(job.last_execution) }}</span>
|
|
||||||
<span v-else class="text-muted">{{ t('Never executed') }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="execution-item">
|
|
||||||
<label>{{ t('Next Execution') }}</label>
|
|
||||||
<div class="execution-value">
|
|
||||||
<span v-if="nextExecutionTime" class="datetime-text next-execution">{{ formatDateTime(nextExecutionTime) }}</span>
|
|
||||||
<span v-else-if="job.stopped" class="text-muted">{{ t('Job is stopped') }}</span>
|
|
||||||
<span v-else class="text-muted">{{ t('Unable to calculate') }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</n-card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="error">
|
|
||||||
<i class="fa fa-exclamation-triangle"></i> {{ t('Job not found') }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed, onMounted } from 'vue'
|
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
|
||||||
import {
|
|
||||||
NSpace,
|
|
||||||
NButton,
|
|
||||||
NIcon,
|
|
||||||
NCard,
|
|
||||||
NCheckbox,
|
|
||||||
useMessage
|
|
||||||
} from 'naive-ui'
|
|
||||||
import { Icon } from '@iconify/vue'
|
|
||||||
import { t } from '@/shared/i18n'
|
|
||||||
import {
|
|
||||||
getScheduledJobDetail,
|
|
||||||
toggleScheduledJobStatus
|
|
||||||
} from '@/shared/api/scheduledJobs'
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const router = useRouter()
|
|
||||||
const message = useMessage()
|
|
||||||
|
|
||||||
// 响应式数据
|
|
||||||
const job = ref<any>(null)
|
|
||||||
const loading = ref(false)
|
|
||||||
|
|
||||||
// Cron 帮助文本
|
|
||||||
const cronHelpText = `* * * * *
|
|
||||||
┬ ┬ ┬ ┬ ┬
|
|
||||||
│ │ │ │ │
|
|
||||||
│ │ │ │ └ day of week (0 - 6) (0 is Sunday)
|
|
||||||
│ │ │ └───── month (1 - 12)
|
|
||||||
│ │ └────────── day of month (1 - 31)
|
|
||||||
│ └─────────────── hour (0 - 23)
|
|
||||||
└──────────────────── minute (0 - 59)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
* - Any value
|
|
||||||
/ - Step values`
|
|
||||||
|
|
||||||
// 计算属性
|
|
||||||
const jobId = computed(() => route.params.id as string)
|
|
||||||
|
|
||||||
// 下次执行时间
|
|
||||||
const nextExecutionTime = ref<string | null>(null)
|
|
||||||
|
|
||||||
// 获取任务详情
|
|
||||||
const fetchJob = async () => {
|
|
||||||
if (!jobId.value) return
|
|
||||||
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const jobData = await getScheduledJobDetail(jobId.value)
|
|
||||||
// 转换 stopped 字段为布尔类型
|
|
||||||
jobData.stopped = Boolean(jobData.stopped)
|
|
||||||
job.value = jobData
|
|
||||||
|
|
||||||
// 直接使用 next_execution 字段
|
|
||||||
if (!jobData.stopped && jobData.next_execution) {
|
|
||||||
nextExecutionTime.value = jobData.next_execution
|
|
||||||
} else {
|
|
||||||
nextExecutionTime.value = null
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取任务详情失败:', error)
|
|
||||||
message.error(t('Failed to load job detail'))
|
|
||||||
job.value = null
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理状态变化
|
|
||||||
const handleStatusChange = async (checked: boolean) => {
|
|
||||||
if (!job.value) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await toggleScheduledJobStatus(jobId.value)
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
message.success(result.message || t('Job status updated successfully'))
|
|
||||||
// 状态已经在 v-model 中自动更新了
|
|
||||||
} else {
|
|
||||||
message.error(result.message || t('Failed to update job status'))
|
|
||||||
// 如果保存失败,恢复原状态
|
|
||||||
job.value.stopped = !checked
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Toggle job status error:', error)
|
|
||||||
message.error(t('Failed to update job status'))
|
|
||||||
// 如果保存失败,恢复原状态
|
|
||||||
job.value.stopped = !checked
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 切换任务状态
|
|
||||||
const toggleJobStatus = async () => {
|
|
||||||
if (!job.value) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await toggleScheduledJobStatus(jobId.value)
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
message.success(result.message || t('Job status updated successfully'))
|
|
||||||
|
|
||||||
// 更新本地状态
|
|
||||||
job.value.stopped = !job.value.stopped
|
|
||||||
} else {
|
|
||||||
message.error(result.message || t('Failed to update job status'))
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Toggle job status error:', error)
|
|
||||||
message.error(t('Failed to update job status'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 返回列表页
|
|
||||||
const goBack = () => {
|
|
||||||
router.push({ name: 'ScheduledJobList' })
|
|
||||||
}
|
|
||||||
|
|
||||||
// 格式化日期时间
|
|
||||||
const formatDateTime = (dateTimeStr: string) => {
|
|
||||||
if (!dateTimeStr) return '—'
|
|
||||||
try {
|
|
||||||
const date = new Date(dateTimeStr)
|
|
||||||
return date.toLocaleString()
|
|
||||||
} catch (error) {
|
|
||||||
return dateTimeStr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 生命周期
|
|
||||||
onMounted(() => {
|
|
||||||
fetchJob()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.job-detail-page {
|
|
||||||
width: 100%;
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-header {
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-title {
|
|
||||||
font-size: 28px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #1f2937;
|
|
||||||
margin: 0 0 8px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.loading {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 60px 20px;
|
|
||||||
color: #6b7280;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading i {
|
|
||||||
margin-right: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 60px 20px;
|
|
||||||
color: #dc2626;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error i {
|
|
||||||
margin-right: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.job-content {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 表单样式 */
|
|
||||||
.properties-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.property-group {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.property-item {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.property-item label {
|
|
||||||
font-weight: 500;
|
|
||||||
color: #374151;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.property-item input {
|
|
||||||
padding: 8px 12px;
|
|
||||||
border: 1px solid #d1d5db;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 14px;
|
|
||||||
background-color: #f9fafb;
|
|
||||||
color: #6b7280;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-display {
|
|
||||||
padding: 8px 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 将checkbox选中状态(已停止)的背景改为红色 */
|
|
||||||
.status-display :deep(.n-checkbox--checked .n-checkbox-box) {
|
|
||||||
background-color: #ef4444 !important;
|
|
||||||
border-color: #ef4444 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-display :deep(.n-checkbox--checked .n-checkbox-box:hover) {
|
|
||||||
background-color: #dc2626 !important;
|
|
||||||
border-color: #dc2626 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-display :deep(.n-checkbox--checked .n-checkbox-box__border) {
|
|
||||||
border-color: #ef4444 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge {
|
|
||||||
padding: 4px 10px;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
display: inline-block;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge.running {
|
|
||||||
background: #dcfce7;
|
|
||||||
color: #166534;
|
|
||||||
border: 1px solid rgba(34, 197, 94, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge.stopped {
|
|
||||||
background: #fee2e2;
|
|
||||||
color: #dc2626;
|
|
||||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge.enabled {
|
|
||||||
background: #d1fae5;
|
|
||||||
color: #065f46;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge.disabled {
|
|
||||||
background: #fee2e2;
|
|
||||||
color: #dc2626;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Cron 格式样式 */
|
|
||||||
.cron-section {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cron-display {
|
|
||||||
background: #f8f9fa;
|
|
||||||
border: 1px solid #e9ecef;
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cron-code {
|
|
||||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #2563eb;
|
|
||||||
background: white;
|
|
||||||
padding: 8px 12px;
|
|
||||||
border-radius: 4px;
|
|
||||||
border: 1px solid #e5e7eb;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cron-description h4 {
|
|
||||||
margin: 0 0 12px 0;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #374151;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cron-help {
|
|
||||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.5;
|
|
||||||
color: #6b7280;
|
|
||||||
background: #f8f9fa;
|
|
||||||
border: 1px solid #e9ecef;
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 12px;
|
|
||||||
margin: 0;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 执行历史样式 */
|
|
||||||
.execution-section {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.execution-item {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.execution-item label {
|
|
||||||
font-weight: 500;
|
|
||||||
color: #374151;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.execution-value {
|
|
||||||
padding: 8px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.datetime-text {
|
|
||||||
color: #374151;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.next-execution {
|
|
||||||
color: #dc2626;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-muted {
|
|
||||||
color: #9ca3af;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 工具栏按钮基础样式 - 与Local Ai Agent工具栏一致 */
|
|
||||||
.toolbar-btn {
|
|
||||||
background: #f3f4f6 !important;
|
|
||||||
color: #374151 !important;
|
|
||||||
border: 1px solid rgba(0, 0, 0, 0.08) !important;
|
|
||||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 清除 Naive UI 按钮内部的边框元素,避免双重边框 */
|
|
||||||
.toolbar-btn :deep(.n-button__border),
|
|
||||||
.toolbar-btn :deep(.n-button__state-border) {
|
|
||||||
border: none !important;
|
|
||||||
border-color: transparent !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar-btn:hover:not(:disabled) {
|
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar-btn:hover:not(:disabled) :deep(.n-button__border),
|
|
||||||
.toolbar-btn:hover:not(:disabled) :deep(.n-button__state-border) {
|
|
||||||
border: none !important;
|
|
||||||
border-color: transparent !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 执行按钮 - 绿色系(与Local Ai Agent执行按钮配色一致) */
|
|
||||||
.execute-btn {
|
|
||||||
background: #dcfce7 !important;
|
|
||||||
color: #166534 !important;
|
|
||||||
border-color: rgba(34, 197, 94, 0.2) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.execute-btn:hover:not(:disabled) {
|
|
||||||
background: #bbf7d0 !important;
|
|
||||||
color: #15803d !important;
|
|
||||||
border-color: rgba(34, 197, 94, 0.3) !important;
|
|
||||||
box-shadow: 0 2px 8px rgba(34, 197, 94, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 停止按钮 - 红色系 */
|
|
||||||
.toolbar-btn.stop-btn {
|
|
||||||
background: #fee2e2 !important;
|
|
||||||
color: #dc2626 !important;
|
|
||||||
border-color: rgba(239, 68, 68, 0.2) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar-btn.stop-btn :deep(.n-button__border),
|
|
||||||
.toolbar-btn.stop-btn :deep(.n-button__state-border) {
|
|
||||||
border: none !important;
|
|
||||||
border-color: transparent !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar-btn.stop-btn:hover:not(:disabled) {
|
|
||||||
background: #fecaca !important;
|
|
||||||
color: #b91c1c !important;
|
|
||||||
border-color: rgba(239, 68, 68, 0.3) !important;
|
|
||||||
box-shadow: 0 2px 8px rgba(239, 68, 68, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar-btn.stop-btn:hover:not(:disabled) :deep(.n-button__border),
|
|
||||||
.toolbar-btn.stop-btn:hover:not(:disabled) :deep(.n-button__state-border) {
|
|
||||||
border: none !important;
|
|
||||||
border-color: transparent !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar-btn:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar-btn:disabled :deep(.n-button__border),
|
|
||||||
.toolbar-btn:disabled :deep(.n-button__state-border) {
|
|
||||||
border: none !important;
|
|
||||||
border-color: transparent !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 响应式设计 */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.properties-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-header {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@ -1,785 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="page">
|
|
||||||
<div class="page-header">
|
|
||||||
<div class="header-left">
|
|
||||||
<h2>{{ t('Scheduled Jobs') }}</h2>
|
|
||||||
</div>
|
|
||||||
<div class="header-right">
|
|
||||||
<div class="filters">
|
|
||||||
<n-input
|
|
||||||
v-model:value="searchQuery"
|
|
||||||
:placeholder="t('Search jobs...')"
|
|
||||||
clearable
|
|
||||||
style="width: 200px"
|
|
||||||
/>
|
|
||||||
<n-select
|
|
||||||
v-model:value="frequencyFilter"
|
|
||||||
:options="frequencyOptions"
|
|
||||||
:placeholder="t('Frequency')"
|
|
||||||
style="width: 120px"
|
|
||||||
/>
|
|
||||||
<n-select
|
|
||||||
v-model:value="statusFilter"
|
|
||||||
:options="statusOptions"
|
|
||||||
:placeholder="t('Status')"
|
|
||||||
style="width: 120px"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button class="refresh-btn" @click="reload" :disabled="loading">
|
|
||||||
<i :class="loading ? 'fa fa-spinner fa-spin' : 'fa fa-refresh'"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="page-content">
|
|
||||||
<div v-if="loading" class="loading">
|
|
||||||
<i class="fa fa-spinner fa-spin"></i> {{ t('Loading...') }}
|
|
||||||
</div>
|
|
||||||
<div v-else>
|
|
||||||
<!-- 列表视图 -->
|
|
||||||
<div class="job-list">
|
|
||||||
<div class="list-header" :style="{ gridTemplateColumns: getGridTemplateColumns() }">
|
|
||||||
<div
|
|
||||||
v-for="field in listViewFields"
|
|
||||||
:key="field.fieldname"
|
|
||||||
class="col-header"
|
|
||||||
:class="`col-${field.fieldname}`"
|
|
||||||
>
|
|
||||||
{{ t(field.label || field.fieldname) }}
|
|
||||||
</div>
|
|
||||||
<div class="col-actions">{{ t('Actions') }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="list-body">
|
|
||||||
<div
|
|
||||||
v-for="job in jobs"
|
|
||||||
:key="job.name"
|
|
||||||
class="list-item"
|
|
||||||
:style="{ gridTemplateColumns: getGridTemplateColumns() }"
|
|
||||||
@click="openDetail(job.name)"
|
|
||||||
>
|
|
||||||
<template v-for="field in listViewFields" :key="field.fieldname">
|
|
||||||
<div class="col-field" :class="`col-${field.fieldname}`">
|
|
||||||
<template v-if="field.fieldtype === 'Check'">
|
|
||||||
<n-checkbox
|
|
||||||
v-model:checked="job[field.fieldname]"
|
|
||||||
@update:checked="field.fieldname === 'stopped' ? () => handleStatusChange(job) : undefined"
|
|
||||||
@click.stop
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
:class="['status-badge', job[field.fieldname] ? 'stopped' : 'running']"
|
|
||||||
v-if="field.fieldname === 'stopped'"
|
|
||||||
>
|
|
||||||
{{ job[field.fieldname] ? t('Stopped') : t('Running') }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<div v-if="field.fieldname === 'method' || field.fieldname === 'name'" class="col-name-wrapper">
|
|
||||||
<div class="name">{{ renderFieldValue(field, job).value }}</div>
|
|
||||||
<div v-if="field.fieldname === 'method' && job.name !== job.method" class="description">{{ job.name }}</div>
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
v-else-if="field.fieldtype === 'Select' && field.fieldname === 'frequency'"
|
|
||||||
class="badge"
|
|
||||||
>
|
|
||||||
{{ t(renderFieldValue(field, job).value) }}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
v-else-if="field.fieldname === 'cron_format' && job.cron_format"
|
|
||||||
class="cron-text"
|
|
||||||
>
|
|
||||||
{{ renderFieldValue(field, job).value }}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
v-else-if="field.fieldtype === 'Datetime' || field.fieldtype === 'Date'"
|
|
||||||
:class="job[field.fieldname] ? 'datetime-text' : 'text-muted'"
|
|
||||||
>
|
|
||||||
{{ renderFieldValue(field, job).value }}
|
|
||||||
</span>
|
|
||||||
<span v-else :class="job[field.fieldname] ? '' : 'text-muted'">
|
|
||||||
{{ renderFieldValue(field, job).value }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<div class="col-actions">
|
|
||||||
<button
|
|
||||||
:class="['action-btn', job.stopped ? 'start-btn' : 'stop-btn']"
|
|
||||||
@click.stop="toggleJobStatus(job.name)"
|
|
||||||
:title="job.stopped ? t('Start') : t('Stop')"
|
|
||||||
>
|
|
||||||
<i :class="job.stopped ? 'fa fa-play' : 'fa fa-pause'"></i>
|
|
||||||
</button>
|
|
||||||
<button class="action-btn" @click.stop="openDetail(job.name)" :title="t('View Details')">
|
|
||||||
<i class="fa fa-eye"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- 分页 -->
|
|
||||||
<div class="list-pagination">
|
|
||||||
<n-pagination v-model:page="page" :page-count="pageCount" size="small" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed, onMounted, watch } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { t } from '../../shared/i18n'
|
|
||||||
import { NInput, NSelect, NPagination, NCheckbox, useMessage } from 'naive-ui'
|
|
||||||
import axios from 'axios'
|
|
||||||
import { get_session_api_headers } from '../../shared/api/auth'
|
|
||||||
import {
|
|
||||||
getScheduledJobs,
|
|
||||||
toggleScheduledJobStatus
|
|
||||||
} from '../../shared/api/scheduledJobs'
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const message = useMessage()
|
|
||||||
|
|
||||||
// 响应式数据
|
|
||||||
const loading = ref(false)
|
|
||||||
const jobs = ref<any[]>([])
|
|
||||||
const allJobs = ref<any[]>([]) // 保存所有原始数据
|
|
||||||
const total = ref(0)
|
|
||||||
const searchQuery = ref('')
|
|
||||||
const frequencyFilter = ref('all')
|
|
||||||
const statusFilter = ref('all')
|
|
||||||
const page = ref(1)
|
|
||||||
const pageSize = ref(parseInt(localStorage.getItem('itemsPerPage') || '10'))
|
|
||||||
|
|
||||||
// 字段元数据
|
|
||||||
const metaFields = ref<any[]>([])
|
|
||||||
const listViewFields = ref<any[]>([]) // 列表中显示的字段
|
|
||||||
|
|
||||||
// 选项数据
|
|
||||||
const frequencyOptions = ref<{ label: string; value: string }[]>([])
|
|
||||||
const statusOptions = computed(() => [
|
|
||||||
{ label: t('All'), value: 'all' },
|
|
||||||
{ label: t('Running'), value: 'running' },
|
|
||||||
{ label: t('Stopped'), value: 'stopped' }
|
|
||||||
])
|
|
||||||
|
|
||||||
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
|
|
||||||
|
|
||||||
// 获取任务列表
|
|
||||||
async function fetchJobs() {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
// 构建过滤条件
|
|
||||||
const filters = []
|
|
||||||
if (searchQuery.value) {
|
|
||||||
filters.push(['method', 'like', `%${searchQuery.value}%`])
|
|
||||||
}
|
|
||||||
if (frequencyFilter.value !== 'all') {
|
|
||||||
filters.push(['frequency', '=', frequencyFilter.value])
|
|
||||||
}
|
|
||||||
if (statusFilter.value !== 'all') {
|
|
||||||
const isStopped = statusFilter.value === 'stopped'
|
|
||||||
filters.push(['stopped', '=', isStopped ? 1 : 0])
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await getScheduledJobs(page.value, pageSize.value, filters)
|
|
||||||
|
|
||||||
allJobs.value = result.items || []
|
|
||||||
processJobs()
|
|
||||||
total.value = result.total || 0
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取任务列表失败:', error)
|
|
||||||
message.error(t('Failed to load jobs'))
|
|
||||||
jobs.value = []
|
|
||||||
allJobs.value = []
|
|
||||||
total.value = 0
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function processJobs() {
|
|
||||||
let filteredJobs = [...allJobs.value]
|
|
||||||
|
|
||||||
// 转换 stopped 字段为布尔类型
|
|
||||||
filteredJobs = filteredJobs.map(job => ({
|
|
||||||
...job,
|
|
||||||
stopped: Boolean(job.stopped)
|
|
||||||
}))
|
|
||||||
|
|
||||||
// 前端搜索和过滤(如果后端没有处理)
|
|
||||||
if (searchQuery.value) {
|
|
||||||
const query = searchQuery.value.toLowerCase()
|
|
||||||
filteredJobs = filteredJobs.filter(job =>
|
|
||||||
job.method?.toLowerCase().includes(query) ||
|
|
||||||
job.name?.toLowerCase().includes(query)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (frequencyFilter.value !== 'all') {
|
|
||||||
filteredJobs = filteredJobs.filter(job => job.frequency === frequencyFilter.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (statusFilter.value !== 'all') {
|
|
||||||
const isStopped = statusFilter.value === 'stopped'
|
|
||||||
filteredJobs = filteredJobs.filter(job => !!job.stopped === isStopped)
|
|
||||||
}
|
|
||||||
|
|
||||||
jobs.value = filteredJobs
|
|
||||||
}
|
|
||||||
|
|
||||||
function reload() {
|
|
||||||
fetchJobs()
|
|
||||||
}
|
|
||||||
|
|
||||||
function openDetail(name: string) {
|
|
||||||
router.push({ name: 'ScheduledJobDetail', params: { id: name } })
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理状态变化
|
|
||||||
async function handleStatusChange(job: any) {
|
|
||||||
try {
|
|
||||||
const result = await toggleScheduledJobStatus(job.name)
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
message.success(result.message || t('Job status updated successfully'))
|
|
||||||
// 状态已经在 v-model 中自动更新了
|
|
||||||
} else {
|
|
||||||
message.error(result.message || t('Failed to update job status'))
|
|
||||||
// 如果保存失败,恢复原状态
|
|
||||||
job.stopped = !job.stopped
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Toggle job status error:', error)
|
|
||||||
message.error(t('Failed to update job status'))
|
|
||||||
// 如果保存失败,恢复原状态
|
|
||||||
job.stopped = !job.stopped
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleJobStatus(jobName: string) {
|
|
||||||
try {
|
|
||||||
const result = await toggleScheduledJobStatus(jobName)
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
message.success(result.message || t('Job status updated successfully'))
|
|
||||||
await fetchJobs()
|
|
||||||
} else {
|
|
||||||
message.error(result.message || t('Failed to update job status'))
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Toggle job status error:', error)
|
|
||||||
message.error(t('Failed to update job status'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDateTime(dateTimeStr: string) {
|
|
||||||
if (!dateTimeStr) return '—'
|
|
||||||
try {
|
|
||||||
const date = new Date(dateTimeStr)
|
|
||||||
return date.toLocaleString()
|
|
||||||
} catch (error) {
|
|
||||||
return dateTimeStr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 生成动态网格列模板
|
|
||||||
function getGridTemplateColumns(): string {
|
|
||||||
if (listViewFields.value.length === 0) {
|
|
||||||
return '1fr 120px 150px 100px 180px 120px'
|
|
||||||
}
|
|
||||||
|
|
||||||
// 为每个字段生成列宽
|
|
||||||
const columns = listViewFields.value.map((field: any) => {
|
|
||||||
// 根据字段类型和名称设置合适的列宽
|
|
||||||
if (field.fieldname === 'method' || field.fieldname === 'name') {
|
|
||||||
return '1fr' // 主字段自适应宽度
|
|
||||||
} else if (field.fieldname === 'frequency') {
|
|
||||||
return '120px'
|
|
||||||
} else if (field.fieldname === 'cron_format') {
|
|
||||||
return '150px'
|
|
||||||
} else if (field.fieldname === 'stopped') {
|
|
||||||
return '120px'
|
|
||||||
} else if (field.fieldtype === 'Datetime' || field.fieldtype === 'Date') {
|
|
||||||
return '180px'
|
|
||||||
} else if (field.fieldtype === 'Check') {
|
|
||||||
return '100px'
|
|
||||||
} else {
|
|
||||||
return '120px' // 默认宽度
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// 添加操作列
|
|
||||||
columns.push('120px')
|
|
||||||
|
|
||||||
return columns.join(' ')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 加载字段元数据
|
|
||||||
async function loadMetaFields() {
|
|
||||||
try {
|
|
||||||
const response = await axios.get(
|
|
||||||
`/api/data/PageType/${encodeURIComponent('Local Scheduled Job')}`,
|
|
||||||
{ headers: get_session_api_headers(), withCredentials: true }
|
|
||||||
)
|
|
||||||
const data = response.data?.data || {}
|
|
||||||
metaFields.value = data.fields || []
|
|
||||||
|
|
||||||
// 筛选出需要在列表视图中显示的字段
|
|
||||||
listViewFields.value = metaFields.value.filter((field: any) => {
|
|
||||||
// 显示 in_list_view 为 1 的字段,或者特定的字段
|
|
||||||
return field.in_list_view === 1 || ['method', 'frequency', 'cron_format', 'stopped', 'last_execution'].includes(field.fieldname)
|
|
||||||
})
|
|
||||||
|
|
||||||
// 按 field_order 排序
|
|
||||||
const fieldOrder = data.field_order || []
|
|
||||||
listViewFields.value.sort((a: any, b: any) => {
|
|
||||||
const indexA = fieldOrder.indexOf(a.fieldname)
|
|
||||||
const indexB = fieldOrder.indexOf(b.fieldname)
|
|
||||||
if (indexA === -1 && indexB === -1) return 0
|
|
||||||
if (indexA === -1) return 1
|
|
||||||
if (indexB === -1) return -1
|
|
||||||
return indexA - indexB
|
|
||||||
})
|
|
||||||
|
|
||||||
// 从字段元数据中提取 frequency 字段的选项
|
|
||||||
const frequencyField = metaFields.value.find((f: any) => f.fieldname === 'frequency')
|
|
||||||
if (frequencyField && frequencyField.options) {
|
|
||||||
const options = typeof frequencyField.options === 'string'
|
|
||||||
? frequencyField.options.split('\n').filter((opt: string) => opt.trim())
|
|
||||||
: []
|
|
||||||
frequencyOptions.value = [
|
|
||||||
{ label: t('All'), value: 'all' },
|
|
||||||
...options.map((option: string) => ({
|
|
||||||
label: t(option.trim()),
|
|
||||||
value: option.trim()
|
|
||||||
}))
|
|
||||||
]
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load meta fields:', error)
|
|
||||||
// 如果加载失败,使用默认字段
|
|
||||||
listViewFields.value = [
|
|
||||||
{ fieldname: 'method', label: 'Method', fieldtype: 'Data' },
|
|
||||||
{ fieldname: 'frequency', label: 'Frequency', fieldtype: 'Select' },
|
|
||||||
{ fieldname: 'cron_format', label: 'Cron Format', fieldtype: 'Data' },
|
|
||||||
{ fieldname: 'stopped', label: 'Status', fieldtype: 'Check' },
|
|
||||||
{ fieldname: 'last_execution', label: 'Last Execution', fieldtype: 'Datetime' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 根据字段类型渲染字段值
|
|
||||||
function renderFieldValue(field: any, job: any): any {
|
|
||||||
const value = job[field.fieldname]
|
|
||||||
const fieldtype = field.fieldtype || 'Data'
|
|
||||||
|
|
||||||
switch (fieldtype) {
|
|
||||||
case 'Check':
|
|
||||||
return {
|
|
||||||
type: 'checkbox',
|
|
||||||
value: Boolean(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'Datetime':
|
|
||||||
case 'Date':
|
|
||||||
if (!value) return { type: 'text', value: '—' }
|
|
||||||
try {
|
|
||||||
const date = new Date(value)
|
|
||||||
return {
|
|
||||||
type: 'text',
|
|
||||||
value: date.toLocaleString()
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
return { type: 'text', value: value }
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'Select':
|
|
||||||
// Select 字段直接显示值
|
|
||||||
return {
|
|
||||||
type: 'text',
|
|
||||||
value: value || '—'
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'Link':
|
|
||||||
// Link 字段显示链接值
|
|
||||||
return {
|
|
||||||
type: 'text',
|
|
||||||
value: value || '—'
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'Data':
|
|
||||||
case 'Small Text':
|
|
||||||
case 'Text':
|
|
||||||
default:
|
|
||||||
return {
|
|
||||||
type: 'text',
|
|
||||||
value: value || '—'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 监听搜索和筛选变化
|
|
||||||
watch([searchQuery, frequencyFilter, statusFilter], () => {
|
|
||||||
page.value = 1 // 重置到第一页
|
|
||||||
fetchJobs()
|
|
||||||
}, { deep: true })
|
|
||||||
|
|
||||||
// 监听分页变化
|
|
||||||
watch([page], () => {
|
|
||||||
fetchJobs()
|
|
||||||
})
|
|
||||||
|
|
||||||
// 监听每页数量变化(从系统设置)
|
|
||||||
watch(() => localStorage.getItem('itemsPerPage'), (newValue) => {
|
|
||||||
if (newValue) {
|
|
||||||
pageSize.value = parseInt(newValue)
|
|
||||||
page.value = 1 // 重置到第一页
|
|
||||||
fetchJobs()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
loadMetaFields()
|
|
||||||
fetchJobs()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.page {
|
|
||||||
padding: 16px;
|
|
||||||
width: 100%;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-left h2 {
|
|
||||||
font-size: 28px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #1f2937;
|
|
||||||
margin: 0 0 8px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-description {
|
|
||||||
font-size: 16px;
|
|
||||||
color: #6b7280;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-right {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filters {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.refresh-btn {
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #f8fafc;
|
|
||||||
color: #64748b;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 14px;
|
|
||||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.refresh-btn:hover {
|
|
||||||
background: #e2e8f0;
|
|
||||||
color: #475569;
|
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.refresh-btn:active {
|
|
||||||
transform: translateY(0);
|
|
||||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.refresh-btn:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: not-allowed;
|
|
||||||
transform: none;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.refresh-btn:disabled:hover {
|
|
||||||
background: #f8fafc;
|
|
||||||
color: #64748b;
|
|
||||||
transform: none;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 60px 20px;
|
|
||||||
color: #6b7280;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading i {
|
|
||||||
margin-right: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 列表视图 */
|
|
||||||
.job-list {
|
|
||||||
background: white;
|
|
||||||
border: 1px solid #e5e7eb;
|
|
||||||
border-radius: 12px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-header {
|
|
||||||
display: grid;
|
|
||||||
gap: 16px;
|
|
||||||
padding: 16px 20px;
|
|
||||||
background: #f9fafb;
|
|
||||||
border-bottom: 1px solid #e5e7eb;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #374151;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.col-header {
|
|
||||||
color: #374151;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-body {
|
|
||||||
max-height: 600px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-item {
|
|
||||||
display: grid;
|
|
||||||
gap: 16px;
|
|
||||||
padding: 16px 20px;
|
|
||||||
border-bottom: 1px solid #f3f4f6;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-item:hover {
|
|
||||||
background: #f9fafb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-item:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.col-field {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.col-name-wrapper {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.col-name-wrapper .name {
|
|
||||||
font-weight: 600;
|
|
||||||
color: #111827;
|
|
||||||
font-size: 14px;
|
|
||||||
margin-bottom: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.col-name-wrapper .description {
|
|
||||||
color: #6b7280;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.3;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 保留向后兼容的类名 */
|
|
||||||
.col-name {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.col-name .name {
|
|
||||||
font-weight: 600;
|
|
||||||
color: #111827;
|
|
||||||
font-size: 14px;
|
|
||||||
margin-bottom: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.col-name .description {
|
|
||||||
color: #6b7280;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.3;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.col-frequency {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.col-cron {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cron-text {
|
|
||||||
color: #374151;
|
|
||||||
background: #f3f4f6;
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.col-status {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 将checkbox选中状态(已停止)的背景改为红色 */
|
|
||||||
.col-status :deep(.n-checkbox--checked .n-checkbox-box) {
|
|
||||||
background-color: #ef4444 !important;
|
|
||||||
border-color: #ef4444 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.col-status :deep(.n-checkbox--checked .n-checkbox-box:hover) {
|
|
||||||
background-color: #dc2626 !important;
|
|
||||||
border-color: #dc2626 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.col-status :deep(.n-checkbox--checked .n-checkbox-box__border) {
|
|
||||||
border-color: #ef4444 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.col-last-execution {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
color: #6b7280;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.datetime-text {
|
|
||||||
color: #374151;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-muted {
|
|
||||||
color: #9ca3af;
|
|
||||||
}
|
|
||||||
|
|
||||||
.col-actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn {
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
border: none;
|
|
||||||
background: #f3f4f6;
|
|
||||||
color: #6b7280;
|
|
||||||
border-radius: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 12px;
|
|
||||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn:hover {
|
|
||||||
background: #3b82f6;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 启动按钮 - 绿色系(与详情页执行按钮配色一致) */
|
|
||||||
.action-btn.start-btn {
|
|
||||||
background: #dcfce7;
|
|
||||||
color: #166534;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn.start-btn:hover {
|
|
||||||
background: #bbf7d0;
|
|
||||||
color: #15803d;
|
|
||||||
box-shadow: 0 2px 8px rgba(34, 197, 94, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 停止按钮 - 红色系 */
|
|
||||||
.action-btn.stop-btn {
|
|
||||||
background: #fee2e2;
|
|
||||||
color: #dc2626;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn.stop-btn:hover {
|
|
||||||
background: #fecaca;
|
|
||||||
color: #b91c1c;
|
|
||||||
box-shadow: 0 2px 8px rgba(239, 68, 68, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge {
|
|
||||||
background: #f3f4f6;
|
|
||||||
color: #374151;
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge {
|
|
||||||
padding: 4px 10px;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
display: inline-block;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge.running {
|
|
||||||
background: #dcfce7;
|
|
||||||
color: #166534;
|
|
||||||
border: 1px solid rgba(34, 197, 94, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge.stopped {
|
|
||||||
background: #fee2e2;
|
|
||||||
color: #dc2626;
|
|
||||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-pagination {
|
|
||||||
padding: 16px 20px;
|
|
||||||
border-top: 1px solid #e5e7eb;
|
|
||||||
background: #f9fafb;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@ -92,19 +92,6 @@
|
|||||||
</template>
|
</template>
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
|
|
||||||
<!-- Workspace 名称 -->
|
|
||||||
<n-form-item v-if="form.type === 'workspace'" :label="t('Workspace Name')">
|
|
||||||
<n-input
|
|
||||||
v-model:value="(form as any).workspaceName"
|
|
||||||
:placeholder="t('e.g. Build or custom workspace name')"
|
|
||||||
/>
|
|
||||||
<template #feedback>
|
|
||||||
<n-text depth="3" style="font-size: 12px;">
|
|
||||||
打开路径:/workspace/{{ pageTypeToSlug((form as any).workspaceName || '') }}
|
|
||||||
</n-text>
|
|
||||||
</template>
|
|
||||||
</n-form-item>
|
|
||||||
|
|
||||||
<n-form-item :label="t('Icon')">
|
<n-form-item :label="t('Icon')">
|
||||||
<IconPicker v-model="form.icon" />
|
<IconPicker v-model="form.icon" />
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
@ -169,8 +156,8 @@ const filteredItems = computed(() => {
|
|||||||
|
|
||||||
// 非 System User 的过滤逻辑
|
// 非 System User 的过滤逻辑
|
||||||
if (!isSystemUser) {
|
if (!isSystemUser) {
|
||||||
// 过滤掉 pagetype 和 workspace 类型
|
// 过滤掉 pagetype 类型
|
||||||
if (m.type === 'pagetype' || m.type === 'workspace') {
|
if (m.type === 'pagetype') {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -200,7 +187,7 @@ const dialog = useDialog()
|
|||||||
const columns = [
|
const columns = [
|
||||||
{ title: t('Display Name'), key: 'label' },
|
{ title: t('Display Name'), key: 'label' },
|
||||||
{ title: t('Type'), key: 'type', render: (row: AppMenuItem) => {
|
{ title: t('Type'), key: 'type', render: (row: AppMenuItem) => {
|
||||||
const typeMap = { 'pagetype': t('PageType'), 'route': t('Route'), 'url': t('URL'), 'workspace': t('Workspace'), 'group': t('Group') }
|
const typeMap = { 'pagetype': t('PageType'), 'route': t('Route'), 'url': t('URL'), 'group': t('Group') }
|
||||||
return typeMap[row.type] || row.type
|
return typeMap[row.type] || row.type
|
||||||
}},
|
}},
|
||||||
|
|
||||||
@ -210,7 +197,6 @@ const columns = [
|
|||||||
return p ? p.label : t('None')
|
return p ? p.label : t('None')
|
||||||
}},
|
}},
|
||||||
{ title: t('PageType'), key: 'pagetype' },
|
{ title: t('PageType'), key: 'pagetype' },
|
||||||
{ title: t('Workspace'), key: 'workspaceName' },
|
|
||||||
{ title: t('Route Name'), key: 'routeName' },
|
{ title: t('Route Name'), key: 'routeName' },
|
||||||
{ title: t('URL Path'), key: 'url' },
|
{ title: t('URL Path'), key: 'url' },
|
||||||
{ title: t('Icon'), key: 'icon' },
|
{ title: t('Icon'), key: 'icon' },
|
||||||
@ -254,13 +240,12 @@ const typeOptions = computed(() => {
|
|||||||
{ label: t('PageType'), value: 'pagetype' },
|
{ label: t('PageType'), value: 'pagetype' },
|
||||||
{ label: t('Route'), value: 'route' },
|
{ label: t('Route'), value: 'route' },
|
||||||
{ label: t('URL'), value: 'url' },
|
{ label: t('URL'), value: 'url' },
|
||||||
{ label: t('Workspace'), value: 'workspace' },
|
|
||||||
{ label: t('Group'), value: 'group' }
|
{ label: t('Group'), value: 'group' }
|
||||||
]
|
]
|
||||||
|
|
||||||
// 非 System User 过滤掉 pagetype 和 workspace 选项
|
// 非 System User 过滤掉 pagetype 选项
|
||||||
if (!isSystemUser) {
|
if (!isSystemUser) {
|
||||||
return allOptions.filter(opt => opt.value !== 'pagetype' && opt.value !== 'workspace')
|
return allOptions.filter(opt => opt.value !== 'pagetype')
|
||||||
}
|
}
|
||||||
|
|
||||||
return allOptions
|
return allOptions
|
||||||
@ -316,7 +301,6 @@ function onTypeChange() {
|
|||||||
form.value.pagetype = ''
|
form.value.pagetype = ''
|
||||||
form.value.routeName = ''
|
form.value.routeName = ''
|
||||||
form.value.url = ''
|
form.value.url = ''
|
||||||
;(form.value as any).workspaceName = ''
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -331,13 +315,13 @@ function onPageTypeChange() {
|
|||||||
function save() {
|
function save() {
|
||||||
const data = { ...form.value }
|
const data = { ...form.value }
|
||||||
|
|
||||||
// 验证:非 System User 不允许保存 pagetype 和 workspace 类型
|
// 验证:非 System User 不允许保存 pagetype 类型
|
||||||
const userType = authStore.user?.user_type
|
const userType = authStore.user?.user_type
|
||||||
const isSystemUser = userType === 'System User'
|
const isSystemUser = userType === 'System User'
|
||||||
if (!isSystemUser && (data.type === 'pagetype' || data.type === 'workspace')) {
|
if (!isSystemUser && data.type === 'pagetype') {
|
||||||
dialog.error({
|
dialog.error({
|
||||||
title: t('Permission Denied'),
|
title: t('Permission Denied'),
|
||||||
content: t('Non-System User cannot create or edit pagetype and workspace menu items'),
|
content: t('Non-System User cannot create or edit pagetype menu items'),
|
||||||
positiveText: t('OK')
|
positiveText: t('OK')
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
@ -347,9 +331,6 @@ function save() {
|
|||||||
if (data.type === 'pagetype' && data.pagetype) {
|
if (data.type === 'pagetype' && data.pagetype) {
|
||||||
data.key = pageTypeToSlug(data.pagetype)
|
data.key = pageTypeToSlug(data.pagetype)
|
||||||
data.url = `/app/${data.key}`
|
data.url = `/app/${data.key}`
|
||||||
} else if (data.type === 'workspace' && (data as any).workspaceName) {
|
|
||||||
data.key = pageTypeToSlug((data as any).workspaceName)
|
|
||||||
data.url = `/workspace/${data.key}`
|
|
||||||
} else if (data.type === 'route' && data.routeName) {
|
} else if (data.type === 'route' && data.routeName) {
|
||||||
data.key = data.routeName
|
data.key = data.routeName
|
||||||
} else if (data.type === 'url' && data.url) {
|
} else if (data.type === 'url' && data.url) {
|
||||||
|
|||||||
@ -1,234 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="workspace">
|
|
||||||
<div class="workspace-header">
|
|
||||||
<n-button quaternary circle @click="refresh" style="margin-left: auto;">
|
|
||||||
<template #icon>
|
|
||||||
<Icon icon="tabler:refresh" />
|
|
||||||
</template>
|
|
||||||
</n-button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="loading" class="loading"></div>
|
|
||||||
<div v-else class="content">
|
|
||||||
<div class="grid-12">
|
|
||||||
<template v-for="item in items" :key="item.key">
|
|
||||||
<div v-if="item.type==='card'" class="card" :class="item.classes">
|
|
||||||
<div class="card-header">
|
|
||||||
<div class="card-title">{{ t(item.title) }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="card-links">
|
|
||||||
<a v-for="link in item.links" :key="link.label" class="link" @click.prevent="openLink(link)">
|
|
||||||
<span class="link-text">{{ t(link.label) }}</span>
|
|
||||||
<Icon class="link-icon" icon="tabler:arrow-up-right" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-else-if="item.type==='shortcut'" class="shortcut" :class="item.classes">
|
|
||||||
<div class="shortcut-content" @click="openShortcut(item.shortcutData)">
|
|
||||||
<span class="shortcut-text">{{ t(item.title) }}</span>
|
|
||||||
<Icon class="shortcut-icon" icon="tabler:arrow-up-right" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-else-if="item.type==='header'" class="header" :class="item.classes">
|
|
||||||
<div class="header-content" v-html="item.text"></div>
|
|
||||||
</div>
|
|
||||||
<div v-else-if="item.type==='spacer'" class="spacer span-12" />
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed, onMounted, watch } from 'vue'
|
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
|
||||||
import { NButton, useMessage } from 'naive-ui'
|
|
||||||
import { Icon } from '@iconify/vue'
|
|
||||||
import { t } from '@/shared/i18n'
|
|
||||||
import { getWorkspace } from '@/shared/api/common'
|
|
||||||
import { pageTypeToSlug } from '@/shared/utils/slug'
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const router = useRouter()
|
|
||||||
const message = useMessage()
|
|
||||||
|
|
||||||
const name = computed(() => String(route.params.name || 'Build'))
|
|
||||||
const decodedName = computed(() => decodeURIComponent(name.value))
|
|
||||||
|
|
||||||
const loading = ref(true)
|
|
||||||
const pageTitle = ref('Workspace')
|
|
||||||
const isPublic = ref(true)
|
|
||||||
const items = ref<any[]>([])
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
loading.value = true
|
|
||||||
const { success, data, message: err } = await getWorkspace(decodedName.value)
|
|
||||||
if (!success) {
|
|
||||||
message.error(err || 'Failed to load workspace')
|
|
||||||
loading.value = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
pageTitle.value = data?.title || decodedName.value
|
|
||||||
isPublic.value = Boolean(data?.public ?? true)
|
|
||||||
|
|
||||||
// 解析 content(布局)与 links(数据项)。
|
|
||||||
const layout = parseJSON(data?.content) as Array<any>
|
|
||||||
const links = Array.isArray(data?.links) ? data.links : []
|
|
||||||
|
|
||||||
// 将 links 按 Card Break 分组:每个 Card Break(label) 之后的连续 Link 归入该分组。
|
|
||||||
const grouped: Record<string, any[]> = {}
|
|
||||||
let current: string = ''
|
|
||||||
for (const ln of links) {
|
|
||||||
if (ln.type === 'Card Break') {
|
|
||||||
current = ln.label || 'Untitled'
|
|
||||||
if (!grouped[current]) grouped[current] = []
|
|
||||||
} else if (ln.type === 'Link' && current) {
|
|
||||||
grouped[current].push({ label: ln.label, link_type: ln.link_type, link_to: ln.link_to })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 将每个 card 作为一个卡片,col 为 12 栅格宽度;默认 6 ⇒ 两列布局
|
|
||||||
const resultItems: any[] = []
|
|
||||||
for (const block of layout || []) {
|
|
||||||
if (block.type === 'card') {
|
|
||||||
const title = block.data?.card_name || 'Section'
|
|
||||||
const col = Number(block.data?.col || 6) // 按 12 栅格,6=两列,3=四列
|
|
||||||
resultItems.push({ key: `card-${title}-${col}-${resultItems.length}` , type:'card', title, col, links: grouped[title] || [], classes: mapColToClasses(col) })
|
|
||||||
} else if (block.type === 'shortcut') {
|
|
||||||
// 处理快捷方式
|
|
||||||
const title = block.data?.shortcut_name || 'Shortcut'
|
|
||||||
const col = Number(block.data?.col || 3) // 默认3列宽度
|
|
||||||
// 从shortcuts数组中查找对应的数据
|
|
||||||
const shortcutData = data?.shortcuts?.find((s: any) => s.label === title)
|
|
||||||
resultItems.push({
|
|
||||||
key: `shortcut-${title}-${col}-${resultItems.length}`,
|
|
||||||
type: 'shortcut',
|
|
||||||
title,
|
|
||||||
col,
|
|
||||||
shortcutData,
|
|
||||||
classes: mapColToClasses(col)
|
|
||||||
})
|
|
||||||
} else if (block.type === 'header') {
|
|
||||||
// 处理标题
|
|
||||||
const text = block.data?.text || 'Header'
|
|
||||||
const col = Number(block.data?.col || 12) // 标题通常占满一行
|
|
||||||
resultItems.push({
|
|
||||||
key: `header-${text}-${col}-${resultItems.length}`,
|
|
||||||
type: 'header',
|
|
||||||
text,
|
|
||||||
col,
|
|
||||||
classes: ['span-12']
|
|
||||||
})
|
|
||||||
} else if (block.type === 'spacer') {
|
|
||||||
// 插入占满一行的分隔,仅用于换行
|
|
||||||
resultItems.push({ key: `spacer-${resultItems.length}`, type: 'spacer' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
items.value = resultItems
|
|
||||||
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
function refresh() { load() }
|
|
||||||
|
|
||||||
function openLink(link: { link_type: string; link_to: string }) {
|
|
||||||
// 简单导航:PageType 走 /app/:entity 列表;Page 则走 /:page;Report 保持 /app/:entity
|
|
||||||
if (!link) return
|
|
||||||
const to = (link.link_type || '').toLowerCase()
|
|
||||||
if (to === 'pagetype' || to === 'report') {
|
|
||||||
const slug = pageTypeToSlug(link.link_to)
|
|
||||||
router.push(`/app/${slug}`)
|
|
||||||
} else if (to === 'page') {
|
|
||||||
router.push(`/${link.link_to}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openShortcut(shortcutData: any) {
|
|
||||||
if (!shortcutData) return
|
|
||||||
|
|
||||||
if (shortcutData.url) {
|
|
||||||
// 如果有URL,直接打开外部链接
|
|
||||||
window.open(shortcutData.url, '_blank')
|
|
||||||
} else if (shortcutData.link_to) {
|
|
||||||
const slug = pageTypeToSlug(shortcutData.link_to)
|
|
||||||
router.push(`/app/${slug}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(() => route.params.name, load)
|
|
||||||
onMounted(load)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.workspace { padding: 16px; }
|
|
||||||
.workspace-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
|
|
||||||
.title-wrap { display: flex; align-items: center; gap: 8px; }
|
|
||||||
.title { margin: 0; font-size: 20px; }
|
|
||||||
.content { min-height: calc(100vh - 160px); }
|
|
||||||
.grid-12 { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); gap: 16px; }
|
|
||||||
/* 基线:移动端单列 */
|
|
||||||
.span-12 { grid-column: span 12; }
|
|
||||||
/* Bootstrap 对齐的断点:sm=576, md=768, lg=992 */
|
|
||||||
@media (min-width: 576px) { .sm-span-6 { grid-column: span 6; } }
|
|
||||||
@media (min-width: 768px) { .md-span-4 { grid-column: span 4; } }
|
|
||||||
@media (min-width: 992px) {
|
|
||||||
.lg-span-3 { grid-column: span 3; }
|
|
||||||
.lg-span-2 { grid-column: span 2; }
|
|
||||||
}
|
|
||||||
.card { background: #fff; border: 1px solid #eef2f7; border-radius: 12px; overflow: hidden; box-shadow: 0 2px 8px rgba(15,23,42,0.04); }
|
|
||||||
.card-header { padding: 14px 16px; border-bottom: 1px solid #f1f5f9; }
|
|
||||||
.card-title { font-weight: 600; color: #0f172a; }
|
|
||||||
.card-body { padding: 12px 16px; color: #334155; }
|
|
||||||
.card-links { display: grid; gap: 8px; }
|
|
||||||
.link { display: inline-flex; align-items: center; gap: 10px; color: #0f172a; text-decoration: none; cursor: pointer; }
|
|
||||||
.link:hover { color: #18a058; }
|
|
||||||
.link-icon { color: #94a3b8; font-size: 13px; transition: all .15s ease; margin-left: 2px; }
|
|
||||||
.link:hover .link-icon { color: #18a058; transform: translate(1px, -1px); }
|
|
||||||
|
|
||||||
.shortcut { background: #fff; border: 1px solid #eef2f7; border-radius: 12px; overflow: hidden; box-shadow: 0 2px 8px rgba(15,23,42,0.04); }
|
|
||||||
.shortcut-content { padding: 16px; display: inline-flex; align-items: center; gap: 10px; cursor: pointer; transition: all .15s ease; }
|
|
||||||
.shortcut-content:hover { background: #f8fafc; }
|
|
||||||
.shortcut-text { font-weight: 500; color: #0f172a; }
|
|
||||||
.shortcut-icon { color: #94a3b8; font-size: 13px; transition: all .15s ease; margin-left: 2px; }
|
|
||||||
.shortcut-content:hover .shortcut-icon { color: #18a058; transform: translate(1px, -1px); }
|
|
||||||
|
|
||||||
.header { margin: 16px 0 8px 0; }
|
|
||||||
.header-content { font-size: 18px; font-weight: 600; color: #0f172a; }
|
|
||||||
|
|
||||||
.spacer { height: 8px; }
|
|
||||||
.loading { color: #64748b; }
|
|
||||||
@media (max-width: 1024px) { .grid-12 { grid-template-columns: 1fr; } }
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
// 本地工具:安全解析 JSON
|
|
||||||
export function parseJSON(input: any): any[] {
|
|
||||||
try {
|
|
||||||
if (typeof input === 'string') return JSON.parse(input)
|
|
||||||
if (Array.isArray(input)) return input
|
|
||||||
return []
|
|
||||||
} catch {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 将 jingrow 的 set_col_class 规则映射到 CSS Grid 的响应式 span 类
|
|
||||||
export function mapColToClasses(width: number): string[] {
|
|
||||||
const classes = ['span-12']
|
|
||||||
if (width <= 12 && width >= 7) {
|
|
||||||
// 大卡:保持整行基线(移动端1列),可按需扩展
|
|
||||||
} else if (width === 6 || width === 5) {
|
|
||||||
classes.push('sm-span-6')
|
|
||||||
} else if (width === 4) {
|
|
||||||
classes.push('sm-span-6', 'md-span-4')
|
|
||||||
} else if (width === 3 || width === 2) {
|
|
||||||
classes.push('sm-span-6', 'md-span-4', `lg-span-${width}`)
|
|
||||||
}
|
|
||||||
return classes
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
|
|
||||||
Loading…
x
Reference in New Issue
Block a user