对照 ledgerly 自文档上次更新以来的全部改动,补充新功能文档、修正品牌与版本残留,并新增下载与安装页;配图已重新截取并上传 OSS,本地截图目录不纳入版本管理。 变更内容: - 新增「智能查数」「账单关系」两个使用文档页,并登记侧边栏与总览 - 设置页扩充为八个面板(账号安全、数据库实时进度、外观、导航等)并改为即时保存 - 收支记录补充关系列、批量操作、AI 搜索浮窗、可配置分页与「账单详情」弹窗 - 统计面板补充饼图/渠道图下钻与筛选双向联动;规则管理补充批量删除 - AI 能力补充智能查数与关系命名;新增下载与安装页 - 开发文档同步:AI 13 类调用、数据模型 24 张表、API、前端偏好、构建部署 - 统一「汇账 Ledgerly」品牌与「设置/日志」菜单文案,版本号对齐 0.1 - 截图脚本新增账单详情等场景并更新登记 说明: - download.md 的 OSS 下载链接保持待发布产物回填,暂不调整 - 已通过 npm run docs:build 与 npm run typecheck:scripts
272 lines
9.1 KiB
TypeScript
272 lines
9.1 KiB
TypeScript
// 将 public/screenshots 下的文档配图上传到阿里云 OSS。
|
||
//
|
||
// 流程:读取 .env.local 配置 -> STS AssumeRole 换取临时凭证 -> 遍历本地截图 ->
|
||
// 逐张上传到 <OSS_KEY_PREFIX>/<相对路径> -> HEAD 校验远端大小 -> 打印公共访问 URL。
|
||
//
|
||
// 凭证来自 .env.local(不纳入版本管理),也可用同名环境变量覆盖。
|
||
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
||
import { createRequire } from 'node:module'
|
||
import { dirname, join, relative, resolve, sep } from 'node:path'
|
||
import { fileURLToPath } from 'node:url'
|
||
import OSS from 'ali-oss'
|
||
import type { AssumeRoleRequest as AssumeRoleRequestType } from '@alicloud/sts20150401'
|
||
import { $OpenApiUtil } from '@alicloud/openapi-core'
|
||
|
||
// @alicloud/sts20150401 为 CommonJS 包,其类型声明在 NodeNext 下无法直接解析为构造签名,
|
||
// 故用 createRequire 加载并显式声明最小类型。
|
||
const require = createRequire(import.meta.url)
|
||
const stsSdk = require('@alicloud/sts20150401') as {
|
||
default: StsClientConstructor
|
||
AssumeRoleRequest: new (options: {
|
||
roleArn: string
|
||
roleSessionName: string
|
||
durationSeconds: number
|
||
}) => AssumeRoleRequestType
|
||
}
|
||
const StsClient = stsSdk.default
|
||
const AssumeRoleRequest = stsSdk.AssumeRoleRequest
|
||
|
||
const currentDir = dirname(fileURLToPath(import.meta.url))
|
||
/** doc-site 根目录 */
|
||
const ROOT = resolve(currentDir, '..', '..')
|
||
/** 截图输出目录(截图脚本的产物,也是本脚本的上传源) */
|
||
const SCREENSHOT_DIR = resolve(ROOT, 'public', 'screenshots')
|
||
/** 本地凭证文件(优先级低于环境变量) */
|
||
const ENV_FILE = resolve(ROOT, '.env.local')
|
||
/** STS 服务接入点 */
|
||
const STS_ENDPOINT = 'sts.aliyuncs.com'
|
||
/** 上传并发数 */
|
||
const CONCURRENCY = 5
|
||
|
||
/** OSS 上传配置 */
|
||
interface OssConfig {
|
||
/** 调用 STS 的长期访问密钥 ID */
|
||
accessKeyId: string
|
||
/** 调用 STS 的长期访问密钥 Secret */
|
||
accessKeySecret: string
|
||
/** 被扮演的 RAM 角色 ARN */
|
||
roleArn: string
|
||
/** 目标桶名 */
|
||
bucket: string
|
||
/** 区域(如 cn-beijing) */
|
||
region: string
|
||
/** 对象键前缀(截图统一放置其下) */
|
||
keyPrefix: string
|
||
}
|
||
|
||
/** STS 临时凭证 */
|
||
interface StsCredentials {
|
||
/** 临时访问密钥 ID */
|
||
accessKeyId: string
|
||
/** 临时访问密钥 Secret */
|
||
accessKeySecret: string
|
||
/** 临时安全令牌 */
|
||
securityToken: string
|
||
/** 过期时间(UTC) */
|
||
expiration: string
|
||
}
|
||
|
||
/** STS AssumeRole 响应体(仅声明用到的字段) */
|
||
interface AssumeRoleResult {
|
||
body?: {
|
||
credentials?: {
|
||
accessKeyId?: string
|
||
accessKeySecret?: string
|
||
securityToken?: string
|
||
expiration?: string
|
||
}
|
||
}
|
||
}
|
||
|
||
/** STS 客户端实例的最小签名 */
|
||
interface StsClientInstance {
|
||
assumeRole(request: AssumeRoleRequestType): Promise<AssumeRoleResult>
|
||
}
|
||
|
||
/** STS 客户端构造函数 */
|
||
type StsClientConstructor = new (config: $OpenApiUtil.Config) => StsClientInstance
|
||
|
||
/**
|
||
* 解析 .env 文件为键值对,忽略空行与以 # 开头的注释。
|
||
*
|
||
* @param filePath .env 文件路径
|
||
* @returns 键值对映射
|
||
*/
|
||
function parseEnvFile(filePath: string): Record<string, string> {
|
||
const result: Record<string, string> = {}
|
||
for (const line of readFileSync(filePath, 'utf8').split(/\r?\n/)) {
|
||
const trimmed = line.trim()
|
||
if (!trimmed || trimmed.startsWith('#')) continue
|
||
const index = trimmed.indexOf('=')
|
||
if (index === -1) continue
|
||
const key = trimmed.slice(0, index).trim()
|
||
let value = trimmed.slice(index + 1).trim()
|
||
const quoted =
|
||
(value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))
|
||
if (quoted) value = value.slice(1, -1)
|
||
result[key] = value
|
||
}
|
||
return result
|
||
}
|
||
|
||
/**
|
||
* 读取 OSS 上传配置,环境变量优先于 .env.local。
|
||
*
|
||
* @returns OSS 上传配置
|
||
* @throws 当必填项缺失时抛出异常
|
||
*/
|
||
function loadConfig(): OssConfig {
|
||
const fromFile = existsSync(ENV_FILE) ? parseEnvFile(ENV_FILE) : {}
|
||
const pick = (name: string): string => process.env[name] ?? fromFile[name] ?? ''
|
||
const config: OssConfig = {
|
||
accessKeyId: pick('OSS_ACCESS_KEY_ID'),
|
||
accessKeySecret: pick('OSS_ACCESS_KEY_SECRET'),
|
||
roleArn: pick('OSS_ROLE_ARN'),
|
||
bucket: pick('OSS_BUCKET'),
|
||
region: pick('OSS_REGION'),
|
||
keyPrefix: pick('OSS_KEY_PREFIX') || 'finance-app-docs/screenshots',
|
||
}
|
||
const missing = (['accessKeyId', 'accessKeySecret', 'roleArn', 'bucket', 'region'] as const).filter(
|
||
(key) => !config[key],
|
||
)
|
||
if (missing.length > 0) {
|
||
throw new Error(`缺少 OSS 配置: ${missing.join(', ')}(请在 .env.local 中配置)`)
|
||
}
|
||
return config
|
||
}
|
||
|
||
/**
|
||
* 递归收集目录下的全部文件(按路径排序,保证输出稳定)。
|
||
*
|
||
* @param dir 目标目录
|
||
* @returns 文件的绝对路径列表
|
||
*/
|
||
function collectFiles(dir: string): string[] {
|
||
const files: string[] = []
|
||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||
const full = join(dir, entry.name)
|
||
if (entry.isDirectory()) files.push(...collectFiles(full))
|
||
else if (entry.isFile()) files.push(full)
|
||
}
|
||
return files.sort()
|
||
}
|
||
|
||
/**
|
||
* 通过 STS AssumeRole 获取临时凭证。
|
||
*
|
||
* @param config OSS 上传配置
|
||
* @returns STS 临时凭证
|
||
* @throws 当 STS 未返回完整凭证时抛出异常
|
||
*/
|
||
async function assumeRole(config: OssConfig): Promise<StsCredentials> {
|
||
const client = new StsClient(
|
||
new $OpenApiUtil.Config({
|
||
accessKeyId: config.accessKeyId,
|
||
accessKeySecret: config.accessKeySecret,
|
||
endpoint: STS_ENDPOINT,
|
||
// 默认连接超时较短,网络抖动时易 ConnectTimeout,这里放宽
|
||
connectTimeout: 20000,
|
||
readTimeout: 20000,
|
||
}),
|
||
)
|
||
const request = new AssumeRoleRequest({
|
||
roleArn: config.roleArn,
|
||
roleSessionName: 'doc-site-screenshot-upload',
|
||
durationSeconds: 3600,
|
||
})
|
||
const response = await client.assumeRole(request)
|
||
const credentials = response.body?.credentials
|
||
if (!credentials?.accessKeyId || !credentials.accessKeySecret || !credentials.securityToken) {
|
||
throw new Error('STS AssumeRole 未返回有效的临时凭证')
|
||
}
|
||
return {
|
||
accessKeyId: credentials.accessKeyId,
|
||
accessKeySecret: credentials.accessKeySecret,
|
||
securityToken: credentials.securityToken,
|
||
expiration: credentials.expiration ?? '',
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 上传单张截图,并通过 HEAD 请求校验远端大小。
|
||
*
|
||
* @param client 已认证的 OSS 客户端
|
||
* @param key 对象键
|
||
* @param file 本地文件绝对路径
|
||
* @returns 对象键与文件大小
|
||
* @throws 当远端大小与本地不一致时抛出异常
|
||
*/
|
||
async function uploadOne(client: OSS, key: string, file: string): Promise<{ key: string; size: number }> {
|
||
const content = readFileSync(file)
|
||
await client.put(key, content, {
|
||
mime: 'image/png',
|
||
headers: { 'Cache-Control': 'public, max-age=86400' },
|
||
})
|
||
const head = await client.head(key)
|
||
const headers = head.res.headers as Record<string, string | undefined>
|
||
const remoteSize = Number(headers['content-length'] ?? headers['Content-Length'])
|
||
if (remoteSize !== content.length) {
|
||
throw new Error(`大小校验失败: ${key} 远端=${remoteSize} 本地=${content.length}`)
|
||
}
|
||
return { key, size: content.length }
|
||
}
|
||
|
||
/**
|
||
* 按并发上限批量执行上传任务。
|
||
*
|
||
* @param tasks 返回 Promise 的任务函数列表
|
||
* @returns 各任务结果(顺序与输入一致)
|
||
*/
|
||
async function runWithConcurrency<T>(tasks: (() => Promise<T>)[]): Promise<T[]> {
|
||
const results: T[] = []
|
||
for (let i = 0; i < tasks.length; i += CONCURRENCY) {
|
||
const batch = tasks.slice(i, i + CONCURRENCY)
|
||
results.push(...(await Promise.all(batch.map((task) => task()))))
|
||
}
|
||
return results
|
||
}
|
||
|
||
/**
|
||
* 脚本入口:上传全部截图并打印公共访问 URL。
|
||
*/
|
||
async function main(): Promise<void> {
|
||
const config = loadConfig()
|
||
if (!existsSync(SCREENSHOT_DIR)) {
|
||
throw new Error(`截图目录不存在: ${SCREENSHOT_DIR}(请先执行 npm run capture)`)
|
||
}
|
||
const files = collectFiles(SCREENSHOT_DIR).filter((file) => file.toLowerCase().endsWith('.png'))
|
||
if (files.length === 0) {
|
||
console.log('未找到可上传的截图')
|
||
return
|
||
}
|
||
|
||
const credentials = await assumeRole(config)
|
||
console.log(`STS 凭证获取成功,有效期至 ${credentials.expiration}`)
|
||
|
||
const client = new OSS({
|
||
region: `oss-${config.region}`,
|
||
accessKeyId: credentials.accessKeyId,
|
||
accessKeySecret: credentials.accessKeySecret,
|
||
stsToken: credentials.securityToken,
|
||
bucket: config.bucket,
|
||
secure: true,
|
||
})
|
||
|
||
const endpoint = `${config.bucket}.oss-${config.region}.aliyuncs.com`
|
||
const tasks = files.map((file) => {
|
||
const relativePath = relative(SCREENSHOT_DIR, file).split(sep).join('/')
|
||
return () => uploadOne(client, `${config.keyPrefix}/${relativePath}`, file)
|
||
})
|
||
|
||
const results = await runWithConcurrency(tasks)
|
||
for (const result of results) {
|
||
console.log(`OK ${result.key} (${result.size} 字节) https://${endpoint}/${result.key}`)
|
||
}
|
||
console.log(`\n共上传 ${results.length} 张截图到 oss://${config.bucket}/${config.keyPrefix}/`)
|
||
}
|
||
|
||
main().catch((error: unknown) => {
|
||
console.error(`上传失败: ${error instanceof Error ? error.message : String(error)}`)
|
||
process.exit(1)
|
||
})
|