mcpField
D
desk-customization

desk-customization

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

关于 desk-customization

面向 Frappe 开发者的 Desk 界面定制技能,涵盖表单脚本、列表视图、对话框和客户端 API。支持通过应用脚本、站点级 Client Script 或 hooks 扩展界面。

主要功能

  1. 01添加表单按钮和自定义操作
  2. 02动态筛选 Link 字段及切换字段可见性
  3. 03构建交互式对话框与提示框
  4. 04定制列表视图指标和批量操作
  5. 05通过 hooks 注入脚本并调用服务器方法

适用场景

  1. 01按条件显示表单操作按钮
  2. 02根据其他字段筛选 Link 字段选项
  3. 03为 DocType 添加保存前校验
  4. 04扩展其他应用的 DocType 界面

安装

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

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

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

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

详细介绍

Frappe Desk 自定义

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

适用场景

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

所需输入

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

操作步骤

0) 选择脚本类型
类型位置受版本控制适用场景
应用级表单脚本<app>/<module>/doctype/<doctype>/<doctype>.js是标准应用行为
Client ScriptDocType:Client Script否(数据库)站点专用自定义
通过钩子注入的脚本通过 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) 通过钩子注入脚本

若要扩展其他应用中的 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
  • 事件未触发:确认事件名称完全匹配(区分大小写)
  • 钩子脚本未生效:检查 hooks.py 中的路径;重新构建资源
  • frappe.call 调用失败:检查方法路径;确认服务端方法有 @frappe.whitelist()

升级处理

  • 服务端控制器逻辑 → doctype-development
  • RPC 端点实现 → api-development
  • Frappe UI(Vue 3)前端 → 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