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( + /