openxiangda 1.0.147 → 1.0.148

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.
package/README.md CHANGED
@@ -216,6 +216,16 @@ openxiangda workspace bind --profile dev --app-type APP_XXXX
216
216
 
217
217
  表单页、流程表单页和自定义代码页都应在 `sy-lowcode-app-workspace` 中实现,由 `openxiangda workspace publish --profile <name>` 统一构建、上传 OSS 并注册到平台。`openxiangda form create`、`form publish`、`page publish` 只作为底层修复/诊断命令,不作为 AI 生成页面的主入口。
218
218
 
219
+ 表单数据导出使用 `openxiangda form export`,不要手写下载接口 URL:
220
+
221
+ ```bash
222
+ openxiangda form export customer --mode xlsx --profile dev
223
+ openxiangda form export customer --mode xlsx-images --profile dev --all
224
+ openxiangda form export customer --mode package --profile dev --output ./exports/
225
+ ```
226
+
227
+ `xlsx` 是普通 Excel;`xlsx-images` 会尽量把 `ImageField` 图片嵌入单元格;`package` 会下载 zip,包含 `data.xlsx`、`attachments/` 和 `manifest.json`。`--output` 指向目录时使用服务端文件名,指向文件路径时按该路径写入;`--json` 只输出 `{ file, filename, mime, bytes, mode, formUuid }` 摘要,不把二进制写到 stdout。
228
+
219
229
  运行时页面读取当前用户信息时,优先使用 `sdk.user.getCurrent<PageUserRecord>()`。用户对象会返回常规组织成员关系 `departments`,也会返回系统维护的所属单位字段 `affiliatedDepartmentId` / `affiliatedDepartment`。`departments` 表示用户真实所在的部门、班级、专业等成员关系;`affiliatedDepartment` 表示业务上用于统计、筛选和展示的归属单位,通常是学院、单位或在源单位缺失时可用的具体部门节点,不用于替代权限部门成员关系。
220
230
 
221
231
  ```ts
package/lib/cli.js CHANGED
@@ -141,6 +141,7 @@ Usage:
141
141
  openxiangda form create <formCode> [--name text] [--type receipt] # low-level shell only
142
142
  openxiangda form bind <formCode> --form-uuid <FORM_XXX>
143
143
  openxiangda form pull <formCode|formUuid> [--json]
144
+ openxiangda form export <formCode|formUuid> --mode xlsx|xlsx-images|package [--output <file-or-dir>]
144
145
  openxiangda form publish <formCode|formUuid> --bundle-url <url> # repair only
145
146
  openxiangda form schema-plan <formCode|formUuid> --schema-json <file>|--components-json <file>
146
147
  openxiangda page list [--profile name] [--json]
@@ -2077,7 +2078,7 @@ async function form(args) {
2077
2078
  const [subcommand, ...rest] = args;
2078
2079
  const { flags, positional } = parseArgs(rest);
2079
2080
  if (wantsSubcommandHelp(subcommand, flags)) {
2080
- print('用法: openxiangda form list|create|bind|pull|publish|schema-plan [--profile name] [--json]');
2081
+ print('用法: openxiangda form list|create|bind|pull|export|publish|schema-plan [--profile name] [--json]');
2081
2082
  return;
2082
2083
  }
2083
2084
  const config = loadConfig();
@@ -2155,6 +2156,46 @@ async function form(args) {
2155
2156
  return;
2156
2157
  }
2157
2158
 
2159
+ if (subcommand === 'export') {
2160
+ const [formKey] = positional;
2161
+ if (!formKey) {
2162
+ fail('用法: openxiangda form export <formCode|formUuid> --mode xlsx|xlsx-images|package');
2163
+ }
2164
+ const target = getWorkspaceTarget(config, profileName, flags);
2165
+ const formUuid = resolveFormUuid(target.bound, formKey, flags);
2166
+ const mode = normalizeFormExportMode(flags.mode);
2167
+ const extension = mode === 'package' ? 'zip' : 'xlsx';
2168
+ const endpoint =
2169
+ mode === 'package' ? 'advancedExportPackage.zip' : 'advancedExport.xlsx';
2170
+ const apiPath = apiPathWithQuery(
2171
+ `/${encodeURIComponent(target.appType)}/v1/form/${endpoint}`,
2172
+ buildFormExportQuery(formUuid, mode, flags)
2173
+ );
2174
+ const download = await downloadWithAuth(config, target.profileName, apiPath, {
2175
+ timeoutMs: flags['timeout-ms'] || flags.timeout || 120000,
2176
+ });
2177
+ const fallbackFilename = `export_${formUuid}_${new Date()
2178
+ .toISOString()
2179
+ .replace(/[-:T.Z]/g, '')
2180
+ .slice(0, 14)}.${extension}`;
2181
+ const output = writeDownloadedFile(
2182
+ download,
2183
+ flags.output || '.',
2184
+ fallbackFilename
2185
+ );
2186
+ const result = {
2187
+ file: output.file,
2188
+ filename: output.filename,
2189
+ mime: download.mime,
2190
+ bytes: download.bytes,
2191
+ mode,
2192
+ formUuid,
2193
+ };
2194
+ if (flags.json) return writeJson(result);
2195
+ print(`已导出 ${mode}: ${output.file} (${download.bytes} bytes)`);
2196
+ return;
2197
+ }
2198
+
2158
2199
  if (subcommand === 'publish') {
2159
2200
  const [formKey] = positional;
2160
2201
  if (!formKey || !flags['bundle-url']) {
@@ -2205,7 +2246,7 @@ async function form(args) {
2205
2246
  return;
2206
2247
  }
2207
2248
 
2208
- fail('用法: openxiangda form list|create|bind|pull|publish|schema-plan');
2249
+ fail('用法: openxiangda form list|create|bind|pull|export|publish|schema-plan');
2209
2250
  }
2210
2251
 
2211
2252
  async function page(args) {
@@ -5379,7 +5420,7 @@ async function commands(args) {
5379
5420
  'env',
5380
5421
  'workspace init|bind|plan|check|publish [--app-name] [--changed|--since|--form|--page|--only|--dry-run|--force|--resources|--skip-resources|--prune]',
5381
5422
  'app list|create|snapshot',
5382
- 'form list|create|bind|pull|publish|schema-plan',
5423
+ 'form list|create|bind|pull|export|publish|schema-plan',
5383
5424
  'page list|publish|bind|releases|activate',
5384
5425
  'menu list|create|update|sort|bind|delete',
5385
5426
  'workflow compile|list|create|bind|pull|publish|delete|validate',
@@ -6079,6 +6120,61 @@ function resolveFormUuid(bound, formKey, flags = {}) {
6079
6120
  return mapped || formKey;
6080
6121
  }
6081
6122
 
6123
+ function normalizeFormExportMode(value) {
6124
+ const mode = String(value || 'xlsx').trim();
6125
+ if (['xlsx', 'xlsx-images', 'package'].includes(mode)) return mode;
6126
+ fail('--mode 只能是 xlsx、xlsx-images 或 package');
6127
+ }
6128
+
6129
+ function buildFormExportQuery(formUuid, mode, flags = {}) {
6130
+ const query = {
6131
+ formUuid,
6132
+ exportAll: flags.all ? 'y' : 'n',
6133
+ embedImages: mode === 'xlsx-images' || mode === 'package' ? 'y' : 'n',
6134
+ currentPage: flags.page,
6135
+ pageSize: flags['page-size'],
6136
+ conditionType: flags['condition-type'],
6137
+ searchKeyWord: flags['search-keyword'],
6138
+ instanceStatus: flags['instance-status'],
6139
+ exportFields: flags.fields,
6140
+ };
6141
+ if (flags['filters-json']) {
6142
+ query.filters = JSON.stringify(readJsonArg(flags['filters-json'], 'filters-json'));
6143
+ }
6144
+ if (flags['order-json']) {
6145
+ query.order = JSON.stringify(readJsonArg(flags['order-json'], 'order-json'));
6146
+ }
6147
+ return query;
6148
+ }
6149
+
6150
+ function writeDownloadedFile(download, outputFlag, fallbackFilename) {
6151
+ const filename = sanitizeDownloadFilename(download.filename || fallbackFilename);
6152
+ const target = String(outputFlag || '.');
6153
+ const absoluteTarget = path.resolve(process.cwd(), target);
6154
+ const targetIsDirectory =
6155
+ target.endsWith('/') ||
6156
+ target.endsWith(path.sep) ||
6157
+ target === '.' ||
6158
+ (fs.existsSync(absoluteTarget) && fs.statSync(absoluteTarget).isDirectory()) ||
6159
+ (!fs.existsSync(absoluteTarget) && !path.extname(absoluteTarget));
6160
+ const file = targetIsDirectory
6161
+ ? path.join(absoluteTarget, filename)
6162
+ : absoluteTarget;
6163
+ fs.mkdirSync(path.dirname(file), { recursive: true });
6164
+ fs.writeFileSync(file, download.buffer);
6165
+ return {
6166
+ file,
6167
+ filename: path.basename(file),
6168
+ };
6169
+ }
6170
+
6171
+ function sanitizeDownloadFilename(value) {
6172
+ return String(value || 'download.bin')
6173
+ .replace(/[\\/]+/g, '_')
6174
+ .replace(/[\0\r\n]+/g, '')
6175
+ .trim() || 'download.bin';
6176
+ }
6177
+
6082
6178
  function readFormSchemaPlanBody(flags = {}) {
6083
6179
  if (flags['schema-json']) {
6084
6180
  return { schema: readJsonArg(flags['schema-json'], 'schema-json') };
@@ -12994,6 +13090,98 @@ async function retryTransientFetch(fn, options = {}) {
12994
13090
  throw lastError;
12995
13091
  }
12996
13092
 
13093
+ async function downloadWithAuth(config, profileName, apiPath, options = {}) {
13094
+ const resolved = getProfile(config, profileName);
13095
+ const profile = resolved.profile;
13096
+ if (!profile.token?.accessToken) {
13097
+ fail(`profile ${resolved.profileName} 未登录,请先执行 openxiangda login --profile ${resolved.profileName}`);
13098
+ }
13099
+ const requestOptions = {
13100
+ method: 'GET',
13101
+ timeoutMs: options.timeoutMs,
13102
+ retries: options.retries,
13103
+ };
13104
+ const send = () =>
13105
+ downloadBinary(profile.baseUrl, apiPath, profile.token.accessToken, requestOptions);
13106
+ try {
13107
+ return await retryTransientFetch(send, requestOptions);
13108
+ } catch (error) {
13109
+ if (!isUnauthorized(error) || !profile.token?.refreshToken) {
13110
+ throw error;
13111
+ }
13112
+ await refreshProfile(config, resolved.profileName);
13113
+ return await retryTransientFetch(send, requestOptions);
13114
+ }
13115
+ }
13116
+
13117
+ async function downloadBinary(baseUrl, apiPath, accessToken, options = {}) {
13118
+ if (typeof fetch !== 'function') {
13119
+ throw new Error('当前 Node.js 版本不支持 fetch,请使用 Node.js 18 或更高版本');
13120
+ }
13121
+ const url = `${baseUrl.replace(/\/+$/, '')}${apiPath}`;
13122
+ const timeoutMs = normalizeRuntimeUploadTimeoutMs(options.timeoutMs);
13123
+ const controller = new AbortController();
13124
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
13125
+ let response;
13126
+ try {
13127
+ response = await fetch(url, {
13128
+ method: options.method || 'GET',
13129
+ headers: {
13130
+ accept: '*/*',
13131
+ authorization: `Bearer ${accessToken}`,
13132
+ },
13133
+ signal: controller.signal,
13134
+ });
13135
+ } catch (error) {
13136
+ if (isAbortError(error)) {
13137
+ throw new Error(maskText(`HTTP download timed out after ${timeoutMs}ms: ${apiPath}`));
13138
+ }
13139
+ throw new Error(formatFetchError(error, apiPath));
13140
+ } finally {
13141
+ clearTimeout(timer);
13142
+ }
13143
+
13144
+ if (!response.ok) {
13145
+ const text = await response.text().catch(() => '');
13146
+ let payload = null;
13147
+ try {
13148
+ payload = text ? JSON.parse(text) : null;
13149
+ } catch {
13150
+ payload = { message: text };
13151
+ }
13152
+ const message = payload?.message || response.statusText || 'download failed';
13153
+ const error = new Error(maskText(`HTTP ${response.status}: ${message}`));
13154
+ error.status = response.status;
13155
+ error.payload = payload;
13156
+ throw error;
13157
+ }
13158
+
13159
+ const arrayBuffer = await response.arrayBuffer();
13160
+ const buffer = Buffer.from(arrayBuffer);
13161
+ const disposition = response.headers.get('content-disposition') || '';
13162
+ return {
13163
+ buffer,
13164
+ filename: parseContentDispositionFilename(disposition),
13165
+ mime: response.headers.get('content-type') || 'application/octet-stream',
13166
+ bytes: buffer.length,
13167
+ };
13168
+ }
13169
+
13170
+ function parseContentDispositionFilename(header) {
13171
+ const value = String(header || '');
13172
+ const encodedMatch = value.match(/filename\*=([^;]+)/i);
13173
+ if (encodedMatch) {
13174
+ const encoded = encodedMatch[1].trim().replace(/^UTF-8''/i, '');
13175
+ try {
13176
+ return sanitizeDownloadFilename(decodeURIComponent(encoded.replace(/^"|"$/g, '')));
13177
+ } catch {
13178
+ return sanitizeDownloadFilename(encoded);
13179
+ }
13180
+ }
13181
+ const plainMatch = value.match(/filename="?([^";]+)"?/i);
13182
+ return plainMatch ? sanitizeDownloadFilename(plainMatch[1]) : '';
13183
+ }
13184
+
12997
13185
  async function requestWithAuth(config, profileName, apiPath, options = {}) {
12998
13186
  const resolved = getProfile(config, profileName);
12999
13187
  const profile = resolved.profile;
@@ -24,6 +24,7 @@ OpenXiangda supports two workspace modes. Classic `sy-lowcode-app-workspace` pub
24
24
  | 创建应用 / 新建 app / scaffold / 初始化工作区 | `openxiangda-app` | `openxiangda workspace init <dir> --profile <name> --app-name "..."` |
25
25
  | 绑定已有应用 / bind existing app | `openxiangda-app` | `openxiangda workspace bind --profile <name> --app-type APP_XXX` |
26
26
  | 创建 / 修改表单字段 / 表单页 / schema | `openxiangda-form` | edit `src/forms/<code>/{schema.ts,page.tsx}` → `workspace publish --form <code>` |
27
+ | 导出表单数据 / 图片 / 附件 / Excel / zip | `openxiangda-form` | `openxiangda form export <formCode> --mode xlsx|xlsx-images|package --profile <name>` |
27
28
  | 创建 / 修改自定义代码页 / portal / dashboard | `openxiangda-page` | edit `src/pages/<code>/` → `workspace publish --page <code>` |
28
29
  | 审批流程 / workflow / 流程节点 / JS_CODE | `openxiangda-workflow-automation` | `openxiangda workflow validate / create / publish` |
29
30
  | 自动化 / 定时任务 / 提交触发 / cron | `openxiangda-workflow-automation` | `openxiangda automation validate / create / publish / enable` |
@@ -57,6 +58,7 @@ OpenXiangda supports two workspace modes. Classic `sy-lowcode-app-workspace` pub
57
58
  - ✅ For form-entry UX, pick components in this order: OpenXiangda platform form components → `antd` / `antd-mobile` wrappers → custom component only when neither exists.
58
59
  - ✅ In React SPA apps, keep imports on public package entrypoints such as `openxiangda`, `openxiangda/runtime`, and `openxiangda/runtime/react`. The default template already groups large vendor chunks; if a bundle is too large, use route-level lazy loading and Vite `manualChunks`, not `openxiangda/packages/sdk/dist/...` internal paths.
59
60
  - ✅ For image thumbnails or lightweight previews, use `imageCompression` on `ImageField` or `AttachmentField`. Keep the original `url` for the source file and use `thumbUrl` / `previewUrl` / `variants` for smaller images.
61
+ - ✅ For data delivery with files, use `openxiangda form export <formCode> --mode xlsx|xlsx-images|package --profile <name>`. `package` writes a zip with `data.xlsx`, `attachments/`, and `manifest.json`; do not hand-write export URLs or stream binary content to stdout.
60
62
  - ✅ List pages, linked options, remote selectors, and report drill-downs must use paginated server-side queries with explicit searchable fields. Do not fetch a large page and filter in local state.
61
63
  - ✅ Treat workflow forms as an exception. Ordinary ticket/order/task/asset lifecycles use status fields, state machines, action logs, permissions, and automations; workflows are for real approval tasks.
62
64
  - ✅ Proactively submit platform feedback when you discover a defect, missing capability, unclear rule, repeated workaround, user-visible UX gap, or implementation uncertainty worth improving.
@@ -188,6 +188,9 @@ Read-only diagnosis can use resource commands:
188
188
  ```bash
189
189
  openxiangda form list --profile dev
190
190
  openxiangda form pull customer --profile dev
191
+ openxiangda form export customer --mode xlsx --profile dev
192
+ openxiangda form export customer --mode xlsx-images --profile dev --all
193
+ openxiangda form export customer --mode package --profile dev --output ./exports/
191
194
  openxiangda page list --profile dev
192
195
  openxiangda workflow list --form-code customer --profile dev
193
196
  openxiangda automation list --form-code customer --profile dev
@@ -106,6 +106,30 @@ openxiangda form publish customer --bundle-url <url> --profile <name>
106
106
 
107
107
  Do not use `openxiangda form create` as the normal way to generate a user-facing page. It creates the old platform form shell and will show the legacy default schema until a workspace bundle is published.
108
108
 
109
+ ## Data Export
110
+
111
+ Use `openxiangda form export` for read-only data delivery. It reuses the active profile token and the workspace app binding, so do not hand-write export URLs or ask for AK/SK.
112
+
113
+ ```bash
114
+ openxiangda form export customer --mode xlsx --profile dev
115
+ openxiangda form export customer --mode xlsx-images --profile dev --all
116
+ openxiangda form export customer --mode package --profile dev --output ./exports/
117
+ ```
118
+
119
+ Modes:
120
+
121
+ - `xlsx`: normal Excel export. Images and attachments stay as links/text.
122
+ - `xlsx-images`: Excel export with `ImageField` images embedded when the server can read a supported `png` / `jpg` / `gif`; failures degrade to links.
123
+ - `package`: zip export containing `data.xlsx`, `attachments/`, and `manifest.json`; attachment/image cells use package-relative paths and the `Attachments` sheet lists one file per row.
124
+
125
+ Useful filters:
126
+
127
+ ```bash
128
+ openxiangda form export customer --mode package --profile dev --all --fields formInstId,instance_title,field_xxx
129
+ openxiangda form export customer --mode xlsx --profile dev --filters-json ./filters.json --order-json ./order.json
130
+ openxiangda form export customer --mode xlsx --profile dev --page 1 --page-size 100 --condition-type and --search-keyword 张三 --instance-status completed
131
+ ```
132
+
109
133
  ## Workspace Form Rules
110
134
 
111
135
  Read these references only when writing or reviewing schema:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openxiangda",
3
- "version": "1.0.147",
3
+ "version": "1.0.148",
4
4
  "description": "OpenXiangda CLI, workspace build tools, runtime SDK, and form components.",
5
5
  "private": false,
6
6
  "bin": {
@@ -62,6 +62,7 @@
62
62
  "scripts": {
63
63
  "build:sdk": "tsup --config packages/sdk/tsup.config.ts",
64
64
  "check": "node --check bin/openxiangda.js && node --check lib/*.js && node --check packages/sdk/src/build-source/src/cli.mjs && node --check packages/sdk/src/build-source/scripts/*.mjs && node --check packages/sdk/src/build-source/scripts/utils/*.mjs",
65
+ "test": "npm run check && node scripts/form-export-cli-smoke.mjs",
65
66
  "prepack": "npm run build:sdk",
66
67
  "test:profile-isolation": "bash scripts/profile-isolation-smoke.sh",
67
68
  "test:resource-plan": "node scripts/resource-plan-smoke.mjs",
@@ -31,6 +31,7 @@ pnpm build
31
31
  pnpm build-js-code
32
32
  pnpm deploy -- --profile <name>
33
33
  openxiangda workspace publish --profile <name> --form <formCode>
34
+ openxiangda form export <formCode> --mode package --profile <name> --output ./exports/
34
35
  openxiangda doctor --profile <name> --json
35
36
  openxiangda design gates --topic public-access --json
36
37
  openxiangda sdd context --json
@@ -22,6 +22,7 @@
22
22
  | 创建应用 / 新建 app / 初始化工作区 | `openxiangda-app` | `openxiangda workspace init <dir> --profile <name> --app-name "..."` |
23
23
  | 绑定已有应用 | `openxiangda-app` | `openxiangda workspace bind --profile <name> --app-type APP_XXX` |
24
24
  | 创建 / 改表单字段、schema、表单页 | `openxiangda-form` | 编辑 `src/forms/<code>/{schema.ts,page.tsx}` → `workspace publish --form <code>` |
25
+ | 导出表单数据 / 图片 / 附件 / Excel / zip | `openxiangda-form` | `openxiangda form export <formCode> --mode xlsx|xlsx-images|package --profile <name>` |
25
26
  | 创建 / 改自定义代码页、portal、看板 | `openxiangda-page` | 编辑 `src/pages/<code>/` → `workspace publish --page <code>` |
26
27
  | 审批流程 / 流程节点 / JS_CODE | `openxiangda-workflow-automation` | `openxiangda workflow validate / create / publish` |
27
28
  | 自动化 / 定时任务 / 提交触发 / cron | `openxiangda-workflow-automation` | `openxiangda automation validate / create / publish / enable` |