Merge remote-tracking branch 'upstream' into schedule

This commit is contained in:
Clas Wen 2025-05-30 19:25:24 +08:00
commit 079ec3de7b
25 changed files with 1969 additions and 475 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 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

View file

@ -24,8 +24,9 @@
"@fullcalendar/react": "^6.1.17",
"@headlessui/vue": "^1.7.23",
"@heroui/react": "2.7.6",
"@internationalized/date": "^3.8.0",
"@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",

File diff suppressed because it is too large Load diff

View file

@ -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="">
&copy; {`2018-${year} ${SITE_NAME}`}
<a href="//github.com/austin2035/astro-air-blog">astro-air-blog</a>
</div>
<div>
<a href="https://beian.miit.gov.cn/" target="_blank" class="text-xs ml-2">浙ICP备2021030831号</a>
<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="">
Copyright &copy; {`${year} ${SITE_NAME}`}
</div>
<div>
<a href="https://beian.miit.gov.cn/" target="_blank" class="text-xs">浙ICP备2021030831号</a>
</div>
</div>
</div>
</div>

View file

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

View file

@ -3,15 +3,34 @@ import NavigationUser from "./NavigationUser.vue"
---
<script>
const button = document.getElementById("repair-header")
button.addEventListener("click", () => {
window.location.href = "/repair"
})
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>
<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>

View file

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

View file

@ -5,9 +5,7 @@ pubDate: 2024-06-18
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'
url: 'https://oss.nbtca.space/blog/clas/YQL05614-5xNq3t-mid-ulLkGV.jpeg'
tags: ["活动","志愿者", "新闻稿", "影留宁理","摄影","毕业"]
theme: 'white'
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

@ -1,53 +0,0 @@
---
layout: "../../../../../layouts/MarkdownPost.astro"
title: 友情链接
pubDate: 2019-12-25 14:27:01
permalink: /friends
article: false
sidebar: false
author:
name: nbtca
link: https://github.com/nbtca
tags:
- 其他
---
<!--
普通卡片列表容器,可用于友情链接、项目推荐、古诗词展示等。
cardList 后面可跟随一个数字表示每行最多显示多少个选值范围1~4默认3。在小屏时会根据屏幕宽度减少每行显示数量。
-->
::: cardList
```yaml
# - name: 麋鹿鲁哟
# desc: 大道至简,知易行难
# avatar: ./_assets/82707d577b914020b54384a615b0676e/20200122153807.jpg # 可选
# link: https://www.cnblogs.com/miluluyo/ # 可选
# bgColor: '#CBEAFA' # 可选默认var(--bodyBg)。颜色值有#号时请添加单引号
# textColor: '#6854A1' # 可选默认var(--textColor)
- name: 鲁冠泽的博客
link: https://cimoc.cn/
avatar: ./_assets/82707d577b914020b54384a615b0676e/cimoccn.png
descr: Java, Web。
- name: 江蕾的博客
link: https://www.cnblogs.com/JLay
avatar: ./_assets/82707d577b914020b54384a615b0676e/DoEH51Nj97Ah64a.png
descr: 前端。
- name: 黄文轩的博客
link: https://www.cnblogs.com/N3ptune
avatar: ./_assets/82707d577b914020b54384a615b0676e/4J9NfH1UZD3sz5I.png
descr: 网安, Linux和C/C++。
- name: 陈学书的博客
link: https://www.cnblogs.com/Flat-White
avatar: ./_assets/82707d577b914020b54384a615b0676e/VjBGkQ6c58vH4l9.png
descr: Mac, 人工智能, Web和流水账。
- name: 王纯的博客
link: https://chundot.org
avatar: ./_assets/82707d577b914020b54384a615b0676e/avatar.png
descr: Web。
- name: 章晟玮的博客
link: https://bcscb.xyz/
avatar: https://cdn.bcscb.xyz/img/1.jpg
descr: 算法记录。
```

View file

@ -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: ["活动"]
---

View file

@ -0,0 +1,396 @@
---
layout: "../../layouts/MarkdownPost.astro"
title: "如何获取属于你的操作系统"
pubDate: 2025-05-20
description: "从零构建ArchLinux"
author: "小明"
cover:
url: "https://www.svgrepo.com/show/349296/arch-linux.svg"
alt: "cover"
tags: ["指南"]
---
# 前言
Arch奉行[极简主义](https://wiki.archlinux.org/title/Arch_Linux)用户可以自行构建任何想要的功能接下来以实际本机部署为例简单介绍如何构建属于自己的archlinux
# 目录
- [准备工作](#准备工作)
- [安装介质构建](#安装介质构建)
- [基础安装](#基础安装)
- [1. 使用arch引导盘](#1.使用arch引导盘)
- [2. UEFI检查](#2.uefi检查)
- [3. 联网](#3.-联网)
- [4. 测试连通性](#4.-测试连通性)
- [5. 同步系统时钟](#5.-同步系统时钟)
- [6. 换国内源](#6.-换国内源(在国际互联网内可忽略))
- [7. 建立btrfs分区](#7.-建立btrfs分区)
- [8. 挂载,请依序从根目录开始挂载](#8.-挂载,请依序从根目录开始挂载)
- [9. 安装系统](#9.-安装系统)
- [10. 生成fstab文件](#10.-生成fstab文件)
- [11. 进入写好的新系统](#11.-进入写好的新系统)
- [12. 设置主机名称与时区](#12.-设置主机名称与时区)
- [13. 硬件时间设置](#13.-硬件时间设置)
- [14. 设置区域](#14.-设置区域)
- [15. 设置root密码](#15.-设置root密码)
- [16. 安装微码](#16.-安装微码)
- [17. 安装Grub引导](#17.-安装grub引导)
- [18. 完成安装](#18.-完成安装)
# 准备工作
电脑U盘任何移动存储介质网络基本检索能力
1. 不论你采取何种镜像方案,即使是离线版镜像构建我也推荐你准备网络条件,这样可以确保内核和工具的更新,当然如果你足够熟练也可自行抉择
2. 如果是无线网络请确保wifi名称是自己记得住的英文名因为tty环境下是没有办法显示中文的会变成一个个没办法识别的方块
3. 如果你希望在同一块硬盘上安装双系统请为archlinux留下足够大小的硬盘空间为了给自己留下安装其他软件的余地请至少[准备100GB(貌似还没有补充相关教学,请自行搜索)](请补充磁盘分区教学)并且确保EFI分区容量不小于256MB或者[新增一个额外的挂载点](https://wiki.archlinux.org/title/EFI_system_partition)
4. 检查Win10分区是否启用Bitlocker加密请提前获取恢复密钥并且关闭电源计划中的快速启动
> 在操作前请确保仔细阅读并对不太理解的检索学习,谨慎操作,及时备份,数据无价。
# 安装介质的构建
1. 仅推荐从[archlinux官方的镜像源](https://archlinux.org/download/)下载请注意arch是滚动发行版
2. 如果你要自行编译,请参考[“内核/传统编译”](https://wiki.archlinux.org/title/Kernel/Traditional_compilation)
3. 如果使用官方提供的安装镜像,我推荐你使用[ventory](https://www.ventoy.net/)烧录
# 基础安装
## 1.使用arch引导盘
> 关机插入U盘后启动进入bios选择从U盘启动在第一个选项回车以进入arch安装环境
## 2.UEFI检查
```bash
$ systemctl stop reflector.service
# 禁用自动更新软件源,因为地理上造成的特殊网络环境最好关掉
```
```bash
$ ls /sys/firmware/efi/efivars
# 若输出了一堆efi变量则启动方式确实为UEFI模式本帖发布的2025年绝大多数机器是UEFI引导的
```
## 3. 联网
> archlinux的安装必须要求网络环境离线安装步骤则更为繁琐可参考社区的[Offline installation](https://wiki.archlinux.org/title/Offline_installation)
有线网络连接则按下不表,连上网线检查接口提示灯是否闪烁,等待几秒地址分配完成建立连接后即可联网
当然在校园网环境下则需要上级路由完成认证,可以参考[nbtverify](https://github.com/nbtca/nbtverify)项目
无线网络则调用iwctl进行连接
```bash
$ lspci -k | grep Network
# 检查无线网卡有没有干活,若明确无问题可以跳过检查
```
> 检查内核是否加载了无线网卡驱动
> 一般会显示形如: 00:14.3 Network controller: Intel Corporation Wi-Fi 6 AX201 (rev 20)
> 若没有执行检查无线连接是否被禁用(blocked: yes)
```bash
$ rfkill list
# 无线网卡一般叫 wlan0
```
```bash
$ ip link set wlan0 up
# 若有类似报错:Operation not possible due to RF-kill则执行
$ rfkill unblock wifi
```
```bash
# 使用iwctl联网
iwctl # 进入交互式命令行
device list # 列出无线网卡设备名,比如无线网卡看到叫 wlan0
station wlan0 scan # 扫描网络
station wlan0 get-networks # 列出所有 wifi 网络
station wlan0 connect wifi-name # 进行连接,注意这里无法输入中文。回车后输入密码即可
exit # 连接成功后退出
```
## 4. 测试连通性
```bash
ping www.bilibili.com # 测试网络连通与否
```
> 若在网络配置上有一些意外情况,可以参见[网络配置/无线网络配置](https://wiki.archlinux.org/title/Network_configuration/Wireless)
## 5. 同步系统时钟
```bash
$ timedatectl set-ntp true # 将系统时间与网络时间进行同步
$ timedatectl status # 检查服务状态
```
## 6. 换国内源(在国际互联网内可忽略)
```bash
$ vim /etc/pacman.d/mirrorlist # 准备换源,若上级路由完成代理即可忽略
Server = https://mirrors.ustc.edu.cn/archlinux/$repo/os/$arch # 中国科学技术大学开源镜像站
Server = https://mirrors.tuna.tsinghua.edu.cn/archlinux/$repo/os/$arch # 清华大学开源软件镜像站
Server = https://repo.huaweicloud.com/archlinux/$repo/os/$arch # 华为开源镜像站
```
## 7. 建立btrfs分区
```bash
$ lsblk # 显示当前分区情况
```
- 请仔细检查自己要安装arch的目标硬盘名字
- sda,nvme分别是sata和nvme协议
- sata协议的硬盘排序为sda、sdb、sdc等sda1、sda2为分区排序
- nvme协议的硬盘排序为nvme0n1、nvme1n1、nvme2n1等nvme0n1p1、nvme0n1p2为分区排序
- 以sata硬盘为示范具体硬盘自己更换指令
```bash
$ cfdisk /dev/sdx # 对安装 archlinux 的磁盘分区
```
> 是不是进入了友好的TUI页面XD
- 因为已经预先留下了空间给arch所以应该是有若干个G的FreeSpace
- 首先创建 Swap 分区。选中 Free space > 再选中操作 [New] > 然后按下回车 Enter 以新建 swap 分区(类似 Windows 的交换文件)
按下回车后会提示输入 分区大小Swap 分区建议为电脑内存大小的 60%,或者和内存大小相等 > 然后按下回车 Enter
- 默认新建的类型是 Linux filesystem我们需要将类型更改为 Linux swap。选中操作 [Type] > 然后按下回车 Enter > 通过方向键 ↑ 和 ↓ 选中 Linux swap > 最后按下回车 Enter
- 我们再只需要一个分区即可(因为使用 Btrfs 文件系统,所以根目录和用户主目录在一个分区上),所以类似的:选中 Free space > 再选中操作 [New] > 然后按下回车 Enter 以新建分区
输入 分区大小(默认是剩余的全部空间。请根据实际情况输入)> 然后按下回车 Enter
- 分区类型默认即可,无需更改。接下来选中操作 [Write] 并回车 Enter > 输入 yes 并回车 Enter 确认分区操作
要是没写入就是白忙活,所以确认[Write]了喔
```
☢️ 警告
再次提醒!请仔细检查命令和操作的正确性,否则将出现不可预料的情况。最危险的是可能造成数据丢失!
常见的错误包括不小心把 Windows 的分区删掉了 😥。
```
```bash
$ fdisk -l
# 复查磁盘情况
```
```bash
$ mkfs.fat -F32 /dev/sdxn
# 格式化并创建 Btrfs 子卷
```
> 如果你是双系统那么无需格式化因为linux可以共享Windows的EFI分区参见[Dual boot with Windows](https://wiki.archlinux.org/title/Dual_boot_with_Windows),只需注意EFI分区大小是否足够你安装
```bash
$ mkswap /dev/sdxn
# 格式化 Swap 分区
```
```bash
$ mkfs.btrfs -L myArch /dev/sdxn
# 格式化 Btrfs 分区
```
```bash
$ mount -t btrfs -o compress=zstd /dev/sdxn /mnt
# 挂载分区以创建子卷
```
```bash
$ btrfs subvolume create /mnt/@ # 创建 / 目录子卷
$ btrfs subvolume create /mnt/@home # 创建 /home 目录子卷
# 创建Btrfs子卷
```
```bash
$ umount /mnt
# 卸载/mnt以挂载子卷
```
## 8. 挂载,请依序从根目录开始挂载
```bash
$ mount -t btrfs -o subvol=/@,compress=zstd /dev/sdxn /mnt # 挂载 / 目录
$ mkdir /mnt/home # 创建 /home 目录
$ mount -t btrfs -o subvol=/@home,compress=zstd /dev/sdxn /mnt/home # 挂载 /home 目录
$ mkdir -p /mnt/boot # 创建 /boot 目录
$ mount /dev/sdxn /mnt/boot # 挂载 /boot 目录
$ swapon /dev/sdxn # 挂载交换分区
```
```zsh
$ df -h # 检查挂载
$ free -h # 复查Swap分区挂载
```
## 9. 安装系统
```bash
$ pacstrap /mnt base base-devel linux linux-firmware btrfs-progs
# 如果使用btrfs文件系统额外安装一个btrfs-progs包
```
```bash
$ pacman -S archlinux-keyring
# 如果提示 GPG 证书错误,可能是因为使用的不是最新的镜像文件,可以通过更新 archlinux-keyring 解决此问题
```
```zsh
$ pacstrap /mnt networkmanager vim sudo zsh zsh-completions
# 使用pacstrap脚本安装必要功能性软件
```
## 10. 生成fstab文件
> 生成fstab以定义磁盘分区受当前挂载情况影响
```zsh
$ genfstab -U /mnt > /mnt/etc/fstab
```
## 11. 进入写好的新系统
```zsh
$ arch-chroot /mnt
# 代码高亮消失了不要慌说明你已经成功change root了
```
## 12. 设置主机名称与时区
```zsh
$ vim /etc/hostname
# 给电脑起个名字吧XD不要包含特殊字符和空格不然有坑的并且不起主机名会有时候出奇怪问题一些GUI程序莫名其妙死了不论怎样还是起一个名字
```
```zsh
$ vim /etc/hosts
# 编辑主机host
```
> 填入如下内容(其中myarch替换成你自己的主机名中间间隙不是空格是tab对齐强迫症狂喜XD)
```zsh
127.0.0.1 localhost
::1 localhost
127.0.1.1 myarch.localdomain myarch
```
```zsh
$ ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
# 在上海时区创建符号链接
# 别问为什么是上海因为没有北京XD当然你也可以使用其他时区
```
```zsh
$ ls /usr/share/zoneinfo/
# 检查你要的时区,更换上条命令的地址
```
## 13. 硬件时间设置
```zsh
$ hwclock --systohc
# 系统时间同步到硬件时间
```
## 14. 设置区域
```zsh
$ vim /etc/locale.gen
# 编辑 /etc/locale.gen去掉 en_US.UTF-8 UTF-8 以及 zh_CN.UTF-8 UTF-8 行前的注释符号(#
# 这一步决定了软件使用的语言和字符集
```
```zsh
$ locale-gen
# 生成locale
```
```zsh
$ echo 'LANG=en_US.UTF-8' > /etc/locale.conf
# 注入locale.conf不推荐任何中文localetty会乱码
```
## 15. 设置root密码
```zsh
$ passwd root
# 输入密码是隐式的并不会显示并非键盘坏了XD
```
## 16. 安装微码
```zsh
$ pacman -S intel-ucode # Intel
$ pacman -S amd-ucode #AMD
```
## 17. 安装Grub引导
```zsh
$ pacman -S grub efibootmgr os-prober
# grub是启动引导器efibootmgr是被启动器用来向nvram写入启动项os-prober用于引导win10
```
```zsh
$ grub-install --target=x86_64-efi --efi-directory=/boot --bootloader-id=ARCH
# 安装grub到EFI分区
```
```zsh
$ vim /etc/default/grub
# 编辑启动参数
```
```zsh
# 修改"loglevel=3 quiet" 至 "loglevel=5 nowatchdog"
# 文件末尾新增一行GRUB_DISABLE_OS_PROBER=false
```
- 去掉 GRUB_CMDLINE_LINUX_DEFAULT 一行中最后的 quiet 参数
- 把 loglevel 的数值从 3 改成 5。这样是为了后续如果出现系统错误方便排错
- 加入 nowatchdog 参数,这可以显著提高开关机速度
- 加入os-prober参数用于引导win10
```zsh
$ grub-mkconfig -o /boot/grub/grub.cfg
# 生成grub所需配置文件
```
> 若检查到win10则会多出一行“Found Windows Boot Manager on /dev/nvme0n1p1@/EFI/Microsoft/Boot/bootmgfw.efi done”的回显若win10在另一块硬盘则不会输出进系统后重新挂载再跑一遍即可
> 此处的全部参数可参见[archwiki](https://wiki.archlinux.org/title/GRUB)
## 18. 完成安装
```zsh
$ exit # 退回安装环境
$ umount -R /mnt # 卸载新分区
$ reboot # 重启
```
> 重启后用root账户登录
```zsh
$ systemctl enable --now NetworkManager # 设置开机自启并立即启动 NetworkManager 服务
$ ping www.bilibili.com # 测试网络连接
```
> 如果是无线网的话
```zsh
$ nmcli dev wifi list # 显示附近的 Wi-Fi 网络
$ nmcli dev wifi connect "Wi-Fi名SSID" password "网络密码" # 连接指定的无线网络
```
```zsh
$ nmtui
# 个人还是比较喜欢nmtui比较友好XD
```
```zsh
$ pacman -S fastfetch
$ fastfetch
# 安装fastfetch检查系统信息
# 喜闻乐见的neofetch时间XD
```
```zsh
$ shutdown 0
$ shutdown -h now
$ poweroff
# 上面三个命令都是关机,🤣记得关机,电源策略还没写呢
```
---
# 恭喜🎉
> 至此你已经完成一个基础无图形界面的archlinux安装了
> 图形化安装应该会在下一次更新发布,不过还是那句老话:多看手册
> 本文抛砖引玉,希望能吸引更多同好前来计协蕉流♂

View file

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

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 { 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,72 +93,133 @@ const filterEventLog = (event: PublicEvent) => {
}
return filteredLogs
}
export default function EventDetail(props: {
eventId?: number
}) {
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>
)
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: {
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>
</Alert>
</div>
<EventDetail eventId={props.event?.eventId}></EventDetail>
<div className="section-content">
<EventDetail eventId={props.event?.eventId}></EventDetail>
</div>
</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 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>

View file

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

View file

@ -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;
}

View file

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

View file

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