chore: sync local workspace changes
This commit is contained in:
@@ -1275,6 +1275,27 @@
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.layout-playground__memo-widget-preview--flat {
|
||||
gap: 10px;
|
||||
padding: 12px 12px calc(16px + env(safe-area-inset-bottom, 0px));
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
border-radius: 20px;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.96));
|
||||
box-shadow: 0 16px 32px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.layout-playground__memo-widget-preview-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.layout-playground__memo-widget-preview-head .ant-typography:first-child {
|
||||
color: #111827;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.layout-playground__memo-widget-preview-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1301,6 +1322,10 @@
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.layout-playground__memo-widget-preview--flat .layout-playground__memo-widget-preview-toolbar .ant-btn:not(:disabled):hover {
|
||||
background: rgba(14, 116, 144, 0.08);
|
||||
}
|
||||
|
||||
.layout-playground__memo-widget-preview-body {
|
||||
display: flex;
|
||||
flex: 1 1 0;
|
||||
@@ -1327,6 +1352,12 @@
|
||||
background: rgba(255, 255, 255, 0.84);
|
||||
}
|
||||
|
||||
.layout-playground__memo-widget-preview--flat .layout-playground__memo-widget-preview-sheet {
|
||||
border-radius: 16px;
|
||||
border-color: rgba(148, 163, 184, 0.22);
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
}
|
||||
|
||||
.layout-playground__memo-widget-preview-empty {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -1356,6 +1387,24 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.layout-playground__memo-widget-preview--flat .layout-playground__memo-widget-preview-list {
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.layout-playground__memo-widget-preview--flat .layout-playground__memo-widget-preview-item {
|
||||
gap: 4px;
|
||||
padding: 11px 12px;
|
||||
border: 1px solid rgba(226, 232, 240, 0.95);
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.layout-playground__memo-widget-preview--flat .layout-playground__memo-widget-preview-item--active {
|
||||
border-color: rgba(14, 116, 144, 0.22);
|
||||
background: rgba(240, 249, 255, 0.96);
|
||||
}
|
||||
|
||||
.layout-playground__memo-widget-preview-item--active {
|
||||
background: rgba(254, 240, 138, 0.42);
|
||||
}
|
||||
@@ -1380,6 +1429,10 @@
|
||||
border-radius: 24px;
|
||||
}
|
||||
|
||||
.layout-playground__memo-widget-preview--flat .layout-playground__memo-widget-preview-editor {
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.layout-playground__memo-widget-preview-editor .ant-input-textarea {
|
||||
display: flex;
|
||||
flex: 1 1 0;
|
||||
@@ -1407,6 +1460,12 @@
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.layout-playground__memo-widget-preview--flat .layout-playground__memo-widget-preview-meta {
|
||||
min-height: 32px;
|
||||
padding: 10px 4px 0;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.layout-playground__memo-widget-preview-meta > :first-child {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
@@ -1439,6 +1498,46 @@
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.layout-playground__memo-widget-preview--flat .layout-playground__memo-widget-preview-input.ant-input,
|
||||
.layout-playground__memo-widget-preview--flat .layout-playground__memo-widget-preview-editor .ant-input {
|
||||
padding: 10px 4px calc(20px + env(safe-area-inset-bottom, 0px));
|
||||
color: #0f172a;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.layout-playground__memo-widget-preview--flat .layout-playground__memo-widget-preview-editor .ant-input::placeholder {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.layout-playground__action-preview {
|
||||
display: flex;
|
||||
flex: 1 1 0;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: 12px;
|
||||
border: 1px solid rgba(14, 116, 144, 0.16);
|
||||
border-radius: 20px;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(240, 249, 255, 0.92));
|
||||
box-shadow: 0 14px 28px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.layout-playground__action-preview-button.ant-btn {
|
||||
height: 46px;
|
||||
border-radius: 14px;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 14px 28px rgba(3, 105, 161, 0.18);
|
||||
}
|
||||
|
||||
.layout-playground__action-preview-copy {
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.layout-playground__select-preview {
|
||||
display: flex;
|
||||
flex: 1 1 0;
|
||||
|
||||
@@ -32,7 +32,13 @@ import { StockAlertFilterPane, StockAlertGridPane, StockAlertLayoutProvider } fr
|
||||
import { resolveSampleEntries, type LoadedSampleEntry } from '../../samples/registry';
|
||||
import { deleteLayout, listSavedLayouts, saveLayout, type LayoutAxis as StoredLayoutAxis, type SavedLayoutRecord, type SizeUnit as StoredSizeUnit } from './layoutStorage';
|
||||
import { resolvePreferredLayoutCodexChatType } from './layoutCodexChatType';
|
||||
import { LayoutPreviewBaseInputPane, LayoutPreviewEmptyPane, LayoutPreviewSelectPane, LayoutPreviewTextMemoPane } from './LayoutPreviewWidgets';
|
||||
import {
|
||||
LayoutPreviewActionPane,
|
||||
LayoutPreviewBaseInputPane,
|
||||
LayoutPreviewEmptyPane,
|
||||
LayoutPreviewSelectPane,
|
||||
LayoutPreviewTextMemoPane,
|
||||
} from './LayoutPreviewWidgets';
|
||||
import {
|
||||
resolveLayoutPreviewBindingKind,
|
||||
useLayoutPreviewRuntime,
|
||||
@@ -1806,6 +1812,7 @@ export function LayoutPlaygroundView({
|
||||
hideInteractionOverlays?: boolean;
|
||||
scopeKey?: string;
|
||||
paneSizingMeta?: LayoutPaneSizingMeta | null;
|
||||
onPreviewActionClick?: () => void;
|
||||
},
|
||||
): React.ReactNode => {
|
||||
const selectedId = options?.selectedId ?? selectedLeafId;
|
||||
@@ -1846,6 +1853,8 @@ export function LayoutPlaygroundView({
|
||||
previewBindingKind === 'select-input' ? resolveSelectPreviewOptions(sourceInteractions) : DEFAULT_COMBO_VALUE_OPTIONS;
|
||||
const shouldFillBaseInputPane =
|
||||
previewBindingKind === 'base-input' && sourceInteractions.some((rule) => rule.title.trim() === '100%가득채움');
|
||||
const memoPaneTitle =
|
||||
previewMode && node.label.trim() && !/^section\s*\d+$/iu.test(node.label.trim()) ? node.label.trim() : '기능설명 본문';
|
||||
const customPreviewBody = isStockAlertLayout
|
||||
? node.componentBinding?.optionId === 'component:select-input:select-input-base'
|
||||
? <StockAlertFilterPane />
|
||||
@@ -1874,6 +1883,8 @@ export function LayoutPlaygroundView({
|
||||
/>
|
||||
) : previewBindingKind === 'text-memo-widget' ? (
|
||||
<LayoutPreviewTextMemoPane
|
||||
skin="flat"
|
||||
title={memoPaneTitle}
|
||||
state={layoutPreviewRuntime.memoStates[scopedLeafId] ?? {
|
||||
draftBody: '',
|
||||
selectedId: null,
|
||||
@@ -1903,6 +1914,11 @@ export function LayoutPlaygroundView({
|
||||
layoutPreviewRuntime.setMemoDraftBody(scopedLeafId, nextValue);
|
||||
}}
|
||||
/>
|
||||
) : previewBindingKind === 'action-button' ? (
|
||||
<LayoutPreviewActionPane
|
||||
label="Codex 실행"
|
||||
onClick={options?.onPreviewActionClick}
|
||||
/>
|
||||
) : previewBindingKind === 'select-input' ? (
|
||||
<LayoutPreviewSelectPane
|
||||
state={layoutPreviewRuntime.selectStates[scopedLeafId] ?? {
|
||||
@@ -2272,6 +2288,18 @@ export function LayoutPlaygroundView({
|
||||
[selectedSavedLayoutPayload],
|
||||
);
|
||||
const savedLayoutRecordMap = useMemo(() => new Map(savedLayouts.map((record) => [record.id, record])), [savedLayouts]);
|
||||
const selectedSavedLayoutPrompt = useMemo(
|
||||
() =>
|
||||
selectedSavedLayoutRecord
|
||||
? buildCodexRequestText({
|
||||
layoutName: selectedSavedLayoutRecord.name,
|
||||
rules: selectedSavedLayoutPayload?.interactions ?? [],
|
||||
leafMap: selectedSavedInteractionLeafMap,
|
||||
root: selectedSavedLayoutPayload?.root ?? null,
|
||||
})
|
||||
: '',
|
||||
[selectedSavedInteractionLeafMap, selectedSavedLayoutPayload, selectedSavedLayoutRecord],
|
||||
);
|
||||
const currentInteractionPrompt = useMemo(
|
||||
() =>
|
||||
buildCodexRequestText({
|
||||
@@ -2335,6 +2363,9 @@ export function LayoutPlaygroundView({
|
||||
interactionLeafMap: selectedSavedInteractionLeafMap,
|
||||
hideInteractionOverlays: true,
|
||||
scopeKey: selectedSavedLayoutRecord.id,
|
||||
onPreviewActionClick: () => {
|
||||
void openCodexLiveForPrompt(selectedSavedLayoutPrompt);
|
||||
},
|
||||
})}
|
||||
</LayoutSamplePreview>
|
||||
</StockAlertLayoutProvider>
|
||||
@@ -2441,6 +2472,16 @@ export function LayoutPlaygroundView({
|
||||
interactionLeafMap: savedLeafMap,
|
||||
hideInteractionOverlays: true,
|
||||
scopeKey: record.id,
|
||||
onPreviewActionClick: () => {
|
||||
void openCodexLiveForPrompt(
|
||||
buildCodexRequestText({
|
||||
layoutName: record.name,
|
||||
rules: savedPayload.interactions,
|
||||
leafMap: savedLeafMap,
|
||||
root: savedPayload.root,
|
||||
}),
|
||||
);
|
||||
},
|
||||
})}
|
||||
</LayoutSamplePreview>
|
||||
</StockAlertLayoutProvider>
|
||||
@@ -2951,6 +2992,9 @@ export function LayoutPlaygroundView({
|
||||
activeInteractionIds: [],
|
||||
hideInteractionOverlays: true,
|
||||
scopeKey: 'editor',
|
||||
onPreviewActionClick: () => {
|
||||
void openCodexLiveForPrompt(currentInteractionPrompt);
|
||||
},
|
||||
})}
|
||||
</LayoutSamplePreview>
|
||||
) : (
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
CheckOutlined,
|
||||
DeleteOutlined,
|
||||
LeftOutlined,
|
||||
PlayCircleOutlined,
|
||||
PlusOutlined,
|
||||
RightOutlined,
|
||||
UnorderedListOutlined,
|
||||
@@ -60,6 +61,8 @@ const EMPTY_PANE_READINESS_META: Record<
|
||||
|
||||
export function LayoutPreviewTextMemoPane({
|
||||
state,
|
||||
skin = 'note',
|
||||
title = '메모 본문',
|
||||
onStartDraft,
|
||||
onToggleList,
|
||||
onDeleteSelection,
|
||||
@@ -69,6 +72,8 @@ export function LayoutPreviewTextMemoPane({
|
||||
onDraftChange,
|
||||
}: {
|
||||
state: LayoutPreviewMemoState;
|
||||
skin?: 'flat' | 'note';
|
||||
title?: string;
|
||||
onStartDraft: () => void;
|
||||
onToggleList: () => void;
|
||||
onDeleteSelection: () => void;
|
||||
@@ -80,9 +85,21 @@ export function LayoutPreviewTextMemoPane({
|
||||
const selectedIndex = state.selectedId ? state.notes.findIndex((note) => note.id === state.selectedId) : -1;
|
||||
const selectedNote = selectedIndex >= 0 ? state.notes[selectedIndex] : null;
|
||||
const hasDraft = state.draftBody.trim().length > 0;
|
||||
const isFlat = skin === 'flat';
|
||||
|
||||
return (
|
||||
<div className="layout-playground__memo-widget-preview" onClick={stopPreviewEvent}>
|
||||
<div
|
||||
className={`layout-playground__memo-widget-preview${
|
||||
isFlat ? ' layout-playground__memo-widget-preview--flat' : ''
|
||||
}`}
|
||||
onClick={stopPreviewEvent}
|
||||
>
|
||||
{isFlat ? (
|
||||
<div className="layout-playground__memo-widget-preview-head">
|
||||
<Text strong>{title}</Text>
|
||||
<Text type="secondary">{selectedNote ? '선택 항목 편집 중' : '새 항목 작성 가능'}</Text>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="layout-playground__memo-widget-preview-toolbar" role="toolbar" aria-label="메모 도구">
|
||||
<div className="layout-playground__memo-widget-preview-toolbar-group">
|
||||
<Button type="text" shape="circle" htmlType="button" icon={<PlusOutlined />} aria-label="새 메모" onClick={onStartDraft} />
|
||||
@@ -216,6 +233,35 @@ export function LayoutPreviewBaseInputPane({
|
||||
);
|
||||
}
|
||||
|
||||
export function LayoutPreviewActionPane({
|
||||
label = 'Codex 실행',
|
||||
description = '선택한 레이아웃과 기능설명 조합 기준으로 실행합니다.',
|
||||
onClick,
|
||||
}: {
|
||||
label?: string;
|
||||
description?: string;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="layout-playground__action-preview" onClick={stopPreviewEvent}>
|
||||
<div className="layout-playground__action-preview-copy">
|
||||
<span className="layout-playground__action-preview-kicker">전역 실행</span>
|
||||
<Text strong>{label}</Text>
|
||||
<Text type="secondary">{description}</Text>
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
icon={<PlayCircleOutlined />}
|
||||
className="layout-playground__action-preview-button"
|
||||
onClick={onClick}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LayoutPreviewSelectPane({
|
||||
state,
|
||||
data,
|
||||
|
||||
863
src/views/play/apps/cbt/CbtPlayAppView.css
Normal file
863
src/views/play/apps/cbt/CbtPlayAppView.css
Normal file
@@ -0,0 +1,863 @@
|
||||
.cbt-play-app {
|
||||
--cbt-bg: linear-gradient(180deg, #f8fbff 0%, #eef4fb 100%);
|
||||
--cbt-surface: rgba(255, 255, 255, 0.92);
|
||||
--cbt-surface-soft: rgba(244, 248, 253, 0.82);
|
||||
--cbt-border: rgba(74, 105, 143, 0.14);
|
||||
--cbt-accent: #3d7cf1;
|
||||
--cbt-accent-strong: #2a5fc4;
|
||||
--cbt-ink: #1f2f45;
|
||||
--cbt-muted: #5f7088;
|
||||
--cbt-success: #2f8a56;
|
||||
--cbt-warn: #bf6f26;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
max-height: 100dvh;
|
||||
padding: 14px 14px calc(18px + env(safe-area-inset-bottom, 0px));
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(130, 190, 255, 0.2), transparent 34%),
|
||||
radial-gradient(circle at top left, rgba(220, 238, 255, 0.86), transparent 28%),
|
||||
var(--cbt-bg);
|
||||
color: var(--cbt-ink);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cbt-play-app--immersive {
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.cbt-play-app__viewport-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 1120px;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
gap: 10px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.cbt-play-app__topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--cbt-border);
|
||||
border-radius: 22px;
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
box-shadow: 0 16px 36px rgba(46, 86, 138, 0.08);
|
||||
}
|
||||
|
||||
.cbt-play-app__topbar-group,
|
||||
.cbt-play-app__topbar-status,
|
||||
.cbt-play-app__section-actions,
|
||||
.cbt-play-app__result-switch {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cbt-play-app__content-card {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
padding: 18px;
|
||||
border: 1px solid var(--cbt-border);
|
||||
background: var(--cbt-surface);
|
||||
border-radius: 28px;
|
||||
box-shadow: 0 16px 40px rgba(46, 86, 138, 0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cbt-play-app--immersive .cbt-play-app__content-card,
|
||||
.cbt-play-app--immersive .cbt-play-app__stack--fill {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.cbt-play-app__action-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.cbt-play-app__action-card {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 18px;
|
||||
border: 1px solid rgba(74, 105, 143, 0.12);
|
||||
border-radius: 24px;
|
||||
background: rgba(247, 250, 255, 0.92);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 120ms ease,
|
||||
border-color 120ms ease,
|
||||
box-shadow 120ms ease;
|
||||
}
|
||||
|
||||
.cbt-play-app__action-card:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: rgba(61, 124, 241, 0.28);
|
||||
box-shadow: 0 16px 32px rgba(46, 86, 138, 0.08);
|
||||
}
|
||||
|
||||
.cbt-play-app__action-card--primary {
|
||||
background: linear-gradient(180deg, rgba(239, 246, 255, 0.98), rgba(248, 251, 255, 0.98));
|
||||
border-color: rgba(61, 124, 241, 0.2);
|
||||
}
|
||||
|
||||
.cbt-play-app__action-card-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cbt-play-app__hero--compact {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.cbt-play-app__panel--section-head {
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.cbt-play-app__stack--results {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.cbt-play-app__card-scroll {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding-right: 2px;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.cbt-play-app__stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-height: min-content;
|
||||
}
|
||||
|
||||
.cbt-play-app__hero,
|
||||
.cbt-play-app__panel {
|
||||
border: 1px solid var(--cbt-border);
|
||||
background: var(--cbt-surface);
|
||||
border-radius: 28px;
|
||||
box-shadow: 0 16px 40px rgba(46, 86, 138, 0.08);
|
||||
}
|
||||
|
||||
.cbt-play-app__hero {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.cbt-play-app__eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(61, 124, 241, 0.1);
|
||||
color: var(--cbt-accent-strong);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.cbt-play-app__hero-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.8fr) minmax(280px, 1fr);
|
||||
gap: 18px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.cbt-play-app__workspace {
|
||||
padding: 18px;
|
||||
border-radius: 32px;
|
||||
border: 1px solid rgba(74, 105, 143, 0.12);
|
||||
background: rgba(251, 253, 255, 0.92);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
.cbt-play-app__workspace-body {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.cbt-play-app__solver-scroll {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding-right: 2px;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.cbt-play-app__hero-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.cbt-play-app__hero-copy h2.ant-typography {
|
||||
margin: 0;
|
||||
color: var(--cbt-ink);
|
||||
font-size: clamp(28px, 4vw, 42px);
|
||||
line-height: 1.08;
|
||||
}
|
||||
|
||||
.cbt-play-app__hero-copy .ant-typography {
|
||||
color: var(--cbt-muted);
|
||||
}
|
||||
|
||||
.cbt-play-app__hero-points {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cbt-play-app__hero-points .ant-tag {
|
||||
margin: 0;
|
||||
border-radius: 999px;
|
||||
padding-inline: 12px;
|
||||
border-color: rgba(61, 124, 241, 0.14);
|
||||
color: var(--cbt-accent-strong);
|
||||
background: rgba(239, 246, 255, 0.95);
|
||||
}
|
||||
|
||||
.cbt-play-app__hero-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.cbt-play-app__metric {
|
||||
padding: 16px;
|
||||
border-radius: 20px;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(241, 247, 255, 0.94));
|
||||
border: 1px solid rgba(74, 105, 143, 0.1);
|
||||
}
|
||||
|
||||
.cbt-play-app__metric-value {
|
||||
display: block;
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
color: var(--cbt-ink);
|
||||
}
|
||||
|
||||
.cbt-play-app__metric-label {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
color: var(--cbt-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.cbt-play-app__option-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px 0 0;
|
||||
border-top: 1px solid rgba(74, 105, 143, 0.12);
|
||||
}
|
||||
|
||||
.cbt-play-app__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.cbt-play-app__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cbt-play-app__field--full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.cbt-play-app__field-label {
|
||||
color: var(--cbt-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.cbt-play-app__toolbar,
|
||||
.cbt-play-app__option-line,
|
||||
.cbt-play-app__summary-row,
|
||||
.cbt-play-app__history-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cbt-play-app__summary-row {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.cbt-play-app__solver-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cbt-play-app__subject-picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cbt-play-app__resume-banner {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 0 0 18px;
|
||||
margin-bottom: 18px;
|
||||
border-bottom: 1px solid rgba(74, 105, 143, 0.12);
|
||||
}
|
||||
|
||||
.cbt-play-app__resume-section {
|
||||
padding-bottom: 18px;
|
||||
margin-bottom: 18px;
|
||||
border-bottom: 1px solid rgba(74, 105, 143, 0.12);
|
||||
}
|
||||
|
||||
.cbt-play-app__resume-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.cbt-play-app__resume-card {
|
||||
padding: 14px 16px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(74, 105, 143, 0.12);
|
||||
background: rgba(247, 250, 255, 0.92);
|
||||
}
|
||||
|
||||
.cbt-play-app__resume-meta {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.cbt-play-app__subject-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cbt-play-app__subject-actions .ant-typography {
|
||||
margin: 0;
|
||||
word-break: keep-all;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.cbt-play-app__sheet,
|
||||
.cbt-play-app__question,
|
||||
.cbt-play-app__history-card,
|
||||
.cbt-play-app__wrong-card {
|
||||
padding: 18px 0;
|
||||
border-radius: 0;
|
||||
border: 0;
|
||||
border-top: 1px solid rgba(74, 105, 143, 0.12);
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.cbt-play-app__stack > :first-child.cbt-play-app__sheet,
|
||||
.cbt-play-app__stack > :first-child.cbt-play-app__panel,
|
||||
.cbt-play-app__stack > :first-child.cbt-play-app__question,
|
||||
.cbt-play-app__stack > :first-child.cbt-play-app__history-card,
|
||||
.cbt-play-app__stack > :first-child.cbt-play-app__wrong-card {
|
||||
border-top: 0;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.cbt-play-app__question {
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
.cbt-play-app__question-head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.cbt-play-app__question-body {
|
||||
margin-top: 18px;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
line-height: 1.55;
|
||||
color: var(--cbt-ink);
|
||||
word-break: keep-all;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.cbt-play-app__choice-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.cbt-play-app__choice-button {
|
||||
justify-content: flex-start;
|
||||
min-height: 64px;
|
||||
white-space: normal;
|
||||
text-align: left;
|
||||
border-radius: 20px;
|
||||
border-width: 1px;
|
||||
color: var(--cbt-ink);
|
||||
font-weight: 600;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.cbt-play-app__choice-button > span:last-child {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
word-break: keep-all;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.cbt-play-app__choice-button.ant-btn-default {
|
||||
border-color: rgba(74, 105, 143, 0.14);
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
}
|
||||
|
||||
.cbt-play-app__choice-button.ant-btn-primary {
|
||||
border-color: transparent;
|
||||
background: linear-gradient(135deg, #3d7cf1, #6aa5ff);
|
||||
}
|
||||
|
||||
.cbt-play-app__choice-prefix {
|
||||
display: inline-flex;
|
||||
width: 28px;
|
||||
color: var(--cbt-accent);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.cbt-play-app__meta-strip {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cbt-play-app__meta-strip .ant-tag {
|
||||
margin: 0;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.cbt-play-app__question-feedback {
|
||||
margin-top: 18px;
|
||||
padding: 16px;
|
||||
border-radius: 18px;
|
||||
background: rgba(242, 247, 255, 0.95);
|
||||
border: 1px solid rgba(61, 124, 241, 0.14);
|
||||
}
|
||||
|
||||
.cbt-play-app__subject-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.cbt-play-app__subject-stat {
|
||||
padding: 14px 0 10px;
|
||||
border-radius: 0;
|
||||
border: 0;
|
||||
border-bottom: 1px solid rgba(74, 105, 143, 0.1);
|
||||
background: transparent;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.cbt-play-app__subject-stat-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
align-items: baseline;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.cbt-play-app__subject-stat-head .ant-typography {
|
||||
min-width: 0;
|
||||
word-break: keep-all;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.cbt-play-app__subject-stat-rate {
|
||||
font-size: 26px;
|
||||
font-weight: 800;
|
||||
color: var(--cbt-accent-strong);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.cbt-play-app__subject-start {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.cbt-play-app__wrong-card,
|
||||
.cbt-play-app__history-card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.cbt-play-app__sticky-bar {
|
||||
position: sticky;
|
||||
bottom: 12px;
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
.cbt-play-app__sticky-inner {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border-radius: 24px;
|
||||
border: 1px solid rgba(74, 105, 143, 0.14);
|
||||
background: rgba(248, 251, 255, 0.98);
|
||||
backdrop-filter: blur(14px);
|
||||
box-shadow: 0 18px 40px rgba(46, 86, 138, 0.12);
|
||||
}
|
||||
|
||||
.cbt-play-app__sticky-inner .ant-btn {
|
||||
min-height: 46px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.cbt-play-app__empty {
|
||||
padding: 32px 16px;
|
||||
text-align: center;
|
||||
color: var(--cbt-muted);
|
||||
}
|
||||
|
||||
.cbt-play-app__empty--fill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.cbt-play-app .ant-tag,
|
||||
.cbt-play-app .ant-btn,
|
||||
.cbt-play-app .ant-segmented-item-label,
|
||||
.cbt-play-app .ant-typography,
|
||||
.cbt-play-app .ant-alert-message,
|
||||
.cbt-play-app .ant-alert-description {
|
||||
white-space: normal;
|
||||
word-break: keep-all;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.cbt-play-app__select {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cbt-play-app__select .ant-select-selector {
|
||||
min-height: 44px !important;
|
||||
padding: 6px 12px !important;
|
||||
align-items: center;
|
||||
border-radius: 16px !important;
|
||||
border-color: rgba(74, 105, 143, 0.16) !important;
|
||||
background: rgba(255, 255, 255, 0.96) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.cbt-play-app__select.ant-select-single {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.cbt-play-app__select.ant-select-single .ant-select-selector {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.cbt-play-app__select.ant-select-single .ant-select-selection-wrap,
|
||||
.cbt-play-app__select.ant-select-single .ant-select-selection-search,
|
||||
.cbt-play-app__select.ant-select-single .ant-select-selection-item,
|
||||
.cbt-play-app__select.ant-select-single .ant-select-selection-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cbt-play-app__select.ant-select-single .ant-select-selection-item,
|
||||
.cbt-play-app__select.ant-select-single .ant-select-selection-placeholder {
|
||||
min-width: 0;
|
||||
line-height: 1.35 !important;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.cbt-play-app__select.ant-select-multiple .ant-select-selector {
|
||||
padding-block: 7px !important;
|
||||
}
|
||||
|
||||
.cbt-play-app__select.ant-select-multiple .ant-select-selection-overflow {
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.cbt-play-app__select.ant-select-multiple .ant-select-selection-overflow-item {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.cbt-play-app__select.ant-select-multiple .ant-select-selection-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: 100%;
|
||||
border-radius: 999px !important;
|
||||
background: rgba(239, 246, 255, 0.95) !important;
|
||||
border: 1px solid rgba(61, 124, 241, 0.12) !important;
|
||||
color: var(--cbt-accent-strong) !important;
|
||||
}
|
||||
|
||||
.cbt-play-app__select.ant-select-multiple .ant-select-selection-item-content {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cbt-play-app__select.ant-select-multiple .ant-select-selection-placeholder {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.cbt-play-app .ant-btn-primary {
|
||||
background: linear-gradient(135deg, #3d7cf1, #6aa5ff);
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.cbt-play-app .ant-space,
|
||||
.cbt-play-app .ant-space-item {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cbt-play-app__stack--fill {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 920px) {
|
||||
.cbt-play-app__hero-grid,
|
||||
.cbt-play-app__grid,
|
||||
.cbt-play-app__subject-grid,
|
||||
.cbt-play-app__action-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.cbt-play-app {
|
||||
padding: 10px 10px calc(10px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.cbt-play-app--immersive {
|
||||
height: 100%;
|
||||
padding: 10px 10px calc(10px + env(safe-area-inset-bottom, 0px));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cbt-play-app__viewport-shell,
|
||||
.cbt-play-app__content-card {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.cbt-play-app__topbar {
|
||||
align-items: flex-start;
|
||||
padding: 8px;
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.cbt-play-app__topbar-group {
|
||||
overflow-x: auto;
|
||||
flex-wrap: nowrap;
|
||||
width: 100%;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.cbt-play-app__topbar-status {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cbt-play-app__content-card {
|
||||
padding: 12px;
|
||||
border-radius: 24px;
|
||||
}
|
||||
|
||||
.cbt-play-app--immersive .cbt-play-app__stack,
|
||||
.cbt-play-app__tab-card > .cbt-play-app__card-scroll > .cbt-play-app__stack {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.cbt-play-app__hero,
|
||||
.cbt-play-app__panel,
|
||||
.cbt-play-app__workspace,
|
||||
.cbt-play-app__sheet,
|
||||
.cbt-play-app__question,
|
||||
.cbt-play-app__history-card,
|
||||
.cbt-play-app__wrong-card {
|
||||
border-radius: 24px;
|
||||
}
|
||||
|
||||
.cbt-play-app__hero {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.cbt-play-app__workspace,
|
||||
.cbt-play-app__panel {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.cbt-play-app--immersive .cbt-play-app__workspace {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
padding: 12px;
|
||||
border-radius: 28px;
|
||||
}
|
||||
|
||||
.cbt-play-app--immersive .cbt-play-app__workspace-body,
|
||||
.cbt-play-app--immersive .cbt-play-app__workspace-body > .cbt-play-app__stack {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.cbt-play-app--immersive .cbt-play-app__workspace-body > .cbt-play-app__stack {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.cbt-play-app--immersive .cbt-play-app__solver-scroll {
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.cbt-play-app__sheet,
|
||||
.cbt-play-app__question,
|
||||
.cbt-play-app__history-card,
|
||||
.cbt-play-app__wrong-card {
|
||||
padding: 14px 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.cbt-play-app__question-body {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.cbt-play-app--immersive .cbt-play-app__panel {
|
||||
flex: 0 0 auto;
|
||||
padding: 12px 14px;
|
||||
border-radius: 22px;
|
||||
}
|
||||
|
||||
.cbt-play-app--immersive .cbt-play-app__question {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
border: 1px solid rgba(74, 105, 143, 0.1);
|
||||
border-radius: 22px;
|
||||
background: rgba(255, 255, 255, 0.99);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.cbt-play-app--immersive .cbt-play-app__question-head {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cbt-play-app--immersive .cbt-play-app__meta-strip {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cbt-play-app--immersive .cbt-play-app__question-body {
|
||||
margin-top: 0;
|
||||
font-size: 17px;
|
||||
line-height: 1.42;
|
||||
}
|
||||
|
||||
.cbt-play-app--immersive .cbt-play-app__choice-list {
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.cbt-play-app--immersive .cbt-play-app__choice-button {
|
||||
min-height: 52px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 16px;
|
||||
font-size: 14px;
|
||||
background: rgba(255, 255, 255, 1);
|
||||
}
|
||||
|
||||
.cbt-play-app--immersive .cbt-play-app__choice-prefix {
|
||||
width: 24px;
|
||||
}
|
||||
|
||||
.cbt-play-app--immersive .cbt-play-app__question-feedback {
|
||||
margin-top: 0;
|
||||
padding: 12px;
|
||||
border-radius: 16px;
|
||||
background: rgba(245, 249, 255, 1);
|
||||
}
|
||||
|
||||
.cbt-play-app--immersive .cbt-play-app__sticky-bar {
|
||||
flex: 0 0 auto;
|
||||
bottom: 0;
|
||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||
}
|
||||
|
||||
.cbt-play-app--immersive .cbt-play-app__sticky-inner {
|
||||
padding: 10px;
|
||||
border-radius: 20px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.cbt-play-app--immersive .cbt-play-app__sticky-inner .ant-btn {
|
||||
min-height: 42px;
|
||||
}
|
||||
|
||||
.cbt-play-app__summary-row,
|
||||
.cbt-play-app__subject-actions,
|
||||
.cbt-play-app__solver-actions {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.cbt-play-app__solver-actions {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.cbt-play-app__resume-banner {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.cbt-play-app__resume-banner .ant-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cbt-play-app__sticky-inner {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
}
|
||||
1475
src/views/play/apps/cbt/CbtPlayAppView.tsx
Normal file
1475
src/views/play/apps/cbt/CbtPlayAppView.tsx
Normal file
File diff suppressed because it is too large
Load Diff
377
src/views/play/apps/cbt/cbtBonusQuestionSeeds.ts
Normal file
377
src/views/play/apps/cbt/cbtBonusQuestionSeeds.ts
Normal file
@@ -0,0 +1,377 @@
|
||||
type BonusQuestionSeed = {
|
||||
id: string;
|
||||
examId?: string;
|
||||
subjectId: string;
|
||||
setId: string;
|
||||
body: string;
|
||||
answerValue: string;
|
||||
explanation: string;
|
||||
difficulty: 'easy' | 'medium' | 'hard';
|
||||
tags: string[];
|
||||
correctRate: number;
|
||||
choices: [string, string, string, string];
|
||||
sourceLabel?: string;
|
||||
};
|
||||
|
||||
export const CBT_BONUS_QUESTION_SEEDS: BonusQuestionSeed[] = [
|
||||
{
|
||||
id: 'algo-core-7',
|
||||
subjectId: 'algo',
|
||||
setId: 'algo-core',
|
||||
body: '요구사항 추적표(Traceability Matrix)를 유지하는 주된 목적은 무엇인가요?',
|
||||
answerValue: '2',
|
||||
explanation: '요구사항이 설계, 구현, 테스트까지 빠짐없이 연결됐는지 확인하려고 사용합니다.',
|
||||
difficulty: 'medium',
|
||||
tags: ['요구사항', '관리'],
|
||||
correctRate: 0.64,
|
||||
choices: ['배포 서버를 자동 증설하려고', '요구사항과 산출물의 연결 상태를 검증하려고', '소스 코드를 난독화하려고', '화면 시안을 압축하려고'],
|
||||
},
|
||||
{
|
||||
id: 'algo-core-8',
|
||||
subjectId: 'algo',
|
||||
setId: 'algo-core',
|
||||
body: '타당성 검토에서 기술적 타당성을 확인할 때 가장 먼저 보는 관점은 무엇인가요?',
|
||||
answerValue: '3',
|
||||
explanation: '현 기술 스택과 인력으로 요구 기능을 구현 가능한지 확인하는 것이 기술적 타당성의 핵심입니다.',
|
||||
difficulty: 'easy',
|
||||
tags: ['타당성', '분석'],
|
||||
correctRate: 0.71,
|
||||
choices: ['사무실 좌석 배치를 바꾸는 비용', '광고 문구의 완성도', '현재 기술과 인력으로 구현 가능한지 여부', '배경 이미지 해상도'],
|
||||
},
|
||||
{
|
||||
id: 'algo-pattern-7',
|
||||
subjectId: 'algo',
|
||||
setId: 'algo-pattern',
|
||||
body: '기존 인터페이스와 호환되지 않는 클래스를 현재 구조에 맞게 연결할 때 적절한 패턴은 무엇인가요?',
|
||||
answerValue: '1',
|
||||
explanation: 'Adapter 패턴은 기존 클래스를 원하는 인터페이스로 감싸 호환되게 만듭니다.',
|
||||
difficulty: 'medium',
|
||||
tags: ['패턴', '설계'],
|
||||
correctRate: 0.59,
|
||||
choices: ['Adapter', 'Iterator', 'Memento', 'Visitor'],
|
||||
},
|
||||
{
|
||||
id: 'algo-pattern-8',
|
||||
subjectId: 'algo',
|
||||
setId: 'algo-pattern',
|
||||
body: '부분-전체 계층 구조를 동일한 방식으로 다뤄야 할 때 적절한 패턴은 무엇인가요?',
|
||||
answerValue: '4',
|
||||
explanation: 'Composite 패턴은 개별 객체와 복합 객체를 같은 인터페이스로 다룰 수 있게 합니다.',
|
||||
difficulty: 'hard',
|
||||
tags: ['패턴', '구조'],
|
||||
correctRate: 0.47,
|
||||
choices: ['Proxy', 'State', 'Interpreter', 'Composite'],
|
||||
},
|
||||
{
|
||||
id: 'algo-quality-7',
|
||||
subjectId: 'algo',
|
||||
setId: 'algo-quality',
|
||||
body: '기능적 관련 요소가 하나의 모듈에 모여 있는 상태를 설명하는 응집도는 무엇인가요?',
|
||||
answerValue: '2',
|
||||
explanation: '기능적 응집도는 모듈이 하나의 명확한 기능만 수행하도록 묶인 가장 바람직한 형태입니다.',
|
||||
difficulty: 'medium',
|
||||
tags: ['품질', '응집도'],
|
||||
correctRate: 0.56,
|
||||
choices: ['우연적 응집도', '기능적 응집도', '시간적 응집도', '논리적 응집도'],
|
||||
},
|
||||
{
|
||||
id: 'algo-quality-8',
|
||||
subjectId: 'algo',
|
||||
setId: 'algo-quality',
|
||||
body: '가용성(Availability) 품질 속성을 수치로 확인할 때 가장 직접적인 지표는 무엇인가요?',
|
||||
answerValue: '3',
|
||||
explanation: '정상 서비스 시간 비율이나 SLA 충족률은 가용성을 대표하는 지표입니다.',
|
||||
difficulty: 'medium',
|
||||
tags: ['품질', '가용성'],
|
||||
correctRate: 0.63,
|
||||
choices: ['화면 전환 애니메이션 수', '개발자 수', '정상 서비스 시간 비율', 'ERD 컬럼 수'],
|
||||
},
|
||||
{
|
||||
id: 'dev-core-7',
|
||||
subjectId: 'dev',
|
||||
setId: 'dev-core',
|
||||
body: '나선형(Spiral) 모델의 핵심 특징으로 가장 적절한 것은 무엇인가요?',
|
||||
answerValue: '1',
|
||||
explanation: '반복 개발과 위험 분석을 함께 수행하는 것이 나선형 모델의 핵심입니다.',
|
||||
difficulty: 'medium',
|
||||
tags: ['프로세스', '개발방법론'],
|
||||
correctRate: 0.62,
|
||||
choices: ['반복 개발 단계마다 위험을 분석한다', '요구사항 변경을 절대 허용하지 않는다', '테스트를 배포 후에만 수행한다', '모든 산출물을 한 번에 작성한다'],
|
||||
},
|
||||
{
|
||||
id: 'dev-core-8',
|
||||
subjectId: 'dev',
|
||||
setId: 'dev-core',
|
||||
body: 'DevOps 도입의 직접적인 목표로 가장 적절한 것은 무엇인가요?',
|
||||
answerValue: '4',
|
||||
explanation: '개발과 운영 협업을 강화해 배포 속도와 안정성을 함께 높이는 것이 목적입니다.',
|
||||
difficulty: 'easy',
|
||||
tags: ['DevOps', '협업'],
|
||||
correctRate: 0.74,
|
||||
choices: ['운영팀을 없앤다', '테스트를 생략한다', '문서를 금지한다', '개발과 운영의 피드백 주기를 단축한다'],
|
||||
},
|
||||
{
|
||||
id: 'dev-test-7',
|
||||
subjectId: 'dev',
|
||||
setId: 'dev-test',
|
||||
body: '기존 기능 수정 후 주변 기능이 깨지지 않았는지 확인하는 테스트는 무엇인가요?',
|
||||
answerValue: '2',
|
||||
explanation: '회귀 테스트는 변경 이후 기존 기능의 정상 동작을 다시 확인하는 테스트입니다.',
|
||||
difficulty: 'easy',
|
||||
tags: ['테스트', '회귀'],
|
||||
correctRate: 0.77,
|
||||
choices: ['인수 테스트', '회귀 테스트', '알파 테스트', '베타 테스트'],
|
||||
},
|
||||
{
|
||||
id: 'dev-test-8',
|
||||
subjectId: 'dev',
|
||||
setId: 'dev-test',
|
||||
body: '입력 구간의 경계값에서 오류가 자주 발생한다는 점에 착안한 테스트 기법은 무엇인가요?',
|
||||
answerValue: '3',
|
||||
explanation: '경계값 분석은 최소값, 최대값, 바로 인접한 값에서 결함을 찾는 기법입니다.',
|
||||
difficulty: 'medium',
|
||||
tags: ['테스트', '기법'],
|
||||
correctRate: 0.68,
|
||||
choices: ['원인-결과 그래프', '오류 추정', '경계값 분석', '상태 전이 테스트'],
|
||||
},
|
||||
{
|
||||
id: 'dev-release-7',
|
||||
subjectId: 'dev',
|
||||
setId: 'dev-release',
|
||||
body: 'Blue-Green 배포의 장점으로 가장 적절한 것은 무엇인가요?',
|
||||
answerValue: '1',
|
||||
explanation: '신구 환경을 분리해 두고 전환하므로 롤백이 빠르고 서비스 중단을 줄일 수 있습니다.',
|
||||
difficulty: 'medium',
|
||||
tags: ['배포', '운영'],
|
||||
correctRate: 0.58,
|
||||
choices: ['문제 발생 시 빠르게 이전 환경으로 전환할 수 있다', '데이터베이스가 자동 정규화된다', '보안 취약점이 모두 사라진다', '테스트 코드가 필요 없어진다'],
|
||||
},
|
||||
{
|
||||
id: 'dev-release-8',
|
||||
subjectId: 'dev',
|
||||
setId: 'dev-release',
|
||||
body: '시맨틱 버저닝에서 `2.4.1`의 마지막 숫자가 증가하는 일반적인 경우는 무엇인가요?',
|
||||
answerValue: '4',
|
||||
explanation: '패치 버전은 하위 호환 가능한 버그 수정이 있을 때 증가합니다.',
|
||||
difficulty: 'easy',
|
||||
tags: ['배포', '버전관리'],
|
||||
correctRate: 0.72,
|
||||
choices: ['대규모 구조 개편이 있을 때', '하위 호환이 깨지는 변경일 때', '새로운 주요 기능 묶음을 추가할 때', '하위 호환 가능한 버그 수정일 때'],
|
||||
},
|
||||
{
|
||||
id: 'db-core-7',
|
||||
subjectId: 'db',
|
||||
setId: 'db-core',
|
||||
body: '제2정규형(2NF)을 만족시키기 위해 제거해야 하는 대표적 문제는 무엇인가요?',
|
||||
answerValue: '2',
|
||||
explanation: '부분 함수 종속을 제거해야 제2정규형을 만족합니다.',
|
||||
difficulty: 'medium',
|
||||
tags: ['정규화', '모델링'],
|
||||
correctRate: 0.57,
|
||||
choices: ['이행 함수 종속', '부분 함수 종속', '후보키 중복 정의', '색인 누락'],
|
||||
},
|
||||
{
|
||||
id: 'db-core-8',
|
||||
subjectId: 'db',
|
||||
setId: 'db-core',
|
||||
body: '약한 엔터티가 강한 엔터티의 기본키 일부를 상속받아 식별될 때 필요한 관계는 무엇인가요?',
|
||||
answerValue: '3',
|
||||
explanation: '식별 관계는 부모 엔터티의 키가 자식 엔터티 식별자에 포함되는 관계입니다.',
|
||||
difficulty: 'hard',
|
||||
tags: ['ERD', '모델링'],
|
||||
correctRate: 0.45,
|
||||
choices: ['비식별 관계', '순환 관계', '식별 관계', '자기 관계'],
|
||||
},
|
||||
{
|
||||
id: 'db-sql-7',
|
||||
subjectId: 'db',
|
||||
setId: 'db-sql',
|
||||
body: '집계 결과에 조건을 적용할 때 `WHERE` 대신 주로 사용하는 절은 무엇인가요?',
|
||||
answerValue: '4',
|
||||
explanation: '집계 이후의 그룹 조건은 HAVING 절에서 처리합니다.',
|
||||
difficulty: 'easy',
|
||||
tags: ['SQL', '집계'],
|
||||
correctRate: 0.79,
|
||||
choices: ['ORDER BY', 'LIMIT', 'GROUP SETS', 'HAVING'],
|
||||
},
|
||||
{
|
||||
id: 'db-sql-8',
|
||||
subjectId: 'db',
|
||||
setId: 'db-sql',
|
||||
body: '공통 키가 있는 행만 결과로 가져오려면 어떤 조인을 사용해야 하나요?',
|
||||
answerValue: '1',
|
||||
explanation: 'INNER JOIN은 양쪽 테이블에서 조건이 일치하는 행만 반환합니다.',
|
||||
difficulty: 'easy',
|
||||
tags: ['SQL', '조인'],
|
||||
correctRate: 0.81,
|
||||
choices: ['INNER JOIN', 'LEFT OUTER JOIN', 'CROSS JOIN', 'SELF JOIN'],
|
||||
},
|
||||
{
|
||||
id: 'db-ops-7',
|
||||
subjectId: 'db',
|
||||
setId: 'db-ops',
|
||||
body: '다른 트랜잭션이 아직 커밋하지 않은 값을 읽는 현상은 무엇인가요?',
|
||||
answerValue: '2',
|
||||
explanation: 'Dirty Read는 미커밋 데이터를 읽는 이상 현상입니다.',
|
||||
difficulty: 'medium',
|
||||
tags: ['트랜잭션', '격리수준'],
|
||||
correctRate: 0.61,
|
||||
choices: ['Phantom Read', 'Dirty Read', 'Deadlock', 'Lost Update'],
|
||||
},
|
||||
{
|
||||
id: 'db-ops-8',
|
||||
subjectId: 'db',
|
||||
setId: 'db-ops',
|
||||
body: '교착상태(Deadlock)를 줄이기 위한 방법으로 가장 적절한 것은 무엇인가요?',
|
||||
answerValue: '3',
|
||||
explanation: '잠금 순서를 일관되게 유지하면 교착상태 가능성을 낮출 수 있습니다.',
|
||||
difficulty: 'medium',
|
||||
tags: ['운영', '동시성'],
|
||||
correctRate: 0.55,
|
||||
choices: ['모든 인덱스를 삭제한다', '트랜잭션 시간을 무조건 늘린다', '잠금 획득 순서를 일관되게 맞춘다', '커밋을 금지한다'],
|
||||
},
|
||||
{
|
||||
id: 'programming-core-7',
|
||||
subjectId: 'programming',
|
||||
setId: 'programming-core',
|
||||
body: '재귀 함수가 무한 호출되지 않도록 반드시 갖춰야 하는 요소는 무엇인가요?',
|
||||
answerValue: '1',
|
||||
explanation: '재귀 종료 조건(base case)이 있어야 반복 호출이 멈춥니다.',
|
||||
difficulty: 'easy',
|
||||
tags: ['기초', '재귀'],
|
||||
correctRate: 0.82,
|
||||
choices: ['종료 조건', '전역 변수', '배열 정렬', 'GUI 이벤트'],
|
||||
},
|
||||
{
|
||||
id: 'programming-core-8',
|
||||
subjectId: 'programming',
|
||||
setId: 'programming-core',
|
||||
body: '값에 의한 호출(Call by Value)의 특징으로 가장 적절한 것은 무엇인가요?',
|
||||
answerValue: '4',
|
||||
explanation: '매개변수에 값의 복사본이 전달되므로 원본 값은 직접 바뀌지 않습니다.',
|
||||
difficulty: 'medium',
|
||||
tags: ['기초', '함수'],
|
||||
correctRate: 0.66,
|
||||
choices: ['항상 원본 객체가 즉시 수정된다', '메모리가 전혀 사용되지 않는다', '참조형을 사용할 수 없다', '원본 대신 복사본을 전달한다'],
|
||||
},
|
||||
{
|
||||
id: 'programming-oo-7',
|
||||
subjectId: 'programming',
|
||||
setId: 'programming-oo',
|
||||
body: '같은 메시지 호출에 대해 객체마다 다른 동작을 수행하게 하는 객체지향 특성은 무엇인가요?',
|
||||
answerValue: '2',
|
||||
explanation: '다형성은 동일한 인터페이스에 대해 서로 다른 구현이 반응하도록 합니다.',
|
||||
difficulty: 'medium',
|
||||
tags: ['객체지향', '다형성'],
|
||||
correctRate: 0.63,
|
||||
choices: ['상속', '다형성', '직렬화', '토큰화'],
|
||||
},
|
||||
{
|
||||
id: 'programming-oo-8',
|
||||
subjectId: 'programming',
|
||||
setId: 'programming-oo',
|
||||
body: '데이터와 메서드를 하나의 단위로 묶고 외부 접근을 제한하는 개념은 무엇인가요?',
|
||||
answerValue: '3',
|
||||
explanation: '캡슐화는 내부 구현을 숨기고 필요한 인터페이스만 노출합니다.',
|
||||
difficulty: 'easy',
|
||||
tags: ['객체지향', '캡슐화'],
|
||||
correctRate: 0.78,
|
||||
choices: ['오버로딩', '추상화', '캡슐화', '가비지 컬렉션'],
|
||||
},
|
||||
{
|
||||
id: 'programming-script-7',
|
||||
subjectId: 'programming',
|
||||
setId: 'programming-script',
|
||||
body: '선입선출(FIFO) 구조로 동작하는 자료구조는 무엇인가요?',
|
||||
answerValue: '2',
|
||||
explanation: 'Queue는 먼저 들어온 데이터가 먼저 나가는 FIFO 구조입니다.',
|
||||
difficulty: 'easy',
|
||||
tags: ['자료구조', '큐'],
|
||||
correctRate: 0.8,
|
||||
choices: ['Stack', 'Queue', 'Tree', 'Graph'],
|
||||
},
|
||||
{
|
||||
id: 'programming-script-8',
|
||||
subjectId: 'programming',
|
||||
setId: 'programming-script',
|
||||
body: '해시 테이블에서 서로 다른 키가 같은 해시 값을 갖는 현상을 무엇이라고 하나요?',
|
||||
answerValue: '1',
|
||||
explanation: 'Collision은 해시 함수 결과가 겹치는 현상이며 체이닝, 개방 주소법 등으로 대응합니다.',
|
||||
difficulty: 'medium',
|
||||
tags: ['자료구조', '해시'],
|
||||
correctRate: 0.6,
|
||||
choices: ['충돌(Collision)', '정렬(Sort)', '병합(Merge)', '순회(Traversal)'],
|
||||
},
|
||||
{
|
||||
id: 'system-core-7',
|
||||
subjectId: 'system',
|
||||
setId: 'system-core',
|
||||
body: '변경관리(Change Management)의 핵심 목적은 무엇인가요?',
|
||||
answerValue: '4',
|
||||
explanation: '변경 요청을 통제해 서비스 영향과 위험을 줄이려는 것이 핵심입니다.',
|
||||
difficulty: 'easy',
|
||||
tags: ['운영', '변경관리'],
|
||||
correctRate: 0.73,
|
||||
choices: ['문서 작성을 금지한다', '모든 변경을 즉시 반영한다', '개발 서버만 유지한다', '변경 영향과 승인 절차를 관리한다'],
|
||||
},
|
||||
{
|
||||
id: 'system-core-8',
|
||||
subjectId: 'system',
|
||||
setId: 'system-core',
|
||||
body: '재해복구 계획에서 RTO가 의미하는 것은 무엇인가요?',
|
||||
answerValue: '2',
|
||||
explanation: 'RTO는 장애 후 서비스를 복구해야 하는 목표 시간입니다.',
|
||||
difficulty: 'medium',
|
||||
tags: ['DR', '복구'],
|
||||
correctRate: 0.58,
|
||||
choices: ['허용 가능한 데이터 손실량', '목표 복구 시간', '백업 파일 크기', '암호화 키 길이'],
|
||||
},
|
||||
{
|
||||
id: 'system-security-7',
|
||||
subjectId: 'system',
|
||||
setId: 'system-security',
|
||||
body: '최소 권한 원칙(Principle of Least Privilege)의 설명으로 가장 적절한 것은 무엇인가요?',
|
||||
answerValue: '1',
|
||||
explanation: '업무 수행에 필요한 최소한의 권한만 부여해야 보안 위험을 줄일 수 있습니다.',
|
||||
difficulty: 'easy',
|
||||
tags: ['보안', '권한'],
|
||||
correctRate: 0.76,
|
||||
choices: ['필요한 최소 권한만 부여한다', '관리자 권한을 기본값으로 준다', '모든 로그를 삭제한다', '암호를 화면에 표시한다'],
|
||||
},
|
||||
{
|
||||
id: 'system-security-8',
|
||||
subjectId: 'system',
|
||||
setId: 'system-security',
|
||||
body: '비밀번호 해시 저장 시 솔트(salt)를 함께 사용하는 주된 이유는 무엇인가요?',
|
||||
answerValue: '3',
|
||||
explanation: '같은 비밀번호라도 서로 다른 해시가 되게 해 사전 계산 공격을 어렵게 만듭니다.',
|
||||
difficulty: 'medium',
|
||||
tags: ['보안', '인증'],
|
||||
correctRate: 0.62,
|
||||
choices: ['패스워드를 평문으로 복구하려고', '로그인을 생략하려고', '레인보우 테이블 공격을 어렵게 하려고', '세션 쿠키를 없애려고'],
|
||||
},
|
||||
{
|
||||
id: 'system-infra-7',
|
||||
subjectId: 'system',
|
||||
setId: 'system-infra',
|
||||
body: '로드밸런서의 헬스 체크(Health Check)를 두는 가장 직접적인 이유는 무엇인가요?',
|
||||
answerValue: '2',
|
||||
explanation: '정상 응답하지 않는 인스턴스를 트래픽 분산 대상에서 제외하기 위해 사용합니다.',
|
||||
difficulty: 'medium',
|
||||
tags: ['인프라', '로드밸런서'],
|
||||
correctRate: 0.64,
|
||||
choices: ['CPU 모델명을 확인하려고', '비정상 서버로 트래픽이 가지 않게 하려고', '소스 코드를 압축하려고', '브라우저 캐시를 비우려고'],
|
||||
},
|
||||
{
|
||||
id: 'system-infra-8',
|
||||
subjectId: 'system',
|
||||
setId: 'system-infra',
|
||||
body: '카나리 배포(Canary Release)의 장점으로 가장 적절한 것은 무엇인가요?',
|
||||
answerValue: '4',
|
||||
explanation: '일부 사용자에게만 먼저 배포해 위험을 제한한 채 반응을 확인할 수 있습니다.',
|
||||
difficulty: 'medium',
|
||||
tags: ['배포', '인프라'],
|
||||
correctRate: 0.57,
|
||||
choices: ['항상 전체 서버를 동시에 교체한다', '데이터베이스 스키마를 자동 복구한다', '장애를 완전히 없앤다', '소수 트래픽으로 먼저 검증 후 점진 확장할 수 있다'],
|
||||
},
|
||||
];
|
||||
1103
src/views/play/apps/cbt/cbtData.ts
Normal file
1103
src/views/play/apps/cbt/cbtData.ts
Normal file
File diff suppressed because it is too large
Load Diff
146
src/views/play/apps/cbt/cbtStorage.ts
Normal file
146
src/views/play/apps/cbt/cbtStorage.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import type { BookmarkItem, CbtSessionRecord, CbtStorageState, ProgressStat, QuestionRecord, WrongNoteItem } from './cbtTypes';
|
||||
|
||||
const STORAGE_KEY = 'play-cbt-storage-v1';
|
||||
|
||||
function getDefaultState(): CbtStorageState {
|
||||
return {
|
||||
sessions: [],
|
||||
wrongNotes: [],
|
||||
bookmarks: [],
|
||||
progressStats: [],
|
||||
activeSessionId: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function loadCbtStorageState(): CbtStorageState {
|
||||
if (typeof window === 'undefined') {
|
||||
return getDefaultState();
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
|
||||
if (!raw) {
|
||||
return getDefaultState();
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw) as Partial<CbtStorageState>;
|
||||
|
||||
return {
|
||||
sessions: Array.isArray(parsed.sessions) ? parsed.sessions : [],
|
||||
wrongNotes: Array.isArray(parsed.wrongNotes) ? parsed.wrongNotes : [],
|
||||
bookmarks: Array.isArray(parsed.bookmarks) ? parsed.bookmarks : [],
|
||||
progressStats: Array.isArray(parsed.progressStats) ? parsed.progressStats : [],
|
||||
activeSessionId: typeof parsed.activeSessionId === 'string' ? parsed.activeSessionId : null,
|
||||
};
|
||||
} catch {
|
||||
return getDefaultState();
|
||||
}
|
||||
}
|
||||
|
||||
export function saveCbtStorageState(state: CbtStorageState) {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||
}
|
||||
|
||||
export function upsertSession(sessions: CbtSessionRecord[], session: CbtSessionRecord) {
|
||||
const nextSessions = sessions.filter((item) => item.id !== session.id);
|
||||
nextSessions.unshift(session);
|
||||
return nextSessions.slice(0, 40);
|
||||
}
|
||||
|
||||
export function mergeWrongNotes(
|
||||
previous: WrongNoteItem[],
|
||||
session: CbtSessionRecord,
|
||||
questionMap: Map<string, QuestionRecord>,
|
||||
) {
|
||||
const next = new Map(previous.map((item) => [item.questionId, item]));
|
||||
|
||||
session.answers.forEach((answer) => {
|
||||
const question = questionMap.get(answer.questionId);
|
||||
|
||||
if (!question || !answer.selectedValue || answer.selectedValue === question.answerValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
const current = next.get(answer.questionId);
|
||||
next.set(answer.questionId, {
|
||||
questionId: answer.questionId,
|
||||
createdAt: current?.createdAt ?? session.submittedAt ?? session.startedAt,
|
||||
subjectId: question.subjectId,
|
||||
examId: question.examId,
|
||||
wrongCount: (current?.wrongCount ?? 0) + 1,
|
||||
lastSessionId: session.id,
|
||||
});
|
||||
});
|
||||
|
||||
return Array.from(next.values()).sort((left, right) => right.createdAt.localeCompare(left.createdAt));
|
||||
}
|
||||
|
||||
export function mergeBookmarks(previous: BookmarkItem[], session: CbtSessionRecord) {
|
||||
const next = new Map(previous.map((item) => [item.questionId, item]));
|
||||
|
||||
session.answers.forEach((answer) => {
|
||||
const current = next.get(answer.questionId);
|
||||
|
||||
if (answer.isBookmarked) {
|
||||
const timestamp = answer.answeredAt ?? session.submittedAt ?? session.startedAt;
|
||||
next.set(answer.questionId, {
|
||||
questionId: answer.questionId,
|
||||
createdAt: current?.createdAt ?? timestamp,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (current) {
|
||||
next.delete(answer.questionId);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(next.values()).sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
|
||||
}
|
||||
|
||||
export function buildProgressStats(sessions: CbtSessionRecord[], questionMap: Map<string, QuestionRecord>): ProgressStat[] {
|
||||
const subjectMap = new Map<string, ProgressStat>();
|
||||
|
||||
sessions
|
||||
.filter((session) => session.submittedAt)
|
||||
.forEach((session) => {
|
||||
session.answers.forEach((answer) => {
|
||||
if (!answer.selectedValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
const question = questionMap.get(answer.questionId);
|
||||
|
||||
if (!question) {
|
||||
return;
|
||||
}
|
||||
|
||||
const current = subjectMap.get(question.subjectId) ?? {
|
||||
subjectId: question.subjectId,
|
||||
solvedCount: 0,
|
||||
correctCount: 0,
|
||||
wrongCount: 0,
|
||||
accuracyRate: 0,
|
||||
};
|
||||
|
||||
current.solvedCount += 1;
|
||||
|
||||
if (answer.selectedValue === question.answerValue) {
|
||||
current.correctCount += 1;
|
||||
} else {
|
||||
current.wrongCount += 1;
|
||||
}
|
||||
|
||||
current.accuracyRate = current.solvedCount > 0 ? Math.round((current.correctCount / current.solvedCount) * 100) : 0;
|
||||
subjectMap.set(question.subjectId, current);
|
||||
});
|
||||
});
|
||||
|
||||
return Array.from(subjectMap.values()).sort((left, right) => right.solvedCount - left.solvedCount);
|
||||
}
|
||||
100
src/views/play/apps/cbt/cbtTypes.ts
Normal file
100
src/views/play/apps/cbt/cbtTypes.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
export type CbtMode = 'sequential' | 'random' | 'wrong-only' | 'bookmarks-only';
|
||||
|
||||
export type ExamCategory = {
|
||||
id: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type Subject = {
|
||||
id: string;
|
||||
examId: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type QuestionSet = {
|
||||
id: string;
|
||||
examId: string;
|
||||
subjectId: string;
|
||||
label: string;
|
||||
sourceLabel: string;
|
||||
};
|
||||
|
||||
export type QuestionChoice = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type QuestionRecord = {
|
||||
id: string;
|
||||
examId: string;
|
||||
subjectId: string;
|
||||
setId: string;
|
||||
type: 'multiple-choice';
|
||||
body: string;
|
||||
answerValue: string;
|
||||
explanation: string;
|
||||
difficulty: 'easy' | 'medium' | 'hard';
|
||||
sourceLabel: string;
|
||||
tags: string[];
|
||||
correctRate: number;
|
||||
choices: QuestionChoice[];
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
export type SessionAnswer = {
|
||||
questionId: string;
|
||||
selectedValue: string | null;
|
||||
isCorrect?: boolean;
|
||||
isMarked: boolean;
|
||||
isBookmarked: boolean;
|
||||
answeredAt: string | null;
|
||||
};
|
||||
|
||||
export type CbtSessionRecord = {
|
||||
id: string;
|
||||
examId: string;
|
||||
subjectIds: string[];
|
||||
questionSetId: string | null;
|
||||
mode: CbtMode;
|
||||
questionIds: string[];
|
||||
questionCount: number;
|
||||
startedAt: string;
|
||||
submittedAt: string | null;
|
||||
score: number | null;
|
||||
correctCount: number;
|
||||
wrongCount: number;
|
||||
currentQuestionIndex: number;
|
||||
revealedExplanationQuestionIds: string[];
|
||||
answers: SessionAnswer[];
|
||||
};
|
||||
|
||||
export type WrongNoteItem = {
|
||||
questionId: string;
|
||||
createdAt: string;
|
||||
subjectId: string;
|
||||
examId: string;
|
||||
wrongCount: number;
|
||||
lastSessionId: string;
|
||||
};
|
||||
|
||||
export type BookmarkItem = {
|
||||
questionId: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type ProgressStat = {
|
||||
subjectId: string;
|
||||
solvedCount: number;
|
||||
correctCount: number;
|
||||
wrongCount: number;
|
||||
accuracyRate: number;
|
||||
};
|
||||
|
||||
export type CbtStorageState = {
|
||||
sessions: CbtSessionRecord[];
|
||||
wrongNotes: WrongNoteItem[];
|
||||
bookmarks: BookmarkItem[];
|
||||
progressStats: ProgressStat[];
|
||||
activeSessionId: string | null;
|
||||
};
|
||||
89
src/views/play/apps/test/TestPlayAppView.css
Normal file
89
src/views/play/apps/test/TestPlayAppView.css
Normal file
@@ -0,0 +1,89 @@
|
||||
.test-play-app {
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding: 32px;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(255, 211, 105, 0.24), transparent 28%),
|
||||
linear-gradient(160deg, #f4efe2 0%, #fcfaf4 48%, #eef3f8 100%);
|
||||
}
|
||||
|
||||
.test-play-app__hero {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.4fr) minmax(280px, 0.9fr);
|
||||
gap: 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.test-play-app__hero-copy,
|
||||
.test-play-app__spotlight,
|
||||
.test-play-app__feature-card {
|
||||
border-radius: 28px;
|
||||
border: 1px solid rgba(34, 49, 63, 0.08);
|
||||
box-shadow: 0 22px 60px rgba(63, 79, 92, 0.08);
|
||||
}
|
||||
|
||||
.test-play-app__hero-copy {
|
||||
padding: 32px;
|
||||
background: rgba(255, 252, 244, 0.82);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.test-play-app__hero-copy .ant-typography h2 {
|
||||
margin-top: 14px;
|
||||
margin-bottom: 14px;
|
||||
font-size: clamp(2rem, 3vw, 3.2rem);
|
||||
line-height: 1.04;
|
||||
}
|
||||
|
||||
.test-play-app__hero-copy .ant-typography {
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
.test-play-app__spotlight {
|
||||
background: linear-gradient(180deg, #1e2b31 0%, #263b45 100%);
|
||||
}
|
||||
|
||||
.test-play-app__spotlight .ant-card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-height: 100%;
|
||||
color: #f5f6f8;
|
||||
}
|
||||
|
||||
.test-play-app__spotlight .ant-typography,
|
||||
.test-play-app__spotlight .ant-typography-copy,
|
||||
.test-play-app__spotlight .ant-typography-secondary {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.test-play-app__feature-card {
|
||||
height: 100%;
|
||||
background: rgba(255, 255, 255, 0.84);
|
||||
}
|
||||
|
||||
.test-play-app__feature-label {
|
||||
display: inline-block;
|
||||
margin-bottom: 8px;
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: #7d5b1f;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.test-play-app {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.test-play-app__hero {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.test-play-app__hero-copy {
|
||||
padding: 24px;
|
||||
}
|
||||
}
|
||||
60
src/views/play/apps/test/TestPlayAppView.tsx
Normal file
60
src/views/play/apps/test/TestPlayAppView.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Button, Card, Col, Input, Row, Space, Tag, Typography } from 'antd';
|
||||
import './TestPlayAppView.css';
|
||||
|
||||
const { Paragraph, Text, Title } = Typography;
|
||||
|
||||
const featureCards = [
|
||||
{
|
||||
title: 'Isolated Entry',
|
||||
description: '기존 Layout Editor 상태와 분리된 전용 play 앱 진입점입니다.',
|
||||
},
|
||||
{
|
||||
title: 'Scoped Styling',
|
||||
description: '이 화면은 `TestPlayAppView.css`만 사용하도록 분리해 독립 스타일 실험이 가능합니다.',
|
||||
},
|
||||
{
|
||||
title: 'Next Extension',
|
||||
description: '이후 라우트, 상태, API 연결을 현재 앱과 분리된 구조로 계속 확장할 수 있습니다.',
|
||||
},
|
||||
];
|
||||
|
||||
export function TestPlayAppView() {
|
||||
return (
|
||||
<div className="test-play-app">
|
||||
<section className="test-play-app__hero">
|
||||
<div className="test-play-app__hero-copy">
|
||||
<Tag color="gold">Apps / Test</Tag>
|
||||
<Title level={2}>분리된 test 앱 작업 공간</Title>
|
||||
<Paragraph>
|
||||
Play 사이드바의 <Text strong>Apps</Text> 카테고리에서 진입하는 전용 앱 뷰입니다. 기존 layout editor와
|
||||
경로, 렌더링, 스타일 파일을 분리해 새 앱 형태를 바로 실험할 수 있게 구성했습니다.
|
||||
</Paragraph>
|
||||
<Space wrap>
|
||||
<Button type="primary">Primary Action</Button>
|
||||
<Button ghost>Secondary</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Card className="test-play-app__spotlight" bordered={false}>
|
||||
<Text type="secondary">test app shell</Text>
|
||||
<Title level={4}>별도 index CSS 대신 전용 뷰 CSS 분리</Title>
|
||||
<Paragraph>
|
||||
React 엔트리는 공유하되, 실제 화면 스타일은 이 앱 전용 CSS로 한정해 기존 앱 전역 규칙과 충돌을 줄였습니다.
|
||||
</Paragraph>
|
||||
<Input placeholder="다음 단계에서 앱 전용 입력/상태를 붙일 수 있습니다." />
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<Row gutter={[20, 20]}>
|
||||
{featureCards.map((card) => (
|
||||
<Col key={card.title} xs={24} md={8}>
|
||||
<Card className="test-play-app__feature-card" bordered={false}>
|
||||
<Text className="test-play-app__feature-label">{card.title}</Text>
|
||||
<Paragraph>{card.description}</Paragraph>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { LAYOUT_EDITOR_CHAT_TYPE_ID } from '../../app/main/chatTypeDefaults';
|
||||
import { LAYOUT_EDITOR_CHAT_TYPE_ID, LAYOUT_EDITOR_GUIDED_CHAT_TYPE_ID } from '../../app/main/chatTypeDefaults';
|
||||
|
||||
type LayoutCodexChatType = {
|
||||
id: string;
|
||||
@@ -6,7 +6,12 @@ type LayoutCodexChatType = {
|
||||
description: string;
|
||||
};
|
||||
|
||||
const PRIORITIZED_CHAT_TYPE_IDS = [LAYOUT_EDITOR_CHAT_TYPE_ID, 'general-request', 'api-request-template'] as const;
|
||||
const PRIORITIZED_CHAT_TYPE_IDS = [
|
||||
LAYOUT_EDITOR_GUIDED_CHAT_TYPE_ID,
|
||||
LAYOUT_EDITOR_CHAT_TYPE_ID,
|
||||
'general-request',
|
||||
'api-request-template',
|
||||
] as const;
|
||||
|
||||
export function resolvePreferredLayoutCodexChatType(chatTypes: LayoutCodexChatType[]) {
|
||||
for (const id of PRIORITIZED_CHAT_TYPE_IDS) {
|
||||
|
||||
@@ -11,7 +11,7 @@ type LayoutLeafNode = {
|
||||
componentBinding: LayoutComponentBinding | null;
|
||||
};
|
||||
|
||||
export type LayoutPreviewBindingKind = 'base-input' | 'select-input' | 'text-memo-widget' | 'sample';
|
||||
export type LayoutPreviewBindingKind = 'action-button' | 'base-input' | 'select-input' | 'text-memo-widget' | 'sample';
|
||||
|
||||
export type LayoutPreviewMemoNote = {
|
||||
id: string;
|
||||
@@ -109,6 +109,13 @@ export function resolveLayoutPreviewBindingKind(binding: LayoutComponentBinding
|
||||
return 'sample';
|
||||
}
|
||||
|
||||
if (
|
||||
binding.optionId.includes('button-editable-input') ||
|
||||
binding.label === 'Button Editable Input'
|
||||
) {
|
||||
return 'action-button';
|
||||
}
|
||||
|
||||
if (
|
||||
binding.optionId === 'component:input:deferred-input' ||
|
||||
binding.optionId === 'component:input:input-base' ||
|
||||
|
||||
Reference in New Issue
Block a user