feat: add query and fix bugs

This commit is contained in:
熊潇 2025-06-18 15:27:50 +08:00
parent fc37a99cf8
commit 1c7c5f388d
35 changed files with 2829 additions and 462 deletions

25
kevisual.json Normal file
View File

@ -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": {}
}

View File

@ -26,7 +26,7 @@
"@kevisual/codemirror": "workspace:*", "@kevisual/codemirror": "workspace:*",
"@kevisual/components": "workspace:*", "@kevisual/components": "workspace:*",
"@kevisual/container": "1.0.0", "@kevisual/container": "1.0.0",
"@kevisual/query": "^0.0.28", "@kevisual/query": "^0.0.29",
"@kevisual/query-config": "workspace:*", "@kevisual/query-config": "workspace:*",
"@kevisual/query-login": "workspace:*", "@kevisual/query-login": "workspace:*",
"@kevisual/query-upload": "workspace:*", "@kevisual/query-upload": "workspace:*",
@ -34,9 +34,9 @@
"@monaco-editor/react": "^4.7.0", "@monaco-editor/react": "^4.7.0",
"@mui/material": "^7.1.1", "@mui/material": "^7.1.1",
"@stackblitz/sdk": "^1.11.0", "@stackblitz/sdk": "^1.11.0",
"@tailwindcss/vite": "^4.1.8", "@tailwindcss/vite": "^4.1.10",
"@uiw/react-textarea-code-editor": "^3.1.1", "@uiw/react-textarea-code-editor": "^3.1.1",
"antd": "^5.25.4", "antd": "^5.26.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"copy-to-clipboard": "^3.3.3", "copy-to-clipboard": "^3.3.3",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
@ -52,9 +52,9 @@
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-dropzone": "^14.3.8", "react-dropzone": "^14.3.8",
"react-hook-form": "^7.57.0", "react-hook-form": "^7.58.1",
"react-i18next": "^15.5.2", "react-i18next": "^15.5.3",
"react-resizable-panels": "^3.0.2", "react-resizable-panels": "^3.0.3",
"react-router": "^7.6.2", "react-router": "^7.6.2",
"react-router-dom": "^7.6.2", "react-router-dom": "^7.6.2",
"react-toastify": "^11.0.5", "react-toastify": "^11.0.5",
@ -62,35 +62,35 @@
"zustand": "^5.0.5" "zustand": "^5.0.5"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.28.0", "@eslint/js": "^9.29.0",
"@kevisual/ssl": "^0.0.1", "@kevisual/ssl": "^0.0.1",
"@types/js-yaml": "^4.0.9", "@types/js-yaml": "^4.0.9",
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@types/node": "^22.15.30", "@types/node": "^24.0.3",
"@types/path-browserify": "^1.0.3", "@types/path-browserify": "^1.0.3",
"@types/qrcode": "^1.5.5", "@types/qrcode": "^1.5.5",
"@types/react": "^19.1.6", "@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6", "@types/react-dom": "^19.1.6",
"@vitejs/plugin-basic-ssl": "^2.0.0", "@vitejs/plugin-basic-ssl": "^2.0.0",
"@vitejs/plugin-react": "^4.5.1", "@vitejs/plugin-react": "^4.5.2",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"eslint": "^9.28.0", "eslint": "^9.29.0",
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20", "eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.2.0", "globals": "^16.2.0",
"lucide-react": "^0.513.0", "lucide-react": "^0.516.0",
"path-browserify": "^1.0.1", "path-browserify": "^1.0.1",
"postcss-import": "^16.1.0", "postcss-import": "^16.1.1",
"pretty-bytes": "^7.0.0", "pretty-bytes": "^7.0.0",
"react-is": "19.1.0", "react-is": "19.1.0",
"tailwind-merge": "^3.3.0", "tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.8", "tailwindcss": "^4.1.10",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"turbo": "^2.5.4", "turbo": "^2.5.4",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"typescript-eslint": "^8.33.1", "typescript-eslint": "^8.34.1",
"vite": "^6.3.5" "vite": "^6.3.5"
}, },
"packageManager": "pnpm@10.11.1" "packageManager": "pnpm@10.12.1"
} }

View File

@ -21,23 +21,23 @@
"@emotion/styled": "^11.14.0", "@emotion/styled": "^11.14.0",
"@kevisual/components": "workspace:*", "@kevisual/components": "workspace:*",
"@kevisual/query-upload": "workspace:*", "@kevisual/query-upload": "workspace:*",
"@kevisual/router": "^0.0.18", "@kevisual/router": "^0.0.22",
"@kevisual/store": "^0.0.4", "@kevisual/store": "^0.0.9",
"@mui/material": "^7.1.0", "@mui/material": "^7.1.1",
"@vitejs/plugin-basic-ssl": "^2.0.0", "@vitejs/plugin-basic-ssl": "^2.0.0",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"immer": "^10.1.1", "immer": "^10.1.1",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"lucide-react": "^0.510.0", "lucide-react": "^0.516.0",
"nanoid": "^5.1.5", "nanoid": "^5.1.5",
"nprogress": "^0.2.0", "nprogress": "^0.2.0",
"pretty-bytes": "^7.0.0", "pretty-bytes": "^7.0.0",
"react": "19.1.0", "react": "19.1.0",
"react-datepicker": "^8.3.0", "react-datepicker": "^8.4.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-dropzone": "^14.3.8", "react-dropzone": "^14.3.8",
"react-toastify": "^11.0.5", "react-toastify": "^11.0.5",
"zustand": "^5.0.4" "zustand": "^5.0.5"
}, },
"devDependencies": { "devDependencies": {
"@kevisual/types": "^0.0.10", "@kevisual/types": "^0.0.10",

1150
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,5 @@
import { QueryClient } from '@kevisual/query'; import { QueryClient } from '@kevisual/query';
import { QueryLoginBrowser } from '@kevisual/query-login'; import { QueryLoginBrowser } from '@/query/query-login/query-login-browser';
import { toastLogin } from '@kevisual/resources/pages/message/ToastLogin.tsx'; import { toastLogin } from '@kevisual/resources/pages/message/ToastLogin.tsx';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
export const query = new QueryClient({ export const query = new QueryClient({
@ -22,7 +22,7 @@ query.afterResponse = async (res, ctx) => {
toast.success('刷新登陆信息'); toast.success('刷新登陆信息');
setTimeout(() => { setTimeout(() => {
window.location.reload(); window.location.reload();
}, 1000); }, 2000);
} }
}, },
}); });

7
src/query/index.ts Normal file
View File

@ -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';

25
src/query/kevisual.json Normal file
View File

@ -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": {}
}

View File

@ -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 服务,生成结果,并返回。',
},
});

View File

@ -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<T extends Query = Query> extends BaseQuery<T, typeof appDefine> {
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,
);
}
}

View File

@ -0,0 +1,3 @@
import { appDefine } from './user-app-list';
import { userAppDefine } from './user-app';
export { appDefine, userAppDefine };

View File

@ -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: '获取公开应用列表',
},
});

View File

@ -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 的数据进行测试,获取版本信息',
},
});

View File

@ -0,0 +1 @@
export * from './defines/index.ts';

View File

@ -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);
}
}

View File

@ -0,0 +1,204 @@
export interface Cache {
/**
* @update
*/
get(key: string): Promise<any>;
/**
* @update
*/
set(key: string, value: any): Promise<any>;
/**
* @update
*/
del(): Promise<void>;
/**
*
*/
init?: () => Promise<any>;
}
export 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<T = Cache> = {
name: string;
/**
*
* @important init
*/
cacheData: CacheLogin;
/**
* cache, init
*/
cache: T;
/**
*
*/
setLoginUser(user: CacheLoginUser): Promise<void>;
/**
*
*/
getCurrentUser(): Promise<User>;
/**
*
*/
getCurrentUserList(): Promise<CacheLoginUser[]>;
/**
* refreshToken
*/
getRefreshToken(): Promise<string>;
/**
* accessToken
*/
getAccessToken(): Promise<string>;
/**
*
*/
clearCurrentUser(): Promise<void>;
/**
*
*/
clearAll(): Promise<void>;
getValue(): Promise<CacheLogin>;
setValue(value: CacheLogin): Promise<CacheLogin>;
delValue(): Promise<void>;
init(): Promise<any>;
};
export type LoginCacheStoreOpts = {
name: string;
cache: Cache;
};
export class LoginCacheStore implements CacheStore<any> {
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<CacheLogin> {
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<CacheLoginUser> {
const cacheData = this.cacheData;
return Promise.resolve(cacheData.user!);
}
getCurrentUserList(): Promise<CacheLoginUser[]> {
return Promise.resolve(this.cacheData.loginUsers.filter((u) => u?.id));
}
getRefreshToken(): Promise<string> {
const cacheData = this.cacheData;
return Promise.resolve(cacheData.refreshToken || '');
}
getAccessToken(): Promise<string> {
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);
}
}

View File

@ -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);
}
}

View File

@ -0,0 +1,12 @@
import { QueryLogin, QueryLoginOpts } from './query-login.ts';
import { MyCache } from '@kevisual/cache';
type QueryLoginNodeOptsWithoutCache = Omit<QueryLoginOpts, 'cache'>;
export class QueryLoginBrowser extends QueryLogin {
constructor(opts: QueryLoginNodeOptsWithoutCache) {
super({
...opts,
cache: new MyCache('login'),
});
}
}

View File

@ -0,0 +1,14 @@
import { QueryLogin, QueryLoginOpts } from './query-login.ts';
import { LoginNodeCache, StorageNode } from './login-node-cache.ts';
type QueryLoginNodeOptsWithoutCache = Omit<QueryLoginOpts, 'cache'>;
export const storage = new StorageNode();
await storage.loadCache();
export class QueryLoginNode extends QueryLogin {
constructor(opts: QueryLoginNodeOptsWithoutCache) {
super({
...opts,
storage,
cache: new LoginNodeCache(),
});
}
}

View File

@ -0,0 +1,468 @@
import { Query, BaseQuery } from '@kevisual/query';
import type { Result, DataOpts } from '@kevisual/query/query';
import { setBaseResponse } from '@kevisual/query/query';
import { LoginCacheStore, CacheStore, User } 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<T = any>(data: any, opts?: DataOpts) {
try {
return this.query.post<T>({ 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<QueryLoginResult>({ 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<QueryLoginResult>({ 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<QueryLoginResult>({ 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;
}
/**
* onSuccessonError
* @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 },
{
noStop: true,
beforeRequest: async (config) => {
return config;
},
afterResponse: async (response, ctx) => {
setBaseResponse(response);
return response as any;
},
},
);
}
/**
* 401token, 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 401token, 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' },
{
noStop: true,
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<Result>({ key: 'logout', data: { tokens } });
}
/**
*
* @param username
* @returns
*/
async hasUser(username: string) {
const that = this;
return this.post<Result>(
{
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;
},
},
);
}
async getLoginUser() {
const that = this;
const userInfo = await that.checkLocalUser();
const token = await that.getToken();
if (userInfo) {
return userInfo;
} else if (token) {
const userinfo = await that.getLoginUserByToken(token);
if (userinfo) {
return userinfo;
}
}
return null;
}
async getLoginUserByToken(token: string): Promise<User | null> {
const me = await this.getMe(token, false);
if (me.code === 200) {
const user = me.data;
this.cacheStore.setLoginUser({
user,
id: user.id,
accessToken: token,
refreshToken: me.data?.refreshToken || '',
});
return user;
}
return null;
}
/**
* login by web
* @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 };
}
}

View File

@ -0,0 +1,154 @@
import { Query } from '@kevisual/query';
import type { Result, DataOpts } from '@kevisual/query/query';
export type SimpleObject = Record<string, any>;
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<Mark, 'id' | 'title' | 'description' | 'summary' | 'link' | 'tags' | 'thumbnail' | 'updatedAt'>;
export type SearchOpts = {
page?: number;
pageSize?: number;
search?: string;
sort?: string; // DESC, ASC
markType?: MarkType; // 类型
[key: string]: any;
};
export type QueryMarkOpts<T extends SimpleObject = SimpleObject> = {
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<T extends SimpleObject = SimpleObject> {
query: Query;
isBrowser: boolean;
load?: boolean;
storage?: Storage;
onLoad?: () => void;
constructor(opts?: QueryMarkOpts<T>) {
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<T = Result<any>>(data: any, opts?: DataOpts): Promise<T> {
try {
return this.query.post({ path: 'mark', ...data }, opts) as Promise<T>;
} catch (error) {
console.log('error', error);
return {
code: 400,
} as any;
}
}
async getMarkList(search: SearchOpts, opts?: DataOpts) {
return this.post<Result<ResultMarkList>>({ key: 'list', ...search }, opts);
}
async getMark(id: string, opts?: DataOpts) {
return this.post<Result<Mark>>({ key: 'get', id }, opts);
}
async getVersion(id: string, opts?: DataOpts) {
return this.post<Result<{ version: number; id: string }>>({ 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<Result<Mark>>({ key: 'update', data }, opts);
}
async deleteMark(id: string, opts?: DataOpts) {
return this.post<Result<Mark>>({ key: 'delete', id }, opts);
}
}
export class QueryMark extends QueryMarkBase<SimpleObject> {
markType: string;
constructor(opts?: QueryMarkOpts & { markType?: MarkType }) {
super(opts);
this.markType = opts?.markType || 'simple';
}
async getMarkList(search?: SearchOpts, opts?: DataOpts) {
return this.post<Result<ResultMarkList>>({ 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);
}
}

View File

@ -0,0 +1,155 @@
import { Content } from './../../apps/ai-editor/content';
import { DataOpts, Result } from '@kevisual/query';
import { adapter } from '@kevisual/query';
import path from 'path-browserify-esm';
import { hashContent } from './utils';
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) {
const prefix = `/${username}/resources/`;
return prefix;
}
setPrefix(prefix: string) {
this.prefix = prefix;
}
header(headers?: Record<string, string>, json = true): Record<string, string> {
const token = this.storage.getItem('token');
const _headers: Record<string, string> = {
'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<any> {
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<Result<any[]>> {
return this.get(data, {
url: `${this.prefix}${prefix}`,
body: data,
...opts,
});
}
async fetchFile(filepath: string, opts?: DataOpts): Promise<Result<any>> {
const url = `${this.prefix}${filepath}`;
return this.get({}, { url, method: 'GET', headers: this.header(opts?.headers, false), isText: true });
}
async uploadFile(filepath: string, content: string, opts?: DataOpts): Promise<Result<any>> {
const pathname = `${this.prefix}${filepath}`;
const filename = path.basename(pathname);
const type = getContentType(filename);
const url = new URL(pathname, window.location.origin);
const hash = hashContent(content);
url.searchParams.set('hash', hash);
const formData = new FormData();
formData.append('file', new Blob([content], { type }));
return adapter({
url: url.toString(),
headers: { ...this.header(opts?.headers, false) },
isPostFile: true,
method: 'POST',
body: formData,
});
}
}
export const getContentType = (filename: string): string => {
const ext = path.extname(filename);
let type = 'text/plain';
switch (ext) {
case '':
type = 'application/octet-stream';
break;
case '.json':
type = 'application/json';
break;
case '.txt':
type = 'text/plain';
break;
case '.csv':
type = 'text/csv';
break;
case '.md':
type = 'text/markdown';
break;
case '.html':
case '.htm':
type = 'text/html';
break;
case '.xml':
type = 'application/xml';
break;
case '.js':
type = 'application/javascript';
break;
case '.css':
type = 'text/css';
break;
case '.ts':
type = 'application/typescript';
break;
case '.pdf':
type = 'application/pdf';
break;
case '.zip':
type = 'application/zip';
break;
case '.docx':
type = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
break;
case '.xlsx':
type = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
break;
case '.mp3':
type = 'audio/mpeg';
break;
case '.mp4':
type = 'video/mp4';
break;
case '.png':
case '.jpg':
case '.jpeg':
case '.gif':
case '.webp':
type = `image/${ext.slice(1)}`;
break;
case '.svg':
type = 'image/svg+xml';
break;
}
return type;
};

View File

@ -0,0 +1,42 @@
import MD5 from 'crypto-js/md5';
export const hashContent = (str: string | Buffer): string => {
if (typeof str === 'string') {
return MD5(str).toString();
} else if (Buffer.isBuffer(str)) {
return MD5(str.toString()).toString();
}
console.error('hashContent error: input must be a string or Buffer');
return '';
};
export const hashFile = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = async (event) => {
try {
const content = event.target?.result;
if (content instanceof ArrayBuffer) {
const contentString = new TextDecoder().decode(content);
const hashHex = MD5(contentString).toString();
resolve(hashHex);
} else if (typeof content === 'string') {
const hashHex = MD5(content).toString();
resolve(hashHex);
} else {
throw new Error('Invalid content type');
}
} catch (error) {
console.error('hashFile error', error);
reject(error);
}
};
reader.onerror = (error) => {
reject(error);
};
// 读取文件为 ArrayBuffer
reader.readAsArrayBuffer(file);
});
};

View File

@ -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 参数',
},
});

View File

@ -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<T extends Query = Query> extends BaseQuery<T, typeof shopDefine> {
constructor(opts?: { query: T }) {
super({
query: opts?.query!,
queryDefine: shopDefine,
});
}
getInstall(data: any, opts?: DataOpts) {
return this.queryDefine.queryChain('install').post(data, opts);
}
}

View File

@ -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();
}
});
};

View File

@ -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);
}
}

View File

@ -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);
};
});
};

View File

@ -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;
};

View File

@ -0,0 +1 @@
// console.log('upload)

View File

@ -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';

View File

@ -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;
};

View File

@ -0,0 +1,3 @@
export * from './to-file.ts';
export * from './filter-files.ts';
export * from './random-id.ts';

View File

@ -0,0 +1,3 @@
export const randomId = () => {
return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
};

View File

@ -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;
};

@ -1 +1 @@
Subproject commit 9d22faa8ba210d183064b15cc229563d9cf89430 Subproject commit bbf826b765f841fb0f07b2180a6fe7f3de009116