TemplateAgents.vue 11.4 KB
<template>
  <div class="template-agents-page">
    <AppNav />

    <div class="main-layout">
      <!-- ═══ Left Sidebar (Template List) ═══ -->
      <aside class="sidebar-panel">
        <div class="sidebar-header">
          <h3>📦 模板库库 ({{ templates.length }})</h3>
        </div>
        <div class="sidebar-search">
          <input type="text" v-model="searchQuery" placeholder="搜索模板..." />
        </div>
        <div class="template-list-scroll">
          <div 
            v-for="tpl in filteredTemplates" :key="tpl.id"
            class="template-item"
            :class="{ active: selectedTemplateId === tpl.id }"
            @click="selectTemplate(tpl.id)"
          >
            <div class="tpl-icon">{{ agentEmoji(tpl.type) }}</div>
            <div class="tpl-info">
              <div class="tpl-name">{{ tpl.name }}</div>
              <div class="tpl-type">{{ tpl.type }}</div>
            </div>
            <div v-if="tpl.benchmarkScore" class="tpl-score">⭐ {{ tpl.benchmarkScore }}</div>
          </div>
          <div v-if="filteredTemplates.length === 0" class="empty-mini">未找到匹配模板</div>
        </div>
      </aside>

      <!-- ═══ Right Content (Detail & Files) ═══ -->
      <main class="content-panel">
        <div v-if="selectedTemplate" class="template-detail">
          
          <div class="detail-header">
            <div class="header-main">
              <span class="header-icon">{{ agentEmoji(selectedTemplate.type) }}</span>
              <div>
                <h2>{{ selectedTemplate.name }}</h2>
                <div class="meta-row">
                  <span class="badge badge-type">{{ selectedTemplate.type }}</span>
                  <span class="badge badge-used">被使用: {{ selectedTemplate.usedCount }} 次</span>
                  <span v-if="selectedTemplate.benchmarkScore" class="badge badge-score">评分: {{ selectedTemplate.benchmarkScore }}</span>
                </div>
              </div>
            </div>
          </div>
          
          <!-- File Explorer -->
          <div class="files-layout">
            <div class="files-tree-panel">
              <div class="panel-title">
                📁 模板文件结构
                <span class="file-count-badge">{{ templateFiles?.totalFiles || 0 }} 文件</span>
              </div>
              <div class="files-tree-scroll">
                <div v-if="templateFiles?.tree?.length > 0">
                  <template v-for="node in templateFiles.tree" :key="node.path">
                    <CompanyFileNode :node="node" :depth="0" :selected="selectedFilePath" @select="previewTemplateFile" />
                  </template>
                </div>
                <div v-else class="empty-mini">暂无文件或系统隐藏</div>
              </div>
            </div>

            <div class="files-preview-panel">
              <div class="panel-title">
                👁️ {{ selectedFilePath || '文件预览' }}
                <span v-if="filePreviewSize" class="file-size-badge">{{ formatFileSize(filePreviewSize) }}</span>
              </div>
              <div class="files-preview-scroll">
                <pre v-if="filePreviewContent !== null" class="file-preview-code">{{ filePreviewContent }}</pre>
                <div v-else class="empty-mini">← 点击左侧文件树以预览内容</div>
              </div>
            </div>
          </div>

        </div>
        <div v-else class="empty-state">
          <div class="empty-icon">📦</div>
          <h3>请在左侧选择一个 Agent 模板</h3>
        </div>
      </main>
    </div>
  </div>
</template>

<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import axios from 'axios'
import AppNav from '@/components/AppNav.vue'
import CompanyFileNode from '@/components/CompanyFileNode.vue'

const router = useRouter()
const route = useRoute()

const templates = ref([])
const searchQuery = ref('')
const selectedTemplateId = ref(null)

const templateFiles = ref(null)
const selectedFilePath = ref(null)
const filePreviewContent = ref(null)
const filePreviewSize = ref(0)

const filteredTemplates = computed(() => {
  if (!searchQuery.value) return templates.value;
  const q = searchQuery.value.toLowerCase();
  return templates.value.filter(t => t.id.toLowerCase().includes(q) || (t.name && t.name.toLowerCase().includes(q)));
})

const selectedTemplate = computed(() => {
  return templates.value.find(t => t.id === selectedTemplateId.value) || null;
})

function agentEmoji(type) {
  const map = { cxo: '👔', role: '🏢', skill: '⚙️', specialist: '🔬', writer: '✍️', 'data-analyst': '📈' }
  return map[type] || '🤖'
}

function formatFileSize(bytes) {
  if (!bytes) return ''
  if (bytes < 1024) return `${bytes} B`
  if (bytes < 1048576) return `${(bytes/1024).toFixed(1)} KB`
  return `${(bytes/1048576).toFixed(1)} MB`
}

async function loadTemplates() {
  try {
    const { data } = await axios.get('/api/company/templates')
    templates.value = data
    
    // Auto select from route if valid
    if (route.params.agentId && templates.value.some(t => t.id === route.params.agentId)) {
      selectedTemplateId.value = route.params.agentId;
      await loadTemplateFiles(route.params.agentId);
    } else if (templates.value.length > 0) {
      // Default auto-select the first one and replace route
      selectTemplate(templates.value[0].id)
    }
  } catch (e) {
    console.error(e)
  }
}

function selectTemplate(id) {
  selectedTemplateId.value = id;
  // Clear file preview selection
  selectedFilePath.value = null;
  filePreviewContent.value = null;
  filePreviewSize.value = 0;
  
  router.push(`/templates/${id}`);
}

async function loadTemplateFiles(id) {
  try {
    const { data } = await axios.get(`/api/company/templates/${id}/files`);
    templateFiles.value = data;
  } catch (e) {
    console.error(e);
  }
}

async function previewTemplateFile(filePath) {
  if (!selectedTemplateId.value) return;
  selectedFilePath.value = filePath;
  try {
    const { data } = await axios.get(`/api/company/templates/${selectedTemplateId.value}/files/content`, {
      params: { path: filePath }
    });
    filePreviewContent.value = data.content;
    filePreviewSize.value = data.size || 0;
  } catch (e) {
    filePreviewContent.value = '(加载失败)';
    filePreviewSize.value = 0;
  }
}

watch(() => route.params.agentId, (newId) => {
  if (newId && newId !== selectedTemplateId.value) {
    selectedTemplateId.value = newId;
    loadTemplateFiles(newId);
  }
})

watch(selectedTemplateId, (newId) => {
  if (newId) {
    loadTemplateFiles(newId);
  }
})

onMounted(() => {
  loadTemplates()
})
</script>

<style scoped>
.template-agents-page {
  height: 100vh;
  display: flex;
  flex-direction: column;
  background: var(--bg-primary);
}

.main-layout {
  flex: 1;
  display: flex;
  overflow: hidden;
}

/* ═══ Sidebar ═══ */
.sidebar-panel {
  width: 280px;
  background: var(--bg-secondary);
  border-right: 1px solid var(--border-primary);
  display: flex;
  flex-direction: column;
}
.sidebar-header {
  padding: 16px 20px;
  border-bottom: 1px solid var(--border-primary);
}
.sidebar-header h3 {
  font-size: 16px; font-weight: 700; margin: 0; color: var(--text-primary);
}
.sidebar-search {
  padding: 12px;
  border-bottom: 1px solid var(--border-primary);
}
.sidebar-search input {
  width: 100%; padding: 8px 12px;
  border: 1px solid var(--border-primary);
  border-radius: 8px; background: var(--bg-input);
  color: var(--text-primary); font-size: 13px;
  transition: all 0.2s;
}
.sidebar-search input:focus {
  outline: none; border-color: var(--accent); background: var(--bg-input-focus);
}
.template-list-scroll {
  flex: 1; overflow-y: auto; padding: 12px;
  display: flex; flex-direction: column; gap: 8px;
}
.template-item {
  display: flex; align-items: center; gap: 12px;
  padding: 12px; border-radius: 10px; cursor: pointer;
  border: 1px solid transparent; transition: all 0.2s;
}
.template-item:hover {
  background: var(--bg-surface-hover);
}
.template-item.active {
  background: var(--bg-surface);
  border-color: var(--border-active);
  box-shadow: 0 4px 12px rgba(0,0,0,0.05);
}
.tpl-icon { font-size: 24px; }
.tpl-info { flex: 1; min-width: 0; }
.tpl-name { 
  font-size: 14px; font-weight: 700; color: var(--text-primary); 
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.tpl-type { font-size: 11px; color: var(--text-muted); text-transform: uppercase; margin-top: 2px; }
.tpl-score { font-size: 11px; color: var(--yellow-text); font-weight: 700; }

.empty-mini { padding: 24px; text-align: center; color: var(--text-muted); font-size: 13px; }

/* ═══ Main Content ═══ */
.content-panel {
  flex: 1; display: flex; flex-direction: column;
  background: var(--bg-primary); padding: 24px;
}
.empty-state {
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  height: 100%; color: var(--text-muted);
}
.empty-icon { font-size: 48px; margin-bottom: 16px; opacity: 0.5; }
.template-detail {
  display: flex; flex-direction: column; height: 100%; gap: 24px;
}
.detail-header {
  background: var(--bg-secondary); border: 1px solid var(--border-primary);
  border-radius: 16px; padding: 24px; display: flex; justify-content: space-between; align-items: center;
}
.header-main { display: flex; align-items: center; gap: 20px; }
.header-icon { font-size: 48px; }
.header-main h2 { font-size: 24px; font-weight: 800; margin: 0 0 8px 0; color: var(--text-primary); }
.meta-row { display: flex; gap: 12px; }
.badge { padding: 4px 10px; border-radius: 8px; font-size: 12px; font-weight: 600; }
.badge-type { background: var(--bg-surface-hover); color: var(--text-secondary); border: 1px solid var(--border-primary); text-transform: uppercase; }
.badge-used { background: rgba(59,130,246,0.1); color: var(--blue-text); }
.badge-score { background: rgba(251,191,36,0.1); color: var(--yellow-text); }

/* ═══ Files Layout ═══ */
.files-layout {
  display: flex; gap: 0; flex: 1; min-height: 0;
  border: 1px solid var(--border-primary); border-radius: 12px; overflow: hidden;
  background: var(--bg-surface);
}
.files-tree-panel {
  width: 300px; min-width: 240px; border-right: 1px solid var(--border-primary);
  display: flex; flex-direction: column; background: var(--bg-overlay);
}
.files-preview-panel {
  flex: 1; display: flex; flex-direction: column; min-width: 0;
}
.panel-title {
  padding: 12px 16px; font-size: 13px; font-weight: 700; color: var(--text-secondary);
  border-bottom: 1px solid var(--border-primary);
  display: flex; align-items: center; justify-content: space-between; gap: 8px;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.files-tree-scroll { flex: 1; overflow-y: auto; padding: 8px; }
.files-preview-scroll { flex: 1; overflow: auto; padding: 12px; background: var(--bg-input); }
.file-preview-code {
  font-family: 'JetBrains Mono', 'Menlo', monospace; font-size: 13px;
  line-height: 1.6; color: var(--text-secondary); white-space: pre-wrap;
  word-break: break-all; margin: 0; padding: 4px;
}
.file-count-badge {
  padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 600;
  background: var(--accent-bg); color: var(--accent); flex-shrink: 0;
}
.file-size-badge {
  padding: 2px 8px; border-radius: 6px; font-size: 11px;
  background: var(--purple-bg, rgba(139,92,246,0.1)); color: var(--purple-text, #a78bfa);
  flex-shrink: 0; font-weight: 600;
}
</style>