删除一些不需要的功能

This commit is contained in:
jingrow 2025-12-27 22:28:48 +08:00
parent 59041b57d0
commit 6be9ce2d10
19 changed files with 17 additions and 3174 deletions

View File

@ -171,13 +171,6 @@ const breadcrumbItems = computed(() => {
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 {
//
const map: Record<string, string> = {
@ -186,11 +179,7 @@ const breadcrumbItems = computed(() => {
AgentDetail: t('Agent Detail'),
NodeList: t('Node Management'),
NodeDetail: t('Node Detail'),
LocalJobList: t('Local Jobs'),
LocalJobDetail: t('Local Job Detail'),
FlowBuilder: t('Flow Builder'),
ScheduledJobList: t('Scheduled Jobs'),
ScheduledJobDetail: t('Scheduled Job Detail'),
MenuManager: t('Menu Manager'),
Settings: t('Settings'),
SearchResults: t('Search Results')

View File

@ -127,12 +127,6 @@ const handleMenuSelect = (key: string) => {
router.push(menuItem.url)
}
} else {
// Workspace
if (menuItem.type === 'workspace' && menuItem.workspaceName) {
const slug = pageTypeToSlug(menuItem.workspaceName)
router.push(`/workspace/${slug}`)
return
}
// 使url使routeName
if (menuItem.url) {
if (menuItem.url.startsWith('http://') || menuItem.url.startsWith('https://')) {

View File

@ -33,38 +33,11 @@ const router = createRouter({
name: 'Dashboard',
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',
name: 'FlowBuilder',
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 列表/详情 - 支持多种格式
{
path: 'app/:entity',

View File

@ -46,14 +46,8 @@ export const loginApi = async (username: string, password: string): Promise<void
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.detail || errorData.message || errorData.exc || '登录请求失败')
}
// dashboard 登录接口成功后会设置 cookie不需要返回数据
// 登录状态通过 cookie 判断
}
// 获取用户信息 - 已删除,使用 cookie 中的 user_id 判断登录状态
// 登出
export const logoutApi = async (): Promise<void> => {
const response = await fetch(`/api/action/logout`, {
method: 'POST',
@ -70,9 +64,6 @@ export const logoutApi = async (): Promise<void> => {
}
}
// 注册接口 - 已删除,/jingrow/signup API 无效
// 仅使用会话Cookie的最小鉴权头部不影响现有API Key逻辑
export function get_session_api_headers() {
return {
'Content-Type': 'application/json',

View File

@ -3,11 +3,6 @@ import { get_session_api_headers } from './auth'
// 统一使用相对路径,通过 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 }> => {
@ -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 }> => {
@ -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 }> => {
// 获取记录总数功能已移除,/jingrow/get-count API 无效
// 可以通过 getRecords 获取总数
try {
const result = await getRecords(pagetype, [], [], 'modified desc', 0, 1)
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 }> => {
@ -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: '上传文件功能已移除' }
}

View File

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

View File

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

View File

@ -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: '发布节点功能已移除' }
}

View File

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

View File

@ -7,11 +7,10 @@ export interface AppMenuItem {
key: string
label: string
icon?: string
type: 'pagetype' | 'route' | 'url' | 'workspace' | 'group' // 菜单类型,新增 group
type: 'pagetype' | 'route' | 'url' | 'group' // 菜单类型,新增 group
pagetype?: string // 页面类型名称Local AI Agent
routeName?: string // 路由名
url?: string // URL路径
workspaceName?: string // Workspace 名称(工作区文档名)
// 层级关系
parentId?: string | null
order?: number
@ -45,19 +44,14 @@ function saveToStorage(items: AppMenuItem[]) {
// 默认菜单,与现有路由对应
// 注意:非 System User 用户只能看到部分菜单项(通过 visibleItems 过滤)
// - pagetype 和 workspace 类型菜单仅对 System User 可见
// - pagetype 类型菜单仅对 System User 可见
// - 开发分组下的应用市场、节点市场、智能体市场仅对 System User 可见
// - 非 System User 只能看到工具市场
function getDefaultMenus(): AppMenuItem[] {
return [
{ 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: '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: '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 },
@ -173,7 +167,7 @@ export const useMenuStore = defineStore('menu', () => {
const authStore = useAuthStore()
const userType = authStore.user?.user_type
// 非 System User 用户类型不显示 pagetype 和 workspace 类型的菜单项
// 非 System User 用户类型不显示 pagetype 类型的菜单项
const isSystemUser = userType === 'System User'
return items.value.filter(m => {
@ -182,8 +176,8 @@ export const useMenuStore = defineStore('menu', () => {
// 非 System User 的过滤逻辑
if (!isSystemUser) {
// 过滤掉 pagetype 和 workspace 类型
if (m.type === 'pagetype' || m.type === 'workspace') {
// 过滤掉 pagetype 类型
if (m.type === 'pagetype') {
return false
}

View File

@ -6,7 +6,7 @@
<!-- 统计卡片 -->
<n-grid :cols="4" :x-gap="16" :y-gap="16" :responsive="'screen'" :item-responsive="true" class="stats-grid">
<!-- 原来的4个统计 -->
<!-- 原来的2个统计 -->
<n-grid-item>
<n-card>
<n-statistic :label="t('Total Agents')" :value="stats.agents" />
@ -17,17 +17,6 @@
<n-statistic :label="t('Total Nodes')" :value="stats.nodes" />
</n-card>
</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个统计 -->
<n-grid-item>
<n-card>
@ -68,13 +57,11 @@ import {
NStatistic
} from 'naive-ui'
import { t } from '../shared/i18n'
import { getCount, getLocalJobCount } from '../shared/api/common'
import { getCount } from '../shared/api/common'
const stats = reactive({
agents: 0,
nodes: 0,
taskQueue: 0,
scheduledTasks: 0,
knowledgeBase: 0,
note: 0,
event: 0,
@ -85,7 +72,7 @@ const stats = reactive({
//
const loadStats = async () => {
try {
// 4
// 2
//
const agentsResult = await getCount('Local Ai Agent')
if (agentsResult.success) {
@ -98,18 +85,6 @@ const loadStats = async () => {
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
//
const knowledgeBaseResult = await getCount('Knowledge Base')

View File

@ -133,8 +133,6 @@ onMounted(async () => {
return
}
// /jingrow/server-config API
//
})
</script>

View File

@ -113,7 +113,6 @@ import { NForm, NFormItem, NInput, NButton, NText, useMessage } from 'naive-ui'
import { Icon } from '@iconify/vue'
import { useAuthStore } from '../../shared/stores/auth'
import { t, getCurrentLocale } from '../../shared/i18n'
// signupApi /jingrow/signup API
const router = useRouter()
const message = useMessage()
@ -218,17 +217,8 @@ const handleSignup = async () => {
await formRef.value?.validate()
loading.value = true
// signupApi /jingrow/signup API
message.error(t('注册功能已移除,/jingrow/signup API 无效'))
message.error(t('注册功能已移除'))
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) {
message.success(t('Sign up successful'))

View File

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

View File

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

View File

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

View File

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

View File

@ -92,19 +92,6 @@
</template>
</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')">
<IconPicker v-model="form.icon" />
</n-form-item>
@ -169,8 +156,8 @@ const filteredItems = computed(() => {
// System User
if (!isSystemUser) {
// pagetype workspace
if (m.type === 'pagetype' || m.type === 'workspace') {
// pagetype
if (m.type === 'pagetype') {
return false
}
@ -200,7 +187,7 @@ const dialog = useDialog()
const columns = [
{ title: t('Display Name'), key: 'label' },
{ 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
}},
@ -210,7 +197,6 @@ const columns = [
return p ? p.label : t('None')
}},
{ title: t('PageType'), key: 'pagetype' },
{ title: t('Workspace'), key: 'workspaceName' },
{ title: t('Route Name'), key: 'routeName' },
{ title: t('URL Path'), key: 'url' },
{ title: t('Icon'), key: 'icon' },
@ -254,13 +240,12 @@ const typeOptions = computed(() => {
{ label: t('PageType'), value: 'pagetype' },
{ label: t('Route'), value: 'route' },
{ label: t('URL'), value: 'url' },
{ label: t('Workspace'), value: 'workspace' },
{ label: t('Group'), value: 'group' }
]
// System User pagetype workspace
// System User pagetype
if (!isSystemUser) {
return allOptions.filter(opt => opt.value !== 'pagetype' && opt.value !== 'workspace')
return allOptions.filter(opt => opt.value !== 'pagetype')
}
return allOptions
@ -316,7 +301,6 @@ function onTypeChange() {
form.value.pagetype = ''
form.value.routeName = ''
form.value.url = ''
;(form.value as any).workspaceName = ''
}
@ -331,13 +315,13 @@ function onPageTypeChange() {
function save() {
const data = { ...form.value }
// System User pagetype workspace
// System User pagetype
const userType = authStore.user?.user_type
const isSystemUser = userType === 'System User'
if (!isSystemUser && (data.type === 'pagetype' || data.type === 'workspace')) {
if (!isSystemUser && data.type === 'pagetype') {
dialog.error({
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')
})
return
@ -347,9 +331,6 @@ function save() {
if (data.type === 'pagetype' && data.pagetype) {
data.key = pageTypeToSlug(data.pagetype)
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) {
data.key = data.routeName
} else if (data.type === 'url' && data.url) {

View File

@ -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 /:pageReport /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>