운영 화면과 회기 리뷰 UI 갱신
This commit is contained in:
parent
e7ebb38177
commit
3a9f70a97b
18 changed files with 2210 additions and 137 deletions
|
|
@ -411,7 +411,11 @@ const AVATAR_CSS = `
|
|||
.vg-avatar__stage{
|
||||
position:relative;width:100%;border-radius:50%;
|
||||
display:flex;align-items:center;justify-content:center;overflow:hidden;
|
||||
background:var(--bg-stage);
|
||||
background:
|
||||
radial-gradient(circle at 50% 38%, rgba(251,250,248,.98) 0%, rgba(246,240,236,.9) 42%, rgba(238,244,242,.76) 64%, rgba(145,200,189,.22) 84%, rgba(30,39,36,.18) 100%);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(251,250,248,.28),
|
||||
inset 0 -24px 40px rgba(28,42,42,.13);
|
||||
}
|
||||
.vg-avatar__aura{
|
||||
position:absolute;inset:-12%;border-radius:50%;pointer-events:none;
|
||||
|
|
|
|||
|
|
@ -1188,6 +1188,24 @@ export interface paths {
|
|||
patch: operations["patch_preferences_users_me_preferences_patch"];
|
||||
trace?: never;
|
||||
};
|
||||
"/users/me/prepost-measures": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** List My Prepost Measures */
|
||||
get: operations["list_my_prepost_measures_users_me_prepost_measures_get"];
|
||||
/** Upsert My Prepost Measure */
|
||||
put: operations["upsert_my_prepost_measure_users_me_prepost_measures_put"];
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/users/me/voice-presets": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -1212,7 +1230,8 @@ export interface paths {
|
|||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
/** List My Support Tickets */
|
||||
get: operations["list_my_support_tickets_users_support_tickets_get"];
|
||||
put?: never;
|
||||
/** Create Support Ticket */
|
||||
post: operations["create_support_ticket_users_support_tickets_post"];
|
||||
|
|
@ -1349,15 +1368,31 @@ export interface components {
|
|||
* @enum {string}
|
||||
*/
|
||||
category: "account_access" | "session_review" | "voice_browser" | "content_scenario" | "safety" | "other";
|
||||
/**
|
||||
* Child Ticket Count
|
||||
* @default 0
|
||||
*/
|
||||
child_ticket_count: number;
|
||||
/** Created At */
|
||||
created_at: number;
|
||||
/**
|
||||
* Duplicate Count
|
||||
* @default 0
|
||||
*/
|
||||
duplicate_count: number;
|
||||
/** Duplicate Parent Candidate Id */
|
||||
duplicate_parent_candidate_id?: string | null;
|
||||
/**
|
||||
* Event Count
|
||||
* @default 0
|
||||
*/
|
||||
event_count: number;
|
||||
/** Fingerprint */
|
||||
fingerprint: string;
|
||||
/** Last Event At */
|
||||
last_event_at?: number | null;
|
||||
/** Parent Ticket Id */
|
||||
parent_ticket_id?: string | null;
|
||||
/**
|
||||
* Priority
|
||||
* @enum {string}
|
||||
|
|
@ -1386,6 +1421,8 @@ export interface components {
|
|||
AdminTicketPatch: {
|
||||
/** Assigned Group */
|
||||
assigned_group?: string | null;
|
||||
/** Parent Ticket Id */
|
||||
parent_ticket_id?: string | null;
|
||||
/** Priority */
|
||||
priority?: ("low" | "normal" | "high" | "urgent") | null;
|
||||
/** Resolution Note */
|
||||
|
|
@ -1521,6 +1558,38 @@ export interface components {
|
|||
/** Used Ratio */
|
||||
used_ratio: number;
|
||||
};
|
||||
/** AdminUsageDailyCost */
|
||||
AdminUsageDailyCost: {
|
||||
/** Cost Usd */
|
||||
cost_usd: number;
|
||||
/** Day */
|
||||
day: string;
|
||||
/** Tokens In */
|
||||
tokens_in: number;
|
||||
/** Tokens Out */
|
||||
tokens_out: number;
|
||||
/** Turns */
|
||||
turns: number;
|
||||
};
|
||||
/** AdminUsageEvaluatorCache */
|
||||
AdminUsageEvaluatorCache: {
|
||||
/** Enabled */
|
||||
enabled: boolean;
|
||||
/** Entries */
|
||||
entries: number;
|
||||
/** Evictions */
|
||||
evictions: number;
|
||||
/** Hit Rate */
|
||||
hit_rate: number;
|
||||
/** Hits */
|
||||
hits: number;
|
||||
/** Misses */
|
||||
misses: number;
|
||||
/** Requests */
|
||||
requests: number;
|
||||
/** Stores */
|
||||
stores: number;
|
||||
};
|
||||
/** AdminUsageResponse */
|
||||
AdminUsageResponse: {
|
||||
budget: components["schemas"]["AdminUsageBudget"];
|
||||
|
|
@ -1528,8 +1597,11 @@ export interface components {
|
|||
by_provider: components["schemas"]["AdminUsageBreakdown"][];
|
||||
/** Cost Usd */
|
||||
cost_usd: number;
|
||||
/** Daily Cost */
|
||||
daily_cost?: components["schemas"]["AdminUsageDailyCost"][];
|
||||
/** Durable */
|
||||
durable: boolean;
|
||||
evaluator_cache: components["schemas"]["AdminUsageEvaluatorCache"];
|
||||
/** Generated At */
|
||||
generated_at: number;
|
||||
/** Metered Turns */
|
||||
|
|
@ -2904,7 +2976,7 @@ export interface components {
|
|||
* Kind
|
||||
* @enum {string}
|
||||
*/
|
||||
kind: "audio" | "silence" | "pace" | "barge_in";
|
||||
kind: "audio" | "silence" | "pace" | "barge_in" | "paralinguistic" | "prosody" | "audio_quality";
|
||||
/** Label */
|
||||
label: string;
|
||||
};
|
||||
|
|
@ -3649,6 +3721,102 @@ export interface components {
|
|||
*/
|
||||
voice_rate: number;
|
||||
};
|
||||
/** UserPrepostMeasureItem */
|
||||
UserPrepostMeasureItem: {
|
||||
/** Collected At */
|
||||
collected_at: number;
|
||||
/** Instrument Version */
|
||||
instrument_version: string;
|
||||
/** Item Count */
|
||||
item_count: number;
|
||||
/** Max Score */
|
||||
max_score: number;
|
||||
/** Measure Id */
|
||||
measure_id: string;
|
||||
/**
|
||||
* Measure Name
|
||||
* @enum {string}
|
||||
*/
|
||||
measure_name: "self_efficacy" | "skill_proficiency" | "training_satisfaction";
|
||||
/** Min Score */
|
||||
min_score: number;
|
||||
/** Normalized Score */
|
||||
normalized_score: number;
|
||||
/** Pilot Id */
|
||||
pilot_id: string;
|
||||
/** Raw Score */
|
||||
raw_score: number;
|
||||
/**
|
||||
* Timepoint
|
||||
* @enum {string}
|
||||
*/
|
||||
timepoint: "pre" | "post";
|
||||
/** Updated At */
|
||||
updated_at: number;
|
||||
};
|
||||
/** UserPrepostMeasureRequest */
|
||||
UserPrepostMeasureRequest: {
|
||||
/**
|
||||
* Instrument Version
|
||||
* @default pilot-prepost-scaffold-2026-06-28
|
||||
*/
|
||||
instrument_version: string;
|
||||
/**
|
||||
* Item Count
|
||||
* @default 1
|
||||
*/
|
||||
item_count: number;
|
||||
/**
|
||||
* Max Score
|
||||
* @default 5
|
||||
*/
|
||||
max_score: number;
|
||||
/**
|
||||
* Measure Name
|
||||
* @enum {string}
|
||||
*/
|
||||
measure_name: "self_efficacy" | "skill_proficiency" | "training_satisfaction";
|
||||
/**
|
||||
* Min Score
|
||||
* @default 1
|
||||
*/
|
||||
min_score: number;
|
||||
/**
|
||||
* Pilot Id
|
||||
* @default phase3-pilot-draft
|
||||
*/
|
||||
pilot_id: string;
|
||||
/** Raw Score */
|
||||
raw_score: number;
|
||||
/**
|
||||
* Timepoint
|
||||
* @enum {string}
|
||||
*/
|
||||
timepoint: "pre" | "post";
|
||||
};
|
||||
/** UserPrepostMeasuresResponse */
|
||||
UserPrepostMeasuresResponse: {
|
||||
/** Complete Measure Pairs */
|
||||
complete_measure_pairs: number;
|
||||
/** Durable */
|
||||
durable: boolean;
|
||||
/** Generated At */
|
||||
generated_at: number;
|
||||
/** Measures */
|
||||
measures: components["schemas"]["UserPrepostMeasureItem"][];
|
||||
/** Pilot Id */
|
||||
pilot_id: string;
|
||||
/** Required Measure Names */
|
||||
required_measure_names: ("self_efficacy" | "skill_proficiency" | "training_satisfaction")[];
|
||||
/** Required Timepoints */
|
||||
required_timepoints: ("pre" | "post")[];
|
||||
/**
|
||||
* Source
|
||||
* @constant
|
||||
* @enum {string}
|
||||
*/
|
||||
source: "database";
|
||||
};
|
||||
/** UserProfilePatch */
|
||||
UserProfilePatch: {
|
||||
/** Affiliation */
|
||||
|
|
@ -3751,6 +3919,40 @@ export interface components {
|
|||
/** User Id */
|
||||
user_id: string;
|
||||
};
|
||||
/** UserSupportTicketListItem */
|
||||
UserSupportTicketListItem: {
|
||||
/** Assigned Group */
|
||||
assigned_group: string;
|
||||
/**
|
||||
* Category
|
||||
* @enum {string}
|
||||
*/
|
||||
category: "account_access" | "session_review" | "voice_browser" | "content_scenario" | "safety" | "other";
|
||||
/** Created At */
|
||||
created_at: number;
|
||||
/**
|
||||
* Priority
|
||||
* @enum {string}
|
||||
*/
|
||||
priority: "low" | "normal" | "high" | "urgent";
|
||||
/** Resolution Note */
|
||||
resolution_note: string;
|
||||
/** Resolved At */
|
||||
resolved_at?: number | null;
|
||||
/** Source Path */
|
||||
source_path: string;
|
||||
/**
|
||||
* Status
|
||||
* @enum {string}
|
||||
*/
|
||||
status: "open" | "triaged" | "in_progress" | "resolved" | "closed";
|
||||
/** Subject */
|
||||
subject: string;
|
||||
/** Ticket Id */
|
||||
ticket_id: string;
|
||||
/** Updated At */
|
||||
updated_at: number;
|
||||
};
|
||||
/** UserSupportTicketRequest */
|
||||
UserSupportTicketRequest: {
|
||||
/** Body */
|
||||
|
|
@ -3800,6 +4002,21 @@ export interface components {
|
|||
/** Ticket Id */
|
||||
ticket_id: string;
|
||||
};
|
||||
/** UserSupportTicketsResponse */
|
||||
UserSupportTicketsResponse: {
|
||||
/** Durable */
|
||||
durable: boolean;
|
||||
/** Generated At */
|
||||
generated_at: number;
|
||||
/**
|
||||
* Source
|
||||
* @constant
|
||||
* @enum {string}
|
||||
*/
|
||||
source: "database";
|
||||
/** Tickets */
|
||||
tickets: components["schemas"]["UserSupportTicketListItem"][];
|
||||
};
|
||||
/** ValidationError */
|
||||
ValidationError: {
|
||||
/** Location */
|
||||
|
|
@ -6083,6 +6300,76 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
list_my_prepost_measures_users_me_prepost_measures_get: {
|
||||
parameters: {
|
||||
query?: {
|
||||
pilot_id?: string;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: {
|
||||
"__Host-vignette_sid"?: string | null;
|
||||
vignette_sid?: string | null;
|
||||
};
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["UserPrepostMeasuresResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
upsert_my_prepost_measure_users_me_prepost_measures_put: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: {
|
||||
"__Host-vignette_sid"?: string | null;
|
||||
vignette_sid?: string | null;
|
||||
};
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["UserPrepostMeasureRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["UserPrepostMeasureItem"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
get_voice_presets_users_me_voice_presets_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -6115,6 +6402,38 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
list_my_support_tickets_users_support_tickets_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: {
|
||||
"__Host-vignette_sid"?: string | null;
|
||||
vignette_sid?: string | null;
|
||||
};
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["UserSupportTicketsResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
create_support_ticket_users_support_tickets_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
|
|||
|
|
@ -562,6 +562,11 @@ export type UserProfilePatchRequest = ApiSchema<"UserProfilePatch">;
|
|||
export type OnboardingRequest = ApiSchema<"OnboardingRequest">;
|
||||
export type AvatarUploadResponse = ApiSchema<"AvatarUploadResponse">;
|
||||
export type VoicePresetResponse = ApiSchema<"VoicePresetResponse">;
|
||||
export type UserSupportTicketListItem = ApiSchema<"UserSupportTicketListItem">;
|
||||
export type UserSupportTicketsResponse = ApiSchema<"UserSupportTicketsResponse">;
|
||||
export type UserPrepostMeasureRequest = ApiSchema<"UserPrepostMeasureRequest">;
|
||||
export type UserPrepostMeasureItem = ApiSchema<"UserPrepostMeasureItem">;
|
||||
export type UserPrepostMeasuresResponse = ApiSchema<"UserPrepostMeasuresResponse">;
|
||||
|
||||
export type RoleString = "learner" | "teacher" | "admin" | string;
|
||||
|
||||
|
|
@ -588,6 +593,13 @@ export const userApi = {
|
|||
updatePreferences: (body: UserPreferencesPatchRequest) =>
|
||||
apiFetch<UserPreferencesResponse>("/users/me/preferences", { method: "PATCH", body }),
|
||||
voicePresets: () => api.get<VoicePresetResponse[]>("/users/me/voice-presets"),
|
||||
supportTickets: () => api.get<UserSupportTicketsResponse>("/users/support-tickets"),
|
||||
prepostMeasures: (pilotId = "phase3-pilot-draft") =>
|
||||
api.get<UserPrepostMeasuresResponse>(
|
||||
`/users/me/prepost-measures?pilot_id=${encodeURIComponent(pilotId)}`,
|
||||
),
|
||||
upsertPrepostMeasure: (body: UserPrepostMeasureRequest) =>
|
||||
apiFetch<UserPrepostMeasureItem>("/users/me/prepost-measures", { method: "PUT", body }),
|
||||
};
|
||||
|
||||
export type AdminEngineConfigResponse = ApiSchema<"AdminEngineConfigResponse">;
|
||||
|
|
|
|||
|
|
@ -221,6 +221,48 @@ function usageBudgetDetail(data: AdminUsageResponse): string {
|
|||
return `${costLabel(data.cost_usd)} / ${costLabel(budget.limit_usd)} · ${pct}% 사용${remaining}`;
|
||||
}
|
||||
|
||||
function rateLabel(value: number): string {
|
||||
if (!Number.isFinite(value) || value <= 0) return "0%";
|
||||
return `${Math.round(value * 1000) / 10}%`;
|
||||
}
|
||||
|
||||
function evaluatorCache(data: AdminUsageResponse): AdminUsageResponse["evaluator_cache"] {
|
||||
return (
|
||||
data.evaluator_cache ?? {
|
||||
enabled: false,
|
||||
entries: 0,
|
||||
hits: 0,
|
||||
misses: 0,
|
||||
stores: 0,
|
||||
evictions: 0,
|
||||
requests: 0,
|
||||
hit_rate: 0,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function evaluatorCacheLabel(data: AdminUsageResponse): string {
|
||||
const cache = evaluatorCache(data);
|
||||
if (!cache.enabled) return "캐시 비활성";
|
||||
return `${rateLabel(cache.hit_rate)} hit`;
|
||||
}
|
||||
|
||||
function evaluatorCacheDetail(data: AdminUsageResponse): string {
|
||||
const cache = evaluatorCache(data);
|
||||
if (!cache.enabled) return "EVALUATOR_SEMANTIC_CACHE 설정이 꺼져 있습니다.";
|
||||
return `${countLabel(cache.hits)} / ${countLabel(cache.requests)} hit · entry ${countLabel(cache.entries)}개 · 저장 ${countLabel(cache.stores)}회`;
|
||||
}
|
||||
|
||||
function usageDailyCost(data: AdminUsageResponse): NonNullable<AdminUsageResponse["daily_cost"]> {
|
||||
return data.daily_cost ?? [];
|
||||
}
|
||||
|
||||
function usageDayLabel(day: string): string {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(day);
|
||||
if (!match) return day;
|
||||
return `${match[2]}.${match[3]}`;
|
||||
}
|
||||
|
||||
function uptimeLabel(data: AdminUptimeResponse | null): string {
|
||||
if (!data) return "-";
|
||||
if (!data.durable || data.sample_count === 0) return "샘플 없음";
|
||||
|
|
@ -268,6 +310,10 @@ function ticketTone(ticket: AdminSupportTicket): "accent" | "neutral" | "warn" |
|
|||
return "accent";
|
||||
}
|
||||
|
||||
function shortTicketId(ticketId: string): string {
|
||||
return ticketId.replace(/-/g, "").slice(0, 8).toUpperCase();
|
||||
}
|
||||
|
||||
function initialOf(user: AdminManagedUser): string {
|
||||
const label = user.display_name.trim() || user.email;
|
||||
return Array.from(label)[0]?.toUpperCase() ?? "?";
|
||||
|
|
@ -622,6 +668,34 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
|||
}
|
||||
};
|
||||
|
||||
const updateTicketParent = async (
|
||||
ticket: AdminSupportTicket,
|
||||
parentTicketId: string | null,
|
||||
) => {
|
||||
setUpdatingTicketId(ticket.ticket_id);
|
||||
setTicketsError(null);
|
||||
try {
|
||||
const updated = await adminApi.updateTicket(ticket.ticket_id, {
|
||||
parent_ticket_id: parentTicketId ?? "",
|
||||
});
|
||||
setTickets((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
tickets: current.tickets.map((item) =>
|
||||
item.ticket_id === updated.ticket_id ? updated : item,
|
||||
),
|
||||
}
|
||||
: current,
|
||||
);
|
||||
await loadTickets();
|
||||
} catch (err) {
|
||||
setTicketsError(err instanceof Error ? err.message : "중복 연결을 저장하지 못했습니다.");
|
||||
} finally {
|
||||
setUpdatingTicketId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const services = health?.services ?? [];
|
||||
const users = usersData?.users ?? [];
|
||||
const counts = useMemo(
|
||||
|
|
@ -848,6 +922,28 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
|||
{usage ? <small>{usageBudgetDetail(usage)}</small> : null}
|
||||
</div>
|
||||
{usage ? <ProgressBar value={Math.min(100, Math.round(usage.budget.used_ratio * 100))} slim /> : null}
|
||||
{usage ? (
|
||||
<div className="ad-cache">
|
||||
<span>평가 캐시 hit-rate</span>
|
||||
<b>{evaluatorCacheLabel(usage)}</b>
|
||||
<small>{evaluatorCacheDetail(usage)}</small>
|
||||
</div>
|
||||
) : null}
|
||||
{usage && usageDailyCost(usage).length > 0 ? (
|
||||
<div className="ad-cost-trend">
|
||||
<span>일별 비용 추이</span>
|
||||
{usageDailyCost(usage)
|
||||
.slice(-7)
|
||||
.map((day) => (
|
||||
<div key={day.day}>
|
||||
<b>{usageDayLabel(day.day)}</b>
|
||||
<span>
|
||||
{costLabel(day.cost_usd)} · {countLabel(day.turns)}턴
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="ad-panel">
|
||||
|
|
@ -1658,44 +1754,84 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
|||
|
||||
{activeTickets.length > 0 ? (
|
||||
<div className="ad-ticket-list" aria-label="미해결 운영 티켓 큐">
|
||||
{activeTickets.map((ticket) => (
|
||||
<article className="ad-ticket" key={ticket.ticket_id}>
|
||||
<div>
|
||||
<div className="ad-ticket__meta">
|
||||
<span>{ticketCategoryLabel(ticket.category)}</span>
|
||||
<span>{ticket.reporter.email}</span>
|
||||
<span>갱신 {dateTimeLabel(ticket.updated_at)}</span>
|
||||
{activeTickets.map((ticket) => {
|
||||
const duplicateParentId = ticket.duplicate_parent_candidate_id;
|
||||
const canLinkDuplicate =
|
||||
ticket.duplicate_count > 0 &&
|
||||
Boolean(duplicateParentId) &&
|
||||
duplicateParentId !== ticket.ticket_id &&
|
||||
duplicateParentId !== ticket.parent_ticket_id;
|
||||
return (
|
||||
<article className="ad-ticket" key={ticket.ticket_id}>
|
||||
<div>
|
||||
<div className="ad-ticket__meta">
|
||||
<span>{ticketCategoryLabel(ticket.category)}</span>
|
||||
<span>{ticket.reporter.email}</span>
|
||||
<span>갱신 {dateTimeLabel(ticket.updated_at)}</span>
|
||||
</div>
|
||||
<h2>{ticket.subject}</h2>
|
||||
<p>{ticket.body}</p>
|
||||
{(ticket.duplicate_count > 0 || ticket.parent_ticket_id || ticket.child_ticket_count > 0) ? (
|
||||
<div className="ad-ticket__dupes">
|
||||
{ticket.parent_ticket_id ? (
|
||||
<span>중복 연결 #{shortTicketId(ticket.parent_ticket_id)}</span>
|
||||
) : ticket.duplicate_count > 0 ? (
|
||||
<span>중복 후보 {countLabel(ticket.duplicate_count)}건</span>
|
||||
) : null}
|
||||
{ticket.child_ticket_count > 0 ? (
|
||||
<span>하위 티켓 {countLabel(ticket.child_ticket_count)}건</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<small>
|
||||
{ticket.source_path || "출처 없음"} · 접수 {dateTimeLabel(ticket.created_at)}
|
||||
{ticket.assigned_group ? ` · 담당 ${ticket.assigned_group}` : ""}
|
||||
{ticket.event_count > 0 ? ` · 처리 이력 ${countLabel(ticket.event_count)}건` : ""}
|
||||
</small>
|
||||
</div>
|
||||
<h2>{ticket.subject}</h2>
|
||||
<p>{ticket.body}</p>
|
||||
<small>
|
||||
{ticket.source_path || "출처 없음"} · 접수 {dateTimeLabel(ticket.created_at)}
|
||||
{ticket.assigned_group ? ` · 담당 ${ticket.assigned_group}` : ""}
|
||||
{ticket.event_count > 0 ? ` · 처리 이력 ${countLabel(ticket.event_count)}건` : ""}
|
||||
</small>
|
||||
</div>
|
||||
<Badge tone={ticketTone(ticket)}>{ticketPriorityLabel(ticket.priority)}</Badge>
|
||||
<Badge tone="accent">{ticketStatusLabel(ticket.status)}</Badge>
|
||||
<div className="ad-ticket__actions">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => void updateTicketStatus(ticket, "in_progress")}
|
||||
disabled={updatingTicketId === ticket.ticket_id || ticket.status === "in_progress"}
|
||||
>
|
||||
처리 중
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
onClick={() => void updateTicketStatus(ticket, "resolved")}
|
||||
disabled={updatingTicketId === ticket.ticket_id}
|
||||
>
|
||||
해결
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
<Badge tone={ticketTone(ticket)}>{ticketPriorityLabel(ticket.priority)}</Badge>
|
||||
<Badge tone="accent">{ticketStatusLabel(ticket.status)}</Badge>
|
||||
<div className="ad-ticket__actions">
|
||||
{canLinkDuplicate ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => void updateTicketParent(ticket, duplicateParentId ?? null)}
|
||||
disabled={updatingTicketId === ticket.ticket_id}
|
||||
>
|
||||
연결
|
||||
</Button>
|
||||
) : null}
|
||||
{ticket.parent_ticket_id ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => void updateTicketParent(ticket, null)}
|
||||
disabled={updatingTicketId === ticket.ticket_id}
|
||||
>
|
||||
해제
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => void updateTicketStatus(ticket, "in_progress")}
|
||||
disabled={updatingTicketId === ticket.ticket_id || ticket.status === "in_progress"}
|
||||
>
|
||||
처리 중
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
onClick={() => void updateTicketStatus(ticket, "resolved")}
|
||||
disabled={updatingTicketId === ticket.ticket_id}
|
||||
>
|
||||
해결
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ import "./session/session.css";
|
|||
/* ── 도메인 상수/타입 ───────────────────────────────────────────────── */
|
||||
|
||||
type FeedbackMode = "immersive" | "ambient" | "coached";
|
||||
type TheoryMode = "humanistic" | "cbt" | "integrative";
|
||||
type Speaker = "client" | "learner";
|
||||
type SignalTone = "pos" | "warn" | "neutral";
|
||||
type VoiceStatus =
|
||||
|
|
@ -115,6 +116,20 @@ const SESSION_PHASES: PhaseInfo[] = [
|
|||
{ key: "정리", desc: "오늘의 대화 정리와 다음 약속" },
|
||||
];
|
||||
|
||||
const THEORY_MODE_OPTIONS: { value: TheoryMode; label: string; detail: string }[] = [
|
||||
{ value: "humanistic", label: "인간중심", detail: "공감·반영" },
|
||||
{ value: "cbt", label: "CBT", detail: "생각·행동" },
|
||||
{ value: "integrative", label: "통합", detail: "혼합 접근" },
|
||||
];
|
||||
|
||||
function preferredTheoryMode(summary: PersonaSummary | null): TheoryMode {
|
||||
const firstSupported = summary?.theory_target.find(
|
||||
(target): target is TheoryMode =>
|
||||
target === "humanistic" || target === "cbt" || target === "integrative",
|
||||
);
|
||||
return firstSupported ?? "humanistic";
|
||||
}
|
||||
|
||||
const PERSONA_AVATAR_LOOKS = {
|
||||
P1: {
|
||||
skinTone: "#F0DDC4",
|
||||
|
|
@ -378,6 +393,7 @@ export default function Session() {
|
|||
const [startError, setStartError] = useState<string | null>(null);
|
||||
const [consentChecked, setConsentChecked] = useState(false);
|
||||
const [consentBusy, setConsentBusy] = useState(false);
|
||||
const [selectedTheoryMode, setSelectedTheoryMode] = useState<TheoryMode>("humanistic");
|
||||
|
||||
// ── 회기/대화 상태 ──
|
||||
const [stage, setStage] = useState<SessionStage>("라포");
|
||||
|
|
@ -394,6 +410,7 @@ export default function Session() {
|
|||
const [feedbackMode, setFeedbackMode] = useState<FeedbackMode>("ambient");
|
||||
const [composeText, setComposeText] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [clientReplyPending, setClientReplyPending] = useState(false);
|
||||
const [voiceStatus, setVoiceStatus] = useState<VoiceStatus>("idle");
|
||||
const [voiceDetail, setVoiceDetail] = useState(
|
||||
"마이크를 켜면 권한 요청 후 음성으로 회기를 진행합니다.",
|
||||
|
|
@ -483,6 +500,7 @@ export default function Session() {
|
|||
setSafety(null);
|
||||
setCrisisResource(null);
|
||||
setResumedSessionLoaded(false);
|
||||
setClientReplyPending(false);
|
||||
setStartError(null);
|
||||
setCoachSuggestion(null);
|
||||
setCoachError(null);
|
||||
|
|
@ -524,6 +542,11 @@ export default function Session() {
|
|||
};
|
||||
}, [personaCode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (started) return;
|
||||
setSelectedTheoryMode(preferredTheoryMode(personaSummary));
|
||||
}, [personaSummary, started]);
|
||||
|
||||
// 현재 단계 인덱스
|
||||
const stageIdx = useMemo(() => SESSION_PHASES.findIndex((p) => p.key === stage), [stage]);
|
||||
const currentPhase = SESSION_PHASES[Math.max(0, stageIdx)] ?? SESSION_PHASES[0];
|
||||
|
|
@ -540,7 +563,7 @@ export default function Session() {
|
|||
if (!autoScroll) return;
|
||||
const el = scrollRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [utterances, autoScroll]);
|
||||
}, [utterances, clientReplyPending, autoScroll]);
|
||||
|
||||
// ── 라이브 신호 6초 페이드(§5.5) ──
|
||||
useEffect(() => {
|
||||
|
|
@ -584,6 +607,7 @@ export default function Session() {
|
|||
setSafety(next.message);
|
||||
setPaused(true);
|
||||
setMicOn(false);
|
||||
setClientReplyPending(false);
|
||||
setVoiceStatus("idle");
|
||||
setVoiceDetail("위기 신호가 감지되어 연습을 중단했습니다.");
|
||||
pushSignal("warn", "위기 안전게이트 작동");
|
||||
|
|
@ -698,6 +722,7 @@ export default function Session() {
|
|||
setCoachEvidenceOpen(false);
|
||||
setCoachHistoryOpen(false);
|
||||
setCoachHistoryTurnSeq(null);
|
||||
setClientReplyPending(false);
|
||||
pushSignal("neutral", ended ? "종료된 회기 기록" : "기존 회기 이어하기");
|
||||
} catch {
|
||||
if (!alive) return;
|
||||
|
|
@ -725,15 +750,9 @@ export default function Session() {
|
|||
setStarting(true);
|
||||
setStartError(null);
|
||||
setResumedSessionLoaded(false);
|
||||
setClientReplyPending(false);
|
||||
try {
|
||||
// 회기 이론모드 = 페르소나 설계 이론(theory_target) 기준. CBT 페르소나는 cbt로 시작.
|
||||
// (이전엔 'humanistic' 하드코딩 — 평가 이론부합이 항상 인간중심으로 고정됐다.)
|
||||
const theoryMode =
|
||||
personaSummary.theory_target.find(
|
||||
(t): t is "humanistic" | "cbt" | "integrative" =>
|
||||
t === "humanistic" || t === "cbt" || t === "integrative",
|
||||
) ?? "humanistic";
|
||||
const res = await sessionApi.start(personaCode, theoryMode);
|
||||
const res = await sessionApi.start(personaCode, selectedTheoryMode);
|
||||
setLiveSessionId(res.session_id); // ★ 진짜 세션 id 저장 — turn 이 이걸 써야 라이브
|
||||
setSessionEnded(false);
|
||||
setReviewReady(false);
|
||||
|
|
@ -778,7 +797,7 @@ export default function Session() {
|
|||
} finally {
|
||||
setStarting(false);
|
||||
}
|
||||
}, [navigate, personaCode, personaSummary, pushSignal]);
|
||||
}, [navigate, personaCode, personaSummary, pushSignal, selectedTheoryMode]);
|
||||
|
||||
const handleAcceptConsent = useCallback(async () => {
|
||||
if (!consentChecked) {
|
||||
|
|
@ -799,6 +818,7 @@ export default function Session() {
|
|||
|
||||
const appendServerClientReply = useCallback(
|
||||
(replyText: string | null, at = elapsed) => {
|
||||
setClientReplyPending(false);
|
||||
if (!replyText) {
|
||||
setAvatarState("listening");
|
||||
pushSignal("warn", "내담자 응답 없음");
|
||||
|
|
@ -817,7 +837,7 @@ export default function Session() {
|
|||
/* ── 학습자 발화 전송(텍스트 입력 폴백 = 음성 1턴 왕복 대역) ──────── */
|
||||
const handleSend = useCallback(async () => {
|
||||
const text = composeText.trim();
|
||||
if (!text || sending || paused) return;
|
||||
if (!text || sending || paused || voiceStatus === "thinking") return;
|
||||
if (sessionEnded) {
|
||||
setTurnError("종료된 회기에서는 새 발화를 보낼 수 없습니다. 리뷰에서 기록을 확인해 주세요.");
|
||||
return;
|
||||
|
|
@ -829,8 +849,14 @@ export default function Session() {
|
|||
}
|
||||
|
||||
const at = elapsed;
|
||||
const learnerId = nextId();
|
||||
setSending(true);
|
||||
setComposeText("");
|
||||
setUtterances((prev) => [
|
||||
...prev,
|
||||
{ id: learnerId, speaker: "learner", text, at },
|
||||
]);
|
||||
setClientReplyPending(true);
|
||||
setAvatarState("thinking");
|
||||
setTurnError(null);
|
||||
|
||||
|
|
@ -841,6 +867,7 @@ export default function Session() {
|
|||
onToken: (chunk) => {
|
||||
if (!chunk) return;
|
||||
clientReply += chunk;
|
||||
setClientReplyPending(false);
|
||||
setAvatarState("speaking");
|
||||
if (clientId == null) {
|
||||
clientId = nextId();
|
||||
|
|
@ -878,25 +905,30 @@ export default function Session() {
|
|||
},
|
||||
});
|
||||
|
||||
const learnerUtterance: Utterance = {
|
||||
id: nextId(),
|
||||
speaker: "learner",
|
||||
text,
|
||||
turnSeq: done.turn_seq,
|
||||
at,
|
||||
};
|
||||
if (clientId != null) {
|
||||
const id = clientId;
|
||||
setUtterances((prev) => {
|
||||
const existingIndex = prev.findIndex((u) => u.id === id);
|
||||
if (existingIndex === -1) return [...prev, learnerUtterance];
|
||||
const next = [...prev];
|
||||
next.splice(existingIndex, 0, learnerUtterance);
|
||||
return next.map((u) => (u.id === id ? { ...u, partial: false } : u));
|
||||
if (existingIndex === -1) {
|
||||
return [
|
||||
...prev.map((u) => (u.id === learnerId ? { ...u, turnSeq: done.turn_seq } : u)),
|
||||
{ id, speaker: "client", text: clientReply, at },
|
||||
];
|
||||
}
|
||||
return prev.map((u) =>
|
||||
u.id === learnerId
|
||||
? { ...u, turnSeq: done.turn_seq }
|
||||
: u.id === id
|
||||
? { ...u, partial: false }
|
||||
: u,
|
||||
);
|
||||
});
|
||||
pushSignal("neutral", "내담자 응답 수신");
|
||||
} else {
|
||||
setUtterances((prev) => [...prev, learnerUtterance]);
|
||||
setClientReplyPending(false);
|
||||
setUtterances((prev) =>
|
||||
prev.map((u) => (u.id === learnerId ? { ...u, turnSeq: done.turn_seq } : u)),
|
||||
);
|
||||
pushSignal("warn", "내담자 응답 없음");
|
||||
}
|
||||
setAvatarState("listening");
|
||||
|
|
@ -907,9 +939,12 @@ export default function Session() {
|
|||
});
|
||||
} catch (err) {
|
||||
setComposeText(text);
|
||||
setClientReplyPending(false);
|
||||
if (clientId != null) {
|
||||
const id = clientId;
|
||||
setUtterances((prev) => prev.filter((u) => u.id !== id));
|
||||
setUtterances((prev) => prev.filter((u) => u.id !== id && u.id !== learnerId));
|
||||
} else {
|
||||
setUtterances((prev) => prev.filter((u) => u.id !== learnerId));
|
||||
}
|
||||
const detail =
|
||||
err instanceof ApiError
|
||||
|
|
@ -928,7 +963,17 @@ export default function Session() {
|
|||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}, [composeText, sending, paused, sessionEnded, liveSessionId, elapsed, pushSignal, requestLiveCoach]);
|
||||
}, [
|
||||
composeText,
|
||||
sending,
|
||||
paused,
|
||||
voiceStatus,
|
||||
sessionEnded,
|
||||
liveSessionId,
|
||||
elapsed,
|
||||
pushSignal,
|
||||
requestLiveCoach,
|
||||
]);
|
||||
|
||||
const onComposeKey = (e: ReactKeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
|
|
@ -1156,6 +1201,7 @@ export default function Session() {
|
|||
return;
|
||||
}
|
||||
recorderRef.current = recorder;
|
||||
setClientReplyPending(false);
|
||||
|
||||
recorder.ondataavailable = (event) => {
|
||||
if (!event.data.size || ws.readyState !== WebSocket.OPEN) return;
|
||||
|
|
@ -1218,6 +1264,7 @@ export default function Session() {
|
|||
const id = nextId();
|
||||
pendingVoiceLearnerIdRef.current = id;
|
||||
pendingVoiceLearnerTextRef.current = payload.text;
|
||||
setClientReplyPending(true);
|
||||
setUtterances((prev) => [
|
||||
...prev,
|
||||
{ id, speaker: "learner", text: payload.text ?? "", at: elapsed, partial: true },
|
||||
|
|
@ -1226,6 +1273,7 @@ export default function Session() {
|
|||
}
|
||||
|
||||
if (payload.type === "reply") {
|
||||
setClientReplyPending(false);
|
||||
if (payload.stage) setStage(payload.stage);
|
||||
if (typeof payload.effective_openness === "number") {
|
||||
setOpenness(payload.effective_openness);
|
||||
|
|
@ -1264,6 +1312,7 @@ export default function Session() {
|
|||
}
|
||||
|
||||
if (payload.type === "degraded") {
|
||||
setClientReplyPending(false);
|
||||
const fallbackReason = "음성 설정이 완료되지 않아 지금은 텍스트 입력으로 진행합니다.";
|
||||
const reason =
|
||||
payload.reason && !payload.reason.includes("OPENAI") ? payload.reason : fallbackReason;
|
||||
|
|
@ -1273,6 +1322,7 @@ export default function Session() {
|
|||
}
|
||||
|
||||
if (payload.type === "error") {
|
||||
setClientReplyPending(false);
|
||||
if (pendingVoiceLearnerIdRef.current != null && payload.detail?.includes("engine unavailable")) {
|
||||
const pendingId = pendingVoiceLearnerIdRef.current;
|
||||
setUtterances((prev) => prev.filter((u) => u.id !== pendingId));
|
||||
|
|
@ -1285,6 +1335,7 @@ export default function Session() {
|
|||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
setClientReplyPending(false);
|
||||
shutdownVoice("error", "음성 연결을 확인하지 못했습니다.");
|
||||
pushSignal("warn", "음성 연결 실패");
|
||||
};
|
||||
|
|
@ -1322,6 +1373,17 @@ export default function Session() {
|
|||
stopMicStream,
|
||||
]);
|
||||
|
||||
const skipVoicePlayback = useCallback(() => {
|
||||
ttsChunksRef.current = [];
|
||||
stopTtsPlayback();
|
||||
closeVoiceSocket();
|
||||
setVoiceAnalyser(null);
|
||||
setAvatarState("listening");
|
||||
setVoiceStatus("idle");
|
||||
setVoiceDetail("음성을 건너뛰었습니다. 다음 발화를 입력하거나 마이크를 켜세요.");
|
||||
pushSignal("neutral", "음성 건너뜀");
|
||||
}, [closeVoiceSocket, pushSignal, stopTtsPlayback]);
|
||||
|
||||
const toggleMic = useCallback(() => {
|
||||
if (paused || sending || sessionEnded) return;
|
||||
if (voiceAvailable === false) {
|
||||
|
|
@ -1348,6 +1410,7 @@ export default function Session() {
|
|||
setPaused((p) => {
|
||||
const next = !p;
|
||||
if (next) {
|
||||
if (voiceStatus !== "idle") setClientReplyPending(false);
|
||||
shutdownVoice("idle", "일시정지 중입니다.");
|
||||
setAvatarState("idle");
|
||||
} else {
|
||||
|
|
@ -1356,7 +1419,7 @@ export default function Session() {
|
|||
}
|
||||
return next;
|
||||
});
|
||||
}, [sessionEnded, shutdownVoice]);
|
||||
}, [sessionEnded, shutdownVoice, voiceStatus]);
|
||||
|
||||
// ── 키보드 단축키 (§5.6: Space=마이크, P=일시정지). 입력 중엔 무시 ──
|
||||
useEffect(() => {
|
||||
|
|
@ -1546,6 +1609,7 @@ export default function Session() {
|
|||
voiceStatus === "thinking" ||
|
||||
voiceStatus === "speaking" ||
|
||||
sending;
|
||||
const textTurnBlocked = sending || voiceStatus === "thinking";
|
||||
const elapsedLabel = formatElapsed(elapsed);
|
||||
const personaIsUsable = personaSummary ? isUsablePersona(personaSummary) : false;
|
||||
const consentRequired = user?.role === "learner" && user.consentAt == null;
|
||||
|
|
@ -1748,6 +1812,23 @@ export default function Session() {
|
|||
</span>
|
||||
))}
|
||||
</div>
|
||||
<fieldset className="sx-theory" aria-label="이론모드 선택">
|
||||
<legend>이론모드</legend>
|
||||
<div className="sx-theory__seg">
|
||||
{THEORY_MODE_OPTIONS.map((option) => (
|
||||
<button
|
||||
type="button"
|
||||
key={option.value}
|
||||
className={option.value === selectedTheoryMode ? "is-selected" : ""}
|
||||
aria-pressed={option.value === selectedTheoryMode}
|
||||
onClick={() => setSelectedTheoryMode(option.value)}
|
||||
>
|
||||
<span>{option.label}</span>
|
||||
<small>{option.detail}</small>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
{personaStatusMessage ? (
|
||||
<p className="sx-prestart__note">{personaStatusMessage}</p>
|
||||
) : null}
|
||||
|
|
@ -1986,57 +2067,77 @@ export default function Session() {
|
|||
</div>
|
||||
|
||||
<div className="sx-transcript__scroll" ref={scrollRef} onScroll={onScroll}>
|
||||
{utterances.length > 0 ? (
|
||||
utterances.map((u) => {
|
||||
const turnCoachEvents =
|
||||
u.speaker === "learner" && u.turnSeq
|
||||
? (coachHistoryByTurn.get(u.turnSeq) ?? [])
|
||||
: [];
|
||||
const latestCoach = turnCoachEvents[turnCoachEvents.length - 1];
|
||||
return (
|
||||
<div
|
||||
key={u.id}
|
||||
className={
|
||||
"sx-utt " +
|
||||
(u.speaker === "client" ? "is-client" : "is-learner") +
|
||||
(u.partial ? " is-partial" : "")
|
||||
}
|
||||
>
|
||||
{utterances.length > 0 || clientReplyPending ? (
|
||||
<>
|
||||
{utterances.map((u) => {
|
||||
const turnCoachEvents =
|
||||
u.speaker === "learner" && u.turnSeq
|
||||
? (coachHistoryByTurn.get(u.turnSeq) ?? [])
|
||||
: [];
|
||||
const latestCoach = turnCoachEvents[turnCoachEvents.length - 1];
|
||||
return (
|
||||
<div
|
||||
key={u.id}
|
||||
className={
|
||||
"sx-utt " +
|
||||
(u.speaker === "client" ? "is-client" : "is-learner") +
|
||||
(u.partial ? " is-partial" : "")
|
||||
}
|
||||
>
|
||||
<div className="sx-utt__head">
|
||||
<span className="sx-utt__spk">
|
||||
{u.speaker === "client" ? clientName : "나 (학습자)"}
|
||||
</span>
|
||||
<span className="sx-utt__tc">{formatTimecode(u.at)}</span>
|
||||
</div>
|
||||
<div className="sx-utt__body">
|
||||
<div className="sx-utt__line">
|
||||
{u.text}
|
||||
{u.partial ? <span className="sx-utt__caret" /> : null}
|
||||
</div>
|
||||
{turnCoachEvents.length && u.turnSeq ? (
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
"sx-utt__coach-mark" +
|
||||
(latestCoach?.suggestion.tone === "pos"
|
||||
? " is-pos"
|
||||
: latestCoach?.suggestion.tone === "warn"
|
||||
? " is-warn"
|
||||
: "")
|
||||
}
|
||||
title={latestCoach?.suggestion.title ?? "코칭 이력"}
|
||||
onClick={() => openCoachHistory(u.turnSeq ?? null)}
|
||||
>
|
||||
C
|
||||
{turnCoachEvents.length > 1 ? (
|
||||
<span>{turnCoachEvents.length}</span>
|
||||
) : null}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{clientReplyPending ? (
|
||||
<div className="sx-utt is-client is-thinking" aria-live="polite">
|
||||
<div className="sx-utt__head">
|
||||
<span className="sx-utt__spk">
|
||||
{u.speaker === "client" ? clientName : "나 (학습자)"}
|
||||
</span>
|
||||
<span className="sx-utt__tc">{formatTimecode(u.at)}</span>
|
||||
<span className="sx-utt__spk">{clientName}</span>
|
||||
<span className="sx-utt__tc">{formatTimecode(elapsed)}</span>
|
||||
</div>
|
||||
<div className="sx-utt__body">
|
||||
<div className="sx-utt__line">
|
||||
{u.text}
|
||||
{u.partial ? <span className="sx-utt__caret" /> : null}
|
||||
답변을 준비 중입니다.
|
||||
<span className="sx-utt__dots" aria-hidden="true">
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</span>
|
||||
</div>
|
||||
{turnCoachEvents.length && u.turnSeq ? (
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
"sx-utt__coach-mark" +
|
||||
(latestCoach?.suggestion.tone === "pos"
|
||||
? " is-pos"
|
||||
: latestCoach?.suggestion.tone === "warn"
|
||||
? " is-warn"
|
||||
: "")
|
||||
}
|
||||
title={latestCoach?.suggestion.title ?? "코칭 이력"}
|
||||
onClick={() => openCoachHistory(u.turnSeq ?? null)}
|
||||
>
|
||||
C
|
||||
{turnCoachEvents.length > 1 ? (
|
||||
<span>{turnCoachEvents.length}</span>
|
||||
) : null}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<div className="sx-transcript__empty">
|
||||
<span className="sx-transcript__empty-dot" aria-hidden="true" />
|
||||
|
|
@ -2078,13 +2179,13 @@ export default function Session() {
|
|||
? "일시정지 중입니다."
|
||||
: "학습자 발화를 입력하세요."
|
||||
}
|
||||
disabled={sessionEnded || paused || sending}
|
||||
disabled={sessionEnded || paused || textTurnBlocked}
|
||||
aria-label="학습자 발화 입력"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => void handleSend()}
|
||||
disabled={!composeText.trim() || sending || paused || sessionEnded}
|
||||
disabled={!composeText.trim() || textTurnBlocked || paused || sessionEnded}
|
||||
trailing={<Icon name="arrow-up" size={15} />}
|
||||
>
|
||||
보내기
|
||||
|
|
@ -2306,6 +2407,17 @@ export default function Session() {
|
|||
<span className="sx-mic-block__l">{micLabel}</span>
|
||||
<span className="sx-mic-block__h">{voiceDetail}</span>
|
||||
</span>
|
||||
{voiceStatus === "speaking" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="sx-voice-skip"
|
||||
onClick={skipVoicePlayback}
|
||||
aria-label="음성 건너뛰기"
|
||||
>
|
||||
<Icon name="x" size={14} />
|
||||
<span>건너뛰기</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<span className="sx-cb-sep" />
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
import {
|
||||
sessionApi,
|
||||
teacherApi,
|
||||
userApi,
|
||||
type ReviewCaseWorksheet,
|
||||
type ReviewNonverbalEvent,
|
||||
type ReviewNote,
|
||||
|
|
@ -21,11 +22,69 @@ import {
|
|||
type ReviewTurn,
|
||||
type ReviewWorksheetSection,
|
||||
type SessionReviewResponse,
|
||||
type UserPrepostMeasureItem,
|
||||
type UserPrepostMeasuresResponse,
|
||||
} from "../lib/api";
|
||||
import { ValenceChart } from "./session-review/ValenceChart";
|
||||
import "./session-review/session-review.css";
|
||||
|
||||
type LoadState = "loading" | "ready" | "error";
|
||||
type PrepostMeasureName = UserPrepostMeasureItem["measure_name"];
|
||||
type PrepostTimepoint = UserPrepostMeasureItem["timepoint"];
|
||||
|
||||
const PREPOST_PILOT_ID = "phase3-pilot-draft";
|
||||
const PREPOST_INSTRUMENT_VERSION = "pilot-prepost-scaffold-2026-06-28";
|
||||
const PREPOST_SCORE_MIN = 1;
|
||||
const PREPOST_SCORE_MAX = 5;
|
||||
|
||||
const PREPOST_MEASURES: Array<{ key: PrepostMeasureName; label: string; hint: string }> = [
|
||||
{ key: "self_efficacy", label: "자기효능감", hint: "상담 수행 자신감" },
|
||||
{ key: "skill_proficiency", label: "기술숙련도", hint: "기법 적용 숙련감" },
|
||||
{ key: "training_satisfaction", label: "수련만족도", hint: "훈련 경험 만족도" },
|
||||
];
|
||||
|
||||
const PREPOST_TIMEPOINTS: Array<{ key: PrepostTimepoint; label: string }> = [
|
||||
{ key: "pre", label: "사전" },
|
||||
{ key: "post", label: "사후" },
|
||||
];
|
||||
|
||||
function prepostDraftKey(measure: PrepostMeasureName, timepoint: PrepostTimepoint) {
|
||||
return `${measure}:${timepoint}`;
|
||||
}
|
||||
|
||||
function splitPrepostDraftKey(key: string): [PrepostMeasureName, PrepostTimepoint] | null {
|
||||
const [measure, timepoint] = key.split(":");
|
||||
if (
|
||||
PREPOST_MEASURES.some((item) => item.key === measure) &&
|
||||
PREPOST_TIMEPOINTS.some((item) => item.key === timepoint)
|
||||
) {
|
||||
return [measure as PrepostMeasureName, timepoint as PrepostTimepoint];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function prepostScoreText(score: number) {
|
||||
return Number.isInteger(score) ? String(score) : score.toFixed(1).replace(/\.0$/, "");
|
||||
}
|
||||
|
||||
function prepostDraftFromResponse(
|
||||
response: UserPrepostMeasuresResponse | null,
|
||||
): Record<string, string> {
|
||||
const next: Record<string, string> = {};
|
||||
for (const item of response?.measures ?? []) {
|
||||
next[prepostDraftKey(item.measure_name, item.timepoint)] = prepostScoreText(item.raw_score);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function parsePrepostScore(value: string): number | null {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
const parsed = Number(trimmed);
|
||||
if (!Number.isFinite(parsed)) return null;
|
||||
if (parsed < PREPOST_SCORE_MIN || parsed > PREPOST_SCORE_MAX) return null;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function renderSummary(summary: string) {
|
||||
const parts = summary.split(/(<hl>.*?<\/hl>)/g);
|
||||
|
|
@ -420,6 +479,181 @@ function CaseWorksheetCard({
|
|||
);
|
||||
}
|
||||
|
||||
function PrepostMeasureCard() {
|
||||
const [data, setData] = useState<UserPrepostMeasuresResponse | null>(null);
|
||||
const [draft, setDraft] = useState<Record<string, string>>({});
|
||||
const [baseline, setBaseline] = useState<Record<string, string>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const next = await userApi.prepostMeasures(PREPOST_PILOT_ID);
|
||||
if (!alive) return;
|
||||
const nextDraft = prepostDraftFromResponse(next);
|
||||
setData(next);
|
||||
setDraft(nextDraft);
|
||||
setBaseline(nextDraft);
|
||||
} catch {
|
||||
if (!alive) return;
|
||||
setError("사전/사후 점수 원장을 불러오지 못했습니다.");
|
||||
} finally {
|
||||
if (alive) setLoading(false);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const dirtyKeys = useMemo(
|
||||
() =>
|
||||
Object.keys(draft).filter((key) => {
|
||||
const split = splitPrepostDraftKey(key);
|
||||
if (!split) return false;
|
||||
const value = draft[key]?.trim() ?? "";
|
||||
return value !== (baseline[key] ?? "") && parsePrepostScore(value) !== null;
|
||||
}),
|
||||
[baseline, draft],
|
||||
);
|
||||
|
||||
const invalidKeys = useMemo(
|
||||
() =>
|
||||
Object.keys(draft).filter((key) => {
|
||||
const split = splitPrepostDraftKey(key);
|
||||
if (!split) return false;
|
||||
const value = draft[key]?.trim() ?? "";
|
||||
return value.length > 0 && parsePrepostScore(value) === null;
|
||||
}),
|
||||
[draft],
|
||||
);
|
||||
|
||||
const saveDraft = async () => {
|
||||
if (dirtyKeys.length === 0) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
for (const key of dirtyKeys) {
|
||||
const split = splitPrepostDraftKey(key);
|
||||
const score = parsePrepostScore(draft[key] ?? "");
|
||||
if (!split || score === null) continue;
|
||||
const [measureName, timepoint] = split;
|
||||
await userApi.upsertPrepostMeasure({
|
||||
pilot_id: PREPOST_PILOT_ID,
|
||||
instrument_version: PREPOST_INSTRUMENT_VERSION,
|
||||
item_count: 1,
|
||||
min_score: PREPOST_SCORE_MIN,
|
||||
max_score: PREPOST_SCORE_MAX,
|
||||
measure_name: measureName,
|
||||
timepoint,
|
||||
raw_score: score,
|
||||
});
|
||||
}
|
||||
const next = await userApi.prepostMeasures(PREPOST_PILOT_ID);
|
||||
const nextDraft = prepostDraftFromResponse(next);
|
||||
setData(next);
|
||||
setDraft(nextDraft);
|
||||
setBaseline(nextDraft);
|
||||
} catch {
|
||||
setError("사전/사후 점수를 저장하지 못했습니다.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const completePairs = data?.complete_measure_pairs ?? 0;
|
||||
|
||||
return (
|
||||
<Card className="sr-card sr-card--side sr-card--prepost">
|
||||
<div className="sr-prepost__head">
|
||||
<div>
|
||||
<Kicker>Phase 3 사전/사후</Kicker>
|
||||
<b>파일럿 증거 원장</b>
|
||||
</div>
|
||||
<span className="sr-prepost__badge">{completePairs}/3 쌍</span>
|
||||
</div>
|
||||
<p className="sr-prepost__note">
|
||||
1~5 평균값만 저장합니다. 효과 판정과 통계 검정은 평가설계 확정 후 별도 산출합니다.
|
||||
</p>
|
||||
<div className="sr-prepost__table" aria-busy={loading || saving}>
|
||||
<div className="sr-prepost__row sr-prepost__row--head" aria-hidden="true">
|
||||
<span>척도</span>
|
||||
{PREPOST_TIMEPOINTS.map((timepoint) => (
|
||||
<span key={timepoint.key}>{timepoint.label}</span>
|
||||
))}
|
||||
</div>
|
||||
{PREPOST_MEASURES.map((measure) => (
|
||||
<div className="sr-prepost__row" key={measure.key}>
|
||||
<div className="sr-prepost__measure">
|
||||
<b>{measure.label}</b>
|
||||
<small>{measure.hint}</small>
|
||||
</div>
|
||||
{PREPOST_TIMEPOINTS.map((timepoint) => {
|
||||
const key = prepostDraftKey(measure.key, timepoint.key);
|
||||
const invalid = invalidKeys.includes(key);
|
||||
return (
|
||||
<label className="sr-prepost__field" key={key}>
|
||||
<span>{measure.label} {timepoint.label}</span>
|
||||
<input
|
||||
type="number"
|
||||
min={PREPOST_SCORE_MIN}
|
||||
max={PREPOST_SCORE_MAX}
|
||||
step="0.1"
|
||||
inputMode="decimal"
|
||||
value={draft[key] ?? ""}
|
||||
placeholder="-"
|
||||
aria-invalid={invalid}
|
||||
aria-label={`${measure.label} ${timepoint.label} 점수`}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.currentTarget.value;
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
[key]: nextValue,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{invalidKeys.length > 0 ? (
|
||||
<p className="sr-prepost__error" role="alert">
|
||||
점수는 1 이상 5 이하로 입력해야 합니다.
|
||||
</p>
|
||||
) : error ? (
|
||||
<p className="sr-prepost__error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="sr-prepost__actions">
|
||||
<span>
|
||||
{loading
|
||||
? "불러오는 중"
|
||||
: dirtyKeys.length > 0
|
||||
? `${dirtyKeys.length}개 변경`
|
||||
: "저장된 값 기준"}
|
||||
</span>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={loading || saving || dirtyKeys.length === 0 || invalidKeys.length > 0}
|
||||
onClick={() => void saveDraft()}
|
||||
>
|
||||
{saving ? "저장 중" : "점수 저장"}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SessionReview() {
|
||||
const { sessionId } = useParams<{ sessionId: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
|
@ -653,7 +887,7 @@ export default function SessionReview() {
|
|||
</div>
|
||||
</Card>
|
||||
|
||||
<div className={`sr-cols ${isSupervisorView ? "sr-cols--supervisor" : ""}`}>
|
||||
<div className={`sr-cols ${isSupervisorView ? "sr-cols--supervisor" : "sr-cols--learner"}`}>
|
||||
<div className="sr-left">
|
||||
<Card className="sr-overview">
|
||||
<Kicker>리뷰 요약</Kicker>
|
||||
|
|
@ -1018,6 +1252,8 @@ export default function SessionReview() {
|
|||
) : null}
|
||||
</Card>
|
||||
|
||||
{!isSupervisorView ? <PrepostMeasureCard /> : null}
|
||||
|
||||
<CaseWorksheetCard
|
||||
sessionId={data.session_id}
|
||||
worksheet={data.caseWorksheet}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ import {
|
|||
type NotificationPreferences,
|
||||
type UserPreferencesResponse,
|
||||
type UserProfileResponse,
|
||||
type UserSupportTicketListItem,
|
||||
type UserSupportTicketsResponse,
|
||||
type VoicePresetResponse,
|
||||
} from "../lib/api";
|
||||
import { readInitialTheme } from "../lib/theme";
|
||||
|
|
@ -43,6 +45,30 @@ const ENGINE_MODE_LABEL: Record<string, string> = {
|
|||
};
|
||||
const ENGINE_MODES = ["claude_cli", "claude_api", "openai", "solar"] as const;
|
||||
|
||||
const TICKET_STATUS_LABEL: Record<UserSupportTicketListItem["status"], string> = {
|
||||
open: "접수",
|
||||
triaged: "분류됨",
|
||||
in_progress: "처리 중",
|
||||
resolved: "해결",
|
||||
closed: "종료",
|
||||
};
|
||||
|
||||
const TICKET_CATEGORY_LABEL: Record<UserSupportTicketListItem["category"], string> = {
|
||||
account_access: "계정",
|
||||
session_review: "회기 리뷰",
|
||||
voice_browser: "음성·브라우저",
|
||||
content_scenario: "콘텐츠",
|
||||
safety: "안전",
|
||||
other: "기타",
|
||||
};
|
||||
|
||||
const TICKET_PRIORITY_LABEL: Record<UserSupportTicketListItem["priority"], string> = {
|
||||
low: "낮음",
|
||||
normal: "보통",
|
||||
high: "높음",
|
||||
urgent: "긴급",
|
||||
};
|
||||
|
||||
function SpeakerIcon({ size = 15 }: { size?: number }) {
|
||||
return (
|
||||
<svg
|
||||
|
|
@ -65,6 +91,22 @@ function healthStatusLabel(status: string | undefined): string {
|
|||
return "확인 중";
|
||||
}
|
||||
|
||||
function ticketStatusTone(status: UserSupportTicketListItem["status"]) {
|
||||
if (status === "resolved" || status === "closed") return "pos";
|
||||
if (status === "in_progress" || status === "triaged") return "info";
|
||||
return "warn";
|
||||
}
|
||||
|
||||
function formatTicketDate(value?: number | null): string {
|
||||
if (!value) return "-";
|
||||
return new Intl.DateTimeFormat("ko-KR", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(new Date(value * 1000));
|
||||
}
|
||||
|
||||
function SettingsLoadingState({
|
||||
children,
|
||||
testId,
|
||||
|
|
@ -155,11 +197,14 @@ export default function Settings() {
|
|||
const [displayName, setDisplayName] = useState(user?.name ?? "");
|
||||
const [affiliation, setAffiliation] = useState("");
|
||||
const [preferences, setPreferences] = useState<UserPreferencesResponse | null>(null);
|
||||
const [supportTickets, setSupportTickets] = useState<UserSupportTicketsResponse | null>(null);
|
||||
const [voicePresets, setVoicePresets] = useState<VoicePresetResponse[]>([]);
|
||||
const [engineConfig, setEngineConfig] = useState<AdminEngineConfigResponse | null>(null);
|
||||
const [adminHealth, setAdminHealth] = useState<AdminHealthResponse | null>(null);
|
||||
const [profileReady, setProfileReady] = useState(false);
|
||||
const [preferencesReady, setPreferencesReady] = useState(false);
|
||||
const [supportTicketsReady, setSupportTicketsReady] = useState(false);
|
||||
const [supportTicketsError, setSupportTicketsError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [savedKey, setSavedKey] = useState<string | null>(null);
|
||||
|
|
@ -192,14 +237,21 @@ export default function Settings() {
|
|||
setError(null);
|
||||
setProfileReady(false);
|
||||
setPreferencesReady(false);
|
||||
setSupportTicketsReady(false);
|
||||
setPreferences(null);
|
||||
setSupportTickets(null);
|
||||
setSupportTicketsError(null);
|
||||
setVoicePresets([]);
|
||||
setEngineConfig(null);
|
||||
engineConfigRef.current = null;
|
||||
try {
|
||||
const [nextProfile, nextPrefs, presets, nextEngine] = await Promise.all([
|
||||
const [nextProfile, nextPrefs, nextTickets, presets, nextEngine] = await Promise.all([
|
||||
userApi.me(),
|
||||
userApi.preferences(),
|
||||
userApi.supportTickets().catch((err) => {
|
||||
setSupportTicketsError(err instanceof Error ? err.message : "지원 요청을 불러오지 못했습니다.");
|
||||
return null;
|
||||
}),
|
||||
userApi.voicePresets(),
|
||||
isAdmin ? adminEngineApi.get() : Promise.resolve(null),
|
||||
]);
|
||||
|
|
@ -215,6 +267,8 @@ export default function Settings() {
|
|||
}
|
||||
setPreferences(nextPrefs);
|
||||
setPreferencesReady(true);
|
||||
setSupportTickets(nextTickets);
|
||||
setSupportTicketsReady(true);
|
||||
setDark(nextPrefs.theme === "dark" || (nextPrefs.theme !== "light" && readInitialTheme() === "dark"));
|
||||
setVoicePresets(presets);
|
||||
if (nextEngine) {
|
||||
|
|
@ -240,6 +294,7 @@ export default function Settings() {
|
|||
const navItems: NavItem[] = useMemo(() => {
|
||||
const base: NavItem[] = [
|
||||
{ id: "account", label: "계정", icon: "users" },
|
||||
{ id: "support", label: "지원 요청", icon: "review" },
|
||||
{ id: "appearance", label: "테마", icon: "settings" },
|
||||
{ id: "notify", label: "알림", icon: "info" },
|
||||
{ id: "voice", label: "음성", icon: "mic" },
|
||||
|
|
@ -550,6 +605,73 @@ export default function Settings() {
|
|||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel id="set-support" className="vg-set__group vg-set__group--wide">
|
||||
<div className="vg-set__group-head">
|
||||
<div>
|
||||
<div className="vg-set__group-title">지원 요청</div>
|
||||
<div className="vg-set__group-desc">
|
||||
내가 접수한 운영 티켓의 처리 상태와 해결 메모입니다.
|
||||
</div>
|
||||
</div>
|
||||
{supportTickets ? (
|
||||
<Badge tone={supportTickets.durable ? "accent" : "neutral"}>
|
||||
{supportTickets.tickets.length}건
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{supportTicketsError ? (
|
||||
<div className="vg-set__state vg-set__state--warn" role="alert">
|
||||
<span className="vg-set__state-dot" aria-hidden="true" />
|
||||
<span>{supportTicketsError}</span>
|
||||
</div>
|
||||
) : supportTickets ? (
|
||||
supportTickets.tickets.length > 0 ? (
|
||||
<div className="vg-set__tickets" aria-label="내 지원 요청 목록">
|
||||
{supportTickets.tickets.map((ticket) => (
|
||||
<article className="vg-set__ticket" key={ticket.ticket_id}>
|
||||
<div className="vg-set__ticket-top">
|
||||
<Badge tone={ticketStatusTone(ticket.status)}>
|
||||
{TICKET_STATUS_LABEL[ticket.status]}
|
||||
</Badge>
|
||||
<span>{TICKET_CATEGORY_LABEL[ticket.category]}</span>
|
||||
<span>{TICKET_PRIORITY_LABEL[ticket.priority]}</span>
|
||||
</div>
|
||||
<div className="vg-set__ticket-title">{ticket.subject}</div>
|
||||
<div className="vg-set__ticket-meta">
|
||||
<span>접수 {formatTicketDate(ticket.created_at)}</span>
|
||||
<span>갱신 {formatTicketDate(ticket.updated_at)}</span>
|
||||
{ticket.assigned_group ? <span>{ticket.assigned_group}</span> : null}
|
||||
</div>
|
||||
{ticket.resolution_note ? (
|
||||
<div className="vg-set__ticket-note">
|
||||
<b>해결 메모</b>
|
||||
<span>{ticket.resolution_note}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="vg-set__ticket-note vg-set__ticket-note--empty">
|
||||
처리 메모가 아직 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<SettingsLoadingState testId="settings-support-empty">
|
||||
접수한 지원 요청이 없습니다.
|
||||
</SettingsLoadingState>
|
||||
)
|
||||
) : supportTicketsReady ? (
|
||||
<SettingsLoadingState testId="settings-support-empty">
|
||||
지원 요청 저장소에 표시할 항목이 없습니다.
|
||||
</SettingsLoadingState>
|
||||
) : (
|
||||
<SettingsSkeleton testId="settings-support-loading" variant="rows">
|
||||
내 지원 요청을 불러오는 중입니다.
|
||||
</SettingsSkeleton>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
{isAdmin ? (
|
||||
<Panel
|
||||
id="set-engine"
|
||||
|
|
|
|||
|
|
@ -272,6 +272,64 @@
|
|||
font-size: 28px;
|
||||
}
|
||||
|
||||
.ad-cache {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--paper-2);
|
||||
}
|
||||
|
||||
.ad-cache span,
|
||||
.ad-cache small {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-xs);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.ad-cache b {
|
||||
color: var(--text-strong);
|
||||
font-size: var(--fs-body);
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.ad-cost-trend {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--paper-2);
|
||||
}
|
||||
|
||||
.ad-cost-trend > span {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-xs);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.ad-cost-trend > div {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ad-cost-trend b {
|
||||
flex: 0 0 auto;
|
||||
color: var(--text-strong);
|
||||
font-family: var(--font-num);
|
||||
font-size: var(--fs-sm);
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.ad-cost-trend div span {
|
||||
min-width: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-xs);
|
||||
line-height: 1.35;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.ad-ticket-mini {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
|
|
@ -1113,6 +1171,24 @@
|
|||
gap: 4px 10px;
|
||||
}
|
||||
|
||||
.ad-ticket__dupes {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
.ad-ticket .ad-ticket__dupes span {
|
||||
border: 1px solid var(--hair);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 3px 6px;
|
||||
background: var(--bg-app);
|
||||
color: var(--accent-deep);
|
||||
font-family: var(--font-num);
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.ad-ticket span,
|
||||
.ad-ticket time {
|
||||
color: var(--text-muted);
|
||||
|
|
@ -1143,8 +1219,10 @@
|
|||
|
||||
.ad-ticket__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
max-width: 190px;
|
||||
}
|
||||
|
||||
.ad-ticket__actions .vg-btn {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
.ap{padding:32px;max-width:1100px;margin:0 auto;color:var(--text-body);}
|
||||
.ap__head h1{font-size:var(--fs-xl);margin:0 0 6px;}
|
||||
.ap__head p{color:var(--text-muted);font-size:var(--fs-sm);margin:0 0 28px;}
|
||||
.ap__hero{display:flex;gap:32px;flex-wrap:wrap;justify-content:center;padding:24px;border-radius:20px;background:var(--bg-stage);margin-bottom:32px;}
|
||||
.ap__hero{display:flex;gap:32px;flex-wrap:wrap;justify-content:center;padding:24px;border:1px solid var(--border-subtle);border-radius:20px;background:linear-gradient(135deg,rgba(238,244,242,.96),rgba(246,240,236,.92));margin-bottom:32px;}
|
||||
.ap__hero .vg-avatar__persona{color:#1c2a2a;}
|
||||
.ap__hero .vg-avatar__state{color:#5a6663;}
|
||||
.ap__hero .vg-avatar__state b{color:#1c2a2a;}
|
||||
.ap__grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:18px;}
|
||||
.ap__cell{margin:0;display:flex;flex-direction:column;align-items:center;gap:8px;}
|
||||
.ap__cell figcaption{font-size:var(--fs-xs);color:var(--text-muted);}
|
||||
|
|
|
|||
|
|
@ -120,6 +120,7 @@
|
|||
"overview flow rubric"
|
||||
"overview transcript good"
|
||||
"overview transcript growth"
|
||||
"overview transcript prepost"
|
||||
"overview transcript worksheet"
|
||||
"overview transcript feedback"
|
||||
"session session session";
|
||||
|
|
@ -133,10 +134,59 @@
|
|||
"overview flow rubric"
|
||||
"overview transcript good"
|
||||
"overview transcript growth"
|
||||
"overview transcript prepost"
|
||||
"overview transcript worksheet"
|
||||
"overview transcript feedback"
|
||||
"session session session";
|
||||
}
|
||||
.sr-cols--learner {
|
||||
grid-template-columns: minmax(0, 1.08fr) minmax(320px, 0.92fr);
|
||||
grid-template-areas:
|
||||
"transcript overview"
|
||||
"transcript chart"
|
||||
"transcript flow"
|
||||
"transcript rubric"
|
||||
"transcript good"
|
||||
"transcript growth"
|
||||
"transcript prepost"
|
||||
"transcript worksheet"
|
||||
"transcript feedback"
|
||||
"session session";
|
||||
}
|
||||
.sr-cols--learner .sr-overview {
|
||||
position: static;
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
scrollbar-gutter: auto;
|
||||
}
|
||||
.sr-root--empty .sr-cols {
|
||||
grid-template-columns: minmax(236px, 300px) minmax(0, 1fr);
|
||||
grid-template-areas:
|
||||
"overview chart"
|
||||
"overview flow"
|
||||
"overview transcript"
|
||||
"overview rubric"
|
||||
"overview good"
|
||||
"overview growth"
|
||||
"overview prepost"
|
||||
"overview worksheet"
|
||||
"overview feedback"
|
||||
"session session";
|
||||
}
|
||||
.sr-root--empty .sr-cols--supervisor {
|
||||
grid-template-areas:
|
||||
"overview chart"
|
||||
"overview flow"
|
||||
"overview teacher"
|
||||
"overview transcript"
|
||||
"overview rubric"
|
||||
"overview good"
|
||||
"overview growth"
|
||||
"overview prepost"
|
||||
"overview worksheet"
|
||||
"overview feedback"
|
||||
"session session";
|
||||
}
|
||||
.sr-left,
|
||||
.sr-right {
|
||||
display: contents;
|
||||
|
|
@ -175,6 +225,9 @@
|
|||
.sr-card--growth {
|
||||
grid-area: growth;
|
||||
}
|
||||
.sr-card--prepost {
|
||||
grid-area: prepost;
|
||||
}
|
||||
.sr-card--worksheet {
|
||||
grid-area: worksheet;
|
||||
}
|
||||
|
|
@ -353,6 +406,146 @@
|
|||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.sr-prepost__head {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
.sr-prepost__head > div {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
.sr-prepost__head b {
|
||||
color: var(--text-strong);
|
||||
font-size: var(--fs-sm);
|
||||
line-height: 1.35;
|
||||
}
|
||||
.sr-prepost__badge {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 28px;
|
||||
padding: 0 9px;
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 24%, var(--border-subtle));
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--accent-deep);
|
||||
background: var(--accent-tint);
|
||||
font-family: var(--font-num);
|
||||
font-size: 11px;
|
||||
font-weight: 750;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sr-prepost__note {
|
||||
margin: var(--sp-3) 0 0;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-xs);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.sr-prepost__table {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
margin-top: var(--sp-4);
|
||||
}
|
||||
.sr-prepost__row {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) repeat(2, minmax(58px, 68px));
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.sr-prepost__row--head {
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-num);
|
||||
font-size: 10.5px;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.sr-prepost__row--head span:not(:first-child) {
|
||||
text-align: center;
|
||||
}
|
||||
.sr-prepost__measure {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
.sr-prepost__measure b {
|
||||
min-width: 0;
|
||||
color: var(--text-strong);
|
||||
font-size: var(--fs-xs);
|
||||
line-height: 1.35;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.sr-prepost__measure small {
|
||||
min-width: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 10.5px;
|
||||
line-height: 1.35;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.sr-prepost__field {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
}
|
||||
.sr-prepost__field span {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sr-prepost__field input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 36px;
|
||||
padding: 0 7px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-strong);
|
||||
font-family: var(--font-num);
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 650;
|
||||
text-align: center;
|
||||
}
|
||||
.sr-prepost__field input:focus {
|
||||
outline: 2px solid var(--accent-tint);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.sr-prepost__field input[aria-invalid="true"] {
|
||||
border-color: color-mix(in srgb, var(--crit-text) 44%, var(--border-subtle));
|
||||
background: var(--crit-tint);
|
||||
}
|
||||
.sr-prepost__error {
|
||||
margin: var(--sp-3) 0 0;
|
||||
color: var(--crit-text);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.sr-prepost__actions {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-3);
|
||||
margin-top: var(--sp-4);
|
||||
padding-top: var(--sp-3);
|
||||
border-top: 1px solid var(--hair);
|
||||
}
|
||||
.sr-prepost__actions span {
|
||||
min-width: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-xs);
|
||||
line-height: 1.35;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.sr-chart {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
|
@ -692,6 +885,15 @@
|
|||
.sr-nonverbal--audio .sr-nonverbal__dot {
|
||||
background: var(--accent);
|
||||
}
|
||||
.sr-nonverbal--paralinguistic .sr-nonverbal__dot {
|
||||
background: var(--good-solid);
|
||||
}
|
||||
.sr-nonverbal--prosody .sr-nonverbal__dot {
|
||||
background: var(--ink-soft);
|
||||
}
|
||||
.sr-nonverbal--audio_quality .sr-nonverbal__dot {
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
.sr-note {
|
||||
max-width: 68ch;
|
||||
|
|
@ -1187,6 +1389,7 @@
|
|||
[data-theme="dark"] .sr-empty,
|
||||
[data-theme="dark"] .sr-chip-toggle,
|
||||
[data-theme="dark"] .sr-ws-item,
|
||||
[data-theme="dark"] .sr-prepost__field input,
|
||||
[data-theme="dark"] .sr-feedback,
|
||||
[data-theme="dark"] .sr-nextline {
|
||||
background: rgba(255, 255, 255, 0.055);
|
||||
|
|
@ -1196,6 +1399,7 @@
|
|||
[data-theme="dark"] .sr-turn + .sr-turn,
|
||||
[data-theme="dark"] .sr-readiness,
|
||||
[data-theme="dark"] .sr-ws-limitations,
|
||||
[data-theme="dark"] .sr-prepost__actions,
|
||||
[data-theme="dark"] .sr-feedback__src {
|
||||
border-color: rgba(203, 227, 220, 0.1);
|
||||
}
|
||||
|
|
@ -1225,6 +1429,20 @@
|
|||
"rubric rubric"
|
||||
"good growth"
|
||||
"transcript transcript"
|
||||
"prepost prepost"
|
||||
"feedback feedback"
|
||||
"worksheet worksheet"
|
||||
"session session";
|
||||
}
|
||||
.sr-root--empty .sr-cols {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
grid-template-areas:
|
||||
"overview overview"
|
||||
"chart flow"
|
||||
"transcript transcript"
|
||||
"rubric rubric"
|
||||
"good growth"
|
||||
"prepost prepost"
|
||||
"feedback feedback"
|
||||
"worksheet worksheet"
|
||||
"session session";
|
||||
|
|
@ -1237,6 +1455,20 @@
|
|||
"rubric rubric"
|
||||
"good growth"
|
||||
"transcript transcript"
|
||||
"prepost prepost"
|
||||
"feedback feedback"
|
||||
"worksheet worksheet"
|
||||
"session session";
|
||||
}
|
||||
.sr-root--empty .sr-cols--supervisor {
|
||||
grid-template-areas:
|
||||
"overview overview"
|
||||
"chart flow"
|
||||
"teacher teacher"
|
||||
"transcript transcript"
|
||||
"rubric rubric"
|
||||
"good growth"
|
||||
"prepost prepost"
|
||||
"feedback feedback"
|
||||
"worksheet worksheet"
|
||||
"session session";
|
||||
|
|
@ -1283,12 +1515,28 @@
|
|||
"rubric"
|
||||
"good"
|
||||
"growth"
|
||||
"prepost"
|
||||
"transcript"
|
||||
"feedback"
|
||||
"worksheet"
|
||||
"session";
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
.sr-root--empty .sr-cols {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-areas:
|
||||
"overview"
|
||||
"chart"
|
||||
"flow"
|
||||
"transcript"
|
||||
"rubric"
|
||||
"good"
|
||||
"growth"
|
||||
"prepost"
|
||||
"feedback"
|
||||
"worksheet"
|
||||
"session";
|
||||
}
|
||||
.sr-cols--supervisor {
|
||||
grid-template-areas:
|
||||
"overview"
|
||||
|
|
@ -1298,11 +1546,27 @@
|
|||
"rubric"
|
||||
"good"
|
||||
"growth"
|
||||
"prepost"
|
||||
"transcript"
|
||||
"feedback"
|
||||
"worksheet"
|
||||
"session";
|
||||
}
|
||||
.sr-root--empty .sr-cols--supervisor {
|
||||
grid-template-areas:
|
||||
"overview"
|
||||
"chart"
|
||||
"flow"
|
||||
"teacher"
|
||||
"transcript"
|
||||
"rubric"
|
||||
"good"
|
||||
"growth"
|
||||
"prepost"
|
||||
"feedback"
|
||||
"worksheet"
|
||||
"session";
|
||||
}
|
||||
.sr-summary {
|
||||
font-size: 20px;
|
||||
line-height: 1.45;
|
||||
|
|
|
|||
|
|
@ -544,8 +544,8 @@
|
|||
}
|
||||
.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)),
|
||||
radial-gradient(circle at 28% 50%, rgba(197, 222, 214, 0.26), transparent 42%),
|
||||
linear-gradient(135deg, rgba(24, 42, 38, 0.94), rgba(48, 65, 59, 0.9)),
|
||||
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);
|
||||
|
|
@ -949,6 +949,33 @@
|
|||
.sx-utt.is-partial .sx-utt__line {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.sx-utt.is-thinking .sx-utt__line {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text-muted);
|
||||
border: 1px dashed color-mix(in srgb, var(--clay-deep) 24%, transparent);
|
||||
}
|
||||
.sx-utt__dots {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
flex: none;
|
||||
}
|
||||
.sx-utt__dots i {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
opacity: 0.35;
|
||||
animation: sxThinkingDot 900ms var(--ease-in-out) infinite;
|
||||
}
|
||||
.sx-utt__dots i:nth-child(2) {
|
||||
animation-delay: 120ms;
|
||||
}
|
||||
.sx-utt__dots i:nth-child(3) {
|
||||
animation-delay: 240ms;
|
||||
}
|
||||
.sx-utt__caret {
|
||||
display: inline-block;
|
||||
width: 2px;
|
||||
|
|
@ -961,6 +988,10 @@
|
|||
@keyframes sxCaret {
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
@keyframes sxThinkingDot {
|
||||
0%, 100% { opacity: 0.25; transform: translateY(0); }
|
||||
50% { opacity: 0.9; transform: translateY(-2px); }
|
||||
}
|
||||
|
||||
/* "최신으로" 칩 (위로 스크롤 시) */
|
||||
.sx-transcript__jump {
|
||||
|
|
@ -1642,6 +1673,10 @@
|
|||
background: rgba(204, 143, 119, 0.12);
|
||||
color: #edf5f2;
|
||||
}
|
||||
.sx-page--active .sx-utt.is-thinking .sx-utt__line {
|
||||
color: rgba(234, 240, 241, 0.62);
|
||||
border-color: rgba(229, 173, 149, 0.22);
|
||||
}
|
||||
.sx-page--active .sx-utt.is-learner .sx-utt__line {
|
||||
background: rgba(111, 179, 164, 0.13);
|
||||
color: #edf5f2;
|
||||
|
|
@ -1731,6 +1766,36 @@
|
|||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.sx-voice-skip {
|
||||
flex: none;
|
||||
height: 34px;
|
||||
padding: 0 11px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border-strong);
|
||||
background: var(--bg-surface-2);
|
||||
color: var(--text-body);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sx-voice-skip:hover {
|
||||
border-color: var(--border-focus);
|
||||
color: var(--text-strong);
|
||||
}
|
||||
.sx-page--active .sx-voice-skip {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(255, 255, 255, 0.14);
|
||||
color: rgba(238, 244, 242, 0.86);
|
||||
}
|
||||
.sx-page--active .sx-voice-skip:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.sx-cb-sep {
|
||||
width: 1px;
|
||||
|
|
@ -2336,6 +2401,72 @@
|
|||
background: rgba(204, 143, 119, 0.17);
|
||||
color: #f0bda9;
|
||||
}
|
||||
.sx-theory {
|
||||
width: min(100%, 520px);
|
||||
margin: var(--sp-4) 0 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.sx-theory legend {
|
||||
padding: 0;
|
||||
color: rgba(238, 244, 242, 0.54);
|
||||
font-size: 11.5px;
|
||||
font-weight: 760;
|
||||
}
|
||||
.sx-theory__seg {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
padding: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(6, 15, 14, 0.26);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.sx-theory__seg button {
|
||||
min-width: 0;
|
||||
min-height: 56px;
|
||||
display: grid;
|
||||
align-content: center;
|
||||
gap: 3px;
|
||||
padding: 8px 7px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: calc(var(--radius-sm) - 2px);
|
||||
background: transparent;
|
||||
color: rgba(238, 244, 242, 0.72);
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
.sx-theory__seg button:hover {
|
||||
border-color: rgba(145, 200, 189, 0.3);
|
||||
color: #f4f8f7;
|
||||
}
|
||||
.sx-theory__seg button:focus-visible {
|
||||
outline: 2px solid rgba(145, 200, 189, 0.7);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.sx-theory__seg button.is-selected {
|
||||
background: rgba(145, 200, 189, 0.18);
|
||||
border-color: rgba(145, 200, 189, 0.46);
|
||||
color: #f4f8f7;
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.sx-theory__seg span,
|
||||
.sx-theory__seg small {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.sx-theory__seg span {
|
||||
font-size: 13px;
|
||||
font-weight: 760;
|
||||
}
|
||||
.sx-theory__seg small {
|
||||
color: rgba(238, 244, 242, 0.56);
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
}
|
||||
.sx-prestart__note {
|
||||
max-width: 460px;
|
||||
font-size: 12.5px;
|
||||
|
|
@ -2431,16 +2562,21 @@
|
|||
.sx-stage.is-paused::after {
|
||||
content: "일시정지";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
inset: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 15px;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-end;
|
||||
padding: 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-on-accent);
|
||||
background: color-mix(in srgb, var(--bg-stage) 62%, transparent);
|
||||
backdrop-filter: blur(1px);
|
||||
color: #f4faf7;
|
||||
border: 1px solid rgba(203, 227, 220, 0.14);
|
||||
border-radius: calc(var(--radius-lg) - 2px);
|
||||
background:
|
||||
linear-gradient(135deg, rgba(238, 244, 242, 0.08), rgba(145, 200, 189, 0.06));
|
||||
backdrop-filter: blur(0.5px);
|
||||
text-shadow: 0 1px 2px rgba(7, 16, 14, 0.5);
|
||||
}
|
||||
|
||||
/* ── 반응형 ── */
|
||||
|
|
@ -2918,6 +3054,23 @@
|
|||
.sx-page--prestart .sx-prestart__chips {
|
||||
display: none;
|
||||
}
|
||||
.sx-page--prestart .sx-theory {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.sx-page--prestart .sx-theory__seg {
|
||||
gap: 4px;
|
||||
padding: 3px;
|
||||
}
|
||||
.sx-page--prestart .sx-theory__seg button {
|
||||
min-height: 50px;
|
||||
padding: 6px 4px;
|
||||
}
|
||||
.sx-page--prestart .sx-theory__seg span {
|
||||
font-size: 12px;
|
||||
}
|
||||
.sx-page--prestart .sx-theory__seg small {
|
||||
font-size: 10.5px;
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__plan {
|
||||
display: none;
|
||||
}
|
||||
|
|
@ -2971,6 +3124,14 @@
|
|||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.sx-voice-skip span {
|
||||
display: none;
|
||||
}
|
||||
.sx-voice-skip {
|
||||
width: 34px;
|
||||
padding: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
|
|
@ -3246,6 +3407,7 @@
|
|||
.sx-orb,
|
||||
.sx-mic.is-on::after,
|
||||
.sx-utt__caret,
|
||||
.sx-utt__dots i,
|
||||
.sx-transcript__live-dot.is-live {
|
||||
animation: none !important;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1334,18 +1334,21 @@ body[data-page="settings"] .vg-set__forms {
|
|||
body[data-page="settings"] #set-account {
|
||||
order: 1;
|
||||
}
|
||||
body[data-page="settings"] #set-engine {
|
||||
body[data-page="settings"] #set-support {
|
||||
order: 2;
|
||||
}
|
||||
body[data-page="settings"] #set-appearance {
|
||||
body[data-page="settings"] #set-engine {
|
||||
order: 3;
|
||||
}
|
||||
body[data-page="settings"] #set-voice {
|
||||
body[data-page="settings"] #set-appearance {
|
||||
order: 4;
|
||||
}
|
||||
body[data-page="settings"] #set-notify {
|
||||
body[data-page="settings"] #set-voice {
|
||||
order: 5;
|
||||
}
|
||||
body[data-page="settings"] #set-notify {
|
||||
order: 6;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__group {
|
||||
padding: 18px;
|
||||
border: 1px solid rgba(37, 45, 42, 0.12);
|
||||
|
|
@ -1409,6 +1412,57 @@ body[data-page="settings"] .vg-set__voicelist {
|
|||
body[data-page="settings"] .vg-set__voice {
|
||||
min-height: 72px;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__tickets {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__ticket {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border: 1px solid rgba(37, 45, 42, 0.12);
|
||||
border-radius: var(--radius);
|
||||
background: rgba(255, 255, 255, 0.86);
|
||||
}
|
||||
body[data-page="settings"] .vg-set__ticket-top,
|
||||
body[data-page="settings"] .vg-set__ticket-meta {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 7px 10px;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
body[data-page="settings"] .vg-set__ticket-title {
|
||||
min-width: 0;
|
||||
color: var(--text-strong);
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__ticket-note {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 9px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(244, 241, 236, 0.78);
|
||||
color: var(--text-body);
|
||||
font-size: var(--fs-xs);
|
||||
line-height: 1.55;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__ticket-note b {
|
||||
color: var(--text-strong);
|
||||
font-size: var(--fs-kicker);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__ticket-note--empty {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
body[data-page="settings"] .vg-set__foot {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
|
|
@ -1594,6 +1648,8 @@ html[data-theme="dark"] body[data-page="settings"] .vg-set__range-row,
|
|||
html[data-theme="dark"] body[data-page="settings"] .vg-set__seg,
|
||||
html[data-theme="dark"] body[data-page="settings"] .vg-set__pill,
|
||||
html[data-theme="dark"] body[data-page="settings"] .vg-set__callout,
|
||||
html[data-theme="dark"] body[data-page="settings"] .vg-set__ticket,
|
||||
html[data-theme="dark"] body[data-page="settings"] .vg-set__ticket-note,
|
||||
html[data-theme="dark"] body[data-page="settings"] .vg-badge {
|
||||
border-color: rgba(203, 227, 220, 0.11);
|
||||
background: rgba(255, 255, 255, 0.055);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue