listpage_cli 0.0.308 → 0.0.310

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -80,6 +80,14 @@ function createNodeFsAdapter() {
80
80
  throw toFsPortError("writeText", targetPath, error);
81
81
  }
82
82
  },
83
+ writeBuffer: (targetPath, content) => {
84
+ try {
85
+ (0, fs_1.writeFileSync)(targetPath, content);
86
+ }
87
+ catch (error) {
88
+ throw toFsPortError("writeBuffer", targetPath, error);
89
+ }
90
+ },
83
91
  };
84
92
  }
85
93
  function toFsPortError(operation, targetPath, error) {
@@ -0,0 +1,179 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.parseBlocksToMarkdown = parseBlocksToMarkdown;
4
+ function extractText(elements) {
5
+ if (!elements)
6
+ return "";
7
+ return elements
8
+ .map((el) => {
9
+ if (el.text_run) {
10
+ let text = el.text_run.content || "";
11
+ const style = el.text_run.text_element_style;
12
+ if (style) {
13
+ if (style.bold)
14
+ text = `**${text}**`;
15
+ if (style.italic)
16
+ text = `*${text}*`;
17
+ if (style.strikethrough)
18
+ text = `~~${text}~~`;
19
+ if (style.inline_code)
20
+ text = `\`${text}\``;
21
+ }
22
+ return text;
23
+ }
24
+ return "";
25
+ })
26
+ .join("");
27
+ }
28
+ async function parseBlocksToMarkdown(blocks, options) {
29
+ if (!blocks || blocks.length === 0)
30
+ return "";
31
+ // 1. 构建 block 映射字典
32
+ const blockMap = new Map();
33
+ for (const block of blocks) {
34
+ if (block.block_id) {
35
+ blockMap.set(block.block_id, block);
36
+ }
37
+ }
38
+ // 2. 找到根节点(通常是 parent_id 为空或者 block_type 为 1 的节点)
39
+ const rootBlock = blocks.find((b) => !b.parent_id || b.block_type === 1);
40
+ if (!rootBlock) {
41
+ return "";
42
+ }
43
+ // 3. 异步递归解析
44
+ async function parseBlock(blockId, indent = "") {
45
+ const block = blockMap.get(blockId);
46
+ if (!block)
47
+ return "";
48
+ let content = "";
49
+ let childIndent = indent;
50
+ switch (block.block_type) {
51
+ case 1: // Page
52
+ content += `# ${extractText(block.page?.elements)}\n\n`;
53
+ break;
54
+ case 2: // Text
55
+ const textContent = extractText(block.text?.elements);
56
+ if (textContent.trim()) {
57
+ content += `${indent}${textContent}\n\n`;
58
+ }
59
+ break;
60
+ case 3: // Heading 1
61
+ content += `${indent}# ${extractText(block.heading1?.elements)}\n\n`;
62
+ break;
63
+ case 4: // Heading 2
64
+ content += `${indent}## ${extractText(block.heading2?.elements)}\n\n`;
65
+ break;
66
+ case 5: // Heading 3
67
+ content += `${indent}### ${extractText(block.heading3?.elements)}\n\n`;
68
+ break;
69
+ case 6: // Heading 4
70
+ content += `${indent}#### ${extractText(block.heading4?.elements)}\n\n`;
71
+ break;
72
+ case 7: // Heading 5
73
+ content += `${indent}##### ${extractText(block.heading5?.elements)}\n\n`;
74
+ break;
75
+ case 8: // Heading 6
76
+ content += `${indent}###### ${extractText(block.heading6?.elements)}\n\n`;
77
+ break;
78
+ case 9: // Heading 7
79
+ content += `${indent}####### ${extractText(block.heading7?.elements)}\n\n`;
80
+ break;
81
+ case 10: // Heading 8
82
+ content += `${indent}######## ${extractText(block.heading8?.elements)}\n\n`;
83
+ break;
84
+ case 11: // Heading 9
85
+ content += `${indent}######### ${extractText(block.heading9?.elements)}\n\n`;
86
+ break;
87
+ case 12: // Bullet
88
+ content += `${indent}- ${extractText(block.bullet?.elements)}\n`;
89
+ childIndent = indent + " ";
90
+ break;
91
+ case 13: // Ordered
92
+ let orderIndex = 1;
93
+ if (block.parent_id) {
94
+ const parentBlock = blockMap.get(block.parent_id);
95
+ if (parentBlock && parentBlock.children) {
96
+ const childIndex = parentBlock.children.indexOf(blockId);
97
+ for (let i = childIndex - 1; i >= 0; i--) {
98
+ const siblingId = parentBlock.children[i];
99
+ const sibling = blockMap.get(siblingId);
100
+ if (sibling && sibling.block_type === 13) {
101
+ orderIndex++;
102
+ }
103
+ else {
104
+ break;
105
+ }
106
+ }
107
+ }
108
+ }
109
+ content += `${indent}${orderIndex}. ${extractText(block.ordered?.elements)}\n`;
110
+ childIndent = indent + " ";
111
+ break;
112
+ case 14: // Code
113
+ content += `${indent}\`\`\`\n${extractText(block.code?.elements)}\n${indent}\`\`\`\n\n`;
114
+ break;
115
+ case 15: // Quote
116
+ content += `${indent}> ${extractText(block.quote?.elements)}\n`;
117
+ childIndent = indent + "> ";
118
+ break;
119
+ case 17: // Todo
120
+ const done = block.todo?.style?.done;
121
+ content += `${indent}- [${done ? "x" : " "}] ${extractText(block.todo?.elements)}\n`;
122
+ childIndent = indent + " ";
123
+ break;
124
+ case 19: // Callout
125
+ const emoji = block.callout?.emoji_id || "bulb";
126
+ content += `${indent}> [!NOTE] :${emoji}:\n`;
127
+ childIndent = indent + "> ";
128
+ break;
129
+ case 22: // Divider
130
+ content += `${indent}---\n\n`;
131
+ break;
132
+ case 27: // Image
133
+ const token = block.image?.token;
134
+ if (token) {
135
+ const downloadUrl = `https://internal-api-drive-stream.feishu.cn/space/api/box/stream/download/v2/cover/${token}/?fallback_source=1&mount_node_token=${block.block_id}&mount_point=docx_image&policy=equal`;
136
+ try {
137
+ const imageFileName = `assets/${token}.png`; // 保存到 assets 目录
138
+ const imagePath = options.fs.resolve(options.outputDir, imageFileName);
139
+ // 确保 assets 目录存在
140
+ const imageDir = options.fs.dirname(imagePath);
141
+ if (!options.fs.exists(imageDir)) {
142
+ options.fs.ensureDir(imageDir);
143
+ }
144
+ // 使用 sdk 提供的下载方法,并直接使用 writeFile 写入
145
+ const fileRes = await options.client.drive.media.download({
146
+ path: { file_token: token },
147
+ });
148
+ // 使用官方推荐的 writeFile 方法直接将文件保存到本地
149
+ await fileRes.writeFile(imagePath);
150
+ content += `${indent}![image](./${imageFileName})\n\n`;
151
+ }
152
+ catch (e) {
153
+ console.warn(`[Lark Parse] Download image failed for block ${block.block_id}:`, e);
154
+ content += `${indent}![image](${downloadUrl})\n\n`;
155
+ }
156
+ }
157
+ else {
158
+ content += `${indent}![image](image)\n\n`;
159
+ }
160
+ break;
161
+ default:
162
+ // 未知类型直接略过或作为普通文本处理
163
+ break;
164
+ }
165
+ // 解析子节点
166
+ if (block.children && block.children.length > 0) {
167
+ for (const childId of block.children) {
168
+ content += await parseBlock(childId, childIndent);
169
+ }
170
+ // 如果当前是列表的最后一项并且有子节点,为美观可以加空行,但为了简单这里暂不处理
171
+ if (block.block_type === 1 || block.block_type === 19) {
172
+ content += "\n";
173
+ }
174
+ }
175
+ return content;
176
+ }
177
+ const finalContent = await parseBlock(rootBlock.block_id);
178
+ return finalContent.trim() + "\n";
179
+ }
@@ -2,30 +2,35 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.handleReadCommand = handleReadCommand;
4
4
  const command_result_1 = require("../../domain/command-result");
5
+ const parse_doc_1 = require("./parse-doc");
5
6
  async function handleReadCommand(input, client, fs) {
6
7
  const docToken = input.positionals[1];
7
8
  if (!docToken) {
8
9
  return (0, command_result_1.commandError)("错误: 请提供飞书文档的 doc_token。用法: listpage_cli lark read <doc_token>", "missing_doc_token", 1);
9
10
  }
10
11
  console.log(`正在读取飞书文档内容,doc_token: ${docToken}`);
11
- const response = await client.docs.v1.content.get({
12
+ const blocksRes = await client.docx.documentBlock.list({
13
+ path: { document_id: docToken },
12
14
  params: {
13
- doc_token: docToken,
14
- doc_type: "docx",
15
- content_type: "markdown",
16
- lang: "zh",
15
+ page_size: 500,
16
+ document_revision_id: -1,
17
17
  },
18
18
  });
19
- if (response.code !== 0) {
20
- return (0, command_result_1.commandError)(`读取飞书文档失败,错误码: ${response.code}, 错误信息: ${response.msg}`, "read_doc_failed", 1);
19
+ if (blocksRes.code !== 0 || !blocksRes.data?.items) {
20
+ return (0, command_result_1.commandError)(`获取文档 blocks 失败: ${blocksRes.msg}`, "get_blocks_failed", 1);
21
21
  }
22
- const docContent = response.data?.content || "";
22
+ const blocks = blocksRes.data.items || [];
23
23
  const relativePath = `.listpage/lark/${docToken}/prd.md`;
24
24
  const absolutePath = fs.resolve(fs.cwd(), relativePath);
25
25
  const outputDir = fs.dirname(absolutePath);
26
26
  if (!fs.exists(outputDir)) {
27
27
  fs.ensureDir(outputDir);
28
28
  }
29
+ const docContent = await (0, parse_doc_1.parseBlocksToMarkdown)(blocks, {
30
+ fs,
31
+ outputDir,
32
+ client,
33
+ });
29
34
  fs.writeText(absolutePath, docContent);
30
35
  console.log(`飞书文档读取成功!内容已保存至: ${relativePath}`);
31
36
  return (0, command_result_1.commandOk)();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "listpage_cli",
3
- "version": "0.0.308",
3
+ "version": "0.0.310",
4
4
  "private": false,
5
5
  "bin": {
6
6
  "listpage_cli": "bin/cli.js"
@@ -25,7 +25,7 @@
25
25
  "class-transformer": "^0.5.1",
26
26
  "class-validator": "~0.14.2",
27
27
  "rxjs": "^7.8.1",
28
- "listpage-next-nest": "~0.0.308"
28
+ "listpage-next-nest": "~0.0.310"
29
29
  },
30
30
  "devDependencies": {
31
31
  "@nestjs/schematics": "^11.0.0",
@@ -12,7 +12,7 @@
12
12
  "dependencies": {
13
13
  "react": "^19.2.0",
14
14
  "react-dom": "^19.2.0",
15
- "listpage-next": "~0.0.308",
15
+ "listpage-next": "~0.0.310",
16
16
  "react-router-dom": ">=6.0.0",
17
17
  "@ant-design/v5-patch-for-react-19": "~1.0.3",
18
18
  "ahooks": "^3.9.5",
@@ -23,7 +23,7 @@
23
23
  "styled-components": "^6.1.19",
24
24
  "mobx": "~6.15.0",
25
25
  "@ant-design/icons": "~6.0.2",
26
- "listpage-components": "~0.0.308",
26
+ "listpage-components": "~0.0.310",
27
27
  "lucide-react": "~0.575.0"
28
28
  "mobx-react-lite": "~4.1.1"
29
29
  },
@@ -0,0 +1,33 @@
1
+ ---
2
+ name: listpage-filter-group
3
+ description: 使用 listpage-components 的 FilterGroup 与 FilterFormOption 搭建筛选区。Use ONLY when the user explicitly requests FilterGroup by name (e.g. 使用 FilterGroup、用 FilterGroup 做筛选). Do NOT apply when the user only mentions listpage, ListPage, or listpage pages—those belong to the separate listpage skill; package name listpage-components is unrelated to this trigger. Do not apply for generic filters unless FilterGroup is explicitly named.
4
+ ---
5
+
6
+ # FilterGroup 使用技能
7
+
8
+ 在用户**明确写出 FilterGroup** 时,按本技能与 [api.md](api.md)、[examples.md](examples.md) 实现。
9
+
10
+ ## 命名与触发(与 listpage 页面技能区分)
11
+
12
+ 组件从包 `listpage-components` 引入,包名含 **listpage**,易与「用 listpage 做列表页」混淆,但这是**两种独立场景**:
13
+
14
+ | 用户说法 | 使用的技能 |
15
+ |----------|------------|
16
+ | 仅 listpage / ListPage / 用 listpage 实现某页 等 | **listpage 页面技能**,**不要**启用本技能 |
17
+ | 明确写出 **FilterGroup**(或「用 FilterGroup 组件」) | **本技能** |
18
+
19
+ 若用户从头到尾只提 listpage、未提 FilterGroup,**忽略本技能**,避免与 listpage 技能抢上下文。
20
+
21
+ ## 生成流程
22
+
23
+ ```
24
+ Task Progress:
25
+ - [ ] 步骤 1:从 listpage-components 导入 FilterGroup、FilterFormOption
26
+ - [ ] 步骤 2:用 FilterFormOption[] 声明字段(name、label、component、colSpan、formItemProps);`component` 可为任意能按注入的 `value`/`onChange`(或 `formItemProps` 中自定义的 valuePropName/trigger)工作的控件,不限组件种类
27
+ - [ ] 步骤 3:按需配置控件细节(placeholder、是否可清空、选项数据等,以用户/产品要求为准)
28
+ - [ ] 步骤 4:实现 onSubmit(参数为筛选条件对象:已去掉值为 `undefined` 的字段,见 api.md);按需实现 onReset
29
+ ```
30
+
31
+ ## 在已选用 FilterGroup 时的约定
32
+
33
+ - 避免再手写一套纯 AntD `Form` + 栅格替代本组件;选项形状以 [api.md](api.md) 为准。
@@ -0,0 +1,46 @@
1
+ # FilterGroup API
2
+
3
+ 对应技能仅在用户**明确写出** `FilterGroup` 时启用;用户若只提 listpage / ListPage,**不要**套用本 API 文档(见 SKILL.md 的触发说明)。
4
+
5
+ 包名:`listpage-components`(与「listpage 页面技能」无关,仅为 npm 包名)。例如:`import { FilterGroup, type FilterGroupProps, type FilterFormOption } from 'listpage-components'`。
6
+
7
+ ## FilterGroup
8
+
9
+ 内部组合表单项与栅格布局。
10
+
11
+ ### 提交给 `onSubmit` 的数据
12
+
13
+ 点击「搜索」或触发与提交等价的逻辑时,`onSubmit` 收到的是**当前筛选条件对象**:在表单取值之后,会**去掉值为 `undefined` 的字段**再传入,便于直接用于请求参数或状态;值为 `null` 的字段会保留,是否参与请求由业务决定。
14
+
15
+ 重置流程中在调用 `onReset` 之前,也会用**同样规则**得到当前表单值并调用一次 `onSubmit`(见下)。
16
+
17
+ ### FilterGroupProps
18
+
19
+ | 属性 | 类型 | 说明 |
20
+ |------|------|------|
21
+ | `options` | `FilterFormOption[]` | 筛选项配置(必填) |
22
+ | `initialValues` | `FormValue` | 表单初始值 |
23
+ | `onSubmit` | `(values?: FormValue) => void` | 筛选条件变化需提交时触发,参数为上一节说明的对象 |
24
+ | `onReset` | `() => void` | 重置时额外回调(在内部已 `resetFields`、并会再触发一次 `onSubmit` 之后调用) |
25
+
26
+ **重置行为(需牢记)**:用户点「重置」时,会先清空字段,再按上述规则调用 `onSubmit`,最后调用 `onReset`。若你只关心「条件变化后拉数」,通常只实现 `onSubmit` 即可。
27
+
28
+ ## FilterFormOption
29
+
30
+ | 字段 | 类型 | 说明 |
31
+ |------|------|------|
32
+ | `name` | `string` | 表单字段名(必填) |
33
+ | `label` | `ReactNode` | 标签文案 |
34
+ | `component` | `ReactElement` | 筛选项控件,**具体用哪种组件不限制**(见下) |
35
+ | `colSpan` | `number` | 栅格占位,对应 12 列网格,**默认 2** |
36
+ | `formItemProps` | 除 `children` 外的 `FormItemProps` | 传给 `Form.Item` 的额外配置;若控件不用 `value`/`onChange`,可在此设 `valuePropName`、`trigger` |
37
+
38
+ ### `component` 与受控
39
+
40
+ `FilterItem` 会对 `component` 做 `cloneElement`,注入**当前值**与**变更回调**(默认对应受控的 `value` 与 `onChange`,与 Ant Design Form 常见写法一致)。只要传入的节点能按注入后的 props 作为受控组件工作即可,**不限定**必须是 `Input`、`Select`、`DatePicker` 等某一种。
41
+
42
+ 若控件使用其它字段名(例如 `checked`),通过 `formItemProps.valuePropName`、`formItemProps.trigger` 与 Ant Design `Form.Item` 约定一致即可。
43
+
44
+ 未提供 `component` 时,该筛选项使用默认的 `Input`。
45
+
46
+ 布局使用内部 `FilterGridLayout`(`grid-cols-12`),操作按钮占一列;筛选项 `colSpan` 为 1–12。
@@ -0,0 +1,82 @@
1
+ # FilterGroup 示例
2
+
3
+ 本技能仅在用户**明确写出 FilterGroup** 时启用;以下为实现参考。
4
+
5
+ ## 仅筛选区 + 本地状态或请求
6
+
7
+ 示例里使用 `Input` / `Select` / `DatePicker` 仅为演示;`component` 可换成任意合适的受控控件(见 [api.md](api.md) 中 `component` 说明)。示例中部分使用 `allowClear` 仅为常见写法,以实际需求为准。
8
+
9
+ ```tsx
10
+ import { useState, useCallback } from 'react';
11
+ import { Input, Select, DatePicker } from 'antd';
12
+ import type { Dayjs } from 'dayjs';
13
+ import {
14
+ FilterGroup,
15
+ type FilterFormOption,
16
+ } from 'listpage-components';
17
+
18
+ type FilterValues = {
19
+ keyword?: string;
20
+ status?: string;
21
+ date?: Dayjs;
22
+ };
23
+
24
+ export function ReportFilterPanel() {
25
+ const [filters, setFilters] = useState<FilterValues>({});
26
+
27
+ const options: FilterFormOption[] = [
28
+ {
29
+ name: 'keyword',
30
+ label: '关键词',
31
+ colSpan: 3,
32
+ component: <Input placeholder="请输入" allowClear />,
33
+ },
34
+ {
35
+ name: 'status',
36
+ label: '状态',
37
+ colSpan: 2,
38
+ component: (
39
+ <Select
40
+ allowClear
41
+ placeholder="全部"
42
+ options={[
43
+ { label: '启用', value: '1' },
44
+ { label: '停用', value: '0' },
45
+ ]}
46
+ />
47
+ ),
48
+ },
49
+ {
50
+ name: 'date',
51
+ label: '日期',
52
+ colSpan: 3,
53
+ component: <DatePicker className="w-full" allowClear />,
54
+ },
55
+ ];
56
+
57
+ const handleSubmit = useCallback((values?: FilterValues) => {
58
+ setFilters(values ?? {});
59
+ // 此处可改为调用 API:fetchReport({ ...values })
60
+ }, []);
61
+
62
+ return (
63
+ <div className="bg-white px-6 py-4 rounded-lg border border-[#f0f0f0]">
64
+ <FilterGroup<FilterValues>
65
+ options={options}
66
+ initialValues={filters}
67
+ onSubmit={handleSubmit}
68
+ onReset={() => {
69
+ /* 可选:仅重置以外的副作用 */
70
+ }}
71
+ />
72
+ </div>
73
+ );
74
+ }
75
+ ```
76
+
77
+ ## 自检
78
+
79
+ - 各控件的交互(是否可清空、选项来源等)与用户/产品要求一致
80
+ - `name` 与类型/接口字段一致
81
+ - 理解「重置」会再次触发 `onSubmit`,避免重复请求未做防抖时可在业务侧处理
82
+