Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions backend/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ require (
golang.org/x/crypto v0.32.0
)

require (
github.com/lestrrat-go/bufferpool v0.0.0-20180220091733-e7784e1b3e37 // indirect
github.com/pkg/errors v0.9.1 // indirect
)

require (
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
Expand Down
5 changes: 5 additions & 0 deletions backend/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,12 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lestrrat-go/bufferpool v0.0.0-20180220091733-e7784e1b3e37 h1:px5km9KhQGUKiPWIVZ++FErEMTd06XEuMi2OswGMrqI=
github.com/lestrrat-go/bufferpool v0.0.0-20180220091733-e7784e1b3e37/go.mod h1:vs3QXw2t0jsgjLEG7JZt0uE1jcSkxnQr+5bhQ80UJHE=
github.com/mozillazg/go-pinyin v0.20.0 h1:BtR3DsxpApHfKReaPO1fCqF4pThRwH9uwvXzm+GnMFQ=
github.com/mozillazg/go-pinyin v0.20.0/go.mod h1:iR4EnMMRXkfpFVV5FMi4FNB6wGq9NV6uDWbUuPhP4Yc=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
Expand All @@ -47,6 +51,7 @@ github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa
github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
Expand Down
5 changes: 5 additions & 0 deletions backend/internal/handler/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,4 +120,9 @@ func (h *Handler) RegisterRoutes() {
})
})
})

// iCalendar 订阅服务, iCalendar 协议只能是公开的, 所以不需要身份验证
h.Mux.Route("/", func(r chi.Router) {
r.Get("/{id}.ics", h.icalHandler)
})
}
109 changes: 109 additions & 0 deletions backend/internal/handler/ical.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package handler

import (
"net/http"
"slices"
"strconv"
"time"

"github.com/go-chi/chi/v5"
"github.com/sysu-ecnc-dev/shift-manager/backend/internal/domain"
)

func (h *Handler) icalHandler(w http.ResponseWriter, r *http.Request) {
userID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
h.internalServerError(w, r, err)
}

plans, err := h.repository.GetAllSchedulePlans()
if err != nil {
h.internalServerError(w, r, err)
}

var icsContent string = `BEGIN:VCALENDAR
CALSCALE:GREGORIAN
PRODID:SYSU-ECNC
VERSION:2.0
X-APPLE-CALENDAR-COLOR:#CC73E1
X-WR-CALNAME:ECNC值班日程
BEGIN:VTIMEZONE
TZID:Asia/Shanghai
BEGIN:STANDARD
DTSTART:19890917T020000
RRULE:FREQ=YEARLY;UNTIL=19910914T170000Z;BYMONTH=9;BYDAY=3SU
TZNAME:GMT+8
TZOFFSETFROM:+0900
TZOFFSETTO:+0800
END:STANDARD
BEGIN:DAYLIGHT
DTSTART:19910414T020000
RDATE:19910414T020000
TZNAME:GMT+8
TZOFFSETFROM:+0800
TZOFFSETTO:+0900
END:DAYLIGHT
END:VTIMEZONE`

for _, plan := range plans {
res, err := h.repository.GetSchedulingResultBySchedulePlanID(plan.ID)
if err != nil {
h.internalServerError(w, r, err)
}

template, err := h.repository.GetScheduleTemplate(plan.ScheduleTemplateID)
if err != nil {
h.internalServerError(w, r, err)
}

shifts := template.Shifts
shiftMap := make(map[int64]*domain.ScheduleTemplateShift)
for _, shift := range shifts {
shiftMap[shift.ID] = &shift
}

for _, resultShift := range res.Shifts {
for _, item := range resultShift.Items {
if (item.PrincipalID != nil && *item.PrincipalID == userID) || slices.Contains(item.AssistantIDs, userID) {
icsContent += "\nBEGIN:VEVENT"
if *item.PrincipalID == userID {
icsContent += "\nDESCRIPTION:负责人岗值班"
} else {
icsContent += "\nDESCRIPTION:普通助理岗值班"
}
templateShift := shiftMap[resultShift.ShiftID]
firstTime := plan.ActiveStartTime
for {
if firstTime.Weekday() == time.Weekday(item.Day-1) {
break
} else {
firstTime = firstTime.Add(24 * time.Hour)
}
}
startTime, err := time.Parse("15:04:05", templateShift.StartTime)
if err != nil {
h.internalServerError(w, r, err)
}
endTime, err := time.Parse("15:04:05", templateShift.EndTime)
if err != nil {
h.internalServerError(w, r, err)
}
icsContent += "\nDTEND;TZID=Asia/Shanghai:" + firstTime.Add(time.Duration(endTime.Hour())*time.Hour).Format("20060102T150405")
icsContent += "\nDTSTAMP:" + plan.CreatedAt.Format("20060102T150405") + "Z"
icsContent += "\nDTSTART;TZID=Asia/Shanghai:" + firstTime.Add(time.Duration(startTime.Hour())*time.Hour).Format("20060102T150405")
icsContent += "\nLAST-MODIFIED:" + time.Now().Format("20060102T150405") + "Z"
icsContent += "\nLOCATION:中山大学东校园"
icsContent += "\nRRULE:FREQ=WEEKLY;UNTIL=" + plan.ActiveEndTime.Format("20060102T150405") + "Z"
icsContent += "\nSEQUENCE:0"
icsContent += "\nSUMMARY:ECNC值班日程"
icsContent += "\nTRANSP:OPAQUE"
icsContent += "\nUID:SYSU-ECNC-" + strconv.FormatInt(userID, 16) + "-" + strconv.FormatInt(resultShift.ShiftID, 16) + "-" + strconv.FormatInt(int64(item.Day), 16)
icsContent += "\nEND:VEVENT"
}
}
}
}
w.Header().Set("Content-Type", "text/calendar")
w.Header().Set("Content-Disposition", "inline; filename=calendar.ics")
w.Write(([]byte)(icsContent + "\nEND:VCALENDAR"))
}
34 changes: 32 additions & 2 deletions frontend/src/routes/_dashboard/index.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,46 @@
import { getMyInfo } from "@/lib/api";
import { useQuery } from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";

import { toast } from "sonner";
export const Route = createFileRoute("/_dashboard/")({
component: RouteComponent,
});

function RouteComponent() {
const {
data: myInfo,
isPending,
isError,
error,
} = useQuery({
queryKey: ["my-info"],
queryFn: () => getMyInfo().then((res) => res.data.data),
});

if (isPending) return null;

if (isError) {
toast.error(error.message);
return null;
}

const icsURL = "https://" + import.meta.env.DOMAIN + "/api/" + myInfo.id + ".ics"

return (
<div className="px-4 flex flex-col gap-2 mt-8">
<div className="px-4 flex flex-col gap-2 mt-8 items-center">
<h1 className="text-2xl font-bold">主页</h1>
<span className="text-sm text-muted-foreground">
如果你看到这里一片空白,不用担心,目前主页没有任何内容 :)
</span>
<span className="text-sm text-muted-foreground">
临时将值班日程订阅入口放在这里,这个链接可以直接下载 .ics 日历数据交换标准文件
</span>
<span className="text-sm text-muted-foreground">
可以在日历软件中导入,也可以在日历软件中订阅这个链接,推荐在手机原生日历软件导入或订阅
</span>
<a href={icsURL}>
{icsURL}
</a>
</div>
);
}
7 changes: 6 additions & 1 deletion frontend/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import viteReact from "@vitejs/plugin-react";
// https://vite.dev/config/
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd());
const API_URL = env.VITE_API_URL || "http://localhost:3000";
const DOMAIN = env.VITE_DOMAIN || "localhost:5173";

return {
plugins: [TanStackRouterVite(), viteReact()],
Expand All @@ -17,11 +19,14 @@ export default defineConfig(({ mode }) => {
server: {
proxy: {
"/api": {
target: env.VITE_API_URL,
target: API_URL,
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ""),
},
},
},
define: {
'import.meta.env.DOMAIN': JSON.stringify(DOMAIN),
}
};
});