diff --git a/client/shared/i18n/langs/en-US/translation.json b/client/shared/i18n/langs/en-US/translation.json index 304e6298..06c086bd 100644 --- a/client/shared/i18n/langs/en-US/translation.json +++ b/client/shared/i18n/langs/en-US/translation.json @@ -32,6 +32,7 @@ "k1ea9f5ff": "Verified", "k206eff71": "Nickname can not be blank", "k21ee7a1f": "Roles", + "k226a52c3": "Invite settings modified successfully", "k22856100": "Not found", "k22ffe5e9": "Allow to manage users", "k2317a90c": "Clear Inbox", @@ -41,6 +42,7 @@ "k249e23b9": "E-mail format is incorrect", "k24ccd723": "Refresh now", "k250e392c": "System is busy, please try again later", + "k263bff41": "Edit invite link", "k267cc491": "Me", "k2a1422d2": "Configuration", "k2a8031e": "Homepage", @@ -87,6 +89,7 @@ "k41064134": "DM", "k416e301a": "An exception occurred, Socket creation failed", "k419da0ef": "Message explanation", + "k41a0bacf": "12 hours", "k4231ab36": "Performance statistics", "k42a98418": "File Service", "k4539164b": "The OTP code is 6 digits", @@ -97,8 +100,10 @@ "k48a38bc1": "Group not found", "k49721de0": "Reset Password", "k4a573576": "Unable to modify the display name of the Everyone permission group", + "k4c6cd28": "6 hours", "k4cda3b42": "30 minutes", "k4d32a754": "Group Name", + "k4e456fee": "50 uses", "k4f672109": "Failed to load global configuration", "k4f69cbc9": "Forget Password", "k50504f9e": "Upload picture to converse", @@ -156,6 +161,7 @@ "k6fc3abcd": "Enter now", "k705ca774": "Hide member full name", "k70751578": "Jump to converse", + "k70b73a14": "1 use", "k7173d09e": "Account", "k736edc2f": "Old and new passwords do not match", "k73d9fc70": "Allow members to view admin channels", @@ -171,6 +177,7 @@ "k78102887": "Member Count", "k78e52ed0": "Accept", "k79304fa9": "Nickname changed successfully", + "k795c9a6e": "5 uses", "k7a89720": "Open in new window", "k7c232f9e": "Panel", "k7cf4e7ff": "Message deleted successfully", @@ -180,6 +187,7 @@ "k7f0c746d": "Operation successful", "k7fc7e508": "Dark Mode", "k814bdc7a": "Is not a valid JSON string", + "k8157b129": "25 uses", "k81662255": "Create invitation code", "k821ff85a": "Common", "k8266bcf2": "New password", @@ -191,6 +199,7 @@ "k87a609ad": "Please do not install plugins from unknown sources, it may steal your personal information in Tailchat", "k87dd7754": "Mention (@) your message will appear here", "k887e1f4b": "Copy plain text succeeded", + "k8990744f": "Maximum number of uses", "k89df1d1e": "The network is abnormal", "k8abdba5c": "Has been sent", "k8acbe00": "Current service available", @@ -220,6 +229,8 @@ "k9fc409ec": "Install Succeed", "ka01a00eb": "System language", "ka0451c97": "Cancel", + "ka0be0bd9": "Allow members to edit invite links", + "ka0ccce44": "100 uses", "ka29e9508": "Send Image", "ka2c48894": "Customize your group", "ka2ed2b61": "Are you sure you want to do this?", @@ -238,6 +249,7 @@ "ka697d1d8": "Password modify complete", "ka7907771": "Save Successful", "ka7ecc377": "Unpin", + "ka820940c": "Never", "ka9481f95": "Creator", "kaa040a8e": "Default Group", "kaa7d786e": "Create Converse", @@ -254,6 +266,7 @@ "kaf403ef0": "Light Mode", "kaf51d834": "Reset to default", "kb030fbd1": "Member List", + "kb0584341": "Usage Count", "kb07659b0": "Repeat password", "kb123dbb9": "Your personal unique identifier", "kb12cc88f": "Unmute", @@ -279,6 +292,7 @@ "kbef193d": "Invitation link copied to clipboard", "kbef5b92e": "Copy Link", "kc14b2ea3": "Back", + "kc161f3a6": "1 hour", "kc1afdd08": "Don't worry, you can make changes anytime after this", "kc1bfb977": "Login {{serverName}}", "kc2d30ab7": "Plugin Name", @@ -338,6 +352,7 @@ "ke17b2c87": "Do not upload pictures that violate local laws and regulations", "ke187440d": "Panel type cannot be empty", "ke2431c67": "Plugin render function does not exist", + "ke3a77a77": "Unlimited", "ke3d797fd": "Drop files to send into current converse", "ke59ffe49": "Muted, there are {{remain}} left", "ke6da074f": "The message was withdrawn successfully", @@ -395,5 +410,6 @@ "kfc07c0a4": "Here is the beginning of all messages, please feel free to speak up.", "kfc0ccc0e": "It can be modified at any time in the user settings later", "kfd340bbc": "Manage members", - "kfe731dfc": "Action" + "kfe731dfc": "Action", + "kfe9c1c6c": "10 uses" } diff --git a/client/shared/i18n/langs/zh-CN/translation.json b/client/shared/i18n/langs/zh-CN/translation.json index 4c731b47..7ed478d1 100644 --- a/client/shared/i18n/langs/zh-CN/translation.json +++ b/client/shared/i18n/langs/zh-CN/translation.json @@ -32,6 +32,7 @@ "k1ea9f5ff": "已认证", "k206eff71": "昵称不能为空", "k21ee7a1f": "身份组", + "k226a52c3": "邀请设置修改成功", "k22856100": "未找到", "k22ffe5e9": "允许管理用户", "k2317a90c": "清空收件箱", @@ -41,6 +42,7 @@ "k249e23b9": "邮箱格式不正确", "k24ccd723": "立即刷新", "k250e392c": "系统忙, 请稍后再试", + "k263bff41": "编辑邀请链接", "k267cc491": "我", "k2a1422d2": "配置", "k2a8031e": "个人主页", @@ -87,6 +89,7 @@ "k41064134": "私信", "k416e301a": "出现异常, Socket 创建失败", "k419da0ef": "消息解释", + "k41a0bacf": "12小时", "k4231ab36": "性能统计", "k42a98418": "文件服务", "k4539164b": "校验码为6位", @@ -97,8 +100,10 @@ "k48a38bc1": "群组不存在", "k49721de0": "重设密码", "k4a573576": "无法修改所有人权限组的显示名称", + "k4c6cd28": "6小时", "k4cda3b42": "30分钟", "k4d32a754": "群组名称", + "k4e456fee": "50次使用", "k4f672109": "全局配置加载失败", "k4f69cbc9": "忘记密码", "k50504f9e": "上传图片到会话", @@ -156,6 +161,7 @@ "k6fc3abcd": "立即进入", "k705ca774": "隐藏成员完整名称", "k70751578": "跳转到会话", + "k70b73a14": "1次使用", "k7173d09e": "账户信息", "k736edc2f": "新旧密码不匹配", "k73d9fc70": "允许成员查看管理频道", @@ -171,6 +177,7 @@ "k78102887": "成员数", "k78e52ed0": "接受", "k79304fa9": "修改昵称成功", + "k795c9a6e": "5次使用", "k7a89720": "在新窗口打开", "k7c232f9e": "面板", "k7cf4e7ff": "消息删除成功", @@ -180,6 +187,7 @@ "k7f0c746d": "操作成功", "k7fc7e508": "暗黑模式", "k814bdc7a": "不是一个合法的JSON字符串", + "k8157b129": "25次使用", "k81662255": "创建邀请码", "k821ff85a": "通用", "k8266bcf2": "新密码", @@ -191,6 +199,7 @@ "k87a609ad": "请不要安装不明来源的插件,这可能会盗取你在 Tailchat 的个人信息", "k87dd7754": "提及(@)您的消息会在这里出现哦", "k887e1f4b": "复制纯文本成功", + "k8990744f": "最大使用次数", "k89df1d1e": "网络出现异常", "k8abdba5c": "已发送", "k8acbe00": "当前服务可用", @@ -220,6 +229,8 @@ "k9fc409ec": "安装成功", "ka01a00eb": "系统语言", "ka0451c97": "取消", + "ka0be0bd9": "允许成员编辑邀请链接", + "ka0ccce44": "100次使用", "ka29e9508": "发送图片", "ka2c48894": "自定义你的群组", "ka2ed2b61": "确认要进行该操作么?", @@ -238,6 +249,7 @@ "ka697d1d8": "密码修改成功", "ka7907771": "保存成功", "ka7ecc377": "Unpin", + "ka820940c": "永不", "ka9481f95": "创建者", "kaa040a8e": "默认群组", "kaa7d786e": "创建会话", @@ -254,6 +266,7 @@ "kaf403ef0": "亮色模式", "kaf51d834": "重置为默认值", "kb030fbd1": "成员列表", + "kb0584341": "使用次数", "kb07659b0": "重复密码", "kb123dbb9": "您的个人唯一标识", "kb12cc88f": "取消免打扰", @@ -279,6 +292,7 @@ "kbef193d": "邀请链接已复制到剪切板", "kbef5b92e": "复制链接", "kc14b2ea3": "返回", + "kc161f3a6": "1小时", "kc1afdd08": "不要担心, 在此之后你可以随时进行变更", "kc1bfb977": "登录 {{serverName}}", "kc2d30ab7": "插件名", @@ -338,6 +352,7 @@ "ke17b2c87": "请勿上传违反当地法律法规的图片", "ke187440d": "面板类型不能为空", "ke2431c67": "插件渲染函数不存在", + "ke3a77a77": "无限制", "ke3d797fd": "拖放文件以发送到当前会话", "ke59ffe49": "禁言中, 还剩 {{remain}}", "ke6da074f": "消息撤回成功", @@ -395,5 +410,6 @@ "kfc07c0a4": "这里是所有消息的开始,请畅所欲言。", "kfc0ccc0e": "后续在用户设置中可以随时修改", "kfd340bbc": "管理成员", - "kfe731dfc": "操作" + "kfe731dfc": "操作", + "kfe9c1c6c": "10次使用" } diff --git a/client/shared/model/group.ts b/client/shared/model/group.ts index 65a3f171..faf134a0 100644 --- a/client/shared/model/group.ts +++ b/client/shared/model/group.ts @@ -97,6 +97,8 @@ export interface GroupInvite { groupId: string; creator: string; expiredAt?: string; + usage: number; + usageLimit?: number; } /** @@ -267,6 +269,27 @@ export async function createGroupInviteCode( return data; } +/** + * 编辑群组邀请链接 + * @param groupId 群组ID + * @param code 邀请码 + * @param expiredAt 过期时间,是一个时间戳,单位ms,为undefined则为不限制 + * @param usageLimit 最大使用次数,为undefined则不限制 + */ +export async function editGroupInvite( + groupId: string, + code: string, + expiredAt?: number, + usageLimit?: number +) { + await request.post('/api/group/invite/editGroupInvite', { + groupId, + code, + expiredAt, + usageLimit, + }); +} + /** * 获取群组所有邀请码 * @param groupId 群组ID diff --git a/client/shared/utils/role-helper.ts b/client/shared/utils/role-helper.ts index d6484a69..c3321a4c 100644 --- a/client/shared/utils/role-helper.ts +++ b/client/shared/utils/role-helper.ts @@ -38,6 +38,7 @@ export const PERMISSION = { message: 'core.message', invite: 'core.invite', unlimitedInvite: 'core.unlimitedInvite', + editInvite: 'core.editInvite', groupDetail: 'core.groupDetail', groupConfig: 'core.groupConfig', manageUser: 'core.manageUser', @@ -68,6 +69,13 @@ export const getPermissionList = (): PermissionItemType[] => [ 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('查看群组详情'), diff --git a/client/web/src/components/modals/CreateGroupInvite/CreateInviteCode.tsx b/client/web/src/components/modals/CreateGroupInvite/CreateInviteCode.tsx index a7f3a95c..a716b3c3 100644 --- a/client/web/src/components/modals/CreateGroupInvite/CreateInviteCode.tsx +++ b/client/web/src/components/modals/CreateGroupInvite/CreateInviteCode.tsx @@ -1,6 +1,7 @@ import { InviteCodeExpiredAt } from '@/components/InviteCodeExpiredAt'; +import { closeModal, openModal } from '@/components/Modal'; import { generateInviteCodeUrl } from '@/utils/url-helper'; -import { Menu, Typography, Dropdown, MenuProps } from 'antd'; +import { Menu, Typography, Dropdown, MenuProps, Button } from 'antd'; import React, { useState } from 'react'; import { useAsyncRequest, @@ -9,7 +10,10 @@ import { GroupInvite, PERMISSION, useHasGroupPermission, + useEvent, + showToasts, } from 'tailchat-shared'; +import { EditGroupInvite } from '../EditGroupInvite'; import styles from './CreateInviteCode.module.less'; enum InviteCodeType { @@ -38,6 +42,23 @@ export const CreateInviteCode: React.FC = React.memo( PERMISSION.core.unlimitedInvite, ]); + const handleEditGroupInvite = useEvent(() => { + if (!createdInvite) { + return; + } + + const key = openModal( + { + showToasts(t('邀请设置修改成功'), 'success'); + closeModal(key); + }} + /> + ); + }); + const menu: MenuProps = { items: [ { @@ -62,6 +83,9 @@ export const CreateInviteCode: React.FC = React.memo(

+

) : ( diff --git a/client/web/src/components/modals/EditGroupInvite/index.tsx b/client/web/src/components/modals/EditGroupInvite/index.tsx new file mode 100644 index 00000000..fdf23610 --- /dev/null +++ b/client/web/src/components/modals/EditGroupInvite/index.tsx @@ -0,0 +1,117 @@ +import { MetaFormFieldMeta, WebMetaForm } from 'tailchat-design'; +import React from 'react'; +import { model, t, useEvent } from 'tailchat-shared'; +import { ModalWrapper } from '../../Modal'; + +interface MetaFormValues { + expiredAt: number; + usageLimit: number; +} + +const fields: MetaFormFieldMeta[] = [ + { + type: 'select', + name: 'expiredAt', + label: t('过期时间'), + defaultValue: -1, + options: [ + { + label: t('30分钟'), + value: 30 * 60, + }, + { + label: t('1小时'), + value: 60 * 60, + }, + { + label: t('6小时'), + value: 6 * 60 * 60, + }, + { + label: t('12小时'), + value: 12 * 60 * 60, + }, + { + label: t('1天'), + value: 24 * 60 * 60, + }, + { + label: t('7天'), + value: 7 * 24 * 60 * 60, + }, + { + label: t('永不'), + value: -1, + }, + ], + }, + { + type: 'select', + name: 'usageLimit', + label: t('最大使用次数'), + defaultValue: -1, + options: [ + { + label: t('无限制'), + value: -1, + }, + { + label: t('1次使用'), + value: 1, + }, + { + label: t('5次使用'), + value: 5, + }, + { + label: t('10次使用'), + value: 10, + }, + { + label: t('25次使用'), + value: 25, + }, + { + label: t('50次使用'), + value: 50, + }, + { + label: t('100次使用'), + value: 100, + }, + ], + }, +]; + +/** + * 群组邀请 + */ + +interface EditGroupInviteProps { + groupId: string; + code: string; + onEditSuccess: () => void; +} +export const EditGroupInvite: React.FC = React.memo( + (props) => { + const handleEditGroupInvite = useEvent(async (values: MetaFormValues) => { + await model.group.editGroupInvite( + props.groupId, + props.code, + values.expiredAt === -1 + ? undefined + : Date.now() + values.expiredAt * 1000, + values.usageLimit === -1 ? undefined : values.usageLimit + ); + + props.onEditSuccess(); + }); + + return ( + + + + ); + } +); +EditGroupInvite.displayName = 'EditGroupInvite'; diff --git a/client/web/src/components/modals/GroupDetail/Invite.tsx b/client/web/src/components/modals/GroupDetail/Invite.tsx index 053b3894..32dd753a 100644 --- a/client/web/src/components/modals/GroupDetail/Invite.tsx +++ b/client/web/src/components/modals/GroupDetail/Invite.tsx @@ -8,17 +8,19 @@ import { deleteGroupInvite, useAsyncRefresh, showToasts, + useEvent, } from 'tailchat-shared'; import { Button, Space, Table, Tooltip } from 'antd'; import type { ColumnType } from 'antd/lib/table'; import { UserName } from '@/components/UserName'; -import { openModal, openReconfirmModalP } from '@/components/Modal'; +import { closeModal, openModal, openReconfirmModalP } from '@/components/Modal'; import { CreateGroupInvite } from '../CreateGroupInvite'; import { LoadingOnFirst } from '@/components/LoadingOnFirst'; import { IconBtn } from '@/components/IconBtn'; import copy from 'copy-to-clipboard'; import { generateInviteCodeUrl } from '@/utils/url-helper'; import { SensitiveText } from 'tailchat-design'; +import { EditGroupInvite } from '../EditGroupInvite'; export const GroupInvite: React.FC<{ groupId: string; @@ -30,7 +32,7 @@ export const GroupInvite: React.FC<{ return list.reverse(); // 倒序返回 }, [groupId]); - const handleCreateInvite = useCallback(() => { + const handleCreateInvite = useEvent(() => { openModal( ); - }, [groupId, refresh]); + }); - const handleCopyInviteCode = useCallback((inviteCode: string) => { + const handleEditInviteCode = useEvent((inviteCode: string) => { + const key = openModal( + { + showToasts(t('邀请设置修改成功'), 'success'); + closeModal(key); + refresh(); + }} + /> + ); + }); + + const handleCopyInviteCode = useEvent((inviteCode: string) => { copy(generateInviteCodeUrl(inviteCode)); showToasts(t('邀请链接已复制到剪切板'), 'success'); - }, []); + }); - const handleDeleteInvite = useCallback( - async (inviteId: string) => { - if (await openReconfirmModalP()) { - await deleteGroupInvite(groupId, inviteId); - await refresh(); - } - }, - [groupId, refresh] - ); + const handleDeleteInvite = useEvent(async (inviteId: string) => { + if (await openReconfirmModalP()) { + await deleteGroupInvite(groupId, inviteId); + await refresh(); + } + }); const columns: ColumnType[] = useMemo( () => [ @@ -92,6 +105,18 @@ export const GroupInvite: React.FC<{ ); }, }, + { + title: t('使用次数'), + dataIndex: 'usage', + render: (usage, record) => { + return ( +
+ {usage} + {record.usageLimit && ` / ${record.usageLimit}`} +
+ ); + }, + }, { title: t('创建者'), dataIndex: 'creator', @@ -103,6 +128,12 @@ export const GroupInvite: React.FC<{ render: (id: string, record) => { return ( + handleEditInviteCode(record.code)} + /> ; + /** + * 过期时间,如果不存在则永不过期 + */ @prop() expiredAt?: Date; + /** + * 被使用次数 + */ + @prop({ + default: 0, + }) + usage: number; + + /** + * 使用上限,如果为空则不限制 + */ + @prop() + usageLimit?: number; + /** * 创建群组邀请 * @param groupId 群组id diff --git a/server/packages/sdk/src/services/lib/role.ts b/server/packages/sdk/src/services/lib/role.ts index 8b1eb5c7..c8e62058 100644 --- a/server/packages/sdk/src/services/lib/role.ts +++ b/server/packages/sdk/src/services/lib/role.ts @@ -7,6 +7,7 @@ export const PERMISSION = { message: 'core.message', invite: 'core.invite', unlimitedInvite: 'core.unlimitedInvite', + editInvite: 'core.editInvite', // 编辑邀请码权限,需要有创建无限制邀请码的权限 groupDetail: 'core.groupDetail', groupConfig: 'core.groupConfig', manageUser: 'core.manageUser', diff --git a/server/services/core/group/invite.service.ts b/server/services/core/group/invite.service.ts index fe55e530..a61eb01c 100644 --- a/server/services/core/group/invite.service.ts +++ b/server/services/core/group/invite.service.ts @@ -12,6 +12,7 @@ import { call, NoPermissionError, PERMISSION, + db, } from 'tailchat-server-sdk'; interface GroupService @@ -31,6 +32,14 @@ class GroupService extends TcService { inviteType: { type: 'enum', values: ['normal', 'permanent'] }, }, }); + this.registerAction('editGroupInvite', this.editGroupInvite, { + params: { + code: 'string', + groupId: 'string', + expiredAt: { type: 'number', optional: true }, + usageLimit: { type: 'number', optional: true }, + }, + }); this.registerAction('getAllGroupInviteCode', this.getAllGroupInviteCode, { params: { groupId: 'string', @@ -88,6 +97,49 @@ class GroupService extends TcService { return await this.transformDocuments(ctx, {}, invite); } + /** + * 编辑群组邀请码 + */ + async editGroupInvite( + ctx: TcContext<{ + code: string; + groupId: string; + expiredAt?: number; // 时间戳,单位ms + usageLimit?: number; + }> + ) { + const { code, groupId, expiredAt, usageLimit } = ctx.params; + const { userId, t } = ctx.meta; + + // 检查权限 + const [hasEditPermission] = await call(ctx).checkUserPermissions( + groupId, + userId, + [PERMISSION.core.editInvite] + ); + + if (!hasEditPermission) { + throw new NoPermissionError(t('没有编辑邀请码权限')); + } + + const update = {}; + if (expiredAt) { + _.set(update, ['expiredAt'], new Date(expiredAt)); + } else { + _.set(update, ['$unset', 'expiredAt'], 1); + } + + if (usageLimit) { + _.set(update, ['usageLimit'], usageLimit); + } else { + _.set(update, ['$unset', 'usageLimit'], 1); + } + + await this.adapter.model.updateOne({ groupId, code }, update); + + return true; + } + /** * 获取所有群组邀请码 */ @@ -143,6 +195,13 @@ class GroupService extends TcService { code, }); + if (typeof invite.usageLimit === 'number') { + const usage = invite.usage || 0; + if (usage >= invite.usageLimit) { + throw new Error(t('该邀请码使用次数耗尽')); + } + } + if (new Date(invite.expiredAt).valueOf() < Date.now()) { throw new Error(t('该邀请码已过期')); } @@ -156,8 +215,18 @@ class GroupService extends TcService { groupId: String(groupId), }); - const creatorInfo = await call(ctx).getUserInfo(String(invite.creator)); + await this.adapter.model.updateOne( + { + _id: new db.Types.ObjectId(invite._id), + }, + { + $inc: { + usage: 1, + }, + } + ); + const creatorInfo = await call(ctx).getUserInfo(String(invite.creator)); await call(ctx).addGroupSystemMessage( String(groupId), t('{{nickname}} 通过 {{creator}} 的邀请码加入群组', {