Bläddra i källkod

💊 按要求佐证资料改为与选项绑定

快乐的梦鱼 1 månad sedan
förälder
incheckning
0424b1c9df

+ 22 - 0
package-lock.json

@@ -29,6 +29,7 @@
         "ant-design-vue": "^4.2.6",
         "async-validator": "^4.2.5",
         "crypto-js": "^4.2.0",
+        "openai": "^6.38.0",
         "parse5": "^8.0.0",
         "pinia": "^3.0.1",
         "tslib": "^2.8.1",
@@ -16514,6 +16515,27 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
+    "node_modules/openai": {
+      "version": "6.38.0",
+      "resolved": "https://registry.npmmirror.com/openai/-/openai-6.38.0.tgz",
+      "integrity": "sha512-AoMplt2UalrpgUDMh3L09QWjNRlgJPipclQvA6sYAaeF6nHNBMgmikAZGmcYLn8on4d9sQY9Q8bOLfrBS7Lc8g==",
+      "license": "Apache-2.0",
+      "bin": {
+        "openai": "bin/cli"
+      },
+      "peerDependencies": {
+        "ws": "^8.18.0",
+        "zod": "^3.25 || ^4.0"
+      },
+      "peerDependenciesMeta": {
+        "ws": {
+          "optional": true
+        },
+        "zod": {
+          "optional": true
+        }
+      }
+    },
     "node_modules/os-locale-s-fix": {
       "version": "1.0.8-fix-1",
       "resolved": "https://registry.npmmirror.com/os-locale-s-fix/-/os-locale-s-fix-1.0.8-fix-1.tgz",

+ 1 - 0
package.json

@@ -57,6 +57,7 @@
     "ant-design-vue": "^4.2.6",
     "async-validator": "^4.2.5",
     "crypto-js": "^4.2.0",
+    "openai": "^6.38.0",
     "parse5": "^8.0.0",
     "pinia": "^3.0.1",
     "tslib": "^2.8.1",

+ 24 - 2
src/api/RequestModules.ts

@@ -6,7 +6,7 @@
 
 import type { DataModel, NewDataModel } from "@imengyu/js-request-transform";
 import { BaseAppServerRequestModule } from "./BaseAppServerRequestModule";
-import { appendGetUrlParams, defaultResponseDataHandlerCatch, RequestApiError, RequestApiResult, RequestCoreInstance, RequestOptions, RequestResponse, type RequestApiInfoStruct } from "@imengyu/imengyu-utils";
+import { appendGetUrlParams, defaultResponseDataHandlerCatch, RandomUtils, RequestApiError, RequestApiResult, RequestCoreInstance, RequestOptions, RequestResponse, type RequestApiInfoStruct } from "@imengyu/imengyu-utils";
 import ApiCofig from "@/common/config/ApiCofig";
 
 /**
@@ -24,7 +24,15 @@ export class AppServerRequestModule<T extends DataModel> extends BaseAppServerRe
 export class UpdateServerRequestModule<T extends DataModel> extends BaseAppServerRequestModule<T> {
   constructor() {
     super("https://update-server1.imengyu.top");
-    this.config.requestInterceptor = undefined;
+    this.config.requestInterceptor = (url, req) => {
+      if (!req.headers)
+        req.headers = {};
+      req.headers['Authorization'] = JSON.stringify({
+        "apiKey":"MQQDGbn8QfFJ7kStNtkxwifHP4sBTSDd",
+        "apiSecret":"3BNAdR7NXGwfiRmQZkRcRM8PsyHPeBmaay2k2F4TXhGEziXSJ3ceEtH2ApfHsMhR"
+      });
+      return { newUrl: url, newReq: req };
+    };
     this.config.responseDataHandler = async function responseDataHandler<T extends DataModel>(response: RequestResponse, req: RequestOptions, resultModelClass: NewDataModel | undefined, instance: RequestCoreInstance<T>, apiInfo: RequestApiInfoStruct): Promise<RequestApiResult<T>> {
       const method = req.method || 'GET';
       try {
@@ -79,6 +87,20 @@ export class UpdateServerRequestModule<T extends DataModel> extends BaseAppServe
       }
     };
   }
+
+  private static readonly DEVICE_UID_KEY = 'deviceUid';
+  private uid = '';
+
+  getDeviceUid() {
+    if (!this.uid) {
+      this.uid = uni.getStorageSync(UpdateServerRequestModule.DEVICE_UID_KEY);
+      if (!this.uid) {
+        this.uid = RandomUtils.genNonDuplicateID(20);
+        uni.setStorageSync(UpdateServerRequestModule.DEVICE_UID_KEY, this.uid);
+      }
+    }
+    return this.uid;
+  }
 }
 
 /**

+ 432 - 0
src/api/agent/Agent.ts

@@ -0,0 +1,432 @@
+import { DataModel, type KeyValue } from "@imengyu/js-request-transform";
+import { appendGetUrlParams, assertNotNull, RandomUtils } from "@imengyu/imengyu-utils";
+import { useAuthStore } from "@/store/auth";
+import { RestfulApi } from "../restful";
+import { SSE } from "./ssemp";
+import type OpenAI from "openai";
+import { CommonPageResult } from "@/api/restful/types";
+import { AgentChatFile } from "./AgentChatFile";
+
+/**
+ * Agent 占位模型(用于复用 RestfulApi 的通用请求能力)
+ */
+export class AgentChatDummy extends DataModel<AgentChatDummy> {
+  constructor() {
+    super(AgentChatDummy, "AI对话");
+  }
+}
+
+/**
+ * AI 聊天会话
+ */
+export class AgentChatHistory extends DataModel<AgentChatHistory> {
+  constructor() {
+    super(AgentChatHistory, "AI聊天会话");
+    this._convertTable = {
+      createdAt: { clientSide: "date" },
+    };
+  }
+
+  id = 0;
+  /**
+   * 用户ID
+   */
+  userId = 0;
+  /**
+   * 描述
+   */
+  desc = "";
+  /**
+   * 摘要
+   */
+  summary = "";
+  /**
+   * 长时记忆
+   */
+  longMemory = "";
+  /**
+   * 短时记忆
+   */
+  shortMemory = "";
+
+  createdAt = new Date();
+}
+
+/**
+ * AI 聊天会话项
+ */
+export class AgentChatHistoryItem extends DataModel<AgentChatHistoryItem> {
+  constructor() {
+    super(AgentChatHistoryItem, "AI聊天记录项");
+    this._convertTable = {
+      files: { clientSide: "array", clientSideChildDataModel: AgentChatFile, serverSide: 'undefined' },
+      date: { clientSide: "date" },
+    };
+    this._blackList.toServer.push('userId','files');
+  }
+
+  id = 0;
+  userId = 0;
+  /**
+   * 会话ID
+   */
+  historyId = 0;
+  /**
+   * 回复ID,针对用户消息
+   */
+  replyItemId = 0;
+  /**
+   * 父级ID,针对非用户消息,链接至用户消息
+   */
+  parentId = 0;
+  /**
+   * 额外信息
+   */
+  extra = "";
+  /**
+   * 状态
+   */
+  state = '';
+  /**
+   * 思考内容
+   */
+  reasoningContent = '';
+  /**
+   * 思考时间
+   */
+  reasoningTime = 0;
+  /**
+   * 回复时间
+   */
+  replyTime = 0;
+  /** 
+   * 回复AI名称
+   */
+  name = "";
+  /**
+   * 附件列表
+   */
+  files?: AgentChatFile[];
+  date = new Date();
+  role: "user" | "assistant" | "system" | "tool" | "" = "user";
+  content = "";
+  type = '';
+}
+
+/**
+ * AI 聊天记录分组
+ */
+export class AgentChatHistoryGroup extends DataModel<AgentChatHistoryGroup> {
+  constructor() {
+    super(AgentChatHistoryGroup, "AI聊天记录分组");
+  }
+
+  id = 0;
+  userId = 0;
+  name = "";
+  customPropmt = "";
+  icon = "";
+}
+
+/**
+ * SSE 推送消息结构(后端 chat-stream 写入的 data JSON)
+ */
+export type AgentChatStreamMessage =
+  | { content: string }
+  | { finished: true }
+  | { error: string };
+
+export type AgentChatModel = {
+  /**
+   * 模型名称
+   */
+  name: string;
+  /**
+   * 模型值
+   */
+  value: string;
+  /**
+   * 模型最大Tokens数
+   */
+  max_tokens: number;
+  /**
+   * 模型是否支持思考
+   * @default false
+   */
+  can_think?: boolean;
+  /**
+   * 模型是否支持视觉
+   * @default false
+   */
+  can_vl?: boolean;
+  /**
+   * 模型是否支持搜索
+   * @default false
+   */
+  can_search?: boolean; 
+  /**
+   * 模型是否支持解析文档
+   * @default false
+   */
+  can_prase_document?: boolean;
+  /**
+   * 模型是否支持读取音频
+   * @default false
+   */
+  can_read_audio?: boolean;
+  /**
+   * 模型是否支持读取视频
+   * @default false
+   */
+  can_read_video?: boolean;
+  /**
+   * 文档传输类型,用于指定文档在系统提示词中还是消息内容中
+   * @default "in_system_prompt"
+   */
+  docunment_transfer_type?: "in_system_prompt" | "in_message_content";
+  /**
+   * 文档传输关键字,用于指定文档在系统提示词中还是消息内容中
+   * @default ""
+   */
+  docunment_transfer_key?: string;
+};
+
+export class AgentApi extends RestfulApi<AgentChatDummy> {
+  constructor() {
+    super("content/agent", AgentChatDummy);
+  }
+
+  /**
+   * 获取模型列表
+   * 后端接口:GET /content/agent/model-list
+   */
+  getModelList() {
+    return this.commonRquest<Record<string, AgentChatModel[]>>(
+      "GET",
+      `/${this.subResKey}/model-list`,
+      "获取模型列表",
+    );
+  }
+  /**
+   * 非流式对话
+   * 后端接口:POST /content/agent/chat,body: { options }
+   */
+  chat(options: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming) {
+    return this.commonRquest<OpenAI.Chat.Completions.ChatCompletion>(
+      "POST",
+      `/${this.subResKey}/chat`,
+      "AI对话(非流式)",
+      { options },
+    );
+  }
+
+  /**
+   * 流式对话(SSE)
+   * 后端接口:POST /content/agent/chat-stream,body: { options }
+   *
+   * 注意:此接口返回 text/event-stream,不走项目内通用的 JSON 包装协议。
+   */
+  chatStream(options: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming): SSE {
+    const authStore = useAuthStore();
+    const devUid = this.getDeviceUid();
+
+    const url = appendGetUrlParams(
+      `${this.config.baseUrl}/content/agent/chat-stream`,
+      "identifier",
+      devUid,
+    );
+
+    return new SSE(url, {
+      headers: { 
+        'Content-Type': 'application/json' ,
+        token: authStore.token,
+      },
+      method: 'POST',
+      payload: JSON.stringify({ options }),
+      withCredentials: false,
+      timeout: 60000,
+    });
+  }
+
+  /**
+   * 获取聊天会话分页(仅当前用户)
+   * 后端接口:GET /content/agent/chat-history?page=&size=&search=
+   */
+  getChatHistory(page = 1, size = 10, search = "", groupId?: number) {
+    return this.get<{
+      allCount?: number;
+      allPage?: number;
+      items?: KeyValue[] | null;
+    }>(
+      "/content/agent/chat-history",
+      "获取聊天会话分页列表",
+      { page, size, search, groupId },
+    ).then((rs) => {
+      assertNotNull(rs.data, "rs.data is null!");
+      return rs.cloneWithOtherData(
+        new CommonPageResult<AgentChatHistory>().fromData(rs.data, page, size, AgentChatHistory),
+      );
+    });
+  }
+
+  /**
+   * 获取聊天会话项分页(仅当前用户)
+   * 后端接口:GET /content/agent/chat-history-items?page=&size=&id=
+   */
+  getChatHistoryItems(page = 1, size = 10, id = 0) {
+    return this.get<{
+      allCount?: number;
+      allPage?: number;
+      items?: KeyValue[] | null;
+    }>(
+      "/content/agent/chat-history-items",
+      "获取聊天会话项分页列表",
+      { 
+        page, size, id, 
+        sort: {
+          field: 'date',
+          order: 'asc',
+        }
+      },
+    ).then((rs) => {
+      assertNotNull(rs.data, "rs.data is null!");
+      return rs.cloneWithOtherData(
+        new CommonPageResult<AgentChatHistoryItem>().fromData(rs.data, page, size, AgentChatHistoryItem),
+      );
+    });
+  }
+
+  /**
+   * 获取聊天记录分组分页(仅当前用户)
+   * 后端接口:GET /content/agent/chat-history-groups?page=&size=
+   */
+  getChatHistoryGroups(page = 1, size = 10) {
+    return this.get<{
+      allCount?: number;
+      allPage?: number;
+      items?: KeyValue[] | null;
+    }>(
+      "/content/agent/chat-history-groups",
+      "获取聊天记录分组分页列表",
+      { page, size },
+    ).then((rs) => {
+      assertNotNull(rs.data, "rs.data is null!");
+      return rs.cloneWithOtherData(
+        new CommonPageResult<AgentChatHistoryGroup>().fromData(rs.data, page, size, AgentChatHistoryGroup),
+      );
+    });
+  }
+
+  /**
+   * 创建聊天记录分组
+   * 后端接口:POST /content/agent/chat-history-groups,body: { name, customPropmt, icon }
+   */
+  createChatHistoryGroup(name: string, customPropmt = "", icon = "") {
+    return this.post<number>(
+      "/content/agent/chat-history-groups",
+      "创建聊天记录分组",
+      { name, customPropmt, icon },
+    );
+  }
+
+  /**
+   * 更新聊天记录分组
+   * 后端接口:PUT /content/agent/chat-history-groups/:id,body: { name, customPropmt, icon }
+   */
+  updateChatHistoryGroup(id: number, name: string, customPropmt = "", icon = "") {
+    return this.put<unknown>(
+      `/content/agent/chat-history-groups/${id}`,
+      "更新聊天记录分组",
+      { name, customPropmt, icon },
+    );
+  }
+
+  /**
+   * 删除聊天记录分组
+   * 后端接口:DELETE /content/agent/chat-history-groups/:id
+   */
+  deleteChatHistoryGroup(id: number) {
+    return this.delete<unknown>(
+      `/content/agent/chat-history-groups/${id}`,
+      "删除聊天记录分组",
+    );
+  }
+
+  /**
+   * 创建我的聊天会话
+   * 后端接口:POST /content/agent/chat-history,body: { desc }
+   */
+  createChatHistory(desc: string, groupId?: number) {
+    return this.post<number>(
+      "/content/agent/chat-history",
+      "创建聊天会话",
+      { desc, groupId },
+    );
+  }
+
+  /**
+   * 更新我的聊天会话
+   * 后端接口:PUT /content/agent/chat-history/:id,body: { data }
+   */
+  updateChatHistory(id: number, data: AgentChatHistory) {
+    return this.put<unknown>(
+      `/content/agent/chat-history/${id}`,
+      "更新聊天会话",
+      { data: data.toServerSide() },
+    );
+  }
+  /**
+   * 更新聊天记录项
+   * 后端接口:PUT /content/agent/chat-history-items/:id,body: { data }
+   */
+  updateChatHistoryItem(id: number, data: AgentChatHistoryItem) {
+    return this.put<unknown>(
+      `/content/agent/chat-history-items/${id}`,
+      "更新聊天记录项",
+      { data: data.toServerSide() },
+    );
+  }
+
+  /**
+   * 删除我的聊天会话
+   * 后端接口:DELETE /content/agent/chat-history/:id
+   * @param id 聊天会话ID
+   * @returns 删除结果
+   */
+  deleteChatHistory(id: number) {
+    return this.delete<unknown>(
+      `/content/agent/chat-history/${id}`,
+      "删除聊天会话",
+    );
+  }
+
+  /**
+   * 向我的聊天会话添加/更新聊天记录项
+   * 后端接口:POST /content/agent/chat-history-items/:historyId,body: { datas }
+   */
+  addOrUpdateChatHistoryItems(historyId: number, datas: AgentChatHistoryItem[], parentId?: number) {
+    return this.post<number[]>(
+      `/content/agent/chat-history-items/${historyId}`,
+      "添加聊天记录项",
+      { 
+        datas: datas.map((k) => k.toServerSide()),
+        parentId,
+      },
+    );
+  }
+
+  /**
+   * 删除我的聊天会话的聊天记录项
+   * 后端接口:POST /content/agent/chat-history-items/:historyId/delete,body: { ids }
+   */
+  deleteChatHistoryItems(historyId: number, ids: number[]) {
+    return this.post<unknown>(
+      `/content/agent/chat-history-items/${historyId}/delete`,
+      "删除聊天记录项",
+      { ids },
+    );
+  }
+}
+
+export default new AgentApi();
+

+ 90 - 0
src/api/agent/AgentChatFile.ts

@@ -0,0 +1,90 @@
+import { DataModel } from "@imengyu/js-request-transform";
+import { RestfulApi } from "../restful";
+
+/**
+ * AI 对话附件(临时文件)
+ */
+export class AgentChatFile extends DataModel<AgentChatFile> {
+  constructor() {
+    super(AgentChatFile, "AI对话附件");
+    this._convertTable = {
+      createdAt: { clientSide: "date" },
+      updatedAt: { clientSide: "date" },
+      deletedAt: { clientSide: "date" },
+    };
+  }
+
+  id = 0;
+  userId = 0;
+  historyItemId = 0;
+  path = "";
+  url = "";
+  aiFileId = '';
+
+  createdAt = new Date();
+  updatedAt = new Date();
+  deletedAt = new Date();
+}
+
+export class AgentChatFileApi extends RestfulApi<AgentChatFile> {
+  constructor() {
+    super("content/agent/chat-file", AgentChatFile);
+  }
+
+  /**
+   * 上传对话附件(FormData)。临时文件,不持久化保存。
+   * 后端:POST /content/agent/chat-file/upload
+   */
+  upload(file: File | Blob) {
+    const formData = new FormData();
+    formData.append("file", file);
+    return this.post<{
+      path: string;
+      url: string;
+      id: number;
+    }>(
+      `/${this.subResKey}/upload`,
+      "上传AI对话附件",
+      formData,
+    );
+  }
+
+  /**
+   * 将聊天附件关联到聊天记录项
+   * 后端:POST /content/agent/chat-file/link-to-history-item
+   */
+  linkToHistoryItem(historyItemId: number, fileId: number) {
+    return this.post<void>(
+      `/${this.subResKey}/link-to-history-item`,
+      "将聊天附件关联到聊天记录项",
+      { historyItemId, fileId },
+    );
+  }
+
+  /**
+   * 删除对话附件(临时文件)
+   * 后端:DELETE /content/agent/chat-file/delete
+   */
+  deleteFile(path: string) {
+    return this.delete<void>(
+      `/${this.subResKey}/delete`,
+      "删除AI对话附件",
+      { path },
+    );
+  }
+
+  /**
+   * 将对话附件转为对应AI系统可用的文件
+   * 后端:POST /content/agent/chat-file/process-to-ai
+   */
+  processChatFileToAi(id: number, modelName: string) {
+    return this.post<string>(
+      `/${this.subResKey}/process-to-ai`,
+      "将对话附件转为对应AI系统可用的文件",
+      { id, modelName },
+    );
+  }
+}
+
+export default new AgentChatFileApi();
+

+ 343 - 0
src/api/agent/AgentWorks.ts

@@ -0,0 +1,343 @@
+import { DataModel } from "@imengyu/js-request-transform";
+import { RestfulApi } from "../restful";
+import AgentApi from "./Agent";
+
+export class AgentWorkApi extends RestfulApi<DataModel> {
+  constructor() {
+    super("content/agent", DataModel);
+  }
+
+  private static readonly WORK_MINI_MODEL_NAME = 'hunyuan-2.0-instruct-20251111';
+
+  private static readonly QUESTION_RELATED_CACHE_TTL_MS = 5 * 60 * 1000;
+  private static readonly QUESTION_RELATED_CACHE_MAX_SIZE = 200;
+  private questionRelatedCache = new Map<string, { value: boolean; expireAt: number }>();
+
+  private extractJsonObject<T>(content: string, fallback: T): T {
+    try {
+      return JSON.parse(content) as T;
+    } catch {
+      const start = content.indexOf('{');
+      const end = content.lastIndexOf('}');
+      if (start >= 0 && end > start) {
+        try {
+          return JSON.parse(content.slice(start, end + 1)) as T;
+        } catch {
+          return fallback;
+        }
+      }
+      return fallback;
+    }
+  }
+
+  private getQuestionRelatedCacheKey(thisQuestion: string, prevQuestions: string[], prevAnswers: string[]) {
+    const normalize = (text: string) => text.trim().replace(/\s+/g, " ").slice(0, 240);
+    return JSON.stringify({
+      q: normalize(thisQuestion),
+      prevQ: prevQuestions.map(normalize),
+      prevA: prevAnswers.map(normalize),
+    });
+  }
+  private getQuestionRelatedFromCache(cacheKey: string): boolean | undefined {
+    const hit = this.questionRelatedCache.get(cacheKey);
+    if (!hit)
+      return undefined;
+    if (Date.now() > hit.expireAt) {
+      this.questionRelatedCache.delete(cacheKey);
+      return undefined;
+    }
+    return hit.value;
+  }
+  private setQuestionRelatedCache(cacheKey: string, value: boolean) {
+    const now = Date.now();
+    this.questionRelatedCache.set(cacheKey, {
+      value,
+      expireAt: now + AgentWorkApi.QUESTION_RELATED_CACHE_TTL_MS,
+    });
+    if (this.questionRelatedCache.size <= AgentWorkApi.QUESTION_RELATED_CACHE_MAX_SIZE)
+      return;
+
+    for (const [key, item] of this.questionRelatedCache.entries()) {
+      if (item.expireAt <= now)
+        this.questionRelatedCache.delete(key);
+    }
+    while (this.questionRelatedCache.size > AgentWorkApi.QUESTION_RELATED_CACHE_MAX_SIZE) {
+      const oldestKey = this.questionRelatedCache.keys().next().value as string | undefined;
+      if (!oldestKey)
+        break;
+      this.questionRelatedCache.delete(oldestKey);
+    }
+  }
+
+
+  /**
+   * 调用模型,获取用户问题的一个简要概览
+   * @param question 
+   * @returns 简要概览,如果获取失败,则返回用户问题的前24个字符
+   */
+  async getQuestionOverview(question: string) {
+    try {
+      const result = await AgentApi.chat({
+        model: AgentWorkApi.WORK_MINI_MODEL_NAME,
+        messages: [
+          { role: "system", content: `你是一个专业的概览生成器,请根据用户的问题生成一个简要概览,
+            注意,你只对用户输入进行简要概括,不要回答问题。输出语言与用户输入语言一致。
+            30字以内,输出格式为 JSON,结构为 { "overview": "简要概览" }
+            示例:
+            用户输入:我想学习如何使用Excel进行数据分析,请帮帮我,最好有详细的步骤说明
+            模型输出:{ "overview": "如何使用Excel进行数据分析" }
+            ` },
+          { role: "user", content: question.slice(0, 240) },
+        ],
+      });
+      const content = result.requireData().choices[0].message.content;
+      const overview = this.extractJsonObject<{ overview: string }>(content ?? '{}', { overview: '' }).overview;
+      return overview || question.slice(0, 24);
+    } catch (error) {
+      return question.slice(0, 24);
+    }
+  }
+
+  /**
+   * 获取用户内容的标题
+   * @param userContent 用户内容
+   * @returns 标题
+   */
+  async getTitle(userContent: string) {
+    try {
+      const result = await AgentApi.chat({
+        model: AgentWorkApi.WORK_MINI_MODEL_NAME,
+        messages: [
+          { role: "system", content: `你是一个专业的标题生成器,请根据内容概括一个标题,
+            输出格式为 JSON,结构为 { "title": "标题" }`
+          },
+          { role: "user", content: userContent.slice(0, 240) },
+        ],
+      });
+      const content = result.requireData().choices[0].message.content;
+      const title = (this.extractJsonObject<{ title: string }>(content ?? '{}', { title: '' })).title;
+      return title || userContent.slice(0, 24);
+    } catch (error) {
+      console.error(error);
+      return userContent.slice(0, 24);
+    }
+  }
+
+  /**
+   * 自动生成可能的问题
+   * @param question 
+   * @returns 可能的问题
+   */
+  async autoGeneratePossibleQuestions(question: string, aiAnswer: string) {
+    try {
+      const result = await AgentApi.chat({
+        model: AgentWorkApi.WORK_MINI_MODEL_NAME,
+        messages: [
+          { role: "system", content: `你是一个专业的问题联想生成器,请根据用户的问题和AI回答生成2~4个用户可能感兴趣的问题,
+            输出格式为 JSON,结构为 ["可能的问题1", "可能的问题2", "可能的问题3"]
+            示例:
+            用户输入:占道经营是什么
+            AI回答:占道经营是指在未获得合法审批的情况下,占用城市道路、桥梁、广场等公共场所进行营利性买卖商品或提供服务的行为。这是一个复杂的社会现象,既关乎城市的“面子”——市容与秩序,也关乎民生的“里子”——生计与便利。
+            模型输出:["有哪些城市成功治理占道经营的案例", "占道经营治理中有哪些常见执法难点", "如何平衡占道经营与城市交通秩序"]
+            ` },
+          { role: "user", content: question.slice(0, 1000) },
+          { role: "assistant", content: aiAnswer.slice(0, 1000) },
+        ],
+      });
+      const content = result.requireData().choices[0].message.content;
+      return (JSON.parse(content ?? '[]') as string[]);
+    } catch (error) {
+      return [];
+    }
+  }
+
+  /**
+   * 检查问题是否相关
+   * @param thisQuestion 
+   * @param prevQuestions 
+   * @param prevAnswers 
+   */
+  async checkQuestionRelated(thisQuestion: string, prevQuestions: string[], prevAnswers: string[]) {
+    const currentQuestion = thisQuestion.trim();
+    if (!currentQuestion)
+      return false;
+    if ((prevQuestions?.length || 0) === 0 && (prevAnswers?.length || 0) === 0)
+      return false;
+    const cacheKey = this.getQuestionRelatedCacheKey(currentQuestion, prevQuestions, prevAnswers);
+    const cached = this.getQuestionRelatedFromCache(cacheKey);
+    if (typeof cached === "boolean")
+      return cached;
+
+    try {
+      const historyPairs = Array.from({
+        length: Math.max(prevQuestions.length, prevAnswers.length),
+      }).map((_, index) => ({
+        q: prevQuestions[index]?.slice(0, 500) || '',
+        a: prevAnswers[index]?.slice(0, 500) || '',
+      }));
+      const result = await AgentApi.chat({
+        model: AgentWorkApi.WORK_MINI_MODEL_NAME,
+        messages: [
+          {
+            role: "system",
+            content: `你是一个对话相关性分类器。请根据“当前问题”和“历史问答”判断是否相关。
+返回 JSON:{ "related": true|false, "confidence": 0~1, "reason": "简短原因" }。
+判定标准:
+1) 主题、实体、任务目标连续,算 related=true;
+2) 完全换话题、换任务目标,算 related=false;
+3) 不确定时倾向 false。`,
+          },
+          {
+            role: "user",
+            content: JSON.stringify({
+              currentQuestion,
+              history: historyPairs,
+            }),
+          },
+        ],
+      });
+
+      const content = result.requireData().choices[0].message.content || "{}";
+      const parsed = this.extractJsonObject<{ related?: boolean; confidence?: number }>(content, 
+        { related: false, confidence: 0 }
+      );
+      const finalRelated =
+        typeof parsed.related === "boolean"
+          ? parsed.related
+          : (typeof parsed.confidence === "number" ? parsed.confidence >= 0.65 : false);
+      this.setQuestionRelatedCache(cacheKey, finalRelated);
+      return finalRelated;
+    } catch (error) {
+      this.setQuestionRelatedCache(cacheKey, false);
+      return false;
+    }
+  }
+
+  /**
+   * 生成会话阶段摘要(中期记忆)
+   */
+  async summarizeConversationStage(historyLines: string[], prevSummary = ""): Promise<string> {
+    const historyText = historyLines.map((line) => line.trim()).filter(Boolean).join("\n");
+    if (!historyText)
+      return prevSummary;
+    try {
+      const result = await AgentApi.chat({
+        model: AgentWorkApi.WORK_MINI_MODEL_NAME,
+        messages: [
+          {
+            role: "system",
+            content: `你是会话记忆压缩器。请将“历史对话”压缩为可供后续对话参考的中期摘要。
+要求:
+1) 保留关键目标、结论、约束、未完成事项;
+2) 删除寒暄与重复信息;
+3) 摘要尽量精炼(建议 8~12 条);
+4) 返回 JSON:{ "summary": "多行文本" }。`,
+          },
+          {
+            role: "user",
+            content: JSON.stringify({
+              previousSummary: prevSummary || "",
+              historyText,
+            }),
+          },
+        ],
+      });
+      const content = result.requireData().choices[0].message.content || "{}";
+      const parsed = this.extractJsonObject<{ summary?: string }>(content, {});
+      return (parsed.summary || prevSummary || "").trim();
+    } catch {
+      return prevSummary;
+    }
+  }
+
+  /**
+   * 抽取长期记忆(稳定偏好/身份/长期约束)
+   */
+  async extractLongTermMemory(historyLines: string[], existingLongMemory = "", maxItems = 12): Promise<string> {
+    const historyText = historyLines.map((line) => line.trim()).filter(Boolean).join("\n");
+    if (!historyText)
+      return existingLongMemory;
+    try {
+      const result = await AgentApi.chat({
+        model: AgentWorkApi.WORK_MINI_MODEL_NAME,
+        messages: [
+          {
+            role: "system",
+            content: `你是长期记忆抽取器。请从对话中抽取“长期稳定且值得记住”的信息:
+- 用户身份/背景(稳定)
+- 长期偏好、禁忌、风格要求
+- 长期目标与硬约束
+- 联系方式等高价值事实
+不要输出瞬时任务细节。
+返回 JSON:{ "items": ["记忆1", "记忆2"] }。
+如无新增重要信息可返回空数组。`,
+          },
+          {
+            role: "user",
+            content: JSON.stringify({
+              existingLongMemory,
+              historyText,
+              maxItems,
+            }),
+          },
+        ],
+      });
+      const content = result.requireData().choices[0].message.content || "{}";
+      const parsed = this.extractJsonObject<{ items?: string[] }>(content, {});
+      const current = existingLongMemory
+        .split("\n")
+        .map((line) => line.trim().replace(/^- /, ""))
+        .filter(Boolean);
+      const merged = new Set<string>(current);
+      for (const item of parsed.items || []) {
+        const text = String(item || "").trim();
+        if (text)
+          merged.add(text);
+      }
+      return Array.from(merged)
+        .slice(0, Math.max(1, maxItems))
+        .map((item) => `- ${item}`)
+        .join("\n");
+    } catch {
+      return existingLongMemory;
+    }
+  }
+
+  /**
+   * 通过用户输入的生平事迹生成文件描述
+   */
+  async generateFileDescByInputDoc(inputDoc: string, fileName: string): Promise<string> {
+    try {
+      const result = await AgentApi.chat({
+        model: AgentWorkApi.WORK_MINI_MODEL_NAME,
+        messages: [
+          {
+            role: "system",
+            content: `你是文件描述生成器。你负责根据用户输入的工作日志事迹,用户本次上传文件的文件名称,
+            生成文件描述。例如:用户输入的工作日志事迹为:张三,2026年1月1日,工作内容为:写代码,工作结果为:完成一个功能。
+            用户本次上传文件的文件名称为:代码.txt,则文件描述为:张三在2026年1月1日写代码,完成一个功能。
+            要求:
+            1) 文件描述要简洁明了,不要超过200字。
+            2) 文件描述要符合用户输入的工作日志事迹,不要偏离主题。
+            3) 文件描述要符合用户本次上传文件的文件名称,不要偏离主题。
+            4) 返回 JSON:{ "desc": "文本" }。不要返回其他内容。`,
+          },
+          {
+            role: "user",
+            content: `用户输入的工作日志事迹:\n${inputDoc}
+            用户本次上传文件的文件名称:${fileName}`,
+          },
+        ],
+      });
+      const content = result.requireData().choices[0].message.content || "{}";
+      const parsed = this.extractJsonObject<{ desc?: string }>(content, {});
+      return (parsed.desc || "").trim();
+    } catch {
+      return "";
+    }
+  }
+  
+}
+
+export default new AgentWorkApi();
+

+ 420 - 0
src/api/agent/ssemp.ts

@@ -0,0 +1,420 @@
+export type SSEMethod = "GET" | "POST" | "PUT" | "DELETE";
+
+export interface SSEOptions {
+  headers?: Record<string, string>;
+  method?: SSEMethod;
+  payload?: string;
+  withCredentials?: boolean;
+  timeout?: number;
+}
+
+type SSEOpenHandler = ((event?: Event) => void) | null;
+type SSEMessageHandler = ((event: MessageEvent<string>) => void) | null;
+type SSEErrorHandler = ((error: unknown) => void) | null;
+type ByteArray = Uint8Array<ArrayBufferLike>;
+
+type ChunkDecoder = {
+  decode: (data: unknown) => string;
+  flush: () => string;
+};
+
+function toUint8Array(data: unknown): ByteArray | null {
+  if (data instanceof Uint8Array) {
+    return data as ByteArray;
+  }
+  if (data instanceof ArrayBuffer) {
+    return new Uint8Array(data) as ByteArray;
+  }
+  if (ArrayBuffer.isView(data)) {
+    return new Uint8Array(data.buffer, data.byteOffset, data.byteLength) as ByteArray;
+  }
+  return null;
+}
+
+function decodeUtf8Bytes(bytes: ByteArray, previousTail: ByteArray): { text: string; tail: ByteArray } {
+  const merged = previousTail.length
+    ? (() => {
+      const tmp = new Uint8Array(previousTail.length + bytes.length);
+      tmp.set(previousTail, 0);
+      tmp.set(bytes, previousTail.length);
+      return tmp as ByteArray;
+    })()
+    : bytes;
+
+  let text = "";
+  let i = 0;
+
+  while (i < merged.length) {
+    const b1 = merged[i];
+
+    if (b1 <= 0x7f) {
+      text += String.fromCharCode(b1);
+      i += 1;
+      continue;
+    }
+
+    let needed = 0;
+    if (b1 >= 0xc2 && b1 <= 0xdf) needed = 2;
+    else if (b1 >= 0xe0 && b1 <= 0xef) needed = 3;
+    else if (b1 >= 0xf0 && b1 <= 0xf4) needed = 4;
+    else {
+      text += "\ufffd";
+      i += 1;
+      continue;
+    }
+
+    if (i + needed > merged.length) {
+      break;
+    }
+
+    const b2 = merged[i + 1];
+    if ((b2 & 0xc0) !== 0x80) {
+      text += "\ufffd";
+      i += 1;
+      continue;
+    }
+
+    if (needed === 2) {
+      const codePoint = ((b1 & 0x1f) << 6) | (b2 & 0x3f);
+      text += String.fromCharCode(codePoint);
+      i += 2;
+      continue;
+    }
+
+    const b3 = merged[i + 2];
+    if ((b3 & 0xc0) !== 0x80) {
+      text += "\ufffd";
+      i += 1;
+      continue;
+    }
+
+    if (needed === 3) {
+      const codePoint = ((b1 & 0x0f) << 12) | ((b2 & 0x3f) << 6) | (b3 & 0x3f);
+      text += String.fromCharCode(codePoint);
+      i += 3;
+      continue;
+    }
+
+    const b4 = merged[i + 3];
+    if ((b4 & 0xc0) !== 0x80) {
+      text += "\ufffd";
+      i += 1;
+      continue;
+    }
+
+    const codePoint =
+      ((b1 & 0x07) << 18) |
+      ((b2 & 0x3f) << 12) |
+      ((b3 & 0x3f) << 6) |
+      (b4 & 0x3f);
+    const cp = codePoint - 0x10000;
+    text += String.fromCharCode((cp >> 10) + 0xd800, (cp & 0x3ff) + 0xdc00);
+    i += 4;
+  }
+
+  return {
+    text,
+    tail: i < merged.length ? (merged.slice(i) as ByteArray) : (new Uint8Array(0) as ByteArray),
+  };
+}
+
+function createChunkDecoder(): ChunkDecoder {
+  if (typeof TextDecoder !== "undefined") {
+    const decoder = new TextDecoder("utf-8");
+    return {
+      decode(data: unknown) {
+        if (typeof data === "string") {
+          return data;
+        }
+        const bytes = toUint8Array(data);
+        if (!bytes) {
+          return "";
+        }
+        return decoder.decode(bytes, { stream: true });
+      },
+      flush() {
+        return decoder.decode();
+      },
+    };
+  }
+
+  let tail: ByteArray = new Uint8Array(0) as ByteArray;
+  return {
+    decode(data: unknown) {
+      if (typeof data === "string") {
+        return data;
+      }
+      const bytes = toUint8Array(data);
+      if (!bytes) {
+        return "";
+      }
+      const decoded = decodeUtf8Bytes(bytes, tail);
+      tail = decoded.tail;
+      return decoded.text;
+    },
+    flush() {
+      if (!tail.length) {
+        return "";
+      }
+      const decoded = decodeUtf8Bytes(new Uint8Array(0) as ByteArray, tail);
+      tail = new Uint8Array(0) as ByteArray;
+      return decoded.text;
+    },
+  };
+}
+
+function parsePayload(payload?: string): string | Record<string, unknown> | ArrayBuffer | undefined {
+  if (!payload) {
+    return undefined;
+  }
+  try {
+    return JSON.parse(payload);
+  } catch {
+    return payload;
+  }
+}
+
+export class SSE {
+  onopen: SSEOpenHandler = null;
+  onmessage: SSEMessageHandler = null;
+  onerror: SSEErrorHandler = null;
+
+  private requestTask: UniApp.RequestTask | null = null;
+  private abortController: AbortController | null = null;
+  private buffer = "";
+  private lastEventId = "";
+  private closed = false;
+  private opened = false;
+
+  constructor(
+    private readonly url: string,
+    private readonly options: SSEOptions = {},
+  ) {}
+
+  stream() {
+    this.closed = false;
+    this.opened = false;
+    this.buffer = "";
+
+    if (typeof uni !== "undefined" && typeof uni.request === "function") {
+      this.startWithUniRequest();
+      return;
+    }
+
+    this.startWithFetch();
+  }
+
+  close() {
+    this.closed = true;
+
+    if (this.requestTask) {
+      this.requestTask.abort();
+      this.requestTask = null;
+    }
+
+    if (this.abortController) {
+      this.abortController.abort();
+      this.abortController = null;
+    }
+  }
+
+  private emitOpen() {
+    if (this.opened || this.closed) {
+      return;
+    }
+    this.opened = true;
+    this.onopen?.();
+  }
+
+  private emitError(error: unknown) {
+    this.onerror?.(error);
+    if (this.closed) {
+      return;
+    }
+  }
+
+  private dispatchRawEvent(raw: string) {
+    if (!raw.trim()) {
+      return;
+    }
+
+    const lines = raw.split("\n");
+    const dataLines: string[] = [];
+    let eventName = "message";
+
+    for (const line of lines) {
+      if (!line || line.startsWith(":")) {
+        continue;
+      }
+
+      const separator = line.indexOf(":");
+      const field = separator >= 0 ? line.slice(0, separator) : line;
+      let value = separator >= 0 ? line.slice(separator + 1) : "";
+      if (value.startsWith(" ")) {
+        value = value.slice(1);
+      }
+
+      if (field === "data") {
+        dataLines.push(value);
+      } else if (field === "event" && value) {
+        eventName = value;
+      } else if (field === "id") {
+        this.lastEventId = value;
+      }
+    }
+
+    if (dataLines.length === 0) {
+      return;
+    }
+
+    const event = {
+      data: dataLines.join("\n"),
+      type: eventName,
+      lastEventId: this.lastEventId,
+      origin: this.url,
+    } as MessageEvent<string>;
+
+    // 兼容 Chat.ts 的使用方式:统一通过 onmessage 消费数据。
+    this.onmessage?.(event);
+  }
+
+  private consumeSseChunk(chunkText: string) {
+    if (!chunkText || this.closed) {
+      return;
+    }
+
+    this.emitOpen();
+    this.buffer += chunkText.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
+
+    let boundaryIndex = this.buffer.indexOf("\n\n");
+    while (boundaryIndex >= 0) {
+      const rawEvent = this.buffer.slice(0, boundaryIndex);
+      this.buffer = this.buffer.slice(boundaryIndex + 2);
+      this.dispatchRawEvent(rawEvent);
+      boundaryIndex = this.buffer.indexOf("\n\n");
+    }
+  }
+
+  private flushRemainder() {
+    if (this.buffer.trim()) {
+      this.dispatchRawEvent(this.buffer);
+      this.buffer = "";
+    }
+  }
+
+  private startWithUniRequest() {
+    const chunkDecoder = createChunkDecoder();
+    const method = this.options.method || "GET";
+    const payloadData = parsePayload(this.options.payload);
+    let hasReceivedChunk = false;
+
+    const requestTask = uni.request({
+      url: this.url,
+      method,
+      header: this.options.headers,
+      data: payloadData,
+      timeout: this.options.timeout,
+      responseType: "arraybuffer",
+      enableChunked: true,
+      success: (res) => {
+        if (res.statusCode !== 200) {
+          this.emitError(new Error(
+            res.data ? JSON.stringify(res.data) : `请求失败: ${res.statusCode}`
+          ));
+          return;
+        }
+        if (this.closed) {
+          return;
+        }
+        if (!hasReceivedChunk) {
+          const text = chunkDecoder.decode(res.data);
+          this.consumeSseChunk(text);
+        }
+        const tail = chunkDecoder.flush();
+        if (tail) {
+          this.consumeSseChunk(tail);
+        }
+        this.flushRemainder();
+      },
+      fail: (error) => {
+        this.emitError(error);
+      },
+    } as UniApp.RequestOptions);
+
+    this.requestTask = requestTask as unknown as UniApp.RequestTask;
+
+    const chunkableTask = this.requestTask as UniApp.RequestTask & {
+      onChunkReceived?: (callback: (result: { data: ArrayBuffer }) => void) => void;
+    };
+    if (typeof chunkableTask.onChunkReceived === "function") {
+      chunkableTask.onChunkReceived((result: { data: ArrayBuffer }) => {
+        hasReceivedChunk = true;
+        if (this.closed) {
+          return;
+        }
+        const text = chunkDecoder.decode(result.data);
+        this.consumeSseChunk(text);
+      });
+    }
+  }
+
+  private async startWithFetch() {
+    if (typeof fetch === "undefined") {
+      this.emitError(new Error("当前环境不支持 fetch 流式读取"));
+      return;
+    }
+
+    this.abortController = new AbortController();
+    const method = this.options.method || "GET";
+
+    try {
+      const response = await fetch(this.url, {
+        method,
+        headers: this.options.headers,
+        body: this.options.payload,
+        credentials: this.options.withCredentials ? "include" : "omit",
+        signal: this.abortController.signal,
+      });
+
+      if (this.closed) {
+        return;
+      }
+
+      if (!response.ok) {
+        throw new Error(`请求失败: ${response.status} ${response.statusText}`);
+      }
+
+      this.emitOpen();
+
+      if (!response.body) {
+        const plainText = await response.text();
+        this.consumeSseChunk(plainText);
+        this.flushRemainder();
+        return;
+      }
+
+      const reader = response.body.getReader();
+      const chunkDecoder = createChunkDecoder();
+
+      while (!this.closed) {
+        const result = await reader.read();
+        if (result.done) {
+          break;
+        }
+        const chunkText = chunkDecoder.decode(result.value);
+        this.consumeSseChunk(chunkText);
+      }
+
+      const tail = chunkDecoder.flush();
+      if (tail) {
+        this.consumeSseChunk(tail);
+      }
+      this.flushRemainder();
+    } catch (error) {
+      if (this.closed) {
+        return;
+      }
+      this.emitError(error);
+    }
+  }
+}

+ 380 - 0
src/api/restful/index.ts

@@ -0,0 +1,380 @@
+/**
+ * RESTFUL API 包装类
+ * 
+ * 功能介绍:
+ *    为 RESTful 的 API 提供了一套快速使用的请求方法。
+ * 
+ * Author: imengyu
+ * Date: 2020/09/12
+ * 
+ * Copyright (c) 2021 imengyu.top. Licensed under the MIT License. 
+ * See License.txt in the project root for license information.
+ */
+
+import { DataModel, type KeyValue } from '@imengyu/js-request-transform';
+import { ArrayUtils, assertNotNull, RequestApiConfig, RequestApiResult, StringUtils, type TypeAll } from '@imengyu/imengyu-utils';
+import { CommonArrayResult, CommonListResult, CommonPageResult } from './types';
+import { UpdateServerRequestModule } from '../RequestModules';
+
+/**
+ * 可搜索类型定义
+ */
+export type SearchableType = string|number|boolean|Array<string>|Array<number>|Array<Date>|null|bigint; 
+
+/**
+ * 搜索参数
+ */
+export interface SearchArrayParams {
+  /**
+   * Key为搜索的字段(用户可能输入多个字段搜索,此字段可能为多个)
+   * 值是用户输入的。如果为Array,则为数组
+   */
+  [index: string]: SearchableType|{
+    fuzzy?: boolean,
+    range?: boolean,
+    value: string|string[]
+  };
+}
+
+/**
+ * 搜索参数
+ */
+export interface SearchParams {
+  /**
+   * 排序方法(可能为空,为空则不排序或使用默认排序)
+   */
+  sort?: {
+    /**
+     * 用来排序的字段
+     */
+    field: string|undefined;
+    /**
+     * 排序顺序(可能为空,为空则服务器默认排序)
+     */
+    order: 'ascend'|'descend'|string|undefined;
+  };
+  /**
+   * 搜索参数(为空则用户没有搜索)
+   */
+  search?: SearchArrayParams;
+  /**
+   * 筛选器(为空则用户没有筛选)
+   */
+  filter?: {
+    /**
+     * Key为要筛选的字段
+     * 值是一个数组,表示用户希望筛选出指定字段值在数组中的数据
+     * (用户可能输入多个字段筛选,此字段可能为多个)
+     */
+    [index: string]: TypeAll[]|undefined;
+  };
+  /**
+   * 分页页码
+   */
+  page?: number;
+  /**
+   * 分页页大小
+   */
+  size?: number;
+}
+
+/**
+ * URL参数
+ */
+export interface QueryParams {
+  /**
+   * URL参数
+   */
+  [index: string]: TypeAll;
+}
+
+/**
+ * 将参数转为斜杠“/”分隔的URL
+ * @param params 输入参数
+ * @returns 例如:输入 [ 'a', 'b', 'c' ] 将返回 a/b/c
+ */
+export function buildParams(params: Array<string>) : string {
+  let rs = '';
+  if(params && params.length > 0) {
+    for(let i = 0; i < params.length; i++) {
+      rs += '/' + params[i];
+    }
+  }
+  return rs
+}
+/**
+ * 转换参数结构为URL参数。
+ * * 此方法将会把参数对象中的属性按key-value展开为URL参数,例如:{ a: 1, b: 2 } 展开为 a=1&b=2。
+ * * 如果对象中的属性值是对象,它将被转为 JSON 字符串。
+ * * 所有的参数都已经URL编码。
+ * @param querys 参数结构
+ * @returns URL参数字符串
+ */
+export function buildQueryParams(querys: QueryParams) : string {
+  let rs = '?';
+  if(querys) {
+    const keys = Object.keys(querys);
+    let j = 0;
+    for(let i = 0; i < keys.length; i++) {
+      let obj = querys[keys[i]];
+      if (!StringUtils.isNullOrEmpty(obj as any)) {
+        if(typeof obj === 'object') obj = JSON.stringify(obj);
+        if(j == 0) rs += keys[i] + '=' + encodeURIComponent('' + obj);
+        else rs += '&' + keys[i] + '=' + encodeURIComponent('' + obj);
+        j++;
+      }
+    }
+  }
+  return rs
+}
+
+
+/**
+ * 通用 Restful 资源请求类
+ */
+export class RestfulApi<T extends DataModel> extends UpdateServerRequestModule<T> {
+
+  /**
+   * api 子路径
+   */
+  public subResKey: string;
+
+  public apiName: string;
+  /**
+   * 类型
+   */
+  public modulInstance: new () => T;
+
+  /**
+   * 创建 通用资源请求类
+   * @param subResKey api 子路径
+   */
+  public constructor(subResKey: string, modulInstance : new () => T) {
+    super();
+    this.subResKey = subResKey;
+    this.modulInstance = modulInstance;
+    this.apiName = (new modulInstance() as any)._classDebugName as string;
+  }
+
+  public commonRquest<T = object>(
+    method: "OPTIONS" | "GET" | "HEAD" | "POST" | "PUT" | "DELETE", 
+    url: string = `/${this.subResKey}`, 
+    apiName: string,
+    data?: Record<string, unknown>, 
+    search: SearchParams = {},
+    querys: QueryParams = {}, 
+    appendParams: string[] = [],
+    prependParams: string[] = [],
+    model?: new () => T,
+  ) : Promise<RequestApiResult<T>> {
+    return this.request<T>(
+      buildParams(prependParams) + url + buildParams(appendParams) + buildQueryParams(querys) + buildSearchParams(search),
+      {},
+      {
+        method,
+        data
+      },
+      apiName,
+      model
+    ) as any;
+  }
+  
+  /**
+   * 请求下拉框数据
+   * @param id 资源ID
+   */
+  public getList(search: SearchParams = {}, querys: QueryParams = {}, appendParams: string[] = [ 'list' ], prependParams: string[] = []) : Promise<RequestApiResult<CommonArrayResult<T>>> {
+    return this.commonRquest<KeyValue[]>(
+      'GET',
+      `/${this.subResKey}`,
+      `获取 ${this.apiName} 列表`,
+      undefined,
+      search,
+      querys,
+      appendParams,
+      prependParams
+    ).then((rs) => {
+      assertNotNull(rs.data, "rs.data is null! ");
+      return rs.cloneWithOtherData(new CommonArrayResult<T>().fromData(rs.data, undefined, this.modulInstance));
+    });
+  }
+  /**
+   * 请求下拉框数据 (无getList时的兼容, 默认分页大小100)
+   */
+  public getListPool(search: string, page: number = 1, size: number = 100, otherSearch: SearchArrayParams = {}, labelKey = 'name', querys: QueryParams = {}, appendParams: string[] = [ 'index' ], prependParams: string[] = []) : Promise<CommonListResult> {
+    return new Promise<CommonListResult>((resolve, reject) => {
+      if (search)
+        otherSearch[labelKey] = search;
+      this.getPage({
+        page,
+        size,
+        search: otherSearch
+      }, querys, appendParams, prependParams).then((d) => {
+        resolve(new CommonListResult().fromData(d.requireData().items));
+      }).catch(e => reject(e));
+    });
+  }
+  /**
+   * 请求分页
+   * @param page 页码,1 开始。默认1
+   * @param pageSize 页大小。默认10
+   * @param searchParams 搜索筛选键值
+   */
+  public getPage(searchParams: SearchParams = { page: 1, size: 10 }, querys: QueryParams = {}, appendParams: string[] = [ '' ], prependParams: string[] = []) : Promise<RequestApiResult<CommonPageResult<T>>> {
+    const page = searchParams.page || 1;
+    const size = searchParams.size || 10;
+    return this.commonRquest<{
+      allCount?: number | undefined;
+      allPage?: number | undefined;
+      items?: KeyValue[] | null | undefined;
+    }>(
+      'GET',
+      `/${this.subResKey}/${page}/${size}`,
+      `获取 ${this.apiName} 分页列表`,
+      undefined,
+      searchParams,
+      querys,
+      appendParams,
+      prependParams
+    ).then((rs) => {
+      assertNotNull(rs.data, "rs.data is null! ");
+      const data = rs.cloneWithOtherData(new CommonPageResult<T>().fromData(rs.data, page, size, this.modulInstance));
+      if (RequestApiConfig.getConfig().EnableApiDataLog)
+        console.log('[Debug] transformPageResult: ', data);
+      return data;
+    });
+  }
+  /**
+   * 请求资源
+   * @param id 资源ID
+   */
+  public getItem(id: number, querys: QueryParams = {}, appendParams: string[] = [], prependParams: string[] = []) : Promise<RequestApiResult<T>> {
+    return this.commonRquest(
+      'GET',
+      `/${this.subResKey}/${id}`,
+      `获取 ${this.apiName} 详情`,
+      undefined,
+      undefined,
+      querys,
+      appendParams,
+      prependParams,
+      this.modulInstance
+    );
+  }
+  /**
+   * 添加资源
+   * @param data 资源数据
+   */
+  public addItem(data: T, querys: QueryParams = {}, appendParams: string[] = [ '' ], prependParams: string[] = []) : Promise<RequestApiResult<number>> {
+    return this.commonRquest<number>(
+      'POST',
+      `/${this.subResKey}`,
+      `新增 ${this.apiName}`,
+      data.toServerSide(),
+      undefined,
+      querys,
+      appendParams,
+      prependParams
+    );
+  }
+  /**
+   * 更新资源
+   * @param id 资源ID
+   * @param data 资源数据
+   */
+  public updateItem(id: number, data: T, querys: QueryParams = {}, appendParams: string[] = [ '' ], prependParams: string[] = []): Promise<RequestApiResult<void>> {
+    return this.commonRquest<void>(
+      'PUT',
+      `/${this.subResKey}/${id}`,
+      `更新 ${this.apiName}`,
+      data.toServerSide(),
+      undefined,
+      querys,
+      appendParams,
+      prependParams
+    );
+  }
+  /**
+   * 更新资源状态
+   * @param id 资源ID
+   * @param state 状态
+   */
+  public toggleItemState(id: number, state: boolean|number, querys: QueryParams = {}, appendParams: string[] = [ 'toggle-state' ], prependParams: string[] = []): Promise<RequestApiResult<void>> {
+    return this.commonRquest<void>(
+      'POST',
+      `/${this.subResKey}/${id}`,
+      `更新 ${this.apiName} 状态为 ${state}`,
+      { state: typeof state === 'boolean' ? (state ? 1 : 0) : state },
+      undefined,
+      querys,
+      appendParams,
+      prependParams
+    );
+  }
+  /**
+   * 删除资源
+   * @param id 资源ID
+   */
+  public deleteItem(id: number, querys: QueryParams = {}, appendParams: string[] = [ '' ], prependParams: string[] = []) : Promise<RequestApiResult<void>>  { 
+    return this.commonRquest<void>(
+      'DELETE',
+      `/${this.subResKey}/${id}`,
+      `删除 ${this.apiName}`,
+      undefined,
+      undefined,
+      querys,
+      appendParams,
+      prependParams
+    );
+  }
+}
+
+/**
+ * 分页的返回标准
+ */
+export type PageReturn<T extends DataModel> = Promise<RequestApiResult<CommonPageResult<T>>>;
+/**
+ * 分页的API标准
+ */
+export type PageFunction<T extends DataModel> = (searchParams: SearchParams, querys?: QueryParams, appendParams?: string[], prependParams?: string[]) => PageReturn<T>;
+
+/**
+ * 排序标识转换到后端
+ */
+export function transformOrder(or : string) : string {
+  switch(or) {
+    case 'ascend': return 'asc';//升序
+    case 'descend': return 'desc';//降序
+  }
+  return or;
+}
+/**
+ * 搜索参数的转换到后端
+ * @param params 
+ * @param expandSearchs 把搜索参数分离成为单独的query
+ * @returns 
+ */
+export function buildSearchParams(params: SearchParams) : string {
+  let rs = '';
+  if(params) {
+    if(params.search && Object.keys(params.search).length > 0) {
+      const newO = {} as { [index: string]: SearchableType };
+      for(const key in params.search) {
+        const v = params.search[key]
+        if(typeof v === 'undefined' || v === null || v === "") continue;
+        if(v instanceof Array && (v.length === 0 || ArrayUtils.isAllNullOrEmpty(v))) continue;
+          
+        newO[key] = v as SearchableType;
+      }
+      if(Object.keys(newO).length > 0) {
+        rs += '&search=' + encodeURIComponent(JSON.stringify(newO));
+      }
+    }
+    if(params.sort)
+      rs += '&sort=' + encodeURIComponent(JSON.stringify(params.sort));
+
+    if(params.filter && Object.keys(params.filter).length > 0)
+      rs += '&filter=' + encodeURIComponent(JSON.stringify(params.filter));
+  }
+  return rs;
+}

+ 134 - 0
src/api/restful/types.ts

@@ -0,0 +1,134 @@
+/**
+ * 通用类型定义
+ * 
+ * 功能介绍:
+ *    为 API 提供了一些通用结构定义。
+ * 
+ * Author: imengyu
+ * Date: 2025/12/27
+ * 
+ * Copyright (c) 2021 imengyu.top. Licensed under the MIT License. 
+ * See License.txt in the project root for license information.
+ */
+
+import { DataModel, type KeyValue } from '@imengyu/js-request-transform';
+
+/**
+ * 通用分页返回结构定义
+ */
+export class CommonArrayResult<T extends DataModel> extends DataModel {
+  /**
+   * 数据
+   */
+  items: T[] = [];
+  /**
+   * 系统中一共有多少条数据
+   */
+  total = 0;
+
+  constructor() {
+    super(CommonArrayResult);
+  }
+
+  fromData(data: KeyValue[] | undefined | null, total: number|undefined, dataModel: new () => T): this {
+    this.items = data?.map((item) => new dataModel().fromServerSide(item) as T) || [];
+    this.total = total || this.items.length;
+    return this;
+  }
+}
+
+/**
+ * 通用下拉框返回结构定义
+ */
+export class CommonListResult extends DataModel {
+  /**
+   * 数据
+   */
+  items: {
+    text: string;
+    value: number;
+    raw: KeyValue;
+  }[] = [];
+  
+  constructor() {
+    super(CommonListResult);
+  }
+
+  fromData(data: KeyValue[] | undefined | null): this {
+    this.items = data?.map((item) => ({
+      text: item.label as string,
+      value: item.value as number,
+      raw: item,
+    })) || [];
+    return this;
+  }
+}
+/**
+ * 通用分页返回结构定义
+ */
+export class CommonPageResult<T extends DataModel> extends DataModel {
+  /**
+   * 当前页数据
+   */
+  items: T[] = [];
+  /**
+   * 当前页码
+   */
+  pageIndex = 0;
+  /**
+   * 当前页的大小
+   */
+  pageSize = 0;
+  /**
+   * 系统中一共有多少条数据
+   */
+  allCount = 0;
+  /**
+   * 系统中一共有多少页
+   */
+  allPage = 0;
+  /**
+   * 指示当前页是不是空的
+   */
+  empty = true;
+
+  constructor() {
+    super(CommonPageResult);
+  }
+
+  fromData(
+    data: {
+      allCount?: number,
+      allPage?: number,
+      items?: KeyValue[] | undefined | null,
+    }, 
+    page: number,
+    size: number,
+    dataModel: new () => T
+  ): this {
+    const listFromServer = data.items as unknown as Array<unknown>;
+    const list = new Array<T>();
+    if(listFromServer instanceof Array) {
+      let index = 1;
+      listFromServer.forEach((i) => {
+        const ii = new dataModel().fromServerSide(i as KeyValue); 
+        ii.index = index++;
+        list.push(ii as T);
+      });
+    }
+
+    this.items = list;
+    this.pageIndex = page;
+    this.pageSize = size;
+    this.allCount = data.allCount as number;
+    this.allPage = data.allPage as number;
+    this.empty = list.length == 0;
+
+    return this;
+  }
+}
+
+/**
+ * 加载状态
+ */
+export type LoadStatus = 'notload'|'success'|'failed'|'loading' ;

+ 27 - 0
src/api/restful/utils.ts

@@ -0,0 +1,27 @@
+/**
+ * 请求工具所使用的工具函数
+ *
+ * 功能介绍:
+ *  提供了一些处理工具函数,方便使用。
+ *
+ * Author: imengyu
+ * Date: 2022/03/25
+ *
+ * Copyright (c) 2021 imengyu.top. Licensed under the MIT License.
+ * See License.txt in the project root for license information.
+ */
+
+/* eslint-disable no-bitwise */
+
+/**
+ * 移除URL的地址部分,只保留路径
+ * @param str 原URL
+ * @returns 
+ */
+export function removeUrlOrigin(str: string) : string {
+  if(str.startsWith('http://') || str.startsWith('https://') || str.startsWith('fts://') || str.startsWith('ftps://')) {
+    str = str.substr(str.indexOf('://') + 3);
+    str = str.substr(str.indexOf('/') + 1);
+  } 
+  return str;
+}

+ 9 - 0
src/components/form/Uploader.vue

@@ -47,6 +47,7 @@
                 :itemMaskTextStyle="itemMaskTextStyle"
                 :defaultSource="itemDefaultSource"
                 :itemSize="itemSize"
+                :itemExtraButtons="itemExtraButtons"
                 @click="() => onItemPress(item)"
                 @delete="() => onItemDeletePress(item)"
               />
@@ -170,6 +171,14 @@ export interface UploaderProps {
    */
   itemMaskStyle?: ViewStyle;
   /**
+   * 条目的自定义额外按钮,仅当listType为list时有效
+   * @default []
+   */
+  itemExtraButtons?: {
+    icon: string;
+    onClick: (item: UploaderItem) => void;
+  }[];
+  /**
    * 初始列表中的条目
    * @default []
    */

+ 9 - 0
src/components/form/UploaderListItem.vue

@@ -74,6 +74,11 @@
       <ActivityIndicator v-else-if="item.state === 'uploading'" :size="45" />
       <Width :size="20" />
       <IconButton v-if="showDelete" icon="trash" @click.stop="emit('delete')" />
+      <IconButton v-for="button in itemExtraButtons" 
+        :key="button.icon" 
+        :icon="button.icon" 
+        @click.stop="button.onClick(item)" 
+      />
     </FlexRow>
   </Touchable>
 </template>
@@ -117,6 +122,10 @@ export interface UploaderListItemProps {
   showDelete: boolean;
   defaultSource: string | undefined;
   isListStyle: boolean;
+  itemExtraButtons?: {
+    icon: string;
+    onClick: (item: UploaderItem) => void;
+  }[];
 }
 
 const props = defineProps<UploaderListItemProps>();

+ 213 - 74
src/pages/collect/assessment/components/EvaluationFormBlock.vue

@@ -3,78 +3,82 @@
     ref="formRef"
     :model="currentForm"
     :options="mergedFormOptions"
-  >
-    <template #insertion="{ data }">
-      <FlexCol v-if="data.name === 'insertCheckList'">
-        <Divider />
-        <H3>自查项目选择</H3>
-        <Height :height="30" />
-        <FlexCol gap="gap.md">
-          <FlexCol v-for="(item, index) in checkItemList" :key="item.id" gap="gap.md">
-            <FlexRow justify="space-between" align="center">
-              <Text fontConfig="subTitleText" :text="`${index + 1}. ${item.name}`" />
-              <Tag :text="getCheckModeText(item.checkType)" />
-            </FlexRow>
-            <FlexCol v-if="item.checkType == 3" gap="gap.sm">
-              <FlexRow v-for="child in item.children" :key="child.id" justify="space-between">
-                <CheckBox
-                  :disabled="readonly"
-                  :text="`${child.name} (${child.points}分)`"
-                  :modelValue="hasCheckedItem(child.id)" 
-                  @update:modelValue="setCheckedItem(item as CheckItemInfo, child as CheckItemInfo, $event)" 
-                />
-                <Stepper 
-                  v-if="hasCheckedItem(child.id)"
-                  :disabled="readonly"
-                  :min="0"
-                  :max="20"
-                  :step="1"
-                  :modelValue="getCheckedItemCount(child.id) ?? 0" 
-                  addonAfter="次"
-                  @update:modelValue="setCheckedItem(item as CheckItemInfo, child as CheckItemInfo, $event)" 
-                />
-                <view v-else></view>
-              </FlexRow>
-            </FlexCol>
-            <FlexCol v-else gap="gap.sm">
-              <CheckBox 
-                v-for="child in item.children" :key="child.id"
-                :disabled="readonly"
-                :text="`${child.name} (${child.points}分)`"
-                :modelValue="hasCheckedItem(child.id)" 
-                @update:modelValue="setCheckedItem(item as CheckItemInfo, child as CheckItemInfo, $event)" 
-              />
-            </FlexCol>
-            <FlexCol gap="gap.sm">
-              <Text
-                fontConfig="subText"
-                color="text.second"
-                :text="readonly ? '佐证资料' : '佐证材料上传'"
-              />
-              <Text
-                v-if="!currentForm.id"
-                fontConfig="subText"
-                color="text.second"
-                text="请先保存评估表后再上传佐证资料"
-              />
-              <Uploader
-                v-else
-                :ref="(el) => bindUploaderRef(item.id, el)"
-                :upload="getAnnexUpload(item.id)"
-                :max-upload-count="100"
-                :max-file-size="20 * 1024 * 1024"
-                :group-type="true"
-                chooseType="file"
-                list-type="list"
-                :readonly="readonly"
-              />
-            </FlexCol>
-          </FlexCol>
+  />
+  
+  <FlexCol>
+    <Divider />
+    <H3>自查项目选择</H3>
+    <Height :height="30" />
+    <FlexCol gap="gap.md">
+      <FlexCol v-for="(item, index) in checkItemList" :key="item.id" gap="gap.md">
+        <FlexRow justify="space-between" align="center">
+          <Text fontConfig="subTitleText" :text="`${index + 1}. ${item.name}`" />
+          <Tag :text="getCheckModeText(item.checkType)" />
+        </FlexRow>
+        <FlexCol v-if="item.checkType == 3" gap="gap.sm">
+          <FlexRow v-for="child in item.children" :key="child.id" justify="space-between">
+            <CheckBox
+              :disabled="readonly"
+              :text="`${child.name} (${child.points}分)`"
+              :modelValue="hasCheckedItem(child.id)" 
+              @update:modelValue="setCheckedItem(item as CheckItemInfo, child as CheckItemInfo, $event)" 
+            />
+            <Stepper 
+              v-if="hasCheckedItem(child.id)"
+              :disabled="readonly"
+              :min="0"
+              :max="20"
+              :step="1"
+              :modelValue="getCheckedItemCount(child.id) ?? 0" 
+              addonAfter="次"
+              @update:modelValue="setCheckedItem(item as CheckItemInfo, child as CheckItemInfo, $event)" 
+            />
+            <view v-else></view>
+          </FlexRow>
+        </FlexCol>
+        <FlexCol v-else gap="gap.sm">
+          <CheckBox 
+            v-for="child in item.children" :key="child.id"
+            :disabled="readonly"
+            :text="`${child.name} (${child.points}分)`"
+            :modelValue="hasCheckedItem(child.id)" 
+            @update:modelValue="setCheckedItem(item as CheckItemInfo, child as CheckItemInfo, $event)" 
+          />
+        </FlexCol>
+        <FlexCol gap="gap.sm" border="1px solid #e0e0e0" radius="radius.md" padding="space.md">
+          <Text
+            fontConfig="subText"
+            color="text.second"
+            :text="readonly ? '佐证资料' : '佐证材料上传'"
+          />
+          <Text
+            v-if="!currentForm.id"
+            fontConfig="subText"
+            color="text.second"
+            text="请先保存评估表后再上传佐证资料"
+          />
+          <Uploader
+            v-show="currentForm.id"
+            :ref="(el) => bindUploaderRef(item.id, el)"
+            :upload="getAnnexUpload(item.id)"
+            :max-upload-count="100"
+            :max-file-size="20 * 1024 * 1024"
+            :group-type="true"
+            chooseType="file"
+            list-type="list"
+            :itemExtraButtons="readonly ? [] : [{ icon: 'edit', onClick: (item2) => editAnnexDesc(item.id, item2) }]"
+            :readonly="readonly"
+          />
         </FlexCol>
       </FlexCol>
-      <slot v-else name="formCeil" :data="data" />
-    </template>
-  </DynamicForm>
+    </FlexCol>
+  </FlexCol>
+
+  <DynamicForm
+    ref="formRef2"
+    :model="currentForm"
+    :options="mergedFormOptionsEnd"
+  />
 </template>
 
 <script setup lang="ts">
@@ -88,6 +92,7 @@ import CheckBox from '@/components/form/CheckBox.vue';
 import Stepper from '@/components/form/Stepper.vue';
 import Uploader, { type UploaderInstance } from '@/components/form/Uploader.vue';
 import AssessmentContentApi, { getCheckAnnexType, SelfAssessmentCheckItemAnswer, type CheckItemInfo, type SelfAssessmentDetail } from '@/api/collect/AssessmentContent';
+import AgentWorkApi from '@/api/agent/AgentWorks';
 import type { IDynamicFormOptions, IDynamicFormRef } from '@/components/dynamic';
 import { ArrayUtils } from '@imengyu/imengyu-utils';
 import Tag from '@/components/display/Tag.vue';
@@ -95,11 +100,12 @@ import Divider from '@/components/display/Divider.vue';
 import Height from '@/components/layout/space/Height.vue';
 import { useAliOssUploadCo } from '@/common/components/upload/AliOssUploadCo';
 import { getMimeType } from '@/common/components/upload/mimes';
-import { stringUrlToUploaderItem, type UploaderAction } from '@/components/form/Uploader';
+import { stringUrlToUploaderItem, type UploaderAction, type UploaderItem } from '@/components/form/Uploader';
 
 const props = withDefaults(defineProps<{
   currentForm: SelfAssessmentDetail;
   formOptions: IDynamicFormOptions;
+  formOptionsEnd: IDynamicFormOptions;
   checkItemList: CheckItemInfo[];
   currentFormCheckItems: SelfAssessmentCheckItemAnswer[];
   /** 管理员只读查看 / 审核页 */
@@ -109,6 +115,7 @@ const props = withDefaults(defineProps<{
 });
 
 const formRef = ref<IDynamicFormRef | null>(null);
+const formRef2 = ref<IDynamicFormRef | null>(null);
 const uploaderRefMap = new Map<number, UploaderInstance | null>();
 const uploadCoMap = new Map<number, (action: UploaderAction) => (() => void)>();
 
@@ -119,7 +126,13 @@ const mergedFormOptions = computed<IDynamicFormOptions>(() => ({
     disabled: props.readonly,
   },
 }));
-
+const mergedFormOptionsEnd = computed<IDynamicFormOptions>(() => ({
+  ...props.formOptionsEnd,
+  formAdditionaProps: {
+    ...props.formOptionsEnd.formAdditionaProps,
+    disabled: props.readonly,
+  },
+}));
 
 function getCheckModeText(checkMode: number) {
   switch (checkMode) {
@@ -172,7 +185,118 @@ function setCheckedItem(checkItem: CheckItemInfo, childItem: CheckItemInfo, coun
     ArrayUtils.remove(props.currentFormCheckItems, item);
 }
 
+
+function formatAnnexDisplayName(desc: string | null | undefined, name: string) {
+  const descText = (desc ?? '').trim();
+  if (!descText)
+    return name;
+  return `${descText}(${name})`;
+}
+
+function buildAnnexDescInputDoc() {
+  const content = props.currentForm.content;
+  if (!content)
+    return '';
+  const lines: string[] = [];
+  for (let i = 0; i <= 6; i++) {
+    const value = String(content[`item${i}`] ?? '').trim();
+    if (!value)
+      continue;
+    lines.push(`第${i + 1}项:${value}`);
+  }
+  return lines.join('\n');
+}
+
+type AnnexUploaderItem = UploaderItem & {
+  annexMeta?: {
+    id: number;
+    rawName: string;
+    desc: string;
+    url: string;
+    type: number;
+    mimetype?: string|null;
+    attachId?: number|null;
+    fileSize?: number|null;
+  };
+};
+
+function createAnnexUploaderItem(item: {
+  id: number;
+  name: string;
+  desc?: string|null;
+  url: string;
+  type: number;
+  mimetype?: string|null;
+  attachId?: number|null;
+  fileSize?: number|null;
+}) {
+  const displayName = formatAnnexDisplayName(item.desc, item.name);
+  const uploaderItem = stringUrlToUploaderItem(item.url, displayName) as AnnexUploaderItem;
+  uploaderItem.annexMeta = {
+    id: item.id,
+    rawName: item.name,
+    desc: item.desc ?? '',
+    url: item.url,
+    type: item.type,
+    mimetype: item.mimetype,
+    attachId: item.attachId,
+    fileSize: item.fileSize,
+  };
+  return uploaderItem;
+}
+
+function promptAnnexDesc(initialDesc: string, title: string) {
+  return new Promise<string|null>((resolve) => {
+    uni.showModal({
+      title,
+      editable: true,
+      placeholderText: '请输入佐证资料说明(可选)',
+      content: initialDesc,
+      success: (res) => {
+        if (!res.confirm) {
+          resolve(null);
+          return;
+        }
+        resolve((res.content ?? '').trim());
+      },
+      fail: () => resolve(null),
+    });
+  });
+}
+
+async function editAnnexDesc(itemId: number, item: UploaderItem, isAfterUpload = false) {
+  const formId = props.currentForm.id;
+  if (!formId)
+    return;
+  const annexItem = item as AnnexUploaderItem;
+  const meta = annexItem.annexMeta;
+  if (!meta?.id) {
+    uni.showToast({ title: '附件信息不完整,无法编辑说明', icon: 'none' });
+    return;
+  }
+  const nextDesc = await promptAnnexDesc(
+    meta.desc ?? '',
+    isAfterUpload ? '上传成功,请补充附件说明' : '编辑附件说明',
+  );
+  if (nextDesc === null)
+    return;
+  await AssessmentContentApi.saveAnnex({
+    id: meta.id,
+    name: meta.rawName,
+    formId,
+    itemId,
+    url: meta.url,
+    type: meta.type,
+    desc: nextDesc,
+    mimetype: meta.mimetype ?? undefined,
+    attachId: meta.attachId ?? undefined,
+    fileSize: meta.fileSize ?? undefined,
+  });
+  await loadAnnexListByItem(itemId);
+}
+
 function bindUploaderRef(itemId: number, el: unknown) {
+  console.log('bindUploaderRef', itemId, el);
   uploaderRefMap.set(itemId, (el as UploaderInstance | null) || null);
   if (el)
     loadAnnexListByItem(itemId);
@@ -187,10 +311,15 @@ function getAnnexUpload(itemId: number) {
     if (!formId)
       return;
     const mimetype = getMimeType(item.filePath);
+    const inputDoc = buildAnnexDescInputDoc();
+    const desc = inputDoc
+      ? await AgentWorkApi.generateFileDescByInputDoc(inputDoc, item.name)
+      : '';
     await AssessmentContentApi.saveAnnex({
       name: item.name,
       formId,
       itemId,
+      desc,
       url: res,
       type: getCheckAnnexType(mimetype),
       mimetype,
@@ -199,6 +328,12 @@ function getAnnexUpload(itemId: number) {
         : undefined,
     });
     await loadAnnexListByItem(itemId);
+    const uploaded = uploaderRefMap.get(itemId)?.getList().find((listItem) => {
+      const meta = (listItem as AnnexUploaderItem).annexMeta;
+      return meta?.url === res;
+    });
+    if (uploaded)
+      await editAnnexDesc(itemId, uploaded, true);
   });
   uploadCoMap.set(itemId, uploadCo);
   return uploadCo;
@@ -207,6 +342,7 @@ function getAnnexUpload(itemId: number) {
 async function loadAnnexListByItem(itemId: number) {
   const formId = props.currentForm.id;
   const uploaderRef = uploaderRefMap.get(itemId);
+  console.log('loadAnnexListByItem', itemId);
   if (!uploaderRef)
     return;
   if (!formId) {
@@ -214,7 +350,7 @@ async function loadAnnexListByItem(itemId: number) {
     return;
   }
   const annexList = await AssessmentContentApi.getAnnexList(formId, itemId);
-  uploaderRef.setList(annexList.data.map((item) => stringUrlToUploaderItem(item.url, item.name)));
+  uploaderRef.setList(annexList.data.map((item) => createAnnexUploaderItem(item)));
 }
 
 async function reloadAllAnnexList() {
@@ -226,12 +362,15 @@ async function validate() {
   if (props.readonly)
     return;
   await formRef.value?.validate();
+  await formRef2.value?.validate();
 }
 
 watch(
   () => [props.currentForm.id, props.checkItemList.map((item) => item.id).join(',')],
   () => {
-    reloadAllAnnexList();
+    setTimeout(() => {
+      reloadAllAnnexList();
+    }, 2000);
   },
   { immediate: true },
 );

+ 2 - 0
src/pages/collect/assessment/components/SelfAssessmentFormDisplay.vue

@@ -4,6 +4,7 @@
       ref="blockRef"
       :current-form="currentForm"
       :form-options="formOptions"
+      :form-options-end="formOptionsEnd"
       :check-item-list="checkItemList"
       :current-form-check-items="currentFormCheckItems"
       :readonly="readonly"
@@ -30,6 +31,7 @@ import Text from '@/components/basic/Text.vue';
 const props = withDefaults(defineProps<{
   currentForm: SelfAssessmentDetail;
   formOptions: IDynamicFormOptions;
+  formOptionsEnd: IDynamicFormOptions;
   checkItemList: CheckItemInfo[];
   currentFormCheckItems: SelfAssessmentCheckItemAnswer[];
   readonly?: boolean;

+ 3 - 1
src/pages/collect/assessment/evaluation-form-review.vue

@@ -14,6 +14,7 @@
             <SelfAssessmentFormDisplay
               :current-form="(currentForm as SelfAssessmentDetail)"
               :form-options="formOptions"
+              :form-options-end="formOptionsEnd"
               :check-item-list="(checkItemList as CheckItemInfo[])"
               :current-form-check-items="(currentFormCheckItems as SelfAssessmentCheckItemAnswer[])"
               :readonly="true"
@@ -97,7 +98,8 @@ import { useLoadQuerys } from '@/components/composeabe/LoadQuerys';
 import { useAuthStore } from '@/store/auth';
 
 const signUploadCo = useImageSimpleUploadCo();
-const formOptions = buildSelfAssessmentFormOptions(signUploadCo);
+const formOptions = buildSelfAssessmentFormOptions(signUploadCo, 'start');
+const formOptionsEnd = buildSelfAssessmentFormOptions(signUploadCo, 'end');
 
 const authStore = useAuthStore();
 const currentUserGroups = computed(() => authStore.userInfo?.adminGroup || []);

+ 3 - 1
src/pages/collect/assessment/evaluation-form.vue

@@ -16,6 +16,7 @@
               ref="blockRef"
               :current-form="(currentForm as SelfAssessmentDetail)"
               :form-options="formOptions"
+              :form-options-end="formOptionsEnd"
               :check-item-list="(checkItemList as CheckItemInfo[])"
               :current-form-check-items="(currentFormCheckItems as SelfAssessmentCheckItemAnswer[])"
               :readonly="false"
@@ -119,7 +120,8 @@ const currentYear = new Date().getFullYear();
 
 const blockRef = ref<InstanceType<typeof SelfAssessmentFormDisplay> | null>(null);
 const signUploadCo = useImageSimpleUploadCo();
-const formOptions = buildSelfAssessmentFormOptions(signUploadCo);
+const formOptions = buildSelfAssessmentFormOptions(signUploadCo, 'start');
+const formOptionsEnd = buildSelfAssessmentFormOptions(signUploadCo, 'end');
 
 const checkItemList = ref<CheckItemInfo[]>([]);
 const levelTitle = computed(() => {

+ 3 - 6
src/pages/collect/assessment/evaluationFormOptions.ts

@@ -7,6 +7,7 @@ import { useImageSimpleUploadCo } from '@/common/components/upload/ImageUploadCo
 /** 与 `evaluation-form` 页一致的动态表单配置(供编辑页与只读展示复用) */
 export function buildSelfAssessmentFormOptions(
   signUpload: ReturnType<typeof useImageSimpleUploadCo>,
+  type: 'start' | 'end',
 ): IDynamicFormOptions {
   return {
     formAdditionaProps: {
@@ -14,7 +15,7 @@ export function buildSelfAssessmentFormOptions(
       inputFlex: 8,
     },
     formItems: [
-      {
+      type === 'start' ? {
         type: 'flat-group',
         label: '传承人自查评估',
         name: 'selfAssessmentGroup',
@@ -193,11 +194,7 @@ export function buildSelfAssessmentFormOptions(
             ],
           },
         ],
-      },
-      {
-        type: 'insertion',
-        name: 'insertCheckList',
-      },
+      } : 
       {
         type: 'flat-group',
         label: '传承人自查评估',