mirror of
https://github.com/m1ngsama/FUJI.git
synced 2025-12-25 02:56:38 +00:00
Merge pull request #85 from wen-templari/add-repair-admin
feat: add repair admin pages
This commit is contained in:
commit
83208e234f
23 changed files with 1199 additions and 134 deletions
|
|
@ -9,6 +9,6 @@ FROM base AS build
|
|||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
|
||||
RUN pnpm run build
|
||||
|
||||
FROM nginx:alpine as deploy
|
||||
FROM nginx:alpine AS deploy
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
|
|
|||
|
|
@ -24,7 +24,9 @@
|
|||
"@fullcalendar/react": "^6.1.17",
|
||||
"@headlessui/vue": "^1.7.23",
|
||||
"@heroui/react": "2.7.6",
|
||||
"@internationalized/date": "^3.8.1",
|
||||
"@logto/browser": "^2.2.18",
|
||||
"@react-stately/data": "^3.13.0",
|
||||
"@stylistic/eslint-plugin": "^2.13.0",
|
||||
"astro": "^4.16.18",
|
||||
"dayjs": "^1.11.13",
|
||||
|
|
|
|||
|
|
@ -38,9 +38,15 @@ importers:
|
|||
'@heroui/react':
|
||||
specifier: 2.7.6
|
||||
version: 2.7.6(@types/react@18.3.20)(framer-motion@11.18.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(tailwindcss@3.4.17)
|
||||
'@internationalized/date':
|
||||
specifier: ^3.8.1
|
||||
version: 3.8.1
|
||||
'@logto/browser':
|
||||
specifier: ^2.2.18
|
||||
version: 2.2.18
|
||||
'@react-stately/data':
|
||||
specifier: ^3.13.0
|
||||
version: 3.13.0(react@18.3.1)
|
||||
'@stylistic/eslint-plugin':
|
||||
specifier: ^2.13.0
|
||||
version: 2.13.0(eslint@8.57.1)(typescript@5.8.3)
|
||||
|
|
@ -1465,8 +1471,8 @@ packages:
|
|||
'@internationalized/date@3.7.0':
|
||||
resolution: {integrity: sha512-VJ5WS3fcVx0bejE/YHfbDKR/yawZgKqn/if+oEeLqNwBtPzVB06olkfcnojTmEMX+gTpH+FlQ69SHNitJ8/erQ==}
|
||||
|
||||
'@internationalized/date@3.8.0':
|
||||
resolution: {integrity: sha512-J51AJ0fEL68hE4CwGPa6E0PO6JDaVLd8aln48xFCSy7CZkZc96dGEGmLs2OEEbBxcsVZtfrqkXJwI2/MSG8yKw==}
|
||||
'@internationalized/date@3.8.1':
|
||||
resolution: {integrity: sha512-PgVE6B6eIZtzf9Gu5HvJxRK3ufUFz9DhspELuhW/N0GuMGMTLvPQNRkHP2hTuP9lblOk+f+1xi96sPiPXANXAA==}
|
||||
|
||||
'@internationalized/message@3.1.7':
|
||||
resolution: {integrity: sha512-gLQlhEW4iO7DEFPf/U7IrIdA3UyLGS0opeqouaFwlMObLUzwexRjbygONHDVbC9G9oFLXsLyGKYkJwqXw/QADg==}
|
||||
|
|
@ -1866,6 +1872,11 @@ packages:
|
|||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
|
||||
|
||||
'@react-stately/data@3.13.0':
|
||||
resolution: {integrity: sha512-7LYPxVbWB6tvmLYKO19H5G5YtXV6eKCSXisOUiL9fVnOcGOPDK5z310sj9TP5vaX7zVPtwy0lDBUrZuRfhvQIQ==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
|
||||
|
||||
'@react-stately/datepicker@3.13.0':
|
||||
resolution: {integrity: sha512-I0Y/aQraQyRLMWnh5tBZMiZ0xlmvPjFErXnQaeD7SdOYUHNtQS4BAQsMByQrMfg8uhOqUTKlIh7xEZusuqYWOA==}
|
||||
peerDependencies:
|
||||
|
|
@ -2140,6 +2151,11 @@ packages:
|
|||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
|
||||
|
||||
'@react-types/shared@3.29.1':
|
||||
resolution: {integrity: sha512-KtM+cDf2CXoUX439rfEhbnEdAgFZX20UP2A35ypNIawR7/PFFPjQDWyA2EnClCcW/dLWJDEPX2U8+EJff8xqmQ==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
|
||||
|
||||
'@react-types/slider@3.7.10':
|
||||
resolution: {integrity: sha512-Yb8wbpu2gS7AwvJUuz0IdZBRi6eIBZq32BSss4UHX0StA8dtR1/K4JeTsArxwiA3P0BA6t0gbR6wzxCvVA9fRw==}
|
||||
peerDependencies:
|
||||
|
|
@ -7395,7 +7411,7 @@ snapshots:
|
|||
dependencies:
|
||||
'@swc/helpers': 0.5.17
|
||||
|
||||
'@internationalized/date@3.8.0':
|
||||
'@internationalized/date@3.8.1':
|
||||
dependencies:
|
||||
'@swc/helpers': 0.5.17
|
||||
|
||||
|
|
@ -7513,7 +7529,7 @@ snapshots:
|
|||
|
||||
'@react-aria/calendar@3.7.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@internationalized/date': 3.7.0
|
||||
'@internationalized/date': 3.8.1
|
||||
'@react-aria/i18n': 3.12.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@react-aria/interactions': 3.24.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@react-aria/live-announcer': 3.4.2
|
||||
|
|
@ -7565,7 +7581,7 @@ snapshots:
|
|||
|
||||
'@react-aria/datepicker@3.14.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@internationalized/date': 3.7.0
|
||||
'@internationalized/date': 3.8.1
|
||||
'@internationalized/number': 3.6.1
|
||||
'@internationalized/string': 3.2.6
|
||||
'@react-aria/focus': 3.20.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
|
|
@ -7657,7 +7673,7 @@ snapshots:
|
|||
|
||||
'@react-aria/i18n@3.12.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@internationalized/date': 3.8.0
|
||||
'@internationalized/date': 3.8.1
|
||||
'@internationalized/message': 3.1.7
|
||||
'@internationalized/number': 3.6.1
|
||||
'@internationalized/string': 3.2.6
|
||||
|
|
@ -7670,7 +7686,7 @@ snapshots:
|
|||
|
||||
'@react-aria/i18n@3.12.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@internationalized/date': 3.8.0
|
||||
'@internationalized/date': 3.8.1
|
||||
'@internationalized/message': 3.1.7
|
||||
'@internationalized/number': 3.6.1
|
||||
'@internationalized/string': 3.2.6
|
||||
|
|
@ -8106,7 +8122,7 @@ snapshots:
|
|||
|
||||
'@react-stately/calendar@3.7.1(react@18.3.1)':
|
||||
dependencies:
|
||||
'@internationalized/date': 3.7.0
|
||||
'@internationalized/date': 3.8.1
|
||||
'@react-stately/utils': 3.10.5(react@18.3.1)
|
||||
'@react-types/calendar': 3.6.1(react@18.3.1)
|
||||
'@react-types/shared': 3.28.0(react@18.3.1)
|
||||
|
|
@ -8147,9 +8163,15 @@ snapshots:
|
|||
'@swc/helpers': 0.5.17
|
||||
react: 18.3.1
|
||||
|
||||
'@react-stately/data@3.13.0(react@18.3.1)':
|
||||
dependencies:
|
||||
'@react-types/shared': 3.29.1(react@18.3.1)
|
||||
'@swc/helpers': 0.5.17
|
||||
react: 18.3.1
|
||||
|
||||
'@react-stately/datepicker@3.13.0(react@18.3.1)':
|
||||
dependencies:
|
||||
'@internationalized/date': 3.7.0
|
||||
'@internationalized/date': 3.8.1
|
||||
'@internationalized/string': 3.2.6
|
||||
'@react-stately/form': 3.1.3(react@18.3.1)
|
||||
'@react-stately/overlays': 3.6.15(react@18.3.1)
|
||||
|
|
@ -8385,13 +8407,13 @@ snapshots:
|
|||
|
||||
'@react-types/calendar@3.6.1(react@18.3.1)':
|
||||
dependencies:
|
||||
'@internationalized/date': 3.7.0
|
||||
'@internationalized/date': 3.8.1
|
||||
'@react-types/shared': 3.28.0(react@18.3.1)
|
||||
react: 18.3.1
|
||||
|
||||
'@react-types/calendar@3.7.0(react@18.3.1)':
|
||||
dependencies:
|
||||
'@internationalized/date': 3.8.0
|
||||
'@internationalized/date': 3.8.1
|
||||
'@react-types/shared': 3.29.0(react@18.3.1)
|
||||
react: 18.3.1
|
||||
|
||||
|
|
@ -8412,7 +8434,7 @@ snapshots:
|
|||
|
||||
'@react-types/datepicker@3.11.0(react@18.3.1)':
|
||||
dependencies:
|
||||
'@internationalized/date': 3.7.0
|
||||
'@internationalized/date': 3.8.1
|
||||
'@react-types/calendar': 3.7.0(react@18.3.1)
|
||||
'@react-types/overlays': 3.8.14(react@18.3.1)
|
||||
'@react-types/shared': 3.29.0(react@18.3.1)
|
||||
|
|
@ -8509,6 +8531,10 @@ snapshots:
|
|||
dependencies:
|
||||
react: 18.3.1
|
||||
|
||||
'@react-types/shared@3.29.1(react@18.3.1)':
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
|
||||
'@react-types/slider@3.7.10(react@18.3.1)':
|
||||
dependencies:
|
||||
'@react-types/shared': 3.29.0(react@18.3.1)
|
||||
|
|
|
|||
|
|
@ -11,36 +11,57 @@ const year = date.getFullYear();
|
|||
<div class="content-body footer-wrapper">
|
||||
<div class="footer-box">
|
||||
<div class="foot-nav">
|
||||
<div class="foot-nav-items">
|
||||
<div class="item">
|
||||
<div class="logo">{SITE_TITLE}</div>
|
||||
<div class="email">Email: {SITE_EMAIL}</div>
|
||||
<div class="foot-nav-items flex flex-col sm:grid gap-2 sm:gap-5 grid-cols-5 text-xs">
|
||||
<div class="item col-span-2">
|
||||
<div>
|
||||
<span class="logo">{SITE_TITLE}</span> By NBTCA
|
||||
</div>
|
||||
<div class="italic">
|
||||
To be at the intersection of technology and liberal arts.
|
||||
</div>
|
||||
|
||||
<div class="mt-1 sm:mt-2 !text-xs">
|
||||
联系我们:
|
||||
<span class="ml-1"> <a class="!text-blue-500" href="mailto:contact@nbtca.space">{SITE_EMAIL}</a></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="item products">
|
||||
<div class="item-title">作品</div>
|
||||
<a href="/" target="_blank">本站</a>
|
||||
<a href="https://repair.nbtca.space" target="_blank">维修管理</a>
|
||||
<div class="item-title">导航</div>
|
||||
<a href="/" target="_blank">主页</a>
|
||||
<a href="https://docs.nbtca.space" target="_blank">文档</a>
|
||||
</div>
|
||||
|
||||
<div class="item community">
|
||||
<div class="item-title">社媒</div>
|
||||
<a href="https://github.com/nbtca" target="_blank">Github</a>
|
||||
<a href="https://qm.qq.com/q/djgZhpnlzW" target="_blank">QQ</a>
|
||||
</div>
|
||||
|
||||
<div class="item resources">
|
||||
<div class="item-title">友链</div>
|
||||
<a href="https://www.cnblogs.com/N3ptune">N3ptune</a>
|
||||
<div class="item">
|
||||
<div class="item-title">维修</div>
|
||||
<a href="https://nbtca.space/repair/create-ticket">预约维修</a>
|
||||
</div>
|
||||
|
||||
<!-- <div class="item">
|
||||
<div class="item-title">维修</div>
|
||||
<a href="https://nbtca.space/repair/create-ticket">预约维修</a>
|
||||
</div> -->
|
||||
</div>
|
||||
<!-- <div class="flex text-xs mt-3">
|
||||
联系我们:
|
||||
<div class="ml-1"> <a href="mailto:contact@nbtca.space">{SITE_EMAIL}</a></div>
|
||||
</div> -->
|
||||
</div>
|
||||
<div class="flex flex-col md:flex-row items-center md:items-end text-sm pt-3 pb-1">
|
||||
<div class=" w-full">
|
||||
<div class="w-full h-[0.5px] my-4 bg-gray-500"> </div>
|
||||
<div class="flex flex-col gap-2 sm:flex-row items-start sm:items-center text-xs text-gray-500">
|
||||
<div class="">
|
||||
© {`2018-${year} ${SITE_NAME}`}
|
||||
<a href="//github.com/austin2035/astro-air-blog">astro-air-blog</a>
|
||||
Copyright © {`${year} ${SITE_NAME}`}
|
||||
</div>
|
||||
<div>
|
||||
<a href="https://beian.miit.gov.cn/" target="_blank" class="text-xs ml-2">浙ICP备2021030831号</a>
|
||||
<a href="https://beian.miit.gov.cn/" target="_blank" class="text-xs">浙ICP备2021030831号</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -13,6 +13,11 @@ const onSignIn = async () => {
|
|||
postRedirectUri: window.location.pathname,
|
||||
})
|
||||
}
|
||||
|
||||
const onGoToAccountManage = async () => {
|
||||
window.open("https://myid.app.nbtca.space/account/aboutme", "_blank")
|
||||
}
|
||||
|
||||
const onSignOut = async () => {
|
||||
logtoClient.value?.signOut(import.meta.env.PUBLIC_LOGTO_REDIRECT_URL)
|
||||
}
|
||||
|
|
@ -66,10 +71,47 @@ onMounted(() => {
|
|||
<div class="p-1">
|
||||
<MenuItem v-slot="{ active }">
|
||||
<button
|
||||
@click="onSignOut"
|
||||
class="text-nowrap"
|
||||
@click="onGoToAccountManage"
|
||||
class="text-nowrap items-center gap-1 group"
|
||||
:class="[active ? 'bg-violet-500 text-white' : 'text-gray-900', 'flex w-full items-center rounded-md px-2 py-2 text-sm']"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="size-4 text-gray-500 group-hover:text-white"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"
|
||||
/>
|
||||
</svg>
|
||||
账号管理
|
||||
</button>
|
||||
</MenuItem>
|
||||
<MenuItem v-slot="{ active }">
|
||||
<button
|
||||
@click="onSignOut"
|
||||
class="text-nowrap items-center gap-1 group"
|
||||
:class="[active ? 'bg-violet-500 text-white' : 'text-gray-900', 'flex w-full items-center rounded-md px-2 py-2 text-sm']"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="size-4 text-gray-500 group-hover:text-white"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15.75 9V5.25A2.25 2.25 0 0 0 13.5 3h-6a2.25 2.25 0 0 0-2.25 2.25v13.5A2.25 2.25 0 0 0 7.5 21h6a2.25 2.25 0 0 0 2.25-2.25V15m3 0 3-3m0 0-3-3m3 3H9"
|
||||
/>
|
||||
</svg>
|
||||
登出
|
||||
</button>
|
||||
</MenuItem>
|
||||
|
|
|
|||
|
|
@ -3,15 +3,34 @@ import NavigationUser from "./NavigationUser.vue"
|
|||
---
|
||||
|
||||
<script>
|
||||
import { validateRepairRole } from "../../pages/repair/RepairAdmin"
|
||||
import { makeLogtoClient } from "../../utils/auth"
|
||||
|
||||
const button = document.getElementById("repair-header")
|
||||
button.addEventListener("click", () => {
|
||||
window.location.href = "/repair"
|
||||
})
|
||||
|
||||
const adminButton = document.getElementById("repair-admin")
|
||||
makeLogtoClient().getIdTokenClaims().then((claims) => {
|
||||
const hasRole = validateRepairRole(claims.roles)
|
||||
if (hasRole) {
|
||||
adminButton.classList.remove("hidden")
|
||||
}
|
||||
else {
|
||||
adminButton.classList.add("hidden")
|
||||
}
|
||||
})
|
||||
</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 cursor-default">维修</span>
|
||||
<div class="flex items-center">
|
||||
<div class="flex items-center gap-2 mr-4 text-xs text-gray-400">
|
||||
<a class="hidden text-gray-500 hover:text-gray-700 appearance-none cursor-pointer" id="repair-admin" href="/repair/admin" style="text-decoration:none">维修管理</a>
|
||||
</div>
|
||||
<NavigationUser client:load />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
export const SITE_TITLE = `拔电关机`
|
||||
export const SITE_EMAIL = "contact@nbtca.space"
|
||||
export const SITE_NAME = "Computer Association"
|
||||
export const SITE_NAME = "NingboTech University, Computer Association"
|
||||
export const SITE_DESCRIPTION = "Computer Association"
|
||||
export const SITE_URL = import.meta.env.PUBLIC_SITE_URL
|
||||
|
|
|
|||
|
|
@ -6,8 +6,6 @@ description: ' “时间之河滚滚而去,青春韶华转瞬即逝”'
|
|||
author: 'kongbai'
|
||||
cover:
|
||||
url: 'https://oss.nbtca.space/blog/clas/YQL05614-5xNq3t-mid-ulLkGV.jpeg'
|
||||
square: 'https://www.apple.com.cn/newsroom/images/values/environment/Apple-Earth-Day-India-mangrove-Alibaug-canoe_Full-Bleed-Image.jpg.large_2x.jpg'
|
||||
alt: 'cover'
|
||||
tags: ["活动","志愿者", "新闻稿", "影留宁理","摄影","毕业"]
|
||||
theme: 'white'
|
||||
featured: true
|
||||
|
|
|
|||
BIN
src/pages/posts/_assets/workSummary/IMG_0069.jpeg
Normal file
BIN
src/pages/posts/_assets/workSummary/IMG_0069.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 187 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.3 MiB |
|
|
@ -5,7 +5,7 @@ pubDate: 2024-10-15
|
|||
description: "活动的一些片段"
|
||||
author: "kongbai"
|
||||
cover:
|
||||
url: ./_assets/post20241016/free_huaji.jpg
|
||||
url: https://oss.nbtca.space/blog/free_huaji_0F7NMW.jpg
|
||||
alt: "cover"
|
||||
tags: ["活动"]
|
||||
---
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ pubDate: 2025-01-12
|
|||
description: ' 人类从历史中学到的唯一教训,就是人类无法从历史中学到任何教训'
|
||||
author: 'zzh0u'
|
||||
cover:
|
||||
url: ./_assets/workSummary/IMG_0069.jpg
|
||||
url: https://oss.nbtca.space/blog/IMG_0069_8xFcDr.jpeg
|
||||
tags: ["闲聊"]
|
||||
theme: 'white'
|
||||
featured: true
|
||||
|
|
|
|||
313
src/pages/repair/EventAction.tsx
Normal file
313
src/pages/repair/EventAction.tsx
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
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 { useEffect, 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"
|
||||
selectedKeys={formData.size ? [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: "",
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const description = props.event?.logs.findLast(v => v.action == "commit" || v.action == "alterCommit")?.description
|
||||
setFormData({
|
||||
size: props.event.size || "",
|
||||
description: description || "",
|
||||
})
|
||||
}, [props.event])
|
||||
|
||||
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}`,
|
||||
ContentType: "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
size: formData.size,
|
||||
content: 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"
|
||||
color="primary"
|
||||
isLoading={props.isLoading === "commit"}
|
||||
onPress={onSubmit}
|
||||
>
|
||||
提交
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export const EventActionAlterCommit = (props: EventActionProps) => {
|
||||
const [formData, setFormData] = useState({
|
||||
size: "",
|
||||
description: "",
|
||||
})
|
||||
useEffect(() => {
|
||||
const description = props.event?.logs?.findLast(v => v.action == "commit" || v.action == "alterCommit")?.description
|
||||
setFormData({
|
||||
size: props.event.size || "",
|
||||
description: description || "",
|
||||
})
|
||||
}, [props.event])
|
||||
|
||||
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}`,
|
||||
ContentType: "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
size: formData.size,
|
||||
content: 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
|
||||
color?: "default" | "primary" | "secondary" | "success" | "warning" | "danger"
|
||||
variant?: "flat" | "solid" | "bordered" | "light" | "faded" | "shadow" | "ghost"
|
||||
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
|
||||
isLoading={props.isLoading === action.action}
|
||||
isDisabled={props.isLoading}
|
||||
color={action.color || "default"}
|
||||
variant={action.variant || "flat"}
|
||||
onPress={() => onAction(action)}
|
||||
>
|
||||
{action.label ?? action.action}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (event.status == EventStatus.open) {
|
||||
actions.push({
|
||||
action: "accept",
|
||||
jsxHandler: makeCommonJsxHandler({
|
||||
action: "accept",
|
||||
label: "接受",
|
||||
variant: "solid",
|
||||
color: "primary",
|
||||
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",
|
||||
color: "success",
|
||||
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",
|
||||
color: "danger",
|
||||
label: "退回",
|
||||
handler: async () => {
|
||||
return await fetch(`${saturdayApiBaseUrl}/events/${event.eventId}/commit`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
Authorization: `Bearer ${identityContext.token}`,
|
||||
},
|
||||
}).then(res => res.json())
|
||||
},
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return actions
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { useEffect, useState } from "react"
|
||||
import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from "react"
|
||||
import type { components } from "../../types/saturday"
|
||||
import { saturdayClient } from "../../utils/client"
|
||||
import { Textarea, Input, Chip } from "@heroui/react"
|
||||
import { Textarea, Input, Chip, Skeleton } from "@heroui/react"
|
||||
import type { PublicMember } from "../../store/member"
|
||||
import dayjs from "dayjs"
|
||||
import { EventStatus, UserEventAction } from "../../types/event"
|
||||
|
|
@ -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">
|
||||
|
|
@ -37,7 +35,7 @@ function EventLogItem(props: {
|
|||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center mt-1 text-gray-600">
|
||||
<div className="flex flex-col gap-2 items-center mt-1 text-gray-600">
|
||||
{dayjs(props.eventLog.gmtCreate).format("YYYY-MM-DD HH:mm")}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -46,20 +44,22 @@ function EventLogItem(props: {
|
|||
)
|
||||
}
|
||||
|
||||
function EventStatusChip(props: {
|
||||
export function EventStatusChip(props: {
|
||||
size?: "sm" | "md" | "lg"
|
||||
status: string
|
||||
}) {
|
||||
const size = props.size || "md"
|
||||
switch (props.status) {
|
||||
case EventStatus.open:
|
||||
return <Chip>未开始</Chip>
|
||||
return <Chip size={size}>未开始</Chip>
|
||||
case EventStatus.accepted:
|
||||
return <Chip>维修中</Chip>
|
||||
return <Chip size={size} color="primary">维修中</Chip>
|
||||
case EventStatus.committed:
|
||||
return <Chip color="primary">维修中</Chip>
|
||||
return <Chip size={size} color="secondary">待审核</Chip>
|
||||
case EventStatus.closed:
|
||||
return <Chip color="success">已完成</Chip>
|
||||
return <Chip size={size} color="success">已完成</Chip>
|
||||
case EventStatus.cancelled:
|
||||
return <Chip>已取消</Chip>
|
||||
return <Chip size={size}>已取消</Chip>
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -93,11 +93,18 @@ const filterEventLog = (event: PublicEvent) => {
|
|||
}
|
||||
return filteredLogs
|
||||
}
|
||||
|
||||
export default function EventDetail(props: {
|
||||
export type EventDetailRef = {
|
||||
refresh: () => Promise<PublicEvent | undefined>
|
||||
event: PublicEvent | undefined
|
||||
}
|
||||
const EventDetail = forwardRef<EventDetailRef, {
|
||||
eventId?: number
|
||||
}) {
|
||||
onRefresh?: () => void
|
||||
action?: React.ReactNode
|
||||
children?: (event: PublicEvent) => React.ReactNode
|
||||
}>((props, ref) => {
|
||||
const [event, setEvent] = useState<PublicEvent | undefined>()
|
||||
|
||||
const fetchAndSetEvent = async (eventId: number) => {
|
||||
const { data } = await saturdayClient.GET("/events/{EventId}", {
|
||||
params: {
|
||||
|
|
@ -107,30 +114,52 @@ 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)
|
||||
}
|
||||
|
||||
const repairDescription = useMemo(() => {
|
||||
return event?.logs.findLast(v => v.action == "commit" || v.action == "alterCommit")?.description
|
||||
}, [event])
|
||||
|
||||
// 初次加载
|
||||
useEffect(() => {
|
||||
refresh()
|
||||
}, [])
|
||||
|
||||
// 暴露给父组件的方法
|
||||
useImperativeHandle(ref, () => ({
|
||||
refresh,
|
||||
event,
|
||||
}))
|
||||
|
||||
return (
|
||||
event
|
||||
? (
|
||||
<section className="box-border mb-24">
|
||||
<div className="section-content mt-8">
|
||||
<section className="box-border">
|
||||
<div className="">
|
||||
<h2 className="text-2xl font-bold">维修详情</h2>
|
||||
<div className="flex gap-2 items-center">
|
||||
<span>
|
||||
#{event.eventId}
|
||||
</span>
|
||||
<EventStatusChip status={event.status}></EventStatusChip>
|
||||
{
|
||||
event.size
|
||||
? <Chip>{"size:" + event.size}</Chip>
|
||||
: <></>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div className="section-content my-8 flex flex-col gap-4">
|
||||
<div className="my-6 flex flex-col gap-4">
|
||||
<Textarea
|
||||
label="问题描述"
|
||||
readOnly
|
||||
|
|
@ -144,6 +173,18 @@ export default function EventDetail(props: {
|
|||
readOnly
|
||||
>
|
||||
</Input>
|
||||
{
|
||||
repairDescription
|
||||
? (
|
||||
<Textarea
|
||||
label="维修描述"
|
||||
readOnly
|
||||
name="description"
|
||||
value={repairDescription || ""}
|
||||
/>
|
||||
)
|
||||
: <></>
|
||||
}
|
||||
<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">
|
||||
维修记录
|
||||
|
|
@ -157,8 +198,28 @@ export default function EventDetail(props: {
|
|||
}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{props.children(event)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
: <div></div>
|
||||
: (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Skeleton className="rounded-lg mb-4">
|
||||
<div className="h-24 rounded-lg bg-default-300" />
|
||||
</Skeleton>
|
||||
<Skeleton className="rounded-lg">
|
||||
<div className="h-24 rounded-lg bg-default-300" />
|
||||
</Skeleton>
|
||||
<Skeleton className="rounded-lg">
|
||||
<div className="h-16 rounded-lg bg-default-300" />
|
||||
</Skeleton>
|
||||
<Skeleton className="rounded-lg">
|
||||
<div className="h-24 rounded-lg bg-default-300" />
|
||||
</Skeleton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
export default EventDetail
|
||||
|
|
|
|||
108
src/pages/repair/ExportEventDialog.tsx
Normal file
108
src/pages/repair/ExportEventDialog.tsx
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import { useState } from "react"
|
||||
import {
|
||||
Modal,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
Button,
|
||||
DateRangePicker,
|
||||
} from "@heroui/react"
|
||||
import { parseDate } from "@internationalized/date"
|
||||
import { saturdayApiBaseUrl } from "../../utils/client"
|
||||
import { makeLogtoClient } from "../../utils/auth"
|
||||
import dayjs from "dayjs"
|
||||
|
||||
export function ExportExcelModal() {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [dateRange, setDateRange] = useState({
|
||||
start: parseDate(dayjs().subtract(1, "month").format("YYYY-MM-DD")),
|
||||
end: parseDate(dayjs().format("YYYY-MM-DD")),
|
||||
})
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const openModal = () => setIsOpen(true)
|
||||
const closeModal = () => setIsOpen(false)
|
||||
|
||||
const downloadExcel = async () => {
|
||||
if (!dateRange.start || !dateRange.end) return
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const start = dateRange.start.toString() // Format: 'YYYY-MM-DD'
|
||||
const end = dateRange.end.toString()
|
||||
const url = `${saturdayApiBaseUrl}/events/xlsx?start_time=${start}&end_time=${end}`
|
||||
|
||||
const token = await makeLogtoClient().getAccessToken()
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
if (!response.ok) throw new Error("Download failed")
|
||||
|
||||
// Extract filename from Content-Disposition header
|
||||
const disposition = response.headers.get("Content-Disposition")
|
||||
let filename = "export.xlsx" // Default filename
|
||||
if (disposition && disposition.includes("filename=")) {
|
||||
const filenameMatch = disposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/)
|
||||
if (filenameMatch != null && filenameMatch[1]) {
|
||||
filename = filenameMatch[1].replace(/['"]/g, "")
|
||||
}
|
||||
}
|
||||
|
||||
const blob = await response.blob()
|
||||
const downloadUrl = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement("a")
|
||||
link.href = downloadUrl
|
||||
link.setAttribute("download", filename)
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
window.URL.revokeObjectURL(downloadUrl) // Clean up
|
||||
}
|
||||
catch (error) {
|
||||
alert("Failed to download Excel file: " + error.message)
|
||||
}
|
||||
finally {
|
||||
setLoading(false)
|
||||
closeModal()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onPress={openModal} color="primary">
|
||||
导出为Excel
|
||||
</Button>
|
||||
|
||||
<Modal isOpen={isOpen} onClose={closeModal}>
|
||||
<ModalContent>
|
||||
<ModalHeader>导出为Excel</ModalHeader>
|
||||
<ModalBody>
|
||||
<DateRangePicker
|
||||
label="选择日期范围"
|
||||
value={dateRange}
|
||||
onChange={setDateRange}
|
||||
granularity="day"
|
||||
visibleMonths={2}
|
||||
/>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="ghost" onClick={closeModal}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
onClick={downloadExcel}
|
||||
isLoading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "导出中..." : "导出"}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
453
src/pages/repair/RepairAdmin.tsx
Normal file
453
src/pages/repair/RepairAdmin.tsx
Normal file
|
|
@ -0,0 +1,453 @@
|
|||
import {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableColumn,
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableCell,
|
||||
User,
|
||||
Pagination,
|
||||
Spinner,
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
Button,
|
||||
CheckboxGroup,
|
||||
Checkbox,
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerBody,
|
||||
DrawerFooter,
|
||||
useDisclosure,
|
||||
Chip,
|
||||
} from "@heroui/react"
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { useAsyncList } from "@react-stately/data"
|
||||
import type { components } from "../../types/saturday"
|
||||
import { saturdayApiBaseUrl, saturdayClient } from "../../utils/client"
|
||||
import EventDetail, { EventStatusChip, type EventDetailRef } from "./EventDetail"
|
||||
import dayjs from "dayjs"
|
||||
import { EventStatus, 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"
|
||||
import { ExportExcelModal } from "./ExportEventDialog"
|
||||
|
||||
type PublicEvent = components["schemas"]["PublicEvent"]
|
||||
|
||||
export const EyeIcon = (props) => {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="none"
|
||||
focusable="false"
|
||||
height="1em"
|
||||
role="presentation"
|
||||
viewBox="0 0 20 20"
|
||||
width="1em"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M12.9833 10C12.9833 11.65 11.65 12.9833 10 12.9833C8.35 12.9833 7.01666 11.65 7.01666 10C7.01666 8.35 8.35 7.01666 10 7.01666C11.65 7.01666 12.9833 8.35 12.9833 10Z"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<path
|
||||
d="M9.99999 16.8916C12.9417 16.8916 15.6833 15.1583 17.5917 12.1583C18.3417 10.9833 18.3417 9.00831 17.5917 7.83331C15.6833 4.83331 12.9417 3.09998 9.99999 3.09998C7.05833 3.09998 4.31666 4.83331 2.40833 7.83331C1.65833 9.00831 1.65833 10.9833 2.40833 12.1583C4.31666 15.1583 7.05833 16.8916 9.99999 16.8916Z"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function CheckboxPopover(props: {
|
||||
value: string[]
|
||||
onValueChange: (value: string[]) => void
|
||||
}) {
|
||||
return (
|
||||
<Popover placement="bottom">
|
||||
<PopoverTrigger>
|
||||
<Button size="sm" isIconOnly variant="bordered">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="size-6">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6.75a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5ZM12 12.75a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5ZM12 18.75a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5Z" />
|
||||
</svg>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<div className="p-2">
|
||||
<CheckboxGroup
|
||||
value={props.value}
|
||||
onValueChange={props.onValueChange}
|
||||
orientation="vertical"
|
||||
>
|
||||
{
|
||||
UserEventStatus.map((status) => {
|
||||
return (
|
||||
<Checkbox key={status.status} value={status.status}>
|
||||
<EventStatusChip size="sm" status={status.status} />
|
||||
</Checkbox>
|
||||
)
|
||||
})
|
||||
}
|
||||
</CheckboxGroup>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
function TicketDetailDrawer(props: {
|
||||
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 ref={eventDetailRef} eventId={props.event?.eventId}>
|
||||
{
|
||||
event => (
|
||||
<div className="mb-12 flex flex-col gap-2">
|
||||
{
|
||||
availableActions?.map((action) => {
|
||||
return (
|
||||
<action.jsxHandler
|
||||
key={action.action}
|
||||
event={event}
|
||||
isLoading={isLoading}
|
||||
identityContext={props.identity}
|
||||
onUpdated={onEventUpdated}
|
||||
onLoading={(action) => {
|
||||
setIsLoading(action)
|
||||
}}
|
||||
>
|
||||
</action.jsxHandler>
|
||||
)
|
||||
}) || <></>
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</EventDetail>
|
||||
</DrawerBody>
|
||||
<DrawerFooter>
|
||||
<Button variant="flat" onPress={onClose}>
|
||||
关闭
|
||||
</Button>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
export const validateRepairRole = (roles: string[]) => {
|
||||
const acceptableRoles = ["repair admin", "repair member"]
|
||||
return roles.some(role => acceptableRoles.includes(role.toLowerCase()))
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [page, setPage] = useState(1)
|
||||
const rowsPerPage = 10
|
||||
const [statusFilter, setStatusFilter] = useState<string[]>(
|
||||
UserEventStatus.filter(v => v.status !== EventStatus.cancelled).map(v => v.status),
|
||||
)
|
||||
const { isOpen, onOpen, onOpenChange } = useDisclosure()
|
||||
const [userInfo, setUserInfo] = useState<UserInfoResponse>()
|
||||
const [currentMember, setCurrentMember] = useState<PublicMember>()
|
||||
const [token, setToken] = useState<string>()
|
||||
|
||||
useEffect(() => {
|
||||
const check = async () => {
|
||||
const adminPath = "/repair/admin"
|
||||
const authenticated = await makeLogtoClient().isAuthenticated()
|
||||
if (!authenticated) {
|
||||
window.location.href = `/repair/login-hint?redirectUrl=${adminPath}`
|
||||
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)
|
||||
|
||||
const currentMember = await fetch(`${saturdayApiBaseUrl}/member`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
}).then(res => res.json())
|
||||
setCurrentMember(currentMember)
|
||||
}
|
||||
check()
|
||||
}, [])
|
||||
|
||||
const list = useAsyncList<PublicEvent>({
|
||||
async load() {
|
||||
const { data } = await saturdayClient.GET("/events", {
|
||||
params: {
|
||||
query: {
|
||||
order: "DESC",
|
||||
offset: 0,
|
||||
limit: 1000,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
setIsLoading(false)
|
||||
|
||||
return {
|
||||
items: data,
|
||||
}
|
||||
},
|
||||
async sort({ items, sortDescriptor }) {
|
||||
return {
|
||||
items: items.sort((a, b) => {
|
||||
const first = a[sortDescriptor.column]
|
||||
const second = b[sortDescriptor.column]
|
||||
let cmp = (parseInt(first) || first) < (parseInt(second) || second) ? -1 : 1
|
||||
|
||||
if (sortDescriptor.direction === "descending") {
|
||||
cmp *= -1
|
||||
}
|
||||
|
||||
return cmp
|
||||
}),
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const filteredList = useMemo(() => {
|
||||
if (statusFilter.length > 0) {
|
||||
return list.items.filter(item => statusFilter.includes(item.status))
|
||||
}
|
||||
return list.items
|
||||
}, [list, statusFilter])
|
||||
|
||||
const items = useMemo(() => {
|
||||
const start = (page - 1) * rowsPerPage
|
||||
const end = start + rowsPerPage
|
||||
|
||||
return filteredList.slice(start, end)
|
||||
}, [filteredList, page, rowsPerPage])
|
||||
|
||||
const pages = useMemo(() => {
|
||||
return Math.ceil(filteredList.length / rowsPerPage)
|
||||
}, [filteredList, rowsPerPage])
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1)
|
||||
}, [statusFilter])
|
||||
|
||||
const columns: {
|
||||
key: string
|
||||
label: string
|
||||
allowSorting?: boolean
|
||||
content?: JSX.Element
|
||||
}[] = [
|
||||
{
|
||||
key: "eventId",
|
||||
label: "单号",
|
||||
},
|
||||
{
|
||||
key: "problem",
|
||||
label: "问题描述",
|
||||
},
|
||||
{
|
||||
key: "model",
|
||||
label: "型号",
|
||||
},
|
||||
{
|
||||
key: "size",
|
||||
label: "工作量",
|
||||
},
|
||||
{
|
||||
key: "memberId",
|
||||
label: "处理人",
|
||||
},
|
||||
{
|
||||
key: "gmtCreate",
|
||||
label: "创建时间",
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "状态",
|
||||
content: (
|
||||
<div className="flex items-center gap-2">
|
||||
状态
|
||||
<CheckboxPopover value={statusFilter} onValueChange={setStatusFilter}></CheckboxPopover>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
label: "操作",
|
||||
},
|
||||
]
|
||||
|
||||
const [activeEvent, setActiveEvent] = useState<PublicEvent>()
|
||||
const onOpenEventDetail = (event: PublicEvent) => {
|
||||
setActiveEvent(event)
|
||||
onOpen()
|
||||
}
|
||||
|
||||
const renderCell = useCallback((event: PublicEvent, columnKey: string | number) => {
|
||||
const cellValue = event[columnKey]
|
||||
|
||||
switch (columnKey) {
|
||||
case "problem":
|
||||
return (
|
||||
<div className="max-w-40 line-clamp-2 overflow-hidden text-ellipsis">
|
||||
{cellValue}
|
||||
</div>
|
||||
)
|
||||
case "memberId":
|
||||
return (
|
||||
event.member
|
||||
? (
|
||||
<User
|
||||
avatarProps={{ radius: "full", src: event.member.avatar, size: "sm" }}
|
||||
name={event.member.alias}
|
||||
description={event.member.memberId}
|
||||
>
|
||||
{event.member.alias}
|
||||
</User>
|
||||
)
|
||||
: <></>
|
||||
)
|
||||
case "size":
|
||||
return (
|
||||
cellValue ? <Chip size="sm">{"size:" + cellValue}</Chip> : <></>
|
||||
)
|
||||
case "gmtCreate":
|
||||
return (
|
||||
<span>
|
||||
{dayjs(cellValue).format("YYYY-MM-DD HH:mm")}
|
||||
</span>
|
||||
)
|
||||
case "status":
|
||||
return EventStatusChip({
|
||||
status: cellValue,
|
||||
size: "sm",
|
||||
})
|
||||
case "actions":
|
||||
return (
|
||||
<Button onPress={() => onOpenEventDetail(event)} size="sm" isIconOnly variant="light">
|
||||
<span className="text-lg text-default-400 cursor-pointer active:opacity-50">
|
||||
<EyeIcon />
|
||||
</span>
|
||||
</Button>
|
||||
)
|
||||
|
||||
default:
|
||||
return cellValue
|
||||
}
|
||||
}, [])
|
||||
return (
|
||||
<section className="box-border max-w-[1024px] mx-auto px-[22px] mb-24">
|
||||
<div className="mt-6 flex justify-between items-center">
|
||||
<div className="text-2xl font-bold">维修管理</div>
|
||||
{
|
||||
userInfo?.roles?.find(v => v.toLowerCase() == "repair admin")
|
||||
? <ExportExcelModal></ExportExcelModal>
|
||||
: <></>
|
||||
}
|
||||
</div>
|
||||
<div className="my-8 flex flex-col gap-4">
|
||||
<Table
|
||||
aria-label="Example table with dynamic content"
|
||||
sortDescriptor={list.sortDescriptor}
|
||||
onSortChange={list.sort}
|
||||
bottomContent={(
|
||||
<div className="flex w-full justify-center">
|
||||
<Pagination
|
||||
isCompact
|
||||
showControls
|
||||
showShadow
|
||||
color="secondary"
|
||||
page={page}
|
||||
total={pages}
|
||||
onChange={page => setPage(page)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<TableHeader columns={columns}>
|
||||
{column => <TableColumn key={column.key} allowsSorting={column.allowSorting} children={column.content ?? <div>{column.label}</div>}></TableColumn>}
|
||||
</TableHeader>
|
||||
<TableBody isLoading={isLoading} items={items} loadingContent={<Spinner label="Loading..." />}>
|
||||
{item => (
|
||||
<TableRow key={item.eventId}>
|
||||
{columnKey => <TableCell>{renderCell(item, columnKey)}</TableCell>}
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<TicketDetailDrawer
|
||||
event={activeEvent}
|
||||
onEventUpdated={list.reload}
|
||||
identity={
|
||||
{
|
||||
member: currentMember,
|
||||
userInfo: userInfo,
|
||||
token: token,
|
||||
}
|
||||
}
|
||||
isOpen={isOpen}
|
||||
onOpenChange={onOpenChange}
|
||||
onClose={() => {
|
||||
onOpenChange()
|
||||
}}
|
||||
onDelete={() => {}}
|
||||
onEdit={() => {}}
|
||||
>
|
||||
</TicketDetailDrawer>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -181,8 +181,9 @@ function TicketFormCreated(props: {
|
|||
</div>
|
||||
</Alert>
|
||||
</div>
|
||||
|
||||
<div className="section-content">
|
||||
<EventDetail eventId={props.event?.eventId}></EventDetail>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
12
src/pages/repair/admin.astro
Normal file
12
src/pages/repair/admin.astro
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
---
|
||||
import BaseLayout from "../../layouts/BaseLayout.astro"
|
||||
import RepairHeader from "../../components/header/RepairHeader.astro"
|
||||
import RepairAdmin from "./RepairAdmin"
|
||||
---
|
||||
|
||||
<BaseLayout primaryTitle="Create Ticket">
|
||||
<RepairHeader></RepairHeader>
|
||||
<div>
|
||||
<RepairAdmin client:only="react"></RepairAdmin>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
|
|
@ -16,6 +16,9 @@ import RepairHeader from "../../components/header/RepairHeader.astro"
|
|||
const logtoClient = makeLogtoClient()
|
||||
const createRepairPath = "/repair/create-ticket"
|
||||
const authenticated = await logtoClient.isAuthenticated()
|
||||
logtoClient.getIdTokenClaims().then((res) => {
|
||||
console.log(res)
|
||||
})
|
||||
if (!authenticated) {
|
||||
window.location.href = `/repair/login-hint?redirectUrl=${createRepairPath}`
|
||||
return
|
||||
|
|
@ -65,7 +68,7 @@ import RepairHeader from "../../components/header/RepairHeader.astro"
|
|||
在接受预约之外,我们每月举办 <span class="text-black">理工维修日</span>,定时定点提供维修。
|
||||
</div>
|
||||
<div>
|
||||
在<a href="/calendar" class="mx-0.5">协会公共日历</a>上查看最近的维修日
|
||||
你可以在<a href="/calendar" class="mx-0.5">协会公共日历</a>上查看最近的维修日。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import EventDetail from "./EventDetail"
|
|||
|
||||
<BaseLayout primaryTitle="Create Ticket">
|
||||
<RepairHeader></RepairHeader>
|
||||
<div>
|
||||
<div class="section-content">
|
||||
<EventDetail client:only="react"></EventDetail>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
|
|
|
|||
|
|
@ -7709,16 +7709,16 @@ html.no-touch.no-reduced-motion
|
|||
}
|
||||
|
||||
.footer-main .item .logo {
|
||||
font-size: 16px;
|
||||
/* font-size: 16px; */
|
||||
font-weight: 600;
|
||||
color: var(--black);
|
||||
}
|
||||
|
||||
.footer-main .foot-nav-items {
|
||||
display: flex;
|
||||
/* display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
align-items: flex-start; */
|
||||
}
|
||||
|
||||
@media screen and (min-width: 320px) {
|
||||
|
|
@ -7774,10 +7774,10 @@ html.no-touch.no-reduced-motion
|
|||
}
|
||||
|
||||
.footer-main .foot-nav-items {
|
||||
display: flex;
|
||||
/* display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
align-items: flex-start; */
|
||||
}
|
||||
|
||||
@media screen and (min-width: 320px) {
|
||||
|
|
@ -7805,7 +7805,7 @@ html.no-touch.no-reduced-motion
|
|||
|
||||
.footer-main .item div,
|
||||
.footer-main .item a {
|
||||
padding: 8px 5px;
|
||||
padding: 4px 0px;
|
||||
font-size: 14px;
|
||||
color: var(--footer-pipe-color);
|
||||
}
|
||||
|
|
@ -7816,7 +7816,7 @@ html.no-touch.no-reduced-motion
|
|||
|
||||
.footer-main .foot-nav-items .item-title {
|
||||
color: var(--footer-directory-title-color);
|
||||
font-size: 14px;
|
||||
/* font-size: 14px; */
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Reference in a new issue