diff --git a/.env.development b/.env.development
index a1bde1c..566ed7a 100644
--- a/.env.development
+++ b/.env.development
@@ -1,14 +1,14 @@
-###
- # @Author: qinzhenpen qzp1807@126.com
- # @Date: 2024-08-28 17:21:18
- # @LastEditors: qinzhenpen qzp1807@126.com
- # @LastEditTime: 2025-05-21 17:07:47
- # @FilePath: \digital-marketing\.env.development
- # @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
-###
-# 页面标题
-VITE_APP_TITLE = 电商互联网产品开发实训系统
-
+###
+ # @Author: qinzhenpen qzp1807@126.com
+ # @Date: 2024-08-28 17:21:18
+ # @LastEditors: qinzhenpen qzp1807@126.com
+ # @LastEditTime: 2025-05-21 17:07:47
+ # @FilePath: \digital-marketing\.env.development
+ # @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
+###
+# 页面标题
+VITE_APP_TITLE = 电商互联网产品开发实训系统
+
# 开发环境配置
VITE_APP_ENV = 'development'
# 若依管理系统/开发环境
diff --git a/src/api/studentTrainingAnswer.js b/src/api/studentTrainingAnswer.js
index 5bdf7fe..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)
@@ -628,7 +797,7 @@ export function checkRequirementsDocumentStepOne(payload) {
export function checkMarketOpportunitySelectionStepOne(items) {
const expected = {
"search-count": "市场规模", "demand-supply-ratio": "竞争强度", "transaction-amount": "市场规模", "transaction-growth": "市场潜力",
- "category-transaction-growth": "市场潜力", "online-products": "竞争强度", "online-merchants": "竞争强度", "organic-traffic-ratio": "运营难度",
+ "category-transaction-growth": "市场潜力", "online-products": "竞争强度", "online-merchants": "竞争强度", "organic-traffic-ratio": "运营难度", "return-rate": "运营难度",
};
if (isStudentDemo()) {
const list = Array.isArray(items) ? items : [];
@@ -640,17 +809,23 @@ export function checkMarketOpportunitySelectionStepOne(items) {
}
export function checkMarketOpportunitySelectionStepTwo(payload) {
- const weights = Array.isArray(payload?.weights) ? payload.weights : [];
- const expectedIds = ["search-count", "transaction-amount", "transaction-growth", "category-transaction-growth", "demand-supply-ratio", "online-products", "online-merchants", "organic-traffic-ratio"];
- if (isStudentDemo()) {
- const ids = new Set(weights.map((item) => item?.id));
- const total = weights.reduce((sum, item) => sum + Number(item?.weight || 0), 0);
- if (weights.length !== expectedIds.length || expectedIds.some((id) => !ids.has(id)) || weights.some((item) => !Number.isInteger(Number(item?.weight)) || Number(item.weight) < 1 || Number(item.weight) > 100) || total !== 100) {
- return Promise.reject(new Error("二级指标权重需为 1 至 100 的整数,且合计必须为 100%"));
+ const primaryWeights = Array.isArray(payload?.primaryWeights) ? payload.primaryWeights : [];
+ const dimensionWeights = Array.isArray(payload?.dimensionWeights) ? payload.dimensionWeights : [];
+ const groups = ["市场规模", "市场潜力", "竞争强度", "运营难度"];
+ const expectedIds = ["search-count", "demand-supply-ratio", "transaction-amount", "transaction-growth", "category-transaction-growth", "online-products", "online-merchants", "organic-traffic-ratio", "return-rate"];
+ if (isStudentDemo()) {
+ const primaryMap = Object.fromEntries(primaryWeights.map((item) => [item?.group, Number(item?.weight)]));
+ const dimensionMap = Object.fromEntries(dimensionWeights.map((item) => [item?.id, Number(item?.weight)]));
+ const groupsByIndicator = { "search-count": "市场规模", "transaction-amount": "市场规模", "transaction-growth": "市场潜力", "category-transaction-growth": "市场潜力", "demand-supply-ratio": "竞争强度", "online-products": "竞争强度", "online-merchants": "竞争强度", "organic-traffic-ratio": "运营难度", "return-rate": "运营难度" };
+ const isWeight = (value) => Number.isInteger(value) && value >= 1 && value <= 100;
+ const validPrimary = primaryWeights.length === groups.length && groups.every((group) => isWeight(primaryMap[group])) && groups.reduce((sum, group) => sum + primaryMap[group], 0) === 100;
+ const validDimensions = dimensionWeights.length === expectedIds.length && expectedIds.every((id) => isWeight(dimensionMap[id])) && groups.every((group) => expectedIds.filter((id) => groupsByIndicator[id] === group).reduce((sum, id) => sum + dimensionMap[id], 0) === 100);
+ if (!validPrimary || !validDimensions) {
+ return Promise.reject(new Error("一级指标权重合计和各类维度内权重合计均须为 100%"));
}
return Promise.resolve({ code: 200, data: { valid: true, message: "校验通过" } });
}
- return request({ url: "/api/student-training-answers/market-opportunity-selection/step-2/validate", method: "post", data: { weights }, headers: { repeatSubmit: false } });
+ return request({ url: "/api/student-training-answers/market-opportunity-selection/step-2/validate", method: "post", data: { primaryWeights, dimensionWeights }, headers: { repeatSubmit: false } });
}
export function checkMarketOpportunitySelectionStepThree(payload) {
@@ -658,8 +833,9 @@ export function checkMarketOpportunitySelectionStepThree(payload) {
const expectedIds = ["smart-fitness-mirror", "smart-bathroom-mirror", "smart-beauty-mirror"];
if (isStudentDemo()) {
const ids = new Set(scores.map((item) => item?.id));
- if (scores.length !== expectedIds.length || expectedIds.some((id) => !ids.has(id)) || scores.some((item) => !Number.isFinite(Number(item?.score)) || Number(item.score) < 0 || Number(item.score) > 100)) {
- return Promise.reject(new Error("请为三类产品填写 0 至 100 分的综合得分"));
+ const isScore = (value) => Number.isFinite(Number(value)) && Number(value) >= 0 && Number(value) <= 100 && Math.round(Number(value) * 100) === Number(value) * 100;
+ if (scores.length !== expectedIds.length || expectedIds.some((id) => !ids.has(id)) || scores.some((item) => !isScore(item?.score))) {
+ return Promise.reject(new Error("请为三类产品填写 0 至 100 分、最多保留两位小数的综合得分"));
}
return Promise.resolve({ code: 200, data: { valid: true, message: "校验通过" } });
}
@@ -675,6 +851,30 @@ export function checkMarketOpportunitySelectionStepFour(payload) {
return request({ url: "/api/student-training-answers/market-opportunity-selection/step-4/validate", method: "post", data: { visualizationUrl }, headers: { repeatSubmit: false } });
}
+export function checkMarketOpportunitySelectionStepFive(payload) {
+ const insights = Array.isArray(payload?.insights) ? payload.insights : [];
+ const expectedIds = ["smart-fitness-mirror", "smart-bathroom-mirror", "smart-beauty-mirror"];
+ const isRate = (value) => Number.isFinite(Number(value)) && Number(value) >= 0 && Number(value) <= 100 && Math.round(Number(value) * 100) === Number(value) * 100;
+ const isFilled = (value) => String(value || "").trim().length > 0;
+ if (isStudentDemo()) {
+ const ids = new Set(insights.map((item) => item?.productId));
+ if (insights.length !== expectedIds.length || expectedIds.some((id) => !ids.has(id)) || insights.some((item) => !isRate(item?.favorableRate) || !isFilled(item?.painPoint) || !isFilled(item?.unmetNeed))) {
+ return Promise.reject(new Error("请完整填写三类产品的好评率、用户痛点和未被满足的需求;好评率为 0 至 100,最多保留两位小数"));
+ }
+ return Promise.resolve({ code: 200, data: { valid: true, message: "校验通过" } });
+ }
+ return request({ url: "/api/student-training-answers/market-opportunity-selection/step-5/validate", method: "post", data: { insights }, headers: { repeatSubmit: false } });
+}
+
+export function checkMarketOpportunitySelectionStepSix(payload) {
+ const developmentConclusion = String(payload?.developmentConclusion || "").trim();
+ if (isStudentDemo()) {
+ if (!developmentConclusion) return Promise.reject(new Error("请填写建议开发的产品及具体原因"));
+ return Promise.resolve({ code: 200, data: { valid: true, message: "校验通过" } });
+ }
+ return request({ url: "/api/student-training-answers/market-opportunity-selection/step-6/validate", method: "post", data: { developmentConclusion }, headers: { repeatSubmit: false } });
+}
+
export function checkProductDevelopmentProcessStepOne(items) {
if (isStudentDemo()) {
const results = Array.isArray(items)
diff --git a/src/api/trainingTask.js b/src/api/trainingTask.js
index fe35e08..67a0468 100644
--- a/src/api/trainingTask.js
+++ b/src/api/trainingTask.js
@@ -1,4 +1,16 @@
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;
+ window.dispatchEvent(new CustomEvent("student-task-config-loading", { detail: { taskKey, loading } }));
+}
export function listTrainingTasks(params) {
return request({
@@ -8,11 +20,73 @@ 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) {
- return request({
- url: `/api/training-tasks/key/${taskKey}`,
+ const normalizedTaskKey = String(taskKey || "").trim();
+ if (pendingTaskRequests.has(normalizedTaskKey)) {
+ return pendingTaskRequests.get(normalizedTaskKey);
+ }
+ notifyStudentTaskConfigLoading(normalizedTaskKey, true);
+ const requestPromise = request({
+ url: `/api/training-tasks/key/${normalizedTaskKey}`,
method: "get",
});
+ pendingTaskRequests.set(normalizedTaskKey, requestPromise);
+ requestPromise.then(
+ () => {
+ pendingTaskRequests.delete(normalizedTaskKey);
+ notifyStudentTaskConfigLoading(normalizedTaskKey, false);
+ },
+ () => {
+ pendingTaskRequests.delete(normalizedTaskKey);
+ notifyStudentTaskConfigLoading(normalizedTaskKey, false);
+ }
+ );
+ return requestPromise;
}
export function updateTrainingTask(id, data) {
diff --git a/src/assets/styles/student-training.scss b/src/assets/styles/student-training.scss
index 1638849..4789c84 100644
--- a/src/assets/styles/student-training.scss
+++ b/src/assets/styles/student-training.scss
@@ -48,14 +48,14 @@
#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;
/* 应用实训页专用字体栈 + 数字等宽对齐 */
font-family: var(--student-font-base) !important;
@@ -64,6 +64,36 @@
font-variant-numeric: tabular-nums !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: var(--student-fs-display) !important; /* 20px 小三 */
line-height: 1.4 !important;
@@ -117,7 +147,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;
@@ -484,7 +514,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 {
@@ -494,7 +524,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;
@@ -508,16 +538,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;
@@ -568,10 +598,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) {
@@ -594,16 +627,16 @@
}
.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;
@@ -669,6 +702,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/layout/components/AppMain.vue b/src/layout/components/AppMain.vue
index 378e31d..626ffe9 100644
--- a/src/layout/components/AppMain.vue
+++ b/src/layout/components/AppMain.vue
@@ -11,10 +11,19 @@
演示模式:{{ demoClassName }}
-
+
-
+
+
+
+ 正在加载当前实训配置
+
+
+
+
+
+
@@ -33,6 +42,8 @@ const tagsViewStore = useTagsViewStore();
const route = useRoute();
const router = useRouter();
const role = ref(JSON.parse(getUserInfo()).roleId);
+const studentTaskLoading = ref(false);
+const currentStudentTaskKey = computed(() => Number(role.value) === 4 ? String(route.meta?.pageKey || "") : "");
const isDemo = computed(() => isStudentDemo());
const demoClassName = computed(() => getStudentDemoSession()?.userInfo?.className || "教学班");
function exitDemo() {
@@ -48,6 +59,16 @@ const mainClass = computed(() => {
if (Number(role.value) === 3) return "teacher-main";
return "app-main2";
});
+function handleStudentTaskConfigLoading(event) {
+ const detail = event?.detail || {};
+ if (!currentStudentTaskKey.value || detail.taskKey !== currentStudentTaskKey.value) return;
+ studentTaskLoading.value = Boolean(detail.loading);
+}
+watch(currentStudentTaskKey, (taskKey) => {
+ studentTaskLoading.value = Boolean(taskKey);
+}, { immediate: true });
+onMounted(() => window.addEventListener("student-task-config-loading", handleStudentTaskConfigLoading));
+onBeforeUnmount(() => window.removeEventListener("student-task-config-loading", handleStudentTaskConfigLoading));
const module = ["市场需求挖掘", "产品规划", "产品投放测试", "供应渠道管理", "产品评估与考核"];
// 全局监听路由进入和离开
onBeforeRouteUpdate((to, from) => {
@@ -105,7 +126,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 +171,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();
});
@@ -166,6 +190,7 @@ router.beforeEach(async (to, from, next) => {
background-size: 100% 100%;
}
.student-demo-banner{position:sticky;top:0;z-index:30;display:flex;justify-content:space-between;padding:8px 18px;color:#fff;background:#b45309}.student-demo-banner button{border:0;border-radius:4px;padding:3px 10px;cursor:pointer}
+.student-task-loading{position:fixed;inset:50px 0 0;z-index:90;display:grid;place-items:start center;padding-top:96px;background:linear-gradient(135deg,rgba(2,15,26,.985),rgba(4,31,47,.985));}.student-task-loading__panel{display:grid;gap:18px;width:min(820px,calc(100vw - 72px));padding:32px;border:1px solid rgba(66,201,250,.34);border-radius:18px;background:rgba(4,27,42,.8);box-shadow:0 22px 52px rgba(0,0,0,.32)}.student-task-loading__label{color:#bdefff;font-weight:700;letter-spacing:.04em}.student-task-loading__line{display:block;height:18px;border-radius:99px;background:linear-gradient(90deg,rgba(26,107,143,.32),rgba(88,208,255,.58),rgba(26,107,143,.32));background-size:200% 100%;animation:student-task-loading-shimmer 1.2s linear infinite}.student-task-loading__line--title{width:42%;height:30px}.student-task-loading__line--short{width:68%}@keyframes student-task-loading-shimmer{to{background-position:-200% 0}}
.app-main2 {
min-height: calc(100vh - 50px);
width: 100%;
diff --git a/src/layout/components/Navbar.vue b/src/layout/components/Navbar.vue
index afa79bf..767c884 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) {
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
diff --git a/src/views/assessment/optimization.vue b/src/views/assessment/optimization.vue
index 9a48692..067e78e 100644
--- a/src/views/assessment/optimization.vue
+++ b/src/views/assessment/optimization.vue
@@ -238,11 +238,17 @@ async function persistProgress(saveAction = "SAVE") { await saveStudentTrainingA
function addRow() { if (categoryRows.value.length >= MAX_ROWS) return; categoryRows.value.push(createRow()); }
function removeLastRow() { categoryRows.value.pop(); }
async function validateStepOne() {
+ if (!categoryRows.value.length) {
+ ElMessage.warning("请至少填写一行品类数据");
+ return false;
+ }
try {
const response = await validateCategoryOptimizationStepOne({ rows: categoryRows.value });
if (response?.data?.valid === false) throw new Error(response.data.message || "数据校验未通过");
return true;
} catch (error) {
+ // 请求层已经展示了后端 400 返回的业务提示,避免再叠加一条英文 Axios 错误。
+ if (error?.response?.status === 400) return false;
ElMessage.error(error?.message || error?.msg || "数据校验未通过,请检查填写内容");
return false;
}
diff --git a/src/views/components/TrainingAiSidebar.vue b/src/views/components/TrainingAiSidebar.vue
index 7731398..d7d2d9a 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 4aa9252..3f4b672 100644
--- a/src/views/components/TrainingMaterialButton.vue
+++ b/src/views/components/TrainingMaterialButton.vue
@@ -25,24 +25,44 @@ const material = computed(() => {
const name = String(props.materialName || "").trim();
const url = String(props.materialUrl || "").trim();
if (!name || !url || (name === "案例资料.rar" && url === "/file/example.rar")) return null;
- return { name, url };
+ return { name, url: normalizeMaterialUrl(url) };
});
const extension = computed(() => String(material.value?.name || material.value?.url || "").split(/[?#]/)[0].split(".").pop().toLowerCase());
const isImage = computed(() => ["jpg", "jpeg", "png", "gif", "webp", "bmp", "svg"].includes(extension.value));
const isPdf = computed(() => extension.value === "pdf");
const previewable = computed(() => isImage.value || isPdf.value);
-function handleDownload() {
+function normalizeMaterialUrl(rawUrl) {
+ const url = String(rawUrl || "").trim();
+ if (!url) return "";
+ try {
+ const parsed = new URL(url);
+ if (["localhost", "127.0.0.1", "::1"].includes(parsed.hostname) && parsed.pathname.startsWith("/file/")) {
+ return `${parsed.pathname}${parsed.search}${parsed.hash}`;
+ }
+ } catch {
+ // Relative file paths are already resolved through the current deployment or Vite proxy.
+ }
+ return url;
+}
+
+async function handleDownload() {
if (!material.value?.url) { ElMessage.warning("暂无案例资料"); return; }
- const link = document.createElement("a");
- link.href = material.value.url;
- link.target = "_blank";
- link.rel = "noopener";
- link.download = material.value.name;
- document.body.appendChild(link);
- link.click();
- document.body.removeChild(link);
- if (!previewable.value) ElMessage.info("该文件类型暂不支持在线预览,已为您打开下载。");
+ try {
+ const response = await fetch(material.value.url);
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
+ const objectUrl = URL.createObjectURL(await response.blob());
+ const link = document.createElement("a");
+ link.href = objectUrl;
+ link.download = material.value.name;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ URL.revokeObjectURL(objectUrl);
+ } catch (error) {
+ console.warn("Case material download failed", error);
+ ElMessage.error("案例资料文件不存在或暂不可下载,请联系管理员重新上传。");
+ }
}
diff --git a/src/views/components/TrainingTaskBrief.vue b/src/views/components/TrainingTaskBrief.vue
index 692ca67..8216839 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/demand/business-feasibility.vue b/src/views/demand/business-feasibility.vue
index 4111851..60d4ba7 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 deae5c3..25ed88c 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 e99e164..53967be 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 cbf5b08..186b89d 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 7cd50b2..733f5d6 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 bd0bdf6..3cf6d7f 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 39752ec..8a87f59 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 b1d29ec..86be196 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 2dd90e8..fdd36e3 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 3ef549e..a6b9256 100644
--- a/src/views/foundation/product-development-factors.vue
+++ b/src/views/foundation/product-development-factors.vue
@@ -139,6 +139,7 @@
diff --git a/tests/product-pricing-step-indicator.static.test.cjs b/tests/product-pricing-step-indicator.static.test.cjs
new file mode 100644
index 0000000..97a31a1
--- /dev/null
+++ b/tests/product-pricing-step-indicator.static.test.cjs
@@ -0,0 +1,36 @@
+const assert = require("node:assert/strict");
+const fs = require("node:fs");
+const path = require("node:path");
+
+const page = fs.readFileSync(
+ path.resolve(__dirname, "../src/views/product/price.vue"),
+ "utf8",
+);
+const stepper = page.match(
+ /