Compare commits

..

No commits in common. 'master' and 'fix/non-blocking-route-navigation' have entirely different histories.

@ -18,4 +18,4 @@ VITE_APP_BASE_API = '/'
# VITE_APP_BASE_API = 'http://118.31.7.2:7548/'
VITE_APP_AI_API = 'https://whmnjy.sztzjy.com:8764/'
VITE_APP_ENV = 'development'
VITE_APP_BASE_API = '/'
VITE_APP_BASE_API = 'http://192.168.1.74:7548/'

@ -1,94 +0,0 @@
# 市场机会识别第二步权重设置 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 让学生在市场机会识别第二步完成符合原型的指标权重设定、实时计算和可靠保存。
**Architecture:** 前端页面负责按固定分组渲染权重表、输入状态和即时总计;既有第二步接口负责服务器端最终校验。答案继续存放在学生实训答案的 `step2Answer`,不新增数据表或接口字段。
**Tech Stack:** Vue 3、Element Plus、Vite、Spring Boot、JUnit 5。
---
## 文件结构
- 修改:`src/views/product/market-opportunity-selection.vue` — 第二步表格、状态提示、交互和保存回显。
- 修改:`tests/market-opportunity-step2.static.test.cjs` — 前端结构与计算契约。
- 修改:`E:/workspace/dianshang/dianshang-houduan/src/test/java/com/sztzjy/linkCommerce/service/impl/MarketOpportunitySelectionStepTwoServiceImplTest.java` — 后端校验回归测试。
### Task 1: 前端第二步权重表契约
**Files:**
- Create: `tests/market-opportunity-step2.static.test.cjs`
- Modify: `src/views/product/market-opportunity-selection.vue`
- [ ] **Step 1: 编写失败的前端契约测试**
断言页面按以下固定顺序含有一级指标和二级指标:市场规模(搜索次数、成交金额)、市场潜力(成交金额增速、类目成交增速)、竞争强度(需供比、在线商品数、在线商家数)、运营难度(自然流占比、退货率);同时断言 2535、6575、5565、3545、2030、1020 建议区间、一级/对应总权重统计、实时错误状态和公式计算函数存在。
- [ ] **Step 2: 运行测试确认失败**
Run`E:/workspace/dianshang/dianshang-qianduan`: `node --test tests/market-opportunity-step2.static.test.cjs`
Expected: 因新的原型布局和输入反馈契约尚未实现而失败。
- [ ] **Step 3: 最小化实现原型布局与即时反馈**
让第二步使用分组表格:一级权重输入使用合并单元格,二级权重按指标逐行输入,对应总权重只读显示;在总计不满足 100% 时显示错误提示。
- [ ] **Step 4: 运行前端测试确认通过**
Run`E:/workspace/dianshang/dianshang-qianduan`: `node --test tests/market-opportunity-step2.static.test.cjs`
Expected: PASS。
### Task 2: 后端权重规则回归
**Files:**
- Modify: `E:/workspace/dianshang/dianshang-houduan/src/test/java/com/sztzjy/linkCommerce/service/impl/MarketOpportunitySelectionStepTwoServiceImplTest.java`
- [ ] **Step 1: 编写失败的后端测试**
新增一个请求:一级权重合法,但某个维度缺少预期的二级指标(或包含未知二级指标),断言服务拒绝。测试请求严格使用既有数组契约:`primaryWeights[].group`、`primaryWeights[].weight`、`dimensionWeights[].id`、`dimensionWeights[].weight`。
- [ ] **Step 2: 运行后端定向测试确认失败或暴露缺口**
Run`E:/workspace/dianshang/dianshang-houduan`: `mvn -Dtest=MarketOpportunitySelectionStepTwoServiceImplTest test`
Expected: 若服务未覆盖该边界则失败;若现有服务已覆盖,记录现有实现满足约束并保持测试通过。
- [ ] **Step 3: 只在测试暴露缺口时补充最小校验代码**
保持请求字段和答案存储格式不变,确保服务拒绝缺失或未知的二级指标。
- [ ] **Step 4: 运行定向测试确认通过**
Run`E:/workspace/dianshang/dianshang-houduan`: `mvn -Dtest=MarketOpportunitySelectionStepTwoServiceImplTest test`
Expected: PASS。
### Task 3: 集成验证
**Files:**
- Modify: `src/views/product/market-opportunity-selection.vue`
- [ ] **Step 1: 验证答案保存与回显数据结构**
确认 `buildStepTwoOutcome` 保存 `primaryWeights`、`dimensionWeights` 和自动计算的 `correspondingWeights`;确认 `loadSavedAnswer` 使用同名字段恢复数据。
- [ ] **Step 2: 运行前端生产构建**
Run`E:/workspace/dianshang/dianshang-qianduan`: `npm run build:prod`
Expected: exit code 0。
- [ ] **Step 3: 运行后端完整测试**
Run`E:/workspace/dianshang/dianshang-houduan`: `mvn test`
Expected: 0 failures0 errors。
- [ ] **Step 4: 提交本次功能修改**
仅提交本次页面、测试和必要的后端修复,不包含本地日志或无关设计文档。

@ -1,57 +0,0 @@
# 数据化选品与市场机会识别第二步设计
## 目标
将“数据化选品与市场机会识别”的第二步实现为可填写、自动计算、可校验和可回显的指标权重设置页面,并保持学生端既有深色实训工作台风格。
## 范围
- 页面:`/product/market-opportunity-selection` 的第二步“指标权重设置”。
- 一级指标固定为市场规模、市场潜力、竞争强度、运营难度。
- 二级指标固定为 9 项,分组、顺序和建议区间以原型为准。
- 不新增文件上传控件;顶部通用案例资料按钮继续沿用现有的图片和 PDF 预览能力。
## 交互与计算
1. 学生必须先通过并保存第一步的指标分类,第二步才可填写。
2. 每个一级指标填写一个 1100 的整数权重,四项合计必须为 100%。
3. 每个二级指标填写维度内的 1100 整数权重;同一一级指标下的二级权重合计必须为 100%。
4. 对应总权重实时显示,计算公式为:一级指标权重 × 二级指标维度内权重 ÷ 100。
5. 全部对应总权重合计必须为 100%。未满足任一规则时,页面即时提示,保存时由后端再次校验。
6. 保存成功后,将第二步答案写入 `step2Answer` 并进入第三步;再次打开页面时回显已保存权重。
## 指标清单与顺序
| 一级指标 | 一级建议区间 | 二级指标(顺序固定) | 二级建议区间 |
| --- | --- | --- | --- |
| 市场规模 | 2535 | 搜索次数(万次)、成交金额(万元) | 2535、6575 |
| 市场潜力 | 2535 | 成交金额增速(%)、类目成交增速(% | 5565、3545 |
| 竞争强度 | 2030 | 需供比、在线商品数、在线商家数 | 2535、3545、2535 |
| 运营难度 | 1020 | 自然流占比(%)、退货率(% | 5565、3545 |
## 保存与校验数据契约
保存到 `step2Answer` 的 JSON 结构固定为:
```json
{
"title": "市场机会识别与选品",
"primaryWeights": { "市场规模": 30, "市场潜力": 25, "竞争强度": 25, "运营难度": 20 },
"dimensionWeights": { "search-count": 30, "transaction-amount": 70 },
"correspondingWeights": { "search-count": 9, "transaction-amount": 21 }
}
```
校验接口请求继续使用数组字段 `primaryWeights``group`、`weight`)和 `dimensionWeights``id`、`weight`);服务端不接受客户端传入的对应总权重,并自行复核总和。
## 后端边界
现有第二步校验接口已经校验第一步完成状态、四类一级权重、九项二级权重及对应总权重总和。此次不改变接口字段或数据表结构,仅补充回归测试,确保上述规则持续有效。
## 验收标准
- 原型中的四类一级指标、九项二级指标、建议区间与顺序一致。
- 权重输入、组内合计、对应总权重和总计均实时可见。
- 不合格数据不能保存并进入第三步。
- 合格数据可以保存、刷新后仍能回显。
- 页面不含原型未提供的上传控件;已配置的案例资料维持预览与下载行为。

@ -7,11 +7,11 @@ export function getAiTrainingEvaluation(taskKey) {
});
}
export function requestAiTrainingHelp(taskKey, question, stepContext = "") {
export function requestAiTrainingHelp(taskKey, question) {
return request({
url: `/api/student/training-tasks/${taskKey}/ai-evaluation/help`,
method: "post",
data: { question, stepContext },
data: { question },
headers: { repeatSubmit: false },
});
}

@ -64,175 +64,6 @@ 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)
@ -797,7 +628,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": "运营难度", "return-rate": "运营难度",
"category-transaction-growth": "市场潜力", "online-products": "竞争强度", "online-merchants": "竞争强度", "organic-traffic-ratio": "运营难度",
};
if (isStudentDemo()) {
const list = Array.isArray(items) ? items : [];
@ -809,23 +640,17 @@ export function checkMarketOpportunitySelectionStepOne(items) {
}
export function checkMarketOpportunitySelectionStepTwo(payload) {
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%"));
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%"));
}
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: { primaryWeights, dimensionWeights }, headers: { repeatSubmit: false } });
return request({ url: "/api/student-training-answers/market-opportunity-selection/step-2/validate", method: "post", data: { weights }, headers: { repeatSubmit: false } });
}
export function checkMarketOpportunitySelectionStepThree(payload) {
@ -833,9 +658,8 @@ 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));
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 分、最多保留两位小数的综合得分"));
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 分的综合得分"));
}
return Promise.resolve({ code: 200, data: { valid: true, message: "校验通过" } });
}
@ -851,30 +675,6 @@ 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)
@ -1078,7 +878,6 @@ export function checkProductDevelopmentProcessStepTwo(items) {
method: "post",
data: { items },
headers: { repeatSubmit: false },
skipErrorMessage: true,
});
}
@ -1911,10 +1710,10 @@ export function checkCompetitiveEnvironmentAnalysisStepFour(payload) {
const value = payload || {}; const priorities = Array.isArray(value.priorities) ? value.priorities : [];
if (isStudentDemo()) {
const types = ["so", "wo", "st", "wt"]; const ranks = priorities.map((item) => Number(item?.priority));
if (priorities.length !== types.length || types.some((type) => !priorities.some((item) => item?.strategyType === type)) || new Set(ranks).size !== 4 || ranks.some((rank) => ![1, 2, 3, 4].includes(rank)) || priorities.some((item) => !String(item?.reason || "").trim()) || !String(value.coreRiskOne || "").trim() || !String(value.coreRiskTwo || "").trim() || !String(value.conclusion || "").trim()) return Promise.reject(new Error("请完整填写策略优先级、至少 2 个核心风险点和总结 SWOT 竞争环境评估结论"));
if (priorities.length !== types.length || types.some((type) => !priorities.some((item) => item?.strategyType === type)) || new Set(ranks).size !== 4 || ranks.some((rank) => ![1, 2, 3, 4].includes(rank)) || !String(value.overallEvaluation || "").trim() || priorities.some((item) => !String(item?.reason || "").trim()) || !String(value.coreRiskOne || "").trim() || !String(value.coreRiskTwo || "").trim() || !String(value.conclusion || "").trim()) return Promise.reject(new Error("请完整填写策略优先级、总体判断、风险点和总结结论"));
return Promise.resolve({ code: 200, data: { valid: true, message: "校验通过" } });
}
return request({ url: "/api/student-training-answers/competitive-environment-analysis/step-4/validate", method: "post", data: value, headers: { repeatSubmit: false }, skipErrorMessage: true });
return request({ url: "/api/student-training-answers/competitive-environment-analysis/step-4/validate", method: "post", data: value, headers: { repeatSubmit: false } });
}
const consumerScenarioJourneyOrder = ["problem-awareness", "information-search", "evaluation-decision", "purchase-conversion", "post-purchase"];

@ -1,11 +1,8 @@
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;
@ -20,51 +17,6 @@ export function listTrainingTasks(params) {
});
}
function getStudentTaskNavigationCacheKey() {
try {
const userInfo = JSON.parse(getUserInfo() || "{}");
const userId = userInfo.userId || userInfo.user_id || userInfo.username || userInfo.userName;
return userId ? `${STUDENT_TASK_NAVIGATION_CACHE_PREFIX}${userId}` : "";
} catch (error) {
return "";
}
}
export function getCachedStudentTrainingTasks() {
const cacheKey = getStudentTaskNavigationCacheKey();
if (!cacheKey || typeof window === "undefined") return [];
try {
const cached = JSON.parse(window.sessionStorage.getItem(cacheKey) || "[]");
return Array.isArray(cached) ? cached : [];
} catch (error) {
return [];
}
}
/**
* The navigation and top bar both need the same enabled task list. Reuse one
* in-flight request and retain the last successful response for immediate menu rendering.
*/
export function loadStudentTrainingTasks() {
const cacheKey = getStudentTaskNavigationCacheKey() || "anonymous";
if (pendingStudentTaskListRequests.has(cacheKey)) {
return pendingStudentTaskListRequests.get(cacheKey);
}
const requestPromise = listTrainingTasks({ enabledOnly: true }).then((res) => {
const tasks = Array.isArray(res?.data) ? res.data : [];
if (cacheKey !== "anonymous" && typeof window !== "undefined") {
window.sessionStorage.setItem(cacheKey, JSON.stringify(tasks));
}
return { ...res, data: tasks };
});
pendingStudentTaskListRequests.set(cacheKey, requestPromise);
requestPromise.then(
() => pendingStudentTaskListRequests.delete(cacheKey),
() => pendingStudentTaskListRequests.delete(cacheKey)
);
return requestPromise;
}
export function getTrainingTaskByKey(taskKey) {
const normalizedTaskKey = String(taskKey || "").trim();
if (pendingTaskRequests.has(normalizedTaskKey)) {

@ -1,5 +1,5 @@
.student-training-shell {
--student-step-height: 42px;
--student-step-height: 46px;
--student-step-font: 14px;
--student-action-height: 42px;
--student-action-font: 16px;
@ -28,86 +28,17 @@
#app .teacherLayout .app-main:has(.student-training-shell) .student-training-shell {
box-sizing: border-box !important;
display: grid !important;
grid-template-columns: minmax(660px, 1fr) 300px !important;
grid-template-columns: minmax(720px, 1fr) 340px !important;
align-items: start !important;
gap: 10px !important;
gap: 24px !important;
width: 100% !important;
max-width: none !important;
min-height: calc(100vh - 90px) !important;
margin: 0 !important;
padding: 10px 10px 16px !important;
padding: 24px 24px 32px !important;
background: #06111d !important;
}
/*
* Student pages use different local names for their main and workbench cards.
* Keep the layout density here so a page-specific card cannot reclaim the
* space reserved for the hands-on activity area.
*/
.student-training-shell > main {
min-width: 0 !important;
padding: 12px !important;
border-radius: 20px !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: 10px 12px !important;
border-radius: 16px !important;
}
/* 产品开发主要流程的信息层级较多,收紧装饰性边距,为甘特图等实操区域留出横向空间。 */
#app .studentLayout .app-main:has(.student-training-shell) .student-training-shell.product-process {
gap: 10px !important;
padding: 10px 10px 16px !important;
}
#app .studentLayout .app-main:has(.student-training-shell) .student-training-shell.product-process > main {
padding: 12px !important;
border-radius: 20px !important;
}
#app .studentLayout .app-main:has(.student-training-shell) .student-training-shell.product-process .process-workbench {
padding: 10px 12px !important;
border-radius: 16px !important;
}
#app .studentLayout .app-main:has(.student-training-shell) .student-training-shell.product-process .process-step-card {
padding: 12px !important;
border-radius: 16px !important;
}
#app .studentLayout .app-main:has(.student-training-shell) .student-training-shell.product-process .task-header {
margin-bottom: 12px !important;
}
#app .studentLayout .app-main:has(.student-training-shell) .student-training-shell.product-process .workbench-head {
margin-bottom: 12px !important;
padding-bottom: 10px !important;
}
#app .studentLayout .app-main:has(.student-training-shell) .student-training-shell.product-process .workbench-head--actions-only {
margin-bottom: 6px !important;
padding-bottom: 0 !important;
}
#app .studentLayout .app-main:has(.student-training-shell) .student-training-shell.product-process .step-indicator {
margin-bottom: 12px !important;
}
.student-training-shell .step-card,
.student-training-shell .process-step-card,
.student-training-shell .factor-step-card {
padding: 12px !important;
border-radius: 16px !important;
}
.student-training-shell .task-header {
margin-bottom: 12px !important;
}
.student-training-shell .task-header .task-title {
font-size: 30px !important;
line-height: 1.3 !important;
@ -145,7 +76,7 @@
justify-content: space-between !important;
gap: 6px !important;
width: 100% !important;
margin: 0 0 12px !important;
margin: 0 0 32px !important;
padding: 6px !important;
border: 1px solid var(--student-panel-border) !important;
border-radius: var(--student-radius-pill) !important;
@ -195,7 +126,7 @@
justify-content: space-between !important;
gap: 6px !important;
width: 100% !important;
margin: 0 0 12px !important;
margin: 0 0 24px !important;
padding: 6px !important;
border: 1px solid var(--student-panel-border) !important;
border-radius: 60px !important;
@ -512,7 +443,7 @@
display: flex !important;
align-items: flex-start !important;
justify-content: space-between !important;
gap: 10px !important;
gap: 18px !important;
}
.student-training-shell .workbench-actions {
@ -522,7 +453,7 @@
align-items: center !important;
justify-content: flex-end !important;
align-self: flex-start !important;
gap: 8px !important;
gap: 10px !important;
min-width: max-content !important;
margin: 0 0 0 auto !important;
padding: 0 !important;
@ -536,16 +467,16 @@
flex: 0 0 auto !important;
min-width: 0 !important;
width: auto !important;
height: 34px !important;
min-height: 34px !important;
max-height: 34px !important;
height: 40px !important;
min-height: 40px !important;
max-height: 40px !important;
margin: 0 !important;
padding: 0 12px !important;
padding: 0 18px !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: 13px !important;
font-size: 14px !important;
font-weight: 800 !important;
line-height: 1 !important;
white-space: nowrap !important;
@ -596,13 +527,10 @@
.legacy-training-page {
display: grid !important;
box-sizing: border-box !important;
grid-template-columns: minmax(0, 1fr) 300px !important;
gap: 14px !important;
grid-template-columns: minmax(0, 1fr) 360px !important;
gap: 24px !important;
align-items: start !important;
width: 100% !important;
padding: 14px !important;
background: #06111d !important;
}
.legacy-training-page > :not(.training-ai-sidebar) {
@ -625,22 +553,22 @@
}
.legacy-training-page .top_item {
margin-bottom: 12px !important;
margin-bottom: 18px !important;
}
.legacy-training-page .top_item .el-button {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
min-width: 136px !important;
height: 36px !important;
padding: 0 12px !important;
min-width: 150px !important;
height: 42px !important;
padding: 0 18px !important;
border: 1px solid #2c83aa !important;
border-radius: 6px !important;
color: #e9fbff !important;
background: rgba(13, 64, 88, 0.62) !important;
box-shadow: none !important;
font-size: 14px !important;
font-size: 15px !important;
font-weight: 800 !important;
line-height: 1 !important;
letter-spacing: 0 !important;
@ -700,10 +628,6 @@
padding: 14px 14px 18px !important;
}
.legacy-training-page {
padding: 12px !important;
}
.student-training-shell .workbench-head {
flex-direction: column !important;
}

@ -14,7 +14,7 @@
<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 && !route.meta?.hideSupplementSteps" v-show="!studentTaskLoading" :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>

@ -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 { getCachedStudentTrainingTasks, loadStudentTrainingTasks } from "@/api/trainingTask";
import { listTrainingTasks } from "@/api/trainingTask";
import { getStudentDemoSession, isStudentDemo } from "@/utils/studentDemo";
import { getComprehensiveTopTask } from "@/utils/studentTrainingNavigation";
const { proxy } = getCurrentInstance();
@ -289,17 +289,11 @@ async function loadStudentTopTask() {
studentTopTask.value = null;
return;
}
const cachedTasks = getCachedStudentTrainingTasks();
if (cachedTasks.length) {
studentTopTask.value = getComprehensiveTopTask(cachedTasks);
}
try {
const res = await loadStudentTrainingTasks();
const res = await listTrainingTasks({ enabledOnly: true });
studentTopTask.value = getComprehensiveTopTask(res.data || []);
} catch (error) {
if (!cachedTasks.length) {
studentTopTask.value = null;
}
studentTopTask.value = null;
}
}
//

@ -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 { getCachedStudentTrainingTasks, loadStudentTrainingTasks } from "@/api/trainingTask";
import { listTrainingTasks } from "@/api/trainingTask";
import { buildStudentTaskSections } from "@/utils/studentTrainingNavigation";
import * as ElementPlusIconsVue from "@element-plus/icons-vue";
const { proxy } = getCurrentInstance();
@ -469,17 +469,11 @@ async function loadStudentTaskNavigation() {
studentTaskSections.value = [];
return;
}
const cachedTasks = getCachedStudentTrainingTasks();
if (cachedTasks.length) {
studentTaskSections.value = buildStudentTaskSections(cachedTasks);
}
try {
const res = await loadStudentTrainingTasks();
const res = await listTrainingTasks({ enabledOnly: true });
studentTaskSections.value = buildStudentTaskSections(res.data || []);
} catch (error) {
if (!cachedTasks.length) {
studentTaskSections.value = [];
}
studentTaskSections.value = [];
}
}
function groupAndSortModules(modules) {

@ -288,7 +288,7 @@ export const dynamicRoutes = [
path: "index",
component: () => import("@/views/product/market-opportunity-selection.vue"),
name: "product",
meta: { title: "市场机会识别与选品", icon: "产品规划", affix: true, pageKey: "market-opportunity-selection", hideSupplementSteps: true },
meta: { title: "市场机会识别与选品", icon: "产品规划", affix: true, pageKey: "market-opportunity-selection" },
},
// 产品定价
{

@ -99,10 +99,9 @@ service.interceptors.response.use(
return Promise.resolve(res.data);
}
},
(error) => {
console.log("err" + error);
let { message } = error;
const serverMessage = String(error?.response?.data?.msg || "").trim();
(error) => {
console.log("err" + error);
let { message } = error;
if (message == "Network Error") {
message = "后端接口连接异常";
} else if (message.includes("timeout")) {
@ -121,13 +120,10 @@ service.interceptors.response.use(
.catch(() => {
isRelogin.show = false;
});
} else if (message.includes("code 400")) {
if (!error.config?.skipErrorMessage) {
ElMessage({ message: serverMessage || "请求参数不符合要求", type: "error" });
} else {
error.message = serverMessage || "请求参数不符合要求";
}
}
} else if (message.includes("code 400")) {
// ElNotification.error({ title: error.response.data })
ElMessage({ message: error.response.data.msg, type: "error" });
}
message = "系统接口" + message.substr(message.length - 3) + "异常";
}
// ElMessage({ message: message, type: 'error', duration: 5 * 1000 })

@ -5,23 +5,13 @@
<TrainingTaskBrief :background="taskBackground" :goals="taskGoals" :requirements="taskRequirement" :show-material="false" />
<section class="workbench">
<div class="workbench-top"><div><p>产品问题诊断与迭代</p><h2>{{ currentStep === 1 ? '全链路转化漏斗诊断' : currentStep === 2 ? '用户评论根因分析' : 'AB 测试验证方案' }}</h2></div><TrainingMaterialButton :material-name="taskConfig?.materialName" :material-url="taskConfig?.materialUrl" /></div>
<nav class="step-indicator" aria-label="">
<button type="button" class="step-tab" :class="{ active: currentStep === 1 }" @click="currentStep = 1"><b>01</b><span>全链路漏斗诊断<small>转化率与异常节点</small></span></button>
<span class="step-connector" aria-hidden="true"></span>
<button type="button" class="step-tab" :class="{ active: currentStep === 2 }" @click="currentStep = 2"><b>02</b><span>用户评论根因分析<small>词云与情感倾向</small></span></button>
<span class="step-connector" aria-hidden="true"></span>
<button type="button" class="step-tab" :class="{ active: currentStep === 3 }" @click="currentStep = 3"><b>03</b><span>AB 测试验证<small>改进方案有效性</small></span></button>
</nav>
<nav class="step-tabs" aria-label=""><button type="button" :class="{ active: currentStep === 1 }" @click="currentStep = 1"><b>01</b><span>全链路漏斗诊断<small>转化率与异常节点</small></span></button><i></i><button type="button" :class="{ active: currentStep === 2 }" @click="currentStep = 2"><b>02</b><span>用户评论根因分析<small>词云与情感倾向</small></span></button><i></i><button type="button" :class="{ active: currentStep === 3 }" @click="currentStep = 3"><b>03</b><span>AB 测试验证<small>改进方案有效性</small></span></button></nav>
<template v-if="currentStep === 1">
<p class="lead">从用户触达产品的全路径出发搭建从曝光到复购的完整数据漏斗逐层计算各环节转化率并对比行业均值定位产品在哪个环节出现了明显异常流失</p>
<div class="diagnosis-materials" aria-label=" 1 ">
<div class="diagnosis-materials__item"><div><strong>节点转化率计算</strong><span>使用案例数据计算从曝光到复购的各节点转化率</span></div><div class="diagnosis-materials__actions"><button type="button" class="secondary-button" @click="downloadNodeGuide"></button><button type="button" class="secondary-button" @click="downloadNodeTemplate">Excel</button></div></div>
<div class="diagnosis-materials__item"><div><strong>全链路转化漏斗可视化</strong><span>根据节点转化率绘制各渠道与行业均值的漏斗对比图</span></div><div class="diagnosis-materials__actions"><button type="button" class="secondary-button" @click="downloadFunnelGuide"></button><button type="button" class="secondary-button" @click="downloadFunnelTemplate">Excel</button></div></div>
</div>
<p class="lead">从曝光到复购逐层计算渠道转化率并与行业均值比较定位产品运营环节中的异常流失</p>
<section class="section-block"><div class="section-heading"><span></span><div><h3>计算各节点转化率</h3><p>根据案例数据填写各渠道链路节点转化率数值以百分比%填写</p></div></div><div class="table-wrap"><table class="funnel-table"><thead><tr><th>链路节点</th><th>天猫</th><th>京东</th><th>抖音</th><th>拼多多</th><th>行业均值</th></tr></thead><tbody><tr v-for="row in conversionRates" :key="row.linkNode"><th>{{ row.linkNode }}</th><td v-for="field in rateFields" :key="field"><el-input-number v-model="row[field]" :min="0" :max="100" :precision="2" :controls="false" placeholder="请输入" /><span class="unit">%</span></td></tr></tbody></table></div></section>
<section class="section-block visual-section"><div class="section-heading"><span></span><div><h3>全链路转化漏斗可视化</h3><p>根据上述转化率绘制各渠道与行业均值的节点转化漏斗对比图</p></div></div><input ref="chartInput" class="visually-hidden" type="file" accept="image/png,image/jpeg,image/webp" @change="handleChartUpload" /><div class="chart-upload-area"><template v-if="funnelChart.url"><div class="chart-preview"><el-image :src="funnelChart.url" :preview-src-list="[funnelChart.url]" fit="cover" preview-teleported /><div><strong>{{ funnelChart.name || '节点转化漏斗对比图' }}</strong><span>点击缩略图可放大预览</span><button type="button" @click="clearChart"></button></div></div><button type="button" class="secondary-button" :disabled="uploading" @click="chartInput?.click()">{{ uploading ? '' : '' }}</button></template><template v-else><button type="button" class="upload-button" :disabled="uploading" @click="chartInput?.click()">{{ uploading ? '' : '' }}</button><p>支持 PNGJPGWEBP 格式图片不超过 5MB上传后可点击预览</p></template></div></section>
<section class="section-block diagnosis-section"><div class="section-heading"><span></span><div><h3>定位异常节点</h3><p>对比行业均值数据定位异常节点并填写分析与判定</p></div></div><div class="table-toolbar"><span>默认 1 最多 5 渠道必须来源于上表</span><div><button type="button" class="table-button" :disabled="anomalyRows.length >= 5" @click="addAnomalyRow"></button><button type="button" class="table-button ghost" :disabled="anomalyRows.length <= 1" @click="removeAnomalyRow"></button></div></div><div class="table-wrap"><table class="diagnosis-table"><thead><tr><th>渠道</th><th>链路节点</th><th>店铺转化率</th><th>行业均值转化率</th><th>异常分析与判定</th></tr></thead><tbody><tr v-for="(row, index) in anomalyRows" :key="index"><td><el-select v-model="row.channel" placeholder="请选择"><el-option v-for="channel in channels" :key="channel.key" :label="channel.label" :value="channel.label" /></el-select></td><td><el-select v-model="row.linkNode" placeholder="请选择"><el-option v-for="node in nodes" :key="node" :label="node" :value="node" /></el-select></td><td><el-input-number v-model="row.storeConversionRate" :min="0" :max="100" :precision="2" :controls="false" placeholder="请输入" /><span class="unit">%</span></td><td><el-input-number v-model="row.industryAverageConversionRate" :min="0" :max="100" :precision="2" :controls="false" placeholder="请输入" /><span class="unit">%</span></td><td><el-input v-model="row.anomalyAnalysis" type="textarea" :rows="2" maxlength="300" show-word-limit placeholder="请填写异常原因或正常判定" /></td></tr></tbody></table></div><p class="diagnosis-step-one-notice"> 5 </p></section>
<section class="section-block diagnosis-section"><div class="section-heading"><span></span><div><h3>定位异常节点</h3><p>对比行业均值数据定位异常节点并填写分析与判定</p></div></div><div class="table-toolbar"><span>默认 1 最多 5 渠道必须来源于上表</span><div><button type="button" class="table-button" :disabled="anomalyRows.length >= 5" @click="addAnomalyRow"></button><button type="button" class="table-button ghost" :disabled="anomalyRows.length <= 1" @click="removeAnomalyRow"></button></div></div><div class="table-wrap"><table class="diagnosis-table"><thead><tr><th>渠道</th><th>链路节点</th><th>店铺转化率</th><th>行业均值转化率</th><th>异常分析与判定</th></tr></thead><tbody><tr v-for="(row, index) in anomalyRows" :key="index"><td><el-select v-model="row.channel" placeholder="请选择"><el-option v-for="channel in channels" :key="channel.key" :label="channel.label" :value="channel.label" /></el-select></td><td><el-select v-model="row.linkNode" placeholder="请选择"><el-option v-for="node in nodes" :key="node" :label="node" :value="node" /></el-select></td><td><el-input-number v-model="row.storeConversionRate" :min="0" :max="100" :precision="2" :controls="false" placeholder="请输入" /><span class="unit">%</span></td><td><el-input-number v-model="row.industryAverageConversionRate" :min="0" :max="100" :precision="2" :controls="false" placeholder="请输入" /><span class="unit">%</span></td><td><el-input v-model="row.anomalyAnalysis" type="textarea" :rows="2" maxlength="300" show-word-limit placeholder="请填写异常原因或正常判定" /></td></tr></tbody></table></div></section>
<div class="actions"><button type="button" class="secondary-button" :disabled="saving" @click="saveStepOneDraft">稿</button><button type="button" class="submit-button" :disabled="saving || uploading" @click="validateStepOne">{{ saving ? '' : ' 2 ' }}</button></div>
</template>
@ -34,11 +24,11 @@
</template>
<template v-else>
<p class="lead">找到根因后还需验证改进方案是否真的有效避免拍脑袋迭代AB测试对照诊断法将用户随机分为对照组维持原方案和实验组应用改进方案对比两组关键指标差异用数据判断改进方案是否值得落地</p>
<section class="section-block"><div class="section-heading"><span></span><div><h3>确定测试方案</h3><p>请基于案例资料针对步骤一和步骤二分析结果设计 AB 测试方案</p></div></div><el-input v-model="abTestPlan" type="textarea" :rows="6" maxlength="1000" show-word-limit placeholder="填写对照组与实验组设置、改进方案、随机分流方式、观察周期和成功判定标准" /></section>
<section class="section-block"><div class="section-heading"><span></span><div><h3>AB测试数据填报</h3><p>请从案例资料中获取 AB 测试两组数据计算后填入下表并逐项判定</p></div></div><div class="table-wrap"><table class="ab-table"><thead><tr><th>测试指标</th><th>对照组数据</th><th>实验组数据</th><th>判定与分析</th></tr></thead><tbody><tr v-for="row in abRows" :key="row.metric"><th>{{ row.metric }}</th><td><el-input-number v-model="row.controlValue" :min="0" :max="100" :precision="2" :controls="false" placeholder="56" /><span class="unit">%</span></td><td><el-input-number v-model="row.experimentValue" :min="0" :max="100" :precision="2" :controls="false" placeholder="64" /><span class="unit">%</span></td><td><el-input v-model="row.analysis" type="textarea" :rows="2" maxlength="300" show-word-limit placeholder="如:提升8pp显著提升采纳" /></td></tr></tbody></table></div></section>
<section class="section-block"><div class="section-heading"><span></span><div><h3>可视化呈现</h3><p>绘制 AB 测试结果对比图并上传作为改进方案的可视化证据</p></div></div><input ref="abChartInput" class="visually-hidden" type="file" accept="image/png,image/jpeg,image/webp" @change="handleAbChartUpload" /><div class="chart-upload-area"><template v-if="abChart.url"><div class="chart-preview"><el-image :src="abChart.url" :preview-src-list="[abChart.url]" fit="cover" preview-teleported /><div><strong>{{ abChart.name || 'AB 测试结果对比图' }}</strong><span>点击缩略图可放大预览</span><button type="button" @click="clearAbChart"></button></div></div><button type="button" class="secondary-button" :disabled="uploading" @click="abChartInput?.click()">{{ uploading ? '' : '' }}</button></template><template v-else><button type="button" class="upload-button" :disabled="uploading" @click="abChartInput?.click()">{{ uploading ? '' : 'AB' }}</button><p>支持 PNGJPGWEBP 格式图片不超过 5MB上传后可点击预览</p></template></div><div class="ab-materials"><div><ol><li>使用 Excel/WPS 绘图功能自主完成 AB 测试结果对比图可参考实训操作说明</li><li>下载 Excel 模板填写关键数据自动计算 AB 测试数据并生成测试结果对比图</li><li>也可导出数据表上传到通用 AI 大模型通过对照组和实验组数据对比生成组合柱状图</li></ol></div><div class="ab-materials__actions"><button type="button" class="secondary-button" @click="downloadAbGuide"></button><button type="button" class="secondary-button" @click="downloadAbTemplate">Excel</button></div></div></section>
<section class="section-block"><div class="section-heading"><span></span><div><h3>诊断结论与迭代优化建议</h3><p>结合全链路数据漏斗诊断售后评论分析法及 AB 测试对照诊断分析产品存在的问题并提出具体迭代优化建议</p></div></div><el-input v-model="iterationRecommendation" type="textarea" :rows="6" maxlength="1200" show-word-limit placeholder="请填写产品问题诊断结论及具体迭代优化建议" /></section>
<p class="lead">找到根因后还需要通过 AB 测试验证改进方案是否真正有效将用户随机分为维持原方案的对照组和应用改进方案的实验组用关键指标差异判断方案是否值得落地</p>
<section class="section-block"><div class="section-heading"><span></span><div><h3>确定测试方案</h3><p>请基于前两步的漏斗异常与评论根因设计 AB 测试方案</p></div></div><el-input v-model="abTestPlan" type="textarea" :rows="6" maxlength="1000" show-word-limit placeholder="说明对照组与实验组设置、改进内容、随机分流方式、观察周期及成功判定标准" /></section>
<section class="section-block"><div class="section-heading"><span></span><div><h3>AB 测试数据填报</h3><p>填写案例资料中的对照组与实验组数据并逐项给出判定与分析</p></div></div><div class="table-wrap"><table class="ab-table"><thead><tr><th>测试指标</th><th>对照组数据</th><th>实验组数据</th><th>判定与分析</th></tr></thead><tbody><tr v-for="row in abRows" :key="row.metric"><th>{{ row.metric }}</th><td><el-input-number v-model="row.controlValue" :min="0" :max="100" :precision="2" :controls="false" placeholder="请输入" /><span class="unit">%</span></td><td><el-input-number v-model="row.experimentValue" :min="0" :max="100" :precision="2" :controls="false" placeholder="请输入" /><span class="unit">%</span></td><td><el-input v-model="row.analysis" type="textarea" :rows="2" maxlength="300" show-word-limit placeholder="如:提升 8pp显著提升建议采用" /></td></tr></tbody></table></div></section>
<section class="section-block"><div class="section-heading"><span></span><div><h3>可视化呈现</h3><p>绘制 AB 测试结果对比图并上传作为改进方案的可视化证据</p></div></div><input ref="abChartInput" class="visually-hidden" type="file" accept="image/png,image/jpeg,image/webp" @change="handleAbChartUpload" /><div class="chart-upload-area"><template v-if="abChart.url"><div class="chart-preview"><el-image :src="abChart.url" :preview-src-list="[abChart.url]" fit="cover" preview-teleported /><div><strong>{{ abChart.name || 'AB 测试结果对比图' }}</strong><span>点击缩略图可放大预览</span><button type="button" @click="clearAbChart"></button></div></div><button type="button" class="secondary-button" :disabled="uploading" @click="abChartInput?.click()">{{ uploading ? '' : '' }}</button></template><template v-else><button type="button" class="upload-button" :disabled="uploading" @click="abChartInput?.click()">{{ uploading ? '' : ' AB ' }}</button><p>支持 PNGJPGWEBP 格式图片不超过 5MB上传后可点击预览</p></template></div></section>
<section class="section-block"><div class="section-heading"><span></span><div><h3>诊断结论与迭代优化建议</h3><p>结合全链路漏斗评论根因与 AB 测试对照结果明确产品问题及下一轮优化动作</p></div></div><el-input v-model="iterationRecommendation" type="textarea" :rows="6" maxlength="1200" show-word-limit placeholder="说明验证结论、需要保留或放大的改进方案、待优化问题及后续迭代建议" /></section>
<div class="actions"><button type="button" class="secondary-button" :disabled="saving" @click="saveStepThreeDraft">稿</button><button type="button" class="submit-button" :disabled="saving || uploading" @click="validateStepThree">{{ saving ? '' : ' 3 ' }}</button></div>
</template>
</section>
@ -59,19 +49,6 @@ import { analyzeProductDiagnosisIterationStepTwo, getStudentTrainingAnswer, save
import { parseTrainingArray } from "@/views/training/taskKeyMap";
import { isStudentDemo } from "@/utils/studentDemo";
const NODE_GUIDE_URL = "/file/training-materials/product-diagnosis-iteration/23.%E5%AE%9E%E8%AE%AD%E6%93%8D%E4%BD%9C%E8%AF%B4%E6%98%8E1.docx";
const NODE_TEMPLATE_URL = "/file/training-materials/product-diagnosis-iteration/23.Excel%E6%A8%A1%E7%89%881.xlsx";
const FUNNEL_GUIDE_URL = "/file/training-materials/product-diagnosis-iteration/23.%E5%AE%9E%E8%AE%AD%E6%93%8D%E4%BD%9C%E8%AF%B4%E6%98%8E2.docx";
const FUNNEL_TEMPLATE_URL = "/file/training-materials/product-diagnosis-iteration/23.Excel%E6%A8%A1%E7%89%882.xlsx";
const AB_TEST_GUIDE_URL = "/file/training-materials/product-diagnosis-iteration/23.%E5%AE%9E%E8%AE%AD%E6%93%8D%E4%BD%9C%E8%AF%B4%E6%98%8E3.docx";
const AB_TEST_TEMPLATE_URL = "/file/training-materials/product-diagnosis-iteration/23.Excel%E6%A8%A1%E7%89%883.xlsx";
function downloadBuiltInFile(url) { window.open(url, "_blank", "noopener"); }
function downloadNodeGuide() { downloadBuiltInFile(NODE_GUIDE_URL); }
function downloadNodeTemplate() { downloadBuiltInFile(NODE_TEMPLATE_URL); }
function downloadFunnelGuide() { downloadBuiltInFile(FUNNEL_GUIDE_URL); }
function downloadFunnelTemplate() { downloadBuiltInFile(FUNNEL_TEMPLATE_URL); }
function downloadAbGuide() { downloadBuiltInFile(AB_TEST_GUIDE_URL); }
function downloadAbTemplate() { downloadBuiltInFile(AB_TEST_TEMPLATE_URL); }
const TASK_KEY = "product-diagnosis-iteration"; const nodes = ["曝光-点击", "点击-加购", "加购-下单", "下单-好评", "好评-复购"]; const channels = [{ key: "tmall", label: "天猫" }, { key: "jd", label: "京东" }, { key: "douyin", label: "抖音" }, { key: "pinduoduo", label: "拼多多" }]; const rateFields = [...channels.map((item) => item.key), "industryAverage"];
const { proxy } = getCurrentInstance(); const taskConfig = ref(null); const currentStep = ref(1); const saving = ref(false); const uploading = ref(false); const analyzing = ref(false); const chartInput = ref(null); const commentInput = ref(null); const abChartInput = ref(null); const wordCloudRef = ref(null); let wordCloudChart = null;
const conversionRates = ref(createConversionRates()); const anomalyRows = ref([createAnomalyRow()]); const funnelChart = ref({ url: "", name: "" }); const analysisResult = ref(null); const commentPreview = ref({ fileName: "", totalComments: 0, comments: [] }); const wordCloud = ref([]); const topIssues = ref(["", "", ""]); const sentiments = ref(createSentiments()); const rootCauseAnalysis = ref(""); const abTestPlan = ref(""); const abRows = ref(createAbRows()); const abChart = ref({ url: "", name: "" }); const iterationRecommendation = ref("");
@ -95,14 +72,5 @@ onMounted(async () => { try { taskConfig.value = (await getTrainingTaskByKey(TAS
</script>
<style scoped lang="scss">
.diagnosis-materials{display:grid;gap:10px;margin:14px 0}.diagnosis-materials__item{display:flex;align-items:center;justify-content:space-between;gap:20px;padding:12px 14px;border:1px solid rgba(76,202,242,.32);border-radius:10px;background:rgba(3,42,61,.45)}.diagnosis-materials__item strong,.diagnosis-materials__item span{display:block}.diagnosis-materials__item strong{color:#e5faff;font-size:15px}.diagnosis-materials__item span{margin-top:4px;color:#93baca;font-size:13px}.diagnosis-materials__actions{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:8px}.diagnosis-step-one-notice{margin:12px 0 0;color:#ff7178;font-size:13px;line-height:1.6}@media (max-width:860px){.diagnosis-materials__item{align-items:flex-start;flex-direction:column}.diagnosis-materials__actions{justify-content:flex-start}}
.ab-materials{display:flex;align-items:center;justify-content:space-between;gap:20px;margin-top:18px;padding:14px 16px;border-left:3px solid #19c9ff;background:rgba(3,42,61,.45);color:#cde9f2}.ab-materials ol{margin:0;padding-left:20px;line-height:1.8}.ab-materials__actions{display:grid;flex:0 0 178px;gap:8px}@media (max-width:860px){.ab-materials{align-items:stretch;flex-direction:column}.ab-materials__actions{grid-template-columns:repeat(2,minmax(0,1fr));flex-basis:auto}}
.ab-table{width:100%;min-width:860px;border-collapse:collapse}.ab-table th,.ab-table td{border:1px solid rgba(139,213,241,.45)}.ab-table thead th{padding:14px 10px;color:#fff;background:#079ed9;font-size:16px}.ab-table tbody th{width:25%;padding:14px 10px;color:#dff8ff;background:rgba(6,67,94,.34);text-align:center}.ab-table td{padding:9px 11px;text-align:center}.ab-table td:nth-child(2),.ab-table td:nth-child(3){width:18%}.ab-table td:last-child{width:39%}
.step-indicator { display: flex; align-items: center; margin: 0 0 24px; padding: 16px 20px; overflow-x: auto; border: 1px solid rgba(0, 180, 255, .3); border-radius: 18px; background: rgba(1, 18, 31, .62); }
.step-tab { display: flex; flex: 1 0 145px; min-width: 0; align-items: center; gap: 10px; min-height: 66px; padding: 8px; border: 1px solid rgba(68, 141, 177, .35); border-radius: 8px; color: #9ab3d0; background: rgba(3, 27, 43, .52); font: inherit; text-align: left; cursor: pointer; }
.step-tab b { display: grid; place-items: center; width: 34px; height: 34px; border: 1px solid currentColor; border-radius: 50%; font-size: 12px; }
.step-tab span { display: grid; gap: 3px; font-weight: 800; }.step-tab small { color: inherit; font-size: 11px; font-weight: 400; }
.step-tab.active { border-color: #53d8fb; color: #fff; background: linear-gradient(115deg, rgba(4, 109, 155, 0.78), rgba(5, 62, 102, 0.64)); }
.step-connector { position: relative; flex: 0 1 32px; min-width: 18px; height: 2px; overflow: visible; background: rgba(98, 169, 201, .34); }
.step-connector::after { position: absolute; top: 50%; right: -1px; width: 6px; height: 6px; border-top: 1px solid #73b1ce; border-right: 1px solid #73b1ce; content: ""; transform: translateY(-50%) rotate(45deg); }
</style>

@ -25,7 +25,6 @@
<button type="button" :class="['step-tab', { active: activeStep === index + 1, complete: activeStep > index + 1 }]" @click="setCurrentStep(index + 1)">
<span>{{ String(index + 1).padStart(2, '0') }}</span>{{ step }}
</button>
<span v-if="index < configuredSteps.length - 1" class="step-connector" aria-hidden="true"></span>
</template>
</nav>
@ -111,20 +110,6 @@
<div class="pareto-actions"><button type="button" class="outline-action" :disabled="uploadingPareto" @click="paretoInput?.click()"><el-icon><UploadFilled /></el-icon></button><button type="button" class="outline-action danger-action" @click="removeParetoImage"><el-icon><Delete /></el-icon></button></div>
</template>
</div>
<section class="pareto-materials" aria-label="">
<div>
<h3>参考方法及工具如下</h3>
<ol>
<li>下载案例数据表使用 Excel 计算各品类占比累计占比并进行 ABC 分层判定绘制帕累托图</li>
<li>下载 Excel 模板填写关键数据完成帕累托图绘制</li>
<li>也可导出数据表并使用通用 AI 工具辅助完成计算与可视化</li>
</ol>
</div>
<div class="pareto-materials__actions">
<button type="button" class="action-button" @click="downloadParetoGuide"></button>
<button type="button" class="action-button action-button--primary" @click="downloadParetoTemplate"> Excel </button>
</div>
</section>
<div class="rule-notice">
<strong>上传校验说明</strong>
<p>帕累托图必须根据步骤二已完成的ABC分层数据绘制未上传图片时不能进入下一步或提交任务</p>
@ -196,8 +181,6 @@ import { parseTrainingArray } from "@/views/training/taskKeyMap";
import { isStudentDemo } from "@/utils/studentDemo";
const TASK_KEY = "category-optimization";
const PARETO_GUIDE_URL = "/file/training-materials/category-optimization/20.实训操作说明.docx";
const PARETO_TEMPLATE_URL = "/file/training-materials/category-optimization/20.帕累托绘制自动化模版.xlsx";
const MAX_ROWS = 30;
const taskConfig = ref(null);
const activeStep = ref(1);
@ -308,16 +291,6 @@ async function handleParetoUpload(event) {
} catch (error) { ElMessage.error(error?.message || "帕累托图上传失败,请重试"); } finally { uploadingPareto.value = false; }
}
function removeParetoImage() { if (paretoImage.value.url.startsWith("blob:")) URL.revokeObjectURL(paretoImage.value.url); paretoImage.value = { url: "", name: "" }; }
function downloadParetoGuide() { downloadBuiltInFile(PARETO_GUIDE_URL, "20.实训操作说明.docx"); }
function downloadParetoTemplate() { downloadBuiltInFile(PARETO_TEMPLATE_URL, "20.帕累托绘制自动化模版.xlsx"); }
function downloadBuiltInFile(url, fileName) {
const link = document.createElement("a");
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
function handleProductChange() { if (stepFour.value.recommendedClassification === selectedProduct.value?.abcClassification) stepFour.value.recommendedClassification = ""; }
async function validateStepFour() {
try {
@ -386,12 +359,7 @@ onMounted(async () => {
.category-step{padding:24px}.instruction{margin:0 0 20px;color:#d8edf5;font-size:15px;line-height:1.75}.table-toolbar{display:flex;justify-content:space-between;gap:14px;margin-bottom:12px;color:var(--muted);font-size:13px}.action-button--muted{border-color:rgba(158,192,207,.35);background:rgba(75,101,112,.25)}.category-table-wrap{overflow-x:auto;border:1px solid var(--line);border-radius:12px}.category-table{width:100%;min-width:1260px;border-collapse:collapse}.category-table th,.category-table td{padding:10px;border-right:1px solid var(--line);border-bottom:1px solid var(--line);text-align:center}.category-table th{color:#e6f9ff;background:rgba(23,112,150,.42);font-size:13px;white-space:nowrap}.category-table td{background:rgba(2,18,28,.32)}.category-table tr:last-child td{border-bottom:0}.category-table th:last-child,.category-table td:last-child{border-right:0}.empty-cell{height:88px;color:var(--muted)}.abc-table{min-width:900px}.abc-table :deep(.el-select){width:100%;min-width:180px}
.rule-notice{margin-top:20px;padding:16px 18px;border:1px solid rgba(255,112,112,.46);border-radius:12px;background:rgba(126,31,35,.15);color:#ffd5d5}.rule-notice strong{color:#ff9292}.rule-notice p{margin:8px 0 0;line-height:1.65}.analysis-step{padding:24px}.analysis-tabs{display:flex;gap:10px;margin-bottom:18px}.analysis-tabs button{padding:9px 14px;border:1px solid var(--line);border-radius:8px;color:var(--muted);background:transparent;font:inherit;cursor:pointer}.analysis-tabs button.active{color:white;border-color:#71dfff;background:rgba(12,114,159,.48)}.business-workspace{overflow:hidden;border:1px solid var(--line);border-radius:14px}.step-navigation{justify-content:center;padding:20px 24px 0}.nav-button--save,.nav-button--primary{border-color:#71dfff;background:linear-gradient(100deg,#0d80b7,#09638f)}.training-actions{justify-content:center;gap:28px;padding:26px 24px}.training-actions :deep(.training-action){min-width:160px;height:50px;border-color:#2c83aa;color:#dff7ff;background:rgba(13,64,88,.62);font-size:17px;font-weight:800}
.pareto-upload-panel{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;min-height:330px;padding:26px;border:1px dashed rgba(113,223,255,.7);border-radius:16px;background:linear-gradient(135deg,rgba(5,47,69,.7),rgba(4,23,35,.58));text-align:center}.file-input{display:none}.upload-icon{font-size:42px;color:#71dfff}.pareto-upload-panel>strong{font-size:20px}.pareto-upload-panel>span,.upload-file-meta span{color:var(--muted);font-size:13px}.pareto-preview{width:min(100%,760px);height:270px;border:1px solid var(--line);border-radius:10px;background:rgba(0,10,18,.65);cursor:zoom-in}.upload-file-meta{display:grid;gap:5px;max-width:100%;word-break:break-all}.pareto-actions{display:flex;flex-wrap:wrap;justify-content:center;gap:10px}.danger-action{border-color:rgba(255,145,145,.6);color:#ffd7d7;background:rgba(130,43,43,.23)}
.pareto-materials{display:flex;align-items:center;justify-content:space-between;gap:22px;margin-top:20px;padding:18px 20px;border-left:3px solid var(--accent);border-radius:0 14px 14px 0;background:rgba(11,79,109,.22);color:#d8edf5}.pareto-materials h3{margin:0 0 8px;font-size:16px}.pareto-materials ol{margin:0;padding-left:20px;line-height:1.8}.pareto-materials__actions{display:grid;flex:0 0 178px;gap:10px}.action-button--primary{border-color:#71dfff;background:linear-gradient(100deg,#0d80b7,#09638f)}
.strategy-step{display:grid;gap:16px}.subsection-heading{margin:8px 0 0;font-size:18px}.subsection-copy{margin:-8px 0 0;color:var(--muted);line-height:1.6}.category-matrix{position:relative;width:min(100%,820px);height:430px;margin:12px auto 22px;padding:38px 52px 52px 78px}.matrix-grid{display:grid;grid-template-columns:1fr 1fr;grid-template-rows:1fr 1fr;width:100%;height:100%;border-left:2px solid rgba(185,240,255,.82);border-bottom:2px solid rgba(185,240,255,.82);background:linear-gradient(135deg,rgba(24,169,209,.12),rgba(3,25,38,.16))}.matrix-cell{display:grid;place-content:center;gap:7px;padding:18px;border-right:1px solid rgba(185,240,255,.65);border-bottom:1px solid rgba(185,240,255,.65);text-align:center}.matrix-cell:nth-child(2n){border-right:0}.matrix-cell:nth-child(n+3){border-bottom:0}.matrix-cell strong{font-size:18px}.matrix-cell small{color:#8dddf5}.matrix-y-title,.matrix-x-title,.matrix-y-high,.matrix-y-low,.matrix-x-high,.matrix-x-low{position:absolute;color:#d9f8ff;font-weight:800}.matrix-y-title{top:5px;left:78px}.matrix-x-title{right:0;bottom:15px}.matrix-y-high{top:63px;left:39px}.matrix-y-low{bottom:61px;left:39px}.matrix-x-low{bottom:29px;left:79px}.matrix-x-high{right:44px;bottom:29px}.matrix-inputs{display:grid;grid-template-columns:minmax(210px,.42fr) 1fr;gap:16px}.matrix-inputs label{display:grid;gap:8px;color:#d9f8ff;font-weight:700}.strategy-table{min-width:1120px}.strategy-table td{vertical-align:top}.strategy-table :deep(.el-select){width:100%;min-width:160px}.strategy-table :deep(.el-textarea__inner){min-height:78px}.current-classification{display:inline-block;padding:8px 10px;border-radius:8px;color:#a5efff;background:rgba(17,126,178,.24);white-space:nowrap}
.operations-table{min-width:1050px}.operations-table th{color:#fff;background:linear-gradient(100deg,#08a6df,#098bc1);font-size:16px}.operations-table td{vertical-align:middle}.operations-table td:first-child{width:17%;font-weight:800}.operations-table td:nth-child(2){width:34%}.operations-table td:nth-child(3){width:25%}.operations-table td:last-child{width:24%}.operations-table :deep(.el-textarea__inner){min-height:86px;text-align:left}.operations-table :deep(.el-select){width:100%;min-width:160px}.layer-cell{color:#dff8ff;font-size:16px;background:rgba(15,103,137,.18)!important}
:deep(.el-input__wrapper){background:rgba(1,15,25,.76);box-shadow:0 0 0 1px rgba(94,213,245,.28) inset}:deep(.el-input__inner){color:#fff;text-align:center}@media(max-width:1100px){.optimization{grid-template-columns:1fr}}@media(max-width:720px){.optimization{padding:12px}.training-core{padding:16px;border-radius:20px}.workbench-head,.table-toolbar{flex-direction:column;align-items:stretch}.workbench-actions,.table-toolbar>div,.step-navigation,.training-actions{justify-content:stretch}.workbench-actions>*,.action-button,.nav-button,.training-actions :deep(.training-action){flex:1;width:100%}.category-step,.analysis-step{padding:16px}}
.step-tab { min-width: 0; }
.step-tab.active { background: linear-gradient(115deg, rgba(4, 109, 155, 0.78), rgba(5, 62, 102, 0.64)); }
.step-connector { position: relative; flex: 0 1 32px; min-width: 18px; height: 2px; overflow: visible; background: rgba(98, 169, 201, .34); }
.step-connector::after { position: absolute; top: 50%; right: -1px; width: 6px; height: 6px; border-top: 1px solid #73b1ce; border-right: 1px solid #73b1ce; content: ""; transform: translateY(-50%) rotate(45deg); }
</style>

@ -154,7 +154,6 @@ import codemirror from "@/components/codemirror/index.vue";
import popModel from "@/views/components/popModal.vue";
const { proxy } = getCurrentInstance();
const TASK_KEY = "purchase-demand-forecast";
const PURCHASE_DEMAND_FORECAST_GUIDE_URL = "/file/training-materials/purchase-demand-forecast/18.实训操作说明.docx";
const taskConfig = ref(null);
const activeStep = ref(1);
const saving = ref(false);
@ -538,15 +537,14 @@ const drawForecastChart = () => {
window.addEventListener("resize", () => myChart.resize());
};
function downloadPracticeGuide() {
downloadBuiltInFile(PURCHASE_DEMAND_FORECAST_GUIDE_URL, "18.实训操作说明.docx");
}
function downloadBuiltInFile(url, fileName) {
const content = "采购需求预测实训操作说明\n\n1. 根据案例给出的 12 个月销量数据,使用 FORECAST.LINEARWPS 可使用 FORECAST函数预测 t=13、t=14、t=15。\n2. 将每个月的预测结果取整后,填写到预测表中。\n3. 将已知 12 个月销量和未来 3 个月预测值制作成散点图,并添加趋势线。\n4. 导出图形后上传至第 2 步,确认图中能清晰呈现历史销量、预测值和整体走势。";
const blob = new Blob([content], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.download = "采购需求预测-实训操作说明.txt";
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
function clearDemandForecastChart() {
const imageUrl = String(demandForecastChart.value?.url || "");

@ -8,10 +8,7 @@
<h1>{{ taskTitle }}</h1>
<p>分别从试产与量产场景评估供应渠道形成可执行的采购与风险管理建议</p>
</div>
<div class="workspace-header__actions">
<TrainingMaterialButton :material-name="taskConfig?.materialName" :material-url="taskConfig?.materialUrl" />
<span class="task-badge">实训任务</span>
</div>
<span class="task-badge">实训任务</span>
</header>
<TrainingTaskBrief
@ -25,11 +22,11 @@
<button :class="['step-tab', { active: currentStep === 1, complete: currentStep > 1 }]" :aria-current="currentStep === 1 ? 'step' : undefined" @click="currentStep = 1">
<span class="step-marker">01</span><span class="step-copy"><b>设置评估权重</b><small>{{ currentStep === 1 ? '当前步骤 →' : '点击切换 →' }}</small></span>
</button>
<span class="step-connector" aria-hidden="true"></span>
<i></i>
<button :class="['step-tab', { active: currentStep === 2, complete: currentStep > 2 }]" :aria-current="currentStep === 2 ? 'step' : undefined" @click="openStepTwo">
<span class="step-marker">02</span><span class="step-copy"><b>渠道评分与对比</b><small>{{ currentStep === 2 ? '当前步骤 →' : '点击切换 →' }}</small></span>
</button>
<span class="step-connector" aria-hidden="true"></span>
<i></i>
<button :class="['step-tab', { active: currentStep === 3 }]" :aria-current="currentStep === 3 ? 'step' : undefined" @click="openStepThree">
<span class="step-marker">03</span><span class="step-copy"><b>采购建议与风险</b><small>{{ currentStep === 3 ? '当前步骤 →' : '点击切换 →' }}</small></span>
</button>
@ -197,7 +194,6 @@ import { computed, onMounted, ref } from "vue";
import { CircleCheck, Delete, Plus } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
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";
@ -529,7 +525,7 @@ onMounted(async () => {
.workspace-shell { box-sizing: border-box; width: min(100%, 1520px); margin: 0 auto; padding: 28px; border: 1px solid rgba(0, 180, 255, .25); border-radius: 36px; background: rgba(8, 18, 28, .6); box-shadow: inset 0 1px 0 rgba(169, 229, 255, .08), 0 22px 48px rgba(0, 0, 0, .22); }
.supplier-page .training-core { box-sizing: border-box; min-width: 0; width: 100%; margin: 0; padding: 28px; border: 1px solid rgba(0, 180, 255, .25); border-radius: 36px; background: rgba(8, 18, 28, .6); box-shadow: inset 0 1px 0 rgba(169, 229, 255, .08), 0 22px 48px rgba(0, 0, 0, .22); }
.supplier-page .workspace-shell { width: 100%; max-width: none; margin: 0; padding: 0; border: 0; border-radius: 0; background: transparent; box-shadow: none; }
.workspace-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; margin-bottom: 24px; }.workspace-header__actions { display: flex; align-items: center; gap: 10px; }
.workspace-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; margin-bottom: 24px; }
.eyebrow, .step-caption { margin: 0 0 7px; color: #63dcff; font-weight: 700; letter-spacing: .1em; }
.workspace-header h1, .intro-card h2 { margin: 0; color: #fff; }.workspace-header h1 { margin-top: 12px; font-size: 30px; font-weight: 900; line-height: 1.3; }
.workspace-header p:not(.eyebrow), .intro-card > p:last-child { margin: 10px 0 0; color: #9db4c4; line-height: 1.75; }
@ -563,7 +559,4 @@ onMounted(async () => {
.recommendation-card { margin-top: 20px; }.recommendation-card label { display: block; margin-bottom: 9px; color: #e7f5fb; font-weight: 700; }.recommendation-card :deep(.el-textarea__inner) { min-height: 130px !important; color: #e7f6fd; background: rgba(2, 15, 24, .7); box-shadow: 0 0 0 1px #315f75 inset; }
.risk-heading { align-items: flex-end; }.row-actions { display: flex; gap: 10px; }.panel-tip { margin: -4px 0 16px; color: #9db8c6; line-height: 1.7; }.risk-table { min-width: 1080px; }.risk-table th:nth-child(1) { width: 15%; }.risk-table th:nth-child(2) { width: 29%; }.risk-table th:nth-child(3) { width: 16%; }.risk-table :deep(.el-select) { width: 100%; }.default-risk-row :deep(.el-input__inner), .default-risk-row :deep(.el-textarea__inner), .default-risk-row :deep(.el-select__selected-item) { color: #b7cbd6; font-style: italic; }
@media (max-width: 980px) { .weights-grid { grid-template-columns: 1fr; }.step-nav { grid-template-columns: 1fr; gap: 8px; }.step-nav i { display: none; }.workspace-header { flex-direction: column; }.risk-heading { align-items: flex-start; flex-direction: column; }.split-actions { gap: 12px; }.supplier-page { padding: 12px; }.workspace-shell { padding: 16px; } }
.step-tab.active { background: linear-gradient(115deg, rgba(4, 109, 155, 0.78), rgba(5, 62, 102, 0.64)); }
.step-connector { position: relative; height: 2px; overflow: visible; background: rgba(86, 198, 235, .48); }
.step-connector::after { position: absolute; top: 50%; right: -1px; width: 7px; height: 7px; border-top: 1px solid #73b1ce; border-right: 1px solid #73b1ce; content: ''; transform: translateY(-50%) rotate(45deg); }
</style>

@ -9,13 +9,7 @@
<p class="ai-instruction">点击参考提示词可自动带入问题AI 将结合当前实训内容给出建议</p>
<div class="ai-output">
<div class="output-glow"></div>
<div class="study-messages" aria-live="polite">
<p v-if="!assistMessages.length" class="study-message study-message--assistant">{{ displayAssistMessage }}</p>
<div v-for="message in assistMessages" :key="message.id" class="study-message" :class="`study-message--${message.role}`">
<strong>{{ message.role === "student" ? "我" : "AI助学" }}</strong>
<span>{{ message.content }}</span>
</div>
</div>
<p>{{ displayAssistMessage }}</p>
<button v-if="referenceText" type="button" class="reference-block" @click="useReferencePrompt">
<strong>{{ referenceTitle }}</strong>
<span v-html="referenceText"></span>
@ -104,11 +98,9 @@ const internalScore = ref("--");
const internalScoreVisible = ref(false);
const assessmentLoading = ref(false);
const evaluation = ref({});
const assistMessages = ref([]);
const route = useRoute();
const currentTaskKey = computed(() => resolveTrainingTaskKey(route, props.taskKey));
const reports = computed(() => normalizeEvaluationReports(evaluation.value));
const currentStepContext = computed(() => [props.studyTip, props.referenceTitle, plainText(props.referenceText)].filter(Boolean).join("\n"));
const displayAssistMessage = computed(() => assistMessage.value || props.studyTip);
const displayScoreVisible = computed(() => props.scoreVisible || internalScoreVisible.value || reports.value.score !== null);
@ -119,10 +111,7 @@ const displayEvalMessage = computed(() => {
});
onMounted(loadEvaluation);
watch(currentTaskKey, (nextTaskKey, previousTaskKey) => {
if (nextTaskKey !== previousTaskKey) assistMessages.value = [];
loadEvaluation();
});
watch(currentTaskKey, loadEvaluation);
async function loadEvaluation() {
if (!currentTaskKey.value) return;
@ -140,22 +129,15 @@ async function requestAssist() {
assistMessage.value = "请输入问题,或点击上方参考提示词后发送。";
return;
}
assistMessages.value.push({ id: `student-${Date.now()}`, role: "student", content: question });
assistQuestion.value = "";
if (currentTaskKey.value) {
try {
await requestAiTrainingHelp(currentTaskKey.value, question, currentStepContext.value);
await requestAiTrainingHelp(currentTaskKey.value, question);
await loadEvaluation();
assistMessage.value = reports.value.helpReport || props.assistText;
assistMessages.value.push({ id: `assistant-${Date.now()}`, role: "assistant", content: assistMessage.value });
return;
} catch (error) {
assistMessages.value.push({ id: `assistant-${Date.now()}`, role: "assistant", content: "AI 助学暂时不可用,请稍后重试。" });
return;
}
} catch (error) {}
}
assistMessage.value = `围绕“${question}”,建议先对照任务目标检查信息是否完整,再补充数据依据和分析结论。`;
assistMessages.value.push({ id: `assistant-${Date.now()}`, role: "assistant", content: assistMessage.value });
}
async function requestEvaluation() {
@ -175,26 +157,18 @@ async function requestEvaluation() {
internalEvalMessage.value = "AI 助评暂时不可用,请稍后重试。";
} finally { assessmentLoading.value = false; }
}
function plainText(value) {
return String(value || "")
.replace(/<[^>]*>/g, " ")
.replace(/&nbsp;/g, " ")
.replace(/\s+/g, " ")
.trim();
}
</script>
<style lang="scss" scoped>
.training-ai-sidebar {
display: flex;
flex-direction: column;
gap: 16px;
gap: 22px;
align-self: start;
min-height: calc(100vh - 106px);
padding: 8px;
padding: 10px;
border: 1px solid rgba(0, 174, 255, 0.22);
border-radius: 22px;
border-radius: 26px;
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%);
@ -262,31 +236,6 @@ function plainText(value) {
margin: 0;
}
}
.study-messages {
position: relative;
z-index: 1;
display: flex;
max-height: 310px;
flex-direction: column;
gap: 10px;
overflow-y: auto;
}
.study-message {
display: flex;
flex-direction: column;
gap: 4px;
margin: 0;
padding: 9px 10px;
border: 1px solid rgba(129, 211, 255, 0.2);
border-radius: 10px;
white-space: pre-wrap;
strong { font-size: 12px; color: #75e9ff; }
span { color: #d9efff; }
&--student { align-self: flex-end; background: rgba(16, 105, 151, 0.26); }
&--assistant { background: rgba(6, 23, 36, 0.6); }
}
.ai-instruction { margin: -4px 0 12px; color: #9fcbe2; font-size: 13px; line-height: 1.6; }
.reference-block { width: 100%; border: 0; background: transparent; padding: 14px 0 0; text-align: left; cursor: pointer; }
.reference-block:hover strong { color: #77eaff; }

@ -25,47 +25,27 @@ 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: normalizeMaterialUrl(url) };
return { name, 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 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() {
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 = objectUrl;
link.download = material.value.name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(objectUrl);
} catch (error) {
console.warn("Case material download failed", error);
ElMessage.error("案例资料文件不存在或暂不可下载,请联系管理员重新上传。");
}
const link = document.createElement("a");
link.href = material.value.url;
link.target = "_blank";
link.rel = "noopener";
link.download = material.value.name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
if (!previewable.value) ElMessage.info("该文件类型暂不支持在线预览,已为您打开下载。");
}
</script>
<style lang="scss" scoped>
.material-actions{display:flex;flex-wrap:wrap;gap:8px}.material-button{display:inline-flex;align-items:center;justify-content:center;gap:6px;min-height:32px;padding:0 10px;border-color:rgba(99,217,255,.55);border-radius:7px;color:#e9fbff;background:rgba(17,126,178,.36);font-size:13px;font-weight:800}.material-button:hover{color:#e9fbff;border-color:rgba(116,235,255,.85);background:rgba(18,146,204,.5)}.image-preview{display:flex;justify-content:center;max-height:68vh;overflow:auto;background:#0b1720}.image-preview img{display:block;max-width:100%;height:auto;object-fit:contain}.pdf-preview{width:100%;height:68vh;border:0;background:#fff}
.material-actions{display:flex;flex-wrap:wrap;gap:10px}.material-button{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:40px;padding:0 16px;border-color:rgba(99,217,255,.55);border-radius:9px;color:#e9fbff;background:rgba(17,126,178,.36);font-size:14px;font-weight:800}.material-button:hover{color:#e9fbff;border-color:rgba(116,235,255,.85);background:rgba(18,146,204,.5)}.image-preview{display:flex;justify-content:center;max-height:68vh;overflow:auto;background:#0b1720}.image-preview img{display:block;max-width:100%;height:auto;object-fit:contain}.pdf-preview{width:100%;height:68vh;border:0;background:#fff}
</style>

@ -131,10 +131,10 @@ function normalizeGoals(value) {
<style lang="scss" scoped>
.training-task-brief {
margin-bottom: 14px;
padding: 12px 16px;
margin-bottom: 28px;
padding: 16px 20px;
border: 1px solid #2d5f7e;
border-radius: 20px;
border-radius: 24px;
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: 10px;
margin-bottom: 14px;
}
.brief-tabs {
@ -233,8 +233,8 @@ function normalizeGoals(value) {
}
.brief-scroll {
max-height: 160px;
min-height: 110px;
max-height: 178px;
min-height: 126px;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
@ -243,10 +243,10 @@ function normalizeGoals(value) {
.brief-content {
margin: 0;
padding: 10px 16px 12px;
padding: 14px 16px 16px;
color: #d9efff;
font-size: 15px;
line-height: 1.55;
line-height: 1.85;
}
.brief-goals {
@ -254,12 +254,12 @@ function normalizeGoals(value) {
list-style: none;
li {
padding: 5px 0 5px 12px;
padding: 8px 0 8px 12px;
border-left: 2px solid rgba(105, 221, 255, 0.5);
}
li + li {
margin-top: 2px;
margin-top: 4px;
}
}
@ -296,7 +296,7 @@ function normalizeGoals(value) {
}
.brief-scroll {
height: 140px;
height: 160px;
}
}
</style>

@ -24,7 +24,7 @@
<section v-if="currentStep === 1" class="step-card">
<div class="step-heading"><div><p>Step 01</p><h2>商业可行性论证</h2></div><em>结构化论证</em></div>
<p class="intro">商业可行性论证需要从多个维度系统展开以下提供了可行性分析的各个子模块根据案例信息完善填写以下信息</p>
<p class="intro">商业可行性论证需要从多个维度系统展开请基于案例信息完成项目市场用户商业模式成本收益和风险的论证</p>
<section class="form-section">
<div class="section-title"><span>01</span><div><h3>项目概述</h3><p>结合前序 PESTSWOT 分析说明项目为何值得推进</p></div></div>
@ -42,7 +42,7 @@
<section class="form-section">
<div class="section-title"><span>03</span><div><h3>目标用户群体分析</h3><p>从不同消费能力和使用场景中识别最有价值的用户</p></div></div>
<div class="table-wrap"><table><thead><tr><th>用户群体</th><th>典型场景</th><th>核心需求</th><th>消费能力</th></tr></thead><tbody><tr v-for="(user, index) in form.targetUsers" :key="index"><td><textarea v-model.trim="user.userGroup" :placeholder="targetUserExamples[index].userGroup" /></td><td><textarea v-model.trim="user.scenario" :placeholder="targetUserExamples[index].scenario" /></td><td><textarea v-model.trim="user.coreNeed" :placeholder="targetUserExamples[index].coreNeed" /></td><td><input v-model.trim="user.spendingPower" :placeholder="targetUserExamples[index].spendingPower" /></td></tr></tbody></table></div>
<div class="table-wrap"><table><thead><tr><th>用户群体</th><th>典型场景</th><th>核心需求</th><th>消费能力</th></tr></thead><tbody><tr v-for="(user, index) in form.targetUsers" :key="index"><td><textarea v-model.trim="user.userGroup" placeholder="例如25-35 岁健身爱好者" /></td><td><textarea v-model.trim="user.scenario" placeholder="例如:居家训练" /></td><td><textarea v-model.trim="user.coreNeed" placeholder="例如:专业指导与反馈" /></td><td><input v-model.trim="user.spendingPower" placeholder="高 / 中 / 低" /></td></tr></tbody></table></div>
</section>
<section class="form-section">
@ -50,18 +50,16 @@
<div class="table-wrap"><table><thead><tr><th>模块</th><th>案例公司的策略</th><th>说明</th></tr></thead><tbody><tr v-for="model in form.businessModels" :key="model.module"><td class="fixed-cell">{{ model.module }}</td><td><textarea v-model.trim="model.strategy" placeholder="填写渠道、产品组合或服务策略" /></td><td><textarea v-model.trim="model.explanation" placeholder="说明策略与用户、成本的关系" /></td></tr></tbody></table></div>
</section>
<section class="form-section">
<div class="section-title"><span>05</span><div><h3>成本结构分析</h3><p>填写各项单位成本单位总成本由系统自动汇总</p></div></div>
<div class="table-wrap"><table><thead><tr><th>成本项目</th><th>金额/</th></tr></thead><tbody><tr v-for="cost in form.costs" :key="cost.id"><td class="fixed-cell">{{ cost.label }}</td><td><input v-model="cost.amount" type="number" min="0" step="0.01" placeholder="填写金额" /></td></tr><tr class="total-row"><td>单位总成本</td><td><input :value="currency(totalCost)" readonly aria-label="" /></td></tr></tbody></table></div>
</section>
<section class="form-section">
<div class="section-title"><span>06</span><div><h3>定价与毛利分析</h3><p>单位毛利 = 单位目标售价 单位总成本毛利率 = 单位毛利 ÷ 单位总成本</p></div></div>
<div class="table-wrap"><table><thead><tr><th>指标</th><th>数值</th></tr></thead><tbody><tr><td class="fixed-cell">目标售价/</td><td><input v-model="form.targetPrice" type="number" min="0" step="0.01" placeholder="填写目标售价" /></td></tr><tr class="total-row"><td>单位毛利(元/台)</td><td><input :value="currency(unitGrossProfit)" readonly aria-label="单位毛利" /></td></tr><tr class="total-row"><td>毛利率%</td><td><input :value="percent(grossMargin)" readonly aria-label="" /></td></tr></tbody></table></div>
<section class="form-section cost-section">
<div class="section-title"><span>05</span><div><h3>成本结构定价与毛利</h3><p>单位总成本单位毛利和毛利率由系统自动汇总毛利率按照原型公式单位毛利 ÷ 单位总成本计算</p></div></div>
<div class="cost-layout">
<div class="table-wrap"><table><thead><tr><th>成本项目</th><th>金额/</th></tr></thead><tbody><tr v-for="cost in form.costs" :key="cost.id"><td>{{ cost.label }}</td><td><input v-model="cost.amount" type="number" min="0" step="0.01" placeholder="0.00" /></td></tr><tr class="total-row"><td>单位总成本</td><td>{{ currency(totalCost) }}</td></tr></tbody></table></div>
<div class="profit-panel"><div class="profit-formula">单位毛利 = 目标售价 单位总成本<br />毛利率 = 单位毛利 ÷ 单位总成本</div><label><span>目标售价/</span><input v-model="form.targetPrice" type="number" min="0" step="0.01" placeholder="填写目标售价" /></label><div class="computed"><span>单位毛利</span><b>{{ currency(unitGrossProfit) }} 元</b></div><div class="computed"><span>毛利率</span><b>{{ percent(grossMargin) }}</b></div></div>
</div>
</section>
<section class="form-section">
<div class="section-title"><span>07</span><div><h3>风险识别与评估</h3><p>识别项目主要风险并明确影响程度及可执行的应对措施</p></div></div>
<div class="section-title"><span>06</span><div><h3>风险识别与评估</h3><p>识别项目主要风险并明确影响程度及可执行的应对措施</p></div></div>
<div class="table-wrap"><table><thead><tr><th>风险类型</th><th>具体风险描述</th><th>影响程度</th><th>应对措施</th></tr></thead><tbody><tr v-for="risk in form.risks" :key="risk.type"><td class="fixed-cell">{{ risk.type }}</td><td><textarea v-model.trim="risk.description" placeholder="填写具体风险" /></td><td><select v-model="risk.impact"><option value="">请选择</option><option value="高">高</option><option value="中">中</option><option value="低"></option></select></td><td><textarea v-model.trim="risk.response" placeholder="填写应对措施" /></td></tr></tbody></table></div>
</section>
@ -115,11 +113,11 @@ import TrainingTaskBrief from "@/views/components/TrainingTaskBrief.vue";
const TASK_KEY = "business-feasibility";
const DEFAULT_STEPS = ["商业可行性论证", "项目初步决策", "可行性论证报告", "决策结论"];
const overviewFields = [
{ key: "projectBackground", label: "项目背景", placeholder: "示例:根据案例信息简述公司基本情况,再简要阐述智能家居、个护健康、美妆工具等相关市场发展情况和需求情况。" },
{ key: "productPositioning", label: "产品定位", placeholder: "示例:阐述项目产品针对的目标市场用户、核心卖点、使用场景、差异化理由以及用户收益。" },
{ key: "projectGoals", label: "项目目标", placeholder: "示例:填写 12 个月短期目标24 个月中期目标 36 个月长期目标" },
{ key: "macroEnvironmentConclusion", label: "宏观环境分析结论", placeholder: "示例:项目产品 PEST 分析结论。" },
{ key: "competitiveLandscapeConclusion", label: "竞争态势分析结论", placeholder: "示例:项目产品 SWOT 分析结论。" },
{ key: "projectBackground", label: "项目背景", placeholder: "概述公司与相关品类市场的发展、需求情况" },
{ key: "productPositioning", label: "产品定位", placeholder: "说明目标用户、核心卖点、使用场景与差异化理由" },
{ key: "projectGoals", label: "项目目标", placeholder: "填写短期、中期和长期可衡量目标" },
{ key: "macroEnvironmentConclusion", label: "宏观环境分析结论", placeholder: "总结 PEST 分析对项目的影响" },
{ key: "competitiveLandscapeConclusion", label: "竞争态势分析结论", placeholder: "总结 SWOT / 竞争分析带来的机会与压力" },
];
const marketFields = [
{ key: "annualMarketSize", label: "年度市场规模", type: "number", suffix: "亿元", placeholder: "例如 80" },
@ -128,11 +126,6 @@ const marketFields = [
{ key: "competitorsPriceRange", label: "主要竞品售价区间", suffix: "元", placeholder: "例如 299 - 899" },
{ key: "marketConcentrationCr3", label: "市场集中度CR3", type: "number", suffix: "%", placeholder: "例如 58" },
];
const targetUserExamples = [
{ userGroup: "示例18-35 岁一二线城市美妆爱好者、美妆博主", scenario: "示例:美妆产品带货、线上美妆教程", coreNeed: "示例补光效果、AI 测肤精度", spendingPower: "示例:高" },
{ userGroup: "示例22-40 岁租房白领、早八通勤族", scenario: "示例:早上上班前快速护肤化妆", coreNeed: "示例:日常化妆、耐用", spendingPower: "示例:中" },
{ userGroup: "示例18-28 岁学生群体、礼品采购人群", scenario: "示例:宿舍美妆尝试、礼物赠送", coreNeed: "示例:宿舍美妆工具、生日礼物", spendingPower: "示例:低" },
];
const feasibilityOptions = [
{ value: "highly-feasible", label: "高度可行,建议立即立项启动", hint: "市场、收益与风险条件均具备" },
{ value: "feasible-after-optimization", label: "基本可行,建议在优化部分条件后立项", hint: "关键条件完善后再推进" },
@ -191,5 +184,4 @@ function exportOutcome() { const blob = new Blob([JSON.stringify({ task: taskTit
.report-guide { margin-top:24px; padding:18px 20px; border-left:3px solid #39d4f8; color:#c5e6f0; background:rgba(8,83,115,.2); line-height:1.85; }.report-guide p,.report-guide ol { margin:0; }.report-guide ol { margin-top:9px; padding-left:23px; }.report-guide li + li { margin-top:6px; }.report-upload-panel { display:grid; min-height:300px; place-content:center; justify-items:center; gap:12px; margin-top:30px; padding:36px; border:1px dashed rgba(83,218,255,.72); border-radius:16px; background:radial-gradient(circle at 50% 22%,rgba(15,149,201,.23),transparent 42%),rgba(1,21,34,.55); text-align:center; }.upload-orbit { display:grid; width:68px; height:68px; place-items:center; border:1px solid rgba(104,225,255,.72); border-radius:22px; color:#74e9ff; background:rgba(9,120,164,.22); font-size:32px; box-shadow:0 0 0 9px rgba(45,193,233,.06); }.report-upload-panel h3 { margin:8px 0 0; color:#f1fcff; font-size:21px; }.report-upload-panel > p { margin:0; color:#8ebfce; }.report-file-card { display:flex; align-items:center; width:min(760px,100%); gap:15px; padding:17px; border:1px solid rgba(93,217,252,.45); border-radius:13px; background:rgba(2,17,29,.7); text-align:left; }.report-file-icon { display:grid; flex:0 0 48px; width:48px; height:48px; place-items:center; border-radius:12px; color:#83e8ff; background:rgba(10,129,176,.28); font-size:25px; }.report-file-meta { min-width:0; flex:1; }.report-file-meta strong,.report-file-meta span { display:block; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }.report-file-meta strong { color:#effdff; }.report-file-meta span { margin-top:5px; color:#83b7c8; font-size:12px; }.report-file-actions { display:flex; gap:8px; }.report-file-actions button,.text-action { display:inline-flex; align-items:center; gap:5px; border:0; color:#93eaff; background:transparent; font:inherit; font-size:13px; cursor:pointer; }.report-file-actions .danger { color:#ffaaa1; }.report-replace { display:flex; align-items:center; gap:13px; color:#85b7c7; font-size:12px; }.text-action:disabled { cursor:not-allowed; opacity:.6; }.visually-hidden { position:absolute; width:1px; height:1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; }.report-preview { display:grid; min-height:64vh; place-items:center; background:#0a1520; }.report-preview .el-image { width:100%; height:64vh; }.report-preview iframe { width:100%; height:64vh; border:0; background:#fff; }
@media (max-width:1280px) { .feasibility-page { grid-template-columns:1fr; }.step-indicator { overflow-x:auto; }.step-tab { min-width:170px; }.market-table { grid-template-columns:1fr; }.market-table label { border-right:0; }.cost-layout { grid-template-columns:1fr; } }
@media (max-width:720px) { .feasibility-page { padding:10px; }.task-header,.workbench,.step-card { padding:16px; }.task-header h1 { font-size:25px; }.workbench-actions { justify-content:flex-start; flex-wrap:wrap; }.form-table label { grid-template-columns:1fr; }.form-table label > span { justify-content:flex-start; }.step-heading em { display:none; }.step-actions { flex-wrap:wrap; }.decision-grid,.priority-grid { grid-template-columns:1fr; }.decision-section { padding:16px; }.hint { margin-left:0; }.report-upload-panel { padding:22px 14px; }.report-file-card { align-items:flex-start; flex-wrap:wrap; }.report-file-actions { width:100%; justify-content:flex-end; }.report-replace { flex-wrap:wrap; justify-content:center; } }
.table-wrap th { display:table-cell; text-align:center; }
</style>

@ -29,11 +29,12 @@
</template>
<template v-else-if="currentStep === 4">
<div class="step-title-row"><div><p>Step 04</p><h2>{{ trainingSteps[3] }}</h2></div><span>最终决策</span></div>
<p class="step-desc">结合前述 SWOT 分析完成策略优先级排序风险识别与竞争环境总结</p>
<p class="step-desc">结合前述 SWOT 分析完成市场进入决策策略优先级排序风险识别与竞争环境总结</p>
<p v-if="!stepThreePassed" class="step-warning"> 3 SWOT </p>
<section class="decision-section"><h3>1优先策略排序</h3><p class="section-tip">策略内容由第 3 步填写的具体策略建议自动带入请完成优先级排序并说明理由</p><div class="priority-table-wrap"><table class="priority-table"><thead><tr><th>优先级排序</th><th>策略</th><th>理由</th></tr></thead><tbody><tr v-for="row in strategyDefinitions" :key="row.key"><td><select v-model.number="finalForm.priorities[row.key].priority" :disabled="!stepThreePassed" :aria-label="`${row.label} 的优先级排序`"><option :value="null">请选择</option><option v-for="rank in 4" :key="rank" :value="rank">{{ rank }}</option></select></td><td class="priority-strategy"><strong>{{ row.label }}</strong><p>{{ strategyForm[row.key].recommendation || '请先在第 3 步填写该策略建议' }}</p></td><td><textarea v-model.trim="finalForm.priorities[row.key].reason" :disabled="!stepThreePassed" placeholder="说明该策略的排序理由" /></td></tr></tbody></table></div></section>
<section class="decision-section"><h3>2需关注的风险点</h3><p>请至少指出 2 个风险点</p><div class="risk-grid"><label><span>核心风险点 1</span><textarea v-model.trim="finalForm.coreRiskOne" :disabled="!stepThreePassed" placeholder="填写核心风险点" /></label><label><span>核心风险点 2</span><textarea v-model.trim="finalForm.coreRiskTwo" :disabled="!stepThreePassed" placeholder="填写核心风险点" /></label><label><span>其他风险点</span><textarea v-model.trim="finalForm.otherRisk" :disabled="!stepThreePassed" placeholder="可补充其他风险点" /></label></div></section>
<section class="decision-section"><h3>3总结 SWOT 竞争环境评估结论</h3><textarea v-model.trim="finalForm.conclusion" class="conclusion-textarea" :disabled="!stepThreePassed" placeholder="综合说明优先策略、风险应对方向及最终结论" /></section>
<section class="decision-section"><h3>1总体竞争态势评估</h3><p>请选择该产品进入目标市场的总体竞争判断</p><div class="radio-options"><label v-for="option in evaluationOptions" :key="option"><input v-model="finalForm.overallEvaluation" :disabled="!stepThreePassed" type="radio" name="competitive-evaluation" :value="option" /><span>{{ option }}</span></label></div></section>
<section class="decision-section"><h3>2优先策略排序</h3><p class="section-tip">策略内容由第 3 步填写的具体策略建议自动带入请完成优先级排序并说明理由</p><div class="priority-table-wrap"><table class="priority-table"><thead><tr><th>优先级排序</th><th>策略</th><th>理由</th></tr></thead><tbody><tr v-for="row in strategyDefinitions" :key="row.key"><td><select v-model.number="finalForm.priorities[row.key].priority" :disabled="!stepThreePassed" :aria-label="`${row.label} 的优先级排序`"><option :value="null">请选择</option><option v-for="rank in 4" :key="rank" :value="rank">{{ rank }}</option></select></td><td class="priority-strategy"><strong>{{ row.label }}</strong><p>{{ strategyForm[row.key].recommendation || '请先在第 3 步填写该策略建议' }}</p></td><td><textarea v-model.trim="finalForm.priorities[row.key].reason" :disabled="!stepThreePassed" placeholder="说明该策略的排序理由" /></td></tr></tbody></table></div></section>
<section class="decision-section"><h3>3需关注的风险点</h3><p>请至少指出 2 个风险点</p><div class="risk-grid"><label><span>核心风险点 1</span><textarea v-model.trim="finalForm.coreRiskOne" :disabled="!stepThreePassed" placeholder="填写核心风险点" /></label><label><span>核心风险点 2</span><textarea v-model.trim="finalForm.coreRiskTwo" :disabled="!stepThreePassed" placeholder="填写核心风险点" /></label><label><span>其他风险点</span><textarea v-model.trim="finalForm.otherRisk" :disabled="!stepThreePassed" placeholder="可补充其他风险点" /></label></div></section>
<section class="decision-section"><h3>4总结 SWOT 竞争环境评估结论</h3><textarea v-model.trim="finalForm.conclusion" class="conclusion-textarea" :disabled="!stepThreePassed" placeholder="综合说明总体判断、优先策略与风险应对方向" /></section>
<div class="task-actions"><button type="button" class="btn btn-primary" :disabled="saving || !stepThreePassed" @click="saveAndCompleteTraining"></button><button type="button" class="btn" :disabled="saving || !stepThreePassed" @click="resetStepFour"><el-icon><RefreshLeft /></el-icon> </button></div>
</template>
<div v-else class="step-placeholder"><p> {{ currentStep }} 步内容待配置</p><span> 1 SWOT 分类已保存可从上方步骤栏返回查看</span></div>
@ -71,13 +72,14 @@ const strategyDefinitions = [
{ key: "wt", label: "WT策略劣势×威胁", first: "weaknesses", firstLabel: "劣势", second: "threats", secondLabel: "威胁" },
];
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(() => 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() }; }
function createStrategyRow() { return { recommendation: "", expectedEffect: "" }; }
function createFinalForm() { return { priorities: Object.fromEntries(strategyDefinitions.map((row) => [row.key, { priority: null, reason: "" }])), coreRiskOne: "", coreRiskTwo: "", otherRisk: "", conclusion: "" }; }
function createFinalForm() { return { overallEvaluation: "", priorities: Object.fromEntries(strategyDefinitions.map((row) => [row.key, { priority: null, reason: "" }])), coreRiskOne: "", coreRiskTwo: "", otherRisk: "", conclusion: "" }; }
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) { restoreOutcome(answer.step1Answer, form); stepOnePassed.value = true; clearDraft(); } if (answer?.step2Answer) { restoreOutcome(answer.step2Answer, supplementForm, "supplements"); stepTwoPassed.value = true; } if (answer?.step3Answer) { restoreStrategyOutcome(answer.step3Answer); stepThreePassed.value = true; } if (answer?.step4Answer) restoreFinalOutcome(answer.step4Answer); if (Number(answer?.currentStep) > 0) currentStep.value = Number(answer.currentStep); } catch (error) { /* Keep local draft when unavailable. */ } }
@ -90,7 +92,7 @@ function switchStep(step) { if (step === 2 && !stepOnePassed.value) { proxy?.$mo
async function saveAndGoToStepTwo() { if (saving.value) return; saving.value = true; try { await checkCompetitiveEnvironmentAnalysisStepOne(form.value); await saveStudentTrainingAnswer(TASK_KEY, { step1Answer: JSON.stringify(buildStepOneOutcome()), currentStep: 2, submitted: false, saveAction: "SAVE" }); stepOnePassed.value = true; clearDraft(); currentStep.value = 2; proxy?.$modal?.msgSuccess("已保存,已进入第 2 步"); } catch (error) { proxy?.$modal?.msgError?.(error?.message || "请完整填写 SWOT 四类信息"); } finally { saving.value = false; } }
async function saveAndGoToStepThree() { if (saving.value || !stepOnePassed.value) return; saving.value = true; try { await checkCompetitiveEnvironmentAnalysisStepTwo(supplementForm.value); await saveStudentTrainingAnswer(TASK_KEY, { step2Answer: JSON.stringify(buildStepTwoOutcome()), currentStep: 3, submitted: false, saveAction: "SAVE" }); stepTwoPassed.value = true; clearDraft(); currentStep.value = 3; proxy?.$modal?.msgSuccess("已保存,已进入第 3 步"); } catch (error) { proxy?.$modal?.msgError?.(error?.message || "请为每个 SWOT 维度至少补充 1 条信息"); } finally { saving.value = false; } }
async function saveAndGoToStepFour() { if (saving.value || !stepTwoPassed.value) return; saving.value = true; try { await checkCompetitiveEnvironmentAnalysisStepThree({ strategies: strategyDefinitions.map((row) => ({ strategyType: row.key, ...strategyForm.value[row.key] })) }); await saveStudentTrainingAnswer(TASK_KEY, { step3Answer: JSON.stringify(buildStepThreeOutcome()), currentStep: 4, submitted: false, saveAction: "SAVE" }); stepThreePassed.value = true; clearDraft(); currentStep.value = 4; proxy?.$modal?.msgSuccess("已保存,已进入第 4 步"); } catch (error) { proxy?.$modal?.msgError?.(error?.message || "请完成四类 SWOT 策略建议和预期效果"); } finally { saving.value = false; } }
async function saveAndCompleteTraining() { if (saving.value || !stepThreePassed.value) return; saving.value = true; try { await checkCompetitiveEnvironmentAnalysisStepFour({ priorities: strategyDefinitions.map((row) => ({ strategyType: row.key, ...finalForm.value.priorities[row.key] })), coreRiskOne: finalForm.value.coreRiskOne, coreRiskTwo: finalForm.value.coreRiskTwo, otherRisk: finalForm.value.otherRisk, conclusion: finalForm.value.conclusion }); await saveStudentTrainingAnswer(TASK_KEY, { step4Answer: JSON.stringify(buildStepFourOutcome()), currentStep: 4, submitted: true, saveAction: "SUBMIT" }); clearDraft(); proxy?.$modal?.msgSuccess("已保存并完成实训"); } catch (error) { proxy?.$modal?.msgError?.(error?.message || "请完整填写策略优先级、至少 2 个核心风险点和总结 SWOT 竞争环境评估结论"); } finally { saving.value = false; } }
async function saveAndCompleteTraining() { if (saving.value || !stepThreePassed.value) return; saving.value = true; try { await checkCompetitiveEnvironmentAnalysisStepFour({ overallEvaluation: finalForm.value.overallEvaluation, priorities: strategyDefinitions.map((row) => ({ strategyType: row.key, ...finalForm.value.priorities[row.key] })), coreRiskOne: finalForm.value.coreRiskOne, coreRiskTwo: finalForm.value.coreRiskTwo, otherRisk: finalForm.value.otherRisk, conclusion: finalForm.value.conclusion }); await saveStudentTrainingAnswer(TASK_KEY, { step4Answer: JSON.stringify(buildStepFourOutcome()), currentStep: 4, submitted: true, saveAction: "SUBMIT" }); clearDraft(); proxy?.$modal?.msgSuccess("已保存并完成实训"); } catch (error) { proxy?.$modal?.msgError?.(error?.message || "请完成总体判断、策略排序、风险点和总结结论"); } finally { saving.value = false; } }
async function resetTraining() { form.value = createForm(); supplementForm.value = createForm(); strategyForm.value = createStrategyForm(); finalForm.value = createFinalForm(); stepOnePassed.value = false; stepTwoPassed.value = false; stepThreePassed.value = false; currentStep.value = 1; clearDraft(); try { saving.value = true; await saveStudentTrainingAnswer(TASK_KEY, { step1Answer: JSON.stringify(buildStepOneOutcome()), currentStep: 1, submitted: false, saveAction: "RESET" }); } catch (error) { /* Local clearing still succeeds. */ } finally { saving.value = false; } proxy?.$modal?.msgSuccess("已清空填写内容"); }
function resetStepTwo() { supplementForm.value = createForm(); proxy?.$modal?.msgSuccess("已清空第 2 步填写内容"); }
function resetStepThree() { strategyForm.value = createStrategyForm(); proxy?.$modal?.msgSuccess("已清空第 3 步填写内容"); }

@ -163,19 +163,6 @@
已编辑 {{ form.questionnaireQuestions.length }} {{ form.questionnaireImport?.name ? "已导入 1 份 Word 问卷,可随时预览。" : "也可导入已完成的 .docx 问卷。" }}
</p>
<div class="questionnaire-template-guide">
<div>
<h5>推荐工具及方法如下自行选择</h5>
<p>1. 使用本系统问卷编辑器自行编辑问卷</p>
<p>2. 下载参考问卷 Word 模板在本地修改后上传导入</p>
<p>3. 也可使用 AI 辅助生成问卷应包含基本信息购买行为需求偏好与开放建议</p>
</div>
<el-button class="questionnaire-template-action" @click="downloadQuestionnaireTemplate">
<el-icon><Download /></el-icon>
下载模板
</el-button>
</div>
<el-dialog v-model="questionnaireEditorVisible" title="消费者需求调研问卷编辑器" width="min(1120px, 94vw)" class="questionnaire-editor-dialog" append-to-body>
<div class="questionnaire-editor-dialog__intro">
<p>请覆盖基本信息购买行为需求偏好和开放建议四部分需求偏好至少设置一题 Likert 五级量表</p>
@ -292,7 +279,6 @@ import { parseTrainingArray } from "@/views/training/taskKeyMap";
import { checkConsumerBehaviorStepOne, checkConsumerBehaviorStepTwo, checkConsumerBehaviorStepThree, checkConsumerBehaviorStepFour, getStudentTrainingAnswer, previewConsumerBehaviorQuestionnaireWord, saveStudentTrainingAnswer, uploadStudentTrainingFile } from "@/api/studentTrainingAnswer";
const TASK_KEY = "consumer-behavior";
const QUESTIONNAIRE_TEMPLATE_URL = "/file/training-materials/consumer-behavior/9.问卷调查报告模板.docx";
const KANO_NEED_LIMIT = 20;
let questionnaireQuestionSequence = 0;
const { proxy } = getCurrentInstance();
@ -896,21 +882,16 @@ function surveyReportContentPlaceholder(index) {
return ["说明本次问卷调研的背景、目标用户与调研目的。", "说明样本数量、调研时间、调研渠道与数据来源。", "结合问卷数据,分析影响用户购买决策的核心因素。", "比较不同人群在需求、价格、渠道或购买场景上的差异。", "总结调研结论,并提出产品功能、定价或营销建议。"][index] || "请输入报告内容";
}
function downloadBuiltInFile(url, fileName) {
function downloadSurveyReportTemplate() {
const rows = Array.from({ length: 5 }, (_, index) => `<tr><td>${surveyReportTitlePlaceholder(index)}</td><td>${surveyReportContentPlaceholder(index)}</td></tr>`).join("");
const html = `<!doctype html><html><head><meta charset="utf-8"><title>消费者需求调研报告模板</title><style>body{font-family:SimSun,serif;padding:32px;color:#222}h1{font-size:22px;text-align:center}p{line-height:1.75}table{width:100%;border-collapse:collapse;margin-top:24px}th,td{border:1px solid #777;padding:14px;vertical-align:top}th{background:#14a3df;color:#fff}td:first-child{width:22%}</style></head><body><h1>消费者需求调研报告</h1><p>请将步骤三设计的调查问卷导入在线问卷调查平台开展调研,并基于收集到的数据完成本报告。</p><table><thead><tr><th>报告模块标题</th><th>内容</th></tr></thead><tbody>${rows}</tbody></table></body></html>`;
const blob = new Blob([html], { type: "application/msword;charset=utf-8" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.download = "消费者需求调研报告模板.doc";
link.click();
document.body.removeChild(link);
}
function downloadQuestionnaireTemplate() {
downloadBuiltInFile(QUESTIONNAIRE_TEMPLATE_URL, "9.问卷调查报告模板.docx");
}
function downloadSurveyReportTemplate() {
downloadBuiltInFile(QUESTIONNAIRE_TEMPLATE_URL, "9.问卷调查报告模板.docx");
URL.revokeObjectURL(url);
}
</script>
@ -1591,41 +1572,6 @@ function downloadSurveyReportTemplate() {
font-size: 13px;
}
.questionnaire-template-guide {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
margin-top: 22px;
padding: 18px 20px;
border-left: 3px solid #25cfff;
border-radius: 0 14px 14px 0;
color: #c7e7f3;
background: rgba(15, 104, 145, 0.18);
h5,
p {
margin: 0 0 5px;
line-height: 1.72;
}
h5 {
color: #ffffff;
font-size: 15px;
}
}
.questionnaire-template-action {
flex: 0 0 auto;
min-width: 132px;
height: 42px;
border: 1px solid rgba(93, 218, 255, 0.68);
border-radius: 10px;
color: #ffffff;
background: linear-gradient(95deg, #0b84bd, #096491);
font-weight: 800;
}
:deep(.questionnaire-editor-dialog .el-dialog),
:deep(.questionnaire-preview-dialog .el-dialog) {
overflow: hidden;
@ -2264,15 +2210,13 @@ function downloadSurveyReportTemplate() {
.questionnaire-design-head,
.questionnaire-editor-launcher,
.questionnaire-template-guide,
.questionnaire-editor-dialog__intro {
align-items: stretch;
flex-direction: column;
}
.questionnaire-import-action,
.questionnaire-editor-launcher__action,
.questionnaire-template-action {
.questionnaire-editor-launcher__action {
width: 100%;
}
@ -2365,3 +2309,4 @@ function downloadSurveyReportTemplate() {
border-bottom: 0;
}
</style>

@ -266,7 +266,6 @@ import { checkConsumerScenarioStepOne, checkConsumerScenarioStepTwo, checkConsum
import { isStudentDemo } from "@/utils/studentDemo";
const TASK_KEY = "consumer-scenario";
const JOURNEY_TEMPLATE_URL = "/file/training-materials/consumer-scenario/10.用户购买旅程模版.pptx";
const { proxy } = getCurrentInstance();
const defaultRequirement =
@ -819,16 +818,25 @@ async function ensureJourneyOptimizationValid() {
}
function downloadJourneyMapTemplate() {
downloadBuiltInFile(JOURNEY_TEMPLATE_URL, "10.用户购买旅程模版.pptx");
}
function downloadBuiltInFile(url, fileName) {
const escapeCsvValue = (value) => `"${String(value || "").replaceAll('"', '""')}"`;
const header = ["序号", "旅程阶段", "用户行为描述", "关键触点", "情感体验", "关键决策因素", "痛点标记", "机会点标记"];
const rows = form.value.journeyAnalysis.map((row, index) => [
index + 1,
stageTitle(row.stageId),
row.behaviorDescription,
row.keyTouchpoints,
row.emotionExperience,
row.decisionFactors,
"",
"",
]);
const csv = [header, ...rows].map((row) => row.map(escapeCsvValue).join(",")).join("\r\n");
const url = URL.createObjectURL(new Blob([`\uFEFF${csv}`], { type: "text/csv;charset=utf-8" }));
const link = document.createElement("a");
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.download = "用户购买旅程图模板.csv";
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
function buildOutcome() {
@ -2266,3 +2274,4 @@ function exportOutcome() {
}
</style>

@ -69,15 +69,6 @@
</el-button>
</div>
<p class="upload-tip">仅支持 .xls.xlsx 文件上传后可预览首个工作表的表头和前 20 行数据</p>
<div class="step-one-materials" aria-label="RFM ">
<p>推荐工具及方法如下自行选择</p>
<p>1. 使用 Excel/WPS 完成 RFM 用户分层分析可参考实训操作说明</p>
<p>2. 下载 Excel 模板填写案例数据并完成计算与可视化</p>
<div class="step-one-materials__actions">
<el-button class="preview-action" @click="downloadRfmInstructions"></el-button>
<el-button class="upload-action" @click="downloadRfmTemplate"> Excel </el-button>
</div>
</div>
<el-dialog v-model="previewDialogVisible" title="RFM 用户分层分析结果预览" width="min(960px, 92vw)" append-to-body>
<div class="excel-preview-meta">
@ -343,8 +334,6 @@ import { parseTrainingArray } from "@/views/training/taskKeyMap";
import { checkTargetUserProfileStepFive, checkTargetUserProfileStepFour, checkTargetUserProfileStepOne, checkTargetUserProfileStepThree, checkTargetUserProfileStepTwo, getStudentTrainingAnswer, saveStudentTrainingAnswer, uploadStudentTrainingFile } from "@/api/studentTrainingAnswer";
const TASK_KEY = "target-user-profile";
const RFM_INSTRUCTIONS_URL = "/file/training-materials/target-user-profile/8.实训操作说明.docx";
const RFM_TEMPLATE_URL = "/file/training-materials/target-user-profile/8.Excel计算与可视化模版.xlsx";
const RFM_CHARTS = [
{ id: "segment-share", index: 1, title: "客户分层占比饼图", buttonText: "上传客户分层占比饼图" },
{ id: "average-spend", index: 2, title: "客户分层平均消费对比柱状图", buttonText: "上传客户分层平均消费对比柱状图" },
@ -711,23 +700,6 @@ function triggerFileSelect() {
fileInputRef.value?.click();
}
function downloadBuiltInFile(url, fileName) {
const link = document.createElement("a");
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
function downloadRfmInstructions() {
downloadBuiltInFile(RFM_INSTRUCTIONS_URL, "8.实训操作说明.docx");
}
function downloadRfmTemplate() {
downloadBuiltInFile(RFM_TEMPLATE_URL, "8.Excel计算与可视化模版.xlsx");
}
function setChartInputRef(chartId, element) {
if (element) {
chartFileInputRefs.value[chartId] = element;
@ -1880,28 +1852,6 @@ function exportOutcome() {
line-height: 1.7;
}
.step-one-materials {
margin-top: 18px;
padding: 16px 18px;
border-left: 3px solid #21c5ff;
border-radius: 0 12px 12px 0;
color: #c8e6f2;
background: rgba(14, 91, 128, 0.18);
font-size: 14px;
line-height: 1.75;
p {
margin: 0 0 4px;
}
}
.step-one-materials__actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 12px;
}
.excel-preview-meta {
margin-bottom: 12px;
color: #52687a;
@ -2477,3 +2427,4 @@ function exportOutcome() {
border-bottom: 0;
}
</style>

@ -86,7 +86,7 @@
</section>
<section v-show="currentStep === 2" class="step-content active-step">
<div class="step-card step-two-survey">
<div class="step-card">
<div class="step-title-row">
<div>
<p class="section-eyebrow">Step 02</p>
@ -136,7 +136,7 @@
<div v-if="step2Form.images.length" class="product-image-list">
<figure v-for="(image, index) in step2Form.images" :key="`${image.url}-${index}`" class="product-image-card">
<button type="button" class="product-image-preview" @click="previewStepTwoImage(image.url)">
<img :src="resolveUploadedImageUrl(image.url)" :alt="`${step2Form.productName || '产品'}图片 ${index + 1}`" />
<img :src="image.url" :alt="`${step2Form.productName || '产品'}图片 ${index + 1}`" />
</button>
<figcaption :title="image.name">{{ image.name }}</figcaption>
<button type="button" class="remove-image-button" :aria-label="` ${index + 1}`" @click="removeStepTwoImage(index)"></button>
@ -272,7 +272,6 @@ import {
import { getTrainingTaskByKey } from "@/api/trainingTask";
import { checkNewProductSurveyStepFour, checkNewProductSurveyStepOne, checkNewProductSurveyStepThree, checkNewProductSurveyStepTwo, getStudentTrainingAnswer, saveStudentTrainingAnswer, uploadStudentTrainingFile } from "@/api/studentTrainingAnswer";
import useUserStore from "@/store/modules/user";
import { isExternal } from "@/utils/validate";
import { extractTrainingStepNames } from "@/views/training/taskKeyMap";
import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue";
import TrainingMaterialButton from "@/views/components/TrainingMaterialButton.vue";
@ -583,24 +582,8 @@ function removeStepTwoImage(index) {
step2Form.value.images.splice(index, 1);
}
function resolveUploadedImageUrl(url) {
const source = String(url || "").trim();
if (!source || source.startsWith("data:") || source.startsWith("blob:")) return source;
if (!isExternal(source)) return source.startsWith("/") ? source : `/${source}`;
try {
const parsed = new URL(source);
if (["localhost", "127.0.0.1", "::1"].includes(parsed.hostname)) {
return `${parsed.pathname}${parsed.search}${parsed.hash}`;
}
} catch {
return source;
}
return source;
}
function previewStepTwoImage(url) {
imagePreviewUrl.value = resolveUploadedImageUrl(url);
imagePreviewUrl.value = url;
imagePreviewOpen.value = true;
}
@ -1373,33 +1356,6 @@ function exportOutcome() {
gap: 9px;
}
.step-two-survey {
.step-title-row {
h3 {
font-size: 19px;
}
}
.survey-options {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
:deep(.survey-option.el-radio) {
padding: 8px 10px;
.el-radio__label {
gap: 2px;
padding-left: 7px;
}
span {
font-size: 11px;
line-height: 1.35;
}
}
}
:deep(.survey-option.el-radio) {
display: grid;
grid-template-columns: auto 1fr;
@ -1844,12 +1800,6 @@ function exportOutcome() {
min-width: 720px;
}
}
@media (max-width: 720px) {
.step-two-survey {
.survey-options {
grid-template-columns: 1fr;
}
}
}
</style>

@ -68,10 +68,7 @@
<template v-else-if="currentStep === 2">
<div class="step-title-row">
<div><p class="section-eyebrow">Step 02</p><h3>{{ trainingSteps[1] }}</h3></div>
<div class="step-title-actions">
<button type="button" class="outline-action schedule-export-action" @click="exportScheduleList"><el-icon><Download /></el-icon></button>
<span class="step-chip">项目排期练习</span>
</div>
<span class="step-chip">项目排期练习</span>
</div>
<p class="process-intro">将开发一款互联网产品的模糊目标拆解为可执行的具体工作任务根据案例信息填写参考工期和项目依赖关系</p>
<p v-if="!hasCompletedStepOne" class="step-warning"> 1 </p>
@ -86,7 +83,7 @@
<td class="classification-cell">{{ row.classification || '—' }}</td>
<td><input v-model="row.startDate" type="date" :disabled="!hasCompletedStepOne || checking" /></td>
<td><input v-model="row.endDate" type="date" :disabled="!hasCompletedStepOne || checking" /></td>
<td><input v-model.trim="row.dependency" class="dependency-input" :disabled="!hasCompletedStepOne || checking" placeholder="示例:开始-开始SS" maxlength="100" /></td>
<td><input v-model.trim="row.dependency" class="dependency-input" :disabled="!hasCompletedStepOne || checking" placeholder="1.1;无" maxlength="100" /></td>
</tr>
</tbody>
</table>
@ -178,10 +175,6 @@ import TrainingTaskBrief from "@/views/components/TrainingTaskBrief.vue";
const { proxy } = getCurrentInstance();
const userStore = useUserStore();
const TASK_KEY = "product-development-process";
const GANTT_PRACTICE_GUIDE_URL = "/file/training-materials/product-development-process/3.甘特图制作实训操作说明.docx";
const GANTT_PRACTICE_GUIDE_NAME = "3.甘特图制作实训操作说明.docx";
const GANTT_TEMPLATE_URL = "/file/training-materials/product-development-process/3.项目进度管理表-项目进度甘特图模版.xlsx";
const GANTT_TEMPLATE_NAME = "3.项目进度管理表-项目进度甘特图模版.xlsx";
const CLASSIFICATION_OPTIONS = ["关键任务", "非关键任务"];
const DEFAULT_STEPS = ["产品开发关键任务识别", "任务依赖关系分析", "开发排期制定", "流程复盘优化"];
const PROCESS_TASKS = [
@ -338,16 +331,6 @@ function buildStepTwoOutcome() {
};
}
function getStepTwoValidationMessage() {
for (const row of processRows.value) {
if (!String(row.dependency || "").trim()) return `请填写任务 ${row.taskNo} 的项目依赖关系`;
if (!String(row.startDate || "").trim()) return `请填写任务 ${row.taskNo} 的开始时间`;
if (!String(row.endDate || "").trim()) return `请填写任务 ${row.taskNo} 的完成时间`;
if (row.startDate > row.endDate) return `任务 ${row.taskNo} 的完成时间不能早于开始时间`;
}
return "";
}
function buildStepThreeOutcome() {
return { title: taskTitle.value, ganttImageUrl: ganttImage.value.url, ganttImageName: ganttImage.value.name };
}
@ -436,11 +419,6 @@ async function resetTraining() {
async function saveAndGoToStepThree() {
if (checking.value || !hasCompletedStepOne.value) return;
const validationMessage = getStepTwoValidationMessage();
if (validationMessage) {
proxy?.$modal?.msgWarning?.(validationMessage);
return;
}
checking.value = true;
try {
await checkProductDevelopmentProcessStepTwo(buildStepTwoOutcome().items);
@ -536,23 +514,17 @@ function previewGanttImage() {
}
function downloadPracticeGuide() {
downloadBuiltInFile(GANTT_PRACTICE_GUIDE_URL, GANTT_PRACTICE_GUIDE_NAME);
const content = [
"产品开发甘特图实训操作说明",
"1. 使用第 2 步填写的任务编号、开始时间、完成时间和项目依赖关系建立任务表。",
"2. 在 Excel 或 WPS 中按日期轴创建堆积条形图;开始时间作为不可见基线,任务工期作为可见条形。",
"3. 为关键任务使用醒目颜色,并标注任务名称、开始及完成时间。",
"4. 导出或截图为 PNG/JPG 后上传至本步骤。",
].join("\n");
downloadBlob(new Blob([content], { type: "text/plain;charset=utf-8" }), "产品开发甘特图-实训操作说明.txt");
}
function downloadGanttTemplate() {
downloadBuiltInFile(GANTT_TEMPLATE_URL, GANTT_TEMPLATE_NAME);
}
function downloadBuiltInFile(url, filename) {
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
function exportScheduleList() {
const rows = processRows.value.map((row) => ({
阶段: row.stage,
任务编号: row.taskNo,
@ -562,14 +534,22 @@ function exportScheduleList() {
完成时间: row.endDate,
项目依赖关系: row.dependency,
}));
const worksheet = XLSX.utils.json_to_sheet(rows);
worksheet["!cols"] = [
{ wch: 12 }, { wch: 10 }, { wch: 28 }, { wch: 14 }, { wch: 14 }, { wch: 14 }, { wch: 22 },
];
const sheet = XLSX.utils.json_to_sheet(rows);
sheet["!cols"] = [{ wch: 16 }, { wch: 12 }, { wch: 32 }, { wch: 16 }, { wch: 14 }, { wch: 14 }, { wch: 24 }];
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, "WBS排期表");
XLSX.writeFile(workbook, "产品开发WBS任务拆解与排期表.xlsx");
proxy?.$modal?.msgSuccess("列表导出成功");
XLSX.utils.book_append_sheet(workbook, sheet, "甘特图任务表");
XLSX.writeFile(workbook, "产品开发甘特图绘制模板.xlsx");
}
function downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
function exportOutcome() {
@ -602,25 +582,25 @@ function exportOutcome() {
.process-core {
min-width: 0;
padding: 20px;
padding: 28px;
border: 1px solid rgba(0, 180, 255, 0.25);
border-radius: 24px;
border-radius: 36px;
background: rgba(8, 18, 28, 0.6);
box-shadow: 0 22px 48px rgba(0, 0, 0, 0.22), inset 0 1px 0 rgba(169, 229, 255, 0.08);
backdrop-filter: blur(8px);
}
.task-header { margin-bottom: 16px; }
.task-header { margin-bottom: 24px; }
.task-badge { display: inline-flex; align-items: center; gap: 7px; margin-bottom: 12px; padding: 4px 14px; border: 1px solid rgba(0, 170, 255, 0.5); border-radius: 30px; color: #d6f6ff; background: rgba(0, 170, 255, 0.2); font-size: 12px; font-weight: 700; }
.task-title { margin: 0; color: transparent; background: linear-gradient(135deg, #fff, #8bcbff); -webkit-background-clip: text; background-clip: text; font-size: 30px; font-weight: 900; line-height: 1.3; }
.process-workbench { min-width: 0; margin: 0; padding: 14px 16px; border: 1px solid rgba(82, 174, 226, 0.42); border-radius: 20px; background: rgba(0, 25, 42, 0.64); }
.process-workbench { min-width: 0; margin: 0; padding: 18px 22px; border: 1px solid rgba(82, 174, 226, 0.42); border-radius: 26px; background: rgba(0, 25, 42, 0.64); }
.process-workbench::before { display: none !important; content: none !important; }
.workbench-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: 22px; padding-bottom: 18px; border-bottom: 1px solid rgba(0, 180, 255, 0.2); }
.workbench-actions { display: flex; flex: 0 0 auto; flex-wrap: wrap; justify-content: flex-end; gap: 10px; }
.outline-action, .btn-nav { display: inline-flex; align-items: center; justify-content: center; gap: 8px; font-family: inherit; cursor: pointer; }
.outline-action, .workbench-actions :deep(.material-download) { min-height: 40px; padding: 0 18px; border: 1px solid rgba(99, 217, 255, 0.55); border-radius: 999px; color: #e9fbff; background: rgba(17, 126, 178, 0.36); box-shadow: none; font-size: 14px; font-weight: 800; line-height: 1; }
.outline-action:hover, .workbench-actions :deep(.material-download:hover) { border-color: rgba(116, 235, 255, 0.85); color: #fff; background: rgba(18, 146, 204, 0.5); transform: translateY(-1px); }
.workbench-head--actions-only { justify-content: flex-end; margin-bottom: 10px; padding-bottom: 0; border-bottom: 0; }
.workbench-head--actions-only { justify-content: flex-end; margin-bottom: 18px; 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; }
@ -628,10 +608,10 @@ function exportOutcome() {
.step-tab.active { border-color: rgba(89, 226, 255, .85); color: #fff; background: linear-gradient(115deg, rgba(4, 109, 155, .78), rgba(5, 62, 102, .64)); box-shadow: 0 0 18px rgba(24, 183, 235, .22), inset 0 1px 0 rgba(176, 246, 255, .2); }.step-tab.active .step-marker { border-color: #72e8ff; color: #fff; background: linear-gradient(135deg, #08b7e8, #1375d0); box-shadow: 0 0 0 5px rgba(25, 179, 237, .14), 0 0 18px rgba(46, 208, 255, .58); }.step-tab.complete { color: #dffbff; }.step-tab.complete .step-marker { border-color: rgba(84, 230, 221, .85); color: #062b37; background: #6ef0e1; box-shadow: 0 0 12px rgba(79, 231, 218, .35); }
.step-marker { display: inline-flex; flex: 0 0 38px; align-items: center; justify-content: center; width: 38px; height: 38px; border: 1px solid rgba(101, 176, 208, .6); border-radius: 50%; color: #a6c4d9; background: rgba(3, 25, 40, .8); font-size: 12px; font-weight: 900; letter-spacing: .04em; }.step-copy { display: grid; min-width: 0; gap: 4px; margin-left: 10px; }.step-name { display: -webkit-box; overflow: hidden; color: inherit; font-size: 14px; font-weight: 800; line-height: 1.25; text-overflow: ellipsis; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }.step-action { color: #66b6d4; font-size: 10px; font-weight: 800; letter-spacing: .06em; line-height: 1; }.step-connector { position: relative; flex: 0 1 40px; min-width: 18px; height: 2px; margin: 0 8px; background: rgba(98, 169, 201, .34); }.step-connector::after { position: absolute; top: 50%; right: -1px; width: 6px; height: 6px; border-top: 1px solid #73b1ce; border-right: 1px solid #73b1ce; content: ''; transform: translateY(-50%) rotate(45deg); }.step-tab.complete + .step-connector { background: linear-gradient(90deg, #6ef0e1, #1cb8e9); box-shadow: 0 0 9px rgba(53, 218, 235, .46); }.step-tab.complete + .step-connector::after { border-color: #63e6f1; }
.process-step-card { margin: 0; padding: 16px; border: 1px solid rgba(82, 174, 226, 0.42); border-radius: 20px; background: rgba(0, 25, 42, 0.64); }.step-title-row { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: 12px; }.step-title-row h3 { margin: 4px 0 0; color: #fff; font-size: 22px; font-weight: 900; line-height: 1.3; }.step-title-actions { display: flex; align-items: center; flex-wrap: wrap; justify-content: flex-end; gap: 10px; }.schedule-export-action { min-height: 34px; padding: 0 13px; font-size: 13px; }.section-eyebrow { margin: 0; color: #71dfff; font-size: 12px; font-weight: 800; line-height: 1.4; }.step-chip { padding: 6px 12px; border: 1px solid rgba(94, 219, 255, .54); border-radius: 999px; color: #a6ebff; background: rgba(9, 104, 149, .2); font-size: 13px; font-weight: 800; }
.process-step-card { margin: 0; padding: 18px 22px; border: 1px solid rgba(82, 174, 226, 0.42); border-radius: 26px; background: rgba(0, 25, 42, 0.64); }.step-title-row { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: 14px; }.step-title-row h3 { margin: 4px 0 0; color: #fff; font-size: 22px; font-weight: 900; line-height: 1.3; }.section-eyebrow { margin: 0; color: #71dfff; font-size: 12px; font-weight: 800; line-height: 1.4; }.step-chip { padding: 6px 12px; border: 1px solid rgba(94, 219, 255, .54); border-radius: 999px; color: #a6ebff; background: rgba(9, 104, 149, .2); font-size: 13px; font-weight: 800; }
.process-intro { margin: 0 0 18px; color: #d7ecff; font-size: 15px; line-height: 1.8; }.training-table-wrap { overflow-x: auto; border: 1px solid rgba(0, 180, 255, .22); border-radius: 22px; background: rgba(1, 14, 26, .72); }.process-table { width: 100%; min-width: 920px; border-collapse: collapse; table-layout: fixed; }.process-table th { padding: 12px; border-right: 1px solid rgba(77, 167, 220, .2); color: #eaffff; background: rgba(8, 55, 82, .98); font-size: 14px; font-weight: 900; text-align: center; }.process-table td { padding: 12px; border-top: 1px solid rgba(77, 167, 220, .2); border-right: 1px solid rgba(77, 167, 220, .2); color: #d7f1ff; background: rgba(1, 14, 26, .48); vertical-align: middle; }.process-table th:last-child, .process-table td:last-child { border-right: 0; }.stage-cell { width: 17%; color: #eafaff; font-size: 14px; font-weight: 900; text-align: center; }.task-no-cell { width: 12%; color: #eafaff; font-size: 14px; font-weight: 800; text-align: center; }.task-cell { color: #cfe8f8; font-size: 14px; line-height: 1.7; text-align: center; }.select-cell { width: 23%; }.select-cell select, .schedule-table input { box-sizing: border-box; width: 100%; min-height: 38px; padding: 7px 10px; border: 1px solid rgba(45, 95, 126, .95); border-radius: 10px; outline: none; color: #eef5ff; background: #0f1a24; font: inherit; }.select-cell select:focus, .schedule-table input:focus { border-color: rgba(92, 224, 255, .7); box-shadow: 0 0 0 3px rgba(0, 174, 255, .13); }.classification-feedback { margin: 6px 0 0; color: #ff9b8f; font-size: 12px; line-height: 1.5; }.is-correct { background: rgba(55, 196, 147, .08); }.is-incorrect { background: rgba(255, 91, 82, .1); }.classification-summary { margin: 16px 0 0; color: #ffbc86; }.classification-summary.is-success { color: #6ee8b1; }
.task-actions { display: flex; justify-content: center; gap: 52px; margin-top: 36px; }.btn-nav { min-width: 172px; min-height: 64px; padding: 0 28px; border: 1px solid #2c83aa; border-radius: 6px; color: #dff7ff; background: rgba(13, 64, 88, .62); box-shadow: none; font-size: 24px; font-weight: 900; line-height: 1; }.btn-submit { border-color: #2c83aa; background: rgba(13, 64, 88, .62); }.task-actions .btn-nav .el-icon { display: none; }.btn-nav:hover:not(:disabled) { border-color: #46b5e7; color: #fff; background: rgba(18, 85, 116, .78); }.btn-nav:disabled { cursor: not-allowed; opacity: .6; }.step-placeholder { display: grid; place-items: center; min-height: 280px; padding: 28px; border: 1px dashed rgba(101, 207, 239, .42); border-radius: 18px; color: #b8d9ec; background: rgba(1, 14, 26, .42); text-align: center; }.step-placeholder p { margin: 0 0 8px; color: #eaffff; font-size: 20px; font-weight: 800; }
.step-warning { margin: 0 0 16px; padding: 10px 14px; border: 1px solid rgba(255, 173, 93, .62); border-radius: 10px; color: #ffd19f; background: rgba(187, 102, 27, .16); }.schedule-table { min-width: 1020px; }.schedule-table .stage-cell { width: 7%; }.schedule-table .task-no-cell { width: 7%; }.schedule-table .task-cell { width: 18%; }.classification-cell { width: 12%; color: #eafaff; font-weight: 800; text-align: center; }.schedule-table th:nth-child(5), .schedule-table td:nth-child(5), .schedule-table th:nth-child(6), .schedule-table td:nth-child(6) { width: 18%; }.schedule-table .dependency-input { min-width: 0; }.schedule-table .dependency-input::placeholder { color: rgba(168, 220, 233, .68); opacity: 1; }.schedule-table th:nth-child(7), .schedule-table td:nth-child(7) { width: 20%; }.schedule-table input:disabled { cursor: not-allowed; opacity: .6; }
.step-warning { margin: 0 0 16px; padding: 10px 14px; border: 1px solid rgba(255, 173, 93, .62); border-radius: 10px; color: #ffd19f; background: rgba(187, 102, 27, .16); }.schedule-table { min-width: 1180px; }.schedule-table .stage-cell { width: 12%; }.schedule-table .task-no-cell { width: 8%; }.schedule-table .task-cell { width: 21%; }.classification-cell { width: 13%; color: #eafaff; font-weight: 800; text-align: center; }.schedule-table .dependency-input { min-width: 180px; }.schedule-table input:disabled { cursor: not-allowed; opacity: .6; }
.gantt-upload-panel { display: grid; justify-items: center; gap: 22px; min-height: 320px; padding: 32px 16px; border: 1px dashed rgba(71, 204, 255, .55); border-radius: 18px; background: radial-gradient(circle at center, rgba(15, 119, 163, .16), transparent 55%); }.gantt-upload-trigger { display: grid; place-items: center; align-content: center; gap: 9px; width: 280px; height: 138px; border-radius: 12px; color: #fff; background: linear-gradient(135deg, #0ba6e0, #0877ad); cursor: pointer; box-shadow: 0 14px 30px rgba(5, 126, 180, .24); }.gantt-upload-trigger input { position: absolute; width: 1px; height: 1px; opacity: 0; }.gantt-upload-trigger .el-icon { font-size: 26px; }.gantt-upload-trigger span { color: #cbefff; font-size: 12px; }.gantt-upload-trigger.disabled { cursor: not-allowed; opacity: .55; }.gantt-preview { width: min(100%, 760px); margin: 0; text-align: center; }.gantt-preview img { display: block; width: 100%; max-height: 410px; object-fit: contain; border: 1px solid rgba(84, 211, 255, .5); border-radius: 12px; background: #fff; cursor: zoom-in; }.gantt-preview figcaption { margin-top: 8px; color: #a8dce9; font-size: 13px; }.gantt-guide { margin-top: 26px; padding: 18px 22px; border-left: 3px solid #23c4ff; border-radius: 0 14px 14px 0; color: #d4edf6; background: rgba(12, 75, 104, .22); }.gantt-guide h4 { margin: 0 0 14px; color: #fff; }.gantt-guide ol { margin: 0; padding-left: 20px; }.gantt-guide li { margin: 11px 0; line-height: 1.7; }.gantt-guide button { margin-left: 12px; border: 0; border-radius: 6px; padding: 5px 10px; color: #fff; background: #109ed4; cursor: pointer; }
.process-reflection { display: grid; gap: 28px; }.reflection-field { display: grid; gap: 12px; color: #d8edf4; font-size: 16px; line-height: 1.7; }.reflection-field span { color: #f1fbff; font-weight: 600; }.reflection-field textarea { min-height: 190px; resize: vertical; padding: 14px; border: 1px solid rgba(45, 95, 126, .95); border-radius: 14px; color: #eef5ff; background: #0f1a24; font: inherit; line-height: 1.7; }.reflection-field textarea:focus { outline: 0; border-color: rgba(92, 224, 255, .7); box-shadow: 0 0 0 3px rgba(0, 174, 255, .13); }.reflection-field textarea:disabled { cursor: not-allowed; opacity: .55; }.reflection-question { color: #f1fbff; font-size: 16px; font-weight: 600; line-height: 1.8; }.scenario-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 28px; }.scenario-grid .reflection-field textarea { min-height: 180px; }.process-reflection.disabled { opacity: .72; }

File diff suppressed because one or more lines are too long

@ -263,7 +263,6 @@ import { parseTrainingArray } from "@/views/training/taskKeyMap";
import { isStudentDemo } from "@/utils/studentDemo";
const TASK_KEY = "product-value-positioning";
const VALUE_PROPOSITION_CANVAS_TEMPLATE_URL = "/file/training-materials/product-value-positioning/12.产品价值主张画布模版.pptx";
const { proxy } = getCurrentInstance();
const saving = ref(false);
const checkingCorePain = ref(false);
@ -609,16 +608,7 @@ function downloadText(content, fileName) {
function exportCanvasSection(name, content) { downloadText(content, `${taskTitle.value}-${name}.md`); }
function downloadCanvasTemplate() {
downloadBuiltInFile(VALUE_PROPOSITION_CANVAS_TEMPLATE_URL, "12.产品价值主张画布模版.pptx");
}
function downloadBuiltInFile(url, fileName) {
const link = document.createElement("a");
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
downloadText("# 产品价值主张画布\n\n## 用户档案\n- 用户目标Jobs\n- 痛点Pains\n- 收益期望Gains\n\n## 产品价值地图\n- 产品与服务:\n- 痛点缓释器Pain Relievers\n- 增益创造器Gain Creators\n\n## 匹配Fit说明\n", "产品价值主张画布模板.md");
}
function openValueCanvasPicker() { valueCanvasInput.value?.click(); }

@ -319,8 +319,6 @@ import { getStudentTrainingAnswer, saveStudentTrainingAnswer, uploadStudentTrain
import { isStudentDemo } from "@/utils/studentDemo";
const TASK_KEY = "hardware-cost";
const HARDWARE_COST_GUIDE_URL = "/file/training-materials/hardware-cost/16.饼图制作操作说明.docx";
const HARDWARE_COST_TEMPLATE_URL = "/file/training-materials/hardware-cost/16.成本占比结构图绘制模版.xlsx";
const MIN_ROWS = 10;
const MAX_ROWS = 40;
const { proxy } = getCurrentInstance();
@ -706,18 +704,33 @@ function downloadTextFile(name, content) {
URL.revokeObjectURL(url);
}
function downloadCostStructureGuide() {
downloadBuiltInFile(HARDWARE_COST_GUIDE_URL, "16.饼图制作操作说明.docx");
}
function downloadBuiltInFile(url, fileName) {
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = fileName;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
}
function downloadCostStructureTemplate() {
downloadBuiltInFile(HARDWARE_COST_TEMPLATE_URL, "16.成本占比结构图绘制模版.xlsx");
downloadTextFile("智能硬件成本结构图-实训操作指导.txt", [
"智能硬件成本结构图绘制指导",
"1. 分别汇总第 2 步物料 BOM 成本与第 3 步制造成本。",
"2. 小批量图和大批量图分别使用饼图或环形图,清晰标注 BOM、制造成本及主要费用占比。",
"3. 结合占比最高的费用项提出降档、替换、减量或集成方案,并说明预期效果和风险控制措施。",
].join("\n"));
}
async function downloadCostStructureTemplate() {
try {
const XLSX = await import("xlsx");
const data = [
["智能硬件成本结构模板"],
[],
["成本类别", "小批量试产(元/台)", "大批量量产(元/台)"],
["物料 BOM 成本", smallBatchTotal.value, largeBatchTotal.value],
["制造成本", smallBatchManufacturingCost.value, largeBatchManufacturingCost.value],
["单件总成本", smallBatchUnitTotalCost.value, largeBatchUnitTotalCost.value],
];
const worksheet = XLSX.utils.aoa_to_sheet(data);
worksheet["!cols"] = [{ wch: 24 }, { wch: 28 }, { wch: 28 }];
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, "成本结构");
XLSX.writeFile(workbook, "智能硬件成本结构模板.xlsx");
notifySuccess("Excel 模板已下载");
} catch (error) {
notifyError(error?.message || "模板下载失败,请稍后重试");
}
}
async function loadAnswer() {
const response = await getStudentTrainingAnswer(TASK_KEY);

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

@ -418,35 +418,17 @@ function normalizeTask(task) {
function normalizeMaterial(materialName, materialUrl) {
const name = String(materialName || "").trim();
const url = normalizeMaterialUrl(materialUrl);
const url = String(materialUrl || "").trim();
return isRealMaterial(name, url) ? { name, url } : { name: "", url: "" };
}
function isRealMaterial(materialName, materialUrl) {
const name = String(materialName || "").trim();
const url = String(materialUrl || "").trim();
return Boolean(name && url) && !(name === "案例资料.rar" && url === "/file/example.rar");
}
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 {
//
if (!name) {
return false;
}
return url;
return !(name === "案例资料.rar" && (!url || url === "/file/example.rar"));
}
function parseArray(value, fallback) {
@ -533,7 +515,7 @@ async function handleMaterialChange(file) {
try {
const res = await uploadTrainingTaskMaterial(data);
taskForm.materialName = res?.originalFilename || rawFile.name || file?.name || taskForm.materialName;
taskForm.materialUrl = normalizeMaterialUrl(res?.url || res?.fileName || "");
taskForm.materialUrl = res?.url || res?.fileName || "";
proxy?.$modal?.msgSuccess("案例资料上传成功");
} catch (error) {
proxy?.$modal?.msgError?.("案例资料上传失败");

@ -125,11 +125,6 @@
</table>
</div>
<p class="hint">渠道名称同步第二步权重同步第一步每项得分乘以对应权重按百分比折算并相加得到加权得分下一步会按已保存的前两步数据重新计算并校验</p>
<p class="step-three-notice">说明渠道来自第二步权重来自第一步每个维度得分乘以权重后相加即为加权得分系统会校验加权得分填写不正确不能进入下一步</p>
<section class="step-three-materials" aria-label="">
<p>参考方法及工具如下下载案例数据表使用 Excel Min-Max 公式计算各渠道归一化得分可参考实训操作说明完成</p>
<el-button type="primary" class="step-three-materials__button" @click="downloadPracticeGuide"><el-icon><Download /></el-icon></el-button>
</section>
<footer class="footer-actions">
<el-button @click="activeStep = 2">上一步</el-button>
<el-button :loading="saving" @click="saveScoreStep"></el-button>
@ -179,7 +174,6 @@ import { getStudentTrainingAnswer, saveStudentTrainingAnswer, validateReleaseCha
import { parseTrainingArray } from "@/views/training/taskKeyMap";
const TASK_KEY = "release-channel-evaluation";
const RELEASE_CHANNEL_GUIDE_URL = "/file/training-materials/release-channel-evaluation/21.实训操作说明.docx";
const activeStep = ref(1);
const taskConfig = ref(null);
const saving = ref(false);
@ -232,17 +226,6 @@ function syncRankingRows(savedRows = []) { const savedByChannel = new Map(savedR
function openStepFour() { syncRankingRows(rankingRows.value); activeStep.value = 4; }
async function saveRankingStep() { if (saving.value) return; saving.value = true; try { await saveAnswer(4, { step4Answer: JSON.stringify(rankingPayload()) }); ElMessage.success("当前进度已保存"); } finally { saving.value = false; } }
async function validateRankingStep() { if (validating.value) return; validating.value = true; try { await validateReleaseChannelEvaluationStepFour(rankingPayload()); await saveStudentTrainingAnswer(TASK_KEY, { saveAction: "SUBMIT", currentStep: 4, step4Answer: JSON.stringify(rankingPayload()) }); ElMessage.success("渠道排名校验通过,任务已提交"); } catch (error) { ElMessage.error(error?.message || "请检查渠道排名和核心渠道运营建议"); } finally { validating.value = false; } }
function downloadPracticeGuide() {
downloadBuiltInFile(RELEASE_CHANNEL_GUIDE_URL, "21.实训操作说明.docx");
}
function downloadBuiltInFile(url, fileName) {
const link = document.createElement("a");
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
function exportChannelRows() { const headers = ["渠道", "日均总搜索量", "供需比", "平均点击率(%", "平均CPC", "平均转化率(%", "现有相关访客数", "匹配度1-5"]; const escape = (value) => `"${String(value ?? "").replaceAll('"', '""')}"`; const lines = [headers, ...channelRows.value.map((row) => [row.channel, row.dailySearchVolume, row.supplyDemandRatio, row.averageClickRate, row.averageCpc, row.averageConversionRate, row.existingVisitorCount, row.matchingScore])].map((row) => row.map(escape).join(",")); const url = URL.createObjectURL(new Blob([`\uFEFF${lines.join("\n")}`], { type: "text/csv;charset=utf-8" })); const link = document.createElement("a"); link.href = url; link.download = "产品发布渠道评估-渠道数据.csv"; link.click(); URL.revokeObjectURL(url); }
function restoreRows(raw, target, defaults, fields) { try { const saved = typeof raw === "string" ? JSON.parse(raw || "{}") : raw; if (!Array.isArray(saved?.rows)) return; target.value = saved.rows.map((row) => { const value = Object.fromEntries(fields.map((field) => [field, row?.[field]])); return makeRow(target === weightRows ? { ...value, suggestion: weightSuggestions[value.dimension] || "" } : value); }); } catch (error) { target.value = defaults(); } }
async function loadProgress() { try { const result = await getStudentTrainingAnswer(TASK_KEY); const answer = result?.data; restoreRows(answer?.step1Answer, weightRows, defaultWeightRows, ["dimension", "description", "weight"]); restoreRows(answer?.step2Answer, channelRows, defaultChannelRows, ["channel", "dailySearchVolume", "supplyDemandRatio", "averageClickRate", "averageCpc", "averageConversionRate", "existingVisitorCount", "matchingScore"]); let savedScores = []; try { const saved = typeof answer?.step3Answer === "string" ? JSON.parse(answer.step3Answer || "{}") : answer?.step3Answer; savedScores = Array.isArray(saved?.rows) ? saved.rows : []; } catch (error) { savedScores = []; } syncScoreRows(savedScores); let savedRanking = []; try { const saved = typeof answer?.step4Answer === "string" ? JSON.parse(answer.step4Answer || "{}") : answer?.step4Answer; savedRanking = Array.isArray(saved?.rows) ? saved.rows : []; coreChannelAnalysis.value = String(saved?.coreChannelAnalysis || ""); } catch (error) { savedRanking = []; coreChannelAnalysis.value = ""; } syncRankingRows(savedRanking); activeStep.value = Math.min(Math.max(Number(answer?.currentStep) || 1, 1), 4); } catch (error) { syncScoreRows(); syncRankingRows(); } }
@ -250,8 +233,7 @@ onMounted(async () => { try { const result = await getTrainingTaskByKey(TASK_KEY
</script>
<style scoped lang="scss">
.release-channel-page{display:grid;grid-template-columns:minmax(720px,1fr) 340px;gap:24px;min-height:calc(100vh - 76px);padding:24px;color:#eef5ff;background:#06111d}.training-core{min-width:0;padding:28px;border:1px solid rgba(0,180,255,.25);border-radius:30px;background:rgba(8,18,28,.6)}.task-header{margin-bottom:24px}.task-badge{display:inline-flex;gap:8px;align-items:center;padding:5px 14px;border:1px solid rgba(99,217,255,.55);border-radius:999px;color:#dff5ff;background:rgba(17,126,178,.36);font-size:12px;font-weight:700}.task-header h1{margin:12px 0 0;color:#fff;font-size:30px}.task-card{margin-top:28px;padding:24px;border:1px solid rgba(82,174,226,.42);border-radius:24px;background:rgba(0,25,42,.64)}.workbench-head{display:flex;align-items:flex-start;justify-content:space-between;gap:18px;padding-bottom:18px;border-bottom:1px solid rgba(0,180,255,.2)}.section-eyebrow{margin:0;color:#71dfff;font-size:12px;font-weight:800}.workbench-head h2{margin:4px 0 0;color:#fff;font-size:24px}.step-tabs{display:flex;gap:10px;margin:20px 0}.step-tabs button{min-height:36px;padding:0 15px;border:1px solid rgba(99,183,222,.42);border-radius:8px;color:#a8cde0;background:rgba(3,27,43,.56);font:inherit;font-size:13px;font-weight:800;cursor:pointer}.step-tabs button.active{border-color:#48d5ff;color:#fff;background:rgba(9,125,177,.7)}.lead,.scoring-guide{margin:22px 0;color:#e2f3ff;font-size:16px;line-height:1.9}.scoring-guide{padding:16px 18px;border-left:3px solid #48d5ff;border-radius:0 12px 12px 0;background:rgba(16,85,121,.2)}.table-actions{display:flex;justify-content:flex-end;gap:12px;margin:0 0 16px}.table-actions :deep(.el-button){min-width:110px;height:42px;font-weight:800}.table-wrap{overflow-x:auto;border:1px solid rgba(99,183,222,.46);border-radius:14px}.table-wrap table{width:100%;border-collapse:collapse}.weight-table{min-width:900px}.channel-table{min-width:1320px}.scoring-table{min-width:1780px}.ranking-table{min-width:880px}.table-wrap th,.table-wrap td{padding:12px;border-right:1px solid rgba(99,183,222,.35);border-bottom:1px solid rgba(99,183,222,.35);vertical-align:middle}.table-wrap th{white-space:nowrap;color:#effaff;background:#0c9dd6;font-size:14px}.table-wrap td{background:rgba(2,21,34,.58)}.weight-table tbody td:nth-child(1){width:22%}.weight-table tbody td:nth-child(2){width:43%}.weight-table tbody td:nth-child(3){width:23%}.channel-table td{min-width:134px}.channel-table td:first-child{min-width:110px}.scoring-table th:first-child{min-width:108px}.scoring-table td{min-width:108px}.scoring-table thead tr:nth-child(2) th{background:rgba(7,116,161,.94)}.ranking-table td{min-width:170px}.ranking-table td:first-child{min-width:100px}.rank-cell,.core-cell{text-align:center;font-weight:900}.core-cell .core{display:inline-flex;align-items:center;justify-content:center;min-width:30px;height:26px;border:1px solid rgba(99,235,204,.8);border-radius:999px;color:#062f30;background:#68edce}.weight-cell{color:#a8d9eb;text-align:center;font-weight:800}.analysis-field{display:block;margin-top:28px}.analysis-field>span{display:block;margin-bottom:10px;color:#dff5ff;font-size:15px;font-weight:800}.analysis-field :deep(.el-textarea__inner){min-height:180px!important;border:1px solid rgba(111,190,225,.3);border-radius:12px;box-shadow:none;color:#fff;background:#102233;line-height:1.7}.table-wrap :deep(.el-input__wrapper),.table-wrap :deep(.el-input-number){width:100%;background:#102233}.table-wrap :deep(.el-input__wrapper){box-shadow:0 0 0 1px rgba(111,190,225,.3) inset}.table-wrap :deep(.el-input__inner){color:#fff}.table-wrap :deep(.el-input-number .el-input__wrapper){background:#102233}.table-wrap small{display:block;margin-top:7px;color:#8fa8b8;font-size:12px}.operation-column{width:100px;text-align:center}.empty-row{text-align:center;color:#99b5c7}.table-wrap tfoot th,.table-wrap tfoot td{border-bottom:0;background:rgba(7,49,72,.74)}.total-error{color:#ff8c8c;font-weight:900}.hint{margin:18px 0 0;color:#95b2c3;line-height:1.8}.footer-actions{display:flex;justify-content:center;gap:16px;margin-top:30px}.footer-actions :deep(.el-button){min-width:156px;height:44px;font-weight:800}.footer-actions :deep(.el-button .el-icon){margin-left:5px}@media (max-width: 1100px){.release-channel-page{grid-template-columns: 1fr;}}@media (max-width:720px){.release-channel-page{padding:14px}.training-core{padding:18px}.task-card{padding:18px}.workbench-head{flex-direction:column}.table-actions,.footer-actions{align-items:stretch;flex-direction:column}.table-actions :deep(.el-button),.footer-actions :deep(.el-button){width:100%}}
.step-three-notice{margin:20px 0 0;padding:12px 16px;border:1px solid rgba(255,112,112,.52);border-radius:10px;color:#ffb3b3;background:rgba(126,31,35,.16);line-height:1.7}.step-three-materials{display:flex;align-items:center;justify-content:space-between;gap:20px;margin-top:18px;padding:16px 18px;border-left:3px solid #48d5ff;border-radius:0 12px 12px 0;background:rgba(16,85,121,.2);color:#d7edf8;line-height:1.7}.step-three-materials p{margin:0}.step-three-materials__button{flex:0 0 auto;font-weight:800}
.release-channel-page{display:grid;grid-template-columns:minmax(720px,1fr) 340px;gap:24px;min-height:calc(100vh - 76px);padding:24px;color:#eef5ff;background:#06111d}.training-core{min-width:0;padding:28px;border:1px solid rgba(0,180,255,.25);border-radius:30px;background:rgba(8,18,28,.6)}.task-header{margin-bottom:24px}.task-badge{display:inline-flex;gap:8px;align-items:center;padding:5px 14px;border:1px solid rgba(99,217,255,.55);border-radius:999px;color:#dff5ff;background:rgba(17,126,178,.36);font-size:12px;font-weight:700}.task-header h1{margin:12px 0 0;color:#fff;font-size:30px}.task-card{margin-top:28px;padding:24px;border:1px solid rgba(82,174,226,.42);border-radius:24px;background:rgba(0,25,42,.64)}.workbench-head{display:flex;align-items:flex-start;justify-content:space-between;gap:18px;padding-bottom:18px;border-bottom:1px solid rgba(0,180,255,.2)}.section-eyebrow{margin:0;color:#71dfff;font-size:12px;font-weight:800}.workbench-head h2{margin:4px 0 0;color:#fff;font-size:24px}.step-tabs{display:flex;gap:10px;margin:20px 0}.step-tabs button{min-height:36px;padding:0 15px;border:1px solid rgba(99,183,222,.42);border-radius:8px;color:#a8cde0;background:rgba(3,27,43,.56);font:inherit;font-size:13px;font-weight:800;cursor:pointer}.step-tabs button.active{border-color:#48d5ff;color:#fff;background:rgba(9,125,177,.7)}.lead,.scoring-guide{margin:22px 0;color:#e2f3ff;font-size:16px;line-height:1.9}.scoring-guide{padding:16px 18px;border-left:3px solid #48d5ff;border-radius:0 12px 12px 0;background:rgba(16,85,121,.2)}.table-actions{display:flex;justify-content:flex-end;gap:12px;margin:0 0 16px}.table-actions :deep(.el-button){min-width:110px;height:42px;font-weight:800}.table-wrap{overflow-x:auto;border:1px solid rgba(99,183,222,.46);border-radius:14px}.table-wrap table{width:100%;border-collapse:collapse}.weight-table{min-width:900px}.channel-table{min-width:1320px}.scoring-table{min-width:1780px}.ranking-table{min-width:880px}.table-wrap th,.table-wrap td{padding:12px;border-right:1px solid rgba(99,183,222,.35);border-bottom:1px solid rgba(99,183,222,.35);vertical-align:middle}.table-wrap th{white-space:nowrap;color:#effaff;background:#0c9dd6;font-size:14px}.table-wrap td{background:rgba(2,21,34,.58)}.weight-table tbody td:nth-child(1){width:22%}.weight-table tbody td:nth-child(2){width:43%}.weight-table tbody td:nth-child(3){width:23%}.channel-table td{min-width:134px}.channel-table td:first-child{min-width:110px}.scoring-table th:first-child{min-width:108px}.scoring-table td{min-width:108px}.scoring-table thead tr:nth-child(2) th{background:rgba(7,116,161,.94)}.ranking-table td{min-width:170px}.ranking-table td:first-child{min-width:100px}.rank-cell,.core-cell{text-align:center;font-weight:900}.core-cell .core{display:inline-flex;align-items:center;justify-content:center;min-width:30px;height:26px;border:1px solid rgba(99,235,204,.8);border-radius:999px;color:#062f30;background:#68edce}.weight-cell{color:#a8d9eb;text-align:center;font-weight:800}.analysis-field{display:block;margin-top:28px}.analysis-field>span{display:block;margin-bottom:10px;color:#dff5ff;font-size:15px;font-weight:800}.analysis-field :deep(.el-textarea__inner){min-height:180px!important;border:1px solid rgba(111,190,225,.3);border-radius:12px;box-shadow:none;color:#fff;background:#102233;line-height:1.7}.table-wrap :deep(.el-input__wrapper),.table-wrap :deep(.el-input-number){width:100%;background:#102233}.table-wrap :deep(.el-input__wrapper){box-shadow:0 0 0 1px rgba(111,190,225,.3) inset}.table-wrap :deep(.el-input__inner){color:#fff}.table-wrap :deep(.el-input-number .el-input__wrapper){background:#102233}.table-wrap small{display:block;margin-top:7px;color:#8fa8b8;font-size:12px}.operation-column{width:100px;text-align:center}.empty-row{text-align:center;color:#99b5c7}.table-wrap tfoot th,.table-wrap tfoot td{border-bottom:0;background:rgba(7,49,72,.74)}.total-error{color:#ff8c8c;font-weight:900}.hint{margin:18px 0 0;color:#95b2c3;line-height:1.8}.footer-actions{display:flex;justify-content:center;gap:16px;margin-top:30px}.footer-actions :deep(.el-button){min-width:156px;height:44px;font-weight:800}.footer-actions :deep(.el-button .el-icon){margin-left:5px}@media (max-width:1100px){.release-channel-page{grid-template-columns:1fr}}@media (max-width:720px){.release-channel-page{padding:14px}.training-core{padding:18px}.task-card{padding:18px}.workbench-head{flex-direction:column}.table-actions,.footer-actions{align-items:stretch;flex-direction:column}.table-actions :deep(.el-button),.footer-actions :deep(.el-button){width:100%}}
/* 与标准实训页保持一致的步骤条:编号、步骤状态和箭头连接均可清晰识别。 */
.step-tabs{display:flex;align-items:center;gap:8px;margin:20px 0 28px;padding:18px 22px;overflow-x:auto;border:1px solid rgba(0,180,255,.3);border-radius:22px;background:rgba(1,18,31,.62)}.step-tabs .step-tab{display:flex;flex:1 1 0;align-items:center;min-width:145px;min-height:66px;gap:10px;padding:8px 12px;border:1px solid rgba(68,141,177,.35);border-radius:12px;color:#9ab3d0;background:rgba(3,27,43,.52);font:inherit;text-align:left;cursor:pointer;transition:.2s ease}.step-tabs .step-tab:hover{border-color:rgba(89,223,255,.82);color:#fff;background:rgba(8,92,131,.52);transform:translateY(-1px)}.step-tabs .step-tab:focus-visible{outline:2px solid #72e8ff;outline-offset:3px}.step-tabs .step-marker{display:grid;flex:0 0 38px;width:38px;height:38px;place-items:center;border:1px solid rgba(101,176,208,.6);border-radius:50%;color:#a6c4d9;background:rgba(3,25,40,.8);font-size:12px;font-weight:900}.step-tabs .step-copy{display:grid;min-width:0;gap:4px}.step-tabs .step-copy b{overflow:hidden;font-size:14px;font-weight:800;text-overflow:ellipsis;white-space:nowrap}.step-tabs .step-copy small{color:#66b6d4;font-size:10px;font-weight:800;letter-spacing:.06em}.step-tabs .step-tab.active{border-color:#53d8fb;color:#fff;background:linear-gradient(135deg,rgba(7,156,207,.85),rgba(13,105,161,.92));box-shadow:inset 0 0 24px rgba(77,213,255,.22)}.step-tabs .step-tab.active .step-marker{border-color:#72e8ff;color:#fff;background:linear-gradient(135deg,#08b7e8,#1375d0);box-shadow:0 0 0 5px rgba(25,179,237,.14),0 0 18px rgba(46,208,255,.58)}.step-tabs .step-tab.complete{color:#dffbff}.step-tabs .step-tab.complete .step-marker{border-color:rgba(84,230,221,.85);color:#062b37;background:#6ef0e1}.step-tabs .step-connector{position:relative;flex:0 0 24px;height:2px;background:rgba(86,198,235,.48)}.step-tabs .step-connector::after{position:absolute;top:50%;right:0;width:7px;height:7px;border-top:1px solid #73b1ce;border-right:1px solid #73b1ce;content:'';transform:translateY(-50%) rotate(45deg)}
@media(max-width:720px){.step-tabs{align-items:stretch;flex-direction:column;padding:14px}.step-tabs .step-tab{flex:0 0 auto;width:100%}.step-tabs .step-connector{flex:0 0 18px;width:2px;height:18px;margin-left:18px}.step-tabs .step-connector::after{top:auto;right:auto;bottom:0;left:50%;transform:translateX(-50%) rotate(135deg)}}

@ -8,7 +8,7 @@
<TrainingTaskBrief :background="taskBackground" :goals="taskGoals" :requirements="taskRequirement" :show-material="false" />
<section class="task-card promotion-phase">
<section class="task-card">
<header class="workbench-head">
<div>
<p class="section-eyebrow">STEP {{ String(activeStep).padStart(2, "0") }}</p>
@ -283,7 +283,6 @@ onMounted(async () => { try { const result = await getTrainingTaskByKey(TASK_KEY
/* 统一为标准实训页步骤条:编号圆点、状态说明和连接箭头。 */
.step-tabs{display:flex;align-items:center;gap:8px;margin:20px 0 28px;padding:18px 22px;overflow-x:auto;border:1px solid rgba(0,180,255,.3);border-radius:22px;background:rgba(1,18,31,.62)}.step-tabs .step-tab{display:flex;flex:1 1 0;align-items:center;min-width:145px;min-height:66px;gap:10px;padding:8px 12px;border:1px solid rgba(68,141,177,.35);border-radius:12px;color:#9ab3d0;background:rgba(3,27,43,.52);font:inherit;text-align:left;cursor:pointer;transition:.2s ease}.step-tabs .step-tab:hover{border-color:rgba(89,223,255,.82);color:#fff;background:rgba(8,92,131,.52);transform:translateY(-1px)}.step-tabs .step-tab:focus-visible{outline:2px solid #72e8ff;outline-offset:3px}.step-tabs .step-marker{display:grid;flex:0 0 38px;width:38px;height:38px;place-items:center;border:1px solid rgba(101,176,208,.6);border-radius:50%;color:#a6c4d9;background:rgba(3,25,40,.8);font-size:12px;font-weight:900}.step-tabs .step-copy{display:grid;min-width:0;gap:4px}.step-tabs .step-copy b{overflow:hidden;font-size:14px;font-weight:800;text-overflow:ellipsis;white-space:nowrap}.step-tabs .step-copy small{color:#66b6d4;font-size:10px;font-weight:800;letter-spacing:.06em}.step-tabs .step-tab.active{border-color:#53d8fb;color:#fff;background:linear-gradient(135deg,rgba(7,156,207,.85),rgba(13,105,161,.92));box-shadow:inset 0 0 24px rgba(77,213,255,.22)}.step-tabs .step-tab.active .step-marker{border-color:#72e8ff;color:#fff;background:linear-gradient(135deg,#08b7e8,#1375d0);box-shadow:0 0 0 5px rgba(25,179,237,.14),0 0 18px rgba(46,208,255,.58)}.step-tabs .step-tab.complete{color:#dffbff}.step-tabs .step-tab.complete .step-marker{border-color:rgba(84,230,221,.85);color:#062b37;background:#6ef0e1}.step-tabs .step-connector{position:relative;flex:0 0 24px;height:2px;background:rgba(86,198,235,.48)}.step-tabs .step-connector::after{position:absolute;top:50%;right:0;width:7px;height:7px;border-top:1px solid #73b1ce;border-right:1px solid #73b1ce;content:'';transform:translateY(-50%) rotate(45deg)}
@media(max-width:720px){.step-tabs{align-items:stretch;flex-direction:column;padding:14px}.step-tabs .step-tab{flex:0 0 auto;width:100%}.step-tabs .step-connector{flex:0 0 18px;width:2px;height:18px;margin-left:18px}.step-tabs .step-connector::after{top:auto;right:auto;bottom:0;left:50%;transform:translateX(-50%) rotate(135deg)}}
@media (max-width: 900px) { .promotion-phase :deep(.el-button) { width: 100%; } }
</style>
<style scoped lang="scss">
.evaluation-form{padding-top:4px}.evaluation-intro{padding:18px;margin-top:14px;border:1px solid rgba(99,183,222,.36);border-radius:14px;background:rgba(8,42,64,.36)}.evaluation-intro p,.decision-section>p{color:#c8e5f1;line-height:1.75}.overall-roi{display:flex;flex-wrap:wrap;gap:12px;margin-top:14px}.overall-roi span{padding:7px 12px;border:1px solid rgba(99,183,222,.42);border-radius:999px;color:#d7f0fa;background:rgba(3,27,43,.58);font-weight:800}.overall-roi .achieved{border-color:rgba(81,224,178,.72);color:#86f3ca}.overall-roi .underperforming{border-color:rgba(255,152,152,.7);color:#ffb0b0}.evaluation-table-wrap{overflow-x:auto;margin-top:24px;border:1px solid rgba(99,183,222,.46);border-radius:14px}.evaluation-table{width:100%;min-width:1050px;border-collapse:collapse}.evaluation-table th,.evaluation-table td{padding:12px;border-right:1px solid rgba(99,183,222,.35);border-bottom:1px solid rgba(99,183,222,.35);vertical-align:middle}.evaluation-table th{white-space:nowrap;color:#effaff;background:#0c9dd6;font-size:14px}.evaluation-table td{background:rgba(2,21,34,.58)}.evaluation-table th:first-child{min-width:110px}.evaluation-table td:nth-child(2){min-width:100px}.evaluation-table td:nth-child(3){min-width:145px}.evaluation-table td:nth-child(4),.evaluation-table td:nth-child(5){min-width:250px}.evaluation-table :deep(.el-select){width:100%}.evaluation-table :deep(.el-input__wrapper),.evaluation-table :deep(.el-textarea__inner),.decision-section :deep(.el-textarea__inner){box-shadow:0 0 0 1px rgba(111,190,225,.3) inset;color:#fff;background:#102233}.synced-roi{color:#7ce4ff;text-align:center;font-size:16px;font-weight:900}.decision-section{margin-top:30px}.decision-section :deep(.el-textarea__inner){min-height:170px!important;padding:12px 14px;line-height:1.75}@media (max-width:720px){.overall-roi{align-items:stretch;flex-direction:column}}

File diff suppressed because it is too large Load Diff

@ -13,11 +13,11 @@ assert(/`\/api\/student\/training-tasks\/\$\{taskKey\}\/ai-evaluation`/.test(api
assert(/\/help/.test(api) && /\/assessment/.test(api), "evaluation API should expose help and assessment operations");
assert(/TrainingAiSidebar/.test(page) && /:has-saved-answer="hasSavedAnswer"/.test(page), "generic training page should pass persisted answer state to the AI sidebar");
assert(/form\.value/.test(page) && /workRows\.value/.test(page) && /processRows\.value/.test(page), "hasAnyAnswer should inspect all supported answer shapes");
assert(/requestAiTrainingHelp\(taskKey, question, stepContext = ""\)/.test(api), "AI help API should accept the student's question and current step context");
assert(/data:\s*\{\s*question\s*,\s*stepContext\s*\}/.test(api), "AI help API should submit the student's question and current step context as JSON");
assert(/requestAiTrainingHelp\(taskKey, question\)/.test(api), "AI help API should accept the student's question");
assert(/data:\s*\{\s*question\s*\}/.test(api), "AI help API should submit the student's question as JSON");
assert(/const question = assistQuestion\.value\.trim\(\)/.test(sidebar), "AI sidebar should trim the student's question before sending it");
assert(/if \(!question\)/.test(sidebar), "AI sidebar should block an empty AI help question");
assert(/requestAiTrainingHelp\(currentTaskKey\.value, question, currentStepContext\.value\)/.test(sidebar), "AI sidebar should send the trimmed question and current step context to AI help");
assert(/requestAiTrainingHelp\(currentTaskKey\.value, question\)/.test(sidebar), "AI sidebar should send the trimmed question to AI help");
assert(/onMounted\(/.test(component) && /watch\(\s*\(\)\s*=>\s*props\.taskKey/.test(component), "AI evaluation should load on mount and task changes");
assert(/AI助学/.test(component) && /AI助评/.test(component), "AI evaluation should offer separate help and assessment controls");

@ -1,13 +0,0 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const source = fs.readFileSync(path.resolve(__dirname, '../src/views/demand/business-feasibility.vue'), 'utf8');
test('商业可行性第一步按七个原型区块拆分成本、定价与风险表格', () => {
assert.ok(source.includes('<h3>成本结构分析</h3>'), '成本结构应为独立区块');
assert.ok(source.includes('<h3>定价与毛利分析</h3>'), '定价与毛利应为独立区块');
assert.ok(source.includes('<span>07</span>'), '风险识别应作为第七个区块');
assert.ok(source.includes('readonly'), '单位总成本、单位毛利和毛利率应为只读计算结果');
});

@ -1,30 +0,0 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const test = require("node:test");
const sourcePath = path.resolve(__dirname, "../src/views/demand/business-feasibility.vue");
const backendPath = path.resolve(
__dirname,
"../../dianshang-houduan/src/main/java/com/sztzjy/linkCommerce/service/impl/BusinessFeasibilityStepOneServiceImpl.java"
);
test("商业可行性论证第 1 步覆盖七个分析区块并由后端校验计算", () => {
const source = fs.readFileSync(sourcePath, "utf8");
const backend = fs.readFileSync(backendPath, "utf8");
["项目概述", "市场规模与竞争格局", "目标用户群体分析", "商业模式梳理", "成本结构分析", "定价与毛利分析", "风险识别与评估"].forEach((label) => {
assert.match(source, new RegExp(label));
});
assert.match(source, /以下提供了可行性分析的各个子模块/);
assert.match(source, /单位毛利 = 单位目标售价 单位总成本/);
assert.match(source, /unitTotalCost/);
assert.match(backend, /validateOverview\(request\.getProjectOverview\(\)\)/);
assert.match(backend, /validateRisks\(request\.getRisks\(\)\)/);
});
test("目标用户群体表格的表头保持横向表格单元格布局", () => {
const source = fs.readFileSync(sourcePath, "utf8");
assert.match(source, /\.table-wrap th \{[^}]*display:table-cell[^}]*text-align:center/);
});

@ -1,22 +0,0 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const test = require("node:test");
const sourcePath = path.resolve(__dirname, "../src/views/assessment/optimization.vue");
const materialsRoot = path.resolve(
__dirname,
"../../dianshang-houduan/src/main/resources/static/file/training-materials/category-optimization"
);
test("品类结构优化第 3 步提供帕累托图资料下载并保留图片预览", () => {
const source = fs.readFileSync(sourcePath, "utf8");
assert.ok(fs.existsSync(path.join(materialsRoot, "20.实训操作说明.docx")), "后端应提供实训操作说明");
assert.ok(fs.existsSync(path.join(materialsRoot, "20.帕累托绘制自动化模版.xlsx")), "后端应提供帕累托图 Excel 模板");
assert.match(source, /下载实训操作说明/);
assert.match(source, /下载 Excel 模板/);
assert.match(source, /:preview-src-list="\[paretoImage\.url\]"/);
assert.match(source, /\/file\/training-materials\/category-optimization\/20\.实训操作说明\.docx/);
assert.match(source, /\/file\/training-materials\/category-optimization\/20\.帕累托绘制自动化模版\.xlsx/);
});

@ -1,16 +0,0 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const test = require("node:test");
const source = fs.readFileSync(path.join(__dirname, "../src/api/studentTrainingAnswer.js"), "utf8");
test("竞争环境分析第 4 步保留后端中文校验文字且不触发全局 400 提示", () => {
const functionStart = source.indexOf("export function checkCompetitiveEnvironmentAnalysisStepFour(payload)");
const functionEnd = source.indexOf("const consumerScenarioJourneyOrder", functionStart);
const functionSource = source.slice(functionStart, functionEnd);
assert.ok(functionStart >= 0 && functionEnd > functionStart, "应能定位第 4 步校验请求");
assert.doesNotMatch(functionSource, /overallEvaluation/);
assert.match(functionSource, /skipErrorMessage: true/);
});

@ -1,20 +0,0 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const test = require("node:test");
const source = fs.readFileSync(path.resolve(__dirname, "../src/views/demand/consumer-behavior.vue"), "utf8");
const templatePath = path.resolve(
__dirname,
"../../dianshang-houduan/src/main/resources/static/file/training-materials/consumer-behavior/9.问卷调查报告模板.docx"
);
test("消费者需求与行为第三步从后端下载问卷模板", () => {
assert.ok(fs.existsSync(templatePath), "问卷模板应随后端资源发布");
assert.match(source, /\/file\/training-materials\/consumer-behavior\/9\.问卷调查报告模板\.docx/);
assert.match(source, /下载模板/);
assert.match(source, /function downloadQuestionnaireTemplate\(/);
const reportFunction = source.match(/function downloadSurveyReportTemplate\(\)[\s\S]*?\n}\n<\/script>/)?.[0] || "";
assert.match(reportFunction, /downloadBuiltInFile\(QUESTIONNAIRE_TEMPLATE_URL, "9\.问卷调查报告模板\.docx"\)/);
assert.doesNotMatch(reportFunction, /new Blob/, "报告模板不应由前端临时生成");
});

@ -1,20 +0,0 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const test = require("node:test");
const source = fs.readFileSync(path.resolve(__dirname, "../src/views/demand/consumer-scenario.vue"), "utf8");
const templatePath = path.resolve(
__dirname,
"../../dianshang-houduan/src/main/resources/static/file/training-materials/consumer-scenario/10.用户购买旅程模版.pptx"
);
test("用户购买旅程模板从后端内置资料目录下载", () => {
assert.ok(fs.existsSync(templatePath), "用户购买旅程模板应随后端资源发布");
assert.match(source, /\/file\/training-materials\/consumer-scenario\/10\.用户购买旅程模版\.pptx/);
assert.match(
source,
/function downloadJourneyMapTemplate\(\) \{\s*downloadBuiltInFile\(JOURNEY_TEMPLATE_URL, "10\.用户购买旅程模版\.pptx"\);\s*}/,
"模板按钮应调用后端内置文件下载"
);
});

@ -1,21 +0,0 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const test = require("node:test");
const sourcePath = path.resolve(__dirname, "../src/views/product/hardware-cost.vue");
const guidePath = path.resolve(
__dirname,
"../../dianshang-houduan/src/main/resources/static/file/training-materials/hardware-cost/16.饼图制作操作说明.docx"
);
test("硬件成本估算操作指导由后端内置 Word 文件提供下载", () => {
const source = fs.readFileSync(sourcePath, "utf8");
assert.ok(fs.existsSync(guidePath), "后端应提供饼图制作操作说明 Word 文件");
assert.match(source, /\/file\/training-materials\/hardware-cost\/16\.饼图制作操作说明\.docx/);
assert.match(
source,
/function downloadCostStructureGuide\(\) \{\s*downloadBuiltInFile\(HARDWARE_COST_GUIDE_URL, "16\.饼图制作操作说明\.docx"\);\s*\}/
);
});

@ -1,21 +0,0 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const test = require("node:test");
const sourcePath = path.resolve(__dirname, "../src/views/product/hardware-cost.vue");
const templatePath = path.resolve(
__dirname,
"../../dianshang-houduan/src/main/resources/static/file/training-materials/hardware-cost/16.成本占比结构图绘制模版.xlsx"
);
test("硬件成本占比结构图模板由后端内置 Excel 文件提供下载", () => {
const source = fs.readFileSync(sourcePath, "utf8");
assert.ok(fs.existsSync(templatePath), "后端应提供成本占比结构图 Excel 模板");
assert.match(source, /\/file\/training-materials\/hardware-cost\/16\.成本占比结构图绘制模版\.xlsx/);
assert.match(
source,
/function downloadCostStructureTemplate\(\) \{\s*downloadBuiltInFile\(HARDWARE_COST_TEMPLATE_URL, "16\.成本占比结构图绘制模版\.xlsx"\);\s*\}/
);
});

@ -1,22 +0,0 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const test = require("node:test");
const sourcePath = path.resolve(__dirname, "../src/views/product/market-opportunity-selection.vue");
const source = fs.readFileSync(sourcePath, "utf8");
const guidePath = path.resolve(
__dirname,
"../../dianshang-houduan/src/main/resources/static/file/training-materials/market-opportunity-selection/6.综合得分计算操作说明.docx"
);
test("综合得分计算操作说明从后端内置资料目录下载", () => {
assert.ok(fs.existsSync(guidePath), "操作说明应随后端资源发布");
assert.match(
source,
/\/file\/training-materials\/market-opportunity-selection\/6\.综合得分计算操作说明\.docx/,
"下载按钮应指向后端文件服务"
);
const functionBody = source.match(/function downloadInstructions\(\)[\s\S]*?\n}\nfunction downloadTemplate/)?.[0] || "";
assert.doesNotMatch(functionBody, /new Blob/, "操作说明不应再由前端临时生成文本文件");
});

@ -1,18 +0,0 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const test = require("node:test");
const pageSource = fs.readFileSync(path.join(__dirname, "../src/views/product/market-opportunity-selection.vue"), "utf8");
const routerSource = fs.readFileSync(path.join(__dirname, "../src/router/index.js"), "utf8");
const appMainSource = fs.readFileSync(path.join(__dirname, "../src/layout/components/AppMain.vue"), "utf8");
test("清空第 1 步会移除已保存答案,重新进入不会恢复分类选择", () => {
assert.match(pageSource, /import \{[^}]*deleteStudentTrainingAnswer[^}]*\} from "@\/api\/studentTrainingAnswer"/);
assert.match(pageSource, /async function resetStepOne\(\)[\s\S]*?await deleteStudentTrainingAnswer\(TASK_KEY\)[\s\S]*?answers\.value = createAnswers\(\)/);
});
test("数据化选品页不显示右下角补充步骤导航", () => {
assert.match(routerSource, /pageKey: "market-opportunity-selection", hideSupplementSteps: true/);
assert.match(appMainSource, /route\.meta\?\.pageKey && !route\.meta\?\.hideSupplementSteps/);
});

@ -1,66 +0,0 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const test = require("node:test");
const source = fs.readFileSync(path.join(__dirname, "../src/views/product/market-opportunity-selection.vue"), "utf8");
test("第二步按指定顺序渲染固定权重维度,并实时展示每个维度合计", () => {
const expectedIds = [
"search-count", "transaction-amount", "transaction-growth",
"category-transaction-growth", "demand-supply-ratio", "online-products",
"online-merchants", "organic-traffic-ratio", "return-rate",
];
const actualIds = [...source.matchAll(/\{ id: "([^"]+)"/g)].slice(0, 9).map((match) => match[1]);
assert.deepEqual(actualIds, expectedIds);
assert.match(source, /items:\s*indicators\.filter\(\(item\) => item\.group === name\)/);
assert.match(source, /维度内权重合计\s*<strong[^>]*>\{\{ dimensionWeightTotal\(group\.name\) \}\}%<\/strong>/);
});
test("第二步以固定分组计算对应总权重,并在保存前要求所有合计均为 100%", () => {
assert.match(source, /primaryWeights\.value\[item\.group\]/);
const validation = source.match(/function isValidSavedStepTwoOutcome\(primary, dimension\) \{([^}]*)\}/)?.[1] || "";
assert.match(validation, /primaryTotal === 100/);
assert.match(validation, /correspondingTotal === 100/);
assert.match(validation, /calculateDimensionWeightTotal\(group, dimension\) === 100/);
assert.match(source, /function isStepTwoReady\(\) \{ return isValidSavedStepTwoOutcome\(primaryWeights\.value, weights\.value\); \}/);
assert.match(source, /if \(!isStepTwoReady\(\)\) \{ proxy\?\.\$modal\?\.msgError\?\.\("请确认一级指标、维度内权重和对应总权重均为 100%"\); return; \}/);
});
test("第二步为全部一级和二级指标提供题目规定的建议区间与固定分组", () => {
const expectedPrimarySuggestions = {
"市场规模": "2535", "市场潜力": "2535", "竞争强度": "2030", "运营难度": "1020",
};
const expectedMetricGroups = {
"search-count": "市场规模", "transaction-amount": "市场规模",
"transaction-growth": "市场潜力", "category-transaction-growth": "市场潜力",
"demand-supply-ratio": "竞争强度", "online-products": "竞争强度", "online-merchants": "竞争强度",
"organic-traffic-ratio": "运营难度", "return-rate": "运营难度",
};
const expectedMetricSuggestions = ["2535", "6575", "5565", "3545", "2535", "3545", "2535", "5565", "3545"];
const metricEntries = [...source.matchAll(/\{ id: "([^"]+)", name: "[^"]+", group: "([^"]+)", suggestion: "([^"]+)" \}/g)].slice(0, 9);
assert.deepEqual(Object.fromEntries(metricEntries.map(([, id, group]) => [id, group])), expectedMetricGroups);
assert.deepEqual(metricEntries.map(([, , , suggestion]) => suggestion), expectedMetricSuggestions);
for (const [group, suggestion] of Object.entries(expectedPrimarySuggestions)) {
assert.match(source, new RegExp(`"${group}": "${suggestion}"`));
}
});
test("第二步的对应总权重列仅显示计算结果,不提供可编辑输入", () => {
const weightTable = source.match(/<table class="weight-table">([\s\S]*?)<\/table>/)?.[1] || "";
assert.match(weightTable, /<td class="corresponding-weight">\{\{ formatWeight\(correspondingWeight\(item\)\) \}\}%<\/td>/);
assert.doesNotMatch(weightTable, /class="corresponding-weight"[^>]*>[\s\S]*<input/);
});
test("恢复历史第一步答案时,只有与固定分组完全一致的答案才能解锁第二步", () => {
assert.match(source, /stepOnePassed\.value = indicators\.every\(\(item\) => answers\.value\[item\.id\] === item\.group\)/);
});
test("第二步只恢复通过三级 100% 校验的历史权重,失效历史数据保持空白", () => {
assert.match(source, /function isValidSavedStepTwoOutcome\(primary, dimension\)/);
assert.match(source, /if \(isValidSavedStepTwoOutcome\(savedPrimaryWeights, savedDimensionWeights\)\) \{/);
assert.match(source, /primaryWeights\.value = createPrimaryWeights\(\); weights\.value = createWeights\(\);/);
});

@ -1,28 +0,0 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const test = require("node:test");
const source = fs.readFileSync(path.join(__dirname, "../src/views/product/market-opportunity-selection.vue"), "utf8");
test("第三步固定呈现三个产品品类的综合得分输入", () => {
const expected = ["smart-fitness-mirror", "smart-bathroom-mirror", "smart-beauty-mirror"];
const productBlock = source.match(/const products = \[([^;]+)\];/)?.[1] || "";
const actual = [...productBlock.matchAll(/id: "([^"]+)"/g)].map((match) => match[1]);
assert.deepEqual(actual, expected);
assert.match(source, /<table class="score-table">[\s\S]*?综合得分[\s\S]*?v-model\.number="scores\[product\.id\]"[\s\S]*?min="0" max="100" step="0\.01"/);
});
test("第三步下载真实 Excel 自动化模板,并保留 Min-Max 与三段式权重说明", () => {
assert.match(source, /import \* as XLSX from "xlsx"/);
assert.match(source, /XLSX\.utils\.book_new\(\)/);
assert.match(source, /XLSX\.utils\.book_append_sheet\(workbook, worksheet, "综合得分计算"\)/);
assert.match(source, /XLSX\.writeFile\(workbook, "市场机会识别与选品-自动化模板\.xlsx"\)/);
assert.match(source, /primaryWeightCell = XLSX\.utils\.encode_cell\(\{ c: rawStartColumn \+ index, r: 9 \}\)/);
assert.match(source, /dimensionWeightCell = XLSX\.utils\.encode_cell\(\{ c: rawStartColumn \+ index, r: 10 \}\)/);
assert.match(source, /correspondingWeightCell = XLSX\.utils\.encode_cell\(\{ c: rawStartColumn \+ index, r: 11 \}\)/);
assert.match(source, /\$\$\{correspondingStart\}\$12:\$\$\{correspondingEnd\}\$12/);
assert.match(source, /Min-Max 归一法/);
assert.match(source, /第 120 天、第 2140 天、第 4160 天/);
});

@ -1,23 +0,0 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const test = require("node:test");
const source = fs.readFileSync(path.join(__dirname, "../src/views/product/market-opportunity-selection.vue"), "utf8");
test("第四步提供可预览的数据可视化结果图上传", () => {
assert.match(source, /accept="image\/png,image\/jpeg,image\/webp"/);
assert.match(source, /<el-image :src="visualization\.url" :preview-src-list="\[visualization\.url\]"/);
assert.match(source, /点击缩略图可放大查看/);
});
test("第四步下载含日综合得分汇总公式的 Excel 自动化模板", () => {
assert.match(source, /function downloadVisualizationTemplate\(\)/);
assert.match(source, /"日综合得分录入"/);
assert.match(source, /AVERAGEIF\(/);
assert.match(source, /MAXIFS\(/);
assert.match(source, /MINIFS\(/);
assert.match(source, /MAXIFS\([\s\S]*?,"<>"/);
assert.match(source, /MINIFS\([\s\S]*?,"<>"/);
assert.match(source, /XLSX\.writeFile\(workbook, "市场机会识别与选品-数据可视化模板\.xlsx"\)/);
});

@ -1,25 +0,0 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const test = require("node:test");
const sourcePath = path.resolve(__dirname, "../src/views/product/market-opportunity-selection.vue");
const source = fs.readFileSync(sourcePath, "utf8");
const templatePath = path.resolve(
__dirname,
"../../dianshang-houduan/src/main/resources/static/file/training-materials/market-opportunity-selection/6.数据表-抖音电商 - 计算模版.xlsx"
);
test("综合得分计算自动化模板从后端内置资料目录下载", () => {
assert.ok(fs.existsSync(templatePath), "自动化模板应随后端资源发布");
assert.match(
source,
/\/file\/training-materials\/market-opportunity-selection\/6\.数据表-抖音电商 - 计算模版\.xlsx/,
"下载按钮应指向后端文件服务"
);
assert.match(
source,
/function downloadTemplate\(\) \{ downloadBuiltInFile\(SCORE_CALCULATION_TEMPLATE_URL, "6\.数据表-抖音电商 - 计算模版\.xlsx"\); \}\nfunction downloadBuiltInFile/,
"自动化模板应通过后端内置文件下载"
);
});

@ -1,16 +0,0 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const test = require("node:test");
const source = fs.readFileSync(path.join(__dirname, "../src/views/foundation/new-product-survey.vue"), "utf8");
test("实训一第二步的上传图片使用可访问地址预览", () => {
assert.match(source, /import\s*\{\s*isExternal\s*\}\s*from\s*["']@\/utils\/validate["']/);
assert.match(source, /<img\s+:src="resolveUploadedImageUrl\(image\.url\)"/);
assert.match(source, /function previewStepTwoImage\(url\)\s*\{\s*imagePreviewUrl\.value\s*=\s*resolveUploadedImageUrl\(url\)/);
});
test("实训一第二步将旧 localhost 文件地址转换为文件服务路径", () => {
assert.match(source, /function resolveUploadedImageUrl\(url\)[\s\S]*?new URL\(source\)[\s\S]*?localhost[\s\S]*?return `\$\{parsed\.pathname\}\$\{parsed\.search\}\$\{parsed\.hash\}`/);
});

@ -1,15 +0,0 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const test = require("node:test");
const source = fs.readFileSync(path.join(__dirname, "../src/views/foundation/new-product-survey.vue"), "utf8");
test("实训一第二步标题与短选项采用紧凑双列布局", () => {
assert.match(source, /<section v-show="currentStep === 2" class="step-content active-step">[\s\S]*?<div class="step-card step-two-survey">/);
const stepOne = source.match(/<section v-show="currentStep === 1" class="step-content active-step">([\s\S]*?)<\/section>/)?.[1] || "";
assert.doesNotMatch(stepOne, /step-two-survey/);
assert.match(source, /\.step-two-survey\s*\{[\s\S]*?\.step-title-row\s*\{[\s\S]*?h3\s*\{[\s\S]*?font-size:\s*19px/);
assert.match(source, /\.step-two-survey\s*\{[\s\S]*?\.survey-options\s*\{[\s\S]*?grid-template-columns:\s*repeat\(2, minmax\(0, 1fr\)\)/);
assert.match(source, /@media \(max-width: 720px\)\s*\{[\s\S]*?\.step-two-survey\s*\{[\s\S]*?\.survey-options\s*\{[\s\S]*?grid-template-columns:\s*1fr/);
});

@ -1,24 +0,0 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const test = require("node:test");
const sourcePath = path.resolve(__dirname, "../src/views/product/structure.vue");
const templatePath = path.resolve(
__dirname,
"../../dianshang-houduan/src/main/resources/static/file/training-materials/product-architecture/13.产品架构图绘制模版.pptx"
);
test("产品架构图绘制模板由后端内置 PPTX 提供下载", () => {
const source = fs.readFileSync(sourcePath, "utf8");
assert.ok(fs.existsSync(templatePath), "后端应提供产品架构图绘制 PPTX 模板");
assert.match(
source,
/\/file\/training-materials\/product-architecture\/13\.产品架构图绘制模版\.pptx/
);
assert.match(
source,
/function downloadArchitectureTemplate\(\) \{\s*downloadBuiltInFile\(PRODUCT_ARCHITECTURE_TEMPLATE_URL, "13\.产品架构图绘制模版\.pptx"\);\s*\}/
);
});

@ -1,24 +0,0 @@
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const root = path.resolve(__dirname, "..");
const styles = fs.readFileSync(path.join(root, "src/assets/styles/student-training.scss"), "utf8");
assert.match(
styles,
/\.student-training-shell\.product-process\s*\{[\s\S]*?gap:\s*10px\s*!important;[\s\S]*?padding:\s*10px\s+10px\s+16px\s*!important;/,
"产品开发主要流程页应压缩外层边距与双栏间隔"
);
assert.match(
styles,
/\.student-training-shell\.product-process\s*>\s*main\s*\{[\s\S]*?padding:\s*12px\s*!important;/,
"产品开发主要流程主卡应减少装饰性内边距"
);
assert.match(
styles,
/\.student-training-shell\.product-process\s+\.process-workbench\s*\{[\s\S]*?padding:\s*10px\s+12px\s*!important;/,
"产品开发主要流程工作台应为实操内容释放空间"
);
console.log("Product development process uses the compact training layout.");

@ -1,21 +0,0 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const source = fs.readFileSync(path.resolve(__dirname, '../src/views/foundation/product-development-process.vue'), 'utf8');
const guidePath = path.resolve(__dirname, '../../dianshang-houduan/src/main/resources/static/file/training-materials/product-development-process/3.甘特图制作实训操作说明.docx');
const templatePath = path.resolve(__dirname, '../../dianshang-houduan/src/main/resources/static/file/training-materials/product-development-process/3.项目进度管理表-项目进度甘特图模版.xlsx');
test('甘特图实训操作说明从后端内置资料目录下载', () => {
assert.ok(fs.existsSync(guidePath), '操作说明应随后端资源发布');
assert.ok(source.includes('/file/training-materials/product-development-process/3.甘特图制作实训操作说明.docx'), '下载按钮应指向后端文件服务');
assert.ok(source.includes('function downloadBuiltInFile'), '下载应使用统一文件下载方法');
assert.doesNotMatch(source.match(/function downloadPracticeGuide\(\)[\s\S]*?\n}\n\nfunction downloadGanttTemplate/)?.[0] || '', /new Blob/, '操作说明不应再由前端临时生成文本文件');
});
test('甘特图绘制模板从后端内置资料目录下载', () => {
assert.ok(fs.existsSync(templatePath), '甘特图模板应随后端资源发布');
assert.ok(source.includes('/file/training-materials/product-development-process/3.项目进度管理表-项目进度甘特图模版.xlsx'), '模板按钮应指向后端文件服务');
assert.doesNotMatch(source.match(/function downloadGanttTemplate\(\)[\s\S]*?\n}\n\nfunction downloadBuiltInFile/)?.[0] || '', /XLSX\.writeFile/, '模板不应再由前端即时生成');
});

@ -1,35 +0,0 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const test = require("node:test");
const source = fs.readFileSync(path.join(__dirname, "../src/views/foundation/product-development-process.vue"), "utf8");
const apiSource = fs.readFileSync(path.join(__dirname, "../src/api/studentTrainingAnswer.js"), "utf8");
const requestSource = fs.readFileSync(path.join(__dirname, "../src/utils/request.js"), "utf8");
test("实训任务三第二步在提交前提示首个未填写字段", () => {
assert.match(source, /function getStepTwoValidationMessage\(\)[\s\S]*?请填写任务 \$\{row\.taskNo\} 的项目依赖关系/);
assert.match(source, /function getStepTwoValidationMessage\(\)[\s\S]*?请填写任务 \$\{row\.taskNo\} 的开始时间[\s\S]*?请填写任务 \$\{row\.taskNo\} 的完成时间/);
});
test("实训任务三第二步不完整时不请求后端校验", () => {
assert.match(source, /async function saveAndGoToStepThree\(\)\s*\{[\s\S]*?const validationMessage = getStepTwoValidationMessage\(\);[\s\S]*?if \(validationMessage\) \{[\s\S]*?msgWarning\?\.\(validationMessage\)[\s\S]*?return;[\s\S]*?\}\s*checking\.value = true;[\s\S]*?checkProductDevelopmentProcessStepTwo/);
});
test("实训任务三第二步可导出当前WBS排期列表", () => {
assert.match(source, /@click="exportScheduleList"[\s\S]*?导出列表/);
assert.match(source, /function exportScheduleList\(\)[\s\S]*?XLSX\.utils\.json_to_sheet[\s\S]*?XLSX\.writeFile[\s\S]*?产品开发WBS任务拆解与排期表\.xlsx/);
});
test("实训任务三第二步使用标准SS依赖示例并采用紧凑表格", () => {
assert.match(source, /placeholder="示例:开始-开始SS"/);
assert.match(source, /\.schedule-table \.dependency-input::placeholder \{ color: rgba\(168, 220, 233, \.68\); opacity: 1; \}/);
assert.match(source, /\.schedule-table \{ min-width: 1020px; \}/);
assert.match(source, /\.schedule-table \.stage-cell \{ width: 7%; \}/);
assert.match(source, /\.schedule-table \.task-no-cell \{ width: 7%; \}/);
});
test("实训任务三第二步只显示后端返回的校验文字", () => {
assert.match(apiSource, /function checkProductDevelopmentProcessStepTwo\(items\)[\s\S]*?skipErrorMessage: true/);
assert.match(requestSource, /message\.includes\("code 400"\)[\s\S]*?if \(!error\.config\?\.skipErrorMessage\)[\s\S]*?error\.message = serverMessage/);
});

@ -1,24 +0,0 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const test = require("node:test");
const sourcePath = path.resolve(__dirname, "../src/views/assessment/diagnosis.vue");
const materialsRoot = path.resolve(
__dirname,
"../../dianshang-houduan/src/main/resources/static/file/training-materials/product-diagnosis-iteration"
);
test("产品问题诊断第 1 步提供漏斗计算与可视化资料下载及图片预览", () => {
const source = fs.readFileSync(sourcePath, "utf8");
["23.实训操作说明1.docx", "23.Excel模版1.xlsx", "23.实训操作说明2.docx", "23.Excel模版2.xlsx"].forEach((name) => {
assert.ok(fs.existsSync(path.join(materialsRoot, name)), `后端应提供 ${name}`);
});
assert.match(source, /节点转化率计算/);
assert.match(source, /全链路转化漏斗可视化/);
assert.match(source, /:preview-src-list="\[funnelChart\.url\]"/);
assert.match(source, /诊断异常节点须从上表已填写渠道中选择/);
assert.match(source, /\/file\/training-materials\/product-diagnosis-iteration\/23\.Excel%E6%A8%A1%E7%89%881\.xlsx/);
assert.match(source, /\/file\/training-materials\/product-diagnosis-iteration\/23\.Excel%E6%A8%A1%E7%89%882\.xlsx/);
});

@ -1,24 +0,0 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const test = require("node:test");
const sourcePath = path.resolve(__dirname, "../src/views/assessment/diagnosis.vue");
const materialsRoot = path.resolve(
__dirname,
"../../dianshang-houduan/src/main/resources/static/file/training-materials/product-diagnosis-iteration"
);
test("产品问题诊断第 3 步提供 AB 测试资料下载与对比图预览", () => {
const source = fs.readFileSync(sourcePath, "utf8");
["23.实训操作说明3.docx", "23.Excel模版3.xlsx"].forEach((name) => {
assert.ok(fs.existsSync(path.join(materialsRoot, name)), `后端应提供 ${name}`);
});
assert.match(source, /找到根因后,还需验证“改进方案是否真的有效”/);
assert.match(source, /AB测试数据填报/);
assert.match(source, /下载实训操作说明/);
assert.match(source, /下载Excel模板/);
assert.match(source, /:preview-src-list="\[abChart\.url\]"/);
assert.match(source, /23\.Excel%E6%A8%A1%E7%89%883\.xlsx/);
});

@ -1,36 +0,0 @@
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.");

@ -1,24 +0,0 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const test = require("node:test");
const sourcePath = path.resolve(__dirname, "../src/views/product/differentiation.vue");
const templatePath = path.resolve(
__dirname,
"../../dianshang-houduan/src/main/resources/static/file/training-materials/product-value-positioning/12.产品价值主张画布模版.pptx"
);
test("产品价值主张画布模板由后端内置 PPTX 提供下载", () => {
const source = fs.readFileSync(sourcePath, "utf8");
assert.ok(fs.existsSync(templatePath), "后端应提供产品价值主张画布 PPTX 模板");
assert.match(
source,
/\/file\/training-materials\/product-value-positioning\/12\.产品价值主张画布模版\.pptx/
);
assert.match(
source,
/function downloadCanvasTemplate\(\) \{\s*downloadBuiltInFile\(VALUE_PROPOSITION_CANVAS_TEMPLATE_URL, "12\.产品价值主张画布模版\.pptx"\);\s*\}/
);
});

@ -1,21 +0,0 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const test = require("node:test");
const sourcePath = path.resolve(__dirname, "../src/views/channel/index.vue");
const guidePath = path.resolve(
__dirname,
"../../dianshang-houduan/src/main/resources/static/file/training-materials/purchase-demand-forecast/18.实训操作说明.docx"
);
test("采购需求预测实训操作说明由后端内置 Word 文件提供下载", () => {
const source = fs.readFileSync(sourcePath, "utf8");
assert.ok(fs.existsSync(guidePath), "后端应提供采购需求预测实训操作说明 Word 文件");
assert.match(source, /\/file\/training-materials\/purchase-demand-forecast\/18\.实训操作说明\.docx/);
assert.match(
source,
/function downloadPracticeGuide\(\) \{\s*downloadBuiltInFile\(PURCHASE_DEMAND_FORECAST_GUIDE_URL, "18\.实训操作说明\.docx"\);\s*\}/
);
});

@ -1,20 +0,0 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const test = require("node:test");
const sourcePath = path.resolve(__dirname, "../src/views/test/index.vue");
const guidePath = path.resolve(
__dirname,
"../../dianshang-houduan/src/main/resources/static/file/training-materials/release-channel-evaluation/21.实训操作说明.docx"
);
test("产品发布渠道评估第 3 步提供操作说明并展示加权得分校验规则", () => {
const source = fs.readFileSync(sourcePath, "utf8");
assert.ok(fs.existsSync(guidePath), "后端应提供产品发布渠道评估操作说明 Word 文件");
assert.match(source, /下载实训操作说明/);
assert.match(source, /step-three-notice/);
assert.match(source, /\/file\/training-materials\/release-channel-evaluation\/21\.实训操作说明\.docx/);
assert.match(source, /function downloadPracticeGuide\(\) \{\s*downloadBuiltInFile\(RELEASE_CHANNEL_GUIDE_URL, "21\.实训操作说明\.docx"\);\s*\}/);
});

@ -1,29 +0,0 @@
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const root = path.resolve(__dirname, "..");
const styles = fs.readFileSync(path.join(root, "src/assets/styles/student-training.scss"), "utf8");
assert.match(
styles,
/\.student-training-shell\s*\{[\s\S]*?--student-step-height:\s*42px;/,
"所有学生端实训页应使用更紧凑的步骤条高度"
);
assert.match(
styles,
/\.student-training-shell\s*>\s*main\s*\{[\s\S]*?padding:\s*12px\s*!important;/,
"所有学生端实训主卡应减少装饰性内边距"
);
assert.match(
styles,
/\.student-training-shell\s*>\s*main\s*>\s*\.task-card,[\s\S]*?padding:\s*10px\s+12px\s*!important;/,
"所有学生端工作台应为实操内容保留更多空间"
);
assert.match(
styles,
/\.student-training-shell\s+\.step-card,[\s\S]*?padding:\s*12px\s*!important;/,
"所有学生端步骤卡应使用紧凑内边距"
);
console.log("All student training pages use the compact layout density.");

@ -1,19 +0,0 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const test = require("node:test");
const source = fs.readFileSync(path.resolve(__dirname, "../src/views/demand/people.vue"), "utf8");
const materialDir = path.resolve(
__dirname,
"../../dianshang-houduan/src/main/resources/static/file/training-materials/target-user-profile"
);
test("目标用户画像第一步从后端下载操作说明与 Excel 模板", () => {
assert.ok(fs.existsSync(path.join(materialDir, "8.实训操作说明.docx")), "操作说明应随后端资源发布");
assert.ok(fs.existsSync(path.join(materialDir, "8.Excel计算与可视化模版.xlsx")), "Excel 模板应随后端资源发布");
assert.match(source, /\/file\/training-materials\/target-user-profile\/8\.实训操作说明\.docx/);
assert.match(source, /\/file\/training-materials\/target-user-profile\/8\.Excel计算与可视化模版\.xlsx/);
assert.match(source, /下载实训操作说明/);
assert.match(source, /下载 Excel 模板/);
});

@ -8,7 +8,6 @@ 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",
@ -29,7 +28,7 @@ assert(
"teacher training task editor should use the common upload API for case materials"
);
assert(
/async function handleMaterialChange/.test(teacherPage) && /taskForm\.materialUrl\s*=\s*normalizeMaterialUrl\(res\?\.(url|fileName)/.test(teacherPage),
/async function handleMaterialChange/.test(teacherPage) && /taskForm\.materialUrl\s*=\s*res\?\.(url|fileName)/.test(teacherPage),
"teacher case material upload should persist the returned materialUrl"
);
assert(
@ -40,31 +39,6 @@ assert(
/function handleMaterialDownload/.test(studentPage) && /暂无案例文档/.test(studentPage),
"student task material button should report empty state when no case material exists"
);
assert.match(
studentPage,
/function normalizeMaterialUrl\(rawUrl\)/,
"comprehensive training must normalize legacy localhost case-material URLs before download"
);
assert.match(
studentPage,
/return \{ name, url: normalizeMaterialUrl\(materialUrl\) \};/,
"comprehensive training must use the normalized case-material URL before download"
);
assert.match(
teacherPage,
/function normalizeMaterialUrl\(rawUrl\)/,
"teacher task editor must normalize legacy localhost case-material URLs before rendering"
);
assert.match(
teacherPage,
/const url = normalizeMaterialUrl\(materialUrl\);/,
"teacher task editor must use the normalized legacy URL before rendering"
);
assert.match(
teacherPage,
/taskForm\.materialUrl = normalizeMaterialUrl\(res\?\.url \|\| res\?\.fileName \|\| ""\);/,
"newly uploaded teacher case materials must store deployment-relative URLs"
);
assert(
/TrainingMaterialButton/.test(newProductSurveyPage),
"new product survey page should render the case material button"
@ -73,35 +47,6 @@ 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