diff --git a/apps/web/src/components/providers/ProviderTable/ProviderTable.module.scss b/apps/web/src/components/providers/ProviderTable/ProviderTable.module.scss index 607ac9746..122b573b5 100644 --- a/apps/web/src/components/providers/ProviderTable/ProviderTable.module.scss +++ b/apps/web/src/components/providers/ProviderTable/ProviderTable.module.scss @@ -76,6 +76,52 @@ $table-columns: 92px minmax(112px, 0.72fr) minmax(144px, 0.96fr) 80px 76px 360px } } +.resizableHeader { + position: relative; + display: flex; + align-items: center; + min-width: 0; + @include text-ellipsis; +} + +.resizeHandle { + position: absolute; + top: 0; + right: -7px; + width: 14px; + height: 100%; + cursor: col-resize; + z-index: 1; + touch-action: none; + + &::after { + content: ''; + position: absolute; + top: 20%; + left: 50%; + width: 2px; + height: 60%; + border-radius: 1px; + background: var(--border-color); + opacity: 0; + transition: opacity $transition-fast; + transform: translateX(-50%); + } + + &:hover::after, + &:active::after { + opacity: 1; + } + + &:active::after { + background: var(--primary-color, #{$primary-color}); + } + + @include mobile { + display: none; + } +} + .cellType { display: flex; align-items: center; diff --git a/apps/web/src/components/providers/ProviderTable/ProviderTable.tsx b/apps/web/src/components/providers/ProviderTable/ProviderTable.tsx index 9a2da1cbf..8426c8112 100644 --- a/apps/web/src/components/providers/ProviderTable/ProviderTable.tsx +++ b/apps/web/src/components/providers/ProviderTable/ProviderTable.tsx @@ -6,6 +6,7 @@ import { IconCheck, IconEye, IconPencil, IconTrash2, IconX } from '@/components/ import { ProviderStatusBar } from '../ProviderStatusBar'; import { getProviderKindIcon, PROVIDER_KIND_LABELS } from './kindMeta'; import type { ProviderRow } from './rowData'; +import { useResizableColumns } from './useResizableColumns'; import styles from './ProviderTable.module.scss'; interface ProviderTableProps { @@ -38,6 +39,8 @@ export function ProviderTable({ onToggle, }: ProviderTableProps) { const { t } = useTranslation(); + const { gridTemplateColumns, onResizeStart, resetColumn, isResizable } = useResizableColumns(); + const gridStyle = { gridTemplateColumns } as const; if (loading && rows.length === 0) { return
{t('common.loading')}
; @@ -57,10 +60,26 @@ export function ProviderTable({ return (
-
+
{t('ai_providers.table_col_type')} - {t('ai_providers.table_col_identity')} - {t('common.base_url')} + {([ + { label: t('ai_providers.table_col_identity'), colIndex: 1, className: undefined }, + { label: t('common.base_url'), colIndex: 2, className: undefined }, + ] as const).map(({ label, colIndex, className }) => ( +
+ {label} + {isResizable(colIndex) && ( +
onResizeStart(colIndex, e)} + onDoubleClick={() => resetColumn(colIndex)} + role="separator" + aria-orientation="vertical" + aria-label={t('common.resize_column')} + /> + )} +
+ ))} {t('ai_providers.table_col_models')} @@ -84,6 +103,7 @@ export function ProviderTable({ role="row" tabIndex={0} className={`${styles.row} ${row.enabled ? '' : styles.rowDisabled}`} + style={gridStyle} onClick={() => onShowDetail(row)} onKeyDown={(event) => handleRowKeyDown(event, row)} aria-label={`${kindLabel} ${row.label}`} diff --git a/apps/web/src/components/providers/ProviderTable/useResizableColumns.ts b/apps/web/src/components/providers/ProviderTable/useResizableColumns.ts new file mode 100644 index 000000000..916a0f2d7 --- /dev/null +++ b/apps/web/src/components/providers/ProviderTable/useResizableColumns.ts @@ -0,0 +1,110 @@ +import { useCallback, useMemo, useRef, useState } from 'react'; + +const STORAGE_KEY = 'providerTable.columnWidths'; + +const COLUMN_DEFAULTS = [ + '92px', + 'minmax(112px, 0.72fr)', + 'minmax(144px, 0.96fr)', + '80px', + '76px', + '360px', + '60px', + '108px', +]; + +const RESIZABLE_MIN_WIDTHS: Record = { + 1: 112, + 2: 144, +}; + +function loadWidths(): Record { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return {}; + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed; + } catch { + /* ignore */ + } + return {}; +} + +function saveWidths(widths: Record) { + try { + if (Object.keys(widths).length === 0) { + localStorage.removeItem(STORAGE_KEY); + } else { + localStorage.setItem(STORAGE_KEY, JSON.stringify(widths)); + } + } catch { + /* ignore */ + } +} + +export function useResizableColumns() { + const [widths, setWidths] = useState(loadWidths); + const dragRef = useRef<{ col: number; startX: number; startW: number } | null>(null); + + const gridTemplateColumns = useMemo(() => { + const parts = [...COLUMN_DEFAULTS]; + for (const [col, w] of Object.entries(widths)) { + const i = Number(col); + if (i in RESIZABLE_MIN_WIDTHS) parts[i] = `${w}px`; + } + return parts.join(' '); + }, [widths]); + + const onResizeStart = useCallback( + (colIndex: number, e: React.PointerEvent) => { + if (!(colIndex in RESIZABLE_MIN_WIDTHS)) return; + e.preventDefault(); + e.stopPropagation(); + + const headerCell = e.currentTarget.parentElement as HTMLElement | null; + if (!headerCell) return; + const startW = headerCell.getBoundingClientRect().width; + + dragRef.current = { col: colIndex, startX: e.clientX, startW }; + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + + const onMove = (ev: PointerEvent) => { + const state = dragRef.current; + if (!state) return; + const minW = RESIZABLE_MIN_WIDTHS[state.col] ?? 80; + const newW = Math.round(Math.max(minW, state.startW + ev.clientX - state.startX)); + setWidths((prev) => ({ ...prev, [state.col]: newW })); + }; + + const onUp = () => { + dragRef.current = null; + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + document.removeEventListener('pointermove', onMove); + document.removeEventListener('pointerup', onUp); + setWidths((prev) => { + saveWidths(prev); + return prev; + }); + }; + + document.addEventListener('pointermove', onMove); + document.addEventListener('pointerup', onUp); + }, + [] + ); + + const resetColumn = useCallback((colIndex: number) => { + setWidths((prev) => { + const next = { ...prev }; + delete next[colIndex]; + saveWidths(next); + return next; + }); + }, []); + + const isResizable = useCallback((colIndex: number) => colIndex in RESIZABLE_MIN_WIDTHS, []); + + return { gridTemplateColumns, onResizeStart, resetColumn, isResizable }; +} diff --git a/apps/web/src/i18n/locales/en.json b/apps/web/src/i18n/locales/en.json index 723dd82e3..742284ec6 100644 --- a/apps/web/src/i18n/locales/en.json +++ b/apps/web/src/i18n/locales/en.json @@ -42,6 +42,7 @@ "prefix": "Prefix", "proxy_url": "Proxy", "priority": "Priority", + "resize_column": "Resize column", "alias": "Alias", "failure": "Failure", "unknown_error": "Unknown error", diff --git a/apps/web/src/i18n/locales/ru.json b/apps/web/src/i18n/locales/ru.json index 72d69f5b3..4beb68d35 100644 --- a/apps/web/src/i18n/locales/ru.json +++ b/apps/web/src/i18n/locales/ru.json @@ -42,6 +42,7 @@ "prefix": "Префикс", "proxy_url": "Прокси", "priority": "Приоритет", + "resize_column": "Изменить ширину столбца", "alias": "Псевдоним", "failure": "Сбой", "unknown_error": "Неизвестная ошибка", diff --git a/apps/web/src/i18n/locales/zh-CN.json b/apps/web/src/i18n/locales/zh-CN.json index 2790eeff4..9a5b46269 100644 --- a/apps/web/src/i18n/locales/zh-CN.json +++ b/apps/web/src/i18n/locales/zh-CN.json @@ -42,6 +42,7 @@ "prefix": "前缀", "proxy_url": "代理", "priority": "优先级", + "resize_column": "调整列宽", "alias": "别名", "failure": "失败", "unknown_error": "未知错误", diff --git a/apps/web/src/i18n/locales/zh-TW.json b/apps/web/src/i18n/locales/zh-TW.json index e23306589..e442e2446 100644 --- a/apps/web/src/i18n/locales/zh-TW.json +++ b/apps/web/src/i18n/locales/zh-TW.json @@ -42,6 +42,7 @@ "prefix": "前綴", "proxy_url": "代理", "priority": "優先順序", + "resize_column": "調整欄寬", "alias": "別名", "failure": "失敗", "unknown_error": "未知錯誤",