fix: 完善学生实训表单校验与可行性论证

master
陈沅 3 days ago
parent 01ca54cda2
commit e1ad967b1b

@ -50,16 +50,18 @@
<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 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 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>
<section class="form-section">
<div class="section-title"><span>06</span><div><h3>风险识别与评估</h3><p>识别项目主要风险并明确影响程度及可执行的应对措施</p></div></div>
<div class="section-title"><span>07</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>

@ -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="image.url" :alt="`${step2Form.productName || '产品'}图片 ${index + 1}`" />
<img :src="resolveUploadedImageUrl(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,6 +272,7 @@ 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";
@ -582,8 +583,24 @@ 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 = url;
imagePreviewUrl.value = resolveUploadedImageUrl(url);
imagePreviewOpen.value = true;
}

@ -331,6 +331,16 @@ 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 };
}
@ -419,6 +429,11 @@ 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);

@ -0,0 +1,13 @@
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'), '单位总成本、单位毛利和毛利率应为只读计算结果');
});

@ -0,0 +1,16 @@
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\}`/);
});

@ -0,0 +1,15 @@
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");
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/);
});
Loading…
Cancel
Save