| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655 |
- <script setup>
- import { ref, onMounted, onUnmounted } from 'vue'
- import { useRouter } from 'vue-router'
- import { NButton, NIcon, NSpin, NEmpty, useDialog, useMessage } from 'naive-ui'
- import { AddOutline, ReloadOutline, SearchOutline } from '@vicons/ionicons5'
- import { useSkillStore } from '../stores/skill'
- import { getCategoryTree, createCategory, updateCategory, deleteCategory as deleteCategoryApi } from '../api/category'
- import { setSkillCategory } from '../api/skill'
- import SkillCard from '../components/skill/SkillCard.vue'
- import SkillUpload from '../components/skill/SkillUpload.vue'
- import TagSelector from '../components/tag/TagSelector.vue'
- import CategoryTree from '../components/skill/CategoryTree.vue'
- const router = useRouter()
- const skillStore = useSkillStore()
- const dialog = useDialog()
- const message = useMessage()
- const showUpload = ref(false)
- const showTagSelector = ref(false)
- const taggingSkill = ref(null)
- // 分类树相关
- const categories = ref([])
- const expandedIds = ref(new Set())
- // 分类树内联编辑状态
- const editingId = ref(null)
- const editingName = ref('')
- const addingParentId = ref(undefined) // undefined = 未激活,null = 添加根分类,数字 = 添加子分类
- const addingName = ref('')
- onMounted(() => {
- skillStore.init()
- loadCategories()
- })
- onUnmounted(() => {
- skillStore.destroy()
- })
- // ==================== 分类树操作 ====================
- async function loadCategories() {
- try {
- const res = await getCategoryTree()
- categories.value = res.data || []
- } catch (err) {
- console.error('加载分类失败', err)
- }
- }
- function toggleExpand(id) {
- if (expandedIds.value.has(id)) {
- expandedIds.value.delete(id)
- } else {
- expandedIds.value.add(id)
- }
- }
- function selectCategory(categoryId) {
- if (skillStore.selectedCategoryId === categoryId) {
- skillStore.setCategory(null)
- } else {
- skillStore.setCategory(categoryId)
- }
- }
- function startAdd(parentId) {
- addingParentId.value = parentId
- addingName.value = ''
- }
- function cancelAdd() {
- addingParentId.value = undefined
- addingName.value = ''
- }
- async function confirmAdd() {
- if (!addingName.value.trim()) return
- try {
- await createCategory({ parentId: addingParentId.value, name: addingName.value.trim() })
- await loadCategories()
- if (addingParentId.value != null) {
- expandedIds.value.add(addingParentId.value)
- }
- cancelAdd()
- } catch (err) {
- message.error(err.message || '创建失败')
- }
- }
- function startEdit({ id, name }) {
- editingId.value = id
- editingName.value = name
- }
- function cancelEdit() {
- editingId.value = null
- editingName.value = ''
- }
- async function confirmEdit() {
- if (!editingName.value.trim()) return
- try {
- await updateCategory(editingId.value, { name: editingName.value.trim() })
- await loadCategories()
- cancelEdit()
- } catch (err) {
- message.error(err.message || '更新失败')
- }
- }
- function handleDeleteCategory({ id, name }) {
- dialog.warning({
- title: '确认删除',
- content: `确定要删除分类「${name}」吗?该分类下的技能将变为未分类。`,
- positiveText: '确认删除',
- negativeText: '取消',
- onPositiveClick: async () => {
- try {
- await deleteCategoryApi(id)
- await loadCategories()
- if (skillStore.selectedCategoryId === id) {
- skillStore.setCategory(null)
- }
- message.success('删除成功')
- } catch (err) {
- message.error(err.message || '删除失败')
- }
- }
- })
- }
- // ==================== 技能操作 ====================
- function handleRefresh() {
- skillStore.fetchSkills()
- loadCategories()
- }
- async function handleUpload(file) {
- try {
- await skillStore.createSkill(file)
- showUpload.value = false
- loadCategories()
- message.success('技能上传成功')
- } catch (err) {
- message.error(err.message || '上传失败')
- }
- }
- function handleEdit(skill) {
- router.push(`/skill/edit/${skill.folderName}`)
- }
- function handleDelete(skill) {
- dialog.warning({
- title: '确认删除',
- content: `确定要删除技能「${skill.translatedName || skill.name}」吗?此操作不可恢复。`,
- positiveText: '确认删除',
- negativeText: '取消',
- onPositiveClick: async () => {
- try {
- await skillStore.removeSkill(skill.folderName)
- loadCategories()
- message.success('删除成功')
- } catch (err) {
- message.error(err.message || '删除失败')
- }
- }
- })
- }
- function handleTag(skill) {
- taggingSkill.value = skill
- showTagSelector.value = true
- }
- async function onTagsSaved() {
- await skillStore.fetchSkills()
- message.success('标签已更新')
- }
- // 搜索防抖
- let searchTimer = null
- function onSearchInput(value) {
- clearTimeout(searchTimer)
- searchTimer = setTimeout(() => {
- skillStore.setSearch(value)
- }, 300)
- }
- </script>
- <template>
- <div class="skill-management">
- <div class="main-layout">
- <!-- 左侧分类树 -->
- <div class="sidebar">
- <div class="sidebar-header">
- <span class="sidebar-title">分类</span>
- <button class="icon-btn" @click="startAdd(null)" title="新建根分类">
- <n-icon size="16"><AddOutline /></n-icon>
- </button>
- </div>
- <div class="category-list">
- <!-- 全部 -->
- <div
- class="all-item"
- :class="{ active: !skillStore.selectedCategoryId }"
- @click="selectCategory(null)"
- >
- <span class="cat-name">全部技能</span>
- <span class="cat-count">{{ skillStore.total }}</span>
- </div>
- <!-- 递归分类树 -->
- <CategoryTree
- :nodes="categories"
- :level="0"
- :selected-id="skillStore.selectedCategoryId"
- :expanded-ids="expandedIds"
- :editing-id="editingId"
- v-model:editing-name="editingName"
- :adding-parent-id="addingParentId"
- v-model:adding-name="addingName"
- @select="selectCategory"
- @toggle="toggleExpand"
- @add="startAdd"
- @delete="handleDeleteCategory"
- @edit-start="startEdit"
- @edit-confirm="confirmEdit"
- @edit-cancel="cancelEdit"
- @add-confirm="confirmAdd"
- @add-cancel="cancelAdd"
- />
- <!-- 根分类添加输入框 -->
- <div v-if="addingParentId === null" class="root-adding">
- <input
- class="inline-input"
- v-model="addingName"
- placeholder="输入分类名称"
- @keyup.enter="confirmAdd"
- @keyup.escape="cancelAdd"
- @blur="cancelAdd"
- autofocus
- />
- </div>
- </div>
- </div>
- <!-- 右侧内容 -->
- <div class="content">
- <!-- 工具栏 -->
- <div class="toolbar">
- <div class="toolbar-left">
- <div class="search-box">
- <n-icon size="16" class="search-icon"><SearchOutline /></n-icon>
- <input
- class="search-input"
- :value="skillStore.searchKeyword"
- @input="onSearchInput($event.target.value)"
- placeholder="搜索技能名称或描述..."
- />
- </div>
- <span class="skill-count">
- 共 <span class="count-num">{{ skillStore.total }}</span> 个技能
- </span>
- </div>
- <div class="toolbar-right">
- <n-button quaternary @click="handleRefresh" :loading="skillStore.loading">
- <template #icon>
- <n-icon><ReloadOutline /></n-icon>
- </template>
- 刷新
- </n-button>
- <n-button type="primary" @click="showUpload = true">
- <template #icon>
- <n-icon><AddOutline /></n-icon>
- </template>
- 新增技能
- </n-button>
- </div>
- </div>
- <!-- 加载状态 -->
- <div v-if="skillStore.loading && skillStore.skills.length === 0" class="loading-area">
- <n-spin size="large" />
- <p class="loading-text">正在加载技能列表...</p>
- </div>
- <!-- 空状态 -->
- <div v-else-if="skillStore.skills.length === 0" class="empty-area">
- <div class="empty-illustration">
- <svg width="120" height="120" viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
- <rect x="20" y="30" width="80" height="60" rx="8" stroke="rgba(56, 189, 248, 0.15)" stroke-width="2" fill="none"/>
- <path d="M45 55H75" stroke="rgba(56, 189, 248, 0.2)" stroke-width="2" stroke-linecap="round"/>
- <path d="M45 65H65" stroke="rgba(56, 189, 248, 0.15)" stroke-width="2" stroke-linecap="round"/>
- <circle cx="60" cy="60" r="30" stroke="rgba(37, 99, 235, 0.1)" stroke-width="1" fill="none"/>
- </svg>
- </div>
- <p class="empty-title">暂无技能</p>
- <p class="empty-desc">点击「新增技能」上传您的第一个 Skill</p>
- <n-button type="primary" @click="showUpload = true" style="margin-top: 16px">
- <template #icon>
- <n-icon><AddOutline /></n-icon>
- </template>
- 新增技能
- </n-button>
- </div>
- <!-- 卡片网格 -->
- <div v-else class="skill-grid">
- <SkillCard
- v-for="(skill, index) in skillStore.skills"
- :key="skill.folderName"
- :skill="skill"
- :style="{ animationDelay: `${index * 60}ms` }"
- class="fade-in"
- @edit="handleEdit"
- @delete="handleDelete"
- @tag="handleTag"
- />
- </div>
- <!-- 分页 -->
- <div v-if="skillStore.totalPages > 1" class="pagination">
- <button
- class="page-btn"
- :disabled="skillStore.currentPage <= 1"
- @click="skillStore.setPage(skillStore.currentPage - 1)"
- >上一页</button>
- <template v-for="p in skillStore.totalPages" :key="p">
- <button
- v-if="p === 1 || p === skillStore.totalPages || Math.abs(p - skillStore.currentPage) <= 1"
- class="page-btn"
- :class="{ active: p === skillStore.currentPage }"
- @click="skillStore.setPage(p)"
- >{{ p }}</button>
- <span v-else-if="Math.abs(p - skillStore.currentPage) === 2" class="page-ellipsis">...</span>
- </template>
- <button
- class="page-btn"
- :disabled="skillStore.currentPage >= skillStore.totalPages"
- @click="skillStore.setPage(skillStore.currentPage + 1)"
- >下一页</button>
- </div>
- </div>
- </div>
- <!-- 上传弹窗 -->
- <SkillUpload
- v-model:show="showUpload"
- :uploading="skillStore.uploading"
- :progress="skillStore.uploadProgress"
- @upload="handleUpload"
- />
- <!-- 标签选择器 -->
- <TagSelector
- v-model:show="showTagSelector"
- entity-type="skill"
- :entity-id="taggingSkill?.folderName || ''"
- :selected-tag-ids="taggingSkill?.tags?.map(t => t.tagId) || []"
- @saved="onTagsSaved"
- />
- </div>
- </template>
- <style scoped>
- .skill-management {
- min-height: 100%;
- }
- .main-layout {
- display: flex;
- gap: 24px;
- height: 100%;
- }
- /* ==================== 左侧分类树 ==================== */
- .sidebar {
- width: 240px;
- min-width: 240px;
- background: var(--bg-card);
- border: 1px solid var(--border-color);
- border-radius: var(--radius-lg);
- padding: 16px 0;
- display: flex;
- flex-direction: column;
- overflow: hidden;
- }
- .sidebar-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 0 16px 12px;
- border-bottom: 1px solid rgba(56, 189, 248, 0.06);
- }
- .sidebar-title {
- font-size: 14px;
- font-weight: 600;
- color: var(--text-primary);
- }
- .category-list {
- flex: 1;
- overflow-y: auto;
- padding: 8px 0;
- }
- .all-item {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 6px 16px;
- cursor: pointer;
- transition: all 0.15s;
- min-height: 32px;
- }
- .all-item:hover {
- background: rgba(37, 99, 235, 0.06);
- }
- .all-item.active {
- background: rgba(37, 99, 235, 0.12);
- }
- .all-item.active .cat-name {
- color: var(--color-accent);
- font-weight: 500;
- }
- .cat-name {
- font-size: 13px;
- color: var(--text-secondary);
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- }
- .cat-count {
- font-size: 11px;
- color: var(--text-muted);
- background: rgba(100, 116, 139, 0.1);
- padding: 1px 6px;
- border-radius: 8px;
- min-width: 20px;
- text-align: center;
- }
- .icon-btn {
- background: none;
- border: none;
- color: var(--text-tertiary);
- cursor: pointer;
- padding: 4px;
- border-radius: 4px;
- display: flex;
- align-items: center;
- transition: all 0.15s;
- }
- .icon-btn:hover {
- color: var(--color-accent);
- background: rgba(37, 99, 235, 0.1);
- }
- .root-adding {
- padding: 6px 16px;
- }
- .inline-input {
- background: rgba(37, 99, 235, 0.08);
- border: 1px solid rgba(37, 99, 235, 0.2);
- border-radius: 4px;
- padding: 2px 8px;
- font-size: 13px;
- color: var(--text-primary);
- outline: none;
- width: 100%;
- font-family: inherit;
- }
- .inline-input:focus {
- border-color: var(--color-accent);
- box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.15);
- }
- /* ==================== 右侧内容 ==================== */
- .content {
- flex: 1;
- min-width: 0;
- }
- .toolbar {
- display: flex;
- align-items: center;
- justify-content: space-between;
- margin-bottom: 20px;
- }
- .toolbar-left {
- display: flex;
- align-items: center;
- gap: 16px;
- }
- .search-box {
- position: relative;
- width: 280px;
- }
- .search-icon {
- position: absolute;
- left: 10px;
- top: 50%;
- transform: translateY(-50%);
- color: var(--text-muted);
- }
- .search-input {
- width: 100%;
- background: var(--bg-card);
- border: 1px solid var(--border-color);
- border-radius: var(--radius-sm);
- padding: 8px 12px 8px 32px;
- font-size: 13px;
- color: var(--text-primary);
- outline: none;
- transition: border-color 0.2s;
- font-family: inherit;
- }
- .search-input:focus {
- border-color: var(--color-accent);
- box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.15);
- }
- .search-input::placeholder {
- color: var(--text-muted);
- }
- .skill-count {
- font-size: 14px;
- color: var(--text-secondary);
- }
- .count-num {
- color: var(--color-accent);
- font-weight: 600;
- }
- .toolbar-right {
- display: flex;
- gap: 8px;
- }
- /* 加载状态 */
- .loading-area {
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- padding: 80px 0;
- gap: 16px;
- }
- .loading-text {
- font-size: 14px;
- color: var(--text-tertiary);
- }
- /* 空状态 */
- .empty-area {
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- padding: 80px 0;
- }
- .empty-illustration {
- margin-bottom: 20px;
- opacity: 0.6;
- }
- .empty-title {
- font-size: 16px;
- font-weight: 500;
- color: var(--text-secondary);
- margin-bottom: 8px;
- }
- .empty-desc {
- font-size: 13px;
- color: var(--text-muted);
- }
- /* 卡片网格 */
- .skill-grid {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
- gap: 20px;
- }
- /* 分页 */
- .pagination {
- display: flex;
- align-items: center;
- justify-content: center;
- gap: 6px;
- margin-top: 32px;
- padding-top: 20px;
- border-top: 1px solid rgba(56, 189, 248, 0.06);
- }
- .page-btn {
- background: var(--bg-card);
- border: 1px solid var(--border-color);
- border-radius: var(--radius-sm);
- padding: 6px 14px;
- font-size: 13px;
- color: var(--text-secondary);
- cursor: pointer;
- transition: all 0.15s;
- font-family: inherit;
- }
- .page-btn:hover:not(:disabled) {
- border-color: var(--color-accent);
- color: var(--color-accent);
- }
- .page-btn.active {
- background: var(--color-accent);
- border-color: var(--color-accent);
- color: #fff;
- }
- .page-btn:disabled {
- opacity: 0.4;
- cursor: not-allowed;
- }
- .page-ellipsis {
- color: var(--text-muted);
- padding: 0 4px;
- }
- </style>
|