819 lines
26 KiB
Vue
819 lines
26 KiB
Vue
<template>
|
||
<div class="skills-manager-container">
|
||
<HeaderComponent title="Skills 管理" description="技能包管理系统。支持文件系统编辑与数据库元数据同步。" :loading="loading" class="main-header">
|
||
<template #actions>
|
||
<a-space :size="12">
|
||
<a-upload
|
||
accept=".zip"
|
||
:show-upload-list="false"
|
||
:custom-request="handleImportUpload"
|
||
:disabled="loading || importing"
|
||
>
|
||
<a-button :loading="importing" class="lucide-icon-btn">
|
||
<Upload :size="14" />
|
||
<span>导入 ZIP</span>
|
||
</a-button>
|
||
</a-upload>
|
||
<a-button type="primary" @click="fetchSkills" :disabled="loading" class="lucide-icon-btn">
|
||
<RotateCw :size="14" />
|
||
<span>刷新</span>
|
||
</a-button>
|
||
</a-space>
|
||
</template>
|
||
</HeaderComponent>
|
||
|
||
<div class="content-body">
|
||
<div class="layout-wrapper">
|
||
<!-- 左侧:技能列表 -->
|
||
<div class="sidebar-list">
|
||
<div class="search-box">
|
||
<a-input v-model:value="searchQuery" placeholder="搜索技能..." allow-clear class="search-input">
|
||
<template #prefix><Search :size="14" class="text-muted" /></template>
|
||
</a-input>
|
||
</div>
|
||
|
||
<div class="list-container">
|
||
<div v-if="filteredSkills.length === 0" class="empty-text">
|
||
<a-empty :image="false" description="无匹配技能" />
|
||
</div>
|
||
<div
|
||
v-for="skill in filteredSkills"
|
||
:key="skill.slug"
|
||
class="list-item"
|
||
:class="{ active: currentSkill?.slug === skill.slug }"
|
||
@click="selectSkill(skill)"
|
||
>
|
||
<div class="item-header">
|
||
<Box :size="16" class="item-icon" />
|
||
<span class="item-name">{{ skill.name }}</span>
|
||
</div>
|
||
<div class="item-details">
|
||
<span class="item-slug">{{ skill.slug }}</span>
|
||
<div class="item-badges">
|
||
<span v-if="skill.tool_dependencies?.length" class="dot-badge blue" title="工具依赖"></span>
|
||
<span v-if="skill.mcp_dependencies?.length" class="dot-badge green" title="MCP依赖"></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 右侧:详情面板 -->
|
||
<div class="main-panel">
|
||
<div v-if="!currentSkill" class="unselected-state">
|
||
<div class="hint-box">
|
||
<FileCode :size="40" class="text-muted" />
|
||
<p>请在左侧选择技能包进行编辑</p>
|
||
</div>
|
||
</div>
|
||
|
||
<template v-else>
|
||
<div class="panel-top-bar">
|
||
<div class="skill-summary">
|
||
<h2>{{ currentSkill.name }}</h2>
|
||
<!-- <code>{{ currentSkill.slug }}</code> -->
|
||
</div>
|
||
<div class="panel-actions">
|
||
<a-space :size="8">
|
||
<a-button size="small" @click="handleExport" class="lucide-icon-btn">
|
||
<Download :size="14" />
|
||
<span>导出</span>
|
||
</a-button>
|
||
<a-button size="small" danger ghost @click="confirmDeleteSkill" class="lucide-icon-btn">
|
||
<Trash2 :size="14" />
|
||
<span>删除</span>
|
||
</a-button>
|
||
</a-space>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="tabs-area">
|
||
<a-tabs v-model:activeKey="activeTab" class="minimal-tabs">
|
||
<a-tab-pane key="editor">
|
||
<template #tab>
|
||
<span class="tab-title"><FileText :size="14" />代码管理</span>
|
||
</template>
|
||
<div class="workspace">
|
||
<div class="tree-container">
|
||
<div class="tree-header">
|
||
<span class="label">项目结构</span>
|
||
<div class="tree-actions">
|
||
<a-tooltip title="新建文件"><button @click="openCreateModal(false)"><FilePlus :size="14" /></button></a-tooltip>
|
||
<a-tooltip title="新建目录"><button @click="openCreateModal(true)"><FolderPlus :size="14" /></button></a-tooltip>
|
||
<a-tooltip title="刷新"><button @click="reloadTree"><RotateCw :size="14" /></button></a-tooltip>
|
||
</div>
|
||
</div>
|
||
<div class="tree-content">
|
||
<FileTreeComponent
|
||
v-model:selectedKeys="selectedTreeKeys"
|
||
v-model:expandedKeys="expandedKeys"
|
||
:tree-data="treeData"
|
||
@select="handleTreeSelect"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="editor-container">
|
||
<div class="editor-header">
|
||
<div class="current-path">
|
||
<File :size="14" />
|
||
<span>{{ selectedPath || '未选择文件' }}</span>
|
||
<span v-if="canSave" class="save-hint">●</span>
|
||
</div>
|
||
<a-button
|
||
type="primary"
|
||
size="small"
|
||
@click="saveCurrentFile"
|
||
:disabled="!canSave"
|
||
:loading="savingFile"
|
||
class="lucide-icon-btn"
|
||
>
|
||
<Save :size="14" />
|
||
<span>保存</span>
|
||
</a-button>
|
||
</div>
|
||
<div class="editor-main">
|
||
<a-empty v-if="!selectedPath || selectedIsDir" description="选择文件以开始编辑" class="mt-40" />
|
||
<a-textarea
|
||
v-else
|
||
v-model:value="fileContent"
|
||
class="pure-editor"
|
||
spellcheck="false"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</a-tab-pane>
|
||
|
||
<a-tab-pane key="dependencies">
|
||
<template #tab>
|
||
<span class="tab-title"><Layers :size="14" />依赖管理</span>
|
||
</template>
|
||
<div class="config-view">
|
||
<div class="config-header">
|
||
<div class="text">
|
||
<h3>依赖声明</h3>
|
||
<p>配置此 Skill 所需的工具、MCP 服务器及其他 Skill 依赖。</p>
|
||
</div>
|
||
<a-button type="primary" :loading="savingDependencies" @click="saveDependencies" class="lucide-icon-btn">
|
||
<Save :size="14" />
|
||
<span>更新依赖</span>
|
||
</a-button>
|
||
</div>
|
||
|
||
<div class="config-form">
|
||
<a-form layout="vertical">
|
||
<a-form-item label="工具依赖 (Tools)">
|
||
<a-select v-model:value="dependencyForm.tool_dependencies" mode="multiple" :options="toolDependencyOptions" placeholder="选择工具..." allow-clear show-search />
|
||
</a-form-item>
|
||
<a-form-item label="MCP 依赖 (Model Context Protocol)">
|
||
<a-select v-model:value="dependencyForm.mcp_dependencies" mode="multiple" :options="mcpDependencyOptions" placeholder="选择 MCP 服务..." allow-clear show-search />
|
||
</a-form-item>
|
||
<a-form-item label="Skill 依赖">
|
||
<a-select v-model:value="dependencyForm.skill_dependencies" mode="multiple" :options="skillDependencyOptions" placeholder="选择 Skill..." allow-clear show-search />
|
||
</a-form-item>
|
||
</a-form>
|
||
</div>
|
||
</div>
|
||
</a-tab-pane>
|
||
</a-tabs>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 弹窗 -->
|
||
<a-modal
|
||
v-model:open="createModalVisible"
|
||
:title="createForm.isDir ? '新建目录' : '新建文件'"
|
||
@ok="handleCreateNode"
|
||
:confirm-loading="creatingNode"
|
||
width="400px"
|
||
>
|
||
<a-form layout="vertical" class="pt-12">
|
||
<a-form-item label="路径 (相对于根目录)" required>
|
||
<a-input v-model:value="createForm.path" placeholder="src/main.py" />
|
||
</a-form-item>
|
||
<a-form-item v-if="!createForm.isDir" label="内容">
|
||
<a-textarea v-model:value="createForm.content" :rows="5" />
|
||
</a-form-item>
|
||
</a-form>
|
||
</a-modal>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { computed, onMounted, reactive, ref } from 'vue'
|
||
import { message, Modal } from 'ant-design-vue'
|
||
import dayjs from 'dayjs'
|
||
import relativeTime from 'dayjs/plugin/relativeTime'
|
||
import 'dayjs/locale/zh-cn'
|
||
import {
|
||
Upload, RotateCw, Download, Trash2, Save, FileText, Layers,
|
||
FilePlus, FolderPlus, File, Search, Box, FileCode
|
||
} from 'lucide-vue-next'
|
||
import { skillApi } from '@/apis/skill_api'
|
||
import HeaderComponent from '@/components/HeaderComponent.vue'
|
||
import FileTreeComponent from '@/components/FileTreeComponent.vue'
|
||
|
||
dayjs.extend(relativeTime)
|
||
dayjs.locale('zh-cn')
|
||
|
||
const loading = ref(false)
|
||
const importing = ref(false)
|
||
const savingFile = ref(false)
|
||
const creatingNode = ref(false)
|
||
const savingDependencies = ref(false)
|
||
const activeTab = ref('editor')
|
||
const searchQuery = ref('')
|
||
|
||
const skills = ref([])
|
||
const currentSkill = ref(null)
|
||
const treeData = ref([])
|
||
const selectedTreeKeys = ref([])
|
||
const expandedKeys = ref([])
|
||
const selectedPath = ref('')
|
||
const selectedIsDir = ref(false)
|
||
const fileContent = ref('')
|
||
const originalFileContent = ref('')
|
||
|
||
const createModalVisible = ref(false)
|
||
const createForm = reactive({ path: '', isDir: false, content: '' })
|
||
const dependencyOptions = reactive({ tools: [], mcps: [], skills: [] })
|
||
const dependencyForm = reactive({ tool_dependencies: [], mcp_dependencies: [], skill_dependencies: [] })
|
||
|
||
const filteredSkills = computed(() => {
|
||
if (!searchQuery.value) return skills.value
|
||
const q = searchQuery.value.toLowerCase()
|
||
return skills.value.filter(s => s.name.toLowerCase().includes(q) || s.slug.toLowerCase().includes(q))
|
||
})
|
||
|
||
const canSave = computed(() => {
|
||
if (!selectedPath.value || selectedIsDir.value) return false
|
||
return fileContent.value !== originalFileContent.value
|
||
})
|
||
|
||
const formatRelativeTime = (time) => time ? dayjs(time).fromNow() : '-'
|
||
|
||
const toolDependencyOptions = computed(() => (dependencyOptions.tools || []).map(i => ({ label: i, value: i })))
|
||
const mcpDependencyOptions = computed(() => (dependencyOptions.mcps || []).map(i => ({ label: i, value: i })))
|
||
const skillDependencyOptions = computed(() => (dependencyOptions.skills || []).filter(s => s !== currentSkill.value?.slug).map(i => ({ label: i, value: i })))
|
||
|
||
const normalizeTree = (nodes) => (nodes || []).map(node => ({
|
||
title: node.name, key: node.path, isLeaf: !node.is_dir, path: node.path, is_dir: node.is_dir,
|
||
children: node.is_dir ? normalizeTree(node.children || []) : undefined
|
||
}))
|
||
|
||
const resetFileState = () => {
|
||
selectedPath.value = ''
|
||
selectedIsDir.value = false
|
||
selectedTreeKeys.value = []
|
||
expandedKeys.value = []
|
||
fileContent.value = ''
|
||
originalFileContent.value = ''
|
||
}
|
||
|
||
const expandAllKeys = (nodes) => {
|
||
let keys = []
|
||
nodes.forEach(node => {
|
||
if (node.is_dir) {
|
||
keys.push(node.key)
|
||
if (node.children) {
|
||
keys = keys.concat(expandAllKeys(node.children))
|
||
}
|
||
}
|
||
})
|
||
return keys
|
||
}
|
||
|
||
const fetchSkills = async () => {
|
||
loading.value = true
|
||
try {
|
||
const result = await skillApi.listSkills()
|
||
skills.value = result?.data || []
|
||
|
||
// 默认选中第一个技能并加载 SKILL.md
|
||
if (!currentSkill.value && skills.value.length > 0) {
|
||
await selectSkill(skills.value[0])
|
||
} else if (currentSkill.value) {
|
||
const latest = skills.value.find(i => i.slug === currentSkill.value.slug)
|
||
if (latest) {
|
||
currentSkill.value = latest
|
||
syncDependencyFormFromSkill(latest)
|
||
} else {
|
||
currentSkill.value = null
|
||
treeData.value = []
|
||
resetFileState()
|
||
}
|
||
}
|
||
await fetchDependencyOptions()
|
||
} catch (error) {
|
||
message.error('加载失败')
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
const fetchDependencyOptions = async () => {
|
||
try {
|
||
const result = await skillApi.getSkillDependencyOptions()
|
||
const data = result?.data || {}
|
||
dependencyOptions.tools = data.tools || []
|
||
dependencyOptions.mcps = data.mcps || []
|
||
dependencyOptions.skills = data.skills || []
|
||
} catch {}
|
||
}
|
||
|
||
const syncDependencyFormFromSkill = (skillRecord) => {
|
||
dependencyForm.tool_dependencies = [...(skillRecord?.tool_dependencies || [])]
|
||
dependencyForm.mcp_dependencies = [...(skillRecord?.mcp_dependencies || [])]
|
||
dependencyForm.skill_dependencies = [...(skillRecord?.skill_dependencies || [])]
|
||
}
|
||
|
||
const reloadTree = async () => {
|
||
if (!currentSkill.value) return
|
||
loading.value = true
|
||
try {
|
||
const result = await skillApi.getSkillTree(currentSkill.value.slug)
|
||
const normalized = normalizeTree(result?.data || [])
|
||
treeData.value = normalized
|
||
expandedKeys.value = expandAllKeys(normalized)
|
||
} catch {
|
||
message.error('加载目录树失败')
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
const loadSkillFile = async (slug, path = 'SKILL.md') => {
|
||
try {
|
||
const fileResult = await skillApi.getSkillFile(slug, path)
|
||
const content = fileResult?.data?.content || ''
|
||
fileContent.value = content
|
||
originalFileContent.value = content
|
||
selectedPath.value = path
|
||
selectedIsDir.value = false
|
||
selectedTreeKeys.value = [path]
|
||
} catch {
|
||
// 文件不存在时忽略
|
||
}
|
||
}
|
||
|
||
const selectSkill = async (record) => {
|
||
currentSkill.value = record
|
||
syncDependencyFormFromSkill(record)
|
||
resetFileState()
|
||
|
||
// 并行执行:加载树结构和获取 SKILL.md
|
||
const [treeResult] = await Promise.all([
|
||
reloadTree(),
|
||
loadSkillFile(record.slug)
|
||
])
|
||
}
|
||
|
||
const handleTreeSelect = async (keys, info) => {
|
||
if (!keys?.length) { resetFileState(); return }
|
||
const node = info?.node || {}
|
||
const path = node.path || node.key
|
||
const isDir = !!node.is_dir
|
||
selectedTreeKeys.value = [path]
|
||
selectedPath.value = path
|
||
selectedIsDir.value = isDir
|
||
if (isDir) {
|
||
fileContent.value = ''; originalFileContent.value = ''
|
||
return
|
||
}
|
||
try {
|
||
const result = await skillApi.getSkillFile(currentSkill.value.slug, path)
|
||
const content = result?.data?.content || ''
|
||
fileContent.value = content
|
||
originalFileContent.value = content
|
||
} catch {
|
||
message.error('文件读取失败')
|
||
}
|
||
}
|
||
|
||
const saveCurrentFile = async () => {
|
||
if (!currentSkill.value || !selectedPath.value || selectedIsDir.value) return
|
||
savingFile.value = true
|
||
try {
|
||
await skillApi.updateSkillFile(currentSkill.value.slug, {
|
||
path: selectedPath.value, content: fileContent.value
|
||
})
|
||
originalFileContent.value = fileContent.value
|
||
message.success('已保存')
|
||
if (selectedPath.value === 'SKILL.md') await fetchSkills()
|
||
} catch {
|
||
message.error('保存失败')
|
||
} finally {
|
||
savingFile.value = false
|
||
}
|
||
}
|
||
|
||
const openCreateModal = (isDir) => {
|
||
if (!currentSkill.value) return
|
||
createForm.path = ''; createForm.content = ''; createForm.isDir = isDir
|
||
createModalVisible.value = true
|
||
}
|
||
|
||
const handleCreateNode = async () => {
|
||
if (!currentSkill.value || !createForm.path.trim()) return
|
||
creatingNode.value = true
|
||
try {
|
||
await skillApi.createSkillFile(currentSkill.value.slug, {
|
||
path: createForm.path.trim(), is_dir: createForm.isDir, content: createForm.content
|
||
})
|
||
createModalVisible.value = false
|
||
await reloadTree()
|
||
message.success('创建成功')
|
||
} catch {
|
||
message.error('创建失败')
|
||
} finally {
|
||
creatingNode.value = false
|
||
}
|
||
}
|
||
|
||
const confirmDeleteNode = () => {
|
||
if (!currentSkill.value || !selectedPath.value || selectedPath.value === 'SKILL.md') return
|
||
Modal.confirm({
|
||
title: '确认删除?',
|
||
content: `将永久删除: ${selectedPath.value}`,
|
||
okText: '删除', okType: 'danger', cancelText: '取消',
|
||
onOk: async () => {
|
||
try {
|
||
await skillApi.deleteSkillFile(currentSkill.value.slug, selectedPath.value)
|
||
resetFileState(); await reloadTree(); message.success('已删除')
|
||
} catch { message.error('删除失败') }
|
||
}
|
||
})
|
||
}
|
||
|
||
const confirmDeleteSkill = () => {
|
||
if (!currentSkill.value) return
|
||
Modal.confirm({
|
||
title: `彻底删除技能「${currentSkill.value.slug}」?`,
|
||
content: '删除后无法恢复,所有文件和配置将永久消失。',
|
||
okText: '确认删除', okType: 'danger', cancelText: '取消',
|
||
onOk: async () => {
|
||
try {
|
||
await skillApi.deleteSkill(currentSkill.value.slug)
|
||
message.success('已删除'); currentSkill.value = null; treeData.value = []
|
||
resetFileState(); await fetchSkills()
|
||
} catch { message.error('删除失败') }
|
||
}
|
||
})
|
||
}
|
||
|
||
const handleExport = async () => {
|
||
if (!currentSkill.value) return
|
||
try {
|
||
const response = await skillApi.exportSkill(currentSkill.value.slug)
|
||
const blob = await response.blob()
|
||
const url = URL.createObjectURL(blob)
|
||
const link = document.createElement('a')
|
||
link.href = url; link.download = `${currentSkill.value.slug}.zip`
|
||
link.click(); URL.revokeObjectURL(url)
|
||
} catch { message.error('导出失败') }
|
||
}
|
||
|
||
const handleImportUpload = async ({ file, onSuccess, onError }) => {
|
||
importing.value = true
|
||
try {
|
||
const result = await skillApi.importSkillZip(file)
|
||
message.success('导入完成'); await fetchSkills()
|
||
const imported = result?.data
|
||
if (imported?.slug) {
|
||
const record = skills.value.find(i => i.slug === imported.slug)
|
||
if (record) await selectSkill(record)
|
||
}
|
||
onSuccess?.(result)
|
||
} catch (e) { message.error('导入失败'); onError?.(e) } finally { importing.value = false }
|
||
}
|
||
|
||
const saveDependencies = async () => {
|
||
if (!currentSkill.value) return
|
||
savingDependencies.value = true
|
||
try {
|
||
const result = await skillApi.updateSkillDependencies(currentSkill.value.slug, {
|
||
tool_dependencies: dependencyForm.tool_dependencies,
|
||
mcp_dependencies: dependencyForm.mcp_dependencies,
|
||
skill_dependencies: dependencyForm.skill_dependencies
|
||
})
|
||
const updated = result?.data
|
||
if (updated) { currentSkill.value = updated; syncDependencyFormFromSkill(updated) }
|
||
await fetchSkills(); message.success('依赖已更新')
|
||
} catch { message.error('更新失败') } finally { savingDependencies.value = false }
|
||
}
|
||
|
||
onMounted(fetchSkills)
|
||
</script>
|
||
|
||
<style scoped lang="less">
|
||
/* 基础变量模拟 Shadcn/UI */
|
||
@border-color: var(--gray-150);
|
||
@bg-secondary: var(--gray-10); /* 调浅背景颜色 */
|
||
@radius: 8px;
|
||
|
||
.skills-manager-container {
|
||
display: flex;
|
||
flex-direction: column;
|
||
height: 100%;
|
||
min-height: 0;
|
||
background-color: var(--gray-0);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.main-header {
|
||
height: 74px;
|
||
box-sizing: border-box;
|
||
border-bottom: 1px solid @border-color;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.content-body {
|
||
flex: 1;
|
||
min-height: 0;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
height: calc(100vh - 74px);
|
||
}
|
||
|
||
.full-spin {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-height: 0;
|
||
height: 100%;
|
||
:deep(.ant-spin-container) {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-height: 0;
|
||
height: 100%;
|
||
}
|
||
:deep(.ant-spin-nested-loading) {
|
||
height: 100%;
|
||
}
|
||
}
|
||
|
||
.layout-wrapper {
|
||
display: flex;
|
||
flex: 1;
|
||
min-height: 0;
|
||
height: 100%;
|
||
}
|
||
|
||
/* 左侧列表 */
|
||
.sidebar-list {
|
||
width: 280px;
|
||
border-right: 1px solid @border-color;
|
||
background-color: @bg-secondary;
|
||
display: flex;
|
||
flex-direction: column;
|
||
flex-shrink: 0;
|
||
|
||
.search-box {
|
||
padding: 12px 12px;
|
||
padding-bottom: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
|
||
.search-input {
|
||
:deep(.ant-input) {
|
||
height: 24px;
|
||
}
|
||
}
|
||
}
|
||
|
||
.list-container {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 12px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
}
|
||
}
|
||
|
||
.list-item {
|
||
padding: 10px 12px;
|
||
border-radius: @radius;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
border: 1px solid transparent;
|
||
|
||
&:hover { background-color: var(--gray-100); }
|
||
|
||
&.active {
|
||
background-color: var(--gray-0);
|
||
border-color: @border-color;
|
||
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
|
||
.item-icon { color: var(--main-color); }
|
||
.item-name { color: var(--gray-900); }
|
||
}
|
||
|
||
.item-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
margin-bottom: 4px;
|
||
.item-icon { color: var(--gray-400); }
|
||
.item-name { font-size: 14px; font-weight: 500; color: var(--gray-700); }
|
||
}
|
||
|
||
.item-details {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
.item-slug { font-size: 12px; color: var(--gray-400); font-family: monospace; }
|
||
.item-badges {
|
||
display: flex; gap: 4px;
|
||
.dot-badge {
|
||
width: 6px; height: 6px; border-radius: 50%;
|
||
&.blue { background-color: #3b82f6; }
|
||
&.green { background-color: #22c55e; }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/* 右侧面板 */
|
||
.main-panel {
|
||
flex: 1;
|
||
min-width: 0;
|
||
min-height: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
background-color: var(--gray-0);
|
||
|
||
.unselected-state {
|
||
flex: 1;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background-color: @bg-secondary;
|
||
.hint-box { text-align: center; p { margin-top: 12px; color: var(--gray-400); } }
|
||
}
|
||
|
||
.panel-top-bar {
|
||
padding: 10px 16px 0 16px;
|
||
box-sizing: border-box;
|
||
// border-bottom: 1px solid @border-color;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
flex-shrink: 0;
|
||
|
||
.skill-summary {
|
||
min-height: 32px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
justify-content: center;
|
||
h2 { margin: 0; font-size: 18px; font-weight: 600; line-height: 1.2; }
|
||
code { font-size: 12px; color: var(--gray-500); background: @bg-secondary; padding: 2px 6px; border-radius: 4px; margin-top: 4px; display: inline-block; }
|
||
}
|
||
}
|
||
}
|
||
|
||
/* Tabs & 内部布局 */
|
||
.tabs-area {
|
||
flex: 1;
|
||
min-height: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
|
||
.minimal-tabs {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-height: 0;
|
||
|
||
:deep(.ant-tabs-nav) {
|
||
margin: 0;
|
||
padding: 0 16px;
|
||
border-bottom: 1px solid @border-color;
|
||
flex-shrink: 0;
|
||
&::before { border-bottom: none; }
|
||
}
|
||
:deep(.ant-tabs-content) { flex: 1; min-height: 0; height: 100%; overflow: hidden; }
|
||
:deep(.ant-tabs-tabpane) { height: 100%; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
|
||
}
|
||
|
||
.tab-title { display: flex; align-items: center; gap: 8px; font-size: 14px; }
|
||
}
|
||
|
||
.workspace {
|
||
display: flex;
|
||
flex: 1;
|
||
min-height: 0;
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* 文件树 */
|
||
.tree-container {
|
||
width: 240px;
|
||
border-right: 1px solid @border-color;
|
||
background-color: @bg-secondary;
|
||
display: flex;
|
||
flex-direction: column;
|
||
flex-shrink: 0;
|
||
|
||
.tree-header {
|
||
padding: 10px 16px;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
border-bottom: 1px solid @border-color;
|
||
background-color: var(--gray-50);
|
||
.label { font-size: 11px; font-weight: 600; color: var(--gray-500); text-transform: uppercase; letter-spacing: 0.5px; }
|
||
.tree-actions {
|
||
display: flex; gap: 4px;
|
||
button {
|
||
background: none; border: none; padding: 2px; cursor: pointer; color: var(--gray-500);
|
||
display: flex; align-items: center;
|
||
&:hover { color: var(--gray-900); }
|
||
}
|
||
}
|
||
}
|
||
|
||
.tree-content {
|
||
flex: 1; overflow-y: auto; padding: 8px;
|
||
}
|
||
}
|
||
|
||
/* 编辑器 */
|
||
.editor-container {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-width: 0;
|
||
min-height: 0;
|
||
|
||
.editor-header {
|
||
padding: 8px 16px;
|
||
border-bottom: 1px solid @border-color;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
background-color: var(--gray-0);
|
||
flex-shrink: 0;
|
||
|
||
.current-path {
|
||
display: flex; align-items: center; gap: 8px; font-family: monospace; font-size: 12px; color: var(--gray-500);
|
||
.save-hint { color: #f59e0b; font-size: 10px; margin-left: 4px; }
|
||
}
|
||
}
|
||
|
||
.editor-main {
|
||
flex: 1;
|
||
min-height: 0;
|
||
background-color: var(--gray-0);
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.editor-main :deep(.ant-empty) {
|
||
flex: 1;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.editor-main :deep(textarea) {
|
||
flex: 1;
|
||
min-height: 0;
|
||
}
|
||
|
||
.pure-editor {
|
||
width: 100%; height: 100%; border: none; resize: none; padding: 20px;
|
||
font-family: 'Fira Code', 'Monaco', monospace; font-size: 13px; line-height: 1.6;
|
||
&:focus { outline: none; }
|
||
}
|
||
}
|
||
|
||
/* 依赖配置 */
|
||
.config-view {
|
||
padding: 32px; flex: 1; overflow-y: auto;
|
||
.config-header {
|
||
display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 32px;
|
||
flex-shrink: 0;
|
||
.text {
|
||
h3 { margin: 0 0 4px 0; font-size: 16px; font-weight: 600; }
|
||
p { margin: 0; color: var(--gray-500); font-size: 13px; }
|
||
}
|
||
}
|
||
.config-form { max-width: 600px; :deep(.ant-form-item-label label) { font-weight: 500; font-size: 13px; } }
|
||
}
|
||
|
||
/* 辅助类 */
|
||
.text-muted { color: var(--gray-400); }
|
||
.mt-40 { margin-top: 40px; }
|
||
.pt-12 { padding-top: 12px; }
|
||
|
||
@media (max-width: 1000px) {
|
||
.sidebar-list { width: 220px; }
|
||
.tree-container { width: 180px; }
|
||
}
|
||
</style>
|