feat: P1 풀빌드 — React 프론트 7화면 + 백엔드 상담루프·평가·음성·RAG
web (Vite+React19+TS, Cloudflare Pages 배포): - 디자인토큰(세이지틸/테라코타 SSOT), 앱셸, 공통 UI 프리미티브 - 7화면: 로그인/학습자홈/상담세션/회기리뷰/교수자/관리자/설정 - ClientAvatar: SVG 반구상 흉상 4상태 + RMS 립싱크 + 6파라미터 정서 - 회기리뷰는 외부 레퍼런스 디자인을 Vignette 토큰으로 리스킨 api (FastAPI): - 게이트웨이 /v1/generate·/v1/stream 어댑터(상주풀/EngineSession 보존) - services: 페르소나 L0~L6 빌더 / 결정론 상태머신 / 가드레일 / 턴 오케스트레이터 / 회기간 메모리 / 평가AI / 음성 / RAG - store: DB off 폴백(in-memory), sessions 실구현 검증: - web: node22 tsc+vite build 통과(node23 segfault 회피), Pages 배포 200 - api: app.main import 통과 - 핫픽스: Topbar initials undefined-safe (undefined.trim 크래시) - E2E: 서연(P1) 상담 1턴 — 좋은/나쁜 상담에 차등 반응 실증
This commit is contained in:
parent
859ab26314
commit
24b1b7a6e1
84 changed files with 19645 additions and 107 deletions
495
apps/web/src/pages/Admin.tsx
Normal file
495
apps/web/src/pages/Admin.tsx
Normal file
|
|
@ -0,0 +1,495 @@
|
|||
/* =====================================================================
|
||||
Admin — 운영 콘솔 (/admin). data-role=admin → accent 슬레이트(§6.2).
|
||||
§6.6: "시스템이 건강한가 · 누가 무엇을 했는가."
|
||||
- 운영 KPI 5 (정상은 무채, 오류만 강조)
|
||||
- 서비스 헬스 라인
|
||||
- 사용·세션 수집(시나리오 분포 / 역할별)
|
||||
- 음성·AI 사용 + 비용 (claude -p / API 텔레메트리, 예산 게이지)
|
||||
- 세션 추이 단일 라인차트
|
||||
- 교수 활동 감사 요약 + audit log(데이터주권 geo 포함)
|
||||
- 관리 진입(페르소나·엔진 설정 등)
|
||||
[주의] 데이터는 전부 mock(data.ts). 백엔드 텔레메트리 붙으면 교체.
|
||||
===================================================================== */
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { AppShell } from "../components/shell/AppShell";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Dot,
|
||||
Icon,
|
||||
Kicker,
|
||||
ProgressBar,
|
||||
} from "../components/ui";
|
||||
import { clamp01, formatPercent } from "../lib/format";
|
||||
import { AdminIcon, type LocalIconName } from "./admin/AdminIcons";
|
||||
import { AuditTable } from "./admin/AuditTable";
|
||||
import { TrendChart } from "./admin/TrendChart";
|
||||
import {
|
||||
AUDIT_LOG,
|
||||
DISTRIB,
|
||||
KPIS,
|
||||
MOCK,
|
||||
PROF_AUDIT,
|
||||
ROLES,
|
||||
SERVICES,
|
||||
TREND_7D,
|
||||
USAGE,
|
||||
type AuditAction,
|
||||
type HealthStatus,
|
||||
} from "./admin/data";
|
||||
import "./admin/admin.css";
|
||||
|
||||
const PAGE_SIZE = 5;
|
||||
|
||||
/** 헬스 상태 → 게이지 fill 클래스 보조 */
|
||||
function healthFillClass(status: HealthStatus): string {
|
||||
if (status === "warn") return "vgad-hrow__fill vgad-hrow__fill--warn";
|
||||
if (status === "crit") return "vgad-hrow__fill vgad-hrow__fill--crit";
|
||||
return "vgad-hrow__fill";
|
||||
}
|
||||
|
||||
/** 헬스 상태 → 배지 톤 */
|
||||
function healthBadgeTone(status: HealthStatus): "pos" | "warn" | "crit" {
|
||||
if (status === "warn") return "warn";
|
||||
if (status === "crit") return "crit";
|
||||
return "pos";
|
||||
}
|
||||
|
||||
/** 관리 진입 항목 — 라우터에 실재하는 목적지만 to, 나머지는 안내 콜아웃 */
|
||||
interface MgmtEntry {
|
||||
icon: LocalIconName;
|
||||
title: string;
|
||||
desc: string;
|
||||
/** 실 라우트(있으면 Link) */
|
||||
to?: string;
|
||||
/** 신규 배지 수 */
|
||||
badge?: number;
|
||||
}
|
||||
|
||||
const MGMT: MgmtEntry[] = [
|
||||
{
|
||||
icon: "persona",
|
||||
title: "페르소나 관리",
|
||||
desc: "가상 내담자 풀, 정서 파라미터, 미성년 라벨 검수.",
|
||||
badge: 3,
|
||||
},
|
||||
{
|
||||
icon: "sliders",
|
||||
title: "엔진 설정",
|
||||
desc: "추론 모델·가드레일 임계치·추론 지역(KR/US) 정책.",
|
||||
to: "/settings",
|
||||
},
|
||||
{
|
||||
icon: "database",
|
||||
title: "데이터 관리",
|
||||
desc: "세션 기록·평가 데이터 보존, 익명화, 내보내기 정책.",
|
||||
},
|
||||
{
|
||||
icon: "megaphone",
|
||||
title: "공지 관리",
|
||||
desc: "학기 일정, 점검 안내, 역할별 배너 공지 발행.",
|
||||
},
|
||||
];
|
||||
|
||||
export default function Admin() {
|
||||
// ── audit log 인터랙션 state ──
|
||||
const [filter, setFilter] = useState<AuditAction | "all">("all");
|
||||
const [query, setQuery] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
// 필터 + 검색 적용 (mock 클라이언트 필터링)
|
||||
const matched = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return AUDIT_LOG.filter((r) => {
|
||||
if (filter !== "all" && r.action !== filter) return false;
|
||||
if (!q) return true;
|
||||
return (
|
||||
r.actorName.toLowerCase().includes(q) ||
|
||||
r.target.toLowerCase().includes(q) ||
|
||||
r.targetMeta.toLowerCase().includes(q) ||
|
||||
r.id.toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
}, [filter, query]);
|
||||
|
||||
const pageCount = Math.max(1, Math.ceil(matched.length / PAGE_SIZE));
|
||||
const safePage = Math.min(page, pageCount);
|
||||
const rangeFrom = matched.length === 0 ? 0 : (safePage - 1) * PAGE_SIZE + 1;
|
||||
const rangeTo = Math.min(safePage * PAGE_SIZE, matched.length);
|
||||
const rows = matched.slice(rangeFrom === 0 ? 0 : rangeFrom - 1, rangeTo);
|
||||
|
||||
// 필터/검색 변경 시 1페이지로 (state 동기화는 핸들러에서)
|
||||
const changeFilter = (key: AuditAction | "all") => {
|
||||
setFilter(key);
|
||||
setPage(1);
|
||||
};
|
||||
const changeQuery = (q: string) => {
|
||||
setQuery(q);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const pulseClass =
|
||||
MOCK.statusLevel === "warn"
|
||||
? "vgad-statusline__pulse vgad-statusline__pulse--warn"
|
||||
: MOCK.statusLevel === "crit"
|
||||
? "vgad-statusline__pulse vgad-statusline__pulse--crit"
|
||||
: "vgad-statusline__pulse";
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
{/* ── 페이지 헤드 ── */}
|
||||
<div className="vgad-head">
|
||||
<div>
|
||||
<Kicker>운영 콘솔</Kicker>
|
||||
<h1 className="vgad-head__title">시스템이 정상 운영 중입니다.</h1>
|
||||
<p className="vgad-head__lead">
|
||||
플랫폼 가동 상태와 전체 사용·세션 흐름을 한눈에. 교수자가 무엇을
|
||||
열람했는지, 추론이 어디에서 일어났는지까지 추적합니다.
|
||||
</p>
|
||||
</div>
|
||||
<div className="vgad-head__when">
|
||||
<b className="tabular">{MOCK.syncedAt}</b>
|
||||
최근 동기화 {MOCK.syncAgo}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 한 줄 상태 요약 ── */}
|
||||
<div className="vgad-statusline" role="status">
|
||||
<span className={pulseClass} aria-hidden="true" />
|
||||
<span className="vgad-statusline__msg">
|
||||
모든 핵심 서비스가 <b>정상 가동</b> 중입니다.{" "}
|
||||
<span className="vgad-statusline__detail">{MOCK.statusDetail}</span>
|
||||
</span>
|
||||
<span className="vgad-statusline__ts tabular">uptime {MOCK.uptime}</span>
|
||||
</div>
|
||||
|
||||
{/* ── 운영 KPI ── */}
|
||||
<div className="vgad-kpis">
|
||||
{KPIS.map((k) => {
|
||||
const numCls =
|
||||
"vgad-kpi__n" +
|
||||
(k.tone === "warn"
|
||||
? " vgad-kpi__n--warn"
|
||||
: k.tone === "crit"
|
||||
? " vgad-kpi__n--crit"
|
||||
: "");
|
||||
const dCls =
|
||||
"vgad-kpi__d" + (k.noteTone ? ` vgad-kpi__d--${k.noteTone}` : "");
|
||||
// note 의 첫 토큰(부호/퍼센트)만 강조, 나머지는 무채
|
||||
const [head, ...tail] = (k.note ?? "").split(" ");
|
||||
return (
|
||||
<div className="vgad-kpi" key={k.label}>
|
||||
<div className="vgad-kpi__l">{k.label}</div>
|
||||
<div className={numCls}>
|
||||
{k.value}
|
||||
{k.unit ? <span className="vgad-kpi__u">{k.unit}</span> : null}
|
||||
</div>
|
||||
{k.note ? (
|
||||
<div className={dCls}>
|
||||
{k.noteTone ? <b>{head}</b> : head}
|
||||
{tail.length ? " " + tail.join(" ") : ""}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ── 서비스 헬스 ── */}
|
||||
<section className="vgad-section">
|
||||
<div className="vgad-sechead">
|
||||
<h2 className="vgad-sechead__title">서비스 헬스</h2>
|
||||
<span className="vgad-sechead__meta">엔진 · 음성 · DB · 큐</span>
|
||||
</div>
|
||||
<div className="vgad-health">
|
||||
{SERVICES.map((s) => (
|
||||
<div className="vgad-hrow" key={s.name}>
|
||||
<div className="vgad-hrow__svc">
|
||||
<Dot tone={healthBadgeTone(s.status)} />
|
||||
<span>
|
||||
<span className="vgad-hrow__nm">{s.name}</span>
|
||||
<span className="vgad-hrow__sub">{s.sub}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="vgad-hrow__gauge">
|
||||
<span
|
||||
className={healthFillClass(s.status)}
|
||||
style={{ width: `${Math.round(clamp01(s.load) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="vgad-hrow__num tabular">{s.metric}</span>
|
||||
<span className="vgad-hrow__stat">
|
||||
<Badge tone={healthBadgeTone(s.status)}>{s.statusLabel}</Badge>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── 사용·세션 수집 ── */}
|
||||
<section className="vgad-section">
|
||||
<div className="vgad-sechead">
|
||||
<h2 className="vgad-sechead__title">사용·세션 수집 현황</h2>
|
||||
<span className="vgad-sechead__meta">최근 7일 기준</span>
|
||||
</div>
|
||||
<div className="vgad-collect">
|
||||
{/* 시나리오 분포 */}
|
||||
<div className="vg-panel vg-panel--flat">
|
||||
<div className="vgad-panel__ph">시나리오별 세션 분포</div>
|
||||
<div className="vgad-panel__psub">
|
||||
이번 주 누적 1,284회기 · 가장 많이 연습된 사례 순
|
||||
</div>
|
||||
<div className="vgad-distrib">
|
||||
{DISTRIB.map((d) => (
|
||||
<div className="vgad-dist" key={d.label}>
|
||||
<span className="vgad-dist__l">{d.label}</span>
|
||||
<ProgressBar
|
||||
value={d.ratio}
|
||||
tone={d.tone === "clay" ? "clay" : d.tone === "warn" ? "warn" : "accent"}
|
||||
slim
|
||||
label={`${d.label} ${d.count}회기`}
|
||||
/>
|
||||
<span className="vgad-dist__v">{d.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 역할별 사용 */}
|
||||
<div className="vg-panel vg-panel--flat">
|
||||
<div className="vgad-panel__ph">역할별 사용 현황</div>
|
||||
<div className="vgad-panel__psub">전체 등록 계정 대비 활성</div>
|
||||
<div className="vgad-rolelist">
|
||||
{ROLES.map((r) => (
|
||||
<div className="vgad-rr" key={r.label}>
|
||||
<div className="vgad-rr__l">
|
||||
<span
|
||||
className="vgad-rr__sq"
|
||||
style={{ background: r.color }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="vgad-rr__nm">
|
||||
{r.label}
|
||||
<span className="vgad-rr__sub">{r.sub}</span>
|
||||
</span>
|
||||
</div>
|
||||
<span className="vgad-rr__v tabular">
|
||||
{r.active}
|
||||
{r.delta != null ? (
|
||||
<span className="vgad-rr__delta">+{r.delta}</span>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── 음성·AI 사용 + 비용 ── */}
|
||||
<section className="vgad-section">
|
||||
<div className="vgad-sechead">
|
||||
<h2 className="vgad-sechead__title">음성·AI 사용과 비용</h2>
|
||||
<span className="vgad-sechead__meta">claude -p · API 텔레메트리</span>
|
||||
</div>
|
||||
<div className="vg-panel vg-panel--flat">
|
||||
<div className="vgad-usage">
|
||||
{USAGE.map((u) => {
|
||||
const over = u.ratio >= 0.8; // 80% 초과 시 warn
|
||||
const pct = formatPercent(u.ratio);
|
||||
return (
|
||||
<div className="vgad-meter" key={u.label}>
|
||||
<div className="vgad-meter__top">
|
||||
<span>
|
||||
<span className="vgad-meter__nm">{u.label}</span>
|
||||
<span className="vgad-meter__sub">{u.sub}</span>
|
||||
</span>
|
||||
<span
|
||||
className={"vgad-meter__v" + (over ? " vgad-meter__v--warn" : "")}
|
||||
>
|
||||
{u.value}
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar
|
||||
value={u.ratio}
|
||||
tone={over ? "warn" : "accent"}
|
||||
slim
|
||||
label={`${u.label} ${pct} 사용`}
|
||||
/>
|
||||
<div className="vgad-meter__foot">
|
||||
<span className="vgad-meter__budget">{u.budget}</span>
|
||||
<span
|
||||
className={
|
||||
"vgad-meter__pct" + (over ? " vgad-meter__pct--warn" : "")
|
||||
}
|
||||
>
|
||||
{pct}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── 세션 추이 ── */}
|
||||
<section className="vgad-section">
|
||||
<div className="vgad-sechead">
|
||||
<h2 className="vgad-sechead__title">일별 세션 추이</h2>
|
||||
<span className="vgad-sechead__meta">최근 7일 · 피크 금요일</span>
|
||||
</div>
|
||||
<div className="vg-panel vg-panel--flat">
|
||||
<TrendChart data={TREND_7D} label="일별 완료 세션 수" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── 교수 활동 감사 요약 ── */}
|
||||
<section className="vgad-section">
|
||||
<div className="vgad-sechead">
|
||||
<h2 className="vgad-sechead__title">교수 활동 감사</h2>
|
||||
<span className="vgad-sechead__meta">피드백 열람률 · 발행 · 최근 활동</span>
|
||||
</div>
|
||||
<div className="vgad-tablewrap">
|
||||
<table className="vgad-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>교수자</th>
|
||||
<th className="vgad-hide-sm">담당 코호트</th>
|
||||
<th className="is-num" style={{ width: 160 }}>
|
||||
피드백 열람률
|
||||
</th>
|
||||
<th className="is-num" style={{ width: 90 }}>
|
||||
발행
|
||||
</th>
|
||||
<th style={{ width: 140 }}>최근 활동</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{PROF_AUDIT.map((p) => {
|
||||
const low = p.reviewRate < 0.6;
|
||||
return (
|
||||
<tr key={p.name}>
|
||||
<td>
|
||||
<div className="vgad-actor">
|
||||
<span className="vgad-actor__av" aria-hidden="true">
|
||||
{p.initial}
|
||||
</span>
|
||||
<span>
|
||||
<span className="vgad-actor__nm">{p.name}</span>
|
||||
<span className="vgad-actor__sub">슈퍼바이저</span>
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="vgad-hide-sm">{p.cohort}</td>
|
||||
<td className="is-num">
|
||||
<span className="vgad-ratebar">
|
||||
<span className="vgad-ratebar__track">
|
||||
<span
|
||||
className={
|
||||
"vgad-ratebar__fill" +
|
||||
(low ? " vgad-ratebar__fill--warn" : "")
|
||||
}
|
||||
style={{ width: `${Math.round(p.reviewRate * 100)}%` }}
|
||||
/>
|
||||
</span>
|
||||
<span className="vgad-ratebar__pct">
|
||||
{Math.round(p.reviewRate * 100)}%
|
||||
</span>
|
||||
</span>
|
||||
</td>
|
||||
<td className="is-num">{p.published}</td>
|
||||
<td>
|
||||
{p.needsAttention ? (
|
||||
<span className="vgad-attn">
|
||||
<Icon name="alert" size={14} strokeWidth={2} />
|
||||
점검 · {p.lastActive}
|
||||
</span>
|
||||
) : (
|
||||
<span className="vgad-td-time">{p.lastActive}</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── audit log ── */}
|
||||
<section className="vgad-section">
|
||||
<div className="vgad-sechead">
|
||||
<h2 className="vgad-sechead__title">교수자 활동 감사 로그</h2>
|
||||
<Button variant="ghost" size="sm" leading={<AdminIcon name="download" size={14} strokeWidth={2} />}>
|
||||
전체 로그 내보내기
|
||||
</Button>
|
||||
</div>
|
||||
<AuditTable
|
||||
rows={rows}
|
||||
filter={filter}
|
||||
onFilter={changeFilter}
|
||||
query={query}
|
||||
onQuery={changeQuery}
|
||||
page={safePage}
|
||||
pageCount={pageCount}
|
||||
onPage={setPage}
|
||||
total={matched.length}
|
||||
rangeFrom={rangeFrom}
|
||||
rangeTo={rangeTo}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* ── 관리 진입 ── */}
|
||||
<section className="vgad-section">
|
||||
<div className="vgad-sechead">
|
||||
<h2 className="vgad-sechead__title">관리</h2>
|
||||
<span className="vgad-sechead__meta">
|
||||
페르소나 · 엔진 · 데이터 · 공지
|
||||
</span>
|
||||
</div>
|
||||
<div className="vgad-mgmt">
|
||||
{MGMT.map((m) => {
|
||||
const inner = (
|
||||
<>
|
||||
<div className="vgad-entry__head">
|
||||
<span className="vgad-entry__ico">
|
||||
<AdminIcon name={m.icon} size={18} strokeWidth={1.8} />
|
||||
</span>
|
||||
{m.badge ? <Badge tone="warn">신규 {m.badge}</Badge> : null}
|
||||
</div>
|
||||
<div className="vgad-entry__t">{m.title}</div>
|
||||
<div className="vgad-entry__d">{m.desc}</div>
|
||||
<div className="vgad-entry__go">
|
||||
{m.to ? "바로가기" : "준비 중"}
|
||||
<Icon name="chevron-right" size={13} strokeWidth={2} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
return m.to ? (
|
||||
<Link className="vgad-entry" to={m.to} key={m.title}>
|
||||
{inner}
|
||||
</Link>
|
||||
) : (
|
||||
// 라우트 미구현 영역: 권한 차단이 아니므로 회색 disabled 처리 금지(§6.3).
|
||||
// 살아있는 진입처럼 보이되 "준비 중" 라벨로만 상태를 알린다.
|
||||
<button className="vgad-entry" type="button" key={m.title}>
|
||||
{inner}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── 페이지 푸터 ── */}
|
||||
<div className="vgad-foot">
|
||||
<span>
|
||||
Vignette · AI 심리상담 시뮬레이션 훈련 플랫폼 · 한신대학교 산학협력
|
||||
</span>
|
||||
<span className="tabular">운영 환경 · v2.4.1</span>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue