Merge pull request #85 from wen-templari/add-repair-admin

feat: add repair admin pages
This commit is contained in:
clas 2025-05-28 12:10:51 +08:00 committed by GitHub
commit 83208e234f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 1199 additions and 134 deletions

View file

@ -9,6 +9,6 @@ FROM base AS build
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
RUN pnpm run build RUN pnpm run build
FROM nginx:alpine as deploy FROM nginx:alpine AS deploy
COPY nginx.conf /etc/nginx/nginx.conf COPY nginx.conf /etc/nginx/nginx.conf
COPY --from=build /app/dist /usr/share/nginx/html COPY --from=build /app/dist /usr/share/nginx/html

View file

@ -24,7 +24,9 @@
"@fullcalendar/react": "^6.1.17", "@fullcalendar/react": "^6.1.17",
"@headlessui/vue": "^1.7.23", "@headlessui/vue": "^1.7.23",
"@heroui/react": "2.7.6", "@heroui/react": "2.7.6",
"@internationalized/date": "^3.8.1",
"@logto/browser": "^2.2.18", "@logto/browser": "^2.2.18",
"@react-stately/data": "^3.13.0",
"@stylistic/eslint-plugin": "^2.13.0", "@stylistic/eslint-plugin": "^2.13.0",
"astro": "^4.16.18", "astro": "^4.16.18",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",

View file

@ -38,9 +38,15 @@ importers:
'@heroui/react': '@heroui/react':
specifier: 2.7.6 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) 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': '@logto/browser':
specifier: ^2.2.18 specifier: ^2.2.18
version: 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': '@stylistic/eslint-plugin':
specifier: ^2.13.0 specifier: ^2.13.0
version: 2.13.0(eslint@8.57.1)(typescript@5.8.3) version: 2.13.0(eslint@8.57.1)(typescript@5.8.3)
@ -1465,8 +1471,8 @@ packages:
'@internationalized/date@3.7.0': '@internationalized/date@3.7.0':
resolution: {integrity: sha512-VJ5WS3fcVx0bejE/YHfbDKR/yawZgKqn/if+oEeLqNwBtPzVB06olkfcnojTmEMX+gTpH+FlQ69SHNitJ8/erQ==} resolution: {integrity: sha512-VJ5WS3fcVx0bejE/YHfbDKR/yawZgKqn/if+oEeLqNwBtPzVB06olkfcnojTmEMX+gTpH+FlQ69SHNitJ8/erQ==}
'@internationalized/date@3.8.0': '@internationalized/date@3.8.1':
resolution: {integrity: sha512-J51AJ0fEL68hE4CwGPa6E0PO6JDaVLd8aln48xFCSy7CZkZc96dGEGmLs2OEEbBxcsVZtfrqkXJwI2/MSG8yKw==} resolution: {integrity: sha512-PgVE6B6eIZtzf9Gu5HvJxRK3ufUFz9DhspELuhW/N0GuMGMTLvPQNRkHP2hTuP9lblOk+f+1xi96sPiPXANXAA==}
'@internationalized/message@3.1.7': '@internationalized/message@3.1.7':
resolution: {integrity: sha512-gLQlhEW4iO7DEFPf/U7IrIdA3UyLGS0opeqouaFwlMObLUzwexRjbygONHDVbC9G9oFLXsLyGKYkJwqXw/QADg==} resolution: {integrity: sha512-gLQlhEW4iO7DEFPf/U7IrIdA3UyLGS0opeqouaFwlMObLUzwexRjbygONHDVbC9G9oFLXsLyGKYkJwqXw/QADg==}
@ -1866,6 +1872,11 @@ packages:
peerDependencies: peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 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': '@react-stately/datepicker@3.13.0':
resolution: {integrity: sha512-I0Y/aQraQyRLMWnh5tBZMiZ0xlmvPjFErXnQaeD7SdOYUHNtQS4BAQsMByQrMfg8uhOqUTKlIh7xEZusuqYWOA==} resolution: {integrity: sha512-I0Y/aQraQyRLMWnh5tBZMiZ0xlmvPjFErXnQaeD7SdOYUHNtQS4BAQsMByQrMfg8uhOqUTKlIh7xEZusuqYWOA==}
peerDependencies: peerDependencies:
@ -2140,6 +2151,11 @@ packages:
peerDependencies: peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 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': '@react-types/slider@3.7.10':
resolution: {integrity: sha512-Yb8wbpu2gS7AwvJUuz0IdZBRi6eIBZq32BSss4UHX0StA8dtR1/K4JeTsArxwiA3P0BA6t0gbR6wzxCvVA9fRw==} resolution: {integrity: sha512-Yb8wbpu2gS7AwvJUuz0IdZBRi6eIBZq32BSss4UHX0StA8dtR1/K4JeTsArxwiA3P0BA6t0gbR6wzxCvVA9fRw==}
peerDependencies: peerDependencies:
@ -7395,7 +7411,7 @@ snapshots:
dependencies: dependencies:
'@swc/helpers': 0.5.17 '@swc/helpers': 0.5.17
'@internationalized/date@3.8.0': '@internationalized/date@3.8.1':
dependencies: dependencies:
'@swc/helpers': 0.5.17 '@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)': '@react-aria/calendar@3.7.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies: 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/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/interactions': 3.24.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@react-aria/live-announcer': 3.4.2 '@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)': '@react-aria/datepicker@3.14.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies: dependencies:
'@internationalized/date': 3.7.0 '@internationalized/date': 3.8.1
'@internationalized/number': 3.6.1 '@internationalized/number': 3.6.1
'@internationalized/string': 3.2.6 '@internationalized/string': 3.2.6
'@react-aria/focus': 3.20.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@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)': '@react-aria/i18n@3.12.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies: dependencies:
'@internationalized/date': 3.8.0 '@internationalized/date': 3.8.1
'@internationalized/message': 3.1.7 '@internationalized/message': 3.1.7
'@internationalized/number': 3.6.1 '@internationalized/number': 3.6.1
'@internationalized/string': 3.2.6 '@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)': '@react-aria/i18n@3.12.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies: dependencies:
'@internationalized/date': 3.8.0 '@internationalized/date': 3.8.1
'@internationalized/message': 3.1.7 '@internationalized/message': 3.1.7
'@internationalized/number': 3.6.1 '@internationalized/number': 3.6.1
'@internationalized/string': 3.2.6 '@internationalized/string': 3.2.6
@ -8106,7 +8122,7 @@ snapshots:
'@react-stately/calendar@3.7.1(react@18.3.1)': '@react-stately/calendar@3.7.1(react@18.3.1)':
dependencies: dependencies:
'@internationalized/date': 3.7.0 '@internationalized/date': 3.8.1
'@react-stately/utils': 3.10.5(react@18.3.1) '@react-stately/utils': 3.10.5(react@18.3.1)
'@react-types/calendar': 3.6.1(react@18.3.1) '@react-types/calendar': 3.6.1(react@18.3.1)
'@react-types/shared': 3.28.0(react@18.3.1) '@react-types/shared': 3.28.0(react@18.3.1)
@ -8147,9 +8163,15 @@ snapshots:
'@swc/helpers': 0.5.17 '@swc/helpers': 0.5.17
react: 18.3.1 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)': '@react-stately/datepicker@3.13.0(react@18.3.1)':
dependencies: dependencies:
'@internationalized/date': 3.7.0 '@internationalized/date': 3.8.1
'@internationalized/string': 3.2.6 '@internationalized/string': 3.2.6
'@react-stately/form': 3.1.3(react@18.3.1) '@react-stately/form': 3.1.3(react@18.3.1)
'@react-stately/overlays': 3.6.15(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)': '@react-types/calendar@3.6.1(react@18.3.1)':
dependencies: dependencies:
'@internationalized/date': 3.7.0 '@internationalized/date': 3.8.1
'@react-types/shared': 3.28.0(react@18.3.1) '@react-types/shared': 3.28.0(react@18.3.1)
react: 18.3.1 react: 18.3.1
'@react-types/calendar@3.7.0(react@18.3.1)': '@react-types/calendar@3.7.0(react@18.3.1)':
dependencies: dependencies:
'@internationalized/date': 3.8.0 '@internationalized/date': 3.8.1
'@react-types/shared': 3.29.0(react@18.3.1) '@react-types/shared': 3.29.0(react@18.3.1)
react: 18.3.1 react: 18.3.1
@ -8412,7 +8434,7 @@ snapshots:
'@react-types/datepicker@3.11.0(react@18.3.1)': '@react-types/datepicker@3.11.0(react@18.3.1)':
dependencies: dependencies:
'@internationalized/date': 3.7.0 '@internationalized/date': 3.8.1
'@react-types/calendar': 3.7.0(react@18.3.1) '@react-types/calendar': 3.7.0(react@18.3.1)
'@react-types/overlays': 3.8.14(react@18.3.1) '@react-types/overlays': 3.8.14(react@18.3.1)
'@react-types/shared': 3.29.0(react@18.3.1) '@react-types/shared': 3.29.0(react@18.3.1)
@ -8509,6 +8531,10 @@ snapshots:
dependencies: dependencies:
react: 18.3.1 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)': '@react-types/slider@3.7.10(react@18.3.1)':
dependencies: dependencies:
'@react-types/shared': 3.29.0(react@18.3.1) '@react-types/shared': 3.29.0(react@18.3.1)

View file

@ -11,36 +11,57 @@ const year = date.getFullYear();
<div class="content-body footer-wrapper"> <div class="content-body footer-wrapper">
<div class="footer-box"> <div class="footer-box">
<div class="foot-nav"> <div class="foot-nav">
<div class="foot-nav-items"> <div class="foot-nav-items flex flex-col sm:grid gap-2 sm:gap-5 grid-cols-5 text-xs">
<div class="item"> <div class="item col-span-2">
<div class="logo">{SITE_TITLE}</div> <div>
<div class="email">Email: {SITE_EMAIL}</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>
<div class="item products"> <div class="item products">
<div class="item-title">作品</div> <div class="item-title">导航</div>
<a href="/" target="_blank">本站</a> <a href="/" target="_blank">主页</a>
<a href="https://repair.nbtca.space" target="_blank">维修管理</a> <a href="https://docs.nbtca.space" target="_blank">文档</a>
</div> </div>
<div class="item community"> <div class="item community">
<div class="item-title">社媒</div> <div class="item-title">社媒</div>
<a href="https://github.com/nbtca" target="_blank">Github</a> <a href="https://github.com/nbtca" target="_blank">Github</a>
<a href="https://qm.qq.com/q/djgZhpnlzW" target="_blank">QQ</a>
</div> </div>
<div class="item resources"> <div class="item">
<div class="item-title">友链</div> <div class="item-title">维修</div>
<a href="https://www.cnblogs.com/N3ptune">N3ptune</a> <a href="https://nbtca.space/repair/create-ticket">预约维修</a>
</div> </div>
<!-- <div class="item">
<div class="item-title">维修</div>
<a href="https://nbtca.space/repair/create-ticket">预约维修</a>
</div> -->
</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>
<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=""> <div class="w-full h-[0.5px] my-4 bg-gray-500"> </div>
&copy; {`2018-${year} ${SITE_NAME}`} <div class="flex flex-col gap-2 sm:flex-row items-start sm:items-center text-xs text-gray-500">
<a href="//github.com/austin2035/astro-air-blog">astro-air-blog</a> <div class="">
</div> Copyright &copy; {`${year} ${SITE_NAME}`}
<div> </div>
<a href="https://beian.miit.gov.cn/" target="_blank" class="text-xs ml-2">浙ICP备2021030831号</a> <div>
<a href="https://beian.miit.gov.cn/" target="_blank" class="text-xs">浙ICP备2021030831号</a>
</div>
</div> </div>
</div> </div>
</div> </div>

View file

@ -13,6 +13,11 @@ const onSignIn = async () => {
postRedirectUri: window.location.pathname, postRedirectUri: window.location.pathname,
}) })
} }
const onGoToAccountManage = async () => {
window.open("https://myid.app.nbtca.space/account/aboutme", "_blank")
}
const onSignOut = async () => { const onSignOut = async () => {
logtoClient.value?.signOut(import.meta.env.PUBLIC_LOGTO_REDIRECT_URL) logtoClient.value?.signOut(import.meta.env.PUBLIC_LOGTO_REDIRECT_URL)
} }
@ -66,10 +71,47 @@ onMounted(() => {
<div class="p-1"> <div class="p-1">
<MenuItem v-slot="{ active }"> <MenuItem v-slot="{ active }">
<button <button
@click="onSignOut" @click="onGoToAccountManage"
class="text-nowrap" 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']" :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> </button>
</MenuItem> </MenuItem>

View file

@ -3,15 +3,34 @@ import NavigationUser from "./NavigationUser.vue"
--- ---
<script> <script>
const button = document.getElementById("repair-header") import { validateRepairRole } from "../../pages/repair/RepairAdmin"
button.addEventListener("click", () => { import { makeLogtoClient } from "../../utils/auth"
window.location.href = "/repair"
}) 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> </script>
<div class="box-border border-b sticky top-0 bg-white/80 backdrop-blur z-20 h-12"> <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]"> <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> <span id="repair-header" class="font-semibold select-none cursor-default">维修</span>
<NavigationUser client:load /> <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>
</div> </div>

View file

@ -1,5 +1,5 @@
export const SITE_TITLE = `拔电关机` export const SITE_TITLE = `拔电关机`
export const SITE_EMAIL = "contact@nbtca.space" 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_DESCRIPTION = "Computer Association"
export const SITE_URL = import.meta.env.PUBLIC_SITE_URL export const SITE_URL = import.meta.env.PUBLIC_SITE_URL

View file

@ -5,9 +5,7 @@ pubDate: 2024-06-18
description: ' “时间之河滚滚而去,青春韶华转瞬即逝”' description: ' “时间之河滚滚而去,青春韶华转瞬即逝”'
author: 'kongbai' author: 'kongbai'
cover: cover:
url: 'https://oss.nbtca.space/blog/clas/YQL05614-5xNq3t-mid-ulLkGV.jpeg' 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: ["活动","志愿者", "新闻稿", "影留宁理","摄影","毕业"] tags: ["活动","志愿者", "新闻稿", "影留宁理","摄影","毕业"]
theme: 'white' theme: 'white'
featured: true featured: true

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 MiB

View file

@ -5,7 +5,7 @@ pubDate: 2024-10-15
description: "活动的一些片段" description: "活动的一些片段"
author: "kongbai" author: "kongbai"
cover: cover:
url: ./_assets/post20241016/free_huaji.jpg url: https://oss.nbtca.space/blog/free_huaji_0F7NMW.jpg
alt: "cover" alt: "cover"
tags: ["活动"] tags: ["活动"]
--- ---

View file

@ -5,7 +5,7 @@ pubDate: 2025-01-12
description: ' 人类从历史中学到的唯一教训,就是人类无法从历史中学到任何教训' description: ' 人类从历史中学到的唯一教训,就是人类无法从历史中学到任何教训'
author: 'zzh0u' author: 'zzh0u'
cover: cover:
url: ./_assets/workSummary/IMG_0069.jpg url: https://oss.nbtca.space/blog/IMG_0069_8xFcDr.jpeg
tags: ["闲聊"] tags: ["闲聊"]
theme: 'white' theme: 'white'
featured: true featured: true

View 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
}

View file

@ -1,7 +1,7 @@
import { useEffect, useState } from "react" import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from "react"
import type { components } from "../../types/saturday" import type { components } from "../../types/saturday"
import { saturdayClient } from "../../utils/client" 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 type { PublicMember } from "../../store/member"
import dayjs from "dayjs" import dayjs from "dayjs"
import { EventStatus, UserEventAction } from "../../types/event" import { EventStatus, UserEventAction } from "../../types/event"
@ -15,8 +15,6 @@ function EventLogItem(props: {
}) { }) {
return ( return (
<div className="py-1 flex items-center"> <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>
<div className="flex items-center"> <div className="flex items-center">
<div className="mr-4"> <div className="mr-4">
@ -37,7 +35,7 @@ function EventLogItem(props: {
</span> </span>
</div> </div>
</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")} {dayjs(props.eventLog.gmtCreate).format("YYYY-MM-DD HH:mm")}
</div> </div>
</div> </div>
@ -46,20 +44,22 @@ function EventLogItem(props: {
) )
} }
function EventStatusChip(props: { export function EventStatusChip(props: {
size?: "sm" | "md" | "lg"
status: string status: string
}) { }) {
const size = props.size || "md"
switch (props.status) { switch (props.status) {
case EventStatus.open: case EventStatus.open:
return <Chip></Chip> return <Chip size={size}></Chip>
case EventStatus.accepted: case EventStatus.accepted:
return <Chip></Chip> return <Chip size={size} color="primary"></Chip>
case EventStatus.committed: case EventStatus.committed:
return <Chip color="primary"></Chip> return <Chip size={size} color="secondary"></Chip>
case EventStatus.closed: case EventStatus.closed:
return <Chip color="success"></Chip> return <Chip size={size} color="success"></Chip>
case EventStatus.cancelled: case EventStatus.cancelled:
return <Chip></Chip> return <Chip size={size}></Chip>
} }
} }
@ -93,72 +93,133 @@ const filterEventLog = (event: PublicEvent) => {
} }
return filteredLogs return filteredLogs
} }
export type EventDetailRef = {
export default function EventDetail(props: { refresh: () => Promise<PublicEvent | undefined>
eventId?: number event: PublicEvent | undefined
}) {
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(() => {
const url = new URL(window.location.href)
const eventId = props.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>
)
} }
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: {
path: {
EventId: eventId,
},
},
})
setEvent(data)
return data
}
const refresh = async () => {
const url = new URL(window.location.href)
const eventId = props.eventId ?? url.searchParams.get("eventId")
console.log("refresh eventId", eventId)
if (eventId) {
return await 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">
<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="my-6 flex flex-col gap-4">
<Textarea
label="问题描述"
readOnly
name="description"
value={event.problem || ""}
/>
<Input
label="型号"
type="text"
value={event.model || ""}
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">
</div>
{
filterEventLog(event).map((v, index) => {
return (
<EventLogItem key={index} actor={v.actor} eventLog={v} />
)
})
}
</div>
</div>
<div>
{props.children(event)}
</div>
</section>
)
: (
<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

View 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>
</>
)
}

View 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>
)
}

View file

@ -181,8 +181,9 @@ function TicketFormCreated(props: {
</div> </div>
</Alert> </Alert>
</div> </div>
<div className="section-content">
<EventDetail eventId={props.event?.eventId}></EventDetail> <EventDetail eventId={props.event?.eventId}></EventDetail>
</div>
</section> </section>
) )
} }

View 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>

View file

@ -16,6 +16,9 @@ import RepairHeader from "../../components/header/RepairHeader.astro"
const logtoClient = makeLogtoClient() const logtoClient = makeLogtoClient()
const createRepairPath = "/repair/create-ticket" const createRepairPath = "/repair/create-ticket"
const authenticated = await logtoClient.isAuthenticated() const authenticated = await logtoClient.isAuthenticated()
logtoClient.getIdTokenClaims().then((res) => {
console.log(res)
})
if (!authenticated) { if (!authenticated) {
window.location.href = `/repair/login-hint?redirectUrl=${createRepairPath}` window.location.href = `/repair/login-hint?redirectUrl=${createRepairPath}`
return return
@ -65,7 +68,7 @@ import RepairHeader from "../../components/header/RepairHeader.astro"
在接受预约之外,我们每月举办 <span class="text-black">理工维修日</span>,定时定点提供维修。 在接受预约之外,我们每月举办 <span class="text-black">理工维修日</span>,定时定点提供维修。
</div> </div>
<div> <div>
在<a href="/calendar" class="mx-0.5">协会公共日历</a>上查看最近的维修日 你可以在<a href="/calendar" class="mx-0.5">协会公共日历</a>上查看最近的维修日
</div> </div>
</div> </div>
</div> </div>

View file

@ -6,7 +6,7 @@ import EventDetail from "./EventDetail"
<BaseLayout primaryTitle="Create Ticket"> <BaseLayout primaryTitle="Create Ticket">
<RepairHeader></RepairHeader> <RepairHeader></RepairHeader>
<div> <div class="section-content">
<EventDetail client:only="react"></EventDetail> <EventDetail client:only="react"></EventDetail>
</div> </div>
</BaseLayout> </BaseLayout>

View file

@ -7709,16 +7709,16 @@ html.no-touch.no-reduced-motion
} }
.footer-main .item .logo { .footer-main .item .logo {
font-size: 16px; /* font-size: 16px; */
font-weight: 600; font-weight: 600;
color: var(--black); color: var(--black);
} }
.footer-main .foot-nav-items { .footer-main .foot-nav-items {
display: flex; /* display: flex;
flex-direction: row; flex-direction: row;
justify-content: space-between; justify-content: space-between;
align-items: flex-start; align-items: flex-start; */
} }
@media screen and (min-width: 320px) { @media screen and (min-width: 320px) {
@ -7774,10 +7774,10 @@ html.no-touch.no-reduced-motion
} }
.footer-main .foot-nav-items { .footer-main .foot-nav-items {
display: flex; /* display: flex;
flex-direction: row; flex-direction: row;
justify-content: space-between; justify-content: space-between;
align-items: flex-start; align-items: flex-start; */
} }
@media screen and (min-width: 320px) { @media screen and (min-width: 320px) {
@ -7805,7 +7805,7 @@ html.no-touch.no-reduced-motion
.footer-main .item div, .footer-main .item div,
.footer-main .item a { .footer-main .item a {
padding: 8px 5px; padding: 4px 0px;
font-size: 14px; font-size: 14px;
color: var(--footer-pipe-color); color: var(--footer-pipe-color);
} }
@ -7816,7 +7816,7 @@ html.no-touch.no-reduced-motion
.footer-main .foot-nav-items .item-title { .footer-main .foot-nav-items .item-title {
color: var(--footer-directory-title-color); color: var(--footer-directory-title-color);
font-size: 14px; /* font-size: 14px; */
font-weight: 600; font-weight: 600;
} }

View file

@ -1,3 +1,5 @@
import type { components } from "./saturday"
export interface Status { export interface Status {
status: string status: string
text: string text: string
@ -76,3 +78,5 @@ export const UserEventAction: Action[] = [
icon: "status_cancelled.svg", 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 type { paths as saturdayPaths } from "../types/saturday"
import { ApiClient } from "./active" import { ApiClient } from "./active"
export const saturdayApiBaseUrl = import.meta.env.PROD ? "https://api.nbtca.space/v2" : "/saturday"
export const saturdayClient = createClient<saturdayPaths>({ export const saturdayClient = createClient<saturdayPaths>({
baseUrl: import.meta.env.PROD ? "https://api.nbtca.space/v2/" : "/saturday", baseUrl: saturdayApiBaseUrl,
}) })
export const activeClient = new ApiClient({ export const activeClient = new ApiClient({