mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-05-13 09:36:05 +00:00
feat(frontend): Phase 5b — port four shared components to Vue 3
CustomStatistic.vue and SettingListItem.vue are mechanical
Vue.component → SFC ports.
AppSidebar.vue: AD-Vue 4 dropped <a-icon :type="dynamic">, so the
five sidebar icons (dashboard/user/setting/tool/logout) live in a
name→component map and render via <component :is>. The legacy
<a-drawer slot="handle"> hack is replaced with a sibling fixed-
position toggle button. Tab paths take basePath/requestUri as
props instead of pulling them from Go template scope.
TableSortable.vue: the biggest Vue 3 rewrite of this phase.
- $listeners is gone — replaced by inheritAttrs: false +
explicit attrs forwarding
- scopedSlots: this.$scopedSlots collapsed into Vue 3's unified
slots object — just iterate Object.keys(this.slots) and forward
- Vue 2 h(tag, { props, on, scopedSlots }, children) →
Vue 3 h(tag, { ...props, ...on }, slotsObject)
- 'a-table' string → resolveComponent('a-table') so app.use(Antd)
registration is honored
- inject: ['sortable'] (Options API) → inject('sortable', null)
(Composition API) inside the trigger child
- beforeDestroy → beforeUnmount
- customRow's return shape flattened (no nested props/on/class)
Two intentional skips, documented in the migration doc:
- aClientTable.html — slot fragments, not a component. Migrates
inline with inbounds.html (new Phase 5f).
- aPersianDatepicker.html — wraps a Persian-only third-party
lib; defer until settings.html lands.
Build verified with vite 8.0.11.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
138696cf36
commit
ebe57ef273
5 changed files with 540 additions and 1 deletions
|
|
@ -108,7 +108,8 @@ Order chosen so that breakage is contained and we always have a working panel:
|
|||
- ✅ Phase 4 — first real page (login.html) ported
|
||||
- ⏳ Phase 5 — medium pages + modals
|
||||
- ✅ 5a — theme system (composable + ThemeSwitch / ThemeSwitchLogin); wired into login
|
||||
- ⏳ 5b — remaining custom components
|
||||
- ✅ 5b — CustomStatistic, SettingListItem, AppSidebar, TableSortable
|
||||
- ⏳ 5c — index.html dashboard
|
||||
|
||||
### Phase 5a notes
|
||||
|
||||
|
|
@ -124,6 +125,30 @@ Order chosen so that breakage is contained and we always have a working panel:
|
|||
|
||||
**Known gap:** the legacy `web/assets/css/custom.min.css` styles `body.dark` / `body.light` / `[data-theme="ultra-dark"]`. The new login page doesn't import that CSS, so toggling theme switches AD-Vue's own components but not the panel chrome (e.g. card backgrounds). The composable still toggles the body class so behavior is correct — visual fidelity is restored when we either port custom.css to the new build or import it directly.
|
||||
|
||||
### Phase 5b notes
|
||||
|
||||
Migrated four components into `frontend/src/components/`:
|
||||
|
||||
- **`CustomStatistic.vue`** — wraps `<a-statistic>` with prefix/suffix slots. Trivial port; only change is `Vue.component(...)` → SFC.
|
||||
- **`SettingListItem.vue`** — wraps `<a-list-item>` with title/description/control slots and a `paddings` prop. Trivial port.
|
||||
- **`AppSidebar.vue`** — main panel sidebar. Surfaces tabs (Dashboard / Inbounds / Settings / Xray / Logout) with icons and the theme switcher. Two key changes from the legacy:
|
||||
- AD-Vue 4 dropped `<a-icon :type="dynamic">`. Replaced with a name→component map (`{ dashboard: DashboardOutlined, ... }`) rendered via `<component :is="...">`.
|
||||
- `<a-drawer>` `slot="handle"` (a non-standard prop in legacy AD-Vue 1) was replaced with a fixed-position toggle button rendered as a sibling. The drawer's `:visible` was renamed to `:open` per AD-Vue 4 conventions.
|
||||
- Tab `key` paths and `requestUri` are passed in as props (parent page knows the basePath); the legacy embedded `{{ .base_path }}` directly via Go templating.
|
||||
- **`TableSortable.vue`** — drag-to-reorder a-table wrapper. The biggest single Vue 3 / AD-Vue 4 rewrite in this phase:
|
||||
- `$listeners` (Vue 2) is gone — replaced by `inheritAttrs: false` + `v-bind="$attrs"` style forwarding via the `attrs` setup return.
|
||||
- `scopedSlots: this.$scopedSlots` → Vue 3 unifies all slots; just iterate `Object.keys(this.slots)` and forward.
|
||||
- Render-function shape changed: Vue 2's `h(tag, { props, on, scopedSlots }, children)` → Vue 3's `h(tag, { ...props, ...on }, slotsObject)` where slot fns are passed as the third arg.
|
||||
- `'a-table'` as a plain string → `resolveComponent('a-table')` so `app.use(Antd)` registration is honored.
|
||||
- `inject: ['sortable']` (Options API) inside child component swapped for `inject('sortable', null)` (Composition API) since the trigger now uses `setup()`.
|
||||
- `beforeDestroy` → `beforeUnmount` (Vue 3 lifecycle rename).
|
||||
- `customRow`'s return shape flattened: Vue 2 nested `attrs/on/class` is now a flat object of attrs + listeners + class.
|
||||
|
||||
**Skipped in 5b:**
|
||||
|
||||
- **`aClientTable.html`** — *not actually a component*. It's a fragment of `<template slot="X" slot-scope="...">` blocks pulled into pages that use it. It depends on outer-scope variables (`record`, `app`, `themeSwitcher`, etc.) and only makes sense inlined into its consumer. Will migrate as part of `inbounds.html`.
|
||||
- **`aPersianDatepicker.html`** — wraps a Persian-only third-party datepicker that isn't in the critical path. Defer until `settings.html` migrates and we know whether to keep the legacy lib, replace with a Vue 3 wrapper, or fall back to a native HTML5 date input.
|
||||
|
||||
### Phase 4 notes
|
||||
|
||||
- Vite 6 → Vite 8.0.11 (released March 2026). Requires Node 20.19+ or 22.12+. `@vitejs/plugin-vue` bumped to ^6.0.6 which lists vite ^8 as a peer.
|
||||
|
|
|
|||
156
frontend/src/components/AppSidebar.vue
Normal file
156
frontend/src/components/AppSidebar.vue
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import {
|
||||
DashboardOutlined,
|
||||
UserOutlined,
|
||||
SettingOutlined,
|
||||
ToolOutlined,
|
||||
LogoutOutlined,
|
||||
CloseOutlined,
|
||||
MenuFoldOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
|
||||
import { currentTheme } from '@/composables/useTheme.js';
|
||||
import ThemeSwitch from '@/components/ThemeSwitch.vue';
|
||||
|
||||
const SIDEBAR_COLLAPSED_KEY = 'isSidebarCollapsed';
|
||||
|
||||
const props = defineProps({
|
||||
// Path prefix (e.g. /custom-base/) the panel is served under. Defaults
|
||||
// to '' which means tab keys end up as '/panel/...'. Pages pass the
|
||||
// value the Go backend gave them (in production via a meta tag).
|
||||
basePath: { type: String, default: '' },
|
||||
// Current request URI so the matching menu item highlights.
|
||||
requestUri: { type: String, default: '' },
|
||||
});
|
||||
|
||||
// AD-Vue 4 dropped <a-icon :type="x"> in favor of explicit icon
|
||||
// imports — keep a small name-to-component map so tab definitions stay
|
||||
// declarative.
|
||||
const iconByName = {
|
||||
dashboard: DashboardOutlined,
|
||||
user: UserOutlined,
|
||||
setting: SettingOutlined,
|
||||
tool: ToolOutlined,
|
||||
logout: LogoutOutlined,
|
||||
};
|
||||
|
||||
const tabs = [
|
||||
{ key: `${props.basePath}panel/`, icon: 'dashboard', title: 'Dashboard' },
|
||||
{ key: `${props.basePath}panel/inbounds`, icon: 'user', title: 'Inbounds' },
|
||||
{ key: `${props.basePath}panel/settings`, icon: 'setting', title: 'Settings' },
|
||||
{ key: `${props.basePath}panel/xray`, icon: 'tool', title: 'Xray' },
|
||||
{ key: `${props.basePath}logout/`, icon: 'logout', title: 'Logout' },
|
||||
];
|
||||
|
||||
const activeTab = ref([props.requestUri]);
|
||||
|
||||
const drawerOpen = ref(false);
|
||||
const collapsed = ref(JSON.parse(localStorage.getItem(SIDEBAR_COLLAPSED_KEY) || 'false'));
|
||||
|
||||
function openLink(key) {
|
||||
if (key.startsWith('http')) {
|
||||
window.open(key);
|
||||
} else {
|
||||
window.location.href = key;
|
||||
}
|
||||
}
|
||||
|
||||
function onCollapse(isCollapsed, type) {
|
||||
// Only persist explicit toggle clicks, not breakpoint-triggered collapses.
|
||||
if (type === 'clickTrigger') {
|
||||
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, isCollapsed);
|
||||
collapsed.value = isCollapsed;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleDrawer() {
|
||||
drawerOpen.value = !drawerOpen.value;
|
||||
}
|
||||
|
||||
function closeDrawer() {
|
||||
drawerOpen.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ant-sidebar">
|
||||
<a-layout-sider
|
||||
:theme="currentTheme"
|
||||
collapsible
|
||||
:collapsed="collapsed"
|
||||
breakpoint="md"
|
||||
@collapse="onCollapse"
|
||||
>
|
||||
<ThemeSwitch />
|
||||
<a-menu
|
||||
:theme="currentTheme"
|
||||
mode="inline"
|
||||
:selected-keys="activeTab"
|
||||
@click="({ key }) => openLink(key)"
|
||||
>
|
||||
<a-menu-item v-for="tab in tabs" :key="tab.key">
|
||||
<component :is="iconByName[tab.icon]" />
|
||||
<span>{{ tab.title }}</span>
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</a-layout-sider>
|
||||
|
||||
<a-drawer
|
||||
placement="left"
|
||||
:closable="false"
|
||||
:open="drawerOpen"
|
||||
:wrap-class-name="currentTheme"
|
||||
:wrap-style="{ padding: 0 }"
|
||||
:style="{ height: '100%' }"
|
||||
@close="closeDrawer"
|
||||
>
|
||||
<ThemeSwitch />
|
||||
<a-menu
|
||||
:theme="currentTheme"
|
||||
mode="inline"
|
||||
:selected-keys="activeTab"
|
||||
@click="({ key }) => openLink(key)"
|
||||
>
|
||||
<a-menu-item v-for="tab in tabs" :key="tab.key">
|
||||
<component :is="iconByName[tab.icon]" />
|
||||
<span>{{ tab.title }}</span>
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</a-drawer>
|
||||
|
||||
<button class="drawer-handle" type="button" @click="toggleDrawer">
|
||||
<CloseOutlined v-if="drawerOpen" />
|
||||
<MenuFoldOutlined v-else />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ant-sidebar > .ant-layout-sider {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.drawer-handle {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
z-index: 1100;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
color: #fff;
|
||||
border: none;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.drawer-handle {
|
||||
display: inline-flex;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
27
frontend/src/components/CustomStatistic.vue
Normal file
27
frontend/src/components/CustomStatistic.vue
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<script setup>
|
||||
defineProps({
|
||||
title: { type: String, default: '' },
|
||||
value: { type: [String, Number], default: '' },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-statistic :title="title" :value="value">
|
||||
<template #prefix><slot name="prefix" /></template>
|
||||
<template #suffix><slot name="suffix" /></template>
|
||||
</a-statistic>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.ant-statistic-content) {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
:global(body.dark .ant-statistic-content) {
|
||||
color: var(--dark-color-text-primary);
|
||||
}
|
||||
|
||||
:global(body.dark .ant-statistic-title) {
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
</style>
|
||||
31
frontend/src/components/SettingListItem.vue
Normal file
31
frontend/src/components/SettingListItem.vue
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
paddings: {
|
||||
type: String,
|
||||
default: 'default',
|
||||
validator: (value) => ['small', 'default'].includes(value),
|
||||
},
|
||||
});
|
||||
|
||||
const padding = computed(() =>
|
||||
props.paddings === 'small' ? '10px 20px !important' : '20px !important',
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-list-item :style="{ padding }">
|
||||
<a-row :gutter="[8, 16]">
|
||||
<a-col :lg="24" :xl="12">
|
||||
<a-list-item-meta>
|
||||
<template #title><slot name="title" /></template>
|
||||
<template #description><slot name="description" /></template>
|
||||
</a-list-item-meta>
|
||||
</a-col>
|
||||
<a-col :lg="24" :xl="12">
|
||||
<slot name="control" />
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-list-item>
|
||||
</template>
|
||||
300
frontend/src/components/TableSortable.vue
Normal file
300
frontend/src/components/TableSortable.vue
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
<script>
|
||||
// Use defineComponent so we can keep the parent + child components in
|
||||
// the same file with the provide() <-> inject relationship intact.
|
||||
import { defineComponent, h, computed, ref, resolveComponent, inject } from 'vue';
|
||||
import { DragOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
const ROW_CLASS = 'sortable-row';
|
||||
|
||||
// Sortable a-table — drag-to-reorder rows using Pointer Events.
|
||||
//
|
||||
// Why a custom component:
|
||||
// - Old impl set draggable: true on every row, which broke text selection
|
||||
// in cells and let HTML5 start drags from anywhere on the row. This
|
||||
// version only initiates drag from an explicit handle, via Pointer
|
||||
// Events (one API for mouse + touch + pen).
|
||||
// - During drag, data-source is reordered live; the source row visually
|
||||
// slides into the target slot. The live reorder IS the visual feedback.
|
||||
// - On commit, emits onsort(sourceIndex, targetIndex) — same signature as
|
||||
// before so existing call sites stay unchanged.
|
||||
// - Keyboard support: ArrowUp/ArrowDown move the focused handle's row by
|
||||
// one; Escape cancels an in-flight drag.
|
||||
|
||||
export const TableSortableTrigger = defineComponent({
|
||||
name: 'TableSortableTrigger',
|
||||
props: {
|
||||
itemIndex: { type: Number, required: true },
|
||||
},
|
||||
setup(props) {
|
||||
const sortable = inject('sortable', null);
|
||||
const ariaLabel = computed(() => `Drag to reorder row ${(props.itemIndex ?? 0) + 1}`);
|
||||
|
||||
function onPointerDown(e) {
|
||||
sortable?.startDrag?.(e, props.itemIndex);
|
||||
}
|
||||
|
||||
function onKeyDown(e) {
|
||||
const move = sortable?.moveByKeyboard;
|
||||
if (!move) return;
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
move(-1, props.itemIndex);
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
move(+1, props.itemIndex);
|
||||
}
|
||||
}
|
||||
|
||||
return () => h(DragOutlined, {
|
||||
class: 'sortable-icon',
|
||||
role: 'button',
|
||||
tabindex: 0,
|
||||
'aria-label': ariaLabel.value,
|
||||
onPointerdown: onPointerDown,
|
||||
onKeydown: onKeyDown,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export default defineComponent({
|
||||
name: 'TableSortable',
|
||||
inheritAttrs: false,
|
||||
props: {
|
||||
dataSource: { type: Array, default: () => [] },
|
||||
customRow: { type: Function, default: null },
|
||||
rowKey: { type: [String, Function], default: null },
|
||||
locale: {
|
||||
type: Object,
|
||||
default: () => ({ filterConfirm: 'OK', filterReset: 'Reset', emptyText: 'No data' }),
|
||||
},
|
||||
},
|
||||
emits: ['onsort'],
|
||||
setup(props, { emit, slots, attrs, expose }) {
|
||||
// null when idle; while dragging:
|
||||
// { sourceIndex, targetIndex, pointerId, sourceKey }
|
||||
const drag = ref(null);
|
||||
const rootRef = ref(null);
|
||||
|
||||
const isDragging = computed(() => drag.value !== null);
|
||||
|
||||
// Resolve the row key for a record. Used to identify the source row
|
||||
// even after data-source is reordered live during drag.
|
||||
function keyOf(record, fallback) {
|
||||
const rk = props.rowKey;
|
||||
if (typeof rk === 'function') return rk(record);
|
||||
if (typeof rk === 'string') return record?.[rk];
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function attachListeners() {
|
||||
document.addEventListener('pointermove', onPointerMove, true);
|
||||
document.addEventListener('pointerup', onPointerUp, true);
|
||||
document.addEventListener('pointercancel', cancelDrag, true);
|
||||
document.addEventListener('keydown', cancelDrag, true);
|
||||
}
|
||||
|
||||
function detachListeners() {
|
||||
document.removeEventListener('pointermove', onPointerMove, true);
|
||||
document.removeEventListener('pointerup', onPointerUp, true);
|
||||
document.removeEventListener('pointercancel', cancelDrag, true);
|
||||
document.removeEventListener('keydown', cancelDrag, true);
|
||||
}
|
||||
|
||||
function startDrag(e, sourceIndex) {
|
||||
// Primary button only (mouse left / first touch).
|
||||
if (e.button != null && e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
const record = props.dataSource?.[sourceIndex];
|
||||
drag.value = {
|
||||
sourceIndex,
|
||||
targetIndex: sourceIndex,
|
||||
pointerId: e.pointerId,
|
||||
sourceKey: keyOf(record, sourceIndex),
|
||||
};
|
||||
// Capture the pointer so move/up keep firing even if the cursor
|
||||
// leaves the icon. Try/catch — some older browsers throw on capture.
|
||||
if (e.target?.setPointerCapture && e.pointerId != null) {
|
||||
try { e.target.setPointerCapture(e.pointerId); } catch (_) { /* ignore */ }
|
||||
}
|
||||
attachListeners();
|
||||
}
|
||||
|
||||
function onPointerMove(e) {
|
||||
const d = drag.value;
|
||||
if (!d) return;
|
||||
if (d.pointerId != null && e.pointerId !== d.pointerId) return;
|
||||
const root = rootRef.value;
|
||||
if (!root) return;
|
||||
const rows = root.querySelectorAll(`tr.${ROW_CLASS}`);
|
||||
if (!rows.length) return;
|
||||
const y = e.clientY;
|
||||
const firstRect = rows[0].getBoundingClientRect();
|
||||
const lastRect = rows[rows.length - 1].getBoundingClientRect();
|
||||
let target = d.targetIndex;
|
||||
if (y < firstRect.top) {
|
||||
target = 0;
|
||||
} else if (y > lastRect.bottom) {
|
||||
target = rows.length - 1;
|
||||
} else {
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const rect = rows[i].getBoundingClientRect();
|
||||
if (y >= rect.top && y <= rect.bottom) {
|
||||
target = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (target !== d.targetIndex) {
|
||||
drag.value = { ...d, targetIndex: target };
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerUp(e) {
|
||||
const d = drag.value;
|
||||
if (!d) return;
|
||||
if (d.pointerId != null && e.pointerId !== d.pointerId) return;
|
||||
detachListeners();
|
||||
const captured = d;
|
||||
drag.value = null;
|
||||
if (captured.sourceIndex !== captured.targetIndex) {
|
||||
emit('onsort', captured.sourceIndex, captured.targetIndex);
|
||||
}
|
||||
}
|
||||
|
||||
function cancelDrag(e) {
|
||||
// Triggered by pointercancel and keydown. For keydown only act on
|
||||
// Escape; otherwise let the event propagate.
|
||||
if (e?.type === 'keydown' && e.key !== 'Escape') return;
|
||||
detachListeners();
|
||||
drag.value = null;
|
||||
}
|
||||
|
||||
function moveByKeyboard(direction, sourceIndex) {
|
||||
const target = sourceIndex + direction;
|
||||
if (target < 0 || target >= (props.dataSource?.length ?? 0)) return;
|
||||
emit('onsort', sourceIndex, target);
|
||||
}
|
||||
|
||||
function customRowRender(record, index) {
|
||||
const parent = typeof props.customRow === 'function' ? props.customRow(record, index) || {} : {};
|
||||
const d = drag.value;
|
||||
const isSource = d && keyOf(record, index) === d.sourceKey;
|
||||
// Vue 3 customRow shape: a flat object of attrs/listeners/class —
|
||||
// no nested props/on like Vue 2.
|
||||
return {
|
||||
...parent,
|
||||
class: { [ROW_CLASS]: true, 'sortable-source-row': !!isSource, ...(parent.class || {}) },
|
||||
};
|
||||
}
|
||||
|
||||
// Render-data: dataSource with the source row spliced into targetIndex.
|
||||
// When idle the original list is returned unchanged so a-table can
|
||||
// diff against a stable reference.
|
||||
const records = computed(() => {
|
||||
const d = drag.value;
|
||||
const src = props.dataSource ?? [];
|
||||
if (!d || d.sourceIndex === d.targetIndex) return src;
|
||||
const list = src.slice();
|
||||
const [item] = list.splice(d.sourceIndex, 1);
|
||||
list.splice(d.targetIndex, 0, item);
|
||||
return list;
|
||||
});
|
||||
|
||||
expose({ startDrag, moveByKeyboard });
|
||||
|
||||
return {
|
||||
rootRef, drag, isDragging, records, slots, attrs,
|
||||
startDrag, moveByKeyboard, customRowRender,
|
||||
};
|
||||
},
|
||||
// provide() needs to live at the options level so child components in
|
||||
// the rendered subtree resolve the same instance methods.
|
||||
provide() {
|
||||
return {
|
||||
sortable: {
|
||||
startDrag: (...a) => this.startDrag(...a),
|
||||
moveByKeyboard: (...a) => this.moveByKeyboard(...a),
|
||||
},
|
||||
};
|
||||
},
|
||||
beforeUnmount() {
|
||||
document.removeEventListener('pointermove', this.onPointerMove, true);
|
||||
document.removeEventListener('pointerup', this.onPointerUp, true);
|
||||
document.removeEventListener('pointercancel', this.cancelDrag, true);
|
||||
document.removeEventListener('keydown', this.cancelDrag, true);
|
||||
},
|
||||
render() {
|
||||
// Forward every passed slot to a-table by reusing the slot fn
|
||||
// directly. Vue 3 slots are scoped by default so no $scopedSlots dance.
|
||||
const tableSlots = {};
|
||||
for (const name of Object.keys(this.slots)) {
|
||||
tableSlots[name] = this.slots[name];
|
||||
}
|
||||
// Resolved at runtime so the user's app.use(Antd) registration wins;
|
||||
// avoids importing Table directly here.
|
||||
const ATable = resolveComponent('a-table');
|
||||
return h(
|
||||
'div',
|
||||
{ ref: 'rootRef' },
|
||||
[h(
|
||||
ATable,
|
||||
{
|
||||
...this.attrs,
|
||||
'data-source': this.records,
|
||||
'row-key': this.rowKey,
|
||||
customRow: this.customRowRender,
|
||||
locale: this.locale,
|
||||
class: ['sortable-table', { 'sortable-table-dragging': this.isDragging }],
|
||||
},
|
||||
tableSlots,
|
||||
)],
|
||||
);
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.sortable-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: grab;
|
||||
padding: 6px;
|
||||
border-radius: 6px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
}
|
||||
.sortable-icon:hover {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.sortable-icon:active { cursor: grabbing; }
|
||||
.sortable-icon:focus-visible {
|
||||
outline: 2px solid #008771;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.light .sortable-icon { color: rgba(0, 0, 0, 0.45); }
|
||||
.light .sortable-icon:hover {
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.sortable-table-dragging .sortable-source-row > td {
|
||||
background: rgba(0, 135, 113, 0.10) !important;
|
||||
transition: background-color 0.18s ease;
|
||||
}
|
||||
.sortable-table-dragging .sortable-source-row .routing-index,
|
||||
.sortable-table-dragging .sortable-source-row .outbound-index {
|
||||
opacity: 0.45;
|
||||
}
|
||||
.sortable-table-dragging .sortable-row > td {
|
||||
transition: background-color 0.18s ease;
|
||||
}
|
||||
.sortable-table-dragging,
|
||||
.sortable-table-dragging * {
|
||||
user-select: none;
|
||||
}
|
||||
</style>
|
||||
Loading…
Reference in a new issue