This commit is contained in:
2025-07-17 16:14:06 +08:00
parent 62c0916566
commit 93975b26bc
13 changed files with 234 additions and 228 deletions

26
Dockerfile Normal file
View File

@@ -0,0 +1,26 @@
# 构建阶段
FROM golang:1.23 AS builder
WORKDIR /app
# 复制源码
COPY . .
# 构建二进制文件
RUN go build -o /app/scheduler
# 运行阶段
FROM scratch
WORKDIR /app
# 复制构建好的二进制文件
COPY --from=builder ./scheduler ./scheduler
RUN mkdir -p config
EXPOSE 25005
USER nonroot:nonroot
CMD ["./scheduler","--config","config/config.yaml"]

View File

@@ -1,33 +1,36 @@
<html data-theme="light"> <html data-theme="light">
<head> <head>
<!-- <link rel="stylesheet" href="https://cdn.jsdmirror.com/npm/@picocss/pico@2/css/pico.min.css"> --> <!-- <link rel="stylesheet" href="https://cdn.jsdmirror.com/npm/@picocss/pico@2/css/pico.min.css"> -->
</head> </head>
<body> <body>
<div> <div>
<h1 align=center>今日值班表</h1> <h1 align="center">今日值班表</h1>
<div align=center> <div align="center">
<button hx-get="/api/getAssignment" hx-swap="outerHTML"> 点我!</button> <button hx-get="/api/getAssignment" hx-swap="outerHTML">点我!</button>
</div> </div>
</div> </div>
</body> </body>
<script src="/htmx.min.js"></script> <script src="/htmx.min.js"></script>
<style> <style>
table { table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
margin:auto margin: auto;
} }
th, td { th,
td {
border: 1px solid black; border: 1px solid black;
padding: 8px; padding: 8px;
text-align: center; text-align: center;
} }
td:first-child, th:first-child { td:first-child,
th:first-child {
background-color: #b3d9f7; background-color: #b3d9f7;
font-weight: bold; font-weight: bold;
} }
td:nth-child(2), th:nth-child(2) { td:nth-child(2),
background-color: #55ffaa th:nth-child(2) {
background-color: #55ffaa;
} }
.ZoneHead { .ZoneHead {
display: inline-block; /* 让颜色块和文字在同一行显示 */ display: inline-block; /* 让颜色块和文字在同一行显示 */
@@ -44,22 +47,22 @@
background-color: #ffa0c9; /* 设置颜色块的背景颜色 */ background-color: #ffa0c9; /* 设置颜色块的背景颜色 */
vertical-align: middle; /* 让颜色块垂直居中 */ vertical-align: middle; /* 让颜色块垂直居中 */
} }
.SwitchOrRepay{ .SwitchOrRepay {
display: inline-block; /* 让颜色块和文字在同一行显示 */ display: inline-block; /* 让颜色块和文字在同一行显示 */
width: 1em; /* 设置颜色块的宽度为1个字的宽度 */ width: 1em; /* 设置颜色块的宽度为1个字的宽度 */
height: 1em; /* 设置颜色块的高度为1个字的高度 */ height: 1em; /* 设置颜色块的高度为1个字的高度 */
background-color: #fff6b5; /* 设置颜色块的背景颜色 */ background-color: #fff6b5; /* 设置颜色块的背景颜色 */
vertical-align: middle; /* 让颜色块垂直居中 */ vertical-align: middle; /* 让颜色块垂直居中 */
} }
.Volunteering{ .Volunteering {
display: inline-block; /* 让颜色块和文字在同一行显示 */ display: inline-block; /* 让颜色块和文字在同一行显示 */
width: 1em; /* 设置颜色块的宽度为1个字的宽度 */ width: 1em; /* 设置颜色块的宽度为1个字的宽度 */
height: 1em; /* 设置颜色块的高度为1个字的高度 */ height: 1em; /* 设置颜色块的高度为1个字的高度 */
background-color: #c0ff85; /* 设置颜色块的背景颜色 */ background-color: #c0ff85; /* 设置颜色块的背景颜色 */
vertical-align: middle; /* 让颜色块垂直居中 */ vertical-align: middle; /* 让颜色块垂直居中 */
} }
.table_notes{ .table_notes {
align-items:left align-items: left;
} }
</style> </style>
</html> </html>

View File

@@ -1,39 +1,39 @@
document.getElementById('getAssignment').addEventListener('click', function () { document.getElementById("getAssignment").addEventListener("click", function () {
dateInput = document.getElementById('calendar').value; dateInput = document.getElementById("calendar").value;
if (!dateInput) { if (!dateInput) {
dateInput = getToday() dateInput = getToday();
} }
const url = `/api/getAssignment?date=${dateInput}`; const url = `/api/getAssignment?date=${dateInput}`;
fetch(url) fetch(url)
.then(response => { .then((response) => {
if (!response.ok) { if (!response.ok) {
throw new Error('网络响应失败'); throw new Error("网络响应失败");
} }
return response.json(); return response.json();
}) })
.then(data => { .then((data) => {
const responseDiv = document.getElementById('response'); const responseDiv = document.getElementById("response");
responseDiv.innerHTML = ''; // 清除旧内容 responseDiv.innerHTML = ""; // 清除旧内容
const table = document.createElement('table'); const table = document.createElement("table");
data.forEach(subArray => { data.forEach((subArray) => {
const row = document.createElement('tr'); const row = document.createElement("tr");
subArray.forEach(item => { subArray.forEach((item) => {
const cell = document.createElement('td'); const cell = document.createElement("td");
cell.textContent = item.Name || item.ID; cell.textContent = item.Name || item.ID;
// 优先判断 Access 条件 // 优先判断 Access 条件
if (item.Access <=3) { if (item.Access <= 3) {
cell.classList.add('cell_Moderator'); cell.classList.add("cell_Moderator");
} else if (item.Note === 1) { } else if (item.Note === 1) {
cell.classList.add('cell_SwitchOrRepay'); cell.classList.add("cell_SwitchOrRepay");
} else if (item.Note === 2) { } else if (item.Note === 2) {
cell.classList.add('cell_Volunteering'); cell.classList.add("cell_Volunteering");
} }
row.appendChild(cell); row.appendChild(cell);
@@ -41,10 +41,10 @@ document.getElementById('getAssignment').addEventListener('click', function () {
table.appendChild(row); table.appendChild(row);
}); });
const title =`<i><h5 align=center >${dateInput}网维值班表</h5></i>` const title = `<i><h5 align=center >${dateInput}网维值班表</h5></i>`;
const titleContainer = document.createElement('div'); const titleContainer = document.createElement("div");
titleContainer.innerHTML = title titleContainer.innerHTML = title;
responseDiv.appendChild(titleContainer) responseDiv.appendChild(titleContainer);
// 插入表格 // 插入表格
responseDiv.appendChild(table); responseDiv.appendChild(table);
@@ -55,13 +55,13 @@ document.getElementById('getAssignment').addEventListener('click', function () {
<i class="table_notes"><span class="SwitchOrRepay"></span>换班/补班<br></i> <i class="table_notes"><span class="SwitchOrRepay"></span>换班/补班<br></i>
<i class="table_notes"><span class="Volunteering"></span>蹭班<br></i> <i class="table_notes"><span class="Volunteering"></span>蹭班<br></i>
`; `;
const legendContainer = document.createElement('div'); const legendContainer = document.createElement("div");
legendContainer.innerHTML = legendHTML; legendContainer.innerHTML = legendHTML;
responseDiv.appendChild(legendContainer); responseDiv.appendChild(legendContainer);
}) })
.catch(error => { .catch((error) => {
console.error('请求失败:', error); console.error("请求失败:", error);
document.getElementById('response').innerHTML = '获取任务失败,请重试。'; document.getElementById("response").innerHTML = "获取任务失败,请重试。";
}); });
}); });
@@ -69,15 +69,8 @@ function getToday() {
const today = new Date(); const today = new Date();
const year = today.getFullYear(); const year = today.getFullYear();
const month = String(today.getMonth() + 1).padStart(2, '0'); // 月份从0开始需要+1 const month = String(today.getMonth() + 1).padStart(2, "0"); // 月份从0开始需要+1
const day = String(today.getDate()).padStart(2, '0'); const day = String(today.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`; return `${year}-${month}-${day}`;
} }

View File

@@ -9,7 +9,6 @@ import (
"gorm.io/driver/sqlite" "gorm.io/driver/sqlite"
"gorm.io/gorm" "gorm.io/gorm"
"zsxyww.com/scheduler/config" "zsxyww.com/scheduler/config"
"zsxyww.com/scheduler/model"
) )
var err error var err error
@@ -35,7 +34,7 @@ func connectSQLite() {
os.Exit(1) os.Exit(1)
} }
if config.InitDB == true { if config.InitDB == true {
Main.AutoMigrate(&model.Member{}, &model.Tweak{}) //Main.AutoMigrate(&model.Member{}, &model.Tweak{})
} }
} }
@@ -45,6 +44,6 @@ func connectPGSQL() {
panic(err) panic(err)
} }
if config.InitDB == true { if config.InitDB == true {
Main.AutoMigrate(&model.Member{}, &model.Tweak{}) //Main.AutoMigrate(&model.Member{}, &model.Tweak{})
} }
} }

View File

@@ -8,3 +8,7 @@
为了让实习成员熟悉每一个片区,每两周都会轮换值班每位成员负责值班的的片区,片区的轮换遵守上面的规则,也就是说,女生只会在女生片区内轮换,男生则会在全部的片区内轮换。 为了让实习成员熟悉每一个片区,每两周都会轮换值班每位成员负责值班的的片区,片区的轮换遵守上面的规则,也就是说,女生只会在女生片区内轮换,男生则会在全部的片区内轮换。
一个学期值班的周数不确定但是一般不会少于12周即可以轮换到每个成员。 一个学期值班的周数不确定但是一般不会少于12周即可以轮换到每个成员。
## 问题
排班算法现在是试图平均分配成员,可能会优化为根据片区的单子数量加权分配
对于每两周换片区的问题,我简单地对当值成员列表进行循环移位来解决,好像和目前的算法有点冲突,未来准备写一个测试来覆盖

4
go.mod
View File

@@ -18,7 +18,7 @@ require (
github.com/hashicorp/hcl v1.0.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.7.5 // indirect github.com/jackc/pgx/v5 v5.7.5
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
@@ -34,7 +34,7 @@ require (
github.com/sourcegraph/conc v0.3.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.11.0 // indirect github.com/spf13/afero v1.11.0 // indirect
github.com/spf13/cast v1.6.0 // indirect github.com/spf13/cast v1.6.0 // indirect
github.com/spf13/pflag v1.0.6 // indirect github.com/spf13/pflag v1.0.6
github.com/subosito/gotenv v1.6.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect

View File

@@ -3,21 +3,17 @@ package handler
import ( import (
//"fmt" //"fmt"
"errors" "errors"
"github.com/gocarina/gocsv" //"github.com/gocarina/gocsv"
"github.com/golang-module/carbon/v2" "github.com/golang-module/carbon/v2"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"net/http" "net/http"
"os" //"os"
"sync" //"sync"
"zsxyww.com/scheduler/config" "zsxyww.com/scheduler/config"
"zsxyww.com/scheduler/model" "zsxyww.com/scheduler/model"
"zsxyww.com/scheduler/signals" //"zsxyww.com/scheduler/signals"
) )
var data *[7][]*model.Member
var mutex sync.RWMutex //lock for data
var err error
// /api/getAssignment GET 获取当日值班表 // /api/getAssignment GET 获取当日值班表
// 接受参数date,是需要生成值班表的日期 // 接受参数date,是需要生成值班表的日期
func GetAssignment(i echo.Context) error { func GetAssignment(i echo.Context) error {
@@ -29,51 +25,40 @@ func GetAssignment(i echo.Context) error {
arg = carbon.Parse(date) arg = carbon.Parse(date)
} }
if (carbon.Now().ToDateString() != signals.Table.GetLastUpdated().ToDateString()) || signals.Table.IsNeedUpdate() == true { data, err := generateTable(arg)
mutex.Lock()
data, err = generateTable(arg)
mutex.Unlock()
if err != nil { if err != nil {
i.String(http.StatusInternalServerError, err.Error()) i.String(http.StatusInternalServerError, err.Error())
return echo.ErrInternalServerError return echo.ErrInternalServerError
} }
//signals.Table.SetUpdated(carbon.Now())
//测试时注释掉上面的状态更新方便调试
}
mutex.RLock()
i.JSON(200, data) i.JSON(200, data)
mutex.RUnlock()
return nil return nil
} }
// 根据指定的时间来生成对应的值班表 // 根据指定的时间来生成对应的值班表
func generateTable(time carbon.Carbon) (*[7][]*model.Member, error) { func generateTable(time carbon.Carbon) (*[7][]*model.Member, error) {
table := [7][]*model.Member{} //结果放入这里 table := [7][]*model.Member{} //结果放入这里
members := []*model.Member{} //包含所有成员信息的切片 members := []*model.Member{} //包含所有成员信息的切片
today := []*model.Member{} //今天值班的人 today := []*model.Member{} //今天值班的人
female := []*model.Member{} //今天的女生 female := []*model.Member{} //今天的女生
male := []*model.Member{} //今天的男生 male := []*model.Member{} //今天的男生
week, dayOfWeek := getWorkDay(time) week, dayOfWeek := getWorkDay(time)
//检查传入时间有没有问题 //检查传入时间有没有问题
//TODO:这里好像有bug对日期是否在值班时间内的判断部分,不过不怎么影响使用 //TODO:这里好像有bug对日期是否在值班时间内的判断部分,不过不怎么影响使用
if (week < 0) || (week > config.Default.Business.Week) { if (week < 0) || (week > config.Default.Business.Week) {
return nil, errors.New("日期错误,日期需要在本学期的值班日期内并且格式正确") return nil, errors.New("日期错误,日期需要在本学期的值班日期内并且格式正确")
} }
// 为了实现更换值班片区,写的一个闭包切片访问器 // 切片访问函数,用来实现自动更换值班片区的功能
iter := func(array []*model.Member, i int) *model.Member { iter := func(array []*model.Member, i int) *model.Member {
return array[(i+week)%len(array)] return array[(i+week)%len(array)]
} }
//读取csv文件 members = model.MemberList
err := readTableData(&members)
if err != nil {
return nil, err
}
//添加标题 //添加标题
table[0] = append(table[0], &model.Member{Name: "凤翔", Access: 7}) table[0] = append(table[0], &model.Member{Name: "凤翔", Access: 7})
table[1] = append(table[1], &model.Member{Name: "朝晖", Access: 7}) table[1] = append(table[1], &model.Member{Name: "朝晖", Access: 7})
@@ -98,32 +83,32 @@ func generateTable(time carbon.Carbon) (*[7][]*model.Member, error) {
} }
} }
//为女生分配负责人 //将所有正式女生分配到女生片区
for i := 0; i < len(female); i++ { for i := range female {
if a := iter(female, i); a.Access < model.FRESH { //是正式成员 if a := iter(female, i); a.Access < model.FRESH { //是正式成员
table[i%4] = append(table[i%4], a) //轮流分配到女生片区 table[i%4] = append(table[i%4], a) //轮流分配到女生片区
a.Arranged = true a.Arranged = true
} }
} }
//为剩下的片区分配负责人 //将所有正式男生分配到所有片区(优先分配人少的片区)
for i := 0; i < len(male); i++ { for i := range male {
if a := iter(male, i); a.Access < model.FRESH { //是正式成员 if a := iter(male, i); a.Access < model.FRESH { //是正式成员
table[fewest(table)] = append(table[fewest(table)], a) table[fewest(table)] = append(table[fewest(table)], a)
a.Arranged = true a.Arranged = true
} }
} }
//分配剩下的所有女生到女生片区 //分配剩下的所有女生到女生片区(优先分配人少的片区)
for i := 0; i < len(female); i++ { for i := range female {
if a := iter(female, i); a.Arranged != true { //还没有安排 if a := iter(female, i); a.Arranged != true { //还没有安排
table[fewestF(table)] = append(table[fewestF(table)], a) table[fewestF(table)] = append(table[fewestF(table)], a)
a.Arranged = true a.Arranged = true
} }
} }
//分配剩下的所有男生 //分配剩下的所有男生(优先分配人少的片区)
for i := 0; i < len(male); i++ { for i := range male {
if a := iter(male, i); a.Arranged == false { //还没有安排 if a := iter(male, i); a.Arranged == false { //还没有安排
table[fewest(table)] = append(table[fewest(table)], a) table[fewest(table)] = append(table[fewest(table)], a)
a.Arranged = true a.Arranged = true
@@ -133,24 +118,6 @@ func generateTable(time carbon.Carbon) (*[7][]*model.Member, error) {
return &table, nil return &table, nil
} }
// 读取csv文件
func readTableData(m *[]*model.Member) error {
data, err := os.OpenFile(config.Default.App.File, os.O_RDWR|os.O_CREATE, os.ModePerm)
if err != nil {
return err
}
defer data.Close()
err = gocsv.UnmarshalFile(data, m)
if err != nil {
return err
}
//for index, member := range *m {
// fmt.Printf("%v:%v\n", index, member) // for debug concerns
//}
return nil
}
// 找出人数最少的片区 // 找出人数最少的片区
func fewest(a [7][]*model.Member) int { func fewest(a [7][]*model.Member) int {
b := min(len(a[0]), len(a[1]), len(a[2]), len(a[3]), len(a[4]), len(a[5]), len(a[6])) b := min(len(a[0]), len(a[1]), len(a[2]), len(a[3]), len(a[4]), len(a[5]), len(a[6]))

1
handler/init.go Normal file
View File

@@ -0,0 +1 @@
package handler

1
handler/tweaks.go Normal file
View File

@@ -0,0 +1 @@
package handler

View File

@@ -1,46 +0,0 @@
// CRUD的基础操作
package uo
import (
"zsxyww.com/scheduler/database"
"zsxyww.com/scheduler/model"
)
// 增加一项tweak
func AddTweak(in *model.Tweak) error {
result := db.Main.Create(in)
return result.Error
}
// 删除一项tweak
func DeleteTweak(in *model.Tweak) error {
if db.Main.Error != nil {
return db.Main.Error
}
return nil
}
// 查询一些tweak通过IssueID
func GetTweakByIssueID(in *model.Tweak) (result []*model.Tweak, err error) {
if db.Main.Error != nil {
return nil, db.Main.Error
}
return nil, nil
}
// 查询一些tweak通过一个日期
func GetTweakByTime(in *model.Tweak) (result []*model.Tweak, err error) {
if db.Main.Error != nil {
return nil, db.Main.Error
}
return nil, nil
}
// 查询一些tweak通过一个工号
func GetTweakByID(in *model.Tweak) (result []*model.Tweak, err error) {
if db.Main.Error != nil {
return nil, db.Main.Error
}
return nil, nil
}

18
main.go
View File

@@ -2,10 +2,13 @@ package main
import ( import (
"fmt" "fmt"
"github.com/gocarina/gocsv"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"html/template" "html/template"
"os"
"zsxyww.com/scheduler/config" "zsxyww.com/scheduler/config"
"zsxyww.com/scheduler/database" "zsxyww.com/scheduler/database"
"zsxyww.com/scheduler/model"
"zsxyww.com/scheduler/route" "zsxyww.com/scheduler/route"
"zsxyww.com/scheduler/templates" "zsxyww.com/scheduler/templates"
) )
@@ -17,6 +20,7 @@ func main() {
app := echo.New() app := echo.New()
register(app) register(app)
csv()
listenAddress := fmt.Sprintf(":%d", config.Default.App.ListenPort) listenAddress := fmt.Sprintf(":%d", config.Default.App.ListenPort)
@@ -31,3 +35,17 @@ func register(app *echo.Echo) {
app.Renderer = renderer app.Renderer = renderer
} }
// 读取csv文件
func csv() {
data, err := os.OpenFile(config.Default.App.File, os.O_RDWR|os.O_CREATE, os.ModePerm)
if err != nil {
panic(err)
}
defer data.Close()
err = gocsv.UnmarshalFile(data, &model.MemberList)
if err != nil {
panic(err)
}
}

View File

@@ -20,3 +20,5 @@ const GROUP = 3 //组长
const FORMAL = 4 //正式成员 const FORMAL = 4 //正式成员
const FRESH = 5 //实习成员 const FRESH = 5 //实习成员
const PRE = 6 //前成员 const PRE = 6 //前成员
var MemberList []*Member

View File

@@ -3,6 +3,7 @@ package model
import ( import (
"gorm.io/gorm" "gorm.io/gorm"
"time" "time"
"zsxyww.com/scheduler/database"
) )
// 这个结构体是供数据库使用的表结构,换班补班蹭班的记录都会以这种方式储存 // 这个结构体是供数据库使用的表结构,换班补班蹭班的记录都会以这种方式储存
@@ -23,3 +24,40 @@ const (
OP_ADMIN_ADD = 4 OP_ADMIN_ADD = 4
OP_ADMIN_SUB = 5 OP_ADMIN_SUB = 5
) )
// 增加一项tweak
func (t *Tweak) addTweak() error {
result := db.Main.Create(t)
return result.Error
}
// 删除一项tweak
func (t *Tweak) deleteTweak() error {
result := db.Main.Delete(t)
return result.Error
}
// 查询一些tweak通过IssueID
func (t *Tweak) getTweakByIssueID() (result []*Tweak, err error) {
if db.Main.Error != nil {
return nil, db.Main.Error
}
return nil, nil
}
// 查询一些tweak通过一个日期
func (t *Tweak) getTweakByTime() (result []*Tweak, err error) {
if db.Main.Error != nil {
return nil, db.Main.Error
}
return nil, nil
}
// 查询一些tweak通过一个工号
func (t *Tweak) getTweakByID() (result []*Tweak, err error) {
if db.Main.Error != nil {
return nil, db.Main.Error
}
return nil, nil
}