From 7e167fd4a19572fa0a3b81cec7fbf9087ac4a442 Mon Sep 17 00:00:00 2001 From: abearxiong Date: Fri, 30 May 2025 21:36:12 +0800 Subject: [PATCH] temp --- packages/api/.npmrc | 3 + packages/api/kevisual.json | 22 + packages/api/package.json | 32 ++ packages/api/query/index.ts | 7 + packages/api/query/kevisual.json | 25 + packages/api/query/query-ai/defines/ai.ts | 42 ++ packages/api/query/query-ai/query-ai.ts | 101 ++++ packages/api/query/query-app/defines/index.ts | 3 + .../query/query-app/defines/user-app-list.ts | 62 +++ .../api/query/query-app/defines/user-app.ts | 33 ++ .../api/query/query-app/query-app-define.ts | 1 + packages/api/query/query-app/query-app.ts | 18 + packages/api/query/query-login/login-cache.ts | 204 ++++++++ .../api/query/query-login/login-node-cache.ts | 132 ++++++ .../query/query-login/query-login-browser.ts | 12 + .../api/query/query-login/query-login-node.ts | 14 + packages/api/query/query-login/query-login.ts | 434 ++++++++++++++++++ packages/api/query/query-mark/query-mark.ts | 154 +++++++ packages/api/query/query-resources/index.ts | 71 +++ .../query-shop/defines/query-shop-define.ts | 27 ++ packages/api/query/query-shop/query-shop.ts | 17 + .../query/query-upload/core/upload-chunk.ts | 134 ++++++ .../query-upload/core/upload-progress.ts | 103 +++++ .../api/query/query-upload/core/upload.ts | 113 +++++ .../query-upload/query-upload-browser.ts | 51 ++ .../query/query-upload/query-upload-node.ts | 1 + .../api/query/query-upload/query-upload.ts | 11 + .../query/query-upload/utils/filter-files.ts | 23 + .../api/query/query-upload/utils/index.ts | 3 + .../api/query/query-upload/utils/random-id.ts | 3 + .../api/query/query-upload/utils/to-file.ts | 105 +++++ packages/api/tsconfig.json | 19 + pnpm-lock.yaml | 44 +- 33 files changed, 2019 insertions(+), 5 deletions(-) create mode 100644 packages/api/.npmrc create mode 100644 packages/api/kevisual.json create mode 100644 packages/api/package.json create mode 100644 packages/api/query/index.ts create mode 100644 packages/api/query/kevisual.json create mode 100644 packages/api/query/query-ai/defines/ai.ts create mode 100644 packages/api/query/query-ai/query-ai.ts create mode 100644 packages/api/query/query-app/defines/index.ts create mode 100644 packages/api/query/query-app/defines/user-app-list.ts create mode 100644 packages/api/query/query-app/defines/user-app.ts create mode 100644 packages/api/query/query-app/query-app-define.ts create mode 100644 packages/api/query/query-app/query-app.ts create mode 100644 packages/api/query/query-login/login-cache.ts create mode 100644 packages/api/query/query-login/login-node-cache.ts create mode 100644 packages/api/query/query-login/query-login-browser.ts create mode 100644 packages/api/query/query-login/query-login-node.ts create mode 100644 packages/api/query/query-login/query-login.ts create mode 100644 packages/api/query/query-mark/query-mark.ts create mode 100644 packages/api/query/query-resources/index.ts create mode 100644 packages/api/query/query-shop/defines/query-shop-define.ts create mode 100644 packages/api/query/query-shop/query-shop.ts create mode 100644 packages/api/query/query-upload/core/upload-chunk.ts create mode 100644 packages/api/query/query-upload/core/upload-progress.ts create mode 100644 packages/api/query/query-upload/core/upload.ts create mode 100644 packages/api/query/query-upload/query-upload-browser.ts create mode 100644 packages/api/query/query-upload/query-upload-node.ts create mode 100644 packages/api/query/query-upload/query-upload.ts create mode 100644 packages/api/query/query-upload/utils/filter-files.ts create mode 100644 packages/api/query/query-upload/utils/index.ts create mode 100644 packages/api/query/query-upload/utils/random-id.ts create mode 100644 packages/api/query/query-upload/utils/to-file.ts create mode 100644 packages/api/tsconfig.json diff --git a/packages/api/.npmrc b/packages/api/.npmrc new file mode 100644 index 0000000..0a2c6cd --- /dev/null +++ b/packages/api/.npmrc @@ -0,0 +1,3 @@ +//npm.xiongxiao.me/:_authToken=${ME_NPM_TOKEN} +//registry.npmjs.org/:_authToken=${NPM_TOKEN} +ignore-workspace-root-check=true \ No newline at end of file diff --git a/packages/api/kevisual.json b/packages/api/kevisual.json new file mode 100644 index 0000000..9f1503c --- /dev/null +++ b/packages/api/kevisual.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://kevisual.xiongxiao.me/root/ai/kevisual/tools/kevisual-sync/schema.json?v=2", + "metadata": { + "share": "public" + }, + "checkDir": { + "query": { + "url": "https://kevisual.xiongxiao.me/root/ai/code/registry/query", + "enabled": true + } + }, + "syncDirectory": [ + { + "files": [ + "query/**/*" + ], + "ignore": [], + "registry": "https://kevisual.xiongxiao.me/root/ai/code/registry" + } + ], + "sync": {} +} \ No newline at end of file diff --git a/packages/api/package.json b/packages/api/package.json new file mode 100644 index 0000000..48a7cbf --- /dev/null +++ b/packages/api/package.json @@ -0,0 +1,32 @@ +{ + "name": "@kevisual/api", + "version": "0.0.1", + "description": "", + "main": "index.js", + "scripts": { + "build2": "bun bun.config.mjs", + "download": "ev sync download", + "upload": "ev sync upload" + }, + "keywords": [], + "files": [ + "src", + "query", + "dist" + ], + "publishConfig": { + "access": "public" + }, + "author": "abearxiong (https://www.xiongxiao.me)", + "license": "MIT", + "packageManager": "pnpm@10.6.2", + "type": "module", + "dependencies": { + "@kevisual/query": "^0.0.18", + "@kevisual/router": "^0.0.20" + }, + "devDependencies": { + "@kevisual/types": "^0.0.10", + "@types/node": "^22.15.27" + } +} \ No newline at end of file diff --git a/packages/api/query/index.ts b/packages/api/query/index.ts new file mode 100644 index 0000000..cff06fa --- /dev/null +++ b/packages/api/query/index.ts @@ -0,0 +1,7 @@ +import { Query } from '@kevisual/query'; + +export const query = new Query(); + +export const clientQuery = new Query({ url: '/client/router' }); + +export { QueryUtil } from '@kevisual/router/define'; diff --git a/packages/api/query/kevisual.json b/packages/api/query/kevisual.json new file mode 100644 index 0000000..ca52328 --- /dev/null +++ b/packages/api/query/kevisual.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://kevisual.xiongxiao.me/root/ai/kevisual/tools/kevisual-sync/schema.json?v=2", + "metadata": { + "share": "public" + }, + "checkDir": { + "src/query": { + "url": "https://kevisual.xiongxiao.me/root/ai/code/registry/query", + "enabled": true + } + }, + "syncDirectory": [ + { + "files": [ + "src/query/**/*" + ], + "ignore": [], + "registry": "https://kevisual.xiongxiao.me/root/ai/code/registry", + "replace": { + "src/": "" + } + } + ], + "sync": {} +} \ No newline at end of file diff --git a/packages/api/query/query-ai/defines/ai.ts b/packages/api/query/query-ai/defines/ai.ts new file mode 100644 index 0000000..d8ca83b --- /dev/null +++ b/packages/api/query/query-ai/defines/ai.ts @@ -0,0 +1,42 @@ +import { QueryUtil } from '@/query/index.ts'; + +type Message = { + role?: 'user' | 'assistant' | 'system' | 'tool'; + content?: string; + name?: string; +}; +export type PostChat = { + messages?: Message[]; + model?: string; + group?: string; + user?: string; +}; + +export type ChatDataOpts = { + id?: string; + title?: string; + messages?: any[]; + data?: any; + type?: 'temp' | 'keep' | string; +}; +export type ChatOpts = { + username: string; + model: string; + /** + * 获取完整消息回复 + */ + getFull?: boolean; + group: string; + /** + * openai的参数 + */ + options?: any; +}; + +export const appDefine = QueryUtil.create({ + chat: { + path: 'ai', + key: 'chat', + description: '与 AI 进行对话, 调用 GPT 的AI 服务,生成结果,并返回。', + }, +}); diff --git a/packages/api/query/query-ai/query-ai.ts b/packages/api/query/query-ai/query-ai.ts new file mode 100644 index 0000000..2ede31c --- /dev/null +++ b/packages/api/query/query-ai/query-ai.ts @@ -0,0 +1,101 @@ +import { appDefine } from './defines/ai.ts'; +import { PostChat, ChatOpts, ChatDataOpts } from './defines/ai.ts'; + +import { BaseQuery, DataOpts, Query } from '@kevisual/query/query'; + +export { appDefine }; + +export class QueryApp extends BaseQuery { + constructor(opts?: { query: T }) { + super({ + ...opts, + query: opts?.query!, + queryDefine: appDefine, + }); + } + /** + * 与 AI 进行对话, 调用 GPT 的AI 服务,生成结果,并返回。 + * @param data + * @param opts + * @returns + */ + postChat(data: PostChat, opts?: DataOpts) { + return this.chain('chat').post(data, opts); + } + /** + * 获取模型列表 + * @param opts + * @returns + */ + getModelList(data?: { usernames?: string[] }, opts?: DataOpts) { + return this.query.post( + { + path: 'ai', + key: 'get-model-list', + data, + }, + opts, + ); + } + /** + * 聊天对话模型 + * @param data + * @param chatOpts + * @param opts + * @returns + */ + chat(data: ChatDataOpts, chatOpts: ChatOpts, opts?: DataOpts) { + const { username, model, group, getFull = true } = chatOpts; + if (!username || !model || !group) { + throw new Error('username, model, group is required'); + } + return this.query.post( + { + path: 'ai', + key: 'chat', + ...chatOpts, + getFull, + data, + }, + opts, + ); + } + clearConfigCache(opts?: DataOpts) { + return this.query.post( + { + path: 'ai', + key: 'clear-cache', + }, + opts, + ); + } + /** + * 获取聊天使用情况 + * @param opts + * @returns + */ + getChatUsage(opts?: DataOpts) { + return this.query.post( + { + path: 'ai', + key: 'get-chat-usage', + }, + opts, + ); + } + + /** + * 清除当前用户模型自己的统计 + * @param opts + * @returns + */ + clearSelfUsage(opts?: DataOpts) { + return this.query.post( + { + path: 'ai', + key: 'clear-chat-limit', + }, + opts, + ); + } +} diff --git a/packages/api/query/query-app/defines/index.ts b/packages/api/query/query-app/defines/index.ts new file mode 100644 index 0000000..2a74834 --- /dev/null +++ b/packages/api/query/query-app/defines/index.ts @@ -0,0 +1,3 @@ +import { appDefine } from './user-app-list'; +import { userAppDefine } from './user-app'; +export { appDefine, userAppDefine }; diff --git a/packages/api/query/query-app/defines/user-app-list.ts b/packages/api/query/query-app/defines/user-app-list.ts new file mode 100644 index 0000000..b887e87 --- /dev/null +++ b/packages/api/query/query-app/defines/user-app-list.ts @@ -0,0 +1,62 @@ +import { QueryUtil } from '@/query/index.ts'; + +export const appDefine = QueryUtil.create({ + getApp: { + path: 'app', + key: 'get', + description: '获取应用信息', + }, + + updateApp: { + path: 'app', + key: 'update', + description: '更新应用信息', + }, + + deleteApp: { + path: 'app', + key: 'delete', + description: '删除应用信息', + }, + + listApps: { + path: 'app', + key: 'list', + description: '列出所有应用信息', + }, + + canUploadFiles: { + path: 'app', + key: 'canUploadFiles', + description: '检查是否可以上传文件', + }, + + uploadFiles: { + path: 'app', + key: 'uploadFiles', + description: '上传文件', + }, + + publishApp: { + path: 'app', + key: 'publish', + description: '发布应用', + }, + + getMinioList: { + path: 'app', + key: 'get-minio-list', + description: '获取 MinIO 文件列表', + }, + + detectVersionList: { + path: 'app', + key: 'detectVersionList', + description: '检测版本列表并同步 MinIO 数据', + }, + publicList: { + path: 'app', + key: 'public-list', + description: '获取公开应用列表', + }, +}); diff --git a/packages/api/query/query-app/defines/user-app.ts b/packages/api/query/query-app/defines/user-app.ts new file mode 100644 index 0000000..9b517f5 --- /dev/null +++ b/packages/api/query/query-app/defines/user-app.ts @@ -0,0 +1,33 @@ +import { QueryUtil } from '@/query/index.ts'; + +export const userAppDefine = QueryUtil.create({ + listUserApps: { + path: 'user-app', + key: 'list', + description: '列出当前用户的所有应用(不包含 data 字段)', + }, + + getUserApp: { + path: 'user-app', + key: 'get', + description: '获取用户应用信息,可以指定 id 或 key', + }, + + updateUserApp: { + path: 'user-app', + key: 'update', + description: '更新或创建用户应用', + }, + + deleteUserApp: { + path: 'user-app', + key: 'delete', + description: '删除用户应用及关联数据', + }, + + testUserApp: { + path: 'user-app', + key: 'test', + description: '对 user-app 的数据进行测试,获取版本信息', + }, +}); diff --git a/packages/api/query/query-app/query-app-define.ts b/packages/api/query/query-app/query-app-define.ts new file mode 100644 index 0000000..23e8118 --- /dev/null +++ b/packages/api/query/query-app/query-app-define.ts @@ -0,0 +1 @@ +export * from './defines/index.ts'; diff --git a/packages/api/query/query-app/query-app.ts b/packages/api/query/query-app/query-app.ts new file mode 100644 index 0000000..6f882f7 --- /dev/null +++ b/packages/api/query/query-app/query-app.ts @@ -0,0 +1,18 @@ +import { appDefine, userAppDefine } from './defines/index.ts'; + +import { BaseQuery, DataOpts, Query } from '@kevisual/query/query'; + +export { appDefine, userAppDefine }; + +export class QueryApp extends BaseQuery { + appDefine = appDefine; + userAppDefine = userAppDefine; + constructor(opts?: { query: Query }) { + super(opts!); + this.appDefine.query = this.query; + this.userAppDefine.query = this.query; + } + getList(data: any, opts?: DataOpts) { + return this.appDefine.queryChain('listApps').post(data, opts); + } +} diff --git a/packages/api/query/query-login/login-cache.ts b/packages/api/query/query-login/login-cache.ts new file mode 100644 index 0000000..0ee1bf9 --- /dev/null +++ b/packages/api/query/query-login/login-cache.ts @@ -0,0 +1,204 @@ +export interface Cache { + /** + * @update 获取缓存 + */ + get(key: string): Promise; + /** + * @update 设置缓存 + */ + set(key: string, value: any): Promise; + /** + * @update 删除缓存 + */ + del(): Promise; + /** + * 初始化 + */ + init?: () => Promise; +} +type User = { + avatar?: string; + description?: string; + id?: string; + needChangePassword?: boolean; + orgs?: string[]; + type?: string; + username?: string; +}; + +export type CacheLoginUser = { + user?: User; + id?: string; + accessToken?: string; + refreshToken?: string; +}; +type CacheLogin = { + loginUsers: CacheLoginUser[]; +} & CacheLoginUser; + +export type CacheStore = { + name: string; + /** + * 缓存数据 + * @important 需要先调用init + */ + cacheData: CacheLogin; + /** + * 实际操作的cache, 需要先调用init + */ + cache: T; + + /** + * 设置当前用户 + */ + setLoginUser(user: CacheLoginUser): Promise; + /** + * 获取当前用户 + */ + getCurrentUser(): Promise; + /** + * 获取当前用户列表 + */ + getCurrentUserList(): Promise; + /** + * 获取缓存的refreshToken + */ + getRefreshToken(): Promise; + /** + * 获取缓存的accessToken + */ + getAccessToken(): Promise; + /** + * 清除当前用户 + */ + clearCurrentUser(): Promise; + /** + * 清除所有用户 + */ + clearAll(): Promise; + + getValue(): Promise; + setValue(value: CacheLogin): Promise; + delValue(): Promise; + init(): Promise; +}; + +export type LoginCacheStoreOpts = { + name: string; + cache: Cache; +}; +export class LoginCacheStore implements CacheStore { + cache: Cache; + name: string; + cacheData: CacheLogin; + constructor(opts: LoginCacheStoreOpts) { + if (!opts.cache) { + throw new Error('cache is required'); + } + // @ts-ignore + this.cache = opts.cache; + this.cacheData = { + loginUsers: [], + user: undefined, + id: undefined, + accessToken: undefined, + refreshToken: undefined, + }; + this.name = opts.name; + } + /** + * 设置缓存 + * @param key + * @param value + * @returns + */ + async setValue(value: CacheLogin) { + await this.cache.set(this.name, value); + this.cacheData = value; + return value; + } + /** + * 删除缓存 + */ + async delValue() { + await this.cache.del(); + } + getValue(): Promise { + return this.cache.get(this.name); + } + /** + * 初始化,设置默认值 + */ + async init() { + const defaultData = { + loginUsers: [], + user: null, + id: null, + accessToken: null, + refreshToken: null, + }; + if (this.cache.init) { + try { + const cacheData = await this.cache.init(); + this.cacheData = cacheData || defaultData; + } catch (error) { + console.log('cacheInit error', error); + } + } else { + this.cacheData = (await this.getValue()) || defaultData; + } + } + /** + * 设置当前用户 + * @param user + */ + async setLoginUser(user: CacheLoginUser) { + const has = this.cacheData.loginUsers.find((u) => u.id === user.id); + if (has) { + this.cacheData.loginUsers = this.cacheData?.loginUsers?.filter((u) => u?.id && u.id !== user.id); + } + this.cacheData.loginUsers.push(user); + this.cacheData.user = user.user; + this.cacheData.id = user.id; + this.cacheData.accessToken = user.accessToken; + this.cacheData.refreshToken = user.refreshToken; + await this.setValue(this.cacheData); + } + + getCurrentUser(): Promise { + const cacheData = this.cacheData; + return Promise.resolve(cacheData.user!); + } + getCurrentUserList(): Promise { + return Promise.resolve(this.cacheData.loginUsers.filter((u) => u?.id)); + } + getRefreshToken(): Promise { + const cacheData = this.cacheData; + return Promise.resolve(cacheData.refreshToken || ''); + } + getAccessToken(): Promise { + const cacheData = this.cacheData; + return Promise.resolve(cacheData.accessToken || ''); + } + + async clearCurrentUser() { + const user = await this.getCurrentUser(); + const has = this.cacheData.loginUsers.find((u) => u.id === user.id); + if (has) { + this.cacheData.loginUsers = this.cacheData?.loginUsers?.filter((u) => u?.id && u.id !== user.id); + } + this.cacheData.user = undefined; + this.cacheData.id = undefined; + this.cacheData.accessToken = undefined; + this.cacheData.refreshToken = undefined; + await this.setValue(this.cacheData); + } + async clearAll() { + this.cacheData.loginUsers = []; + this.cacheData.user = undefined; + this.cacheData.id = undefined; + this.cacheData.accessToken = undefined; + this.cacheData.refreshToken = undefined; + await this.setValue(this.cacheData); + } +} diff --git a/packages/api/query/query-login/login-node-cache.ts b/packages/api/query/query-login/login-node-cache.ts new file mode 100644 index 0000000..a0e2b26 --- /dev/null +++ b/packages/api/query/query-login/login-node-cache.ts @@ -0,0 +1,132 @@ +import { Cache } from './login-cache.ts'; +import { homedir } from 'node:os'; +import { join, dirname } from 'node:path'; +import fs from 'node:fs'; +import { readFileSync, writeFileSync, accessSync } from 'node:fs'; +import { readFile, writeFile, unlink, mkdir } from 'node:fs/promises'; +export const fileExists = async ( + filePath: string, + { createIfNotExists = true, isFile = true, isDir = false }: { createIfNotExists?: boolean; isFile?: boolean; isDir?: boolean } = {}, +) => { + try { + accessSync(filePath, fs.constants.F_OK); + return true; + } catch (error) { + if (createIfNotExists && isDir) { + await mkdir(filePath, { recursive: true }); + return true; + } else if (createIfNotExists && isFile) { + await mkdir(dirname(filePath), { recursive: true }); + return false; + } + return false; + } +}; +export const readConfigFile = (filePath: string) => { + try { + const data = readFileSync(filePath, 'utf-8'); + const jsonData = JSON.parse(data); + return jsonData; + } catch (error) { + return {}; + } +}; +export const writeConfigFile = (filePath: string, data: any) => { + writeFileSync(filePath, JSON.stringify(data, null, 2)); +}; +export const getHostName = () => { + const configDir = join(homedir(), '.config', 'envision'); + const configFile = join(configDir, 'config.json'); + const config = readConfigFile(configFile); + const baseURL = config.baseURL || 'https://kevisual.cn'; + const hostname = new URL(baseURL).hostname; + return hostname; +}; +export class StorageNode implements Storage { + cacheData: any; + filePath: string; + constructor() { + this.cacheData = {}; + const configDir = join(homedir(), '.config', 'envision'); + const hostname = getHostName(); + this.filePath = join(configDir, 'config', `${hostname}-storage.json`); + fileExists(this.filePath, { isFile: true }); + } + async loadCache() { + const filePath = this.filePath; + try { + const data = await readConfigFile(filePath); + this.cacheData = data; + } catch (error) { + this.cacheData = {}; + await writeFile(filePath, JSON.stringify(this.cacheData, null, 2)); + } + } + get length() { + return Object.keys(this.cacheData).length; + } + getItem(key: string) { + return this.cacheData[key]; + } + setItem(key: string, value: any) { + this.cacheData[key] = value; + writeFile(this.filePath, JSON.stringify(this.cacheData, null, 2)); + } + removeItem(key: string) { + delete this.cacheData[key]; + writeFile(this.filePath, JSON.stringify(this.cacheData, null, 2)); + } + clear() { + this.cacheData = {}; + writeFile(this.filePath, JSON.stringify(this.cacheData, null, 2)); + } + key(index: number) { + return Object.keys(this.cacheData)[index]; + } +} +export class LoginNodeCache implements Cache { + filepath: string; + + constructor(filepath?: string) { + this.filepath = filepath || join(homedir(), '.config', 'envision', 'config', `${getHostName()}-login.json`); + fileExists(this.filepath, { isFile: true }); + } + async get(_key: string) { + try { + const filePath = this.filepath; + const data = readConfigFile(filePath); + return data; + } catch (error) { + console.log('get error', error); + return {}; + } + } + async set(_key: string, value: any) { + try { + const data = readConfigFile(this.filepath); + const newData = { ...data, ...value }; + writeConfigFile(this.filepath, newData); + } catch (error) { + console.log('set error', error); + } + } + async del() { + await unlink(this.filepath); + } + async loadCache(filePath: string) { + try { + const data = await readFile(filePath, 'utf-8'); + const jsonData = JSON.parse(data); + return jsonData; + } catch (error) { + // console.log('loadCache error', error); + console.log('create new cache file:', filePath); + const defaultData = { loginUsers: [] }; + writeConfigFile(filePath, defaultData); + return defaultData; + } + } + async init() { + return await this.loadCache(this.filepath); + } +} diff --git a/packages/api/query/query-login/query-login-browser.ts b/packages/api/query/query-login/query-login-browser.ts new file mode 100644 index 0000000..2d131cb --- /dev/null +++ b/packages/api/query/query-login/query-login-browser.ts @@ -0,0 +1,12 @@ +import { QueryLogin, QueryLoginOpts } from './query-login.ts'; +import { MyCache } from '@kevisual/cache'; +type QueryLoginNodeOptsWithoutCache = Omit; + +export class QueryLoginBrowser extends QueryLogin { + constructor(opts: QueryLoginNodeOptsWithoutCache) { + super({ + ...opts, + cache: new MyCache('login'), + }); + } +} diff --git a/packages/api/query/query-login/query-login-node.ts b/packages/api/query/query-login/query-login-node.ts new file mode 100644 index 0000000..5cb6527 --- /dev/null +++ b/packages/api/query/query-login/query-login-node.ts @@ -0,0 +1,14 @@ +import { QueryLogin, QueryLoginOpts } from './query-login.ts'; +import { LoginNodeCache, StorageNode } from './login-node-cache.ts'; +type QueryLoginNodeOptsWithoutCache = Omit; +export const storage = new StorageNode(); +await storage.loadCache(); +export class QueryLoginNode extends QueryLogin { + constructor(opts: QueryLoginNodeOptsWithoutCache) { + super({ + ...opts, + storage, + cache: new LoginNodeCache(), + }); + } +} diff --git a/packages/api/query/query-login/query-login.ts b/packages/api/query/query-login/query-login.ts new file mode 100644 index 0000000..ce9769b --- /dev/null +++ b/packages/api/query/query-login/query-login.ts @@ -0,0 +1,434 @@ +import { Query, BaseQuery } from '@kevisual/query'; +import type { Result, DataOpts } from '@kevisual/query/query'; +import { setBaseResponse } from '@kevisual/query/query'; +import { LoginCacheStore, CacheStore } from './login-cache.ts'; +import { Cache } from './login-cache.ts'; + +export type QueryLoginOpts = { + query?: Query; + isBrowser?: boolean; + onLoad?: () => void; + storage?: Storage; + cache: Cache; +}; +export type QueryLoginData = { + username?: string; + password: string; + email?: string; +}; +export type QueryLoginResult = { + accessToken: string; + refreshToken: string; +}; + +export class QueryLogin extends BaseQuery { + /** + * query login cache, 非实际操作, 一个cache的包裹模块 + */ + cacheStore: CacheStore; + isBrowser: boolean; + load?: boolean; + storage: Storage; + onLoad?: () => void; + + constructor(opts?: QueryLoginOpts) { + super({ + query: opts?.query || new Query(), + }); + this.cacheStore = new LoginCacheStore({ name: 'login', cache: opts?.cache! }); + this.isBrowser = opts?.isBrowser ?? true; + this.init(); + this.onLoad = opts?.onLoad; + this.storage = opts?.storage || localStorage; + } + setQuery(query: Query) { + this.query = query; + } + private async init() { + await this.cacheStore.init(); + this.load = true; + this.onLoad?.(); + } + async post(data: any, opts?: DataOpts) { + try { + return this.query.post({ path: 'user', ...data }, opts); + } catch (error) { + console.log('error', error); + return { + code: 400, + } as any; + } + } + /** + * 登录, + * @param data + * @returns + */ + async login(data: QueryLoginData) { + const res = await this.post({ key: 'login', ...data }); + if (res.code === 200) { + const { accessToken, refreshToken } = res?.data || {}; + this.storage.setItem('token', accessToken || ''); + await this.beforeSetLoginUser({ accessToken, refreshToken }); + } + return res; + } + /** + * 手机号登录 + * @param data + * @returns + */ + async loginByCode(data: { phone: string; code: string }) { + const res = await this.post({ path: 'sms', key: 'login', data }); + if (res.code === 200) { + const { accessToken, refreshToken } = res?.data || {}; + this.storage.setItem('token', accessToken || ''); + await this.beforeSetLoginUser({ accessToken, refreshToken }); + } + return res; + } + /** + * 设置token + * @param token + */ + async setLoginToken(token: { accessToken: string; refreshToken: string }) { + const { accessToken, refreshToken } = token; + this.storage.setItem('token', accessToken || ''); + await this.beforeSetLoginUser({ accessToken, refreshToken }); + } + async loginByWechat(data: { code: string }) { + const res = await this.post({ path: 'wx', key: 'open-login', code: data.code }); + if (res.code === 200) { + const { accessToken, refreshToken } = res?.data || {}; + this.storage.setItem('token', accessToken || ''); + await this.beforeSetLoginUser({ accessToken, refreshToken }); + } + return res; + } + /** + * 检测微信登录,登陆成功后,调用onSuccess,否则调用onError + * @param param0 + */ + async checkWechat({ onSuccess, onError }: { onSuccess?: (res: QueryLoginResult) => void; onError?: (res: any) => void }) { + const url = new URL(window.location.href); + const code = url.searchParams.get('code'); + const state = url.searchParams.get('state'); + if (code && state) { + const res = await this.loginByWechat({ code }); + if (res.code === 200) { + onSuccess?.(res.data); + } else { + onError?.(res); + } + } + } + /** + * 登陆成功,需要获取用户信息进行缓存 + * @param param0 + */ + async beforeSetLoginUser({ accessToken, refreshToken, check401 }: { accessToken?: string; refreshToken?: string; check401?: boolean }) { + if (accessToken && refreshToken) { + const resUser = await this.getMe(accessToken, check401); + if (resUser.code === 200) { + const user = resUser.data; + if (user) { + this.cacheStore.setLoginUser({ + user, + id: user.id, + accessToken, + refreshToken, + }); + } else { + console.error('登录失败'); + } + } + } + } + /** + * 刷新token + * @param refreshToken + * @returns + */ + async queryRefreshToken(refreshToken?: string) { + const _refreshToken = refreshToken || this.cacheStore.getRefreshToken(); + let data = { refreshToken: _refreshToken }; + if (!_refreshToken) { + await this.cacheStore.clearCurrentUser(); + return { + code: 401, + message: '请先登录', + data: {} as any, + }; + } + return this.post( + { key: 'refreshToken', data }, + { + afterResponse: async (response, ctx) => { + setBaseResponse(response); + return response as any; + }, + }, + ); + } + /** + * 检查401错误,并刷新token, 如果refreshToken存在,则刷新token, 否则返回401 + * 拦截请求,请使用run401Action, 不要直接使用 afterCheck401ToRefreshToken + * @param response + * @param ctx + * @param refetch + * @returns + */ + async afterCheck401ToRefreshToken(response: Result, ctx?: { req?: any; res?: any; fetch?: any }, refetch?: boolean) { + const that = this; + if (response?.code === 401) { + const hasRefreshToken = await that.cacheStore.getRefreshToken(); + if (hasRefreshToken) { + const res = await that.queryRefreshToken(hasRefreshToken); + if (res.code === 200) { + const { accessToken, refreshToken } = res?.data || {}; + that.storage.setItem('token', accessToken || ''); + await that.beforeSetLoginUser({ accessToken, refreshToken, check401: false }); + if (refetch && ctx && ctx.req && ctx.req.url && ctx.fetch) { + await new Promise((resolve) => setTimeout(resolve, 1500)); + const url = ctx.req?.url; + const body = ctx.req?.body; + const headers = ctx.req?.headers; + const res = await ctx.fetch(url, { + method: 'POST', + body: body, + headers: { ...headers, Authorization: `Bearer ${accessToken}` }, + }); + setBaseResponse(res); + return res; + } + } else { + that.storage.removeItem('token'); + await that.cacheStore.clearCurrentUser(); + } + return res; + } + } + return response as any; + } + /** + * 一个简单的401处理, 如果401,则刷新token, 如果refreshToken不存在,则返回401 + * refetch 是否重新请求, 会有bug,无限循环,按需要使用 + * TODO: + * @param response + * @param ctx + * @param opts + * @returns + */ + async run401Action( + response: Result, + ctx?: { req?: any; res?: any; fetch?: any }, + opts?: { + /** + * 是否重新请求, 会有bug,无限循环,按需要使用 + */ + refetch?: boolean; + /** + * check之后的回调 + */ + afterCheck?: (res: Result) => any; + /** + * 401处理后, 还是401, 则回调 + */ + afterAlso401?: (res: Result) => any; + }, + ) { + const that = this; + const refetch = opts?.refetch ?? false; + if (response?.code === 401) { + if (that.query.stop === true) { + return { code: 500, success: false, message: 'refresh token loading...' }; + } + that.query.stop = true; + const res = await that.afterCheck401ToRefreshToken(response, ctx, refetch); + that.query.stop = false; + opts?.afterCheck?.(res); + if (res.code === 401) { + opts?.afterAlso401?.(res); + } + return res; + } else { + return response as any; + } + } + /** + * 获取用户信息 + * @param token + * @returns + */ + async getMe(token?: string, check401: boolean = true) { + const _token = token || this.storage.getItem('token'); + const that = this; + return that.post( + { key: 'me' }, + { + beforeRequest: async (config) => { + if (config.headers) { + config.headers['Authorization'] = `Bearer ${_token}`; + } + if (!_token) { + return false; + } + return config; + }, + afterResponse: async (response, ctx) => { + if (response?.code === 401 && check401 && !token) { + return await that.afterCheck401ToRefreshToken(response, ctx); + } + return response as any; + }, + }, + ); + } + /** + * 检查本地用户,如果本地用户存在,则返回本地用户,否则返回null + * @returns + */ + async checkLocalUser() { + const user = await this.cacheStore.getCurrentUser(); + if (user) { + return user; + } + return null; + } + /** + * 检查本地token是否存在,简单的判断是否已经属于登陆状态 + * @returns + */ + async checkLocalToken() { + const token = this.storage.getItem('token'); + return !!token; + } + /** + * 检查本地用户列表 + * @returns + */ + async getToken() { + const token = this.storage.getItem('token'); + return token || ''; + } + async beforeRequest(opts: any = {}) { + const token = this.storage.getItem('token'); + if (token) { + opts.headers = { ...opts.headers, Authorization: `Bearer ${token}` }; + } + return opts; + } + /** + * 请求更新,切换用户, 使用switchUser + * @param username + * @returns + */ + private async postSwitchUser(username: string) { + return this.post({ key: 'switchCheck', data: { username } }); + } + /** + * 切换用户 + * @param username + * @returns + */ + async switchUser(username: string) { + const localUserList = await this.cacheStore.getCurrentUserList(); + const user = localUserList.find((userItem) => userItem.user!.username === username); + if (user) { + this.storage.setItem('token', user.accessToken || ''); + await this.beforeSetLoginUser({ accessToken: user.accessToken, refreshToken: user.refreshToken }); + return { + code: 200, + data: { + accessToken: user.accessToken, + refreshToken: user.refreshToken, + }, + success: true, + message: '切换用户成功', + }; + } + const res = await this.postSwitchUser(username); + + if (res.code === 200) { + const { accessToken, refreshToken } = res?.data || {}; + this.storage.setItem('token', accessToken || ''); + await this.beforeSetLoginUser({ accessToken, refreshToken }); + } + return res; + } + /** + * 退出登陆,去掉token, 并删除缓存 + * @returns + */ + async logout() { + this.storage.removeItem('token'); + const users = await this.cacheStore.getCurrentUserList(); + const tokens = users + .map((user) => { + return user?.accessToken; + }) + .filter(Boolean); + this.cacheStore.delValue(); + return this.post({ key: 'logout', data: { tokens } }); + } + /** + * 检查用户名的组,这个用户是否存在 + * @param username + * @returns + */ + async hasUser(username: string) { + const that = this; + return this.post( + { + path: 'org', + key: 'hasUser', + data: { + username, + }, + }, + { + afterResponse: async (response, ctx) => { + if (response?.code === 401) { + const res = await that.afterCheck401ToRefreshToken(response, ctx, true); + return res; + } + return response as any; + }, + }, + ); + } + /** + * 检查登录状态 + * @param token + * @returns + */ + async checkLoginStatus(token: string) { + const res = await this.post({ + path: 'user', + key: 'checkLoginStatus', + loginToken: token, + }); + if (res.code === 200) { + const accessToken = res.data?.accessToken; + this.storage.setItem('token', accessToken || ''); + await this.beforeSetLoginUser({ accessToken, refreshToken: res.data?.refreshToken }); + return res; + } + return false; + } + /** + * 使用web登录,创建url地址, 需要MD5和jsonwebtoken + */ + loginWithWeb(baseURL: string, { MD5, jsonwebtoken }: { MD5: any; jsonwebtoken: any }) { + const randomId = Math.random().toString(36).substring(2, 15); + const timestamp = Date.now(); + const tokenSecret = 'xiao' + randomId; + const sign = MD5(`${tokenSecret}${timestamp}`).toString(); + const token = jsonwebtoken.sign({ randomId, timestamp, sign }, tokenSecret, { + // 10分钟过期 + expiresIn: 60 * 10, // 10分钟 + }); + const url = `${baseURL}/api/router?path=user&key=webLogin&p&loginToken=${token}&sign=${sign}&randomId=${randomId}`; + return { url, token, tokenSecret }; + } +} diff --git a/packages/api/query/query-mark/query-mark.ts b/packages/api/query/query-mark/query-mark.ts new file mode 100644 index 0000000..1d58692 --- /dev/null +++ b/packages/api/query/query-mark/query-mark.ts @@ -0,0 +1,154 @@ +import { Query } from '@kevisual/query'; +import type { Result, DataOpts } from '@kevisual/query/query'; + +export type SimpleObject = Record; +export const markType = ['simple', 'md', 'mdx', 'wallnote', 'excalidraw', 'chat'] as const; +export type MarkType = (typeof markType)[number]; +export type MarkData = { + nodes?: any[]; + edges?: any[]; + elements?: any[]; + permission?: any; + + [key: string]: any; +}; +export type Mark = { + id: string; + title: string; + description: string; + markType: MarkType; + link: string; + data?: MarkData; + uid: string; + puid: string; + summary: string; + thumbnail?: string; + tags: string[]; + createdAt: string; + updatedAt: string; + version: number; +}; +export type ShowMarkPick = Pick; + +export type SearchOpts = { + page?: number; + pageSize?: number; + search?: string; + sort?: string; // DESC, ASC + markType?: MarkType; // 类型 + [key: string]: any; +}; + +export type QueryMarkOpts = { + query?: Query; + isBrowser?: boolean; + onLoad?: () => void; +} & T; + +export type ResultMarkList = { + list: Mark[]; + pagination: { + pageSize: number; + current: number; + total: number; + }; +}; +export type QueryMarkData = { + id?: string; + title?: string; + description?: string; + [key: string]: any; +}; +export type QueryMarkResult = { + accessToken: string; + refreshToken: string; +}; + +export class QueryMarkBase { + query: Query; + isBrowser: boolean; + load?: boolean; + storage?: Storage; + onLoad?: () => void; + + constructor(opts?: QueryMarkOpts) { + this.query = opts?.query || new Query(); + this.isBrowser = opts?.isBrowser ?? true; + this.init(); + this.onLoad = opts?.onLoad; + } + setQuery(query: Query) { + this.query = query; + } + private async init() { + this.load = true; + this.onLoad?.(); + } + + async post>(data: any, opts?: DataOpts): Promise { + try { + return this.query.post({ path: 'mark', ...data }, opts) as Promise; + } catch (error) { + console.log('error', error); + return { + code: 400, + } as any; + } + } + + async getMarkList(search: SearchOpts, opts?: DataOpts) { + return this.post>({ key: 'list', ...search }, opts); + } + + async getMark(id: string, opts?: DataOpts) { + return this.post>({ key: 'get', id }, opts); + } + async getVersion(id: string, opts?: DataOpts) { + return this.post>({ key: 'getVersion', id }, opts); + } + /** + * 检查版本 + * 当需要更新时,返回true + * @param id + * @param version + * @param opts + * @returns + */ + async checkVersion(id: string, version?: number, opts?: DataOpts) { + if (!version) { + return true; + } + const res = await this.getVersion(id, opts); + if (res.code === 200) { + if (res.data!.version > version) { + return true; + } + return false; + } + return true; + } + + async updateMark(data: any, opts?: DataOpts) { + return this.post>({ key: 'update', data }, opts); + } + + async deleteMark(id: string, opts?: DataOpts) { + return this.post>({ key: 'delete', id }, opts); + } +} +export class QueryMark extends QueryMarkBase { + markType: string; + constructor(opts?: QueryMarkOpts & { markType?: MarkType }) { + super(opts); + this.markType = opts?.markType || 'simple'; + } + async getMarkList(search?: SearchOpts, opts?: DataOpts) { + return this.post>({ key: 'list', ...search, markType: this.markType }, opts); + } + async updateMark(data: any, opts?: DataOpts) { + if (!data.id) { + data.markType = this.markType || 'simple'; + } + return super.updateMark(data, opts); + } +} diff --git a/packages/api/query/query-resources/index.ts b/packages/api/query/query-resources/index.ts new file mode 100644 index 0000000..a913183 --- /dev/null +++ b/packages/api/query/query-resources/index.ts @@ -0,0 +1,71 @@ +import { adapter, DataOpts, Result } from '@kevisual/query'; + +type QueryResourcesOptions = { + prefix?: string; + storage?: Storage; + username?: string; + [key: string]: any; +}; +export class QueryResources { + prefix: string; // root/resources + storage: Storage; + constructor(opts: QueryResourcesOptions) { + if (opts.username) { + this.prefix = `/${opts.username}/resources/`; + } else { + this.prefix = opts.prefix || ''; + } + this.storage = opts.storage || localStorage; + } + setUsername(username: string) { + this.prefix = `/${username}/resources/`; + } + header(headers?: Record, json = true): Record { + const token = this.storage.getItem('token'); + const _headers: Record = { + 'Content-Type': 'application/json', + ...headers, + }; + if (!json) { + delete _headers['Content-Type']; + } + if (!token) { + return _headers; + } + return { + ..._headers, + Authorization: `Bearer ${token}`, + }; + } + async get(data: any, opts: DataOpts): Promise { + return adapter({ + url: opts.url!, + method: 'GET', + body: data, + ...opts, + headers: this.header(opts?.headers), + }); + } + async getList(prefix: string, data?: { recursive?: boolean }, opts?: DataOpts): Promise> { + return this.get(data, { + url: `${this.prefix}${prefix}`, + body: data, + ...opts, + }); + } + async fetchFile(filepath: string, opts?: DataOpts): Promise> { + return fetch(`${this.prefix}${filepath}`, { + method: 'GET', + headers: this.header(opts?.headers, false), + }).then(async (res) => { + if (!res.ok) { + return { + code: 500, + success: false, + message: `Failed to fetch file: ${res.status} ${res.statusText}`, + } as Result; + } + return { code: 200, data: await res.text(), success: true } as Result; + }); + } +} diff --git a/packages/api/query/query-shop/defines/query-shop-define.ts b/packages/api/query/query-shop/defines/query-shop-define.ts new file mode 100644 index 0000000..160a089 --- /dev/null +++ b/packages/api/query/query-shop/defines/query-shop-define.ts @@ -0,0 +1,27 @@ +import { QueryUtil } from '@/query/index.ts'; + +export const shopDefine = QueryUtil.create({ + getRegistry: { + path: 'shop', + key: 'get-registry', + description: '获取应用商店注册表信息', + }, + + listInstalled: { + path: 'shop', + key: 'list-installed', + description: '列出当前已安装的所有应用', + }, + + install: { + path: 'shop', + key: 'install', + description: '安装指定的应用,可以指定 id、type、force 和 yes 参数', + }, + + uninstall: { + path: 'shop', + key: 'uninstall', + description: '卸载指定的应用,可以指定 id 和 type 参数', + }, +}); diff --git a/packages/api/query/query-shop/query-shop.ts b/packages/api/query/query-shop/query-shop.ts new file mode 100644 index 0000000..c5eba7e --- /dev/null +++ b/packages/api/query/query-shop/query-shop.ts @@ -0,0 +1,17 @@ +import { shopDefine } from './defines/query-shop-define.ts'; + +import { BaseQuery, DataOpts, Query } from '@kevisual/query/query'; + +export { shopDefine }; + +export class QueryShop extends BaseQuery { + constructor(opts?: { query: T }) { + super({ + query: opts?.query!, + queryDefine: shopDefine, + }); + } + getInstall(data: any, opts?: DataOpts) { + return this.queryDefine.queryChain('install').post(data, opts); + } +} diff --git a/packages/api/query/query-upload/core/upload-chunk.ts b/packages/api/query/query-upload/core/upload-chunk.ts new file mode 100644 index 0000000..6b7a4dd --- /dev/null +++ b/packages/api/query/query-upload/core/upload-chunk.ts @@ -0,0 +1,134 @@ +import { randomId } from '../utils/random-id.ts'; +import { UploadProgress } from './upload-progress.ts'; +export type ConvertOpts = { + appKey?: string; + version?: string; + username?: string; + directory?: string; + isPublic?: boolean; + filename?: string; + /** + * 是否不检查应用文件, 默认 true,默认不检测 + */ + noCheckAppFiles?: boolean; +}; + +// createEventSource: (baseUrl: string, searchParams: URLSearchParams) => { +// return new EventSource(baseUrl + '/api/s1/events?' + searchParams.toString()); +// }, +export type UploadOpts = { + uploadProgress: UploadProgress; + /** + * 创建 EventSource 兼容 nodejs + * @param baseUrl 基础 URL + * @param searchParams 查询参数 + * @returns EventSource + */ + createEventSource: (baseUrl: string, searchParams: URLSearchParams) => EventSource; + baseUrl?: string; + token: string; + FormDataFn: any; +}; +export const uploadFileChunked = async (file: File, opts: ConvertOpts, opts2: UploadOpts) => { + const { directory, appKey, version, username, isPublic, noCheckAppFiles = true } = opts; + const { uploadProgress, createEventSource, baseUrl = '', token, FormDataFn } = opts2 || {}; + return new Promise(async (resolve, reject) => { + const taskId = randomId(); + const filename = opts.filename || file.name; + uploadProgress?.start(`${filename} 上传中...`); + + const searchParams = new URLSearchParams(); + searchParams.set('taskId', taskId); + if (isPublic) { + searchParams.set('public', 'true'); + } + if (noCheckAppFiles) { + searchParams.set('noCheckAppFiles', '1'); + } + const eventSource = createEventSource(baseUrl + '/api/s1/events', searchParams); + let isError = false; + // 监听服务器推送的进度更新 + eventSource.onmessage = function (event) { + console.log('Progress update:', event.data); + const parseIfJson = (data: string) => { + try { + return JSON.parse(data); + } catch (e) { + return data; + } + }; + const receivedData = parseIfJson(event.data); + if (typeof receivedData === 'string') return; + const progress = Number(receivedData.progress); + const progressFixed = progress.toFixed(2); + uploadProgress?.set(progress, { ...receivedData, progressFixed, filename, taskId }); + }; + eventSource.onerror = function (event) { + console.log('eventSource.onerror', event); + isError = true; + reject(event); + }; + + const chunkSize = 1 * 1024 * 1024; // 1MB + const totalChunks = Math.ceil(file.size / chunkSize); + + for (let currentChunk = 0; currentChunk < totalChunks; currentChunk++) { + const start = currentChunk * chunkSize; + const end = Math.min(start + chunkSize, file.size); + const chunk = file.slice(start, end); + + const formData = new FormDataFn(); + formData.append('file', chunk, filename); + formData.append('chunkIndex', currentChunk.toString()); + formData.append('totalChunks', totalChunks.toString()); + const isLast = currentChunk === totalChunks - 1; + if (directory) { + formData.append('directory', directory); + } + if (appKey && version) { + formData.append('appKey', appKey); + formData.append('version', version); + } + if (username) { + formData.append('username', username); + } + try { + const res = await fetch(baseUrl + '/api/s1/resources/upload/chunk?taskId=' + taskId, { + method: 'POST', + body: formData, + headers: { + 'task-id': taskId, + Authorization: `Bearer ${token}`, + }, + }).then((response) => response.json()); + + if (res?.code !== 200) { + console.log('uploadChunk error', res); + uploadProgress?.error(res?.message || '上传失败'); + isError = true; + eventSource.close(); + + uploadProgress?.done(); + reject(new Error(res?.message || '上传失败')); + return; + } + if (isLast) { + fetch(baseUrl + '/api/s1/events/close?taskId=' + taskId); + eventSource.close(); + uploadProgress?.done(); + resolve(res); + } + // console.log(`Chunk ${currentChunk + 1}/${totalChunks} uploaded`, res); + } catch (error) { + console.log('Error uploading chunk', error); + fetch(baseUrl + '/api/s1/events/close?taskId=' + taskId); + reject(error); + return; + } + } + // 循环结束 + if (!uploadProgress?.end) { + uploadProgress?.done(); + } + }); +}; diff --git a/packages/api/query/query-upload/core/upload-progress.ts b/packages/api/query/query-upload/core/upload-progress.ts new file mode 100644 index 0000000..1de66a6 --- /dev/null +++ b/packages/api/query/query-upload/core/upload-progress.ts @@ -0,0 +1,103 @@ +interface UploadNProgress { + start: (msg?: string) => void; + done: () => void; + set: (progress: number) => void; +} +export type UploadProgressData = { + progress: number; + progressFixed: number; + filename?: string; + taskId?: string; +}; +type UploadProgressOpts = { + onStart?: () => void; + onDone?: () => void; + onProgress?: (progress: number, data?: UploadProgressData) => void; +}; +export class UploadProgress implements UploadNProgress { + /** + * 进度 + */ + progress: number; + /** + * 开始回调 + */ + onStart: (() => void) | undefined; + /** + * 结束回调 + */ + onDone: (() => void) | undefined; + /** + * 消息回调 + */ + onProgress: ((progress: number, data?: UploadProgressData) => void) | undefined; + /** + * 数据 + */ + data: any; + /** + * 是否结束 + */ + end: boolean; + constructor(uploadOpts: UploadProgressOpts) { + this.progress = 0; + this.end = false; + const mockFn = () => {}; + this.onStart = uploadOpts.onStart || mockFn; + this.onDone = uploadOpts.onDone || mockFn; + this.onProgress = uploadOpts.onProgress || mockFn; + } + start(msg?: string) { + this.progress = 0; + msg && this.info(msg); + this.end = false; + this.onStart?.(); + } + done() { + this.progress = 100; + this.end = true; + this.onDone?.(); + } + set(progress: number, data?: UploadProgressData) { + this.progress = progress; + this.data = data; + this.onProgress?.(progress, data); + console.log('uploadProgress set', progress, data); + } + /** + * 开始回调 + */ + setOnStart(callback: () => void) { + this.onStart = callback; + } + /** + * 结束回调 + */ + setOnDone(callback: () => void) { + this.onDone = callback; + } + /** + * 消息回调 + */ + setOnProgress(callback: (progress: number, data?: UploadProgressData) => void) { + this.onProgress = callback; + } + /** + * 打印信息 + */ + info(msg: string) { + console.log(msg); + } + /** + * 打印错误 + */ + error(msg: string) { + console.error(msg); + } + /** + * 打印警告 + */ + warn(msg: string) { + console.warn(msg); + } +} diff --git a/packages/api/query/query-upload/core/upload.ts b/packages/api/query/query-upload/core/upload.ts new file mode 100644 index 0000000..d545a3d --- /dev/null +++ b/packages/api/query/query-upload/core/upload.ts @@ -0,0 +1,113 @@ +import { randomId } from '../utils/random-id.ts'; +import type { UploadOpts } from './upload-chunk.ts'; +type ConvertOpts = { + appKey?: string; + version?: string; + username?: string; + directory?: string; + /** + * 文件大小限制 + */ + maxSize?: number; + /** + * 文件数量限制 + */ + maxCount?: number; + /** + * 是否不检查应用文件, 默认 true,默认不检测 + */ + noCheckAppFiles?: boolean; +}; + +export const uploadFiles = async (files: File[], opts: ConvertOpts, opts2: UploadOpts) => { + const { directory, appKey, version, username, noCheckAppFiles = true } = opts; + const { uploadProgress, createEventSource, baseUrl = '', token, FormDataFn } = opts2 || {}; + const length = files.length; + const maxSize = opts.maxSize || 20 * 1024 * 1024; // 20MB + const totalSize = files.reduce((acc, file) => acc + file.size, 0); + if (totalSize > maxSize) { + const maxSizeMB = maxSize / 1024 / 1024; + uploadProgress?.error('有文件大小不能超过' + maxSizeMB + 'MB'); + return; + } + const maxCount = opts.maxCount || 10; + if (length > maxCount) { + uploadProgress?.error(`最多只能上传${maxCount}个文件`); + return; + } + uploadProgress?.info(`上传中,共${length}个文件`); + return new Promise((resolve, reject) => { + const formData = new FormDataFn(); + const webkitRelativePath = files[0]?.webkitRelativePath; + const keepDirectory = webkitRelativePath !== ''; + const root = keepDirectory ? webkitRelativePath.split('/')[0] : ''; + for (let i = 0; i < files.length; i++) { + const file = files[i]; + if (keepDirectory) { + // relativePath 去除第一级 + const webkitRelativePath = file.webkitRelativePath.replace(root + '/', ''); + formData.append('file', file, webkitRelativePath); // 保留文件夹路径 + } else { + formData.append('file', files[i], files[i].name); + } + } + if (directory) { + formData.append('directory', directory); + } + if (appKey && version) { + formData.append('appKey', appKey); + formData.append('version', version); + } + if (username) { + formData.append('username', username); + } + const searchParams = new URLSearchParams(); + const taskId = randomId(); + searchParams.set('taskId', taskId); + + if (noCheckAppFiles) { + searchParams.set('noCheckAppFiles', '1'); + } + const eventSource = new EventSource('/api/s1/events?taskId=' + taskId); + + uploadProgress?.start('上传中...'); + eventSource.onopen = async function (event) { + const res = await fetch('/api/s1/resources/upload?' + searchParams.toString(), { + method: 'POST', + body: formData, + headers: { + 'task-id': taskId, + Authorization: `Bearer ${token}`, + }, + }).then((response) => response.json()); + + console.log('upload success', res); + fetch('/api/s1/events/close?taskId=' + taskId); + eventSource.close(); + uploadProgress?.done(); + resolve(res); + }; + // 监听服务器推送的进度更新 + eventSource.onmessage = function (event) { + console.log('Progress update:', event.data); + const parseIfJson = (data: string) => { + try { + return JSON.parse(data); + } catch (e) { + return data; + } + }; + const receivedData = parseIfJson(event.data); + if (typeof receivedData === 'string') return; + const progress = Number(receivedData.progress); + const progressFixed = progress.toFixed(2); + console.log('progress', progress); + uploadProgress?.set(progress, { ...receivedData, taskId, progressFixed }); + }; + + eventSource.onerror = function (event) { + console.log('eventSource.onerror', event); + reject(event); + }; + }); +}; diff --git a/packages/api/query/query-upload/query-upload-browser.ts b/packages/api/query/query-upload/query-upload-browser.ts new file mode 100644 index 0000000..16e502c --- /dev/null +++ b/packages/api/query/query-upload/query-upload-browser.ts @@ -0,0 +1,51 @@ +import { UploadProgress, UploadProgressData } from './core/upload-progress.ts'; +import { uploadFileChunked } from './core/upload-chunk.ts'; +import { toFile, uploadFiles, randomId } from './query-upload.ts'; + +export { toFile, randomId }; +export { uploadFiles, uploadFileChunked, UploadProgress }; + +type UploadFileProps = { + onStart?: () => void; + onDone?: () => void; + onProgress?: (progress: number, data: UploadProgressData) => void; + onSuccess?: (res: any) => void; + onError?: (err: any) => void; + token?: string; +}; +export type ConvertOpts = { + appKey?: string; + version?: string; + username?: string; + directory?: string; + isPublic?: boolean; + filename?: string; + /** + * 是否不检查应用文件, 默认 true,默认不检测 + */ + noCheckAppFiles?: boolean; +}; + +export const uploadChunk = async (file: File, opts: ConvertOpts, props?: UploadFileProps) => { + const uploadProgress = new UploadProgress({ + onStart: function () { + props?.onStart?.(); + }, + onDone: () => { + props?.onDone?.(); + }, + onProgress: (progress, data) => { + props?.onProgress?.(progress, data!); + }, + }); + const result = await uploadFileChunked(file, opts, { + uploadProgress, + token: props?.token!, + createEventSource: (url: string, searchParams: URLSearchParams) => { + return new EventSource(url + '?' + searchParams.toString()); + }, + FormDataFn: FormData, + }); + + return result; +}; diff --git a/packages/api/query/query-upload/query-upload-node.ts b/packages/api/query/query-upload/query-upload-node.ts new file mode 100644 index 0000000..f638cb6 --- /dev/null +++ b/packages/api/query/query-upload/query-upload-node.ts @@ -0,0 +1 @@ +// console.log('upload) \ No newline at end of file diff --git a/packages/api/query/query-upload/query-upload.ts b/packages/api/query/query-upload/query-upload.ts new file mode 100644 index 0000000..547d20b --- /dev/null +++ b/packages/api/query/query-upload/query-upload.ts @@ -0,0 +1,11 @@ +import { uploadFiles } from './core/upload.ts'; + +import { uploadFileChunked } from './core/upload-chunk.ts'; +import { UploadProgress } from './core/upload-progress.ts'; + +export { uploadFiles, uploadFileChunked, UploadProgress }; + +export * from './utils/to-file.ts'; +export { randomId } from './utils/random-id.ts'; + +export { filterFiles } from './utils/filter-files.ts'; diff --git a/packages/api/query/query-upload/utils/filter-files.ts b/packages/api/query/query-upload/utils/filter-files.ts new file mode 100644 index 0000000..71ab8f0 --- /dev/null +++ b/packages/api/query/query-upload/utils/filter-files.ts @@ -0,0 +1,23 @@ +/** + * 过滤文件, 过滤 .DS_Store, node_modules, 以.开头的文件, 过滤 __开头的文件 + * @param files + * @returns + */ +export const filterFiles = (files: File[]) => { + files = files.filter((file) => { + if (file.webkitRelativePath.startsWith('__MACOSX')) { + return false; + } + // 过滤node_modules + if (file.webkitRelativePath.includes('node_modules')) { + return false; + } + // 过滤文件 .DS_Store + if (file.name === '.DS_Store') { + return false; + } + // 过滤以.开头的文件 + return !file.name.startsWith('.'); + }); + return files; +}; diff --git a/packages/api/query/query-upload/utils/index.ts b/packages/api/query/query-upload/utils/index.ts new file mode 100644 index 0000000..86bd60c --- /dev/null +++ b/packages/api/query/query-upload/utils/index.ts @@ -0,0 +1,3 @@ +export * from './to-file.ts'; +export * from './filter-files.ts'; +export * from './random-id.ts'; diff --git a/packages/api/query/query-upload/utils/random-id.ts b/packages/api/query/query-upload/utils/random-id.ts new file mode 100644 index 0000000..c36e4f0 --- /dev/null +++ b/packages/api/query/query-upload/utils/random-id.ts @@ -0,0 +1,3 @@ +export const randomId = () => { + return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15); +}; \ No newline at end of file diff --git a/packages/api/query/query-upload/utils/to-file.ts b/packages/api/query/query-upload/utils/to-file.ts new file mode 100644 index 0000000..1072e24 --- /dev/null +++ b/packages/api/query/query-upload/utils/to-file.ts @@ -0,0 +1,105 @@ +const getFileExtension = (filename: string) => { + return filename.split('.').pop(); +}; +const getFileType = (extension: string) => { + switch (extension) { + case 'js': + return 'text/javascript'; + case 'css': + return 'text/css'; + case 'html': + return 'text/html'; + case 'json': + return 'application/json'; + case 'png': + return 'image/png'; + case 'jpg': + return 'image/jpeg'; + case 'jpeg': + return 'image/jpeg'; + case 'gif': + return 'image/gif'; + case 'svg': + return 'image/svg+xml'; + case 'webp': + return 'image/webp'; + case 'ico': + return 'image/x-icon'; + default: + return 'text/plain'; + } +}; +const checkIsBase64 = (content: string) => { + return content.startsWith('data:'); +}; +/** + * 获取文件的目录和文件名 + * @param filename 文件名 + * @returns 目录和文件名 + */ +export const getDirectoryAndName = (filename: string) => { + if (!filename) { + return null; + } + if (filename.startsWith('.')) { + return null; + } else { + filename = filename.replace(/^\/+/, ''); // Remove all leading slashes + } + const hasDirectory = filename.includes('/'); + if (!hasDirectory) { + return { directory: '', name: filename }; + } + const parts = filename.split('/'); + const name = parts.pop()!; // Get the last part as the file name + const directory = parts.join('/'); // Join the remaining parts as the directory + return { directory, name }; +}; +/** + * 把字符串转为文件流,并返回文件流,根据filename的扩展名,自动设置文件类型. + * 当不是文本类型,自动需要把base64的字符串转为blob + * @param content 字符串 + * @param filename 文件名 + * @returns 文件流 + */ +export const toFile = (content: string, filename: string) => { + // 如果文件名是 a/d/a.js 格式的,则需要把d作为目录,a.js作为文件名 + const directoryAndName = getDirectoryAndName(filename); + if (!directoryAndName) { + throw new Error('Invalid filename'); + } + const { name } = directoryAndName; + const extension = getFileExtension(name); + if (!extension) { + throw new Error('Invalid filename'); + } + const isBase64 = checkIsBase64(content); + const type = getFileType(extension); + + if (isBase64) { + // Decode base64 string + const base64Data = content.split(',')[1]; // Remove the data URL prefix + const byteCharacters = atob(base64Data); + const byteNumbers = new Array(byteCharacters.length); + for (let i = 0; i < byteCharacters.length; i++) { + byteNumbers[i] = byteCharacters.charCodeAt(i); + } + const byteArray = new Uint8Array(byteNumbers); + const blob = new Blob([byteArray], { type }); + return new File([blob], filename, { type }); + } else { + const blob = new Blob([content], { type }); + return new File([blob], filename, { type }); + } +}; + +/** + * 把字符串转为文本文件 + * @param content 字符串 + * @param filename 文件名 + * @returns 文件流 + */ +export const toTextFile = (content: string = 'keep directory exist', filename: string = 'keep.txt') => { + const file = toFile(content, filename); + return file; +}; diff --git a/packages/api/tsconfig.json b/packages/api/tsconfig.json new file mode 100644 index 0000000..3d64ec7 --- /dev/null +++ b/packages/api/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "@kevisual/types/json/frontend.json", + "compilerOptions": { + "baseUrl": ".", + "typeRoots": [ + "./node_modules/@types", + "./node_modules/@kevisual" + ], + "paths": { + "@/*": [ + "src/*" + ] + }, + }, + "include": [ + "src/**/*", + "query/**/*", + ], +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 29421c0..41d88e4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,6 +27,22 @@ importers: specifier: ^8.4.0 version: 8.4.0(typescript@5.8.3) + packages/api: + dependencies: + '@kevisual/query': + specifier: ^0.0.18 + version: 0.0.18 + '@kevisual/router': + specifier: ^0.0.20 + version: 0.0.20 + devDependencies: + '@kevisual/types': + specifier: ^0.0.10 + version: 0.0.10 + '@types/node': + specifier: ^22.15.27 + version: 22.15.27 + packages/list: {} packages/query-app: {} @@ -67,7 +83,7 @@ importers: devDependencies: '@types/node': specifier: ^22.14.1 - version: 22.15.18 + version: 22.15.27 tsup: specifier: ^8.4.0 version: 8.4.0(typescript@5.8.3) @@ -86,7 +102,7 @@ importers: devDependencies: '@types/node': specifier: ^22.13.11 - version: 22.15.18 + version: 22.15.27 tsup: specifier: ^8.4.0 version: 8.4.0(typescript@5.8.3) @@ -95,7 +111,7 @@ importers: devDependencies: '@types/node': specifier: ^22.13.14 - version: 22.15.18 + version: 22.15.27 eventsource: specifier: ^3.0.6 version: 3.0.7 @@ -407,56 +423,67 @@ packages: resolution: {integrity: sha512-de6TFZYIvJwRNjmW3+gaXiZ2DaWL5D5yGmSYzkdzjBDS3W+B9JQ48oZEsmMvemqjtAFzE16DIBLqd6IQQRuG9Q==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.40.2': resolution: {integrity: sha512-urjaEZubdIkacKc930hUDOfQPysezKla/O9qV+O89enqsqUmQm8Xj8O/vh0gHg4LYfv7Y7UsE3QjzLQzDYN1qg==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.40.2': resolution: {integrity: sha512-KlE8IC0HFOC33taNt1zR8qNlBYHj31qGT1UqWqtvR/+NuCVhfufAq9fxO8BMFC22Wu0rxOwGVWxtCMvZVLmhQg==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.40.2': resolution: {integrity: sha512-j8CgxvfM0kbnhu4XgjnCWJQyyBOeBI1Zq91Z850aUddUmPeQvuAy6OiMdPS46gNFgy8gN1xkYyLgwLYZG3rBOg==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loongarch64-gnu@4.40.2': resolution: {integrity: sha512-Ybc/1qUampKuRF4tQXc7G7QY9YRyeVSykfK36Y5Qc5dmrIxwFhrOzqaVTNoZygqZ1ZieSWTibfFhQ5qK8jpWxw==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-powerpc64le-gnu@4.40.2': resolution: {integrity: sha512-3FCIrnrt03CCsZqSYAOW/k9n625pjpuMzVfeI+ZBUSDT3MVIFDSPfSUgIl9FqUftxcUXInvFah79hE1c9abD+Q==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.40.2': resolution: {integrity: sha512-QNU7BFHEvHMp2ESSY3SozIkBPaPBDTsfVNGx3Xhv+TdvWXFGOSH2NJvhD1zKAT6AyuuErJgbdvaJhYVhVqrWTg==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.40.2': resolution: {integrity: sha512-5W6vNYkhgfh7URiXTO1E9a0cy4fSgfE4+Hl5agb/U1sa0kjOLMLC1wObxwKxecE17j0URxuTrYZZME4/VH57Hg==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.40.2': resolution: {integrity: sha512-B7LKIz+0+p348JoAL4X/YxGx9zOx3sR+o6Hj15Y3aaApNfAshK8+mWZEf759DXfRLeL2vg5LYJBB7DdcleYCoQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.40.2': resolution: {integrity: sha512-lG7Xa+BmBNwpjmVUbmyKxdQJ3Q6whHjMjzQplOs5Z+Gj7mxPtWakGHqzMqNER68G67kmCX9qX57aRsW5V0VOng==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.40.2': resolution: {integrity: sha512-tD46wKHd+KJvsmije4bUskNuvWKFcTOIM9tZ/RrmIvcXnbi0YK/cKS9FzFtAm7Oxi2EhV5N2OpfFB348vSQRXA==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-win32-arm64-msvc@4.40.2': resolution: {integrity: sha512-Bjv/HG8RRWLNkXwQQemdsWw4Mg+IJ29LK+bJPW2SCzPKOUaMmPEppQlu/Fqk1d7+DX3V7JbFdbkh/NMmurT6Pg==} @@ -488,6 +515,9 @@ packages: '@types/node@22.15.18': resolution: {integrity: sha512-v1DKRfUdyW+jJhZNEI1PYy29S2YRxMV5AOO/x/SjKmW0acCIOqmbj6Haf9eHAhsPmrhlHSxEhv/1WszcLWV4cg==} + '@types/node@22.15.27': + resolution: {integrity: sha512-5fF+eu5mwihV2BeVtX5vijhdaZOfkQTATrePEaXTcKqI16LhJ7gi2/Vhd9OZM0UojcdmiOCVg5rrax+i1MdoQQ==} + '@types/resolve@1.20.2': resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} @@ -1382,12 +1412,12 @@ snapshots: '@types/node-fetch@2.6.12': dependencies: - '@types/node': 22.15.18 + '@types/node': 22.15.27 form-data: 4.0.2 '@types/node-forge@1.3.11': dependencies: - '@types/node': 22.15.18 + '@types/node': 22.15.27 '@types/node@18.19.100': dependencies: @@ -1397,6 +1427,10 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/node@22.15.27': + dependencies: + undici-types: 6.21.0 + '@types/resolve@1.20.2': {} abort-controller@3.0.0: