Skip to content
Merged
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
55 changes: 37 additions & 18 deletions src/pages/repair/EventDetail.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from "react"
import type { components } from "../../types/saturday"
import { saturdayClient } from "../../utils/client"
import { makeLogtoClient } from "../../utils/auth"
import { Textarea, Input, Chip, Skeleton } from "@heroui/react"
import type { PublicMember } from "../../store/member"
import dayjs from "dayjs"
import { EventStatus, UserEventAction } from "../../types/event"
import EventPhotos from "./EventPhotos"

type PublicEvent = components["schemas"]["PublicEvent"]
type Event = components["schemas"]["Event"]
Expand Down Expand Up @@ -105,6 +107,7 @@ const EventDetail = forwardRef<EventDetailRef, {
children?: (event: PublicEvent) => React.ReactNode
token?: string
currentMemberId?: string
isAdmin?: boolean
}>((props, ref) => {
const [event, setEvent] = useState<PublicEvent | undefined>()
const [eventWithContact, setEventWithContact] = useState<Event | undefined>()
Expand All @@ -125,16 +128,17 @@ const EventDetail = forwardRef<EventDetailRef, {
if (!props.token) return

try {
const { data } = await saturdayClient.GET("/member/events/{EventId}", {
params: {
header: {
Authorization: `Bearer ${props.token}`,
},
path: {
EventId: eventId,
},
const params = {
header: {
Authorization: `Bearer ${await makeLogtoClient().getAccessToken()}`,
},
})
path: {
EventId: eventId,
},
}
const { data } = props.currentMemberId
? await saturdayClient.GET("/member/events/{EventId}", { params })
: await saturdayClient.GET("/client/events/{EventId}", { params })
setEventWithContact(data)
}
catch (error) {
Expand All @@ -149,21 +153,25 @@ const EventDetail = forwardRef<EventDetailRef, {
if (eventId) {
const event = await fetchAndSetEvent(eventId as unknown as number)

// Fetch contact info if user has accepted the event
// Show contact info if the event is in any state after acceptance (accepted, committed, closed)
if (event && props.token && props.currentMemberId && event.member?.memberId === props.currentMemberId) {
const hasAccepted = event.status === EventStatus.accepted
|| event.status === EventStatus.committed
|| event.status === EventStatus.closed
if (hasAccepted) {
await fetchContactInfo(eventId as unknown as number)
}
const hasAccepted = event?.status === EventStatus.accepted
|| event?.status === EventStatus.committed
|| event?.status === EventStatus.closed
const isAssignee = props.currentMemberId && event?.member?.memberId === props.currentMemberId
if (event && props.token && (props.isAdmin || !props.currentMemberId || (isAssignee && hasAccepted))) {
await fetchContactInfo(eventId as unknown as number)
}
else {
setEventWithContact(undefined)
}

return event
}
}

const canUpload = !!eventWithContact
&& (eventWithContact.status === EventStatus.accepted || eventWithContact.status === EventStatus.committed)
&& (!!props.isAdmin || (!!props.currentMemberId && eventWithContact.memberId === props.currentMemberId))

const repairDescription = useMemo(() => {
return event?.logs.findLast(v => v.action == "commit" || v.action == "alterCommit")?.description
}, [event])
Expand Down Expand Up @@ -245,6 +253,17 @@ const EventDetail = forwardRef<EventDetailRef, {
)
: <></>
}
{
eventWithContact && (canUpload || !!eventWithContact.photos?.length) && (
<EventPhotos
photos={eventWithContact.photos ?? []}
eventId={eventWithContact.eventId}
canUpload={canUpload}
onUploaded={setEventWithContact}
onExpired={() => fetchContactInfo(eventWithContact.eventId)}
/>
)
}
<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">
维修记录
Expand Down
131 changes: 131 additions & 0 deletions src/pages/repair/EventPhotos.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { useRef, useState } from "react"
import { Button } from "@heroui/react"
import type { components } from "../../types/saturday"
import { saturdayClient } from "../../utils/client"
import { makeLogtoClient } from "../../utils/auth"

type Event = components["schemas"]["Event"]
type EventPhoto = components["schemas"]["EventPhoto"]

const reasons: Record<string, string> = {
"only the assigned member or an admin can add photos": "只有接单成员或管理员可以添加照片",
"photos can only be added to accepted or committed events": "工单当前的状态不能再添加照片",
"an event can have at most 20 photos": "每单最多 20 张照片",
}

async function compress(file: File): Promise<Blob> {
const bitmap = await createImageBitmap(file)
const scale = Math.min(1, 2048 / Math.max(bitmap.width, bitmap.height))
const canvas = document.createElement("canvas")
canvas.width = Math.round(bitmap.width * scale)
canvas.height = Math.round(bitmap.height * scale)
canvas.getContext("2d")!.drawImage(bitmap, 0, 0, canvas.width, canvas.height)
bitmap.close()
const encode = (type: string, quality: number) =>
new Promise<Blob | null>(resolve => canvas.toBlob(resolve, type, quality))
const webp = await encode("image/webp", 0.8)
if (webp?.type === "image/webp") return webp
const jpeg = await encode("image/jpeg", 0.85)
if (!jpeg) throw new Error("encode failed")
return jpeg
}

export default function EventPhotos(props: {
photos: EventPhoto[]
eventId: number
canUpload: boolean
onUploaded: (event: Event) => void
onExpired: () => void
}) {
const input = useRef<HTMLInputElement>(null)
const refreshed = useRef(false)
const [uploading, setUploading] = useState(false)
const [error, setError] = useState("")

const upload = async (files: FileList) => {
setError("")
if (props.photos.length + files.length > 20) {
setError("每单最多 20 张照片")
if (input.current) input.current.value = ""
return
}
setUploading(true)
try {
const blobs: Blob[] = []
try {
for (const file of files) blobs.push(await compress(file))
}
catch {
setError("有照片无法读取,请换一张再试")
return
}
const { data, error, response } = await saturdayClient.POST("/member/events/{EventId}/photos", {
params: {
path: { EventId: props.eventId },
header: { Authorization: `Bearer ${await makeLogtoClient().getAccessToken()}` },
},
body: { photos: [] },
bodySerializer: () => {
const form = new FormData()
blobs.forEach((blob, i) => form.append("photos", blob, `${i}.${blob.type === "image/webp" ? "webp" : "jpg"}`))
return form
},
})
if (data) props.onUploaded(data)
else if (response.status === 422) setError(`上传失败:${reasons[error?.detail ?? ""] ?? "照片没有通过检查,请换一张再试"}`)
else setError("上传失败,请稍后再试")
}
catch {
setError("网络异常,上传失败,请稍后再试")
}
finally {
setUploading(false)
if (input.current) input.current.value = ""
}
}

return (
<div className="bg-gray-100 rounded-xl text-sm px-3 py-2">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-semibold text-gray-600">现场照片</span>
{props.canUpload && (
<>
<input
ref={input}
type="file"
accept="image/*"
multiple
hidden
onChange={e => e.target.files?.length && upload(e.target.files)}
/>
<Button size="sm" variant="flat" isLoading={uploading} onPress={() => input.current?.click()}>
添加照片
</Button>
</>
)}
</div>
{props.photos.length
? (
<div className="grid grid-cols-3 sm:grid-cols-4 gap-2">
{props.photos.map(photo => (
<a key={photo.photoId} href={photo.url} target="_blank" rel="noreferrer">
<img
src={photo.url}
alt="现场照片"
loading="lazy"
className="aspect-square w-full object-cover rounded-lg"
onError={() => {
if (refreshed.current) return
refreshed.current = true
props.onExpired()
}}
/>
</a>
))}
</div>
)
: <p className="text-gray-500">还没有照片</p>}
{error && <p className="text-danger mt-2">{error}</p>}
</div>
)
}
1 change: 1 addition & 0 deletions src/pages/repair/RepairAdmin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ function TicketDetailDrawer(props: {
eventId={props.event?.eventId}
token={props.identity?.token}
currentMemberId={props.identity?.member?.memberId}
isAdmin={props.identity?.userInfo?.roles?.some(role => role.toLowerCase() === "repair admin")}
>
{
event => (
Expand Down
8 changes: 5 additions & 3 deletions src/pages/repair/TicketDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ type PublicEvent = components["schemas"]["PublicEvent"]

export default function TicketDetail() {
const [userInfo, setUserInfo] = useState<UserInfoResponse | null>(null)
const [token, setToken] = useState<string>()
const [loading, setLoading] = useState(true)
const [eventId, setEventId] = useState<number | null>(null)
const [event, setEvent] = useState<PublicEvent | null>(null)
Expand Down Expand Up @@ -38,6 +39,7 @@ export default function TicketDetail() {
if (authenticated) {
const claims = await logtoClient.getIdTokenClaims()
setUserInfo(claims)
setToken(await logtoClient.getAccessToken())
}
else {
// Redirect to login if not authenticated
Expand Down Expand Up @@ -114,12 +116,12 @@ export default function TicketDetail() {
)
}

if (!userInfo || !eventId) {
if (!userInfo || !eventId || !token) {
return (
<div className="container mx-auto pt-16 pb-20">
<div className="flex flex-col items-center justify-center">
<p className="text-gray-500 mb-4">
{!userInfo ? "请先登录以查看维修详情" : "无效的维修单ID"}
{!userInfo || !token ? "请先登录以查看维修详情" : "无效的维修单ID"}
</p>
<Button color="primary" onPress={handleBackToHome}>
返回首页
Expand All @@ -134,7 +136,7 @@ export default function TicketDetail() {

{/* Event detail content */}
<div className="section-content">
<EventDetail eventId={eventId}>
<EventDetail eventId={eventId} token={token}>
{(eventData: PublicEvent) => {
// Store the event data for actions
if (eventData && !event) {
Expand Down
74 changes: 74 additions & 0 deletions src/types/saturday.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,23 @@ export interface paths {
patch: operations["alter-commit-event"]
trace?: never
}
"/member/events/{EventId}/photos": {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
get?: never
put?: never
/** Upload event photos */
post: operations["upload-event-photos"]
delete?: never
options?: never
head?: never
patch?: never
trace?: never
}
"/member/notification-preferences": {
parameters: {
query?: never
Expand Down Expand Up @@ -643,6 +660,7 @@ export interface components {
memberId: string
model: string
phone: string
photos: components["schemas"]["EventPhoto"][] | null
problem: string
qq: string
size: string
Expand All @@ -656,6 +674,13 @@ export interface components {
logId: number
memberId: string
}
"EventPhoto": {
gmtCreate: string
memberId: string
/** Format: int64 */
photoId: number
url: string
}
"Item": {
enabled: boolean
notificationType: string
Expand Down Expand Up @@ -1757,6 +1782,55 @@ export interface operations {
}
}
}
"upload-event-photos": {
parameters: {
query?: never
header?: {
/** @description Bearer token or JWT token */
Authorization?: string
}
path: {
/**
* @description Event ID
* @example 123
*/
EventId: number
}
cookie?: never
}
requestBody?: {
content: {
"multipart/form-data": {
photos: string[]
}
}
}
responses: {
/** @description OK */
200: {
headers: {
"X-Limit"?: number | null
"X-Offset"?: number | null
"X-Page"?: number | null
"X-Total-Count"?: number | null
"X-Total-Pages"?: number | null
[name: string]: unknown
}
content: {
"application/json": components["schemas"]["Event"]
}
}
/** @description Error */
default: {
headers: {
[name: string]: unknown
}
content: {
"application/problem+json": components["schemas"]["ErrorModel"]
}
}
}
}
"get-notification-preferences": {
parameters: {
query?: never
Expand Down
Loading