Files
pj0235-eai_agentplatform/eai_agentplatform/frontend/src/layout/MainLayout.vue
T
eaiadminandClaude Code 14f303459e refactor: 后端仓库层收口(A1:课程/产品/素材)+ 收进工作区既有对象化重构
本提交含两部分。第一部分是本轮工作;第二部分是此前一直留在工作区、
从未提交的对象化重构,与第一部分在文件上互相咬合(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>
2026-09-19 01:23:51 +08:00

1529 lines
44 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>