Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions ui/src/api/API_README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,9 @@ API 枚举与类型统一在 `src/api` 范围内管理,相关规则由本文
- Admin 普通 JSON 请求使用 Axios;`request.ts` 导出 Axios 实例以及 `promise`、`get`、
`post`、`put` 和 `del` 请求封装。
- 正常 JSON 接口返回 `Promise<T>`,请求层负责解包后端 `{ code, message, data }` 响应。
- GET 文件导出使用 `getExportFile`,由请求层统一获取 Blob、解析 `Content-Disposition` 文件名
并触发浏览器下载;业务 API 只需传入默认文件名、接口地址和可选请求参数。
- GET 文件导出使用 `getExportFile`;需要通过 POST 同时传递查询参数和可选请求体的 Excel 导出
使用 `postExportExcel`。请求层统一获取 Blob、解析 `Content-Disposition` 文件名并触发浏览器
下载;业务 API 只需传入默认文件名、接口地址及业务参数。
- 业务代码通过 `api.method().then(...)` 处理接口成功后的状态变化;通用接口错误由请求层统一
提示,不在调用处重复使用 `try/catch` 或 `.catch()` 提示相同错误。只有业务降级、状态恢复等
非提示类失败处理可以按需保留失败分支。
Expand Down
65 changes: 47 additions & 18 deletions ui/src/api/admin/core/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,31 @@ async function getResponseErrorMessage(error: unknown) {
return responseData?.message
}

async function downloadExportResponse(
response: AxiosResponse<Blob>,
fileName: string,
mimeType = 'application/octet-stream',
) {
if (response.data.type.includes('application/json')) {
const text = await response.data.text()
try {
const data = JSON.parse(text) as Partial<ApiResponse<unknown>>
MsgError(data.message || text)
} catch {
MsgError(text)
}
throw new Error('Response is not a valid file')
}

const blob = new Blob([response.data], { type: mimeType })
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = extractFilename(response.headers['content-disposition']) || fileName
link.click()
URL.revokeObjectURL(link.href)
return true
}

export const request = axios.create({
baseURL: `${ADMIN_BASE_PATH.replace(/\/+$/, '')}/api`,
timeout: DEFAULT_TIMEOUT,
Expand Down Expand Up @@ -197,24 +222,29 @@ export async function getExportFile(
skipGlobalErrorMessage: true,
} as ExportRequestConfig)

if (response.data.type.includes('application/json')) {
const text = await response.data.text()
try {
const data = JSON.parse(text) as Partial<ApiResponse<unknown>>
MsgError(data.message || text)
} catch {
MsgError(text)
}
throw new Error('Response is not a valid file')
}
return downloadExportResponse(response, fileName)
} finally {
finishLoading(loading)
}
}

const blob = new Blob([response.data], { type: 'application/octet-stream' })
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = extractFilename(response.headers['content-disposition']) || fileName
link.click()
URL.revokeObjectURL(link.href)
return true
/** 发送 POST 请求并将 Blob 响应下载为 Excel 文件。 */
export async function postExportExcel<TData = unknown>(
fileName: string,
url: string,
params?: RequestParams,
data?: TData,
loading?: LoadingTarget,
): Promise<boolean> {
startLoading(loading)
try {
const response = await request.post<Blob>(url, data, {
params,
responseType: 'blob',
skipGlobalErrorMessage: true,
} as ExportRequestConfig)

return downloadExportResponse(response, fileName, 'application/vnd.ms-excel')
} finally {
finishLoading(loading)
}
Expand All @@ -231,7 +261,6 @@ export function post<TData = unknown, T = unknown>(
return promise<T>(request.post<ApiResponse<T>>(url, data, { params, timeout }), loading)
}


/** 发送 PUT 请求。 */
export function put<TData = unknown, T = unknown>(
url: string,
Expand Down
26 changes: 13 additions & 13 deletions ui/src/api/admin/system/operate-log.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { get, post, postBlob } from '../core/request'
import { postExportExcel, get, post } from '../core/request'
import type { ParamsPage, ResponsePage } from '../core/types'
import type { OperateLog, OperateLogMenuOption, OperateLogQuery } from '@/api/types'
import type { OperateLog, OperateLogMenuOption, RequestParams } from '@/api/types'

const prefix = '/operate_log'

/** 获取操作日志分页列表。 */
const getOperateLogPage = (page: ParamsPage, query: OperateLogQuery) => {
const getOperateLogPage = (page: ParamsPage, query: RequestParams) => {
return get<ResponsePage<OperateLog>>(`${prefix}/${page.currentPage}/${page.pageSize}`, query)
}

Expand All @@ -14,25 +14,25 @@ const getOperateLogMenuOptions = () => {
return get<OperateLogMenuOption[]>(`${prefix}/menu_operation_option/`)
}

/** 导出符合当前筛选条件的操作日志。 */
const postOperateLogExport = (query: OperateLogQuery) => {
return postBlob(`${prefix}/export/`, query)
/** 导出操作日志。 */
const exportOperateLog = (query: RequestParams) => {
return postExportExcel('log.xlsx', `${prefix}/export/`, query)
}

/** 保存操作日志自动清理天数。 */
const postOperateLogCleanTime = (cleanTime: number) => {
return post<{ clean_time: number }, boolean>(`${prefix}/save`, { clean_time: cleanTime })
}

/** 获取操作日志自动清理天数。 */
/** 获取对话日志自动清理天数。 */
const getOperateLogCleanTime = () => {
return get<number>(`${prefix}/get_clean_time`)
}

/** 保存对话日志自动清理天数。 */
const postOperateLogCleanTime = (cleanTime: number) => {
return post<{ clean_time: number }, boolean>(`${prefix}/save`, { clean_time: cleanTime })
}

export default {
exportOperateLog,
getOperateLogCleanTime,
getOperateLogMenuOptions,
getOperateLogPage,
postOperateLogCleanTime,
postOperateLogExport,
}
10 changes: 0 additions & 10 deletions ui/src/api/types/system-operate-log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,3 @@ export interface OperateLogMenuOption {
menu: string
menu_label: string
}

export interface OperateLogQuery extends RequestParams {
end_time?: string
ip_address?: string
menu?: string
start_time?: string
status?: string
user?: string
workspace_ids?: string
}
2 changes: 1 addition & 1 deletion ui/src/assets/iconfont.js

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions ui/src/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ declare module 'vue' {
MkSearchInput: typeof import('./components/global/mk-search-input/index.vue')['default']
MkStatusLabel: typeof import('./components/global/mk-status-label/index.vue')['default']
MkTable: typeof import('./components/global/mk-table/index.vue')['default']
MkTableFilter: typeof import('./components/global/mk-table/mk-table-filter.vue')['default']
MkTableMoreDropdown: typeof import('./components/global/mk-table/mk-table-more-dropdown.vue')['default']
MkTagGroup: typeof import('./components/global/mk-tag-group/index.vue')['default']
MkViewLayout: typeof import('./components/global/mk-view-layout/index.vue')['default']
Expand Down Expand Up @@ -55,6 +56,7 @@ declare global {
const MkSearchInput: typeof import('./components/global/mk-search-input/index.vue')['default']
const MkStatusLabel: typeof import('./components/global/mk-status-label/index.vue')['default']
const MkTable: typeof import('./components/global/mk-table/index.vue')['default']
const MkTableFilter: typeof import('./components/global/mk-table/mk-table-filter.vue')['default']
const MkTableMoreDropdown: typeof import('./components/global/mk-table/mk-table-more-dropdown.vue')['default']
const MkTagGroup: typeof import('./components/global/mk-tag-group/index.vue')['default']
const MkViewLayout: typeof import('./components/global/mk-view-layout/index.vue')['default']
Expand Down
51 changes: 50 additions & 1 deletion ui/src/components/COMPONENT_README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,13 @@ src/components/
│ │ └── index.vue # 布尔状态图标和文案
│ ├── mk-table/
│ │ ├── index.vue # 表格、分页、列宽拖拽和批量操作
│ │ ├── mk-table-filter.vue # 表头多选筛选器
│ │ └── mk-table-more-dropdown.vue # 表格操作列 More 下拉菜单
│ └── mk-tag-group/
│ └── index.vue # 标签折叠和剩余标签浮层
├── mk-date-range/
│ ├── index.vue # 日期预设与自定义日期区间组合筛选器,手动导入
│ └── types.ts # 日期筛选结果类型
├── mk-search-list/
│ └── index.vue # 搜索框与剩余空间滚动列表,手动导入
├── mk-form-list/
Expand All @@ -100,6 +104,7 @@ Vue 模板中使用,不需要手动导入。其他共享组件必须从具体
```ts
import MkSearchList from '@/components/mk-search-list/index.vue'
import MkFormList from '@/components/mk-form-list/index.vue'
import MkDateRange from '@/components/mk-date-range/index.vue'
import PythonCodeEditor from '@/components/codemirror-editor/python.vue'
import JsonInput from '@/components/codemirror-editor/Json.vue'
import MkSourceCard from '@/components/mk-source-card/index.vue'
Expand Down Expand Up @@ -472,7 +477,26 @@ const paginationConfig = ref({

`maxTableHeight` 表示窗口中除表格外需要扣除的高度,默认为 `250`;组件会在窗口尺寸变化时
重新计算 `max-height`。传入 `resizable` 后启用列宽拖拽,并隐藏为拖拽借用的原生边框视觉。
需要显示 Element Plus 原生边框时直接使用 `el-table`。
`resizable` 采用白名单式启用:只有需求明确指定的页面级表格才能开启;未明确指定的表格,以及
Dialog、Drawer、Popover、嵌套区域等其他大、小表格均禁止开启。需要显示 Element Plus 原生边框
时直接使用 `el-table`。

表头需要多选筛选时使用 `MkTableFilter`。`label` 设置表头文案,`options` 接收
`OptionItem<string>[]`,`v-model` 绑定已选值;初始不选择任何选项,确认或重置后通过 `change`
返回筛选值。过长的选项文案会显示省略号,悬停时可查看完整文案。

```vue
<el-table-column prop="menu" min-width="140">
<template #header>
<MkTableFilter
v-model="selectedMenus"
label="操作菜单"
:options="menuOptions"
@change="loadRecords()"
/>
</template>
</el-table-column>
```

表格操作列需要 More 菜单时使用 `MkTableMoreDropdown`。组件统一提供点击型、右下定位的 More
按钮以及 `MkDropdownMenu`,默认插槽中直接放置 `MkDropdownItem`。其他 Dropdown 属性和事件通过
Expand Down Expand Up @@ -513,6 +537,31 @@ const paginationConfig = ref({

## 手动导入组件

### MkDateRange

组合日期预设下拉框和自定义日期区间选择器。默认显示“过去 7 天”,仅在用户修改筛选条件时通过
`change` 返回 `{ startTime, endTime }`;组件挂载时不主动触发 `change`。预设日期的 `endTime` 为
空字符串,自定义日期清空时两个字段均为空字符串。组件不绑定具体接口字段,使用方负责初始化
默认查询参数,并将筛选结果映射为业务查询参数。

```vue
<script setup lang="ts">
import { ref } from 'vue'
import MkDateRange from '@/components/mk-date-range/index.vue'
import type { MkDateRangeValue } from '@/components/mk-date-range/types'
import { beforeDay } from '@/utils/time'

const dateQuery = ref({ start_time: beforeDay(7), end_time: '' })

function handleDateRangeChange({ startTime, endTime }: MkDateRangeValue) {
dateQuery.value = { start_time: startTime, end_time: endTime }
loadRecords()
}
</script>

<MkDateRange @change="handleDateRangeChange" />
```

### PythonCodeEditor

基于 CodeMirror 6 的 Python 代码编辑器,通过 `v-model` 管理代码,并在组件内部调用工具 pylint
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ const tableData = computed(() => {
<template #reference>
<MkTagGroup :tags="tags" popover-disabled class="cursor-pointer" />
</template>
<div class="px-3 py-2">
<div class="p-6">
<MkTable :data="tableData" max-height="300">
<template v-for="(label, prop) in tableRenderParams" :key="prop">
<el-table-column
Expand Down
86 changes: 19 additions & 67 deletions ui/src/components/global/mk-table/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,7 @@ const props = withDefaults(
data?: unknown[]
maxTableHeight?: number
paginationConfig?: PaginationConfig
resizable?: boolean
isSearching?: boolean
resizable?: boolean // 非指定表格禁止开启
}>(),
{
data: () => [],
Expand Down Expand Up @@ -68,51 +67,6 @@ function clearSelection() {
selectedRows.value = []
}

/**
* 列宽拖拽
* resizable 借用 Element Plus border 开启拖拽交互,并补充固定在列边界的 Hover 提示线。
*/
const tableRootRef = ref<HTMLElement>()
const resizeHoverStyle = ref<{ height: string; left: string; top: string }>()

function clearResizeHover() {
resizeHoverStyle.value = undefined
}

function handleTableMouseMove(event: MouseEvent) {
if (!props.resizable) {
clearResizeHover()
return
}

const resizeProxy = tableRootRef.value?.querySelector('.el-table__column-resize-proxy')

if (resizeProxy instanceof HTMLElement && resizeProxy.style.display !== 'none') {
clearResizeHover()
return
}

const target = event.target instanceof Element ? event.target.closest('th.el-table__cell') : null

if (document.body.style.cursor !== 'col-resize' || !(target instanceof HTMLElement)) {
clearResizeHover()
return
}

const rootRect = tableRootRef.value?.getBoundingClientRect()
const targetRect = target.getBoundingClientRect()

if (!rootRect) {
return
}

resizeHoverStyle.value = {
height: `${targetRect.height}px`,
left: `${targetRect.right - rootRect.left}px`,
top: `${targetRect.top - rootRect.top}px`,
}
}

/** 分页 */
function handleCurrentPageChange(currentPage: number) {
if (!props.paginationConfig) {
Expand Down Expand Up @@ -152,12 +106,7 @@ defineExpose({ clearSelection, tableRef })
</script>

<template>
<div
ref="tableRootRef"
class="mk-table relative flex w-full min-h-0 flex-1 flex-col"
@mouseleave="clearResizeHover"
@mousemove="handleTableMouseMove"
>
<div class="mk-table relative flex w-full min-h-0 flex-1 flex-col">
<el-table
ref="tableRef"
:class="{ 'mk-table__resizable--borderless': props.resizable }"
Expand All @@ -171,12 +120,6 @@ defineExpose({ clearSelection, tableRef })
<slot />
</el-table>

<div
v-if="props.resizable && resizeHoverStyle"
class="mk-table__resize-hover-indicator"
:style="resizeHoverStyle"
/>

<div class="mt-4 flex justify-end" v-if="props.paginationConfig">
<el-pagination
background
Expand Down Expand Up @@ -212,6 +155,8 @@ defineExpose({ clearSelection, tableRef })

<style scoped lang="scss">
:deep(.mk-table__resizable--borderless) {
border: none !important;

&,
&::after,
&::before,
Expand All @@ -223,14 +168,21 @@ defineExpose({ clearSelection, tableRef })
.el-table__border-left-patch {
display: none;
}
}

.mk-table__resize-hover-indicator {
background-color: color-mix(in srgb, var(--mk-primary) 20%, transparent);
pointer-events: none;
position: absolute;
transform: translateX(-50%);
width: 6px;
z-index: 10;
.el-table__header-wrapper:hover th.el-table__cell:not(:last-child)::after {
background-color: var(--mk-N300);
content: '';
height: 22px;
pointer-events: none;
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
width: 2px;
z-index: 1;
}
.el-table__column-resize-proxy {
border-left: 2px solid var(--el-color-primary);
}
}
</style>
Loading
Loading