You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
dianshang-qianduan/src/views/product/price.vue

1445 lines
44 KiB
Vue

2 months ago
<template>
<div class="price-page student-training-shell">
<main class="training-core">
<section class="task-header">
<p class="task-kicker">PRODUCT PRICING</p>
<h1>{{ taskTitle }}</h1>
</section>
<TrainingTaskBrief
:background="taskBackground"
:goals="taskGoals"
:requirements="taskRequirement"
:show-material="false"
/>
<section class="price-workbench">
<!-- legacy malformed navigation retained only for source compatibility
<nav class="step-switcher" aria-label="">
<button type="button" :class="{ active: currentStep === 1 }" @click="switchStep(1)">
<span>01</span>{{ labels.pricingTitle }}
</button>
<i></i>
<button type="button" :class="{ active: currentStep === 2 }" @click="switchStep(2)">
<span>02</span>{{ labels.competitorPricingTitle }}
</button>
<i></i>
<button type="button" :class="{ active: currentStep === 4 }" @click="switchStep(4)">
<span>04</span>{{ labels.psmTitle }}
</button>
</nav>
-->
<nav class="step-switcher" aria-label="">
<button type="button" :class="{ active: currentStep === 1 }" @click="switchStep(1)">
<span>01</span>{{ labels.pricingTitle }}
</button>
<i></i>
<button type="button" :class="{ active: currentStep === 2 }" @click="switchStep(2)">
<span>02</span>{{ labels.competitorPricingTitle }}
</button>
</nav>
<header class="workbench-head">
<div>
<p class="section-eyebrow">STEP {{ String(currentStep).padStart(2, "0") }}</p>
<h2>{{ currentStepTitle }}</h2>
</div>
<TrainingMaterialButton
:material-name="taskConfig?.materialName"
:material-url="taskConfig?.materialUrl"
/>
</header>
<template v-if="currentStep === 1">
<p class="intro-copy">{{ labels.formulaTip }}</p>
<section class="unit-cost-panel" :aria-label="labels.unitCost">
<div class="cost-panel-copy">
<span class="cost-dot"></span>
<div>
<p>{{ labels.unitCost }}</p>
<small>{{ labels.unitCostHelp }}</small>
</div>
</div>
<el-input
v-model="unitCost"
class="unit-cost-input"
inputmode="decimal"
:placeholder="labels.unitCostPlaceholder"
@input="clearValidation"
>
<template #append>{{ labels.yuan }}</template>
</el-input>
</section>
<section class="pricing-table-wrap">
<table class="pricing-table">
<thead>
<tr>
<th>{{ labels.serial }}</th>
<th>{{ labels.channel }}</th>
<th>{{ labels.markupRate }}</th>
<th>{{ labels.salePrice }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, index) in pricingRows" :key="row.id">
<td class="serial-cell">{{ String(index + 1).padStart(2, "0") }}</td>
<td class="channel-cell">{{ row.channel }}</td>
<td>
<el-input
v-model="row.markupRate"
class="markup-input"
inputmode="decimal"
:placeholder="ratePlaceholder(row)"
@input="clearValidation"
>
<template #append>%</template>
</el-input>
<small class="range-hint">{{ rateHint(row) }}</small>
</td>
<td class="sale-price-cell">
<span v-if="salePrice(row) !== null">{{ labels.yuanSign }} {{ formatMoney(salePrice(row)) }}</span>
<span v-else class="empty-price">{{ labels.waitForInput }}</span>
</td>
</tr>
</tbody>
</table>
</section>
<p v-if="validationMessage" class="validation-message">{{ validationMessage }}</p>
<footer class="workbench-footer">
<p>{{ labels.footerTip }}</p>
<div class="footer-actions">
<el-button plain :disabled="saving" @click="resetStepOne">{{ labels.clear }}</el-button>
<el-button type="primary" :loading="saving" @click="saveStepOne">{{ labels.saveStepOne }}</el-button>
</div>
</footer>
2 months ago
</template>
<template v-else-if="currentStep === 4">
<section class="psm-section">
<div class="psm-toolbar">
<p>{{ labels.psmIntro }}</p>
<div class="toolbar-actions">
<button type="button" class="primary-button" @click="addPsmRow">{{ labels.psmAddRow }}</button>
<button type="button" class="secondary-button" :disabled="!canRemovePsmRow" @click="removePsmRow">
{{ labels.psmRemoveRow }}
</button>
<button type="button" class="secondary-button" @click="exportPsmTable">{{ labels.psmExport }}</button>
</div>
</div>
<div class="table-scroll">
<table class="price-table psm-table">
<thead>
<tr>
<th>{{ labels.psmPriceRange }}</th>
<th>{{ labels.psmTooExpensive }}</th>
<th>{{ labels.psmTooCheap }}</th>
<th>{{ labels.psmExpensiveAcceptable }}</th>
<th>{{ labels.psmCheapValue }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in psmRows" :key="row.id">
<td>
<span v-if="row.isDefault">{{ row.priceRange }}</span>
<input v-else v-model="row.priceRange" type="text" :placeholder="labels.psmCustomRangePlaceholder" />
</td>
<td><input v-model="row.tooExpensive" type="number" min="0" max="100" step="0.01" :placeholder="labels.psmPercentagePlaceholder" /></td>
<td><input v-model="row.tooCheap" type="number" min="0" max="100" step="0.01" :placeholder="labels.psmPercentagePlaceholder" /></td>
<td><input v-model="row.expensiveAcceptable" type="number" min="0" max="100" step="0.01" :placeholder="labels.psmPercentagePlaceholder" /></td>
<td><input v-model="row.cheapValue" type="number" min="0" max="100" step="0.01" :placeholder="labels.psmPercentagePlaceholder" /></td>
</tr>
</tbody>
</table>
</div>
<section class="psm-point-section">
<h3>{{ labels.psmPricePointTitle }}</h3>
<div class="psm-point-grid">
<label>
<span>{{ labels.psmOptimalPrice }}</span>
<input v-model="psmPricePoints.optimalPrice" type="text" :placeholder="labels.psmPricePointPlaceholder" />
</label>
<label>
<span>{{ labels.psmAcceptablePrice }}</span>
<input v-model="psmPricePoints.acceptablePrice" type="text" :placeholder="labels.psmPricePointPlaceholder" />
</label>
<label>
<span>{{ labels.psmAcceptableRange }}</span>
<input v-model="psmPricePoints.acceptableRange" type="text" :placeholder="labels.psmPricePointPlaceholder" />
</label>
</div>
</section>
<section class="psm-chart-section">
<div class="psm-chart-head">
<div>
<h3>{{ labels.psmChartTitle }}</h3>
<p>{{ labels.psmChartHint }}</p>
</div>
<button type="button" class="primary-button" :disabled="uploadingPsmChart" @click="triggerPsmChartUpload">
{{ uploadingPsmChart ? labels.uploading : labels.psmUploadChart }}
</button>
</div>
<input ref="psmChartInput" class="file-picker" type="file" accept="image/*" @change="handlePsmChartUpload" />
<div v-if="psmChart.url" class="psm-chart-preview">
<el-image
:src="psmChart.url"
:preview-src-list="psmChartPreviewList"
fit="cover"
class="psm-chart-image"
preview-teleported
/>
<div>
<p>{{ psmChart.name }}</p>
<button type="button" class="text-button" @click="removePsmChart">{{ labels.psmChartRemove }}</button>
</div>
</div>
</section>
<div class="step-footer">
<button type="button" class="primary-button" :disabled="saving" @click="saveStepFour">
{{ saving ? labels.psmSaving : labels.psmSave }}
</button>
</div>
</section>
2 months ago
</template>
<template v-else>
<p class="intro-copy">{{ labels.competitorIntro }}</p>
<section class="competitor-section">
<div class="subsection-title">
<span>01</span>
<h3>{{ labels.competitorAnalysis }}</h3>
</div>
<div class="pricing-table-wrap">
<table class="pricing-table competitor-table">
<thead>
<tr>
<th>{{ labels.competitorProductType }}</th>
<th>{{ labels.competitorBrand }}</th>
<th>{{ labels.competitorEcommercePrice }}</th>
<th>{{ labels.competitorDifferentiator }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in competitorRows" :key="row.id">
<td>
<el-input v-model="row.productType" :placeholder="row.label" @input="clearValidation" />
</td>
<td>
<el-input v-model="row.brand" :placeholder="labels.brandPlaceholder" @input="clearValidation" />
</td>
<td>
<el-input v-model="row.ecommercePrice" inputmode="decimal" :placeholder="labels.pricePlaceholder" @input="clearValidation">
<template #append>{{ labels.yuan }}</template>
</el-input>
</td>
<td>
<el-input v-model="row.differentiator" type="textarea" :autosize="{ minRows: 2, maxRows: 4 }" :placeholder="labels.differentiatorPlaceholder" @input="clearValidation" />
</td>
</tr>
</tbody>
</table>
</div>
</section>
<section class="competitor-section">
<div class="subsection-title">
<span>02</span>
<h3>{{ labels.decisionTitle }}</h3>
</div>
<div class="pricing-table-wrap">
<table class="pricing-table decision-table">
<thead>
<tr>
<th>{{ labels.benchmarkCompetitor }}</th>
<th>{{ labels.targetPrice }}</th>
<th>{{ labels.grossMarginRate }}</th>
<th>{{ labels.competitorDifferentiator }}</th>
<th>{{ labels.pricingReason }}</th>
</tr>
</thead>
<tbody>
<tr>
<td><el-input v-model="pricingDecision.benchmarkCompetitor" :placeholder="labels.benchmarkPlaceholder" @input="clearValidation" /></td>
<td>
<el-input v-model="pricingDecision.targetPrice" inputmode="decimal" :placeholder="labels.pricePlaceholder" @input="clearValidation">
<template #append>{{ labels.yuan }}</template>
</el-input>
</td>
<td class="sale-price-cell">
<span v-if="grossMarginRate !== null">{{ formatMoney(grossMarginRate) }}%</span>
<span v-else class="empty-price">{{ labels.waitForInput }}</span>
</td>
<td><el-input v-model="pricingDecision.differentiator" type="textarea" :autosize="{ minRows: 2, maxRows: 4 }" @input="clearValidation" /></td>
<td><el-input v-model="pricingDecision.pricingReason" type="textarea" :autosize="{ minRows: 2, maxRows: 4 }" @input="clearValidation" /></td>
</tr>
</tbody>
</table>
</div>
</section>
<p v-if="validationMessage" class="validation-message">{{ validationMessage }}</p>
<footer class="workbench-footer">
<p>{{ labels.competitorFooterTip }}</p>
<div class="footer-actions">
<el-button plain :disabled="saving" @click="resetStepTwo">{{ labels.clearStepTwo }}</el-button>
<el-button type="primary" :loading="saving" @click="saveStepTwo">{{ labels.saveStepTwo }}</el-button>
</div>
</footer>
2 months ago
</template>
</section>
</main>
<TrainingAiSidebar
:task-key="TASK_KEY"
:has-saved-answer="hasSavedAnswer"
:progress-items="progressItems"
:study-tip="labels.studyTip"
:reference-title="labels.referenceTitle"
:reference-text="labels.referenceText"
/>
2 months ago
</div>
</template>
<script setup>
import { computed, getCurrentInstance, onMounted, reactive, ref } from "vue";
import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue";
import TrainingMaterialButton from "@/views/components/TrainingMaterialButton.vue";
import TrainingTaskBrief from "@/views/components/TrainingTaskBrief.vue";
import {
getStudentTrainingAnswer,
saveStudentTrainingAnswer,
uploadStudentTrainingFile,
} from "@/api/studentTrainingAnswer";
import { getTrainingTaskByKey } from "@/api/trainingTask";
import { parseTrainingArray } from "@/views/training/taskKeyMap";
import { isStudentDemo } from "@/utils/studentDemo";
const TASK_KEY = "product-pricing";
const { proxy } = getCurrentInstance();
const labels = {
psmTitle: "PSM价格敏感度测试",
psmIntro: "根据案例资料填写 PSM 累计占比,并补充价格点结论。",
psmAddRow: "增加一行",
psmRemoveRow: "删除一行",
psmExport: "导出",
psmPriceRange: "价格(元)",
psmTooExpensive: "太贵不买累计占比(%",
psmTooCheap: "太便宜不买累计占比(%",
psmExpensiveAcceptable: "偏高可接受累计占比(%",
psmCheapValue: "便宜划算累计占比(%",
psmCustomRangePlaceholder: "填写价格区间",
psmPercentagePlaceholder: "0-100",
psmPricePointTitle: "确定价格点",
psmOptimalPrice: "最优价格点",
psmAcceptablePrice: "可接受价格点",
psmAcceptableRange: "可接受价格范围",
psmPricePointPlaceholder: "学生手动填写",
psmChartTitle: "PSM价格数据可视化",
psmChartHint: "上传绘制完成的 PSM 价格数据折线图,支持点击缩略图预览。",
psmUploadChart: "上传PSM价格数据折线图",
psmChartUploadSuccess: "图表上传成功",
psmChartUploadError: "图表上传失败,请重试",
psmChartRemove: "移除图表",
psmSave: "保存PSM价格敏感度测试",
psmSaving: "保存中...",
psmSaveSuccess: "PSM价格敏感度测试已保存",
uploading: "上传中...",
pricingTitle: "\u6e20\u9053\u52a0\u6210\u5b9a\u4ef7",
formulaTip: "\u6838\u5fc3\u903b\u8f91\uff1a\u603b\u6210\u672c \u00d7\uff081 + \u52a0\u6210\u7387\uff09= \u6700\u7ec8\u552e\u4ef7\u3002\u8bf7\u4f9d\u636e\u6848\u4f8b\u8d44\u6599\uff0c\u4e3a\u4e0d\u540c\u6e20\u9053\u586b\u5199\u5408\u9002\u7684\u52a0\u6210\u7387\u3002",
unitCost: "\u5355\u53f0\u7efc\u5408\u6210\u672c\uff08\u5143\uff09",
unitCostHelp: "\u7531\u5b66\u751f\u4f9d\u636e\u6848\u4f8b\u8d44\u6599\u5728\u672c\u9875\u586b\u5199\uff0c\u4ec5\u7528\u4e8e\u672c\u9875\u81ea\u52a8\u8ba1\u7b97\u3002",
unitCostPlaceholder: "\u8bf7\u8f93\u5165\u5355\u53f0\u7efc\u5408\u6210\u672c",
yuan: "\u5143",
yuanSign: "\u00a5",
serial: "\u5e8f\u53f7",
channel: "\u6e20\u9053",
markupRate: "\u52a0\u6210\u7387\uff08%\uff09",
salePrice: "\u52a0\u6210\u552e\u4ef7\uff08\u5143\uff09",
rangePrefix: "\u5efa\u8bae\u8303\u56f4\uff1a",
waitForInput: "\u7b49\u5f85\u586b\u5199",
footerTip: "\u586b\u5199\u5355\u53f0\u7efc\u5408\u6210\u672c\u548c\u4e09\u4e2a\u6e20\u9053\u7684\u52a0\u6210\u7387\u540e\uff0c\u7cfb\u7edf\u5c06\u81ea\u52a8\u8ba1\u7b97\u52a0\u6210\u552e\u4ef7\u3002",
clear: "\u6e05\u7a7a\u586b\u5199",
save: "\u4fdd\u5b58\u672c\u6b65\u9aa4",
studyTip: "\u5148\u6838\u5bf9\u6848\u4f8b\u4e2d\u7684\u5355\u53f0\u7efc\u5408\u6210\u672c\uff0c\u518d\u7ed3\u5408\u5404\u6e20\u9053\u5b9a\u4f4d\u586b\u5199\u52a0\u6210\u7387\uff1b\u552e\u4ef7\u4f1a\u6309\u516c\u5f0f\u81ea\u52a8\u8ba1\u7b97\u3002",
referenceTitle: "\u5b9a\u4ef7\u6838\u5bf9\u63d0\u793a",
referenceText: "\u8bf7\u68c0\u67e5\u5355\u53f0\u7efc\u5408\u6210\u672c\u662f\u5426\u6765\u81ea\u6848\u4f8b\u8d44\u6599\uff0c\u5e76\u786e\u8ba4\u4e09\u4e2a\u6e20\u9053\u7684\u52a0\u6210\u7387\u5747\u5728\u9875\u9762\u7ed9\u51fa\u7684\u5efa\u8bae\u8303\u56f4\u5185\u3002",
saveSuccess: "\u672c\u6b65\u9aa4\u5df2\u4fdd\u5b58",
resetSuccess: "\u5df2\u6e05\u7a7a\u672c\u9875\u586b\u5199\u5185\u5bb9",
costRequired: "\u8bf7\u586b\u5199\u5927\u4e8e 0 \u7684\u5355\u53f0\u7efc\u5408\u6210\u672c\u3002",
rateRequired: "\u8bf7\u586b\u5199\u201c{channel}\u201d\u7684\u52a0\u6210\u7387\u3002",
rateRange: "\u201c{channel}\u201d\u7684\u52a0\u6210\u7387\u5e94\u5728 {min}%\u2013{max}% \u4e4b\u95f4\u3002",
competitorPricingTitle: "\u7ade\u54c1\u4ef7\u683c\u5e26\u4e0e\u5b9a\u4ef7",
competitorIntro: "\u8bf7\u57fa\u4e8e\u6848\u4f8b\u8d44\u6599\u5b8c\u6210 4 \u4e2a\u7ade\u54c1\u7684\u4ef7\u683c\u53ca\u5dee\u5f02\u5316\u5206\u6790\uff0c\u5e76\u9009\u62e9\u5bf9\u6807\u7ade\u54c1\u5236\u5b9a\u672c\u4ea7\u54c1\u5b9a\u4ef7\u3002",
competitorAnalysis: "\u7ade\u54c1\u5dee\u5f02\u5316\u4f18\u52bf\u53ca\u552e\u4ef7\u5206\u6790",
competitorProductType: "\u7ade\u54c1\u4ea7\u54c1\u7c7b\u578b",
competitorBrand: "\u54c1\u724c",
competitorEcommercePrice: "\u7535\u5546\u552e\u4ef7\uff08\u5143\uff09",
competitorDifferentiator: "\u5dee\u5f02\u5316\u4f18\u52bf",
brandPlaceholder: "\u8bf7\u586b\u5199\u54c1\u724c",
pricePlaceholder: "\u8bf7\u586b\u5199\u91d1\u989d",
differentiatorPlaceholder: "\u8bf7\u586b\u5199\u5dee\u5f02\u5316\u4f18\u52bf",
decisionTitle: "\u786e\u5b9a\u5bf9\u6807\u7ade\u54c1\u53ca\u5b9a\u4ef7",
benchmarkCompetitor: "\u5bf9\u6807\u7ade\u54c1",
benchmarkPlaceholder: "\u8bf7\u586b\u5199\u5bf9\u6807\u7ade\u54c1",
targetPrice: "\u5b9a\u4ef7\uff08\u5143\uff09",
grossMarginRate: "\u6bdb\u5229\u6da6\u7387",
pricingReason: "\u5b9a\u4ef7\u539f\u56e0\u5206\u6790",
competitorFooterTip: "\u7ade\u54c1\u4fe1\u606f\u548c\u5b9a\u4ef7\u539f\u56e0\u5747\u9700\u5b8c\u6574\u586b\u5199\uff1b\u6bdb\u5229\u6da6\u7387\u4f1a\u6839\u636e\u7b2c 1 \u6b65\u7684\u5355\u53f0\u7efc\u5408\u6210\u672c\u81ea\u52a8\u8ba1\u7b97\u3002",
saveStepOne: "\u4fdd\u5b58\u5e76\u8fdb\u5165\u7b2c 2 \u6b65",
clearStepTwo: "\u6e05\u7a7a\u7b2c 2 \u6b65",
saveStepTwo: "\u4fdd\u5b58\u7b2c 2 \u6b65",
stepOneSaveSuccess: "\u7b2c 1 \u6b65\u5df2\u4fdd\u5b58\uff0c\u5df2\u8fdb\u5165\u7b2c 2 \u6b65",
stepTwoSaveSuccess: "\u7b2c 2 \u6b65\u5df2\u4fdd\u5b58",
stepTwoResetSuccess: "\u5df2\u6e05\u7a7a\u7b2c 2 \u6b65\u586b\u5199\u5185\u5bb9",
costRequiredForStepTwo: "\u8bf7\u5148\u5728\u7b2c 1 \u6b65\u586b\u5199\u5927\u4e8e 0 \u7684\u5355\u53f0\u7efc\u5408\u6210\u672c\u3002",
competitorRequired: "\u8bf7\u5b8c\u6574\u586b\u5199\u7ade\u54c1 {index} \u7684\u4fe1\u606f\u3002",
competitorPriceRequired: "\u8bf7\u586b\u5199\u7ade\u54c1 {index} \u5927\u4e8e 0 \u7684\u7535\u5546\u552e\u4ef7\u3002",
benchmarkRequired: "\u8bf7\u586b\u5199\u5bf9\u6807\u7ade\u54c1\u3002",
targetPriceRequired: "\u8bf7\u586b\u5199\u5927\u4e8e 0 \u7684\u5b9a\u4ef7\u3002",
differentiatorRequired: "\u8bf7\u586b\u5199\u672c\u4ea7\u54c1\u7684\u5dee\u5f02\u5316\u4f18\u52bf\u3002",
pricingReasonRequired: "\u8bf7\u586b\u5199\u5b9a\u4ef7\u539f\u56e0\u5206\u6790\u3002",
};
const DEFAULT_ROWS = [
{ id: "dealer", channel: "\u5382\u5bb6\u7ed9\u7ecf\u9500\u5546", minRate: 15, maxRate: 25 },
{ id: "direct-retail", channel: "\u5382\u5bb6\u76f4\u63a5\u7535\u5546\u96f6\u552e", minRate: 40, maxRate: 60 },
{ id: "premium-brand", channel: "\u9ad8\u7aef\u5b9a\u4f4d\u54c1\u724c", minRate: 70, maxRate: 80 },
];
const taskConfig = ref(null);
const saving = ref(false);
const hasSavedAnswer = ref(false);
const unitCost = ref("");
const pricingRows = ref(createPricingRows());
const validationMessage = ref("");
const currentStep = ref(1);
const competitorRows = ref(createCompetitorRows());
const pricingDecision = ref(createPricingDecision());
const DEFAULT_PSM_PRICE_RANGES = [
{ id: "psm-under-100", priceRange: "≤100元" },
{ id: "psm-100-150", priceRange: "100~150元" },
{ id: "psm-150-200", priceRange: "150~200元" },
{ id: "psm-200-250", priceRange: "200~250元" },
{ id: "psm-250-300", priceRange: "250~300元" },
{ id: "psm-over-300", priceRange: "≥300元" },
];
let psmRowSeed = 0;
const psmRows = ref(createPsmRows());
const psmPricePoints = reactive({
2 months ago
optimalPrice: "",
acceptablePrice: "",
acceptableRange: "",
});
const psmChart = reactive({ url: "", name: "" });
const psmChartInput = ref(null);
const uploadingPsmChart = ref(false);
const psmChartPreviewList = computed(() => (psmChart.url ? [psmChart.url] : []));
const canRemovePsmRow = computed(() => psmRows.value.some((row) => !row.isDefault));
const currentStepTitle = computed(() => {
if (currentStep.value === 1) return labels.pricingTitle;
if (currentStep.value === 2) return labels.competitorPricingTitle;
return labels.psmTitle;
2 months ago
});
const taskTitle = computed(() => taskConfig.value?.taskName || "\u65b0\u4ea7\u54c1\u5b9a\u4ef7");
const taskBackground = computed(
() =>
taskConfig.value?.background ||
"\u57fa\u4e8e\u6848\u4f8b\u4e2d\u7684\u5355\u53f0\u7efc\u5408\u6210\u672c\uff0c\u7ed3\u5408\u6e20\u9053\u7c7b\u578b\u548c\u54c1\u724c\u5b9a\u4f4d\uff0c\u5b8c\u6210\u4e0d\u540c\u6e20\u9053\u7684\u52a0\u6210\u5b9a\u4ef7\u3002",
);
const taskRequirement = computed(
() =>
taskConfig.value?.taskRequirement ||
"\u586b\u5199\u5355\u53f0\u7efc\u5408\u6210\u672c\uff0c\u5e76\u4e3a\u4e09\u79cd\u6e20\u9053\u586b\u5199\u5efa\u8bae\u8303\u56f4\u5185\u7684\u52a0\u6210\u7387\uff0c\u7cfb\u7edf\u81ea\u52a8\u8ba1\u7b97\u52a0\u6210\u552e\u4ef7\u3002",
);
const taskGoals = computed(() =>
parseTrainingArray(taskConfig.value?.trainingGoal, [
"\u7406\u89e3\u6210\u672c\u52a0\u6210\u5b9a\u4ef7\u7684\u57fa\u672c\u903b\u8f91",
"\u533a\u5206\u4e0d\u540c\u6e20\u9053\u7684\u52a0\u6210\u7b56\u7565",
"\u5b8c\u6210\u4e09\u7c7b\u6e20\u9053\u7684\u552e\u4ef7\u8ba1\u7b97",
]),
);
const progressItems = computed(() => [{ text: "\u65b0\u4ea7\u54c1\u5b9a\u4ef7", status: "doing" }]);
const grossMarginRate = computed(() => {
const cost = parseNumber(unitCost.value);
const price = parseNumber(pricingDecision.value.targetPrice);
if (cost === null || cost <= 0 || price === null || price <= 0) return null;
return ((price - cost) / price) * 100;
});
function createPricingRows() {
return DEFAULT_ROWS.map((row) => ({ ...row, markupRate: "" }));
}
function createCompetitorRows() {
return Array.from({ length: 4 }, (_, index) => ({
id: `competitor-${index + 1}`,
label: `\u7ade\u54c1${index + 1}`,
productType: "",
brand: "",
ecommercePrice: "",
differentiator: "",
}));
}
function createPricingDecision() {
return {
benchmarkCompetitor: "",
targetPrice: "",
differentiator: "",
pricingReason: "",
};
}
function createPsmRow(row = {}) {
return {
id: row.id || `psm-custom-${++psmRowSeed}`,
priceRange: row.priceRange || "",
isDefault: Boolean(row.isDefault),
tooExpensive: row.tooExpensive ?? "",
tooCheap: row.tooCheap ?? "",
expensiveAcceptable: row.expensiveAcceptable ?? "",
cheapValue: row.cheapValue ?? "",
};
}
function createPsmRows(savedRows = []) {
const sourceRows = Array.isArray(savedRows) ? savedRows : [];
const defaultRows = DEFAULT_PSM_PRICE_RANGES.map((row) =>
createPsmRow({
...row,
...sourceRows.find((item) => item?.id === row.id),
isDefault: true,
})
);
const customRows = sourceRows
.filter((row) => row && !DEFAULT_PSM_PRICE_RANGES.some((item) => item.id === row.id))
.map((row) => createPsmRow({ ...row, isDefault: false }));
return [...defaultRows, ...customRows];
}
function addPsmRow() {
psmRows.value.push(createPsmRow());
}
function removePsmRow() {
for (let index = psmRows.value.length - 1; index >= 0; index -= 1) {
if (!psmRows.value[index].isDefault) {
psmRows.value.splice(index, 1);
return;
}
}
}
function buildStepFourProgress() {
return {
psmRows: psmRows.value.map((row) => ({ ...row })),
pricePoints: { ...psmPricePoints },
chart: { ...psmChart },
};
}
function applyStepFourProgress(progress) {
if (!progress || typeof progress !== "object") return;
psmRows.value = createPsmRows(progress.psmRows);
Object.assign(
psmPricePoints,
{ optimalPrice: "", acceptablePrice: "", acceptableRange: "" },
progress.pricePoints || {}
);
Object.assign(psmChart, { url: "", name: "" }, progress.chart || {});
}
function triggerPsmChartUpload() {
psmChartInput.value?.click();
}
async function handlePsmChartUpload(event) {
const file = event?.target?.files?.[0];
if (event?.target) event.target.value = "";
if (!file) return;
uploadingPsmChart.value = true;
try {
let url = "";
if (isStudentDemo()) {
url = URL.createObjectURL(file);
} else {
const formData = new FormData();
formData.append("file", file);
const response = await uploadStudentTrainingFile(formData);
url = response?.url || response?.data?.url || "";
}
if (!url) throw new Error(labels.psmChartUploadError);
Object.assign(psmChart, { url, name: file.name });
proxy?.$modal?.msgSuccess?.(labels.psmChartUploadSuccess);
} catch (error) {
proxy?.$modal?.msgError?.(error?.message || labels.psmChartUploadError);
} finally {
uploadingPsmChart.value = false;
}
}
function removePsmChart() {
Object.assign(psmChart, { url: "", name: "" });
}
function formatPsmCsvCell(value) {
return `"${String(value ?? "").replace(/"/g, '""')}"`;
}
function exportPsmTable() {
const rows = [
[
labels.psmPriceRange,
labels.psmTooExpensive,
labels.psmTooCheap,
labels.psmExpensiveAcceptable,
labels.psmCheapValue,
2 months ago
],
...psmRows.value.map((row) => [
row.priceRange,
row.tooExpensive,
row.tooCheap,
row.expensiveAcceptable,
row.cheapValue,
]),
[],
[labels.psmOptimalPrice, psmPricePoints.optimalPrice],
[labels.psmAcceptablePrice, psmPricePoints.acceptablePrice],
[labels.psmAcceptableRange, psmPricePoints.acceptableRange],
];
const blob = new Blob(
[`\ufeff${rows.map((row) => row.map(formatPsmCsvCell).join(",")).join("\n")}`],
{ type: "text/csv;charset=utf-8;" }
);
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "PSM价格敏感度测试.csv";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
async function saveStepFour() {
if (saving.value) return;
saving.value = true;
try {
await saveStudentTrainingAnswer(TASK_KEY, {
saveAction: "SAVE",
currentStep: 4,
step4Answer: JSON.stringify(buildStepFourProgress()),
});
hasSavedAnswer.value = true;
proxy?.$modal?.msgSuccess?.(labels.psmSaveSuccess);
} finally {
saving.value = false;
}
}
function parseNumber(value) {
if (value === null || value === undefined || String(value).trim() === "") {
return null;
}
const normalized = String(value).trim().replace(/[%\uff0c,]/g, "");
const number = Number(normalized);
return Number.isFinite(number) ? number : null;
}
function salePrice(row) {
const cost = parseNumber(unitCost.value);
const rate = parseNumber(row.markupRate);
if (cost === null || cost < 0 || rate === null || rate < 0) {
return null;
}
return cost * (1 + rate / 100);
}
function formatMoney(value) {
return Number(value).toFixed(2);
}
function ratePlaceholder(row) {
return `${row.minRate}%-${row.maxRate}%`;
}
function rateHint(row) {
return `${labels.rangePrefix}${row.minRate}%-${row.maxRate}%`;
}
function clearValidation() {
validationMessage.value = "";
}
function interpolate(template, values) {
return Object.entries(values).reduce(
(result, [key, value]) => result.replace(`{${key}}`, value),
template,
);
}
function validateStepOne() {
const cost = parseNumber(unitCost.value);
if (cost === null || cost <= 0) {
return labels.costRequired;
}
for (const row of pricingRows.value) {
const rate = parseNumber(row.markupRate);
if (rate === null) {
return interpolate(labels.rateRequired, { channel: row.channel });
}
if (rate < row.minRate || rate > row.maxRate) {
return interpolate(labels.rateRange, {
channel: row.channel,
min: row.minRate,
max: row.maxRate,
});
}
}
return "";
}
function buildStepOneProgress() {
return {
version: 1,
unitCost: unitCost.value,
pricingRows: pricingRows.value.map((row) => ({
id: row.id,
markupRate: row.markupRate,
})),
updatedAt: new Date().toISOString(),
};
}
function applyStepOneProgress(progress) {
if (!progress || typeof progress !== "object") {
return;
}
unitCost.value = progress.unitCost ?? "";
const savedRows = new Map(
Array.isArray(progress.pricingRows)
? progress.pricingRows.map((row) => [row?.id, row])
: [],
);
pricingRows.value = DEFAULT_ROWS.map((row) => ({
...row,
markupRate: savedRows.get(row.id)?.markupRate ?? "",
}));
}
function buildStepTwoProgress() {
return {
version: 1,
competitorRows: competitorRows.value.map((row) => ({
id: row.id,
productType: row.productType,
brand: row.brand,
ecommercePrice: row.ecommercePrice,
differentiator: row.differentiator,
})),
pricingDecision: { ...pricingDecision.value },
updatedAt: new Date().toISOString(),
};
}
function applyStepTwoProgress(progress) {
if (!progress || typeof progress !== "object") {
return;
}
const savedRows = new Map(
Array.isArray(progress.competitorRows)
? progress.competitorRows.map((row) => [row?.id, row])
: [],
);
competitorRows.value = createCompetitorRows().map((row) => {
const saved = savedRows.get(row.id) || {};
return {
...row,
productType: saved.productType ?? "",
brand: saved.brand ?? "",
ecommercePrice: saved.ecommercePrice ?? "",
differentiator: saved.differentiator ?? "",
};
2 months ago
});
pricingDecision.value = {
...createPricingDecision(),
...(progress.pricingDecision || {}),
2 months ago
};
}
function isBlank(value) {
return !String(value ?? "").trim();
}
function validateStepTwo() {
const cost = parseNumber(unitCost.value);
if (cost === null || cost <= 0) {
return labels.costRequiredForStepTwo;
}
for (const [index, row] of competitorRows.value.entries()) {
if (isBlank(row.productType) || isBlank(row.brand) || isBlank(row.differentiator)) {
return interpolate(labels.competitorRequired, { index: index + 1 });
}
const ecommercePrice = parseNumber(row.ecommercePrice);
if (ecommercePrice === null || ecommercePrice <= 0) {
return interpolate(labels.competitorPriceRequired, { index: index + 1 });
}
}
if (isBlank(pricingDecision.value.benchmarkCompetitor)) {
return labels.benchmarkRequired;
}
const targetPrice = parseNumber(pricingDecision.value.targetPrice);
if (targetPrice === null || targetPrice <= 0) {
return labels.targetPriceRequired;
}
if (isBlank(pricingDecision.value.differentiator)) {
return labels.differentiatorRequired;
}
if (isBlank(pricingDecision.value.pricingReason)) {
return labels.pricingReasonRequired;
}
return "";
}
function parseSavedJson(raw) {
if (!raw) {
return null;
}
try {
return typeof raw === "string" ? JSON.parse(raw) : raw;
} catch (error) {
return null;
}
}
function switchStep(step) {
currentStep.value = step;
clearValidation();
}
async function saveStepOne() {
if (saving.value) {
return;
}
const message = validateStepOne();
validationMessage.value = message;
if (message) {
proxy?.$modal?.msgWarning?.(message);
return;
}
saving.value = true;
try {
await saveStudentTrainingAnswer(TASK_KEY, {
saveAction: "SAVE",
currentStep: 2,
step1Answer: JSON.stringify(buildStepOneProgress()),
2 months ago
});
hasSavedAnswer.value = true;
currentStep.value = 2;
proxy?.$modal?.msgSuccess?.(labels.stepOneSaveSuccess);
} finally {
saving.value = false;
}
}
async function resetStepOne() {
if (saving.value) {
return;
}
unitCost.value = "";
pricingRows.value = createPricingRows();
validationMessage.value = "";
saving.value = true;
try {
await saveStudentTrainingAnswer(TASK_KEY, {
saveAction: "RESET",
currentStep: 1,
step1Answer: JSON.stringify(buildStepOneProgress()),
});
hasSavedAnswer.value = true;
proxy?.$modal?.msgSuccess?.(labels.resetSuccess);
} finally {
saving.value = false;
}
}
async function saveStepTwo() {
if (saving.value) {
return;
}
const message = validateStepTwo();
validationMessage.value = message;
if (message) {
proxy?.$modal?.msgWarning?.(message);
return;
}
saving.value = true;
try {
await saveStudentTrainingAnswer(TASK_KEY, {
saveAction: "SAVE",
currentStep: 2,
step2Answer: JSON.stringify(buildStepTwoProgress()),
2 months ago
});
hasSavedAnswer.value = true;
proxy?.$modal?.msgSuccess?.(labels.stepTwoSaveSuccess);
} finally {
saving.value = false;
}
}
async function resetStepTwo() {
if (saving.value) {
return;
}
competitorRows.value = createCompetitorRows();
pricingDecision.value = createPricingDecision();
validationMessage.value = "";
saving.value = true;
try {
await saveStudentTrainingAnswer(TASK_KEY, {
saveAction: "SAVE",
currentStep: 2,
step2Answer: JSON.stringify(buildStepTwoProgress()),
2 months ago
});
hasSavedAnswer.value = true;
proxy?.$modal?.msgSuccess?.(labels.stepTwoResetSuccess);
} finally {
saving.value = false;
}
}
async function loadTaskConfig() {
try {
const response = await getTrainingTaskByKey(TASK_KEY);
taskConfig.value = response?.data || response || null;
} catch (error) {
taskConfig.value = null;
}
}
async function loadSavedProgress() {
try {
const response = await getStudentTrainingAnswer(TASK_KEY);
const answer = response?.data || response || {};
const stepOneProgress = parseSavedJson(answer?.step1Answer);
const stepTwoProgress = parseSavedJson(answer?.step2Answer);
const stepFourProgress = parseSavedJson(answer?.step4Answer);
hasSavedAnswer.value = Boolean(stepOneProgress || stepTwoProgress || stepFourProgress);
if (stepOneProgress) {
applyStepOneProgress(stepOneProgress);
}
if (stepTwoProgress) {
applyStepTwoProgress(stepTwoProgress);
currentStep.value = 2;
}
if (stepFourProgress) {
applyStepFourProgress(stepFourProgress);
currentStep.value = 4;
}
} catch (error) {
hasSavedAnswer.value = false;
}
}
onMounted(async () => {
await Promise.all([loadTaskConfig(), loadSavedProgress()]);
2 months ago
});
</script>
<style scoped lang="scss">
.price-page {
--shell-bg: #061923;
--surface: rgba(8, 35, 48, 0.9);
--surface-soft: rgba(13, 51, 67, 0.7);
--line: rgba(91, 197, 231, 0.25);
--line-strong: rgba(87, 211, 247, 0.55);
--ink: #edfaff;
--muted: #a3c4d0;
--accent: #24c6f2;
--accent-deep: #1184bb;
display: grid;
grid-template-columns: minmax(0, 1fr) 330px;
gap: 24px;
min-height: 100%;
padding: 26px;
color: var(--ink);
background:
radial-gradient(circle at 10% 0%, rgba(24, 164, 213, 0.18), transparent 31rem),
linear-gradient(135deg, #061923 0%, #082633 46%, #061923 100%);
.training-core {
min-width: 0;
}
.task-header {
margin-bottom: 18px;
.task-kicker,
h1 {
margin: 0;
}
.task-kicker,
.section-eyebrow {
color: #66d9fb;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.16em;
}
h1 {
margin-top: 6px;
font-size: 30px;
line-height: 1.25;
}
}
.price-workbench {
overflow: hidden;
margin-top: 18px;
border: 1px solid var(--line-strong);
border-radius: 18px;
background: var(--surface);
box-shadow: 0 20px 45px rgba(0, 0, 0, 0.2);
}
.step-switcher {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
padding: 18px 26px 0;
button {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
color: var(--muted);
cursor: pointer;
border: 1px solid var(--line);
border-radius: 999px;
background: transparent;
}
button.active {
color: #e9fbff;
border-color: var(--line-strong);
background: rgba(20, 149, 190, 0.25);
}
span {
display: grid;
width: 20px;
height: 20px;
place-items: center;
color: #7de2ff;
font-size: 11px;
font-weight: 700;
background: rgba(117, 226, 255, 0.18);
border-radius: 50%;
}
i {
color: #55d3f8;
font-size: 18px;
font-style: normal;
}
}
.workbench-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 24px 26px 18px;
border-bottom: 1px solid var(--line);
h2 {
margin: 4px 0 0;
font-size: 24px;
}
}
.intro-copy {
margin: 20px 26px;
color: var(--muted);
line-height: 1.75;
}
.unit-cost-panel {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
margin: 0 26px 22px;
padding: 18px 20px;
border: 1px solid var(--line);
border-radius: 14px;
background: var(--surface-soft);
}
.cost-panel-copy {
display: flex;
align-items: flex-start;
gap: 12px;
p {
margin: 0 0 4px;
font-size: 16px;
font-weight: 700;
}
small {
color: var(--muted);
line-height: 1.55;
}
}
.cost-dot {
width: 10px;
height: 10px;
margin-top: 7px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 12px var(--accent);
}
.unit-cost-input {
width: 270px;
}
.pricing-table-wrap {
overflow-x: auto;
margin: 0 26px;
border: 1px solid var(--line);
border-radius: 14px;
}
.pricing-table {
width: 100%;
min-width: 720px;
border-collapse: collapse;
th,
td {
padding: 16px;
border-bottom: 1px solid var(--line);
text-align: left;
}
th {
color: #ddf8ff;
font-size: 14px;
background: rgba(25, 122, 157, 0.46);
}
tbody tr:last-child td {
border-bottom: 0;
}
th:first-child,
td:first-child {
width: 84px;
text-align: center;
}
}
.competitor-section {
margin-bottom: 24px;
}
.subsection-title {
display: flex;
align-items: center;
gap: 10px;
margin: 0 26px 12px;
span {
display: grid;
width: 22px;
height: 22px;
place-items: center;
color: #7de2ff;
font-size: 12px;
font-weight: 700;
background: rgba(117, 226, 255, 0.18);
border-radius: 50%;
}
h3 {
margin: 0;
font-size: 16px;
}
}
.competitor-table {
min-width: 900px;
}
.decision-table {
min-width: 1160px;
}
.competitor-table th:first-child,
.competitor-table td:first-child,
.decision-table th:first-child,
.decision-table td:first-child {
width: auto;
text-align: left;
}
.competitor-table td,
.decision-table td {
vertical-align: top;
}
.serial-cell {
color: #7de2ff;
font-weight: 700;
}
.channel-cell {
color: #f2fcff;
font-weight: 600;
}
.markup-input {
width: 190px;
2 months ago
}
.range-hint {
display: block;
margin-top: 6px;
color: var(--muted);
font-size: 12px;
}
.sale-price-cell {
color: #7de2ff;
font-size: 17px;
font-weight: 700;
}
.empty-price {
color: #7998a3;
font-size: 14px;
font-weight: 400;
}
.validation-message {
margin: 14px 26px 0;
color: #ff9e9e;
line-height: 1.5;
}
.workbench-footer {
2 months ago
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
margin-top: 22px;
padding: 18px 26px;
border-top: 1px solid var(--line);
background: rgba(2, 19, 28, 0.42);
p {
margin: 0;
color: var(--muted);
font-size: 13px;
line-height: 1.65;
2 months ago
}
}
.footer-actions {
display: flex;
flex: none;
gap: 10px;
}
:deep(.el-input__wrapper) {
background: rgba(2, 19, 28, 0.7);
box-shadow: 0 0 0 1px rgba(94, 213, 245, 0.3) inset;
}
:deep(.el-input__inner) {
color: #fff;
}
:deep(.el-textarea__inner) {
color: #fff;
background: rgba(2, 19, 28, 0.7);
box-shadow: 0 0 0 1px rgba(94, 213, 245, 0.3) inset;
}
:deep(.el-input-group__append) {
color: #91dff5;
border-left-color: rgba(94, 213, 245, 0.3);
background: rgba(16, 98, 130, 0.4);
box-shadow: none;
}
@media (max-width: 1200px) {
grid-template-columns: 1fr;
}
@media (max-width: 640px) {
padding: 14px;
.workbench-head,
.unit-cost-panel,
.workbench-footer {
align-items: stretch;
flex-direction: column;
}
.unit-cost-input {
width: 100%;
}
.footer-actions {
justify-content: flex-end;
}
}
}
.psm-section {
display: grid;
gap: 20px;
}
.psm-toolbar,
.psm-chart-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.psm-toolbar p,
.psm-chart-head p {
margin: 0;
color: #5e6b7b;
line-height: 1.7;
}
.toolbar-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 10px;
}
.psm-table input,
.psm-point-grid input {
box-sizing: border-box;
width: 100%;
padding: 8px 10px;
color: #243447;
background: #fff;
border: 1px solid #d8e1eb;
border-radius: 6px;
}
.psm-table td {
min-width: 170px;
}
.psm-point-section,
.psm-chart-section {
padding: 20px;
background: #fbfdff;
border: 1px solid #dbe5ef;
border-radius: 12px;
}
.psm-point-section h3,
.psm-chart-section h3 {
margin: 0 0 14px;
color: #20344b;
font-size: 17px;
}
.psm-point-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
}
.psm-point-grid label {
display: grid;
gap: 8px;
color: #45566a;
font-weight: 600;
}
.file-picker {
display: none;
}
.psm-chart-preview {
display: flex;
align-items: center;
gap: 14px;
padding: 12px;
margin-top: 16px;
background: #fff;
border: 1px solid #dbe5ef;
border-radius: 10px;
}
.psm-chart-image {
width: 148px;
height: 92px;
overflow: hidden;
border-radius: 8px;
cursor: zoom-in;
}
.psm-chart-preview p {
margin: 0 0 7px;
color: #45566a;
word-break: break-all;
}
.text-button {
padding: 0;
color: #1687c6;
cursor: pointer;
background: transparent;
border: 0;
}
@media (max-width: 768px) {
.psm-toolbar,
.psm-chart-head {
flex-direction: column;
}
.toolbar-actions {
justify-content: flex-start;
}
.psm-point-grid {
grid-template-columns: 1fr;
}
}
</style>