diff --git a/src/App.vue b/src/App.vue index 58d60ad..5f9ad4a 100644 --- a/src/App.vue +++ b/src/App.vue @@ -7,10 +7,11 @@ * 再往下传,避免各组件各拉一次。 * - 会话状态留在对话页内部,侧栏的「新建对话」通过组件 ref 调用它的方法触发。 */ -import { computed, onMounted, ref } from "vue"; +import { computed, onMounted, onUnmounted, ref } from "vue"; import { Message } from "view-ui-plus"; import { agentApi, baseApi } from "@/api/module/agent"; import AppSidebar from "@/components/AppSidebar.vue"; +import { onUsageChange, usageScope, usageSummary } from "@/utils/usage"; import type { AgentToolCatalog, UserInfoVo } from "@/types/agent"; const user = ref(null); @@ -30,6 +31,21 @@ const writeCount = computed(() => { return n; }); +/** + * 累计「已帮你办成」件数(本机使用画像,只有写操作成功才累加)。 + * + * 顶栏原本只回答"这个系统有多大"(能力数 / 可提交数), + * 补上这一格之后它同时回答"它为我做过多少事" —— 老用户最关心的那个数字。 + * 画像变更用订阅器感知,因此用户刚提交完一张表单,这里立刻 +1。 + */ +const usageVersion = ref(0); +let offUsage: (() => void) | null = null; + +const doneCount = computed(() => { + usageVersion.value; // 显式依赖:订阅器触发时重算 + return usageSummary(usageScope(catalog.value)).doneTotal; +}); + /** * 拉取当前登录用户(平台 CAS 会话)。 * @@ -63,8 +79,11 @@ async function loadCatalog() { onMounted(() => { loadUser(); loadCatalog(); + offUsage = onUsageChange(() => usageVersion.value++); }); +onUnmounted(() => offUsage?.()); + function onNewSession() { chatRef.value?.newSession?.(); } @@ -89,6 +108,10 @@ function onNewSession() { 可用能力 {{ toolCount }} 项 可直接提交 {{ writeCount }} 项 + diff --git a/src/components/AppSidebar.vue b/src/components/AppSidebar.vue index 53293f2..87b2646 100644 --- a/src/components/AppSidebar.vue +++ b/src/components/AppSidebar.vue @@ -4,7 +4,8 @@ * * 承担三件事,让主区可以专心做对话: * 1. 品牌与身份 —— 一眼知道这是什么、当前是谁在用 - * 2. 能力导航 —— 7 个业务模块全部列出,点一下就等于发出一句完整请求 + * 2. 能力导航 —— 当前身份可用的模块全部列出(**常用的排在前面并带一道色条**), + * 点一下就等于发出一句完整请求 * 3. 使用提示 —— 常驻说明"信息不全也没关系",降低首次使用门槛 */ import { computed, ref } from "vue"; @@ -13,6 +14,7 @@ import { baseApi } from "@/api/module/agent"; import conf from "@/assets/config/host.config"; import { MODULE_LABEL, moduleMeta, orderedModuleKeys } from "@/assets/config/modules"; import { beginLogoutGuard } from "@/utils/logout-guard"; +import { rankModuleKeys, usageScope, usageSnapshot } from "@/utils/usage"; import type { AgentToolCatalog, UserInfoVo } from "@/types/agent"; const props = defineProps<{ catalog: AgentToolCatalog | null; user?: UserInfoVo | null }>(); @@ -74,16 +76,26 @@ async function logout() { const active = ref(""); /** - * 侧栏只列出「当前身份真正可用」的模块。 + * 侧栏只列出「当前身份真正可用」的模块,并**按使用频次把常用项置顶**。 * * 能力目录由后端按「角色 + 已授权模块」过滤后下发,学生 / 教师 / 管理员拿到的集合本就不同, * 因此这里直接以目录为准,而不是任何前端写死的名单 —— 既不会出现"点了就被拒"的假入口, * 也不会漏掉后端新上线、前端还没配元数据的模块。 + * + * ★ 排序只在**目录到达时算一次**(`catalog` 变化才重算),不订阅画像变更: + * 侧栏是导航而非展示位,如果用户点完一个模块它就当场跳到第一位, + * "我刚点的那一行跑哪去了"会打断操作。下次进页面时它自然已经在前面。 */ const modules = computed(() => { const mods = props.catalog?.modules || {}; - return orderedModuleKeys(mods).map(key => { + const keyList = orderedModuleKeys(mods); + const scope = usageScope(props.catalog); + const keys = rankModuleKeys(scope, keyList); + const heat = usageSnapshot(scope).modules; + + return keys.map(key => { const meta = moduleMeta(key); + const used = heat[key]?.count ?? 0; return { key, icon: meta.icon, @@ -91,6 +103,9 @@ const modules = computed(() => { sample: meta.sample, label: MODULE_LABEL[key] || key, count: mods[key].length, + /** 用过 2 次以上算常用:左侧给一道色条,方便扫视时定位 */ + hot: used >= 2, + used, }; }); }); @@ -156,8 +171,12 @@ function pick(m: { key: string; sample: string }) { v-for="m in modules" :key="m.key" class="sb__item" - :class="[`tone-${m.tone}`, { 'sb__item--on': active === m.key }]" - :title="`对「${m.label}」说:${m.sample}`" + :class="[`tone-${m.tone}`, { 'sb__item--on': active === m.key, 'sb__item--hot': m.hot }]" + :title=" + m.hot + ? `你常办这个(用过 ${m.used} 次)|对「${m.label}」说:${m.sample}` + : `对「${m.label}」说:${m.sample}` + " @click="pick(m)" > {{ m.icon }} @@ -383,6 +402,15 @@ function pick(m: { key: string; sample: string }) { box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.12); } +/* 常用模块:左缘一道亮色条(深色侧栏上比文字标记更安静,也不会挤掉模块名) */ +.sb__item--hot { + box-shadow: inset 2px 0 0 var(--tone-light, #93c5fd); +} + +.sb__item--hot.sb__item--on { + box-shadow: inset 2px 0 0 var(--tone-light, #93c5fd), inset 0 0 0 1px rgba(255, 255, 255, 0.12); +} + .sb__item-icon { flex: none; width: 25px; diff --git a/src/components/QuickActions.vue b/src/components/QuickActions.vue index 94fb204..e41ea4b 100644 --- a/src/components/QuickActions.vue +++ b/src/components/QuickActions.vue @@ -1,21 +1,37 @@