新增SPC Vue控制图项目

This commit is contained in:
zhangshun
2026-06-29 09:18:24 +08:00
parent 856a7250f2
commit 37517d5279
27 changed files with 3864 additions and 0 deletions

3
spc-vue/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules
dist
*.local

31
spc-vue/README.md Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

23
spc-vue/package.json Normal file
View 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
View 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>

View 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>

View 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>

View 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>

View 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>

View 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
View File

@@ -0,0 +1,3 @@
export const appConfig = {
wasmScriptUrl: "/wasm/smart_math.js"
};

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

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

View 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]])
}
]
};
}

View 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>

View 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`左侧可以替换 nk规格限和测量数据所有控制图页面共用同一份计算结果
</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>

View 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>

View 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>

View 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>

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