design-merge 강제 병합 - 디자인 반영본 전체 통합
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@@ -1,14 +1,14 @@
|
||||
spring:
|
||||
jta:
|
||||
enabled: true
|
||||
enabled: false
|
||||
config:
|
||||
activate:
|
||||
on-profile: dev
|
||||
jpa:
|
||||
properties:
|
||||
hibernate:
|
||||
show_sql: true
|
||||
format_sql: true
|
||||
show_sql: false
|
||||
format_sql: false
|
||||
use_sql_comments: true
|
||||
devtools:
|
||||
restart:
|
||||
@@ -35,7 +35,7 @@ gateway:
|
||||
|
||||
hibernate-dialect: org.hibernate.dialect.Oracle12cDialect
|
||||
hibernate-physical-naming-strategy: com.eactive.apim.portal.common.entity.CustomPhysicalNamingStrategy
|
||||
entity-package: com.eactive.eai.data.entity.onl
|
||||
entity-package: com.eactive.eai.data.entity.onl,com.eactive.apim.gateway.data.statistics.entity
|
||||
|
||||
|
||||
portal:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
spring:
|
||||
jta:
|
||||
enabled: true
|
||||
enabled: false
|
||||
config:
|
||||
activate:
|
||||
on-profile: prod
|
||||
@@ -29,4 +29,10 @@ spring:
|
||||
cache: false
|
||||
|
||||
portal:
|
||||
test-auth-notice-enabled: false # prod 환경: UI 안내 미표시
|
||||
test-auth-notice-enabled: false # prod 환경: UI 안내 미표시
|
||||
|
||||
app:
|
||||
resource-versioning:
|
||||
enabled: true
|
||||
resource-caching:
|
||||
enabled: true
|
||||
@@ -1,6 +1,6 @@
|
||||
spring:
|
||||
jta:
|
||||
enabled: true
|
||||
enabled: false
|
||||
web:
|
||||
resources:
|
||||
cache:
|
||||
@@ -46,5 +46,5 @@ gateway:
|
||||
|
||||
hibernate-dialect: org.hibernate.dialect.Oracle12cDialect
|
||||
hibernate-physical-naming-strategy: com.eactive.apim.portal.common.entity.CustomPhysicalNamingStrategy
|
||||
entity-package: com.eactive.eai.data.entity.onl
|
||||
entity-package: com.eactive.eai.data.entity.onl,com.eactive.apim.gateway.data.statistics.entity
|
||||
|
||||
|
||||
@@ -2,7 +2,13 @@ server:
|
||||
servlet:
|
||||
context-path: /
|
||||
session:
|
||||
timeout: 15m
|
||||
# 물리 세션 타임아웃은 DB PortalProperty(Portal/session.timeout.minutes)로 관리한다.
|
||||
# 로그인 성공 시 PortalAuthenticationSuccessHandler 가
|
||||
# session.setMaxInactiveInterval(session.timeout.minutes * 60) 으로 적용 → 물리=논리 일치.
|
||||
# 익명/로그인 전 세션은 컨테이너 기본값으로 fallback (weblogic.xml <timeout-secs>1800).
|
||||
# timeout: 10m
|
||||
cookie:
|
||||
name: JSESSIONID_PORTAL
|
||||
encoding:
|
||||
charset: UTF-8
|
||||
port: '39130'
|
||||
@@ -41,10 +47,11 @@ spring:
|
||||
# 아래 app.resource-versioning.enabled 토글을 읽어 직접 수행한다.
|
||||
|
||||
jta:
|
||||
enabled: true
|
||||
enabled: false
|
||||
atomikos:
|
||||
properties:
|
||||
log-base-dir: /logs/atomikos
|
||||
# log-base-dir: /logs/eapim/${inst.Name:devSvr11}/atomikos
|
||||
log-base-dir: /dev/null
|
||||
log-base-name: adp_tx
|
||||
|
||||
thymeleaf:
|
||||
@@ -61,7 +68,12 @@ spring:
|
||||
# prod 는 PortalConfigWebDispatcherServlet 에서 항상 ON 으로 고정되어 이 값을 무시함.
|
||||
app:
|
||||
resource-versioning:
|
||||
enabled: true
|
||||
enabled: false
|
||||
# 정적자원 서버측 캐싱(ResourceChain 캐시) 토글 (prod 제외 전 프로파일에 적용).
|
||||
# false 면 sass/JS 변경이 서버 재시작 없이 즉시 반영됨(매 요청 재해석).
|
||||
# prod 는 PortalConfigWebDispatcherServlet 에서 항상 ON 으로 고정되어 이 값을 무시함.
|
||||
resource-caching:
|
||||
enabled: false
|
||||
|
||||
security:
|
||||
basic:
|
||||
@@ -88,7 +100,7 @@ gateway:
|
||||
|
||||
hibernate-dialect: org.hibernate.dialect.Oracle12cDialect
|
||||
hibernate-physical-naming-strategy: com.eactive.apim.portal.common.entity.CustomPhysicalNamingStrategy
|
||||
entity-package: com.eactive.eai.data.entity.onl
|
||||
entity-package: com.eactive.eai.data.entity.onl,com.eactive.apim.gateway.data.statistics.entity
|
||||
|
||||
portal:
|
||||
file:
|
||||
@@ -252,6 +264,9 @@ page:
|
||||
verification_email:
|
||||
name: "이메일 인증"
|
||||
path: "/signup/verification-email"
|
||||
decision:
|
||||
name: "법인회원 초대"
|
||||
path: "/signup/decision_process"
|
||||
agreements:
|
||||
name: 약관
|
||||
path: "#"
|
||||
@@ -305,6 +320,9 @@ page:
|
||||
faq_list:
|
||||
name: FAQ
|
||||
path: "/faq_list"
|
||||
faq_detail:
|
||||
name: FAQ
|
||||
path: "/faq_view"
|
||||
inquiry:
|
||||
name: "Q&A"
|
||||
path: "/inquiry"
|
||||
@@ -369,9 +387,6 @@ page:
|
||||
myapikey_modify_step3:
|
||||
name: "앱 수정 요청 완료"
|
||||
path: "/myapikey/modify/step3"
|
||||
commission:
|
||||
name: "과금 관리"
|
||||
path: "/commission/manage"
|
||||
api_statistics:
|
||||
name: "이용 통계"
|
||||
path: "/statistics/api"
|
||||
|
||||
@@ -5,10 +5,20 @@
|
||||
|
||||
<property name="LOG_PATH" value="${profileLogPath}/${inst.Name:-devSvr00}"/>
|
||||
|
||||
|
||||
<!-- <property name="CONSOLE_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} %magenta([%thread]) %highlight([%-3level]) %logger{5} - %msg %n" />-->
|
||||
<property name="CONSOLE_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} %magenta([%thread]) %highlight([%-3level]) %logger - %msg %n" />
|
||||
|
||||
<!-- 콘솔 로그 JVM 파라미터 제어 -->
|
||||
<!-- 사용: -DCONSOLE_LOG_ENABLED=true -DCONSOLE_LOG_LEVEL=DEBUG -->
|
||||
<!-- 기본값: 비활성(콘솔 OFF), 활성 시 레벨 INFO -->
|
||||
<property name="CONSOLE_LOG_ENABLED" value="${CONSOLE_LOG_ENABLED:-false}"/>
|
||||
<property name="CONSOLE_LOG_LEVEL" value="${CONSOLE_LOG_LEVEL:-INFO}"/>
|
||||
<!-- enabled=true → 지정 레벨, false → OFF (대소문자 모두 대응) -->
|
||||
<property name="__consoleLevel.true" value="${CONSOLE_LOG_LEVEL}"/>
|
||||
<property name="__consoleLevel.TRUE" value="${CONSOLE_LOG_LEVEL}"/>
|
||||
<property name="__consoleLevel.false" value="OFF"/>
|
||||
<property name="__consoleLevel.FALSE" value="OFF"/>
|
||||
<property name="CONSOLE_EFFECTIVE_LEVEL" value="${__consoleLevel.${CONSOLE_LOG_ENABLED}}"/>
|
||||
|
||||
|
||||
<appender name="HIBERNATE_APPENDER" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||
<file>${LOG_PATH}/hibernate.log</file>
|
||||
@@ -47,6 +57,9 @@
|
||||
</appender>
|
||||
|
||||
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
|
||||
<level>${CONSOLE_EFFECTIVE_LEVEL}</level>
|
||||
</filter>
|
||||
<encoder><Pattern>${CONSOLE_PATTERN}</Pattern></encoder>
|
||||
</appender>
|
||||
|
||||
@@ -58,6 +71,7 @@
|
||||
|
||||
<root level="INFO">
|
||||
<appender-ref ref="ROLLING"/>
|
||||
<appender-ref ref="CONSOLE"/>
|
||||
</root>
|
||||
|
||||
<springProfile name="dev">
|
||||
@@ -66,15 +80,4 @@
|
||||
<appender-ref ref="CONSOLE"/>
|
||||
</root>
|
||||
</springProfile>
|
||||
|
||||
<springProfile name="stage">
|
||||
<root level="INFO">
|
||||
<appender-ref ref="ROLLING"/>
|
||||
</root>
|
||||
</springProfile>
|
||||
|
||||
|
||||
<springProfile name="stage,dev">
|
||||
|
||||
</springProfile>
|
||||
</configuration>
|
||||
@@ -1,18 +1,74 @@
|
||||
/* API Statistics Page Styles */
|
||||
|
||||
.api-statistics-container {
|
||||
/*max-width: 1200px;*/
|
||||
margin: 0 auto;
|
||||
padding: 48px 0px;
|
||||
}
|
||||
|
||||
/* Search Section */
|
||||
.statistics-notice {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
padding: 12px 18px;
|
||||
background: #eef4fc;
|
||||
border: 1px solid #d6e4f5;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
color: #4a5568;
|
||||
}
|
||||
|
||||
.statistics-notice-latest {
|
||||
color: #0049B4;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.statistics-search-section {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.search-mode-tabs {
|
||||
display: inline-flex;
|
||||
background: #eef1f5;
|
||||
border-radius: 22px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.mode-tab {
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 7px 22px;
|
||||
border-radius: 18px;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.mode-tab.active {
|
||||
background: #0049B4;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.month-range-picker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.month-input {
|
||||
border: none;
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.search-filter-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -54,16 +110,13 @@
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-search:hover {
|
||||
background: #003d99;
|
||||
}
|
||||
|
||||
.btn-search svg {
|
||||
stroke: #fff;
|
||||
}
|
||||
|
||||
/* Summary Card */
|
||||
.statistics-summary-card {
|
||||
background: #f5f8fc;
|
||||
border-radius: 16px;
|
||||
@@ -103,11 +156,9 @@
|
||||
outline: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.app-select:hover {
|
||||
background-color: #f0f5ff;
|
||||
}
|
||||
|
||||
.app-select:focus {
|
||||
border-color: #003d99;
|
||||
box-shadow: 0 0 0 3px rgba(0, 73, 180, 0.1);
|
||||
@@ -122,7 +173,6 @@
|
||||
color: #333;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.summary-total-text strong {
|
||||
font-size: 24px;
|
||||
color: #0049B4;
|
||||
@@ -140,11 +190,12 @@
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.summary-detail-item.success .detail-icon {
|
||||
color: #4A90D9;
|
||||
}
|
||||
|
||||
.summary-detail-item.timeout .detail-icon {
|
||||
color: #F5C36B;
|
||||
}
|
||||
.summary-detail-item.failure .detail-icon {
|
||||
color: #F5A3B5;
|
||||
}
|
||||
@@ -163,7 +214,6 @@
|
||||
color: #999;
|
||||
}
|
||||
|
||||
/* Donut Chart */
|
||||
.summary-center {
|
||||
flex: 0 0 200px;
|
||||
display: flex;
|
||||
@@ -180,7 +230,6 @@
|
||||
transition: stroke-dasharray 0.3s ease, stroke-dashoffset 0.3s ease;
|
||||
}
|
||||
|
||||
/* Rate Display */
|
||||
.summary-right {
|
||||
flex: 0 0 230px;
|
||||
display: flex;
|
||||
@@ -195,7 +244,6 @@
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.rate-display:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
@@ -205,11 +253,12 @@
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.rate-indicator.success {
|
||||
background: #4A90D9;
|
||||
}
|
||||
|
||||
.rate-indicator.timeout {
|
||||
background: #F5C36B;
|
||||
}
|
||||
.rate-indicator.failure {
|
||||
background: #F5A3B5;
|
||||
}
|
||||
@@ -228,6 +277,10 @@
|
||||
color: #4A90D9;
|
||||
}
|
||||
|
||||
.timeout-rate .rate-value {
|
||||
color: #F5C36B;
|
||||
}
|
||||
|
||||
.failure-rate .rate-value {
|
||||
color: #F5A3B5;
|
||||
}
|
||||
@@ -237,12 +290,15 @@
|
||||
color: #999;
|
||||
}
|
||||
|
||||
/* Statistics Table Card */
|
||||
.statistics-table-card {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.statistics-table-card:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
@@ -262,7 +318,6 @@
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.table-title svg {
|
||||
stroke: #fff;
|
||||
}
|
||||
@@ -279,7 +334,6 @@
|
||||
font-size: 14px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-download:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
color: #fff;
|
||||
@@ -293,7 +347,6 @@
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.statistics-table thead th {
|
||||
padding: 16px 20px;
|
||||
text-align: left;
|
||||
@@ -302,16 +355,23 @@
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.statistics-table tbody td {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.statistics-table tbody tr:hover {
|
||||
background: #f9f9f9;
|
||||
}
|
||||
.statistics-table tfoot .total-row {
|
||||
background: #e3f2fd;
|
||||
}
|
||||
.statistics-table tfoot .total-row td {
|
||||
padding: 16px 20px;
|
||||
font-weight: 600;
|
||||
color: #0049B4;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.api-name {
|
||||
display: flex;
|
||||
@@ -328,20 +388,16 @@
|
||||
background: #f0f0f0;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.api-icon svg {
|
||||
stroke: #666;
|
||||
}
|
||||
|
||||
.api-icon.total {
|
||||
background: #e3f2fd;
|
||||
}
|
||||
|
||||
.api-icon.total svg {
|
||||
stroke: #0049B4;
|
||||
}
|
||||
|
||||
/* Progress Bar */
|
||||
.progress-cell {
|
||||
min-width: 200px;
|
||||
}
|
||||
@@ -359,83 +415,64 @@
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-timeout {
|
||||
background: #F5C36B;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-failure {
|
||||
background: #F5A3B5;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
/* Total Row */
|
||||
.statistics-table tfoot .total-row {
|
||||
background: #e3f2fd;
|
||||
}
|
||||
|
||||
.statistics-table tfoot .total-row td {
|
||||
padding: 16px 20px;
|
||||
font-weight: 600;
|
||||
color: #0049B4;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-row td {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 992px) {
|
||||
.summary-content {
|
||||
flex-direction: column;
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
.summary-left,
|
||||
.summary-center,
|
||||
.summary-right {
|
||||
flex: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.summary-center {
|
||||
order: -1;
|
||||
}
|
||||
|
||||
.summary-right {
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.api-statistics-container {
|
||||
padding: 20px 16px;
|
||||
}
|
||||
|
||||
.statistics-summary-card {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
|
||||
.app-toggle-buttons {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.statistics-table thead th,
|
||||
.statistics-table tbody td,
|
||||
.statistics-table tfoot td {
|
||||
padding: 12px 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.progress-cell {
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.search-filter-row {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@@ -5655,14 +5655,44 @@ select.form-control {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.session-timer-mobile {
|
||||
margin-right: 8px;
|
||||
font-size: 12px;
|
||||
.session-float {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
right: 20px;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 14px;
|
||||
background: #fff;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
.session-timer-mobile .session-timer-text {
|
||||
min-width: 34px;
|
||||
.session-float .session-keepalive {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin: 0;
|
||||
padding-left: 8px;
|
||||
border-left: 1px solid #e0e0e0;
|
||||
font-size: 12px;
|
||||
color: #64748B;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.session-float .session-keepalive input {
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.session-float {
|
||||
top: 12px;
|
||||
right: 72px;
|
||||
padding: 4px 10px;
|
||||
}
|
||||
}
|
||||
.data-table {
|
||||
width: 100%;
|
||||
background: #FFFFFF;
|
||||
@@ -12245,7 +12275,7 @@ body.index-page-body {
|
||||
}
|
||||
}
|
||||
.signup-selection-page {
|
||||
min-height: calc(100vh - 140px);
|
||||
min-height: calc(100vh - 380px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -22004,343 +22034,6 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
transform: translateY(4px);
|
||||
}
|
||||
|
||||
.commission-container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.wrap {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
background: #FFFFFF;
|
||||
padding: 24px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(75, 155, 255, 0.1);
|
||||
}
|
||||
|
||||
.searchInfo {
|
||||
display: inline-block;
|
||||
margin-right: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.searchBox {
|
||||
background: #f8f9fa;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.searchBox.row-two {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.searchBox label {
|
||||
font-weight: 500;
|
||||
color: #1A1A2E;
|
||||
min-width: 80px;
|
||||
}
|
||||
.searchBox .form-control {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid #E2E8F0;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.form-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.form-inline .period-selects {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.form-inline .period-selects .period-unit {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.btnBox {
|
||||
margin-left: auto;
|
||||
}
|
||||
.btnBox .btn + .btn {
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
#printBtn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.commission-notice {
|
||||
color: #dc3545;
|
||||
margin-top: 30px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.voffset3 {
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.color_red {
|
||||
color: #FF6B6B;
|
||||
}
|
||||
|
||||
.color_blue {
|
||||
color: #007bff;
|
||||
}
|
||||
|
||||
.color_darkRed {
|
||||
color: #8b0000;
|
||||
}
|
||||
|
||||
.table-responsive {
|
||||
overflow-x: auto;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
}
|
||||
.table th {
|
||||
background: #F8FAFC;
|
||||
font-weight: 500;
|
||||
color: #1A1A2E;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
border: 1px solid #E2E8F0;
|
||||
}
|
||||
.table td {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
border: 1px solid #E2E8F0;
|
||||
}
|
||||
|
||||
.table-bordered {
|
||||
border: 1px solid #E2E8F0;
|
||||
}
|
||||
.table-bordered th,
|
||||
.table-bordered td {
|
||||
border: 1px solid #E2E8F0;
|
||||
}
|
||||
|
||||
.border-type {
|
||||
border: 2px solid #dee2e6;
|
||||
}
|
||||
|
||||
.commission-table-wrapper {
|
||||
margin-top: 30px;
|
||||
}
|
||||
.commission-table-wrapper .table {
|
||||
border: 2px solid #dee2e6;
|
||||
}
|
||||
|
||||
.summary-row {
|
||||
background-color: #fff3cd !important;
|
||||
}
|
||||
.summary-row td {
|
||||
font-weight: 600;
|
||||
}
|
||||
.summary-row .summary-amount {
|
||||
font-size: 16px;
|
||||
color: #007bff;
|
||||
}
|
||||
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.text-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.text-left {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.bg-warning {
|
||||
background-color: #fff3cd !important;
|
||||
}
|
||||
|
||||
.glyphicon-search::before {
|
||||
content: "🔍";
|
||||
}
|
||||
|
||||
.info-table {
|
||||
border-top: 10px solid #64748B;
|
||||
width: 100%;
|
||||
border-bottom: 2px solid #1A1A2E;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
.info-table th {
|
||||
text-align: left;
|
||||
padding: 16px 24px;
|
||||
font-weight: 600;
|
||||
background: none;
|
||||
border: none;
|
||||
}
|
||||
.info-table td {
|
||||
padding: 16px 24px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.commission-table {
|
||||
width: 100%;
|
||||
margin-top: 40px;
|
||||
}
|
||||
.commission-table th,
|
||||
.commission-table td {
|
||||
border: 1px solid #999 !important;
|
||||
padding: 16px 24px;
|
||||
}
|
||||
.commission-table td {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.bg_ygreen {
|
||||
background-color: aquamarine;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bg_skyBlue {
|
||||
background-color: skyblue;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bg_yellow {
|
||||
background-color: yellow;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media print {
|
||||
body {
|
||||
padding: 0;
|
||||
background: #FFFFFF;
|
||||
}
|
||||
.searchBox,
|
||||
.searchInfo,
|
||||
#printButton,
|
||||
#backButton,
|
||||
#printBtn {
|
||||
display: none !important;
|
||||
}
|
||||
@page {
|
||||
size: auto;
|
||||
margin: 0 0cm;
|
||||
}
|
||||
html {
|
||||
margin: 0 0cm;
|
||||
}
|
||||
}
|
||||
@media screen and (max-width: 1024px) {
|
||||
.wrap {
|
||||
padding: 16px;
|
||||
}
|
||||
.searchBox {
|
||||
padding: 16px;
|
||||
}
|
||||
.searchBox.row-two {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
}
|
||||
.form-inline {
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.form-inline label {
|
||||
min-width: auto;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.form-inline .form-control {
|
||||
width: 100%;
|
||||
}
|
||||
.form-inline--period .period-selects {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
.form-inline--period .period-selects .form-control {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.form-inline--period .period-selects .period-unit {
|
||||
flex-shrink: 0;
|
||||
font-size: 14px;
|
||||
color: #1A1A2E;
|
||||
}
|
||||
.btnBox {
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.btnBox .btn {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.table-responsive {
|
||||
overflow-x: scroll;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
}
|
||||
body.commission-print-page {
|
||||
font-size: 12px;
|
||||
width: 600px;
|
||||
margin: 90px;
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
body.commission-print-page div {
|
||||
position: relative;
|
||||
}
|
||||
body.commission-print-page table {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
body.commission-print-page table th,
|
||||
body.commission-print-page table td {
|
||||
padding: 5px 10px;
|
||||
}
|
||||
body.commission-print-page table tr {
|
||||
font-size: 12px;
|
||||
}
|
||||
body.commission-print-page h1 {
|
||||
text-align: center;
|
||||
}
|
||||
body.commission-print-page h1 img {
|
||||
width: 300px;
|
||||
}
|
||||
body.commission-print-page h2 {
|
||||
margin-top: 40px;
|
||||
float: left;
|
||||
}
|
||||
body.commission-print-page p {
|
||||
line-height: 1.6;
|
||||
}
|
||||
body.commission-print-page p[style*="float: right"] {
|
||||
margin-top: 25px;
|
||||
}
|
||||
body.commission-print-page .btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
body.commission-print-page .btn-primary {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
}
|
||||
body.commission-print-page .btn-primary:hover {
|
||||
background: #5a67d8;
|
||||
}
|
||||
|
||||
.terms-page {
|
||||
min-height: calc(100vh - 140px);
|
||||
background: #F8FAFC;
|
||||
@@ -24013,12 +23706,72 @@ body.commission-print-page .btn-primary:hover {
|
||||
padding: 48px 0px;
|
||||
}
|
||||
|
||||
.statistics-notice {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
padding: 12px 18px;
|
||||
background: #eef4fc;
|
||||
border: 1px solid #d6e4f5;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
color: #4a5568;
|
||||
}
|
||||
|
||||
.statistics-notice-latest {
|
||||
color: #0049B4;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.statistics-search-section {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.search-mode-tabs {
|
||||
display: inline-flex;
|
||||
background: #eef1f5;
|
||||
border-radius: 22px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.mode-tab {
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 7px 22px;
|
||||
border-radius: 18px;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.mode-tab.active {
|
||||
background: #0049B4;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.month-range-picker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.month-input {
|
||||
border: none;
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.search-filter-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -24143,6 +23896,9 @@ body.commission-print-page .btn-primary:hover {
|
||||
.summary-detail-item.success .detail-icon {
|
||||
color: #4A90D9;
|
||||
}
|
||||
.summary-detail-item.timeout .detail-icon {
|
||||
color: #F5C36B;
|
||||
}
|
||||
.summary-detail-item.failure .detail-icon {
|
||||
color: #F5A3B5;
|
||||
}
|
||||
@@ -24203,6 +23959,9 @@ body.commission-print-page .btn-primary:hover {
|
||||
.rate-indicator.success {
|
||||
background: #4A90D9;
|
||||
}
|
||||
.rate-indicator.timeout {
|
||||
background: #F5C36B;
|
||||
}
|
||||
.rate-indicator.failure {
|
||||
background: #F5A3B5;
|
||||
}
|
||||
@@ -24221,6 +23980,10 @@ body.commission-print-page .btn-primary:hover {
|
||||
color: #4A90D9;
|
||||
}
|
||||
|
||||
.timeout-rate .rate-value {
|
||||
color: #F5C36B;
|
||||
}
|
||||
|
||||
.failure-rate .rate-value {
|
||||
color: #F5A3B5;
|
||||
}
|
||||
@@ -24235,6 +23998,10 @@ body.commission-print-page .btn-primary:hover {
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.statistics-table-card:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
@@ -24351,6 +24118,11 @@ body.commission-print-page .btn-primary:hover {
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-timeout {
|
||||
background: #F5C36B;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-failure {
|
||||
background: #F5A3B5;
|
||||
transition: width 0.3s ease;
|
||||
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* client-guard.js
|
||||
*
|
||||
* 클라이언트측 하드닝. 서버가 내려준 window.__CLIENT_GUARD__ 플래그에 따라 각 기능을 독립 적용한다.
|
||||
* - contextmenu: 우클릭(컨텍스트 메뉴) 차단
|
||||
* - devtools : 창 크기 휴리스틱으로 DevTools 열림 감지 시 화면 전체 요소 영구 삭제
|
||||
*
|
||||
* 주의(한계): 창 크기 휴리스틱은 오탐/미탐이 있다(줌·북마크바·사이드패널·좁은창=오탐, undock 창=미탐).
|
||||
* JS 비활성화·breakpoint·DOM 편집으로 우회 가능하므로 실질 보안이 아니라 억제용 데코이다.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var cfg = window.__CLIENT_GUARD__ || {};
|
||||
|
||||
// ── 우클릭(contextmenu) 차단 ─────────────────────────────────────────
|
||||
if (cfg.contextmenu) {
|
||||
document.addEventListener('contextmenu', function (e) {
|
||||
e.preventDefault();
|
||||
}, true);
|
||||
}
|
||||
|
||||
// ── DevTools 감지 → 화면 영구 삭제 ───────────────────────────────────
|
||||
if (cfg.devtools) {
|
||||
var THRESHOLD = 160; // 도킹된 DevTools 패널이 차지하는 최소 폭/높이(px) 추정값
|
||||
var POLL_MS = 500;
|
||||
var wiped = false;
|
||||
|
||||
function isDevtoolsOpen() {
|
||||
var widthGap = window.outerWidth - window.innerWidth;
|
||||
var heightGap = window.outerHeight - window.innerHeight;
|
||||
return widthGap > THRESHOLD || heightGap > THRESHOLD;
|
||||
}
|
||||
|
||||
function wipe() {
|
||||
if (wiped) {
|
||||
return;
|
||||
}
|
||||
wiped = true;
|
||||
// 화면 전체 요소 영구 삭제 (닫아도 복구되지 않음, 새로고침 필요)
|
||||
try {
|
||||
document.documentElement.replaceChildren();
|
||||
} catch (ignore) {
|
||||
if (document.body) {
|
||||
document.body.innerHTML = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function check() {
|
||||
if (!wiped && isDevtoolsOpen()) {
|
||||
wipe();
|
||||
}
|
||||
}
|
||||
|
||||
window.setInterval(check, POLL_MS);
|
||||
window.addEventListener('resize', check);
|
||||
check();
|
||||
}
|
||||
})();
|
||||
@@ -12,6 +12,7 @@
|
||||
const formEl = document.getElementById('djbCommentForm');
|
||||
const inputEl = document.getElementById('djbCommentInput');
|
||||
const counterEl = document.getElementById('djbCommentCharCounter');
|
||||
const privateEl = document.getElementById('djbCommentPrivate');
|
||||
|
||||
function getCsrfToken() {
|
||||
// 세션 기반 CSRF: 쿠키 대신 <meta name="_csrf">에서 토큰을 읽는다.
|
||||
@@ -74,16 +75,28 @@
|
||||
}
|
||||
comments.forEach(function (c) {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'djb-comment-item' + (c.adminYn === 'Y' ? ' djb-comment-item--admin' : '');
|
||||
var cls = 'djb-comment-item';
|
||||
if (c.adminYn === 'Y') cls += ' djb-comment-item--admin';
|
||||
if (c.privatePlaceholder) cls += ' djb-comment-item--private-hidden';
|
||||
else if (c.privateComment) cls += ' djb-comment-item--private';
|
||||
li.className = cls;
|
||||
li.setAttribute('data-comment-id', c.id);
|
||||
|
||||
var writerHtml = (c.adminYn === 'Y')
|
||||
? '<span class="djb-comment-admin-badge">관리자</span>'
|
||||
: '<span class="djb-comment-writer">' + escapeHtml(c.writerName || '(알 수 없음)') + '</span>';
|
||||
// 비공개 댓글(열람 권한 있는 경우)에는 "비공개" 태그 표시
|
||||
var privateTag = (c.privateComment && !c.privatePlaceholder)
|
||||
? '<span class="djb-comment-private-tag">비공개</span>' : '';
|
||||
|
||||
li.innerHTML = ''
|
||||
+ '<div class="djb-comment-meta">'
|
||||
+ ' <div class="djb-comment-meta-left">'
|
||||
+ ' <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 2px; opacity: 0.5;">'
|
||||
+ ' <path d="M5 3v5a2 2 0 0 0 2 2h5 M9 7l3 3-3 3" stroke="#000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>'
|
||||
+ ' </svg>'
|
||||
+ ' <span class="djb-comment-writer">' + escapeHtml(c.writerName || '(알 수 없음)') + '</span>'
|
||||
+ (c.adminYn === 'Y' ? ' <span class="djb-comment-admin-badge">관리자</span>' : '')
|
||||
+ writerHtml
|
||||
+ privateTag
|
||||
+ ' </div>'
|
||||
+ ' <div class="djb-comment-meta-right">'
|
||||
+ ' <span class="djb-comment-date">' + escapeHtml(formatDate(c.createdDate)) + '</span>'
|
||||
@@ -114,9 +127,10 @@
|
||||
inputEl.focus();
|
||||
return;
|
||||
}
|
||||
var visibility = (privateEl && privateEl.checked) ? 'PRIVATE' : 'ALL';
|
||||
fetchJson('/djb/inquiry/' + encodeURIComponent(inquiryId) + '/comments', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ content: content })
|
||||
body: JSON.stringify({ content: content, visibility: visibility })
|
||||
})
|
||||
.then(function (res) {
|
||||
if (res.status === 409) throw new Error('종료된 문의에는 댓글을 작성할 수 없습니다.');
|
||||
@@ -125,6 +139,7 @@
|
||||
})
|
||||
.then(function () {
|
||||
inputEl.value = '';
|
||||
if (privateEl) privateEl.checked = false;
|
||||
updateCounter();
|
||||
load();
|
||||
})
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// 조회수 비정상 증가 억제: sessionStorage 로 한 세션 내 중복 증가 방지.
|
||||
var container = document.querySelector('.inquiry-detail-container[data-inquiry-id]');
|
||||
if (!container) return;
|
||||
|
||||
var inquiryId = container.getAttribute('data-inquiry-id');
|
||||
if (!inquiryId) return;
|
||||
|
||||
var viewedKey = 'inquiry_viewed_' + inquiryId;
|
||||
if (sessionStorage.getItem(viewedKey)) {
|
||||
return; // 이미 이번 세션에서 조회 → 증가하지 않음
|
||||
}
|
||||
|
||||
function getCsrfToken() {
|
||||
// 세션 기반 CSRF: 쿠키 대신 <meta name="_csrf">에서 토큰을 읽는다.
|
||||
var meta = document.querySelector('meta[name="_csrf"]');
|
||||
return meta ? meta.getAttribute('content') : '';
|
||||
}
|
||||
|
||||
fetch('/inquiry/' + encodeURIComponent(inquiryId) + '/view', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'X-XSRF-TOKEN': getCsrfToken() }
|
||||
})
|
||||
.then(function (res) {
|
||||
if (res.ok) {
|
||||
sessionStorage.setItem(viewedKey, 'true');
|
||||
}
|
||||
})
|
||||
.catch(function (err) {
|
||||
console.error('[inquiry-view] increment error', err);
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,161 @@
|
||||
/*!
|
||||
* djb-swagger-i18n.js
|
||||
* Swagger UI 영문 라벨을 한글로 치환하는 i18n 패치 (방법 B - DOM patch)
|
||||
* 배포 경로 권장: static/plugins/swaggerUI/djb-swagger-i18n.js
|
||||
* index.html 의 swagger-initializer.js 직전 또는 직후에 <script> 로 로드
|
||||
*
|
||||
* ?lang=en 쿼리스트링이 있으면 패치 비활성화 (원문 보기)
|
||||
* 매핑 카탈로그는 01-i18n-mapping/i18n-strings.csv 와 동기 유지
|
||||
*/
|
||||
(function (window) {
|
||||
'use strict';
|
||||
|
||||
// ── 비활성화 옵션 ──
|
||||
if (/[?&]lang=en\b/.test(window.location.search)) return;
|
||||
|
||||
// ── 매핑 (key 는 디버깅용, 실제 비교는 EN 문자열 일치) ──
|
||||
var I18N_MAP = {
|
||||
// P0 — OAuth2 모달 본문
|
||||
'auth.modal.title': { en: 'Available authorizations', ko: '사용 가능한 인증' },
|
||||
'auth.scopes.description': {
|
||||
en: 'Scopes are used to grant an application different levels of access to data on behalf of the end user. Each API may declare one or more scopes.',
|
||||
ko: '스코프는 최종 사용자를 대신하여 애플리케이션이 데이터에 접근할 수 있는 권한 수준을 부여합니다. 각 API는 하나 이상의 스코프를 선언할 수 있습니다.'
|
||||
},
|
||||
'auth.scopes.swagger_grant': {
|
||||
en: 'API requires the following scopes. Select which ones you want to grant to Swagger UI.',
|
||||
ko: '이 API 는 아래 스코프가 필요합니다. Swagger UI 에 부여할 스코프를 선택하세요.'
|
||||
},
|
||||
'auth.flow.clientCredentials': { en: 'OAuth2 client credentials flow', ko: 'OAuth2 클라이언트 자격증명 흐름' },
|
||||
'auth.flow.implicit': { en: 'OAuth2 implicit flow', ko: 'OAuth2 암묵적 흐름' },
|
||||
'auth.flow.password': { en: 'OAuth2 password flow', ko: 'OAuth2 비밀번호 흐름' },
|
||||
'auth.flow.authorizationCode': { en: 'OAuth2 authorization code flow', ko: 'OAuth2 인가 코드 흐름' },
|
||||
'auth.field.tokenUrl': { en: 'Token URL:', ko: '토큰 URL:' },
|
||||
'auth.field.flow': { en: 'Flow:', ko: '흐름:' },
|
||||
'auth.field.clientId': { en: 'client_id:', ko: '클라이언트 ID (client_id):' },
|
||||
'auth.field.clientSecret': { en: 'client_secret:',ko: '클라이언트 시크릿 (client_secret):' },
|
||||
'auth.field.scopes': { en: 'Scopes:', ko: '스코프:' },
|
||||
'auth.action.selectAll': { en: 'select all', ko: '모두 선택' },
|
||||
'auth.action.selectNone': { en: 'select none', ko: '선택 해제' },
|
||||
'auth.action.authorize': { en: 'Authorize', ko: '인증' },
|
||||
'auth.action.close': { en: 'Close', ko: '닫기' },
|
||||
'auth.action.logout': { en: 'Logout', ko: '로그아웃' },
|
||||
'auth.modal.authorized': { en: 'authorized', ko: '인증됨' },
|
||||
|
||||
// P1 — 기타 인증 흐름
|
||||
'auth.scheme.apiKey': { en: 'API key', ko: 'API 키' },
|
||||
'auth.field.name': { en: 'name:', ko: '이름:' },
|
||||
'auth.field.in': { en: 'in:', ko: '위치:' },
|
||||
'auth.field.value': { en: 'Value:', ko: '값:' },
|
||||
'basic.field.username': { en: 'Username:',ko: '사용자 ID:' },
|
||||
'basic.field.password': { en: 'Password:',ko: '비밀번호:' },
|
||||
|
||||
// P2 — 오퍼레이션 / 파라미터 / 응답 UI 라벨 (PoC ko-i18n.js 카탈로그와 동기)
|
||||
'op.tryItOut': { en: 'Try it out', ko: '실행해보기' },
|
||||
'op.cancel': { en: 'Cancel', ko: '취소' },
|
||||
'op.execute': { en: 'Execute', ko: '실행' },
|
||||
'op.clear': { en: 'Clear', ko: '지우기' },
|
||||
'op.reset': { en: 'Reset', ko: '초기화' },
|
||||
'op.parameters': { en: 'Parameters', ko: '파라미터' },
|
||||
'op.parameter': { en: 'Parameter', ko: '파라미터' },
|
||||
'op.name': { en: 'Name', ko: '이름' },
|
||||
'op.description': { en: 'Description', ko: '설명' },
|
||||
'op.value': { en: 'Value', ko: '값' },
|
||||
'op.type': { en: 'Type', ko: '타입' },
|
||||
'op.requestBody': { en: 'Request body', ko: '요청 본문' },
|
||||
'op.requestUrl': { en: 'Request URL', ko: '요청 URL' },
|
||||
'op.responses': { en: 'Responses', ko: '응답' },
|
||||
'op.response': { en: 'Response', ko: '응답' },
|
||||
'op.responseBody': { en: 'Response body', ko: '응답 본문' },
|
||||
'op.responseHeaders': { en: 'Response headers', ko: '응답 헤더' },
|
||||
'op.responseCType': { en: 'Response content type', ko: '응답 컨텐츠 타입' },
|
||||
'op.code': { en: 'Code', ko: '코드' },
|
||||
'op.details': { en: 'Details', ko: '상세' },
|
||||
'op.schema': { en: 'Schema', ko: '스키마' },
|
||||
'op.schemas': { en: 'Schemas', ko: '스키마' },
|
||||
'op.exampleValue': { en: 'Example Value', ko: '예시 값' },
|
||||
'op.noParameters': { en: 'No parameters', ko: '파라미터 없음' },
|
||||
'op.required': { en: 'required', ko: '필수' },
|
||||
'op.servers': { en: 'Servers', ko: '서버' },
|
||||
'op.serverResponse': { en: 'Server response', ko: '서버 응답' },
|
||||
'op.loading': { en: 'Loading...', ko: '불러오는 중...' },
|
||||
'op.curlUpper': { en: 'Curl', ko: 'cURL' },
|
||||
'op.curlLower': { en: 'curl', ko: 'cURL' },
|
||||
'op.editValue': { en: 'Edit Value', ko: '값 편집' },
|
||||
'op.model': { en: 'Model', ko: '모델' },
|
||||
'op.snippets': { en: 'Snippets', ko: '스니펫' },
|
||||
'op.mediaType': { en: 'Media type', ko: '미디어 타입' },
|
||||
'op.controlsAccept': { en: 'Controls Accept header.', ko: 'Accept 헤더를 제어합니다.' },
|
||||
'op.sendEmptyValue': { en: 'Send empty value', ko: '빈 값 전송' },
|
||||
'op.hide': { en: 'Hide', ko: '숨기기' },
|
||||
'op.show': { en: 'Show', ko: '표시' }
|
||||
};
|
||||
|
||||
// 빠른 lookup 을 위해 EN → KO 인덱스 사전 생성
|
||||
var EN_TO_KO = Object.create(null);
|
||||
Object.keys(I18N_MAP).forEach(function (k) {
|
||||
var e = I18N_MAP[k];
|
||||
EN_TO_KO[e.en] = e.ko;
|
||||
});
|
||||
|
||||
// ── 치환 대상 노드 식별 + 치환 ──
|
||||
function translateNode(node) {
|
||||
if (!node) return;
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
var raw = node.nodeValue;
|
||||
if (!raw) return;
|
||||
var trimmed = raw.trim();
|
||||
if (!trimmed) return;
|
||||
// 1) 완전 일치 (가장 안전)
|
||||
if (EN_TO_KO[trimmed]) {
|
||||
node.nodeValue = raw.replace(trimmed, EN_TO_KO[trimmed]);
|
||||
return;
|
||||
}
|
||||
// 2) 라벨 colon 패턴: "Token URL:" 같이 끝이 ":" 인 짧은 라벨
|
||||
// (불필요 — 1번 완전 일치로 대부분 커버)
|
||||
return;
|
||||
}
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return;
|
||||
if (node.tagName === 'SCRIPT' || node.tagName === 'STYLE') return;
|
||||
|
||||
// input placeholder / button title 도 일부 케이스에 대비
|
||||
if (node.tagName === 'INPUT' && node.placeholder && EN_TO_KO[node.placeholder.trim()]) {
|
||||
node.placeholder = EN_TO_KO[node.placeholder.trim()];
|
||||
}
|
||||
|
||||
var i, child = node.childNodes, len = child.length;
|
||||
for (i = 0; i < len; i++) translateNode(child[i]);
|
||||
}
|
||||
|
||||
function translateAll() {
|
||||
var root = document.getElementById('swagger-ui') || document.body;
|
||||
translateNode(root);
|
||||
}
|
||||
|
||||
// ── MutationObserver — SwaggerUI 가 동적으로 DOM 을 다시 그릴 때마다 재적용 ──
|
||||
var observer = new MutationObserver(function (mutations) {
|
||||
for (var i = 0; i < mutations.length; i++) {
|
||||
var m = mutations[i];
|
||||
if (m.addedNodes && m.addedNodes.length) {
|
||||
for (var j = 0; j < m.addedNodes.length; j++) translateNode(m.addedNodes[j]);
|
||||
}
|
||||
if (m.type === 'characterData') {
|
||||
translateNode(m.target);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function start() {
|
||||
translateAll();
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', start);
|
||||
} else {
|
||||
start();
|
||||
}
|
||||
})(window);
|
||||
@@ -0,0 +1,350 @@
|
||||
/*!
|
||||
* DjbSwaggerResponse — Swagger UI 기본 라이브 응답 표(.live-responses-table)를 CSS 로
|
||||
* 숨기고, 실행(Execute)한 operation 블록 안에 커스텀 응답 패널을 세로 적층으로 렌더한다.
|
||||
* (요청 스니펫 .request-snippets 은 같은 .responses-wrapper 안에 있으므로 유지)
|
||||
*
|
||||
* ┌─────────────────────────────────────────────┐
|
||||
* │ 응답 헤더 (Table) │
|
||||
* ├─────────────────────────────────────────────┤
|
||||
* │ 응답 본문 (JSON Pretty / Raw) │
|
||||
* ├─────────────────────────────────────────────┤
|
||||
* │ 응답코드 칩 + Response Time(ms) │
|
||||
* └─────────────────────────────────────────────┘
|
||||
*
|
||||
* 포탈 testbed 는 API 하나에 operation 이 여러 개일 수 있으므로, 전역 단일 그리드가
|
||||
* 아니라 Execute 를 누른 opblock 안에 그리드를 만든다(클릭 캡처로 대상 특정).
|
||||
*
|
||||
* 연결:
|
||||
* const ui = SwaggerUIBundle({
|
||||
* responseInterceptor: function (res) { DjbSwaggerResponse.renderResponse(res); return res; }
|
||||
* });
|
||||
* window.ui = ui;
|
||||
* DjbSwaggerResponse.attach('#swagger-ui'); // Execute 클릭 캡처 + 타이머 등록
|
||||
*/
|
||||
(function (global) {
|
||||
"use strict";
|
||||
|
||||
var startTs = 0;
|
||||
var targetOpblock = null;
|
||||
|
||||
function now() {
|
||||
return (global.performance && performance.now) ? performance.now() : Date.now();
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s == null ? "" : s)
|
||||
.replace(/&/g, "&").replace(/</g, "<")
|
||||
.replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
|
||||
function statusKind(status) {
|
||||
if (status >= 200 && status < 300) return "ok";
|
||||
if (status >= 300 && status < 400) return "warn";
|
||||
if (status >= 400) return "err";
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
function prettyJson(text) {
|
||||
try { return JSON.stringify(JSON.parse(text), null, 2); }
|
||||
catch (e) { return null; }
|
||||
}
|
||||
|
||||
// window.ui 에서 해석된 spec(json) 조회. (snippet-panel 과 동일 방식)
|
||||
function getSpecJson() {
|
||||
try {
|
||||
var ui = global.ui;
|
||||
if (ui && typeof ui.spec === "function") {
|
||||
var s = ui.spec();
|
||||
if (s && typeof s.toJS === "function") {
|
||||
var o = s.toJS();
|
||||
return o.json || o;
|
||||
}
|
||||
}
|
||||
} catch (e) { /* noop */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
// 단일 API 페이지 전제(첫 path/method)로 operation 반환.
|
||||
function getSingleOperation(spec) {
|
||||
var paths = (spec && spec.paths) ? spec.paths : {};
|
||||
var pk = Object.keys(paths)[0];
|
||||
if (!pk) return null;
|
||||
var ops = paths[pk];
|
||||
var mk = ["get", "post", "put", "patch", "delete", "options", "head"].filter(function (m) { return ops[m]; })[0];
|
||||
if (!mk) return null;
|
||||
return { path: pk, method: mk, operation: ops[mk] };
|
||||
}
|
||||
|
||||
// opblock 안 파라미터 입력값(header/query) 읽기.
|
||||
function readParamInput(opblock, name, where) {
|
||||
var scope = opblock || document;
|
||||
var row = scope.querySelector('tr[data-param-name="' + name + '"][data-param-in="' + where + '"]');
|
||||
if (row) {
|
||||
var input = row.querySelector('input[type="text"], input:not([type]), textarea');
|
||||
if (input && input.value) return input.value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Execute 직전 상태에서 검증용 요청 객체를 조립(헤더/쿼리/본문).
|
||||
function buildValidationRequest(opblock, opInfo) {
|
||||
var op = opInfo.operation;
|
||||
var headers = {};
|
||||
var query = [];
|
||||
(op.parameters || []).forEach(function (p) {
|
||||
if (p.in === "header") {
|
||||
var hv = readParamInput(opblock, p.name, "header");
|
||||
if (hv !== undefined) headers[p.name] = hv;
|
||||
} else if (p.in === "query") {
|
||||
var qv = readParamInput(opblock, p.name, "query");
|
||||
if (qv !== undefined) query.push(encodeURIComponent(p.name) + "=" + encodeURIComponent(qv));
|
||||
}
|
||||
});
|
||||
var body = "";
|
||||
if (op.requestBody) {
|
||||
headers["Content-Type"] = headers["Content-Type"] || "application/json";
|
||||
var ta = (opblock || document).querySelector("textarea.body-param__text");
|
||||
if (ta) body = ta.value;
|
||||
}
|
||||
var url = "http://_local" + opInfo.path + (query.length ? "?" + query.join("&") : "");
|
||||
return { url: url, method: opInfo.method.toUpperCase(), headers: headers, body: body };
|
||||
}
|
||||
|
||||
function renderEmpty() {
|
||||
return ''
|
||||
+ '<div class="cell-headers"><div class="cell-title">응답 헤더</div>'
|
||||
+ '<div class="empty">요청 중…</div></div>'
|
||||
+ '<div class="cell-body"><div class="cell-title">응답 본문</div>'
|
||||
+ '<div class="empty">요청 중…</div></div>'
|
||||
+ '<div class="cell-meta">'
|
||||
+ '<span class="status-chip neutral">대기</span>'
|
||||
+ '<span class="time">Response Time: -</span>'
|
||||
+ '</div>';
|
||||
}
|
||||
|
||||
function renderHeadersTable(headers) {
|
||||
var keys = Object.keys(headers || {});
|
||||
if (!keys.length) return '<div class="empty">헤더가 없습니다.</div>';
|
||||
var rows = keys.map(function (k) {
|
||||
return '<tr><td class="hdr-name">' + escapeHtml(k) + '</td>'
|
||||
+ '<td class="hdr-val">' + escapeHtml(headers[k]) + '</td></tr>';
|
||||
}).join("");
|
||||
return '<table class="hdr-table"><thead><tr><th>이름</th><th>값</th></tr></thead>'
|
||||
+ '<tbody>' + rows + '</tbody></table>';
|
||||
}
|
||||
|
||||
function bodyTabs(rawText) {
|
||||
var pretty = prettyJson(rawText);
|
||||
var hasJson = pretty != null;
|
||||
var jsonTab = hasJson ? '<button type="button" class="body-tab active" data-tab="json">JSON (Pretty)</button>' : "";
|
||||
var rawTab = '<button type="button" class="body-tab' + (hasJson ? "" : " active") + '" data-tab="raw">Raw</button>';
|
||||
var jsonPanel = hasJson ? '<pre class="body-panel mono active" data-tab="json">' + escapeHtml(pretty) + '</pre>' : "";
|
||||
var rawPanel = '<pre class="body-panel mono' + (hasJson ? "" : " active") + '" data-tab="raw">' + escapeHtml(rawText || "") + '</pre>';
|
||||
return '<div class="body-tabs" role="tablist">' + jsonTab + rawTab + '</div>'
|
||||
+ '<div class="body-panels">' + jsonPanel + rawPanel + '</div>';
|
||||
}
|
||||
|
||||
function attachTabHandlers(root) {
|
||||
if (root.__djbTabBound) return;
|
||||
root.__djbTabBound = true;
|
||||
root.addEventListener("click", function (e) {
|
||||
var btn = e.target.closest && e.target.closest(".body-tab");
|
||||
if (!btn) return;
|
||||
var panels = root.querySelectorAll(".body-panel");
|
||||
var tabs = root.querySelectorAll(".body-tab");
|
||||
tabs.forEach(function (t) { t.classList.toggle("active", t === btn); });
|
||||
var which = btn.getAttribute("data-tab");
|
||||
panels.forEach(function (p) { p.classList.toggle("active", p.getAttribute("data-tab") === which); });
|
||||
});
|
||||
}
|
||||
|
||||
// 대상 opblock(없으면 열려있는/첫 opblock) 안 .opblock-body 에 그리드 1개 확보.
|
||||
// 항상 opblock-body 최하단으로 이동시킨다: Execute 시점엔 responses-wrapper(요청
|
||||
// 스니펫)가 아직 없어 그리드가 그 위에 붙지만, 응답 후 재호출 시 최하단으로 옮겨
|
||||
// "Execute → 요청 스니펫 → 응답 그리드" 순서를 보장한다.
|
||||
function gridFor(opblock) {
|
||||
var host = opblock
|
||||
|| document.querySelector("#swagger-ui .opblock.is-open")
|
||||
|| document.querySelector("#swagger-ui .opblock");
|
||||
if (!host) return null;
|
||||
var body = host.querySelector(".opblock-body");
|
||||
if (!body) return null; // 접힘 상태에는 마운트하지 않음 (bare opblock 오배치 방지)
|
||||
var grid = body.querySelector(".djb-response-grid");
|
||||
if (!grid) {
|
||||
grid = document.createElement("div");
|
||||
grid.className = "djb-response-grid djb-empty";
|
||||
grid.innerHTML = renderEmpty();
|
||||
attachTabHandlers(grid);
|
||||
}
|
||||
body.appendChild(grid); // 기존 노드면 최하단으로 이동
|
||||
return grid;
|
||||
}
|
||||
|
||||
// 검증 실패 렌더: 네트워크 요청이 발생하지 않은 상태. djb-error 스타일 그리드.
|
||||
function renderValidationErrors(errors) {
|
||||
var grid = gridFor(targetOpblock);
|
||||
if (!grid) return;
|
||||
var items = (errors || []).map(function (er) {
|
||||
return '<li class="verr-item">'
|
||||
+ '<span class="verr-path">' + escapeHtml(er.path) + '</span> '
|
||||
+ '<span class="verr-msg">' + escapeHtml(er.message) + '</span>'
|
||||
+ (er.value !== undefined ? ' <code class="verr-val mono">' + escapeHtml(JSON.stringify(er.value)) + '</code>' : "")
|
||||
+ '</li>';
|
||||
}).join("");
|
||||
grid.classList.remove("djb-empty");
|
||||
grid.classList.add("djb-error");
|
||||
grid.innerHTML = ''
|
||||
+ '<div class="cell-headers"><div class="cell-title">응답 헤더</div>'
|
||||
+ '<div class="empty">검증 실패 — 요청이 전송되지 않았습니다.</div></div>'
|
||||
+ '<div class="cell-body"><div class="cell-title">검증 실패 (' + (errors || []).length + '건)</div>'
|
||||
+ '<ul class="verr-list">' + items + '</ul></div>'
|
||||
+ '<div class="cell-meta">'
|
||||
+ '<span class="status-chip err">검증실패</span>'
|
||||
+ '<span class="time">Response Time: -</span>'
|
||||
+ '</div>';
|
||||
}
|
||||
|
||||
// 네트워크/CORS 실패 렌더: responseInterceptor 로 오지 않고 store 에만 error 로 남는 케이스.
|
||||
function renderNetworkError(msg) {
|
||||
var grid = gridFor(targetOpblock);
|
||||
if (!grid) return;
|
||||
grid.classList.remove("djb-empty");
|
||||
grid.classList.add("djb-error");
|
||||
grid.innerHTML = ''
|
||||
+ '<div class="cell-headers"><div class="cell-title">응답 헤더</div>'
|
||||
+ '<div class="empty">응답을 받지 못했습니다.</div></div>'
|
||||
+ '<div class="cell-body"><div class="cell-title">네트워크 오류</div>'
|
||||
+ '<pre class="body-panel mono active" data-tab="raw">요청 실패: ' + escapeHtml(msg) + '</pre></div>'
|
||||
+ '<div class="cell-meta">'
|
||||
+ '<span class="status-chip err">네트워크 오류</span>'
|
||||
+ '<span class="time">Response Time: -</span>'
|
||||
+ '</div>';
|
||||
}
|
||||
|
||||
// Execute 클릭 시점에 대상 opblock 확정 + (검증) + 타이머 시작 + 빈 그리드 표시.
|
||||
// 캡처 단계라 Swagger(React) 의 실행 핸들러보다 먼저 실행됨 → 검증 실패 시
|
||||
// stopImmediatePropagation 으로 native 실행을 차단하고 에러만 렌더한다.
|
||||
function onExecuteClick(e) {
|
||||
var t = e.target;
|
||||
if (!t || !t.closest) return;
|
||||
var btn = t.closest(".btn.execute");
|
||||
if (!btn) return;
|
||||
targetOpblock = btn.closest(".opblock");
|
||||
|
||||
// ── 요청 파라미터/본문 검증 (검증기 로드된 경우) ──
|
||||
if (global.DjbSwaggerValidator) {
|
||||
try {
|
||||
var spec = getSpecJson();
|
||||
var opInfo = spec && getSingleOperation(spec);
|
||||
if (opInfo) {
|
||||
var req = buildValidationRequest(targetOpblock, opInfo);
|
||||
var result = global.DjbSwaggerValidator.validateOperation(opInfo.operation, req, spec);
|
||||
if (!result.ok) {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
renderValidationErrors(result.errors);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (err) { console.error("djb validation error:", err); }
|
||||
}
|
||||
|
||||
startTs = now();
|
||||
var grid = gridFor(targetOpblock);
|
||||
if (grid) {
|
||||
grid.classList.add("djb-empty");
|
||||
grid.classList.remove("djb-error");
|
||||
grid.innerHTML = renderEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} res Swagger UI responseInterceptor 결과
|
||||
* { status, statusText, headers, text, data, ... }
|
||||
*/
|
||||
function renderResponse(res) {
|
||||
if (!res) return;
|
||||
// spec 로딩 응답(/…/swagger.json)은 responseInterceptor 로도 들어온다. 이때 렌더하면
|
||||
// 실행 전인데 응답 본문에 OpenAPI spec 이 그려지므로 무시한다. 사용자 API 호출은
|
||||
// 프록시(/api/call-api)로 나가므로 url 에 swagger.json 이 없다.
|
||||
if (res.url && res.url.indexOf("swagger.json") !== -1) return;
|
||||
var ms = Math.max(0, Math.round(now() - startTs));
|
||||
var status = res.status || 0;
|
||||
var statusText = res.statusText || "";
|
||||
var headers = res.headers || {};
|
||||
var rawText = (typeof res.text === "string") ? res.text
|
||||
: (typeof res.data === "string") ? res.data
|
||||
: (res.data != null ? JSON.stringify(res.data) : "");
|
||||
var op = targetOpblock;
|
||||
|
||||
// Swagger(React) 가 응답 렌더로 opblock-body 를 재조정한 뒤 append 되도록 지연.
|
||||
setTimeout(function () {
|
||||
var grid = gridFor(op);
|
||||
if (!grid) return;
|
||||
var kind = statusKind(status);
|
||||
grid.classList.remove("djb-empty", "djb-error");
|
||||
grid.innerHTML = ''
|
||||
+ '<div class="cell-headers"><div class="cell-title">응답 헤더</div>'
|
||||
+ renderHeadersTable(headers) + '</div>'
|
||||
+ '<div class="cell-body"><div class="cell-title">응답 본문</div>'
|
||||
+ bodyTabs(rawText) + '</div>'
|
||||
+ '<div class="cell-meta">'
|
||||
+ '<span class="status-chip ' + kind + '">응답코드: ' + escapeHtml(status)
|
||||
+ (statusText ? " " + escapeHtml(statusText) : "") + '</span>'
|
||||
+ '<span class="time">Response Time: ' + ms + ' ms</span>'
|
||||
+ '</div>';
|
||||
attachTabHandlers(grid);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
// fetch 실패(네트워크/CORS/타임아웃)는 responseInterceptor 로 전달되지 않고 store 의
|
||||
// spec.responses[path][method] 에 { error:true, err } 로만 저장된다. store 를 구독해
|
||||
// 사용자에게 네트워크 오류 그리드로 보여준다. (PoC bootstrap 의 getStore().subscribe 이식)
|
||||
function watchNetworkErrors(ui) {
|
||||
if (!ui || typeof ui.getStore !== "function" || ui.__djbNetWatch) return;
|
||||
ui.__djbNetWatch = true;
|
||||
var rendered = {};
|
||||
ui.getStore().subscribe(function () {
|
||||
try {
|
||||
var respMap = ui.getState().getIn(["spec", "responses"]);
|
||||
if (!respMap || typeof respMap.forEach !== "function") return;
|
||||
respMap.forEach(function (methods, path) {
|
||||
if (!methods || typeof methods.forEach !== "function") return;
|
||||
methods.forEach(function (resp, method) {
|
||||
var key = path + " " + method;
|
||||
var hasError = resp && (resp.get ? resp.get("error") : resp.error);
|
||||
if (hasError) {
|
||||
if (rendered[key]) return;
|
||||
rendered[key] = true;
|
||||
var err = resp.get ? resp.get("err") : resp.err;
|
||||
var msg = (err && (err.message || (err.get && err.get("message"))))
|
||||
|| (err ? String(err) : "응답을 받지 못했습니다.");
|
||||
renderNetworkError(String(msg));
|
||||
} else {
|
||||
rendered[key] = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (e) { /* ignore */ }
|
||||
});
|
||||
}
|
||||
|
||||
// #swagger-ui 는 API 전환 시에도 element 자체는 유지되므로 캡처 리스너 1회 등록으로 충분.
|
||||
function attach(rootSel) {
|
||||
var root = document.querySelector(rootSel || "#swagger-ui");
|
||||
if (!root || root.__djbRespBound) return;
|
||||
root.__djbRespBound = true;
|
||||
// capture 단계: native 응답 흐름보다 먼저 타겟/타이머 확보 + 검증 차단
|
||||
root.addEventListener("click", onExecuteClick, true);
|
||||
// window.ui 준비 시 네트워크 오류 감시 등록
|
||||
watchNetworkErrors(global.ui);
|
||||
}
|
||||
|
||||
global.DjbSwaggerResponse = {
|
||||
attach: attach,
|
||||
renderResponse: renderResponse,
|
||||
renderValidationErrors: renderValidationErrors,
|
||||
renderNetworkError: renderNetworkError,
|
||||
watchNetworkErrors: watchNetworkErrors
|
||||
};
|
||||
})(window);
|
||||
@@ -0,0 +1,265 @@
|
||||
/*!
|
||||
* DjbSwaggerSnippetPanel — Execute 없이 페이지 진입 즉시 요청 스니펫을 보여주는 상시 패널.
|
||||
* (PoC rinjae-works/swagger-poc/js/snippet-panel.js 를 포탈용으로 이식)
|
||||
*
|
||||
* Swagger UI 5 의 내장 .request-snippets 는 Execute 이후에만, 그리고 응답영역 안에
|
||||
* 렌더되므로 CSS 로 숨기고, 동일한 window.SnippetGenerators 로 직접 패널을 만들어
|
||||
* operation 의 Execute 버튼(.btn-group) 바로 아래에 마운트한다. Body/헤더 변경 시 자동 갱신.
|
||||
*
|
||||
* 전제: 이 페이지는 항상 1개 API(단일 operation) 노출 — spec 의 첫 path/method 사용.
|
||||
* spec 은 window.ui 에서 지연 조회한다.
|
||||
*
|
||||
* 연결: SwaggerUIBundle 의 onComplete 에서 DjbSwaggerSnippetPanel.mount() 호출.
|
||||
*/
|
||||
(function (global) {
|
||||
"use strict";
|
||||
|
||||
var TABS = [
|
||||
{ key: "curl_bash", title: "cURL (Bash)", gen: "curlBash" },
|
||||
{ key: "curl_powershell", title: "cURL (PowerShell)", gen: "curlPwsh" },
|
||||
{ key: "curl_cmd", title: "cURL (CMD)", gen: "curlCmd" },
|
||||
{ key: "csharp", title: "C#", gen: "csharp" },
|
||||
{ key: "ecma5", title: "ECMA5 (JavaScript)", gen: "ecma5" },
|
||||
{ key: "java", title: "Java", gen: "javaOkhttp" },
|
||||
{ key: "python", title: "Python", gen: "python" }
|
||||
];
|
||||
|
||||
var activeKey = "curl_bash";
|
||||
var rootSel = "#swagger-ui";
|
||||
var observer = null;
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s == null ? "" : s)
|
||||
.replace(/&/g, "&").replace(/</g, "<")
|
||||
.replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
|
||||
// window.ui 에서 해석된 spec(json) 조회.
|
||||
function getSpecJson() {
|
||||
try {
|
||||
var ui = global.ui;
|
||||
if (ui && typeof ui.spec === "function") {
|
||||
var s = ui.spec();
|
||||
if (s && typeof s.toJS === "function") {
|
||||
var o = s.toJS();
|
||||
return o.json || o;
|
||||
}
|
||||
}
|
||||
} catch (e) { /* noop */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
// Path 1 + Method 1 전제 하에 단일 operation 반환.
|
||||
function getSingleOperation(spec) {
|
||||
var paths = (spec && spec.paths) ? spec.paths : {};
|
||||
var pk = Object.keys(paths)[0];
|
||||
if (!pk) return null;
|
||||
var ops = paths[pk];
|
||||
var mk = ["get", "post", "put", "patch", "delete", "options", "head"].filter(function (m) { return ops[m]; })[0];
|
||||
if (!mk) return null;
|
||||
return { path: pk, method: mk, operation: ops[mk] };
|
||||
}
|
||||
|
||||
function readHeaderInput(name) {
|
||||
var row = document.querySelector('tr[data-param-name="' + name + '"][data-param-in="header"]');
|
||||
if (row) {
|
||||
var input = row.querySelector('input[type="text"], input:not([type])');
|
||||
if (input && input.value) return input.value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Authorize 모달 사용 후에만 표시되는 인증 헤더 추출.
|
||||
function readAuthHeaders() {
|
||||
var headers = {};
|
||||
try {
|
||||
var ui = global.ui;
|
||||
if (!ui || !ui.authSelectors) return headers;
|
||||
var auths = ui.authSelectors.authorized();
|
||||
if (!auths) return headers;
|
||||
var js = auths.toJS();
|
||||
Object.keys(js).forEach(function (key) {
|
||||
var a = js[key];
|
||||
if (!a || !a.schema) return;
|
||||
var scheme = a.schema;
|
||||
if (scheme.type === "oauth2") {
|
||||
var tok = a.token && a.token.access_token;
|
||||
if (tok) headers["Authorization"] = "Bearer " + tok;
|
||||
} else if (scheme.type === "apiKey" && scheme.in === "header") {
|
||||
if (a.value) headers[scheme.name] = a.value;
|
||||
} else if (scheme.type === "http" && scheme.scheme === "bearer") {
|
||||
if (a.value) headers["Authorization"] = "Bearer " + a.value;
|
||||
}
|
||||
});
|
||||
} catch (e) { /* noop */ }
|
||||
return headers;
|
||||
}
|
||||
|
||||
function buildRequest() {
|
||||
var spec = getSpecJson();
|
||||
var opInfo = spec && getSingleOperation(spec);
|
||||
if (!opInfo) return null;
|
||||
var op = opInfo.operation;
|
||||
var server = (spec.servers && spec.servers[0] && spec.servers[0].url) || "";
|
||||
var url = server + opInfo.path;
|
||||
|
||||
var headers = {};
|
||||
(op.parameters || []).forEach(function (p) {
|
||||
if (p.in !== "header") return;
|
||||
var val = readHeaderInput(p.name)
|
||||
|| (p.example !== undefined ? String(p.example) : null)
|
||||
|| (p.schema && p.schema.example !== undefined ? String(p.schema.example) : null);
|
||||
if (val) headers[p.name] = val;
|
||||
});
|
||||
|
||||
var body = "";
|
||||
if (op.requestBody && op.requestBody.content) {
|
||||
var cts = Object.keys(op.requestBody.content);
|
||||
var ct = cts[0] || "application/json";
|
||||
headers["Content-Type"] = ct;
|
||||
var ta = document.querySelector(rootSel + " textarea.body-param__text");
|
||||
if (ta && ta.value) {
|
||||
body = ta.value;
|
||||
} else if (op.requestBody.content[ct] && op.requestBody.content[ct].example !== undefined) {
|
||||
try { body = JSON.stringify(op.requestBody.content[ct].example, null, 2); } catch (e) { body = ""; }
|
||||
}
|
||||
}
|
||||
headers["accept"] = headers["accept"] || "application/json";
|
||||
var auth = readAuthHeaders();
|
||||
Object.keys(auth).forEach(function (k) { headers[k] = auth[k]; });
|
||||
|
||||
return { url: url, method: opInfo.method.toUpperCase(), headers: headers, body: body };
|
||||
}
|
||||
|
||||
function panelEl() { return document.querySelector("#djb-snippet-panel"); }
|
||||
|
||||
function renderInto(el) {
|
||||
if (!el) return;
|
||||
var gens = global.SnippetGenerators;
|
||||
var code;
|
||||
if (!gens) {
|
||||
code = "// 스니펫 생성기 로드 대기…";
|
||||
} else {
|
||||
try {
|
||||
var req = buildRequest();
|
||||
if (!req) { code = "// 스펙 로딩 중…"; }
|
||||
else {
|
||||
var active = TABS.filter(function (t) { return t.key === activeKey; })[0] || TABS[0];
|
||||
code = gens[active.gen](req);
|
||||
}
|
||||
} catch (e) { code = "// 스니펫 생성 오류: " + e.message; }
|
||||
}
|
||||
var tabsHtml = TABS.map(function (t) {
|
||||
return '<button type="button" class="djb-sn-tab ' + (t.key === activeKey ? "active" : "") + '" data-key="' + t.key + '">' + t.title + '</button>';
|
||||
}).join("");
|
||||
el.innerHTML =
|
||||
'<div class="djb-sn-header">'
|
||||
+ '<h4>요청 스니펫</h4>'
|
||||
+ '<span class="djb-sn-hint">(실행 없이 즉시 미리보기 — Body / 헤더 변경 시 자동 갱신)</span>'
|
||||
+ '<button type="button" class="djb-sn-copy" data-action="copy">복사</button>'
|
||||
+ '</div>'
|
||||
+ '<div class="djb-sn-tabs" role="tablist">' + tabsHtml + '</div>'
|
||||
+ '<pre class="djb-sn-code mono">' + escapeHtml(code) + '</pre>';
|
||||
}
|
||||
|
||||
// 옵저버 재진입(무한 루프) 방지: 우리 DOM 변경 중에는 관찰 일시 중지.
|
||||
function withObserverPaused(fn) {
|
||||
if (observer) observer.disconnect();
|
||||
try { fn(); }
|
||||
finally {
|
||||
if (observer) {
|
||||
var root = document.querySelector(rootSel);
|
||||
if (root) observer.observe(root, { childList: true, subtree: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Execute 영역 바로 아래 = Swagger UI 폼 하단에 패널 확보.
|
||||
// - 앵커는 opblock-body '직속' 자식만 본다: 실행 전에는 .execute-wrapper, 실행 후에는
|
||||
// Swagger UI 5 가 이를 .btn-group(실행+초기화 버튼군)으로 '교체'하므로 둘 다 허용한다.
|
||||
// (execute-wrapper 만 앵커로 쓰면 실행 후 앵커 소실 → 패널이 제거되고 재생성 안 됨.)
|
||||
// - 중첩 .btn-group(파라미터 섹션 헤더의 try-out '취소' 버튼군)은 직속 자식이 아니라
|
||||
// 건너뛴다: 그걸 앵커로 잡으면 flex 헤더 안에 삽입돼 "파라미터" 라벨이 찌부된다.
|
||||
// - operation 이 접혀 opblock-body/Execute 영역이 없으면: 잘못 남은 패널을 제거하고 중단.
|
||||
// 다음 펼침 때 재생성.
|
||||
// - 이미 있으나 위치가 어긋나면: anchor 바로 뒤로 재배치.
|
||||
function ensureMounted() {
|
||||
var op = document.querySelector(rootSel + " .opblock.is-open")
|
||||
|| document.querySelector(rootSel + " .opblock");
|
||||
var body = op && op.querySelector(".opblock-body");
|
||||
var anchor = null;
|
||||
if (body) {
|
||||
for (var i = 0; i < body.children.length; i++) {
|
||||
var kid = body.children[i];
|
||||
if (kid.classList.contains("execute-wrapper") || kid.classList.contains("btn-group")) {
|
||||
anchor = kid;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
var existing = panelEl();
|
||||
|
||||
if (!body || !anchor) {
|
||||
if (existing && existing.parentNode) existing.parentNode.removeChild(existing);
|
||||
return;
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
if (existing.parentNode !== anchor.parentNode || existing.previousElementSibling !== anchor) {
|
||||
anchor.parentNode.insertBefore(existing, anchor.nextSibling);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var panel = document.createElement("div");
|
||||
panel.id = "djb-snippet-panel";
|
||||
panel.className = "djb-snippet-panel";
|
||||
anchor.parentNode.insertBefore(panel, anchor.nextSibling);
|
||||
renderInto(panel);
|
||||
}
|
||||
|
||||
function onClick(e) {
|
||||
var tab = e.target.closest && e.target.closest("#djb-snippet-panel .djb-sn-tab");
|
||||
if (tab) {
|
||||
activeKey = tab.getAttribute("data-key");
|
||||
withObserverPaused(function () { renderInto(panelEl()); });
|
||||
return;
|
||||
}
|
||||
var copy = e.target.closest && e.target.closest('#djb-snippet-panel [data-action="copy"]');
|
||||
if (copy) {
|
||||
var code = document.querySelector("#djb-snippet-panel .djb-sn-code");
|
||||
if (code && global.navigator && navigator.clipboard) {
|
||||
navigator.clipboard.writeText(code.innerText).then(function () {
|
||||
copy.textContent = "복사됨";
|
||||
setTimeout(function () { copy.textContent = "복사"; }, 1200);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onInput(e) {
|
||||
var t = e.target;
|
||||
if (!t || !t.matches) return;
|
||||
if (t.matches("textarea.body-param__text") || t.matches("tr[data-param-name] input")) {
|
||||
withObserverPaused(function () { renderInto(panelEl()); });
|
||||
}
|
||||
}
|
||||
|
||||
function mount(opts) {
|
||||
rootSel = (opts && opts.rootSel) || "#swagger-ui";
|
||||
var root = document.querySelector(rootSel);
|
||||
if (!root) return;
|
||||
if (!root.__djbSnBound) {
|
||||
root.__djbSnBound = true;
|
||||
root.addEventListener("click", onClick);
|
||||
root.addEventListener("input", onInput);
|
||||
observer = new MutationObserver(function () {
|
||||
withObserverPaused(function () { ensureMounted(); });
|
||||
});
|
||||
observer.observe(root, { childList: true, subtree: true });
|
||||
}
|
||||
withObserverPaused(function () { ensureMounted(); });
|
||||
}
|
||||
|
||||
global.DjbSwaggerSnippetPanel = { mount: mount, render: function () { renderInto(panelEl()); } };
|
||||
})(window);
|
||||
@@ -0,0 +1,223 @@
|
||||
/*!
|
||||
* DjbSwaggerSnippets — Swagger UI 의 SnippetGeneratorPlugin 에 등록되는 7개 언어
|
||||
* 클라이언트 코드 생성기. 서버측 /api/sample_code/{language} 호출을 대체한다.
|
||||
*
|
||||
* cURL (Bash) / cURL (PowerShell) / cURL (CMD) / C# / ECMA5 (JS) / Java / Python
|
||||
*
|
||||
* 입력: Swagger UI requestSnippetGenerator 가 넘기는 Immutable request.
|
||||
* .toJS() 로 plain object 변환 후 url/method/headers/body 사용.
|
||||
* (showMutatedRequest:false 이므로 프록시 URL 이 아닌 실제 대상 요청이 넘어온다)
|
||||
* 출력: string (코드 본문)
|
||||
*
|
||||
* 전역: window.SnippetGeneratorPlugin (Swagger plugins 배열에 등록),
|
||||
* window.SnippetGenerators (개별 생성 함수 접근용)
|
||||
*/
|
||||
(function (global) {
|
||||
"use strict";
|
||||
|
||||
function unwrap(req) {
|
||||
if (!req) return {};
|
||||
if (typeof req.toJS === "function") return req.toJS();
|
||||
return req;
|
||||
}
|
||||
|
||||
function getCt(headers) {
|
||||
for (const k of Object.keys(headers || {})) {
|
||||
if (k.toLowerCase() === "content-type") return headers[k];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function bodyAsString(body) {
|
||||
if (body === undefined || body === null) return "";
|
||||
if (typeof body === "string") return body;
|
||||
try { return JSON.stringify(body); } catch (e) { return String(body); }
|
||||
}
|
||||
|
||||
function escapeSingleQuote(s) { return String(s).replace(/'/g, "'\\''"); }
|
||||
function escapeDoubleQuote(s) { return String(s).replace(/"/g, '\\"'); }
|
||||
|
||||
// ─── cURL (Bash) ────────────────────────────────────────────────────────────
|
||||
function curlBash(reqRaw) {
|
||||
const r = unwrap(reqRaw);
|
||||
const lines = [`curl --location --request ${r.method || "GET"} '${escapeSingleQuote(r.url)}'`];
|
||||
const headers = r.headers || {};
|
||||
for (const k of Object.keys(headers)) {
|
||||
lines.push(` --header '${escapeSingleQuote(k)}: ${escapeSingleQuote(headers[k])}'`);
|
||||
}
|
||||
const body = bodyAsString(r.body);
|
||||
if (body) {
|
||||
lines.push(` --data-raw '${escapeSingleQuote(body)}'`);
|
||||
}
|
||||
return lines.join(" \\\n");
|
||||
}
|
||||
|
||||
// ─── cURL (PowerShell) ─────────────────────────────────────────────────────
|
||||
function curlPwsh(reqRaw) {
|
||||
const r = unwrap(reqRaw);
|
||||
// 백틱(`) 줄 연속자
|
||||
const segments = [`curl.exe --location --request ${r.method || "GET"} "${escapeDoubleQuote(r.url)}"`];
|
||||
const headers = r.headers || {};
|
||||
for (const k of Object.keys(headers)) {
|
||||
segments.push(` --header "${escapeDoubleQuote(k)}: ${escapeDoubleQuote(headers[k])}"`);
|
||||
}
|
||||
const body = bodyAsString(r.body);
|
||||
if (body) {
|
||||
segments.push(` --data-raw "${escapeDoubleQuote(body)}"`);
|
||||
}
|
||||
return segments.join(" `\n");
|
||||
}
|
||||
|
||||
// ─── cURL (Windows CMD) ────────────────────────────────────────────────────
|
||||
function curlCmd(reqRaw) {
|
||||
const r = unwrap(reqRaw);
|
||||
const segments = [`curl.exe --location --request ${r.method || "GET"} "${escapeDoubleQuote(r.url)}"`];
|
||||
const headers = r.headers || {};
|
||||
for (const k of Object.keys(headers)) {
|
||||
segments.push(` --header "${escapeDoubleQuote(k)}: ${escapeDoubleQuote(headers[k])}"`);
|
||||
}
|
||||
const body = bodyAsString(r.body);
|
||||
if (body) {
|
||||
segments.push(` --data-raw "${escapeDoubleQuote(body)}"`);
|
||||
}
|
||||
return segments.join(" ^\n");
|
||||
}
|
||||
|
||||
// ─── C# (RestSharp) ─────────────────────────────────────────────────────────
|
||||
function csharp(reqRaw) {
|
||||
const r = unwrap(reqRaw);
|
||||
const method = (r.method || "GET").toUpperCase();
|
||||
const headers = r.headers || {};
|
||||
const body = bodyAsString(r.body);
|
||||
const lines = [
|
||||
`using RestSharp;`,
|
||||
``,
|
||||
`var client = new RestClient("${escapeDoubleQuote(r.url)}");`,
|
||||
`var request = new RestRequest(Method.${method});`,
|
||||
];
|
||||
for (const k of Object.keys(headers)) {
|
||||
lines.push(`request.AddHeader("${escapeDoubleQuote(k)}", "${escapeDoubleQuote(headers[k])}");`);
|
||||
}
|
||||
if (body) {
|
||||
const ct = getCt(headers) || "application/json";
|
||||
lines.push(`request.AddParameter("${escapeDoubleQuote(ct)}", @"${body.replace(/"/g, '""')}", ParameterType.RequestBody);`);
|
||||
}
|
||||
lines.push(`IRestResponse response = client.Execute(request);`);
|
||||
lines.push(`Console.WriteLine(response.Content);`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// ─── ECMA5 / JavaScript (XHR) ──────────────────────────────────────────────
|
||||
function ecma5(reqRaw) {
|
||||
const r = unwrap(reqRaw);
|
||||
const method = (r.method || "GET").toUpperCase();
|
||||
const headers = r.headers || {};
|
||||
const body = bodyAsString(r.body);
|
||||
const lines = [
|
||||
`var xhr = new XMLHttpRequest();`,
|
||||
`xhr.withCredentials = false;`,
|
||||
``,
|
||||
`xhr.addEventListener("readystatechange", function () {`,
|
||||
` if (this.readyState === 4) {`,
|
||||
` console.log(this.responseText);`,
|
||||
` }`,
|
||||
`});`,
|
||||
``,
|
||||
`xhr.open("${method}", "${escapeDoubleQuote(r.url)}");`,
|
||||
];
|
||||
for (const k of Object.keys(headers)) {
|
||||
lines.push(`xhr.setRequestHeader("${escapeDoubleQuote(k)}", "${escapeDoubleQuote(headers[k])}");`);
|
||||
}
|
||||
if (body) {
|
||||
// ES5 라 template literal 사용 X — JSON.stringify 로 안전한 문자열 리터럴 생성
|
||||
lines.push(`xhr.send(${JSON.stringify(body)});`);
|
||||
} else {
|
||||
lines.push(`xhr.send(null);`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// ─── Java (OkHttp) ─────────────────────────────────────────────────────────
|
||||
function javaOkhttp(reqRaw) {
|
||||
const r = unwrap(reqRaw);
|
||||
const method = (r.method || "GET").toUpperCase();
|
||||
const headers = r.headers || {};
|
||||
const body = bodyAsString(r.body);
|
||||
const ct = getCt(headers) || "application/json";
|
||||
const lines = [
|
||||
`OkHttpClient client = new OkHttpClient().newBuilder().build();`,
|
||||
`MediaType mediaType = MediaType.parse("${escapeDoubleQuote(ct)}");`,
|
||||
];
|
||||
if (body) {
|
||||
lines.push(`RequestBody body = RequestBody.create(mediaType, "${escapeDoubleQuote(body)}");`);
|
||||
}
|
||||
lines.push(`Request request = new Request.Builder()`);
|
||||
lines.push(` .url("${escapeDoubleQuote(r.url)}")`);
|
||||
if (body) {
|
||||
lines.push(` .method("${method}", body)`);
|
||||
} else {
|
||||
lines.push(` .method("${method}", null)`);
|
||||
}
|
||||
for (const k of Object.keys(headers)) {
|
||||
lines.push(` .addHeader("${escapeDoubleQuote(k)}", "${escapeDoubleQuote(headers[k])}")`);
|
||||
}
|
||||
lines.push(` .build();`);
|
||||
lines.push(`Response response = client.newCall(request).execute();`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// ─── Python (requests) ─────────────────────────────────────────────────────
|
||||
function python(reqRaw) {
|
||||
const r = unwrap(reqRaw);
|
||||
const method = (r.method || "GET").toLowerCase();
|
||||
const headers = r.headers || {};
|
||||
const body = bodyAsString(r.body);
|
||||
|
||||
const headerEntries = Object.keys(headers).map(
|
||||
k => ` "${escapeDoubleQuote(k)}": "${escapeDoubleQuote(headers[k])}"`
|
||||
);
|
||||
const lines = [
|
||||
`import requests`,
|
||||
``,
|
||||
`url = "${escapeDoubleQuote(r.url)}"`,
|
||||
``,
|
||||
];
|
||||
if (body) {
|
||||
lines.push(`payload = ${JSON.stringify(body)}`);
|
||||
}
|
||||
lines.push(`headers = {`);
|
||||
if (headerEntries.length) lines.push(headerEntries.join(",\n"));
|
||||
lines.push(`}`);
|
||||
lines.push(``);
|
||||
if (body) {
|
||||
lines.push(`response = requests.request("${method.toUpperCase()}", url, headers=headers, data=payload)`);
|
||||
} else {
|
||||
lines.push(`response = requests.request("${method.toUpperCase()}", url, headers=headers)`);
|
||||
}
|
||||
lines.push(`print(response.text)`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// ─── Swagger UI 플러그인 등록 페이로드 ──────────────────────────────────────
|
||||
const SnippetGeneratorPlugin = function () {
|
||||
return {
|
||||
fn: {
|
||||
// curl_bash / curl_powershell / curl_cmd 는 Swagger UI 5 내장 기본 키와 동일하게
|
||||
// 맞춰 내장 생성기를 덮어쓴다. (curl_pwsh 등 다른 키를 쓰면 내장 PowerShell 탭이
|
||||
// 중복 표시됨)
|
||||
requestSnippetGenerator_curl_bash: curlBash,
|
||||
requestSnippetGenerator_curl_powershell: curlPwsh,
|
||||
requestSnippetGenerator_curl_cmd: curlCmd,
|
||||
requestSnippetGenerator_csharp: csharp,
|
||||
requestSnippetGenerator_ecma5: ecma5,
|
||||
requestSnippetGenerator_java: javaOkhttp,
|
||||
requestSnippetGenerator_python: python
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
global.SnippetGeneratorPlugin = SnippetGeneratorPlugin;
|
||||
global.SnippetGenerators = {
|
||||
curlBash, curlPwsh, curlCmd, csharp, ecma5, javaOkhttp, python
|
||||
};
|
||||
})(window);
|
||||
@@ -0,0 +1,314 @@
|
||||
/* ─────────────────────────────────────────────────────────────────────────── */
|
||||
/* DJB Swagger Testbed overlay */
|
||||
/* - 기본 라이브 응답 표(.live-responses-table) 숨김 (스니펫은 유지) */
|
||||
/* - 실행한 operation 안에 세로 적층 커스텀 응답 패널(.djb-response-grid) */
|
||||
/* - D2Coding 우선 monospace fallback */
|
||||
/* 포탈 통합용. PoC(rinjae-works/swagger-poc/css/overlay.css) 에서 응답 파트만 */
|
||||
/* 발췌·클래스화(단일 ID → 다중 op 대응). */
|
||||
/* ─────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/* D2Coding 은 시스템에 설치된 경우에만 사용 (local()). 미설치 시 fallback 체인 사용. */
|
||||
@font-face {
|
||||
font-family: "D2Coding";
|
||||
src: local("D2Coding"),
|
||||
local("D2Coding-Regular"),
|
||||
local("D2Coding ligature");
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
:root {
|
||||
--djb-mono: "D2Coding", "Menlo", "Consolas", "Courier New", monospace;
|
||||
--djb-border: #d0d7de;
|
||||
--djb-bg: #ffffff;
|
||||
--djb-bg-alt: #f6f8fa;
|
||||
--djb-text: #1f2328;
|
||||
--djb-text-muted: #6e7781;
|
||||
--djb-ok: #1f883d;
|
||||
--djb-warn: #bf8700;
|
||||
--djb-err: #cf222e;
|
||||
--djb-accent: #0969da;
|
||||
}
|
||||
|
||||
/* ─── Swagger UI 기본 응답영역: 문서화 응답 샘플만 노출 ────────────────────────
|
||||
응답 영역(.responses-wrapper)은 표시하되(= 상태코드별 응답 예제/스키마 = "응답 샘플"),
|
||||
실행 결과 라이브 블록은 숨긴다:
|
||||
- 요청 스니펫(.request-snippets) → 상시 다크 패널(#djb-snippet-panel)로 대체
|
||||
- 라이브 응답(Curl/Request URL/Server response + .live-responses-table)
|
||||
= .responses-inner 의 직접 자식 <div> → 커스텀 그리드(.djb-response-grid)로 대체
|
||||
문서화 응답 예제는 .responses-inner > table.responses-table 이라 <div> 숨김과 구분됨.
|
||||
최종 순서: 파라미터 → 요청 본문 → Execute → 요청 스니펫(패널) → 응답 샘플(표) → 응답 그리드. */
|
||||
.swagger-ui .request-snippets { display: none !important; }
|
||||
.swagger-ui .responses-inner > div { display: none !important; }
|
||||
.swagger-ui .live-responses-table { display: none !important; }
|
||||
|
||||
/* ─── 상시 요청 스니펫 패널 (PoC overlay.css #djb-snippet-panel) ─────────────── */
|
||||
.swagger-ui #djb-snippet-panel {
|
||||
margin: 16px;
|
||||
padding: 12px 16px 16px;
|
||||
background: var(--djb-bg);
|
||||
border: 1px solid var(--djb-border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.swagger-ui #djb-snippet-panel .djb-sn-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.swagger-ui #djb-snippet-panel .djb-sn-header h4 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--djb-text);
|
||||
}
|
||||
.swagger-ui #djb-snippet-panel .djb-sn-hint {
|
||||
color: var(--djb-text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
.swagger-ui #djb-snippet-panel .djb-sn-copy {
|
||||
margin-left: auto;
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
background: var(--djb-bg-alt);
|
||||
border: 1px solid var(--djb-border);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--djb-text);
|
||||
}
|
||||
.swagger-ui #djb-snippet-panel .djb-sn-copy:hover { background: #eaeef2; }
|
||||
.swagger-ui #djb-snippet-panel .djb-sn-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-bottom: 8px;
|
||||
border-bottom: 1px solid var(--djb-border);
|
||||
}
|
||||
.swagger-ui #djb-snippet-panel .djb-sn-tab {
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-bottom: none;
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--djb-text-muted);
|
||||
cursor: pointer;
|
||||
border-radius: 4px 4px 0 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.swagger-ui #djb-snippet-panel .djb-sn-tab:hover { color: var(--djb-text); }
|
||||
.swagger-ui #djb-snippet-panel .djb-sn-tab.active {
|
||||
color: var(--djb-accent);
|
||||
border-color: var(--djb-border);
|
||||
background: var(--djb-bg);
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
.swagger-ui #djb-snippet-panel .djb-sn-code {
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
background: #0d1117;
|
||||
color: #e6edf3;
|
||||
border-radius: 6px;
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
font-family: var(--djb-mono);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
/* 한글 라벨이 길어도 깨지지 않도록 */
|
||||
.swagger-ui .opblock .opblock-summary-path,
|
||||
.swagger-ui .opblock-tag,
|
||||
.swagger-ui table thead tr th,
|
||||
.swagger-ui .parameter__name,
|
||||
.swagger-ui .parameter__type {
|
||||
word-break: keep-all;
|
||||
}
|
||||
|
||||
/* ─── 커스텀 응답 그리드: 헤더 → 본문 → 메타 세로 적층 (opblock 마다 1개) ── */
|
||||
.swagger-ui .djb-response-grid {
|
||||
margin: 16px;
|
||||
padding: 16px;
|
||||
background: var(--djb-bg);
|
||||
border: 1px solid var(--djb-border);
|
||||
border-radius: 8px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-areas:
|
||||
"headers"
|
||||
"body"
|
||||
"meta";
|
||||
gap: 12px;
|
||||
color: var(--djb-text);
|
||||
}
|
||||
.swagger-ui .djb-response-grid.djb-error { border-color: var(--djb-err); background: #fff8f8; }
|
||||
|
||||
.swagger-ui .djb-response-grid .cell-headers { grid-area: headers; min-width: 0; }
|
||||
.swagger-ui .djb-response-grid .cell-body { grid-area: body; min-width: 0; }
|
||||
.swagger-ui .djb-response-grid .cell-meta {
|
||||
grid-area: meta;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px dashed var(--djb-border);
|
||||
}
|
||||
|
||||
.swagger-ui .djb-response-grid .cell-title {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--djb-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.swagger-ui .djb-response-grid .empty {
|
||||
color: var(--djb-text-muted);
|
||||
font-size: 13px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
/* Header 테이블 */
|
||||
.swagger-ui .djb-response-grid .hdr-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
table-layout: fixed;
|
||||
}
|
||||
.swagger-ui .djb-response-grid .hdr-table th,
|
||||
.swagger-ui .djb-response-grid .hdr-table td {
|
||||
border-bottom: 1px solid var(--djb-border);
|
||||
padding: 6px 8px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
word-break: break-all;
|
||||
}
|
||||
.swagger-ui .djb-response-grid .hdr-table thead th {
|
||||
background: var(--djb-bg-alt);
|
||||
font-weight: 600;
|
||||
}
|
||||
.swagger-ui .djb-response-grid .hdr-table td.hdr-name {
|
||||
font-weight: 600;
|
||||
color: var(--djb-text);
|
||||
width: 40%;
|
||||
}
|
||||
.swagger-ui .djb-response-grid .hdr-table td.hdr-val {
|
||||
font-family: var(--djb-mono);
|
||||
font-size: 12px;
|
||||
color: var(--djb-text);
|
||||
}
|
||||
|
||||
/* Body 탭 */
|
||||
.swagger-ui .djb-response-grid .body-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
border-bottom: 1px solid var(--djb-border);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.swagger-ui .djb-response-grid .body-tab {
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-bottom: none;
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--djb-text-muted);
|
||||
cursor: pointer;
|
||||
border-radius: 4px 4px 0 0;
|
||||
}
|
||||
.swagger-ui .djb-response-grid .body-tab:hover { color: var(--djb-text); }
|
||||
.swagger-ui .djb-response-grid .body-tab.active {
|
||||
color: var(--djb-accent);
|
||||
border-color: var(--djb-border);
|
||||
background: var(--djb-bg);
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
|
||||
.swagger-ui .djb-response-grid .body-panels {
|
||||
position: relative;
|
||||
min-height: 80px;
|
||||
}
|
||||
.swagger-ui .djb-response-grid .body-panel {
|
||||
display: none;
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
background: #0d1117;
|
||||
color: #e6edf3;
|
||||
border-radius: 6px;
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.swagger-ui .djb-response-grid .body-panel.active { display: block; }
|
||||
.swagger-ui .djb-response-grid .mono { font-family: var(--djb-mono); }
|
||||
|
||||
/* Meta 칩 / 시간 */
|
||||
.swagger-ui .djb-response-grid .status-chip {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
font-family: var(--djb-mono);
|
||||
color: #fff;
|
||||
}
|
||||
.swagger-ui .djb-response-grid .status-chip.ok { background: var(--djb-ok); }
|
||||
.swagger-ui .djb-response-grid .status-chip.warn { background: var(--djb-warn); }
|
||||
.swagger-ui .djb-response-grid .status-chip.err { background: var(--djb-err); }
|
||||
.swagger-ui .djb-response-grid .status-chip.neutral { background: var(--djb-text-muted); }
|
||||
|
||||
.swagger-ui .djb-response-grid .time {
|
||||
font-family: var(--djb-mono);
|
||||
font-size: 12px;
|
||||
color: var(--djb-text-muted);
|
||||
}
|
||||
|
||||
/* ─── 검증 실패 목록 (djb-swagger-validator.js) ─────────────────────────────── */
|
||||
.swagger-ui .djb-response-grid .verr-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.swagger-ui .djb-response-grid .verr-item {
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 6px;
|
||||
background: #fff8f8;
|
||||
border: 1px solid #ffd7d5;
|
||||
border-left: 3px solid var(--djb-err);
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.swagger-ui .djb-response-grid .verr-path {
|
||||
font-family: var(--djb-mono);
|
||||
font-weight: 700;
|
||||
color: var(--djb-err);
|
||||
}
|
||||
.swagger-ui .djb-response-grid .verr-msg { color: var(--djb-text); }
|
||||
.swagger-ui .djb-response-grid .verr-val {
|
||||
display: inline-block;
|
||||
margin-top: 2px;
|
||||
padding: 1px 6px;
|
||||
background: var(--djb-bg-alt);
|
||||
border: 1px solid var(--djb-border);
|
||||
border-radius: 3px;
|
||||
color: var(--djb-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ─── 다크 코드 패널 텍스트 선택 가독성 ──────────────────────────────────────
|
||||
기본 ::selection 색이 어두운 배경(#0d1117)과 겹쳐 선택한 글자가 안 보이므로,
|
||||
선택 영역 배경/글자색을 명시한다. (요청 스니펫 코드 + 응답 본문 패널) */
|
||||
.swagger-ui #djb-snippet-panel .djb-sn-code::selection,
|
||||
.swagger-ui #djb-snippet-panel .djb-sn-code ::selection,
|
||||
.swagger-ui .djb-response-grid .body-panel::selection,
|
||||
.swagger-ui .djb-response-grid .body-panel ::selection {
|
||||
background: #2f5c9e;
|
||||
color: #ffffff;
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
/*!
|
||||
* DjbSwaggerValidator — Execute 전에 요청 파라미터(header/query)와 요청 본문(body)을
|
||||
* OpenAPI 스키마로 검증하는 경량 JSON Schema 검증기.
|
||||
* (PoC rinjae-works/swagger-poc/js/validator.js 를 포탈용으로 이식)
|
||||
*
|
||||
* 지원 키워드: type, required, properties, items, $ref, maxLength, minLength,
|
||||
* maximum, minimum, pattern, enum
|
||||
* 지원 type: string, integer, number, object, array, boolean
|
||||
*
|
||||
* 사용:
|
||||
* var r = DjbSwaggerValidator.validateOperation(operation, parsedRequest, rootSpec);
|
||||
* if (!r.ok) console.log(r.errors); // [{ path, message, value }]
|
||||
*/
|
||||
(function (global) {
|
||||
"use strict";
|
||||
|
||||
function resolveRef(spec, ref) {
|
||||
if (ref.indexOf("#/") !== 0) return null;
|
||||
var parts = ref.slice(2).split("/");
|
||||
var cur = spec;
|
||||
for (var i = 0; i < parts.length; i++) {
|
||||
if (cur == null) return null;
|
||||
cur = cur[decodeURIComponent(parts[i]).replace(/~1/g, "/").replace(/~0/g, "~")];
|
||||
}
|
||||
return cur || null;
|
||||
}
|
||||
|
||||
function deref(spec, schema, seen) {
|
||||
if (!schema || typeof schema !== "object") return schema;
|
||||
if (schema.$ref) {
|
||||
if (seen && seen.indexOf(schema.$ref) !== -1) return {};
|
||||
var next = resolveRef(spec, schema.$ref);
|
||||
var seen2 = (seen || []).concat([schema.$ref]);
|
||||
return deref(spec, next, seen2);
|
||||
}
|
||||
return schema;
|
||||
}
|
||||
|
||||
function typeMatches(value, type) {
|
||||
if (value === null || value === undefined) return false;
|
||||
switch (type) {
|
||||
case "string": return typeof value === "string";
|
||||
case "integer": return typeof value === "number" && isFinite(value) && Math.floor(value) === value;
|
||||
case "number": return typeof value === "number" && !isNaN(value);
|
||||
case "boolean": return typeof value === "boolean";
|
||||
case "array": return Array.isArray(value);
|
||||
case "object": return typeof value === "object" && !Array.isArray(value);
|
||||
default: return true;
|
||||
}
|
||||
}
|
||||
|
||||
function pushErr(errors, path, message, value) {
|
||||
errors.push({ path: path || "/", message: message, value: value });
|
||||
}
|
||||
|
||||
function checkSchema(spec, schema, value, path, errors) {
|
||||
if (!schema) return;
|
||||
schema = deref(spec, schema);
|
||||
|
||||
if (value === undefined || value === null) {
|
||||
// required 는 상위 object 단계에서 확인. 여기서는 통과.
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type && !typeMatches(value, schema.type)) {
|
||||
var actual = typeof value === "object" ? (Array.isArray(value) ? "array" : "object") : typeof value;
|
||||
pushErr(errors, path, '타입이 "' + schema.type + '" 이어야 합니다 (현재: ' + actual + ")", value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(schema.enum) && schema.enum.indexOf(value) === -1) {
|
||||
pushErr(errors, path, "허용된 값이 아닙니다 (허용: " + schema.enum.join(", ") + ")", value);
|
||||
}
|
||||
|
||||
if (schema.type === "string" || typeof value === "string") {
|
||||
if (typeof schema.maxLength === "number" && value.length > schema.maxLength) {
|
||||
pushErr(errors, path, "최대 " + schema.maxLength + " 자까지 허용됩니다 (현재: " + value.length + "자)", value);
|
||||
}
|
||||
if (typeof schema.minLength === "number" && value.length < schema.minLength) {
|
||||
pushErr(errors, path, "최소 " + schema.minLength + " 자 이상이어야 합니다 (현재: " + value.length + "자)", value);
|
||||
}
|
||||
if (schema.pattern) {
|
||||
try {
|
||||
if (!new RegExp(schema.pattern).test(value)) {
|
||||
pushErr(errors, path, "형식이 올바르지 않습니다 (pattern: " + schema.pattern + ")", value);
|
||||
}
|
||||
} catch (e) { /* invalid regex in spec — skip */ }
|
||||
}
|
||||
}
|
||||
|
||||
if (schema.type === "integer" || schema.type === "number" || typeof value === "number") {
|
||||
if (typeof schema.maximum === "number" && value > schema.maximum) {
|
||||
pushErr(errors, path, "최대값 " + schema.maximum + " 이하여야 합니다 (현재: " + value + ")", value);
|
||||
}
|
||||
if (typeof schema.minimum === "number" && value < schema.minimum) {
|
||||
pushErr(errors, path, "최소값 " + schema.minimum + " 이상이어야 합니다 (현재: " + value + ")", value);
|
||||
}
|
||||
}
|
||||
|
||||
if (schema.type === "object" && schema.properties) {
|
||||
var requiredList = Array.isArray(schema.required) ? schema.required : [];
|
||||
requiredList.forEach(function (reqKey) {
|
||||
if (value[reqKey] === undefined || value[reqKey] === null || value[reqKey] === "") {
|
||||
pushErr(errors, path + "/" + reqKey, "필수 항목이 누락되었습니다", undefined);
|
||||
}
|
||||
});
|
||||
Object.keys(schema.properties).forEach(function (key) {
|
||||
if (value[key] !== undefined && value[key] !== null) {
|
||||
checkSchema(spec, schema.properties[key], value[key], path + "/" + key, errors);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (schema.type === "array" && schema.items && Array.isArray(value)) {
|
||||
value.forEach(function (item, idx) {
|
||||
checkSchema(spec, schema.items, item, path + "/" + idx, errors);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Swagger UI 의 request 객체에서 header/query/body 를 추출해 operation 정의로 검증.
|
||||
*
|
||||
* @param {Object} operation spec.paths[path][method] 객체
|
||||
* @param {Object} req { url, method, headers, body, ... }
|
||||
* @param {Object} rootSpec $ref 해석을 위한 전체 OpenAPI 문서(json)
|
||||
* @returns {{ ok:boolean, errors:Array<{path,message,value}> }}
|
||||
*/
|
||||
function validateOperation(operation, req, rootSpec) {
|
||||
var errors = [];
|
||||
if (!operation) return { ok: true, errors: errors };
|
||||
|
||||
// 1. parameters 검증 (header / query). path 는 Swagger 가 이미 치환 → 건너뜀.
|
||||
var params = Array.isArray(operation.parameters) ? operation.parameters : [];
|
||||
for (var i = 0; i < params.length; i++) {
|
||||
var p = deref(rootSpec, params[i]);
|
||||
var where = p.in;
|
||||
var name = p.name;
|
||||
var schema = p.schema ? deref(rootSpec, p.schema) : { type: "string" };
|
||||
|
||||
var value;
|
||||
if (where === "header") {
|
||||
var headers = req.headers || {};
|
||||
var matchKey = Object.keys(headers).filter(function (k) {
|
||||
return k.toLowerCase() === String(name).toLowerCase();
|
||||
})[0];
|
||||
value = matchKey ? headers[matchKey] : undefined;
|
||||
} else if (where === "query") {
|
||||
try {
|
||||
var u = new URL(req.url, "http://_local");
|
||||
value = u.searchParams.get(name);
|
||||
} catch (e) { value = undefined; }
|
||||
} else {
|
||||
continue; // path/cookie 등은 건너뜀
|
||||
}
|
||||
|
||||
if (p.required && (value === undefined || value === "" || value === null)) {
|
||||
pushErr(errors, (where === "query" ? "query" : "header") + "[" + name + "]",
|
||||
(where === "query" ? "필수 쿼리 파라미터가" : "필수 헤더가") + " 누락되었습니다", undefined);
|
||||
continue;
|
||||
}
|
||||
if (value !== undefined && value !== null && value !== "") {
|
||||
var coerced = (schema.type === "integer" || schema.type === "number") ? Number(value) : String(value);
|
||||
checkSchema(rootSpec, schema, coerced, (where === "query" ? "query" : "header") + "[" + name + "]", errors);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. requestBody 검증
|
||||
if (operation.requestBody) {
|
||||
var rb = deref(rootSpec, operation.requestBody);
|
||||
if (rb.required && (req.body === undefined || req.body === null || req.body === "")) {
|
||||
pushErr(errors, "body", "요청 본문이 비어 있습니다", undefined);
|
||||
} else if (req.body) {
|
||||
var contentTypes = Object.keys(rb.content || {});
|
||||
var ctHeader = (req.headers && (req.headers["Content-Type"] || req.headers["content-type"])) || contentTypes[0];
|
||||
var matchedCt = contentTypes.filter(function (c) {
|
||||
return ctHeader && ctHeader.toLowerCase().indexOf(c.split(";")[0].toLowerCase()) !== -1;
|
||||
})[0] || contentTypes[0];
|
||||
var bodySchema = (matchedCt && rb.content[matchedCt]) ? deref(rootSpec, rb.content[matchedCt].schema) : null;
|
||||
|
||||
var parsed = req.body;
|
||||
if (typeof parsed === "string") {
|
||||
try { parsed = JSON.parse(parsed); }
|
||||
catch (e) {
|
||||
pushErr(errors, "body", "JSON 파싱 실패: " + e.message, req.body);
|
||||
return { ok: false, errors: errors };
|
||||
}
|
||||
}
|
||||
if (bodySchema) {
|
||||
checkSchema(rootSpec, bodySchema, parsed, "body", errors);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: errors.length === 0, errors: errors };
|
||||
}
|
||||
|
||||
global.DjbSwaggerValidator = {
|
||||
validateOperation: validateOperation,
|
||||
_internal: { resolveRef: resolveRef, deref: deref, checkSchema: checkSchema }
|
||||
};
|
||||
})(window);
|
||||
@@ -1,7 +1,7 @@
|
||||
html {
|
||||
box-sizing: border-box;
|
||||
overflow: -moz-scrollbars-vertical;
|
||||
height: 100%;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
*,
|
||||
@@ -12,4 +12,5 @@ html {
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
@@ -1,79 +1,6 @@
|
||||
<!doctype html>
|
||||
<html lang="en-US">
|
||||
<head>
|
||||
<title>Swagger UI: OAuth2 Redirect</title>
|
||||
</head>
|
||||
<body>
|
||||
<script>
|
||||
'use strict';
|
||||
function run () {
|
||||
var oauth2 = window.opener.swaggerUIRedirectOauth2;
|
||||
var sentState = oauth2.state;
|
||||
var redirectUrl = oauth2.redirectUrl;
|
||||
var isValid, qp, arr;
|
||||
|
||||
if (/code|token|error/.test(window.location.hash)) {
|
||||
qp = window.location.hash.substring(1).replace('?', '&');
|
||||
} else {
|
||||
qp = location.search.substring(1);
|
||||
}
|
||||
|
||||
arr = qp.split("&");
|
||||
arr.forEach(function (v,i,_arr) { _arr[i] = '"' + v.replace('=', '":"') + '"';});
|
||||
qp = qp ? JSON.parse('{' + arr.join() + '}',
|
||||
function (key, value) {
|
||||
return key === "" ? value : decodeURIComponent(value);
|
||||
}
|
||||
) : {};
|
||||
|
||||
isValid = qp.state === sentState;
|
||||
|
||||
if ((
|
||||
oauth2.auth.schema.get("flow") === "accessCode" ||
|
||||
oauth2.auth.schema.get("flow") === "authorizationCode" ||
|
||||
oauth2.auth.schema.get("flow") === "authorization_code"
|
||||
) && !oauth2.auth.code) {
|
||||
if (!isValid) {
|
||||
oauth2.errCb({
|
||||
authId: oauth2.auth.name,
|
||||
source: "auth",
|
||||
level: "warning",
|
||||
message: "Authorization may be unsafe, passed state was changed in server. The passed state wasn't returned from auth server."
|
||||
});
|
||||
}
|
||||
|
||||
if (qp.code) {
|
||||
delete oauth2.state;
|
||||
oauth2.auth.code = qp.code;
|
||||
oauth2.callback({auth: oauth2.auth, redirectUrl: redirectUrl});
|
||||
} else {
|
||||
let oauthErrorMsg;
|
||||
if (qp.error) {
|
||||
oauthErrorMsg = "["+qp.error+"]: " +
|
||||
(qp.error_description ? qp.error_description+ ". " : "no accessCode received from the server. ") +
|
||||
(qp.error_uri ? "More info: "+qp.error_uri : "");
|
||||
}
|
||||
|
||||
oauth2.errCb({
|
||||
authId: oauth2.auth.name,
|
||||
source: "auth",
|
||||
level: "error",
|
||||
message: oauthErrorMsg || "[Authorization failed]: no accessCode received from the server."
|
||||
});
|
||||
}
|
||||
} else {
|
||||
oauth2.callback({auth: oauth2.auth, token: qp, isValid: isValid, redirectUrl: redirectUrl});
|
||||
}
|
||||
window.close();
|
||||
}
|
||||
|
||||
if (document.readyState !== 'loading') {
|
||||
run();
|
||||
} else {
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
run();
|
||||
});
|
||||
}
|
||||
</script>
|
||||
<script src="oauth2-redirect.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -32,12 +32,51 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 모바일 헤더(햄버거 버튼 좌측)용 타이머
|
||||
.session-timer-mobile {
|
||||
margin-right: $spacing-sm;
|
||||
font-size: $font-size-xs;
|
||||
// -----------------------------------------------------------------------------
|
||||
// 우측 상단 Float 세션 타이머 위젯
|
||||
// 데스크톱: 헤더 우측(유저 메뉴 우측)에 상단 정렬로 고정(fixed).
|
||||
// 모바일: 헤더 우측 햄버거(메뉴 버튼) 바로 왼쪽에 배치.
|
||||
// -----------------------------------------------------------------------------
|
||||
.session-float {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
right: 20px;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
padding: 6px 14px;
|
||||
background: #fff;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
|
||||
|
||||
.session-timer-text {
|
||||
min-width: 34px;
|
||||
// 세션 유지 체크박스 (비운영 전용, 조건부 렌더)
|
||||
.session-keepalive {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin: 0;
|
||||
padding-left: 8px;
|
||||
border-left: 1px solid #e0e0e0;
|
||||
font-size: $font-size-xs;
|
||||
color: $text-gray;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
|
||||
input {
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 모바일: 헤더 우측 햄버거(메뉴 버튼) 바로 왼쪽에 나란히 배치.
|
||||
// right = 헤더 padding(20px) + 햄버거 폭(~32px) + 간격(~20px) ≈ 72px
|
||||
@media (max-width: $breakpoint-sm) {
|
||||
.session-float {
|
||||
top: 12px;
|
||||
right: 72px;
|
||||
padding: 4px 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,7 +63,6 @@
|
||||
@use 'pages/org-register' as *;
|
||||
@use 'pages/partnership' as *;
|
||||
@use 'pages/user-management' as *;
|
||||
@use 'pages/commission' as *;
|
||||
@use 'pages/terms-agreements' as *;
|
||||
@use 'pages/service' as *;
|
||||
@use 'pages/api-statistics' as *;
|
||||
|
||||
@@ -11,13 +11,77 @@
|
||||
padding: 48px 0px;
|
||||
}
|
||||
|
||||
// 집계 안내 문구
|
||||
.statistics-notice {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
padding: 12px 18px;
|
||||
background: #eef4fc;
|
||||
border: 1px solid #d6e4f5;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
color: #4a5568;
|
||||
}
|
||||
|
||||
.statistics-notice-latest {
|
||||
color: #0049B4;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
// Search Section
|
||||
.statistics-search-section {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
// 일별/월별 모드 토글
|
||||
.search-mode-tabs {
|
||||
display: inline-flex;
|
||||
background: #eef1f5;
|
||||
border-radius: 22px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.mode-tab {
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 7px 22px;
|
||||
border-radius: 18px;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&.active {
|
||||
background: #0049B4;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
// 월 선택 (월별 모드)
|
||||
.month-range-picker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.month-input {
|
||||
border: none;
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.search-filter-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -149,6 +213,10 @@
|
||||
color: #4A90D9;
|
||||
}
|
||||
|
||||
&.timeout .detail-icon {
|
||||
color: #F5C36B;
|
||||
}
|
||||
|
||||
&.failure .detail-icon {
|
||||
color: #F5A3B5;
|
||||
}
|
||||
@@ -214,6 +282,10 @@
|
||||
background: #4A90D9;
|
||||
}
|
||||
|
||||
&.timeout {
|
||||
background: #F5C36B;
|
||||
}
|
||||
|
||||
&.failure {
|
||||
background: #F5A3B5;
|
||||
}
|
||||
@@ -233,6 +305,10 @@
|
||||
color: #4A90D9;
|
||||
}
|
||||
|
||||
.timeout-rate .rate-value {
|
||||
color: #F5C36B;
|
||||
}
|
||||
|
||||
.failure-rate .rate-value {
|
||||
color: #F5A3B5;
|
||||
}
|
||||
@@ -248,6 +324,11 @@
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
margin-bottom: 30px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.table-header {
|
||||
@@ -377,6 +458,11 @@
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-timeout {
|
||||
background: #F5C36B;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-failure {
|
||||
background: #F5A3B5;
|
||||
transition: width 0.3s ease;
|
||||
|
||||
@@ -1313,4 +1313,4 @@
|
||||
background: #1b4ab7;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -456,4 +456,4 @@
|
||||
color: #64748b;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4404,4 +4404,4 @@ input[type="checkbox"]:checked+.custom-checkbox {
|
||||
color: #ffffff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,412 +0,0 @@
|
||||
@use '../abstracts/variables' as *;
|
||||
@use '../abstracts/color-functions' as *;
|
||||
@use '../abstracts/mixins' as *;
|
||||
|
||||
// Commission Pages Styles
|
||||
// 수수료 관리 및 청구서 출력 페이지 스타일
|
||||
|
||||
|
||||
// ==================== Commission Management Page ====================
|
||||
|
||||
// Commission Container
|
||||
.commission-container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.wrap {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
background: $white;
|
||||
padding: $spacing-lg;
|
||||
border-radius: $border-radius-md;
|
||||
box-shadow: $shadow-md;
|
||||
}
|
||||
|
||||
.searchInfo {
|
||||
display: inline-block;
|
||||
margin-right: $spacing-sm;
|
||||
margin-bottom: $spacing-md;
|
||||
}
|
||||
|
||||
// Search Box
|
||||
.searchBox {
|
||||
background: #f8f9fa;
|
||||
padding: 20px;
|
||||
border-radius: $border-radius-md;
|
||||
margin-bottom: 20px;
|
||||
|
||||
&.row-two {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
label {
|
||||
font-weight: $font-weight-medium;
|
||||
color: $text-dark;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid $border-gray;
|
||||
border-radius: $border-radius-sm;
|
||||
font-size: $font-size-sm;
|
||||
min-width: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
.form-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
// 조회기간 [년] [월] 컨테이너 - PC 기본 스타일
|
||||
.period-selects {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
.period-unit {
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.btnBox {
|
||||
margin-left: auto;
|
||||
|
||||
.btn + .btn {
|
||||
margin-left: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
// Print Button (initially hidden)
|
||||
#printBtn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
// Notice Text
|
||||
.commission-notice {
|
||||
color: #dc3545;
|
||||
margin-top: 30px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.voffset3 {
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
// Color Utilities
|
||||
.color_red {
|
||||
color: $accent-orange;
|
||||
}
|
||||
|
||||
.color_blue {
|
||||
color: #007bff;
|
||||
}
|
||||
|
||||
.color_darkRed {
|
||||
color: #8b0000;
|
||||
}
|
||||
|
||||
// Table Styles
|
||||
.table-responsive {
|
||||
overflow-x: auto;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: $font-size-sm;
|
||||
|
||||
th {
|
||||
background: $gray-bg;
|
||||
font-weight: $font-weight-medium;
|
||||
color: $text-dark;
|
||||
padding: $spacing-md;
|
||||
text-align: center;
|
||||
border: 1px solid $border-gray;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: $spacing-md;
|
||||
text-align: center;
|
||||
border: 1px solid $border-gray;
|
||||
}
|
||||
}
|
||||
|
||||
.table-bordered {
|
||||
border: 1px solid $border-gray;
|
||||
|
||||
th,
|
||||
td {
|
||||
border: 1px solid $border-gray;
|
||||
}
|
||||
}
|
||||
|
||||
.border-type {
|
||||
border: 2px solid #dee2e6;
|
||||
}
|
||||
|
||||
// Commission Tables
|
||||
.commission-table-wrapper {
|
||||
margin-top: 30px;
|
||||
|
||||
.table {
|
||||
border: 2px solid #dee2e6;
|
||||
}
|
||||
}
|
||||
|
||||
// Summary Row
|
||||
.summary-row {
|
||||
background-color: #fff3cd !important;
|
||||
|
||||
td {
|
||||
font-weight: $font-weight-semibold;
|
||||
}
|
||||
|
||||
.summary-amount {
|
||||
font-size: 16px;
|
||||
color: #007bff;
|
||||
}
|
||||
}
|
||||
|
||||
// Text Alignment Utilities
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.text-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.text-left {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
// Background Utilities
|
||||
.bg-warning {
|
||||
background-color: #fff3cd !important;
|
||||
}
|
||||
|
||||
// Icon
|
||||
.glyphicon-search::before {
|
||||
content: "🔍";
|
||||
}
|
||||
|
||||
// ==================== Commission Print Page ====================
|
||||
|
||||
.info-table {
|
||||
border-top: 10px solid $text-gray;
|
||||
width: 100%;
|
||||
border-bottom: 2px solid $text-dark;
|
||||
margin-bottom: $spacing-2xl;
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
padding: $spacing-md $spacing-lg;
|
||||
font-weight: $font-weight-semibold;
|
||||
background: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: $spacing-md $spacing-lg;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
.commission-table {
|
||||
width: 100%;
|
||||
margin-top: $spacing-2xl;
|
||||
|
||||
th,
|
||||
td {
|
||||
border: 1px solid #999 !important;
|
||||
padding: $spacing-md $spacing-lg;
|
||||
}
|
||||
|
||||
td {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.bg_ygreen {
|
||||
background-color: aquamarine;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bg_skyBlue {
|
||||
background-color: skyblue;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bg_yellow {
|
||||
background-color: yellow;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
// Print-specific styles
|
||||
@media print {
|
||||
body {
|
||||
padding: 0;
|
||||
background: $white;
|
||||
}
|
||||
|
||||
.searchBox,
|
||||
.searchInfo,
|
||||
#printButton,
|
||||
#backButton,
|
||||
#printBtn {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@page {
|
||||
size: auto;
|
||||
margin: 0 -0cm;
|
||||
}
|
||||
|
||||
html {
|
||||
margin: 0 0cm;
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive Design
|
||||
@media screen and (max-width: $breakpoint-md) {
|
||||
.wrap {
|
||||
padding: $spacing-md;
|
||||
}
|
||||
|
||||
.searchBox {
|
||||
padding: $spacing-md;
|
||||
|
||||
&.row-two {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
}
|
||||
|
||||
.form-inline {
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
|
||||
label {
|
||||
min-width: auto;
|
||||
margin-bottom: $spacing-xs;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
// 조회기간: [년] [월] 한 줄 유지
|
||||
&--period {
|
||||
.period-selects {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
width: 100%;
|
||||
|
||||
.form-control {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.period-unit {
|
||||
flex-shrink: 0;
|
||||
font-size: $font-size-sm;
|
||||
color: $text-dark;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.btnBox {
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.table-responsive {
|
||||
overflow-x: scroll;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
}
|
||||
|
||||
// Commission Print Page Specific
|
||||
// Note: commissionPrint.html uses body class for scoping
|
||||
body.commission-print-page {
|
||||
font-size: 12px;
|
||||
width: 600px;
|
||||
margin: 90px;
|
||||
font-family: $font-family-primary;
|
||||
|
||||
div {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
tr {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
h1 {
|
||||
text-align: center;
|
||||
|
||||
img {
|
||||
width: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-top: $spacing-2xl;
|
||||
float: left;
|
||||
}
|
||||
|
||||
p {
|
||||
line-height: 1.6;
|
||||
|
||||
&[style*="float: right"] {
|
||||
margin-top: 25px;
|
||||
}
|
||||
}
|
||||
|
||||
// Print buttons
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
|
||||
&:hover {
|
||||
background: #5a67d8;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -862,4 +862,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
.signup-selection-page {
|
||||
min-height: calc(100vh - 140px); // Account for header and footer
|
||||
min-height: calc(100vh - 380px); // Account for header and footer
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
@@ -147,6 +147,21 @@
|
||||
<div class="tab-content" id="testbed-tab">
|
||||
<link rel="stylesheet" type="text/css" th:href="@{/plugins/swaggerUI/swagger-ui.css}"/>
|
||||
<link rel="stylesheet" type="text/css" th:href="@{/plugins/swaggerUI/index.css}"/>
|
||||
<link rel="stylesheet" type="text/css" th:href="@{/plugins/swaggerUI/djb-swagger-testbed.css}"/>
|
||||
|
||||
<!-- DJPGPT0001: 앱(인증키) 선택 → 인증 정보 자동 주입 -->
|
||||
<div class="testbed-app-panel" id="appsWrap">
|
||||
<div class="testbed-app-panel__head">
|
||||
<span class="testbed-app-panel__title">앱 선택</span>
|
||||
<span class="testbed-app-panel__desc">테스트할 앱(인증키)을 선택하면 인증 정보가 자동 입력됩니다.</span>
|
||||
</div>
|
||||
<div class="testbed-app-field">
|
||||
<select id="apps">
|
||||
<option value="">앱을 선택하세요</option>
|
||||
</select>
|
||||
</div>
|
||||
<p class="testbed-app-notice" id="appsNotice" style="display:none;"></p>
|
||||
</div>
|
||||
|
||||
<div id="swagger-ui"></div>
|
||||
</div>
|
||||
@@ -160,6 +175,8 @@
|
||||
</body>
|
||||
|
||||
<th:block layout:fragment="contentScript">
|
||||
<!-- DJB Swagger UI 한글화 패치 (MutationObserver DOM patch) -->
|
||||
<script th:src="@{/plugins/swaggerUI/djb-swagger-i18n.js}" charset="UTF-8"></script>
|
||||
<script th:inline="javascript">
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// DOM Elements
|
||||
@@ -284,7 +301,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const swaggerUrl = `/api/apis/${apiId}/swagger.json`;
|
||||
const swaggerUrl = `/djb/testbed/apis/${apiId}/swagger.json`;
|
||||
|
||||
// Load Swagger UI scripts dynamically
|
||||
const loadScript = (src) => {
|
||||
@@ -305,63 +322,29 @@
|
||||
// Load code template script
|
||||
return loadScript(/*[[@{/js/code_template.js}]]*/ '');
|
||||
}).then(() => {
|
||||
// Initialize Swagger UI
|
||||
const sampleCodeBaseUrl = /*[[@{/api/sample_code/}]]*/ '/api/sample_code/';
|
||||
const generateSnippet = (parsedRequest, language) => {
|
||||
let result;
|
||||
try {
|
||||
$.ajax({
|
||||
url: sampleCodeBaseUrl + language,
|
||||
type: 'POST',
|
||||
async: false,
|
||||
contentType: 'application/json',
|
||||
headers: {
|
||||
'X-XSRF-TOKEN': /*[[${_csrf.token}]]*/ ''
|
||||
},
|
||||
data: JSON.stringify(parsedRequest),
|
||||
success: function(data) {
|
||||
result = data;
|
||||
},
|
||||
error: function(jqXHR, textStatus, errorThrown) {
|
||||
console.error('Error:', errorThrown);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const SnippetGeneratorPlugin = {
|
||||
fn: {
|
||||
requestSnippetGenerator_csharp: (request) => {
|
||||
var parsedRequest = parseSwaggerRequest(request);
|
||||
return generateSnippet(parsedRequest, 'csharp');
|
||||
},
|
||||
requestSnippetGenerator_ecma5: (request) => {
|
||||
var parsedRequest = parseSwaggerRequest(request);
|
||||
return generateSnippet(parsedRequest, 'ecma5');
|
||||
},
|
||||
requestSnippetGenerator_java: (request) => {
|
||||
var parsedRequest = parseSwaggerRequest(request);
|
||||
return generateSnippet(parsedRequest, 'java');
|
||||
},
|
||||
requestSnippetGenerator_python: (request) => {
|
||||
var parsedRequest = parseSwaggerRequest(request);
|
||||
return generateSnippet(parsedRequest, 'python');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// DJB 요청 스니펫(7종) / 상시 스니펫 패널 / 커스텀 응답 그리드 스크립트
|
||||
return Promise.all([
|
||||
loadScript(/*[[@{/plugins/swaggerUI/djb-swagger-snippets.js}]]*/ ''),
|
||||
loadScript(/*[[@{/plugins/swaggerUI/djb-swagger-snippet-panel.js}]]*/ ''),
|
||||
loadScript(/*[[@{/plugins/swaggerUI/djb-swagger-validator.js}]]*/ ''),
|
||||
loadScript(/*[[@{/plugins/swaggerUI/djb-swagger-response.js}]]*/ '')
|
||||
]);
|
||||
}).then(() => {
|
||||
// Clear existing Swagger UI
|
||||
document.getElementById('swagger-ui').innerHTML = '';
|
||||
|
||||
// 테스트베드 프록시 사용 여부(djb.gateway.use-proxy). N 이면 브라우저에서 대상으로 직접 호출.
|
||||
window.__djbUseProxy = /*[[${@djbTestbedGatewayProperty.useProxy()}]]*/ true;
|
||||
// 토큰 발급 프록시 사용 여부(djb.gateway.token-use-proxy) — API 호출과 별개.
|
||||
window.__djbTokenUseProxy = /*[[${@djbTestbedGatewayProperty.tokenUseProxy()}]]*/ true;
|
||||
|
||||
// Initialize Swagger UI
|
||||
const ui = SwaggerUIBundle({
|
||||
url: swaggerUrl,
|
||||
dom_id: '#swagger-ui',
|
||||
requestInterceptor: function(req) {
|
||||
if (!req.loadSpec) {
|
||||
// 프록시 사용 시에만 /api/call-api 로 우회. 직접호출 모드(false)면 req.url(스펙 서버+경로) 그대로 호출.
|
||||
if (!req.loadSpec && window.__djbUseProxy !== false) {
|
||||
const urlPath = new URL(req.url).pathname;
|
||||
const method = req.method.toLowerCase();
|
||||
|
||||
@@ -387,48 +370,158 @@
|
||||
}
|
||||
return req;
|
||||
},
|
||||
responseInterceptor: function(res) {
|
||||
// 커스텀 응답 그리드에 렌더 (기본 응답영역은 CSS 로 숨김)
|
||||
try { window.DjbSwaggerResponse.renderResponse(res); } catch (e) { console.error(e); }
|
||||
return res;
|
||||
},
|
||||
onComplete: function() {
|
||||
// 단일 API 페이지 — 상시 요청 스니펫 패널 마운트
|
||||
setTimeout(function() { try { window.DjbSwaggerSnippetPanel.mount({ rootSel: '#swagger-ui' }); } catch (e) { console.error(e); } }, 50);
|
||||
},
|
||||
showMutatedRequest: false,
|
||||
validatorUrl: '',
|
||||
deepLinking: true,
|
||||
// 항상 1개 API 노출 → operation 을 펼친 상태로, Try it out 활성 기본
|
||||
docExpansion: 'full',
|
||||
defaultModelsExpandDepth: -1,
|
||||
defaultModelExpandDepth: 5,
|
||||
tryItOutEnabled: true,
|
||||
displayRequestDuration: true,
|
||||
presets: [
|
||||
SwaggerUIBundle.presets.apis,
|
||||
SwaggerUIStandalonePreset.slice(1)
|
||||
],
|
||||
plugins: [
|
||||
SwaggerUIBundle.plugins.DownloadUrl,
|
||||
SnippetGeneratorPlugin
|
||||
window.SnippetGeneratorPlugin
|
||||
],
|
||||
layout: 'StandaloneLayout',
|
||||
requestSnippetsEnabled: true,
|
||||
requestSnippets: {
|
||||
generators: {
|
||||
'csharp': {
|
||||
title: 'C#',
|
||||
syntax: 'csharp'
|
||||
},
|
||||
'ecma5': {
|
||||
title: 'ECMA5',
|
||||
syntax: 'javascript'
|
||||
},
|
||||
'java': {
|
||||
title: 'Java',
|
||||
syntax: 'java'
|
||||
},
|
||||
'python': {
|
||||
title: 'Python',
|
||||
syntax: 'python'
|
||||
}
|
||||
'curl_bash': { title: 'cURL (Bash)', syntax: 'bash' },
|
||||
'curl_powershell': { title: 'cURL (PowerShell)', syntax: 'powershell' },
|
||||
'curl_cmd': { title: 'cURL (CMD)', syntax: 'bash' },
|
||||
'csharp': { title: 'C#', syntax: 'csharp' },
|
||||
'ecma5': { title: 'ECMA5 (JavaScript)', syntax: 'javascript' },
|
||||
'java': { title: 'Java', syntax: 'java' },
|
||||
'python': { title: 'Python', syntax: 'python' }
|
||||
},
|
||||
defaultExpanded: true,
|
||||
languages: null
|
||||
}
|
||||
});
|
||||
window.ui = ui;
|
||||
// Execute 클릭 캡처(응답 그리드 대상/타이머) 등록 — #swagger-ui 는 재초기화돼도 유지됨
|
||||
window.DjbSwaggerResponse.attach('#swagger-ui');
|
||||
// 앱이 이미 선택돼 있으면 스펙 로드 후 재주입
|
||||
if (appsSelect && appsSelect.value) {
|
||||
setTimeout(function () { onAppSelected(appsSelect.value); }, 300);
|
||||
}
|
||||
}).catch(error => {
|
||||
console.error('Failed to load Swagger UI:', error);
|
||||
document.getElementById('swagger-ui').innerHTML = '<p>Swagger UI를 불러오는 중 오류가 발생했습니다.</p>';
|
||||
});
|
||||
}
|
||||
|
||||
// ===== DJPGPT0001: 앱 인증 정보 자동 주입 =====
|
||||
const DEFAULT_TOKEN_API_ID = 'default-token-api-spec';
|
||||
const appsSelect = document.getElementById('apps');
|
||||
|
||||
function eligibilityMessage(reason) {
|
||||
switch (reason) {
|
||||
case 'ANONYMOUS': return '로그인 후 본인 앱의 인증 정보를 사용할 수 있습니다.';
|
||||
case 'INDIVIDUAL_USER': return '개인사용자는 앱 인증 정보를 사용할 수 없습니다. 법인 계정으로 이용해 주세요.';
|
||||
case 'NO_CREDENTIAL': return '사용 가능한 앱(인증키)이 없습니다. 인증 키 관리에서 앱을 생성해 주세요.';
|
||||
default: return '앱 인증 정보를 사용할 수 없습니다.';
|
||||
}
|
||||
}
|
||||
|
||||
function loadTestbedContext() {
|
||||
fetch(/*[[@{/djb/testbed/auth/context}]]*/ '/djb/testbed/auth/context')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (ctx) {
|
||||
window.__djbGateway = ctx.gateway || {};
|
||||
if (!appsSelect) return;
|
||||
const notice = document.getElementById('appsNotice');
|
||||
if (!ctx.eligible) {
|
||||
appsSelect.disabled = true;
|
||||
if (notice) { notice.style.display = 'block'; notice.textContent = eligibilityMessage(ctx.reason); }
|
||||
return;
|
||||
}
|
||||
appsSelect.disabled = false;
|
||||
if (notice) notice.style.display = 'none';
|
||||
(ctx.credentials || []).forEach(function (c) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = c.clientId;
|
||||
opt.textContent = c.clientName ? (c.clientName + ' (' + c.clientId + ')') : c.clientId;
|
||||
appsSelect.appendChild(opt);
|
||||
});
|
||||
})
|
||||
.catch(function (e) { console.error('테스트베드 컨텍스트 로드 실패', e); });
|
||||
}
|
||||
|
||||
function fetchOAuthToken(clientId, clientSecret, gw) {
|
||||
const body = 'grant_type=client_credentials'
|
||||
+ '&client_id=' + encodeURIComponent(clientId)
|
||||
+ '&client_secret=' + encodeURIComponent(clientSecret)
|
||||
+ '&scope=api';
|
||||
// 토큰 발급 프록시 여부(djb.gateway.token-use-proxy). false 면 토큰 URL 로 브라우저 직접 호출.
|
||||
var direct = (window.__djbTokenUseProxy === false);
|
||||
var url = direct ? gw.tokenUrl : window.location.origin + (/*[[@{/api/call-api}]]*/ '/api/call-api');
|
||||
var headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
|
||||
if (!direct) {
|
||||
headers['original-url'] = gw.tokenUrl;
|
||||
headers['X-XSRF-TOKEN'] = /*[[${_csrf.token}]]*/ '';
|
||||
}
|
||||
return fetch(url, {
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
body: body
|
||||
}).then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (data) { return data ? data.access_token : null; });
|
||||
}
|
||||
|
||||
function authorizeApp(secret) {
|
||||
const gw = window.__djbGateway || {};
|
||||
if (!window.ui) return;
|
||||
if (secret.authType === 'OAUTH') {
|
||||
fetchOAuthToken(secret.clientId, secret.clientSecret, gw).then(function (token) {
|
||||
if (!token) return;
|
||||
window.ui.authActions.authorize({
|
||||
djbOAuth: {
|
||||
name: 'djbOAuth',
|
||||
schema: { type: 'apiKey', in: 'header', name: gw.oauthTokenHeader },
|
||||
value: 'Bearer ' + token
|
||||
}
|
||||
});
|
||||
});
|
||||
} else if (secret.authType === 'API_KEY') {
|
||||
window.ui.authActions.authorize({
|
||||
djbApiKey: {
|
||||
name: 'djbApiKey',
|
||||
schema: { type: 'apiKey', in: 'header', name: gw.apiKeyHeader },
|
||||
value: secret.clientSecret
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function onAppSelected(clientId) {
|
||||
if (!clientId) return;
|
||||
if (!currentApiId || currentApiId === 'default' || currentApiId === DEFAULT_TOKEN_API_ID) return;
|
||||
fetch((/*[[@{/djb/testbed/auth/credentials/}]]*/ '/djb/testbed/auth/credentials/')
|
||||
+ encodeURIComponent(clientId) + '/secret?apiId=' + encodeURIComponent(currentApiId))
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (secret) { if (secret) authorizeApp(secret); })
|
||||
.catch(function (e) { console.error('앱 인증정보 주입 실패', e); });
|
||||
}
|
||||
|
||||
if (appsSelect) {
|
||||
appsSelect.addEventListener('change', function () { onAppSelected(this.value); });
|
||||
}
|
||||
loadTestbedContext();
|
||||
});
|
||||
</script>
|
||||
</th:block>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<section layout:fragment="contentFragment" class="content">
|
||||
<link rel="stylesheet" type="text/css" th:href="@{/plugins/swaggerUI/swagger-ui.css}"/>
|
||||
<link rel="stylesheet" type="text/css" th:href="@{/plugins/swaggerUI/index.css}"/>
|
||||
<link rel="stylesheet" type="text/css" th:href="@{/plugins/swaggerUI/djb-swagger-testbed.css}"/>
|
||||
|
||||
<div class="content_wrap">
|
||||
<div class="sub_title3">
|
||||
@@ -40,54 +41,15 @@
|
||||
|
||||
<th:block layout:fragment="contentScript">
|
||||
<script th:src="@{/js/code_template.js}"></script>
|
||||
<!-- DJB Swagger UI 한글화 패치 (MutationObserver DOM patch) -->
|
||||
<script th:src="@{/plugins/swaggerUI/djb-swagger-i18n.js}" charset="UTF-8"></script>
|
||||
<!-- DJB 요청 스니펫(클라이언트 7종) / 상시 스니펫 패널 / 커스텀 응답 그리드 -->
|
||||
<script th:src="@{/plugins/swaggerUI/djb-swagger-snippets.js}" charset="UTF-8"></script>
|
||||
<script th:src="@{/plugins/swaggerUI/djb-swagger-snippet-panel.js}" charset="UTF-8"></script>
|
||||
<script th:src="@{/plugins/swaggerUI/djb-swagger-validator.js}" charset="UTF-8"></script>
|
||||
<script th:src="@{/plugins/swaggerUI/djb-swagger-response.js}" charset="UTF-8"></script>
|
||||
<script>
|
||||
|
||||
const generateSnippet = (parsedRequest, language) => {
|
||||
let result;
|
||||
try {
|
||||
$.ajax({
|
||||
url: '[[@{/api/sample_code/}]]' + language,
|
||||
type: 'POST',
|
||||
async: false,
|
||||
contentType: 'application/json',
|
||||
headers: {
|
||||
'X-XSRF-TOKEN': '[[${_csrf.token}]]'
|
||||
},
|
||||
data: JSON.stringify(parsedRequest),
|
||||
success: function(data) {
|
||||
result = data;
|
||||
},
|
||||
error: function(jqXHR, textStatus, errorThrown) {
|
||||
console.error('Error:', errorThrown);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const SnippetGeneratorPlugin = {
|
||||
fn: {
|
||||
requestSnippetGenerator_csharp: (request) => {
|
||||
var parsedRequest = parseSwaggerRequest(request);
|
||||
return generateSnippet(parsedRequest, 'csharp');
|
||||
},
|
||||
requestSnippetGenerator_ecma5: (request) => {
|
||||
var parsedRequest = parseSwaggerRequest(request);
|
||||
return generateSnippet(parsedRequest, 'ecma5');
|
||||
},
|
||||
requestSnippetGenerator_java: (request) => {
|
||||
var parsedRequest = parseSwaggerRequest(request);
|
||||
return generateSnippet(parsedRequest, 'java');
|
||||
},
|
||||
requestSnippetGenerator_python: (request) => {
|
||||
var parsedRequest = parseSwaggerRequest(request);
|
||||
return generateSnippet(parsedRequest, 'python');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
let currentApiId = '[[${id}]]';
|
||||
|
||||
@@ -96,12 +58,15 @@
|
||||
const swaggerUrl = `/api/apis/${apiId}/swagger.json`;
|
||||
// Clear existing Swagger UI
|
||||
document.getElementById('swagger-ui').innerHTML = '';
|
||||
// 테스트베드 프록시 사용 여부(djb.gateway.use-proxy). N 이면 브라우저에서 대상으로 직접 호출.
|
||||
window.__djbUseProxy = ('[[${@djbTestbedGatewayProperty.useProxy()}]]' !== 'false');
|
||||
// Initialize Swagger UI
|
||||
const ui = SwaggerUIBundle({
|
||||
url: swaggerUrl,
|
||||
dom_id: '#swagger-ui',
|
||||
requestInterceptor: function(req) {
|
||||
if (!req.loadSpec) {
|
||||
// 프록시 사용 시에만 /api/call-api 로 우회. 직접호출 모드면 req.url 그대로 호출.
|
||||
if (!req.loadSpec && window.__djbUseProxy !== false) {
|
||||
const urlPath = new URL(req.url).pathname;
|
||||
const method = req.method.toLowerCase();
|
||||
|
||||
@@ -131,43 +96,51 @@
|
||||
}
|
||||
return req;
|
||||
},
|
||||
responseInterceptor: function(res) {
|
||||
// 커스텀 응답 그리드에 렌더 (기본 응답영역은 CSS 로 숨김)
|
||||
try { window.DjbSwaggerResponse.renderResponse(res); } catch (e) { console.error(e); }
|
||||
return res;
|
||||
},
|
||||
onComplete: function() {
|
||||
// 단일 API 페이지 — 상시 요청 스니펫 패널 마운트
|
||||
setTimeout(function() { try { window.DjbSwaggerSnippetPanel.mount({ rootSel: '#swagger-ui' }); } catch (e) { console.error(e); } }, 50);
|
||||
},
|
||||
showMutatedRequest: false,
|
||||
validatorUrl: '',
|
||||
deepLinking: true,
|
||||
// 항상 1개 API 노출 → operation 을 펼친 상태로, Try it out 활성 기본
|
||||
docExpansion: 'full',
|
||||
defaultModelsExpandDepth: -1,
|
||||
defaultModelExpandDepth: 5,
|
||||
tryItOutEnabled: true,
|
||||
displayRequestDuration: true,
|
||||
presets: [
|
||||
SwaggerUIBundle.presets.apis,
|
||||
SwaggerUIStandalonePreset.slice(1)
|
||||
],
|
||||
plugins: [
|
||||
SwaggerUIBundle.plugins.DownloadUrl,
|
||||
SnippetGeneratorPlugin
|
||||
window.SnippetGeneratorPlugin
|
||||
],
|
||||
layout: 'StandaloneLayout',
|
||||
requestSnippetsEnabled: true,
|
||||
requestSnippets: {
|
||||
generators: {
|
||||
'csharp': {
|
||||
title: 'C#',
|
||||
syntax: 'csharp'
|
||||
},
|
||||
'ecma5': {
|
||||
title: 'ECMA5',
|
||||
syntax: 'javascript'
|
||||
},
|
||||
'java': {
|
||||
title: 'Java',
|
||||
syntax: 'java'
|
||||
},
|
||||
'python': {
|
||||
title: 'Python',
|
||||
syntax: 'python'
|
||||
}
|
||||
'curl_bash': { title: 'cURL (Bash)', syntax: 'bash' },
|
||||
'curl_powershell': { title: 'cURL (PowerShell)', syntax: 'powershell' },
|
||||
'curl_cmd': { title: 'cURL (CMD)', syntax: 'bash' },
|
||||
'csharp': { title: 'C#', syntax: 'csharp' },
|
||||
'ecma5': { title: 'ECMA5 (JavaScript)', syntax: 'javascript' },
|
||||
'java': { title: 'Java', syntax: 'java' },
|
||||
'python': { title: 'Python', syntax: 'python' }
|
||||
},
|
||||
defaultExpanded: true,
|
||||
languages: null
|
||||
}
|
||||
});
|
||||
window.ui = ui;
|
||||
// Execute 클릭 캡처(응답 그리드 대상/타이머) 등록 — #swagger-ui 는 재초기화돼도 유지됨
|
||||
window.DjbSwaggerResponse.attach('#swagger-ui');
|
||||
}
|
||||
const apiList = document.getElementById('apiList');
|
||||
createCustomSelect(apiList, {
|
||||
|
||||
@@ -1,496 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org"
|
||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
||||
layout:decorate="~{layout/djbank_title_layout}">
|
||||
<head>
|
||||
<title>수수료 관리</title>
|
||||
</head>
|
||||
<body>
|
||||
<section layout:fragment="title">
|
||||
<div class="page-title-banner">
|
||||
<img th:src="@{/img/img_title_bg.png}" class="title-image">
|
||||
<h1>기관 수수료 관리</h1>
|
||||
</div>
|
||||
</section>
|
||||
<th:block layout:fragment="contentFragment">
|
||||
<section class="org-register-container">
|
||||
<div class="searchBox row-two">
|
||||
<span class="form-inline form-inline--period">
|
||||
<label for="forYear">조회기간</label>
|
||||
<span class="period-selects">
|
||||
<select id="forYear" class="form-control">
|
||||
<option value="">- 선택 -</option>
|
||||
</select> <span class="period-unit">년</span>
|
||||
<select id="forMonth" class="form-control">
|
||||
<option value="">- 선택 -</option>
|
||||
<option value="1">1</option>
|
||||
<option value="2">2</option>
|
||||
<option value="3">3</option>
|
||||
<option value="4">4</option>
|
||||
<option value="5">5</option>
|
||||
<option value="6">6</option>
|
||||
<option value="7">7</option>
|
||||
<option value="8">8</option>
|
||||
<option value="9">9</option>
|
||||
<option value="10">10</option>
|
||||
<option value="11">11</option>
|
||||
<option value="12">12</option>
|
||||
</select> <span class="period-unit">월</span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="btnBox">
|
||||
<span id="searchedPeriod" class="searched-period" style="display:none; margin-right:10px; font-weight:bold; color:#28a745;"></span>
|
||||
<button type="button" class="btn btn-primary" onclick="loadData()">
|
||||
<span class="fa fa-search"></span><span>조회</span>
|
||||
</button>
|
||||
<button type="button" id="printBtn" class="btn btn-secondary" onclick="printCommission()">
|
||||
<span>청구서 출력</span>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="commission-container">
|
||||
<div class="col-md-12">
|
||||
<p class="commission-notice">
|
||||
이용기관은 매월 1~5일 전월 수수료 조회 가능하며, 10일 확정금액으로 정산됩니다. <br/>
|
||||
금액이상시 담당자 연락 및 조정요청 하시기 바랍니다. (매월 1~5일 수수료 조회, 6~9일 수수료 조정 및 확정, 10일 정산)
|
||||
</p>
|
||||
|
||||
<!-- API 수수료 테이블 -->
|
||||
<div class="table-responsive">
|
||||
<table class="table table-bordered border-type">
|
||||
<colgroup>
|
||||
<col style="width:6%"/>
|
||||
<col style="width:16%"/>
|
||||
<col style="width:8%"/>
|
||||
<col style="width:8%"/>
|
||||
<col style="width:8%"/>
|
||||
<col style="width:8%"/>
|
||||
<col style="width:8%"/>
|
||||
<col style="width:8%"/>
|
||||
<col style="width:8%"/>
|
||||
<col style="width:8%"/>
|
||||
<col/>
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th rowspan="2">구분</th>
|
||||
<th rowspan="2">API 명</th>
|
||||
<th colspan="4">조정 전</th>
|
||||
<th colspan="4">조정 후</th>
|
||||
<th rowspan="2">조정사유</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>처리<br/>건수</th>
|
||||
<th>적용<br/>수수료(원)</th>
|
||||
<th>최저<br/>수수료</th>
|
||||
<th>출금<br/>예상금액</th>
|
||||
<th>조정<br/>건수</th>
|
||||
<th>조정<br/>수수료(원)</th>
|
||||
<th>최저<br/>수수료</th>
|
||||
<th>출금<br/>예상금액</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="apiTableBody">
|
||||
<!-- 동적 생성 -->
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- 판매 수수료 테이블 -->
|
||||
<table class="table table-bordered border-type voffset3">
|
||||
<colgroup>
|
||||
<col style="width: 6%;">
|
||||
<col style="width: 8%;">
|
||||
<col style="width: 8%;">
|
||||
<col style="width: 5%;">
|
||||
<col style="width: 5%;">
|
||||
<col style="width: 7%;">
|
||||
<col style="width: 6%;">
|
||||
<col style="width: 6%;">
|
||||
<col style="width: 7%;">
|
||||
<col style="width: 7%;">
|
||||
<col style="width: 6%;">
|
||||
<col style="width: 6%;">
|
||||
<col style="width: 7%;">
|
||||
<col style="width: 7%;">
|
||||
<col>
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th rowspan="2">구분</th>
|
||||
<th rowspan="2">과금대상<br/>업무</th>
|
||||
<th rowspan="2">수수료<br/>유형</th>
|
||||
<th rowspan="2">과금<br/>기준</th>
|
||||
<th rowspan="2">값구분</th>
|
||||
<th rowspan="2">판매액</th>
|
||||
<th colspan="4">조정 전</th>
|
||||
<th colspan="4">조정 후</th>
|
||||
<th rowspan="2">조정사유</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>적용<br/>수수료</th>
|
||||
<th>처리<br/>건수</th>
|
||||
<th>최저<br/>수수료</th>
|
||||
<th>출금<br/>예상수수료</th>
|
||||
<th>적용<br/>수수료</th>
|
||||
<th>처리<br/>건수</th>
|
||||
<th>최저<br/>수수료</th>
|
||||
<th>출금<br/>예상수수료</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="saleTableBody">
|
||||
<!-- 동적 생성 -->
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- 합계 테이블 -->
|
||||
<table class="table table-bordered border-type voffset3">
|
||||
<colgroup>
|
||||
<col/>
|
||||
<col style="width: 13%"/>
|
||||
<col style="width: 13%"/>
|
||||
<col style="width: 13%"/>
|
||||
<col style="width: 13%"/>
|
||||
<col style="width: 13%"/>
|
||||
<col style="width: 13%"/>
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th rowspan="2">구분</th>
|
||||
<th colspan="3">조정 전</th>
|
||||
<th colspan="3">조정 후</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>판매액</th>
|
||||
<th>처리건수(건)</th>
|
||||
<th>출금예상금액</th>
|
||||
<th>판매액</th>
|
||||
<th>처리건수(건)</th>
|
||||
<th>출금예상금액</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="summary-row">
|
||||
<td class="text-center"><strong id="totalTitle">합계</strong></td>
|
||||
<td class="text-right"><strong id="totalSalesAmount1">-</strong></td>
|
||||
<td class="text-right"><strong id="totalCount1">-</strong></td>
|
||||
<td class="text-right"><strong class="summary-amount" id="totalAmount1">-</strong></td>
|
||||
<td class="text-right"><strong id="totalSalesAmount2">-</strong></td>
|
||||
<td class="text-right"><strong id="totalCount2">-</strong></td>
|
||||
<td class="text-right"><strong class="summary-amount" id="totalAmount2">-</strong></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</th:block>
|
||||
|
||||
<th:block layout:fragment="contentScript">
|
||||
<script th:inline="javascript">
|
||||
// 페이지 로드 시 초기화 및 자동 조회
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var noOrgCode = /*[[${noOrgCode}]]*/ false;
|
||||
if (noOrgCode) {
|
||||
customPopups.showAlert('제휴기관코드가 등록되지 않았습니다.<br/>관리자에게 문의하세요.', function() {
|
||||
history.back();
|
||||
});
|
||||
return;
|
||||
}
|
||||
initializeDateSelects();
|
||||
// 페이지 로딩 후 자동 조회
|
||||
loadData();
|
||||
});
|
||||
|
||||
function initializeDateSelects() {
|
||||
const now = new Date();
|
||||
const currentYear = now.getFullYear();
|
||||
const currentMonth = now.getMonth() + 1; // 0-indexed
|
||||
|
||||
// 전월 계산
|
||||
let lastMonth = currentMonth - 1;
|
||||
let lastMonthYear = currentYear;
|
||||
if (lastMonth === 0) {
|
||||
lastMonth = 12;
|
||||
lastMonthYear = currentYear - 1;
|
||||
}
|
||||
|
||||
// 연도 옵션 동적 생성 (최근 3년)
|
||||
const yearSelect = document.getElementById('forYear');
|
||||
for (let year = currentYear; year >= currentYear - 2; year--) {
|
||||
const option = document.createElement('option');
|
||||
option.value = year;
|
||||
option.textContent = year;
|
||||
if (year === lastMonthYear) {
|
||||
option.selected = true;
|
||||
}
|
||||
yearSelect.appendChild(option);
|
||||
}
|
||||
|
||||
// 월 기본값 설정 (전월)
|
||||
const monthSelect = document.getElementById('forMonth');
|
||||
monthSelect.value = lastMonth.toString();
|
||||
}
|
||||
|
||||
function showSearchedPeriod(year, month) {
|
||||
const searchedPeriodEl = document.getElementById('searchedPeriod');
|
||||
searchedPeriodEl.textContent = year + '년 ' + month + '월 조회완료';
|
||||
searchedPeriodEl.style.display = 'inline';
|
||||
}
|
||||
|
||||
function hideSearchedPeriod() {
|
||||
const searchedPeriodEl = document.getElementById('searchedPeriod');
|
||||
searchedPeriodEl.style.display = 'none';
|
||||
}
|
||||
|
||||
function formatNumber(num) {
|
||||
return new Intl.NumberFormat('ko-KR').format(num);
|
||||
}
|
||||
|
||||
function loadData() {
|
||||
const year = document.getElementById('forYear').value;
|
||||
const month = document.getElementById('forMonth').value;
|
||||
|
||||
if (!year || !month) {
|
||||
customPopups.showAlert('조회 기간을 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 서버에서 데이터 조회 API 호출
|
||||
fetch('/commission', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-XSRF-TOKEN': /*[[${_csrf.token}]]*/ ''
|
||||
},
|
||||
body: JSON.stringify({
|
||||
year: year,
|
||||
month: month
|
||||
})
|
||||
})
|
||||
.then(response => {
|
||||
return response.json().then(data => {
|
||||
if (!response.ok) {
|
||||
// 서버에서 반환한 에러 메시지 추출
|
||||
const errorMessage = data.description || data.message || '데이터 조회에 실패했습니다.';
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
return data;
|
||||
});
|
||||
})
|
||||
.then(data => renderCommissionData(data, year, month))
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
hideSearchedPeriod();
|
||||
customPopups.showAlert(error.message || '데이터 조회 중 오류가 발생했습니다.');
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
function renderCommissionData(data, year, month) {
|
||||
// API 수수료 항목 필터링
|
||||
const apiCommissions = data.list.filter(item => item.paymentTargetType === '01');
|
||||
const saleCommissions = data.list.filter(item => item.paymentTargetType !== '01');
|
||||
|
||||
// API 수수료 테이블 렌더링
|
||||
const apiTableBody = document.getElementById('apiTableBody');
|
||||
apiTableBody.innerHTML = '';
|
||||
|
||||
let apiTotal1 = 0;
|
||||
let apiTotal2 = 0;
|
||||
let apiCount1 = 0;
|
||||
let apiCount2 = 0;
|
||||
|
||||
apiCommissions.forEach((item, index) => {
|
||||
if (index === 0) {
|
||||
apiTableBody.innerHTML += `
|
||||
<tr>
|
||||
<td rowspan="${apiCommissions.length}" class="text-center">API<br/>수수료</td>
|
||||
<td class="text-left">${item.paymentTargetName}</td>
|
||||
<td class="text-right">${formatNumber(item.totalCount || 0)}</td>
|
||||
<td class="text-right">${formatNumber(item.value || 0)}</td>
|
||||
<td class="text-right">${formatNumber(item.minimumAmount || 0)}</td>
|
||||
<td class="text-right"><span class="color_darkRed">${formatNumber(item.sum || 0)}</span></td>
|
||||
<td class="text-right"><strong class="color_blue">${formatNumber(item.totalCount2 || 0)}</strong></td>
|
||||
<td class="text-right">${formatNumber(item.value2 || 0)}</td>
|
||||
<td class="text-right">${formatNumber(item.minimumAmount2 || 0)}</td>
|
||||
<td class="text-right"><span class="color_darkRed">${formatNumber(item.sum2 || 0)}</span></td>
|
||||
<td>${item.changeReason2 || ''}</td>
|
||||
</tr>
|
||||
`;
|
||||
} else {
|
||||
apiTableBody.innerHTML += `
|
||||
<tr>
|
||||
<td class="text-left">${item.paymentTargetName}</td>
|
||||
<td class="text-right">${formatNumber(item.totalCount || 0)}</td>
|
||||
<td class="text-right">${formatNumber(item.value || 0)}</td>
|
||||
<td class="text-right">${formatNumber(item.minimumAmount || 0)}</td>
|
||||
<td class="text-right"><span class="color_darkRed">${formatNumber(item.sum || 0)}</span></td>
|
||||
<td class="text-right"><strong class="color_blue">${formatNumber(item.totalCount2 || 0)}</strong></td>
|
||||
<td class="text-right">${formatNumber(item.value2 || 0)}</td>
|
||||
<td class="text-right">${formatNumber(item.minimumAmount2 || 0)}</td>
|
||||
<td class="text-right"><span class="color_darkRed">${formatNumber(item.sum2 || 0)}</span></td>
|
||||
<td>${item.changeReason2 || ''}</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
apiTotal1 += parseFloat(item.sum || 0);
|
||||
apiTotal2 += parseFloat(item.sum2 || 0);
|
||||
apiCount1 += parseInt(item.totalCount || 0);
|
||||
apiCount2 += parseInt(item.totalCount2 || 0);
|
||||
});
|
||||
|
||||
// API 소계
|
||||
apiTableBody.innerHTML += `
|
||||
<tr>
|
||||
<td colspan="2" class="text-center">${year}년 ${month}월 API 수수료 소계</td>
|
||||
<td class="text-right">${formatNumber(apiCount1)}</td>
|
||||
<td class="text-right"></td>
|
||||
<td class="text-right"></td>
|
||||
<td class="text-right"><strong class="color_blue">${formatNumber(apiTotal1)}</strong></td>
|
||||
<td class="text-right">${formatNumber(apiCount2)}</td>
|
||||
<td class="text-right"></td>
|
||||
<td class="text-right"></td>
|
||||
<td class="text-right"><strong class="color_blue">${formatNumber(apiTotal2)}</strong></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
`;
|
||||
|
||||
// 판매 수수료 테이블 렌더링
|
||||
const saleTableBody = document.getElementById('saleTableBody');
|
||||
saleTableBody.innerHTML = '';
|
||||
|
||||
let saleTotal1 = 0;
|
||||
let saleTotal2 = 0;
|
||||
let saleCount1 = 0;
|
||||
let saleCount2 = 0;
|
||||
let saleSalesAmount = 0;
|
||||
|
||||
saleCommissions.forEach((item, index) => {
|
||||
const isPercentageFee = item.paymentBaseName === '금액';
|
||||
if (index === 0) {
|
||||
saleTableBody.innerHTML += `
|
||||
<tr>
|
||||
<td rowspan="${saleCommissions.length}" class="text-center">판매<br/>수수료</td>
|
||||
<td class="text-left">${item.paymentTargetName}</td>
|
||||
<td>${item.detailFeeTypeName || ''}</td>
|
||||
<td>${item.paymentBaseName || ''}</td>
|
||||
<td>${item.valueDivisionName || ''}</td>
|
||||
<td class="text-right">${item.totalValue ? formatNumber(item.totalValue) : ''}</td>
|
||||
<td class="text-right">${isPercentageFee ? (parseFloat(item.value || 0) * 100) + '%' : formatNumber(item.value || 0)}</td>
|
||||
<td class="text-right">${formatNumber(item.totalCount || 0)}</td>
|
||||
<td class="text-right">${formatNumber(item.minimumAmount || 0)}</td>
|
||||
<td class="text-right"><span class="color_darkRed">${formatNumber(item.sum || 0)}</span></td>
|
||||
<td class="text-right">${isPercentageFee ? (parseFloat(item.value2 || 0) * 100) + '%' : formatNumber(item.value2 || 0)}</td>
|
||||
<td class="text-right">${formatNumber(item.totalCount2 || 0)}</td>
|
||||
<td class="text-right">${formatNumber(item.minimumAmount2 || 0)}</td>
|
||||
<td class="text-right"><span class="color_darkRed">${formatNumber(item.sum2 || 0)}</span></td>
|
||||
<td>${item.changeReason2 || ''}</td>
|
||||
</tr>
|
||||
`;
|
||||
} else {
|
||||
saleTableBody.innerHTML += `
|
||||
<tr>
|
||||
<td class="text-left">${item.paymentTargetName}</td>
|
||||
<td>${item.detailFeeTypeName || ''}</td>
|
||||
<td>${item.paymentBaseName || ''}</td>
|
||||
<td>${item.valueDivisionName || ''}</td>
|
||||
<td class="text-right">${item.totalValue ? formatNumber(item.totalValue) : ''}</td>
|
||||
<td class="text-right">${isPercentageFee ? (parseFloat(item.value || 0) * 100) + '%' : formatNumber(item.value || 0)}</td>
|
||||
<td class="text-right">${formatNumber(item.totalCount || 0)}</td>
|
||||
<td class="text-right">${formatNumber(item.minimumAmount || 0)}</td>
|
||||
<td class="text-right"><span class="color_darkRed">${formatNumber(item.sum || 0)}</span></td>
|
||||
<td class="text-right">${isPercentageFee ? (parseFloat(item.value2 || 0) * 100) + '%' : formatNumber(item.value2 || 0)}</td>
|
||||
<td class="text-right">${formatNumber(item.totalCount2 || 0)}</td>
|
||||
<td class="text-right">${formatNumber(item.minimumAmount2 || 0)}</td>
|
||||
<td class="text-right"><span class="color_darkRed">${formatNumber(item.sum2 || 0)}</span></td>
|
||||
<td>${item.changeReason2 || ''}</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
saleTotal1 += parseFloat(item.sum || 0);
|
||||
saleTotal2 += parseFloat(item.sum2 || 0);
|
||||
saleCount1 += parseInt(item.totalCount || 0);
|
||||
saleCount2 += parseInt(item.totalCount2 || 0);
|
||||
saleSalesAmount += parseFloat(item.totalValue || 0);
|
||||
});
|
||||
|
||||
// 판매 소계
|
||||
saleTableBody.innerHTML += `
|
||||
<tr>
|
||||
<td colspan="5" class="text-center">${year}년 ${month}월 판매 수수료 소계</td>
|
||||
<td class="text-right">${formatNumber(saleSalesAmount)}</td>
|
||||
<td class="text-right"></td>
|
||||
<td class="text-right">${formatNumber(saleCount1)}</td>
|
||||
<td></td>
|
||||
<td class="text-right"><strong class="color_blue">${formatNumber(saleTotal1)}</strong></td>
|
||||
<td class="text-right"></td>
|
||||
<td class="text-right">${formatNumber(saleCount2)}</td>
|
||||
<td></td>
|
||||
<td class="text-right"><strong class="color_blue">${formatNumber(saleTotal2)}</strong></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
`;
|
||||
|
||||
// 합계 업데이트
|
||||
document.getElementById('totalTitle').textContent = `${year}년 ${month}월 합계`;
|
||||
document.getElementById('totalSalesAmount1').textContent = formatNumber(saleSalesAmount);
|
||||
document.getElementById('totalCount1').textContent = formatNumber(apiCount1 + saleCount1);
|
||||
document.getElementById('totalAmount1').textContent = formatNumber(apiTotal1 + saleTotal1);
|
||||
document.getElementById('totalSalesAmount2').textContent = formatNumber(saleSalesAmount);
|
||||
document.getElementById('totalCount2').textContent = formatNumber(apiCount2 + saleCount2);
|
||||
document.getElementById('totalAmount2').textContent = formatNumber(apiTotal2 + saleTotal2);
|
||||
|
||||
// 출력 버튼 표시
|
||||
document.getElementById('printBtn').style.display = 'inline-block';
|
||||
|
||||
// 조회 기간 표시
|
||||
showSearchedPeriod(year, month);
|
||||
}
|
||||
|
||||
function printCommission() {
|
||||
const year = document.getElementById('forYear').value;
|
||||
const month = document.getElementById('forMonth').value;
|
||||
|
||||
if (!year || !month) {
|
||||
customPopups.showAlert('조회 기간을 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 먼저 청구서 출력용 API로 데이터 조회하여 에러 여부 확인
|
||||
fetch('/commission/print/check', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-XSRF-TOKEN': /*[[${_csrf.token}]]*/ ''
|
||||
},
|
||||
body: JSON.stringify({
|
||||
year: year,
|
||||
month: month
|
||||
})
|
||||
})
|
||||
.then(response => {
|
||||
return response.json().then(data => {
|
||||
if (!response.ok) {
|
||||
const errorMessage = data.description || data.message || '데이터 조회에 실패했습니다.';
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
return data;
|
||||
});
|
||||
})
|
||||
.then(data => {
|
||||
// 에러가 없으면 청구서 출력 페이지 열기
|
||||
const printUrl = '/commission/print/' + year + '/' + month;
|
||||
window.open(printUrl, '_blank', 'width=800,height=900,scrollbars=yes,resizable=yes');
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
customPopups.showAlert(error.message || '청구서 조회 중 오류가 발생했습니다.');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</th:block>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,197 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>수수료 청구서</title>
|
||||
<!-- <link rel="stylesheet" th:href="@{/content/css/vendor.css}">-->
|
||||
<!-- <link rel="stylesheet" th:href="@{/css/main.css}">-->
|
||||
<style>
|
||||
html,body{font-size:12px;}
|
||||
body{width:600px; margin:90px}
|
||||
table{border-collapse: collapse;}
|
||||
table th, td{padding:5px 10px;}
|
||||
table tr{font-size:12px;}
|
||||
.info-table{ border-top: 10px solid gray; width: 100%; border-bottom: 2px solid #333;}
|
||||
.info-table th{text-align:left;}
|
||||
.commission-table{width:100%;}
|
||||
.commission-table th,.commission-table td{border:1px solid #999 !important}
|
||||
.commission-table td{text-align:right}
|
||||
.bg_ygreen{background-color:aquamarine; text-align:center}
|
||||
.bg_skyBlue{background-color:skyblue; text-align:center}
|
||||
.bg_yellow{background-color:yellow; text-align:center}
|
||||
|
||||
DIV { position: relative; }
|
||||
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #5a67d8;
|
||||
}
|
||||
|
||||
@media print {
|
||||
@page {
|
||||
size: auto;
|
||||
margin: 0 -0cm;
|
||||
}
|
||||
html {
|
||||
margin: 0 0cm;
|
||||
}
|
||||
.control {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="commission-print-page">
|
||||
<div class="control">
|
||||
<input id="printButton" type="button" class="btn btn-primary" value="청구서 인쇄" onclick="window.print()"/>
|
||||
</div>
|
||||
<div id="PrintDIV">
|
||||
<h1 style="text-align:center">
|
||||
<img style="height:30px;" th:src="@{/img/logo/logo-djb.png}" alt="DJBank 오픈뱅킹" />
|
||||
</h1>
|
||||
<p style="text-align:center">우61470 광주광역시 동구 제봉로 225 전화 (062)239-6722 FAX (062)239-6719 오픈뱅크 플랫폼 담당자</p>
|
||||
|
||||
<table class="info-table">
|
||||
<colgroup>
|
||||
<col style="width:15%;" />
|
||||
<col/>
|
||||
</colgroup>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>문서번호</th>
|
||||
<td><span th:text="|전자금융(OBP) ${year}-${month}-001호|">전자금융(OBP) 2024-03-001호</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>발 급 일</th>
|
||||
<td><span th:text="${toDay}">2024년 3월 15일</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>수 신</th>
|
||||
<td><span th:text="${organization}">ABC 핀테크 주식회사</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>참 조</th>
|
||||
<td></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>제 목</th>
|
||||
<td><span th:text="|${year}년 ${month}월 오픈뱅크플랫폼 API 서비스 이용수수료 청구|">2024년 3월 오픈뱅크플랫폼 API 서비스 이용수수료 청구</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div>
|
||||
<p>1. 귀 사의 무궁한 발전을 기원합니다.</p>
|
||||
<p>2. 귀 사와 체결된 서비스 이용 계약에 따라 동 업무처리와 관련하여 발생된 수수료를 다음과 같이 청구하오니 <br/>협조하여 주시기 바랍니다.</p>
|
||||
</div>
|
||||
|
||||
<h2 style="float:left">1. 청구내역</h2>
|
||||
<p style="float: right;margin-top: 25px;">(단위:건수/원)</p>
|
||||
|
||||
<table class="commission-table">
|
||||
<colgroup>
|
||||
<col style="width:35%"/>
|
||||
<col style="width:20%"/>
|
||||
<col style="width:15%"/>
|
||||
<col style="width:15%"/>
|
||||
<col style="width:15%"/>
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="bg_ygreen">청구내용</th>
|
||||
<th class="bg_ygreen">대출금액</th>
|
||||
<th class="bg_ygreen">처리건수</th>
|
||||
<th class="bg_ygreen">수수료</th>
|
||||
<th class="bg_ygreen">수수료합계</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<th:block th:if="${commission != null and commission.list != null}">
|
||||
<!-- API 수수료 -->
|
||||
<tr th:each="item : ${commission.list}" th:if="${item.paymentTargetType == '01'}">
|
||||
<td style="text-align:left" th:text="${item.paymentTargetName}">계좌잔액조회</td>
|
||||
<td></td>
|
||||
<td th:text="${#numbers.formatInteger(item.totalValue2, 0, 'COMMA')}">20,543</td>
|
||||
<td th:text="|₩${#numbers.formatInteger(item.value2, 0, 'COMMA')}|">₩50</td>
|
||||
<td th:text="|₩${#numbers.formatInteger(item.sum2, 0, 'COMMA')}|">₩1,027,150</td>
|
||||
</tr>
|
||||
|
||||
<!-- API 소계 -->
|
||||
<th:block th:if="${commission.apiSum2 != null}">
|
||||
<tr>
|
||||
<th class="bg_skyBlue" colspan="4">소계</th>
|
||||
<td class="bg_skyBlue" th:text="|₩${#numbers.formatInteger(commission.apiSum2, 0, 'COMMA')}|">₩3,306,780</td>
|
||||
</tr>
|
||||
</th:block>
|
||||
|
||||
<!-- 판매 수수료 -->
|
||||
<tr th:each="item : ${commission.list}" th:if="${item.paymentTargetType != '01'}">
|
||||
<td style="text-align:left" th:text="${item.paymentTargetName}">개인대출 상품 A</td>
|
||||
<td th:text="${item.paymentBaseName == '금액' ? '₩' + #numbers.formatInteger(item.totalValue2, 0, 'COMMA') : ''}">₩1,050,000,000</td>
|
||||
<td th:text="${item.paymentBaseName == '건' ? #numbers.formatInteger(item.totalCount2, 0, 'COMMA') : ''}">55</td>
|
||||
<td th:text="|₩${#numbers.formatInteger(item.value2, 0, 'COMMA')}|">₩5,000</td>
|
||||
<td th:text="|₩${#numbers.formatInteger(item.sum2, 0, 'COMMA')}|">₩275,000</td>
|
||||
</tr>
|
||||
|
||||
<!-- 판매 소계 -->
|
||||
<th:block th:if="${commission.productSum2 != null}">
|
||||
<tr>
|
||||
<th class="bg_skyBlue" colspan="4">소계</th>
|
||||
<td class="bg_skyBlue" th:text="|₩${#numbers.formatInteger(commission.productSum2, 0, 'COMMA')}|">₩533,000</td>
|
||||
</tr>
|
||||
</th:block>
|
||||
|
||||
<!-- 합계 -->
|
||||
<th:block th:if="${commission.sum2 != null}">
|
||||
<tr>
|
||||
<th class="bg_yellow" colspan="4">합계</th>
|
||||
<td class="bg_yellow" th:text="|₩${#numbers.formatInteger(commission.sum2, 0, 'COMMA')}|">₩3,839,780</td>
|
||||
</tr>
|
||||
</th:block>
|
||||
</th:block>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p>* 수수료는 Max(처리건수*금액, 월최저수수료) 금액으로 출금됩니다</p>
|
||||
<br>
|
||||
<h2>2. 수수료정산</h2>
|
||||
<p>① 정산방법 : 수수료 결제계좌에서 자동출금</p>
|
||||
<p>② 처리일자 : 매월10일 (휴,공휴일인 경우 익영업일). 끝.</p>
|
||||
|
||||
<div style="text-align:center;padding:20px 0">
|
||||
<h1>
|
||||
<span style="letter-spacing:0.8;">주식회사 광주은행</span><br/>
|
||||
<span style="letter-spacing:6.5;">디지털</span> <span style="letter-spacing:6.5;">사업부장</span>
|
||||
<small style="position: absolute; bottom: 39px; margin-left: 12px; font-weight: normal; font-size: 13px;">(직인생략)</small>
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script th:if="${error}" th:inline="javascript">
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var errorMessage = /*[[${error}]]*/ '';
|
||||
if (errorMessage) {
|
||||
alert(errorMessage);
|
||||
// 에러 발생 시 인쇄 버튼 비활성화
|
||||
var printButton = document.getElementById('printButton');
|
||||
if (printButton) {
|
||||
printButton.disabled = true;
|
||||
printButton.value = '조회 오류';
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -20,6 +20,9 @@
|
||||
placeholder="내용"
|
||||
required></textarea>
|
||||
<div class="djb-comment-form-actions">
|
||||
<label class="djb-comment-private-toggle">
|
||||
<input type="checkbox" id="djbCommentPrivate"> 비공개
|
||||
</label>
|
||||
<span class="djb-comment-char-counter" id="djbCommentCharCounter">0 / 2000</span>
|
||||
<button type="submit" class="djb-comment-submit">댓글달기</button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
|
||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
|
||||
|
||||
<body>
|
||||
<section layout:fragment="title">
|
||||
<div class="page-title-banner">
|
||||
<img th:src="@{/img/img_title_bg.png}" class="title-image" alt="타이틀 배경">
|
||||
<h1>FAQ</h1>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section layout:fragment="contentFragment">
|
||||
<div class="notice-detail-container">
|
||||
|
||||
<!-- FAQ Header: Question and Date -->
|
||||
<div class="notice-detail-header">
|
||||
<h2 class="notice-detail-title">
|
||||
<span th:text="${faq.faqQuestion}">FAQ 질문</span>
|
||||
</h2>
|
||||
<span class="notice-detail-date" th:text="${#temporals.format(faq.createdDate, 'yyyy.MM.dd')}">2025.11.01</span>
|
||||
</div>
|
||||
|
||||
<!-- Attachment Section -->
|
||||
<div class="notice-detail-attachment" th:if="${!#strings.isEmpty(faq.fileId)}">
|
||||
<div class="attachment-list" th:with="fileInfo=${@fileService.findById(faq.fileId)}">
|
||||
<div class="attachment-item" th:each="fileDetail, status : ${fileInfo.getFileDetails()}">
|
||||
<a th:href="'javascript:fn_downloadFile(\'' + ${fileDetail.fileId} + '\',\''+ ${fileDetail.fileSn} +'\')'" class="notice-attachment-link">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18 15.75C18 17.4833 17.3917 18.9583 16.175 20.175C14.9583 21.3917 13.4833 22 11.75 22C10.0167 22 8.54167 21.3917 7.325 20.175C6.10833 18.9583 5.5 17.4833 5.5 15.75V6.5C5.5 5.25 5.9375 4.1875 6.8125 3.3125C7.6875 2.4375 8.75 2 10 2C11.25 2 12.3125 2.4375 13.1875 3.3125C14.0625 4.1875 14.5 5.25 14.5 6.5V15.25C14.5 16.0167 14.2333 16.6667 13.7 17.2C13.1667 17.7333 12.5167 18 11.75 18C10.9833 18 10.3333 17.7333 9.8 17.2C9.26667 16.6667 9 16.0167 9 15.25V6H11V15.25C11 15.4667 11.0708 15.6458 11.2125 15.7875C11.3542 15.9292 11.5333 16 11.75 16C11.9667 16 12.1458 15.9292 12.2875 15.7875C12.4292 15.6458 12.5 15.4667 12.5 15.25V6.5C12.4833 5.8 12.2375 5.20833 11.7625 4.725C11.2875 4.24167 10.7 4 10 4C9.3 4 8.70833 4.24167 8.225 4.725C7.74167 5.20833 7.5 5.8 7.5 6.5V15.75C7.48333 16.9333 7.89167 17.9375 8.725 18.7625C9.55833 19.5875 10.5667 20 11.75 20C12.9167 20 13.9083 19.5875 14.725 18.7625C15.5417 17.9375 15.9667 16.9333 16 15.75V6H18V15.75Z" fill="currentColor"/>
|
||||
</svg>
|
||||
<span class="attachment-label">첨부파일</span>
|
||||
<span class="attachment-filename">[[${fileDetail.originalFileName}]].[[${fileDetail.fileExtension}]]</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content Section -->
|
||||
<div class="notice-detail-content">
|
||||
<div id="faqDetail" class="notice-content-body editor-content" th:utext="${faq.faqAnswer}">
|
||||
답변 내용이 여기에 표시됩니다.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="notice-detail-actions">
|
||||
<button type="button" class="btn-notice-list" th:onclick="|location.href='@{/faq_list}'|">목록</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</body>
|
||||
|
||||
<th:block layout:fragment="contentScript">
|
||||
<script>
|
||||
function fn_downloadFile(fileId, fileSn) {
|
||||
window.open('[[@{/file/download}]]' + "?fileId=" + fileId + "&fileSn=" + fileSn);
|
||||
}
|
||||
|
||||
function decodeHTMLEntities(text) {
|
||||
var textArea = document.createElement('textarea');
|
||||
textArea.innerHTML = text;
|
||||
return textArea.value;
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var element = document.getElementById('faqDetail');
|
||||
if (element) {
|
||||
element.innerHTML = decodeHTMLEntities(element.innerHTML);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</th:block>
|
||||
|
||||
</html>
|
||||
@@ -38,23 +38,31 @@
|
||||
|
||||
<!-- Content -->
|
||||
<section class="service-content">
|
||||
<div class="inquiry-detail-container">
|
||||
<div class="inquiry-detail-container" th:attr="data-inquiry-id=${inquiry.id}">
|
||||
|
||||
<!-- Detail Header -->
|
||||
<div class="notice-detail-header">
|
||||
<div class="inquiry-header-meta">
|
||||
<span th:text="${#temporals.format(inquiry.createdDate, 'yyyy.MM.dd')}">2026.06.24</span>
|
||||
<span>|</span>
|
||||
<span
|
||||
th:text="${inquiry.inquirerName != null ? inquiry.inquirerName : (inquiry.inquirer != null ? inquiry.inquirer.userName : '')}">EACTIVE</span>
|
||||
<span th:text="${inquiry.inquirerDisplay}">EACTIVE</span>
|
||||
<span>|</span>
|
||||
<span th:text="|조회 ${inquiry.viewCount}|">조회 0</span>
|
||||
<span>|</span>
|
||||
<span class="inquiry-detail-visibility"
|
||||
th:classappend="${inquiry.visibility == 'PRIVATE'} ? ' inquiry-detail-visibility--private'">
|
||||
<svg th:if="${inquiry.visibility == 'PRIVATE'}" class="inquiry-lock-icon" width="13" height="13"
|
||||
viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-label="비공개">
|
||||
<rect x="4" y="10" width="16" height="11" rx="2" fill="currentColor" />
|
||||
<path d="M7 10V7a5 5 0 0 1 10 0v3" stroke="currentColor" stroke-width="2" fill="none" />
|
||||
</svg>
|
||||
<span th:text="${inquiry.visibilityLabel}">법인공개</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="notice-detail-title-wrapper">
|
||||
<span class="notice-type-badge notice-type-badge--completed"
|
||||
th:if="${inquiry.inquiryStatus == 'RESPONDED'}">답변완료</span>
|
||||
<span class="notice-type-badge notice-type-badge--closed"
|
||||
th:if="${inquiry.inquiryStatus == 'CLOSED'}">종료</span>
|
||||
<span class="notice-type-badge notice-type-badge--pending"
|
||||
th:if="${inquiry.inquiryStatus != 'RESPONDED' and inquiry.inquiryStatus != 'CLOSED'}">답변대기</span>
|
||||
<span class="inquiry-status-badge"
|
||||
th:classappend="${T(com.eactive.apim.portal.djb.community.qna.constant.DjbInquiryStatus).badgeClass(inquiry.inquiryStatus)}"
|
||||
th:text="${T(com.eactive.apim.portal.djb.community.qna.constant.DjbInquiryStatus).displayName(inquiry.inquiryStatus)}">답변대기</span>
|
||||
<h2 class="notice-detail-title" th:text="${inquiry.inquirySubject}">질문 제목</h2>
|
||||
</div>
|
||||
</div>
|
||||
@@ -64,6 +72,13 @@
|
||||
<div id="inquiryDetail" class="inquiry-content-body" th:utext="${inquiry.inquiryDetail}">
|
||||
문의 내용이 여기에 표시됩니다.
|
||||
</div>
|
||||
<!-- Attachment: 강제 다운로드 링크만 제공 (이미지 인라인 노출 안 함) -->
|
||||
<div class="inquiry-detail-attach" th:if="${inquiry.attachFile != null and !inquiry.attachFile.isEmpty()}">
|
||||
<a th:href="@{/file/download(fileId=${inquiry.attachFile}, fileSn=1)}" class="inquiry-attach-link"
|
||||
download>
|
||||
첨부 이미지 다운로드
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Admin Response Section -->
|
||||
@@ -112,7 +127,7 @@
|
||||
<th:block layout:fragment="contentScript">
|
||||
<script th:inline="javascript">
|
||||
$(document).ready(function () {
|
||||
var successMsg = [[${ success }]];
|
||||
var successMsg = [[${success}]];
|
||||
if (successMsg) {
|
||||
customPopups.showAlert(successMsg);
|
||||
}
|
||||
@@ -147,6 +162,7 @@
|
||||
});
|
||||
</script>
|
||||
<script th:src="@{/js/djb/inquiry-comments.js}"></script>
|
||||
<script th:src="@{/js/djb/inquiry-view.js}"></script>
|
||||
</th:block>
|
||||
|
||||
</html>
|
||||
@@ -47,7 +47,7 @@
|
||||
|
||||
<!-- Form Content -->
|
||||
<form name="inquiryForm" id="inquiryForm" th:action="${isNew}? @{/inquiry} : @{/inquiry/edit}"
|
||||
th:object="${inquiry}" method="post" class="djb-board-form">
|
||||
th:object="${inquiry}" method="post" enctype="multipart/form-data" class="djb-board-form">
|
||||
<input type="hidden" th:field="*{id}" th:if="${!isNew}">
|
||||
|
||||
<!-- Subject Field -->
|
||||
@@ -76,6 +76,27 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Visibility Field -->
|
||||
<div class="form-group">
|
||||
<label class="djb-label" for="inquiryVisibility">공개범위</label>
|
||||
<select id="inquiryVisibility" th:field="*{visibility}" class="djb-input">
|
||||
<option th:if="${allowAll}" value="ALL">전체공개</option>
|
||||
<option th:if="${allowOrg}" value="ORG">법인공개</option>
|
||||
<option value="PRIVATE">비공개</option>
|
||||
</select>
|
||||
<small class="form-help-text">비공개 글은 본인과 소속 법인 관리자만 열람할 수 있습니다.</small>
|
||||
</div>
|
||||
|
||||
<!-- Image Attach Field -->
|
||||
<div class="form-group">
|
||||
<label class="djb-label" for="inquiryImage">이미지 첨부</label>
|
||||
<input type="file" id="inquiryImage" name="image" class="djb-input"
|
||||
accept="image/png,image/jpeg,image/gif">
|
||||
<small class="form-help-text">jpg, jpeg, png, gif 이미지 1개만 첨부할 수 있습니다.</small>
|
||||
<small th:if="${!isNew and inquiry.attachFile != null and !inquiry.attachFile.isEmpty()}"
|
||||
class="form-help-text">현재 첨부된 이미지가 있습니다. 새 파일을 선택하면 교체됩니다.</small>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
<!-- Form Actions -->
|
||||
@@ -144,6 +165,15 @@
|
||||
return false;
|
||||
}
|
||||
|
||||
const imageInput = document.getElementById('inquiryImage');
|
||||
if (imageInput && imageInput.files && imageInput.files.length > 0) {
|
||||
const ext = imageInput.files[0].name.toLowerCase().split('.').pop();
|
||||
if (['jpg', 'jpeg', 'png', 'gif'].indexOf(ext) === -1) {
|
||||
customPopups.showAlert('이미지 파일(jpg, jpeg, png, gif)만 첨부할 수 있습니다.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
form.submit();
|
||||
});
|
||||
|
||||
|
||||
@@ -43,6 +43,9 @@
|
||||
<div class="table-controls">
|
||||
<h2 class="total-count">
|
||||
총 <strong th:text="${page.totalElements}">0</strong>건
|
||||
<span class="inquiry-visibility-notice" th:if="${visibilityCeiling != null}">
|
||||
공개범위 설정: <span th:text="${visibilityCeiling.label()}">법인공개</span>
|
||||
</span>
|
||||
</h2>
|
||||
|
||||
<div class="search-field">
|
||||
@@ -66,34 +69,52 @@
|
||||
<div class="board-table-header">
|
||||
<div class="header-cell" style="width: 80px;">NO</div>
|
||||
<div class="header-cell" style="flex: 1; min-width: 200px;">제목</div>
|
||||
<div class="header-cell" style="width: 100px;">작성자</div>
|
||||
<div class="header-cell" style="width: 120px;">처리상태</div>
|
||||
<div class="header-cell" style="width: 80px;">조회수</div>
|
||||
<div class="header-cell" style="width: 120px;">등록일</div>
|
||||
</div>
|
||||
|
||||
<!-- Table Body -->
|
||||
<div class="board-table-body">
|
||||
<div class="board-table-row" th:each="inquiry, status : ${inquiries}"
|
||||
th:onclick="'location.href=\'' + @{/inquiry/detail(id=${inquiry.id})} + '\''"
|
||||
style="cursor: pointer;">
|
||||
th:classappend="${inquiry.privatePlaceholder} ? ' board-table-row--private'"
|
||||
th:onclick="${inquiry.privatePlaceholder} ? null : ('location.href=\'' + @{/inquiry/detail(id=${inquiry.id})} + '\'')"
|
||||
th:style="${inquiry.privatePlaceholder} ? 'cursor: default;' : 'cursor: pointer;'">
|
||||
<div class="row-cell row-cell--number" style="width: 80px;" data-label="NO"
|
||||
th:text="${page.totalElements - (page.number * page.size) - status.index}">1</div>
|
||||
<div class="row-cell row-cell--title" style="flex: 1; min-width: 200px;" data-label="제목">
|
||||
<a th:href="@{/inquiry/detail(id=${inquiry.id})}" class="notice-title-link">
|
||||
<a th:href="${inquiry.privatePlaceholder} ? null : @{/inquiry/detail(id=${inquiry.id})}"
|
||||
class="notice-title-link"
|
||||
th:classappend="${inquiry.privatePlaceholder} ? ' notice-title-link--disabled'">
|
||||
<span class="notice-number"
|
||||
th:text="|[${page.totalElements - (page.number * page.size) - status.index}]|">[1]</span>
|
||||
|
||||
<span class="notice-type-badge notice-type-badge--maintenance"
|
||||
th:if="${inquiry.inquiryStatus == 'PENDING'}">답변대기</span>
|
||||
<span class="notice-type-badge notice-type-badge--fix"
|
||||
th:if="${inquiry.inquiryStatus == 'RESPONDED'}">답변완료</span>
|
||||
<span class="notice-type-badge notice-type-badge--incident"
|
||||
th:if="${inquiry.inquiryStatus == 'CLOSED'}">종료</span>
|
||||
|
||||
<span th:text="${inquiry.inquirySubject}">질문 제목</span>
|
||||
<svg th:if="${inquiry.visibility == 'PRIVATE'}" class="inquiry-lock-icon" width="14" height="14"
|
||||
viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-label="비공개">
|
||||
<rect x="4" y="10" width="16" height="11" rx="2" fill="currentColor" />
|
||||
<path d="M7 10V7a5 5 0 0 1 10 0v3" stroke="currentColor" stroke-width="2" fill="none" />
|
||||
</svg>
|
||||
<span th:if="${inquiry.privatePlaceholder}" class="inquiry-private-label">비공개 게시물</span>
|
||||
<span th:unless="${inquiry.privatePlaceholder}" th:text="${inquiry.inquirySubject}">질문 제목</span>
|
||||
<span class="inquiry-comment-count"
|
||||
th:if="${commentCounts != null and commentCounts.get(inquiry.id) != null and commentCounts.get(inquiry.id) > 0}"
|
||||
th:text="|[${commentCounts.get(inquiry.id)}]|">[3]</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="row-cell row-cell--writer" style="width: 100px;" data-label="작성자">
|
||||
<span class="inquiry-writer-name" th:text="${inquiry.maskedInquirerName}">홍**</span>
|
||||
<span class="inquiry-writer-org"
|
||||
th:if="${inquiry.inquirerOrgName != null and !inquiry.inquirerOrgName.isEmpty()}"
|
||||
th:text="|(${inquiry.inquirerOrgName})|">(법인명)</span>
|
||||
</div>
|
||||
<div class="row-cell" style="width: 120px;" data-label="처리상태">
|
||||
<span class="inquiry-status-badge"
|
||||
th:classappend="${T(com.eactive.apim.portal.djb.community.qna.constant.DjbInquiryStatus).badgeClass(inquiry.inquiryStatus)}"
|
||||
th:text="${T(com.eactive.apim.portal.djb.community.qna.constant.DjbInquiryStatus).displayName(inquiry.inquiryStatus)}">
|
||||
답변대기
|
||||
</span>
|
||||
</div>
|
||||
<div class="row-cell" style="width: 80px;" data-label="조회수" th:text="${inquiry.viewCount}">0</div>
|
||||
<div class="row-cell" style="width: 120px;" data-label="등록일"
|
||||
th:text="${#temporals.format(inquiry.createdDate, 'yyyy.MM.dd')}">2025.01.01</div>
|
||||
</div>
|
||||
|
||||
@@ -45,6 +45,27 @@
|
||||
<p class="subtitle">DJ Bank은 온라인 비즈니스 혁신을 위한 피드백/개선요청을 환영합니다.</p>
|
||||
</div>
|
||||
|
||||
<!-- 내가 작성한 최근 글(최대 3건) 아코디언 -->
|
||||
<div class="recent-apps" th:if="${recentApplications != null and !recentApplications.isEmpty()}">
|
||||
<p class="recent-apps-title">내가 작성한 최근 글</p>
|
||||
<ul class="recent-apps-list">
|
||||
<li class="recent-apps-item" th:each="item : ${recentApplications}">
|
||||
<button type="button" class="recent-apps-head">
|
||||
<span class="recent-apps-subject" th:text="${item.bizSubject}">제목</span>
|
||||
<span class="recent-apps-date"
|
||||
th:text="${item.createdDate != null ? #temporals.format(item.createdDate, 'yyyy.MM.dd') : ''}">2026.07.13</span>
|
||||
<svg class="recent-apps-arrow" width="18" height="18" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="recent-apps-body">
|
||||
<p class="recent-apps-detail" th:text="${item.bizDetail}">내용</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Form Content -->
|
||||
<form name="partnershipForm" id="partnershipForm" th:action="@{/partnership}" th:object="${partnership}"
|
||||
enctype="multipart/form-data" method="post" class="djb-board-form">
|
||||
|
||||
@@ -157,6 +157,27 @@
|
||||
|
||||
$(function () {
|
||||
|
||||
// CSRF 토큰은 세션 기반 → 세션 만료(server.servlet.session.timeout=10m) 전에
|
||||
// 메인 페이지로 이동시켜 stale 토큰 제출(403) 방지. 이동은 서버 요청이라 세션 idle도 리셋됨.
|
||||
// 단, 입력 중에는 이탈 금지 → idle 타이머(활동 시 리셋, 입력값/포커스 있으면 보류).
|
||||
// 만료 시간은 하드코딩하지 않고 서버 세션 타임아웃(초)을 렌더 시점에 읽어 파생 → yml 변경 시 자동 반영.
|
||||
var SESSION_TIMEOUT_SEC = /*[[${#request.session.maxInactiveInterval}]]*/ 600;
|
||||
var LOGIN_IDLE_LIMIT_MS = Math.max(60, SESSION_TIMEOUT_SEC - 120) * 1000; // 세션 만료 2분 전
|
||||
var loginIdleTimer;
|
||||
function scheduleIdleRedirect() {
|
||||
clearTimeout(loginIdleTimer);
|
||||
loginIdleTimer = setTimeout(function () {
|
||||
var idEl = document.getElementById('id');
|
||||
var pwEl = document.getElementById('password');
|
||||
var busy = document.activeElement === idEl || document.activeElement === pwEl
|
||||
|| (idEl && idEl.value) || (pwEl && pwEl.value);
|
||||
if (busy) { scheduleIdleRedirect(); return; } // 입력 중/입력값 있음 → 이탈 보류
|
||||
window.location.href = /*[[@{/}]]*/ '/';
|
||||
}, LOGIN_IDLE_LIMIT_MS);
|
||||
}
|
||||
$('#id, #password, #checkId').on('input keydown focus click', scheduleIdleRedirect);
|
||||
scheduleIdleRedirect();
|
||||
|
||||
var successMsg = [[${success}]];
|
||||
console.log("Success message:", successMsg);
|
||||
if (successMsg) {
|
||||
|
||||
@@ -261,7 +261,7 @@
|
||||
<div class="api-cards-container">
|
||||
<!-- Services 데이터를 반복하여 API 카드 표시 (최대 4개) -->
|
||||
<div class="api-card" th:each="service, iterStat : ${services}" th:if="${iterStat.index < 4}"
|
||||
th:onclick="${service.id != null} ? |location.href='@{/apis(groupId=${service.id})}'| : |location.href='@{/apis}'|">
|
||||
th:onclick="${service.id != null} ? |location.href='@{/apis(groupIds=${service.id})}'| : |location.href='@{/apis}'|">
|
||||
<h3 class="card-title" th:text="${service.groupName}">API 서비스</h3>
|
||||
<p class="card-description">
|
||||
<span th:if="${service.groupDesc != null and !#strings.isEmpty(service.groupDesc)}"
|
||||
@@ -363,7 +363,7 @@
|
||||
|
||||
<div class="support-grid">
|
||||
<!-- Notice Card (Featured Wide Card) -->
|
||||
<a href="#" class="support-card support-card-notice">
|
||||
<a th:href="@{/portalnotice}" class="support-card support-card-notice">
|
||||
<div class="card-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="35" height="35" fill="currentColor" class="bi bi-megaphone"
|
||||
viewBox="0 0 16 16">
|
||||
@@ -385,7 +385,7 @@
|
||||
</a>
|
||||
|
||||
<!-- FAQ Card -->
|
||||
<a href="#" class="support-card support-card-faq">
|
||||
<a th:href="@{/faq_list}" class="support-card support-card-faq">
|
||||
<div class="card-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="35" height="35" fill="currentColor"
|
||||
class="bi bi-patch-question" viewBox="0 0 16 16">
|
||||
@@ -410,7 +410,7 @@
|
||||
</a>
|
||||
|
||||
<!-- Q&A Card -->
|
||||
<a href="#" class="support-card support-card-qna">
|
||||
<a th:href="@{/inquiry}" class="support-card support-card-qna">
|
||||
<div class="card-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="35" height="35" fill="currentColor"
|
||||
class="bi bi-chat-left-text" viewBox="0 0 16 16">
|
||||
@@ -594,9 +594,9 @@
|
||||
<th:block layout:fragment="contentScript">
|
||||
<!-- 메인 페이지 전용 스크립트 모듈 추가 -->
|
||||
<script th:src="@{/js/main.js}"></script>
|
||||
</th:block>
|
||||
|
||||
</html>
|
||||
<!-- 로그인 후 처리 스크립트 -->
|
||||
<script th:src="@{/js/login-success-handler.js}"></script>
|
||||
<script th:inline="javascript">
|
||||
$(document).ready(function () {
|
||||
LoginSuccessHandler.init({
|
||||
|
||||
@@ -144,17 +144,17 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.app-card-badge.badge-pending {
|
||||
.app-card-badge.status-pending {
|
||||
background: #fdf4d4;
|
||||
color: #ef9546;
|
||||
}
|
||||
|
||||
.app-card-badge.badge-approved {
|
||||
.app-card-badge.status-approved {
|
||||
background: #1b4ab7;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.app-card-badge.badge-inactive {
|
||||
.app-card-badge.status-inactive {
|
||||
background: #f3f4f6;
|
||||
color: #6b7280;
|
||||
}
|
||||
@@ -255,21 +255,21 @@
|
||||
<!-- App Name -->
|
||||
<h3 class="app-card-title" th:text="${request.clientName}">앱 이름</h3>
|
||||
<!-- Status Badge -->
|
||||
<span class="app-card-badge badge-pending"
|
||||
<span class="app-card-badge status-pending"
|
||||
th:text="${request.approval != null and request.approval.approvalStatus != null ? request.approval.approvalStatus.description : '승인대기'}">
|
||||
승인대기
|
||||
</span>
|
||||
</div>
|
||||
<!-- App Description -->
|
||||
<!-- App Description & Expected Completion Date -->
|
||||
<div class="app-card-footer-row">
|
||||
<p class="app-card-desc"
|
||||
th:text="${request.appDescription != null ? request.appDescription : '설명 없음'}">
|
||||
앱 설명이 여기에 표시됩니다.
|
||||
</p>
|
||||
<span class="app-card-expected-date"
|
||||
th:if="${request.approval != null and request.approval.approvalStatus != null and (request.approval.approvalStatus.toString() == 'REQUESTED' or request.approval.approvalStatus.description == '진행중')}"
|
||||
th:text="${request.createdDate != null ? '예상 완료일 : ' + #temporals.format(request.createdDate.plusDays(14), 'yyyy.MM.dd') + ' (' + #temporals.format(request.createdDate.plusDays(14), 'E') + ')' : '예상 완료일 : 산정 중'}">
|
||||
예상 완료일 : 2026.07.14 (화)
|
||||
th:if="${request.approval != null and request.approval.expectEndDate != null and #strings.length(request.approval.expectEndDate) >= 8}"
|
||||
th:text="|예상 완료일 : ${#strings.substring(request.approval.expectEndDate,4,6)}월 ${#strings.substring(request.approval.expectEndDate,6,8)}일 (${request.approval.expectEndDateDayOfWeek})|">
|
||||
예상 완료일 : 05월 30일 (토)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -300,7 +300,7 @@
|
||||
<h3 class="app-card-title" th:text="${apikey.clientname}">앱 이름</h3>
|
||||
<!-- Status Badge -->
|
||||
<span class="app-card-badge"
|
||||
th:classappend="${apikey.appstatus == '1' ? 'badge-approved' : apikey.appstatus == '0' ? 'badge-inactive' : 'badge-pending'}"
|
||||
th:classappend="${apikey.appstatus == '1' ? 'status-approved' : apikey.appstatus == '0' ? 'status-inactive' : 'status-pending'}"
|
||||
th:text="${apikey.appstatus == '1' ? '승인' : apikey.appstatus == '0' ? '비활성화' : '승인대기'}">
|
||||
승인
|
||||
</span>
|
||||
@@ -361,4 +361,4 @@
|
||||
</th:block>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -29,20 +29,10 @@
|
||||
<!-- Progress Steps Card -->
|
||||
<div class="s1-progress-card">
|
||||
<div class="s1-steps">
|
||||
<!-- Step 1 Active: 앱 정보 입력 (form/input icon) -->
|
||||
<!-- Step 1 Active: 앱 정보 입력 -->
|
||||
<div class="s1-step s1-step--active">
|
||||
<div class="s1-step-circle">
|
||||
<!-- 입력폼 아이콘 -->
|
||||
<svg class="s1-step-svg" width="26" height="26" viewBox="0 0 24 24" fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="3" y="5" width="18" height="14" rx="2" stroke="currentColor" stroke-width="1.8" />
|
||||
<line x1="7" y1="9" x2="17" y2="9" stroke="currentColor" stroke-width="1.8"
|
||||
stroke-linecap="round" />
|
||||
<line x1="7" y1="12" x2="14" y2="12" stroke="currentColor" stroke-width="1.8"
|
||||
stroke-linecap="round" />
|
||||
<line x1="7" y1="15" x2="11" y2="15" stroke="currentColor" stroke-width="1.8"
|
||||
stroke-linecap="round" />
|
||||
</svg>
|
||||
<img th:src="@{/img/apikey_step1.png}" alt="앱 정보 입력" width="36" height="36">
|
||||
</div>
|
||||
<span class="s1-step-num">1단계</span>
|
||||
<span class="s1-step-name">앱 정보 입력</span>
|
||||
@@ -50,17 +40,10 @@
|
||||
|
||||
<div class="s1-step-line"></div>
|
||||
|
||||
<!-- Step 2: API 선택 (chain link icon) -->
|
||||
<!-- Step 2: API 선택 -->
|
||||
<div class="s1-step">
|
||||
<div class="s1-step-circle">
|
||||
<!-- 체인링크/API 아이콘 -->
|
||||
<svg class="s1-step-svg" width="26" height="26" viewBox="0 0 24 24" fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" stroke="currentColor"
|
||||
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" stroke="currentColor"
|
||||
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
<img th:src="@{/img/apikey_step2.png}" alt="API 선택" width="36" height="36">
|
||||
</div>
|
||||
<span class="s1-step-num">2단계</span>
|
||||
<span class="s1-step-name">API 선택</span>
|
||||
@@ -68,23 +51,10 @@
|
||||
|
||||
<div class="s1-step-line"></div>
|
||||
|
||||
<!-- Step 3: 앱 생성 완료 (rocket icon) -->
|
||||
<!-- Step 3: 앱 생성 완료 -->
|
||||
<div class="s1-step">
|
||||
<div class="s1-step-circle">
|
||||
<!-- 로켓 아이콘 -->
|
||||
<svg class="s1-step-svg" width="26" height="26" viewBox="0 0 24 24" fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"
|
||||
stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path
|
||||
d="M12 15l-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"
|
||||
stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0" stroke="currentColor" stroke-width="1.6"
|
||||
stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5" stroke="currentColor" stroke-width="1.6"
|
||||
stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
<img th:src="@{/img/apikey_step3.png}" alt="앱 생성 완료" width="36" height="36">
|
||||
</div>
|
||||
<span class="s1-step-num">3단계</span>
|
||||
<span class="s1-step-name">앱 생성 완료</span>
|
||||
@@ -109,12 +79,6 @@
|
||||
<div class="s1-upload-box" id="iconDropZone">
|
||||
<img id="iconPreviewImage" class="s1-preview-img" style="display:none;" alt="미리보기">
|
||||
<div id="iconPlaceholder" class="s1-upload-inner">
|
||||
<!-- <svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="48" height="48" rx="8" fill="#E8F1FF" />
|
||||
<path
|
||||
d="M34 14H14C12.9 14 12 14.9 12 16V32C12 33.1 12.9 34 14 34H34C35.1 34 36 33.1 36 32V16C36 14.9 35.1 14 34 14ZM20 28L17 24.5L14 28H34L28 21L22 28H20ZM20 22C21.1 22 22 21.1 22 20C22 18.9 21.1 18 20 18C18.9 18 18 18.9 18 20C18 21.1 18.9 22 20 22Z"
|
||||
fill="#2A69DE" />
|
||||
</svg> -->
|
||||
<img th:src="@{/img/icon/img_icon.png}">
|
||||
<p class="s1-upload-title">앱 아이콘 이미지 파일을 업로드 하세요</p>
|
||||
<p class="s1-upload-hint">권장 사이즈는 1280 * 12 픽셀이며 JPG,PNG,GIF 파일만 등록할 수 있습니다.</p>
|
||||
@@ -147,12 +111,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Call Back URL -->
|
||||
<!-- Call Back URL : OAuth2 3-legged 전용. 현재 미사용으로 주석 처리 (추후 OAuth2 도입 시 복원)
|
||||
<div class="s1-field">
|
||||
<label class="s1-label">Call Back URL</label>
|
||||
<input type="text" id="callbackUrl" name="callbackUrl" th:field="*{callbackUrl}" class="s1-input"
|
||||
placeholder="URL을 입력해 주세요.">
|
||||
</div>
|
||||
-->
|
||||
|
||||
<!-- 화이트 리스트 -->
|
||||
<div class="s1-field">
|
||||
@@ -249,12 +214,12 @@
|
||||
if (ipAddresses.length > 0) {
|
||||
ipList.style.display = 'block';
|
||||
ipItems.innerHTML = ipAddresses.map(function (ip) {
|
||||
return '<div class="s1-ip-item">' +
|
||||
'<div class="s1-ip-display">' + ip + '</div>' +
|
||||
'<button type="button" class="s1-ip-btn s1-ip-btn--remove" onclick="removeIpAddress(\'' + ip + '\')">' +
|
||||
'<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M19 11H5v2h14z"/></svg>' +
|
||||
'제거</button>' +
|
||||
'</div>';
|
||||
return `<div class="s1-ip-item">`
|
||||
+ `<div class="s1-ip-display">${ip}</div>`
|
||||
+ `<button type="button" class="s1-ip-btn s1-ip-btn--remove" onclick="removeIpAddress('${ip}')">`
|
||||
+ `<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M19 11H5v2h14z"/></svg>`
|
||||
+ `제거</button>`
|
||||
+ `</div>`;
|
||||
}).join('');
|
||||
} else {
|
||||
ipList.style.display = 'none';
|
||||
@@ -372,7 +337,8 @@
|
||||
form.addEventListener('submit', function (e) {
|
||||
const name = document.getElementById('appName').value.trim();
|
||||
const desc = textarea.value.trim();
|
||||
const url = document.getElementById('callbackUrl').value.trim();
|
||||
// callbackUrl: OAuth2 3-legged 전용. 현재 미사용으로 주석 처리.
|
||||
// const url = document.getElementById('callbackUrl').value.trim();
|
||||
|
||||
if (!name) {
|
||||
e.preventDefault();
|
||||
@@ -388,18 +354,27 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (url) {
|
||||
try { new URL(url); } catch (_) {
|
||||
e.preventDefault();
|
||||
customPopups.showAlert('올바른 URL 형식이 아닙니다.\n예: https://example.com/callback');
|
||||
document.getElementById('callbackUrl').focus();
|
||||
return;
|
||||
}
|
||||
// 화이트리스트 필수
|
||||
if (ipAddresses.length === 0) {
|
||||
e.preventDefault();
|
||||
customPopups.showAlert('화이트리스트 IP를 1개 이상 등록해주세요.');
|
||||
document.getElementById('ipWhitelistInput').focus();
|
||||
return;
|
||||
}
|
||||
|
||||
// callbackUrl 검증: OAuth2 3-legged 전용. 현재 미사용으로 주석 처리.
|
||||
// if (url) {
|
||||
// try { new URL(url); } catch (_) {
|
||||
// e.preventDefault();
|
||||
// customPopups.showAlert('올바른 URL 형식이 아닙니다.\n예: https://example.com/callback');
|
||||
// document.getElementById('callbackUrl').focus();
|
||||
// return;
|
||||
// }
|
||||
// }
|
||||
|
||||
sessionStorage.setItem('registration_appName', name);
|
||||
sessionStorage.setItem('registration_appDescription', desc);
|
||||
sessionStorage.setItem('registration_callbackUrl', url);
|
||||
// sessionStorage.setItem('registration_callbackUrl', url);
|
||||
sessionStorage.setItem('registration_ipWhitelist', ipAddresses.join(','));
|
||||
});
|
||||
});
|
||||
@@ -407,4 +382,4 @@
|
||||
</th:block>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -45,13 +45,15 @@
|
||||
</div>
|
||||
<!-- App Info -->
|
||||
<div class="dt-app-info">
|
||||
<!-- Status Badge line (Only for Approved or In Progress states) -->
|
||||
<th:block th:if="${appRequest.approval != null and appRequest.approval.approvalStatus != null and (appRequest.approval.approvalStatus.toString() == 'APPROVED' or appRequest.approval.approvalStatus.toString() == 'REQUESTED' or appRequest.approval.approvalStatus.description == '진행중')}">
|
||||
<div class="dt-app-status-row">
|
||||
<span th:if="${appRequest.approval.approvalStatus.toString() == 'APPROVED'}" class="dt-status-badge status-approved">답변완료</span>
|
||||
<span th:if="${appRequest.approval.approvalStatus.toString() == 'REQUESTED' or appRequest.approval.approvalStatus.description == '진행중'}" class="dt-status-badge status-pending">진행중</span>
|
||||
</div>
|
||||
</th:block>
|
||||
<!-- Status Badge line (approval 존재 시 표시, 상태별 description/색상) -->
|
||||
<div class="dt-app-status-row" th:if="${appRequest.approval != null}">
|
||||
<span class="dt-status-badge"
|
||||
th:classappend="${appRequest.approval.approvalStatus.toString() == 'APPROVED' ? 'status-approved' :
|
||||
(appRequest.approval.approvalStatus.toString() == 'REJECTED' ? 'status-rejected' : 'status-pending')}"
|
||||
th:text="${appRequest.approval.approvalStatus.description}">
|
||||
대기중
|
||||
</span>
|
||||
</div>
|
||||
<h3 class="dt-app-name" th:text="${appRequest.clientName}">앱 이름</h3>
|
||||
<!-- App Description -->
|
||||
<p class="dt-app-desc"
|
||||
@@ -94,7 +96,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Callback URL -->
|
||||
<!-- Callback URL (기능본에서 비활성 처리됨 - 디자인 마크업으로 보존) -->
|
||||
<!--/*
|
||||
<div class="dt-row" th:if="${appRequest.clientId != null}">
|
||||
<div class="dt-col">
|
||||
<label class="dt-label">Callback URL</label>
|
||||
@@ -106,6 +109,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
*/-->
|
||||
|
||||
<!-- 화이트리스트 -->
|
||||
<div class="dt-row">
|
||||
@@ -154,21 +158,22 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 예상 완료일 (Only if REQUESTED or 진행중) -->
|
||||
<div class="dt-row" th:if="${appRequest.approval != null and appRequest.approval.approvalStatus != null and (appRequest.approval.approvalStatus.toString() == 'REQUESTED' or appRequest.approval.approvalStatus.description == '진행중')}">
|
||||
<!-- 예상 완료일 (관리자가 설정한 expectEndDate 가 있을 때만) -->
|
||||
<div class="dt-row"
|
||||
th:if="${appRequest.approval != null and appRequest.approval.expectEndDate != null and #strings.length(appRequest.approval.expectEndDate) >= 8}">
|
||||
<div class="dt-col">
|
||||
<label class="dt-label">예상 완료일</label>
|
||||
<div class="dt-input-group">
|
||||
<div class="dt-input-box"
|
||||
th:text="${appRequest.createdDate != null ? #temporals.format(appRequest.createdDate.plusDays(14), 'yyyy.MM.dd') + ' (' + #temporals.format(appRequest.createdDate.plusDays(14), 'E') + ')' : 'N/A'}">
|
||||
2026.07.14 (화)
|
||||
th:text="|${#strings.substring(appRequest.approval.expectEndDate,0,4)}.${#strings.substring(appRequest.approval.expectEndDate,4,6)}.${#strings.substring(appRequest.approval.expectEndDate,6,8)} (${appRequest.approval.expectEndDateDayOfWeek})|">
|
||||
2025.05.30 (토)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 승인일시 (Only if APPROVED) -->
|
||||
<div class="dt-row" th:if="${appRequest.approval != null and appRequest.approval.approvalStatus != null and appRequest.approval.approvalStatus.toString() == 'APPROVED' and appRequest.approval.approvalDate != null}">
|
||||
<!-- 승인일시 (approvalDate 가 있을 때만) -->
|
||||
<div class="dt-row" th:if="${appRequest.approval != null and appRequest.approval.approvalDate != null}">
|
||||
<div class="dt-col">
|
||||
<label class="dt-label">승인일시</label>
|
||||
<div class="dt-input-group">
|
||||
@@ -200,7 +205,7 @@
|
||||
<!-- Cancel Request Button (danger red) -->
|
||||
<button type="button" sec:authorize="hasRole('ROLE_API_KEY_REQUEST')" class="dt-btn-red"
|
||||
th:if="${appRequest.approval != null and appRequest.approval.approvalStatus != null and
|
||||
(appRequest.approval.approvalStatus.toString() == 'PENDING' or appRequest.approval.approvalStatus.toString() == 'REQUESTED' or appRequest.approval.approvalStatus.description == '진행중')}"
|
||||
(appRequest.approval.approvalStatus.toString() == 'PENDING' or appRequest.approval.approvalStatus.toString() == 'REQUESTED')}"
|
||||
th:data-request-id="${appRequest.id}" onclick="cancelRequestById(this)">
|
||||
신청 취소
|
||||
</button>
|
||||
@@ -291,4 +296,4 @@
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
</div>
|
||||
<div class="dt-app-info">
|
||||
<div class="dt-app-status-row">
|
||||
<span th:classappend="${apiKey.appstatus == '1' ? 'status-approved' : 'status-inactive'}" class="dt-status-badge" th:text="${apiKey.appstatus == '1' ? '답변완료' : '비활성화'}">답변완료</span>
|
||||
<span th:classappend="${apiKey.appstatus == '1' ? 'status-approved' : 'status-inactive'}" class="dt-status-badge" th:text="${apiKey.appstatus == '1' ? '승인' : '비활성화'}">승인</span>
|
||||
</div>
|
||||
<h3 class="dt-app-name" th:text="${apiKey.clientname}">앱 이름</h3>
|
||||
<p class="dt-app-desc" th:text="${apiKey.appDescription != null ? apiKey.appDescription : '앱 설명이 없습니다.'}">앱 설명이 표시됩니다.</p>
|
||||
@@ -50,6 +50,17 @@
|
||||
|
||||
<!-- Credential Info Section Card -->
|
||||
<div class="dt-info-container">
|
||||
|
||||
<!-- 인증 방식 -->
|
||||
<div class="dt-row">
|
||||
<div class="dt-col">
|
||||
<label class="dt-label">인증 방식</label>
|
||||
<div class="dt-input-group">
|
||||
<div class="dt-input-box" th:text="${authType}">OAuth2</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Client ID -->
|
||||
<div class="dt-row">
|
||||
<div class="dt-col">
|
||||
@@ -70,51 +81,71 @@
|
||||
<!-- Client Secret -->
|
||||
<div class="dt-row">
|
||||
<div class="dt-col">
|
||||
<label class="dt-label">Client Secret</label>
|
||||
|
||||
<!-- View Button (shown initially) -->
|
||||
<div class="dt-input-group" id="hiddenSecretBox">
|
||||
<button type="button" style="width: 100%; border-radius: 10px; background-color: #2a69de; color: #fff; height: 47px; display: flex; justify-content: center; align-items: center; border: none; font-size: 16px; font-weight: 500; cursor: pointer; transition: background-color 0.2s;" onmouseover="this.style.backgroundColor='#1d56c0'" onmouseout="this.style.backgroundColor='#2a69de'" onclick="showPasswordPrompt()">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right: 8px;">
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<path d="m21 21-4.35-4.35"></path>
|
||||
</svg>
|
||||
<span>조회</span>
|
||||
</button>
|
||||
</div>
|
||||
<label class="dt-label">
|
||||
Client Secret
|
||||
<button type="button" class="btn-guide-info" onclick="showLostKeyGuide()" title="비밀정보 안내" aria-label="비밀정보 안내"
|
||||
style="margin-left:6px;width:18px;height:18px;border-radius:50%;border:1px solid #bbb;background:#f5f5f5;color:#666;font-size:12px;line-height:1;cursor:pointer;padding:0;vertical-align:middle;">?</button>
|
||||
</label>
|
||||
|
||||
<!-- Actual Secret (hidden initially) -->
|
||||
<div id="revealedSecretBox" style="display: none; width: 100%;">
|
||||
<div class="dt-input-group">
|
||||
<div class="dt-input-box" th:text="${apiKey.clientsecret}" style="flex: 1;">secret-key</div>
|
||||
<button type="button" class="dt-btn-copy" th:data-secret="${apiKey.clientsecret}" onclick="copyToClipboardFromButton(this)">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
||||
<!-- secret이 아직 DB에 존재: 최초 1회 조회 가능 -->
|
||||
<th:block th:if="${secretAvailable}">
|
||||
<!-- View Button (shown initially) -->
|
||||
<div class="dt-input-group" id="hiddenSecretBox">
|
||||
<button type="button" style="width: 100%; border-radius: 10px; background-color: #2a69de; color: #fff; height: 47px; display: flex; justify-content: center; align-items: center; border: none; font-size: 16px; font-weight: 500; cursor: pointer; transition: background-color 0.2s;" onmouseover="this.style.backgroundColor='#1d56c0'" onmouseout="this.style.backgroundColor='#2a69de'" onclick="showPasswordPrompt()">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right: 8px;">
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<path d="m21 21-4.35-4.35"></path>
|
||||
</svg>
|
||||
<span>복사</span>
|
||||
<span>조회</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Actual Secret (hidden initially, 값은 서버 응답으로 JS가 주입) -->
|
||||
<div id="revealedSecretBox" style="display: none; width: 100%;">
|
||||
<div class="dt-input-group">
|
||||
<div class="dt-input-box" id="revealedSecretValue" style="flex: 1;"></div>
|
||||
<button type="button" class="dt-btn-copy" id="revealedSecretCopyBtn" onclick="copyToClipboardFromButton(this)">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
||||
</svg>
|
||||
<span>복사</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="secret-warning" style="margin:10px 0 0;padding:10px 12px;background:#fdecea;border:1px solid #f5c6c2;border-radius:6px;color:#c0392b;font-size:13px;line-height:1.6;">
|
||||
⚠ 이 값은 <strong>지금 한 번만</strong> 표시되며, 이후에는 다시 조회할 수 없습니다.<br>
|
||||
반드시 <strong>안전한 곳에 복사하여 보관</strong>해 주세요.
|
||||
</p>
|
||||
</div>
|
||||
</th:block>
|
||||
|
||||
<!-- secret이 이미 노출·삭제됨 -->
|
||||
<div th:unless="${secretAvailable}" class="secret-revealed-notice" style="display:flex;align-items:center;gap:12px;">
|
||||
<span class="secret-revealed-text" style="color:#888;font-size:14px;">이미 1회 노출되어 삭제된 인증정보입니다.</span>
|
||||
<button type="button" class="btn-guide-link" onclick="showLostKeyGuide()"
|
||||
style="background:none;border:none;color:#2b6cb0;text-decoration:underline;cursor:pointer;font-size:13px;padding:0;">분실 시 안내</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Callback URL -->
|
||||
<!-- Callback URL (기능본에서 비활성 처리됨 — 렌더링되지 않음) -->
|
||||
<!--/*
|
||||
<div class="dt-row">
|
||||
<div class="dt-col">
|
||||
<label class="dt-label">Callback URL</label>
|
||||
<div class="dt-input-group">
|
||||
<div class="dt-input-box" th:text="${apiKey.redirecturi != null and !apiKey.redirecturi.isEmpty() ? apiKey.redirecturi : '설정되지않음'}">http://www.abcd.co.kr</div>
|
||||
<div class="dt-input-box" th:text="${apiKey.redirecturi != null ? apiKey.redirecturi : '설정되지 않음'}">http://www.abcd.co.kr</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
*/-->
|
||||
|
||||
<!-- 화이트 리스트 -->
|
||||
<div class="dt-row">
|
||||
<div class="dt-col">
|
||||
<label class="dt-label">화이트 리스트</label>
|
||||
<div class="dt-input-group">
|
||||
<div class="dt-input-box" th:text="${apiKey.allowedips != null and !apiKey.allowedips.isEmpty() ? apiKey.allowedips : '설정되지않음'}">설정되지않음</div>
|
||||
<div class="dt-input-box" th:text="${apiKey.allowedips != null ? apiKey.allowedips : '설정되지 않음'}">설정되지 않음</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -140,7 +171,7 @@
|
||||
<div class="dt-col">
|
||||
<label class="dt-label">최종 수정일자</label>
|
||||
<div class="dt-input-group">
|
||||
<div class="dt-input-box" th:text="${apiKey.modifiedon != null ? #temporals.format(apiKey.modifiedon, 'yyyy.MM.dd') : 'N/A'}">2025.11.22</div>
|
||||
<div class="dt-input-box" th:text="${#temporals.format(apiKey.modifiedon, 'yyyy.MM.dd')}">2025.11.22</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -164,36 +195,50 @@
|
||||
<script th:inline="javascript">
|
||||
/*<![CDATA[*/
|
||||
|
||||
// Show password prompt for viewing client secret
|
||||
var CREDENTIAL_CLIENT_ID = /*[[${apiKey.clientid}]]*/ '';
|
||||
|
||||
// 비밀정보 안내(분실 시 조치) 가이드 팝업
|
||||
function showLostKeyGuide() {
|
||||
customPopups.showAlert(
|
||||
'Client Secret은 보안을 위해 <strong>최초 1회만 조회</strong>할 수 있으며,<br>' +
|
||||
'조회하는 즉시 개발자포탈에서 <strong>영구 삭제</strong>됩니다.<br><br>' +
|
||||
'값을 분실하셨다면 복구가 불가능하므로,<br>' +
|
||||
'<strong>인증키를 새로 신청</strong>하여 발급받아 주세요.'
|
||||
);
|
||||
}
|
||||
|
||||
// Show password prompt for viewing client secret (최초 1회 노출 + 서버측 물리 삭제)
|
||||
function showPasswordPrompt() {
|
||||
customPopups.showPasswordInput({
|
||||
title: 'Client Secret 조회',
|
||||
message: '보안을 위해 비밀번호를 입력해주세요.',
|
||||
message: '보안을 위해 비밀번호를 입력해주세요.<br>조회 즉시 값은 영구 삭제됩니다.',
|
||||
onConfirm: function(password) {
|
||||
// Verify password via AJAX
|
||||
$.ajax({
|
||||
url: /*[[@{/myapikey/verify-password}]]*/ '/myapikey/verify-password',
|
||||
url: /*[[@{/myapikey/credential/reveal-secret}]]*/ '/myapikey/credential/reveal-secret',
|
||||
type: 'POST',
|
||||
data: {password: password},
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify({ clientId: CREDENTIAL_CLIENT_ID, password: password }),
|
||||
headers: {
|
||||
'X-XSRF-TOKEN': /*[[${_csrf.token}]]*/ 'token'
|
||||
},
|
||||
success: function(response) {
|
||||
if (response.success) {
|
||||
// Hide password popup
|
||||
customPopups.hidePasswordInput();
|
||||
|
||||
// Reveal the client secret
|
||||
$('#hiddenSecretBox').hide();
|
||||
$('#revealedSecretBox').fadeIn(300);
|
||||
} else {
|
||||
// Show error message
|
||||
customPopups.showPasswordError(response.message || '비밀번호가 일치하지 않습니다.');
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
customPopups.showPasswordError('오류가 발생했습니다. 다시 시도해주세요.');
|
||||
}
|
||||
}).done(function(response) {
|
||||
if (response.success) {
|
||||
customPopups.hidePasswordInput();
|
||||
|
||||
// 서버가 반환한 secret을 화면에 1회 주입
|
||||
$('#revealedSecretValue').text(response.secret);
|
||||
$('#revealedSecretCopyBtn').attr('data-secret', response.secret).data('secret', response.secret);
|
||||
|
||||
$('#hiddenSecretBox').hide();
|
||||
$('#revealedSecretBox').fadeIn(300);
|
||||
} else if (response.alreadyRevealed) {
|
||||
customPopups.hidePasswordInput();
|
||||
showLostKeyGuide();
|
||||
} else {
|
||||
customPopups.showPasswordError(response.message || '비밀번호가 일치하지 않습니다.');
|
||||
}
|
||||
}).fail(function() {
|
||||
customPopups.showPasswordError('오류가 발생했습니다. 다시 시도해주세요.');
|
||||
});
|
||||
},
|
||||
onCancel: function() {
|
||||
@@ -238,31 +283,38 @@
|
||||
function deleteApiKeyFromButton(button) {
|
||||
var clientId = $(button).data('client-id');
|
||||
|
||||
if (!confirm('정말로 이 인증키를 삭제하시겠습니까?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$('.loading-overlay').show();
|
||||
|
||||
const requestData = {
|
||||
clientId: clientId
|
||||
};
|
||||
|
||||
$.ajax({
|
||||
url: '/myapikey/api_key_delete',
|
||||
type: 'POST',
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify(requestData),
|
||||
headers: {
|
||||
'X-XSRF-TOKEN': /*[[${_csrf.token}]]*/ 'token'
|
||||
customPopups.showConfirm('정말로 이 인증키를 삭제하시겠습니까?', function(confirmed) {
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
}).done(function(response) {
|
||||
alert(response.msg || 'API Key가 삭제되었습니다.');
|
||||
window.location.href = /*[[@{/myapikey}]]*/ '/myapikey';
|
||||
}).fail(function(jqXHR, textStatus, errorThrown) {
|
||||
alert('API 삭제 요청 중 오류가 발생했습니다: ' + errorThrown);
|
||||
}).always(function() {
|
||||
$('.loading-overlay').hide();
|
||||
|
||||
$('.loading-overlay').show();
|
||||
|
||||
const requestData = {
|
||||
clientId: clientId
|
||||
};
|
||||
|
||||
$.ajax({
|
||||
url: '/myapikey/api_key_delete',
|
||||
type: 'POST',
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify(requestData),
|
||||
headers: {
|
||||
'X-XSRF-TOKEN': /*[[${_csrf.token}]]*/ 'token'
|
||||
}
|
||||
}).done(function(response) {
|
||||
if (response && response.success === false) {
|
||||
customPopups.showAlert(response.msg || 'API 삭제에 실패했습니다.');
|
||||
return;
|
||||
}
|
||||
customPopups.showAlert(response.msg || 'API Key가 삭제되었습니다.', function() {
|
||||
window.location.href = /*[[@{/myapikey}]]*/ '/myapikey';
|
||||
});
|
||||
}).fail(function(jqXHR, textStatus, errorThrown) {
|
||||
customPopups.showAlert('API 삭제 요청 중 오류가 발생했습니다: ' + errorThrown);
|
||||
}).always(function() {
|
||||
$('.loading-overlay').hide();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -129,6 +129,14 @@
|
||||
}
|
||||
};
|
||||
|
||||
// 제출 대상 org 필드 (검증 필수목록과 별개 - 선택입력 포함 전 필드 전송)
|
||||
// 제외: ipWhitelist(가입 미입력), orgCode(관리자/시스템 부여)
|
||||
const submitOrgFields = [
|
||||
'compRegNo', 'corpRegNo', 'orgName', 'compRegFile', 'files',
|
||||
'ceoName', 'orgAddr', 'serviceName', 'orgPhoneNumber', 'scPhoneNumber',
|
||||
'orgSectors', 'orgIndustryType'
|
||||
];
|
||||
|
||||
// 이메일 유효성 검사 함수
|
||||
function isValidEmail(email) {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
@@ -177,7 +185,7 @@
|
||||
}
|
||||
});
|
||||
|
||||
fields.org.forEach(fieldId => {
|
||||
submitOrgFields.forEach(fieldId => {
|
||||
const element = document.getElementById(fieldId);
|
||||
if (element) {
|
||||
if (element.type === 'file') {
|
||||
@@ -375,6 +383,35 @@
|
||||
return scenarioValidation && commonValidation;
|
||||
}
|
||||
|
||||
// 미입력/미충족 항목을 구체적으로 수집
|
||||
function collectFormErrors() {
|
||||
const errors = [];
|
||||
|
||||
// 시나리오별 항목
|
||||
if (registrationScenario === 'new') {
|
||||
const loginIdElement = document.getElementById('loginId');
|
||||
const userNameElement = document.getElementById('userName');
|
||||
|
||||
if (!(loginIdElement && isValidEmail(loginIdElement.value))) errors.push('이메일 아이디를 올바르게 입력해주세요.');
|
||||
if (!(userNameElement && userNameElement.value.trim() !== '')) errors.push('성명을 입력해주세요.');
|
||||
if (!isAuthVerified) errors.push('휴대폰 인증을 완료해주세요.');
|
||||
if (document.getElementById('isPasswordValid')?.value.trim() !== 'true') errors.push('비밀번호 조건을 확인해주세요.');
|
||||
if (document.getElementById('isPasswordMatch')?.value.trim() !== 'true') errors.push('비밀번호 확인이 일치하지 않습니다.');
|
||||
}
|
||||
|
||||
// 공통 항목
|
||||
const notificationConsentEl = document.getElementById('notificationConsent');
|
||||
if ($('#orgName').val().trim() === '') errors.push('회사명을 입력해주세요.');
|
||||
if ($('#compRegNo').val().trim() === '') errors.push('사업자등록번호를 올바르게 입력해주세요.');
|
||||
if ($('#corpRegNo').val().trim() === '') errors.push('법인등록번호를 올바르게 입력해주세요.');
|
||||
if ($('#compRegFile').val().trim() === '') errors.push('사업자등록증 파일을 첨부해주세요.');
|
||||
if (!$('#termsOfUse').prop('checked')) errors.push('이용약관에 동의해주세요.');
|
||||
if (!$('#privacyCollect').prop('checked')) errors.push('개인정보 수집·이용에 동의해주세요.');
|
||||
if (notificationConsentEl && !notificationConsentEl.checked) errors.push('알림 수신에 동의해주세요.');
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
let isSubmitting = false;
|
||||
|
||||
// 등록 버튼 클릭 이벤트
|
||||
@@ -426,7 +463,10 @@
|
||||
isPasswordMatch
|
||||
});
|
||||
|
||||
let errorMessage = '모든 필수 항목을 입력하고 약관에 동의해주세요.'
|
||||
const errors = collectFormErrors();
|
||||
let errorMessage = errors.length > 0
|
||||
? '아래 항목을 확인해주세요.<br><br>' + errors.map(e => '• ' + e).join('<br>')
|
||||
: '모든 필수 항목을 입력하고 약관에 동의해주세요.';
|
||||
customPopups.showAlert(errorMessage);
|
||||
|
||||
// Reset submission state since validation failed
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<th:block th:fragment="agreementContent(termsOfUse, privacyCollect)">
|
||||
<!-- Agreement Section Header -->
|
||||
<div class="org-section-header org-section-header--agreement">
|
||||
<h3>약관 동의</h3>
|
||||
<h3 th:text="${agreementTitle} ?: '약관 동의'">약관 동의</h3>
|
||||
<span class="required-badge">필수 동의</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -32,16 +32,6 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="result-info-box" style="background-color: #fff3cd; border-color: #ffc107; margin-top: 16px;">
|
||||
<p class="info-text" style="margin: 0;">
|
||||
<strong>초대코드:</strong>
|
||||
<span th:text="${invitationCode}" style="font-family: monospace; font-size: 18px; font-weight: bold; color: #333; letter-spacing: 3px; margin-left: 8px;"></span>
|
||||
</p>
|
||||
<p style="margin: 8px 0 0 0; font-size: 12px; color: #856404;">
|
||||
이메일로 받은 초대코드와 일치하는지 확인해 주세요.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="result-info-box" style="background-color: #e7f3ff; border-color: #1a73e8; margin-top: 16px;">
|
||||
<p class="info-text" style="margin: 0; color: #1a73e8;">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
|
||||
@@ -43,15 +43,6 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="result-info-box" style="background-color: #fff3cd; border-color: #ffc107; margin-top: 16px;">
|
||||
<p class="info-text" style="margin: 0;">
|
||||
<strong>초대코드:</strong>
|
||||
<span th:text="${invitationCode}" style="font-family: monospace; font-size: 18px; font-weight: bold; color: #333; letter-spacing: 3px; margin-left: 8px;"></span>
|
||||
</p>
|
||||
<p style="margin: 8px 0 0 0; font-size: 12px; color: #856404;">
|
||||
이메일로 받은 초대코드와 일치하는지 확인해 주세요.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -12,18 +12,133 @@
|
||||
|
||||
<th:block layout:fragment="contentFragment">
|
||||
<link rel="stylesheet" type="text/css" th:href="@{/css/api-statistics.css}"/>
|
||||
<style>
|
||||
/* 통계 테이블 (실제 table 태그) 공통 */
|
||||
.api-statistics-container .stats-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
background: #fff;
|
||||
}
|
||||
.api-statistics-container .stats-table th,
|
||||
.api-statistics-container .stats-table td {
|
||||
padding: 14px 12px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.api-statistics-container .stats-table thead th {
|
||||
text-align: center;
|
||||
background: #f8fafc;
|
||||
color: #475569;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.api-statistics-container .stats-table tfoot td {
|
||||
font-weight: 600;
|
||||
background: #f8fafc;
|
||||
}
|
||||
/* API별 표: auto layout — 숫자열은 내용에 맞춰 최소(nowrap), 통계는 고정, API명이 남은 폭 전체 */
|
||||
.api-statistics-container .stats-table--api { table-layout: auto; }
|
||||
.api-statistics-container .stats-table--api .col-name {
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
padding-left: 4px;
|
||||
}
|
||||
/* API명 헤더 셀은 가운데 정렬 (데이터 셀은 좌측 유지) */
|
||||
.api-statistics-container .stats-table--api thead .col-name {
|
||||
text-align: center;
|
||||
}
|
||||
.api-statistics-container .stats-table--api .col-num {
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
width: 1%;
|
||||
}
|
||||
.api-statistics-container .stats-table--api .col-chart {
|
||||
text-align: center;
|
||||
width: 180px;
|
||||
min-width: 180px;
|
||||
}
|
||||
.api-statistics-container .stats-table--api .col-chart .stats-progress {
|
||||
min-width: 150px;
|
||||
}
|
||||
.api-statistics-container .stats-table--api .api-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
vertical-align: middle;
|
||||
margin-right: 6px;
|
||||
}
|
||||
.api-statistics-container .stats-table--api .api-icon svg { display: block; }
|
||||
.api-statistics-container .stats-table--api .api-name-text { vertical-align: middle; }
|
||||
/* 일별/기간별 표: fixed layout — 5열 동일 너비, 전부 가운데 */
|
||||
.api-statistics-container .stats-table--period { table-layout: fixed; }
|
||||
.api-statistics-container .stats-table--period th,
|
||||
.api-statistics-container .stats-table--period td {
|
||||
text-align: center;
|
||||
width: 20%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* progress bar */
|
||||
.api-statistics-container .stats-progress {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background: #f1f5f9;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
/* empty state */
|
||||
.api-statistics-container .stats-empty td {
|
||||
text-align: center;
|
||||
padding: 40px 0;
|
||||
color: #64748b;
|
||||
font-weight: 400;
|
||||
font-size: 16px;
|
||||
}
|
||||
</style>
|
||||
<div class="service-main app-management-layout">
|
||||
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('statistics')}"></th:block>
|
||||
<div class="app-management-content">
|
||||
<section class="api-statistics-container">
|
||||
|
||||
<!-- 집계 안내 문구 (집계 주기 분 + 최신 집계 시각, 서버 계산값) -->
|
||||
<div class="statistics-notice">
|
||||
<span th:if="${statsAggregationMinute != null}"
|
||||
th:text="'통계 데이터는 매시 ' + ${statsAggregationMinute} + '분 집계되며, 집계 이전 시간 기준으로 생성됩니다.'">통계 안내</span>
|
||||
<span th:if="${statsAggregationMinute == null}">통계 데이터는 매시 집계되며, 집계 이전 시간 기준으로 생성됩니다.</span>
|
||||
<span th:if="${statsLatestTime != null}" class="statistics-notice-latest"
|
||||
th:text="'※ 최신 집계 시각: ' + ${statsLatestTime} + ' 기준'">최신 집계 시각</span>
|
||||
</div>
|
||||
|
||||
<!-- Search Filter -->
|
||||
<div class="statistics-search-section">
|
||||
<!-- 일별/월별 모드 토글 -->
|
||||
<div class="search-mode-tabs">
|
||||
<button type="button" class="mode-tab active" data-mode="DAILY">일별</button>
|
||||
<button type="button" class="mode-tab" data-mode="MONTHLY">월별</button>
|
||||
</div>
|
||||
|
||||
<div class="search-filter-row">
|
||||
<div class="date-range-picker">
|
||||
<!-- 일별: 날짜 범위 -->
|
||||
<div class="date-range-picker" id="dailyFilter">
|
||||
<input type="date" id="startDate" class="date-input"
|
||||
th:value="${#temporals.format(searchDto.startDate, 'yyyy-MM-dd')}">
|
||||
<span class="date-separator">-</span>
|
||||
<input type="date" id="endDate" class="date-input"
|
||||
th:value="${#temporals.format(searchDto.endDate, 'yyyy-MM-dd')}">
|
||||
</div>
|
||||
|
||||
<!-- 월별: 특정 년월 단일 선택 (가용월 목록) -->
|
||||
<div class="month-range-picker" id="monthlyFilter" style="display:none;">
|
||||
<select id="month" class="month-input">
|
||||
<option th:each="m,st : ${availableMonths}" th:value="${m}"
|
||||
th:text="${#strings.substring(m,0,4) + '-' + #strings.substring(m,4,6)}"
|
||||
th:selected="${st.index == 0}">2026-07</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn-search" id="btnSearch">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
@@ -42,9 +157,9 @@
|
||||
<select id="appSelect" class="app-select">
|
||||
<option value="">전체 앱</option>
|
||||
<option th:each="app : ${appList}"
|
||||
th:value="${app.clientid}"
|
||||
th:text="${app.clientname}"
|
||||
th:selected="${searchDto.clientId != null and searchDto.clientId == app.clientid}">
|
||||
th:value="${app.clientId}"
|
||||
th:text="${app.clientName}"
|
||||
th:selected="${searchDto.clientId != null and searchDto.clientId == app.clientId}">
|
||||
앱 이름
|
||||
</option>
|
||||
</select>
|
||||
@@ -52,46 +167,55 @@
|
||||
|
||||
<div class="summary-info">
|
||||
<p class="summary-total-text">
|
||||
총 API 호출 수는 <strong id="totalAttempted" th:text="${#numbers.formatInteger(summary.totalAttempted, 0, 'COMMA')}">0</strong>건입니다.
|
||||
총 API 호출 수는 <strong id="totalCount" th:text="${#numbers.formatInteger(summary.totalCount, 0, 'COMMA')}">0</strong>건입니다.
|
||||
</p>
|
||||
<div class="summary-detail-list">
|
||||
<div class="summary-detail-item success">
|
||||
<span class="detail-icon">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<circle cx="8" cy="8" r="6"/>
|
||||
</svg>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><circle cx="8" cy="8" r="6"/></svg>
|
||||
</span>
|
||||
<span class="detail-label">성공</span>
|
||||
<span class="detail-value" id="totalCompleted" th:text="${#numbers.formatInteger(summary.totalCompleted, 0, 'COMMA')}">0</span>
|
||||
<span class="detail-percent">(<span id="successRateText" th:text="${summary.successRate}">0</span>%)</span>
|
||||
<span class="detail-value" id="successCount" th:text="${#numbers.formatInteger(summary.successCount, 0, 'COMMA')}">0</span>
|
||||
<span class="detail-percent">(<span id="successRateText" th:text="${summary.totalCount == 0 ? '-' : summary.successRate}">0</span>%)</span>
|
||||
</div>
|
||||
<div class="summary-detail-item timeout">
|
||||
<span class="detail-icon">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><circle cx="8" cy="8" r="6"/></svg>
|
||||
</span>
|
||||
<span class="detail-label">타임아웃</span>
|
||||
<span class="detail-value" id="timeoutCount" th:text="${#numbers.formatInteger(summary.timeoutCount, 0, 'COMMA')}">0</span>
|
||||
<span class="detail-percent">(<span id="timeoutRateText" th:text="${summary.totalCount == 0 ? '-' : summary.timeoutRate}">0</span>%)</span>
|
||||
</div>
|
||||
<div class="summary-detail-item failure">
|
||||
<span class="detail-icon">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<circle cx="8" cy="8" r="6"/>
|
||||
</svg>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><circle cx="8" cy="8" r="6"/></svg>
|
||||
</span>
|
||||
<span class="detail-label">실패</span>
|
||||
<span class="detail-value" id="totalFailed" th:text="${#numbers.formatInteger(summary.totalFailed, 0, 'COMMA')}">0</span>
|
||||
<span class="detail-percent">(<span id="failureRateText" th:text="${summary.failureRate}">0</span>%)</span>
|
||||
<span class="detail-value" id="errorCount" th:text="${#numbers.formatInteger(summary.errorCount, 0, 'COMMA')}">0</span>
|
||||
<span class="detail-percent">(<span id="errorRateText" th:text="${summary.totalCount == 0 ? '-' : summary.errorRate}">0</span>%)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Center: Donut Chart (SVG) -->
|
||||
<!-- Center: Donut Chart (SVG, 3 segments: 성공/타임아웃/실패) -->
|
||||
<div class="summary-center">
|
||||
<svg id="donutChart" width="200" height="200" viewBox="0 0 200 200">
|
||||
<circle class="donut-bg" cx="100" cy="100" r="70" fill="none" stroke="#e0e0e0" stroke-width="24"/>
|
||||
<circle id="donutSuccess" class="donut-segment" cx="100" cy="100" r="70" fill="none"
|
||||
stroke="#4A90D9" stroke-width="24" stroke-linecap="round"
|
||||
stroke="#4A90D9" stroke-width="24" stroke-linecap="butt"
|
||||
th:attr="stroke-dasharray=${summary.successRate * 4.398} + ' 439.8'"
|
||||
transform="rotate(-90 100 100)"/>
|
||||
<circle id="donutFailure" class="donut-segment" cx="100" cy="100" r="70" fill="none"
|
||||
stroke="#F5A3B5" stroke-width="24" stroke-linecap="round"
|
||||
th:attr="stroke-dasharray=${summary.failureRate * 4.398} + ' 439.8',
|
||||
stroke-dashoffset="0" transform="rotate(-90 100 100)"/>
|
||||
<circle id="donutTimeout" class="donut-segment" cx="100" cy="100" r="70" fill="none"
|
||||
stroke="#F5C36B" stroke-width="24" stroke-linecap="butt"
|
||||
th:attr="stroke-dasharray=${summary.timeoutRate * 4.398} + ' 439.8',
|
||||
stroke-dashoffset='-' + ${summary.successRate * 4.398}"
|
||||
transform="rotate(-90 100 100)"/>
|
||||
<circle id="donutError" class="donut-segment" cx="100" cy="100" r="70" fill="none"
|
||||
stroke="#F5A3B5" stroke-width="24" stroke-linecap="butt"
|
||||
th:attr="stroke-dasharray=${summary.errorRate * 4.398} + ' 439.8',
|
||||
stroke-dashoffset='-' + ${(summary.successRate + summary.timeoutRate) * 4.398}"
|
||||
transform="rotate(-90 100 100)"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
@@ -100,20 +224,26 @@
|
||||
<div class="rate-display success-rate">
|
||||
<span class="rate-indicator success"></span>
|
||||
<span class="rate-label">성공률</span>
|
||||
<span class="rate-value" id="successRate" th:text="${summary.successRate}">0</span>
|
||||
<span class="rate-value" id="successRate" th:text="${summary.totalCount == 0 ? '-' : summary.successRate}">0</span>
|
||||
<span class="rate-percent">%</span>
|
||||
</div>
|
||||
<div class="rate-display timeout-rate">
|
||||
<span class="rate-indicator timeout"></span>
|
||||
<span class="rate-label">타임아웃율</span>
|
||||
<span class="rate-value" id="timeoutRate" th:text="${summary.totalCount == 0 ? '-' : summary.timeoutRate}">0</span>
|
||||
<span class="rate-percent">%</span>
|
||||
</div>
|
||||
<div class="rate-display failure-rate">
|
||||
<span class="rate-indicator failure"></span>
|
||||
<span class="rate-label">실패률</span>
|
||||
<span class="rate-value" id="failureRate" th:text="${summary.failureRate}">0</span>
|
||||
<span class="rate-value" id="errorRate" th:text="${summary.totalCount == 0 ? '-' : summary.errorRate}">0</span>
|
||||
<span class="rate-percent">%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API Statistics Table Section -->
|
||||
<!-- API별 통계 Table Section -->
|
||||
<div class="statistics-table-section" style="margin-top: 40px;">
|
||||
<div class="table-controls" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; background: none; border: none; padding: 0;">
|
||||
<h3 class="table-title" style="font-size: 20px; font-weight: 700; color: #1e293b; display: flex; align-items: center; gap: 8px; margin: 0;">
|
||||
@@ -123,78 +253,111 @@
|
||||
</svg>
|
||||
API별 통계
|
||||
</h3>
|
||||
<a th:href="@{/statistics/api/download(startDate=${searchDto.startDate},endDate=${searchDto.endDate},clientId=${searchDto.clientId})}"
|
||||
class="btn-download" id="btnDownload" style="display: inline-flex; align-items: center; gap: 6px; padding: 8px 16px; border: 1px solid #e2e8f0; border-radius: 8px; background: #fff; color: #475569; font-size: 14px; font-weight: 500; text-decoration: none; transition: all 0.2s;">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7 10 12 15 17 10"/>
|
||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
다운로드
|
||||
</a>
|
||||
<!-- 다운로드 버튼은 UI에서 제거됨 (백엔드 /statistics/api/download 는 유지) -->
|
||||
</div>
|
||||
|
||||
<div class="board-table">
|
||||
<div class="board-table-header">
|
||||
<div class="header-cell" style="flex: 2; min-width: 200px; text-align: left;">API명</div>
|
||||
<div class="header-cell" style="width: 120px; justify-content: flex-end;">API호출수</div>
|
||||
<div class="header-cell" style="width: 120px; justify-content: flex-end;">성공</div>
|
||||
<div class="header-cell" style="width: 120px; justify-content: flex-end;">실패</div>
|
||||
<div class="header-cell" style="flex: 1; min-width: 150px; justify-content: center;">통계</div>
|
||||
</div>
|
||||
|
||||
<div class="board-table-body" id="statisticsTableBody">
|
||||
<div class="board-table-row" th:each="detail : ${details}">
|
||||
<div class="row-cell" style="flex: 2; min-width: 200px; text-align: left; display: flex; align-items: center; gap: 8px;" data-label="API명">
|
||||
<table class="stats-table stats-table--api">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-name">API명</th>
|
||||
<th class="col-num">API호출수</th>
|
||||
<th class="col-num">성공</th>
|
||||
<th class="col-num">실패</th>
|
||||
<th class="col-num">타임아웃</th>
|
||||
<th class="col-chart">통계</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="statisticsTableBody">
|
||||
<tr th:each="detail : ${details}">
|
||||
<td class="col-name">
|
||||
<span class="api-icon">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span th:text="${detail.apiName}">API 이름</span>
|
||||
</div>
|
||||
<div class="row-cell" style="width: 120px; justify-content: flex-end;" data-label="API호출수" th:text="${#numbers.formatInteger(detail.attemptedCount, 0, 'COMMA')}">0</div>
|
||||
<div class="row-cell" style="width: 120px; justify-content: flex-end;" data-label="성공" th:text="${#numbers.formatInteger(detail.completedCount, 0, 'COMMA')}">0</div>
|
||||
<div class="row-cell" style="width: 120px; justify-content: flex-end;" data-label="실패" th:text="${#numbers.formatInteger(detail.failedCount, 0, 'COMMA')}">0</div>
|
||||
<div class="row-cell" style="flex: 1; min-width: 150px; justify-content: center;" data-label="통계">
|
||||
<div class="progress-bar" style="width: 100%; height: 8px; background: #f1f5f9; border-radius: 4px; display: flex; overflow: hidden;">
|
||||
</span><span class="api-name-text" th:text="${detail.apiName}">API 이름</span>
|
||||
</td>
|
||||
<td class="col-num" th:text="${#numbers.formatInteger(detail.totalCount, 0, 'COMMA')}">0</td>
|
||||
<td class="col-num" th:text="${#numbers.formatInteger(detail.successCount, 0, 'COMMA')}">0</td>
|
||||
<td class="col-num" th:text="${#numbers.formatInteger(detail.errorCount, 0, 'COMMA')}">0</td>
|
||||
<td class="col-num" th:text="${#numbers.formatInteger(detail.timeoutCount, 0, 'COMMA')}">0</td>
|
||||
<td class="col-chart">
|
||||
<div class="stats-progress">
|
||||
<div class="progress-success" th:style="'width:' + ${detail.successRate} + '%; background: #4A90D9;'"></div>
|
||||
<div class="progress-failure" th:style="'width:' + ${detail.failureRate} + '%; background: #F5A3B5;'"></div>
|
||||
<div class="progress-timeout" th:style="'width:' + ${detail.timeoutRate} + '%; background: #F5C36B;'"></div>
|
||||
<div class="progress-failure" th:style="'width:' + ${detail.errorRate} + '%; background: #F5A3B5;'"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Empty State -->
|
||||
<div th:if="${details == null or details.isEmpty()}" class="user-empty-state" style="border-bottom: 1px solid #e2e8f0;">
|
||||
<h3 style="text-align: center; padding: 40px 0; color: #64748b; font-weight: 400; font-size: 16px;">데이터가 없습니다.</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="board-table-body total-row-container" id="statisticsTableFoot" th:if="${details != null and !details.isEmpty()}" style="border-top: none; background-color: #f8fafc; border-bottom: 1px solid #e2e8f0;">
|
||||
<div class="board-table-row total-row" style="font-weight: 600;">
|
||||
<div class="row-cell" style="flex: 2; min-width: 200px; text-align: left; display: flex; align-items: center; gap: 8px;">
|
||||
</td>
|
||||
</tr>
|
||||
<tr th:if="${details == null or details.isEmpty()}" class="stats-empty">
|
||||
<td colspan="6">데이터가 없습니다.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot id="statisticsTableFoot">
|
||||
<tr class="total-row" th:if="${details != null and !details.isEmpty()}">
|
||||
<td class="col-name">
|
||||
<span class="api-icon total">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M3 3v18h18"/>
|
||||
<path d="M18 9l-5 5-4-4-4 4"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span>전체</span>
|
||||
</div>
|
||||
<div class="row-cell" style="width: 120px; justify-content: flex-end;" th:text="${#numbers.formatInteger(summary.totalAttempted, 0, 'COMMA')}">0</div>
|
||||
<div class="row-cell" style="width: 120px; justify-content: flex-end;" th:text="${#numbers.formatInteger(summary.totalCompleted, 0, 'COMMA')}">0</div>
|
||||
<div class="row-cell" style="width: 120px; justify-content: flex-end;" th:text="${#numbers.formatInteger(summary.totalFailed, 0, 'COMMA')}">0</div>
|
||||
<div class="row-cell progress-cell" style="flex: 1; min-width: 150px; justify-content: center;">
|
||||
<div class="progress-bar" style="width: 100%; height: 8px; background: #f1f5f9; border-radius: 4px; display: flex; overflow: hidden;">
|
||||
</span><span class="api-name-text">전체</span>
|
||||
</td>
|
||||
<td class="col-num" th:text="${#numbers.formatInteger(summary.totalCount, 0, 'COMMA')}">0</td>
|
||||
<td class="col-num" th:text="${#numbers.formatInteger(summary.successCount, 0, 'COMMA')}">0</td>
|
||||
<td class="col-num" th:text="${#numbers.formatInteger(summary.errorCount, 0, 'COMMA')}">0</td>
|
||||
<td class="col-num" th:text="${#numbers.formatInteger(summary.timeoutCount, 0, 'COMMA')}">0</td>
|
||||
<td class="col-chart">
|
||||
<div class="stats-progress">
|
||||
<div class="progress-success" th:style="'width:' + ${summary.successRate} + '%; background: #4A90D9;'"></div>
|
||||
<div class="progress-failure" th:style="'width:' + ${summary.failureRate} + '%; background: #F5A3B5;'"></div>
|
||||
<div class="progress-timeout" th:style="'width:' + ${summary.timeoutRate} + '%; background: #F5C36B;'"></div>
|
||||
<div class="progress-failure" th:style="'width:' + ${summary.errorRate} + '%; background: #F5A3B5;'"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 기간별(일별/월별) 누적 통계 Table Section -->
|
||||
<div class="statistics-table-section" style="margin-top: 40px;">
|
||||
<div class="table-controls" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; background: none; border: none; padding: 0;">
|
||||
<h3 class="table-title" style="font-size: 20px; font-weight: 700; color: #1e293b; display: flex; align-items: center; gap: 8px; margin: 0;">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2"/>
|
||||
<path d="M16 2v4M8 2v4M3 10h18"/>
|
||||
</svg>
|
||||
<span id="periodTableTitle">일별 통계</span>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<table class="stats-table stats-table--period">
|
||||
<thead>
|
||||
<tr>
|
||||
<th id="periodLabel">일자</th>
|
||||
<th>API호출수</th>
|
||||
<th>성공</th>
|
||||
<th>실패</th>
|
||||
<th>타임아웃</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="periodTableBody">
|
||||
<tr th:each="p : ${periods}">
|
||||
<td th:text="${p.period}">2026-07-15 (수)</td>
|
||||
<td th:text="${p.hasData ? #numbers.formatInteger(p.totalCount, 0, 'COMMA') : '-'}">0</td>
|
||||
<td th:text="${p.hasData ? #numbers.formatInteger(p.successCount, 0, 'COMMA') : '-'}">0</td>
|
||||
<td th:text="${p.hasData ? #numbers.formatInteger(p.errorCount, 0, 'COMMA') : '-'}">0</td>
|
||||
<td th:text="${p.hasData ? #numbers.formatInteger(p.timeoutCount, 0, 'COMMA') : '-'}">0</td>
|
||||
</tr>
|
||||
<tr th:if="${periods == null or periods.isEmpty()}" class="stats-empty">
|
||||
<td colspan="5">데이터가 없습니다.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</th:block>
|
||||
|
||||
<th:block layout:fragment="contentScript">
|
||||
@@ -203,122 +366,128 @@
|
||||
// Initial data from server
|
||||
let currentData = {
|
||||
summary: {
|
||||
totalAttempted: [[${summary.totalAttempted}]],
|
||||
totalCompleted: [[${summary.totalCompleted}]],
|
||||
totalFailed: [[${summary.totalFailed}]],
|
||||
totalCount: [[${summary.totalCount}]],
|
||||
successCount: [[${summary.successCount}]],
|
||||
timeoutCount: [[${summary.timeoutCount}]],
|
||||
errorCount: [[${summary.errorCount}]],
|
||||
successRate: [[${summary.successRate}]],
|
||||
failureRate: [[${summary.failureRate}]]
|
||||
timeoutRate: [[${summary.timeoutRate}]],
|
||||
errorRate: [[${summary.errorRate}]]
|
||||
},
|
||||
details: [[${details}]]
|
||||
details: [[${details}]],
|
||||
periods: [[${periods}]]
|
||||
};
|
||||
|
||||
let selectedClientId = [[${searchDto.clientId}]] || '';
|
||||
let currentMode = 'DAILY';
|
||||
const CIRCUMFERENCE = 439.8; // 2 * PI * 70
|
||||
const MAX_DATE_RANGE_DAYS = 40;
|
||||
|
||||
// Update SVG Donut Chart
|
||||
// ── Donut (성공/타임아웃/실패 3세그먼트) ──
|
||||
function updateChart() {
|
||||
var successRate = currentData.summary.successRate || 0;
|
||||
var failureRate = currentData.summary.failureRate || 0;
|
||||
|
||||
var successLength = successRate * CIRCUMFERENCE / 100;
|
||||
var failureLength = failureRate * CIRCUMFERENCE / 100;
|
||||
|
||||
$('#donutSuccess').attr('stroke-dasharray', successLength + ' ' + CIRCUMFERENCE);
|
||||
$('#donutFailure').attr('stroke-dasharray', failureLength + ' ' + CIRCUMFERENCE);
|
||||
$('#donutFailure').attr('stroke-dashoffset', -successLength);
|
||||
var s = currentData.summary.successRate || 0;
|
||||
var t = currentData.summary.timeoutRate || 0;
|
||||
var e = currentData.summary.errorRate || 0;
|
||||
var sl = s * CIRCUMFERENCE / 100;
|
||||
var tl = t * CIRCUMFERENCE / 100;
|
||||
var el = e * CIRCUMFERENCE / 100;
|
||||
$('#donutSuccess').attr('stroke-dasharray', sl + ' ' + CIRCUMFERENCE).attr('stroke-dashoffset', 0);
|
||||
$('#donutTimeout').attr('stroke-dasharray', tl + ' ' + CIRCUMFERENCE).attr('stroke-dashoffset', -sl);
|
||||
$('#donutError').attr('stroke-dasharray', el + ' ' + CIRCUMFERENCE).attr('stroke-dashoffset', -(sl + tl));
|
||||
}
|
||||
|
||||
// Update Summary Display
|
||||
// ── Summary ──
|
||||
function updateSummaryDisplay() {
|
||||
$('#totalAttempted').text(numberWithCommas(currentData.summary.totalAttempted));
|
||||
$('#totalCompleted').text(numberWithCommas(currentData.summary.totalCompleted));
|
||||
$('#totalFailed').text(numberWithCommas(currentData.summary.totalFailed));
|
||||
$('#successRate, #successRateText').text(currentData.summary.successRate);
|
||||
$('#failureRate, #failureRateText').text(currentData.summary.failureRate);
|
||||
var sm = currentData.summary;
|
||||
var t = sm.totalCount;
|
||||
$('#totalCount').text(numberWithCommas(sm.totalCount));
|
||||
$('#successCount').text(numberWithCommas(sm.successCount));
|
||||
$('#timeoutCount').text(numberWithCommas(sm.timeoutCount));
|
||||
$('#errorCount').text(numberWithCommas(sm.errorCount));
|
||||
$('#successRate, #successRateText').text(dashRate(t, sm.successRate));
|
||||
$('#timeoutRate, #timeoutRateText').text(dashRate(t, sm.timeoutRate));
|
||||
$('#errorRate, #errorRateText').text(dashRate(t, sm.errorRate));
|
||||
}
|
||||
|
||||
// Update Table
|
||||
// ── API별 표 (table DOM) ──
|
||||
function updateTable() {
|
||||
const tbody = $('#statisticsTableBody');
|
||||
tbody.empty();
|
||||
|
||||
if (currentData.details && currentData.details.length > 0) {
|
||||
currentData.details.forEach(function(detail) {
|
||||
const row = `
|
||||
<div class="board-table-row">
|
||||
<div class="row-cell" style="flex: 2; min-width: 200px; text-align: left; display: flex; align-items: center; gap: 8px;" data-label="API명">
|
||||
currentData.details.forEach(function(d) {
|
||||
tbody.append(`
|
||||
<tr>
|
||||
<td class="col-name">
|
||||
<span class="api-icon">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span>${detail.apiName || '-'}</span>
|
||||
</div>
|
||||
<div class="row-cell" style="width: 120px; justify-content: flex-end;" data-label="API호출수">${numberWithCommas(detail.attemptedCount)}</div>
|
||||
<div class="row-cell" style="width: 120px; justify-content: flex-end;" data-label="성공">${numberWithCommas(detail.completedCount)}</div>
|
||||
<div class="row-cell" style="width: 120px; justify-content: flex-end;" data-label="실패">${numberWithCommas(detail.failedCount)}</div>
|
||||
<div class="row-cell" style="flex: 1; min-width: 150px; justify-content: center;" data-label="통계">
|
||||
<div class="progress-bar" style="width: 100%; height: 8px; background: #f1f5f9; border-radius: 4px; display: flex; overflow: hidden;">
|
||||
<div class="progress-success" style="width:${detail.successRate}%; background: #4A90D9;"></div>
|
||||
<div class="progress-failure" style="width:${detail.failureRate}%; background: #F5A3B5;"></div>
|
||||
</span><span class="api-name-text">${d.apiName || '-'}</span>
|
||||
</td>
|
||||
<td class="col-num">${numberWithCommas(d.totalCount)}</td>
|
||||
<td class="col-num">${numberWithCommas(d.successCount)}</td>
|
||||
<td class="col-num">${numberWithCommas(d.errorCount)}</td>
|
||||
<td class="col-num">${numberWithCommas(d.timeoutCount)}</td>
|
||||
<td class="col-chart">
|
||||
<div class="stats-progress">
|
||||
<div class="progress-success" style="width:${d.successRate}%; background: #4A90D9;"></div>
|
||||
<div class="progress-timeout" style="width:${d.timeoutRate}%; background: #F5C36B;"></div>
|
||||
<div class="progress-failure" style="width:${d.errorRate}%; background: #F5A3B5;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
tbody.append(row);
|
||||
</td>
|
||||
</tr>`);
|
||||
});
|
||||
|
||||
// Update footer
|
||||
if ($('#statisticsTableFoot').length === 0) {
|
||||
const footerHtml = `
|
||||
<div class="board-table-body total-row-container" id="statisticsTableFoot" style="border-top: none; background-color: #f8fafc; border-bottom: 1px solid #e2e8f0;">
|
||||
<div class="board-table-row total-row" style="font-weight: 600;">
|
||||
<div class="row-cell" style="flex: 2; min-width: 200px; text-align: left; display: flex; align-items: center; gap: 8px;">
|
||||
<span class="api-icon total">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M3 3v18h18"/>
|
||||
<path d="M18 9l-5 5-4-4-4 4"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span>전체</span>
|
||||
</div>
|
||||
<div class="row-cell" style="width: 120px; justify-content: flex-end;">${numberWithCommas(currentData.summary.totalAttempted)}</div>
|
||||
<div class="row-cell" style="width: 120px; justify-content: flex-end;">${numberWithCommas(currentData.summary.totalCompleted)}</div>
|
||||
<div class="row-cell" style="width: 120px; justify-content: flex-end;">${numberWithCommas(currentData.summary.totalFailed)}</div>
|
||||
<div class="row-cell progress-cell" style="flex: 1; min-width: 150px; justify-content: center;">
|
||||
<div class="progress-bar" style="width: 100%; height: 8px; background: #f1f5f9; border-radius: 4px; display: flex; overflow: hidden;">
|
||||
<div class="progress-success" style="width:${currentData.summary.successRate}%; background: #4A90D9;"></div>
|
||||
<div class="progress-failure" style="width:${currentData.summary.failureRate}%; background: #F5A3B5;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
$('.board-table').append(footerHtml);
|
||||
} else {
|
||||
const tfoot = $('#statisticsTableFoot');
|
||||
tfoot.find('.row-cell:eq(1)').text(numberWithCommas(currentData.summary.totalAttempted));
|
||||
tfoot.find('.row-cell:eq(2)').text(numberWithCommas(currentData.summary.totalCompleted));
|
||||
tfoot.find('.row-cell:eq(3)').text(numberWithCommas(currentData.summary.totalFailed));
|
||||
tfoot.find('.progress-success').css('width', currentData.summary.successRate + '%');
|
||||
tfoot.find('.progress-failure').css('width', currentData.summary.failureRate + '%');
|
||||
}
|
||||
renderTotalFooter();
|
||||
} else {
|
||||
tbody.html('<div class="user-empty-state" style="border-bottom: 1px solid #e2e8f0;"><h3 style="text-align: center; padding: 40px 0; color: #64748b; font-weight: 400; font-size: 16px;">데이터가 없습니다.</h3></div>');
|
||||
$('#statisticsTableFoot').remove();
|
||||
tbody.html('<tr class="stats-empty"><td colspan="6">데이터가 없습니다.</td></tr>');
|
||||
$('#statisticsTableFoot').empty();
|
||||
}
|
||||
}
|
||||
|
||||
// Update Download Link
|
||||
function updateDownloadLink() {
|
||||
const startDate = $('#startDate').val();
|
||||
const endDate = $('#endDate').val();
|
||||
let downloadUrl = `/statistics/api/download?startDate=${startDate}&endDate=${endDate}`;
|
||||
if (selectedClientId) {
|
||||
downloadUrl += `&clientId=${selectedClientId}`;
|
||||
function renderTotalFooter() {
|
||||
var sm = currentData.summary;
|
||||
$('#statisticsTableFoot').html(`
|
||||
<tr class="total-row">
|
||||
<td class="col-name">
|
||||
<span class="api-icon total">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M3 3v18h18"/><path d="M18 9l-5 5-4-4-4 4"/>
|
||||
</svg>
|
||||
</span><span class="api-name-text">전체</span>
|
||||
</td>
|
||||
<td class="col-num">${numberWithCommas(sm.totalCount)}</td>
|
||||
<td class="col-num">${numberWithCommas(sm.successCount)}</td>
|
||||
<td class="col-num">${numberWithCommas(sm.errorCount)}</td>
|
||||
<td class="col-num">${numberWithCommas(sm.timeoutCount)}</td>
|
||||
<td class="col-chart">
|
||||
<div class="stats-progress">
|
||||
<div class="progress-success" style="width:${sm.successRate}%; background: #4A90D9;"></div>
|
||||
<div class="progress-timeout" style="width:${sm.timeoutRate}%; background: #F5C36B;"></div>
|
||||
<div class="progress-failure" style="width:${sm.errorRate}%; background: #F5A3B5;"></div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>`);
|
||||
}
|
||||
|
||||
// ── 기간별(일/월) 표 (table DOM) ──
|
||||
function updatePeriodTable() {
|
||||
const tbody = $('#periodTableBody');
|
||||
tbody.empty();
|
||||
if (currentData.periods && currentData.periods.length > 0) {
|
||||
currentData.periods.forEach(function(p) {
|
||||
tbody.append(`
|
||||
<tr>
|
||||
<td>${p.period || '-'}</td>
|
||||
<td>${p.hasData ? numberWithCommas(p.totalCount) : '-'}</td>
|
||||
<td>${p.hasData ? numberWithCommas(p.successCount) : '-'}</td>
|
||||
<td>${p.hasData ? numberWithCommas(p.errorCount) : '-'}</td>
|
||||
<td>${p.hasData ? numberWithCommas(p.timeoutCount) : '-'}</td>
|
||||
</tr>`);
|
||||
});
|
||||
} else {
|
||||
tbody.html('<tr class="stats-empty"><td colspan="5">데이터가 없습니다.</td></tr>');
|
||||
}
|
||||
$('#btnDownload').attr('href', downloadUrl);
|
||||
}
|
||||
|
||||
// Number formatting
|
||||
@@ -327,6 +496,11 @@
|
||||
return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
||||
}
|
||||
|
||||
// 데이터 없음(해당 total 0)일 때: 건수는 0(그대로) 표기, 비율(%)만 '-' 로 표기
|
||||
function dashRate(total, val) {
|
||||
return (!total || total == 0) ? '-' : val;
|
||||
}
|
||||
|
||||
// 세션 기반 CSRF: 쿠키 대신 <meta name="_csrf">에서 토큰을 읽는다.
|
||||
function getCsrfToken() {
|
||||
var meta = document.querySelector('meta[name="_csrf"]');
|
||||
@@ -335,85 +509,115 @@
|
||||
|
||||
// Validate date range (max 40 days)
|
||||
function validateDateRange(startDate, endDate) {
|
||||
if (!startDate || !endDate) {
|
||||
return { valid: false, message: '시작일과 종료일을 입력해주세요.' };
|
||||
}
|
||||
var start = new Date(startDate);
|
||||
var end = new Date(endDate);
|
||||
if (start > end) {
|
||||
return { valid: false, message: '시작일이 종료일보다 늦을 수 없습니다.' };
|
||||
}
|
||||
if (!startDate || !endDate) return { valid: false, message: '시작일과 종료일을 입력해주세요.' };
|
||||
var start = new Date(startDate), end = new Date(endDate);
|
||||
if (start > end) return { valid: false, message: '시작일이 종료일보다 늦을 수 없습니다.' };
|
||||
var diffDays = Math.floor((end - start) / (1000 * 60 * 60 * 24));
|
||||
if (diffDays > MAX_DATE_RANGE_DAYS) {
|
||||
return { valid: false, message: '조회 기간은 최대 ' + MAX_DATE_RANGE_DAYS + '일까지 가능합니다.' };
|
||||
}
|
||||
if (diffDays > MAX_DATE_RANGE_DAYS) return { valid: false, message: '조회 기간은 최대 ' + MAX_DATE_RANGE_DAYS + '일까지 가능합니다.' };
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
function buildPayload() {
|
||||
return {
|
||||
mode: currentMode,
|
||||
startDate: $('#startDate').val(),
|
||||
endDate: $('#endDate').val(),
|
||||
month: $('#month').val() || null,
|
||||
clientId: selectedClientId || null
|
||||
};
|
||||
}
|
||||
|
||||
// ── 조회 파라미터 sessionStorage 유지 (일별/월별 모드·날짜·월·앱 선택) ──
|
||||
var STATS_PARAM_KEY = 'apiStatsParams';
|
||||
function saveParams() {
|
||||
try {
|
||||
sessionStorage.setItem(STATS_PARAM_KEY, JSON.stringify({
|
||||
mode: currentMode,
|
||||
clientId: selectedClientId || '',
|
||||
startDate: $('#startDate').val(),
|
||||
endDate: $('#endDate').val(),
|
||||
month: $('#month').val() || ''
|
||||
}));
|
||||
} catch (e) {}
|
||||
}
|
||||
function restoreParams() {
|
||||
try {
|
||||
var raw = sessionStorage.getItem(STATS_PARAM_KEY);
|
||||
if (!raw) return false;
|
||||
var p = JSON.parse(raw);
|
||||
if (p.mode) {
|
||||
currentMode = p.mode;
|
||||
$('.mode-tab').removeClass('active');
|
||||
$('.mode-tab[data-mode="' + p.mode + '"]').addClass('active');
|
||||
if (p.mode === 'MONTHLY') { $('#dailyFilter').hide(); $('#monthlyFilter').show(); }
|
||||
else { $('#monthlyFilter').hide(); $('#dailyFilter').show(); }
|
||||
}
|
||||
if (p.startDate) $('#startDate').val(p.startDate);
|
||||
if (p.endDate) $('#endDate').val(p.endDate);
|
||||
if (p.month) $('#month').val(p.month);
|
||||
if (p.clientId !== undefined && p.clientId !== null) { selectedClientId = p.clientId; $('#appSelect').val(p.clientId); }
|
||||
return true;
|
||||
} catch (e) { return false; }
|
||||
}
|
||||
|
||||
// Search Statistics
|
||||
function searchStatistics() {
|
||||
const startDate = $('#startDate').val();
|
||||
const endDate = $('#endDate').val();
|
||||
|
||||
// Client-side validation
|
||||
var validation = validateDateRange(startDate, endDate);
|
||||
if (!validation.valid) {
|
||||
alert(validation.message);
|
||||
return;
|
||||
if (currentMode === 'MONTHLY') {
|
||||
if (!$('#month').val()) { alert('조회할 월을 선택해주세요.'); return; }
|
||||
} else {
|
||||
var v = validateDateRange($('#startDate').val(), $('#endDate').val());
|
||||
if (!v.valid) { alert(v.message); return; }
|
||||
}
|
||||
saveParams();
|
||||
|
||||
$.ajax({
|
||||
url: '/statistics/api/search',
|
||||
type: 'POST',
|
||||
contentType: 'application/json',
|
||||
beforeSend: function(xhr) {
|
||||
xhr.setRequestHeader('X-XSRF-TOKEN', getCsrfToken());
|
||||
},
|
||||
data: JSON.stringify({
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
clientId: selectedClientId || null
|
||||
}),
|
||||
beforeSend: function(xhr) { xhr.setRequestHeader('X-XSRF-TOKEN', getCsrfToken()); },
|
||||
data: JSON.stringify(buildPayload()),
|
||||
success: function(data) {
|
||||
currentData = data;
|
||||
updateSummaryDisplay();
|
||||
updateChart();
|
||||
updateTable();
|
||||
updateDownloadLink();
|
||||
updatePeriodTable();
|
||||
},
|
||||
error: function(xhr, status, error) {
|
||||
console.error('Search failed:', error);
|
||||
error: function(xhr) {
|
||||
var errorMsg = '통계 조회에 실패했습니다.';
|
||||
if (xhr.responseJSON && xhr.responseJSON.error) {
|
||||
errorMsg = xhr.responseJSON.error;
|
||||
}
|
||||
if (xhr.responseJSON && xhr.responseJSON.error) errorMsg = xhr.responseJSON.error;
|
||||
alert(errorMsg);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Event Handlers
|
||||
$('#btnSearch').on('click', function() {
|
||||
searchStatistics();
|
||||
});
|
||||
|
||||
// App selection change
|
||||
$('#appSelect').on('change', function() {
|
||||
selectedClientId = $(this).val();
|
||||
searchStatistics();
|
||||
});
|
||||
|
||||
// Enter key search
|
||||
$('#startDate, #endDate').on('keypress', function(e) {
|
||||
if (e.which === 13) {
|
||||
searchStatistics();
|
||||
// ── 모드 토글 ──
|
||||
$('.mode-tab').on('click', function() {
|
||||
currentMode = $(this).data('mode');
|
||||
$('.mode-tab').removeClass('active');
|
||||
$(this).addClass('active');
|
||||
// 하단 기간표는 월별 조회에서도 '일별'로 표기하므로 라벨은 항상 일자 유지
|
||||
if (currentMode === 'MONTHLY') {
|
||||
$('#dailyFilter').hide();
|
||||
$('#monthlyFilter').show();
|
||||
} else {
|
||||
$('#monthlyFilter').hide();
|
||||
$('#dailyFilter').show();
|
||||
}
|
||||
});
|
||||
|
||||
// Date change auto search
|
||||
$('#startDate, #endDate').on('change', function() {
|
||||
searchStatistics();
|
||||
});
|
||||
|
||||
// Event Handlers
|
||||
$('#btnSearch').on('click', searchStatistics);
|
||||
$('#appSelect').on('change', function() { selectedClientId = $(this).val(); searchStatistics(); });
|
||||
$('#startDate, #endDate').on('change', searchStatistics);
|
||||
$('#month').on('change', searchStatistics);
|
||||
$('#startDate, #endDate').on('keypress', function(e) { if (e.which === 13) searchStatistics(); });
|
||||
|
||||
// 저장된 조회 파라미터가 있으면 복원 후 재조회 (없으면 서버 초기 데이터 유지)
|
||||
if (restoreParams()) {
|
||||
searchStatistics();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</th:block>
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
<option>DJBank 모바일뱅킹</option>
|
||||
</select>
|
||||
</div>
|
||||
<p class="footer-contact">고객센터 1588-3388</p>
|
||||
<p class="footer-contact" th:text="'고객센터 ' + ${customerCenterContact}">고객센터 1588-3388</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,23 @@
|
||||
<meta content="https://www.eactive.co.kr/" property="og:url"/>
|
||||
<meta charset="UTF-8"/>
|
||||
|
||||
<!-- Favicon (DJB 로고) -->
|
||||
<link rel="icon" type="image/png" sizes="32x32" th:href="@{/img/favicon/favicon-32x32.png}"/>
|
||||
<link rel="icon" type="image/png" sizes="16x16" th:href="@{/img/favicon/favicon-16x16.png}"/>
|
||||
<link rel="shortcut icon" type="image/png" th:href="@{/img/favicon/favicon.png}"/>
|
||||
<link rel="apple-touch-icon" sizes="180x180" th:href="@{/img/favicon/apple-touch-icon.png}"/>
|
||||
|
||||
<!-- 클라이언트 가드 (우클릭 차단 · DevTools 감지). PortalProperty 토글이 하나라도 켜지면 로드 -->
|
||||
<th:block th:if="${clientGuardContextmenu or clientGuardDevtools}">
|
||||
<script th:inline="javascript">
|
||||
window.__CLIENT_GUARD__ = {
|
||||
contextmenu: /*[[${clientGuardContextmenu}]]*/ false,
|
||||
devtools: /*[[${clientGuardDevtools}]]*/ false
|
||||
};
|
||||
</script>
|
||||
<script th:src="@{/js/djb/client-guard.js}"></script>
|
||||
</th:block>
|
||||
|
||||
<!-- CSRF 토큰 (세션 기반). 정적 JS/AJAX에서 토큰·헤더명을 읽어 사용한다. -->
|
||||
<meta name="_csrf" th:content="${_csrf != null ? _csrf.token : ''}"/>
|
||||
<meta name="_csrf_header" th:content="${_csrf != null ? _csrf.headerName : 'X-XSRF-TOKEN'}"/>
|
||||
@@ -82,6 +99,12 @@
|
||||
<script th:src="@{/plugins/summernote/summernote-lite.min.js}"></script>
|
||||
<script th:src="@{/plugins/summernote/summernote-cleaner.js}"></script>
|
||||
<script th:src="@{/plugins/jquery-ui/jquery-ui.min.js}"></script>
|
||||
|
||||
<!-- LiveReload (개발 전용): DevTools 가 정적 리소스/템플릿 변경을 감지하면 브라우저를 자동 새로고침한다.
|
||||
localhost 외 IP(예: 172.30.1.14)로 접속해도 동작하도록 접속 호스트 기준으로 livereload.js 를 로드한다.
|
||||
prod/stage 에는 DevTools 자체가 없으므로(developmentOnly) 개발 프로파일에서만 주입한다. -->
|
||||
<script th:if="${@environment.acceptsProfiles('local_rinjaemac','local')}"
|
||||
th:src="|//${#request.serverName}:35729/livereload.js|"></script>
|
||||
</head>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -80,6 +80,11 @@
|
||||
<i class="fas fa-clock session-timer-icon"></i>
|
||||
<span class="session-timer-text" id="sessionRemainingTime">--:--</span>
|
||||
</span>
|
||||
<!-- 세션 유지(타임아웃 무시) 체크박스: 비운영 + property 활성 시에만 렌더 (prod 미노출) -->
|
||||
<label class="session-keepalive" th:if="${sessionKeepAliveAllowed}" title="세션 자동 유지 (비운영 전용)">
|
||||
<input type="checkbox" id="sessionKeepAliveToggle">
|
||||
<span>세션 유지</span>
|
||||
</label>
|
||||
<span class="divider">•</span>
|
||||
<div class="user-identity">
|
||||
<img th:src="@{/img/user_icon.svg}" alt="User" class="user-icon">
|
||||
@@ -283,6 +288,7 @@
|
||||
var countdownTimer = null;
|
||||
var promptShown = false; // 연장 확인 모달 중복 표시 방지
|
||||
var redirecting = false;
|
||||
var keepAliveActive = false; // 세션 유지 체크 시 true → 카운트다운 정지, 무제한(∞) 표시
|
||||
|
||||
function getCookie(name) {
|
||||
var m = document.cookie.match('(^|;)\\s*' + name + '\\s*=\\s*([^;]+)');
|
||||
@@ -310,6 +316,14 @@
|
||||
|
||||
function render() {
|
||||
if (!timerTexts.length) return;
|
||||
// 세션 유지 활성 시 카운트다운 대신 무제한(∞) 표시
|
||||
if (keepAliveActive) {
|
||||
for (var k = 0; k < timerTexts.length; k++) {
|
||||
timerTexts[k].textContent = '∞';
|
||||
timerTexts[k].classList.remove('session-timer-warning');
|
||||
}
|
||||
return;
|
||||
}
|
||||
var label = formatTime(remainingSeconds);
|
||||
var warn = remainingSeconds <= WARNING_SECONDS;
|
||||
for (var i = 0; i < timerTexts.length; i++) {
|
||||
@@ -373,6 +387,11 @@
|
||||
}
|
||||
|
||||
function tick() {
|
||||
// 세션 유지 활성 시 로컬 카운트다운·만료 처리 중단 (heartbeat가 세션 연장)
|
||||
if (keepAliveActive) {
|
||||
render();
|
||||
return;
|
||||
}
|
||||
remainingSeconds--;
|
||||
if (remainingSeconds <= 0) {
|
||||
goTo(EXPIRED_URL);
|
||||
@@ -410,6 +429,44 @@
|
||||
render();
|
||||
countdownTimer = setInterval(tick, 1000);
|
||||
setInterval(pollStatus, POLL_INTERVAL_MS);
|
||||
|
||||
// 세션 유지(타임아웃 무시): 비운영 + DB property 활성 시에만 동작(prod는 서버가 false 주입 → 체크박스도 미렌더).
|
||||
// 체크 시 주기적 heartbeat로 세션을 계속 연장. 탭을 닫으면 heartbeat 중단 → 정상 만료.
|
||||
var KEEPALIVE_ALLOWED = /*[[${sessionKeepAliveAllowed}]]*/ false;
|
||||
var KEEPALIVE_LS_KEY = 'sessionKeepAlive';
|
||||
var KEEPALIVE_INTERVAL_MS = 60000; // 세션 타임아웃보다 충분히 짧게
|
||||
var keepAliveTimer = null;
|
||||
var keepAliveToggle = document.getElementById('sessionKeepAliveToggle');
|
||||
|
||||
function startKeepAlive() {
|
||||
keepAliveActive = true;
|
||||
render(); // 즉시 ∞ 표시
|
||||
if (keepAliveTimer) return;
|
||||
keepAliveTimer = setInterval(extendSession, KEEPALIVE_INTERVAL_MS);
|
||||
extendSession(); // 즉시 1회 연장
|
||||
}
|
||||
function stopKeepAlive() {
|
||||
keepAliveActive = false;
|
||||
if (keepAliveTimer) { clearInterval(keepAliveTimer); keepAliveTimer = null; }
|
||||
render(); // 카운트다운 표시 복귀
|
||||
pollStatus(); // 정지 중 stale 해소: 서버 잔여시간 즉시 재동기화
|
||||
}
|
||||
|
||||
if (KEEPALIVE_ALLOWED && keepAliveToggle) {
|
||||
if (localStorage.getItem(KEEPALIVE_LS_KEY) === 'true') {
|
||||
keepAliveToggle.checked = true;
|
||||
startKeepAlive();
|
||||
}
|
||||
keepAliveToggle.addEventListener('change', function () {
|
||||
if (keepAliveToggle.checked) {
|
||||
localStorage.setItem(KEEPALIVE_LS_KEY, 'true');
|
||||
startKeepAlive();
|
||||
} else {
|
||||
localStorage.removeItem(KEEPALIVE_LS_KEY);
|
||||
stopKeepAlive();
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
|
||||
@@ -56,16 +56,17 @@
|
||||
th:classappend="${activeMenu == 'apiKey'} ? 'service-nav__item--active' : ''"
|
||||
class="service-nav__item">인증 키 관리</a>
|
||||
|
||||
<a th:href="@{/statistics/daily}"
|
||||
th:classappend="${activeMenu == 'statistics'} ? 'service-nav__item--active' : ''"
|
||||
class="service-nav__item">이용통계</a>
|
||||
|
||||
<a th:href="@{/mypage/update_user}"
|
||||
th:classappend="${activeMenu == 'profile'} ? 'service-nav__item--active' : ''"
|
||||
<a th:href="@{/statistics/api}"
|
||||
th:classappend="${activeMenu == 'statistics'} ? 'service-nav__item--active' : ''"
|
||||
class="service-nav__item"
|
||||
sec:authorize="hasRole('ROLE_APP')">이용통계</a>
|
||||
|
||||
<a th:href="@{/mypage}"
|
||||
th:classappend="${activeMenu == 'profile'} ? 'service-nav__item--active' : ''"
|
||||
class="service-nav__item">내정보 관리</a>
|
||||
|
||||
<a th:href="@{/mypage/password}"
|
||||
th:classappend="${activeMenu == 'password'} ? 'service-nav__item--active' : ''"
|
||||
|
||||
<a th:href="@{/change_password}"
|
||||
th:classappend="${activeMenu == 'password'} ? 'service-nav__item--active' : ''"
|
||||
class="service-nav__item">비밀번호 변경</a>
|
||||
</th:block>
|
||||
</nav>
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<weblogic-web-app
|
||||
xmlns="http://xmlns.oracle.com/weblogic/weblogic-web-app"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://xmlns.oracle.com/weblogic/weblogic-web-app http://xmlns.oracle.com/weblogic/weblogic-web-app/1.6/weblogic-web-app.xsd">
|
||||
<weblogic-web-app xmlns="http://xmlns.oracle.com/weblogic/weblogic-web-app">
|
||||
|
||||
<context-root>/</context-root>
|
||||
|
||||
@@ -18,6 +15,7 @@
|
||||
|
||||
<session-descriptor>
|
||||
<timeout-secs>1800</timeout-secs>
|
||||
<cookie-name>JSESSIONID_PORTAL</cookie-name>
|
||||
<persistent-store-type>replicated_if_clustered</persistent-store-type>
|
||||
</session-descriptor>
|
||||
</weblogic-web-app>
|
||||