diff --git a/frontend/src/components/complex-table/index.vue b/frontend/src/components/complex-table/index.vue deleted file mode 100644 index dfd5bc232c1c..000000000000 --- a/frontend/src/components/complex-table/index.vue +++ /dev/null @@ -1,564 +0,0 @@ - - - - {{ header }} - - - - - - - - - - - - - - - - - {{ btn.label }} - - - - - - - diff --git a/frontend/src/components/fu/FuTableOperations.vue b/frontend/src/components/fu/FuTableOperations.vue deleted file mode 100644 index 9602d1c9891e..000000000000 --- a/frontend/src/components/fu/FuTableOperations.vue +++ /dev/null @@ -1,228 +0,0 @@ - - - - - - - - - - {{ button.label }} - - - - handleButtonClick(button, row)" - > - - - {{ t('fu.table.more') }} - - - - - - - - - {{ button.label }} - - - - - - - - - - - diff --git a/frontend/src/components/fu/__compat-smoke__.ts b/frontend/src/components/fu/__compat-smoke__.ts deleted file mode 100644 index e206e42101d8..000000000000 --- a/frontend/src/components/fu/__compat-smoke__.ts +++ /dev/null @@ -1,3 +0,0 @@ -import FuCompatComponents from '@/components/fu'; - -void FuCompatComponents; diff --git a/frontend/src/components/fu/index.ts b/frontend/src/components/fu/index.ts deleted file mode 100644 index 0a1ebe0cc974..000000000000 --- a/frontend/src/components/fu/index.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { type App } from 'vue'; - -import FuInputRwSwitch from './FuInputRwSwitch.vue'; -import FuDropdownItem from './FuDropdownItem.vue'; -import FuReadWriteSwitch from './FuReadWriteSwitch.vue'; -import FuSelectRwSwitch from './FuSelectRwSwitch.vue'; -import FuStep from './FuStep'; -import FuSteps from './FuSteps'; -import FuTable from './FuTable'; -import FuTableColumnSelect from './FuTableColumnSelect.vue'; -import FuTableOperations from './FuTableOperations.vue'; -import FuTablePagination from './FuTablePagination.vue'; - -const components = [ - FuTable, - FuTableOperations, - FuTablePagination, - FuDropdownItem, - FuInputRwSwitch, - FuReadWriteSwitch, - FuSelectRwSwitch, - FuSteps, - FuStep, - FuTableColumnSelect, -]; - -export default { - install(app: App) { - components.forEach((component) => { - app.component((component as any).name, component as any); - }); - }, -}; diff --git a/frontend/src/components/fu/shared.ts b/frontend/src/components/fu/shared.ts deleted file mode 100644 index 44d079fffbcd..000000000000 --- a/frontend/src/components/fu/shared.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { Comment, Fragment, Text, type VNode } from 'vue'; -import type { PermissionBindingValue } from '@/utils/permission'; - -export interface FuTableColumnConfig { - key: string; - label: string; - prop?: string; - show: boolean; - fixed?: boolean | string; -} - -export interface FuTableOperationButton { - label?: string | number; - click?: (row: any) => void; - disabled?: boolean | ((row: any) => boolean); - permission?: true | PermissionBindingValue; - nodeAdmin?: boolean; - show?: boolean | ((row: any) => boolean); - type?: string; - icon?: any; - divided?: boolean; - [key: string]: any; -} - -export const FU_TABLE_STORAGE_PREFIX = 'FU-T-'; - -export const flattenVNodes = (nodes: VNode[] = []): VNode[] => { - const flattened: VNode[] = []; - for (const node of nodes) { - if (!node) { - continue; - } - if (Array.isArray(node)) { - flattened.push(...flattenVNodes(node)); - continue; - } - if (node.type === Fragment) { - flattened.push(...flattenVNodes((node.children as VNode[]) || [])); - continue; - } - if (node.type === Comment || node.type === Text) { - continue; - } - flattened.push(node); - } - return flattened; -}; - -export const getVNodeComponentName = (vnode: VNode) => { - const type = vnode.type as any; - return type?.name || type?.__name || type; -}; - -export const isElTableColumnVNode = (vnode: VNode) => { - const type = vnode.type as any; - return type === 'el-table-column' || type?.name === 'ElTableColumn' || type?.__name === 'ElTableColumn'; -}; - -export const resolveMaybeFn = (value: R | ((row: T) => R), row: T): R => { - if (typeof value === 'function') { - return (value as (row: T) => R)(row); - } - return value; -}; - -export const updateArrayInPlace = (target: T[], next: T[]) => { - if (target.length === next.length && target.every((item, index) => item === next[index])) { - return; - } - target.splice(0, target.length, ...next); -}; diff --git a/frontend/src/components/fu/steps/FuHorizontalSteps.ts b/frontend/src/components/fu/steps/FuHorizontalSteps.ts index f6a62363dcaf..e091bcd18803 100644 --- a/frontend/src/components/fu/steps/FuHorizontalSteps.ts +++ b/frontend/src/components/fu/steps/FuHorizontalSteps.ts @@ -1,6 +1,6 @@ import { computed, defineComponent, h, provide, ref, Transition, watch } from 'vue'; -import { flattenVNodes, getVNodeComponentName } from '../shared'; +import { flattenVNodes, getVNodeComponentName } from '@/components/shared/vnode'; import FuHorizontalNavigation from './FuHorizontalNavigation.vue'; import FuStepsFooter from './FuStepsFooter'; import { Step, Stepper } from './Stepper'; diff --git a/frontend/src/components/fu/steps/FuVerticalSteps.ts b/frontend/src/components/fu/steps/FuVerticalSteps.ts index 09bfdc8d9ce2..238057ecf5ee 100644 --- a/frontend/src/components/fu/steps/FuVerticalSteps.ts +++ b/frontend/src/components/fu/steps/FuVerticalSteps.ts @@ -1,6 +1,6 @@ import { defineComponent, h, provide, ref, watch } from 'vue'; -import { flattenVNodes, getVNodeComponentName } from '../shared'; +import { flattenVNodes, getVNodeComponentName } from '@/components/shared/vnode'; import FuStepsFooter from './FuStepsFooter'; import FuVerticalNavigation from './FuVerticalNavigation.vue'; import { Step, Stepper } from './Stepper'; diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index 353cd695840d..6b6afa71665d 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -1,29 +1,32 @@ import { type App } from 'vue'; import LayoutContent from './layout-content/index.vue'; import RouterButton from './router-button/index.vue'; -import ComplexTable from './complex-table/index.vue'; +import ComplexTable from './table/complex/ComplexTable.vue'; import OpDialog from './del-dialog/index.vue'; -import TableSearch from './table-search/index.vue'; -import TableSetting from './table-setting/index.vue'; -import TableRefresh from './table-refresh/index.vue'; import CopyButton from '@/components/copy-button/index.vue'; import MsgInfo from '@/components/msg-info/index.vue'; import DrawerPro from '@/components/drawer-pro/index.vue'; import DialogPro from '@/components/dialog-pro/index.vue'; -import FuComponents from '@/components/fu'; +import FuDropdownItem from '@/components/fu/FuDropdownItem.vue'; +import FuInputRwSwitch from '@/components/fu/FuInputRwSwitch.vue'; +import FuReadWriteSwitch from '@/components/fu/FuReadWriteSwitch.vue'; +import FuSelectRwSwitch from '@/components/fu/FuSelectRwSwitch.vue'; +import FuStep from '@/components/fu/FuStep'; +import FuSteps from '@/components/fu/FuSteps'; +import TableComponents from '@/components/table'; export default { install(app: App) { - app.use(FuComponents); + app.use(TableComponents); app.component(LayoutContent.name, LayoutContent); app.component(RouterButton.name, RouterButton); app.component(ComplexTable.name, ComplexTable); app.component(OpDialog.name, OpDialog); app.component(CopyButton.name, CopyButton); - app.component(TableSearch.name, TableSearch); - app.component(TableSetting.name, TableSetting); - app.component(TableRefresh.name, TableRefresh); app.component(MsgInfo.name, MsgInfo); app.component(DrawerPro.name, DrawerPro); app.component(DialogPro.name, DialogPro); + [FuDropdownItem, FuInputRwSwitch, FuReadWriteSwitch, FuSelectRwSwitch, FuSteps, FuStep].forEach((component) => { + app.component((component as any).name, component as any); + }); }, }; diff --git a/frontend/src/components/shared/vnode.ts b/frontend/src/components/shared/vnode.ts new file mode 100644 index 000000000000..021190fccb7b --- /dev/null +++ b/frontend/src/components/shared/vnode.ts @@ -0,0 +1,28 @@ +import { Comment, Fragment, Text, type VNode } from 'vue'; + +export const flattenVNodes = (nodes: VNode[] = []): VNode[] => { + const flattened: VNode[] = []; + for (const node of nodes) { + if (!node) { + continue; + } + if (Array.isArray(node)) { + flattened.push(...flattenVNodes(node)); + continue; + } + if (node.type === Fragment) { + flattened.push(...flattenVNodes((node.children as VNode[]) || [])); + continue; + } + if (node.type === Comment || node.type === Text) { + continue; + } + flattened.push(node); + } + return flattened; +}; + +export const getVNodeComponentName = (vnode: VNode) => { + const type = vnode.type as any; + return type?.name || type?.__name || type; +}; diff --git a/frontend/src/components/fu/FuTable.ts b/frontend/src/components/table/Table.ts similarity index 88% rename from frontend/src/components/fu/FuTable.ts rename to frontend/src/components/table/Table.ts index 54e3291df0af..5ed14959bd17 100644 --- a/frontend/src/components/fu/FuTable.ts +++ b/frontend/src/components/table/Table.ts @@ -4,14 +4,14 @@ import { cloneVNode, computed, defineComponent, h, onMounted, ref, watch, type P import { FU_TABLE_STORAGE_PREFIX, flattenVNodes, + getTableColumnKey, isElTableColumnVNode, type FuTableColumnConfig, updateArrayInPlace, } from './shared'; const buildColumnKey = (vnode: VNode, index: number) => { - const props = (vnode.props || {}) as Record; - return String(props.columnKey || props.prop || props.label || `column-${index}`); + return getTableColumnKey(vnode) || `column-${index}`; }; const buildColumnConfig = (vnode: VNode, index: number): FuTableColumnConfig => { @@ -150,15 +150,17 @@ export default defineComponent({ if (!vnode) { return null; } + const vnodeProps = (vnode.props || {}) as Record; return cloneVNode(vnode, { key: column.key, + fixed: vnodeProps.fixed ?? (vnodeProps.fix || undefined), }); }) .filter((column): column is VNode => Boolean(column)); }); const renderedChildren = computed(() => { - if (orderedConfigurableChildren.value.length === 0) { + if (slotColumns.value.length === 0) { return slotChildren.value; } const children: VNode[] = []; @@ -187,7 +189,11 @@ export default defineComponent({ if (!props.localKey || !nextColumns || typeof window === 'undefined') { return; } - localStorage.setItem(`${FU_TABLE_STORAGE_PREFIX}${props.localKey}`, JSON.stringify(nextColumns)); + try { + localStorage.setItem(`${FU_TABLE_STORAGE_PREFIX}${props.localKey}`, JSON.stringify(nextColumns)); + } catch { + // Ignore unavailable or full browser storage; column configuration remains usable in memory. + } }, { deep: true, @@ -203,6 +209,12 @@ export default defineComponent({ expose({ refElTable }); + const resolvedHeaderRowClassName = (scope: unknown) => { + const externalClassName = attrs.headerRowClassName ?? attrs['header-row-class-name']; + const className = typeof externalClassName === 'function' ? externalClassName(scope) : externalClassName; + return ['fu-table-header', className].filter(Boolean).join(' '); + }; + return () => h( ElTable as any, @@ -210,7 +222,7 @@ export default defineComponent({ ...attrs, ref: refElTable, class: ['fu-table', attrs.class], - headerRowClassName: 'fu-table-header', + headerRowClassName: resolvedHeaderRowClassName, }, { ...slots, diff --git a/frontend/src/components/fu/FuTableColumnSelect.vue b/frontend/src/components/table/TableColumnSelect.vue similarity index 100% rename from frontend/src/components/fu/FuTableColumnSelect.vue rename to frontend/src/components/table/TableColumnSelect.vue diff --git a/frontend/src/components/table/TableOperationActions.vue b/frontend/src/components/table/TableOperationActions.vue new file mode 100644 index 000000000000..2b3e80b4d1a1 --- /dev/null +++ b/frontend/src/components/table/TableOperationActions.vue @@ -0,0 +1,80 @@ + + + + + + {{ button.label }} + + + + + + + {{ t('fu.table.more') }} + + + + + + + {{ button.label }} + + + + + + + + + diff --git a/frontend/src/components/table/TableOperations.vue b/frontend/src/components/table/TableOperations.vue new file mode 100644 index 000000000000..7491408f7bb8 --- /dev/null +++ b/frontend/src/components/table/TableOperations.vue @@ -0,0 +1,153 @@ + + + + + + + + + + diff --git a/frontend/src/components/fu/FuTablePagination.vue b/frontend/src/components/table/TablePagination.vue similarity index 100% rename from frontend/src/components/fu/FuTablePagination.vue rename to frontend/src/components/table/TablePagination.vue diff --git a/frontend/src/components/table-refresh/index.vue b/frontend/src/components/table/TableRefresh.vue similarity index 100% rename from frontend/src/components/table-refresh/index.vue rename to frontend/src/components/table/TableRefresh.vue diff --git a/frontend/src/components/table-search/index.vue b/frontend/src/components/table/TableSearch.vue similarity index 100% rename from frontend/src/components/table-search/index.vue rename to frontend/src/components/table/TableSearch.vue diff --git a/frontend/src/components/table-setting/index.vue b/frontend/src/components/table/TableSetting.vue similarity index 100% rename from frontend/src/components/table-setting/index.vue rename to frontend/src/components/table/TableSetting.vue diff --git a/frontend/src/components/table/TableViewSwitch.vue b/frontend/src/components/table/TableViewSwitch.vue new file mode 100644 index 000000000000..441f0a17e69d --- /dev/null +++ b/frontend/src/components/table/TableViewSwitch.vue @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + diff --git a/frontend/src/components/table/complex/ComplexTable.vue b/frontend/src/components/table/complex/ComplexTable.vue new file mode 100644 index 000000000000..e9b702d4e320 --- /dev/null +++ b/frontend/src/components/table/complex/ComplexTable.vue @@ -0,0 +1,708 @@ + + + + {{ header }} + + + + + + + + + + + + + + + + + + + + + + + + + + + {{ getColumnLabel(contentColumn) }} + + + + + + + + {{ getColumnLabel(fullContentColumn) }} + + + + + + + + {{ getColumnLabel(descriptionColumn) }} + + + + + + + + + + + + + + + + + + + {{ btn.label }} + + + + + + + diff --git a/frontend/src/components/table/complex/useCardColumns.ts b/frontend/src/components/table/complex/useCardColumns.ts new file mode 100644 index 000000000000..74a3396f652b --- /dev/null +++ b/frontend/src/components/table/complex/useCardColumns.ts @@ -0,0 +1,88 @@ +import { computed, defineComponent, h, type Component, type PropType, type VNode } from 'vue'; + +import FuTableOperations from '@/components/table/TableOperations.vue'; +import { getTableColumnKey, isElTableColumnVNode } from '@/components/table/shared'; + +export type CardType = 'name' | 'status' | 'content' | 'content-full' | 'description' | 'button'; + +const cardTypes: CardType[] = ['name', 'status', 'content', 'content-full', 'description', 'button']; + +const isOperationsColumn = (column: VNode) => { + const type = column.type as any; + return ( + type === FuTableOperations || + type?.name === 'FuTableOperations' || + type?.__name === 'FuTableOperations' || + type === 'fu-table-operations' || + type === 'FuTableOperations' + ); +}; + +const CardColumnValue = defineComponent({ + name: 'ComplexTableCardColumnValue', + props: { + column: { type: Object as PropType, required: true }, + row: { type: Object, required: true }, + index: { type: Number, required: true }, + }, + setup(props) { + return () => { + const columnProps = (props.column.props || {}) as Record; + const scope = { row: props.row, $index: props.index, column: columnProps, viewMode: 'card' }; + let content: any; + if (isOperationsColumn(props.column)) { + content = h(FuTableOperations, { ...columnProps, cardRow: props.row }); + } else { + const defaultSlot = (props.column.children as any)?.default; + if (typeof defaultSlot === 'function') { + content = defaultSlot(scope); + } else { + const value = String(columnProps.prop || '') + .split('.') + .filter(Boolean) + .reduce((current, key) => current?.[key], props.row as any); + content = + typeof columnProps.formatter === 'function' + ? columnProps.formatter(props.row, columnProps, value, props.index) + : (value ?? '-'); + } + } + return h('span', { class: 'complex-table__card-column-value' }, content); + }; + }, +}); + +export const useCardColumns = (columnNodes: () => VNode[], columns: () => unknown) => { + const isVisible = (column: VNode) => { + if (isOperationsColumn(column)) { + return true; + } + const configuredColumns = columns(); + if (!Array.isArray(configuredColumns) || configuredColumns.length === 0) { + return true; + } + const config = configuredColumns.find((item: any) => item.key === getTableColumnKey(column)); + return config?.show !== false; + }; + + const cardColumns = computed>(() => { + const grouped = Object.fromEntries(cardTypes.map((type) => [type, []])) as Record; + for (const column of columnNodes().filter( + (vnode) => isElTableColumnVNode(vnode) || isOperationsColumn(vnode), + )) { + const columnProps = (column.props || {}) as Record; + const cardType = (columnProps.cardType || columnProps['card-type']) as CardType | undefined; + if (cardType && cardTypes.includes(cardType) && isVisible(column)) { + grouped[cardType].push(column); + } + } + return grouped; + }); + + return { + CardColumnValue: CardColumnValue as Component, + cardColumns, + getColumnKey: getTableColumnKey, + getColumnLabel: (column: VNode) => String((column.props as Record | null)?.label || ''), + }; +}; diff --git a/frontend/src/components/table/complex/useContextMenu.ts b/frontend/src/components/table/complex/useContextMenu.ts new file mode 100644 index 000000000000..d6324ab08c43 --- /dev/null +++ b/frontend/src/components/table/complex/useContextMenu.ts @@ -0,0 +1,44 @@ +import { computed, onBeforeUnmount, ref, toValue, type MaybeRefOrGetter } from 'vue'; +import { isOperationDisabled, isOperationVisible, type FuTableOperationButton } from '@/components/table/shared'; + +export const useContextMenu = (buttons: MaybeRefOrGetter[] | undefined>) => { + const contextMenu = ref({ + visible: false, + left: 0, + top: 0, + currentRow: null as Row | null, + }); + + const close = () => { + contextMenu.value.visible = false; + document.removeEventListener('click', close); + }; + + const open = (row: Row, event: MouseEvent) => { + if (!toValue(buttons)?.length) { + return; + } + event.preventDefault(); + contextMenu.value = { + visible: true, + left: event.clientX + 5, + top: event.clientY, + currentRow: row, + }; + document.addEventListener('click', close); + }; + + const visibleButtons = computed(() => + (toValue(buttons) || []).filter((button) => isOperationVisible(button, contextMenu.value.currentRow as Row)), + ); + const isDisabled = (button: FuTableOperationButton) => + isOperationDisabled(button, contextMenu.value.currentRow as Row); + const click = (button: FuTableOperationButton) => { + close(); + button.click?.(contextMenu.value.currentRow as Row); + }; + + onBeforeUnmount(close); + + return { contextMenu, open, close, visibleButtons, isDisabled, click }; +}; diff --git a/frontend/src/components/table/complex/useResponsivePagination.ts b/frontend/src/components/table/complex/useResponsivePagination.ts new file mode 100644 index 000000000000..62965547af3c --- /dev/null +++ b/frontend/src/components/table/complex/useResponsivePagination.ts @@ -0,0 +1,40 @@ +import { computed, onBeforeUnmount, onMounted, ref, type Ref } from 'vue'; + +interface PaginationConfig { + small?: boolean; +} + +export const useResponsivePagination = ( + paginationConfig: () => PaginationConfig | undefined, + isMobile: Ref, +) => { + const paginationRef = ref(null); + const paginationWidth = ref(0); + let resizeObserver: ResizeObserver | null = null; + + const updateWidth = () => { + paginationWidth.value = paginationRef.value?.clientWidth || 0; + }; + + const responsivePaginationLayout = computed(() => { + if (isMobile.value || paginationConfig()?.small || paginationWidth.value < 520) { + return 'total, prev, pager, next'; + } + return 'total, sizes, prev, pager, next, jumper'; + }); + + const responsivePagerCount = computed(() => + isMobile.value || paginationConfig()?.small || paginationWidth.value < 720 ? 5 : 7, + ); + + onMounted(() => { + updateWidth(); + if (paginationRef.value) { + resizeObserver = new ResizeObserver(updateWidth); + resizeObserver.observe(paginationRef.value); + } + }); + onBeforeUnmount(() => resizeObserver?.disconnect()); + + return { paginationRef, responsivePaginationLayout, responsivePagerCount }; +}; diff --git a/frontend/src/components/table/complex/useTableSelection.ts b/frontend/src/components/table/complex/useTableSelection.ts new file mode 100644 index 000000000000..dfb0a6116117 --- /dev/null +++ b/frontend/src/components/table/complex/useTableSelection.ts @@ -0,0 +1,154 @@ +import { ref, type Ref } from 'vue'; + +type TableRef = { refElTable?: any } | undefined; + +export const useTableSelection = ( + tableRef: Ref, + getTableData: () => any[], + onSelectionChange: (rows: any[]) => void, + isRowSelectable: (row: any) => boolean = () => true, +) => { + const selectedRows = ref([]); + const shiftPressed = ref(false); + const lastSelectedRow = ref(null); + const rangeBaseRows = ref([]); + let isSyncingTableSelection = false; + + const getTable = () => tableRef.value?.refElTable; + const clearTextSelection = () => window.getSelection?.()?.removeAllRanges(); + const hasActiveTextSelection = () => { + const selection = window.getSelection?.(); + return !!selection && !selection.isCollapsed && selection.toString().trim().length > 0; + }; + const setSelectedRows = (rows: any[]) => { + selectedRows.value = rows; + onSelectionChange(rows); + }; + const syncTableSelection = () => { + const table = getTable(); + if (!table) { + return; + } + isSyncingTableSelection = true; + try { + table.clearSelection(); + selectedRows.value + .filter((row) => getTableData().includes(row) && isRowSelectable(row)) + .forEach((row) => table.toggleRowSelection(row, true)); + } finally { + isSyncingTableSelection = false; + } + }; + const selectRow = (row: any, selected = !selectedRows.value.includes(row)) => { + if (!isRowSelectable(row)) { + return; + } + const nextRows = selected + ? selectedRows.value.includes(row) + ? selectedRows.value + : [...selectedRows.value, row] + : selectedRows.value.filter((item) => item !== row); + setSelectedRows(nextRows); + syncTableSelection(); + }; + const applyRangeSelection = (targetRow: any) => { + if (!lastSelectedRow.value) return false; + const tableData = getTableData(); + const startIndex = tableData.indexOf(lastSelectedRow.value); + const endIndex = tableData.indexOf(targetRow); + if (startIndex === -1 || endIndex === -1) return false; + + const [start, end] = [startIndex, endIndex].sort((a, b) => a - b); + const rangeRows = tableData.slice(start, end + 1).filter(isRowSelectable); + const nextRows = [...rangeBaseRows.value]; + rangeRows.forEach((row) => !nextRows.includes(row) && nextRows.push(row)); + setSelectedRows(nextRows); + syncTableSelection(); + return true; + }; + const handleSelectionChange = (rows: any[]) => { + if (isSyncingTableSelection) { + return; + } + setSelectedRows(rows); + if (rows.length === 0) { + lastSelectedRow.value = null; + rangeBaseRows.value = []; + } + }; + const handleSelect = (selection: any[], row: any) => { + if (shiftPressed.value && applyRangeSelection(row)) { + clearTextSelection(); + return; + } + lastSelectedRow.value = row; + rangeBaseRows.value = selection.filter((item) => item !== row); + clearTextSelection(); + }; + const clearSelects = () => { + setSelectedRows([]); + syncTableSelection(); + lastSelectedRow.value = null; + rangeBaseRows.value = []; + }; + const pruneSelection = () => { + const nextRows = selectedRows.value.filter((row) => getTableData().includes(row)); + if (nextRows.length !== selectedRows.value.length) { + setSelectedRows(nextRows); + } + if (lastSelectedRow.value && !nextRows.includes(lastSelectedRow.value)) { + lastSelectedRow.value = null; + rangeBaseRows.value = []; + } + syncTableSelection(); + }; + const toggleSelection = () => { + const selectableRows = getTableData().filter(isRowSelectable); + const allSelected = + selectableRows.length > 0 && selectableRows.every((row) => selectedRows.value.includes(row)); + const nextRows = allSelected + ? selectedRows.value.filter((row) => !selectableRows.includes(row)) + : [...selectedRows.value, ...selectableRows.filter((row) => !selectedRows.value.includes(row))]; + setSelectedRows(nextRows); + syncTableSelection(); + }; + const handleRowClick = (row: any, _column: any, event: MouseEvent) => { + if (!isRowSelectable(row) || (hasActiveTextSelection() && !event.shiftKey)) return; + const target = event.target as HTMLElement; + if ( + target.closest( + '.el-checkbox, button, a, input, textarea, [contenteditable="true"], .el-input, .el-textarea, .el-input-number, .el-date-editor, .el-switch, .el-select, .table-link, .cursor-pointer', + ) + ) + return; + if (event.shiftKey && applyRangeSelection(row)) { + clearTextSelection(); + return; + } + const selected = !selectedRows.value.includes(row); + selectRow(row, selected); + lastSelectedRow.value = row; + rangeBaseRows.value = selected ? selectedRows.value.filter((item) => item !== row) : selectedRows.value; + clearTextSelection(); + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Shift') shiftPressed.value = true; + }; + const handleKeyUp = (event: KeyboardEvent) => { + if (event.key === 'Shift') shiftPressed.value = false; + }; + + return { + selectedRows, + clearSelects, + pruneSelection, + toggleSelection, + selectRow, + syncTableSelection, + handleSelect, + handleSelectionChange, + handleRowClick, + handleKeyDown, + handleKeyUp, + }; +}; diff --git a/frontend/src/components/table/index.ts b/frontend/src/components/table/index.ts new file mode 100644 index 000000000000..3fa796bed9de --- /dev/null +++ b/frontend/src/components/table/index.ts @@ -0,0 +1,29 @@ +import type { App } from 'vue'; + +import FuTable from './Table'; +import FuTableColumnSelect from './TableColumnSelect.vue'; +import FuTableOperations from './TableOperations.vue'; +import FuTablePagination from './TablePagination.vue'; +import TableRefresh from './TableRefresh.vue'; +import TableSearch from './TableSearch.vue'; +import TableSetting from './TableSetting.vue'; +import TableViewSwitch from './TableViewSwitch.vue'; + +const components = [ + FuTable, + FuTableOperations, + FuTablePagination, + FuTableColumnSelect, + TableSearch, + TableSetting, + TableRefresh, + TableViewSwitch, +]; + +export default { + install(app: App) { + components.forEach((component) => { + app.component((component as any).name, component as any); + }); + }, +}; diff --git a/frontend/src/components/table/shared.ts b/frontend/src/components/table/shared.ts new file mode 100644 index 000000000000..2f4f09820eb8 --- /dev/null +++ b/frontend/src/components/table/shared.ts @@ -0,0 +1,65 @@ +import type { VNode } from 'vue'; +import { hasManagePermissionAccess, hasPermissionAccess, type PermissionBindingValue } from '@/utils/permission'; + +export { flattenVNodes } from '@/components/shared/vnode'; + +export interface FuTableColumnConfig { + key: string; + label: string; + prop?: string; + show: boolean; + fixed?: boolean | string; +} + +export interface FuTableOperationButton { + key?: string | number; + label?: string | number; + command?: string | number | object; + click?: (row: Row) => void; + disabled?: boolean | ((row: Row) => boolean); + permission?: true | PermissionBindingValue; + nodeAdmin?: boolean; + show?: boolean | ((row: Row) => boolean); + type?: string; + icon?: any; + divided?: boolean; +} + +export const FU_TABLE_STORAGE_PREFIX = 'FU-T-'; + +export const isElTableColumnVNode = (vnode: VNode) => { + const type = vnode.type as any; + return type === 'el-table-column' || type?.name === 'ElTableColumn' || type?.__name === 'ElTableColumn'; +}; + +export const getTableColumnKey = (vnode: VNode) => { + const props = (vnode.props || {}) as Record; + return String(props.columnKey || props.prop || vnode.key || props.label || ''); +}; + +export const resolveMaybeFn = (value: R | ((row: T) => R), row: T): R => { + if (typeof value === 'function') { + return (value as (row: T) => R)(row); + } + return value; +}; + +export const isOperationVisible = (button: FuTableOperationButton, row: T) => { + return resolveMaybeFn(button.show ?? true, row); +}; + +export const isOperationDisabled = (button: FuTableOperationButton, row: T) => { + const permissionOptions = { nodeAdmin: button.nodeAdmin === true }; + const permissionDisabled = + button.permission === true + ? !hasManagePermissionAccess(undefined, permissionOptions) + : button.permission !== undefined && !hasPermissionAccess(button.permission, permissionOptions); + return permissionDisabled || Boolean(resolveMaybeFn(button.disabled ?? false, row)); +}; + +export const updateArrayInPlace = (target: T[], next: T[]) => { + if (target.length === next.length && target.every((item, index) => item === next[index])) { + return; + } + target.splice(0, target.length, ...next); +}; diff --git a/frontend/src/lang/modules/en.ts b/frontend/src/lang/modules/en.ts index 0730f616a4fa..d4357c8c59ea 100644 --- a/frontend/src/lang/modules/en.ts +++ b/frontend/src/lang/modules/en.ts @@ -710,7 +710,7 @@ const message = { syncAgentsHelper: 'Update openclaw.json for agents using this model account', appVersion: 'App Version', webuiPort: 'WebUI Port', - tokenOrAuth: 'Token/Username & Password', + tokenOrAuth: 'Token/Auth', allowedOrigins: 'Access Addresses', allowedOriginsRequired: 'Enter at least one access address', allowedOriginsInvalid: 'Use the format http(s)://host-or-ip[:port]', @@ -5778,7 +5778,8 @@ const message = { storagePool: 'Storage Pool', network: 'Network', bridgeName: 'Bridge Name', - natNetworkHelper: 'VMs can access the network through the host, but external devices usually cannot access them directly.', + natNetworkHelper: + 'VMs can access the network through the host, but external devices usually cannot access them directly.', bridgeNetworkHelper: 'Connect VMs to an existing host bridge (such as br0). If the host has an unused additional NIC, create a bridge for it and use it.', bridgeNameHelper: diff --git a/frontend/src/lang/modules/es-es.ts b/frontend/src/lang/modules/es-es.ts index 43cdd419d9eb..f30d922b2b09 100644 --- a/frontend/src/lang/modules/es-es.ts +++ b/frontend/src/lang/modules/es-es.ts @@ -712,7 +712,7 @@ const message = { syncAgentsHelper: 'Actualiza openclaw.json para los agentes que usan esta cuenta de modelo', appVersion: 'Versión de la app', webuiPort: 'Puerto WebUI', - tokenOrAuth: 'Token/Usuario y contraseña', + tokenOrAuth: 'Token/Autenticación', allowedOrigins: 'Direcciones de acceso', allowedOriginsRequired: 'Introduce al menos una dirección de acceso', allowedOriginsInvalid: 'Usa el formato http(s)://host-o-ip[:puerto]', @@ -5822,7 +5822,8 @@ const message = { storagePool: 'Pool de almacenamiento', network: 'Red', bridgeName: 'Nombre del bridge', - natNetworkHelper: 'Las VM pueden acceder a la red a través del host, pero los dispositivos externos normalmente no pueden acceder a ellas directamente.', + natNetworkHelper: + 'Las VM pueden acceder a la red a través del host, pero los dispositivos externos normalmente no pueden acceder a ellas directamente.', bridgeNetworkHelper: 'Conecta las VM a un bridge existente del host (como br0). Si el host tiene una NIC adicional sin usar, puedes crear un bridge para ella y utilizarlo.', bridgeNameHelper: diff --git a/frontend/src/lang/modules/fa.ts b/frontend/src/lang/modules/fa.ts index 837b83bdfadb..9a9438a074f9 100644 --- a/frontend/src/lang/modules/fa.ts +++ b/frontend/src/lang/modules/fa.ts @@ -700,6 +700,7 @@ const message = { syncAgentsHelper: 'بهروزرسانی openclaw.json برای عاملهایی که از این حساب مدل استفاده میکنند', appVersion: 'نسخه برنامه', webuiPort: 'پورت WebUI', + tokenOrAuth: 'Token/احراز هویت', allowedOrigins: 'آدرسهای دسترسی', allowedOriginsRequired: 'حداقل یک آدرس دسترسی وارد کنید', allowedOriginsInvalid: 'از فرمت http(s)://host-or-ip[:port] استفاده کنید', @@ -5722,7 +5723,8 @@ const message = { storagePool: 'استخر ذخیرهسازی', network: 'شبکه', bridgeName: 'نام Bridge', - natNetworkHelper: 'ماشین مجازی میتواند از طریق میزبان به شبکه دسترسی داشته باشد، اما دستگاههای خارجی معمولاً نمیتوانند مستقیماً به آن دسترسی پیدا کنند.', + natNetworkHelper: + 'ماشین مجازی میتواند از طریق میزبان به شبکه دسترسی داشته باشد، اما دستگاههای خارجی معمولاً نمیتوانند مستقیماً به آن دسترسی پیدا کنند.', bridgeNetworkHelper: 'ماشین مجازی را به bridge موجود میزبان (مانند br0) متصل کنید. اگر میزبان کارت شبکه اضافیِ استفادهنشده دارد، میتوانید برای آن bridge بسازید و استفاده کنید.', bridgeNameHelper: diff --git a/frontend/src/lang/modules/ja.ts b/frontend/src/lang/modules/ja.ts index 533b09b29a90..2626f464a8e9 100644 --- a/frontend/src/lang/modules/ja.ts +++ b/frontend/src/lang/modules/ja.ts @@ -707,7 +707,7 @@ const message = { syncAgentsHelper: 'このモデルアカウントを使用するエージェントの openclaw.json を更新', appVersion: 'アプリバージョン', webuiPort: 'WebUI ポート', - tokenOrAuth: 'Token/ユーザー名・パスワード', + tokenOrAuth: 'Token/認証', allowedOrigins: 'アクセスアドレス', allowedOriginsRequired: '少なくとも 1 つのアクセスアドレスを入力してください', allowedOriginsInvalid: 'http(s)://host-or-ip[:port] の形式で入力してください', @@ -5103,7 +5103,8 @@ const message = { }, tamper: { tamper: 'ウェブサイトの改ざん防止', - tamperCreateHint: '現在のディレクトリは改ざん防止またはファイル属性の影響を受け、作成に失敗する可能性があります。', + tamperCreateHint: + '現在のディレクトリは改ざん防止またはファイル属性の影響を受け、作成に失敗する可能性があります。', ignoreTemplate: '除外テンプレート', protectTemplate: '保護テンプレート', ignoreTemplateHelper: @@ -5785,7 +5786,8 @@ const message = { storagePool: 'ストレージプール', network: 'ネットワーク', bridgeName: 'ブリッジ名', - natNetworkHelper: 'VM はホスト経由でネットワークに接続できますが、外部の機器から VM へ直接アクセスすることは通常できません。', + natNetworkHelper: + 'VM はホスト経由でネットワークに接続できますが、外部の機器から VM へ直接アクセスすることは通常できません。', bridgeNetworkHelper: 'VM をホスト上の既存ブリッジ(br0 など)に接続します。未使用の追加 NIC がある場合は、その NIC 用のブリッジを作成して使用できます。', bridgeNameHelper: diff --git a/frontend/src/lang/modules/ko.ts b/frontend/src/lang/modules/ko.ts index 3fb812faf411..6d48f45ed60e 100644 --- a/frontend/src/lang/modules/ko.ts +++ b/frontend/src/lang/modules/ko.ts @@ -699,7 +699,7 @@ const message = { syncAgentsHelper: '이 모델 계정을 사용하는 에이전트의 openclaw.json 업데이트', appVersion: '앱 버전', webuiPort: 'WebUI 포트', - tokenOrAuth: 'Token/사용자 이름 및 비밀번호', + tokenOrAuth: 'Token/인증', allowedOrigins: '접속 주소', allowedOriginsRequired: '접속 주소를 하나 이상 입력하세요', allowedOriginsInvalid: 'http(s)://host-or-ip[:port] 형식으로 입력하세요', @@ -5670,7 +5670,8 @@ const message = { storagePool: '스토리지 풀', network: '네트워크', bridgeName: '브리지 이름', - natNetworkHelper: 'VM은 호스트를 통해 네트워크에 연결할 수 있지만, 외부 장치에서는 일반적으로 VM에 직접 접근할 수 없습니다.', + natNetworkHelper: + 'VM은 호스트를 통해 네트워크에 연결할 수 있지만, 외부 장치에서는 일반적으로 VM에 직접 접근할 수 없습니다.', bridgeNetworkHelper: 'VM을 호스트의 기존 브리지(예: br0)에 연결합니다. 호스트에 사용하지 않는 추가 네트워크 카드가 있으면 브리지를 만들어 사용할 수 있습니다.', bridgeNameHelper: diff --git a/frontend/src/lang/modules/ms.ts b/frontend/src/lang/modules/ms.ts index 055ec4183198..d96213b49065 100644 --- a/frontend/src/lang/modules/ms.ts +++ b/frontend/src/lang/modules/ms.ts @@ -714,7 +714,7 @@ const message = { syncAgentsHelper: 'Kemas kini openclaw.json untuk agen yang menggunakan akaun model ini', appVersion: 'Versi aplikasi', webuiPort: 'Port WebUI', - tokenOrAuth: 'Token/Nama pengguna & Kata laluan', + tokenOrAuth: 'Token/Pengesahan', allowedOrigins: 'Alamat akses', allowedOriginsRequired: 'Masukkan sekurang-kurangnya satu alamat akses', allowedOriginsInvalid: 'Gunakan format http(s)://hos-atau-ip[:port]', @@ -5863,7 +5863,8 @@ const message = { storagePool: 'Kolam Storan', network: 'Rangkaian', bridgeName: 'Nama Bridge', - natNetworkHelper: 'VM boleh mengakses rangkaian melalui hos, tetapi peranti luar biasanya tidak boleh mengaksesnya secara langsung.', + natNetworkHelper: + 'VM boleh mengakses rangkaian melalui hos, tetapi peranti luar biasanya tidak boleh mengaksesnya secara langsung.', bridgeNetworkHelper: 'Sambungkan VM ke bridge hos sedia ada (seperti br0). Jika hos mempunyai kad rangkaian tambahan yang tidak digunakan, anda boleh mencipta bridge untuknya dan menggunakannya.', bridgeNameHelper: diff --git a/frontend/src/lang/modules/pt-br.ts b/frontend/src/lang/modules/pt-br.ts index 2664b993b6df..216b1ee6d9c4 100644 --- a/frontend/src/lang/modules/pt-br.ts +++ b/frontend/src/lang/modules/pt-br.ts @@ -708,7 +708,7 @@ const message = { syncAgentsHelper: 'Atualize o openclaw.json para agentes que usam esta conta de modelo', appVersion: 'Versão do app', webuiPort: 'Porta WebUI', - tokenOrAuth: 'Token/Usuário e senha', + tokenOrAuth: 'Token/Autenticação', allowedOrigins: 'Endereços de acesso', allowedOriginsRequired: 'Informe pelo menos um endereço de acesso', allowedOriginsInvalid: 'Use o formato http(s)://host-ou-ip[:porta]', @@ -6014,7 +6014,8 @@ const message = { storagePool: 'Pool de Armazenamento', network: 'Rede', bridgeName: 'Nome da Bridge', - natNetworkHelper: 'As VMs podem acessar a rede pelo host, mas dispositivos externos geralmente não podem acessá-las diretamente.', + natNetworkHelper: + 'As VMs podem acessar a rede pelo host, mas dispositivos externos geralmente não podem acessá-las diretamente.', bridgeNetworkHelper: 'Conecte as VMs a uma bridge existente do host (como br0). Se o host tiver uma placa de rede adicional não utilizada, crie uma bridge para ela e use-a.', bridgeNameHelper: diff --git a/frontend/src/lang/modules/ru.ts b/frontend/src/lang/modules/ru.ts index ac008d4eee09..c52cf8906fb4 100644 --- a/frontend/src/lang/modules/ru.ts +++ b/frontend/src/lang/modules/ru.ts @@ -706,7 +706,7 @@ const message = { syncAgentsHelper: 'Обновите openclaw.json для агентов, использующих этот аккаунт модели', appVersion: 'Версия приложения', webuiPort: 'Порт WebUI', - tokenOrAuth: 'Token/имя пользователя и пароль', + tokenOrAuth: 'Token/Аутентификация', allowedOrigins: 'Адреса доступа', allowedOriginsRequired: 'Укажите хотя бы один адрес доступа', allowedOriginsInvalid: 'Используйте формат http(s)://host-or-ip[:port]', @@ -5862,7 +5862,8 @@ const message = { storagePool: 'Пул хранения', network: 'Сеть', bridgeName: 'Имя моста', - natNetworkHelper: 'ВМ может выходить в сеть через хост, но внешние устройства обычно не могут обращаться к ней напрямую.', + natNetworkHelper: + 'ВМ может выходить в сеть через хост, но внешние устройства обычно не могут обращаться к ней напрямую.', bridgeNetworkHelper: 'Подключите ВМ к существующему мосту хоста (например, br0). Если у хоста есть неиспользуемая дополнительная сетевая карта, для неё можно создать мост и использовать его.', bridgeNameHelper: diff --git a/frontend/src/lang/modules/tr.ts b/frontend/src/lang/modules/tr.ts index a6f93884363d..6b31f1823598 100644 --- a/frontend/src/lang/modules/tr.ts +++ b/frontend/src/lang/modules/tr.ts @@ -710,7 +710,7 @@ const message = { syncAgentsHelper: 'Bu model hesabını kullanan ajanlar için openclaw.json dosyasını güncelleyin', appVersion: 'Uygulama sürümü', webuiPort: 'WebUI portu', - tokenOrAuth: 'Token/Kullanıcı adı ve parola', + tokenOrAuth: 'Token/Kimlik Doğrulama', allowedOrigins: 'Erişim adresleri', allowedOriginsRequired: 'En az bir erişim adresi girin', allowedOriginsInvalid: 'http(s)://host-veya-ip[:port] biçimini kullanın', @@ -5378,10 +5378,12 @@ const message = { 'Çevrimdışı ortamlarda, farklı mimarideki düğümler güncellenmeden önce eşleşen güncelleme paketi yüklenmelidir.', healthCheck: 'Sağlık Kontrolü', healthCheckFrequency: 'Kontrol Sıklığı', - healthCheckFrequencyHelper: 'Arka uç düğümlerinin sağlığını düzenli olarak kontrol eder. Kontrolü kapatmak için 0 girin.', + healthCheckFrequencyHelper: + 'Arka uç düğümlerinin sağlığını düzenli olarak kontrol eder. Kontrolü kapatmak için 0 girin.', healthCheckFrequencyLimit: 'Kontrol sıklığı bir günü aşamaz ve negatif olmayan bir tam sayı olmalıdır.', loadFailedNodeResources: 'Başarısız düğüm kaynaklarını sorgula', - loadFailedNodeResourcesHelper: 'Düğüm listesi açıldığında başarısız düğümlerin kaynak bilgilerini de sorgular.', + loadFailedNodeResourcesHelper: + 'Düğüm listesi açıldığında başarısız düğümlerin kaynak bilgilerini de sorgular.', nodeUnhealthy: 'Düğüm durumu anormal', deletedNode: 'Silinmiş düğüm {0} şu anda yükseltme işlemlerini desteklemiyor!', nodeUnhealthyHelper: @@ -5857,7 +5859,8 @@ const message = { storagePool: 'Depolama Havuzu', network: 'Ağ', bridgeName: 'Bridge Adı', - natNetworkHelper: 'VM’ler ana makine üzerinden ağa erişebilir, ancak harici cihazlar genellikle onlara doğrudan erişemez.', + natNetworkHelper: + 'VM’ler ana makine üzerinden ağa erişebilir, ancak harici cihazlar genellikle onlara doğrudan erişemez.', bridgeNetworkHelper: 'VM’leri ana makinedeki mevcut bir bridge’e (örneğin br0) bağlayın. Ana makinede kullanılmayan ek bir ağ kartı varsa, bunun için bir bridge oluşturup kullanabilirsiniz.', bridgeNameHelper: diff --git a/frontend/src/lang/modules/zh-Hant.ts b/frontend/src/lang/modules/zh-Hant.ts index d5236078c3f8..a87c312a4c3a 100644 --- a/frontend/src/lang/modules/zh-Hant.ts +++ b/frontend/src/lang/modules/zh-Hant.ts @@ -676,7 +676,7 @@ const message = { syncAgentsHelper: '更新使用該模型帳號的智能體 openclaw.json', appVersion: '應用版本', webuiPort: 'WebUI 埠', - tokenOrAuth: 'Token/使用者名稱密碼', + tokenOrAuth: 'Token/Auth', allowedOrigins: '訪問地址', allowedOriginsRequired: '請至少填寫一個訪問地址', allowedOriginsInvalid: '訪問地址格式錯誤,請輸入 http(s)://網域或IP[:埠]', @@ -5377,7 +5377,8 @@ const message = { network: '網路', bridgeName: '橋接名稱', natNetworkHelper: '虛擬機可透過主機存取網路,外部裝置通常無法直接存取虛擬機。', - bridgeNetworkHelper: '讓虛擬機接入主機既有的網橋(如 br0)。如主機有未使用的額外網卡,也可為其建立網橋後使用。', + bridgeNetworkHelper: + '讓虛擬機接入主機既有的網橋(如 br0)。如主機有未使用的額外網卡,也可為其建立網橋後使用。', bridgeNameHelper: '請選擇主機上已存在的 Linux bridge,例如 br0。實體網卡不是 bridge,docker/libvirt 管理的 bridge 不能在此選擇。', natCIDRHelper: 'CIDR、閘道和 DHCP 位址範圍將用於新建的 NAT 虛擬網路。', diff --git a/frontend/src/lang/modules/zh.ts b/frontend/src/lang/modules/zh.ts index 8794eba620c3..6b192a0e8c85 100644 --- a/frontend/src/lang/modules/zh.ts +++ b/frontend/src/lang/modules/zh.ts @@ -676,7 +676,7 @@ const message = { syncAgentsHelper: '更新使用该模型账号的智能体 openclaw.json', appVersion: '应用版本', webuiPort: 'WebUI 端口', - tokenOrAuth: 'Token/用户名密码', + tokenOrAuth: 'Token/Auth', allowedOrigins: '访问地址', allowedOriginsRequired: '请至少填写一个访问地址', allowedOriginsInvalid: '访问地址格式错误,请输入 http(s)://域名或IP[:端口]', diff --git a/frontend/src/views/ai/agents/agent/index.vue b/frontend/src/views/ai/agents/agent/index.vue index a3f97f4e0dbe..356d3c27f5cd 100644 --- a/frontend/src/views/ai/agents/agent/index.vue +++ b/frontend/src/views/ai/agents/agent/index.vue @@ -10,19 +10,32 @@ + - + - + - + - + {{ row.appVersion }} @@ -91,6 +109,7 @@ show-overflow-tooltip prop="provider" min-width="150" + card-type="content-full" > @@ -102,14 +121,24 @@ - - + {{ $t('aiTools.agents.webuiPort') }}: {{ row.webUIPort }} - + @@ -121,7 +150,7 @@ - + - + @@ -147,9 +176,10 @@ v-else-if="row.agentType === 'hermes-agent'" direction="vertical" alignment="start" + :size="2" > {{ row.dashboardUsername || '-' }} - + {{ row.dashboardPassword ? '********' : '-' }} @@ -157,7 +187,7 @@ - - + @@ -288,6 +320,7 @@ const EnterpriseBatchInstall = defineAsyncComponent(() => const items = ref([]); const loading = ref(false); +const viewMode = ref<'table' | 'card'>('table'); const addRef = ref(); const taskLogRef = ref(); const deleteRef = ref(); @@ -346,23 +379,6 @@ const buttons = [ click: (row: AI.AgentItem) => openOverview(row), show: (row: AI.AgentItem) => row.agentType === 'openclaw', }, - { - label: i18n.global.t('commons.operate.start'), - permission: true, - click: (row: AI.AgentItem) => onOperate(row, 'start'), - disabled: (row: AI.AgentItem) => row.status === 'Running', - }, - { - label: i18n.global.t('commons.operate.stop'), - permission: true, - click: (row: AI.AgentItem) => onOperate(row, 'stop'), - disabled: (row: AI.AgentItem) => row.status !== 'Running', - }, - { - label: i18n.global.t('commons.operate.restart'), - permission: true, - click: (row: AI.AgentItem) => onOperate(row, 'restart'), - }, { label: i18n.global.t('commons.button.upgrade'), permission: true, diff --git a/frontend/src/views/container/container/index.vue b/frontend/src/views/container/container/index.vue index aa91ccaffdcf..586d71f86e52 100644 --- a/frontend/src/views/container/container/index.vue +++ b/frontend/src/views/container/container/index.vue @@ -64,6 +64,7 @@ + - + - - {{ item }} + + {{ item }} + - - + + - - - - - - - - {{ item.length > 25 ? item.substring(0, 25) + '...' : item }} - - - {{ item }} - - - - - - - {{ $t('commons.button.expand') }}... - - - - - {{ $t('commons.button.collapse') }} + + + + + {{ item }} - + {{ item }} + + + +{{ row.ports.length - 2 }} + + @@ -366,11 +380,13 @@ + + + + + + IPv6 + + + + ') !== -1 && goDashboard(item)" + > + {{ item }} + + + + + + @@ -419,7 +461,7 @@ import Uploads from '@/components/upload/index.vue'; import DockerStatus from '@/views/container/docker-status/index.vue'; import ContainerLogDialog from '@/components/log/container-drawer/index.vue'; import Status from '@/components/status/index.vue'; -import { reactive, onMounted, ref } from 'vue'; +import { computed, reactive, onMounted, ref } from 'vue'; import { containerItemStats, containerListStats, @@ -444,7 +486,16 @@ const isActive = ref(false); const isExist = ref(false); const loading = ref(false); +const viewMode = ref<'table' | 'card'>('table'); const data = ref([]); +const networkItemsByRow = computed( + () => new Map(data.value.map((row) => [row, (row.network || []).filter((item: string) => item?.trim())])), +); +const portsDialogVisible = ref(false); +const selectedPorts = ref([]); +const portsDialogContainer = ref(''); +const portFilter = ref(''); +const showIPv6Ports = ref(true); const selects = ref([]); const paginationConfig = reactive({ cacheSizeKey: 'container-page-size', @@ -584,6 +635,31 @@ const goDashboard = async (port: any) => { dialogPortJumpRef.value.acceptParams({ port: portEx, ip: ip }); }; +const openPorts = (row: Container.ContainerInfo) => { + selectedPorts.value = row.ports || []; + portsDialogContainer.value = row.name; + portFilter.value = ''; + showIPv6Ports.value = true; + portsDialogVisible.value = true; +}; + +const isIPv6Port = (port: string) => { + const address = port.split('->')[0] || port; + return address.includes('[') || (address.match(/:/g)?.length || 0) > 1; +}; + +const getNetworkItems = (row: Container.ContainerInfo) => networkItemsByRow.value.get(row) || []; + +const filteredPorts = computed(() => { + const keyword = portFilter.value.trim().toLowerCase(); + return selectedPorts.value.filter((port) => { + if (!showIPv6Ports.value && isIPv6Port(port)) { + return false; + } + return !keyword || port.toLowerCase().includes(keyword); + }); +}); + interface Filters { filters?: string; } @@ -933,6 +1009,68 @@ onMounted(() => { .tagMargin { margin-top: 2px; } +.container-port-list, +.container-port-dialog { + display: flex; + justify-content: flex-start; + gap: 6px; + flex-wrap: wrap; +} +.container-port-empty { + min-height: 24px; +} +.container-port-list { + width: 100%; + text-align: left; + + :deep(.el-button) { + max-width: calc(50% - 3px); + min-width: 0; + } + + :deep(.el-button > span) { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + :deep(.el-button + .el-button) { + margin-left: 0; + } + + &:not(.is-card) { + flex-direction: column; + align-items: flex-start; + + :deep(.el-button) { + max-width: 100%; + } + } +} +.container-port-dialog-filters { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 12px; + + .el-input { + flex: 1; + } +} +.container-port-dialog { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + text-align: left; + + :deep(.el-button) { + width: 100%; + margin-left: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} .source-font { font-size: 12px; } diff --git a/frontend/src/views/cronjob/cronjob/index.vue b/frontend/src/views/cronjob/cronjob/index.vue index afb5f07cfa89..7d56e7d3738d 100644 --- a/frontend/src/views/cronjob/cronjob/index.vue +++ b/frontend/src/views/cronjob/cronjob/index.vue @@ -42,6 +42,7 @@ + {{ $t('commons.table.group') }} @@ -60,6 +61,7 @@ @@ -80,7 +83,12 @@ - + - + - + @@ -144,7 +163,12 @@ - + @@ -173,34 +198,24 @@ {{ row.lastRecordTime }} - + - - - - - {{ item === 'localhost' ? $t('setting.LOCAL') : item }} - - - - - - - - - {{ $t('commons.button.expand') }}... - - - - - {{ $t('commons.button.collapse') }} - - + + {{ + row.downloadAccount === 'localhost' ? $t('setting.LOCAL') : row.downloadAccount + }} + + - + + +{{ row.sourceAccounts.length - 1 }} + @@ -212,6 +227,7 @@ :min-width="isMobile ? 'auto' : 200" :fixed="isMobile ? false : 'right'" fix + card-type="button" /> @@ -239,6 +255,12 @@ + + + {{ account === 'localhost' ? $t('setting.LOCAL') : account }} + + + @@ -271,6 +293,7 @@ const { currentNode, isMobile } = useGlobalStore(); useOperateNodeContext(currentNode); const loading = ref(); +const viewMode = ref<'table' | 'card'>('table'); const selects = ref([]); const isRecordShow = ref(); const operateIDs = ref(); @@ -328,6 +351,9 @@ const search = async (column?: any) => { const dialogRecordRef = ref(); const dialogBackupRef = ref(); +const backupAccountsVisible = ref(false); +const backupAccounts = ref([]); +const downloadAccount = ref(''); const onOpenDialog = async (id: string) => { routerToNameWithQuery('CronjobOperate', { id: id }); @@ -494,6 +520,12 @@ const loadBackups = async (row: any) => { dialogBackupRef.value!.acceptParams({ cronjobID: row.id, cronjob: row.name }); }; +const openBackupAccounts = (row: Cronjob.CronjobInfo) => { + backupAccounts.value = row.sourceAccounts || []; + downloadAccount.value = row.downloadAccount; + backupAccountsVisible.value = true; +}; + const onHandle = async (row: Cronjob.CronjobInfo) => { loading.value = true; await handleOnce(row.id) diff --git a/frontend/src/views/host/file-management/code-editor/history/index.vue b/frontend/src/views/host/file-management/code-editor/history/index.vue index 5449867ca019..f38da1d47e79 100644 --- a/frontend/src/views/host/file-management/code-editor/history/index.vue +++ b/frontend/src/views/host/file-management/code-editor/history/index.vue @@ -235,7 +235,7 @@ import { loadMonacoLanguageSupport, setupMonacoEnvironment } from '@/utils/monac import { ElMessageBox, type FormInstance, type FormRules } from 'element-plus'; import { Languages } from '@/global/mimetype'; import i18n from '@/lang'; -import ComplexTable from '@/components/complex-table/index.vue'; +import ComplexTable from '@/components/table/complex/ComplexTable.vue'; type MonacoEditorApi = typeof import('monaco-editor/esm/vs/editor/editor.api'); diff --git a/frontend/src/views/website/website/domain/index.vue b/frontend/src/views/website/website/domain/index.vue index e20d5275860c..3e0de24bbe0b 100644 --- a/frontend/src/views/website/website/domain/index.vue +++ b/frontend/src/views/website/website/domain/index.vue @@ -1,6 +1,6 @@ - + - + (); +const props = withDefaults(defineProps(), { + hideName: false, + showFavorite: true, +}); const emit = defineEmits(['favoriteChange', 'domainEdit']); const inputRef = ref(); const isEditing = ref(false); @@ -233,6 +238,10 @@ const shouldShowDomainTooltip = (domain: string) => { min-width: 0; } +.name-main--actions-only { + flex: initial; +} + .domain-text { display: inline-flex; align-items: center; diff --git a/frontend/src/views/website/website/index.vue b/frontend/src/views/website/website/index.vue index 4ceaad5f5a25..5524fca2c364 100644 --- a/frontend/src/views/website/website/index.vue +++ b/frontend/src/views/website/website/index.vue @@ -31,6 +31,7 @@ + {{ $t('commons.table.type') }} @@ -66,6 +67,7 @@ - + + + + {{ row.primaryDomain }} + + + @@ -116,7 +141,7 @@ - + @@ -166,12 +192,14 @@ :label="$t('commons.table.protocol')" prop="protocol" width="90px" + card-type="content" > @@ -207,7 +235,12 @@ - + {{ dateFormatSimple(row.sslExpireDate) }} @@ -215,7 +248,12 @@ - + @@ -234,6 +272,7 @@ :label="$t('commons.table.operate')" :fixed="isMobile ? false : 'right'" fix + card-type="button" />