本提交含两部分。第一部分是本轮工作;第二部分是此前一直留在工作区、
从未提交的对象化重构,与第一部分在文件上互相咬合(internal/repository
整个包都是未跟踪状态,且 api 层已有文件引用它),无法拆成两个可编译的提交。
一、仓库层收口 A1 批(本轮工作)
把 api 层手写的 store.DB 查询收进具名仓库方法,只给真正获益的对象做方法,
不机械包裹全量。本批迁移 22 处裸查询(courses.go 9 / media.go 12 / products.go 1),
新增方法:
- MediaFileRepo.ListByBind / ListForAudit / MarkExtracted
- KnowledgeChunkRepo.CountByMediaFile
- ProductRepo.GetVisibleByID
两条业务口径改由仓库单点持有,避免各处手写漂移:
「只有 approved 素材出现在课程详情」与「已停用产品不在课程详情露出」。
修掉两个真实缺陷:
- ProductRepo.GetByID 缺 Where 条件。此前 GET /api/products/{id} 对任意 id 都返回
第一条产品、对不存在的 id 返回 200,且 PUT /api/products/{id} 会覆盖第一条产品
—— 数据损坏级。全仓扫描确认这是唯一一处同型写法。
- ProductRepo.Delete 写 status="deleted",而 DELETE 处理器文档与回包都声称
"inactive",接口在说谎;管理员用 status=all 拉列表会看到前端不认识的状态。
已对齐为 inactive(与 CourseRepo.Delete 一致)。
删除 8 个零调用且列名不存在的死方法(一调即 SQL 报错):
- media_file 上的 file_path / file_type / approval_status 三列并不存在,
GetByPath / ListByType / UpdateStatus 全废
- knowledge_chunk 上的 space_id 列不存在(模型早已改为 knowledge_space_key),
List / Total / ListBySpaceIDs / DeleteBySpace / SearchByVector 全废
取舍边界:能对当前 schema 跑通的死方法保留,跑不通的删或修。
CourseRepo.List 补齐 status=all 档(此前传给它会当作 status='all' 过滤出空列表)。
该方法此前零调用,现与产品列表语义对齐。
验证:go build ./... 与 go test ./... 全绿;另用真实 HTTP 请求验证 34 项
(课程 17 / 产品 3 / 素材 14),跑在数据库副本与独立 KB_DATA_DIR 上,
含 multipart 真上传 → 审批 → pdftotext 提取 → 分片入库的完整链路。
二、此前未提交的对象化重构(非本轮工作)
- 新增 internal/repository 仓库层、connectors、skills、specialists、xapps、jsonutil,
model/task_record|task_run|task_artifact、api/task_runtime|action_definition|chat_message
- 删除 api/app_definition、connectors、my_app_center、notification、office_skill、
export_docx|pptx|xlsx、official_account_* 等,随 XApp/Skill/Specialist/Connector
可插拔打包方向(AR10/AR11)调整
- 资产目录归位:backend-go/knowledge_source → assets/knowledge/source、
training_materials → assets/training/materials;README 内相对路径同步加深两级;
deploy env 补 ASSET_ROOT_DIR 并改 KNOWLEDGE_SOURCE_DIR / TRAINING_MATERIALS_DIR
- 前端新增 skills/ specialists/ connectors/ xapps/ 目录与对应页面
验证:前端 npm run build 通过(7.26s)。
Co-Authored-By: Claude Code <noreply@anthropic.com>
1529 lines
44 KiB
Vue
1529 lines
44 KiB
Vue
<template>
|
||
<div class="wb-shell">
|
||
<!-- ===== WORKBUDDY SIDEBAR ===== -->
|
||
<aside class="wb-sidebar" :class="{ collapsed: sidebarCollapsed }">
|
||
<div class="wb-sidebar-head">
|
||
<!-- 第一排:展开/收起侧边栏 + 搜索任务 -->
|
||
<div class="wb-head-row">
|
||
<button
|
||
class="wb-head-icon"
|
||
:title="sidebarCollapsed ? '展开侧边栏' : '收起侧边栏'"
|
||
:aria-label="sidebarCollapsed ? '展开侧边栏' : '收起侧边栏'"
|
||
@click="toggleSidebar"
|
||
>
|
||
<el-icon><component :is="sidebarCollapsed ? Expand : Fold" /></el-icon>
|
||
</button>
|
||
<button
|
||
class="wb-head-icon"
|
||
title="搜索任务"
|
||
aria-label="搜索任务"
|
||
@click="searchOpen = true"
|
||
>
|
||
<el-icon><Search /></el-icon>
|
||
</button>
|
||
</div>
|
||
|
||
<!-- 第二排:产品名 -->
|
||
<div class="wb-brand">
|
||
<span class="wb-brand-mark">博</span>
|
||
<span v-if="!sidebarCollapsed" class="wb-brand-name">博昇AI数字员工</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 一级导航全部来自 navSections:
|
||
新建任务现在也是一级项,不再在这里单独特判一颗按钮。 -->
|
||
<nav class="wb-nav">
|
||
<button
|
||
v-for="section in visibleSections"
|
||
:key="section.key"
|
||
class="wb-nav-item"
|
||
:class="{ active: activeSection?.key === section.key }"
|
||
:aria-current="activeSection?.key === section.key ? 'page' : undefined"
|
||
:title="section.label"
|
||
:aria-label="section.label"
|
||
@click="openSection(section)"
|
||
>
|
||
<span class="wb-nav-icon">
|
||
<el-icon><component :is="section.icon" /></el-icon>
|
||
</span>
|
||
<span v-if="!sidebarCollapsed" class="wb-nav-label">{{ section.label }}</span>
|
||
</button>
|
||
|
||
<!-- 聊过天的任务落在这里,标题就是第一句话。任务在发出第一条消息时
|
||
才落库,所以这里必须是真数据(myTasks),不是写死的样例。
|
||
行本身不再是 <button>:里面要放「小点」菜单,button 套 button
|
||
是非法嵌套,浏览器会把内层那个甩到外面去。 -->
|
||
<div v-if="!sidebarCollapsed && !loadingMyTasks" class="wb-project-block">
|
||
<div class="wb-task-head">最近项目</div>
|
||
<div class="wb-project-tree">
|
||
<div
|
||
v-for="group in recentProjectGroups"
|
||
:key="group.projectId"
|
||
class="wb-project-group"
|
||
>
|
||
<div class="wb-project-row" :class="{ active: isProjectRoute(group.projectId) }">
|
||
<button
|
||
class="wb-project-caret"
|
||
type="button"
|
||
:title="isProjectExpanded(group.projectId) ? '折叠项目' : '展开项目'"
|
||
:aria-label="isProjectExpanded(group.projectId) ? '折叠项目' : '展开项目'"
|
||
:aria-expanded="isProjectExpanded(group.projectId) ? 'true' : 'false'"
|
||
@click.stop="toggleProjectExpanded(group.projectId)"
|
||
>
|
||
<span :class="{ 'is-collapsed': !isProjectExpanded(group.projectId) }">▾</span>
|
||
</button>
|
||
<button
|
||
class="wb-project-name"
|
||
type="button"
|
||
:title="group.projectName"
|
||
@click="openRecentProject(group.projectId)"
|
||
>
|
||
{{ group.projectName }}
|
||
</button>
|
||
</div>
|
||
|
||
<template v-if="isProjectExpanded(group.projectId)">
|
||
<div
|
||
v-for="task in group.tasks"
|
||
:key="task.id"
|
||
class="wb-task-item wb-task-item--tree"
|
||
:class="{
|
||
active: String(task.id) === String(currentTaskId),
|
||
'menu-open': openMenuId === task.id,
|
||
}"
|
||
role="button"
|
||
tabindex="0"
|
||
:title="task.title"
|
||
@click="openMyTask(task)"
|
||
@keydown.enter.prevent="openMyTask(task)"
|
||
>
|
||
<input
|
||
v-if="renamingId === task.id"
|
||
:ref="(el) => setRenameInput(el, task.id)"
|
||
v-model="renameText"
|
||
class="wb-task-rename"
|
||
maxlength="40"
|
||
@click.stop
|
||
@keydown.enter.prevent="commitRename(task)"
|
||
@keydown.esc.prevent="cancelRename"
|
||
@blur="commitRename(task)"
|
||
/>
|
||
<template v-else>
|
||
<div class="wb-task-main">
|
||
<span class="wb-task-title">
|
||
<el-icon v-if="task.pinned" class="wb-task-pin"><Top /></el-icon>
|
||
{{ task.title }}
|
||
</span>
|
||
</div>
|
||
<span class="wb-task-time">{{ recentTaskTime(task) }}</span>
|
||
</template>
|
||
|
||
<el-dropdown
|
||
v-if="renamingId !== task.id"
|
||
class="wb-task-more-wrap"
|
||
trigger="click"
|
||
placement="bottom-end"
|
||
:persistent="false"
|
||
@command="(command) => onTaskCommand(command, task)"
|
||
@visible-change="(visible) => (openMenuId = visible ? task.id : null)"
|
||
>
|
||
<button class="wb-task-more" title="更多" aria-label="更多" @click.stop>
|
||
<el-icon><MoreFilled /></el-icon>
|
||
</button>
|
||
<template #dropdown>
|
||
<el-dropdown-menu>
|
||
<el-dropdown-item command="rename">
|
||
<el-icon><EditPen /></el-icon>重命名
|
||
</el-dropdown-item>
|
||
<el-dropdown-item command="join-project">
|
||
<el-icon><FolderAdd /></el-icon>加入项目
|
||
</el-dropdown-item>
|
||
<el-dropdown-item command="pin">
|
||
<el-icon><Top /></el-icon>{{ task.pinned ? '取消置顶' : '置顶' }}
|
||
</el-dropdown-item>
|
||
<el-dropdown-item command="delete" divided>
|
||
<el-icon><Delete /></el-icon>删除
|
||
</el-dropdown-item>
|
||
</el-dropdown-menu>
|
||
</template>
|
||
</el-dropdown>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
|
||
<div v-if="!recentProjectGroups.length" class="wb-task-empty">
|
||
还没有加入项目的最近任务
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="!sidebarCollapsed && !loadingMyTasks" class="wb-task-block">
|
||
<div class="wb-task-head">最近任务</div>
|
||
<div
|
||
v-for="task in recentLooseTasks"
|
||
:key="task.id"
|
||
class="wb-task-item"
|
||
:class="{
|
||
active: String(task.id) === String(currentTaskId),
|
||
'menu-open': openMenuId === task.id,
|
||
}"
|
||
role="button"
|
||
tabindex="0"
|
||
:title="task.title"
|
||
@click="openMyTask(task)"
|
||
@keydown.enter.prevent="openMyTask(task)"
|
||
>
|
||
<!-- 重命名就地改:菜单点「重命名」后这一行直接变成输入框,
|
||
回车提交、Esc 取消、失焦提交,跟 ChatGPT / WorkBuddy 一致。 -->
|
||
<input
|
||
v-if="renamingId === task.id"
|
||
:ref="(el) => setRenameInput(el, task.id)"
|
||
v-model="renameText"
|
||
class="wb-task-rename"
|
||
maxlength="40"
|
||
@click.stop
|
||
@keydown.enter.prevent="commitRename(task)"
|
||
@keydown.esc.prevent="cancelRename"
|
||
@blur="commitRename(task)"
|
||
/>
|
||
<template v-else>
|
||
<div class="wb-task-main">
|
||
<span class="wb-task-title">
|
||
<el-icon v-if="task.pinned" class="wb-task-pin"><Top /></el-icon>
|
||
{{ task.title }}
|
||
</span>
|
||
</div>
|
||
<span class="wb-task-time">{{ recentTaskTime(task) }}</span>
|
||
</template>
|
||
|
||
<!-- 「小点」:默认藏着,悬停或菜单打开时才浮出来,跟两家一样。
|
||
菜单靠 el-dropdown 传送到 body,不会被侧边栏的滚动容器裁掉;
|
||
persistent=false 让关掉的菜单真的从 DOM 里消失 —— 默认行为是
|
||
每行都留一份隐藏的菜单在页面里,任务攒到 50 条就是 50 份。 -->
|
||
<el-dropdown
|
||
v-if="renamingId !== task.id"
|
||
class="wb-task-more-wrap"
|
||
trigger="click"
|
||
placement="bottom-end"
|
||
:persistent="false"
|
||
@command="(command) => onTaskCommand(command, task)"
|
||
@visible-change="(visible) => (openMenuId = visible ? task.id : null)"
|
||
>
|
||
<button class="wb-task-more" title="更多" aria-label="更多" @click.stop>
|
||
<el-icon><MoreFilled /></el-icon>
|
||
</button>
|
||
<template #dropdown>
|
||
<el-dropdown-menu>
|
||
<el-dropdown-item command="rename">
|
||
<el-icon><EditPen /></el-icon>重命名
|
||
</el-dropdown-item>
|
||
<el-dropdown-item command="join-project">
|
||
<el-icon><FolderAdd /></el-icon>加入项目
|
||
</el-dropdown-item>
|
||
<el-dropdown-item command="pin">
|
||
<el-icon><Top /></el-icon>{{ task.pinned ? '取消置顶' : '置顶' }}
|
||
</el-dropdown-item>
|
||
<el-dropdown-item command="delete" divided>
|
||
<el-icon><Delete /></el-icon>删除
|
||
</el-dropdown-item>
|
||
</el-dropdown-menu>
|
||
</template>
|
||
</el-dropdown>
|
||
</div>
|
||
<!-- 空列表要有一句话解释:任务不是点「新建任务」就出现的,
|
||
得先发出第一条消息,不说清楚会以为按钮坏了。 -->
|
||
<div v-if="!recentLooseTasks.length" class="wb-task-empty">
|
||
还没有任务,发送第一条消息后出现在这里
|
||
</div>
|
||
</div>
|
||
</nav>
|
||
|
||
<div class="wb-sidebar-foot">
|
||
<!-- Account lives in the bottom-left corner of the shell. -->
|
||
<el-dropdown trigger="click" placement="top-start">
|
||
<button class="wb-user" aria-label="账号">
|
||
<span class="wb-avatar">{{ avatarText }}</span>
|
||
<span v-if="!sidebarCollapsed" class="wb-user-meta">
|
||
<span class="wb-user-name">{{ username }}</span>
|
||
<span class="wb-user-role">{{ isAdmin ? '管理员' : '业务人员' }}</span>
|
||
</span>
|
||
</button>
|
||
<template #dropdown>
|
||
<el-dropdown-menu>
|
||
<el-dropdown-item @click="router.push('/my/settings')">我的设置</el-dropdown-item>
|
||
<el-dropdown-item divided @click="handleLogout">退出登录</el-dropdown-item>
|
||
</el-dropdown-menu>
|
||
</template>
|
||
</el-dropdown>
|
||
</div>
|
||
</aside>
|
||
|
||
<!-- ===== MAIN COLUMN ===== -->
|
||
<div class="wb-main">
|
||
<!-- 工作区胶囊条已在架构收敛中移除,此处不再渲染。 -->
|
||
|
||
<!-- Content -->
|
||
<main class="wb-content">
|
||
<!-- 带 groups 的一级项(专员·技能·连接器)画两行 tab:组一行,页面一行。 -->
|
||
<SectionTabs
|
||
:items="activeSection?.items || []"
|
||
:groups="activeSection?.groups || []"
|
||
:group-key="activeGroup?.key || ''"
|
||
/>
|
||
<div v-if="shouldShowTaskTitlebar && currentTaskDisplayTitle" class="wb-task-titlebar" :title="currentTaskDisplayTitle">
|
||
<div class="wb-task-titlebar-text">{{ currentTaskDisplayTitle }}</div>
|
||
</div>
|
||
<div class="wb-content-scroll">
|
||
<router-view />
|
||
</div>
|
||
</main>
|
||
</div>
|
||
|
||
<SurfaceChatRail
|
||
v-if="surfaceChatContext"
|
||
:scope-key="surfaceChatContext.scopeKey"
|
||
:title="surfaceChatContext.title"
|
||
:subtitle="surfaceChatContext.subtitle"
|
||
:placeholder="surfaceChatContext.placeholder"
|
||
:empty-text="surfaceChatContext.emptyText"
|
||
:starter-prompts="surfaceChatContext.starterPrompts"
|
||
/>
|
||
|
||
<NavSearch v-model="searchOpen" :sections="visibleSections" :tabs="openTabs" />
|
||
|
||
<el-dialog
|
||
v-model="joinProjectDialogVisible"
|
||
title="加入项目"
|
||
width="420px"
|
||
destroy-on-close
|
||
>
|
||
<div v-if="joinProjectTask" class="wb-join-project-body">
|
||
<div class="wb-join-project-label">任务</div>
|
||
<div class="wb-join-project-title">{{ joinProjectTask.title }}</div>
|
||
<div class="wb-join-project-label">选择项目</div>
|
||
<label class="wb-join-project-search">
|
||
<el-icon><Search /></el-icon>
|
||
<input
|
||
v-model.trim="joinProjectKeyword"
|
||
class="wb-join-project-search-input"
|
||
type="text"
|
||
placeholder="搜索已有项目"
|
||
/>
|
||
</label>
|
||
<div class="wb-join-project-list" role="listbox" aria-label="项目列表">
|
||
<button
|
||
v-for="project in filteredProjectOptions"
|
||
:key="project.id"
|
||
type="button"
|
||
class="wb-join-project-item"
|
||
:class="{ selected: selectedProjectId === String(project.id) }"
|
||
:aria-selected="selectedProjectId === String(project.id) ? 'true' : 'false'"
|
||
@click="selectedProjectId = String(project.id)"
|
||
>
|
||
<span class="wb-join-project-item-name">{{ project.name }}</span>
|
||
<el-icon v-if="selectedProjectId === String(project.id)" class="wb-join-project-item-check"><Check /></el-icon>
|
||
</button>
|
||
<div v-if="!filteredProjectOptions.length" class="wb-join-project-empty">
|
||
没有匹配的项目
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<template #footer>
|
||
<el-button @click="joinProjectDialogVisible = false">取消</el-button>
|
||
<el-button
|
||
type="primary"
|
||
:loading="joiningProject"
|
||
:disabled="!selectedProjectId"
|
||
@click="confirmJoinProject"
|
||
>
|
||
加入项目
|
||
</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
import { Check, Delete, EditPen, Expand, Fold, FolderAdd, MoreFilled, Search, Top } from '@element-plus/icons-vue'
|
||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||
import SurfaceChatRail from '@/components/chat/SurfaceChatRail.vue'
|
||
import SectionTabs from '@/components/SectionTabs.vue'
|
||
import NavSearch from '@/components/NavSearch.vue'
|
||
import { useAuthStore } from '@/store/auth'
|
||
import { useProjectStore } from '@/store/project'
|
||
import { useSkillCatalogStore } from '@/skills/store/skillCatalog'
|
||
import { useSpecialistCatalogStore } from '@/specialists/store/specialistCatalog'
|
||
import { useWorkbenchStore } from '@/store/workbench'
|
||
import { useTaskRuntimeStore } from '@/store/taskRuntime'
|
||
import { getDefaultWorkspaceCaseId, getWorkspaceCases } from '@/config/workbench'
|
||
import { relativeTime } from '@/config/projectTemplates'
|
||
import {
|
||
INTERNAL_EXAM_XAPP_ROOT,
|
||
INTERNAL_TRAINING_MODULE_ROUTES,
|
||
INTERNAL_TRAINING_XAPP_ROOT,
|
||
XAPP_DIRECTORY_ROUTE,
|
||
} from '@/xapps/config/xappRoutes'
|
||
import {
|
||
findGroupByPath,
|
||
findSectionByPath,
|
||
navSections,
|
||
sectionHomeRoute,
|
||
} from '@/config/nav'
|
||
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
const auth = useAuthStore()
|
||
const projectStore = useProjectStore()
|
||
const skillCatalog = useSkillCatalogStore()
|
||
const specialistCatalog = useSpecialistCatalogStore()
|
||
const workbench = useWorkbenchStore()
|
||
const taskRuntime = useTaskRuntimeStore()
|
||
|
||
const isAdmin = computed(() => auth.isAdmin)
|
||
const username = computed(() => auth.username || '同事')
|
||
const avatarText = computed(() => username.value.charAt(0))
|
||
|
||
const sidebarCollapsed = ref(localStorage.getItem('wb-sidebar-collapsed') === '1')
|
||
const searchOpen = ref(false)
|
||
|
||
// ===== Navigation model =====
|
||
// 一级导航由 config/nav.js 定义。这里只做两件事:按权限和可路由性过滤,
|
||
// 以及把当前路由映射回它所属的一级项。
|
||
const visibleSections = computed(() =>
|
||
navSections
|
||
// Only surface destinations the router can actually resolve — config entries
|
||
// for not-yet-built specialists must not become dead nav links.
|
||
.map((section) => {
|
||
const keep = (item) => (!item.adminOnly || isAdmin.value) && isRoutable(item.pageRoute)
|
||
// 分组的一级项要把权限过滤同样下推到每个组,否则整组没人可看时
|
||
// 侧边栏还会留着一个点不动的行。
|
||
if (section.groups) {
|
||
const groups = section.groups
|
||
.map((group) => ({ ...group, items: group.items.filter(keep) }))
|
||
.filter((group) => group.items.length)
|
||
return { ...section, groups, items: groups.flatMap((group) => group.items) }
|
||
}
|
||
return { ...section, items: section.items.filter(keep) }
|
||
})
|
||
.filter((section) => section.items.length)
|
||
)
|
||
|
||
const activeSection = computed(() => findSectionByPath(visibleSections.value, route.path))
|
||
const activeGroup = computed(() => findGroupByPath(activeSection.value, route.path))
|
||
|
||
const openTabs = computed(() => workbench.openTabs)
|
||
|
||
// 侧边栏那一列「最近任务」。归属由后端按登录用户定,这里只负责显示。
|
||
const myTasks = computed(() => taskRuntime.myTasks)
|
||
const loadingMyTasks = computed(() => taskRuntime.loadingMyTasks)
|
||
const currentTaskId = computed(() => taskRuntime.currentTaskId)
|
||
const currentTask = computed(() => taskRuntime.currentTask)
|
||
const pendingTaskTitle = computed(() => String(taskRuntime.pendingTaskTitle || '').trim())
|
||
const projectOptions = computed(() => projectStore.projects)
|
||
const collapsedProjectMap = ref({})
|
||
const currentTaskTitle = computed(() => (
|
||
String(currentTask.value?.title || '').trim() || pendingTaskTitle.value
|
||
))
|
||
const currentTaskDisplayTitle = computed(() =>
|
||
currentTaskTitle.value ? `当前任务:${currentTaskTitle.value}` : ''
|
||
)
|
||
const shouldShowTaskTitlebar = computed(() => {
|
||
const path = route.path || ''
|
||
if (path === XAPP_DIRECTORY_ROUTE) return false
|
||
if (path.startsWith(INTERNAL_TRAINING_XAPP_ROOT)) return false
|
||
if (path.startsWith(INTERNAL_EXAM_XAPP_ROOT)) return false
|
||
if (path === '/knowledge-hub') return false
|
||
return true
|
||
})
|
||
const surfaceChatContext = computed(() => resolveSurfaceChatContext(route.path || ''))
|
||
const recentLooseTasks = computed(() => myTasks.value.filter((task) => !task?.project_id))
|
||
const recentProjectGroups = computed(() => {
|
||
const projectMap = new Map(
|
||
projectStore.projects.map((project) => [String(project.id), project])
|
||
)
|
||
const groups = new Map()
|
||
|
||
myTasks.value.forEach((task) => {
|
||
const projectId = String(task?.project_id || '')
|
||
if (!projectId) return
|
||
if (!groups.has(projectId)) {
|
||
const project = projectMap.get(projectId)
|
||
groups.set(projectId, {
|
||
projectId,
|
||
projectName: project?.name || `项目 ${projectId}`,
|
||
tasks: [],
|
||
})
|
||
}
|
||
groups.get(projectId).tasks.push(task)
|
||
})
|
||
|
||
return Array.from(groups.values())
|
||
})
|
||
|
||
// ===== Worker runtime context (used by workspace surfaces) =====
|
||
const workspaceKey = computed(() => route.meta.workspaceKey || '')
|
||
const runtimeCases = computed(() => taskRuntime.tasksBySpecialist[workspaceKey.value] || [])
|
||
const currentCases = computed(() => {
|
||
if (runtimeCases.value.length) {
|
||
return runtimeCases.value.map(item => ({
|
||
id: String(item.id),
|
||
title: item.title,
|
||
owner: item.owner || '待指派',
|
||
priority: item.priority || 'P2',
|
||
due: formatDueAt(item.due_at),
|
||
summary: item.summary || '待补充事项摘要',
|
||
status: item.status || '待处理',
|
||
}))
|
||
}
|
||
return workspaceKey.value ? getWorkspaceCases(workspaceKey.value) : []
|
||
})
|
||
const selectedCaseId = computed(() => {
|
||
if (!workspaceKey.value) return ''
|
||
return workbench.selectedCases[workspaceKey.value] || currentCases.value[0]?.id || getDefaultWorkspaceCaseId(workspaceKey.value)
|
||
})
|
||
|
||
onMounted(() => {
|
||
skillCatalog.hydrate()
|
||
specialistCatalog.hydrate()
|
||
loadMyTasks()
|
||
projectStore.loadProjects().catch((error) => {
|
||
console.warn('load projects for shell failed', error)
|
||
})
|
||
window.addEventListener('keydown', handleShortcut)
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
window.removeEventListener('keydown', handleShortcut)
|
||
})
|
||
|
||
// ⌘K / Ctrl+K 打开搜索
|
||
function handleShortcut(event) {
|
||
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
|
||
event.preventDefault()
|
||
searchOpen.value = true
|
||
}
|
||
}
|
||
|
||
watch(
|
||
workspaceKey,
|
||
async (key) => {
|
||
if (!key) return
|
||
try {
|
||
await taskRuntime.loadTasks(key)
|
||
} catch (error) {
|
||
console.warn('load worker tasks for shell failed', error)
|
||
}
|
||
},
|
||
{ immediate: true }
|
||
)
|
||
|
||
watch(
|
||
() => currentCases.value.map(item => item.id).join(','),
|
||
(ids) => {
|
||
if (!workspaceKey.value || !ids) return
|
||
const selected = workbench.selectedCases[workspaceKey.value]
|
||
if (!selected || !currentCases.value.some(item => item.id === selected)) {
|
||
workbench.setSelectedCase(workspaceKey.value, currentCases.value[0]?.id || getDefaultWorkspaceCaseId(workspaceKey.value))
|
||
}
|
||
},
|
||
{ immediate: true }
|
||
)
|
||
|
||
watch(
|
||
selectedCaseId,
|
||
async (id) => {
|
||
if (!id || !/^\d+$/.test(String(id))) return
|
||
try {
|
||
await taskRuntime.loadTaskDetail(id)
|
||
} catch (error) {
|
||
console.warn('load worker task detail for shell failed', error)
|
||
}
|
||
},
|
||
{ immediate: true }
|
||
)
|
||
|
||
watch(
|
||
() => route.path,
|
||
(path) => {
|
||
// 顶部胶囊是一级粒度的,所以这里只做两件事:记住你落在哪个专员的工作面
|
||
// (右侧工作台要用),以及把路由喂给 store —— 折算成哪个胶囊由 store 一处决定。
|
||
const specialist = specialistCatalog.getByPath(path)
|
||
if (specialist?.key && !workbench.selectedCases[specialist.key]) {
|
||
workbench.setSelectedCase(specialist.key, getDefaultWorkspaceCaseId(specialist.key))
|
||
}
|
||
workbench.ensureTab({ pageRoute: path })
|
||
},
|
||
{ immediate: true }
|
||
)
|
||
|
||
function isRoutable(path) {
|
||
try {
|
||
return router.resolve(path).matched.length > 0
|
||
} catch (error) {
|
||
return false
|
||
}
|
||
}
|
||
|
||
function toggleSidebar() {
|
||
sidebarCollapsed.value = !sidebarCollapsed.value
|
||
localStorage.setItem('wb-sidebar-collapsed', sidebarCollapsed.value ? '1' : '0')
|
||
}
|
||
|
||
function openSection(section) {
|
||
if (section.key === 'new-task') {
|
||
startNewTask()
|
||
return
|
||
}
|
||
router.push(sectionHomeRoute(section))
|
||
}
|
||
|
||
// 新建任务:回到通用助手的开场画布,不建任务记录。
|
||
//
|
||
// 这里刻意不落库 —— 任务记录等第一条消息发出时才建(见 store 的
|
||
// createTaskForFirstMessage),标题就是那句话,专员用 + 里先选的那个。
|
||
// 点一次建一条的话,连点几下就会在「最近任务」里堆出一列没聊过天的空任务。
|
||
function startNewTask() {
|
||
taskRuntime.startNewTask()
|
||
router.push('/home')
|
||
// 从别的页面过来时组件会重新挂载,画布本来就是干净的;已经停在 /home
|
||
// 时靠这个事件把当前对话清掉。两种情况都发一次,调用方不用自己判断。
|
||
window.dispatchEvent(new CustomEvent('wb:new-task'))
|
||
}
|
||
|
||
async function loadMyTasks() {
|
||
try {
|
||
await taskRuntime.loadMyTasks()
|
||
} catch (error) {
|
||
console.warn('load my tasks for shell failed', error)
|
||
}
|
||
}
|
||
|
||
// 点任务列里的一条:把它设为当前,并恢复这条任务上次停留的对象上下文。
|
||
//
|
||
// Phase 1 先把「当前技能」挂进 runtime store:侧边栏重开一条任务时,优先
|
||
// 回到它上次使用的技能入口;还没有对象上下文的旧任务,继续回 /home。
|
||
//
|
||
// specialist_key 顺手带进 store:右栏要靠它知道画哪个专员,而 myTasks 可能
|
||
// 还没加载到这一条。
|
||
//
|
||
// 注意:对话内容目前没有按任务落库,所以重开一条旧任务时恢复的是「在哪做」
|
||
// 和「用哪个技能做」,不是完整消息历史。
|
||
async function openMyTask(task) {
|
||
taskRuntime.openTask(task.id, task.specialist_key)
|
||
const currentSkill = skillCatalog.getByKey(taskRuntime.skillKeyOfTask(task.id))
|
||
const target = currentSkill?.objectEntryRoute || '/home'
|
||
if (route.path !== target) await router.push(target)
|
||
}
|
||
|
||
function openRecentProject(projectId) {
|
||
const id = String(projectId || '')
|
||
if (!id) return
|
||
projectStore.openProject(id)
|
||
router.push(`/projects/${id}`)
|
||
}
|
||
|
||
function isProjectExpanded(projectId) {
|
||
return collapsedProjectMap.value[String(projectId || '')] !== true
|
||
}
|
||
|
||
function toggleProjectExpanded(projectId) {
|
||
const id = String(projectId || '')
|
||
if (!id) return
|
||
collapsedProjectMap.value = {
|
||
...collapsedProjectMap.value,
|
||
[id]: isProjectExpanded(id),
|
||
}
|
||
}
|
||
|
||
// ===== 任务行的「小点」菜单 =====
|
||
// openMenuId 只管一件事:让「小点」在菜单开着的时候别消失 —— 它平时是
|
||
// opacity:0 悬停才出现的,菜单一弹出来鼠标就离开那一行了。
|
||
const openMenuId = ref(null)
|
||
const renamingId = ref(null)
|
||
const renameText = ref('')
|
||
const renameInputRef = ref(null)
|
||
const joinProjectDialogVisible = ref(false)
|
||
const joinProjectTask = ref(null)
|
||
const joinProjectKeyword = ref('')
|
||
const selectedProjectId = ref('')
|
||
const joiningProject = ref(false)
|
||
const filteredProjectOptions = computed(() => {
|
||
const keyword = joinProjectKeyword.value.trim().toLowerCase()
|
||
if (!keyword) return projectOptions.value
|
||
return projectOptions.value.filter((project) => String(project?.name || '').toLowerCase().includes(keyword))
|
||
})
|
||
|
||
async function onTaskCommand(command, task) {
|
||
if (command === 'rename') return startRename(task)
|
||
if (command === 'join-project') return openJoinProjectDialog(task)
|
||
|
||
if (command === 'pin') {
|
||
try {
|
||
await taskRuntime.togglePinned(task.id)
|
||
} catch (error) {
|
||
console.warn('toggle task pinned failed', error)
|
||
ElMessage.error(task.pinned ? '取消置顶失败,请稍后重试' : '置顶失败,请稍后重试')
|
||
}
|
||
return
|
||
}
|
||
|
||
if (command === 'delete') {
|
||
// 后端是硬删,没有回收站,所以这里必须把「找不回来」说在明面上,
|
||
// 不能只给一个光秃秃的「确定吗」。
|
||
try {
|
||
await ElMessageBox.confirm(
|
||
`「${task.title}」将被永久删除,无法恢复。`,
|
||
'删除任务',
|
||
{
|
||
type: 'warning',
|
||
confirmButtonText: '删除',
|
||
cancelButtonText: '取消',
|
||
confirmButtonClass: 'el-button--danger',
|
||
}
|
||
)
|
||
} catch {
|
||
return // 点了取消
|
||
}
|
||
try {
|
||
await taskRuntime.deleteMyTask(task.id)
|
||
ElMessage.success('已删除')
|
||
} catch (error) {
|
||
console.warn('delete task failed', error)
|
||
ElMessage.error('删除失败,请稍后重试')
|
||
}
|
||
}
|
||
}
|
||
|
||
async function openJoinProjectDialog(task) {
|
||
try {
|
||
if (!projectStore.projects.length) {
|
||
await projectStore.loadProjects()
|
||
}
|
||
} catch (error) {
|
||
console.warn('load projects for task menu failed', error)
|
||
ElMessage.error('加载项目列表失败,请稍后重试')
|
||
return
|
||
}
|
||
|
||
if (!projectStore.projects.length) {
|
||
ElMessage.info('还没有项目,请先到“项目”里创建一个项目')
|
||
return
|
||
}
|
||
|
||
joinProjectTask.value = task
|
||
joinProjectKeyword.value = ''
|
||
selectedProjectId.value = task?.project_id ? String(task.project_id) : ''
|
||
joinProjectDialogVisible.value = true
|
||
}
|
||
|
||
async function confirmJoinProject() {
|
||
const task = joinProjectTask.value
|
||
const projectId = selectedProjectId.value
|
||
if (!task?.id || !projectId) return
|
||
if (String(task.project_id || '') === String(projectId)) {
|
||
joinProjectDialogVisible.value = false
|
||
ElMessage.info('这条任务已经在该项目中了')
|
||
return
|
||
}
|
||
|
||
joiningProject.value = true
|
||
try {
|
||
const previousProjectId = task.project_id || 0
|
||
const updatedTask = await taskRuntime.assignTaskToProject(task.id, projectId)
|
||
projectStore.syncProjectTask(updatedTask, previousProjectId)
|
||
const project = projectStore.projects.find((item) => String(item.id) === String(projectId))
|
||
ElMessage.success(`已加入项目${project?.name ? `「${project.name}」` : ''}`)
|
||
joinProjectDialogVisible.value = false
|
||
joinProjectTask.value = null
|
||
joinProjectKeyword.value = ''
|
||
selectedProjectId.value = ''
|
||
} catch (error) {
|
||
console.warn('assign task to project failed', error)
|
||
ElMessage.error('加入项目失败,请稍后重试')
|
||
} finally {
|
||
joiningProject.value = false
|
||
}
|
||
}
|
||
|
||
// 输入框是 v-if 出来的,挂载时才拿得到元素。用函数 ref 而不是
|
||
// `:ref="..."` 直接赋值:这里明确写成一次方法调用,不依赖模板对 ref
|
||
// 赋值的那套解包规则,读起来也不用猜。
|
||
function setRenameInput(el, id) {
|
||
if (el && renamingId.value === id) renameInputRef.value = el
|
||
}
|
||
|
||
async function startRename(task) {
|
||
renamingId.value = task.id
|
||
renameText.value = task.title
|
||
await nextTick()
|
||
renameInputRef.value?.focus()
|
||
renameInputRef.value?.select()
|
||
}
|
||
|
||
// 回车和失焦都会走到这里,靠 renamingId 判重,第二次进来直接返回,
|
||
// 免得同一次改名提交两遍。
|
||
async function commitRename(task) {
|
||
if (renamingId.value !== task.id) return
|
||
const title = renameText.value.trim()
|
||
renamingId.value = null
|
||
if (!title || title === task.title) return
|
||
try {
|
||
await taskRuntime.renameTask(task.id, title)
|
||
} catch (error) {
|
||
console.warn('rename task failed', error)
|
||
ElMessage.error('重命名失败,请稍后重试')
|
||
}
|
||
}
|
||
|
||
function cancelRename() {
|
||
renamingId.value = null
|
||
}
|
||
|
||
function recentTaskTime(task) {
|
||
return relativeTime(task?.updated_at || task?.created_at) || ''
|
||
}
|
||
|
||
function isProjectRoute(projectId) {
|
||
return route.path === `/projects/${projectId}`
|
||
}
|
||
|
||
function handleLogout() {
|
||
auth.logout()
|
||
router.push('/login')
|
||
}
|
||
|
||
function formatDueAt(value) {
|
||
if (!value) return '待设置'
|
||
const date = new Date(value)
|
||
if (Number.isNaN(date.getTime())) return '待设置'
|
||
const month = `${date.getMonth() + 1}`.padStart(2, '0')
|
||
const day = `${date.getDate()}`.padStart(2, '0')
|
||
const hour = `${date.getHours()}`.padStart(2, '0')
|
||
const minute = `${date.getMinutes()}`.padStart(2, '0')
|
||
return `${month}-${day} ${hour}:${minute}`
|
||
}
|
||
|
||
function resolveSurfaceChatContext(path) {
|
||
if (path.startsWith(INTERNAL_TRAINING_MODULE_ROUTES.courses)) {
|
||
return {
|
||
scopeKey: 'surface-training',
|
||
title: '培训协作',
|
||
subtitle: '边看课程,边让 AI 帮你总结、提炼和辅导。',
|
||
placeholder: '例如:帮我总结这门课程的重点,并给出陪练建议',
|
||
emptyText: '进入培训 APP 后,右侧会一直保留 AI 对话栏,方便你边学边问。',
|
||
starterPrompts: [
|
||
'帮我总结这门课程的 3 个重点',
|
||
'把这节课整理成销售陪练提纲',
|
||
],
|
||
}
|
||
}
|
||
if (path.startsWith(INTERNAL_TRAINING_MODULE_ROUTES.products)) {
|
||
return {
|
||
scopeKey: 'surface-product',
|
||
title: '产品协作',
|
||
subtitle: '随时提问产品知识、收费结构和销售表达。',
|
||
placeholder: '例如:帮我解释这个产品的收费结构和销售话术',
|
||
emptyText: '你可以一边查看产品资料,一边让 AI 帮你解释、对比和整理表达。',
|
||
starterPrompts: [
|
||
'把这个产品整理成客户可读介绍',
|
||
'帮我提炼销售时该怎么讲',
|
||
],
|
||
}
|
||
}
|
||
if (path.startsWith(INTERNAL_TRAINING_MODULE_ROUTES.company)) {
|
||
return {
|
||
scopeKey: 'surface-company-train',
|
||
title: '介绍协作',
|
||
subtitle: '结合公司介绍内容,随时生成摘要、讲解稿和问答。',
|
||
placeholder: '例如:把这段公司介绍整理成新人培训讲解稿',
|
||
emptyText: '右侧 AI 对话栏会跟着公司介绍页一起工作,方便直接边看边问。',
|
||
starterPrompts: [
|
||
'把公司介绍压缩成 1 分钟讲解稿',
|
||
'根据这页内容生成新人常见问答',
|
||
],
|
||
}
|
||
}
|
||
if (path.startsWith(INTERNAL_EXAM_XAPP_ROOT)) {
|
||
return {
|
||
scopeKey: 'surface-exam',
|
||
title: '考试协作',
|
||
subtitle: '边看考试页,边让 AI 帮你解释规则、复盘和出练习建议。',
|
||
placeholder: '例如:根据当前考试情况帮我做一版复盘建议',
|
||
emptyText: '考试 APP 里也会保留 AI 对话栏,方便把练习、自测和复盘连起来。',
|
||
starterPrompts: [
|
||
'根据当前错题情况给我复盘建议',
|
||
'帮我生成一版考前冲刺清单',
|
||
],
|
||
}
|
||
}
|
||
return null
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
/* ===== SHELL ===== */
|
||
.wb-shell {
|
||
height: 100vh;
|
||
display: flex;
|
||
background: #f0f2f5;
|
||
}
|
||
|
||
/* ===== SIDEBAR ===== */
|
||
.wb-sidebar {
|
||
width: 248px;
|
||
min-width: 248px;
|
||
background: #f7f8fa;
|
||
border-right: 1px solid #e8ecf1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
transition: width 0.2s ease, min-width 0.2s ease;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.wb-sidebar.collapsed {
|
||
width: 60px;
|
||
min-width: 60px;
|
||
}
|
||
|
||
.wb-sidebar-head {
|
||
flex-shrink: 0;
|
||
padding: 10px 10px 4px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
}
|
||
|
||
/* 第一排:展开/收起 + 搜索 */
|
||
.wb-head-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
}
|
||
|
||
/* 收起后只剩 60px,两个图标改为竖排 */
|
||
.wb-sidebar.collapsed .wb-head-row {
|
||
flex-direction: column;
|
||
}
|
||
|
||
.wb-head-icon {
|
||
width: 28px;
|
||
height: 28px;
|
||
border: none;
|
||
border-radius: 8px;
|
||
background: transparent;
|
||
color: #606266;
|
||
font-size: 16px;
|
||
cursor: pointer;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: background 0.15s, color 0.15s;
|
||
}
|
||
|
||
.wb-head-icon:hover {
|
||
background: #eef1f6;
|
||
color: #303133;
|
||
}
|
||
|
||
/* 第二排:产品名 */
|
||
.wb-brand {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
min-width: 0;
|
||
padding: 4px 6px 6px;
|
||
}
|
||
|
||
.wb-sidebar.collapsed .wb-brand {
|
||
justify-content: center;
|
||
padding: 4px 0 6px;
|
||
}
|
||
|
||
.wb-brand-mark {
|
||
width: 22px;
|
||
height: 22px;
|
||
border-radius: 7px;
|
||
background: linear-gradient(135deg, #34d399 0%, #10b981 100%);
|
||
color: #fff;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.wb-brand-name {
|
||
font-size: 14px;
|
||
font-weight: 700;
|
||
color: #1f2d3d;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.wb-nav {
|
||
flex: 1;
|
||
min-height: 0;
|
||
overflow-y: auto;
|
||
padding: 0 8px 12px;
|
||
}
|
||
|
||
.wb-nav-item {
|
||
width: 100%;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 8px 10px;
|
||
border: none;
|
||
background: transparent;
|
||
border-radius: 8px;
|
||
cursor: pointer;
|
||
font-size: 13px;
|
||
color: #303133;
|
||
text-align: left;
|
||
transition: background 0.15s, color 0.15s;
|
||
}
|
||
|
||
.wb-nav-item:hover {
|
||
background: #eef1f6;
|
||
}
|
||
|
||
.wb-nav-item.active {
|
||
background: #ececec;
|
||
color: #1f2329;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.wb-sidebar.collapsed .wb-nav-item {
|
||
justify-content: center;
|
||
padding: 10px 0;
|
||
}
|
||
|
||
.wb-nav-icon {
|
||
width: 20px;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 16px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.wb-nav-label {
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.wb-join-project-body {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
}
|
||
|
||
.wb-join-project-label {
|
||
font-size: 12px;
|
||
color: #909399;
|
||
}
|
||
|
||
.wb-join-project-title {
|
||
font-size: 14px;
|
||
line-height: 1.5;
|
||
color: #303133;
|
||
word-break: break-word;
|
||
}
|
||
|
||
.wb-join-project-search {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
height: 36px;
|
||
padding: 0 10px;
|
||
border: 1px solid #e4e7ed;
|
||
border-radius: 10px;
|
||
background: #fff;
|
||
color: #909399;
|
||
}
|
||
|
||
.wb-join-project-search-input {
|
||
flex: 1;
|
||
min-width: 0;
|
||
border: none;
|
||
outline: none;
|
||
background: transparent;
|
||
font-size: 13px;
|
||
color: #303133;
|
||
font-family: inherit;
|
||
}
|
||
|
||
.wb-join-project-search-input::placeholder {
|
||
color: #c0c4cc;
|
||
}
|
||
|
||
.wb-join-project-list {
|
||
width: 100%;
|
||
max-height: 400px;
|
||
overflow-y: auto;
|
||
padding: 4px;
|
||
border: 1px solid #e4e7ed;
|
||
border-radius: 12px;
|
||
background: #fafbfc;
|
||
}
|
||
|
||
.wb-join-project-item {
|
||
width: 100%;
|
||
min-height: 38px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 8px;
|
||
padding: 0 10px;
|
||
border: none;
|
||
border-radius: 8px;
|
||
background: transparent;
|
||
color: #303133;
|
||
font-size: 13px;
|
||
font-family: inherit;
|
||
text-align: left;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.wb-join-project-item:hover {
|
||
background: #eef4ff;
|
||
}
|
||
|
||
.wb-join-project-item.selected {
|
||
background: #ecf5ff;
|
||
color: #409eff;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.wb-join-project-item-name {
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.wb-join-project-item-check {
|
||
flex: none;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.wb-join-project-empty {
|
||
padding: 14px 10px;
|
||
font-size: 13px;
|
||
color: #909399;
|
||
text-align: center;
|
||
}
|
||
|
||
/* ===== 最近任务 ===== */
|
||
/* 和上面的一级导航拉开距离:它们不是一类东西 ——
|
||
上面是「去哪儿」,这里是「正在做的哪件事」。 */
|
||
.wb-project-block,
|
||
.wb-task-block {
|
||
margin-top: 12px;
|
||
padding-top: 12px;
|
||
border-top: 1px solid #e8ecf1;
|
||
}
|
||
|
||
.wb-task-head {
|
||
padding: 0 8px 6px;
|
||
font-size: 11px;
|
||
color: #909399;
|
||
letter-spacing: 0.04em;
|
||
}
|
||
|
||
.wb-project-tree {
|
||
--wb-tree-row-height: 28px;
|
||
max-height: calc(var(--wb-tree-row-height) * 10);
|
||
overflow-y: auto;
|
||
overflow-x: hidden;
|
||
padding-right: 2px;
|
||
scrollbar-width: thin;
|
||
scrollbar-color: #c7ced8 transparent;
|
||
}
|
||
|
||
.wb-project-tree::-webkit-scrollbar {
|
||
width: 6px;
|
||
}
|
||
|
||
.wb-project-tree::-webkit-scrollbar-thumb {
|
||
background: #c7ced8;
|
||
border-radius: 999px;
|
||
}
|
||
|
||
.wb-project-tree::-webkit-scrollbar-track {
|
||
background: transparent;
|
||
}
|
||
|
||
.wb-project-group + .wb-project-group {
|
||
margin-top: 4px;
|
||
}
|
||
|
||
.wb-project-row {
|
||
box-sizing: border-box;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
min-height: var(--wb-tree-row-height);
|
||
padding: 4px 8px;
|
||
border-radius: 8px;
|
||
transition: background 0.15s, color 0.15s;
|
||
}
|
||
|
||
.wb-project-row:hover {
|
||
background: #eef1f6;
|
||
}
|
||
|
||
.wb-project-row.active {
|
||
background: #ececec;
|
||
}
|
||
|
||
.wb-project-caret {
|
||
flex: none;
|
||
width: 18px;
|
||
height: 18px;
|
||
padding: 0;
|
||
border: none;
|
||
background: transparent;
|
||
color: #8b95a1;
|
||
cursor: pointer;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.wb-project-caret span {
|
||
display: inline-block;
|
||
font-size: 11px;
|
||
transition: transform 0.15s ease;
|
||
}
|
||
|
||
.wb-project-caret .is-collapsed {
|
||
transform: rotate(-90deg);
|
||
}
|
||
|
||
.wb-project-name {
|
||
flex: 1;
|
||
min-width: 0;
|
||
padding: 0;
|
||
border: none;
|
||
background: transparent;
|
||
text-align: left;
|
||
font-size: 13px;
|
||
color: #303133;
|
||
font-weight: 600;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.wb-task-item {
|
||
/* 「小点」绝对定位的锚 */
|
||
position: relative;
|
||
box-sizing: border-box;
|
||
--wb-task-row-bg: #f7f8fa;
|
||
--wb-task-side-slot: 64px;
|
||
width: 100%;
|
||
display: flex;
|
||
align-items: center;
|
||
/* 右边留出「小点」的宽度,标题再长也不会钻到它下面 */
|
||
min-height: 32px;
|
||
padding: 6px 8px;
|
||
border: none;
|
||
background: transparent;
|
||
border-radius: 8px;
|
||
cursor: pointer;
|
||
text-align: left;
|
||
transition: background 0.15s, color 0.15s;
|
||
}
|
||
|
||
.wb-task-item--tree {
|
||
padding-left: 26px;
|
||
}
|
||
|
||
.wb-task-item:focus-visible {
|
||
outline: 2px solid #409eff;
|
||
outline-offset: -2px;
|
||
}
|
||
|
||
.wb-task-item:hover {
|
||
--wb-task-row-bg: #eef1f6;
|
||
background: #eef1f6;
|
||
}
|
||
|
||
.wb-task-item.active {
|
||
--wb-task-row-bg: #d9f2e6;
|
||
background: #d9f2e6;
|
||
}
|
||
|
||
.wb-task-main {
|
||
width: 100%;
|
||
min-width: 0;
|
||
padding-right: var(--wb-task-side-slot);
|
||
}
|
||
|
||
.wb-task-title {
|
||
display: block;
|
||
flex: 1;
|
||
min-width: 0;
|
||
font-size: 13px;
|
||
color: #303133;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.wb-task-item.active .wb-task-title {
|
||
color: #059669;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.wb-task-empty {
|
||
padding: 2px 8px 4px;
|
||
font-size: 12px;
|
||
line-height: 1.5;
|
||
color: #a8abb2;
|
||
}
|
||
|
||
/* ===== 任务行的「小点」与它的菜单 ===== */
|
||
/* 平时藏着,悬停这一行才浮出来。菜单开着的时候也要留着(menu-open),
|
||
否则鼠标一移进菜单,触发它的那个点就消失了,看着像菜单凭空悬空。 */
|
||
.wb-task-more-wrap {
|
||
position: absolute;
|
||
right: 4px;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
opacity: 0;
|
||
/* opacity 单独用不管用:透明元素照样吃点击,得把事件一起关掉 */
|
||
pointer-events: none;
|
||
transition: opacity 0.12s;
|
||
}
|
||
|
||
.wb-task-item:hover .wb-task-more-wrap,
|
||
.wb-task-item.menu-open .wb-task-more-wrap,
|
||
.wb-task-item:focus-visible .wb-task-more-wrap {
|
||
opacity: 1;
|
||
pointer-events: auto;
|
||
}
|
||
|
||
.wb-task-time {
|
||
position: absolute;
|
||
right: 4px;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
flex-shrink: 0;
|
||
width: calc(var(--wb-task-side-slot) - 8px);
|
||
font-size: 11px;
|
||
color: #a8abb2;
|
||
white-space: nowrap;
|
||
text-align: right;
|
||
transition: opacity 0.12s;
|
||
pointer-events: none;
|
||
padding-left: 6px;
|
||
background: linear-gradient(to right, rgba(255, 255, 255, 0), var(--wb-task-row-bg) 36%);
|
||
}
|
||
|
||
.wb-task-item:hover .wb-task-time,
|
||
.wb-task-item.menu-open .wb-task-time,
|
||
.wb-task-item:focus-visible .wb-task-time {
|
||
opacity: 0;
|
||
}
|
||
|
||
.wb-task-more {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 20px;
|
||
height: 20px;
|
||
padding: 0;
|
||
border: none;
|
||
border-radius: 5px;
|
||
background: var(--wb-task-row-bg);
|
||
color: #909399;
|
||
cursor: pointer;
|
||
transition: background 0.12s, color 0.12s;
|
||
}
|
||
|
||
.wb-task-more:hover {
|
||
background: rgba(0, 0, 0, 0.08);
|
||
color: #303133;
|
||
}
|
||
|
||
/* 置顶是个不该靠悬停才知道的状态,所以图钉常驻显示 */
|
||
.wb-task-pin {
|
||
margin-right: 3px;
|
||
font-size: 11px;
|
||
color: #059669;
|
||
vertical-align: -1px;
|
||
}
|
||
|
||
/* 就地重命名:这一行的标题直接被输入框顶掉 */
|
||
.wb-task-rename {
|
||
width: 100%;
|
||
padding: 2px 6px;
|
||
font-size: 13px;
|
||
color: #303133;
|
||
background: #fff;
|
||
border: 1px solid #409eff;
|
||
border-radius: 6px;
|
||
outline: none;
|
||
}
|
||
|
||
.wb-sidebar-foot {
|
||
flex-shrink: 0;
|
||
padding: 10px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
|
||
/* Let the account trigger stretch across the rail. */
|
||
.wb-sidebar-foot :deep(.el-dropdown) {
|
||
width: 100%;
|
||
}
|
||
|
||
.wb-user {
|
||
width: 100%;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
min-width: 0;
|
||
padding: 6px 8px;
|
||
border: none;
|
||
border-radius: 8px;
|
||
background: transparent;
|
||
cursor: pointer;
|
||
text-align: left;
|
||
transition: background 0.15s;
|
||
}
|
||
|
||
.wb-user:hover {
|
||
background: #eef1f6;
|
||
}
|
||
|
||
.wb-sidebar.collapsed .wb-user {
|
||
justify-content: center;
|
||
padding: 6px 0;
|
||
}
|
||
|
||
.wb-avatar {
|
||
width: 30px;
|
||
height: 30px;
|
||
border-radius: 50%;
|
||
background: #409eff;
|
||
color: #fff;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.wb-user-meta {
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-width: 0;
|
||
}
|
||
|
||
.wb-user-name {
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
color: #303133;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.wb-user-role {
|
||
font-size: 11px;
|
||
color: #909399;
|
||
}
|
||
|
||
/* ===== MAIN COLUMN ===== */
|
||
.wb-main {
|
||
flex: 1;
|
||
min-width: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* 工作区胶囊条相关样式已随架构收敛移除。 */
|
||
|
||
/* ===== CONTENT ===== */
|
||
.wb-content {
|
||
flex: 1;
|
||
min-height: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.wb-task-titlebar {
|
||
flex: none;
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 10px 24px 4px;
|
||
background: transparent;
|
||
}
|
||
|
||
.wb-task-titlebar-text {
|
||
min-width: 0;
|
||
max-width: min(100%, 720px);
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
line-height: 1.5;
|
||
color: #606266;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
/* Keeps the section tabs pinned while the page itself scrolls. */
|
||
.wb-content-scroll {
|
||
flex: 1;
|
||
min-height: 0;
|
||
overflow: auto;
|
||
}
|
||
|
||
/* ===== RESPONSIVE ===== */
|
||
/* Narrow viewports force the rail collapsed. Labels are hidden here too,
|
||
because the markup (not the stylesheet) normally decides that. */
|
||
@media (max-width: 900px) {
|
||
.wb-sidebar {
|
||
width: 60px;
|
||
min-width: 60px;
|
||
}
|
||
.wb-brand-name,
|
||
.wb-nav-label,
|
||
.wb-user-meta {
|
||
display: none;
|
||
}
|
||
.wb-head-row {
|
||
flex-direction: column;
|
||
}
|
||
.wb-brand {
|
||
justify-content: center;
|
||
padding: 4px 0 6px;
|
||
}
|
||
.wb-user {
|
||
justify-content: center;
|
||
padding: 6px 0;
|
||
}
|
||
.wb-nav-item {
|
||
justify-content: center;
|
||
padding: 10px 0;
|
||
}
|
||
.wb-task-titlebar {
|
||
padding: 8px 16px 2px;
|
||
}
|
||
}
|
||
</style>
|