befly-admin 3.12.2 → 3.12.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "befly-admin",
3
- "version": "3.12.2",
4
- "gitHead": "0e9cfa6f2979b12725e06e34936737738a219747",
3
+ "version": "3.12.4",
4
+ "gitHead": "9823cc29996ba9950dd08bfd72856e3b65815039",
5
5
  "private": false,
6
6
  "description": "Befly Admin - 基于 Vue3 + TDesign Vue Next 的后台管理系统",
7
7
  "files": [
@@ -28,7 +28,7 @@
28
28
  "preview": "vite preview"
29
29
  },
30
30
  "dependencies": {
31
- "@befly-addon/admin": "^1.8.2",
31
+ "@befly-addon/admin": "^1.8.4",
32
32
  "@iconify-json/lucide": "^1.2.85",
33
33
  "axios": "^1.13.2",
34
34
  "befly-shared": "1.4.3",
@@ -0,0 +1,470 @@
1
+ <template>
2
+ <div class="page-table-detail page-table">
3
+ <div class="main-tool">
4
+ <div class="left">
5
+ <slot name="toolLeft" :reload="reload" :current-row="currentRow"></slot>
6
+ </div>
7
+ <div class="right">
8
+ <slot name="toolRight" :reload="reload" :current-row="currentRow"></slot>
9
+ </div>
10
+ </div>
11
+
12
+ <div class="main-content">
13
+ <div class="main-table">
14
+ <TTable :data="rows" :columns="columns" :loading="loading" :active-row-keys="activeRowKeys" :row-key="rowKey" :height="tableHeight" active-row-type="single" @active-change="onActiveChange">
15
+ <template #operation="scope">
16
+ <slot name="operation" v-bind="buildOperationSlotProps(scope)"></slot>
17
+ </template>
18
+
19
+ <template v-for="name in forwardedTableSlotNames" :key="name" v-slot:[name]="scope">
20
+ <slot :name="name" v-bind="scope"></slot>
21
+ </template>
22
+ </TTable>
23
+ </div>
24
+
25
+ <div class="main-detail">
26
+ <slot name="detail" :row="currentRow" :reload="reload">
27
+ <DetailPanel :data="currentRow" :fields="detailFields" />
28
+ </slot>
29
+ </div>
30
+ </div>
31
+
32
+ <div class="main-page">
33
+ <TPagination :current-page="pager.currentPage" :page-size="pager.limit" :total="pager.total" :align="paginationAlign" :layout="paginationLayout" @current-change="onPageChange" @page-size-change="onPageSizeChange" />
34
+ </div>
35
+
36
+ <slot name="dialogs" :reload="reload" :current-row="currentRow"></slot>
37
+ </div>
38
+ </template>
39
+
40
+ <script setup lang="ts">
41
+ import { computed, onMounted, ref, useSlots } from "vue";
42
+ import { DialogPlugin, MessagePlugin, Pagination as TPagination, Table as TTable, type PrimaryTableCol, type TableRowData } from "tdesign-vue-next";
43
+
44
+ import DetailPanel from "@/components/DetailPanel.vue";
45
+ import { $Http, type HttpApiResponse } from "@/plugins/http";
46
+
47
+ type PagerState = {
48
+ currentPage: number;
49
+ limit: number;
50
+ total: number;
51
+ };
52
+
53
+ type DeleteConfirmContent = {
54
+ header: string;
55
+ body: string;
56
+ confirmBtn?: string;
57
+ cancelBtn?: string;
58
+ status?: "warning" | "danger" | "info" | "success";
59
+ };
60
+
61
+ type DeleteEndpoint<Row extends TableRowData> = {
62
+ path: string;
63
+ idKey?: string;
64
+ confirm?: DeleteConfirmContent | ((row: Row) => DeleteConfirmContent);
65
+ dropValues?: readonly (string | number | boolean | null | undefined)[];
66
+ dropKeyValue?: Record<string, readonly (string | number | boolean | null | undefined)[]>;
67
+ buildData?: (row: Row) => Record<string, unknown>;
68
+ };
69
+
70
+ type ListEndpoint = {
71
+ path: string;
72
+ pageKey?: string;
73
+ limitKey?: string;
74
+ dropValues?: readonly (string | number | boolean | null | undefined)[];
75
+ dropKeyValue?: Record<string, readonly (string | number | boolean | null | undefined)[]>;
76
+ buildData?: (pager: { currentPage: number; limit: number }) => Record<string, unknown>;
77
+ };
78
+
79
+ type Endpoints<Row extends TableRowData> = {
80
+ list?: ListEndpoint;
81
+ delete?: DeleteEndpoint<Row>;
82
+ };
83
+
84
+ type ListResponse<Row extends TableRowData> = {
85
+ lists: Row[];
86
+ total: number;
87
+ };
88
+
89
+ type LoadListOptions = {
90
+ keepSelection?: boolean;
91
+ allowPageFallback?: boolean;
92
+ };
93
+
94
+ const props = withDefaults(
95
+ defineProps<{
96
+ columns: PrimaryTableCol[];
97
+ detailFields?: PrimaryTableCol[];
98
+ rowKey?: string;
99
+ tableHeight?: string;
100
+ pageSize?: number;
101
+ endpoints?: Endpoints<TableRowData>;
102
+ paginationAlign?: "left" | "center" | "right";
103
+ paginationLayout?: string;
104
+ autoLoad?: boolean;
105
+ tableSlotNames?: string[];
106
+ }>(),
107
+ {
108
+ detailFields: undefined,
109
+ rowKey: "id",
110
+ tableHeight: "calc(100vh - var(--search-height) - var(--pagination-height) - var(--layout-gap) * 4)",
111
+ pageSize: 30,
112
+ endpoints: undefined,
113
+ paginationAlign: "right",
114
+ paginationLayout: "total, prev, pager, next, jumper",
115
+ autoLoad: true,
116
+ tableSlotNames: undefined
117
+ }
118
+ );
119
+
120
+ const emit = defineEmits<{
121
+ (e: "loaded", payload: { rows: TableRowData[]; total: number }): void;
122
+ (e: "row-change", payload: { row: TableRowData | null }): void;
123
+ (e: "deleted", payload: { row: TableRowData }): void;
124
+ }>();
125
+
126
+ const slots = useSlots();
127
+
128
+ const rows = ref<TableRowData[]>([]);
129
+ const loading = ref<boolean>(false);
130
+ const pager = ref<PagerState>({
131
+ currentPage: 1,
132
+ limit: props.pageSize,
133
+ total: 0
134
+ });
135
+
136
+ const currentRow = ref<TableRowData | null>(null);
137
+ const activeRowKeys = ref<Array<string | number>>([]);
138
+
139
+ let requestSeq = 0;
140
+
141
+ const detailFields = computed(() => {
142
+ if (props.detailFields && Array.isArray(props.detailFields)) {
143
+ return props.detailFields;
144
+ }
145
+ return props.columns;
146
+ });
147
+
148
+ const forwardedTableSlotNames = computed(() => {
149
+ if (Array.isArray(props.tableSlotNames) && props.tableSlotNames.length > 0) {
150
+ const out: string[] = [];
151
+ const set = new Set<string>();
152
+ for (const name of props.tableSlotNames) {
153
+ if (typeof name !== "string") continue;
154
+ const trimmed = name.trim();
155
+ if (trimmed.length === 0) continue;
156
+ if (set.has(trimmed)) continue;
157
+ set.add(trimmed);
158
+ out.push(trimmed);
159
+ }
160
+ return out;
161
+ }
162
+
163
+ const names: string[] = [];
164
+ const reserved = ["toolLeft", "toolRight", "detail", "dialogs", "operation", "default"]; // operation 单独处理
165
+
166
+ for (const key of Object.keys(slots)) {
167
+ if (reserved.includes(key)) continue;
168
+ names.push(key);
169
+ }
170
+
171
+ return names;
172
+ });
173
+
174
+ function setCurrentRow(row: TableRowData | null): void {
175
+ currentRow.value = row;
176
+ emit("row-change", { row: row });
177
+ }
178
+
179
+ function getRowKeyValue(row: TableRowData): string | number | null {
180
+ const key = props.rowKey;
181
+ if (!key) return null;
182
+
183
+ const record = row as Record<string, unknown>;
184
+ const raw = record[key];
185
+ if (typeof raw === "string") return raw;
186
+ if (typeof raw === "number") return raw;
187
+ return null;
188
+ }
189
+
190
+ function applyAutoSelection(list: TableRowData[], preferKey: string | number | null): void {
191
+ if (!Array.isArray(list) || list.length === 0) {
192
+ setCurrentRow(null);
193
+ activeRowKeys.value = [];
194
+ return;
195
+ }
196
+
197
+ if (preferKey !== null) {
198
+ for (const row of list) {
199
+ const k = getRowKeyValue(row);
200
+ if (k === preferKey) {
201
+ setCurrentRow(row);
202
+ activeRowKeys.value = [k];
203
+ return;
204
+ }
205
+ }
206
+ }
207
+
208
+ const first = list[0] as TableRowData;
209
+ const firstKey = getRowKeyValue(first);
210
+ setCurrentRow(first);
211
+
212
+ if (firstKey === null) {
213
+ activeRowKeys.value = [];
214
+ return;
215
+ }
216
+
217
+ activeRowKeys.value = [firstKey];
218
+ }
219
+
220
+ function getLastPage(total: number, limit: number): number {
221
+ if (total <= 0) return 1;
222
+ if (limit <= 0) return 1;
223
+ const pages = Math.ceil(total / limit);
224
+ if (!Number.isFinite(pages) || pages <= 0) return 1;
225
+ return pages;
226
+ }
227
+
228
+ async function loadList(options?: LoadListOptions): Promise<void> {
229
+ const listEndpoint = props.endpoints?.list;
230
+ if (!listEndpoint) {
231
+ return;
232
+ }
233
+
234
+ const preferKey = options?.keepSelection ? (currentRow.value ? getRowKeyValue(currentRow.value) : null) : null;
235
+
236
+ requestSeq += 1;
237
+ const seq = requestSeq;
238
+
239
+ loading.value = true;
240
+ try {
241
+ const pageKey = listEndpoint.pageKey || "page";
242
+ const limitKey = listEndpoint.limitKey || "limit";
243
+
244
+ const data: Record<string, unknown> = {};
245
+ data[pageKey] = pager.value.currentPage;
246
+ data[limitKey] = pager.value.limit;
247
+
248
+ if (listEndpoint.buildData) {
249
+ const extra = listEndpoint.buildData({ currentPage: pager.value.currentPage, limit: pager.value.limit });
250
+ if (extra && typeof extra === "object") {
251
+ for (const k of Object.keys(extra)) {
252
+ data[k] = extra[k];
253
+ }
254
+ }
255
+ }
256
+
257
+ const res = (await $Http.post<ListResponse<TableRowData>>(listEndpoint.path, data, {
258
+ dropValues: listEndpoint.dropValues,
259
+ dropKeyValue: listEndpoint.dropKeyValue
260
+ })) as HttpApiResponse<ListResponse<TableRowData>>;
261
+
262
+ // 并发保护:旧请求返回后不应覆盖新请求的状态
263
+ if (seq !== requestSeq) {
264
+ return;
265
+ }
266
+
267
+ const lists = Array.isArray(res?.data?.lists) ? res.data.lists : [];
268
+ const total = typeof res?.data?.total === "number" ? res.data.total : 0;
269
+
270
+ // B:页码越界修正(删除/过滤等导致 total 变小后,当前页可能已不存在)
271
+ const allowPageFallback = options?.allowPageFallback !== false;
272
+ if (allowPageFallback && lists.length === 0 && total > 0) {
273
+ const lastPage = getLastPage(total, pager.value.limit);
274
+ if (pager.value.currentPage > lastPage) {
275
+ pager.value.currentPage = lastPage;
276
+ await loadList({ keepSelection: false, allowPageFallback: false });
277
+ return;
278
+ }
279
+ }
280
+
281
+ rows.value = lists;
282
+ pager.value.total = total;
283
+
284
+ applyAutoSelection(lists, preferKey);
285
+ emit("loaded", { rows: lists, total: total });
286
+ } catch (error) {
287
+ if (seq !== requestSeq) {
288
+ return;
289
+ }
290
+ MessagePlugin.error("加载数据失败");
291
+ } finally {
292
+ if (seq === requestSeq) {
293
+ loading.value = false;
294
+ }
295
+ }
296
+ }
297
+
298
+ async function reload(options?: { keepSelection?: boolean; resetPage?: boolean }): Promise<void> {
299
+ if (options?.resetPage) {
300
+ pager.value.currentPage = 1;
301
+ }
302
+ await loadList({ keepSelection: options?.keepSelection, allowPageFallback: true });
303
+ }
304
+
305
+ function onPageChange(info: { currentPage: number }): void {
306
+ pager.value.currentPage = info.currentPage;
307
+ void reload({ keepSelection: true });
308
+ }
309
+
310
+ function onPageSizeChange(info: { pageSize: number }): void {
311
+ pager.value.limit = info.pageSize;
312
+ pager.value.currentPage = 1;
313
+ void reload({ keepSelection: false });
314
+ }
315
+
316
+ function onActiveChange(value: Array<string | number>, context: { activeRowList?: Array<{ row: TableRowData }> }): void {
317
+ // 禁止取消高亮:如果新值为空,保持当前选中
318
+ if (value.length === 0) {
319
+ if (activeRowKeys.value.length > 0) {
320
+ return;
321
+ }
322
+
323
+ activeRowKeys.value = [];
324
+ setCurrentRow(null);
325
+ return;
326
+ }
327
+
328
+ activeRowKeys.value = value;
329
+
330
+ const list = context.activeRowList;
331
+ if (list && Array.isArray(list) && list.length > 0) {
332
+ const row = list[0]?.row || null;
333
+ setCurrentRow(row);
334
+ return;
335
+ }
336
+
337
+ // C1:兜底回查(activeRowList 缺失时按 key 在 rows 中回查)
338
+ const activeKey = value[0];
339
+ for (const row of rows.value) {
340
+ const key = getRowKeyValue(row);
341
+ if (key === activeKey) {
342
+ setCurrentRow(row);
343
+ return;
344
+ }
345
+ }
346
+ }
347
+
348
+ function getDeleteConfirmContent(ep: DeleteEndpoint<TableRowData>, row: TableRowData): DeleteConfirmContent {
349
+ const confirm = ep.confirm;
350
+ if (!confirm) {
351
+ return {
352
+ header: "确认删除",
353
+ body: "确认删除该记录吗?",
354
+ confirmBtn: "删除"
355
+ };
356
+ }
357
+
358
+ if (typeof confirm === "function") {
359
+ return confirm(row);
360
+ }
361
+
362
+ return confirm;
363
+ }
364
+
365
+ async function deleteRow(row: TableRowData): Promise<void> {
366
+ const ep = props.endpoints?.delete;
367
+ if (!ep) {
368
+ MessagePlugin.error("未配置删除接口");
369
+ return;
370
+ }
371
+
372
+ const idKey = ep.idKey || "id";
373
+ const record = row as Record<string, unknown>;
374
+ const rawId = record[idKey];
375
+
376
+ if (rawId === null || rawId === undefined || rawId === "") {
377
+ MessagePlugin.error("删除失败:缺少主键");
378
+ return;
379
+ }
380
+
381
+ const confirmContent = getDeleteConfirmContent(ep, row);
382
+
383
+ let dialog: { destroy?: () => void; setConfirmLoading?: (v: boolean) => void } | null = null;
384
+ let destroyed = false;
385
+
386
+ const destroy = () => {
387
+ if (destroyed) return;
388
+ destroyed = true;
389
+ if (dialog && typeof dialog.destroy === "function") {
390
+ dialog.destroy();
391
+ }
392
+ };
393
+
394
+ dialog = DialogPlugin.confirm({
395
+ header: confirmContent.header,
396
+ body: confirmContent.body,
397
+ status: confirmContent.status || "warning",
398
+ confirmBtn: confirmContent.confirmBtn || "删除",
399
+ cancelBtn: confirmContent.cancelBtn || "取消",
400
+ onConfirm: async () => {
401
+ if (dialog && typeof dialog.setConfirmLoading === "function") {
402
+ dialog.setConfirmLoading(true);
403
+ }
404
+
405
+ try {
406
+ const data: Record<string, unknown> = {};
407
+ data[idKey] = rawId;
408
+
409
+ if (ep.buildData) {
410
+ const extra = ep.buildData(row);
411
+ if (extra && typeof extra === "object") {
412
+ for (const k of Object.keys(extra)) {
413
+ data[k] = extra[k];
414
+ }
415
+ }
416
+ }
417
+
418
+ await $Http.post(ep.path, data, {
419
+ dropValues: ep.dropValues,
420
+ dropKeyValue: ep.dropKeyValue
421
+ });
422
+
423
+ MessagePlugin.success("删除成功");
424
+ destroy();
425
+ emit("deleted", { row: row });
426
+ await reload({ keepSelection: true });
427
+ } catch (error) {
428
+ MessagePlugin.error("删除失败");
429
+ } finally {
430
+ if (dialog && typeof dialog.setConfirmLoading === "function") {
431
+ dialog.setConfirmLoading(false);
432
+ }
433
+ }
434
+ },
435
+ onClose: () => {
436
+ destroy();
437
+ }
438
+ });
439
+ }
440
+
441
+ function buildOperationSlotProps(scope: Record<string, unknown>): Record<string, unknown> {
442
+ const out: Record<string, unknown> = {};
443
+
444
+ for (const k of Object.keys(scope)) {
445
+ out[k] = scope[k];
446
+ }
447
+
448
+ out["deleteRow"] = deleteRow;
449
+ out["reload"] = reload;
450
+
451
+ return out;
452
+ }
453
+
454
+ defineExpose({
455
+ reload: reload,
456
+ deleteRow: deleteRow,
457
+ rows: rows,
458
+ pager: pager,
459
+ currentRow: currentRow
460
+ });
461
+
462
+ onMounted(() => {
463
+ if (!props.autoLoad) return;
464
+ void reload({ keepSelection: false });
465
+ });
466
+ </script>
467
+
468
+ <style scoped lang="scss">
469
+ // 样式继承自全局 page-table
470
+ </style>
@@ -20,6 +20,7 @@ declare module 'vue' {
20
20
  'ILucide:logOut': typeof import('~icons/lucide/log-out')['default']
21
21
  'ILucide:settings': typeof import('~icons/lucide/settings')['default']
22
22
  'ILucide:user': typeof import('~icons/lucide/user')['default']
23
+ PagedTableDetailPage: typeof import('./../components/PagedTableDetailPage.vue')['default']
23
24
  RouterLink: typeof import('vue-router')['RouterLink']
24
25
  RouterView: typeof import('vue-router')['RouterView']
25
26
  TButton: typeof import('tdesign-vue-next')['Button']