Files
rosercode 923d6db5c0 docs: 同步汇账最新功能,新增智能查数、账单关系等文档
对照 ledgerly 自文档上次更新以来的全部改动,补充新功能文档、修正品牌与版本残留,并新增下载与安装页;配图已重新截取并上传 OSS,本地截图目录不纳入版本管理。

变更内容:
- 新增「智能查数」「账单关系」两个使用文档页,并登记侧边栏与总览
- 设置页扩充为八个面板(账号安全、数据库实时进度、外观、导航等)并改为即时保存
- 收支记录补充关系列、批量操作、AI 搜索浮窗、可配置分页与「账单详情」弹窗
- 统计面板补充饼图/渠道图下钻与筛选双向联动;规则管理补充批量删除
- AI 能力补充智能查数与关系命名;新增下载与安装页
- 开发文档同步:AI 13 类调用、数据模型 24 张表、API、前端偏好、构建部署
- 统一「汇账 Ledgerly」品牌与「设置/日志」菜单文案,版本号对齐 0.1
- 截图脚本新增账单详情等场景并更新登记

说明:
- download.md 的 OSS 下载链接保持待发布产物回填,暂不调整
- 已通过 npm run docs:build 与 npm run typecheck:scripts
2026-09-26 21:57:38 +08:00

272 lines
9.1 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 将 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)
})