700 lines
22 KiB
Vue
700 lines
22 KiB
Vue
<script setup lang="ts">
|
||
import { ref, onMounted, computed } from 'vue'
|
||
import { useRoute } from 'vue-router'
|
||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||
import apiClient from '@/api/client'
|
||
import { copyToClipboard } from '@/utils/clipboard'
|
||
|
||
const route = useRoute()
|
||
const kbId = route.params.id as string
|
||
|
||
const isMobile = ref(window.innerWidth <= 768)
|
||
const showMobileSidebar = ref(false)
|
||
|
||
window.addEventListener('resize', () => {
|
||
isMobile.value = window.innerWidth <= 768
|
||
if (!isMobile.value) showMobileSidebar.value = false
|
||
})
|
||
|
||
const kb = ref<any>(null)
|
||
const docs = ref<any[]>([])
|
||
const categories = ref<any[]>([])
|
||
const allCategoriesFlat = ref<any[]>([])
|
||
const loading = ref(false)
|
||
const uploading = ref(false)
|
||
const aiUrl = ref('')
|
||
const selectedCategoryId = ref<string | null>(null)
|
||
const selectedCategoryPath = ref<string | null>(null)
|
||
|
||
// 文本内容对话框
|
||
const showTextDialog = ref(false)
|
||
const textForm = ref({
|
||
title: '',
|
||
content: '',
|
||
content_format: 'markdown',
|
||
category_id: null as string | null,
|
||
})
|
||
const textLoading = ref(false)
|
||
|
||
// 目录管理
|
||
const showCatDialog = ref(false)
|
||
const catForm = ref({ name: '', parent_id: null as string | null, is_folder: true })
|
||
const catLoading = ref(false)
|
||
const editingCatId = ref<string | null>(null)
|
||
|
||
// 编辑文档分类
|
||
const editingDocId = ref<string | null>(null)
|
||
|
||
onMounted(async () => {
|
||
await loadKb()
|
||
await loadCategories()
|
||
await loadDocs()
|
||
await loadLink()
|
||
})
|
||
|
||
async function loadKb() {
|
||
try {
|
||
const { data } = await apiClient.get(`/knowledge-bases/${kbId}`)
|
||
kb.value = data
|
||
} catch { /* ignore */ }
|
||
}
|
||
|
||
async function loadCategories() {
|
||
try {
|
||
const { data } = await apiClient.get(`/knowledge-bases/${kbId}/categories/tree`)
|
||
categories.value = data
|
||
// 同时加载平铺列表
|
||
const { data: flat } = await apiClient.get(`/knowledge-bases/${kbId}/categories`)
|
||
allCategoriesFlat.value = flat
|
||
} catch { /* ignore */ }
|
||
}
|
||
|
||
async function loadDocs() {
|
||
loading.value = true
|
||
try {
|
||
let url = `/documents?kb_id=${kbId}`
|
||
if (selectedCategoryId.value) {
|
||
url += `&category_id=${selectedCategoryId.value}`
|
||
}
|
||
const { data } = await apiClient.get(url)
|
||
docs.value = data.items
|
||
} catch { /* ignore */ }
|
||
loading.value = false
|
||
}
|
||
|
||
// 链接有效期
|
||
const expiryOptions = [
|
||
{ label: '10 分钟', value: 10 },
|
||
{ label: '30 分钟', value: 30 },
|
||
{ label: '1 小时', value: 60 },
|
||
{ label: '3 小时', value: 180 },
|
||
{ label: '24 小时', value: 1440 },
|
||
{ label: '长期有效', value: 0 },
|
||
]
|
||
const linkExpiry = ref<{ expires_at: string | null; is_expired: boolean }>({
|
||
expires_at: null,
|
||
is_expired: false,
|
||
})
|
||
const expiryText = computed(() => {
|
||
if (linkExpiry.value.is_expired) return '⛔ 已失效'
|
||
if (!linkExpiry.value.expires_at) return '♾️ 长期有效'
|
||
return `⏱️ 有效期至 ${linkExpiry.value.expires_at}`
|
||
})
|
||
|
||
async function loadLink() {
|
||
try {
|
||
const { data } = await apiClient.get(`/knowledge-bases/${kbId}/link`)
|
||
aiUrl.value = `${window.location.origin}/k/${data.token}`
|
||
linkExpiry.value = {
|
||
expires_at: data.expires_at,
|
||
is_expired: data.is_expired,
|
||
}
|
||
} catch { /* ignore */ }
|
||
}
|
||
|
||
// 设置链接有效期(0 = 长期有效,需二次确认)
|
||
async function handleExpiryChange(val: number) {
|
||
if (val === 0) {
|
||
try {
|
||
await ElMessageBox.confirm(
|
||
'长期有效的链接一旦泄露,任何持有该链接的人都可永久访问此知识库,存在泄露风险。确定设为长期有效?',
|
||
'⚠️ 泄露风险提示',
|
||
{ type: 'warning', confirmButtonText: '仍要设为长期有效', cancelButtonText: '取消' },
|
||
)
|
||
} catch {
|
||
return // 用户取消
|
||
}
|
||
}
|
||
try {
|
||
await apiClient.post(`/knowledge-bases/${kbId}/set-expiry`, {
|
||
expires_in_minutes: val === 0 ? null : val,
|
||
})
|
||
ElMessage.success('链接有效期已更新。')
|
||
loadLink()
|
||
} catch { /* ignore */ }
|
||
}
|
||
|
||
function selectCategory(cat: any) {
|
||
selectedCategoryId.value = cat.id
|
||
selectedCategoryPath.value = cat.path
|
||
loadDocs()
|
||
}
|
||
|
||
function clearCategoryFilter() {
|
||
selectedCategoryId.value = null
|
||
selectedCategoryPath.value = null
|
||
loadDocs()
|
||
}
|
||
|
||
// 上传文档
|
||
async function handleUpload(options: any) {
|
||
uploading.value = true
|
||
try {
|
||
const formData = new FormData()
|
||
formData.append('kb_id', kbId)
|
||
formData.append('file', options.file)
|
||
if (selectedCategoryId.value) {
|
||
formData.append('category_id', selectedCategoryId.value)
|
||
}
|
||
await apiClient.post('/documents/upload', formData, {
|
||
headers: { 'Content-Type': 'multipart/form-data' },
|
||
})
|
||
ElMessage.success('文档上传成功!')
|
||
loadDocs()
|
||
loadCategories()
|
||
} catch { /* ignore */ }
|
||
uploading.value = false
|
||
}
|
||
|
||
// 打开添加文本对话框(预填左侧选中的目录,对话框内的选择优先)
|
||
function openTextDialog() {
|
||
textForm.value.category_id = selectedCategoryId.value
|
||
showTextDialog.value = true
|
||
}
|
||
|
||
// 创建文本内容
|
||
async function handleCreateText() {
|
||
if (!textForm.value.title.trim() || !textForm.value.content.trim()) {
|
||
ElMessage.warning('请输入标题和内容。')
|
||
return
|
||
}
|
||
textLoading.value = true
|
||
try {
|
||
await apiClient.post('/documents/create-text', {
|
||
kb_id: kbId,
|
||
title: textForm.value.title,
|
||
content: textForm.value.content,
|
||
content_format: textForm.value.content_format,
|
||
category_id: textForm.value.category_id,
|
||
})
|
||
ElMessage.success('文本内容已创建!')
|
||
showTextDialog.value = false
|
||
textForm.value = { title: '', content: '', content_format: 'markdown', category_id: null }
|
||
loadDocs()
|
||
loadCategories()
|
||
} catch { /* ignore */ }
|
||
textLoading.value = false
|
||
}
|
||
|
||
// 修改文档分类
|
||
async function handleChangeDocCategory(doc: any, newCategoryId: string) {
|
||
try {
|
||
await apiClient.put(`/documents/${doc.id}`, { category_id: newCategoryId })
|
||
ElMessage.success('目录已更新。')
|
||
loadDocs()
|
||
loadCategories()
|
||
} catch { /* ignore */ }
|
||
}
|
||
|
||
async function handleDeleteDoc(doc: any) {
|
||
try {
|
||
await ElMessageBox.confirm(`确定删除「${doc.original_filename}」?`, '确认删除', { type: 'warning' })
|
||
await apiClient.delete(`/documents/${doc.id}`)
|
||
ElMessage.success('已删除。')
|
||
loadDocs()
|
||
loadCategories()
|
||
} catch { /* ignore */ }
|
||
}
|
||
|
||
async function handleReprocess(doc: any) {
|
||
try {
|
||
await apiClient.post(`/documents/${doc.id}/reprocess`)
|
||
ElMessage.success('重新解析已提交。')
|
||
loadDocs()
|
||
} catch { /* ignore */ }
|
||
}
|
||
|
||
async function handleCopyLink() {
|
||
const success = await copyToClipboard(aiUrl.value)
|
||
if (success) {
|
||
ElMessage.success('AI 链接已复制!')
|
||
} else {
|
||
ElMessage.error('复制失败,请手动长按复制。')
|
||
}
|
||
}
|
||
|
||
// 重新生成 AI 链接
|
||
async function handleRegenerateLink() {
|
||
try {
|
||
await ElMessageBox.confirm('重新生成链接后,旧链接将立即失效。确定继续?', '确认', { type: 'warning' })
|
||
const { data } = await apiClient.post(`/knowledge-bases/${kbId}/regenerate-token`)
|
||
aiUrl.value = `${window.location.origin}/k/${data.token}`
|
||
ElMessage.success('链接已重新生成!')
|
||
} catch { /* ignore */ }
|
||
}
|
||
|
||
// 目录管理
|
||
function openAddCategory(parentId: string | null = null) {
|
||
editingCatId.value = null
|
||
catForm.value = { name: '', parent_id: parentId, is_folder: true }
|
||
showCatDialog.value = true
|
||
}
|
||
|
||
function openEditCategory(cat: any) {
|
||
editingCatId.value = cat.id
|
||
catForm.value = { name: cat.name, parent_id: cat.parent_id || null, is_folder: cat.is_folder }
|
||
showCatDialog.value = true
|
||
}
|
||
|
||
async function handleSaveCategory() {
|
||
if (!catForm.value.name.trim()) {
|
||
ElMessage.warning('请输入目录名称。')
|
||
return
|
||
}
|
||
catLoading.value = true
|
||
try {
|
||
if (editingCatId.value) {
|
||
await apiClient.put(`/knowledge-bases/${kbId}/categories/${editingCatId.value}`, catForm.value)
|
||
ElMessage.success('目录已更新。')
|
||
} else {
|
||
await apiClient.post(`/knowledge-bases/${kbId}/categories`, catForm.value)
|
||
ElMessage.success('目录已创建。')
|
||
}
|
||
showCatDialog.value = false
|
||
loadCategories()
|
||
} catch { /* ignore */ }
|
||
catLoading.value = false
|
||
}
|
||
|
||
async function handleDeleteCategory(cat: any) {
|
||
try {
|
||
await ElMessageBox.confirm(`确定删除目录「${cat.name}」及其所有子目录?`, '确认删除', { type: 'warning' })
|
||
await apiClient.delete(`/knowledge-bases/${kbId}/categories/${cat.id}`)
|
||
ElMessage.success('已删除。')
|
||
if (selectedCategoryId.value === cat.id) {
|
||
clearCategoryFilter()
|
||
}
|
||
loadCategories()
|
||
loadDocs()
|
||
} catch { /* ignore */ }
|
||
}
|
||
|
||
function formatSize(bytes: number) {
|
||
if (bytes < 1024) return bytes + ' B'
|
||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
||
}
|
||
|
||
function statusType(status: string) {
|
||
if (status === 'READY') return 'success'
|
||
if (status === 'FAILED') return 'danger'
|
||
return 'warning'
|
||
}
|
||
|
||
// 获取分类名称
|
||
function getCategoryName(catId: string) {
|
||
const cat = allCategoriesFlat.value.find((c: any) => c.id === catId)
|
||
return cat ? cat.name : '未分类'
|
||
}
|
||
|
||
// AI 链接打码:显示域名,token 部分用 • 代替
|
||
const maskedAiUrl = computed(() => {
|
||
if (!aiUrl.value) return '尚未生成'
|
||
const idx = aiUrl.value.indexOf('/k/')
|
||
if (idx === -1) return '••••••••'
|
||
const prefix = aiUrl.value.slice(0, idx + 3)
|
||
const tokenLen = aiUrl.value.length - idx - 3
|
||
return prefix + '•'.repeat(tokenLen)
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div v-if="kb">
|
||
<!-- 手机端:顶部操作栏 -->
|
||
<div class="mobile-header">
|
||
<h1 style="margin: 0; font-size: 20px">{{ kb.name }}</h1>
|
||
<div style="display: flex; gap: 8px; margin-top: 10px">
|
||
<el-button @click="handleCopyLink" size="small" style="flex: 1">📋 复制链接</el-button>
|
||
<el-button type="warning" @click="handleRegenerateLink" size="small" style="flex: 1">🔄 重置链接</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="main-layout">
|
||
<!-- 左侧:目录树(桌面端常驻,手机端抽屉) -->
|
||
<aside class="sidebar" :class="{ 'mobile-show': showMobileSidebar }">
|
||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px">
|
||
<h3 style="margin: 0; font-size: 16px">📁 目录</h3>
|
||
<el-button size="small" type="primary" @click="openAddCategory(null)">+ 新建</el-button>
|
||
</div>
|
||
|
||
<div
|
||
class="cat-item"
|
||
:class="{ active: !selectedCategoryId }"
|
||
@click="clearCategoryFilter"
|
||
>
|
||
📋 全部文档
|
||
</div>
|
||
|
||
<el-tree :data="categories" node-key="id" default-expand-all :expand-on-click-node="false">
|
||
<template #default="{ data }">
|
||
<div class="tree-node">
|
||
<span class="tree-label" :class="{ selected: selectedCategoryId === data.id }" @click="selectCategory(data)">
|
||
{{ data.is_folder ? '📁' : '📄' }} {{ data.name }}
|
||
<span v-if="data.doc_count > 0" style="color: #999; font-size: 12px">({{ data.doc_count }})</span>
|
||
</span>
|
||
<span class="tree-actions">
|
||
<el-button size="small" text @click.stop="openAddCategory(data.id)">+</el-button>
|
||
<el-button size="small" text @click.stop="openEditCategory(data)">✎</el-button>
|
||
<el-button size="small" text type="danger" @click.stop="handleDeleteCategory(data)">×</el-button>
|
||
</span>
|
||
</div>
|
||
</template>
|
||
</el-tree>
|
||
</aside>
|
||
|
||
<!-- 手机端遮罩 -->
|
||
<div v-if="showMobileSidebar" class="mobile-overlay" @click="showMobileSidebar = false"></div>
|
||
|
||
<!-- 右侧:文档列表 -->
|
||
<main class="content">
|
||
<!-- 桌面端标题 -->
|
||
<div class="desktop-header">
|
||
<h1 style="margin: 0; font-size: 24px">{{ kb.name }}</h1>
|
||
<div style="display: flex; gap: 12px">
|
||
<el-button @click="handleCopyLink" size="large">📋 复制 AI 链接</el-button>
|
||
<el-button type="warning" @click="handleRegenerateLink" size="large">🔄 重新生成链接</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- AI 链接(打码显示,只能复制获取) -->
|
||
<el-card style="margin-bottom: 16px" shadow="hover">
|
||
<div style="display: flex; align-items: center; gap: 10px; flex-wrap: wrap">
|
||
<span style="font-weight: bold; font-size: 14px">AI 链接:</span>
|
||
<span style="flex: 1; min-width: 200px; font-size: 14px; color: #666; font-family: monospace; user-select: none">
|
||
{{ maskedAiUrl }}
|
||
</span>
|
||
<el-select
|
||
:model-value="null"
|
||
placeholder="⏱️ 设置有效期"
|
||
style="width: 150px"
|
||
@change="(val: number) => handleExpiryChange(val)"
|
||
>
|
||
<el-option
|
||
v-for="opt in expiryOptions"
|
||
:key="opt.value"
|
||
:label="opt.label"
|
||
:value="opt.value"
|
||
/>
|
||
</el-select>
|
||
<el-button type="primary" @click="handleCopyLink" size="large">📋 复制 AI 链接</el-button>
|
||
</div>
|
||
<div style="display: flex; justify-content: space-between; flex-wrap: wrap; gap: 8px; margin-top: 8px; font-size: 12px">
|
||
<span :style="{ color: linkExpiry.is_expired ? '#f56c6c' : '#67c23a', fontWeight: linkExpiry.is_expired ? 'bold' : 'normal' }">
|
||
{{ expiryText }}
|
||
</span>
|
||
<span style="color: #999">🔒 链接即访问凭证,已隐藏显示;仅可通过复制按钮获取。请勿公开传播。</span>
|
||
</div>
|
||
</el-card>
|
||
|
||
<!-- 手机端目录按钮 + 操作按钮 -->
|
||
<div class="mobile-actions">
|
||
<el-button @click="showMobileSidebar = true" style="flex: 1">📁 目录</el-button>
|
||
<el-button @click="openTextDialog" style="flex: 1">✏️ 添加文本</el-button>
|
||
<el-upload :show-file-list="false" :http-request="handleUpload" accept=".docx,.pdf" style="flex: 1">
|
||
<el-button type="primary" :loading="uploading" style="width: 100%">📤 上传</el-button>
|
||
</el-upload>
|
||
</div>
|
||
|
||
<el-card shadow="hover">
|
||
<template #header>
|
||
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px">
|
||
<span style="font-size: 16px; font-weight: bold">
|
||
📄 文档列表
|
||
<el-tag v-if="selectedCategoryPath" closable @close="clearCategoryFilter" style="margin-left: 8px">
|
||
{{ selectedCategoryPath }}
|
||
</el-tag>
|
||
</span>
|
||
<div class="desktop-actions">
|
||
<el-button @click="openTextDialog">✏️ 添加文本</el-button>
|
||
<el-upload :show-file-list="false" :http-request="handleUpload" accept=".docx,.pdf">
|
||
<el-button type="primary" :loading="uploading">📤 上传文档</el-button>
|
||
</el-upload>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- 桌面端:表格 -->
|
||
<div class="desktop-table">
|
||
<el-table :data="docs" v-loading="loading" style="width: 100%">
|
||
<el-table-column prop="original_filename" label="标题" min-width="180" show-overflow-tooltip />
|
||
<el-table-column label="目录" width="180">
|
||
<template #default="{ row }">
|
||
<el-select :model-value="row.category_id" @change="(val: string) => handleChangeDocCategory(row, val)" placeholder="选择目录" size="small" style="width: 100%">
|
||
<el-option v-for="cat in allCategoriesFlat" :key="cat.id" :label="cat.name" :value="cat.id" />
|
||
</el-select>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="状态" width="90" align="center">
|
||
<template #default="{ row }">
|
||
<el-tag :type="statusType(row.status)" size="small">{{ row.status }}</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="大小" width="80" align="center">
|
||
<template #default="{ row }">{{ formatSize(row.file_size) }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" width="180" align="center">
|
||
<template #default="{ row }">
|
||
<el-button size="small" @click="handleReprocess(row)">重新解析</el-button>
|
||
<el-button size="small" type="danger" @click="handleDeleteDoc(row)">删除</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</div>
|
||
|
||
<!-- 手机端:卡片列表 -->
|
||
<div class="mobile-cards">
|
||
<div v-for="doc in docs" :key="doc.id" class="doc-card">
|
||
<div style="font-weight: bold; font-size: 15px; margin-bottom: 8px">{{ doc.original_filename }}</div>
|
||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px; flex-wrap: wrap">
|
||
<el-tag :type="statusType(doc.status)" size="small">{{ doc.status }}</el-tag>
|
||
<span style="color: #999; font-size: 12px">{{ formatSize(doc.file_size) }}</span>
|
||
</div>
|
||
<el-select :model-value="doc.category_id" @change="(val: string) => handleChangeDocCategory(doc, val)" placeholder="选择目录" size="small" style="width: 100%; margin-bottom: 8px">
|
||
<el-option v-for="cat in allCategoriesFlat" :key="cat.id" :label="cat.name" :value="cat.id" />
|
||
</el-select>
|
||
<div style="display: flex; gap: 8px">
|
||
<el-button size="small" style="flex: 1" @click="handleReprocess(doc)">重新解析</el-button>
|
||
<el-button size="small" type="danger" style="flex: 1" @click="handleDeleteDoc(doc)">删除</el-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</el-card>
|
||
</main>
|
||
</div>
|
||
|
||
<!-- 对话框 -->
|
||
<el-dialog v-model="showTextDialog" title="✏️ 添加文本内容" :width="isMobile ? '95%' : '700px'">
|
||
<el-form :model="textForm" label-position="top">
|
||
<el-form-item label="标题" required>
|
||
<el-input v-model="textForm.title" placeholder="文档标题" size="large" />
|
||
</el-form-item>
|
||
<el-form-item label="所属目录">
|
||
<el-select v-model="textForm.category_id" placeholder="选择目录" clearable style="width: 100%" size="large">
|
||
<el-option v-for="cat in allCategoriesFlat" :key="cat.id" :label="cat.name" :value="cat.id" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="格式">
|
||
<el-radio-group v-model="textForm.content_format" size="large">
|
||
<el-radio value="markdown">Markdown</el-radio>
|
||
<el-radio value="text">纯文本</el-radio>
|
||
</el-radio-group>
|
||
</el-form-item>
|
||
<el-form-item label="内容" required>
|
||
<el-input v-model="textForm.content" type="textarea" :rows="12" placeholder="输入文本内容(支持 Markdown)" size="large" />
|
||
</el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="showTextDialog = false" size="large">取消</el-button>
|
||
<el-button type="primary" :loading="textLoading" @click="handleCreateText" size="large">创建</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<el-dialog v-model="showCatDialog" :title="editingCatId ? '✏️ 编辑目录' : '📁 新建目录'" :width="isMobile ? '95%' : '450px'">
|
||
<el-form :model="catForm" label-position="top">
|
||
<el-form-item label="目录名称" required>
|
||
<el-input v-model="catForm.name" placeholder="如:公司基本信息" size="large" />
|
||
</el-form-item>
|
||
<el-form-item label="类型">
|
||
<el-radio-group v-model="catForm.is_folder" size="large">
|
||
<el-radio :value="true">📁 文件夹</el-radio>
|
||
<el-radio :value="false">📄 叶子分类</el-radio>
|
||
</el-radio-group>
|
||
</el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="showCatDialog = false" size="large">取消</el-button>
|
||
<el-button type="primary" :loading="catLoading" @click="handleSaveCategory" size="large">{{ editingCatId ? '保存' : '创建' }}</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.main-layout {
|
||
display: flex;
|
||
gap: 20px;
|
||
}
|
||
|
||
.sidebar {
|
||
width: 280px;
|
||
flex-shrink: 0;
|
||
overflow-y: auto;
|
||
max-height: calc(100vh - 180px);
|
||
position: sticky;
|
||
top: 20px;
|
||
}
|
||
|
||
.content {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.cat-item {
|
||
padding: 10px 12px;
|
||
cursor: pointer;
|
||
border-radius: 6px;
|
||
margin-bottom: 6px;
|
||
font-size: 14px;
|
||
background: #f5f7fa;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.cat-item:hover {
|
||
background: #e8e8e8;
|
||
}
|
||
|
||
.cat-item.active {
|
||
background: #ecf5ff;
|
||
color: #409eff;
|
||
font-weight: bold;
|
||
}
|
||
|
||
.tree-node {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
width: 100%;
|
||
padding: 4px 0;
|
||
}
|
||
|
||
.tree-label {
|
||
cursor: pointer;
|
||
font-size: 14px;
|
||
flex: 1;
|
||
}
|
||
|
||
.tree-label.selected {
|
||
color: #409eff;
|
||
font-weight: bold;
|
||
}
|
||
|
||
.tree-actions {
|
||
display: flex;
|
||
gap: 2px;
|
||
}
|
||
|
||
.mobile-header {
|
||
display: none;
|
||
}
|
||
|
||
.mobile-actions {
|
||
display: none;
|
||
}
|
||
|
||
.mobile-cards {
|
||
display: none;
|
||
}
|
||
|
||
.desktop-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.desktop-actions {
|
||
display: flex;
|
||
gap: 10px;
|
||
}
|
||
|
||
.mobile-overlay {
|
||
display: none;
|
||
}
|
||
|
||
/* 手机端适配 */
|
||
@media (max-width: 768px) {
|
||
.main-layout {
|
||
flex-direction: column;
|
||
}
|
||
|
||
.sidebar {
|
||
display: none;
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
width: 80%;
|
||
max-width: 300px;
|
||
height: 100vh;
|
||
background: #fff;
|
||
z-index: 1000;
|
||
padding: 20px;
|
||
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.2);
|
||
overflow-y: auto;
|
||
max-height: 100vh;
|
||
}
|
||
|
||
.sidebar.mobile-show {
|
||
display: block;
|
||
}
|
||
|
||
.mobile-overlay {
|
||
display: block;
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background: rgba(0, 0, 0, 0.5);
|
||
z-index: 999;
|
||
}
|
||
|
||
.content {
|
||
width: 100%;
|
||
}
|
||
|
||
.mobile-header {
|
||
display: block;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.desktop-header {
|
||
display: none;
|
||
}
|
||
|
||
.mobile-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.desktop-actions {
|
||
display: none;
|
||
}
|
||
|
||
.desktop-table {
|
||
display: none;
|
||
}
|
||
|
||
.mobile-cards {
|
||
display: block;
|
||
}
|
||
|
||
.doc-card {
|
||
border: 1px solid #eee;
|
||
border-radius: 8px;
|
||
padding: 12px;
|
||
margin-bottom: 12px;
|
||
}
|
||
}
|
||
</style>
|