ExtJS 經典場景:窗口包含 FormPanel(輸入金額、選擇稅率)與 GridPanel(明細行),需實現「稅種下拉變更 → 當前行含稅金額自動重算 → 總計更新」。本文提供基於 ViewModel、Binding、ComboBox change 事件 的完整 MVVM 實現方案。
// ViewModel 定義
Ext.define('MyApp.view.invoice.InvoiceViewModel', {
extend: 'Ext.app.ViewModel',
alias: 'viewmodel.invoice',
formulas: {
// 總含稅金額計算屬性
totalTaxAmount: {
get: function(get) {
var store = get('detailStore');
var total = 0;
store.each(function(rec) {
total += rec.get('taxAmount') || 0;
});
return total;
}
}
},
stores: {
detailStore: {
model: 'MyApp.model.InvoiceDetail',
autoLoad: false,
data: [] // 初始空數據
}
}
});
Ext.define('MyApp.model.InvoiceDetail', {
extend: 'Ext.data.Model',
fields: [
{ name: 'id', type: 'int' },
{ name: 'itemName', type: 'string' },
{ name: 'quantity', type: 'number', defaultValue: 1 },
{ name: 'unitPrice', type: 'number', defaultValue: 0 },
{ name: 'taxRate', type: 'number', defaultValue: 0.13 }, // 稅率 13%
{ name: 'taxType', type: 'string' }, // 稅種代碼
// 計算字段:不持久化,自動重算
{
name: 'amount',
type: 'number',
calculate: function(data) {
return (data.quantity || 0) * (data.unitPrice || 0);
}
},
{
name: 'taxAmount',
type: 'number',
calculate: function(data) {
var amt = (data.quantity || 0) * (data.unitPrice || 0);
return amt * (data.taxRate || 0);
}
},
{
name: 'totalAmount',
type: 'number',
calculate: function(data) {
var amt = (data.quantity || 0) * (data.unitPrice || 0);
return amt * (1 + (data.taxRate || 0));
}
}
]
});
Ext.define('MyApp.view.invoice.InvoiceWindow', {
extend: 'Ext.window.Window',
title: '發票錄入',
width: 800,
height: 500,
layout: 'border',
viewModel: { type: 'invoice' },
items: [{
// 北部:主表單
region: 'north',
xtype: 'form',
height: 120,
bodyPadding: 10,
reference: 'headerForm',
items: [{
xtype: 'fieldcontainer',
layout: 'hbox',
items: [
{ xtype: 'textfield', fieldLabel: '發票號', bind: '{invoiceNo}', flex: 1 },
{ xtype: 'datefield', fieldLabel: '開票日期', bind: '{invoiceDate}', flex: 1 }
]
}]
}, {
// 中部:明細 Grid
region: 'center',
xtype: 'grid',
reference: 'detailGrid',
bind: { store: '{detailStore}' },
plugins: {
ptype: 'cellediting',
clicksToEdit: 1
},
columns: [
{ text: '商品', dataIndex: 'itemName', flex: 2, editor: 'textfield' },
{ text: '數量', dataIndex: 'quantity', width: 80, editor: { xtype: 'numberfield', minValue: 0 } },
{ text: '單價', dataIndex: 'unitPrice', width: 100, editor: { xtype: 'numberfield', minValue: 0 } },
{
text: '稅種',
dataIndex: 'taxType',
width: 120,
editor: {
xtype: 'combobox',
store: {
fields: ['code', 'name', 'rate'],
data: [
{ code: 'VAT13', name: '增值稅 13%', rate: 0.13 },
{ code: 'VAT9', name: '增值稅 9%', rate: 0.09 },
{ code: 'VAT6', name: '增值稅 6%', rate: 0.06 },
{ code: 'FREE', name: '免稅', rate: 0 }
]
},
displayField: 'name',
valueField: 'code',
queryMode: 'local',
listeners: {
// 關鍵:選擇稅種時同步更新 taxRate 字段,觸發 calculate 重算
select: function(combo, record) {
var grid = combo.up('grid');
var editing = grid.getPlugin('cellediting');
var context = editing.getActiveContext();
if (context) {
context.record.set('taxRate', record.get('rate'));
// taxAmount、totalAmount 會自動重算 (calculate 字段)
}
}
}
},
renderer: function(val) {
var store = this.getEditor().getStore();
var rec = store.findRecord('code', val);
return rec ? rec.get('name') : val;
}
},
{ text: '稅率', dataIndex: 'taxRate', width: 80, renderer: function(v) { return (v*100).toFixed(2) + '%'; } },
{ text: '金額', dataIndex: 'amount', width: 100, renderer: Ext.util.Format.numberRenderer('0,0.00') },
{ text: '稅額', dataIndex: 'taxAmount', width: 100, renderer: Ext.util.Format.numberRenderer('0,0.00') },
{ text: '含稅價', dataIndex: 'totalAmount', width: 100, renderer: Ext.util.Format.numberRenderer('0,0.00') },
{
xtype: 'actioncolumn',
width: 50,
items: [{
iconCls: 'x-fa fa-trash',
handler: 'onDeleteRow'
}]
}
],
tbar: [
{ text: '新增行', iconCls: 'x-fa fa-plus', handler: 'onAddRow' }
],
bbar: [
'->',
{
xtype: 'displayfield',
bind: '{totalTaxAmount}',
renderer: function(v) { return '總含稅金額: ' + Ext.util.Format.number(v, '0,0.00'); }
}
]
}],
// Controller 方法 (或 ViewController)
controller: {
onAddRow: function() {
var store = this.getViewModel().getStore('detailStore');
store.add({ itemName: '', quantity: 1, unitPrice: 0, taxType: 'VAT13', taxRate: 0.13 });
},
onDeleteRow: function(grid, rowIndex) {
var store = grid.getStore();
store.removeAt(rowIndex);
}
}
});
| 技術點 | 作用 | 關鍵代碼 |
|---|---|---|
| Model calculate 字段 | 聲明式計算邏輯,依賴字段變化自動重算,無需手動監聽 | calculate: function(data) { return data.quantity * data.unitPrice * data.taxRate; } |
| ComboBox select 事件 | 用戶選擇稅種時,將稅率寫入 record,觸發 calculate | context.record.set('taxRate', record.get('rate')) |
| CellEditing 插件 | 啟用單元格編輯模式,編輯後自動同步到 store | ptype: 'cellediting', clicksToEdit: 1 |
| ViewModel formulas | 總計字段響應式更新,綁定到 bbar displayfield | formulas: { totalTaxAmount: { get: function(get) { ... } } } |
| Store 綁定 | Grid 與 Form 共享同一 store,數據單一來源 | bind: { store: '{detailStore}' } |
若需「修改表頭稅率 → 所有明細行稅率同步更新」:
// ViewModel 添加表頭稅率字段
data: {
headerTaxRate: 0.13
},
formulas: {
// ...
}
// 表頭 Form 綁定
{ xtype: 'combobox', fieldLabel: '默認稅率', bind: '{headerTaxRate}',
store: taxRateStore, displayField: 'name', valueField: 'rate',
listeners: {
change: function(combo, newRate) {
var store = this.getViewModel().getStore('detailStore');
store.each(function(rec) {
// 僅更新未手動修改稅率的行 (可擴展 dirty 追蹤)
if (!rec.dirtyTaxRate) rec.set('taxRate', newRate);
});
}
}
}
ExtJS 實現 Form-Grid 聯動的核心是Model.calculate 聲明式計算字段配合ComboBox 選擇事件同步關鍵字段。利用 MVVM 雙向綁定,稅率變更 → record.set('taxRate', ...) → calculate 自動重算 → Grid 單元格更新 → ViewModel formulas 總計更新,形成完整響應式鏈路,代碼零手動刷新、零監聽雜亂。