Skill: chengfen1987/bitable-formula-generator (version 1.1.0)

--- name: bitable-formula-generator description: 飞书多维表格字段公式生成器。通过导出Excel分析字段结构，利用Excel公式逻辑生成多维表格兼容公式，并写入多维表格字段。 --- # 飞书多维表格字段公式生成器 ## 目的 根据用户描述的业务逻辑，自动生成可直接用于飞书多维表格公式字段的公式。 公式语法与 Excel 类似，但只使用多维表格支持的函数，且用**字段名**（而非列字母）引用数据。 --- ## 完整工作流程 ### 第 1 步：获取多维表格结构 优先通过以下方式获取字段信息： 1. **API 自动获取（推荐）**：使用 `scripts/feishu-bitable-api.js` 脚本自动列出表和字段。 2. **导出为 Excel**：用户导出多维表格为 `.xlsx`，读取表头行获取字段名和数据类型。 3. **用户直接提供**：用户列出字段名及数据类型。 #### 使用 API 脚本获取字段信息 脚本路径：`scripts/feishu-bitable-api.js` **前置条件**：需设置环境变量 `FEISHU_APP_ID` 和 `FEISHU_APP_SECRET`。 **列出所有表**： ```bash node scripts/feishu-bitable-api.js list-tables <app_token> ``` **列出指定表的所有字段**（公式字段会显示当前公式预览，`⚠️ 0` 表示公式未设置）： ```bash node scripts/feishu-bitable-api.js list-fields <app_token> <table_name> ``` **查看指定字段的完整 property**（公式字段显示完整公式，单选字段显示所有选项）： ```bash node scripts/feishu-bitable-api.js get-field <app_token> <table_name> <field_name> ``` > `app_token` 可从多维表格 URL 中获取，格式如 `https://xxx.feishu.cn/base/<app_token>`。 从 Excel 读取字段信息时，提取第 1 行（表头）作为字段名列表。 --- ### 第 1b 步：确认依赖字段的公式正确性（重要！） **如果目标公式依赖其他公式字段（中间字段），必须先确认中间字段的公式是否正确，再继续。** ```bash # 查看中间字段的当前公式 node scripts/feishu-bitable-api.js get-field <app_token> <table_name> <中间字段名> ``` ⚠️ 常见陷阱：中间字段可能是硬编码的 `0` 或空公式，会导致最终结果全为 0。 --- ### 第 2 步：生成公式（Excel 风格 → 多维表格风格） #### 2a. 先构造 Excel 公式 借鉴 `excel-formula-generator` 技能的公式模式，基于字段映射（A列=字段1, B列=字段2 ...）构造 Excel 公式原型。 #### 2b. 替换列字母为字段名 将公式中的列引用（如 `B2`、`C2`、`B:B`）替换为对应字段名： | Excel 引用 | 多维表格写法 | |---|---| | `B2` | `字段名` | | `C2` | `另一个字段名` | | `"固定文本"` | 保持不变 | | `数字常量` | 保持不变 | 示例： - Excel: `=(B2-C2)/B2` - 多维表格: `(销售额-成本)/销售额` #### 2c. 函数替换规则（仅使用多维表格支持的函数） 不使用 Excel 专有函数，严格只用多维表格支持的函数（见下方函数列表）。 **常见替换：** | Excel 函数 | 多维表格替代 | |---|---| | `VLOOKUP` | `LOOKUP` | | `XLOOKUP` | `LOOKUP` | | `CONCAT` / `&` | `CONCATENATE` | | `SUMPRODUCT` | 无直接等价，用 `SUM`+`FILTER` 组合 | | `COUNTIFS` | `COUNTIF`（单条件）| | `AVERAGEIF/AVERAGEIFS` | 用 `SUM`/`COUNTA` 手工计算 | | `STDEV` | 不支持，提示用户 | | `PERCENTRANK` | 不支持，提示用户 | | `XLOOKUP/INDEX/MATCH` | `LOOKUP` | --- ### 第 3 步：将公式写入多维表格 #### 方法 A：API 脚本写入（推荐） 使用 `scripts/feishu-bitable-api.js` 脚本直接写入公式字段，支持自动创建新字段或更新已有字段： ```bash node scripts/feishu-bitable-api.js set-formula <app_token> <table_name> <field_name> <formula> [formatter] ``` **参数说明：** - `app_token`：多维表格的 app_token - `table_name`：表名（支持中文，如「客户档案表」） - `field_name`：目标字段名（如「热爱得分」） - `formula`：公式内容（建议用引号包裹） - `formatter`（可选）：数字格式，默认 `0.0`（一位小数）。可选 `""`（整数）、`0.00`（两位小数）、`#,##0.0`（千分位） **示例：** ```bash # 写入公式到「热爱得分」字段 node scripts/feishu-bitable-api.js set-formula MIASw72fgiRMCxkyqhtcuSwVnPf 客户档案表 "热爱得分" "REGEXEXTRACT(时间投入, \"（([0-9]+)分）\")" # 写入公式，保留两位小数 node scripts/feishu-bitable-api.js set-formula MIASw72fgiRMCxkyqhtcuSwVnPf 客户档案表 "毛利率" "(销售额-成本)/销售额" "0.00" ``` > ⚠️ **公式含 `<`、`>`、`|`、`&` 等特殊字符时，不能通过命令行传入**，PowerShell 会截断参数导致 `FormulaFieldPropertyError`。请改用方法 C（脚本文件 PUT 写入）。 > 脚本会自动：通过环境变量获取 access_token → 通过表名查找 table_id → 检查字段是否存在 → 创建或更新字段。 #### 方法 B：手动粘贴 直接输出公式，让用户复制到多维表格公式字段。 #### 方法 C：直接调用 API 使用飞书开放平台 API 写入公式字段（`PUT` 方法）： ``` PUT https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/fields/{field_id} Authorization: Bearer {access_token} Content-Type: application/json { "field_name": "目标字段名", "type": 20, "property": { "formatter": "0.0", "formula_expression": "={公式内容}", "type": { "data_type": 2, "ui_property": { "formatter": "0.0" }, "ui_type": "Number" } } } ``` > - 字段类型 `20` 对应公式字段（Formula） > - 更新已有字段用 `PUT`，创建新字段用 `POST` > - `data_type: 2` 表示数字类型 --- ## 多维表格支持的函数列表 ### 日期函数 `DATE` `DATEDIF` `DAY` `DAYS` `EDATE` `EOMONTH` `HOUR` `MINUTE` `SECOND` `MONTH` `NETWORKDAYS` `NOW` `TODAY` `WEEKDAY` `WEEKNUM` `WORKDAY` `YEAR` `DURATION` `TODATE` ### 逻辑函数 `AND` `CONTAIN` `FALSE` `IF` `IFBLANK` `IFERROR` `IFS` `ISBLANK` `ISERROR` `ISNUMBER` `ISNULL` `MAP` `NOT` `OR` `RANK` `RECORD_ID` `SWITCH` `TRUE` `CONTAINSALL` `CONTAINSONLY` `RANDOMBETWEEN` `RANDOMITEM` ### 文本函数 `CHAR` `CONCATENATE` `CONTAINTEXT` `FIND` `HYPERLINK` `LEFT` `RIGHT` `MID` `LEN` `LOWER` `UPPER` `REPLACE` `SUBSTITUTE` `TEXT` `SPLIT` `TRIM` `ENCODEURL` `FORMAT` `REGEXEXTRACT` `REGEXEXTRACTALL` `REGEXMATCH` `REGEXREPLACE` ### 数字函数 `ABS` `ACOS` `ASIN` `ATAN` `ACOSH` `ASINH` `ATANH` `ATAN2` `AVERAGE` `CEILING` `COS` `SIN` `TAN` `COSH` `SINH` `TANH` `COUNTA` `COUNTIF` `FLOOR` `INT` `ISODD` `MAX` `MIN` `MEDIAN` `MOD` `PI` `POWER` `QUOTIENT` `ROUND` `ROUNDDOWN` `ROUNDUP` `SUM` `SUMIF` `VALUE` `SEQUENCE` ### 位置函数 `DISTANCE` ### 列表/查找函数 `ARRAYJOIN` `FILTER` `FIRST` `LAST` `LIST` `LISTCOMBINE` `LOOKUP` `NTH` `SORT` `SORTBY` `UNIQUE` --- ## 公式语法要点 1. **字段引用**：直接用字段名，如 `销售额`、`时间投入`，字段名有空格也直接使用。 2. **函数调用**：与 Excel 一致，如 `IF(状态="完成", 1, 0)`。 3. **字符串**：用英文双引号 `""`。 4. **四则运算**：`+` `-` `*` `/` `^`。 5. **比较运算**：`=` `<>` `>` `<` `>=` `<=`。 6. **不支持数组公式**（Ctrl+Shift+Enter 那种）。 --- ## 示例交互 ### 示例 1：计算毛利率 **用户：** 字段有"销售额"和"成本"，要计算毛利率 **输出：** ``` IFERROR((销售额-成本)/销售额, 0) ``` > 说明：毛利除以销售额，除零时返回0。将目标字段类型设为"公式"，格式设为百分比。 --- ### 示例 2：根据分数评级 **用户：** 字段"分数"，90以上优秀，70以上良好，60以上合格，否则不合格 **输出：** ``` IFS(分数>=90,"优秀",分数>=70,"良好",分数>=60,"合格",TRUE,"不合格") ``` --- ### 示例 3：计算工龄（年） **用户：** 字段"入职日期"，计算到今天的工龄 **输出：** ``` DATEDIF(入职日期, TODAY(), "Y") ``` --- ### 示例 4：条件拼接文本 **用户：** 字段"姓名"和"部门"，生成"姓名(部门)"格式 **输出：** ``` CONCATENATE(姓名,"(",部门,")") ``` 或简写： ``` 姓名&"("&部门&")" ``` --- ### 示例 5：蔬菜农场-计算总产值 **用户：** 字段"亩产"、"种植面积"、"销售单价"，计算总产值 **输出：** ``` 亩产*种植面积*销售单价 ``` --- ### 示例 6：蔬菜农场-计算亩均收入 **用户：** 字段"总收入"、"总面积"，计算亩均收入 **输出：** ``` IFERROR(总收入/总面积, 0) ``` > 说明：防除零保护，总面积为空或0时返回0。 --- ### 示例 7：检查中间字段公式是否正确 **场景：** 目标公式依赖 `学习投入分数` 字段，先确认它的公式 ```bash node scripts/feishu-bitable-api.js get-field MIASw72fgiRMCxkyqhtcuSwVnPf 客户档案表 学习投入分数 ``` **输出示例（有问题）：** ``` 字段 ID : fldZTgCSLa 类型 : 20（Formula） 公式 : ⚠️ "0"（未设置有效公式！） ``` > 发现公式为 `0`，需先修复该字段，再使用它参与计算。 --- ## 注意事项 - ⚠️ **中文括号不需要转义**：`（` 和 `）` 可以直接写在正则中，无需转义。 - ⚠️ **提取括号内分数的写法**：`VALUE(REGEXEXTRACT(字段名, "（\d+)分）"))` - ⚠️ **不支持的 Excel 函数**：若用户需求涉及 Excel 专有函数（如 `XLOOKUP`、`SUMPRODUCT`、`STDEV`），需告知用户并给出多维表格的替代方案。 - ⚠️ **字段名区分大小写**：请与实际字段名完全一致。 - ⚠️ **关联字段**：引用"关联字段"中的子字段，用 `关联字段名.子字段名` 格式。 - ⚠️ **多选字段**：多选字段返回的是数组，搭配 `ARRAYJOIN`、`CONTAIN` 等函数使用。 - ⚠️ **API 写入时 PowerShell 中文问题**：在 Windows PowerShell 中执行包含中文的 API 调用可能出错，建议使用脚本文件（`.js`）而非直接命令行执行。 - ⚠️ **公式含特殊字符时禁止命令行传参**：公式中含 `<`、`>`、`|`、`&` 等字符时，PowerShell 会将其解析为重定向/管道符导致参数被截断，**必须**将公式写入 `.js` 脚本文件后执行，不能直接通过 `set-formula` 命令行传入。 - ⚠️ **单选字段 IF 匹配前必须检查实际选项**：用 `get-field` 查看字段的真实选项列表，不要假设选项文本唯一——历史录入可能存在前置空格、引号写法差异等脏数据，需用 `OR` 兼容所有变体写法。

path: references/api_reference.md
content: # 多维表格函数 vs Excel 函数对照表

## 完全支持（行为与 Excel 一致）
| 类别 | 函数 |
|---|---|
| 逻辑 | IF, IFS, AND, OR, NOT, TRUE, FALSE, IFERROR, ISBLANK, ISERROR, ISNUMBER, SWITCH |
| 文本 | LEFT, RIGHT, MID, LEN, LOWER, UPPER, TRIM, FIND, SUBSTITUTE, REPLACE, CHAR, TEXT, CONCATENATE |
| 数字 | ABS, SUM, AVERAGE, MAX, MIN, ROUND, ROUNDUP, ROUNDDOWN, FLOOR, CEILING, MOD, INT, POWER, SQRT（无，用POWER替代）, PI, SUMIF, COUNTIF, COUNTA, RANK, MEDIAN |
| 日期 | TODAY, NOW, YEAR, MONTH, DAY, HOUR, MINUTE, SECOND, DATE, DATEDIF, DAYS, EDATE, EOMONTH, WEEKDAY, WEEKNUM, NETWORKDAYS, WORKDAY |
| 数学 | SIN, COS, TAN, ASIN, ACOS, ATAN, ATAN2, SINH, COSH, TANH, ASINH, ACOSH, ATANH |

## 多维表格特有（Excel 无对应）
| 函数 | 说明 |
|---|---|
| CONTAIN | 判断字段是否包含某值（常用于多选字段）|
| CONTAINSALL | 字段包含所有指定值 |
| CONTAINSONLY | 字段仅包含指定值 |
| CONTAINTEXT | 文本字段是否包含某子串 |
| RECORD_ID | 获取当前行的唯一 ID |
| DURATION | 生成时长值 |
| ARRAYJOIN | 将多值字段拼接为字符串 |
| LISTCOMBINE | 合并多组字段 |
| LOOKUP | 跨表/关联字段查找 |
| FILTER | 筛选关联字段记录 |
| SORT / SORTBY | 对关联字段排序 |
| MAP | 字段值映射转换 |
| FORMAT | 模板字符串格式化 |
| REGEXEXTRACT / REGEXEXTRACTALL / REGEXMATCH / REGEXREPLACE | 正则函数 |
| DISTANCE | 两地理坐标间距离 |
| TODATE | 文本转日期 |
| IFBLANK | 等同 IF(ISBLANK(...)) |
| ISNULL | 等同 ISBLANK |
| SEQUENCE | 生成数字序列 |
| RANDOMITEM | 从列表随机选取 |
| RANDOMBETWEEN | 指定范围随机整数 |
| HYPERLINK | 创建超链接 |
| ENCODEURL | URL 编码 |
| SPLIT | 文本分割 |

## Excel 有但多维表格不支持的函数
| Excel 函数 | 说明 | 替代方案 |
|---|---|---|
| VLOOKUP | 垂直查找 | 使用 `LOOKUP` |
| XLOOKUP | 扩展查找 | 使用 `LOOKUP` |
| INDEX | 索引引用 | 使用 `LOOKUP`/`NTH` |
| MATCH | 匹配位置 | 使用 `LOOKUP` |
| HLOOKUP | 水平查找 | 使用 `LOOKUP` |
| SUMIFS | 多条件求和 | 用 `SUMIF` 嵌套或 `SUM(FILTER(...))` |
| COUNTIFS | 多条件计数 | 用 `COUNTIF` 嵌套 |
| AVERAGEIF/AVERAGEIFS | 条件均值 | `=SUMIF(...)/COUNTIF(...)` |
| CONCAT | 文本连接 | `CONCATENATE` |
| TEXTJOIN | 带分隔符连接 | `ARRAYJOIN` |
| STDEV/STDEVP | 标准差 | 不支持，提示用户 |
| VAR/VARP | 方差 | 不支持，提示用户 |
| PERCENTRANK | 百分位排名 | 不支持，提示用户 |
| INDIRECT | 间接引用 | 不支持 |
| OFFSET | 偏移引用 | 不支持 |
| CHOOSE | 按序号选择 | 用 `IFS` 或 `SWITCH` 替代 |
| ISNUMBER | 判断数字 | 多维表格支持 `ISNUMBER` ✅ |
| ISERR | 判断错误 | 用 `ISERROR` |
| EXACT | 精确匹配（区分大小写）| 不支持，用 `=` 比较（不区分大小写）|
| PROPER | 首字母大写 | 不支持 |

## 字段引用语法
```
普通字段：     字段名
关联字段：     关联字段.子字段名
多选字段判断：  CONTAIN(多选字段, "选项A")
多选转文本：   ARRAYJOIN(多选字段, ",")
```


path: scripts/feishu-bitable-api.js
content: /**
 * 飞书多维表格 API 工具脚本
 *
 * 功能：
 *   1. 获取 tenant_access_token（通过环境变量 FEISHU_APP_ID / FEISHU_APP_SECRET）
 *   2. 列出多维表格中所有表（tables）
 *   3. 列出指定表的所有字段（fields）
 *   4. 查看指定字段的完整 property
 *   5. 创建/更新公式字段
 *
 * 用法：
 *   node feishu-bitable-api.js <command> [args...]
 *
 * 命令：
 *   list-tables  <app_token>                                        列出所有表
 *   list-fields  <app_token> <table_name>                          列出指定表的所有字段（公式字段显示当前公式预览）
 *   get-field    <app_token> <table_name> <field_name>             查看指定字段的完整 property（含公式内容）
 *   set-formula  <app_token> <table_name> <field_name> <formula> [formatter]
 *                                                                   设置/更新公式字段
 *
 * 环境变量：
 *   FEISHU_APP_ID       飞书应用 App ID
 *   FEISHU_APP_SECRET   飞书应用 App Secret
 *
 * 示例：
 *   node feishu-bitable-api.js list-tables <your_app_token>
 *   node feishu-bitable-api.js list-fields <your_app_token> 客户档案表
 *   node feishu-bitable-api.js get-field   <your_app_token> 客户档案表 学习投入分数
 *   node feishu-bitable-api.js set-formula <your_app_token> 客户档案表 热爱得分 "(时间投入+想做什么)/15*100"
 *   node feishu-bitable-api.js set-formula <your_app_token> 客户档案表 热爱得分 "(A+B)/C*100" "0.00"
 *
 * ⚠️  公式含 <、>、|、& 等特殊字符时，PowerShell 会截断参数，请改用 .js 脚本文件传入公式。
 */

const https = require('https');

// ==================== 帮助文档 ====================

const USAGE = `
飞书多维表格 API 工具

用法：
  node feishu-bitable-api.js <command> [args...]

命令：
  list-tables  <app_token>
      列出所有表

  list-fields  <app_token> <table_name>
      列出指定表的所有字段（公式字段显示当前公式预览）

  get-field    <app_token> <table_name> <field_name>
      查看指定字段的完整 property（含公式内容 / 选项列表）

  set-formula  <app_token> <table_name> <field_name> <formula> [formatter]
      设置/更新公式字段，formatter 默认 "0.0"

环境变量：
  FEISHU_APP_ID       飞书应用 App ID
  FEISHU_APP_SECRET   飞书应用 App Secret

注意事项：
  ⚠️ 公式含 <、>、|、& 等字符时，PowerShell 会截断参数，
     请改用 .js 脚本文件传入公式（见 SKILL.md 方法 C）。
`.trim();

// 参数数量要求（不含命令本身）
const REQUIRED_ARGS = {
  'list-tables': 1,
  'list-fields': 2,
  'get-field':   3,
  'set-formula': 4,
};

// ==================== 通用 HTTP 请求 ====================

/**
 * 发送 HTTPS 请求到飞书开放平台。
 * @param {string} method  HTTP 方法（GET / POST / PUT）
 * @param {string} path    API 路径
 * @param {object} [body]  请求体（可选）
 * @param {string} [token] Bearer token；若不传则不添加 Authorization 头（用于获取 token 本身）
 */
function request(method, path, body, token) {
  return new Promise((resolve, reject) => {
    const payload = body ? JSON.stringify(body) : null;
    const headers = { 'Content-Type': 'application/json' };

    // 只在 token 明确传入时才加 Authorization 头，避免携带 undefined
    if (token) {
      headers['Authorization'] = 'Bearer ' + token;
    }
    if (payload) {
      headers['Content-Length'] = Buffer.byteLength(payload, 'utf8');
    }

    const options = {
      hostname: 'open.feishu.cn',
      path,
      method,
      headers,
    };

    const req = https.request(options, (res) => {
      let data = '';
      res.on('data', (chunk) => data += chunk);
      res.on('end', () => {
        try {
          const result = JSON.parse(data);
          if (result.code === 0) {
            resolve(result);
          } else {
            reject(new Error(`API Error [${result.code}]: ${result.msg}`));
          }
        } catch (e) {
          reject(new Error('Invalid JSON response: ' + data));
        }
      });
    });

    req.on('error', reject);
    if (payload) req.write(payload, 'utf8');
    req.end();
  });
}

// ==================== 获取 Access [REDACTED_SECRET]

/**
 * 从飞书获取 tenant_access_token 并存入 global.accessToken。
 * 同时记录过期时间，供后续自动刷新使用。
 */
async function getAccessToken() {
  const appId = process.env.FEISHU_APP_ID;
  const appSecret = process.env.FEISHU_APP_SECRET;

  if (!appId || !appSecret) {
    console.error('❌ 请设置环境变量 FEISHU_APP_ID 和 FEISHU_APP_SECRET');
    process.exit(1);
  }

  // 注意：获取 token 的接口不需要 Bearer 认证，不传 token 参数
  const result = await request('POST', '/open-apis/auth/v3/tenant_access_token/internal', {
    app_id: appId,
    app_secret: appSecret,
  });

  global.accessToken = result.tenant_access_token;
  // 记录过期时间（提前 60 秒刷新）
  global.tokenExpireAt = Date.now() + (result.expire - 60) * 1000;
  console.log(`✅ Access Token 获取成功 (有效期 ${result.expire}s)\n`);
}

/**
 * 确保 token 有效，过期则自动刷新。
 */
async function ensureToken() {
  if (!global.accessToken || Date.now() >= (global.tokenExpireAt || 0)) {
    await getAccessToken();
  }
}

/**
 * 带 token 的 API 请求（自动刷新 token）。
 */
async function apiRequest(method, path, body) {
  await ensureToken();
  return request(method, path, body, global.accessToken);
}

// ==================== 分页请求封装 ====================

/**
 * 自动翻页，返回所有 items。
 * @param {string} basePath  API 路径（不含 page_token 参数）
 */
async function fetchAllPages(basePath) {
  let items = [];
  let pageToken = null;

  do {
    const path = pageToken
      ? `${basePath}${basePath.includes('?') ? '&' : '?'}page_token=${encodeURIComponent(pageToken)}`
      : basePath;

    const res = await apiRequest('GET', path);
    const data = res.data || {};
    items = items.concat(data.items || []);
    pageToken = data.has_more ? data.page_token : null;
  } while (pageToken);

  return items;
}

// ==================== 公共辅助：查找 table_id ====================

/**
 * 根据表名找到对应的 table 对象，找不到时打印可用表并退出。
 */
async function resolveTable(appToken, tableName) {
  const tables = await fetchAllPages(`/open-apis/bitable/v1/apps/${appToken}/tables`);
  const table = tables.find(t => t.name === tableName);

  if (!table) {
    console.error(`❌ 未找到表「${tableName}」`);
    console.log('可用的表：');
    tables.forEach((t, i) => console.log(`  ${i + 1}. ${t.name}`));
    process.exit(1);
  }

  return table;
}

// ==================== 列出所有表 ====================

async function listTables(appToken) {
  console.log(`📋 获取多维表格 [${appToken}] 的所有表...\n`);
  const tables = await fetchAllPages(`/open-apis/bitable/v1/apps/${appToken}/tables`);

  console.log('序号\tTable ID\t\t\t表名');
  console.log('─'.repeat(60));
  tables.forEach((t, i) => {
    console.log(`${i + 1}\t${t.table_id}\t${t.name}`);
  });
  console.log(`\n共 ${tables.length} 个表`);
  return tables;
}

// ==================== 列出指定表的所有字段 ====================

async function listFields(appToken, tableName) {
  const table = await resolveTable(appToken, tableName);
  console.log(`📋 获取表「${tableName}」(${table.table_id}) 的所有字段...\n`);

  const fields = await fetchAllPages(
    `/open-apis/bitable/v1/apps/${appToken}/tables/${table.table_id}/fields`
  );

  console.log('字段名\t\t\t\tField ID\t\t类型\t\t公式预览');
  console.log('─'.repeat(100));
  fields.forEach(f => {
    // type 20 = 公式字段
    let formulaHint = '';
    if (f.type === 20 && f.property && f.property.formula_expression) {
      const expr = f.property.formula_expression;
      if (expr === '0' || expr.trim() === '0') {
        formulaHint = '⚠️  0（未设置公式！）';
      } else {
        formulaHint = expr.length > 60 ? expr.slice(0, 60) + '…' : expr;
      }
    }
    const typeName = f.ui_type || (f.type === 20 ? 'Formula' : String(f.type));
    console.log(`${f.field_name.padEnd(20)}\t${f.field_id}\t${typeName.padEnd(12)}\t${formulaHint}`);
  });
  console.log(`\n共 ${fields.length} 个字段`);
  return fields;
}

// ==================== 查看指定字段的完整 property ====================

async function getField(appToken, tableName, fieldName) {
  const table = await resolveTable(appToken, tableName);

  const fields = await fetchAllPages(
    `/open-apis/bitable/v1/apps/${appToken}/tables/${table.table_id}/fields`
  );
  const field = fields.find(f => f.field_name === fieldName);

  if (!field) {
    console.error(`❌ 未找到字段「${fieldName}」`);
    console.log('可用的字段：');
    fields.forEach((f, i) => console.log(`  ${i + 1}. ${f.field_name}`));
    process.exit(1);
  }

  console.log(`📋 字段「${fieldName}」详情：\n`);
  console.log(`  字段 ID   : ${field.field_id}`);
  console.log(`  类型      : ${field.type}（${field.ui_type || ''}）`);

  if (field.type === 20) {
    const expr = field.property && field.property.formula_expression;
    const fmt  = field.property && field.property.formatter;
    console.log(`  格式      : ${fmt || '（无）'}`);
    if (!expr || expr === '0' || expr.trim() === '0') {
      console.log(`  公式      : ⚠️  "${expr}"（未设置有效公式！）`);
    } else {
      console.log(`  公式      :\n${expr}`);
    }
  } else if (field.property && field.property.options) {
    console.log(`\n  选项列表（共 ${field.property.options.length} 个）：`);
    field.property.options.forEach((o, i) => {
      console.log(`    ${i + 1}. [${o.id}] ${o.name}`);
    });
  } else {
    console.log(`\n  property  :\n${JSON.stringify(field.property, null, 2)}`);
  }

  return field;
}

// ==================== 设置/更新公式字段 ====================

async function setFormula(appToken, tableName, fieldName, formula, formatter) {
  const table = await resolveTable(appToken, tableName);

  // 检查字段是否已存在
  const fields = await fetchAllPages(
    `/open-apis/bitable/v1/apps/${appToken}/tables/${table.table_id}/fields`
  );
  const existingField = fields.find(f => f.field_name === fieldName);

  // 根据是否提供 formatter 决定 property 结构：
  //   - 有 formatter（数字格式）：添加 type 和格式信息
  //   - 无 formatter（文本/自动）：只写 formula_expression，避免类型冲突
  const hasFormatter = formatter && formatter.trim() !== '';
  const property = hasFormatter
    ? {
        formatter: formatter,
        formula_expression: formula,
        type: {
          data_type: 2,
          ui_property: { formatter: formatter },
          ui_type: 'Number',
        },
      }
    : {
        formula_expression: formula,
      };

  const body = {
    field_name: fieldName,
    type: 20, // 20 = 公式类型
    property,
  };

  let method, path, action;

  if (existingField) {
    method = 'PUT';
    path = `/open-apis/bitable/v1/apps/${appToken}/tables/${table.table_id}/fields/${existingField.field_id}`;
    action = '更新';
  } else {
    method = 'POST';
    path = `/open-apis/bitable/v1/apps/${appToken}/tables/${table.table_id}/fields`;
    action = '创建';
  }

  console.log(`${action}公式字段「${fieldName}」...`);
  console.log(`表: ${tableName} (${table.table_id})`);
  console.log(`公式: ${formula}`);
  console.log(`格式: ${hasFormatter ? formatter : '（自动）'}\n`);

  const result = await apiRequest(method, path, body);
  console.log(`✅ ${action}成功！`);
  console.log(`   字段 ID: ${result.data.field.field_id}`);
  console.log(`   字段名: ${result.data.field.field_name}`);
  console.log(`   公式: ${result.data.field.property.formula_expression}`);

  return result.data.field;
}

// ==================== 主入口 ====================

async function main() {
  const args = process.argv.slice(2);
  const command = args[0];

  // 帮助
  if (!command || command === 'help' || command === '--help' || command === '-h') {
    console.log(USAGE);
    process.exit(0);
  }

  // 参数数量校验
  const minArgs = REQUIRED_ARGS[command];
  if (minArgs === undefined) {
    console.error(`❌ 未知命令: ${command}`);
    console.log('可用命令: list-tables, list-fields, get-field, set-formula, help');
    process.exit(1);
  }
  if (args.length - 1 < minArgs) {
    console.error(`❌ 命令 "${command}" 需要 ${minArgs} 个参数，实际提供了 ${args.length - 1} 个`);
    console.log(`\n运行 "node feishu-bitable-api.js help" 查看用法`);
    process.exit(1);
  }

  // 获取 token（仅一次，后续通过 ensureToken 自动刷新）
  await getAccessToken();

  try {
    switch (command) {
      case 'list-tables':
        await listTables(args[1]);
        break;

      case 'list-fields':
        await listFields(args[1], args[2]);
        break;

      case 'get-field':
        await getField(args[1], args[2], args[3]);
        break;

      case 'set-formula':
        // args[5] 为可选的 formatter，不传则默认 undefined（由 setFormula 判断）
        await setFormula(args[1], args[2], args[3], args[4], args[5]);
        break;
    }
  } catch (err) {
    console.error(`❌ ${err.message}`);
    process.exit(1);
  }
}

main();


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (HIGH)