From 8e5a5b7e6fe7eef27ec302dada56d352710491ee Mon Sep 17 00:00:00 2001 From: GUARDiA AutoDeploy Date: Wed, 3 Jun 2026 20:18:13 +0900 Subject: [PATCH] =?UTF-8?q?feat(icons):=204=EA=B0=9C=20=EC=8B=9C=EC=8A=A4?= =?UTF-8?q?=ED=85=9C=20=EB=9D=BC=EC=9D=B8=20SVG=20=EC=95=84=EC=9D=B4?= =?UTF-8?q?=EC=BD=98=20=EC=A0=84=EB=A9=B4=20=EC=A0=81=EC=9A=A9=20[auto-syn?= =?UTF-8?q?c]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 63 +++++-- app/(tabs)/chat.tsx | 15 +- app/(tabs)/index.tsx | 34 ++-- app/(tabs)/notifications.tsx | 79 +++++--- app/(tabs)/settings.tsx | 41 +++-- components/LineIcon.tsx | 339 +++++++++++++++++++++++++++++++++++ 6 files changed, 496 insertions(+), 75 deletions(-) create mode 100644 components/LineIcon.tsx diff --git a/CLAUDE.md b/CLAUDE.md index ba5c8eaa..3382a5c1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,18 +1,53 @@ -# GUARDiA Messenger +# GUARDiA Messenger — React Native 모바일 앱 -**저장소**: http://101.79.17.164:3000/zio/guardia-messenger -**배포**: EAS Build (Android APK/AAB, iOS IPA) -**패키지**: kr.co.zioinfo.guardia +> **Claude Code용 프로젝트 마스터 컨텍스트** -## 기술 스택 -- React Native 0.74 + Expo SDK 51 + TypeScript -- expo-router (파일 기반 라우팅) +--- -## 빌드 -```bash -eas build --platform android --profile preview # 테스트 APK -eas build --platform android --profile production # 스토어 AAB -``` +## 프로젝트 현황 -## 하네스 -- 앱 개발: `messenger-orchestrator` 스킬 +| 항목 | 상태 | +|------|------| +| 기술 스택 | React Native 0.74.5 + Expo SDK 51 + TypeScript | +| 패키지명 | kr.co.zioinfo.guardia | +| EAS 계정 | zioinfo | +| 프로젝트 ID | ca2f72d6-7dda-4491-9590-7ace34b10a88 | +| 최신 성공 빌드 | 51096ada (Android APK) | +| 서버 | https://zioinfo.co.kr:8443 (GUARDiA ITSM) | + +--- + +## 구현된 화면 (6개) + +| 화면 | 경로 | 기능 | +|------|------|------| +| 로그인 | `app/(auth)/login.tsx` | JWT 인증 | +| 대시보드 | `app/(tabs)/index.tsx` | SR 통계, 서비스 상태 | +| SR 관리 | `app/(tabs)/sr.tsx` | SR 목록·등록 | +| AI 챗봇 | `app/(tabs)/chat.tsx` | Ollama 연동 | +| 알림 | `app/(tabs)/notifications.tsx` | 푸시 알림 목록 | +| 설정 | `app/(tabs)/settings.tsx` | 프로필·로그아웃 | + +--- + +## 빌드 핵심 원칙 (위반 시 빌드 실패) + +1. `android/`, `ios/` 폴더 — 로컬 생성 금지 (`.easignore`로 EAS 제외) +2. `expo-notifications` — `app.json` 플러그인 등록 금지 +3. `babel.config.js` — `expo-router/babel` 추가 금지 +4. `plugins/withGradleProps.js` — `enablePngCrunchInReleaseBuilds=false` 필수 + +--- + +## 하네스: GUARDiA Messenger + +**목표:** 화면 개발·EAS 빌드·스토어 등록·문서화 전 과정 자동화 + +**트리거:** 화면 구현, 기능 추가, EAS 빌드, Play Store 등록, 가이드 작성 요청 시 +`messenger-orchestrator` 스킬을 사용하라. 다시 실행, 수정, 업데이트도 포함. + +**변경 이력:** +| 날짜 | 변경 내용 | 대상 | 사유 | +|------|----------|------|------| +| 2026-05-31 | 초기 하네스 구성 | 전체 | GUARDiA Messenger 앱 개발·배포 자동화 | +| 2026-05-31 | feature-developer 에이전트 + new-features 스킬 추가 | agents/, skills/ | DR·네트워크·CSAP·생체인증·오프라인 등 신규 기능 10종 | diff --git a/app/(tabs)/chat.tsx b/app/(tabs)/chat.tsx index f9b78bc1..ca26cc89 100644 --- a/app/(tabs)/chat.tsx +++ b/app/(tabs)/chat.tsx @@ -6,6 +6,7 @@ import { import { COLORS } from '../../constants/Config' import { sendAIMessage } from '../../services/api' import { useAuth } from '../../hooks/useAuth' +import LineIcon from '../../components/LineIcon' interface Msg { id: number; role: 'user' | 'ai'; text: string; time: string } @@ -50,7 +51,11 @@ export default function ChatScreen() { {msgs.map(m => ( - {m.role === 'ai' && 🤖} + {m.role === 'ai' && ( + + + + )} {m.text} {m.time} @@ -59,7 +64,9 @@ export default function ChatScreen() { ))} {loading && ( - 🤖 + + + @@ -92,7 +99,7 @@ export default function ChatScreen() { /> send()} disabled={!input.trim() || loading}> - + @@ -105,7 +112,7 @@ const s = StyleSheet.create({ messages: { flex:1 }, msgRow: { flexDirection:'row', alignItems:'flex-end', marginBottom:12, gap:8 }, userRow: { flexDirection:'row-reverse' }, - avatar: { fontSize:24, marginBottom:4 }, + avatar: { width:38, height:38, alignItems:'center', justifyContent:'center', marginBottom:4 }, bubble: { maxWidth:'75%', borderRadius:16, padding:12 }, aiBubble: { backgroundColor:'#fff', borderBottomLeftRadius:4, shadowColor:'#000', shadowOffset:{width:0,height:1}, shadowOpacity:.06, elevation:1 }, diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 182dde16..01c122cb 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -3,6 +3,7 @@ import { View, Text, ScrollView, StyleSheet, TouchableOpacity, RefreshControl, A import { COLORS, PRIORITY_COLOR } from '../../constants/Config' import { getDashboard, getLicenseStatus } from '../../services/api' import { useAuth } from '../../hooks/useAuth' +import LineIcon from '../../components/LineIcon' interface Stats { total_tasks: number @@ -14,13 +15,15 @@ interface Stats { recent_tasks?: any[] } +type IconName = Parameters[0]['name'] + /* Variant 스타일 StatCard — screenshot9 패턴 */ -function StatCard({ icon, label, value, color }: { icon: string; label: string; value: number | string; color: string }) { +function StatCard({ iconName, label, value, color }: { iconName: IconName; label: string; value: number | string; color: string }) { return ( {/* 아이콘 박스 — 연파랑 컨테이너 */} - {icon} + {/* 라벨 (위) */} {label} @@ -84,16 +87,16 @@ export default function Dashboard() { {/* 통계 카드 */} - - - - + + + + {/* 최근 SR */} {stats?.recent_tasks && stats.recent_tasks.length > 0 && ( - 📋 최근 서비스 요청 + 최근 서비스 요청 {stats.recent_tasks.slice(0, 5).map((sr: any) => ( @@ -108,16 +111,18 @@ export default function Dashboard() { {/* 빠른 실행 */} - ⚡ 빠른 실행 + 빠른 실행 {[ - { icon: '📝', label: 'SR 등록' }, - { icon: '🤖', label: 'AI 질문' }, - { icon: '📊', label: '리포트' }, - { icon: '🔒', label: '감사로그' }, + { iconName: 'sr' as const, label: 'SR 등록' }, + { iconName: 'ai' as const, label: 'AI 질문' }, + { iconName: 'dashboard'as const, label: '리포트' }, + { iconName: 'lock' as const, label: '감사로그' }, ].map(q => ( - {q.icon} + + + {q.label} ))} @@ -188,6 +193,9 @@ const s = StyleSheet.create({ backgroundColor: COLORS.bg, borderRadius: 12, flex: 1, marginHorizontal: 3, }, + quickIconBox: { width: 36, height: 36, borderRadius: 10, marginBottom: 5, + backgroundColor: 'rgba(0,160,200,.1)', + alignItems: 'center', justifyContent: 'center' }, quickIcon: { fontSize: 26, marginBottom: 5 }, quickLabel: { fontSize: 11, fontWeight: '600', color: COLORS.primary }, }) diff --git a/app/(tabs)/notifications.tsx b/app/(tabs)/notifications.tsx index 3af75980..ef789517 100644 --- a/app/(tabs)/notifications.tsx +++ b/app/(tabs)/notifications.tsx @@ -3,16 +3,32 @@ import { View, Text, ScrollView, StyleSheet, TouchableOpacity, RefreshControl, A import { COLORS } from '../../constants/Config' import { getNotifications, markNotificationRead } from '../../services/api' import { useWebSocket } from '../../hooks/useWebSocket' +import LineIcon from '../../components/LineIcon' -const TYPE_ICON: Record = { - SR_CREATED:'📋', SR_UPDATED:'🔄', SR_COMPLETED:'✅', - INCIDENT:'🚨', SLA_BREACH:'⏰', DEPLOY_SUCCESS:'🚀', - DEPLOY_FAIL:'❌', LICENSE:'🔑', SYSTEM:'⚙️', DEFAULT:'🔔', - sr_created:'📋', sr_updated:'🔄', sr_completed:'✅', - deploy_notify:'🚀', incident_created:'🚨', sla_breach:'⏰', - anomaly_alert:'⚠️', batch_notify:'⚙️', +type IconName = Parameters[0]['name'] + +const TYPE_ICON_NAME: Record = { + SR_CREATED: 'sr', SR_UPDATED: 'sync', SR_COMPLETED: 'check', + INCIDENT: 'alert', SLA_BREACH: 'bell', DEPLOY_SUCCESS: 'zap', + DEPLOY_FAIL:'alert', LICENSE: 'lock', SYSTEM: 'settings', + DEFAULT: 'bell', + sr_created: 'sr', sr_updated: 'sync', sr_completed: 'check', + deploy_notify: 'zap', incident_created: 'alert', sla_breach: 'bell', + anomaly_alert: 'alert', batch_notify: 'settings', } +/* 하위 호환: 이모지 대신 타입 색상 */ +const TYPE_COLOR: Record = { + SR_CREATED: '#00A0C8', SR_UPDATED: '#f59e0b', SR_COMPLETED: '#22c55e', + INCIDENT: '#ef4444', SLA_BREACH: '#f59e0b', DEPLOY_SUCCESS: '#22c55e', + DEPLOY_FAIL: '#ef4444', DEFAULT: '#64748b', + sr_created: '#00A0C8', sr_updated: '#f59e0b', sr_completed: '#22c55e', + deploy_notify: '#22c55e', incident_created: '#ef4444', +} + +/** 이전 코드와의 호환을 위한 빈 맵 (미사용) */ +const TYPE_ICON: Record = {} + interface NotifItem { id: string | number; type: string; title: string message: string; is_read: boolean; created_at: string @@ -107,9 +123,11 @@ export default function NotificationsScreen() { {wsEvents.length>0 && wsEvents[0] && ( - {TYPE_ICON[wsEvents[0]?.event_type] ?? '⚡'} + + + - ⚡ 실시간: {fmtTitle(wsEvents[0]?.event_type ?? '')} + 실시간: {fmtTitle(wsEvents[0]?.event_type ?? '')} {fmtMsg(wsEvents[0])} @@ -118,28 +136,35 @@ export default function NotificationsScreen() { load(true)} />}> {!loading && items.length===0 && ( - 🔔 + + + 알림이 없습니다. SR 등록 또는 배포 실행 시{'\n'}알림이 표시됩니다. )} - {items.map(n => ( - markRead(n.id)}> - - {TYPE_ICON[n.type] ?? TYPE_ICON.DEFAULT} - {n.source==='ws' && } - - - {n.title} - {n.message} - - {new Date(n.created_at).toLocaleString('ko-KR')} - {n.source==='ws' && ⚡ 실시간} + {items.map(n => { + const iconName = TYPE_ICON_NAME[n.type] ?? TYPE_ICON_NAME.DEFAULT + const iconColor = TYPE_COLOR[n.type] ?? '#64748b' + return ( + markRead(n.id)}> + + + {n.source==='ws' && } - - {!n.is_read && } - - ))} + + {n.title} + {n.message} + + {new Date(n.created_at).toLocaleString('ko-KR')} + {n.source==='ws' && 실시간} + + + {!n.is_read && } + + ) + })} @@ -163,6 +188,8 @@ const s = StyleSheet.create({ item: { flexDirection:'row', backgroundColor:'#fff', padding:14, borderBottomWidth:1, borderBottomColor:'#f1f5f9', gap:10, alignItems:'flex-start' }, unread: { backgroundColor:'#f8f9ff' }, + iconBox: { width:40, height:40, borderRadius:10, alignItems:'center', justifyContent:'center', + position:'relative', flexShrink:0 }, icon: { fontSize:24, marginTop:2 }, wsPin: { position:'absolute', top:-2, right:-4, width:8, height:8, borderRadius:4, backgroundColor:COLORS.accent }, iT: { fontSize:14, color:COLORS.text }, diff --git a/app/(tabs)/settings.tsx b/app/(tabs)/settings.tsx index 3b16b62c..198af111 100644 --- a/app/(tabs)/settings.tsx +++ b/app/(tabs)/settings.tsx @@ -2,13 +2,18 @@ import { View, Text, StyleSheet, TouchableOpacity, ScrollView, Alert, Switch, Li import { COLORS } from '../../constants/Config' import { useAuth } from '../../hooks/useAuth' import { useState } from 'react' +import LineIcon from '../../components/LineIcon' -function MenuItem({ icon, label, value, onPress, danger, toggle, enabled, onToggle }: - { icon: string; label: string; value?: string; onPress?: () => void; danger?: boolean +type IconName = Parameters[0]['name'] + +function MenuItem({ iconName, label, value, onPress, danger, toggle, enabled, onToggle }: + { iconName: IconName; label: string; value?: string; onPress?: () => void; danger?: boolean toggle?: boolean; enabled?: boolean; onToggle?: (v: boolean) => void }) { return ( - {icon} + + + {label} {value && {value}} @@ -50,42 +55,42 @@ export default function SettingsScreen() { {/* 서버 정보 */} 서버 연결 - - - + + + {/* 알림 설정 */} 알림 - - {}} /> - {}} /> + + {}} /> + {}} /> {/* 앱 설정 */} 앱 설정 - - - + + + {/* 지원 */} 지원 - Linking.openURL('http://zioinfo.co.kr:8090')} /> - Linking.openURL('https://zioinfo.co.kr:8443')} /> - Linking.openURL('mailto:guardia@zioinfo.co.kr')} /> - + {/* 로그아웃 */} - + @@ -108,7 +113,7 @@ const s = StyleSheet.create({ textTransform:'uppercase', letterSpacing:.5 }, item: { flexDirection:'row', alignItems:'center', paddingHorizontal:16, paddingVertical:14, borderBottomWidth:1, borderBottomColor:'#f1f5f9', gap:12 }, - itemIcon: { fontSize:20, width:28, textAlign:'center' }, + itemIcon: { width:34, height:34, borderRadius:9, alignItems:'center', justifyContent:'center' }, itemLabel: { fontSize:14, color:COLORS.text, fontWeight:'500' }, itemValue: { fontSize:12, color:COLORS.muted, marginTop:2 }, chevron: { fontSize:20, color:COLORS.muted }, diff --git a/components/LineIcon.tsx b/components/LineIcon.tsx new file mode 100644 index 00000000..1c070932 --- /dev/null +++ b/components/LineIcon.tsx @@ -0,0 +1,339 @@ +/** + * GUARDiA Messenger — 라인 아이콘 컴포넌트 (React Native) + * react-native-svg 없이 View + StyleSheet만 사용하여 선 아이콘을 구현. + * 폐쇄망/온프레미스 환경 호환. + * stroke 스타일은 View border로 표현, 복잡한 패스는 Text 유니코드 대체. + */ + +import { View, StyleSheet, Text } from 'react-native' + +interface Props { + name: keyof typeof ICONS + size?: number + color?: string +} + +/** 아이콘 이름 → 렌더 함수 맵 */ +const ICONS = { + dashboard: (s: number, c: string) => , + sr: (s: number, c: string) => , + chat: (s: number, c: string) => , + bell: (s: number, c: string) => , + settings: (s: number, c: string) => , + server: (s: number, c: string) => , + alert: (s: number, c: string) => , + check: (s: number, c: string) => , + sync: (s: number, c: string) => , + user: (s: number, c: string) => , + lock: (s: number, c: string) => , + globe: (s: number, c: string) => , + mail: (s: number, c: string) => , + send: (s: number, c: string) => , + mic: (s: number, c: string) => , + building: (s: number, c: string) => , + ai: (s: number, c: string) => , + zap: (s: number, c: string) => , +} as const + +export default function LineIcon({ name, size = 22, color = '#00A0C8' }: Props) { + const renderer = ICONS[name] + if (!renderer) return null + return renderer(size, color) +} + +/* ─── 개별 아이콘 구현 ───────────────────────────── */ + +function DashboardIcon({ size, color }: { size: number; color: string }) { + const b = size * 0.07 + return ( + + + + + + ) +} + +function SrIcon({ size, color }: { size: number; color: string }) { + const b = size * 0.07 + return ( + + + + + + + + + + ) +} + +function ChatIcon({ size, color }: { size: number; color: string }) { + const b = size * 0.07 + return ( + + + + + + ) +} + +function BellIcon({ size, color }: { size: number; color: string }) { + const b = size * 0.07 + return ( + + + + + + ) +} + +function SettingsIcon({ size, color }: { size: number; color: string }) { + const b = size * 0.07 + const r = (size - b * 2) / 2 + const innerR = r * 0.3 + return ( + + + + + ) +} + +function ServerIcon({ size, color }: { size: number; color: string }) { + const b = size * 0.07 + return ( + + + + + + + + + ) +} + +function AlertIcon({ size, color }: { size: number; color: string }) { + const b = size * 0.07 + return ( + + + + + + + + + ) +} + +function CheckIcon({ size, color }: { size: number; color: string }) { + const b = size * 0.07 + return ( + + + + + + + ) +} + +function SyncIcon({ size, color }: { size: number; color: string }) { + const b = size * 0.07 + return ( + + + + + ) +} + +function UserIcon({ size, color }: { size: number; color: string }) { + const b = size * 0.07 + return ( + + + + + ) +} + +function LockIcon({ size, color }: { size: number; color: string }) { + const b = size * 0.07 + return ( + + + + + + + ) +} + +function GlobeIcon({ size, color }: { size: number; color: string }) { + const b = size * 0.07 + return ( + + + + + + ) +} + +function MailIcon({ size, color }: { size: number; color: string }) { + const b = size * 0.07 + return ( + + + + + + + + ) +} + +function SendIcon({ size, color }: { size: number; color: string }) { + return ( + + ) +} + +function MicIcon({ size, color }: { size: number; color: string }) { + const b = size * 0.07 + return ( + + + + + + + ) +} + +function BuildingIcon({ size, color }: { size: number; color: string }) { + const b = size * 0.07 + return ( + + + + + + + + + + + ) +} + +function AiIcon({ size, color }: { size: number; color: string }) { + const b = size * 0.07 + return ( + + + + {[0,1,2].map(i => ( + + ))} + + + + ) +} + +function ZapIcon({ size, color }: { size: number; color: string }) { + return ( + + ) +}