排班工具
This commit is contained in:
101
pages/api/tool.ts
Normal file
101
pages/api/tool.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import fs from "fs";
|
||||
import { store, html } from "@/store";
|
||||
import glob from "glob";
|
||||
import { promisify } from "util";
|
||||
const g = promisify(glob);
|
||||
const read = promisify(fs.readFile);
|
||||
|
||||
// 索引与工时
|
||||
const indexToHour: Record<string, number> = JSON.parse(
|
||||
fs.readFileSync("./hours.json", "utf8")
|
||||
).selections;
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<Record<string, string>>
|
||||
) {
|
||||
// 读入全部json文件
|
||||
// users: {姓名: {坐标: 权重}}
|
||||
const users: Record<string, Record<string, number>> = {};
|
||||
const files = await g("./json/*.json");
|
||||
for (const file of files) {
|
||||
const jsonStr = await read(file, "utf8");
|
||||
const json: {
|
||||
user: string;
|
||||
selections: Record<string, number>;
|
||||
} = JSON.parse(jsonStr);
|
||||
|
||||
// Normalization
|
||||
// 使用 标准分数 算法 z = (x - mean) / std
|
||||
const { selections } = json;
|
||||
const nums = Object.values(selections);
|
||||
const mean = nums.reduce((a, b) => a + b) / nums.length;
|
||||
const std = Math.sqrt(
|
||||
nums.map((x) => Math.pow(x - mean, 2)).reduce((a, b) => a + b) /
|
||||
nums.length
|
||||
);
|
||||
const normalizedNums = nums.map((x) => (std === 0 ? 0 : (x - mean) / std));
|
||||
for (const index in selections) {
|
||||
selections[index] = std === 0 ? 0 : (selections[index] - mean) / std;
|
||||
}
|
||||
users[json.user] = selections;
|
||||
}
|
||||
// 计算每一个格子有多少人选择
|
||||
// counts: {坐标: {姓名: 权重}}
|
||||
const counts: Record<string, Record<string, Record<string, number>>> = {};
|
||||
for (const user in users) {
|
||||
for (const select in users[user]) {
|
||||
if (counts[select] === undefined) counts[select] = {};
|
||||
counts[select][user] = users[user];
|
||||
}
|
||||
}
|
||||
|
||||
// 构造返回数据, key 为 坐标,value 为姓名
|
||||
const resp: Record<string, string> = {};
|
||||
|
||||
// 找到选择数量最少的格子
|
||||
// sortable: [[坐标, 选择人数]]
|
||||
// 如果遇到选择数量相同的格子,随机打乱顺序进行迭代
|
||||
const sortable: [string, number][] = [];
|
||||
for (const index in counts) {
|
||||
sortable.push([index, Object.keys(counts[index]).length]);
|
||||
}
|
||||
sortable.sort((a, b) => a[1] - b[1] || Math.random() - 0.5);
|
||||
|
||||
// 记录这个人的出现次数,还有这个人的工时总数
|
||||
const hoursCount: Record<string, number> = {};
|
||||
|
||||
for (const [smallestIndex, _] of sortable) {
|
||||
// console.log("smallestIndex", smallestIndex);
|
||||
// console.log("cell", counts[smallestIndex]);
|
||||
// 找到格子里权重最低的人选 [姓名, 小时数, 出现次数, 权重]
|
||||
const weightList: [string, number, number][] = [];
|
||||
for (const user in counts[smallestIndex]) {
|
||||
weightList.push([
|
||||
user,
|
||||
hoursCount[user] || 0,
|
||||
counts[smallestIndex][user][smallestIndex],
|
||||
]);
|
||||
}
|
||||
weightList.sort((a, b) => a[1] - b[1] || a[2] - b[2]);
|
||||
// console.log("weightList", weightList);
|
||||
const theChoosenUser = weightList[0][0];
|
||||
// console.log("theChoosenUser", theChoosenUser);
|
||||
// 记录结果
|
||||
resp[smallestIndex] = theChoosenUser;
|
||||
hoursCount[theChoosenUser] =
|
||||
(hoursCount[theChoosenUser] || 0) + indexToHour[smallestIndex];
|
||||
}
|
||||
// console.log("hoursCount", hoursCount);
|
||||
|
||||
const sortedHoursCount: [string, number][] = [];
|
||||
for (const user in hoursCount) {
|
||||
sortedHoursCount.push([user, hoursCount[user]]);
|
||||
}
|
||||
sortedHoursCount.sort((a, b) => a[1] - b[1]);
|
||||
console.log("sortedHoursCount", sortedHoursCount);
|
||||
console.log("sortedHoursCount.length", sortedHoursCount.length);
|
||||
|
||||
res.status(200).json(resp);
|
||||
}
|
||||
50
pages/report-tool.tsx
Normal file
50
pages/report-tool.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import React from "react";
|
||||
import Head from "next/head";
|
||||
|
||||
const ReportPage = () => {
|
||||
const ref = React.useRef();
|
||||
const getReport = async () => {
|
||||
const resp = await fetch("/api/html").then((resp) => resp.json());
|
||||
ref.current.innerHTML = resp.html;
|
||||
const json: Record<string, string> = await fetch("/api/tool").then(
|
||||
(resp) => resp.json()
|
||||
);
|
||||
const table = ref.current.children[0];
|
||||
const tbody = table.children[table.children.length - 1];
|
||||
for (const tr_index in tbody.children) {
|
||||
const tr = tbody.children[tr_index];
|
||||
for (const td_index in tr.children) {
|
||||
const td = tr.children[td_index];
|
||||
if (td.tagName !== "TD") continue;
|
||||
const index = `${tr_index},${td_index}`;
|
||||
if (json[index] === undefined) continue;
|
||||
td.innerHTML = json[index];
|
||||
}
|
||||
}
|
||||
};
|
||||
React.useEffect(() => {
|
||||
getReport();
|
||||
}, []);
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Create Next App</title>
|
||||
<meta name="description" content="Generated by create next app" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
<main>
|
||||
<button onClick={async () => getReport()}>Refresh</button>
|
||||
<div
|
||||
ref={ref}
|
||||
onInput={(event) => {
|
||||
console.log(event.currentTarget.innerHTML);
|
||||
}}
|
||||
contentEditable="true"
|
||||
></div>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReportPage;
|
||||
55
pages/tool.tsx
Normal file
55
pages/tool.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import React from "react";
|
||||
import Head from "next/head";
|
||||
import Timetable from "@/components/Timetable";
|
||||
|
||||
export default function Home() {
|
||||
const [user, setUser] = React.useState("");
|
||||
const [begin, setBegin] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setUser(localStorage.getItem("user") || "");
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Create Next App</title>
|
||||
<meta name="description" content="Generated by create next app" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
<main>
|
||||
{!begin && (
|
||||
<div>
|
||||
<input
|
||||
placeholder="在这输你的名字"
|
||||
value={user}
|
||||
onChange={(event) => setUser(event.target.value)}
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (user.trim() === "") {
|
||||
alert("姓名不能为空");
|
||||
return;
|
||||
}
|
||||
setUser(user.trim());
|
||||
setBegin(true);
|
||||
localStorage.setItem("user", user);
|
||||
}}
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{begin && (
|
||||
<Timetable
|
||||
user={user}
|
||||
disableConflictCheck={true}
|
||||
disableNetwork={true}
|
||||
replaceInputType={"number"}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user