feat: 工作台外壳收尾 + 冻结旧导航 + 任务延迟创建与最近任务行菜单
外壳收尾 - 后端:内置「通用智能助手」(specialist.state = system)—— 它是任务的默认归属, 不是可安装的专员,常规列表一律挡掉,只有显式带 state=system 才查得到 - config/nav.js:一级导航、二级 tab、搜索面板共用一份配置,替代原先散在 layout/ 与 config/workbench.js 里的两套,旧的那批导航导出随之下架 - NavSearch.vue / SectionTabs.vue 组件化;考试 8 个页面收进 ExamTabsView 统一外壳 - 新增 /apps 专员选择页(SpecialistPickerPage.vue) - ChatInputBar 重构:输入框的 + 拆为独立组件 PlusMenu.vue,新增 update:mode 事件 - 删除 LibraryTabs.vue(已被 SectionTabs 取代) 冻结旧导航(新增 frontend/src/_frozen/ + FROZEN.md 登记表) - 工作区胶囊条连通知铃铛一起冻:外壳重构后成了冗余的第二套导航。不删代码, 抽成自包含的 WorkspaceTabs.frozen.vue 归档,将来能原样放回 - 同源孤儿一并收拢:旧 SideNav / SectionTabs / navigation.js / Notification.vue - 统一加 .frozen 后缀,登记表逐条写明原位置 / 冻结原因 / 外部依赖 / 解冻条件 / 解冻步骤 - 已验:冻结物不进构建产物(dist 里搜不到 wb-tabs),src/ 下无代码引用, 只有 MainLayout 里指向 FROZEN.md 的指引注释 - 后端通知四接口与 store/api 层保留(BusinessAppPage 仍在用),解冻是纯前端工作量 我的任务 - 任务记录延迟到发出第一条消息时创建:新建任务只回到开场画布,不落库, 标题取第一句话,专员取输入框 + 里先选的那个(pendingSpecialistKey 暂存, 建任务时一并提交)。原实现点一次落一条「新任务」,是「最近任务」堆满空行的根因 - 最近任务行菜单:重命名(就地编辑,回车提交 / Esc 取消)、置顶、删除 (硬删无回收站,确认框写明无法恢复)。新增 worker_task.pinned 字段、 DELETE /api/worker/my-tasks/:id,列表排序改为 pinned DESC, updated_at DESC; el-dropdown 加 :persistent="false"(默认每行留一份隐藏菜单在 DOM 里) - 对照 ChatGPT / WorkBuddy 后决定不做:分享任务(纯内网)、打开文件夹(Web 无此能力)、 保存到工作空间(无 workspace 概念)、归档(没有「已归档」入口可找回) - 当前任务高亮改用 WorkBuddy 绿;空列表给出「发送第一条消息后出现在这里」提示 - PROJECT_STATE.md 补记 D21(冻结机制)/ D22(延迟建任务)/ D23(行菜单) Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
# 冻结件登记表(FROZEN)
|
||||
|
||||
本目录存放**已停用但完整保留**的代码:不参与构建、不被任何存活代码引用,但随时可回收。
|
||||
|
||||
冻结不是删除。删掉的东西要靠记忆和 git log 才能找回来;冻结的东西保持可读、可搜索、
|
||||
并在此处登记它的来龙去脉。**「解冻条件」这一列是核心** —— 没有它,这批代码半年后没人
|
||||
敢动,就从资产变成了负债。
|
||||
|
||||
## 约定
|
||||
|
||||
- 目录 `src/_frozen/`,所有冻结文件统一加 `.frozen` 后缀
|
||||
- 冻结件**不 import 存活代码以外的路径**;冻结件之间用相对引用,自成封闭单元
|
||||
- 冻结件保留原有注释与代码原貌,只在文件头加一段「原位置 / 冻结日期」说明
|
||||
- 冻结件内引用的 store / api **未必也冻结**(见下表「外部依赖」列),解冻前先核对
|
||||
|
||||
## 登记表
|
||||
|
||||
| 冻结物 | 原位置 | 为什么冻 | 外部依赖 | 解冻条件 | 解冻步骤 |
|
||||
|---|---|---|---|---|---|
|
||||
| `WorkspaceTabs.frozen.vue`<br>工作区胶囊条 + 通知铃铛 | `src/layout/MainLayout.vue` 模板 101-128 行、`isTabActive()` 314-318、`closeTab()` 375-388、通知轮询 240-241/248、样式 727-802 | 外壳重构后一级导航移到侧边栏、二级导航移到内容区 `components/SectionTabs.vue`,这行胶囊成了冗余的第二套导航;且铃铛指向未注册的 `/notifications`,是点了没反应的死按钮 | ✅ 存活:`@/store/workbench`、`@/store/notification`、`@/config/nav`、`vue-router`、`@element-plus/icons-vue` | 需要「跨一级项快速切换任务」这一交互时;或恢复通知功能时(铃铛含在内) | 在 `MainLayout.vue` 的 `.wb-main` 内、`<!-- Content -->` 之前挂 `<WorkspaceTabs />` 并 import。组件已自包含,无需改动内部代码 |
|
||||
| `SectionTabs.frozen.vue`<br>旧二级导航(`el-tabs` 形态) | `src/layout/SectionTabs.vue` | 被 `src/components/SectionTabs.vue` 取代(新的由 `config/nav.js` 驱动,改为原生 button + 下划线)。**注意与存活的那个同名,勿混淆** | ✅ 存活:`@/store/auth`、`@/store/notification`<br>🔒 冻结:`./navigation.frozen.js` | 想要 `el-tabs` 形态的二级导航时(旧版自带 el-badge,与 Element 风格更统一) | 1. 放回 `src/layout/`;2. 把 import 改回 `@/config/navigation`,并将 `navigation.frozen.js` 放回 `src/config/`;3. 在 `MainLayout.vue` 挂载 |
|
||||
| `SideNav.frozen.vue`<br>旧侧边栏 | `src/layout/SideNav.vue` | 被 `MainLayout.vue` 内联的新侧边栏取代 | ✅ 存活:`@/store/auth`、`@/store/notification`<br>🔒 冻结:`./navigation.frozen.js` | 想把侧边栏从 `MainLayout.vue` 里拆成独立组件时 —— 旧版结构上比现在的内联写法更清晰 | 同 `SectionTabs.frozen.vue`:放回 `src/layout/`,import 改回 `@/config/navigation` 并还原 `navigation.frozen.js` |
|
||||
| `navigation.frozen.js`<br>旧导航配置 | `src/config/navigation.js` | 被 `src/config/nav.js` 取代(`nav.js` 收敛了原先 `sidebarGroups` / `LibraryTabs` / `ExamTabs` 三份重复清单) | 无 | 随上面两个组件一起解冻 | 放回 `src/config/` 并去掉文件名里的 `.frozen` |
|
||||
| `Notification.frozen.vue`<br>站内通知页面(175 行,实现完整) | `src/views/Notification.vue` | `router/index.js` 里没有 `/notifications` 路由,页面无入口,已成孤儿 | ✅ 存活:`@/api/notification`、`@/store/notification`<br>✅ 后端四个接口仍在(`backend-go/internal/api/router.go:90-93`) | 要恢复站内通知时。**是纯前端工作量** —— 后端接口从未下线 | 1. 放回 `src/views/`;2. 在 `router/index.js` 注册 `/notifications`;3. 补导航入口(`config/nav.js` 里没有它,需新增);4. 参考 `WorkspaceTabs.frozen.vue` 把铃铛加回 |
|
||||
|
||||
## 已知遗留(本次未修,改动存活代码时注意)
|
||||
|
||||
铃铛冻结后,`store/notification.js` 的 `unread` 在前端**已无任何 UI 消费者**,
|
||||
但 `src/views/workbench/BusinessAppPage.vue`(825 / 855 / 892 行)仍会调用
|
||||
`notificationStore.refresh()`。属于既有死端,本次一并冻掉铃铛后更明显。
|
||||
若将来确认通知不再恢复,可连同 `store/notification.js`、`api/notification.js`
|
||||
的引用一起清理。
|
||||
|
||||
## 冻结记录
|
||||
|
||||
| 日期 | 动作 |
|
||||
|---|---|
|
||||
| 2026-09-14 | 建立本目录与约定。冻结工作区胶囊条(`WorkspaceTabs.frozen.vue`,新抽出);收拢外壳重构遗留的四个孤儿(`SectionTabs` / `SideNav` / `navigation` / `Notification`) |
|
||||
+2
-1
@@ -33,7 +33,8 @@ import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { useNotificationStore } from '@/store/notification'
|
||||
import { resolveSection, resolveTab } from '@/config/navigation'
|
||||
// 冻结件之间用相对引用,让这批归档自成一个封闭单元(原为 @/config/navigation)
|
||||
import { resolveSection, resolveTab } from './navigation.frozen.js'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
+2
-1
@@ -65,7 +65,8 @@ import {
|
||||
} from '@element-plus/icons-vue'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { useNotificationStore } from '@/store/notification'
|
||||
import { getVisibleSections, resolveSection } from '@/config/navigation'
|
||||
// 冻结件之间用相对引用,让这批归档自成一个封闭单元(原为 @/config/navigation)
|
||||
import { getVisibleSections, resolveSection } from './navigation.frozen.js'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -0,0 +1,179 @@
|
||||
<!--
|
||||
【冻结件 · 请勿直接引用】工作区胶囊条(含通知铃铛)
|
||||
|
||||
原位置:src/layout/MainLayout.vue
|
||||
- 模板 101-128 行 <div class="wb-tabs"> 整块
|
||||
- 脚本 isTabActive() 314-318 行、closeTab() 375-388 行
|
||||
- 脚本 通知轮询 onMounted 240-241 行 / onBeforeUnmount 248 行
|
||||
- 样式 .wb-icon-btn 与 .wb-tabs* 727-802 行
|
||||
冻结于:2026-09-14
|
||||
冻结原因与解冻步骤:见同目录 FROZEN.md
|
||||
|
||||
为什么自包含:本组件不依赖 MainLayout 的任何局部变量(activeSection 自己算),
|
||||
将来原样放回任意位置即可工作。唯一改动是 activeSection 的来源,见下方注释。
|
||||
-->
|
||||
<template>
|
||||
<div class="wb-tabs">
|
||||
<div class="wb-tabs-list">
|
||||
<button
|
||||
v-for="tab in openTabs"
|
||||
:key="tab.route"
|
||||
class="wb-tab"
|
||||
:class="{ active: isTabActive(tab) }"
|
||||
@click="router.push(tab.route)"
|
||||
>
|
||||
<span>{{ tab.label }}</span>
|
||||
<el-icon
|
||||
v-if="tab.closable"
|
||||
class="wb-tab-close"
|
||||
@click.stop="closeTab(tab.route)"
|
||||
>
|
||||
<Close />
|
||||
</el-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="wb-tabs-actions">
|
||||
<el-badge :value="unread" :max="99" :hidden="unread === 0">
|
||||
<button class="wb-icon-btn" aria-label="通知" @click="router.push('/notifications')">
|
||||
<el-icon><Bell /></el-icon>
|
||||
</button>
|
||||
</el-badge>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Bell, Close } from '@element-plus/icons-vue'
|
||||
import { useNotificationStore } from '@/store/notification'
|
||||
import { useWorkbenchStore } from '@/store/workbench'
|
||||
import { findSectionByPath, navSections } from '@/config/nav'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const workbench = useWorkbenchStore()
|
||||
const notif = useNotificationStore()
|
||||
|
||||
const openTabs = computed(() => workbench.openTabs)
|
||||
const unread = computed(() => notif.unread)
|
||||
|
||||
// 与原实现唯一的差异:MainLayout 里 activeSection 来自做过权限过滤的 visibleSections,
|
||||
// 这里直接按当前路由找。isTabActive 只比较 section.key,不做权限过滤也不影响结果,
|
||||
// 换来的是这个组件可以被独立放置。
|
||||
const activeSection = computed(() => findSectionByPath(navSections, route.path))
|
||||
|
||||
let timer = null
|
||||
|
||||
onMounted(() => {
|
||||
notif.refresh()
|
||||
timer = setInterval(() => notif.refresh(), 60000)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (timer) clearInterval(timer)
|
||||
})
|
||||
|
||||
// 胶囊高亮:/home 有自己的钉子胶囊(智能助手,不可关),所以停在 /home 时
|
||||
// 只点亮它;其余情况按「当前路由属于哪个一级项」点亮那一枚。
|
||||
function isTabActive(tab) {
|
||||
if (tab.route === '/home') return route.path === '/home'
|
||||
if (route.path === '/home') return false
|
||||
return activeSection.value?.key === tab.sectionKey
|
||||
}
|
||||
|
||||
function closeTab(path) {
|
||||
const tabs = openTabs.value
|
||||
const index = tabs.findIndex((tab) => tab.route === path && tab.closable)
|
||||
if (index === -1) return
|
||||
// 胶囊是一级粒度,所以「关的是不是当前这枚」不能拿路径相等去比 ——
|
||||
// 人可能停在组里任意一页。按归属判断。
|
||||
const closingCurrent = isTabActive(tabs[index])
|
||||
workbench.closeTab(path)
|
||||
if (closingCurrent) {
|
||||
const nextTabs = workbench.openTabs
|
||||
const fallback = nextTabs[index - 1] || nextTabs[nextTabs.length - 1]
|
||||
router.push(fallback?.route || '/home')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.wb-icon-btn {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid #e8ecf1;
|
||||
background: #f5f7fa;
|
||||
color: #606266;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.wb-icon-btn:hover {
|
||||
background: #eef1f6;
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
/* ===== WORKSPACE TABS (topmost row) ===== */
|
||||
.wb-tabs {
|
||||
height: 48px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0 20px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #eef1f6;
|
||||
}
|
||||
|
||||
.wb-tabs-list {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.wb-tabs-actions {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.wb-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: 1px solid #e8ecf1;
|
||||
border-radius: 999px;
|
||||
background: #f8fafc;
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
color: #526071;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.wb-tab:hover {
|
||||
border-color: #c8d8ea;
|
||||
}
|
||||
|
||||
.wb-tab.active {
|
||||
background: #e1effe;
|
||||
border-color: #409eff;
|
||||
color: #409eff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.wb-tab-close {
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -4,6 +4,23 @@ export function listWorkerTasks(params) {
|
||||
return http.get('/worker/tasks', { params })
|
||||
}
|
||||
|
||||
// 「我的任务」:跨专员,归属由后端按登录用户定,前端不传 owner。
|
||||
export function listMyTasks() {
|
||||
return http.get('/worker/my-tasks')
|
||||
}
|
||||
|
||||
export function createMyTask(data = {}) {
|
||||
return http.post('/worker/my-tasks', data)
|
||||
}
|
||||
|
||||
export function updateMyTask(id, data) {
|
||||
return http.put(`/worker/my-tasks/${id}`, data)
|
||||
}
|
||||
|
||||
export function deleteMyTask(id) {
|
||||
return http.delete(`/worker/my-tasks/${id}`)
|
||||
}
|
||||
|
||||
export function getWorkerTaskDetail(id) {
|
||||
return http.get(`/worker/tasks/${id}`)
|
||||
}
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
<template>
|
||||
<!-- Second-level navigation for 资料库. The sidebar carries a single 资料库
|
||||
entry; the sections below it switch here instead of in the sidebar. -->
|
||||
<nav v-if="visibleSections.length > 1" class="library-tabs">
|
||||
<button
|
||||
v-for="section in visibleSections"
|
||||
:key="section.route"
|
||||
class="library-tab"
|
||||
:class="{ active: isCurrent(section.route) }"
|
||||
:title="section.caption"
|
||||
@click="router.push(section.route)"
|
||||
>
|
||||
{{ section.label }}
|
||||
</button>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { knowledgeLinks } from '@/config/workbench'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const visibleSections = computed(() =>
|
||||
knowledgeLinks.filter((section) => !section.adminOnly || auth.isAdmin)
|
||||
)
|
||||
|
||||
function isCurrent(path) {
|
||||
return route.path === path || route.path.startsWith(`${path}/`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.library-tabs {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0 20px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e8ecf1;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.library-tab {
|
||||
position: relative;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 12px 14px;
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.library-tab:hover {
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.library-tab.active {
|
||||
color: #409eff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.library-tab.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
right: 14px;
|
||||
bottom: -1px;
|
||||
height: 2px;
|
||||
border-radius: 2px;
|
||||
background: #409eff;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,253 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="modelValue" class="nav-search-mask" @click.self="close">
|
||||
<div class="nav-search-panel" role="dialog" aria-label="搜索任务">
|
||||
<div class="nav-search-input-row">
|
||||
<el-icon class="nav-search-icon"><Search /></el-icon>
|
||||
<input
|
||||
ref="inputRef"
|
||||
v-model="keyword"
|
||||
class="nav-search-input"
|
||||
type="text"
|
||||
placeholder="搜索任务、页面…"
|
||||
@keydown.esc.prevent="close"
|
||||
@keydown.down.prevent="move(1)"
|
||||
@keydown.up.prevent="move(-1)"
|
||||
@keydown.enter.prevent="openCursor()"
|
||||
/>
|
||||
<span class="nav-search-hint">Esc</span>
|
||||
</div>
|
||||
|
||||
<div v-if="groups.length" class="nav-search-results">
|
||||
<div v-for="group in groups" :key="group.key" class="nav-search-group">
|
||||
<div class="nav-search-group-label">{{ group.label }}</div>
|
||||
<button
|
||||
v-for="item in group.items"
|
||||
:key="item.route"
|
||||
class="nav-search-item"
|
||||
:class="{ cursor: flat.indexOf(item) === cursor }"
|
||||
@click="go(item)"
|
||||
@mouseenter="cursor = flat.indexOf(item)"
|
||||
>
|
||||
<span class="nav-search-item-label">{{ item.label }}</span>
|
||||
<span class="nav-search-item-meta">{{ item.meta }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="nav-search-empty">没有匹配的条目</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Search } from '@element-plus/icons-vue'
|
||||
import { sectionEntries } from '@/config/nav'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
// 一级导航(含二级项),用于搜索页面
|
||||
sections: { type: Array, default: () => [] },
|
||||
// 已打开的工作区标签,即用户最近在做的任务
|
||||
tabs: { type: Array, default: () => [] },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const router = useRouter()
|
||||
const inputRef = ref(null)
|
||||
const keyword = ref('')
|
||||
const cursor = ref(0)
|
||||
|
||||
const NAV_LIMIT = 8
|
||||
const TAB_LIMIT = 6
|
||||
|
||||
const navEntries = computed(() =>
|
||||
props.sections.flatMap((section) =>
|
||||
// 分组的一级项副标题用组名(专员 / 工具 / 连接器),
|
||||
// 否则满屏都是同一个「专员·工具·连接器」,看不出这条在哪一格。
|
||||
sectionEntries(section).map((item) => ({
|
||||
label: item.label,
|
||||
route: item.route,
|
||||
meta: item.group || section.label,
|
||||
}))
|
||||
)
|
||||
)
|
||||
|
||||
const tabEntries = computed(() =>
|
||||
props.tabs.map((tab) => ({ label: tab.label, route: tab.route, meta: '最近打开' }))
|
||||
)
|
||||
|
||||
function matches(entry, query) {
|
||||
if (!query) return true
|
||||
return (
|
||||
entry.label.toLowerCase().includes(query) ||
|
||||
entry.meta.toLowerCase().includes(query)
|
||||
)
|
||||
}
|
||||
|
||||
const groups = computed(() => {
|
||||
const query = keyword.value.trim().toLowerCase()
|
||||
const tabs = tabEntries.value.filter((item) => matches(item, query)).slice(0, TAB_LIMIT)
|
||||
const nav = navEntries.value.filter((item) => matches(item, query)).slice(0, query ? 30 : NAV_LIMIT)
|
||||
return [
|
||||
{ key: 'tabs', label: '最近打开', items: tabs },
|
||||
{ key: 'nav', label: '导航', items: nav },
|
||||
].filter((group) => group.items.length)
|
||||
})
|
||||
|
||||
// 光标在两组之间连续移动,所以展开成一个扁平列表
|
||||
const flat = computed(() => groups.value.flatMap((group) => group.items))
|
||||
|
||||
watch(flat, () => {
|
||||
cursor.value = 0
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
async (open) => {
|
||||
if (!open) return
|
||||
keyword.value = ''
|
||||
cursor.value = 0
|
||||
await nextTick()
|
||||
inputRef.value?.focus()
|
||||
}
|
||||
)
|
||||
|
||||
function move(step) {
|
||||
if (!flat.value.length) return
|
||||
cursor.value = (cursor.value + step + flat.value.length) % flat.value.length
|
||||
}
|
||||
|
||||
function openCursor() {
|
||||
const item = flat.value[cursor.value]
|
||||
if (item) go(item)
|
||||
}
|
||||
|
||||
function go(item) {
|
||||
router.push(item.route)
|
||||
close()
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.nav-search-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 3000;
|
||||
background: rgba(15, 23, 42, 0.35);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 12vh 16px 16px;
|
||||
}
|
||||
|
||||
.nav-search-panel {
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 20px 50px rgba(15, 23, 42, 0.22);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 70vh;
|
||||
}
|
||||
|
||||
.nav-search-input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid #eef1f6;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nav-search-icon {
|
||||
color: #909399;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.nav-search-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
color: #303133;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.nav-search-input::placeholder {
|
||||
color: #c0c8d4;
|
||||
}
|
||||
|
||||
.nav-search-hint {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
color: #a8b0bb;
|
||||
border: 1px solid #e8ecf1;
|
||||
border-radius: 4px;
|
||||
padding: 1px 5px;
|
||||
}
|
||||
|
||||
.nav-search-results {
|
||||
overflow-y: auto;
|
||||
padding: 6px 8px 10px;
|
||||
}
|
||||
|
||||
.nav-search-group-label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #909399;
|
||||
letter-spacing: 0.5px;
|
||||
padding: 8px 10px 4px;
|
||||
}
|
||||
|
||||
.nav-search-item {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 9px 10px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-size: 13px;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.nav-search-item.cursor {
|
||||
background: #e1effe;
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.nav-search-item-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nav-search-item-meta {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
color: #a8b0bb;
|
||||
}
|
||||
|
||||
.nav-search-empty {
|
||||
padding: 28px 16px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,144 @@
|
||||
<template>
|
||||
<!-- 二级导航:侧边栏只保留一级导航,一级项下面的页面在这里以 tab 条展开。
|
||||
带 groups 的一级项(专员·工具·连接器)画两行:上面选组,下面选该组里的页面。
|
||||
只有一行的那些一级项照旧。 -->
|
||||
<div v-if="groups.length || tabItems.length > 1" class="section-tabs">
|
||||
<nav v-if="groups.length" class="section-tabs-row section-tabs-groups" aria-label="分组">
|
||||
<button
|
||||
v-for="group in groups"
|
||||
:key="group.key"
|
||||
class="section-tab"
|
||||
:class="{ active: group.key === groupKey }"
|
||||
@click="openGroup(group)"
|
||||
>
|
||||
{{ group.label }}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<nav v-if="tabItems.length > 1" class="section-tabs-row" aria-label="页面">
|
||||
<button
|
||||
v-for="item in tabItems"
|
||||
:key="item.route"
|
||||
class="section-tab"
|
||||
:class="{ active: isCurrent(item.route) }"
|
||||
:title="item.caption || item.label"
|
||||
@click="router.push(item.route)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
const props = defineProps({
|
||||
items: { type: Array, default: () => [] },
|
||||
// 分组的一级项才传:数组里每项 { key, label, items }
|
||||
groups: { type: Array, default: () => [] },
|
||||
// 当前路由落在哪个组
|
||||
groupKey: { type: String, default: '' },
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
// 在组里但不在 tab 条上露出的项(比如每个专员卡片)要跳过去,不能当成落点
|
||||
function landing(group) {
|
||||
return group.items.find((item) => !item.hiddenInTabs) || group.items[0]
|
||||
}
|
||||
|
||||
const tabItems = computed(() => {
|
||||
if (!props.groups.length) return props.items
|
||||
const group = props.groups.find((item) => item.key === props.groupKey)
|
||||
if (!group) return []
|
||||
return group.items.filter((item) => !item.hiddenInTabs)
|
||||
})
|
||||
|
||||
function openGroup(group) {
|
||||
const target = landing(group)
|
||||
if (target) router.push(target.route)
|
||||
}
|
||||
|
||||
function isCurrent(path) {
|
||||
return route.path === path || route.path.startsWith(`${path}/`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.section-tabs {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e8ecf1;
|
||||
}
|
||||
|
||||
.section-tabs-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0 20px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.section-tab {
|
||||
position: relative;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 12px 14px;
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.section-tab:hover {
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.section-tab.active {
|
||||
color: #409eff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.section-tab.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
right: 14px;
|
||||
bottom: -1px;
|
||||
height: 2px;
|
||||
border-radius: 2px;
|
||||
background: #409eff;
|
||||
}
|
||||
|
||||
/* 组那一行是"容器",比下面的页面行重一点:字大一号、选中的用实心底。
|
||||
两行都画同一条下划线的话,看不出哪一行在决定哪一行。 */
|
||||
.section-tabs-groups {
|
||||
padding-top: 4px;
|
||||
background: #fbfcfd;
|
||||
}
|
||||
|
||||
.section-tabs-groups .section-tab {
|
||||
padding: 9px 14px;
|
||||
font-size: 13px;
|
||||
border-radius: 8px;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.section-tabs-groups .section-tab:hover {
|
||||
background: #eef4ff;
|
||||
}
|
||||
|
||||
.section-tabs-groups .section-tab.active {
|
||||
background: #e1effe;
|
||||
}
|
||||
|
||||
.section-tabs-groups .section-tab.active::after {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="chat-input-bar" :class="{ focused }">
|
||||
<div class="chat-input-bar" :class="[`variant-${variant}`, { focused }]">
|
||||
<textarea
|
||||
ref="textareaRef"
|
||||
v-model="localValue"
|
||||
@@ -13,15 +13,8 @@
|
||||
/>
|
||||
<div class="input-bottom-bar">
|
||||
<div class="input-left-actions">
|
||||
<button
|
||||
v-for="action in actions"
|
||||
:key="action.key"
|
||||
class="input-action-btn"
|
||||
:title="action.title"
|
||||
@click="action.handler && action.handler()"
|
||||
>
|
||||
{{ action.icon }}
|
||||
</button>
|
||||
<!-- 加号:唯一的左下角入口,点开是两级菜单(模式 / 专员 / 工具 / 连接器) -->
|
||||
<PlusMenu :modes="modes" :model-value="mode" @update:model-value="emit('update:mode', $event)" />
|
||||
<slot name="left-actions"></slot>
|
||||
</div>
|
||||
<div class="input-right-actions">
|
||||
@@ -45,6 +38,7 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import PlusMenu from '@/components/chat/PlusMenu.vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
@@ -67,17 +61,22 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showActions: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
actions: {
|
||||
// 传给 + 菜单的「模式」列表;tool 页没有模式,留空即不显示这一项。
|
||||
modes: {
|
||||
type: Array,
|
||||
default: () => [
|
||||
{ key: 'attach', icon: '📎', title: '文件附件', handler: null },
|
||||
{ key: 'voice', icon: '🎤', title: '语音输入', handler: null },
|
||||
{ key: 'reference', icon: '📑', title: '引用', handler: null },
|
||||
],
|
||||
default: () => [],
|
||||
},
|
||||
// 当前模式。注意它跟 v-model 不是一回事 —— v-model 是输入框里的文字。
|
||||
mode: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
// docked:贴底通栏,靠外层的白条托住(对话进行中)。
|
||||
// card:自成一张白卡片,输入区和动作按钮同框(任务开始前的空状态)。
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'docked',
|
||||
validator: (value) => ['docked', 'card'].includes(value),
|
||||
},
|
||||
quickMode: {
|
||||
type: Boolean,
|
||||
@@ -89,7 +88,7 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'send', 'quickMode', 'focus'])
|
||||
const emit = defineEmits(['update:modelValue', 'update:mode', 'send', 'quickMode', 'focus'])
|
||||
|
||||
const localValue = computed({
|
||||
get: () => props.modelValue,
|
||||
@@ -192,6 +191,25 @@ defineExpose({ focus })
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
/* 加号:输入框左下角唯一的按钮。它打开的是两级菜单(模式/专员/工具/连接器),
|
||||
菜单本身的样式在 PlusMenu.vue 里,这里只管那颗按钮。 */
|
||||
.input-plus-btn {
|
||||
border: 1px solid #e0e6ed;
|
||||
border-radius: 50%;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin-right: 4px;
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.input-plus-btn:hover {
|
||||
background: #ecf5ff;
|
||||
border-color: #409eff;
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.input-right-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -216,42 +234,82 @@ defineExpose({ focus })
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
/* 发送键照 WorkBuddy 量出来的来:32×32 的实心圆,箭头是圆里挖出来的白洞。
|
||||
有字时圆是近黑(对方实测 path fill rgba(0,0,0,.9),落在白卡上就是 #1a1a1a),
|
||||
没字时同一个圆降到 36% 黑,不用另换一个颜色或藏起来。
|
||||
之前那版是蓝底 + 蓝色投影,跟对不上。 */
|
||||
.send-circle {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: #409eff;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: #1a1a1a;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
box-shadow: 0 2px 8px rgba(64,158,255,0.3);
|
||||
padding: 0;
|
||||
transition: background 0.2s, transform 0.15s;
|
||||
}
|
||||
|
||||
.send-circle:hover {
|
||||
transform: scale(1.08);
|
||||
box-shadow: 0 4px 12px rgba(64,158,255,0.4);
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.send-circle:active {
|
||||
transform: scale(0.95);
|
||||
transform: scale(0.94);
|
||||
}
|
||||
|
||||
.send-circle.disabled {
|
||||
background: #dcdfe6;
|
||||
box-shadow: none;
|
||||
background: rgba(0, 0, 0, 0.36);
|
||||
cursor: default;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* ===== card 变体:输入框和动作按钮同一个白框 ===== */
|
||||
/* 空状态里没有贴底白条托着,所以卡片得自己长出来 —— 否则按钮会飘在灰底上。
|
||||
参照 WorkBuddy:不画描边,靠一层很淡的投影把卡片托起来(实测对方是
|
||||
0 12px 24px -8px rgba(0,0,0,.02) / 0 2px 4px rgba(0,0,0,.02),
|
||||
这里略加重一点,因为它家底色比我们更浅,原值在我们这儿看不出来)。 */
|
||||
.chat-input-bar.variant-card {
|
||||
padding: 12px 12px 8px;
|
||||
background: #fff;
|
||||
border: none;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 12px 24px -8px rgba(0, 0, 0, 0.06), 0 2px 4px rgba(0, 0, 0, 0.04);
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
|
||||
/* 没有描边可用,聚焦就靠一圈浅蓝光环 */
|
||||
.chat-input-bar.variant-card.focused {
|
||||
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.18), 0 12px 24px -8px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
/* 描边交给外层卡片,输入区自己不要再画一圈;高度也照它放大到 72px */
|
||||
.chat-input-bar.variant-card .input-textarea {
|
||||
min-height: 72px;
|
||||
padding: 2px 2px 6px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.chat-input-bar.variant-card .input-textarea:focus {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.chat-input-bar.variant-card .input-bottom-bar {
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
/* Mobile: compact padding */
|
||||
@media (max-width: 768px) {
|
||||
.chat-input-bar {
|
||||
padding: 0 12px;
|
||||
}
|
||||
.chat-input-bar.variant-card {
|
||||
padding: 10px 12px 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -81,9 +81,17 @@
|
||||
<!-- Welcome state -->
|
||||
<div v-if="!hasMessages" class="welcome-state">
|
||||
<slot name="welcome">
|
||||
<div class="welcome-title">我 Bosheng 数字员工,我帮你</div>
|
||||
<div class="welcome-title">博昇AI数字员工,您说,我做</div>
|
||||
<div class="welcome-subtitle">今天帮你做些什么? @ 引用对话文件,/ 调用技能与指令</div>
|
||||
</slot>
|
||||
|
||||
<!-- 任务开始前,输入框就长在标题下面,不钉在屏幕底部 ——
|
||||
否则中间空一大片,眼睛得跑到最下面才找得到它。 -->
|
||||
<div v-if="heroInput" class="hero-input">
|
||||
<slot name="input"></slot>
|
||||
</div>
|
||||
|
||||
<slot name="welcome-footer"></slot>
|
||||
</div>
|
||||
|
||||
<!-- Message list -->
|
||||
@@ -92,8 +100,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom input bar -->
|
||||
<div class="chat-input-area">
|
||||
<!-- Bottom input bar: the docked position once a task is under way -->
|
||||
<div v-if="hasMessages || !heroInput" class="chat-input-area">
|
||||
<slot name="input"></slot>
|
||||
</div>
|
||||
</main>
|
||||
@@ -130,6 +138,13 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
// Opt-in: before the first message the input moves up into the hero instead
|
||||
// of docking at the bottom. Pages whose hero is a long settings form keep
|
||||
// the docked input until they ask for this.
|
||||
heroInput: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
@@ -382,6 +397,9 @@ defineExpose({ messageList })
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
/* Without this it shrinks to its widest child, capping the hero input well
|
||||
under its own max-width. */
|
||||
width: 100%;
|
||||
padding: 48px 24px 32px;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -396,6 +414,13 @@ defineExpose({ messageList })
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
/* 空状态里的输入框:比底部通栏窄一截,视觉上和标题、胶囊同属一组 */
|
||||
.hero-input {
|
||||
width: 100%;
|
||||
max-width: 760px;
|
||||
margin: 22px auto 30px;
|
||||
}
|
||||
|
||||
.message-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
<template>
|
||||
<el-popover
|
||||
v-model:visible="open"
|
||||
placement="top-start"
|
||||
:width="CASCADE_WIDTH"
|
||||
:show-arrow="false"
|
||||
trigger="click"
|
||||
popper-class="plus-menu-popper"
|
||||
>
|
||||
<template #reference>
|
||||
<button class="input-action-btn input-plus-btn" title="添加" aria-label="添加">
|
||||
<el-icon><Plus /></el-icon>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!--
|
||||
两级菜单长在同一个 popper 里,而不是套两层 popover:二级要压在
|
||||
一级右边缘上 5px、并且顶边跟当前那一行齐平,套两层的话鼠标从
|
||||
一级挪到二级的瞬间一级会先收到 mouseleave,二级跟着闪掉。
|
||||
放在一个 flex 行里就没有这个缝。
|
||||
-->
|
||||
<div class="plus-menu">
|
||||
<div class="plus-l1">
|
||||
<button
|
||||
v-for="entry in entries"
|
||||
:key="entry.key"
|
||||
type="button"
|
||||
class="plus-row"
|
||||
:class="{ on: activeKey === entry.key }"
|
||||
@mouseenter="activeKey = entry.key"
|
||||
@focus="activeKey = entry.key"
|
||||
@click="activeKey = entry.key"
|
||||
>
|
||||
<el-icon class="plus-row-icon"><component :is="entry.icon" /></el-icon>
|
||||
<span class="plus-row-label">{{ entry.label }}</span>
|
||||
<el-icon class="plus-row-arrow"><ArrowRight /></el-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 二级:顶边对齐当前行(一级首行距面板顶 8px,行高 32 + 行距 2) -->
|
||||
<div v-if="active" class="plus-l2" :style="{ marginTop: `${PANEL_PAD + ROW_STEP * activeIndex}px` }">
|
||||
<!-- 模式:一句说明 + 单选 -->
|
||||
<template v-if="active.type === 'mode'">
|
||||
<div class="plus-desc">{{ active.desc }}</div>
|
||||
<div class="plus-divider"></div>
|
||||
<button
|
||||
v-for="m in modes"
|
||||
:key="m.key"
|
||||
type="button"
|
||||
class="plus-row"
|
||||
@click="pickMode(m.key)"
|
||||
>
|
||||
<el-icon class="plus-row-icon"><Check v-if="m.key === modelValue" /><span v-else class="plus-dot"></span></el-icon>
|
||||
<span class="plus-row-label">{{ m.label }}</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- 专员 / 工具 / 连接器:搜索 + 列表,底下挂一条管理入口 -->
|
||||
<template v-else>
|
||||
<label class="plus-search">
|
||||
<el-icon class="plus-search-icon"><Search /></el-icon>
|
||||
<input v-model="keyword" class="plus-search-input" :placeholder="active.searchPlaceholder" />
|
||||
</label>
|
||||
|
||||
<div class="plus-body">
|
||||
<div v-if="!filtered.length" class="plus-empty">{{ active.empty }}</div>
|
||||
<button
|
||||
v-for="item in filtered"
|
||||
:key="item.route"
|
||||
type="button"
|
||||
class="plus-item"
|
||||
@click="pickItem(item)"
|
||||
>
|
||||
<span class="plus-item-label">{{ item.label }}</span>
|
||||
<span v-if="item.caption" class="plus-item-caption">{{ item.caption }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<template v-if="active.manage">
|
||||
<div class="plus-divider"></div>
|
||||
<button type="button" class="plus-row plus-row-manage" @click="pickItem(active.manage)">
|
||||
<el-icon class="plus-row-icon"><Setting /></el-icon>
|
||||
<span class="plus-row-label">{{ active.manage.label }}</span>
|
||||
</button>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</el-popover>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
ArrowRight, Avatar, Check, Connection, EditPen, MagicStick, Plus, Search, Setting,
|
||||
} from '@element-plus/icons-vue'
|
||||
import { businessApps, chatTools, connectors } from '@/config/workbench'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { useWorkerRuntimeStore } from '@/store/workerRuntime'
|
||||
|
||||
const props = defineProps({
|
||||
// 当前模式。tool 页没有模式,传空数组即不渲染「模式」这一行。
|
||||
modes: { type: Array, default: () => [] },
|
||||
modelValue: { type: String, default: '' },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
// 一级面板 200 宽、二级最宽 320 —— popper 给足两者并排的宽度,
|
||||
// 多出来的部分是透明的(见 .plus-menu-popper 的 pointer-events),不挡后面的东西。
|
||||
const CASCADE_WIDTH = 520
|
||||
const PANEL_PAD = 8
|
||||
const ROW_STEP = 34 // 行高 32 + 行距 2
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const workerRuntime = useWorkerRuntimeStore()
|
||||
const isAdmin = computed(() => auth.isAdmin)
|
||||
const open = ref(false)
|
||||
const keyword = ref('')
|
||||
const activeKey = ref('')
|
||||
|
||||
function routable(path) {
|
||||
try {
|
||||
return router.resolve(path).matched.length > 0
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const entries = computed(() => {
|
||||
const list = []
|
||||
if (props.modes.length) {
|
||||
list.push({
|
||||
key: 'mode',
|
||||
label: '模式',
|
||||
icon: EditPen,
|
||||
type: 'mode',
|
||||
desc: props.modes.find((m) => m.key === props.modelValue)?.desc || '',
|
||||
})
|
||||
}
|
||||
list.push({
|
||||
key: 'specialists',
|
||||
label: '专员',
|
||||
icon: Avatar,
|
||||
type: 'resource',
|
||||
searchPlaceholder: '搜索专员',
|
||||
empty: '暂无可用专员',
|
||||
items: businessApps
|
||||
.filter((app) => routable(app.route))
|
||||
// key 要在切换时用:选了专员就等于把当前任务改挂到它名下。
|
||||
.map((app) => ({ key: app.key, label: app.label, caption: app.summary, route: app.route })),
|
||||
manage: { label: '专员市场', route: '/market' },
|
||||
})
|
||||
list.push({
|
||||
key: 'tools',
|
||||
label: '工具',
|
||||
icon: MagicStick,
|
||||
type: 'resource',
|
||||
searchPlaceholder: '搜索工具',
|
||||
empty: '暂无可用工具',
|
||||
items: chatTools
|
||||
.filter((tool) => routable(tool.route))
|
||||
.map((tool) => ({ label: tool.label, caption: tool.caption, route: tool.route })),
|
||||
// 照 WorkBuddy 的技能那一格:列表底下还有一条「去管理」。
|
||||
// 我们工具/技能是在 DW 工坊里拼到专员身上的,所以指向那儿。
|
||||
// 工坊是管理员的页面,普通人看到的就是一条点不动的死链,索性不画。
|
||||
manage: isAdmin.value ? { label: '管理工具', route: '/studio' } : null,
|
||||
})
|
||||
list.push({
|
||||
key: 'connectors',
|
||||
label: '连接器',
|
||||
icon: Connection,
|
||||
type: 'resource',
|
||||
searchPlaceholder: '搜索连接器',
|
||||
empty: '暂无可用连接器',
|
||||
items: connectors
|
||||
.filter((c) => routable(c.route))
|
||||
.map((c) => ({ label: c.label, caption: c.caption, route: c.route })),
|
||||
})
|
||||
return list.filter((entry) => entry.type === 'mode' || entry.items.length || entry.manage)
|
||||
})
|
||||
|
||||
const activeIndex = computed(() => entries.value.findIndex((e) => e.key === activeKey.value))
|
||||
const active = computed(() => (activeIndex.value < 0 ? null : entries.value[activeIndex.value]))
|
||||
|
||||
const filtered = computed(() => {
|
||||
const items = active.value?.items || []
|
||||
const q = keyword.value.trim().toLowerCase()
|
||||
if (!q) return items
|
||||
return items.filter((item) => `${item.label} ${item.caption || ''}`.toLowerCase().includes(q))
|
||||
})
|
||||
|
||||
// 换一级项、或关掉重开,搜索词都归零 —— 否则下次点「工具」会带着上次搜专员的词。
|
||||
watch(activeKey, () => { keyword.value = '' })
|
||||
watch(open, (value) => { if (value) activeKey.value = entries.value[0]?.key || '' })
|
||||
|
||||
function close() {
|
||||
open.value = false
|
||||
}
|
||||
|
||||
function pickMode(key) {
|
||||
emit('update:modelValue', key)
|
||||
close()
|
||||
}
|
||||
|
||||
function pickItem(item) {
|
||||
close()
|
||||
// 在专员里选一个,等于给这条任务指定了专员。任务还没建出来时(刚点过
|
||||
// 「新建任务」、还没发第一条消息)store 会先把这次选择寄存起来,建任务
|
||||
// 时一并提交,不白选。这个专员后端没有对应记录时只是切页面,不打断。
|
||||
if (active.value?.key === 'specialists' && item.key) {
|
||||
workerRuntime.attachSpecialistToCurrentTask(item.key).catch((error) => {
|
||||
console.warn('attach specialist to current task failed', error)
|
||||
})
|
||||
}
|
||||
router.push(item.route)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 一级面板 */
|
||||
.plus-l1 {
|
||||
width: 200px;
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 8px;
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.06), 0 4px 6px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.plus-menu {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
/* 二级面板:往左压 5px 贴住一级,顶边由 marginTop 对齐到当前行 */
|
||||
.plus-l2 {
|
||||
margin-left: -5px;
|
||||
width: 320px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 8px;
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.06), 0 4px 6px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.plus-row,
|
||||
.plus-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
padding: 0 8px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
color: #303133;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.plus-row:hover,
|
||||
.plus-row.on,
|
||||
.plus-item:hover {
|
||||
background: #f2f3f5;
|
||||
}
|
||||
|
||||
.plus-row-icon {
|
||||
width: 16px;
|
||||
font-size: 15px;
|
||||
color: #909399;
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* 一级每行右侧的 ›:它表示"右边还有一层",别换成别的符号 */
|
||||
.plus-row-arrow {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
color: #c0c4cc;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.plus-row-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.plus-desc {
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.plus-divider {
|
||||
height: 1px;
|
||||
margin: 4px 0;
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* 搜索框:浅灰底、无描边,跟二级面板的其它行同高 */
|
||||
.plus-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 32px;
|
||||
padding: 0 8px;
|
||||
border-radius: 8px;
|
||||
background: #f7f7f7;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.plus-search-icon {
|
||||
font-size: 15px;
|
||||
color: #909399;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.plus-search-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.plus-search-input::placeholder {
|
||||
color: #c0c4cc;
|
||||
}
|
||||
|
||||
.plus-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
max-height: 224px;
|
||||
overflow-y: auto;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.plus-empty {
|
||||
height: 32px;
|
||||
padding: 0 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
color: #c0c4cc;
|
||||
}
|
||||
|
||||
/* 列表项比一级行多一行说明文字,所以高一点 */
|
||||
.plus-item {
|
||||
height: auto;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1px;
|
||||
padding: 5px 8px;
|
||||
}
|
||||
|
||||
.plus-item-label {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.plus-item-caption {
|
||||
font-size: 11px;
|
||||
color: #a8b0bb;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* 模式单选:选中项左边一个勾,未选中留等宽的空位保证文字对齐 */
|
||||
.plus-dot {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* popper 本身只当定位框:宽出来的一截必须透明且不吃点击,
|
||||
否则一级面板右边那片空白会挡住它后面的胶囊行。 */
|
||||
.plus-menu-popper.el-popover.el-popper {
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
pointer-events: none;
|
||||
/* 高度必须钉死。二级面板换个入口就换一次高度(模式 161、连接器 85、
|
||||
专员 317),Element Plus 会按新尺寸重新定位整个 popper —— 一级面板
|
||||
跟着上下跳,鼠标底下的那一行就跑了。按最高的那个留足,谁也不用再动。 */
|
||||
height: 340px;
|
||||
}
|
||||
|
||||
.plus-menu-popper .plus-menu {
|
||||
pointer-events: auto;
|
||||
align-items: flex-start;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,172 @@
|
||||
// 统一导航模型。
|
||||
//
|
||||
// 侧边栏只承载一级导航(navSections 里的每一项)。一级项下面有两种展开方式:
|
||||
// - groups:这一项里还分几个 tab(专员 / 工具 / 连接器),SectionTabs 先画一行
|
||||
// 组 tab,再画当前组自己的页面 tab。
|
||||
// - items:直接就是一行页面 tab。
|
||||
// 之前的 sidebarGroups / LibraryTabs / ExamTabs 三份重复清单收敛到这里。
|
||||
import { Avatar, EditPen, Files, Menu, Monitor } from '@element-plus/icons-vue'
|
||||
import { businessApps } from '@/config/workbench'
|
||||
|
||||
// 专员 / 工具 / 连接器 合起来是一个一级项 —— 侧边栏只占一行,进去是三个 tab。
|
||||
// 照 WorkBuddy 的「专家·技能·连接器」那一格:那一行本身就是个容器,
|
||||
// 名字就是把里面三格念一遍。
|
||||
const capabilityGroups = [
|
||||
{
|
||||
key: 'specialists',
|
||||
label: '专员',
|
||||
items: [
|
||||
{ label: '我的专员', route: '/apps' },
|
||||
{ label: '专员市场', route: '/market' },
|
||||
// 每个专员是「我的专员」选择器里的一张卡片,不在 tab 条里铺开
|
||||
// (hiddenInTabs),但登记在这里:搜索、路由归属、+ 菜单都认得出它们。
|
||||
...businessApps.map((app) => ({
|
||||
label: app.label,
|
||||
route: app.route,
|
||||
hiddenInTabs: true,
|
||||
})),
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'tools',
|
||||
label: '工具',
|
||||
items: [
|
||||
{ label: '智能助手', route: '/home' },
|
||||
{ label: '文档翻译', route: '/tools/document-translate' },
|
||||
{ label: '文案校对', route: '/tools/copy-proofreading' },
|
||||
{ label: '语音转写', route: '/tools/audio-transcribe' },
|
||||
{ label: '批量提取', route: '/tools/batch-extract' },
|
||||
{ label: '合同审查', route: '/tools/contract-review' },
|
||||
{ label: '报告生成', route: '/report-gen' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'connectors',
|
||||
label: '连接器',
|
||||
// 这一格对所有人开放,进去是只读的连接器清单。后续的增删改、凭据轮换、
|
||||
// 健康监控再加 adminOnly 的项,普通用户看到的仍然只有这一条。
|
||||
items: [{ label: '可用连接器', route: '/console' }],
|
||||
},
|
||||
]
|
||||
|
||||
export const navSections = [
|
||||
{
|
||||
key: 'ai-capability',
|
||||
label: '专员·工具·连接器',
|
||||
icon: Avatar,
|
||||
groups: capabilityGroups,
|
||||
// 三个组摊平。搜索和「当前路由属于哪个一级项」都读这一份,
|
||||
// 不用再去 groups 里翻。
|
||||
items: capabilityGroups.flatMap((group) => group.items),
|
||||
},
|
||||
{
|
||||
key: 'exam',
|
||||
label: 'AI考试',
|
||||
icon: EditPen,
|
||||
items: [
|
||||
{ label: '自测练习', route: '/exam/self-test' },
|
||||
{ label: '正式考试', route: '/exam/formal' },
|
||||
{ label: '我的记录', route: '/exam/my-records' },
|
||||
{ label: '错题本', route: '/exam/my-mistakes' },
|
||||
{ label: '我的证书', route: '/exam/my-certificates' },
|
||||
{ label: '岗位应学', route: '/exam/my-position' },
|
||||
{ label: '排行榜', route: '/exam/leaderboard' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'knowledge',
|
||||
label: '资料库',
|
||||
icon: Files,
|
||||
items: [
|
||||
{ label: '素材管理', route: '/knowledge/materials' },
|
||||
{ label: '素材审批', route: '/knowledge/audit' },
|
||||
{ label: '题库管理', route: '/knowledge/questions' },
|
||||
{ label: '成绩查询', route: '/knowledge/exam-records' },
|
||||
{ label: '知识库应用', route: '/knowledge-hub' },
|
||||
{ label: '知识库管理', route: '/knowledge-management', adminOnly: true },
|
||||
{ label: '产品知识', route: '/products' },
|
||||
{ label: '销售培训', route: '/courses' },
|
||||
{ label: '公司介绍', route: '/company-train' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'admin',
|
||||
label: '后台管理',
|
||||
icon: Monitor,
|
||||
items: [
|
||||
{ label: '工坊', route: '/studio', adminOnly: true },
|
||||
{ label: '用户管理', route: '/system/users', adminOnly: true },
|
||||
{ label: '部门管理', route: '/system/departments', adminOnly: true },
|
||||
{ label: '岗位管理', route: '/system/positions', adminOnly: true },
|
||||
{ label: '公司配置', route: '/system/company-config', adminOnly: true },
|
||||
{ label: '系统配置', route: '/system/config', adminOnly: true },
|
||||
{ label: 'AI 用量', route: '/system/ai-usage', adminOnly: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'me',
|
||||
label: '我的',
|
||||
icon: Menu,
|
||||
items: [
|
||||
{ label: '工作台总览', route: '/overview' },
|
||||
{ label: '我的资料', route: '/exam/my-profile' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// 一级导航的落地页:有 groups 的落在第一个组的第一项,否则落在第一个二级项。
|
||||
export function sectionHomeRoute(section) {
|
||||
if (!section) return '/home'
|
||||
if (section.groups?.length) {
|
||||
const first = section.groups.find((group) => group.items.length)
|
||||
if (first) return first.items[0].route
|
||||
}
|
||||
return section.items?.[0]?.route || '/home'
|
||||
}
|
||||
|
||||
// 顶部那排胶囊是「一级粒度」的:一个一级项一个胶囊,点它回到这个一级项的首页。
|
||||
// 之前是每个具体页面一个胶囊(文档翻译、文案校对…各占一个),走两步就铺满一整排。
|
||||
// sectionKey 记下来是给高亮用的 —— 胶囊的 route 是首页,但人可能停在组里任意一页。
|
||||
export function sectionTabFor(section) {
|
||||
if (!section) return null
|
||||
return {
|
||||
route: sectionHomeRoute(section),
|
||||
label: section.label,
|
||||
sectionKey: section.key,
|
||||
closable: true,
|
||||
}
|
||||
}
|
||||
|
||||
// 路由 → 一级导航。先精确匹配二级项,再按前缀兜底 —— 前缀兜底必须排在后面,
|
||||
// 否则 /exam/my-profile(属于「我的」)会被「AI考试」的 /exam 前缀抢走。
|
||||
export function findSectionByPath(sections, path) {
|
||||
for (const section of sections) {
|
||||
if (section.items.some((item) => item.route === path)) return section
|
||||
}
|
||||
for (const section of sections) {
|
||||
if (section.items.some((item) => path.startsWith(`${item.route}/`))) return section
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// 路由 → 它落在哪个 tab 组里(只有分组的一级项才有)。
|
||||
export function findGroupByPath(section, path) {
|
||||
if (!section?.groups) return null
|
||||
for (const group of section.groups) {
|
||||
if (group.items.some((item) => item.route === path)) return group
|
||||
}
|
||||
for (const group of section.groups) {
|
||||
if (group.items.some((item) => path.startsWith(`${item.route}/`))) return group
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// 搜索用的扁平列表,带上组名当副标题。
|
||||
export function sectionEntries(section) {
|
||||
if (section?.groups?.length) {
|
||||
return section.groups.flatMap((group) =>
|
||||
group.items.map((item) => ({ ...item, group: group.label }))
|
||||
)
|
||||
}
|
||||
return (section?.items || []).map((item) => ({ ...item, group: '' }))
|
||||
}
|
||||
@@ -1,3 +1,19 @@
|
||||
// 智能助手的模式。输入框上方的胶囊和输入框 + 菜单里的「模式」读的是同一份,
|
||||
// 两个入口改的是同一个状态,不会各说各话。
|
||||
export const assistantModes = [
|
||||
// 首页大标题下面原来有一行副标题写着同样的话,重复了 —— 去掉那一行,
|
||||
// 把 @ / 这两个真用得上的提示挪进 placeholder,一行说完。
|
||||
{ key: 'chat', label: '对话', icon: '💬', desc: '默认对话模式,直接回答你的问题。', placeholder: '@ 引用对话文件,/ 调用技能与指令' },
|
||||
{ key: 'task', label: '任务拆解', icon: '📋', desc: '把一件事拆成可执行的步骤。', placeholder: '输入需要拆解的任务...' },
|
||||
{ key: 'copy', label: '文案生成', icon: '✏️', desc: '按你要的类型生成文案。', placeholder: '输入需要生成的文案类型...' },
|
||||
{ key: 'extract', label: '批量提取', icon: '📊', desc: '从文档里批量抽出字段。', placeholder: '输入包含字段的文档内容...' },
|
||||
]
|
||||
|
||||
// 连接器:目前只有控制台一页,后续的增删改、凭据轮换、健康监控都落在这里。
|
||||
export const connectors = [
|
||||
{ key: 'console', label: '控制台', caption: '连接器状态、凭据与健康监控', route: '/console' },
|
||||
]
|
||||
|
||||
// AI 工具(Chat-First 主入口)—— 全部走 ChatLayout 聊天优先界面
|
||||
export const chatTools = [
|
||||
{ key: 'smart-assistant', label: '智能助手', caption: '对话 / 任务拆解 / 文案生成', route: '/home' },
|
||||
@@ -9,25 +25,6 @@ export const chatTools = [
|
||||
{ key: 'report-generation', label: '报告生成', caption: '日报 / 周报 / 复盘', route: '/report-gen' },
|
||||
]
|
||||
|
||||
export const topNavItems = [
|
||||
{ key: 'chat-tools', label: 'AI 工具', title: 'AI 工具', route: '/home' },
|
||||
{ key: 'ziliaoku', label: '字料库', title: '字料库', route: '/knowledge-hub' },
|
||||
{ key: 'apps', label: '专员工作区', title: '专员工作区', route: '/home' },
|
||||
{ key: 'market', label: '专员市场', title: '专员市场', route: '/market', adminOnly: true },
|
||||
{ key: 'studio', label: '工坊', title: '工坊', route: '/studio', adminOnly: true },
|
||||
{ key: 'console', label: '控制台', title: '控制台', route: '/console', adminOnly: true },
|
||||
]
|
||||
|
||||
// 字料库子级 Tab 导航(用于页面内 tab 切换)
|
||||
export const ziliaokuTabs = [
|
||||
{ key: 'chat-search', label: '知识问答', route: '/knowledge-hub' },
|
||||
{ key: 'knowledge-admin', label: '知识管理', route: '/knowledge-management' },
|
||||
{ key: 'product', label: '产品知识', route: '/products' },
|
||||
{ key: 'sales', label: '销售培训', route: '/courses' },
|
||||
{ key: 'company', label: '公司介绍', route: '/company-train' },
|
||||
{ key: 'materials', label: '素材审批', route: '/knowledge/material-approve' },
|
||||
]
|
||||
|
||||
export const businessApps = [
|
||||
{
|
||||
key: 'contract-review',
|
||||
@@ -171,55 +168,12 @@ export const businessApps = [
|
||||
},
|
||||
]
|
||||
|
||||
export const knowledgeLinks = [
|
||||
{ label: '知识库应用', route: '/knowledge-hub', caption: 'Chat / Search / Upload' },
|
||||
{ label: '知识库管理', route: '/knowledge-management', caption: 'Spaces / Docs / Chunks', adminOnly: true },
|
||||
{ label: '产品知识', route: '/products', caption: '现有知识内容' },
|
||||
{ label: '销售培训', route: '/courses', caption: '课程与训练' },
|
||||
{ label: '公司介绍', route: '/company-train', caption: '组织基础资料' },
|
||||
]
|
||||
|
||||
export const backendLinks = [
|
||||
{ label: '专员市场', route: '/market', adminOnly: true },
|
||||
{ label: '工坊', route: '/studio', adminOnly: true },
|
||||
{ label: '控制台', route: '/console', adminOnly: true },
|
||||
]
|
||||
|
||||
export const secondaryNavItems = {
|
||||
market: [
|
||||
{ label: '已安装专员', route: '/market' },
|
||||
{ label: '可升级专员', route: '/market' },
|
||||
{ label: '模板资产', route: '/market' },
|
||||
],
|
||||
studio: [
|
||||
{ label: '画布创建', route: '/studio' },
|
||||
{ label: 'DW 模板', route: '/studio' },
|
||||
{ label: '发布记录', route: '/studio' },
|
||||
],
|
||||
'knowledge-app': [
|
||||
{ label: '对话工作台', route: '/knowledge-hub' },
|
||||
{ label: '产品知识', route: '/products' },
|
||||
{ label: '销售培训', route: '/courses' },
|
||||
{ label: '公司介绍', route: '/company-train' },
|
||||
],
|
||||
'knowledge-admin': [
|
||||
{ label: '知识空间', route: '/knowledge-management' },
|
||||
{ label: '文档管理', route: '/knowledge-management' },
|
||||
{ label: '审批发布', route: '/knowledge-management' },
|
||||
{ label: '质量运营', route: '/knowledge-management' },
|
||||
],
|
||||
console: [
|
||||
{ label: '连接器健康', route: '/console' },
|
||||
{ label: '资源用量', route: '/console' },
|
||||
{ label: '权限管理', route: '/console' },
|
||||
{ label: '运行日志', route: '/console' },
|
||||
],
|
||||
}
|
||||
|
||||
export function getSecondaryNav(surface) {
|
||||
return secondaryNavItems[surface] || []
|
||||
}
|
||||
|
||||
const appWorkspaceData = {
|
||||
'contract-review': {
|
||||
title: '合同审查专员',
|
||||
@@ -767,10 +721,6 @@ export function getMarketItemsByTier(tier) {
|
||||
}))
|
||||
}
|
||||
|
||||
export function getVisibleTopNavItems(isAdmin) {
|
||||
return topNavItems.filter((item) => !item.adminOnly || isAdmin)
|
||||
}
|
||||
|
||||
export function getVisibleBackendLinks(isAdmin) {
|
||||
return backendLinks.filter((item) => !item.adminOnly || isAdmin)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||
import ExamTabsView from '@/views/exam/ExamTabsView.vue'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
@@ -19,6 +20,8 @@ const routes = [
|
||||
{ path: 'knowledge-hub', name: 'KnowledgeHub', component: () => import('@/views/workbench/KnowledgeHub.vue') },
|
||||
{ path: 'knowledge-management', name: 'KnowledgeManagement', component: () => import('@/views/workbench/KnowledgeManagementPage.vue') },
|
||||
{ path: 'market', name: 'Market', component: () => import('@/views/workbench/MarketPage.vue') },
|
||||
// AI专员 的落地页:卡片选择器,点卡片进具体专员。
|
||||
{ path: 'apps', name: 'SpecialistPicker', component: () => import('@/views/workbench/SpecialistPickerPage.vue') },
|
||||
{ path: 'apps/contract-review', name: 'ContractReviewApp', component: () => import('@/views/workbench/BusinessAppPage.vue') },
|
||||
{ path: 'apps/solution-proposal', name: 'SolutionProposalApp', component: () => import('@/views/workbench/BusinessAppPage.vue') },
|
||||
{ path: 'apps/training-delivery', name: 'TrainingDeliveryApp', component: () => import('@/views/workbench/BusinessAppPage.vue') },
|
||||
@@ -39,14 +42,23 @@ const routes = [
|
||||
{ path: 'products/:id', name: 'ProductDetail', component: () => import('@/views/product/ProductDetail.vue') },
|
||||
{ path: 'courses', name: 'CourseList', component: () => import('@/views/salesTrain/CourseList.vue') },
|
||||
{ path: 'courses/:id', name: 'CourseDetail', component: () => import('@/views/salesTrain/CourseDetail.vue') },
|
||||
{ path: 'exam/self-test', name: 'ExamSelfTest', component: () => import('@/views/exam/ExamSelfTest.vue') },
|
||||
{ path: 'exam/formal', name: 'ExamFormal', component: () => import('@/views/exam/ExamFormal.vue') },
|
||||
{ path: 'exam/my-records', name: 'ExamMyRecord', component: () => import('@/views/exam/ExamMyRecord.vue') },
|
||||
{ path: 'exam/my-position', name: 'MyPosition', component: () => import('@/views/exam/MyPosition.vue') },
|
||||
{ path: 'exam/my-mistakes', name: 'MyMistakes', component: () => import('@/views/exam/MyMistakes.vue') },
|
||||
// 考试评估 — 父子路由:父路由带 tabs,子路由各页面
|
||||
{
|
||||
path: 'exam',
|
||||
component: ExamTabsView,
|
||||
redirect: '/exam/self-test',
|
||||
children: [
|
||||
{ path: 'self-test', name: 'ExamSelfTest', component: () => import('@/views/exam/ExamSelfTest.vue') },
|
||||
{ path: 'formal', name: 'ExamFormal', component: () => import('@/views/exam/ExamFormal.vue') },
|
||||
{ path: 'my-records', name: 'ExamMyRecord', component: () => import('@/views/exam/ExamMyRecord.vue') },
|
||||
{ path: 'my-mistakes', name: 'MyMistakes', component: () => import('@/views/exam/MyMistakes.vue') },
|
||||
{ path: 'my-certificates', name: 'MyCertificates', component: () => import('@/views/exam/MyCertificates.vue') },
|
||||
{ path: 'my-position', name: 'MyPosition', component: () => import('@/views/exam/MyPosition.vue') },
|
||||
{ path: 'leaderboard', name: 'Leaderboard', component: () => import('@/views/exam/Leaderboard.vue') },
|
||||
],
|
||||
},
|
||||
// 我的资料独立(不在 tabs 内)
|
||||
{ path: 'exam/my-profile', name: 'MyProfile', component: () => import('@/views/exam/MyProfile.vue') },
|
||||
{ path: 'exam/my-certificates', name: 'MyCertificates', component: () => import('@/views/exam/MyCertificates.vue') },
|
||||
{ path: 'exam/leaderboard', name: 'Leaderboard', component: () => import('@/views/exam/Leaderboard.vue') },
|
||||
{ path: 'knowledge/materials', name: 'MaterialManage', component: () => import('@/views/knowledge/MaterialManage.vue') },
|
||||
{ path: 'knowledge/audit', name: 'MaterialAudit', component: () => import('@/views/knowledge/MaterialAudit.vue') },
|
||||
{ path: 'knowledge/questions', name: 'ExamQuestionBank', component: () => import('@/views/knowledge/ExamQuestionBank.vue') },
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { findSectionByPath, navSections, sectionTabFor } from '@/config/nav'
|
||||
|
||||
const STORAGE_KEY = 'eai-workbench-state-v1'
|
||||
|
||||
@@ -17,15 +18,30 @@ const HOME_TAB = { route: '/home', label: '智能助手', closable: false }
|
||||
|
||||
// Tabs persisted before that move: the old /home tab was 工作台总览, and the
|
||||
// assistant lived at /tools/smart-assistant (now an alias for /home).
|
||||
//
|
||||
// 同时也把胶囊折到一级粒度:老版本存的是每个具体页面一个胶囊
|
||||
// (/tools/document-translate、/report-gen…),读出来时按当前路由归属
|
||||
// 收回到它所属的一级项。这样升级后不会看到一排陈年胶囊,
|
||||
// 也保证了 ensureTab 无论喂进来哪个路由,落下的都是一个一级胶囊。
|
||||
function normalizeTabs(tabs) {
|
||||
const seen = new Set()
|
||||
const normalized = []
|
||||
const push = (tab) => {
|
||||
if (!tab?.route || seen.has(tab.route)) return
|
||||
seen.add(tab.route)
|
||||
normalized.push(tab)
|
||||
}
|
||||
|
||||
for (const tab of tabs || []) {
|
||||
const route = tab.route === '/tools/smart-assistant' ? '/home' : tab.route
|
||||
if (seen.has(route)) continue
|
||||
seen.add(route)
|
||||
normalized.push(route === '/home' ? { ...HOME_TAB } : { ...tab, route })
|
||||
if (route === '/home') {
|
||||
push({ ...HOME_TAB })
|
||||
continue
|
||||
}
|
||||
// 认不出归属的(配置里已删掉的页面)直接丢掉,别留一个点不动的胶囊。
|
||||
push(sectionTabFor(findSectionByPath(navSections, route)))
|
||||
}
|
||||
|
||||
return normalized.length ? normalized : [{ ...HOME_TAB }]
|
||||
}
|
||||
|
||||
@@ -49,9 +65,14 @@ export const useWorkbenchStore = defineStore('workbench', () => {
|
||||
)
|
||||
}
|
||||
|
||||
// 传进来的是「你在哪一页」,落下来的胶囊永远是它所属的一级项 ——
|
||||
// 收敛只在这一处做,调用方不用各自记得先折算一次。
|
||||
function ensureTab(tab) {
|
||||
if (!openTabs.value.find((item) => item.route === tab.route)) {
|
||||
openTabs.value.push(tab)
|
||||
const route = tab?.route === '/tools/smart-assistant' ? '/home' : tab?.route
|
||||
const next = route === '/home' ? { ...HOME_TAB } : sectionTabFor(findSectionByPath(navSections, route || ''))
|
||||
if (!next) return
|
||||
if (!openTabs.value.find((item) => item.route === next.route)) {
|
||||
openTabs.value.push(next)
|
||||
persist()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,30 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import {
|
||||
createMyTask,
|
||||
createWorkerTask,
|
||||
deleteMyTask,
|
||||
deleteWorkerTask,
|
||||
executeWorkerTaskAction,
|
||||
getWorkerArtifactDetail,
|
||||
getWorkerTaskDetail,
|
||||
listMyTasks,
|
||||
listWorkerTasks,
|
||||
updateMyTask,
|
||||
updateWorkerArtifactStatus,
|
||||
updateWorkerTask,
|
||||
updateWorkerTaskStatus,
|
||||
} from '@/api/worker'
|
||||
|
||||
// 「新建任务」在没选专员之前,任务先落在后端那条内置的通用智能助手名下
|
||||
// (seed 里 key = general-assistant、state = system 的记录)。
|
||||
export const GENERAL_ASSISTANT_KEY = 'general-assistant'
|
||||
|
||||
// 后端在 title 为空时兜的占位标题(myTaskPlaceholderTitle)。
|
||||
// 现在建任务时一定会带上第一句话作标题,所以正常流程里用不到它了;
|
||||
// 留着是因为它记录的是后端的契约,将来做重命名 UI 时要认这个值。
|
||||
export const PLACEHOLDER_TASK_TITLE = '新任务'
|
||||
|
||||
export const useWorkerRuntimeStore = defineStore('workerRuntime', () => {
|
||||
const tasksBySpecialist = ref({})
|
||||
const detailByTask = ref({})
|
||||
@@ -131,9 +144,142 @@ export const useWorkerRuntimeStore = defineStore('workerRuntime', () => {
|
||||
return task
|
||||
}
|
||||
|
||||
// ===== 我的任务:外壳侧边栏那一列,「新建任务」写的就是它 =====
|
||||
const myTasks = ref([])
|
||||
const loadingMyTasks = ref(false)
|
||||
// 当前正在做的这条任务。第一条消息发出、任务建出来时设上,+ 里选专员时改挂的是它。
|
||||
const currentTaskId = ref('')
|
||||
// 还没建任务时在 + 里选的专员。任务记录等第一条消息才落库,所以这一步的
|
||||
// 选择先寄存在这,建任务时作为 specialist_key 一起提交,不白选。
|
||||
const pendingSpecialistKey = ref('')
|
||||
|
||||
const currentTask = computed(
|
||||
() => myTasks.value.find((task) => String(task.id) === String(currentTaskId.value)) || null
|
||||
)
|
||||
|
||||
async function loadMyTasks() {
|
||||
loadingMyTasks.value = true
|
||||
try {
|
||||
const res = await listMyTasks()
|
||||
myTasks.value = res?.data || []
|
||||
return myTasks.value
|
||||
} finally {
|
||||
loadingMyTasks.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 「新建任务」不落库:只把画布退回开场状态 —— 当前任务清空、暂存的专员清空。
|
||||
// 记录等第一条消息发出时才建,所以点几次都不会堆出一列没聊过天的空任务。
|
||||
function startNewTask() {
|
||||
currentTaskId.value = ''
|
||||
pendingSpecialistKey.value = ''
|
||||
}
|
||||
|
||||
// 第一条消息要发出去时,用它把任务建出来:标题就是这句话,专员用 + 里先选的那个。
|
||||
// 没选专员时后端会归到通用智能助手名下。抛错表示没建成,调用方别接着往下发。
|
||||
async function createTaskForFirstMessage(text) {
|
||||
const title = String(text || '').trim().slice(0, 40)
|
||||
const payload = pendingSpecialistKey.value
|
||||
? { title, specialist_key: pendingSpecialistKey.value }
|
||||
: { title }
|
||||
const res = await createMyTask(payload)
|
||||
const task = res?.data || null
|
||||
if (task) {
|
||||
currentTaskId.value = String(task.id)
|
||||
myTasks.value = [task, ...myTasks.value.filter((item) => item.id !== task.id)]
|
||||
pendingSpecialistKey.value = ''
|
||||
}
|
||||
return task
|
||||
}
|
||||
|
||||
// 列表的排序规则,跟后端 ListMyTasks 保持一致:先置顶,再按最近动过的排。
|
||||
// 只用于本地重排(见 togglePinned),平时以服务端返回的顺序为准。
|
||||
function sortMyTasks() {
|
||||
myTasks.value = [...myTasks.value].sort((a, b) => {
|
||||
if (!!a.pinned !== !!b.pinned) return a.pinned ? -1 : 1
|
||||
const at = String(a.updated_at || '')
|
||||
const bt = String(b.updated_at || '')
|
||||
if (at !== bt) return bt.localeCompare(at)
|
||||
return Number(b.id) - Number(a.id)
|
||||
})
|
||||
}
|
||||
|
||||
// 重命名任意一条任务(侧边栏那个「小点」菜单里的「重命名」)。
|
||||
// 这里不重排:改个名字就把行从鼠标底下挪走,比排序不实时更难受 ——
|
||||
// 位置等下次 loadMyTasks 再对齐服务端。
|
||||
async function renameTask(taskId, title) {
|
||||
const id = String(taskId || '')
|
||||
const trimmed = String(title || '').trim()
|
||||
if (!id || !trimmed) return null
|
||||
const res = await updateMyTask(id, { title: trimmed.slice(0, 40) })
|
||||
applyTaskUpdate(res?.data)
|
||||
return res?.data || null
|
||||
}
|
||||
|
||||
// 置顶/取消置顶。置顶纯粹是排序偏好,所以本地立刻重排一次 ——
|
||||
// 行「跳」到列表顶端就是这次操作生效的反馈,等下次加载才动会像没反应。
|
||||
async function togglePinned(taskId) {
|
||||
const id = String(taskId || '')
|
||||
const task = myTasks.value.find((item) => String(item.id) === id)
|
||||
if (!task) return null
|
||||
const res = await updateMyTask(id, { pinned: !task.pinned })
|
||||
applyTaskUpdate(res?.data)
|
||||
sortMyTasks()
|
||||
return res?.data || null
|
||||
}
|
||||
|
||||
// 删除任务。后端是硬删,删了找不回来,所以确认框由调用方负责。
|
||||
async function deleteTask(taskId) {
|
||||
const id = String(taskId || '')
|
||||
if (!id) return
|
||||
await deleteMyTask(id)
|
||||
myTasks.value = myTasks.value.filter((item) => String(item.id) !== id)
|
||||
// 删掉的正好是当前这条:把当前任务也清掉。留着的话侧边栏会停在一个
|
||||
// 已经不存在的任务上,接着发消息也挂不上去。
|
||||
if (String(currentTaskId.value) === id) currentTaskId.value = ''
|
||||
}
|
||||
|
||||
// 输入框的 + 里选了专员:有当前任务就改挂过去,还没有任务就先寄存着,
|
||||
// 等第一条消息建任务时一起提交 —— 两种情况下这次选择都不丢。
|
||||
async function attachSpecialistToCurrentTask(specialistKey) {
|
||||
if (!specialistKey) return null
|
||||
if (!currentTaskId.value) {
|
||||
pendingSpecialistKey.value = specialistKey
|
||||
return null
|
||||
}
|
||||
const res = await updateMyTask(currentTaskId.value, { specialist_key: specialistKey })
|
||||
applyTaskUpdate(res?.data)
|
||||
return res?.data || null
|
||||
}
|
||||
|
||||
function applyTaskUpdate(task) {
|
||||
if (!task?.id) return
|
||||
myTasks.value = myTasks.value.map((item) => (item.id === task.id ? { ...item, ...task } : item))
|
||||
}
|
||||
|
||||
// 点开一条已有任务:它自带专员,之前暂存的那个就没用了,一并清掉,
|
||||
// 免得下次在这条任务上发消息时被一个不相干的专员顶掉。
|
||||
function openTask(taskId) {
|
||||
currentTaskId.value = String(taskId || '')
|
||||
pendingSpecialistKey.value = ''
|
||||
}
|
||||
|
||||
const hasTasks = computed(() => Object.values(tasksBySpecialist.value).some(items => Array.isArray(items) && items.length))
|
||||
|
||||
return {
|
||||
myTasks,
|
||||
loadingMyTasks,
|
||||
currentTaskId,
|
||||
currentTask,
|
||||
pendingSpecialistKey,
|
||||
loadMyTasks,
|
||||
startNewTask,
|
||||
createTaskForFirstMessage,
|
||||
renameTask,
|
||||
togglePinned,
|
||||
deleteTask,
|
||||
attachSpecialistToCurrentTask,
|
||||
openTask,
|
||||
tasksBySpecialist,
|
||||
detailByTask,
|
||||
artifactDetailById,
|
||||
|
||||
@@ -75,3 +75,8 @@ html, body, #app {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* 隐藏所有工具页面的类别胶囊 */
|
||||
.category-bar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -1,138 +1,119 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-title">正式考试</div>
|
||||
<!-- 筛选卡 -->
|
||||
<el-card shadow="never" class="filter-card">
|
||||
<el-form :inline="true" :model="filters" size="default">
|
||||
<el-form-item label="课程">
|
||||
<el-select v-model="filters.courseId" placeholder="全部" clearable style="width: 200px;">
|
||||
<el-option v-for="c in courses" :key="c.id" :label="c.name" :value="c.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="load">查询</el-button>
|
||||
<el-button @click="resetFilters">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- 试卷列表 -->
|
||||
<template v-if="mode === 'list'">
|
||||
<el-alert title="正式考试提交后成绩永久存档,且每人限考一次" type="warning" :closable="false" show-icon style="margin-bottom: 16px;" />
|
||||
<el-card v-for="p in papers" :key="p.id" shadow="never" class="paper-card" v-loading="loading">
|
||||
<div class="paper-row">
|
||||
<div class="paper-info">
|
||||
<div class="paper-name">{{ p.name }}</div>
|
||||
<div class="paper-meta">
|
||||
<span>共 {{ p.question_count }} 题</span>
|
||||
<el-divider direction="vertical" />
|
||||
<span>满分 {{ p.total_score }} 分 · 合格 {{ p.pass_score }} 分</span>
|
||||
<el-divider direction="vertical" />
|
||||
<span>限时 {{ p.duration_minutes }} 分钟</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-button v-if="p.status === 'available'" type="danger" @click="startExam(p)">开始考试</el-button>
|
||||
<el-tag v-else type="info">已完成</el-tag>
|
||||
<!-- 列表卡 -->
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>正式考试(共 {{ total }} 场)</span>
|
||||
<el-button size="small" type="warning" icon="InfoFilled">
|
||||
提交后成绩永久存档,且每人限考一次
|
||||
</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
<el-empty v-if="!loading && papers.length === 0" description="暂无正式考试" />
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- 答题 -->
|
||||
<template v-else-if="mode === 'quiz'">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="exam-header">
|
||||
<span>{{ currentPaper?.name }}</span>
|
||||
<el-tag type="danger">剩余 {{ countdownText }}</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
<div v-for="(q, idx) in questions" :key="q.id" class="question-item">
|
||||
<div class="question-title">
|
||||
{{ idx + 1 }}. {{ q.stem }}
|
||||
<el-tag size="small" :type="q.type === 'multiple' ? 'success' : q.type === 'judge' ? 'warning' : 'info'">
|
||||
{{ labelOf(QUESTION_TYPES, q.type) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<el-radio-group v-if="q.type === 'single'" v-model="answers[q.id]" class="opt-group">
|
||||
<el-radio v-for="opt in q.options" :key="opt.key" :value="opt.key" style="display: block; margin-bottom: 8px;">
|
||||
{{ opt.key }}. {{ opt.text }}
|
||||
</el-radio>
|
||||
</el-radio-group>
|
||||
<el-checkbox-group v-else-if="q.type === 'multiple'" v-model="answers[q.id]" class="opt-group">
|
||||
<el-checkbox v-for="opt in q.options" :key="opt.key" :value="opt.key" style="display: block; margin-bottom: 8px;">
|
||||
{{ opt.key }}. {{ opt.text }}
|
||||
</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
<el-radio-group v-else-if="q.type === 'judge'" v-model="answers[q.id]" class="opt-group">
|
||||
<el-radio value="true" style="display: block; margin-bottom: 8px;">正确</el-radio>
|
||||
<el-radio value="false" style="display: block; margin-bottom: 8px;">错误</el-radio>
|
||||
</el-radio-group>
|
||||
<el-input v-else-if="q.type === 'essay'" v-model="answers[q.id]" type="textarea" :rows="4" placeholder="请输入你的答案" class="opt-group" />
|
||||
</div>
|
||||
<div style="margin-top: 24px; text-align: center;">
|
||||
<el-button type="danger" size="large" :loading="submitting" @click="submit">交卷</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<!-- 结果 -->
|
||||
<template v-else-if="mode === 'result'">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="exam-header"><span>考试成绩</span><el-tag :type="result.passed ? 'success' : 'danger'">{{ result.passed ? '通过' : '未通过' }}</el-tag></div>
|
||||
</template>
|
||||
<el-result
|
||||
:icon="result.passed ? 'success' : 'error'"
|
||||
:title="`得分 ${result.score} / ${result.total_score}`"
|
||||
:sub-title="`答对 ${result.correct_count} 题,答错 ${result.wrong_count} 题(合格线 ${result.pass_score} 分)`"
|
||||
>
|
||||
<template #extra>
|
||||
<el-button type="primary" @click="backToList">返回考试列表</el-button>
|
||||
<el-table :data="items" border stripe style="width: 100%" v-loading="loading">
|
||||
<el-table-column prop="id" label="编号" width="70" />
|
||||
<el-table-column prop="name" label="考试名称" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column label="题目数" width="90" align="center">
|
||||
<template #default="{ row }">{{ row.question_count ?? row.questions?.length ?? 0 }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="满分" width="80" align="center">
|
||||
<template #default="{ row }">{{ row.total_score ?? 0 }} 分</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="限时" width="100" align="center">
|
||||
<template #default="{ row }">{{ row.duration_minutes ?? 0 }} 分钟</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link size="small" @click="startExam(row)">开始考试</el-button>
|
||||
</template>
|
||||
</el-result>
|
||||
<el-divider content-position="left">逐题解析</el-divider>
|
||||
<div v-for="(d, idx) in result.detail" :key="d.question_id" class="question-item">
|
||||
<div class="question-title">
|
||||
{{ idx + 1 }}. {{ d.stem }}
|
||||
<el-tag size="small" :type="d.is_correct ? 'success' : 'danger'">{{ d.is_correct ? '正确' : '错误' }}</el-tag>
|
||||
</div>
|
||||
<div v-if="d.type === 'essay'" class="detail-line">
|
||||
AI 得分率:{{ Math.round((d.score_rate || 0) * 100) }}%<span v-if="d.comment"> · {{ d.comment }}</span>
|
||||
</div>
|
||||
<div class="detail-line">正确答案:{{ formatAnswer(d.correct_answer) }}</div>
|
||||
<div v-if="d.explanation" class="detail-line explain">解析:{{ d.explanation }}</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-empty v-if="!loading && items.length === 0" description="暂无正式考试" />
|
||||
|
||||
<div class="table-pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="size"
|
||||
:page-sizes="[10, 20, 40]"
|
||||
:total="total"
|
||||
background
|
||||
layout="sizes, prev, pager, next"
|
||||
@current-change="load"
|
||||
@size-change="handleSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { listExams, examStart, examSubmit } from '@/api/exam'
|
||||
import { QUESTION_TYPES, labelOf } from '@/utils/labels'
|
||||
|
||||
const mode = ref('list')
|
||||
const filters = reactive({ courseId: '' })
|
||||
const items = ref([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const size = ref(10)
|
||||
const loading = ref(false)
|
||||
const papers = ref([])
|
||||
const courses = ref([])
|
||||
|
||||
// 答题状态
|
||||
const quizVisible = ref(false)
|
||||
const currentPaper = ref(null)
|
||||
const questions = ref([])
|
||||
const answers = reactive({})
|
||||
const sessionId = ref('')
|
||||
const startedAt = ref(0)
|
||||
const timeLimitSec = ref(0)
|
||||
const remainingSec = ref(0)
|
||||
const submitting = ref(false)
|
||||
const result = ref(null)
|
||||
let timer = null
|
||||
|
||||
const countdownText = computed(() => {
|
||||
const m = Math.floor(remainingSec.value / 60)
|
||||
const s = remainingSec.value % 60
|
||||
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
|
||||
})
|
||||
|
||||
async function loadPapers() {
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await listExams()
|
||||
papers.value = (res.data || []).filter((p) => p.type === 'formal')
|
||||
const exams = res.data || []
|
||||
items.value = exams
|
||||
.filter((p) => p.type === 'formal')
|
||||
.slice((page.value - 1) * size.value, (page.value.value * size.value))
|
||||
total.value = exams.filter((p) => p.type === 'formal').length
|
||||
} catch (e) {
|
||||
papers.value = []
|
||||
items.value = []
|
||||
total.value = 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
filters.courseId = ''
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function handleSizeChange() {
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
async function startExam(p) {
|
||||
try {
|
||||
const res = await examStart(p.id)
|
||||
@@ -145,51 +126,30 @@ async function startExam(p) {
|
||||
answers[q.id] = q.type === 'multiple' ? [] : ''
|
||||
})
|
||||
startedAt.value = Math.floor(Date.now() / 1000)
|
||||
timeLimitSec.value = (d.time_limit_min || 0) * 60
|
||||
remainingSec.value = timeLimitSec.value
|
||||
mode.value = 'quiz'
|
||||
startTimer()
|
||||
quizVisible.value = true
|
||||
} catch (e) {
|
||||
// 拦截器已提示
|
||||
ElMessage.error(e?.message || '加载试卷失败')
|
||||
}
|
||||
}
|
||||
|
||||
function startTimer() {
|
||||
clearInterval(timer)
|
||||
timer = setInterval(() => {
|
||||
remainingSec.value = timeLimitSec.value - (Math.floor(Date.now() / 1000) - startedAt.value)
|
||||
if (remainingSec.value <= 0) {
|
||||
remainingSec.value = 0
|
||||
submit(true)
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
async function submit(auto = false) {
|
||||
if (submitting.value) return
|
||||
async function submit() {
|
||||
submitting.value = true
|
||||
clearInterval(timer)
|
||||
try {
|
||||
const timeSpent = Math.floor(Date.now() / 1000) - startedAt.value
|
||||
const res = await examSubmit({ session_id: sessionId.value, answers: { ...answers }, time_spent_sec: timeSpent })
|
||||
const res = await examSubmit({
|
||||
session_id: sessionId.value,
|
||||
answers: { ...answers },
|
||||
time_spent_sec: timeSpent,
|
||||
})
|
||||
result.value = res.data
|
||||
mode.value = 'result'
|
||||
if (auto) ElMessage.warning('考试时间到,已自动交卷')
|
||||
ElMessage.success('提交成功')
|
||||
} catch (e) {
|
||||
if (mode.value === 'quiz') {
|
||||
startTimer()
|
||||
}
|
||||
ElMessage.error(e?.message || '交卷失败')
|
||||
ElMessage.error(e?.message || '提交失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function backToList() {
|
||||
mode.value = 'list'
|
||||
loadPapers()
|
||||
}
|
||||
|
||||
function formatAnswer(correct) {
|
||||
if (!correct) return '—'
|
||||
if (correct.length === 1 && (correct[0] === 'true' || correct[0] === 'false')) {
|
||||
@@ -198,54 +158,21 @@ function formatAnswer(correct) {
|
||||
return correct.join('、')
|
||||
}
|
||||
|
||||
onMounted(loadPapers)
|
||||
onBeforeUnmount(() => clearInterval(timer))
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.paper-card {
|
||||
.filter-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.paper-row {
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.paper-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.paper-meta {
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
}
|
||||
.exam-header {
|
||||
.table-pagination {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-weight: 600;
|
||||
}
|
||||
.question-item {
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
.question-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.question-title {
|
||||
font-weight: 500;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.opt-group {
|
||||
width: 100%;
|
||||
}
|
||||
.detail-line {
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.detail-line.explain {
|
||||
color: #909399;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,27 +1,45 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-title">我的考试记录</div>
|
||||
|
||||
<el-card v-if="records.length > 0" shadow="never" style="margin-bottom: 16px;">
|
||||
<template #header><span>成绩趋势</span></template>
|
||||
<LineChart :items="trendItems" color="#409eff" />
|
||||
<!-- 筛选卡 -->
|
||||
<el-card shadow="never" class="filter-card">
|
||||
<el-form :inline="true" :model="filters" size="default">
|
||||
<el-form-item label="考试名称">
|
||||
<el-input v-model="filters.examName" placeholder="请输入" clearable style="width: 200px;" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="load">查询</el-button>
|
||||
<el-button @click="resetFilters">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- 列表卡 -->
|
||||
<el-card shadow="never">
|
||||
<el-table :data="pagedRecords" border stripe style="width: 100%" v-loading="loading">
|
||||
<el-table-column prop="exam_name" label="考试名称" min-width="180" />
|
||||
<el-table-column label="得分" width="140">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>我的考试记录(共 {{ total }} 条)</span>
|
||||
<div>
|
||||
<el-button size="small" type="info" icon="TrendCharts">
|
||||
成绩趋势
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table :data="items" border stripe style="width: 100%" v-loading="loading">
|
||||
<el-table-column prop="exam_name" label="考试名称" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column label="得分" width="140" align="center">
|
||||
<template #default="{ row }">{{ row.score }} / {{ row.total_score }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100">
|
||||
<el-table-column label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.passed ? 'success' : 'danger'">{{ row.passed ? '通过' : '未通过' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="对/错" width="120">
|
||||
<el-table-column label="对/错" width="120" align="center">
|
||||
<template #default="{ row }">{{ row.correct_count }} 对 / {{ row.wrong_count }} 错</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="提交时间" min-width="170">
|
||||
<el-table-column label="提交时间" width="170">
|
||||
<template #default="{ row }">{{ formatTime(row.submitted_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="100" fixed="right">
|
||||
@@ -30,15 +48,19 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-if="!loading && records.length === 0" description="暂无考试记录" />
|
||||
<div v-else class="table-pagination">
|
||||
|
||||
<el-empty v-if="!loading && items.length === 0" description="暂无考试记录" />
|
||||
|
||||
<div class="table-pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="pageSize"
|
||||
v-model:page-size="size"
|
||||
:page-sizes="[10, 20, 40]"
|
||||
:total="records.length"
|
||||
:total="total"
|
||||
background
|
||||
layout="sizes, prev, pager, next"
|
||||
@current-change="load"
|
||||
@size-change="handleSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
@@ -67,47 +89,46 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { examRecords, examRecordDetail } from '@/api/exam'
|
||||
import LineChart from '@/components/charts/LineChart.vue'
|
||||
|
||||
const loading = ref(false)
|
||||
const records = ref([])
|
||||
const filters = reactive({ examName: '' })
|
||||
const items = ref([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const pagedRecords = computed(() => records.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
|
||||
const size = ref(10)
|
||||
const loading = ref(false)
|
||||
const detailVisible = ref(false)
|
||||
const detail = ref(null)
|
||||
|
||||
const questions = computed(() => detail.value?.detail?.questions || [])
|
||||
|
||||
const trendItems = computed(() => {
|
||||
const list = [...records.value].sort((a, b) =>
|
||||
String(a.submitted_at || '').localeCompare(String(b.submitted_at || ''))
|
||||
)
|
||||
return list.map((r) => ({ label: String(r.submitted_at || '').slice(0, 10), value: r.score }))
|
||||
})
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await examRecords()
|
||||
records.value = res.data || []
|
||||
const records = res.data || []
|
||||
items.value = records
|
||||
.filter((r) => !filters.examName || (r.exam_name || '').includes(filters.examName))
|
||||
.slice((page.value - 1) * size.value, page.value.value * size.value)
|
||||
total.value = records.length
|
||||
} catch (e) {
|
||||
records.value = []
|
||||
items.value = []
|
||||
total.value = 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openDetail(row) {
|
||||
try {
|
||||
const res = await examRecordDetail(row.id)
|
||||
detail.value = res.data
|
||||
detailVisible.value = true
|
||||
} catch (e) {
|
||||
// 拦截器已提示
|
||||
}
|
||||
function resetFilters() {
|
||||
filters.examName = ''
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function handleSizeChange() {
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function formatTime(t) {
|
||||
@@ -130,10 +151,28 @@ function fmtCorrect(ans) {
|
||||
return ans.join('、')
|
||||
}
|
||||
|
||||
function openDetail(row) {
|
||||
detail.value = row
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.filter-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.table-pagination {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.detail-summary {
|
||||
text-align: center;
|
||||
margin-bottom: 8px;
|
||||
@@ -157,9 +196,4 @@ onMounted(load)
|
||||
.detail-line.explain {
|
||||
color: #909399;
|
||||
}
|
||||
.table-pagination {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,100 +1,65 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-title">自测练习</div>
|
||||
<!-- 筛选卡 -->
|
||||
<el-card shadow="never" class="filter-card">
|
||||
<el-form :inline="true" :model="filters" size="default">
|
||||
<el-form-item label="课程">
|
||||
<el-select v-model="filters.courseId" placeholder="全部" clearable style="width: 200px;">
|
||||
<el-option v-for="c in courses" :key="c.id" :label="c.name" :value="c.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="load">查询</el-button>
|
||||
<el-button @click="resetFilters">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- 试卷列表 -->
|
||||
<template v-if="mode === 'list'">
|
||||
<el-alert title="自测不计入成绩、不限次数,可反复练习" type="info" :closable="false" show-icon style="margin-bottom: 16px;" />
|
||||
<el-card v-for="p in papers" :key="p.id" shadow="never" class="paper-card" v-loading="loading">
|
||||
<div class="paper-row">
|
||||
<div class="paper-info">
|
||||
<div class="paper-name">{{ p.name }}</div>
|
||||
<div class="paper-meta">
|
||||
<span>共 {{ p.question_count }} 题</span>
|
||||
<el-divider direction="vertical" />
|
||||
<span>满分 {{ p.total_score }} 分</span>
|
||||
<el-divider direction="vertical" />
|
||||
<span>限时 {{ p.duration_minutes }} 分钟</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-button type="primary" @click="startExam(p)">开始练习</el-button>
|
||||
<!-- 列表卡 -->
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>自测试卷(共 {{ total }} 份)</span>
|
||||
<el-button size="small" type="info" icon="InfoFilled">
|
||||
自测不计入成绩、不限次数,可反复练习
|
||||
</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
<el-empty v-if="!loading && papers.length === 0" description="暂无自测试卷" />
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- 答题 -->
|
||||
<template v-else-if="mode === 'quiz'">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="exam-header">
|
||||
<span>{{ currentPaper?.name }}</span>
|
||||
<el-tag type="warning">练习模式</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
<div v-for="(q, idx) in questions" :key="q.id" class="question-item">
|
||||
<div class="question-title">
|
||||
{{ idx + 1 }}. {{ q.stem }}
|
||||
<el-tag size="small" :type="q.type === 'multiple' ? 'success' : q.type === 'judge' ? 'warning' : 'info'">
|
||||
{{ labelOf(QUESTION_TYPES, q.type) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<!-- 单选 -->
|
||||
<el-radio-group v-if="q.type === 'single'" v-model="answers[q.id]" class="opt-group">
|
||||
<el-radio v-for="opt in q.options" :key="opt.key" :value="opt.key" style="display: block; margin-bottom: 8px;">
|
||||
{{ opt.key }}. {{ opt.text }}
|
||||
</el-radio>
|
||||
</el-radio-group>
|
||||
<!-- 多选 -->
|
||||
<el-checkbox-group v-else-if="q.type === 'multiple'" v-model="answers[q.id]" class="opt-group">
|
||||
<el-checkbox v-for="opt in q.options" :key="opt.key" :value="opt.key" style="display: block; margin-bottom: 8px;">
|
||||
{{ opt.key }}. {{ opt.text }}
|
||||
</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
<!-- 判断 -->
|
||||
<el-radio-group v-else-if="q.type === 'judge'" v-model="answers[q.id]" class="opt-group">
|
||||
<el-radio value="true" style="display: block; margin-bottom: 8px;">正确</el-radio>
|
||||
<el-radio value="false" style="display: block; margin-bottom: 8px;">错误</el-radio>
|
||||
</el-radio-group>
|
||||
<!-- 简答 -->
|
||||
<el-input v-else-if="q.type === 'essay'" v-model="answers[q.id]" type="textarea" :rows="4" placeholder="请输入你的答案" class="opt-group" />
|
||||
</div>
|
||||
<div style="margin-top: 24px; text-align: center;">
|
||||
<el-button size="large" @click="mode = 'list'">退出</el-button>
|
||||
<el-button type="primary" size="large" :loading="submitting" @click="submit">提交答案</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<!-- 结果 -->
|
||||
<template v-else-if="mode === 'result'">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="exam-header"><span>练习结果</span><el-tag type="warning">练习模式</el-tag></div>
|
||||
</template>
|
||||
<el-result
|
||||
:icon="result.passed ? 'success' : 'info'"
|
||||
:title="`得分 ${result.score} / ${result.total_score}`"
|
||||
:sub-title="`答对 ${result.correct_count} 题,答错 ${result.wrong_count} 题`"
|
||||
>
|
||||
<template #extra>
|
||||
<el-button type="primary" @click="mode = 'list'">返回练习</el-button>
|
||||
<el-table :data="items" border stripe style="width: 100%" v-loading="loading">
|
||||
<el-table-column prop="id" label="编号" width="70" />
|
||||
<el-table-column prop="name" label="试卷名称" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column label="题目数" width="90" align="center">
|
||||
<template #default="{ row }">{{ row.question_count ?? row.questions?.length ?? 0 }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="满分" width="80" align="center">
|
||||
<template #default="{ row }">{{ row.total_score ?? 0 }} 分</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="限时" width="100" align="center">
|
||||
<template #default="{ row }">{{ row.duration_minutes ?? 0 }} 分钟</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link size="small" @click="startExam(row)">开始练习</el-button>
|
||||
</template>
|
||||
</el-result>
|
||||
<el-divider content-position="left">逐题解析</el-divider>
|
||||
<div v-for="(d, idx) in result.detail" :key="d.question_id" class="question-item">
|
||||
<div class="question-title">
|
||||
{{ idx + 1 }}. {{ d.stem }}
|
||||
<el-tag size="small" :type="d.is_correct ? 'success' : 'danger'">{{ d.is_correct ? '正确' : '错误' }}</el-tag>
|
||||
</div>
|
||||
<div v-if="d.type === 'essay'" class="detail-line">
|
||||
AI 得分率:{{ Math.round((d.score_rate || 0) * 100) }}%<span v-if="d.comment"> · {{ d.comment }}</span>
|
||||
</div>
|
||||
<div class="detail-line">正确答案:{{ formatAnswer(d.correct_answer) }}</div>
|
||||
<div v-if="d.explanation" class="detail-line explain">解析:{{ d.explanation }}</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-empty v-if="!loading && items.length === 0" description="暂无自测试卷" />
|
||||
|
||||
<div class="table-pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="size"
|
||||
:page-sizes="[10, 20, 40]"
|
||||
:total="total"
|
||||
background
|
||||
layout="sizes, prev, pager, next"
|
||||
@current-change="load"
|
||||
@size-change="handleSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -102,12 +67,17 @@
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { listExams, examStart, examSubmit } from '@/api/exam'
|
||||
import { QUESTION_TYPES, labelOf } from '@/utils/labels'
|
||||
|
||||
const mode = ref('list')
|
||||
const filters = reactive({ courseId: '' })
|
||||
const items = ref([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const size = ref(10)
|
||||
const loading = ref(false)
|
||||
const papers = ref([])
|
||||
const courses = ref([])
|
||||
|
||||
// 答题状态
|
||||
const quizVisible = ref(false)
|
||||
const currentPaper = ref(null)
|
||||
const questions = ref([])
|
||||
const answers = reactive({})
|
||||
@@ -116,18 +86,34 @@ const startedAt = ref(0)
|
||||
const submitting = ref(false)
|
||||
const result = ref(null)
|
||||
|
||||
async function loadPapers() {
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await listExams()
|
||||
papers.value = (res.data || []).filter((p) => p.type === 'self_test')
|
||||
const exams = res.data || []
|
||||
items.value = exams
|
||||
.filter((p) => p.type === 'self_test')
|
||||
.slice((page.value - 1) * size.value, page.value.value * size.value)
|
||||
total.value = exams.filter((p) => p.type === 'self_test').length
|
||||
} catch (e) {
|
||||
papers.value = []
|
||||
items.value = []
|
||||
total.value = 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
filters.courseId = ''
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function handleSizeChange() {
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
async function startExam(p) {
|
||||
try {
|
||||
const res = await examStart(p.id)
|
||||
@@ -135,15 +121,14 @@ async function startExam(p) {
|
||||
sessionId.value = d.session_id
|
||||
currentPaper.value = { name: d.exam_name }
|
||||
questions.value = d.questions || []
|
||||
// 清空答案
|
||||
Object.keys(answers).forEach((k) => delete answers[k])
|
||||
questions.value.forEach((q) => {
|
||||
answers[q.id] = q.type === 'multiple' ? [] : ''
|
||||
})
|
||||
startedAt.value = Math.floor(Date.now() / 1000)
|
||||
mode.value = 'quiz'
|
||||
quizVisible.value = true
|
||||
} catch (e) {
|
||||
// 拦截器已提示
|
||||
ElMessage.error(e?.message || '加载试卷失败')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,9 +136,13 @@ async function submit() {
|
||||
submitting.value = true
|
||||
try {
|
||||
const timeSpent = Math.floor(Date.now() / 1000) - startedAt.value
|
||||
const res = await examSubmit({ session_id: sessionId.value, answers: { ...answers }, time_spent_sec: timeSpent })
|
||||
const res = await examSubmit({
|
||||
session_id: sessionId.value,
|
||||
answers: { ...answers },
|
||||
time_spent_sec: timeSpent,
|
||||
})
|
||||
result.value = res.data
|
||||
mode.value = 'result'
|
||||
ElMessage.success('提交成功')
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.message || '提交失败')
|
||||
} finally {
|
||||
@@ -169,53 +158,21 @@ function formatAnswer(correct) {
|
||||
return correct.join('、')
|
||||
}
|
||||
|
||||
onMounted(loadPapers)
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.paper-card {
|
||||
.filter-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.paper-row {
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.paper-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.paper-meta {
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
}
|
||||
.exam-header {
|
||||
.table-pagination {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-weight: 600;
|
||||
}
|
||||
.question-item {
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
.question-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.question-title {
|
||||
font-weight: 500;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.opt-group {
|
||||
width: 100%;
|
||||
}
|
||||
.detail-line {
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.detail-line.explain {
|
||||
color: #909399;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<!-- 考试评估的二级导航由 MainLayout 的 SectionTabs 统一渲染,
|
||||
这里只作为父路由的出口,保持 /exam/* 的公共内边距。 -->
|
||||
<div class="exam-shell">
|
||||
<router-view />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.exam-shell {
|
||||
min-height: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -1,18 +1,30 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-title">学习积分排行榜</div>
|
||||
<!-- 筛选卡 -->
|
||||
<el-card shadow="never" class="filter-card">
|
||||
<el-form :inline="true" :model="filters" size="default">
|
||||
<el-form-item label="部门">
|
||||
<el-select v-model="filters.department" placeholder="全部" clearable style="width: 160px;">
|
||||
<el-option v-for="d in departments" :key="d" :label="d" :value="d" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="load">查询</el-button>
|
||||
<el-button @click="resetFilters">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" v-loading="loading">
|
||||
<!-- 列表卡 -->
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="head">
|
||||
<span>全员学习积分排行(共 {{ total }} 人)</span>
|
||||
<div class="card-header">
|
||||
<span>学习积分排行榜(共 {{ total }} 人)</span>
|
||||
<el-tag v-if="myRank > 0" type="primary">我的名次:第 {{ myRank }} 名</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-empty v-if="!loading && items.length === 0" description="暂无排行榜数据" />
|
||||
|
||||
<el-table v-else :data="items" border stripe>
|
||||
<el-table :data="items" border stripe style="width: 100%" v-loading="loading">
|
||||
<el-table-column label="排名" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.rank <= 3" :type="rankType(row.rank)" effect="dark" size="small" round>
|
||||
@@ -36,18 +48,37 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-empty v-if="!loading && items.length === 0" description="暂无排行榜数据" />
|
||||
|
||||
<div class="table-pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="size"
|
||||
:page-sizes="[10, 20, 40]"
|
||||
:total="total"
|
||||
background
|
||||
layout="sizes, prev, pager, next"
|
||||
@current-change="load"
|
||||
@size-change="handleSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { leaderboard } from '@/api/profile'
|
||||
|
||||
const loading = ref(false)
|
||||
const filters = reactive({ department: '' })
|
||||
const items = ref([])
|
||||
const myRank = ref(0)
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const size = ref(10)
|
||||
const loading = ref(false)
|
||||
const myRank = ref(0)
|
||||
const departments = ref([])
|
||||
|
||||
function rankType(rank) {
|
||||
if (rank === 1) return 'danger'
|
||||
@@ -60,25 +91,55 @@ async function load() {
|
||||
try {
|
||||
const res = await leaderboard()
|
||||
const d = res.data || {}
|
||||
items.value = d.items || []
|
||||
const allItems = d.items || []
|
||||
// Filter by department if needed
|
||||
if (filters.department) {
|
||||
items.value = allItems
|
||||
.filter((r) => r.department === filters.department)
|
||||
.slice((page.value - 1) * size.value, page.value.value * size.value)
|
||||
} else {
|
||||
items.value = allItems.slice((page.value - 1) * size.value, page.value.value * size.value)
|
||||
}
|
||||
myRank.value = d.my_rank || 0
|
||||
total.value = d.total || 0
|
||||
total.value = d.total || allItems.length
|
||||
// Extract unique departments
|
||||
departments.value = [...new Set(allItems.map((r) => r.department).filter(Boolean))]
|
||||
} catch (e) {
|
||||
items.value = []
|
||||
total.value = 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
filters.department = ''
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function handleSizeChange() {
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.head {
|
||||
.filter-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.table-pagination {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.me {
|
||||
font-weight: 600;
|
||||
color: #409eff;
|
||||
|
||||
@@ -1,27 +1,59 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-title">我的证书</div>
|
||||
<!-- 筛选卡 -->
|
||||
<el-card shadow="never" class="filter-card">
|
||||
<el-form :inline="true" :model="filters" size="default">
|
||||
<el-form-item label="考试名称">
|
||||
<el-input v-model="filters.examName" placeholder="请输入" clearable style="width: 200px;" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="load">查询</el-button>
|
||||
<el-button @click="resetFilters">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- 列表卡 -->
|
||||
<el-card shadow="never">
|
||||
<template #header><span>共 {{ items.length }} 张合格证书</span></template>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>我的证书(共 {{ total }} 张)</span>
|
||||
<el-button size="small" type="info" icon="Document">
|
||||
正式考试通过后自动颁发
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-empty v-if="!loading && items.length === 0" description="暂无证书,正式考试通过后自动颁发" />
|
||||
|
||||
<el-table v-else :data="items" border stripe v-loading="loading">
|
||||
<el-table :data="items" border stripe style="width: 100%" v-loading="loading">
|
||||
<el-table-column prop="cert_no" label="证书编号" width="140" />
|
||||
<el-table-column prop="exam_name" label="考试名称" min-width="160" />
|
||||
<el-table-column label="成绩" width="120">
|
||||
<el-table-column prop="exam_name" label="考试名称" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column label="成绩" width="120" align="center">
|
||||
<template #default="{ row }">{{ row.score }} / {{ row.total_score }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="issued_at" label="颁发时间" width="180">
|
||||
<template #default="{ row }">{{ fmtTime(row.issued_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120">
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" size="small" @click="openCert(row)">查看证书</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-empty v-if="!loading && items.length === 0" description="暂无证书,正式考试通过后自动颁发" />
|
||||
|
||||
<div class="table-pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="size"
|
||||
:page-sizes="[10, 20, 40]"
|
||||
:total="total"
|
||||
background
|
||||
layout="sizes, prev, pager, next"
|
||||
@current-change="load"
|
||||
@size-change="handleSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 证书弹窗(可打印) -->
|
||||
@@ -51,11 +83,15 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { myCertificates } from '@/api/certificate'
|
||||
|
||||
const loading = ref(false)
|
||||
const filters = reactive({ examName: '' })
|
||||
const items = ref([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const size = ref(10)
|
||||
const loading = ref(false)
|
||||
const showCert = ref(false)
|
||||
const cert = ref(null)
|
||||
|
||||
@@ -77,18 +113,47 @@ async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await myCertificates()
|
||||
items.value = res.data || []
|
||||
const records = res.data || []
|
||||
items.value = records
|
||||
.filter((c) => !filters.examName || (c.exam_name || '').includes(filters.examName))
|
||||
.slice((page.value - 1) * size.value, page.value.value * size.value)
|
||||
total.value = records.length
|
||||
} catch (e) {
|
||||
items.value = []
|
||||
total.value = 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
filters.examName = ''
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function handleSizeChange() {
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.filter-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.table-pagination {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.cert-printable {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
@@ -1,181 +1,143 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-title">我的错题本</div>
|
||||
<!-- 筛选卡 -->
|
||||
<el-card shadow="never" class="filter-card">
|
||||
<el-form :inline="true" :model="filters" size="default">
|
||||
<el-form-item label="来源">
|
||||
<el-select v-model="filters.source" placeholder="全部" clearable style="width: 140px;">
|
||||
<el-option label="自测练习" value="self_test" />
|
||||
<el-option label="正式考试" value="formal" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="filters.resolved" placeholder="全部" clearable style="width: 140px;">
|
||||
<el-option label="未掌握" :value="false" />
|
||||
<el-option label="已掌握" :value="true" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="load">查询</el-button>
|
||||
<el-button @click="resetFilters">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- 列表 -->
|
||||
<template v-if="mode === 'list'">
|
||||
<el-card shadow="never" class="filter-card">
|
||||
<el-form :inline="true" size="default">
|
||||
<el-form-item label="来源">
|
||||
<el-select v-model="filter.source" placeholder="全部" clearable style="width: 140px;" @change="page = 1">
|
||||
<el-option label="自测练习" value="self_test" />
|
||||
<el-option label="正式考试" value="formal" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="filter.resolved" placeholder="全部" clearable style="width: 140px;" @change="page = 1">
|
||||
<el-option label="未掌握" :value="false" />
|
||||
<el-option label="已掌握" :value="true" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button @click="resetFilter">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="list-header">
|
||||
<span>共 {{ filtered.length }} 条(未掌握 {{ unresolvedCount }} 条)</span>
|
||||
<el-button type="primary" size="small" :disabled="unresolvedCount === 0" @click="startPractice">
|
||||
错题重练(未掌握 {{ unresolvedCount }} 题)
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-for="m in paged" :key="m.id" class="mistake-item">
|
||||
<div class="mistake-head">
|
||||
<div class="mistake-title">
|
||||
<el-tag size="small" :type="m.question_type === 'multiple' ? 'success' : m.question_type === 'judge' ? 'warning' : m.question_type === 'essay' ? 'danger' : ''">
|
||||
{{ labelOf(QUESTION_TYPES, m.question_type) }}
|
||||
</el-tag>
|
||||
<el-tag size="small" :type="m.source === 'formal' ? 'danger' : 'info'" effect="plain">
|
||||
{{ m.source === 'formal' ? '正式考试' : '自测练习' }}
|
||||
</el-tag>
|
||||
<span class="stem">{{ m.question_stem }}</span>
|
||||
</div>
|
||||
<el-button
|
||||
:type="m.resolved ? 'info' : 'success'"
|
||||
size="small"
|
||||
:plain="m.resolved"
|
||||
@click="toggleResolved(m)"
|
||||
>
|
||||
{{ m.resolved ? '取消掌握' : '标记已掌握' }}
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="mistake-body">
|
||||
<div class="line">我的作答:<span :class="{ wrong: !m.resolved }">{{ fmtUser(m.question_type, m.user_answer) }}</span></div>
|
||||
<div class="line">参考答案:{{ fmtCorrect(m.question_type, m.correct_answer) }}</div>
|
||||
<div v-if="m.explanation" class="line explain">解析:{{ m.explanation }}</div>
|
||||
</div>
|
||||
<!-- 列表卡 -->
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>我的错题本(共 {{ total }} 条)</span>
|
||||
<el-button type="primary" size="small" :disabled="unresolvedCount === 0" @click="startPractice">
|
||||
错题重练(未掌握 {{ unresolvedCount }} 题)
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-empty v-if="!loading && filtered.length === 0" description="太棒了,暂无错题" />
|
||||
<div v-else class="table-pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="pageSize"
|
||||
:page-sizes="[10, 20, 40]"
|
||||
:total="filtered.length"
|
||||
background
|
||||
layout="sizes, prev, pager, next"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<!-- 错题重练答题 -->
|
||||
<template v-else-if="mode === 'quiz'">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="exam-header">
|
||||
<span>错题重练</span>
|
||||
<el-tag type="warning">重练模式 · 答对即掌握</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
<div v-for="(q, idx) in questions" :key="q.id" class="question-item">
|
||||
<div class="question-title">
|
||||
{{ idx + 1 }}. {{ q.stem }}
|
||||
<el-tag size="small" :type="q.type === 'multiple' ? 'success' : q.type === 'judge' ? 'warning' : 'info'">
|
||||
{{ labelOf(QUESTION_TYPES, q.type) }}
|
||||
<el-table :data="items" border stripe style="width: 100%" v-loading="loading">
|
||||
<el-table-column prop="id" label="编号" width="70" />
|
||||
<el-table-column label="题目类型" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="row.question_type === 'multiple' ? 'success' : row.question_type === 'judge' ? 'warning' : row.question_type === 'essay' ? 'danger' : ''">
|
||||
{{ labelOf(QUESTION_TYPES, row.question_type) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<el-radio-group v-if="q.type === 'single'" v-model="answers[q.id]" class="opt-group">
|
||||
<el-radio v-for="opt in q.options" :key="opt.key" :value="opt.key" style="display: block; margin-bottom: 8px;">
|
||||
{{ opt.key }}. {{ opt.text }}
|
||||
</el-radio>
|
||||
</el-radio-group>
|
||||
<el-checkbox-group v-else-if="q.type === 'multiple'" v-model="answers[q.id]" class="opt-group">
|
||||
<el-checkbox v-for="opt in q.options" :key="opt.key" :value="opt.key" style="display: block; margin-bottom: 8px;">
|
||||
{{ opt.key }}. {{ opt.text }}
|
||||
</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
<el-radio-group v-else-if="q.type === 'judge'" v-model="answers[q.id]" class="opt-group">
|
||||
<el-radio value="true" style="display: block; margin-bottom: 8px;">正确</el-radio>
|
||||
<el-radio value="false" style="display: block; margin-bottom: 8px;">错误</el-radio>
|
||||
</el-radio-group>
|
||||
<el-input v-else-if="q.type === 'essay'" v-model="answers[q.id]" type="textarea" :rows="4" placeholder="请输入你的答案" class="opt-group" />
|
||||
</div>
|
||||
<div style="margin-top: 24px; text-align: center;">
|
||||
<el-button size="large" @click="mode = 'list'">退出</el-button>
|
||||
<el-button type="primary" size="large" :loading="submitting" @click="submitPractice">提交答案</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<!-- 结果 -->
|
||||
<template v-else-if="mode === 'result'">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="exam-header"><span>重练结果</span><el-tag type="warning">错题重练</el-tag></div>
|
||||
</template>
|
||||
<el-result
|
||||
:icon="result.passed ? 'success' : 'info'"
|
||||
:title="`答对 ${result.correct_count} / ${result.wrong_count + result.correct_count} 题`"
|
||||
:sub-title="`答对的错题已自动标记为「已掌握」,答错的继续留在错题本`"
|
||||
>
|
||||
<template #extra>
|
||||
<el-button type="primary" @click="backToList">返回错题本</el-button>
|
||||
</template>
|
||||
</el-result>
|
||||
<el-divider content-position="left">逐题解析</el-divider>
|
||||
<div v-for="(d, idx) in result.detail" :key="d.question_id" class="question-item">
|
||||
<div class="question-title">
|
||||
{{ idx + 1 }}. {{ d.stem }}
|
||||
<el-tag size="small" :type="d.is_correct ? 'success' : 'danger'">{{ d.is_correct ? '正确' : '错误' }}</el-tag>
|
||||
</div>
|
||||
<div v-if="d.type === 'essay'" class="detail-line">
|
||||
AI 得分率:{{ Math.round((d.score_rate || 0) * 100) }}%<span v-if="d.comment"> · {{ d.comment }}</span>
|
||||
</div>
|
||||
<div class="detail-line">正确答案:{{ fmtCorrect(d.type, d.correct_answer) }}</div>
|
||||
<div v-if="d.explanation" class="detail-line explain">解析:{{ d.explanation }}</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="来源" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="row.source === 'formal' ? 'danger' : 'info'" effect="plain">
|
||||
{{ row.source === 'formal' ? '正式考试' : '自测练习' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="question_stem" label="题目" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column label="我的作答" width="120">
|
||||
<template #default="{ row }">{{ fmtUser(row.question_type, row.user_answer) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="参考答案" width="120">
|
||||
<template #default="{ row }">{{ fmtCorrect(row.question_type, row.correct_answer) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-button :type="row.resolved ? 'info' : 'success'" size="small" :plain="row.resolved" @click="toggleResolved(row)">
|
||||
{{ row.resolved ? '已掌握' : '未掌握' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="提交时间" width="170">
|
||||
<template #default="{ row }">{{ formatTime(row.created_at) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-empty v-if="!loading && items.length === 0" description="太棒了,暂无错题" />
|
||||
|
||||
<div class="table-pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="size"
|
||||
:page-sizes="[10, 20, 40]"
|
||||
:total="total"
|
||||
background
|
||||
layout="sizes, prev, pager, next"
|
||||
@current-change="load"
|
||||
@size-change="handleSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { myMistakes, resolveMistake, mistakePractice, examSubmit } from '@/api/exam'
|
||||
import { myMistakes, resolveMistake, mistakePractice } from '@/api/exam'
|
||||
import { QUESTION_TYPES, labelOf } from '@/utils/labels'
|
||||
|
||||
const mode = ref('list')
|
||||
const loading = ref(false)
|
||||
const filters = reactive({ source: '', resolved: null })
|
||||
const items = ref([])
|
||||
const filter = reactive({ source: '', resolved: null })
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
|
||||
const questions = ref([])
|
||||
const answers = reactive({})
|
||||
const sessionId = ref('')
|
||||
const startedAt = ref(0)
|
||||
const submitting = ref(false)
|
||||
const result = ref(null)
|
||||
|
||||
const filtered = computed(() => {
|
||||
return items.value.filter((m) => {
|
||||
if (filter.source && m.source !== filter.source) return false
|
||||
if (filter.resolved !== null && m.resolved !== filter.resolved) return false
|
||||
return true
|
||||
})
|
||||
})
|
||||
const size = ref(10)
|
||||
const loading = ref(false)
|
||||
|
||||
const unresolvedCount = computed(() => items.value.filter((m) => !m.resolved).length)
|
||||
const paged = computed(() => filtered.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await myMistakes()
|
||||
const records = res.data || []
|
||||
items.value = records
|
||||
.filter((m) => {
|
||||
if (filters.source && m.source !== filters.source) return false
|
||||
if (filters.resolved !== null && m.resolved !== filters.resolved) return false
|
||||
return true
|
||||
})
|
||||
.slice((page.value - 1) * size.value, page.value.value * size.value)
|
||||
total.value = records.length
|
||||
} catch (e) {
|
||||
items.value = []
|
||||
total.value = 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
filters.source = ''
|
||||
filters.resolved = null
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function handleSizeChange() {
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function formatTime(t) {
|
||||
if (!t) return '—'
|
||||
return String(t).replace('T', ' ').slice(0, 19)
|
||||
}
|
||||
|
||||
function fmtUser(type, ans) {
|
||||
if (ans == null || ans === '') return '未作答'
|
||||
@@ -191,30 +153,13 @@ function fmtCorrect(type, correct) {
|
||||
return correct.join('、')
|
||||
}
|
||||
|
||||
function resetFilter() {
|
||||
filter.source = ''
|
||||
filter.resolved = null
|
||||
page.value = 1
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await myMistakes()
|
||||
items.value = res.data || []
|
||||
} catch (e) {
|
||||
items.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleResolved(m) {
|
||||
try {
|
||||
const next = !m.resolved
|
||||
await resolveMistake(m.id, next)
|
||||
m.resolved = next
|
||||
ElMessage.success(next ? '已标记掌握' : '已取消掌握')
|
||||
load()
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.message || '操作失败')
|
||||
}
|
||||
@@ -223,39 +168,12 @@ async function toggleResolved(m) {
|
||||
async function startPractice() {
|
||||
try {
|
||||
const res = await mistakePractice({ only_unresolved: true })
|
||||
const d = res.data
|
||||
sessionId.value = d.session_id
|
||||
questions.value = d.questions || []
|
||||
Object.keys(answers).forEach((k) => delete answers[k])
|
||||
questions.value.forEach((q) => {
|
||||
answers[q.id] = q.type === 'multiple' ? [] : ''
|
||||
})
|
||||
startedAt.value = Math.floor(Date.now() / 1000)
|
||||
mode.value = 'quiz'
|
||||
ElMessage.success('错题重练已开始')
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.message || '暂无错题可重练')
|
||||
}
|
||||
}
|
||||
|
||||
async function submitPractice() {
|
||||
submitting.value = true
|
||||
try {
|
||||
const timeSpent = Math.floor(Date.now() / 1000) - startedAt.value
|
||||
const res = await examSubmit({ session_id: sessionId.value, answers: { ...answers }, time_spent_sec: timeSpent })
|
||||
result.value = res.data
|
||||
mode.value = 'result'
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.message || '提交失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function backToList() {
|
||||
mode.value = 'list'
|
||||
load()
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
@@ -263,80 +181,14 @@ onMounted(load)
|
||||
.filter-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.list-header {
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.mistake-item {
|
||||
padding: 14px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
.mistake-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.mistake-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.mistake-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.stem {
|
||||
font-weight: 500;
|
||||
color: #303133;
|
||||
}
|
||||
.mistake-body {
|
||||
margin-top: 8px;
|
||||
padding-left: 4px;
|
||||
}
|
||||
.line {
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.line .wrong {
|
||||
color: #f56c6c;
|
||||
}
|
||||
.line.explain {
|
||||
color: #909399;
|
||||
}
|
||||
.table-pagination {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.exam-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-weight: 600;
|
||||
}
|
||||
.question-item {
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
.question-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.question-title {
|
||||
font-weight: 500;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.opt-group {
|
||||
width: 100%;
|
||||
}
|
||||
.detail-line {
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.detail-line.explain {
|
||||
color: #909399;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,57 +1,105 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-title">我的岗位应学清单</div>
|
||||
<!-- 筛选卡 -->
|
||||
<el-card shadow="never" class="filter-card">
|
||||
<el-form :inline="true" :model="filters" size="default">
|
||||
<el-form-item label="知识域">
|
||||
<el-select v-model="filters.domain" placeholder="全部" clearable style="width: 160px;">
|
||||
<el-option v-for="d in domains" :key="d" :label="labelOf(QUESTION_DOMAINS, d)" :value="d" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="要求级别">
|
||||
<el-select v-model="filters.level" placeholder="全部" clearable style="width: 160px;">
|
||||
<el-option v-for="l in levels" :key="l" :label="labelOf(POSITION_LEVELS, l)" :value="l" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="load">查询</el-button>
|
||||
<el-button @click="resetFilters">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-empty v-if="!loading && !data.position" description="尚未关联岗位,请联系管理员在「用户管理」中为你设置岗位" />
|
||||
|
||||
<template v-else>
|
||||
<el-card shadow="never" class="pos-card">
|
||||
<div class="pos-head">
|
||||
<div>
|
||||
<div class="pos-name">{{ data.position?.name }}</div>
|
||||
<div class="pos-code">{{ data.position?.code }}</div>
|
||||
</div>
|
||||
<el-tag size="large" type="primary">共 {{ data.count }} 项应学内容</el-tag>
|
||||
<!-- 岗位信息卡 -->
|
||||
<el-card shadow="never" v-if="data.position" class="pos-card">
|
||||
<div class="pos-head">
|
||||
<div>
|
||||
<div class="pos-name">{{ data.position?.name }}</div>
|
||||
<div class="pos-code">{{ data.position?.code }}</div>
|
||||
</div>
|
||||
<div v-if="data.position?.description" class="pos-desc">{{ data.position.description }}</div>
|
||||
</el-card>
|
||||
<el-tag size="large" type="primary">共 {{ data.count }} 项应学内容</el-tag>
|
||||
</div>
|
||||
<div v-if="data.position?.description" class="pos-desc">{{ data.position.description }}</div>
|
||||
</el-card>
|
||||
<el-empty v-else description="尚未关联岗位,请联系管理员在「用户管理」中为你设置岗位" />
|
||||
|
||||
<el-card shadow="never">
|
||||
<template #header><span>应学范围</span></template>
|
||||
<el-table :data="data.knowledge" border stripe v-loading="loading">
|
||||
<el-table-column label="知识域" width="120">
|
||||
<template #default="{ row }">{{ labelOf(QUESTION_DOMAINS, row.domain) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="课程" min-width="180">
|
||||
<template #default="{ row }">{{ row.course_name || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="产品" min-width="180">
|
||||
<template #default="{ row }">{{ row.product_name || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="要求级别" width="140">
|
||||
<template #default="{ row }">{{ labelOf(POSITION_LEVELS, row.required_level) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="必学" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="row.is_mandatory ? 'danger' : 'info'">{{ row.is_mandatory ? '必学' : '选学' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="权重" width="80">
|
||||
<template #default="{ row }">{{ row.weight }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</template>
|
||||
<!-- 应学内容表 -->
|
||||
<el-card shadow="never" v-if="data.position">
|
||||
<template #header>
|
||||
<span>应学范围</span>
|
||||
</template>
|
||||
|
||||
<el-table :data="filteredItems" border stripe style="width: 100%" v-loading="loading">
|
||||
<el-table-column label="知识域" width="120">
|
||||
<template #default="{ row }">{{ labelOf(QUESTION_DOMAINS, row.domain) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="课程" min-width="180">
|
||||
<template #default="{ row }">{{ row.course_name || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="产品" min-width="180">
|
||||
<template #default="{ row }">{{ row.product_name || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="要求级别" width="140">
|
||||
<template #default="{ row }">{{ labelOf(POSITION_LEVELS, row.required_level) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="必学" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="row.is_mandatory ? 'danger' : 'info'">{{ row.is_mandatory ? '必学' : '选学' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="权重" width="80">
|
||||
<template #default="{ row }">{{ row.weight }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="table-pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="size"
|
||||
:page-sizes="[10, 20, 40]"
|
||||
:total="total"
|
||||
background
|
||||
layout="sizes, prev, pager, next"
|
||||
@current-change="load"
|
||||
@size-change="handleSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { myPosition } from '@/api/position'
|
||||
import { QUESTION_DOMAINS, POSITION_LEVELS, labelOf } from '@/utils/labels'
|
||||
|
||||
const filters = reactive({ domain: '', level: null })
|
||||
const loading = ref(false)
|
||||
const data = reactive({ position: null, knowledge: [], count: 0 })
|
||||
const page = ref(1)
|
||||
const size = ref(10)
|
||||
|
||||
const domains = computed(() => [...new Set((data.knowledge || []).map((k) => k.domain))])
|
||||
const levels = computed(() => [...new Set((data.knowledge || []).map((k) => k.required_level))])
|
||||
|
||||
const filteredItems = computed(() => {
|
||||
let items = data.knowledge || []
|
||||
if (filters.domain) items = items.filter((k) => k.domain === filters.domain)
|
||||
if (filters.level !== null) items = items.filter((k) => k.required_level === filters.level)
|
||||
return items
|
||||
})
|
||||
|
||||
const total = computed(() => filteredItems.value.length)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
@@ -70,10 +118,24 @@ async function load() {
|
||||
}
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
filters.domain = ''
|
||||
filters.level = null
|
||||
load()
|
||||
}
|
||||
|
||||
function handleSizeChange() {
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.filter-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.pos-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
@@ -97,4 +159,9 @@ onMounted(load)
|
||||
color: #606266;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.table-pagination {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-title">我的学习档案</div>
|
||||
<!-- 筛选卡 -->
|
||||
<el-card shadow="never" class="filter-card">
|
||||
<el-form :inline="true" :model="filters" size="default">
|
||||
<el-form-item label="时间范围">
|
||||
<el-select v-model="filters.timeRange" placeholder="全部" clearable style="width: 160px;">
|
||||
<el-option label="最近7天" value="7d" />
|
||||
<el-option label="最近30天" value="30d" />
|
||||
<el-option label="最近90天" value="90d" />
|
||||
<el-option label="全部" value="all" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="load">查询</el-button>
|
||||
<el-button @click="resetFilters">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-row :gutter="16" v-loading="loading">
|
||||
<!-- 左:能力雷达 -->
|
||||
@@ -64,11 +80,12 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { myProfile } from '@/api/profile'
|
||||
import RadarChart from '@/components/charts/RadarChart.vue'
|
||||
import LineChart from '@/components/charts/LineChart.vue'
|
||||
|
||||
const filters = reactive({ timeRange: 'all' })
|
||||
const loading = ref(false)
|
||||
const data = ref({})
|
||||
|
||||
@@ -98,10 +115,18 @@ async function load() {
|
||||
}
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
filters.timeRange = 'all'
|
||||
load()
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.filter-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -126,14 +126,8 @@
|
||||
v-model="inputText"
|
||||
:placeholder="inputText ? '继续对话或发送新指令...' : '上传音频或描述你的转录需求...'"
|
||||
:can-send="inputText.trim().length > 0 && !sending"
|
||||
:show-actions="true"
|
||||
@send="handleSend"
|
||||
>
|
||||
<template #left-actions>
|
||||
<button class="input-action-btn" title="文件附件" @click="handleFileAttach">📎</button>
|
||||
<button class="input-action-btn" title="录音" @click="handleRecord">🎤</button>
|
||||
</template>
|
||||
</ChatInputBar>
|
||||
/>
|
||||
</template>
|
||||
</ChatLayout>
|
||||
</div>
|
||||
@@ -255,9 +249,6 @@ function downloadTranscription(msg) {
|
||||
ElMessage.success('下载完成')
|
||||
}
|
||||
|
||||
function handleFileAttach() { ElMessage.info('文件上传功能开发中') }
|
||||
function handleRecord() { ElMessage.info('录音功能开发中') }
|
||||
|
||||
function escapeHtml(text) {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
|
||||
@@ -137,13 +137,8 @@
|
||||
v-model="inputText"
|
||||
:placeholder="'描述提取需求,如:从这段文本中提取姓名、电话、邮箱...'"
|
||||
:can-send="inputText.trim().length > 0 && !sending"
|
||||
:show-actions="true"
|
||||
@send="handleSend"
|
||||
>
|
||||
<template #left-actions>
|
||||
<button class="input-action-btn" title="文件附件" @click="handleFileAttach">📎</button>
|
||||
</template>
|
||||
</ChatInputBar>
|
||||
/>
|
||||
</template>
|
||||
</ChatLayout>
|
||||
</div>
|
||||
@@ -246,8 +241,6 @@ function downloadExtract(msg) {
|
||||
ElMessage.info('下载功能开发中')
|
||||
}
|
||||
|
||||
function handleFileAttach() { ElMessage.info('文件上传功能开发中') }
|
||||
|
||||
function escapeHtml(text) {
|
||||
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
@@ -643,7 +643,7 @@
|
||||
|
||||
<!-- Welcome state -->
|
||||
<div class="welcome-state" id="welcomeState">
|
||||
<div class="welcome-title">我 Bosheng 数字员工,我帮你</div>
|
||||
<div class="welcome-title">博昇AI数字员工,您说,我做</div>
|
||||
<div class="welcome-subtitle">今天帮你做些什么? @ 引用对话文件,/ 调用技能与指令</div>
|
||||
|
||||
<!-- Category pills -->
|
||||
|
||||
@@ -165,13 +165,8 @@
|
||||
v-model="inputText"
|
||||
:placeholder="'审查合同,如:审查这份采购合同的法律风险...'"
|
||||
:can-send="inputText.trim().length > 0 && !sending"
|
||||
:show-actions="true"
|
||||
@send="handleSend"
|
||||
>
|
||||
<template #left-actions>
|
||||
<button class="input-action-btn" title="文件附件" @click="handleFileAttach">📎</button>
|
||||
</template>
|
||||
</ChatInputBar>
|
||||
/>
|
||||
</template>
|
||||
</ChatLayout>
|
||||
</div>
|
||||
@@ -276,8 +271,6 @@ function copyReview(msg) {
|
||||
navigator.clipboard.writeText(text).then(() => ElMessage.success('已复制'))
|
||||
}
|
||||
|
||||
function handleFileAttach() { ElMessage.info('文件上传功能开发中') }
|
||||
|
||||
function escapeHtml(text) {
|
||||
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
@@ -140,13 +140,8 @@
|
||||
v-model="inputText"
|
||||
:placeholder="'粘贴文案进行校对,如:检查这段销售话术的错别字和语病...'"
|
||||
:can-send="inputText.trim().length > 0 && !sending"
|
||||
:show-actions="true"
|
||||
@send="handleSend"
|
||||
>
|
||||
<template #left-actions>
|
||||
<button class="input-action-btn" title="文件附件" @click="handleFileAttach">📎</button>
|
||||
</template>
|
||||
</ChatInputBar>
|
||||
/>
|
||||
</template>
|
||||
</ChatLayout>
|
||||
</div>
|
||||
@@ -244,8 +239,6 @@ function copyProof(msg) {
|
||||
navigator.clipboard.writeText(text).then(() => ElMessage.success('已复制'))
|
||||
}
|
||||
|
||||
function handleFileAttach() { ElMessage.info('文件上传功能开发中') }
|
||||
|
||||
function escapeHtml(text) {
|
||||
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
@@ -161,14 +161,8 @@
|
||||
v-model="inputText"
|
||||
:placeholder="'描述你的翻译任务,如:把这段翻译成英文,保持专业语气...'"
|
||||
:can-send="inputText.trim().length > 0 && !sending"
|
||||
:show-actions="true"
|
||||
@send="handleSend"
|
||||
>
|
||||
<template #left-actions>
|
||||
<button class="input-action-btn" title="文件附件" @click="handleFileAttach">📎</button>
|
||||
<button class="input-action-btn" title="引用" @click="handleReference">📑</button>
|
||||
</template>
|
||||
</ChatInputBar>
|
||||
/>
|
||||
</template>
|
||||
</ChatLayout>
|
||||
</div>
|
||||
@@ -309,14 +303,6 @@ function downloadTranslation(msg) {
|
||||
ElMessage.info('下载功能开发中')
|
||||
}
|
||||
|
||||
function handleFileAttach() {
|
||||
ElMessage.info('文件上传功能开发中')
|
||||
}
|
||||
|
||||
function handleReference() {
|
||||
ElMessage.info('引用功能开发中')
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -23,6 +23,7 @@
|
||||
|
||||
<ChatLayout
|
||||
ref="chatLayoutRef"
|
||||
hero-input
|
||||
:has-messages="messages.length > 1"
|
||||
:active-nav-item="activeNavItem"
|
||||
:history-count="5"
|
||||
@@ -35,26 +36,29 @@
|
||||
<!-- Welcome state (shown when no messages) -->
|
||||
<template v-if="messages.length <= 1" #welcome>
|
||||
<div class="welcome-content">
|
||||
<div class="welcome-title">我 Bosheng 数字员工,我帮你</div>
|
||||
<div class="welcome-subtitle">今天帮你做些什么? @ 引用对话文件,/ 调用技能与指令</div>
|
||||
<!-- 大标题下面原本还有一行副标题(今天帮你做些什么?…),
|
||||
和输入框的 placeholder 说的是同一件事,删掉一行。
|
||||
@ / 的用法提示留在 placeholder 里,那儿才是要用的时候看得见的地方。 -->
|
||||
<div class="welcome-title">博昇AI数字员工,您说,我做</div>
|
||||
|
||||
<!-- Mode pills above input -->
|
||||
<!-- Mode pills above input:和输入框 + 菜单里的「模式」是同一份配置、同一个状态 -->
|
||||
<div class="mode-pills">
|
||||
<div class="mode-pill" :class="{ active: mode === 'chat' }" @click="mode = 'chat'">
|
||||
<span class="pill-icon">💬</span> 对话
|
||||
</div>
|
||||
<div class="mode-pill" :class="{ active: mode === 'task' }" @click="mode = 'task'">
|
||||
<span class="pill-icon">📋</span> 任务拆解
|
||||
</div>
|
||||
<div class="mode-pill" :class="{ active: mode === 'copy' }" @click="mode = 'copy'">
|
||||
<span class="pill-icon">✏️</span> 文案生成
|
||||
</div>
|
||||
<div class="mode-pill" :class="{ active: mode === 'extract' }" @click="mode = 'extract'">
|
||||
<span class="pill-icon">📊</span> 批量提取
|
||||
<div
|
||||
v-for="m in assistantModes"
|
||||
:key="m.key"
|
||||
class="mode-pill"
|
||||
:class="{ active: mode === m.key }"
|
||||
@click="mode = m.key"
|
||||
>
|
||||
<span class="pill-icon">{{ m.icon }}</span> {{ m.label }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Best practice cards -->
|
||||
<!-- 输入框下方:任务还没开始时给几个能直接点的例子 -->
|
||||
<template #welcome-footer>
|
||||
<div class="welcome-content">
|
||||
<div class="best-practices">
|
||||
<div class="practices-header">
|
||||
<span class="practices-title">不知道做什么,试试最佳实践案例</span>
|
||||
@@ -130,18 +134,16 @@
|
||||
<!-- Input slot -->
|
||||
<template #input>
|
||||
<ChatInputBar
|
||||
ref="inputBarRef"
|
||||
v-model="inputText"
|
||||
:modes="assistantModes"
|
||||
:mode="mode"
|
||||
:placeholder="placeholder"
|
||||
:can-send="inputText.trim().length > 0 && !sending"
|
||||
:show-actions="true"
|
||||
:variant="messages.length > 1 ? 'docked' : 'card'"
|
||||
@update:mode="mode = $event"
|
||||
@send="handleSend"
|
||||
>
|
||||
<template #left-actions>
|
||||
<button class="input-action-btn" title="文件附件" @click="handleFileAttach">📎</button>
|
||||
<button class="input-action-btn" title="语音输入" @click="handleVoice">🎤</button>
|
||||
<button class="input-action-btn" title="引用" @click="handleReference">📑</button>
|
||||
</template>
|
||||
</ChatInputBar>
|
||||
/>
|
||||
</template>
|
||||
</ChatLayout>
|
||||
</div>
|
||||
@@ -153,12 +155,17 @@ import { ElMessage } from 'element-plus'
|
||||
import { chatWithAssistant } from '@/api/assistant'
|
||||
import ChatLayout from '@/components/chat/ChatLayout.vue'
|
||||
import ChatInputBar from '@/components/chat/ChatInputBar.vue'
|
||||
import { assistantModes } from '@/config/workbench'
|
||||
import { useWorkerRuntimeStore } from '@/store/workerRuntime'
|
||||
|
||||
const workerRuntime = useWorkerRuntimeStore()
|
||||
|
||||
const mode = ref('chat')
|
||||
const selectedCategory = ref('doc')
|
||||
const activeNavItem = ref('assistant')
|
||||
const inputText = ref('')
|
||||
const sending = ref(false)
|
||||
const inputBarRef = ref(null)
|
||||
const messages = ref([
|
||||
{
|
||||
role: 'assistant',
|
||||
@@ -175,26 +182,36 @@ const taskItems = ref([
|
||||
{ id: '5', title: '管理企业微信待办...' },
|
||||
])
|
||||
|
||||
const placeholder = computed(() => {
|
||||
const map = {
|
||||
chat: '输入你的问题...',
|
||||
task: '输入需要拆解的任务...',
|
||||
copy: '输入需要生成的文案类型...',
|
||||
extract: '输入包含字段的文档内容...',
|
||||
}
|
||||
return map[mode.value] || '输入你的问题...'
|
||||
})
|
||||
const placeholder = computed(
|
||||
() => assistantModes.find((m) => m.key === mode.value)?.placeholder || '输入你的问题...'
|
||||
)
|
||||
|
||||
async function send() {
|
||||
if (!inputText.value.trim() || sending.value) return
|
||||
|
||||
const userMsg = {
|
||||
role: 'user',
|
||||
content: inputText.value,
|
||||
timestamp: new Date().toLocaleTimeString(),
|
||||
}
|
||||
messages.value.push(userMsg)
|
||||
const text = inputText.value
|
||||
|
||||
// 这条画布上还没有任务(刚点过「新建任务」,或压根没点开过任务)——
|
||||
// 任务在这时候才落库,标题就是这句话,专员用 + 里先选的那个。
|
||||
// 建失败就把话原样留在输入框里返回:此刻一个字都还没发出去,
|
||||
// 比起发完再回头收拾,这样用户看到的和实际发生的是一致的。
|
||||
if (!workerRuntime.currentTaskId) {
|
||||
sending.value = true
|
||||
try {
|
||||
await workerRuntime.createTaskForFirstMessage(text)
|
||||
} catch (error) {
|
||||
console.warn('create task for first message failed', error)
|
||||
ElMessage.error('创建任务失败,请稍后重试')
|
||||
sending.value = false
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
messages.value.push({
|
||||
role: 'user',
|
||||
content: text,
|
||||
timestamp: new Date().toLocaleTimeString(),
|
||||
})
|
||||
inputText.value = ''
|
||||
sending.value = true
|
||||
|
||||
@@ -225,6 +242,8 @@ async function handleSend(text) {
|
||||
await send()
|
||||
}
|
||||
|
||||
// 新建任务 = 把画布清回开场状态:对话清空、草稿丢掉、输入框回到标题下面
|
||||
// 并直接把光标放进去,点完就能打字。
|
||||
function handleNewChat() {
|
||||
messages.value = [
|
||||
{
|
||||
@@ -233,6 +252,8 @@ function handleNewChat() {
|
||||
timestamp: new Date().toLocaleTimeString(),
|
||||
},
|
||||
]
|
||||
inputText.value = ''
|
||||
nextTick(() => inputBarRef.value?.focus())
|
||||
}
|
||||
|
||||
function handleSelectTask(id) {
|
||||
@@ -266,17 +287,6 @@ function copyMessage(text) {
|
||||
})
|
||||
}
|
||||
|
||||
function handleFileAttach() {
|
||||
ElMessage.info('文件上传功能开发中')
|
||||
}
|
||||
|
||||
function handleVoice() {
|
||||
ElMessage.info('语音输入功能开发中')
|
||||
}
|
||||
|
||||
function handleReference() {
|
||||
ElMessage.info('引用功能开发中')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -7,8 +7,10 @@
|
||||
<p>这里承载可安装的数字员工专员、连接器包、技能单元和模板版本,并明确区分通用专员与行业专员。</p>
|
||||
</div>
|
||||
<div class="hero-actions">
|
||||
<el-button type="primary" plain @click="goStudioCreateDw">画布创建 DW</el-button>
|
||||
<el-button type="primary" @click="openCreate">新增专员</el-button>
|
||||
<!-- 这一页对所有人开放(专员 tab 里就有它),但增删改是管理员的活:
|
||||
普通用户看到的是可浏览、可打开工作区的目录,不是一个点了就 403 的表单。 -->
|
||||
<el-button v-if="isAdmin" type="primary" plain @click="goStudioCreateDw">画布创建 DW</el-button>
|
||||
<el-button v-if="isAdmin" type="primary" @click="openCreate">新增专员</el-button>
|
||||
<el-button @click="loadAll">刷新目录</el-button>
|
||||
</div>
|
||||
</section>
|
||||
@@ -61,7 +63,7 @@
|
||||
<div class="meta secondary">{{ item.capability }}</div>
|
||||
<div class="card-actions">
|
||||
<el-button type="primary" link size="small" :disabled="item.state !== 'active'" @click="router.push(item.route)">打开工作区</el-button>
|
||||
<el-button type="primary" link size="small" @click="openEdit(item.raw)">编辑</el-button>
|
||||
<el-button v-if="isAdmin" type="primary" link size="small" @click="openEdit(item.raw)">编辑</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -92,13 +94,13 @@
|
||||
<div class="meta secondary">{{ item.capability }}</div>
|
||||
<div class="card-actions">
|
||||
<el-button type="primary" link size="small" :disabled="item.state !== 'active'" @click="router.push(item.route)">打开工作区</el-button>
|
||||
<el-button type="primary" link size="small" @click="openEdit(item.raw)">编辑</el-button>
|
||||
<el-button v-if="isAdmin" type="primary" link size="small" @click="openEdit(item.raw)">编辑</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel admin-panel">
|
||||
<section v-if="isAdmin" class="panel admin-panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<div class="group-title">专员目录维护</div>
|
||||
@@ -482,8 +484,12 @@ import {
|
||||
listSpecialists,
|
||||
updateSpecialist,
|
||||
} from '@/api/specialist'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
// 浏览对所有人开放,目录维护只给管理员 —— 后端的写接口本来也只有管理员能调。
|
||||
const isAdmin = computed(() => auth.isAdmin)
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const dialogVisible = ref(false)
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
<template>
|
||||
<div class="page-container spec-picker">
|
||||
<header class="picker-head">
|
||||
<div class="picker-title">
|
||||
<h2>我的专员</h2>
|
||||
<span class="picker-count">· {{ specialists.length }} 个在用</span>
|
||||
</div>
|
||||
<p class="picker-lede">
|
||||
每个专员是一个能独立跑完一条事务流的数字员工。卡片上的进度和风险就是它当前手上的活儿。
|
||||
</p>
|
||||
<el-button v-if="isAdmin" type="primary" plain size="small" @click="router.push('/market')">
|
||||
+ 专员市场
|
||||
</el-button>
|
||||
</header>
|
||||
|
||||
<div v-if="specialists.length" class="spec-grid">
|
||||
<button
|
||||
v-for="app in specialists"
|
||||
:key="app.key"
|
||||
type="button"
|
||||
class="spec-card"
|
||||
:style="{ '--stripe': app.color || '#409eff' }"
|
||||
:aria-label="`打开${app.label}`"
|
||||
@click="router.push(app.route)"
|
||||
>
|
||||
<div class="spec-top">
|
||||
<span class="spec-name">{{ app.label }}</span>
|
||||
<span class="spec-tier">{{ app.marketTag }}</span>
|
||||
</div>
|
||||
<p class="spec-desc">{{ app.summary }}</p>
|
||||
<div class="spec-meta">
|
||||
<span v-if="app.stage" class="chip">{{ app.stage }}</span>
|
||||
<span v-if="app.status" class="chip">{{ app.status }}</span>
|
||||
<span v-if="app.risk" class="chip" :class="{ warn: isRisk(app.risk) }">{{ app.risk }}</span>
|
||||
</div>
|
||||
<div v-if="app.progress > 0" class="spec-progress">
|
||||
<div class="spec-bar"><i :style="{ width: `${app.progress}%` }" /></div>
|
||||
<span class="spec-percent">{{ app.progress }}%</span>
|
||||
</div>
|
||||
<div v-else class="spec-progress idle">尚未开始</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<el-empty v-else description="还没有可用的专员" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { businessApps } from '@/config/workbench'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const isAdmin = computed(() => auth.isAdmin)
|
||||
|
||||
// config/workbench.js 是专员清单的唯一来源 —— 侧边栏和 tab 都按它认路,
|
||||
// 这里同样只列出路由真的能解析出来的那些,避免点进去是空白页。
|
||||
const specialists = computed(() => businessApps.filter((app) => isRoutable(app.route)))
|
||||
|
||||
// 「0 个异常」是正常态,其余(高风险 / 阻塞 / 待确认 / 延迟)都要显眼。
|
||||
function isRisk(risk) {
|
||||
return !/^0\s*个/.test(risk)
|
||||
}
|
||||
|
||||
function isRoutable(path) {
|
||||
try {
|
||||
return router.resolve(path).matched.length > 0
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.spec-picker {
|
||||
padding: 20px 24px 28px;
|
||||
}
|
||||
|
||||
.picker-head {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: start;
|
||||
gap: 6px 16px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.picker-title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.picker-title h2 {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
color: #1f2d3d;
|
||||
}
|
||||
|
||||
.picker-count {
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.picker-lede {
|
||||
grid-column: 1 / -1;
|
||||
margin: 0;
|
||||
max-width: 62ch;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
/* 按钮钉在第一行右侧;让它自动排会被 lede 挤到下一行并拉满整行。 */
|
||||
.picker-head :deep(.el-button) {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
/* 卡片按可用宽度自动铺排,窄屏自然落成一列。 */
|
||||
.spec-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(256px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.spec-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
overflow: hidden;
|
||||
padding: 13px 15px 13px 18px;
|
||||
border: 1px solid #e8ecf1;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, box-shadow 0.15s, transform 0.15s;
|
||||
}
|
||||
|
||||
/* 左侧色条:沿用每个专员自己的 color,一眼能区分。 */
|
||||
.spec-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 3px;
|
||||
background: var(--stripe);
|
||||
}
|
||||
|
||||
.spec-card:hover {
|
||||
border-color: #409eff;
|
||||
box-shadow: 0 4px 14px rgba(31, 45, 61, 0.08);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.spec-card:focus-visible {
|
||||
outline: 2px solid #409eff;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.spec-top {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.spec-name {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #1f2d3d;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.spec-tier {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
color: #a8b0bb;
|
||||
}
|
||||
|
||||
.spec-desc {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.spec-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
border-radius: 999px;
|
||||
padding: 2px 9px;
|
||||
background: #f4f6f9;
|
||||
color: #526071;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.chip.warn {
|
||||
background: #fef0f0;
|
||||
color: #f56c6c;
|
||||
}
|
||||
|
||||
.spec-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: auto;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.spec-bar {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: #eef1f6;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.spec-bar i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--stripe);
|
||||
}
|
||||
|
||||
.spec-percent {
|
||||
font-size: 11px;
|
||||
color: #909399;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.spec-progress.idle {
|
||||
font-size: 11px;
|
||||
color: #c0c8d4;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.picker-head {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.spec-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user