@liujitcn/kratos-admin-system 0.0.65 → 0.0.66

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 (41) hide show
  1. package/README.md +5 -3
  2. package/dist/package/declarations/src/api/base/v1/ai_knowledge.d.ts +7 -0
  3. package/dist/package/declarations/src/api/system/admin/v1/ai_provider.d.ts +3 -1
  4. package/dist/package/declarations/src/api/system/admin/v1/ai_query.d.ts +10 -0
  5. package/dist/package/declarations/src/api/system/admin/v1/ai_session.d.ts +9 -0
  6. package/dist/package/declarations/src/locales/generated.d.ts +1801 -1553
  7. package/dist/package/declarations/src/rpc/base/v1/ai_knowledge.d.ts +22 -0
  8. package/dist/package/declarations/src/rpc/base/v1/ai_message.d.ts +2 -0
  9. package/dist/package/declarations/src/rpc/system/admin/v1/ai_provider.d.ts +63 -5
  10. package/dist/package/declarations/src/rpc/system/admin/v1/ai_query.d.ts +73 -0
  11. package/dist/package/declarations/src/rpc/system/admin/v1/ai_session.d.ts +114 -0
  12. package/dist/package/declarations/src/views/ai/chat/components/ChatPanel.vue.d.ts +9 -2
  13. package/dist/package/declarations/src/views/ai/chat/components/XSender.vue.d.ts +7 -0
  14. package/dist/package/declarations/src/views/ai/query/index.vue.d.ts +3 -0
  15. package/dist/package/declarations/src/views/ai/session/index.vue.d.ts +3 -0
  16. package/dist/package/src/api/base/v1/ai_knowledge.ts +22 -0
  17. package/dist/package/src/api/system/admin/v1/ai_provider.ts +7 -0
  18. package/dist/package/src/api/system/admin/v1/ai_query.ts +25 -0
  19. package/dist/package/src/api/system/admin/v1/ai_session.ts +25 -0
  20. package/dist/package/src/components/account/ChangePasswordForm.vue +1 -1
  21. package/dist/package/src/components/tenant-project/TenantProjectSelect.vue +21 -4
  22. package/dist/package/src/locales/en-US.json +354 -292
  23. package/dist/package/src/locales/ja-JP.json +351 -289
  24. package/dist/package/src/locales/zh-CN.json +351 -289
  25. package/dist/package/src/locales/zh-TW.json +351 -289
  26. package/dist/package/src/rpc/base/v1/ai_knowledge.ts +33 -0
  27. package/dist/package/src/rpc/base/v1/ai_message.ts +2 -0
  28. package/dist/package/src/rpc/system/admin/v1/ai_provider.ts +68 -5
  29. package/dist/package/src/rpc/system/admin/v1/ai_query.ts +89 -0
  30. package/dist/package/src/rpc/system/admin/v1/ai_session.ts +131 -0
  31. package/dist/package/src/views/ai/chat/components/ChatPanel.vue +18 -0
  32. package/dist/package/src/views/ai/chat/components/XSender.vue +101 -0
  33. package/dist/package/src/views/ai/chat/index.vue +22 -2
  34. package/dist/package/src/views/ai/query/index.vue +222 -0
  35. package/dist/package/src/views/ai/session/index.vue +422 -0
  36. package/dist/package/src/views/base/ai-provider/index.vue +201 -155
  37. package/dist/package/src/views/base/message-provider/index.vue +2 -2
  38. package/dist/package/src/views/base/oauth-provider/index.vue +2 -2
  39. package/dist/package/src/views/base/tenant-project-grant/index.vue +9 -4
  40. package/dist/package/src/views/tool/code-gen/config.ts +1 -0
  41. package/package.json +2 -2
@@ -0,0 +1,222 @@
1
+ <template>
2
+ <div class="table-box">
3
+ <ProTable ref="table" row-key="id" :columns="columns" :request-api="requestTable" />
4
+
5
+ <el-drawer
6
+ v-model="detail.visible"
7
+ :title="t('system.ai.query.detail.title')"
8
+ size="min(720px, calc(100vw - 32px))"
9
+ destroy-on-close
10
+ >
11
+ <div v-if="detail.row" v-loading="detail.loading" class="ai-query-detail">
12
+ <div class="ai-query-detail__meta">
13
+ <span>{{ t("system.ai.query.field.user") }}:{{ displayUser(detail.row) }}</span>
14
+ <el-tag :type="statusTagType(detail.row.status)" size="small" effect="plain">
15
+ {{ statusLabel(detail.row.status) }}
16
+ </el-tag>
17
+ <span>{{ t("system.ai.query.field.row_count") }}:{{ detail.row.row_count }}</span>
18
+ <span>{{ t("system.ai.query.field.elapsed_ms") }}:{{ detail.row.elapsed_ms }}</span>
19
+ <span>{{ t("common.field.created_at") }}:{{ detail.row.created_at }}</span>
20
+ </div>
21
+ <div class="ai-query-detail__question">{{ detail.row.question }}</div>
22
+ <div v-if="detail.row.error_msg" class="ai-query-detail__error">
23
+ {{ t("system.ai.query.field.error_msg") }}:{{ detail.row.error_msg }}
24
+ </div>
25
+ <div class="ai-query-detail__sql-title">{{ t("system.ai.query.field.sql") }}</div>
26
+ <CodeBlock class="ai-query-detail__sql" :code="detail.row.sql || '-'" language="sql" />
27
+ <template v-if="resultRows.length">
28
+ <div class="ai-query-detail__sql-title">{{ t("system.ai.query.detail.result") }}</div>
29
+ <el-table :data="resultRows" size="small" max-height="320" border class="ai-query-detail__result">
30
+ <el-table-column
31
+ v-for="(column, index) in detail.row.result_columns"
32
+ :key="`${column}-${index}`"
33
+ :prop="String(index)"
34
+ :label="column"
35
+ show-overflow-tooltip
36
+ min-width="120"
37
+ />
38
+ </el-table>
39
+ </template>
40
+ </div>
41
+ </el-drawer>
42
+ </div>
43
+ </template>
44
+
45
+ <script setup lang="ts">
46
+ import { computed, reactive, ref } from "vue";
47
+ import { View } from "@element-plus/icons-vue";
48
+ import ProTable from "@liujitcn/kratos-admin-core/components/ProTable";
49
+ import CodeBlock from "@liujitcn/kratos-admin-core/components/CodeBlock/index.vue";
50
+ import type { ColumnProps, EnumProps, ProTableInstance } from "@liujitcn/kratos-admin-core/components/ProTable/interface";
51
+ import { useAuthButtons } from "@liujitcn/kratos-admin-core/auth";
52
+ import { buildPageRequest } from "@liujitcn/kratos-admin-core/table";
53
+ import { t } from "@liujitcn/kratos-admin-core";
54
+ import { defAiQueryService } from "@liujitcn/kratos-admin-system/api/system/admin/v1/ai_query";
55
+ import type { AiQueryRecord, PageAiQueryRequest } from "@liujitcn/kratos-admin-system/rpc/system/admin/v1/ai_query";
56
+ import { AiQueryStatus } from "@liujitcn/kratos-admin-system/rpc/system/admin/v1/ai_query";
57
+
58
+ defineOptions({ name: "AiQuery", inheritAttrs: false });
59
+
60
+ const { BUTTONS } = useAuthButtons();
61
+ const table = ref<ProTableInstance>();
62
+ const detail = reactive({
63
+ visible: false,
64
+ row: null as AiQueryRecord | null,
65
+ loading: false
66
+ });
67
+
68
+ /** 问数状态枚举选项。 */
69
+ const statusEnum = computed<EnumProps[]>(() => [
70
+ { label: t("system.ai.query.status.success"), value: AiQueryStatus.AI_QUERY_STATUS_SUCCESS, tagType: "success" },
71
+ { label: t("system.ai.query.status.failed"), value: AiQueryStatus.AI_QUERY_STATUS_FAILED, tagType: "danger" }
72
+ ]);
73
+
74
+ const columns = computed<ColumnProps[]>(() => [
75
+ { prop: "id", label: t("system.ai.query.field.id"), width: 90, align: "center" },
76
+ {
77
+ prop: "question",
78
+ label: t("system.ai.query.field.question"),
79
+ minWidth: 220,
80
+ showOverflowTooltip: true,
81
+ search: { el: "input" }
82
+ },
83
+ {
84
+ prop: "user",
85
+ label: t("system.ai.query.field.user"),
86
+ minWidth: 120,
87
+ showOverflowTooltip: true,
88
+ render: scope => displayUser(scope.row as AiQueryRecord)
89
+ },
90
+ {
91
+ prop: "status",
92
+ label: t("system.ai.query.field.status"),
93
+ width: 110,
94
+ align: "center",
95
+ enum: statusEnum,
96
+ search: { el: "select", props: { clearable: true } }
97
+ },
98
+ { prop: "row_count", label: t("system.ai.query.field.row_count"), width: 100, align: "right" },
99
+ { prop: "elapsed_ms", label: t("system.ai.query.field.elapsed_ms"), width: 110, align: "right" },
100
+ { prop: "created_at", label: t("common.field.created_at"), minWidth: 170, align: "center" },
101
+ {
102
+ prop: "operation",
103
+ label: t("common.field.operation"),
104
+ cellType: "actions",
105
+ fixed: "right",
106
+ width: 120,
107
+ actions: [
108
+ {
109
+ label: t("system.ai.query.action.detail"),
110
+ link: true,
111
+ icon: View,
112
+ hidden: () => !BUTTONS.value["base:ai-query:detail"],
113
+ onClick: scope => openDetail(scope.row as AiQueryRecord)
114
+ }
115
+ ]
116
+ }
117
+ ]);
118
+
119
+ /** 请求智能问数记录表格数据。 */
120
+ async function requestTable(params: Record<string, unknown>) {
121
+ const request = buildPageRequest<PageAiQueryRequest>(params as unknown as PageAiQueryRequest);
122
+ if (!request.status) delete request.status;
123
+ const data = await defAiQueryService.PageAiQuery(request);
124
+ return { data: { list: data.queries ?? [], total: data.total } };
125
+ }
126
+
127
+ /** 展示记录所属用户,昵称缺失时回退账号。 */
128
+ function displayUser(row: AiQueryRecord): string {
129
+ return row.user_name || String(row.user_id ?? "");
130
+ }
131
+
132
+ /** 转换问数状态为展示文案。 */
133
+ function statusLabel(status?: AiQueryStatus): string {
134
+ return status === AiQueryStatus.AI_QUERY_STATUS_SUCCESS
135
+ ? t("system.ai.query.status.success")
136
+ : status === AiQueryStatus.AI_QUERY_STATUS_FAILED
137
+ ? t("system.ai.query.status.failed")
138
+ : "";
139
+ }
140
+
141
+ /** 转换问数状态为标签类型。 */
142
+ function statusTagType(status?: AiQueryStatus): "success" | "danger" | "info" {
143
+ switch (status) {
144
+ case AiQueryStatus.AI_QUERY_STATUS_SUCCESS:
145
+ return "success";
146
+ case AiQueryStatus.AI_QUERY_STATUS_FAILED:
147
+ return "danger";
148
+ default:
149
+ return "info";
150
+ }
151
+ }
152
+
153
+ /** 打开问数详情抽屉。 */
154
+ async function openDetail(row: AiQueryRecord) {
155
+ detail.row = row;
156
+ detail.visible = true;
157
+ detail.loading = true;
158
+ try {
159
+ detail.row = await defAiQueryService.GetAiQuery({ id: row.id });
160
+ } finally {
161
+ detail.loading = false;
162
+ }
163
+ }
164
+
165
+ /** 将结果快照行转换为表格行数据,按列下标取值。 */
166
+ const resultRows = computed<Record<string, string>[]>(() => {
167
+ const columns = detail.row?.result_columns ?? [];
168
+ return (detail.row?.result_rows ?? []).map(row => {
169
+ const item: Record<string, string> = {};
170
+ columns.forEach((_, index) => {
171
+ item[String(index)] = row.values[index] ?? "";
172
+ });
173
+ return item;
174
+ });
175
+ });
176
+ </script>
177
+
178
+ <style scoped>
179
+ .ai-query-detail {
180
+ display: flex;
181
+ flex-direction: column;
182
+ gap: 16px;
183
+ }
184
+
185
+ .ai-query-detail__meta {
186
+ display: flex;
187
+ align-items: center;
188
+ flex-wrap: wrap;
189
+ gap: 12px;
190
+ color: var(--el-text-color-secondary);
191
+ font-size: 12px;
192
+ }
193
+
194
+ .ai-query-detail__question {
195
+ font-size: 14px;
196
+ font-weight: 600;
197
+ line-height: 1.6;
198
+ }
199
+
200
+ .ai-query-detail__error {
201
+ padding: 8px 12px;
202
+ color: var(--el-color-danger);
203
+ font-size: 13px;
204
+ border: 1px solid var(--el-color-danger-light-8);
205
+ border-radius: 6px;
206
+ background-color: var(--el-color-danger-light-9);
207
+ }
208
+
209
+ .ai-query-detail__sql-title {
210
+ color: var(--el-text-color-secondary);
211
+ font-size: 12px;
212
+ font-weight: 600;
213
+ }
214
+
215
+ .ai-query-detail__result {
216
+ width: 100%;
217
+ }
218
+
219
+ :deep(.ai-query-detail__result .el-table__cell) {
220
+ font-size: 12px;
221
+ }
222
+ </style>
@@ -0,0 +1,422 @@
1
+ <template>
2
+ <div class="table-box">
3
+ <ProTable ref="table" row-key="id" :columns="columns" :request-api="requestTable" />
4
+
5
+ <el-drawer
6
+ v-model="detail.visible"
7
+ :title="t('system.ai.session.detail.title')"
8
+ size="min(920px, calc(100vw - 32px))"
9
+ destroy-on-close
10
+ >
11
+ <div v-if="detail.row" class="ai-session-detail">
12
+ <div class="ai-session-detail__head">
13
+ <div class="ai-session-detail__title">{{ detail.row.title }}</div>
14
+ <div v-if="detail.row.summary" class="ai-session-detail__summary">{{ detail.row.summary }}</div>
15
+ <div class="ai-session-detail__meta">
16
+ <el-tag size="small" effect="plain">{{ terminalLabel(detail.row.terminal) }}</el-tag>
17
+ <span>{{ t("system.ai.session.field.user") }}:{{ displayUser(detail.row) }}</span>
18
+ <span>{{ t("system.ai.session.field.message_count") }}:{{ detail.row.message_count }}</span>
19
+ <span>{{ t("system.ai.session.field.total_tokens") }}:{{ detail.row.total_tokens }}</span>
20
+ <span>{{ t("common.field.updated_at") }}:{{ detail.row.updated_at }}</span>
21
+ </div>
22
+ </div>
23
+
24
+ <div v-loading="detail.loading" class="ai-session-detail__chat">
25
+ <div v-if="!detail.list.length" class="ai-session-detail__empty">
26
+ {{ t("system.ai.session.message.empty") }}
27
+ </div>
28
+ <BubbleList v-else class="ai-session-detail__bubbles" :list="detailBubbles" max-height="52vh" :auto-scroll="false">
29
+ <template #content="{ item }">
30
+ <div class="ai-session-detail__bubble" :class="{ 'is-user': item.role === 'user' }">
31
+ <div
32
+ v-if="item.role !== 'user'"
33
+ class="ai-session-detail__message-meta"
34
+ >
35
+ <el-tag :type="statusTagType(item.status)" size="small" effect="plain">{{ statusLabel(item.status) }}</el-tag>
36
+ <span v-if="item.model">{{ t("system.ai.session.field.model") }}:{{ item.model }}</span>
37
+ <span>{{ t("system.ai.session.field.input_tokens") }}:{{ item.inputTokens }}</span>
38
+ <span>{{ t("system.ai.session.field.output_tokens") }}:{{ item.outputTokens }}</span>
39
+ <span>{{ t("system.ai.session.field.total_tokens") }}:{{ item.totalTokens }}</span>
40
+ <span>{{ t("common.field.created_at") }}:{{ item.createdAtText }}</span>
41
+ </div>
42
+ <div class="ai-session-detail__content" :class="{ 'is-user': item.role === 'user' }">
43
+ <AiMarkdown v-if="item.role !== 'user'" :content="item.content" />
44
+ <span v-else>{{ item.content || t("system.ai.session.detail.no_content") }}</span>
45
+ </div>
46
+ <div v-if="item.attachments?.length" class="ai-session-detail__attachments">
47
+ <Attachments :items="buildMessageAttachmentItems(item.attachments)" overflow="wrap" :hide-upload="true" />
48
+ </div>
49
+ </div>
50
+ </template>
51
+ </BubbleList>
52
+ </div>
53
+
54
+ <el-pagination
55
+ v-model:current-page="detail.pageNum"
56
+ :page-size="detail.pageSize"
57
+ :total="detail.total"
58
+ layout="total, prev, pager, next"
59
+ background
60
+ class="ai-session-detail__pagination"
61
+ @current-change="loadDetailMessages"
62
+ />
63
+ </div>
64
+ </el-drawer>
65
+
66
+ <ProDialog
67
+ v-model="usage.visible"
68
+ :title="usage.row ? t('system.ai.session.usage.title') + ' - ' + usage.row.title : t('system.ai.session.usage.title')"
69
+ width="min(1320px, calc(100vw - 32px))"
70
+ :show-footer="false"
71
+ >
72
+ <div v-loading="usage.loading" class="ai-session-usage">
73
+ <el-table :data="usage.list" row-key="id" border size="default">
74
+ <el-table-column prop="model" :label="t('system.ai.session.field.model')" min-width="160" />
75
+ <el-table-column prop="input_tokens" :label="t('system.ai.session.field.input_tokens')" width="110" align="right" />
76
+ <el-table-column prop="output_tokens" :label="t('system.ai.session.field.output_tokens')" width="110" align="right" />
77
+ <el-table-column prop="cache_tokens" :label="t('system.ai.session.field.cache_tokens')" width="110" align="right" />
78
+ <el-table-column prop="total_tokens" :label="t('system.ai.session.field.total_tokens')" width="110" align="right" />
79
+ <el-table-column prop="first_token_ms" :label="t('system.ai.session.field.first_token_ms')" width="120" align="right" />
80
+ <el-table-column prop="duration_ms" :label="t('system.ai.session.field.duration_ms')" width="100" align="right" />
81
+ <el-table-column :label="t('system.ai.session.field.status')" width="90" align="center">
82
+ <template #default="{ row }">
83
+ <el-tag :type="statusTagType(row.status)" size="small" effect="plain">{{ statusLabel(row.status) }}</el-tag>
84
+ </template>
85
+ </el-table-column>
86
+ <el-table-column prop="created_at" :label="t('common.field.created_at')" width="185" align="center" />
87
+ </el-table>
88
+ </div>
89
+ </ProDialog>
90
+ </div>
91
+ </template>
92
+
93
+ <script setup lang="ts">
94
+ import { computed, reactive, ref } from "vue";
95
+ import { DataAnalysis, View } from "@element-plus/icons-vue";
96
+ import { Attachments, BubbleList } from "vue-element-plus-x";
97
+ import type { FilesCardProps } from "vue-element-plus-x/types/FilesCard";
98
+ import ProTable from "@liujitcn/kratos-admin-core/components/ProTable";
99
+ import ProDialog from "@liujitcn/kratos-admin-core/components/Dialog/ProDialog.vue";
100
+ import type { ColumnProps, EnumProps, ProTableInstance } from "@liujitcn/kratos-admin-core/components/ProTable/interface";
101
+ import { useAuthButtons } from "@liujitcn/kratos-admin-core/auth";
102
+ import { buildPageRequest } from "@liujitcn/kratos-admin-core/table";
103
+ import { t } from "@liujitcn/kratos-admin-core";
104
+ import { defAiSessionService } from "@liujitcn/kratos-admin-system/api/system/admin/v1/ai_session";
105
+ import type { AiSessionMessageRecord, AiSessionRecord, PageAiSessionRequest } from "@liujitcn/kratos-admin-system/rpc/system/admin/v1/ai_session";
106
+ import type { AiAttachment } from "@liujitcn/kratos-admin-system/rpc/base/v1/ai_session";
107
+ import { AiMessageStatus } from "@liujitcn/kratos-admin-system/rpc/base/v1/ai_session";
108
+ import { Terminal } from "@liujitcn/kratos-admin-system/rpc/base/v1/ai_tool";
109
+ import AiMarkdown from "../chat/components/AiMarkdown.vue";
110
+ import { buildAIAttachmentFileCard } from "../chat/attachment";
111
+ import type { ChatMessageItem } from "../chat/types";
112
+
113
+ defineOptions({ name: "AiSession", inheritAttrs: false });
114
+
115
+ const PAGE_SIZE = 20;
116
+ const MAX_USAGE_MESSAGES = 2000;
117
+ const USAGE_PAGE_SIZE = 100;
118
+
119
+ /** 会话详情气泡展示项,在聊天气泡基础上补充管理端展示信息。 */
120
+ interface DetailBubbleItem extends ChatMessageItem {
121
+ /** 创建时间文本。 */
122
+ createdAtText?: string;
123
+ /** 输入 Token 数。 */
124
+ inputTokens?: number;
125
+ /** 输出 Token 数。 */
126
+ outputTokens?: number;
127
+ /** 总 Token 数。 */
128
+ totalTokens?: number;
129
+ }
130
+
131
+ const { BUTTONS } = useAuthButtons();
132
+ const table = ref<ProTableInstance>();
133
+ const detail = reactive({
134
+ visible: false,
135
+ row: null as AiSessionRecord | null,
136
+ list: [] as AiSessionMessageRecord[],
137
+ loading: false,
138
+ pageNum: 1,
139
+ pageSize: PAGE_SIZE,
140
+ total: 0
141
+ });
142
+ const usage = reactive({
143
+ visible: false,
144
+ row: null as AiSessionRecord | null,
145
+ list: [] as AiSessionMessageRecord[],
146
+ loading: false
147
+ });
148
+
149
+ /** 终端枚举选项。 */
150
+ const terminalEnum = computed<EnumProps[]>(() => [
151
+ { label: t("system.ai.session.terminal.admin"), value: Terminal.TERMINAL_ADMIN, tagType: "primary" },
152
+ { label: t("system.ai.session.terminal.app"), value: Terminal.TERMINAL_APP, tagType: "success" }
153
+ ]);
154
+
155
+ const columns = computed<ColumnProps[]>(() => [
156
+ { prop: "id", label: t("system.ai.session.field.id"), width: 90, align: "center" },
157
+ { prop: "title", label: t("system.ai.session.field.title"), minWidth: 200, showOverflowTooltip: true, search: { el: "input" } },
158
+ {
159
+ prop: "user",
160
+ label: t("system.ai.session.field.user"),
161
+ minWidth: 140,
162
+ showOverflowTooltip: true,
163
+ render: scope => displayUser(scope.row as AiSessionRecord)
164
+ },
165
+ {
166
+ prop: "terminal",
167
+ label: t("system.ai.session.field.terminal"),
168
+ width: 110,
169
+ align: "center",
170
+ enum: terminalEnum,
171
+ isFilterEnum: true,
172
+ tag: true,
173
+ search: { el: "select", props: { clearable: true } }
174
+ },
175
+ { prop: "message_count", label: t("system.ai.session.field.message_count"), width: 100, align: "right" },
176
+ { prop: "input_tokens", label: t("system.ai.session.field.input_tokens"), width: 110, align: "right" },
177
+ { prop: "output_tokens", label: t("system.ai.session.field.output_tokens"), width: 110, align: "right" },
178
+ { prop: "total_tokens", label: t("system.ai.session.field.total_tokens"), width: 110, align: "right" },
179
+ { prop: "updated_at", label: t("common.field.updated_at"), minWidth: 170, align: "center" },
180
+ {
181
+ prop: "operation",
182
+ label: t("common.field.operation"),
183
+ cellType: "actions",
184
+ fixed: "right",
185
+ width: 170,
186
+ actions: [
187
+ { label: t("common.action.detail"), link: true, icon: View, hidden: () => !BUTTONS.value["base:ai-session:detail"], onClick: scope => openDetail(scope.row as AiSessionRecord) },
188
+ { label: t("system.ai.session.action.usage"), link: true, icon: DataAnalysis, hidden: () => !BUTTONS.value["base:ai-session:usage"], onClick: scope => openUsage(scope.row as AiSessionRecord) }
189
+ ]
190
+ }
191
+ ]);
192
+
193
+ /** 详情抽屉内按聊天窗口结构拆分的提问与回复气泡。 */
194
+ const detailBubbles = computed<DetailBubbleItem[]>(() =>
195
+ detail.list.flatMap(message => {
196
+ const attachments = (message.attachments ?? []).map(item => ({
197
+ id: "",
198
+ name: item.name,
199
+ size: item.size,
200
+ url: item.url,
201
+ mime_type: item.mime_type
202
+ })) as AiAttachment[];
203
+ const question: DetailBubbleItem = {
204
+ key: `question-${message.id}`,
205
+ role: "user",
206
+ kind: "text",
207
+ content: String(message.input_content ?? ""),
208
+ placement: "end",
209
+ attachments: attachments
210
+ } as DetailBubbleItem;
211
+ const answer: DetailBubbleItem = {
212
+ key: `answer-${message.id}`,
213
+ role: "ai",
214
+ kind: "text",
215
+ content: String(message.output_content ?? ""),
216
+ placement: "start",
217
+ model: message.model,
218
+ status: message.status,
219
+ createdAtText: message.created_at,
220
+ inputTokens: message.input_tokens,
221
+ outputTokens: message.output_tokens,
222
+ totalTokens: message.total_tokens
223
+ } as DetailBubbleItem;
224
+ return [question, answer];
225
+ })
226
+ );
227
+
228
+ /** 请求AI会话表格数据。 */
229
+ async function requestTable(params: Record<string, unknown>) {
230
+ const request = buildPageRequest<PageAiSessionRequest>(params as unknown as PageAiSessionRequest);
231
+ if (!request.terminal) delete request.terminal;
232
+ const data = await defAiSessionService.PageAiSession(request);
233
+ return { data: { list: data.sessions ?? [], total: data.total } };
234
+ }
235
+
236
+ /** 展示会话所属用户,昵称缺失时回退账号,用户已删除时标注未知用户。 */
237
+ function displayUser(row: AiSessionRecord): string {
238
+ return row.nick_name || row.user_name || t("system.ai.session.unknown_user", { id: row.user_id ?? 0 });
239
+ }
240
+
241
+ /** 转换终端枚举为展示文案。 */
242
+ function terminalLabel(terminal?: Terminal): string {
243
+ return terminal === Terminal.TERMINAL_APP ? t("system.ai.session.terminal.app") : t("system.ai.session.terminal.admin");
244
+ }
245
+
246
+ /** 转换消息状态为展示文案。 */
247
+ function statusLabel(status?: AiMessageStatus): string {
248
+ switch (status) {
249
+ case AiMessageStatus.AI_MESSAGE_STATUS_SUCCESS:
250
+ return t("system.ai.session.status.success");
251
+ case AiMessageStatus.AI_MESSAGE_STATUS_FAILED:
252
+ return t("system.ai.session.status.failed");
253
+ case AiMessageStatus.AI_MESSAGE_STATUS_GENERATING:
254
+ return t("system.ai.session.status.generating");
255
+ default:
256
+ return "";
257
+ }
258
+ }
259
+
260
+ /** 转换消息状态为标签类型。 */
261
+ function statusTagType(status?: AiMessageStatus): "success" | "danger" | "warning" | "info" {
262
+ switch (status) {
263
+ case AiMessageStatus.AI_MESSAGE_STATUS_SUCCESS:
264
+ return "success";
265
+ case AiMessageStatus.AI_MESSAGE_STATUS_FAILED:
266
+ return "danger";
267
+ case AiMessageStatus.AI_MESSAGE_STATUS_GENERATING:
268
+ return "warning";
269
+ default:
270
+ return "info";
271
+ }
272
+ }
273
+
274
+ /** 构建消息附件卡片,与聊天窗口一致。 */
275
+ function buildMessageAttachmentItems(attachments: AiAttachment[]): FilesCardProps[] {
276
+ return attachments.map(attachment => buildAIAttachmentFileCard(attachment, { maxWidth: "240px" }));
277
+ }
278
+
279
+ /** 打开会话详情抽屉。 */
280
+ async function openDetail(row: AiSessionRecord) {
281
+ detail.row = row;
282
+ detail.visible = true;
283
+ detail.pageNum = 1;
284
+ detail.total = 0;
285
+ detail.list = [];
286
+ await loadDetailMessages();
287
+ }
288
+
289
+ /** 加载会话详情消息列表。 */
290
+ async function loadDetailMessages() {
291
+ const row = detail.row;
292
+ if (!row) return;
293
+ detail.loading = true;
294
+ try {
295
+ const response = await defAiSessionService.PageAiSessionMessage({ id: row.id, page_num: detail.pageNum, page_size: detail.pageSize });
296
+ detail.list = response.messages ?? [];
297
+ detail.total = response.total;
298
+ } finally {
299
+ detail.loading = false;
300
+ }
301
+ }
302
+
303
+ /** 打开会话用量统计弹窗。 */
304
+ async function openUsage(row: AiSessionRecord) {
305
+ usage.row = row;
306
+ usage.visible = true;
307
+ usage.list = [];
308
+ await loadUsageMessages();
309
+ }
310
+
311
+ /** 加载当前会话全部消息。 */
312
+ async function loadUsageMessages() {
313
+ const row = usage.row;
314
+ if (!row) return;
315
+ usage.loading = true;
316
+ try {
317
+ let pageNum = 1;
318
+ while (pageNum * USAGE_PAGE_SIZE <= MAX_USAGE_MESSAGES) {
319
+ const response = await defAiSessionService.PageAiSessionMessage({ id: row.id, page_num: pageNum, page_size: USAGE_PAGE_SIZE });
320
+ const messages = response.messages ?? [];
321
+ usage.list.push(...messages);
322
+ if (messages.length < USAGE_PAGE_SIZE || usage.list.length >= response.total) break;
323
+ pageNum++;
324
+ }
325
+ } finally {
326
+ usage.loading = false;
327
+ }
328
+ }
329
+ </script>
330
+
331
+ <style scoped>
332
+ .ai-session-detail {
333
+ display: flex;
334
+ flex-direction: column;
335
+ gap: 16px;
336
+ }
337
+
338
+ .ai-session-detail__head {
339
+ display: flex;
340
+ flex-direction: column;
341
+ gap: 8px;
342
+ padding-bottom: 12px;
343
+ border-bottom: 1px solid var(--el-border-color-lighter);
344
+ }
345
+
346
+ .ai-session-detail__title {
347
+ font-size: 16px;
348
+ font-weight: 600;
349
+ }
350
+
351
+ .ai-session-detail__summary {
352
+ color: var(--el-text-color-secondary);
353
+ font-size: 13px;
354
+ }
355
+
356
+ .ai-session-detail__meta {
357
+ display: flex;
358
+ align-items: center;
359
+ flex-wrap: wrap;
360
+ gap: 12px;
361
+ color: var(--el-text-color-secondary);
362
+ font-size: 12px;
363
+ }
364
+
365
+ .ai-session-detail__chat {
366
+ min-height: 120px;
367
+ }
368
+
369
+ .ai-session-detail__empty {
370
+ padding: 32px 0;
371
+ color: var(--el-text-color-secondary);
372
+ text-align: center;
373
+ }
374
+
375
+ .ai-session-detail__bubbles {
376
+ width: 100%;
377
+ }
378
+
379
+ .ai-session-detail__bubble {
380
+ min-width: 0;
381
+ }
382
+
383
+ .ai-session-detail__message-meta {
384
+ display: flex;
385
+ align-items: center;
386
+ flex-wrap: wrap;
387
+ gap: 10px;
388
+ margin-bottom: 4px;
389
+ color: var(--el-text-color-secondary);
390
+ font-size: 12px;
391
+ }
392
+
393
+ .ai-session-detail__content {
394
+ font-size: 13px;
395
+ line-height: 1.7;
396
+ overflow-wrap: anywhere;
397
+ }
398
+
399
+ .ai-session-detail__content.is-user {
400
+ white-space: pre-wrap;
401
+ word-break: break-word;
402
+ }
403
+
404
+ .ai-session-detail__attachments {
405
+ margin-top: 8px;
406
+ }
407
+
408
+ .ai-session-detail__pagination {
409
+ justify-content: flex-end;
410
+ }
411
+
412
+ .ai-session-usage {
413
+ display: flex;
414
+ flex-direction: column;
415
+ gap: 16px;
416
+ }
417
+
418
+ /* 用量表格单元格统一不换行,避免时间等列折行。 */
419
+ .ai-session-usage :deep(.el-table .cell) {
420
+ white-space: nowrap;
421
+ }
422
+ </style>