@fui-org/fui-cli 1.3.1 → 2.0.0

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.
Files changed (111) hide show
  1. package/README.md +19 -3
  2. package/dist/fui-bmg5pnmq.js +379 -0
  3. package/dist/fui.js +1 -1
  4. package/package.json +7 -6
  5. package/skills/fui/SKILL.md +9 -41
  6. package/skills/fui-skill/SKILL.md +95 -225
  7. package/skills/fui-skill/assets/projectdefaultstyle-3.0.css +555 -0
  8. package/skills/fui-skill/assets/projectdefaultstyle.css +207 -235
  9. package/skills/fui-skill/references/INDEX.md +105 -137
  10. package/skills/fui-skill/references/advanced-techniques.md +76 -68
  11. package/skills/fui-skill/references/coding-standards.md +56 -56
  12. package/skills/fui-skill/references/component-design.md +166 -173
  13. package/skills/fui-skill/references/component-quickref.md +61 -60
  14. package/skills/fui-skill/references/component-table.md +128 -117
  15. package/skills/fui-skill/references/components-dialog.md +55 -56
  16. package/skills/fui-skill/references/components-display.md +24 -30
  17. package/skills/fui-skill/references/components-echart.md +186 -261
  18. package/skills/fui-skill/references/components-input.md +72 -96
  19. package/skills/fui-skill/references/controls-patterns.md +196 -342
  20. package/skills/fui-skill/references/controls-styling-vocabulary.md +130 -97
  21. package/skills/fui-skill/references/db-table-design.md +24 -28
  22. package/skills/fui-skill/references/db-workflow.md +191 -390
  23. package/skills/fui-skill/references/default-function.md +169 -128
  24. package/skills/fui-skill/references/design-modes.md +35 -63
  25. package/skills/fui-skill/references/echart-templates.md +204 -196
  26. package/skills/fui-skill/references/fastproject.md +62 -60
  27. package/skills/fui-skill/references/fsheet.md +109 -124
  28. package/skills/fui-skill/references/fullstack-workflow.md +90 -128
  29. package/skills/fui-skill/references/module-data-patterns.md +31 -40
  30. package/skills/fui-skill/references/module-json-anatomy.md +47 -52
  31. package/skills/fui-skill/references/module-structure.md +80 -196
  32. package/skills/fui-skill/references/new-session.md +49 -51
  33. package/skills/fui-skill/references/pdfmake.md +17 -17
  34. package/skills/fui-skill/references/permission-system.md +89 -108
  35. package/skills/fui-skill/references/platform-architecture.md +128 -153
  36. package/skills/fui-skill/references/project-config.md +102 -134
  37. package/skills/fui-skill/references/project-provisioning.md +139 -244
  38. package/skills/fui-skill/references/script-map.md +208 -242
  39. package/skills/fui-skill/references/sql-clr-functions.md +98 -97
  40. package/skills/fui-skill/references/system-design.md +63 -88
  41. package/skills/fui-skill/references/tapi-file-api.md +46 -52
  42. package/skills/fui-skill/references/tapi-permission-patterns.md +51 -53
  43. package/skills/fui-skill/references/tapi-reference.md +132 -207
  44. package/skills/fui-skill/references/tools-registry.md +84 -460
  45. package/skills/fui-skill/references/ui-crosswindow-patterns.md +79 -75
  46. package/skills/fui-skill/references/ui-dialog-patterns.md +98 -72
  47. package/skills/fui-skill/references/ui-layout-patterns.md +26 -26
  48. package/skills/fui-skill/references/ui-patterns.md +71 -83
  49. package/skills/fui-skill/references/ui-screenshot-review.md +63 -62
  50. package/skills/fui-skill/references/ui-table-cell-patterns.md +61 -59
  51. package/skills/fui-skill/references/ui-templates.md +16 -23
  52. package/skills/fui-skill/references/verification.md +236 -246
  53. package/skills/fui-skill/references/watcher-patterns.md +30 -63
  54. package/skills/fui-skill/references/websocket-realtime.md +83 -66
  55. package/skills/fui-skill/scripts/component-3.0.js +298 -131
  56. package/skills/fui-skill/scripts/component.js +277 -271
  57. package/skills/fui-skill/scripts/componentTable-3.0.js +182 -53
  58. package/skills/fui-skill/scripts/componentTable.js +171 -49
  59. package/skills/fui-skill/scripts/defaultfunction-3.0.js +88 -3
  60. package/skills/fui-skill/scripts/defaultfunction.js +88 -3
  61. package/skills/fui-skill/scripts/fsheet.js +38 -0
  62. package/dist/fui-x7kph78r.js +0 -420
  63. package/skills/fui-skill/README.md +0 -112
  64. package/skills/fui-skill/metadata.json +0 -75
  65. package/skills/fui-skill-cli/SKILL.md +0 -139
  66. package/skills/fui-skill-cli/references/INDEX.md +0 -110
  67. package/skills/fui-skill-cli/references/advanced-techniques.md +0 -168
  68. package/skills/fui-skill-cli/references/coding-standards.md +0 -112
  69. package/skills/fui-skill-cli/references/component-design.md +0 -448
  70. package/skills/fui-skill-cli/references/component-quickref.md +0 -78
  71. package/skills/fui-skill-cli/references/component-table.md +0 -248
  72. package/skills/fui-skill-cli/references/components-dialog.md +0 -191
  73. package/skills/fui-skill-cli/references/components-display.md +0 -141
  74. package/skills/fui-skill-cli/references/components-echart.md +0 -316
  75. package/skills/fui-skill-cli/references/components-input.md +0 -335
  76. package/skills/fui-skill-cli/references/controls-patterns.md +0 -701
  77. package/skills/fui-skill-cli/references/controls-styling-vocabulary.md +0 -137
  78. package/skills/fui-skill-cli/references/db-table-design.md +0 -73
  79. package/skills/fui-skill-cli/references/db-workflow.md +0 -288
  80. package/skills/fui-skill-cli/references/default-function.md +0 -425
  81. package/skills/fui-skill-cli/references/design-modes.md +0 -57
  82. package/skills/fui-skill-cli/references/echart-templates.md +0 -489
  83. package/skills/fui-skill-cli/references/fastproject.md +0 -99
  84. package/skills/fui-skill-cli/references/fsheet.md +0 -203
  85. package/skills/fui-skill-cli/references/fullstack-workflow.md +0 -313
  86. package/skills/fui-skill-cli/references/module-data-patterns.md +0 -117
  87. package/skills/fui-skill-cli/references/module-json-anatomy.md +0 -132
  88. package/skills/fui-skill-cli/references/module-structure.md +0 -141
  89. package/skills/fui-skill-cli/references/new-session.md +0 -85
  90. package/skills/fui-skill-cli/references/pdfmake.md +0 -60
  91. package/skills/fui-skill-cli/references/permission-system.md +0 -150
  92. package/skills/fui-skill-cli/references/platform-architecture.md +0 -269
  93. package/skills/fui-skill-cli/references/project-config.md +0 -303
  94. package/skills/fui-skill-cli/references/project-provisioning.md +0 -278
  95. package/skills/fui-skill-cli/references/script-map.md +0 -262
  96. package/skills/fui-skill-cli/references/sql-clr-functions.md +0 -225
  97. package/skills/fui-skill-cli/references/system-design.md +0 -89
  98. package/skills/fui-skill-cli/references/tapi-file-api.md +0 -185
  99. package/skills/fui-skill-cli/references/tapi-permission-patterns.md +0 -156
  100. package/skills/fui-skill-cli/references/tapi-reference.md +0 -474
  101. package/skills/fui-skill-cli/references/tools-registry.md +0 -84
  102. package/skills/fui-skill-cli/references/ui-crosswindow-patterns.md +0 -321
  103. package/skills/fui-skill-cli/references/ui-dialog-patterns.md +0 -255
  104. package/skills/fui-skill-cli/references/ui-layout-patterns.md +0 -176
  105. package/skills/fui-skill-cli/references/ui-patterns.md +0 -303
  106. package/skills/fui-skill-cli/references/ui-screenshot-review.md +0 -95
  107. package/skills/fui-skill-cli/references/ui-table-cell-patterns.md +0 -318
  108. package/skills/fui-skill-cli/references/ui-templates.md +0 -22
  109. package/skills/fui-skill-cli/references/verification.md +0 -236
  110. package/skills/fui-skill-cli/references/watcher-patterns.md +0 -163
  111. package/skills/fui-skill-cli/references/websocket-realtime.md +0 -271
@@ -241,17 +241,6 @@ Vue.component("FDynamicComponent", {
241
241
  },
242
242
 
243
243
  });
244
- // //===========================================================================================
245
- // Vue.component("FOpenwindowControl", {
246
- // template: `<component v-for="(w,index) in windowList" v-bind:is="w.el" v-bind="w.attr">{{w.innerHTML}}</component>`,
247
- // props: {
248
- // windowList: {
249
- // default: [],
250
- // required: true
251
- // },
252
- // },
253
- // });
254
-
255
244
  //===========================================================================================
256
245
  Vue.component('FLabel', {
257
246
  props: ['items', "text", "color", 'iconText'],
@@ -1533,7 +1522,7 @@ Vue.component('HeaderBar', {
1533
1522
  <v-navigation-drawer
1534
1523
  v-if="leftMenuAndMobile"
1535
1524
  :color="menuStyle.leftmenu.bgcolor"
1536
- :class="['text-' + menuStyle.leftmenu.textcolor, 'fp-custom-scrollbar']"
1525
+ :class="['text-' + menuStyle.leftmenu.textcolor, 'fp-left-menu', 'fp-stable-left-menu']"
1537
1526
  v-model="openLeftMenu"
1538
1527
  app
1539
1528
  clipped
@@ -1559,6 +1548,7 @@ Vue.component('HeaderBar', {
1559
1548
  :target="item.target"
1560
1549
  :title="item.name"
1561
1550
  :class="['text-' + menuStyle.leftmenu.textcolor]"
1551
+ v-on:click="rememberMenuPosition"
1562
1552
  :data-active="isActiveUrl(item.url) ? 'true' : null"
1563
1553
  :style="isActiveUrl(item.url) ? {backgroundColor: activeColor} : {}">
1564
1554
  <template v-slot:prepend v-if="item.icon">
@@ -1578,6 +1568,7 @@ Vue.component('HeaderBar', {
1578
1568
  :href="i.url"
1579
1569
  :target="i.target"
1580
1570
  class="fp-submenu-item"
1571
+ v-on:click="rememberMenuPosition"
1581
1572
  :data-active="isActiveUrl(i.url) ? 'true' : null"
1582
1573
  :style="isActiveUrl(i.url) ? {backgroundColor: activeColor} : {}">
1583
1574
  <template v-slot:prepend v-if="i.icon"><v-icon :icon="i.icon" class="opacity-100"></v-icon></template>
@@ -1662,58 +1653,71 @@ Vue.component('HeaderBar', {
1662
1653
  if (!url) return false;
1663
1654
  return this.currentFullUrl === url;
1664
1655
  },
1665
- scrollToActiveMenu: function () {
1656
+ getDrawerContent: function () {
1657
+ return this.$el.querySelector('.v-navigation-drawer__content');
1658
+ },
1659
+ rememberMenuPosition: function () {
1660
+ var content = this.getDrawerContent();
1661
+ if (!content) return;
1662
+
1663
+ sessionStorage.setItem('fpLeftMenuScrollTop', String(content.scrollTop));
1664
+ },
1665
+ restoreMenuPosition: function () {
1666
1666
  if (!this.leftMenuAndMobile || this.leftMenuAndMobile.length === 0) return;
1667
-
1667
+
1668
1668
  var self = this;
1669
1669
  this.$nextTick(function() {
1670
- setTimeout(function() {
1671
- var activeEl = self.$el.querySelector('[data-active="true"]');
1672
- if (activeEl) {
1673
- // Fallback cho trình duyệt cũ không hỗ trợ options
1674
- if ('scrollBehavior' in document.documentElement.style) {
1675
- activeEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
1676
- } else {
1677
- activeEl.scrollIntoView(true);
1678
- }
1670
+ var content = self.getDrawerContent();
1671
+ if (!content) return;
1672
+
1673
+ var savedTop = Number(sessionStorage.getItem('fpLeftMenuScrollTop'));
1674
+ if (Number.isFinite(savedTop) && savedTop >= 0) {
1675
+ content.scrollTop = savedTop;
1676
+ }
1677
+ content.scrollLeft = 0;
1678
+
1679
+ var activeEl = self.$el.querySelector('[data-active="true"]');
1680
+ if (activeEl) {
1681
+ var containerRect = content.getBoundingClientRect();
1682
+ var activeRect = activeEl.getBoundingClientRect();
1683
+ var isOutside = activeRect.top < containerRect.top || activeRect.bottom > containerRect.bottom;
1684
+
1685
+ if (isOutside) {
1686
+ content.scrollTop += activeRect.top - containerRect.top
1687
+ - ((containerRect.height - activeRect.height) / 2);
1679
1688
  }
1680
- }, 300);
1689
+ }
1690
+
1691
+ self._menuScrollListener = function() {
1692
+ if (content.scrollLeft !== 0) content.scrollLeft = 0;
1693
+ sessionStorage.setItem('fpLeftMenuScrollTop', String(content.scrollTop));
1694
+ };
1695
+ content.addEventListener('scroll', self._menuScrollListener, { passive: true });
1681
1696
  });
1682
1697
  },
1683
- injectScrollbarStyles: function () {
1698
+ injectLeftMenuStyles: function () {
1684
1699
  if (!this.leftMenuAndMobile || this.leftMenuAndMobile.length === 0) return;
1685
-
1686
- var styleId = 'fp-custom-scrollbar-style';
1700
+
1701
+ var styleId = 'fp-left-menu-style';
1687
1702
  if (document.getElementById(styleId)) return;
1688
-
1689
- var thumbColor = 'rgba(255,255,255,0.3)';
1690
- var thumbHoverColor = 'rgba(255,255,255,0.5)';
1691
- var trackColor = 'rgba(0,0,0,0.1)';
1692
-
1703
+
1704
+ // Thanh cuộn KHÔNG vẽ ở đây: lấy từ .fpScrollbar trong projectdefaultstyle.css
1705
+ // (rule đó đã phủ sẵn .v-navigation-drawer__content).
1693
1706
  var style = document.createElement('style');
1694
1707
  style.id = styleId;
1695
1708
  style.textContent = [
1696
- '.fp-custom-scrollbar .v-navigation-drawer__content::-webkit-scrollbar {',
1697
- ' width: 5px;',
1698
- '}',
1699
- '.fp-custom-scrollbar .v-navigation-drawer__content::-webkit-scrollbar-track {',
1700
- ' background: ' + trackColor + ';',
1709
+ '.fp-left-menu .v-navigation-drawer__content {',
1710
+ ' overflow-x: hidden !important;',
1711
+ ' scroll-behavior: auto !important;',
1701
1712
  '}',
1702
- '.fp-custom-scrollbar .v-navigation-drawer__content::-webkit-scrollbar-thumb {',
1703
- ' background: ' + thumbColor + ';',
1704
- ' border-radius: 3px;',
1713
+ '.fp-stable-left-menu,',
1714
+ '.fp-stable-left-menu.v-navigation-drawer {',
1715
+ ' transition: none !important;',
1705
1716
  '}',
1706
- '.fp-custom-scrollbar .v-navigation-drawer__content::-webkit-scrollbar-thumb:hover {',
1707
- ' background: ' + thumbHoverColor + ';',
1708
- '}',
1709
- '.fp-custom-scrollbar .v-navigation-drawer__content {',
1710
- ' scrollbar-width: thin;',
1711
- ' scrollbar-color: ' + thumbColor + ' ' + trackColor + ';',
1712
- '}',
1713
- '.fp-custom-scrollbar .v-list-group__items .fp-submenu-item {',
1717
+ '.fp-left-menu .v-list-group__items .fp-submenu-item {',
1714
1718
  ' padding-left: 32px !important;',
1715
1719
  '}',
1716
- '.fp-custom-scrollbar .v-list-group__items .v-list-item__prepend {',
1720
+ '.fp-left-menu .v-list-group__items .v-list-item__prepend {',
1717
1721
  ' width: auto;',
1718
1722
  ' margin-right: 8px;',
1719
1723
  '}'
@@ -1734,11 +1738,16 @@ Vue.component('HeaderBar', {
1734
1738
  },
1735
1739
  mounted: function() {
1736
1740
  if (this.leftMenuAndMobile && this.leftMenuAndMobile.length > 0) {
1737
- this.injectScrollbarStyles();
1738
- this.scrollToActiveMenu();
1741
+ this.injectLeftMenuStyles();
1742
+ this.restoreMenuPosition();
1739
1743
  }
1740
1744
  },
1741
1745
  beforeDestroy: function() {
1746
+ this.rememberMenuPosition();
1747
+ var content = this.getDrawerContent();
1748
+ if (content && this._menuScrollListener) {
1749
+ content.removeEventListener('scroll', this._menuScrollListener);
1750
+ }
1742
1751
  window.removeEventListener('scroll', this._scrollListener);
1743
1752
  }
1744
1753
  });
@@ -2350,7 +2359,8 @@ Vue.component('FPdfmake', {
2350
2359
 
2351
2360
  //===========================================================================================
2352
2361
  Vue.component('FExcelReader', {
2353
- emits: ['update:modelValue', 'load', 'error', 'action-error'],
2362
+ inheritAttrs: false,
2363
+ emits: ['update:modelValue', 'load', 'error', 'action-error', 'cancel'],
2354
2364
  props: {
2355
2365
  modelValue: { type: Array, default: () => [] },
2356
2366
 
@@ -2359,27 +2369,75 @@ Vue.component('FExcelReader', {
2359
2369
  dateFormat: { type: String, default: 'dd/mm/yyyy' },
2360
2370
  defval: { type: [String, Number], default: '' },
2361
2371
 
2362
- sheet: { type: [String, Number], default: 0 },
2363
- skipRows: { type: [String, Number], default: 0 },
2372
+ sheet: { type: [String, Number], default: null },
2373
+ sheetPicker: { type: [Boolean, String], default: true },
2374
+ sheetStats: { type: [Boolean, String], default: false },
2375
+ skipRows: { type: [String, Number], default: 0 },
2364
2376
 
2365
- label: { type: String, default: 'Load Excel' },
2366
- iconText: { type: String, default: 'mdi-grid' },
2367
- accept: { type: String, default: '.xlsx,.xlsb,.xlsm,.xls,.csv,.txt,.ods,.dbf,.prn,.html,.htm' },
2368
- maxSize: { type: [String, Number], default: 20 },
2377
+ label: { type: String, default: 'Load Excel' },
2378
+ iconText: { type: String, default: 'mdi-grid' },
2379
+ accept: { type: String, default: '.xlsx,.xlsb,.xlsm,.xls,.csv,.txt,.ods,.dbf,.prn,.html,.htm' },
2380
+ maxSize: { type: [String, Number], default: 20 },
2369
2381
  loadTimeout: { type: [String, Number], default: 15000 },
2382
+ loadingText: { type: String, default: 'Đang xử lý dữ liệu...' },
2383
+
2384
+ notify: { type: [Boolean, String], default: true },
2385
+ notifyTimeout: { type: [String, Number], default: 6000 },
2370
2386
 
2371
2387
  action: { type: Object, default: null }
2372
2388
  },
2373
2389
  template: `
2374
2390
  <div>
2375
2391
  <v-btn :text="label" v-bind="$attrs" :prepend-icon="iconText"
2376
- :disabled="parsing" :loading="busy" @click="onButtonClick"></v-btn>
2377
- <input ref="uploader" style="display:none" type="file" :multiple="false"
2378
- :accept="accept" @change="onchange" onclick="this.value=null;">
2392
+ :disabled="parsing" @click="onButtonClick"></v-btn>
2393
+ <input ref="uploader" style="display:none" type="file" :multiple="false" :accept="accept" @change="onchange" onclick="this.value=null;">
2394
+
2395
+ <v-dialog v-model="loading" persistent width="320">
2396
+ <v-card class="pa-8 d-flex flex-column align-center justify-center text-center">
2397
+ <div class="fui-loading"></div>
2398
+ <div class="mt-5 body-2">{{ loadingMsg }}</div>
2399
+ <div v-if="loadingSub" class="mt-1 caption grey--text text-truncate" style="max-width:100%;">{{ loadingSub }}</div>
2400
+ </v-card>
2401
+ </v-dialog>
2402
+
2403
+ <v-dialog v-model="dialog" max-width="440" persistent>
2404
+ <v-card>
2405
+ <v-card-title class="text-subtitle-1">Chọn sheet cần đọc</v-card-title>
2406
+ <v-card-text class="pb-0">
2407
+ <div class="text-caption mb-2">{{ pendingName }}</div>
2408
+ <v-list density="compact" nav max-height="300" class="overflow-y-auto">
2409
+ <v-list-item v-for="s in sheetList" :key="s.name"
2410
+ :active="s.name === sheetChoice"
2411
+ @click="sheetChoice = s.name">
2412
+ <v-list-item-title>{{ s.name }}</v-list-item-title>
2413
+ <v-list-item-subtitle v-if="s.info">{{ s.info }}</v-list-item-subtitle>
2414
+ </v-list-item>
2415
+ </v-list>
2416
+ </v-card-text>
2417
+ <v-card-actions>
2418
+ <v-spacer></v-spacer>
2419
+ <v-btn text="Hủy" @click="pickSheet(null)"></v-btn>
2420
+ <v-btn text="Chọn" color="primary" :disabled="!sheetChoice"
2421
+ @click="pickSheet(sheetChoice)"></v-btn>
2422
+ </v-card-actions>
2423
+ </v-card>
2424
+ </v-dialog>
2425
+
2426
+ <v-snackbar v-model="snack" :color="snackColor" :timeout="Number(notifyTimeout)" location="top">
2427
+ {{ snackMsg }}
2428
+ <template #actions>
2429
+ <v-btn variant="text" @click="snack = false">Đóng</v-btn>
2430
+ </template>
2431
+ </v-snackbar>
2379
2432
  </div>
2380
2433
  `,
2381
2434
  data() {
2382
- return { busy: false, parsing: false, reqId: 0 };
2435
+ return {
2436
+ parsing: false, reqId: 0,
2437
+ loading: false, loadingMsg: '', loadingSub: '',
2438
+ dialog: false, sheetList: [], sheetChoice: null, pendingName: '',
2439
+ snack: false, snackMsg: '', snackColor: 'error'
2440
+ };
2383
2441
  },
2384
2442
  computed: {
2385
2443
  optHeader() {
@@ -2391,26 +2449,51 @@ Vue.component('FExcelReader', {
2391
2449
  if (s.toUpperCase() === 'A') return 'A';
2392
2450
  return h;
2393
2451
  },
2394
- optRaw() {
2395
- if (typeof this.raw === 'boolean') return this.raw;
2396
- return ['true', '1', 'yes'].includes(String(this.raw).trim().toLowerCase());
2397
- },
2452
+ optRaw() { return this.toBool(this.raw, false); },
2453
+ usePicker() { return this.toBool(this.sheetPicker, true); },
2454
+ useStats() { return this.toBool(this.sheetStats, false); },
2455
+ useNotify() { return this.toBool(this.notify, true); },
2398
2456
  optRange() {
2399
2457
  const n = parseInt(this.skipRows, 10);
2400
2458
  return n > 0 ? n : undefined;
2401
2459
  },
2402
2460
  limitMB() {
2403
- const mb = parseFloat(this.maxSize);
2404
- return isNaN(mb) ? 20 : mb; // NaN -> default, KHÔNG bỏ giới hạn
2461
+ const n = parseFloat(this.maxSize);
2462
+ if (isNaN(n)) return 20;
2463
+ return n > 0 ? n : 0;
2405
2464
  }
2406
2465
  },
2407
2466
  methods: {
2467
+ toBool(v, def) {
2468
+ if (typeof v === 'boolean') return v;
2469
+ if (v === null || v === undefined || v === '') return def;
2470
+ const s = String(v).trim().toLowerCase();
2471
+ if (['true', '1', 'yes'].includes(s)) return true;
2472
+ if (['false', '0', 'no'].includes(s)) return false;
2473
+ return def;
2474
+ },
2475
+
2476
+ // ⭐ nhường 1 khung hình để trình duyệt kịp vẽ dialog trước khi chạy code đồng bộ nặng
2477
+ nextPaint() {
2478
+ return new Promise(resolve => {
2479
+ requestAnimationFrame(() => requestAnimationFrame(() => setTimeout(resolve, 0)));
2480
+ });
2481
+ },
2482
+
2483
+ showLoading(msg, sub) {
2484
+ this.loadingMsg = msg || this.loadingText;
2485
+ this.loadingSub = sub || '';
2486
+ this.loading = true;
2487
+ return this.$nextTick().then(() => this.nextPaint());
2488
+ },
2489
+
2490
+ hideLoading() {
2491
+ this.loading = false;
2492
+ this.loadingSub = '';
2493
+ },
2494
+
2408
2495
  onButtonClick() {
2409
- if (this.parsing) return; // (2) chống click chồng
2410
- this.busy = true;
2411
- window.addEventListener('focus', () => {
2412
- setTimeout(() => { if (!this.parsing) this.busy = false; }, 300);
2413
- }, { once: true });
2496
+ if (this.parsing) return;
2414
2497
  this.$refs.uploader.click();
2415
2498
  },
2416
2499
 
@@ -2418,38 +2501,141 @@ Vue.component('FExcelReader', {
2418
2501
  const file = evt.target.files && evt.target.files[0];
2419
2502
  evt.target.value = '';
2420
2503
 
2421
- if (this.parsing) return; // (2)
2422
- if (!file) { this.busy = false; return; }
2504
+ if (this.parsing) return;
2505
+ if (!file) return;
2423
2506
 
2424
- const id = ++this.reqId; // (1) vé của lượt này
2507
+ const id = ++this.reqId;
2425
2508
  this.parsing = true;
2426
- this.busy = true;
2427
2509
 
2428
2510
  let result;
2429
2511
  try {
2430
- if (this.limitMB > 0 && file.size > this.limitMB * 1024 * 1024)
2431
- throw new Error('File vượt quá ' + this.limitMB + 'MB');
2512
+ if (this.limitMB > 0 && file.size > this.limitMB * 1048576)
2513
+ throw new Error('File "' + file.name + '" nặng ' + this.fmtSize(file.size)
2514
+ + ', vượt giới hạn ' + this.limitMB + 'MB');
2432
2515
 
2516
+ await this.showLoading('Đang mở tập tin...', file.name);
2433
2517
  await this.ensureXLSX();
2434
- result = await this.parseFile(file);
2435
2518
 
2436
- if (id !== this.reqId) return; // (1) kết quả cũ -> vứt
2519
+ const buf = await this.readBuffer(file);
2520
+ if (id !== this.reqId) return;
2521
+ const u8 = new Uint8Array(buf);
2522
+
2523
+ await this.showLoading('Đang đọc cấu trúc tập tin...', file.name);
2524
+ const wsname = await this.resolveSheet(u8, file);
2525
+ if (id !== this.reqId) return;
2526
+
2527
+ if (!wsname) {
2528
+ this.$emit('cancel', { fileName: file.name, reason: 'sheet' });
2529
+ return;
2530
+ }
2531
+
2532
+ // ⭐ dialog loading cho MỌI trường hợp, kể cả file chỉ 1 sheet
2533
+ await this.showLoading(this.loadingText, 'Sheet: ' + wsname);
2534
+ result = this.parseSheet(u8, wsname, file);
2535
+ if (id !== this.reqId) return;
2437
2536
  } catch (err) {
2438
2537
  if (id === this.reqId) this.fail(err, file);
2439
2538
  else console.warn('[FExcelReader] bỏ qua lỗi của lượt cũ:', err);
2440
2539
  return;
2441
2540
  } finally {
2442
- if (id === this.reqId) { this.parsing = false; this.busy = false; }
2541
+ if (id === this.reqId) { this.parsing = false; this.hideLoading(); }
2443
2542
  }
2444
2543
 
2445
- // ===== Từ đây trở xuống: Excel ĐÃ đọc thành công, không thể thành error =====
2544
+ // ===== Excel ĐÃ đọc xong, từ đây không thể thành error =====
2446
2545
  this.$emit('update:modelValue', result.rows);
2447
2546
  this.$emit('load', result.meta);
2448
2547
 
2449
2548
  await this.$nextTick();
2450
- this.runAction(file); // (3) tách hẳn khỏi try
2549
+ this.runAction(file);
2550
+ },
2551
+
2552
+ // ---------- PHA 1: chỉ lấy tên sheet ----------
2553
+ listSheets(u8) {
2554
+ if (this.useStats) {
2555
+ const wb = XLSX.read(u8, { bookSheets: true, bookProps: true, dense: true });
2556
+ return { names: wb.SheetNames, wb };
2557
+ }
2558
+ const wb = XLSX.read(u8, { bookSheets: true });
2559
+ return { names: wb.SheetNames, wb: null };
2560
+ },
2561
+
2562
+ resolveSheet(u8, file) {
2563
+ const sp = this.sheet;
2564
+ const { names, wb } = this.listSheets(u8);
2565
+ if (!names || !names.length) throw new Error('File "' + file.name + '" không có sheet nào');
2566
+
2567
+ if (sp !== null && sp !== undefined && sp !== '') {
2568
+ const s = String(sp).trim();
2569
+ if (/^\d+$/.test(s)) {
2570
+ const n = names[parseInt(s, 10)];
2571
+ if (!n) throw new Error('Không có sheet ở vị trí ' + s
2572
+ + ' (file chỉ có ' + names.length + ' sheet)');
2573
+ return Promise.resolve(n);
2574
+ }
2575
+ if (names.indexOf(s) === -1)
2576
+ throw new Error('Không tìm thấy sheet "' + s + '". File có: ' + names.join(', '));
2577
+ return Promise.resolve(s);
2578
+ }
2579
+
2580
+ if (names.length === 1 || !this.usePicker) return Promise.resolve(names[0]);
2581
+
2582
+ this.sheetList = names.map(n => this.sheetInfo(wb, n));
2583
+ this.sheetChoice = (this.sheetList.find(s => !s.empty) || this.sheetList[0]).name;
2584
+ this.pendingName = file.name + ' — ' + names.length + ' sheet';
2585
+ this.hideLoading(); // ẩn loading trong lúc chờ người dùng
2586
+ this.dialog = true;
2587
+ return new Promise(resolve => { this._pickSheet = resolve; });
2451
2588
  },
2452
2589
 
2590
+ sheetInfo(wb, name) {
2591
+ if (!wb) return { name, info: '', empty: false };
2592
+ const ref = wb.Sheets[name] && wb.Sheets[name]['!ref'];
2593
+ if (!ref) return { name, info: 'sheet rỗng', empty: true };
2594
+ const r = XLSX.utils.decode_range(ref);
2595
+ return {
2596
+ name,
2597
+ info: (r.e.r - r.s.r + 1) + ' dòng × ' + (r.e.c - r.s.c + 1) + ' cột',
2598
+ empty: false
2599
+ };
2600
+ },
2601
+
2602
+ pickSheet(name) {
2603
+ this.dialog = false;
2604
+ const done = this._pickSheet;
2605
+ this._pickSheet = null;
2606
+ if (done) done(name || null);
2607
+ },
2608
+
2609
+ // ---------- PHA 2: parse đúng 1 sheet ----------
2610
+ parseSheet(u8, wsname, file) {
2611
+ const wb = XLSX.read(u8, { type: 'array', cellDates: true, sheets: wsname });
2612
+ const ws = wb.Sheets[wsname];
2613
+ if (!ws) throw new Error('Không đọc được sheet "' + wsname + '"');
2614
+
2615
+ const rows = XLSX.utils.sheet_to_json(ws, {
2616
+ header: this.optHeader,
2617
+ raw: this.optRaw,
2618
+ dateNF: this.dateFormat,
2619
+ defval: this.defval,
2620
+ range: this.optRange,
2621
+ blankrows: false
2622
+ });
2623
+
2624
+ return {
2625
+ rows,
2626
+ meta: {
2627
+ rows,
2628
+ rowCount: rows.length,
2629
+ headers: this.optHeader === 1 && rows.length ? rows[0] : Object.keys(rows[0] || {}),
2630
+ sheetName: wsname,
2631
+ sheetNames: wb.SheetNames,
2632
+ fileName: file.name,
2633
+ fileSize: file.size
2634
+ }
2635
+ };
2636
+ },
2637
+
2638
+ // ---------- phụ trợ ----------
2453
2639
  ensureXLSX() {
2454
2640
  return new Promise((resolve, reject) => {
2455
2641
  if (typeof XLSX !== 'undefined') return resolve();
@@ -2461,7 +2647,7 @@ Vue.component('FExcelReader', {
2461
2647
  const timer = setTimeout(() => {
2462
2648
  if (settled) return;
2463
2649
  settled = true;
2464
- reject(new Error('Quá thời gian nạp thư viện XLSX'));
2650
+ reject(new Error('Quá thời gian nạp thư viện đọc Excel'));
2465
2651
  }, ms);
2466
2652
 
2467
2653
  new Loader().require(['/include/excel/xlsx.full.min.js'], () => {
@@ -2470,7 +2656,7 @@ Vue.component('FExcelReader', {
2470
2656
  clearTimeout(timer);
2471
2657
  typeof XLSX !== 'undefined'
2472
2658
  ? resolve()
2473
- : reject(new Error('Nạp thư viện XLSX thất bại'));
2659
+ : reject(new Error('Nạp thư viện đọc Excel thất bại'));
2474
2660
  });
2475
2661
  });
2476
2662
  },
@@ -2479,71 +2665,52 @@ Vue.component('FExcelReader', {
2479
2665
  return new Promise((resolve, reject) => {
2480
2666
  const reader = new FileReader();
2481
2667
  reader.onload = e => resolve(e.target.result);
2482
- reader.onerror = () => reject(new Error('Không đọc được file: ' + file.name));
2668
+ reader.onerror = () => reject(new Error('Không đọc được file "' + file.name + '"'));
2483
2669
  reader.onabort = () => reject(new Error('Việc đọc file bị hủy'));
2484
2670
  reader.readAsArrayBuffer(file);
2485
2671
  });
2486
2672
  },
2487
2673
 
2488
- async parseFile(file) {
2489
- const buf = await this.readBuffer(file);
2490
- const wb = XLSX.read(new Uint8Array(buf), { type: 'array', cellDates: true });
2491
-
2492
- const isIndex = typeof this.sheet === 'number' || /^\d+$/.test(String(this.sheet));
2493
- const wsname = isIndex
2494
- ? wb.SheetNames[parseInt(this.sheet, 10)]
2495
- : (wb.SheetNames.includes(this.sheet) ? this.sheet : wb.SheetNames[0]);
2496
-
2497
- const ws = wb.Sheets[wsname];
2498
- if (!ws) throw new Error('Không tìm thấy sheet: ' + this.sheet);
2499
-
2500
- const rows = XLSX.utils.sheet_to_json(ws, {
2501
- header: this.optHeader,
2502
- raw: this.optRaw,
2503
- dateNF: this.dateFormat,
2504
- defval: this.defval,
2505
- range: this.optRange,
2506
- blankrows: false
2507
- });
2508
-
2509
- return {
2510
- rows,
2511
- meta: {
2512
- rows,
2513
- rowCount: rows.length,
2514
- headers: this.optHeader === 1 && rows.length ? rows[0] : Object.keys(rows[0] || {}),
2515
- sheetName: wsname,
2516
- sheetNames: wb.SheetNames,
2517
- fileName: file.name
2518
- }
2519
- };
2520
- },
2521
-
2522
2674
  runAction(file) {
2523
2675
  if (!this.action || typeof CALL !== 'function') return;
2524
2676
  const onErr = (err) => {
2677
+ const msg = (err && err.message) || String(err);
2525
2678
  console.error('[FExcelReader] action lỗi (dữ liệu Excel vẫn hợp lệ):', err);
2526
- this.$emit('action-error', {
2527
- message: (err && err.message) || String(err),
2528
- fileName: file.name
2529
- });
2679
+ this.toast('Đọc Excel thành công nhưng xử lý sau đó lỗi: ' + msg, 'warning');
2680
+ this.$emit('action-error', { message: msg, fileName: file.name });
2530
2681
  };
2531
2682
  try {
2532
2683
  const r = CALL(this.action);
2533
- if (r && typeof r.then === 'function') r.then(null, onErr); // action async
2684
+ if (r && typeof r.then === 'function') r.then(null, onErr);
2534
2685
  } catch (err) {
2535
2686
  onErr(err);
2536
2687
  }
2537
2688
  },
2538
2689
 
2690
+ toast(msg, color) {
2691
+ if (!this.useNotify) return;
2692
+ this.snackColor = color || 'error';
2693
+ this.snackMsg = msg;
2694
+ this.snack = true;
2695
+ },
2696
+
2697
+ fmtSize(bytes) {
2698
+ if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + 'MB';
2699
+ if (bytes >= 1024) return (bytes / 1024).toFixed(0) + 'KB';
2700
+ return bytes + 'B';
2701
+ },
2702
+
2539
2703
  fail(err, file) {
2704
+ const msg = (err && err.message) || String(err);
2540
2705
  console.error('[FExcelReader]', err);
2541
2706
  this.parsing = false;
2542
- this.busy = false;
2543
- this.$emit('error', {
2544
- message: (err && err.message) || String(err),
2545
- fileName: file ? file.name : null
2546
- });
2707
+ this.dialog = false;
2708
+ this.hideLoading();
2709
+ this.toast(msg, 'error');
2710
+ this.$emit('error', { message: msg, fileName: file ? file.name : null });
2547
2711
  }
2712
+ },
2713
+ beforeUnmount() {
2714
+ if (this._pickSheet) { this._pickSheet(null); this._pickSheet = null; }
2548
2715
  }
2549
2716
  });