Преглед изворни кода

fix(ventAI): 浏览器验收发现并修复 is_admin 仅 watch 拉取导致管理员也看不到入口 + 注释补全

【缺陷(验收实测)】M3 原实现只在 visible 的 watch 里调 fetchMe(),
组件若以 visible=true 直接挂载(本组件在 default 布局与 homeAI 首页由页面持有
visible,存在「挂载即显示」的用法),watch 不触发 → getMe 从未被调用 →
isAdmin 恒为 false → 管理员同样看不到「技能/子智能体/思考级别」入口。
用 Playwright 实测:拦截 /api/me 计数为 0,管理员可见性 skill=0/subAgents=0/think=0。

【修复】增加 onMounted(() => fetchMe()),挂载即拉取一次;watch 内保留原有调用
(用于「每次打开都刷新」,管理员名单可能在服务端 .env 变更)。

【同时】429 文案去掉分钟数后的多余空格(「约15 分钟」→「约15分钟」);
为便于人工审核,在全部改动处补齐「为什么改 / 与原来的差别」注释。

【验收】Playwright 真实浏览器(mock 登录取真令牌):
- M1 3/3:点选请求头含 X-Access-Token 且与登录态令牌一致
- M2 5/5:429/413/404 均显示中文可读文案,不再出现「HTTP错误: xxx」
- M3 9/9:非管理员 0/0/0,管理员 1/1/1,/api/me 未上线(404) 时保守降级隐藏
ESLint:改动前 8 个 ChatInputArea 既有未使用变量报错,改后同样 8 个,无新增。
lizuo пре 5 дана
родитељ
комит
29d9be9eed

+ 14 - 0
src/views/ventAI/dataPicker/api.ts

@@ -1,5 +1,7 @@
 import type { SSERawEvent } from './types';
+// 【改动 M1】取基座登录态的令牌(与对话链路 manageAssistent/api.ts 同源)
 import { getToken } from '/@/utils/auth';
+// 【改动 M2】复用对话框模块既有的结构化错误解析器,避免两套实现各自演化
 import { throwStreamHttpError } from '/@/views/ventAI/manageAssistent/api';
 
 enum Api {
@@ -43,12 +45,24 @@ async function ssePost(
     method: 'POST',
     headers: {
       'Content-Type': 'application/json',
+      // 【改动 M1 · 对应 TfAgents 决策 D5 / B1-20】
+      // 点选解读原先不带令牌,是基座里唯一一条「匿名」调 TfAgents 的路径,
+      // 后果:消耗只能记到兜底账号 click-anonymous,无法按登录人记账。
+      // 现在补上与对话链路同名的请求头,TfAgents 侧读的是小写 x-access-token(HTTP 头不区分大小写)。
+      // ⚠️ 令牌是「可选」语义:getToken() 为空时仍照常发请求(TfAgents 侧有 click-anonymous 兜底),
+      //    因此这里不能加「未登录就拦截/跳登录」的判断——那会把未登录场景直接变成不可用。
       'X-Access-Token': getToken(),
     },
     body: JSON.stringify(body),
   });
 
   if (!response.ok) {
+    // 【改动 M2 · 对应 TfAgents 决策 D6 / B1-15 / B1-19 / B1-08】
+    // 原先:throw new Error(`HTTP错误: ${response.status}`) —— 用户只看到「HTTP错误: 429」。
+    // 现在:复用 manageAssistent/api.ts 的 throwStreamHttpError,它会解析响应体的
+    //       detail(字符串或 { code, message, retry_after, limit_tokens, spent_tokens }),
+    //       抛出携带 status/retryAfter/limitTokens/spentTokens 的 StreamError;
+    //       最终文案由 useDataPicker.ts 的 formatClickError() 统一渲染。
     await throwStreamHttpError(response);
   }
 

+ 22 - 2
src/views/ventAI/dataPicker/useDataPicker.ts

@@ -339,19 +339,39 @@ export function useDataPicker(
     }
   }
 
-  /** 点击解读失败时,把结构化 StreamError 转成可读文案 */
+  /**
+   * 点击解读失败时,把「结构化错误」转成用户能看懂的中文文案。
+   *
+   * 背景(对应改动清单 M2 / TfAgents 决策 D6、B1-15、B1-19):
+   * TfAgents 现在对失败请求返回结构化错误体,形如
+   *   HTTP 429 → { "detail": { "code": "...", "message": "今日额度已用完…", "retry_after": 900 } }
+   *   HTTP 413 → { "detail": { "code": "...", "message": "…" } }
+   *   HTTP 404 → { "detail": { "code": "...", "message": "…" } }
+   * 而点选链路原先直接抛 `HTTP错误: 429`,用户看不懂。
+   *
+   * 原先的差别:本文件原来只有一行 `error.value = e?.message || '请求失败'`,
+   * 429/413/404 只能显示 `HTTP错误: xxx`。现在复用 manageAssistent/api.ts 既有的
+   * `throwStreamHttpError`(不新写一套解析器)+ 本地的兜底文案,得到可读提示。
+   *
+   * 保守策略:只对「确认是 StreamError」的情况改写文案;普通 Error 一律走 fallback,
+   * 保证行为不回归(例如网络中断、JSON 解析失败等仍显示原始 message)。
+   */
   function formatClickError(e: any): string {
     const fallback = e?.message || '请求失败';
+    // 429:额度用完 / 并发闸 / 每用户任务上限,服务端会给 retry_after(秒)
     if (e instanceof StreamError && e.status === 429) {
       if (typeof e.retryAfter === 'number') {
         const minutes = Math.ceil(e.retryAfter / 60);
-        return `${fallback}(约${minutes < 1 ? '不到 1 分钟' : `${minutes} 分钟`}后恢复)`;
+        // 不足 1 分钟时不显示「0 分钟」
+        return `${fallback}(约${minutes < 1 ? '不到 1 分钟' : `${minutes}分钟`}后恢复)`;
       }
       return `${fallback}(请稍后再试)`;
     }
+    // 413:上传体积超限;仅当服务端没给出可读文案时才用本地兜底
     if (e instanceof StreamError && e.status === 413 && (!e.message || e.message === 'HTTP错误: 413')) {
       return '附件体积超过上限,请减小文件后重试';
     }
+    // 404:会话不存在或无权访问(TfAgents 的 B1-08 会话归属校验)
     if (e instanceof StreamError && e.status === 404 && (!e.message || e.message === 'HTTP错误: 404')) {
       return '会话不存在或无权访问';
     }

+ 22 - 3
src/views/ventAI/manageAssistent/api.ts

@@ -67,6 +67,9 @@ export class StreamError extends Error {
   }
 }
 
+// 【改动 M2】由「模块内私有 const」改为「导出」:点选链路(dataPicker/api.ts)要复用同一套
+// 结构化错误解析,避免在点选侧再复制一份 detail 解析逻辑(两份实现会各自漂移)。
+// 函数体本身零改动。
 export const throwStreamHttpError = async (response: Response): Promise<never> => {
   let detail: ApiErrorDetail | undefined;
   let message = `HTTP错误: ${response.status}`;
@@ -91,15 +94,31 @@ export const throwStreamHttpError = async (response: Response): Promise<never> =
  */
 export const getTodayUsage = () => defHttp.get({ url: Api.usageToday }, { isTransformResponse: false });
 
+/**
+ * 【改动 M3 · 对应 TfAgents 决策 D1 / D9 与任务 B2-01】
+ * 当前登录用户的权限信息。TfAgents 把「技能上传/启停、子智能体增删改、思考级别切换」等
+ * 全局变更端点收归管理员后,基座据此隐藏入口(用户已定案「方案 B:由后端返回 is_admin,前端隐藏」)。
+ *
+ * 后端契约(TfAgents 侧):
+ *   GET /api/me(需认证,带 X-Access-Token)
+ *   → 200 { "username": "...", "is_admin": true|false, "admin_configured": true|false }
+ *   → 401 未登录 / 令牌无效
+ * 说明:admin_configured 表示服务端管理员白名单是否已配置(false 时可提示「管理员未配置」);
+ *       接口不返回白名单内容。
+ *
+ * 两点实现说明:
+ * 1) 这里不手写请求头:走 defHttp 时,axios 请求拦截器(src/utils/http/axios/index.ts:209-216)
+ *    会自动把令牌塞进 Authorization 与 X-Access-Token,与其它接口保持一致。
+ * 2) errorMessageMode: 'none' 是刻意加的:该接口在 TfAgents 尚未上线时返回 404,
+ *    若用默认的错误提示模式,每次打开 AI 弹框都会弹一个红色报错(误判为故障)。
+ *    这里静默失败,由调用方(AiAssistantModal.fetchMe)按「非管理员」保守降级。
+ */
 export interface MeInfo {
   username: string;
   is_admin: boolean;
   admin_configured: boolean;
 }
 
-/**
- * 当前登录用户信息(is_admin:是否管理员,用于前端隐藏全局管理入口)
- */
 export const getMe = () => defHttp.get<MeInfo>({ url: Api.me }, { isTransformResponse: false, errorMessageMode: 'none' });
 
 /**

+ 29 - 4
src/views/ventAI/manageAssistent/components/AiAssistantModal.vue

@@ -35,6 +35,9 @@
           @toggle-sub-agents="toggleSubAgentPanel"
           @toggle-schedules="toggleSchedulePanel"
         />
+        <!-- 【改动 M3】isAdmin 透传给左侧任务面板:由面板内部过滤掉「技能」「子智能体」两个管理入口
+             (这两个入口的按钮实际渲染在 TaskListPanel 的 optionItems 里,不是本文件第 33/34 行的监听上)。
+             注意「定时任务」不属于全局变更,保持对所有人开放。 -->
       </div>
 
       <!-- 右侧内容区:根据 activePanel 切换显示 -->
@@ -76,6 +79,8 @@
           <TodoListBar v-if="showTodoBar" :todos="currentTodos" />
 
           <!-- 输入区域 -->
+          <!-- 【改动 M3】:isAdmin 透传给输入区,由输入区隐藏「思考级别」切换控件;
+               注意只隐藏"切换",思考级别的读取(getModelMsg)对所有人保持开放 -->
           <ChatInputArea
             ref="chatInputRef"
             v-model="inputMessage"
@@ -150,7 +155,7 @@
 </template>
 
 <script setup lang="ts">
-  import { ref, nextTick, watch, computed, onBeforeUnmount } from 'vue';
+  import { ref, nextTick, watch, computed, onBeforeUnmount, onMounted } from 'vue';
   import dayjs from 'dayjs';
   import {
     unifiedStream,
@@ -310,9 +315,12 @@
   const contextMax = ref(1000000);
   const contextPercent = ref(0);
   const thinkLevel = ref('off');
-  // 是否具备“全局变更”管理权限(TfAgents 的 is_admin);取不到一律按非管理员处理(保守)
+  // 【改动 M3】是否具备「全局变更」管理权限(来自 TfAgents 的 is_admin)。
+  // 初值刻意取 false:在接口返回之前先按非管理员渲染,避免管理入口「闪现」给非管理员。
+  // 取不到(未登录 / 接口未上线 / 令牌失效)也一律停留在 false —— 保守失败,宁可不显示。
   const isAdmin = ref(false);
-  // 管理员白名单是否已在服务端配置;false 时可提示管理员未配置
+  // 服务端管理员白名单是否已配置(TfAgents 的 admin_configured)。
+  // 预留:false 时可在面板处提示「管理员未配置,管理功能暂不可用」,避免运维误判为故障。
   const adminConfigured = ref(true);
   const editMode = ref('full');
   const contextBreakdown = ref({ messages: 0, mcp: 0, skills: 0, system_prompt: 0, other: 0 });
@@ -1349,6 +1357,16 @@
     }
   };
 
+  /**
+   * 拉取当前登录用户的权限信息(TfAgents 的 GET /ventAI/api/me)。
+   *
+   * 为什么需要:TfAgents 已把「技能上传/启停、子智能体增删改、思考级别切换」等全局变更端点收归管理员
+   * (见 docs/基座(mky-vent-base)配合改动清单.md 的 M3,用户已定案方案 B)。基座据此隐藏对应入口,
+   * 避免非管理员点了才被 403。
+   *
+   * 安全语义(保守失败):任何异常(未登录 401 / 接口未上线 404 / 网络失败)一律按「非管理员」处理,
+   * 隐藏管理入口。注意这只是界面可见性,真正的拦截仍在 TfAgents 后端——前端隐藏不等于安全。
+   */
   const fetchMe = async () => {
     try {
       const me = await getMe();
@@ -1362,6 +1380,13 @@
     }
   };
 
+  // 必须挂载即拉取一次:本组件有两种用法——① 由页面在打开时才置 visible=true(watch 能捕获变化);
+  // ② 直接以 visible=true 挂载(如 aix 首页/嵌入式场景),此时 watch 不会触发,若不在此处拉取,
+  // isAdmin 将永远停留在 false,管理员也会看不到管理入口(浏览器验收实测确认过该缺陷)。
+  onMounted(() => {
+    fetchMe();
+  });
+
   const fetchSessionMode = async (sessionId: string) => {
     try {
       const res = await getSessionMode(sessionId);
@@ -1931,7 +1956,7 @@
       } else {
         await fetchSessionList();
         fetchModelMsg();
-        fetchMe();
+        fetchMe(); // 【改动 M3】每次打开时刷新权限(管理员名单可能在服务端 .env 变更)
         fetchScheduleListForDetail();
         fetchTodayUsage();
 

+ 4 - 0
src/views/ventAI/manageAssistent/components/chatModal/ChatInputArea.vue

@@ -237,6 +237,9 @@
           </div>
         </div> -->
         <div class="action-divider"></div>
+        <!-- 【改动 M3】思考级别切换控件对非管理员隐藏:TfAgents 已把「思考级别切换」列为全局变更端点
+             (见改动清单 M3)。这里只包住 .btn-think-wrapper 本身,左右的分隔线与发送按钮保持原样,
+             以免改动按钮区布局。思考级别的"读取"(getModelMsg)不受影响,对所有人依旧可用。 -->
         <div v-if="isAdmin" class="btn-think-wrapper" ref="thinkPopupRef">
           <a-tooltip title="思考级别">
             <div class="btn-think" @click="toggleThinkPopup">
@@ -357,6 +360,7 @@
       initialEditMode?: string;
       taskList?: Task[];
       currentTaskId?: string;
+      // 【改动 M3】是否管理员(由 AiAssistantModal 透传);可选 + 默认 false = 值缺失按非管理员处理
       isAdmin?: boolean;
     }>(),
     {

+ 7 - 0
src/views/ventAI/manageAssistent/components/chatModal/TaskListPanel.vue

@@ -60,6 +60,8 @@
   const props = defineProps<{
     taskList: Task[];
     currentTaskId: string;
+    // 【改动 M3】是否管理员(由 AiAssistantModal 透传 TfAgents 的 is_admin)。
+    // 为可选属性并默认 false:既兼容其它潜在调用方,也保证「值缺失 = 按非管理员处理」的保守语义。
     isAdmin?: boolean;
   }>();
 
@@ -100,6 +102,11 @@
     emit('pin-task', task);
   };
 
+  // 【改动 M3】顶部操作区的入口列表。
+  // 原先:普通 const 数组,恒含「技能」「子智能体」,任何角色都能点进管理面板。
+  // 现在:改为 computed 并按 isAdmin 过滤 —— 非管理员看不到这两个「全局变更」入口,
+  //       「新建任务 / 定时任务」对所有角色保持开放(定时任务属于个人功能,不在 TfAgents 的收敛范围内)。
+  // 用 computed(而非普通数组)是为了让 isAdmin 异步返回后能自动重算,无需刷新。
   const optionItems = computed(() => {
     const items = [
       { type: 'add' as OptionType, label: '新建任务' },