diff options
Diffstat (limited to 'packages')
| -rw-r--r-- | packages/backend/src/server/api/endpoints/admin/show-user.ts | 39 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkButton.vue | 23 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkNumber.vue | 7 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkRolePreview.vue | 2 | ||||
| -rw-r--r-- | packages/frontend/src/pages/admin-user.vue | 187 | ||||
| -rw-r--r-- | packages/misskey-js/src/autogen/types.ts | 8 |
6 files changed, 193 insertions, 73 deletions
diff --git a/packages/backend/src/server/api/endpoints/admin/show-user.ts b/packages/backend/src/server/api/endpoints/admin/show-user.ts index 6dbfbf9d9a..1579719246 100644 --- a/packages/backend/src/server/api/endpoints/admin/show-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/show-user.ts @@ -12,6 +12,7 @@ import { RoleEntityService } from '@/core/entities/RoleEntityService.js'; import { IdService } from '@/core/IdService.js'; import { notificationRecieveConfig } from '@/models/json-schema/user.js'; import { isSystemAccount } from '@/misc/is-system-account.js'; +import { CacheService } from '@/core/CacheService.js'; export const meta = { tags: ['admin'], @@ -186,6 +187,36 @@ export const meta = { }, }, }, + followStats: { + type: 'object', + optional: false, nullable: false, + properties: { + totalFollowing: { + type: 'number', + optional: false, nullable: false, + }, + totalFollowers: { + type: 'number', + optional: false, nullable: false, + }, + localFollowing: { + type: 'number', + optional: false, nullable: false, + }, + localFollowers: { + type: 'number', + optional: false, nullable: false, + }, + remoteFollowing: { + type: 'number', + optional: false, nullable: false, + }, + remoteFollowers: { + type: 'number', + optional: false, nullable: false, + }, + }, + }, }, }, } as const; @@ -213,6 +244,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- private roleService: RoleService, private roleEntityService: RoleEntityService, private idService: IdService, + private readonly cacheService: CacheService, ) { super(meta, paramDef, async (ps, me) => { const [user, profile] = await Promise.all([ @@ -237,6 +269,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- const roleAssigns = await this.roleService.getUserAssigns(user.id); const roles = await this.roleService.getUserRoles(user.id); + const followStats = await this.cacheService.getFollowStats(user.id); + return { email: profile.email, emailVerified: profile.emailVerified, @@ -269,6 +303,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- expiresAt: a.expiresAt ? a.expiresAt.toISOString() : null, roleId: a.roleId, })), + followStats: { + ...followStats, + totalFollowers: Math.max(user.followersCount, followStats.localFollowers + followStats.remoteFollowers), + totalFollowing: Math.max(user.followingCount, followStats.localFollowing + followStats.remoteFollowing), + }, }; }); } diff --git a/packages/frontend/src/components/MkButton.vue b/packages/frontend/src/components/MkButton.vue index d37f7f39f8..53453be2c1 100644 --- a/packages/frontend/src/components/MkButton.vue +++ b/packages/frontend/src/components/MkButton.vue @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only <button v-if="!link" ref="el" class="_button" - :class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike, [$style.iconOnly]: iconOnly, [$style.wait]: wait }]" + :class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.accent]: accent, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike, [$style.iconOnly]: iconOnly, [$style.wait]: wait }]" :type="type" :name="name" :value="value" @@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only </button> <MkA v-else class="_button" - :class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike, [$style.iconOnly]: iconOnly, [$style.wait]: wait }]" + :class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.accent]: accent, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike, [$style.iconOnly]: iconOnly, [$style.wait]: wait }]" :to="to ?? '#'" :behavior="linkBehavior" @mousedown="onMousedown" @@ -48,6 +48,7 @@ const props = defineProps<{ linkBehavior?: null | 'window' | 'browser'; autofocus?: boolean; wait?: boolean; + accent?: boolean; danger?: boolean; full?: boolean; small?: boolean; @@ -234,6 +235,24 @@ function onMousedown(evt: MouseEvent): void { } } + &.accent { + font-weight: bold; + color: var(--MI_THEME-accent); + + &.primary { + color: #fff; + background: var(--MI_THEME-accent); + + &:not(:disabled):hover { + background: hsl(from var(--MI_THEME-accent) h s calc(l + 10)); + } + + &:not(:disabled):active { + background: hsl(from var(--MI_THEME-accent) h s calc(l - 10)); + } + } + } + &.danger { font-weight: bold; color: var(--MI_THEME-error); diff --git a/packages/frontend/src/components/MkNumber.vue b/packages/frontend/src/components/MkNumber.vue index a278205b61..7c2393bf5c 100644 --- a/packages/frontend/src/components/MkNumber.vue +++ b/packages/frontend/src/components/MkNumber.vue @@ -10,6 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { reactive, watch } from 'vue'; import number from '@/filters/number.js'; +import { prefer } from '@/preferences'; const props = defineProps<{ value: number; @@ -36,7 +37,11 @@ watch(() => props.value, (to, from) => { } } - window.requestAnimationFrame(step); + if (prefer.s.animation) { + window.requestAnimationFrame(step); + } else { + tweened.number = to; + } }, { immediate: true, }); diff --git a/packages/frontend/src/components/MkRolePreview.vue b/packages/frontend/src/components/MkRolePreview.vue index 3f14c5b5e0..15149b3f0c 100644 --- a/packages/frontend/src/components/MkRolePreview.vue +++ b/packages/frontend/src/components/MkRolePreview.vue @@ -41,7 +41,7 @@ import { i18n } from '@/i18n.js'; const props = withDefaults(defineProps<{ role: Misskey.entities.Role; forModeration: boolean; - detailed: boolean; + detailed?: boolean; }>(), { detailed: true, }); diff --git a/packages/frontend/src/pages/admin-user.vue b/packages/frontend/src/pages/admin-user.vue index efe547ff21..d3c0de3040 100644 --- a/packages/frontend/src/pages/admin-user.vue +++ b/packages/frontend/src/pages/admin-user.vue @@ -7,38 +7,49 @@ SPDX-License-Identifier: AGPL-3.0-only <PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"> <div class="_spacer" style="--MI_SPACER-w: 600px; --MI_SPACER-min: 16px; --MI_SPACER-max: 32px;"> <FormSuspense :p="init"> - <div v-if="tab === 'overview'" class="_gaps_m"> - <div class="aeakzknw"> + <div v-if="tab === 'overview'" class="_gaps"> + <div v-if="user" class="aeakzknw"> <MkAvatar class="avatar" :user="user" indicator link preview/> <div class="body"> <span class="name"><MkUserName class="name" :user="user"/></span> - <span class="sub"><span class="acct _monospace">@{{ acct(user) }}</span></span> + <span class="sub"> + <span class="acct _monospace">@{{ acct(user) }}</span> + <button v-tooltip="i18n.ts.copy" class="_textButton" style="margin-left: 0.5em;" @click="copyToClipboard('@' + acct(user))"><i class="ti ti-copy"></i></button> + </span> + <span class="sub"> + <span class="_monospace">{{ user.id }}</span> + <button v-tooltip="i18n.ts.copy" class="_textButton" style="margin-left: 0.5em;" @click="copyToClipboard(user.id)"><i class="ti ti-copy"></i></button> + </span> <span class="state"> <span v-if="!approved" class="silenced">{{ i18n.ts.notApproved }}</span> <span v-if="approved && !user.host" class="moderator">{{ i18n.ts.approved }}</span> - <span v-if="suspended" class="suspended">Suspended</span> - <span v-if="silenced" class="silenced">Silenced</span> - <span v-if="moderator" class="moderator">Moderator</span> + <span v-if="suspended" class="suspended">{{ i18n.ts.suspended }}</span> + <span v-if="silenced" class="silenced">{{ i18n.ts.silenced }}</span> + <span v-if="moderator" class="moderator">{{ i18n.ts.moderator }}</span> </span> </div> </div> <MkInfo v-if="isSystem">{{ i18n.ts.isSystemAccount }}</MkInfo> - <FormLink v-if="user.host" :to="`/instance-info/${user.host}`">{{ i18n.ts.instanceInfo }}</FormLink> - - <div style="display: flex; flex-direction: column; gap: 1em;"> - <MkKeyValue :copy="user.id" oneline> - <template #key>ID</template> - <template #value><span class="_monospace">{{ user.id }}</span></template> - </MkKeyValue> - <!-- 要る? - <MkKeyValue v-if="ips.length > 0" :copy="user.id" oneline> - <template #key>IP (recent)</template> - <template #value><span class="_monospace">{{ ips[0].ip }}</span></template> - </MkKeyValue> - --> - <template v-if="!isSystem"> + <MkFolder v-if="!isSystem"> + <template #icon><i class="ph-list-bullets ph-bold ph-lg"></i></template> + <template #label>{{ i18n.ts.details }}</template> + <div style="display: flex; flex-direction: column; gap: 1em;"> + <MkKeyValue v-if="user" :copy="user.id" oneline> + <template #key>{{ i18n.ts.id }}</template> + <template #value><span class="_monospace">{{ user.id }}</span></template> + </MkKeyValue> + <MkKeyValue v-if="user" :copy="'@' + acct(user)" oneline> + <template #key>{{ i18n.ts.username }}</template> + <template #value><span class="_monospace">@{{ acct(user) }}</span></template> + </MkKeyValue> + <!-- 要る? + <MkKeyValue v-if="ips.length > 0" :copy="user.id" oneline> + <template #key>IP (recent)</template> + <template #value><span class="_monospace">{{ ips[0].ip }}</span></template> + </MkKeyValue> + --> <MkKeyValue oneline> <template #key>{{ i18n.ts.createdAt }}</template> <template #value><span class="_monospace"><MkTime :time="user.createdAt" :mode="'detail'"/></span></template> @@ -51,16 +62,64 @@ SPDX-License-Identifier: AGPL-3.0-only <template #key>{{ i18n.ts.email }}</template> <template #value><span class="_monospace">{{ info.email }}</span></template> </MkKeyValue> - </template> - </div> + <MkKeyValue v-if="info" oneline> + <template #key>{{ i18n.ts.totalFollowers }}</template> + <template #value><span class="_monospace"><MkNumber :value="info.followStats.totalFollowers"></MkNumber></span></template> + </MkKeyValue> + <MkKeyValue v-if="info" oneline> + <template #key>{{ i18n.ts.totalFollowing }}</template> + <template #value><span class="_monospace"><MkNumber :value="info.followStats.totalFollowing"></MkNumber></span></template> + </MkKeyValue> + <MkKeyValue v-if="info" oneline> + <template #key>{{ i18n.ts.remoteFollowers }}</template> + <template #value><span class="_monospace"><MkNumber :value="info.followStats.remoteFollowers"></MkNumber></span></template> + </MkKeyValue> + <MkKeyValue v-if="info" oneline> + <template #key>{{ i18n.ts.remoteFollowing }}</template> + <template #value><span class="_monospace"><MkNumber :value="info.followStats.remoteFollowing"></MkNumber></span></template> + </MkKeyValue> + <MkKeyValue v-if="info" oneline> + <template #key>{{ i18n.ts.localFollowers }}</template> + <template #value><span class="_monospace"><MkNumber :value="info.followStats.localFollowers"></MkNumber></span></template> + </MkKeyValue> + <MkKeyValue v-if="info" oneline> + <template #key>{{ i18n.ts.localFollowing }}</template> + <template #value><span class="_monospace"><MkNumber :value="info.followStats.localFollowing"></MkNumber></span></template> + </MkKeyValue> + </div> + </MkFolder> - <MkTextarea v-model="moderationNote" manualSave @update:modelValue="onModerationNoteChanged"> + <MkFolder v-if="info"> + <template #icon><i class="ph-scroll ph-bold ph-lg"></i></template> + <template #label>{{ i18n.ts._role.policies }}</template> + <div class="_gaps"> + <div v-for="policy in Object.keys(info.policies)" :key="policy"> + {{ policy }} ... {{ info.policies[policy] }} + </div> + </div> + </MkFolder> + + <MkFolder v-if="iAmAdmin && ips && ips.length > 0"> + <template #icon><i class="ph-network ph-bold ph-lg"></i></template> + <template #label>{{ i18n.ts.ip }}</template> + <MkInfo>{{ i18n.ts.ipTip }}</MkInfo> + <div v-for="record in ips" :key="record.ip" class="_monospace" :class="$style.ip" style="margin: 1em 0;"> + <span class="date">{{ record.createdAt }}</span> + <span class="ip">{{ record.ip }}</span> + </div> + </MkFolder> + + <MkFolder v-if="iAmModerator" :defaultOpen="moderationNote.length > 0"> + <template #icon><i class="ph-stamp ph-bold ph-lg"></i></template> <template #label>{{ i18n.ts.moderationNote }}</template> - <template #caption>{{ i18n.ts.moderationNoteDescription }}</template> - </MkTextarea> + <MkTextarea v-model="moderationNote" manualSave @update:modelValue="onModerationNoteChanged"> + <template #label>{{ i18n.ts.moderationNote }}</template> + <template #caption>{{ i18n.ts.moderationNoteDescription }}</template> + </MkTextarea> + </MkFolder> - <FormSection v-if="user.host"> - <template #label>ActivityPub</template> + <FormSection v-if="user?.host"> + <template #label>{{ i18n.ts.activityPub }}</template> <div class="_gaps_m"> <div style="display: flex; flex-direction: column; gap: 1em;"> @@ -73,12 +132,10 @@ SPDX-License-Identifier: AGPL-3.0-only <template #value><MkTime mode="detail" :time="user.lastFetchedAt"/></template> </MkKeyValue> </div> - - <MkButton @click="updateRemoteUser"><i class="ti ti-refresh"></i> {{ i18n.ts.updateRemoteUser }}</MkButton> </div> </FormSection> - <FormSection v-if="!isSystem"> + <FormSection v-if="!isSystem && user && iAmModerator"> <div class="_gaps"> <MkSwitch v-model="silenced" @update:modelValue="toggleSilence">{{ i18n.ts.silence }}</MkSwitch> <MkSwitch v-if="!isSystem" v-model="suspended" @update:modelValue="toggleSuspend">{{ i18n.ts.suspend }}</MkSwitch> @@ -90,58 +147,40 @@ SPDX-License-Identifier: AGPL-3.0-only <template #caption>{{ i18n.ts.mandatoryCWDescription }}</template> </MkInput> - <div> - <MkButton v-if="user.host == null && !isSystem" inline style="margin-right: 8px;" @click="resetPassword"><i class="ti ti-key"></i> {{ i18n.ts.resetPassword }}</MkButton> + <div :class="$style.buttonStrip"> + <MkButton v-if="user.host != null" inline @click="updateRemoteUser"><i class="ph-cloud-arrow-down ph-bold ph-lg"></i> {{ i18n.ts.updateRemoteUser }}</MkButton> + <MkButton v-if="user.host == null" inline accent @click="resetPassword"><i class="ph-password ph-bold ph-lg"></i> {{ i18n.ts.resetPassword }}</MkButton> + <MkButton inline accent @click="unsetUserAvatar"><i class="ph-camera-slash ph-bold ph-lg"></i> {{ i18n.ts.unsetUserAvatar }}</MkButton> + <MkButton inline accent @click="unsetUserBanner"><i class="ph-image-broken ph-bold ph-lg"></i> {{ i18n.ts.unsetUserBanner }}</MkButton> + <MkButton inline danger @click="deleteAllFiles"><i class="ph-trash ph-bold ph-lg"></i> {{ i18n.ts.deleteAllFiles }}</MkButton> + <MkButton v-if="iAmAdmin" inline danger @click="deleteAccount"><i class="ph-skull ph-bold ph-lg"></i> {{ i18n.ts.deleteAccount }}</MkButton> </div> - - <MkFolder> - <template #icon><i class="ph-scroll ph-bold ph-lg"></i></template> - <template #label>{{ i18n.ts._role.policies }}</template> - <div class="_gaps"> - <div v-for="policy in Object.keys(info.policies)" :key="policy"> - {{ policy }} ... {{ info.policies[policy] }} - </div> - </div> - </MkFolder> - - <MkFolder> - <template #icon><i class="ti ti-password"></i></template> - <template #label>IP</template> - <MkInfo v-if="!iAmAdmin" warn>{{ i18n.ts.requireAdminForView }}</MkInfo> - <!-- TODO translate --> - <MkInfo v-else>The date is the IP address was first acknowledged.</MkInfo> - <template v-if="iAmAdmin && ips"> - <div v-for="record in ips" :key="record.ip" class="_monospace" :class="$style.ip" style="margin: 1em 0;"> - <span class="date">{{ record.createdAt }}</span> - <span class="ip">{{ record.ip }}</span> - </div> - </template> - </MkFolder> - - <div> - <MkButton v-if="iAmModerator" inline danger style="margin-right: 8px;" @click="unsetUserAvatar"><i class="ti ti-user-circle"></i> {{ i18n.ts.unsetUserAvatar }}</MkButton> - <MkButton v-if="iAmModerator" inline danger style="margin-right: 8px;" @click="unsetUserBanner"><i class="ti ti-photo"></i> {{ i18n.ts.unsetUserBanner }}</MkButton> - <MkButton v-if="iAmModerator" inline danger @click="deleteAllFiles"><i class="ph-cloud ph-bold ph-lg"></i> {{ i18n.ts.deleteAllFiles }}</MkButton> - </div> - <MkButton v-if="$i.isAdmin && !isSystem" inline danger @click="deleteAccount">{{ i18n.ts.deleteAccount }}</MkButton> </div> </FormSection> </div> <div v-else-if="tab === 'roles'" class="_gaps"> - <MkButton v-if="user.host == null" primary rounded @click="assignRole"><i class="ti ti-plus"></i> {{ i18n.ts.assign }}</MkButton> + <MkButton primary rounded @click="assignRole"><i class="ti ti-plus"></i> {{ i18n.ts.assign }}</MkButton> <div v-for="role in info.roles" :key="role.id"> <div :class="$style.roleItemMain"> <MkRolePreview :class="$style.role" :role="role" :forModeration="true"/> - <button class="_button" @click="toggleRoleItem(role)"><i class="ti ti-chevron-down"></i></button> - <button v-if="role.target === 'manual'" class="_button" :class="$style.roleUnassign" @click="unassignRole(role, $event)"><i class="ti ti-x"></i></button> + <button class="_button" @click="toggleRoleItem(role)"> + <i v-if="!expandedRoles.includes(role.id)" class="ti ti-chevron-down"></i> + <i v-if="expandedRoles.includes(role.id)" class="ti ti-chevron-left"></i> + </button> + <button v-if="role.target === 'manual' || info.roleAssigns.some(a => a.roleId === role.id)" class="_button" :class="$style.roleUnassign" @click="unassignRole(role, $event)"><i class="ti ti-x"></i></button> <button v-else class="_button" :class="$style.roleUnassign" disabled><i class="ti ti-ban"></i></button> </div> <div v-if="expandedRoles.includes(role.id)" :class="$style.roleItemSub"> - <div>Assigned: <MkTime :time="info.roleAssigns.find(a => a.roleId === role.id).createdAt" mode="detail"/></div> - <div v-if="info.roleAssigns.find(a => a.roleId === role.id).expiresAt">Period: {{ new Date(info.roleAssigns.find(a => a.roleId === role.id).expiresAt).toLocaleString() }}</div> - <div v-else>Period: {{ i18n.ts.indefinitely }}</div> + <template v-if="info.roleAssigns.some(a => a.roleId === role.id)"> + <div>{{ i18n.ts.roleAssigned }}: <MkTime :time="info.roleAssigns.find(a => a.roleId === role.id).createdAt" mode="detail"/></div> + <div v-if="info.roleAssigns.find(a => a.roleId === role.id).expiresAt">{{ i18n.ts.rolePeriod }}: {{ new Date(info.roleAssigns.find(a => a.roleId === role.id).expiresAt).toLocaleString() }}</div> + <div v-else>{{ i18n.ts.rolePeriod }}: {{ i18n.ts.indefinitely }}</div> + </template> + <template v-else> + <div>{{ i18n.ts.roleAssigned }}: {{ i18n.ts.roleAutomatic }}</div> + </template> </div> </div> </div> @@ -231,6 +270,8 @@ import { iAmAdmin, $i, iAmModerator } from '@/i.js'; import MkRolePreview from '@/components/MkRolePreview.vue'; import MkPagination from '@/components/MkPagination.vue'; import MkInput from '@/components/MkInput.vue'; +import MkNumber from '@/components/MkNumber.vue'; +import { copyToClipboard } from '@/utility/copy-to-clipboard'; const props = withDefaults(defineProps<{ userId: string; @@ -740,4 +781,12 @@ definePage(() => ({ border-radius: var(--MI-radius-sm); cursor: pointer; } + +.buttonStrip { + margin: calc(var(--MI-margin) / 2 * -1); + + >* { + margin: calc(var(--MI-margin) / 2); + } +} </style> diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index 778b00c66d..dee803a9e6 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -11222,6 +11222,14 @@ export type operations = { expiresAt: string | null; roleId: string; })[]; + followStats: { + totalFollowing: number; + totalFollowers: number; + localFollowing: number; + localFollowers: number; + remoteFollowing: number; + remoteFollowers: number; + }; }; }; }; |