This commit is contained in:
2026-04-25 16:36:34 +08:00
commit db90e7579b
1876 changed files with 189777 additions and 0 deletions

View File

@@ -0,0 +1,14 @@
import { joinArray } from '../array-helper';
describe('array-helper', () => {
test('joinArray', () => {
expect(joinArray([1, 2, 3], '5')).toMatchObject([1, '5', 2, '5', 3]);
expect(joinArray([{ a: 1 }, { a: 2 }, { a: 3 }], '5')).toMatchObject([
{ a: 1 },
'5',
{ a: 2 },
'5',
{ a: 3 },
]);
});
});

View File

@@ -0,0 +1,14 @@
import { parseColorScheme } from '../color-scheme-helper';
describe('parseColorScheme', () => {
test.each([
['dark', { isDarkMode: true, extraSchemeName: null }],
['light', { isDarkMode: false, extraSchemeName: null }],
['auto', { isDarkMode: true, extraSchemeName: null }],
['dark+miku', { isDarkMode: true, extraSchemeName: 'theme-miku' }],
['light+miku', { isDarkMode: false, extraSchemeName: 'theme-miku' }],
['miku', { isDarkMode: true, extraSchemeName: 'theme-miku' }],
])('%s', (input, output) => {
expect(parseColorScheme(input)).toEqual(output);
});
});

View File

@@ -0,0 +1,21 @@
import { isToday, getMessageTimeDiff } from '../date-helper';
describe('isToday', () => {
test.each([
[new Date(), true],
[new Date(new Date().setDate(new Date().getDate() - 1)), false],
])('%s => %s', (input, should) => {
expect(isToday(input)).toBe(should);
});
});
describe('getMessageTimeDiff', () => {
test.each([
[new Date(), '几秒前'],
[new Date(new Date().setMinutes(new Date().getMinutes() - 1)), '1 分钟前'],
[new Date(new Date().setHours(new Date().getHours() - 1)), '1 小时前'],
[new Date('2020-01-01T00:00:00Z'), '2020-01-01 08:00:00'],
])('%s => %s', (input, should) => {
expect(getMessageTimeDiff(input)).toBe(should);
});
});

View File

@@ -0,0 +1,16 @@
import { isPromise } from '../is-promise';
describe('isPromise', () => {
test.each([
[Promise.resolve(), true],
['str', false],
[123, false],
[[], false],
[{}, false],
[Symbol('sym'), false],
[undefined, false],
[null, false],
])('%s => %s', (input, should) => {
expect(isPromise(input)).toBe(should);
});
});

View File

@@ -0,0 +1,18 @@
import { isValidJson } from '../json-helper';
describe('isValidJson', () => {
test.each([
['foo', false],
['[]', true],
['{}', true],
['{"foo": []}', true],
['{"foo": [}', false],
['{foo: bar}', false],
['{"foo": "bar"}', true],
[[], false],
[null, false],
[undefined, false],
])('%s => %s', (input: any, should) => {
expect(isValidJson(input)).toBe(should);
});
});

View File

@@ -0,0 +1,41 @@
import { isAvailableString, isObjectId, isUrl } from '../string-helper';
describe('string-helper', () => {
describe('isAvailableString', () => {
test.each<[any, boolean]>([
['any string', true],
['', false],
[1, false],
[() => {}, false],
[{}, false],
[[], false],
[undefined, false],
[null, false],
])('%p => %p', (url, res) => {
expect(isAvailableString(url)).toBe(res);
});
});
describe('isUrl', () => {
test.each<[string, boolean]>([
['http://baidu.com', true],
['https://baidu.com', true],
['ws://baidu.com', true],
['wss://baidu.com', true],
['baidu.com', false],
['baidu', false],
])('%s => %p', (url, res) => {
expect(isUrl(url)).toBe(res);
});
});
describe('isObjectId', () => {
test.each<[string, boolean]>([
['1', false],
['unknown', false],
['64b4a473a44c273805b25da5', true],
])('%s => %p', (input, res) => {
expect(isObjectId(input)).toBe(res);
});
});
});

View File

@@ -0,0 +1,21 @@
import _flatten from 'lodash/flatten';
/**
* 类似于join但是返回一个数组
* join会将元素强制转化为字符串
*
* 改函数可以用于join ReactNode
*
* @example joinArray([1, 2, 3], '5') => [1, '5', 2, '5', 3]
*/
export function joinArray<T, K>(arr: T[], separator: K): (T | K)[] {
return _flatten(
arr.map((item, i) => {
if (i === 0) {
return [item];
} else {
return [separator, item];
}
})
);
}

View File

@@ -0,0 +1,41 @@
import { isValidStr } from './string-helper';
/**
* 解析配色方案优先dark模式
*/
export function parseColorScheme(colorScheme: string): {
isDarkMode: boolean;
extraSchemeName: string | null;
} {
if (colorScheme === 'dark') {
return {
isDarkMode: true,
extraSchemeName: null,
};
} else if (colorScheme === 'light') {
return {
isDarkMode: false,
extraSchemeName: null,
};
} else if (colorScheme === 'auto') {
return {
isDarkMode: window.matchMedia
? window.matchMedia('(prefers-color-scheme: dark)').matches
: true,
extraSchemeName: null,
};
} else {
// 可能是插件 for example: dark+miku
let [base, name] = colorScheme.split('+');
if (!isValidStr(name)) {
name = base;
base = 'dark';
}
return {
isDarkMode: base === 'dark',
extraSchemeName: `theme-${name}`,
};
}
}

View File

@@ -0,0 +1,34 @@
import type { GlobalConfig } from '../model/config';
/**
* 昵称合法性匹配
* 最大八个汉字内容或者16字英文
* 且中间不能有空格
*/
export const NAME_REGEXP =
/^([0-9a-zA-Z]{1,2}|[\u4e00-\u9eff]|[\u3040-\u309Fー]|[\u30A0-\u30FF]){1,8}$/;
/**
* 系统语言的常量
*/
export const LANGUAGE_KEY = 'i18n:language';
/**
* 系统用户id
*/
export const SYSTEM_USERID = '000000000000000000000000';
export const defaultGlobalConfig: GlobalConfig = {
tianji: {},
uploadFileLimit: 1 * 1024 * 1024,
emailVerification: false,
serverName: 'Tailchat',
disableMsgpack: false,
disableUserRegister: false,
disableGuestLogin: false,
disableCreateGroup: false,
disablePluginStore: false,
disableAddFriend: false,
disableTelemetry: false,
announcement: false,
};

View File

@@ -0,0 +1,104 @@
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime'; // 导入插件
import duration from 'dayjs/plugin/duration'; // 导入插件
import 'dayjs/locale/zh-cn'; // 导入本地化语言
import { onLanguageChanged } from '../i18n';
/**
* Reference: https://day.js.org/
*/
dayjs.extend(relativeTime);
dayjs.extend(duration);
dayjs.locale('zh-cn'); // 默认使用中文
onLanguageChanged((lang) => {
if (lang === 'en-US') {
dayjs.locale('en');
return;
}
dayjs.locale('zh-cn');
});
/**
* 是否为当天
*/
export function isToday(date: dayjs.ConfigType): boolean {
return dayjs(date).isSame(dayjs(), 'd');
}
/**
* 获取消息显示时间
*/
export function getMessageTimeDiff(input: Date): string {
const date = dayjs(input);
if (isToday(date)) {
return date.fromNow();
} else {
return date.format('YYYY-MM-DD HH:mm:ss');
}
}
/**
* 小时消息时间
* 如果是当天则显示短时间,如果不是当天则显示完整时间
*/
export function showMessageTime(input: Date): string {
const date = dayjs(input);
if (isToday(date)) {
return formatShortTime(date);
} else {
return formatFullTime(date);
}
}
/**
* 是否应该显示消息时间
* 间隔时间大于十五分钟则显示
*/
export function shouldShowMessageTime(date1: Date, date2: Date): boolean {
return Math.abs(date1.valueOf() - date2.valueOf()) > 15 * 60 * 1000;
}
/**
* 格式化为 小时:分钟
*/
export function formatShortTime(date: dayjs.ConfigType): string {
return dayjs(date).format('HH:mm');
}
/**
* 格式化为完整时间 YYYY-MM-DD HH:mm:ss
*/
export function formatFullTime(date: dayjs.ConfigType): string {
return dayjs(date).format('YYYY-MM-DD HH:mm:ss');
}
/**
* 返回当前实例到现在的相对时间。
* @example
* dayjs('1999-01-01').toNow() // 22 年后
*/
export function datetimeToNow(input: dayjs.ConfigType): string {
const date = dayjs(input);
return date.toNow();
}
/**
* 返回当前实例到现在的相对时间。
* @example
* dayjs('1999-01-01').toNow() // 22 年前
*/
export function datetimeFromNow(input: dayjs.ConfigType): string {
const date = dayjs(input);
return date.fromNow();
}
/**
* 将毫秒转换为易读的人类语言
*/
export function humanizeMsDuration(ms: number): string {
return dayjs.duration(ms, 'ms').humanize();
}

View File

@@ -0,0 +1,9 @@
export const isBrowser = typeof window !== 'undefined';
export const isNavigator = typeof navigator !== 'undefined';
export const isDevelopment = process.env.NODE_ENV === 'development';
export const isProduction = process.env.NODE_ENV === 'production';
export const version = process.env.VERSION || '0.0.0';

View File

@@ -0,0 +1,7 @@
export function isPromise(obj: any): obj is Promise<unknown> {
return (
!!obj &&
(typeof obj === 'object' || typeof obj === 'function') &&
typeof obj.then === 'function'
);
}

View File

@@ -0,0 +1,16 @@
/**
* 判断是否是一个合法的json字符串
* @param jsonStr json字符串
*/
export function isValidJson(jsonStr: string): boolean {
if (typeof jsonStr !== 'string') {
return false;
}
try {
JSON.parse(jsonStr);
return true;
} catch (e) {
return false;
}
}

View File

@@ -0,0 +1,47 @@
import type {
ChatMessage,
SendMessagePayload,
SimpleMessagePayload,
} from '../model/message';
import _isNil from 'lodash/isNil';
import _set from 'lodash/set';
import _get from 'lodash/get';
import _pick from 'lodash/pick';
const replyMsgFields = ['_id', 'content', 'author'] as const;
export type ReplyMsgType = Pick<ChatMessage, typeof replyMsgFields[number]>;
export class MessageHelper {
private payload: SendMessagePayload;
constructor(origin: SimpleMessagePayload) {
this.payload = { ...origin };
}
/**
* 判断消息体内是否有回复信息
*/
hasReply(): ReplyMsgType | false {
const reply = _get(this.payload, ['meta', 'reply']);
if (_isNil(reply)) {
return false;
}
return reply;
}
setReplyMsg(replyMsg: ReplyMsgType) {
if (_isNil(replyMsg)) {
return;
}
_set(this.payload, ['meta', 'reply'], _pick(replyMsg, replyMsgFields));
}
/**
* 生成待发送的消息体
*/
generatePayload(): SendMessagePayload {
return { ...this.payload };
}
}

View File

@@ -0,0 +1,16 @@
import { GroupPanel, GroupPanelType } from '../model/group';
/**
* 判断面板是否为会话面板
*
* 会话面板的属性是带有已读未读属性的(如默认的文本面板)
*/
export function isConversePanel(panel: GroupPanel) {
// 目前只有文本面板
if (panel.type === GroupPanelType.TEXT) {
return true;
}
return false;
}

View File

@@ -0,0 +1,89 @@
import _chunk from 'lodash/chunk';
import _flatten from 'lodash/flatten';
interface QueueItem<T, R> {
params: T;
resolve: (r: R) => void;
reject: (reason: unknown) => void;
}
/**
* 创建一个自动合并请求的函数
* 在一定窗口期内的所有请求都会被合并提交合并发送
* @param fn 合并后的请求函数
* @param windowMs 窗口期
*/
export function createAutoMergedRequest<T, R>(
fn: (mergedParams: T[]) => Promise<R[]>,
windowMs = 200
): (params: T) => Promise<R> {
let queue: QueueItem<T, R>[] = [];
let timer: number | null = null;
async function submitQueue() {
timer = null; // 清空计时器以接受后续请求
const _queue = [...queue];
queue = []; // 清空队列
try {
const list = await fn(_queue.map((q) => q.params));
_queue.forEach((q1, i) => {
q1.resolve(list[i]);
});
} catch (err) {
_queue.forEach((q2) => {
q2.reject(err);
});
}
}
return (params: T): Promise<R> => {
if (!timer) {
// 如果没有开始窗口期,则创建
timer = window.setTimeout(() => {
submitQueue();
}, windowMs);
}
return new Promise<R>((resolve, reject) => {
queue.push({
params,
resolve,
reject,
});
});
};
}
/**
* 创建一个自动拆分请求参数的函数
*/
export function createAutoSplitRequest<Key, Item>(
fn: (keys: Key[]) => Promise<Item[]>,
type: 'serial' | 'parallel',
limit = 100
): (arr: Key[]) => Promise<Item[]> {
return async (arr: Key[]): Promise<Item[]> => {
const groups = _chunk(arr, limit);
if (type === 'serial') {
const list: Item[] = [];
for (const group of groups) {
const res = await fn(group);
if (Array.isArray(res)) {
list.push(...res);
} else {
console.warn('[createAutoSplitRequest] fn should be return array');
}
}
return list;
} else if (type === 'parallel') {
const res = await Promise.all(groups.map((group) => fn(group)));
return _flatten(res);
}
return [];
};
}

View File

@@ -0,0 +1,176 @@
import { GroupPanelType } from 'tailchat-types';
import { model, t } from '..';
/**
* 所有人权限
* 群组最低权限标识
*/
export const ALL_PERMISSION = Symbol('AllPermission');
export interface PermissionItemType {
/**
* 权限唯一key, 用于写入数据库
* 如果为插件则权限点应当符合命名规范, 如: plugin.com.msgbyte.github.manage
*/
key: string;
/**
* 权限点显示名称
*/
title: string;
/**
* 权限描述
*/
desc: string;
/**
* 是否默认开启
*/
default: boolean;
/**
* 是否依赖其他权限点
*/
required?: string[];
/**
* 面板权限
* 如果是内置类型(数字) 则仅会在规定的类型中展示
* 如果是字符串数组则仅会在特定的插件面板中显示
* 如果不传则视为不适用于面板
*
* @default undefined
*/
panel?: boolean | (string | GroupPanelType)[];
}
export const PERMISSION = {
/**
* 非插件的权限点都叫core
*/
core: {
viewPanel: 'core.viewPanel',
message: 'core.message',
invite: 'core.invite',
unlimitedInvite: 'core.unlimitedInvite',
editInvite: 'core.editInvite',
groupDetail: 'core.groupDetail',
groupBaseInfo: 'core.groupBaseInfo',
groupConfig: 'core.groupConfig',
manageUser: 'core.manageUser',
managePanel: 'core.managePanel',
manageInvite: 'core.manageInvite',
manageRoles: 'core.manageRoles',
deleteMessage: 'core.deleteMessage',
},
};
export const getPermissionList = (): PermissionItemType[] => [
{
key: PERMISSION.core.viewPanel,
title: t('查看面板'),
desc: t('允许成员查看面板'),
default: true,
panel: true,
},
{
key: PERMISSION.core.message,
title: t('发送消息'),
desc: t('允许成员在文字频道发送消息'),
default: true,
panel: [GroupPanelType.TEXT],
},
{
key: PERMISSION.core.invite,
title: t('邀请链接'),
desc: t('允许成员创建邀请链接'),
default: false,
},
{
key: PERMISSION.core.unlimitedInvite,
title: t('不限时邀请链接'),
desc: t('允许成员创建不限时邀请链接'),
default: false,
required: [PERMISSION.core.invite],
},
{
key: PERMISSION.core.editInvite,
title: t('编辑邀请链接'),
desc: t('允许成员编辑邀请链接'),
default: false,
required: [PERMISSION.core.unlimitedInvite],
},
{
key: PERMISSION.core.groupDetail,
title: t('查看群组详情'),
desc: t('允许成员查看群组详情'),
default: false,
},
{
key: PERMISSION.core.groupBaseInfo,
title: t('修改群组基本信息'),
desc: t('允许成员修改群组基本信息'),
default: false,
required: [PERMISSION.core.groupDetail],
},
{
key: PERMISSION.core.groupConfig,
title: t('修改群组配置'),
desc: t('允许成员修改群组配置'),
default: false,
required: [PERMISSION.core.groupDetail],
},
{
key: PERMISSION.core.manageUser,
title: t('允许管理用户'),
desc: t('允许成员管理用户,如禁言、移除用户等操作'),
default: false,
required: [PERMISSION.core.groupDetail],
},
{
key: PERMISSION.core.managePanel,
title: t('允许管理频道'),
desc: t('允许成员查看管理频道'),
default: false,
required: [PERMISSION.core.groupDetail],
},
{
key: PERMISSION.core.manageInvite,
title: t('允许管理邀请链接'),
desc: t('允许成员管理邀请链接'),
default: false,
required: [PERMISSION.core.groupDetail],
},
{
key: PERMISSION.core.manageRoles,
title: t('允许管理身份组'),
desc: t('允许成员管理身份组'),
default: false,
required: [PERMISSION.core.groupDetail],
},
{
key: PERMISSION.core.deleteMessage,
title: t('删除消息'),
desc: t('允许删除用户信息'),
default: false,
required: [PERMISSION.core.groupDetail],
},
];
/**
* 获取默认权限列表
*
* @default ['core.message']
*/
export function getDefaultPermissionList(): string[] {
return getPermissionList()
.filter((p) => p.default === true)
.map((p) => p.key);
}
/**
* 初始化默认所有人身份组权限
*/
export async function applyDefaultFallbackGroupPermission(groupId: string) {
await model.group.modifyGroupField(
groupId,
'fallbackPermissions',
getDefaultPermissionList()
);
}

View File

@@ -0,0 +1,72 @@
import _isString from 'lodash/isString';
import urlRegex from 'url-regex';
/**
* 判断一个字符串是否可用()
* @param str 要判断的字符串
*/
export function isAvailableString(str: unknown): boolean {
return typeof str === 'string' && str.length > 0;
}
/**
* 判断一个字符串是否是url
* @param str 要判断的字符串
*/
export function isUrl(str: string) {
return urlRegex({ exact: true }).test(str);
}
/**
* 判断字符串是否是一个blobUrl
* @param str url字符串
*/
export const isBlobUrl = (str: string) => {
return _isString(str) && str.startsWith('blob:');
};
/**
* 获取一段字符串中的所有url
* @param str 字符串
*/
export const getUrls = (str: string): string[] => {
return str.match(urlRegex()) ?? [];
};
/**
* 用于判定环境变量的值
*/
export function is(it: string) {
return !!it && it !== '0' && it !== 'false';
}
/**
* 是否一个可用的字符串
* 定义为有长度的字符串
*/
export function isValidStr(str: unknown): str is string {
return typeof str == 'string' && str !== '';
}
export function isLocalMessageId(str: unknown): boolean {
if (typeof str !== 'string') {
return false;
}
return str.startsWith('localMessage_');
}
/**
* 是一个MongoDB的objectId
*/
export function isObjectId(str: any): boolean {
if (typeof str === 'string' && str.length === 12) {
return true;
}
if (typeof str === 'string' && /^[0-9A-Fa-f]{24}$/.test(str)) {
return true;
}
return false;
}

View File

@@ -0,0 +1,62 @@
import { showToasts, t } from '..';
import { request } from '../api/request';
import _get from 'lodash/get';
import { getGlobalConfig } from '../model/config';
import { showErrorToasts } from '../manager/ui';
import filesize from 'filesize';
export type UploadFileUsage = 'chat' | 'group' | 'user' | 'unknown';
interface UploadFileOptions {
usage?: UploadFileUsage;
onProgress?: (percent: number, progressEvent: unknown) => void;
}
export interface UploadFileResult {
etag: string;
path: string;
url: string;
}
/**
* 上传文件
*/
export async function uploadFile(
file: File,
options: UploadFileOptions = {}
): Promise<UploadFileResult> {
const form = new FormData();
form.append('file', file);
form.append('usage', options.usage ?? 'unknown');
const uploadFileLimit = getGlobalConfig().uploadFileLimit;
if (file.size > uploadFileLimit) {
// 文件过大
showErrorToasts(
`${t('上传失败, 支持的文件最大大小为:')} ${filesize(uploadFileLimit, {
base: 2,
})}`
);
throw new Error('File Too Large');
}
try {
const { data } = await request.post('/upload', form, {
onUploadProgress(progressEvent) {
if (progressEvent.lengthComputable) {
if (typeof options.onProgress === 'function') {
options.onProgress(
progressEvent.loaded / progressEvent.total,
progressEvent
);
}
}
},
});
return data;
} catch (e) {
showToasts(`${t('上传失败')}: ${t('可能是文件体积过大')}`, 'error');
console.error(`${t('上传失败')}: ${_get(e, 'message')}`);
throw e;
}
}

View File

@@ -0,0 +1,12 @@
import { getServiceUrl } from '../manager/service';
/**
* 解析url, 将中间的常量替换成变量
* @param originUrl 原始Url
* @returns 解析后的url
*/
export function parseUrlStr(originUrl: string): string {
return String(originUrl)
.replace('{BACKEND}', getServiceUrl())
.replace('%7BBACKEND%7D', getServiceUrl());
}

View File

@@ -0,0 +1,10 @@
/**
* JavaScript 中的 sleep 函数
* 参考 https://github.com/sqren/await-sleep/blob/master/index.js
* @param milliseconds 阻塞毫秒
*/
export function sleep(milliseconds: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, milliseconds);
});
}