@hedeqiang/dsh-nl2sql 0.12.2 → 0.13.0
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/CHANGELOG.md +18 -1
- package/README.md +22 -0
- package/README.zh-CN.md +22 -0
- package/bin/nl2sql-mcp.mjs +103 -0
- package/cordis.patch.yml +4 -0
- package/docs/mcp.md +119 -0
- package/index.js +17 -1
- package/lib/client.js +1680 -6
- package/package.json +6 -1
- package/service/alerts.js +20 -6
- package/service/features.js +21 -0
- package/service/forecast.js +252 -0
- package/service/freshness.js +747 -0
- package/service/mcp.js +677 -0
- package/service/pptx.js +362 -0
- package/service/present.js +516 -0
- package/service/proactive.js +531 -0
- package/service/query.js +3 -2
- package/service/reconcile.js +565 -0
- package/service/shared.js +27 -0
- package/service/subscriptions.js +563 -0
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feature "present": analysis & presentation.
|
|
3
|
+
* - PowerPoint export of a dashboard or of results (service/pptx.js)
|
|
4
|
+
* - Long image of a dashboard (one tall SVG → PNG with resvg) and sending it to
|
|
5
|
+
* image-capable channels (Lark app bot, Telegram bot)
|
|
6
|
+
* - Lark Bitable (多维表格) sync of a widget / SQL result, on demand or daily
|
|
7
|
+
*
|
|
8
|
+
* state.bitableSyncs = [{ id, name, channelId, appToken, tableId, source: { widgetId } | { datasourceId, sql },
|
|
9
|
+
* mode: 'replace'|'append', schedule: { time: 'HH:MM', enabled }, lastRunAt?, lastStatus?, lastError?, createdAt, updatedAt }]
|
|
10
|
+
* Runs are logged as records of kind 'bitableRun' (ref: syncId).
|
|
11
|
+
*/
|
|
12
|
+
import * as echarts from 'echarts'
|
|
13
|
+
import { Resvg } from '@resvg/resvg-js'
|
|
14
|
+
import { chartOption, chartable, fmtNum, renderChartPng } from '../channels/message.js'
|
|
15
|
+
import { larkApi, resolveBaseUrl, getTenantToken } from '../channels/lark.js'
|
|
16
|
+
import { request } from '../channels/http.js'
|
|
17
|
+
import { admitSql } from '../sql-guard.js'
|
|
18
|
+
import { buildPptx, PPTX_MEDIA_TYPE } from './pptx.js'
|
|
19
|
+
import { UserError, lastDailySlot, newId, requireText } from './shared.js'
|
|
20
|
+
|
|
21
|
+
const MAX_WIDGETS = 30
|
|
22
|
+
const FONT_STACK = 'PingFang SC, Hiragino Sans GB, Microsoft YaHei, Noto Sans CJK SC, Noto Sans SC, sans-serif'
|
|
23
|
+
const stamp = (t = Date.now()) => new Date(t).toLocaleString('zh-CN', { hour12: false })
|
|
24
|
+
const safeFile = (s) => String(s || '数据问答').replace(/[\\/:*?"<>|\s]+/g, '_').slice(0, 60)
|
|
25
|
+
|
|
26
|
+
/** A KPI-shaped result: chart kpi, or one row with only a few numeric columns. */
|
|
27
|
+
function kpiOf(r) {
|
|
28
|
+
if (!r?.rows?.length) return null
|
|
29
|
+
const kpi = r.chart?.type === 'kpi' || (r.rows.length === 1 && r.columns.length <= 4 && r.columns.some(c => c.kind === 'number') && !chartable(r))
|
|
30
|
+
if (!kpi) return null
|
|
31
|
+
const names = r.chart?.y?.length ? r.chart.y : r.columns.filter(c => c.kind === 'number').map(c => c.name)
|
|
32
|
+
return names.slice(0, 6).map(name => {
|
|
33
|
+
const i = r.columns.findIndex(c => c.name === name)
|
|
34
|
+
const v = r.rows[0][i]
|
|
35
|
+
return { label: name, value: v === null || v === undefined || v === '' ? '—' : fmtNum(v) || String(v) }
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ------------------------------------------------------------- PPTX
|
|
40
|
+
|
|
41
|
+
function slideFor(r) {
|
|
42
|
+
const notes = [r.title, r.datasourceName ? `数据源:${r.datasourceName}` : '', r.rowCount !== undefined ? `${r.rowCount} 行${r.truncated ? '(已截断)' : ''}` : '', r.filters?.length ? `筛选:${r.filters.map(f => f.description).join(';')}` : '', r.sql ? `SQL:\n${r.sql}` : ''].filter(Boolean).join('\n')
|
|
43
|
+
const footer = [r.datasourceName, r.rowCount !== undefined ? `${r.rowCount} 行${r.truncated ? '(已截断)' : ''}` : '', (r.rowCount ?? r.rows?.length ?? 0) > 15 && !chartable(r) && !kpiOf(r) ? '幻灯片显示前 15 行' : ''].filter(Boolean).join(' · ')
|
|
44
|
+
const base = { title: r.title || '查询结果', notes, footer }
|
|
45
|
+
if (r.error) return { ...base, text: `查询失败:${r.error}`, error: true }
|
|
46
|
+
if (!r.rows?.length) return { ...base, text: '(无数据)' }
|
|
47
|
+
if (chartable(r)) {
|
|
48
|
+
try { return { ...base, image: renderChartPng(r, { width: 1200, height: 600 }), imageAspect: 2 } } catch (error) { /* fall back to a table */ base.footer = `${footer} · 图表渲染失败:${error.message}` }
|
|
49
|
+
}
|
|
50
|
+
const kpis = kpiOf(r)
|
|
51
|
+
if (kpis) return { ...base, kpis }
|
|
52
|
+
return { ...base, table: { columns: r.columns, rows: r.rows } }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ------------------------------------------------------------- long image (SVG)
|
|
56
|
+
|
|
57
|
+
const esc = (s) => String(s ?? '').replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
|
58
|
+
const charW = (ch, size) => (/[\u1100-\u115F\u2E80-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFF60\uFFE0-\uFFE6]/.test(ch) ? size : size * 0.58)
|
|
59
|
+
/** Truncate to fit `maxW` px at `size` px (approximate widths). */
|
|
60
|
+
export function fitText(s, maxW, size) {
|
|
61
|
+
const str = String(s ?? '').replace(/\r\n?|\n/g, ' ')
|
|
62
|
+
let w = 0
|
|
63
|
+
let out = ''
|
|
64
|
+
for (const ch of str) {
|
|
65
|
+
w += charW(ch, size)
|
|
66
|
+
if (w > maxW) return `${out.replace(/.$/u, '')}…`
|
|
67
|
+
out += ch
|
|
68
|
+
}
|
|
69
|
+
return out
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function chartSvg(r, w, h) {
|
|
73
|
+
const chart = echarts.init(null, null, { renderer: 'svg', ssr: true, width: w, height: h })
|
|
74
|
+
try {
|
|
75
|
+
chart.setOption(chartOption(r))
|
|
76
|
+
return chart.renderToSVGString()
|
|
77
|
+
} finally {
|
|
78
|
+
chart.dispose()
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Cell body: { h, draw(x, y, w) → svg } */
|
|
83
|
+
function cellBody(r, w) {
|
|
84
|
+
const pad = 16
|
|
85
|
+
if (r.error) return { h: 60, draw: (x, y) => `<text x="${x + pad}" y="${y + 34}" font-size="14" fill="#f54a45">${esc(fitText(`查询失败:${r.error}`, w - 2 * pad, 14))}</text>` }
|
|
86
|
+
if (!r.rows?.length) return { h: 60, draw: (x, y) => `<text x="${x + pad}" y="${y + 34}" font-size="14" fill="#8f959e">(无数据)</text>` }
|
|
87
|
+
if (chartable(r)) {
|
|
88
|
+
const ch = 320
|
|
89
|
+
let inner
|
|
90
|
+
try { inner = chartSvg(r, w - 2 * pad, ch) } catch { inner = null }
|
|
91
|
+
if (inner) return { h: ch + 8, draw: (x, y) => inner.replace(/^<svg\b/, `<svg x="${x + pad}" y="${y}"`) }
|
|
92
|
+
}
|
|
93
|
+
const kpis = kpiOf(r)
|
|
94
|
+
if (kpis) {
|
|
95
|
+
const per = (w - 2 * pad) / kpis.length
|
|
96
|
+
const size = kpis.length > 3 ? 30 : 40
|
|
97
|
+
return { h: 110, draw: (x, y) => kpis.map((k, i) => {
|
|
98
|
+
const cx = x + pad + per * i + per / 2
|
|
99
|
+
return `<text x="${cx}" y="${y + 52}" text-anchor="middle" font-size="${size}" font-weight="700" fill="#3370ff">${esc(fitText(k.value, per - 8, size))}</text><text x="${cx}" y="${y + 84}" text-anchor="middle" font-size="14" fill="#646a73">${esc(fitText(k.label, per - 8, 14))}</text>`
|
|
100
|
+
}).join('') }
|
|
101
|
+
}
|
|
102
|
+
const cols = r.columns.slice(0, 6)
|
|
103
|
+
const rows = r.rows.slice(0, 12)
|
|
104
|
+
const rowH = 28
|
|
105
|
+
const colW = (w - 2 * pad) / Math.max(1, cols.length)
|
|
106
|
+
const more = (r.rowCount ?? r.rows.length) > rows.length || r.columns.length > cols.length
|
|
107
|
+
return { h: rowH * (rows.length + 1) + (more ? 30 : 10), draw: (x, y) => {
|
|
108
|
+
const x0 = x + pad
|
|
109
|
+
let out = `<rect x="${x0}" y="${y}" width="${w - 2 * pad}" height="${rowH}" rx="4" fill="#f2f3f5"/>`
|
|
110
|
+
cols.forEach((c, ci) => {
|
|
111
|
+
const num = c.kind === 'number'
|
|
112
|
+
const tx = num ? x0 + colW * (ci + 1) - 8 : x0 + colW * ci + 8
|
|
113
|
+
out += `<text x="${tx}" y="${y + 19}" font-size="13" font-weight="600" fill="#1f2329"${num ? ' text-anchor="end"' : ''}>${esc(fitText(c.name, colW - 16, 13))}</text>`
|
|
114
|
+
})
|
|
115
|
+
rows.forEach((row, ri) => {
|
|
116
|
+
const ry = y + rowH * (ri + 1)
|
|
117
|
+
out += `<line x1="${x0}" y1="${ry + rowH}" x2="${x0 + w - 2 * pad}" y2="${ry + rowH}" stroke="#eff0f1"/>`
|
|
118
|
+
cols.forEach((c, ci) => {
|
|
119
|
+
const num = c.kind === 'number'
|
|
120
|
+
const v = row[ci]
|
|
121
|
+
const text = v === null || v === undefined ? 'NULL' : num ? fmtNum(v) : String(v)
|
|
122
|
+
const tx = num ? x0 + colW * (ci + 1) - 8 : x0 + colW * ci + 8
|
|
123
|
+
out += `<text x="${tx}" y="${ry + 19}" font-size="13" fill="${v === null ? '#8f959e' : '#1f2329'}"${num ? ' text-anchor="end"' : ''}>${esc(fitText(text, colW - 16, 13))}</text>`
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
if (more) out += `<text x="${x0}" y="${y + rowH * (rows.length + 1) + 20}" font-size="12" fill="#8f959e">${esc(`共 ${r.rowCount ?? r.rows.length} 行 × ${r.columns.length} 列,图中显示前 ${rows.length} 行 × ${cols.length} 列`)}</text>`
|
|
127
|
+
return out
|
|
128
|
+
} }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* One tall SVG of a dashboard: header, then the widgets in a 2-column grid
|
|
133
|
+
* (full-width widgets span both columns). items: [{ result, width }].
|
|
134
|
+
*/
|
|
135
|
+
export function dashboardSvg({ title, subtitle, items, width = 1200 }) {
|
|
136
|
+
const W = Math.max(600, Math.min(2400, Math.round(Number(width) || 1200)))
|
|
137
|
+
const M = 32
|
|
138
|
+
const GAP = 20
|
|
139
|
+
const colW = (W - 2 * M - GAP) / 2
|
|
140
|
+
let y = 0
|
|
141
|
+
let body = ''
|
|
142
|
+
// header
|
|
143
|
+
body += `<rect x="0" y="0" width="${W}" height="8" fill="#3370ff"/>`
|
|
144
|
+
body += `<text x="${M}" y="${y + 64}" font-size="30" font-weight="700" fill="#1f2329">${esc(fitText(title, W - 2 * M, 30))}</text>`
|
|
145
|
+
const sub = String(subtitle ?? '').split('\n').filter(Boolean)
|
|
146
|
+
sub.forEach((line, i) => { body += `<text x="${M}" y="${y + 96 + i * 22}" font-size="14" fill="#646a73">${esc(fitText(line, W - 2 * M, 14))}</text>` })
|
|
147
|
+
y += 96 + sub.length * 22 + 8
|
|
148
|
+
// grid rows
|
|
149
|
+
let row = []
|
|
150
|
+
const flush = () => {
|
|
151
|
+
if (!row.length) return
|
|
152
|
+
const cells = row.map(c => ({ ...c, body: cellBody(c.result, c.w) }))
|
|
153
|
+
const hgt = Math.max(...cells.map(c => c.body.h)) + 52
|
|
154
|
+
for (const c of cells) {
|
|
155
|
+
body += `<rect x="${c.x}" y="${y}" width="${c.w}" height="${hgt}" rx="10" fill="#ffffff" stroke="#dee0e3"/>`
|
|
156
|
+
body += `<text x="${c.x + 16}" y="${y + 30}" font-size="16" font-weight="600" fill="#1f2329">${esc(fitText(c.result.title || '', c.w - 32, 16))}</text>`
|
|
157
|
+
body += c.body.draw(c.x, y + 44, c.w)
|
|
158
|
+
}
|
|
159
|
+
y += hgt + GAP
|
|
160
|
+
row = []
|
|
161
|
+
}
|
|
162
|
+
for (const it of items) {
|
|
163
|
+
const full = it.width === 'full' || items.length === 1
|
|
164
|
+
if (full) { flush(); row = [{ result: it.result, x: M, w: W - 2 * M }]; flush(); continue }
|
|
165
|
+
row.push({ result: it.result, x: row.length ? M + colW + GAP : M, w: colW })
|
|
166
|
+
if (row.length === 2) flush()
|
|
167
|
+
}
|
|
168
|
+
flush()
|
|
169
|
+
y += M - GAP + 20
|
|
170
|
+
body += `<text x="${W - M}" y="${y - 14}" text-anchor="end" font-size="12" fill="#8f959e">DeepSeek Harness · 数据问答</text>`
|
|
171
|
+
const H = Math.ceil(y)
|
|
172
|
+
return { svg: `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" font-family="${esc(FONT_STACK)}"><rect width="${W}" height="${H}" fill="#f5f6f7"/>${body}</svg>`, width: W, height: H }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function rasterize(svg, width, scale = 1) {
|
|
176
|
+
return new Resvg(svg, { fitTo: { mode: 'width', value: Math.round(width * scale) }, font: { loadSystemFonts: true, defaultFontFamily: 'PingFang SC' } }).render().asPng()
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ------------------------------------------------------------- Bitable helpers
|
|
180
|
+
|
|
181
|
+
/** Lark field types: 1 text, 2 number, 5 date. */
|
|
182
|
+
export const FIELD_TYPE = { text: 1, number: 2, datetime: 5 }
|
|
183
|
+
export function fieldTypeFor(kind) {
|
|
184
|
+
return kind === 'number' ? FIELD_TYPE.number : kind === 'time' ? FIELD_TYPE.datetime : FIELD_TYPE.text
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** A cell as a Bitable value for a field type; undefined = leave empty. */
|
|
188
|
+
export function bitableValue(v, type) {
|
|
189
|
+
if (v === null || v === undefined || v === '') return undefined
|
|
190
|
+
if (type === FIELD_TYPE.number) { const n = Number(v); return Number.isFinite(n) ? n : undefined }
|
|
191
|
+
if (type === FIELD_TYPE.datetime) {
|
|
192
|
+
if (v instanceof Date) return Number.isFinite(v.getTime()) ? v.getTime() : undefined
|
|
193
|
+
if (typeof v === 'number') return v > 1e11 ? v : v * 1000
|
|
194
|
+
const s = String(v).trim()
|
|
195
|
+
// Bare dates and "YYYY-MM-DD HH:MM[:SS]" are local times; ISO strings with a zone keep it.
|
|
196
|
+
const m = s.match(/^(\d{4})-(\d{1,2})-(\d{1,2})(?:[ T](\d{1,2}):(\d{2})(?::(\d{2}))?)?$/)
|
|
197
|
+
const t = m ? new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), Number(m[4] ?? 0), Number(m[5] ?? 0), Number(m[6] ?? 0)).getTime() : Date.parse(s)
|
|
198
|
+
return Number.isFinite(t) ? t : undefined
|
|
199
|
+
}
|
|
200
|
+
if (typeof v === 'object') return JSON.stringify(v)
|
|
201
|
+
return String(v)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const BITABLE_HINTS = {
|
|
205
|
+
91402: '多维表格不存在或 app_token 错误',
|
|
206
|
+
1254003: 'app_token 错误:请从多维表格链接(…/base/<app_token>)中复制',
|
|
207
|
+
1254004: 'table_id 错误:请从链接中的 table= 参数复制',
|
|
208
|
+
1254040: 'app_token 不存在',
|
|
209
|
+
1254041: 'table_id 不存在',
|
|
210
|
+
1254302: '应用没有该多维表格的权限:请在多维表格「… → 更多 → 添加文档应用」中添加该应用并给予可编辑权限',
|
|
211
|
+
1254304: '应用没有该多维表格的权限',
|
|
212
|
+
91403: '应用没有该多维表格的权限:请在多维表格中添加该文档应用',
|
|
213
|
+
99991672: '应用缺少权限:请在开发者后台开通 bitable:app(查看、评论、编辑和管理多维表格)',
|
|
214
|
+
1254103: '数据表记录数已达上限(20,000 条)',
|
|
215
|
+
1254014: '字段名重复',
|
|
216
|
+
}
|
|
217
|
+
function bitableError(error, step) {
|
|
218
|
+
if (error instanceof UserError) return error
|
|
219
|
+
const code = typeof error.code === 'number' ? error.code : undefined
|
|
220
|
+
const hint = code !== undefined ? BITABLE_HINTS[code] : undefined
|
|
221
|
+
return new UserError('nl2sql/bitable', `${step}失败:${hint ? `${hint}(${error.larkMsg ?? error.message})` : error.message}`)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const chunk = (list, n) => Array.from({ length: Math.ceil(list.length / n) }, (_, i) => list.slice(i * n, i * n + n))
|
|
225
|
+
const encP = encodeURIComponent
|
|
226
|
+
|
|
227
|
+
// ------------------------------------------------------------- service methods
|
|
228
|
+
|
|
229
|
+
export const methods = {
|
|
230
|
+
/** Run every widget of a dashboard → { d, results:[{ ...result, title, width }] }. */
|
|
231
|
+
async presentDashboardResults(dashboardId, filters) {
|
|
232
|
+
const d = dashboardId ? this.requireDashboard(dashboardId) : this.currentDashboardObj()
|
|
233
|
+
const results = []
|
|
234
|
+
for (const w of d.widgets.slice(0, MAX_WIDGETS)) {
|
|
235
|
+
try { results.push({ ...(await this.runWidget(w.id, filters ?? d.filters)), title: w.title, width: w.width }) } catch (error) { results.push({ title: w.title, width: w.width, error: error.message, columns: [], rows: [], sql: w.sql, datasourceName: this.findDatasource(w.datasourceId)?.name }) }
|
|
236
|
+
}
|
|
237
|
+
if (!results.length) throw new UserError('nl2sql/invalid', '看板为空,没有可导出的组件')
|
|
238
|
+
return { d, results }
|
|
239
|
+
},
|
|
240
|
+
|
|
241
|
+
presentSourceNames(results) {
|
|
242
|
+
return [...new Set(results.map(r => r.datasourceName).filter(Boolean))]
|
|
243
|
+
},
|
|
244
|
+
|
|
245
|
+
presentPptx(title, results, extraLines = []) {
|
|
246
|
+
const sources = this.presentSourceNames(results)
|
|
247
|
+
const buf = buildPptx({
|
|
248
|
+
title,
|
|
249
|
+
subtitle: stamp(),
|
|
250
|
+
lines: [sources.length ? `数据源:${sources.join('、')}` : '', ...extraLines].filter(Boolean),
|
|
251
|
+
slides: results.map(slideFor),
|
|
252
|
+
})
|
|
253
|
+
return { fileName: `${safeFile(title)}-${new Date().toISOString().slice(0, 10)}.pptx`, mediaType: PPTX_MEDIA_TYPE, base64: buf.toString('base64'), bytes: buf.length, slides: results.length + 1 }
|
|
254
|
+
},
|
|
255
|
+
|
|
256
|
+
async exportDashboardPptx({ dashboardId, filters } = {}) {
|
|
257
|
+
const { d, results } = await this.presentDashboardResults(dashboardId, filters)
|
|
258
|
+
const f = results.flatMap(r => r.filters ?? []).map(x => x.description)
|
|
259
|
+
return this.presentPptx(d.name || '数据看板', results, f.length ? [`筛选:${[...new Set(f)].join(';')}`] : [])
|
|
260
|
+
},
|
|
261
|
+
|
|
262
|
+
async exportResultsPptx({ resultIds = [], title } = {}) {
|
|
263
|
+
const results = (Array.isArray(resultIds) ? resultIds : [resultIds]).slice(0, 50).map(id => this.lookupResult(id)).filter(Boolean)
|
|
264
|
+
if (!results.length) throw new UserError('nl2sql/result-expired', '没有可导出的结果(可能已过期),请重新查询')
|
|
265
|
+
return this.presentPptx(String(title || results[0].title || '数据问答').slice(0, 120), results)
|
|
266
|
+
},
|
|
267
|
+
|
|
268
|
+
async dashboardImagePng({ dashboardId, filters, width = 1200 } = {}) {
|
|
269
|
+
const { d, results } = await this.presentDashboardResults(dashboardId, filters)
|
|
270
|
+
const sources = this.presentSourceNames(results)
|
|
271
|
+
const f = [...new Set(results.flatMap(r => r.filters ?? []).map(x => x.description))]
|
|
272
|
+
const { svg, width: W, height: H } = dashboardSvg({
|
|
273
|
+
title: d.name || '数据看板',
|
|
274
|
+
subtitle: [`生成时间:${stamp()}`, sources.length ? `数据源:${sources.join('、')}` : '', f.length ? `筛选:${f.join(';')}` : ''].filter(Boolean).join('\n'),
|
|
275
|
+
items: results.map(r => ({ result: r, width: r.width })),
|
|
276
|
+
width,
|
|
277
|
+
})
|
|
278
|
+
// 2x for sharpness, capped so very tall boards stay a reasonable size.
|
|
279
|
+
const scale = H * 2 > 16000 ? Math.max(1, 16000 / H) : 2
|
|
280
|
+
const png = rasterize(svg, W, scale)
|
|
281
|
+
return { d, png, width: Math.round(W * scale), height: Math.round(H * scale), widgets: results.length }
|
|
282
|
+
},
|
|
283
|
+
|
|
284
|
+
async exportDashboardImage(input = {}) {
|
|
285
|
+
const { d, png, width, height, widgets } = await this.dashboardImagePng(input)
|
|
286
|
+
return { fileName: `${safeFile(d.name || '数据看板')}-${new Date().toISOString().slice(0, 10)}.png`, mediaType: 'image/png', base64: png.toString('base64'), bytes: png.length, width, height, widgets }
|
|
287
|
+
},
|
|
288
|
+
|
|
289
|
+
/** Send the dashboard long image to a channel that can carry images (Lark app bot, Telegram bot). */
|
|
290
|
+
async sendDashboardImage({ dashboardId, channelId, filters, width } = {}) {
|
|
291
|
+
const c = this.requireChannel(channelId)
|
|
292
|
+
if (c.needsSecret) throw new UserError('nl2sql/invalid', `渠道「${c.name}」需要重新填写密钥后才能使用`)
|
|
293
|
+
if (!['lark_app', 'telegram_bot'].includes(c.type)) throw new UserError('nl2sql/unsupported', `渠道「${c.name}」不支持发送图片:长图只能发送到飞书应用机器人或 Telegram 机器人渠道(群机器人 Webhook、企业微信、钉钉暂不支持图片上传)。可改用「推送看板」发送卡片。`)
|
|
294
|
+
const ch = this.adapterChannel(c)
|
|
295
|
+
const secrets = await this.channelSecrets(c)
|
|
296
|
+
const { d, png, width: W, height: H } = await this.dashboardImagePng({ dashboardId, filters, width })
|
|
297
|
+
const started = Date.now()
|
|
298
|
+
const title = d.name || '数据看板'
|
|
299
|
+
try {
|
|
300
|
+
let messageId = null
|
|
301
|
+
if (c.type === 'lark_app') messageId = await this.presentLarkImage(ch, secrets, png)
|
|
302
|
+
else messageId = await this.presentTelegramImage(ch, secrets, png, { title, width: W, height: H })
|
|
303
|
+
c.lastSentAt = Date.now()
|
|
304
|
+
delete c.lastError
|
|
305
|
+
this.recordDelivery({ channel: c, title: `${title}(长图)`, status: 'ok', results: 0, images: 1, source: 'dashboard', elapsedMs: Date.now() - started })
|
|
306
|
+
await this.save()
|
|
307
|
+
return { ok: true, channel: c.name, platform: c.platform, messageId, images: 1, bytes: png.length }
|
|
308
|
+
} catch (error) {
|
|
309
|
+
c.lastError = error.message
|
|
310
|
+
this.recordDelivery({ channel: c, title: `${title}(长图)`, status: 'error', error: error.message, results: 0, source: 'dashboard', elapsedMs: Date.now() - started })
|
|
311
|
+
await this.save()
|
|
312
|
+
throw error instanceof UserError ? error : new UserError(error.code && /^(im|channel)\//.test(error.code) ? error.code : 'nl2sql/send-failed', error.message)
|
|
313
|
+
}
|
|
314
|
+
},
|
|
315
|
+
|
|
316
|
+
/** Upload a PNG with the app's tenant token and send it as an image message. */
|
|
317
|
+
async presentLarkImage(ch, secrets, png) {
|
|
318
|
+
if (!ch.appId || !secrets.appSecret) throw new UserError('nl2sql/invalid', '缺少 App ID 或 App Secret')
|
|
319
|
+
if (!ch.receiveId) throw new UserError('nl2sql/invalid', '渠道缺少接收者 ID')
|
|
320
|
+
const base = this.config?.larkBaseUrl ?? resolveBaseUrl({ domain: ch.domain, baseUrl: ch.baseUrl })
|
|
321
|
+
const token = await getTenantToken({ appId: ch.appId, appSecret: secrets.appSecret, domain: ch.domain, baseUrl: base })
|
|
322
|
+
if (png.length > 10 * 1024 * 1024) throw new UserError('nl2sql/too-large', '长图超过飞书图片上限 10 MB,请减少看板组件或调小宽度')
|
|
323
|
+
const form = new FormData()
|
|
324
|
+
form.set('image_type', 'message')
|
|
325
|
+
form.set('image', new Blob([png], { type: 'image/png' }), 'dashboard.png')
|
|
326
|
+
const up = await request(`${base}/open-apis/im/v1/images`, { method: 'POST', headers: { authorization: `Bearer ${token}` }, body: form, timeoutMs: 60000, label: '飞书' })
|
|
327
|
+
if (up.json?.code !== 0) throw new UserError('nl2sql/send-failed', `飞书图片上传失败:${up.json?.msg ?? `HTTP ${up.status}`}${up.json?.code === 99991672 ? '(请为应用开通 im:resource 权限)' : ''}`)
|
|
328
|
+
const type = ['chat_id', 'open_id', 'user_id', 'union_id', 'email'].includes(ch.receiveIdType) ? ch.receiveIdType : 'chat_id'
|
|
329
|
+
const sent = await request(`${base}/open-apis/im/v1/messages?receive_id_type=${type}`, { method: 'POST', headers: { authorization: `Bearer ${token}` }, json: { receive_id: ch.receiveId, msg_type: 'image', content: JSON.stringify({ image_key: up.json.data.image_key }) }, label: '飞书' })
|
|
330
|
+
if (sent.json?.code !== 0) throw new UserError('nl2sql/send-failed', `飞书发送图片失败:${sent.json?.msg ?? `HTTP ${sent.status}`}`)
|
|
331
|
+
return sent.json.data?.message_id ?? null
|
|
332
|
+
},
|
|
333
|
+
|
|
334
|
+
/** sendPhoto, or sendDocument when the image exceeds Telegram's photo limits (w+h ≤ 10000, ratio ≤ 20, ≤ 10 MB). */
|
|
335
|
+
async presentTelegramImage(ch, secrets, png, { title, width, height }) {
|
|
336
|
+
const { callApi, apiBaseOf } = await import('../channels/telegram.js')
|
|
337
|
+
const chatId = ch.chatId ?? ch.config?.chatId
|
|
338
|
+
if (!chatId) throw new UserError('nl2sql/invalid', '渠道缺少 Chat ID(推送目标)')
|
|
339
|
+
const asPhoto = width + height <= 10000 && Math.max(width / height, height / width) <= 20 && png.length <= 10 * 1024 * 1024
|
|
340
|
+
const form = new FormData()
|
|
341
|
+
form.append('chat_id', String(chatId))
|
|
342
|
+
form.append('caption', String(title).slice(0, 1000))
|
|
343
|
+
form.append(asPhoto ? 'photo' : 'document', new Blob([png], { type: 'image/png' }), `${safeFile(title)}.png`)
|
|
344
|
+
const msg = await callApi({ base: apiBaseOf(ch), token: secrets.botToken }, asPhoto ? 'sendPhoto' : 'sendDocument', undefined, { form, timeoutMs: 60000 })
|
|
345
|
+
return msg?.message_id !== undefined ? String(msg.message_id) : null
|
|
346
|
+
},
|
|
347
|
+
|
|
348
|
+
// ----------------------------------------------------------- Bitable sync
|
|
349
|
+
|
|
350
|
+
listBitableSyncs() {
|
|
351
|
+
const syncs = this.state.bitableSyncs ?? []
|
|
352
|
+
return syncs.map(s => ({
|
|
353
|
+
...s,
|
|
354
|
+
channelName: this.state.channels.find(c => c.id === s.channelId)?.name ?? null,
|
|
355
|
+
sourceLabel: s.source?.widgetId ? (this.findWidget(s.source.widgetId).w?.title ?? null) : (this.findDatasource(s.source?.datasourceId)?.name ?? null),
|
|
356
|
+
runs: this.records.list('bitableRun', { ref: s.id, limit: 5 }),
|
|
357
|
+
}))
|
|
358
|
+
},
|
|
359
|
+
|
|
360
|
+
async saveBitableSync(input = {}) {
|
|
361
|
+
this.state.bitableSyncs ??= []
|
|
362
|
+
const existing = input.id ? this.state.bitableSyncs.find(s => s.id === input.id) : undefined
|
|
363
|
+
if (input.id && !existing) throw new UserError('nl2sql/not-found', '同步任务不存在')
|
|
364
|
+
const merged = { ...(existing ?? {}), ...input }
|
|
365
|
+
const name = requireText(merged.name, 'name', 80)
|
|
366
|
+
const c = this.state.channels.find(x => x.id === merged.channelId)
|
|
367
|
+
if (!c) throw Object.assign(new UserError('nl2sql/invalid', '请选择一个飞书应用机器人渠道(提供 App ID / Secret)'), { field: 'channelId' })
|
|
368
|
+
if (c.type !== 'lark_app') throw Object.assign(new UserError('nl2sql/invalid', `渠道「${c.name}」不是飞书应用机器人:多维表格同步需要应用凭据(App ID / Secret)`), { field: 'channelId' })
|
|
369
|
+
const appToken = String(merged.appToken ?? '').trim()
|
|
370
|
+
const tableId = String(merged.tableId ?? '').trim()
|
|
371
|
+
if (!/^[A-Za-z0-9]{8,64}$/.test(appToken)) throw Object.assign(new UserError('nl2sql/invalid', 'app_token 格式不正确:请从多维表格链接 …/base/<app_token>?table=… 中复制'), { field: 'appToken' })
|
|
372
|
+
if (!/^tbl[A-Za-z0-9]{4,40}$/.test(tableId)) throw Object.assign(new UserError('nl2sql/invalid', 'table_id 格式不正确:应以 tbl 开头(链接中的 table= 参数)'), { field: 'tableId' })
|
|
373
|
+
const src = merged.source ?? {}
|
|
374
|
+
let source
|
|
375
|
+
if (src.widgetId) {
|
|
376
|
+
if (!this.findWidget(src.widgetId).w) throw Object.assign(new UserError('nl2sql/invalid', '看板组件不存在'), { field: 'source' })
|
|
377
|
+
source = { widgetId: src.widgetId }
|
|
378
|
+
} else {
|
|
379
|
+
const ds = this.requireDatasource(src.datasourceId)
|
|
380
|
+
const sql = requireText(src.sql, 'sql', 20000)
|
|
381
|
+
try { admitSql(sql, { dialect: ds.type ?? 'mysql' }) } catch (error) { throw Object.assign(new UserError('nl2sql/invalid', error.message), { field: 'sql' }) }
|
|
382
|
+
source = { datasourceId: ds.id, sql }
|
|
383
|
+
}
|
|
384
|
+
const time = /^\d{1,2}:\d{2}$/.test(String(merged.schedule?.time ?? '')) ? String(merged.schedule.time).padStart(5, '0') : '08:00'
|
|
385
|
+
const sync = {
|
|
386
|
+
id: existing?.id ?? newId('bt'),
|
|
387
|
+
name, channelId: c.id, appToken, tableId, source,
|
|
388
|
+
mode: merged.mode === 'append' ? 'append' : 'replace',
|
|
389
|
+
schedule: { time, enabled: Boolean(merged.schedule?.enabled) },
|
|
390
|
+
createdAt: existing?.createdAt ?? Date.now(),
|
|
391
|
+
updatedAt: Date.now(),
|
|
392
|
+
...(existing ? { lastRunAt: existing.lastRunAt, lastStatus: existing.lastStatus, lastError: existing.lastError, lastRows: existing.lastRows } : {}),
|
|
393
|
+
}
|
|
394
|
+
const i = this.state.bitableSyncs.findIndex(s => s.id === sync.id)
|
|
395
|
+
if (i >= 0) this.state.bitableSyncs[i] = sync; else this.state.bitableSyncs.push(sync)
|
|
396
|
+
await this.save()
|
|
397
|
+
return sync
|
|
398
|
+
},
|
|
399
|
+
|
|
400
|
+
async deleteBitableSync(id) {
|
|
401
|
+
const before = (this.state.bitableSyncs ?? []).length
|
|
402
|
+
this.state.bitableSyncs = (this.state.bitableSyncs ?? []).filter(s => s.id !== id)
|
|
403
|
+
if (this.state.bitableSyncs.length !== before) await this.save()
|
|
404
|
+
return { deleted: id }
|
|
405
|
+
},
|
|
406
|
+
|
|
407
|
+
async runBitableSync(id, { trigger = 'manual' } = {}) {
|
|
408
|
+
const sync = (this.state.bitableSyncs ?? []).find(s => s.id === id)
|
|
409
|
+
if (!sync) throw new UserError('nl2sql/not-found', '同步任务不存在')
|
|
410
|
+
return this.withJobLock(`bitable:${id}`, () => this.runBitableSyncInner(sync, trigger))
|
|
411
|
+
},
|
|
412
|
+
|
|
413
|
+
async runBitableSyncInner(sync, trigger) {
|
|
414
|
+
const started = Date.now()
|
|
415
|
+
const run = { id: newId('br'), at: started, syncId: sync.id, name: sync.name, trigger, mode: sync.mode, status: 'ok', rows: 0, created: 0, deleted: 0, fieldsCreated: [] }
|
|
416
|
+
try {
|
|
417
|
+
const c = this.state.channels.find(x => x.id === sync.channelId)
|
|
418
|
+
if (!c || c.type !== 'lark_app') throw new UserError('nl2sql/invalid', '同步任务关联的飞书应用机器人渠道不存在')
|
|
419
|
+
const ch = this.adapterChannel(c)
|
|
420
|
+
const secrets = await this.channelSecrets(c)
|
|
421
|
+
if (!ch.appId || !secrets.appSecret) throw new UserError('nl2sql/invalid', `渠道「${c.name}」缺少 App ID 或 App Secret`)
|
|
422
|
+
const opts = { appId: ch.appId, appSecret: secrets.appSecret, domain: ch.domain, baseUrl: this.config?.larkBaseUrl ?? ch.baseUrl }
|
|
423
|
+
// 1. data (always through runQuery: admission, policy, row rules, masking, audit)
|
|
424
|
+
const r = sync.source?.widgetId
|
|
425
|
+
? await this.runWidget(sync.source.widgetId, undefined, { useCache: false })
|
|
426
|
+
: await this.runQuery({ sql: sync.source.sql, datasourceId: sync.source.datasourceId, title: `多维表格同步:${sync.name}`, source: 'bitable', confirmScan: true, useCache: false })
|
|
427
|
+
run.rows = r.rows.length
|
|
428
|
+
run.truncated = Boolean(r.truncated)
|
|
429
|
+
const base = `/open-apis/bitable/v1/apps/${encP(sync.appToken)}/tables/${encP(sync.tableId)}`
|
|
430
|
+
const api = (path, init, step) => larkApi(opts, base + path, init).catch(error => { throw bitableError(error, step) })
|
|
431
|
+
// 2. fields: create missing ones by column kind
|
|
432
|
+
const fields = new Map()
|
|
433
|
+
let pageToken = ''
|
|
434
|
+
for (let page = 0; page < 20; page++) {
|
|
435
|
+
const json = await api(`/fields?page_size=100${pageToken ? `&page_token=${encP(pageToken)}` : ''}`, {}, '读取字段')
|
|
436
|
+
for (const f of json.data?.items ?? []) fields.set(f.field_name, f.type)
|
|
437
|
+
if (!json.data?.has_more || !json.data?.page_token) break
|
|
438
|
+
pageToken = json.data.page_token
|
|
439
|
+
}
|
|
440
|
+
const cols = r.columns.map(col => ({ name: String(col.name).trim().slice(0, 100) || 'column', kind: col.kind }))
|
|
441
|
+
for (const col of cols) {
|
|
442
|
+
if (fields.has(col.name)) continue
|
|
443
|
+
const type = fieldTypeFor(col.kind)
|
|
444
|
+
const body = { field_name: col.name, type, ...(type === FIELD_TYPE.datetime ? { property: { date_formatter: 'yyyy-MM-dd HH:mm' } } : type === FIELD_TYPE.number ? { property: { formatter: '0.00' } } : {}) }
|
|
445
|
+
await api('/fields', { method: 'POST', body }, `创建字段「${col.name}」`)
|
|
446
|
+
fields.set(col.name, type)
|
|
447
|
+
run.fieldsCreated.push(col.name)
|
|
448
|
+
}
|
|
449
|
+
// 3. replace mode: delete existing records (≤ 500 per call)
|
|
450
|
+
if (sync.mode === 'replace') {
|
|
451
|
+
const ids = []
|
|
452
|
+
let token = ''
|
|
453
|
+
for (let page = 0; page < 100; page++) {
|
|
454
|
+
const json = await api(`/records?page_size=500${token ? `&page_token=${encP(token)}` : ''}`, {}, '读取记录')
|
|
455
|
+
for (const rec of json.data?.items ?? []) ids.push(rec.record_id ?? rec.id)
|
|
456
|
+
if (!json.data?.has_more || !json.data?.page_token) break
|
|
457
|
+
token = json.data.page_token
|
|
458
|
+
}
|
|
459
|
+
for (const part of chunk(ids.filter(Boolean), 500)) {
|
|
460
|
+
await api('/records/batch_delete', { method: 'POST', body: { records: part } }, '删除旧记录')
|
|
461
|
+
run.deleted += part.length
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
// 4. insert (≤ 500 per call; values converted to each field's actual type)
|
|
465
|
+
const typeOf = cols.map(col => { const t = fields.get(col.name); return t === FIELD_TYPE.number || t === FIELD_TYPE.datetime ? t : FIELD_TYPE.text })
|
|
466
|
+
const records = r.rows.map(row => {
|
|
467
|
+
const f = {}
|
|
468
|
+
cols.forEach((col, i) => { const v = bitableValue(row[i], typeOf[i]); if (v !== undefined) f[col.name] = v })
|
|
469
|
+
return { fields: f }
|
|
470
|
+
})
|
|
471
|
+
for (const part of chunk(records, 500)) {
|
|
472
|
+
await api('/records/batch_create', { method: 'POST', body: { records: part } }, '写入记录')
|
|
473
|
+
run.created += part.length
|
|
474
|
+
}
|
|
475
|
+
} catch (error) {
|
|
476
|
+
run.status = 'error'
|
|
477
|
+
run.error = String(error.message).slice(0, 500)
|
|
478
|
+
}
|
|
479
|
+
run.elapsedMs = Date.now() - started
|
|
480
|
+
const cur = (this.state.bitableSyncs ?? []).find(s => s.id === sync.id)
|
|
481
|
+
if (cur) Object.assign(cur, { lastRunAt: started, lastStatus: run.status, lastError: run.error ?? undefined, lastRows: run.created })
|
|
482
|
+
this.records.put('bitableRun', run)
|
|
483
|
+
await this.save()
|
|
484
|
+
if (run.status === 'error' && trigger === 'manual') throw new UserError('nl2sql/bitable', run.error)
|
|
485
|
+
return run
|
|
486
|
+
},
|
|
487
|
+
|
|
488
|
+
listBitableRuns({ syncId, limit = 50 } = {}) {
|
|
489
|
+
return this.records.list('bitableRun', { ref: syncId, limit: Math.min(500, Number(limit) || 50) })
|
|
490
|
+
},
|
|
491
|
+
|
|
492
|
+
/** Scheduler: each enabled sync once per day at its time (catches up once after downtime). */
|
|
493
|
+
jobs_present(now) {
|
|
494
|
+
return (this.state.bitableSyncs ?? []).filter(s => s.schedule?.enabled).map(s => ({
|
|
495
|
+
id: `bitable:${s.id}`,
|
|
496
|
+
due: (n) => Math.max(s.lastRunAt ?? 0, s.createdAt ?? 0) < lastDailySlot(s.schedule.time, n),
|
|
497
|
+
run: () => this.runBitableSyncInner((this.state.bitableSyncs ?? []).find(x => x.id === s.id) ?? s, 'schedule'),
|
|
498
|
+
}))
|
|
499
|
+
},
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
export const FEATURE = {
|
|
503
|
+
name: 'present',
|
|
504
|
+
methods,
|
|
505
|
+
rpc: (service) => ({
|
|
506
|
+
exportDashboardPptx: (p) => service.exportDashboardPptx(p ?? {}),
|
|
507
|
+
exportResultsPptx: (p) => service.exportResultsPptx(p ?? {}),
|
|
508
|
+
exportDashboardImage: (p) => service.exportDashboardImage(p ?? {}),
|
|
509
|
+
sendDashboardImage: (p) => service.sendDashboardImage(p ?? {}),
|
|
510
|
+
listBitableSyncs: () => service.listBitableSyncs(),
|
|
511
|
+
saveBitableSync: (p) => service.saveBitableSync(p ?? {}),
|
|
512
|
+
deleteBitableSync: (p) => service.deleteBitableSync(p?.id),
|
|
513
|
+
runBitableSync: (p) => service.runBitableSync(p?.id),
|
|
514
|
+
listBitableRuns: (p) => service.listBitableRuns(p ?? {}),
|
|
515
|
+
}),
|
|
516
|
+
}
|