현재 작업 전체 반영
This commit is contained in:
parent
5560638e54
commit
c0dddab594
85 changed files with 11322 additions and 539 deletions
|
|
@ -16,6 +16,8 @@ npm run dev # http://localhost:5173 (개발 서버, /api → :8000 프록
|
|||
|
||||
```bash
|
||||
npm run build # tsc -b + vite build → dist/
|
||||
npm run generate:api-types # FastAPI OpenAPI → src/lib/api.gen.ts
|
||||
npm run check:api-types # 생성 타입 stale 체크
|
||||
npm run generate:live2d-assets
|
||||
npm run preview # 빌드 결과 미리보기
|
||||
npm run typecheck # tsc --noEmit (타입 체크만)
|
||||
|
|
@ -64,7 +66,8 @@ src/
|
|||
tokens.css ★ 디자인 토큰 (라이트/다크/역할 accent). dev_dashboard 계승.
|
||||
global.css reset + body + 스크롤바 + 유틸.
|
||||
lib/
|
||||
api.ts fetch wrapper(credentials:include) + SSE 헬퍼 + 세션 API + 백엔드 타입.
|
||||
api.ts fetch wrapper(credentials:include) + SSE 헬퍼 + 세션 API.
|
||||
api.gen.ts FastAPI OpenAPI에서 생성한 백엔드 DTO 타입.
|
||||
auth.tsx AuthContext(user/role/login/logout). body[data-role] 반영.
|
||||
format.ts 시간/타이머/숫자 포맷(tabular).
|
||||
components/
|
||||
|
|
|
|||
|
|
@ -39,6 +39,35 @@ interface AdminUsersResponse {
|
|||
users: AdminManagedUser[];
|
||||
}
|
||||
|
||||
interface AdminUsageBreakdown {
|
||||
provider: string;
|
||||
model: string;
|
||||
turns: number;
|
||||
tokens_in: number;
|
||||
tokens_out: number;
|
||||
cost_usd: number;
|
||||
}
|
||||
|
||||
interface AdminUsageBudget {
|
||||
limit_usd: number;
|
||||
used_ratio: number;
|
||||
remaining_usd: number | null;
|
||||
status: "disabled" | "ok" | "warn" | "exceeded";
|
||||
}
|
||||
|
||||
interface AdminUsageResponse {
|
||||
source: "database" | "server_session_registry";
|
||||
durable: boolean;
|
||||
window_days: number;
|
||||
total_turns: number;
|
||||
metered_turns: number;
|
||||
tokens_in: number;
|
||||
tokens_out: number;
|
||||
cost_usd: number;
|
||||
budget: AdminUsageBudget;
|
||||
by_provider: AdminUsageBreakdown[];
|
||||
}
|
||||
|
||||
async function expectResponseOk(response: APIResponse | Response) {
|
||||
if (!response.ok()) {
|
||||
expect(response.ok(), await response.text()).toBeTruthy();
|
||||
|
|
@ -66,6 +95,11 @@ function isAdminUsersResponse(response: Response) {
|
|||
return response.request().method() === "GET" && url.pathname.endsWith("/admin/users");
|
||||
}
|
||||
|
||||
function isAdminUsageResponse(response: Response) {
|
||||
const url = new URL(response.url());
|
||||
return response.request().method() === "GET" && url.pathname.endsWith("/admin/usage");
|
||||
}
|
||||
|
||||
function isAdminUserCreate(response: Response) {
|
||||
const url = new URL(response.url());
|
||||
return response.request().method() === "POST" && url.pathname.endsWith("/admin/users");
|
||||
|
|
@ -106,17 +140,33 @@ function engineModeLabel(value: string) {
|
|||
return value;
|
||||
}
|
||||
|
||||
function countLabel(value: number) {
|
||||
if (!Number.isFinite(value)) return "0";
|
||||
return Math.round(value).toLocaleString("ko-KR");
|
||||
}
|
||||
|
||||
function costLabel(value: number) {
|
||||
if (!Number.isFinite(value) || value <= 0) return "$0";
|
||||
return `$${value.toFixed(value < 0.01 ? 6 : 4)}`;
|
||||
}
|
||||
|
||||
async function openAdminAndReadHealth(page: Page) {
|
||||
const healthResponsePromise = page.waitForResponse(isAdminHealthResponse);
|
||||
const usageResponsePromise = page.waitForResponse(isAdminUsageResponse);
|
||||
|
||||
await page.goto("/admin");
|
||||
|
||||
const healthResponse = await healthResponsePromise;
|
||||
const [healthResponse, usageResponse] = await Promise.all([
|
||||
healthResponsePromise,
|
||||
usageResponsePromise,
|
||||
]);
|
||||
await expectResponseOk(healthResponse);
|
||||
await expectResponseOk(usageResponse);
|
||||
|
||||
const health = (await healthResponse.json()) as AdminHealthResponse;
|
||||
const usage = (await usageResponse.json()) as AdminUsageResponse;
|
||||
expect(health.services.length).toBeGreaterThan(0);
|
||||
return health;
|
||||
return { health, usage };
|
||||
}
|
||||
|
||||
async function openAdminAndReadUsers(page: Page) {
|
||||
|
|
@ -225,7 +275,7 @@ test.describe("admin route", () => {
|
|||
await signInAsAdmin(page);
|
||||
|
||||
await withGlobalEngineConfigLock("admin-health-dashboard", async () => {
|
||||
const health = await openAdminAndReadHealth(page);
|
||||
const { health, usage } = await openAdminAndReadHealth(page);
|
||||
const serviceCards = page.locator(".ad-service:not(.ad-service--skeleton)");
|
||||
const counts = {
|
||||
ok: health.services.filter((service) => service.status === "ok").length,
|
||||
|
|
@ -242,6 +292,34 @@ test.describe("admin route", () => {
|
|||
String(counts.degraded),
|
||||
String(counts.down),
|
||||
]);
|
||||
await expect(page.getByRole("heading", { name: "AI 비용 관측" })).toBeVisible();
|
||||
await expect(page.locator(".ad-usage-kpi b")).toHaveText([
|
||||
costLabel(usage.cost_usd),
|
||||
countLabel(usage.tokens_in),
|
||||
countLabel(usage.tokens_out),
|
||||
usage.total_turns > 0
|
||||
? `${Math.round((usage.metered_turns / usage.total_turns) * 100)}%`
|
||||
: "0%",
|
||||
]);
|
||||
await expect(page.locator(".ad-usage-budget")).toContainText(
|
||||
usage.budget.status === "disabled"
|
||||
? "예산 경고 비활성"
|
||||
: usage.budget.status === "exceeded"
|
||||
? "예산 초과"
|
||||
: usage.budget.status === "warn"
|
||||
? "예산 주의"
|
||||
: "예산 정상",
|
||||
);
|
||||
if (usage.by_provider.length > 0) {
|
||||
await expect(page.locator(".ad-usage-row")).toHaveCount(usage.by_provider.length);
|
||||
await expect(page.locator(".ad-usage-row").first()).toContainText(
|
||||
usage.by_provider[0].provider,
|
||||
);
|
||||
} else {
|
||||
await expect(page.locator(".ad-usage-breakdown")).toContainText(
|
||||
"최근 윈도우에 계량된 AI 턴이 없습니다.",
|
||||
);
|
||||
}
|
||||
await expect(serviceCards).toHaveCount(health.services.length);
|
||||
|
||||
for (const service of health.services) {
|
||||
|
|
|
|||
|
|
@ -69,10 +69,8 @@ test.describe("auth domain policy", () => {
|
|||
const googleButtons = page.locator(".lg-obtn");
|
||||
await expect(googleButtons).toHaveCount(2);
|
||||
await expect(page.locator(".lg-policy b")).toContainText(config.allowed_email_domains);
|
||||
const currentHost = new URL(page.url()).hostname;
|
||||
const redirectHost = new URL(config.redirect_uri).hostname;
|
||||
const localOAuthUnavailable =
|
||||
isLocalHostname(currentHost) &&
|
||||
const devOAuthUnavailable =
|
||||
config.dev_login_enabled &&
|
||||
!isLocalHostname(redirectHost);
|
||||
if (config.dev_login_enabled) {
|
||||
|
|
@ -81,7 +79,7 @@ test.describe("auth domain policy", () => {
|
|||
await expect(page.locator(".lg-dev")).toHaveCount(0);
|
||||
}
|
||||
|
||||
if (config.google_oauth_configured && !localOAuthUnavailable) {
|
||||
if (config.google_oauth_configured && !devOAuthUnavailable) {
|
||||
await expect(googleButtons.first()).toBeEnabled();
|
||||
await expect(page.locator(".lg-config")).toHaveCount(0);
|
||||
} else {
|
||||
|
|
@ -91,8 +89,11 @@ test.describe("auth domain policy", () => {
|
|||
await expect(page).toHaveURL(/\/login$/);
|
||||
await expect(page.locator("body")).not.toContainText("Google OAuth is not configured");
|
||||
|
||||
if (localOAuthUnavailable) {
|
||||
if (devOAuthUnavailable) {
|
||||
await expect(page.locator(".lg-config")).toContainText("로컬 테스트 계정으로 로그인");
|
||||
await page.goto("/api/auth/login?provider=google&next=%2Flearn");
|
||||
await expect(page).toHaveURL(/\/login\?oauth=local_oauth_unavailable$/);
|
||||
await expect(page.locator(".lg-error")).toContainText("로컬 개발 주소에서는 Google OAuth");
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -103,6 +104,14 @@ test.describe("auth domain policy", () => {
|
|||
}
|
||||
});
|
||||
|
||||
test("shows the concrete OAuth failure reason on the login screen", async ({ page }) => {
|
||||
await page.goto("/login?oauth=provider_error");
|
||||
const error = page.locator(".lg-error");
|
||||
|
||||
await expect(error).toContainText("Google이 인증 코드를 발급하지 못했습니다");
|
||||
await expect(error).toContainText("오류 코드: provider_error");
|
||||
});
|
||||
|
||||
test("logs in locally with the server dev session and redirects to learner home", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
|
|
|||
301
apps/web/package-lock.json
generated
301
apps/web/package-lock.json
generated
|
|
@ -17,6 +17,7 @@
|
|||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"openapi-typescript": "^7.13.0",
|
||||
"puppeteer-core": "^25.2.1",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^6.3.5"
|
||||
|
|
@ -837,6 +838,52 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@redocly/ajv": {
|
||||
"version": "8.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz",
|
||||
"integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"json-schema-traverse": "^1.0.0",
|
||||
"require-from-string": "^2.0.2",
|
||||
"uri-js-replace": "^1.0.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/@redocly/config": {
|
||||
"version": "0.22.0",
|
||||
"resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.0.tgz",
|
||||
"integrity": "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@redocly/openapi-core": {
|
||||
"version": "1.34.16",
|
||||
"resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.16.tgz",
|
||||
"integrity": "sha512-zIgmQTT2TV/U/SJ3N4jlIw36erH6X8ga1UNIoyrlbr0yLEbsiII/16LZ0kMxWu2A8pw0xd56rwTz5sMudy2OAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@redocly/ajv": "8.11.2",
|
||||
"@redocly/config": "0.22.0",
|
||||
"colorette": "1.4.0",
|
||||
"https-proxy-agent": "7.0.6",
|
||||
"js-levenshtein": "1.1.6",
|
||||
"js-yaml": "4.2.0",
|
||||
"minimatch": "5.1.9",
|
||||
"pluralize": "8.0.0",
|
||||
"yaml-ast-parser": "0.0.43"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.17.0",
|
||||
"npm": ">=9.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@remix-run/router": {
|
||||
"version": "1.23.3",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz",
|
||||
|
|
@ -1296,6 +1343,26 @@
|
|||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-colors": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz",
|
||||
"integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
|
||||
|
|
@ -1322,6 +1389,20 @@
|
|||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/argparse": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||
"dev": true,
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.10.38",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz",
|
||||
|
|
@ -1335,6 +1416,16 @@
|
|||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz",
|
||||
"integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.28.4",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz",
|
||||
|
|
@ -1390,6 +1481,13 @@
|
|||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/change-case": {
|
||||
"version": "5.4.4",
|
||||
"resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz",
|
||||
"integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/chromium-bidi": {
|
||||
"version": "16.0.1",
|
||||
"resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-16.0.1.tgz",
|
||||
|
|
@ -1422,6 +1520,13 @@
|
|||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/colorette": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz",
|
||||
"integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/convert-source-map": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
|
|
@ -1527,6 +1632,13 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
|
|
@ -1593,6 +1705,43 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/index-to-position": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz",
|
||||
"integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/js-levenshtein": {
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz",
|
||||
"integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
|
|
@ -1600,6 +1749,29 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz",
|
||||
"integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/puzrin"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/nodeca"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"node_modules/jsesc": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
||||
|
|
@ -1613,6 +1785,13 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/json-schema-traverse": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json5": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
||||
|
|
@ -1636,6 +1815,19 @@
|
|||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "5.1.9",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
|
||||
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/mitt": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
|
||||
|
|
@ -1689,6 +1881,55 @@
|
|||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/openapi-typescript": {
|
||||
"version": "7.13.0",
|
||||
"resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.13.0.tgz",
|
||||
"integrity": "sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@redocly/openapi-core": "^1.34.6",
|
||||
"ansi-colors": "^4.1.3",
|
||||
"change-case": "^5.4.4",
|
||||
"parse-json": "^8.3.0",
|
||||
"supports-color": "^10.2.2",
|
||||
"yargs-parser": "^21.1.1"
|
||||
},
|
||||
"bin": {
|
||||
"openapi-typescript": "bin/cli.js"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5.x"
|
||||
}
|
||||
},
|
||||
"node_modules/openapi-typescript/node_modules/yargs-parser": {
|
||||
"version": "21.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
|
||||
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/parse-json": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz",
|
||||
"integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.26.2",
|
||||
"index-to-position": "^1.1.0",
|
||||
"type-fest": "^4.39.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
|
|
@ -1756,6 +1997,16 @@
|
|||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pluralize": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
|
||||
"integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.15",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||
|
|
@ -1866,6 +2117,16 @@
|
|||
"react-dom": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.62.2",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
|
||||
|
|
@ -1971,6 +2232,19 @@
|
|||
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "10.2.2",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
|
||||
"integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.17",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||
|
|
@ -1988,6 +2262,19 @@
|
|||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/type-fest": {
|
||||
"version": "4.41.0",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
|
||||
"integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
|
||||
"dev": true,
|
||||
"license": "(MIT OR CC0-1.0)",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/typed-query-selector": {
|
||||
"version": "2.12.2",
|
||||
"resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.2.tgz",
|
||||
|
|
@ -2040,6 +2327,13 @@
|
|||
"browserslist": ">= 4.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/uri-js-replace": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz",
|
||||
"integrity": "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "6.4.3",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
|
||||
|
|
@ -2179,6 +2473,13 @@
|
|||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yaml-ast-parser": {
|
||||
"version": "0.0.43",
|
||||
"resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz",
|
||||
"integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "18.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz",
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@
|
|||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"generate:api-types": "node scripts/generate-api-types.mjs",
|
||||
"check:api-types": "node scripts/generate-api-types.mjs --check",
|
||||
"generate:live2d-assets": "node scripts/generate-live2d-assets.mjs",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc -b",
|
||||
|
|
@ -28,6 +30,7 @@
|
|||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"openapi-typescript": "^7.13.0",
|
||||
"puppeteer-core": "^25.2.1",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^6.3.5"
|
||||
|
|
|
|||
BIN
apps/web/public/design-elements/clinical-paper-ambient.png
Normal file
BIN
apps/web/public/design-elements/clinical-paper-ambient.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.2 MiB |
BIN
apps/web/public/design-elements/warm-visual-elements.png
Normal file
BIN
apps/web/public/design-elements/warm-visual-elements.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 MiB |
57
apps/web/scripts/generate-api-types.mjs
Normal file
57
apps/web/scripts/generate-api-types.mjs
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { existsSync } from "node:fs";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const webRoot = resolve(scriptDir, "..");
|
||||
const repoRoot = resolve(webRoot, "..", "..");
|
||||
const apiRoot = resolve(repoRoot, "apps", "api");
|
||||
const schemaPath = resolve(webRoot, "node_modules", ".tmp", "openapi.json");
|
||||
const outPath = resolve(webRoot, "src", "lib", "api.gen.ts");
|
||||
const exporter = resolve(apiRoot, "scripts", "export-openapi.py");
|
||||
const checkOnly = process.argv.includes("--check");
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: options.cwd ?? webRoot,
|
||||
env: process.env,
|
||||
stdio: options.stdio ?? "inherit",
|
||||
shell: options.shell ?? false,
|
||||
});
|
||||
if (result.status === 0) return true;
|
||||
if (options.allowFailure) return false;
|
||||
const rendered = [command, ...args].join(" ");
|
||||
throw new Error(`${rendered} failed with exit code ${result.status}`);
|
||||
}
|
||||
|
||||
async function exportOpenApi() {
|
||||
await mkdir(dirname(schemaPath), { recursive: true });
|
||||
const configured = process.env.PYTHON?.trim();
|
||||
if (configured && run(configured, [exporter, schemaPath], { cwd: apiRoot, allowFailure: true })) {
|
||||
return;
|
||||
}
|
||||
if (process.platform === "win32" && run("py", ["-3.11", exporter, schemaPath], { cwd: apiRoot, allowFailure: true })) {
|
||||
return;
|
||||
}
|
||||
if (run("python", [exporter, schemaPath], { cwd: apiRoot, allowFailure: true })) {
|
||||
return;
|
||||
}
|
||||
throw new Error("Python 3.11 with apps/api dependencies is required to export OpenAPI.");
|
||||
}
|
||||
|
||||
function generateTypes() {
|
||||
const bin = process.platform === "win32"
|
||||
? resolve(webRoot, "node_modules", ".bin", "openapi-typescript.cmd")
|
||||
: resolve(webRoot, "node_modules", ".bin", "openapi-typescript");
|
||||
if (!existsSync(bin)) {
|
||||
throw new Error("openapi-typescript is not installed. Run npm install first.");
|
||||
}
|
||||
const args = [schemaPath, "-o", outPath];
|
||||
if (checkOnly) args.push("--check");
|
||||
run(bin, args, { shell: process.platform === "win32" });
|
||||
}
|
||||
|
||||
await exportOpenApi();
|
||||
generateTypes();
|
||||
|
|
@ -71,6 +71,38 @@
|
|||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
/* 운영 콘솔은 생성 시안처럼 어두운 크롬을 쓴다. 본문 컴포넌트 토큰은 그대로 유지한다. */
|
||||
body[data-role="admin"] .vg-topbar {
|
||||
background: #17211f;
|
||||
border-bottom-color: rgba(255, 255, 255, 0.08);
|
||||
color: #eef4f2;
|
||||
}
|
||||
body[data-role="admin"] .vg-topbar__wm,
|
||||
body[data-role="admin"] .vg-topbar__role,
|
||||
body[data-role="admin"] .vg-topbar__uname {
|
||||
color: #eef4f2;
|
||||
}
|
||||
body[data-role="admin"] .vg-topbar__brand svg {
|
||||
color: #7eb8ad;
|
||||
}
|
||||
body[data-role="admin"] .vg-topbar__user {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
body[data-role="admin"] .vg-topbar__avatar {
|
||||
background: rgba(126, 184, 173, 0.18);
|
||||
color: #bfe0d9;
|
||||
}
|
||||
body[data-role="admin"] .vg-topbar__role {
|
||||
border-left-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
body[data-role="admin"] .vg-iconbtn {
|
||||
color: rgba(238, 244, 242, 0.78);
|
||||
}
|
||||
body[data-role="admin"] .vg-iconbtn:hover {
|
||||
background: rgba(255, 255, 255, 0.09);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* 톱바 아이콘 버튼 (테마/로그아웃) */
|
||||
.vg-iconbtn {
|
||||
display: inline-flex;
|
||||
|
|
@ -209,6 +241,40 @@
|
|||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
body[data-role="admin"] .vg-shell__body {
|
||||
background-image: linear-gradient(rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.08));
|
||||
}
|
||||
body[data-role="admin"] .vg-nav {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(23, 33, 31, 0.98), rgba(26, 38, 42, 0.98)),
|
||||
var(--asset-warm-elements) center / cover no-repeat;
|
||||
color: #eef4f2;
|
||||
}
|
||||
body[data-role="admin"] .vg-nav__label,
|
||||
body[data-role="admin"] .vg-nav__ethic {
|
||||
color: rgba(238, 244, 242, 0.54);
|
||||
}
|
||||
body[data-role="admin"] .vg-nav__item {
|
||||
color: rgba(238, 244, 242, 0.76);
|
||||
}
|
||||
body[data-role="admin"] .vg-nav__item:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #ffffff;
|
||||
}
|
||||
body[data-role="admin"] .vg-nav__item .vg-nav__ic {
|
||||
color: rgba(191, 224, 217, 0.72);
|
||||
}
|
||||
body[data-role="admin"] .vg-nav__item.is-active {
|
||||
background: rgba(126, 184, 173, 0.22);
|
||||
color: #ffffff;
|
||||
}
|
||||
body[data-role="admin"] .vg-nav__item.is-active .vg-nav__ic {
|
||||
color: #91c8bd;
|
||||
}
|
||||
body[data-role="admin"] .vg-nav__foot {
|
||||
border-top-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* ── 메인 콘텐츠 ── */
|
||||
.vg-main {
|
||||
min-width: 0; /* 그리드 자식 overflow 방지 */
|
||||
|
|
|
|||
3723
apps/web/src/lib/api.gen.ts
Normal file
3723
apps/web/src/lib/api.gen.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -6,6 +6,10 @@
|
|||
- SSE: POST /sessions/{id}/stream 의 token/done/ping/error 이벤트를 콜백으로 전달.
|
||||
===================================================================== */
|
||||
|
||||
import type { components } from "./api.gen";
|
||||
|
||||
type ApiSchema<Name extends keyof components["schemas"]> = components["schemas"][Name];
|
||||
|
||||
// Vite 환경변수. 기본 "/api" (vite proxy 또는 nginx 가 백엔드로 라우팅).
|
||||
const PUBLIC_API_ORIGIN_BY_HOST: Record<string, string> = {
|
||||
"vignette.chanpaca.net": "https://api-vignette.chanpaca.net",
|
||||
|
|
@ -209,6 +213,9 @@ export interface PersonaReviewSummary {
|
|||
approved_at: string | null;
|
||||
}
|
||||
|
||||
export type PersonaDraftPayload = ApiSchema<"PersonaDraftPayload">;
|
||||
export type PersonaDraftDetail = ApiSchema<"PersonaDraftDetail">;
|
||||
|
||||
/** POST /sessions — sessions.py SessionStartResponse */
|
||||
export interface SessionStartResponse {
|
||||
session_id: string;
|
||||
|
|
@ -221,19 +228,15 @@ export interface SessionStartResponse {
|
|||
}
|
||||
|
||||
/** POST /sessions/{id}/turn — sessions.py TurnResponse */
|
||||
export interface TurnResponse {
|
||||
turn_seq: number;
|
||||
stage: SessionStage;
|
||||
effective_openness: number;
|
||||
client_reply: string | null;
|
||||
safety_flagged: boolean;
|
||||
}
|
||||
export type TurnResponse = ApiSchema<"TurnResponse">;
|
||||
|
||||
/** POST /sessions/{id}/end — sessions.py SessionEndResponse */
|
||||
export interface SessionEndResponse {
|
||||
session_id: string;
|
||||
session_no: number;
|
||||
digest_pending: boolean;
|
||||
export type SessionEndResponse = ApiSchema<"SessionEndResponse">;
|
||||
|
||||
export interface CrisisResource {
|
||||
title: string;
|
||||
number: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface LearnerSessionSummary {
|
||||
|
|
@ -290,6 +293,12 @@ export interface ReviewTechnique {
|
|||
label: string;
|
||||
}
|
||||
|
||||
export interface ReviewNonverbalEvent {
|
||||
kind: "audio" | "silence" | "pace" | "barge_in";
|
||||
label: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface ReviewNote {
|
||||
author: "ai" | "instructor" | string;
|
||||
tone: "good" | "watch";
|
||||
|
|
@ -305,6 +314,7 @@ export interface ReviewTurn {
|
|||
who: string;
|
||||
text: string;
|
||||
techniques: ReviewTechnique[];
|
||||
nonverbal: ReviewNonverbalEvent[];
|
||||
note?: ReviewNote | null;
|
||||
}
|
||||
|
||||
|
|
@ -333,6 +343,34 @@ export interface ReviewPoint {
|
|||
jumpTo?: string | null;
|
||||
}
|
||||
|
||||
export interface ReviewWorksheetEvidence {
|
||||
turnId: string;
|
||||
speaker: "learner" | "client";
|
||||
quote: string;
|
||||
}
|
||||
|
||||
export interface ReviewWorksheetItem {
|
||||
key: string;
|
||||
label: string;
|
||||
value?: string | null;
|
||||
evidence: ReviewWorksheetEvidence[];
|
||||
confidence: "none" | "low" | "medium";
|
||||
emptyReason?: string | null;
|
||||
}
|
||||
|
||||
export interface ReviewWorksheetSection {
|
||||
key: string;
|
||||
title: string;
|
||||
items: ReviewWorksheetItem[];
|
||||
}
|
||||
|
||||
export interface ReviewCaseWorksheet {
|
||||
status: "empty" | "draft_from_transcript";
|
||||
generatedBy: string;
|
||||
sections: ReviewWorksheetSection[];
|
||||
limitations: string[];
|
||||
}
|
||||
|
||||
export interface SessionReviewResponse {
|
||||
session_id: string;
|
||||
client: ReviewClient;
|
||||
|
|
@ -353,6 +391,7 @@ export interface SessionReviewResponse {
|
|||
rubric: ReviewRubricRow[];
|
||||
goodMoments: ReviewPoint[];
|
||||
growthPoints: ReviewPoint[];
|
||||
caseWorksheet?: ReviewCaseWorksheet | null;
|
||||
nextLine?: string | null;
|
||||
clientFeedback?: string | null;
|
||||
audioUrl?: string | null;
|
||||
|
|
@ -388,6 +427,9 @@ export interface SessionStreamDone {
|
|||
effective_openness?: number;
|
||||
turn_seq?: number;
|
||||
safety_flagged?: boolean;
|
||||
crisis_kind?: string;
|
||||
crisis_resource?: CrisisResource | null;
|
||||
conversation_stopped?: boolean;
|
||||
}
|
||||
|
||||
function safeParse(data: string): unknown {
|
||||
|
|
@ -453,6 +495,9 @@ export async function openSessionStream(
|
|||
effective_openness: parsed.effective_openness,
|
||||
turn_seq: parsed.turn_seq,
|
||||
safety_flagged: parsed.safety_flagged,
|
||||
crisis_kind: parsed.crisis_kind,
|
||||
crisis_resource: parsed.crisis_resource,
|
||||
conversation_stopped: parsed.conversation_stopped,
|
||||
};
|
||||
handlers.onDone?.(donePayload);
|
||||
return;
|
||||
|
|
@ -520,6 +565,12 @@ export const personaReviewApi = {
|
|||
api.post<PersonaReviewSummary>(`/personas/review/${encodeURIComponent(personaId)}`, {
|
||||
action,
|
||||
}),
|
||||
createDraft: (payload: PersonaDraftPayload) =>
|
||||
api.post<PersonaReviewSummary>("/personas/drafts", payload),
|
||||
getDraft: (personaId: string) =>
|
||||
api.get<PersonaDraftDetail>(`/personas/drafts/${encodeURIComponent(personaId)}`),
|
||||
updateDraft: (personaId: string, payload: PersonaDraftPayload) =>
|
||||
api.put<PersonaReviewSummary>(`/personas/drafts/${encodeURIComponent(personaId)}`, payload),
|
||||
};
|
||||
|
||||
export const sessionApi = {
|
||||
|
|
@ -568,8 +619,39 @@ export interface AdminHealthResponse {
|
|||
services: AdminServiceHealth[];
|
||||
}
|
||||
|
||||
export interface AdminUsageBreakdown {
|
||||
provider: string;
|
||||
model: string;
|
||||
turns: number;
|
||||
tokens_in: number;
|
||||
tokens_out: number;
|
||||
cost_usd: number;
|
||||
}
|
||||
|
||||
export interface AdminUsageBudget {
|
||||
limit_usd: number;
|
||||
used_ratio: number;
|
||||
remaining_usd: number | null;
|
||||
status: "disabled" | "ok" | "warn" | "exceeded";
|
||||
}
|
||||
|
||||
export interface AdminUsageResponse {
|
||||
source: "database" | "server_session_registry";
|
||||
durable: boolean;
|
||||
window_days: number;
|
||||
generated_at: number;
|
||||
total_turns: number;
|
||||
metered_turns: number;
|
||||
tokens_in: number;
|
||||
tokens_out: number;
|
||||
cost_usd: number;
|
||||
budget: AdminUsageBudget;
|
||||
by_provider: AdminUsageBreakdown[];
|
||||
}
|
||||
|
||||
export const adminApi = {
|
||||
health: () => api.get<AdminHealthResponse>("/admin/health"),
|
||||
usage: (windowDays = 7) => api.get<AdminUsageResponse>(`/admin/usage?window_days=${windowDays}`),
|
||||
};
|
||||
|
||||
export interface AdminManagedUser {
|
||||
|
|
@ -629,12 +711,58 @@ export interface TeacherSessionSummary {
|
|||
ended_at: string | null;
|
||||
}
|
||||
|
||||
export interface TeacherSafetyAlert {
|
||||
id: string;
|
||||
session_id: string;
|
||||
learner_id: string;
|
||||
learner_label: string;
|
||||
persona_code: string;
|
||||
session_no: number;
|
||||
trigger_type: string;
|
||||
ko_risk_level: number;
|
||||
escalated: boolean;
|
||||
created_at: string;
|
||||
resource_title: string;
|
||||
resource_number: string;
|
||||
}
|
||||
|
||||
export interface TeacherGrowthPoint {
|
||||
session_id: string;
|
||||
session_no: number;
|
||||
persona_code: string;
|
||||
stage: string;
|
||||
started_at: string;
|
||||
ended_at: string | null;
|
||||
score: number | null;
|
||||
rapport: number | null;
|
||||
technique_count: number;
|
||||
watch_count: number;
|
||||
}
|
||||
|
||||
export interface TeacherLearnerGrowth {
|
||||
learner_id: string;
|
||||
learner_label: string;
|
||||
sessions: number;
|
||||
ended_sessions: number;
|
||||
latest_at: string;
|
||||
first_score: number | null;
|
||||
latest_score: number | null;
|
||||
score_delta: number | null;
|
||||
avg_score: number | null;
|
||||
avg_rapport: number | null;
|
||||
trend: "up" | "down" | "flat" | "insufficient" | string;
|
||||
top_techniques: string[];
|
||||
points: TeacherGrowthPoint[];
|
||||
}
|
||||
|
||||
export interface TeacherDashboardResponse {
|
||||
source: string;
|
||||
cohort_label: string;
|
||||
total_learners: number;
|
||||
active_sessions: number;
|
||||
ended_sessions: number;
|
||||
safety_alerts: TeacherSafetyAlert[];
|
||||
learner_growth: TeacherLearnerGrowth[];
|
||||
pending_reviews: TeacherSessionSummary[];
|
||||
recent_sessions: TeacherSessionSummary[];
|
||||
message: string;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -111,6 +111,9 @@
|
|||
--shadow-sm: 0 1px 2px rgba(28, 42, 42, 0.05);
|
||||
--shadow-md: 0 4px 14px rgba(28, 42, 42, 0.07); /* 모달·팝오버만 */
|
||||
|
||||
/* ── 래스터 디자인 요소 ── */
|
||||
--asset-warm-elements: url("/design-elements/clinical-paper-ambient.png");
|
||||
|
||||
/* ── 모션 §3.9 ── */
|
||||
--ease-out: cubic-bezier(0.22, 0.61, 0.36, 1);
|
||||
--ease-in-out: cubic-bezier(0.45, 0.05, 0.55, 0.95);
|
||||
|
|
|
|||
|
|
@ -25,6 +25,17 @@ export default defineConfig({
|
|||
target: apiProxyTarget,
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
configure(proxy) {
|
||||
proxy.on("proxyReq", (proxyReq, req) => {
|
||||
const forwardedHost = req.headers.host;
|
||||
if (forwardedHost) {
|
||||
proxyReq.setHeader("x-forwarded-host", forwardedHost);
|
||||
const hostName = String(forwardedHost).split(":")[0];
|
||||
const forwardedProto = allowedHosts.includes(hostName) ? "https" : "http";
|
||||
proxyReq.setHeader("x-forwarded-proto", forwardedProto);
|
||||
}
|
||||
});
|
||||
},
|
||||
// 백엔드 라우트는 /auth, /sessions, /health 로 노출되므로 /api 프리픽스 제거.
|
||||
rewrite: (path) => path.replace(/^\/api/, ""),
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue