현재 작업 전체 반영
This commit is contained in:
parent
5560638e54
commit
c0dddab594
85 changed files with 11322 additions and 539 deletions
|
|
@ -6,6 +6,7 @@ import {
|
|||
adminUsersApi,
|
||||
type AdminHealthResponse,
|
||||
type AdminHealthStatus,
|
||||
type AdminUsageResponse,
|
||||
type AdminManagedUser,
|
||||
type AdminUserCreateRequest,
|
||||
type AdminUsersResponse,
|
||||
|
|
@ -84,6 +85,38 @@ function dateTimeLabel(seconds: number): string {
|
|||
});
|
||||
}
|
||||
|
||||
function countLabel(value: number): string {
|
||||
if (!Number.isFinite(value)) return "0";
|
||||
return Math.round(value).toLocaleString("ko-KR");
|
||||
}
|
||||
|
||||
function costLabel(value: number): string {
|
||||
if (!Number.isFinite(value) || value <= 0) return "$0";
|
||||
return `$${value.toFixed(value < 0.01 ? 6 : 4)}`;
|
||||
}
|
||||
|
||||
function usageSourceLabel(data: AdminUsageResponse | null): string {
|
||||
if (!data) return "대기 중";
|
||||
return data.durable ? "DB 계량" : "비영구 런타임 계량";
|
||||
}
|
||||
|
||||
function usageBudgetLabel(data: AdminUsageResponse): string {
|
||||
const { budget } = data;
|
||||
if (budget.status === "disabled") return "예산 경고 비활성";
|
||||
if (budget.status === "exceeded") return "예산 초과";
|
||||
if (budget.status === "warn") return "예산 주의";
|
||||
return "예산 정상";
|
||||
}
|
||||
|
||||
function usageBudgetDetail(data: AdminUsageResponse): string {
|
||||
const { budget } = data;
|
||||
if (budget.status === "disabled") return "ADMIN_USAGE_BUDGET_USD가 설정되지 않았습니다.";
|
||||
const pct = Math.round(budget.used_ratio * 100);
|
||||
const remaining =
|
||||
budget.remaining_usd === null ? "" : ` · 잔여 ${costLabel(budget.remaining_usd)}`;
|
||||
return `${costLabel(data.cost_usd)} / ${costLabel(budget.limit_usd)} · ${pct}% 사용${remaining}`;
|
||||
}
|
||||
|
||||
function initialOf(user: AdminManagedUser): string {
|
||||
const label = user.display_name.trim() || user.email;
|
||||
return Array.from(label)[0]?.toUpperCase() ?? "?";
|
||||
|
|
@ -114,6 +147,9 @@ export default function Admin() {
|
|||
const [creatingUser, setCreatingUser] = useState(false);
|
||||
const [deactivatingUserId, setDeactivatingUserId] = useState<string | null>(null);
|
||||
const [userSearch, setUserSearch] = useState("");
|
||||
const [usage, setUsage] = useState<AdminUsageResponse | null>(null);
|
||||
const [usageLoading, setUsageLoading] = useState(true);
|
||||
const [usageError, setUsageError] = useState<string | null>(null);
|
||||
|
||||
const loadHealth = useCallback(async () => {
|
||||
setLoading(true);
|
||||
|
|
@ -155,14 +191,27 @@ export default function Admin() {
|
|||
}
|
||||
}, []);
|
||||
|
||||
const loadUsage = useCallback(async () => {
|
||||
setUsageLoading(true);
|
||||
setUsageError(null);
|
||||
try {
|
||||
setUsage(await adminApi.usage(7));
|
||||
} catch (err) {
|
||||
setUsageError(err instanceof Error ? err.message : "비용 사용량을 불러오지 못했습니다.");
|
||||
} finally {
|
||||
setUsageLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadHealth();
|
||||
void loadUsers();
|
||||
}, [loadHealth, loadUsers]);
|
||||
void loadUsage();
|
||||
}, [loadHealth, loadUsage, loadUsers]);
|
||||
|
||||
const refreshAll = useCallback(async () => {
|
||||
await Promise.all([loadHealth(), loadUsers()]);
|
||||
}, [loadHealth, loadUsers]);
|
||||
await Promise.all([loadHealth(), loadUsers(), loadUsage()]);
|
||||
}, [loadHealth, loadUsage, loadUsers]);
|
||||
|
||||
const updateDraft = (userId: string, patch: Partial<UserDraft>) => {
|
||||
setUserDrafts((current) => ({
|
||||
|
|
@ -308,9 +357,9 @@ export default function Admin() {
|
|||
variant="secondary"
|
||||
leading={<Icon name="settings" size={16} />}
|
||||
onClick={() => void refreshAll()}
|
||||
disabled={loading || usersLoading}
|
||||
disabled={loading || usersLoading || usageLoading}
|
||||
>
|
||||
{loading || usersLoading ? "확인 중" : "새로고침"}
|
||||
{loading || usersLoading || usageLoading ? "확인 중" : "새로고침"}
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
|
|
@ -355,6 +404,95 @@ export default function Admin() {
|
|||
</section>
|
||||
</section>
|
||||
|
||||
<section className="ad-section ad-usage-section" aria-label="AI 비용 관측">
|
||||
<div className="ad-section__head">
|
||||
<h2>AI 비용 관측</h2>
|
||||
<span>
|
||||
{usage
|
||||
? `최근 ${usage.window_days}일 · ${usageSourceLabel(usage)}`
|
||||
: usageSourceLabel(usage)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{usageError ? (
|
||||
<section className="ad-error" role="alert">
|
||||
<Icon name="alert" size={18} />
|
||||
<span>{usageError}</span>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<div className="ad-usage-grid">
|
||||
<div className="ad-usage-kpi">
|
||||
<span>누적 비용</span>
|
||||
<b>{usage ? costLabel(usage.cost_usd) : "-"}</b>
|
||||
<small>{usage ? `${countLabel(usage.metered_turns)}개 계량 턴` : "계산 중"}</small>
|
||||
</div>
|
||||
<div className="ad-usage-kpi">
|
||||
<span>입력 토큰</span>
|
||||
<b>{usage ? countLabel(usage.tokens_in) : "-"}</b>
|
||||
<small>프롬프트/컨텍스트</small>
|
||||
</div>
|
||||
<div className="ad-usage-kpi">
|
||||
<span>출력 토큰</span>
|
||||
<b>{usage ? countLabel(usage.tokens_out) : "-"}</b>
|
||||
<small>내담자 응답</small>
|
||||
</div>
|
||||
<div className="ad-usage-kpi">
|
||||
<span>계량 커버리지</span>
|
||||
<b>
|
||||
{usage && usage.total_turns > 0
|
||||
? `${Math.round((usage.metered_turns / usage.total_turns) * 100)}%`
|
||||
: usage
|
||||
? "0%"
|
||||
: "-"}
|
||||
</b>
|
||||
<small>{usage ? `${countLabel(usage.total_turns)}개 내담자 턴` : "계산 중"}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{usage ? (
|
||||
<section
|
||||
className={`ad-usage-budget ad-usage-budget--${usage.budget.status}`}
|
||||
role={usage.budget.status === "warn" || usage.budget.status === "exceeded" ? "alert" : "status"}
|
||||
>
|
||||
<Icon
|
||||
name={usage.budget.status === "warn" || usage.budget.status === "exceeded" ? "alert" : "check"}
|
||||
size={18}
|
||||
/>
|
||||
<div>
|
||||
<b>{usageBudgetLabel(usage)}</b>
|
||||
<span>{usageBudgetDetail(usage)}</span>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<div className="ad-usage-breakdown" aria-label="모델별 비용">
|
||||
<div className="ad-usage-breakdown__head">
|
||||
<span>Provider / Model</span>
|
||||
<span>턴</span>
|
||||
<span>토큰</span>
|
||||
<span>비용</span>
|
||||
</div>
|
||||
{(usage?.by_provider ?? []).map((item) => (
|
||||
<div className="ad-usage-row" key={`${item.provider}:${item.model}`}>
|
||||
<span>
|
||||
<b>{item.provider}</b>
|
||||
<small>{item.model}</small>
|
||||
</span>
|
||||
<span>{countLabel(item.turns)}</span>
|
||||
<span>{countLabel(item.tokens_in + item.tokens_out)}</span>
|
||||
<span>{costLabel(item.cost_usd)}</span>
|
||||
</div>
|
||||
))}
|
||||
{usageLoading && !usage ? (
|
||||
<div className="ad-users-empty">비용 사용량을 계산하는 중입니다.</div>
|
||||
) : null}
|
||||
{!usageLoading && usage && usage.by_provider.length === 0 ? (
|
||||
<div className="ad-users-empty">최근 윈도우에 계량된 AI 턴이 없습니다.</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{error ? (
|
||||
<section className="ad-error" role="alert">
|
||||
<Icon name="alert" size={18} />
|
||||
|
|
@ -698,9 +836,25 @@ export default function Admin() {
|
|||
|
||||
const ADMIN_CSS = `
|
||||
.ad-root{
|
||||
width:min(100%,1180px);
|
||||
margin:0 auto;
|
||||
position:relative;
|
||||
isolation:isolate;
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
gap:18px;
|
||||
gap:16px;
|
||||
}
|
||||
.ad-root::before{
|
||||
content:"";
|
||||
position:absolute;
|
||||
z-index:-1;
|
||||
inset:-80px -180px auto auto;
|
||||
width:min(560px,52vw);
|
||||
height:420px;
|
||||
background:var(--asset-warm-elements) center / cover no-repeat;
|
||||
opacity:.045;
|
||||
filter:saturate(.75);
|
||||
pointer-events:none;
|
||||
}
|
||||
.ad-head{
|
||||
display:flex;
|
||||
|
|
@ -708,7 +862,7 @@ const ADMIN_CSS = `
|
|||
justify-content:space-between;
|
||||
gap:var(--sp-4);
|
||||
flex-wrap:wrap;
|
||||
padding-bottom:2px;
|
||||
padding:0 2px 2px;
|
||||
}
|
||||
.ad-head h1{
|
||||
margin:6px 0 0;
|
||||
|
|
@ -726,7 +880,7 @@ const ADMIN_CSS = `
|
|||
}
|
||||
.ad-ops{
|
||||
display:grid;
|
||||
grid-template-columns:minmax(0,max-content) minmax(320px,1fr);
|
||||
grid-template-columns:minmax(320px,.64fr) minmax(0,1fr);
|
||||
gap:12px;
|
||||
}
|
||||
.ad-status{
|
||||
|
|
@ -738,7 +892,8 @@ const ADMIN_CSS = `
|
|||
padding:14px 16px;
|
||||
border:1px solid var(--hair);
|
||||
border-radius:var(--radius);
|
||||
background:var(--bg-surface);
|
||||
background:
|
||||
linear-gradient(180deg,color-mix(in srgb,var(--accent-tint) 42%,var(--bg-surface)),var(--bg-surface));
|
||||
box-shadow:var(--shadow-sm);
|
||||
}
|
||||
.ad-status__dot{
|
||||
|
|
@ -779,7 +934,7 @@ const ADMIN_CSS = `
|
|||
}
|
||||
.ad-kpis{
|
||||
display:grid;
|
||||
grid-template-columns:repeat(2,minmax(0,1fr));
|
||||
grid-template-columns:repeat(4,minmax(0,1fr));
|
||||
border:1px solid var(--hair);
|
||||
border-radius:var(--radius);
|
||||
overflow:hidden;
|
||||
|
|
@ -793,7 +948,8 @@ const ADMIN_CSS = `
|
|||
gap:4px;
|
||||
}
|
||||
.ad-kpi:nth-child(even){border-left:1px solid var(--hair);}
|
||||
.ad-kpi:nth-child(n+3){border-top:1px solid var(--hair);}
|
||||
.ad-kpi + .ad-kpi{border-left:1px solid var(--hair);}
|
||||
.ad-kpi:nth-child(n+3){border-top:0;}
|
||||
.ad-kpi__lab{
|
||||
display:block;
|
||||
color:var(--text-muted);
|
||||
|
|
@ -812,6 +968,138 @@ const ADMIN_CSS = `
|
|||
font-size:12px;
|
||||
line-height:1.35;
|
||||
}
|
||||
.ad-usage-section{
|
||||
padding:14px;
|
||||
border:1px solid var(--hair);
|
||||
border-radius:var(--radius);
|
||||
background:var(--bg-surface);
|
||||
box-shadow:var(--shadow-sm);
|
||||
}
|
||||
.ad-usage-grid{
|
||||
display:grid;
|
||||
grid-template-columns:repeat(4,minmax(0,1fr));
|
||||
gap:10px;
|
||||
}
|
||||
.ad-usage-kpi{
|
||||
min-width:0;
|
||||
display:grid;
|
||||
gap:5px;
|
||||
padding:12px;
|
||||
border:1px solid var(--hair);
|
||||
border-radius:var(--radius-sm);
|
||||
background:var(--bg-surface-2);
|
||||
}
|
||||
.ad-usage-kpi span{
|
||||
color:var(--text-muted);
|
||||
font-size:var(--fs-xs);
|
||||
font-weight:700;
|
||||
}
|
||||
.ad-usage-kpi b{
|
||||
color:var(--text-strong);
|
||||
font-family:var(--font-num);
|
||||
font-size:22px;
|
||||
line-height:1;
|
||||
overflow-wrap:anywhere;
|
||||
}
|
||||
.ad-usage-kpi small{
|
||||
color:var(--text-muted);
|
||||
font-size:12px;
|
||||
line-height:1.35;
|
||||
}
|
||||
.ad-usage-budget{
|
||||
display:flex;
|
||||
align-items:flex-start;
|
||||
gap:10px;
|
||||
min-width:0;
|
||||
padding:10px 12px;
|
||||
border:1px solid var(--hair);
|
||||
border-radius:var(--radius-sm);
|
||||
background:var(--bg-surface-2);
|
||||
color:var(--text-muted);
|
||||
}
|
||||
.ad-usage-budget svg{
|
||||
flex:0 0 auto;
|
||||
margin-top:1px;
|
||||
}
|
||||
.ad-usage-budget div{
|
||||
display:grid;
|
||||
gap:2px;
|
||||
min-width:0;
|
||||
}
|
||||
.ad-usage-budget b{
|
||||
color:var(--text-strong);
|
||||
font-size:var(--fs-xs);
|
||||
line-height:1.3;
|
||||
}
|
||||
.ad-usage-budget span{
|
||||
font-size:12px;
|
||||
line-height:1.4;
|
||||
overflow-wrap:anywhere;
|
||||
}
|
||||
.ad-usage-budget--warn{
|
||||
border-color:color-mix(in srgb,var(--warn-solid) 42%,var(--hair));
|
||||
background:color-mix(in srgb,var(--warn-tint) 72%,var(--bg-surface));
|
||||
color:var(--warn-text);
|
||||
}
|
||||
.ad-usage-budget--exceeded{
|
||||
border-color:color-mix(in srgb,var(--crit-solid) 42%,var(--hair));
|
||||
background:color-mix(in srgb,var(--crit-tint) 72%,var(--bg-surface));
|
||||
color:var(--crit-text);
|
||||
}
|
||||
.ad-usage-budget--ok{
|
||||
border-color:color-mix(in srgb,var(--pos-solid) 36%,var(--hair));
|
||||
background:color-mix(in srgb,var(--pos-tint) 72%,var(--bg-surface));
|
||||
color:var(--pos-text);
|
||||
}
|
||||
.ad-usage-breakdown{
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
min-width:0;
|
||||
border:1px solid var(--hair);
|
||||
border-radius:var(--radius-sm);
|
||||
overflow:hidden;
|
||||
background:var(--bg-surface-2);
|
||||
}
|
||||
.ad-usage-breakdown__head,
|
||||
.ad-usage-row{
|
||||
display:grid;
|
||||
grid-template-columns:minmax(170px,1fr) 72px 110px 90px;
|
||||
align-items:center;
|
||||
gap:10px;
|
||||
padding:9px 12px;
|
||||
}
|
||||
.ad-usage-breakdown__head{
|
||||
background:var(--bg-app);
|
||||
color:var(--text-muted);
|
||||
font-size:var(--fs-xs);
|
||||
font-weight:800;
|
||||
}
|
||||
.ad-usage-row{
|
||||
border-top:1px solid var(--hair);
|
||||
color:var(--text-body);
|
||||
font-family:var(--font-num);
|
||||
font-size:var(--fs-sm);
|
||||
}
|
||||
.ad-usage-row > span{
|
||||
min-width:0;
|
||||
}
|
||||
.ad-usage-row > span:first-child{
|
||||
display:grid;
|
||||
gap:2px;
|
||||
font-family:var(--font-sans);
|
||||
}
|
||||
.ad-usage-row b{
|
||||
color:var(--text-strong);
|
||||
font-size:var(--fs-sm);
|
||||
line-height:1.25;
|
||||
overflow-wrap:anywhere;
|
||||
}
|
||||
.ad-usage-row small{
|
||||
color:var(--text-muted);
|
||||
font-size:12px;
|
||||
line-height:1.25;
|
||||
overflow-wrap:anywhere;
|
||||
}
|
||||
.ad-section{
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
|
|
@ -996,12 +1284,13 @@ const ADMIN_CSS = `
|
|||
}
|
||||
.ad-user-workspace{
|
||||
display:grid;
|
||||
grid-template-columns:minmax(250px,292px) minmax(0,1fr);
|
||||
grid-template-columns:minmax(0,1fr) minmax(258px,300px);
|
||||
align-items:stretch;
|
||||
gap:14px;
|
||||
min-width:0;
|
||||
}
|
||||
.ad-user-sidecar{
|
||||
order:2;
|
||||
position:sticky;
|
||||
top:calc(var(--topbar-h) + 16px);
|
||||
align-self:start;
|
||||
|
|
@ -1011,6 +1300,7 @@ const ADMIN_CSS = `
|
|||
min-width:0;
|
||||
}
|
||||
.ad-user-listpane{
|
||||
order:1;
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
min-width:0;
|
||||
|
|
@ -1073,10 +1363,10 @@ const ADMIN_CSS = `
|
|||
.ad-user{
|
||||
min-width:0;
|
||||
display:grid;
|
||||
grid-template-columns:minmax(150px,.75fr) minmax(260px,1.35fr) minmax(104px,.45fr) max-content;
|
||||
grid-template-columns:minmax(190px,.75fr) minmax(420px,1.4fr) minmax(138px,.42fr) max-content;
|
||||
align-items:center;
|
||||
gap:10px 12px;
|
||||
padding:11px 12px;
|
||||
padding:10px 12px;
|
||||
border-top:1px solid var(--paper-2);
|
||||
background:var(--bg-surface);
|
||||
}
|
||||
|
|
@ -1123,7 +1413,7 @@ const ADMIN_CSS = `
|
|||
}
|
||||
.ad-user__fields{
|
||||
display:grid;
|
||||
grid-template-columns:minmax(0,1fr) 104px;
|
||||
grid-template-columns:minmax(130px,1.1fr) 96px minmax(118px,.95fr) minmax(110px,.85fr);
|
||||
gap:8px;
|
||||
min-width:0;
|
||||
}
|
||||
|
|
@ -1134,10 +1424,17 @@ const ADMIN_CSS = `
|
|||
gap:6px;
|
||||
}
|
||||
.ad-user__fields span{
|
||||
display:none;
|
||||
color:var(--text-muted);
|
||||
font-size:var(--fs-xs);
|
||||
font-weight:650;
|
||||
}
|
||||
.ad-user__fields input,
|
||||
.ad-user__fields select{
|
||||
height:32px;
|
||||
font-size:12.5px;
|
||||
padding-inline:9px;
|
||||
}
|
||||
.ad-user__meta{
|
||||
display:grid;
|
||||
gap:4px;
|
||||
|
|
@ -1180,12 +1477,20 @@ const ADMIN_CSS = `
|
|||
grid-template-columns:1fr;
|
||||
}
|
||||
.ad-user-sidecar{
|
||||
order:0;
|
||||
position:static;
|
||||
}
|
||||
.ad-user-listpane{order:1;}
|
||||
.ad-user-create{grid-template-columns:repeat(2,minmax(0,1fr));}
|
||||
.ad-user{
|
||||
grid-template-columns:minmax(190px,.8fr) minmax(0,1.2fr) max-content;
|
||||
}
|
||||
.ad-user__fields{
|
||||
grid-template-columns:minmax(0,1fr) 104px;
|
||||
}
|
||||
.ad-user__fields span{
|
||||
display:block;
|
||||
}
|
||||
.ad-user__meta{
|
||||
grid-column:1 / -1;
|
||||
display:flex;
|
||||
|
|
@ -1203,6 +1508,9 @@ const ADMIN_CSS = `
|
|||
.ad-kpi:nth-child(even),
|
||||
.ad-kpi + .ad-kpi{border-left:1px solid var(--hair);}
|
||||
.ad-kpi:nth-child(n+3){border-top:0;}
|
||||
.ad-usage-grid{
|
||||
grid-template-columns:repeat(2,minmax(0,1fr));
|
||||
}
|
||||
.ad-service{
|
||||
grid-template-columns:minmax(160px,.85fr) minmax(0,1fr);
|
||||
}
|
||||
|
|
@ -1218,6 +1526,7 @@ const ADMIN_CSS = `
|
|||
}
|
||||
}
|
||||
@media (max-width:700px){
|
||||
.ad-root::before{display:none;}
|
||||
.ad-head{
|
||||
align-items:flex-start;
|
||||
flex-direction:column;
|
||||
|
|
@ -1231,6 +1540,16 @@ const ADMIN_CSS = `
|
|||
.ad-kpi:nth-child(even){border-left:1px solid var(--hair);}
|
||||
.ad-kpi:nth-child(odd){border-left:0;}
|
||||
.ad-kpi:nth-child(n+3){border-top:1px solid var(--hair);}
|
||||
.ad-usage-breakdown__head{
|
||||
display:none;
|
||||
}
|
||||
.ad-usage-row{
|
||||
grid-template-columns:minmax(0,1fr) auto;
|
||||
gap:8px 12px;
|
||||
}
|
||||
.ad-usage-row > span:first-child{
|
||||
grid-row:1 / span 3;
|
||||
}
|
||||
.ad-service{
|
||||
grid-template-columns:1fr;
|
||||
}
|
||||
|
|
@ -1259,6 +1578,13 @@ const ADMIN_CSS = `
|
|||
border-left:0;
|
||||
}
|
||||
.ad-kpi + .ad-kpi{border-top:1px solid var(--hair);}
|
||||
.ad-usage-grid{grid-template-columns:1fr;}
|
||||
.ad-usage-row{
|
||||
grid-template-columns:1fr;
|
||||
}
|
||||
.ad-usage-row > span:first-child{
|
||||
grid-row:auto;
|
||||
}
|
||||
.ad-service__meter{grid-template-columns:1fr;}
|
||||
.ad-service__meter span{text-align:left;}
|
||||
.ad-user__top{align-items:stretch;flex-direction:column;}
|
||||
|
|
|
|||
|
|
@ -430,6 +430,8 @@ const LH_CSS = `
|
|||
width:min(100%,1480px);
|
||||
min-height:calc(100dvh - var(--topbar-h));
|
||||
margin:0 auto;
|
||||
position:relative;
|
||||
isolation:isolate;
|
||||
display:grid;
|
||||
grid-template-rows:auto minmax(0,1fr);
|
||||
align-content:start;
|
||||
|
|
@ -438,6 +440,18 @@ const LH_CSS = `
|
|||
background:var(--bg-app);
|
||||
overflow:visible;
|
||||
}
|
||||
.lh-root::before{
|
||||
content:"";
|
||||
position:absolute;
|
||||
z-index:-1;
|
||||
inset:-120px -220px auto auto;
|
||||
width:min(720px,58vw);
|
||||
height:520px;
|
||||
background:var(--asset-warm-elements) center / cover no-repeat;
|
||||
opacity:.07;
|
||||
filter:saturate(.8);
|
||||
pointer-events:none;
|
||||
}
|
||||
.lh-head{
|
||||
display:flex;
|
||||
align-items:center;
|
||||
|
|
@ -500,6 +514,16 @@ const LH_CSS = `
|
|||
box-shadow:var(--shadow-sm);
|
||||
overflow:hidden;
|
||||
}
|
||||
.lh-list-pane::after{
|
||||
content:"";
|
||||
position:absolute;
|
||||
inset:auto -48px -68px auto;
|
||||
width:180px;
|
||||
height:180px;
|
||||
background:var(--asset-warm-elements) center / cover no-repeat;
|
||||
opacity:.05;
|
||||
pointer-events:none;
|
||||
}
|
||||
.lh-pane-head{
|
||||
display:flex;
|
||||
align-items:center;
|
||||
|
|
@ -637,6 +661,7 @@ const LH_CSS = `
|
|||
}
|
||||
.lh-preview__main{
|
||||
min-width:0;
|
||||
position:relative;
|
||||
display:grid;
|
||||
gap:var(--sp-4);
|
||||
align-content:start;
|
||||
|
|
@ -645,6 +670,22 @@ const LH_CSS = `
|
|||
border-radius:var(--radius);
|
||||
background:var(--bg-surface);
|
||||
box-shadow:var(--shadow-sm);
|
||||
overflow:hidden;
|
||||
}
|
||||
.lh-preview__main::after{
|
||||
content:"";
|
||||
position:absolute;
|
||||
inset:auto -180px -210px auto;
|
||||
width:520px;
|
||||
height:360px;
|
||||
background:var(--asset-warm-elements) center / cover no-repeat;
|
||||
opacity:.08;
|
||||
filter:saturate(.82);
|
||||
pointer-events:none;
|
||||
}
|
||||
.lh-preview__main > *{
|
||||
position:relative;
|
||||
z-index:1;
|
||||
}
|
||||
.lh-preview__hero{
|
||||
min-width:0;
|
||||
|
|
@ -721,7 +762,8 @@ const LH_CSS = `
|
|||
min-width:0;
|
||||
padding:var(--sp-4);
|
||||
border-radius:var(--radius);
|
||||
background:var(--bg-surface-2);
|
||||
background:
|
||||
linear-gradient(180deg,color-mix(in srgb,var(--clay-tint) 48%,var(--bg-surface-2)),var(--bg-surface-2));
|
||||
}
|
||||
.lh-summary p{
|
||||
margin:8px 0 0;
|
||||
|
|
@ -971,6 +1013,11 @@ const LH_CSS = `
|
|||
max-height:none;
|
||||
overflow:visible;
|
||||
}
|
||||
.lh-root::before{
|
||||
width:560px;
|
||||
height:420px;
|
||||
opacity:.055;
|
||||
}
|
||||
.lh-personas{
|
||||
grid-template-columns:repeat(2,minmax(0,1fr));
|
||||
grid-auto-rows:auto;
|
||||
|
|
@ -989,6 +1036,11 @@ const LH_CSS = `
|
|||
padding:12px;
|
||||
gap:12px;
|
||||
}
|
||||
.lh-root::before,
|
||||
.lh-preview__main::after,
|
||||
.lh-list-pane::after{
|
||||
display:none;
|
||||
}
|
||||
.lh-head{
|
||||
align-items:flex-start;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,16 +10,50 @@ const OAUTH_FAILED_MESSAGE =
|
|||
"Google 로그인 흐름을 완료하지 못했습니다. 다시 시도하거나 관리자에게 설정 확인을 요청하세요.";
|
||||
const LOCAL_OAUTH_UNAVAILABLE_MESSAGE =
|
||||
"로컬 개발 주소에서는 Google OAuth 콜백이 공개 API로 돌아가므로 로컬 테스트 계정으로 로그인하세요.";
|
||||
const OAUTH_STATE_FAILED_MESSAGE =
|
||||
"로그인 세션 확인에 실패했습니다. 브라우저 쿠키를 허용한 뒤 다시 시도하세요.";
|
||||
const OAUTH_TOKEN_FAILED_MESSAGE =
|
||||
"Google 인증 코드를 서버에서 교환하지 못했습니다. 관리자에게 OAuth 클라이언트 secret과 redirect URI 확인을 요청하세요.";
|
||||
const OAUTH_IDENTITY_FAILED_MESSAGE =
|
||||
"Google 계정 정보를 확인하지 못했습니다. 다시 시도하거나 관리자에게 OAuth 클라이언트 설정 확인을 요청하세요.";
|
||||
const OAUTH_PROVIDER_DENIED_MESSAGE =
|
||||
"Google 로그인이 취소되었거나 계정 선택이 거부되었습니다. 다시 시도하세요.";
|
||||
const OAUTH_PROVIDER_FAILED_MESSAGE =
|
||||
"Google이 인증 코드를 발급하지 못했습니다. 관리자에게 OAuth 동의 화면과 클라이언트 설정 확인을 요청하세요.";
|
||||
const OAUTH_UNSUPPORTED_PROVIDER_MESSAGE =
|
||||
"지원하지 않는 로그인 공급자입니다. Google 로그인 버튼으로 다시 시작하세요.";
|
||||
const SAML_NOT_CONFIGURED_MESSAGE =
|
||||
"학교 SSO가 아직 연결되지 않았습니다. 현재는 승인된 Google 계정으로 로그인하세요.";
|
||||
const SAML_FAILED_MESSAGE =
|
||||
"학교 SSO 로그인 흐름을 완료하지 못했습니다. 관리자에게 SSO 설정 확인을 요청하세요.";
|
||||
|
||||
function oauthMessage(reason: string | null): string | null {
|
||||
if (!reason) return null;
|
||||
if (reason === "not_configured") return OAUTH_NOT_CONFIGURED_MESSAGE;
|
||||
if (reason === "local_oauth_unavailable") return LOCAL_OAUTH_UNAVAILABLE_MESSAGE;
|
||||
if (reason === "invalid_state" || reason === "missing_callback") {
|
||||
return OAUTH_STATE_FAILED_MESSAGE;
|
||||
}
|
||||
if (reason === "token_exchange_failed") return OAUTH_TOKEN_FAILED_MESSAGE;
|
||||
if (
|
||||
reason === "id_token_missing" ||
|
||||
reason === "id_token_invalid" ||
|
||||
reason === "audience_mismatch" ||
|
||||
reason === "issuer_mismatch"
|
||||
) {
|
||||
return OAUTH_IDENTITY_FAILED_MESSAGE;
|
||||
}
|
||||
if (reason === "domain_not_allowed") {
|
||||
return "승인된 이메일 도메인의 Google 계정만 사용할 수 있습니다.";
|
||||
}
|
||||
if (reason === "inactive_user") {
|
||||
return "비활성화된 계정입니다. 관리자에게 계정 상태 확인을 요청하세요.";
|
||||
}
|
||||
if (reason === "access_denied") return OAUTH_PROVIDER_DENIED_MESSAGE;
|
||||
if (reason === "provider_error") return OAUTH_PROVIDER_FAILED_MESSAGE;
|
||||
if (reason === "unsupported_provider") return OAUTH_UNSUPPORTED_PROVIDER_MESSAGE;
|
||||
if (reason === "saml_not_configured") return SAML_NOT_CONFIGURED_MESSAGE;
|
||||
if (reason.startsWith("saml_")) return SAML_FAILED_MESSAGE;
|
||||
return OAUTH_FAILED_MESSAGE;
|
||||
}
|
||||
|
||||
|
|
@ -49,6 +83,7 @@ export default function Login() {
|
|||
const [selected, setSelected] = useState<Role>("learner");
|
||||
const [pending, setPending] = useState(false);
|
||||
const [loginError, setLoginError] = useState<string | null>(null);
|
||||
const [loginErrorReason, setLoginErrorReason] = useState<string | null>(null);
|
||||
const [authConfig, setAuthConfig] = useState<AuthConfigResponse | null>(null);
|
||||
const [authConfigError, setAuthConfigError] = useState<string | null>(null);
|
||||
|
||||
|
|
@ -63,6 +98,7 @@ export default function Login() {
|
|||
useEffect(() => {
|
||||
const oauthState = new URLSearchParams(location.search).get("oauth");
|
||||
setLoginError(oauthMessage(oauthState));
|
||||
setLoginErrorReason(oauthState);
|
||||
}, [location.search]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -86,24 +122,21 @@ export default function Login() {
|
|||
|
||||
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 &&
|
||||
const devOAuthUnavailable =
|
||||
devLoginReady &&
|
||||
authConfig?.google_oauth_configured === true &&
|
||||
!isLocalRedirectUri(authConfig.redirect_uri);
|
||||
const oauthReady =
|
||||
authConfig?.google_oauth_configured === true && !localOAuthUnavailable;
|
||||
authConfig?.google_oauth_configured === true && !devOAuthUnavailable;
|
||||
const allowedDomains = authConfig?.allowed_email_domains ?? [];
|
||||
const primaryDomainLabel = localOAuthUnavailable
|
||||
const primaryDomainLabel = devOAuthUnavailable
|
||||
? "로컬은 테스트 계정 사용"
|
||||
: allowedDomains[0]
|
||||
? `@${allowedDomains[0]}`
|
||||
: oauthChecking
|
||||
? "도메인 확인 중"
|
||||
: "승인 도메인 계정";
|
||||
const secondaryDomainLabel = localOAuthUnavailable
|
||||
const secondaryDomainLabel = devOAuthUnavailable
|
||||
? "공개 주소에서 사용"
|
||||
: allowedDomains[1]
|
||||
? `@${allowedDomains[1]}`
|
||||
|
|
@ -113,8 +146,9 @@ export default function Login() {
|
|||
|
||||
const startOAuth = () => {
|
||||
if (!oauthReady) {
|
||||
setLoginErrorReason(devOAuthUnavailable ? "local_oauth_unavailable" : "not_configured");
|
||||
setLoginError(
|
||||
localOAuthUnavailable
|
||||
devOAuthUnavailable
|
||||
? LOCAL_OAUTH_UNAVAILABLE_MESSAGE
|
||||
: (authConfigError ?? OAUTH_NOT_CONFIGURED_MESSAGE),
|
||||
);
|
||||
|
|
@ -128,6 +162,7 @@ export default function Login() {
|
|||
const enterDev = async (role: Role) => {
|
||||
setPending(true);
|
||||
setLoginError(null);
|
||||
setLoginErrorReason(null);
|
||||
try {
|
||||
const signedIn = await login(role);
|
||||
navigate(roleHomePath(signedIn.role), { replace: true });
|
||||
|
|
@ -233,7 +268,7 @@ export default function Login() {
|
|||
<div className="lg-config" role="status">
|
||||
<Icon name={authConfigError ? "alert" : "info"} size={17} />
|
||||
<span>
|
||||
{localOAuthUnavailable
|
||||
{devOAuthUnavailable
|
||||
? LOCAL_OAUTH_UNAVAILABLE_MESSAGE
|
||||
: oauthChecking
|
||||
? "Google 로그인 설정을 확인하는 중입니다."
|
||||
|
|
@ -275,11 +310,21 @@ export default function Login() {
|
|||
>
|
||||
{pending ? "테스트 계정 확인 중" : "로컬 테스트 계정으로 계속"}
|
||||
</button>
|
||||
{loginError ? <p className="lg-error">{loginError}</p> : null}
|
||||
{loginError ? (
|
||||
<p className="lg-error">
|
||||
{loginError}
|
||||
{loginErrorReason ? <small>오류 코드: {loginErrorReason}</small> : null}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!devLoginReady && loginError ? <p className="lg-error">{loginError}</p> : null}
|
||||
{!devLoginReady && loginError ? (
|
||||
<p className="lg-error">
|
||||
{loginError}
|
||||
{loginErrorReason ? <small>오류 코드: {loginErrorReason}</small> : null}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<p className="lg-note">
|
||||
교육용 비치료 연구 도구입니다. 실제 치료, 진단, 위기 개입을 대체하지 않습니다.
|
||||
|
|
@ -293,22 +338,58 @@ export default function Login() {
|
|||
const LOGIN_CSS = `
|
||||
.lg-root{
|
||||
min-height:100dvh;
|
||||
position:relative;
|
||||
display:grid;
|
||||
grid-template-columns:minmax(0,1fr) minmax(360px,480px);
|
||||
background:var(--bg-app);
|
||||
color:var(--text-strong);
|
||||
overflow:hidden;
|
||||
}
|
||||
.lg-root::before{
|
||||
content:"";
|
||||
position:absolute;
|
||||
inset:auto -12vw -22vw 38vw;
|
||||
height:42vw;
|
||||
min-height:360px;
|
||||
background:var(--asset-warm-elements) center / cover no-repeat;
|
||||
opacity:.08;
|
||||
filter:saturate(.82);
|
||||
pointer-events:none;
|
||||
}
|
||||
.lg-brand,
|
||||
.lg-enter{
|
||||
position:relative;
|
||||
z-index:1;
|
||||
}
|
||||
.lg-brand{
|
||||
min-width:0;
|
||||
position:relative;
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
justify-content:space-between;
|
||||
gap:var(--sp-7);
|
||||
padding:var(--sp-7);
|
||||
background:var(--bg-stage);
|
||||
background:
|
||||
linear-gradient(115deg,rgba(14,22,20,.94),rgba(30,39,36,.84) 54%,rgba(30,39,36,.68)),
|
||||
var(--asset-warm-elements) center / cover no-repeat;
|
||||
color:#edf4f2;
|
||||
overflow:hidden;
|
||||
}
|
||||
.lg-brand::after{
|
||||
content:"";
|
||||
position:absolute;
|
||||
left:var(--sp-7);
|
||||
bottom:var(--sp-7);
|
||||
width:min(360px,42vw);
|
||||
height:120px;
|
||||
border:1px solid rgba(255,255,255,.12);
|
||||
border-radius:var(--radius-lg);
|
||||
background:rgba(251,250,248,.06);
|
||||
pointer-events:none;
|
||||
}
|
||||
.lg-wordmark{
|
||||
position:relative;
|
||||
z-index:1;
|
||||
display:flex;
|
||||
align-items:center;
|
||||
gap:10px;
|
||||
|
|
@ -319,6 +400,11 @@ const LOGIN_CSS = `
|
|||
}
|
||||
.lg-mark{display:grid;place-items:center;color:var(--accent-bright);}
|
||||
.lg-copy{max-width:620px;}
|
||||
.lg-copy,
|
||||
.lg-policy{
|
||||
position:relative;
|
||||
z-index:1;
|
||||
}
|
||||
.lg-kicker{
|
||||
display:inline-flex;
|
||||
align-items:center;
|
||||
|
|
@ -373,6 +459,8 @@ const LOGIN_CSS = `
|
|||
align-items:center;
|
||||
justify-content:center;
|
||||
padding:var(--sp-6);
|
||||
background:
|
||||
linear-gradient(180deg,rgba(251,250,248,.9),rgba(244,242,238,.76));
|
||||
}
|
||||
.lg-panel{
|
||||
width:100%;
|
||||
|
|
@ -517,6 +605,14 @@ const LOGIN_CSS = `
|
|||
font-size:13px;
|
||||
line-height:1.5;
|
||||
}
|
||||
.lg-error small{
|
||||
display:block;
|
||||
margin-top:4px;
|
||||
color:var(--text-muted);
|
||||
font-family:var(--font-num);
|
||||
font-size:11.5px;
|
||||
overflow-wrap:anywhere;
|
||||
}
|
||||
.lg-note{
|
||||
margin:var(--sp-5) 0 0;
|
||||
color:var(--text-muted);
|
||||
|
|
@ -525,7 +621,9 @@ const LOGIN_CSS = `
|
|||
}
|
||||
@media (max-width:880px){
|
||||
.lg-root{grid-template-columns:1fr;}
|
||||
.lg-root::before{display:none;}
|
||||
.lg-brand{padding:var(--sp-6) var(--sp-5);gap:var(--sp-6);}
|
||||
.lg-brand::after{display:none;}
|
||||
.lg-copy h1{font-size:36px;}
|
||||
.lg-enter{padding:var(--sp-5);}
|
||||
.lg-panel{max-width:560px;}
|
||||
|
|
|
|||
|
|
@ -6,8 +6,13 @@ import {
|
|||
personaReviewApi,
|
||||
teacherApi,
|
||||
type PersonaReviewAction,
|
||||
type PersonaDraftDetail,
|
||||
type PersonaDraftPayload,
|
||||
type PersonaReviewStatus,
|
||||
type PersonaReviewSummary,
|
||||
type TeacherLearnerGrowth,
|
||||
type TeacherGrowthPoint,
|
||||
type TeacherSafetyAlert,
|
||||
type TeacherDashboardResponse,
|
||||
} from "../lib/api";
|
||||
|
||||
|
|
@ -25,6 +30,24 @@ function formatDateTime(value: string | null): string {
|
|||
});
|
||||
}
|
||||
|
||||
function formatScore(value: number | null | undefined): string {
|
||||
if (typeof value !== "number" || Number.isNaN(value)) return "평가 부족";
|
||||
return `${Math.round(value * 100)}%`;
|
||||
}
|
||||
|
||||
function formatDelta(value: number | null | undefined): string {
|
||||
if (typeof value !== "number" || Number.isNaN(value)) return "변화 부족";
|
||||
const sign = value > 0 ? "+" : "";
|
||||
return `${sign}${Math.round(value * 100)}%p`;
|
||||
}
|
||||
|
||||
function trendLabel(value: string): string {
|
||||
if (value === "up") return "상승";
|
||||
if (value === "down") return "하락";
|
||||
if (value === "flat") return "유지";
|
||||
return "평가 부족";
|
||||
}
|
||||
|
||||
function personaReviewStatusLabel(status: PersonaReviewStatus): string {
|
||||
if (status === "review") return "검수 대기";
|
||||
if (status === "draft") return "수정 대기";
|
||||
|
|
@ -38,6 +61,79 @@ function personaReviewTone(status: PersonaReviewStatus): "accent" | "neutral" |
|
|||
return "neutral";
|
||||
}
|
||||
|
||||
const EMPTY_PERSONA_DRAFT: PersonaDraftPayload = {
|
||||
code: "P4",
|
||||
display_name: "새 페르소나",
|
||||
difficulty: "moderate",
|
||||
theory_target: ["humanistic"],
|
||||
demographics: {
|
||||
age_band: "F-20s",
|
||||
},
|
||||
presenting: {
|
||||
complaint: "",
|
||||
},
|
||||
history: {},
|
||||
big5: {
|
||||
O: 0.5,
|
||||
C: 0.5,
|
||||
E: 0.5,
|
||||
A: 0.5,
|
||||
N: 0.5,
|
||||
},
|
||||
resistance: {
|
||||
base_resistance: 0.5,
|
||||
unlock_rate: 0.1,
|
||||
decay_floor: 0.05,
|
||||
silence_prob: 0.15,
|
||||
deflection_prob: 0.25,
|
||||
},
|
||||
speech_style: {
|
||||
register: "polite",
|
||||
avg_sentence_len: "medium",
|
||||
fillers: [],
|
||||
honorific: true,
|
||||
verbal_tics: [],
|
||||
},
|
||||
affect_baseline: {
|
||||
negative_affect: 0.45,
|
||||
hopelessness: 0.2,
|
||||
anhedonia: 0.2,
|
||||
sleep: 0.2,
|
||||
anxiety: 0.35,
|
||||
suicide_ideation_stage: 1,
|
||||
},
|
||||
ccd: {},
|
||||
dsm5_dimensional: {},
|
||||
source_provenance: "clinical draft",
|
||||
is_synthetic: true,
|
||||
submit_for_review: false,
|
||||
};
|
||||
|
||||
function stringifyDraft(payload: PersonaDraftPayload): string {
|
||||
return JSON.stringify(payload, null, 2);
|
||||
}
|
||||
|
||||
function draftDetailToPayload(detail: PersonaDraftDetail): PersonaDraftPayload {
|
||||
return {
|
||||
code: detail.code,
|
||||
display_name: detail.display_name,
|
||||
difficulty: detail.difficulty === "easy" || detail.difficulty === "hard" ? detail.difficulty : "moderate",
|
||||
theory_target: detail.theory_target,
|
||||
demographics: detail.demographics,
|
||||
presenting: detail.presenting,
|
||||
history: detail.history,
|
||||
big5: detail.big5,
|
||||
resistance: detail.resistance,
|
||||
speech_style: detail.speech_style,
|
||||
affect_baseline: detail.affect_baseline,
|
||||
ccd: detail.ccd,
|
||||
dsm5_dimensional: detail.dsm5_dimensional,
|
||||
source_provenance: detail.source_provenance,
|
||||
is_synthetic: detail.is_synthetic,
|
||||
submit_for_review: detail.status === "review",
|
||||
};
|
||||
}
|
||||
|
||||
function EmptyState({ title, desc }: { title: string; desc: string }) {
|
||||
return (
|
||||
<div className="pf-empty">
|
||||
|
|
@ -55,6 +151,11 @@ export default function Professor() {
|
|||
const [personaReviewLoading, setPersonaReviewLoading] = useState(true);
|
||||
const [personaReviewError, setPersonaReviewError] = useState<string | null>(null);
|
||||
const [personaReviewBusy, setPersonaReviewBusy] = useState<string | null>(null);
|
||||
const [draftJson, setDraftJson] = useState(() => stringifyDraft(EMPTY_PERSONA_DRAFT));
|
||||
const [draftEditingId, setDraftEditingId] = useState<string | null>(null);
|
||||
const [draftBusy, setDraftBusy] = useState<"load" | "save" | "submit" | null>(null);
|
||||
const [draftError, setDraftError] = useState<string | null>(null);
|
||||
const [draftMessage, setDraftMessage] = useState<string | null>(null);
|
||||
const [updatedAt, setUpdatedAt] = useState<Date | null>(null);
|
||||
|
||||
const loadDashboard = useCallback(async () => {
|
||||
|
|
@ -119,6 +220,73 @@ export default function Professor() {
|
|||
[],
|
||||
);
|
||||
|
||||
const resetPersonaDraft = useCallback(() => {
|
||||
setDraftJson(stringifyDraft(EMPTY_PERSONA_DRAFT));
|
||||
setDraftEditingId(null);
|
||||
setDraftError(null);
|
||||
setDraftMessage(null);
|
||||
}, []);
|
||||
|
||||
const parsePersonaDraft = useCallback(
|
||||
(submitForReview: boolean): PersonaDraftPayload => {
|
||||
const parsed = JSON.parse(draftJson) as PersonaDraftPayload;
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new Error("JSON 객체가 필요합니다.");
|
||||
}
|
||||
return {
|
||||
...parsed,
|
||||
submit_for_review: submitForReview,
|
||||
};
|
||||
},
|
||||
[draftJson],
|
||||
);
|
||||
|
||||
const savePersonaDraft = useCallback(
|
||||
async (submitForReview: boolean) => {
|
||||
setDraftBusy(submitForReview ? "submit" : "save");
|
||||
setDraftError(null);
|
||||
setDraftMessage(null);
|
||||
try {
|
||||
const payload = parsePersonaDraft(submitForReview);
|
||||
const updated = draftEditingId
|
||||
? await personaReviewApi.updateDraft(draftEditingId, payload)
|
||||
: await personaReviewApi.createDraft(payload);
|
||||
setDraftEditingId(updated.persona_id);
|
||||
setDraftMessage(
|
||||
updated.status === "review"
|
||||
? `${updated.code} v${updated.version} 검수 요청을 올렸습니다.`
|
||||
: `${updated.code} v${updated.version} 초안을 저장했습니다.`,
|
||||
);
|
||||
await loadPersonaReviews();
|
||||
} catch (err) {
|
||||
if (err instanceof SyntaxError) {
|
||||
setDraftError("JSON 형식이 올바르지 않습니다.");
|
||||
} else {
|
||||
setDraftError(err instanceof Error ? err.message : "페르소나 초안을 저장하지 못했습니다.");
|
||||
}
|
||||
} finally {
|
||||
setDraftBusy(null);
|
||||
}
|
||||
},
|
||||
[draftEditingId, loadPersonaReviews, parsePersonaDraft],
|
||||
);
|
||||
|
||||
const loadPersonaDraft = useCallback(async (personaId: string) => {
|
||||
setDraftBusy("load");
|
||||
setDraftError(null);
|
||||
setDraftMessage(null);
|
||||
try {
|
||||
const detail = await personaReviewApi.getDraft(personaId);
|
||||
setDraftEditingId(detail.persona_id);
|
||||
setDraftJson(stringifyDraft(draftDetailToPayload(detail)));
|
||||
setDraftMessage(`${detail.code} v${detail.version} 초안을 불러왔습니다.`);
|
||||
} catch (err) {
|
||||
setDraftError(err instanceof Error ? err.message : "페르소나 초안을 불러오지 못했습니다.");
|
||||
} finally {
|
||||
setDraftBusy(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const kpis = useMemo(
|
||||
() => [
|
||||
{
|
||||
|
|
@ -139,6 +307,12 @@ export default function Professor() {
|
|||
hint: "저장 완료",
|
||||
icon: "check" as const,
|
||||
},
|
||||
{
|
||||
label: "위기 알림",
|
||||
value: dashboard?.safety_alerts.length ?? 0,
|
||||
hint: "109 확인",
|
||||
icon: "alert" as const,
|
||||
},
|
||||
{
|
||||
label: "리뷰 대기",
|
||||
value: dashboard?.pending_reviews.length ?? 0,
|
||||
|
|
@ -152,6 +326,8 @@ export default function Professor() {
|
|||
const hasPending = pendingCount > 0;
|
||||
const totalSessions = (dashboard?.active_sessions ?? 0) + (dashboard?.ended_sessions ?? 0);
|
||||
const personaReviewCount = personaReviews.length;
|
||||
const safetyAlerts = dashboard?.safety_alerts ?? [];
|
||||
const learnerGrowth = dashboard?.learner_growth ?? [];
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
|
|
@ -225,8 +401,99 @@ export default function Professor() {
|
|||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="pf-section pf-section--growth">
|
||||
<div className="pf-section__head">
|
||||
<div>
|
||||
<Kicker>학습자 성장 추적</Kicker>
|
||||
<h2>이력·항목별 추이</h2>
|
||||
</div>
|
||||
<Badge tone={learnerGrowth.length > 0 ? "accent" : "neutral"}>
|
||||
{learnerGrowth.length}명
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<Card className="pf-panel pf-growth-panel">
|
||||
{learnerGrowth.length > 0 ? (
|
||||
<div className="pf-growth-list">
|
||||
{learnerGrowth.map((learner) => (
|
||||
<GrowthCard learner={learner} key={learner.learner_id} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
title={loadState === "loading" ? "성장 지표 계산 중" : "표시할 성장 이력 없음"}
|
||||
desc="학습자 회기와 턴별 평가가 쌓이면 적절성·라포·기법 사용 추이를 표시합니다."
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<section className="pf-workspace">
|
||||
<div className="pf-queue-stack">
|
||||
<section className="pf-section">
|
||||
<div className="pf-section__head">
|
||||
<div>
|
||||
<Kicker>페르소나 저작</Kicker>
|
||||
<h2>초안 작성</h2>
|
||||
</div>
|
||||
<Badge tone={draftEditingId ? "warn" : "neutral"}>
|
||||
{draftEditingId ? "편집 중" : "새 초안"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<Card className="pf-panel pf-draft-panel">
|
||||
<div className="pf-draft-toolbar">
|
||||
<span>{draftEditingId ? "기존 초안 수정" : "새 페르소나 버전"}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
leading={<Icon name="x" size={14} />}
|
||||
onClick={resetPersonaDraft}
|
||||
disabled={draftBusy !== null}
|
||||
>
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
<textarea
|
||||
className="pf-draft-json"
|
||||
value={draftJson}
|
||||
onChange={(event) => {
|
||||
setDraftJson(event.target.value);
|
||||
setDraftError(null);
|
||||
setDraftMessage(null);
|
||||
}}
|
||||
spellCheck={false}
|
||||
aria-label="페르소나 JSON 초안"
|
||||
/>
|
||||
{draftError ? (
|
||||
<p className="pf-draft-status is-error" role="alert">
|
||||
{draftError}
|
||||
</p>
|
||||
) : draftMessage ? (
|
||||
<p className="pf-draft-status">{draftMessage}</p>
|
||||
) : null}
|
||||
<div className="pf-draft-actions">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
leading={<Icon name="check" size={14} />}
|
||||
onClick={() => void savePersonaDraft(false)}
|
||||
disabled={draftBusy !== null}
|
||||
>
|
||||
{draftBusy === "save" ? "저장 중" : "초안 저장"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
leading={<Icon name="review" size={14} />}
|
||||
onClick={() => void savePersonaDraft(true)}
|
||||
disabled={draftBusy !== null}
|
||||
>
|
||||
{draftBusy === "submit" ? "요청 중" : "검수 요청"}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<section className="pf-section">
|
||||
<div className="pf-section__head">
|
||||
<div>
|
||||
|
|
@ -284,6 +551,15 @@ export default function Professor() {
|
|||
<span>{formatDateTime(persona.created_at)}</span>
|
||||
</div>
|
||||
<div className="pf-persona__actions">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
leading={<Icon name="review" size={14} />}
|
||||
onClick={() => void loadPersonaDraft(persona.persona_id)}
|
||||
disabled={busy || draftBusy !== null}
|
||||
>
|
||||
{draftBusy === "load" && draftEditingId === persona.persona_id ? "불러오는 중" : "편집"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
|
|
@ -315,6 +591,33 @@ export default function Professor() {
|
|||
</Card>
|
||||
</section>
|
||||
|
||||
<section className="pf-section">
|
||||
<div className="pf-section__head">
|
||||
<div>
|
||||
<Kicker>위기 알림</Kicker>
|
||||
<h2>109 안전 확인 큐</h2>
|
||||
</div>
|
||||
<Badge tone={safetyAlerts.length > 0 ? "warn" : "neutral"}>
|
||||
{safetyAlerts.length}건
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<Card className="pf-panel">
|
||||
{safetyAlerts.length > 0 ? (
|
||||
<div className="pf-alerts">
|
||||
{safetyAlerts.map((alert) => (
|
||||
<SafetyAlertRow alert={alert} key={alert.id} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="현재 위기 알림 없음"
|
||||
desc="실제 위기 신호가 감지되면 이 목록에 109 확인 큐로 표시됩니다."
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<section className="pf-section">
|
||||
<div className="pf-section__head">
|
||||
<div>
|
||||
|
|
@ -422,6 +725,110 @@ export default function Professor() {
|
|||
);
|
||||
}
|
||||
|
||||
function GrowthCard({ learner }: { learner: TeacherLearnerGrowth }) {
|
||||
const recentPoints = learner.points.slice(-3).reverse();
|
||||
return (
|
||||
<article className={`pf-growth-card trend-${learner.trend}`}>
|
||||
<div className="pf-growth-card__top">
|
||||
<div className="pf-growth-card__id">
|
||||
<b>{learner.learner_label}</b>
|
||||
<span>
|
||||
{learner.ended_sessions}/{learner.sessions}회기 완료 · {formatDateTime(learner.latest_at)}
|
||||
</span>
|
||||
</div>
|
||||
<Badge tone={learner.trend === "down" ? "warn" : learner.trend === "up" ? "accent" : "neutral"}>
|
||||
{trendLabel(learner.trend)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="pf-growth-card__metrics" aria-label="학습자 성장 요약">
|
||||
<span>
|
||||
<small>최근 적절성</small>
|
||||
<b>{formatScore(learner.latest_score)}</b>
|
||||
</span>
|
||||
<span>
|
||||
<small>변화</small>
|
||||
<b>{formatDelta(learner.score_delta)}</b>
|
||||
</span>
|
||||
<span>
|
||||
<small>평균 라포</small>
|
||||
<b>{formatScore(learner.avg_rapport == null ? null : (learner.avg_rapport + 1) / 2)}</b>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="pf-growth-bars" aria-label="회기별 적절성 추이">
|
||||
{learner.points.map((point) => (
|
||||
<GrowthBar point={point} key={point.session_id} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="pf-growth-card__tags">
|
||||
{learner.top_techniques.length > 0 ? (
|
||||
learner.top_techniques.map((tag) => <span key={tag}>{tag}</span>)
|
||||
) : (
|
||||
<span>기법 태그 부족</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pf-growth-card__points">
|
||||
{recentPoints.length > 0 ? (
|
||||
recentPoints.map((point) => (
|
||||
<div className="pf-growth-point" key={point.session_id}>
|
||||
<b>
|
||||
{point.persona_code} · {point.session_no}회기
|
||||
</b>
|
||||
<span>
|
||||
{formatScore(point.score)} · 기법 {point.technique_count} · 점검 {point.watch_count}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="pf-growth-point">
|
||||
<b>회기 평가 부족</b>
|
||||
<span>종료 회기와 턴별 평가가 필요합니다.</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function GrowthBar({ point }: { point: TeacherGrowthPoint }) {
|
||||
const hasScore = typeof point.score === "number" && !Number.isNaN(point.score);
|
||||
const height = hasScore ? Math.max(10, Math.round((point.score ?? 0) * 100)) : 10;
|
||||
return (
|
||||
<span
|
||||
className={`pf-growth-bar ${hasScore ? "" : "is-empty"}`}
|
||||
title={`${point.persona_code} ${point.session_no}회기 · ${formatScore(point.score)}`}
|
||||
>
|
||||
<i style={{ height: `${height}%` }} />
|
||||
<small>{point.session_no}</small>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SafetyAlertRow({ alert }: { alert: TeacherSafetyAlert }) {
|
||||
return (
|
||||
<article className="pf-alert">
|
||||
<span className="pf-alert__ic" aria-hidden="true">
|
||||
<Icon name="alert" size={16} />
|
||||
</span>
|
||||
<div className="pf-alert__main">
|
||||
<b>{alert.learner_label}</b>
|
||||
<span>
|
||||
{alert.persona_code || "세션"} · 위험도 {alert.ko_risk_level} ·{" "}
|
||||
{formatDateTime(alert.created_at)}
|
||||
</span>
|
||||
<code>{alert.session_id}</code>
|
||||
</div>
|
||||
<div className="pf-alert__resource">
|
||||
<span>{alert.resource_title}</span>
|
||||
<b>{alert.resource_number}</b>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
const PF_CSS = `
|
||||
.pf-root{
|
||||
max-width:var(--maxw);
|
||||
|
|
@ -526,7 +933,7 @@ const PF_CSS = `
|
|||
}
|
||||
.pf-kpis{
|
||||
display:grid;
|
||||
grid-template-columns:repeat(2,minmax(0,1fr));
|
||||
grid-template-columns:repeat(3,minmax(0,1fr));
|
||||
border:1px solid var(--hair);
|
||||
border-radius:var(--radius);
|
||||
overflow:hidden;
|
||||
|
|
@ -541,8 +948,8 @@ const PF_CSS = `
|
|||
grid-template-columns:minmax(0,1fr) auto;
|
||||
gap:4px 10px;
|
||||
}
|
||||
.pf-kpi:nth-child(even){border-left:1px solid var(--hair);}
|
||||
.pf-kpi:nth-child(n+3){border-top:1px solid var(--hair);}
|
||||
.pf-kpi:nth-child(n+2){border-left:1px solid var(--hair);}
|
||||
.pf-kpi:nth-child(n+4){border-top:1px solid var(--hair);}
|
||||
.pf-kpi__ic{
|
||||
grid-column:2;
|
||||
grid-row:1 / span 3;
|
||||
|
|
@ -609,6 +1016,213 @@ const PF_CSS = `
|
|||
padding:0;
|
||||
overflow:hidden;
|
||||
}
|
||||
.pf-draft-panel{
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
gap:10px;
|
||||
padding:12px;
|
||||
}
|
||||
.pf-draft-toolbar{
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:space-between;
|
||||
gap:10px;
|
||||
min-width:0;
|
||||
}
|
||||
.pf-draft-toolbar span{
|
||||
color:var(--text-muted);
|
||||
font-size:var(--fs-xs);
|
||||
font-weight:700;
|
||||
}
|
||||
.pf-draft-json{
|
||||
width:100%;
|
||||
min-height:240px;
|
||||
max-height:min(420px,48vh);
|
||||
resize:vertical;
|
||||
overflow:auto;
|
||||
padding:11px 12px;
|
||||
border:1px solid var(--line-strong);
|
||||
border-radius:var(--radius);
|
||||
background:var(--bg);
|
||||
color:var(--text-strong);
|
||||
font-family:var(--font-num);
|
||||
font-size:11px;
|
||||
line-height:1.55;
|
||||
outline:none;
|
||||
}
|
||||
.pf-draft-json:focus{
|
||||
border-color:var(--accent);
|
||||
box-shadow:0 0 0 3px var(--accent-tint);
|
||||
}
|
||||
.pf-draft-status{
|
||||
margin:0;
|
||||
color:var(--accent-deep);
|
||||
font-size:var(--fs-xs);
|
||||
font-weight:700;
|
||||
}
|
||||
.pf-draft-status.is-error{
|
||||
color:var(--crit-text);
|
||||
}
|
||||
.pf-draft-actions{
|
||||
display:flex;
|
||||
justify-content:flex-end;
|
||||
gap:8px;
|
||||
min-width:0;
|
||||
}
|
||||
.pf-draft-actions .vg-btn{
|
||||
min-width:84px;
|
||||
}
|
||||
.pf-section--growth{
|
||||
min-width:0;
|
||||
}
|
||||
.pf-growth-panel{
|
||||
background:linear-gradient(180deg,var(--bg-surface),color-mix(in srgb,var(--accent-tint) 18%,var(--bg-surface)));
|
||||
}
|
||||
.pf-growth-list{
|
||||
display:grid;
|
||||
grid-template-columns:repeat(3,minmax(0,1fr));
|
||||
gap:12px;
|
||||
padding:12px;
|
||||
}
|
||||
.pf-growth-card{
|
||||
min-width:0;
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
gap:12px;
|
||||
padding:13px;
|
||||
border:1px solid var(--paper-2);
|
||||
border-radius:var(--radius);
|
||||
background:var(--bg-surface);
|
||||
box-shadow:var(--shadow-sm);
|
||||
}
|
||||
.pf-growth-card__top{
|
||||
display:flex;
|
||||
align-items:flex-start;
|
||||
justify-content:space-between;
|
||||
gap:10px;
|
||||
min-width:0;
|
||||
}
|
||||
.pf-growth-card__id{
|
||||
min-width:0;
|
||||
display:grid;
|
||||
gap:3px;
|
||||
}
|
||||
.pf-growth-card__id b{
|
||||
color:var(--text-strong);
|
||||
font-size:var(--fs-sm);
|
||||
line-height:1.35;
|
||||
overflow:hidden;
|
||||
text-overflow:ellipsis;
|
||||
white-space:nowrap;
|
||||
}
|
||||
.pf-growth-card__id span,
|
||||
.pf-growth-card__metrics small,
|
||||
.pf-growth-point span{
|
||||
color:var(--text-muted);
|
||||
font-size:12px;
|
||||
line-height:1.4;
|
||||
}
|
||||
.pf-growth-card__metrics{
|
||||
display:grid;
|
||||
grid-template-columns:repeat(3,minmax(0,1fr));
|
||||
gap:8px;
|
||||
}
|
||||
.pf-growth-card__metrics span{
|
||||
min-width:0;
|
||||
display:grid;
|
||||
gap:3px;
|
||||
padding:9px 10px;
|
||||
border:1px solid var(--paper-2);
|
||||
border-radius:var(--radius-sm);
|
||||
background:var(--bg-surface-2);
|
||||
}
|
||||
.pf-growth-card__metrics b{
|
||||
color:var(--text-strong);
|
||||
font-family:var(--font-num);
|
||||
font-size:15px;
|
||||
line-height:1.15;
|
||||
white-space:nowrap;
|
||||
}
|
||||
.pf-growth-bars{
|
||||
height:82px;
|
||||
display:flex;
|
||||
align-items:flex-end;
|
||||
gap:6px;
|
||||
padding:8px 8px 6px;
|
||||
border:1px solid var(--paper-2);
|
||||
border-radius:var(--radius-sm);
|
||||
background:color-mix(in srgb,var(--bg-surface-2) 82%,transparent);
|
||||
}
|
||||
.pf-growth-bar{
|
||||
flex:1 1 0;
|
||||
min-width:14px;
|
||||
height:100%;
|
||||
display:grid;
|
||||
grid-template-rows:minmax(0,1fr) 14px;
|
||||
gap:4px;
|
||||
align-items:end;
|
||||
}
|
||||
.pf-growth-bar i{
|
||||
display:block;
|
||||
width:100%;
|
||||
min-height:6px;
|
||||
border-radius:6px 6px 3px 3px;
|
||||
background:linear-gradient(180deg,var(--accent),var(--accent-deep));
|
||||
}
|
||||
.pf-growth-bar.is-empty i{
|
||||
background:repeating-linear-gradient(135deg,var(--paper-2),var(--paper-2) 3px,var(--hair) 3px,var(--hair) 6px);
|
||||
}
|
||||
.pf-growth-bar small{
|
||||
color:var(--text-muted);
|
||||
font-family:var(--font-num);
|
||||
font-size:10px;
|
||||
text-align:center;
|
||||
line-height:1;
|
||||
}
|
||||
.pf-growth-card__tags{
|
||||
min-height:26px;
|
||||
display:flex;
|
||||
flex-wrap:wrap;
|
||||
gap:6px;
|
||||
align-content:flex-start;
|
||||
}
|
||||
.pf-growth-card__tags span{
|
||||
max-width:100%;
|
||||
padding:4px 7px;
|
||||
border:1px solid var(--paper-2);
|
||||
border-radius:999px;
|
||||
color:var(--text-body);
|
||||
background:var(--bg-surface-2);
|
||||
font-size:11px;
|
||||
line-height:1.2;
|
||||
overflow:hidden;
|
||||
text-overflow:ellipsis;
|
||||
white-space:nowrap;
|
||||
}
|
||||
.pf-growth-card__points{
|
||||
display:grid;
|
||||
gap:7px;
|
||||
}
|
||||
.pf-growth-point{
|
||||
min-width:0;
|
||||
display:grid;
|
||||
grid-template-columns:minmax(86px,.5fr) minmax(0,1fr);
|
||||
gap:8px;
|
||||
align-items:center;
|
||||
}
|
||||
.pf-growth-point b{
|
||||
color:var(--text-strong);
|
||||
font-size:12px;
|
||||
line-height:1.35;
|
||||
overflow:hidden;
|
||||
text-overflow:ellipsis;
|
||||
white-space:nowrap;
|
||||
}
|
||||
.pf-growth-point span{
|
||||
overflow:hidden;
|
||||
text-overflow:ellipsis;
|
||||
white-space:nowrap;
|
||||
}
|
||||
.pf-empty{
|
||||
min-height:118px;
|
||||
display:grid;
|
||||
|
|
@ -707,6 +1321,63 @@ const PF_CSS = `
|
|||
min-width:72px;
|
||||
padding-inline:10px;
|
||||
}
|
||||
.pf-alerts{
|
||||
max-height:min(300px,38vh);
|
||||
overflow:auto;
|
||||
scrollbar-gutter:stable;
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
}
|
||||
.pf-alert{
|
||||
display:grid;
|
||||
grid-template-columns:auto minmax(0,1fr) auto;
|
||||
gap:10px;
|
||||
align-items:center;
|
||||
padding:12px;
|
||||
border-top:1px solid var(--paper-2);
|
||||
background:color-mix(in srgb,var(--warn-tint) 34%,transparent);
|
||||
}
|
||||
.pf-alert:first-child{border-top:0;}
|
||||
.pf-alert__ic{
|
||||
width:30px;
|
||||
height:30px;
|
||||
display:grid;
|
||||
place-items:center;
|
||||
border-radius:var(--radius);
|
||||
color:var(--warn-text);
|
||||
background:var(--warn-tint);
|
||||
}
|
||||
.pf-alert__main{
|
||||
min-width:0;
|
||||
display:grid;
|
||||
gap:3px;
|
||||
}
|
||||
.pf-alert__main b{
|
||||
color:var(--text-strong);
|
||||
font-size:var(--fs-sm);
|
||||
}
|
||||
.pf-alert__main span,
|
||||
.pf-alert__main code,
|
||||
.pf-alert__resource span{
|
||||
color:var(--text-muted);
|
||||
font-size:12px;
|
||||
}
|
||||
.pf-alert__main code{
|
||||
overflow:hidden;
|
||||
text-overflow:ellipsis;
|
||||
white-space:nowrap;
|
||||
}
|
||||
.pf-alert__resource{
|
||||
display:grid;
|
||||
gap:2px;
|
||||
justify-items:end;
|
||||
min-width:86px;
|
||||
}
|
||||
.pf-alert__resource b{
|
||||
color:var(--warn-text);
|
||||
font-family:var(--font-num);
|
||||
font-size:18px;
|
||||
}
|
||||
.pf-session{
|
||||
display:grid;
|
||||
grid-template-columns:8px minmax(0,1fr);
|
||||
|
|
@ -831,9 +1502,11 @@ const PF_CSS = `
|
|||
.pf-kpis{
|
||||
grid-template-columns:repeat(4,minmax(0,1fr));
|
||||
}
|
||||
.pf-kpi:nth-child(even),
|
||||
.pf-kpi + .pf-kpi{border-left:1px solid var(--hair);}
|
||||
.pf-kpi:nth-child(n+3){border-top:0;}
|
||||
.pf-kpi:nth-child(n+2){border-left:1px solid var(--hair);}
|
||||
.pf-kpi:nth-child(n+4){border-top:0;}
|
||||
.pf-growth-list{
|
||||
grid-template-columns:repeat(2,minmax(0,1fr));
|
||||
}
|
||||
.pf-list,
|
||||
.pf-personas{
|
||||
max-height:360px;
|
||||
|
|
@ -851,8 +1524,12 @@ const PF_CSS = `
|
|||
width:100%;
|
||||
}
|
||||
.pf-kpis{grid-template-columns:repeat(2,minmax(0,1fr));}
|
||||
.pf-kpi:nth-child(odd){border-left:0;}
|
||||
.pf-kpi:nth-child(n+2){border-left:0;}
|
||||
.pf-kpi:nth-child(even){border-left:1px solid var(--hair);}
|
||||
.pf-kpi:nth-child(n+3){border-top:1px solid var(--hair);}
|
||||
.pf-growth-list{
|
||||
grid-template-columns:1fr;
|
||||
}
|
||||
.pf-recent-list{
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
|
|
@ -911,6 +1588,27 @@ const PF_CSS = `
|
|||
.pf-persona__actions .vg-btn{
|
||||
width:100%;
|
||||
}
|
||||
.pf-growth-list{
|
||||
padding:10px;
|
||||
}
|
||||
.pf-growth-card__metrics{
|
||||
grid-template-columns:1fr;
|
||||
}
|
||||
.pf-growth-point{
|
||||
grid-template-columns:1fr;
|
||||
gap:2px;
|
||||
}
|
||||
.pf-growth-point b,
|
||||
.pf-growth-point span{
|
||||
white-space:normal;
|
||||
}
|
||||
.pf-alert{
|
||||
grid-template-columns:auto minmax(0,1fr);
|
||||
}
|
||||
.pf-alert__resource{
|
||||
grid-column:2;
|
||||
justify-items:start;
|
||||
}
|
||||
.pf-recent-list{
|
||||
padding:8px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import {
|
|||
apiWsUrl,
|
||||
personaApi,
|
||||
sessionApi,
|
||||
type CrisisResource,
|
||||
type PersonaSummary,
|
||||
type SessionDetailResponse,
|
||||
type SessionStage,
|
||||
|
|
@ -65,6 +66,8 @@ interface VoiceEvent {
|
|||
stage?: SessionStage;
|
||||
effective_openness?: number;
|
||||
safety_flagged?: boolean;
|
||||
crisis_resource?: CrisisResource | null;
|
||||
conversation_stopped?: boolean;
|
||||
}
|
||||
|
||||
interface Utterance {
|
||||
|
|
@ -403,6 +406,7 @@ export default function Session() {
|
|||
const [utterances, setUtterances] = useState<Utterance[]>([]);
|
||||
const [openness, setOpenness] = useState(0);
|
||||
const [safety, setSafety] = useState<string | null>(null);
|
||||
const [crisisResource, setCrisisResource] = useState<CrisisResource | null>(null);
|
||||
const [turnError, setTurnError] = useState<string | null>(null);
|
||||
|
||||
// ── 음성/턴 UI 상태 ──
|
||||
|
|
@ -452,6 +456,8 @@ export default function Session() {
|
|||
setStarted(false);
|
||||
setUtterances([]);
|
||||
setElapsed(0);
|
||||
setSafety(null);
|
||||
setCrisisResource(null);
|
||||
setResumedSessionLoaded(false);
|
||||
setStartError(null);
|
||||
}, [navigate, routeIsSessionId, routeParam]);
|
||||
|
|
@ -537,6 +543,22 @@ export default function Session() {
|
|||
setSignalSeq((seq) => [...seq.slice(-4), tone]);
|
||||
}, []);
|
||||
|
||||
const applyCrisisGate = useCallback((resource?: CrisisResource | null) => {
|
||||
const fallback: CrisisResource = {
|
||||
title: "자살예방상담전화 109",
|
||||
number: "109",
|
||||
message: "지금은 연습을 멈추고 실제 안전 확인이 먼저입니다.",
|
||||
};
|
||||
const next = resource ?? fallback;
|
||||
setCrisisResource(next);
|
||||
setSafety(next.message);
|
||||
setPaused(true);
|
||||
setMicOn(false);
|
||||
setVoiceStatus("idle");
|
||||
setVoiceDetail("위기 신호가 감지되어 연습을 중단했습니다.");
|
||||
pushSignal("warn", "위기 안전게이트 작동");
|
||||
}, [pushSignal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!routeIsSessionId) return;
|
||||
let alive = true;
|
||||
|
|
@ -567,6 +589,8 @@ export default function Session() {
|
|||
setElapsed(elapsedFromSession(detail));
|
||||
setStarted(true);
|
||||
setPaused(false);
|
||||
setSafety(null);
|
||||
setCrisisResource(null);
|
||||
setAvatarState("idle");
|
||||
setMicOn(false);
|
||||
setVoiceStatus("idle");
|
||||
|
|
@ -610,6 +634,8 @@ export default function Session() {
|
|||
setStage(res.stage);
|
||||
setOpenness(res.effective_openness);
|
||||
setUtterances([]);
|
||||
setSafety(null);
|
||||
setCrisisResource(null);
|
||||
setElapsed(0);
|
||||
if (res.degraded) {
|
||||
pushSignal("neutral", "서버 기록 제한");
|
||||
|
|
@ -700,9 +726,17 @@ export default function Session() {
|
|||
if (data.safety_flagged) {
|
||||
setSafety("방금 발화에서 위기 신호가 감지됐어요. 안전을 우선해 머물러 주세요.");
|
||||
}
|
||||
if (data.conversation_stopped || data.crisis_resource) {
|
||||
applyCrisisGate(data.crisis_resource);
|
||||
}
|
||||
},
|
||||
onSafety: () => {
|
||||
onSafety: (payload) => {
|
||||
setSafety("방금 발화에서 위기 신호가 감지됐어요. 안전을 우선해 머물러 주세요.");
|
||||
if (payload && typeof payload === "object") {
|
||||
const resource = (payload as { crisis_resource?: CrisisResource }).crisis_resource;
|
||||
const stopped = (payload as { conversation_stopped?: boolean }).conversation_stopped;
|
||||
if (resource || stopped) applyCrisisGate(resource);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -994,6 +1028,9 @@ export default function Session() {
|
|||
if (payload.safety_flagged) {
|
||||
setSafety("방금 발화에서 위기 신호가 감지됐어요. 안전을 우선해 머물러 주세요.");
|
||||
}
|
||||
if (payload.conversation_stopped || payload.crisis_resource) {
|
||||
applyCrisisGate(payload.crisis_resource);
|
||||
}
|
||||
const pendingId = pendingVoiceLearnerIdRef.current;
|
||||
if (pendingId != null) {
|
||||
setUtterances((prev) =>
|
||||
|
|
@ -1748,6 +1785,12 @@ export default function Session() {
|
|||
</span>
|
||||
<span className="sx-safety__text">
|
||||
<b>안전 점검</b> · {safety}
|
||||
{crisisResource ? (
|
||||
<span className="sx-crisis-resource">
|
||||
<strong>{crisisResource.title}</strong>
|
||||
<a href={`tel:${crisisResource.number}`}>{crisisResource.number}</a>
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</section>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ import {
|
|||
} from "../components/ui";
|
||||
import {
|
||||
sessionApi,
|
||||
type ReviewCaseWorksheet,
|
||||
type ReviewNonverbalEvent,
|
||||
type ReviewNote,
|
||||
type ReviewPoint,
|
||||
type ReviewTechnique,
|
||||
|
|
@ -56,6 +58,16 @@ function TechniqueChip({ tech }: { tech: ReviewTechnique }) {
|
|||
);
|
||||
}
|
||||
|
||||
function NonverbalChip({ event }: { event: ReviewNonverbalEvent }) {
|
||||
return (
|
||||
<span className={`sr-nonverbal sr-nonverbal--${event.kind}`} title={event.detail}>
|
||||
<span className="sr-nonverbal__dot" aria-hidden="true" />
|
||||
{event.label}
|
||||
<span className="sr-nonverbal__detail">{event.detail}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SupervisorCallout({ note }: { note: ReviewNote }) {
|
||||
const toneCls = note.tone === "good" ? "sr-note--ai" : "sr-note--warn";
|
||||
const iconName = note.tone === "good" ? "check" : "info";
|
||||
|
|
@ -112,6 +124,73 @@ function JumpablePoint({
|
|||
);
|
||||
}
|
||||
|
||||
function confidenceLabel(confidence: "none" | "low" | "medium") {
|
||||
if (confidence === "medium") return "근거 있음";
|
||||
if (confidence === "low") return "초안";
|
||||
return "빈칸";
|
||||
}
|
||||
|
||||
function CaseWorksheetCard({
|
||||
worksheet,
|
||||
onJump,
|
||||
}: {
|
||||
worksheet: ReviewCaseWorksheet | null | undefined;
|
||||
onJump: (id: string) => void;
|
||||
}) {
|
||||
const sections = worksheet?.sections ?? [];
|
||||
return (
|
||||
<Card className="sr-card sr-card--side sr-card--worksheet">
|
||||
<Kicker>사례개념화 워크시트</Kicker>
|
||||
<div className="sr-worksheet">
|
||||
{sections.length > 0 ? (
|
||||
sections.map((section) => (
|
||||
<section className="sr-ws-section" key={section.key}>
|
||||
<h3>{section.title}</h3>
|
||||
<div className="sr-ws-items">
|
||||
{section.items.map((item) => {
|
||||
const evidence = item.evidence[0];
|
||||
return (
|
||||
<div className="sr-ws-item" key={item.key}>
|
||||
<div className="sr-ws-item__head">
|
||||
<b>{item.label}</b>
|
||||
<span className={`sr-ws-badge sr-ws-badge--${item.confidence}`}>
|
||||
{confidenceLabel(item.confidence)}
|
||||
</span>
|
||||
</div>
|
||||
<p>{item.value || item.emptyReason || "근거 대기"}</p>
|
||||
{evidence ? (
|
||||
<button
|
||||
type="button"
|
||||
className="sr-ws-evidence"
|
||||
onClick={() => onJump(evidence.turnId)}
|
||||
>
|
||||
{evidence.speaker === "learner" ? "학습자" : "내담자"} · {evidence.quote}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
))
|
||||
) : (
|
||||
<EmptyBlock
|
||||
title="워크시트 대기"
|
||||
desc="저장된 축어록이 생기면 사례개념화 초안이 표시됩니다."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{(worksheet?.limitations ?? []).length > 0 ? (
|
||||
<div className="sr-ws-limitations">
|
||||
{worksheet!.limitations.slice(0, 2).map((item) => (
|
||||
<span key={item}>{item}</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SessionReview() {
|
||||
const { sessionId } = useParams<{ sessionId: string }>();
|
||||
const [data, setData] = useState<SessionReviewResponse | null>(null);
|
||||
|
|
@ -407,6 +486,12 @@ export default function SessionReview() {
|
|||
{turn.techniques.map((tech, i) => (
|
||||
<TechniqueChip key={`${tech.label}-${i}`} tech={tech} />
|
||||
))}
|
||||
{(turn.nonverbal ?? []).map((event, i) => (
|
||||
<NonverbalChip
|
||||
key={`${event.kind}-${event.detail}-${i}`}
|
||||
event={event}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p
|
||||
className={
|
||||
|
|
@ -517,6 +602,8 @@ export default function SessionReview() {
|
|||
) : null}
|
||||
</Card>
|
||||
|
||||
<CaseWorksheetCard worksheet={data.caseWorksheet} onJump={jumpToTurn} />
|
||||
|
||||
<div className={`sr-feedback${data.clientFeedback ? " sr-feedback--filled" : ""}`}>
|
||||
<div className="sr-feedback__kicker">
|
||||
<span className="sr-technique__dot" aria-hidden="true" />
|
||||
|
|
|
|||
|
|
@ -3,9 +3,23 @@
|
|||
.sr-root {
|
||||
width: min(100%, 1360px);
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
display: grid;
|
||||
gap: var(--sp-5);
|
||||
}
|
||||
.sr-root::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
inset: -90px -170px auto auto;
|
||||
width: min(620px, 56vw);
|
||||
height: 440px;
|
||||
background: var(--asset-warm-elements) center / cover no-repeat;
|
||||
opacity: .05;
|
||||
filter: saturate(.8);
|
||||
pointer-events: none;
|
||||
}
|
||||
.sr-root--empty {
|
||||
--sr-empty-tone: var(--neutral-sig);
|
||||
}
|
||||
|
|
@ -16,6 +30,11 @@
|
|||
align-items: center;
|
||||
gap: var(--sp-5);
|
||||
padding: var(--sp-4) var(--sp-5);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius);
|
||||
background:
|
||||
linear-gradient(180deg, color-mix(in srgb, var(--bg-surface) 86%, var(--accent-tint)), var(--bg-surface));
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.sr-head__id {
|
||||
min-width: 0;
|
||||
|
|
@ -109,12 +128,13 @@
|
|||
.sr-cols {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.38fr) minmax(320px, 0.72fr);
|
||||
grid-template-columns: minmax(0, 1.34fr) minmax(320px, 0.66fr);
|
||||
grid-template-areas:
|
||||
"overview rubric"
|
||||
"chart rubric"
|
||||
"flow good"
|
||||
"transcript growth"
|
||||
"transcript worksheet"
|
||||
"transcript feedback"
|
||||
"transcript session";
|
||||
gap: var(--sp-5);
|
||||
|
|
@ -149,6 +169,9 @@
|
|||
.sr-card--growth {
|
||||
grid-area: growth;
|
||||
}
|
||||
.sr-card--worksheet {
|
||||
grid-area: worksheet;
|
||||
}
|
||||
.sr-card--transcript {
|
||||
grid-area: transcript;
|
||||
}
|
||||
|
|
@ -157,6 +180,7 @@
|
|||
}
|
||||
.sr-card {
|
||||
min-width: 0;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.sr-card--side {
|
||||
align-self: start;
|
||||
|
|
@ -349,6 +373,7 @@
|
|||
.sr-card--transcript {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border-color: color-mix(in srgb, var(--accent) 14%, var(--border-subtle));
|
||||
}
|
||||
.sr-tx__head {
|
||||
display: flex;
|
||||
|
|
@ -525,6 +550,48 @@
|
|||
background: var(--warn-solid);
|
||||
}
|
||||
|
||||
.sr-nonverbal {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--hair);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--paper-1);
|
||||
color: var(--text-body);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
line-height: 1.5;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sr-nonverbal__dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
flex: none;
|
||||
background: var(--text-muted);
|
||||
}
|
||||
.sr-nonverbal__detail {
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-num);
|
||||
font-size: 10px;
|
||||
font-weight: 650;
|
||||
}
|
||||
.sr-nonverbal--silence .sr-nonverbal__dot {
|
||||
background: var(--warn-solid);
|
||||
}
|
||||
.sr-nonverbal--pace .sr-nonverbal__dot {
|
||||
background: var(--info-solid);
|
||||
}
|
||||
.sr-nonverbal--barge_in .sr-nonverbal__dot {
|
||||
background: var(--clay);
|
||||
}
|
||||
.sr-nonverbal--audio .sr-nonverbal__dot {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.sr-note {
|
||||
max-width: 68ch;
|
||||
margin-top: var(--sp-3);
|
||||
|
|
@ -535,7 +602,7 @@
|
|||
transform var(--dur-base) var(--ease-out);
|
||||
}
|
||||
.sr-note--ai {
|
||||
background: var(--accent-tint);
|
||||
background: color-mix(in srgb, var(--info-tint) 72%, var(--accent-tint));
|
||||
}
|
||||
.sr-note--warn {
|
||||
background: var(--warn-tint);
|
||||
|
|
@ -632,6 +699,109 @@
|
|||
font-size: 11px;
|
||||
}
|
||||
|
||||
.sr-worksheet {
|
||||
display: grid;
|
||||
gap: var(--sp-4);
|
||||
margin-top: var(--sp-4);
|
||||
}
|
||||
.sr-ws-section {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
.sr-ws-section h3 {
|
||||
margin: 0;
|
||||
color: var(--text-strong);
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 680;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.sr-ws-items {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.sr-ws-item {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 10px 11px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-surface-2);
|
||||
}
|
||||
.sr-ws-item__head {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.sr-ws-item__head b {
|
||||
min-width: 0;
|
||||
color: var(--text-strong);
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 680;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.sr-ws-badge {
|
||||
flex: none;
|
||||
padding: 2px 7px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 10.5px;
|
||||
font-weight: 680;
|
||||
line-height: 1.45;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sr-ws-badge--medium {
|
||||
color: var(--pos-text);
|
||||
background: var(--pos-tint);
|
||||
}
|
||||
.sr-ws-badge--low {
|
||||
color: var(--accent-deep);
|
||||
background: var(--accent-tint);
|
||||
}
|
||||
.sr-ws-badge--none {
|
||||
color: var(--text-muted);
|
||||
background: var(--paper-2);
|
||||
}
|
||||
.sr-ws-item p {
|
||||
margin: 0;
|
||||
color: var(--text-body);
|
||||
font-size: var(--fs-xs);
|
||||
line-height: 1.55;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.sr-ws-evidence {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--accent-deep);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
line-height: 1.5;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.sr-ws-evidence:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.sr-ws-limitations {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
margin-top: var(--sp-4);
|
||||
padding-top: var(--sp-3);
|
||||
border-top: 1px solid var(--hair);
|
||||
}
|
||||
.sr-ws-limitations span {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
/* Empty state: keep the card as low-contrast as the other placeholders so an
|
||||
unanswered feedback block never outweighs the page heading or actions. */
|
||||
.sr-feedback {
|
||||
|
|
@ -644,7 +814,9 @@
|
|||
/* Filled state: only a real client quote earns the high-contrast stage card. */
|
||||
.sr-feedback--filled {
|
||||
border-color: transparent;
|
||||
background: var(--bg-stage);
|
||||
background:
|
||||
linear-gradient(135deg, rgba(30, 39, 36, .95), rgba(30, 39, 36, .82)),
|
||||
var(--asset-warm-elements) center / cover no-repeat;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.sr-feedback__kicker {
|
||||
|
|
@ -749,7 +921,7 @@
|
|||
margin-top: var(--sp-4);
|
||||
padding: var(--sp-4);
|
||||
border-radius: var(--radius);
|
||||
background: var(--accent-tint);
|
||||
background: color-mix(in srgb, var(--warn-tint) 58%, var(--bg-surface-2));
|
||||
}
|
||||
.sr-nextline__lab {
|
||||
margin-bottom: 7px;
|
||||
|
|
@ -789,6 +961,7 @@
|
|||
"chart flow"
|
||||
"rubric rubric"
|
||||
"good growth"
|
||||
"worksheet worksheet"
|
||||
"feedback feedback"
|
||||
"transcript transcript"
|
||||
"session session";
|
||||
|
|
@ -799,6 +972,9 @@
|
|||
.sr-root {
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
.sr-root::before {
|
||||
display: none;
|
||||
}
|
||||
.sr-head {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--sp-4);
|
||||
|
|
@ -816,6 +992,7 @@
|
|||
"rubric"
|
||||
"good"
|
||||
"growth"
|
||||
"worksheet"
|
||||
"feedback"
|
||||
"transcript"
|
||||
"session";
|
||||
|
|
|
|||
|
|
@ -18,6 +18,13 @@
|
|||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
gap: var(--sp-4);
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
word-break: keep-all;
|
||||
overflow-wrap: break-word;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(251, 250, 248, 0.94), rgba(244, 242, 238, 0.9)),
|
||||
var(--asset-warm-elements) center / cover no-repeat;
|
||||
}
|
||||
.sx-page,
|
||||
.sx-page * {
|
||||
|
|
@ -26,6 +33,12 @@
|
|||
.sx-page--prestart {
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
overflow: auto;
|
||||
color: #eef4f2;
|
||||
background:
|
||||
radial-gradient(circle at 14% 18%, rgba(95, 150, 139, 0.2), transparent 26%),
|
||||
radial-gradient(circle at 86% 8%, rgba(176, 115, 92, 0.18), transparent 24%),
|
||||
linear-gradient(135deg, rgba(13, 24, 22, 0.96), rgba(28, 39, 36, 0.93)),
|
||||
var(--asset-warm-elements) center / cover no-repeat;
|
||||
}
|
||||
.sx-page--active {
|
||||
height: 100vh;
|
||||
|
|
@ -33,9 +46,16 @@
|
|||
grid-template-rows: minmax(0, 1fr) auto;
|
||||
padding: 14px;
|
||||
gap: 12px;
|
||||
background:
|
||||
radial-gradient(circle at 16% 14%, rgba(95, 150, 139, 0.2), transparent 27%),
|
||||
radial-gradient(circle at 82% 8%, rgba(176, 115, 92, 0.18), transparent 24%),
|
||||
linear-gradient(135deg, #111c1a, #1d2926 52%, #15211f);
|
||||
color: #eaf0f1;
|
||||
}
|
||||
.sx-page--active .sx-grid {
|
||||
height: auto;
|
||||
width: min(100%, 1460px);
|
||||
margin: 0 auto;
|
||||
}
|
||||
.sx-page--active .sx-head {
|
||||
display: none;
|
||||
|
|
@ -216,6 +236,9 @@
|
|||
gap: 12px;
|
||||
min-height: 0;
|
||||
}
|
||||
.sx-page--active .sx-col-center {
|
||||
grid-template-rows: minmax(230px, 0.42fr) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
/* ── LEFT: 세로 단계 트랙 ── */
|
||||
.sx-track {
|
||||
|
|
@ -419,6 +442,24 @@
|
|||
column-gap: 18px;
|
||||
row-gap: 8px;
|
||||
}
|
||||
.sx-page--active .sx-stage {
|
||||
background:
|
||||
radial-gradient(circle at 28% 50%, rgba(145, 200, 189, 0.18), transparent 42%),
|
||||
linear-gradient(135deg, rgba(11, 22, 20, 0.96), rgba(31, 43, 39, 0.93)),
|
||||
var(--asset-warm-elements) center / cover no-repeat;
|
||||
border-color: rgba(255, 255, 255, 0.11);
|
||||
box-shadow: 0 18px 44px rgba(7, 16, 14, 0.28);
|
||||
grid-template-columns: minmax(184px, 256px) minmax(0, 1fr);
|
||||
padding: 20px 24px;
|
||||
}
|
||||
.sx-page--active .sx-stage::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||
border-radius: calc(var(--radius-lg) - 2px);
|
||||
pointer-events: none;
|
||||
}
|
||||
/* stage 상단 좌측 상태 라벨 (어두운 배경 위 옅은 텍스트) */
|
||||
.sx-stage__top {
|
||||
width: 100%;
|
||||
|
|
@ -526,17 +567,17 @@
|
|||
min-width: 0;
|
||||
}
|
||||
.sx-page--active .sx-stage .vg-avatar {
|
||||
width: clamp(136px, 12vw, 184px) !important;
|
||||
width: clamp(176px, 15vw, 238px) !important;
|
||||
}
|
||||
.sx-page--active .sx-stage .vg-avatar__label {
|
||||
display: none;
|
||||
}
|
||||
.sx-page--active .sx-stage .vg-avatar__stage {
|
||||
height: clamp(136px, 12vw, 184px) !important;
|
||||
height: clamp(176px, 15vw, 238px) !important;
|
||||
}
|
||||
.sx-page--active .sx-stage .vg-avatar__svg {
|
||||
width: clamp(136px, 12vw, 184px) !important;
|
||||
height: clamp(136px, 12vw, 184px) !important;
|
||||
width: clamp(176px, 15vw, 238px) !important;
|
||||
height: clamp(176px, 15vw, 238px) !important;
|
||||
}
|
||||
.sx-page--active .sx-stage .vg-avatar__meta {
|
||||
display: none;
|
||||
|
|
@ -554,6 +595,11 @@
|
|||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.sx-page--active .sx-transcript {
|
||||
background: rgba(251, 250, 248, 0.97);
|
||||
border-color: rgba(255, 255, 255, 0.16);
|
||||
box-shadow: 0 14px 34px rgba(7, 16, 14, 0.16);
|
||||
}
|
||||
.sx-transcript__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -987,6 +1033,25 @@
|
|||
.sx-safety__text b {
|
||||
font-weight: 600;
|
||||
}
|
||||
.sx-crisis-resource {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid color-mix(in srgb, var(--warn-text) 18%, transparent);
|
||||
}
|
||||
.sx-crisis-resource strong {
|
||||
font-size: 12px;
|
||||
color: var(--warn-text);
|
||||
}
|
||||
.sx-crisis-resource a {
|
||||
width: fit-content;
|
||||
color: var(--warn-text);
|
||||
font-family: var(--font-num);
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* ── 하단 컨트롤 바 (80px) ── */
|
||||
.sx-controlbar {
|
||||
|
|
@ -1003,6 +1068,47 @@
|
|||
gap: 14px;
|
||||
min-width: 0;
|
||||
}
|
||||
.sx-page--active .sx-controlbar {
|
||||
width: min(100%, 1460px);
|
||||
margin: 0 auto;
|
||||
background: rgba(16, 28, 26, 0.94);
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
box-shadow: 0 14px 36px rgba(7, 16, 14, 0.24);
|
||||
color: rgba(238, 244, 242, 0.86);
|
||||
}
|
||||
.sx-page--active .sx-mic-block__l,
|
||||
.sx-page--active .sx-seg-block__label {
|
||||
color: #eef4f2;
|
||||
}
|
||||
.sx-page--active .sx-mic-block__h {
|
||||
color: rgba(238, 244, 242, 0.58);
|
||||
}
|
||||
.sx-page--active .sx-segmented {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.sx-page--active .sx-segmented button {
|
||||
color: rgba(238, 244, 242, 0.68);
|
||||
}
|
||||
.sx-page--active .sx-segmented button:hover {
|
||||
color: #ffffff;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.sx-page--active .sx-segmented button.is-on {
|
||||
background: rgba(145, 200, 189, 0.2);
|
||||
color: #ffffff;
|
||||
}
|
||||
.sx-page--active .sx-cb-sep {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
.sx-page--active .sx-pause {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
color: #eef4f2;
|
||||
}
|
||||
.sx-page--active .sx-pause:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
/* 마이크 (주 컨트롤, 음성 호흡 펄스) — 원형 예외 허용 */
|
||||
.sx-mic-block {
|
||||
display: flex;
|
||||
|
|
@ -1226,28 +1332,80 @@
|
|||
align-self: start;
|
||||
justify-self: center;
|
||||
text-align: left;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
position: relative;
|
||||
background:
|
||||
linear-gradient(132deg, rgba(15, 28, 26, 0.96), rgba(31, 44, 40, 0.92) 56%, rgba(46, 55, 49, 0.86)),
|
||||
var(--asset-warm-elements) center / cover no-repeat;
|
||||
border: 1px solid rgba(255, 255, 255, 0.11);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
box-shadow: 0 18px 52px rgba(6, 14, 13, 0.34);
|
||||
color: #eef4f2;
|
||||
overflow: hidden;
|
||||
}
|
||||
.sx-page--prestart .sx-head {
|
||||
width: min(1180px, 100%);
|
||||
margin: 0 auto;
|
||||
}
|
||||
.sx-page--prestart .sx-head__title,
|
||||
.sx-page--prestart .sx-ph.is-cur .sx-ph__name {
|
||||
color: #f3f8f6;
|
||||
}
|
||||
.sx-page--prestart .sx-head__title em {
|
||||
color: #91c8bd;
|
||||
}
|
||||
.sx-page--prestart .sx-head__sub,
|
||||
.sx-page--prestart .sx-ph__name,
|
||||
.sx-page--prestart .sx-ph__t {
|
||||
color: rgba(238, 244, 242, 0.62);
|
||||
}
|
||||
.sx-page--prestart .sx-ph__dot {
|
||||
background: rgba(255, 255, 255, 0.09);
|
||||
border-color: rgba(255, 255, 255, 0.22);
|
||||
}
|
||||
.sx-page--prestart .sx-ph__link {
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
.sx-page--prestart .sx-ph__link.is-fill {
|
||||
background: rgba(145, 200, 189, 0.74);
|
||||
}
|
||||
.sx-page--prestart .sx-prestart::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.08), transparent 38%),
|
||||
radial-gradient(circle at 20% 22%, rgba(126, 184, 173, 0.22), transparent 32%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.sx-prestart__visual {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: var(--sp-3);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
align-self: stretch;
|
||||
align-content: center;
|
||||
padding: var(--sp-4);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: var(--radius);
|
||||
background:
|
||||
radial-gradient(circle at 50% 45%, rgba(145, 200, 189, 0.2), transparent 52%),
|
||||
rgba(251, 250, 248, 0.06);
|
||||
}
|
||||
.sx-prestart__visual .vg-avatar {
|
||||
justify-self: center;
|
||||
}
|
||||
.sx-prestart__main {
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.sx-prestart__eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--accent-deep);
|
||||
color: #a8d4ca;
|
||||
font-family: var(--font-num);
|
||||
font-size: var(--fs-kicker);
|
||||
font-weight: 700;
|
||||
|
|
@ -1264,13 +1422,13 @@
|
|||
font-size: var(--fs-h2);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
color: var(--text-strong);
|
||||
color: #f4f8f7;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.sx-prestart__desc {
|
||||
margin-top: var(--sp-3);
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--text-body);
|
||||
color: rgba(238, 244, 242, 0.76);
|
||||
line-height: 1.6;
|
||||
max-width: 58ch;
|
||||
}
|
||||
|
|
@ -1283,17 +1441,20 @@
|
|||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__facts {
|
||||
border-color: rgba(255, 255, 255, 0.13);
|
||||
}
|
||||
.sx-prestart__facts div {
|
||||
min-width: 0;
|
||||
}
|
||||
.sx-prestart__facts dt {
|
||||
color: var(--text-muted);
|
||||
color: rgba(238, 244, 242, 0.52);
|
||||
font-size: 11.5px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.sx-prestart__facts dd {
|
||||
margin: 6px 0 0;
|
||||
color: var(--text-strong);
|
||||
color: #f4f8f7;
|
||||
font-size: 13.5px;
|
||||
font-weight: 650;
|
||||
line-height: 1.4;
|
||||
|
|
@ -1314,20 +1475,20 @@
|
|||
min-height: 26px;
|
||||
padding: 0 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--accent-tint);
|
||||
color: var(--accent-deep);
|
||||
background: rgba(145, 200, 189, 0.16);
|
||||
color: #bfe0d9;
|
||||
font-family: var(--font-num);
|
||||
font-size: 11.5px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.sx-prestart__chips span.is-clay {
|
||||
background: var(--clay-tint);
|
||||
color: var(--clay-deep);
|
||||
background: rgba(204, 143, 119, 0.17);
|
||||
color: #f0bda9;
|
||||
}
|
||||
.sx-prestart__note {
|
||||
max-width: 460px;
|
||||
font-size: 12.5px;
|
||||
color: var(--text-muted);
|
||||
color: rgba(238, 244, 242, 0.6);
|
||||
line-height: 1.55;
|
||||
margin-top: calc(var(--sp-3) * -1);
|
||||
}
|
||||
|
|
@ -1343,7 +1504,7 @@
|
|||
margin-top: var(--sp-5);
|
||||
}
|
||||
.sx-prestart__actions span {
|
||||
color: var(--text-muted);
|
||||
color: rgba(238, 244, 242, 0.58);
|
||||
font-size: var(--fs-xs);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
|
@ -1353,7 +1514,12 @@
|
|||
display: grid;
|
||||
align-content: center;
|
||||
gap: var(--sp-4);
|
||||
padding: var(--sp-3) 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
padding: var(--sp-4);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: var(--radius);
|
||||
background: rgba(251, 250, 248, 0.07);
|
||||
}
|
||||
.sx-prestart__plan ol {
|
||||
list-style: none;
|
||||
|
|
@ -1378,7 +1544,7 @@
|
|||
}
|
||||
.sx-prestart__plan p {
|
||||
margin: 0;
|
||||
color: var(--text-body);
|
||||
color: rgba(238, 244, 242, 0.74);
|
||||
font-size: var(--fs-xs);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue