@liujitcn/kratos-admin-system 0.0.50 → 0.0.52

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +3 -3
  2. package/dist/package/declarations/src/api/system/admin/v1/base_redact_output_policy.d.ts +4 -1
  3. package/dist/package/declarations/src/api/system/admin/v1/base_redact_storage_policy.d.ts +3 -1
  4. package/dist/package/declarations/src/components/tenant-project/TenantProjectManager.vue.d.ts +1 -2
  5. package/dist/package/declarations/src/components/tenant-project/tenant-project-manager-data.d.ts +10 -0
  6. package/dist/package/declarations/src/components/tenant-project/tenant-project-manager.d.ts +8 -3
  7. package/dist/package/declarations/src/index.d.ts +1 -1
  8. package/dist/package/declarations/src/locales/generated.d.ts +24 -0
  9. package/dist/package/declarations/src/rpc/system/admin/v1/base_redact_output_policy.d.ts +8 -0
  10. package/dist/package/declarations/src/rpc/system/admin/v1/base_redact_storage_policy.d.ts +25 -0
  11. package/dist/package/declarations/src/views/ai/chat/components/speech-recognition.d.ts +37 -0
  12. package/dist/package/src/api/system/admin/v1/base_redact_output_policy.ts +7 -0
  13. package/dist/package/src/api/system/admin/v1/base_redact_storage_policy.ts +7 -0
  14. package/dist/package/src/components/tenant-project/TenantProjectManager.vue +77 -60
  15. package/dist/package/src/components/tenant-project/tenant-project-manager-data.ts +39 -1
  16. package/dist/package/src/components/tenant-project/tenant-project-manager.ts +19 -4
  17. package/dist/package/src/index.ts +2 -0
  18. package/dist/package/src/locales/en-US.json +6 -0
  19. package/dist/package/src/locales/ja-JP.json +6 -0
  20. package/dist/package/src/locales/zh-CN.json +6 -0
  21. package/dist/package/src/locales/zh-TW.json +6 -0
  22. package/dist/package/src/rpc/system/admin/v1/base_redact_output_policy.ts +9 -0
  23. package/dist/package/src/rpc/system/admin/v1/base_redact_storage_policy.ts +30 -0
  24. package/dist/package/src/views/ai/chat/components/XSender.vue +18 -61
  25. package/dist/package/src/views/ai/chat/components/speech-recognition.ts +177 -0
  26. package/dist/package/src/views/base/job/index.vue +82 -158
  27. package/dist/package/src/views/base/login-policy/index.vue +12 -6
  28. package/dist/package/src/views/base/redact-output-policy/index.vue +19 -7
  29. package/dist/package/src/views/base/redact-rule/index.vue +70 -53
  30. package/dist/package/src/views/base/redact-storage-policy/index.vue +18 -7
  31. package/dist/package/src/views/base/role/index.vue +1 -1
  32. package/dist/package/src/views/base/{project-grant → tenant-project-grant}/index.vue +3 -1
  33. package/package.json +2 -2
  34. /package/dist/package/declarations/src/components/{tenant-project → tenant-project-grant}/ProjectGrantDialog.vue.d.ts +0 -0
  35. /package/dist/package/declarations/src/views/base/{project-grant → tenant-project-grant}/index.vue.d.ts +0 -0
  36. /package/dist/package/src/components/{tenant-project → tenant-project-grant}/ProjectGrantDialog.vue +0 -0
@@ -1,5 +1,11 @@
1
1
  import type { BaseTenantProject } from "@liujitcn/kratos-admin-system/rpc/system/admin/v1/base_tenant_project";
2
2
 
3
+ /** 租户项目表格列的最小结构,用于在不依赖表格组件的情况下排列扩展列。 */
4
+ type TenantProjectColumnLike = {
5
+ prop?: string;
6
+ type?: string;
7
+ };
8
+
3
9
  /** 租户项目业务数据查询键。 */
4
10
  export interface TenantProjectKey {
5
11
  /** 租户 ID。 */
@@ -11,13 +17,45 @@ export interface TenantProjectKey {
11
17
  /** 租户项目扩展数据,外层键由租户 ID 和项目 ID 组成。 */
12
18
  export type TenantProjectExtraData = Record<string, Record<string, unknown>>;
13
19
 
20
+ /** 按基础字段位置排列租户项目管理的扩展列。 */
21
+ export function arrangeTenantProjectColumns<T extends TenantProjectColumnLike>(
22
+ baseColumns: T[],
23
+ extraColumns: Array<T & { after?: string }>
24
+ ): T[] {
25
+ const baseKeys = new Set(baseColumns.map(column => column.prop ?? column.type));
26
+ const extrasByAnchor = new Map<string | undefined, T[]>();
27
+ for (const extraColumn of extraColumns) {
28
+ const { after, ...column } = extraColumn;
29
+ const anchor = after && baseKeys.has(after) ? after : "remark";
30
+ const columns = extrasByAnchor.get(anchor) ?? [];
31
+ columns.push(column as T);
32
+ extrasByAnchor.set(anchor, columns);
33
+ }
34
+
35
+ const result: T[] = [];
36
+ for (const baseColumn of baseColumns) {
37
+ result.push(baseColumn);
38
+ const anchor = baseColumn.prop ?? baseColumn.type;
39
+ const columns = extrasByAnchor.get(anchor);
40
+ if (!columns) continue;
41
+ result.push(...columns);
42
+ extrasByAnchor.delete(anchor);
43
+ }
44
+ const remaining: T[] = [];
45
+ for (const columns of extrasByAnchor.values()) remaining.push(...columns);
46
+ return [...result, ...remaining];
47
+ }
48
+
14
49
  /** 创建租户项目扩展数据的稳定键。 */
15
50
  export function tenantProjectKey(tenantId: number, projectId: number): string {
16
51
  return `${tenantId}:${projectId}`;
17
52
  }
18
53
 
19
54
  /** 将外部业务字段合并到项目行,同时保护公共项目字段。 */
20
- export function mergeTenantProjectExtraData(project: BaseTenantProject, extra?: Record<string, unknown>): BaseTenantProject & Record<string, unknown> {
55
+ export function mergeTenantProjectExtraData(
56
+ project: BaseTenantProject,
57
+ extra?: Record<string, unknown>
58
+ ): BaseTenantProject & Record<string, unknown> {
21
59
  if (!extra) return { ...project } as BaseTenantProject & Record<string, unknown>;
22
60
 
23
61
  const protectedFields = new Set(["id", "tenant_id", "code", "name", "status", "sort", "remark", "created_at", "updated_at"]);
@@ -1,8 +1,17 @@
1
- import type { BaseTenantProject, PageBaseTenantProjectRequest } from "@liujitcn/kratos-admin-system/rpc/system/admin/v1/base_tenant_project";
1
+ import type {
2
+ BaseTenantProject,
3
+ PageBaseTenantProjectRequest
4
+ } from "@liujitcn/kratos-admin-system/rpc/system/admin/v1/base_tenant_project";
2
5
  import type { ColumnProps, TableActionProps } from "@liujitcn/kratos-admin-core/components/ProTable/interface";
3
6
  import type { TenantProjectExtraData, TenantProjectKey } from "./tenant-project-manager-data";
4
7
 
5
- export { mergeTenantProjectExtraData, tenantProjectKey, type TenantProjectExtraData, type TenantProjectKey } from "./tenant-project-manager-data";
8
+ export {
9
+ arrangeTenantProjectColumns,
10
+ mergeTenantProjectExtraData,
11
+ tenantProjectKey,
12
+ type TenantProjectExtraData,
13
+ type TenantProjectKey
14
+ } from "./tenant-project-manager-data";
6
15
 
7
16
  /** 租户项目单条业务上下文。 */
8
17
  export interface TenantProjectContext<TData extends Record<string, unknown> = Record<string, unknown>> extends TenantProjectKey {
@@ -37,10 +46,16 @@ export type TenantProjectAction = Omit<TableActionProps, "disabled" | "hidden" |
37
46
  onClick: (context: TenantProjectContext, params?: Record<string, any>) => void | Promise<void>;
38
47
  };
39
48
 
49
+ /** 租户项目管理的扩展列配置。 */
50
+ export type TenantProjectExtraColumn = ColumnProps & {
51
+ /** 插入到指定基础字段后面,未配置时默认插入备注字段后面。 */
52
+ after?: string;
53
+ };
54
+
40
55
  /** 租户项目管理组件属性。 */
41
56
  export interface TenantProjectManagerProps {
42
- /** 追加到基础列表的表格列。 */
43
- extraColumns?: ColumnProps[];
57
+ /** 追加到基础列表的表格列,可通过 after 指定插入位置。 */
58
+ extraColumns?: TenantProjectExtraColumn[];
44
59
  /** 追加到基础操作列的业务操作。 */
45
60
  extraActions?: TenantProjectAction[];
46
61
  /** 加载当前页外部业务字段。 */
@@ -3,9 +3,11 @@ export type { AdminAiExtension } from "./ai";
3
3
  export { default as TenantProjectManager } from "./components/tenant-project/TenantProjectManager.vue";
4
4
  export {
5
5
  mergeTenantProjectExtraData,
6
+ arrangeTenantProjectColumns,
6
7
  tenantProjectKey,
7
8
  type TenantProjectAction,
8
9
  type TenantProjectContext,
10
+ type TenantProjectExtraColumn,
9
11
  type TenantProjectExtraData,
10
12
  type TenantProjectExtraDataLoadContext,
11
13
  type TenantProjectExtraDataLoader,
@@ -807,6 +807,7 @@
807
807
  "system.base.redact_rule.description.fixed_length_mask": "Replace content with a fixed character while preserving its length",
808
808
  "system.base.redact_rule.description.hash_digest": "Generate an irreversible result with a digest algorithm",
809
809
  "system.base.redact_rule.description.ip_mask": "Keep the IP prefix octets and mask the rest",
810
+ "system.base.redact_rule.description.string_mask": "Keep configured leading and trailing characters and mask the middle",
810
811
  "system.base.redact_rule.description.phone_mask": "Keep the first three and last four digits of a phone number",
811
812
  "system.base.redact_rule.description.regex_replace": "Replace matching content with a regular expression",
812
813
  "system.base.redact_rule.description.stable_uuid": "Convert text to a stable UUID",
@@ -821,6 +822,7 @@
821
822
  "system.base.redact_rule.parameter.char": "Replacement character",
822
823
  "system.base.redact_rule.parameter.keep_first": "Leading characters to keep",
823
824
  "system.base.redact_rule.parameter.keep_last": "Trailing characters to keep",
825
+ "system.base.redact_rule.parameter.min_mask": "Minimum masked characters",
824
826
  "system.base.redact_rule.parameter.keep_local_first": "Local-part characters to keep",
825
827
  "system.base.redact_rule.parameter.keep_octets": "IP octets to keep",
826
828
  "system.base.redact_rule.parameter.length": "Kept length",
@@ -834,6 +836,7 @@
834
836
  "system.base.redact_rule.template.fixed_length_mask": "Fixed-length mask",
835
837
  "system.base.redact_rule.template.hash_digest": "Hash digest",
836
838
  "system.base.redact_rule.template.ip_mask": "IP address mask",
839
+ "system.base.redact_rule.template.string_mask": "Generic string mask",
837
840
  "system.base.redact_rule.template.phone_mask": "Phone number mask",
838
841
  "system.base.redact_rule.template.regex_replace": "Regular expression replacement",
839
842
  "system.base.redact_rule.template.stable_uuid": "Stable UUID",
@@ -841,6 +844,9 @@
841
844
  "system.base.redact_rule.template.url_mask": "URL parameter mask",
842
845
  "system.base.redact_rule.title": "Redaction rules",
843
846
  "system.base.redact_rule.validation.parameters": "Enter the rule parameters",
847
+ "system.base.redact_rule.validation.code": "Enter the rule code",
848
+ "system.base.redact_rule.validation.name": "Enter the rule name",
849
+ "system.base.redact_rule.validation.rule_type": "Select the rule type",
844
850
  "system.base.redact_storage_policy.field.column_name": "Field",
845
851
  "system.base.redact_storage_policy.field.parameters": "Rule parameters",
846
852
  "system.base.redact_storage_policy.field.rule": "Redaction rule",
@@ -807,6 +807,7 @@
807
807
  "system.base.redact_rule.description.fixed_length_mask": "固定文字で置換し、元の長さを保持します",
808
808
  "system.base.redact_rule.description.hash_digest": "ダイジェストアルゴリズムで不可逆な結果を生成します",
809
809
  "system.base.redact_rule.description.ip_mask": "IP の先頭オクテットを保持し、残りをマスクします",
810
+ "system.base.redact_rule.description.string_mask": "設定した先頭と末尾の文字を保持し、中間をマスクします",
810
811
  "system.base.redact_rule.description.phone_mask": "電話番号の先頭 3 桁と末尾 4 桁を保持します",
811
812
  "system.base.redact_rule.description.regex_replace": "正規表現で一致した内容を置換します",
812
813
  "system.base.redact_rule.description.stable_uuid": "テキストを安定 UUID に変換します",
@@ -821,6 +822,7 @@
821
822
  "system.base.redact_rule.parameter.char": "置換文字",
822
823
  "system.base.redact_rule.parameter.keep_first": "先頭から保持する文字数",
823
824
  "system.base.redact_rule.parameter.keep_last": "末尾から保持する文字数",
825
+ "system.base.redact_rule.parameter.min_mask": "最小マスク文字数",
824
826
  "system.base.redact_rule.parameter.keep_local_first": "ローカル部で保持する文字数",
825
827
  "system.base.redact_rule.parameter.keep_octets": "保持するオクテット数",
826
828
  "system.base.redact_rule.parameter.length": "保持する長さ",
@@ -834,6 +836,7 @@
834
836
  "system.base.redact_rule.template.fixed_length_mask": "固定長マスク",
835
837
  "system.base.redact_rule.template.hash_digest": "ハッシュダイジェスト",
836
838
  "system.base.redact_rule.template.ip_mask": "IP アドレスマスク",
839
+ "system.base.redact_rule.template.string_mask": "汎用文字列マスク",
837
840
  "system.base.redact_rule.template.phone_mask": "電話番号マスク",
838
841
  "system.base.redact_rule.template.regex_replace": "正規表現置換",
839
842
  "system.base.redact_rule.template.stable_uuid": "安定 UUID",
@@ -841,6 +844,9 @@
841
844
  "system.base.redact_rule.template.url_mask": "URL パラメーターマスク",
842
845
  "system.base.redact_rule.title": "脱敏ルール",
843
846
  "system.base.redact_rule.validation.parameters": "ルールパラメーターを入力してください",
847
+ "system.base.redact_rule.validation.code": "ルールコードを入力してください",
848
+ "system.base.redact_rule.validation.name": "ルール名を入力してください",
849
+ "system.base.redact_rule.validation.rule_type": "ルールタイプを選択してください",
844
850
  "system.base.redact_storage_policy.field.column_name": "フィールド",
845
851
  "system.base.redact_storage_policy.field.parameters": "ルールパラメーター",
846
852
  "system.base.redact_storage_policy.field.rule": "脱敏ルール",
@@ -807,6 +807,7 @@
807
807
  "system.base.redact_rule.description.fixed_length_mask": "使用固定字符替换为等长文本",
808
808
  "system.base.redact_rule.description.hash_digest": "使用摘要算法生成不可逆结果",
809
809
  "system.base.redact_rule.description.ip_mask": "保留 IP 前缀网段并隐藏其余部分",
810
+ "system.base.redact_rule.description.string_mask": "按配置保留字符串首尾字符并掩码中间内容",
810
811
  "system.base.redact_rule.description.phone_mask": "保留手机号前三位和后四位",
811
812
  "system.base.redact_rule.description.regex_replace": "使用正则表达式替换匹配内容",
812
813
  "system.base.redact_rule.description.stable_uuid": "将文本转换为稳定 UUID",
@@ -821,6 +822,7 @@
821
822
  "system.base.redact_rule.parameter.char": "替换字符",
822
823
  "system.base.redact_rule.parameter.keep_first": "保留开头字符数",
823
824
  "system.base.redact_rule.parameter.keep_last": "保留结尾字符数",
825
+ "system.base.redact_rule.parameter.min_mask": "最少脱敏字符数",
824
826
  "system.base.redact_rule.parameter.keep_local_first": "保留本地部分字符数",
825
827
  "system.base.redact_rule.parameter.keep_octets": "保留网段数",
826
828
  "system.base.redact_rule.parameter.length": "保留长度",
@@ -834,6 +836,7 @@
834
836
  "system.base.redact_rule.template.fixed_length_mask": "等长掩码",
835
837
  "system.base.redact_rule.template.hash_digest": "哈希摘要",
836
838
  "system.base.redact_rule.template.ip_mask": "IP 地址掩码",
839
+ "system.base.redact_rule.template.string_mask": "通用字符串掩码",
837
840
  "system.base.redact_rule.template.phone_mask": "手机号掩码",
838
841
  "system.base.redact_rule.template.regex_replace": "正则替换",
839
842
  "system.base.redact_rule.template.stable_uuid": "稳定 UUID",
@@ -841,6 +844,9 @@
841
844
  "system.base.redact_rule.template.url_mask": "URL 参数掩码",
842
845
  "system.base.redact_rule.title": "脱敏规则",
843
846
  "system.base.redact_rule.validation.parameters": "请输入规则参数",
847
+ "system.base.redact_rule.validation.code": "请输入规则编码",
848
+ "system.base.redact_rule.validation.name": "请输入规则名称",
849
+ "system.base.redact_rule.validation.rule_type": "请选择规则类型",
844
850
  "system.base.redact_storage_policy.field.column_name": "字段",
845
851
  "system.base.redact_storage_policy.field.parameters": "规则参数",
846
852
  "system.base.redact_storage_policy.field.rule": "脱敏规则",
@@ -807,6 +807,7 @@
807
807
  "system.base.redact_rule.description.fixed_length_mask": "使用固定字元取代為等長文字",
808
808
  "system.base.redact_rule.description.hash_digest": "使用摘要演算法產生不可逆結果",
809
809
  "system.base.redact_rule.description.ip_mask": "保留 IP 前綴網段並隱藏其餘部分",
810
+ "system.base.redact_rule.description.string_mask": "依設定保留字串首尾字元並遮罩中間內容",
810
811
  "system.base.redact_rule.description.phone_mask": "保留手機號碼前三碼和後四碼",
811
812
  "system.base.redact_rule.description.regex_replace": "使用正規表示式取代符合的內容",
812
813
  "system.base.redact_rule.description.stable_uuid": "將文字轉換為穩定 UUID",
@@ -821,6 +822,7 @@
821
822
  "system.base.redact_rule.parameter.char": "取代字元",
822
823
  "system.base.redact_rule.parameter.keep_first": "保留開頭字元數",
823
824
  "system.base.redact_rule.parameter.keep_last": "保留結尾字元數",
825
+ "system.base.redact_rule.parameter.min_mask": "最少遮罩字元數",
824
826
  "system.base.redact_rule.parameter.keep_local_first": "保留本機部分字元數",
825
827
  "system.base.redact_rule.parameter.keep_octets": "保留網段數",
826
828
  "system.base.redact_rule.parameter.length": "保留長度",
@@ -834,6 +836,7 @@
834
836
  "system.base.redact_rule.template.fixed_length_mask": "等長遮罩",
835
837
  "system.base.redact_rule.template.hash_digest": "雜湊摘要",
836
838
  "system.base.redact_rule.template.ip_mask": "IP 位址遮罩",
839
+ "system.base.redact_rule.template.string_mask": "通用字串遮罩",
837
840
  "system.base.redact_rule.template.phone_mask": "手機號碼遮罩",
838
841
  "system.base.redact_rule.template.regex_replace": "正規表示式取代",
839
842
  "system.base.redact_rule.template.stable_uuid": "穩定 UUID",
@@ -841,6 +844,9 @@
841
844
  "system.base.redact_rule.template.url_mask": "URL 參數遮罩",
842
845
  "system.base.redact_rule.title": "脫敏規則",
843
846
  "system.base.redact_rule.validation.parameters": "請輸入規則參數",
847
+ "system.base.redact_rule.validation.code": "請輸入規則編碼",
848
+ "system.base.redact_rule.validation.name": "請輸入規則名稱",
849
+ "system.base.redact_rule.validation.rule_type": "請選擇規則類型",
844
850
  "system.base.redact_storage_policy.field.column_name": "欄位",
845
851
  "system.base.redact_storage_policy.field.parameters": "規則參數",
846
852
  "system.base.redact_storage_policy.field.rule": "脫敏規則",
@@ -7,6 +7,7 @@
7
7
  /* eslint-disable */
8
8
  import type { Status } from "../../../common/v1/enum";
9
9
  import type { Empty } from "../../../google/protobuf/empty";
10
+ import type { BaseApiDoc } from "./base_api";
10
11
 
11
12
  /** 出库脱敏策略处理模式。 */
12
13
  export enum BaseRedactOutputPolicyMode {
@@ -140,6 +141,12 @@ export interface BaseRedactOutputPolicy {
140
141
  updated_at: string;
141
142
  }
142
143
 
144
+ /** 查询可出库脱敏字段请求。 */
145
+ export interface GetBaseRedactOutputFieldDocRequest {
146
+ /** 接口ID */
147
+ api_id: number;
148
+ }
149
+
143
150
  /** Admin出库脱敏策略服务。 */
144
151
  export interface BaseRedactOutputPolicyService {
145
152
  /** 查询出库脱敏策略分页列表。 */
@@ -154,4 +161,6 @@ export interface BaseRedactOutputPolicyService {
154
161
  DeleteBaseRedactOutputPolicy(request: DeleteBaseRedactOutputPolicyRequest): Promise<Empty>;
155
162
  /** 设置出库脱敏策略状态。 */
156
163
  SetBaseRedactOutputPolicyStatus(request: SetBaseRedactOutputPolicyStatusRequest): Promise<Empty>;
164
+ /** 查询可出库脱敏的响应字段文档。 */
165
+ GetBaseRedactOutputFieldDoc(request: GetBaseRedactOutputFieldDocRequest): Promise<BaseApiDoc>;
157
166
  }
@@ -118,6 +118,32 @@ export interface BaseRedactStoragePolicy {
118
118
  updated_at: string;
119
119
  }
120
120
 
121
+ /** 查询可入库脱敏字段请求。 */
122
+ export interface ListBaseRedactStorageColumnRequest {
123
+ /** 数据源名称 */
124
+ source_name: string;
125
+ /** 数据库表名 */
126
+ table_name: string;
127
+ }
128
+
129
+ /** 可入库脱敏字段。 */
130
+ export interface BaseRedactStorageColumn {
131
+ /** 字段名 */
132
+ name: string;
133
+ /** 字段注释 */
134
+ comment: string;
135
+ /** 数据库类型 */
136
+ db_type: string;
137
+ /** 数据库完整类型 */
138
+ column_type: string;
139
+ }
140
+
141
+ /** 可入库脱敏字段列表响应。 */
142
+ export interface ListBaseRedactStorageColumnResponse {
143
+ /** 可脱敏字段列表 */
144
+ columns: BaseRedactStorageColumn[];
145
+ }
146
+
121
147
  /** Admin入库脱敏策略服务。 */
122
148
  export interface BaseRedactStoragePolicyService {
123
149
  /** 查询入库脱敏策略分页列表。 */
@@ -134,4 +160,8 @@ export interface BaseRedactStoragePolicyService {
134
160
  DeleteBaseRedactStoragePolicy(request: DeleteBaseRedactStoragePolicyRequest): Promise<Empty>;
135
161
  /** 设置入库脱敏策略状态。 */
136
162
  SetBaseRedactStoragePolicyStatus(request: SetBaseRedactStoragePolicyStatusRequest): Promise<Empty>;
163
+ /** 查询可入库脱敏的字符串字段列表。 */
164
+ ListBaseRedactStorageColumn(
165
+ request: ListBaseRedactStorageColumnRequest,
166
+ ): Promise<ListBaseRedactStorageColumnResponse>;
137
167
  }
@@ -101,8 +101,8 @@
101
101
  </template>
102
102
 
103
103
  <script setup lang="ts" name="XSender">
104
- import { computed, ref, watch } from "vue";
105
- import { Attachments, useRecord, XSender as BaseXSender } from "vue-element-plus-x";
104
+ import { computed, ref } from "vue";
105
+ import { Attachments, XSender as BaseXSender } from "vue-element-plus-x";
106
106
  import type { FilesCardProps } from "vue-element-plus-x/types/FilesCard";
107
107
  import { Loading, Microphone, Paperclip, Promotion } from "@element-plus/icons-vue";
108
108
  import { ElMessage } from "element-plus";
@@ -111,13 +111,8 @@ import { defFileService } from "@liujitcn/kratos-admin-core/api/base/v1/file";
111
111
  import type { AiAttachment } from "@liujitcn/kratos-admin-system/rpc/base/v1/ai_session";
112
112
  import type { SubmitPayload } from "../types";
113
113
  import { buildAIAttachmentFileCard } from "../attachment";
114
-
115
- /** 语音识别错误的最小字段,兼容浏览器事件和不支持错误。 */
116
- type RecordError = {
117
- code?: number;
118
- error?: string;
119
- message?: string;
120
- };
114
+ import { useSpeechRecognition } from "./speech-recognition";
115
+ import type { SpeechRecognitionError } from "./speech-recognition";
121
116
 
122
117
  const props = defineProps<{
123
118
  /** 消息发送加载状态。 */
@@ -153,13 +148,17 @@ const acceptedAttachmentExtensions = [
153
148
  ];
154
149
  const acceptedAttachmentTypes = acceptedAttachmentExtensions.join(",");
155
150
 
151
+ const voiceBaseText = ref("");
156
152
  const {
157
153
  loading: recording,
158
154
  start: startRecord,
159
- stop: stopRecord,
160
- value: recordText
161
- } = useRecord({
155
+ stop: stopRecord
156
+ } = useSpeechRecognition({
157
+ onStart: () => {
158
+ voiceBaseText.value = inputText.value.trim();
159
+ },
162
160
  onEnd: handleRecordEnd,
161
+ onResult: setRecordText,
163
162
  onError: handleRecordError
164
163
  });
165
164
 
@@ -217,10 +216,11 @@ function handleToggleRecord() {
217
216
  /** 语音识别结束后保留当前识别文本。 */
218
217
  function handleRecordEnd(result: string) {
219
218
  setRecordText(result);
219
+ voiceBaseText.value = "";
220
220
  }
221
221
 
222
222
  /** 处理语音识别失败,提示浏览器不支持或麦克风授权异常。 */
223
- function handleRecordError(error: RecordError) {
223
+ function handleRecordError(error: SpeechRecognitionError) {
224
224
  const message = resolveRecordErrorMessage(error);
225
225
  ElMessage.warning(message);
226
226
  }
@@ -229,52 +229,18 @@ function handleRecordError(error: RecordError) {
229
229
  function setRecordText(recordText: string) {
230
230
  const normalizedRecordText = normalizeRecordText(recordText);
231
231
  if (!normalizedRecordText) return;
232
- senderRef.value?.setText(normalizedRecordText);
233
- inputText.value = normalizedRecordText;
232
+ const nextText = [voiceBaseText.value, normalizedRecordText].filter(Boolean).join(" ");
233
+ senderRef.value?.setText(nextText);
234
+ inputText.value = nextText;
234
235
  }
235
236
 
236
237
  /** 标准化语音识别文本,压掉多余空白。 */
237
238
  function normalizeRecordText(recordText: string) {
238
- return collapseCumulativeRecordText(recordText.replace(/\s+/g, " ").trim());
239
- }
240
-
241
- /** 压缩 useRecord 在中文连续识别时返回的前缀累计文本。 */
242
- function collapseCumulativeRecordText(text: string) {
243
- if (text.length < 4) return text;
244
- for (let candidateLength = 2; candidateLength <= Math.floor(text.length / 2); candidateLength++) {
245
- const candidate = text.slice(-candidateLength);
246
- const matchResult = matchCumulativeRecordCandidate(text, candidate);
247
- if (matchResult.matched && (matchResult.hasPartial || matchResult.segmentCount >= 3)) {
248
- return candidate;
249
- }
250
- }
251
- return text;
252
- }
253
-
254
- /** 判断文本是否由同一识别结果的前缀片段累计组成。 */
255
- function matchCumulativeRecordCandidate(text: string, candidate: string) {
256
- let index = 0;
257
- let segmentCount = 0;
258
- let hasPartial = false;
259
- while (index < text.length) {
260
- let matchedLength = 0;
261
- const maxLength = Math.min(candidate.length, text.length - index);
262
- for (let length = maxLength; length >= 1; length--) {
263
- if (candidate.startsWith(text.slice(index, index + length))) {
264
- matchedLength = length;
265
- break;
266
- }
267
- }
268
- if (!matchedLength) return { matched: false, segmentCount, hasPartial };
269
- if (matchedLength < candidate.length) hasPartial = true;
270
- segmentCount++;
271
- index += matchedLength;
272
- }
273
- return { matched: true, segmentCount, hasPartial };
239
+ return recordText.replace(/\s+/g, " ").trim();
274
240
  }
275
241
 
276
242
  /** 根据浏览器语音识别错误类型生成用户可理解的提示。 */
277
- function resolveRecordErrorMessage(error: RecordError) {
243
+ function resolveRecordErrorMessage(error: SpeechRecognitionError) {
278
244
  if (error.code === -1) return t("system.ai.chat.message.voice_unsupported");
279
245
  const errorName = error.error ?? "";
280
246
  if (["not-allowed", "service-not-allowed", "permission-denied"].includes(errorName)) {
@@ -389,15 +355,6 @@ function handleDeleteCard(item: { uid?: string | number }) {
389
355
  function resetFileInput() {
390
356
  if (fileInputRef.value) fileInputRef.value.value = "";
391
357
  }
392
-
393
- /** 按 useRecord 文档监听识别文本,并同步到 XSender。 */
394
- watch(
395
- recordText,
396
- value => {
397
- setRecordText(value);
398
- },
399
- { deep: true }
400
- );
401
358
  </script>
402
359
 
403
360
  <style scoped lang="scss">
@@ -0,0 +1,177 @@
1
+ import { getCurrentInstance, onBeforeUnmount, ref } from "vue";
2
+
3
+ /** 语音识别结果的最小结构,便于在不同浏览器实现间复用。 */
4
+ export type SpeechResultList = ArrayLike<{
5
+ 0?: {
6
+ transcript: string;
7
+ };
8
+ isFinal?: boolean;
9
+ length: number;
10
+ }>;
11
+
12
+ /** 语音识别错误的最小结构,兼容浏览器事件和不支持错误。 */
13
+ export type SpeechRecognitionError = {
14
+ code?: number;
15
+ error?: string;
16
+ message?: string;
17
+ };
18
+
19
+ type SpeechRecognitionResultEvent = {
20
+ results: SpeechResultList;
21
+ resultIndex: number;
22
+ };
23
+
24
+ type SpeechRecognitionInstance = {
25
+ continuous: boolean;
26
+ interimResults: boolean;
27
+ lang: string;
28
+ onstart: (() => void) | null;
29
+ onend: (() => void) | null;
30
+ onerror: ((error: SpeechRecognitionError) => void) | null;
31
+ onresult: ((event: SpeechRecognitionResultEvent) => void) | null;
32
+ start: () => void;
33
+ stop: () => void;
34
+ };
35
+
36
+ type SpeechRecognitionConstructor = new () => SpeechRecognitionInstance;
37
+
38
+ type SpeechRecognitionWindow = Window & {
39
+ SpeechRecognition?: SpeechRecognitionConstructor;
40
+ webkitSpeechRecognition?: SpeechRecognitionConstructor;
41
+ };
42
+
43
+ type SpeechRecognitionOptions = {
44
+ /** 识别错误回调。 */
45
+ onError?: (error: SpeechRecognitionError) => void;
46
+ /** 识别开始回调。 */
47
+ onStart?: () => void;
48
+ /** 识别结束回调。 */
49
+ onEnd?: (text: string) => void;
50
+ /** 识别结果回调。 */
51
+ onResult?: (text: string) => void;
52
+ };
53
+
54
+ /** 增量累积语音识别结果,避免连续模式下 interim 结果重复计入已确认文本。 */
55
+ export function accumulateSpeechResultText(
56
+ results: SpeechResultList,
57
+ resultIndex: number,
58
+ previousFinalText: string
59
+ ): { finalText: string; text: string } {
60
+ let finalText = previousFinalText;
61
+ let interimText = "";
62
+ for (let index = resultIndex; index < results.length; index++) {
63
+ const result = results[index];
64
+ const transcript = result?.[0]?.transcript ?? "";
65
+ if (result?.isFinal) {
66
+ finalText += transcript;
67
+ } else {
68
+ interimText += transcript;
69
+ }
70
+ }
71
+ // Chrome 连续模式下 interim 结果会累计包含已确认的 final 文本,去除重复前缀。
72
+ if (interimText && finalText && interimText.startsWith(finalText)) {
73
+ interimText = interimText.slice(finalText.length);
74
+ }
75
+ return { finalText, text: finalText + interimText };
76
+ }
77
+
78
+ /** 创建浏览器语音识别实例,并统一结果、状态和卸载处理。 */
79
+ export function useSpeechRecognition({ onError, onStart, onEnd, onResult }: SpeechRecognitionOptions = {}) {
80
+ const loading = ref(false);
81
+ const value = ref("");
82
+ let recognition: SpeechRecognitionInstance | null = null;
83
+ let recognitionSession = 0;
84
+ let finalText = "";
85
+
86
+ /** 获取当前浏览器支持的语音识别构造函数。 */
87
+ function resolveRecognitionConstructor() {
88
+ if (typeof window === "undefined") return undefined;
89
+ const speechWindow = window as SpeechRecognitionWindow;
90
+ return speechWindow.SpeechRecognition ?? speechWindow.webkitSpeechRecognition;
91
+ }
92
+
93
+ /** 开始一次语音识别。 */
94
+ function start() {
95
+ if (loading.value) return;
96
+ const RecognitionConstructor = resolveRecognitionConstructor();
97
+ if (!RecognitionConstructor) {
98
+ onError?.({ code: -1, message: "Speech recognition is not supported by this browser" });
99
+ return;
100
+ }
101
+
102
+ const nextRecognition = new RecognitionConstructor();
103
+ const currentSession = ++recognitionSession;
104
+ recognition = nextRecognition;
105
+ finalText = "";
106
+ const isCurrentRecognition = () => recognition === nextRecognition && recognitionSession === currentSession;
107
+ nextRecognition.continuous = true;
108
+ nextRecognition.interimResults = true;
109
+ nextRecognition.lang = "zh-CN";
110
+ nextRecognition.onstart = () => {
111
+ if (!isCurrentRecognition()) return;
112
+ loading.value = true;
113
+ value.value = "";
114
+ onStart?.();
115
+ };
116
+ nextRecognition.onresult = event => {
117
+ if (!isCurrentRecognition()) return;
118
+ const accumulated = accumulateSpeechResultText(event.results, event.resultIndex, finalText);
119
+ finalText = accumulated.finalText;
120
+ value.value = accumulated.text;
121
+ onResult?.(value.value);
122
+ };
123
+ nextRecognition.onerror = error => {
124
+ if (!isCurrentRecognition()) return;
125
+ loading.value = false;
126
+ recognition = null;
127
+ onError?.(error);
128
+ };
129
+ nextRecognition.onend = () => {
130
+ if (!isCurrentRecognition()) return;
131
+ loading.value = false;
132
+ recognition = null;
133
+ onEnd?.(value.value);
134
+ };
135
+
136
+ loading.value = true;
137
+ try {
138
+ nextRecognition.start();
139
+ } catch (error) {
140
+ if (!isCurrentRecognition()) return;
141
+ loading.value = false;
142
+ recognition = null;
143
+ onError?.({
144
+ code: -2,
145
+ message: error instanceof Error ? error.message : String(error)
146
+ });
147
+ }
148
+ }
149
+
150
+ /** 停止当前语音识别。 */
151
+ function stop() {
152
+ const currentRecognition = recognition;
153
+ if (!currentRecognition) return;
154
+ recognition = null;
155
+ recognitionSession++;
156
+ loading.value = false;
157
+ onEnd?.(value.value);
158
+ currentRecognition.stop();
159
+ }
160
+
161
+ if (getCurrentInstance()) {
162
+ onBeforeUnmount(() => {
163
+ const currentRecognition = recognition;
164
+ recognition = null;
165
+ recognitionSession++;
166
+ loading.value = false;
167
+ currentRecognition?.stop();
168
+ });
169
+ }
170
+
171
+ return {
172
+ loading,
173
+ start,
174
+ stop,
175
+ value
176
+ };
177
+ }