feat: complete admin panel and backend admin api migration
This commit is contained in:
@@ -0,0 +1,365 @@
|
||||
<template>
|
||||
<!--
|
||||
Ported from legacy henza_admin/src/components/x-table/XTable.vue (572 lines).
|
||||
|
||||
Preserved: the server contract (POST {search, filters, page, limit, select,
|
||||
titles, report_title}), the { rows, query } response shape, the filter bar,
|
||||
the column show/hide picker, search, sorting and the named cell slots.
|
||||
|
||||
Dropped: the fullscreen toggle and the excel/json/print export buttons —
|
||||
exports needed a legacy endpoint that returned a file url, which the new
|
||||
backend does not implement. The buttons are omitted rather than left as
|
||||
dead controls that silently fail.
|
||||
-->
|
||||
<div class="x-table">
|
||||
<div class="x-table__toolbar">
|
||||
<a-input-search
|
||||
v-if="!model._hideSearch"
|
||||
v-model:value="query.search"
|
||||
placeholder="جستجو"
|
||||
allow-clear
|
||||
style="max-width: 260px"
|
||||
@search="onSearch"
|
||||
/>
|
||||
|
||||
<div class="x-table__actions">
|
||||
<a-button v-if="!model._noFilters" :type="activeFilter ? 'primary' : 'default'" @click="toggleVisibleFilter">
|
||||
فیلتر
|
||||
<a-badge v-if="query.filters.length" :count="query.filters.length" />
|
||||
</a-button>
|
||||
|
||||
<a-dropdown :trigger="['click']">
|
||||
<a-button>ستونها</a-button>
|
||||
<template #overlay>
|
||||
<a-menu>
|
||||
<a-menu-item v-for="col in cols" :key="col.key">
|
||||
<a-checkbox v-model:checked="col.show">{{ col.title }}</a-checkbox>
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
|
||||
<a-button class="refresh-btn" :loading="loading" @click="fetch()">بارگذاری مجدد</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- filter bar: one row per active filter -->
|
||||
<div v-if="activeFilter" class="x-table__filters">
|
||||
<div v-for="(f, index) in query.filters" :key="f.id" class="x-table__filter-row">
|
||||
<a-select
|
||||
v-model:value="f.field"
|
||||
placeholder="ستون"
|
||||
style="width: 180px"
|
||||
:options="filterableOptions"
|
||||
/>
|
||||
<a-select v-model:value="f.op" placeholder="شرط" style="width: 130px" :options="opOptions" />
|
||||
<!--
|
||||
A column declared with filterSelect({...}) gets a dropdown of its
|
||||
value->label map; a date column gets a date picker; everything else
|
||||
gets a free-text box, as in legacy.
|
||||
-->
|
||||
<a-select
|
||||
v-if="selectItemsFor(f.field)"
|
||||
v-model:value="f.value"
|
||||
placeholder="مقدار"
|
||||
style="width: 200px"
|
||||
:options="selectItemsFor(f.field)"
|
||||
/>
|
||||
<a-date-picker
|
||||
v-else-if="isDateField(f.field)"
|
||||
v-model:value="f.value"
|
||||
placeholder="مقدار"
|
||||
style="width: 200px"
|
||||
value-format="YYYY-MM-DD"
|
||||
/>
|
||||
<a-input v-else v-model:value="f.value" placeholder="مقدار" style="width: 200px" />
|
||||
<a-input
|
||||
v-if="f.op === 'between'"
|
||||
v-model:value="f.value2"
|
||||
placeholder="تا"
|
||||
style="width: 200px"
|
||||
/>
|
||||
<a-button danger @click="removeFilter(index)">حذف</a-button>
|
||||
</div>
|
||||
<div class="x-table__filter-actions">
|
||||
<a-button @click="addFilterRow">افزودن فیلتر</a-button>
|
||||
<a-button type="primary" @click="doFilter">اعمال</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="antColumns"
|
||||
:data-source="data"
|
||||
:loading="loading"
|
||||
:pagination="paginationConfig"
|
||||
:locale="locale"
|
||||
:row-selection="rowSelection"
|
||||
row-key="id"
|
||||
size="middle"
|
||||
bordered
|
||||
@change="onTableChange"
|
||||
>
|
||||
<template #bodyCell="{ column, record, text }">
|
||||
<!--
|
||||
Legacy used named scoped slots (renderSlot('opt') -> slot="opt").
|
||||
Vue 3 routes them through #bodyCell, and the slot name is forwarded to
|
||||
the parent under the same name, so ported views keep their markup.
|
||||
-->
|
||||
<template v-if="slotNameFor(column)">
|
||||
<slot :name="slotNameFor(column)" :text="text" :record="record" />
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import api from '../../api/client';
|
||||
import type { XTbl } from './model';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
model: XTbl;
|
||||
/** legacy prop: renders a checkbox column (ProductList uses this) */
|
||||
hasSelection?: boolean;
|
||||
/** legacy used v-model for the selected rows */
|
||||
modelValue?: Record<string, unknown>[];
|
||||
}>(),
|
||||
{ hasSelection: false, modelValue: () => [] },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'query', q: unknown): void;
|
||||
(e: 'update:modelValue', rows: Record<string, unknown>[]): void;
|
||||
}>();
|
||||
|
||||
/** Only present when hasSelection is set, so other tables are unaffected. */
|
||||
const rowSelection = computed(() =>
|
||||
props.hasSelection
|
||||
? {
|
||||
selectedRowKeys: props.modelValue.map((r) => r.id as string),
|
||||
onChange: (_keys: (string | number)[], rows: Record<string, unknown>[]) => {
|
||||
emit('update:modelValue', rows);
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
|
||||
interface FilterRow {
|
||||
id: number;
|
||||
field?: string;
|
||||
op?: string;
|
||||
value?: unknown;
|
||||
value2?: unknown;
|
||||
}
|
||||
|
||||
const data = ref<Record<string, unknown>[]>([]);
|
||||
const loading = ref(false);
|
||||
const activeFilter = ref(false);
|
||||
const sort = ref<string | undefined>(undefined);
|
||||
const sortDirection = ref<'asc' | 'desc'>('asc');
|
||||
|
||||
// legacy data.query
|
||||
const query = reactive({
|
||||
search: undefined as string | undefined,
|
||||
filters: [] as FilterRow[],
|
||||
total: 0,
|
||||
limit: 10,
|
||||
page: 1,
|
||||
});
|
||||
|
||||
const locale = { emptyText: 'هیج رکوردی یافت نشد.' };
|
||||
|
||||
const opOptions = [
|
||||
{ value: '=', label: 'برابر' },
|
||||
{ value: '!=', label: 'نامساوی' },
|
||||
{ value: 'like', label: 'شامل' },
|
||||
{ value: '>', label: 'بزرگتر' },
|
||||
{ value: '>=', label: 'بزرگتر مساوی' },
|
||||
{ value: '<', label: 'کوچکتر' },
|
||||
{ value: '<=', label: 'کوچکتر مساوی' },
|
||||
{ value: 'between', label: 'بین' },
|
||||
];
|
||||
|
||||
const cols = computed(() => props.model?.cols ?? []);
|
||||
const visibleCols = computed(() => cols.value.filter((c) => c.show));
|
||||
const filterableOptions = computed(() =>
|
||||
cols.value.filter((c) => c.filtered).map((c) => ({ value: c.__fkey ?? c.key, label: c.title })),
|
||||
);
|
||||
|
||||
/** legacy `order` computed: '+field' / '-field' */
|
||||
const order = computed(() =>
|
||||
sort.value ? `${sortDirection.value === 'asc' ? '+' : '-'}${sort.value}` : undefined,
|
||||
);
|
||||
|
||||
const antColumns = computed(() =>
|
||||
visibleCols.value.map((c) => ({
|
||||
key: c.key,
|
||||
title: c.title,
|
||||
dataIndex: c.dataIndex.includes('.') ? c.dataIndex.split('.') : c.dataIndex,
|
||||
sorter: c.__sorter,
|
||||
width: c.width,
|
||||
__slot: c.scopedSlots?.customRender,
|
||||
})),
|
||||
);
|
||||
|
||||
function slotNameFor(column: { __slot?: string }) {
|
||||
return column.__slot;
|
||||
}
|
||||
|
||||
/** Resolves a filter row's column back to its Xtc definition. */
|
||||
function colForField(field?: string) {
|
||||
if (!field) return undefined;
|
||||
return cols.value.find((c) => (c.__fkey ?? c.key) === field);
|
||||
}
|
||||
|
||||
/** Dropdown options for a filterSelect column, or undefined for other types. */
|
||||
function selectItemsFor(field?: string) {
|
||||
const col = colForField(field);
|
||||
if (col?.filterType !== 'select' || !col.filterItems) return undefined;
|
||||
return Object.entries(col.filterItems).map(([value, label]) => ({ value, label }));
|
||||
}
|
||||
|
||||
function isDateField(field?: string) {
|
||||
return colForField(field)?.filterType === 'date';
|
||||
}
|
||||
|
||||
const paginationConfig = computed(() =>
|
||||
props.model?._hidePagination
|
||||
? false
|
||||
: {
|
||||
current: query.page,
|
||||
pageSize: query.limit,
|
||||
total: query.total,
|
||||
showSizeChanger: true,
|
||||
},
|
||||
);
|
||||
|
||||
/** legacy requestData() */
|
||||
function requestData(op?: string) {
|
||||
const titles: Record<string, string> = {};
|
||||
visibleCols.value.forEach((c) => {
|
||||
titles[c.key] = c.title;
|
||||
});
|
||||
const select = visibleCols.value.map((c) => c.key);
|
||||
const base = {
|
||||
search: query.search,
|
||||
filters: query.filters.filter((f) => f.field && f.op),
|
||||
total: query.total,
|
||||
limit: query.limit,
|
||||
page: query.page,
|
||||
order: order.value,
|
||||
select,
|
||||
titles,
|
||||
report_title: props.model?.title,
|
||||
};
|
||||
return op ? { ...base, op } : base;
|
||||
}
|
||||
|
||||
/** legacy fetch() + getDataUsage() in 'laravel' mode */
|
||||
async function fetch(op?: string) {
|
||||
if (!props.model) return;
|
||||
try {
|
||||
loading.value = true;
|
||||
const { data: res } = await api.post(props.model.url, requestData(op));
|
||||
data.value = res.rows;
|
||||
query.total = res.query?.total ?? 0;
|
||||
query.page = res.query?.page ?? query.page;
|
||||
query.limit = res.query?.limit ?? query.limit;
|
||||
emit('query', res.query);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
throw error;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch(value: string) {
|
||||
query.search = value;
|
||||
query.page = 1;
|
||||
fetch();
|
||||
}
|
||||
|
||||
function toggleVisibleFilter() {
|
||||
activeFilter.value = !activeFilter.value;
|
||||
if (activeFilter.value && query.filters.length === 0) addFilterRow();
|
||||
}
|
||||
|
||||
/** legacy addFilterRow(): id is a random float used only as a list key */
|
||||
function addFilterRow() {
|
||||
query.filters.push({ id: parseFloat(`${query.filters.length}.${Math.floor(Math.random() * 1000)}`) });
|
||||
}
|
||||
|
||||
function removeFilter(index: number) {
|
||||
query.filters.splice(index, 1);
|
||||
}
|
||||
|
||||
function doFilter() {
|
||||
activeFilter.value = false;
|
||||
query.page = 1;
|
||||
fetch();
|
||||
}
|
||||
|
||||
function onTableChange(
|
||||
pagination: { current?: number; pageSize?: number },
|
||||
_filters: unknown,
|
||||
sorter: { columnKey?: string; order?: string },
|
||||
) {
|
||||
query.page = pagination.current ?? 1;
|
||||
query.limit = pagination.pageSize ?? query.limit;
|
||||
if (sorter?.columnKey && sorter.order) {
|
||||
sort.value = String(sorter.columnKey);
|
||||
sortDirection.value = sorter.order === 'ascend' ? 'asc' : 'desc';
|
||||
} else {
|
||||
sort.value = undefined;
|
||||
}
|
||||
fetch();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// legacy applied model.defaultFilters before the first fetch
|
||||
for (const df of props.model?.defaultFilters ?? []) {
|
||||
query.filters.push({ id: Math.random(), ...df });
|
||||
}
|
||||
fetch();
|
||||
});
|
||||
|
||||
defineExpose({ fetch });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.x-table__toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.x-table__actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.x-table__filters {
|
||||
background: #fafafa;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.x-table__filter-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.x-table__filter-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.refresh-btn {
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user