删除冗余的路由和文件
This commit is contained in:
parent
dd3330e3c2
commit
04d4bd95fd
@ -20,26 +20,6 @@ const router = createRouter({
|
|||||||
name: 'Dashboard',
|
name: 'Dashboard',
|
||||||
component: () => import('../../views/Dashboard.vue')
|
component: () => import('../../views/Dashboard.vue')
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: 'agents',
|
|
||||||
name: 'AgentList',
|
|
||||||
component: () => import('../../views/agents/AgentList.vue')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'agents/:id',
|
|
||||||
name: 'AgentDetail',
|
|
||||||
component: () => import('../../views/agents/AgentDetail.vue')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'nodes',
|
|
||||||
name: 'NodeList',
|
|
||||||
component: () => import('../../views/nodes/NodeList.vue')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'nodes/:name',
|
|
||||||
name: 'NodeDetail',
|
|
||||||
component: () => import('../../views/nodes/NodeDetail.vue')
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: 'local-jobs',
|
path: 'local-jobs',
|
||||||
name: 'LocalJobList',
|
name: 'LocalJobList',
|
||||||
|
|||||||
@ -1,421 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="agent-detail-page">
|
|
||||||
<div class="page-header">
|
|
||||||
<n-space justify="space-between" align="center">
|
|
||||||
<div>
|
|
||||||
<h1 class="page-title">{{ isNew ? t('Create Agent') : (agent?.agent_name || t('Agent Detail')) }}</h1>
|
|
||||||
<p class="page-description">{{ isNew ? t('Create a new AI agent') : t('View and manage agent settings') }}</p>
|
|
||||||
</div>
|
|
||||||
<n-space>
|
|
||||||
<n-button v-if="!isNew && canExecute" type="success" :disabled="executing" @click="handleExecute">
|
|
||||||
<template #icon>
|
|
||||||
<n-icon><Icon icon="tabler:play" /></n-icon>
|
|
||||||
</template>
|
|
||||||
{{ executing ? t('Executing...') : t('Execute') }}
|
|
||||||
</n-button>
|
|
||||||
<n-button type="info" @click="handleFlowBuilder">
|
|
||||||
<template #icon>
|
|
||||||
<n-icon><Icon icon="tabler:workflow" /></n-icon>
|
|
||||||
</template>
|
|
||||||
{{ t('Flow Builder') }}
|
|
||||||
</n-button>
|
|
||||||
<n-button type="primary" :disabled="saving" @click="handleSave">
|
|
||||||
<template #icon>
|
|
||||||
<n-icon><Icon icon="tabler:edit" /></n-icon>
|
|
||||||
</template>
|
|
||||||
{{ t('Save') }}
|
|
||||||
</n-button>
|
|
||||||
</n-space>
|
|
||||||
</n-space>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 标签页内容 -->
|
|
||||||
<n-tabs v-model:value="activeTab" type="line" animated>
|
|
||||||
<!-- 详情标签页 -->
|
|
||||||
<n-tab-pane name="detail" :tab="t('Detail')">
|
|
||||||
<!-- 基本信息 -->
|
|
||||||
<n-card :title="t('Basic Information')">
|
|
||||||
<div class="properties-grid">
|
|
||||||
<div class="property-group">
|
|
||||||
<div class="property-item">
|
|
||||||
<label>{{ t('Name') }}</label>
|
|
||||||
<input type="text" v-model="agentForm.agent_name" />
|
|
||||||
</div>
|
|
||||||
<div class="property-item">
|
|
||||||
<label>{{ t('Status') }}</label>
|
|
||||||
<n-select v-model:value="agentForm.status" :options="statusOptions" clearable/>
|
|
||||||
</div>
|
|
||||||
<div class="property-item">
|
|
||||||
<label>{{ t('Enabled') }}</label>
|
|
||||||
<n-select v-model:value="agentForm.enabled" :options="enabledOptions" clearable/>
|
|
||||||
</div>
|
|
||||||
<div class="property-item">
|
|
||||||
<label>{{ t('Progress') }}</label>
|
|
||||||
<input type="number" v-model="agentForm.progress" min="0" max="100" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="property-group">
|
|
||||||
<div class="property-item">
|
|
||||||
<label>{{ t('Executions') }}</label>
|
|
||||||
<input type="number" v-model="agentForm.ai_repeat" min="0" />
|
|
||||||
</div>
|
|
||||||
<div class="property-item">
|
|
||||||
<label>{{ t('Trigger Mode') }}</label>
|
|
||||||
<n-select v-model:value="agentForm.trigger_mode" :options="triggerModeOptions" clearable/>
|
|
||||||
</div>
|
|
||||||
<div v-if="agentForm.trigger_mode === 'Scheduled Trigger'" class="property-item">
|
|
||||||
<label>{{ t('Trigger Time') }}</label>
|
|
||||||
<CronEditor v-model="agentForm.trigger_time" />
|
|
||||||
</div>
|
|
||||||
<div class="property-item">
|
|
||||||
<label>{{ t('Created At') }}</label>
|
|
||||||
<input type="text" v-model="agentForm.creation" readonly />
|
|
||||||
</div>
|
|
||||||
<div class="property-item">
|
|
||||||
<label>{{ t('Updated At') }}</label>
|
|
||||||
<input type="text" v-model="agentForm.modified" readonly />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</n-card>
|
|
||||||
</n-tab-pane>
|
|
||||||
|
|
||||||
<!-- 流程标签页 -->
|
|
||||||
<n-tab-pane name="flow" :tab="t('Flow')">
|
|
||||||
<n-card :title="t('Agent Flow')">
|
|
||||||
<div class="flow-content">
|
|
||||||
<textarea
|
|
||||||
v-model="agentForm.agent_flow"
|
|
||||||
class="flow-textarea"
|
|
||||||
rows="15"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</n-card>
|
|
||||||
</n-tab-pane>
|
|
||||||
|
|
||||||
<!-- 排除关键词标签页 -->
|
|
||||||
<n-tab-pane name="exclude" :tab="t('Exclude Keywords')">
|
|
||||||
<n-card :title="t('Exclude Keywords')">
|
|
||||||
<div class="exclude-content">
|
|
||||||
<textarea
|
|
||||||
v-model="agentForm.exclude_prompts"
|
|
||||||
class="exclude-textarea"
|
|
||||||
rows="8"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</n-card>
|
|
||||||
</n-tab-pane>
|
|
||||||
</n-tabs>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed, onMounted, watch } from 'vue'
|
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
|
||||||
import {
|
|
||||||
NSpace,
|
|
||||||
NButton,
|
|
||||||
NIcon,
|
|
||||||
NCard,
|
|
||||||
NSelect,
|
|
||||||
NTabs,
|
|
||||||
NTabPane,
|
|
||||||
useMessage
|
|
||||||
} from 'naive-ui'
|
|
||||||
import { Icon } from '@iconify/vue'
|
|
||||||
import { useFlowBuilderStore } from '../../shared/stores/flowBuilder'
|
|
||||||
import { useAgentStore } from '../../shared/stores/agent'
|
|
||||||
import { type AIAgent } from '../../shared/types/agent'
|
|
||||||
import { t } from '../../shared/i18n'
|
|
||||||
import CronEditor from '../../core/pagetype/form/controls/CronEditor.vue'
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const router = useRouter()
|
|
||||||
const message = useMessage()
|
|
||||||
const agentStore = useAgentStore()
|
|
||||||
const flowBuilderStore = useFlowBuilderStore()
|
|
||||||
|
|
||||||
// 响应式数据
|
|
||||||
const agent = ref<AIAgent | null>(null)
|
|
||||||
const loading = ref(false)
|
|
||||||
const saving = ref(false)
|
|
||||||
const executing = ref(false)
|
|
||||||
const activeTab = ref('detail')
|
|
||||||
|
|
||||||
// 表单数据
|
|
||||||
const agentForm = ref({
|
|
||||||
agent_name: '',
|
|
||||||
status: '草稿',
|
|
||||||
enabled: '1',
|
|
||||||
progress: 0,
|
|
||||||
ai_repeat: 0,
|
|
||||||
trigger_mode: '',
|
|
||||||
trigger_time: '* * * * *',
|
|
||||||
agent_flow: '',
|
|
||||||
exclude_prompts: '',
|
|
||||||
creation: '',
|
|
||||||
modified: ''
|
|
||||||
})
|
|
||||||
|
|
||||||
// 动态选项
|
|
||||||
const statusOptions = ref<{ label: string; value: string }[]>([])
|
|
||||||
const enabledOptions = ref<{ label: string; value: string }[]>([])
|
|
||||||
const triggerModeOptions = ref<{ label: string; value: string }[]>([])
|
|
||||||
|
|
||||||
// 加载选项
|
|
||||||
const loadOptions = async () => {
|
|
||||||
try {
|
|
||||||
const { getAgentStatusOptions, getAgentTriggerModeOptions } = await import('@/shared/api/agents')
|
|
||||||
const { getFieldSelectOptions } = await import('@/shared/api/common')
|
|
||||||
|
|
||||||
// 并行加载所有选项
|
|
||||||
const [statusData, enabledData, triggerData] = await Promise.all([
|
|
||||||
getAgentStatusOptions(),
|
|
||||||
getFieldSelectOptions('Local Ai Agent', 'enabled'),
|
|
||||||
getAgentTriggerModeOptions()
|
|
||||||
])
|
|
||||||
|
|
||||||
statusOptions.value = statusData.map(option => ({
|
|
||||||
label: t(option),
|
|
||||||
value: option
|
|
||||||
}))
|
|
||||||
|
|
||||||
enabledOptions.value = enabledData.map(option => ({
|
|
||||||
label: t(option),
|
|
||||||
value: option
|
|
||||||
}))
|
|
||||||
|
|
||||||
triggerModeOptions.value = triggerData.map(option => ({
|
|
||||||
label: t(option),
|
|
||||||
value: option
|
|
||||||
}))
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load options:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 计算属性
|
|
||||||
const agentId = computed(() => route.params.id as string)
|
|
||||||
const isNew = computed(() => agentId.value === 'new' || agentId.value === '')
|
|
||||||
|
|
||||||
// 是否可以执行(参考 Jingrow 逻辑:enabled 且 status 为待执行)
|
|
||||||
const canExecute = computed(() => {
|
|
||||||
if (!agent.value) return false
|
|
||||||
const enabled = agent.value.enabled
|
|
||||||
const status = agent.value.status
|
|
||||||
return enabled && (status === '待执行' || status === '草稿')
|
|
||||||
})
|
|
||||||
|
|
||||||
// 保存智能体
|
|
||||||
const handleSave = async () => {
|
|
||||||
saving.value = true
|
|
||||||
try {
|
|
||||||
if (isNew.value) {
|
|
||||||
// 创建新智能体
|
|
||||||
const { createAgent } = await import('@/shared/api/agents')
|
|
||||||
const result = await createAgent(agentForm.value)
|
|
||||||
if (result.success) {
|
|
||||||
message.success(t('Agent created successfully'))
|
|
||||||
// 跳转到新创建的智能体详情页
|
|
||||||
const created = result?.data ?? result
|
|
||||||
const newName = created?.name ?? created?.data?.name ?? created?.message?.name
|
|
||||||
if (newName) {
|
|
||||||
router.push({ name: 'AgentDetail', params: { id: newName } })
|
|
||||||
} else {
|
|
||||||
router.push({ name: 'AgentList' })
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
message.error(t('Create failed') + ': ' + (result.message || t('Unknown error')))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 更新现有智能体
|
|
||||||
const { updateAgent } = await import('@/shared/api/agents')
|
|
||||||
const result = await updateAgent(agentId.value, agentForm.value)
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
message.success(t('Agent updated successfully'))
|
|
||||||
|
|
||||||
await fetchAgent()
|
|
||||||
} else {
|
|
||||||
message.error(t('Update failed') + ': ' + (result.message || t('Unknown error')))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Save error:', error)
|
|
||||||
message.error(t('Save failed') + ': ' + (error.message || error))
|
|
||||||
} finally {
|
|
||||||
saving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleFlowBuilder = () => {
|
|
||||||
let flowData = agent.value?.agent_flow || {}
|
|
||||||
if (typeof flowData === 'string') {
|
|
||||||
try { flowData = JSON.parse(flowData) } catch (e) { flowData = {} }
|
|
||||||
}
|
|
||||||
const agentId = agent.value?.name || ''
|
|
||||||
flowBuilderStore.activateFlowBuilder(flowData, agentId)
|
|
||||||
router.push({ name: 'FlowBuilder', query: { agentId } })
|
|
||||||
}
|
|
||||||
|
|
||||||
// 执行智能体(参考 Jingrow 的异步执行逻辑)
|
|
||||||
const handleExecute = async () => {
|
|
||||||
if (!agentId.value || !agent.value) {
|
|
||||||
message.warning(t('Please select agent first'))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
executing.value = true
|
|
||||||
|
|
||||||
// 调用后端异步执行接口
|
|
||||||
const response = await fetch('/jingrow/agents/execute', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
agent_id: agentId.value,
|
|
||||||
agent_name: agent.value.agent_name,
|
|
||||||
session_cookie: document.cookie.split('; ').find(row => row.startsWith('sid='))?.split('=')[1]
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await response.json()
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
message.success(t('Agent execution started successfully'))
|
|
||||||
// 刷新智能体状态
|
|
||||||
await fetchAgent()
|
|
||||||
} else {
|
|
||||||
message.error(result.message || t('Execution failed'))
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
message.error(error?.message || t('Execution failed'))
|
|
||||||
} finally {
|
|
||||||
executing.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchAgent = async () => {
|
|
||||||
if (!agentId.value || isNew.value) return
|
|
||||||
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
await agentStore.fetchAgent(agentId.value)
|
|
||||||
agent.value = agentStore.currentAgent
|
|
||||||
|
|
||||||
// 更新表单数据
|
|
||||||
if (agent.value) {
|
|
||||||
agentForm.value = {
|
|
||||||
agent_name: agent.value.agent_name || '',
|
|
||||||
status: agent.value.status || '草稿',
|
|
||||||
enabled: String(agent.value.enabled ? 1 : 0),
|
|
||||||
progress: agent.value.progress || 0,
|
|
||||||
ai_repeat: agent.value.ai_repeat || 0,
|
|
||||||
trigger_mode: agent.value.trigger_mode || '',
|
|
||||||
trigger_time: agent.value.trigger_time || '* * * * *',
|
|
||||||
agent_flow: agent.value.agent_flow ? (typeof agent.value.agent_flow === 'string' ? agent.value.agent_flow : JSON.stringify(agent.value.agent_flow, null, 2)) : '',
|
|
||||||
exclude_prompts: agent.value.exclude_prompts || '',
|
|
||||||
creation: agent.value.creation || '',
|
|
||||||
modified: agent.value.modified || ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
message.error(t('Failed to load agent detail'))
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 生命周期
|
|
||||||
onMounted(() => {
|
|
||||||
loadOptions()
|
|
||||||
fetchAgent()
|
|
||||||
})
|
|
||||||
|
|
||||||
// 监听路由变化
|
|
||||||
watch(() => route.params.id, (newId, oldId) => {
|
|
||||||
if (newId && newId !== oldId) {
|
|
||||||
fetchAgent()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.agent-detail-page {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.page-header { margin-bottom: 24px; }
|
|
||||||
.page-title { font-size: 28px; font-weight: 700; color: #1f2937; margin: 0 0 8px 0; }
|
|
||||||
.page-description { font-size: 16px; color: #6b7280; margin: 0; }
|
|
||||||
.flow-content { background-color: #f8f9fa; border-radius: 6px; padding: 16px; border: 1px solid #e9ecef; }
|
|
||||||
.flow-json { margin: 0; font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; font-size: 12px; line-height: 1.5; color: #495057; white-space: pre-wrap; word-break: break-all; max-height: 500px; overflow-y: auto; }
|
|
||||||
.exclude-content { background-color: #f8f9fa; border-radius: 6px; padding: 16px; border: 1px solid #e9ecef; min-height: 100px; }
|
|
||||||
|
|
||||||
/* 表单样式 */
|
|
||||||
.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,
|
|
||||||
.property-item textarea {
|
|
||||||
padding: 8px 12px;
|
|
||||||
border: 1px solid #d1d5db;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 14px;
|
|
||||||
transition: border-color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.property-item input:focus,
|
|
||||||
.property-item textarea:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: #3b82f6;
|
|
||||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.property-item input[readonly] {
|
|
||||||
background-color: #f9fafb;
|
|
||||||
color: #6b7280;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flow-textarea,
|
|
||||||
.exclude-textarea {
|
|
||||||
width: 100%;
|
|
||||||
padding: 8px 12px;
|
|
||||||
border: 1px solid #d1d5db;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 14px;
|
|
||||||
transition: border-color 0.2s;
|
|
||||||
resize: vertical;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flow-textarea:focus,
|
|
||||||
.exclude-textarea:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: #3b82f6;
|
|
||||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
@ -1,933 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="page">
|
|
||||||
<div class="page-header">
|
|
||||||
<div class="header-left">
|
|
||||||
<h2>{{ t('AI Agents') }}</h2>
|
|
||||||
<p class="page-description">{{ t('Manage and configure your AI agents workflows') }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="header-right">
|
|
||||||
<div class="filters">
|
|
||||||
<n-input
|
|
||||||
v-model:value="searchQuery"
|
|
||||||
:placeholder="t('Search agents...')"
|
|
||||||
clearable
|
|
||||||
style="width: 200px"
|
|
||||||
/>
|
|
||||||
<n-select
|
|
||||||
v-model:value="statusFilter"
|
|
||||||
:options="statusOptions"
|
|
||||||
:placeholder="t('Status')"
|
|
||||||
style="width: 120px"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="view-toggle">
|
|
||||||
<button
|
|
||||||
class="toggle-btn"
|
|
||||||
:class="{ active: viewMode === 'list' }"
|
|
||||||
@click="viewMode = 'list'"
|
|
||||||
:title="t('List View')"
|
|
||||||
>
|
|
||||||
<i class="fa fa-list"></i>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="toggle-btn"
|
|
||||||
:class="{ active: viewMode === 'card' }"
|
|
||||||
@click="viewMode = 'card'"
|
|
||||||
:title="t('Card View')"
|
|
||||||
>
|
|
||||||
<i class="fa fa-th-large"></i>
|
|
||||||
</button>
|
|
||||||
</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="selectedAgents.length === 0"
|
|
||||||
class="create-btn"
|
|
||||||
@click="createAgent"
|
|
||||||
:disabled="creating || loading"
|
|
||||||
>
|
|
||||||
<i class="fa fa-plus"></i>
|
|
||||||
{{ t('Create Agent') }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-else
|
|
||||||
class="delete-btn"
|
|
||||||
@click="handleDeleteSelected"
|
|
||||||
:disabled="deleting || loading"
|
|
||||||
>
|
|
||||||
<i class="fa fa-trash"></i>
|
|
||||||
{{ t('Delete Selected') }} ({{ selectedAgents.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 v-if="viewMode === 'card'" class="agent-grid">
|
|
||||||
<div
|
|
||||||
v-for="agent in agents"
|
|
||||||
:key="agent.name"
|
|
||||||
class="agent-card"
|
|
||||||
:class="{ selected: selectedAgents.includes(agent.name) }"
|
|
||||||
@click="toggleAgentSelection(agent.name)"
|
|
||||||
>
|
|
||||||
<div class="card-checkbox">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
:checked="selectedAgents.includes(agent.name)"
|
|
||||||
@click.stop="toggleAgentSelection(agent.name)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="card-content" @click.stop="openDetail(agent.name)">
|
|
||||||
<div class="info">
|
|
||||||
<div class="title">{{ agent.agent_name || agent.name }}</div>
|
|
||||||
<div class="desc">{{ agent.name }}</div>
|
|
||||||
<div class="meta">
|
|
||||||
<span v-if="agent.enabled" class="enabled-badge enabled">{{ t('Enabled') }}</span>
|
|
||||||
<span v-else class="enabled-badge disabled">{{ t('Disabled') }}</span>
|
|
||||||
<span v-if="agent.trigger_mode" class="badge">{{ t(agent.trigger_mode) }}</span>
|
|
||||||
<span v-if="agent.status" class="status-badge" :class="agent.status">{{ t(agent.status) }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 卡片视图分页 -->
|
|
||||||
<div v-if="viewMode === 'card'" class="list-pagination">
|
|
||||||
<n-pagination v-model:page="page" :page-count="pageCount" size="small" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 列表视图 -->
|
|
||||||
<div v-else class="agent-list">
|
|
||||||
<div class="list-header">
|
|
||||||
<div class="col-checkbox">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
:checked="selectedAgents.length === agents.length && agents.length > 0"
|
|
||||||
:indeterminate="selectedAgents.length > 0 && selectedAgents.length < agents.length"
|
|
||||||
@change="toggleSelectAll"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="col-name">{{ t('Name') }}</div>
|
|
||||||
<div class="col-enabled">{{ t('Enabled') }}</div>
|
|
||||||
<div class="col-trigger">{{ t('Trigger Mode') }}</div>
|
|
||||||
<div class="col-status">{{ t('Status') }}</div>
|
|
||||||
<div class="col-actions">{{ t('Actions') }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="list-body">
|
|
||||||
<div
|
|
||||||
v-for="agent in agents"
|
|
||||||
:key="agent.name"
|
|
||||||
class="list-item"
|
|
||||||
:class="{ selected: selectedAgents.includes(agent.name) }"
|
|
||||||
@click="openDetail(agent.name)"
|
|
||||||
>
|
|
||||||
<div class="col-checkbox">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
:checked="selectedAgents.includes(agent.name)"
|
|
||||||
@click.stop="toggleAgentSelection(agent.name)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="col-name" @click.stop="openDetail(agent.name)">
|
|
||||||
<div class="name">{{ agent.agent_name || agent.name }}</div>
|
|
||||||
<div class="description">{{ agent.name }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-enabled" @click.stop="openDetail(agent.name)">
|
|
||||||
<span v-if="agent.enabled" class="enabled-badge enabled">{{ t('Enabled') }}</span>
|
|
||||||
<span v-else class="enabled-badge disabled">{{ t('Disabled') }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="col-trigger" @click.stop="openDetail(agent.name)">
|
|
||||||
<span class="badge">{{ t(agent.trigger_mode || '—') }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="col-status" @click.stop="openDetail(agent.name)">
|
|
||||||
<span class="status-badge" :class="agent.status">{{ t(agent.status) }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="col-actions">
|
|
||||||
<button class="action-btn" @click.stop="executeAgent(agent.name)" :title="t('Execute')">
|
|
||||||
<i class="fa fa-play"></i>
|
|
||||||
</button>
|
|
||||||
<button class="action-btn" @click.stop="editAgent(agent)" :title="t('Edit')">
|
|
||||||
<i class="fa fa-edit"></i>
|
|
||||||
</button>
|
|
||||||
<button class="action-btn" @click.stop="deleteAgent(agent.name)" :title="t('Delete')">
|
|
||||||
<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>
|
|
||||||
|
|
||||||
|
|
||||||
<n-modal v-model:show="showEditModal" preset="dialog" :title="t('Edit Agent')">
|
|
||||||
<n-form :model="editForm" :rules="editRules" ref="editFormRef">
|
|
||||||
<n-form-item :label="t('Name')" path="name">
|
|
||||||
<n-input
|
|
||||||
v-model:value="editForm.name"
|
|
||||||
:placeholder="t('Enter agent name')"
|
|
||||||
/>
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item :label="t('Description')" path="description">
|
|
||||||
<n-input
|
|
||||||
v-model:value="editForm.description"
|
|
||||||
type="textarea"
|
|
||||||
:placeholder="t('Enter agent description')"
|
|
||||||
:rows="3"
|
|
||||||
/>
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item :label="t('Status')" path="status">
|
|
||||||
<n-select
|
|
||||||
v-model:value="editForm.status"
|
|
||||||
:options="statusOptions"
|
|
||||||
:placeholder="t('Select status')"
|
|
||||||
/>
|
|
||||||
</n-form-item>
|
|
||||||
</n-form>
|
|
||||||
<template #action>
|
|
||||||
<n-button @click="showEditModal = false">{{ t('Cancel') }}</n-button>
|
|
||||||
<n-button type="primary" @click="handleUpdate">{{ t('Update') }}</n-button>
|
|
||||||
</template>
|
|
||||||
</n-modal>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, reactive, computed, onMounted, watch } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { t } from '../../shared/i18n'
|
|
||||||
import { NInput, NSelect, NPagination, NModal, NForm, NFormItem, NButton, useMessage, useDialog } from 'naive-ui'
|
|
||||||
import { useAgentStore } from '../../shared/stores/agent'
|
|
||||||
import { type AIAgent } from '../../shared/types/agent'
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const message = useMessage()
|
|
||||||
const dialog = useDialog()
|
|
||||||
const agentStore = useAgentStore()
|
|
||||||
|
|
||||||
// 响应式数据
|
|
||||||
const loading = ref(false)
|
|
||||||
const creating = ref(false)
|
|
||||||
const deleting = ref(false)
|
|
||||||
const agents = ref<AIAgent[]>([])
|
|
||||||
const allAgents = ref<AIAgent[]>([]) // 保存所有原始数据
|
|
||||||
const total = ref(0)
|
|
||||||
const selectedAgents = ref<string[]>([])
|
|
||||||
const viewMode = ref<'card' | 'list'>(
|
|
||||||
(localStorage.getItem('agentListViewMode') as 'card' | 'list') || 'list'
|
|
||||||
)
|
|
||||||
const searchQuery = ref('')
|
|
||||||
const statusFilter = ref('all')
|
|
||||||
const page = ref(1)
|
|
||||||
const pageSize = ref(parseInt(localStorage.getItem('itemsPerPage') || '10'))
|
|
||||||
const showEditModal = ref(false)
|
|
||||||
const currentAgent = ref<AIAgent | null>(null)
|
|
||||||
|
|
||||||
// 表单数据
|
|
||||||
const editForm = reactive({
|
|
||||||
name: '',
|
|
||||||
description: '',
|
|
||||||
status: 'draft'
|
|
||||||
})
|
|
||||||
|
|
||||||
// 表单验证规则
|
|
||||||
|
|
||||||
const editRules = {
|
|
||||||
name: [{ required: true, message: t('Please enter agent name'), trigger: 'blur' }],
|
|
||||||
description: [{ required: true, message: t('Please enter agent description'), trigger: 'blur' }]
|
|
||||||
}
|
|
||||||
|
|
||||||
// 状态选项
|
|
||||||
const statusOptions = computed(() => [
|
|
||||||
{ label: t('All'), value: 'all' },
|
|
||||||
{ label: t('Draft'), value: 'Draft' },
|
|
||||||
{ label: t('Active'), value: 'Active' },
|
|
||||||
{ label: t('Pending'), value: 'Pending' },
|
|
||||||
{ label: t('Failed'), value: 'Failed' },
|
|
||||||
{ label: t('Completed'), value: 'Completed' }
|
|
||||||
])
|
|
||||||
|
|
||||||
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
|
|
||||||
|
|
||||||
// 获取智能体列表
|
|
||||||
async function fetchAgents() {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
page: page.value.toString(),
|
|
||||||
page_size: pageSize.value.toString()
|
|
||||||
})
|
|
||||||
|
|
||||||
const res = await fetch(`/api/action/jingrow.ai.utils.jlocal.get_local_ai_agents_list?${params}`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Accept': 'application/json'
|
|
||||||
},
|
|
||||||
credentials: 'include',
|
|
||||||
body: JSON.stringify({
|
|
||||||
page: page.value,
|
|
||||||
page_size: pageSize.value,
|
|
||||||
fields: ['name', 'agent_name', 'status', 'enabled', 'trigger_mode', 'progress', 'ai_repeat', 'creation', 'modified', 'modified_by', 'description'],
|
|
||||||
order_by: 'modified desc'
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const data = await res.json()
|
|
||||||
|
|
||||||
const result = data.message
|
|
||||||
allAgents.value = (result.items || []).map((a: any) => ({
|
|
||||||
name: a.name,
|
|
||||||
agent_name: a.agent_name,
|
|
||||||
status: a.status,
|
|
||||||
enabled: a.enabled,
|
|
||||||
trigger_mode: a.trigger_mode,
|
|
||||||
progress: a.progress,
|
|
||||||
ai_repeat: a.ai_repeat,
|
|
||||||
creation: a.creation,
|
|
||||||
modified: a.modified,
|
|
||||||
modified_by: a.modified_by
|
|
||||||
}))
|
|
||||||
|
|
||||||
// 处理数据(搜索和过滤)
|
|
||||||
processAgents()
|
|
||||||
total.value = result.total || 0
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取智能体列表失败:', error)
|
|
||||||
agents.value = []
|
|
||||||
allAgents.value = []
|
|
||||||
total.value = 0
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function processAgents() {
|
|
||||||
let filteredAgents = [...allAgents.value]
|
|
||||||
|
|
||||||
// 前端搜索和过滤
|
|
||||||
if (searchQuery.value) {
|
|
||||||
const query = searchQuery.value.toLowerCase()
|
|
||||||
filteredAgents = filteredAgents.filter(agent =>
|
|
||||||
agent.agent_name?.toLowerCase().includes(query) ||
|
|
||||||
agent.name?.toLowerCase().includes(query)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (statusFilter.value !== 'all') {
|
|
||||||
filteredAgents = filteredAgents.filter(agent => agent.status === statusFilter.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
agents.value = filteredAgents
|
|
||||||
}
|
|
||||||
|
|
||||||
function reload() {
|
|
||||||
fetchAgents()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 选择相关函数
|
|
||||||
function toggleAgentSelection(agentName: string) {
|
|
||||||
const index = selectedAgents.value.indexOf(agentName)
|
|
||||||
if (index > -1) {
|
|
||||||
selectedAgents.value.splice(index, 1)
|
|
||||||
} else {
|
|
||||||
selectedAgents.value.push(agentName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleSelectAll() {
|
|
||||||
if (selectedAgents.value.length === agents.value.length) {
|
|
||||||
selectedAgents.value = []
|
|
||||||
} else {
|
|
||||||
selectedAgents.value = agents.value.map(agent => agent.name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDeleteSelected() {
|
|
||||||
if (selectedAgents.value.length === 0) return
|
|
||||||
|
|
||||||
dialog.error({
|
|
||||||
title: t('Confirm Delete'),
|
|
||||||
content: t('Are you sure you want to delete the selected agents? This action cannot be undone.'),
|
|
||||||
positiveText: t('Delete'),
|
|
||||||
negativeText: t('Cancel'),
|
|
||||||
onPositiveClick: async () => {
|
|
||||||
deleting.value = true
|
|
||||||
try {
|
|
||||||
const { deleteAgents } = await import('@/shared/api/agents')
|
|
||||||
const result = await deleteAgents(selectedAgents.value)
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
// 删除成功,重新加载数据
|
|
||||||
selectedAgents.value = []
|
|
||||||
await fetchAgents()
|
|
||||||
} else {
|
|
||||||
alert(t('Delete failed') + ': ' + (result.message || t('Unknown error')))
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Delete error:', error)
|
|
||||||
alert(t('Delete failed') + ': ' + t('Network error'))
|
|
||||||
} finally {
|
|
||||||
deleting.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 监听搜索和筛选变化
|
|
||||||
watch([searchQuery, statusFilter], () => {
|
|
||||||
page.value = 1 // 重置到第一页
|
|
||||||
// 重新处理数据,不需要重新请求API
|
|
||||||
processAgents()
|
|
||||||
}, { deep: true })
|
|
||||||
|
|
||||||
// 监听分页变化
|
|
||||||
watch([page], () => {
|
|
||||||
fetchAgents()
|
|
||||||
})
|
|
||||||
|
|
||||||
// 监听每页数量变化(从系统设置)
|
|
||||||
watch(() => localStorage.getItem('itemsPerPage'), (newValue) => {
|
|
||||||
if (newValue) {
|
|
||||||
pageSize.value = parseInt(newValue)
|
|
||||||
page.value = 1 // 重置到第一页
|
|
||||||
fetchAgents()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(viewMode, (m) => {
|
|
||||||
localStorage.setItem('agentListViewMode', m)
|
|
||||||
})
|
|
||||||
|
|
||||||
function openDetail(name: string) {
|
|
||||||
router.push({ name: 'AgentDetail', params: { id: name } })
|
|
||||||
}
|
|
||||||
|
|
||||||
function createAgent() {
|
|
||||||
// 仅跳转到新建模式,由详情页保存时再创建后端记录
|
|
||||||
router.push({ name: 'AgentDetail', params: { id: 'new' } })
|
|
||||||
}
|
|
||||||
|
|
||||||
function editAgent(agent: AIAgent) {
|
|
||||||
currentAgent.value = agent
|
|
||||||
editForm.name = agent.name
|
|
||||||
editForm.description = ''
|
|
||||||
editForm.status = agent.status || 'draft'
|
|
||||||
showEditModal.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
async function executeAgent(name: string) {
|
|
||||||
try {
|
|
||||||
const result = await agentStore.executeAgent(name)
|
|
||||||
if (result.success) {
|
|
||||||
message.success(t('Agent executed successfully'))
|
|
||||||
fetchAgents()
|
|
||||||
} else {
|
|
||||||
message.error(result.error || t('Execution failed'))
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
message.error(t('Execution failed'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteAgent(_name: string) {
|
|
||||||
try {
|
|
||||||
// 这里需要实现删除API
|
|
||||||
message.success(t('Agent deleted successfully'))
|
|
||||||
fetchAgents()
|
|
||||||
} catch (error) {
|
|
||||||
message.error(t('Delete failed'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async function handleUpdate() {
|
|
||||||
try {
|
|
||||||
// 这里需要实现更新API
|
|
||||||
message.success(t('Agent updated successfully'))
|
|
||||||
showEditModal.value = false
|
|
||||||
fetchAgents()
|
|
||||||
} catch (error) {
|
|
||||||
message.error(t('Update failed'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
fetchAgents()
|
|
||||||
})
|
|
||||||
</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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.view-toggle {
|
|
||||||
display: flex;
|
|
||||||
background: #f8fafc;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 2px;
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-btn {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
color: #64748b;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-radius: 6px;
|
|
||||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-btn:hover {
|
|
||||||
background: #e2e8f0;
|
|
||||||
color: #475569;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-btn.active {
|
|
||||||
background: #2563eb;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 现代化刷新按钮 */
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 新建按钮 */
|
|
||||||
.create-btn {
|
|
||||||
height: 36px;
|
|
||||||
padding: 0 16px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #2563eb;
|
|
||||||
color: white;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.create-btn:hover {
|
|
||||||
background: #1d4ed8;
|
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.create-btn:active {
|
|
||||||
transform: translateY(0);
|
|
||||||
box-shadow: 0 2px 6px rgba(37, 99, 235, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.create-btn i {
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 60px 20px;
|
|
||||||
color: #6b7280;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading i {
|
|
||||||
margin-right: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 卡片视图 */
|
|
||||||
.agent-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
|
||||||
gap: 20px;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.agent-card {
|
|
||||||
background: white;
|
|
||||||
border: 1px solid #e5e7eb;
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 20px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.agent-card:hover {
|
|
||||||
border-color: #2563eb;
|
|
||||||
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.1);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.agent-card .title {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #111827;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.agent-card .desc {
|
|
||||||
font-size: 14px;
|
|
||||||
color: #6b7280;
|
|
||||||
line-height: 1.5;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 2;
|
|
||||||
line-clamp: 2;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.agent-card .meta {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge {
|
|
||||||
background: #f3f4f6;
|
|
||||||
color: #374151;
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge {
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge.Draft {
|
|
||||||
background: #fef3c7;
|
|
||||||
color: #92400e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge.Active {
|
|
||||||
background: #d1fae5;
|
|
||||||
color: #065f46;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge.Pending {
|
|
||||||
background: #dbeafe;
|
|
||||||
color: #1e40af;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge.Failed {
|
|
||||||
background: #fee2e2;
|
|
||||||
color: #dc2626;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge.Completed {
|
|
||||||
background: #d1fae5;
|
|
||||||
color: #065f46;
|
|
||||||
}
|
|
||||||
|
|
||||||
.enabled-badge {
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.enabled-badge.enabled {
|
|
||||||
background: #d1fae5;
|
|
||||||
color: #065f46;
|
|
||||||
}
|
|
||||||
|
|
||||||
.enabled-badge.disabled {
|
|
||||||
background: #fee2e2;
|
|
||||||
color: #dc2626;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 列表视图 */
|
|
||||||
.agent-list {
|
|
||||||
background: white;
|
|
||||||
border: 1px solid #e5e7eb;
|
|
||||||
border-radius: 12px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-header {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 40px 1fr 100px 120px 100px 120px;
|
|
||||||
gap: 16px;
|
|
||||||
padding: 16px 20px;
|
|
||||||
background: #f9fafb;
|
|
||||||
border-bottom: 1px solid #e5e7eb;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #374151;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-body {
|
|
||||||
max-height: 600px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-item {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 40px 1fr 100px 120px 100px 120px;
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-item:hover {
|
|
||||||
background: #f9fafb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-item:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.col-enabled {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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-trigger {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
color: #6b7280;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.col-status {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-pagination {
|
|
||||||
padding: 16px 20px;
|
|
||||||
border-top: 1px solid #e5e7eb;
|
|
||||||
background: #f9fafb;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 选择相关样式 */
|
|
||||||
.col-checkbox {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-checkbox {
|
|
||||||
position: absolute;
|
|
||||||
top: 12px;
|
|
||||||
right: 12px;
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-checkbox input[type="checkbox"] {
|
|
||||||
cursor: pointer;
|
|
||||||
accent-color: #3b82f6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.agent-card {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.agent-card.selected {
|
|
||||||
border-color: #3b82f6;
|
|
||||||
background: #f0f9ff;
|
|
||||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-item.selected {
|
|
||||||
background: #f0f9ff;
|
|
||||||
border-color: #3b82f6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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);
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.delete-btn:hover {
|
|
||||||
background: #dc2626;
|
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.delete-btn:active {
|
|
||||||
transform: translateY(0);
|
|
||||||
box-shadow: 0 2px 6px rgba(239, 68, 68, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.delete-btn:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.delete-btn i {
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@ -1,721 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="page">
|
|
||||||
<!-- 二级操作栏 -->
|
|
||||||
<div class="secondary-bar">
|
|
||||||
<div class="left-actions">
|
|
||||||
<button class="btn btn-outline" @click="openSchemaEditor">
|
|
||||||
<i class="fa fa-edit"></i> {{ t('Edit Schema') }}
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-primary" :disabled="saving" @click="save">
|
|
||||||
<i :class="saving ? 'fa fa-spinner fa-spin' : 'fa fa-save'"></i> {{ t('Save') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</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 class="content-grid">
|
|
||||||
<!-- 节点元数据卡片 -->
|
|
||||||
<div class="properties-card">
|
|
||||||
<h4>{{ t('Node Metadata') }}</h4>
|
|
||||||
<div class="properties-grid">
|
|
||||||
<div class="property-group">
|
|
||||||
<div class="property-item">
|
|
||||||
<label>{{ t('Node Type') }}</label>
|
|
||||||
<input type="text" v-model="nodeRecord.node_type" />
|
|
||||||
</div>
|
|
||||||
<div class="property-item">
|
|
||||||
<label>{{ t('Node Label') }}</label>
|
|
||||||
<input type="text" v-model="nodeRecord.node_label" />
|
|
||||||
</div>
|
|
||||||
<div class="property-item">
|
|
||||||
<label>{{ t('Node Icon') }}</label>
|
|
||||||
<input type="text" v-model="nodeRecord.node_icon" />
|
|
||||||
</div>
|
|
||||||
<div class="property-item">
|
|
||||||
<label>{{ t('Node Color') }}</label>
|
|
||||||
<div class="color-picker">
|
|
||||||
<input type="color" v-model="nodeRecord.node_color" />
|
|
||||||
<input type="text" v-model="nodeRecord.node_color" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="property-group">
|
|
||||||
<div class="property-item">
|
|
||||||
<label>{{ t('Status') }}</label>
|
|
||||||
<n-select v-model:value="nodeRecord.status" :options="statusOptions" clearable/>
|
|
||||||
</div>
|
|
||||||
<div class="property-item">
|
|
||||||
<label>{{ t('Node Group') }}</label>
|
|
||||||
<n-select v-model:value="nodeRecord.node_group" :options="groupOptions" filterable clearable/>
|
|
||||||
</div>
|
|
||||||
<div class="property-item">
|
|
||||||
<label>{{ t('Node Component') }}</label>
|
|
||||||
<n-select v-model:value="nodeRecord.node_component" :options="componentOptions" filterable clearable/>
|
|
||||||
</div>
|
|
||||||
<div class="property-item full-width">
|
|
||||||
<label>{{ t('Node Description') }}</label>
|
|
||||||
<textarea v-model="nodeRecord.node_description"></textarea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Schema编辑器卡片 -->
|
|
||||||
<div class="editor-card">
|
|
||||||
<div class="editor-header">
|
|
||||||
<h4>{{ t('Node Schema') }}</h4>
|
|
||||||
</div>
|
|
||||||
<div class="editor-container">
|
|
||||||
<textarea v-model="schemaText" spellcheck="false" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Schema 编辑器模态框 -->
|
|
||||||
<SchemaEditorModal
|
|
||||||
v-model:visible="showSchemaEditor"
|
|
||||||
:node-type="nodeRecord.node_type || ''"
|
|
||||||
:node-name="nodeName"
|
|
||||||
:initial-schema="schema"
|
|
||||||
:on-save="handleSchemaSave"
|
|
||||||
@close="showSchemaEditor = false"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed, onMounted, watch } from 'vue'
|
|
||||||
import { useRoute } from 'vue-router'
|
|
||||||
import $ from 'jquery'
|
|
||||||
import { useMessage, NSelect } from 'naive-ui'
|
|
||||||
import { t } from '@/shared/i18n'
|
|
||||||
import { checkNodeTypeExists, createNode, getNodeGroups, getNodeComponents, getNodeStatusOptions, getNodeRecord, updateNode, exportNodeDefinition } from '@/shared/api/nodes'
|
|
||||||
import SchemaEditorModal from '@/core/components/SchemaEditorModal.vue'
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface Window { $: typeof $; jQuery: typeof $; jingrow: any }
|
|
||||||
}
|
|
||||||
window.$ = window.jQuery = $
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const message = useMessage()
|
|
||||||
|
|
||||||
const nodeName = computed(() => String(route.params.name || ''))
|
|
||||||
const isNew = computed(() => nodeName.value === 'new' || nodeName.value === '')
|
|
||||||
const loading = ref(true)
|
|
||||||
const saving = ref(false)
|
|
||||||
const showSchemaEditor = ref(false)
|
|
||||||
const nodeRecord = ref<any>({})
|
|
||||||
const schema = ref<any>({})
|
|
||||||
const schemaText = ref('')
|
|
||||||
const groupOptions = ref<{ label: string; value: string }[]>([])
|
|
||||||
const componentOptions = ref<{ label: string; value: string }[]>([])
|
|
||||||
const statusOptions = ref<{ label: string; value: string }[]>([])
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
if (isNew.value) {
|
|
||||||
// 新建模式:初始化空对象,不请求后端
|
|
||||||
nodeRecord.value = { node_type: '', node_label: '', node_icon: '', node_color: '#6b7280', node_group: '', node_component: '', node_description: '', status: 'Draft' }
|
|
||||||
schema.value = {}
|
|
||||||
schemaText.value = JSON.stringify(schema.value, null, 2)
|
|
||||||
await loadSelectOptions()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// 编辑模式:获取节点记录(包含所有字段)
|
|
||||||
const recordData = await getNodeRecord(nodeName.value)
|
|
||||||
nodeRecord.value = recordData || {}
|
|
||||||
|
|
||||||
// 从 node_schema 字段获取 schema
|
|
||||||
schema.value = nodeRecord.value.node_schema || {}
|
|
||||||
|
|
||||||
schemaText.value = JSON.stringify(schema.value, null, 2)
|
|
||||||
await loadSelectOptions()
|
|
||||||
} catch (e) {
|
|
||||||
message.error(t('Load failed') + ': ' + (e instanceof Error ? e.message : String(e)))
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadSelectOptions() {
|
|
||||||
try {
|
|
||||||
const [groups, components, statuses] = await Promise.all([
|
|
||||||
getNodeGroups(),
|
|
||||||
getNodeComponents(),
|
|
||||||
getNodeStatusOptions()
|
|
||||||
])
|
|
||||||
groupOptions.value = groups.map((g: string) => ({ label: t(g), value: g }))
|
|
||||||
componentOptions.value = components.map((c: string) => ({ label: t(c), value: c }))
|
|
||||||
statusOptions.value = statuses.map((s: string) => ({ label: t(s), value: s }))
|
|
||||||
} catch (_) {
|
|
||||||
groupOptions.value = []
|
|
||||||
componentOptions.value = []
|
|
||||||
statusOptions.value = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function save() {
|
|
||||||
let schemaBody: any
|
|
||||||
try {
|
|
||||||
schemaBody = JSON.parse(schemaText.value)
|
|
||||||
} catch (e) {
|
|
||||||
message.error(t('Invalid JSON, please fix before saving'))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
saving.value = true
|
|
||||||
try {
|
|
||||||
if (isNew.value) {
|
|
||||||
if (!nodeRecord.value.node_type) {
|
|
||||||
message.warning(t('Please enter Node Type'))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const exists = await checkNodeTypeExists(nodeRecord.value.node_type)
|
|
||||||
if (exists) {
|
|
||||||
message.error(t('Node Type already exists'))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await createNode({
|
|
||||||
node_type: nodeRecord.value.node_type,
|
|
||||||
node_label: nodeRecord.value.node_label,
|
|
||||||
node_icon: nodeRecord.value.node_icon,
|
|
||||||
node_color: nodeRecord.value.node_color,
|
|
||||||
node_group: nodeRecord.value.node_group,
|
|
||||||
node_component: nodeRecord.value.node_component,
|
|
||||||
node_description: nodeRecord.value.node_description,
|
|
||||||
status: nodeRecord.value.status || 'Draft',
|
|
||||||
node_schema: schemaBody
|
|
||||||
})
|
|
||||||
const created = result?.data ?? result
|
|
||||||
const newName = created?.name
|
|
||||||
?? created?.data?.name
|
|
||||||
?? created?.message?.name
|
|
||||||
?? created?.document?.name
|
|
||||||
if (!result?.success) {
|
|
||||||
const msg = result?.message || 'create failed'
|
|
||||||
if (/Duplicate|exists|unique/i.test(String(msg))) {
|
|
||||||
message.error(t('Node Type already exists'))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (result?.success && newName) {
|
|
||||||
message.success(t('Created successfully'))
|
|
||||||
// 写入服务器 JSON
|
|
||||||
try {
|
|
||||||
await exportNodeDefinition({
|
|
||||||
metadata: {
|
|
||||||
type: nodeRecord.value.node_type,
|
|
||||||
label: nodeRecord.value.node_label,
|
|
||||||
icon: nodeRecord.value.node_icon,
|
|
||||||
color: nodeRecord.value.node_color,
|
|
||||||
description: nodeRecord.value.node_description,
|
|
||||||
group: nodeRecord.value.node_group,
|
|
||||||
component_type: nodeRecord.value.node_component || 'GenericNode'
|
|
||||||
},
|
|
||||||
schema: schemaBody
|
|
||||||
})
|
|
||||||
} catch (_) {}
|
|
||||||
// 延迟跳转到新节点的详情页
|
|
||||||
setTimeout(() => {
|
|
||||||
window.location.href = `/nodes/${encodeURIComponent(newName)}`
|
|
||||||
}, 1500)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
throw new Error('create failed')
|
|
||||||
}
|
|
||||||
// 编辑模式:更新
|
|
||||||
const nodeData = { ...nodeRecord.value, node_schema: schemaBody }
|
|
||||||
await updateNode(nodeName.value, nodeData)
|
|
||||||
// 写入服务器 JSON
|
|
||||||
try {
|
|
||||||
await exportNodeDefinition({
|
|
||||||
metadata: {
|
|
||||||
type: nodeRecord.value.node_type,
|
|
||||||
label: nodeRecord.value.node_label,
|
|
||||||
icon: nodeRecord.value.node_icon,
|
|
||||||
color: nodeRecord.value.node_color,
|
|
||||||
description: nodeRecord.value.node_description,
|
|
||||||
group: nodeRecord.value.node_group,
|
|
||||||
component_type: nodeRecord.value.node_component || 'GenericNode'
|
|
||||||
},
|
|
||||||
schema: schemaBody
|
|
||||||
})
|
|
||||||
} catch (_) {}
|
|
||||||
message.success(t('Saved successfully'))
|
|
||||||
await load()
|
|
||||||
} catch (e) {
|
|
||||||
message.error(t('Save failed, please check permission and server logs'))
|
|
||||||
} finally {
|
|
||||||
saving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openSchemaEditor() {
|
|
||||||
if (!nodeRecord.value.node_type) {
|
|
||||||
message.warning(t('Please select node type first'))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
showSchemaEditor.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSchemaSave(schemaData: any) {
|
|
||||||
try {
|
|
||||||
// 准备完整的节点数据
|
|
||||||
const nodeData = {
|
|
||||||
...nodeRecord.value,
|
|
||||||
node_schema: schemaData
|
|
||||||
}
|
|
||||||
|
|
||||||
// 调用更新API
|
|
||||||
await updateNode(nodeName.value, nodeData)
|
|
||||||
|
|
||||||
// 更新本地数据
|
|
||||||
schema.value = schemaData
|
|
||||||
schemaText.value = JSON.stringify(schemaData, null, 2)
|
|
||||||
|
|
||||||
// 重新加载数据以确保同步
|
|
||||||
await load()
|
|
||||||
|
|
||||||
} catch (e) {
|
|
||||||
throw e // 让组件处理错误显示
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
watch(() => route.params.name, load)
|
|
||||||
onMounted(load)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.page {
|
|
||||||
min-height: 100vh;
|
|
||||||
background: #f8fafc;
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 顶部导航栏 */
|
|
||||||
.top-bar {
|
|
||||||
background: #fff;
|
|
||||||
border-bottom: 1px solid #e5e7eb;
|
|
||||||
padding: 12px 24px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
.breadcrumb {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
color: #64748b;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.breadcrumb i {
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.top-actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-box {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-box i {
|
|
||||||
position: absolute;
|
|
||||||
left: 12px;
|
|
||||||
color: #94a3b8;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-box input {
|
|
||||||
padding: 8px 12px 8px 36px;
|
|
||||||
border: 1px solid #e5e7eb;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #f8fafc;
|
|
||||||
font-size: 14px;
|
|
||||||
width: 200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-box input:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: #3b82f6;
|
|
||||||
background: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-btn {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
padding: 8px;
|
|
||||||
border-radius: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
color: #64748b;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-btn:hover {
|
|
||||||
background: #f1f5f9;
|
|
||||||
color: #334155;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-avatar {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: #3b82f6;
|
|
||||||
color: white;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-avatar.small {
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 二级操作栏 */
|
|
||||||
.secondary-bar {
|
|
||||||
background: #fff;
|
|
||||||
border-bottom: 1px solid #e5e7eb;
|
|
||||||
padding: 8px 16px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.left-actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 主布局 */
|
|
||||||
.main-layout { display: block; min-height: calc(100vh - 60px); }
|
|
||||||
|
|
||||||
/* Schema编辑器对话框样式 */
|
|
||||||
.schema-editor-dialog {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
z-index: 20;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dialog-overlay {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.5);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dialog-content {
|
|
||||||
background: white;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
|
|
||||||
width: 90%;
|
|
||||||
max-width: 1200px;
|
|
||||||
max-height: 90vh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dialog-header {
|
|
||||||
padding: 20px;
|
|
||||||
border-bottom: 1px solid #e5e7eb;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dialog-header h3 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.close-btn {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
font-size: 18px;
|
|
||||||
cursor: pointer;
|
|
||||||
color: #6b7280;
|
|
||||||
padding: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.close-btn:hover {
|
|
||||||
color: #374151;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dialog-body {
|
|
||||||
flex: 1;
|
|
||||||
padding: 20px;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dialog-footer {
|
|
||||||
padding: 20px;
|
|
||||||
border-top: 1px solid #e5e7eb;
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
#schema-builder-container {
|
|
||||||
min-height: 400px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 主内容区域 */
|
|
||||||
.main-content { flex: 1; padding: 24px; overflow-y: auto; max-width: none; }
|
|
||||||
|
|
||||||
.loading {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 48px;
|
|
||||||
color: #64748b;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content-grid { display: flex; flex-direction: column; gap: 24px; width: 100%; max-width: none; }
|
|
||||||
|
|
||||||
/* 卡片样式 */
|
|
||||||
.properties-card,
|
|
||||||
.editor-card {
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid #e5e7eb;
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 24px;
|
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.properties-card h4,
|
|
||||||
.editor-card h4 {
|
|
||||||
margin: 0 0 20px 0;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #1e293b;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 属性网格 */
|
|
||||||
.properties-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 24px; }
|
|
||||||
|
|
||||||
.property-group {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.property-item {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.property-item.full-width {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.property-item label {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #374151;
|
|
||||||
}
|
|
||||||
|
|
||||||
.property-item input,
|
|
||||||
.property-item textarea {
|
|
||||||
padding: 10px 12px;
|
|
||||||
border: 1px solid #d1d5db;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 14px;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.property-item input:focus,
|
|
||||||
.property-item textarea:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: #3b82f6;
|
|
||||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.property-item input[readonly] {
|
|
||||||
background: #f9fafb;
|
|
||||||
color: #6b7280;
|
|
||||||
}
|
|
||||||
|
|
||||||
.color-picker {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.color-picker input[type="color"] {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 8px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.color-picker input[type="text"] {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 编辑器 */
|
|
||||||
.editor-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-container textarea {
|
|
||||||
width: 100%;
|
|
||||||
height: 560px;
|
|
||||||
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.6;
|
|
||||||
padding: 16px;
|
|
||||||
border: 1px solid #d1d5db;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #f9fafb;
|
|
||||||
color: #111827;
|
|
||||||
resize: vertical;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-container textarea:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: #3b82f6;
|
|
||||||
background: #fff;
|
|
||||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-container textarea::-webkit-scrollbar-thumb {
|
|
||||||
background: #cbd5e1;
|
|
||||||
border-radius: 4px;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
/* 评论区域 */
|
|
||||||
.comment-input {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comment-input input {
|
|
||||||
flex: 1;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border: 1px solid #d1d5db;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comment-input input:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: #3b82f6;
|
|
||||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 按钮样式 */
|
|
||||||
.btn {
|
|
||||||
padding: 6px 12px;
|
|
||||||
border: 1px solid #d1d5db;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: #fff;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
transition: all 0.2s;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:hover {
|
|
||||||
background: #f9fafb;
|
|
||||||
border-color: #9ca3af;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-outline {
|
|
||||||
background: #fff;
|
|
||||||
color: #374151;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background: #1e293b;
|
|
||||||
color: #fff;
|
|
||||||
border-color: #1e293b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover {
|
|
||||||
background: #334155;
|
|
||||||
border-color: #334155;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-sm {
|
|
||||||
padding: 6px 12px;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn[disabled] {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn[disabled]:hover {
|
|
||||||
background: #fff;
|
|
||||||
border-color: #d1d5db;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 响应式设计 */
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
.properties-grid { grid-template-columns: 1fr; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.main-layout { flex-direction: column; }
|
|
||||||
|
|
||||||
.top-bar,
|
|
||||||
.secondary-bar {
|
|
||||||
padding: 12px 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-content {
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
|
|
||||||
@ -1,902 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="page">
|
|
||||||
<div class="page-header">
|
|
||||||
<div class="header-left">
|
|
||||||
<h2>{{ t('Node Management') }}</h2>
|
|
||||||
<p class="page-description">{{ t('Manage and configure your AI node components') }}</p>
|
|
||||||
</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>
|
|
||||||
<div class="view-toggle">
|
|
||||||
<button
|
|
||||||
class="toggle-btn"
|
|
||||||
:class="{ active: viewMode === 'list' }"
|
|
||||||
@click="viewMode = 'list'"
|
|
||||||
:title="t('List View')"
|
|
||||||
>
|
|
||||||
<i class="fa fa-list"></i>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="toggle-btn"
|
|
||||||
:class="{ active: viewMode === 'card' }"
|
|
||||||
@click="viewMode = 'card'"
|
|
||||||
:title="t('Card View')"
|
|
||||||
>
|
|
||||||
<i class="fa fa-th-large"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<button class="refresh-btn" @click="reload" :disabled="loading">
|
|
||||||
<i :class="loading ? 'fa fa-spinner fa-spin' : 'fa fa-refresh'"></i>
|
|
||||||
</button>
|
|
||||||
<n-dropdown
|
|
||||||
trigger="click"
|
|
||||||
:options="menuOptions"
|
|
||||||
@select="onMenuSelect"
|
|
||||||
>
|
|
||||||
<button class="refresh-btn" :disabled="loading || importing" :title="t('More')">
|
|
||||||
<i :class="importing ? 'fa fa-ellipsis-h fa-spin' : 'fa fa-ellipsis-h'"></i>
|
|
||||||
</button>
|
|
||||||
</n-dropdown>
|
|
||||||
<button
|
|
||||||
v-if="selectedNodes.length === 0"
|
|
||||||
class="create-btn"
|
|
||||||
@click="handleCreateNode"
|
|
||||||
:disabled="creating || loading"
|
|
||||||
>
|
|
||||||
<i class="fa fa-plus"></i>
|
|
||||||
{{ t('Create Node') }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-else
|
|
||||||
class="delete-btn"
|
|
||||||
@click="handleDeleteSelected"
|
|
||||||
:disabled="deleting || loading"
|
|
||||||
>
|
|
||||||
<i class="fa fa-trash"></i>
|
|
||||||
{{ t('Delete Selected') }} ({{ selectedNodes.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 v-if="viewMode === 'card'" class="node-grid">
|
|
||||||
<div
|
|
||||||
v-for="node in nodes"
|
|
||||||
:key="node.name"
|
|
||||||
class="node-card"
|
|
||||||
:class="{ selected: selectedNodes.includes(node.name) }"
|
|
||||||
@click="toggleNodeSelection(node.name)"
|
|
||||||
>
|
|
||||||
<div class="card-checkbox">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
:checked="selectedNodes.includes(node.name)"
|
|
||||||
@click.stop="toggleNodeSelection(node.name)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="card-content" @click.stop="openDetail(node.name)">
|
|
||||||
<div class="icon" :style="{ color: node.node_color || '#6b7280' }">
|
|
||||||
<i :class="resolveIcon(node.node_icon)"></i>
|
|
||||||
</div>
|
|
||||||
<div class="info">
|
|
||||||
<div class="title">{{ node.title || node.type }}</div>
|
|
||||||
<div class="desc">{{ node.description || '—' }}</div>
|
|
||||||
<div class="meta">
|
|
||||||
<span v-if="node.category" class="badge">{{ node.category }}</span>
|
|
||||||
<span v-if="node.status" class="status-badge" :class="node.status">{{ t(node.status) }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 卡片视图分页 -->
|
|
||||||
<div v-if="viewMode === 'card'" class="list-pagination">
|
|
||||||
<n-pagination v-model:page="page" :page-count="pageCount" size="small" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 列表视图 -->
|
|
||||||
<div v-else class="node-list">
|
|
||||||
<div class="list-header">
|
|
||||||
<div class="col-checkbox">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
:checked="selectedNodes.length === nodes.length && nodes.length > 0"
|
|
||||||
:indeterminate="selectedNodes.length > 0 && selectedNodes.length < nodes.length"
|
|
||||||
@change="toggleSelectAll"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="col-icon">{{ t('Icon') }}</div>
|
|
||||||
<div class="col-name">{{ t('Name') }}</div>
|
|
||||||
<div class="col-type">{{ t('Type') }}</div>
|
|
||||||
<div class="col-category">{{ t('Category') }}</div>
|
|
||||||
<div class="col-status">{{ t('Status') }}</div>
|
|
||||||
<div class="col-actions">{{ t('Actions') }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="list-body">
|
|
||||||
<div
|
|
||||||
v-for="node in nodes"
|
|
||||||
:key="node.name"
|
|
||||||
class="node-list-item"
|
|
||||||
:class="{ selected: selectedNodes.includes(node.name) }"
|
|
||||||
@click="openDetail(node.name)"
|
|
||||||
>
|
|
||||||
<div class="col-checkbox">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
:checked="selectedNodes.includes(node.name)"
|
|
||||||
@click.stop="toggleNodeSelection(node.name)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="col-icon" @click.stop="openDetail(node.name)">
|
|
||||||
<div class="icon" :style="{ color: node.node_color || '#6b7280' }">
|
|
||||||
<i :class="resolveIcon(node.node_icon)"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-name" @click.stop="openDetail(node.name)">
|
|
||||||
<div class="name">{{ node.title || node.type }}</div>
|
|
||||||
<div class="description">{{ node.description || '—' }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-type" @click.stop="openDetail(node.name)">{{ node.type }}</div>
|
|
||||||
<div class="col-category" @click.stop="openDetail(node.name)">
|
|
||||||
<span v-if="node.category" class="badge">{{ node.category }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="col-status" @click.stop="openDetail(node.name)">
|
|
||||||
<span v-if="node.status" class="status-badge" :class="node.status">{{ t(node.status) }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="col-actions" @click.stop>
|
|
||||||
<button class="action-btn" @click="openDetail(node.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, onMounted, computed, watch } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { t } from '@/shared/i18n'
|
|
||||||
import { NInput, NSelect, NPagination, NDropdown, useDialog } from 'naive-ui'
|
|
||||||
import { getNodeList, importLocalNodes } from '@/shared/api/nodes'
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const dialog = useDialog()
|
|
||||||
const loading = ref(true)
|
|
||||||
const creating = ref(false)
|
|
||||||
const deleting = ref(false)
|
|
||||||
const importing = ref(false)
|
|
||||||
const nodes = ref<any[]>([])
|
|
||||||
const allNodes = ref<any[]>([]) // 保存所有原始数据
|
|
||||||
const total = ref(0)
|
|
||||||
const selectedNodes = ref<string[]>([])
|
|
||||||
const viewMode = ref<'card' | 'list'>(
|
|
||||||
(localStorage.getItem('nodeListViewMode') as 'card' | 'list') || 'list'
|
|
||||||
)
|
|
||||||
const searchQuery = ref('')
|
|
||||||
const statusFilter = ref('all')
|
|
||||||
const page = ref(1)
|
|
||||||
const pageSize = ref(parseInt(localStorage.getItem('itemsPerPage') || '10'))
|
|
||||||
|
|
||||||
const statusOptions = computed(() => [
|
|
||||||
{ label: t('All'), value: 'all' },
|
|
||||||
{ label: t('Published'), value: 'Published' },
|
|
||||||
{ label: t('Unpublished'), value: 'Unpublished' },
|
|
||||||
{ label: t('Draft'), value: 'Draft' }
|
|
||||||
])
|
|
||||||
|
|
||||||
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
|
|
||||||
|
|
||||||
// 顶部菜单
|
|
||||||
const menuOptions = computed(() => [
|
|
||||||
{ label: t('Import local nodes'), key: 'import-local-nodes' }
|
|
||||||
])
|
|
||||||
|
|
||||||
function onMenuSelect(key: string) {
|
|
||||||
if (key === 'import-local-nodes') {
|
|
||||||
handleImportLocalNodes()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchNodes() {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const result = await getNodeList(page.value, pageSize.value)
|
|
||||||
|
|
||||||
allNodes.value = (result.items || []).map((n: any) => ({
|
|
||||||
name: n.name,
|
|
||||||
type: n.node_type,
|
|
||||||
title: n.node_label,
|
|
||||||
description: n.node_description,
|
|
||||||
category: n.node_group,
|
|
||||||
node_icon: n.node_icon,
|
|
||||||
node_color: n.node_color,
|
|
||||||
properties: {},
|
|
||||||
status: n.status,
|
|
||||||
}))
|
|
||||||
|
|
||||||
// 处理数据(搜索和过滤)
|
|
||||||
processNodes()
|
|
||||||
total.value = result.total || 0
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Fetch nodes error:', error)
|
|
||||||
nodes.value = []
|
|
||||||
allNodes.value = []
|
|
||||||
total.value = 0
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveIcon(icon?: string) {
|
|
||||||
if (!icon) return 'fa fa-cube'
|
|
||||||
if (icon.startsWith('fa-')) return `fa ${icon}`
|
|
||||||
return icon
|
|
||||||
}
|
|
||||||
|
|
||||||
function processNodes() {
|
|
||||||
let filteredNodes = [...allNodes.value]
|
|
||||||
|
|
||||||
// 前端搜索和过滤
|
|
||||||
if (searchQuery.value) {
|
|
||||||
const query = searchQuery.value.toLowerCase()
|
|
||||||
filteredNodes = filteredNodes.filter(node =>
|
|
||||||
node.title?.toLowerCase().includes(query) ||
|
|
||||||
node.type?.toLowerCase().includes(query) ||
|
|
||||||
node.description?.toLowerCase().includes(query) ||
|
|
||||||
node.category?.toLowerCase().includes(query)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (statusFilter.value !== 'all') {
|
|
||||||
filteredNodes = filteredNodes.filter(node => node.status === statusFilter.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
nodes.value = filteredNodes
|
|
||||||
}
|
|
||||||
|
|
||||||
function reload() {
|
|
||||||
fetchNodes()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 一键导入本地节点(改为调用后端接口)
|
|
||||||
async function handleImportLocalNodes() {
|
|
||||||
if (importing.value) return
|
|
||||||
importing.value = true
|
|
||||||
try {
|
|
||||||
const res = await importLocalNodes()
|
|
||||||
await fetchNodes()
|
|
||||||
if (!res?.success) {
|
|
||||||
dialog.error({ title: t('Error'), content: t('Import failed') })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const matchedCount = res.matched || 0
|
|
||||||
const importedCount = res.imported || 0
|
|
||||||
const skippedExisting = res.skipped_existing || 0
|
|
||||||
if (matchedCount === 0) {
|
|
||||||
dialog.info({ title: t('Info'), content: t('No local node definitions found') })
|
|
||||||
} else if (importedCount === 0 && skippedExisting > 0) {
|
|
||||||
dialog.info({ title: t('Info'), content: t('All local nodes already exist') })
|
|
||||||
} else if (importedCount > 0) {
|
|
||||||
dialog.success({ title: t('Success'), content: `${t('Imported')} ${importedCount} ${t('nodes')}` })
|
|
||||||
} else {
|
|
||||||
dialog.info({ title: t('Info'), content: t('No new local nodes found') })
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Import local nodes error:', e)
|
|
||||||
dialog.error({ title: t('Error'), content: t('Import failed') })
|
|
||||||
} finally {
|
|
||||||
importing.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 选择相关函数
|
|
||||||
function toggleNodeSelection(nodeName: string) {
|
|
||||||
const index = selectedNodes.value.indexOf(nodeName)
|
|
||||||
if (index > -1) {
|
|
||||||
selectedNodes.value.splice(index, 1)
|
|
||||||
} else {
|
|
||||||
selectedNodes.value.push(nodeName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleSelectAll() {
|
|
||||||
if (selectedNodes.value.length === nodes.value.length) {
|
|
||||||
selectedNodes.value = []
|
|
||||||
} else {
|
|
||||||
selectedNodes.value = nodes.value.map(node => node.name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDeleteSelected() {
|
|
||||||
if (selectedNodes.value.length === 0) return
|
|
||||||
|
|
||||||
dialog.error({
|
|
||||||
title: t('Confirm Delete'),
|
|
||||||
content: t('Are you sure you want to delete the selected nodes? This action cannot be undone.'),
|
|
||||||
positiveText: t('Delete'),
|
|
||||||
negativeText: t('Cancel'),
|
|
||||||
onPositiveClick: async () => {
|
|
||||||
deleting.value = true
|
|
||||||
try {
|
|
||||||
const { deleteNodes } = await import('@/shared/api/nodes')
|
|
||||||
const result = await deleteNodes(selectedNodes.value)
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
// 删除成功,重新加载数据
|
|
||||||
selectedNodes.value = []
|
|
||||||
await fetchNodes()
|
|
||||||
} else {
|
|
||||||
alert(t('Delete failed') + ': ' + (result.message || t('Unknown error')))
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Delete error:', error)
|
|
||||||
alert(t('Delete failed') + ': ' + t('Network error'))
|
|
||||||
} finally {
|
|
||||||
deleting.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 监听搜索和筛选变化
|
|
||||||
watch([searchQuery, statusFilter], () => {
|
|
||||||
page.value = 1 // 重置到第一页
|
|
||||||
// 重新处理数据,不需要重新请求API
|
|
||||||
processNodes()
|
|
||||||
}, { deep: true })
|
|
||||||
|
|
||||||
// 监听分页变化
|
|
||||||
watch([page], () => {
|
|
||||||
fetchNodes()
|
|
||||||
})
|
|
||||||
|
|
||||||
// 监听每页数量变化(从系统设置)
|
|
||||||
watch(() => localStorage.getItem('itemsPerPage'), (newValue) => {
|
|
||||||
if (newValue) {
|
|
||||||
pageSize.value = parseInt(newValue)
|
|
||||||
page.value = 1 // 重置到第一页
|
|
||||||
fetchNodes()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// 监听视图模式变化并持久化
|
|
||||||
watch(viewMode, (m) => {
|
|
||||||
localStorage.setItem('nodeListViewMode', m)
|
|
||||||
})
|
|
||||||
|
|
||||||
function openDetail(name: string) {
|
|
||||||
router.push({ name: 'NodeDetail', params: { name } })
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleCreateNode() {
|
|
||||||
// 仅跳转到新建模式,由详情页保存时再创建后端记录
|
|
||||||
router.push({ name: 'NodeDetail', params: { name: 'new' } })
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
fetchNodes()
|
|
||||||
})
|
|
||||||
</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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.view-toggle {
|
|
||||||
display: flex;
|
|
||||||
background: #f8fafc;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 2px;
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-btn {
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
color: #64748b;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 14px;
|
|
||||||
border-radius: 6px;
|
|
||||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-btn:hover {
|
|
||||||
background: #e2e8f0;
|
|
||||||
color: #475569;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-btn.active {
|
|
||||||
background: #3b82f6;
|
|
||||||
color: white;
|
|
||||||
box-shadow: 0 2px 4px rgba(59, 130, 246, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-btn.active:hover {
|
|
||||||
background: #2563eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 现代化刷新按钮 */
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 微妙的脉冲效果 */
|
|
||||||
.refresh-btn:not(:disabled):hover::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
background: rgba(59, 130, 246, 0.1);
|
|
||||||
border-radius: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
animation: pulse 0.6s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse {
|
|
||||||
0% {
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/* 卡片视图样式 */
|
|
||||||
.node-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.node-card {
|
|
||||||
position: relative;
|
|
||||||
padding: 16px;
|
|
||||||
border: 1px solid #e5e7eb;
|
|
||||||
border-radius: 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
background: #fff;
|
|
||||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.node-card:hover {
|
|
||||||
border-color: #3b82f6;
|
|
||||||
background: #f8fafc;
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-content {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon {
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
border-radius: 12px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: #f3f4f6;
|
|
||||||
font-size: 20px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
padding-right: 30px; /* 为复选框留出空间 */
|
|
||||||
}
|
|
||||||
|
|
||||||
.title {
|
|
||||||
font-weight: 600;
|
|
||||||
color: #111827;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.desc {
|
|
||||||
color: #6b7280;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.4;
|
|
||||||
height: 36px;
|
|
||||||
overflow: hidden;
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 2;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
}
|
|
||||||
|
|
||||||
.meta {
|
|
||||||
margin-top: 8px;
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge {
|
|
||||||
background: #eef2ff;
|
|
||||||
color: #334155;
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge {
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge.active {
|
|
||||||
background: #dcfce7;
|
|
||||||
color: #166534;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge.inactive {
|
|
||||||
background: #fee2e2;
|
|
||||||
color: #991b1b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge.draft {
|
|
||||||
background: #fef3c7;
|
|
||||||
color: #92400e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge.published {
|
|
||||||
background: #dcfce7;
|
|
||||||
color: #166534;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge.unpublished {
|
|
||||||
background: #fee2e2;
|
|
||||||
color: #991b1b;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 列表视图样式 */
|
|
||||||
.node-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 60px 2fr 1fr 1fr 1fr 80px;
|
|
||||||
gap: 16px;
|
|
||||||
padding: 16px 20px;
|
|
||||||
background: #f8fafc;
|
|
||||||
border-bottom: 1px solid #e5e7eb;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #374151;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.node-list-item {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 40px 60px 2fr 1fr 1fr 1fr 80px;
|
|
||||||
gap: 16px;
|
|
||||||
padding: 16px 20px;
|
|
||||||
border-bottom: 1px solid #f3f4f6;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.node-list-item:hover {
|
|
||||||
background: #f8fafc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.node-list-item:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.col-icon {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.col-icon .icon {
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.col-name {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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-type {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
color: #6b7280;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.col-category {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.col-status {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.col-actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 新建按钮 */
|
|
||||||
.create-btn {
|
|
||||||
height: 36px;
|
|
||||||
padding: 0 16px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #2563eb;
|
|
||||||
color: white;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.create-btn:hover {
|
|
||||||
background: #1d4ed8;
|
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.create-btn:active {
|
|
||||||
transform: translateY(0);
|
|
||||||
box-shadow: 0 2px 6px rgba(37, 99, 235, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.create-btn i {
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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);
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.delete-btn:hover {
|
|
||||||
background: #dc2626;
|
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.delete-btn:active {
|
|
||||||
transform: translateY(0);
|
|
||||||
box-shadow: 0 2px 6px rgba(239, 68, 68, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.delete-btn:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.delete-btn i {
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 选择相关样式 */
|
|
||||||
.col-checkbox {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-checkbox {
|
|
||||||
position: absolute;
|
|
||||||
top: 12px;
|
|
||||||
right: 12px;
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-checkbox input[type="checkbox"] {
|
|
||||||
cursor: pointer;
|
|
||||||
accent-color: #3b82f6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.node-card.selected {
|
|
||||||
border-color: #3b82f6;
|
|
||||||
background: #f0f9ff;
|
|
||||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.node-list-item.selected {
|
|
||||||
background: #f0f9ff;
|
|
||||||
border-color: #3b82f6;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
Loading…
x
Reference in New Issue
Block a user