feat(sheet): 支持创建多维表格

This commit is contained in:
zhaoyingbo 2024-09-23 12:10:58 +00:00
parent f6c472f138
commit 35da14f32c
16 changed files with 473 additions and 51 deletions

@ -27,9 +27,12 @@
"ChakrounAnas.turbo-console-log",
"streetsidesoftware.code-spell-checker",
"MS-CEINTL.vscode-language-pack-zh-hans",
"Prisma.prisma"
"Prisma.prisma",
"humao.rest-client",
"GitHub.copilot",
"GitHub.copilot-chat"
]
}
},
"onCreateCommand": "curl -fsSL https://bun.sh/install | bash"
"onCreateCommand": "curl -fsSL https://imoaix.cn/Soft/bun/install.sh | bash"
}

BIN
bun.lockb

Binary file not shown.

@ -17,18 +17,18 @@
]
},
"devDependencies": {
"@commitlint/cli": "^19.4.0",
"@commitlint/config-conventional": "^19.2.2",
"@eslint/js": "^9.9.0",
"@commitlint/cli": "^19.5.0",
"@commitlint/config-conventional": "^19.5.0",
"@eslint/js": "^9.11.0",
"@types/node-schedule": "^2.1.7",
"@types/uuid": "^10.0.0",
"bun-types": "^1.1.25",
"eslint": "^9.9.0",
"bun-types": "^1.1.29",
"eslint": "^9.11.0",
"eslint-plugin-simple-import-sort": "^12.1.1",
"husky": "^9.1.5",
"lint-staged": "^15.2.9",
"husky": "^9.1.6",
"lint-staged": "^15.2.10",
"prettier": "^3.3.3",
"typescript-eslint": "^8.2.0"
"typescript-eslint": "^8.6.0"
},
"peerDependencies": {
"typescript": "^5.5.4"
@ -36,14 +36,15 @@
"dependencies": {
"@egg/hooks": "^1.2.0",
"@egg/lark-msg-tool": "^1.2.0",
"@egg/logger": "^1.3.0",
"@egg/net-tool": "^1.6.0",
"@egg/logger": "^1.4.2",
"@egg/net-tool": "^1.6.3",
"@egg/path-tool": "^1.3.0",
"joi": "^17.13.3",
"node-schedule": "^2.1.1",
"p-limit": "^6.1.0",
"pocketbase": "^0.21.4",
"pocketbase": "^0.21.5",
"uuid": "^10.0.0",
"winston": "^3.14.2",
"winston-daily-rotate-file": "^5.0.0"
}
}
}

@ -10,6 +10,7 @@ import {
import { LarkService } from "../../services"
import { Context } from "../../types"
import createKVTemp from "../sheet/createKVTemp"
/**
* P2P或者群聊并且艾特了小煎蛋
@ -98,12 +99,8 @@ const manageIdMsg = (chatId: string, service: LarkService): void => {
* @param {Context.Data} ctx - body, logger, larkService和attachService
* @returns {boolean}
*/
const manageCMDMsg = ({
body,
logger,
larkService,
attachService,
}: Context.Data): boolean => {
const manageCMDMsg = (ctx: Context.Data): boolean => {
const { body, logger, larkService, attachService } = ctx
const text = getMsgText(body)
logger.debug(`bot req text: ${text}`)
const chatId = getChatId(body)
@ -133,6 +130,12 @@ const manageCMDMsg = ({
attachService.reportCollector(body)
return true
}
// 创建Sheet DB
if (text.trim() === "/gen db") {
logger.info(`bot command is /gen db, chatId: ${chatId}`)
createKVTemp.createFromEvent(ctx)
return true
}
return false
}

@ -0,0 +1,75 @@
import { getChatId } from "@egg/lark-msg-tool"
import { Context, LarkServer } from "../../types"
import { genSheetDbErrorMsg, genTempMsg } from "../../utils/genMsg"
/**
*
*
* @param {Context.Data} ctx - larkService和logger
* @returns {Promise<LarkServer.BaseRes>} - token的响应
*/
const create = async ({
larkService,
logger,
}: Context.Data): Promise<LarkServer.BaseRes> => {
const copyRes = await larkService.drive.copyFile(
"D6ETfzaU9lN08adVDz3kjLey4Bx",
"bask4drDOy7zc3nDVyZb5RYDzOe",
"xxx 项目 KV管理",
"bitable"
)
if (copyRes.code !== 0) return copyRes
const fileToken = copyRes.data.file.token
const tableRes = await larkService.sheet.getTables(fileToken)
if (tableRes.code !== 0) return tableRes
const tableId = tableRes.data[0].table_id
const viewRes = await larkService.sheet.getViews(fileToken, tableId)
if (viewRes.code !== 0) return viewRes
const viewId = viewRes.data[0].view_id
const link = `https://xiaomi.f.mioffice.cn/base/${fileToken}?table=${tableId}&view=${viewId}`
const token = `${fileToken}|${tableId}|${viewId}`
logger.info(`create KV bitable successfully: ${{ link, token }}`)
return {
code: 0,
data: {
link,
token,
},
message: "success",
}
}
/**
*
* @param {Context.Data} ctx - larkService和logger
*/
const createFromEvent = async (ctx: Context.Data) => {
const chatId = getChatId(ctx.body)
if (!chatId) return
const createRes = await create(ctx)
// 错误处理,发送错误消息
if (createRes.code !== 0) {
const errorMsg = genSheetDbErrorMsg(createRes.message)
ctx.larkService.message.send("chat_id", chatId, "interactive", errorMsg)
return
}
// 成功了组织一下URL和Token发送成功消息
const successMsg = genTempMsg("ctp_AA00oqPWPXtG", createRes.data)
ctx.larkService.message.send("chat_id", chatId, "interactive", successMsg)
}
const createKVTemp = {
create,
createFromEvent,
}
export default createKVTemp

@ -1,6 +1,9 @@
import Joi from "joi"
import db from "../../db"
import { Context } from "../../types"
import { SheetProxy } from "../../types/sheetProxy"
import insertSheet from "./insert"
/**
*
@ -11,22 +14,47 @@ const validateSheetReq = async (
ctx: Context.Data
): Promise<false | Response> => {
const { body, genResp } = ctx
if (!body.api_key) {
return genResp.badRequest("api_key is required")
}
if (!body.sheet_token) {
return genResp.badRequest("sheet_token is required")
}
if (!body.range) {
return genResp.badRequest("range is required")
}
if (!body.values) {
return genResp.badRequest("values is required")
// 定义基础的Schema
let schema = Joi.object({
api_key: Joi.string()
.required()
.messages({ "any.required": "api_key is required" }),
sheet_token: Joi.string()
.required()
.messages({ "any.required": "sheet_token is required" }),
range: Joi.string()
.required()
.messages({ "any.required": "range is required" }),
type: Joi.string()
.required()
.custom((value, helpers) => {
if (!SheetProxy.isType(value)) {
return helpers.error("any.invalid")
}
return value
})
.messages({
"any.required": "type is required",
"any.invalid": "type is invalid",
}),
})
if (body.type === "insert") {
schema = schema.keys({
values: Joi.array()
.items(Joi.array().items(Joi.string()))
.required()
.messages({ "any.required": "values is required" }),
})
}
if (!SheetProxy.isType(body.type)) {
return genResp.badRequest("type is invalid")
// 校验参数
const { error } = schema.validate(body)
if (error) {
return genResp.badRequest(error.message)
}
return false
}
@ -36,8 +64,8 @@ const validateSheetReq = async (
* @returns {Promise<Response>}
*/
export const manageSheetReq = async (ctx: Context.Data): Promise<Response> => {
const { body: rawBody, genResp, larkService } = ctx
const body = rawBody as SheetProxy.Body
const { body: rawBody, genResp } = ctx
const body = rawBody as SheetProxy.InsertData
// 校验参数
const validateRes = await validateSheetReq(ctx)
@ -55,17 +83,8 @@ export const manageSheetReq = async (ctx: Context.Data): Promise<Response> => {
return genResp.notFound("app name not found")
}
if (body.type === "insert") {
// 插入行
const insertRes = await larkService
.child(appName)
.sheet.insertRows(body.sheet_token, body.range, body.values)
if (insertRes?.code !== 0) {
return genResp.serverError(insertRes?.message)
}
// 返回插入结果
return genResp.ok(insertRes?.data)
}
// 根据请求类型处理
if (body.type === "insert") return await insertSheet(ctx, appName)
// 默认返回成功响应
return genResp.ok()

25
routes/sheet/insert.ts Normal file

@ -0,0 +1,25 @@
import { Context } from "../../types"
import { SheetProxy } from "../../types/sheetProxy"
/**
*
* @param {Context.Data} ctx -
* @param {string} appName -
* @returns {Promise<Response>}
*/
const insertSheet = async (ctx: Context.Data, appName: string) => {
const { genResp, larkService } = ctx
const body = ctx.body as SheetProxy.InsertData
const insertRes = await larkService
.child(appName)
.sheet.insertRows(body.sheet_token, body.range, body.values)
if (insertRes?.code !== 0) {
return genResp.serverError(insertRes?.message)
}
// 返回成功
return genResp.ok(insertRes?.data)
}
export default insertSheet

@ -53,6 +53,62 @@ class LarkDriveService extends LarkBaseService {
message: "success",
}
}
/**
*
*
* @param {string} folderToken -
* @returns {Promise<LarkServer.BaseRes>}
*/
async listFiles(folderToken: string) {
const path = "/drive/v1/files"
return this.get<LarkServer.BaseRes>(path, {
folder_token: folderToken,
})
}
/**
*
*
* @param {string} folderToken -
* @param {string} fileName -
* @param {"doc" | "sheet" | "bitable"} fileType -
* @returns {Promise<LarkServer.BaseRes>} Promise
*/
async createFile(
folderToken: string,
fileName: string,
fileType: "doc" | "sheet" | "bitable"
) {
const path = `/drive/explorer/v2/file/${folderToken}`
return this.post<LarkServer.BaseRes>(path, {
title: fileName,
type: fileType,
})
}
/**
*
*
* @param {string} folderToken -
* @param {string} fileToken -
* @param {string} fileName -
* @param {"doc" | "sheet" | "bitable"} fileType -
* @returns {Promise<LarkServer.BaseRes<LarkServer.CopyFileData>} 包含响应数据的Promise
*/
async copyFile(
folderToken: string,
fileToken: string,
fileName: string,
fileType: "doc" | "sheet" | "bitable"
) {
const path = `/drive/v1/files/${fileToken}/copy`
return this.post<LarkServer.BaseRes<LarkServer.CopyFileData>>(path, {
type: fileType,
folder_token: folderToken,
name: fileName,
})
}
}
export default LarkDriveService

@ -29,6 +29,70 @@ class LarkSheetService extends LarkBaseService {
const path = `/sheets/v2/spreadsheets/${sheetToken}/values/${range}?valueRenderOption=ToString`
return this.get<LarkServer.SpreadsheetRes>(path)
}
/**
*
* @param {string} appToken -
* @returns {Promise<LarkServer.BaseRes} 返回一个包含响应数据的Promise
*/
async getTables(appToken: string) {
const path = `/bitable/v1/apps/${appToken}/tables`
let has_more = true
const res = [] as LarkServer.TableData[]
while (has_more) {
const { data, code } = await this.get<
LarkServer.BaseListRes<LarkServer.TableData>
>(path, {
page_size: 100,
})
if (code !== 0) break
res.push(...data.items)
has_more = data.has_more
}
return {
code: 0,
data: res,
message: "ok",
}
}
/**
*
* @param {string} appToken -
* @param {string} tableId - ID
* @returns {Promise<LarkServer.BaseRes} 返回一个包含响应数据的Promise
*/
async getViews(appToken: string, tableId: string) {
const path = `/bitable/v1/apps/${appToken}/tables/${tableId}/views`
let has_more = true
const res = [] as LarkServer.ViewData[]
while (has_more) {
const { data, code } = await this.get<
LarkServer.BaseListRes<LarkServer.ViewData>
>(path, {
page_size: 100,
})
if (code !== 0) break
res.push(...data.items)
has_more = data.has_more
}
return {
code: 0,
data: res,
message: "ok",
}
}
/**
* ()
* @param {string} appToken -
* @param {string} tableId - ID
* @returns {Promise<LarkServer.BaseRes>} Promise
*/
async getRecords(appToken: string, tableId: string) {
const path = `/bitable/v1/apps/${appToken}/tables/${tableId}/records`
return this.get<LarkServer.BaseRes>(path)
}
}
export default LarkSheetService

26
test/copyFile.ts Normal file

@ -0,0 +1,26 @@
import { LarkService } from "../services"
const service = new LarkService("egg", "")
const {
data: {
file: { token: fileToken },
},
} = await service.drive.copyFile(
"D6ETfzaU9lN08adVDz3kjLey4Bx",
"bask4drDOy7zc3nDVyZb5RYDzOe",
"xxx 项目 KV管理器",
"bitable"
)
const { data: tableList } = await service.sheet.getTables(fileToken)
const tableId = tableList[0].table_id
const { data: viewList } = await service.sheet.getViews(fileToken, tableId)
const viewId = viewList[0].view_id
console.log(fileToken, tableId, viewId)
// https://xiaomi.f.mioffice.cn/base/bask4IvKT61xCvTUxIkcMaWoiYf?table=tblghpLxu1pAdVOD&view=vewEpqn4oM

11
test/createFile.ts Normal file

@ -0,0 +1,11 @@
import LarkDriveService from "../services/lark/drive"
const service = new LarkDriveService("egg", "")
const res = await service.createFile(
"D6ETfzaU9lN08adVDz3kjLey4Bx",
"xxx 项目 KV管理器",
"bitable"
)
console.log(JSON.stringify(res, null, 2))

@ -0,0 +1,10 @@
POST http://localhost:3000/sheet
Content-Type: application/json
{
"api_key": "uwnpzb9hvoft28h",
"sheet_token": "shtk4VNSEksIDylbZjl2N77vERg",
"range": "vtLQhI",
"values": [["1", "2"]],
"type": "insert"
}

@ -0,0 +1,13 @@
import { test } from "bun:test"
import LarkSheetService from "../services/lark/sheet"
const service = new LarkSheetService("egg", "")
test("getRecords", async () => {
const res = await service.getRecords(
"bask4BA989TBbnu5R7Onmdh1csb",
"tblabYZk3AYtGLSe"
)
console.log(res)
})

@ -71,13 +71,46 @@ export namespace LarkServer {
valueRange: ValueRange // 值与范围
}
export interface BaseRes {
export interface CopyFileData {
file: {
name: string
parent_token: string
token: string
type: "doc" | "sheet" | "bitable"
url: string
}
}
export interface TableData {
table_id: string
revision: number
name: string
}
export interface ViewData {
view_id: string
view_name: string
view_public_level: "Public" | "Locked" | "Private"
view_type: string
view_private_owner_id?: string
}
export interface BaseRes<T = any> {
code: number
data: any
data: T
// 在错误处理中msg会被赋值为message
message: string
}
export interface BaseListRes<T = any> extends BaseRes {
data: {
has_more: boolean
page_token: string
total: number
items: T[]
}
}
export interface SpreadsheetRes extends BaseRes {
data: SpreadsheetData
}

@ -3,12 +3,16 @@ export namespace SheetProxy {
Insert = "insert",
}
export interface Body {
export interface BaseData {
api_key: string
sheet_token: string
type: "insert"
range: string
values: string[][] // 二维数组
type: Type
}
export interface InsertData extends BaseData {
type: Type.Insert
values: string[][]
}
// 判断一个值是否是枚举中的值

79
utils/genMsg.ts Normal file

@ -0,0 +1,79 @@
/**
* JSON
* @param {string} title -
* @param {string} content -
* @returns {string} JSON
*/
const genErrorMsg = (title: string, content: string) =>
JSON.stringify({
elements: [
{
tag: "markdown",
content,
},
],
header: {
title: {
content: title,
tag: "plain_text",
},
template: "red",
},
})
/**
* JSON
* @param {string} title -
* @param {string} content -
* @returns {string} JSON
*/
const genSuccessMsg = (title: string, content: string) =>
JSON.stringify({
elements: [
{
tag: "markdown",
content,
},
],
header: {
title: {
content: title,
tag: "plain_text",
},
template: "green",
},
})
/**
* JSON
* @param {string} id - ID
* @param {any} variable -
* @returns {string} JSON
*/
export const genTempMsg = (id: string, variable: any) =>
JSON.stringify({
type: "template",
data: {
config: {
update_multi: true,
},
template_id: id,
template_variable: variable,
},
})
/**
* Sheet DB JSON
* @param {string} content -
* @returns {string} JSON
*/
export const genSheetDbErrorMsg = (content: string) =>
genErrorMsg("🍳 小煎蛋 Sheet DB 错误提醒", content)
/**
* Sheet DB JSON
* @param {string} content -
* @returns {string} JSON
*/
export const genSheetDbSuccessMsg = (content: string) =>
genSuccessMsg("🍳 感谢使用小煎蛋 Sheet DB", content)