Stabilize runtime auth and E2E coverage
This commit is contained in:
parent
6a3e3b541c
commit
188e899394
133 changed files with 55987 additions and 6775 deletions
|
|
@ -6,22 +6,22 @@
|
|||
머무는 단 하나의 오브젝트 → 산만하지 않게(동시 채널 ≤2), 실존 인물로 오인되지
|
||||
않게(코·주름·모공 없음, 상단 라벨 상시), 정서는 "감지되되 단언되지 않게".
|
||||
|
||||
기술(§4.7): SVG + CSS/Web Animations + Web Audio (1차안).
|
||||
기술(§4.7): Live2D Cubism 4 런타임 + SVG/CSS/Web Audio 폴백.
|
||||
- 단일 rAF 루프 useAvatarMotion: 호흡 + 깜빡임 + 립싱크(RMS) + saccade
|
||||
- 6파라미터 정서 상태머신(persona.ts): eyelidDrop/gazeAvert/shoulderTurn/
|
||||
breathRate/auraHue/blinkInterval. 라포 상승 시 저항 8°→0° 서서히(교육 핵심).
|
||||
- transform/opacity 만 애니메이션(layout 금지). 동시 움직임 ≤2 채널.
|
||||
- prefers-reduced-motion: 호흡/광배 정지 + 상태 텍스트.
|
||||
- analyser=null: 텍스트 길이/타이핑 기반 가짜 립싱크 폴백.
|
||||
- analyser=null: 음성 분석이 없으면 립싱크 없이 상태 모션만 유지.
|
||||
|
||||
── Rive 2차 교체 지점 ─────────────────────────────────────────────
|
||||
외부 .riv 아트가 준비되면, 이 컴포넌트의 입력 계약
|
||||
── Live2D 교체 지점 ─────────────────────────────────────────────
|
||||
외부 .model3.json 아트가 준비되면, 이 컴포넌트의 입력 계약
|
||||
(persona / state / affect / analyser / rapport) 은 그대로 두고
|
||||
내부의 <svg>+useAvatarMotion 묶음만 <RiveAvatar> 로 교체한다.
|
||||
AffectParams(persona.ts) → .riv state-machine input 으로 wiring.
|
||||
Live2DAvatar 가 Cubism model parameter 로 wiring 한다. 모델/코어가 없거나
|
||||
로딩 실패하면 SVG 폴백이 그대로 남는다.
|
||||
===================================================================== */
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Suspense, lazy, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
ageLookFor,
|
||||
baseResistanceOf,
|
||||
|
|
@ -41,6 +41,10 @@ import { Mouth } from "./Mouth";
|
|||
Session.tsx 등이 ClientAvatar 모듈에서 타입을 가져갈 수 있으므로 유지. */
|
||||
export type { AvatarState, AvatarAffect, AvatarPersona } from "./persona";
|
||||
|
||||
const Live2DAvatar = lazy(() =>
|
||||
import("./Live2DAvatar").then((mod) => ({ default: mod.Live2DAvatar })),
|
||||
);
|
||||
|
||||
export interface ClientAvatarProps {
|
||||
persona: AvatarPersona;
|
||||
state: AvatarState;
|
||||
|
|
@ -53,7 +57,7 @@ export interface ClientAvatarProps {
|
|||
*/
|
||||
rapport?: number;
|
||||
/**
|
||||
* analyser 없을 때 가짜 립싱크용 타이핑 진행도 0~1.
|
||||
* analyser 없을 때 선택적으로 전달하는 타이핑 진행도 0~1.
|
||||
* null/미지정이면 speaking 동안 차분한 의사 발화 모션.
|
||||
*/
|
||||
speakingProgress?: number | null;
|
||||
|
|
@ -89,6 +93,29 @@ const STATE_TEXT: Record<AvatarState, string> = {
|
|||
speaking: "이야기하는 중",
|
||||
};
|
||||
|
||||
const DISABLED_LIVE2D_MODEL_URLS = new Set(["off", "false", "none"]);
|
||||
const BUNDLED_DEMO_LIVE2D_MODEL_PATHS = new Set([
|
||||
"/live2d/mao/Mao.model3.json",
|
||||
"/live2d/haru/haru_greeter_t03.model3.json",
|
||||
]);
|
||||
|
||||
function isBundledDemoLive2DModel(url: string): boolean {
|
||||
try {
|
||||
return BUNDLED_DEMO_LIVE2D_MODEL_PATHS.has(
|
||||
new URL(url, "http://vignette.local").pathname,
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveLive2DModelUrl(persona: AvatarPersona): string | null {
|
||||
const url = persona.live2dModelUrl?.trim();
|
||||
if (!url || DISABLED_LIVE2D_MODEL_URLS.has(url.toLowerCase())) return null;
|
||||
if (import.meta.env.PROD && isBundledDemoLive2DModel(url)) return null;
|
||||
return url;
|
||||
}
|
||||
|
||||
export function ClientAvatar({
|
||||
persona,
|
||||
state,
|
||||
|
|
@ -115,6 +142,29 @@ export function ClientAvatar({
|
|||
() => resolveAffectParams(affect, state, effectiveRapport),
|
||||
[affect, state, effectiveRapport],
|
||||
);
|
||||
const live2DModelUrl = useMemo(() => resolveLive2DModelUrl(persona), [persona]);
|
||||
const [live2DReady, setLive2DReady] = useState(false);
|
||||
const [live2DFailed, setLive2DFailed] = useState(false);
|
||||
const [live2DError, setLive2DError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setLive2DReady(false);
|
||||
setLive2DFailed(false);
|
||||
setLive2DError(null);
|
||||
}, [live2DModelUrl]);
|
||||
|
||||
const handleLive2DReady = useCallback(() => {
|
||||
setLive2DReady(true);
|
||||
setLive2DError(null);
|
||||
}, []);
|
||||
|
||||
const handleLive2DUnavailable = useCallback((reason: string) => {
|
||||
setLive2DReady(false);
|
||||
setLive2DFailed(true);
|
||||
setLive2DError(reason);
|
||||
}, []);
|
||||
|
||||
const shouldUseLive2D = Boolean(live2DModelUrl) && !live2DFailed;
|
||||
|
||||
// 모션 루프 (reduced 면 정지)
|
||||
const frame = useAvatarMotion({
|
||||
|
|
@ -148,6 +198,8 @@ export function ClientAvatar({
|
|||
style={{ width: size }}
|
||||
data-state={state}
|
||||
data-affect={affect}
|
||||
data-live2d={live2DReady ? "ready" : shouldUseLive2D ? "loading" : "off"}
|
||||
data-live2d-error={live2DError ?? undefined}
|
||||
data-realism={realism} /* 사실성은 데이터로만 유지(시각 표식 비노출, §4.6) */
|
||||
aria-label={`교육용 가상 내담자: ${persona.label}, ${STATE_TEXT[state]}`}
|
||||
>
|
||||
|
|
@ -171,7 +223,7 @@ export function ClientAvatar({
|
|||
/>
|
||||
|
||||
<svg
|
||||
className="vg-avatar__svg"
|
||||
className={"vg-avatar__svg" + (live2DReady ? " is-live2d-covered" : "")}
|
||||
viewBox="0 0 200 200"
|
||||
width={size}
|
||||
height={size}
|
||||
|
|
@ -205,6 +257,23 @@ export function ClientAvatar({
|
|||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
{shouldUseLive2D && live2DModelUrl ? (
|
||||
<Suspense fallback={null}>
|
||||
<Live2DAvatar
|
||||
modelUrl={live2DModelUrl}
|
||||
state={state}
|
||||
affect={affect}
|
||||
params={params}
|
||||
analyser={analyser}
|
||||
speakingProgress={speakingProgress}
|
||||
reduced={reduced}
|
||||
size={size}
|
||||
onReady={handleLive2DReady}
|
||||
onUnavailable={handleLive2DUnavailable}
|
||||
/>
|
||||
</Suspense>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* 페르소나 메타 + 상태 텍스트 */}
|
||||
|
|
@ -237,7 +306,10 @@ const AVATAR_CSS = `
|
|||
}
|
||||
.vg-avatar__aura.is-reduced{animation:none;}
|
||||
@keyframes vgAuraBreathe{0%,100%{opacity:.85;transform:scale(1)}50%{opacity:1;transform:scale(1.04)}}
|
||||
.vg-avatar__svg{position:relative;z-index:1;display:block;}
|
||||
.vg-avatar__svg{position:relative;z-index:1;display:block;transition:opacity var(--dur-base) var(--ease-out);}
|
||||
.vg-avatar__svg.is-live2d-covered{opacity:0;}
|
||||
.vg-avatar__live2d{position:absolute;inset:0;z-index:2;display:flex;align-items:center;justify-content:center;pointer-events:none;}
|
||||
.vg-avatar__live2d-canvas{width:100%;height:100%;display:block;}
|
||||
.vg-avatar__meta{display:flex;flex-direction:column;align-items:center;gap:3px;text-align:center;}
|
||||
.vg-avatar__persona{font-size:var(--fs-sm);font-weight:600;color:var(--text-strong);}
|
||||
.vg-avatar__state{font-size:var(--fs-xs);color:var(--text-muted);}
|
||||
|
|
|
|||
383
apps/web/src/components/avatar/Live2DAvatar.tsx
Normal file
383
apps/web/src/components/avatar/Live2DAvatar.tsx
Normal file
|
|
@ -0,0 +1,383 @@
|
|||
import { useEffect, useRef } from "react";
|
||||
import type { AffectParams, AvatarAffect, AvatarState } from "./persona";
|
||||
|
||||
type PixiNamespace = typeof import("pixi.js");
|
||||
type PixiApplication = import("pixi.js").Application;
|
||||
type Live2DModule = typeof import("pixi-live2d-display/cubism4");
|
||||
type Live2DModelCtor = Live2DModule["Live2DModel"];
|
||||
type Live2DModelInstance = import("pixi-live2d-display/cubism4").Live2DModel;
|
||||
|
||||
interface Live2DAvatarProps {
|
||||
modelUrl: string;
|
||||
state: AvatarState;
|
||||
affect: AvatarAffect;
|
||||
params: AffectParams;
|
||||
analyser: AnalyserNode | null;
|
||||
speakingProgress: number | null;
|
||||
reduced: boolean;
|
||||
size: number;
|
||||
onReady: () => void;
|
||||
onUnavailable: (reason: string) => void;
|
||||
}
|
||||
|
||||
type MutableLive2DModel = Live2DModelInstance & {
|
||||
internalModel?: {
|
||||
coreModel?: {
|
||||
setParameterValueById?: (id: string, value: number, weight?: number) => void;
|
||||
addParameterValueById?: (id: string, value: number, weight?: number) => void;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type Live2DRefState = Pick<
|
||||
Live2DAvatarProps,
|
||||
"state" | "affect" | "params" | "analyser" | "speakingProgress" | "reduced" | "size"
|
||||
>;
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
PIXI?: PixiNamespace;
|
||||
Live2DCubismCore?: unknown;
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_CUBISM_CORE = "/live2d/live2dcubismcore.min.js";
|
||||
|
||||
const CORE_SCRIPT =
|
||||
import.meta.env.VITE_LIVE2D_CUBISM_CORE?.trim() || DEFAULT_CUBISM_CORE;
|
||||
|
||||
let pixiRegistered = false;
|
||||
let cubismCorePromise: Promise<void> | null = null;
|
||||
let runtimePromise:
|
||||
| Promise<{ PIXI: PixiNamespace; Live2DModel: Live2DModelCtor; MotionPreloadStrategy: Live2DModule["MotionPreloadStrategy"] }>
|
||||
| null = null;
|
||||
|
||||
function registerPixi(PIXI: PixiNamespace, Live2DModel: Live2DModelCtor) {
|
||||
if (pixiRegistered) return;
|
||||
window.PIXI = PIXI;
|
||||
Live2DModel.registerTicker(PIXI.Ticker);
|
||||
pixiRegistered = true;
|
||||
}
|
||||
|
||||
function loadLive2DRuntime() {
|
||||
if (!runtimePromise) {
|
||||
runtimePromise = Promise.all([
|
||||
import("pixi.js"),
|
||||
import("pixi-live2d-display/cubism4"),
|
||||
]).then(([PIXI, live2d]) => {
|
||||
registerPixi(PIXI, live2d.Live2DModel);
|
||||
return {
|
||||
PIXI,
|
||||
Live2DModel: live2d.Live2DModel,
|
||||
MotionPreloadStrategy: live2d.MotionPreloadStrategy,
|
||||
};
|
||||
});
|
||||
}
|
||||
return runtimePromise;
|
||||
}
|
||||
|
||||
function hasCubismCore(): boolean {
|
||||
return typeof window !== "undefined" && Boolean(window.Live2DCubismCore);
|
||||
}
|
||||
|
||||
function appendScript(src: string): Promise<void> {
|
||||
if (hasCubismCore()) return Promise.resolve();
|
||||
|
||||
const existing = Array.from(document.scripts).find(
|
||||
(script) => script.dataset.vgLive2dCore === src,
|
||||
);
|
||||
if (existing) {
|
||||
return new Promise((resolve, reject) => {
|
||||
existing.addEventListener("load", () => resolve(), { once: true });
|
||||
existing.addEventListener("error", () => reject(new Error(`Cubism Core load failed: ${src}`)), {
|
||||
once: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const script = document.createElement("script");
|
||||
script.src = src;
|
||||
script.async = true;
|
||||
script.dataset.vgLive2dCore = src;
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => reject(new Error(`Cubism Core load failed: ${src}`));
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
function ensureCubismCore(): Promise<void> {
|
||||
if (hasCubismCore()) return Promise.resolve();
|
||||
if (!cubismCorePromise) {
|
||||
cubismCorePromise = (async () => {
|
||||
await appendScript(CORE_SCRIPT);
|
||||
if (!hasCubismCore()) throw new Error("Cubism Core did not initialize.");
|
||||
})().catch((err) => {
|
||||
cubismCorePromise = null;
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
return cubismCorePromise;
|
||||
}
|
||||
|
||||
function fitModel(model: Live2DModelInstance, size: number) {
|
||||
model.anchor.set(0.5, 0.5);
|
||||
model.scale.set(1);
|
||||
|
||||
const bounds = model.getLocalBounds();
|
||||
const boundsWidth = Math.max(1, bounds.width);
|
||||
const boundsHeight = Math.max(1, bounds.height);
|
||||
const scale = Math.min((size * 0.86) / boundsWidth, (size * 0.98) / boundsHeight);
|
||||
|
||||
model.scale.set(scale);
|
||||
model.x = size / 2;
|
||||
model.y = size * 0.58;
|
||||
}
|
||||
|
||||
function fallbackMouthTarget(tSec: number, progress: number | null): number {
|
||||
if (progress !== null && progress >= 1) return 0;
|
||||
const syl = 0.5 + 0.5 * Math.sin(tSec * 2 * Math.PI * 5.5);
|
||||
const jitter = 0.5 + 0.5 * Math.sin(tSec * 2 * Math.PI * 11 + 1.3);
|
||||
return Math.min(0.85, 0.18 + 0.42 * syl * jitter);
|
||||
}
|
||||
|
||||
function mouthFromAnalyser(
|
||||
analyser: AnalyserNode | null,
|
||||
buffer: Float32Array<ArrayBuffer>,
|
||||
previous: number,
|
||||
dtSec: number,
|
||||
tSec: number,
|
||||
progress: number | null,
|
||||
): number {
|
||||
let target = fallbackMouthTarget(tSec, progress);
|
||||
if (analyser) {
|
||||
analyser.getFloatTimeDomainData(buffer);
|
||||
let sum = 0;
|
||||
for (let i = 0; i < buffer.length; i++) sum += buffer[i] * buffer[i];
|
||||
const rms = Math.sqrt(sum / buffer.length);
|
||||
target = rms < 0.04 ? 0 : Math.min(1, rms * 3.2);
|
||||
}
|
||||
const tau = 0.16;
|
||||
const alpha = 1 - Math.exp(-dtSec / tau);
|
||||
return previous + (target - previous) * alpha;
|
||||
}
|
||||
|
||||
function setParam(model: MutableLive2DModel, id: string, value: number, weight = 0.85) {
|
||||
try {
|
||||
model.internalModel?.coreModel?.setParameterValueById?.(id, value, weight);
|
||||
} catch {
|
||||
/* Some models omit optional standard parameters. */
|
||||
}
|
||||
}
|
||||
|
||||
function applyLive2DParams(
|
||||
model: MutableLive2DModel,
|
||||
snapshot: Live2DRefState,
|
||||
mouth: number,
|
||||
tSec: number,
|
||||
) {
|
||||
const { state, affect, params, reduced } = snapshot;
|
||||
if (reduced) return;
|
||||
|
||||
const gazeBase = state === "listening" ? -params.gazeAvert * 0.15 : -params.gazeAvert * 0.22;
|
||||
const thinkingDrift = state === "thinking" ? Math.sin(tSec * 0.9) * 0.08 : 0;
|
||||
const speakEnergy = state === "speaking" ? mouth : 0;
|
||||
const smile = Math.max(0, params.mouthCurve);
|
||||
const downturn = Math.max(0, -params.mouthCurve);
|
||||
|
||||
setParam(model, "ParamMouthOpenY", speakEnergy, 1);
|
||||
setParam(model, "ParamA", speakEnergy, 1);
|
||||
setParam(model, "ParamMouthForm", params.mouthCurve * 0.25, 0.35);
|
||||
setParam(model, "ParamMouthUp", smile * 0.45, 0.25);
|
||||
setParam(model, "ParamMouthDown", downturn * 0.45, 0.25);
|
||||
setParam(model, "ParamMouthAngry", affect === "resistant" ? 0.55 : 0, 0.25);
|
||||
setParam(model, "ParamEyeBallX", gazeBase + thinkingDrift, 0.5);
|
||||
setParam(model, "ParamEyeBallY", state === "thinking" ? -0.12 : 0.03, 0.45);
|
||||
setParam(model, "ParamAngleX", gazeBase * 14, 0.35);
|
||||
setParam(model, "ParamAngleY", state === "thinking" ? -3 : 1, 0.3);
|
||||
setParam(model, "ParamAngleZ", -params.shoulderTurn * 0.25, 0.25);
|
||||
setParam(model, "ParamBodyAngleX", -params.shoulderTurn * 0.45, 0.35);
|
||||
setParam(model, "ParamBreath", 0.5 + Math.sin(tSec * 2 * Math.PI / params.breathPeriod) * 0.22, 0.35);
|
||||
}
|
||||
|
||||
function focusPointFor(state: AvatarState, params: AffectParams, size: number): [number, number] {
|
||||
if (state === "thinking") return [size * 0.42, size * 0.62];
|
||||
if (state === "listening") return [size * 0.56, size * 0.48];
|
||||
if (state === "speaking") return [size * 0.5, size * 0.46];
|
||||
return [size * (0.5 - params.gazeAvert * 0.008), size * 0.52];
|
||||
}
|
||||
|
||||
function expressionsFor(affect: AvatarAffect): string[] {
|
||||
if (affect === "depressed") return ["sad", "f02", "f03", "exp_05", "exp_06"];
|
||||
if (affect === "anxious") return ["surprised", "f03", "f04", "exp_03", "exp_04"];
|
||||
if (affect === "resistant") return ["angry", "f06", "f07", "exp_07", "exp_08"];
|
||||
return ["normal", "f00", "f01", "exp_01", "exp_02"];
|
||||
}
|
||||
|
||||
async function tryMotion(model: Live2DModelInstance, groups: string[]) {
|
||||
for (const group of groups) {
|
||||
const ok = await model.motion(group).catch(() => false);
|
||||
if (ok) return;
|
||||
}
|
||||
}
|
||||
|
||||
async function tryExpression(model: Live2DModelInstance, names: string[]) {
|
||||
for (const name of names) {
|
||||
const ok = await model.expression(name).catch(() => false);
|
||||
if (ok) return;
|
||||
}
|
||||
}
|
||||
|
||||
export function Live2DAvatar({
|
||||
modelUrl,
|
||||
state,
|
||||
affect,
|
||||
params,
|
||||
analyser,
|
||||
speakingProgress,
|
||||
reduced,
|
||||
size,
|
||||
onReady,
|
||||
onUnavailable,
|
||||
}: Live2DAvatarProps) {
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const appRef = useRef<PixiApplication | null>(null);
|
||||
const modelRef = useRef<MutableLive2DModel | null>(null);
|
||||
const snapshotRef = useRef<Live2DRefState>({
|
||||
state,
|
||||
affect,
|
||||
params,
|
||||
analyser,
|
||||
speakingProgress,
|
||||
reduced,
|
||||
size,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
snapshotRef.current = { state, affect, params, analyser, speakingProgress, reduced, size };
|
||||
}, [state, affect, params, analyser, speakingProgress, reduced, size]);
|
||||
|
||||
useEffect(() => {
|
||||
const model = modelRef.current;
|
||||
if (!model) return;
|
||||
model.autoUpdate = !reduced;
|
||||
}, [reduced]);
|
||||
|
||||
useEffect(() => {
|
||||
const model = modelRef.current;
|
||||
if (!model || reduced) return;
|
||||
const [x, y] = focusPointFor(state, params, size);
|
||||
model.focus(x, y);
|
||||
void tryExpression(model, expressionsFor(affect));
|
||||
if (state === "speaking") void tryMotion(model, ["Speak", "Speaking", "TapBody"]);
|
||||
else if (state === "thinking") void tryMotion(model, ["Think", "Thinking"]);
|
||||
else if (state === "idle") void tryMotion(model, ["Idle", "idle"]);
|
||||
}, [affect, params, reduced, size, state]);
|
||||
|
||||
useEffect(() => {
|
||||
const app = appRef.current;
|
||||
const model = modelRef.current;
|
||||
if (!app || !model) return;
|
||||
app.renderer.resize(size, size);
|
||||
fitModel(model, size);
|
||||
}, [size]);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
if (!host) return;
|
||||
|
||||
let cancelled = false;
|
||||
let mouth = 0;
|
||||
let lastT = performance.now();
|
||||
const t0 = lastT;
|
||||
const audioBuffer: Float32Array<ArrayBuffer> = new Float32Array(1024);
|
||||
|
||||
const tick = () => {
|
||||
const model = modelRef.current;
|
||||
if (!model) return;
|
||||
const now = performance.now();
|
||||
const dtSec = Math.min(0.05, (now - lastT) / 1000);
|
||||
const tSec = (now - t0) / 1000;
|
||||
lastT = now;
|
||||
const snapshot = snapshotRef.current;
|
||||
if (snapshot.state === "speaking") {
|
||||
mouth = mouthFromAnalyser(
|
||||
snapshot.analyser,
|
||||
audioBuffer,
|
||||
mouth,
|
||||
dtSec,
|
||||
tSec,
|
||||
snapshot.speakingProgress,
|
||||
);
|
||||
} else if (mouth > 0.001) {
|
||||
mouth = mouth * Math.exp(-dtSec / 0.1);
|
||||
if (mouth < 0.005) mouth = 0;
|
||||
}
|
||||
applyLive2DParams(model, snapshot, mouth, tSec);
|
||||
};
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
await ensureCubismCore();
|
||||
const runtime = await loadLive2DRuntime();
|
||||
if (cancelled) return;
|
||||
|
||||
const app = new runtime.PIXI.Application({
|
||||
width: size,
|
||||
height: size,
|
||||
antialias: true,
|
||||
autoDensity: true,
|
||||
backgroundAlpha: 0,
|
||||
resolution: Math.min(window.devicePixelRatio || 1, 2),
|
||||
});
|
||||
appRef.current = app;
|
||||
|
||||
const canvas = app.view as HTMLCanvasElement;
|
||||
canvas.className = "vg-avatar__live2d-canvas";
|
||||
canvas.setAttribute("aria-hidden", "true");
|
||||
host.appendChild(canvas);
|
||||
|
||||
const model = (await runtime.Live2DModel.from(modelUrl, {
|
||||
autoInteract: false,
|
||||
autoUpdate: !snapshotRef.current.reduced,
|
||||
motionPreload: runtime.MotionPreloadStrategy.IDLE,
|
||||
})) as MutableLive2DModel;
|
||||
if (cancelled) {
|
||||
model.destroy({ children: true, texture: true, baseTexture: true });
|
||||
app.destroy(true, { children: true, texture: true, baseTexture: true });
|
||||
return;
|
||||
}
|
||||
|
||||
modelRef.current = model;
|
||||
fitModel(model, size);
|
||||
app.stage.addChild(model);
|
||||
app.ticker.add(tick);
|
||||
void tryMotion(model, ["Idle", "idle"]);
|
||||
onReady();
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
const reason = err instanceof Error ? err.message : "Live2D model load failed.";
|
||||
onUnavailable(reason);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
const app = appRef.current;
|
||||
const model = modelRef.current;
|
||||
if (app) app.ticker.remove(tick);
|
||||
modelRef.current = null;
|
||||
appRef.current = null;
|
||||
if (model && !model.destroyed) {
|
||||
model.destroy({ children: true, texture: true, baseTexture: true });
|
||||
}
|
||||
if (app) {
|
||||
app.destroy(true, { children: true, texture: true, baseTexture: true });
|
||||
}
|
||||
host.replaceChildren();
|
||||
};
|
||||
}, [modelUrl, onReady, onUnavailable, size]);
|
||||
|
||||
return <div className="vg-avatar__live2d" ref={hostRef} aria-hidden="true" />;
|
||||
}
|
||||
|
|
@ -34,6 +34,8 @@ export interface AvatarPersona {
|
|||
* 미지정 시 affect="resistant" → 0.6, 그 외 0 으로 본다.
|
||||
*/
|
||||
resistance?: number;
|
||||
/** Optional persona-specific Live2D Cubism 3/4 model3.json URL. Omit for SVG fallback. */
|
||||
live2dModelUrl?: string | null;
|
||||
}
|
||||
|
||||
/* ── 6파라미터 상태머신 (§4.5) ──────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -59,10 +59,10 @@ function smoothMouthFromRMS(
|
|||
return prev + (target - prev) * alpha;
|
||||
}
|
||||
|
||||
/* ── analyser 없을 때 가짜 립싱크 (§ 명세: 텍스트/타이핑 기반 폴백) ──────
|
||||
외부에서 speakingProgress(0~1, 발화 타이핑 진행) 를 주면 그걸 따라가고,
|
||||
없으면 음절감 있는 의사 RMS 를 합성한다(과하지 않게). */
|
||||
function fakeMouthTarget(tSec: number, progress: number | null): number {
|
||||
/* ── analyser 없을 때의 립싱크 폴백 ─────────────────────────────────────
|
||||
외부에서 speakingProgress(0~1, 발화 타이핑 진행)를 명시적으로 줄 때만
|
||||
낮은 진폭으로 움직인다. 실제 음성 분석이 없으면 합성 발화처럼 보이지 않게 닫아 둔다. */
|
||||
function fallbackMouthTarget(tSec: number, progress: number | null): number {
|
||||
if (progress !== null) {
|
||||
// 타이핑 진행 중에만 입을 움직임. 진행이 멈추면(완료) 닫힘.
|
||||
if (progress >= 1) return 0;
|
||||
|
|
@ -72,17 +72,14 @@ function fakeMouthTarget(tSec: number, progress: number | null): number {
|
|||
const base = 0.18 + 0.42 * syl * jitter;
|
||||
return Math.min(0.85, base);
|
||||
}
|
||||
// progress 미제공: 차분한 의사 발화 (말하는 듯한 진폭)
|
||||
const a = 0.5 + 0.5 * Math.sin(tSec * 2 * Math.PI * 4.5);
|
||||
const b = 0.5 + 0.5 * Math.sin(tSec * 2 * Math.PI * 9.2 + 0.7);
|
||||
return Math.min(0.8, 0.15 + 0.45 * a * b);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export interface AvatarMotionOptions {
|
||||
state: AvatarState;
|
||||
params: AffectParams;
|
||||
analyser: AnalyserNode | null;
|
||||
/** analyser 없을 때 가짜 립싱크용 타이핑 진행도 0~1 (null=의사 발화) */
|
||||
/** analyser 없을 때 선택적으로 쓰는 타이핑 진행도 0~1 */
|
||||
speakingProgress?: number | null;
|
||||
/** false 면 루프 정지(reduced-motion) — IDLE_FRAME 고정 */
|
||||
enabled: boolean;
|
||||
|
|
@ -188,14 +185,14 @@ export function useAvatarMotion({
|
|||
}
|
||||
}
|
||||
|
||||
// ── 4) 립싱크 (speaking 시만; analyser 우선, 없으면 폴백) ──
|
||||
// ── 4) 립싱크 (speaking 시만; analyser 우선, 없으면 정적 폴백) ──
|
||||
let mouth = mouthPrev;
|
||||
if (st === "speaking") {
|
||||
const a = analyserRef.current;
|
||||
if (a) {
|
||||
mouthPrev = smoothMouthFromRMS(a, audioBuf, mouthPrev, dtSec);
|
||||
} else {
|
||||
const target = fakeMouthTarget(tSec, progressRef.current ?? null);
|
||||
const target = fallbackMouthTarget(tSec, progressRef.current ?? null);
|
||||
const tau = 0.12;
|
||||
const alpha = 1 - Math.exp(-dtSec / tau);
|
||||
mouthPrev = mouthPrev + (target - mouthPrev) * alpha;
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ export interface AppShellProps {
|
|||
hideNav?: boolean;
|
||||
/** 메인 패딩·최대폭 제거 (풀-블리드 레이아웃) */
|
||||
bleed?: boolean;
|
||||
/** 톱바까지 제거하는 실제 전체화면 작업 공간 */
|
||||
hideTopbar?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -18,13 +20,13 @@ export interface AppShellProps {
|
|||
* body[data-role] 은 AuthProvider 가 관리(여기선 셸 골격만).
|
||||
* 미인증 시에도 안전하게 렌더(네비 없이) — 가드는 라우터(RequireAuth)가 담당.
|
||||
*/
|
||||
export function AppShell({ children, contextLabel, hideNav, bleed }: AppShellProps) {
|
||||
export function AppShell({ children, contextLabel, hideNav, bleed, hideTopbar }: AppShellProps) {
|
||||
const { user } = useAuth();
|
||||
const showNav = !hideNav && !!user;
|
||||
|
||||
return (
|
||||
<div className="vg-shell">
|
||||
<Topbar contextLabel={contextLabel} />
|
||||
<div className={"vg-shell" + (hideTopbar ? " vg-shell--fullscreen" : "")}>
|
||||
{hideTopbar ? null : <Topbar contextLabel={contextLabel} />}
|
||||
<div className={"vg-shell__body" + (showNav ? "" : " vg-shell__body--bare")}>
|
||||
{showNav ? <Sidebar role={user.role} /> : null}
|
||||
<main className={"vg-main" + (bleed ? " vg-main--bleed" : "")}>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@
|
|||
min-height: 100vh;
|
||||
background: var(--bg-app);
|
||||
}
|
||||
.vg-shell--fullscreen {
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
/* ── 톱바 ── */
|
||||
.vg-topbar {
|
||||
|
|
@ -42,7 +45,7 @@
|
|||
.vg-topbar__wm {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
letter-spacing: 0;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
.vg-topbar__wm .v {
|
||||
|
|
@ -219,6 +222,9 @@
|
|||
.vg-shell__body {
|
||||
grid-template-columns: var(--nav-w-collapsed) 1fr;
|
||||
}
|
||||
.vg-shell__body--bare {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.vg-nav {
|
||||
padding: var(--sp-4) var(--sp-2);
|
||||
}
|
||||
|
|
@ -233,6 +239,49 @@
|
|||
}
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
html {
|
||||
scroll-padding-top: calc(var(--topbar-h) + 68px);
|
||||
scroll-padding-bottom: var(--sp-6);
|
||||
}
|
||||
.vg-shell__body {
|
||||
display: block;
|
||||
}
|
||||
.vg-shell__body--bare {
|
||||
display: block;
|
||||
}
|
||||
.vg-nav {
|
||||
position: sticky;
|
||||
top: var(--topbar-h);
|
||||
z-index: 29;
|
||||
height: 58px;
|
||||
padding: 7px max(12px, env(safe-area-inset-left)) 7px max(12px, env(safe-area-inset-right));
|
||||
border-right: 0;
|
||||
border-top: 0;
|
||||
border-bottom: 1px solid var(--hair);
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
.vg-nav__label,
|
||||
.vg-nav__spacer,
|
||||
.vg-nav__foot {
|
||||
display: none;
|
||||
}
|
||||
.vg-nav__item {
|
||||
min-width: 112px;
|
||||
height: 44px;
|
||||
justify-content: center;
|
||||
padding: 0 14px;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.vg-nav__item span:not(.vg-nav__ic) {
|
||||
display: inline;
|
||||
font-size: 13px;
|
||||
}
|
||||
.vg-topbar__role {
|
||||
display: none;
|
||||
}
|
||||
|
|
@ -242,4 +291,7 @@
|
|||
.vg-main {
|
||||
padding: var(--sp-5) var(--sp-4) var(--sp-7);
|
||||
}
|
||||
.vg-main--bleed {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useId } from "react";
|
||||
import { forwardRef, useId } from "react";
|
||||
import type { InputHTMLAttributes, ReactNode } from "react";
|
||||
|
||||
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
|
|
@ -6,16 +6,20 @@ export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
|||
}
|
||||
|
||||
/** Input — radius 6px, 포커스 보더색 + ring. 좌측바 금지. §7.2 */
|
||||
export function Input({ invalid, className, ...rest }: InputProps) {
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||
{ invalid, className, ...rest },
|
||||
ref,
|
||||
) {
|
||||
const cls = ["vg-input", className ?? ""].filter(Boolean).join(" ");
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
className={cls}
|
||||
aria-invalid={invalid ? "true" : undefined}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export interface FieldProps {
|
||||
/** 라벨 텍스트 */
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { clamp01 } from "../../lib/format";
|
||||
|
||||
export type ProgressTone = "accent" | "muted" | "warn" | "clay";
|
||||
export type ProgressTone = "accent" | "muted" | "warn" | "crit" | "clay";
|
||||
|
||||
export interface ProgressBarProps {
|
||||
/** 0~1 비율 (또는 value/max) */
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@
|
|||
.vg-sechead__title {
|
||||
font-size: var(--fs-h2);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.015em;
|
||||
letter-spacing: 0;
|
||||
color: var(--text-strong);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
|
@ -228,7 +228,7 @@
|
|||
font-weight: 700;
|
||||
color: var(--text-strong);
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: -0.02em;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.vg-statline__lab {
|
||||
font-size: var(--fs-xs);
|
||||
|
|
@ -263,6 +263,9 @@
|
|||
.vg-progress__fill--warn {
|
||||
background: var(--warn-solid);
|
||||
}
|
||||
.vg-progress__fill--crit {
|
||||
background: var(--crit-solid);
|
||||
}
|
||||
.vg-progress__fill--clay {
|
||||
background: var(--clay);
|
||||
}
|
||||
|
|
@ -321,7 +324,7 @@
|
|||
.vg-empty__title {
|
||||
font-size: var(--fs-h1);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
letter-spacing: 0;
|
||||
color: var(--text-strong);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,12 +3,22 @@
|
|||
백엔드 계약: apps/api/app/routes (auth.py, sessions.py).
|
||||
- 모든 요청은 credentials:"include" (BFF __Host-vignette_sid HttpOnly 쿠키).
|
||||
- 에러는 ApiError 로 표준화.
|
||||
- SSE: GET /sessions/{id}/stream 의 token/done/ping/error 이벤트를 콜백으로 전달.
|
||||
- SSE: POST /sessions/{id}/stream 의 token/done/ping/error 이벤트를 콜백으로 전달.
|
||||
===================================================================== */
|
||||
|
||||
// Vite 환경변수. 기본 "/api" (vite proxy 또는 nginx 가 백엔드로 라우팅).
|
||||
const API_BASE: string =
|
||||
(import.meta.env.VITE_API_BASE as string | undefined) ?? "/api";
|
||||
function defaultApiBase(): string {
|
||||
if (typeof window !== "undefined") {
|
||||
const host = window.location.hostname;
|
||||
if (host === "vignette.chanpaca.net" || host.endsWith(".pages.dev")) {
|
||||
return "https://api-vignette.chanpaca.net";
|
||||
}
|
||||
}
|
||||
return "/api";
|
||||
}
|
||||
|
||||
const configuredApiBase = (import.meta.env.VITE_API_BASE as string | undefined)?.trim();
|
||||
const API_BASE: string = configuredApiBase || defaultApiBase();
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
|
|
@ -43,6 +53,16 @@ function joinUrl(path: string): string {
|
|||
return `${base}${p}`;
|
||||
}
|
||||
|
||||
export function apiUrl(path: string): string {
|
||||
return joinUrl(path);
|
||||
}
|
||||
|
||||
export function apiWsUrl(path: string): string {
|
||||
const url = new URL(joinUrl(path), window.location.origin);
|
||||
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
async function parseError(res: Response): Promise<ApiError> {
|
||||
let detail = res.statusText || "request failed";
|
||||
let body: unknown = undefined;
|
||||
|
|
@ -126,19 +146,47 @@ export const api = {
|
|||
/** GET /auth/me — auth.py MeResponse */
|
||||
export interface MeResponse {
|
||||
user_id: string;
|
||||
email: string;
|
||||
display_name: string;
|
||||
role: string; // "learner" | "teacher" | "admin"
|
||||
cohort_ids: string[];
|
||||
}
|
||||
|
||||
export interface AuthConfigResponse {
|
||||
google_oauth_configured: boolean;
|
||||
allowed_email_domains: string[];
|
||||
redirect_uri: string;
|
||||
dev_login_enabled: boolean;
|
||||
}
|
||||
|
||||
export const authApi = {
|
||||
config: () => api.get<AuthConfigResponse>("/auth/config"),
|
||||
};
|
||||
|
||||
export type SessionStage = "라포" | "탐색" | "개입" | "정리";
|
||||
|
||||
/** GET /personas — personas.py PersonaSummary */
|
||||
export interface PersonaSummary {
|
||||
code: string;
|
||||
display_name: string;
|
||||
difficulty: "easy" | "moderate" | "hard" | string;
|
||||
theory_target: string[];
|
||||
demographics: Record<string, unknown>;
|
||||
presenting_summary: string;
|
||||
voice_preset: string | null;
|
||||
source: string;
|
||||
degraded: boolean;
|
||||
}
|
||||
|
||||
/** POST /sessions — sessions.py SessionStartResponse */
|
||||
export interface SessionStartResponse {
|
||||
session_id: string;
|
||||
case_id: string;
|
||||
session_no: number;
|
||||
stage: SessionStage;
|
||||
effective_openness: number;
|
||||
recall_summary: string | null;
|
||||
degraded: boolean;
|
||||
}
|
||||
|
||||
/** POST /sessions/{id}/turn — sessions.py TurnResponse */
|
||||
|
|
@ -157,17 +205,144 @@ export interface SessionEndResponse {
|
|||
digest_pending: boolean;
|
||||
}
|
||||
|
||||
export interface LearnerSessionSummary {
|
||||
session_id: string;
|
||||
persona_code: string;
|
||||
persona_name: string;
|
||||
session_no: number;
|
||||
status: "active" | "ended";
|
||||
stage: string;
|
||||
turn_count: number;
|
||||
learner_turn_count: number;
|
||||
client_turn_count: number;
|
||||
started_at: string;
|
||||
ended_at: string | null;
|
||||
review_ready: boolean;
|
||||
}
|
||||
|
||||
export interface LearnerSessionsResponse {
|
||||
source: string;
|
||||
sessions: LearnerSessionSummary[];
|
||||
}
|
||||
|
||||
export interface SessionDetailTurn {
|
||||
turn_seq: number;
|
||||
speaker: "learner" | "client";
|
||||
stage: string;
|
||||
text: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface SessionDetailResponse {
|
||||
session_id: string;
|
||||
case_id: string;
|
||||
persona_code: string;
|
||||
persona_name: string;
|
||||
theory_mode: string;
|
||||
status: "active" | "ended";
|
||||
stage: SessionStage;
|
||||
effective_openness: number;
|
||||
started_at: string;
|
||||
ended_at: string | null;
|
||||
turns: SessionDetailTurn[];
|
||||
review_ready: boolean;
|
||||
}
|
||||
|
||||
export interface ReviewClient {
|
||||
name: string;
|
||||
initial: string;
|
||||
persona: string;
|
||||
}
|
||||
|
||||
export interface ReviewTechnique {
|
||||
kind: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface ReviewNote {
|
||||
author: "ai" | "instructor" | string;
|
||||
tone: "good" | "watch";
|
||||
title: string;
|
||||
body: string;
|
||||
quote?: string | null;
|
||||
}
|
||||
|
||||
export interface ReviewTurn {
|
||||
id: string;
|
||||
ts: string;
|
||||
speaker: "learner" | "client";
|
||||
who: string;
|
||||
text: string;
|
||||
techniques: ReviewTechnique[];
|
||||
note?: ReviewNote | null;
|
||||
}
|
||||
|
||||
export interface ReviewPhaseSegment {
|
||||
key: string;
|
||||
label: string;
|
||||
weight: number;
|
||||
}
|
||||
|
||||
export interface ReviewValencePoint {
|
||||
t: number;
|
||||
v: number;
|
||||
}
|
||||
|
||||
export interface ReviewRubricRow {
|
||||
name: string;
|
||||
cluster: string;
|
||||
ratio: number;
|
||||
quality: "good" | "watch";
|
||||
freq: string;
|
||||
}
|
||||
|
||||
export interface ReviewPoint {
|
||||
title: string;
|
||||
body: string;
|
||||
jumpTo?: string | null;
|
||||
}
|
||||
|
||||
export interface SessionReviewResponse {
|
||||
session_id: string;
|
||||
client: ReviewClient;
|
||||
date: string;
|
||||
durationLabel: string;
|
||||
durationSeconds: number;
|
||||
reachedPhase: string;
|
||||
sessionSignal: string;
|
||||
supervisorState: string;
|
||||
supervisorName: string;
|
||||
summary: string;
|
||||
phases: ReviewPhaseSegment[];
|
||||
phaseAxis: string[];
|
||||
valenceAxis: string[];
|
||||
clientValence: ReviewValencePoint[];
|
||||
counselorBaseline: ReviewValencePoint[];
|
||||
turns: ReviewTurn[];
|
||||
rubric: ReviewRubricRow[];
|
||||
goodMoments: ReviewPoint[];
|
||||
growthPoints: ReviewPoint[];
|
||||
nextLine?: string | null;
|
||||
clientFeedback?: string | null;
|
||||
audioUrl?: string | null;
|
||||
pdfExportUrl?: string | null;
|
||||
degraded: boolean;
|
||||
reviewReady: boolean;
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
SSE 헬퍼 — GET /sessions/{id}/stream
|
||||
SSE 헬퍼 — POST /sessions/{id}/stream
|
||||
백엔드 이벤트(sse_starlette): "token" | "done" | "ping" | "safety" | "error"
|
||||
EventSource 는 쿠키를 same-origin 으로 자동 전송하므로 BFF 인증과 호환.
|
||||
턴 본문이 필요하므로 EventSource 가 아니라 fetch stream 으로 처리한다.
|
||||
===================================================================== */
|
||||
|
||||
export interface SessionStreamHandlers {
|
||||
/** 서버가 요청을 수락했고 learner turn 이 저장 가능한 지점 */
|
||||
onOpen?: () => void;
|
||||
/** 내담자 AI 토큰 1조각 */
|
||||
onToken?: (chunk: string) => void;
|
||||
/** 스트림 정상 종료 */
|
||||
onDone?: (data: { session_id: string }) => void;
|
||||
onDone?: (data: SessionStreamDone) => void;
|
||||
/** 안전(위기) 신호 */
|
||||
onSafety?: (data: unknown) => void;
|
||||
/** 에러 이벤트(백엔드 EngineError) 또는 연결 오류 */
|
||||
|
|
@ -176,9 +351,12 @@ export interface SessionStreamHandlers {
|
|||
onPing?: () => void;
|
||||
}
|
||||
|
||||
export interface SessionStreamHandle {
|
||||
/** 스트림 종료(EventSource close) */
|
||||
close: () => void;
|
||||
export interface SessionStreamDone {
|
||||
session_id: string;
|
||||
stage?: SessionStage;
|
||||
effective_openness?: number;
|
||||
turn_seq?: number;
|
||||
safety_flagged?: boolean;
|
||||
}
|
||||
|
||||
function safeParse(data: string): unknown {
|
||||
|
|
@ -190,61 +368,285 @@ function safeParse(data: string): unknown {
|
|||
}
|
||||
|
||||
/**
|
||||
* 내담자 AI 응답 SSE 스트림을 연다.
|
||||
* @returns close() 가능한 핸들. 컴포넌트 언마운트 시 반드시 close 호출.
|
||||
* 내담자 AI 응답 SSE 스트림을 실행한다.
|
||||
* 백엔드는 learner turn 을 저장한 뒤 token/done 이벤트를 흘린다.
|
||||
*/
|
||||
export function openSessionStream(
|
||||
export async function openSessionStream(
|
||||
sessionId: string,
|
||||
text: string,
|
||||
handlers: SessionStreamHandlers,
|
||||
): SessionStreamHandle {
|
||||
const url = joinUrl(`/sessions/${encodeURIComponent(sessionId)}/stream`);
|
||||
// withCredentials: same-origin 쿠키 전송 (BFF). cross-origin SSE 는 CORS 필요.
|
||||
const es = new EventSource(url, { withCredentials: true });
|
||||
): Promise<SessionStreamDone> {
|
||||
const res = await fetch(joinUrl(`/sessions/${encodeURIComponent(sessionId)}/stream`), {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
Accept: "text/event-stream",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ text }),
|
||||
});
|
||||
|
||||
// addEventListener 의 커스텀 이벤트 리스너 시그니처는 Event 를 받으므로
|
||||
// MessageEvent 로 안전하게 좁힌다(.data 접근).
|
||||
const dataOf = (ev: Event): string | undefined =>
|
||||
(ev as MessageEvent).data as string | undefined;
|
||||
if (!res.ok) {
|
||||
throw await parseError(res);
|
||||
}
|
||||
if (!res.body) {
|
||||
throw new ApiError(res.status, "스트림 응답 본문이 없습니다.");
|
||||
}
|
||||
|
||||
es.addEventListener("token", (ev: Event) => {
|
||||
const data = dataOf(ev);
|
||||
if (data != null) handlers.onToken?.(data);
|
||||
});
|
||||
es.addEventListener("done", (ev: Event) => {
|
||||
const parsed = safeParse(dataOf(ev) ?? "{}") as { session_id?: string };
|
||||
handlers.onDone?.({ session_id: parsed.session_id ?? sessionId });
|
||||
es.close();
|
||||
});
|
||||
es.addEventListener("safety", (ev: Event) => {
|
||||
handlers.onSafety?.(safeParse(dataOf(ev) ?? "null"));
|
||||
});
|
||||
es.addEventListener("ping", () => {
|
||||
handlers.onPing?.();
|
||||
});
|
||||
es.addEventListener("error", (ev: Event) => {
|
||||
// sse_starlette 의 명시적 error 이벤트는 data 를 가짐.
|
||||
// 브라우저 연결 오류 이벤트는 data 가 없음 → 일반 연결 오류로 처리.
|
||||
const data = dataOf(ev);
|
||||
if (data) {
|
||||
const parsed = safeParse(data) as { detail?: string };
|
||||
handlers.onError?.({ detail: parsed.detail ?? "stream error" });
|
||||
} else if (es.readyState === EventSource.CLOSED) {
|
||||
handlers.onError?.({ detail: "스트림 연결이 종료되었습니다." });
|
||||
handlers.onOpen?.();
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let eventName = "message";
|
||||
let dataLines: string[] = [];
|
||||
let donePayload: SessionStreamDone | null = null;
|
||||
let streamError: ApiError | null = null;
|
||||
|
||||
const dispatch = () => {
|
||||
if (!eventName && dataLines.length === 0) return;
|
||||
const data = dataLines.join("\n");
|
||||
const event = eventName || "message";
|
||||
eventName = "message";
|
||||
dataLines = [];
|
||||
|
||||
if (event === "token") {
|
||||
handlers.onToken?.(data);
|
||||
return;
|
||||
}
|
||||
if (event === "done") {
|
||||
const parsed = safeParse(data || "{}") as Partial<SessionStreamDone>;
|
||||
donePayload = {
|
||||
session_id: parsed.session_id ?? sessionId,
|
||||
stage: parsed.stage,
|
||||
effective_openness: parsed.effective_openness,
|
||||
turn_seq: parsed.turn_seq,
|
||||
safety_flagged: parsed.safety_flagged,
|
||||
};
|
||||
handlers.onDone?.(donePayload);
|
||||
return;
|
||||
}
|
||||
if (event === "safety") {
|
||||
handlers.onSafety?.(safeParse(data || "null"));
|
||||
return;
|
||||
}
|
||||
if (event === "ping") {
|
||||
handlers.onPing?.();
|
||||
return;
|
||||
}
|
||||
if (event === "error") {
|
||||
const parsed = safeParse(data || "{}") as { detail?: string };
|
||||
const detail = parsed.detail ?? "stream error";
|
||||
handlers.onError?.({ detail });
|
||||
streamError = new ApiError(503, detail, parsed);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
close: () => es.close(),
|
||||
};
|
||||
|
||||
const processLine = (line: string) => {
|
||||
if (line === "") {
|
||||
dispatch();
|
||||
return;
|
||||
}
|
||||
if (line.startsWith(":")) return;
|
||||
const idx = line.indexOf(":");
|
||||
const field = idx === -1 ? line : line.slice(0, idx);
|
||||
const rawValue = idx === -1 ? "" : line.slice(idx + 1);
|
||||
const value = rawValue.startsWith(" ") ? rawValue.slice(1) : rawValue;
|
||||
if (field === "event") eventName = value;
|
||||
else if (field === "data") dataLines.push(value);
|
||||
};
|
||||
|
||||
const processBuffer = (final = false) => {
|
||||
const lines = buffer.split(/\r?\n/);
|
||||
buffer = final ? "" : (lines.pop() ?? "");
|
||||
for (const line of lines) processLine(line.endsWith("\r") ? line.slice(0, -1) : line);
|
||||
if (final && buffer) processLine(buffer);
|
||||
if (final && dataLines.length > 0) dispatch();
|
||||
};
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
processBuffer();
|
||||
if (streamError) break;
|
||||
}
|
||||
buffer += decoder.decode();
|
||||
processBuffer(true);
|
||||
|
||||
if (streamError) throw streamError;
|
||||
return donePayload ?? { session_id: sessionId };
|
||||
}
|
||||
|
||||
/* === 세션 API 헬퍼 (Features 단계 Session 페이지가 사용) === */
|
||||
export const personaApi = {
|
||||
list: () => api.get<PersonaSummary[]>("/personas"),
|
||||
};
|
||||
|
||||
export const sessionApi = {
|
||||
list: () => api.get<LearnerSessionsResponse>("/sessions"),
|
||||
get: (sessionId: string) =>
|
||||
api.get<SessionDetailResponse>(`/sessions/${encodeURIComponent(sessionId)}`),
|
||||
start: (persona_code: string, theory_mode: "humanistic" | "cbt" | "integrative" = "humanistic") =>
|
||||
api.post<SessionStartResponse>("/sessions", { persona_code, theory_mode }),
|
||||
turn: (sessionId: string, text: string) =>
|
||||
api.post<TurnResponse>(`/sessions/${encodeURIComponent(sessionId)}/turn`, { text }),
|
||||
end: (sessionId: string) =>
|
||||
api.post<SessionEndResponse>(`/sessions/${encodeURIComponent(sessionId)}/end`),
|
||||
review: (sessionId: string) =>
|
||||
api.get<SessionReviewResponse>(`/sessions/${encodeURIComponent(sessionId)}/review`),
|
||||
stream: openSessionStream,
|
||||
};
|
||||
|
||||
export type AdminHealthStatus = "ok" | "degraded" | "down";
|
||||
|
||||
export interface AdminServiceHealth {
|
||||
key: string;
|
||||
name: string;
|
||||
status: AdminHealthStatus;
|
||||
detail: string;
|
||||
metric: string;
|
||||
load: number;
|
||||
}
|
||||
|
||||
export interface AdminHealthResponse {
|
||||
status: AdminHealthStatus;
|
||||
environment: string;
|
||||
engine_mode: string;
|
||||
services: AdminServiceHealth[];
|
||||
}
|
||||
|
||||
export const adminApi = {
|
||||
health: () => api.get<AdminHealthResponse>("/admin/health"),
|
||||
};
|
||||
|
||||
export interface AdminManagedUser {
|
||||
user_id: string;
|
||||
email: string;
|
||||
display_name: string;
|
||||
role: "learner" | "teacher" | "admin";
|
||||
cohort_ids: string[];
|
||||
affiliation: string;
|
||||
active_sessions: number;
|
||||
created_at: number;
|
||||
last_seen_at: number;
|
||||
source: "database" | "server_session_registry";
|
||||
}
|
||||
|
||||
export interface AdminUsersResponse {
|
||||
source: "database" | "server_session_registry";
|
||||
durable: boolean;
|
||||
users: AdminManagedUser[];
|
||||
}
|
||||
|
||||
export type AdminUserCreateRequest = Pick<
|
||||
AdminManagedUser,
|
||||
"email" | "display_name" | "role" | "affiliation" | "cohort_ids"
|
||||
>;
|
||||
|
||||
export const adminUsersApi = {
|
||||
list: () => api.get<AdminUsersResponse>("/admin/users"),
|
||||
create: (body: AdminUserCreateRequest) =>
|
||||
apiFetch<AdminManagedUser>("/admin/users", { method: "POST", body }),
|
||||
update: (
|
||||
userId: string,
|
||||
body: Partial<Pick<AdminManagedUser, "display_name" | "role" | "affiliation" | "cohort_ids">>,
|
||||
) => apiFetch<AdminManagedUser>(`/admin/users/${encodeURIComponent(userId)}`, {
|
||||
method: "PATCH",
|
||||
body,
|
||||
}),
|
||||
deactivate: (userId: string) =>
|
||||
apiFetch<{ ok: boolean; user_id: string }>(`/admin/users/${encodeURIComponent(userId)}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
};
|
||||
|
||||
export interface TeacherSessionSummary {
|
||||
session_id: string;
|
||||
learner_id: string;
|
||||
learner_label: string;
|
||||
persona_code: string;
|
||||
persona_name: string;
|
||||
session_no: number;
|
||||
status: "active" | "ended" | string;
|
||||
stage: string;
|
||||
turn_count: number;
|
||||
learner_turn_count: number;
|
||||
client_turn_count: number;
|
||||
started_at: string;
|
||||
ended_at: string | null;
|
||||
}
|
||||
|
||||
export interface TeacherDashboardResponse {
|
||||
source: string;
|
||||
cohort_label: string;
|
||||
total_learners: number;
|
||||
active_sessions: number;
|
||||
ended_sessions: number;
|
||||
pending_reviews: TeacherSessionSummary[];
|
||||
recent_sessions: TeacherSessionSummary[];
|
||||
message: string;
|
||||
}
|
||||
|
||||
export const teacherApi = {
|
||||
dashboard: () => api.get<TeacherDashboardResponse>("/teacher/dashboard"),
|
||||
};
|
||||
|
||||
export interface UserProfileResponse {
|
||||
user_id: string;
|
||||
email: string;
|
||||
display_name: string;
|
||||
role: RoleString;
|
||||
cohort_ids: string[];
|
||||
affiliation: string;
|
||||
}
|
||||
|
||||
export interface NotificationPreferences {
|
||||
session_done: boolean;
|
||||
safety_signal: boolean;
|
||||
learner_progress: boolean;
|
||||
product_news: boolean;
|
||||
}
|
||||
|
||||
export interface UserPreferencesResponse {
|
||||
theme: "system" | "light" | "dark" | string;
|
||||
voice_preset_id: string;
|
||||
voice_rate: number;
|
||||
notifications: NotificationPreferences;
|
||||
}
|
||||
|
||||
export interface VoicePresetResponse {
|
||||
id: string;
|
||||
voice_id: string;
|
||||
name: string;
|
||||
desc: string;
|
||||
persona_hint: string;
|
||||
}
|
||||
|
||||
export type RoleString = "learner" | "teacher" | "admin" | string;
|
||||
|
||||
export const userApi = {
|
||||
me: () => api.get<UserProfileResponse>("/users/me"),
|
||||
updateMe: (body: { display_name?: string; affiliation?: string }) =>
|
||||
apiFetch<UserProfileResponse>("/users/me", { method: "PATCH", body }),
|
||||
preferences: () => api.get<UserPreferencesResponse>("/users/me/preferences"),
|
||||
updatePreferences: (body: Partial<UserPreferencesResponse>) =>
|
||||
apiFetch<UserPreferencesResponse>("/users/me/preferences", { method: "PATCH", body }),
|
||||
voicePresets: () => api.get<VoicePresetResponse[]>("/users/me/voice-presets"),
|
||||
};
|
||||
|
||||
export interface AdminEngineConfigResponse {
|
||||
engine_mode: string;
|
||||
engine_url: string;
|
||||
model: string;
|
||||
updated_by: string | null;
|
||||
updated_at: number | null;
|
||||
durable: boolean;
|
||||
source: "database" | "runtime_cache" | "runtime_default" | string;
|
||||
}
|
||||
|
||||
export const adminEngineApi = {
|
||||
get: () => api.get<AdminEngineConfigResponse>("/admin/engine-config"),
|
||||
update: (body: Partial<Pick<AdminEngineConfigResponse, "engine_mode" | "engine_url" | "model">>) =>
|
||||
apiFetch<AdminEngineConfigResponse>("/admin/engine-config", { method: "PATCH", body }),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,13 +1,3 @@
|
|||
/* =====================================================================
|
||||
Vignette — AuthContext
|
||||
- user/role 상태, login/logout.
|
||||
- 개발용 mock 로그인(역할 선택) + 실서버 /auth/me 부트스트랩(있으면 사용).
|
||||
- role 에 따라 <body data-role>·data-theme 반영 → tokens.css §6.2 accent 스왑.
|
||||
백엔드 Role enum(deps.py): learner | teacher | admin.
|
||||
디자인 accent(DESIGN_CONCEPT §6.2): learner | instructor | admin.
|
||||
teacher → data-role="instructor" 로 매핑(인디고-블루).
|
||||
===================================================================== */
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
|
|
@ -17,16 +7,14 @@ import {
|
|||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { api, ApiError, type MeResponse } from "./api";
|
||||
import { api, type MeResponse } from "./api";
|
||||
|
||||
/** 인증·인가 도메인 역할 (백엔드 deps.py Role 미러). */
|
||||
export type Role = "learner" | "teacher" | "admin";
|
||||
|
||||
/** tokens.css §6.2 accent 스왑용 data-role 값. */
|
||||
export type DesignRole = "learner" | "instructor" | "admin";
|
||||
|
||||
export interface AuthUser {
|
||||
userId: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: Role;
|
||||
cohortIds: string[];
|
||||
|
|
@ -35,26 +23,19 @@ export interface AuthUser {
|
|||
export interface AuthContextValue {
|
||||
user: AuthUser | null;
|
||||
role: Role | null;
|
||||
/** 부트스트랩(/auth/me) 진행 여부 — 가드 라우트의 깜빡임 방지 */
|
||||
loading: boolean;
|
||||
/** 개발용 mock 로그인: 역할 선택으로 즉시 인증 상태 진입 */
|
||||
login: (role: Role, opts?: { name?: string; userId?: string }) => void;
|
||||
/** 로그아웃 — 서버 세션 무효화 시도 후 로컬 상태 클리어 */
|
||||
login: (role: Role, opts?: { email?: string; displayName?: string }) => Promise<AuthUser>;
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "vignette.dev-auth";
|
||||
|
||||
/** API role → 디자인 data-role 매핑. */
|
||||
export function designRoleOf(role: Role): DesignRole {
|
||||
return role === "teacher" ? "instructor" : role;
|
||||
}
|
||||
|
||||
/** 역할별 한국어 컨텍스트 라벨 (톱바 좌측, §6.3). */
|
||||
export function roleLabel(role: Role): string {
|
||||
switch (role) {
|
||||
case "learner":
|
||||
return "학습 대시보드";
|
||||
return "학습자 공간";
|
||||
case "teacher":
|
||||
return "교수 콘솔";
|
||||
case "admin":
|
||||
|
|
@ -62,7 +43,6 @@ export function roleLabel(role: Role): string {
|
|||
}
|
||||
}
|
||||
|
||||
/** 역할 진입 기본 경로. */
|
||||
export function roleHomePath(role: Role): string {
|
||||
switch (role) {
|
||||
case "learner":
|
||||
|
|
@ -74,50 +54,42 @@ export function roleHomePath(role: Role): string {
|
|||
}
|
||||
}
|
||||
|
||||
const ROLE_DEFAULT_NAME: Record<Role, string> = {
|
||||
learner: "김수련",
|
||||
teacher: "이교수",
|
||||
admin: "운영자",
|
||||
const DEV_EMAIL_BY_ROLE: Record<Role, string> = {
|
||||
learner: "learner@hs.ac.kr",
|
||||
teacher: "teacher@hs.ac.kr",
|
||||
admin: "admin@twentyoz.kr",
|
||||
};
|
||||
|
||||
const DEV_NAME_BY_ROLE: Record<Role, string> = {
|
||||
learner: "학습자",
|
||||
teacher: "교수자",
|
||||
admin: "관리자",
|
||||
};
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
function loadStored(): AuthUser | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as AuthUser;
|
||||
if (parsed && typeof parsed.role === "string") return parsed;
|
||||
} catch {
|
||||
/* 무시 */
|
||||
}
|
||||
return null;
|
||||
function userFromMe(me: MeResponse): AuthUser {
|
||||
return {
|
||||
userId: me.user_id,
|
||||
email: me.email,
|
||||
name: me.display_name || me.email || me.user_id,
|
||||
role: (me.role as Role) ?? "learner",
|
||||
cohortIds: me.cohort_ids ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<AuthUser | null>(() => loadStored());
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// 부트스트랩: 실서버 세션이 있으면 우선. 없으면(401/네트워크오류) 저장된 mock 유지.
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
(async () => {
|
||||
try {
|
||||
const me = await api.get<MeResponse>("/auth/me");
|
||||
if (!alive) return;
|
||||
const serverUser: AuthUser = {
|
||||
userId: me.user_id,
|
||||
name: me.user_id,
|
||||
role: (me.role as Role) ?? "learner",
|
||||
cohortIds: me.cohort_ids ?? [],
|
||||
};
|
||||
setUser(serverUser);
|
||||
} catch (err) {
|
||||
// 401(미인증) 또는 백엔드 미가동 → mock/로그아웃 상태 유지(에러 아님).
|
||||
if (!(err instanceof ApiError) && !(err instanceof TypeError)) {
|
||||
// 예기치 못한 오류는 콘솔로만 (UX 차단 안 함)
|
||||
console.warn("[auth] /auth/me bootstrap failed", err);
|
||||
}
|
||||
if (alive) setUser(userFromMe(me));
|
||||
} catch {
|
||||
if (alive) setUser(null);
|
||||
} finally {
|
||||
if (alive) setLoading(false);
|
||||
}
|
||||
|
|
@ -127,42 +99,28 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||
};
|
||||
}, []);
|
||||
|
||||
// role → <body data-role> 반영 (accent 스왑). 미인증이면 속성 제거.
|
||||
useEffect(() => {
|
||||
const body = document.body;
|
||||
if (user) {
|
||||
body.setAttribute("data-role", designRoleOf(user.role));
|
||||
} else {
|
||||
body.removeAttribute("data-role");
|
||||
}
|
||||
if (user) body.setAttribute("data-role", designRoleOf(user.role));
|
||||
else body.removeAttribute("data-role");
|
||||
}, [user]);
|
||||
|
||||
const login = useCallback<AuthContextValue["login"]>((role, opts) => {
|
||||
const next: AuthUser = {
|
||||
userId: opts?.userId ?? `dev-${role}`,
|
||||
name: opts?.name ?? ROLE_DEFAULT_NAME[role],
|
||||
const login = useCallback<AuthContextValue["login"]>(async (role, opts) => {
|
||||
const me = await api.post<MeResponse>("/auth/dev-login", {
|
||||
email: opts?.email ?? DEV_EMAIL_BY_ROLE[role],
|
||||
role,
|
||||
cohortIds: [],
|
||||
};
|
||||
display_name: opts?.displayName ?? DEV_NAME_BY_ROLE[role],
|
||||
});
|
||||
const next = userFromMe(me);
|
||||
setUser(next);
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||
} catch {
|
||||
/* 저장 실패 무시 */
|
||||
}
|
||||
return next;
|
||||
}, []);
|
||||
|
||||
const logout = useCallback<AuthContextValue["logout"]>(async () => {
|
||||
try {
|
||||
await api.post("/auth/logout");
|
||||
} catch {
|
||||
// 서버 미가동/스텁이어도 로컬 클리어는 진행
|
||||
}
|
||||
setUser(null);
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
} catch {
|
||||
/* 무시 */
|
||||
} finally {
|
||||
setUser(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
|
@ -177,7 +135,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||
export function useAuth(): AuthContextValue {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useAuth 는 <AuthProvider> 내부에서만 사용할 수 있습니다.");
|
||||
throw new Error("useAuth must be used inside AuthProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,340 +1,540 @@
|
|||
/* =====================================================================
|
||||
Login — 완성 페이지 (나머지 페이지의 품질 레퍼런스).
|
||||
docs/mockups/login.html 포팅. Vignette 토큰/철칙 준수.
|
||||
OAuth(한신대/구글) 버튼 = mock. 역할 선택(learner/teacher/admin) mock 로그인 후 라우팅.
|
||||
철칙: border-left 0 · 이모지 0(아이콘 inline SVG/Icon) · 순흑/순백 금지 · 강조는 weight/tint/kicker/dot.
|
||||
===================================================================== */
|
||||
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { Icon } from "../components/ui/Icon";
|
||||
import { useAuth, roleHomePath, type Role } from "../lib/auth";
|
||||
import { roleHomePath, useAuth, type Role } from "../lib/auth";
|
||||
import { apiUrl, authApi, type AuthConfigResponse } from "../lib/api";
|
||||
|
||||
const OAUTH_NOT_CONFIGURED_MESSAGE =
|
||||
"Google 로그인이 아직 연결되지 않았습니다. 관리자에게 OAuth 클라이언트 설정을 요청하세요.";
|
||||
const OAUTH_FAILED_MESSAGE =
|
||||
"Google 로그인 흐름을 완료하지 못했습니다. 다시 시도하거나 관리자에게 설정 확인을 요청하세요.";
|
||||
const LOCAL_OAUTH_UNAVAILABLE_MESSAGE =
|
||||
"로컬 개발 주소에서는 Google OAuth 콜백이 공개 API로 돌아가므로 로컬 테스트 계정으로 로그인하세요.";
|
||||
|
||||
function oauthMessage(reason: string | null): string | null {
|
||||
if (!reason) return null;
|
||||
if (reason === "not_configured") return OAUTH_NOT_CONFIGURED_MESSAGE;
|
||||
if (reason === "domain_not_allowed") {
|
||||
return "승인된 이메일 도메인의 Google 계정만 사용할 수 있습니다.";
|
||||
}
|
||||
if (reason === "inactive_user") {
|
||||
return "비활성화된 계정입니다. 관리자에게 계정 상태 확인을 요청하세요.";
|
||||
}
|
||||
return OAUTH_FAILED_MESSAGE;
|
||||
}
|
||||
|
||||
const ROLE_OPTIONS: { role: Role; label: string; desc: string; dotClass: string }[] = [
|
||||
{ role: "learner", label: "학습자", desc: "상담을 연습합니다", dotClass: "learner" },
|
||||
{ role: "teacher", label: "교수자", desc: "수련생을 감독합니다", dotClass: "instructor" },
|
||||
{ role: "admin", label: "관리자", desc: "운영을 점검합니다", dotClass: "admin" },
|
||||
{ role: "learner", label: "학습자", desc: "연습 공간으로 이동", dotClass: "learner" },
|
||||
{ role: "teacher", label: "교수자", desc: "담당 학습자 관리", dotClass: "teacher" },
|
||||
{ role: "admin", label: "관리자", desc: "운영 설정과 감사", dotClass: "admin" },
|
||||
];
|
||||
|
||||
function isLocalHostname(hostname: string): boolean {
|
||||
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
|
||||
}
|
||||
|
||||
function isLocalRedirectUri(uri: string | undefined): boolean {
|
||||
if (!uri) return false;
|
||||
try {
|
||||
return isLocalHostname(new URL(uri).hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export default function Login() {
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [selected, setSelected] = useState<Role>("learner");
|
||||
const [pending, setPending] = useState(false);
|
||||
const [loginError, setLoginError] = useState<string | null>(null);
|
||||
const [authConfig, setAuthConfig] = useState<AuthConfigResponse | null>(null);
|
||||
const [authConfigError, setAuthConfigError] = useState<string | null>(null);
|
||||
|
||||
// mock 로그인: 선택 역할로 즉시 인증 진입 → 역할 홈으로.
|
||||
const enter = (role: Role) => {
|
||||
login(role);
|
||||
navigate(roleHomePath(role), { replace: true });
|
||||
const requestedPath =
|
||||
typeof location.state === "object" &&
|
||||
location.state !== null &&
|
||||
"from" in location.state &&
|
||||
typeof location.state.from === "string"
|
||||
? location.state.from
|
||||
: "/";
|
||||
|
||||
useEffect(() => {
|
||||
const oauthState = new URLSearchParams(location.search).get("oauth");
|
||||
setLoginError(oauthMessage(oauthState));
|
||||
}, [location.search]);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
void authApi
|
||||
.config()
|
||||
.then((next) => {
|
||||
if (alive) setAuthConfig(next);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (alive) {
|
||||
setAuthConfigError(
|
||||
err instanceof Error ? err.message : "로그인 설정을 확인하지 못했습니다.",
|
||||
);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const oauthChecking = authConfig === null && authConfigError === null;
|
||||
const devLoginReady = import.meta.env.DEV && authConfig?.dev_login_enabled === true;
|
||||
const localOrigin =
|
||||
typeof window !== "undefined" && isLocalHostname(window.location.hostname);
|
||||
const localOAuthUnavailable =
|
||||
localOrigin &&
|
||||
devLoginReady &&
|
||||
authConfig?.google_oauth_configured === true &&
|
||||
!isLocalRedirectUri(authConfig.redirect_uri);
|
||||
const oauthReady =
|
||||
authConfig?.google_oauth_configured === true && !localOAuthUnavailable;
|
||||
const allowedDomains = authConfig?.allowed_email_domains ?? [];
|
||||
const primaryDomainLabel = localOAuthUnavailable
|
||||
? "로컬은 테스트 계정 사용"
|
||||
: allowedDomains[0]
|
||||
? `@${allowedDomains[0]}`
|
||||
: oauthChecking
|
||||
? "도메인 확인 중"
|
||||
: "승인 도메인 계정";
|
||||
const secondaryDomainLabel = localOAuthUnavailable
|
||||
? "공개 주소에서 사용"
|
||||
: allowedDomains[1]
|
||||
? `@${allowedDomains[1]}`
|
||||
: allowedDomains[0]
|
||||
? "승인된 Google 계정"
|
||||
: "관리자 설정 필요";
|
||||
|
||||
const startOAuth = () => {
|
||||
if (!oauthReady) {
|
||||
setLoginError(
|
||||
localOAuthUnavailable
|
||||
? LOCAL_OAUTH_UNAVAILABLE_MESSAGE
|
||||
: (authConfigError ?? OAUTH_NOT_CONFIGURED_MESSAGE),
|
||||
);
|
||||
return;
|
||||
}
|
||||
window.location.assign(
|
||||
apiUrl(`/auth/login?provider=google&next=${encodeURIComponent(requestedPath)}`),
|
||||
);
|
||||
};
|
||||
|
||||
const enterDev = async (role: Role) => {
|
||||
setPending(true);
|
||||
setLoginError(null);
|
||||
try {
|
||||
const signedIn = await login(role);
|
||||
navigate(roleHomePath(signedIn.role), { replace: true });
|
||||
} catch (err) {
|
||||
setLoginError(err instanceof Error ? err.message : "로그인에 실패했습니다.");
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="lg-root">
|
||||
<main className="lg-root">
|
||||
<style>{LOGIN_CSS}</style>
|
||||
|
||||
{/* 공통 헤더 */}
|
||||
<header className="lg-topbar">
|
||||
<span className="lg-wordmark">
|
||||
<span className="lg-mark">
|
||||
<svg viewBox="0 0 26 26" width={24} height={24} fill="none" aria-hidden="true">
|
||||
<circle cx="13" cy="13" r="11" stroke="var(--accent)" strokeWidth="2" />
|
||||
<section className="lg-brand" aria-label="Vignette">
|
||||
<div className="lg-wordmark">
|
||||
<span className="lg-mark" aria-hidden="true">
|
||||
<svg viewBox="0 0 26 26" width={26} height={26} fill="none">
|
||||
<circle cx="13" cy="13" r="11" stroke="currentColor" strokeWidth="2" />
|
||||
<path
|
||||
d="M8 14.5c1.4 1.7 3 2.5 5 2.5s3.6-.8 5-2.5"
|
||||
stroke="var(--accent)"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<circle cx="13" cy="9" r="1.6" fill="var(--clay)" />
|
||||
</svg>
|
||||
</span>
|
||||
<span className="nm">Vignette</span>
|
||||
<span className="lg-sub">상담 시뮬레이션</span>
|
||||
</span>
|
||||
<span className="lg-role-pill">
|
||||
<span className="d" aria-hidden="true" />
|
||||
로그인 전
|
||||
</span>
|
||||
</header>
|
||||
<span>Vignette</span>
|
||||
</div>
|
||||
|
||||
<div className="lg-shell">
|
||||
{/* LEFT: 브랜드 + 한 문장 가치제안 */}
|
||||
<section className="lg-brand">
|
||||
<div className="lg-brand-top">
|
||||
<span className="lg-kicker">
|
||||
<span className="d" aria-hidden="true" />
|
||||
한신대학교 산학협력
|
||||
</span>
|
||||
</div>
|
||||
<div className="lg-copy">
|
||||
<span className="lg-kicker">
|
||||
<span className="d" aria-hidden="true" />
|
||||
상담 시뮬레이션 학습
|
||||
</span>
|
||||
<h1>실제 계정으로 들어가고, 실제 회기만 남깁니다.</h1>
|
||||
<p>
|
||||
Vignette는 상담 연습, 교수자 피드백, 운영 관리 권한을 안전한 로그인 세션으로 분리합니다.
|
||||
브라우저에는 인증 토큰을 저장하지 않습니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="lg-lede">
|
||||
<h1>
|
||||
불안한 사람을 마주하는 연습을,
|
||||
<br />
|
||||
<span className="em">안전한 자리</span>에서.
|
||||
</h1>
|
||||
<p>
|
||||
가상 내담자와 음성으로 상담을 나누고, 회기가 끝나면 조용히 관찰하던 평가
|
||||
AI가 당신의 다음 한 마디를 함께 돌아봅니다.
|
||||
</p>
|
||||
</div>
|
||||
<div className="lg-policy">
|
||||
<span>
|
||||
<Icon name="shield" size={17} />
|
||||
허용 도메인
|
||||
</span>
|
||||
{allowedDomains.length ? (
|
||||
allowedDomains.map((domain) => <b key={domain}>{domain}</b>)
|
||||
) : (
|
||||
<b>{oauthChecking ? "확인 중" : "설정 필요"}</b>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="lg-brand-foot">
|
||||
<div className="lg-roles">
|
||||
<span className="r">
|
||||
<span className="d learner" aria-hidden="true" />
|
||||
학습자
|
||||
</span>
|
||||
<span className="sep" aria-hidden="true" />
|
||||
<span className="r">
|
||||
<span className="d instructor" aria-hidden="true" />
|
||||
교수자
|
||||
</span>
|
||||
<span className="sep" aria-hidden="true" />
|
||||
<span className="r">
|
||||
<span className="d admin" aria-hidden="true" />
|
||||
관리자
|
||||
</span>
|
||||
</div>
|
||||
<p className="lg-ethic">
|
||||
교육·훈련을 위한 시뮬레이션 도구입니다. 실제 심리치료나 임상 진단을
|
||||
대체하지 않습니다.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
<section className="lg-enter" aria-label="로그인">
|
||||
<div className="lg-panel">
|
||||
<span className="lg-kicker">
|
||||
<span className="d" aria-hidden="true" />
|
||||
계정으로 시작
|
||||
</span>
|
||||
<h2>로그인</h2>
|
||||
<p className="lg-lead">
|
||||
학교 또는 승인된 Google 계정으로 접속하면 역할과 코호트 권한을 확인합니다.
|
||||
</p>
|
||||
|
||||
{/* RIGHT: OAuth 진입 (mock) */}
|
||||
<section className="lg-enter">
|
||||
<div className="lg-panel">
|
||||
<span className="lg-panel-kicker">
|
||||
<span className="d" aria-hidden="true" />
|
||||
계정으로 시작
|
||||
</span>
|
||||
<h2>다시 오셨네요.</h2>
|
||||
<p className="lg-lead">
|
||||
학교 계정으로 들어오면 역할에 맞는 화면으로 바로 안내합니다.
|
||||
</p>
|
||||
|
||||
<div className="lg-oauth">
|
||||
{/* 한신대 계정 = 주 경로 (선택된 역할로 mock 로그인) */}
|
||||
<button className="lg-obtn primary" type="button" onClick={() => enter(selected)}>
|
||||
<span className="ic">
|
||||
<Icon name="school" size={19} strokeWidth={1.8} />
|
||||
</span>
|
||||
<span className="txt">
|
||||
한신대학교 계정으로 계속
|
||||
<span className="sub">@hs.ac.kr 통합 로그인</span>
|
||||
</span>
|
||||
<span className="arrow">
|
||||
<Icon name="chevron-right" size={18} strokeWidth={2} />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Google = 보조 경로 (mock) */}
|
||||
<button className="lg-obtn secondary" type="button" onClick={() => enter(selected)}>
|
||||
<span className="ic">
|
||||
<Icon name="google" size={19} />
|
||||
</span>
|
||||
<span className="txt">
|
||||
Google 계정으로 계속
|
||||
<span className="sub">개인 Google 워크스페이스</span>
|
||||
</span>
|
||||
<span className="arrow">
|
||||
<Icon name="chevron-right" size={18} strokeWidth={2} />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 역할 선택 (개발용 mock) */}
|
||||
<div className="lg-divider">
|
||||
<span className="ln" aria-hidden="true" />
|
||||
<span className="lb">개발용 역할 선택</span>
|
||||
<span className="ln" aria-hidden="true" />
|
||||
</div>
|
||||
|
||||
<div className="lg-rolepick" role="radiogroup" aria-label="역할 선택">
|
||||
{ROLE_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.role}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected === opt.role}
|
||||
className={"lg-roleopt" + (selected === opt.role ? " is-sel" : "")}
|
||||
onClick={() => setSelected(opt.role)}
|
||||
>
|
||||
<span className={"d " + opt.dotClass} aria-hidden="true" />
|
||||
<span className="rt">
|
||||
<b>{opt.label}</b>
|
||||
<span className="rd">{opt.desc}</span>
|
||||
</span>
|
||||
{selected === opt.role ? (
|
||||
<span className="ck">
|
||||
<Icon name="check" size={15} strokeWidth={2.4} />
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 교육용 비치료 도구 고지 (info tint, dot+텍스트) */}
|
||||
<div className="lg-notice">
|
||||
<div className="lg-actions">
|
||||
<button
|
||||
className="lg-obtn primary"
|
||||
type="button"
|
||||
onClick={startOAuth}
|
||||
disabled={!oauthReady}
|
||||
>
|
||||
<span className="ic">
|
||||
<Icon name="info" size={16} strokeWidth={1.8} />
|
||||
<Icon name="school" size={19} strokeWidth={1.8} />
|
||||
</span>
|
||||
<span className="tx">
|
||||
<b>교육용 비치료 도구</b>입니다. 본 플랫폼의 가상 내담자·피드백은 상담
|
||||
역량 훈련을 위한 것으로, 실제 치료·진단·위기 개입을 제공하지 않습니다.
|
||||
<span className="txt">
|
||||
학교 Google 계정으로 계속
|
||||
<span className="sub">{primaryDomainLabel}</span>
|
||||
</span>
|
||||
<Icon name="chevron-right" size={18} strokeWidth={2} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="lg-obtn secondary"
|
||||
type="button"
|
||||
onClick={startOAuth}
|
||||
disabled={!oauthReady}
|
||||
>
|
||||
<span className="ic">
|
||||
<Icon name="google" size={19} />
|
||||
</span>
|
||||
<span className="txt">
|
||||
Google 계정으로 계속
|
||||
<span className="sub">{secondaryDomainLabel}</span>
|
||||
</span>
|
||||
<Icon name="chevron-right" size={18} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!oauthReady ? (
|
||||
<div className="lg-config" role="status">
|
||||
<Icon name={authConfigError ? "alert" : "info"} size={17} />
|
||||
<span>
|
||||
{localOAuthUnavailable
|
||||
? LOCAL_OAUTH_UNAVAILABLE_MESSAGE
|
||||
: oauthChecking
|
||||
? "Google 로그인 설정을 확인하는 중입니다."
|
||||
: "Google 로그인이 아직 연결되지 않았습니다. 관리자에게 OAuth 클라이언트 설정을 요청하세요."}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<p className="lg-help">
|
||||
계정 연결에 문제가 있나요? <a href="#help">관리자에게 문의</a>
|
||||
<br />
|
||||
처음이라면 담당 교수자에게 초대 코드를 받으세요.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
{devLoginReady ? (
|
||||
<div className="lg-dev">
|
||||
<div className="lg-devhead">
|
||||
<span>로컬 테스트</span>
|
||||
<small>보안 로그인 세션 사용</small>
|
||||
</div>
|
||||
<div className="lg-rolepick" role="radiogroup" aria-label="로컬 테스트 역할">
|
||||
{ROLE_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.role}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected === opt.role}
|
||||
className={`lg-roleopt ${selected === opt.role ? "is-sel" : ""}`}
|
||||
onClick={() => setSelected(opt.role)}
|
||||
>
|
||||
<span className={`d ${opt.dotClass}`} aria-hidden="true" />
|
||||
<span>
|
||||
<b>{opt.label}</b>
|
||||
<small>{opt.desc}</small>
|
||||
</span>
|
||||
{selected === opt.role ? <Icon name="check" size={15} /> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className="lg-devbtn"
|
||||
type="button"
|
||||
onClick={() => void enterDev(selected)}
|
||||
disabled={pending}
|
||||
>
|
||||
{pending ? "테스트 계정 확인 중" : "로컬 테스트 계정으로 계속"}
|
||||
</button>
|
||||
{loginError ? <p className="lg-error">{loginError}</p> : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!devLoginReady && loginError ? <p className="lg-error">{loginError}</p> : null}
|
||||
|
||||
<p className="lg-note">
|
||||
교육용 비치료 연구 도구입니다. 실제 치료, 진단, 위기 개입을 대체하지 않습니다.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const LOGIN_CSS = `
|
||||
.lg-root{min-height:100vh;display:flex;flex-direction:column;background:var(--bg-app);}
|
||||
|
||||
/* 헤더 */
|
||||
.lg-topbar{
|
||||
height:56px;flex:none;background:var(--bg-surface);border-bottom:1px solid var(--hair);
|
||||
display:flex;align-items:center;gap:var(--sp-5);padding:0 var(--sp-7);
|
||||
.lg-root{
|
||||
min-height:100dvh;
|
||||
display:grid;
|
||||
grid-template-columns:minmax(0,1fr) minmax(360px,480px);
|
||||
background:var(--bg-app);
|
||||
color:var(--text-strong);
|
||||
}
|
||||
.lg-wordmark{display:flex;align-items:center;gap:9px;}
|
||||
.lg-mark{display:flex;align-items:center;}
|
||||
.lg-wordmark .nm{font-size:18px;font-weight:700;color:var(--text-strong);letter-spacing:-0.02em;}
|
||||
.lg-sub{font-family:var(--font-num);font-size:11px;font-weight:600;letter-spacing:0.1em;text-transform:uppercase;color:var(--text-muted);}
|
||||
.lg-role-pill{
|
||||
margin-left:auto;display:inline-flex;align-items:center;gap:7px;
|
||||
background:var(--accent-tint);color:var(--accent-deep);
|
||||
font-size:12px;font-weight:600;padding:5px 11px;border-radius:999px;
|
||||
}
|
||||
.lg-role-pill .d{width:6px;height:6px;border-radius:50%;background:var(--accent);}
|
||||
|
||||
/* 2분할 */
|
||||
.lg-shell{flex:1;display:grid;grid-template-columns:1.05fr 1fr;min-height:0;}
|
||||
|
||||
/* LEFT */
|
||||
.lg-brand{
|
||||
background:var(--bg-stage);color:#E8EEF2;position:relative;overflow:hidden;
|
||||
display:flex;flex-direction:column;justify-content:space-between;
|
||||
padding:var(--sp-9) var(--sp-8);
|
||||
min-width:0;
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
justify-content:space-between;
|
||||
gap:var(--sp-7);
|
||||
padding:var(--sp-7);
|
||||
background:var(--bg-stage);
|
||||
color:#edf4f2;
|
||||
}
|
||||
.lg-brand::before{
|
||||
content:"";position:absolute;width:520px;height:520px;border-radius:50%;
|
||||
top:-160px;right:-180px;
|
||||
background:radial-gradient(circle, rgba(95,150,139,.20) 0%, transparent 68%);
|
||||
animation:lgAura 9s ease-in-out infinite;
|
||||
.lg-wordmark{
|
||||
display:flex;
|
||||
align-items:center;
|
||||
gap:10px;
|
||||
font-size:19px;
|
||||
font-weight:700;
|
||||
letter-spacing:0;
|
||||
color:#edf4f2;
|
||||
}
|
||||
.lg-brand::after{
|
||||
content:"";position:absolute;width:380px;height:380px;border-radius:50%;
|
||||
bottom:-140px;left:-120px;
|
||||
background:radial-gradient(circle, rgba(176,115,92,.13) 0%, transparent 70%);
|
||||
}
|
||||
@keyframes lgAura{0%,100%{opacity:.85;transform:scale(1)}50%{opacity:1;transform:scale(1.04)}}
|
||||
.lg-brand-top{position:relative;z-index:1;}
|
||||
.lg-mark{display:grid;place-items:center;color:var(--accent-bright);}
|
||||
.lg-copy{max-width:620px;}
|
||||
.lg-kicker{
|
||||
font-family:var(--font-num);font-size:12px;font-weight:600;
|
||||
letter-spacing:0.12em;text-transform:uppercase;color:rgba(232,238,242,.62);
|
||||
display:inline-flex;align-items:center;gap:9px;
|
||||
display:inline-flex;
|
||||
align-items:center;
|
||||
gap:8px;
|
||||
font-family:var(--font-num);
|
||||
font-size:12px;
|
||||
font-weight:700;
|
||||
letter-spacing:.08em;
|
||||
text-transform:uppercase;
|
||||
color:var(--accent-bright);
|
||||
}
|
||||
.lg-kicker .d{width:6px;height:6px;border-radius:50%;background:var(--accent-bright);}
|
||||
.lg-lede{position:relative;z-index:1;max-width:480px;}
|
||||
.lg-lede h1{font-size:38px;line-height:1.32;font-weight:700;letter-spacing:-0.02em;color:#F2F6F6;}
|
||||
.lg-lede h1 .em{color:var(--accent-bright);font-weight:700;}
|
||||
.lg-lede p{margin-top:var(--sp-5);font-size:16px;line-height:1.7;color:rgba(232,238,242,.7);max-width:420px;}
|
||||
.lg-brand-foot{position:relative;z-index:1;display:flex;flex-direction:column;gap:var(--sp-4);}
|
||||
.lg-roles{display:flex;align-items:center;gap:var(--sp-5);}
|
||||
.lg-roles .r{display:inline-flex;align-items:center;gap:9px;font-size:13px;color:rgba(232,238,242,.66);}
|
||||
.lg-roles .r .d{width:7px;height:7px;border-radius:50%;}
|
||||
.lg-roles .r .d.learner{background:var(--accent-bright);}
|
||||
.lg-roles .r .d.instructor{background:#5478C4;}
|
||||
.lg-roles .r .d.admin{background:#9DA1AE;}
|
||||
.lg-roles .sep{width:1px;height:13px;background:rgba(232,238,242,.18);}
|
||||
.lg-ethic{font-size:13px;line-height:1.6;color:rgba(232,238,242,.5);border-top:1px solid rgba(232,238,242,.12);padding-top:var(--sp-4);max-width:440px;}
|
||||
|
||||
/* RIGHT */
|
||||
.lg-enter{background:var(--bg-app);display:flex;align-items:center;justify-content:center;padding:var(--sp-8) var(--sp-7);}
|
||||
.lg-panel{width:100%;max-width:392px;}
|
||||
.lg-panel-kicker{
|
||||
font-family:var(--font-num);font-size:12px;font-weight:600;letter-spacing:0.08em;color:var(--accent);
|
||||
display:inline-flex;align-items:center;gap:8px;margin-bottom:var(--sp-4);
|
||||
.lg-kicker .d{width:6px;height:6px;border-radius:50%;background:currentColor;}
|
||||
.lg-copy h1{
|
||||
margin:var(--sp-4) 0 0;
|
||||
max-width:640px;
|
||||
font-size:56px;
|
||||
line-height:1.12;
|
||||
letter-spacing:0;
|
||||
font-weight:760;
|
||||
}
|
||||
.lg-panel-kicker .d{width:6px;height:6px;border-radius:50%;background:var(--accent-bright);}
|
||||
.lg-panel h2{font-size:24px;font-weight:700;letter-spacing:-0.015em;color:var(--text-strong);}
|
||||
.lg-lead{margin-top:10px;font-size:15px;line-height:1.65;color:var(--text-body);max-width:340px;}
|
||||
|
||||
.lg-oauth{margin-top:var(--sp-7);display:flex;flex-direction:column;gap:var(--sp-3);}
|
||||
.lg-copy p{
|
||||
margin:var(--sp-5) 0 0;
|
||||
max-width:560px;
|
||||
color:rgba(237,244,242,.72);
|
||||
font-size:17px;
|
||||
line-height:1.75;
|
||||
}
|
||||
.lg-policy{
|
||||
display:flex;
|
||||
align-items:center;
|
||||
flex-wrap:wrap;
|
||||
gap:10px;
|
||||
color:rgba(237,244,242,.66);
|
||||
font-size:13px;
|
||||
}
|
||||
.lg-policy span,.lg-policy b{
|
||||
display:inline-flex;
|
||||
align-items:center;
|
||||
gap:7px;
|
||||
}
|
||||
.lg-policy b{
|
||||
color:#edf4f2;
|
||||
background:rgba(255,255,255,.08);
|
||||
border:1px solid rgba(255,255,255,.12);
|
||||
border-radius:999px;
|
||||
padding:5px 10px;
|
||||
font-weight:650;
|
||||
}
|
||||
.lg-enter{
|
||||
min-width:0;
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
padding:var(--sp-6);
|
||||
}
|
||||
.lg-panel{
|
||||
width:100%;
|
||||
max-width:400px;
|
||||
background:var(--bg-surface);
|
||||
border:1px solid var(--border-subtle);
|
||||
border-radius:var(--radius-lg);
|
||||
box-shadow:var(--shadow-sm);
|
||||
padding:var(--sp-6);
|
||||
}
|
||||
.lg-panel h2{
|
||||
margin:var(--sp-3) 0 0;
|
||||
font-size:28px;
|
||||
line-height:1.25;
|
||||
letter-spacing:0;
|
||||
}
|
||||
.lg-lead{
|
||||
margin:10px 0 0;
|
||||
color:var(--text-body);
|
||||
font-size:14px;
|
||||
line-height:1.65;
|
||||
}
|
||||
.lg-actions{display:flex;flex-direction:column;gap:var(--sp-3);margin-top:var(--sp-6);}
|
||||
.lg-obtn{
|
||||
width:100%;display:flex;align-items:center;gap:14px;padding:14px 18px;border-radius:var(--radius);
|
||||
font-family:var(--font-sans);font-size:15px;font-weight:600;cursor:pointer;text-align:left;
|
||||
transition:border-color .16s var(--ease-out),background .16s var(--ease-out),box-shadow .16s var(--ease-out);
|
||||
width:100%;
|
||||
min-height:58px;
|
||||
display:grid;
|
||||
grid-template-columns:36px minmax(0,1fr) 18px;
|
||||
align-items:center;
|
||||
gap:13px;
|
||||
border-radius:var(--radius);
|
||||
padding:11px 14px;
|
||||
font-family:var(--font-sans);
|
||||
font-size:15px;
|
||||
font-weight:650;
|
||||
text-align:left;
|
||||
cursor:pointer;
|
||||
}
|
||||
.lg-obtn.primary{background:var(--accent);border:1px solid var(--accent);color:var(--text-on-accent);}
|
||||
.lg-obtn.primary:hover{background:var(--accent-deep);border-color:var(--accent-deep);}
|
||||
.lg-obtn.primary .ic{background:rgba(251,250,248,.16);color:var(--text-on-accent);}
|
||||
.lg-obtn.primary .sub{color:rgba(251,250,248,.72);}
|
||||
.lg-obtn.primary .arrow{color:rgba(251,250,248,.8);}
|
||||
.lg-obtn.secondary{background:var(--bg-surface);border:1px solid var(--neutral-150);color:var(--text-strong);}
|
||||
.lg-obtn.secondary:hover{border-color:var(--neutral-200);}
|
||||
.lg-obtn.secondary .arrow{color:var(--ink-3);}
|
||||
.lg-obtn .ic{width:34px;height:34px;border-radius:8px;flex:none;display:flex;align-items:center;justify-content:center;background:var(--bg-surface-2);}
|
||||
.lg-obtn .txt{display:flex;flex-direction:column;gap:1px;line-height:1.4;}
|
||||
.lg-obtn .sub{font-size:12px;font-weight:500;color:var(--ink-3);}
|
||||
.lg-obtn .arrow{margin-left:auto;display:flex;align-items:center;}
|
||||
|
||||
.lg-divider{display:flex;align-items:center;gap:var(--sp-4);margin:var(--sp-5) 0;}
|
||||
.lg-divider .ln{flex:1;height:1px;background:var(--hair);}
|
||||
.lg-divider .lb{font-size:12px;color:var(--text-muted);font-weight:500;letter-spacing:0.02em;}
|
||||
|
||||
/* 역할 선택 */
|
||||
.lg-rolepick{display:flex;flex-direction:column;gap:var(--sp-2);}
|
||||
.lg-obtn.secondary{background:var(--bg-surface);border:1px solid var(--border-strong);color:var(--text-strong);}
|
||||
.lg-obtn.secondary:hover{border-color:var(--accent);}
|
||||
.lg-obtn:disabled{
|
||||
cursor:not-allowed;
|
||||
opacity:1;
|
||||
background:var(--bg-surface-2);
|
||||
border-color:var(--border-subtle);
|
||||
color:var(--text-muted);
|
||||
}
|
||||
.lg-obtn:disabled:hover{
|
||||
background:var(--bg-surface-2);
|
||||
border-color:var(--border-subtle);
|
||||
}
|
||||
.lg-obtn:disabled .ic{
|
||||
background:var(--bg-surface);
|
||||
color:var(--text-muted);
|
||||
}
|
||||
.lg-obtn:disabled .sub{
|
||||
color:var(--text-muted);
|
||||
}
|
||||
.lg-obtn .ic{
|
||||
width:36px;
|
||||
height:36px;
|
||||
display:grid;
|
||||
place-items:center;
|
||||
border-radius:8px;
|
||||
background:rgba(255,255,255,.16);
|
||||
}
|
||||
.lg-obtn.secondary .ic{background:var(--bg-surface-2);}
|
||||
.lg-obtn .txt{min-width:0;display:flex;flex-direction:column;gap:1px;}
|
||||
.lg-obtn .sub{font-size:12px;font-weight:550;color:var(--text-muted);}
|
||||
.lg-obtn.primary .sub{color:rgba(251,250,248,.74);}
|
||||
.lg-config{
|
||||
display:flex;
|
||||
align-items:flex-start;
|
||||
gap:10px;
|
||||
margin-top:var(--sp-3);
|
||||
padding:10px 12px;
|
||||
border-radius:var(--radius);
|
||||
background:var(--warn-tint);
|
||||
color:var(--warn-text);
|
||||
font-size:12.5px;
|
||||
line-height:1.5;
|
||||
}
|
||||
.lg-dev{
|
||||
margin-top:var(--sp-6);
|
||||
padding-top:var(--sp-5);
|
||||
border-top:1px solid var(--hair);
|
||||
}
|
||||
.lg-devhead{
|
||||
display:flex;
|
||||
align-items:baseline;
|
||||
justify-content:space-between;
|
||||
gap:var(--sp-3);
|
||||
color:var(--text-strong);
|
||||
font-size:13px;
|
||||
font-weight:700;
|
||||
}
|
||||
.lg-devhead small{color:var(--text-muted);font-weight:600;}
|
||||
.lg-rolepick{display:grid;grid-template-columns:1fr;gap:var(--sp-2);margin-top:var(--sp-3);}
|
||||
.lg-roleopt{
|
||||
display:flex;align-items:center;gap:12px;width:100%;text-align:left;
|
||||
padding:11px 14px;border-radius:var(--radius);cursor:pointer;
|
||||
background:var(--bg-surface);border:1px solid var(--neutral-150);color:var(--text-strong);
|
||||
transition:border-color .15s var(--ease-out),background .15s var(--ease-out);
|
||||
min-height:50px;
|
||||
display:grid;
|
||||
grid-template-columns:10px minmax(0,1fr) 16px;
|
||||
align-items:center;
|
||||
gap:10px;
|
||||
padding:9px 11px;
|
||||
border:1px solid var(--border-subtle);
|
||||
border-radius:var(--radius);
|
||||
background:var(--bg-surface);
|
||||
color:var(--text-strong);
|
||||
text-align:left;
|
||||
cursor:pointer;
|
||||
}
|
||||
.lg-roleopt:hover{border-color:var(--neutral-200);}
|
||||
.lg-roleopt.is-sel{border-color:var(--accent);background:var(--accent-tint);}
|
||||
.lg-roleopt .d{width:8px;height:8px;border-radius:50%;flex:none;}
|
||||
.lg-roleopt .d{width:8px;height:8px;border-radius:50%;}
|
||||
.lg-roleopt .d.learner{background:var(--accent-bright);}
|
||||
.lg-roleopt .d.instructor{background:#5478C4;}
|
||||
.lg-roleopt .d.admin{background:#7D818E;}
|
||||
.lg-roleopt .rt{display:flex;flex-direction:column;gap:1px;}
|
||||
.lg-roleopt .rt b{font-size:14px;font-weight:600;}
|
||||
.lg-roleopt .rt .rd{font-size:12px;color:var(--text-muted);}
|
||||
.lg-roleopt .ck{margin-left:auto;color:var(--accent-deep);display:flex;align-items:center;}
|
||||
|
||||
/* 고지 */
|
||||
.lg-notice{margin-top:var(--sp-6);background:var(--info-tint);border-radius:var(--radius);padding:14px 16px;display:flex;gap:11px;align-items:flex-start;}
|
||||
.lg-notice .ic{flex:none;margin-top:1px;color:var(--info-solid);}
|
||||
.lg-notice .tx{font-size:12.5px;line-height:1.6;color:var(--info-text);}
|
||||
.lg-notice .tx b{font-weight:600;}
|
||||
|
||||
.lg-help{margin-top:var(--sp-6);font-size:13px;color:var(--text-muted);line-height:1.7;}
|
||||
.lg-help a{color:var(--accent-deep);text-decoration:none;font-weight:600;}
|
||||
.lg-help a:hover{text-decoration:underline;}
|
||||
|
||||
/* 반응형 */
|
||||
@media (max-width:920px){
|
||||
.lg-shell{grid-template-columns:1fr;}
|
||||
.lg-brand{padding:var(--sp-8) var(--sp-7) var(--sp-7);gap:var(--sp-7);}
|
||||
.lg-lede h1{font-size:30px;}
|
||||
.lg-enter{padding:var(--sp-8) var(--sp-5);}
|
||||
.lg-topbar{padding:0 var(--sp-5);}
|
||||
.lg-roleopt .d.teacher{background:#5478c4;}
|
||||
.lg-roleopt .d.admin{background:#7d818e;}
|
||||
.lg-roleopt b{display:block;font-size:13px;}
|
||||
.lg-roleopt small{display:block;margin-top:1px;color:var(--text-muted);font-size:12px;}
|
||||
.lg-devbtn{
|
||||
width:100%;
|
||||
min-height:46px;
|
||||
margin-top:var(--sp-3);
|
||||
border:1px solid var(--accent);
|
||||
border-radius:var(--radius);
|
||||
background:var(--accent-tint);
|
||||
color:var(--accent-deep);
|
||||
font-family:var(--font-sans);
|
||||
font-size:14px;
|
||||
font-weight:750;
|
||||
cursor:pointer;
|
||||
}
|
||||
@media (max-width:520px){
|
||||
.lg-lede h1{font-size:26px;}
|
||||
.lg-roles{flex-wrap:wrap;gap:var(--sp-3) var(--sp-4);}
|
||||
.lg-roles .sep{display:none;}
|
||||
.lg-devbtn:disabled{opacity:.62;cursor:wait;}
|
||||
.lg-error{
|
||||
margin:var(--sp-3) 0 0;
|
||||
color:var(--crit-text);
|
||||
background:var(--crit-tint);
|
||||
border-radius:var(--radius);
|
||||
padding:10px 12px;
|
||||
font-size:13px;
|
||||
line-height:1.5;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce){
|
||||
.lg-brand::before{animation:none;}
|
||||
.lg-note{
|
||||
margin:var(--sp-5) 0 0;
|
||||
color:var(--text-muted);
|
||||
font-size:12.5px;
|
||||
line-height:1.6;
|
||||
}
|
||||
@media (max-width:880px){
|
||||
.lg-root{grid-template-columns:1fr;}
|
||||
.lg-brand{padding:var(--sp-6) var(--sp-5);gap:var(--sp-6);}
|
||||
.lg-copy h1{font-size:36px;}
|
||||
.lg-enter{padding:var(--sp-5);}
|
||||
.lg-panel{max-width:560px;}
|
||||
}
|
||||
@media (max-width:480px){
|
||||
.lg-brand{padding:var(--sp-5);}
|
||||
.lg-copy h1{font-size:30px;}
|
||||
.lg-copy p{font-size:15px;}
|
||||
.lg-enter{padding:var(--sp-4);}
|
||||
.lg-panel{padding:var(--sp-5);}
|
||||
}
|
||||
`;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,13 +1,4 @@
|
|||
/* =====================================================================
|
||||
SessionReview — 회기 종료 리뷰 (/learn/session/:sessionId/review).
|
||||
DESIGN_CONCEPT §5.7: 타임라인 + 기법 라벨 + 슈퍼바이저 코멘트 + 개선점.
|
||||
톤: 점수/등급/합불 없음 → 한줄요약(24px/600) + 기법분포 + 잘한순간 + 개선점(≤3).
|
||||
외부 ref(session-review-external-ref.html)의 레이아웃/구조/인터랙션 차용,
|
||||
Vignette 토큰(세이지틸·테라코타)으로 리스킨.
|
||||
⚠️ 데이터는 mock(./session-review/mock.ts) — 백엔드 digest 계약 붙기 전.
|
||||
===================================================================== */
|
||||
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { AppShell } from "../components/shell/AppShell";
|
||||
import {
|
||||
|
|
@ -19,24 +10,27 @@ import {
|
|||
Kicker,
|
||||
ProgressBar,
|
||||
} from "../components/ui";
|
||||
import { ValenceChart } from "./session-review/ValenceChart";
|
||||
import {
|
||||
MOCK_REVIEW,
|
||||
type SupervisorNote,
|
||||
type Technique,
|
||||
type Turn,
|
||||
} from "./session-review/mock";
|
||||
sessionApi,
|
||||
type ReviewNote,
|
||||
type ReviewPoint,
|
||||
type ReviewTechnique,
|
||||
type ReviewTurn,
|
||||
type SessionReviewResponse,
|
||||
} from "../lib/api";
|
||||
import { ValenceChart } from "./session-review/ValenceChart";
|
||||
import "./session-review/session-review.css";
|
||||
|
||||
/** 한 줄 요약의 <hl>…</hl> 마커를 accent 강조 span 으로 렌더. */
|
||||
type LoadState = "loading" | "ready" | "error";
|
||||
|
||||
function renderSummary(summary: string) {
|
||||
const parts = summary.split(/(<hl>.*?<\/hl>)/g);
|
||||
return parts.map((part, i) => {
|
||||
const m = part.match(/^<hl>(.*?)<\/hl>$/);
|
||||
if (m) {
|
||||
const match = part.match(/^<hl>(.*?)<\/hl>$/);
|
||||
if (match) {
|
||||
return (
|
||||
<span key={i} className="sr-hl">
|
||||
{m[1]}
|
||||
{match[1]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -44,8 +38,16 @@ function renderSummary(summary: string) {
|
|||
});
|
||||
}
|
||||
|
||||
/** 기법 라벨 칩. */
|
||||
function TechniqueChip({ tech }: { tech: Technique }) {
|
||||
function EmptyBlock({ title, desc }: { title: string; desc: string }) {
|
||||
return (
|
||||
<div className="vg-empty sr-empty">
|
||||
<div className="vg-empty__title">{title}</div>
|
||||
<div className="vg-empty__desc">{desc}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TechniqueChip({ tech }: { tech: ReviewTechnique }) {
|
||||
return (
|
||||
<span className={`sr-technique sr-technique--${tech.kind}`}>
|
||||
<span className="sr-technique__dot" aria-hidden="true" />
|
||||
|
|
@ -54,11 +56,10 @@ function TechniqueChip({ tech }: { tech: Technique }) {
|
|||
);
|
||||
}
|
||||
|
||||
/** 인라인 슈퍼바이저 노트 — 말풍선 금지, 발화 아래 들여쓴 tint 콜아웃. */
|
||||
function SupervisorCallout({ note }: { note: SupervisorNote }) {
|
||||
function SupervisorCallout({ note }: { note: ReviewNote }) {
|
||||
const toneCls = note.tone === "good" ? "sr-note--ai" : "sr-note--warn";
|
||||
const iconName = note.tone === "good" ? "check" : "info";
|
||||
const tag = note.author === "ai" ? "AI" : "교수자";
|
||||
const tag = note.author === "ai" ? "AI" : note.author === "transcript" ? "기록" : "교수자";
|
||||
return (
|
||||
<div className={`sr-note ${toneCls}`}>
|
||||
<div className="sr-note__head">
|
||||
|
|
@ -79,31 +80,93 @@ function SupervisorCallout({ note }: { note: SupervisorNote }) {
|
|||
);
|
||||
}
|
||||
|
||||
function JumpablePoint({
|
||||
point,
|
||||
turns,
|
||||
onJump,
|
||||
}: {
|
||||
point: ReviewPoint;
|
||||
turns: ReviewTurn[];
|
||||
onJump: (id: string) => void;
|
||||
}) {
|
||||
const targetTs = point.jumpTo ? turns.find((turn) => turn.id === point.jumpTo)?.ts : null;
|
||||
return (
|
||||
<div className="sr-point">
|
||||
<span className="sr-point__mk" aria-hidden="true" />
|
||||
<div>
|
||||
<div className="sr-point__h">{point.title}</div>
|
||||
<div className="sr-point__d">
|
||||
{point.body}
|
||||
{point.jumpTo && targetTs ? (
|
||||
<button
|
||||
type="button"
|
||||
className="sr-point__at tabular"
|
||||
onClick={() => onJump(point.jumpTo!)}
|
||||
>
|
||||
{targetTs}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SessionReview() {
|
||||
const { sessionId } = useParams<{ sessionId: string }>();
|
||||
const data = MOCK_REVIEW;
|
||||
|
||||
// 인터랙션: 학습자 발화만 보기 토글 + 타임라인 마커 클릭 → 발화 점프(강조)
|
||||
const [data, setData] = useState<SessionReviewResponse | null>(null);
|
||||
const [loadState, setLoadState] = useState<LoadState>("loading");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [learnerOnly, setLearnerOnly] = useState(false);
|
||||
const [activeTurn, setActiveTurn] = useState<string | null>(null);
|
||||
const turnRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||
|
||||
const visibleTurns = useMemo<Turn[]>(
|
||||
() => (learnerOnly ? data.turns.filter((t) => t.speaker === "learner") : data.turns),
|
||||
[learnerOnly, data.turns],
|
||||
);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
if (!sessionId) {
|
||||
setError("세션 ID가 없습니다.");
|
||||
setLoadState("error");
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}
|
||||
|
||||
(async () => {
|
||||
setLoadState("loading");
|
||||
setError(null);
|
||||
try {
|
||||
const next = await sessionApi.review(sessionId);
|
||||
if (!alive) return;
|
||||
setData(next);
|
||||
setLoadState("ready");
|
||||
} catch (err) {
|
||||
if (!alive) return;
|
||||
setError(err instanceof Error ? err.message : "회기 리뷰를 불러오지 못했습니다.");
|
||||
setLoadState("error");
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [sessionId]);
|
||||
|
||||
const visibleTurns = useMemo<ReviewTurn[]>(() => {
|
||||
const turns = data?.turns ?? [];
|
||||
return learnerOnly ? turns.filter((turn) => turn.speaker === "learner") : turns;
|
||||
}, [learnerOnly, data?.turns]);
|
||||
|
||||
const phaseTotal = useMemo(
|
||||
() => data.phases.reduce((acc, p) => acc + p.weight, 0),
|
||||
[data.phases],
|
||||
() => Math.max(1, (data?.phases ?? []).reduce((acc, phase) => acc + phase.weight, 0)),
|
||||
[data?.phases],
|
||||
);
|
||||
|
||||
function jumpToTurn(id: string) {
|
||||
if (!data) return;
|
||||
setActiveTurn(id);
|
||||
if (learnerOnly && data.turns.find((t) => t.id === id)?.speaker === "client") {
|
||||
if (learnerOnly && data.turns.find((turn) => turn.id === id)?.speaker === "client") {
|
||||
setLearnerOnly(false);
|
||||
}
|
||||
// 다음 프레임에 스크롤(필터 해제 반영 후)
|
||||
requestAnimationFrame(() => {
|
||||
turnRefs.current[id]?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
|
|
@ -112,10 +175,46 @@ export default function SessionReview() {
|
|||
});
|
||||
}
|
||||
|
||||
if (loadState === "loading") {
|
||||
return (
|
||||
<AppShell contextLabel="회기 리뷰">
|
||||
<div className="sr-root">
|
||||
<Card>
|
||||
<Kicker>회기 리뷰</Kicker>
|
||||
<EmptyBlock title="리뷰를 불러오는 중" desc="회기 축어록을 확인하고 있습니다." />
|
||||
</Card>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (loadState === "error" || !data) {
|
||||
return (
|
||||
<AppShell contextLabel="회기 리뷰">
|
||||
<div className="sr-root">
|
||||
<Card>
|
||||
<Kicker>회기 리뷰</Kicker>
|
||||
<EmptyBlock
|
||||
title="리뷰를 표시할 수 없습니다"
|
||||
desc={error ?? "세션을 찾을 수 없거나 접근 권한이 없습니다."}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
const hasValence = data.clientValence.length > 0 || data.counselorBaseline.length > 0;
|
||||
const canOpenAudio = Boolean(data.audioUrl);
|
||||
const canExportPdf = Boolean(data.pdfExportUrl);
|
||||
const hasTranscript = data.turns.length > 0;
|
||||
const reviewReadiness = hasTranscript
|
||||
? `${data.turns.length}개 발화 기반`
|
||||
: "축어록 저장 후 생성";
|
||||
|
||||
return (
|
||||
<AppShell contextLabel="회기 리뷰">
|
||||
<div className="sr-root">
|
||||
{/* ── (1) 세션 헤더 ── */}
|
||||
<div className={`sr-root ${hasTranscript ? "" : "sr-root--empty"}`}>
|
||||
<Card className="sr-head">
|
||||
<div className="sr-head__id">
|
||||
<span className="sr-avatar" aria-hidden="true">
|
||||
|
|
@ -134,12 +233,11 @@ export default function SessionReview() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* 우측 스탯 — 점수 박스 금지, '성장 신호'로 톤다운 */}
|
||||
<div className="sr-head__stats">
|
||||
<div className="sr-stat">
|
||||
<span className="sr-stat__lab">SESSION</span>
|
||||
<span className="sr-stat__val sr-stat__val--accent">
|
||||
<Dot tone="accent" size={7} />
|
||||
<Dot tone={data.reviewReady ? "accent" : "warn"} size={7} />
|
||||
{data.sessionSignal}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -156,75 +254,106 @@ export default function SessionReview() {
|
|||
</Card>
|
||||
|
||||
<div className="sr-cols">
|
||||
{/* ── LEFT: 요약 + 차트 + 흐름 + 트랜스크립트 ── */}
|
||||
<div className="sr-left">
|
||||
<Card>
|
||||
<Card className="sr-overview">
|
||||
<Kicker>회기 리뷰</Kicker>
|
||||
<p className="sr-summary" style={{ marginTop: "var(--sp-3)" }}>
|
||||
<p className="sr-summary">
|
||||
{renderSummary(data.summary)}
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "var(--sp-3)",
|
||||
marginTop: "var(--sp-5)",
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<Button variant="secondary" size="sm" leading={<Icon name="play" size={14} />}>
|
||||
오디오 다시듣기
|
||||
<div className="sr-readiness" aria-label="리뷰 생성 상태">
|
||||
<span>
|
||||
<b>{reviewReadiness}</b>
|
||||
<small>리뷰 근거</small>
|
||||
</span>
|
||||
<span>
|
||||
<b>{data.reviewReady ? "준비됨" : "대기"}</b>
|
||||
<small>평가 상태</small>
|
||||
</span>
|
||||
<span>
|
||||
<b>{data.reachedPhase}</b>
|
||||
<small>도달 단계</small>
|
||||
</span>
|
||||
</div>
|
||||
<div className="sr-actions">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
leading={<Icon name="play" size={14} />}
|
||||
disabled={!canOpenAudio}
|
||||
onClick={() => {
|
||||
if (data.audioUrl) window.open(data.audioUrl, "_blank", "noopener");
|
||||
}}
|
||||
>
|
||||
오디오 다시 듣기
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" leading={<Icon name="review" size={14} />}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
leading={<Icon name="review" size={14} />}
|
||||
disabled={!canExportPdf}
|
||||
onClick={() => {
|
||||
if (data.pdfExportUrl) window.open(data.pdfExportUrl, "_blank", "noopener");
|
||||
}}
|
||||
>
|
||||
PDF 내보내기
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 감정 밸런스 타임라인 */}
|
||||
<Card>
|
||||
<Card className="sr-card sr-card--chart">
|
||||
<Kicker>감정 밸런스 타임라인</Kicker>
|
||||
<div style={{ marginTop: "var(--sp-4)" }}>
|
||||
<ValenceChart
|
||||
client={data.clientValence}
|
||||
baseline={data.counselorBaseline}
|
||||
xLabels={data.valenceAxis}
|
||||
/>
|
||||
{hasValence ? (
|
||||
<ValenceChart
|
||||
client={data.clientValence}
|
||||
baseline={data.counselorBaseline}
|
||||
xLabels={data.valenceAxis}
|
||||
/>
|
||||
) : (
|
||||
<EmptyBlock
|
||||
title="감정 타임라인 대기"
|
||||
desc="평가 AI가 생성한 감정 추적 데이터가 아직 없습니다."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 회기 흐름 단계 막대 */}
|
||||
<Card>
|
||||
<Card className="sr-card sr-card--flow">
|
||||
<Kicker>회기 흐름</Kicker>
|
||||
<div className="sr-phasebar" style={{ marginTop: "var(--sp-4)" }}>
|
||||
<div className="sr-phasebar__track">
|
||||
{data.phases.map((p) => (
|
||||
<div
|
||||
key={p.key}
|
||||
className="sr-phase"
|
||||
style={{
|
||||
flex: p.weight,
|
||||
// 단계별 accent-tint 농담(좌→우 옅어짐). 색 박스 강조 아님, 흐름 신호.
|
||||
background: `color-mix(in srgb, var(--accent-tint) ${
|
||||
100 - data.phases.findIndex((x) => x.key === p.key) * 22
|
||||
}%, var(--surface))`,
|
||||
}}
|
||||
title={`${p.label} · ${Math.round((p.weight / phaseTotal) * 100)}%`}
|
||||
>
|
||||
<span className="sr-phase__dot" aria-hidden="true" />
|
||||
{p.label}
|
||||
{data.phases.length > 0 ? (
|
||||
<>
|
||||
<div className="sr-phasebar__track">
|
||||
{data.phases.map((phase, index) => (
|
||||
<div
|
||||
key={`${phase.key}-${index}`}
|
||||
className="sr-phase"
|
||||
style={{
|
||||
flex: phase.weight,
|
||||
background: `color-mix(in srgb, var(--accent-tint) ${
|
||||
100 - index * 22
|
||||
}%, var(--surface))`,
|
||||
}}
|
||||
title={`${phase.label} · ${Math.round((phase.weight / phaseTotal) * 100)}%`}
|
||||
>
|
||||
<span className="sr-phase__dot" aria-hidden="true" />
|
||||
{phase.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="sr-phasebar__axis tabular">
|
||||
{data.phaseAxis.map((t, i) => (
|
||||
<span key={i}>{t}</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="sr-phasebar__axis tabular">
|
||||
{data.phaseAxis.map((label, index) => (
|
||||
<span key={`${label}-${index}`}>{label}</span>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<EmptyBlock title="흐름 데이터 없음" desc="저장된 단계 변화가 아직 없습니다." />
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 세션 트랜스크립트 */}
|
||||
<Card>
|
||||
<Card className="sr-card sr-card--transcript">
|
||||
<div className="sr-tx__head">
|
||||
<Kicker>세션 트랜스크립트</Kicker>
|
||||
<div className="sr-tx__filters">
|
||||
|
|
@ -233,6 +362,7 @@ export default function SessionReview() {
|
|||
className="sr-chip-toggle"
|
||||
aria-pressed={!learnerOnly}
|
||||
onClick={() => setLearnerOnly(false)}
|
||||
disabled={data.turns.length === 0}
|
||||
>
|
||||
전체
|
||||
</button>
|
||||
|
|
@ -241,6 +371,7 @@ export default function SessionReview() {
|
|||
className="sr-chip-toggle"
|
||||
aria-pressed={learnerOnly}
|
||||
onClick={() => setLearnerOnly(true)}
|
||||
disabled={data.turns.length === 0}
|
||||
>
|
||||
내 발화만
|
||||
</button>
|
||||
|
|
@ -248,159 +379,170 @@ export default function SessionReview() {
|
|||
</div>
|
||||
|
||||
<div className="sr-turns">
|
||||
{visibleTurns.map((turn, idx) => {
|
||||
const isLast = idx === visibleTurns.length - 1;
|
||||
return (
|
||||
<div
|
||||
key={turn.id}
|
||||
ref={(el) => {
|
||||
turnRefs.current[turn.id] = el;
|
||||
}}
|
||||
className={
|
||||
"sr-turn" + (activeTurn === turn.id ? " sr-turn--active" : "")
|
||||
}
|
||||
>
|
||||
<div className="sr-turn__rail">
|
||||
<span className="sr-turn__ts">{turn.ts}</span>
|
||||
<span
|
||||
className={`sr-turn__node sr-turn__node--${turn.speaker}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{!isLast ? <span className="sr-turn__stem" aria-hidden="true" /> : null}
|
||||
</div>
|
||||
|
||||
<div className="sr-turn__body">
|
||||
<div className={`sr-turn__who sr-turn__who--${turn.speaker}`}>
|
||||
{turn.who}
|
||||
{turn.techniques?.map((tech, i) => (
|
||||
<TechniqueChip key={i} tech={tech} />
|
||||
))}
|
||||
{visibleTurns.length > 0 ? (
|
||||
visibleTurns.map((turn, idx) => {
|
||||
const isLast = idx === visibleTurns.length - 1;
|
||||
return (
|
||||
<div
|
||||
key={turn.id}
|
||||
ref={(el) => {
|
||||
turnRefs.current[turn.id] = el;
|
||||
}}
|
||||
className={
|
||||
"sr-turn" + (activeTurn === turn.id ? " sr-turn--active" : "")
|
||||
}
|
||||
>
|
||||
<div className="sr-turn__rail">
|
||||
<span className="sr-turn__ts">{turn.ts}</span>
|
||||
<span
|
||||
className={`sr-turn__node sr-turn__node--${turn.speaker}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{!isLast ? <span className="sr-turn__stem" aria-hidden="true" /> : null}
|
||||
</div>
|
||||
|
||||
<div className="sr-turn__body">
|
||||
<div className={`sr-turn__who sr-turn__who--${turn.speaker}`}>
|
||||
{turn.who}
|
||||
{turn.techniques.map((tech, i) => (
|
||||
<TechniqueChip key={`${tech.label}-${i}`} tech={tech} />
|
||||
))}
|
||||
</div>
|
||||
<p
|
||||
className={
|
||||
"sr-turn__said" +
|
||||
(turn.speaker === "client" ? " sr-turn__said--client" : "")
|
||||
}
|
||||
>
|
||||
{turn.text}
|
||||
</p>
|
||||
{turn.note ? <SupervisorCallout note={turn.note} /> : null}
|
||||
</div>
|
||||
<p
|
||||
className={
|
||||
"sr-turn__said" +
|
||||
(turn.speaker === "client" ? " sr-turn__said--client" : "")
|
||||
}
|
||||
>
|
||||
{turn.text}
|
||||
</p>
|
||||
{turn.note ? <SupervisorCallout note={turn.note} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<EmptyBlock
|
||||
title="축어록 없음"
|
||||
desc="아직 이 세션에 저장된 실제 발화가 없습니다."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* ── RIGHT: 스킬 루브릭 + 잘한 순간 + 개선점 + AI 내담자 피드백 ── */}
|
||||
<div className="sr-right">
|
||||
{/* 스킬 루브릭 (가로 바, 빈도+적절성 — 점수 아님) */}
|
||||
<Card>
|
||||
<Card className="sr-card sr-card--side">
|
||||
<Kicker>기법 사용 분포</Kicker>
|
||||
<div className="sr-rubric" style={{ marginTop: "var(--sp-4)" }}>
|
||||
{data.rubric.map((row) => (
|
||||
<div key={row.name} className="sr-rubric__row">
|
||||
<div className="sr-rubric__top">
|
||||
<span className="sr-rubric__name">{row.name}</span>
|
||||
<span className={`sr-rubric__qual sr-rubric__qual--${row.quality === "good" ? "good" : "watch"}`}>
|
||||
<span className="sr-technique__dot" aria-hidden="true" />
|
||||
{row.quality === "good" ? "적절" : "살펴볼 점"}
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar
|
||||
value={row.ratio}
|
||||
tone={row.quality === "good" ? "accent" : "warn"}
|
||||
slim
|
||||
label={`${row.name} 사용 빈도`}
|
||||
/>
|
||||
<div className="sr-rubric__cluster">
|
||||
{row.cluster} · {row.freq}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 잘한 순간 */}
|
||||
<Card>
|
||||
<Kicker>잘한 순간</Kicker>
|
||||
<div className="sr-points sr-points--good" style={{ marginTop: "var(--sp-4)" }}>
|
||||
{data.goodMoments.map((pt, i) => (
|
||||
<div key={i} className="sr-point">
|
||||
<span className="sr-point__mk" aria-hidden="true" />
|
||||
<div>
|
||||
<div className="sr-point__h">{pt.title}</div>
|
||||
<div className="sr-point__d">
|
||||
{pt.body}
|
||||
{pt.jumpTo ? (
|
||||
<button
|
||||
type="button"
|
||||
className="sr-point__at tabular"
|
||||
onClick={() => jumpToTurn(pt.jumpTo!)}
|
||||
>
|
||||
{data.turns.find((t) => t.id === pt.jumpTo)?.ts ?? ""}
|
||||
</button>
|
||||
) : null}
|
||||
{data.rubric.length > 0 ? (
|
||||
data.rubric.map((row) => (
|
||||
<div key={row.name} className="sr-rubric__row">
|
||||
<div className="sr-rubric__top">
|
||||
<span className="sr-rubric__name">{row.name}</span>
|
||||
<span
|
||||
className={`sr-rubric__qual sr-rubric__qual--${
|
||||
row.quality === "good" ? "good" : "watch"
|
||||
}`}
|
||||
>
|
||||
<span className="sr-technique__dot" aria-hidden="true" />
|
||||
{row.quality === "good" ? "적절" : "살펴보기"}
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar
|
||||
value={row.ratio}
|
||||
tone={row.quality === "good" ? "accent" : "warn"}
|
||||
slim
|
||||
label={`${row.name} 사용 빈도`}
|
||||
/>
|
||||
<div className="sr-rubric__cluster">
|
||||
{row.cluster} · {row.freq}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
))
|
||||
) : (
|
||||
<EmptyBlock
|
||||
title="평가 대기"
|
||||
desc="기법 분포는 평가 AI 또는 교수자 리뷰가 생성된 뒤 표시됩니다."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 다음 회기 개선점 (최대 3개) */}
|
||||
<Card>
|
||||
<Card className="sr-card sr-card--side">
|
||||
<Kicker>좋았던 순간</Kicker>
|
||||
<div className="sr-points sr-points--good" style={{ marginTop: "var(--sp-4)" }}>
|
||||
{data.goodMoments.length > 0 ? (
|
||||
data.goodMoments.map((point, i) => (
|
||||
<JumpablePoint
|
||||
key={`${point.title}-${i}`}
|
||||
point={point}
|
||||
turns={data.turns}
|
||||
onJump={jumpToTurn}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<EmptyBlock
|
||||
title="아직 코멘트 없음"
|
||||
desc="교수자 또는 평가 AI가 근거가 있는 강점을 생성하면 이곳에 표시됩니다."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="sr-card sr-card--side">
|
||||
<Kicker>다음 회기 개선점</Kicker>
|
||||
<div className="sr-points sr-points--grow" style={{ marginTop: "var(--sp-4)" }}>
|
||||
{data.growthPoints.slice(0, 3).map((pt, i) => (
|
||||
<div key={i} className="sr-point">
|
||||
<span className="sr-point__mk" aria-hidden="true" />
|
||||
<div>
|
||||
<div className="sr-point__h">{pt.title}</div>
|
||||
<div className="sr-point__d">
|
||||
{pt.body}
|
||||
{pt.jumpTo ? (
|
||||
<button
|
||||
type="button"
|
||||
className="sr-point__at tabular"
|
||||
onClick={() => jumpToTurn(pt.jumpTo!)}
|
||||
>
|
||||
{data.turns.find((t) => t.id === pt.jumpTo)?.ts ?? ""}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="sr-nextline">
|
||||
<div className="sr-nextline__lab">다음에 시도할 한 문장</div>
|
||||
<div className="sr-nextline__q">{data.nextLine}</div>
|
||||
{data.growthPoints.length > 0 ? (
|
||||
data.growthPoints.slice(0, 3).map((point, i) => (
|
||||
<JumpablePoint
|
||||
key={`${point.title}-${i}`}
|
||||
point={point}
|
||||
turns={data.turns}
|
||||
onJump={jumpToTurn}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<EmptyBlock
|
||||
title="개선점 대기"
|
||||
desc="실제 평가 결과가 없는 동안에는 개선점을 임의로 제시하지 않습니다."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{data.nextLine ? (
|
||||
<div className="sr-nextline">
|
||||
<div className="sr-nextline__lab">다음에 시도할 문장</div>
|
||||
<div className="sr-nextline__q">{data.nextLine}</div>
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
{/* AI 내담자 피드백 (다크 카드, italic 1인칭) */}
|
||||
<div className="sr-feedback">
|
||||
<div className="sr-feedback__kicker">
|
||||
<span className="sr-technique__dot" aria-hidden="true" />
|
||||
내담자가 느낀 것
|
||||
</div>
|
||||
<p className="sr-feedback__quote">“{data.clientFeedback}”</p>
|
||||
<div className="sr-feedback__src">
|
||||
<Icon name="info" size={13} strokeWidth={2} />
|
||||
AI 내담자 페르소나가 생성
|
||||
내담자가 남긴 것
|
||||
</div>
|
||||
{data.clientFeedback ? (
|
||||
<>
|
||||
<p className="sr-feedback__quote">“{data.clientFeedback}”</p>
|
||||
<div className="sr-feedback__src">
|
||||
<Icon name="info" size={13} strokeWidth={2} />
|
||||
AI 내담자 페르소나가 생성
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="sr-feedback__quote">아직 생성된 내담자 피드백이 없습니다.</p>
|
||||
<div className="sr-feedback__src">
|
||||
<Icon name="info" size={13} strokeWidth={2} />
|
||||
실제 평가 결과가 생기면 표시
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{sessionId ? (
|
||||
<p
|
||||
style={{
|
||||
fontSize: "var(--fs-xs)",
|
||||
color: "var(--text-muted)",
|
||||
fontFamily: "var(--font-num)",
|
||||
}}
|
||||
>
|
||||
<p className="sr-session-id">
|
||||
세션 ID · <span className="tabular">{sessionId}</span>
|
||||
</p>
|
||||
) : null}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,105 +0,0 @@
|
|||
/* =====================================================================
|
||||
Admin 페이지 전용 inline SVG 아이콘.
|
||||
공통 Icon(ui/Icon.tsx)에 없는 운영용 아이콘만 여기서 로컬 정의.
|
||||
(공통 Icon 은 수정 금지 → 이 페이지에서 필요한 것만 자체 보유.)
|
||||
철칙: 이모지 금지. 전부 stroke=currentColor, fill=none.
|
||||
===================================================================== */
|
||||
|
||||
import type { ReactNode, SVGProps } from "react";
|
||||
|
||||
type LocalIconName =
|
||||
| "search"
|
||||
| "download"
|
||||
| "database"
|
||||
| "persona"
|
||||
| "megaphone"
|
||||
| "shield-check"
|
||||
| "sliders";
|
||||
|
||||
interface AdminIconProps extends Omit<SVGProps<SVGSVGElement>, "name"> {
|
||||
name: LocalIconName;
|
||||
size?: number;
|
||||
strokeWidth?: number;
|
||||
}
|
||||
|
||||
const PATHS: Record<LocalIconName, ReactNode> = {
|
||||
search: (
|
||||
<>
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</>
|
||||
),
|
||||
download: (
|
||||
<>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="7 10 12 15 17 10" />
|
||||
<line x1="12" y1="15" x2="12" y2="3" />
|
||||
</>
|
||||
),
|
||||
database: (
|
||||
<>
|
||||
<ellipse cx="12" cy="5" rx="8" ry="3" />
|
||||
<path d="M4 5v14c0 1.66 3.58 3 8 3s8-1.34 8-3V5" />
|
||||
<path d="M4 12c0 1.66 3.58 3 8 3s8-1.34 8-3" />
|
||||
</>
|
||||
),
|
||||
persona: (
|
||||
<>
|
||||
<circle cx="12" cy="8" r="4" />
|
||||
<path d="M4 21c0-4 3.6-6 8-6s8 2 8 6" />
|
||||
</>
|
||||
),
|
||||
megaphone: (
|
||||
<>
|
||||
<path d="M3 11l18-5v12L3 14v-3z" />
|
||||
<path d="M11.6 16.8a3 3 0 1 1-5.8-1.6" />
|
||||
</>
|
||||
),
|
||||
"shield-check": (
|
||||
<>
|
||||
<path d="M12 2l8 3v6c0 5-3.5 8.5-8 10-4.5-1.5-8-5-8-10V5l8-3z" />
|
||||
<path d="M9 12l2 2 4-4" />
|
||||
</>
|
||||
),
|
||||
sliders: (
|
||||
<>
|
||||
<line x1="4" y1="21" x2="4" y2="14" />
|
||||
<line x1="4" y1="10" x2="4" y2="3" />
|
||||
<line x1="12" y1="21" x2="12" y2="12" />
|
||||
<line x1="12" y1="8" x2="12" y2="3" />
|
||||
<line x1="20" y1="21" x2="20" y2="16" />
|
||||
<line x1="20" y1="12" x2="20" y2="3" />
|
||||
<line x1="1" y1="14" x2="7" y2="14" />
|
||||
<line x1="9" y1="8" x2="15" y2="8" />
|
||||
<line x1="17" y1="16" x2="23" y2="16" />
|
||||
</>
|
||||
),
|
||||
};
|
||||
|
||||
/** Admin 로컬 아이콘 — stroke 기반, currentColor. */
|
||||
export function AdminIcon({
|
||||
name,
|
||||
size = 18,
|
||||
strokeWidth = 1.8,
|
||||
...rest
|
||||
}: AdminIconProps) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
{...rest}
|
||||
>
|
||||
{PATHS[name]}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export type { LocalIconName };
|
||||
|
|
@ -1,203 +0,0 @@
|
|||
/* =====================================================================
|
||||
교수자 활동 audit log 테이블 — 필터 칩 + 검색 + 데이터주권(geo) + 페이지네이션.
|
||||
§6.6: 시간순 한 컬럼 흐름, 행위자·행위·대상. 색은 중립(감사). 민감 접근만 warn 배지.
|
||||
인터랙션은 부모(Admin) state 가 제어 — 이 컴포넌트는 표현/이벤트 위임만.
|
||||
===================================================================== */
|
||||
|
||||
import { Badge, Dot } from "../../components/ui";
|
||||
import { AdminIcon } from "./AdminIcons";
|
||||
import {
|
||||
ACTION_META,
|
||||
scopeTone,
|
||||
type AuditAction,
|
||||
type AuditEntry,
|
||||
} from "./data";
|
||||
|
||||
export interface AuditFilter {
|
||||
key: AuditAction | "all";
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const AUDIT_FILTERS: AuditFilter[] = [
|
||||
{ key: "all", label: "전체" },
|
||||
{ key: "view", label: "세션 열람" },
|
||||
{ key: "audio", label: "오디오 재생" },
|
||||
{ key: "export", label: "내보내기" },
|
||||
{ key: "grade", label: "평가 작성" },
|
||||
{ key: "edit", label: "평가 수정" },
|
||||
];
|
||||
|
||||
export interface AuditTableProps {
|
||||
/** 현재 페이지에 보일 행 */
|
||||
rows: AuditEntry[];
|
||||
/** 활성 필터 */
|
||||
filter: AuditAction | "all";
|
||||
onFilter: (key: AuditAction | "all") => void;
|
||||
/** 검색어 */
|
||||
query: string;
|
||||
onQuery: (q: string) => void;
|
||||
/** 페이지네이션 */
|
||||
page: number;
|
||||
pageCount: number;
|
||||
onPage: (p: number) => void;
|
||||
/** 전체 매칭 건수(푸터 표기) */
|
||||
total: number;
|
||||
/** 현재 페이지 표시 범위 */
|
||||
rangeFrom: number;
|
||||
rangeTo: number;
|
||||
}
|
||||
|
||||
function GeoTag({ geo }: { geo: "kr" | "us" }) {
|
||||
const isKr = geo === "kr";
|
||||
return (
|
||||
<span
|
||||
className="vgad-geo"
|
||||
title={isKr ? "국내(KR) 추론 — 데이터 주권 준수" : "해외(US) 추론 경유"}
|
||||
>
|
||||
<span className={"vgad-geo__dot " + (isKr ? "vgad-geo__dot--kr" : "vgad-geo__dot--us")} />
|
||||
{isKr ? "KR" : "US"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function AuditTable(props: AuditTableProps) {
|
||||
const {
|
||||
rows,
|
||||
filter,
|
||||
onFilter,
|
||||
query,
|
||||
onQuery,
|
||||
page,
|
||||
pageCount,
|
||||
onPage,
|
||||
total,
|
||||
rangeFrom,
|
||||
rangeTo,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div className="vgad-tablewrap">
|
||||
{/* 툴바: 검색 + 행위 필터 칩 */}
|
||||
<div className="vgad-toolbar">
|
||||
<label className="vgad-search">
|
||||
<span className="vgad-search__ic">
|
||||
<AdminIcon name="search" size={15} strokeWidth={2} />
|
||||
</span>
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => onQuery(e.target.value)}
|
||||
placeholder="교수자·학습자·세션 ID로 검색"
|
||||
aria-label="감사 로그 검색"
|
||||
/>
|
||||
</label>
|
||||
{AUDIT_FILTERS.map((f) => (
|
||||
<button
|
||||
key={f.key}
|
||||
type="button"
|
||||
className={"vgad-chip" + (filter === f.key ? " is-on" : "")}
|
||||
aria-pressed={filter === f.key}
|
||||
onClick={() => onFilter(f.key)}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
<span className="vgad-toolbar__spacer" />
|
||||
</div>
|
||||
|
||||
<table className="vgad-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 96 }}>시각</th>
|
||||
<th style={{ width: 190 }}>교수자</th>
|
||||
<th style={{ width: 170 }} className="vgad-hide-sm">행위</th>
|
||||
<th>대상</th>
|
||||
<th style={{ width: 74 }} className="vgad-hide-sm">추론 지역</th>
|
||||
<th style={{ width: 120 }}>범위</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 ? (
|
||||
<tr className="vgad-empty-row">
|
||||
<td colSpan={6}>조건에 맞는 감사 기록이 없습니다.</td>
|
||||
</tr>
|
||||
) : (
|
||||
rows.map((r) => {
|
||||
const meta = ACTION_META[r.action];
|
||||
return (
|
||||
<tr key={r.id}>
|
||||
<td className="vgad-td-time">{r.time}</td>
|
||||
<td>
|
||||
<div className="vgad-actor">
|
||||
<span className="vgad-actor__av" aria-hidden="true">
|
||||
{r.actorInitial}
|
||||
</span>
|
||||
<span>
|
||||
<span className="vgad-actor__nm">{r.actorName}</span>
|
||||
<span className="vgad-actor__sub">{r.actorRole}</span>
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="vgad-hide-sm">
|
||||
<span className="vgad-act">
|
||||
<Dot tone={meta.tone} size={7} />
|
||||
{r.actionLabel}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span className="vgad-target">
|
||||
{r.target}
|
||||
<span className="vgad-target__meta">{r.targetMeta}</span>
|
||||
</span>
|
||||
</td>
|
||||
<td className="vgad-hide-sm">
|
||||
<GeoTag geo={r.geo} />
|
||||
</td>
|
||||
<td>
|
||||
<Badge tone={scopeTone(r.sensitive)}>{r.scope}</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div className="vgad-tfoot">
|
||||
<span className="vgad-tfoot__cnt tabular">
|
||||
{total > 0
|
||||
? `최근 24시간 ${total}건 중 ${rangeFrom}–${rangeTo} 표시 · 모든 열람은 위·변조 불가 로그로 보존됩니다`
|
||||
: "표시할 감사 기록이 없습니다"}
|
||||
</span>
|
||||
<div className="vgad-pg">
|
||||
<button
|
||||
type="button"
|
||||
disabled={page <= 1}
|
||||
onClick={() => onPage(page - 1)}
|
||||
aria-label="이전 페이지"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
{Array.from({ length: pageCount }, (_, i) => i + 1).map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
type="button"
|
||||
className={p === page ? "is-on" : ""}
|
||||
aria-current={p === page ? "page" : undefined}
|
||||
onClick={() => onPage(p)}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
disabled={page >= pageCount}
|
||||
onClick={() => onPage(page + 1)}
|
||||
aria-label="다음 페이지"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,127 +0,0 @@
|
|||
/* =====================================================================
|
||||
세션 추이 — 단일 라인 차트 (§6.7 데이터 시각화 하드룰).
|
||||
막대 금지 / 면적 그라데이션 금지 / 세로 격자 없음.
|
||||
y축 옅은 점선만, 라인 무채(--ink-2), 피크 1점만 accent 라벨.
|
||||
진입 draw 애니메이션은 admin.css 가 처리(prefers-reduced-motion 대응).
|
||||
===================================================================== */
|
||||
|
||||
import { useMemo, type CSSProperties } from "react";
|
||||
|
||||
/** CSS 커스텀 프로퍼티(--vgad-len)를 style 에 안전하게 싣기 위한 확장 타입. */
|
||||
type CSSVarStyle = CSSProperties & Record<`--${string}`, string | number>;
|
||||
|
||||
export interface TrendPoint {
|
||||
day: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface TrendChartProps {
|
||||
data: TrendPoint[];
|
||||
/** 접근성 라벨 */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
const W = 560;
|
||||
const H = 160;
|
||||
const PAD_L = 34;
|
||||
const PAD_R = 16;
|
||||
const PAD_T = 16;
|
||||
const PAD_B = 26;
|
||||
|
||||
export function TrendChart({ data, label = "세션 추이" }: TrendChartProps) {
|
||||
const { points, peak, gridYs, niceMax } = useMemo(() => {
|
||||
const max = Math.max(...data.map((d) => d.value), 1);
|
||||
// 50 단위로 올림(차트 y 상한 — tabular 라벨용)
|
||||
const nm = Math.ceil(max / 50) * 50;
|
||||
const innerW = W - PAD_L - PAD_R;
|
||||
const innerH = H - PAD_T - PAD_B;
|
||||
const stepX = data.length > 1 ? innerW / (data.length - 1) : 0;
|
||||
|
||||
const pts = data.map((d, i) => ({
|
||||
...d,
|
||||
x: PAD_L + stepX * i,
|
||||
y: PAD_T + innerH * (1 - d.value / nm),
|
||||
}));
|
||||
|
||||
// 피크(최댓값) 1점만 라벨
|
||||
let peakIdx = 0;
|
||||
data.forEach((d, i) => {
|
||||
if (d.value > data[peakIdx].value) peakIdx = i;
|
||||
});
|
||||
|
||||
const ys = [0, 0.5, 1].map((r) => ({
|
||||
v: Math.round(nm * (1 - r)),
|
||||
y: PAD_T + innerH * r,
|
||||
}));
|
||||
|
||||
return { points: pts, peak: pts[peakIdx], gridYs: ys, niceMax: nm };
|
||||
}, [data]);
|
||||
|
||||
const path = points
|
||||
.map((p, i) => `${i === 0 ? "M" : "L"}${p.x.toFixed(1)} ${p.y.toFixed(1)}`)
|
||||
.join(" ");
|
||||
|
||||
// 폴리라인 길이 근사 → draw 애니메이션 dasharray 변수
|
||||
const len = useMemo(() => {
|
||||
let total = 0;
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
const dx = points[i].x - points[i - 1].x;
|
||||
const dy = points[i].y - points[i - 1].y;
|
||||
total += Math.hypot(dx, dy);
|
||||
}
|
||||
return Math.ceil(total) + 4;
|
||||
}, [points]);
|
||||
|
||||
return (
|
||||
<svg
|
||||
className="vgad-trend"
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
role="img"
|
||||
aria-label={`${label}. 최대 ${niceMax}건, 피크 ${peak.day}요일 ${peak.value}건.`}
|
||||
style={{ "--vgad-len": len } as CSSVarStyle}
|
||||
>
|
||||
{/* y축 점선 격자 + 라벨 (세로 격자 없음) */}
|
||||
{gridYs.map((g, i) => (
|
||||
<g key={i}>
|
||||
<line
|
||||
className="vgad-trend__grid"
|
||||
x1={PAD_L}
|
||||
y1={g.y}
|
||||
x2={W - PAD_R}
|
||||
y2={g.y}
|
||||
/>
|
||||
<text className="vgad-trend__ylab" x={PAD_L - 8} y={g.y + 3} textAnchor="end">
|
||||
{g.v}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
|
||||
{/* 단일 라인 */}
|
||||
<path className="vgad-trend__line" d={path} />
|
||||
|
||||
{/* 피크 1점만 강조 */}
|
||||
<circle className="vgad-trend__peak" cx={peak.x} cy={peak.y} r={3.5} />
|
||||
<text
|
||||
className="vgad-trend__peaklab"
|
||||
x={peak.x}
|
||||
y={peak.y - 10}
|
||||
textAnchor="middle"
|
||||
>
|
||||
{peak.value}
|
||||
</text>
|
||||
|
||||
{/* x축 요일 라벨 */}
|
||||
{points.map((p, i) => (
|
||||
<text
|
||||
key={i}
|
||||
className="vgad-trend__xlab"
|
||||
x={p.x}
|
||||
y={H - 8}
|
||||
textAnchor="middle"
|
||||
>
|
||||
{p.day}
|
||||
</text>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,869 +0,0 @@
|
|||
/* =====================================================================
|
||||
Admin 페이지 전용 스타일. 토큰(tokens.css)만 참조.
|
||||
네임스페이스 vgad- 로 공통 ui.css(vg-)와 충돌 0.
|
||||
철칙: border-left 강조선 0 · 이모지 0 · 카드덤프 0 · 순흑/순백 0 ·
|
||||
radius 절제(8/12) · 강조는 weight+tint+kicker+dot.
|
||||
===================================================================== */
|
||||
|
||||
/* ── 페이지 헤드 (제목 + 우측 동기화 시각) ── */
|
||||
.vgad-head {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-5);
|
||||
margin-bottom: var(--sp-6);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.vgad-head__title {
|
||||
font-size: var(--fs-h1);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--text-strong);
|
||||
line-height: 1.25;
|
||||
margin-top: var(--sp-2);
|
||||
}
|
||||
.vgad-head__lead {
|
||||
font-size: var(--fs-body);
|
||||
color: var(--text-body);
|
||||
margin-top: 6px;
|
||||
max-width: 600px;
|
||||
}
|
||||
.vgad-head__when {
|
||||
font-family: var(--font-num);
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
}
|
||||
.vgad-head__when b {
|
||||
display: block;
|
||||
color: var(--text-body);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
/* ── 한 줄 상태 요약 (에디토리얼 — 큰 문장 하나) ── */
|
||||
.vgad-statusline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: var(--sp-5) var(--sp-6);
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--hair);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
.vgad-statusline__pulse {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-radius: 50%;
|
||||
background: var(--pos-solid);
|
||||
flex: none;
|
||||
box-shadow: 0 0 0 0 rgba(59, 140, 94, 0.4);
|
||||
animation: vgad-pulse var(--dur-breathe) var(--ease-in-out) infinite;
|
||||
}
|
||||
.vgad-statusline__pulse--warn {
|
||||
background: var(--warn-solid);
|
||||
animation: none;
|
||||
}
|
||||
.vgad-statusline__pulse--crit {
|
||||
background: var(--crit-solid);
|
||||
animation: none;
|
||||
}
|
||||
@keyframes vgad-pulse {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(59, 140, 94, 0.35);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 8px rgba(59, 140, 94, 0);
|
||||
}
|
||||
}
|
||||
.vgad-statusline__msg {
|
||||
font-size: var(--fs-lead);
|
||||
font-weight: 500;
|
||||
color: var(--text-strong);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.vgad-statusline__msg b {
|
||||
font-weight: 700;
|
||||
}
|
||||
.vgad-statusline__detail {
|
||||
color: var(--text-body);
|
||||
font-weight: 400;
|
||||
}
|
||||
.vgad-statusline__ts {
|
||||
margin-left: auto;
|
||||
font-family: var(--font-num);
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── 운영 KPI (인라인 행 — 카드덤프 아님, 헤어라인 셀 구분) ── */
|
||||
.vgad-kpis {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
margin-top: var(--sp-5);
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--hair);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
.vgad-kpi {
|
||||
padding: var(--sp-5) var(--sp-5);
|
||||
}
|
||||
/* 셀 구분 헤어라인 — 강조선이 아니라 컬럼 divider */
|
||||
.vgad-kpi + .vgad-kpi {
|
||||
border-left: 1px solid var(--hair);
|
||||
}
|
||||
.vgad-kpi__l {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
.vgad-kpi__n {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: var(--text-strong);
|
||||
letter-spacing: -0.02em;
|
||||
margin-top: 8px;
|
||||
line-height: 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.vgad-kpi__n--warn {
|
||||
color: var(--warn-text);
|
||||
}
|
||||
.vgad-kpi__n--crit {
|
||||
color: var(--crit-text);
|
||||
}
|
||||
.vgad-kpi__u {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
margin-left: 4px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.vgad-kpi__d {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--text-muted);
|
||||
margin-top: 9px;
|
||||
}
|
||||
.vgad-kpi__d b {
|
||||
font-weight: 600;
|
||||
}
|
||||
.vgad-kpi__d--pos b {
|
||||
color: var(--pos-text);
|
||||
}
|
||||
.vgad-kpi__d--info b {
|
||||
color: var(--info-text);
|
||||
}
|
||||
.vgad-kpi__d--warn b {
|
||||
color: var(--warn-text);
|
||||
}
|
||||
.vgad-kpi__d--crit b {
|
||||
color: var(--crit-text);
|
||||
}
|
||||
|
||||
/* ── 섹션 ── */
|
||||
.vgad-section {
|
||||
margin-top: var(--sp-7);
|
||||
}
|
||||
.vgad-sechead {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-4);
|
||||
margin-bottom: var(--sp-5);
|
||||
}
|
||||
.vgad-sechead__title {
|
||||
font-size: var(--fs-h3);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
.vgad-sechead__meta {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ── 헬스 라인 ── */
|
||||
.vgad-health {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--hair);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
.vgad-hrow {
|
||||
display: grid;
|
||||
grid-template-columns: 220px 1fr auto 86px;
|
||||
align-items: center;
|
||||
gap: var(--sp-4);
|
||||
padding: 14px var(--sp-6);
|
||||
}
|
||||
.vgad-hrow + .vgad-hrow {
|
||||
border-top: 1px solid var(--hair);
|
||||
}
|
||||
.vgad-hrow__svc {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
}
|
||||
.vgad-hrow__nm {
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 600;
|
||||
color: var(--text-strong);
|
||||
line-height: 1.3;
|
||||
}
|
||||
.vgad-hrow__sub {
|
||||
display: block;
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--text-muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
.vgad-hrow__gauge {
|
||||
height: 5px;
|
||||
background: var(--paper-2);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
max-width: 300px;
|
||||
}
|
||||
.vgad-hrow__fill {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
background: var(--accent-bright);
|
||||
transition: width var(--dur-slow) var(--ease-out);
|
||||
}
|
||||
.vgad-hrow__fill--warn {
|
||||
background: var(--warn-solid);
|
||||
}
|
||||
.vgad-hrow__fill--crit {
|
||||
background: var(--crit-solid);
|
||||
}
|
||||
.vgad-hrow__num {
|
||||
font-family: var(--font-num);
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 600;
|
||||
color: var(--text-body);
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.vgad-hrow__stat {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
/* ── 2-컬럼 수집 현황 ── */
|
||||
.vgad-collect {
|
||||
display: grid;
|
||||
grid-template-columns: 1.4fr 1fr;
|
||||
gap: var(--sp-6);
|
||||
}
|
||||
.vgad-panel__ph {
|
||||
font-size: var(--fs-body);
|
||||
font-weight: 600;
|
||||
color: var(--text-strong);
|
||||
margin-bottom: var(--sp-2);
|
||||
}
|
||||
.vgad-panel__psub {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--text-muted);
|
||||
margin-bottom: var(--sp-5);
|
||||
}
|
||||
|
||||
/* 분포 — 가로 게이지 목록 */
|
||||
.vgad-distrib {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-5);
|
||||
}
|
||||
.vgad-dist {
|
||||
display: grid;
|
||||
grid-template-columns: 130px 1fr 52px;
|
||||
align-items: center;
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
.vgad-dist__l {
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--text-body);
|
||||
font-weight: 500;
|
||||
}
|
||||
.vgad-dist__v {
|
||||
font-family: var(--font-num);
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 600;
|
||||
color: var(--text-strong);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* 역할별 사용 — 행 목록 */
|
||||
.vgad-rolelist {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.vgad-rr {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 0;
|
||||
}
|
||||
.vgad-rr + .vgad-rr {
|
||||
border-top: 1px solid var(--paper-2);
|
||||
}
|
||||
.vgad-rr__l {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
}
|
||||
.vgad-rr__sq {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 2px;
|
||||
flex: none;
|
||||
}
|
||||
.vgad-rr__nm {
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--text-strong);
|
||||
font-weight: 500;
|
||||
}
|
||||
.vgad-rr__sub {
|
||||
display: block;
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--text-muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
.vgad-rr__v {
|
||||
font-family: var(--font-num);
|
||||
font-size: var(--fs-lead);
|
||||
font-weight: 700;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
.vgad-rr__delta {
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 600;
|
||||
color: var(--pos-text);
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
/* ── 사용·비용 미터 목록 ── */
|
||||
.vgad-usage {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--sp-5) var(--sp-6);
|
||||
}
|
||||
.vgad-meter__top {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-3);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.vgad-meter__nm {
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 600;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
.vgad-meter__sub {
|
||||
display: block;
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--text-muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
.vgad-meter__v {
|
||||
font-family: var(--font-num);
|
||||
font-size: var(--fs-body);
|
||||
font-weight: 700;
|
||||
color: var(--text-strong);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.vgad-meter__v--warn {
|
||||
color: var(--warn-text);
|
||||
}
|
||||
.vgad-meter__foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 7px;
|
||||
}
|
||||
.vgad-meter__budget {
|
||||
font-family: var(--font-num);
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.vgad-meter__pct {
|
||||
font-family: var(--font-num);
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 600;
|
||||
color: var(--text-body);
|
||||
}
|
||||
.vgad-meter__pct--warn {
|
||||
color: var(--warn-text);
|
||||
}
|
||||
|
||||
/* ── 세션 추이 라인 차트 (단일 라인, 막대 금지) ── */
|
||||
.vgad-trend {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
.vgad-trend__grid {
|
||||
stroke: var(--hair);
|
||||
stroke-width: 1;
|
||||
stroke-dasharray: 3 4;
|
||||
}
|
||||
.vgad-trend__line {
|
||||
fill: none;
|
||||
stroke: var(--ink-2);
|
||||
stroke-width: 1.5;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
.vgad-trend__peak {
|
||||
fill: var(--accent);
|
||||
}
|
||||
.vgad-trend__peaklab {
|
||||
font-family: var(--font-num);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
fill: var(--accent-deep);
|
||||
}
|
||||
.vgad-trend__xlab {
|
||||
font-family: var(--font-num);
|
||||
font-size: 11px;
|
||||
fill: var(--text-muted);
|
||||
}
|
||||
.vgad-trend__ylab {
|
||||
font-family: var(--font-num);
|
||||
font-size: 10px;
|
||||
fill: var(--text-muted);
|
||||
}
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.vgad-trend__line {
|
||||
stroke-dasharray: var(--vgad-len, 1000);
|
||||
stroke-dashoffset: var(--vgad-len, 1000);
|
||||
animation: vgad-draw var(--dur-slow) var(--ease-out) forwards;
|
||||
}
|
||||
}
|
||||
@keyframes vgad-draw {
|
||||
to {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 교수 활동 감사 + audit log 공통 테이블 ── */
|
||||
.vgad-tablewrap {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--hair);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
.vgad-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-4) var(--sp-5);
|
||||
border-bottom: 1px solid var(--hair);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.vgad-search {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
max-width: 320px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
background: var(--bg-surface-2);
|
||||
border: 1px solid var(--hair);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 7px 12px;
|
||||
transition: border-color var(--dur-fast) var(--ease-out),
|
||||
box-shadow var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
.vgad-search:focus-within {
|
||||
border-color: var(--border-focus);
|
||||
box-shadow: 0 0 0 3px var(--focus-ring);
|
||||
}
|
||||
.vgad-search__ic {
|
||||
color: var(--text-muted);
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
}
|
||||
.vgad-search input {
|
||||
border: none;
|
||||
background: none;
|
||||
outline: none;
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--text-strong);
|
||||
width: 100%;
|
||||
}
|
||||
.vgad-search input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.vgad-chip {
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 500;
|
||||
color: var(--text-body);
|
||||
padding: 7px 13px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--hair);
|
||||
background: var(--bg-surface);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background var(--dur-fast) var(--ease-out),
|
||||
color var(--dur-fast) var(--ease-out),
|
||||
border-color var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
.vgad-chip:hover {
|
||||
background: var(--bg-surface-2);
|
||||
color: var(--text-strong);
|
||||
}
|
||||
.vgad-chip.is-on {
|
||||
background: var(--accent-tint);
|
||||
color: var(--accent-deep);
|
||||
border-color: transparent;
|
||||
font-weight: 600;
|
||||
}
|
||||
.vgad-toolbar__spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.vgad-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.vgad-table thead th {
|
||||
font-family: var(--font-num);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
text-align: left;
|
||||
padding: 11px var(--sp-5);
|
||||
background: var(--paper-2);
|
||||
border-bottom: 1px solid var(--hair);
|
||||
}
|
||||
.vgad-table thead th.is-num {
|
||||
text-align: right;
|
||||
}
|
||||
.vgad-table tbody td {
|
||||
padding: 14px var(--sp-5);
|
||||
border-bottom: 1px solid var(--paper-2);
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--text-body);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.vgad-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
.vgad-table tbody tr {
|
||||
transition: background var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
.vgad-table tbody tr:hover {
|
||||
background: var(--paper);
|
||||
}
|
||||
.vgad-table td.is-num {
|
||||
text-align: right;
|
||||
font-family: var(--font-num);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.vgad-td-time {
|
||||
font-family: var(--font-num);
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 행위자 셀 */
|
||||
.vgad-actor {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.vgad-actor__av {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
background: var(--accent-tint);
|
||||
color: var(--accent-deep);
|
||||
}
|
||||
.vgad-actor__nm {
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 600;
|
||||
color: var(--text-strong);
|
||||
line-height: 1.3;
|
||||
}
|
||||
.vgad-actor__sub {
|
||||
display: block;
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--text-muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* 행위 셀 */
|
||||
.vgad-act {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 대상 셀 */
|
||||
.vgad-target {
|
||||
color: var(--text-strong);
|
||||
font-weight: 500;
|
||||
}
|
||||
.vgad-target__meta {
|
||||
display: block;
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--text-muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* 데이터 주권(geo) 표기 */
|
||||
.vgad-geo {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: var(--font-num);
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-body);
|
||||
}
|
||||
.vgad-geo__dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
flex: none;
|
||||
}
|
||||
.vgad-geo__dot--kr {
|
||||
background: var(--accent-bright);
|
||||
}
|
||||
.vgad-geo__dot--us {
|
||||
background: var(--clay);
|
||||
}
|
||||
|
||||
/* 테이블 푸터(페이지네이션) */
|
||||
.vgad-tfoot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-4);
|
||||
padding: 13px var(--sp-5);
|
||||
border-top: 1px solid var(--hair);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.vgad-tfoot__cnt {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.vgad-pg {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.vgad-pg button {
|
||||
font-family: var(--font-num);
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--text-body);
|
||||
border: 1px solid var(--hair);
|
||||
background: var(--bg-surface);
|
||||
min-width: 30px;
|
||||
height: 30px;
|
||||
padding: 0 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: background var(--dur-fast) var(--ease-out),
|
||||
color var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
.vgad-pg button:hover:not(:disabled) {
|
||||
background: var(--bg-surface-2);
|
||||
color: var(--text-strong);
|
||||
}
|
||||
.vgad-pg button.is-on {
|
||||
background: var(--accent);
|
||||
color: var(--text-on-accent);
|
||||
border-color: transparent;
|
||||
font-weight: 600;
|
||||
}
|
||||
.vgad-pg button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 빈 결과 행 */
|
||||
.vgad-empty-row td {
|
||||
padding: var(--sp-6) var(--sp-5);
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
/* 교수 감사 — 열람률 미니바 */
|
||||
.vgad-ratebar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.vgad-ratebar__track {
|
||||
width: 64px;
|
||||
height: 5px;
|
||||
background: var(--paper-2);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.vgad-ratebar__fill {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
background: var(--accent-bright);
|
||||
}
|
||||
.vgad-ratebar__fill--warn {
|
||||
background: var(--warn-solid);
|
||||
}
|
||||
.vgad-ratebar__pct {
|
||||
font-family: var(--font-num);
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 600;
|
||||
color: var(--text-strong);
|
||||
min-width: 38px;
|
||||
text-align: right;
|
||||
}
|
||||
.vgad-attn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--warn-text);
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── 관리 진입 (entry 카드, 4개 — 덤프 아님: 명확한 액션 목적지) ── */
|
||||
.vgad-mgmt {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
.vgad-entry {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 11px;
|
||||
padding: var(--sp-5);
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--hair);
|
||||
border-radius: var(--radius-lg);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: border-color var(--dur-base) var(--ease-out),
|
||||
transform var(--dur-base) var(--ease-out);
|
||||
}
|
||||
.vgad-entry:hover {
|
||||
border-color: var(--accent-bright);
|
||||
transform: translateY(-1px);
|
||||
text-decoration: none;
|
||||
}
|
||||
.vgad-entry__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
.vgad-entry__ico {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: var(--radius);
|
||||
background: var(--accent-tint);
|
||||
color: var(--accent-deep);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
}
|
||||
.vgad-entry__t {
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 600;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
.vgad-entry__d {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--text-muted);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.vgad-entry__go {
|
||||
margin-top: auto;
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
/* ── 페이지 푸터 (윤리/버전) ── */
|
||||
.vgad-foot {
|
||||
margin-top: var(--sp-7);
|
||||
padding-top: var(--sp-5);
|
||||
border-top: 1px solid var(--hair);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-4);
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--text-muted);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ── 반응형 ── */
|
||||
@media (max-width: 1080px) {
|
||||
.vgad-kpis {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
.vgad-kpi:nth-child(odd) {
|
||||
border-left: none;
|
||||
}
|
||||
.vgad-kpi:nth-child(n + 3) {
|
||||
border-top: 1px solid var(--hair);
|
||||
}
|
||||
.vgad-kpi:nth-child(even) {
|
||||
border-left: 1px solid var(--hair);
|
||||
}
|
||||
.vgad-collect {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.vgad-usage {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.vgad-mgmt {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
.vgad-hrow {
|
||||
grid-template-columns: 180px 1fr auto;
|
||||
}
|
||||
.vgad-hrow__stat {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.vgad-hrow {
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
.vgad-hrow__gauge {
|
||||
display: none;
|
||||
}
|
||||
.vgad-mgmt {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.vgad-table td.vgad-hide-sm,
|
||||
.vgad-table th.vgad-hide-sm {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,357 +0,0 @@
|
|||
/* =====================================================================
|
||||
Admin 페이지 전용 mock 데이터 + 타입.
|
||||
[주의] 전부 mock(가짜) 값이다 — 백엔드 텔레메트리 엔드포인트가 붙기 전까지의
|
||||
화면 검증용 더미. 실데이터 배선 시 이 파일을 api.ts 응답으로 교체.
|
||||
디자인 §6.6: 운영 KPI · 세션추이 · 음성/AI 사용+비용 · 감사 테이블 · audit log.
|
||||
===================================================================== */
|
||||
|
||||
import type { BadgeTone, DotTone } from "../../components/ui";
|
||||
|
||||
/* ── 시스템 헬스 상태 ── */
|
||||
export type HealthStatus = "ok" | "warn" | "crit";
|
||||
|
||||
export interface ServiceHealth {
|
||||
/** 서비스 명 */
|
||||
name: string;
|
||||
/** 보조 설명(추론 대상 등) */
|
||||
sub: string;
|
||||
status: HealthStatus;
|
||||
/** 부하/사용 게이지 0~1 */
|
||||
load: number;
|
||||
/** 우측 수치 라벨 (mono) */
|
||||
metric: string;
|
||||
/** 상태 배지 라벨 */
|
||||
statusLabel: string;
|
||||
}
|
||||
|
||||
/* ── 운영 KPI ── */
|
||||
export interface Kpi {
|
||||
label: string;
|
||||
/** 본문 숫자 (이미 포맷된 문자열, tabular) */
|
||||
value: string;
|
||||
/** 숫자 뒤 단위 */
|
||||
unit?: string;
|
||||
/** 보조 설명(변화량 등) */
|
||||
note?: string;
|
||||
/** 보조 설명 톤 — 정상은 무채(undefined) */
|
||||
noteTone?: "pos" | "info" | "warn" | "crit";
|
||||
/** KPI 자체 강조색 — 정상은 무채(undefined). "조용한 게 정상" */
|
||||
tone?: "warn" | "crit";
|
||||
}
|
||||
|
||||
/* ── 사용량·비용 ── */
|
||||
export interface UsageMeter {
|
||||
label: string;
|
||||
sub: string;
|
||||
/** 표시 수치 (tabular) */
|
||||
value: string;
|
||||
/** 예산 대비 비율 0~1 (게이지) */
|
||||
ratio: number;
|
||||
/** 예산 라벨 (게이지 우측) */
|
||||
budget: string;
|
||||
}
|
||||
|
||||
/* ── 시나리오 분포 ── */
|
||||
export interface DistRow {
|
||||
label: string;
|
||||
count: number;
|
||||
/** 게이지 비율 0~1 */
|
||||
ratio: number;
|
||||
/** 게이지 톤 */
|
||||
tone?: "accent" | "clay" | "warn";
|
||||
}
|
||||
|
||||
/* ── 역할별 사용 ── */
|
||||
export interface RoleRow {
|
||||
label: string;
|
||||
sub: string;
|
||||
/** 색 점(role accent hex — 토큰 외 브랜드성 식별색) */
|
||||
color: string;
|
||||
active: number;
|
||||
delta?: number;
|
||||
}
|
||||
|
||||
/* ── 교수 활동 감사(요약 테이블) ── */
|
||||
export interface ProfessorAudit {
|
||||
name: string;
|
||||
initial: string;
|
||||
/** 담당 코호트 */
|
||||
cohort: string;
|
||||
/** 피드백 열람률 0~1 */
|
||||
reviewRate: number;
|
||||
/** 발행 시나리오 수 */
|
||||
published: number;
|
||||
/** 마지막 활동(상대 시각) */
|
||||
lastActive: string;
|
||||
/** 점검 필요 여부 — true 면 "점검" 신호 */
|
||||
needsAttention?: boolean;
|
||||
}
|
||||
|
||||
/* ── audit log(행위 로그) ── */
|
||||
export type AuditAction = "view" | "audio" | "export" | "grade" | "edit";
|
||||
|
||||
export interface AuditEntry {
|
||||
id: string;
|
||||
time: string; // HH:MM:SS
|
||||
actorName: string;
|
||||
actorInitial: string;
|
||||
actorRole: string;
|
||||
action: AuditAction;
|
||||
actionLabel: string;
|
||||
target: string;
|
||||
targetMeta: string;
|
||||
/** 데이터 주권 — 추론이 일어난 지역(kr/us) */
|
||||
geo: "kr" | "us";
|
||||
/** 접근 범위 라벨 */
|
||||
scope: string;
|
||||
/** 민감 접근(학습자 식별/원본 등) */
|
||||
sensitive?: boolean;
|
||||
}
|
||||
|
||||
/* === action 메타: dot 톤 + 한국어 라벨 매핑 === */
|
||||
export const ACTION_META: Record<AuditAction, { tone: DotTone; label: string }> = {
|
||||
view: { tone: "info", label: "열람" },
|
||||
audio: { tone: "accent", label: "오디오" },
|
||||
export: { tone: "warn", label: "내보내기" },
|
||||
grade: { tone: "pos", label: "평가 작성" },
|
||||
edit: { tone: "warn", label: "평가 수정" },
|
||||
};
|
||||
|
||||
/* === scope 배지 톤 === */
|
||||
export function scopeTone(sensitive?: boolean): BadgeTone {
|
||||
return sensitive ? "warn" : "neutral";
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
↓↓↓ mock 값 ↓↓↓ (실데이터 배선 시 제거/교체)
|
||||
===================================================================== */
|
||||
|
||||
export const MOCK = {
|
||||
syncedAt: "2026-06-25 21:14 KST",
|
||||
syncAgo: "22초 전",
|
||||
uptime: "47d 12h",
|
||||
statusMessage: "모든 핵심 서비스가 정상 가동 중입니다.",
|
||||
statusDetail: "음성 합성 지연만 평소보다 약간 높습니다.",
|
||||
/** 전체 시스템 상태 — ok 면 녹색 펄스, warn/crit 이면 색 변경 */
|
||||
statusLevel: "ok" as HealthStatus,
|
||||
};
|
||||
|
||||
/** 운영 KPI 5 — 정상은 전부 무채(§6.6 "조용한 게 정상"). API오류만 warn. */
|
||||
export const KPIS: Kpi[] = [
|
||||
{ label: "진행 중 세션", value: "14", unit: "건", note: "+5 지난 1시간", noteTone: "pos" },
|
||||
{ label: "오늘 완료 세션", value: "186", unit: "건", note: "+12% 전일 동시간", noteTone: "pos" },
|
||||
{ label: "활성 사용자 (24h)", value: "312", unit: "명", note: "학습 287 · 교수 21 · 관리 4" },
|
||||
{ label: "가동률 (30일)", value: "99.94", unit: "%", note: "SLA 99.9% 충족", noteTone: "pos" },
|
||||
// 오류가 0 초과 → 이 KPI만 warn 강조 (다른 건 전부 무채)
|
||||
{ label: "API 오류 (1h)", value: "3", unit: "건", note: "임계 20건 미만", noteTone: "warn", tone: "warn" },
|
||||
];
|
||||
|
||||
export const SERVICES: ServiceHealth[] = [
|
||||
{
|
||||
name: "내담자 대화 엔진",
|
||||
sub: "claude -p · 가드레일",
|
||||
status: "ok",
|
||||
load: 0.42,
|
||||
metric: "42% 부하",
|
||||
statusLabel: "정상",
|
||||
},
|
||||
{
|
||||
name: "음성 합성 (TTS)",
|
||||
sub: "스트리밍 · 8개 보이스",
|
||||
status: "warn",
|
||||
load: 0.78,
|
||||
metric: "P95 1.4s",
|
||||
statusLabel: "지연 주의",
|
||||
},
|
||||
{
|
||||
name: "음성 인식 (STT)",
|
||||
sub: "실시간 스트림",
|
||||
status: "ok",
|
||||
load: 0.36,
|
||||
metric: "P95 0.6s",
|
||||
statusLabel: "정상",
|
||||
},
|
||||
{
|
||||
name: "세션·평가 DB",
|
||||
sub: "PostgreSQL 기본",
|
||||
status: "ok",
|
||||
load: 0.51,
|
||||
metric: "conn 51/200",
|
||||
statusLabel: "정상",
|
||||
},
|
||||
{
|
||||
name: "평가 작업 큐",
|
||||
sub: "회기말 리뷰 생성",
|
||||
status: "ok",
|
||||
load: 0.18,
|
||||
metric: "대기 3건",
|
||||
statusLabel: "정상",
|
||||
},
|
||||
];
|
||||
|
||||
/** 음성·AI 사용 + 비용 (claude -p / API 텔레메트리). 80% 넘으면 warn. */
|
||||
export const USAGE: UsageMeter[] = [
|
||||
{ label: "내담자 추론 토큰", sub: "claude -p · 7일 누적", value: "48.2M", ratio: 0.61, budget: "예산 80M" },
|
||||
{ label: "평가 토큰", sub: "회기말 리뷰 생성", value: "12.6M", ratio: 0.42, budget: "예산 30M" },
|
||||
{ label: "음성 합성 분량", sub: "TTS 스트리밍", value: "5,140분", ratio: 0.83, budget: "예산 6,000분" },
|
||||
{ label: "추정 운영비", sub: "이번 달 누적", value: "$1,284", ratio: 0.71, budget: "예산 $1,800" },
|
||||
];
|
||||
|
||||
export const DISTRIB: DistRow[] = [
|
||||
{ label: "우울 호소 청소년", count: 412, ratio: 0.88, tone: "clay" },
|
||||
{ label: "불안·공황 20대", count: 298, ratio: 0.64 },
|
||||
{ label: "관계 갈등 성인", count: 221, ratio: 0.48 },
|
||||
{ label: "학업 스트레스 고3", count: 174, ratio: 0.37 },
|
||||
{ label: "위기 개입 시뮬", count: 112, ratio: 0.25, tone: "warn" },
|
||||
{ label: "기타", count: 67, ratio: 0.14 },
|
||||
];
|
||||
|
||||
export const ROLES: RoleRow[] = [
|
||||
{ label: "학습자", sub: "상담 수련생 · 등록 248", color: "#3E7A6E", active: 231, delta: 18 },
|
||||
{ label: "교수자", sub: "슈퍼바이저 · 등록 34", color: "#3A5BA0", active: 29, delta: 2 },
|
||||
{ label: "관리자", sub: "운영 · 등록 6", color: "#5B5F6B", active: 4 },
|
||||
{ label: "페르소나", sub: "가상 내담자 풀", color: "#B0735C", active: 23, delta: 3 },
|
||||
];
|
||||
|
||||
export const PROF_AUDIT: ProfessorAudit[] = [
|
||||
{ name: "김상담", initial: "김", cohort: "상담실습 A반 · 12명", reviewRate: 0.94, published: 6, lastActive: "8분 전" },
|
||||
{ name: "정교수", initial: "정", cohort: "상담실습 B반 · 14명", reviewRate: 0.81, published: 4, lastActive: "23분 전" },
|
||||
{ name: "한지도", initial: "한", cohort: "위기개입 세미나 · 9명", reviewRate: 0.42, published: 1, lastActive: "3일 전", needsAttention: true },
|
||||
{ name: "오슈퍼", initial: "오", cohort: "집단상담 · 11명", reviewRate: 0.67, published: 3, lastActive: "1시간 전" },
|
||||
];
|
||||
|
||||
/** 7일 세션 추이(단일 라인 차트용 정규화 전 값). 피크는 컴포넌트가 산출. */
|
||||
export const TREND_7D: { day: string; value: number }[] = [
|
||||
{ day: "월", value: 142 },
|
||||
{ day: "화", value: 168 },
|
||||
{ day: "수", value: 151 },
|
||||
{ day: "목", value: 203 },
|
||||
{ day: "금", value: 247 },
|
||||
{ day: "토", value: 118 },
|
||||
{ day: "일", value: 186 },
|
||||
];
|
||||
|
||||
export const AUDIT_LOG: AuditEntry[] = [
|
||||
{
|
||||
id: "S-20448-1",
|
||||
time: "21:08:42",
|
||||
actorName: "김상담",
|
||||
actorInitial: "김",
|
||||
actorRole: "슈퍼바이저",
|
||||
action: "view",
|
||||
actionLabel: "회기 리뷰 열람",
|
||||
target: "서연 사례 · 회기 1 리뷰",
|
||||
targetMeta: "학습자 이수민 · 세션 #S-20448",
|
||||
geo: "kr",
|
||||
scope: "학습자 식별",
|
||||
sensitive: true,
|
||||
},
|
||||
{
|
||||
id: "S-20431-1",
|
||||
time: "20:51:17",
|
||||
actorName: "정교수",
|
||||
actorInitial: "정",
|
||||
actorRole: "슈퍼바이저",
|
||||
action: "export",
|
||||
actionLabel: "리뷰 PDF 내보내기",
|
||||
target: "불안·공황 20대 · 회기 3",
|
||||
targetMeta: "학습자 박지훈 · 세션 #S-20431",
|
||||
geo: "kr",
|
||||
scope: "원본 포함",
|
||||
sensitive: true,
|
||||
},
|
||||
{
|
||||
id: "S-20448-2",
|
||||
time: "20:39:05",
|
||||
actorName: "김상담",
|
||||
actorInitial: "김",
|
||||
actorRole: "슈퍼바이저",
|
||||
action: "grade",
|
||||
actionLabel: "슈퍼바이저 코멘트 작성",
|
||||
target: "감정 반영 구간 코멘트 3건",
|
||||
targetMeta: "학습자 이수민 · 세션 #S-20448",
|
||||
geo: "kr",
|
||||
scope: "평가 작성",
|
||||
},
|
||||
{
|
||||
id: "S-20419-1",
|
||||
time: "20:22:48",
|
||||
actorName: "한지도",
|
||||
actorInitial: "한",
|
||||
actorRole: "슈퍼바이저",
|
||||
action: "audio",
|
||||
actionLabel: "오디오 다시듣기",
|
||||
target: "관계 갈등 성인 · 회기 2",
|
||||
targetMeta: "학습자 최영아 · 세션 #S-20419",
|
||||
geo: "us",
|
||||
scope: "음성 원본",
|
||||
sensitive: true,
|
||||
},
|
||||
{
|
||||
id: "S-20431-2",
|
||||
time: "19:57:31",
|
||||
actorName: "정교수",
|
||||
actorInitial: "정",
|
||||
actorRole: "슈퍼바이저",
|
||||
action: "edit",
|
||||
actionLabel: "평가 항목 수정",
|
||||
target: "'닫힌 질문' 라벨 1건 해제",
|
||||
targetMeta: "학습자 박지훈 · 세션 #S-20431",
|
||||
geo: "kr",
|
||||
scope: "평가 수정",
|
||||
},
|
||||
{
|
||||
id: "C-A-1",
|
||||
time: "19:40:12",
|
||||
actorName: "한지도",
|
||||
actorInitial: "한",
|
||||
actorRole: "슈퍼바이저",
|
||||
action: "view",
|
||||
actionLabel: "학습자 성장 곡선 열람",
|
||||
target: "담당 그룹 12명 추세",
|
||||
targetMeta: "2026 봄학기 상담실습 A반",
|
||||
geo: "kr",
|
||||
scope: "집계 조회",
|
||||
},
|
||||
{
|
||||
id: "S-20448-3",
|
||||
time: "19:12:55",
|
||||
actorName: "김상담",
|
||||
actorInitial: "김",
|
||||
actorRole: "슈퍼바이저",
|
||||
action: "view",
|
||||
actionLabel: "실시간 세션 관찰",
|
||||
target: "우울 호소 청소년 · 진행 중",
|
||||
targetMeta: "학습자 이수민 · 세션 #S-20448",
|
||||
geo: "kr",
|
||||
scope: "실시간 열람",
|
||||
sensitive: true,
|
||||
},
|
||||
{
|
||||
id: "S-20402-1",
|
||||
time: "18:44:09",
|
||||
actorName: "오슈퍼",
|
||||
actorInitial: "오",
|
||||
actorRole: "슈퍼바이저",
|
||||
action: "export",
|
||||
actionLabel: "집계 리포트 내보내기",
|
||||
target: "집단상담 주간 요약",
|
||||
targetMeta: "익명 집계 · 11명",
|
||||
geo: "kr",
|
||||
scope: "집계 조회",
|
||||
},
|
||||
{
|
||||
id: "S-20415-1",
|
||||
time: "18:20:31",
|
||||
actorName: "정교수",
|
||||
actorInitial: "정",
|
||||
actorRole: "슈퍼바이저",
|
||||
action: "audio",
|
||||
actionLabel: "오디오 다시듣기",
|
||||
target: "학업 스트레스 고3 · 회기 2",
|
||||
targetMeta: "학습자 윤하늘 · 세션 #S-20415",
|
||||
geo: "us",
|
||||
scope: "음성 원본",
|
||||
sensitive: true,
|
||||
},
|
||||
];
|
||||
|
|
@ -1,167 +0,0 @@
|
|||
/* =====================================================================
|
||||
Professor 페이지 전용 차트 프리미티브 (로컬).
|
||||
- SparkLine: 테이블 셀 안 추세 압축(최근 N회 종합점수). §6.7 "테이블 안 추세 압축"
|
||||
- MiniMultiple: small-multiples 라인 1칸(공통 y축 0-100, 마지막 점 강조). §6.5/§6.7
|
||||
철칙: 레이더 금지. 라인은 stroke 기반. 마지막 점만 강조. 색은 추세 시맨틱만.
|
||||
이 파일은 Professor 전용 — 공통 ui/ 가 아님(아직 차트 프리미티브 미배포 단계).
|
||||
===================================================================== */
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { trendOf, type Trend } from "../../lib/format";
|
||||
|
||||
/** 추세별 라인 stroke 색 토큰. up=차분 무채(기본), down=warn, flat=무채. */
|
||||
function trackStrokeFor(trend: Trend): string {
|
||||
// 표 안에서는 라인 자체는 조용히(무채), 추세는 끝점 dot 색으로 신호.
|
||||
return trend === "down" ? "var(--warn-solid)" : "var(--ink-3)";
|
||||
}
|
||||
|
||||
/** 끝점(최신) dot 색: 하락=crit, 주의=warn, 상승/유지=accent. */
|
||||
function endDotFor(trend: Trend, lastDelta: number): string {
|
||||
if (trend === "down") return "var(--crit-solid)";
|
||||
if (lastDelta > 0) return "var(--accent)";
|
||||
return "var(--warn-solid)";
|
||||
}
|
||||
|
||||
export interface SparkLineProps {
|
||||
/** 최근 N회 종합점수(0~100). 마지막이 최신. */
|
||||
values: number[];
|
||||
/** 추세 판정용 델타(없으면 last-first). */
|
||||
delta?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/** SparkLine — 7px급 미니 추세선 + 끝점. 표 셀 전용. */
|
||||
export function SparkLine({ values, delta, width = 70, height = 22 }: SparkLineProps) {
|
||||
const { points, last, trend, d } = useMemo(() => {
|
||||
const n = values.length;
|
||||
if (n === 0) {
|
||||
return { points: "", last: { x: 0, y: height / 2 }, trend: "flat" as Trend, d: 0 };
|
||||
}
|
||||
const min = Math.min(...values);
|
||||
const max = Math.max(...values);
|
||||
const span = max - min || 1;
|
||||
const padX = 2;
|
||||
const padY = 3;
|
||||
const w = width - padX * 2;
|
||||
const h = height - padY * 2;
|
||||
const xs = values.map((_, i) => padX + (n === 1 ? w / 2 : (i / (n - 1)) * w));
|
||||
// y 반전(점수 높을수록 위). SVG는 위가 0.
|
||||
const ys = values.map((v) => padY + (1 - (v - min) / span) * h);
|
||||
const pts = xs.map((x, i) => `${x.toFixed(1)},${ys[i].toFixed(1)}`).join(" ");
|
||||
const dd = delta ?? values[n - 1] - values[0];
|
||||
return {
|
||||
points: pts,
|
||||
last: { x: xs[n - 1], y: ys[n - 1] },
|
||||
trend: trendOf(dd, 1.5),
|
||||
d: dd,
|
||||
};
|
||||
}, [values, delta, width, height]);
|
||||
|
||||
return (
|
||||
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} fill="none" aria-hidden="true">
|
||||
<polyline
|
||||
points={points}
|
||||
stroke={trackStrokeFor(trend)}
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<circle cx={last.x} cy={last.y} r={2.4} fill={endDotFor(trend, d)} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export interface MiniMultipleProps {
|
||||
/** 역량 라벨 */
|
||||
label: string;
|
||||
/** 시계열 값(0~100), 마지막이 최신 */
|
||||
values: number[];
|
||||
/** 최신값(표기). 없으면 values 마지막 */
|
||||
latest?: number;
|
||||
/** 추세 화살표용 델타 */
|
||||
delta: number;
|
||||
/** 첫 카드에만 y축 눈금(0/50/100) 옅게 표기 */
|
||||
showAxis?: boolean;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/** MiniMultiple — small-multiples 라인 1칸. 공통 y축 0-100 고정(카드 간 비교 정확). */
|
||||
export function MiniMultiple({
|
||||
label,
|
||||
values,
|
||||
latest,
|
||||
delta,
|
||||
showAxis = false,
|
||||
width = 168,
|
||||
height = 64,
|
||||
}: MiniMultipleProps) {
|
||||
const trend = trendOf(delta, 1.5);
|
||||
const { points, last } = useMemo(() => {
|
||||
const n = values.length;
|
||||
const padX = 2;
|
||||
const padY = 6;
|
||||
const w = width - padX * 2;
|
||||
const h = height - padY * 2;
|
||||
// 공통 y축 0-100 고정(min/max 자동 아님 — small-multiples 핵심).
|
||||
const xs = values.map((_, i) => padX + (n <= 1 ? w / 2 : (i / (n - 1)) * w));
|
||||
const ys = values.map((v) => padY + (1 - v / 100) * h);
|
||||
const pts = xs.map((x, i) => `${x.toFixed(1)},${ys[i].toFixed(1)}`).join(" ");
|
||||
return { points: pts, last: { x: xs[n - 1] ?? padX, y: ys[n - 1] ?? h } };
|
||||
}, [values, width, height]);
|
||||
|
||||
const lv = latest ?? values[values.length - 1] ?? 0;
|
||||
|
||||
return (
|
||||
<div className="pf-mm">
|
||||
<div className="pf-mm__top">
|
||||
<span className="pf-mm__label">{label}</span>
|
||||
</div>
|
||||
<div className="pf-mm__chart">
|
||||
<svg width="100%" height={height} viewBox={`0 0 ${width} ${height}`} fill="none" preserveAspectRatio="none" aria-hidden="true">
|
||||
{showAxis ? (
|
||||
<>
|
||||
{/* 공통 y축 0/50/100 옅은 가이드(첫 카드만) */}
|
||||
<line x1="2" y1="6" x2={width - 2} y2="6" stroke="var(--hair)" strokeWidth="1" />
|
||||
<line x1="2" y1={height / 2} x2={width - 2} y2={height / 2} stroke="var(--hair)" strokeWidth="1" strokeDasharray="2 3" />
|
||||
<line x1="2" y1={height - 6} x2={width - 2} y2={height - 6} stroke="var(--hair)" strokeWidth="1" />
|
||||
</>
|
||||
) : null}
|
||||
<polyline
|
||||
points={points}
|
||||
stroke="var(--ink-2)"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<circle cx={last.x} cy={last.y} r="3" fill="var(--accent)" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="pf-mm__foot">
|
||||
<span className="pf-mm__val tabular">{Math.round(lv)}</span>
|
||||
<TrendArrow trend={trend} delta={delta} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 추세 화살표 — 이모지 아님. inline SVG, 색만 시맨틱. */
|
||||
export function TrendArrow({ trend, delta }: { trend: Trend; delta: number }) {
|
||||
const cls =
|
||||
trend === "up" ? "pf-ar pf-ar--up" : trend === "down" ? "pf-ar pf-ar--down" : "pf-ar pf-ar--flat";
|
||||
const sign = delta > 0 ? `+${delta}` : String(delta);
|
||||
return (
|
||||
<span className={cls} aria-label={`변화량 ${sign}`}>
|
||||
<svg viewBox="0 0 24 24" width={13} height={13} fill="none" stroke="currentColor" strokeWidth={2.25} aria-hidden="true">
|
||||
{trend === "up" ? (
|
||||
<polyline points="6 14 12 8 18 14" strokeLinecap="round" strokeLinejoin="round" />
|
||||
) : trend === "down" ? (
|
||||
<polyline points="6 10 12 16 18 10" strokeLinecap="round" strokeLinejoin="round" />
|
||||
) : (
|
||||
<line x1="5" y1="12" x2="19" y2="12" strokeLinecap="round" />
|
||||
)}
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,263 +0,0 @@
|
|||
/* =====================================================================
|
||||
Professor 페이지 더미(mock) 데이터.
|
||||
⚠️ 전부 mock — 백엔드 교수자 집계 API(미구현) 연동 전 화면을 살리기 위한 값.
|
||||
실제 연동 시 이 파일을 lib/api.ts 의 교수자 엔드포인트 호출로 교체한다.
|
||||
수치/추세는 DESIGN_CONCEPT §6.5 와이어의 시나리오와 일치하도록 구성.
|
||||
===================================================================== */
|
||||
|
||||
export interface TriageItem {
|
||||
id: string;
|
||||
name: string;
|
||||
meta: string; // 학번/학년 등 보조
|
||||
/** triage 사유 — 자연어("왜 올라왔나"). 강조 토큰은 렌더에서 처리. */
|
||||
reasonLead: string;
|
||||
/** 수치 변화 시퀀스 표기(있으면 mono 강조) */
|
||||
trail?: string;
|
||||
/** 행동 신호(미연습/미열람/이탈) — 우측 메타 */
|
||||
signal: string;
|
||||
/** 마지막 연습일 */
|
||||
lastPracticed: string;
|
||||
/** 슈퍼바이저가 이미 남긴 코멘트 수(0이면 신규) */
|
||||
existingComments: number;
|
||||
}
|
||||
|
||||
/** 개입 큐 — 알고리즘이 뽑되 교수가 최종 판단(왜를 노출). 위험도 순. */
|
||||
export const TRIAGE: TriageItem[] = [
|
||||
{
|
||||
id: "20231148",
|
||||
name: "박서연",
|
||||
meta: "상담심리 3학년",
|
||||
reasonLead: "개입정확도 3주 연속 하락",
|
||||
trail: "61 → 52 → 44",
|
||||
signal: "5일째 미연습",
|
||||
lastPracticed: "6/19",
|
||||
existingComments: 0,
|
||||
},
|
||||
{
|
||||
id: "20231152",
|
||||
name: "이도현",
|
||||
meta: "상담심리 3학년",
|
||||
reasonLead: "위기상담 시뮬 중도이탈 2회",
|
||||
trail: undefined,
|
||||
signal: "이탈 직후 미복귀",
|
||||
lastPracticed: "6/20",
|
||||
existingComments: 1,
|
||||
},
|
||||
{
|
||||
id: "20221073",
|
||||
name: "최민준",
|
||||
meta: "상담심리 4학년",
|
||||
reasonLead: "라포 점수 급락 · 슈퍼바이저 피드백 7건 미열람",
|
||||
trail: "79 → 58",
|
||||
signal: "피드백 7건 대기",
|
||||
lastPracticed: "6/22",
|
||||
existingComments: 0,
|
||||
},
|
||||
];
|
||||
|
||||
export type LearnerState = "crit" | "warn" | "ok";
|
||||
|
||||
export interface CompetencyCell {
|
||||
value: number;
|
||||
/** 직전 대비 변화량(부호) */
|
||||
delta: number;
|
||||
}
|
||||
|
||||
export interface LearnerRow {
|
||||
id: string;
|
||||
name: string;
|
||||
sessions: number;
|
||||
rapport: CompetencyCell;
|
||||
technique: CompetencyCell;
|
||||
intervention: CompetencyCell;
|
||||
empathy: CompetencyCell;
|
||||
/** 최근 8회 종합점수(sparkline) */
|
||||
trend: number[];
|
||||
lastPracticed: string;
|
||||
state: LearnerState;
|
||||
/** 위험도 정렬용 스코어(높을수록 위험) */
|
||||
risk: number;
|
||||
}
|
||||
|
||||
const cell = (value: number, delta: number): CompetencyCell => ({ value, delta });
|
||||
|
||||
/** 담당 학습자 현황 — 위험도 순 기본 정렬. (mock, 28명 중 7명 표본) */
|
||||
export const LEARNERS: LearnerRow[] = [
|
||||
{
|
||||
id: "20231148",
|
||||
name: "박서연",
|
||||
sessions: 8,
|
||||
rapport: cell(55, -7),
|
||||
technique: cell(49, -6),
|
||||
intervention: cell(44, -8),
|
||||
empathy: cell(58, -4),
|
||||
trend: [72, 68, 63, 59, 55, 51, 47, 44],
|
||||
lastPracticed: "6/19",
|
||||
state: "crit",
|
||||
risk: 94,
|
||||
},
|
||||
{
|
||||
id: "20231152",
|
||||
name: "이도현",
|
||||
sessions: 14,
|
||||
rapport: cell(71, 0),
|
||||
technique: cell(66, -5),
|
||||
intervention: cell(52, -9),
|
||||
empathy: cell(70, 1),
|
||||
trend: [64, 67, 63, 66, 60, 63, 56, 52],
|
||||
lastPracticed: "6/20",
|
||||
state: "crit",
|
||||
risk: 88,
|
||||
},
|
||||
{
|
||||
id: "20221073",
|
||||
name: "최민준",
|
||||
sessions: 11,
|
||||
rapport: cell(58, -21),
|
||||
technique: cell(70, 1),
|
||||
intervention: cell(64, 0),
|
||||
empathy: cell(67, -4),
|
||||
trend: [79, 77, 74, 72, 68, 64, 61, 58],
|
||||
lastPracticed: "6/22",
|
||||
state: "crit",
|
||||
risk: 81,
|
||||
},
|
||||
{
|
||||
id: "20231130",
|
||||
name: "정유나",
|
||||
sessions: 9,
|
||||
rapport: cell(74, 4),
|
||||
technique: cell(62, 0),
|
||||
intervention: cell(59, -6),
|
||||
empathy: cell(71, 5),
|
||||
trend: [60, 62, 61, 66, 64, 69, 72, 71],
|
||||
lastPracticed: "6/23",
|
||||
state: "warn",
|
||||
risk: 48,
|
||||
},
|
||||
{
|
||||
id: "20221089",
|
||||
name: "김수련",
|
||||
sessions: 12,
|
||||
rapport: cell(82, 6),
|
||||
technique: cell(68, 0),
|
||||
intervention: cell(61, -3),
|
||||
empathy: cell(75, 5),
|
||||
trend: [62, 65, 68, 70, 74, 77, 80, 82],
|
||||
lastPracticed: "6/24",
|
||||
state: "ok",
|
||||
risk: 18,
|
||||
},
|
||||
{
|
||||
id: "20231115",
|
||||
name: "한가람",
|
||||
sessions: 15,
|
||||
rapport: cell(88, 4),
|
||||
technique: cell(79, 7),
|
||||
intervention: cell(71, 0),
|
||||
empathy: cell(84, 6),
|
||||
trend: [74, 76, 79, 80, 83, 84, 86, 88],
|
||||
lastPracticed: "6/24",
|
||||
state: "ok",
|
||||
risk: 9,
|
||||
},
|
||||
{
|
||||
id: "20221061",
|
||||
name: "오세진",
|
||||
sessions: 10,
|
||||
rapport: cell(79, 0),
|
||||
technique: cell(73, 5),
|
||||
intervention: cell(68, 4),
|
||||
empathy: cell(77, 0),
|
||||
trend: [70, 72, 71, 74, 73, 77, 78, 79],
|
||||
lastPracticed: "6/23",
|
||||
state: "ok",
|
||||
risk: 22,
|
||||
},
|
||||
];
|
||||
|
||||
export interface CohortCompetency {
|
||||
label: string;
|
||||
/** 반 평균(0~100) */
|
||||
avg: number;
|
||||
/** 가장 취약 1개만 강조 */
|
||||
weakest?: boolean;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
/** 반 전체 역량 분포 = 가로 막대(레이더 아님). 최저만 강조. */
|
||||
export const COHORT_BARS: CohortCompetency[] = [
|
||||
{ label: "반영적 경청", avg: 78 },
|
||||
{ label: "라포 형성", avg: 74 },
|
||||
{ label: "공감 반영", avg: 71 },
|
||||
{ label: "개방형 질문", avg: 63 },
|
||||
{
|
||||
label: "위기 개입 정확도",
|
||||
avg: 47,
|
||||
weakest: true,
|
||||
note: "28명 중 19명이 자해·위기 신호 장면에서 화제 전환 또는 성급한 안심시키기로 대응. 반 평균 47점.",
|
||||
},
|
||||
];
|
||||
|
||||
/** 반 평균 역량 추이 = small-multiples(공통 y축 0-100). 최근 6주. */
|
||||
export interface CohortTrend {
|
||||
label: string;
|
||||
values: number[];
|
||||
delta: number;
|
||||
}
|
||||
export const COHORT_TRENDS: CohortTrend[] = [
|
||||
{ label: "반영적 경청", values: [71, 73, 74, 76, 77, 78], delta: 1 },
|
||||
{ label: "라포 형성", values: [70, 71, 73, 74, 73, 74], delta: 1 },
|
||||
{ label: "공감 반영", values: [66, 68, 69, 70, 71, 71], delta: 0 },
|
||||
{ label: "위기 개입 정확도", values: [58, 55, 53, 51, 49, 47], delta: -2 },
|
||||
];
|
||||
|
||||
/** 검수 대기 회기(슈퍼바이저 코멘트 미작성). 시간순. */
|
||||
export interface PendingReview {
|
||||
id: string;
|
||||
learner: string;
|
||||
learnerId: string;
|
||||
caseLabel: string; // 내담자 유형
|
||||
sessionNo: number;
|
||||
endedAt: string; // "6/24 14:20"
|
||||
/** AI가 표시한 살펴볼 순간 수 */
|
||||
flagged: number;
|
||||
}
|
||||
|
||||
export const PENDING_REVIEWS: PendingReview[] = [
|
||||
{
|
||||
id: "s-9921",
|
||||
learner: "박서연",
|
||||
learnerId: "20231148",
|
||||
caseLabel: "위기 · 자해 사고 청소년",
|
||||
sessionNo: 8,
|
||||
endedAt: "6/19 16:42",
|
||||
flagged: 4,
|
||||
},
|
||||
{
|
||||
id: "s-9930",
|
||||
learner: "이도현",
|
||||
learnerId: "20231152",
|
||||
caseLabel: "위기 · 자해 사고 청소년",
|
||||
sessionNo: 14,
|
||||
endedAt: "6/20 11:08",
|
||||
flagged: 3,
|
||||
},
|
||||
{
|
||||
id: "s-9947",
|
||||
learner: "정유나",
|
||||
learnerId: "20231130",
|
||||
caseLabel: "불안 · 시험 공황 대학생",
|
||||
sessionNo: 9,
|
||||
endedAt: "6/23 10:15",
|
||||
flagged: 1,
|
||||
},
|
||||
];
|
||||
|
||||
export const COHORT_META = {
|
||||
course: "상담실습 II",
|
||||
total: 28,
|
||||
week: 4,
|
||||
needIntervention: 3,
|
||||
stable: 25,
|
||||
};
|
||||
|
|
@ -7,7 +7,7 @@
|
|||
순흑/순백 금지 — stroke 는 토큰 currentColor 계열만.
|
||||
===================================================================== */
|
||||
|
||||
import type { ValencePoint } from "./mock";
|
||||
import type { ReviewValencePoint as ValencePoint } from "../../lib/api";
|
||||
|
||||
export interface ValenceChartProps {
|
||||
client: ValencePoint[];
|
||||
|
|
|
|||
|
|
@ -1,305 +0,0 @@
|
|||
/* =====================================================================
|
||||
SessionReview 더미(mock) 데이터.
|
||||
⚠️ 전부 mock — 실제 백엔드 연동 전, 화면이 살아있게 채우는 용도.
|
||||
백엔드 계약(SessionEndResponse/digest)이 붙으면 이 파일을 교체한다.
|
||||
톤(§5.7): 점수/등급/합불 없음 → 성장 신호·한줄요약·기법분포·잘한순간·개선점.
|
||||
===================================================================== */
|
||||
|
||||
/** 상담 기법 군집 (taxonomy 군집 기반: 관계/탐색/개입/안정/구조화). */
|
||||
export type TechniqueKind =
|
||||
| "empathy" // 관계 — 공감/반영적 경청
|
||||
| "explore" // 탐색 — 개방형 질문
|
||||
| "reflect" // 탐색 — 반영
|
||||
| "confront" // 개입 — 직면
|
||||
| "closed"; // 살펴볼 점 — 닫힌 질문
|
||||
|
||||
export interface Technique {
|
||||
kind: TechniqueKind;
|
||||
/** 칩에 표시할 라벨 */
|
||||
label: string;
|
||||
}
|
||||
|
||||
export type Speaker = "learner" | "client";
|
||||
export type NoteAuthor = "ai" | "instructor";
|
||||
|
||||
export interface SupervisorNote {
|
||||
/** ai = 자동 코멘트, instructor = 교수자 직접 */
|
||||
author: NoteAuthor;
|
||||
/** good(잘한 점) → accent-tint, watch(살펴볼 점) → warn-tint */
|
||||
tone: "good" | "watch";
|
||||
title: string;
|
||||
/** 본문. quote 부분은 컴포넌트가 italic 강조 */
|
||||
body: string;
|
||||
/** 인용 대안 발화(있으면 italic 강조 블록) */
|
||||
quote?: string;
|
||||
}
|
||||
|
||||
export interface Turn {
|
||||
id: string;
|
||||
/** "MM:SS" 또는 "M:SS" */
|
||||
ts: string;
|
||||
speaker: Speaker;
|
||||
/** 화자 표기 (예: "나 (학습자)", "서연 (내담자)") */
|
||||
who: string;
|
||||
text: string;
|
||||
/** 학습자 발화에만: 감지된 기법 라벨(0~2개) */
|
||||
techniques?: Technique[];
|
||||
/** 인라인 슈퍼바이저 노트(있으면 발화 아래 들여쓴 콜아웃) */
|
||||
note?: SupervisorNote;
|
||||
}
|
||||
|
||||
export interface PhaseSegment {
|
||||
key: "rapport" | "explore" | "intervene" | "closing";
|
||||
label: string;
|
||||
/** 소요 비례 가중치(flex) */
|
||||
weight: number;
|
||||
}
|
||||
|
||||
export interface ValencePoint {
|
||||
/** 0~1 정규화 시간축 위치 */
|
||||
t: number;
|
||||
/** -1 ~ +1 정서가(valence) */
|
||||
v: number;
|
||||
}
|
||||
|
||||
export interface RubricRow {
|
||||
name: string;
|
||||
/** taxonomy 군집 라벨 (관계/탐색/개입/안정/구조화) */
|
||||
cluster: string;
|
||||
/** 0~1 — 빈도+적절성 종합(점수 아님, 막대 길이) */
|
||||
ratio: number;
|
||||
/** good = 충분/적절, watch = 과다·과소(살펴볼 점) */
|
||||
quality: "good" | "watch";
|
||||
/** 빈도 메타 (예: "8회 · 적절") */
|
||||
freq: string;
|
||||
}
|
||||
|
||||
export interface GrowthPoint {
|
||||
title: string;
|
||||
body: string;
|
||||
/** 연결된 타임라인 발화 id(클릭 점프) */
|
||||
jumpTo?: string;
|
||||
}
|
||||
|
||||
export interface SessionReviewData {
|
||||
client: {
|
||||
name: string;
|
||||
initial: string;
|
||||
persona: string;
|
||||
};
|
||||
date: string; // 표시용 한국어
|
||||
durationLabel: string;
|
||||
/** 진행 단계 신호 (성장 신호 톤 — 점수 아님) */
|
||||
reachedPhase: string;
|
||||
/** SESSION 칸 — 성장 신호 (점수 금지) */
|
||||
sessionSignal: string;
|
||||
/** SUPERVISOR 칸 — 검토 상태 */
|
||||
supervisorState: string;
|
||||
supervisorName: string;
|
||||
/** 한 줄 요약. hl 토큰(<hl>…</hl>)으로 accent 강조 구간 표시 */
|
||||
summary: string;
|
||||
phases: PhaseSegment[];
|
||||
/** 단계 막대 시간축 라벨 */
|
||||
phaseAxis: string[];
|
||||
valenceAxis: string[];
|
||||
clientValence: ValencePoint[];
|
||||
counselorBaseline: ValencePoint[];
|
||||
turns: Turn[];
|
||||
rubric: RubricRow[];
|
||||
goodMoments: GrowthPoint[];
|
||||
growthPoints: GrowthPoint[];
|
||||
nextLine: string;
|
||||
/** AI 내담자 피드백 (italic 1인칭) */
|
||||
clientFeedback: string;
|
||||
}
|
||||
|
||||
export const MOCK_REVIEW: SessionReviewData = {
|
||||
client: {
|
||||
name: "서연",
|
||||
initial: "서",
|
||||
persona: "17세 · 우울 호소 청소년",
|
||||
},
|
||||
date: "2026-06-25",
|
||||
durationLabel: "32분 14초",
|
||||
reachedPhase: "탐색 단계까지 진행",
|
||||
sessionSignal: "라포 형성 신호 뚜렷",
|
||||
supervisorState: "검토 대기",
|
||||
supervisorName: "김",
|
||||
summary:
|
||||
"라포는 <hl>안정적으로 형성</hl>됐어요. 다만 탐색 단계에서 닫힌 질문이 몇 차례 반복되면서, 서연이 막 열기 시작한 자기개방이 잠깐씩 멈췄습니다.",
|
||||
phases: [
|
||||
{ key: "rapport", label: "라포 형성", weight: 1.4 },
|
||||
{ key: "explore", label: "탐색", weight: 2.6 },
|
||||
{ key: "intervene", label: "개입", weight: 1.0 },
|
||||
{ key: "closing", label: "정리", weight: 0.8 },
|
||||
],
|
||||
phaseAxis: ["00:00", "09:12", "24:30", "32:14"],
|
||||
valenceAxis: ["0분", "8분", "16분", "24분", "32분"],
|
||||
// 내담자 valence: 위축(낮음) → 자기개방 들어가며 완만히 상승, 닫힌 질문 구간(16~17분) 잠깐 하강 → 회복
|
||||
clientValence: [
|
||||
{ t: 0.0, v: -0.55 },
|
||||
{ t: 0.12, v: -0.4 },
|
||||
{ t: 0.24, v: -0.18 },
|
||||
{ t: 0.36, v: 0.05 },
|
||||
{ t: 0.5, v: -0.32 }, // 닫힌 질문 구간 위축
|
||||
{ t: 0.62, v: -0.1 },
|
||||
{ t: 0.76, v: 0.18 },
|
||||
{ t: 0.9, v: 0.28 },
|
||||
{ t: 1.0, v: 0.22 },
|
||||
],
|
||||
// 상담자 baseline: 안정적 중립 톤 유지(점선)
|
||||
counselorBaseline: [
|
||||
{ t: 0.0, v: 0.1 },
|
||||
{ t: 0.18, v: 0.16 },
|
||||
{ t: 0.36, v: 0.12 },
|
||||
{ t: 0.5, v: 0.18 },
|
||||
{ t: 0.64, v: 0.14 },
|
||||
{ t: 0.8, v: 0.2 },
|
||||
{ t: 1.0, v: 0.16 },
|
||||
],
|
||||
turns: [
|
||||
{
|
||||
id: "t1",
|
||||
ts: "10:42",
|
||||
speaker: "learner",
|
||||
who: "나 (학습자)",
|
||||
text: "학교에 가는 게 많이 버겁게 느껴지는 것 같아요. 혹시 어떤 순간이 가장 힘든지 조금 더 들려줄 수 있을까요?",
|
||||
techniques: [{ kind: "explore", label: "탐색 · 개방형 질문" }],
|
||||
note: {
|
||||
author: "instructor",
|
||||
tone: "good",
|
||||
title: "잘한 점",
|
||||
body: '서연이 먼저 꺼내기 어려워하던 지점을 압박 없이 열어줬어요. 시간 여유를 둔 표현("조금 더")이 안전감을 만들었습니다.',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "t2",
|
||||
ts: "11:05",
|
||||
speaker: "client",
|
||||
who: "서연 (내담자)",
|
||||
text: "아침에… 눈을 뜨면요. 그냥 천장만 보고 있어요. 일어나야 하는 건 아는데, 몸이 안 움직여요. 그게 제일… 무서워요.",
|
||||
},
|
||||
{
|
||||
id: "t3",
|
||||
ts: "11:28",
|
||||
speaker: "learner",
|
||||
who: "나 (학습자)",
|
||||
text: "눈을 떠도 몸이 따라주지 않는 그 무력감이, 무섭게까지 느껴지는군요.",
|
||||
techniques: [{ kind: "empathy", label: "공감 · 반영적 경청" }],
|
||||
note: {
|
||||
author: "ai",
|
||||
tone: "good",
|
||||
title: "잘한 점",
|
||||
body: '내담자의 단어("무서워요")를 그대로 반영하며 감정의 강도까지 받아냈어요. 직후 서연의 자기개방이 한 단계 깊어졌습니다.',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "t4",
|
||||
ts: "16:54",
|
||||
speaker: "learner",
|
||||
who: "나 (학습자)",
|
||||
text: "그럼 학교는 그냥 가기 싫은 거예요?",
|
||||
techniques: [{ kind: "closed", label: "닫힌 질문" }],
|
||||
note: {
|
||||
author: "instructor",
|
||||
tone: "watch",
|
||||
title: "살펴볼 점",
|
||||
body: "'예/아니오'로 닫히는 질문이라 서연이 \"그런 건 아니고요…\" 하고 짧게 답했어요. 같은 의도를",
|
||||
quote:
|
||||
'"학교를 떠올리면 어떤 마음이 먼저 드는지 들려줄래요?" 처럼 열어두면 자기개방이 이어졌을 거예요.',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "t5",
|
||||
ts: "17:10",
|
||||
speaker: "client",
|
||||
who: "서연 (내담자)",
|
||||
text: "…그런 건 아니고요. 그냥… 모르겠어요.",
|
||||
},
|
||||
{
|
||||
id: "t6",
|
||||
ts: "21:36",
|
||||
speaker: "learner",
|
||||
who: "나 (학습자)",
|
||||
text: "방금 잠깐 말문이 막혔던 것 같아요. 괜찮아요, 천천히 해도 돼요. 요즘 친구들과 함께 있을 때는 어떤 느낌이 드는지 궁금해요.",
|
||||
techniques: [
|
||||
{ kind: "reflect", label: "반영" },
|
||||
{ kind: "explore", label: "탐색" },
|
||||
],
|
||||
note: {
|
||||
author: "ai",
|
||||
tone: "good",
|
||||
title: "잘한 점",
|
||||
body: "닫힌 질문 뒤 위축을 알아채고 페이스를 늦춘 회복이 좋았어요. 곧바로 열린 질문으로 전환해 대화가 다시 흐르기 시작했습니다.",
|
||||
},
|
||||
},
|
||||
],
|
||||
rubric: [
|
||||
{
|
||||
name: "반영적 경청",
|
||||
cluster: "관계",
|
||||
ratio: 0.82,
|
||||
quality: "good",
|
||||
freq: "6회 · 적절",
|
||||
},
|
||||
{
|
||||
name: "공감 · 타당화",
|
||||
cluster: "관계",
|
||||
ratio: 0.74,
|
||||
quality: "good",
|
||||
freq: "5회 · 적절",
|
||||
},
|
||||
{
|
||||
name: "개방형 질문",
|
||||
cluster: "탐색",
|
||||
ratio: 0.48,
|
||||
quality: "watch",
|
||||
freq: "3회 · 더 늘려보기",
|
||||
},
|
||||
{
|
||||
name: "닫힌 질문",
|
||||
cluster: "탐색",
|
||||
ratio: 0.58,
|
||||
quality: "watch",
|
||||
freq: "5회 · 다소 잦음",
|
||||
},
|
||||
{
|
||||
name: "침묵 견디기",
|
||||
cluster: "안정",
|
||||
ratio: 0.66,
|
||||
quality: "good",
|
||||
freq: "여백 활용 양호",
|
||||
},
|
||||
],
|
||||
goodMoments: [
|
||||
{
|
||||
title: "감정을 그대로 받아낸 반영",
|
||||
body: "서연의 표현을 빌려 무력감을 비춰줬고, 직후 자기개방이 깊어졌어요.",
|
||||
jumpTo: "t3",
|
||||
},
|
||||
{
|
||||
title: "위축을 알아챈 회복",
|
||||
body: "닫힌 질문 뒤 멈춤을 감지하고 페이스를 늦춰 안전감을 되찾았어요.",
|
||||
jumpTo: "t6",
|
||||
},
|
||||
],
|
||||
growthPoints: [
|
||||
{
|
||||
title: "닫힌 질문을 열어두기",
|
||||
body: "탐색 단계에서 '예/아니오' 질문이 몇 차례 반복됐어요. 의도는 같아도 열린 형태로 바꿔보면 좋겠어요.",
|
||||
jumpTo: "t4",
|
||||
},
|
||||
{
|
||||
title: "침묵을 조금 더 견디기",
|
||||
body: "서연이 망설일 때 바로 다음 질문을 채우기보다, 잠깐의 여백을 두면 스스로 더 말할 공간이 생겨요.",
|
||||
},
|
||||
{
|
||||
title: "감정 단어를 함께 명명하기",
|
||||
body: '"무섭다"처럼 등장한 감정 단어를 다음 회기에서 조금 더 풀어 물으면 자기이해가 깊어집니다.',
|
||||
},
|
||||
],
|
||||
nextLine:
|
||||
'"그 마음을 떠올리면, 가장 먼저 어떤 장면이 생각나는지 들려줄래요?"',
|
||||
clientFeedback:
|
||||
"선생님이 제 말을 진짜로 들어준다는 느낌이 들 때가 있었어요. 근데 중간에 '학교 가기 싫은 거냐'고 물었을 땐… 제 마음이 그렇게 단순하진 않은데, 하고 살짝 멈칫했어요. 그냥 그때 얼마나 막막했는지를 먼저 알아줬으면 했어요.",
|
||||
};
|
||||
|
|
@ -12,6 +12,9 @@
|
|||
max-width: var(--maxw);
|
||||
margin: 0 auto;
|
||||
}
|
||||
.sr-root--empty {
|
||||
--sr-empty-tone: var(--neutral-sig);
|
||||
}
|
||||
|
||||
/* ── (1) 세션 헤더: 아바타 이니셜 + 메타 + 우측 스탯(성장 신호) ── */
|
||||
.sr-head {
|
||||
|
|
@ -40,14 +43,14 @@
|
|||
font-family: var(--font-num);
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
letter-spacing: 0;
|
||||
color: var(--clay-deep);
|
||||
background: var(--clay-tint);
|
||||
}
|
||||
.sr-head__name {
|
||||
font-size: var(--fs-h3);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
letter-spacing: 0;
|
||||
color: var(--text-strong);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
|
@ -131,16 +134,70 @@
|
|||
|
||||
/* ── 한 줄 요약 (24px / 600 — 에디토리얼 미니멀 핵심) ── */
|
||||
.sr-summary {
|
||||
margin-top: var(--sp-3);
|
||||
font-size: var(--fs-h2);
|
||||
line-height: 1.5;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
letter-spacing: 0;
|
||||
color: var(--text-strong);
|
||||
max-width: 56ch;
|
||||
}
|
||||
.sr-summary .sr-hl {
|
||||
color: var(--accent-deep);
|
||||
}
|
||||
.sr-actions {
|
||||
display: flex;
|
||||
gap: var(--sp-3);
|
||||
margin-top: var(--sp-5);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.sr-readiness {
|
||||
margin-top: var(--sp-5);
|
||||
padding: var(--sp-4) 0;
|
||||
border-top: 1px solid var(--hair);
|
||||
border-bottom: 1px solid var(--hair);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
.sr-readiness span {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
.sr-readiness b {
|
||||
color: var(--text-strong);
|
||||
font-size: var(--fs-sm);
|
||||
line-height: 1.35;
|
||||
}
|
||||
.sr-readiness small {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 600;
|
||||
}
|
||||
.sr-card {
|
||||
min-width: 0;
|
||||
}
|
||||
.sr-root--empty .sr-card--side {
|
||||
padding-top: var(--sp-5);
|
||||
padding-bottom: var(--sp-5);
|
||||
}
|
||||
.sr-empty {
|
||||
max-width: none;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-4);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-surface-2);
|
||||
}
|
||||
.sr-empty .vg-empty__title {
|
||||
font-size: var(--fs-h3);
|
||||
line-height: 1.35;
|
||||
}
|
||||
.sr-empty .vg-empty__desc {
|
||||
font-size: var(--fs-sm);
|
||||
line-height: 1.6;
|
||||
color: var(--text-body);
|
||||
}
|
||||
|
||||
/* ── 감정 밸런스 타임라인 (SVG 라인 차트) ── */
|
||||
.sr-chart {
|
||||
|
|
@ -266,20 +323,26 @@
|
|||
justify-content: space-between;
|
||||
gap: var(--sp-3);
|
||||
margin-bottom: var(--sp-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.sr-tx__filters {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
flex: none;
|
||||
}
|
||||
.sr-chip-toggle {
|
||||
flex: none;
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 100px;
|
||||
padding: 4px 12px;
|
||||
white-space: nowrap;
|
||||
word-break: keep-all;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background var(--dur-base) var(--ease-out),
|
||||
|
|
@ -649,6 +712,12 @@
|
|||
margin: 0 -8px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
.sr-session-id {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-num);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.sr-cols {
|
||||
|
|
@ -659,6 +728,54 @@
|
|||
}
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.sr-head {
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
.sr-head__stats {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.sr-stat {
|
||||
padding: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.sr-summary {
|
||||
font-size: 21px;
|
||||
line-height: 1.48;
|
||||
}
|
||||
.sr-readiness {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--sp-3);
|
||||
margin-top: var(--sp-4);
|
||||
padding: var(--sp-3) 0;
|
||||
}
|
||||
.sr-actions .vg-btn {
|
||||
flex: 1 1 140px;
|
||||
}
|
||||
.sr-empty {
|
||||
padding: var(--sp-3);
|
||||
}
|
||||
.sr-empty .vg-empty__title {
|
||||
font-size: var(--fs-h3);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.sr-head__id {
|
||||
align-items: flex-start;
|
||||
}
|
||||
.sr-head__meta {
|
||||
gap: 7px;
|
||||
}
|
||||
.sr-summary {
|
||||
font-size: 20px;
|
||||
}
|
||||
.sr-actions {
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sr-note,
|
||||
.sr-chip-toggle,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,6 @@
|
|||
/* =====================================================================
|
||||
Toggle — settings 전용 로컬 스위치 컴포넌트.
|
||||
목업 settings.html 의 .toggle 패턴을 토큰 기반으로 옮긴 것.
|
||||
설정 화면의 토큰 기반 스위치 패턴.
|
||||
원형 예외 허용 대상(노브). 강조선·이모지 없음. 200ms ease-out.
|
||||
공통 ui/ 에는 토글 프리미티브가 없어 이 페이지 안에서만 정의한다.
|
||||
===================================================================== */
|
||||
|
|
|
|||
|
|
@ -8,10 +8,10 @@
|
|||
/* ── 레이아웃: 좌측 섹션 내비(sticky) + 우측 폼 ── */
|
||||
.vg-set {
|
||||
display: grid;
|
||||
grid-template-columns: 220px 1fr;
|
||||
gap: var(--sp-7);
|
||||
grid-template-columns: 204px minmax(0, 780px);
|
||||
gap: var(--sp-6);
|
||||
align-items: start;
|
||||
max-width: 1000px;
|
||||
max-width: 1060px;
|
||||
}
|
||||
|
||||
/* 좌측 섹션 내비 */
|
||||
|
|
@ -21,6 +21,11 @@
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: var(--sp-3);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--bg-surface);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.vg-set__nav-kicker {
|
||||
font-family: var(--font-num);
|
||||
|
|
@ -28,7 +33,7 @@
|
|||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-muted);
|
||||
padding: 0 12px var(--sp-3);
|
||||
padding: var(--sp-1) 10px var(--sp-3);
|
||||
}
|
||||
.vg-set__nav-item {
|
||||
display: flex;
|
||||
|
|
@ -71,6 +76,8 @@
|
|||
/* ── 그룹(섹션 카드) — 헤어라인 + 톤차, 상단 강조선 없음 ── */
|
||||
.vg-set__group {
|
||||
scroll-margin-top: calc(var(--topbar-h) + var(--sp-4));
|
||||
box-shadow: var(--shadow-sm);
|
||||
min-width: 0;
|
||||
}
|
||||
.vg-set__group-head {
|
||||
margin-bottom: var(--sp-5);
|
||||
|
|
@ -78,7 +85,7 @@
|
|||
.vg-set__group-title {
|
||||
font-size: var(--fs-h3);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
letter-spacing: 0;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
.vg-set__group-desc {
|
||||
|
|
@ -96,6 +103,7 @@
|
|||
gap: var(--sp-5);
|
||||
padding: var(--sp-5) 0;
|
||||
border-top: 1px solid var(--bg-surface-2);
|
||||
min-width: 0;
|
||||
}
|
||||
.vg-set__row:first-of-type {
|
||||
border-top: none;
|
||||
|
|
@ -105,6 +113,7 @@
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
.vg-set__row-label .l {
|
||||
font-size: var(--fs-sm);
|
||||
|
|
@ -122,6 +131,10 @@
|
|||
gap: var(--sp-2);
|
||||
min-width: 0;
|
||||
}
|
||||
.vg-set__row-field .vg-input {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* ── select (입력과 동일 톤, radius 6px) ── */
|
||||
.vg-set__select {
|
||||
|
|
@ -227,9 +240,11 @@
|
|||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── 세그먼트 토글(엔진 모드): 2지선다, 둥근 알약 아님(8px) ── */
|
||||
/* ── 세그먼트 토글(엔진 모드): 백엔드 지원 모드, 둥근 알약 아님(8px) ── */
|
||||
.vg-set__seg {
|
||||
display: inline-flex;
|
||||
width: min(100%, 520px);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
background: var(--bg-surface-2);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius);
|
||||
|
|
@ -239,7 +254,9 @@
|
|||
.vg-set__seg-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 500;
|
||||
|
|
@ -263,6 +280,64 @@
|
|||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.vg-set__ops {
|
||||
display: grid;
|
||||
grid-template-columns: 10px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
margin-bottom: var(--sp-4);
|
||||
padding: var(--sp-4);
|
||||
border-radius: var(--radius);
|
||||
background: var(--info-tint);
|
||||
color: var(--info-text);
|
||||
}
|
||||
.vg-set__ops--ok {
|
||||
background: var(--pos-tint);
|
||||
color: var(--pos-text);
|
||||
}
|
||||
.vg-set__ops--down {
|
||||
background: var(--crit-tint);
|
||||
color: var(--crit-text);
|
||||
}
|
||||
.vg-set__ops-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
.vg-set__ops b {
|
||||
display: block;
|
||||
font-size: var(--fs-sm);
|
||||
line-height: 1.35;
|
||||
}
|
||||
.vg-set__ops span {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
font-size: var(--fs-xs);
|
||||
line-height: 1.45;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.vg-set__state {
|
||||
display: grid;
|
||||
grid-template-columns: 10px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-4);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-surface-2);
|
||||
color: var(--text-body);
|
||||
font-size: var(--fs-sm);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.vg-set__state-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--neutral-sig);
|
||||
}
|
||||
|
||||
/* ── 라디오형 음성 프리셋 행 ── */
|
||||
.vg-set__voicelist {
|
||||
display: flex;
|
||||
|
|
@ -289,6 +364,7 @@
|
|||
.vg-set__voice.is-on {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-tint);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 10%, transparent);
|
||||
}
|
||||
.vg-set__voice-rad {
|
||||
width: 18px;
|
||||
|
|
@ -399,6 +475,7 @@
|
|||
align-items: center;
|
||||
gap: var(--sp-5);
|
||||
margin-bottom: var(--sp-5);
|
||||
min-width: 0;
|
||||
}
|
||||
.vg-set__avatar {
|
||||
width: 56px;
|
||||
|
|
@ -417,11 +494,16 @@
|
|||
font-size: var(--fs-lead);
|
||||
font-weight: 600;
|
||||
color: var(--text-strong);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.vg-set__profile-meta .e {
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--text-body);
|
||||
margin-top: 2px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.vg-set__profile-meta {
|
||||
min-width: 0;
|
||||
}
|
||||
.vg-set__profile-meta .badges {
|
||||
display: flex;
|
||||
|
|
@ -437,6 +519,11 @@
|
|||
font-size: var(--fs-xs);
|
||||
color: var(--text-muted);
|
||||
margin-top: var(--sp-2);
|
||||
min-width: 0;
|
||||
}
|
||||
.vg-set__meta span {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.vg-set__meta code {
|
||||
font-family: var(--font-num);
|
||||
|
|
@ -457,6 +544,7 @@
|
|||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: var(--sp-2);
|
||||
}
|
||||
.vg-set__nav-kicker {
|
||||
display: none;
|
||||
|
|
@ -465,6 +553,21 @@
|
|||
grid-template-columns: 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
.vg-set__group,
|
||||
.vg-set__foot,
|
||||
.vg-set__foot .vg-btn {
|
||||
scroll-margin-top: calc(var(--topbar-h) + var(--sp-4));
|
||||
scroll-margin-bottom: 104px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.vg-set__group,
|
||||
.vg-set__foot,
|
||||
.vg-set__foot .vg-btn {
|
||||
scroll-margin-top: calc(var(--topbar-h) + 70px);
|
||||
scroll-margin-bottom: var(--sp-7);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ body {
|
|||
color: var(--text-strong);
|
||||
font-size: var(--fs-body);
|
||||
line-height: 1.6;
|
||||
letter-spacing: -0.01em;
|
||||
letter-spacing: 0;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
|
@ -90,7 +90,7 @@ svg {
|
|||
/* ── 공통 유틸 ── */
|
||||
.tabular {
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: -0.01em;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
|
|
|
|||
2
apps/web/src/vite-env.d.ts
vendored
2
apps/web/src/vite-env.d.ts
vendored
|
|
@ -3,6 +3,8 @@
|
|||
interface ImportMetaEnv {
|
||||
/** API 베이스 URL. 기본 "/api" (vite proxy / nginx 가 백엔드로 라우팅). */
|
||||
readonly VITE_API_BASE?: string;
|
||||
/** 선택 Cubism Core JS URL. 기본 /live2d/live2dcubismcore.min.js. */
|
||||
readonly VITE_LIVE2D_CUBISM_CORE?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue