This commit is contained in:
Clas Wen 2025-05-13 22:00:08 +08:00
parent 0c7a5a9578
commit d782137f81
11 changed files with 407 additions and 54 deletions

View file

@ -27,6 +27,7 @@
"@logto/browser": "^2.2.18",
"@stylistic/eslint-plugin": "^2.13.0",
"astro": "^4.16.18",
"dayjs": "^1.11.13",
"framer-motion": "^11.18.2",
"ical.js": "^1.5.0",
"md5": "^2.3.0",

View file

@ -47,6 +47,9 @@ importers:
astro:
specifier: ^4.16.18
version: 4.16.18(@types/node@22.14.1)(rollup@4.40.0)(typescript@5.8.3)
dayjs:
specifier: ^1.11.13
version: 1.11.13
framer-motion:
specifier: ^11.18.2
version: 11.18.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@ -3096,6 +3099,9 @@ packages:
resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==}
engines: {node: '>= 0.4'}
dayjs@1.11.13:
resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==}
debug@3.2.7:
resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==}
peerDependencies:
@ -9642,6 +9648,8 @@ snapshots:
es-errors: 1.3.0
is-data-view: 1.0.2
dayjs@1.11.13: {}
debug@3.2.7:
dependencies:
ms: 2.1.3

View file

@ -0,0 +1,17 @@
---
import NavigationUser from "./NavigationUser.vue"
---
<script>
const button = document.getElementById("repair-header")
button.addEventListener("click", () => {
window.location.href = "/repair"
})
</script>
<div class="box-border border-b sticky top-0 bg-white/80 backdrop-blur z-20 h-12">
<div class="h-full flex items-center justify-between text-lg max-w-[1024px] mx-auto px-[22px]">
<span id="repair-header" class="font-semibold select-none">维修</span>
<NavigationUser client:load />
</div>
</div>

View file

@ -0,0 +1,163 @@
import { useEffect, useState } from "react"
import type { components } from "../../types/saturday"
import { saturdayClient } from "../../utils/client"
import { Textarea, Input, Chip } from "@heroui/react"
import type { PublicMember } from "../../store/member"
import dayjs from "dayjs"
import { EventStatus, UserEventAction } from "../../types/event"
type PublicEvent = components["schemas"]["PublicEvent"]
type EventLog = components["schemas"]["EventLog"]
function EventLogItem(props: {
eventLog: EventLog
actor?: PublicMember
}) {
return (
<div className="py-1 flex items-center">
{/* <div className="mr-4 h-10 bg-red-400 flex flex-col items-center gap-2">
</div> */}
<div>
<div className="flex items-center">
<div className="mr-4">
{
UserEventAction.find(v => v.action === props.eventLog.action)?.text || props.eventLog.action
}
</div>
<div className="flex items-center">
{
props.actor?.avatar
? <img src={props.actor?.avatar} alt="actor avatar" className="w-6 aspect-square rounded-full" />
: <></>
}
<span className="text-gray-600 ml-2">
{
props.actor ? props.actor.alias : ""
}
</span>
</div>
</div>
<div className="flex gap-2 items-center mt-1 text-gray-600">
{dayjs(props.eventLog.gmtCreate).format("YYYY-MM-DD HH:mm")}
</div>
</div>
</div>
)
}
function EventStatusChip(props: {
status: string
}) {
switch (props.status) {
case EventStatus.open:
return <Chip></Chip>
case EventStatus.accepted:
return <Chip></Chip>
case EventStatus.committed:
return <Chip color="primary"></Chip>
case EventStatus.closed:
return <Chip color="success"></Chip>
case EventStatus.cancelled:
return <Chip></Chip>
}
}
const filterEventLog = (event: PublicEvent) => {
const eventLogs = event.logs
const filteredLogs: (EventLog & { actor?: PublicMember })[] = []
// find the first log that action is "create"
const createLog = eventLogs.find(log => log.action === "create")
filteredLogs.push(createLog)
// find the first log that action is "cancel"
const cancelLog = eventLogs.find(log => log.action === "cancel")
if (cancelLog) {
filteredLogs.push(cancelLog)
return filteredLogs
}
// find the last log that action is "accept"
const acceptLog = eventLogs.findLast(log => log.action === "accept")
if (acceptLog) {
filteredLogs.push({
...acceptLog,
actor: event.member,
})
}
// find the last log that action is "close"
const closeLog = eventLogs.findLast(log => log.action === "close")
if (closeLog) {
filteredLogs.push({
...closeLog,
actor: event.closedBy,
})
}
return filteredLogs
}
export default function EventDetail() {
const [event, setEvent] = useState<PublicEvent | undefined>()
const fetchAndSetEvent = async (eventId: number) => {
const { data } = await saturdayClient.GET("/events/{EventId}", {
params: {
path: {
EventId: eventId,
},
},
})
setEvent(data)
}
useEffect(() => {
// get the eventId from the url
const url = new URL(window.location.href)
const eventId = url.searchParams.get("eventId")
if (!eventId) {
return
}
fetchAndSetEvent(eventId as unknown as number)
}, [])
return (
event
? (
<section className="box-border mb-24">
<div className="section-content mt-8">
<h2 className="text-2xl font-bold"></h2>
<div className="flex gap-2 items-center">
<span>
#{event.eventId}
</span>
<EventStatusChip status={event.status}></EventStatusChip>
</div>
</div>
<div className="section-content my-8 flex flex-col gap-4">
<Textarea
label="问题描述"
readOnly
name="description"
value={event.problem || ""}
/>
<Input
label="型号"
type="text"
value={event.model || ""}
readOnly
>
</Input>
<div className="bg-gray-100 rounded-xl text-sm px-3 py-2 mt-2 ">
<div className="text-xs font-semibold text-gray-600 mb-1">
</div>
{
filterEventLog(event).map((v, index) => {
return (
<EventLogItem key={index} actor={v.actor} eventLog={v} />
)
})
}
</div>
</div>
</section>
)
: <div></div>
)
}

View file

@ -1,10 +0,0 @@
---
import NavigationUser from "../../components/header/NavigationUser.vue"
---
<div class="box-border border-b sticky top-0 bg-white/80 backdrop-blur z-20 h-12">
<div class="h-full flex items-center justify-between text-lg max-w-[1024px] mx-auto px-[22px]">
<span class="font-semibold">维修</span>
<NavigationUser client:load />
</div>
</div>

View file

@ -3,49 +3,28 @@ import { makeLogtoClient } from "../../utils/auth"
import type { UserInfoResponse } from "@logto/browser"
import { Form, Input, Button, Textarea } from "@heroui/react"
import { saturdayClient } from "../../utils/client"
import type LogtoClient from "@logto/browser"
import type { components } from "../../types/saturday"
import QRCode from "qrcode"
import EventDetail from "./EventDetail"
export default function App() {
const [userInfo, setUserInfo] = useState<UserInfoResponse>()
const [formData, setFormData] = useState<{
type TicketFormData = {
model?: string
phone?: string
qq?: string
description?: string
}>({})
let logtoClient: LogtoClient | undefined = undefined
useEffect(() => {
const check = async () => {
logtoClient = makeLogtoClient()
const createRepairPath = "/repair/create-ticket"
const authenticated = await logtoClient.isAuthenticated()
if (!authenticated) {
window.location.href = `/repair/login-hint?redirectUrl=${createRepairPath}`
return
}
const res = await logtoClient.getIdTokenClaims()
setUserInfo(res)
}
check()
}, [])
function TicketForm(props: {
userInfo: UserInfoResponse
onSubmit: (form: TicketFormData) => Promise<void>
}) {
const [loading, setLoading] = useState<boolean>()
const [formData, setFormData] = useState<TicketFormData>({})
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
const logtoToken = await logtoClient.getAccessToken()
await saturdayClient.POST("/client/event", {
headers: {
Authorization: `Bearer ${logtoToken}`,
},
body: {
Problem: formData.description,
model: formData.model,
phone: formData.phone,
qq: formData.qq,
},
})
setLoading(true)
await props.onSubmit(formData)
setLoading(false)
}
return (
@ -96,7 +75,7 @@ export default function App() {
placeholder="example@nbtca.space"
isRequired
type="email"
value={userInfo?.email || ""}
value={props.userInfo?.email || ""}
readOnly
description="我们会向此邮箱发送维修相关的通知"
/>
@ -108,6 +87,7 @@ export default function App() {
type="phone"
name="phone"
value={formData.phone || ""}
maxLength={11}
onChange={(e) => {
setFormData({ ...formData, phone: e.target.value })
}}
@ -120,7 +100,7 @@ export default function App() {
setFormData({ ...formData, qq: e.target.value })
}}
/>
<Button type="submit" color="primary" className="my-4 w-full">
<Button type="submit" color="primary" className="my-4 w-full" isLoading={loading}>
</Button>
</Form>
@ -128,3 +108,107 @@ export default function App() {
</section>
)
}
type PublicEvent = components["schemas"]["PublicEvent"]
function TicketFormCreated(props: {
event: PublicEvent
}) {
const [svg, setSvg] = useState<string>()
useEffect(() => {
QRCode.toString(props.event.eventId.toString()).then((res) => {
// const root = createRoot(
// document.getElementById("svg-root"),
// )
// root.render(res)
setSvg(res)
})
})
// const svg = ref()
// watch(id, async () => {
// if (!id.value) {
// return
// }
// svg.value = await QRCode.toString(useGraduationIdURL().constructURL(id.value))
// })
return (
<div className="w-full h-[80vh] flex flex-col justify-between items-center">
<div className=" flex flex-col sm:flex-row items-center h-full max-w-[1280px] justify-center gap-8 p-8">
<div className="min-h-64 h-[36vh] aspect-square">
<div className="h-full" dangerouslySetInnerHTML={{ __html: svg }}></div>
</div>
<div
className=" sm:w-auto"
>
<div className="flex items-center gap-2">
<div className="text-brand text-nowrap text-3xl font-bold">
</div>
</div>
<div className="sm:w-[30vw] mt-6 text-gray-600 lg:text-lg">
<div>
</div>
<div>
</div>
</div>
</div>
</div>
</div>
// <div>{props.event.eventId}</div>
)
}
export default function App() {
const [userInfo, setUserInfo] = useState<UserInfoResponse>()
const [event, setEvent] = useState<PublicEvent | undefined>({
eventId: 123,
})
useEffect(() => {
const check = async () => {
const createRepairPath = "/repair/create-ticket"
const authenticated = await makeLogtoClient().isAuthenticated()
if (!authenticated) {
window.location.href = `/repair/login-hint?redirectUrl=${createRepairPath}`
return
}
const res = await makeLogtoClient().getIdTokenClaims()
setUserInfo(res)
}
check()
}, [])
const onSubmit = async (formData: TicketFormData) => {
const logtoToken = await makeLogtoClient().getAccessToken()
try {
const res = await saturdayClient.POST("/client/event", {
headers: {
Authorization: `Bearer ${logtoToken}`,
},
body: {
Problem: formData.description,
model: formData.model,
phone: formData.phone,
qq: formData.qq,
},
})
setEvent(res.data as unknown as PublicEvent)
}
catch (error) {
console.log(error)
}
}
return (
<>
<EventDetail />
(
event?.eventId
? <TicketFormCreated event={event}></TicketFormCreated>
: <TicketForm userInfo={userInfo} onSubmit={onSubmit}></TicketForm>
)
</>
)
}

View file

@ -1,6 +1,6 @@
---
import BaseLayout from "../../layouts/BaseLayout.astro"
import RepairHeader from "./RepairHeader.astro"
import RepairHeader from "../../components/header/RepairHeader.astro"
import TicketForm from "./TicketForm"
---

View file

@ -3,7 +3,7 @@ import BaseLayout from "../../layouts/BaseLayout.astro"
import hayasaka from "../_assets/hayasaka.jpg"
import repairDayOnSite from "../_assets/repair_day_on_site.jpeg"
import { Button } from "@heroui/react"
import RepairHeader from "./RepairHeader.astro";
import RepairHeader from "../../components/header/RepairHeader.astro"
---

View file

@ -1,6 +1,6 @@
---
import BaseLayout from "../../layouts/BaseLayout.astro"
import RepairHeader from "./RepairHeader.astro"
import RepairHeader from "../../components/header/RepairHeader.astro"
import { Button } from "@heroui/react"
---

View file

@ -0,0 +1,12 @@
---
import BaseLayout from "../../layouts/BaseLayout.astro"
import RepairHeader from "../../components/header/RepairHeader.astro"
import EventDetail from "./EventDetail"
---
<BaseLayout primaryTitle="Create Ticket">
<RepairHeader></RepairHeader>
<div>
<EventDetail client:only="react"></EventDetail>
</div>
</BaseLayout>

78
src/types/event.ts Normal file
View file

@ -0,0 +1,78 @@
export interface Status {
status: string
text: string
icon: string
}
export interface Action {
action: string
text: string
icon: string
}
export enum EventStatus {
open = "open",
accepted = "accepted",
committed = "committed",
closed = "closed",
cancelled = "cancelled",
}
export const UserEventStatus: Status[] = [
{
status: EventStatus.open,
text: "待处理",
icon: "status_initial.svg",
},
{
status: EventStatus.accepted,
text: "维修中",
icon: "status_ongoing.svg",
},
{
status: EventStatus.committed,
text: "维修中",
icon: "status_ongoing.svg",
},
{
status: EventStatus.closed,
text: "已完成",
icon: "status_complete.svg",
},
{
status: EventStatus.cancelled,
text: "已取消",
icon: "status_cancelled.svg",
},
]
export enum EventAction {
create = "create",
accept = "accept",
commit = "commit",
close = "close",
cancel = "cancel",
}
export const UserEventAction: Action[] = [
{
action: EventAction.create,
text: "事件创建",
icon: "event_create.svg",
},
{
action: EventAction.accept,
text: "维修开始",
icon: "status_ongoing.svg",
},
{
action: EventAction.close,
text: "维修完成",
icon: "status_complete.svg",
},
{
action: EventAction.cancel,
text: "事件取消",
icon: "status_cancelled.svg",
},
]