mcpField
F
frappe-desk-customization

frappe-desk-customization

github.com/lubusin开发69243 次安装12 周前更新
代码仓库

关于 frappe-desk-customization

面向 Frappe Desk 开发者的界面定制指南,涵盖表单脚本、列表视图、对话框和客户端 API。适用于按站点或应用需求扩展管理界面交互。

主要功能

  1. 01为表单添加按钮和客户端校验
  2. 02动态筛选 Link 字段及控制字段显示
  3. 03构建对话框并调用服务端方法
  4. 04定制列表视图标记和批量操作
  5. 05通过 hooks 注入脚本并监听实时事件

适用场景

  1. 01按条件显示表单操作按钮
  2. 02根据字段值筛选关联记录
  3. 03为用户收集输入并调用 RPC
  4. 04定制列表状态标记或操作

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/lubusin/frappe-skills --skill frappe-desk-customization

安装后在 Claude Code 中输入/frappe-desk-customization确认已加载。

附带文件references/desk.mdreferences/js-api.md

详细介绍

Frappe Desk 自定义

通过表单脚本、列表视图、对话框和客户端 API 自定义 Frappe Desk 管理界面。

使用时机

  • 为表单添加自定义按钮或操作
  • 动态筛选 Link 字段
  • 根据条件切换字段可见性
  • 自定义列表视图指示器和批量操作
  • 构建交互式对话框和提示词
  • 在保存前添加客户端验证
  • 通过 hooks 向其他应用的 DocType 注入脚本

所需输入

  • 要自定义的目标 DocType
  • 脚本是应用级(受版本控制)还是 Client Script(特定站点)
  • 要挂接的事件(refresh、validate、字段变更等)
  • UI 行为要求(按钮、筛选器、可见性)

流程

0)选择脚本类型
类型位置受版本控制使用场景
应用级表单脚本<app>/<module>/doctype/<doctype>/<doctype>.js是标准应用行为
Client ScriptDocType:Client Script否(DB)特定站点的自定义
通过 hook 注入的脚本通过 hooks.py 中的 doctype_js是扩展其他应用的 DocType
1)编写表单脚本
frappe.ui.form.on("My DocType", {
    // Called once during form setup
    setup(frm) {
        frm.set_query("customer", function() {
            return {
                filters: { "status": "Active" }
            };
        });
    },

    // Called every time form loads or refreshes
    refresh(frm) {
        if (frm.doc.status === "Draft") {
            frm.add_custom_button(__("Submit for Review"), function() {
                frappe.call({
                    method: "my_app.api.submit_for_review",
                    args: { name: frm.doc.name },
                    callback(r) {
                        frm.reload_doc();
                    }
                });
            }, __("Actions"));
        }

        // Toggle field visibility
        frm.toggle_display("discount_section", frm.doc.grand_total > 1000);

        // Set field properties
        frm.set_df_property("notes", "read_only", frm.doc.docstatus === 1);
    },

    // Called before save — return false to cancel
    validate(frm) {
        if (frm.doc.end_date < frm.doc.start_date) {
            frappe.msgprint(__("End date must be after start date"));
            frappe.validated = false;
        }
    },

    // Field change handler (use fieldname as key)
    customer(frm) {
        if (frm.doc.customer) {
            frappe.db.get_value("Customer", frm.doc.customer, "territory",
                function(r) {
                    frm.set_value("territory", r.territory);
                }
            );
        }
    },

    // Before save hook
    before_save(frm) {
        frm.doc.full_name = `${frm.doc.first_name} ${frm.doc.last_name}`;
    },

    // After save hook
    after_save(frm) {
        frappe.show_alert({
            message: __("Document saved successfully"),
            indicator: "green"
        });
    }
});

// Child table events
frappe.ui.form.on("My DocType Item", {
    qty(frm, cdt, cdn) {
        let row = locals[cdt][cdn];
        frappe.model.set_value(cdt, cdn, "amount", row.qty * row.rate);
        calculate_total(frm);
    },

    items_remove(frm) {
        calculate_total(frm);
    }
});

function calculate_total(frm) {
    let total = 0;
    (frm.doc.items || []).forEach(row => {
        total += row.amount || 0;
    });
    frm.set_value("grand_total", total);
}
2)构建对话框和提示词
// Simple prompt
frappe.prompt(
    { fieldname: "reason", fieldtype: "Small Text", label: "Reason", reqd: 1 },
    function(values) {
        frappe.call({
            method: "my_app.api.reject",
            args: { name: frm.doc.name, reason: values.reason }
        });
    },
    __("Rejection Reason"),
    __("Reject")
);

// Multi-field dialog
let d = new frappe.ui.Dialog({
    title: __("Configure Settings"),
    fields: [
        { fieldname: "email", fieldtype: "Data", options: "Email", label: "Email", reqd: 1 },
        { fieldname: "frequency", fieldtype: "Select", options: "Daily\nWeekly\nMonthly", label: "Frequency" },
        { fieldname: "active", fieldtype: "Check", label: "Active", default: 1 }
    ],
    primary_action_label: __("Save"),
    primary_action(values) {
        frappe.call({
            method: "my_app.api.save_settings",
            args: values,
            callback() {
                d.hide();
                frappe.show_alert({ message: __("Settings saved"), indicator: "green" });
            }
        });
    }
});
d.show();

// Confirmation dialog
frappe.confirm(
    __("Are you sure you want to delete this?"),
    function() { /* Yes */ },
    function() { /* No */ }
);
3)发起服务器调用
// Standard call (callback)
frappe.call({
    method: "my_app.api.get_stats",
    args: { customer: frm.doc.customer },
    freeze: true,
    freeze_message: __("Loading..."),
    callback(r) {
        if (r.message) {
            frm.set_value("total_orders", r.message.total);
        }
    }
});

// Promise-based call
let result = await frappe.xcall("my_app.api.get_stats", {
    customer: frm.doc.customer
});
4)自定义列表视图
// my_app/public/js/sample_doc_list.js
// or via hooks: doctype_list_js = {"Sample Doc": "public/js/sample_doc_list.js"}

frappe.listview_settings["Sample Doc"] = {
    // Status indicator colors
    get_indicator(doc) {
        if (doc.status === "Open") return [__("Open"), "orange", "status,=,Open"];
        if (doc.status === "Closed") return [__("Closed"), "green", "status,=,Closed"];
        return [__("Draft"), "grey", "status,=,Draft"];
    },

    // Add bulk actions
    onload(listview) {
        listview.page.add_action_item(__("Mark as Closed"), function() {
            let names = listview.get_checked_items(true);
            frappe.call({
                method: "my_app.api.bulk_close",
                args: { names },
                callback() { listview.refresh(); }
            });
        });
    },

    // Hide default "New" button
    hide_name_column: true
};
5)使用实时事件
// Listen for server-side events
frappe.realtime.on("export_complete", function(data) {
    frappe.show_alert({
        message: __("Export complete: {0} records", [data.count]),
        indicator: "green"
    });
});
6)通过 hooks 注入脚本

若要扩展其他应用中的 DocType,而不修改它:

# hooks.py
doctype_js = {
    "Sales Order": "public/js/sales_order_custom.js"
}

doctype_list_js = {
    "Sales Order": "public/js/sales_order_list_custom.js"
}
# Rebuild assets after adding hook scripts
bench build --app my_app
7)导航和路由
// Navigate to a document
frappe.set_route("Form", "Sales Order", "SO-001");

// Navigate to list with filters
frappe.route_options = { "status": "Open" };
frappe.set_route("List", "Sales Order");

// Get current route
let route = frappe.get_route();

验证

  • 表单脚本加载时没有 JS 控制台错误
  • 自定义按钮在正确的条件下显示
  • 字段可见性切换正常
  • Link 字段筛选器返回正确选项
  • 验证可阻止无效保存
  • 列表视图指示器正确显示
  • 对话框可打开、收集输入并提交

故障模式 / 调试

  • 脚本未加载:检查文件路径是否与 DocType 匹配;运行 bench build
  • 按钮未显示:检查 refresh 中的条件逻辑;验证 frm.doc.docstatus
  • 事件未触发:确认事件名称完全匹配(区分大小写)
  • Hook 脚本被忽略:检查 hooks.py 路径;重新构建资源
  • frappe.call 失败:检查方法路径;确认服务器端方法使用了 @frappe.whitelist()

升级处理

  • 服务器端控制器逻辑 → frappe-doctype-development
  • RPC 端点实现 → frappe-api-development
  • Frappe UI(Vue 3)前端 → frappe-frontend-development

参考资料

使用规范

  • 使用 frm.doc,不要直接使用 doc:始终通过 frm.doc 访问文档,以保持一致性和响应性
  • 保存前进行验证:在 validate 事件中使用 frm.validate(),不要在 before_save 中使用
  • 注意异步操作:frappe.call() 是异步的;若要按顺序执行操作,请使用回调或 async/await
  • 字段变更后刷新:以编程方式修改后调用 frm.refresh_field() 或 frm.refresh_fields()
  • 适当检查 frm.is_new():某些操作仅适用于已保存的文档

常见错误

错误失败原因修复方法
set_value 后未调用 frm.refresh_field()UI 不会更新调用 frm.set_value() 后调用 frm.refresh_field('fieldname')
事件挂钩名称错误事件永远不会触发使用准确名称:refresh、validate、onload、before_save
使用同步调用阻塞 UI页面冻结使用 frappe.call(),并设置 async: true(默认值)
使用 cur_frm 而非 frm在对话框/多个表单中会出错始终使用传递给处理程序的 frm 参数
未检查 frm.doc.docstatus按钮会显示在已提交的文档上显示编辑操作前检查 frm.doc.docstatus == 0
console.log(frm.doc) 显示过时数据造成调试困惑使用 frm.reload_doc() 或检查网络响应

评论

支持 Markdown