fix: clean default Feishu bitable scaffold

This commit is contained in:
wxs 2026-05-12 13:04:53 +08:00
parent baaa4d5d81
commit ca6db50215
2 changed files with 342 additions and 7 deletions

View File

@ -909,6 +909,236 @@
return createdFields;
}
async function updateFeishuBitableTextField(options) {
const settings = options || {};
const token = settings.tenantAccessToken;
const appToken = settings.appToken;
const tableId = settings.tableId;
const fieldId = settings.fieldId;
const fieldName = settings.fieldName;
if (!token || !appToken || !tableId || !fieldId || !fieldName) {
throw new Error("缺少飞书多维表格字段更新参数。");
}
const json = await feishuApiRequest(
`/bitable/v1/apps/${encodeURIComponent(appToken)}/tables/${encodeURIComponent(
tableId,
)}/fields/${encodeURIComponent(fieldId)}`,
{
method: "PUT",
headers: {
Authorization: `Bearer ${token}`,
},
body: {
field_name: fieldName,
type: 1,
},
fetchImpl: settings.fetchImpl,
actionName: `更新飞书多维表格字段 ${fieldName}`,
},
);
return json?.data?.field || json?.data || {};
}
async function listFeishuBitableFields(options) {
const settings = options || {};
const token = settings.tenantAccessToken;
const appToken = settings.appToken;
const tableId = settings.tableId;
if (!token || !appToken || !tableId) {
throw new Error("缺少飞书多维表格字段查询参数。");
}
const json = await feishuApiRequest(
`/bitable/v1/apps/${encodeURIComponent(appToken)}/tables/${encodeURIComponent(
tableId,
)}/fields`,
{
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
},
fetchImpl: settings.fetchImpl,
actionName: "获取飞书多维表格字段",
},
);
return json?.data?.items || json?.data?.fields || [];
}
function pickReusableFeishuBitablePrimaryField(fields) {
const list = Array.isArray(fields) ? fields : [];
return (
list.find((field) => field.is_primary || field.isPrimary) ||
list.find((field) => field.field_id || field.fieldId || field.id) ||
null
);
}
async function prepareFeishuBitableFields(options) {
const settings = options || {};
const fields = Array.isArray(settings.fields) ? settings.fields : [];
if (!fields.length) {
return [];
}
const existingFields = await listFeishuBitableFields(settings);
const reusableField = pickReusableFeishuBitablePrimaryField(existingFields);
const createdFields = [];
let remainingFields = fields.slice();
if (reusableField) {
const fieldId = reusableField.field_id || reusableField.fieldId || reusableField.id;
createdFields.push(
await updateFeishuBitableTextField({
...settings,
fieldId,
fieldName: getFieldLabel(fields[0]),
}),
);
remainingFields = fields.slice(1);
}
createdFields.push(
...(await createFeishuBitableTextFields({
...settings,
fields: remainingFields,
})),
);
return createdFields;
}
async function deleteFeishuBitableField(options) {
const settings = options || {};
const token = settings.tenantAccessToken;
const appToken = settings.appToken;
const tableId = settings.tableId;
const fieldId = settings.fieldId;
if (!token || !appToken || !tableId || !fieldId) {
throw new Error("缺少飞书多维表格字段删除参数。");
}
await feishuApiRequest(
`/bitable/v1/apps/${encodeURIComponent(appToken)}/tables/${encodeURIComponent(
tableId,
)}/fields/${encodeURIComponent(fieldId)}`,
{
method: "DELETE",
headers: {
Authorization: `Bearer ${token}`,
},
fetchImpl: settings.fetchImpl,
actionName: "删除飞书多维表格默认字段",
},
);
}
async function deleteFeishuBitableExtraFields(options) {
const settings = options || {};
const selectedLabels = new Set(
(Array.isArray(settings.fields) ? settings.fields : []).map((field) =>
getFieldLabel(field),
),
);
const fields = await listFeishuBitableFields(settings);
const removableFields = fields.filter((field) => {
const fieldName = field.field_name || field.fieldName || field.name;
const fieldId = field.field_id || field.fieldId || field.id;
return fieldId && fieldName && !selectedLabels.has(fieldName);
});
for (const field of removableFields) {
await deleteFeishuBitableField({
...settings,
fieldId: field.field_id || field.fieldId || field.id,
});
}
return { deletedCount: removableFields.length };
}
async function listFeishuBitableRecords(options) {
const settings = options || {};
const token = settings.tenantAccessToken;
const appToken = settings.appToken;
const tableId = settings.tableId;
if (!token || !appToken || !tableId) {
throw new Error("缺少飞书多维表格记录查询参数。");
}
const records = [];
let pageToken = "";
do {
const suffix = pageToken ? `?page_token=${encodeURIComponent(pageToken)}` : "";
const json = await feishuApiRequest(
`/bitable/v1/apps/${encodeURIComponent(appToken)}/tables/${encodeURIComponent(
tableId,
)}/records${suffix}`,
{
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
},
fetchImpl: settings.fetchImpl,
actionName: "获取飞书多维表格默认记录",
},
);
const data = json?.data || {};
records.push(...(data.items || data.records || []));
pageToken = data.has_more ? data.page_token || "" : "";
} while (pageToken);
return records;
}
async function deleteFeishuBitableRecords(options) {
const settings = options || {};
const token = settings.tenantAccessToken;
const appToken = settings.appToken;
const tableId = settings.tableId;
const recordIds = Array.isArray(settings.recordIds) ? settings.recordIds : [];
if (!token || !appToken || !tableId) {
throw new Error("缺少飞书多维表格记录删除参数。");
}
if (!recordIds.length) {
return { deletedCount: 0 };
}
const batchSize = 500;
let deletedCount = 0;
for (let index = 0; index < recordIds.length; index += batchSize) {
const batch = recordIds.slice(index, index + batchSize);
await feishuApiRequest(
`/bitable/v1/apps/${encodeURIComponent(appToken)}/tables/${encodeURIComponent(
tableId,
)}/records/batch_delete`,
{
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
},
body: {
records: batch,
},
fetchImpl: settings.fetchImpl,
actionName: "删除飞书多维表格默认记录",
},
);
deletedCount += batch.length;
}
return { deletedCount };
}
async function deleteFeishuBitableDefaultRecords(options) {
const records = await listFeishuBitableRecords(options);
const recordIds = records
.map((record) => record.record_id || record.recordId || record.id)
.filter(Boolean);
return deleteFeishuBitableRecords({
...options,
recordIds,
});
}
async function writeFeishuBitableRecords(options) {
const settings = options || {};
const token = settings.tenantAccessToken;
@ -970,13 +1200,26 @@
title: settings.title,
fetchImpl,
});
await createFeishuBitableTextFields({
await prepareFeishuBitableFields({
tenantAccessToken,
appToken: bitable.appToken,
tableId: bitable.tableId,
fields,
fetchImpl,
});
await deleteFeishuBitableExtraFields({
tenantAccessToken,
appToken: bitable.appToken,
tableId: bitable.tableId,
fields,
fetchImpl,
});
await deleteFeishuBitableDefaultRecords({
tenantAccessToken,
appToken: bitable.appToken,
tableId: bitable.tableId,
fetchImpl,
});
const bitableRecords = buildFeishuBitableRecords(records, fields);
const writeResult = await writeFeishuBitableRecords({
tenantAccessToken,
@ -2444,6 +2687,10 @@
createFeishuBitableTextFields,
createExportController,
createFeishuSpreadsheet,
deleteFeishuBitableDefaultRecords,
deleteFeishuBitableExtraFields,
deleteFeishuBitableField,
deleteFeishuBitableRecords,
extractBloggerId,
exportRecordsToFeishuBitable,
exportRecordsToFeishuSpreadsheet,
@ -2452,7 +2699,11 @@
getFieldLabel,
getFeishuFirstSheetId,
getFeishuTenantAccessToken,
listFeishuBitableFields,
listFeishuBitableRecords,
parseCreatorInputs,
prepareFeishuBitableFields,
updateFeishuBitableTextField,
writeFeishuBitableRecords,
writeFeishuSheetValues,
};

View File

@ -195,6 +195,7 @@ test("exportRecordsToFeishuSpreadsheet creates spreadsheet then writes values",
test("exportRecordsToFeishuBitable creates app fields and records", async () => {
const calls = [];
let fieldsGetCount = 0;
async function fetchImpl(url, options) {
const body = options && options.body ? JSON.parse(options.body) : null;
@ -220,9 +221,57 @@ test("exportRecordsToFeishuBitable creates app fields and records", async () =>
},
});
}
if (String(url).includes("/fields")) {
if (String(url).endsWith("/fields") && options.method === "POST") {
return okJson({ code: 0, data: { field: { field_id: "fld-created" } } });
}
if (String(url).includes("/fields/") && options.method === "PUT") {
return okJson({ code: 0, data: { field: { field_id: "fld-default-text" } } });
}
if (String(url).endsWith("/fields") && options.method === "GET") {
fieldsGetCount += 1;
if (fieldsGetCount === 1) {
return okJson({
code: 0,
data: {
items: [
{ field_id: "fld-default-text", field_name: "文本", is_primary: true },
{ field_id: "fld-default-select", field_name: "单选" },
{ field_id: "fld-default-date", field_name: "日期" },
{ field_id: "fld-default-attachment", field_name: "附件" },
],
},
});
}
return okJson({
code: 0,
data: {
items: [
{ field_id: "fld-default-text", field_name: "达人ID", is_primary: true },
{ field_id: "fld-default-select", field_name: "单选" },
{ field_id: "fld-default-date", field_name: "日期" },
{ field_id: "fld-default-attachment", field_name: "附件" },
{ field_id: "fld-name", field_name: "达人昵称" },
],
},
});
}
if (String(url).includes("/fields/")) {
return okJson({ code: 0, data: {} });
}
if (String(url).endsWith("/records") && options.method === "GET") {
return okJson({
code: 0,
data: {
items: Array.from({ length: 10 }, (_, index) => ({
record_id: `rec-empty-${index + 1}`,
fields: {},
})),
},
});
}
if (String(url).includes("/records/batch_delete")) {
return okJson({ code: 0, data: {} });
}
if (String(url).includes("/records/batch_create")) {
return okJson({ code: 0, data: { records: [{ record_id: "rec-1" }] } });
}
@ -250,7 +299,7 @@ test("exportRecordsToFeishuBitable creates app fields and records", async () =>
assert.equal(result.tableId, "tbl-default");
assert.equal(result.url, "https://feishu.example/base-token");
assert.equal(result.rowCount, 1);
assert.equal(calls.length, 5);
assert.equal(calls.length, 12);
assert.deepEqual(calls[0].body, {
app_id: "cli_xxx",
app_secret: "secret",
@ -259,21 +308,56 @@ test("exportRecordsToFeishuBitable creates app fields and records", async () =>
name: "测试多维表格",
});
assert.equal(calls[2].url.endsWith("/bitable/v1/apps/base-token/tables/tbl-default/fields"), true);
assert.deepEqual(calls[2].body, {
assert.equal(calls[2].method, "GET");
assert.equal(calls[3].url.endsWith("/fields/fld-default-text"), true);
assert.equal(calls[3].method, "PUT");
assert.deepEqual(calls[3].body, {
field_name: "达人ID",
type: 1,
});
assert.deepEqual(calls[3].body, {
assert.equal(calls[4].url.endsWith("/bitable/v1/apps/base-token/tables/tbl-default/fields"), true);
assert.equal(calls[4].method, "POST");
assert.deepEqual(calls[4].body, {
field_name: "达人昵称",
type: 1,
});
assert.equal(calls[5].url.endsWith("/bitable/v1/apps/base-token/tables/tbl-default/fields"), true);
assert.equal(calls[5].method, "GET");
assert.equal(calls[6].url.endsWith("/fields/fld-default-select"), true);
assert.equal(calls[6].method, "DELETE");
assert.equal(calls[7].url.endsWith("/fields/fld-default-date"), true);
assert.equal(calls[7].method, "DELETE");
assert.equal(calls[8].url.endsWith("/fields/fld-default-attachment"), true);
assert.equal(calls[8].method, "DELETE");
assert.equal(calls[9].url.endsWith("/bitable/v1/apps/base-token/tables/tbl-default/records"), true);
assert.equal(calls[9].method, "GET");
assert.equal(
calls[4].url.endsWith(
calls[10].url.endsWith(
"/bitable/v1/apps/base-token/tables/tbl-default/records/batch_delete",
),
true,
);
assert.deepEqual(calls[10].body, {
records: [
"rec-empty-1",
"rec-empty-2",
"rec-empty-3",
"rec-empty-4",
"rec-empty-5",
"rec-empty-6",
"rec-empty-7",
"rec-empty-8",
"rec-empty-9",
"rec-empty-10",
],
});
assert.equal(
calls[11].url.endsWith(
"/bitable/v1/apps/base-token/tables/tbl-default/records/batch_create",
),
true,
);
assert.deepEqual(calls[4].body, {
assert.deepEqual(calls[11].body, {
records: [
{
fields: {