memi-board 0.4.0 → 0.4.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/composables/useMemiBoardModeration.d.ts +2 -5
- package/dist/composables/useMemiBoardPosts.d.ts +2 -0
- package/dist/composables/useMemiBoardSettings.d.ts +10 -4
- package/dist/index.d.ts +1 -1
- package/dist/index.js +271 -159
- package/dist/index.js.map +1 -1
- package/dist/module.mjs +28 -15
- package/dist/module.mjs.map +1 -1
- package/dist/runtime/components/CommentList.vue +1 -0
- package/dist/runtime/components/Editor.vue +233 -26
- package/dist/runtime/components/List.vue +56 -9
- package/dist/types.d.ts +4 -0
- package/package.json +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { ref, computed,
|
|
2
|
+
import { ref, computed, watch } from 'vue'
|
|
3
3
|
import type { Attachment } from 'memi-board'
|
|
4
4
|
import { useMemiBoardAuth } from 'memi-board'
|
|
5
5
|
import { useMemiBoardPosts } from 'memi-board'
|
|
@@ -8,38 +8,87 @@ import { useMemiBoardSettings } from 'memi-board'
|
|
|
8
8
|
import MemiBoardAttachments from './Attachments.vue'
|
|
9
9
|
|
|
10
10
|
const props = defineProps<{
|
|
11
|
-
/** 지정하면 수정
|
|
11
|
+
/** 지정하면 수정 모드 */
|
|
12
12
|
postId?: string
|
|
13
|
+
/**
|
|
14
|
+
* path 등으로 카테고리가 이미 정해진 경우 (예: /board/:category/new).
|
|
15
|
+
* 설정되면 선택 UI 없이 이 값으로 저장한다.
|
|
16
|
+
*/
|
|
17
|
+
fixedCategory?: string
|
|
13
18
|
}>()
|
|
14
19
|
|
|
15
20
|
const emit = defineEmits<{ saved: [id: string], cancel: [] }>()
|
|
16
21
|
|
|
17
|
-
const { user } = useMemiBoardAuth()
|
|
22
|
+
const { user, isSignedIn } = useMemiBoardAuth()
|
|
18
23
|
const { getPost, createPost, updatePost } = useMemiBoardPosts()
|
|
19
24
|
const { checkText } = useMemiBoardModeration()
|
|
20
|
-
const { categories } = useMemiBoardSettings()
|
|
25
|
+
const { categories, categoryLabel, ensureSettings, addCategory } = useMemiBoardSettings()
|
|
21
26
|
|
|
22
27
|
const title = ref('')
|
|
23
28
|
const content = ref('')
|
|
24
29
|
const tagsInput = ref('')
|
|
30
|
+
/** boardSettings.categories 의 id (fixedCategory 없을 때만 선택) */
|
|
25
31
|
const category = ref<string | undefined>(undefined)
|
|
26
32
|
const attachments = ref<Attachment[]>([])
|
|
27
33
|
|
|
28
|
-
const categoryItems = computed(() =>
|
|
34
|
+
const categoryItems = computed(() =>
|
|
35
|
+
categories.value.map(c => ({ label: c.label, value: c.id })),
|
|
36
|
+
)
|
|
29
37
|
|
|
30
|
-
|
|
38
|
+
/** 실제 저장에 쓰는 카테고리 id */
|
|
39
|
+
const effectiveCategory = computed(() => props.fixedCategory || category.value)
|
|
40
|
+
|
|
41
|
+
const isEdit = computed(() => Boolean(props.postId))
|
|
42
|
+
const categoryLocked = computed(() => Boolean(props.fixedCategory))
|
|
43
|
+
const loading = ref(false)
|
|
31
44
|
const saving = ref(false)
|
|
45
|
+
/** 제출 중 단계 표시 (로컬 필터 → AI Logic → 저장) */
|
|
46
|
+
const submitHint = ref('')
|
|
47
|
+
/** 마지막 검열 결과 요약 (화면 + 콘솔) */
|
|
48
|
+
const lastModerationDebug = ref('')
|
|
32
49
|
const error = ref('')
|
|
33
50
|
|
|
34
|
-
|
|
51
|
+
function formatModerationDebug(m: {
|
|
52
|
+
flagged: boolean
|
|
53
|
+
category: string
|
|
54
|
+
reason: string
|
|
55
|
+
error?: boolean
|
|
56
|
+
via?: string
|
|
57
|
+
}): string {
|
|
58
|
+
const viaLabel: Record<string, string> = {
|
|
59
|
+
empty: '빈 텍스트',
|
|
60
|
+
disabled: '검열 끔',
|
|
61
|
+
local: '로컬 금칙어',
|
|
62
|
+
ai: 'Firebase AI Logic',
|
|
63
|
+
'ai-error-allow': 'AI 실패 → onError:allow (통과)',
|
|
64
|
+
'ai-error-block': 'AI 실패 → onError:block (차단)',
|
|
65
|
+
}
|
|
66
|
+
const via = m.via ? (viaLabel[m.via] || m.via) : '?'
|
|
67
|
+
return [
|
|
68
|
+
`via: ${via}`,
|
|
69
|
+
`flagged: ${m.flagged}`,
|
|
70
|
+
`category: ${m.category}`,
|
|
71
|
+
m.reason ? `reason: ${m.reason}` : '',
|
|
72
|
+
m.error ? 'apiError: true' : '',
|
|
73
|
+
].filter(Boolean).join(' · ')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const showAddCategory = ref(false)
|
|
77
|
+
const newCategoryLabel = ref('')
|
|
78
|
+
const addingCategory = ref(false)
|
|
79
|
+
|
|
35
80
|
const attachmentNamespace = ref(props.postId ?? `new-${Date.now()}`)
|
|
36
81
|
|
|
37
|
-
|
|
38
|
-
|
|
82
|
+
async function loadPost(id: string) {
|
|
83
|
+
loading.value = true
|
|
84
|
+
error.value = ''
|
|
39
85
|
try {
|
|
40
|
-
|
|
86
|
+
await ensureSettings().catch(() => {})
|
|
87
|
+
const post = await getPost(id)
|
|
41
88
|
if (!post) {
|
|
42
89
|
error.value = '게시글을 찾을 수 없습니다.'
|
|
90
|
+
title.value = ''
|
|
91
|
+
content.value = ''
|
|
43
92
|
return
|
|
44
93
|
}
|
|
45
94
|
title.value = post.title
|
|
@@ -47,11 +96,75 @@ onMounted(async () => {
|
|
|
47
96
|
tagsInput.value = (post.tags ?? []).join(', ')
|
|
48
97
|
category.value = post.category
|
|
49
98
|
attachments.value = post.attachments ?? []
|
|
99
|
+
attachmentNamespace.value = id
|
|
100
|
+
}
|
|
101
|
+
catch (e) {
|
|
102
|
+
const msg = (e as Error).message || String(e)
|
|
103
|
+
error.value = msg.includes('permission')
|
|
104
|
+
? '글을 불러올 권한이 없습니다. 로그인 상태를 확인해 주세요.'
|
|
105
|
+
: `글을 불러오지 못했습니다: ${msg}`
|
|
50
106
|
}
|
|
51
107
|
finally {
|
|
52
108
|
loading.value = false
|
|
53
109
|
}
|
|
54
|
-
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
watch(
|
|
113
|
+
() => props.postId,
|
|
114
|
+
(id) => {
|
|
115
|
+
if (id) void loadPost(id)
|
|
116
|
+
else {
|
|
117
|
+
loading.value = false
|
|
118
|
+
attachmentNamespace.value = `new-${Date.now()}`
|
|
119
|
+
if (props.fixedCategory) category.value = props.fixedCategory
|
|
120
|
+
void ensureSettings().catch(() => {})
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
{ immediate: true },
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
watch(
|
|
127
|
+
() => props.fixedCategory,
|
|
128
|
+
(c) => {
|
|
129
|
+
if (c && !props.postId) category.value = c
|
|
130
|
+
},
|
|
131
|
+
{ immediate: true },
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
async function handleAddCategory() {
|
|
135
|
+
error.value = ''
|
|
136
|
+
if (!isSignedIn.value) {
|
|
137
|
+
error.value = '카테고리 추가에는 로그인이 필요합니다.'
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
addingCategory.value = true
|
|
141
|
+
try {
|
|
142
|
+
const label = newCategoryLabel.value.trim()
|
|
143
|
+
const moderation = await checkText(label)
|
|
144
|
+
if (moderation.flagged) {
|
|
145
|
+
error.value = moderation.reason || '사용할 수 없는 카테고리 이름입니다.'
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
const id = await addCategory(label)
|
|
149
|
+
category.value = id
|
|
150
|
+
newCategoryLabel.value = ''
|
|
151
|
+
showAddCategory.value = false
|
|
152
|
+
}
|
|
153
|
+
catch (e) {
|
|
154
|
+
error.value = (e as Error).message || String(e)
|
|
155
|
+
}
|
|
156
|
+
finally {
|
|
157
|
+
addingCategory.value = false
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function friendlyWriteError(e: unknown): string {
|
|
162
|
+
const msg = e instanceof Error ? e.message : String(e)
|
|
163
|
+
if (msg.includes('permission-denied') || msg.includes('Permission denied')) {
|
|
164
|
+
return '수정 권한이 없습니다. 본인이 작성한 글인지, 로그인이 유지되는지 확인해 주세요.'
|
|
165
|
+
}
|
|
166
|
+
return msg
|
|
167
|
+
}
|
|
55
168
|
|
|
56
169
|
async function handleSubmit() {
|
|
57
170
|
error.value = ''
|
|
@@ -63,28 +176,53 @@ async function handleSubmit() {
|
|
|
63
176
|
error.value = '로그인이 필요합니다.'
|
|
64
177
|
return
|
|
65
178
|
}
|
|
179
|
+
const cat = effectiveCategory.value
|
|
180
|
+
if (!cat) {
|
|
181
|
+
error.value = '카테고리를 선택해 주세요.'
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
if (!categories.value.some(c => c.id === cat) && !props.fixedCategory) {
|
|
185
|
+
error.value = '목록에 있는 카테고리를 선택해 주세요.'
|
|
186
|
+
return
|
|
187
|
+
}
|
|
66
188
|
|
|
67
189
|
saving.value = true
|
|
190
|
+
submitHint.value = '내용을 검토하는 중… (로컬 → AI Logic)'
|
|
191
|
+
lastModerationDebug.value = ''
|
|
68
192
|
try {
|
|
69
|
-
const
|
|
193
|
+
const text = `${title.value}\n${content.value}`
|
|
194
|
+
console.info('[memi-board] post checkText 시작', { title: title.value, contentLen: content.value.length })
|
|
195
|
+
const moderation = await checkText(text)
|
|
196
|
+
lastModerationDebug.value = formatModerationDebug(moderation)
|
|
197
|
+
console.info('[memi-board] post checkText 끝', lastModerationDebug.value, moderation)
|
|
198
|
+
|
|
70
199
|
if (moderation.flagged) {
|
|
71
200
|
error.value = moderation.reason || '게시할 수 없는 내용이 포함되어 있습니다.'
|
|
72
201
|
return
|
|
73
202
|
}
|
|
74
203
|
|
|
204
|
+
if (moderation.via === 'ai-error-allow') {
|
|
205
|
+
submitHint.value = 'AI 검열 실패 — 설정상 통과 후 저장…'
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
submitHint.value = '저장하는 중…'
|
|
209
|
+
}
|
|
75
210
|
const tags = tagsInput.value.split(',').map(t => t.trim()).filter(Boolean)
|
|
211
|
+
const payload = {
|
|
212
|
+
title: title.value.trim(),
|
|
213
|
+
content: content.value,
|
|
214
|
+
tags,
|
|
215
|
+
category: cat,
|
|
216
|
+
attachments: attachments.value,
|
|
217
|
+
}
|
|
76
218
|
|
|
77
219
|
if (props.postId) {
|
|
78
|
-
await updatePost(props.postId,
|
|
220
|
+
await updatePost(props.postId, payload)
|
|
79
221
|
emit('saved', props.postId)
|
|
80
222
|
}
|
|
81
223
|
else {
|
|
82
224
|
const id = await createPost({
|
|
83
|
-
|
|
84
|
-
content: content.value,
|
|
85
|
-
tags,
|
|
86
|
-
category: category.value,
|
|
87
|
-
attachments: attachments.value,
|
|
225
|
+
...payload,
|
|
88
226
|
authorUid: user.value.uid,
|
|
89
227
|
authorName: user.value.displayName,
|
|
90
228
|
authorPhoto: user.value.photoURL,
|
|
@@ -93,10 +231,11 @@ async function handleSubmit() {
|
|
|
93
231
|
}
|
|
94
232
|
}
|
|
95
233
|
catch (e) {
|
|
96
|
-
error.value = (e
|
|
234
|
+
error.value = friendlyWriteError(e)
|
|
97
235
|
}
|
|
98
236
|
finally {
|
|
99
237
|
saving.value = false
|
|
238
|
+
submitHint.value = ''
|
|
100
239
|
}
|
|
101
240
|
}
|
|
102
241
|
</script>
|
|
@@ -115,12 +254,68 @@ async function handleSubmit() {
|
|
|
115
254
|
class="flex flex-col gap-4"
|
|
116
255
|
@submit.prevent="handleSubmit"
|
|
117
256
|
>
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
257
|
+
<!-- path 로 카테고리 고정 시: 표시만 / 아니면 선택+추가 -->
|
|
258
|
+
<div
|
|
259
|
+
v-if="categoryLocked"
|
|
260
|
+
class="flex items-center gap-2 text-sm"
|
|
261
|
+
>
|
|
262
|
+
<span class="text-muted">카테고리</span>
|
|
263
|
+
<UBadge
|
|
264
|
+
:label="categoryLabel(fixedCategory) || fixedCategory"
|
|
265
|
+
variant="subtle"
|
|
266
|
+
/>
|
|
267
|
+
</div>
|
|
268
|
+
|
|
269
|
+
<div
|
|
270
|
+
v-else
|
|
271
|
+
class="flex flex-col gap-2"
|
|
272
|
+
>
|
|
273
|
+
<div class="flex items-center gap-2 flex-wrap">
|
|
274
|
+
<USelect
|
|
275
|
+
v-model="category"
|
|
276
|
+
:items="categoryItems"
|
|
277
|
+
placeholder="카테고리 선택"
|
|
278
|
+
class="w-48"
|
|
279
|
+
/>
|
|
280
|
+
<UButton
|
|
281
|
+
v-if="isSignedIn"
|
|
282
|
+
type="button"
|
|
283
|
+
variant="outline"
|
|
284
|
+
color="neutral"
|
|
285
|
+
size="sm"
|
|
286
|
+
icon="i-lucide-plus"
|
|
287
|
+
label="카테고리 추가"
|
|
288
|
+
@click="showAddCategory = !showAddCategory"
|
|
289
|
+
/>
|
|
290
|
+
</div>
|
|
291
|
+
<div
|
|
292
|
+
v-if="showAddCategory"
|
|
293
|
+
class="flex items-center gap-2"
|
|
294
|
+
>
|
|
295
|
+
<UInput
|
|
296
|
+
v-model="newCategoryLabel"
|
|
297
|
+
placeholder="새 카테고리 이름"
|
|
298
|
+
class="w-48"
|
|
299
|
+
@keydown.enter.prevent="handleAddCategory"
|
|
300
|
+
/>
|
|
301
|
+
<UButton
|
|
302
|
+
type="button"
|
|
303
|
+
size="sm"
|
|
304
|
+
label="추가"
|
|
305
|
+
:loading="addingCategory"
|
|
306
|
+
@click="handleAddCategory"
|
|
307
|
+
/>
|
|
308
|
+
<UButton
|
|
309
|
+
type="button"
|
|
310
|
+
size="sm"
|
|
311
|
+
variant="ghost"
|
|
312
|
+
color="neutral"
|
|
313
|
+
label="취소"
|
|
314
|
+
@click="showAddCategory = false; newCategoryLabel = ''"
|
|
315
|
+
/>
|
|
316
|
+
</div>
|
|
317
|
+
</div>
|
|
318
|
+
|
|
124
319
|
<UInput
|
|
125
320
|
v-model="title"
|
|
126
321
|
placeholder="제목"
|
|
@@ -150,12 +345,24 @@ async function handleSubmit() {
|
|
|
150
345
|
>
|
|
151
346
|
{{ error }}
|
|
152
347
|
</p>
|
|
348
|
+
<p
|
|
349
|
+
v-else-if="submitHint"
|
|
350
|
+
class="text-sm text-muted"
|
|
351
|
+
>
|
|
352
|
+
{{ submitHint }}
|
|
353
|
+
</p>
|
|
354
|
+
<p
|
|
355
|
+
v-if="lastModerationDebug"
|
|
356
|
+
class="text-xs font-mono text-muted break-all rounded-md bg-muted/40 px-2 py-1.5"
|
|
357
|
+
>
|
|
358
|
+
검열 디버그: {{ lastModerationDebug }}
|
|
359
|
+
</p>
|
|
153
360
|
|
|
154
361
|
<div class="flex gap-2">
|
|
155
362
|
<UButton
|
|
156
363
|
type="submit"
|
|
157
364
|
:loading="saving"
|
|
158
|
-
:label="
|
|
365
|
+
:label="isEdit ? '수정 완료' : '게시하기'"
|
|
159
366
|
/>
|
|
160
367
|
<UButton
|
|
161
368
|
variant="ghost"
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { ref,
|
|
2
|
+
import { ref, watch } from 'vue'
|
|
3
3
|
import { RouterLink } from 'vue-router'
|
|
4
4
|
import type { DocumentData, QueryDocumentSnapshot } from 'firebase/firestore'
|
|
5
5
|
import { useMemiBoardPosts } from 'memi-board'
|
|
@@ -9,7 +9,20 @@ import { formatDate } from 'memi-board'
|
|
|
9
9
|
|
|
10
10
|
const props = withDefaults(defineProps<{
|
|
11
11
|
pageSize?: number
|
|
12
|
-
/**
|
|
12
|
+
/**
|
|
13
|
+
* 글 상세 링크 생성.
|
|
14
|
+
* 예: (p) => `/board/${p.category}/${p.id}`
|
|
15
|
+
* 없으면 postLinkBase / linkBase / select 순.
|
|
16
|
+
*/
|
|
17
|
+
getPostLink?: (post: PostModel) => string | undefined
|
|
18
|
+
/**
|
|
19
|
+
* 글 상세 링크 prefix.
|
|
20
|
+
* 예: `/board/post` → `/board/post/{id}`
|
|
21
|
+
*/
|
|
22
|
+
postLinkBase?: string
|
|
23
|
+
/** 지정 시 해당 카테고리 글만 조회 */
|
|
24
|
+
category?: string
|
|
25
|
+
/** @deprecated postLinkBase / getPostLink 사용 */
|
|
13
26
|
linkBase?: string
|
|
14
27
|
}>(), {
|
|
15
28
|
pageSize: 20,
|
|
@@ -25,25 +38,52 @@ const cursor = ref<QueryDocumentSnapshot<DocumentData> | undefined>(undefined)
|
|
|
25
38
|
const hasMore = ref(false)
|
|
26
39
|
const loading = ref(false)
|
|
27
40
|
const initialLoading = ref(true)
|
|
41
|
+
const loadError = ref('')
|
|
28
42
|
|
|
29
|
-
|
|
43
|
+
function postTo(post: PostModel): string | undefined {
|
|
44
|
+
if (props.getPostLink) return props.getPostLink(post)
|
|
45
|
+
const base = props.postLinkBase || props.linkBase
|
|
46
|
+
if (!base || !post.id) return undefined
|
|
47
|
+
return `${base.replace(/\/$/, '')}/${post.id}`
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function loadMore(reset = false) {
|
|
30
51
|
loading.value = true
|
|
52
|
+
loadError.value = ''
|
|
53
|
+
if (reset) {
|
|
54
|
+
posts.value = []
|
|
55
|
+
cursor.value = undefined
|
|
56
|
+
hasMore.value = false
|
|
57
|
+
initialLoading.value = true
|
|
58
|
+
}
|
|
31
59
|
try {
|
|
32
|
-
const result = await getPosts({
|
|
60
|
+
const result = await getPosts({
|
|
61
|
+
pageSize: props.pageSize,
|
|
62
|
+
cursor: reset ? undefined : cursor.value,
|
|
63
|
+
category: props.category || undefined,
|
|
64
|
+
})
|
|
33
65
|
posts.value.push(...result.posts)
|
|
34
66
|
cursor.value = result.cursor
|
|
35
67
|
hasMore.value = result.hasMore
|
|
36
68
|
}
|
|
69
|
+
catch (e) {
|
|
70
|
+
loadError.value = (e as Error).message || String(e)
|
|
71
|
+
console.error('[memi-board] getPosts failed', e)
|
|
72
|
+
}
|
|
37
73
|
finally {
|
|
38
74
|
loading.value = false
|
|
39
75
|
initialLoading.value = false
|
|
40
76
|
}
|
|
41
77
|
}
|
|
42
78
|
|
|
43
|
-
|
|
79
|
+
watch(
|
|
80
|
+
() => props.category,
|
|
81
|
+
() => { void loadMore(true) },
|
|
82
|
+
{ immediate: true },
|
|
83
|
+
)
|
|
44
84
|
|
|
45
85
|
function handleClick(post: PostModel) {
|
|
46
|
-
if (!
|
|
86
|
+
if (!postTo(post)) emit('select', post)
|
|
47
87
|
}
|
|
48
88
|
</script>
|
|
49
89
|
|
|
@@ -57,6 +97,13 @@ function handleClick(post: PostModel) {
|
|
|
57
97
|
/>
|
|
58
98
|
</template>
|
|
59
99
|
|
|
100
|
+
<p
|
|
101
|
+
v-else-if="loadError"
|
|
102
|
+
class="text-sm text-error text-center py-8"
|
|
103
|
+
>
|
|
104
|
+
목록을 불러오지 못했습니다: {{ loadError }}
|
|
105
|
+
</p>
|
|
106
|
+
|
|
60
107
|
<p
|
|
61
108
|
v-else-if="!posts.length"
|
|
62
109
|
class="text-sm text-muted text-center py-8"
|
|
@@ -65,10 +112,10 @@ function handleClick(post: PostModel) {
|
|
|
65
112
|
</p>
|
|
66
113
|
|
|
67
114
|
<component
|
|
68
|
-
:is="
|
|
115
|
+
:is="postTo(post) ? RouterLink : 'button'"
|
|
69
116
|
v-for="post in posts"
|
|
70
117
|
:key="post.id"
|
|
71
|
-
:to="
|
|
118
|
+
:to="postTo(post)"
|
|
72
119
|
class="text-left"
|
|
73
120
|
@click="handleClick(post)"
|
|
74
121
|
>
|
|
@@ -115,7 +162,7 @@ function handleClick(post: PostModel) {
|
|
|
115
162
|
label="더 보기"
|
|
116
163
|
block
|
|
117
164
|
:loading="loading"
|
|
118
|
-
@click="loadMore"
|
|
165
|
+
@click="loadMore(false)"
|
|
119
166
|
/>
|
|
120
167
|
</div>
|
|
121
168
|
</template>
|
package/dist/types.d.ts
CHANGED
|
@@ -53,10 +53,14 @@ export interface BoardSettingsModel {
|
|
|
53
53
|
categories: BoardCategory[];
|
|
54
54
|
updatedAt?: Timestamp;
|
|
55
55
|
}
|
|
56
|
+
/** 검열이 어느 단계에서 끝났는지 (디버그·UI 표시용) */
|
|
57
|
+
export type ModerationVia = 'empty' | 'disabled' | 'local' | 'ai' | 'ai-error-allow' | 'ai-error-block';
|
|
56
58
|
export interface ModerationResult {
|
|
57
59
|
flagged: boolean;
|
|
58
60
|
category: 'none' | 'abuse' | 'spam' | 'adult' | 'violence' | 'other';
|
|
59
61
|
reason: string;
|
|
60
62
|
/** true면 API/파싱 실패로 판단이 불확실함을 뜻한다 (onError 옵션에 따라 처리). */
|
|
61
63
|
error?: boolean;
|
|
64
|
+
/** 로컬 리스트 / AI / 실패 후 allow 등 */
|
|
65
|
+
via?: ModerationVia;
|
|
62
66
|
}
|