删除冗余的路由和文件
This commit is contained in:
parent
dd3330e3c2
commit
04d4bd95fd
@ -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',
|
||||
|
||||
@ -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