删除冗余的路由和文件

This commit is contained in:
jingrow 2025-11-03 04:43:08 +08:00
parent dd3330e3c2
commit 04d4bd95fd
5 changed files with 0 additions and 2997 deletions

View File

@ -20,26 +20,6 @@ const router = createRouter({
name: 'Dashboard',
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',
name: 'LocalJobList',

View File

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

View File

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

View File

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

View File

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