From e00f0db72960a574eb18e15bb088b110443fe99b Mon Sep 17 00:00:00 2001 From: lijing Date: Tue, 13 Oct 2026 17:11:43 +0800 Subject: [PATCH] =?UTF-8?q?feat(welcome):=20=E9=A6=96=E5=B1=8F=E6=8C=89?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=E7=94=BB=E5=83=8F=E5=81=9A=E3=80=8C=E5=B8=B8?= =?UTF-8?q?=E7=94=A8=E4=BC=98=E5=85=88=E3=80=8D=EF=BC=8C=E8=80=81=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E5=9B=9E=E6=9D=A5=E5=85=88=E7=9C=8B=E8=A7=81=E8=87=AA?= =?UTF-8?q?=E5=B7=B1=E5=B8=B8=E5=8A=9E=E7=9A=84=E4=BA=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原来首屏对每个人都长一个样:网格顺序写死,指标卡全是「能力 91 项 / 可提交 39 项」 这类覆盖数字。用过一百次请假的人,请假入口照样排在第 4 位。 新增 src/utils/usage.ts(本机使用画像) - 用 localStorage 记「模块 / 能力的调用次数 + 最近时间」与「写操作成功件数」。 后端 GET /api/agent/tools 只下发"你能用什么",不提供"你常用什么", 所以第一步先落在本机:零后端改动、零数据出网;换设备重新累积。 - 归属按 userId + roleId 隔离(一个账号多重身份能力范围不同,混在一起会串推荐); 目录未回来时退回平台写入的 currentRole,取不到才退化匿名。 - 模块按「轮」计数(一轮查 3 个接口只 +1,符合用户心里"我问过几次"), 能力按「调用次数」计数;done 只统计 write && success —— 查询再多次也不算成果。 - rankFrequent / summarize 有意吃 state 而不是吃 scope:调用方手里那份快照带版本依赖, 记录或清除后能立刻重算,避免"点了清除,页面上常用区块还在"的幽灵状态。 首屏(QuickActions.vue)改成双形态 - 无使用记录 → 完全保持原有引导形态(自我介绍 + 覆盖面指标 + 各模块一句成品请求)。 - 有使用记录 → 个性化问候「欢迎回来,X」,正文直接列出「你最近常办:…」; 指标卡换成「本周已办成 / 常用模块 / 可操作能力」; 新增「常用」区块(用过最多的模块,点一下就发); 快速开始网格按频次重排,用过 2 次以上的卡片描边并打「常用 N 次」角标。 - 能力清单弹层叠加使用次数(模块级「你用过 N 次」+ 能力级次数), 底部提供「清除本机使用记录」(两段式确认),并说明数据不出本机。 顺带把「常用」延伸到导航与顶栏 - AppSidebar:模块按频次置顶 + 常用项左缘一道色条;排序只在进页面时算一次, 不在会话内实时跳动(否则"我刚点的那行跑哪去了"会打断操作)。 - App.vue:顶栏胶囊补「已帮你办成 N 件」——原来只回答"系统有多大", 现在同时回答"它为我做过多少事"。 埋点(Chat.vue) - 挂在每轮响应的 finally,只认回包里的 toolCalls(含浏览器代执行续跑后的最终结果), 不认"用户点了哪张卡":用户完全可以自己在输入框里打出「我要请假」, 只有后端的执行轨迹才是事实;失败时 traces 为空,自动不记。 铁律不变:所有入口仍由 /api/agent/tools 目录驱动,前端不自行判权。 「常用」只做排序不做增删;无权使用的能力即使局部画像里有记录,也不进清单。 验证:真机 46/46(.scratch/verify-usage-personalization.mjs,覆盖首次访问形态、 埋点计数口径、写操作才计件、常用前置与角标、侧栏顺序、清除记录、多账号隔离); 请假登记回归 17/17。 --- src/App.vue | 25 +- src/components/AppSidebar.vue | 38 ++- src/components/QuickActions.vue | 459 ++++++++++++++++++++++++++++---- src/utils/usage.ts | 337 +++++++++++++++++++++++ src/views/Chat.vue | 24 +- 5 files changed, 820 insertions(+), 63 deletions(-) create mode 100644 src/utils/usage.ts 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 @@