From c6b430a19711177d2c6bb582b51fba264f696719 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 22 Sep 2026 15:23:00 +0900 Subject: [PATCH] =?UTF-8?q?=EC=83=81=EC=84=B8=ED=8E=98=EC=9D=B4=EC=A7=80?= =?UTF-8?q?=20UI=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/styles.css | 15 ++-- docs/qa-feedback-api-integration-task.md | 8 +++ egbim/app.js | 92 +++++++++++++++++++++++- egbim/detail.html | 2 +- src/index.js | 39 +++++++--- 5 files changed, 141 insertions(+), 15 deletions(-) diff --git a/assets/styles.css b/assets/styles.css index 4e8cc1c..d27d4df 100644 --- a/assets/styles.css +++ b/assets/styles.css @@ -274,6 +274,11 @@ button { cursor: pointer; } .attachment-item { padding: 7px 0; color: #555; font-size: 13px; } .attachments a { color: var(--navy); text-decoration: underline; } .detail-actions { display: flex; justify-content: space-between; padding: 23px 22px 0; } +.owner-actions { display: flex; gap: 8px; } +.button-danger { border-color: #c43d3d; background: #c43d3d; color: #fff; } +.button-danger:hover { border-color: #a52f2f; background: #a52f2f; } +.edit-form { padding-top: 24px; border-top: 2px solid #242424; } +.edit-form h3 { margin: 0; padding: 0 22px 18px; font-size: 18px; } .comment-box { margin-top: 54px; } .comment-box h3 { padding-bottom: 15px; margin: 0; border-bottom: 2px solid #222; font-size: 17px; } .comment { padding: 19px 10px; border-bottom: 1px solid var(--line); } @@ -282,9 +287,9 @@ button { cursor: pointer; } .comment-empty { padding: 32px; border-bottom: 1px solid var(--line); color: #999; text-align: center; } .comment-form { padding: 20px 10px 0; } .comment-form .textarea { min-height: 120px; } -.comment-attachment-input { padding-top: 14px; color: #555; font-size: 13px; } -.comment-attachment-input label { display: block; margin-bottom: 8px; font-weight: 600; } -.comment-attachment-input input { max-width: 100%; } +.comment-attachment-input { padding-top: 14px; } +.comment-attachment-input input { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; clip-path: inset(50%); } +.comment-image-button { height: 34px; padding: 0 13px; font-size: 13px; } .comment-image-preview, .comment-attachments-list { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 10px; } .comment-image-preview-item { width: 104px; overflow: hidden; border: 1px solid #ddd; background: #fafafa; } .comment-image-preview-item img { display: block; width: 104px; height: 78px; object-fit: cover; } @@ -332,7 +337,7 @@ button { cursor: pointer; } .qa-list-page .sub-nav a[aria-current='page'], .egbim-page .sub-nav a[aria-current='page'] { background: rgba(0, 121, 116, .5); color: #ffc400; } .qa-list-page .sub-nav a.disabled-tab, .egbim-page .sub-nav a.disabled-tab { cursor: default; } .qa-list-page .sub-nav a.disabled-tab:hover, .egbim-page .sub-nav a.disabled-tab:hover { background: transparent; color: #fff; } -.qa-list-page .page-shell { max-width: none; padding: 51px 10px 62px; } +.qa-list-page .page-shell { width: calc(100% - 80px); max-width: 1200px; padding: 51px 0 62px; margin-right: auto; margin-left: auto; } .egbim-page .page-shell { padding-top: 51px; } .qa-list-heading { margin: 0 0 40px; text-align: center; } .qa-list-heading h1 { margin: 0; font-size: 30px; line-height: 1.35; letter-spacing: -.06em; } @@ -380,7 +385,7 @@ button { cursor: pointer; } .qa-list-page .page-hero p, .egbim-page .page-hero p { font-size: 16px; } .qa-list-page .sub-nav-inner, .egbim-page .sub-nav-inner { max-width: none; } .qa-list-page .sub-nav a, .egbim-page .sub-nav a { padding: 14px 10px; font-size: 14px; } - .qa-list-page .page-shell { padding: 39px 14px 55px; } + .qa-list-page .page-shell { width: 100%; max-width: none; padding: 39px 14px 55px; } .qa-list-heading { margin-bottom: 28px; } .qa-list-heading h1 { font-size: 26px; } .qa-list-page .filter-row { flex-wrap: wrap; } diff --git a/docs/qa-feedback-api-integration-task.md b/docs/qa-feedback-api-integration-task.md index 61ce642..b33561d 100644 --- a/docs/qa-feedback-api-integration-task.md +++ b/docs/qa-feedback-api-integration-task.md @@ -85,6 +85,14 @@ - [x] 상세페이지 진입 시 최신 상태 재조회 - [ ] 실제 관리페이지에서 상태 변경 후 Q&A 화면 반영 테스트 +## 5차 범위: 작성자 문의 수정·삭제 + +- [x] 작성자 본인 확인 후 상세페이지 수정·삭제 버튼 표시 +- [x] Worker `PATCH /api/feedbacks/{feedbackId}` 프록시 추가 +- [x] Worker `DELETE /api/feedbacks/{feedbackId}` 프록시 추가 +- [x] 제목·내용·카테고리·비밀글 수정 UI 추가 +- [ ] 실제 배포 환경에서 본인/타인 수정·삭제 권한 테스트 + ## 보안 요구사항 - [x] API Key를 정적 JavaScript, HTML, `egbim/config.js`에 넣지 않음 diff --git a/egbim/app.js b/egbim/app.js index cc8975f..e98b433 100644 --- a/egbim/app.js +++ b/egbim/app.js @@ -46,6 +46,19 @@ localStorage.setItem(STORAGE_KEY, JSON.stringify(posts)); } + function removeStoredPost(id) { + const posts = readJson(STORAGE_KEY, []).filter(function (post) { return post.id !== id; }); + localStorage.setItem(STORAGE_KEY, JSON.stringify(posts)); + } + + function isPostOwner(post, user) { + if (!post || !user) return false; + const postIds = [post.requesterId, post.requesterUuid].filter(Boolean); + const userIds = [user.requesterId, user.ssoSubject, user.userUuid].filter(Boolean); + if (postIds.length && userIds.length) return postIds.some(function (id) { return userIds.indexOf(id) > -1; }); + return Boolean(post.author && [user.name, user.loginId, user.email].filter(Boolean).indexOf(post.author) > -1); + } + function normalizeStatus(value) { if (value && typeof value === 'object') value = value.code || value.key || value.value || value.name || ''; const raw = String(value || '').trim(); @@ -365,7 +378,7 @@ if (!qs('#category').value || !title || !content) { errorBox.textContent = '구분, 제목, 내용을 모두 입력해주세요.'; errorBox.style.display = 'block'; return; } const feedbackId = makeUuid(); const createdAt = new Date().toISOString(); - const post = { id: feedbackId, feedbackId: feedbackId, category: qs('#category').value, company: currentUser.familyCompany || currentUser.company || '외부 사용자', department: currentUser.department || '-', author: currentUser.name || currentUser.loginId, title: title, date: createdAt.slice(0, 10), createdAt: createdAt, status: '접수', secret: qs('#secret').checked, content: content }; + const post = { id: feedbackId, feedbackId: feedbackId, requesterId: currentUser.requesterId, requesterTenantId: currentUser.requesterTenantId, category: qs('#category').value, company: currentUser.familyCompany || currentUser.company || '외부 사용자', department: currentUser.department || '-', author: currentUser.name || currentUser.loginId, title: title, date: createdAt.slice(0, 10), createdAt: createdAt, status: '접수', secret: qs('#secret').checked, content: content }; const submitButton = qs('#submitButton'); submitButton.disabled = true; submitButton.textContent = '등록 중...'; @@ -413,6 +426,83 @@ if (attachmentBox) attachmentBox.innerHTML = attachments.length ? attachments.map(function (attachment) { return '
📎 ' + escapeHtml(attachment.originalFileName || '첨부파일') + '
'; }).join('') : '첨부된 파일이 없습니다.'; + const editButton = qs('#editButton'); + const deleteButton = qs('#deleteButton'); + const editForm = qs('#editForm'); + const editError = qs('#editError'); + const ownerActions = function () { + const owner = isPostOwner(post, getAuthUser()); + if (editButton) editButton.hidden = !owner; + if (deleteButton) deleteButton.hidden = !owner; + }; + ownerActions(); + if (authReady) authReady.then(ownerActions); + if (editButton && editForm) editButton.addEventListener('click', function () { + qs('#editCategory').value = post.category || '일반문의'; + qs('#editTitle').value = post.title || ''; + qs('#editContent').value = post.content || ''; + qs('#editSecret').checked = Boolean(post.secret); + editError.hidden = true; + editError.style.display = 'none'; + editForm.hidden = false; + editForm.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }); + const editCancel = qs('#editCancel'); + if (editCancel && editForm) editCancel.addEventListener('click', function () { editForm.hidden = true; }); + if (editForm) editForm.addEventListener('submit', async function (event) { + event.preventDefault(); + editError.hidden = true; + editError.style.display = 'none'; + const title = qs('#editTitle').value.trim(); + const content = qs('#editContent').value.trim(); + const category = qs('#editCategory').value; + if (!title || !content || !category) { + editError.textContent = '구분, 제목, 내용을 모두 입력해주세요.'; + editError.hidden = false; + editError.style.display = 'block'; + return; + } + const saveButton = qs('button[type="submit"]', editForm); + saveButton.disabled = true; + saveButton.textContent = '저장 중...'; + try { + await requestJson('/api/feedbacks/' + encodeURIComponent(post.id), { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title: title, contents: content, Category: CATEGORY_CODES[category], is_secret: qs('#editSecret').checked ? 'true' : 'false' }) + }); + post.category = category; + post.title = title; + post.content = content; + post.secret = qs('#editSecret').checked; + updateStoredPost(post); + qs('[data-detail-category]').textContent = post.category; + qs('[data-detail-title]').textContent = post.title; + qs('[data-detail-content]').textContent = post.content; + qs('[data-detail-secret]').hidden = !post.secret; + editForm.hidden = true; + showToast('문의가 수정되었습니다.'); + } catch (error) { + editError.textContent = error.message || '문의 수정에 실패했습니다.'; + editError.hidden = false; + editError.style.display = 'block'; + } finally { + saveButton.disabled = false; + saveButton.textContent = '수정 저장'; + } + }); + if (deleteButton) deleteButton.addEventListener('click', async function () { + if (!window.confirm('이 문의를 삭제하시겠습니까?')) return; + deleteButton.disabled = true; + try { + await requestJson('/api/feedbacks/' + encodeURIComponent(post.id), { method: 'DELETE' }); + removeStoredPost(post.id); + window.location.href = 'index.html'; + } catch (error) { + deleteButton.disabled = false; + showToast(error.message || '문의 삭제에 실패했습니다.'); + } + }); function safeMediaUrl(value) { value = String(value || ''); return /^(https?:\/\/|\/|blob:)/i.test(value) ? value : ''; diff --git a/egbim/detail.html b/egbim/detail.html index 68295b9..8d776c9 100644 --- a/egbim/detail.html +++ b/egbim/detail.html @@ -3,6 +3,6 @@ 문의 상세 | EG-BIM Q&A
EG-BIM SUPPORT

Q&A

EG-BIM 관련 문의하기

-

문의 상세

문의 내용과 답변을 확인할 수 있습니다.

문의 문의 제목

문의접수
2026-09-17작성자회사부서

첨부파일

첨부된 파일이 없습니다.

답변 0

이미지 파일만 등록할 수 있으며, 파일당 최대 30MB입니다.
+

문의 상세

문의 내용과 답변을 확인할 수 있습니다.

문의 문의 제목

문의접수
2026-09-17작성자회사부서

첨부파일

첨부된 파일이 없습니다.
목록

답변 0

diff --git a/src/index.js b/src/index.js index a37213f..ceaa4bb 100644 --- a/src/index.js +++ b/src/index.js @@ -506,7 +506,7 @@ async function feedbackComments(request, env, url) { } async function feedbackDetail(request, env, url) { - if (request.method !== 'GET') return json({ error: 'method_not_allowed' }, 405, { allow: 'GET' }); + if (!['GET', 'PATCH', 'DELETE'].includes(request.method)) return json({ error: 'method_not_allowed' }, 405, { allow: 'GET, PATCH, DELETE' }); const user = await getSession(request, env); if (!user) return json({ error: 'unauthenticated' }, 401); if (!env.ABC_API_KEY) return json({ error: 'feedback_api_not_configured' }, 503); @@ -520,16 +520,37 @@ async function feedbackDetail(request, env, url) { if (!requesterId || !requesterTenantId) return json({ error: 'requester_identity_missing' }, 422); const apiBaseUrl = String(env.ABC_API_BASE_URL || 'https://feedback.hmac.kr').replace(/\/$/, ''); const endpoint = `${apiBaseUrl}/api/projects/${encodeURIComponent(env.ABC_PROJECT_ID)}/channels/${encodeURIComponent(env.ABC_CHANNEL_ID)}/feedbacks/${encodeURIComponent(feedbackId)}`; + const headers = { + accept: 'application/json', + 'x-api-key': env.ABC_API_KEY, + 'x-requester-id': requesterId, + 'x-requester-tenant-id': requesterTenantId + }; + let body; + if (request.method === 'PATCH') { + let input; + try { + input = await request.json(); + } catch (error) { + return json({ error: 'invalid_json' }, 400); + } + if (!input || typeof input !== 'object' || Array.isArray(input)) return json({ error: 'invalid_json' }, 400); + const payload = {}; + if (input.title != null) payload.title = String(input.title).trim(); + if (input.contents != null) payload.contents = String(input.contents).trim(); + if (input.Category != null) payload.Category = String(input.Category).trim(); + if (input.is_secret != null) payload.is_secret = input.is_secret === true || input.is_secret === 'true' || input.is_secret === 1 || input.is_secret === '1' ? 'true' : 'false'; + if (payload.title === '' || payload.contents === '' || payload.Category === '') return json({ error: 'invalid_feedback_update' }, 400); + if (!Object.keys(payload).length) return json({ error: 'feedback_update_required' }, 400); + headers['content-type'] = 'application/json'; + body = JSON.stringify(payload); + } let response; try { response = await fetch(endpoint, { - method: 'GET', - headers: { - accept: 'application/json', - 'x-api-key': env.ABC_API_KEY, - 'x-requester-id': requesterId, - 'x-requester-tenant-id': requesterTenantId - } + method: request.method, + headers, + body }); } catch (error) { console.error('feedback_detail_api_request_failed', error instanceof Error ? error.message : 'unknown'); @@ -540,6 +561,8 @@ async function feedbackDetail(request, env, url) { const upstreamMessage = String(result.message || result.error || result.code || '').slice(0, 240); return json({ error: 'feedback_api_rejected', message: upstreamMessage || '문의 상세 조회에 실패했습니다.', status: response.status }, response.status >= 500 ? 502 : response.status); } + if (request.method === 'DELETE') return json({ ok: true, id: feedbackId }); + if (request.method === 'PATCH') return json({ feedback: result.feedback || result.data || result }); return json({ feedback: result.feedback || result.data || result }); }