대시보드 폴드아웃/드릴다운 정리 + 페르소나 역린·misconduct 반응 + 게이트웨이 격리·RAG 비차단 수정
SSOT 대시보드:
- 한신대 기술분석 PDF(19쪽) 정합성 분석 + 이번 세션 발견 섹션 추가
- 섹션 폴드아웃(접기)·상단 목차(드릴다운)·모두 펼치기/접기 — 내용 보존, 레이아웃만 정리
페르소나 반응 강화('저항·반응 조절' 핵심 차별):
- PersonaCard.triggers(역린) 필드 + CCD 핵심상처 파생 역린 블록
- L0에 무례·모욕·조롱 시 현실적 동맹 균열 반응 지침
버그·성능 수정(라이브/E2E로 포착):
- 게이트웨이 페르소나 격리: --append-system-prompt를 --system-prompt(교체)로 + --exclude-dynamic-system-prompt-sections (내담자 캐릭터 붕괴·개발맥락 누출 차단)
- RAG: 임베더 동기 로드(약 7-13초)를 _warm_rag_caches 백그라운드 warm으로(세션 생성 블로킹 회귀 수정)
- voice TTS RMS 데드힌트 제거, init_state OpennessParams 파라미터객체화
- 한국어 PII(날짜·금액·주소) 마스킹 보강
- 레이아웃 시각 게이트: 폼 컨트롤 값 스크롤 오탐 제외(7/7)
검증: 백엔드 84/84, E2E 42(데스크톱 27·모바일 11·아바타 4), 시각 게이트 7/7
This commit is contained in:
parent
cb2aebd76c
commit
085460b5e0
327 changed files with 31226 additions and 1829 deletions
|
|
@ -1,7 +1,15 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { AppShell } from "../components/shell/AppShell";
|
||||
import { Badge, Button, Card, Dot, Icon, Kicker } from "../components/ui";
|
||||
import { teacherApi, type TeacherDashboardResponse } from "../lib/api";
|
||||
import {
|
||||
ApiError,
|
||||
personaReviewApi,
|
||||
teacherApi,
|
||||
type PersonaReviewAction,
|
||||
type PersonaReviewStatus,
|
||||
type PersonaReviewSummary,
|
||||
type TeacherDashboardResponse,
|
||||
} from "../lib/api";
|
||||
|
||||
type LoadState = "loading" | "ready" | "error";
|
||||
|
||||
|
|
@ -17,6 +25,19 @@ function formatDateTime(value: string | null): string {
|
|||
});
|
||||
}
|
||||
|
||||
function personaReviewStatusLabel(status: PersonaReviewStatus): string {
|
||||
if (status === "review") return "검수 대기";
|
||||
if (status === "draft") return "수정 대기";
|
||||
if (status === "approved") return "승인됨";
|
||||
return "보관됨";
|
||||
}
|
||||
|
||||
function personaReviewTone(status: PersonaReviewStatus): "accent" | "neutral" | "warn" {
|
||||
if (status === "review") return "accent";
|
||||
if (status === "draft") return "warn";
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
function EmptyState({ title, desc }: { title: string; desc: string }) {
|
||||
return (
|
||||
<div className="pf-empty">
|
||||
|
|
@ -28,8 +49,12 @@ function EmptyState({ title, desc }: { title: string; desc: string }) {
|
|||
|
||||
export default function Professor() {
|
||||
const [dashboard, setDashboard] = useState<TeacherDashboardResponse | null>(null);
|
||||
const [personaReviews, setPersonaReviews] = useState<PersonaReviewSummary[]>([]);
|
||||
const [loadState, setLoadState] = useState<LoadState>("loading");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [personaReviewLoading, setPersonaReviewLoading] = useState(true);
|
||||
const [personaReviewError, setPersonaReviewError] = useState<string | null>(null);
|
||||
const [personaReviewBusy, setPersonaReviewBusy] = useState<string | null>(null);
|
||||
const [updatedAt, setUpdatedAt] = useState<Date | null>(null);
|
||||
|
||||
const loadDashboard = useCallback(async () => {
|
||||
|
|
@ -46,9 +71,53 @@ export default function Professor() {
|
|||
}
|
||||
}, []);
|
||||
|
||||
const loadPersonaReviews = useCallback(async () => {
|
||||
setPersonaReviewLoading(true);
|
||||
setPersonaReviewError(null);
|
||||
try {
|
||||
const next = await personaReviewApi.list();
|
||||
setPersonaReviews(next);
|
||||
} catch (err) {
|
||||
// 404(검수 엔드포인트 부재/검수 데이터 없음)는 장애가 아니므로
|
||||
// 빨간 에러 배너 대신 정상 빈 상태로만 표시한다.
|
||||
if (err instanceof ApiError && err.status === 404) {
|
||||
setPersonaReviews([]);
|
||||
} else {
|
||||
// 진짜 장애(5xx/네트워크)일 때만 정돈된 에러 카드를 노출한다.
|
||||
setPersonaReviewError("검수 데이터를 불러오지 못했습니다.");
|
||||
}
|
||||
} finally {
|
||||
setPersonaReviewLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadDashboard();
|
||||
}, [loadDashboard]);
|
||||
void loadPersonaReviews();
|
||||
}, [loadDashboard, loadPersonaReviews]);
|
||||
|
||||
const decidePersonaReview = useCallback(
|
||||
async (personaId: string, action: PersonaReviewAction) => {
|
||||
setPersonaReviewBusy(`${personaId}:${action}`);
|
||||
setPersonaReviewError(null);
|
||||
try {
|
||||
const updated = await personaReviewApi.decide(personaId, action);
|
||||
setPersonaReviews((current) => {
|
||||
if (updated.status === "approved" || updated.status === "archived") {
|
||||
return current.filter((item) => item.persona_id !== updated.persona_id);
|
||||
}
|
||||
return current.map((item) => (item.persona_id === updated.persona_id ? updated : item));
|
||||
});
|
||||
} catch (err) {
|
||||
setPersonaReviewError(
|
||||
err instanceof Error ? err.message : "페르소나 검수 결정을 저장하지 못했습니다.",
|
||||
);
|
||||
} finally {
|
||||
setPersonaReviewBusy(null);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const kpis = useMemo(
|
||||
() => [
|
||||
|
|
@ -82,6 +151,7 @@ export default function Professor() {
|
|||
const pendingCount = dashboard?.pending_reviews.length ?? 0;
|
||||
const hasPending = pendingCount > 0;
|
||||
const totalSessions = (dashboard?.active_sessions ?? 0) + (dashboard?.ended_sessions ?? 0);
|
||||
const personaReviewCount = personaReviews.length;
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
|
|
@ -101,31 +171,51 @@ export default function Professor() {
|
|||
<Button
|
||||
variant="secondary"
|
||||
leading={<Icon name="review" size={16} />}
|
||||
onClick={() => void loadDashboard()}
|
||||
disabled={loadState === "loading"}
|
||||
onClick={() => {
|
||||
void loadDashboard();
|
||||
void loadPersonaReviews();
|
||||
}}
|
||||
disabled={loadState === "loading" || personaReviewLoading}
|
||||
>
|
||||
새로고침
|
||||
{loadState === "loading" || personaReviewLoading ? "확인 중" : "새로고침"}
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<section className={`pf-triage ${hasPending ? "is-active" : ""}`} role="status">
|
||||
<Dot tone={loadState === "error" ? "crit" : "accent"} size={9} />
|
||||
<div className="pf-triage__copy">
|
||||
<Kicker>검토 큐</Kicker>
|
||||
<b>{dashboard?.message ?? "학습 세션을 확인하는 중입니다."}</b>
|
||||
<span>{dashboard ? `${dashboard.cohort_label} · 실제 기록` : "확인 중"}</span>
|
||||
</div>
|
||||
<div className="pf-triage__meta">
|
||||
<span>
|
||||
<b>{pendingCount}</b>
|
||||
<small>리뷰 대기</small>
|
||||
</span>
|
||||
<span>
|
||||
<b>{totalSessions}</b>
|
||||
<small>누적 회기</small>
|
||||
</span>
|
||||
<time>{updatedAt ? updatedAt.toLocaleTimeString("ko-KR") : ""}</time>
|
||||
</div>
|
||||
<section className="pf-signal-strip" aria-label="교수자 검토 요약">
|
||||
<section className={`pf-triage ${hasPending ? "is-active" : ""}`} role="status">
|
||||
<Dot tone={loadState === "error" ? "crit" : "accent"} size={9} />
|
||||
<div className="pf-triage__copy">
|
||||
<Kicker>검토 큐</Kicker>
|
||||
<b>{dashboard?.message ?? "학습 세션을 확인하는 중입니다."}</b>
|
||||
<span>
|
||||
{dashboard ? `${dashboard.cohort_label} · 실제 기록` : "확인 중"}
|
||||
{updatedAt ? ` · ${updatedAt.toLocaleTimeString("ko-KR")} 갱신` : ""}
|
||||
</span>
|
||||
</div>
|
||||
<div className="pf-triage__meta">
|
||||
<span>
|
||||
<b>{pendingCount}</b>
|
||||
<small>리뷰 대기</small>
|
||||
</span>
|
||||
<span>
|
||||
<b>{totalSessions}</b>
|
||||
<small>누적 회기</small>
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="pf-kpis" aria-label="교수자 요약">
|
||||
{kpis.map((kpi) => (
|
||||
<div className="pf-kpi" key={kpi.label}>
|
||||
<span className="pf-kpi__ic" aria-hidden="true">
|
||||
<Icon name={kpi.icon} size={15} strokeWidth={1.9} />
|
||||
</span>
|
||||
<span className="pf-kpi__lab">{kpi.label}</span>
|
||||
<b>{kpi.value}</b>
|
||||
<small>{kpi.hint}</small>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
</section>
|
||||
|
||||
{error ? (
|
||||
|
|
@ -135,20 +225,97 @@ export default function Professor() {
|
|||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="pf-kpis" aria-label="교수자 요약">
|
||||
{kpis.map((kpi) => (
|
||||
<div className="pf-kpi" key={kpi.label}>
|
||||
<span className="pf-kpi__ic" aria-hidden="true">
|
||||
<Icon name={kpi.icon} size={15} strokeWidth={1.9} />
|
||||
</span>
|
||||
<span className="pf-kpi__lab">{kpi.label}</span>
|
||||
<b>{kpi.value}</b>
|
||||
<small>{kpi.hint}</small>
|
||||
<section className="pf-workspace">
|
||||
<div className="pf-queue-stack">
|
||||
<section className="pf-section">
|
||||
<div className="pf-section__head">
|
||||
<div>
|
||||
<Kicker>페르소나 검수</Kicker>
|
||||
<h2>공개 전 승인 큐</h2>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
<Badge tone={personaReviewCount > 0 ? "accent" : "neutral"}>
|
||||
{personaReviewCount}건
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<section className="pf-section">
|
||||
<Card className="pf-panel">
|
||||
{personaReviewError ? (
|
||||
<div className="pf-empty" role="alert">
|
||||
<Icon name="alert" size={18} />
|
||||
<b>{personaReviewError}</b>
|
||||
<span>네트워크 또는 서버 오류가 발생했습니다.</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
leading={<Icon name="review" size={14} />}
|
||||
onClick={() => void loadPersonaReviews()}
|
||||
disabled={personaReviewLoading}
|
||||
>
|
||||
{personaReviewLoading ? "확인 중" : "다시 시도"}
|
||||
</Button>
|
||||
</div>
|
||||
) : personaReviews.length > 0 ? (
|
||||
<div className="pf-personas">
|
||||
{personaReviews.map((persona) => {
|
||||
const approveKey = `${persona.persona_id}:approve`;
|
||||
const rejectKey = `${persona.persona_id}:reject`;
|
||||
const busy = personaReviewBusy?.startsWith(`${persona.persona_id}:`) ?? false;
|
||||
return (
|
||||
<article
|
||||
className="pf-persona"
|
||||
key={`${persona.persona_id}:${persona.version}`}
|
||||
data-persona-review-row="true"
|
||||
>
|
||||
<div className="pf-persona__main">
|
||||
<span className="pf-persona__code">{persona.code}</span>
|
||||
<div>
|
||||
<b>{persona.display_name}</b>
|
||||
<span>
|
||||
v{persona.version} · {persona.difficulty} ·{" "}
|
||||
{persona.theory_target.join(" · ") || "공통"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p>{persona.source_provenance || "출처 정보 없음"}</p>
|
||||
<div className="pf-persona__meta">
|
||||
<Badge tone={personaReviewTone(persona.status)}>
|
||||
{personaReviewStatusLabel(persona.status)}
|
||||
</Badge>
|
||||
<span>{formatDateTime(persona.created_at)}</span>
|
||||
</div>
|
||||
<div className="pf-persona__actions">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
leading={<Icon name="x" size={14} />}
|
||||
onClick={() => void decidePersonaReview(persona.persona_id, "reject")}
|
||||
disabled={busy}
|
||||
>
|
||||
{personaReviewBusy === rejectKey ? "저장 중" : "반려"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
leading={<Icon name="check" size={14} />}
|
||||
onClick={() => void decidePersonaReview(persona.persona_id, "approve")}
|
||||
disabled={busy}
|
||||
>
|
||||
{personaReviewBusy === approveKey ? "저장 중" : "승인"}
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
title={personaReviewLoading ? "페르소나 검수 큐 확인 중" : "검수 대기 페르소나 없음"}
|
||||
desc="draft 또는 review 상태의 페르소나가 등록되면 이 목록에서 승인하거나 반려합니다."
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<section className="pf-section">
|
||||
<div className="pf-section__head">
|
||||
<div>
|
||||
<Kicker>검토 대기</Kicker>
|
||||
|
|
@ -184,9 +351,10 @@ export default function Professor() {
|
|||
/>
|
||||
)}
|
||||
</Card>
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="pf-section">
|
||||
<section className="pf-section pf-section--recent">
|
||||
<div className="pf-section__head">
|
||||
<div>
|
||||
<Kicker>최근 세션</Kicker>
|
||||
|
|
@ -197,42 +365,48 @@ export default function Professor() {
|
|||
|
||||
<Card className="pf-panel">
|
||||
{dashboard && dashboard.recent_sessions.length > 0 ? (
|
||||
<div className="pf-tablewrap">
|
||||
<table className="pf-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>학습자</th>
|
||||
<th>페르소나</th>
|
||||
<th>상태</th>
|
||||
<th>단계</th>
|
||||
<th>턴</th>
|
||||
<th>시작</th>
|
||||
<th>종료</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{dashboard.recent_sessions.map((session) => (
|
||||
<tr key={session.session_id}>
|
||||
<td>
|
||||
<span className="pf-learner">
|
||||
{session.learner_label}
|
||||
<code>{session.session_id}</code>
|
||||
</span>
|
||||
</td>
|
||||
<td>{session.persona_code}</td>
|
||||
<td>
|
||||
<Badge tone={session.status === "ended" ? "neutral" : "accent"}>
|
||||
{session.status === "ended" ? "종료" : "진행 중"}
|
||||
</Badge>
|
||||
</td>
|
||||
<td>{session.stage}</td>
|
||||
<td className="tabular">{session.turn_count}</td>
|
||||
<td className="tabular">{formatDateTime(session.started_at)}</td>
|
||||
<td className="tabular">{formatDateTime(session.ended_at)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="pf-recent-list">
|
||||
<div className="pf-recent-head" aria-hidden="true">
|
||||
<span>학습자</span>
|
||||
<span>페르소나</span>
|
||||
<span>상태</span>
|
||||
<span>단계</span>
|
||||
<span>턴</span>
|
||||
<span>시작</span>
|
||||
<span>종료</span>
|
||||
</div>
|
||||
{dashboard.recent_sessions.map((session) => (
|
||||
<article
|
||||
className="pf-recent-row"
|
||||
key={session.session_id}
|
||||
data-recent-session-row="true"
|
||||
>
|
||||
<div className="pf-recent__learner">
|
||||
<b>{session.learner_label}</b>
|
||||
<code>{session.session_id}</code>
|
||||
</div>
|
||||
<span className="pf-recent__cell" data-label="페르소나">
|
||||
{session.persona_code}
|
||||
</span>
|
||||
<span className="pf-recent__cell" data-label="상태">
|
||||
<Badge tone={session.status === "ended" ? "neutral" : "accent"}>
|
||||
{session.status === "ended" ? "종료" : "진행 중"}
|
||||
</Badge>
|
||||
</span>
|
||||
<span className="pf-recent__cell" data-label="단계">
|
||||
{session.stage}
|
||||
</span>
|
||||
<span className="pf-recent__cell tabular" data-label="턴">
|
||||
{session.turn_count}
|
||||
</span>
|
||||
<span className="pf-recent__cell tabular" data-label="시작">
|
||||
{formatDateTime(session.started_at)}
|
||||
</span>
|
||||
<span className="pf-recent__cell tabular" data-label="종료">
|
||||
{formatDateTime(session.ended_at)}
|
||||
</span>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
|
|
@ -241,6 +415,7 @@ export default function Professor() {
|
|||
/>
|
||||
)}
|
||||
</Card>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
</AppShell>
|
||||
|
|
@ -253,99 +428,97 @@ const PF_CSS = `
|
|||
margin:0 auto;
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
gap:var(--sp-6);
|
||||
gap:18px;
|
||||
}
|
||||
.pf-head{
|
||||
display:flex;
|
||||
align-items:flex-end;
|
||||
justify-content:space-between;
|
||||
gap:var(--sp-5);
|
||||
gap:var(--sp-4);
|
||||
flex-wrap:wrap;
|
||||
}
|
||||
.pf-head h1{
|
||||
margin:8px 0 0;
|
||||
margin:6px 0 0;
|
||||
color:var(--text-strong);
|
||||
font-size:var(--fs-h1);
|
||||
line-height:1.24;
|
||||
font-size:var(--fs-h2);
|
||||
line-height:1.28;
|
||||
letter-spacing:0;
|
||||
}
|
||||
.pf-head p{
|
||||
margin:10px 0 0;
|
||||
max-width:760px;
|
||||
margin:6px 0 0;
|
||||
max-width:680px;
|
||||
color:var(--text-body);
|
||||
font-size:var(--fs-sm);
|
||||
line-height:1.65;
|
||||
font-size:var(--fs-xs);
|
||||
line-height:1.55;
|
||||
}
|
||||
.pf-signal-strip{
|
||||
display:grid;
|
||||
grid-template-columns:minmax(0,1.45fr) minmax(300px,.75fr);
|
||||
gap:12px;
|
||||
min-width:0;
|
||||
}
|
||||
.pf-triage{
|
||||
display:grid;
|
||||
grid-template-columns:auto minmax(0,1fr) auto;
|
||||
align-items:center;
|
||||
gap:var(--sp-4);
|
||||
padding:var(--sp-5);
|
||||
gap:12px;
|
||||
min-width:0;
|
||||
padding:14px 16px;
|
||||
border:1px solid var(--hair);
|
||||
border-radius:var(--radius-lg);
|
||||
border-radius:var(--radius);
|
||||
background:var(--bg-surface);
|
||||
box-shadow:var(--shadow-sm);
|
||||
}
|
||||
.pf-triage.is-active{
|
||||
background:linear-gradient(135deg,var(--bg-surface) 0%,var(--accent-tint) 100%);
|
||||
background:color-mix(in srgb,var(--accent-tint) 42%,var(--bg-surface));
|
||||
}
|
||||
.pf-triage__copy{
|
||||
min-width:0;
|
||||
}
|
||||
.pf-triage__copy .vg-kicker{
|
||||
margin-bottom:6px;
|
||||
margin-bottom:4px;
|
||||
}
|
||||
.pf-triage__copy b{
|
||||
display:block;
|
||||
color:var(--text-strong);
|
||||
font-size:var(--fs-lead);
|
||||
line-height:1.4;
|
||||
font-size:var(--fs-body);
|
||||
line-height:1.35;
|
||||
}
|
||||
.pf-triage__copy span,
|
||||
.pf-triage__meta small,
|
||||
.pf-triage__meta time{
|
||||
.pf-triage__meta small{
|
||||
color:var(--text-muted);
|
||||
font-size:var(--fs-xs);
|
||||
}
|
||||
.pf-triage__meta{
|
||||
display:flex;
|
||||
align-items:stretch;
|
||||
display:grid;
|
||||
grid-template-columns:repeat(2,minmax(0,1fr));
|
||||
gap:0;
|
||||
min-width:max-content;
|
||||
min-width:180px;
|
||||
border:1px solid var(--border-subtle);
|
||||
border-radius:var(--radius);
|
||||
background:color-mix(in srgb,var(--bg-surface) 86%,transparent);
|
||||
overflow:hidden;
|
||||
}
|
||||
.pf-triage__meta span,
|
||||
.pf-triage__meta time{
|
||||
min-width:96px;
|
||||
.pf-triage__meta span{
|
||||
display:grid;
|
||||
align-content:center;
|
||||
gap:2px;
|
||||
padding:10px 14px;
|
||||
padding:9px 12px;
|
||||
}
|
||||
.pf-triage__meta span + span,
|
||||
.pf-triage__meta time{
|
||||
.pf-triage__meta span + span{
|
||||
border-left:1px solid var(--hair);
|
||||
}
|
||||
.pf-triage__meta b{
|
||||
color:var(--text-strong);
|
||||
font-family:var(--font-num);
|
||||
font-size:18px;
|
||||
font-size:17px;
|
||||
line-height:1;
|
||||
}
|
||||
.pf-triage__meta time{
|
||||
font-family:var(--font-num);
|
||||
white-space:nowrap;
|
||||
text-align:right;
|
||||
}
|
||||
.pf-error{
|
||||
display:flex;
|
||||
align-items:center;
|
||||
gap:var(--sp-3);
|
||||
padding:var(--sp-4);
|
||||
gap:10px;
|
||||
padding:12px 14px;
|
||||
border-radius:var(--radius);
|
||||
background:var(--crit-tint);
|
||||
color:var(--crit-text);
|
||||
|
|
@ -353,9 +526,9 @@ const PF_CSS = `
|
|||
}
|
||||
.pf-kpis{
|
||||
display:grid;
|
||||
grid-template-columns:repeat(4,minmax(0,1fr));
|
||||
grid-template-columns:repeat(2,minmax(0,1fr));
|
||||
border:1px solid var(--hair);
|
||||
border-radius:var(--radius-lg);
|
||||
border-radius:var(--radius);
|
||||
overflow:hidden;
|
||||
background:var(--bg-surface);
|
||||
box-shadow:var(--shadow-sm);
|
||||
|
|
@ -363,17 +536,18 @@ const PF_CSS = `
|
|||
.pf-kpi{
|
||||
position:relative;
|
||||
min-width:0;
|
||||
padding:var(--sp-5);
|
||||
padding:11px 12px;
|
||||
display:grid;
|
||||
grid-template-columns:minmax(0,1fr) auto;
|
||||
gap:6px var(--sp-3);
|
||||
gap:4px 10px;
|
||||
}
|
||||
.pf-kpi + .pf-kpi{border-left:1px solid var(--hair);}
|
||||
.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__ic{
|
||||
grid-column:2;
|
||||
grid-row:1 / span 3;
|
||||
width:34px;
|
||||
height:34px;
|
||||
width:30px;
|
||||
height:30px;
|
||||
display:grid;
|
||||
place-items:center;
|
||||
border-radius:var(--radius);
|
||||
|
|
@ -390,7 +564,7 @@ const PF_CSS = `
|
|||
display:block;
|
||||
color:var(--text-strong);
|
||||
font-family:var(--font-num);
|
||||
font-size:30px;
|
||||
font-size:23px;
|
||||
line-height:1;
|
||||
}
|
||||
.pf-kpi small{
|
||||
|
|
@ -398,21 +572,36 @@ const PF_CSS = `
|
|||
font-size:12px;
|
||||
line-height:1.35;
|
||||
}
|
||||
.pf-workspace{
|
||||
display:grid;
|
||||
grid-template-columns:minmax(330px,380px) minmax(0,1fr);
|
||||
align-items:start;
|
||||
gap:14px;
|
||||
min-width:0;
|
||||
}
|
||||
.pf-queue-stack{
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
gap:14px;
|
||||
min-width:0;
|
||||
}
|
||||
.pf-section{
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
gap:var(--sp-4);
|
||||
gap:10px;
|
||||
min-width:0;
|
||||
}
|
||||
.pf-section__head{
|
||||
display:flex;
|
||||
align-items:flex-end;
|
||||
justify-content:space-between;
|
||||
gap:var(--sp-4);
|
||||
gap:12px;
|
||||
}
|
||||
.pf-section__head h2{
|
||||
margin:6px 0 0;
|
||||
margin:4px 0 0;
|
||||
color:var(--text-strong);
|
||||
font-size:var(--fs-h3);
|
||||
font-size:var(--fs-body);
|
||||
font-weight:700;
|
||||
line-height:1.3;
|
||||
letter-spacing:0;
|
||||
}
|
||||
|
|
@ -421,11 +610,11 @@ const PF_CSS = `
|
|||
overflow:hidden;
|
||||
}
|
||||
.pf-empty{
|
||||
min-height:156px;
|
||||
min-height:118px;
|
||||
display:grid;
|
||||
place-items:center;
|
||||
gap:6px;
|
||||
padding:var(--sp-6);
|
||||
padding:var(--sp-5);
|
||||
text-align:center;
|
||||
}
|
||||
.pf-empty b{
|
||||
|
|
@ -434,21 +623,96 @@ const PF_CSS = `
|
|||
}
|
||||
.pf-empty span{
|
||||
color:var(--text-muted);
|
||||
font-size:var(--fs-sm);
|
||||
font-size:var(--fs-xs);
|
||||
}
|
||||
.pf-list{
|
||||
max-height:min(430px,52vh);
|
||||
max-height:min(320px,42vh);
|
||||
overflow:auto;
|
||||
scrollbar-gutter:stable;
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
}
|
||||
.pf-personas{
|
||||
max-height:min(320px,42vh);
|
||||
overflow:auto;
|
||||
scrollbar-gutter:stable;
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
}
|
||||
.pf-persona{
|
||||
display:grid;
|
||||
grid-template-columns:minmax(0,1fr);
|
||||
gap:8px;
|
||||
align-items:start;
|
||||
padding:12px;
|
||||
border-top:1px solid var(--paper-2);
|
||||
}
|
||||
.pf-persona:first-child{border-top:0;}
|
||||
.pf-persona__main{
|
||||
min-width:0;
|
||||
display:grid;
|
||||
grid-template-columns:40px minmax(0,1fr);
|
||||
gap:10px;
|
||||
align-items:center;
|
||||
}
|
||||
.pf-persona__code{
|
||||
width:40px;
|
||||
height:32px;
|
||||
display:grid;
|
||||
place-items:center;
|
||||
border-radius:var(--radius);
|
||||
color:var(--accent-deep);
|
||||
background:var(--accent-tint);
|
||||
font-family:var(--font-num);
|
||||
font-weight:800;
|
||||
font-size:12px;
|
||||
}
|
||||
.pf-persona__main b{
|
||||
display:block;
|
||||
color:var(--text-strong);
|
||||
font-size:var(--fs-sm);
|
||||
line-height:1.35;
|
||||
overflow:hidden;
|
||||
text-overflow:ellipsis;
|
||||
white-space:nowrap;
|
||||
}
|
||||
.pf-persona__main span,
|
||||
.pf-persona p,
|
||||
.pf-persona__meta span{
|
||||
color:var(--text-muted);
|
||||
font-size:var(--fs-xs);
|
||||
line-height:1.45;
|
||||
}
|
||||
.pf-persona p{
|
||||
margin:0;
|
||||
min-width:0;
|
||||
overflow:hidden;
|
||||
text-overflow:ellipsis;
|
||||
white-space:nowrap;
|
||||
}
|
||||
.pf-persona__meta{
|
||||
display:flex;
|
||||
align-items:center;
|
||||
gap:10px;
|
||||
justify-content:space-between;
|
||||
white-space:nowrap;
|
||||
}
|
||||
.pf-persona__actions{
|
||||
display:flex;
|
||||
justify-content:flex-end;
|
||||
gap:8px;
|
||||
min-width:0;
|
||||
}
|
||||
.pf-persona__actions .vg-btn{
|
||||
min-width:72px;
|
||||
padding-inline:10px;
|
||||
}
|
||||
.pf-session{
|
||||
display:grid;
|
||||
grid-template-columns:8px minmax(0,1fr) auto;
|
||||
gap:var(--sp-4);
|
||||
align-items:center;
|
||||
padding:var(--sp-4) var(--sp-5);
|
||||
grid-template-columns:8px minmax(0,1fr);
|
||||
gap:8px 10px;
|
||||
align-items:start;
|
||||
padding:12px;
|
||||
border-top:1px solid var(--paper-2);
|
||||
}
|
||||
.pf-session:first-child{border-top:0;}
|
||||
|
|
@ -471,62 +735,112 @@ const PF_CSS = `
|
|||
.pf-session__main span,.pf-session__main code{
|
||||
color:var(--text-muted);
|
||||
font-size:var(--fs-xs);
|
||||
overflow-wrap:anywhere;
|
||||
}
|
||||
.pf-session__main code,.pf-learner code{
|
||||
.pf-session__main code,.pf-recent__learner code{
|
||||
font-family:var(--font-num);
|
||||
overflow:hidden;
|
||||
text-overflow:ellipsis;
|
||||
}
|
||||
.pf-session__meta{
|
||||
grid-column:2;
|
||||
display:flex;
|
||||
align-items:center;
|
||||
gap:var(--sp-3);
|
||||
justify-content:space-between;
|
||||
gap:8px;
|
||||
color:var(--text-muted);
|
||||
font-family:var(--font-num);
|
||||
font-size:var(--fs-xs);
|
||||
white-space:nowrap;
|
||||
white-space:normal;
|
||||
}
|
||||
.pf-tablewrap{
|
||||
max-height:min(480px,58vh);
|
||||
.pf-recent-list{
|
||||
max-height:min(620px,calc(100vh - 220px));
|
||||
overflow:auto;
|
||||
scrollbar-gutter:stable;
|
||||
min-width:0;
|
||||
}
|
||||
.pf-table{
|
||||
width:100%;
|
||||
border-collapse:collapse;
|
||||
min-width:760px;
|
||||
.pf-recent-head,
|
||||
.pf-recent-row{
|
||||
display:grid;
|
||||
grid-template-columns:minmax(155px,1.35fr) 82px 78px minmax(110px,.95fr) 42px 86px 86px;
|
||||
align-items:center;
|
||||
gap:9px;
|
||||
min-width:0;
|
||||
}
|
||||
.pf-table th{
|
||||
.pf-recent-head{
|
||||
position:sticky;
|
||||
top:0;
|
||||
z-index:1;
|
||||
text-align:left;
|
||||
color:var(--text-muted);
|
||||
font-size:var(--fs-xs);
|
||||
font-weight:700;
|
||||
padding:var(--sp-4) var(--sp-5) var(--sp-3);
|
||||
padding:9px 12px;
|
||||
border-bottom:1px solid var(--hair);
|
||||
background:var(--bg-surface);
|
||||
white-space:nowrap;
|
||||
}
|
||||
.pf-table td{
|
||||
.pf-recent-row{
|
||||
padding:10px 12px;
|
||||
border-top:1px solid var(--paper-2);
|
||||
}
|
||||
.pf-recent-head + .pf-recent-row{
|
||||
border-top:0;
|
||||
}
|
||||
.pf-recent__learner,
|
||||
.pf-recent__cell{
|
||||
min-width:0;
|
||||
color:var(--text-body);
|
||||
font-size:var(--fs-sm);
|
||||
padding:var(--sp-4) var(--sp-5);
|
||||
border-bottom:1px solid var(--paper-2);
|
||||
vertical-align:middle;
|
||||
overflow-wrap:anywhere;
|
||||
}
|
||||
.pf-table tr:last-child td{border-bottom:0;}
|
||||
.pf-learner{
|
||||
.pf-recent__cell::before{
|
||||
display:none;
|
||||
}
|
||||
.pf-recent__learner{
|
||||
min-width:0;
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
gap:3px;
|
||||
}
|
||||
.pf-learner code{
|
||||
max-width:240px;
|
||||
.pf-recent__learner b{
|
||||
color:var(--text-strong);
|
||||
font-size:var(--fs-sm);
|
||||
line-height:1.35;
|
||||
overflow-wrap:anywhere;
|
||||
}
|
||||
.pf-recent__learner code{
|
||||
max-width:100%;
|
||||
color:var(--text-muted);
|
||||
font-size:11px;
|
||||
white-space:nowrap;
|
||||
overflow:hidden;
|
||||
text-overflow:ellipsis;
|
||||
}
|
||||
.pf-recent__cell .vg-badge{
|
||||
justify-self:start;
|
||||
}
|
||||
@media (max-width:1100px){
|
||||
.pf-signal-strip,
|
||||
.pf-workspace{
|
||||
grid-template-columns:1fr;
|
||||
}
|
||||
.pf-triage{
|
||||
grid-template-columns:auto minmax(0,max-content) auto;
|
||||
justify-content:start;
|
||||
}
|
||||
.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-list,
|
||||
.pf-personas{
|
||||
max-height:360px;
|
||||
}
|
||||
.pf-recent-list{
|
||||
max-height:460px;
|
||||
}
|
||||
}
|
||||
@media (max-width:860px){
|
||||
.pf-triage{
|
||||
|
|
@ -536,34 +850,76 @@ const PF_CSS = `
|
|||
grid-column:1 / -1;
|
||||
width:100%;
|
||||
}
|
||||
.pf-triage__meta span,
|
||||
.pf-triage__meta time{
|
||||
min-width:0;
|
||||
flex:1;
|
||||
text-align:left;
|
||||
}
|
||||
.pf-kpis{grid-template-columns:repeat(2,minmax(0,1fr));}
|
||||
.pf-kpi:nth-child(odd){border-left:0;}
|
||||
.pf-kpi:nth-child(n+3){border-top:1px solid var(--hair);}
|
||||
.pf-list{max-height:380px;}
|
||||
.pf-tablewrap{max-height:420px;}
|
||||
.pf-session{grid-template-columns:1fr;}
|
||||
.pf-session__meta{justify-content:space-between;white-space:normal;}
|
||||
.pf-recent-list{
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
gap:10px;
|
||||
padding:10px;
|
||||
background:var(--bg-surface-2);
|
||||
overflow-x:hidden;
|
||||
}
|
||||
.pf-recent-head{
|
||||
display:none;
|
||||
}
|
||||
.pf-recent-row{
|
||||
display:grid;
|
||||
grid-template-columns:repeat(2,minmax(0,1fr));
|
||||
gap:10px 12px;
|
||||
padding:12px;
|
||||
border:1px solid var(--paper-2);
|
||||
border-radius:var(--radius);
|
||||
background:var(--bg-surface);
|
||||
}
|
||||
.pf-recent__learner{
|
||||
grid-column:1 / -1;
|
||||
padding-bottom:10px;
|
||||
border-bottom:1px solid var(--paper-2);
|
||||
}
|
||||
.pf-recent__learner code{
|
||||
white-space:normal;
|
||||
overflow-wrap:anywhere;
|
||||
}
|
||||
.pf-recent__cell{
|
||||
display:grid;
|
||||
grid-template-columns:minmax(74px,.4fr) minmax(0,1fr);
|
||||
gap:8px;
|
||||
align-items:center;
|
||||
}
|
||||
.pf-recent__cell::before{
|
||||
display:block;
|
||||
content:attr(data-label);
|
||||
color:var(--text-muted);
|
||||
font-size:var(--fs-xs);
|
||||
font-weight:700;
|
||||
line-height:1.35;
|
||||
}
|
||||
.pf-session__meta{justify-content:flex-start;}
|
||||
}
|
||||
@media (max-width:520px){
|
||||
.pf-kpis{grid-template-columns:1fr;}
|
||||
.pf-kpi + .pf-kpi{border-left:0;border-top:1px solid var(--hair);}
|
||||
.pf-triage__meta{
|
||||
.pf-kpi,
|
||||
.pf-kpi:nth-child(even),
|
||||
.pf-kpi + .pf-kpi{border-left:0;}
|
||||
.pf-kpi + .pf-kpi{border-top:1px solid var(--hair);}
|
||||
.pf-persona__actions{
|
||||
display:grid;
|
||||
grid-template-columns:repeat(2,minmax(0,1fr));
|
||||
}
|
||||
.pf-triage__meta time{
|
||||
grid-column:1 / -1;
|
||||
border-left:0;
|
||||
border-top:1px solid var(--hair);
|
||||
.pf-persona__actions .vg-btn{
|
||||
width:100%;
|
||||
}
|
||||
.pf-triage__meta span:nth-child(2){
|
||||
border-left:1px solid var(--hair);
|
||||
.pf-recent-list{
|
||||
padding:8px;
|
||||
}
|
||||
.pf-recent-row{
|
||||
grid-template-columns:1fr;
|
||||
gap:10px;
|
||||
}
|
||||
.pf-recent__cell{
|
||||
grid-template-columns:minmax(64px,.32fr) minmax(0,1fr);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue