@fui-org/fui-cli 0.1.1 → 0.3.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.
- package/README.md +18 -3
- package/dist/fui.js +160 -43
- package/package.json +4 -2
- package/skills/fui/SKILL.md +131 -66
- package/skills/fui-skill/README.md +112 -0
- package/skills/fui-skill/SKILL.md +269 -0
- package/skills/fui-skill/assets/projectdefaultstyle.css +518 -0
- package/skills/fui-skill/design-md/airbnb/DESIGN.md +545 -0
- package/skills/fui-skill/design-md/airbnb/README.md +5 -0
- package/skills/fui-skill/design-md/airtable/DESIGN.md +554 -0
- package/skills/fui-skill/design-md/airtable/README.md +5 -0
- package/skills/fui-skill/design-md/apple/DESIGN.md +562 -0
- package/skills/fui-skill/design-md/apple/README.md +5 -0
- package/skills/fui-skill/design-md/asu/DESIGN.md +179 -0
- package/skills/fui-skill/design-md/asu/README.md +107 -0
- package/skills/fui-skill/design-md/binance/DESIGN.md +634 -0
- package/skills/fui-skill/design-md/binance/README.md +5 -0
- package/skills/fui-skill/design-md/bmw/DESIGN.md +544 -0
- package/skills/fui-skill/design-md/bmw/README.md +5 -0
- package/skills/fui-skill/design-md/bmw-m/DESIGN.md +503 -0
- package/skills/fui-skill/design-md/bmw-m/README.md +5 -0
- package/skills/fui-skill/design-md/bugatti/DESIGN.md +454 -0
- package/skills/fui-skill/design-md/bugatti/README.md +5 -0
- package/skills/fui-skill/design-md/cal/DESIGN.md +542 -0
- package/skills/fui-skill/design-md/cal/README.md +5 -0
- package/skills/fui-skill/design-md/claude/DESIGN.md +589 -0
- package/skills/fui-skill/design-md/claude/README.md +5 -0
- package/skills/fui-skill/design-md/clay/DESIGN.md +541 -0
- package/skills/fui-skill/design-md/clay/README.md +5 -0
- package/skills/fui-skill/design-md/clickhouse/DESIGN.md +544 -0
- package/skills/fui-skill/design-md/clickhouse/README.md +5 -0
- package/skills/fui-skill/design-md/cohere/DESIGN.md +451 -0
- package/skills/fui-skill/design-md/cohere/README.md +5 -0
- package/skills/fui-skill/design-md/coinbase/DESIGN.md +570 -0
- package/skills/fui-skill/design-md/coinbase/README.md +5 -0
- package/skills/fui-skill/design-md/composio/DESIGN.md +506 -0
- package/skills/fui-skill/design-md/composio/README.md +5 -0
- package/skills/fui-skill/design-md/cursor/DESIGN.md +537 -0
- package/skills/fui-skill/design-md/cursor/README.md +5 -0
- package/skills/fui-skill/design-md/elevenlabs/DESIGN.md +504 -0
- package/skills/fui-skill/design-md/elevenlabs/README.md +5 -0
- package/skills/fui-skill/design-md/expo/DESIGN.md +526 -0
- package/skills/fui-skill/design-md/expo/README.md +5 -0
- package/skills/fui-skill/design-md/ferrari/DESIGN.md +531 -0
- package/skills/fui-skill/design-md/ferrari/README.md +5 -0
- package/skills/fui-skill/design-md/figma/DESIGN.md +578 -0
- package/skills/fui-skill/design-md/figma/README.md +5 -0
- package/skills/fui-skill/design-md/framer/DESIGN.md +544 -0
- package/skills/fui-skill/design-md/framer/README.md +5 -0
- package/skills/fui-skill/design-md/fui/DESIGN.md +532 -0
- package/skills/fui-skill/design-md/hashicorp/DESIGN.md +575 -0
- package/skills/fui-skill/design-md/hashicorp/README.md +5 -0
- package/skills/fui-skill/design-md/ibm/DESIGN.md +550 -0
- package/skills/fui-skill/design-md/ibm/README.md +5 -0
- package/skills/fui-skill/design-md/intercom/DESIGN.md +546 -0
- package/skills/fui-skill/design-md/intercom/README.md +5 -0
- package/skills/fui-skill/design-md/kraken/DESIGN.md +125 -0
- package/skills/fui-skill/design-md/kraken/README.md +5 -0
- package/skills/fui-skill/design-md/lamborghini/DESIGN.md +288 -0
- package/skills/fui-skill/design-md/lamborghini/README.md +5 -0
- package/skills/fui-skill/design-md/linear.app/DESIGN.md +548 -0
- package/skills/fui-skill/design-md/linear.app/README.md +5 -0
- package/skills/fui-skill/design-md/lovable/DESIGN.md +298 -0
- package/skills/fui-skill/design-md/lovable/README.md +5 -0
- package/skills/fui-skill/design-md/mastercard/DESIGN.md +365 -0
- package/skills/fui-skill/design-md/mastercard/README.md +5 -0
- package/skills/fui-skill/design-md/meta/DESIGN.md +683 -0
- package/skills/fui-skill/design-md/meta/README.md +5 -0
- package/skills/fui-skill/design-md/minimax/DESIGN.md +746 -0
- package/skills/fui-skill/design-md/minimax/README.md +5 -0
- package/skills/fui-skill/design-md/mintlify/DESIGN.md +852 -0
- package/skills/fui-skill/design-md/mintlify/README.md +5 -0
- package/skills/fui-skill/design-md/miro/DESIGN.md +825 -0
- package/skills/fui-skill/design-md/miro/README.md +5 -0
- package/skills/fui-skill/design-md/mistral.ai/DESIGN.md +773 -0
- package/skills/fui-skill/design-md/mistral.ai/README.md +5 -0
- package/skills/fui-skill/design-md/mongodb/DESIGN.md +767 -0
- package/skills/fui-skill/design-md/mongodb/README.md +5 -0
- package/skills/fui-skill/design-md/nike/DESIGN.md +575 -0
- package/skills/fui-skill/design-md/nike/README.md +5 -0
- package/skills/fui-skill/design-md/notion/DESIGN.md +821 -0
- package/skills/fui-skill/design-md/notion/README.md +5 -0
- package/skills/fui-skill/design-md/nvidia/DESIGN.md +640 -0
- package/skills/fui-skill/design-md/nvidia/README.md +5 -0
- package/skills/fui-skill/design-md/ollama/DESIGN.md +539 -0
- package/skills/fui-skill/design-md/ollama/README.md +5 -0
- package/skills/fui-skill/design-md/opencode.ai/DESIGN.md +521 -0
- package/skills/fui-skill/design-md/opencode.ai/README.md +5 -0
- package/skills/fui-skill/design-md/pinterest/DESIGN.md +597 -0
- package/skills/fui-skill/design-md/pinterest/README.md +5 -0
- package/skills/fui-skill/design-md/playstation/DESIGN.md +661 -0
- package/skills/fui-skill/design-md/playstation/README.md +5 -0
- package/skills/fui-skill/design-md/posthog/DESIGN.md +690 -0
- package/skills/fui-skill/design-md/posthog/README.md +5 -0
- package/skills/fui-skill/design-md/raycast/DESIGN.md +669 -0
- package/skills/fui-skill/design-md/raycast/README.md +5 -0
- package/skills/fui-skill/design-md/renault/DESIGN.md +589 -0
- package/skills/fui-skill/design-md/renault/README.md +5 -0
- package/skills/fui-skill/design-md/replicate/DESIGN.md +616 -0
- package/skills/fui-skill/design-md/replicate/README.md +5 -0
- package/skills/fui-skill/design-md/resend/DESIGN.md +585 -0
- package/skills/fui-skill/design-md/resend/README.md +5 -0
- package/skills/fui-skill/design-md/revolut/DESIGN.md +636 -0
- package/skills/fui-skill/design-md/revolut/README.md +5 -0
- package/skills/fui-skill/design-md/runwayml/DESIGN.md +244 -0
- package/skills/fui-skill/design-md/runwayml/README.md +5 -0
- package/skills/fui-skill/design-md/sanity/DESIGN.md +357 -0
- package/skills/fui-skill/design-md/sanity/README.md +5 -0
- package/skills/fui-skill/design-md/sentry/DESIGN.md +551 -0
- package/skills/fui-skill/design-md/sentry/README.md +5 -0
- package/skills/fui-skill/design-md/shopify/DESIGN.md +516 -0
- package/skills/fui-skill/design-md/shopify/README.md +5 -0
- package/skills/fui-skill/design-md/slack/DESIGN.md +482 -0
- package/skills/fui-skill/design-md/spacex/DESIGN.md +363 -0
- package/skills/fui-skill/design-md/spacex/README.md +5 -0
- package/skills/fui-skill/design-md/spotify/DESIGN.md +246 -0
- package/skills/fui-skill/design-md/spotify/README.md +5 -0
- package/skills/fui-skill/design-md/starbucks/DESIGN.md +580 -0
- package/skills/fui-skill/design-md/starbucks/README.md +5 -0
- package/skills/fui-skill/design-md/stripe/DESIGN.md +487 -0
- package/skills/fui-skill/design-md/stripe/README.md +5 -0
- package/skills/fui-skill/design-md/supabase/DESIGN.md +462 -0
- package/skills/fui-skill/design-md/supabase/README.md +5 -0
- package/skills/fui-skill/design-md/superhuman/DESIGN.md +448 -0
- package/skills/fui-skill/design-md/superhuman/README.md +5 -0
- package/skills/fui-skill/design-md/tesla/DESIGN.md +286 -0
- package/skills/fui-skill/design-md/tesla/README.md +5 -0
- package/skills/fui-skill/design-md/theverge/DESIGN.md +339 -0
- package/skills/fui-skill/design-md/theverge/README.md +5 -0
- package/skills/fui-skill/design-md/together.ai/DESIGN.md +633 -0
- package/skills/fui-skill/design-md/together.ai/README.md +5 -0
- package/skills/fui-skill/design-md/uber/DESIGN.md +636 -0
- package/skills/fui-skill/design-md/uber/README.md +5 -0
- package/skills/fui-skill/design-md/vercel/DESIGN.md +736 -0
- package/skills/fui-skill/design-md/vercel/README.md +5 -0
- package/skills/fui-skill/design-md/vodafone/DESIGN.md +538 -0
- package/skills/fui-skill/design-md/vodafone/README.md +5 -0
- package/skills/fui-skill/design-md/voltagent/DESIGN.md +521 -0
- package/skills/fui-skill/design-md/voltagent/README.md +5 -0
- package/skills/fui-skill/design-md/warp/DESIGN.md +526 -0
- package/skills/fui-skill/design-md/warp/README.md +5 -0
- package/skills/fui-skill/design-md/webflow/DESIGN.md +588 -0
- package/skills/fui-skill/design-md/webflow/README.md +5 -0
- package/skills/fui-skill/design-md/wired/DESIGN.md +497 -0
- package/skills/fui-skill/design-md/wired/README.md +5 -0
- package/skills/fui-skill/design-md/wise/DESIGN.md +544 -0
- package/skills/fui-skill/design-md/wise/README.md +5 -0
- package/skills/fui-skill/design-md/x.ai/DESIGN.md +465 -0
- package/skills/fui-skill/design-md/x.ai/README.md +5 -0
- package/skills/fui-skill/design-md/zapier/DESIGN.md +537 -0
- package/skills/fui-skill/design-md/zapier/README.md +5 -0
- package/skills/fui-skill/examples/component.vue +162 -0
- package/skills/fui-skill/examples/f-table-patterns.json +331 -0
- package/skills/fui-skill/examples/module-patterns.json +973 -0
- package/skills/fui-skill/examples/project-patterns.json +222 -0
- package/skills/fui-skill/metadata.json +75 -0
- package/skills/fui-skill/references/INDEX.md +144 -0
- package/skills/fui-skill/references/advanced-techniques.md +160 -0
- package/skills/fui-skill/references/coding-standards.md +112 -0
- package/skills/fui-skill/references/component-design.md +455 -0
- package/skills/fui-skill/references/component-quickref.md +77 -0
- package/skills/fui-skill/references/component-table.md +276 -0
- package/skills/fui-skill/references/components-dialog.md +192 -0
- package/skills/fui-skill/references/components-display.md +147 -0
- package/skills/fui-skill/references/components-echart.md +391 -0
- package/skills/fui-skill/references/components-input.md +359 -0
- package/skills/fui-skill/references/controls-patterns.md +847 -0
- package/skills/fui-skill/references/controls-styling-vocabulary.md +140 -0
- package/skills/fui-skill/references/db-table-design.md +77 -0
- package/skills/fui-skill/references/db-workflow.md +504 -0
- package/skills/fui-skill/references/default-function.md +415 -0
- package/skills/fui-skill/references/design-modes.md +85 -0
- package/skills/fui-skill/references/echart-templates.md +481 -0
- package/skills/fui-skill/references/fastproject.md +97 -0
- package/skills/fui-skill/references/fsheet.md +218 -0
- package/skills/fui-skill/references/fullstack-workflow.md +351 -0
- package/skills/fui-skill/references/module-data-patterns.md +126 -0
- package/skills/fui-skill/references/module-json-anatomy.md +137 -0
- package/skills/fui-skill/references/module-structure.md +260 -0
- package/skills/fui-skill/references/new-session.md +108 -0
- package/skills/fui-skill/references/pdfmake.md +60 -0
- package/skills/fui-skill/references/permission-system.md +169 -0
- package/skills/fui-skill/references/platform-architecture.md +294 -0
- package/skills/fui-skill/references/project-config.md +335 -0
- package/skills/fui-skill/references/project-provisioning.md +383 -0
- package/skills/fui-skill/references/script-map.md +296 -0
- package/skills/fui-skill/references/sql-clr-functions.md +224 -0
- package/skills/fui-skill/references/system-design.md +116 -0
- package/skills/fui-skill/references/tapi-file-api.md +191 -0
- package/skills/fui-skill/references/tapi-permission-patterns.md +158 -0
- package/skills/fui-skill/references/tapi-reference.md +549 -0
- package/skills/fui-skill/references/tools-registry.md +460 -0
- package/skills/fui-skill/references/ui-crosswindow-patterns.md +317 -0
- package/skills/fui-skill/references/ui-dialog-patterns.md +229 -0
- package/skills/fui-skill/references/ui-layout-patterns.md +176 -0
- package/skills/fui-skill/references/ui-patterns.md +315 -0
- package/skills/fui-skill/references/ui-screenshot-review.md +94 -0
- package/skills/fui-skill/references/ui-table-cell-patterns.md +316 -0
- package/skills/fui-skill/references/ui-templates.md +29 -0
- package/skills/fui-skill/references/verification.md +246 -0
- package/skills/fui-skill/references/watcher-patterns.md +196 -0
- package/skills/fui-skill/references/websocket-realtime.md +254 -0
- package/skills/fui-skill/scripts/component-3.0.js +2549 -0
- package/skills/fui-skill/scripts/component.js +3142 -0
- package/skills/fui-skill/scripts/componentTable-3.0.js +909 -0
- package/skills/fui-skill/scripts/componentTable.js +769 -0
- package/skills/fui-skill/scripts/defaultfunction-3.0.js +781 -0
- package/skills/fui-skill/scripts/defaultfunction.js +966 -0
- package/skills/fui-skill/scripts/fastproject-3.0.js +870 -0
- package/skills/fui-skill/scripts/fastproject.js +828 -0
- package/skills/fui-skill/scripts/fechart.js +890 -0
- package/skills/fui-skill/scripts/fsheet.js +1330 -0
|
@@ -0,0 +1,1330 @@
|
|
|
1
|
+
// Bộ cellRenderer dựng sẵn của FSheet (tương đương THtml/TLink/TButton/TMenu của
|
|
2
|
+
// componentTable-3.0 cũ) — đăng ký sẵn trong initGrid() nên bất kỳ colDef nào cũng
|
|
3
|
+
// dùng được ngay qua "cellRenderer": "fsXxxCellRenderer", không cần khai gì thêm
|
|
4
|
+
// trong script.js của module. Cấu hình qua cellRendererParams (object hoặc hàm,
|
|
5
|
+
// AG Grid tự merge vào params) — dùng lại openWindow/tableActionEvent, 2 hàm global
|
|
6
|
+
// gốc của FUI (giống hệt componentTable-3.0), không tự chế lại action engine.
|
|
7
|
+
window.fsHtmlCellRenderer = function (p) {
|
|
8
|
+
var div = document.createElement('div')
|
|
9
|
+
div.innerHTML = p.value != null ? String(p.value) : ''
|
|
10
|
+
return div
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
window.fsLinkCellRenderer = function (p) {
|
|
14
|
+
var url = p.urlField ? p.data[p.urlField] : p.value
|
|
15
|
+
var text = p.textField ? p.data[p.textField] : p.value
|
|
16
|
+
var a = document.createElement('a')
|
|
17
|
+
a.style.color = '#1976d2'
|
|
18
|
+
a.innerHTML = (p.icon ? '<i class="mdi ' + p.icon + '" style="margin-right:4px"></i>' : '') + (text != null ? text : '')
|
|
19
|
+
if (p.target === 'dialog') {
|
|
20
|
+
a.href = 'javascript:void(0)'
|
|
21
|
+
a.addEventListener('click', function (e) {
|
|
22
|
+
e.preventDefault()
|
|
23
|
+
openWindow({ id: p.wid, title: p.title, url: url, onclose: p.onclose })
|
|
24
|
+
})
|
|
25
|
+
} else {
|
|
26
|
+
a.href = url || 'javascript:void(0)'
|
|
27
|
+
a.target = p.target || '_blank'
|
|
28
|
+
}
|
|
29
|
+
return a
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
window.fsButtonCellRenderer = function (p) {
|
|
33
|
+
var color = p.color || '#1976d2'
|
|
34
|
+
var btn = document.createElement('button')
|
|
35
|
+
btn.innerHTML = (p.icon ? '<i class="mdi ' + p.icon + '"></i> ' : '') + (p.label || '')
|
|
36
|
+
btn.style.cssText = 'font-size:11px;padding:1px 8px;cursor:pointer;border-radius:3px;border:1px solid ' + color + ';color:' + color + ';background:white;line-height:1.6'
|
|
37
|
+
btn.addEventListener('click', function () {
|
|
38
|
+
if (p.url) openWindow({ id: p.wid, title: p.title, url: p.url, onclose: p.onclose })
|
|
39
|
+
else tableActionEvent({ action: p.action, item: p.data })
|
|
40
|
+
})
|
|
41
|
+
return btn
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
window.fsMenuCellRenderer = function (p) {
|
|
45
|
+
var items = typeof p.items === 'function' ? p.items(p) : (p.items || [])
|
|
46
|
+
var btn = document.createElement('button')
|
|
47
|
+
btn.innerHTML = '<i class="mdi ' + (p.icon || 'mdi-dots-vertical') + '"></i>'
|
|
48
|
+
btn.style.cssText = 'border:none;background:transparent;cursor:pointer;font-size:18px;line-height:1;padding:2px 6px'
|
|
49
|
+
btn.addEventListener('click', function (e) {
|
|
50
|
+
e.stopPropagation()
|
|
51
|
+
openFsMenu(btn, items, p.data)
|
|
52
|
+
})
|
|
53
|
+
return btn
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function openFsMenu(anchor, items, rowData) {
|
|
57
|
+
closeFsMenu()
|
|
58
|
+
var rect = anchor.getBoundingClientRect()
|
|
59
|
+
var panel = document.createElement('div')
|
|
60
|
+
panel.style.cssText = 'position:fixed;z-index:9999;background:#fff;border:1px solid #ddd;border-radius:4px;box-shadow:0 2px 8px rgba(0,0,0,.15);min-width:160px;top:' + rect.bottom + 'px;left:' + rect.left + 'px'
|
|
61
|
+
items.forEach(function (item) {
|
|
62
|
+
var row = document.createElement('div')
|
|
63
|
+
row.style.cssText = 'padding:8px 12px;cursor:pointer;font-size:13px;display:flex;align-items:center;gap:8px;color:' + (item.color || '#333')
|
|
64
|
+
row.innerHTML = (item.icon ? '<i class="mdi ' + item.icon + '"></i>' : '') + '<span>' + item.text + '</span>'
|
|
65
|
+
row.addEventListener('mouseenter', function () { row.style.background = '#f5f5f5' })
|
|
66
|
+
row.addEventListener('mouseleave', function () { row.style.background = '' })
|
|
67
|
+
row.addEventListener('click', function () {
|
|
68
|
+
closeFsMenu()
|
|
69
|
+
tableActionEvent({ action: item.action, item: rowData })
|
|
70
|
+
})
|
|
71
|
+
panel.appendChild(row)
|
|
72
|
+
})
|
|
73
|
+
document.body.appendChild(panel)
|
|
74
|
+
window.__fsOpenMenuPanel = panel
|
|
75
|
+
setTimeout(function () { document.addEventListener('click', closeFsMenu, { once: true }) }, 0)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function closeFsMenu() {
|
|
79
|
+
if (window.__fsOpenMenuPanel) { window.__fsOpenMenuPanel.remove(); window.__fsOpenMenuPanel = null }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Menu chuột phải kiểu Excel (icon + nhãn + phím tắt căn phải). Dùng chung panel/close
|
|
83
|
+
// với openFsMenu (window.__fsOpenMenuPanel) vì chỉ một menu mở tại một thời điểm.
|
|
84
|
+
function openFsContextMenu(x, y, items) {
|
|
85
|
+
closeFsMenu()
|
|
86
|
+
var panel = document.createElement('div')
|
|
87
|
+
panel.style.cssText = 'position:fixed;z-index:9999;background:#fff;border:1px solid #ddd;border-radius:4px;box-shadow:0 2px 8px rgba(0,0,0,.15);min-width:220px;padding:4px 0;top:' + y + 'px;left:' + x + 'px'
|
|
88
|
+
items.forEach(function (item) {
|
|
89
|
+
var row = document.createElement('div')
|
|
90
|
+
row.style.cssText = 'padding:8px 12px;cursor:pointer;font-size:13px;display:flex;align-items:center;gap:10px;color:#333'
|
|
91
|
+
row.innerHTML = '<i class="mdi ' + item.icon + '" style="width:16px;color:#666"></i>' +
|
|
92
|
+
'<span style="flex:1">' + item.text + '</span>' +
|
|
93
|
+
(item.shortcut ? '<span style="color:#999;font-size:12px">' + item.shortcut + '</span>' : '')
|
|
94
|
+
row.addEventListener('mouseenter', function () { row.style.background = '#f5f5f5' })
|
|
95
|
+
row.addEventListener('mouseleave', function () { row.style.background = '' })
|
|
96
|
+
row.addEventListener('click', function () { closeFsMenu(); item.action() })
|
|
97
|
+
panel.appendChild(row)
|
|
98
|
+
})
|
|
99
|
+
document.body.appendChild(panel)
|
|
100
|
+
window.__fsOpenMenuPanel = panel
|
|
101
|
+
setTimeout(function () { document.addEventListener('click', closeFsMenu, { once: true }) }, 0)
|
|
102
|
+
// Giữ trong viewport khi bấm chuột phải gần mép phải/dưới màn hình.
|
|
103
|
+
var rect = panel.getBoundingClientRect()
|
|
104
|
+
if (rect.right > window.innerWidth) panel.style.left = Math.max(0, window.innerWidth - rect.width - 4) + 'px'
|
|
105
|
+
if (rect.bottom > window.innerHeight) panel.style.top = Math.max(0, window.innerHeight - rect.height - 4) + 'px'
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Copy qua menu (không phải Ctrl+C) không có DOM ClipboardEvent thật để lấy e.clipboardData
|
|
109
|
+
// → phải tự ghi vào clipboard hệ thống. navigator.clipboard cần secure context; nơi nào
|
|
110
|
+
// không có (http nội bộ, trình duyệt cũ) thì rơi về execCommand('copy') qua textarea ẩn.
|
|
111
|
+
function fsFallbackCopy(text) {
|
|
112
|
+
var ta = document.createElement('textarea')
|
|
113
|
+
ta.value = text
|
|
114
|
+
ta.style.cssText = 'position:fixed;top:-9999px;left:-9999px'
|
|
115
|
+
document.body.appendChild(ta)
|
|
116
|
+
ta.select()
|
|
117
|
+
try { document.execCommand('copy') } catch (err) {}
|
|
118
|
+
document.body.removeChild(ta)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Class của chính AG Grid dùng để tô vùng chọn.
|
|
122
|
+
var FS_RANGE_CLASSES = [
|
|
123
|
+
'ag-cell-range-selected', 'ag-cell-range-single-cell',
|
|
124
|
+
'ag-cell-range-top', 'ag-cell-range-right', 'ag-cell-range-bottom', 'ag-cell-range-left',
|
|
125
|
+
'ag-selection-fill-top', 'ag-selection-fill-right', 'ag-selection-fill-bottom', 'ag-selection-fill-left'
|
|
126
|
+
]
|
|
127
|
+
|
|
128
|
+
// CSS vùng chọn — vì sao f-sheet phải tự bơm:
|
|
129
|
+
// Bundle UMD của AG Grid có sẵn stylesheet legacy (community-modules/styles/ag-grid.css,
|
|
130
|
+
// gắn <style data-ag-scope="legacy"> vào head lúc load) và trong đó CÓ đủ CSS vùng chọn.
|
|
131
|
+
// NHƯNG ngay khi truyền theme:{...} (Theming API v33+), AG Grid gọi uninstallLegacyCSS()
|
|
132
|
+
// xoá sạch mấy <style data-ag-scope="legacy"> đó rồi bơm lại core.css + CSS của các module
|
|
133
|
+
// ĐANG đăng ký. Phần .ag-cell-range-*/.ag-selection-fill-*/.ag-fill-handle nằm trong CSS
|
|
134
|
+
// của CellSelectionModule (Enterprise) nên bản Community không bao giờ được bơm — gắn class
|
|
135
|
+
// vẫn thành công mà nhìn không thấy gì, đúng triệu chứng "không select được nhiều ô".
|
|
136
|
+
// Core.css chỉ giữ lại đúng .ag-cell-range-single-cell (viền ô đang focus) và
|
|
137
|
+
// .ag-cell{border:1px solid transparent} — nhờ vậy ở đây chỉ cần khai màu/kiểu viền.
|
|
138
|
+
// Viết theo biến CSS của theme nên màu vẫn đổi được bằng
|
|
139
|
+
// withParams({ rangeSelectionBackgroundColor, rangeSelectionBorderColor, rangeSelectionBorderStyle }).
|
|
140
|
+
// Mỗi var() đều kèm giá trị dự phòng: var() không giải được thì thuộc tính thành "invalid at
|
|
141
|
+
// computed-value time" → trong suốt, tức hỏng mà không báo gì. Có fallback thì xấu nhất vẫn nhìn thấy.
|
|
142
|
+
var FS_RANGE_BG = 'var(--ag-range-selection-background-color, rgba(33,150,243,.18))'
|
|
143
|
+
var FS_RANGE_BC = 'var(--ag-range-selection-border-color, #2196f3)'
|
|
144
|
+
var FS_RANGE_BS = 'var(--ag-range-selection-border-style, solid)'
|
|
145
|
+
function fsInstallRangeCss () {
|
|
146
|
+
var st = document.getElementById('fsheet-range-css')
|
|
147
|
+
if (!st) {
|
|
148
|
+
st = document.createElement('style')
|
|
149
|
+
st.id = 'fsheet-range-css'
|
|
150
|
+
st.textContent = [
|
|
151
|
+
// Nền vùng chọn. Chừa ô đang focus (giống Excel: ô neo vẫn nền trắng).
|
|
152
|
+
'.ag-cell-range-selected:not(.ag-cell-focus){background-color:' + FS_RANGE_BG + '}',
|
|
153
|
+
// Viền bao quanh khối. Ô đơn lẻ đã có viền sẵn từ core.css nên loại ra.
|
|
154
|
+
'.ag-cell.ag-cell-range-selected:not(.ag-cell-range-single-cell).ag-cell-range-top{border-top-color:' + FS_RANGE_BC + ';border-top-style:' + FS_RANGE_BS + '}',
|
|
155
|
+
'.ag-cell.ag-cell-range-selected:not(.ag-cell-range-single-cell).ag-cell-range-right{border-right-color:' + FS_RANGE_BC + ';border-right-style:' + FS_RANGE_BS + '}',
|
|
156
|
+
'.ag-cell.ag-cell-range-selected:not(.ag-cell-range-single-cell).ag-cell-range-bottom{border-bottom-color:' + FS_RANGE_BC + ';border-bottom-style:' + FS_RANGE_BS + '}',
|
|
157
|
+
'.ag-cell.ag-cell-range-selected:not(.ag-cell-range-single-cell).ag-cell-range-left{border-left-color:' + FS_RANGE_BC + ';border-left-style:' + FS_RANGE_BS + '}',
|
|
158
|
+
// Viền đứt xem trước vùng sắp fill. Trái/phải phải !important vì .ag-cell đã bị
|
|
159
|
+
// columnBorder của theme set border-right/left.
|
|
160
|
+
'.ag-cell.ag-selection-fill-top{border-top:1px dashed ' + FS_RANGE_BC + '}',
|
|
161
|
+
'.ag-cell.ag-selection-fill-bottom{border-bottom:1px dashed ' + FS_RANGE_BC + '}',
|
|
162
|
+
'.ag-cell.ag-selection-fill-right{border-right:1px dashed ' + FS_RANGE_BC + '!important}',
|
|
163
|
+
'.ag-cell.ag-selection-fill-left{border-left:1px dashed ' + FS_RANGE_BC + '!important}',
|
|
164
|
+
// Nút vuông góc dưới-phải để kéo fill. .ag-cell là position:absolute (core.css) nên neo đúng.
|
|
165
|
+
// Viền trắng bao quanh để nổi lên trên nền vùng chọn, z-index để ô kế bên không phủ mất.
|
|
166
|
+
'.ag-fill-handle{position:absolute;bottom:-2px;right:-2px;width:8px;height:8px;z-index:1;cursor:crosshair;box-sizing:border-box;border:1px solid #fff;background-color:' + FS_RANGE_BC + '}',
|
|
167
|
+
// Nháy báo đã copy: phủ một lớp xanh đậm (::after) NẰM TRÊN nền vùng chọn rồi mờ dần
|
|
168
|
+
// opacity — không animate thẳng background-color của .ag-cell, vì màu đó đang bị luật
|
|
169
|
+
// nền vùng chọn (.ag-cell-range-selected) tranh cùng thuộc tính, animation kết thúc là
|
|
170
|
+
// "bật" ngược lại màu nền bên dưới, giật cục thay vì mờ dần mượt. ::after kế thừa
|
|
171
|
+
// position:absolute từ .ag-cell (chính nó là containing block của pseudo-element) nên
|
|
172
|
+
// inset:0 phủ khít, forwards giữ opacity 0 tới khi JS gỡ class, tránh chớp lại giữa lúc
|
|
173
|
+
// animation xong và setTimeout gỡ class (flashCopy, cùng 800ms).
|
|
174
|
+
'@keyframes fsCopyFlash{from{opacity:.6}to{opacity:0}}',
|
|
175
|
+
'.ag-cell.ag-cell-copy-flash::after{content:"";position:absolute;inset:0;pointer-events:none;background-color:' + FS_RANGE_BC + ';animation:fsCopyFlash .8s ease-out forwards}'
|
|
176
|
+
].join('\n')
|
|
177
|
+
}
|
|
178
|
+
// appendChild trên node đã nằm trong head = DỜI xuống cuối, không nhân bản. Cần vậy vì
|
|
179
|
+
// AG Grid bơm core.css lúc createGrid() (sau khi file này nạp), mà core.css có luật
|
|
180
|
+
// .ag-cell.ag-cell-last-left-pinned:not(.ag-cell-range-right,…) cùng specificity (0,3,0)
|
|
181
|
+
// với luật viền ở trên — ai đứng sau thì thắng, nên f-sheet phải đứng sau.
|
|
182
|
+
document.head.appendChild(st)
|
|
183
|
+
}
|
|
184
|
+
fsInstallRangeCss()
|
|
185
|
+
|
|
186
|
+
// modulo luôn ra số dương (JS trả số âm với toán hạng âm) — dùng để lặp vòng khối nguồn khi fill.
|
|
187
|
+
function fsMod(a, n) { return ((a % n) + n) % n }
|
|
188
|
+
|
|
189
|
+
// Tách text clipboard (TSV của Excel/Sheets) thành mảng 2 chiều.
|
|
190
|
+
function fsParseClipboard(text) {
|
|
191
|
+
var lines = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n')
|
|
192
|
+
while (lines.length && lines[lines.length - 1] === '') lines.pop()
|
|
193
|
+
return lines.map(function (l) { return l.split('\t') })
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ===== Kiểu ngày =====
|
|
197
|
+
// KHÔNG dùng moment/dayjs: file này dùng chung V2 + V3, mà V2 chỉ bundle moment còn V3 chỉ
|
|
198
|
+
// bundle dayjs — không thư viện nào chắc chắn có mặt ở cả hai runtime. Cả hai cũng không từ
|
|
199
|
+
// chối nhất quán ngày kiểu 31/02, trong khi f-sheet cần phân biệt rõ hợp lệ / không hợp lệ.
|
|
200
|
+
|
|
201
|
+
// Chuẩn hoá về 'yyyy-mm-dd' — dạng lưu DUY NHẤT của cột ngày. Không nhận diện được → null.
|
|
202
|
+
// Đây là hợp đồng giá trị chung của cả nền tảng: <input type="date"> của trình duyệt,
|
|
203
|
+
// f-date (component.js, emit 'yyyy-mm-dd') và OPENJSON ... date '$.X' của tAPI đều dùng nó.
|
|
204
|
+
function fsToISODate(val) {
|
|
205
|
+
if (val === '' || val === null || val === undefined) return null
|
|
206
|
+
var y, m, d
|
|
207
|
+
// toString thay vì `instanceof Date`: instanceof so identity của constructor nên hỏng khi
|
|
208
|
+
// Date đến từ realm khác (iframe, vm) — mà agDateColumnFilter thì đưa vào một Date.
|
|
209
|
+
if (Object.prototype.toString.call(val) === '[object Date]') {
|
|
210
|
+
if (isNaN(val.getTime())) return null
|
|
211
|
+
y = val.getFullYear(); m = val.getMonth() + 1; d = val.getDate()
|
|
212
|
+
} else {
|
|
213
|
+
var s = String(val).trim()
|
|
214
|
+
// yyyy-mm-dd — không neo đuôi, để bắt luôn ISO datetime '2026-08-15T00:00:00' của tAPI
|
|
215
|
+
var iso = s.match(/^(\d{4})-(\d{1,2})-(\d{1,2})/)
|
|
216
|
+
var loc = iso ? null : s.match(/^(\d{1,2})[\/-](\d{1,2})[\/-](\d{4})$/) // dd/mm/yyyy · dd-mm-yyyy
|
|
217
|
+
if (iso) { y = +iso[1]; m = +iso[2]; d = +iso[3] }
|
|
218
|
+
else if (loc) { d = +loc[1]; m = +loc[2]; y = +loc[3] }
|
|
219
|
+
else return null
|
|
220
|
+
}
|
|
221
|
+
// Round-trip bắt buộc: new Date(2026, 12, 15) tự cuộn sang 2027-01-15, tức ngày SAI biến
|
|
222
|
+
// thành một ngày HỢP LỆ KHÁC. Bỏ bước so lại thì 15/13/2026 và 31/02/2026 lọt qua im lặng
|
|
223
|
+
// — đẻ ra hỏng-âm-thầm mới, tệ hơn cái đang đi sửa.
|
|
224
|
+
var dt = new Date(y, m - 1, d)
|
|
225
|
+
if (dt.getFullYear() !== y || dt.getMonth() !== m - 1 || dt.getDate() !== d) return null
|
|
226
|
+
return y + '-' + (m < 10 ? '0' : '') + m + '-' + (d < 10 ? '0' : '') + d
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Hiển thị 'dd/mm/yyyy'. Không parse được → null để caller tự quyết (buildColDefs in chuỗi
|
|
230
|
+
// thô ra, cho dữ liệu rác HIỆN LÊN chứ không biến mất).
|
|
231
|
+
function fsFormatDate(val) {
|
|
232
|
+
var iso = fsToISODate(val)
|
|
233
|
+
if (!iso) return null
|
|
234
|
+
return iso.slice(8, 10) + '/' + iso.slice(5, 7) + '/' + iso.slice(0, 4)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// agDateColumnFilter đưa vào một Date lúc nửa đêm; giá trị ô là chuỗi thô nên phải tự so.
|
|
238
|
+
function fsDateFilterComparator(filterLocalDateAtMidnight, cellValue) {
|
|
239
|
+
var iso = fsToISODate(cellValue)
|
|
240
|
+
if (!iso) return -1
|
|
241
|
+
var f = fsToISODate(filterLocalDateAtMidnight)
|
|
242
|
+
if (!f) return 0
|
|
243
|
+
return iso < f ? -1 : (iso > f ? 1 : 0)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Bảng columnType f-sheet đăng ký — NGUỒN DUY NHẤT. gridOptions.columnTypes, parseByType,
|
|
247
|
+
// dán từ Excel, onCellValueChanged và cảnh báo type lạ đều đọc từ đây; thêm type thứ ba =
|
|
248
|
+
// thêm MỘT mục ở đây + một nhánh trong parseByType, không đi sửa 5 chỗ rẽ nhánh rải rác.
|
|
249
|
+
var FS_COLUMN_TYPES = {
|
|
250
|
+
number: { filter: 'agNumberColumnFilter' },
|
|
251
|
+
date: { filter: 'agDateColumnFilter', filterParams: { comparator: fsDateFilterComparator } }
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Trả về tên type nếu f-sheet có đăng ký, ngược lại null. Dùng thay cho việc so
|
|
255
|
+
// `=== 'number'` rải rác — chỗ nào cũng hỏi cùng một bảng.
|
|
256
|
+
function fsKnownType(type) {
|
|
257
|
+
if (typeof type !== 'string') return null
|
|
258
|
+
return Object.prototype.hasOwnProperty.call(FS_COLUMN_TYPES, type) ? type : null
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// `type` lạ thì AG Grid chỉ log warning #36 qua console.warn — mà renderUI chỉ chuyển tiếp
|
|
262
|
+
// console.error và [Vue warn], nên cảnh báo đó không bao giờ tới được người đang sửa module
|
|
263
|
+
// (0/275 bản ghi trong _logs/tool-errors.jsonl nhắc AG Grid). f-sheet tự nói bằng
|
|
264
|
+
// console.error để lỗi nổi lên trong module_simulate({renderUI:true}) và trong log.
|
|
265
|
+
// Phải quét đệ quy vào children: colDef thật của column group nằm trong đó.
|
|
266
|
+
function fsWarnUnknownColumnTypes(defs) {
|
|
267
|
+
var valid = Object.keys(FS_COLUMN_TYPES)
|
|
268
|
+
;(defs || []).forEach(function (def) {
|
|
269
|
+
if (!def) return
|
|
270
|
+
if (def.children) { fsWarnUnknownColumnTypes(def.children); return }
|
|
271
|
+
if (def.type === null || def.type === undefined || def.type === '') return
|
|
272
|
+
// AG Grid cho phép colDef.type là mảng tên
|
|
273
|
+
;[].concat(def.type).forEach(function (t) {
|
|
274
|
+
if (valid.indexOf(t) >= 0) return
|
|
275
|
+
console.error(
|
|
276
|
+
'[f-sheet] colDef.type "' + t + '" chưa đăng ký — cột "' +
|
|
277
|
+
(def.headerName || def.field || '?') +
|
|
278
|
+
'" sẽ KHÔNG có valueFormatter/valueParser/filter nào cả (chạy y hệt cột text thô). ' +
|
|
279
|
+
'f-sheet chỉ nhận: ' + valid.join(', ') + '. Xem references/fsheet.md mục "columnType".'
|
|
280
|
+
)
|
|
281
|
+
})
|
|
282
|
+
})
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
var fsheetTheme = window.agGrid.themeQuartz
|
|
286
|
+
.withPart(window.agGrid.iconSetQuartzLight)
|
|
287
|
+
.withParams({
|
|
288
|
+
borderRadius: 3,
|
|
289
|
+
browserColorScheme: 'light',
|
|
290
|
+
columnBorder: true,
|
|
291
|
+
fontFamily: 'inherit',
|
|
292
|
+
iconSize: 16,
|
|
293
|
+
wrapperBorder: true,
|
|
294
|
+
wrapperBorderRadius: 4
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
Vue.component('FSheet', {
|
|
298
|
+
template: `<div class="f-sheet-root"> <div class="d-flex align-center mb-2"> <span v-if="label" class="text-subtitle-1 font-weight-bold text-primary">{{ label }}</span> <v-spacer></v-spacer> <span v-if="changedCount > 0" class="text-caption text-orange mr-3">{{ changedCount }} dòng thay đổi</span> <v-btn icon variant="text" density="comfortable" color="primary" class="mr-2" title="Xuất Excel" @click="exportExcel"> <v-icon>mdi-microsoft-excel</v-icon> </v-btn> <v-btn v-if="allowAdd" icon variant="text" density="comfortable" color="primary" class="mr-2" title="Thêm dòng" @click="addRow"> <v-icon>mdi-plus</v-icon> </v-btn> <v-btn v-if="changedCount > 0" icon variant="text" density="comfortable" color="success" :loading="saving" :disabled="!updateApi" class="mr-2" title="Lưu thay đổi" @click="saveAll"> <v-icon>mdi-content-save</v-icon> </v-btn> <v-btn v-if="changedCount > 0" icon variant="text" density="comfortable" class="mr-2" title="Huỷ thay đổi" @click="discardAll"> <v-icon>mdi-undo-variant</v-icon> </v-btn> <v-btn v-if="allowDelete && selectedRows.length > 0" icon variant="text" density="comfortable" color="error" title="Xoá dòng đã chọn" @click="deleteSelected"> <v-icon>mdi-delete</v-icon> </v-btn> </div> <div ref="gridEl" :style="gridStyle"></div> </div>`, props: {
|
|
299
|
+
label: {}, columns: {}, items: {}, rowKey: {},
|
|
300
|
+
height: {},
|
|
301
|
+
allowAdd: {}, allowDelete: {}, readonly: {},
|
|
302
|
+
updateApi: {}, saveExtra: {}, defaultItem: {}, deleteApi: {}
|
|
303
|
+
},
|
|
304
|
+
data: function () {
|
|
305
|
+
return {
|
|
306
|
+
gridApi: null,
|
|
307
|
+
changedRows: {},
|
|
308
|
+
selectedRows: [],
|
|
309
|
+
saving: false,
|
|
310
|
+
localCounter: 0,
|
|
311
|
+
fillHeight: 0
|
|
312
|
+
}
|
|
313
|
+
},
|
|
314
|
+
computed: {
|
|
315
|
+
changedCount: function () {
|
|
316
|
+
return Object.keys(this.changedRows).length
|
|
317
|
+
},
|
|
318
|
+
// null = không khai height → lưới tự giãn theo số dòng (domLayout 'autoHeight')
|
|
319
|
+
heightNum: function () {
|
|
320
|
+
if (this.height === undefined || this.height === null || this.height === '') return null
|
|
321
|
+
var n = Number(this.height)
|
|
322
|
+
return isNaN(n) ? null : n
|
|
323
|
+
},
|
|
324
|
+
gridStyle: function () {
|
|
325
|
+
if (this.heightNum === null) return { width: '100%' }
|
|
326
|
+
var h = (this.heightNum > 0) ? this.heightNum : this.fillHeight
|
|
327
|
+
if (!h) return { width: '100%' }
|
|
328
|
+
return { height: h + 'px', width: '100%' }
|
|
329
|
+
}
|
|
330
|
+
},
|
|
331
|
+
watch: {
|
|
332
|
+
items: function (val) {
|
|
333
|
+
if (this.gridApi) {
|
|
334
|
+
this.buildOriginalData(val)
|
|
335
|
+
if (!this.columns || !this.columns.length) {
|
|
336
|
+
this.gridApi.setGridOption('columnDefs', this.buildColDefs(null))
|
|
337
|
+
}
|
|
338
|
+
this.gridApi.setGridOption('rowData', val || [])
|
|
339
|
+
this.changedRows = {}
|
|
340
|
+
this.selectedRows = []
|
|
341
|
+
this.localCounter = 0
|
|
342
|
+
// dữ liệu mới → vùng chọn cũ trỏ vào dòng không còn tồn tại, bỏ đi
|
|
343
|
+
this.clearRange()
|
|
344
|
+
}
|
|
345
|
+
},
|
|
346
|
+
columns: function (val) {
|
|
347
|
+
if (this.gridApi) {
|
|
348
|
+
this.gridApi.setGridOption('columnDefs', this.buildColDefs(val))
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
},
|
|
352
|
+
mounted: function () {
|
|
353
|
+
this.initGrid()
|
|
354
|
+
if (this.heightNum !== null && this.heightNum <= 0) {
|
|
355
|
+
this.updateFillHeight()
|
|
356
|
+
this.resizeHandler = this.updateFillHeight.bind(this)
|
|
357
|
+
window.addEventListener('resize', this.resizeHandler)
|
|
358
|
+
}
|
|
359
|
+
},
|
|
360
|
+
beforeDestroy: function () { this.destroyGrid() },
|
|
361
|
+
beforeUnmount: function () { this.destroyGrid() },
|
|
362
|
+
methods: {
|
|
363
|
+
destroyGrid: function () {
|
|
364
|
+
if (this.resizeHandler) {
|
|
365
|
+
window.removeEventListener('resize', this.resizeHandler)
|
|
366
|
+
this.resizeHandler = null
|
|
367
|
+
}
|
|
368
|
+
if (this.mouseUpHandler) {
|
|
369
|
+
document.removeEventListener('mouseup', this.mouseUpHandler)
|
|
370
|
+
this.mouseUpHandler = null
|
|
371
|
+
}
|
|
372
|
+
if (this.mouseDownHandler) {
|
|
373
|
+
document.removeEventListener('pointerdown', this.mouseDownHandler, true)
|
|
374
|
+
document.removeEventListener('mousedown', this.mouseDownHandler, true)
|
|
375
|
+
this.mouseDownHandler = null
|
|
376
|
+
}
|
|
377
|
+
if (this.keyStateHandler) {
|
|
378
|
+
document.removeEventListener('keydown', this.keyStateHandler, true)
|
|
379
|
+
document.removeEventListener('keyup', this.keyStateHandler, true)
|
|
380
|
+
this.keyStateHandler = null
|
|
381
|
+
}
|
|
382
|
+
if (this.rangeObserver) {
|
|
383
|
+
this.rangeObserver.disconnect()
|
|
384
|
+
this.rangeObserver = null
|
|
385
|
+
}
|
|
386
|
+
if (this.$refs.gridEl) {
|
|
387
|
+
if (this.copyHandler) this.$refs.gridEl.removeEventListener('copy', this.copyHandler)
|
|
388
|
+
if (this.pasteHandler) this.$refs.gridEl.removeEventListener('paste', this.pasteHandler)
|
|
389
|
+
if (this.keyDownHandler) this.$refs.gridEl.removeEventListener('keydown', this.keyDownHandler)
|
|
390
|
+
if (this.contextMenuHandler) this.$refs.gridEl.removeEventListener('contextmenu', this.contextMenuHandler)
|
|
391
|
+
}
|
|
392
|
+
this.copyHandler = null
|
|
393
|
+
this.pasteHandler = null
|
|
394
|
+
this.keyDownHandler = null
|
|
395
|
+
this.contextMenuHandler = null
|
|
396
|
+
if (this.copyFlashTimer) { clearTimeout(this.copyFlashTimer); this.copyFlashTimer = null }
|
|
397
|
+
if (this.gridApi) {
|
|
398
|
+
this.gridApi.destroy()
|
|
399
|
+
this.gridApi = null
|
|
400
|
+
}
|
|
401
|
+
},
|
|
402
|
+
updateFillHeight: function () {
|
|
403
|
+
var self = this
|
|
404
|
+
self.$nextTick(function () {
|
|
405
|
+
requestAnimationFrame(function () {
|
|
406
|
+
var el = self.$refs.gridEl
|
|
407
|
+
if (!el) return
|
|
408
|
+
var top = el.getBoundingClientRect().top
|
|
409
|
+
var bottomMargin = Math.abs(self.heightNum)
|
|
410
|
+
self.fillHeight = Math.max(200, window.innerHeight - top - bottomMargin)
|
|
411
|
+
})
|
|
412
|
+
})
|
|
413
|
+
},
|
|
414
|
+
buildOriginalData: function (rows) {
|
|
415
|
+
var keyField = this.rowKey || 'id'
|
|
416
|
+
this.originalData = {}
|
|
417
|
+
this.originalRows = (rows || []).map(function (row) {
|
|
418
|
+
var copy = Object.assign({}, row)
|
|
419
|
+
var key = row[keyField]
|
|
420
|
+
if (key !== undefined && key !== null) {
|
|
421
|
+
this.originalData[String(key)] = copy
|
|
422
|
+
}
|
|
423
|
+
return copy
|
|
424
|
+
}, this)
|
|
425
|
+
},
|
|
426
|
+
autoColDefs: function () {
|
|
427
|
+
var sample = this.items && this.items[0]
|
|
428
|
+
if (!sample) return []
|
|
429
|
+
return Object.keys(sample).map(function (key) {
|
|
430
|
+
return { field: key, headerName: key }
|
|
431
|
+
})
|
|
432
|
+
},
|
|
433
|
+
changeCellStyle: function (params) {
|
|
434
|
+
var self = this
|
|
435
|
+
var keyField = self.rowKey || 'id'
|
|
436
|
+
var field = params.colDef.field
|
|
437
|
+
if (!field) return null
|
|
438
|
+
var data = params.data
|
|
439
|
+
if (!data) return null
|
|
440
|
+
// Dòng mới nhận diện qua _fsNewKey (khoá tạm nội bộ) — KHÔNG còn dựa vào giá trị ô
|
|
441
|
+
// khoá thật là số âm nữa, vì ô khoá thật giờ luôn để trống cho người dùng tự gõ.
|
|
442
|
+
if (data._fsNewKey !== undefined) {
|
|
443
|
+
return { backgroundColor: '#e8f5e9' }
|
|
444
|
+
}
|
|
445
|
+
var key = data[keyField]
|
|
446
|
+
if (key === undefined || key === null) return null
|
|
447
|
+
var orig = self.originalData && self.originalData[String(key)]
|
|
448
|
+
if (orig && orig.hasOwnProperty(field) && params.value !== orig[field]) {
|
|
449
|
+
return { backgroundColor: '#fff8e1' }
|
|
450
|
+
}
|
|
451
|
+
return null
|
|
452
|
+
},
|
|
453
|
+
// Ép giá trị về đúng kiểu của cột. Mọi type ở đây phải có mặt trong FS_COLUMN_TYPES.
|
|
454
|
+
parseByType: function (type, val) {
|
|
455
|
+
if (type === 'number') {
|
|
456
|
+
if (val === '' || val === null || val === undefined) return null
|
|
457
|
+
if (typeof val === 'number') return val
|
|
458
|
+
var num = Number(val)
|
|
459
|
+
return isNaN(num) ? null : num
|
|
460
|
+
}
|
|
461
|
+
if (type === 'date') {
|
|
462
|
+
// Không nhận diện được → null, đối xứng với nhánh number ở trên (giữ chuỗi rác
|
|
463
|
+
// lại thì SP nhận vào một giá trị không phải ngày mà không ai biết).
|
|
464
|
+
return fsToISODate(val)
|
|
465
|
+
}
|
|
466
|
+
return val
|
|
467
|
+
},
|
|
468
|
+
|
|
469
|
+
// ===== Chọn vùng nhiều ô + fill handle + clipboard =====
|
|
470
|
+
// AG Grid gate Cell Selection vào bản Enterprise (cả JS lẫn CSS của module đó), nên
|
|
471
|
+
// f-sheet tự viết phần ĐIỀU KHIỂN rồi gắn đúng bộ class của AG Grid
|
|
472
|
+
// (.ag-cell-range-*, .ag-selection-fill-*, .ag-fill-handle); CSS cho mấy class này
|
|
473
|
+
// được bơm ở đầu file (xem khối #fsheet-range-css).
|
|
474
|
+
// Tô vùng chọn bằng cách sửa class trực tiếp trên DOM (paintRange), KHÔNG dùng
|
|
475
|
+
// refreshCells: refreshCells dựng lại cellRenderer của cả lưới, rê chuột một cái là
|
|
476
|
+
// giật và làm rớt trạng thái ô đang sửa.
|
|
477
|
+
// Giá trị ghi qua node.setDataValue nên onCellValueChanged vẫn chạy → phần
|
|
478
|
+
// changedRows/highlight/lưu bên dưới không cần biết gì về đoạn này.
|
|
479
|
+
colIndex: function (column) {
|
|
480
|
+
if (!this.gridApi || !column) return -1
|
|
481
|
+
return this.gridApi.getAllDisplayedColumns().indexOf(column)
|
|
482
|
+
},
|
|
483
|
+
rangeBounds: function () {
|
|
484
|
+
var a = this.rangeStart, b = this.rangeEnd
|
|
485
|
+
if (!a || !b) return null
|
|
486
|
+
return {
|
|
487
|
+
top: Math.min(a.row, b.row), bottom: Math.max(a.row, b.row),
|
|
488
|
+
left: Math.min(a.col, b.col), right: Math.max(a.col, b.col)
|
|
489
|
+
}
|
|
490
|
+
},
|
|
491
|
+
// Vùng SẼ được fill khi thả chuột (chỉ để vẽ viền đứt xem trước, chưa ghi gì).
|
|
492
|
+
// Kéo chéo thì bám trục có độ lệch lớn hơn, giống Excel.
|
|
493
|
+
fillTarget: function () {
|
|
494
|
+
var r = this.rangeBounds()
|
|
495
|
+
var e = this.fillEnd
|
|
496
|
+
if (!r || !e) return null
|
|
497
|
+
var dRow = e.row > r.bottom ? e.row - r.bottom : (e.row < r.top ? e.row - r.top : 0)
|
|
498
|
+
var dCol = e.col > r.right ? e.col - r.right : (e.col < r.left ? e.col - r.left : 0)
|
|
499
|
+
if (!dRow && !dCol) return null
|
|
500
|
+
if (Math.abs(dRow) >= Math.abs(dCol)) {
|
|
501
|
+
return dRow > 0
|
|
502
|
+
? { top: r.bottom + 1, bottom: e.row, left: r.left, right: r.right }
|
|
503
|
+
: { top: e.row, bottom: r.top - 1, left: r.left, right: r.right }
|
|
504
|
+
}
|
|
505
|
+
return dCol > 0
|
|
506
|
+
? { top: r.top, bottom: r.bottom, left: r.right + 1, right: e.col }
|
|
507
|
+
: { top: r.top, bottom: r.bottom, left: e.col, right: r.left - 1 }
|
|
508
|
+
},
|
|
509
|
+
schedulePaint: function () {
|
|
510
|
+
var self = this
|
|
511
|
+
if (self.paintPending) return
|
|
512
|
+
self.paintPending = true
|
|
513
|
+
requestAnimationFrame(function () { self.paintPending = false; self.paintRange() })
|
|
514
|
+
},
|
|
515
|
+
paintRange: function () {
|
|
516
|
+
var self = this
|
|
517
|
+
var el = self.$refs.gridEl
|
|
518
|
+
if (!el || !self.gridApi) return
|
|
519
|
+
var r = self.rangeBounds()
|
|
520
|
+
var f = self.filling ? self.fillTarget() : null
|
|
521
|
+
var single = !!r && r.top === r.bottom && r.left === r.right
|
|
522
|
+
var columns = self.gridApi.getAllDisplayedColumns()
|
|
523
|
+
var idx = {}
|
|
524
|
+
columns.forEach(function (c, i) { idx[c.getColId()] = i })
|
|
525
|
+
var corner = null
|
|
526
|
+
var cells = el.querySelectorAll('.ag-cell')
|
|
527
|
+
var matched = 0
|
|
528
|
+
|
|
529
|
+
Array.prototype.forEach.call(cells, function (cell) {
|
|
530
|
+
cell.classList.remove.apply(cell.classList, FS_RANGE_CLASSES)
|
|
531
|
+
// closest chứ không phải parentElement: từ v36 AG Grid bọc ô trong cell-section
|
|
532
|
+
// (.ag-row > .ag-grid-pinned-left-cells|.ag-grid-scrolling-cells >
|
|
533
|
+
// .ag-grid-container-wrapper > .ag-cell), row-index vẫn nằm trên .ag-row nên
|
|
534
|
+
// parentElement trỏ vào wrapper và tra ra null → NaN → rụng sạch mọi ô.
|
|
535
|
+
var rowEl = cell.closest('.ag-row')
|
|
536
|
+
var row = rowEl ? parseInt(rowEl.getAttribute('row-index'), 10) : NaN
|
|
537
|
+
var col = idx[cell.getAttribute('col-id')]
|
|
538
|
+
if (isNaN(row) || col === undefined) return
|
|
539
|
+
matched++
|
|
540
|
+
if (r && row >= r.top && row <= r.bottom && col >= r.left && col <= r.right) {
|
|
541
|
+
cell.classList.add('ag-cell-range-selected')
|
|
542
|
+
if (single) {
|
|
543
|
+
cell.classList.add('ag-cell-range-single-cell')
|
|
544
|
+
} else {
|
|
545
|
+
if (row === r.top) cell.classList.add('ag-cell-range-top')
|
|
546
|
+
if (row === r.bottom) cell.classList.add('ag-cell-range-bottom')
|
|
547
|
+
if (col === r.left) cell.classList.add('ag-cell-range-left')
|
|
548
|
+
if (col === r.right) cell.classList.add('ag-cell-range-right')
|
|
549
|
+
}
|
|
550
|
+
if (row === r.bottom && col === r.right) corner = cell
|
|
551
|
+
}
|
|
552
|
+
if (f && row >= f.top && row <= f.bottom && col >= f.left && col <= f.right) {
|
|
553
|
+
if (row === f.top) cell.classList.add('ag-selection-fill-top')
|
|
554
|
+
if (row === f.bottom) cell.classList.add('ag-selection-fill-bottom')
|
|
555
|
+
if (col === f.left) cell.classList.add('ag-selection-fill-left')
|
|
556
|
+
if (col === f.right) cell.classList.add('ag-selection-fill-right')
|
|
557
|
+
}
|
|
558
|
+
})
|
|
559
|
+
|
|
560
|
+
// .ag-fill-handle là element con thật (AG Grid style sẵn position/size/cursor),
|
|
561
|
+
// không phải pseudo-element, nên phải gắn/gỡ DOM. Ô .ag-cell đã là position:absolute
|
|
562
|
+
// nên nó tự neo đúng góc dưới-phải.
|
|
563
|
+
Array.prototype.forEach.call(el.querySelectorAll('.ag-fill-handle'), function (h) { h.remove() })
|
|
564
|
+
// Cột không cho sửa (colDef.editable:false) thì cũng không cho kéo fill xuống/ngang
|
|
565
|
+
// — isCellEditable() là API sẵn có của AG Grid, đã dùng đúng chỗ này ở doFill/pasteText.
|
|
566
|
+
var cornerCol = r ? columns[r.right] : null
|
|
567
|
+
var cornerNode = r ? self.gridApi.getDisplayedRowAtIndex(r.bottom) : null
|
|
568
|
+
if (corner && !self.readonly && cornerCol && cornerNode && cornerCol.isCellEditable(cornerNode)) {
|
|
569
|
+
var handle = document.createElement('div')
|
|
570
|
+
handle.className = 'ag-fill-handle'
|
|
571
|
+
corner.appendChild(handle)
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// Có vùng chọn mà không khớp ô nào = cấu trúc DOM của AG Grid đã đổi (row-index/col-id
|
|
575
|
+
// nằm chỗ khác). Kêu đúng một lần kèm số liệu cần thiết, khỏi phải dò lại từ đầu.
|
|
576
|
+
if (r && matched === 0 && cells.length && !self.rangeWarned) {
|
|
577
|
+
self.rangeWarned = true
|
|
578
|
+
var c0 = cells[0]
|
|
579
|
+
console.warn('[f-sheet] paintRange khớp 0 ô — DOM của AG Grid có thể đã đổi.', {
|
|
580
|
+
cells: cells.length,
|
|
581
|
+
parent: c0.parentElement && c0.parentElement.className,
|
|
582
|
+
rowIndex: c0.closest('.ag-row') && c0.closest('.ag-row').getAttribute('row-index'),
|
|
583
|
+
colId: c0.getAttribute('col-id'),
|
|
584
|
+
colIds: Object.keys(idx)
|
|
585
|
+
})
|
|
586
|
+
}
|
|
587
|
+
},
|
|
588
|
+
// Chặn bôi đen chữ khi đang rê chọn vùng.
|
|
589
|
+
lockSelect: function (on) {
|
|
590
|
+
var el = this.$refs.gridEl
|
|
591
|
+
if (el) el.style.userSelect = on ? 'none' : ''
|
|
592
|
+
},
|
|
593
|
+
clearRange: function () {
|
|
594
|
+
this.rangeStart = null
|
|
595
|
+
this.rangeEnd = null
|
|
596
|
+
this.fillEnd = null
|
|
597
|
+
this.paintRange()
|
|
598
|
+
},
|
|
599
|
+
doFill: function () {
|
|
600
|
+
var self = this
|
|
601
|
+
var r = self.rangeBounds()
|
|
602
|
+
var t = self.fillTarget()
|
|
603
|
+
if (!r || !t || self.readonly) return
|
|
604
|
+
var cols = self.gridApi.getAllDisplayedColumns()
|
|
605
|
+
var spanR = r.bottom - r.top + 1
|
|
606
|
+
var spanC = r.right - r.left + 1
|
|
607
|
+
for (var row = t.top; row <= t.bottom; row++) {
|
|
608
|
+
var node = self.gridApi.getDisplayedRowAtIndex(row)
|
|
609
|
+
if (!node) continue
|
|
610
|
+
// lặp lại tuần tự khối nguồn (kiểu copy), không tự suy diễn dãy số tăng dần
|
|
611
|
+
var srcNode = self.gridApi.getDisplayedRowAtIndex(r.top + fsMod(row - r.top, spanR))
|
|
612
|
+
if (!srcNode) continue
|
|
613
|
+
for (var col = t.left; col <= t.right; col++) {
|
|
614
|
+
var column = cols[col]
|
|
615
|
+
var srcCol = cols[r.left + fsMod(col - r.left, spanC)]
|
|
616
|
+
if (!column || !srcCol) continue
|
|
617
|
+
var cd = column.getColDef()
|
|
618
|
+
var sf = srcCol.getColDef().field
|
|
619
|
+
if (!cd.field || !sf || !column.isCellEditable(node)) continue
|
|
620
|
+
node.setDataValue(cd.field, srcNode.data ? srcNode.data[sf] : null)
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
// vùng chọn mới bao trùm cả khối nguồn lẫn phần vừa fill
|
|
624
|
+
self.rangeStart = { row: Math.min(r.top, t.top), col: Math.min(r.left, t.left) }
|
|
625
|
+
self.rangeEnd = { row: Math.max(r.bottom, t.bottom), col: Math.max(r.right, t.right) }
|
|
626
|
+
},
|
|
627
|
+
// Vùng đang chọn, hoặc nếu không có thì ô đang focus (dùng chung cho copy/cut/paste
|
|
628
|
+
// dù gọi từ phím tắt hay từ menu chuột phải).
|
|
629
|
+
currentRangeOrFocus: function () {
|
|
630
|
+
var self = this
|
|
631
|
+
var r = self.rangeBounds()
|
|
632
|
+
if (r) return r
|
|
633
|
+
var fc = self.gridApi.getFocusedCell()
|
|
634
|
+
if (!fc) return null
|
|
635
|
+
var ci = self.colIndex(fc.column)
|
|
636
|
+
return { top: fc.rowIndex, bottom: fc.rowIndex, left: ci, right: ci }
|
|
637
|
+
},
|
|
638
|
+
// Dựng text TSV cho một vùng — dùng chung cho Ctrl+C và menu Copy/Copy with Headers.
|
|
639
|
+
rangeText: function (r, withHeaders) {
|
|
640
|
+
var self = this
|
|
641
|
+
var cols = self.gridApi.getAllDisplayedColumns()
|
|
642
|
+
var out = []
|
|
643
|
+
if (withHeaders) {
|
|
644
|
+
var head = []
|
|
645
|
+
for (var hc = r.left; hc <= r.right; hc++) {
|
|
646
|
+
var hcol = cols[hc]
|
|
647
|
+
head.push(hcol ? (hcol.getColDef().headerName || hcol.getColDef().field || '') : '')
|
|
648
|
+
}
|
|
649
|
+
out.push(head.join('\t'))
|
|
650
|
+
}
|
|
651
|
+
for (var i = r.top; i <= r.bottom; i++) {
|
|
652
|
+
var node = self.gridApi.getDisplayedRowAtIndex(i)
|
|
653
|
+
if (!node) continue
|
|
654
|
+
var line = []
|
|
655
|
+
for (var c = r.left; c <= r.right; c++) {
|
|
656
|
+
var column = cols[c]
|
|
657
|
+
if (!column) continue
|
|
658
|
+
var f = column.getColDef().field
|
|
659
|
+
var v = (f && node.data) ? node.data[f] : ''
|
|
660
|
+
line.push(v === null || v === undefined ? '' : String(v))
|
|
661
|
+
}
|
|
662
|
+
out.push(line.join('\t'))
|
|
663
|
+
}
|
|
664
|
+
return out.join('\n')
|
|
665
|
+
},
|
|
666
|
+
// Ghi clipboard hệ thống khi KHÔNG có ClipboardEvent thật (bấm menu, không phải Ctrl+C).
|
|
667
|
+
writeClipboard: function (text) {
|
|
668
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
669
|
+
navigator.clipboard.writeText(text).catch(function () { fsFallbackCopy(text) })
|
|
670
|
+
} else {
|
|
671
|
+
fsFallbackCopy(text)
|
|
672
|
+
}
|
|
673
|
+
},
|
|
674
|
+
// Nháy nền vùng vừa copy để báo hiệu — gỡ rồi gắn lại class để animation chạy lại
|
|
675
|
+
// được cả khi copy liên tiếp cùng một vùng (đổi class suông không tự restart animation).
|
|
676
|
+
flashCopy: function (r) {
|
|
677
|
+
var self = this
|
|
678
|
+
var el = self.$refs.gridEl
|
|
679
|
+
if (!el || !self.gridApi) return
|
|
680
|
+
var cols = self.gridApi.getAllDisplayedColumns()
|
|
681
|
+
var idx = {}
|
|
682
|
+
cols.forEach(function (c, i) { idx[c.getColId()] = i })
|
|
683
|
+
var hit = []
|
|
684
|
+
Array.prototype.forEach.call(el.querySelectorAll('.ag-cell'), function (cell) {
|
|
685
|
+
var rowEl = cell.closest('.ag-row')
|
|
686
|
+
var row = rowEl ? parseInt(rowEl.getAttribute('row-index'), 10) : NaN
|
|
687
|
+
var col = idx[cell.getAttribute('col-id')]
|
|
688
|
+
if (isNaN(row) || col === undefined) return
|
|
689
|
+
if (row >= r.top && row <= r.bottom && col >= r.left && col <= r.right) hit.push(cell)
|
|
690
|
+
})
|
|
691
|
+
if (!hit.length) return
|
|
692
|
+
clearTimeout(self.copyFlashTimer)
|
|
693
|
+
hit.forEach(function (cell) { cell.classList.remove('ag-cell-copy-flash'); void cell.offsetWidth })
|
|
694
|
+
hit.forEach(function (cell) { cell.classList.add('ag-cell-copy-flash') })
|
|
695
|
+
self.copyFlashTimer = setTimeout(function () {
|
|
696
|
+
hit.forEach(function (cell) { cell.classList.remove('ag-cell-copy-flash') })
|
|
697
|
+
}, 800)
|
|
698
|
+
},
|
|
699
|
+
doCopy: function (e) {
|
|
700
|
+
var self = this
|
|
701
|
+
var r = self.currentRangeOrFocus()
|
|
702
|
+
if (!r) return
|
|
703
|
+
var text = self.rangeText(r, false)
|
|
704
|
+
if (!text) return
|
|
705
|
+
e.clipboardData.setData('text/plain', text)
|
|
706
|
+
e.preventDefault()
|
|
707
|
+
self.flashCopy(r)
|
|
708
|
+
},
|
|
709
|
+
// Copy / Copy with Headers từ menu chuột phải — không có ClipboardEvent nên tự ghi clipboard.
|
|
710
|
+
menuCopy: function (withHeaders) {
|
|
711
|
+
var self = this
|
|
712
|
+
var r = self.currentRangeOrFocus()
|
|
713
|
+
if (!r) return
|
|
714
|
+
var text = self.rangeText(r, withHeaders)
|
|
715
|
+
if (!text) return
|
|
716
|
+
self.writeClipboard(text)
|
|
717
|
+
self.flashCopy(r)
|
|
718
|
+
},
|
|
719
|
+
// Cut từ menu chuột phải: copy rồi xoá giá trị các ô trong vùng (chỉ ô sửa được).
|
|
720
|
+
menuCut: function () {
|
|
721
|
+
var self = this
|
|
722
|
+
if (self.readonly) return
|
|
723
|
+
var r = self.currentRangeOrFocus()
|
|
724
|
+
if (!r) return
|
|
725
|
+
var text = self.rangeText(r, false)
|
|
726
|
+
if (!text) return
|
|
727
|
+
self.writeClipboard(text)
|
|
728
|
+
self.flashCopy(r)
|
|
729
|
+
var cols = self.gridApi.getAllDisplayedColumns()
|
|
730
|
+
for (var i = r.top; i <= r.bottom; i++) {
|
|
731
|
+
var node = self.gridApi.getDisplayedRowAtIndex(i)
|
|
732
|
+
if (!node) continue
|
|
733
|
+
for (var c = r.left; c <= r.right; c++) {
|
|
734
|
+
var column = cols[c]
|
|
735
|
+
if (!column) continue
|
|
736
|
+
var cd = column.getColDef()
|
|
737
|
+
if (!cd.field || !column.isCellEditable(node)) continue
|
|
738
|
+
node.setDataValue(cd.field, null)
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
},
|
|
742
|
+
// Lõi dán dùng chung cho Ctrl+V (ClipboardEvent) và menu Paste (Clipboard API).
|
|
743
|
+
pasteText: function (text) {
|
|
744
|
+
var self = this
|
|
745
|
+
if (self.readonly || !text) return
|
|
746
|
+
var r = self.currentRangeOrFocus()
|
|
747
|
+
if (!r) return
|
|
748
|
+
var startRow = r.top, startCol = r.left
|
|
749
|
+
var grid = fsParseClipboard(text)
|
|
750
|
+
var cols = self.gridApi.getAllDisplayedColumns()
|
|
751
|
+
grid.forEach(function (rowVals, i) {
|
|
752
|
+
var node = self.gridApi.getDisplayedRowAtIndex(startRow + i)
|
|
753
|
+
if (!node) return
|
|
754
|
+
rowVals.forEach(function (val, j) {
|
|
755
|
+
var column = cols[startCol + j]
|
|
756
|
+
if (!column) return
|
|
757
|
+
var cd = column.getColDef()
|
|
758
|
+
if (!cd.field || !column.isCellEditable(node)) return
|
|
759
|
+
// Dán đi thẳng vào data, KHÔNG qua valueParser của colDef — nên phải tự ép kiểu
|
|
760
|
+
// ở đây, nếu không thì cột ngày/số dán từ Excel vẫn lưu chuỗi thô.
|
|
761
|
+
var pType = fsKnownType(cd.type)
|
|
762
|
+
node.setDataValue(cd.field, pType ? self.parseByType(pType, val) : val)
|
|
763
|
+
})
|
|
764
|
+
})
|
|
765
|
+
self.rangeStart = { row: startRow, col: startCol }
|
|
766
|
+
self.rangeEnd = {
|
|
767
|
+
row: startRow + grid.length - 1,
|
|
768
|
+
col: startCol + (grid[0] ? grid[0].length : 1) - 1
|
|
769
|
+
}
|
|
770
|
+
self.schedulePaint()
|
|
771
|
+
},
|
|
772
|
+
doPaste: function (e) {
|
|
773
|
+
var self = this
|
|
774
|
+
if (self.readonly) return
|
|
775
|
+
var cb = e.clipboardData || window.clipboardData
|
|
776
|
+
var text = cb ? cb.getData('text') : ''
|
|
777
|
+
if (!text) return
|
|
778
|
+
e.preventDefault()
|
|
779
|
+
self.pasteText(text)
|
|
780
|
+
},
|
|
781
|
+
// Paste từ menu chuột phải: không có ClipboardEvent nên phải chủ động đọc clipboard
|
|
782
|
+
// (Clipboard API, cần secure context + có thể xin quyền lần đầu).
|
|
783
|
+
menuPaste: function () {
|
|
784
|
+
var self = this
|
|
785
|
+
if (self.readonly) return
|
|
786
|
+
if (navigator.clipboard && navigator.clipboard.readText) {
|
|
787
|
+
navigator.clipboard.readText().then(function (text) { self.pasteText(text) }).catch(function () {
|
|
788
|
+
Vue.$toast.error('Không đọc được clipboard — thử Ctrl+V hoặc cấp quyền clipboard cho trang.', { position: 'top' })
|
|
789
|
+
})
|
|
790
|
+
} else {
|
|
791
|
+
Vue.$toast.error('Trình duyệt không hỗ trợ đọc clipboard qua menu — dùng Ctrl+V.', { position: 'top' })
|
|
792
|
+
}
|
|
793
|
+
},
|
|
794
|
+
buildColDefs: function (cols) {
|
|
795
|
+
var self = this
|
|
796
|
+
var source = (cols && cols.length) ? cols : self.autoColDefs()
|
|
797
|
+
fsWarnUnknownColumnTypes(source)
|
|
798
|
+
var defs = []
|
|
799
|
+
|
|
800
|
+
if (self.allowDelete && !self.readonly) {
|
|
801
|
+
defs.push({
|
|
802
|
+
headerName: '',
|
|
803
|
+
checkboxSelection: true,
|
|
804
|
+
headerCheckboxSelection: true,
|
|
805
|
+
width: 44,
|
|
806
|
+
minWidth: 44,
|
|
807
|
+
maxWidth: 44,
|
|
808
|
+
sortable: false,
|
|
809
|
+
filter: false,
|
|
810
|
+
resizable: false,
|
|
811
|
+
editable: false,
|
|
812
|
+
pinned: 'left'
|
|
813
|
+
})
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
source.forEach(function (col) {
|
|
817
|
+
var def = Object.assign({}, col)
|
|
818
|
+
if (self.readonly) def.editable = false
|
|
819
|
+
// colDef có thể khai cellEditorParams bằng tên string trỏ tới hàm global (vd
|
|
820
|
+
// script.js của module) khi cần giá trị động (vd danh sách chọn lấy từ vueData) —
|
|
821
|
+
// AG Grid hỗ trợ cellEditorParams là function (gọi lại lúc bắt đầu sửa) nên chỉ cần
|
|
822
|
+
// resolve tên thành function thật, không cần cơ chế đăng ký riêng như cellRenderer.
|
|
823
|
+
;['cellEditorParams', 'cellRendererParams'].forEach(function (key) {
|
|
824
|
+
if (typeof def[key] === 'string' && typeof window[def[key]] === 'function') {
|
|
825
|
+
def[key] = window[def[key]]
|
|
826
|
+
}
|
|
827
|
+
})
|
|
828
|
+
var userCellStyle = def.cellStyle
|
|
829
|
+
var isNumber = def.type === 'number'
|
|
830
|
+
var isDate = def.type === 'date'
|
|
831
|
+
// Nền vùng chọn do paintRange gắn class lo, không đụng vào cellStyle. Nhờ vậy
|
|
832
|
+
// nền vàng/xanh của ô đã sửa (style inline) vẫn thắng nền vùng chọn (CSS class)
|
|
833
|
+
// → quét chọn ngang qua không làm mất dấu ô đang có thay đổi chưa lưu.
|
|
834
|
+
def.cellStyle = function (params) {
|
|
835
|
+
var change = self.changeCellStyle(params)
|
|
836
|
+
var extra = (typeof userCellStyle === 'function') ? userCellStyle(params) : userCellStyle
|
|
837
|
+
if (!extra) {
|
|
838
|
+
if (isNumber) extra = { textAlign: 'right' }
|
|
839
|
+
else if (isDate) extra = { textAlign: 'center' }
|
|
840
|
+
}
|
|
841
|
+
if (change && extra) return Object.assign({}, extra, change)
|
|
842
|
+
return change || extra || null
|
|
843
|
+
}
|
|
844
|
+
if (isNumber && !def.valueFormatter) {
|
|
845
|
+
def.valueFormatter = function (params) {
|
|
846
|
+
if (params.value === null || params.value === undefined || params.value === '') return ''
|
|
847
|
+
var n = Number(params.value)
|
|
848
|
+
if (isNaN(n)) return String(params.value)
|
|
849
|
+
return n.toLocaleString('en-US', { maximumFractionDigits: 10 })
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
if (isNumber && !def.valueParser) {
|
|
853
|
+
def.valueParser = function (params) { return self.parseByType('number', params.newValue) }
|
|
854
|
+
}
|
|
855
|
+
if (isDate && !def.valueFormatter) {
|
|
856
|
+
def.valueFormatter = function (params) {
|
|
857
|
+
if (params.value === null || params.value === undefined || params.value === '') return ''
|
|
858
|
+
var s = fsFormatDate(params.value)
|
|
859
|
+
// Không parse được → in chuỗi thô (đúng khuôn của cột số ở trên): dữ liệu rác
|
|
860
|
+
// phải HIỆN RA, nếu trả '' thì ô trông như trống và không ai đi tìm.
|
|
861
|
+
return s === null ? String(params.value) : s
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
if (isDate && !def.valueParser) {
|
|
865
|
+
def.valueParser = function (params) { return self.parseByType('date', params.newValue) }
|
|
866
|
+
}
|
|
867
|
+
defs.push(def)
|
|
868
|
+
})
|
|
869
|
+
|
|
870
|
+
return defs
|
|
871
|
+
},
|
|
872
|
+
initGrid: function () {
|
|
873
|
+
var self = this
|
|
874
|
+
var keyField = self.rowKey || 'id'
|
|
875
|
+
self.buildOriginalData(self.items)
|
|
876
|
+
|
|
877
|
+
// Trạng thái vùng chọn — cố ý KHÔNG khai trong data(): đổi liên tục khi rê chuột,
|
|
878
|
+
// để reactive chỉ tốn công theo dõi mà template không hề dùng tới. Việc vẽ lại
|
|
879
|
+
// do paintRange() chủ động gọi.
|
|
880
|
+
self.rangeStart = null
|
|
881
|
+
self.rangeEnd = null
|
|
882
|
+
self.selecting = false
|
|
883
|
+
self.filling = false
|
|
884
|
+
self.fillEnd = null
|
|
885
|
+
self.paintPending = false
|
|
886
|
+
self.handleGrab = false
|
|
887
|
+
self.shiftGrab = false
|
|
888
|
+
self.rightClick = false
|
|
889
|
+
self.rangeWarned = false
|
|
890
|
+
self.copyFlashTimer = null
|
|
891
|
+
|
|
892
|
+
var colDefs = self.buildColDefs(self.columns)
|
|
893
|
+
// colDef có thể khai cellRenderer/cellEditor bằng tên string trỏ tới class/hàm
|
|
894
|
+
// global (vd script.js của module) — AG Grid cần đăng ký tường minh trong
|
|
895
|
+
// components thì mới resolve được, không tự dò theo window[name].
|
|
896
|
+
// Tên editor có sẵn của AG Grid (vd agDateStringCellEditor) không cần đăng ký,
|
|
897
|
+
// chỉ những hàm global tự viết (window[name] là function) mới được thêm vào đây.
|
|
898
|
+
// Column group (headerName + children) phải quét đệ quy vì colDef thật nằm
|
|
899
|
+
// trong children, không phải ở object group.
|
|
900
|
+
var components = {
|
|
901
|
+
fsHtmlCellRenderer: fsHtmlCellRenderer,
|
|
902
|
+
fsLinkCellRenderer: fsLinkCellRenderer,
|
|
903
|
+
fsButtonCellRenderer: fsButtonCellRenderer,
|
|
904
|
+
fsMenuCellRenderer: fsMenuCellRenderer
|
|
905
|
+
}
|
|
906
|
+
function collectComponents(defs) {
|
|
907
|
+
(defs || []).forEach(function (def) {
|
|
908
|
+
if (def.children) { collectComponents(def.children); return }
|
|
909
|
+
['cellRenderer', 'cellEditor'].forEach(function (key) {
|
|
910
|
+
var name = def[key]
|
|
911
|
+
if (typeof name === 'string' && !components[name] && typeof window[name] === 'function') {
|
|
912
|
+
components[name] = window[name]
|
|
913
|
+
}
|
|
914
|
+
})
|
|
915
|
+
})
|
|
916
|
+
}
|
|
917
|
+
collectComponents(colDefs)
|
|
918
|
+
|
|
919
|
+
self.gridApi = window.agGrid.createGrid(self.$refs.gridEl, {
|
|
920
|
+
theme: fsheetTheme,
|
|
921
|
+
components: components,
|
|
922
|
+
columnDefs: colDefs,
|
|
923
|
+
rowData: self.items || [],
|
|
924
|
+
domLayout: (self.heightNum === null) ? 'autoHeight' : 'normal',
|
|
925
|
+
columnTypes: FS_COLUMN_TYPES,
|
|
926
|
+
rowSelection: (self.allowDelete && !self.readonly) ? 'multiple' : undefined,
|
|
927
|
+
suppressRowClickSelection: true,
|
|
928
|
+
defaultColDef: {
|
|
929
|
+
// AG Grid mặc định editable=false; f-sheet là lưới NHẬP LIỆU nên bật sẵn,
|
|
930
|
+
// cột nào chỉ đọc thì khai editable:false trong colDef.
|
|
931
|
+
editable: !self.readonly,
|
|
932
|
+
sortable: true,
|
|
933
|
+
filter: true,
|
|
934
|
+
resizable: true,
|
|
935
|
+
flex: 1,
|
|
936
|
+
minWidth: 80
|
|
937
|
+
},
|
|
938
|
+
rowHeight: 36,
|
|
939
|
+
headerHeight: 36,
|
|
940
|
+
animateRows: true,
|
|
941
|
+
stopEditingWhenCellsLoseFocus: true,
|
|
942
|
+
onSelectionChanged: function () {
|
|
943
|
+
self.selectedRows = self.gridApi.getSelectedRows()
|
|
944
|
+
},
|
|
945
|
+
// Bấm trúng chính element .ag-fill-handle thì là kéo fill, còn lại là quét chọn
|
|
946
|
+
// vùng mới. Nhận diện bằng e.target (cờ handleGrab đặt ở pha capture bên dưới)
|
|
947
|
+
// chứ không đo toạ độ góc ô — handle là element thật nên hỏi thẳng nó.
|
|
948
|
+
// Bỏ qua cột không có field (cột checkbox chọn dòng).
|
|
949
|
+
onCellMouseDown: function (params) {
|
|
950
|
+
var e = params.event
|
|
951
|
+
if (!e || e.button !== 0) return
|
|
952
|
+
if (!params.column.getColDef().field) return
|
|
953
|
+
var row = params.node.rowIndex
|
|
954
|
+
var col = self.colIndex(params.column)
|
|
955
|
+
if (self.handleGrab) {
|
|
956
|
+
self.filling = true
|
|
957
|
+
self.fillEnd = { row: row, col: col }
|
|
958
|
+
self.lockSelect(true)
|
|
959
|
+
return
|
|
960
|
+
}
|
|
961
|
+
if (e.shiftKey && self.rangeStart) {
|
|
962
|
+
self.rangeEnd = { row: row, col: col }
|
|
963
|
+
} else {
|
|
964
|
+
self.rangeStart = { row: row, col: col }
|
|
965
|
+
self.rangeEnd = { row: row, col: col }
|
|
966
|
+
}
|
|
967
|
+
self.selecting = true
|
|
968
|
+
self.lockSelect(true)
|
|
969
|
+
self.paintRange()
|
|
970
|
+
},
|
|
971
|
+
onCellMouseOver: function (params) {
|
|
972
|
+
if (!params.node || params.node.rowIndex == null) return
|
|
973
|
+
var pos = { row: params.node.rowIndex, col: self.colIndex(params.column) }
|
|
974
|
+
if (self.filling) { self.fillEnd = pos; self.schedulePaint(); return }
|
|
975
|
+
if (!self.selecting) return
|
|
976
|
+
if (!params.column.getColDef().field) return
|
|
977
|
+
self.rangeEnd = pos
|
|
978
|
+
self.schedulePaint()
|
|
979
|
+
},
|
|
980
|
+
// Di chuyển ô bằng bàn phím → thu vùng chọn về đúng ô đang focus (giống Excel),
|
|
981
|
+
// TRỪ khi đang giữ Shift thì kéo dài vùng chọn tới ô mới (Shift+mũi tên kiểu Excel)
|
|
982
|
+
// thay vì thu về 1 ô — self.shiftGrab phản ánh trạng thái Shift tại thời điểm
|
|
983
|
+
// mousedown/keydown gần nhất (xem mouseDownHandler/keyStateHandler bên dưới).
|
|
984
|
+
// Trong lúc đang rê chuột thì bỏ qua, nếu không sẽ đạp lên vùng vừa quét.
|
|
985
|
+
onCellFocused: function (params) {
|
|
986
|
+
if (self.selecting || self.filling || self.handleGrab || self.rightClick) return
|
|
987
|
+
if (params.rowIndex == null || !params.column || typeof params.column === 'string') return
|
|
988
|
+
var col = self.colIndex(params.column)
|
|
989
|
+
if (self.shiftGrab && self.rangeStart) {
|
|
990
|
+
self.rangeEnd = { row: params.rowIndex, col: col }
|
|
991
|
+
} else {
|
|
992
|
+
self.rangeStart = { row: params.rowIndex, col: col }
|
|
993
|
+
self.rangeEnd = { row: params.rowIndex, col: col }
|
|
994
|
+
}
|
|
995
|
+
// paintRange() thẳng chứ không schedulePaint(): AG Grid tự gỡ .ag-cell-focus khỏi
|
|
996
|
+
// ô cũ NGAY trong cùng thao tác focus, còn schedulePaint() hoãn việc gỡ
|
|
997
|
+
// .ag-cell-range-selected sang khung hình animation kế tiếp — trong khoảng hở đó ô
|
|
998
|
+
// cũ khớp ".ag-cell-range-selected:not(.ag-cell-focus)" nên nền chớp sáng lên rồi
|
|
999
|
+
// mới tắt. Gọi đồng bộ giống onCellMouseDown thì không còn khoảng hở đó nữa.
|
|
1000
|
+
self.paintRange()
|
|
1001
|
+
},
|
|
1002
|
+
// Cuộn/ảo hoá cột/sắp xếp/lọc đều dựng lại DOM ô → class tô vùng chọn bị mất,
|
|
1003
|
+
// phải vẽ lại. Sắp xếp và lọc không phát hai event kia nên cần onModelUpdated.
|
|
1004
|
+
onBodyScroll: function () { self.schedulePaint() },
|
|
1005
|
+
onVirtualColumnsChanged: function () { self.schedulePaint() },
|
|
1006
|
+
onModelUpdated: function () { self.schedulePaint() },
|
|
1007
|
+
onCellValueChanged: function (params) {
|
|
1008
|
+
var field = params.colDef.field
|
|
1009
|
+
var cType = fsKnownType(params.colDef.type)
|
|
1010
|
+
if (field && cType) {
|
|
1011
|
+
var coerced = self.parseByType(cType, params.newValue)
|
|
1012
|
+
if (coerced !== params.newValue) {
|
|
1013
|
+
params.data[field] = coerced
|
|
1014
|
+
params.newValue = coerced
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
// Dòng mới tra changedRows theo _fsNewKey (khoá tạm nội bộ, không đổi trong suốt
|
|
1018
|
+
// vòng đời dòng) thay vì giá trị ô khoá thật — ô khoá thật có thể bị người dùng
|
|
1019
|
+
// gõ đi gõ lại nhiều lần, nếu tra theo nó thì mỗi lần sửa lại rơi vào một slot
|
|
1020
|
+
// khác nhau trong changedRows, slot cũ bị bỏ quên (nhìn như "chỉ sửa được 1 lần").
|
|
1021
|
+
var key = (params.data._fsNewKey !== undefined) ? params.data._fsNewKey : params.data[keyField]
|
|
1022
|
+
if (key !== undefined && key !== null) {
|
|
1023
|
+
var next = {}
|
|
1024
|
+
for (var k in self.changedRows) {
|
|
1025
|
+
if (Object.prototype.hasOwnProperty.call(self.changedRows, k)) next[k] = self.changedRows[k]
|
|
1026
|
+
}
|
|
1027
|
+
next[String(key)] = params.data
|
|
1028
|
+
self.changedRows = next
|
|
1029
|
+
}
|
|
1030
|
+
// refreshCells dựng lại DOM ô → mất class vùng chọn, vẽ lại ngay sau đó
|
|
1031
|
+
self.gridApi.refreshCells({ force: true })
|
|
1032
|
+
self.schedulePaint()
|
|
1033
|
+
self.$emit('change', params.data, params.colDef.field, params.newValue)
|
|
1034
|
+
}
|
|
1035
|
+
})
|
|
1036
|
+
|
|
1037
|
+
// createGrid() vừa bơm core.css của AG Grid vào cuối <head> → dời style của f-sheet
|
|
1038
|
+
// xuống sau nó, nếu không mấy luật viền cùng specificity sẽ bị đè.
|
|
1039
|
+
fsInstallRangeCss()
|
|
1040
|
+
|
|
1041
|
+
// Ghi nhận ở pha CAPTURE nên chắc chắn chạy trước mọi handler của AG Grid: thứ tự
|
|
1042
|
+
// giữa onCellMouseDown và onCellFocused không được đảm bảo, mà nếu onCellFocused
|
|
1043
|
+
// chạy trước thì nó thu vùng chọn về 1 ô → kéo fill mất sạch khối nguồn.
|
|
1044
|
+
self.mouseDownHandler = function (e) {
|
|
1045
|
+
var t = e.target
|
|
1046
|
+
self.handleGrab = !!(t && t.classList && t.classList.contains('ag-fill-handle'))
|
|
1047
|
+
self.shiftGrab = !!e.shiftKey
|
|
1048
|
+
self.rightClick = e.button === 2
|
|
1049
|
+
}
|
|
1050
|
+
// AG Grid tự focus lại ô ngay khi bấm chuột (kể cả chuột phải, vì bản Community
|
|
1051
|
+
// không có rangeSvc thật nên nhánh "giữ nguyên vùng chọn khi chuột phải trong vùng"
|
|
1052
|
+
// của chính AG Grid không có tác dụng) — nhưng việc đó chạy trên sự kiện
|
|
1053
|
+
// "pointerdown" (Pointer Events, feature-detect trong RowContainerEventsFeature),
|
|
1054
|
+
// KHÔNG PHẢI "mousedown". pointerdown luôn bắn TRƯỚC mousedown trong cùng một cú
|
|
1055
|
+
// bấm, nên nếu chỉ nghe mousedown thì self.rightClick được set quá trễ — AG Grid đã
|
|
1056
|
+
// kịp đổi focus (và bắn mutation cho rangeObserver) trước khi cờ kịp bật. Nghe thêm
|
|
1057
|
+
// pointerdown ở pha capture trên document để chắc chắn chạy trước AG Grid dù nó
|
|
1058
|
+
// dùng loại event nào (giữ luôn mousedown làm phương án dự phòng cho trình duyệt/
|
|
1059
|
+
// input không hỗ trợ Pointer Events).
|
|
1060
|
+
document.addEventListener('pointerdown', self.mouseDownHandler, true)
|
|
1061
|
+
document.addEventListener('mousedown', self.mouseDownHandler, true)
|
|
1062
|
+
// Cùng cờ shiftGrab ở trên nhưng cập nhật theo bàn phím: AG Grid tự di chuyển focus
|
|
1063
|
+
// khi bấm mũi tên (kể cả giữ Shift, Cell Selection Enterprise mới có kiểu "kéo dài
|
|
1064
|
+
// vùng chọn" sẵn — bản Community không có nên f-sheet tự làm ở onCellFocused), nên
|
|
1065
|
+
// cần biết Shift có đang giữ hay không NGAY LÚC bắt phím, trước khi onCellFocused chạy.
|
|
1066
|
+
self.keyStateHandler = function (e) { self.shiftGrab = !!e.shiftKey }
|
|
1067
|
+
document.addEventListener('keydown', self.keyStateHandler, true)
|
|
1068
|
+
document.addEventListener('keyup', self.keyStateHandler, true)
|
|
1069
|
+
// AG Grid gọi các callback gridOptions.onXxx (onCellFocused, onCellMouseDown...)
|
|
1070
|
+
// qua một hàng đợi setTimeout(0) riêng (mọi "grid event" public đều async, trừ đúng
|
|
1071
|
+
// 3 cái không liên quan tới đây), TRỄ hơn hẳn việc chính AG Grid gỡ/gắn class
|
|
1072
|
+
// .ag-cell-focus lên DOM — việc đó chạy ĐỒNG BỘ ngay trong lúc xử lý mousedown/phím
|
|
1073
|
+
// mũi tên (RowRenderer.updateCellFocus). Khoảng hở này là lý do onCellFocused gọi
|
|
1074
|
+
// paintRange() đồng bộ (bên trên) vẫn chớp: lúc callback của AG Grid chạy tới thì
|
|
1075
|
+
// trình duyệt đã kịp vẽ một khung hình với ô cũ mất .ag-cell-focus mà vẫn còn
|
|
1076
|
+
// .ag-cell-range-selected. MutationObserver không lệ thuộc hàng đợi đó — callback của
|
|
1077
|
+
// nó luôn chạy ở microtask ngay sau khi DOM đổi, tức luôn trước khung hình kế tiếp —
|
|
1078
|
+
// nên bắt trực tiếp lúc .ag-cell-focus đổi rồi tự suy ra ô đang focus qua DOM
|
|
1079
|
+
// (giống contextMenuHandler bên dưới) mà không cần chờ AG Grid gọi lại callback.
|
|
1080
|
+
self.rangeObserver = new MutationObserver(function (records) {
|
|
1081
|
+
if (self.selecting || self.filling || self.handleGrab || self.rightClick) return
|
|
1082
|
+
var focusChanged = records.some(function (m) {
|
|
1083
|
+
if (m.attributeName !== 'class' || !m.target.classList) return false
|
|
1084
|
+
var had = (m.oldValue || '').indexOf('ag-cell-focus') !== -1
|
|
1085
|
+
return had !== m.target.classList.contains('ag-cell-focus')
|
|
1086
|
+
})
|
|
1087
|
+
if (!focusChanged) return
|
|
1088
|
+
var el = self.$refs.gridEl
|
|
1089
|
+
var focusedCell = el && el.querySelector('.ag-cell-focus')
|
|
1090
|
+
if (!focusedCell) return
|
|
1091
|
+
var rowEl = focusedCell.closest('.ag-row')
|
|
1092
|
+
var row = rowEl ? parseInt(rowEl.getAttribute('row-index'), 10) : NaN
|
|
1093
|
+
var columns = self.gridApi.getAllDisplayedColumns()
|
|
1094
|
+
var idx = {}
|
|
1095
|
+
columns.forEach(function (c, i) { idx[c.getColId()] = i })
|
|
1096
|
+
var col = idx[focusedCell.getAttribute('col-id')]
|
|
1097
|
+
if (isNaN(row) || col === undefined) return
|
|
1098
|
+
if (self.shiftGrab && self.rangeStart) {
|
|
1099
|
+
self.rangeEnd = { row: row, col: col }
|
|
1100
|
+
} else {
|
|
1101
|
+
self.rangeStart = { row: row, col: col }
|
|
1102
|
+
self.rangeEnd = { row: row, col: col }
|
|
1103
|
+
}
|
|
1104
|
+
self.paintRange()
|
|
1105
|
+
})
|
|
1106
|
+
self.rangeObserver.observe(self.$refs.gridEl, { attributes: true, attributeFilter: ['class'], attributeOldValue: true, subtree: true })
|
|
1107
|
+
// mouseup nghe ở document (không phải ở lưới) để thả chuột ngoài lưới vẫn kết thúc
|
|
1108
|
+
// được thao tác, tránh kẹt trạng thái đang quét.
|
|
1109
|
+
self.mouseUpHandler = function () {
|
|
1110
|
+
if (self.filling) {
|
|
1111
|
+
self.filling = false
|
|
1112
|
+
self.doFill()
|
|
1113
|
+
self.fillEnd = null
|
|
1114
|
+
}
|
|
1115
|
+
self.selecting = false
|
|
1116
|
+
self.handleGrab = false
|
|
1117
|
+
self.shiftGrab = false
|
|
1118
|
+
self.lockSelect(false)
|
|
1119
|
+
self.paintRange()
|
|
1120
|
+
}
|
|
1121
|
+
document.addEventListener('mouseup', self.mouseUpHandler)
|
|
1122
|
+
// copy/paste gắn ở gridEl: sự kiện nổi lên từ ô đang focus của AG Grid, nên chỉ
|
|
1123
|
+
// chạy khi người dùng thực sự đang ở trong lưới này.
|
|
1124
|
+
self.copyHandler = function (e) { self.doCopy(e) }
|
|
1125
|
+
self.pasteHandler = function (e) { self.doPaste(e) }
|
|
1126
|
+
self.keyDownHandler = function (e) { if (e.key === 'Escape') self.clearRange() }
|
|
1127
|
+
self.$refs.gridEl.addEventListener('copy', self.copyHandler)
|
|
1128
|
+
self.$refs.gridEl.addEventListener('paste', self.pasteHandler)
|
|
1129
|
+
self.$refs.gridEl.addEventListener('keydown', self.keyDownHandler)
|
|
1130
|
+
// Chuột phải trong ô: nếu ô đó nằm ngoài vùng đang chọn thì thu vùng chọn về đúng
|
|
1131
|
+
// ô đó trước (giống Excel), rồi mở menu Cut/Copy/Copy with Headers/Paste tại vị trí
|
|
1132
|
+
// con trỏ. Bỏ qua cột không có field (vd cột checkbox) để không đạp lên vùng đang chọn.
|
|
1133
|
+
self.contextMenuHandler = function (e) {
|
|
1134
|
+
var cellEl = e.target && e.target.closest && e.target.closest('.ag-cell')
|
|
1135
|
+
if (!cellEl) return
|
|
1136
|
+
e.preventDefault()
|
|
1137
|
+
var rowEl = cellEl.closest('.ag-row')
|
|
1138
|
+
var row = rowEl ? parseInt(rowEl.getAttribute('row-index'), 10) : NaN
|
|
1139
|
+
var cols = self.gridApi.getAllDisplayedColumns()
|
|
1140
|
+
var idx = {}
|
|
1141
|
+
cols.forEach(function (c, i) { idx[c.getColId()] = i })
|
|
1142
|
+
var col = idx[cellEl.getAttribute('col-id')]
|
|
1143
|
+
if (isNaN(row) || col === undefined) return
|
|
1144
|
+
var r = self.rangeBounds()
|
|
1145
|
+
var inside = !!r && row >= r.top && row <= r.bottom && col >= r.left && col <= r.right
|
|
1146
|
+
var hasField = cols[col] && cols[col].getColDef().field
|
|
1147
|
+
if (!inside && hasField) {
|
|
1148
|
+
self.rangeStart = { row: row, col: col }
|
|
1149
|
+
self.rangeEnd = { row: row, col: col }
|
|
1150
|
+
self.paintRange()
|
|
1151
|
+
}
|
|
1152
|
+
var items = []
|
|
1153
|
+
if (!self.readonly) {
|
|
1154
|
+
items.push({ icon: 'mdi-content-cut', text: 'Cut', shortcut: 'Ctrl+X', action: function () { self.menuCut() } })
|
|
1155
|
+
}
|
|
1156
|
+
items.push({ icon: 'mdi-content-copy', text: 'Copy', shortcut: 'Ctrl+C', action: function () { self.menuCopy(false) } })
|
|
1157
|
+
items.push({ icon: 'mdi-content-duplicate', text: 'Copy with Headers', action: function () { self.menuCopy(true) } })
|
|
1158
|
+
if (!self.readonly) {
|
|
1159
|
+
items.push({ icon: 'mdi-content-paste', text: 'Paste', shortcut: 'Ctrl+V', action: function () { self.menuPaste() } })
|
|
1160
|
+
}
|
|
1161
|
+
openFsContextMenu(e.clientX, e.clientY, items)
|
|
1162
|
+
}
|
|
1163
|
+
self.$refs.gridEl.addEventListener('contextmenu', self.contextMenuHandler)
|
|
1164
|
+
},
|
|
1165
|
+
getChangedRows: function () {
|
|
1166
|
+
var rows = []
|
|
1167
|
+
for (var k in this.changedRows) {
|
|
1168
|
+
if (Object.prototype.hasOwnProperty.call(this.changedRows, k)) rows.push(this.changedRows[k])
|
|
1169
|
+
}
|
|
1170
|
+
return rows
|
|
1171
|
+
},
|
|
1172
|
+
addRow: function () {
|
|
1173
|
+
if (!this.gridApi) return
|
|
1174
|
+
this.localCounter--
|
|
1175
|
+
var newRow = {}
|
|
1176
|
+
mapData(this.defaultItem || {}, newRow, vueData)
|
|
1177
|
+
// Khoá tạm nội bộ (_fsNewKey), TÁCH RIÊNG khỏi ô khoá thật (rowKey). Trước đây gán
|
|
1178
|
+
// thẳng localCounter vào field khoá thật nên: (1) ô khoá hiện số âm -1, -2... ngay
|
|
1179
|
+
// khi thêm dòng, và (2) mọi bảng theo dõi (changedRows/originalData) tra theo GIÁ
|
|
1180
|
+
// TRỊ Ô KHOÁ — hễ người dùng gõ lại ô khoá là "lạc" khỏi slot cũ trong changedRows,
|
|
1181
|
+
// sửa từ lần 2 trở đi coi như không ghi nhận. _fsNewKey không có colDef nào tham
|
|
1182
|
+
// chiếu nên không hiện thành cột, không gửi kèm lên UI — chỉ f-sheet tự dùng.
|
|
1183
|
+
newRow._fsNewKey = 'new_' + this.localCounter
|
|
1184
|
+
this.gridApi.applyTransaction({ add: [newRow], addIndex: 0 })
|
|
1185
|
+
var next = {}
|
|
1186
|
+
for (var k in this.changedRows) {
|
|
1187
|
+
if (Object.prototype.hasOwnProperty.call(this.changedRows, k)) next[k] = this.changedRows[k]
|
|
1188
|
+
}
|
|
1189
|
+
next[newRow._fsNewKey] = newRow
|
|
1190
|
+
this.changedRows = next
|
|
1191
|
+
this.gridApi.refreshCells({ force: true })
|
|
1192
|
+
},
|
|
1193
|
+
deleteRow: function (row) {
|
|
1194
|
+
var self = this
|
|
1195
|
+
var keyField = self.rowKey || 'id'
|
|
1196
|
+
var isNew = row._fsNewKey !== undefined
|
|
1197
|
+
var trackKey = isNew ? row._fsNewKey : row[keyField]
|
|
1198
|
+
|
|
1199
|
+
function removeFromGrid() {
|
|
1200
|
+
self.gridApi.applyTransaction({ remove: [row] })
|
|
1201
|
+
var next = {}
|
|
1202
|
+
for (var k in self.changedRows) {
|
|
1203
|
+
if (Object.prototype.hasOwnProperty.call(self.changedRows, k) && k !== String(trackKey)) next[k] = self.changedRows[k]
|
|
1204
|
+
}
|
|
1205
|
+
self.changedRows = next
|
|
1206
|
+
if (self.originalData) delete self.originalData[String(trackKey)]
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
if (isNew) { removeFromGrid(); return }
|
|
1210
|
+
if (!self.deleteApi) { removeFromGrid(); return }
|
|
1211
|
+
var extra = {}
|
|
1212
|
+
mapData(self.saveExtra || {}, extra, vueData)
|
|
1213
|
+
ajaxCALL(self.deleteApi, Object.assign({}, extra, row), function (res) {
|
|
1214
|
+
var r = res && res.data && res.data[0] ? res.data[0] : null
|
|
1215
|
+
if (r && r.Message) {
|
|
1216
|
+
if (r.Success) { Vue.$toast.success(r.Message, { position: 'top' }); removeFromGrid() }
|
|
1217
|
+
else Vue.$toast.error(r.Message, { position: 'top' })
|
|
1218
|
+
} else {
|
|
1219
|
+
removeFromGrid()
|
|
1220
|
+
}
|
|
1221
|
+
}, function () {
|
|
1222
|
+
Vue.$toast.error('Lỗi khi xoá dữ liệu', { position: 'top' })
|
|
1223
|
+
})
|
|
1224
|
+
},
|
|
1225
|
+
deleteSelected: function () {
|
|
1226
|
+
var self = this
|
|
1227
|
+
var rows = self.selectedRows.slice()
|
|
1228
|
+
if (!rows.length) return
|
|
1229
|
+
var hasApi = self.deleteApi
|
|
1230
|
+
var newRows = rows.filter(function (r) { return r._fsNewKey !== undefined })
|
|
1231
|
+
var existRows = rows.filter(function (r) { return r._fsNewKey === undefined })
|
|
1232
|
+
|
|
1233
|
+
newRows.forEach(function (r) { self.deleteRow(r) })
|
|
1234
|
+
|
|
1235
|
+
if (!existRows.length) return
|
|
1236
|
+
if (hasApi) {
|
|
1237
|
+
confirm({
|
|
1238
|
+
title: 'Xoá dữ liệu',
|
|
1239
|
+
message: 'Xoá ' + existRows.length + ' dòng đã chọn?',
|
|
1240
|
+
action: function () {
|
|
1241
|
+
existRows.forEach(function (r) { self.deleteRow(r) })
|
|
1242
|
+
self.selectedRows = []
|
|
1243
|
+
}
|
|
1244
|
+
})
|
|
1245
|
+
} else {
|
|
1246
|
+
existRows.forEach(function (r) { self.deleteRow(r) })
|
|
1247
|
+
self.selectedRows = []
|
|
1248
|
+
}
|
|
1249
|
+
},
|
|
1250
|
+
saveAll: function () {
|
|
1251
|
+
var self = this
|
|
1252
|
+
var rows = self.getChangedRows()
|
|
1253
|
+
self.$emit('save', rows)
|
|
1254
|
+
if (!self.updateApi || rows.length === 0) return
|
|
1255
|
+
self.saving = true
|
|
1256
|
+
var pending = rows.length
|
|
1257
|
+
var hasError = false
|
|
1258
|
+
|
|
1259
|
+
rows.forEach(function (row) {
|
|
1260
|
+
var extra = {}
|
|
1261
|
+
mapData(self.saveExtra || {}, extra, vueData)
|
|
1262
|
+
var payload = Object.assign({}, extra, row)
|
|
1263
|
+
// _fsNewKey chỉ là khoá tạm nội bộ để f-sheet tự theo dõi dòng mới, không phải dữ
|
|
1264
|
+
// liệu thật — không gửi lên API. Ô khoá thật (payload[keyField]) giờ luôn đúng bằng
|
|
1265
|
+
// giá trị người dùng đã gõ (có thể để trống nếu API tự sinh khoá), không còn số âm
|
|
1266
|
+
// giả nào cần dọn trước khi gửi nữa.
|
|
1267
|
+
delete payload._fsNewKey
|
|
1268
|
+
ajaxCALL(self.updateApi, payload, function (res) {
|
|
1269
|
+
pending--
|
|
1270
|
+
var r = res && res.data && res.data[0] ? res.data[0] : null
|
|
1271
|
+
if (r && r.Message) {
|
|
1272
|
+
if (r.Success) Vue.$toast.success(r.Message, { position: 'top' })
|
|
1273
|
+
else { Vue.$toast.error(r.Message, { position: 'top' }); hasError = true }
|
|
1274
|
+
}
|
|
1275
|
+
if (pending === 0) {
|
|
1276
|
+
self.saving = false
|
|
1277
|
+
if (!hasError) {
|
|
1278
|
+
var current = []
|
|
1279
|
+
self.gridApi.forEachNode(function (node) {
|
|
1280
|
+
current.push(Object.assign({}, node.data))
|
|
1281
|
+
})
|
|
1282
|
+
self.buildOriginalData(current)
|
|
1283
|
+
self.gridApi.setGridOption('rowData', current)
|
|
1284
|
+
self.changedRows = {}
|
|
1285
|
+
self.localCounter = 0
|
|
1286
|
+
self.$emit('saved')
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
}, function () {
|
|
1290
|
+
pending--
|
|
1291
|
+
hasError = true
|
|
1292
|
+
Vue.$toast.error('Lỗi khi lưu dữ liệu', { position: 'top' })
|
|
1293
|
+
if (pending === 0) self.saving = false
|
|
1294
|
+
})
|
|
1295
|
+
})
|
|
1296
|
+
},
|
|
1297
|
+
discardAll: function () {
|
|
1298
|
+
this.changedRows = {}
|
|
1299
|
+
this.localCounter = 0
|
|
1300
|
+
this.selectedRows = []
|
|
1301
|
+
if (this.gridApi) {
|
|
1302
|
+
var restored = (this.originalRows || []).map(function (r) { return Object.assign({}, r) })
|
|
1303
|
+
this.gridApi.setGridOption('rowData', restored)
|
|
1304
|
+
this.gridApi.refreshCells({ force: true })
|
|
1305
|
+
this.clearRange()
|
|
1306
|
+
}
|
|
1307
|
+
},
|
|
1308
|
+
exportExcel: function () {
|
|
1309
|
+
var self = this
|
|
1310
|
+
var cols = self.gridApi.getAllDisplayedColumns().filter(function (col) {
|
|
1311
|
+
var f = col.getColDef().field
|
|
1312
|
+
return f && f !== '_del'
|
|
1313
|
+
})
|
|
1314
|
+
var rows = []
|
|
1315
|
+
self.gridApi.forEachNode(function (node) {
|
|
1316
|
+
var row = {}
|
|
1317
|
+
cols.forEach(function (col) {
|
|
1318
|
+
var def = col.getColDef()
|
|
1319
|
+
row[def.headerName || def.field] = node.data[def.field]
|
|
1320
|
+
})
|
|
1321
|
+
rows.push(row)
|
|
1322
|
+
})
|
|
1323
|
+
jsonToExcel({
|
|
1324
|
+
data: rows,
|
|
1325
|
+
filename: self.label || 'export',
|
|
1326
|
+
worksheet: self.label || 'Sheet1'
|
|
1327
|
+
})
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
});
|