refactor: 实训系统多模块优化与功能完善

1.  重构实训步骤解析工具,统一数组解析逻辑
2.  优化侧边栏与任务卡片样式,调整间距与圆角
3.  新增实训任务缓存机制,优化页面加载体验
4.  完善表单校验逻辑,新增定价模块后端校验接口
5.  修复权限路由加载逻辑,新增加载状态提示
6.  优化案例资料下载功能,修复本地URL处理与错误提示
7.  新增实训页面单元测试,补充定价步骤指示器校验
8.  调整全局布局样式,优化移动端适配与间距
dev-QQq
qinzhenpen 5 days ago
commit 85dc0b8099

@ -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)

@ -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) {

@ -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;
}

@ -11,10 +11,19 @@
<div v-if="isDemo" class="student-demo-banner">演示模式:{{ demoClassName }}<button @click="exitDemo">退</button></div>
<router-view v-slot="{ Component, route }">
<keep-alive :include="tagsViewStore.cachedViews">
<component v-if="!route.meta.link" :is="Component" :key="route.path" />
<component v-if="!route.meta.link" v-show="!studentTaskLoading" :is="Component" :key="route.path" />
</keep-alive>
</router-view>
<DynamicTrainingStepNavigator v-if="Number(role) === 4 && route.meta?.pageKey" :task-key="route.meta.pageKey" />
<DynamicTrainingStepNavigator v-if="Number(role) === 4 && route.meta?.pageKey" v-show="!studentTaskLoading" :task-key="route.meta.pageKey" />
<div v-if="studentTaskLoading" class="student-task-loading" role="status" aria-live="polite">
<div class="student-task-loading__panel">
<span class="student-task-loading__label">正在加载当前实训配置</span>
<span class="student-task-loading__line student-task-loading__line--title"></span>
<span class="student-task-loading__line"></span>
<span class="student-task-loading__line"></span>
<span class="student-task-loading__line student-task-loading__line--short"></span>
</div>
</div>
<iframe-toggle />
</section>
</template>
@ -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,20 +171,8 @@ 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%;

@ -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,12 +289,18 @@ 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) {
if (!cachedTasks.length) {
studentTopTask.value = null;
}
}
}
//
const getUserInfo = () => {

@ -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,12 +469,18 @@ 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) {
if (!cachedTasks.length) {
studentTaskSections.value = [];
}
}
}
function groupAndSortModules(modules) {
const grouped = modules.reduce((acc, item) => {

@ -39,6 +39,23 @@ router.beforeEach((to, from, next) => {
}
} 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,11 +73,16 @@ 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 {

@ -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;
}

@ -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%);

@ -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; }
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 = material.value.url;
link.target = "_blank";
link.rel = "noopener";
link.href = objectUrl;
link.download = material.value.name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
if (!previewable.value) ElMessage.info("该文件类型暂不支持在线预览,已为您打开下载。");
URL.revokeObjectURL(objectUrl);
} catch (error) {
console.warn("Case material download failed", error);
ElMessage.error("案例资料文件不存在或暂不可下载,请联系管理员重新上传。");
}
}
</script>

@ -131,10 +131,10 @@ function normalizeGoals(value) {
<style lang="scss" scoped>
.training-task-brief {
margin-bottom: 28px;
padding: 16px 20px;
margin-bottom: 14px;
padding: 12px 16px;
border: 1px solid #2d5f7e;
border-radius: 24px;
border-radius: 20px;
background: rgba(0, 35, 55, 0.6);
box-shadow: none;
}
@ -143,7 +143,7 @@ function normalizeGoals(value) {
display: flex;
align-items: stretch;
gap: 10px;
margin-bottom: 14px;
margin-bottom: 10px;
}
.brief-tabs {
@ -233,8 +233,8 @@ function normalizeGoals(value) {
}
.brief-scroll {
max-height: 178px;
min-height: 126px;
max-height: 160px;
min-height: 110px;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
@ -243,7 +243,7 @@ function normalizeGoals(value) {
.brief-content {
margin: 0;
padding: 14px 16px 16px;
padding: 10px 16px 12px;
color: #d9efff;
font-size: var(--student-fs-body);
line-height: 1.85;
@ -254,12 +254,12 @@ function normalizeGoals(value) {
list-style: none;
li {
padding: 8px 0 8px 12px;
padding: 5px 0 5px 12px;
border-left: 2px solid rgba(105, 221, 255, 0.5);
}
li + li {
margin-top: 4px;
margin-top: 2px;
}
}
@ -296,7 +296,7 @@ function normalizeGoals(value) {
}
.brief-scroll {
height: 160px;
height: 140px;
}
}
</style>

@ -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);

@ -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() }; }

@ -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(() => [

@ -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(() =>

@ -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) => {

@ -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] || "");

@ -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) => {

@ -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; });

@ -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(() =>

@ -139,6 +139,7 @@
<script setup>
import { Download, Operation, RefreshLeft } from "@element-plus/icons-vue";
import { getTrainingTaskByKey } from "@/api/trainingTask";
import { parseTrainingArray } from "@/views/training/taskKeyMap";
import { checkProductDevelopmentFactorsStepOne, checkProductDevelopmentFactorsStepTwo, checkProductDevelopmentFactorsStepThree, checkProductDevelopmentFactorsStepFour, getStudentTrainingAnswer, saveStudentTrainingAnswer } from "@/api/studentTrainingAnswer";
import useUserStore from "@/store/modules/user";
import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue";
@ -175,7 +176,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, []).slice(0, 4));
const trainingSteps = computed(() => parseTrainingArray(taskConfig.value?.steps, []).slice(0, 4));
const STEP_ONE_FACTOR_ROWS = [
{

@ -166,6 +166,7 @@ import { Download, Operation, RefreshLeft, Upload } from "@element-plus/icons-vu
import * as XLSX from "xlsx";
import { checkProductDevelopmentProcessStepOne, checkProductDevelopmentProcessStepTwo, checkProductDevelopmentProcessStepThree, checkProductDevelopmentProcessStepFour, getStudentTrainingAnswer, saveStudentTrainingAnswer, uploadStudentTrainingFile } 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";
@ -206,7 +207,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 feedbackByTaskNo = computed(() => Object.fromEntries((checkResult.value?.items || []).map((item) => [item.taskNo, item])));
const hasCompletedStepOne = computed(() => stepOnePassed.value);
const progressItems = computed(() => [
@ -581,9 +582,9 @@ function exportOutcome() {
.process-core {
min-width: 0;
padding: 28px;
padding: 20px;
border: 1px solid rgba(0, 180, 255, 0.25);
border-radius: 36px;
border-radius: 24px;
background: rgba(8, 18, 28, 0.6);
box-shadow: 0 22px 48px rgba(0, 0, 0, 0.22), inset 0 1px 0 rgba(169, 229, 255, 0.08);
backdrop-filter: blur(8px);
@ -599,7 +600,7 @@ function exportOutcome() {
.outline-action, .btn-nav { display: inline-flex; align-items: center; justify-content: center; gap: 8px; font-family: inherit; cursor: pointer; }
.outline-action, .workbench-actions :deep(.material-download) { min-height: 40px; padding: 0 18px; border: 1px solid rgba(99, 217, 255, 0.55); border-radius: 999px; color: #e9fbff; background: rgba(17, 126, 178, 0.36); box-shadow: none; font-size: var(--student-fs-button); font-weight: 800; line-height: 1; }
.outline-action:hover, .workbench-actions :deep(.material-download:hover) { border-color: rgba(116, 235, 255, 0.85); color: #fff; background: rgba(18, 146, 204, 0.5); transform: translateY(-1px); }
.workbench-head--actions-only { justify-content: flex-end; margin-bottom: 18px; padding-bottom: 0; border-bottom: 0; }
.workbench-head--actions-only { justify-content: flex-end; margin-bottom: 10px; padding-bottom: 0; border-bottom: 0; }
.step-indicator { position: relative; display: flex; align-items: center; gap: 0; width: calc(100% + 24px); margin: 0 -12px 32px; padding: 18px 22px; overflow-x: auto; border: 1px solid rgba(0, 180, 255, 0.3); border-radius: 18px; background: linear-gradient(90deg, rgba(6, 42, 61, 0.58), rgba(1, 18, 31, 0.42)), rgba(0, 0, 0, 0.3); box-shadow: inset 0 1px 0 rgba(145, 233, 255, 0.08); scrollbar-width: thin; }
.step-tab { display: flex; flex: 1 0 145px; align-items: center; min-width: 0; min-height: 66px; padding: 8px 10px; border: 1px solid rgba(68, 141, 177, 0.35); border-radius: 8px; color: #9ab3d0; background: rgba(3, 27, 43, 0.52); box-shadow: inset 0 1px 0 rgba(135, 221, 255, 0.04); font-family: inherit; cursor: pointer; text-align: left; transition: transform .2s ease, color .2s ease, border-color .2s ease, background .2s ease, box-shadow .2s ease; }

@ -218,6 +218,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";
const TASK_KEY = "market-opportunity-selection";
@ -306,7 +307,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 steps = computed(() =>
flowSteps.value.map((name, index) => ({
no: index + 1,

@ -10,23 +10,23 @@
<template v-if="currentStep === 1">
<div class="step-heading"><div><p>Step 01</p><h2>{{ trainingSteps[0] }}</h2></div><em>指标分类练习</em></div>
<p class="intro">请对实训数据表中的各项指标按照市场规模市场潜力竞争强度运营难度 4 个类别进行分类</p>
<div class="table-wrap"><table><thead><tr><th>序号</th><th>指标</th><th>分类</th></tr></thead><tbody><tr v-for="(item, index) in indicators" :key="item.id"><td>{{ index + 1 }}</td><td class="metric">{{ item.name }}</td><td><select v-model="answers[item.id]" :aria-label="`${item.name} 的分类`"><option value="">请选择</option><option v-for="category in categories" :key="category" :value="category">{{ category }}</option></select></td></tr></tbody></table></div>
<div class="table-wrap"><table><thead><tr><th>序号</th><th>指标</th><th>分类</th></tr></thead><tbody><tr v-for="(item, index) in indicators" :key="item.id"><td>{{ index + 1 }}</td><td class="metric">{{ item.name }}</td><td><select v-model="answers[item.id]" :class="{ 'input-error': stepOneCheckResults[item.id] && !stepOneCheckResults[item.id].correct }" :aria-label="`${item.name} 的分类`" @change="clearStepOneCheck(item.id)"><option value="">请选择</option><option v-for="category in categories" :key="category" :value="category">{{ category }}</option></select><small v-if="stepOneCheckResults[item.id] && !stepOneCheckResults[item.id].correct" class="classification-error">{{ stepOneCheckResults[item.id].message }}</small></td></tr></tbody></table></div>
<div class="step-actions"><button class="btn primary" type="button" :disabled="saving" @click="checkAndContinue"> 2 </button><button class="btn" type="button" :disabled="saving" @click="resetStepOne"><el-icon><RefreshLeft /></el-icon> </button></div>
</template>
<template v-else-if="currentStep === 2">
<div class="step-heading"><div><p>Step 02</p><h2>{{ trainingSteps[1] }}</h2></div><em>指标权重设定</em></div>
<p class="intro">根据案例信息为各二级指标设定权重系统将自动汇总一级指标权重</p>
<p class="intro">1 步分类结果已自动带入请填写四类一级指标权重和各维度内二级指标权重系统将自动计算对应总权重</p>
<p v-if="!stepOnePassed" class="warning"> 1 </p>
<div class="weight-summary"><span>权重合计</span><strong :class="{ valid: totalWeight === 100 }">{{ totalWeight }}%</strong><small>每项建议填写 1100合计必须为 100%</small></div>
<div class="table-wrap"><table class="weight-table"><thead><tr><th>一级指标</th><th>二级指标</th><th>权重%</th></tr></thead><tbody><tr v-for="item in indicators" :key="item.id"><td class="first-level">{{ item.group }}{{ groupWeight(item.group) }}%</td><td class="metric">{{ item.name }}</td><td><input v-model.number="weights[item.id]" :disabled="!stepOnePassed" type="number" min="1" max="100" step="1" :aria-label="`${item.name} 的权重`" /><span class="percent">%</span></td></tr></tbody></table></div>
<div class="weight-summary"><span>一级指标权重合计</span><strong :class="{ valid: primaryWeightTotal === 100 }">{{ primaryWeightTotal }}%</strong><span>对应总权重合计</span><strong :class="{ valid: correspondingWeightTotal === 100 }">{{ correspondingWeightTotal }}%</strong><small>一级权重合计每类维度内权重合计对应总权重均须为 100%</small></div>
<div class="table-wrap"><table class="weight-table"><thead><tr><th>一级指标</th><th>权重占比%</th><th>二级指标</th><th>维度内权重%</th><th>对应总权重</th></tr></thead><tbody><template v-for="group in indicatorGroups" :key="group.name"><tr v-for="(item, index) in group.items" :key="item.id"><td v-if="index === 0" :rowspan="group.items.length" class="first-level">{{ group.name }}</td><td v-if="index === 0" :rowspan="group.items.length" class="primary-weight"><input v-model.number="primaryWeights[group.name]" :disabled="!stepOnePassed" type="number" min="1" max="100" step="1" :aria-label="`${group.name} 的一级指标权重`" /><span class="percent">%</span><small>建议 {{ group.suggestion }}</small></td><td class="metric">{{ item.name }}</td><td><input v-model.number="weights[item.id]" :disabled="!stepOnePassed" type="number" min="1" max="100" step="1" :aria-label="`${item.name} 的维度内权重`" /><span class="percent">%</span><small>建议 {{ item.suggestion }}</small></td><td class="corresponding-weight">{{ formatWeight(correspondingWeight(item)) }}%</td></tr></template></tbody></table></div>
<div class="step-actions"><button class="btn primary" type="button" :disabled="saving || !stepOnePassed" @click="saveAndGoToStepThree"> 3 </button><button class="btn" type="button" :disabled="saving || !stepOnePassed" @click="resetStepTwo"><el-icon><RefreshLeft /></el-icon> </button></div>
</template>
<template v-else-if="currentStep === 3">
<div class="step-heading"><div><p>Step 03</p><h2>{{ trainingSteps[2] }}</h2></div><em>加权评分计算</em></div>
<p class="intro">请根据数据表及第 2 步权重使用 Min-Max 归一法计算各二级指标得分再采用加权评分法填写产品综合得分</p>
<p class="intro">通过 Min-Max 归一法计算各二级指标得分采用加权评分法计算产品品类综合得分</p>
<p v-if="!stepTwoPassed" class="warning"> 2 </p>
<div class="score-table-wrap"><table class="score-table"><thead><tr><th>序号</th><th>产品品类</th><th>综合得分</th></tr></thead><tbody><tr v-for="(product, index) in products" :key="product.id"><td>{{ index + 1 }}</td><td class="metric">{{ product.name }}</td><td><input v-model.number="scores[product.id]" :disabled="!stepTwoPassed" type="number" min="0" max="100" step="0.01" :aria-label="`${product.name} 的综合得分`" /><span class="percent"></span></td></tr></tbody></table></div>
<div class="calculation-guide"><div><h3>推荐工具及方法如下自行选择</h3><p>1. Excel/WPS 表格自行操作计算可参考实训操作说明完成</p><p>2. 下载 Excel 模板填写关键数据完成指标计算</p><p>3. 导出实训数据表上传到通用 AI 大模型参考提示词完成计算</p><p>请根据数据表及各指标权重设定表通过 Min-Max 归一法计算各二级指标得分采用加权评分法计算综合得分</p></div><div class="download-stack"><button class="btn" type="button" @click="downloadInstructions"></button><button class="btn primary" type="button" @click="downloadTemplate"></button></div></div>
<div class="calculation-guide"><div><h3>推荐工具及方法如下自行选择</h3><p>1. Excel/WPS 表格自行操作计算可参考实训操作说明完成</p><p>2. 下载自动化模板填写案例数据及第 2 步权重完成指标计算</p><p>3. 导出实训数据表上传到通用 AI 大模型参考提示词完成计算</p><p>先按 Min-Max 归一法和加权评分法计算品类综合得分如案例资料提供三段式时间权重则分别按第 120 2140 4160 天的权重计算最终得分</p></div><div class="download-stack"><button class="btn" type="button" @click="downloadInstructions"></button><button class="btn primary" type="button" @click="downloadTemplate"></button></div></div>
<div class="step-actions"><button class="btn primary" type="button" :disabled="saving || !stepTwoPassed" @click="saveAndGoToStepFour"> 4 </button><button class="btn" type="button" :disabled="saving || !stepTwoPassed" @click="resetStepThree"><el-icon><RefreshLeft /></el-icon> </button></div>
</template>
<template v-else-if="currentStep === 4">
@ -39,8 +39,23 @@
<p>支持 PNGJPGWEBP 格式文件不超过 5MB</p>
<div v-if="visualization.url" class="visualization-preview"><el-image :src="visualization.url" :preview-src-list="[visualization.url]" fit="cover" preview-teleported /><div><strong>{{ visualization.name || "数据可视化结果图" }}</strong><span>点击缩略图可放大查看</span><button class="text-button" type="button" @click="clearVisualization"></button></div></div>
</div>
<div class="visual-guide"><h3>推荐工具及方法如下自行选择</h3><p>1. 使用 Excel/WPS 表格计算三类产品的平均得分最高分和最低分并生成图表</p><p>2. 下载 Excel 模板填写关键数据完成指标计算和可视化</p><p>3. 导出实训数据表上传到通用 AI 大模型参考提示词生成可视化结果</p></div>
<div class="step-actions"><button class="btn primary" type="button" :disabled="saving || uploading || !stepThreePassed" @click="saveAndCompleteTraining"></button></div>
<div class="visual-guide"><div><h3>推荐工具及方法如下自行选择</h3><p>1. 使用 Excel/WPS 表格计算三类产品的平均得分最高分和最低分并生成图表</p><p>2. 下载自动化模板填写关键数据完成指标计算和可视化</p><p>3. 导出实训数据表上传到通用 AI 大模型参考提示词完成计算与图表生成</p><p>请根据数据表及各指标权重设定表使用 Min-Max 归一法与加权评分法计算产品综合得分并汇总平均分最高分和最低分</p></div><div class="download-stack"><button class="btn" type="button" @click="downloadVisualizationInstructions"></button><button class="btn primary" type="button" @click="downloadVisualizationTemplate"></button></div></div>
<div class="step-actions"><button class="btn primary" type="button" :disabled="saving || uploading || !stepThreePassed" @click="saveAndGoToStepFive"> 5 </button></div>
</template>
<template v-else-if="currentStep === 5">
<div class="step-heading"><div><p>Step 05</p><h2>{{ trainingSteps[4] }}</h2></div><em>评论数据洞察</em></div>
<p class="intro">结合本实训提供的智能健身镜智能卫浴镜和智能美妆镜三类产品评论数据分析用户的痛点及未被满足的需求</p>
<p v-if="!stepFourPassed" class="warning"> 4 </p>
<div class="table-wrap"><table class="insight-table"><thead><tr><th>品类</th><th>好评率%</th><th>用户痛点</th><th>未被满足的需求</th></tr></thead><tbody><tr v-for="product in products" :key="product.id"><td class="metric">{{ product.name }}</td><td><input v-model.number="reviewInsights[product.id].favorableRate" :disabled="!stepFourPassed" type="number" min="0" max="100" step="0.01" :aria-label="`${product.name} 的好评率`" /><span class="percent">%</span></td><td><textarea v-model.trim="reviewInsights[product.id].painPoint" :disabled="!stepFourPassed" :aria-label="`${product.name} `" placeholder="请根据评论数据提炼用户痛点" /></td><td><textarea v-model.trim="reviewInsights[product.id].unmetNeed" :disabled="!stepFourPassed" :aria-label="`${product.name} `" placeholder="请根据评论数据提炼未被满足的需求" /></td></tr></tbody></table></div>
<div class="insight-guide"><div><h3>可参考以下方法</h3><p>1. 自行根据评价数据计算好评率阅读评论后分析用户痛点及未被满足的需求</p><p>2. 导出评论数据表上传到通用 AI 大模型通过文本分析完成计算与提炼</p><p>提示词请计算 Excel 表中产品评论数据的好评率提炼用户痛点和未被满足的需求</p></div></div>
<div class="step-actions"><button class="btn primary" type="button" :disabled="saving || !stepFourPassed" @click="saveAndGoToStepSix"> 6 </button><button class="btn" type="button" :disabled="saving || !stepFourPassed" @click="resetStepFive"><el-icon><RefreshLeft /></el-icon> </button></div>
</template>
<template v-else-if="currentStep === 6">
<div class="step-heading"><div><p>Step 06</p><h2>{{ trainingSteps[5] }}</h2></div><em>选品结论</em></div>
<p class="intro">结合前面的数据分析请说明应该进行开发什么产品具体原因是什么</p>
<p v-if="!stepFivePassed" class="warning"> 5 </p>
<div class="conclusion-field"><label for="market-opportunity-conclusion">开发建议与原因</label><textarea id="market-opportunity-conclusion" v-model.trim="developmentConclusion" :disabled="!stepFivePassed" placeholder="请结合综合得分、评论痛点与未被满足的需求,说明建议开发的产品及具体原因。" /></div>
<div class="step-actions"><button class="btn primary" type="button" :disabled="saving || !stepFivePassed" @click="saveAndCompleteTraining"></button><button class="btn" type="button" :disabled="saving || !stepFivePassed" @click="resetStepSix"><el-icon><RefreshLeft /></el-icon> </button></div>
</template>
<div v-else class="placeholder"><p> {{ currentStep }} 步内容待配置</p><span>已保存的步骤可从上方步骤栏返回查看</span></div>
</section>
@ -52,54 +67,77 @@
<script setup>
import { Download, RefreshLeft, UploadFilled } from "@element-plus/icons-vue";
import { checkMarketOpportunitySelectionStepOne, checkMarketOpportunitySelectionStepTwo, checkMarketOpportunitySelectionStepThree, checkMarketOpportunitySelectionStepFour, getStudentTrainingAnswer, saveStudentTrainingAnswer, uploadStudentTrainingFile } from "@/api/studentTrainingAnswer";
import { checkMarketOpportunitySelectionStepOne, checkMarketOpportunitySelectionStepTwo, checkMarketOpportunitySelectionStepThree, checkMarketOpportunitySelectionStepFour, checkMarketOpportunitySelectionStepFive, checkMarketOpportunitySelectionStepSix, getStudentTrainingAnswer, saveStudentTrainingAnswer, uploadStudentTrainingFile } from "@/api/studentTrainingAnswer";
import { getTrainingTaskByKey } from "@/api/trainingTask";
import { parseTrainingArray } from "@/views/training/taskKeyMap";
import { isStudentDemo } from "@/utils/studentDemo";
import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue";
import TrainingMaterialButton from "@/views/components/TrainingMaterialButton.vue";
import TrainingTaskBrief from "@/views/components/TrainingTaskBrief.vue";
const TASK_KEY = "market-opportunity-selection";
const DEFAULT_STEPS = ["指标分类", "市场机会分析", "选品方案设计", "选品结论"];
const DEFAULT_STEPS = ["指标分类", "指标权重设置", "综合得分计算", "数据可视化", "用户需求洞察", "选品结论"];
const categories = ["市场规模", "市场潜力", "竞争强度", "运营难度"];
const indicators = [
{ id: "search-count", name: "搜索次数", group: "市场规模" }, { id: "transaction-amount", name: "成交金额(元)", group: "市场规模" }, { id: "transaction-growth", name: "成交金额增速", group: "市场潜力" }, { id: "category-transaction-growth", name: "类目成交增速", group: "市场潜力" },
{ id: "demand-supply-ratio", name: "需供比", group: "竞争强度" }, { id: "online-products", name: "在线商品数", group: "竞争强度" }, { id: "online-merchants", name: "在线商家数", group: "竞争强度" }, { id: "organic-traffic-ratio", name: "自然流占比", group: "运营难度" },
{ id: "search-count", name: "搜索次数(万次)", group: "市场规模", suggestion: "2535" }, { id: "demand-supply-ratio", name: "需供比", group: "竞争强度", suggestion: "2535" }, { id: "transaction-amount", name: "成交金额(万元)", group: "市场规模", suggestion: "6575" },
{ id: "transaction-growth", name: "成交金额增速(%", group: "市场潜力", suggestion: "5565" }, { id: "category-transaction-growth", name: "类目成交增速(%", group: "市场潜力", suggestion: "3545" }, { id: "online-products", name: "在线商品数", group: "竞争强度", suggestion: "3545" },
{ id: "online-merchants", name: "在线商家数", group: "竞争强度", suggestion: "2535" }, { id: "organic-traffic-ratio", name: "自然流占比(%", group: "运营难度", suggestion: "5565" }, { id: "return-rate", name: "退货率(%", group: "运营难度", suggestion: "3545" },
];
const groupSuggestions = { "市场规模": "2535", "市场潜力": "2535", "竞争强度": "2030", "运营难度": "1020" };
const products = [{ id: "smart-fitness-mirror", name: "智能健身镜" }, { id: "smart-bathroom-mirror", name: "智能卫浴镜" }, { id: "smart-beauty-mirror", name: "智能美妆镜" }];
const defaultTask = { title: "市场机会识别与选品", background: "基于实训数据中的市场、供给与流量指标,完成市场机会识别和选品判断。", goals: ["理解选品数据指标的业务含义", "掌握市场规模、潜力、竞争和运营难度的分类方法", "为后续选品分析建立指标基础"], requirement: "阅读案例资料,完成 8 项指标的分类,并依据判题反馈修改。" };
const defaultTask = { title: "市场机会识别与选品", background: "基于实训数据中的市场、供给与流量指标,完成市场机会识别和选品判断。", goals: ["理解选品数据指标的业务含义", "掌握市场规模、潜力、竞争和运营难度的分类方法", "为后续选品分析建立指标基础"], requirement: "阅读案例资料,自行完成 9 项指标的分类,并依据系统判题反馈修改。" };
const { proxy } = getCurrentInstance();
const taskConfig = ref(null); const currentStep = ref(1); const saving = ref(false); const uploading = ref(false); const visualizationInput = ref(null); const visualization = ref({ url: "", name: "" }); const stepOnePassed = ref(false); const stepTwoPassed = ref(false); const stepThreePassed = ref(false); const answers = ref(createAnswers()); const weights = ref(createWeights()); const scores = ref(createScores());
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 taskConfig = ref(null); const currentStep = ref(1); const saving = ref(false); const uploading = ref(false); const visualizationInput = ref(null); const visualization = ref({ url: "", name: "" }); const stepOnePassed = ref(false); const stepTwoPassed = ref(false); const stepThreePassed = ref(false); const stepFourPassed = ref(false); const stepFivePassed = ref(false); const answers = ref(createAnswers()); const primaryWeights = ref(createPrimaryWeights()); const weights = ref(createWeights()); const scores = ref(createScores()); const reviewInsights = ref(createReviewInsights()); const developmentConclusion = ref(""); const dynamicStepAnswers = ref({}); const stepOneCheckResults = ref({});
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(() => { const configured = parseTrainingArray(taskConfig.value?.steps, DEFAULT_STEPS); return DEFAULT_STEPS.map((fallback, index) => configured[index] || fallback); });
const progressItems = computed(() => indicators.map((item) => ({ text: `${answers.value[item.id] ? "已分类" : "待分类"}${item.name}`, status: answers.value[item.id] ? "done" : "" })));
const totalWeight = computed(() => indicators.reduce((total, item) => total + (Number(weights.value[item.id]) || 0), 0));
const indicatorGroups = computed(() => categories.map((name) => ({ name, suggestion: groupSuggestions[name], items: indicators.filter((item) => answers.value[item.id] === name) })));
const primaryWeightTotal = computed(() => categories.reduce((total, group) => total + (Number(primaryWeights.value[group]) || 0), 0));
const correspondingWeightTotal = computed(() => roundWeight(indicators.reduce((total, item) => total + correspondingWeight(item), 0)));
onMounted(async () => { await loadTaskConfig(); await loadSavedAnswer(); });
function createAnswers() { return Object.fromEntries(indicators.map((item) => [item.id, ""])); }
function createPrimaryWeights() { return Object.fromEntries(categories.map((group) => [group, null])); }
function createWeights() { return Object.fromEntries(indicators.map((item) => [item.id, null])); }
function createScores() { return Object.fromEntries(products.map((product) => [product.id, null])); }
function createReviewInsights() { return Object.fromEntries(products.map((product) => [product.id, { favorableRate: null, painPoint: "", unmetNeed: "" }])); }
function isValidRate(value) { return Number.isFinite(Number(value)) && Number(value) >= 0 && Number(value) <= 100 && Math.round(Number(value) * 100) === Number(value) * 100; }
function hasCompleteReviewInsights() { return products.every((product) => { const insight = reviewInsights.value[product.id] || {}; return isValidRate(insight.favorableRate) && String(insight.painPoint || "").trim() && String(insight.unmetNeed || "").trim(); }); }
function parseArray(value, fallback) { if (Array.isArray(value)) return value.length ? value : fallback; if (!value) return fallback; try { const parsed = JSON.parse(value); return Array.isArray(parsed) && parsed.length ? parsed.map((item) => String(item?.name ?? item).trim()).filter(Boolean) : fallback; } catch (error) { const list = String(value).split(/[;|]/).map((item) => item.trim()).filter(Boolean); return list.length ? list : fallback; } }
async function loadTaskConfig() { try { taskConfig.value = (await getTrainingTaskByKey(TASK_KEY))?.data || null; } catch (error) { taskConfig.value = null; } }
async function loadSavedAnswer() { try { const answer = (await getStudentTrainingAnswer(TASK_KEY))?.data; if (answer?.step1Answer) { const parsed = JSON.parse(answer.step1Answer); answers.value = { ...createAnswers(), ...(parsed?.answers || parsed || {}) }; stepOnePassed.value = true; } if (answer?.step2Answer) { const parsed = JSON.parse(answer.step2Answer); weights.value = { ...createWeights(), ...(parsed?.weights || parsed || {}) }; stepTwoPassed.value = true; } if (answer?.step3Answer) { const parsed = JSON.parse(answer.step3Answer); scores.value = { ...createScores(), ...(parsed?.scores || parsed || {}) }; stepThreePassed.value = true; } if (answer?.step4Answer) { const parsed = JSON.parse(answer.step4Answer); visualization.value = { url: parsed?.visualization?.url || "", name: parsed?.visualization?.name || "" }; } if (Number(answer?.currentStep) > 0) currentStep.value = Number(answer.currentStep); } catch (error) { /* Keep the page available when history cannot be restored. */ } }
async function loadSavedAnswer() { try { const answer = (await getStudentTrainingAnswer(TASK_KEY))?.data; if (answer?.step1Answer) { const parsed = JSON.parse(answer.step1Answer); answers.value = { ...createAnswers(), ...(parsed?.answers || parsed || {}) }; stepOnePassed.value = indicators.every((item) => Boolean(answers.value[item.id])); } if (answer?.step2Answer) { const parsed = JSON.parse(answer.step2Answer); primaryWeights.value = { ...createPrimaryWeights(), ...(parsed?.primaryWeights || {}) }; weights.value = { ...createWeights(), ...(parsed?.dimensionWeights || parsed?.weights || {}) }; stepTwoPassed.value = stepOnePassed.value && categories.every((group) => isIntegerWeight(primaryWeights.value[group])) && indicators.every((item) => isIntegerWeight(weights.value[item.id])) && primaryWeightTotal.value === 100 && categories.every((group) => dimensionWeightTotal(group) === 100); } if (answer?.step3Answer) { const parsed = JSON.parse(answer.step3Answer); scores.value = { ...createScores(), ...(parsed?.scores || parsed || {}) }; stepThreePassed.value = stepTwoPassed.value; } if (answer?.step4Answer) { const parsed = JSON.parse(answer.step4Answer); visualization.value = { url: parsed?.visualization?.url || "", name: parsed?.visualization?.name || "" }; stepFourPassed.value = stepThreePassed.value && Boolean(visualization.value.url); } if (answer?.dynamicStepAnswers) { const parsed = JSON.parse(answer.dynamicStepAnswers); dynamicStepAnswers.value = parsed && typeof parsed === "object" ? parsed : {}; reviewInsights.value = { ...createReviewInsights(), ...(dynamicStepAnswers.value?.step5?.reviewInsights || {}) }; developmentConclusion.value = String(dynamicStepAnswers.value?.step6?.developmentConclusion || ""); stepFivePassed.value = stepFourPassed.value && hasCompleteReviewInsights(); } const savedStep = Number(answer?.currentStep) || 1; currentStep.value = stepFivePassed.value ? Math.min(savedStep, 6) : stepFourPassed.value ? Math.min(savedStep, 5) : stepThreePassed.value ? Math.min(savedStep, 4) : stepTwoPassed.value ? Math.min(savedStep, 3) : stepOnePassed.value ? Math.min(savedStep, 2) : 1; } catch (error) { /* Keep the page available when history cannot be restored. */ } }
function buildStepOneOutcome() { return { title: taskTitle.value, answers: { ...answers.value } }; }
function buildStepTwoOutcome() { return { title: taskTitle.value, weights: { ...weights.value }, primaryWeights: Object.fromEntries(["市场规模", "市场潜力", "竞争强度", "运营难度"].map((group) => [group, groupWeight(group)])) }; }
function buildStepTwoOutcome() { return { title: taskTitle.value, primaryWeights: { ...primaryWeights.value }, dimensionWeights: { ...weights.value }, correspondingWeights: Object.fromEntries(indicators.map((item) => [item.id, correspondingWeight(item)])) }; }
function buildStepThreeOutcome() { return { title: taskTitle.value, scores: { ...scores.value } }; }
function buildStepFourOutcome() { return { title: taskTitle.value, visualization: { ...visualization.value } }; }
async function checkAndContinue() { if (saving.value) return; saving.value = true; try { const result = await checkMarketOpportunitySelectionStepOne(indicators.map((item) => ({ id: item.id, category: answers.value[item.id] }))); if (!result?.data?.allCorrect) throw new Error(`当前答对 ${result?.data?.correctCount || 0}/${indicators.length} 项,请根据提示修改`); await saveStudentTrainingAnswer(TASK_KEY, { step1Answer: JSON.stringify(buildStepOneOutcome()), currentStep: 2, submitted: false, saveAction: "SAVE" }); stepOnePassed.value = true; currentStep.value = 2; proxy?.$modal?.msgSuccess("分类正确,已进入第 2 步"); } catch (error) { proxy?.$modal?.msgError?.(error?.message || "请完成指标分类并根据提示修改"); } finally { saving.value = false; } }
function groupWeight(group) { return indicators.filter((item) => item.group === group).reduce((total, item) => total + (Number(weights.value[item.id]) || 0), 0); }
function switchStep(step) { if (step > 1 && !stepOnePassed.value) { proxy?.$modal?.msgWarning("请先完成第 1 步指标分类"); return; } if (step > 2 && !stepTwoPassed.value) { proxy?.$modal?.msgWarning("请先完成第 2 步指标权重设定"); return; } if (step > 3 && !stepThreePassed.value) { proxy?.$modal?.msgWarning("请先完成第 3 步综合得分计算"); return; } currentStep.value = step; }
async function saveAndGoToStepThree() { if (saving.value || !stepOnePassed.value) return; saving.value = true; try { await checkMarketOpportunitySelectionStepTwo({ weights: indicators.map((item) => ({ id: item.id, weight: weights.value[item.id] })) }); await saveStudentTrainingAnswer(TASK_KEY, { step2Answer: JSON.stringify(buildStepTwoOutcome()), currentStep: 3, submitted: false, saveAction: "SAVE" }); stepTwoPassed.value = true; currentStep.value = 3; proxy?.$modal?.msgSuccess("已保存,已进入第 3 步"); } catch (error) { proxy?.$modal?.msgError?.(error?.message || "请填写合理的二级指标权重,且合计为 100%"); } finally { saving.value = false; } }
async function saveAndGoToStepFour() { if (saving.value || !stepTwoPassed.value) return; saving.value = true; try { await checkMarketOpportunitySelectionStepThree({ scores: products.map((product) => ({ id: product.id, score: scores.value[product.id] })) }); await saveStudentTrainingAnswer(TASK_KEY, { step3Answer: JSON.stringify(buildStepThreeOutcome()), currentStep: 4, submitted: false, saveAction: "SAVE" }); stepThreePassed.value = true; currentStep.value = 4; proxy?.$modal?.msgSuccess("已保存,已进入第 4 步"); } catch (error) { proxy?.$modal?.msgError?.(error?.message || "请填写 0 至 100 分的综合得分"); } finally { saving.value = false; } }
function resetStepOne() { answers.value = createAnswers(); proxy?.$modal?.msgSuccess("已清空第 1 步填写内容"); }
function resetStepTwo() { weights.value = createWeights(); proxy?.$modal?.msgSuccess("已清空第 2 步填写内容"); }
function buildStepFiveOutcome() { return { title: taskTitle.value, reviewInsights: Object.fromEntries(products.map((product) => [product.id, { ...reviewInsights.value[product.id] }])) }; }
function buildStepSixOutcome() { return { title: taskTitle.value, developmentConclusion: developmentConclusion.value.trim() }; }
function buildDynamicStepAnswers(overrides) { return { ...dynamicStepAnswers.value, ...overrides }; }
async function checkAndContinue() { if (saving.value) return; saving.value = true; try { const result = await checkMarketOpportunitySelectionStepOne(indicators.map((item) => ({ id: item.id, category: answers.value[item.id] }))); stepOneCheckResults.value = Object.fromEntries((result?.data?.items || []).map((item) => [item.id, item])); if (!result?.data?.allCorrect) throw new Error(`当前答对 ${result?.data?.correctCount || 0}/${indicators.length} 项,请根据表内提示修改`); await saveStudentTrainingAnswer(TASK_KEY, { step1Answer: JSON.stringify(buildStepOneOutcome()), currentStep: 2, submitted: false, saveAction: "SAVE" }); stepOnePassed.value = true; currentStep.value = 2; proxy?.$modal?.msgSuccess("分类正确,已进入第 2 步"); } catch (error) { proxy?.$modal?.msgError?.(error?.message || "请完成指标分类并根据提示修改"); } finally { saving.value = false; } }
function isIntegerWeight(value) { return value !== null && value !== "" && Number.isInteger(Number(value)) && Number(value) >= 1 && Number(value) <= 100; }
function dimensionWeightTotal(group) { return indicators.filter((item) => answers.value[item.id] === group).reduce((total, item) => total + (Number(weights.value[item.id]) || 0), 0); }
function correspondingWeight(item) { return roundWeight(((Number(primaryWeights.value[answers.value[item.id]]) || 0) * (Number(weights.value[item.id]) || 0)) / 100); }
function roundWeight(value) { return Math.round((value + Number.EPSILON) * 100) / 100; }
function formatWeight(value) { return Number.isInteger(value) ? value : value.toFixed(2); }
function switchStep(step) { if (step > 1 && !stepOnePassed.value) { proxy?.$modal?.msgWarning("请先完成第 1 步指标分类"); return; } if (step > 2 && !stepTwoPassed.value) { proxy?.$modal?.msgWarning("请先完成第 2 步指标权重设定"); return; } if (step > 3 && !stepThreePassed.value) { proxy?.$modal?.msgWarning("请先完成第 3 步综合得分计算"); return; } if (step > 4 && !stepFourPassed.value) { proxy?.$modal?.msgWarning("请先完成第 4 步数据可视化"); return; } if (step > 5 && !stepFivePassed.value) { proxy?.$modal?.msgWarning("请先完成第 5 步用户需求洞察"); return; } currentStep.value = step; }
async function saveAndGoToStepThree() { if (saving.value || !stepOnePassed.value) return; saving.value = true; try { await checkMarketOpportunitySelectionStepTwo({ primaryWeights: categories.map((group) => ({ group, weight: primaryWeights.value[group] })), dimensionWeights: indicators.map((item) => ({ id: item.id, weight: weights.value[item.id] })) }); await saveStudentTrainingAnswer(TASK_KEY, { step2Answer: JSON.stringify(buildStepTwoOutcome()), currentStep: 3, submitted: false, saveAction: "SAVE" }); stepTwoPassed.value = true; currentStep.value = 3; proxy?.$modal?.msgSuccess("权重校验通过,已进入第 3 步"); } catch (error) { proxy?.$modal?.msgError?.(error?.message || "请确认一级指标、维度内权重和对应总权重均满足规则"); } finally { saving.value = false; } }
async function saveAndGoToStepFour() { if (saving.value || !stepTwoPassed.value) return; saving.value = true; try { await checkMarketOpportunitySelectionStepThree({ scores: products.map((product) => ({ id: product.id, score: scores.value[product.id] })) }); await saveStudentTrainingAnswer(TASK_KEY, { step3Answer: JSON.stringify(buildStepThreeOutcome()), currentStep: 4, submitted: false, saveAction: "SAVE" }); stepThreePassed.value = true; currentStep.value = 4; proxy?.$modal?.msgSuccess("综合得分校验通过,已进入第 4 步"); } catch (error) { proxy?.$modal?.msgError?.(error?.message || "请填写 0 至 100 分、最多保留两位小数的综合得分"); } finally { saving.value = false; } }
function clearStepOneCheck(id) { if (stepOneCheckResults.value[id]) { const next = { ...stepOneCheckResults.value }; delete next[id]; stepOneCheckResults.value = next; } }
function resetStepOne() { answers.value = createAnswers(); stepOneCheckResults.value = {}; proxy?.$modal?.msgSuccess("已清空第 1 步填写内容"); }
function resetStepTwo() { primaryWeights.value = createPrimaryWeights(); weights.value = createWeights(); proxy?.$modal?.msgSuccess("已清空第 2 步填写内容"); }
function resetStepThree() { scores.value = createScores(); proxy?.$modal?.msgSuccess("已清空第 3 步填写内容"); }
function resetStepFive() { reviewInsights.value = createReviewInsights(); stepFivePassed.value = false; proxy?.$modal?.msgSuccess("已清空第 5 步填写内容"); }
function resetStepSix() { developmentConclusion.value = ""; proxy?.$modal?.msgSuccess("已清空第 6 步填写内容"); }
function openVisualizationPicker() { visualizationInput.value?.click(); }
function clearVisualization() { if (visualization.value.url.startsWith("blob:")) URL.revokeObjectURL(visualization.value.url); visualization.value = { url: "", name: "" }; }
function clearVisualization() { if (visualization.value.url.startsWith("blob:")) URL.revokeObjectURL(visualization.value.url); visualization.value = { url: "", name: "" }; stepFourPassed.value = false; stepFivePassed.value = false; }
async function handleVisualizationUpload(event) { const file = event.target?.files?.[0]; if (!file) return; const acceptedTypes = ["image/jpeg", "image/png", "image/webp"]; if (!acceptedTypes.includes(file.type)) { proxy?.$modal?.msgError?.("请上传 PNG、JPG 或 WEBP 格式的图片"); event.target.value = ""; return; } if (file.size > 5 * 1024 * 1024) { proxy?.$modal?.msgError?.("图片大小不能超过 5MB"); event.target.value = ""; return; } uploading.value = true; try { if (isStudentDemo()) { clearVisualization(); visualization.value = { url: URL.createObjectURL(file), name: file.name }; } else { const formData = new FormData(); formData.append("file", file); const result = await uploadStudentTrainingFile(formData); const url = result?.url || result?.data?.url; if (!url) throw new Error("图片上传失败,请重试"); visualization.value = { url, name: file.name }; } proxy?.$modal?.msgSuccess("可视化结果图上传成功"); } catch (error) { proxy?.$modal?.msgError?.(error?.message || "图片上传失败,请重试"); } finally { uploading.value = false; event.target.value = ""; } }
async function saveAndCompleteTraining() { if (saving.value || !stepThreePassed.value) return; saving.value = true; try { await checkMarketOpportunitySelectionStepFour({ visualizationUrl: visualization.value.url }); await saveStudentTrainingAnswer(TASK_KEY, { step4Answer: JSON.stringify(buildStepFourOutcome()), currentStep: 4, submitted: true, saveAction: "SUBMIT" }); proxy?.$modal?.msgSuccess("实训成果已保存,恭喜完成本次实训"); } catch (error) { proxy?.$modal?.msgError?.(error?.message || "请上传数据可视化结果图"); } finally { saving.value = false; } }
async function saveAndGoToStepFive() { if (saving.value || !stepThreePassed.value) return; saving.value = true; try { await checkMarketOpportunitySelectionStepFour({ visualizationUrl: visualization.value.url }); await saveStudentTrainingAnswer(TASK_KEY, { step4Answer: JSON.stringify(buildStepFourOutcome()), currentStep: 5, submitted: false, saveAction: "SAVE" }); stepFourPassed.value = true; currentStep.value = 5; proxy?.$modal?.msgSuccess("可视化结果已保存,已进入第 5 步"); } catch (error) { proxy?.$modal?.msgError?.(error?.message || "请上传数据可视化结果图"); } finally { saving.value = false; } }
async function saveAndGoToStepSix() { if (saving.value || !stepFourPassed.value) return; saving.value = true; try { await checkMarketOpportunitySelectionStepFive({ insights: products.map((product) => ({ productId: product.id, ...reviewInsights.value[product.id] })) }); dynamicStepAnswers.value = buildDynamicStepAnswers({ step5: buildStepFiveOutcome() }); await saveStudentTrainingAnswer(TASK_KEY, { dynamicStepAnswers: JSON.stringify(dynamicStepAnswers.value), currentStep: 6, submitted: false, saveAction: "SAVE" }); stepFivePassed.value = true; currentStep.value = 6; proxy?.$modal?.msgSuccess("用户需求洞察已保存,已进入第 6 步"); } catch (error) { proxy?.$modal?.msgError?.(error?.message || "请完整填写三类产品的好评率、用户痛点和未被满足的需求"); } finally { saving.value = false; } }
async function saveAndCompleteTraining() { if (saving.value || !stepFivePassed.value) return; saving.value = true; try { await checkMarketOpportunitySelectionStepSix({ developmentConclusion: developmentConclusion.value }); dynamicStepAnswers.value = buildDynamicStepAnswers({ step5: buildStepFiveOutcome(), step6: buildStepSixOutcome() }); await saveStudentTrainingAnswer(TASK_KEY, { dynamicStepAnswers: JSON.stringify(dynamicStepAnswers.value), currentStep: 6, submitted: true, saveAction: "SUBMIT" }); proxy?.$modal?.msgSuccess("实训成果已保存,恭喜完成本次实训"); } catch (error) { proxy?.$modal?.msgError?.(error?.message || "请填写建议开发的产品及具体原因"); } finally { saving.value = false; } }
function downloadFile(content, fileName, type) { const blob = new Blob([content], { type }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = fileName; link.click(); URL.revokeObjectURL(url); }
function downloadInstructions() { downloadFile("市场机会识别与选品 - 实训操作说明\n\n1. 汇总三类产品的原始数据。\n2. 对正向指标计算 (x-min)/(max-min),对逆向指标计算 (max-x)/(max-min)。\n3. 将归一化得分乘以第 2 步二级指标权重后求和。\n4. 将综合得分填写回第 3 步表格,保留两位小数。", "市场机会识别与选品-实训操作说明.txt", "text/plain;charset=utf-8"); }
function downloadTemplate() { const header = ["产品品类", ...indicators.map((item) => item.name), "综合得分"]; const rows = products.map((product) => [product.name, ...indicators.map(() => ""), ""]); const weightRow = ["二级指标权重(%)", ...indicators.map((item) => weights.value[item.id] ?? ""), ""]; downloadFile([header, weightRow, ...rows].map((row) => row.join(",")).join("\n"), "市场机会识别与选品-自动化模板.csv", "text/csv;charset=utf-8"); }
function exportOutcome() { const blob = new Blob([JSON.stringify({ taskName: taskTitle.value, step1: buildStepOneOutcome(), step2: buildStepTwoOutcome(), step3: buildStepThreeOutcome(), step4: buildStepFourOutcome(), currentStep: currentStep.value }, null, 2)], { type: "application/json;charset=utf-8" }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = `${taskTitle.value}-实训结果.json`; link.click(); URL.revokeObjectURL(url); }
function downloadInstructions() { downloadFile("市场机会识别与选品 - 实训操作说明\n\n1. 汇总三类产品的原始数据。\n2. 对正向指标计算 (x-min)/(max-min),对逆向指标计算 (max-x)/(max-min)。\n3. 将归一化得分乘以第 2 步的对应总权重后求和。\n4. 如案例资料提供三段式时间权重,分别计算第 120 天、第 2140 天和第 4160 天的加权得分。\n5. 将产品品类综合得分填写回第 3 步表格,保留两位小数。", "市场机会识别与选品-实训操作说明.txt", "text/plain;charset=utf-8"); }
function downloadTemplate() { const header = ["产品品类", ...indicators.map((item) => item.name), "综合得分"]; const rows = products.map((product) => [product.name, ...indicators.map(() => ""), ""]); const primaryWeightRow = ["一级指标权重(%)", ...indicators.map((item) => primaryWeights.value[item.group] ?? ""), ""]; const dimensionWeightRow = ["维度内权重(%)", ...indicators.map((item) => weights.value[item.id] ?? ""), ""]; const correspondingWeightRow = ["对应总权重(%)", ...indicators.map((item) => correspondingWeight(item)), ""]; downloadFile([header, primaryWeightRow, dimensionWeightRow, correspondingWeightRow, ...rows].map((row) => row.join(",")).join("\n"), "市场机会识别与选品-自动化模板.csv", "text/csv;charset=utf-8"); }
function downloadVisualizationInstructions() { downloadFile("市场机会识别与选品 - 数据可视化操作说明\n\n1. 按智能健身镜、智能卫浴镜和智能美妆镜汇总产品综合得分。\n2. 分别计算各品类的平均分、最高分和最低分。\n3. 使用柱状图、折线图或组合图完成可视化,并清晰标注品类和得分。\n4. 导出图表为 PNG、JPG 或 WEBP 格式后上传。", "市场机会识别与选品-数据可视化操作说明.txt", "text/plain;charset=utf-8"); }
function downloadVisualizationTemplate() { const header = ["产品品类", "平均得分", "最高分", "最低分"]; const rows = products.map((product) => [product.name, "", "", ""]); downloadFile([header, ...rows].map((row) => row.join(",")).join("\n"), "市场机会识别与选品-数据可视化模板.csv", "text/csv;charset=utf-8"); }
function exportOutcome() { const blob = new Blob([JSON.stringify({ taskName: taskTitle.value, step1: buildStepOneOutcome(), step2: buildStepTwoOutcome(), step3: buildStepThreeOutcome(), step4: buildStepFourOutcome(), step5: buildStepFiveOutcome(), step6: buildStepSixOutcome(), currentStep: currentStep.value }, null, 2)], { type: "application/json;charset=utf-8" }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = `${taskTitle.value}-实训结果.json`; link.click(); URL.revokeObjectURL(url); }
</script>
<style lang="scss" scoped>

File diff suppressed because it is too large Load Diff

@ -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(
/<nav class="step-indicator" aria-label="新产品定价步骤">([\s\S]*?)<\/nav>/,
)?.[1] || "";
assert.ok(stepper, "product pricing must use the shared step-indicator navigation pattern");
assert.match(stepper, /v-for="step in pricingSteps"/, "the step navigation must be rendered from actual step data");
assert.match(stepper, /@click="switchStep\(step\.no\)"/, "each pricing step must be navigable");
assert.match(page, /class="workbench-head workbench-head--actions-only"/, "pricing must use the standard top action row");
assert.match(page, /@click="exportPriceOutcome"/, "pricing must provide the standard export action");
assert.ok(page.indexOf("workbench-head--actions-only") < page.indexOf('class="step-indicator"'), "actions must appear before the step indicator");
assert.ok(page.indexOf('class="step-indicator"') < page.indexOf('<section class="step-card">'), "step content must be inside a separate standard step card");
assert.match(page, /const pricingSteps = computed\(\(\) => \[/, "the page must define its pricing steps");
for (const label of ["pricingTitle", "competitorPricingTitle", "valueBasedPricingTitle", "psmTitle", "finalPricingTitle"]) {
assert.match(page, new RegExp(`title: labels\\.${label}`), `${label} must appear in the five-step navigation`);
}
assert.match(page, /\.step-indicator\s*\{/, "the page must style the shared step-indicator layout");
assert.match(page, /\.step-connector\s*\{[\s\S]*?flex:\s*0 1 40px;/, "connectors must use the compact shared spacing");
assert.match(page, /\.primary-button,\s*\.secondary-button\s*\{[\s\S]*?color:\s*#e9fbff;/, "pricing toolbar buttons must use the student dark theme");
assert.match(page, /\.primary-button\s*\{[\s\S]*?background:\s*linear-gradient/, "the primary action must use the blue gradient button style");
assert.match(page, /\.toolbar-actions\s*\{[\s\S]*?flex:\s*none;/, "toolbar actions must retain their width instead of being compressed");
const psmTheme = page.match(/\/\* PSM dark theme \*\/([\s\S]*?)<\/style>/)?.[1] || "";
assert.ok(psmTheme, "the PSM step must define its dark theme locally");
assert.match(psmTheme, /\.psm-point-section,\s*\.psm-chart-section\s*\{[\s\S]*?background:\s*rgba\(5, 35, 48, 0\.72\)/, "PSM panels must not fall back to white cards");
assert.match(psmTheme, /\.psm-table\s*\{[\s\S]*?width:\s*100%/, "the PSM table must fill its available workspace");
assert.match(psmTheme, /\.psm-table input,\s*\.psm-point-grid input\s*\{[\s\S]*?background:\s*rgba\(2, 19, 28, 0\.7\)/, "PSM inputs must use the dark form style");
console.log("Product pricing step indicator contract passed.");

@ -8,6 +8,7 @@ const studentPage = fs.readFileSync(path.join(root, "src/views/training/GenericT
const newProductSurveyPage = fs.readFileSync(path.join(root, "src/views/foundation/new-product-survey.vue"), "utf8");
const productDevelopmentFactorsPage = fs.readFileSync(path.join(root, "src/views/foundation/product-development-factors.vue"), "utf8");
const trainingIntro = fs.readFileSync(path.join(root, "src/views/components/TrainingIntro.vue"), "utf8");
const materialButton = fs.readFileSync(path.join(root, "src/views/components/TrainingMaterialButton.vue"), "utf8");
const directStudentBriefFiles = [
"src/views/demand/index.vue",
"src/views/demand/environment.vue",
@ -47,6 +48,35 @@ assert(
/TrainingMaterialButton/.test(productDevelopmentFactorsPage),
"product development factors page should render the case material button"
);
assert.match(
materialButton,
/function normalizeMaterialUrl\(rawUrl\)/,
"case material downloads must normalize legacy absolute URLs"
);
assert.ok(
materialButton.includes('["localhost", "127.0.0.1", "::1"]'),
"localhost case material URLs must not be sent to each student's own browser port"
);
assert.match(
materialButton,
/url: normalizeMaterialUrl\(url\)/,
"the download button must use the normalized material URL"
);
assert.match(
materialButton,
/async function handleDownload\(\)/,
"case material downloads must validate the file response before opening a download"
);
assert.match(
materialButton,
/await fetch\(material\.value\.url\)/,
"case material downloads must request the configured file URL"
);
assert.match(
materialButton,
/案例资料文件不存在或暂不可下载/,
"a missing case material file must show a clear recovery message"
);
const studentBriefPages = [studentPage, newProductSurveyPage, productDevelopmentFactorsPage, trainingIntro, ...directStudentBriefPages];
assert(/实训背景/.test(trainingTaskBrief) && /实训目标/.test(trainingTaskBrief) && /实训要求/.test(trainingTaskBrief), "common TrainingTaskBrief component should render the three student brief sections");

Loading…
Cancel
Save