cc 2 weken geleden
bovenliggende
commit
7556a7f030

+ 87 - 0
.workbuddy-ai/memory/2026-09-18.md

@@ -814,3 +814,90 @@ exit code 0,但不建 loose ref、也不改 `packed-refs` 旧值,`git rev-pa
 **补充:入口页窄屏实测(登录卡)**。`@media (max-width: 1279px)` 会隐藏整个左侧品牌区,
 右侧卡片居中。实测 1024×700 / 900×700 / 768×700 / 600×800 / 390×844:
 均无纵向裁切、无横向溢出;390 下卡片收到 358 宽(左右各 16 边距),内容完整。
+
+## 入口页去掉顶部 header 栏(老爷要求「不需要」)
+
+文件:`src/plat/views/authorization/index.vue`。
+
+- 模板里删掉 `.case-header-bar`(含 `<AppLogo>` 与右侧空 div),同步删掉该 class 的整段样式
+  (内含 `.jeesite-app-logo` / `.jeesite-header-action-button`)与 `AppLogo` import。
+- 布局零副作用:`.authorization-page` 是 `flex-direction: column`,`.authorization-content { flex: 1 }`
+  直接吃满 100vh。全文**没有** `calc(100vh - 48px)` 之类的头部高度耦合。
+- 去掉 header 后左侧蓝色品牌区从 y=0 起、高 = 视口高,右侧卡片在内容区重新居中。
+- 实测(`header:false` 为准):授权卡 1600×950 → 597 高不裁切;登录卡 419;390×844 → 383。
+- `-webkit-app-region: drag` 随之消失,但**本仓没有装 Electron**(`package.json` 无 electron 依赖,
+  无 electron 主进程目录),是历史遗留,删掉无影响。
+- stylelint 112 → **111**(少了一条 rgba),零新增;eslint ✅;`vue-tsc` 89 = 基线。
+
+## ★ 顺带修掉一个真 bug:入口页 URL 上的 query 在冷启动时被冲掉
+
+**症状**:`open /authorization?forceAuth=1`、`?expired=1&message=...`、`?redirect=/case`
+冷启动全部变成裸 `/authorization`,query 消失。`?forceAuth=1` 因此显示不出授权卡(显示了登录卡)。
+
+**根因**:`main.ts#bootstrap()` 里 `await ensureLoggedIn()` 写在 `await router.isReady()` **之前**。
+此时 `router.currentRoute.value` 还是初始占位(path=`'/'`),于是:
+```js
+const source = current.fullPath && current.fullPath !== '/' ? current.fullPath : '';
+router.replace({ path: BASE_AUTHORIZATION, query: source ? { redirect: source } : undefined });
+```
+`current.fullPath === '/'` ⇒ `source === ''` ⇒ `query: undefined` ⇒ **replace 成裸 `/authorization`,query 被吞**。
+
+**修法**:把 `await router.isReady()` 提到 `ensureLoggedIn()` 之前(`resolveStartupRoute()` 之后),
+删掉原来后面那句。修完实测:`?foo=bar` 保留、`?forceAuth=1` 正确显示授权卡、
+`/case`(无 token)→ `/authorization?redirect=/case`(redirect 也保住了)。
+
+**注意**:`.right-pane__scroll` 有 `overflow-y: auto`,所以矮屏(≤720 高)授权卡 695 高于视口时
+**是可以滚动的**,不是"内容够不到"。别只看 `card.bottom > innerHeight` 就误判成 bug。
+
+## ★ 修 `PUT /chat/sessions/{id}/title` 参数对不上(400 缺少必填参数: title)
+
+**症状**:会话重命名失败。抓包是 `PUT /js/a/chat/sessions/{id}/title`,**URL 上没有 query**,
+`body: {"title":"xxx"}`;后端 `AgentChatController.updateSessionTitle(@PathVariable Long sessionId, @RequestParam String title)`
+读的是查询参数 ⇒ 400 `缺少必填参数: title`。
+
+**根因不在调用点,在 defHttp**:`core/utils/http/axios/index.ts` 的 `beforeRequestHook` 非 GET 分支有条兜底 ——
+```js
+if (Reflect.has(config, 'data') && config.data && Object.keys(config.data).length > 0) {
+  config.data = data; config.params = params;
+} else {
+  // 非GET请求如果没有提供data,则将params视为data
+  config.data = params; config.params = undefined;
+}
+```
+`putRaw(url, undefined, { title })` 里 `data` 是 undefined ⇒ 走 else ⇒ **params 被搬进请求体、query 被清空**。
+所以 `aiHttp.putRaw` 的第三个参数 `params` 对非 GET 请求**一直是失效的**(静默,不报错)。
+
+**修法**(前端单侧,后端零改动):`src/ai/api/http.ts` 新增 `withQuery()`,
+`putRaw` 带 params 时自己拼进 URL,不再把 params 交给 defHttp:
+```ts
+const target = params && Object.keys(params).length > 0 ? withQuery(withPrefix(url), params) : withPrefix(url);
+return defHttp.put<T>({ url: target, data, headers: jsonHeaders() }, RAW);
+```
+**实测**:请求变成 `PUT .../title?title=%E8%B5%84...`,body `{}`,HTTP 200,回查标题已改。
+顺带回归了同文件另外两个无参 PUT(`/pin`、`/messages/{id}/star`),不受影响。
+
+**排查套路(可复用)**:patch `XMLHttpRequest.prototype.open/send` 记录真实 URL + body,
+再用 pinia 实例直接调 store action,比看截图/猜强得多:
+```js
+const s = document.querySelector('#app').__vue_app__.config.globalProperties.$pinia._s.get('ai-chat-stream');
+await s.renameSession('<sessionId>', '新标题');
+```
+
+## 会话重命名改成弹窗(老爷要求)
+
+`src/ai/components/SessionList.vue`:删掉 `SessionRow` 的行内编辑(`editing` prop / draft / 内联 Input /
+`start-rename`·`commit-rename`·`cancel-rename` 三个事件),改为 `<Modal>` 弹窗;`SessionRow` 只 emit `rename` 派发意图。
+沿用项目既有写法(`user-dropdown` 的 `<Modal v-model:open :confirm-loading :width="420" @ok>`)。
+- 弹窗内 `<a-input>` 预填当前标题,`maxlength=128`(DB 列 `VARCHAR(128)`,见 `sql/insight_agent_tables.sql`)。
+- 自带字数提示 `.ai-rename__count`(**ant-design-vue 4.2.6 的 `show-count` 只在 TextArea 上实现**,
+  单行 Input 传了也不渲染计数)。
+- 打开后自动 focus + select:**4.2.6 的 Modal 没有 `afterOpenChange` 事件**(只有 `afterClose`),
+  写 `@after-open-change` 不触发;改用 `watch(renameVisible) + nextTick`。
+- 空标题时「保存」禁用(`:ok-button-props="{ disabled: !canRename }"`),Enter 提交。
+- 弹窗被 teleport 到 body,scoped 样式命不中 ⇒ 计数样式放在全局 `<style>` 块。
+- 顺手删掉改动前就已存在的死代码 `const AInput = Input; const AButton = Button;`(模板用的是全局注册的 `a-input`/`a-button`)。
+
+**实测(走真实 UI 路径)**:开案 → `/aiAnalysis/index` → 点行内铅笔 → 弹窗标题「重命名会话」、
+预填「新对话」、计数 `3 / 128`、**已聚焦且全选 `0-3`**、无 `.is-editing` 元素;
+清空 → 保存 `disabled=true`;输入 12 字 → 保存可用;点保存 → 弹窗关闭、列表标题即时更新。
+eslint ✅、stylelint ✅、`vue-tsc` 89 = 基线。测试改的标题/置顶都已还原。

+ 25 - 1
ai-frontend/src/ai/api/http.ts

@@ -26,6 +26,23 @@ function jsonHeaders(): Record<string, string> {
   return { 'content-type': ContentTypeEnum.JSON };
 }
 
+/**
+ * 把查询参数拼进 URL。
+ *
+ * <p>为什么不用 defHttp 的 `params`:它的 `beforeRequestHook` 对**非 GET** 请求有条兜底 ——
+ * 「没有提供 data 就把 params 视为 data」,会把 params 搬进**请求体**并清空 params
+ * (见 `core/utils/http/axios/index.ts`)。结果是后端 `@RequestParam` 永远收不到,
+ * 典型症状:`PUT /chat/sessions/{id}/title` 返回 400「缺少必填参数: title」,
+ * 而抓包看到的是 `body: {"title":"..."}`、URL 上没有任何 query。
+ */
+function withQuery(url: string, params: Record<string, any>): string {
+  const qs = Object.keys(params)
+    .map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(params[key] ?? '')}`)
+    .join('&');
+  if (!qs) return url;
+  return `${url}${url.includes('?') ? '&' : '?'}${qs}`;
+}
+
 export const aiHttp = {
   /* ---------------- 裸响应(/chat/**) ---------------- */
 
@@ -37,8 +54,15 @@ export const aiHttp = {
     return defHttp.postJson<T>({ url: withPrefix(url), data }, RAW);
   },
 
+  /**
+   * PUT(裸响应)。
+   *
+   * <p>`data` → 请求体,`params` → **URL 查询参数**(后端 `@RequestParam` 收)。
+   * ⚠️ `params` 不能直接交给 defHttp,会被搬进请求体,详见 {@link withQuery}。
+   */
   putRaw<T>(url: string, data?: Record<string, any>, params?: Record<string, any>): Promise<T> {
-    return defHttp.put<T>({ url: withPrefix(url), data, params, headers: jsonHeaders() }, RAW);
+    const target = params && Object.keys(params).length > 0 ? withQuery(withPrefix(url), params) : withPrefix(url);
+    return defHttp.put<T>({ url: target, data, headers: jsonHeaders() }, RAW);
   },
 
   deleteRaw<T>(url: string, params?: Record<string, any>): Promise<T> {

+ 82 - 51
ai-frontend/src/ai/components/SessionList.vue

@@ -23,11 +23,8 @@
             :key="item.id"
             :session="item"
             :active="item.id === activeId"
-            :editing="editingId === item.id"
             @select="emit('select', item.id)"
-            @start-rename="startRename(item.id, item.title)"
-            @commit-rename="(t) => commitRename(item.id, t)"
-            @cancel-rename="cancelRename"
+            @rename="startRename(item.id, item.title)"
             @pin="emit('pin', item.id)"
             @remove="emit('remove', item.id)"
           />
@@ -40,11 +37,8 @@
             :key="item.id"
             :session="item"
             :active="item.id === activeId"
-            :editing="editingId === item.id"
             @select="emit('select', item.id)"
-            @start-rename="startRename(item.id, item.title)"
-            @commit-rename="(t) => commitRename(item.id, t)"
-            @cancel-rename="cancelRename"
+            @rename="startRename(item.id, item.title)"
             @pin="emit('pin', item.id)"
             @remove="emit('remove', item.id)"
           />
@@ -55,6 +49,28 @@
         </div>
       </template>
     </div>
+
+    <!-- 重命名:统一走弹窗,不再做行内编辑(行内输入在窄侧栏里不好点、也容易误触) -->
+    <Modal
+      v-model:open="renameVisible"
+      title="重命名会话"
+      :width="420"
+      ok-text="保存"
+      cancel-text="取消"
+      :ok-button-props="{ disabled: !canRename }"
+      @ok="handleRenameOk"
+      @cancel="handleRenameCancel"
+    >
+      <a-input
+        ref="renameInputRef"
+        v-model:value="renameDraft"
+        :maxlength="TITLE_MAX_LENGTH"
+        allow-clear
+        placeholder="请输入会话标题"
+        @press-enter="handleRenameOk"
+      />
+      <div class="ai-rename__count">{{ renameDraft.length }} / {{ TITLE_MAX_LENGTH }}</div>
+    </Modal>
   </aside>
 </template>
 
@@ -65,62 +81,35 @@
    * 搜索在前端做(会话量级为个人级,无需服务端分页),
    * 置顶会话单独成组,其余按最近消息时间排序。
    */
-  import { computed, defineComponent, h, ref, watch } from 'vue';
-  import { Button, Input, Modal } from 'ant-design-vue';
+  import { computed, defineComponent, h, nextTick, ref, watch } from 'vue';
+  import { Modal } from 'ant-design-vue';
   import Icon from '@/core/components/Icon/src/Icon.vue';
   import type { ChatSession } from '../api/types';
   import { fromNow, sessionTitle as resolveTitle } from '../utils/parseMessageContent';
 
-  const AInput = Input;
-  const AButton = Button;
-
-  /** 单行会话(内联重命名),拆成局部组件避免列表模板过长 */
+  /** 单行会话;重命名由父级弹窗处理,这里只负责派发意图 */
   const SessionRow = defineComponent({
     name: 'AiSessionRow',
     props: {
       session: { type: Object as () => ChatSession, required: true },
       active: { type: Boolean, default: false },
-      editing: { type: Boolean, default: false },
     },
-    emits: ['select', 'start-rename', 'commit-rename', 'cancel-rename', 'pin', 'remove'],
+    emits: ['select', 'rename', 'pin', 'remove'],
     setup(props, { emit }) {
-      const draft = ref('');
-      watch(
-        () => props.editing,
-        (v) => {
-          if (v) draft.value = props.session.title || '';
-        },
-        { immediate: true },
-      );
-
       return () => {
         const s = props.session;
         const title = resolveTitle(s);
         const meta = fromNow(s.lastMessageAt || s.updateAt || s.createAt);
 
-        const titleNode = props.editing
-          ? h(Input, {
-              value: draft.value,
-              size: 'small',
-              autofocus: true,
-              'onUpdate:value': (v: string) => (draft.value = v),
-              onClick: (e: Event) => e.stopPropagation(),
-              onPressEnter: () => emit('commit-rename', draft.value.trim() || title),
-              onKeydown: (e: KeyboardEvent) => {
-                if (e.key === 'Escape') emit('cancel-rename');
-              },
-            })
-          : h('span', { class: 'ai-session__title', title }, title);
-
         return h(
           'div',
           {
-            class: ['ai-session', { 'is-active': props.active, 'is-editing': props.editing }],
-            onClick: () => !props.editing && emit('select'),
+            class: ['ai-session', { 'is-active': props.active }],
+            onClick: () => emit('select'),
           },
           [
             h('div', { class: 'ai-session__main' }, [
-              titleNode,
+              h('span', { class: 'ai-session__title', title }, title),
               h('div', { class: 'ai-session__meta' }, [
                 s.messageCount ? `${s.messageCount} 条` : '',
                 s.messageCount && meta ? ' · ' : '',
@@ -145,7 +134,7 @@
                 title: '重命名',
                 onClick: (e: Event) => {
                   e.stopPropagation();
-                  emit('start-rename');
+                  emit('rename');
                 },
               }),
               h(Icon, {
@@ -192,13 +181,35 @@
   }>();
 
   const localKeyword = ref(props.keyword);
-  const editingId = ref<string | null>(null);
+
+  /* ---------------- 重命名弹窗 ---------------- */
+  /** 会话标题列是 VARCHAR(128),前端按同长度硬限制 */
+  const TITLE_MAX_LENGTH = 128;
+
+  const renameVisible = ref(false);
+  const renameTargetId = ref<string | null>(null);
+  const renameDraft = ref('');
+  const renameInputRef = ref<{ focus?: () => void; select?: () => void } | null>(null);
+  const canRename = computed(() => renameDraft.value.trim().length > 0);
 
   watch(
     () => props.keyword,
     (v) => (localKeyword.value = v),
   );
 
+  /**
+   * 弹窗打开后自动聚焦并全选,方便直接覆盖输入。
+   *
+   * <p>ant-design-vue 4.2.6 的 Modal **没有** `afterOpenChange` 事件(只有 `afterClose`),
+   * 写 `@after-open-change` 不会触发,所以这里自己用 watch + nextTick 控。
+   */
+  watch(renameVisible, async (open) => {
+    if (!open) return;
+    await nextTick();
+    renameInputRef.value?.focus?.();
+    renameInputRef.value?.select?.();
+  });
+
   function match(s: ChatSession) {
     const kw = localKeyword.value.trim().toLowerCase();
     if (!kw) return true;
@@ -208,17 +219,24 @@
   const filteredPinned = computed(() => props.sessions.filter((s) => s.pinned).filter(match));
   const filteredNormal = computed(() => props.sessions.filter((s) => !s.pinned).filter(match));
 
-  function startRename(id: string, _title?: string) {
-    editingId.value = id;
+  function startRename(id: string, title?: string) {
+    renameTargetId.value = id;
+    renameDraft.value = title || '';
+    renameVisible.value = true;
   }
 
-  function commitRename(id: string, title: string) {
-    editingId.value = null;
-    if (title) emit('rename', id, title);
+  function handleRenameOk() {
+    const title = renameDraft.value.trim();
+    const id = renameTargetId.value;
+    if (!title || !id) return;
+    renameVisible.value = false;
+    renameTargetId.value = null;
+    emit('rename', id, title);
   }
 
-  function cancelRename() {
-    editingId.value = null;
+  function handleRenameCancel() {
+    renameVisible.value = false;
+    renameTargetId.value = null;
   }
 </script>
 
@@ -346,4 +364,17 @@
       }
     }
   }
+
+  /*
+   * 重命名弹窗的字数提示。
+   * Modal 会被 teleport 到 body,scoped 样式命不中,所以放在全局块里。
+   * 注:ant-design-vue 4.2.6 的 show-count 只在 TextArea 上实现,单行 Input 不渲染计数,故自行实现。
+   */
+  .ai-rename__count {
+    margin-top: 6px;
+    font-size: 12px;
+    line-height: 1.4;
+    color: var(--ai-text-2);
+    text-align: right;
+  }
 </style>

+ 7 - 3
ai-frontend/src/main.ts

@@ -228,11 +228,15 @@ async function bootstrap() {
 
   await resolveStartupRoute();
 
-  // 授权通过后再校验登录态:无 token 直接进登录页
-  await ensureLoggedIn();
-
+  // ★ 必须先等首次导航落定,再判登录态。
+  // 否则此时 router.currentRoute 还是初始占位(path='/'),ensureLoggedIn 里的
+  // replace 会按「来源不可用」走到 `query: undefined`,把 URL 上的
+  // redirect / forceAuth / expired / message 全部冲掉 —— 入口页自己的 query 契约就失效了。
   await router.isReady();
 
+  // 授权通过后再校验登录态:无 token 直接进授权/登录入口页
+  await ensureLoggedIn();
+
   // Register global directive
   setupGlobDirectives(app);
 

+ 10 - 10
ai-server/src/main/java/com/zsjz/ai/common/exception/GlobalExceptionHandler.java

@@ -115,14 +115,14 @@ public class GlobalExceptionHandler {
         String message = e.getFieldErrors().stream()
                 .map(fe -> fe.getField() + ": " + fe.getDefaultMessage())
                 .findFirst().orElse("参数绑定失败");
-        log.info("参数绑定失败: {}", message);
+        log.error("参数绑定失败: {}", message);
         return ResponseEntity.status(HttpStatus.BAD_REQUEST)
                 .body(Result.failure(400, message));
     }
 
     @ExceptionHandler(ConstraintViolationException.class)
     public ResponseEntity<Result<Void>> handleConstraint(ConstraintViolationException e) {
-        log.info("约束校验失败: {}", e.getMessage());
+        log.error("约束校验失败: {}", e.getMessage());
         return ResponseEntity.status(HttpStatus.BAD_REQUEST)
                 .body(Result.failure(400, e.getMessage()));
     }
@@ -131,21 +131,21 @@ public class GlobalExceptionHandler {
     @ExceptionHandler(HttpMessageNotReadableException.class)
     public ResponseEntity<Result<Void>> handleNotReadable(
             org.springframework.http.converter.HttpMessageNotReadableException e) {
-        log.info("请求体解析失败: {}", e.getMessage());
+        log.error("请求体解析失败: {}", e.getMessage());
         return ResponseEntity.status(HttpStatus.BAD_REQUEST)
                 .body(Result.failure(400, "请求体格式错误"));
     }
 
     @ExceptionHandler(NoHandlerFoundException.class)
     public ResponseEntity<Result<Void>> handleNoHandler(NoHandlerFoundException e) {
-        log.info("接口不存在: {} {}", e.getHttpMethod(), e.getRequestURL());
+        log.error("接口不存在: {} {}", e.getHttpMethod(), e.getRequestURL());
         return ResponseEntity.status(HttpStatus.NOT_FOUND)
                 .body(Result.failure(404, "接口不存在"));
     }
 
     @ExceptionHandler(NoResourceFoundException.class)
     public ResponseEntity<Result<Void>> handleNoResource(NoResourceFoundException e) {
-        log.info("资源不存在: {}", e.getMessage());
+        log.error("资源不存在: {}", e.getMessage());
         return ResponseEntity.status(HttpStatus.NOT_FOUND)
                 .body(Result.failure(404, "资源不存在"));
     }
@@ -153,7 +153,7 @@ public class GlobalExceptionHandler {
     @ExceptionHandler(HttpRequestMethodNotSupportedException.class)
     public ResponseEntity<Result<Void>> handleMethodNotAllowed(
             HttpRequestMethodNotSupportedException e) {
-        log.info("请求方法不支持: method={}", e.getMethod());
+        log.error("请求方法不支持: method={}", e.getMethod());
         return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED)
                 .body(Result.failure(405, "请求方法不支持"));
     }
@@ -161,7 +161,7 @@ public class GlobalExceptionHandler {
     @ExceptionHandler(HttpMediaTypeNotSupportedException.class)
     public ResponseEntity<Result<Void>> handleMediaTypeNotSupported(
             HttpMediaTypeNotSupportedException e) {
-        log.info("不支持的媒体类型: {}", e.getContentType());
+        log.error("不支持的媒体类型: {}", e.getContentType());
         return ResponseEntity.status(HttpStatus.UNSUPPORTED_MEDIA_TYPE)
                 .body(Result.failure(415, "不支持的Content-Type"));
     }
@@ -175,7 +175,7 @@ public class GlobalExceptionHandler {
     @ExceptionHandler(MissingServletRequestParameterException.class)
     public ResponseEntity<Result<Void>> handleMissingParam(MissingServletRequestParameterException e) {
         // 缺少必填查询参数属客户端错误,返回 400(此前落到兜底 Exception → 500)
-        log.info("缺少必填参数: name={}", e.getParameterName());
+        log.error("缺少必填参数: name={}", e.getParameterName());
         return ResponseEntity.status(HttpStatus.BAD_REQUEST)
                 .body(Result.failure(400, "缺少必填参数: " + e.getParameterName()));
     }
@@ -185,11 +185,11 @@ public class GlobalExceptionHandler {
         // 路径变量转换失败视为资源不存在(如超长/非法 ID 无法匹配任何资源),复用前端"资源不存在"识别逻辑
         boolean pathVariable = e.getParameter().hasParameterAnnotation(PathVariable.class);
         if (pathVariable) {
-            log.info("路径参数无效: name={}, value={}", e.getName(), e.getValue());
+            log.error("路径参数无效: name={}, value={}", e.getName(), e.getValue());
             return ResponseEntity.status(HttpStatus.NOT_FOUND)
                     .body(Result.failure(404, "资源不存在"));
         }
-        log.info("请求参数格式错误: name={}, value={}", e.getName(), e.getValue());
+        log.error("请求参数格式错误: name={}, value={}", e.getName(), e.getValue());
         return ResponseEntity.status(HttpStatus.BAD_REQUEST)
                 .body(Result.failure(400, "请求参数格式错误"));
     }