권한 UI와 글 목록 검색 보정 v1.5.10
This commit is contained in:
@@ -12,6 +12,13 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['saved', 'deleted'])
|
||||
|
||||
const { data: adminSession } = await useFetch('/admin/api/auth/me', {
|
||||
default: () => ({
|
||||
userId: '',
|
||||
roleCode: ''
|
||||
})
|
||||
})
|
||||
|
||||
const isNewMember = computed(() => props.mode === 'new')
|
||||
const saveMessage = ref('')
|
||||
const saveError = ref('')
|
||||
@@ -87,6 +94,57 @@ const normalizedLabels = computed(() => [...new Set(
|
||||
)])
|
||||
|
||||
const currentRoleLabel = computed(() => roleOptions.find((option) => option.value === form.roleCode)?.label || '멤버')
|
||||
const currentAdminRoleCode = computed(() => adminSession.value?.roleCode || '')
|
||||
const isCurrentAdminPrivileged = computed(() => ['owner', 'admin'].includes(currentAdminRoleCode.value))
|
||||
const isEditingSelf = computed(() => Boolean(props.member?.id && adminSession.value?.userId)
|
||||
&& String(props.member.id) === String(adminSession.value.userId))
|
||||
const isTargetPrivilegedRole = computed(() => ['owner', 'admin'].includes(props.member?.roleCode || form.roleCode))
|
||||
const shouldRenderRoleAsText = computed(() => isNewMember.value || !isCurrentAdminPrivileged.value)
|
||||
const canEditRoleSelect = computed(() => {
|
||||
if (shouldRenderRoleAsText.value || isSaving.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (currentAdminRoleCode.value === 'owner') {
|
||||
return !isEditingSelf.value
|
||||
}
|
||||
|
||||
if (currentAdminRoleCode.value === 'admin') {
|
||||
return !isTargetPrivilegedRole.value
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
const availableRoleOptions = computed(() => {
|
||||
if (!canEditRoleSelect.value) {
|
||||
return roleOptions
|
||||
}
|
||||
|
||||
if (currentAdminRoleCode.value === 'admin') {
|
||||
return roleOptions.filter((option) => ['vip', 'member'].includes(option.value))
|
||||
}
|
||||
|
||||
return roleOptions
|
||||
})
|
||||
const roleHelpText = computed(() => {
|
||||
if (shouldRenderRoleAsText.value) {
|
||||
return '멤버와 VIP는 관리자 권한이 없어 등급을 변경할 수 없습니다.'
|
||||
}
|
||||
|
||||
if (isEditingSelf.value && currentAdminRoleCode.value === 'owner') {
|
||||
return '소유자는 본인 권한을 직접 낮출 수 없습니다.'
|
||||
}
|
||||
|
||||
if (currentAdminRoleCode.value === 'admin' && isTargetPrivilegedRole.value) {
|
||||
return '관리자는 소유자 또는 다른 관리자의 등급을 변경할 수 없습니다.'
|
||||
}
|
||||
|
||||
if (currentAdminRoleCode.value === 'admin') {
|
||||
return '관리자는 멤버와 VIP 등급만 변경할 수 있습니다.'
|
||||
}
|
||||
|
||||
return 'VIP 이상 등급은 멤버십 게시물을 볼 수 있습니다.'
|
||||
})
|
||||
|
||||
/**
|
||||
* 회원 저장 요청 본문을 문자열로 직렬화한다.
|
||||
@@ -383,7 +441,7 @@ const saveMember = async () => {
|
||||
|
||||
try {
|
||||
const payload = getMemberPayload()
|
||||
if (!isNewMember.value && form.roleCode !== props.member?.roleCode) {
|
||||
if (!isNewMember.value && canEditRoleSelect.value && form.roleCode !== props.member?.roleCode) {
|
||||
await $fetch(`/admin/api/members/${props.member.id}/role`, {
|
||||
method: 'PUT',
|
||||
body: {
|
||||
@@ -558,17 +616,28 @@ watch(() => props.member, () => {
|
||||
|
||||
<label class="admin-member-form__field mt-5 block">
|
||||
<span class="admin-member-form__label mb-2 block text-sm font-semibold text-[#15171a]">멤버 등급</span>
|
||||
<select
|
||||
v-model="form.roleCode"
|
||||
class="admin-member-form__select h-12 w-full rounded-md border border-transparent bg-[#eef1f4] px-4 text-sm text-[#15171a] outline-none focus:border-[#c5ccd5] focus:bg-white focus:ring-2 focus:ring-[#dce2e8] disabled:opacity-60"
|
||||
:disabled="isNewMember || isSaving"
|
||||
<span
|
||||
v-if="shouldRenderRoleAsText"
|
||||
class="admin-member-form__role-text flex h-12 w-full items-center rounded-md bg-[#eef1f4] px-4 text-sm font-semibold text-[#15171a]"
|
||||
>
|
||||
<option v-for="option in roleOptions" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
{{ currentRoleLabel }}
|
||||
</span>
|
||||
<span v-else class="admin-member-form__select-wrap relative block">
|
||||
<select
|
||||
v-model="form.roleCode"
|
||||
class="admin-member-form__select h-12 w-full appearance-none rounded-md border border-transparent bg-[#eef1f4] px-4 pr-10 text-sm text-[#15171a] outline-none focus:border-[#c5ccd5] focus:bg-white focus:ring-2 focus:ring-[#dce2e8] disabled:opacity-60"
|
||||
:disabled="!canEditRoleSelect"
|
||||
>
|
||||
<option v-for="option in availableRoleOptions" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
<svg class="pointer-events-none absolute right-4 top-1/2 size-4 -translate-y-1/2 text-[#394047]" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="admin-member-form__hint mt-2 block text-sm text-[#8a95a5]">
|
||||
VIP 이상 등급은 멤버십 게시물을 볼 수 있습니다.
|
||||
{{ roleHelpText }}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
|
||||
@@ -40,6 +40,23 @@ const htmlCursorRange = reactive({
|
||||
end: 0
|
||||
})
|
||||
|
||||
const defaultHtmlDocument = `<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Landing</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
const form = reactive({
|
||||
title: props.initialPage.title || '',
|
||||
slug: props.initialPage.slug || '',
|
||||
@@ -203,6 +220,42 @@ const insertTextAtHtmlCursor = async (text) => {
|
||||
htmlCursorRange.end = nextCursor
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML 기본 문서 골격을 현재 본문에 채운다.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const completeHtmlDocumentSkeleton = async () => {
|
||||
form.content = defaultHtmlDocument
|
||||
|
||||
await nextTick()
|
||||
|
||||
const bodyIndex = form.content.indexOf('</body>')
|
||||
const nextCursor = bodyIndex > -1 ? bodyIndex : form.content.length
|
||||
htmlEditor.value?.focus()
|
||||
htmlEditor.value?.setSelectionRange(nextCursor, nextCursor)
|
||||
htmlCursorRange.start = nextCursor
|
||||
htmlCursorRange.end = nextCursor
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML textarea에서 VS Code식 기본 골격 단축 입력을 처리한다.
|
||||
* @param {KeyboardEvent} event - 키보드 이벤트
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const handleHtmlEditorKeydown = async (event) => {
|
||||
if (event.key !== 'Tab' || form.renderMode !== 'html_document') {
|
||||
return
|
||||
}
|
||||
|
||||
const content = form.content.trim()
|
||||
if (content !== '' && content !== '!') {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
await completeHtmlDocumentSkeleton()
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지 HTML 자산을 업로드하고 본문 커서 위치에 URL을 삽입한다.
|
||||
* @param {Event} event - 파일 입력 이벤트
|
||||
@@ -355,20 +408,10 @@ defineExpose({
|
||||
@click="rememberHtmlCursor"
|
||||
@focus="rememberHtmlCursor"
|
||||
@input="rememberHtmlCursor"
|
||||
@keydown="handleHtmlEditorKeydown"
|
||||
@keyup="rememberHtmlCursor"
|
||||
@select="rememberHtmlCursor"
|
||||
placeholder="<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Landing</title>
|
||||
<style>
|
||||
body { margin: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
</body>
|
||||
</html>"
|
||||
:placeholder="defaultHtmlDocument"
|
||||
/>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user