대시보드 폴드아웃/드릴다운 정리 + 페르소나 역린·misconduct 반응 + 게이트웨이 격리·RAG 비차단 수정
SSOT 대시보드:
- 한신대 기술분석 PDF(19쪽) 정합성 분석 + 이번 세션 발견 섹션 추가
- 섹션 폴드아웃(접기)·상단 목차(드릴다운)·모두 펼치기/접기 — 내용 보존, 레이아웃만 정리
페르소나 반응 강화('저항·반응 조절' 핵심 차별):
- PersonaCard.triggers(역린) 필드 + CCD 핵심상처 파생 역린 블록
- L0에 무례·모욕·조롱 시 현실적 동맹 균열 반응 지침
버그·성능 수정(라이브/E2E로 포착):
- 게이트웨이 페르소나 격리: --append-system-prompt를 --system-prompt(교체)로 + --exclude-dynamic-system-prompt-sections (내담자 캐릭터 붕괴·개발맥락 누출 차단)
- RAG: 임베더 동기 로드(약 7-13초)를 _warm_rag_caches 백그라운드 warm으로(세션 생성 블로킹 회귀 수정)
- voice TTS RMS 데드힌트 제거, init_state OpennessParams 파라미터객체화
- 한국어 PII(날짜·금액·주소) 마스킹 보강
- 레이아웃 시각 게이트: 폼 컨트롤 값 스크롤 오탐 제외(7/7)
검증: 백엔드 84/84, E2E 42(데스크톱 27·모바일 11·아바타 4), 시각 게이트 7/7
This commit is contained in:
parent
cb2aebd76c
commit
085460b5e0
327 changed files with 31226 additions and 1829 deletions
|
|
@ -80,11 +80,60 @@ function SettingsLoadingState({
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 로딩 스켈레톤 — 회색 박스 반복 대신 섹션 형태(라벨/행 윤곽)를 미리 그려
|
||||
* 미완성·오류로 오인되지 않게 한다. 로딩 testId·role 은 그대로 유지한다.
|
||||
*/
|
||||
function SettingsSkeleton({
|
||||
children,
|
||||
testId,
|
||||
variant = "lines",
|
||||
}: {
|
||||
children: string;
|
||||
testId: string;
|
||||
variant?: "lines" | "rows" | "voice";
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`vg-set__skel vg-set__skel--${variant}`}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-testid={testId}
|
||||
>
|
||||
<span className="vg-set__skel-sr">{children}</span>
|
||||
{variant === "rows" ? (
|
||||
<>
|
||||
<span className="vg-set__skel-row" aria-hidden="true">
|
||||
<span className="vg-set__skel-bar" />
|
||||
<span className="vg-set__skel-pill" />
|
||||
</span>
|
||||
<span className="vg-set__skel-row" aria-hidden="true">
|
||||
<span className="vg-set__skel-bar" />
|
||||
<span className="vg-set__skel-pill" />
|
||||
</span>
|
||||
</>
|
||||
) : variant === "voice" ? (
|
||||
<>
|
||||
<span className="vg-set__skel-card" aria-hidden="true" />
|
||||
<span className="vg-set__skel-card" aria-hidden="true" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="vg-set__skel-line vg-set__skel-line--label" aria-hidden="true" />
|
||||
<span className="vg-set__skel-line" aria-hidden="true" />
|
||||
<span className="vg-set__skel-line vg-set__skel-line--short" aria-hidden="true" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Settings() {
|
||||
const { user } = useAuth();
|
||||
const role = user?.role ?? "learner";
|
||||
const isAdmin = role === "admin";
|
||||
const [dark, setDark] = useTheme();
|
||||
const [activeSection, setActiveSection] = useState("account");
|
||||
|
||||
const [profile, setProfile] = useState<UserProfileResponse | null>(null);
|
||||
const [displayName, setDisplayName] = useState(user?.name ?? "");
|
||||
|
|
@ -169,8 +218,8 @@ export default function Settings() {
|
|||
const base: NavItem[] = [
|
||||
{ id: "account", label: "계정", icon: "users" },
|
||||
{ id: "appearance", label: "테마", icon: "settings" },
|
||||
{ id: "voice", label: "음성", icon: "mic" },
|
||||
{ id: "notify", label: "알림", icon: "info" },
|
||||
{ id: "voice", label: "음성", icon: "mic" },
|
||||
];
|
||||
if (isAdmin) {
|
||||
base.splice(1, 0, { id: "engine", label: "AI 운영", icon: "shield", adminOnly: true });
|
||||
|
|
@ -178,6 +227,30 @@ export default function Settings() {
|
|||
return base;
|
||||
}, [isAdmin]);
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const visible = entries
|
||||
.filter((entry) => entry.isIntersecting)
|
||||
.sort((a, b) => b.intersectionRatio - a.intersectionRatio)[0];
|
||||
if (visible?.target.id) {
|
||||
setActiveSection(visible.target.id.replace(/^set-/, ""));
|
||||
}
|
||||
},
|
||||
{
|
||||
rootMargin: "-18% 0px -62% 0px",
|
||||
threshold: [0.2, 0.55],
|
||||
},
|
||||
);
|
||||
|
||||
for (const item of navItems) {
|
||||
const section = document.getElementById(`set-${item.id}`);
|
||||
if (section) observer.observe(section);
|
||||
}
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [navItems]);
|
||||
|
||||
const visibleNotifications = useMemo(
|
||||
() => [
|
||||
{
|
||||
|
|
@ -206,6 +279,7 @@ export default function Settings() {
|
|||
);
|
||||
|
||||
const goSection = (id: string) => {
|
||||
setActiveSection(id);
|
||||
document.getElementById(`set-${id}`)?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
};
|
||||
|
||||
|
|
@ -274,7 +348,9 @@ export default function Settings() {
|
|||
}) : cur);
|
||||
};
|
||||
|
||||
const initials = (displayName || profile?.email || "U").trim().slice(0, 1).toUpperCase();
|
||||
const accountName = displayName || profile?.email || user?.name || user?.email || "사용자";
|
||||
const accountEmail = profile?.email ?? user?.email ?? "";
|
||||
const initials = accountName.trim().slice(0, 1).toUpperCase();
|
||||
const engineHasRequiredFields = engineConfig
|
||||
? engineConfig.engine_url.trim().length > 0 && engineConfig.model.trim().length > 0
|
||||
: false;
|
||||
|
|
@ -290,12 +366,20 @@ export default function Settings() {
|
|||
|
||||
return (
|
||||
<AppShell contextLabel="설정">
|
||||
<div style={{ marginBottom: "var(--sp-6)" }}>
|
||||
<div className="vg-set__mast">
|
||||
<SectionHead
|
||||
kicker="설정"
|
||||
title="계정과 학습 환경을 관리합니다"
|
||||
desc="계정 표시 정보, 음성, 알림, 운영 설정을 한 곳에서 관리합니다."
|
||||
/>
|
||||
<div className="vg-set__mast-meta" aria-label="설정 상태">
|
||||
<span className="vg-set__pill">{roleLabel(role)}</span>
|
||||
{isAdmin ? (
|
||||
<span className={`vg-set__pill vg-set__pill--${engineServiceStatus}`}>
|
||||
AI {healthStatusLabel(engineService?.status)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
|
|
@ -308,26 +392,47 @@ export default function Settings() {
|
|||
) : null}
|
||||
|
||||
<div className="vg-set" aria-busy={loading}>
|
||||
<nav className="vg-set__nav" aria-label="설정 섹션">
|
||||
<div className="vg-set__nav-kicker">SETTINGS</div>
|
||||
{navItems.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className="vg-set__nav-item"
|
||||
onClick={() => goSection(item.id)}
|
||||
>
|
||||
<Icon name={item.icon} size={18} />
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<aside className="vg-set__rail">
|
||||
<div className="vg-set__rail-card" aria-label="계정 요약">
|
||||
<div className="vg-set__rail-avatar" aria-hidden="true">
|
||||
{initials}
|
||||
</div>
|
||||
<div className="vg-set__rail-copy">
|
||||
<div className="vg-set__rail-name">{accountName}</div>
|
||||
<div className="vg-set__rail-email">{accountEmail}</div>
|
||||
<div className="vg-set__rail-badges">
|
||||
<Badge tone="accent">{roleLabel(role)}</Badge>
|
||||
{affiliation ? <Badge tone="neutral">{affiliation}</Badge> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="vg-set__nav" aria-label="설정 섹션">
|
||||
<div className="vg-set__nav-kicker">SECTIONS</div>
|
||||
{navItems.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={
|
||||
"vg-set__nav-item" + (activeSection === item.id ? " is-active" : "")
|
||||
}
|
||||
aria-current={activeSection === item.id ? "location" : undefined}
|
||||
onClick={() => goSection(item.id)}
|
||||
>
|
||||
<Icon name={item.icon} size={17} />
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<div className="vg-set__forms">
|
||||
<Panel id="set-account" className="vg-set__group">
|
||||
<Panel id="set-account" className="vg-set__group vg-set__group--wide">
|
||||
<div className="vg-set__group-head">
|
||||
<div className="vg-set__group-title">계정</div>
|
||||
<div className="vg-set__group-desc">로그인 계정과 표시 정보를 관리합니다.</div>
|
||||
<div>
|
||||
<div className="vg-set__group-title">계정</div>
|
||||
<div className="vg-set__group-desc">로그인 계정과 표시 정보를 관리합니다.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="vg-set__profile">
|
||||
|
|
@ -335,8 +440,8 @@ export default function Settings() {
|
|||
{initials}
|
||||
</div>
|
||||
<div className="vg-set__profile-meta">
|
||||
<div className="n">{displayName || profile?.email}</div>
|
||||
<div className="e">{profile?.email ?? user?.email}</div>
|
||||
<div className="n">{accountName}</div>
|
||||
<div className="e">{accountEmail}</div>
|
||||
<div className="badges">
|
||||
<Badge tone="accent">{roleLabel(role)}</Badge>
|
||||
{affiliation ? <Badge tone="neutral">{affiliation}</Badge> : null}
|
||||
|
|
@ -344,12 +449,12 @@ export default function Settings() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="vg-set__row">
|
||||
<div className="vg-set__row-label">
|
||||
<span className="l">표시 이름</span>
|
||||
<span className="h">교수자와 운영자가 확인하는 이름입니다.</span>
|
||||
</div>
|
||||
<div className="vg-set__row-field">
|
||||
<div className="vg-set__field-grid vg-set__field-grid--account">
|
||||
<div className="vg-set__field">
|
||||
<div className="vg-set__field-copy">
|
||||
<span className="l">표시 이름</span>
|
||||
<span className="h">교수자와 운영자가 확인하는 이름입니다.</span>
|
||||
</div>
|
||||
<Field>
|
||||
<Input
|
||||
ref={displayNameInputRef}
|
||||
|
|
@ -359,30 +464,32 @@ export default function Settings() {
|
|||
displayNameValueRef.current = event.target.value;
|
||||
setDisplayName(event.target.value);
|
||||
}}
|
||||
placeholder="표시할 이름을 입력하세요"
|
||||
aria-label="표시 이름"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="vg-set__row">
|
||||
<div className="vg-set__row-label">
|
||||
<span className="l">이메일</span>
|
||||
<span className="h">로그인으로 확인된 주소입니다.</span>
|
||||
</div>
|
||||
<div className="vg-set__row-field">
|
||||
<div className="vg-set__field">
|
||||
<div className="vg-set__field-copy">
|
||||
<span className="l">이메일</span>
|
||||
<span className="h">로그인으로 확인된 주소입니다.</span>
|
||||
</div>
|
||||
<Field>
|
||||
<Input value={profile?.email ?? ""} disabled aria-label="이메일" />
|
||||
<Input
|
||||
value={profile?.email ?? ""}
|
||||
disabled
|
||||
placeholder="로그인 계정 이메일"
|
||||
aria-label="이메일"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="vg-set__row">
|
||||
<div className="vg-set__row-label">
|
||||
<span className="l">소속</span>
|
||||
<span className="h">강의나 연구 운영에서 표시할 소속입니다.</span>
|
||||
</div>
|
||||
<div className="vg-set__row-field">
|
||||
<div className="vg-set__field vg-set__field--wide">
|
||||
<div className="vg-set__field-copy">
|
||||
<span className="l">소속</span>
|
||||
<span className="h">강의나 연구 운영에서 표시할 소속입니다.</span>
|
||||
</div>
|
||||
<Field>
|
||||
<Input
|
||||
ref={affiliationInputRef}
|
||||
|
|
@ -392,6 +499,7 @@ export default function Settings() {
|
|||
affiliationValueRef.current = event.target.value;
|
||||
setAffiliation(event.target.value);
|
||||
}}
|
||||
placeholder="소속을 입력하세요"
|
||||
aria-label="소속"
|
||||
/>
|
||||
</Field>
|
||||
|
|
@ -405,43 +513,53 @@ export default function Settings() {
|
|||
저장됨
|
||||
</span>
|
||||
) : null}
|
||||
<Button onClick={() => void saveProfile()} disabled={loading || !profileReady}>
|
||||
<Button
|
||||
size="sm"
|
||||
leading={<Icon name="check" size={14} />}
|
||||
onClick={() => void saveProfile()}
|
||||
disabled={loading || !profileReady}
|
||||
>
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
{isAdmin ? (
|
||||
<Panel id="set-engine" className="vg-set__group">
|
||||
<div className="vg-set__group-head">
|
||||
<Kicker>관리자</Kicker>
|
||||
<div className="vg-set__group-title" style={{ marginTop: 6 }}>
|
||||
AI 운영
|
||||
</div>
|
||||
<div className="vg-set__group-desc">
|
||||
상담 응답 생성에 사용할 운영 연결과 기본 모델을 관리합니다.
|
||||
</div>
|
||||
</div>
|
||||
<div className={`vg-set__ops vg-set__ops--${engineServiceStatus}`} role="status">
|
||||
<span className="vg-set__ops-dot" aria-hidden="true" />
|
||||
<Panel
|
||||
id="set-engine"
|
||||
className="vg-set__group vg-set__group--wide vg-set__group--engine"
|
||||
>
|
||||
<div className="vg-set__group-head vg-set__group-head--split">
|
||||
<div>
|
||||
<b>응답 생성 {healthStatusLabel(engineService?.status)}</b>
|
||||
<span>
|
||||
{engineService
|
||||
? `${engineService.detail} · ${engineService.metric}`
|
||||
: "운영 상태를 확인하는 중입니다."}
|
||||
</span>
|
||||
<Kicker>관리자</Kicker>
|
||||
<div className="vg-set__group-title vg-set__group-title--kicker">
|
||||
AI 운영
|
||||
</div>
|
||||
<div className="vg-set__group-desc">
|
||||
상담 응답 생성에 사용할 운영 연결과 기본 모델을 관리합니다.
|
||||
</div>
|
||||
</div>
|
||||
<div className={`vg-set__ops vg-set__ops--${engineServiceStatus}`} role="status">
|
||||
<span className="vg-set__ops-dot" aria-hidden="true" />
|
||||
<div>
|
||||
<b>응답 생성 {healthStatusLabel(engineService?.status)}</b>
|
||||
<span>
|
||||
{engineService
|
||||
? `${engineService.detail} · ${engineService.metric}`
|
||||
: "운영 상태를 확인하는 중입니다."}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{engineConfig ? (
|
||||
<>
|
||||
<div className="vg-set__row">
|
||||
<div className="vg-set__row-label">
|
||||
<span className="l">운영 방식</span>
|
||||
<span className="h">응답 생성 연결 방식을 선택합니다.</span>
|
||||
</div>
|
||||
<div className="vg-set__row-field">
|
||||
<div className="vg-set__engine-grid">
|
||||
<div className="vg-set__control-block">
|
||||
<div className="vg-set__field-copy">
|
||||
<span className="l">운영 방식</span>
|
||||
<span className="h">응답 생성 연결 방식을 선택합니다.</span>
|
||||
</div>
|
||||
<div className="vg-set__seg" role="radiogroup" aria-label="AI 운영 방식">
|
||||
{ENGINE_MODES.map((mode) => (
|
||||
<button
|
||||
|
|
@ -461,46 +579,46 @@ export default function Settings() {
|
|||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="vg-set__row">
|
||||
<div className="vg-set__row-label">
|
||||
<span className="l">연결 주소</span>
|
||||
<span className="h">응답 생성 서비스의 연결 주소입니다.</span>
|
||||
</div>
|
||||
<div className="vg-set__row-field">
|
||||
<Field>
|
||||
<Input
|
||||
ref={engineUrlInputRef}
|
||||
value={engineConfig.engine_url}
|
||||
onChange={(event) =>
|
||||
updateEngineConfig({ engine_url: event.target.value })
|
||||
}
|
||||
placeholder="운영 연결 주소를 입력하세요"
|
||||
aria-label="AI 연결 주소"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
<div className="vg-set__field-grid vg-set__field-grid--engine">
|
||||
<div className="vg-set__field">
|
||||
<div className="vg-set__field-copy">
|
||||
<span className="l">연결 주소</span>
|
||||
<span className="h">응답 생성 서비스의 연결 주소입니다.</span>
|
||||
</div>
|
||||
<Field>
|
||||
<Input
|
||||
ref={engineUrlInputRef}
|
||||
value={engineConfig.engine_url}
|
||||
onChange={(event) =>
|
||||
updateEngineConfig({ engine_url: event.target.value })
|
||||
}
|
||||
placeholder="운영 연결 주소를 입력하세요"
|
||||
aria-label="AI 연결 주소"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="vg-set__row">
|
||||
<div className="vg-set__row-label">
|
||||
<span className="l">모델</span>
|
||||
<span className="h">응답 생성에 사용할 기본 모델 이름입니다.</span>
|
||||
</div>
|
||||
<div className="vg-set__row-field">
|
||||
<Field>
|
||||
<Input
|
||||
ref={engineModelInputRef}
|
||||
value={engineConfig.model}
|
||||
onChange={(event) => updateEngineConfig({ model: event.target.value })}
|
||||
placeholder="기본 모델 이름을 입력하세요"
|
||||
aria-label="모델"
|
||||
/>
|
||||
</Field>
|
||||
<div className="vg-set__meta">
|
||||
<span>최근 변경자: {engineConfig.updated_by ?? "-"}</span>
|
||||
<span>저장 상태: {engineStorageLabel}</span>
|
||||
<div className="vg-set__field">
|
||||
<div className="vg-set__field-copy">
|
||||
<span className="l">모델</span>
|
||||
<span className="h">응답 생성에 사용할 기본 모델 이름입니다.</span>
|
||||
</div>
|
||||
<Field>
|
||||
<Input
|
||||
ref={engineModelInputRef}
|
||||
value={engineConfig.model}
|
||||
onChange={(event) =>
|
||||
updateEngineConfig({ model: event.target.value })
|
||||
}
|
||||
placeholder="기본 모델 이름을 입력하세요"
|
||||
aria-label="모델"
|
||||
/>
|
||||
</Field>
|
||||
<div className="vg-set__meta">
|
||||
<span>최근 변경자: {engineConfig.updated_by ?? "-"}</span>
|
||||
<span>저장 상태: {engineStorageLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -513,6 +631,8 @@ export default function Settings() {
|
|||
</span>
|
||||
) : null}
|
||||
<Button
|
||||
size="sm"
|
||||
leading={<Icon name="check" size={14} />}
|
||||
onClick={() => void saveEngine()}
|
||||
disabled={loading || !engineHasRequiredFields}
|
||||
>
|
||||
|
|
@ -521,17 +641,19 @@ export default function Settings() {
|
|||
</div>
|
||||
</>
|
||||
) : (
|
||||
<SettingsLoadingState testId="settings-engine-loading">
|
||||
<SettingsSkeleton testId="settings-engine-loading" variant="lines">
|
||||
서버 AI 운영 설정을 불러오는 중입니다.
|
||||
</SettingsLoadingState>
|
||||
</SettingsSkeleton>
|
||||
)}
|
||||
</Panel>
|
||||
) : null}
|
||||
|
||||
<Panel id="set-appearance" className="vg-set__group">
|
||||
<Panel id="set-appearance" className="vg-set__group vg-set__group--compact">
|
||||
<div className="vg-set__group-head">
|
||||
<div className="vg-set__group-title">테마</div>
|
||||
<div className="vg-set__group-desc">화면 밝기를 설정합니다.</div>
|
||||
<div>
|
||||
<div className="vg-set__group-title">테마</div>
|
||||
<div className="vg-set__group-desc">화면 밝기를 설정합니다.</div>
|
||||
</div>
|
||||
</div>
|
||||
{preferences ? (
|
||||
<>
|
||||
|
|
@ -540,13 +662,7 @@ export default function Settings() {
|
|||
<div className="l">다크 모드</div>
|
||||
<div className="h">현재 기기와 브라우저에 적용됩니다.</div>
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "var(--sp-3)",
|
||||
}}
|
||||
>
|
||||
<span className="vg-set__toggle-line">
|
||||
<Icon name={dark ? "moon" : "sun"} size={16} />
|
||||
<Toggle checked={dark} onChange={setDark} label="다크 모드" />
|
||||
</span>
|
||||
|
|
@ -559,6 +675,8 @@ export default function Settings() {
|
|||
</span>
|
||||
) : null}
|
||||
<Button
|
||||
size="sm"
|
||||
leading={<Icon name="check" size={14} />}
|
||||
onClick={() => void savePreferences("appearance")}
|
||||
disabled={loading || !preferencesReady}
|
||||
>
|
||||
|
|
@ -567,16 +685,18 @@ export default function Settings() {
|
|||
</div>
|
||||
</>
|
||||
) : (
|
||||
<SettingsLoadingState testId="settings-preferences-loading">
|
||||
<SettingsSkeleton testId="settings-preferences-loading" variant="rows">
|
||||
서버 환경 설정을 불러오는 중입니다.
|
||||
</SettingsLoadingState>
|
||||
</SettingsSkeleton>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<Panel id="set-voice" className="vg-set__group">
|
||||
<Panel id="set-voice" className="vg-set__group vg-set__group--wide">
|
||||
<div className="vg-set__group-head">
|
||||
<div className="vg-set__group-title">음성</div>
|
||||
<div className="vg-set__group-desc">상담 연습에 사용할 음성 프리셋을 선택합니다.</div>
|
||||
<div>
|
||||
<div className="vg-set__group-title">음성</div>
|
||||
<div className="vg-set__group-desc">상담 연습에 사용할 음성 프리셋을 선택합니다.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{preferences ? (
|
||||
|
|
@ -619,12 +739,12 @@ export default function Settings() {
|
|||
})}
|
||||
</div>
|
||||
|
||||
<div className="vg-set__row" style={{ marginTop: "var(--sp-5)" }}>
|
||||
<div className="vg-set__row-label">
|
||||
<div className="vg-set__range-row">
|
||||
<div className="vg-set__field-copy">
|
||||
<span className="l">말하기 속도</span>
|
||||
<span className="h">기본 발화 속도입니다.</span>
|
||||
</div>
|
||||
<div className="vg-set__row-field">
|
||||
<div className="vg-set__range-control">
|
||||
<input
|
||||
type="range"
|
||||
min={0.8}
|
||||
|
|
@ -642,9 +762,8 @@ export default function Settings() {
|
|||
)
|
||||
}
|
||||
aria-label="말하기 속도"
|
||||
style={{ accentColor: "var(--accent)", width: "100%" }}
|
||||
/>
|
||||
<div className="vg-set__meta">
|
||||
<div className="vg-set__meta vg-set__meta--range">
|
||||
<span>0.8x</span>
|
||||
<span>{preferences.voice_rate.toFixed(2)}x</span>
|
||||
<span>1.2x</span>
|
||||
|
|
@ -660,6 +779,8 @@ export default function Settings() {
|
|||
</span>
|
||||
) : null}
|
||||
<Button
|
||||
size="sm"
|
||||
leading={<Icon name="check" size={14} />}
|
||||
onClick={() => void savePreferences("voice")}
|
||||
disabled={loading || !preferencesReady}
|
||||
>
|
||||
|
|
@ -673,37 +794,41 @@ export default function Settings() {
|
|||
</SettingsLoadingState>
|
||||
)
|
||||
) : (
|
||||
<SettingsLoadingState testId="settings-voice-loading">
|
||||
<SettingsSkeleton testId="settings-voice-loading" variant="voice">
|
||||
서버 음성 설정을 불러오는 중입니다.
|
||||
</SettingsLoadingState>
|
||||
</SettingsSkeleton>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<Panel id="set-notify" className="vg-set__group">
|
||||
<Panel id="set-notify" className="vg-set__group vg-set__group--compact">
|
||||
<div className="vg-set__group-head">
|
||||
<div className="vg-set__group-title">알림</div>
|
||||
<div className="vg-set__group-desc">역할에 맞는 알림만 표시됩니다.</div>
|
||||
<div>
|
||||
<div className="vg-set__group-title">알림</div>
|
||||
<div className="vg-set__group-desc">역할에 맞는 알림만 표시됩니다.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{preferences ? (
|
||||
<>
|
||||
{visibleNotifications.map((item) => (
|
||||
<div className="vg-set__opt" key={item.id}>
|
||||
<div className="vg-set__opt-text">
|
||||
<div className="l">{item.label}</div>
|
||||
<div className="h">{item.hint}</div>
|
||||
<div className="vg-set__opt-grid">
|
||||
{visibleNotifications.map((item) => (
|
||||
<div className="vg-set__opt" key={item.id}>
|
||||
<div className="vg-set__opt-text">
|
||||
<div className="l">{item.label}</div>
|
||||
<div className="h">{item.hint}</div>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={Boolean(
|
||||
preferences.notifications[item.id as keyof NotificationPreferences],
|
||||
)}
|
||||
onChange={(next) =>
|
||||
updateNotification(item.id as keyof NotificationPreferences, next)
|
||||
}
|
||||
label={item.label}
|
||||
/>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={Boolean(
|
||||
preferences.notifications[item.id as keyof NotificationPreferences],
|
||||
)}
|
||||
onChange={(next) =>
|
||||
updateNotification(item.id as keyof NotificationPreferences, next)
|
||||
}
|
||||
label={item.label}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="vg-set__foot">
|
||||
{savedKey === "notify" ? (
|
||||
|
|
@ -713,6 +838,8 @@ export default function Settings() {
|
|||
</span>
|
||||
) : null}
|
||||
<Button
|
||||
size="sm"
|
||||
leading={<Icon name="check" size={14} />}
|
||||
onClick={() => void savePreferences("notify")}
|
||||
disabled={loading || !preferencesReady}
|
||||
>
|
||||
|
|
@ -721,9 +848,9 @@ export default function Settings() {
|
|||
</div>
|
||||
</>
|
||||
) : (
|
||||
<SettingsLoadingState testId="settings-notify-loading">
|
||||
<SettingsSkeleton testId="settings-notify-loading" variant="rows">
|
||||
서버 알림 설정을 불러오는 중입니다.
|
||||
</SettingsLoadingState>
|
||||
</SettingsSkeleton>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue