feat: 通報の即時解決機能を追加 (#113)
* feat: 通報の即時解決機能を追加 * fix: 条件変更時に有効期限を変更していないのに勝手に更新される問題を修正 * fix: 条件のパターンの削除ができない問題を修正 * fix: リソルバーの通報を解決する判定基準が間違っていたのを修正 * fix: 変更する変数が間違っていたのを修正 * fix: getUTCMonthはゼロ始まりかも * enhance: Storybookのストーリーを作成 * fix: 色々修正 * fix: 型エラーを修正 * [ci skip] Update CHANGELOG.md * [ci skip] Update CHANGELOG.md * Update CHANGELOG.md * リファクタリング * refactor: 型定義をよりよくした * refactor: beforeExpiresAtの初期値はundefinedの方がいい * refactor: 変数の名前を変更 * Fix: リモートサーバーから転送された通報も対象に追加 * Update CHANGELOG.md * take review --------- Co-authored-by: Chocolate Pie <106949016+chocolate-pie@users.noreply.github.com>
This commit is contained in:
		| @@ -0,0 +1,39 @@ | ||||
| /* eslint-disable @typescript-eslint/explicit-function-return-type */ | ||||
| import MkAbuseReportResolver from './MkAbuseReportResolver.vue'; | ||||
| import type { StoryObj } from '@storybook/vue3'; | ||||
| export const Default = { | ||||
| 	render(args) { | ||||
| 		return { | ||||
| 			components: { | ||||
| 				MkAbuseReportResolver, | ||||
| 			}, | ||||
| 			setup() { | ||||
| 				return { | ||||
| 					args, | ||||
| 				}; | ||||
| 			}, | ||||
| 			computed: { | ||||
| 				props() { | ||||
| 					return { | ||||
| 						...this.args, | ||||
| 					}; | ||||
| 				}, | ||||
| 			}, | ||||
| 			template: '<MkAbuseReportResolver v-bind="props" />', | ||||
| 		}; | ||||
| 	}, | ||||
| 	args: { | ||||
| 		editable: true, | ||||
| 		data: { | ||||
| 			name: 'Sample', | ||||
| 			targetUserPattern: '^.*@.+$', | ||||
| 			reporterPattern: null, | ||||
| 			reportContentPattern: null, | ||||
| 			expiresAt: 'indefinitely', | ||||
| 			forward: false, | ||||
| 		}, | ||||
| 	}, | ||||
| 	parameters: { | ||||
| 		layout: 'centered', | ||||
| 	}, | ||||
| } satisfies StoryObj<typeof MkAbuseReportResolver>; | ||||
							
								
								
									
										155
									
								
								packages/frontend/src/components/MkAbuseReportResolver.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										155
									
								
								packages/frontend/src/components/MkAbuseReportResolver.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,155 @@ | ||||
| <template> | ||||
| <div class="_gaps dslkjkwejflew"> | ||||
| 	<MkInput v-model="value.name" :readonly="!props.editable"> | ||||
| 		<template #label>{{ i18n.ts.name }}</template> | ||||
| 	</MkInput> | ||||
| 	<div> | ||||
| 		<div :class="$style.label">{{ i18n.ts._abuse._resolver.targetUserPattern }}</div> | ||||
| 		<PrismEditor v-model="value.targetUserPattern" class="_code code" :class="$style.highlight" :highlight="highlighter" :lineNumbers="false" :readonly="!props.editable"/> | ||||
| 	</div> | ||||
| 	<div> | ||||
| 		<div :class="$style.label">{{ i18n.ts._abuse._resolver.reporterPattern }}</div> | ||||
| 		<PrismEditor v-model="value.reporterPattern" class="_code code" :class="$style.highlight" :highlight="highlighter" :lineNumbers="false" :readonly="!props.editable"/> | ||||
| 	</div> | ||||
| 	<div> | ||||
| 		<div :class="$style.label">{{ i18n.ts._abuse._resolver.reportContentPattern }}</div> | ||||
| 		<PrismEditor v-model="value.reportContentPattern" class="_code code" :class="$style.highlight" :highlight="highlighter" :lineNumbers="false" :readonly="!props.editable"/> | ||||
| 	</div> | ||||
| 	<MkSelect v-model="value.expiresAt" :disabled="!props.editable"> | ||||
| 		<template #label>{{ i18n.ts._abuse._resolver.expiresAt }}<span v-if="expirationDate" style="float: right;">{{ expirationDate }}</span></template> | ||||
| 		<option value="1hour">{{ i18n.ts._abuse._resolver['1hour'] }}</option> | ||||
| 		<option value="12hours">{{ i18n.ts._abuse._resolver['12hours'] }}</option> | ||||
| 		<option value="1day">{{ i18n.ts._abuse._resolver['1day'] }}</option> | ||||
| 		<option value="1week">{{ i18n.ts._abuse._resolver['1week'] }}</option> | ||||
| 		<option value="1month">{{ i18n.ts._abuse._resolver['1month'] }}</option> | ||||
| 		<option value="3months">{{ i18n.ts._abuse._resolver['3months'] }}</option> | ||||
| 		<option value="6months">{{ i18n.ts._abuse._resolver['6months'] }}</option> | ||||
| 		<option value="1year">{{ i18n.ts._abuse._resolver['1year'] }}</option> | ||||
| 		<option value="indefinitely">{{ i18n.ts._abuse._resolver.indefinitely }}</option> | ||||
| 	</MkSelect> | ||||
| 	<MkSwitch v-model="value.forward" :disabled="!props.editable"> | ||||
| 		{{ i18n.ts.forwardReport }} | ||||
| 		<template #caption>{{ i18n.ts.forwardReportIsAnonymous }}</template> | ||||
| 	</MkSwitch> | ||||
| 	<slot name="button"></slot> | ||||
| </div> | ||||
| </template> | ||||
| <script lang="ts" setup> | ||||
| import { computed, watch } from 'vue'; | ||||
| import { PrismEditor } from 'vue-prism-editor'; | ||||
| import { highlight, languages } from 'prismjs/components/prism-core'; | ||||
| import MkInput from '@/components/MkInput.vue'; | ||||
| import MkSelect from '@/components/MkSelect.vue'; | ||||
| import MkSwitch from '@/components/MkSwitch.vue'; | ||||
| import { i18n } from '@/i18n'; | ||||
| import 'vue-prism-editor/dist/prismeditor.min.css'; | ||||
| import 'prismjs/components/prism-clike'; | ||||
| import 'prismjs/components/prism-regex'; | ||||
| import 'prismjs/themes/prism-okaidia.css'; | ||||
|  | ||||
| const props = defineProps<{ | ||||
| 	modelValue?: { | ||||
| 		name: string; | ||||
| 		targetUserPattern: string | null; | ||||
| 		reporterPattern: string | null; | ||||
| 		reportContentPattern: string | null; | ||||
| 		expiresAt: string; | ||||
| 		forward: boolean; | ||||
| 		expirationDate: string; | ||||
| 		previousExpiresAt?: string; | ||||
| 	} | ||||
| 	editable: boolean; | ||||
| 	data?: { | ||||
| 		name: string; | ||||
| 		targetUserPattern: string | null; | ||||
| 		reporterPattern: string | null; | ||||
| 		reportContentPattern: string | null; | ||||
| 		expirationDate: string | null; | ||||
| 		expiresAt: string; | ||||
| 		forward: boolean; | ||||
| 		previousExpiresAt?: string; | ||||
| 	} | ||||
| }>(); | ||||
| let expirationDate: string | null = $ref(null); | ||||
|  | ||||
| type NonNullType<T> = { | ||||
| 	[P in keyof T]: NonNullable<T[P]> | ||||
| } | ||||
|  | ||||
| const emit = defineEmits(['update:modelValue']); | ||||
|  | ||||
| const value = computed({ | ||||
| 	get() { | ||||
| 		const data = props.data ?? props.modelValue ?? { | ||||
| 			name: '', | ||||
| 			targetUserPattern: '', | ||||
| 			reporterPattern: '', | ||||
| 			reportContentPattern: '', | ||||
| 			expirationDate: null, | ||||
| 			expiresAt: 'indefinitely', | ||||
| 			forward: false, | ||||
| 			previousExpiresAt: undefined, | ||||
| 		}; | ||||
| 		for (const [key, _value] of Object.entries(data)) { | ||||
| 			if (_value === null) { | ||||
| 				data[key] = ''; | ||||
| 			} | ||||
| 		} | ||||
| 		if (props.modelValue && props.editable) { | ||||
| 			emit('update:modelValue', data); | ||||
| 		} | ||||
| 		return data as NonNullType<typeof data>; | ||||
| 	}, | ||||
| 	set(updateValue) { | ||||
| 		if (props.modelValue && props.editable) { | ||||
| 			emit('update:modelValue', updateValue); | ||||
| 		} | ||||
| 	}, | ||||
| }); | ||||
|  | ||||
| function highlighter(code) { | ||||
| 	return highlight(code, languages.regex); | ||||
| } | ||||
|  | ||||
| function renderExpirationDate(empty = false) { | ||||
| 	if (value.value.expirationDate && !empty) { | ||||
| 		expirationDate = new Date(value.value.expirationDate).toLocaleString(); | ||||
| 	} else { | ||||
| 		expirationDate = null; | ||||
| 	} | ||||
| } | ||||
|  | ||||
| watch(() => value.value.expirationDate, () => renderExpirationDate(), { immediate: true }); | ||||
| watch(() => value.value.expiresAt, () => renderExpirationDate(true)); | ||||
| watch(() => props.editable, () => { | ||||
| 	if (props.editable) { | ||||
| 		value.value.previousExpiresAt = value.value.expiresAt; | ||||
| 	} | ||||
| }); | ||||
|  | ||||
| </script> | ||||
| <style lang="scss"> | ||||
| .dslkjkwejflew .prism-editor__textarea { | ||||
| 	padding-left: 10px !important; | ||||
| 	padding-bottom: 10px !important; | ||||
| } | ||||
|  | ||||
| .dslkjkwejflew .prism-editor__editor { | ||||
| 	padding-left: 10px !important; | ||||
| 	padding-bottom: 10px !important; | ||||
| } | ||||
| </style> | ||||
| <style lang="scss" module> | ||||
| .label { | ||||
| 	font-size: 0.85em; | ||||
| 	padding: 0 0 8px 0; | ||||
| 	user-select: none; | ||||
| } | ||||
| .highlight { | ||||
| 	padding: 0; | ||||
| 	position: relative; | ||||
| 	padding-top: 6px; | ||||
| 	padding-bottom: 6px; | ||||
| 	border-radius: 4px; | ||||
| } | ||||
| </style> | ||||
| @@ -108,6 +108,10 @@ onMounted(() => { | ||||
| 	const myBg = computedStyle.getPropertyValue('--panel'); | ||||
| 	bgSame = parentBg === myBg; | ||||
| }); | ||||
|  | ||||
| defineExpose({ | ||||
| 	toggle, | ||||
| }); | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" module> | ||||
|   | ||||
| @@ -18,7 +18,7 @@ | ||||
| 			:spellcheck="spellcheck" | ||||
| 			:step="step" | ||||
| 			:list="id" | ||||
| 			@focus="focused = true" | ||||
| 			@focus="onFocus" | ||||
| 			@blur="focused = false" | ||||
| 			@keydown="onKeydown($event)" | ||||
| 			@input="onInput" | ||||
| @@ -98,6 +98,12 @@ const onKeydown = (ev: KeyboardEvent) => { | ||||
| 	} | ||||
| }; | ||||
|  | ||||
| const onFocus = () => { | ||||
| 	if (!(props.readonly || props.disabled)) { | ||||
| 		focused.value = true; | ||||
| 	} | ||||
| }; | ||||
|  | ||||
| const updated = () => { | ||||
| 	changed.value = false; | ||||
| 	if (type.value === 'number') { | ||||
|   | ||||
| @@ -10,7 +10,6 @@ | ||||
| 			:class="$style.inputCore" | ||||
| 			:disabled="disabled" | ||||
| 			:required="required" | ||||
| 			:readonly="readonly" | ||||
| 			:placeholder="placeholder" | ||||
| 			@focus="focused = true" | ||||
| 			@blur="focused = false" | ||||
| @@ -60,7 +59,7 @@ const opening = ref(false); | ||||
| const changed = ref(false); | ||||
| const invalid = ref(false); | ||||
| const filled = computed(() => v.value !== '' && v.value != null); | ||||
| const inputEl = ref(null); | ||||
| const inputEl = ref<HTMLSelectElement | null>(null); | ||||
| const prefixEl = ref(null); | ||||
| const suffixEl = ref(null); | ||||
| const container = ref(null); | ||||
| @@ -119,6 +118,9 @@ onMounted(() => { | ||||
| }); | ||||
|  | ||||
| function show(ev: MouseEvent) { | ||||
| 	if (inputEl.value && inputEl.value.hasAttribute('disabled')) { | ||||
| 		return; | ||||
| 	} | ||||
| 	focused.value = true; | ||||
| 	opening.value = true; | ||||
|  | ||||
|   | ||||
| @@ -1,24 +1,24 @@ | ||||
| <template> | ||||
| <MkStickyContainer> | ||||
| 	<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> | ||||
| 	<template #header><XHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> | ||||
| 	<MkSpacer :contentMax="900"> | ||||
| 		<div> | ||||
| 		<div v-if="tab === 'list'"> | ||||
| 			<div class="reports"> | ||||
| 				<div class=""> | ||||
| 					<div class="inputs" style="display: flex;"> | ||||
| 						<MkSelect v-model="state" style="margin: 0; flex: 1;"> | ||||
| 						<MkSelect v-model="state" :class="$style.state"> | ||||
| 							<template #label>{{ i18n.ts.state }}</template> | ||||
| 							<option value="all">{{ i18n.ts.all }}</option> | ||||
| 							<option value="unresolved">{{ i18n.ts.unresolved }}</option> | ||||
| 							<option value="resolved">{{ i18n.ts.resolved }}</option> | ||||
| 						</MkSelect> | ||||
| 						<MkSelect v-model="targetUserOrigin" style="margin: 0; flex: 1;"> | ||||
| 						<MkSelect v-model="targetUserOrigin" :class="$style.targetUserOrigin"> | ||||
| 							<template #label>{{ i18n.ts.reporteeOrigin }}</template> | ||||
| 							<option value="combined">{{ i18n.ts.all }}</option> | ||||
| 							<option value="local">{{ i18n.ts.local }}</option> | ||||
| 							<option value="remote">{{ i18n.ts.remote }}</option> | ||||
| 						</MkSelect> | ||||
| 						<MkSelect v-model="reporterOrigin" style="margin: 0; flex: 1;"> | ||||
| 						<MkSelect v-model="reporterOrigin" :class="$style.reporterOrigin"> | ||||
| 							<template #label>{{ i18n.ts.reporterOrigin }}</template> | ||||
| 							<option value="combined">{{ i18n.ts.all }}</option> | ||||
| 							<option value="local">{{ i18n.ts.local }}</option> | ||||
| @@ -42,27 +42,89 @@ | ||||
| 				</div> | ||||
| 			</div> | ||||
| 		</div> | ||||
| 		<div v-else> | ||||
| 			<div class="_gaps"> | ||||
| 				<MkFolder ref="folderComponent"> | ||||
| 					<template #label><i class="ti ti-plus" style="margin-right: 5px;"></i>{{ i18n.ts.createNew }}</template> | ||||
| 					<MkAbuseReportResolver v-model="newResolver" :editable="true"> | ||||
| 						<template #button> | ||||
| 							<MkButton primary :class="$style.margin" @click="create">{{ i18n.ts.create }}</MkButton> | ||||
| 						</template> | ||||
| 					</MkAbuseReportResolver> | ||||
| 				</MkFolder> | ||||
| 				<MkPagination v-slot="{items}" ref="resolverPagingComponent" :pagination="resolverPagination"> | ||||
| 					<MkSpacer v-for="resolver in items" :key="resolver.id" :marginMin="14" :marginMax="22" :class="$style.resolverList"> | ||||
| 						<MkAbuseReportResolver v-model="editingResolver" :data="(resolver as any)" :editable="editableResolver === resolver.id"> | ||||
| 							<template #button> | ||||
| 								<div v-if="editableResolver !== resolver.id"> | ||||
| 									<MkButton primary inline :class="$style['button-margin']" @click="edit(resolver.id)"><i class="ti ti-pencil"></i> {{ i18n.ts.edit }}</MkButton> | ||||
| 									<MkButton danger inline @click="deleteResolver(resolver.id)"><i class="ti ti-trash"></i> {{ i18n.ts.remove }}</MkButton> | ||||
| 								</div> | ||||
| 								<div v-else> | ||||
| 									<MkButton primary inline @click="save">{{ i18n.ts.save }}</MkButton> | ||||
| 								</div> | ||||
| 							</template> | ||||
| 						</MkAbuseReportResolver> | ||||
| 					</MkSpacer> | ||||
| 				</MkPagination> | ||||
| 			</div> | ||||
| 		</div> | ||||
| 	</MkSpacer> | ||||
| </MkStickyContainer> | ||||
| </template> | ||||
|  | ||||
| <script lang="ts" setup> | ||||
| import { computed } from 'vue'; | ||||
|  | ||||
| import XHeader from './_header_.vue'; | ||||
| import MkSelect from '@/components/MkSelect.vue'; | ||||
| import MkButton from '@/components/MkButton.vue'; | ||||
| import MkPagination from '@/components/MkPagination.vue'; | ||||
| import MkFolder from '@/components/MkFolder.vue'; | ||||
| import MkAbuseReportResolver from '@/components/MkAbuseReportResolver.vue'; | ||||
| import XAbuseReport from '@/components/MkAbuseReport.vue'; | ||||
| import { i18n } from '@/i18n'; | ||||
| import * as os from '@/os'; | ||||
| import { definePageMetadata } from '@/scripts/page-metadata'; | ||||
|  | ||||
| let reports = $shallowRef<InstanceType<typeof MkPagination>>(); | ||||
| let resolverPagingComponent = $ref<InstanceType<typeof MkPagination>>(); | ||||
| let folderComponent = $ref<InstanceType<typeof MkFolder>>(); | ||||
|  | ||||
| let state = $ref('unresolved'); | ||||
| let reporterOrigin = $ref('combined'); | ||||
| let targetUserOrigin = $ref('combined'); | ||||
| let searchUsername = $ref(''); | ||||
| let searchHost = $ref(''); | ||||
| let tab = $ref('list'); | ||||
| let editableResolver: null | string = $ref(null); | ||||
| const defaultResolver = { | ||||
| 	name: '', | ||||
| 	targetUserPattern: '', | ||||
| 	reporterPattern: '', | ||||
| 	reportContentPattern: '', | ||||
| 	expirationDate: '', | ||||
| 	expiresAt: 'indefinitely', | ||||
| 	forward: false, | ||||
| }; | ||||
|  | ||||
| let newResolver = $ref<{ | ||||
| 	name: string; | ||||
| 	targetUserPattern: string; | ||||
| 	reporterPattern: string; | ||||
| 	reportContentPattern: string; | ||||
| 	expirationDate: string; | ||||
| 	expiresAt: string; | ||||
| 	forward: boolean; | ||||
| }>(defaultResolver); | ||||
|  | ||||
| let editingResolver = $ref<{ | ||||
| 	name: string; | ||||
| 	targetUserPattern: string; | ||||
| 	reporterPattern: string; | ||||
| 	reportContentPattern: string; | ||||
| 	expiresAt: string; | ||||
| 	expirationDate: string; | ||||
| 	forward: boolean; | ||||
| 	previousExpiresAt?: string; | ||||
| }>(defaultResolver); | ||||
|  | ||||
| const pagination = { | ||||
| 	endpoint: 'admin/abuse-user-reports' as const, | ||||
| @@ -74,16 +136,108 @@ const pagination = { | ||||
| 	})), | ||||
| }; | ||||
|  | ||||
| const resolverPagination = { | ||||
| 	endpoint: 'admin/abuse-report-resolver/list' as const, | ||||
| 	limit: 10, | ||||
| }; | ||||
|  | ||||
| function resolved(reportId) { | ||||
| 	reports.removeItem(reportId); | ||||
| 	reports!.removeItem(reportId); | ||||
| } | ||||
|  | ||||
| function edit(id: string) { | ||||
| 	editableResolver = id; | ||||
| } | ||||
|  | ||||
| function save(): void { | ||||
| 	os.apiWithDialog('admin/abuse-report-resolver/update', { | ||||
| 		resolverId: editableResolver, | ||||
| 		name: editingResolver.name, | ||||
| 		targetUserPattern: editingResolver.targetUserPattern || null, | ||||
| 		reporterPattern: editingResolver.reporterPattern || null, | ||||
| 		reportContentPattern: editingResolver.reportContentPattern || null, | ||||
| 		...(editingResolver.previousExpiresAt && editingResolver.previousExpiresAt === editingResolver.expiresAt ? {} : { | ||||
| 			expiresAt: editingResolver.expiresAt, | ||||
| 		}), | ||||
| 		forward: editingResolver.forward, | ||||
| 	}).then(() => { | ||||
| 		editableResolver = null; | ||||
| 	}); | ||||
| } | ||||
|  | ||||
| function deleteResolver(id: string): void { | ||||
| 	os.apiWithDialog('admin/abuse-report-resolver/delete', { | ||||
| 		resolverId: id, | ||||
| 	}).then(() => { | ||||
| 		resolverPagingComponent?.reload(); | ||||
| 	}); | ||||
| } | ||||
|  | ||||
| function create(): void { | ||||
| 	os.apiWithDialog('admin/abuse-report-resolver/create', { | ||||
| 		name: newResolver.name, | ||||
| 		targetUserPattern: newResolver.targetUserPattern || null, | ||||
| 		reporterPattern: newResolver.reporterPattern || null, | ||||
| 		reportContentPattern: newResolver.reportContentPattern || null, | ||||
| 		expiresAt: newResolver.expiresAt, | ||||
| 		forward: newResolver.forward, | ||||
| 	}).then(() => { | ||||
| 		folderComponent?.toggle(); | ||||
| 		resolverPagingComponent?.reload(); | ||||
| 		newResolver.name = ''; | ||||
| 		newResolver.targetUserPattern = ''; | ||||
| 		newResolver.reporterPattern = ''; | ||||
| 		newResolver.reportContentPattern = ''; | ||||
| 		newResolver.expiresAt = 'indefinitely'; | ||||
| 		newResolver.forward = false; | ||||
| 	}); | ||||
| } | ||||
|  | ||||
| const headerActions = $computed(() => []); | ||||
|  | ||||
| const headerTabs = $computed(() => []); | ||||
| const headerTabs = $computed(() => [{ | ||||
| 	key: 'list', | ||||
| 	title: i18n.ts._abuse.list, | ||||
| }, { | ||||
| 	key: 'resolver', | ||||
| 	title: i18n.ts._abuse.resolver, | ||||
| }]); | ||||
|  | ||||
| definePageMetadata({ | ||||
| 	title: i18n.ts.abuseReports, | ||||
| 	icon: 'ti ti-exclamation-circle', | ||||
| }); | ||||
| </script> | ||||
| <style lang="scss" module> | ||||
| .input-base { | ||||
| 	margin: 0; | ||||
| 	flex: 1; | ||||
| } | ||||
|  | ||||
| .button-margin { | ||||
| 	margin-right: 6px; | ||||
| } | ||||
|  | ||||
| .state { | ||||
| 	@extend .input-base; | ||||
| 	@extend .button-margin; | ||||
| } | ||||
| .reporterOrigin { | ||||
| 	@extend .input-base; | ||||
| } | ||||
|  | ||||
| .targetUserOrigin { | ||||
| 	@extend .input-base; | ||||
| 	@extend .button-margin; | ||||
| } | ||||
|  | ||||
| .margin { | ||||
| 	margin: 0 auto var(--margin) auto; | ||||
| } | ||||
|  | ||||
| .resolverList { | ||||
| 	background: var(--panel); | ||||
| 	border-radius: 6px; | ||||
| 	margin-bottom: 13px; | ||||
| } | ||||
| </style> | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 まっちゃとーにゅ
					まっちゃとーにゅ