From 5c51a585f9624d826fc7e3d50efec4530ab73fea Mon Sep 17 00:00:00 2001 From: chenyuan Date: Fri, 21 Aug 2026 10:33:27 +0800 Subject: [PATCH 01/11] fix: avoid blocking route navigation --- src/layout/components/AppMain.vue | 35 +++++++++++++++++-------------- src/permission.js | 34 ++++++++++++++++++++++++------ 2 files changed, 47 insertions(+), 22 deletions(-) diff --git a/src/layout/components/AppMain.vue b/src/layout/components/AppMain.vue index 378e31d..e16f200 100644 --- a/src/layout/components/AppMain.vue +++ b/src/layout/components/AppMain.vue @@ -105,7 +105,22 @@ const getCurrentTeachingClassId = async () => { } return classId; }; -router.beforeEach(async (to, from, next) => { +const updateVisitCountInBackground = (projectName) => { + getCurrentTeachingClassId() + .then((currentClassId) => { + if (!currentClassId) return; + return indexApi.updateVisitCount({ + classId: currentClassId, + projectName, + schoolId: userStore.userInfo.schoolId, + userId: userStore.userInfo.userId, + }); + }) + .catch((error) => { + console.warn("更新模块访问次数失败", error); + }); +}; +router.beforeEach((to, from, next) => { if (isDemo.value) { next(); return; } const disabledTitles = proxy.$cache.session.getJSON("disabledTitles"); const routess = Array.isArray(disabledTitles) ? disabledTitles : []; @@ -135,21 +150,9 @@ router.beforeEach(async (to, from, next) => { } start(toModule); if (module.includes(fromModule)) { - const currentClassId = await getCurrentTeachingClassId(); - if (!currentClassId) { - next(); - return; - } - // 更新访问次数 - indexApi - .updateVisitCount({ - classId: currentClassId, - projectName: fromModule, - schoolId: userStore.userInfo.schoolId, - userId: userStore.userInfo.userId, - }) - .then((res) => {}); - } + // 访问统计不影响页面跳转,避免等待当前教学班接口。 + updateVisitCountInBackground(fromModule); + } } next(); }); diff --git a/src/permission.js b/src/permission.js index 839c5a7..ab954a9 100644 --- a/src/permission.js +++ b/src/permission.js @@ -37,8 +37,25 @@ router.beforeEach((to, from, next) => { next({ path: "/index" }); NProgress.done(); } - } else { + } else { const userStore = useUserStore(); + const requiresSchoolProductConfig = to.matched.some((record) => + record.meta?.requiresFacultyManagement + || record.meta?.requiresMajorManagement + || record.meta?.requiresAdminClass + ); + const continueNavigation = () => { + usePermissionStore() + .getRoutersPermission() + .then(() => next()); // hack方法 确保addRoutes已完成 + }; + + // 普通页面无需等待学校配置接口;仅管理页需要用该配置做访问校验。 + if (!requiresSchoolProductConfig) { + continueNavigation(); + return; + } + userStore .ensureSchoolProductConfig() .then((config) => { @@ -56,12 +73,17 @@ router.beforeEach((to, from, next) => { NProgress.done(); return null; } - return usePermissionStore().getRoutersPermission(); + return true; + }) + .then((allowed) => { + if (allowed === null) return; + continueNavigation(); }) - .then((accessRoutes) => { - if (accessRoutes === null) return; - next(); // hack方法 确保addRoutes已完成 - }); + .catch(() => { + ElMessage.error("学校配置加载失败,请稍后重试"); + next(false); + NProgress.done(); + }); } } else { // 没有token From 5320ef827b7e509a64b31018ad5d000cbb3b20a9 Mon Sep 17 00:00:00 2001 From: chenyuan Date: Mon, 24 Aug 2026 19:28:12 +0800 Subject: [PATCH 02/11] fix: eliminate duplicate student task loads --- src/api/trainingTask.js | 18 ++++++++++++++++-- src/views/demand/business-feasibility.vue | 3 ++- .../competitive-environment-analysis.vue | 3 ++- src/views/demand/consumer-behavior.vue | 3 ++- src/views/demand/consumer-scenario.vue | 3 ++- src/views/demand/environment.vue | 3 ++- src/views/demand/feature-conversion.vue | 3 ++- src/views/demand/index.vue | 3 ++- src/views/demand/industry-demand-analysis.vue | 3 ++- src/views/demand/people.vue | 3 ++- .../foundation/product-development-factors.vue | 3 ++- .../foundation/product-development-process.vue | 3 ++- src/views/product/index.vue | 3 ++- .../product/market-opportunity-selection.vue | 3 ++- 14 files changed, 42 insertions(+), 15 deletions(-) diff --git a/src/api/trainingTask.js b/src/api/trainingTask.js index fe35e08..73f2d86 100644 --- a/src/api/trainingTask.js +++ b/src/api/trainingTask.js @@ -1,5 +1,9 @@ import request from "@/utils/request"; +// 学生页面和全局“补充步骤”导航会在同一轮路由渲染中读取同一任务配置。 +// 仅合并进行中的请求:教师保存后下一次读取仍会获得最新配置,不引入陈旧缓存。 +const pendingTaskRequests = new Map(); + export function listTrainingTasks(params) { return request({ url: "/api/training-tasks", @@ -9,10 +13,20 @@ export function listTrainingTasks(params) { } export function getTrainingTaskByKey(taskKey) { - return request({ - url: `/api/training-tasks/key/${taskKey}`, + const normalizedTaskKey = String(taskKey || "").trim(); + if (pendingTaskRequests.has(normalizedTaskKey)) { + return pendingTaskRequests.get(normalizedTaskKey); + } + const requestPromise = request({ + url: `/api/training-tasks/key/${normalizedTaskKey}`, method: "get", }); + pendingTaskRequests.set(normalizedTaskKey, requestPromise); + requestPromise.then( + () => pendingTaskRequests.delete(normalizedTaskKey), + () => pendingTaskRequests.delete(normalizedTaskKey) + ); + return requestPromise; } export function updateTrainingTask(id, data) { diff --git a/src/views/demand/business-feasibility.vue b/src/views/demand/business-feasibility.vue index 203ac1c..c87e0c4 100644 --- a/src/views/demand/business-feasibility.vue +++ b/src/views/demand/business-feasibility.vue @@ -105,6 +105,7 @@ import { Check, Delete, Document, Download, RefreshLeft, UploadFilled, View } from "@element-plus/icons-vue"; import { checkBusinessFeasibilityStepOne, checkBusinessFeasibilityStepTwo, checkBusinessFeasibilityStepThree, getStudentTrainingAnswer, saveStudentTrainingAnswer, uploadStudentTrainingFile } from "@/api/studentTrainingAnswer"; import { getTrainingTaskByKey } from "@/api/trainingTask"; +import { parseTrainingArray } from "@/views/training/taskKeyMap"; import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue"; import TrainingMaterialButton from "@/views/components/TrainingMaterialButton.vue"; import TrainingTaskBrief from "@/views/components/TrainingTaskBrief.vue"; @@ -142,7 +143,7 @@ const priorityOptions = [ const defaultTask = { title: "商业可行性论证", background: "在产品开发前,综合市场机会、用户需求、竞争态势、成本收益和风险,判断项目是否具有可推进的商业价值。", goals: ["掌握商业可行性论证的基本维度", "形成市场、用户、成本与收益的一致判断", "识别主要风险并提出应对措施"], requirement: "请以案例资料和前序分析为依据,完成表格全部内容;成本与价格数据需真实、合理且逻辑一致。" }; const { proxy } = getCurrentInstance(); const taskConfig = ref(null); const currentStep = ref(1); const saving = ref(false); const uploading = ref(false); const completed = ref(false); const stepTwoCompleted = ref(false); const stepThreeCompleted = ref(false); const reportInput = ref(null); const reportPreviewVisible = ref(false); const report = ref(createReport()); const form = ref(createForm()); const stepTwo = ref(createStepTwo()); -const taskTitle = computed(() => taskConfig.value?.taskName || defaultTask.title); const taskBackground = computed(() => taskConfig.value?.background || defaultTask.background); const taskGoals = computed(() => parseArray(taskConfig.value?.objectives, defaultTask.goals)); const taskRequirement = computed(() => taskConfig.value?.requirements || defaultTask.requirement); const trainingSteps = computed(() => parseArray(taskConfig.value?.steps, DEFAULT_STEPS)); +const taskTitle = computed(() => taskConfig.value?.taskName || defaultTask.title); const taskBackground = computed(() => taskConfig.value?.background || defaultTask.background); const taskGoals = computed(() => parseArray(taskConfig.value?.objectives, defaultTask.goals)); const taskRequirement = computed(() => taskConfig.value?.requirements || defaultTask.requirement); const trainingSteps = computed(() => parseTrainingArray(taskConfig.value?.steps, DEFAULT_STEPS)); const totalCost = computed(() => form.value.costs.reduce((sum, cost) => sum + numeric(cost.amount), 0)); const unitGrossProfit = computed(() => numeric(form.value.targetPrice) - totalCost.value); const grossMargin = computed(() => totalCost.value > 0 ? unitGrossProfit.value / totalCost.value * 100 : null); const progressItems = computed(() => [{ text: completed.value ? "项目论证已保存" : "待完成项目论证", status: completed.value ? "done" : "" }, { text: totalCost.value > 0 ? "成本与毛利已计算" : "待填写成本与售价", status: totalCost.value > 0 ? "done" : "" }, { text: form.value.risks.filter((risk) => risk.description && risk.response && risk.impact).length === 4 ? "风险评估已完成" : "待完成风险评估", status: form.value.risks.filter((risk) => risk.description && risk.response && risk.impact).length === 4 ? "done" : "" }]); const selectedPriorityCount = computed(() => stepTwo.value.priorities.length); diff --git a/src/views/demand/competitive-environment-analysis.vue b/src/views/demand/competitive-environment-analysis.vue index d327d39..296df6a 100644 --- a/src/views/demand/competitive-environment-analysis.vue +++ b/src/views/demand/competitive-environment-analysis.vue @@ -49,6 +49,7 @@ import { Download, RefreshLeft, TrendCharts } from "@element-plus/icons-vue"; import { checkCompetitiveEnvironmentAnalysisStepOne, checkCompetitiveEnvironmentAnalysisStepTwo, checkCompetitiveEnvironmentAnalysisStepThree, checkCompetitiveEnvironmentAnalysisStepFour, getStudentTrainingAnswer, saveStudentTrainingAnswer } from "@/api/studentTrainingAnswer"; import { getTrainingTaskByKey } from "@/api/trainingTask"; +import { parseTrainingArray } from "@/views/training/taskKeyMap"; import useUserStore from "@/store/modules/user"; import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue"; import TrainingMaterialButton from "@/views/components/TrainingMaterialButton.vue"; @@ -73,7 +74,7 @@ const strategyDefinitions = [ const defaultTask = { title: "竞争环境分析", background: "通过 SWOT 模型归类案例资料中的竞争环境信息,为后续竞品比较与竞争策略判断提供依据。", goals: ["掌握 SWOT 四维度的分类方法", "提炼案例中的竞争环境关键信息", "形成竞争分析的基础材料"], requirement: "请阅读案例资料,将相关信息归类为优势、劣势、机会和威胁。" }; const evaluationOptions = ["优势明显,建议快速进入", "机会大于挑战,建议积极布局", "挑战与机会并存,需谨慎推进", "劣势突出,建议暂缓进入"]; const taskConfig = ref(null); const currentStep = ref(1); const saving = ref(false); const draftReady = ref(false); const stepOnePassed = ref(false); const stepTwoPassed = ref(false); const stepThreePassed = ref(false); const form = ref(createForm()); const supplementForm = ref(createForm()); const strategyForm = ref(createStrategyForm()); const finalForm = ref(createFinalForm()); -const taskTitle = computed(() => taskConfig.value?.taskName || defaultTask.title); const taskBackground = computed(() => taskConfig.value?.background || defaultTask.background); const taskGoals = computed(() => parseArray(taskConfig.value?.objectives, defaultTask.goals)); const taskRequirement = computed(() => taskConfig.value?.requirements || defaultTask.requirement); const trainingSteps = computed(() => parseArray(taskConfig.value?.steps, DEFAULT_STEPS)); const progressItems = computed(() => swotRows.map((row) => ({ text: `${strategyForm.value.so.recommendation ? "已制定策略" : supplementForm.value[row.key] ? "已补充" : form.value[row.key] ? "已归类" : "待填写"}:${row.label}`, status: strategyForm.value.so.recommendation || supplementForm.value[row.key] || form.value[row.key] ? "done" : "" }))); +const taskTitle = computed(() => taskConfig.value?.taskName || defaultTask.title); const taskBackground = computed(() => taskConfig.value?.background || defaultTask.background); const taskGoals = computed(() => parseArray(taskConfig.value?.objectives, defaultTask.goals)); const taskRequirement = computed(() => taskConfig.value?.requirements || defaultTask.requirement); const trainingSteps = computed(() => parseTrainingArray(taskConfig.value?.steps, DEFAULT_STEPS)); const progressItems = computed(() => swotRows.map((row) => ({ text: `${strategyForm.value.so.recommendation ? "已制定策略" : supplementForm.value[row.key] ? "已补充" : form.value[row.key] ? "已归类" : "待填写"}:${row.label}`, status: strategyForm.value.so.recommendation || supplementForm.value[row.key] || form.value[row.key] ? "done" : "" }))); onMounted(async () => { await loadTaskConfig(); loadDraft(); await loadSavedAnswer(); draftReady.value = true; }); watch(() => JSON.stringify(buildDraft()), persistDraft); function createForm() { return { strengths: "", weaknesses: "", opportunities: "", threats: "" }; } function createStrategyForm() { return { so: createStrategyRow(), wo: createStrategyRow(), st: createStrategyRow(), wt: createStrategyRow() }; } diff --git a/src/views/demand/consumer-behavior.vue b/src/views/demand/consumer-behavior.vue index 98c3a99..6ddd57e 100644 --- a/src/views/demand/consumer-behavior.vue +++ b/src/views/demand/consumer-behavior.vue @@ -275,6 +275,7 @@ import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue"; import TrainingMaterialButton from "@/views/components/TrainingMaterialButton.vue"; import TrainingTaskBrief from "@/views/components/TrainingTaskBrief.vue"; import { getTrainingTaskByKey } from "@/api/trainingTask"; +import { parseTrainingArray } from "@/views/training/taskKeyMap"; import { checkConsumerBehaviorStepOne, checkConsumerBehaviorStepTwo, checkConsumerBehaviorStepThree, checkConsumerBehaviorStepFour, getStudentTrainingAnswer, previewConsumerBehaviorQuestionnaireWord, saveStudentTrainingAnswer, uploadStudentTrainingFile } from "@/api/studentTrainingAnswer"; const TASK_KEY = "consumer-behavior"; @@ -480,7 +481,7 @@ const taskTitle = computed(() => taskConfig.value?.taskName || "消费者需求 const taskBackground = computed(() => taskConfig.value?.background || defaultBackground); const taskGoals = computed(() => parseArray(taskConfig.value?.objectives, defaultGoals)); const taskRequirement = computed(() => taskConfig.value?.requirements || defaultRequirement); -const flowSteps = computed(() => parseArray(taskConfig.value?.steps, []).slice(0, 4)); +const flowSteps = computed(() => parseTrainingArray(taskConfig.value?.steps, []).slice(0, 4)); const activeStepName = computed(() => flowSteps.value[form.value.activeStep] || ""); const progressItems = computed(() => [ diff --git a/src/views/demand/consumer-scenario.vue b/src/views/demand/consumer-scenario.vue index 7fe7541..60b18b5 100644 --- a/src/views/demand/consumer-scenario.vue +++ b/src/views/demand/consumer-scenario.vue @@ -261,6 +261,7 @@ import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue"; import TrainingMaterialButton from "@/views/components/TrainingMaterialButton.vue"; import TrainingTaskBrief from "@/views/components/TrainingTaskBrief.vue"; import { getTrainingTaskByKey } from "@/api/trainingTask"; +import { parseTrainingArray } from "@/views/training/taskKeyMap"; import { checkConsumerScenarioStepOne, checkConsumerScenarioStepTwo, checkConsumerScenarioStepThree, checkConsumerScenarioStepFour, getStudentTrainingAnswer, saveStudentTrainingAnswer, uploadStudentTrainingFile } from "@/api/studentTrainingAnswer"; import { isStudentDemo } from "@/utils/studentDemo"; @@ -468,7 +469,7 @@ const taskTitle = computed(() => taskConfig.value?.taskName || "消费场景解 const taskBackground = computed(() => taskConfig.value?.background || defaultBackground); const taskGoals = computed(() => parseArray(taskConfig.value?.objectives, defaultGoals)); const taskRequirement = computed(() => taskConfig.value?.requirements || defaultRequirement); -const trainingSteps = computed(() => parseArray(taskConfig.value?.steps, [])); +const trainingSteps = computed(() => parseTrainingArray(taskConfig.value?.steps, [])); const hasConfiguredSteps = computed(() => trainingSteps.value.length > 0); const activeStepName = computed(() => trainingSteps.value[form.value.activeStep - 1] || ""); const orderedJourneyStages = computed(() => diff --git a/src/views/demand/environment.vue b/src/views/demand/environment.vue index e73096b..e646b5b 100644 --- a/src/views/demand/environment.vue +++ b/src/views/demand/environment.vue @@ -132,6 +132,7 @@ import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue"; import TrainingMaterialButton from "@/views/components/TrainingMaterialButton.vue"; import TrainingTaskBrief from "@/views/components/TrainingTaskBrief.vue"; import { getTrainingTaskByKey } from "@/api/trainingTask"; +import { parseTrainingArray } from "@/views/training/taskKeyMap"; import { getStudentTrainingAnswer, saveStudentTrainingAnswer } from "@/api/studentTrainingAnswer"; import { ArrowLeft, ArrowRight, CircleCheck, Download } from "@element-plus/icons-vue"; @@ -226,7 +227,7 @@ const taskTitle = computed(() => taskConfig.value?.taskName || "竞争环境分 const taskBackground = computed(() => taskConfig.value?.background || defaultBackground); const taskGoals = computed(() => parseArray(taskConfig.value?.objectives, defaultGoals)); const taskRequirement = computed(() => taskConfig.value?.requirements || defaultRequirement); -const trainingSteps = computed(() => parseArray(taskConfig.value?.steps, [])); +const trainingSteps = computed(() => parseTrainingArray(taskConfig.value?.steps, [])); const activeStepName = computed(() => trainingSteps.value[currentStep.value - 1] || trainingSteps.value[0] || ""); watch(trainingSteps, (steps) => { diff --git a/src/views/demand/feature-conversion.vue b/src/views/demand/feature-conversion.vue index 5013d93..557dec1 100644 --- a/src/views/demand/feature-conversion.vue +++ b/src/views/demand/feature-conversion.vue @@ -230,6 +230,7 @@ import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue"; import TrainingMaterialButton from "@/views/components/TrainingMaterialButton.vue"; import TrainingTaskBrief from "@/views/components/TrainingTaskBrief.vue"; import { getTrainingTaskByKey } from "@/api/trainingTask"; +import { parseTrainingArray } from "@/views/training/taskKeyMap"; import { checkFeatureConversionStepOne, checkFeatureConversionStepTwo, checkFeatureConversionStepThree, getStudentTrainingAnswer, saveStudentTrainingAnswer } from "@/api/studentTrainingAnswer"; const TASK_KEY = "feature-conversion"; @@ -375,7 +376,7 @@ const taskTitle = computed(() => taskConfig.value?.taskName || "需求转化为 const taskBackground = computed(() => taskConfig.value?.background || defaultBackground); const taskGoals = computed(() => parseArray(taskConfig.value?.objectives, defaultGoals)); const taskRequirement = computed(() => taskConfig.value?.requirements || defaultRequirement); -const flowSteps = computed(() => parseArray(taskConfig.value?.steps)); +const flowSteps = computed(() => parseTrainingArray(taskConfig.value?.steps)); const hasConfiguredSteps = computed(() => flowSteps.value.length > 0); const activeStepName = computed(() => flowSteps.value[form.value.activeStep - 1] || ""); diff --git a/src/views/demand/index.vue b/src/views/demand/index.vue index b7858de..1b7d355 100644 --- a/src/views/demand/index.vue +++ b/src/views/demand/index.vue @@ -126,6 +126,7 @@ import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue"; import TrainingMaterialButton from "@/views/components/TrainingMaterialButton.vue"; import TrainingTaskBrief from "@/views/components/TrainingTaskBrief.vue"; import { getTrainingTaskByKey } from "@/api/trainingTask"; +import { parseTrainingArray } from "@/views/training/taskKeyMap"; import { getStudentTrainingAnswer, saveStudentTrainingAnswer } from "@/api/studentTrainingAnswer"; import { ArrowLeft, ArrowRight, CircleCheck, Download } from "@element-plus/icons-vue"; @@ -222,7 +223,7 @@ const taskTitle = computed(() => taskConfig.value?.taskName || "行业需求分 const taskBackground = computed(() => taskConfig.value?.background || defaultBackground); const taskGoals = computed(() => parseArray(taskConfig.value?.objectives, defaultGoals)); const taskRequirement = computed(() => taskConfig.value?.requirements || defaultRequirement); -const trainingSteps = computed(() => parseArray(taskConfig.value?.steps, [])); +const trainingSteps = computed(() => parseTrainingArray(taskConfig.value?.steps, [])); const activeStepName = computed(() => trainingSteps.value[currentStep.value - 1] || trainingSteps.value[0] || ""); watch(trainingSteps, (steps) => { diff --git a/src/views/demand/industry-demand-analysis.vue b/src/views/demand/industry-demand-analysis.vue index 934df1b..5c4f4cd 100644 --- a/src/views/demand/industry-demand-analysis.vue +++ b/src/views/demand/industry-demand-analysis.vue @@ -90,6 +90,7 @@ import { DataAnalysis, Download, RefreshLeft } from "@element-plus/icons-vue"; import { checkIndustryDemandAnalysisStepOne, checkIndustryDemandAnalysisStepTwo, checkIndustryDemandAnalysisStepThree, checkIndustryDemandAnalysisStepFour, getStudentTrainingAnswer, saveStudentTrainingAnswer } from "@/api/studentTrainingAnswer"; import { getTrainingTaskByKey } from "@/api/trainingTask"; +import { parseTrainingArray } from "@/views/training/taskKeyMap"; import useUserStore from "@/store/modules/user"; import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue"; import TrainingMaterialButton from "@/views/components/TrainingMaterialButton.vue"; @@ -134,7 +135,7 @@ const taskTitle = computed(() => taskConfig.value?.taskName || defaultTask.title const taskBackground = computed(() => taskConfig.value?.background || defaultTask.background); const taskGoals = computed(() => parseArray(taskConfig.value?.objectives, defaultTask.goals)); const taskRequirement = computed(() => taskConfig.value?.requirements || defaultTask.requirement); -const trainingSteps = computed(() => parseArray(taskConfig.value?.steps, DEFAULT_STEPS)); +const trainingSteps = computed(() => parseTrainingArray(taskConfig.value?.steps, DEFAULT_STEPS)); const progressItems = computed(() => pestRows.map((row) => ({ text: `${conclusionForm.value.summary ? "已总结" : analysisForm.value[row.key].opportunities ? "已分析" : supplementForm.value[row.key] ? "已补充" : form.value[row.key] ? "已归类" : "待填写"}:${row.label}`, status: conclusionForm.value.summary || analysisForm.value[row.key].opportunities || supplementForm.value[row.key] || form.value[row.key] ? "done" : "" }))); onMounted(async () => { await loadTaskConfig(); loadDraft(); await loadSavedAnswer(); draftReady.value = true; }); diff --git a/src/views/demand/people.vue b/src/views/demand/people.vue index 9fa9253..489aeed 100644 --- a/src/views/demand/people.vue +++ b/src/views/demand/people.vue @@ -330,6 +330,7 @@ import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue"; import TrainingMaterialButton from "@/views/components/TrainingMaterialButton.vue"; import TrainingTaskBrief from "@/views/components/TrainingTaskBrief.vue"; import { getTrainingTaskByKey } from "@/api/trainingTask"; +import { parseTrainingArray } from "@/views/training/taskKeyMap"; import { checkTargetUserProfileStepFive, checkTargetUserProfileStepFour, checkTargetUserProfileStepOne, checkTargetUserProfileStepThree, checkTargetUserProfileStepTwo, getStudentTrainingAnswer, saveStudentTrainingAnswer, uploadStudentTrainingFile } from "@/api/studentTrainingAnswer"; const TASK_KEY = "target-user-profile"; @@ -539,7 +540,7 @@ const taskBackground = computed(() => taskConfig.value?.background || defaultBac const taskGoals = computed(() => parseArray(taskConfig.value?.objectives, defaultGoals)); const taskRequirement = computed(() => taskConfig.value?.requirements || defaultRequirement); const flowSteps = computed(() => { - const configuredSteps = parseArray(taskConfig.value?.steps, DEFAULT_FLOW_STEPS); + const configuredSteps = parseTrainingArray(taskConfig.value?.steps, DEFAULT_FLOW_STEPS); return configuredSteps.length >= DEFAULT_FLOW_STEPS.length ? configuredSteps : DEFAULT_FLOW_STEPS; }); const steps = computed(() => diff --git a/src/views/foundation/product-development-factors.vue b/src/views/foundation/product-development-factors.vue index a5f258f..3b77b40 100644 --- a/src/views/foundation/product-development-factors.vue +++ b/src/views/foundation/product-development-factors.vue @@ -139,6 +139,7 @@ From d9e0239720cb47f8bbe326919a64901528bb5032 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E6=B2=85?= <907037276@qq.com> Date: Tue, 25 Aug 2026 11:35:45 +0800 Subject: [PATCH 06/11] fix: route development API through local proxy --- .env.development | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.env.development b/.env.development index dcf21ec..362f574 100644 --- a/.env.development +++ b/.env.development @@ -18,4 +18,4 @@ VITE_APP_BASE_API = '/' # VITE_APP_BASE_API = 'http://118.31.7.2:7548/' VITE_APP_AI_API = 'https://whmnjy.sztzjy.com:8764/' VITE_APP_ENV = 'development' -VITE_APP_BASE_API = 'http://192.168.1.74:7548/' +VITE_APP_BASE_API = '/' From d604db38ba59086299885cb6d87e2d7112221326 Mon Sep 17 00:00:00 2001 From: chenyuan Date: Tue, 25 Aug 2026 12:01:20 +0800 Subject: [PATCH 07/11] perf: speed up student navigation loading --- src/api/trainingTask.js | 48 +++++++++++++++++++++++++ src/layout/components/Navbar.vue | 12 +++++-- src/layout/components/Sidebar/index.vue | 12 +++++-- 3 files changed, 66 insertions(+), 6 deletions(-) diff --git a/src/api/trainingTask.js b/src/api/trainingTask.js index e99eda6..67a0468 100644 --- a/src/api/trainingTask.js +++ b/src/api/trainingTask.js @@ -1,8 +1,11 @@ import request from "@/utils/request"; +import { getUserInfo } from "@/utils/auth"; // 学生页面和全局“补充步骤”导航会在同一轮路由渲染中读取同一任务配置。 // 仅合并进行中的请求:教师保存后下一次读取仍会获得最新配置,不引入陈旧缓存。 const pendingTaskRequests = new Map(); +const pendingStudentTaskListRequests = new Map(); +const STUDENT_TASK_NAVIGATION_CACHE_PREFIX = "student-training-task-navigation:v1:"; function notifyStudentTaskConfigLoading(taskKey, loading) { if (typeof window === "undefined") return; @@ -17,6 +20,51 @@ export function listTrainingTasks(params) { }); } +function getStudentTaskNavigationCacheKey() { + try { + const userInfo = JSON.parse(getUserInfo() || "{}"); + const userId = userInfo.userId || userInfo.user_id || userInfo.username || userInfo.userName; + return userId ? `${STUDENT_TASK_NAVIGATION_CACHE_PREFIX}${userId}` : ""; + } catch (error) { + return ""; + } +} + +export function getCachedStudentTrainingTasks() { + const cacheKey = getStudentTaskNavigationCacheKey(); + if (!cacheKey || typeof window === "undefined") return []; + try { + const cached = JSON.parse(window.sessionStorage.getItem(cacheKey) || "[]"); + return Array.isArray(cached) ? cached : []; + } catch (error) { + return []; + } +} + +/** + * The navigation and top bar both need the same enabled task list. Reuse one + * in-flight request and retain the last successful response for immediate menu rendering. + */ +export function loadStudentTrainingTasks() { + const cacheKey = getStudentTaskNavigationCacheKey() || "anonymous"; + if (pendingStudentTaskListRequests.has(cacheKey)) { + return pendingStudentTaskListRequests.get(cacheKey); + } + const requestPromise = listTrainingTasks({ enabledOnly: true }).then((res) => { + const tasks = Array.isArray(res?.data) ? res.data : []; + if (cacheKey !== "anonymous" && typeof window !== "undefined") { + window.sessionStorage.setItem(cacheKey, JSON.stringify(tasks)); + } + return { ...res, data: tasks }; + }); + pendingStudentTaskListRequests.set(cacheKey, requestPromise); + requestPromise.then( + () => pendingStudentTaskListRequests.delete(cacheKey), + () => pendingStudentTaskListRequests.delete(cacheKey) + ); + return requestPromise; +} + export function getTrainingTaskByKey(taskKey) { const normalizedTaskKey = String(taskKey || "").trim(); if (pendingTaskRequests.has(normalizedTaskKey)) { diff --git a/src/layout/components/Navbar.vue b/src/layout/components/Navbar.vue index c799bfe..9940ec6 100644 --- a/src/layout/components/Navbar.vue +++ b/src/layout/components/Navbar.vue @@ -81,7 +81,7 @@ import useAppStore from "@/store/modules/app"; import useUserStore from "@/store/modules/user"; import useSettingsStore from "@/store/modules/settings"; import * as indexApi from "@/api/teacher"; -import { listTrainingTasks } from "@/api/trainingTask"; +import { getCachedStudentTrainingTasks, loadStudentTrainingTasks } from "@/api/trainingTask"; import { getStudentDemoSession, isStudentDemo } from "@/utils/studentDemo"; import { getComprehensiveTopTask } from "@/utils/studentTrainingNavigation"; const { proxy } = getCurrentInstance(); @@ -289,11 +289,17 @@ async function loadStudentTopTask() { studentTopTask.value = null; return; } + const cachedTasks = getCachedStudentTrainingTasks(); + if (cachedTasks.length) { + studentTopTask.value = getComprehensiveTopTask(cachedTasks); + } try { - const res = await listTrainingTasks({ enabledOnly: true }); + const res = await loadStudentTrainingTasks(); studentTopTask.value = getComprehensiveTopTask(res.data || []); } catch (error) { - studentTopTask.value = null; + if (!cachedTasks.length) { + studentTopTask.value = null; + } } } // 获取用户详情 diff --git a/src/layout/components/Sidebar/index.vue b/src/layout/components/Sidebar/index.vue index fffb9a2..7b4b409 100644 --- a/src/layout/components/Sidebar/index.vue +++ b/src/layout/components/Sidebar/index.vue @@ -62,7 +62,7 @@ import useSettingsStore from "@/store/modules/settings"; import usePermissionStore from "@/store/modules/permission"; import { constantRoutes, dynamicRoutes, teacherRoutes, platformAdminRoutes, schoolAdminRoutes } from "@/router/index.js"; import useUserStore from "@/store/modules/user"; -import { listTrainingTasks } from "@/api/trainingTask"; +import { getCachedStudentTrainingTasks, loadStudentTrainingTasks } from "@/api/trainingTask"; import { buildStudentTaskSections } from "@/utils/studentTrainingNavigation"; import * as ElementPlusIconsVue from "@element-plus/icons-vue"; const { proxy } = getCurrentInstance(); @@ -469,11 +469,17 @@ async function loadStudentTaskNavigation() { studentTaskSections.value = []; return; } + const cachedTasks = getCachedStudentTrainingTasks(); + if (cachedTasks.length) { + studentTaskSections.value = buildStudentTaskSections(cachedTasks); + } try { - const res = await listTrainingTasks({ enabledOnly: true }); + const res = await loadStudentTrainingTasks(); studentTaskSections.value = buildStudentTaskSections(res.data || []); } catch (error) { - studentTaskSections.value = []; + if (!cachedTasks.length) { + studentTaskSections.value = []; + } } } function groupAndSortModules(modules) { From 7b8644a8ec842d633272f3890a02fbe093ff9b89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E6=B2=85?= <907037276@qq.com> Date: Tue, 25 Aug 2026 16:18:34 +0800 Subject: [PATCH 08/11] feat: improve student training experience --- src/api/studentTrainingAnswer.js | 169 ++++ src/assets/styles/student-training.scss | 73 +- src/views/components/TrainingAiSidebar.vue | 6 +- .../components/TrainingMaterialButton.vue | 2 +- src/views/components/TrainingTaskBrief.vue | 22 +- .../product-development-process.vue | 12 +- .../product/market-opportunity-selection.vue | 2 +- src/views/product/price.vue | 769 +++++++++++++++++- 8 files changed, 996 insertions(+), 59 deletions(-) diff --git a/src/api/studentTrainingAnswer.js b/src/api/studentTrainingAnswer.js index bcb5bde..32b2708 100644 --- a/src/api/studentTrainingAnswer.js +++ b/src/api/studentTrainingAnswer.js @@ -64,6 +64,175 @@ export function uploadStudentTrainingFile(data) { }); } +export function validateProductPricingStepOne(payload) { + const value = payload || {}; + const rows = Array.isArray(value.pricingRows) ? value.pricingRows : []; + if (isStudentDemo()) { + const ranges = { dealer: [15, 25], "direct-retail": [40, 60], "premium-brand": [70, 80] }; + const unitCost = Number(value.unitCost); + if (!Number.isFinite(unitCost) || unitCost <= 0) return Promise.reject(new Error("单台综合成本必须大于 0")); + if (rows.length !== 3) return Promise.reject(new Error("请填写三个渠道的加成率")); + try { + const pricingRows = Object.keys(ranges).map((id) => { + const markupRate = Number(rows.find((item) => item?.id === id)?.markupRate); + const [min, max] = ranges[id]; + if (!Number.isFinite(markupRate) || markupRate < min || markupRate > max) { + throw new Error(`渠道加成率需在 ${min}%~${max}% 范围内`); + } + return { id, markupRate: Number(markupRate.toFixed(2)), salePrice: Number((unitCost * (1 + markupRate / 100)).toFixed(2)) }; + }); + return Promise.resolve({ code: 200, data: { valid: true, message: "渠道加成定价已校验", unitCost: Number(unitCost.toFixed(2)), pricingRows } }); + } catch (error) { + return Promise.reject(error); + } + } + return request({ url: "/api/student-training-answers/product-pricing/step-1/validate", method: "post", data: value, headers: { repeatSubmit: false } }); +} + +export function validateProductPricingStepTwo(payload) { + const value = payload || {}; + const rows = Array.isArray(value.competitorRows) ? value.competitorRows : []; + const decision = value.decision || {}; + if (isStudentDemo()) { + const unitCost = Number(value.unitCost); + if (!Number.isFinite(unitCost) || unitCost <= 0) return Promise.reject(new Error("请先完成第 1 步并填写单台综合成本")); + if (rows.length !== 4) return Promise.reject(new Error("请完整填写 4 个竞品信息")); + try { + const productTypes = rows.map((row, index) => { + const productType = String(row?.productType || "").trim(); + const brand = String(row?.brand || "").trim(); + const differentiator = String(row?.differentiator || "").trim(); + const ecommercePrice = Number(row?.ecommercePrice); + if (!productType || !brand || !differentiator || !Number.isFinite(ecommercePrice) || ecommercePrice <= 0) throw new Error(`请完整填写竞品 ${index + 1} 的信息`); + return productType; + }); + if (new Set(productTypes).size !== productTypes.length) { + throw new Error("竞品产品类型不能重复,请使用可区分的竞品名称"); + } + const targetPrice = Number(decision.targetPrice); + if (!productTypes.includes(String(decision.benchmarkCompetitor || "").trim())) throw new Error("对标竞品必须从上方已填写的竞品中选择"); + if (!Number.isFinite(targetPrice) || targetPrice <= 0 || !String(decision.differentiator || "").trim() || !String(decision.pricingReason || "").trim()) throw new Error("请完整填写本产品定价、差异化优势和定价原因"); + return Promise.resolve({ code: 200, data: { valid: true, message: "竞品价格带与定价已校验", competitorRows: rows, decision: { ...decision, targetPrice: Number(targetPrice.toFixed(2)), grossMarginRate: Number((((targetPrice - unitCost) / targetPrice) * 100).toFixed(2)) } } }); + } catch (error) { + return Promise.reject(error); + } + } + return request({ url: "/api/student-training-answers/product-pricing/step-2/validate", method: "post", data: value, headers: { repeatSubmit: false } }); +} + +export function validateProductPricingStepThree(payload) { + const value = payload || {}; + const valueItems = Array.isArray(value.valueItems) ? value.valueItems : []; + if (isStudentDemo()) { + try { + if (!valueItems.length) throw new Error("请至少填写一项可替代产品或功能的价值感知"); + const normalizedItems = valueItems.map((item, index) => { + const name = String(item?.name || "").trim(); + const perceivedValue = Number(item?.perceivedValue); + if (!String(item?.id || "").trim() || !name || !Number.isFinite(perceivedValue) || perceivedValue <= 0) { + throw new Error(`请完整填写第 ${index + 1} 项价值感知`); + } + return { ...item, id: String(item.id).trim(), name, perceivedValue: Number(perceivedValue.toFixed(2)) }; + }); + if (new Set(normalizedItems.map((item) => item.id)).size !== normalizedItems.length) throw new Error("价值感知条目不能重复"); + const discountRate = Number(value.discountRate); + if (!Number.isFinite(discountRate) || discountRate <= 0 || discountRate > 100) throw new Error("利润让渡折扣率应大于 0 且不超过 100%"); + const perceivedValueTotal = Number(normalizedItems.reduce((total, item) => total + item.perceivedValue, 0).toFixed(2)); + return Promise.resolve({ + code: 200, + data: { + valid: true, + message: "用户感知价值定价已校验", + valueItems: normalizedItems, + perceivedValueTotal, + discountRate: Number(discountRate.toFixed(2)), + valueBasedPrice: Number((perceivedValueTotal * discountRate / 100).toFixed(2)), + }, + }); + } catch (error) { + return Promise.reject(error); + } + } + return request({ url: "/api/student-training-answers/product-pricing/step-3/validate", method: "post", data: value, headers: { repeatSubmit: false } }); +} + +export function validateProductPricingStepFour(payload) { + const value = payload || {}; + const psmRows = Array.isArray(value.psmRows) ? value.psmRows : []; + const pricePoints = value.pricePoints || {}; + if (isStudentDemo()) { + try { + const requiredRanges = ["≤100元", "100~150元", "150~200元", "200~250元", "250~300元", "≥300元"]; + if (psmRows.length < requiredRanges.length) throw new Error("请完整填写 6 个固定价格区间的 PSM 累计占比"); + const seenIds = new Set(); + const seenRanges = new Set(); + const normalizedRows = psmRows.map((row, index) => { + const id = String(row?.id || "").trim(); + const priceRange = String(row?.priceRange || "").trim(); + if (!id || !priceRange || seenIds.has(id) || seenRanges.has(priceRange)) throw new Error("PSM 价格区间不能重复且不能为空"); + seenIds.add(id); + seenRanges.add(priceRange); + const normalized = { ...row, id, priceRange }; + [["tooExpensive", "太贵不买"], ["tooCheap", "太便宜不买"], ["expensiveAcceptable", "偏高可接受"], ["cheapValue", "便宜划算"]].forEach(([field, label]) => { + const percentage = Number(row?.[field]); + if (!Number.isFinite(percentage) || percentage < 0 || percentage > 100) throw new Error(`第 ${index + 1} 行“${label}”累计占比应在 0%–100% 之间`); + normalized[field] = Number(percentage.toFixed(2)); + }); + return normalized; + }); + if (!requiredRanges.every((range) => seenRanges.has(range))) throw new Error("请保留并填写 6 个固定价格区间"); + const optimalPrice = Number(pricePoints.optimalPrice); + const acceptablePrice = Number(pricePoints.acceptablePrice); + const acceptableRange = String(pricePoints.acceptableRange || "").trim(); + if (!Number.isFinite(optimalPrice) || optimalPrice <= 0 || !Number.isFinite(acceptablePrice) || acceptablePrice <= 0 || !acceptableRange) { + throw new Error("请完整填写最优价格点、可接受价格点和可接受价格范围"); + } + return Promise.resolve({ code: 200, data: { + valid: true, + message: "PSM 价格敏感度测试已校验", + psmRows: normalizedRows, + pricePoints: { optimalPrice: Number(optimalPrice.toFixed(2)), acceptablePrice: Number(acceptablePrice.toFixed(2)), acceptableRange }, + } }); + } catch (error) { + return Promise.reject(error); + } + } + return request({ url: "/api/student-training-answers/product-pricing/step-4/validate", method: "post", data: value, headers: { repeatSubmit: false } }); +} + +export function validateProductPricingStepFive(payload) { + const value = payload || {}; + const methodResults = Array.isArray(value.methodResults) ? value.methodResults : []; + if (isStudentDemo()) { + try { + const requiredKeys = ["cost-markup", "competitive", "value-based", "psm"]; + const selectedCostMarkupChannel = String(value.selectedCostMarkupChannel || "").trim(); + if (!selectedCostMarkupChannel) throw new Error("请选择用于汇总的成本加成定价渠道"); + if (methodResults.length !== requiredKeys.length) throw new Error("请完整汇总四种定价方法的测算结果"); + const seenKeys = new Set(); + const normalizedResults = methodResults.map((item) => { + const key = String(item?.key || "").trim(); + const methodName = String(item?.methodName || "").trim(); + const measuredPrice = Number(item?.measuredPrice); + const grossMarginRate = Number(item?.grossMarginRate); + if (!requiredKeys.includes(key) || seenKeys.has(key) || !methodName || !Number.isFinite(measuredPrice) || measuredPrice <= 0 || !Number.isFinite(grossMarginRate)) { + throw new Error("请完整填写四种定价方法的测算结果和毛利润率"); + } + if (grossMarginRate < -100 || grossMarginRate > 100) throw new Error("毛利润率应在 -100% 至 100% 之间"); + seenKeys.add(key); + return { key, methodName, measuredPrice: Number(measuredPrice.toFixed(2)), grossMarginRate: Number(grossMarginRate.toFixed(2)) }; + }); + if (!requiredKeys.every((key) => seenKeys.has(key))) throw new Error("定价方法汇总项不正确或重复"); + const finalAnalysis = String(value.finalAnalysis || "").trim(); + if (!finalAnalysis) throw new Error("请填写最终量产定价分析"); + return Promise.resolve({ code: 200, data: { valid: true, message: "最终量产定价汇总已校验", selectedCostMarkupChannel, methodResults: normalizedResults, finalAnalysis } }); + } catch (error) { + return Promise.reject(error); + } + } + return request({ url: "/api/student-training-answers/product-pricing/step-5/validate", method: "post", data: value, headers: { repeatSubmit: false } }); +} + export function checkNewProductSurveyStepOne(items) { if (isStudentDemo()) { const results = Array.isArray(items) diff --git a/src/assets/styles/student-training.scss b/src/assets/styles/student-training.scss index e8fff74..3a88609 100644 --- a/src/assets/styles/student-training.scss +++ b/src/assets/styles/student-training.scss @@ -28,17 +28,47 @@ #app .teacherLayout .app-main:has(.student-training-shell) .student-training-shell { box-sizing: border-box !important; display: grid !important; - grid-template-columns: minmax(720px, 1fr) 340px !important; + grid-template-columns: minmax(660px, 1fr) 300px !important; align-items: start !important; - gap: 24px !important; + gap: 14px !important; width: 100% !important; max-width: none !important; min-height: calc(100vh - 90px) !important; margin: 0 !important; - padding: 24px 24px 32px !important; + padding: 14px 14px 20px !important; background: #06111d !important; } +/* + * Student pages use different local names for their main and workbench cards. + * Keep the layout density here so a page-specific card cannot reclaim the + * space reserved for the hands-on activity area. + */ +.student-training-shell > main { + min-width: 0 !important; + padding: 20px !important; + border-radius: 24px !important; +} + +.student-training-shell > main > .task-card, +.student-training-shell > main > .process-workbench, +.student-training-shell > main > .factors-workbench, +.student-training-shell > main > .workbench { + padding: 14px 16px !important; + border-radius: 20px !important; +} + +.student-training-shell .step-card, +.student-training-shell .process-step-card, +.student-training-shell .factor-step-card { + padding: 16px !important; + border-radius: 20px !important; +} + +.student-training-shell .task-header { + margin-bottom: 16px !important; +} + .student-training-shell .task-header .task-title { font-size: 30px !important; line-height: 1.3 !important; @@ -76,7 +106,7 @@ justify-content: space-between !important; gap: 6px !important; width: 100% !important; - margin: 0 0 32px !important; + margin: 0 0 18px !important; padding: 6px !important; border: 1px solid var(--student-panel-border) !important; border-radius: var(--student-radius-pill) !important; @@ -443,7 +473,7 @@ display: flex !important; align-items: flex-start !important; justify-content: space-between !important; - gap: 18px !important; + gap: 10px !important; } .student-training-shell .workbench-actions { @@ -453,7 +483,7 @@ align-items: center !important; justify-content: flex-end !important; align-self: flex-start !important; - gap: 10px !important; + gap: 8px !important; min-width: max-content !important; margin: 0 0 0 auto !important; padding: 0 !important; @@ -467,16 +497,16 @@ flex: 0 0 auto !important; min-width: 0 !important; width: auto !important; - height: 40px !important; - min-height: 40px !important; - max-height: 40px !important; + height: 34px !important; + min-height: 34px !important; + max-height: 34px !important; margin: 0 !important; - padding: 0 18px !important; + padding: 0 12px !important; border: 1px solid rgba(99, 217, 255, 0.55) !important; border-radius: var(--student-radius-pill) !important; color: #e9fbff !important; background: rgba(17, 126, 178, 0.36) !important; - font-size: 14px !important; + font-size: 13px !important; font-weight: 800 !important; line-height: 1 !important; white-space: nowrap !important; @@ -527,10 +557,13 @@ .legacy-training-page { display: grid !important; - grid-template-columns: minmax(0, 1fr) 360px !important; - gap: 24px !important; + box-sizing: border-box !important; + grid-template-columns: minmax(0, 1fr) 300px !important; + gap: 14px !important; align-items: start !important; width: 100% !important; + padding: 14px !important; + background: #06111d !important; } .legacy-training-page > :not(.training-ai-sidebar) { @@ -553,22 +586,22 @@ } .legacy-training-page .top_item { - margin-bottom: 18px !important; + margin-bottom: 12px !important; } .legacy-training-page .top_item .el-button { display: inline-flex !important; align-items: center !important; justify-content: center !important; - min-width: 150px !important; - height: 42px !important; - padding: 0 18px !important; + min-width: 136px !important; + height: 36px !important; + padding: 0 12px !important; border: 1px solid #2c83aa !important; border-radius: 6px !important; color: #e9fbff !important; background: rgba(13, 64, 88, 0.62) !important; box-shadow: none !important; - font-size: 15px !important; + font-size: 14px !important; font-weight: 800 !important; line-height: 1 !important; letter-spacing: 0 !important; @@ -628,6 +661,10 @@ padding: 14px 14px 18px !important; } + .legacy-training-page { + padding: 12px !important; + } + .student-training-shell .workbench-head { flex-direction: column !important; } diff --git a/src/views/components/TrainingAiSidebar.vue b/src/views/components/TrainingAiSidebar.vue index d70250b..15657dc 100644 --- a/src/views/components/TrainingAiSidebar.vue +++ b/src/views/components/TrainingAiSidebar.vue @@ -163,12 +163,12 @@ async function requestEvaluation() { .training-ai-sidebar { display: flex; flex-direction: column; - gap: 22px; + gap: 16px; align-self: start; min-height: calc(100vh - 106px); - padding: 10px; + padding: 8px; border: 1px solid rgba(0, 174, 255, 0.22); - border-radius: 26px; + border-radius: 22px; background: linear-gradient(180deg, rgba(13, 30, 44, 0.92), rgba(5, 15, 26, 0.96)), radial-gradient(circle at 50% 0%, rgba(0, 220, 255, 0.16), transparent 42%); diff --git a/src/views/components/TrainingMaterialButton.vue b/src/views/components/TrainingMaterialButton.vue index b087ba3..a56d29f 100644 --- a/src/views/components/TrainingMaterialButton.vue +++ b/src/views/components/TrainingMaterialButton.vue @@ -47,5 +47,5 @@ function handleDownload() { diff --git a/src/views/components/TrainingTaskBrief.vue b/src/views/components/TrainingTaskBrief.vue index 92bfb9d..ae1052b 100644 --- a/src/views/components/TrainingTaskBrief.vue +++ b/src/views/components/TrainingTaskBrief.vue @@ -131,10 +131,10 @@ function normalizeGoals(value) { diff --git a/src/views/foundation/product-development-process.vue b/src/views/foundation/product-development-process.vue index a14816c..35acece 100644 --- a/src/views/foundation/product-development-process.vue +++ b/src/views/foundation/product-development-process.vue @@ -582,25 +582,25 @@ function exportOutcome() { .process-core { min-width: 0; - padding: 28px; + padding: 20px; border: 1px solid rgba(0, 180, 255, 0.25); - border-radius: 36px; + border-radius: 24px; background: rgba(8, 18, 28, 0.6); box-shadow: 0 22px 48px rgba(0, 0, 0, 0.22), inset 0 1px 0 rgba(169, 229, 255, 0.08); backdrop-filter: blur(8px); } -.task-header { margin-bottom: 24px; } +.task-header { margin-bottom: 16px; } .task-badge { display: inline-flex; align-items: center; gap: 7px; margin-bottom: 12px; padding: 4px 14px; border: 1px solid rgba(0, 170, 255, 0.5); border-radius: 30px; color: #d6f6ff; background: rgba(0, 170, 255, 0.2); font-size: 12px; font-weight: 700; } .task-title { margin: 0; color: transparent; background: linear-gradient(135deg, #fff, #8bcbff); -webkit-background-clip: text; background-clip: text; font-size: 30px; font-weight: 900; line-height: 1.3; } -.process-workbench { min-width: 0; margin: 0; padding: 18px 22px; border: 1px solid rgba(82, 174, 226, 0.42); border-radius: 26px; background: rgba(0, 25, 42, 0.64); } +.process-workbench { min-width: 0; margin: 0; padding: 14px 16px; border: 1px solid rgba(82, 174, 226, 0.42); border-radius: 20px; background: rgba(0, 25, 42, 0.64); } .process-workbench::before { display: none !important; content: none !important; } .workbench-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: 22px; padding-bottom: 18px; border-bottom: 1px solid rgba(0, 180, 255, 0.2); } .workbench-actions { display: flex; flex: 0 0 auto; flex-wrap: wrap; justify-content: flex-end; gap: 10px; } .outline-action, .btn-nav { display: inline-flex; align-items: center; justify-content: center; gap: 8px; font-family: inherit; cursor: pointer; } .outline-action, .workbench-actions :deep(.material-download) { min-height: 40px; padding: 0 18px; border: 1px solid rgba(99, 217, 255, 0.55); border-radius: 999px; color: #e9fbff; background: rgba(17, 126, 178, 0.36); box-shadow: none; font-size: 14px; font-weight: 800; line-height: 1; } .outline-action:hover, .workbench-actions :deep(.material-download:hover) { border-color: rgba(116, 235, 255, 0.85); color: #fff; background: rgba(18, 146, 204, 0.5); transform: translateY(-1px); } -.workbench-head--actions-only { justify-content: flex-end; margin-bottom: 18px; padding-bottom: 0; border-bottom: 0; } +.workbench-head--actions-only { justify-content: flex-end; margin-bottom: 10px; padding-bottom: 0; border-bottom: 0; } .step-indicator { position: relative; display: flex; align-items: center; gap: 0; width: calc(100% + 24px); margin: 0 -12px 32px; padding: 18px 22px; overflow-x: auto; border: 1px solid rgba(0, 180, 255, 0.3); border-radius: 18px; background: linear-gradient(90deg, rgba(6, 42, 61, 0.58), rgba(1, 18, 31, 0.42)), rgba(0, 0, 0, 0.3); box-shadow: inset 0 1px 0 rgba(145, 233, 255, 0.08); scrollbar-width: thin; } .step-tab { display: flex; flex: 1 0 145px; align-items: center; min-width: 0; min-height: 66px; padding: 8px 10px; border: 1px solid rgba(68, 141, 177, 0.35); border-radius: 8px; color: #9ab3d0; background: rgba(3, 27, 43, 0.52); box-shadow: inset 0 1px 0 rgba(135, 221, 255, 0.04); font-family: inherit; cursor: pointer; text-align: left; transition: transform .2s ease, color .2s ease, border-color .2s ease, background .2s ease, box-shadow .2s ease; } @@ -608,7 +608,7 @@ function exportOutcome() { .step-tab.active { border-color: rgba(89, 226, 255, .85); color: #fff; background: linear-gradient(115deg, rgba(4, 109, 155, .78), rgba(5, 62, 102, .64)); box-shadow: 0 0 18px rgba(24, 183, 235, .22), inset 0 1px 0 rgba(176, 246, 255, .2); }.step-tab.active .step-marker { border-color: #72e8ff; color: #fff; background: linear-gradient(135deg, #08b7e8, #1375d0); box-shadow: 0 0 0 5px rgba(25, 179, 237, .14), 0 0 18px rgba(46, 208, 255, .58); }.step-tab.complete { color: #dffbff; }.step-tab.complete .step-marker { border-color: rgba(84, 230, 221, .85); color: #062b37; background: #6ef0e1; box-shadow: 0 0 12px rgba(79, 231, 218, .35); } .step-marker { display: inline-flex; flex: 0 0 38px; align-items: center; justify-content: center; width: 38px; height: 38px; border: 1px solid rgba(101, 176, 208, .6); border-radius: 50%; color: #a6c4d9; background: rgba(3, 25, 40, .8); font-size: 12px; font-weight: 900; letter-spacing: .04em; }.step-copy { display: grid; min-width: 0; gap: 4px; margin-left: 10px; }.step-name { display: -webkit-box; overflow: hidden; color: inherit; font-size: 14px; font-weight: 800; line-height: 1.25; text-overflow: ellipsis; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }.step-action { color: #66b6d4; font-size: 10px; font-weight: 800; letter-spacing: .06em; line-height: 1; }.step-connector { position: relative; flex: 0 1 40px; min-width: 18px; height: 2px; margin: 0 8px; background: rgba(98, 169, 201, .34); }.step-connector::after { position: absolute; top: 50%; right: -1px; width: 6px; height: 6px; border-top: 1px solid #73b1ce; border-right: 1px solid #73b1ce; content: ''; transform: translateY(-50%) rotate(45deg); }.step-tab.complete + .step-connector { background: linear-gradient(90deg, #6ef0e1, #1cb8e9); box-shadow: 0 0 9px rgba(53, 218, 235, .46); }.step-tab.complete + .step-connector::after { border-color: #63e6f1; } -.process-step-card { margin: 0; padding: 18px 22px; border: 1px solid rgba(82, 174, 226, 0.42); border-radius: 26px; background: rgba(0, 25, 42, 0.64); }.step-title-row { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: 14px; }.step-title-row h3 { margin: 4px 0 0; color: #fff; font-size: 22px; font-weight: 900; line-height: 1.3; }.section-eyebrow { margin: 0; color: #71dfff; font-size: 12px; font-weight: 800; line-height: 1.4; }.step-chip { padding: 6px 12px; border: 1px solid rgba(94, 219, 255, .54); border-radius: 999px; color: #a6ebff; background: rgba(9, 104, 149, .2); font-size: 13px; font-weight: 800; } +.process-step-card { margin: 0; padding: 16px; border: 1px solid rgba(82, 174, 226, 0.42); border-radius: 20px; background: rgba(0, 25, 42, 0.64); }.step-title-row { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: 12px; }.step-title-row h3 { margin: 4px 0 0; color: #fff; font-size: 22px; font-weight: 900; line-height: 1.3; }.section-eyebrow { margin: 0; color: #71dfff; font-size: 12px; font-weight: 800; line-height: 1.4; }.step-chip { padding: 6px 12px; border: 1px solid rgba(94, 219, 255, .54); border-radius: 999px; color: #a6ebff; background: rgba(9, 104, 149, .2); font-size: 13px; font-weight: 800; } .process-intro { margin: 0 0 18px; color: #d7ecff; font-size: 15px; line-height: 1.8; }.training-table-wrap { overflow-x: auto; border: 1px solid rgba(0, 180, 255, .22); border-radius: 22px; background: rgba(1, 14, 26, .72); }.process-table { width: 100%; min-width: 920px; border-collapse: collapse; table-layout: fixed; }.process-table th { padding: 12px; border-right: 1px solid rgba(77, 167, 220, .2); color: #eaffff; background: rgba(8, 55, 82, .98); font-size: 14px; font-weight: 900; text-align: center; }.process-table td { padding: 12px; border-top: 1px solid rgba(77, 167, 220, .2); border-right: 1px solid rgba(77, 167, 220, .2); color: #d7f1ff; background: rgba(1, 14, 26, .48); vertical-align: middle; }.process-table th:last-child, .process-table td:last-child { border-right: 0; }.stage-cell { width: 17%; color: #eafaff; font-size: 14px; font-weight: 900; text-align: center; }.task-no-cell { width: 12%; color: #eafaff; font-size: 14px; font-weight: 800; text-align: center; }.task-cell { color: #cfe8f8; font-size: 14px; line-height: 1.7; text-align: center; }.select-cell { width: 23%; }.select-cell select, .schedule-table input { box-sizing: border-box; width: 100%; min-height: 38px; padding: 7px 10px; border: 1px solid rgba(45, 95, 126, .95); border-radius: 10px; outline: none; color: #eef5ff; background: #0f1a24; font: inherit; }.select-cell select:focus, .schedule-table input:focus { border-color: rgba(92, 224, 255, .7); box-shadow: 0 0 0 3px rgba(0, 174, 255, .13); }.classification-feedback { margin: 6px 0 0; color: #ff9b8f; font-size: 12px; line-height: 1.5; }.is-correct { background: rgba(55, 196, 147, .08); }.is-incorrect { background: rgba(255, 91, 82, .1); }.classification-summary { margin: 16px 0 0; color: #ffbc86; }.classification-summary.is-success { color: #6ee8b1; } .task-actions { display: flex; justify-content: center; gap: 52px; margin-top: 36px; }.btn-nav { min-width: 172px; min-height: 64px; padding: 0 28px; border: 1px solid #2c83aa; border-radius: 6px; color: #dff7ff; background: rgba(13, 64, 88, .62); box-shadow: none; font-size: 24px; font-weight: 900; line-height: 1; }.btn-submit { border-color: #2c83aa; background: rgba(13, 64, 88, .62); }.task-actions .btn-nav .el-icon { display: none; }.btn-nav:hover:not(:disabled) { border-color: #46b5e7; color: #fff; background: rgba(18, 85, 116, .78); }.btn-nav:disabled { cursor: not-allowed; opacity: .6; }.step-placeholder { display: grid; place-items: center; min-height: 280px; padding: 28px; border: 1px dashed rgba(101, 207, 239, .42); border-radius: 18px; color: #b8d9ec; background: rgba(1, 14, 26, .42); text-align: center; }.step-placeholder p { margin: 0 0 8px; color: #eaffff; font-size: 20px; font-weight: 800; } .step-warning { margin: 0 0 16px; padding: 10px 14px; border: 1px solid rgba(255, 173, 93, .62); border-radius: 10px; color: #ffd19f; background: rgba(187, 102, 27, .16); }.schedule-table { min-width: 1180px; }.schedule-table .stage-cell { width: 12%; }.schedule-table .task-no-cell { width: 8%; }.schedule-table .task-cell { width: 21%; }.classification-cell { width: 13%; color: #eafaff; font-weight: 800; text-align: center; }.schedule-table .dependency-input { min-width: 180px; }.schedule-table input:disabled { cursor: not-allowed; opacity: .6; } diff --git a/src/views/product/market-opportunity-selection.vue b/src/views/product/market-opportunity-selection.vue index 46e2da7..7b78065 100644 --- a/src/views/product/market-opportunity-selection.vue +++ b/src/views/product/market-opportunity-selection.vue @@ -141,7 +141,7 @@ function exportOutcome() { const blob = new Blob([JSON.stringify({ taskName: tas From 2c8615c47e3a7bcad430492de0a2327910dd790a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E6=B2=85?= <907037276@qq.com> Date: Tue, 25 Aug 2026 17:26:21 +0800 Subject: [PATCH 09/11] fix: polish product pricing training layout --- src/views/product/price.vue | 548 ++++++++++++------ ...uct-pricing-step-indicator.static.test.cjs | 32 + 2 files changed, 392 insertions(+), 188 deletions(-) create mode 100644 tests/product-pricing-step-indicator.static.test.cjs diff --git a/src/views/product/price.vue b/src/views/product/price.vue index 567e8f6..9710647 100644 --- a/src/views/product/price.vue +++ b/src/views/product/price.vue @@ -14,41 +14,28 @@ />
- -