add event actions

This commit is contained in:
Clas Wen 2025-05-24 12:40:25 +08:00
parent bd38b8ed2b
commit 33d93d0347
5 changed files with 487 additions and 203 deletions

View file

@ -0,0 +1,288 @@
import type { UserInfoResponse } from "@logto/browser"
import type { PublicMember } from "../../store/member"
import { EventStatus, type PublicEvent } from "../../types/event"
import { saturdayApiBaseUrl } from "../../utils/client"
import { Button, Form, Select, SelectItem, Textarea } from "@heroui/react"
import { useState } from "react"
export type IdentityContext = {
member: PublicMember
userInfo: UserInfoResponse
token: string
}
enum RepairRole {
repairAdmin = "repair admin",
repairMember = "repair member",
}
export type EventActionProps = {
event: PublicEvent
identityContext: IdentityContext
isLoading?: string
onUpdated: (event: PublicEvent) => void
onLoading: (loadingAction?: string) => void
}
const EventSizeOptions: {
size: string
description?: string
}[] = [
{ size: "xs", description: "无需工具,仅简单排查或软件层级操作" },
{ size: "s", description: "简单拆装部件,操作快,风险低" },
{ size: "m", description: "需基本工具、一定技术判断,时间较长" },
{ size: "l", description: "较复杂的拆装和测试流程,需熟练技能、多人协作可能" },
{ size: "xl", description: "工作量极大,涉及多个设备,需团队作业和详细记录" },
]
const EventActionCommitForm = (props: {
formData: {
size: string
description: string
}
setFormData: (data: {
size: string
description: string
}) => void
}) => {
const { formData, setFormData } = props
return (
<Form>
<Select
items={EventSizeOptions}
label="维修难度"
size="sm"
value={formData.size}
onChange={(value) => {
setFormData({ ...formData, size: value.target.value.split(",")[0] })
}}
placeholder="请选择维修难度"
>
{
size => (
<SelectItem key={size.size} textValue={"size:" + size.size}>
<div className="flex gap-2 items-center">
<div className="flex flex-col">
<span className="text-small">{size.size}</span>
<span className="text-tiny text-default-400">{size.description}</span>
</div>
</div>
</SelectItem>
)
}
</Select>
<Textarea
label="维修描述"
placeholder="请输入维修描述"
errorMessage="维修描述不能为空"
required
name="description"
value={formData.description || ""}
onChange={(e) => {
setFormData({ ...formData, description: e.target.value })
}}
isRequired
rows={3}
/>
</Form>
)
}
export const EventActionCommit = (props: EventActionProps) => {
const [formData, setFormData] = useState({
size: "",
description: "",
})
const onSubmit = async () => {
props.onLoading("commit")
const res = await fetch(`${saturdayApiBaseUrl}/member/events/${props.event.eventId}/commit`, {
method: "POST",
headers: {
Authorization: `Bearer ${props.identityContext.token}`,
},
body: JSON.stringify({
size: formData.size,
problem: formData.description,
}),
}).then(res => res.json())
props.onLoading()
return props.onUpdated(res)
}
return (
<div className="flex flex-col gap-4">
<EventActionCommitForm
formData={formData}
setFormData={setFormData}
>
</EventActionCommitForm>
<Button
variant="flat"
isLoading={props.isLoading === "commit"}
onPress={() => onSubmit()}
>
</Button>
</div>
)
}
export const EventActionAlterCommit = (props: EventActionProps) => {
const [formData, setFormData] = useState({
size: props.event.size,
description: props.event.problem,
})
const onSubmit = async () => {
props.onLoading("alterCommit")
const res = await fetch(`${saturdayApiBaseUrl}/member/events/${props.event.eventId}/commit`, {
method: "PATCH",
headers: {
Authorization: `Bearer ${props.identityContext.token}`,
},
body: JSON.stringify({
size: formData.size,
problem: formData.description,
}),
}).then(res => res.json())
props.onLoading()
return props.onUpdated(res)
}
return (
<div className="flex flex-col gap-4">
<EventActionCommitForm
formData={formData}
setFormData={setFormData}
>
</EventActionCommitForm>
<Button
variant="flat"
isLoading={props.isLoading === "commit"}
onPress={() => onSubmit()}
>
</Button>
</div>
)
}
type CommonHandler = () => Promise<unknown>
type JsxHandler = (props: EventActionProps) => JSX.Element
export type EventAction = {
action: string
label?: string
handler?: CommonHandler
jsxHandler: JsxHandler
}
export const getAvailableEventActions = (event: PublicEvent, identityContext: IdentityContext) => {
console.log("getting event actions", event, identityContext)
const actions: EventAction[] = []
const makeCommonJsxHandler = (action: Omit<EventAction, "jsxHandler">) => {
return (props: EventActionProps) => {
const onAction = async (action: {
action: string
handler?: CommonHandler
}) => {
props.onLoading(action.action)
if (action.handler) {
const res = await action.handler()
props.onUpdated(res as PublicEvent)
}
props.onLoading()
}
return (
<div className="flex flex-col">
<Button
variant="flat"
isLoading={props.isLoading === action.action}
isDisabled={props.isLoading}
onPress={() => onAction(action)}
>
{action.label ?? action.action}
</Button>
</div>
)
}
}
if (event.status == EventStatus.open) {
actions.push({
action: "accept",
jsxHandler: makeCommonJsxHandler({
action: "accept",
label: "接受",
handler: async () => {
return await fetch(`${saturdayApiBaseUrl}/member/events/${event.eventId}/accept`, {
method: "POST",
headers: {
Authorization: `Bearer ${identityContext.token}`,
},
}).then(res => res.json())
},
}),
})
}
else if (event.status == EventStatus.accepted && event.member?.memberId == identityContext.member.memberId) {
actions.push({
action: "commit",
jsxHandler: EventActionCommit,
})
actions.push({
action: "drop",
jsxHandler: makeCommonJsxHandler({
action: "drop",
label: "放弃",
handler: async () => {
return await fetch(`${saturdayApiBaseUrl}/member/events/${event.eventId}/accept`, {
method: "DELETE",
headers: {
Authorization: `Bearer ${identityContext.token}`,
},
}).then(res => res.json())
},
}),
})
}
else if (event.status == EventStatus.committed) {
if (event.member?.memberId == identityContext.member.memberId) {
actions.push({
action: "alterCommit",
jsxHandler: EventActionAlterCommit,
})
}
if (identityContext.userInfo.roles.find(role => role.toLocaleLowerCase() == RepairRole.repairAdmin)) {
actions.push({
action: "close",
jsxHandler: makeCommonJsxHandler({
action: "close",
label: "关闭",
handler: async () => {
return await fetch(`${saturdayApiBaseUrl}/events/${event.eventId}/close`, {
method: "POST",
headers: {
Authorization: `Bearer ${identityContext.token}`,
},
}).then(res => res.json())
},
}),
})
actions.push({
action: "reject",
jsxHandler: makeCommonJsxHandler({
action: "rejectCommit",
label: "退回",
handler: async () => {
return await fetch(`${saturdayApiBaseUrl}/events/${event.eventId}/commit`, {
method: "DELETE",
headers: {
Authorization: `Bearer ${identityContext.token}`,
},
}).then(res => res.json())
},
}),
})
}
}
return actions
}

View file

@ -1,4 +1,4 @@
import { useEffect, useState } from "react"
import { forwardRef, useEffect, useImperativeHandle, useState } from "react"
import type { components } from "../../types/saturday"
import { saturdayClient } from "../../utils/client"
import { Textarea, Input, Chip } from "@heroui/react"
@ -15,8 +15,6 @@ function EventLogItem(props: {
}) {
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">
@ -55,9 +53,9 @@ export function EventStatusChip(props: {
case EventStatus.open:
return <Chip size={size}></Chip>
case EventStatus.accepted:
return <Chip size={size}></Chip>
case EventStatus.committed:
return <Chip size={size} color="primary"></Chip>
case EventStatus.committed:
return <Chip size={size} color="secondary"></Chip>
case EventStatus.closed:
return <Chip size={size} color="success"></Chip>
case EventStatus.cancelled:
@ -95,11 +93,16 @@ const filterEventLog = (event: PublicEvent) => {
}
return filteredLogs
}
export default function EventDetail(props: {
export type EventDetailRef = {
refresh: () => Promise<PublicEvent | undefined>
}
const EventDetail = forwardRef<EventDetailRef, {
eventId?: number
}) {
onRefresh?: () => void
action?: React.ReactNode
}>((props, ref) => {
const [event, setEvent] = useState<PublicEvent | undefined>()
const fetchAndSetEvent = async (eventId: number) => {
const { data } = await saturdayClient.GET("/events/{EventId}", {
params: {
@ -109,21 +112,33 @@ export default function EventDetail(props: {
},
})
setEvent(data)
return data
}
useEffect(() => {
const refresh = async () => {
const url = new URL(window.location.href)
const eventId = props.eventId ?? url.searchParams.get("eventId")
if (!eventId) {
return
console.log("refresh eventId", eventId)
if (eventId) {
return await fetchAndSetEvent(eventId as unknown as number)
}
fetchAndSetEvent(eventId as unknown as number)
}
// 初次加载
useEffect(() => {
refresh()
}, [])
// 暴露给父组件的方法
useImperativeHandle(ref, () => ({
refresh,
}))
return (
event
? (
<section className="box-border mb-24">
<div className="mt-8">
<section className="box-border">
<div className="">
<h2 className="text-2xl font-bold"></h2>
<div className="flex gap-2 items-center">
<span>
@ -132,7 +147,7 @@ export default function EventDetail(props: {
<EventStatusChip status={event.status}></EventStatusChip>
</div>
</div>
<div className="my-8 flex flex-col gap-4">
<div className="my-6 flex flex-col gap-4">
<Textarea
label="问题描述"
readOnly
@ -163,4 +178,6 @@ export default function EventDetail(props: {
)
: <div></div>
)
}
})
export default EventDetail

View file

@ -20,18 +20,21 @@ import {
DrawerBody,
DrawerFooter,
useDisclosure,
Chip,
} from "@heroui/react"
import { useCallback, useEffect, useMemo, useState } from "react"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useAsyncList } from "@react-stately/data"
import type { components } from "../../types/saturday"
import { saturdayClient } from "../../utils/client"
import EventDetail, { EventStatusChip } from "./EventDetail"
import { saturdayApiBaseUrl, saturdayClient } from "../../utils/client"
import EventDetail, { EventStatusChip, type EventDetailRef } from "./EventDetail"
import dayjs from "dayjs"
import { UserEventStatus } from "../../types/event"
import { makeLogtoClient } from "../../utils/auth"
import type { PublicMember } from "../../store/member"
import type { UserInfoResponse } from "@logto/browser"
import { getAvailableEventActions, type EventAction, type IdentityContext } from "./EventAction"
type PublicEvent = components["schemas"]["PublicEvent"]
// type EventLog = components["schemas"]["EventLog"]
export const EyeIcon = (props) => {
return (
@ -63,106 +66,10 @@ export const EyeIcon = (props) => {
)
}
export const DeleteIcon = (props) => {
return (
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="1em"
role="presentation"
viewBox="0 0 20 20"
width="1em"
{...props}
>
<path
d="M17.5 4.98332C14.725 4.70832 11.9333 4.56665 9.15 4.56665C7.5 4.56665 5.85 4.64998 4.2 4.81665L2.5 4.98332"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
/>
<path
d="M7.08331 4.14169L7.26665 3.05002C7.39998 2.25835 7.49998 1.66669 8.90831 1.66669H11.0916C12.5 1.66669 12.6083 2.29169 12.7333 3.05835L12.9166 4.14169"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
/>
<path
d="M15.7084 7.61664L15.1667 16.0083C15.075 17.3166 15 18.3333 12.675 18.3333H7.32502C5.00002 18.3333 4.92502 17.3166 4.83335 16.0083L4.29169 7.61664"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
/>
<path
d="M8.60834 13.75H11.3833"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
/>
<path
d="M7.91669 10.4167H12.0834"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
/>
</svg>
)
}
export const EditIcon = (props) => {
return (
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="1em"
role="presentation"
viewBox="0 0 20 20"
width="1em"
{...props}
>
<path
d="M11.05 3.00002L4.20835 10.2417C3.95002 10.5167 3.70002 11.0584 3.65002 11.4334L3.34169 14.1334C3.23335 15.1084 3.93335 15.775 4.90002 15.6084L7.58335 15.15C7.95835 15.0834 8.48335 14.8084 8.74168 14.525L15.5834 7.28335C16.7667 6.03335 17.3 4.60835 15.4583 2.86668C13.625 1.14168 12.2334 1.75002 11.05 3.00002Z"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeMiterlimit={10}
strokeWidth={1.5}
/>
<path
d="M9.90833 4.20831C10.2667 6.50831 12.1333 8.26665 14.45 8.49998"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeMiterlimit={10}
strokeWidth={1.5}
/>
<path
d="M2.5 18.3333H17.5"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeMiterlimit={10}
strokeWidth={1.5}
/>
</svg>
)
}
function CheckboxPopover(props: {
value: string[]
onValueChange: (value: string[]) => void
}) {
// const [selectedValues, setSelectedValues] = useState([])
// const handleSelectionChange = (values) => {
// setSelectedValues(values)
// }
return (
<Popover placement="bottom">
<PopoverTrigger>
@ -196,23 +103,67 @@ function CheckboxPopover(props: {
}
function TicketDetailDrawer(props: {
eventId: number
event: PublicEvent
identity: IdentityContext
isOpen: boolean
onEventUpdated: (event: PublicEvent) => void
onOpenChange: (isOpen: boolean) => void
onClose: () => void
onDelete: () => void
onEdit: () => void
}) {
const { isOpen, onOpenChange, onClose } = props
const [isLoading, setIsLoading] = useState("")
const eventDetailRef = useRef<EventDetailRef>(null)
const [availableActions, setAvailableActions] = useState<EventAction[]>([])
useEffect(() => {
if (!props.event || !props.identity?.member || !props.identity?.userInfo?.roles) {
return
}
setAvailableActions(getAvailableEventActions(props.event, props.identity))
}, [props.event, props.identity])
const onEventUpdated = async (event: PublicEvent) => {
props.onEventUpdated(event)
const res = await eventDetailRef.current?.refresh()
console.log("onEventUpdated", res)
if (!res || !props.identity?.member || !props.identity?.userInfo?.roles) {
return
}
setAvailableActions(getAvailableEventActions(res, props.identity))
}
return (
<Drawer isOpen={isOpen} onOpenChange={onOpenChange}>
<DrawerContent>
<DrawerHeader>
<h2 className="text-2xl font-bold"></h2>
{isLoading}
</DrawerHeader>
<DrawerBody>
<EventDetail eventId={props.eventId}></EventDetail>
<EventDetail ref={eventDetailRef} eventId={props.event?.eventId}></EventDetail>
<div className="mb-12 flex flex-col gap-2">
{
availableActions?.map((action) => {
return (
<action.jsxHandler
key={action.action}
event={props.event}
isLoading={isLoading}
identityContext={props.identity}
onUpdated={onEventUpdated}
onLoading={(action) => {
setIsLoading(action)
}}
>
</action.jsxHandler>
)
})
}
</div>
</DrawerBody>
<DrawerFooter>
<Button variant="flat" onPress={onClose}>
@ -232,11 +183,12 @@ export const validateRepairRole = (roles: string[]) => {
export default function App() {
const [isLoading, setIsLoading] = useState(true)
const [page, setPage] = useState(1)
const rowsPerPage = 15
// const [events, setEvents] = useState<PublicEvent[]>([])
const rowsPerPage = 10
const [statusFilter, setStatusFilter] = useState<string[]>([])
const { isOpen, onOpen, onOpenChange } = useDisclosure()
// const [userInfo, setUserInfo] = useState<UserInfoResponse>()
const [userInfo, setUserInfo] = useState<UserInfoResponse>()
const [currentMember, setCurrentMember] = useState<PublicMember>()
const [token, setToken] = useState<string>()
useEffect(() => {
const check = async () => {
@ -247,27 +199,25 @@ export default function App() {
return
}
const res = await makeLogtoClient().getIdTokenClaims()
const token = await makeLogtoClient().getAccessToken()
setToken(token)
const hasRole = validateRepairRole(res.roles)
if (!hasRole) {
window.location.href = `/repair/login-hint?redirectUrl=${adminPath}`
return
}
// setUserInfo(res)
setUserInfo(res)
const currentMember = await fetch(`${saturdayApiBaseUrl}/member`, {
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
},
}).then(res => res.json())
setCurrentMember(currentMember)
}
check()
}, [])
// const fetchAndSetEvent = async () => {
// const { data } = await saturdayClient.GET("/events", {
// params: {
// query: {
// order: "DESC",
// offset: 1,
// limit: 1000,
// },
// },
// })
// setEvents(data)
// }
const list = useAsyncList<PublicEvent>({
async load() {
@ -321,11 +271,17 @@ export default function App() {
const pages = useMemo(() => {
return Math.ceil(filteredList.length / rowsPerPage)
}, [filteredList, rowsPerPage])
// useEffect(() => {
// fetchAndSetEvent()
// }, [])
const columns = [
useEffect(() => {
setPage(1)
}, [statusFilter])
const columns: {
key: string
label: string
allowSorting?: boolean
content?: JSX.Element
}[] = [
{
key: "eventId",
label: "单号",
@ -338,6 +294,10 @@ export default function App() {
key: "model",
label: "型号",
},
{
key: "size",
label: "工作量",
},
{
key: "memberId",
label: "处理人",
@ -362,13 +322,13 @@ export default function App() {
},
]
const [activeEventId, setActiveEventId] = useState<number>()
const onOpenEventDetail = (eventId: number) => {
setActiveEventId(eventId)
const [activeEvent, setActiveEvent] = useState<PublicEvent>()
const onOpenEventDetail = (event: PublicEvent) => {
setActiveEvent(event)
onOpen()
}
const renderCell = useCallback((event: PublicEvent, columnKey: string) => {
const renderCell = useCallback((event: PublicEvent, columnKey: string | number) => {
const cellValue = event[columnKey]
switch (columnKey) {
@ -391,6 +351,10 @@ export default function App() {
)
: <></>
)
case "size":
return (
cellValue ? <Chip size="sm">{"size:" + cellValue}</Chip> : <></>
)
case "gmtCreate":
return (
<span>
@ -400,10 +364,11 @@ export default function App() {
case "status":
return EventStatusChip({
status: cellValue,
size: "sm",
})
case "actions":
return (
<Button onPress={() => onOpenEventDetail(event.eventId)} size="sm" isIconOnly variant="light">
<Button onPress={() => onOpenEventDetail(event)} size="sm" isIconOnly variant="light">
<span className="text-lg text-default-400 cursor-pointer active:opacity-50">
<EyeIcon />
</span>
@ -417,7 +382,7 @@ export default function App() {
return (
<section className="box-border mb-24">
<div className="section-content mt-8">
<div className="section-content mt-6">
<h2 className="text-2xl font-bold"></h2>
</div>
<div className="section-content my-8 flex flex-col gap-4">
@ -452,7 +417,15 @@ export default function App() {
</Table>
</div>
<TicketDetailDrawer
eventId={activeEventId}
event={activeEvent}
onEventUpdated={list.reload}
identity={
{
member: currentMember,
userInfo: userInfo,
token: token,
}
}
isOpen={isOpen}
onOpenChange={onOpenChange}
onClose={() => {

View file

@ -1,3 +1,5 @@
import type { components } from "./saturday"
export interface Status {
status: string
text: string
@ -76,3 +78,5 @@ export const UserEventAction: Action[] = [
icon: "status_cancelled.svg",
},
]
export type PublicEvent = components["schemas"]["PublicEvent"]

View file

@ -2,8 +2,10 @@ import createClient from "openapi-fetch"
import type { paths as saturdayPaths } from "../types/saturday"
import { ApiClient } from "./active"
export const saturdayApiBaseUrl = import.meta.env.PROD ? "https://api.nbtca.space/v2/" : "/saturday"
export const saturdayClient = createClient<saturdayPaths>({
baseUrl: import.meta.env.PROD ? "https://api.nbtca.space/v2/" : "/saturday",
baseUrl: saturdayApiBaseUrl,
})
export const activeClient = new ApiClient({