From 47c4a3973d84dda43cdb8f52c83b30fc143d4f77 Mon Sep 17 00:00:00 2001 From: Ap_Tx <3045929398@qq.com> Date: Sun, 18 Oct 2026 07:37:15 +0800 Subject: [PATCH 01/16] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E7=9A=AE?= =?UTF-8?q?=E8=82=A4=E7=AB=99=E7=AE=A1=E7=90=86=E5=8F=B0=E8=B5=84=E6=BA=90?= =?UTF-8?q?=E4=B8=8B=E8=BD=BD=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui/skin-site/SkinSiteAdminView.vue | 2 + .../ui/skin-site/admin/AdminDownloadsPage.vue | 576 ++++++++++++++++++ .../app-frontend/src/helpers/skin-site-api.ts | 101 +++ .../app-frontend/src/locales/en-US/index.json | 34 +- .../app-frontend/src/locales/zh-CN/index.json | 34 +- scripts/axolotl/i18n-check.mjs | 1 + 6 files changed, 746 insertions(+), 2 deletions(-) create mode 100644 apps/app-frontend/src/components/ui/skin-site/admin/AdminDownloadsPage.vue diff --git a/apps/app-frontend/src/components/ui/skin-site/SkinSiteAdminView.vue b/apps/app-frontend/src/components/ui/skin-site/SkinSiteAdminView.vue index bff6268..498c808 100644 --- a/apps/app-frontend/src/components/ui/skin-site/SkinSiteAdminView.vue +++ b/apps/app-frontend/src/components/ui/skin-site/SkinSiteAdminView.vue @@ -5,6 +5,7 @@ import { computed, ref } from 'vue' import { skinSiteUser } from '@/composables/skin-site-session' import AdminBroadcastPage from './admin/AdminBroadcastPage.vue' +import AdminDownloadsPage from './admin/AdminDownloadsPage.vue' import AdminLauncherPage from './admin/AdminLauncherPage.vue' import AdminPlayersPage from './admin/AdminPlayersPage.vue' import AdminReviewPage from './admin/AdminReviewPage.vue' @@ -164,6 +165,7 @@ const current = ref('summary') +

{{ current }}

diff --git a/apps/app-frontend/src/components/ui/skin-site/admin/AdminDownloadsPage.vue b/apps/app-frontend/src/components/ui/skin-site/admin/AdminDownloadsPage.vue new file mode 100644 index 0000000..1511e4a --- /dev/null +++ b/apps/app-frontend/src/components/ui/skin-site/admin/AdminDownloadsPage.vue @@ -0,0 +1,576 @@ + + diff --git a/apps/app-frontend/src/helpers/skin-site-api.ts b/apps/app-frontend/src/helpers/skin-site-api.ts index 4bc126b..313fa7c 100644 --- a/apps/app-frontend/src/helpers/skin-site-api.ts +++ b/apps/app-frontend/src/helpers/skin-site-api.ts @@ -564,32 +564,32 @@ export function skinSitePublicMapServers(): Promise +> { + return skinSiteRequest('review/admin/papers') +} + +export function skinSiteReviewPaperAdmin( + paperId: string, +): Promise> { + return skinSiteRequest( + 'review/admin/papers/' + encodeURIComponent(paperId), + ) +} + +export function skinSiteCreateReviewPaperAdmin( + data: SkinSiteReviewConfigData, +): Promise> { + return skinSiteRequest('review/admin/papers', { + method: 'POST', + body: data, + }) +} + +export function skinSiteUpdateReviewPaperAdmin( + paperId: string, + data: SkinSiteReviewConfigData, +): Promise> { + return skinSiteRequest( + 'review/admin/papers/' + encodeURIComponent(paperId), + { method: 'PUT', body: data }, + ) +} diff --git a/apps/app-frontend/src/locales/en-US/index.json b/apps/app-frontend/src/locales/en-US/index.json index 9e29657..ddfee71 100644 --- a/apps/app-frontend/src/locales/en-US/index.json +++ b/apps/app-frontend/src/locales/en-US/index.json @@ -12141,5 +12141,42 @@ "app.skin-site.admin.wiki.max-page-bytes": {"message": "Max page bytes"}, "app.skin-site.admin.wiki.max-attachment-bytes": {"message": "Max attachment bytes"}, "app.skin-site.admin.wiki.save-settings": {"message": "Save settings"}, - "app.skin-site.admin.wiki.settings-saved": {"message": "Settings saved."} + "app.skin-site.admin.wiki.settings-saved": {"message": "Settings saved."}, + "app.skin-site.admin.review.tab-list": {"message": "Submissions"}, + "app.skin-site.admin.review.tab-papers": {"message": "Papers"}, + "app.skin-site.admin.review.papers-empty": {"message": "No review papers yet."}, + "app.skin-site.admin.review.new-paper": {"message": "New paper"}, + "app.skin-site.admin.review.edit-paper": {"message": "Edit"}, + "app.skin-site.admin.review.paper-title": {"message": "Title"}, + "app.skin-site.admin.review.paper-annotation": {"message": "Description"}, + "app.skin-site.admin.review.paper-enabled": {"message": "Enabled"}, + "app.skin-site.admin.review.paper-mode": {"message": "Mode"}, + "app.skin-site.admin.review.paper-pass-score": {"message": "Pass score"}, + "app.skin-site.admin.review.paper-target-permissions": {"message": "Target permissions"}, + "app.skin-site.admin.review.paper-questions": {"message": "Questions"}, + "app.skin-site.admin.review.add-question": {"message": "Add question"}, + "app.skin-site.admin.review.question-title": {"message": "Question title"}, + "app.skin-site.admin.review.question-type": {"message": "Type"}, + "app.skin-site.admin.review.question-score": {"message": "Score"}, + "app.skin-site.admin.review.question-annotation": {"message": "Annotation"}, + "app.skin-site.admin.review.add-option": {"message": "Add option"}, + "app.skin-site.admin.review.option-description": {"message": "Option text"}, + "app.skin-site.admin.review.option-correct": {"message": "Correct"}, + "app.skin-site.admin.review.remove-question": {"message": "Remove"}, + "app.skin-site.admin.review.question-no": {"message": "Question {n}"}, + "app.skin-site.admin.review.paper-created": {"message": "Paper created."}, + "app.skin-site.admin.review.paper-saved": {"message": "Paper saved."}, + "app.skin-site.admin.review.mail-title": {"message": "Pass mail"}, + "app.skin-site.admin.review.mail-enabled": {"message": "Send mail on pass"}, + "app.skin-site.admin.review.mail-subject": {"message": "Subject"}, + "app.skin-site.admin.review.mail-message": {"message": "Body"}, + "app.skin-site.admin.review.mail-extra": {"message": "Extra text"}, + "app.skin-site.admin.review.mail-include-invite": {"message": "Include invite code"}, + "app.skin-site.admin.review.prevent-duplicate": {"message": "Reject duplicate submissions"}, + "app.skin-site.admin.review.copy-link": {"message": "Copy link"}, + "app.skin-site.admin.review.link-copied": {"message": "Link copied."}, + "app.skin-site.admin.review.move-up": {"message": "Move up"}, + "app.skin-site.admin.review.move-down": {"message": "Move down"}, + "app.skin-site.admin.review.mail-subject-required": {"message": "Subject is required when mail is enabled."}, + "app.skin-site.admin.review.mail-message-required": {"message": "Body is required when mail is enabled."} } diff --git a/apps/app-frontend/src/locales/zh-CN/index.json b/apps/app-frontend/src/locales/zh-CN/index.json index 2dac68e..3cc6a7b 100644 --- a/apps/app-frontend/src/locales/zh-CN/index.json +++ b/apps/app-frontend/src/locales/zh-CN/index.json @@ -11972,5 +11972,42 @@ "app.skin-site.admin.wiki.max-page-bytes": {"message": "页面最大字节数"}, "app.skin-site.admin.wiki.max-attachment-bytes": {"message": "附件最大字节数"}, "app.skin-site.admin.wiki.save-settings": {"message": "保存设置"}, - "app.skin-site.admin.wiki.settings-saved": {"message": "设置已保存。"} + "app.skin-site.admin.wiki.settings-saved": {"message": "设置已保存。"}, + "app.skin-site.admin.review.tab-list": {"message": "申请列表"}, + "app.skin-site.admin.review.tab-papers": {"message": "试卷管理"}, + "app.skin-site.admin.review.papers-empty": {"message": "还没有审核试卷。"}, + "app.skin-site.admin.review.new-paper": {"message": "新建试卷"}, + "app.skin-site.admin.review.edit-paper": {"message": "编辑"}, + "app.skin-site.admin.review.paper-title": {"message": "试卷标题"}, + "app.skin-site.admin.review.paper-annotation": {"message": "描述"}, + "app.skin-site.admin.review.paper-enabled": {"message": "启用"}, + "app.skin-site.admin.review.paper-mode": {"message": "阅卷模式"}, + "app.skin-site.admin.review.paper-pass-score": {"message": "通过分数"}, + "app.skin-site.admin.review.paper-target-permissions": {"message": "通过后获得的身份"}, + "app.skin-site.admin.review.paper-questions": {"message": "题目"}, + "app.skin-site.admin.review.add-question": {"message": "添加题目"}, + "app.skin-site.admin.review.question-title": {"message": "题目"}, + "app.skin-site.admin.review.question-type": {"message": "题型"}, + "app.skin-site.admin.review.question-score": {"message": "分数"}, + "app.skin-site.admin.review.question-annotation": {"message": "题目注解"}, + "app.skin-site.admin.review.add-option": {"message": "添加选项"}, + "app.skin-site.admin.review.option-description": {"message": "选项文本"}, + "app.skin-site.admin.review.option-correct": {"message": "正确"}, + "app.skin-site.admin.review.remove-question": {"message": "删除"}, + "app.skin-site.admin.review.question-no": {"message": "第 {n} 题"}, + "app.skin-site.admin.review.paper-created": {"message": "试卷已创建。"}, + "app.skin-site.admin.review.paper-saved": {"message": "试卷已保存。"}, + "app.skin-site.admin.review.mail-title": {"message": "通过邮件"}, + "app.skin-site.admin.review.mail-enabled": {"message": "审核通过后发送邮件"}, + "app.skin-site.admin.review.mail-subject": {"message": "邮件标题"}, + "app.skin-site.admin.review.mail-message": {"message": "邮件正文"}, + "app.skin-site.admin.review.mail-extra": {"message": "邮件补充说明"}, + "app.skin-site.admin.review.mail-include-invite": {"message": "附带邀请码"}, + "app.skin-site.admin.review.prevent-duplicate": {"message": "禁止重复作答"}, + "app.skin-site.admin.review.copy-link": {"message": "复制直链"}, + "app.skin-site.admin.review.link-copied": {"message": "直链已复制。"}, + "app.skin-site.admin.review.move-up": {"message": "上移"}, + "app.skin-site.admin.review.move-down": {"message": "下移"}, + "app.skin-site.admin.review.mail-subject-required": {"message": "开启邮件后必须填写邮件标题。"}, + "app.skin-site.admin.review.mail-message-required": {"message": "开启邮件后必须填写邮件正文。"} } From fbcbb803acfc56a601b928594832fba425b04ce6 Mon Sep 17 00:00:00 2001 From: Ap_Tx <3045929398@qq.com> Date: Sun, 18 Oct 2026 11:18:55 +0800 Subject: [PATCH 10/16] =?UTF-8?q?feat:=20=E8=A1=A5=E5=85=A8=E7=9A=AE?= =?UTF-8?q?=E8=82=A4=E7=AB=99=E7=AE=A1=E7=90=86=E5=8F=B0=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 用户列表:搜索(用户名/邮箱/UUID)、分页、封禁状态、注册时间 - 编辑用户:用户名、邮箱、QQ、密码、权限组、封禁状态 - 绑定玩家:展示 Mojang / StarLight 来源与旧内服适配标记 - Bot 管理:查看状态、修改机器人用户名、使 Token 失效、封禁/解封 Bot 创建 - 频率限制:滑块与数字输入设置丢包频率,支持保存与取消 - 管理审计:按用户或全局查看,游标分页加载更早记录 - 邀请码:按邮箱生成邀请码并复制 - 补充对应 API 与类型定义,中英文各补 39 个文案 --- .../ui/skin-site/admin/AdminUsersPage.vue | 1305 ++++++++++++++--- .../app-frontend/src/helpers/skin-site-api.ts | 113 ++ .../app-frontend/src/locales/en-US/index.json | 41 +- .../app-frontend/src/locales/zh-CN/index.json | 41 +- 4 files changed, 1257 insertions(+), 243 deletions(-) diff --git a/apps/app-frontend/src/components/ui/skin-site/admin/AdminUsersPage.vue b/apps/app-frontend/src/components/ui/skin-site/admin/AdminUsersPage.vue index 3cf833a..bf28e4e 100644 --- a/apps/app-frontend/src/components/ui/skin-site/admin/AdminUsersPage.vue +++ b/apps/app-frontend/src/components/ui/skin-site/admin/AdminUsersPage.vue @@ -4,11 +4,22 @@ import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/u import { computed, onMounted, ref } from 'vue' import { - skinSiteAdminDeleteUser, type SkinSiteAdminPlayer, - skinSiteAdminUpdateUser, type SkinSiteAdminUser, + type SkinSiteBotInfo, + type SkinSiteUserAdminAuditInfo, + type SkinSiteUserFrequencyLimit, + skinSiteAdminBotInfo, + skinSiteAdminDeleteUser, + skinSiteAdminInvalidateBotToken, + skinSiteAdminInviteUser, + skinSiteAdminRenameBot, + skinSiteAdminSetBotCreationBanned, + skinSiteAdminUpdateUser, + skinSiteAdminUpdateUserFrequencyLimit, + skinSiteAdminUserAudit, skinSiteAdminUserCount, + skinSiteAdminUserFrequencyLimit, skinSiteAdminUserList, skinSiteAdminUserPlayers, } from '@/helpers/skin-site-api' @@ -36,10 +47,6 @@ const messages = defineMessages({ players: { id: 'app.skin-site.admin.users.players', defaultMessage: 'Players' }, updated: { id: 'app.skin-site.admin.users.updated', defaultMessage: 'User updated.' }, deleted: { id: 'app.skin-site.admin.users.deleted', defaultMessage: 'User deleted.' }, - confirmDelete: { - id: 'app.skin-site.admin.users.confirm-delete', - defaultMessage: 'Delete user “{name}”? This cannot be undone.', - }, username: { id: 'app.skin-site.admin.users.username', defaultMessage: 'Username' }, email: { id: 'app.skin-site.admin.users.email', defaultMessage: 'Email' }, qq: { id: 'app.skin-site.admin.users.qq', defaultMessage: 'QQ' }, @@ -56,6 +63,108 @@ const messages = defineMessages({ }, noPlayers: { id: 'app.skin-site.admin.users.no-players', defaultMessage: 'No linked players.' }, registered: { id: 'app.skin-site.admin.users.registered', defaultMessage: 'Registered' }, + bot: { id: 'app.skin-site.admin.users.bot', defaultMessage: 'Bot' }, + botTitle: { id: 'app.skin-site.admin.users.bot-title', defaultMessage: 'Bot management' }, + botExists: { id: 'app.skin-site.admin.users.bot-exists', defaultMessage: 'Bot created' }, + botNotExists: { id: 'app.skin-site.admin.users.bot-not-exists', defaultMessage: 'No bot yet' }, + botActive: { id: 'app.skin-site.admin.users.bot-active', defaultMessage: 'Token valid' }, + botInactive: { + id: 'app.skin-site.admin.users.bot-inactive', + defaultMessage: 'Token invalidated', + }, + botCreationBanned: { + id: 'app.skin-site.admin.users.bot-creation-banned', + defaultMessage: 'Token creation banned', + }, + botUsername: { + id: 'app.skin-site.admin.users.bot-username', + defaultMessage: 'Bot username', + }, + botSaveUsername: { + id: 'app.skin-site.admin.users.bot-save-username', + defaultMessage: 'Save bot username', + }, + botTokenHint: { + id: 'app.skin-site.admin.users.bot-token-hint', + defaultMessage: 'Token hint', + }, + botInvalidate: { + id: 'app.skin-site.admin.users.bot-invalidate', + defaultMessage: 'Invalidate token', + }, + botBan: { id: 'app.skin-site.admin.users.bot-ban', defaultMessage: 'Ban bot creation' }, + botUnban: { id: 'app.skin-site.admin.users.bot-unban', defaultMessage: 'Unban bot creation' }, + frequency: { id: 'app.skin-site.admin.users.frequency', defaultMessage: 'Rate limit' }, + frequencyTitle: { + id: 'app.skin-site.admin.users.frequency-title', + defaultMessage: 'Rate limit', + }, + frequencyHint: { + id: 'app.skin-site.admin.users.frequency-hint', + defaultMessage: 'Packets from this user will be dropped at the given rate.', + }, + frequencyNone: { + id: 'app.skin-site.admin.users.frequency-none', + defaultMessage: 'Not enabled', + }, + frequencyCurrent: { + id: 'app.skin-site.admin.users.frequency-current', + defaultMessage: 'Currently {percent}%', + }, + frequencyRange: { + id: 'app.skin-site.admin.users.frequency-range', + defaultMessage: 'Only integer values from 10% to 100% are allowed.', + }, + frequencySave: { + id: 'app.skin-site.admin.users.frequency-save', + defaultMessage: 'Save limit', + }, + frequencyClear: { + id: 'app.skin-site.admin.users.frequency-clear', + defaultMessage: 'Clear limit', + }, + audit: { id: 'app.skin-site.admin.users.audit', defaultMessage: 'Audit' }, + auditTitle: { + id: 'app.skin-site.admin.users.audit-title', + defaultMessage: 'User management audit', + }, + auditEmpty: { + id: 'app.skin-site.admin.users.audit-empty', + defaultMessage: 'No audit records yet.', + }, + auditMore: { id: 'app.skin-site.admin.users.audit-more', defaultMessage: 'Load earlier' }, + invite: { id: 'app.skin-site.admin.users.invite', defaultMessage: 'Invite user' }, + inviteTitle: { id: 'app.skin-site.admin.users.invite-title', defaultMessage: 'Invite user' }, + inviteEmail: { id: 'app.skin-site.admin.users.invite-email', defaultMessage: 'Email' }, + inviteGenerate: { + id: 'app.skin-site.admin.users.invite-generate', + defaultMessage: 'Generate invite code', + }, + inviteCopy: { id: 'app.skin-site.admin.users.invite-copy', defaultMessage: 'Copy' }, + inviteCopied: { + id: 'app.skin-site.admin.users.invite-copied', + defaultMessage: 'Invite code copied.', + }, + inviteEmailInvalid: { + id: 'app.skin-site.admin.users.invite-email-invalid', + defaultMessage: 'Enter a valid email address.', + }, + boundPlayersTitle: { + id: 'app.skin-site.admin.users.bound-players-title', + defaultMessage: 'Linked players', + }, + legacyAdapted: { + id: 'app.skin-site.admin.users.legacy-adapted', + defaultMessage: 'Legacy internal adapted', + }, + registeredAt: { + id: 'app.skin-site.admin.users.registered-at', + defaultMessage: 'Registered at', + }, + status: { id: 'app.skin-site.admin.users.status', defaultMessage: 'Status' }, + statusNormal: { id: 'app.skin-site.admin.users.status-normal', defaultMessage: 'Normal' }, + statusBanned: { id: 'app.skin-site.admin.users.status-banned', defaultMessage: 'Banned' }, + actions: { id: 'app.skin-site.admin.users.actions', defaultMessage: 'Actions' }, }) const PAGE_SIZE = 20 @@ -83,26 +192,283 @@ const deletingUuid = ref(null) const playersFor = ref(null) const playersLoading = ref(false) -const playersUuid = ref(null) +const playersTarget = ref(null) const page = computed(() => Math.floor(offset.value / PAGE_SIZE) + 1) const pageCount = computed(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE))) const hasPrev = computed(() => offset.value > 0) const hasNext = computed(() => offset.value + PAGE_SIZE < total.value) -const SEARCH_FIELDS: Array<'username' | 'email' | 'uuid'> = ['username', 'email', 'uuid'] +// ---------- bot ---------- +const botTarget = ref(null) +const botInfo = ref(null) +const botLoading = ref(false) +const botBusy = ref(false) +const botUsername = ref('') +const validBotUsername = computed(() => { + const name = botUsername.value.trim() + return name.length >= 2 && name.length <= 32 +}) -function searchFieldLabel(field: string): string { - if (field === 'email') return formatMessage(messages.searchEmail) - if (field === 'uuid') return formatMessage(messages.searchUuid) - return formatMessage(messages.searchName) +function openBot(user: SkinSiteAdminUser) { + botTarget.value = user + botInfo.value = null + botUsername.value = '' + botLoading.value = true + skinSiteAdminBotInfo(user.uuid) + .then((res) => { + if (!res.success) throw new Error(res.errorMessage) + botInfo.value = res.payload + botUsername.value = res.payload.username ?? '' + }) + .catch(handleError) + .finally(() => { + botLoading.value = false + }) } -function formatDate(ts: number): string { +function closeBot() { + botTarget.value = null + botInfo.value = null +} + +async function saveBotUsername() { + if (!botTarget.value || !validBotUsername.value) return + botBusy.value = true try { - return new Date(ts).toLocaleDateString() + const res = await skinSiteAdminRenameBot(botTarget.value.uuid, botUsername.value.trim()) + if (!res.success) throw new Error(res.errorMessage) + botInfo.value = res.payload + botUsername.value = res.payload.username ?? '' + } catch (error) { + handleError(error) + } finally { + botBusy.value = false + } +} + +async function invalidateBot() { + if (!botTarget.value) return + botBusy.value = true + try { + const res = await skinSiteAdminInvalidateBotToken(botTarget.value.uuid) + if (!res.success) throw new Error(res.errorMessage) + botInfo.value = res.payload + } catch (error) { + handleError(error) + } finally { + botBusy.value = false + } +} + +async function toggleBotCreationBan() { + if (!botTarget.value || !botInfo.value) return + botBusy.value = true + try { + const res = await skinSiteAdminSetBotCreationBanned( + botTarget.value.uuid, + !botInfo.value.creationBanned, + ) + if (!res.success) throw new Error(res.errorMessage) + botInfo.value = res.payload + } catch (error) { + handleError(error) + } finally { + botBusy.value = false + } +} + +// ---------- frequency ---------- +const frequencyTarget = ref(null) +const frequencyLoading = ref(false) +const frequencyBusy = ref(false) +const frequencyCurrent = ref(null) +const frequencyPercent = ref(50) + +const validFrequency = computed( + () => + Number.isInteger(frequencyPercent.value) && + frequencyPercent.value >= 10 && + frequencyPercent.value <= 100, +) +const currentFrequencyLimit = computed(() => frequencyCurrent.value?.dropPercent ?? null) + +function openFrequency(user: SkinSiteAdminUser) { + frequencyTarget.value = user + frequencyCurrent.value = null + frequencyPercent.value = 50 + frequencyLoading.value = true + skinSiteAdminUserFrequencyLimit(user.uuid) + .then((res) => { + if (!res.success) throw new Error(res.errorMessage) + frequencyCurrent.value = res.payload + frequencyPercent.value = res.payload.dropPercent ?? 50 + }) + .catch(handleError) + .finally(() => { + frequencyLoading.value = false + }) +} + +function closeFrequency() { + frequencyTarget.value = null + frequencyCurrent.value = null +} + +async function saveFrequency() { + if (!frequencyTarget.value || !validFrequency.value) return + frequencyBusy.value = true + try { + const res = await skinSiteAdminUpdateUserFrequencyLimit( + frequencyTarget.value.uuid, + frequencyPercent.value, + ) + if (!res.success) throw new Error(res.errorMessage) + frequencyCurrent.value = res.payload + } catch (error) { + handleError(error) + } finally { + frequencyBusy.value = false + } +} + +async function clearFrequency() { + if (!frequencyTarget.value) return + frequencyBusy.value = true + try { + const res = await skinSiteAdminUpdateUserFrequencyLimit(frequencyTarget.value.uuid, null) + if (!res.success) throw new Error(res.errorMessage) + frequencyCurrent.value = res.payload + } catch (error) { + handleError(error) + } finally { + frequencyBusy.value = false + } +} + +// ---------- audit ---------- +const auditTarget = ref(null) +const auditItems = ref([]) +const auditLoading = ref(false) +const auditNextCursor = ref(null) + +const AUDIT_ACTION_LABELS: Record = { + USER_UPDATED: 'user-updated', + USER_DELETED: 'user-deleted', + RATE_LIMIT_UPDATED: 'rate-limit-updated', + BOT_USERNAME_UPDATED: 'bot-username-updated', + BOT_TOKEN_INVALIDATED: 'bot-token-invalidated', + BOT_CREATION_BANNED: 'bot-creation-banned', + BOT_CREATION_UNBANNED: 'bot-creation-unbanned', +} + +function auditActionLabel(action: string) { + return formatMessage({ + id: 'app.skin-site.admin.users.audit-action.' + (AUDIT_ACTION_LABELS[action] ?? 'unknown'), + defaultMessage: action, + }) +} + +function auditFieldLabel(field: string) { + return formatMessage({ + id: 'app.skin-site.admin.users.audit-field.' + field, + defaultMessage: field, + }) +} + +function auditValue(value?: string | null) { + if (value === null || value === undefined || value === '') return '—' + return value +} + +async function loadAudit(reset = false) { + auditLoading.value = true + if (reset) { + auditItems.value = [] + auditNextCursor.value = null + } + try { + const res = await skinSiteAdminUserAudit({ + targetUuid: auditTarget.value?.uuid, + before: auditNextCursor.value ?? undefined, + }) + if (!res.success) throw new Error(res.errorMessage) + auditItems.value = reset ? res.payload.items : [...auditItems.value, ...res.payload.items] + auditNextCursor.value = res.payload.nextCursor ?? null + } catch (error) { + handleError(error) + } finally { + auditLoading.value = false + } +} + +function openAudit(user?: SkinSiteAdminUser) { + auditTarget.value = user ?? null + void loadAudit(true) +} + +function closeAudit() { + auditTarget.value = null + auditItems.value = [] + auditNextCursor.value = null +} + +function loadMoreAudit() { + if (auditLoading.value) return + void loadAudit(false) +} + +// ---------- invite ---------- +const inviteOpen = ref(false) +const inviteEmail = ref('') +const inviteCode = ref('') +const inviteBusy = ref(false) +const inviteEmailValid = computed(() => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(inviteEmail.value)) + +function openInvite() { + inviteOpen.value = true + inviteEmail.value = '' + inviteCode.value = '' +} + +function closeInvite() { + inviteOpen.value = false +} + +async function generateInvite() { + if (!inviteEmailValid.value) { + addNotification({ type: 'error', title: formatMessage(messages.inviteEmailInvalid) }) + return + } + inviteBusy.value = true + try { + const res = await skinSiteAdminInviteUser(inviteEmail.value.trim()) + if (!res.success) throw new Error(res.errorMessage) + inviteCode.value = res.payload.code + } catch (error) { + handleError(error) + } finally { + inviteBusy.value = false + } +} + +async function copyInvite() { + if (!inviteCode.value) return + try { + await navigator.clipboard.writeText(inviteCode.value) + addNotification({ type: 'success', title: formatMessage(messages.inviteCopied) }) + } catch (error) { + handleError(error) + } +} + +// ---------- list ---------- +function formatDateTime(ts?: number | null): string { + if (!ts) return '—' + try { + return new Date(ts).toLocaleString() } catch { - return '' + return '—' } } @@ -111,21 +477,19 @@ async function load(reset = false) { loading.value = true failed.value = false try { - const kw = keyword.value.trim() - const listResult = await skinSiteAdminUserList({ + const listRes = await skinSiteAdminUserList({ + keyword: keyword.value.trim() || undefined, + searchField: searchField.value, limit: PAGE_SIZE, offset: offset.value, - searchField: kw ? searchField.value : undefined, - keyword: kw || undefined, }) - if (!listResult.success) throw new Error(listResult.errorMessage) - items.value = listResult.payload ?? [] - const countResult = await skinSiteAdminUserCount( - kw ? { searchField: searchField.value, keyword: kw } : undefined, - ) - if (countResult.success && typeof countResult.payload === 'number') { - total.value = countResult.payload - } + if (!listRes.success) throw new Error(listRes.errorMessage) + items.value = listRes.payload ?? [] + const countRes = await skinSiteAdminUserCount({ + keyword: keyword.value.trim() || undefined, + searchField: searchField.value, + }) + if (countRes.success && typeof countRes.payload === 'number') total.value = countRes.payload } catch (error) { failed.value = true handleError(error) @@ -134,6 +498,10 @@ async function load(reset = false) { } } +function search() { + void load(true) +} + async function prevPage() { if (!hasPrev.value) return offset.value = Math.max(0, offset.value - PAGE_SIZE) @@ -151,46 +519,38 @@ function startEdit(user: SkinSiteAdminUser) { editForm.value = { username: user.username, email: user.email, - qq: user.qq === undefined || user.qq === null ? '' : String(user.qq), + qq: user.qq !== null && user.qq !== undefined ? String(user.qq) : '', password: '', banned: user.banned, - permissions: user.permissions.join(', '), + permissions: (user.permissions ?? []).join(', '), } } function cancelEdit() { editingUuid.value = null - editForm.value = { - username: '', - email: '', - qq: '', - password: '', - banned: false, - permissions: '', - } } async function saveEdit() { if (!editingUuid.value || saving.value) return saving.value = true try { - const qqText = editForm.value.qq.trim() - const permissions = editForm.value.permissions - .split(',') - .map((p) => p.trim()) - .filter(Boolean) - const result = await skinSiteAdminUpdateUser({ - uuid: editingUuid.value, + const target = items.value.find((u) => u.uuid === editingUuid.value) + if (!target) throw new Error('user not found') + const res = await skinSiteAdminUpdateUser({ + uuid: target.uuid, username: editForm.value.username.trim(), email: editForm.value.email.trim(), - qq: qqText ? Number(qqText) : undefined, - password: editForm.value.password ? editForm.value.password : undefined, + password: editForm.value.password.trim() || undefined, + qq: editForm.value.qq.trim() ? Number(editForm.value.qq.trim()) : undefined, banned: editForm.value.banned, - permissions, + permissions: editForm.value.permissions + .split(',') + .map((p) => p.trim()) + .filter(Boolean), }) - if (!result.success) throw new Error(result.errorMessage) + if (!res.success) throw new Error(res.errorMessage) addNotification({ type: 'success', title: formatMessage(messages.updated) }) - cancelEdit() + editingUuid.value = null await load() } catch (error) { handleError(error) @@ -199,13 +559,13 @@ async function saveEdit() { } } -async function remove(user: SkinSiteAdminUser) { - if (deletingUuid.value !== null) return +async function removeUser(user: SkinSiteAdminUser) { + if (deletingUuid.value) return if (!window.confirm(formatMessage(messages.confirmDelete, { name: user.username }))) return deletingUuid.value = user.uuid try { - const result = await skinSiteAdminDeleteUser(user.uuid) - if (!result.success) throw new Error(result.errorMessage) + const res = await skinSiteAdminDeleteUser(user.uuid) + if (!res.success) throw new Error(res.errorMessage) addNotification({ type: 'success', title: formatMessage(messages.deleted) }) await load() } catch (error) { @@ -215,237 +575,194 @@ async function remove(user: SkinSiteAdminUser) { } } -async function togglePlayers(user: SkinSiteAdminUser) { - if (playersUuid.value === user.uuid) { - playersUuid.value = null - playersFor.value = null - return - } - playersUuid.value = user.uuid - playersLoading.value = true +async function openPlayers(user: SkinSiteAdminUser) { + playersTarget.value = user playersFor.value = null + playersLoading.value = true try { - const result = await skinSiteAdminUserPlayers(user.uuid) - if (result.success) { - playersFor.value = result.payload ?? [] - } else { - playersFor.value = [] - } + const res = await skinSiteAdminUserPlayers(user.uuid) + if (!res.success) throw new Error(res.errorMessage) + playersFor.value = res.payload ?? [] } catch (error) { - playersFor.value = [] handleError(error) } finally { playersLoading.value = false } } +function closePlayers() { + playersTarget.value = null + playersFor.value = null +} + onMounted(() => void load()) + + + +
+
+

{{ formatMessage(messages.edit) }}

+ + + + + + +
+ + +
+
+
+ + +
+
+

+ {{ formatMessage(messages.boundPlayersTitle) }} · {{ playersTarget.username }} +

+
+ + {{ formatMessage(messages.loading) }} +
+

+ {{ formatMessage(messages.noPlayers) }} +

+
+
+
+ {{ player.name }} + + {{ player.isMojang ? 'Mojang' : 'StarLight' }} + +
+

{{ player.uuid }}

+

+ {{ formatMessage(messages.legacyAdapted) }} +

+
+
+
+ +
+
+
+ + +
+
+

+ {{ formatMessage(messages.botTitle) }} · {{ botTarget.username }} +

+
+

{{ botTarget.username }}

+

{{ botTarget.uuid }}

+
+
+ + {{ formatMessage(messages.loading) }} +
+ +
+ +
+
+
+ + +
+
+

+ {{ formatMessage(messages.frequencyTitle) }} · {{ frequencyTarget.username }} +

+
+

{{ frequencyTarget.username }}

+

{{ frequencyTarget.uuid }}

+
+

{{ formatMessage(messages.frequencyHint) }}

+
+ + {{ formatMessage(messages.loading) }} +
+ +
+ +
+
+
+ + +
+
+

+ {{ formatMessage(messages.auditTitle) }} + + · {{ auditTarget.username }} + +

+
+ + {{ formatMessage(messages.loading) }} +
+

+ {{ formatMessage(messages.auditEmpty) }} +

+
+
+
+
+

+ {{ auditActionLabel(entry.action) }} +

+

+ {{ entry.actorName }} → {{ entry.targetName }} +

+
+ + {{ formatDateTime(entry.createdAt) }} + +
+
+
+ {{ auditFieldLabel(change.field) }} + + {{ auditValue(change.before) }} + → + {{ auditValue(change.after) }} + +
+
+
+ +
+
+ +
+
+
+ +
+
+

+ {{ formatMessage(messages.inviteTitle) }} +

+ +
+ + +
+
+ + +
+
diff --git a/apps/app-frontend/src/helpers/skin-site-api.ts b/apps/app-frontend/src/helpers/skin-site-api.ts index 313fa7c..eb2e9f5 100644 --- a/apps/app-frontend/src/helpers/skin-site-api.ts +++ b/apps/app-frontend/src/helpers/skin-site-api.ts @@ -1902,3 +1902,116 @@ export function skinSiteUpdateReviewPaperAdmin( { method: 'PUT', body: data }, ) } + +export interface SkinSiteBotInfo { + exists: boolean + username?: string | null + active: boolean + creationBanned: boolean + tokenHint?: string | null + createdAt?: number | null + updatedAt?: number | null +} + +export interface SkinSiteUserFrequencyLimit { + dropPercent?: number | null +} + +export type SkinSiteUserAdminAuditAction = + | 'USER_UPDATED' + | 'USER_DELETED' + | 'RATE_LIMIT_UPDATED' + | 'BOT_USERNAME_UPDATED' + | 'BOT_TOKEN_INVALIDATED' + | 'BOT_CREATION_BANNED' + | 'BOT_CREATION_UNBANNED' + +export interface SkinSiteUserAdminAuditChange { + field: string + before?: string | null + after?: string | null +} + +export interface SkinSiteUserAdminAuditInfo { + id: number + actorUuid: string + actorName: string + targetUuid: string + targetName: string + action: SkinSiteUserAdminAuditAction + changes: SkinSiteUserAdminAuditChange[] + createdAt: number +} + +export interface SkinSiteUserAdminAuditPage { + items: SkinSiteUserAdminAuditInfo[] + nextCursor?: number | null +} + +export interface SkinSiteInviteCode { + email: string + code: string +} + +export function skinSiteAdminBotInfo( + userUuid: string, +): Promise> { + return skinSiteRequest('admin/users/' + encodeURIComponent(userUuid) + '/bot') +} + +export function skinSiteAdminRenameBot( + userUuid: string, + username: string, +): Promise> { + return skinSiteRequest( + 'admin/users/' + encodeURIComponent(userUuid) + '/bot/username', + { method: 'POST', body: { username } }, + ) +} + +export function skinSiteAdminInvalidateBotToken( + userUuid: string, +): Promise> { + return skinSiteRequest( + 'admin/users/' + encodeURIComponent(userUuid) + '/bot/invalidate', + { method: 'POST' }, + ) +} + +export function skinSiteAdminSetBotCreationBanned( + userUuid: string, + banned: boolean, +): Promise> { + return skinSiteRequest( + 'admin/users/' + encodeURIComponent(userUuid) + '/bot/ban', + { method: 'POST', body: { banned } }, + ) +} + +export function skinSiteAdminUpdateUserFrequencyLimit( + userUuid: string, + dropPercent: number | null, +): Promise> { + return skinSiteRequest( + 'admin/users/' + encodeURIComponent(userUuid) + '/frequency-limit', + { method: 'POST', body: { dropPercent } }, + ) +} + +export function skinSiteAdminUserAudit(params?: { + targetUuid?: string + before?: number +}): Promise> { + const query = new URLSearchParams() + if (params?.targetUuid) query.set('targetUuid', params.targetUuid) + if (params?.before) query.set('before', String(params.before)) + const suffix = query.size > 0 ? '?' + query.toString() : '' + return skinSiteRequest('admin/users/audit' + suffix) +} + +export function skinSiteAdminInviteUser(email: string): Promise> { + return skinSiteRequest('admin/inviteCode', { + method: 'POST', + body: { email }, + }) +} diff --git a/apps/app-frontend/src/locales/en-US/index.json b/apps/app-frontend/src/locales/en-US/index.json index ddfee71..4d34b60 100644 --- a/apps/app-frontend/src/locales/en-US/index.json +++ b/apps/app-frontend/src/locales/en-US/index.json @@ -12178,5 +12178,44 @@ "app.skin-site.admin.review.move-up": {"message": "Move up"}, "app.skin-site.admin.review.move-down": {"message": "Move down"}, "app.skin-site.admin.review.mail-subject-required": {"message": "Subject is required when mail is enabled."}, - "app.skin-site.admin.review.mail-message-required": {"message": "Body is required when mail is enabled."} + "app.skin-site.admin.review.mail-message-required": {"message": "Body is required when mail is enabled."}, + "app.skin-site.admin.users.actions": {"message": "Actions"}, + "app.skin-site.admin.users.audit": {"message": "Audit"}, + "app.skin-site.admin.users.audit-empty": {"message": "No audit records yet."}, + "app.skin-site.admin.users.audit-more": {"message": "Load earlier"}, + "app.skin-site.admin.users.audit-title": {"message": "User management audit"}, + "app.skin-site.admin.users.bot": {"message": "Bot"}, + "app.skin-site.admin.users.bot-active": {"message": "Token valid"}, + "app.skin-site.admin.users.bot-ban": {"message": "Ban bot creation"}, + "app.skin-site.admin.users.bot-creation-banned": {"message": "Token creation banned"}, + "app.skin-site.admin.users.bot-exists": {"message": "Bot created"}, + "app.skin-site.admin.users.bot-inactive": {"message": "Token invalidated"}, + "app.skin-site.admin.users.bot-invalidate": {"message": "Invalidate token"}, + "app.skin-site.admin.users.bot-not-exists": {"message": "No bot yet"}, + "app.skin-site.admin.users.bot-save-username": {"message": "Save bot username"}, + "app.skin-site.admin.users.bot-title": {"message": "Bot management"}, + "app.skin-site.admin.users.bot-token-hint": {"message": "Token hint"}, + "app.skin-site.admin.users.bot-unban": {"message": "Unban bot creation"}, + "app.skin-site.admin.users.bot-username": {"message": "Bot username"}, + "app.skin-site.admin.users.bound-players-title": {"message": "Linked players"}, + "app.skin-site.admin.users.frequency": {"message": "Rate limit"}, + "app.skin-site.admin.users.frequency-clear": {"message": "Clear limit"}, + "app.skin-site.admin.users.frequency-current": {"message": "Currently {percent}%"}, + "app.skin-site.admin.users.frequency-hint": {"message": "Packets from this user will be dropped at the given rate."}, + "app.skin-site.admin.users.frequency-none": {"message": "Not enabled"}, + "app.skin-site.admin.users.frequency-range": {"message": "Only integer values from 10% to 100% are allowed."}, + "app.skin-site.admin.users.frequency-save": {"message": "Save limit"}, + "app.skin-site.admin.users.frequency-title": {"message": "Rate limit"}, + "app.skin-site.admin.users.invite": {"message": "Invite user"}, + "app.skin-site.admin.users.invite-copied": {"message": "Invite code copied."}, + "app.skin-site.admin.users.invite-copy": {"message": "Copy"}, + "app.skin-site.admin.users.invite-email": {"message": "Email"}, + "app.skin-site.admin.users.invite-email-invalid": {"message": "Enter a valid email address."}, + "app.skin-site.admin.users.invite-generate": {"message": "Generate invite code"}, + "app.skin-site.admin.users.invite-title": {"message": "Invite user"}, + "app.skin-site.admin.users.legacy-adapted": {"message": "Legacy internal adapted"}, + "app.skin-site.admin.users.registered-at": {"message": "Registered at"}, + "app.skin-site.admin.users.status": {"message": "Status"}, + "app.skin-site.admin.users.status-banned": {"message": "Banned"}, + "app.skin-site.admin.users.status-normal": {"message": "Normal"} } diff --git a/apps/app-frontend/src/locales/zh-CN/index.json b/apps/app-frontend/src/locales/zh-CN/index.json index 3cc6a7b..56b811a 100644 --- a/apps/app-frontend/src/locales/zh-CN/index.json +++ b/apps/app-frontend/src/locales/zh-CN/index.json @@ -12009,5 +12009,44 @@ "app.skin-site.admin.review.move-up": {"message": "上移"}, "app.skin-site.admin.review.move-down": {"message": "下移"}, "app.skin-site.admin.review.mail-subject-required": {"message": "开启邮件后必须填写邮件标题。"}, - "app.skin-site.admin.review.mail-message-required": {"message": "开启邮件后必须填写邮件正文。"} + "app.skin-site.admin.review.mail-message-required": {"message": "开启邮件后必须填写邮件正文。"}, + "app.skin-site.admin.users.actions": {"message": "操作"}, + "app.skin-site.admin.users.audit": {"message": "审计"}, + "app.skin-site.admin.users.audit-empty": {"message": "暂无管理审计记录。"}, + "app.skin-site.admin.users.audit-more": {"message": "加载更早记录"}, + "app.skin-site.admin.users.audit-title": {"message": "用户管理审计"}, + "app.skin-site.admin.users.bot": {"message": "机器人"}, + "app.skin-site.admin.users.bot-active": {"message": "Token 有效"}, + "app.skin-site.admin.users.bot-ban": {"message": "封禁 Bot 创建"}, + "app.skin-site.admin.users.bot-creation-banned": {"message": "禁止创建 Token"}, + "app.skin-site.admin.users.bot-exists": {"message": "已创建 Bot"}, + "app.skin-site.admin.users.bot-inactive": {"message": "Token 已失效"}, + "app.skin-site.admin.users.bot-invalidate": {"message": "使 Token 失效"}, + "app.skin-site.admin.users.bot-not-exists": {"message": "尚未创建 Bot"}, + "app.skin-site.admin.users.bot-save-username": {"message": "保存机器人用户名"}, + "app.skin-site.admin.users.bot-title": {"message": "Bot 管理"}, + "app.skin-site.admin.users.bot-token-hint": {"message": "Token 标识"}, + "app.skin-site.admin.users.bot-unban": {"message": "解除 Bot 封禁"}, + "app.skin-site.admin.users.bot-username": {"message": "机器人用户名"}, + "app.skin-site.admin.users.bound-players-title": {"message": "绑定玩家"}, + "app.skin-site.admin.users.frequency": {"message": "频率限制"}, + "app.skin-site.admin.users.frequency-clear": {"message": "取消限制"}, + "app.skin-site.admin.users.frequency-current": {"message": "当前 {percent}%"}, + "app.skin-site.admin.users.frequency-hint": {"message": "以下列频率对指定用户进行丢包处理,限制其进入服务器的频率。"}, + "app.skin-site.admin.users.frequency-none": {"message": "当前未启用"}, + "app.skin-site.admin.users.frequency-range": {"message": "仅允许设置 10% 至 100% 的整数。"}, + "app.skin-site.admin.users.frequency-save": {"message": "保存限制"}, + "app.skin-site.admin.users.frequency-title": {"message": "频率限制"}, + "app.skin-site.admin.users.invite": {"message": "邀请用户"}, + "app.skin-site.admin.users.invite-copied": {"message": "邀请码已复制。"}, + "app.skin-site.admin.users.invite-copy": {"message": "复制"}, + "app.skin-site.admin.users.invite-email": {"message": "邮箱"}, + "app.skin-site.admin.users.invite-email-invalid": {"message": "请输入正确格式的邮箱。"}, + "app.skin-site.admin.users.invite-generate": {"message": "生成邀请码"}, + "app.skin-site.admin.users.invite-title": {"message": "邀请用户"}, + "app.skin-site.admin.users.legacy-adapted": {"message": "已适配旧内服"}, + "app.skin-site.admin.users.registered-at": {"message": "注册时间"}, + "app.skin-site.admin.users.status": {"message": "状态"}, + "app.skin-site.admin.users.status-banned": {"message": "已封禁"}, + "app.skin-site.admin.users.status-normal": {"message": "正常"} } From 87de3062452fb2a111364889a11349c617c56853 Mon Sep 17 00:00:00 2001 From: Ap_Tx <3045929398@qq.com> Date: Sun, 18 Oct 2026 12:02:39 +0800 Subject: [PATCH 11/16] =?UTF-8?q?feat:=20=E8=A1=A5=E5=85=A8=E7=9A=AE?= =?UTF-8?q?=E8=82=A4=E7=AB=99=E7=AE=A1=E7=90=86=E5=8F=B0=20Mod=20=E9=A1=B5?= =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E4=B8=8E=E6=89=B9=E9=87=8F=E6=93=8D=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 Mod 上传弹窗:拖拽或选择 .jar、多文件、大小限制、规则标签附加 - 上传流程:xxHash64 查重、同名检测、名称冲突确认(覆盖/跳过)、429 限流退避重试、HashDiff 重传 - Mod 列表支持多选模式:全选本页、取消选择、批量删除 - 新增服务器地址过滤,配合名称搜索与标签筛选 - 补充 Mod 相关 API:文件哈希、按哈希/名称查重、上传、批量更新 - 引入 xxhash-wasm 依赖 - 中英文各补 32 个文案 --- apps/app-frontend/package.json | 3 +- .../ui/skin-site/admin/AdminModsPage.vue | 196 +++++++- .../ui/skin-site/admin/ModUploadModal.vue | 440 ++++++++++++++++++ .../app-frontend/src/helpers/skin-site-api.ts | 56 +++ .../app-frontend/src/locales/en-US/index.json | 34 +- .../app-frontend/src/locales/zh-CN/index.json | 34 +- pnpm-lock.yaml | 8 + 7 files changed, 767 insertions(+), 4 deletions(-) create mode 100644 apps/app-frontend/src/components/ui/skin-site/admin/ModUploadModal.vue diff --git a/apps/app-frontend/package.json b/apps/app-frontend/package.json index f10352a..b24ed39 100644 --- a/apps/app-frontend/package.json +++ b/apps/app-frontend/package.json @@ -50,7 +50,8 @@ "vue": "^3.5.13", "vue-i18n": "^10.0.0", "vue-router": "^4.6.0", - "vuedraggable": "^4.1.0" + "vuedraggable": "^4.1.0", + "xxhash-wasm": "1.1.0" }, "devDependencies": { "@eslint/compat": "^1.1.1", diff --git a/apps/app-frontend/src/components/ui/skin-site/admin/AdminModsPage.vue b/apps/app-frontend/src/components/ui/skin-site/admin/AdminModsPage.vue index 5aab3a6..f902f81 100644 --- a/apps/app-frontend/src/components/ui/skin-site/admin/AdminModsPage.vue +++ b/apps/app-frontend/src/components/ui/skin-site/admin/AdminModsPage.vue @@ -9,6 +9,7 @@ import { skinSiteDeleteModFile, skinSiteDeleteModRuleTag, skinSiteDeleteModRuntime, + skinSiteDeleteMultiModFile, skinSiteModCount, type SkinSiteModInfo, skinSiteModList, @@ -20,9 +21,12 @@ import { skinSiteModSearch, skinSiteUpdateModInfo, skinSiteUpdateModRuleTag, + skinSiteUpdateMultiModInfo, skinSiteUploadModRuntime, } from '@/helpers/skin-site-api' +import ModUploadModal from './ModUploadModal.vue' + const { formatMessage } = useVIntl() const { handleError, addNotification } = injectNotificationManager() @@ -92,6 +96,46 @@ const messages = defineMessages({ unavailable: { id: 'app.skin-site.admin.mods.unavailable', defaultMessage: 'Unavailable' }, active: { id: 'app.skin-site.admin.mods.active', defaultMessage: 'Active' }, uploader: { id: 'app.skin-site.admin.mods.uploader', defaultMessage: 'by {name}' }, + upload: { id: 'app.skin-site.admin.mods.upload', defaultMessage: 'Upload' }, + selectMode: { + id: 'app.skin-site.admin.mods.select-mode', + defaultMessage: 'Select multiple', + }, + exitSelect: { + id: 'app.skin-site.admin.mods.exit-select', + defaultMessage: 'Done selecting', + }, + selectAll: { id: 'app.skin-site.admin.mods.select-all', defaultMessage: 'Select all' }, + clearSelection: { + id: 'app.skin-site.admin.mods.clear-selection', + defaultMessage: 'Clear selection', + }, + selectedCount: { + id: 'app.skin-site.admin.mods.selected-count', + defaultMessage: '{count} selected', + }, + deleteSelected: { + id: 'app.skin-site.admin.mods.delete-selected', + defaultMessage: 'Delete selected', + }, + confirmDeleteMulti: { + id: 'app.skin-site.admin.mods.confirm-delete-multi', + defaultMessage: 'Delete {count} selected mods? This cannot be undone.', + }, + deletedMulti: { + id: 'app.skin-site.admin.mods.deleted-multi', + defaultMessage: 'Selected mods deleted.', + }, + serverAddress: { + id: 'app.skin-site.admin.mods.server-address', + defaultMessage: 'Server address', + }, + serverAddressHint: { + id: 'app.skin-site.admin.mods.server-address-hint', + defaultMessage: 'Filter by server address', + }, + apply: { id: 'app.skin-site.admin.mods.apply', defaultMessage: 'Apply' }, + clear: { id: 'app.skin-site.admin.mods.clear', defaultMessage: 'Clear' }, }) type Tab = 'mods' | 'tags' | 'runtime' @@ -112,6 +156,13 @@ const editForm = ref({ name: '', modId: '', version: '', domainRules: '' }) const savingMod = ref(false) const deletingHash = ref(null) +const selectedHashes = ref>(new Set()) +const selectionMode = ref(false) +const deletingMulti = ref(false) +const serverAddressInput = ref('') +const serverAddressFilter = ref('') +const uploadOpen = ref(false) + const tags = ref([]) const tagsLoading = ref(false) const tagEditorOpen = ref(false) @@ -173,8 +224,12 @@ async function loadMods(reset = true) { limit: PAGE_SIZE, offset: modOffset.value, ruleTagId: tagFilter.value, + serverAddr: serverAddressFilter.value || undefined, + }), + skinSiteModCount({ + ruleTagId: tagFilter.value, + serverAddr: serverAddressFilter.value || undefined, }), - skinSiteModCount({ ruleTagId: tagFilter.value }), ]) if (!listResult.success) throw new Error(listResult.errorMessage) mods.value = listResult.payload ?? [] @@ -293,6 +348,56 @@ async function removeMod(mod: SkinSiteModInfo) { } } +function toggleSelect(hash: string) { + const next = new Set(selectedHashes.value) + if (next.has(hash)) next.delete(hash) + else next.add(hash) + selectedHashes.value = next +} + +function selectAllVisible() { + selectedHashes.value = new Set(mods.value.map((m) => m.hash)) +} + +function clearSelection() { + selectedHashes.value = new Set() +} + +function exitSelectionMode() { + selectionMode.value = false + clearSelection() +} + +async function deleteSelected() { + if (deletingMulti.value || !selectedHashes.value.size) return + const count = selectedHashes.value.size + if (!window.confirm(formatMessage(messages.confirmDeleteMulti, { count }))) return + deletingMulti.value = true + try { + const result = await skinSiteDeleteMultiModFile(Array.from(selectedHashes.value)) + if (!result.success) throw new Error(result.errorMessage) + addNotification({ type: 'success', title: formatMessage(messages.deletedMulti) }) + clearSelection() + selectionMode.value = false + await loadMods(false) + } catch (error) { + handleError(error) + } finally { + deletingMulti.value = false + } +} + +function applyServerAddress() { + serverAddressFilter.value = serverAddressInput.value.trim() + void loadMods(true) +} + +function clearServerAddress() { + serverAddressInput.value = '' + serverAddressFilter.value = '' + void loadMods(true) +} + function openNewTag() { editingTagId.value = null tagForm.value = { name: '', rules: '' } @@ -456,9 +561,84 @@ onMounted(async () => { + + + {{ formatMessage(messages.total, { count: modTotal }) }} + + {{ formatMessage(messages.selectedCount, { count: selectedHashes.size }) }} + + + +
+ + +
@@ -476,8 +656,16 @@ onMounted(async () => { v-for="mod in mods" :key="mod.hash" class="flex flex-col gap-2 rounded-xl bg-bg-raised p-3" + :class="selectionMode && selectedHashes.has(mod.hash) ? 'ring-2 ring-brand' : ''" >
+ {{ mod.metadata.name || mod.name }} @@ -784,5 +972,11 @@ onMounted(async () => {
+ + diff --git a/apps/app-frontend/src/components/ui/skin-site/admin/ModUploadModal.vue b/apps/app-frontend/src/components/ui/skin-site/admin/ModUploadModal.vue new file mode 100644 index 0000000..a8485e2 --- /dev/null +++ b/apps/app-frontend/src/components/ui/skin-site/admin/ModUploadModal.vue @@ -0,0 +1,440 @@ + + + diff --git a/apps/app-frontend/src/helpers/skin-site-api.ts b/apps/app-frontend/src/helpers/skin-site-api.ts index eb2e9f5..4f3e1ae 100644 --- a/apps/app-frontend/src/helpers/skin-site-api.ts +++ b/apps/app-frontend/src/helpers/skin-site-api.ts @@ -2015,3 +2015,59 @@ export function skinSiteAdminInviteUser(email: string): Promise { + return import('xxhash-wasm').then(async ({ default: xxhash }) => { + const { create64 } = await xxhash() + const hasher = create64() + const reader = file.stream().getReader() + for (;;) { + const { done, value } = await reader.read() + if (done) break + hasher.update(value) + } + const raw: bigint = hasher.digest() + const mask = (1n << 64n) - 1n + const v = raw & mask + const signed = v >= 1n << 63n ? v - (1n << 64n) : v + return signed.toString() + }) +} + +export function skinSiteModExistsByHash(hash: string): Promise> { + return skinSiteRequest('query/hash/' + encodeURIComponent(hash), { type: 'mod' }) +} + +export function skinSiteModExistsByName(modName: string): Promise> { + const q = new URLSearchParams({ mod_name: modName }) + return skinSiteRequest('query/name?' + q.toString(), { type: 'mod' }) +} + +export async function skinSiteUploadMod( + file: File, + ruleTagIds: number[] = [], + overwrite = false, +): Promise> { + const hash = await skinSiteModHashFromFile(file) + const form = new FormData() + form.append('mods', file, file.name) + const query = new URLSearchParams() + if (ruleTagIds.length) query.set('rule_tag_ids', ruleTagIds.join(',')) + if (overwrite) query.set('overwrite', 'true') + const suffix = query.size ? '?' + query.toString() : '' + return skinSiteRequest('upload/' + hash + suffix, { + method: 'POST', + type: 'mod', + form, + }) +} + +export function skinSiteUpdateMultiModInfo( + mods: SkinSiteModInfo[], +): Promise> { + return skinSiteRequest('update/info/multi', { + method: 'POST', + type: 'mod', + body: mods, + }) +} diff --git a/apps/app-frontend/src/locales/en-US/index.json b/apps/app-frontend/src/locales/en-US/index.json index 4d34b60..cc17884 100644 --- a/apps/app-frontend/src/locales/en-US/index.json +++ b/apps/app-frontend/src/locales/en-US/index.json @@ -12217,5 +12217,37 @@ "app.skin-site.admin.users.registered-at": {"message": "Registered at"}, "app.skin-site.admin.users.status": {"message": "Status"}, "app.skin-site.admin.users.status-banned": {"message": "Banned"}, - "app.skin-site.admin.users.status-normal": {"message": "Normal"} + "app.skin-site.admin.users.status-normal": {"message": "Normal"}, + "app.skin-site.admin.mods.upload": {"message": "Upload"}, + "app.skin-site.admin.mods.select-mode": {"message": "Select multiple"}, + "app.skin-site.admin.mods.exit-select": {"message": "Done selecting"}, + "app.skin-site.admin.mods.select-all": {"message": "Select all"}, + "app.skin-site.admin.mods.clear-selection": {"message": "Clear selection"}, + "app.skin-site.admin.mods.selected-count": {"message": "{count} selected"}, + "app.skin-site.admin.mods.delete-selected": {"message": "Delete selected"}, + "app.skin-site.admin.mods.confirm-delete-multi": {"message": "Delete {count} selected mods? This cannot be undone."}, + "app.skin-site.admin.mods.deleted-multi": {"message": "Selected mods deleted."}, + "app.skin-site.admin.mods.server-address": {"message": "Server address"}, + "app.skin-site.admin.mods.server-address-hint": {"message": "Filter by server address"}, + "app.skin-site.admin.mods.apply": {"message": "Apply"}, + "app.skin-site.admin.mods.clear": {"message": "Clear"}, + "app.skin-site.admin.mods.upload-title": {"message": "Upload mods"}, + "app.skin-site.admin.mods.upload-drop": {"message": "Drag .jar files here, or click to choose"}, + "app.skin-site.admin.mods.upload-sub": {"message": "Multiple files supported, up to {size} MB each"}, + "app.skin-site.admin.mods.upload-tags": {"message": "Attach rule tags"}, + "app.skin-site.admin.mods.upload-queue": {"message": "Files"}, + "app.skin-site.admin.mods.upload-pending": {"message": "Pending"}, + "app.skin-site.admin.mods.upload-checking": {"message": "Checking…"}, + "app.skin-site.admin.mods.upload-uploading": {"message": "Uploading…"}, + "app.skin-site.admin.mods.upload-conflict": {"message": "Name exists"}, + "app.skin-site.admin.mods.upload-success": {"message": "Done"}, + "app.skin-site.admin.mods.upload-error": {"message": "Failed"}, + "app.skin-site.admin.mods.upload-exists": {"message": "This file already exists on the server."}, + "app.skin-site.admin.mods.upload-name-conflict": {"message": "A mod with the same file name already exists"}, + "app.skin-site.admin.mods.upload-name-conflict-hint": {"message": "Overwrite the existing mod, or skip this file."}, + "app.skin-site.admin.mods.upload-overwrite": {"message": "Overwrite"}, + "app.skin-site.admin.mods.upload-skip": {"message": "Skip"}, + "app.skin-site.admin.mods.upload-close": {"message": "Close"}, + "app.skin-site.admin.mods.upload-all-done": {"message": "All files processed."}, + "app.skin-site.admin.mods.upload-too-large": {"message": "File too large: {name}"} } diff --git a/apps/app-frontend/src/locales/zh-CN/index.json b/apps/app-frontend/src/locales/zh-CN/index.json index 56b811a..13b7baa 100644 --- a/apps/app-frontend/src/locales/zh-CN/index.json +++ b/apps/app-frontend/src/locales/zh-CN/index.json @@ -12048,5 +12048,37 @@ "app.skin-site.admin.users.registered-at": {"message": "注册时间"}, "app.skin-site.admin.users.status": {"message": "状态"}, "app.skin-site.admin.users.status-banned": {"message": "已封禁"}, - "app.skin-site.admin.users.status-normal": {"message": "正常"} + "app.skin-site.admin.users.status-normal": {"message": "正常"}, + "app.skin-site.admin.mods.upload": {"message": "上传"}, + "app.skin-site.admin.mods.select-mode": {"message": "选择多个"}, + "app.skin-site.admin.mods.exit-select": {"message": "完成选择"}, + "app.skin-site.admin.mods.select-all": {"message": "全选本页"}, + "app.skin-site.admin.mods.clear-selection": {"message": "取消全选"}, + "app.skin-site.admin.mods.selected-count": {"message": "已选择 {count} 项"}, + "app.skin-site.admin.mods.delete-selected": {"message": "删除所选"}, + "app.skin-site.admin.mods.confirm-delete-multi": {"message": "确定删除选中的 {count} 个 Mod?此操作不可撤销。"}, + "app.skin-site.admin.mods.deleted-multi": {"message": "已删除所选 Mod。"}, + "app.skin-site.admin.mods.server-address": {"message": "服务器地址"}, + "app.skin-site.admin.mods.server-address-hint": {"message": "按服务器地址过滤"}, + "app.skin-site.admin.mods.apply": {"message": "应用"}, + "app.skin-site.admin.mods.clear": {"message": "清除"}, + "app.skin-site.admin.mods.upload-title": {"message": "上传 Mod"}, + "app.skin-site.admin.mods.upload-drop": {"message": "拖拽 .jar 文件到此处,或点击选择"}, + "app.skin-site.admin.mods.upload-sub": {"message": "支持多文件,单个不超过 {size} MB"}, + "app.skin-site.admin.mods.upload-tags": {"message": "附加规则标签"}, + "app.skin-site.admin.mods.upload-queue": {"message": "文件列表"}, + "app.skin-site.admin.mods.upload-pending": {"message": "等待中"}, + "app.skin-site.admin.mods.upload-checking": {"message": "检查中…"}, + "app.skin-site.admin.mods.upload-uploading": {"message": "上传中…"}, + "app.skin-site.admin.mods.upload-conflict": {"message": "名称已存在"}, + "app.skin-site.admin.mods.upload-success": {"message": "完成"}, + "app.skin-site.admin.mods.upload-error": {"message": "失败"}, + "app.skin-site.admin.mods.upload-exists": {"message": "该文件已存在于服务器。"}, + "app.skin-site.admin.mods.upload-name-conflict": {"message": "已存在同名 Mod"}, + "app.skin-site.admin.mods.upload-name-conflict-hint": {"message": "覆盖已有 Mod,或跳过此文件。"}, + "app.skin-site.admin.mods.upload-overwrite": {"message": "覆盖"}, + "app.skin-site.admin.mods.upload-skip": {"message": "跳过"}, + "app.skin-site.admin.mods.upload-close": {"message": "关闭"}, + "app.skin-site.admin.mods.upload-all-done": {"message": "全部文件处理完成。"}, + "app.skin-site.admin.mods.upload-too-large": {"message": "文件过大:{name}"} } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f6c8ba0..ec019e5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -188,6 +188,9 @@ importers: vuedraggable: specifier: ^4.1.0 version: 4.1.0(vue@3.5.27(typescript@5.9.3)) + xxhash-wasm: + specifier: 1.1.0 + version: 1.1.0 devDependencies: '@eslint/compat': specifier: ^1.1.1 @@ -7103,6 +7106,9 @@ packages: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} + xxhash-wasm@1.1.0: + resolution: {integrity: sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -14160,6 +14166,8 @@ snapshots: xtend@4.0.2: {} + xxhash-wasm@1.1.0: {} + y18n@5.0.8: {} yallist@3.1.1: {} From ef1435bd31ba43294b8e410236a83ab6091e2b6c Mon Sep 17 00:00:00 2001 From: Ap_Tx <3045929398@qq.com> Date: Sun, 18 Oct 2026 12:13:34 +0800 Subject: [PATCH 12/16] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E7=9A=AE?= =?UTF-8?q?=E8=82=A4=E7=AB=99=E7=AE=A1=E7=90=86=E5=8F=B0=E8=81=8A=E5=A4=A9?= =?UTF-8?q?=E5=AE=A4=E6=88=BF=E9=97=B4=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 房间列表:头像、名称、可见性、类型、语音标记、前置权限、创建时间 - 创建/编辑房间:名称、简介、公开或私有、普通或聚落、前置条件判定与所需权限 - 房间头像:上传、更换、移除,支持 PNG/JPEG/WebP,5 MB 限制 - 删除房间带二次确认 - 新增 RoomAvatar 与 RoomAvatarEditor 组件,头像按附件 id 缓存 Blob URL - 补充聊天室相关 API:房间列表、创建、更新、删除、头像更新、附件上传、头像获取 - 管理台导航接入聊天室入口 - 中英文各补 37 个文案 --- .../components/ui/skin-site/RoomAvatar.vue | 63 +++ .../ui/skin-site/RoomAvatarEditor.vue | 161 ++++++ .../ui/skin-site/SkinSiteAdminView.vue | 2 + .../ui/skin-site/admin/AdminChatPage.vue | 514 ++++++++++++++++++ .../ui/skin-site/room-avatar-cache.ts | 30 + .../app-frontend/src/helpers/skin-site-api.ts | 122 +++++ .../app-frontend/src/locales/en-US/index.json | 39 +- .../app-frontend/src/locales/zh-CN/index.json | 39 +- 8 files changed, 968 insertions(+), 2 deletions(-) create mode 100644 apps/app-frontend/src/components/ui/skin-site/RoomAvatar.vue create mode 100644 apps/app-frontend/src/components/ui/skin-site/RoomAvatarEditor.vue create mode 100644 apps/app-frontend/src/components/ui/skin-site/admin/AdminChatPage.vue create mode 100644 apps/app-frontend/src/components/ui/skin-site/room-avatar-cache.ts diff --git a/apps/app-frontend/src/components/ui/skin-site/RoomAvatar.vue b/apps/app-frontend/src/components/ui/skin-site/RoomAvatar.vue new file mode 100644 index 0000000..c991d57 --- /dev/null +++ b/apps/app-frontend/src/components/ui/skin-site/RoomAvatar.vue @@ -0,0 +1,63 @@ + + + diff --git a/apps/app-frontend/src/components/ui/skin-site/RoomAvatarEditor.vue b/apps/app-frontend/src/components/ui/skin-site/RoomAvatarEditor.vue new file mode 100644 index 0000000..9652dbe --- /dev/null +++ b/apps/app-frontend/src/components/ui/skin-site/RoomAvatarEditor.vue @@ -0,0 +1,161 @@ + + + diff --git a/apps/app-frontend/src/components/ui/skin-site/SkinSiteAdminView.vue b/apps/app-frontend/src/components/ui/skin-site/SkinSiteAdminView.vue index 382374a..76162e3 100644 --- a/apps/app-frontend/src/components/ui/skin-site/SkinSiteAdminView.vue +++ b/apps/app-frontend/src/components/ui/skin-site/SkinSiteAdminView.vue @@ -6,6 +6,7 @@ import { skinSiteUser } from '@/composables/skin-site-session' import AdminAccessAuditPage from './admin/AdminAccessAuditPage.vue' import AdminBroadcastPage from './admin/AdminBroadcastPage.vue' +import AdminChatPage from './admin/AdminChatPage.vue' import AdminDonationsPage from './admin/AdminDonationsPage.vue' import AdminDownloadsPage from './admin/AdminDownloadsPage.vue' import AdminLauncherPage from './admin/AdminLauncherPage.vue' @@ -177,6 +178,7 @@ const current = ref('summary') + diff --git a/apps/app-frontend/src/components/ui/skin-site/admin/AdminChatPage.vue b/apps/app-frontend/src/components/ui/skin-site/admin/AdminChatPage.vue new file mode 100644 index 0000000..2ba8467 --- /dev/null +++ b/apps/app-frontend/src/components/ui/skin-site/admin/AdminChatPage.vue @@ -0,0 +1,514 @@ + + + diff --git a/apps/app-frontend/src/components/ui/skin-site/room-avatar-cache.ts b/apps/app-frontend/src/components/ui/skin-site/room-avatar-cache.ts new file mode 100644 index 0000000..4e0a88c --- /dev/null +++ b/apps/app-frontend/src/components/ui/skin-site/room-avatar-cache.ts @@ -0,0 +1,30 @@ +import { skinSiteChatRoomAvatarFetch } from '@/helpers/skin-site-api' + +const avatarUrls = new Map() +const pending = new Map>() + +export function loadCachedRoomAvatar( + roomId: number, + attachmentId: number, +): Promise { + const cached = avatarUrls.get(attachmentId) + if (cached) return Promise.resolve(cached) + const running = pending.get(attachmentId) + if (running) return running + + const request = skinSiteChatRoomAvatarFetch(roomId).then((blob) => { + pending.delete(attachmentId) + if (!blob) return null + const url = URL.createObjectURL(blob) + avatarUrls.set(attachmentId, url) + return url + }) + pending.set(attachmentId, request) + return request +} + +export function forgetRoomAvatar(attachmentId: number) { + const url = avatarUrls.get(attachmentId) + if (url) URL.revokeObjectURL(url) + avatarUrls.delete(attachmentId) +} diff --git a/apps/app-frontend/src/helpers/skin-site-api.ts b/apps/app-frontend/src/helpers/skin-site-api.ts index 4f3e1ae..b25843e 100644 --- a/apps/app-frontend/src/helpers/skin-site-api.ts +++ b/apps/app-frontend/src/helpers/skin-site-api.ts @@ -2071,3 +2071,125 @@ export function skinSiteUpdateMultiModInfo( body: mods, }) } + +// --------------------------------------------------------------------------- +// Chat +// --------------------------------------------------------------------------- + +export type SkinSiteChatRoomVisibility = 'PUBLIC' | 'PRIVATE' +export type SkinSiteChatRoomKind = 'STANDARD' | 'SETTLEMENT' +export type SkinSiteChatPrerequisiteMode = 'ANY' | 'ALL' + +export interface SkinSiteChatRoomInfo { + id: number + name: string + description: string + visibility: SkinSiteChatRoomVisibility + joined: boolean + invited: boolean + manager: boolean + createdAt: number + kind: SkinSiteChatRoomKind + avatarAttachmentId?: number | null + voiceEnabled: boolean + voiceAvailable: boolean + voiceParticipantCount: number + voicePublisherCount: number + voicePublisherLimit: number + voiceParticipantLimit: number + voiceListenerLimit: number + prerequisiteMode: SkinSiteChatPrerequisiteMode + prerequisitePermissions: string[] + prerequisiteEligible: boolean +} + +export interface SkinSiteChatUploadInfo { + token: string + attachment: { + id: number + kind: 'IMAGE' | 'FILE' + name: string + size: number + } +} + +export interface SkinSiteChatRoomCreateInput { + name: string + description: string + visibility: SkinSiteChatRoomVisibility + kind?: SkinSiteChatRoomKind + prerequisiteMode: SkinSiteChatPrerequisiteMode + prerequisitePermissions: string[] +} + +export interface SkinSiteChatRoomUpdateInput { + name: string + description: string + visibility: SkinSiteChatRoomVisibility + prerequisiteMode: SkinSiteChatPrerequisiteMode + prerequisitePermissions: string[] +} + +export function skinSiteChatRoomList(): Promise> { + return skinSiteRequest('chat/admin/rooms') +} + +export function skinSiteChatRoomCreate( + data: SkinSiteChatRoomCreateInput, +): Promise> { + return skinSiteRequest('chat/admin/rooms', { + method: 'POST', + body: data, + }) +} + +export function skinSiteChatRoomUpdate( + id: number, + data: SkinSiteChatRoomUpdateInput, +): Promise> { + return skinSiteRequest('chat/rooms/' + id, { + method: 'PUT', + body: data, + }) +} + +export function skinSiteChatRoomDelete(id: number): Promise> { + return skinSiteRequest('chat/admin/rooms/' + id, { method: 'DELETE' }) +} + +export function skinSiteChatRoomAvatarUpdate( + roomId: number, + data: { imageToken?: string; removeImage?: boolean }, +): Promise> { + return skinSiteRequest('chat/rooms/' + roomId + '/avatar', { + method: 'PUT', + body: data, + }) +} + +export function skinSiteChatAttachmentUpload( + file: File, +): Promise> { + const form = new FormData() + form.append('file', file, file.name) + return skinSiteRequest('chat/attachments', { + method: 'POST', + form, + }) +} + +export async function skinSiteChatRoomAvatarFetch(roomId: number): Promise { + const url = `${SKIN_SITE_ORIGIN}${REQUEST_TYPE_PATH.starlight}/chat/rooms/${roomId}/avatar` + const headers: Record = { Accept: 'image/*' } + const token = getSkinSiteToken() + if (token) headers['Authorization'] = `Bearer ${token}` + try { + const response = await tauriFetch(url, { method: 'GET', headers }) + if (!response.ok) return null + const type = response.headers.get('content-type') ?? '' + if (type.includes('application/json') || type.includes('text/')) return null + return await response.blob() + } catch { + return null + } +} diff --git a/apps/app-frontend/src/locales/en-US/index.json b/apps/app-frontend/src/locales/en-US/index.json index cc17884..93a09ab 100644 --- a/apps/app-frontend/src/locales/en-US/index.json +++ b/apps/app-frontend/src/locales/en-US/index.json @@ -12249,5 +12249,42 @@ "app.skin-site.admin.mods.upload-skip": {"message": "Skip"}, "app.skin-site.admin.mods.upload-close": {"message": "Close"}, "app.skin-site.admin.mods.upload-all-done": {"message": "All files processed."}, - "app.skin-site.admin.mods.upload-too-large": {"message": "File too large: {name}"} + "app.skin-site.admin.mods.upload-too-large": {"message": "File too large: {name}"}, + "app.skin-site.chat.avatar.label": {"message": "Room avatar"}, + "app.skin-site.chat.avatar.upload": {"message": "Upload avatar"}, + "app.skin-site.chat.avatar.replace": {"message": "Replace avatar"}, + "app.skin-site.chat.avatar.remove": {"message": "Remove avatar"}, + "app.skin-site.chat.avatar.hint": {"message": "PNG, JPEG or WebP, up to 8192 px per side and 5 MB."}, + "app.skin-site.chat.avatar.bad-format": {"message": "Only PNG, JPEG and WebP are supported."}, + "app.skin-site.chat.avatar.too-large": {"message": "Avatar must not exceed 5 MB."}, + "app.skin-site.chat.avatar.not-image": {"message": "Avatar must be an image."}, + "app.skin-site.chat.admin.title": {"message": "Chat rooms"}, + "app.skin-site.chat.admin.subtitle": {"message": "Create public or private rooms and hand daily moderation to trusted members."}, + "app.skin-site.chat.admin.create-room": {"message": "Create room"}, + "app.skin-site.chat.admin.loading": {"message": "Loading…"}, + "app.skin-site.chat.admin.failed": {"message": "Could not load rooms."}, + "app.skin-site.chat.admin.empty": {"message": "No chat rooms yet."}, + "app.skin-site.chat.admin.edit": {"message": "Edit"}, + "app.skin-site.chat.admin.remove": {"message": "Delete"}, + "app.skin-site.chat.admin.save": {"message": "Save"}, + "app.skin-site.chat.admin.cancel": {"message": "Cancel"}, + "app.skin-site.chat.admin.name": {"message": "Name"}, + "app.skin-site.chat.admin.description": {"message": "Description"}, + "app.skin-site.chat.admin.visibility": {"message": "Visibility"}, + "app.skin-site.chat.admin.visibility-public": {"message": "Public"}, + "app.skin-site.chat.admin.visibility-private": {"message": "Private"}, + "app.skin-site.chat.admin.kind": {"message": "Kind"}, + "app.skin-site.chat.admin.kind-standard": {"message": "Standard"}, + "app.skin-site.chat.admin.kind-settlement": {"message": "Settlement"}, + "app.skin-site.chat.admin.prerequisite-mode": {"message": "Prerequisite mode"}, + "app.skin-site.chat.admin.prerequisite-any": {"message": "Any"}, + "app.skin-site.chat.admin.prerequisite-all": {"message": "All"}, + "app.skin-site.chat.admin.prerequisite-permissions": {"message": "Required permissions"}, + "app.skin-site.chat.admin.prerequisite-hint": {"message": "Comma separated. Leave empty for no restriction."}, + "app.skin-site.chat.admin.created": {"message": "Room created."}, + "app.skin-site.chat.admin.updated": {"message": "Room updated."}, + "app.skin-site.chat.admin.deleted": {"message": "Room deleted."}, + "app.skin-site.chat.admin.confirm-delete": {"message": "Permanently delete room “{name}” and all its messages?"}, + "app.skin-site.chat.admin.manager-badge": {"message": "Manager"}, + "app.skin-site.chat.admin.voice-badge": {"message": "Voice"} } diff --git a/apps/app-frontend/src/locales/zh-CN/index.json b/apps/app-frontend/src/locales/zh-CN/index.json index 13b7baa..60caae6 100644 --- a/apps/app-frontend/src/locales/zh-CN/index.json +++ b/apps/app-frontend/src/locales/zh-CN/index.json @@ -12080,5 +12080,42 @@ "app.skin-site.admin.mods.upload-skip": {"message": "跳过"}, "app.skin-site.admin.mods.upload-close": {"message": "关闭"}, "app.skin-site.admin.mods.upload-all-done": {"message": "全部文件处理完成。"}, - "app.skin-site.admin.mods.upload-too-large": {"message": "文件过大:{name}"} + "app.skin-site.admin.mods.upload-too-large": {"message": "文件过大:{name}"}, + "app.skin-site.chat.avatar.label": {"message": "房间头像"}, + "app.skin-site.chat.avatar.upload": {"message": "上传头像"}, + "app.skin-site.chat.avatar.replace": {"message": "更换头像"}, + "app.skin-site.chat.avatar.remove": {"message": "移除头像"}, + "app.skin-site.chat.avatar.hint": {"message": "支持 PNG、JPEG、WebP,单边不超过 8192 像素,文件不超过 5 MB。"}, + "app.skin-site.chat.avatar.bad-format": {"message": "房间头像仅支持 PNG、JPEG 和 WebP 格式。"}, + "app.skin-site.chat.avatar.too-large": {"message": "房间头像大小不能超过 5 MB。"}, + "app.skin-site.chat.avatar.not-image": {"message": "房间头像必须是图片。"}, + "app.skin-site.chat.admin.title": {"message": "聊天室管理"}, + "app.skin-site.chat.admin.subtitle": {"message": "创建公开或私有房间,并将日常管理权限交给合适的成员。"}, + "app.skin-site.chat.admin.create-room": {"message": "创建房间"}, + "app.skin-site.chat.admin.loading": {"message": "加载中…"}, + "app.skin-site.chat.admin.failed": {"message": "无法加载房间列表。"}, + "app.skin-site.chat.admin.empty": {"message": "还没有聊天室。"}, + "app.skin-site.chat.admin.edit": {"message": "编辑"}, + "app.skin-site.chat.admin.remove": {"message": "删除"}, + "app.skin-site.chat.admin.save": {"message": "保存"}, + "app.skin-site.chat.admin.cancel": {"message": "取消"}, + "app.skin-site.chat.admin.name": {"message": "房间名称"}, + "app.skin-site.chat.admin.description": {"message": "房间简介"}, + "app.skin-site.chat.admin.visibility": {"message": "可见性"}, + "app.skin-site.chat.admin.visibility-public": {"message": "公开"}, + "app.skin-site.chat.admin.visibility-private": {"message": "私有"}, + "app.skin-site.chat.admin.kind": {"message": "房间类型"}, + "app.skin-site.chat.admin.kind-standard": {"message": "普通房间"}, + "app.skin-site.chat.admin.kind-settlement": {"message": "聚落房间"}, + "app.skin-site.chat.admin.prerequisite-mode": {"message": "前置条件判定"}, + "app.skin-site.chat.admin.prerequisite-any": {"message": "满足任一"}, + "app.skin-site.chat.admin.prerequisite-all": {"message": "满足全部"}, + "app.skin-site.chat.admin.prerequisite-permissions": {"message": "所需权限"}, + "app.skin-site.chat.admin.prerequisite-hint": {"message": "以逗号分隔;留空表示无限制。"}, + "app.skin-site.chat.admin.created": {"message": "房间已创建。"}, + "app.skin-site.chat.admin.updated": {"message": "房间资料已更新。"}, + "app.skin-site.chat.admin.deleted": {"message": "房间已删除。"}, + "app.skin-site.chat.admin.confirm-delete": {"message": "确认永久删除房间“{name}”及其中全部消息吗?"}, + "app.skin-site.chat.admin.manager-badge": {"message": "管理"}, + "app.skin-site.chat.admin.voice-badge": {"message": "语音"} } From ac38c98203635f3412c021c295f5924f4065f3d3 Mon Sep 17 00:00:00 2001 From: Ap_Tx <3045929398@qq.com> Date: Sun, 18 Oct 2026 12:20:45 +0800 Subject: [PATCH 13/16] =?UTF-8?q?feat:=20=E8=81=8A=E5=A4=A9=E5=AE=A4?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E8=A1=A5=E6=88=BF=E7=AE=A1=E4=BB=BB=E5=91=BD?= =?UTF-8?q?=E3=80=81=E8=81=9A=E8=90=BD=E4=B8=BB=E4=B8=8E=E5=AE=A1=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 ChatUserPicker 用户选择器:支持按用户名/邮箱/账号 ID 或玩家名称搜索,封禁与游客不可选,支持排除与白名单 - 房管任命与撤销 - 聚落房间可任命聚落主,展示当前聚落主 - 房间审计记录查看,操作类型本地化映射 - 补充对应 API 与类型定义 - i18n 白名单加入 ID 缩写 - 中英文各补 31 个文案 --- .../ui/skin-site/ChatUserPicker.vue | 290 ++++++++++++++ .../ui/skin-site/admin/AdminChatPage.vue | 358 ++++++++++++++++++ .../app-frontend/src/helpers/skin-site-api.ts | 59 +++ .../app-frontend/src/locales/en-US/index.json | 33 +- .../app-frontend/src/locales/zh-CN/index.json | 33 +- scripts/axolotl/i18n-check.mjs | 1 + 6 files changed, 772 insertions(+), 2 deletions(-) create mode 100644 apps/app-frontend/src/components/ui/skin-site/ChatUserPicker.vue diff --git a/apps/app-frontend/src/components/ui/skin-site/ChatUserPicker.vue b/apps/app-frontend/src/components/ui/skin-site/ChatUserPicker.vue new file mode 100644 index 0000000..ad24d06 --- /dev/null +++ b/apps/app-frontend/src/components/ui/skin-site/ChatUserPicker.vue @@ -0,0 +1,290 @@ + + + diff --git a/apps/app-frontend/src/components/ui/skin-site/admin/AdminChatPage.vue b/apps/app-frontend/src/components/ui/skin-site/admin/AdminChatPage.vue index 2ba8467..3293c2e 100644 --- a/apps/app-frontend/src/components/ui/skin-site/admin/AdminChatPage.vue +++ b/apps/app-frontend/src/components/ui/skin-site/admin/AdminChatPage.vue @@ -12,8 +12,15 @@ import { skinSiteChatRoomDelete, skinSiteChatRoomList, skinSiteChatRoomUpdate, + skinSiteChatAuditList, + skinSiteChatManagerSet, + skinSiteChatSettlementOwnerAssign, + type SkinSiteChatAuditInfo, + type SkinSiteChatUserInfo, } from '@/helpers/skin-site-api' +import ChatUserPicker from '../ChatUserPicker.vue' + import RoomAvatar from '../RoomAvatar.vue' import RoomAvatarEditor from '../RoomAvatarEditor.vue' @@ -98,6 +105,59 @@ const messages = defineMessages({ id: 'app.skin-site.chat.admin.voice-badge', defaultMessage: 'Voice', }, + managers: { id: 'app.skin-site.chat.admin.managers', defaultMessage: 'Managers' }, + managerTitle: { + id: 'app.skin-site.chat.admin.manager-title', + defaultMessage: 'Room manager', + }, + managerHint: { + id: 'app.skin-site.chat.admin.manager-hint', + defaultMessage: 'Managers can moderate members, messages and files in this room.', + }, + grant: { id: 'app.skin-site.chat.admin.grant', defaultMessage: 'Grant' }, + revoke: { id: 'app.skin-site.chat.admin.revoke', defaultMessage: 'Revoke' }, + managerGranted: { + id: 'app.skin-site.chat.admin.manager-granted', + defaultMessage: 'Manager granted.', + }, + managerRevoked: { + id: 'app.skin-site.chat.admin.manager-revoked', + defaultMessage: 'Manager revoked.', + }, + ownerTitle: { + id: 'app.skin-site.chat.admin.owner-title', + defaultMessage: 'Settlement owner', + }, + ownerHint: { + id: 'app.skin-site.chat.admin.owner-hint', + defaultMessage: 'Assign the first settlement owner for this room.', + }, + ownerAssigned: { + id: 'app.skin-site.chat.admin.owner-assigned', + defaultMessage: 'Settlement owner assigned.', + }, + ownerCurrent: { + id: 'app.skin-site.chat.admin.owner-current', + defaultMessage: 'Current owner: {name}', + }, + ownerNone: { + id: 'app.skin-site.chat.admin.owner-none', + defaultMessage: 'No settlement owner yet.', + }, + audit: { id: 'app.skin-site.chat.admin.audit', defaultMessage: 'Audit' }, + auditTitle: { + id: 'app.skin-site.chat.admin.audit-title', + defaultMessage: 'Room audit', + }, + auditEmpty: { + id: 'app.skin-site.chat.admin.audit-empty', + defaultMessage: 'No audit records yet.', + }, + auditTarget: { + id: 'app.skin-site.chat.admin.audit-target', + defaultMessage: 'Target: {name}', + }, + close: { id: 'app.skin-site.chat.admin.close', defaultMessage: 'Close' }, }) type FormState = { @@ -268,6 +328,138 @@ function formatDateTime(ts?: number | null): string { } } +const managerRoom = ref(null) +const managerUser = ref(null) +const managerGranting = ref(false) + +const ownerRoom = ref(null) +const ownerUser = ref(null) +const ownerSaving = ref(false) + +const auditRoom = ref(null) +const auditItems = ref([]) +const auditLoading = ref(false) + +const AUDIT_LABELS: Record = { + ROOM_CREATED: '创建房间', + ROOM_UPDATED: '修改房间资料', + ROOM_AVATAR_UPDATED: '更新房间头像', + ROOM_AVATAR_CLEARED: '移除房间头像', + MEMBER_JOINED: '成员加入房间', + MEMBER_LEFT: '成员退出房间', + MEMBER_INVITED: '邀请成员', + MEMBER_REMOVED: '踢出成员', + MEMBER_BANNED: '踢出并拉黑', + MEMBER_UNBANNED: '解除成员封禁', + MEMBER_MUTED: '禁言成员', + MEMBER_UNMUTED: '解除成员禁言', + MANAGER_ADDED: '任命房间管理', + MANAGER_REMOVED: '撤销房间管理', + BOT_ADDED: '添加机器人', + BOT_REMOVED: '移除机器人', + MESSAGE_DELETED: '撤回消息', + MESSAGE_RECALLED: '撤回消息', + MESSAGE_PINNED: '设为精华', + MESSAGE_UNPINNED: '取消精华', + ANNOUNCEMENT_UPDATED: '更新房间公告', + ANNOUNCEMENT_CLEARED: '清空房间公告', + FILE_DELETED: '删除房间文件', + VOICE_ROOM_UPDATED: '修改房间语音设置', + VOICE_CAPACITY_UPDATED: '修改房间语音容量', + VOICE_MEMBER_KICKED: '断开成员语音', + VOICE_MEMBER_BANNED: '临时封禁成员语音', + VOICE_MEMBER_UNBANNED: '解除成员语音封禁', + VOICE_SPEAKER_REVOKED: '关闭成员麦克风', + VOICE_OBSERVER_JOINED: 'Web管理开始旁听语音', + VOICE_OBSERVER_LEFT: 'Web管理结束旁听语音', + SETTLEMENT_OWNER_ASSIGNED: '任命聚落主', + SETTLEMENT_OWNER_TRANSFERRED: '转让聚落', + SETTLEMENT_MANAGER_ADDED: '任命聚落管理', + SETTLEMENT_MANAGER_REMOVED: '撤销聚落管理', +} + +function auditLabel(action: string): string { + return AUDIT_LABELS[action] ?? action +} + +function auditActor(name?: string): string { + return name?.trim() || '已删除用户' +} + +function openManager(room: SkinSiteChatRoomInfo) { + managerRoom.value = room + managerUser.value = null +} + +function closeManager() { + managerRoom.value = null + managerUser.value = null +} + +async function grantManager(manager: boolean) { + if (!managerRoom.value || !managerUser.value || managerGranting.value) return + managerGranting.value = true + try { + const res = await skinSiteChatManagerSet(managerRoom.value.id, managerUser.value.uuid, manager) + if (!res.success) throw new Error(res.errorMessage) + addNotification({ + type: 'success', + title: formatMessage(manager ? messages.managerGranted : messages.managerRevoked), + }) + closeManager() + } catch (error) { + handleError(error) + } finally { + managerGranting.value = false + } +} + +function openOwner(room: SkinSiteChatRoomInfo) { + ownerRoom.value = room + ownerUser.value = null +} + +function closeOwner() { + ownerRoom.value = null + ownerUser.value = null +} + +async function assignOwner() { + if (!ownerRoom.value || !ownerUser.value || ownerSaving.value) return + ownerSaving.value = true + try { + const res = await skinSiteChatSettlementOwnerAssign(ownerRoom.value.id, ownerUser.value.uuid) + if (!res.success) throw new Error(res.errorMessage) + addNotification({ type: 'success', title: formatMessage(messages.ownerAssigned) }) + closeOwner() + await loadRooms() + } catch (error) { + handleError(error) + } finally { + ownerSaving.value = false + } +} + +async function openAudit(room: SkinSiteChatRoomInfo) { + auditRoom.value = room + auditItems.value = [] + auditLoading.value = true + try { + const res = await skinSiteChatAuditList(room.id) + if (!res.success) throw new Error(res.errorMessage) + auditItems.value = res.payload ?? [] + } catch (error) { + handleError(error) + } finally { + auditLoading.value = false + } +} + +function closeAudit() { + auditRoom.value = null + auditItems.value = [] +} + onMounted(() => void loadRooms()) @@ -371,6 +563,28 @@ onMounted(() => void loadRooms()) > {{ perm }} + + + + + + + + + +
+
+

+ {{ formatMessage(messages.ownerTitle) }} · {{ ownerRoom.name }} +

+

{{ formatMessage(messages.ownerHint) }}

+

+ {{ + ownerRoom.settlementOwnerName + ? formatMessage(messages.ownerCurrent, { name: ownerRoom.settlementOwnerName }) + : formatMessage(messages.ownerNone) + }} +

+ +
+ + +
+
+
+ +
+
+

+ {{ formatMessage(messages.auditTitle) }} · {{ auditRoom.name }} +

+
+ + {{ formatMessage(messages.loading) }} +
+

+ {{ formatMessage(messages.auditEmpty) }} +

+
+
+ {{ auditLabel(entry.action) }} + {{ auditActor(entry.actorName) }} + + {{ formatMessage(messages.auditTarget, { name: entry.target }) }} + + + {{ formatDateTime(entry.createdAt) }} + +
+
+
+ +
+
+
diff --git a/apps/app-frontend/src/helpers/skin-site-api.ts b/apps/app-frontend/src/helpers/skin-site-api.ts index b25843e..0464a58 100644 --- a/apps/app-frontend/src/helpers/skin-site-api.ts +++ b/apps/app-frontend/src/helpers/skin-site-api.ts @@ -2193,3 +2193,62 @@ export async function skinSiteChatRoomAvatarFetch(roomId: number): Promise> { + const query = new URLSearchParams({ keyword }) + if (roomId !== undefined) query.set('room_id', String(roomId)) + if (searchBy === 'player') query.set('search_by', 'player') + return skinSiteRequest('chat/users/search?' + query.toString()) +} + +export function skinSiteChatManagerSet( + roomId: number, + userUuid: string, + manager: boolean, +): Promise> { + return skinSiteRequest('chat/admin/rooms/' + roomId + '/manager', { + method: 'POST', + body: { userUuid, manager }, + }) +} + +export function skinSiteChatSettlementOwnerAssign( + roomId: number, + userUuid: string, +): Promise> { + return skinSiteRequest('chat/settlements/' + roomId + '/owner', { + method: 'POST', + body: { userUuid }, + }) +} + +export function skinSiteChatAuditList( + roomId: number, +): Promise> { + return skinSiteRequest('chat/rooms/' + roomId + '/audit') +} diff --git a/apps/app-frontend/src/locales/en-US/index.json b/apps/app-frontend/src/locales/en-US/index.json index 93a09ab..a485699 100644 --- a/apps/app-frontend/src/locales/en-US/index.json +++ b/apps/app-frontend/src/locales/en-US/index.json @@ -12286,5 +12286,36 @@ "app.skin-site.chat.admin.deleted": {"message": "Room deleted."}, "app.skin-site.chat.admin.confirm-delete": {"message": "Permanently delete room “{name}” and all its messages?"}, "app.skin-site.chat.admin.manager-badge": {"message": "Manager"}, - "app.skin-site.chat.admin.voice-badge": {"message": "Voice"} + "app.skin-site.chat.admin.voice-badge": {"message": "Voice"}, + "app.skin-site.chat.user-picker.label": {"message": "Pick a user"}, + "app.skin-site.chat.user-picker.placeholder": {"message": "Search username, email or account ID"}, + "app.skin-site.chat.user-picker.by-user": {"message": "By user"}, + "app.skin-site.chat.user-picker.by-player": {"message": "By player"}, + "app.skin-site.chat.user-picker.hint": {"message": "Fuzzy search on username and email, or search by account ID."}, + "app.skin-site.chat.user-picker.player-hint": {"message": "Enter a player name to find the bound user."}, + "app.skin-site.chat.user-picker.empty": {"message": "No matching user."}, + "app.skin-site.chat.user-picker.banned": {"message": "This account is banned."}, + "app.skin-site.chat.user-picker.guest": {"message": "Guests cannot become a manager or settlement owner."}, + "app.skin-site.chat.user-picker.already-in": {"message": "This user is already in the room."}, + "app.skin-site.chat.user-picker.not-allowed": {"message": "This user is outside the allowed range."}, + "app.skin-site.chat.user-picker.select": {"message": "Select"}, + "app.skin-site.chat.user-picker.selected": {"message": "Selected"}, + "app.skin-site.chat.user-picker.id": {"message": "ID"}, + "app.skin-site.chat.admin.managers": {"message": "Managers"}, + "app.skin-site.chat.admin.manager-title": {"message": "Room manager"}, + "app.skin-site.chat.admin.manager-hint": {"message": "Managers can moderate members, messages and files in this room."}, + "app.skin-site.chat.admin.grant": {"message": "Grant"}, + "app.skin-site.chat.admin.revoke": {"message": "Revoke"}, + "app.skin-site.chat.admin.manager-granted": {"message": "Manager granted."}, + "app.skin-site.chat.admin.manager-revoked": {"message": "Manager revoked."}, + "app.skin-site.chat.admin.owner-title": {"message": "Settlement owner"}, + "app.skin-site.chat.admin.owner-hint": {"message": "Assign the first settlement owner for this room."}, + "app.skin-site.chat.admin.owner-assigned": {"message": "Settlement owner assigned."}, + "app.skin-site.chat.admin.owner-current": {"message": "Current owner: {name}"}, + "app.skin-site.chat.admin.owner-none": {"message": "No settlement owner yet."}, + "app.skin-site.chat.admin.audit": {"message": "Audit"}, + "app.skin-site.chat.admin.audit-title": {"message": "Room audit"}, + "app.skin-site.chat.admin.audit-empty": {"message": "No audit records yet."}, + "app.skin-site.chat.admin.audit-target": {"message": "Target: {name}"}, + "app.skin-site.chat.admin.close": {"message": "Close"} } diff --git a/apps/app-frontend/src/locales/zh-CN/index.json b/apps/app-frontend/src/locales/zh-CN/index.json index 60caae6..4af0739 100644 --- a/apps/app-frontend/src/locales/zh-CN/index.json +++ b/apps/app-frontend/src/locales/zh-CN/index.json @@ -12117,5 +12117,36 @@ "app.skin-site.chat.admin.deleted": {"message": "房间已删除。"}, "app.skin-site.chat.admin.confirm-delete": {"message": "确认永久删除房间“{name}”及其中全部消息吗?"}, "app.skin-site.chat.admin.manager-badge": {"message": "管理"}, - "app.skin-site.chat.admin.voice-badge": {"message": "语音"} + "app.skin-site.chat.admin.voice-badge": {"message": "语音"}, + "app.skin-site.chat.user-picker.label": {"message": "选择用户"}, + "app.skin-site.chat.user-picker.placeholder": {"message": "搜索用户名、邮箱或账号 ID"}, + "app.skin-site.chat.user-picker.by-user": {"message": "按用户信息"}, + "app.skin-site.chat.user-picker.by-player": {"message": "按玩家 ID"}, + "app.skin-site.chat.user-picker.hint": {"message": "支持按用户名、邮箱模糊搜索,也可输入账号 ID 搜索。"}, + "app.skin-site.chat.user-picker.player-hint": {"message": "输入玩家名称,查找绑定该玩家 ID 的用户。"}, + "app.skin-site.chat.user-picker.empty": {"message": "没有找到匹配的用户"}, + "app.skin-site.chat.user-picker.banned": {"message": "该账号已被封禁,不能执行此操作。"}, + "app.skin-site.chat.user-picker.guest": {"message": "游客不能成为房管、聚落主或聚落成员。"}, + "app.skin-site.chat.user-picker.already-in": {"message": "该用户已经在当前房间中。"}, + "app.skin-site.chat.user-picker.not-allowed": {"message": "该用户不在当前允许选择的范围内"}, + "app.skin-site.chat.user-picker.select": {"message": "选择此用户"}, + "app.skin-site.chat.user-picker.selected": {"message": "已选择此用户"}, + "app.skin-site.chat.user-picker.id": {"message": "ID"}, + "app.skin-site.chat.admin.managers": {"message": "房管"}, + "app.skin-site.chat.admin.manager-title": {"message": "任命房管"}, + "app.skin-site.chat.admin.manager-hint": {"message": "房管可以管理该房间的成员、消息与文件。"}, + "app.skin-site.chat.admin.grant": {"message": "任命"}, + "app.skin-site.chat.admin.revoke": {"message": "撤销"}, + "app.skin-site.chat.admin.manager-granted": {"message": "已任命房管。"}, + "app.skin-site.chat.admin.manager-revoked": {"message": "已撤销房管。"}, + "app.skin-site.chat.admin.owner-title": {"message": "聚落主"}, + "app.skin-site.chat.admin.owner-hint": {"message": "为该房间任命首任聚落主。"}, + "app.skin-site.chat.admin.owner-assigned": {"message": "已任命聚落主。"}, + "app.skin-site.chat.admin.owner-current": {"message": "当前聚落主:{name}"}, + "app.skin-site.chat.admin.owner-none": {"message": "尚未设置聚落主。"}, + "app.skin-site.chat.admin.audit": {"message": "审计"}, + "app.skin-site.chat.admin.audit-title": {"message": "房间审计"}, + "app.skin-site.chat.admin.audit-empty": {"message": "暂无审计记录。"}, + "app.skin-site.chat.admin.audit-target": {"message": "对象:{name}"}, + "app.skin-site.chat.admin.close": {"message": "关闭"} } diff --git a/scripts/axolotl/i18n-check.mjs b/scripts/axolotl/i18n-check.mjs index 1482e1e..1f52425 100644 --- a/scripts/axolotl/i18n-check.mjs +++ b/scripts/axolotl/i18n-check.mjs @@ -31,6 +31,7 @@ const allowedUntranslatedMessages = new Set([ 'LiteLoader', 'LogShare.CN', 'UUID', + 'ID', 'QQ', 'IP', 'URL', From 667c1a09001539e87a8b20fdb6f2ecc46992719e Mon Sep 17 00:00:00 2001 From: Ap_Tx <3045929398@qq.com> Date: Sun, 18 Oct 2026 12:29:45 +0800 Subject: [PATCH 14/16] =?UTF-8?q?feat:=20=E8=81=8A=E5=A4=A9=E5=AE=A4?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E6=8E=A5=E5=85=A5=E5=AE=9E=E6=97=B6=E8=AF=AD?= =?UTF-8?q?=E9=9F=B3=E5=AD=90=E7=B3=BB=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 顶部展示语音运行时状态:状态标签、LiveKit 版本、下载进度,未就绪时每 5 秒轮询 - 部署失败或不支持平台时提供重新部署入口 - 语音库配置弹窗:启用开关、目录、镜像地址、公网地址、监听地址、信令与媒体端口、启动超时、外部 IP 与节点 IP - 语音日志弹窗:增量拉取、自动刷新开关、手动刷新、最多保留 2000 条 - 房间语音容量设置:参与人数与旁听人数上限 - 补充语音相关 API 与类型定义 - 中英文各补 26 个文案 --- .../ui/skin-site/admin/AdminChatPage.vue | 384 +++++++++++++++++- .../app-frontend/src/helpers/skin-site-api.ts | 115 ++++++ .../app-frontend/src/locales/en-US/index.json | 54 ++- .../app-frontend/src/locales/zh-CN/index.json | 54 ++- 4 files changed, 603 insertions(+), 4 deletions(-) diff --git a/apps/app-frontend/src/components/ui/skin-site/admin/AdminChatPage.vue b/apps/app-frontend/src/components/ui/skin-site/admin/AdminChatPage.vue index 3293c2e..07ca04b 100644 --- a/apps/app-frontend/src/components/ui/skin-site/admin/AdminChatPage.vue +++ b/apps/app-frontend/src/components/ui/skin-site/admin/AdminChatPage.vue @@ -1,7 +1,7 @@ diff --git a/apps/app-frontend/src/helpers/skin-site-api.ts b/apps/app-frontend/src/helpers/skin-site-api.ts index 9fb7d0d..c2cf8d8 100644 --- a/apps/app-frontend/src/helpers/skin-site-api.ts +++ b/apps/app-frontend/src/helpers/skin-site-api.ts @@ -2367,3 +2367,129 @@ export function skinSiteChatVoiceCapacityUpdate( { method: 'PUT', body: data }, ) } + +// --------------------------------------------------------------------------- +// Wiki migration +// --------------------------------------------------------------------------- + +export type SkinSiteWikiMigrationState = + | 'IMPORTING' + | 'REVIEW' + | 'PUBLISHING' + | 'PUBLISHED' + | 'FAILED' + | 'ROLLED_BACK' + +export type SkinSiteWikiMigrationDecision = 'PENDING' | 'IMPORT' | 'EXCLUDE' + +export interface SkinSiteWikiMigrationJobInfo { + id: number + source: string + state: SkinSiteWikiMigrationState + total: number + reviewed: number + imported: number + excluded: number + error?: string | null + missingAttachmentCount?: number +} + +export interface SkinSiteWikiMigrationCandidate { + id: number + title: string + lastUpdatedAt: number + maintainerCount: number + suspiciousReasons: string[] + decision: SkinSiteWikiMigrationDecision + conflict: boolean +} + +export interface SkinSiteWikiMigrationPage { + items: SkinSiteWikiMigrationCandidate[] + nextCursor?: number | null +} + +export interface SkinSiteWikiMigrationPublishResult { + imported: number + overwritten: number +} + +export interface SkinSiteWikiAutomaticMigrationInput { + apiUrl: string + username?: string | null + password?: string | null + allowPrivateAddress: boolean + rules: { + staleDays: number + maintainerWindowDays: number + maximumMaintainers: number + staleMarkers: string[] + } +} + +export function skinSiteWikiMigrationJobs(): Promise< + SkinSiteResponse +> { + return skinSiteRequest('wiki/admin/migration') +} + +export function skinSiteWikiMigrationStartAutomatic( + data: SkinSiteWikiAutomaticMigrationInput, +): Promise> { + return skinSiteRequest('wiki/admin/migration/automatic', { + method: 'POST', + body: data, + }) +} + +export function skinSiteWikiMigrationStartXml( + file: File, +): Promise> { + const form = new FormData() + form.append('xml', file, file.name) + return skinSiteRequest('wiki/admin/migration/xml', { + method: 'POST', + form, + }) +} + +export function skinSiteWikiMigrationCandidates( + jobId: number, + after?: number, +): Promise> { + const suffix = after ? '?after=' + after : '' + return skinSiteRequest( + 'wiki/admin/migration/' + jobId + '/candidates' + suffix, + ) +} + +export function skinSiteWikiMigrationDecide( + candidateId: number, + data: { decision: SkinSiteWikiMigrationDecision; targetTitle?: string | null; overwrite?: boolean }, +): Promise> { + return skinSiteRequest('wiki/admin/migration/candidates/' + candidateId, { + method: 'POST', + body: data, + }) +} + +export function skinSiteWikiMigrationPublish( + jobId: number, +): Promise> { + return skinSiteRequest( + 'wiki/admin/migration/' + jobId + '/publish', + { method: 'POST' }, + ) +} + +export function skinSiteWikiMigrationRollback( + jobId: number, +): Promise> { + return skinSiteRequest('wiki/admin/migration/' + jobId + '/rollback', { + method: 'POST', + }) +} + +export function skinSiteWikiMigrationDelete(jobId: number): Promise> { + return skinSiteRequest('wiki/admin/migration/' + jobId, { method: 'DELETE' }) +} diff --git a/apps/app-frontend/src/locales/en-US/index.json b/apps/app-frontend/src/locales/en-US/index.json index 3ace0fa..571bc67 100644 --- a/apps/app-frontend/src/locales/en-US/index.json +++ b/apps/app-frontend/src/locales/en-US/index.json @@ -12369,5 +12369,60 @@ "app.skin-site.chat.admin.voice-saved": {"message": "Voice configuration saved."}, "app.skin-site.chat.admin.voice-logs-auto-refresh": {"message": "Auto refresh"}, "app.skin-site.chat.admin.voice-logs-refresh": {"message": "Refresh"}, - "app.skin-site.chat.admin.voice-logs-empty": {"message": "No logs yet."} + "app.skin-site.chat.admin.voice-logs-empty": {"message": "No logs yet."}, + "app.skin-site.admin.wiki.tab-migration": {"message": "Migration"}, + "app.skin-site.admin.wiki.migration-title": {"message": "MediaWiki migration"}, + "app.skin-site.admin.wiki.migration-hint": {"message": "Migrated content lands in a hidden staging area and is published in one batch after every page is reviewed."}, + "app.skin-site.admin.wiki.auto-title": {"message": "Read from MediaWiki API"}, + "app.skin-site.admin.wiki.api-url": {"message": "api.php URL"}, + "app.skin-site.admin.wiki.bot-username": {"message": "Bot username (optional)"}, + "app.skin-site.admin.wiki.bot-password": {"message": "Bot password (optional)"}, + "app.skin-site.admin.wiki.credentials-hint": {"message": "Credentials stay in memory for this job only. HTTPS is required when supplied."}, + "app.skin-site.admin.wiki.allow-private": {"message": "Allow private network source"}, + "app.skin-site.admin.wiki.allow-private-hint": {"message": "Private sources may reach services inside the server network."}, + "app.skin-site.admin.wiki.start-auto": {"message": "Create automatic migration job"}, + "app.skin-site.admin.wiki.xml-title": {"message": "Import MediaWiki XML"}, + "app.skin-site.admin.wiki.xml-file": {"message": "Export file"}, + "app.skin-site.admin.wiki.start-xml": {"message": "Upload and stage"}, + "app.skin-site.admin.wiki.stale-rules": {"message": "Stale page detection rules"}, + "app.skin-site.admin.wiki.stale-days": {"message": "Days without update"}, + "app.skin-site.admin.wiki.maintainer-window": {"message": "Maintainer window (days)"}, + "app.skin-site.admin.wiki.max-maintainers": {"message": "Max maintainers"}, + "app.skin-site.admin.wiki.stale-markers": {"message": "Stale categories, templates or text"}, + "app.skin-site.admin.wiki.stale-markers-hint": {"message": "Comma separated"}, + "app.skin-site.admin.wiki.jobs": {"message": "Migration jobs"}, + "app.skin-site.admin.wiki.refresh-jobs": {"message": "Refresh"}, + "app.skin-site.admin.wiki.jobs-empty": {"message": "No migration jobs yet."}, + "app.skin-site.admin.wiki.reviewed-count": {"message": "Reviewed {reviewed} / {total}"}, + "app.skin-site.admin.wiki.imported-excluded": {"message": "Imported {imported} · Excluded {excluded}"}, + "app.skin-site.admin.wiki.view-job": {"message": "View job"}, + "app.skin-site.admin.wiki.delete-job": {"message": "Delete job record"}, + "app.skin-site.admin.wiki.confirm-delete-job": {"message": "Delete migration job #{id}? This cannot be undone."}, + "app.skin-site.admin.wiki.candidates": {"message": "Candidate pages"}, + "app.skin-site.admin.wiki.candidates-empty": {"message": "Select a job to review its pages."}, + "app.skin-site.admin.wiki.last-updated": {"message": "Updated {date}"}, + "app.skin-site.admin.wiki.maintainer-count": {"message": "{count} maintainers"}, + "app.skin-site.admin.wiki.conflict": {"message": "Title conflict"}, + "app.skin-site.admin.wiki.target-title": {"message": "Target title"}, + "app.skin-site.admin.wiki.overwrite": {"message": "Overwrite"}, + "app.skin-site.admin.wiki.import": {"message": "Import"}, + "app.skin-site.admin.wiki.exclude": {"message": "Exclude"}, + "app.skin-site.admin.wiki.publish": {"message": "Publish batch"}, + "app.skin-site.admin.wiki.rollback": {"message": "Roll back"}, + "app.skin-site.admin.wiki.confirm-rollback": {"message": "Roll back this migration batch? Published pages will be reverted."}, + "app.skin-site.admin.wiki.state-importing": {"message": "Importing"}, + "app.skin-site.admin.wiki.state-review": {"message": "Waiting review"}, + "app.skin-site.admin.wiki.state-publishing": {"message": "Publishing"}, + "app.skin-site.admin.wiki.state-published": {"message": "Published"}, + "app.skin-site.admin.wiki.state-failed": {"message": "Failed"}, + "app.skin-site.admin.wiki.state-rolled-back": {"message": "Rolled back"}, + "app.skin-site.admin.wiki.decision-pending": {"message": "Pending"}, + "app.skin-site.admin.wiki.decision-import": {"message": "Import"}, + "app.skin-site.admin.wiki.decision-exclude": {"message": "Exclude"}, + "app.skin-site.admin.wiki.migration-started": {"message": "Migration job created."}, + "app.skin-site.admin.wiki.xml-staged": {"message": "XML parsed and staged for review."}, + "app.skin-site.admin.wiki.published": {"message": "Published {imported} pages, overwrote {overwritten}."}, + "app.skin-site.admin.wiki.rolled-back": {"message": "Migration batch rolled back."}, + "app.skin-site.admin.wiki.job-deleted": {"message": "Migration record deleted."}, + "app.skin-site.admin.wiki.select-xml": {"message": "Choose a MediaWiki XML file first."} } diff --git a/apps/app-frontend/src/locales/zh-CN/index.json b/apps/app-frontend/src/locales/zh-CN/index.json index 202bffb..be7e653 100644 --- a/apps/app-frontend/src/locales/zh-CN/index.json +++ b/apps/app-frontend/src/locales/zh-CN/index.json @@ -12200,5 +12200,60 @@ "app.skin-site.chat.admin.voice-saved": {"message": "语音库配置已保存。"}, "app.skin-site.chat.admin.voice-logs-auto-refresh": {"message": "自动刷新"}, "app.skin-site.chat.admin.voice-logs-refresh": {"message": "刷新"}, - "app.skin-site.chat.admin.voice-logs-empty": {"message": "暂无日志。"} + "app.skin-site.chat.admin.voice-logs-empty": {"message": "暂无日志。"}, + "app.skin-site.admin.wiki.tab-migration": {"message": "迁移中心"}, + "app.skin-site.admin.wiki.migration-title": {"message": "MediaWiki 迁移"}, + "app.skin-site.admin.wiki.migration-hint": {"message": "迁移内容会先进入不可见暂存区,所有页面完成导入或排除审核后才能整批发布。"}, + "app.skin-site.admin.wiki.auto-title": {"message": "从 MediaWiki API 自动读取"}, + "app.skin-site.admin.wiki.api-url": {"message": "api.php 地址"}, + "app.skin-site.admin.wiki.bot-username": {"message": "Bot 用户名(选填)"}, + "app.skin-site.admin.wiki.bot-password": {"message": "Bot 密码(选填)"}, + "app.skin-site.admin.wiki.credentials-hint": {"message": "凭据只存在于本次后台任务的内存中,携带凭据时必须使用 HTTPS。"}, + "app.skin-site.admin.wiki.allow-private": {"message": "明确允许访问内网来源"}, + "app.skin-site.admin.wiki.allow-private-hint": {"message": "内网来源可能访问皮肤站所在网络中的服务,请只对可信地址启用。"}, + "app.skin-site.admin.wiki.start-auto": {"message": "创建自动迁移任务"}, + "app.skin-site.admin.wiki.xml-title": {"message": "导入 MediaWiki XML"}, + "app.skin-site.admin.wiki.xml-file": {"message": "导出文件"}, + "app.skin-site.admin.wiki.start-xml": {"message": "上传并建立暂存任务"}, + "app.skin-site.admin.wiki.stale-rules": {"message": "疑似过时页面筛选规则"}, + "app.skin-site.admin.wiki.stale-days": {"message": "未更新天数"}, + "app.skin-site.admin.wiki.maintainer-window": {"message": "维护者统计天数"}, + "app.skin-site.admin.wiki.max-maintainers": {"message": "维护者人数不超过"}, + "app.skin-site.admin.wiki.stale-markers": {"message": "过时分类、模板或文字标记"}, + "app.skin-site.admin.wiki.stale-markers-hint": {"message": "使用逗号分隔"}, + "app.skin-site.admin.wiki.jobs": {"message": "迁移任务"}, + "app.skin-site.admin.wiki.refresh-jobs": {"message": "刷新"}, + "app.skin-site.admin.wiki.jobs-empty": {"message": "还没有迁移任务。"}, + "app.skin-site.admin.wiki.reviewed-count": {"message": "已审核 {reviewed} / {total}"}, + "app.skin-site.admin.wiki.imported-excluded": {"message": "导入 {imported} · 排除 {excluded}"}, + "app.skin-site.admin.wiki.view-job": {"message": "查看任务"}, + "app.skin-site.admin.wiki.delete-job": {"message": "删除迁移记录"}, + "app.skin-site.admin.wiki.confirm-delete-job": {"message": "确认删除迁移任务 #{id}?此操作不可恢复。"}, + "app.skin-site.admin.wiki.candidates": {"message": "候选页面"}, + "app.skin-site.admin.wiki.candidates-empty": {"message": "选择任务后查看其页面。"}, + "app.skin-site.admin.wiki.last-updated": {"message": "更新于 {date}"}, + "app.skin-site.admin.wiki.maintainer-count": {"message": "{count} 位维护者"}, + "app.skin-site.admin.wiki.conflict": {"message": "标题冲突"}, + "app.skin-site.admin.wiki.target-title": {"message": "目标标题"}, + "app.skin-site.admin.wiki.overwrite": {"message": "覆盖"}, + "app.skin-site.admin.wiki.import": {"message": "导入"}, + "app.skin-site.admin.wiki.exclude": {"message": "排除"}, + "app.skin-site.admin.wiki.publish": {"message": "整批发布"}, + "app.skin-site.admin.wiki.rollback": {"message": "回滚"}, + "app.skin-site.admin.wiki.confirm-rollback": {"message": "确认回滚本批迁移?已发布的页面将被还原。"}, + "app.skin-site.admin.wiki.state-importing": {"message": "正在读取"}, + "app.skin-site.admin.wiki.state-review": {"message": "等待审核"}, + "app.skin-site.admin.wiki.state-publishing": {"message": "正在发布"}, + "app.skin-site.admin.wiki.state-published": {"message": "已发布"}, + "app.skin-site.admin.wiki.state-failed": {"message": "失败"}, + "app.skin-site.admin.wiki.state-rolled-back": {"message": "已回滚"}, + "app.skin-site.admin.wiki.decision-pending": {"message": "待审核"}, + "app.skin-site.admin.wiki.decision-import": {"message": "导入"}, + "app.skin-site.admin.wiki.decision-exclude": {"message": "排除"}, + "app.skin-site.admin.wiki.migration-started": {"message": "迁移任务已创建。"}, + "app.skin-site.admin.wiki.xml-staged": {"message": "XML 已解析并进入逐页审核阶段。"}, + "app.skin-site.admin.wiki.published": {"message": "已发布 {imported} 个页面,覆盖 {overwritten} 个。"}, + "app.skin-site.admin.wiki.rolled-back": {"message": "迁移批次已安全回滚。"}, + "app.skin-site.admin.wiki.job-deleted": {"message": "迁移记录已删除。"}, + "app.skin-site.admin.wiki.select-xml": {"message": "请先选择 MediaWiki XML 文件。"} } From 71b8d111fb8b6780b979c0cf97fa8532e5f2aff6 Mon Sep 17 00:00:00 2001 From: Ap_Tx <3045929398@qq.com> Date: Sun, 18 Oct 2026 13:13:03 +0800 Subject: [PATCH 16/16] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E7=9A=AE?= =?UTF-8?q?=E8=82=A4=E7=AB=99=E7=BB=B4=E5=9F=BA=E5=8F=AA=E8=AF=BB=E6=B5=8F?= =?UTF-8?q?=E8=A7=88=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 WikiView 页面:输入标题加载页面,支持内部词条跳转与返回栈 - 新增 WikiNodeRenderer 递归渲染器:标题、段落、加粗斜体下划线删除线、高亮、行内代码、黑幕、分隔线、内外链、有序无序列表、引用、代码块、表格、附件、提示块 - 新增 WikiNotice 提示块与 WikiAttachmentNode 附件组件,图片支持点击查看大图 - 补充维基节点与附件类型定义,新增附件查询接口与附件内容 URL 拼接 - 主页导航 Wiki 卡片改为站内打开 - 中英文各补 10 个文案 --- .../components/ui/skin-site/SkinSiteHome.vue | 4 + .../ui/skin-site/SkinSiteNavCards.vue | 19 +- .../src/components/ui/skin-site/WikiView.vue | 192 ++++++++++++++++++ .../ui/skin-site/wiki/WikiAttachmentNode.vue | 74 +++++++ .../ui/skin-site/wiki/WikiNodeRenderer.vue | 185 +++++++++++++++++ .../ui/skin-site/wiki/WikiNotice.vue | 32 +++ .../ui/skin-site/wiki/wiki-color.ts | 18 ++ .../app-frontend/src/helpers/skin-site-api.ts | 48 ++++- .../app-frontend/src/locales/en-US/index.json | 12 +- .../app-frontend/src/locales/zh-CN/index.json | 12 +- 10 files changed, 590 insertions(+), 6 deletions(-) create mode 100644 apps/app-frontend/src/components/ui/skin-site/WikiView.vue create mode 100644 apps/app-frontend/src/components/ui/skin-site/wiki/WikiAttachmentNode.vue create mode 100644 apps/app-frontend/src/components/ui/skin-site/wiki/WikiNodeRenderer.vue create mode 100644 apps/app-frontend/src/components/ui/skin-site/wiki/WikiNotice.vue create mode 100644 apps/app-frontend/src/components/ui/skin-site/wiki/wiki-color.ts diff --git a/apps/app-frontend/src/components/ui/skin-site/SkinSiteHome.vue b/apps/app-frontend/src/components/ui/skin-site/SkinSiteHome.vue index 4d626df..23fe86a 100644 --- a/apps/app-frontend/src/components/ui/skin-site/SkinSiteHome.vue +++ b/apps/app-frontend/src/components/ui/skin-site/SkinSiteHome.vue @@ -18,6 +18,7 @@ import SkinSiteNavCards from './SkinSiteNavCards.vue' import SkinSiteProfileModal from './SkinSiteProfileModal.vue' import SkinSiteProfileView from './SkinSiteProfileView.vue' import SkinSiteReviewView from './SkinSiteReviewView.vue' +import WikiView from './WikiView.vue' const { formatMessage } = useVIntl() const { addNotification } = injectNotificationManager() @@ -45,6 +46,7 @@ const view = ref< | 'bug-detail' | 'bug-notifications' | 'admin' + | 'wiki' >('home') const selectedBugId = ref(null) @@ -98,6 +100,8 @@ async function copyAddress() { @open="openBug" /> + + () +const emit = defineEmits<{ + (e: 'navigate', view: 'downloads' | 'review' | 'bugs' | 'map' | 'wiki'): void +}>() const messages = defineMessages({ title: { id: 'app.skin-site.home.nav.title', defaultMessage: 'Skin site' }, @@ -104,8 +106,19 @@ const items: NavItem[] = [ ] function open(item: NavItem) { - if (item.id === 'download' || item.id === 'review' || item.id === 'bugs' || item.id === 'map') { - emit('navigate', item.id === 'download' ? 'downloads' : item.id) + if ( + item.id === 'download' || + item.id === 'review' || + item.id === 'bugs' || + item.id === 'map' || + item.id === 'wiki' + ) { + emit( + 'navigate', + item.id === 'download' + ? 'downloads' + : (item.id as 'review' | 'bugs' | 'map' | 'wiki'), + ) return } void import('@tauri-apps/plugin-opener').then(({ openUrl }) => openUrl(item.href)) diff --git a/apps/app-frontend/src/components/ui/skin-site/WikiView.vue b/apps/app-frontend/src/components/ui/skin-site/WikiView.vue new file mode 100644 index 0000000..4ad2544 --- /dev/null +++ b/apps/app-frontend/src/components/ui/skin-site/WikiView.vue @@ -0,0 +1,192 @@ + + + diff --git a/apps/app-frontend/src/components/ui/skin-site/wiki/WikiAttachmentNode.vue b/apps/app-frontend/src/components/ui/skin-site/wiki/WikiAttachmentNode.vue new file mode 100644 index 0000000..4f73b5a --- /dev/null +++ b/apps/app-frontend/src/components/ui/skin-site/wiki/WikiAttachmentNode.vue @@ -0,0 +1,74 @@ + + + diff --git a/apps/app-frontend/src/components/ui/skin-site/wiki/WikiNodeRenderer.vue b/apps/app-frontend/src/components/ui/skin-site/wiki/WikiNodeRenderer.vue new file mode 100644 index 0000000..0c5288f --- /dev/null +++ b/apps/app-frontend/src/components/ui/skin-site/wiki/WikiNodeRenderer.vue @@ -0,0 +1,185 @@ + + + + + diff --git a/apps/app-frontend/src/components/ui/skin-site/wiki/WikiNotice.vue b/apps/app-frontend/src/components/ui/skin-site/wiki/WikiNotice.vue new file mode 100644 index 0000000..95ae146 --- /dev/null +++ b/apps/app-frontend/src/components/ui/skin-site/wiki/WikiNotice.vue @@ -0,0 +1,32 @@ + + + diff --git a/apps/app-frontend/src/components/ui/skin-site/wiki/wiki-color.ts b/apps/app-frontend/src/components/ui/skin-site/wiki/wiki-color.ts new file mode 100644 index 0000000..e64cb4e --- /dev/null +++ b/apps/app-frontend/src/components/ui/skin-site/wiki/wiki-color.ts @@ -0,0 +1,18 @@ +export function safeWikiColor(value: unknown): string | null { + if (typeof value !== 'string') return null + const color = value.trim() + return color.length <= 80 && + /^(?:[a-z]{1,20}|#[\da-f]{3}|#[\da-f]{4}|#[\da-f]{6}|#[\da-f]{8}|rgba?\([\d\s.,%]+\))$/i.test(color) + ? color + : null +} + +export function safeExternalUrl(url?: string | null): string { + if (!url) return '#' + try { + const parsed = new URL(url) + return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed.toString() : '#' + } catch { + return '#' + } +} diff --git a/apps/app-frontend/src/helpers/skin-site-api.ts b/apps/app-frontend/src/helpers/skin-site-api.ts index c2cf8d8..c3bba36 100644 --- a/apps/app-frontend/src/helpers/skin-site-api.ts +++ b/apps/app-frontend/src/helpers/skin-site-api.ts @@ -1740,11 +1740,45 @@ export function skinSiteDeleteModRuntime(id: number): Promise> { return skinSiteRequest('wiki/admin/migration/' + jobId, { method: 'DELETE' }) } + +export function skinSiteWikiAttachment( + name: string, +): Promise> { + return skinSiteRequest( + 'wiki/attachment?name=' + encodeURIComponent(name), + ) +} + +export function skinSiteWikiAttachmentUrl(id: number): string { + return `${SKIN_SITE_ORIGIN}${REQUEST_TYPE_PATH.starlight}/wiki/attachment/${id}/content` +} diff --git a/apps/app-frontend/src/locales/en-US/index.json b/apps/app-frontend/src/locales/en-US/index.json index 571bc67..9a3ae7f 100644 --- a/apps/app-frontend/src/locales/en-US/index.json +++ b/apps/app-frontend/src/locales/en-US/index.json @@ -12424,5 +12424,15 @@ "app.skin-site.admin.wiki.published": {"message": "Published {imported} pages, overwrote {overwritten}."}, "app.skin-site.admin.wiki.rolled-back": {"message": "Migration batch rolled back."}, "app.skin-site.admin.wiki.job-deleted": {"message": "Migration record deleted."}, - "app.skin-site.admin.wiki.select-xml": {"message": "Choose a MediaWiki XML file first."} + "app.skin-site.admin.wiki.select-xml": {"message": "Choose a MediaWiki XML file first."}, + "app.skin-site.wiki.title": {"message": "StarLight Wiki"}, + "app.skin-site.wiki.search-placeholder": {"message": "Enter a page title"}, + "app.skin-site.wiki.open": {"message": "Open"}, + "app.skin-site.wiki.loading": {"message": "Loading…"}, + "app.skin-site.wiki.not-found": {"message": "Page not found."}, + "app.skin-site.wiki.empty": {"message": "Enter a page title above to start reading."}, + "app.skin-site.wiki.updated-at": {"message": "Last edited {date} by {author}"}, + "app.skin-site.wiki.revision": {"message": "Revision #{id}"}, + "app.skin-site.wiki.categories": {"message": "Categories"}, + "app.skin-site.wiki.back": {"message": "Back"} } diff --git a/apps/app-frontend/src/locales/zh-CN/index.json b/apps/app-frontend/src/locales/zh-CN/index.json index be7e653..0e33964 100644 --- a/apps/app-frontend/src/locales/zh-CN/index.json +++ b/apps/app-frontend/src/locales/zh-CN/index.json @@ -12255,5 +12255,15 @@ "app.skin-site.admin.wiki.published": {"message": "已发布 {imported} 个页面,覆盖 {overwritten} 个。"}, "app.skin-site.admin.wiki.rolled-back": {"message": "迁移批次已安全回滚。"}, "app.skin-site.admin.wiki.job-deleted": {"message": "迁移记录已删除。"}, - "app.skin-site.admin.wiki.select-xml": {"message": "请先选择 MediaWiki XML 文件。"} + "app.skin-site.admin.wiki.select-xml": {"message": "请先选择 MediaWiki XML 文件。"}, + "app.skin-site.wiki.title": {"message": "StarLight 维基"}, + "app.skin-site.wiki.search-placeholder": {"message": "输入页面标题"}, + "app.skin-site.wiki.open": {"message": "打开"}, + "app.skin-site.wiki.loading": {"message": "加载中…"}, + "app.skin-site.wiki.not-found": {"message": "页面不存在。"}, + "app.skin-site.wiki.empty": {"message": "在上方输入页面标题开始阅读。"}, + "app.skin-site.wiki.updated-at": {"message": "最后编辑于 {date},编辑者 {author}"}, + "app.skin-site.wiki.revision": {"message": "修订 #{id}"}, + "app.skin-site.wiki.categories": {"message": "分类"}, + "app.skin-site.wiki.back": {"message": "返回"} }