fix: unify comprehensive training layout

bugfix/ai-assessment-evidence-rescore
chenyuan 1 month ago
parent 51f89898c6
commit 606c58236f

@ -164,7 +164,7 @@ export const dynamicRoutes = [
path: "index", path: "index",
component: () => import("@/views/training/GenericTrainingPage.vue"), component: () => import("@/views/training/GenericTrainingPage.vue"),
name: "ComprehensiveCaseTraining", name: "ComprehensiveCaseTraining",
meta: { title: "综合实训", icon: "Finished", affix: true, pageKey: "comprehensive-case-training" }, meta: { title: "综合实训", icon: "Finished", affix: true, pageKey: "comprehensive-case-training", hideSupplementSteps: true },
}, },
], ],
}, },

@ -18,42 +18,37 @@
class="comprehensive-training-toolbar" class="comprehensive-training-toolbar"
:material-name="page.material?.name" :material-name="page.material?.name"
:material-url="page.material?.url" :material-url="page.material?.url"
:task-key="pageKey"
:on-submit="submitTask" :on-submit="submitTask"
:on-reset="resetTraining" :on-reset="resetTraining"
:on-export="exportOutcome" :on-export="exportOutcome"
/> />
</div> </div>
<nav v-if="isComprehensiveTurnNavigation" class="comprehensive-turn-nav" aria-label="实训步骤"> <nav
<div class="comprehensive-turn-row comprehensive-turn-row--forward"> v-if="isComprehensiveCaseTraining && indexedConfiguredSteps.length"
<template v-for="item in comprehensiveTurnForwardSteps" :key="`${item.step}-${item.index}`"> ref="comprehensiveStepNavRef"
<button :class="['comprehensive-step-nav', { 'is-dragging': comprehensiveNavDragging }]"
type="button" aria-label="综合实训步骤,可左右拖动浏览"
:class="['step-tab', { active: currentStep === item.index + 1, complete: currentStep > item.index + 1 }]" @dragstart.prevent
:aria-label="`切换至第 ${item.index + 1} 步:${item.step}`" @pointerdown="startComprehensiveNavDrag"
:aria-current="currentStep === item.index + 1 ? 'step' : undefined" @pointermove="moveComprehensiveNavDrag"
@click="currentStep = item.index + 1" @pointerup="endComprehensiveNavDrag"
@pointercancel="endComprehensiveNavDrag"
> >
<span class="step-marker" aria-hidden="true">{{ String(item.index + 1).padStart(2, '0') }}</span> <div class="comprehensive-step-strip">
<span class="step-copy"><strong class="step-name" :title="item.step">{{ item.step }}</strong><span class="step-action">{{ currentStep === item.index + 1 ? '当前步骤' : '点击切换' }} <span aria-hidden="true">→</span></span></span> <template v-for="item in indexedConfiguredSteps" :key="`${item.step}-${item.index}`">
</button>
<span v-if="item.index < comprehensiveTurnForwardSteps[comprehensiveTurnForwardSteps.length - 1]?.index" class="step-connector" aria-hidden="true"></span>
</template>
</div>
<span class="comprehensive-turn-rail" aria-hidden="true"></span>
<div class="comprehensive-turn-row comprehensive-turn-row--return">
<template v-for="item in comprehensiveTurnReturnSteps" :key="`${item.step}-${item.index}`">
<button <button
type="button" type="button"
:class="['step-tab', { active: currentStep === item.index + 1, complete: currentStep > item.index + 1 }]" :class="['step-tab', { active: currentStep === item.index + 1, complete: currentStep > item.index + 1 }]"
:aria-label="`切换至第 ${item.index + 1} 步:${item.step}`" :aria-label="`切换至第 ${item.index + 1} 步:${item.step}`"
:aria-current="currentStep === item.index + 1 ? 'step' : undefined" :aria-current="currentStep === item.index + 1 ? 'step' : undefined"
@click="currentStep = item.index + 1" @click="selectComprehensiveStep(item.index + 1)"
> >
<span class="step-marker" aria-hidden="true">{{ String(item.index + 1).padStart(2, '0') }}</span> <span class="step-marker" aria-hidden="true">{{ String(item.index + 1).padStart(2, '0') }}</span>
<span class="step-copy"><strong class="step-name" :title="item.step">{{ item.step }}</strong><span class="step-action">{{ currentStep === item.index + 1 ? '当前步骤' : '点击切换' }} <span aria-hidden="true">←</span></span></span> <span class="step-copy"><strong class="step-name" :title="item.step">{{ item.step }}</strong><span class="step-action">{{ currentStep === item.index + 1 ? '当前步骤' : '点击切换' }} <span aria-hidden="true">→</span></span></span>
</button> </button>
<span v-if="item.index > comprehensiveTurnReturnSteps[comprehensiveTurnReturnSteps.length - 1]?.index" class="step-connector step-connector--reverse" aria-hidden="true"></span> <span v-if="item.index < indexedConfiguredSteps.length - 1" class="step-connector" aria-hidden="true"></span>
</template> </template>
</div> </div>
</nav> </nav>
@ -747,10 +742,14 @@ const uploading = ref(false);
const currentStep = ref(1); const currentStep = ref(1);
const configuredSteps = computed(() => parseArray(remoteTask.value?.steps)); const configuredSteps = computed(() => parseArray(remoteTask.value?.steps));
const indexedConfiguredSteps = computed(() => configuredSteps.value.map((step, index) => ({ step, index }))); const indexedConfiguredSteps = computed(() => configuredSteps.value.map((step, index) => ({ step, index })));
const isComprehensiveTurnNavigation = computed(() => isComprehensiveCaseTraining.value && indexedConfiguredSteps.value.length > 7); const comprehensiveStepNavRef = ref(null);
// 综合实训专用折返步骤条:上行保留更宽的文字空间,下行承载剩余四步。 const comprehensiveNavDragging = ref(false);
const comprehensiveTurnForwardSteps = computed(() => indexedConfiguredSteps.value.slice(0, 6)); const comprehensiveNavDrag = {
const comprehensiveTurnReturnSteps = computed(() => indexedConfiguredSteps.value.slice(6).reverse()); pointerId: null,
startX: 0,
startScrollLeft: 0,
moved: false,
};
const activeStepName = computed(() => configuredSteps.value[currentStep.value - 1] || configuredSteps.value[0] || ""); const activeStepName = computed(() => configuredSteps.value[currentStep.value - 1] || configuredSteps.value[0] || "");
const visibleProcessRows = computed(() => { const visibleProcessRows = computed(() => {
if (!configuredSteps.value.length || !processRows.value.length) { if (!configuredSteps.value.length || !processRows.value.length) {
@ -793,6 +792,48 @@ watch(configuredSteps, (steps) => {
currentStep.value = Math.min(Math.max(currentStep.value, 1), steps.length); currentStep.value = Math.min(Math.max(currentStep.value, 1), steps.length);
}); });
function startComprehensiveNavDrag(event) {
if (event.pointerType === "mouse" && event.button !== 0) return;
const nav = comprehensiveStepNavRef.value;
if (!nav) return;
comprehensiveNavDrag.pointerId = event.pointerId;
comprehensiveNavDrag.startX = event.clientX;
comprehensiveNavDrag.startScrollLeft = nav.scrollLeft;
comprehensiveNavDrag.moved = false;
comprehensiveNavDragging.value = true;
nav.setPointerCapture?.(event.pointerId);
}
function moveComprehensiveNavDrag(event) {
if (!comprehensiveNavDragging.value || event.pointerId !== comprehensiveNavDrag.pointerId) return;
const nav = comprehensiveStepNavRef.value;
if (!nav) return;
const distance = event.clientX - comprehensiveNavDrag.startX;
if (Math.abs(distance) > 4) {
comprehensiveNavDrag.moved = true;
event.preventDefault();
}
nav.scrollLeft = comprehensiveNavDrag.startScrollLeft - distance;
}
function endComprehensiveNavDrag(event) {
if (event.pointerId !== comprehensiveNavDrag.pointerId) return;
const nav = comprehensiveStepNavRef.value;
if (nav?.hasPointerCapture?.(event.pointerId)) {
nav.releasePointerCapture(event.pointerId);
}
comprehensiveNavDragging.value = false;
comprehensiveNavDrag.pointerId = null;
}
function selectComprehensiveStep(step) {
if (comprehensiveNavDrag.moved) {
comprehensiveNavDrag.moved = false;
return;
}
currentStep.value = step;
}
async function loadRemoteTask() { async function loadRemoteTask() {
remoteTask.value = null; remoteTask.value = null;
if (!pageKey.value) return; if (!pageKey.value) return;
@ -2004,86 +2045,68 @@ function exportOutcome() {
border-color: #63e6f1; border-color: #63e6f1;
} }
.comprehensive-turn-nav { .comprehensive-step-nav {
position: relative; position: relative;
width: calc(100% + 24px); width: calc(100% + 24px);
margin: 0 -12px 32px; margin: 0 -12px 32px;
padding: 18px 22px; padding: 18px 22px;
overflow-x: auto; overflow-x: auto;
overflow-y: hidden;
border: 1px solid rgba(53, 178, 222, 0.42); border: 1px solid rgba(53, 178, 222, 0.42);
border-radius: 22px; border-radius: 22px;
background: linear-gradient(90deg, rgba(4, 29, 47, 0.94), rgba(5, 44, 67, 0.75)); background: linear-gradient(90deg, rgba(4, 29, 47, 0.94), rgba(5, 44, 67, 0.75));
cursor: grab;
overscroll-behavior-x: contain;
scroll-behavior: smooth;
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: rgba(86, 189, 227, 0.75) rgba(11, 43, 63, 0.75); scrollbar-color: rgba(86, 189, 227, 0.75) rgba(11, 43, 63, 0.75);
touch-action: pan-y;
user-select: none;
}
.comprehensive-step-nav.is-dragging {
cursor: grabbing;
scroll-behavior: auto;
} }
.comprehensive-turn-row { .comprehensive-step-strip {
display: flex; display: flex;
align-items: center; align-items: center;
min-width: 1040px; width: max-content;
min-width: 100%;
} }
.comprehensive-turn-row .step-tab { .comprehensive-step-strip .step-tab {
flex: 1 1 0; flex: 0 0 190px;
min-width: 112px; width: 190px;
min-width: 190px;
} }
.comprehensive-turn-row .step-name { .comprehensive-step-strip .step-name {
display: block;
white-space: nowrap;
font-size: 13px; font-size: 13px;
-webkit-line-clamp: unset;
} }
.comprehensive-turn-row .step-marker { .comprehensive-step-strip .step-marker {
flex-basis: 32px; flex-basis: 32px;
width: 32px; width: 32px;
height: 32px; height: 32px;
font-size: 11px; font-size: 11px;
} }
.comprehensive-turn-row .step-copy { .comprehensive-step-strip .step-copy {
margin-left: 8px; margin-left: 8px;
} }
.comprehensive-turn-row .step-connector { .comprehensive-step-strip .step-connector {
flex: 0 1 30px; flex: 0 0 30px;
min-width: 14px; width: 30px;
min-width: 30px;
margin: 0 5px; margin: 0 5px;
} }
.comprehensive-turn-row--return {
justify-content: flex-end;
}
.comprehensive-turn-row--return .step-tab {
flex: 0 1 190px;
}
.comprehensive-turn-rail {
display: block;
position: relative;
width: 2px;
height: 44px;
margin-left: auto;
background: rgba(98, 169, 201, 0.48);
}
.comprehensive-turn-rail::after {
position: absolute;
bottom: -1px;
left: -1px;
width: 7px;
height: 7px;
border-bottom: 1px solid #73b1ce;
border-left: 1px solid #73b1ce;
content: "";
transform: translateX(-50%) rotate(45deg);
}
.step-connector--reverse::after {
right: auto;
left: -1px;
transform: translateY(-50%) rotate(225deg);
}
.step-title-row { .step-title-row {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
@ -2628,45 +2651,34 @@ function exportOutcome() {
transform: translateX(-50%) rotate(135deg); transform: translateX(-50%) rotate(135deg);
} }
.comprehensive-turn-nav { .comprehensive-step-nav {
width: 100%; width: 100%;
margin: 0 0 32px; margin: 0 0 32px;
padding: 16px; padding: 16px;
overflow: visible; overflow-x: auto;
}
.comprehensive-turn-row {
flex-direction: column;
align-items: stretch;
min-width: 0;
}
.comprehensive-turn-row--return {
justify-content: flex-start;
}
.comprehensive-turn-row .step-tab {
flex: 0 0 auto;
width: 100%;
} }
.comprehensive-turn-row .step-connector { .comprehensive-step-strip .step-tab {
flex: 0 0 20px; flex: 0 0 172px;
width: 2px; width: 172px;
height: 20px; min-width: 172px;
margin: 4px 0 4px 18px;
} }
.comprehensive-turn-rail { .comprehensive-step-strip .step-connector {
display: none; flex: 0 0 24px;
width: 24px;
min-width: 24px;
height: 2px;
align-self: center;
margin: 0 5px;
} }
.comprehensive-turn-nav .step-connector--reverse::after { .comprehensive-step-strip .step-connector::after {
top: auto; top: 50%;
right: auto; right: -1px;
bottom: -1px; bottom: auto;
left: 50%; left: auto;
transform: translateX(-50%) rotate(135deg); transform: translateY(-50%) rotate(45deg);
} }
.product-development-process-page { .product-development-process-page {

@ -0,0 +1,32 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const test = require("node:test");
const frontendRoot = path.resolve(__dirname, "..");
const pageSource = fs.readFileSync(path.join(frontendRoot, "src/views/training/GenericTrainingPage.vue"), "utf8");
const routerSource = fs.readFileSync(path.join(frontendRoot, "src/router/index.js"), "utf8");
test("综合实训不显示右下角补充步骤", () => {
assert.match(
routerSource,
/pageKey: "comprehensive-case-training", hideSupplementSteps: true/,
);
});
test("综合实训操作按钮复用统一工具栏并位于工作台标题右侧", () => {
assert.match(
pageSource,
/<div class="workbench-head">[\s\S]*?<TrainingTaskToolbar[\s\S]*?:task-key="pageKey"[\s\S]*?:on-submit="submitTask"[\s\S]*?:on-reset="resetTraining"[\s\S]*?:on-export="exportOutcome"/,
);
assert.doesNotMatch(pageSource, /<TrainingTaskBrief[^>]*:show-toolbar="true"/);
});
test("综合实训步骤保持单行并支持鼠标或触控左右拖动", () => {
assert.match(pageSource, /class="comprehensive-step-strip"/);
assert.match(pageSource, /@pointerdown="startComprehensiveNavDrag"/);
assert.match(pageSource, /@pointermove="moveComprehensiveNavDrag"/);
assert.match(pageSource, /\.comprehensive-step-strip\s*\{[\s\S]*?width: max-content/);
assert.match(pageSource, /\.comprehensive-step-strip \.step-name\s*\{[\s\S]*?white-space: nowrap/);
assert.doesNotMatch(pageSource, /comprehensive-turn-row|comprehensive-turn-rail/);
});

@ -14,5 +14,8 @@ test("清空第 1 步会移除已保存答案,重新进入不会恢复分类
test("数据化选品页不显示右下角补充步骤导航", () => { test("数据化选品页不显示右下角补充步骤导航", () => {
assert.match(routerSource, /pageKey: "market-opportunity-selection", hideSupplementSteps: true/); assert.match(routerSource, /pageKey: "market-opportunity-selection", hideSupplementSteps: true/);
assert.match(appMainSource, /route\.meta\?\.pageKey && !route\.meta\?\.hideSupplementSteps/); assert.ok(
!/DynamicTrainingStepNavigator/.test(appMainSource) || /route\.meta\?\.pageKey && !route\.meta\?\.hideSupplementSteps/.test(appMainSource),
"the app shell must omit the supplement navigator or honor hideSupplementSteps",
);
}); });

Loading…
Cancel
Save