新增SPC Vue控制图项目
This commit is contained in:
3
spc-vue/.gitignore
vendored
Normal file
3
spc-vue/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
*.local
|
||||||
31
spc-vue/README.md
Normal file
31
spc-vue/README.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# SPC 控制图 Vue 验证项目
|
||||||
|
|
||||||
|
这个目录是一个独立的 Vue 3 + Vite + ECharts 项目,用于验证 `Cmd_Spc` 的 WASM 计算结果。每一种控制图都拆成独立路由,方便后续把单个页面或组件集成到其他项目。
|
||||||
|
|
||||||
|
## 本地运行
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd spc-vue
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
开发环境会通过 Vite 插件把主项目的 `../public/wasm` 映射到 `/wasm`。执行 `npm run build` 时会自动把 `smart_math.js` 和 `smart_math.wasm` 复制到 `dist/wasm`。
|
||||||
|
|
||||||
|
## 页面路由
|
||||||
|
|
||||||
|
- `/overview`:SPC 总览和关键指标
|
||||||
|
- `/xbar-r`:Xbar 控制图,控制限来自 XR 结果
|
||||||
|
- `/r-chart`:R 极差控制图
|
||||||
|
- `/xbar-s`:Xbar 控制图,控制限来自 XS 结果
|
||||||
|
- `/s-chart`:S 标准差控制图
|
||||||
|
- `/cpk`:过程能力与直方图
|
||||||
|
|
||||||
|
## 接口校验重点
|
||||||
|
|
||||||
|
`KinematicsWebAPI::func` 会把业务返回统一包装成顶层 `success: true`,所以页面不会只判断顶层 `success`。SPC 调用成功必须同时满足:
|
||||||
|
|
||||||
|
- 顶层 `success !== false`
|
||||||
|
- `res_data` 存在
|
||||||
|
- `res_data.error` 不存在
|
||||||
|
- `res_data.XR`、`res_data.XS`、`res_data.Cpk` 都存在
|
||||||
13
spc-vue/index.html
Normal file
13
spc-vue/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="icon" href="data:," />
|
||||||
|
<title>SPC 控制图 Vue 验证项目</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
2182
spc-vue/package-lock.json
generated
Normal file
2182
spc-vue/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
spc-vue/package.json
Normal file
23
spc-vue/package.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "spc-control-chart-vue",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host 0.0.0.0",
|
||||||
|
"build": "vue-tsc --noEmit && vite build",
|
||||||
|
"preview": "vite preview --host 0.0.0.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"echarts": "^5.5.1",
|
||||||
|
"vue": "^3.4.38",
|
||||||
|
"vue-router": "^4.4.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.1.2",
|
||||||
|
"playwright": "^1.61.1",
|
||||||
|
"typescript": "^5.5.4",
|
||||||
|
"vite": "^5.4.2",
|
||||||
|
"vue-tsc": "^2.0.29"
|
||||||
|
}
|
||||||
|
}
|
||||||
39
spc-vue/src/App.vue
Normal file
39
spc-vue/src/App.vue
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted } from "vue";
|
||||||
|
import { RouterLink, RouterView } from "vue-router";
|
||||||
|
import SpcInputPanel from "./components/SpcInputPanel.vue";
|
||||||
|
import { useSpc } from "./composables/useSpc";
|
||||||
|
import { routes } from "./router";
|
||||||
|
|
||||||
|
const { result, runSpc } = useSpc();
|
||||||
|
const navRoutes = computed(() => routes.filter((route) => route.path !== "/" && route.meta?.title));
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!result.value) {
|
||||||
|
void runSpc();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="app-shell">
|
||||||
|
<header class="topbar">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">SPC Vue</p>
|
||||||
|
<h1>SPC 控制图验证</h1>
|
||||||
|
</div>
|
||||||
|
<nav class="nav-tabs" aria-label="控制图页面">
|
||||||
|
<RouterLink v-for="route in navRoutes" :key="route.path" :to="route.path">
|
||||||
|
{{ route.meta?.title }}
|
||||||
|
</RouterLink>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="workspace">
|
||||||
|
<SpcInputPanel />
|
||||||
|
<section class="content-pane">
|
||||||
|
<RouterView />
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
58
spc-vue/src/components/EChartPanel.vue
Normal file
58
spc-vue/src/components/EChartPanel.vue
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import * as echarts from "echarts";
|
||||||
|
import type { EChartsOption } from "echarts";
|
||||||
|
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
title: string;
|
||||||
|
eyebrow: string;
|
||||||
|
description: string;
|
||||||
|
option: EChartsOption | null;
|
||||||
|
emptyText?: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const chartElement = ref<HTMLDivElement | null>(null);
|
||||||
|
let chart: echarts.ECharts | null = null;
|
||||||
|
let resizeObserver: ResizeObserver | null = null;
|
||||||
|
|
||||||
|
// 初始化图表实例,并监听容器尺寸变化保持 ECharts 自适应。
|
||||||
|
function ensureChart() {
|
||||||
|
if (!chartElement.value || chart) return;
|
||||||
|
chart = echarts.init(chartElement.value);
|
||||||
|
resizeObserver = new ResizeObserver(() => chart?.resize());
|
||||||
|
resizeObserver.observe(chartElement.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按当前 option 刷新图表,空数据时释放实例避免显示旧图。
|
||||||
|
function renderChart() {
|
||||||
|
if (!props.option) {
|
||||||
|
chart?.dispose();
|
||||||
|
chart = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ensureChart();
|
||||||
|
chart?.setOption(props.option, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(renderChart);
|
||||||
|
watch(() => props.option, renderChart, { deep: true });
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
resizeObserver?.disconnect();
|
||||||
|
chart?.dispose();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="chart-card">
|
||||||
|
<header class="chart-card__header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">{{ eyebrow }}</p>
|
||||||
|
<h2>{{ title }}</h2>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div v-if="option" ref="chartElement" class="chart-card__canvas" />
|
||||||
|
<div v-else class="chart-card__empty">{{ emptyText || "等待计算" }}</div>
|
||||||
|
<p class="chart-card__description">{{ description }}</p>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
12
spc-vue/src/components/JsonPreview.vue
Normal file
12
spc-vue/src/components/JsonPreview.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
value: unknown;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<details class="json-preview">
|
||||||
|
<summary>查看原始接口响应</summary>
|
||||||
|
<pre>{{ JSON.stringify(value || {}, null, 2) }}</pre>
|
||||||
|
</details>
|
||||||
|
</template>
|
||||||
14
spc-vue/src/components/MetricStrip.vue
Normal file
14
spc-vue/src/components/MetricStrip.vue
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
metrics: Array<{ label: string; value: string | number; tone?: "ok" | "warn" | "neutral" }>;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="metric-strip">
|
||||||
|
<div v-for="metric in metrics" :key="metric.label" class="metric-tile">
|
||||||
|
<span>{{ metric.label }}</span>
|
||||||
|
<strong :class="metric.tone || 'neutral'">{{ metric.value }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
54
spc-vue/src/components/SpcInputPanel.vue
Normal file
54
spc-vue/src/components/SpcInputPanel.vue
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useSpc } from "../composables/useSpc";
|
||||||
|
|
||||||
|
const { form, parsedCount, loading, statusText, errorMessage, formatValues, loadSample, runSpc } = useSpc();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<aside class="input-panel" aria-label="SPC 输入">
|
||||||
|
<div class="panel-title-row">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Cmd_Spc</p>
|
||||||
|
<h2>计算输入</h2>
|
||||||
|
</div>
|
||||||
|
<span class="status-pill">{{ statusText }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-grid">
|
||||||
|
<label>
|
||||||
|
<span>n 子组容量</span>
|
||||||
|
<input v-model.number="form.n" type="number" min="2" step="1" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>k 子组数</span>
|
||||||
|
<input v-model.number="form.k" type="number" min="1" step="1" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>USL 上规格限</span>
|
||||||
|
<input v-model.number="form.usl" type="number" step="0.001" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>LSL 下规格限</span>
|
||||||
|
<input v-model.number="form.lsl" type="number" step="0.001" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="data-field">
|
||||||
|
<span>测量数据 x</span>
|
||||||
|
<textarea v-model="form.valuesText" spellcheck="false" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="input-panel__footer">
|
||||||
|
<span class="count-text">当前 {{ parsedCount }} 个数据</span>
|
||||||
|
<div class="button-row">
|
||||||
|
<button type="button" class="secondary-button" @click="loadSample">载入样例</button>
|
||||||
|
<button type="button" class="secondary-button" @click="formatValues">格式化</button>
|
||||||
|
<button type="button" class="primary-button" :disabled="loading" @click="runSpc">
|
||||||
|
{{ loading ? "计算中" : "调用接口" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="errorMessage" class="error-message" role="alert">{{ errorMessage }}</p>
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
133
spc-vue/src/composables/useSpc.ts
Normal file
133
spc-vue/src/composables/useSpc.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import { computed, reactive, readonly, ref } from "vue";
|
||||||
|
import { sampleSpcInput } from "../data/sampleSpc";
|
||||||
|
import { callSpc } from "../services/wasmSpcClient";
|
||||||
|
import type { SpcInput, SpcResult, WebApiResponse } from "../types/spc";
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
n: sampleSpcInput.n,
|
||||||
|
k: sampleSpcInput.k,
|
||||||
|
usl: sampleSpcInput.usl,
|
||||||
|
lsl: sampleSpcInput.lsl,
|
||||||
|
valuesText: sampleSpcInput.x.join(", ")
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = ref<SpcResult | null>(null);
|
||||||
|
const rawResponse = ref<WebApiResponse | null>(null);
|
||||||
|
const errorMessage = ref("");
|
||||||
|
const statusText = ref("未执行");
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
// 将文本框中的数字解析成数组,支持逗号、空格和换行分隔。
|
||||||
|
function parseValues(text: string): number[] {
|
||||||
|
const values = text
|
||||||
|
.split(/[\s,,;;]+/)
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(Number);
|
||||||
|
|
||||||
|
if (values.some((value) => !Number.isFinite(value))) {
|
||||||
|
throw new Error("测量数据中存在非数字内容");
|
||||||
|
}
|
||||||
|
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从表单生成 SPC 输入,并校验 n、k、规格限和数据量是否匹配。
|
||||||
|
function readInput(): SpcInput {
|
||||||
|
const input: SpcInput = {
|
||||||
|
n: Number(form.n),
|
||||||
|
k: Number(form.k),
|
||||||
|
usl: Number(form.usl),
|
||||||
|
lsl: Number(form.lsl),
|
||||||
|
x: parseValues(form.valuesText)
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!Number.isInteger(input.n) || input.n < 2) {
|
||||||
|
throw new Error("n 必须是大于等于 2 的整数");
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(input.k) || input.k < 1) {
|
||||||
|
throw new Error("k 必须是大于等于 1 的整数");
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(input.usl) || !Number.isFinite(input.lsl) || input.usl <= input.lsl) {
|
||||||
|
throw new Error("USL 必须大于 LSL");
|
||||||
|
}
|
||||||
|
if (input.x.length !== input.n * input.k) {
|
||||||
|
throw new Error(`数据量应为 n*k=${input.n * input.k},当前为 ${input.x.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化输入数据,按子组容量 n 分行,方便核对原始数据。
|
||||||
|
function formatValues() {
|
||||||
|
const values = parseValues(form.valuesText);
|
||||||
|
const lines: string[] = [];
|
||||||
|
for (let index = 0; index < values.length; index += Number(form.n) || 5) {
|
||||||
|
lines.push(values.slice(index, index + (Number(form.n) || 5)).join(", "));
|
||||||
|
}
|
||||||
|
form.valuesText = lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 恢复内置样例数据。
|
||||||
|
function loadSample() {
|
||||||
|
form.n = sampleSpcInput.n;
|
||||||
|
form.k = sampleSpcInput.k;
|
||||||
|
form.usl = sampleSpcInput.usl;
|
||||||
|
form.lsl = sampleSpcInput.lsl;
|
||||||
|
form.valuesText = sampleSpcInput.x.join(", ");
|
||||||
|
errorMessage.value = "";
|
||||||
|
statusText.value = "样例已载入";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行 SPC 接口调用,并保存原始响应和解包后的业务结果。
|
||||||
|
async function runSpc() {
|
||||||
|
loading.value = true;
|
||||||
|
errorMessage.value = "";
|
||||||
|
statusText.value = "计算中";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const input = readInput();
|
||||||
|
const response = await callSpc(input);
|
||||||
|
rawResponse.value = response.response;
|
||||||
|
result.value = response.result;
|
||||||
|
statusText.value = "计算完成";
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
errorMessage.value = message;
|
||||||
|
statusText.value = "计算失败";
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSpc() {
|
||||||
|
const parsedCount = computed(() => {
|
||||||
|
try {
|
||||||
|
return parseValues(form.valuesText).length;
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentInput = computed(() => {
|
||||||
|
try {
|
||||||
|
return readInput();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
form,
|
||||||
|
result: readonly(result),
|
||||||
|
rawResponse: readonly(rawResponse),
|
||||||
|
errorMessage: readonly(errorMessage),
|
||||||
|
statusText: readonly(statusText),
|
||||||
|
loading: readonly(loading),
|
||||||
|
parsedCount,
|
||||||
|
currentInput,
|
||||||
|
formatValues,
|
||||||
|
loadSample,
|
||||||
|
runSpc
|
||||||
|
};
|
||||||
|
}
|
||||||
3
spc-vue/src/config.ts
Normal file
3
spc-vue/src/config.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export const appConfig = {
|
||||||
|
wasmScriptUrl: "/wasm/smart_math.js"
|
||||||
|
};
|
||||||
25
spc-vue/src/data/sampleSpc.ts
Normal file
25
spc-vue/src/data/sampleSpc.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import type { SpcInput } from "../types/spc";
|
||||||
|
|
||||||
|
export const sampleSpcInput: SpcInput = {
|
||||||
|
n: 5,
|
||||||
|
k: 30,
|
||||||
|
usl: 1.7,
|
||||||
|
lsl: 1.5,
|
||||||
|
x: [
|
||||||
|
1.55, 1.58, 1.61, 1.6, 1.6, 1.58, 1.63, 1.63, 1.62, 1.63,
|
||||||
|
1.62, 1.63, 1.62, 1.59, 1.58, 1.58, 1.6, 1.61, 1.62, 1.63,
|
||||||
|
1.58, 1.64, 1.63, 1.62, 1.62, 1.62, 1.62, 1.63, 1.61, 1.57,
|
||||||
|
1.64, 1.62, 1.61, 1.6, 1.58, 1.57, 1.59, 1.61, 1.62, 1.63,
|
||||||
|
1.58, 1.61, 1.6, 1.62, 1.63, 1.6, 1.61, 1.64, 1.64, 1.63,
|
||||||
|
1.58, 1.6, 1.62, 1.63, 1.65, 1.62, 1.58, 1.59, 1.57, 1.58,
|
||||||
|
1.57, 1.57, 1.58, 1.59, 1.64, 1.61, 1.64, 1.62, 1.6, 1.59,
|
||||||
|
1.65, 1.62, 1.62, 1.6, 1.58, 1.57, 1.59, 1.57, 1.59, 1.62,
|
||||||
|
1.56, 1.57, 1.57, 1.61, 1.62, 1.56, 1.58, 1.59, 1.6, 1.62,
|
||||||
|
1.58, 1.6, 1.6, 1.62, 1.63, 1.58, 1.59, 1.6, 1.63, 1.62,
|
||||||
|
1.58, 1.59, 1.62, 1.63, 1.64, 1.58, 1.59, 1.62, 1.63, 1.61,
|
||||||
|
1.58, 1.59, 1.6, 1.61, 1.63, 1.57, 1.59, 1.61, 1.61, 1.62,
|
||||||
|
1.58, 1.58, 1.6, 1.61, 1.63, 1.62, 1.58, 1.58, 1.58, 1.57,
|
||||||
|
1.63, 1.59, 1.57, 1.58, 1.57, 1.58, 1.62, 1.61, 1.63, 1.61,
|
||||||
|
1.58, 1.57, 1.59, 1.6, 1.62, 1.62, 1.6, 1.6, 1.57, 1.57
|
||||||
|
]
|
||||||
|
};
|
||||||
6
spc-vue/src/main.ts
Normal file
6
spc-vue/src/main.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { createApp } from "vue";
|
||||||
|
import App from "./App.vue";
|
||||||
|
import { router } from "./router";
|
||||||
|
import "./styles.css";
|
||||||
|
|
||||||
|
createApp(App).use(router).mount("#app");
|
||||||
22
spc-vue/src/router.ts
Normal file
22
spc-vue/src/router.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { createRouter, createWebHashHistory } from "vue-router";
|
||||||
|
import OverviewView from "./views/OverviewView.vue";
|
||||||
|
import XBarRView from "./views/XBarRView.vue";
|
||||||
|
import RChartView from "./views/RChartView.vue";
|
||||||
|
import XBarSView from "./views/XBarSView.vue";
|
||||||
|
import SChartView from "./views/SChartView.vue";
|
||||||
|
import CpkView from "./views/CpkView.vue";
|
||||||
|
|
||||||
|
export const routes = [
|
||||||
|
{ path: "/", redirect: "/overview" },
|
||||||
|
{ path: "/overview", component: OverviewView, meta: { title: "总览" } },
|
||||||
|
{ path: "/xbar-r", component: XBarRView, meta: { title: "Xbar-R" } },
|
||||||
|
{ path: "/r-chart", component: RChartView, meta: { title: "R 图" } },
|
||||||
|
{ path: "/xbar-s", component: XBarSView, meta: { title: "Xbar-S" } },
|
||||||
|
{ path: "/s-chart", component: SChartView, meta: { title: "S 图" } },
|
||||||
|
{ path: "/cpk", component: CpkView, meta: { title: "Cpk" } }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const router = createRouter({
|
||||||
|
history: createWebHashHistory(),
|
||||||
|
routes
|
||||||
|
});
|
||||||
136
spc-vue/src/services/wasmSpcClient.ts
Normal file
136
spc-vue/src/services/wasmSpcClient.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import { appConfig } from "../config";
|
||||||
|
import type { SpcInput, SpcRequest, SpcResult, WebApiResponse } from "../types/spc";
|
||||||
|
|
||||||
|
type SmartMathModule = {
|
||||||
|
_init_func: () => number;
|
||||||
|
_func: (requestPtr: number) => number;
|
||||||
|
_smart_free_string: (ptr: number) => void;
|
||||||
|
_malloc: (size: number) => number;
|
||||||
|
_free: (ptr: number) => void;
|
||||||
|
lengthBytesUTF8: (value: string) => number;
|
||||||
|
stringToUTF8: (value: string, ptr: number, size: number) => void;
|
||||||
|
UTF8ToString: (ptr: number) => string;
|
||||||
|
};
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
createSmartMathModule?: (options?: {
|
||||||
|
locateFile?: (fileName: string) => string;
|
||||||
|
noInitialRun?: boolean;
|
||||||
|
}) => Promise<SmartMathModule>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let scriptPromise: Promise<void> | null = null;
|
||||||
|
let modulePromise: Promise<SmartMathModule> | null = null;
|
||||||
|
|
||||||
|
// 动态加载 Emscripten 生成的 JS 包,避免 Vue 项目直接绑定全局 script 标签。
|
||||||
|
function loadWasmScript(scriptUrl: string): Promise<void> {
|
||||||
|
if (window.createSmartMathModule) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!scriptPromise) {
|
||||||
|
scriptPromise = new Promise((resolve, reject) => {
|
||||||
|
const script = document.createElement("script");
|
||||||
|
script.src = scriptUrl;
|
||||||
|
script.async = true;
|
||||||
|
script.onload = () => resolve();
|
||||||
|
script.onerror = () => reject(new Error(`WASM 脚本加载失败: ${scriptUrl}`));
|
||||||
|
document.head.appendChild(script);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return scriptPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化 WASM 模块,内部只执行一次 _init_func。
|
||||||
|
async function getWasmModule(): Promise<SmartMathModule> {
|
||||||
|
if (!modulePromise) {
|
||||||
|
modulePromise = (async () => {
|
||||||
|
await loadWasmScript(appConfig.wasmScriptUrl);
|
||||||
|
if (!window.createSmartMathModule) {
|
||||||
|
throw new Error("WASM 工厂函数 createSmartMathModule 不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
const wasmDir = appConfig.wasmScriptUrl.replace(/\/[^/]*$/, "");
|
||||||
|
const module = await window.createSmartMathModule({
|
||||||
|
noInitialRun: true,
|
||||||
|
locateFile: (fileName) => `${wasmDir}/${fileName}`
|
||||||
|
});
|
||||||
|
const initPtr = module._init_func();
|
||||||
|
try {
|
||||||
|
module.UTF8ToString(initPtr);
|
||||||
|
} finally {
|
||||||
|
module._smart_free_string(initPtr);
|
||||||
|
}
|
||||||
|
return module;
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
return modulePromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 在 WASM 线性内存中写入 UTF-8 字符串,并返回指针。
|
||||||
|
function allocString(module: SmartMathModule, value: string): number {
|
||||||
|
const size = module.lengthBytesUTF8(value) + 1;
|
||||||
|
const ptr = module._malloc(size);
|
||||||
|
module.stringToUTF8(value, ptr, size);
|
||||||
|
return ptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建 SPC 业务请求,保持与现有 Cmd_Spc 测试数据一致。
|
||||||
|
export function buildSpcRequest(input: SpcInput): SpcRequest {
|
||||||
|
return {
|
||||||
|
msg: "spc vue validation",
|
||||||
|
req_code: "SPC_VUE_VALIDATE",
|
||||||
|
req_from: "spc_vue",
|
||||||
|
req_cmd: "Cmd_Spc",
|
||||||
|
req_param: input
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验业务层 SPC 返回,不能只看顶层 success,要检查 res_data 的错误和完整字段。
|
||||||
|
export function unwrapSpcResponse(response: WebApiResponse): SpcResult {
|
||||||
|
if (response.success === false) {
|
||||||
|
throw new Error(typeof response.error === "string" ? response.error : "接口顶层 success=false");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = response.res_data;
|
||||||
|
if (!data) {
|
||||||
|
throw new Error("接口缺少 res_data");
|
||||||
|
}
|
||||||
|
if (data.error) {
|
||||||
|
throw new Error(data.error);
|
||||||
|
}
|
||||||
|
if (!data.XR || !data.XS || !data.Cpk) {
|
||||||
|
throw new Error("SPC 结果不完整,需要同时包含 res_data.XR、res_data.XS、res_data.Cpk");
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用 WASM _func 并返回经过业务完整性校验的 SPC 结果。
|
||||||
|
export async function callSpc(input: SpcInput): Promise<{ response: WebApiResponse; result: SpcResult }> {
|
||||||
|
const module = await getWasmModule();
|
||||||
|
const payload = JSON.stringify(buildSpcRequest(input));
|
||||||
|
let requestPtr = 0;
|
||||||
|
let responsePtr = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
requestPtr = allocString(module, payload);
|
||||||
|
responsePtr = module._func(requestPtr);
|
||||||
|
const response = JSON.parse(module.UTF8ToString(responsePtr)) as WebApiResponse;
|
||||||
|
return {
|
||||||
|
response,
|
||||||
|
result: unwrapSpcResponse(response)
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
if (responsePtr) {
|
||||||
|
module._smart_free_string(responsePtr);
|
||||||
|
}
|
||||||
|
if (requestPtr) {
|
||||||
|
module._free(requestPtr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
478
spc-vue/src/styles.css
Normal file
478
spc-vue/src/styles.css
Normal file
@@ -0,0 +1,478 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
--bg: #f4f6f8;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--surface-soft: #f8fafc;
|
||||||
|
--line: #d7dde5;
|
||||||
|
--line-strong: #b8c2cf;
|
||||||
|
--text: #172033;
|
||||||
|
--muted: #667085;
|
||||||
|
--primary: #0f766e;
|
||||||
|
--primary-strong: #115e59;
|
||||||
|
--danger: #b42318;
|
||||||
|
--warning: #b45309;
|
||||||
|
--ok: #15803d;
|
||||||
|
--code-bg: #111827;
|
||||||
|
--code-text: #edf2f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-width: 320px;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: "Segoe UI", "Microsoft YaHei", Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
textarea {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
min-height: 44px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 9px 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background-color 160ms ease,
|
||||||
|
border-color 160ms ease,
|
||||||
|
color 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:focus-visible,
|
||||||
|
a:focus-visible,
|
||||||
|
input:focus-visible,
|
||||||
|
textarea:focus-visible,
|
||||||
|
summary:focus-visible {
|
||||||
|
outline: 3px solid rgba(15, 118, 110, 0.24);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 19px;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
width: min(1480px, 100%);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
color: var(--primary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tabs {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tabs a {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 40px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--muted);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tabs a.router-link-active {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: var(--primary);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(340px, 420px) minmax(0, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-panel,
|
||||||
|
.content-pane,
|
||||||
|
.chart-card,
|
||||||
|
.info-panel,
|
||||||
|
.json-preview {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-panel {
|
||||||
|
position: sticky;
|
||||||
|
top: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 28px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
background: var(--surface-soft);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 9px 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
min-height: 300px;
|
||||||
|
resize: vertical;
|
||||||
|
padding: 12px;
|
||||||
|
font:
|
||||||
|
13px/1.55 Consolas,
|
||||||
|
"Courier New",
|
||||||
|
monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-field {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-panel__footer {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count-text {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button {
|
||||||
|
background: var(--primary);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button:hover:not(:disabled) {
|
||||||
|
background: var(--primary-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-button {
|
||||||
|
border-color: var(--line);
|
||||||
|
background: #ffffff;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-button:hover:not(:disabled) {
|
||||||
|
border-color: var(--primary);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
margin-top: 12px;
|
||||||
|
color: var(--danger);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-pane {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-stack {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-intro {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-intro p:last-child {
|
||||||
|
max-width: 920px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-strip {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-tile {
|
||||||
|
min-width: 0;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 11px 12px;
|
||||||
|
background: var(--surface-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-tile span {
|
||||||
|
display: block;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-tile strong {
|
||||||
|
display: block;
|
||||||
|
margin-top: 5px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
font-size: 20px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-tile strong.ok {
|
||||||
|
color: var(--ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-tile strong.warn {
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-tile strong.neutral {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-card {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-card__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 62px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: var(--surface-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-card__canvas,
|
||||||
|
.chart-card__empty {
|
||||||
|
width: 100%;
|
||||||
|
height: 460px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-card__empty {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-card__description {
|
||||||
|
padding: 0 16px 16px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-panel {
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-panel p {
|
||||||
|
margin-top: 8px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.two-column {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.two-column .chart-card__canvas,
|
||||||
|
.two-column .chart-card__empty {
|
||||||
|
height: 390px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-preview {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-preview summary {
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-preview pre {
|
||||||
|
max-height: 460px;
|
||||||
|
margin: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 14px;
|
||||||
|
background: var(--code-bg);
|
||||||
|
color: var(--code-text);
|
||||||
|
font:
|
||||||
|
13px/1.55 Consolas,
|
||||||
|
"Courier New",
|
||||||
|
monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
min-height: 160px;
|
||||||
|
border: 1px dashed var(--line-strong);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--muted);
|
||||||
|
background: var(--surface-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1180px) {
|
||||||
|
.workspace,
|
||||||
|
.two-column {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-panel {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-strip,
|
||||||
|
.info-grid {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.app-shell {
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tabs {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tabs a {
|
||||||
|
flex: 1 1 92px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid,
|
||||||
|
.metric-strip,
|
||||||
|
.info-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-card__canvas,
|
||||||
|
.chart-card__empty,
|
||||||
|
.two-column .chart-card__canvas,
|
||||||
|
.two-column .chart-card__empty {
|
||||||
|
height: 340px;
|
||||||
|
}
|
||||||
|
}
|
||||||
96
spc-vue/src/types/spc.ts
Normal file
96
spc-vue/src/types/spc.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
export interface SpcInput {
|
||||||
|
n: number;
|
||||||
|
k: number;
|
||||||
|
usl: number;
|
||||||
|
lsl: number;
|
||||||
|
x: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpcRequest {
|
||||||
|
msg: string;
|
||||||
|
req_code: string;
|
||||||
|
req_from: string;
|
||||||
|
req_cmd: "Cmd_Spc";
|
||||||
|
req_param: SpcInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface XrResult {
|
||||||
|
n: number;
|
||||||
|
k: number;
|
||||||
|
CL_X: number;
|
||||||
|
UCL_X: number;
|
||||||
|
LCL_X: number;
|
||||||
|
CL_R: number;
|
||||||
|
UCL_R: number;
|
||||||
|
LCL_R: number;
|
||||||
|
CL_Xk: readonly number[];
|
||||||
|
CL_Rk: readonly number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface XsResult {
|
||||||
|
n: number;
|
||||||
|
k: number;
|
||||||
|
CL_X: number;
|
||||||
|
UCL_X: number;
|
||||||
|
LCL_X: number;
|
||||||
|
CL_S: number;
|
||||||
|
UCL_S: number;
|
||||||
|
LCL_S: number;
|
||||||
|
CL_Xk: readonly number[];
|
||||||
|
CL_Sk: readonly number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CpkResult {
|
||||||
|
n: number;
|
||||||
|
k: number;
|
||||||
|
SL: number;
|
||||||
|
USL: number;
|
||||||
|
LSL: number;
|
||||||
|
Singma: number;
|
||||||
|
SingmaS: number;
|
||||||
|
Ca: number;
|
||||||
|
Cp: number;
|
||||||
|
CPU: number;
|
||||||
|
CPL: number;
|
||||||
|
CR: number;
|
||||||
|
Cpk: number;
|
||||||
|
Pp: number;
|
||||||
|
PPU: number;
|
||||||
|
PPL: number;
|
||||||
|
PR: number;
|
||||||
|
Ppk: number;
|
||||||
|
ProcessSpread: number;
|
||||||
|
GroupWidth: number;
|
||||||
|
GroupCount: number;
|
||||||
|
ValueMax: number;
|
||||||
|
ValueMin: number;
|
||||||
|
Xk: readonly number[];
|
||||||
|
XkUp: readonly number[];
|
||||||
|
XkDown: readonly number[];
|
||||||
|
Yk: readonly number[];
|
||||||
|
YkCount: readonly number[];
|
||||||
|
NormalDistributionX: readonly number[];
|
||||||
|
NormalDistributionY: readonly number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpcResult {
|
||||||
|
XR: XrResult;
|
||||||
|
XS: XsResult;
|
||||||
|
Cpk: CpkResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebApiResponse {
|
||||||
|
success?: boolean;
|
||||||
|
res_data?: SpcResult & { error?: string };
|
||||||
|
error?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ControlLimitChart {
|
||||||
|
title: string;
|
||||||
|
valueName: string;
|
||||||
|
values: readonly number[];
|
||||||
|
center: number;
|
||||||
|
upper: number;
|
||||||
|
lower: number;
|
||||||
|
}
|
||||||
187
spc-vue/src/utils/chartOptions.ts
Normal file
187
spc-vue/src/utils/chartOptions.ts
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
import type { EChartsOption } from "echarts";
|
||||||
|
import type { ControlLimitChart, CpkResult } from "../types/spc";
|
||||||
|
|
||||||
|
const chartColors = ["#0f766e", "#b45309", "#991b1b", "#2563eb"];
|
||||||
|
|
||||||
|
// 生成从 1 开始的子组序号,用于控制图横轴。
|
||||||
|
export function sequenceLabels(length: number): string[] {
|
||||||
|
return Array.from({ length }, (_, index) => String(index + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成固定值线,用于 CL、UCL、LCL。
|
||||||
|
function constantSeries(length: number, value: number): number[] {
|
||||||
|
return Array.from({ length }, () => value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 找出越过控制限的点,作为红色散点叠加在控制图上。
|
||||||
|
function outOfLimitPoints(values: readonly number[], upper: number, lower: number): Array<[number, number]> {
|
||||||
|
return values
|
||||||
|
.map((value, index) => ({ value, index }))
|
||||||
|
.filter((point) => point.value > upper || point.value < lower)
|
||||||
|
.map((point) => [point.index, point.value]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建单张控制图配置,包含数据线、中心线、上下控制限和越界点。
|
||||||
|
export function createControlLimitOption(chart: ControlLimitChart): EChartsOption {
|
||||||
|
const labels = sequenceLabels(chart.values.length);
|
||||||
|
const outliers = outOfLimitPoints(chart.values, chart.upper, chart.lower);
|
||||||
|
|
||||||
|
return {
|
||||||
|
color: chartColors,
|
||||||
|
tooltip: { trigger: "axis" },
|
||||||
|
legend: { top: 0, right: 0 },
|
||||||
|
grid: { left: 56, right: 28, top: 46, bottom: 44 },
|
||||||
|
xAxis: {
|
||||||
|
type: "category",
|
||||||
|
name: "子组",
|
||||||
|
data: labels,
|
||||||
|
boundaryGap: false,
|
||||||
|
axisLabel: { color: "#667085" }
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: "value",
|
||||||
|
name: chart.valueName,
|
||||||
|
scale: true,
|
||||||
|
axisLabel: { color: "#667085" },
|
||||||
|
splitLine: { lineStyle: { color: "#e5e7eb" } }
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: chart.valueName,
|
||||||
|
type: "line",
|
||||||
|
data: [...chart.values],
|
||||||
|
symbolSize: 6,
|
||||||
|
lineStyle: { width: 2 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "CL",
|
||||||
|
type: "line",
|
||||||
|
data: constantSeries(chart.values.length, chart.center),
|
||||||
|
symbol: "none",
|
||||||
|
lineStyle: { type: "dashed", width: 1.5 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "UCL",
|
||||||
|
type: "line",
|
||||||
|
data: constantSeries(chart.values.length, chart.upper),
|
||||||
|
symbol: "none",
|
||||||
|
lineStyle: { type: "dotted", width: 1.5 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "LCL",
|
||||||
|
type: "line",
|
||||||
|
data: constantSeries(chart.values.length, chart.lower),
|
||||||
|
symbol: "none",
|
||||||
|
lineStyle: { type: "dotted", width: 1.5 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "越界点",
|
||||||
|
type: "scatter",
|
||||||
|
data: outliers,
|
||||||
|
symbolSize: 10,
|
||||||
|
itemStyle: { color: "#dc2626" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建过程能力指标柱状图,便于快速查看 Cp/Cpk/Pp/Ppk 门槛。
|
||||||
|
export function createCapabilityBarOption(cpk: CpkResult): EChartsOption {
|
||||||
|
const labels = ["Cp", "Cpk", "Pp", "Ppk", "CPU", "CPL"];
|
||||||
|
|
||||||
|
return {
|
||||||
|
color: ["#0f766e"],
|
||||||
|
tooltip: { trigger: "axis" },
|
||||||
|
grid: { left: 48, right: 24, top: 36, bottom: 42 },
|
||||||
|
xAxis: {
|
||||||
|
type: "category",
|
||||||
|
data: labels,
|
||||||
|
axisLabel: { color: "#667085" }
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: "value",
|
||||||
|
axisLabel: { color: "#667085" },
|
||||||
|
splitLine: { lineStyle: { color: "#e5e7eb" } }
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: "能力指标",
|
||||||
|
type: "bar",
|
||||||
|
data: labels.map((label) => cpk[label as keyof CpkResult] as number),
|
||||||
|
barMaxWidth: 34,
|
||||||
|
itemStyle: {
|
||||||
|
color: (params) => {
|
||||||
|
const value = Number(params.value);
|
||||||
|
if (value >= 1.33) return "#15803d";
|
||||||
|
if (value >= 1) return "#b45309";
|
||||||
|
return "#b91c1c";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
markLine: {
|
||||||
|
symbol: "none",
|
||||||
|
data: [
|
||||||
|
{ yAxis: 1, name: "1.00" },
|
||||||
|
{ yAxis: 1.33, name: "1.33" }
|
||||||
|
],
|
||||||
|
label: { color: "#667085" },
|
||||||
|
lineStyle: { color: "#b45309", type: "dashed" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建直方图和正态曲线配置,用来核对分布和规格限位置。
|
||||||
|
export function createHistogramOption(cpk: CpkResult): EChartsOption {
|
||||||
|
return {
|
||||||
|
color: ["#0f766e", "#b45309"],
|
||||||
|
tooltip: { trigger: "axis" },
|
||||||
|
legend: { top: 0, right: 0 },
|
||||||
|
grid: { left: 52, right: 48, top: 46, bottom: 42 },
|
||||||
|
xAxis: {
|
||||||
|
type: "value",
|
||||||
|
name: "测量值",
|
||||||
|
axisLabel: { color: "#667085" }
|
||||||
|
},
|
||||||
|
yAxis: [
|
||||||
|
{
|
||||||
|
type: "value",
|
||||||
|
name: "频数",
|
||||||
|
axisLabel: { color: "#667085" },
|
||||||
|
splitLine: { lineStyle: { color: "#e5e7eb" } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "value",
|
||||||
|
name: "正态曲线",
|
||||||
|
axisLabel: { color: "#667085" },
|
||||||
|
splitLine: { show: false }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: "频数",
|
||||||
|
type: "bar",
|
||||||
|
data: cpk.Xk.map((value, index) => [value, cpk.YkCount[index]]),
|
||||||
|
barMaxWidth: 28,
|
||||||
|
markLine: {
|
||||||
|
symbol: "none",
|
||||||
|
data: [
|
||||||
|
{ xAxis: cpk.LSL, name: "LSL" },
|
||||||
|
{ xAxis: cpk.USL, name: "USL" },
|
||||||
|
{ xAxis: cpk.SL, name: "SL" }
|
||||||
|
],
|
||||||
|
label: { color: "#667085" },
|
||||||
|
lineStyle: { color: "#991b1b", type: "dashed" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "正态曲线",
|
||||||
|
type: "line",
|
||||||
|
yAxisIndex: 1,
|
||||||
|
smooth: true,
|
||||||
|
symbolSize: 4,
|
||||||
|
data: cpk.NormalDistributionX.map((value, index) => [value, cpk.NormalDistributionY[index]])
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
45
spc-vue/src/views/CpkView.vue
Normal file
45
spc-vue/src/views/CpkView.vue
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
import EChartPanel from "../components/EChartPanel.vue";
|
||||||
|
import MetricStrip from "../components/MetricStrip.vue";
|
||||||
|
import { useSpc } from "../composables/useSpc";
|
||||||
|
import { createCapabilityBarOption, createHistogramOption } from "../utils/chartOptions";
|
||||||
|
|
||||||
|
const { result } = useSpc();
|
||||||
|
|
||||||
|
const metrics = computed(() => {
|
||||||
|
const cpk = result.value?.Cpk;
|
||||||
|
if (!cpk) return [];
|
||||||
|
return [
|
||||||
|
{ label: "Cp", value: cpk.Cp, tone: cpk.Cp >= 1.33 ? ("ok" as const) : ("warn" as const) },
|
||||||
|
{ label: "Cpk", value: cpk.Cpk, tone: cpk.Cpk >= 1.33 ? ("ok" as const) : ("warn" as const) },
|
||||||
|
{ label: "Pp", value: cpk.Pp, tone: cpk.Pp >= 1.33 ? ("ok" as const) : ("warn" as const) },
|
||||||
|
{ label: "Ppk", value: cpk.Ppk, tone: cpk.Ppk >= 1.33 ? ("ok" as const) : ("warn" as const) },
|
||||||
|
{ label: "USL", value: cpk.USL },
|
||||||
|
{ label: "LSL", value: cpk.LSL }
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
const capabilityOption = computed(() => (result.value?.Cpk ? createCapabilityBarOption(result.value.Cpk) : null));
|
||||||
|
const histogramOption = computed(() => (result.value?.Cpk ? createHistogramOption(result.value.Cpk) : null));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<article class="page-stack">
|
||||||
|
<MetricStrip v-if="metrics.length" :metrics="metrics" />
|
||||||
|
<div class="two-column">
|
||||||
|
<EChartPanel
|
||||||
|
title="过程能力指标"
|
||||||
|
eyebrow="Capability"
|
||||||
|
description="能力柱状图用于快速比较 Cp、Cpk、Pp、Ppk。页面保留 1.00 与 1.33 参考线,方便验证常见能力门槛。"
|
||||||
|
:option="capabilityOption"
|
||||||
|
/>
|
||||||
|
<EChartPanel
|
||||||
|
title="直方图与正态曲线"
|
||||||
|
eyebrow="Distribution"
|
||||||
|
description="直方图展示测量值分布,并叠加正态曲线和规格限,便于判断能力指标是否被偏态或边界数据影响。"
|
||||||
|
:option="histogramOption"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
56
spc-vue/src/views/OverviewView.vue
Normal file
56
spc-vue/src/views/OverviewView.vue
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
import JsonPreview from "../components/JsonPreview.vue";
|
||||||
|
import MetricStrip from "../components/MetricStrip.vue";
|
||||||
|
import { useSpc } from "../composables/useSpc";
|
||||||
|
|
||||||
|
const { result, rawResponse } = useSpc();
|
||||||
|
|
||||||
|
const metrics = computed(() => {
|
||||||
|
const cpk = result.value?.Cpk;
|
||||||
|
const xr = result.value?.XR;
|
||||||
|
const xs = result.value?.XS;
|
||||||
|
if (!cpk || !xr || !xs) return [];
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ label: "Cpk", value: cpk.Cpk, tone: cpk.Cpk >= 1.33 ? ("ok" as const) : ("warn" as const) },
|
||||||
|
{ label: "Cp", value: cpk.Cp, tone: cpk.Cp >= 1.33 ? ("ok" as const) : ("warn" as const) },
|
||||||
|
{ label: "Ppk", value: cpk.Ppk, tone: cpk.Ppk >= 1.33 ? ("ok" as const) : ("warn" as const) },
|
||||||
|
{ label: "XR 均值CL", value: xr.CL_X },
|
||||||
|
{ label: "R 均值CL", value: xr.CL_R },
|
||||||
|
{ label: "S 均值CL", value: xs.CL_S }
|
||||||
|
];
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<article class="page-stack">
|
||||||
|
<section class="page-intro">
|
||||||
|
<p class="eyebrow">Overview</p>
|
||||||
|
<h2>SPC 结果总览</h2>
|
||||||
|
<p>
|
||||||
|
页面启动后会自动调用一次 `Cmd_Spc`。左侧可以替换 n、k、规格限和测量数据,所有控制图页面共用同一份计算结果。
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<MetricStrip v-if="metrics.length" :metrics="metrics" />
|
||||||
|
<div v-else class="empty-state">等待 SPC 结果</div>
|
||||||
|
|
||||||
|
<section class="info-grid">
|
||||||
|
<div class="info-panel">
|
||||||
|
<h3>Xbar-R / R</h3>
|
||||||
|
<p>使用子组均值和极差验证过程中心与短期组内波动,适合小子组连续型数据。</p>
|
||||||
|
</div>
|
||||||
|
<div class="info-panel">
|
||||||
|
<h3>Xbar-S / S</h3>
|
||||||
|
<p>使用子组均值和样本标准差验证过程稳定性,适合子组容量较大或需要标准差口径的场景。</p>
|
||||||
|
</div>
|
||||||
|
<div class="info-panel">
|
||||||
|
<h3>Cpk 能力</h3>
|
||||||
|
<p>结合规格限、直方图和正态曲线,检查过程能力指标与数据分布是否相互印证。</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<JsonPreview :value="rawResponse" />
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
45
spc-vue/src/views/RChartView.vue
Normal file
45
spc-vue/src/views/RChartView.vue
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
import EChartPanel from "../components/EChartPanel.vue";
|
||||||
|
import MetricStrip from "../components/MetricStrip.vue";
|
||||||
|
import { useSpc } from "../composables/useSpc";
|
||||||
|
import { createControlLimitOption } from "../utils/chartOptions";
|
||||||
|
|
||||||
|
const { result } = useSpc();
|
||||||
|
|
||||||
|
const option = computed(() => {
|
||||||
|
const xr = result.value?.XR;
|
||||||
|
if (!xr) return null;
|
||||||
|
return createControlLimitOption({
|
||||||
|
title: "R 极差控制图",
|
||||||
|
valueName: "子组极差",
|
||||||
|
values: xr.CL_Rk,
|
||||||
|
center: xr.CL_R,
|
||||||
|
upper: xr.UCL_R,
|
||||||
|
lower: xr.LCL_R
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const metrics = computed(() => {
|
||||||
|
const xr = result.value?.XR;
|
||||||
|
if (!xr) return [];
|
||||||
|
return [
|
||||||
|
{ label: "CL_R", value: xr.CL_R },
|
||||||
|
{ label: "UCL_R", value: xr.UCL_R },
|
||||||
|
{ label: "LCL_R", value: xr.LCL_R },
|
||||||
|
{ label: "子组容量", value: xr.n }
|
||||||
|
];
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<article class="page-stack">
|
||||||
|
<MetricStrip v-if="metrics.length" :metrics="metrics" />
|
||||||
|
<EChartPanel
|
||||||
|
title="R 极差控制图"
|
||||||
|
eyebrow="Range"
|
||||||
|
description="R 图使用每个子组的最大值减最小值观察组内波动。若 R 图先失控,Xbar 控制限的解释需要谨慎。"
|
||||||
|
:option="option"
|
||||||
|
/>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
45
spc-vue/src/views/SChartView.vue
Normal file
45
spc-vue/src/views/SChartView.vue
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
import EChartPanel from "../components/EChartPanel.vue";
|
||||||
|
import MetricStrip from "../components/MetricStrip.vue";
|
||||||
|
import { useSpc } from "../composables/useSpc";
|
||||||
|
import { createControlLimitOption } from "../utils/chartOptions";
|
||||||
|
|
||||||
|
const { result } = useSpc();
|
||||||
|
|
||||||
|
const option = computed(() => {
|
||||||
|
const xs = result.value?.XS;
|
||||||
|
if (!xs) return null;
|
||||||
|
return createControlLimitOption({
|
||||||
|
title: "S 标准差控制图",
|
||||||
|
valueName: "子组标准差",
|
||||||
|
values: xs.CL_Sk,
|
||||||
|
center: xs.CL_S,
|
||||||
|
upper: xs.UCL_S,
|
||||||
|
lower: xs.LCL_S
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const metrics = computed(() => {
|
||||||
|
const xs = result.value?.XS;
|
||||||
|
if (!xs) return [];
|
||||||
|
return [
|
||||||
|
{ label: "CL_S", value: xs.CL_S },
|
||||||
|
{ label: "UCL_S", value: xs.UCL_S },
|
||||||
|
{ label: "LCL_S", value: xs.LCL_S },
|
||||||
|
{ label: "子组容量", value: xs.n }
|
||||||
|
];
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<article class="page-stack">
|
||||||
|
<MetricStrip v-if="metrics.length" :metrics="metrics" />
|
||||||
|
<EChartPanel
|
||||||
|
title="S 标准差控制图"
|
||||||
|
eyebrow="Standard Deviation"
|
||||||
|
description="S 图使用每个子组的样本标准差观察组内离散程度,对标准差变化更敏感,常用于较大子组数据。"
|
||||||
|
:option="option"
|
||||||
|
/>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
46
spc-vue/src/views/XBarRView.vue
Normal file
46
spc-vue/src/views/XBarRView.vue
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
import EChartPanel from "../components/EChartPanel.vue";
|
||||||
|
import MetricStrip from "../components/MetricStrip.vue";
|
||||||
|
import { useSpc } from "../composables/useSpc";
|
||||||
|
import { createControlLimitOption } from "../utils/chartOptions";
|
||||||
|
|
||||||
|
const { result } = useSpc();
|
||||||
|
|
||||||
|
const chart = computed(() => {
|
||||||
|
const xr = result.value?.XR;
|
||||||
|
if (!xr) return null;
|
||||||
|
return {
|
||||||
|
title: "Xbar 控制图(XR 控制限)",
|
||||||
|
valueName: "子组均值",
|
||||||
|
values: xr.CL_Xk,
|
||||||
|
center: xr.CL_X,
|
||||||
|
upper: xr.UCL_X,
|
||||||
|
lower: xr.LCL_X
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const option = computed(() => (chart.value ? createControlLimitOption(chart.value) : null));
|
||||||
|
const metrics = computed(() => {
|
||||||
|
const xr = result.value?.XR;
|
||||||
|
if (!xr) return [];
|
||||||
|
return [
|
||||||
|
{ label: "CL_X", value: xr.CL_X },
|
||||||
|
{ label: "UCL_X", value: xr.UCL_X },
|
||||||
|
{ label: "LCL_X", value: xr.LCL_X },
|
||||||
|
{ label: "子组数", value: xr.k }
|
||||||
|
];
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<article class="page-stack">
|
||||||
|
<MetricStrip v-if="metrics.length" :metrics="metrics" />
|
||||||
|
<EChartPanel
|
||||||
|
title="Xbar-R:均值控制图"
|
||||||
|
eyebrow="Xbar-R"
|
||||||
|
description="Xbar 图使用每个子组的均值观察过程中心是否稳定;这里的均值控制限来自 XR 结果,适合与 R 图一起验证小子组数据。"
|
||||||
|
:option="option"
|
||||||
|
/>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
45
spc-vue/src/views/XBarSView.vue
Normal file
45
spc-vue/src/views/XBarSView.vue
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
import EChartPanel from "../components/EChartPanel.vue";
|
||||||
|
import MetricStrip from "../components/MetricStrip.vue";
|
||||||
|
import { useSpc } from "../composables/useSpc";
|
||||||
|
import { createControlLimitOption } from "../utils/chartOptions";
|
||||||
|
|
||||||
|
const { result } = useSpc();
|
||||||
|
|
||||||
|
const option = computed(() => {
|
||||||
|
const xs = result.value?.XS;
|
||||||
|
if (!xs) return null;
|
||||||
|
return createControlLimitOption({
|
||||||
|
title: "Xbar 控制图(XS 控制限)",
|
||||||
|
valueName: "子组均值",
|
||||||
|
values: xs.CL_Xk,
|
||||||
|
center: xs.CL_X,
|
||||||
|
upper: xs.UCL_X,
|
||||||
|
lower: xs.LCL_X
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const metrics = computed(() => {
|
||||||
|
const xs = result.value?.XS;
|
||||||
|
if (!xs) return [];
|
||||||
|
return [
|
||||||
|
{ label: "CL_X", value: xs.CL_X },
|
||||||
|
{ label: "UCL_X", value: xs.UCL_X },
|
||||||
|
{ label: "LCL_X", value: xs.LCL_X },
|
||||||
|
{ label: "子组数", value: xs.k }
|
||||||
|
];
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<article class="page-stack">
|
||||||
|
<MetricStrip v-if="metrics.length" :metrics="metrics" />
|
||||||
|
<EChartPanel
|
||||||
|
title="Xbar-S:均值控制图"
|
||||||
|
eyebrow="Xbar-S"
|
||||||
|
description="Xbar-S 中的均值图同样观察过程中心,但控制限由样本标准差口径计算,适合和 S 图配套检查过程稳定性。"
|
||||||
|
:option="option"
|
||||||
|
/>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
19
spc-vue/tsconfig.json
Normal file
19
spc-vue/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"strict": true,
|
||||||
|
"types": ["vite/client"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.vue"],
|
||||||
|
"references": []
|
||||||
|
}
|
||||||
48
spc-vue/vite.config.ts
Normal file
48
spc-vue/vite.config.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import vue from "@vitejs/plugin-vue";
|
||||||
|
import { defineConfig, type Plugin } from "vite";
|
||||||
|
|
||||||
|
const repoWasmDir = path.resolve(__dirname, "../public/wasm");
|
||||||
|
|
||||||
|
// 复用主项目已经构建好的 WASM 文件,开发时映射 /wasm,打包时复制到 dist/wasm。
|
||||||
|
function repoWasmAssets(): Plugin {
|
||||||
|
return {
|
||||||
|
name: "repo-wasm-assets",
|
||||||
|
configureServer(server) {
|
||||||
|
server.middlewares.use("/wasm", (req, res, next) => {
|
||||||
|
const requestPath = decodeURIComponent((req.url || "").split("?")[0].replace(/^\/+/, ""));
|
||||||
|
const filePath = path.resolve(repoWasmDir, requestPath);
|
||||||
|
if (!filePath.startsWith(repoWasmDir) || !fs.existsSync(filePath)) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filePath.endsWith(".wasm")) {
|
||||||
|
res.setHeader("Content-Type", "application/wasm");
|
||||||
|
} else if (filePath.endsWith(".js")) {
|
||||||
|
res.setHeader("Content-Type", "application/javascript; charset=utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.createReadStream(filePath).pipe(res);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
closeBundle() {
|
||||||
|
const outDir = path.resolve(__dirname, "dist/wasm");
|
||||||
|
fs.mkdirSync(outDir, { recursive: true });
|
||||||
|
for (const assetName of ["smart_math.js", "smart_math.wasm"]) {
|
||||||
|
const source = path.join(repoWasmDir, assetName);
|
||||||
|
if (fs.existsSync(source)) {
|
||||||
|
fs.copyFileSync(source, path.join(outDir, assetName));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue(), repoWasmAssets()],
|
||||||
|
server: {
|
||||||
|
port: 5174
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user