feat: login slide CMS + G2B RFP doc + 4 report decks + SCR-31 screen
- Login page: 5-image auto-rotation slides (latest-first) with admin CRUD (upload, caption, active toggle, RBAC + audit log), V11 seed migration - docs: G2B-style RFP (74 requirements, tech 90/price 10, prototype demo 32pt) - Deliverables: execution plan (25s), kickoff (13s), interim (15s), final (25s) report decks - Stitch: SCR-31 check-in kiosk screen HTML Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
ba90b54d81
commit
1de3b9b269
475
docs/RFP_나라장터_제안요청서.md
Normal file
475
docs/RFP_나라장터_제안요청서.md
Normal file
@ -0,0 +1,475 @@
|
||||
# 제안요청서 (RFP)
|
||||
|
||||
# 킨텍스 AI 기반 전시운영 자동화 플랫폼 구축
|
||||
|
||||
> **나라장터(국가종합전자조달, g2b) 공고용 제안요청서**
|
||||
> 발주기관: (주)킨텍스(KINTEX, 한국국제전시장) *(가정)*
|
||||
> 작성일: 2026-07-12 · 문서번호: KINTEX-RFP-2026-○○○ *(가안)*
|
||||
> 근거 문서: `docs/PLANNING.md` v3.1(시스템 기획서), `docs/IMPLEMENTATION_BACKLOG.md` v2.0(구현 백로그)
|
||||
> ※ 본 문서에서 "(가안)" 표기 항목은 공고 시 확정한다.
|
||||
|
||||
---
|
||||
|
||||
## 목차
|
||||
|
||||
1. 사업 개요
|
||||
2. 현황 및 문제점
|
||||
3. 사업 범위 (요구사항 총괄)
|
||||
- 3.1 기능 요구사항 (SFR)
|
||||
- 3.2 성능 요구사항 (PER)
|
||||
- 3.3 시스템 장비구성 요구사항 (ECR)
|
||||
- 3.4 인터페이스 요구사항 (SIR)
|
||||
- 3.5 데이터 요구사항 (DAR)
|
||||
- 3.6 테스트 요구사항 (TER)
|
||||
- 3.7 품질 요구사항 (QUR)
|
||||
- 3.8 보안 요구사항 (SER)
|
||||
- 3.9 제약사항 (COR)
|
||||
- 3.10 프로젝트 관리 요구사항 (PMR)
|
||||
- 3.11 프로젝트 지원 요구사항 (PSR)
|
||||
4. 제안서 작성 요령 및 목차 지정
|
||||
5. 평가 기준
|
||||
6. 계약 조건 및 일정
|
||||
7. 보안·준수사항
|
||||
8. 첨부 양식 목록
|
||||
|
||||
---
|
||||
|
||||
# 1. 사업 개요
|
||||
|
||||
## 1.1 사업명
|
||||
|
||||
**킨텍스 AI 기반 전시운영 자동화 플랫폼 구축**
|
||||
(영문: KINTEX Exhibition Automation Platform)
|
||||
|
||||
## 1.2 추진 배경
|
||||
|
||||
- 킨텍스는 총 전시면적 108,011㎡(국내 최대, 2028년 제3전시장 완공 시 178,000㎡)를 운영하나, 전시 준비 실무는 **HWP 서식 다운로드 → 이메일/방문 제출**, **CAD 수작업 배치도 → 홀매니저 육안 검수**, **수기 위치표시도 기반 유틸리티 신청**에 머물러 있음.
|
||||
- 온라인 작업신고 시스템(kxwp)이 존재하나 로그인 기반 서류 업로드 창구 수준이며, 참가업체 유틸리티 신청은 킨텍스 공통 플랫폼 없이 전시회별 주최자 사무국 시스템에 파편화되어 있음.
|
||||
- 배치도·도면·위치표시도 등 공간 데이터가 이미지/수기로만 유통되어 자동 검증·시각화·정산·분석의 원천 데이터가 부재함.
|
||||
- 독립부스 시공은 킨텍스 등록 장치업체(14개 분류 × 739개) 시공이 필수이나, 업체 리스트/엑셀 다운로드 수준으로 견적 비교·경쟁 유도 수단이 없어 업체 선정이 불투명함.
|
||||
- 관람객 등록·배지·리드 데이터가 행사별 주최자 사무국에 파편화되어 경영분석·재방문 유치에 활용되지 못함.
|
||||
|
||||
## 1.3 사업 목적
|
||||
|
||||
- 전시장 운영 전 과정(홀 배정 → 부스 배치 → 장치공사 설계 → 전기/조명·네트워크 배선 → 발주 → 반입/반출 → 관람 → 정산·분석)을 **AI 기반으로 자동 설계·검증·시각화**하는 통합 플랫폼 구축.
|
||||
- **생성형 AI 이미지 파이프라인**으로 시공 전에 "공사 후 결과 사진" 수준의 시각화를 제공하여, 도면을 읽지 못하는 주최자·참가업체도 의사결정 가능하게 함 ("신청서를 내는 순간, 시공 후 사진을 먼저 본다").
|
||||
- AI 설계 산출물(배치도·설계안·물량서·시공 예상 이미지)을 근거로 등록업체가 견적서로 응찰하는 **공사/장치 역경매(옥션)** 를 도입, 시공 발주의 가격 최적화·투명화 실현.
|
||||
- 관람객 등록·배지·체크인·리드캡처와 경영분석(BI)까지 단일 데이터·계정 체계로 통합, "설계→시각화→발주→시공→운영→분석" 폐루프 완성.
|
||||
|
||||
## 1.4 사업 기간
|
||||
|
||||
- **계약일로부터 12개월** (가안)
|
||||
- 무상 하자보수: 검사 완료(최종 인수) 후 **12개월** 별도
|
||||
|
||||
## 1.5 사업 예산
|
||||
|
||||
- **금 ○○억 원 (부가세 포함)** (가안)
|
||||
- ※ **예산 미정 — 공고 시 확정**. 제안 가격은 부가세 포함 총액으로 제출.
|
||||
|
||||
## 1.6 발주기관 및 계약 방식
|
||||
|
||||
| 구분 | 내용 |
|
||||
|---|---|
|
||||
| 발주기관 | (주)킨텍스 *(가정)* |
|
||||
| 입찰 방식 | 일반경쟁입찰, **협상에 의한 계약** (국가계약법 시행령 제43조 준용) |
|
||||
| 공고 매체 | 나라장터(g2b.go.kr) *(가안)* |
|
||||
| 계약 형태 | 총액 확정 계약 |
|
||||
|
||||
---
|
||||
|
||||
# 2. 현황 및 문제점
|
||||
|
||||
## 2.1 현행 업무 프로세스 (As-Is)
|
||||
|
||||
| 시점 | 현행 프로세스 | 매체 | 문제점 |
|
||||
|---|---|---|---|
|
||||
| D-150 ~ | 전시홀 배정신청서 제출 → 배정협의 → 견적 | HWP 양식, 방문/이메일 | 가용성 실시간 조회 불가, 견적 회신 수일 소요 |
|
||||
| 계약 | 계약금 20% → 중도금 50% → 잔금 30% + 예치금 15~20% | 공문+계약서 | 납부 일정 수동 관리 |
|
||||
| D-30 | 홀매니저 사전업무협의(장치·홍보·보안) | 대면/유선 | 협의 이력 비정형, 담당자 의존 |
|
||||
| D-25 내외 | 참가업체 유틸리티 신청 마감(전기·인터넷·급배수 등) | 행사별 주최자 사무국 시스템 | 행사마다 신청 채널 상이, 누락 빈발, 위치표시도 수기 작도 |
|
||||
| D-7 | 신고서류 7종+ 일괄 제출(운영계획서·부스배치도·재해대처계획서 등) | kxwp 업로드 | 마감 집중, 육안 검수 병목 |
|
||||
| 행사 중 | 반입/반출 화물차 순번제·현장 대기열 | 현장 통제 | 슬롯 예약 체계 부재 |
|
||||
| 행사 후 | 전기사용료·폐기물비 등 예치금 정산 | 세금계산서 | 정산 근거 불투명 |
|
||||
|
||||
## 2.2 주요 문제점 요약
|
||||
|
||||
1. **수작업 부스 배치·견적**: 배치도 CAD 수작업 수일~수주, 견적 회신 수일. 규정(부스 높이 5m, 리깅 6.5~8.5m, 홀별 바닥하중 2~5t/㎡, 방염 등)이 수치로 명확함에도 검증은 육안.
|
||||
2. **서류 반복 작성**: D-7 신고서류 7종 이상을 HWP로 수기 작성, 마일스톤(D-150/D-30/D-25/D-7) 추적 체계 부재.
|
||||
3. **업체 선정 불투명**: 등록업체 739개 리스트를 엑셀로 받아 개별 접촉·수기 견적 취합. 동일 사양 기준 견적 비교·경쟁 수단 없음.
|
||||
4. **시공 결과 사전 확인 불가**: 참가업체는 시공 결과를 개장일에 처음 봄. 조감도는 외주 산출물로 배선·조명 미반영.
|
||||
5. **관람객 데이터 미활용**: 등록·체크인·리드 데이터가 파편화되어 참가업체 ROI 측정·재방문 유치·경영분석에 활용 불가.
|
||||
6. **공간 데이터 비구조화**: 배치도·배선·위치표시도가 이미지/수기로 유통되어 자동화의 원천 데이터 부재.
|
||||
|
||||
## 2.3 기대 효과 (To-Be)
|
||||
|
||||
| 항목 | As-Is | To-Be |
|
||||
|---|---|---|
|
||||
| 홀 배정 견적 회신 | 수일 | 요율 룰 엔진 기반 즉시 자동 견적 |
|
||||
| 부스 배치도 초안 | CAD 수작업 수일~수주 | 조건 입력 후 수 분 내 3안 자동 생성·병합 |
|
||||
| 도면 규정 검수 | 육안 검토 | 위반 자동 플래깅 후 사람 확정 |
|
||||
| 유틸리티 위치표시도 | 수기 작도 | 좌표 클릭 → 자동 배선안 + 자동 견적 + 위치표시도 자동 생성 |
|
||||
| 시공 결과 예측 | 불가 | AI 생성 표준 샷 세트(정면/야간/통로/조감 등) 사전 제공 |
|
||||
| 시공 발주 | 개별 접촉·수기 견적 | AI 자료 기반 역경매 옥션·견적서 비교·낙찰 |
|
||||
|
||||
---
|
||||
|
||||
# 3. 사업 범위 (요구사항 총괄)
|
||||
|
||||
## 3.0 시스템 개요 및 요구사항 총괄표
|
||||
|
||||
### 3.0.1 대상 시스템 구성
|
||||
|
||||
- **역할별 웹 포털 6종**: 주최자 콘솔(organizer) · 참가업체 포털(exhibitor) · 장치/공사업체 포털(contractor) · 킨텍스 운영 대시보드(ops) · 관리자 백오피스(admin) · 공개 홍보 사이트/관람객 웹(public)
|
||||
- **모바일 앱 2타깃(단일 코드베이스)**: 운영 앱(B2B, 사내 QR/APK 배포) · 관람객 앱(B2C, 스토어 공개)
|
||||
- **백엔드 공통 플랫폼**: 인증(JWT+2FA OTP)·룰 엔진·배치/배선 엔진(공간 SQL)·옥션 엔진·BI 집계·CMS·비동기 작업 큐·AI 이미지 생성 워커
|
||||
|
||||
### 3.0.2 요구사항 총괄표
|
||||
|
||||
| 분류 | 코드 | 건수 | 비고 |
|
||||
|---|---|---|---|
|
||||
| 기능 요구사항 | SFR-001 ~ SFR-036 | 36 | 모듈 M1~M18 + 공통 업무기능 + 포털/모바일 + AI 플랫폼 공통 |
|
||||
| 성능 요구사항 | PER-001 ~ PER-006 | 6 | 동시사용자·응답시간·이미지 생성 큐 |
|
||||
| 시스템 장비구성 | ECR-001 ~ ECR-004 | 4 | 서버·DB·스토리지·이중화 |
|
||||
| 인터페이스 요구사항 | SIR-001 ~ SIR-007 | 7 | PG·이미지 생성 API·CAD/JPG·kxwp 등 |
|
||||
| 데이터 요구사항 | DAR-001 ~ DAR-006 | 6 | 공간데이터·마스터데이터·개인정보 |
|
||||
| 테스트 요구사항 | TER-001 ~ TER-004 | 4 | 단위·통합·성능·보안 |
|
||||
| 품질 요구사항 | QUR-001 ~ QUR-004 | 4 | 표준·문서화·유지보수성 |
|
||||
| 보안 요구사항 | SER-001 ~ SER-008 | 8 | 2FA·RBAC·감사·암호화 |
|
||||
| 제약사항 | COR-001 ~ COR-006 | 6 | 기술스택·법규·데이터 확보 |
|
||||
| 프로젝트 관리 | PMR-001 ~ PMR-005 | 5 | 방법론·조직·보고 |
|
||||
| 프로젝트 지원 | PSR-001 ~ PSR-004 | 4 | 하자보수·교육·이관 |
|
||||
|
||||
---
|
||||
|
||||
## 3.1 기능 요구사항 (SFR)
|
||||
|
||||
> 우선순위: **P0** = 핵심 차별화(필수·선행) / **P1** = 운영 핵심(필수) / **P2** = 확장(구축 범위 내 기본 기능 구현, 고도화는 협의)
|
||||
|
||||
### 3.1.1 공통/시스템관리 기능군 (전 모듈 선행 기반)
|
||||
|
||||
| ID | 요구사항 명칭 | 상세 내용 | 우선순위 |
|
||||
|---|---|---|---|
|
||||
| SFR-001 | 통합 인증 (JWT + 2차 인증 OTP) | 단일 SSO(JWT) 기반 통합 로그인. **2차 인증 OTP(TOTP, RFC 6238: SHA1·30초·6자리·±1윈도우)** — 최초 QR 등록, 로그인 2단계(비밀번호→OTP), 마이페이지 재설정/해제, 관리자 OTP 초기화. 업무 사용자(주최자·참가업체·공사업체·직원·관리자) 2FA **필수**, 일반 관람객 미강제(선택). 로그인 실패 잠금 + 관리자 해제. 관리자(admin) 초기 비밀번호는 환경변수 암호화 주입(하드코딩 시드 금지) | P1 |
|
||||
| SFR-002 | 역할 기반 권한 관리 (RBAC) | 6역할(주최자·참가업체·장치/공사업체·홀매니저/운영·관리자·관람객/일반대중) + **행사(Event) 단위 워크스페이스 RBAC** 이중 평가. 주최자=행사 owner, 참가업체=부스 단위 멤버, 업체=초대(등록업체 검증), 홀매니저=행사 전체 열람/승인. 역할·메뉴·API 단위 권한 게이트 | P1 |
|
||||
| SFR-003 | 회원가입·계정 체계 | **단일 통합 계정 + 가입 트랙 분리**: 업무 트랙(승인/초대 기반 가입 + 2FA 필수) / 관람객 트랙(이메일·소셜 간편가입 + 게스트 예매 허용). 관람객→바이어/참가업체 담당자 **계정 승격 시 단일 계정 등급 상향**(리드·매칭·재방문 이력 연속성 유지, 계정 재생성 없음). 게스트 예매 이력의 사후 가입 병합 | P1 |
|
||||
| SFR-004 | 시스템관리 (사용자·코드·메뉴) | 사용자 관리(CRUD·상태·소속), 역할/권한 관리, 공통코드 관리(홀·부스유형·공종 14분류·유틸리티 요금코드 등 도메인 코드 포함), 메뉴 관리(역할별 포털 메뉴 트리·권한 매핑), 시스템설정(룰셋 버전·AI 게이트·마감 정책 등) | P1 |
|
||||
| SFR-005 | 감사로그 | 로그인·승인·**낙찰**·설계 변경·룰셋 개정·리드(개인정보) 접근 등 주요 행위 전수 기록. 행위자·일시·대상·전후 값. 관리자 조회·검색·기간 필터·내보내기 | P1 |
|
||||
| SFR-006 | 공통 업무기능 — 업무일지·일정 | 업무일지(일일 일지·댓글 — 홀매니저 현장 일지·업체 시공 일지 활용), 일정 관리(개인/부서 캘린더 + **행사 마일스톤(D-150/D-30/D-25/D-7)·옥션 마감·반입 슬롯 통합 뷰**) | P1 |
|
||||
| SFR-007 | 공통 업무기능 — 쪽지·공지·의견 | 쪽지(주최자↔참가업체↔업체↔홀매니저 행사 내 커뮤니케이션), 공지(게시·팝업, 행사 공지 — CMS 채널과 경계 정합), 의견접수(고객의소리·규정 문의) | P1 |
|
||||
| SFR-008 | 공통 업무기능 — 통합검색·회의록·업무보고 | 통합검색(행사·부스·업체·문서·콘텐츠 크로스 모듈 검색), 회의록(**회의 음성 녹음 → STT(음성인식) → 회의록 자동 작성 → PDF 출력** — 사전업무협의(D-30) 회의록 자동화 적용), 업무보고·통계(일/주/월/분기/연 기간별 집계 보고서·**PDF 리포트 출력**) | P1 |
|
||||
| SFR-009 | 공통 업무기능 — 알림센터·마이페이지 | 통합 알림센터(마감 리마인더(D-데이 역산)·낙찰·승인·결제 알림, 웹 실시간 푸시(WebSocket)·모바일 푸시), 마이페이지(프로필·OTP 관리·알림 설정·즐겨찾기·최근 방문 개인화) | P1 |
|
||||
|
||||
### 3.1.2 부스 설계·시각화 코어 기능군 (P0 — 핵심 차별화)
|
||||
|
||||
| ID | 요구사항 명칭 | 상세 내용 | 우선순위 |
|
||||
|---|---|---|---|
|
||||
| SFR-010 | M1. 행사·홀 배정 및 자동 견적 | 행사일정 연동 가용성 캘린더에서 홀/반홀 선택 → 룰 엔진 즉시 견적: 기본요율(전시홀 2,250원/㎡·로비 10,000원/㎡·옥외 2,000원/㎡) × 면적 × 일수 × 성수기(+10%)/비수기(-10%) × 1전시장(+10%) + 초과시간 요금 + 관리비 예치금(15~20%). 배정신청서 웹폼 입력 → 킨텍스 제출 서식 자동 생성. 최종 배정 확정은 킨텍스 내부 의사결정(시스템은 신청+가견적까지) | P1 |
|
||||
| SFR-011 | M2. 플로어플랜 스튜디오 — 부스 배치 자동 생성 | 홀 선택(홀별 실측 규격·바닥하중) + 조건 입력(목표 부스 수·기본/프리미엄 비율·주출입구·무대) → 배치 엔진이 통로 폭·비상구 접근·트렌치 위치를 제약조건으로 **정확히 3안 자동 생성**(제약 충족 솔버+휴리스틱 중심, LLM은 조건 해석 보조). 3안은 서로 다른 최적화 목표(부스 수 최대/동선·가시성/피난·안전) | P0 |
|
||||
| SFR-012 | M2. 배치안 선택·병합·규정 검증 | ① 한 안 선택 또는 ② 여러 안의 구역/블록을 레이어 토글·드래그로 조합 **병합**. 병합 결과 **규정 검증 자동 재실행**(피난 통로·홀별 바닥하중(2~5t/㎡)·비상구·복층 가능 홀 판정·소방 체크리스트). 최종안 버전 기록(선택/병합 출처 추적), 부스별 좌표·번호 확정 → 참가업체 초대 링크 발급. 수동 편집 캔버스(SVG/WebGL) 제공 | P0 |
|
||||
| SFR-013 | M3. 부스 설계 스튜디오 | **조립부스**: 옵션(간판 문구·가구·조명) 웹 선택 → 프리뷰 + AI 예상 사진 즉시 생성. **독립부스**: 크기·업종·전시품·예산 입력 → AI가 레이아웃+파라메트릭 구조(벽체·트러스·사인) **3안 생성** → 선택/병합 → 버전 기록. 기존 도면(PDF/이미지) 업로드 시 비전 모델 치수·구조 추출("참고용 검증" 라벨) | P0 |
|
||||
| SFR-014 | M3. 장치 규정 사전 검증 | 제출 전 자동 플래깅: 부스 높이 5m 이하, 리깅 6.5~8.5m(구조계산서 D-7 필요 플래그), 복층 1/2 이내, 방염 자재 체크리스트, 이격(인접 벽 30cm·천장 60cm), 장내 금지작업(전기톱·용접·페인트) 공정 경고, 조명 반입 금지 규정. 검증 리포트에 룰셋 버전·면책 문구 기록(시스템=사전 필터, 최종 승인=킨텍스·구조기술사) | P0 |
|
||||
| SFR-015 | M4a. 전기·조명 설계 자동화 | 부스 내 기기 목록(전시장비·조명·PC) 입력 → kW 합산 → 분전반 용량·수량 자동 산출 → 최근접 트렌치→분전반 배선 경로 자동 생성(통로 횡단 최소화) → 요금 자동 견적(1kW 55,000원·분전반 50A 100,000원 등 요금 마스터 기반). 조명은 부스 설계 기반 조도 목표별 배치안 제안 + 야간 점등 예상 이미지 연계. 홀 단위 전력 부하 집계(홀매니저) | P0 |
|
||||
| SFR-016 | M4b. 네트워크·급배수·압축공기 배선 자동화 | 부스 도면 위 단말 위치 클릭 → 트렌치 최단 배선 자동 산출 → **위치표시도 자동 생성**(수기 작도 폐지) → 견적·신청·마감 리마인더(D-25 역산)를 단일 화면 처리. "현장 추가신청 불가" 항목(인터넷 등) 신청 누락 방지 알림 | P0 |
|
||||
| SFR-017 | M5. AI 시공 예상 이미지 생성 (생성형 이미지 파이프라인) | M2~M4 구조화 데이터를 씬 스키마로 컴파일 → 참조 이미지(도면/간이 렌더) + 구조화 프롬프트(보존/교체 명시 분리)로 **image-to-image 생성** → "시공 후 사진" 표준 샷 세트: S1 부스 정면·S2 야간 점등·S3 통로 뷰·S4 부스 내부·S5 Before/After 페어·S7 홀 전경(조감). 부스 유형/스타일/조명 레이어 사전(UI 선택값과 프롬프트 단일 출처) 기반 조립. 한글 간판 텍스트 오탈자 자동 검수·실패 시 후처리 합성 | P0 |
|
||||
| SFR-018 | M5. 배선 오버레이(S6)·비동기 렌더 처리 | S6 배선 오버레이는 생성형이 아닌 **백엔드 래스터 합성**(공간 지오메트리를 전기 적·네트워크 청·급배수 녹으로 정확 오버레이 — 좌표 정확성 보장). 렌더 작업(RenderJob)은 전면 비동기: 큐 발행 → 워커 생성 → 오브젝트 스토리지 적재 → WebSocket 완료 푸시. 단계별 로딩 UX·Before/After 비교 슬라이더. 크기 가드·에러 분기(키/쿼터/세이프티)·**성공 시에만 쿼터 차감**·동일 스키마 해시 캐시·행사별 생성 쿼터 관리 | P0 |
|
||||
| SFR-019 | M5. AI 이미지 워터마크·고지 (필수 불변) | 모든 생성 이미지에 시각 워터마크("AI 생성 예상 이미지 — 실제 시공 결과와 다를 수 있음") + 메타데이터(생성일·스키마 해시·모델 버전) 임베드. **계약·심사 서류에는 생성 이미지 자동 배제**(도면만 유효). 컨펌 화면 "시공 기준은 도면" 동의 체크 | P0 |
|
||||
|
||||
### 3.1.3 판매·발주·정산 기능군
|
||||
|
||||
| ID | 요구사항 명칭 | 상세 내용 | 우선순위 |
|
||||
|---|---|---|---|
|
||||
| SFR-020 | M6. 서류·마일스톤 워크플로 | 행사 생성 시 D-150(배정)/D-30(사전협의)/D-25(유틸리티)/D-7(신고서류) 마일스톤 자동 생성·역산 알림. 신고서류 7종+(행사운영계획서·부스배치도·재해대처계획서·방화관리 책임서약서·주차관리 신청서·보안요원 배치계획·위험물 반입신고서·리깅 구조계산서)를 웹폼 → HWP/PDF 자동 생성(킨텍스 제출 형식 유지). AI 서류 검수(누락 항목·문서 간 불일치·필수 요소 체크) → 홀매니저 검수 요약 리포트. kxwp 제출은 파일 생성+업로드 안내 릴레이(직접 연동은 SIR-006) | P1 |
|
||||
| SFR-021 | M7. 등록업체 매칭·검증 | 등록업체 DB(14개 분류 × 739개) 관리·검색·추천(부스 규모·업종·예산·지역 기반). 설계안 첨부 견적요청(RFQ) 복수 발송·비교. **미등록 업체 시공 엄금 규정의 시스템 강제**(미등록 업체 초대·응찰 원천 차단) | P1 |
|
||||
| SFR-022 | M15. 공사/장치 옥션(역경매) — 자료 열람·응찰 | 참가업체/주최자가 옥션 개설 → 초대(또는 공개)된 **킨텍스 등록업체만** AI 생성 자료 패키지(M2 배치도·M3 설계안·M4 배선/물량서(BOQ)·M5 시공 예상 이미지+사양서) 열람 → **정식 견적서(Quotation) 제출로 응찰**. 견적서 = 라인아이템(공종·자재·수량·단가·금액)·총액·부가세·납기·유효기간·조건·첨부, **PDF 산출·버전 관리**(라운드 내 재응찰 이력 보존) | P1 |
|
||||
| SFR-023 | M15. 옥션 메커니즘·낙찰 | 옥션 유형 설정형: 기본 **역경매**(라운드/마감 내 재응찰) / 단일 라운드 RFQ / 고정가 비교. 응찰 라운드·마감 타이머·**실시간 순위**(현재 순위·최저가·내 위치, 익명 옵션) WebSocket 노출, 라운드 종료 자동 마감. 낙찰 기준: 최저가 또는 **종합평가**(가격+평판+납기 가중 스코어, 개설 시 설정) — 항목별 비교표 제공. **낙찰(Award)** → 낙찰 견적서의 계약/발주 문서 전환(M6·M9 연동)·시공 일정 연계. 전 낙찰 행위 감사로그 | P1 |
|
||||
| SFR-024 | M8. 반입/반출 물류 슬롯 | 하역장·화물출입구 슬롯 예약제, 중량물(5t 이상) 우선순위 자동 배치, 통행증 QR 발급, 지게차 사전신청 연동, 철거일 피크 대기열 시뮬레이션 | P2 |
|
||||
| SFR-025 | M9. 정산·결제 | 납부 스케줄 자동 생성·알림(계약금 20%→중도금→잔금+예치금), 유틸리티 신청 건 PG 온라인 결제(SIR-001), 행사 후 실사용(전기 검침 등) 대비 예치금 정산 내역 투명화, 취소·환불 규정 다구간 수수료 서버 권위 산출 | P1 |
|
||||
|
||||
### 3.1.4 관람객·마케팅 기능군
|
||||
|
||||
| ID | 요구사항 명칭 | 상세 내용 | 우선순위 |
|
||||
|---|---|---|---|
|
||||
| SFR-026 | M10. 관람객 등록·티켓·배지·체크인·리드캡처 | 간편가입(이메일/소셜) 또는 **게스트 예매**(가입 없이 티켓 구매) → 사전등록(관람객/바이어 유형별 폼) → **모바일 배지/QR 발급** → 현장 QR 체크인(즉석 배지 인쇄·오프라인 폴백) → 실시간 입장 집계. 참가업체 **리드캡처**: 운영 앱으로 배지 QR 스캔 → 연락처·관심도 평점·메모 저장 → 팔로업 EDM 연계. 부정입장 방지(디바이스 바인딩/회전 QR)는 고도화 제안 항목 | P1 |
|
||||
| SFR-027 | M12. 마케팅·EDM·공개 홍보 사이트 | **공개 홍보 사이트**(불특정 다수): 행사 소개·일정·교통·사전등록 유도, **SEO(SSR/정적 생성·메타·사이트맵·구조화 데이터)·다국어(한/영/중/일)·hreflang**, 공개 인터랙티브 플로어플랜. **EDM/마케팅 자동화**: 세그먼트별(사전등록자·과거 관람객·바이어) 캠페인·리마인더·리드 팔로업, 수신동의 관리(정보통신망법 준수), AI 카피 초안·AI 예상샷 활용 | P1 |
|
||||
| SFR-028 | M11. 비즈니스 매칭 | 관람객/참가업체 프로필·관심 업종·의향 기반 AI 미팅 추천 → 미팅 슬롯 예약·일정 관리 → 부스 위치·길안내 연계. 세션 정원·마감·대기 처리 | P2 |
|
||||
| SFR-029 | M13. Wayfinding(실내 길안내) | M2 실측 플로어플랜 기반 부스·시설(비상구·화장실) 검색·경로 안내. 초기 **지도 기반 존-레벨 길안내(측위 하드웨어 무의존)** 구현, 정밀 측위(BLE/UWB)는 인프라 협의 시 확장 구조로 설계 | P2 |
|
||||
| SFR-030 | M14. 현장운영 대시보드 | 홀매니저용: 입장·혼잡 실시간(체크인 데이터), 홀 단위 전력 부하 집계, 주차 점유(외부 연계), 안전 체크(규정 위반 신고·소음·금지작업 플래그), 이상 시 알림 | P2 |
|
||||
|
||||
### 3.1.5 경영·콘텐츠·관리 기능군
|
||||
|
||||
| ID | 요구사항 명칭 | 상세 내용 | 우선순위 |
|
||||
|---|---|---|---|
|
||||
| SFR-031 | M16. 경영분석 BI | **운영사(킨텍스) 관점**: 홀·기간별 가동률(반홀 분할·성수기 구분), 매출 구성(임대+유틸리티+부대), 행사별 P&L·마진, 전시장별 ROI(RevPAD·㎡당 수익), 참가사 리텐션·LTV, 수요예측·수율/가격 시뮬레이션(요율·계수), 경영진 KPI 대시보드(목표 대비·드릴다운·내보내기). **참가업체 관점 ROI**(리드 수·품질·비용 대비 성과)와 대시보드·권한 분리. 집계는 BI 데이터마트(스타 스키마)/스냅샷 배치로 운영 DB 부하 회피 | P1 |
|
||||
| SFR-032 | M17. CMS(콘텐츠 관리) | 전시 콘텐츠·공지 관리(게시 워크플로: 초안→검수→게시, 버전 관리), **참가업체 마이크로사이트**(부스 소개·제품·AI 예상샷 게시), 다국어(한/영/중/일) 콘텐츠, 배너/프로모션, 디지털 사이니지 연계 콘텐츠 배포(확장 구조) | P1 |
|
||||
| SFR-033 | M18. 관리자 백오피스 | 웹 전용 별도 앱(admin). 시스템관리(SFR-004~005 통합) + **킨텍스 마스터데이터 관리**: 홀 마스터(규격·하중·트렌치)·요율표·유틸리티 요금·규정 룰셋(**버전 관리** — 연 단위 개정 무중단 반영)·등록업체 DB·표준 단가. **웹 주요 이미지 교체(이미지 슬롯 관리)**: 공개 사이트·포털의 메인 히어로·배너·주요 페이지 이미지를 관리자 화면에서 슬롯 단위로 업로드·교체·미리보기·롤백(개발자 배포 없이 운영자가 이미지 변경) | P1 |
|
||||
| SFR-034 | 역할별 포털·모바일 앱 | 웹 포털 6종(§3.0.1) — 역할별 번들 분리(최소권한·공격면 축소), 공유 디자인 시스템·컴포넌트·API 계약 상속, 반응형(데스크톱=설계/에디터/대시보드, 모바일 웹=조회/승인). **모바일 앱 단일 크로스플랫폼 코드베이스·2배포 타깃**: ① 운영 앱(B2B — 현장 체크리스트·검수·승인·리드캡처, 스토어 미공개) ② 관람객 앱(B2C — 티켓 지갑·배지/QR·wayfinding·매칭, 스토어 공개). 역할/기능별 진입을 분기하는 **통합 런처 구조**로 구성. 운영 앱은 **관리자 화면 기반 QR 배포 체계**(앱 패키지 업로드 → QR 자동 생성 → 다운로드 랜딩 페이지, 스토어 미경유 사내 배포) 필수. 푸시 알림·오프라인 대비 포함 | P1 |
|
||||
|
||||
### 3.1.6 AI 플랫폼 공통 기능군
|
||||
|
||||
| ID | 요구사항 명칭 | 상세 내용 | 우선순위 |
|
||||
|---|---|---|---|
|
||||
| SFR-035 | 하이브리드 AI 아키텍처 (온프레미스 sLLM + 상용 LLM API) | AI 텍스트 기능(배치 조건 해석·서류 검수·규정 문의 챗봇·EDM 카피 초안 등)은 **상용 LLM API와 온프레미스 sLLM을 병용하는 하이브리드 아키텍처**로 구현: ① 관리자 화면에서 AI 제공자/모델을 설정형으로 전환(무배포 변경) ② 상용 API 장애·쿼터 초과 시 **온프레미스 모델 자동 폴백 체인** ③ 개인정보·영업비밀(부스 설계 등) 포함 요청의 **외부 전송 차단 정책**(민감 데이터는 온프레미스 경로 처리). API 키는 서버 환경변수 관리(SER-005) | P1 |
|
||||
| SFR-036 | RAG 기반 근거 제시형 AI 응답 (환각 차단) | 규정집·매뉴얼·룰셋·공지 등 내부 문서를 벡터DB에 임베딩·검색(RAG)하여 AI 응답에 **근거 문서·인용 출처를 함께 제시**. 근거 부족 시 임의 생성 대신 **답변 회피(abstain)** 처리로 환각(할루시네이션) 차단. 답변 신뢰도 표시, 사용자 피드백 수집 구조. 규정 문의 챗봇·AI 서류 검수 사유 제시(SFR-020)에 적용 | P1 |
|
||||
|
||||
> **비고(범위 한정)**: ① 임대계약의 법적 전자계약 체결, ② 구조계산서의 구조 안전성 판정 자체(체크·누락 검출까지만), ③ 정밀 측위 하드웨어(비콘) 구축은 본 사업 범위에서 제외한다. 다중 전시관(멀티테넌트) 확장은 **테넌트 격리 가능 구조(데이터 모델·권한 계층)로 설계**하되, 타 전시관 실 온보딩은 본 사업 범위 외(COR-006).
|
||||
|
||||
## 3.2 성능 요구사항 (PER)
|
||||
|
||||
| ID | 요구사항 명칭 | 상세 내용 |
|
||||
|---|---|---|
|
||||
| PER-001 | 동시 사용자 | 행사 피크(대형 행사 개장일) 기준 **동시 접속 2,000명 이상**(가안) 무중단 처리. 관람객 사전등록·체크인 피크 **초당 50건 이상**(가안) 처리 |
|
||||
| PER-002 | 온라인 응답시간 | 일반 조회·트랜잭션 화면 응답 **3초 이내(95percentile)**, 단순 API 1초 이내(가안). 플로어플랜 캔버스 초기 로딩 5초 이내 |
|
||||
| PER-003 | 배치·배선 엔진 처리 | 부스 배치 3안 생성: 홀당 부스 200~600개 기준 **5분 이내**(가안). 배선 경로 산출·규정 검증: 요청당 10초 이내(가안) |
|
||||
| PER-004 | AI 이미지 생성 큐 | 렌더 작업은 전면 비동기 큐 처리(동기 대기 금지). 표준 샷 1장 평균 60초 내외 완료(외부 API 지연 제외, 가안), 큐 상태·진행률 실시간 표시. 자동 생성은 핵심 샷(S1·S7) 한정 + 온디맨드, 동일 입력 해시 캐시로 중복 생성 차단, 행사별 쿼터 상한 |
|
||||
| PER-005 | 가용성 | 서비스 가동률 **99.5% 이상**(계획 정지 제외, 가안). 행사 기간 중 무중단 운영 원칙, 점검은 사전 공지 |
|
||||
| PER-006 | 확장성 | 사용자·행사 수 증가 대비 수평 확장 가능 구조(무상태 API·큐 기반 워커 증설). 제3전시장(2028, 홀11~18) 홀 마스터 확장을 데이터 등록만으로 수용 |
|
||||
|
||||
## 3.3 시스템 장비구성 요구사항 (ECR)
|
||||
|
||||
| ID | 요구사항 명칭 | 상세 내용 |
|
||||
|---|---|---|
|
||||
| ECR-001 | 서버 구성 | 웹/API 서버(WAS)·비동기 워커(이미지 생성·서류·알림)·DB 서버·캐시/큐(Redis)로 계층 분리. 규모 산정 및 구성(온프레미스/클라우드)은 제안사가 PER 요건 충족 근거와 함께 제안(발주기관 인프라 정책과 협의 확정) |
|
||||
| ECR-002 | DB·공간데이터 | PostgreSQL + **PostGIS 확장**(부스 폴리곤·트렌치 포인트·배선 LineString 공간 연산). 정기 백업(일 단위 이상)·복구 절차 포함 |
|
||||
| ECR-003 | 오브젝트 스토리지 | 도면·AI 생성 이미지·서식·콘텐츠 파일 적재용 오브젝트 스토리지. 공개 사이트 정적 자원 CDN/캐시 구성 제안 |
|
||||
| ECR-004 | 이중화·백업 | DB 백업·장애 복구(RTO/RPO 목표 제시), 주요 구성요소 단일 장애점 최소화 방안 제안. HA 구성 수준은 예산 범위 내 제안사 제안 |
|
||||
|
||||
## 3.4 인터페이스 요구사항 (SIR)
|
||||
|
||||
| ID | 요구사항 명칭 | 상세 내용 |
|
||||
|---|---|---|
|
||||
| SIR-001 | PG 결제 연동 | 국내 PG(토스페이먼츠 등) 연동 — 카드·계좌이체·간편결제, 유틸리티 신청·티켓 결제, 취소/부분환불, 결제 웹훅 처리, 세금계산서 발행 프로세스 연계(발주기관 재무 프로세스 협의) |
|
||||
| SIR-002 | 생성형 이미지 API 연동 | 이미지 생성 모델 API(Google Gemini 이미지 생성 모델 — image-to-image, 참조 이미지+지시문) 연동. **별도 워커 프로세스에서만 호출**(백엔드 직접 호출 금지), API 키는 서버 환경변수로만 관리(코드·DB·로그 기재 금지), 쿼터·비용 상한·재시도·에러 분기 처리 |
|
||||
| SIR-003 | CAD(DWG)/JPG 평면도 입력 | 홀 평면도 입력 포맷 = **CAD(DWG) + JPG**. CAD에서 홀 경계·기둥·비상구·트렌치 그리드 좌표를 추출해 공간 DB에 적재하는 도구/절차 구현. 도면(PDF/이미지) 업로드 비전 추출 포함(SFR-013) |
|
||||
| SIR-004 | 행사일정 연동 | 킨텍스 행사일정 데이터 수집·연동(공개 캘린더 수집 → 가용성 역산, 내부 데이터 제공 시 정합 교체) |
|
||||
| SIR-005 | 등록업체 DB 연동 | 킨텍스 등록업체 공개 데이터(14분류×739개) 주기 수집·자체 DB화, 추후 공식 피드 전환 가능 구조 |
|
||||
| SIR-006 | kxwp 작업신고 릴레이 | 킨텍스 온라인 작업신고(kxwp)는 API 미공개 — 본 사업은 **제출용 파일 자동 생성 + 업로드 안내(수동 릴레이)** 까지 구현. 직접 연동은 발주기관 IT 협의 성사 시 변경 협의 대상 |
|
||||
| SIR-007 | 나라장터(g2b) 연동 | **해당 없음** — 본 시스템은 나라장터와 시스템 연동을 요구하지 않음(본 문서는 조달 공고용이며, 구축 대상 시스템의 기능 범위에 g2b 연동은 포함되지 않음을 명시) |
|
||||
|
||||
## 3.5 데이터 요구사항 (DAR)
|
||||
|
||||
| ID | 요구사항 명칭 | 상세 내용 |
|
||||
|---|---|---|
|
||||
| DAR-001 | 공간 데이터 일원화 (PostGIS) | 부스 폴리곤·트렌치 포인트·배선 경로(LineString)를 PostGIS 지오메트리로 단일 원천 저장. 최단 배선(라우팅)·통로 폭 검증(버퍼)·면적 정산·wayfinding·부하 집계·㎡당 수익 분석이 동일 공간 원천을 재사용 |
|
||||
| DAR-002 | 마스터데이터 구축 | 홀 마스터(홀1~10 실측 규격·바닥하중·반홀 분할, 제3전시장 확장 구조), 트렌치 그리드(홀별 공급 매트릭스 — 전기·급배수·압축공기·전화·인터넷·가스), 요율표, 유틸리티 요금표, 부스 표준 사양(조립부스 포함 품목·프리미엄 사양), 규정 룰셋(높이·리깅·복층·방염·이격·소음·금지작업), 등록업체 DB. **룰셋·요율은 버전 관리 데이터**로 구축(코드 하드코딩 금지) |
|
||||
| DAR-003 | 핵심 엔티티 모델 | 행사(Event)–홀배정–부스(Booth)–설계안(DesignPlan, 3안·선택/병합 출처·버전)–유틸리티주문(UtilityOrder)–렌더작업(RenderJob)–문서(Document)–결제(Payment) + 옥션(Auction–Quotation–Award) + 관람(Visitor–Registration–Badge–CheckIn–Lead–Meeting) + 경영(KpiSnapshot·Content·Microsite·MasterData·User·Role·AuditLog). ERD·표준 명명 규칙·데이터 사전 산출 |
|
||||
| DAR-004 | 개인정보 보호 처리 | 관람객·리드 PII(성명·연락처·이메일)는 **암호화 저장(AES-256-GCM 등)** + 조회 화면 **마스킹** 기본. 수집 최소화·수집/이용 동의·보존기간·파기 정책 구현(게스트 예매는 최소 수집·사후 병합 정책 명시). 리드(개인정보) 접근 전수 감사로그 |
|
||||
| DAR-005 | 데이터 격리 | 행사 단위 데이터 격리(부스 설계는 소유 참가업체+주최자+홀매니저만 접근 — 참가업체 간 영업비밀 보호). 데이터 모델은 전시관(테넌트) 단위 격리 가능 구조(tenant 식별자 수용)로 설계(COR-006) |
|
||||
| DAR-006 | BI 데이터마트 | 경영분석은 운영 DB 직조회가 아닌 **스타 스키마 데이터마트**(Fact: 배정·정산·유틸리티·옥션·관람 / Dim: 홀·행사·일자·참가사) + 야간 배치/스냅샷 적재로 설계 |
|
||||
|
||||
## 3.6 테스트 요구사항 (TER)
|
||||
|
||||
| ID | 요구사항 명칭 | 상세 내용 |
|
||||
|---|---|---|
|
||||
| TER-001 | 단위·통합 테스트 | 모듈별 단위 테스트 및 모듈 간 통합 테스트(설계→시각화→옥션→정산 폐루프 시나리오 포함). 테스트 계획서·케이스·결과서 산출 |
|
||||
| TER-002 | 성능 테스트 | PER 요건(동시사용자·응답시간·큐 처리) 충족 검증 부하 테스트. 시나리오·결과 보고서 제출 |
|
||||
| TER-003 | 시나리오 검증 (파일럿) | 실제(또는 과거) 행사 1건 데이터로 **배치→설계→배선→시각화→옥션→정산 전 여정 시연** 검증. 검수 기준 사전 합의 |
|
||||
| TER-004 | 보안 테스트 | 웹 취약점 진단(OWASP Top 10 기준), 권한 우회·행사 간 데이터 격리·2FA 우회 여부 점검, 조치 결과 확인 후 검수 |
|
||||
|
||||
## 3.7 품질 요구사항 (QUR)
|
||||
|
||||
| ID | 요구사항 명칭 | 상세 내용 |
|
||||
|---|---|---|
|
||||
| QUR-001 | 표준 준수 | 웹 표준·웹 접근성(KWCAG 2.2 — 공개 사이트 우선 적용)·반응형 지원. 브라우저 호환(Chrome·Edge·Safari 최신, 모바일 브라우저) |
|
||||
| QUR-002 | 문서화 | 요구사항정의서, 화면설계서, ERD/테이블정의서, API 명세서, 시스템 구성도, 운영자/사용자 매뉴얼, 테스트 결과서 등 공공 SW사업 표준 산출물 제출 |
|
||||
| QUR-003 | 유지보수성 | 계층 분리(프론트/백엔드/워커)·모듈화·코드 컨벤션 준수, 룰셋·요율·이미지 슬롯 등 **운영 변경 항목의 무배포 반영 구조**(관리자 화면 변경) |
|
||||
| QUR-004 | AI 산출물 품질 관리 | AI 생성물(배치안·설계안·이미지·서류검수)은 전부 "초안/예상" 포지셔닝 — 사람 확정 절차·면책 고지·버전 기록 필수. 생성 이미지 품질 기준(구조 보존·워터마크)·재생성 절차 정의 |
|
||||
|
||||
## 3.8 보안 요구사항 (SER)
|
||||
|
||||
| ID | 요구사항 명칭 | 상세 내용 |
|
||||
|---|---|---|
|
||||
| SER-001 | 2차 인증 (2FA OTP) | 업무 사용자 전원 TOTP(RFC 6238) 2차 인증 필수(SFR-001). OTP 시크릿 안전 저장, 관리자 초기화 절차, 로그인 실패 잠금 |
|
||||
| SER-002 | 접근 통제 (RBAC) | 역할·행사 단위 이중 권한 평가, API 단위 권한 게이트, 관리자 API 역할 검증(`ADMIN` 이상), 최소권한 원칙·역할별 프론트 번들 분리 |
|
||||
| SER-003 | 감사로그 | 인증·승인·낙찰·설계 변경·룰셋 개정·개인정보 접근 전수 기록(SFR-005), 위·변조 방지 보관, 보존기간 정책 |
|
||||
| SER-004 | 데이터 암호화 | 개인정보·인증정보 암호화 저장(AES-256-GCM 등), 전송 구간 TLS 적용, 비밀번호 단방향 해시(BCrypt 등). 관리자 초기 비밀번호 환경변수 암호화 주입(하드코딩 금지) |
|
||||
| SER-005 | 시크릿 관리 | API 키(이미지 생성·PG 등)·DB 접속정보는 서버 환경변수/시크릿 저장소로만 관리 — 소스코드·저장소·로그·화면 노출 금지 |
|
||||
| SER-006 | 오류 응답 통제 | 스택트레이스·내부 경로·SQL 등 내부 정보 응답 노출 금지 — 오류 ID + 요약 메시지만 반환, 상세는 서버 로그 |
|
||||
| SER-007 | 개인정보보호 | 개인정보보호법 준수: 수집 최소화·동의·마스킹·파기(DAR-004), 개인정보 처리방침 화면, 정보통신망법 광고성 정보 수신동의(EDM) |
|
||||
| SER-008 | 세션·입력 보안 | JWT 만료·갱신 정책, CSRF/XSS/SQL Injection 방어, 파일 업로드 검증(확장자·크기·악성코드), 외부 입력값 전수 검증 |
|
||||
|
||||
## 3.9 제약사항 (COR)
|
||||
|
||||
| ID | 요구사항 명칭 | 상세 내용 |
|
||||
|---|---|---|
|
||||
| COR-001 | 기술 스택 (지정) | 프론트엔드 **React 18/19 + Vite + TypeScript**, 백엔드 **Spring Boot 3.x(Java 17) + MyBatis, REST + WebSocket(STOMP)**, DB **PostgreSQL + PostGIS**, 비동기 큐 **Redis**, AI 이미지 생성 **Python 워커 사이드카**(백엔드는 큐·상태 관리만 담당), 인증 **행사 단위 RBAC + JWT(+2FA OTP)**. 동등 이상 대안 제시는 가능하나 발주기관 승인 필수 |
|
||||
| COR-002 | AI 이미지의 법적 지위 | 생성 이미지는 참고용 — 계약·심사 근거 사용 금지(자동 배제), 워터마크·고지 필수(SFR-019). 시스템의 규정 검증은 '사전 필터'로 정의, 최종 승인 주체(킨텍스·소방·구조기술사)를 화면·리포트에 명시 |
|
||||
| COR-003 | 발주기관 제공 데이터 의존 | 트렌치 실측 좌표·CAD 원본 등 일부 마스터데이터는 발주기관 제공 필수. 미확보 구간은 공개 규격 기반 근사('가정' 라벨 표기)로 개발 진행하고, 실측 데이터 확보 시 교체 절차를 수립(요금·규정 공시가도 동일 — "최종가는 킨텍스 확정" 고지) |
|
||||
| COR-004 | 외부 시스템 제약 | kxwp는 직접 연동 불가 전제(릴레이 방식, SIR-006). KT 인터넷 개통·주차(iparking)·사이니지 하드웨어 연동은 협의 성사 시 확장 항목 |
|
||||
| COR-005 | 산출물 언어·형상관리 | 산출 문서는 한국어 작성(코드 식별자·커밋 메시지는 영어 허용). 소스코드는 발주기관 지정 형상관리 저장소에 커밋, 지속적 통합/배포 체계 구성 |
|
||||
| COR-006 | 확장 구조 (멀티테넌트 대비) | 데이터 모델·권한 계층은 다중 전시관(테넌트) 격리 확장이 가능한 구조(공유 스키마 + 테넌트 식별자 수용, 관리자 2계층 확장 여지)로 설계한다. 단, 타 전시관 실제 온보딩·운영은 본 사업 범위 외 |
|
||||
|
||||
## 3.10 프로젝트 관리 요구사항 (PMR)
|
||||
|
||||
| ID | 요구사항 명칭 | 상세 내용 |
|
||||
|---|---|---|
|
||||
| PMR-001 | 수행 방법론·단계 | 단계적 구축: **1단계(~4개월) 공통 레이어(인증·시스템관리) + 설계·시각화 코어(M1~M5 중심) → 2단계(~4개월) 워크플로·옥션·관람객·CMS(M6·M7·M9·M10·M12·M15·M17) → 3단계(~4개월) BI·현장·확장(M8·M11·M13·M14·M16 고도화)·통합 안정화** (가안 — 상세 WBS는 착수 시 확정). 단계별 중간 검수 |
|
||||
| PMR-002 | 수행 조직 | PM(총괄)·아키텍트(응용/데이터)·백엔드·프론트엔드·AI/데이터·QA·기획/디자인 역할을 포함한 투입 조직·M/M 제시. PM은 유사 사업 경험 보유자 |
|
||||
| PMR-003 | 일정·진척 관리 | WBS 기반 주간 진척 보고, 월간 운영위원회 보고, 마일스톤·리스크·이슈 관리 대장 운영 |
|
||||
| PMR-004 | 변경 관리 | 요구사항 추적표(RTM) 운영, 변경요청(CR) 절차·영향 분석·승인 체계. 과업 변경은 발주기관 서면 승인 |
|
||||
| PMR-005 | 위험 관리 | 핵심 리스크(생성 이미지 오인 분쟁·심사 책임 소재·외부 연동 불확실성·실측 데이터 미확보·이미지 생성 비용/지연·업체 참여율 등)의 완화 방안을 제안서에 제시하고 수행 중 관리 |
|
||||
|
||||
## 3.11 프로젝트 지원 요구사항 (PSR)
|
||||
|
||||
| ID | 요구사항 명칭 | 상세 내용 |
|
||||
|---|---|---|
|
||||
| PSR-001 | 무상 하자보수 | 최종 검수 후 **12개월 무상 하자보수**. 하자 등급별 대응 시간(치명 4시간 내 응답 등) 제안 |
|
||||
| PSR-002 | 교육·매뉴얼 | 역할별(관리자·홀매니저·주최자·참가업체·업체) 사용자 교육 실시, 운영자·사용자 매뉴얼 및 교육 자료 제공 |
|
||||
| PSR-003 | 운영 이관 | 운영 조직 대상 기술 이전(아키텍처·배포·장애 대응·룰셋/마스터 운영 절차), 운영 절차서·장애 대응 절차서 제공 |
|
||||
| PSR-004 | 안정화 지원 | 오픈 후 안정화 기간(최소 1개월, 가안) 상주 또는 밀착 지원, 초기 행사 적용 현장 지원 |
|
||||
|
||||
---
|
||||
|
||||
# 4. 제안서 작성 요령 및 목차 지정
|
||||
|
||||
## 4.1 작성 요령
|
||||
|
||||
1. 제안서는 본 제안요청서의 요구사항 전체(SFR~PSR)에 대해 **항목별 수용 여부·구현 방안**을 기술하고, 요구사항 ID 기준 **요구사항 추적표(RTM)** 를 첨부한다.
|
||||
2. 제안서는 **한글로 작성**하며, A4 기준 **200쪽 이내**(표지·목차·별첨 제외, 가안)로 한다.
|
||||
3. 객관적 근거(유사 실적·화면 예시·아키텍처 도식) 중심으로 작성하고, 검증 불가한 미사여구는 지양한다.
|
||||
4. 제안 내용은 계약 시 **계약문서의 일부**로 효력을 가지며, 제안한 사항은 사업 범위에 포함된 것으로 본다.
|
||||
5. 제안서 제출 후 내용 변경은 불가하며, 허위 기재 시 협상 대상 제외 또는 계약 해지 사유가 된다.
|
||||
6. 가격 제안서는 기술 제안서와 **분리 밀봉** 제출한다.
|
||||
|
||||
## 4.2 제안서 목차 (지정)
|
||||
|
||||
| 장 | 목차 | 주요 내용 |
|
||||
|---|---|---|
|
||||
| Ⅰ | 제안 개요 | 제안사 일반현황, 사업 이해도, 추진 목표·전략 |
|
||||
| Ⅱ | 사업 수행 부문 | 요구사항 이해 및 구현 방안(SFR 모듈별), 시스템 아키텍처(응용·데이터·인프라), AI 설계·시각화 파이프라인 구현 방안, 옥션·관람객·BI·CMS 구현 방안, 공통 레이어·보안 구현 방안 |
|
||||
| Ⅲ | 성능·품질 부문 | 성능 확보 방안(PER), 테스트 계획(TER), 품질 보증(QUR), 보안 대책(SER) |
|
||||
| Ⅳ | 프로젝트 관리 부문 | 수행 방법론·WBS·일정, 투입 조직·인력(M/M), 위험·변경·의사소통 관리 |
|
||||
| Ⅴ | 지원 부문 | 교육, 하자보수·안정화, 기술 이전·운영 이관 |
|
||||
| Ⅵ | 별첨 | 요구사항 추적표(RTM), 투입인력 이력사항, 유사 사업 실적 증명, 기술적용계획표, 상생협력·보안 관련 확약 서류 |
|
||||
|
||||
---
|
||||
|
||||
# 5. 평가 기준
|
||||
|
||||
## 5.1 평가 방법
|
||||
|
||||
- **협상에 의한 계약** — 기술평가(90점) + 가격평가(10점) 합산, 종합평점 고득점 순 협상적격자 선정 후 순차 협상.
|
||||
- 기술평가 점수가 **기술평가 배점의 85% 미만**인 경우 협상적격자에서 제외한다(가안).
|
||||
- 평가위원회는 발주기관이 구성하며, **제안설명회(PT)와 함께 제안 기술의 시연(데모) 평가를 실시한다** — 핵심 기능(AI 배치/설계 생성·시공 예상 이미지·옥션 등)에 대해 **실제 동작하는 프로토타입 시연**을 요구하며, 슬라이드·목업만으로는 실증 배점을 인정하지 않는다.
|
||||
- 공동수급(컨소시엄)의 경우 기술평가는 **주사업자(대표사)의 역량·실적을 중심으로 평가**한다.
|
||||
|
||||
## 5.2 기술평가 항목 (90점)
|
||||
|
||||
| 평가 부문 | 평가 항목 | 배점 |
|
||||
|---|---|---|
|
||||
| 전략·이해 (10) | 사업 이해도·추진 전략의 타당성 | 5 |
|
||||
| | 전시 도메인(홀 배정·장치 규정·유틸리티·반입출) 이해도 | 5 |
|
||||
| 기술·기능 (38) | 부스 배치·설계·배선 자동화(3안 생성·병합·규정 검증) 구현 방안의 구체성·실현성 | 8 |
|
||||
| | AI 시공 예상 이미지 파이프라인(구조 보존 image-to-image·비동기 큐·워터마크·비용 통제) 구현 방안 | 8 |
|
||||
| | AI 플랫폼 아키텍처 — 온프레미스 sLLM+상용 API 하이브리드·자동 폴백·RAG 근거/인용·환각 차단(SFR-035/036) 구현 방안 | 6 |
|
||||
| | 공간정보(PostGIS) 데이터 모델·CAD 좌표 추출·마스터데이터/룰셋 버전 관리 설계 | 5 |
|
||||
| | 공사/장치 옥션(역경매·견적서·낙찰)·정산 구현 방안 | 4 |
|
||||
| | 관람객(등록·배지·리드)·마케팅/공개사이트(SEO·다국어)·CMS·BI 구현 방안 | 4 |
|
||||
| | 보안(2FA OTP·RBAC·감사·암호화·PII)·품질·성능 확보 방안 | 3 |
|
||||
| 수행 능력·실증 (32) | **구현 완성도 실증 — 제안 핵심 기술의 동작 프로토타입 시연(데모) 평가** | 10 |
|
||||
| | **유사 AI 플랫폼(생성형 AI·업무 자동화 등) 구축 실적 — 최근 3년, 유사 규모 이상 다수 보유 우대** | 8 |
|
||||
| | **온프레미스 AI(sLLM·벡터DB) 구축·운영 경험** | 4 |
|
||||
| | **공간정보(GIS/PostGIS) 처리 시스템 구축 실적** | 4 |
|
||||
| | 수행 방법론·일정(WBS)·단계별 검수 계획의 적정성 | 3 |
|
||||
| | 투입 조직·인력의 전문성(제안 기술 스택 실무 경험) | 3 |
|
||||
| 관리·지원 (10) | 위험 관리(생성 이미지 분쟁·데이터 미확보·외부 연동)의 인식·대응 | 3 |
|
||||
| | 하자보수·교육·기술 이전·안정화 지원 계획 | 4 |
|
||||
| | 상생협력·중소기업 참여 계획 | 3 |
|
||||
| **합계** | | **90** |
|
||||
|
||||
## 5.3 가격평가 (10점)
|
||||
|
||||
- 배점한도 10점, 입찰가격 평점 산식은 국가계약법령 협상에 의한 계약 기준 산식을 준용한다(최저 입찰가 대비 상대평가).
|
||||
- 예정가격 초과 입찰은 무효로 한다.
|
||||
|
||||
---
|
||||
|
||||
# 6. 계약 조건 및 일정
|
||||
|
||||
## 6.1 입찰 및 계약 방식
|
||||
|
||||
| 구분 | 내용 |
|
||||
|---|---|
|
||||
| 입찰 방식 | 일반경쟁입찰(협상에 의한 계약) |
|
||||
| 참가 자격 | 소프트웨어사업자(SW진흥법에 따른 신고), 국가계약법상 결격 사유 없는 자. **SW진흥법 제48조에 따른 대기업인 소프트웨어사업자의 참여 제한 적용**(가안 — 공고 시 확정), **중소 SW기업 참여 우대**. 공동수급(컨소시엄) 허용 — 대표사(주사업자) 지분 50% 이상(가안), 기술평가는 주사업자 역량 중심(§5.1) |
|
||||
| 계약 방식 | 총액 확정 계약 |
|
||||
| 대가 지급 | 선금(계약금액의 일정률, 청구 시)·중도금(단계 검수)·잔금(최종 검수) — 계약 시 확정(가안) |
|
||||
| 계약 보증 | 계약보증금 계약금액의 10%, 하자보수보증금 3%(가안) |
|
||||
| 지체상금 | 지체상금률 1일 1/1000(가안, 국가계약법령 준용) |
|
||||
|
||||
## 6.2 추진 일정 (가안)
|
||||
|
||||
| 구분 | 일정 | 비고 |
|
||||
|---|---|---|
|
||||
| 입찰 공고 | 2026-07-20 (가안) | 나라장터 게시 |
|
||||
| 제안요청 설명회 | 2026-07-27 (가안) | 참석 여부는 평가와 무관 (가안) |
|
||||
| 질의 접수 마감 | 2026-08-07 (가안) | 서면(전자) 질의 |
|
||||
| 질의 회신 | 2026-08-14 (가안) | 나라장터·발주기관 공지 |
|
||||
| 제안서 제출 마감 | 2026-08-31 18:00 (가안) | 기술·가격 분리 제출 |
|
||||
| 제안 평가(PT 포함) | 2026-09-07 주간 (가안) | 평가위원회 |
|
||||
| 협상 및 계약 체결 | 2026-09-21 주간 (가안) | 협상적격자 순차 협상 |
|
||||
| 사업 착수 | 계약일로부터 14일 이내 착수보고 | |
|
||||
| 사업 종료 | 계약일로부터 12개월 (가안) | 최종 검수 |
|
||||
|
||||
## 6.3 검수 및 하자보수
|
||||
|
||||
- 단계별 중간 검수(PMR-001) + 최종 통합 검수(파일럿 행사 전 여정 시연 포함, TER-003).
|
||||
- 최종 검수 후 **무상 하자보수 12개월**(PSR-001). 하자보수 기간 중 결함은 수급인 부담으로 조치.
|
||||
- 하자보수와 별개의 유지관리(운영) 계약은 별도 협의.
|
||||
|
||||
---
|
||||
|
||||
# 7. 보안·준수사항
|
||||
|
||||
## 7.1 법규 준수
|
||||
|
||||
| 구분 | 내용 |
|
||||
|---|---|
|
||||
| 개인정보보호법 | 관람객·리드·회원 개인정보의 수집·이용·제공·파기 전 과정 준수. 수집 최소화, 동의 절차, 암호화·마스킹(DAR-004·SER-007), 개인정보 처리방침 게시. 수급인은 개인정보 처리 위탁 계약 및 교육 의무 이행 |
|
||||
| 정보통신망법 | 광고성 정보(EDM) 전송 시 수신동의·수신거부 처리 준수 |
|
||||
| SW진흥법 | 소프트웨어사업 계약·관리감독 관련 규정 준수. **대기업인 소프트웨어사업자 참여 제한(제48조) 적용**(가안 — 발주기관의 국가기관등 해당 여부에 따라 공고 시 최종 확정), **중소 SW기업 참여 우대 및 상생협력 적용**. SW 기술자 투입·대가 산정은 SW사업 대가산정 가이드 참조 |
|
||||
| 국가계약법령 | 협상에 의한 계약 절차·입찰 무효·부정당업자 제재 등 준용 |
|
||||
|
||||
## 7.2 보안 서약 및 자료 관리
|
||||
|
||||
1. 수급인 및 투입 인력 전원은 착수 시 **보안서약서**를 제출한다.
|
||||
2. 사업 수행 중 취득한 발주기관 내부 정보(홀 실측 도면·트렌치 좌표·요율·업체·관람객 데이터 등)는 본 사업 목적 외 사용·외부 유출을 금지하며, 사업 종료 시 반환·파기한다.
|
||||
3. 소스코드·저장소·문서·로그에 **자격증명(API 키·비밀번호·접속정보) 기재를 금지**한다(SER-005).
|
||||
4. 외부 API(이미지 생성·PG 등) 사용은 발주기관 승인 범위 내로 한정하고, 전송 데이터에 개인정보·내부 기밀 포함을 금지한다.
|
||||
5. 개발·운영 환경 분리, 운영 데이터의 개발 환경 반입 금지(불가피 시 비식별화).
|
||||
|
||||
## 7.3 산출물 귀속 및 지식재산권
|
||||
|
||||
1. 본 사업으로 개발된 산출물(소스코드·문서·데이터·디자인)의 지식재산권은 **발주기관에 귀속**함을 원칙으로 한다(가안 — 계약 시 확정, SW진흥법 취지에 따른 공동 활용 협의 가능).
|
||||
2. 제3자 상용 SW·오픈소스 사용 시 라이선스 목록·조건을 제안서에 명시하고, 라이선스 위반 책임은 수급인이 부담한다.
|
||||
3. AI 생성 이미지의 활용 범위·고지 의무(SFR-019)는 산출물 인계 후에도 시스템 기능으로 유지되어야 한다.
|
||||
|
||||
---
|
||||
|
||||
# 8. 첨부 양식 목록
|
||||
|
||||
| 번호 | 양식명 | 비고 |
|
||||
|---|---|---|
|
||||
| 별첨 1 | 입찰 참가 신청서 | 나라장터 전자 제출 |
|
||||
| 별첨 2 | 제안서 표지 및 목차 양식 | §4.2 목차 준수 |
|
||||
| 별첨 3 | 요구사항 추적표(RTM) 양식 | SFR~PSR 전 항목 대응 |
|
||||
| 별첨 4 | 기술적용계획표 | 전자정부 표준·상호운용성·보안 기술 적용 계획 |
|
||||
| 별첨 5 | 투입인력 이력사항 및 M/M 총괄표 | 자격·경력 증빙 첨부 |
|
||||
| 별첨 6 | 유사 사업 수행 실적 증명서 | AI 플랫폼·GIS 실적은 최근 3년, 일반 실적 최근 5년(가안) |
|
||||
| 별첨 6-1 | 시연(데모) 계획서 | 시연 대상 기능·환경·시나리오 (§5.1 실증 평가) |
|
||||
| 별첨 7 | 보안서약서 (법인·개인) | 착수 시 전 인력 제출 |
|
||||
| 별첨 8 | 개인정보 처리 위탁 확약서 | 개인정보보호법 준수 |
|
||||
| 별첨 9 | 상생협력(하도급) 계획서 | 해당 시 |
|
||||
| 별첨 10 | 청렴계약 이행 서약서 | |
|
||||
| 별첨 11 | 가격 제안서 양식 | 부가세 포함, 분리 밀봉 |
|
||||
| 별첨 12 | 오픈소스/상용 SW 라이선스 목록 양식 | §7.3 |
|
||||
|
||||
---
|
||||
|
||||
## 문의처 (가안)
|
||||
|
||||
| 구분 | 내용 |
|
||||
|---|---|
|
||||
| 사업 담당 | (주)킨텍스 ○○팀 (가안) |
|
||||
| 문의 방법 | 나라장터 질의 게시판(서면 질의 원칙) |
|
||||
|
||||
> 본 제안요청서의 해석에 이견이 있는 경우 발주기관의 해석에 따르며, 명시되지 않은 사항은 국가계약법령 및 관련 법규를 준용한다.
|
||||
BIN
docs/deliverables/킨텍스AI_사업수행계획서.pptx
Normal file
BIN
docs/deliverables/킨텍스AI_사업수행계획서.pptx
Normal file
Binary file not shown.
BIN
docs/deliverables/킨텍스AI_완료보고서.pptx
Normal file
BIN
docs/deliverables/킨텍스AI_완료보고서.pptx
Normal file
Binary file not shown.
BIN
docs/deliverables/킨텍스AI_중간보고서.pptx
Normal file
BIN
docs/deliverables/킨텍스AI_중간보고서.pptx
Normal file
Binary file not shown.
BIN
docs/deliverables/킨텍스AI_착수보고서.pptx
Normal file
BIN
docs/deliverables/킨텍스AI_착수보고서.pptx
Normal file
Binary file not shown.
@ -36,7 +36,9 @@ public class SecurityConfig {
|
||||
"/api/internal/render/callback",
|
||||
"/api/auth/login/secure", "/api/auth/otp/verify",
|
||||
"/api/auth/register", "/api/auth/password/forgot",
|
||||
"/api/auth/password/reset").permitAll()
|
||||
"/api/auth/password/reset",
|
||||
// 공개 콘텐츠(로그인 슬라이드 등) + 업로드 정적 파일
|
||||
"/api/public/**", "/uploads/**").permitAll()
|
||||
.anyRequest().authenticated())
|
||||
.exceptionHandling(eh -> eh.authenticationEntryPoint((req, res, ex) -> {
|
||||
// 스택트레이스 미노출 — 표준 봉투로 401만 반환.
|
||||
|
||||
@ -0,0 +1,30 @@
|
||||
package com.zioinfo.kintex.config;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
import java.nio.file.Paths;
|
||||
|
||||
/**
|
||||
* 정적 리소스 서빙 — 관리자가 업로드한 파일({kintex.upload.dir})을 /uploads/** 로 노출.
|
||||
* 현재 용도: 로그인 슬라이드 이미지(/uploads/login-slides/**). 업로드 파일명은 서버가 UUID로 강제한다.
|
||||
*/
|
||||
@Configuration
|
||||
public class WebMvcConfig implements WebMvcConfigurer {
|
||||
|
||||
private final String uploadDir;
|
||||
|
||||
public WebMvcConfig(@Value("${kintex.upload.dir:./data/uploads}") String uploadDir) {
|
||||
this.uploadDir = uploadDir;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
||||
String location = Paths.get(uploadDir).toAbsolutePath().normalize().toUri().toString();
|
||||
registry.addResourceHandler("/uploads/**")
|
||||
.addResourceLocations(location)
|
||||
.setCachePeriod(3600);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,77 @@
|
||||
package com.zioinfo.kintex.system.loginslide;
|
||||
|
||||
import com.zioinfo.kintex.auth.KintexPrincipal;
|
||||
import com.zioinfo.kintex.common.ApiResponse;
|
||||
import com.zioinfo.kintex.common.audit.Audited;
|
||||
import com.zioinfo.kintex.system.SystemAccessGuard;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 로그인 슬라이드 API.
|
||||
* <ul>
|
||||
* <li>공개: {@code GET /api/public/login-slides} — 활성만, 최신 등록순(created_at DESC) 최대 5건.</li>
|
||||
* <li>관리자: {@code /api/admin/login-slides} CRUD — ADMIN 게이트({@link SystemAccessGuard}).</li>
|
||||
* </ul>
|
||||
*/
|
||||
@RestController
|
||||
public class LoginSlideController {
|
||||
|
||||
private final LoginSlideService service;
|
||||
private final SystemAccessGuard guard;
|
||||
|
||||
public LoginSlideController(LoginSlideService service, SystemAccessGuard guard) {
|
||||
this.service = service;
|
||||
this.guard = guard;
|
||||
}
|
||||
|
||||
/** 공개 — 로그인 화면 캐러셀용(인증 불필요). */
|
||||
@GetMapping("/api/public/login-slides")
|
||||
public ApiResponse<List<LoginSlideDto>> publicSlides() {
|
||||
return ApiResponse.ok(service.publicSlides());
|
||||
}
|
||||
|
||||
/** 관리자 — 전체 목록(비활성 포함, 최신 등록순). */
|
||||
@GetMapping("/api/admin/login-slides")
|
||||
public ApiResponse<List<LoginSlideDto>> adminList(@AuthenticationPrincipal KintexPrincipal principal) {
|
||||
guard.requireAdmin(principal);
|
||||
return ApiResponse.ok(service.adminList());
|
||||
}
|
||||
|
||||
/** 관리자 — 신규 등록(multipart: image + title/caption[/sortOrder]). */
|
||||
@Audited(action = "LOGIN_SLIDE_CREATE", targetType = "login_slide")
|
||||
@PostMapping(value = "/api/admin/login-slides", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
public ApiResponse<LoginSlideDto> create(@AuthenticationPrincipal KintexPrincipal principal,
|
||||
@RequestPart("image") MultipartFile image,
|
||||
@RequestParam("title") String title,
|
||||
@RequestParam(value = "caption", required = false) String caption,
|
||||
@RequestParam(value = "sortOrder", required = false) Integer sortOrder) {
|
||||
guard.requireAdmin(principal);
|
||||
return ApiResponse.ok(service.create(image, title, caption, sortOrder));
|
||||
}
|
||||
|
||||
/** 관리자 — 문구·정렬·활성 여부 수정. */
|
||||
@Audited(action = "LOGIN_SLIDE_UPDATE", targetType = "login_slide")
|
||||
@PutMapping("/api/admin/login-slides/{id}")
|
||||
public ApiResponse<LoginSlideDto> update(@AuthenticationPrincipal KintexPrincipal principal,
|
||||
@PathVariable long id,
|
||||
@Valid @RequestBody LoginSlideUpdateRequest req) {
|
||||
guard.requireAdmin(principal);
|
||||
return ApiResponse.ok(service.update(id, req));
|
||||
}
|
||||
|
||||
/** 관리자 — 삭제(업로드 이미지 파일 정리 포함). */
|
||||
@Audited(action = "LOGIN_SLIDE_DELETE", targetType = "login_slide")
|
||||
@DeleteMapping("/api/admin/login-slides/{id}")
|
||||
public ApiResponse<Void> delete(@AuthenticationPrincipal KintexPrincipal principal,
|
||||
@PathVariable long id) {
|
||||
guard.requireAdmin(principal);
|
||||
service.delete(id);
|
||||
return ApiResponse.ok(null);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
package com.zioinfo.kintex.system.loginslide;
|
||||
|
||||
/**
|
||||
* 로그인 슬라이드 DTO — 로그인 화면 좌측 캐러셀 1장.
|
||||
* 공개 API(/api/public/login-slides)와 관리자 API가 공유한다(민감정보 없음).
|
||||
*/
|
||||
public record LoginSlideDto(
|
||||
Long id,
|
||||
String imageUrl,
|
||||
String title,
|
||||
String caption,
|
||||
Integer sortOrder,
|
||||
boolean active,
|
||||
String createdAt,
|
||||
String updatedAt) {
|
||||
}
|
||||
@ -0,0 +1,40 @@
|
||||
package com.zioinfo.kintex.system.loginslide;
|
||||
|
||||
import org.apache.ibatis.annotations.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** 로그인 슬라이드 매퍼. 노출 순서 권위 = created_at DESC(최신 등록 먼저). */
|
||||
@Mapper
|
||||
public interface LoginSlideMapper {
|
||||
|
||||
String COLS = "id, image_url AS imageUrl, title, caption, sort_order AS sortOrder, "
|
||||
+ "is_active AS active, to_char(created_at, 'YYYY-MM-DD HH24:MI') AS createdAt, "
|
||||
+ "to_char(updated_at, 'YYYY-MM-DD HH24:MI') AS updatedAt";
|
||||
|
||||
/** 공개 노출용 — 활성만, 최신 등록순 최대 {limit}건. */
|
||||
@Select("SELECT " + COLS + " FROM login_slide WHERE is_active = true "
|
||||
+ "ORDER BY created_at DESC, id DESC LIMIT #{limit}")
|
||||
List<Map<String, Object>> findActiveLatest(@Param("limit") int limit);
|
||||
|
||||
/** 관리자 목록 — 전체, 최신 등록순. */
|
||||
@Select("SELECT " + COLS + " FROM login_slide ORDER BY created_at DESC, id DESC")
|
||||
List<Map<String, Object>> findAll();
|
||||
|
||||
@Select("SELECT " + COLS + " FROM login_slide WHERE id = #{id}")
|
||||
Map<String, Object> findById(@Param("id") long id);
|
||||
|
||||
@Insert("INSERT INTO login_slide (image_url, title, caption, sort_order, is_active) "
|
||||
+ "VALUES (#{imageUrl}, #{title}, #{caption}, COALESCE(#{sortOrder}, 0), true)")
|
||||
@Options(useGeneratedKeys = true, keyProperty = "id", keyColumn = "id")
|
||||
int insert(Map<String, Object> p);
|
||||
|
||||
@Update("UPDATE login_slide SET title = #{title}, caption = #{caption}, "
|
||||
+ "sort_order = COALESCE(#{sortOrder}, sort_order), "
|
||||
+ "is_active = COALESCE(#{active}, is_active), updated_at = now() WHERE id = #{id}")
|
||||
int update(Map<String, Object> p);
|
||||
|
||||
@Delete("DELETE FROM login_slide WHERE id = #{id}")
|
||||
int delete(@Param("id") long id);
|
||||
}
|
||||
@ -0,0 +1,189 @@
|
||||
package com.zioinfo.kintex.system.loginslide;
|
||||
|
||||
import com.zioinfo.kintex.common.error.ApiException;
|
||||
import com.zioinfo.kintex.common.error.ErrorCode;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 로그인 슬라이드 서비스 — 공개 조회(활성·최신순 최대 5건) + 관리자 CRUD/이미지 업로드.
|
||||
* 업로드 파일은 {kintex.upload.dir}/login-slides/ 아래 UUID 파일명으로 저장하고
|
||||
* 정적 경로 /uploads/login-slides/** 로 서빙한다(WebMvcConfig). 원본 파일명은 신뢰하지 않는다.
|
||||
*/
|
||||
@Service
|
||||
public class LoginSlideService {
|
||||
|
||||
/** 공개 로테이션 최대 노출 수 — 최신 5개. */
|
||||
public static final int PUBLIC_LIMIT = 5;
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(LoginSlideService.class);
|
||||
private static final long MAX_IMAGE_BYTES = 8L * 1024 * 1024; // 8MB
|
||||
private static final Map<String, String> ALLOWED_TYPES = Map.of(
|
||||
"image/png", "png",
|
||||
"image/jpeg", "jpg",
|
||||
"image/webp", "webp");
|
||||
private static final Set<String> ALLOWED_EXTS = Set.of("png", "jpg", "jpeg", "webp");
|
||||
|
||||
private final LoginSlideMapper mapper;
|
||||
private final Path uploadRoot;
|
||||
|
||||
public LoginSlideService(LoginSlideMapper mapper,
|
||||
@Value("${kintex.upload.dir:./data/uploads}") String uploadDir) {
|
||||
this.mapper = mapper;
|
||||
this.uploadRoot = Paths.get(uploadDir).toAbsolutePath().normalize();
|
||||
}
|
||||
|
||||
/** 공개 — 활성 슬라이드 최신 등록순 최대 5건. */
|
||||
public List<LoginSlideDto> publicSlides() {
|
||||
return mapper.findActiveLatest(PUBLIC_LIMIT).stream().map(LoginSlideService::toDto).toList();
|
||||
}
|
||||
|
||||
/** 관리자 — 전체 목록(최신 등록순). */
|
||||
public List<LoginSlideDto> adminList() {
|
||||
return mapper.findAll().stream().map(LoginSlideService::toDto).toList();
|
||||
}
|
||||
|
||||
/** 관리자 — 이미지 업로드 + 문구로 신규 슬라이드 등록(등록 즉시 활성). */
|
||||
public LoginSlideDto create(MultipartFile image, String title, String caption, Integer sortOrder) {
|
||||
if (title == null || title.isBlank()) {
|
||||
throw new ApiException(ErrorCode.VALIDATION, "슬라이드 문구(타이틀)를 입력해 주세요.");
|
||||
}
|
||||
if (title.length() > 200 || (caption != null && caption.length() > 500)) {
|
||||
throw new ApiException(ErrorCode.VALIDATION, "문구 길이가 허용 범위를 초과했습니다.");
|
||||
}
|
||||
String imageUrl = storeImage(image);
|
||||
|
||||
Map<String, Object> p = new HashMap<>();
|
||||
p.put("imageUrl", imageUrl);
|
||||
p.put("title", title.trim());
|
||||
p.put("caption", caption == null ? null : caption.trim());
|
||||
p.put("sortOrder", sortOrder);
|
||||
mapper.insert(p);
|
||||
long id = ((Number) p.get("id")).longValue();
|
||||
return get(id);
|
||||
}
|
||||
|
||||
/** 관리자 — 문구·정렬·활성 여부 수정. */
|
||||
public LoginSlideDto update(long id, LoginSlideUpdateRequest req) {
|
||||
requireExists(id);
|
||||
Map<String, Object> p = new HashMap<>();
|
||||
p.put("id", id);
|
||||
p.put("title", req.title().trim());
|
||||
p.put("caption", req.caption() == null ? null : req.caption().trim());
|
||||
p.put("sortOrder", req.sortOrder());
|
||||
p.put("active", req.active());
|
||||
mapper.update(p);
|
||||
return get(id);
|
||||
}
|
||||
|
||||
/** 관리자 — 삭제(업로드 파일도 정리, 시드/번들 자산은 보존). */
|
||||
public void delete(long id) {
|
||||
LoginSlideDto slide = get(id);
|
||||
mapper.delete(id);
|
||||
removeUploadedFile(slide.imageUrl());
|
||||
}
|
||||
|
||||
public LoginSlideDto get(long id) {
|
||||
Map<String, Object> r = mapper.findById(id);
|
||||
if (r == null) {
|
||||
throw new ApiException(ErrorCode.NOT_FOUND, "슬라이드를 찾을 수 없습니다.");
|
||||
}
|
||||
return toDto(r);
|
||||
}
|
||||
|
||||
private void requireExists(long id) {
|
||||
if (mapper.findById(id) == null) {
|
||||
throw new ApiException(ErrorCode.NOT_FOUND, "슬라이드를 찾을 수 없습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
// ── 이미지 저장 ──
|
||||
|
||||
private String storeImage(MultipartFile image) {
|
||||
if (image == null || image.isEmpty()) {
|
||||
throw new ApiException(ErrorCode.VALIDATION, "슬라이드 이미지를 첨부해 주세요.");
|
||||
}
|
||||
if (image.getSize() > MAX_IMAGE_BYTES) {
|
||||
throw new ApiException(ErrorCode.VALIDATION, "이미지는 8MB 이하만 업로드할 수 있습니다.");
|
||||
}
|
||||
String ext = resolveExtension(image);
|
||||
String fileName = UUID.randomUUID() + "." + ext;
|
||||
try {
|
||||
Path dir = uploadRoot.resolve("login-slides");
|
||||
Files.createDirectories(dir);
|
||||
Path target = dir.resolve(fileName).normalize();
|
||||
if (!target.startsWith(uploadRoot)) { // 경로 탈출 방어(파일명은 UUID라 사실상 불변식)
|
||||
throw new ApiException(ErrorCode.VALIDATION, "잘못된 파일 경로입니다.");
|
||||
}
|
||||
try (var in = image.getInputStream()) {
|
||||
Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.error("login-slide image store failed: {}", e.getMessage());
|
||||
throw new ApiException(ErrorCode.INTERNAL, "이미지 저장에 실패했습니다.");
|
||||
}
|
||||
return "/uploads/login-slides/" + fileName;
|
||||
}
|
||||
|
||||
private static String resolveExtension(MultipartFile image) {
|
||||
String byType = image.getContentType() == null
|
||||
? null : ALLOWED_TYPES.get(image.getContentType().toLowerCase(Locale.ROOT));
|
||||
if (byType != null) {
|
||||
return byType;
|
||||
}
|
||||
String name = image.getOriginalFilename();
|
||||
if (name != null && name.contains(".")) {
|
||||
String ext = name.substring(name.lastIndexOf('.') + 1).toLowerCase(Locale.ROOT);
|
||||
if (ALLOWED_EXTS.contains(ext)) {
|
||||
return "jpeg".equals(ext) ? "jpg" : ext;
|
||||
}
|
||||
}
|
||||
throw new ApiException(ErrorCode.VALIDATION, "PNG/JPG/WebP 이미지만 업로드할 수 있습니다.");
|
||||
}
|
||||
|
||||
private void removeUploadedFile(String imageUrl) {
|
||||
if (imageUrl == null || !imageUrl.startsWith("/uploads/login-slides/")) {
|
||||
return; // 번들 정적 자산(시드) 등은 삭제하지 않음
|
||||
}
|
||||
String fileName = imageUrl.substring("/uploads/login-slides/".length());
|
||||
if (fileName.contains("/") || fileName.contains("\\") || fileName.contains("..")) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Files.deleteIfExists(uploadRoot.resolve("login-slides").resolve(fileName));
|
||||
} catch (IOException e) {
|
||||
log.warn("login-slide image cleanup failed: {}", e.getMessage()); // 삭제 실패는 무해 — 로그만
|
||||
}
|
||||
}
|
||||
|
||||
private static LoginSlideDto toDto(Map<String, Object> r) {
|
||||
return new LoginSlideDto(
|
||||
((Number) r.get("id")).longValue(),
|
||||
str(r.get("imageUrl")),
|
||||
str(r.get("title")),
|
||||
str(r.get("caption")),
|
||||
r.get("sortOrder") == null ? null : ((Number) r.get("sortOrder")).intValue(),
|
||||
Boolean.TRUE.equals(r.get("active")),
|
||||
str(r.get("createdAt")),
|
||||
str(r.get("updatedAt")));
|
||||
}
|
||||
|
||||
private static String str(Object o) {
|
||||
return o == null ? null : String.valueOf(o);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
package com.zioinfo.kintex.system.loginslide;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
/** 로그인 슬라이드 수정 요청 — 문구·정렬·활성 여부(이미지 교체는 삭제 후 재등록). */
|
||||
public record LoginSlideUpdateRequest(
|
||||
@NotBlank @Size(max = 200) String title,
|
||||
@Size(max = 500) String caption,
|
||||
Integer sortOrder,
|
||||
Boolean active) {
|
||||
}
|
||||
@ -14,6 +14,10 @@ server:
|
||||
spring:
|
||||
application:
|
||||
name: kintex-backend
|
||||
servlet:
|
||||
multipart:
|
||||
max-file-size: 8MB # 로그인 슬라이드 등 이미지 업로드 상한
|
||||
max-request-size: 10MB
|
||||
datasource:
|
||||
url: ${DB_URL:jdbc:postgresql://localhost:5432/kintex_db}
|
||||
username: ${DB_USER:kintex}
|
||||
@ -62,6 +66,9 @@ kintex:
|
||||
# 버전 관리되는 룰셋 데이터(규정·요율) — 코드가 아닌 데이터로 유지(킨텍스 규정 개정 대응)
|
||||
compliance-ruleset: classpath:rulesets/compliance-v1.json
|
||||
rate-ruleset: classpath:rulesets/rates-v1.json
|
||||
upload:
|
||||
# 관리자 업로드 파일 저장 루트(로그인 슬라이드 이미지 등) — /uploads/** 로 정적 서빙
|
||||
dir: ${UPLOAD_DIR:./data/uploads}
|
||||
render:
|
||||
# RenderJob Redis 큐 이름(나노바나나 워커가 소비) — visualizer와 합의한 계약
|
||||
queue-key: ${RENDER_QUEUE_KEY:kintex:renderjob:queue}
|
||||
|
||||
@ -0,0 +1,43 @@
|
||||
-- V11 — 로그인 화면 좌측 슬라이드(캐러셀) 관리 테이블 + 샘플 시드 5건 (멱등)
|
||||
-- 공개 노출 규칙: is_active=true 만, created_at DESC(최신 등록순) 최대 5건 로테이션.
|
||||
-- sort_order 는 보조 정보(관리 화면 표시용)로 유지 — 노출 순서 권위는 created_at DESC.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS login_slide (
|
||||
id bigserial PRIMARY KEY,
|
||||
image_url varchar(500) NOT NULL,
|
||||
title varchar(200) NOT NULL,
|
||||
caption varchar(500),
|
||||
sort_order integer NOT NULL DEFAULT 0,
|
||||
is_active boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_login_slide_active_created ON login_slide(is_active, created_at DESC);
|
||||
|
||||
-- 샘플 시드 5건 — 킨텍스 시설 사진(프론트 정적 자산 /login-slides/slide-*.png).
|
||||
-- created_at 을 순차로 다르게 두어 로테이션 순서를 결정적으로 만든다(slide-1 이 최신 → 첫 슬라이드).
|
||||
INSERT INTO login_slide (image_url, title, caption, sort_order, is_active, created_at, updated_at)
|
||||
SELECT v.image_url, v.title, v.caption, v.sort_order, true, now() - (v.age_min * interval '1 minute'), now()
|
||||
FROM (VALUES
|
||||
('/login-slides/slide-1.png',
|
||||
'아시아를 대표하는 전시컨벤션센터, KINTEX',
|
||||
'10만㎡ 전시 공간에서 펼쳐지는 무한한 비즈니스 기회를 만나보세요.',
|
||||
1, 0),
|
||||
('/login-slides/slide-2.png',
|
||||
'신청서를 내는 순간, 시공 후 사진을 먼저 봅니다',
|
||||
'AI가 부스 배치부터 시공 결과 시각화까지 전 과정을 자동으로 설계합니다.',
|
||||
2, 1),
|
||||
('/login-slides/slide-3.png',
|
||||
'국내 최대 무주(無柱) 전시홀이 당신의 무대가 됩니다',
|
||||
'기둥 없는 대공간에서 어떤 규모의 전시도 자유롭게 연출하세요.',
|
||||
3, 2),
|
||||
('/login-slides/slide-4.png',
|
||||
'회의부터 국제 컨벤션까지, 완벽한 비즈니스 공간',
|
||||
'최첨단 회의 시설과 전문 운영 인력이 성공적인 행사를 지원합니다.',
|
||||
4, 3),
|
||||
('/login-slides/slide-5.png',
|
||||
'전시 운영의 모든 순간을 하나의 플랫폼에서',
|
||||
'부스 배선·전기·조명 설계와 현장 검수까지 데이터로 관리하세요.',
|
||||
5, 4)
|
||||
) AS v(image_url, title, caption, sort_order, age_min)
|
||||
WHERE NOT EXISTS (SELECT 1 FROM login_slide WHERE login_slide.image_url = v.image_url);
|
||||
BIN
src/frontend/public/login-slides/slide-1.png
Normal file
BIN
src/frontend/public/login-slides/slide-1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
BIN
src/frontend/public/login-slides/slide-2.png
Normal file
BIN
src/frontend/public/login-slides/slide-2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 953 KiB |
BIN
src/frontend/public/login-slides/slide-3.png
Normal file
BIN
src/frontend/public/login-slides/slide-3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
BIN
src/frontend/public/login-slides/slide-4.png
Normal file
BIN
src/frontend/public/login-slides/slide-4.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
BIN
src/frontend/public/login-slides/slide-5.png
Normal file
BIN
src/frontend/public/login-slides/slide-5.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 530 KiB |
@ -16,6 +16,7 @@ import { AnalyticsDashboardPage } from './screens/analytics/AnalyticsDashboardPa
|
||||
import { HallOperationsPage } from './screens/ops/HallOperationsPage';
|
||||
import { ExhibitionSchedulePage } from './screens/schedule/ExhibitionSchedulePage';
|
||||
import { AdminDashboardPage } from './screens/admin/AdminDashboardPage';
|
||||
import { LoginSlideAdminPage } from './screens/admin/LoginSlideAdminPage';
|
||||
import { ComponentGuidePage } from './screens/styleguide/ComponentGuidePage';
|
||||
import { ContractorOnsiteChecklistPage } from './screens/mobile/ContractorOnsiteChecklistPage';
|
||||
import { ManagerFieldInspectionPage } from './screens/mobile/ManagerFieldInspectionPage';
|
||||
@ -92,6 +93,8 @@ export function App() {
|
||||
<Route path="/schedule" element={<ExhibitionSchedulePage />} />
|
||||
{/* SCR-16 관리자 대시보드 (M18 랜딩) */}
|
||||
<Route path="/admin" element={<AdminDashboardPage />} />
|
||||
{/* 관리자 — 로그인 슬라이드 관리 (M18) */}
|
||||
<Route path="/admin/login-slides" element={<LoginSlideAdminPage />} />
|
||||
{/* SCR-17 컴포넌트 가이드 (내부 전용 · 네비 비노출) */}
|
||||
<Route path="/_styleguide" element={<ComponentGuidePage />} />
|
||||
</Route>
|
||||
|
||||
@ -41,7 +41,10 @@ interface RequestOptions {
|
||||
}
|
||||
|
||||
async function request<T>(path: string, opts: RequestOptions = {}): Promise<T> {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
const isForm = opts.body instanceof FormData;
|
||||
const headers: Record<string, string> = {};
|
||||
// FormData는 브라우저가 boundary 포함 Content-Type을 직접 설정한다.
|
||||
if (!isForm) headers['Content-Type'] = 'application/json';
|
||||
if (!opts.anonymous && accessToken) headers.Authorization = `Bearer ${accessToken}`;
|
||||
|
||||
let res: Response;
|
||||
@ -49,7 +52,11 @@ async function request<T>(path: string, opts: RequestOptions = {}): Promise<T> {
|
||||
res = await fetch(`${API_BASE}${path}`, {
|
||||
method: opts.method ?? 'GET',
|
||||
headers,
|
||||
body: opts.body != null ? JSON.stringify(opts.body) : undefined,
|
||||
body: isForm
|
||||
? (opts.body as FormData)
|
||||
: opts.body != null
|
||||
? JSON.stringify(opts.body)
|
||||
: undefined,
|
||||
signal: opts.signal,
|
||||
});
|
||||
} catch (e) {
|
||||
@ -95,4 +102,9 @@ export const api = {
|
||||
request<T>(path, { ...opts, method: 'POST', body }),
|
||||
put: <T>(path: string, body?: unknown, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
|
||||
request<T>(path, { ...opts, method: 'PUT', body }),
|
||||
del: <T>(path: string, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
|
||||
request<T>(path, { ...opts, method: 'DELETE' }),
|
||||
/** multipart 업로드(POST FormData) — 파일 업로드 경로 전용. */
|
||||
postForm: <T>(path: string, form: FormData, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
|
||||
request<T>(path, { ...opts, method: 'POST', body: form }),
|
||||
};
|
||||
|
||||
@ -17,6 +17,8 @@ import type {
|
||||
LayoutSaveRequest,
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
LoginSlideDto,
|
||||
LoginSlideUpdateRequest,
|
||||
RegisterRequest,
|
||||
RegisterResponse,
|
||||
RenderJobDto,
|
||||
@ -49,6 +51,28 @@ export const authApi = {
|
||||
api.post<void>('/api/auth/password/reset', body, { anonymous: true }),
|
||||
};
|
||||
|
||||
// ── 로그인 슬라이드 (SCR-01 캐러셀 · 관리자) ──
|
||||
export const loginSlideApi = {
|
||||
// 공개 — 활성만, 최신 등록순 최대 5건(인증 불필요).
|
||||
publicList: () =>
|
||||
api.get<LoginSlideDto[]>('/api/public/login-slides', { anonymous: true }),
|
||||
// 관리자 — 전체 목록(비활성 포함, 최신 등록순).
|
||||
adminList: () => api.get<LoginSlideDto[]>('/api/admin/login-slides'),
|
||||
// 관리자 — 신규 등록(이미지 multipart + 문구).
|
||||
create: (image: File, title: string, caption: string) => {
|
||||
const form = new FormData();
|
||||
form.append('image', image);
|
||||
form.append('title', title);
|
||||
if (caption) form.append('caption', caption);
|
||||
return api.postForm<LoginSlideDto>('/api/admin/login-slides', form);
|
||||
},
|
||||
// 관리자 — 문구·정렬·활성 여부 수정.
|
||||
update: (id: number, body: LoginSlideUpdateRequest) =>
|
||||
api.put<LoginSlideDto>(`/api/admin/login-slides/${id}`, body),
|
||||
// 관리자 — 삭제.
|
||||
remove: (id: number) => api.del<void>(`/api/admin/login-slides/${id}`),
|
||||
};
|
||||
|
||||
// ── M2 플로어플랜 (SCR-03/04) ──
|
||||
const layoutBase = (eventId: string, hallId: string) =>
|
||||
`/api/events/${encodeURIComponent(eventId)}/halls/${encodeURIComponent(hallId)}/layout`;
|
||||
|
||||
@ -302,3 +302,23 @@ export interface RenderJobDto {
|
||||
errorMessage: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// ── 7. 로그인 슬라이드 (SCR-01 좌측 캐러셀 · 관리자 M18) ──
|
||||
// 공개 GET /api/public/login-slides — 활성만, 최신 등록순(created_at DESC) 최대 5건.
|
||||
export interface LoginSlideDto {
|
||||
id: number;
|
||||
imageUrl: string;
|
||||
title: string;
|
||||
caption: string | null;
|
||||
sortOrder: number | null;
|
||||
active: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
// 관리자 PUT /api/admin/login-slides/{id} — 문구·정렬·활성 여부 수정.
|
||||
export interface LoginSlideUpdateRequest {
|
||||
title: string;
|
||||
caption?: string | null;
|
||||
sortOrder?: number | null;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
@ -38,6 +39,10 @@ export function AdminDashboardPage() {
|
||||
<p className="kx-admin__subtitle">실시간 지표 및 운영 상태</p>
|
||||
</div>
|
||||
<div className="kx-admin__head-actions">
|
||||
{/* 관리자 하위 화면 — 로그인 슬라이드 관리 */}
|
||||
<Link to="/admin/login-slides" className="kx-btn kx-btn--secondary">
|
||||
로그인 슬라이드 관리
|
||||
</Link>
|
||||
{/* 플랫폼 슈퍼관리자 전용 테넌트 스위처(샘플) */}
|
||||
<select
|
||||
className="kx-select"
|
||||
|
||||
258
src/frontend/src/screens/admin/LoginSlideAdminPage.tsx
Normal file
258
src/frontend/src/screens/admin/LoginSlideAdminPage.tsx
Normal file
@ -0,0 +1,258 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { loginSlideApi } from '../../api/endpoints';
|
||||
import { ApiRequestError } from '../../api/client';
|
||||
import type { LoginSlideDto } from '../../api/types';
|
||||
import { Button } from '../../components/ui/Button';
|
||||
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
|
||||
import './admin.css';
|
||||
|
||||
/**
|
||||
* 관리자 — 로그인 슬라이드 관리 (M18 · /admin/login-slides).
|
||||
* 로그인 화면 좌측 캐러셀에 노출할 이미지+문구를 등록·수정·삭제·활성 토글한다.
|
||||
* 공개 노출 규칙: 활성 슬라이드 중 최신 등록순(created_at DESC) 최대 5건.
|
||||
*/
|
||||
export function LoginSlideAdminPage() {
|
||||
const [slides, setSlides] = useState<LoginSlideDto[] | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
|
||||
// 추가 폼 상태
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [preview, setPreview] = useState<string | null>(null);
|
||||
const [title, setTitle] = useState('');
|
||||
const [caption, setCaption] = useState('');
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [busyId, setBusyId] = useState<number | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
async function load() {
|
||||
setLoadError(null);
|
||||
setSlides(null);
|
||||
try {
|
||||
setSlides(await loginSlideApi.adminList());
|
||||
} catch (err) {
|
||||
setSlides([]);
|
||||
setLoadError(err instanceof ApiRequestError ? err.message : '목록을 불러오지 못했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
// 파일 선택 → 미리보기 URL 관리(누수 방지 revoke)
|
||||
useEffect(() => {
|
||||
if (!file) {
|
||||
setPreview(null);
|
||||
return;
|
||||
}
|
||||
const url = URL.createObjectURL(file);
|
||||
setPreview(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
}, [file]);
|
||||
|
||||
async function handleCreate(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setFormError(null);
|
||||
if (!file) {
|
||||
setFormError('슬라이드 이미지를 선택해 주세요.');
|
||||
return;
|
||||
}
|
||||
if (!title.trim()) {
|
||||
setFormError('타이틀 문구를 입력해 주세요.');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const created = await loginSlideApi.create(file, title.trim(), caption.trim());
|
||||
setSlides((prev) => [created, ...(prev ?? [])]); // 최신 등록이 맨 위(공개 로테이션 첫 슬라이드)
|
||||
setFile(null);
|
||||
setTitle('');
|
||||
setCaption('');
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
} catch (err) {
|
||||
setFormError(err instanceof ApiRequestError ? err.message : '등록에 실패했습니다.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleActive(slide: LoginSlideDto) {
|
||||
setBusyId(slide.id);
|
||||
try {
|
||||
const updated = await loginSlideApi.update(slide.id, {
|
||||
title: slide.title,
|
||||
caption: slide.caption,
|
||||
sortOrder: slide.sortOrder,
|
||||
active: !slide.active,
|
||||
});
|
||||
setSlides((prev) => (prev ?? []).map((s) => (s.id === slide.id ? updated : s)));
|
||||
} catch (err) {
|
||||
setLoadError(err instanceof ApiRequestError ? err.message : '변경에 실패했습니다.');
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(slide: LoginSlideDto) {
|
||||
if (!window.confirm(`"${slide.title}" 슬라이드를 삭제할까요?`)) return;
|
||||
setBusyId(slide.id);
|
||||
try {
|
||||
await loginSlideApi.remove(slide.id);
|
||||
setSlides((prev) => (prev ?? []).filter((s) => s.id !== slide.id));
|
||||
} catch (err) {
|
||||
setLoadError(err instanceof ApiRequestError ? err.message : '삭제에 실패했습니다.');
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
const activeCount = (slides ?? []).filter((s) => s.active).length;
|
||||
|
||||
return (
|
||||
<div className="kx-admin">
|
||||
<header className="kx-admin__head">
|
||||
<div>
|
||||
<h1 className="kx-admin__title">로그인 슬라이드 관리</h1>
|
||||
<p className="kx-admin__subtitle">
|
||||
로그인 화면 좌측 캐러셀 이미지·문구 — 활성 슬라이드 중 <strong>최신 등록순 5건</strong>이
|
||||
로테이션됩니다.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 새 슬라이드 추가 */}
|
||||
<section className="kx-card kx-slideadm__form-card" aria-label="새 슬라이드 추가">
|
||||
<div className="kx-card__head">
|
||||
<h2>새 슬라이드 추가</h2>
|
||||
</div>
|
||||
<form className="kx-slideadm__form" onSubmit={handleCreate}>
|
||||
<div className="kx-slideadm__form-grid">
|
||||
<div className="kx-slideadm__upload">
|
||||
<label className="kx-slideadm__label" htmlFor="slide-image">
|
||||
이미지 (PNG/JPG/WebP, 8MB 이하)
|
||||
</label>
|
||||
<input
|
||||
id="slide-image"
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
<div
|
||||
className={`kx-slideadm__preview ${preview ? 'has-image' : ''}`}
|
||||
style={preview ? { backgroundImage: `url(${preview})` } : undefined}
|
||||
>
|
||||
{!preview && <span>미리보기</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="kx-slideadm__fields">
|
||||
<div className="kx-field">
|
||||
<label htmlFor="slide-title">타이틀 문구</label>
|
||||
<input
|
||||
id="slide-title"
|
||||
type="text"
|
||||
maxLength={200}
|
||||
placeholder="예) 아시아를 대표하는 전시컨벤션센터, KINTEX"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="kx-field">
|
||||
<label htmlFor="slide-caption">캡션(보조 문구)</label>
|
||||
<input
|
||||
id="slide-caption"
|
||||
type="text"
|
||||
maxLength={500}
|
||||
placeholder="예) 10만㎡ 전시 공간에서 펼쳐지는 비즈니스 기회를 만나보세요."
|
||||
value={caption}
|
||||
onChange={(e) => setCaption(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{formError && (
|
||||
<p className="kx-slideadm__error" role="alert">
|
||||
{formError}
|
||||
</p>
|
||||
)}
|
||||
<div className="kx-slideadm__form-actions">
|
||||
<Button type="submit" disabled={submitting}>
|
||||
{submitting ? '등록 중…' : '슬라이드 등록'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{/* 목록 */}
|
||||
<section className="kx-card kx-slideadm__list-card" aria-label="슬라이드 목록">
|
||||
<div className="kx-card__head">
|
||||
<h2>
|
||||
등록된 슬라이드{' '}
|
||||
{slides && (
|
||||
<span className="kx-slideadm__count tnum">
|
||||
총 {slides.length}건 · 활성 {activeCount}건
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{loadError && <ErrorState message={loadError} onRetry={() => void load()} />}
|
||||
|
||||
{slides === null ? (
|
||||
<div className="kx-slideadm__skeletons">
|
||||
<Skeleton height={64} />
|
||||
<Skeleton height={64} />
|
||||
<Skeleton height={64} />
|
||||
</div>
|
||||
) : slides.length === 0 && !loadError ? (
|
||||
<EmptyState
|
||||
title="등록된 슬라이드가 없습니다"
|
||||
description="이미지와 문구를 등록하면 로그인 화면 캐러셀에 노출됩니다. (미등록 시 기본 5종 표시)"
|
||||
/>
|
||||
) : (
|
||||
<ul className="kx-slideadm__list">
|
||||
{slides.map((s, i) => (
|
||||
<li key={s.id} className={`kx-slideadm__row ${s.active ? '' : 'is-inactive'}`}>
|
||||
<div
|
||||
className="kx-slideadm__thumb"
|
||||
style={{ backgroundImage: `url(${s.imageUrl})` }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="kx-slideadm__body">
|
||||
<div className="kx-slideadm__row-top">
|
||||
<strong className="kx-slideadm__row-title">{s.title}</strong>
|
||||
{s.active && i < 5 && <span className="kx-slideadm__live-badge">노출 중</span>}
|
||||
{!s.active && <span className="kx-slideadm__off-badge">비활성</span>}
|
||||
</div>
|
||||
{s.caption && <p className="kx-slideadm__row-caption">{s.caption}</p>}
|
||||
<p className="kx-slideadm__row-meta tnum">등록 {s.createdAt}</p>
|
||||
</div>
|
||||
<div className="kx-slideadm__actions">
|
||||
<label className="kx-slideadm__toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={s.active}
|
||||
disabled={busyId === s.id}
|
||||
onChange={() => void handleToggleActive(s)}
|
||||
/>
|
||||
<span>활성</span>
|
||||
</label>
|
||||
<Button
|
||||
variant="danger"
|
||||
type="button"
|
||||
disabled={busyId === s.id}
|
||||
onClick={() => void handleDelete(s)}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -156,3 +156,171 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 로그인 슬라이드 관리 (/admin/login-slides) ── */
|
||||
.kx-slideadm__form {
|
||||
padding: var(--space-4) 0 0;
|
||||
}
|
||||
.kx-slideadm__form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 280px 1fr;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.kx-slideadm__form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.kx-slideadm__upload {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.kx-slideadm__label {
|
||||
font-size: var(--fs-caption);
|
||||
font-weight: 600;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
.kx-slideadm__upload input[type='file'] {
|
||||
font-size: var(--fs-caption);
|
||||
}
|
||||
.kx-slideadm__preview {
|
||||
height: 150px;
|
||||
border: 1px dashed var(--color-neutral-200);
|
||||
border-radius: var(--radius-sm);
|
||||
background-color: var(--color-neutral-050);
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-neutral-500);
|
||||
font-size: var(--fs-caption);
|
||||
}
|
||||
.kx-slideadm__preview.has-image {
|
||||
border-style: solid;
|
||||
}
|
||||
.kx-slideadm__preview.has-image span {
|
||||
display: none;
|
||||
}
|
||||
.kx-slideadm__fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
.kx-slideadm__form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.kx-slideadm__error {
|
||||
color: var(--color-error);
|
||||
font-size: var(--fs-caption);
|
||||
margin: -4px 0 0;
|
||||
}
|
||||
.kx-slideadm__count {
|
||||
font-size: var(--fs-caption);
|
||||
font-weight: 500;
|
||||
color: var(--color-neutral-500);
|
||||
margin-left: var(--space-2);
|
||||
}
|
||||
.kx-slideadm__skeletons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4) 0;
|
||||
}
|
||||
.kx-slideadm__list {
|
||||
list-style: none;
|
||||
margin: var(--space-3) 0 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.kx-slideadm__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-3) 0;
|
||||
border-top: 1px solid var(--color-neutral-200);
|
||||
}
|
||||
.kx-slideadm__row.is-inactive .kx-slideadm__thumb {
|
||||
filter: grayscale(1);
|
||||
opacity: 0.55;
|
||||
}
|
||||
.kx-slideadm__thumb {
|
||||
width: 112px;
|
||||
height: 64px;
|
||||
flex-shrink: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--color-neutral-200);
|
||||
background-color: var(--color-neutral-050);
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
}
|
||||
.kx-slideadm__body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.kx-slideadm__row-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.kx-slideadm__row-title {
|
||||
font-size: var(--fs-body);
|
||||
color: var(--color-neutral-900);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.kx-slideadm__live-badge,
|
||||
.kx-slideadm__off-badge {
|
||||
flex-shrink: 0;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-pill);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.kx-slideadm__live-badge {
|
||||
background: rgba(0, 102, 179, 0.1);
|
||||
color: var(--color-primary-600);
|
||||
}
|
||||
.kx-slideadm__off-badge {
|
||||
background: var(--color-neutral-050);
|
||||
color: var(--color-neutral-500);
|
||||
border: 1px solid var(--color-neutral-200);
|
||||
}
|
||||
.kx-slideadm__row-caption {
|
||||
font-size: var(--fs-caption);
|
||||
color: var(--color-neutral-700);
|
||||
margin-top: 2px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.kx-slideadm__row-meta {
|
||||
font-size: 11px;
|
||||
color: var(--color-neutral-500);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.kx-slideadm__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.kx-slideadm__toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: var(--fs-caption);
|
||||
color: var(--color-neutral-700);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.kx-slideadm__toggle input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: var(--color-primary-600);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@ -9,6 +9,7 @@ import { EmptyState } from '../../components/ui/States';
|
||||
import { InviteDialog } from './InviteDialog';
|
||||
import { SignupDialog } from './SignupDialog';
|
||||
import { ForgotPasswordDialog } from './ForgotPasswordDialog';
|
||||
import { LoginSlides } from './LoginSlides';
|
||||
import type { WorkspaceDto } from '../../api/types';
|
||||
import './login.css';
|
||||
|
||||
@ -127,21 +128,8 @@ export function LoginPage() {
|
||||
|
||||
return (
|
||||
<div className="kx-login">
|
||||
{/* 좌측 55% 브랜드 패널 */}
|
||||
<section className="kx-login__brand" aria-hidden="true">
|
||||
<div className="kx-login__brand-overlay" />
|
||||
<div className="kx-login__brand-content">
|
||||
<span className="kx-login__ai-cap">✦ AI 생성 예상 이미지</span>
|
||||
<h1 className="kx-login__headline">
|
||||
신청서를 내는 순간,
|
||||
<br />
|
||||
시공 후 사진을 먼저 봅니다.
|
||||
</h1>
|
||||
<p className="kx-login__sub">
|
||||
KINTEX AI 전시관리 솔루션으로 더 빠르고 정확하게 공간을 기획하세요.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
{/* 좌측 55% 브랜드 패널 — 관리자 등록 슬라이드 캐러셀(페이드·5초·도트) */}
|
||||
<LoginSlides />
|
||||
|
||||
{/* 우측 45% 로그인 카드 */}
|
||||
<section className="kx-login__panel">
|
||||
|
||||
139
src/frontend/src/screens/login/LoginSlides.tsx
Normal file
139
src/frontend/src/screens/login/LoginSlides.tsx
Normal file
@ -0,0 +1,139 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { loginSlideApi } from '../../api/endpoints';
|
||||
|
||||
/** 슬라이드 1장(공개 DTO 부분집합) — 폴백도 같은 shape 사용. */
|
||||
interface Slide {
|
||||
id: number;
|
||||
imageUrl: string;
|
||||
title: string;
|
||||
caption: string | null;
|
||||
}
|
||||
|
||||
/** API 실패/빈 응답 시 번들 기본 5종(시드와 동일 자산·문구). */
|
||||
const FALLBACK_SLIDES: Slide[] = [
|
||||
{
|
||||
id: -1,
|
||||
imageUrl: '/login-slides/slide-1.png',
|
||||
title: '아시아를 대표하는 전시컨벤션센터, KINTEX',
|
||||
caption: '10만㎡ 전시 공간에서 펼쳐지는 무한한 비즈니스 기회를 만나보세요.',
|
||||
},
|
||||
{
|
||||
id: -2,
|
||||
imageUrl: '/login-slides/slide-2.png',
|
||||
title: '신청서를 내는 순간, 시공 후 사진을 먼저 봅니다',
|
||||
caption: 'AI가 부스 배치부터 시공 결과 시각화까지 전 과정을 자동으로 설계합니다.',
|
||||
},
|
||||
{
|
||||
id: -3,
|
||||
imageUrl: '/login-slides/slide-3.png',
|
||||
title: '국내 최대 무주(無柱) 전시홀이 당신의 무대가 됩니다',
|
||||
caption: '기둥 없는 대공간에서 어떤 규모의 전시도 자유롭게 연출하세요.',
|
||||
},
|
||||
{
|
||||
id: -4,
|
||||
imageUrl: '/login-slides/slide-4.png',
|
||||
title: '회의부터 국제 컨벤션까지, 완벽한 비즈니스 공간',
|
||||
caption: '최첨단 회의 시설과 전문 운영 인력이 성공적인 행사를 지원합니다.',
|
||||
},
|
||||
{
|
||||
id: -5,
|
||||
imageUrl: '/login-slides/slide-5.png',
|
||||
title: '전시 운영의 모든 순간을 하나의 플랫폼에서',
|
||||
caption: '부스 배선·전기·조명 설계와 현장 검수까지 데이터로 관리하세요.',
|
||||
},
|
||||
];
|
||||
|
||||
const INTERVAL_MS = 5000;
|
||||
|
||||
/**
|
||||
* SCR-01 로그인 좌측 브랜드 패널 — 이미지 캐러셀(페이드 전환·5초·인디케이터 도트).
|
||||
* 슬라이드는 관리자 등록분(공개 API, 최신 5건)을 쓰고 실패 시 번들 기본 5종으로 폴백한다.
|
||||
*/
|
||||
export function LoginSlides() {
|
||||
const [slides, setSlides] = useState<Slide[]>(FALLBACK_SLIDES);
|
||||
const [index, setIndex] = useState(0);
|
||||
const timerRef = useRef<number | null>(null);
|
||||
|
||||
// 공개 슬라이드 로드 — 실패/빈 목록이면 폴백 유지(로그인 화면은 항상 그려져야 함).
|
||||
useEffect(() => {
|
||||
const ctrl = new AbortController();
|
||||
loginSlideApi
|
||||
.publicList()
|
||||
.then((list) => {
|
||||
if (!ctrl.signal.aborted && list && list.length > 0) {
|
||||
setSlides(list.map((s) => ({ id: s.id, imageUrl: s.imageUrl, title: s.title, caption: s.caption })));
|
||||
setIndex(0);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
/* 폴백 유지 */
|
||||
});
|
||||
return () => ctrl.abort();
|
||||
}, []);
|
||||
|
||||
// 자동 로테이션 — 슬라이드 2장 이상일 때만.
|
||||
useEffect(() => {
|
||||
if (slides.length < 2) return;
|
||||
timerRef.current = window.setInterval(
|
||||
() => setIndex((i) => (i + 1) % slides.length),
|
||||
INTERVAL_MS,
|
||||
);
|
||||
return () => {
|
||||
if (timerRef.current != null) window.clearInterval(timerRef.current);
|
||||
};
|
||||
}, [slides.length]);
|
||||
|
||||
function goTo(i: number) {
|
||||
setIndex(i);
|
||||
// 수동 이동 시 타이머 리셋(바로 다시 넘어가는 어색함 방지)
|
||||
if (timerRef.current != null) {
|
||||
window.clearInterval(timerRef.current);
|
||||
timerRef.current = window.setInterval(
|
||||
() => setIndex((v) => (v + 1) % slides.length),
|
||||
INTERVAL_MS,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const current = slides[index] ?? slides[0];
|
||||
|
||||
return (
|
||||
<section className="kx-login__brand" aria-label="KINTEX 소개 슬라이드">
|
||||
{slides.map((s, i) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className={`kx-login__slide ${i === index ? 'is-active' : ''}`}
|
||||
style={{ backgroundImage: `url(${s.imageUrl})` }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
))}
|
||||
<div className="kx-login__brand-overlay" />
|
||||
<div className="kx-login__brand-content">
|
||||
<span className="kx-login__ai-cap">✦ KINTEX AI 전시관리</span>
|
||||
<h1 className="kx-login__headline" key={`t-${current.id}`}>
|
||||
{current.title}
|
||||
</h1>
|
||||
{current.caption && (
|
||||
<p className="kx-login__sub" key={`c-${current.id}`}>
|
||||
{current.caption}
|
||||
</p>
|
||||
)}
|
||||
{slides.length > 1 && (
|
||||
<div className="kx-login__dots" role="tablist" aria-label="슬라이드 선택">
|
||||
{slides.map((s, i) => (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={i === index}
|
||||
aria-label={`슬라이드 ${i + 1}`}
|
||||
className={`kx-login__dot ${i === index ? 'is-active' : ''}`}
|
||||
onClick={() => goTo(i)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -8,7 +8,7 @@
|
||||
position: relative;
|
||||
width: 55%;
|
||||
display: none;
|
||||
background: #0a2a47 url('/brand/login-hero.jpg') center / cover no-repeat;
|
||||
background: #0a2a47;
|
||||
overflow: hidden;
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
@ -16,6 +16,61 @@
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
/* 캐러셀 슬라이드 — 페이드 전환(겹쳐 두고 opacity 토글) */
|
||||
.kx-login__slide {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
opacity: 0;
|
||||
transition: opacity 0.9s ease-in-out;
|
||||
}
|
||||
.kx-login__slide.is-active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 인디케이터 도트 */
|
||||
.kx-login__dots {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
.kx-login__dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: var(--radius-pill);
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease, width 0.2s ease;
|
||||
}
|
||||
.kx-login__dot.is-active {
|
||||
width: 22px;
|
||||
background: var(--color-white);
|
||||
}
|
||||
.kx-login__dot:focus-visible {
|
||||
outline: 2px solid var(--color-white);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* 문구 페이드 인 — 슬라이드 전환 시 key 교체로 재생 */
|
||||
.kx-login__headline,
|
||||
.kx-login__sub {
|
||||
animation: kx-login-copy-in 0.7s ease both;
|
||||
}
|
||||
@keyframes kx-login-copy-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
.kx-login__brand-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
@ -17,6 +17,11 @@ export default defineConfig({
|
||||
target: process.env.VITE_BACKEND_ORIGIN || 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
// 관리자 업로드 정적 파일(로그인 슬라이드 이미지) — 백엔드가 서빙
|
||||
'/uploads': {
|
||||
target: process.env.VITE_BACKEND_ORIGIN || 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/ws': {
|
||||
target: process.env.VITE_BACKEND_ORIGIN || 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
|
||||
277
stitch_kintex_ai_system_architect/scr_31_checkin_kiosk/code.html
Normal file
277
stitch_kintex_ai_system_architect/scr_31_checkin_kiosk/code.html
Normal file
@ -0,0 +1,277 @@
|
||||
<!DOCTYPE html>
|
||||
|
||||
<html class="light" lang="ko"><head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&family=Geist:wght@500;600&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<style>
|
||||
.material-symbols-outlined {
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||
}
|
||||
.glass-overlay {
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
.qr-scanner-frame {
|
||||
position: relative;
|
||||
}
|
||||
.qr-scanner-frame::before, .qr-scanner-frame::after,
|
||||
.qr-scanner-frame .corner-tl, .qr-scanner-frame .corner-tr,
|
||||
.qr-scanner-frame .corner-bl, .qr-scanner-frame .corner-br {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-color: #0066B3; /* Custom KINTEX Blue */
|
||||
border-width: 6px;
|
||||
}
|
||||
.corner-tl { top: 0; left: 0; border-right: 0; border-bottom: 0; }
|
||||
.corner-tr { top: 0; right: 0; border-left: 0; border-bottom: 0; }
|
||||
.corner-bl { bottom: 0; left: 0; border-right: 0; border-top: 0; }
|
||||
.corner-br { bottom: 0; right: 0; border-left: 0; border-top: 0; }
|
||||
|
||||
@keyframes scan-line {
|
||||
0% { top: 10%; }
|
||||
100% { top: 90%; }
|
||||
}
|
||||
.scan-line {
|
||||
position: absolute;
|
||||
height: 2px;
|
||||
width: 80%;
|
||||
left: 10%;
|
||||
background: linear-gradient(90deg, transparent, #0066B3, transparent);
|
||||
box-shadow: 0 0 15px #0066B3;
|
||||
animation: scan-line 2s infinite ease-in-out alternate;
|
||||
}
|
||||
</style>
|
||||
<script id="tailwind-config">
|
||||
tailwind.config = {
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
"colors": {
|
||||
"primary": "#003765",
|
||||
"secondary": "#5427e6",
|
||||
"kintex-blue": "#0066B3",
|
||||
"success-green": "#0E8A5F",
|
||||
"surface-bright": "#f9f9ff",
|
||||
"surface-container-lowest": "#ffffff",
|
||||
"surface-container-low": "#f2f3ff",
|
||||
"outline-variant": "#c1c7d3",
|
||||
"on-surface": "#141b2c",
|
||||
"on-surface-variant": "#424750",
|
||||
"error": "#ba1a1a"
|
||||
},
|
||||
"borderRadius": {
|
||||
"DEFAULT": "0.125rem",
|
||||
"lg": "0.25rem",
|
||||
"xl": "0.5rem",
|
||||
"full": "0.75rem"
|
||||
},
|
||||
"fontFamily": {
|
||||
"display-lg": ["Inter"],
|
||||
"headline-md": ["Inter"],
|
||||
"label-md": ["Geist"],
|
||||
"body-base": ["Inter"]
|
||||
},
|
||||
"fontSize": {
|
||||
"display-lg": ["36px", {"lineHeight": "44px", "letterSpacing": "-0.02em", "fontWeight": "700"}],
|
||||
"headline-md": ["24px", {"lineHeight": "32px", "fontWeight": "600"}],
|
||||
"label-md": ["12px", {"lineHeight": "16px", "letterSpacing": "0.02em", "fontWeight": "600"}],
|
||||
"body-base": ["14px", {"lineHeight": "20px", "fontWeight": "400"}]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body class="bg-surface-bright text-on-surface font-body-base overflow-hidden h-screen flex flex-col">
|
||||
<!-- Top Bar Component -->
|
||||
<header class="bg-surface-bright dark:bg-surface-dim border-b border-outline-variant dark:border-outline flex justify-between items-center w-full px-container-padding h-20 z-50">
|
||||
<div class="flex items-center gap-4">
|
||||
<h1 class="font-display-lg text-display-lg font-bold text-primary dark:text-inverse-primary tracking-tight">KINTEX Check-in</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-6">
|
||||
<div class="flex items-center gap-2 bg-surface-container-low px-4 py-2 rounded-xl border border-outline-variant">
|
||||
<span class="text-primary font-bold font-body-base">키오스크 모드</span>
|
||||
<button aria-checked="true" class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none bg-kintex-blue" id="kioskToggle" role="switch">
|
||||
<span class="translate-x-5 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex gap-4">
|
||||
<span class="material-symbols-outlined text-primary cursor-pointer active:scale-95 transition-all p-2 hover:bg-surface-container-low rounded-lg" data-icon="account_circle">account_circle</span>
|
||||
<span class="material-symbols-outlined text-primary cursor-pointer active:scale-95 transition-all p-2 hover:bg-surface-container-low rounded-lg" data-icon="settings">settings</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main class="flex flex-1 overflow-hidden">
|
||||
<!-- Center Column: QR & Main Search -->
|
||||
<section class="flex-1 overflow-y-auto px-12 py-10 flex flex-col items-center">
|
||||
<!-- Large QR Area -->
|
||||
<div class="w-full max-w-2xl mb-10 text-center">
|
||||
<div class="qr-scanner-frame relative w-[480px] h-[360px] mx-auto bg-black/5 rounded-xl flex items-center justify-center overflow-hidden border border-outline-variant/30">
|
||||
<div class="corner-tl"></div>
|
||||
<div class="corner-tr"></div>
|
||||
<div class="corner-bl"></div>
|
||||
<div class="corner-br"></div>
|
||||
<div class="scan-line"></div>
|
||||
<div class="flex flex-col items-center gap-4 text-on-surface-variant/40">
|
||||
<span class="material-symbols-outlined text-6xl" data-icon="qr_code_scanner">qr_code_scanner</span>
|
||||
<p class="font-headline-md text-headline-md">카메라 초기화 중...</p>
|
||||
</div>
|
||||
<!-- Viewfinder text overlay -->
|
||||
<div class="absolute bottom-6 left-0 right-0">
|
||||
<span class="bg-black/60 text-white px-6 py-2 rounded-full font-headline-md text-headline-md">배지 QR을 스캔하세요</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Manual Search -->
|
||||
<div class="w-full max-w-2xl mb-10">
|
||||
<div class="relative group">
|
||||
<span class="material-symbols-outlined absolute left-6 top-1/2 -translate-y-1/2 text-on-surface-variant text-2xl" data-icon="search">search</span>
|
||||
<input class="w-full pl-16 pr-6 py-6 rounded-full bg-surface-container-low border border-outline-variant focus:border-kintex-blue focus:ring-4 focus:ring-kintex-blue/10 transition-all font-headline-md text-headline-md placeholder:text-on-surface-variant/50" placeholder="이름, 연락처, 등록번호로 검색" type="text"/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Visitor Profile Card -->
|
||||
<div class="w-full max-w-2xl animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
<div class="bg-surface-container-lowest border border-outline-variant rounded-xl shadow-md overflow-hidden border-l-4 border-kintex-blue">
|
||||
<div class="p-8 flex gap-8">
|
||||
<div class="w-40 h-40 rounded-lg overflow-hidden flex-shrink-0 bg-surface-container">
|
||||
<img class="w-full h-full object-cover" src="https://lh3.googleusercontent.com/aida/AP1WRLsXFsekmPa77JGx0JMCs31rJ8hCsvn7xGfnRM9YyXX7chbyO5TIB8FjZBm5fuFcHrQrYs35rIMDAjOdQ7L5aS6UTo4NYut5TRziZbF74ZvNWLE74dsJn7t1r9a7q3Ix39o7yZfIRaOTvJrCAHxHopNa-re1XpQvCTPmq8Lv42KQOHH6nzp1ze0UtP0LbreAHgyKKhKyKztA-HxR1EaCG0tF47e0ojeYQQySFKsAjShPLTVV1GHZvv3aQa8"/>
|
||||
</div>
|
||||
<div class="flex-1 flex flex-col justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<h2 class="font-display-lg text-display-lg text-on-surface">정관람</h2>
|
||||
<span class="bg-primary-fixed text-on-primary-fixed px-3 py-1 rounded-full font-label-md text-label-md">바이어 (Buyer)</span>
|
||||
</div>
|
||||
<p class="text-on-surface-variant font-headline-md text-headline-md mb-1">(주)미래기술</p>
|
||||
<p class="text-on-surface-variant font-body-base">등록번호: 2024-KNTX-8802</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4 bg-surface-container-low flex gap-4">
|
||||
<button class="flex-1 bg-success-green hover:bg-success-green/90 text-white py-5 rounded-lg font-headline-md text-headline-md flex items-center justify-center gap-3 transition-colors active:scale-95">
|
||||
<span class="material-symbols-outlined" data-icon="print">print</span>
|
||||
즉석 배지 인쇄
|
||||
</button>
|
||||
<button class="flex-1 border-2 border-outline text-on-surface hover:bg-surface-container-high py-5 rounded-lg font-headline-md text-headline-md flex items-center justify-center gap-3 transition-colors active:scale-95">
|
||||
<span class="material-symbols-outlined" data-icon="refresh">refresh</span>
|
||||
재발급
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Right SideNavBar Component -->
|
||||
<aside class="fixed right-0 top-20 bottom-0 flex flex-col p-stack-lg bg-surface-container-lowest dark:bg-surface-container border-l border-outline-variant dark:border-outline w-80 z-40">
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<div class="w-10 h-10 rounded-full bg-kintex-blue flex items-center justify-center text-white font-bold">04</div>
|
||||
<div>
|
||||
<h3 class="font-headline-md text-headline-md text-primary leading-tight">Station 04</h3>
|
||||
<p class="font-label-md text-label-md text-on-surface-variant">Hall 1 Main Entrance</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Entry Widget -->
|
||||
<div class="flex-1 space-y-6">
|
||||
<div class="bg-surface-container-low rounded-xl p-6 border border-outline-variant">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h4 class="font-headline-md text-headline-md text-on-surface">실시간 입장</h4>
|
||||
<span class="material-symbols-outlined text-kintex-blue" data-icon="sensors">sensors</span>
|
||||
</div>
|
||||
<!-- Hourly Bar Chart (Simulated) -->
|
||||
<div class="flex items-end justify-between h-24 gap-1 mb-6">
|
||||
<div class="w-full bg-outline-variant/30 rounded-t h-[40%] hover:bg-kintex-blue/40 transition-colors"></div>
|
||||
<div class="w-full bg-outline-variant/30 rounded-t h-[60%] hover:bg-kintex-blue/40 transition-colors"></div>
|
||||
<div class="w-full bg-outline-variant/30 rounded-t h-[85%] hover:bg-kintex-blue/40 transition-colors"></div>
|
||||
<div class="w-full bg-kintex-blue rounded-t h-[95%]"></div>
|
||||
<div class="w-full bg-outline-variant/30 rounded-t h-[70%] hover:bg-kintex-blue/40 transition-colors"></div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-on-surface-variant font-label-md text-label-md mb-1">현재 장내 인원</p>
|
||||
<p class="font-display-lg text-display-lg text-kintex-blue">8,210명</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<button class="w-full flex items-center justify-between px-6 py-4 rounded-xl text-on-surface-variant hover:bg-surface-container-low transition-all duration-200 group">
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="material-symbols-outlined group-hover:text-primary transition-colors" data-icon="sensors">sensors</span>
|
||||
<span class="font-label-md text-label-md">Live Feed</span>
|
||||
</div>
|
||||
<span class="w-2 h-2 rounded-full bg-success-green animate-pulse"></span>
|
||||
</button>
|
||||
<button class="w-full flex items-center justify-between px-6 py-4 rounded-xl text-primary font-bold border-r-4 border-primary bg-primary-fixed-dim/20 transition-all duration-200">
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="material-symbols-outlined" data-icon="group" style="font-variation-settings: 'FILL' 1;">group</span>
|
||||
<span class="font-label-md text-label-md">Attendance</span>
|
||||
</div>
|
||||
</button>
|
||||
<button class="w-full flex items-center justify-between px-6 py-4 rounded-xl text-on-surface-variant hover:bg-surface-container-low transition-all duration-200 group">
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="material-symbols-outlined group-hover:text-primary transition-colors" data-icon="badge">badge</span>
|
||||
<span class="font-label-md text-label-md">Staff Info</span>
|
||||
</div>
|
||||
</button>
|
||||
<button class="w-full flex items-center justify-between px-6 py-4 rounded-xl text-on-surface-variant hover:bg-surface-container-low transition-all duration-200 group">
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="material-symbols-outlined group-hover:text-primary transition-colors" data-icon="help">help</span>
|
||||
<span class="font-label-md text-label-md">Help</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-auto space-y-4">
|
||||
<!-- Status Badge -->
|
||||
<div class="flex items-center gap-3 bg-error-container text-on-error-container px-4 py-3 rounded-lg border border-error/20">
|
||||
<span class="material-symbols-outlined text-error" data-icon="wifi_off">wifi_off</span>
|
||||
<span class="font-label-md text-label-md font-bold">오프라인 - 로컬 저장 후 동기화</span>
|
||||
</div>
|
||||
<button class="w-full py-4 rounded-xl bg-error/10 hover:bg-error/20 text-error font-bold flex items-center justify-center gap-2 transition-all active:scale-95">
|
||||
<span class="material-symbols-outlined" data-icon="exit_to_app">exit_to_app</span>
|
||||
근무 종료 (End Shift)
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</main>
|
||||
<!-- Simple Notification Bar -->
|
||||
<div class="fixed bottom-10 left-1/2 -translate-x-1/2 translate-y-20 opacity-0 bg-inverse-surface text-inverse-on-surface px-8 py-4 rounded-full flex items-center gap-4 shadow-xl transition-all duration-500 z-[100]" id="toast">
|
||||
<span class="material-symbols-outlined text-success-green" data-icon="check_circle">check_circle</span>
|
||||
<span class="font-headline-md text-headline-md">배지 인쇄를 시작합니다.</span>
|
||||
</div>
|
||||
<script>
|
||||
// Micro-interactions
|
||||
const kioskToggle = document.getElementById('kioskToggle');
|
||||
kioskToggle.addEventListener('click', () => {
|
||||
const isChecked = kioskToggle.getAttribute('aria-checked') === 'true';
|
||||
kioskToggle.setAttribute('aria-checked', !isChecked);
|
||||
kioskToggle.classList.toggle('bg-kintex-blue');
|
||||
kioskToggle.classList.toggle('bg-outline');
|
||||
kioskToggle.querySelector('span').classList.toggle('translate-x-5');
|
||||
kioskToggle.querySelector('span').classList.toggle('translate-x-0');
|
||||
});
|
||||
|
||||
const printBtn = document.querySelector('.bg-success-green');
|
||||
const toast = document.getElementById('toast');
|
||||
printBtn.addEventListener('click', () => {
|
||||
toast.classList.remove('translate-y-20', 'opacity-0');
|
||||
toast.classList.add('translate-y-0', 'opacity-100');
|
||||
setTimeout(() => {
|
||||
toast.classList.add('translate-y-20', 'opacity-0');
|
||||
toast.classList.remove('translate-y-0', 'opacity-100');
|
||||
}, 3000);
|
||||
});
|
||||
|
||||
// Simulating QR Scanner active scan
|
||||
setInterval(() => {
|
||||
const line = document.querySelector('.scan-line');
|
||||
if (line) {
|
||||
line.style.opacity = Math.random() > 0.1 ? "1" : "0.5";
|
||||
}
|
||||
}, 100);
|
||||
</script>
|
||||
</body></html>
|
||||
Loading…
Reference in New Issue
Block a user