Compare commits

...
22 Commits
Author SHA1 Message Date
labmem 26afe86e5a Merge branch 'main' of https://git.labmem.icu/labmem/lab 2026-09-07 22:46:45 +08:00
labmemandClaude Opus 4.8 178478190f feat(polaris): 完善首页背景与搜索入口视觉
使用本地背景和官方 Provider 图标,闭合状态仅展示当前图标。
调整冷蓝磨砂层次,并移除快捷卡片外部阴影以消除底部色带。
补充搜索 Provider 展示行为测试。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-07 22:25:30 +08:00
labmemandClaude Opus 4.8 c3d2a40c1f feat(ts): 实现 Polaris 可交互首页
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-02 23:53:28 +08:00
张育新 e21e65d898 Merge branch 'main' of https://git.labmem.icu/labmem/lab 2026-08-22 18:19:16 +08:00
张育新 746397dd1e docs(note): 新增 SSH 密钥登录配置笔记 2026-08-22 18:17:13 +08:00
labmem 95c3da0f71 docs(ts): 重组 Polaris 设计文档体系并迁移至 nextjs 目录
- 拆分为项目说明/首页设计/分类页设计三份文档,职责分离
- 新增项目说明文档:14 条决策记录、技术选型(Next.js 静态导出)、
  数据结构、Go API 草案、通用语法表结构、五阶段规划
- 融入决策:JSON 数据载体、Ctrl+K 仅首页生效、统一图标方案
  (si:/local:/url: 前缀 + 四级 fallback)、搜索按模式严格分离、
  首页补足 8 个常用链接(跨分类归属)
- 目录 ts/web/astro/polaris → ts/web/nextjs/polaris,
  删除过时 README,更新 ts/README.md 框架描述
2026-08-21 22:08:14 +08:00
labmem 86182ce5d7 docs(ts): 新增 Polaris 个人导航站说明文档与设计图
- 新增 Polaris 个人导航站项目说明文档,涵盖项目定位、视觉风格、
  首页布局、搜索方案、技术选型、数据结构与分阶段开发规划
- 新增 Polaris 首页页面设计图
2026-08-20 23:23:53 +08:00
labmem cde8281523 Merge branch 'main' of https://git.labmem.icu/labmem/lab 2026-08-17 23:44:23 +08:00
labmem 5263eb3a5f docs(ts): 新增 TypeScript 实验目录及 Astro 项目说明
- 添加 ts/README.md 约定文档(每个实验独立 npm 项目)
- 新增 ts/web/astro/polaris 实验项目说明与 logo
- .gitignore 增加 Node/TypeScript 忽略规则
- 根 README 目录说明补充 ts/ 条目
2026-08-17 23:29:26 +08:00
张育新 f40efea159 docs(note): 补充 go 命令/excelize/MySQL 字符集笔记 2026-08-08 18:20:19 +08:00
张育新 722a5b13e0 test(go): 添加 errors.AsType、net 及手机号正则匹配测试 2026-08-08 18:20:19 +08:00
张育新 a2dbd51f35 Merge branch 'main' of https://github.com/OSHMKUFA5100/lab
# Conflicts:
#	note/随手记/go.md
2026-08-06 09:15:15 +08:00
张育新 241a7c43da docs(note): 新增 excel/正则/Nginx 笔记并整理目录
- 新增 AGENTS.md 仓库说明与 excelize 依赖
- 新增正则表达式、go操作excel、Nginx常见错误、Golang底层架构笔记
- 布尔变量命名迁移至资源/编程/规范,删除随手记旧笔记
2026-08-05 18:32:17 +08:00
张育新 735a339deb test(regularMatch): 添加中文正则匹配测试 2026-08-05 18:31:59 +08:00
labmem b6063cb9f9 docs(go): 添加内存逃逸学习笔记与示例代码
- 新增 go/basics/MemoryEscape/main.go,演示返回局部变量指针和闭包引用两种典型逃逸场景
- 在 note/随手记/go.md 中补充内存逃逸的概念、影响、常见原因及逃逸分析命令
- 清理 note/随手记/网址.md 中的多余空行
2026-07-23 00:07:16 +08:00
张育新 ef18635509 docs(note): 补充技术笔记和主题配置
- Go map Swiss Table 实现说明
- MarkText 仿 onelight 暗色主题
- 布尔变量命名规范
- 更新网址收藏及跟踪 assets 资源
2026-07-21 17:53:15 +08:00
张育新 3819ae9d94 Merge branch 'main' of https://github.com/OSHMKUFA5100/lab 2026-07-15 19:14:20 +08:00
张育新 e486d47e2a docs(note): reorganize notes structure and migrate content
- Move Windows and 算法 notes from 随手记 to 资源 directory
- Migrate Golang map pitfalls and dot import notes to Golang/使用注意
- Delete omos配置 note
- Add Typora custom theme reference link
2026-07-15 19:12:07 +08:00
labmem e6fe90a7c0 Merge branch 'main' of https://github.com/OSHMKUFA5100/lab 2026-07-13 23:12:38 +08:00
张育新 e00245ea66 docs(note): 补充 git merge --abort 取消合并命令 2026-07-13 22:46:30 +08:00
张育新 f7f5d379a9 test(testing): 添加中位数计算和通道通信测试用例
- 新增 TestNormal: 测试 GetMiddNumber 中位数计算逻辑
- 新增 TestChannel: 模拟通道传球 goroutine 交互
2026-07-13 22:46:07 +08:00
张育新 be711334fb docs(note): 添加笔记目录及 gitignore 规则
- 新增 note 目录包含 AI、SQL、Golang、网络等多领域学习笔记
- 更新 .gitignore 添加 note/assets/ 和 note/项目/ 忽略规则
2026-07-10 09:24:40 +08:00
117 changed files with 23119 additions and 1 deletions
+17
View File
@@ -19,6 +19,20 @@ vendor/
.pub/
build/
# ---- Node / TypeScript ----
node_modules/
dist/
.astro/
*.tsbuildinfo
.next/
out/
coverage/
.env*
!.env.example
# ---- Claude Code ----
.claude/worktrees/
# ---- IDE / 编辑器 ----
.idea/
.vscode/
@@ -28,3 +42,6 @@ build/
# ---- 操作系统 ----
.DS_Store
Thumbs.db
# ---- Note 目录 ----
note/项目/
+36
View File
@@ -0,0 +1,36 @@
# AGENTS.md
## 仓库结构
按语言分层的个人练习仓库,每种语言一个顶层目录,各自独立管理依赖。
| 目录 | 说明 |
|---|---|
| `go/` | Go module `lab`(Go 1.26.2),go.mod 在该目录内 |
| `note/` | 个人笔记(非代码),`note/项目/` 被 gitignore |
| `dart/` | 预留,尚未建立 |
## Go 约定
- **Module 在 `go/` 目录下,不在仓库根**——所有 go 命令需在 `go/` 内执行
- 每个叶子目录 = 一个独立小程序(`package main` + `main.go`)
- 多个小程序可并存,不同目录、各自独立
- 主题目录(basics/concurrency/stdlib/patterns/algorithms/web/testing/project)可随时增删
- 测试文件用 `*_test.go`,运行单个测试:`cd go && go test ./testing/ -run TestSliceAppend`
## 开发命令
```
# Go——所有命令在 go/ 目录下执行
cd go
go run ./<主题>/<程序名>/ # 运行单个程序
go test ./... # 运行全部测试
go test ./testing/ -v # 运行 testing 包测试
go mod tidy # 整理依赖
```
## 注意事项
- 仓库无 CI、无 pre-commit hooks、无代码检查——每个程序自行保证可编译即可
- 笔记和练习代码严格分离:程序相关说明写在程序目录的 README.md,系统性知识放 `note/`
- `.gitignore` 已覆盖 Go 二进制、Dart/Flutter 构建产物、IDE 配置、操作系统文件
+1
View File
@@ -6,6 +6,7 @@
- `go/` — Go(共用一个 module,约定见 [go/README.md](go/README.md))
- `dart/` — Dart
- `ts/` — TypeScript / JavaScript(每个实验独立 npm 项目,约定见 [ts/README.md](ts/README.md))
## 约定
+25
View File
@@ -0,0 +1,25 @@
package main
import (
"fmt"
)
type User struct {
Name string
}
func NewUser() *User {
u := User{}
return &u
}
func ClosureFunc() func() int {
a := 1
return func() int {
return a
}
}
func main() {
fmt.Println(NewUser())
cf:= ClosureFunc()
fmt.Println(cf())
}
+12
View File
@@ -1,3 +1,15 @@
module lab
go 1.26.2
require (
github.com/richardlehane/mscfb v1.0.7 // indirect
github.com/richardlehane/msoleps v1.0.6 // indirect
github.com/tiendc/go-deepcopy v1.7.2 // indirect
github.com/xuri/efp v0.0.1 // indirect
github.com/xuri/excelize/v2 v2.11.0 // indirect
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
golang.org/x/crypto v0.53.0 // indirect
golang.org/x/net v0.56.0 // indirect
golang.org/x/text v0.38.0 // indirect
)
+18
View File
@@ -0,0 +1,18 @@
github.com/richardlehane/mscfb v1.0.7 h1:oeoiM0WE79vHwE8RpIYYvIAc8ajTH2mb6UZm55/+EB0=
github.com/richardlehane/mscfb v1.0.7/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo=
github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg=
github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44=
github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
github.com/xuri/excelize/v2 v2.11.0 h1:HxaEFl6sRN2+8J5a8HaKq+0M4FsjBGMnWWtjOCPSG88=
github.com/xuri/excelize/v2 v2.11.0/go.mod h1:jxFLbzaIwGQ5ufFNvYfUOHqXhfPaNmP14KWfmNz2Uak=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
+34
View File
@@ -0,0 +1,34 @@
package error
import (
"errors"
"fmt"
"testing"
)
type MatchError struct {
code int
err error
}
func (e *MatchError) Error() string {
return fmt.Sprintf("code: %d, error: %v", e.code, e.err)
}
func GetMatchError() error {
return &MatchError{code: 1, err: errors.New("123")}
}
func GetError() error {
return errors.New("123")
}
func TestError(t *testing.T) {
if err, ok := errors.AsType[*MatchError](GetMatchError()); ok {
t.Log(err)
} else {
t.Errorf("expected MatchError, got %T", GetMatchError())
}
if err, ok := errors.AsType[*MatchError](GetError()); ok {
t.Log(err)
} else {
t.Errorf("expected MatchError, got %T", GetError())
}
}
+18
View File
@@ -0,0 +1,18 @@
package net
import (
"net"
"testing"
)
func TestNet(t *testing.T) {
s := net.JoinHostPort("127.0.0.1", "8080")
t.Log(s)
t.Log(s)
addrs, err := net.LookupAddr("198.18.0.18")
if err != nil {
t.Fatal(err)
}
t.Log(addrs)
}
+48
View File
@@ -0,0 +1,48 @@
package testing
import (
"sort"
"testing"
"time"
)
func TestNormal(t *testing.T) {
numbers := map[int][]int{
1: {8, 2, 4, 4},
2: {0, 5, 3},
3: {1},
}
for _, numbers := range numbers {
middle := GetMiddNumber(numbers)
t.Log(middle)
}
}
func GetMiddNumber(numbers []int) float64 {
sort.Ints(numbers)
length := len(numbers)
isOddNUmber := length%2 == 1
if !isOddNUmber {
return float64(numbers[length/2-1]+numbers[length/2]) / 2.0
}
return float64(numbers[length/2])
}
func TestChannel(t *testing.T) {
var ball = make(chan string)
kickBall := func(playerName string) {
for {
t.Log(<-ball, "传球", "\n")
time.Sleep(time.Second)
ball <- playerName
}
}
go kickBall("张三")
go kickBall("李四")
go kickBall("王二麻子")
go kickBall("刘大")
ball <- "裁判" // 开球
var c chan bool // 一个零值nil通道
<-c // 永久阻塞在此
}
+34
View File
@@ -0,0 +1,34 @@
package regularmatch
import (
"regexp"
"testing"
)
func TestChineseRegExp(t *testing.T) {
reg, err := regexp.Compile("[\u4e00-\u9fa5]")
if err != nil {
t.Errorf("Failed to compile regex: %v", err)
}
matchStrSlice := reg.FindAllString("hello 世界", -1)
if len(matchStrSlice) != 2 {
t.Errorf("Expected 2 matches, got %d", len(matchStrSlice))
}
if matchStrSlice[0] != "世" || matchStrSlice[1] != "界" {
t.Errorf("Expected [世, 界], got %v", matchStrSlice)
}
}
func TestChinaMobile(t *testing.T) {
reg, err := regexp.Compile(`^1(3\d|4[5-9]|5[0-35-9]|6[2567]|7[0-8]|8\d|9[0-35-9])\d{8}$`)
if err != nil {
t.Errorf("Failed to compile regex: %v", err)
}
// matchStrSlice := reg.FindAllString("123-12345678 1234-12345678", -1)
// if len(matchStrSlice) != 2 {
// t.Errorf("Expected 2 matches, got %d", len(matchStrSlice))
// }
matchStrSlice := reg.FindAllString("17623862704", -1)
t.Log(matchStrSlice[0])
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

+303
View File
@@ -0,0 +1,303 @@
# HTTP
## 上传文件
> **https://github.com/zeromicro/zero-examples/tree/main/http/upload**
### 前端
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>Document</title>
</head>
<body>
<form
enctype="multipart/form-data"
action="http://localhost:8888/upload"
method="post"
>
<input type="file" name="myFile" />
<input type="submit" value="upload" />
</form>
</body>
</html>
```
> **参数说明**
- `enctype="multipart/form-data"` 用于支持文件的二进制传输
- `<input type="file">` 让用户挑选本地文件
- `action` 和 `method`,将数据以 `POST` 方式提交到后端地址
### 后端
```yaml
Name: upload-api
Host: 0.0.0.0
Port: 8888
MaxBytes: 1073741824
Timeout: 30000
```
> **yaml文件需要设置上传的最大字节,并且如果为大文件,需要考虑超时时间设置是否合理**
```go
func (l *UploadLogic) Upload(r *http.Request) (resp *types.Response, err error) {
err = r.ParseMultipartForm(10 << 27)
if err != nil {
fmt.Println("错误发生:", err)
return nil, err
}
file, handler, err := r.FormFile("myFile")
if err != nil {
fmt.Println(err)
return nil, err
}
defer file.Close()
fmt.Printf("Uploaded File: %+v\n", handler.Filename)
fmt.Printf("File Size: %+v\n", handler.Size)
fmt.Printf("MIME Header: %+v\n", handler.Header)
tempFile, err := os.Create(path.Join(".", handler.Filename))
if err != nil {
fmt.Println(err)
return nil, err
}
defer tempFile.Close()
io.Copy(tempFile, file)
return &types.Response{
OK: 0,
}, nil
}
```
> **API逻辑说明**
- 需要在handler里面将`*http.Request`传递到逻辑层
- `ParseMultipartForm`用于解析 `multipart/form-data` 类型的请求体
主要作用是:
​ 解析表单字段(包括文件上传字段)
​ 将请求体拆分成多个部分(parts),并填充到 `r.MultipartForm` 中
​ 内存与磁盘分配:最多在内存中保留 `maxFileSize` 字节,超出的部分会写入临时文件
- `FormFile(key string)` 从已解析的 `r.MultipartForm` 中提取名为 `"myFile"` 的文件部分
- `tempFile, err := os.Create(path.Join(".", handler.Filename))`将文件上传到当前目录
# DTM
## 基础概念
- **AP(Application)**:应用程序,发起全局事务请求。
- **TM(Transaction Manager)**:事务协调者,负责协调分布式事务的执行流程。
- **RM(Resource Manager)**:资源管理者,例如订单服务、库存服务等,执行具体业务与补偿逻辑
- **事务分支**:我们把每个服务管理的全局事务组成部分,称为事务分支,例如前面的转账,分为转出和转入两个事务分支
- **分支操作**:每个事务分支,在SAGA、XA、TCC等事务模式下,会有多个操作,例如转出事务分支,包括正向操作TransOut和TransOutCompensate
- **本地事务**:转出事务分支中的正向操作,通常会开启一个事务,对余额进行扣减,我们将数据操作的这个事务,称为本地事务
- **GID**:全局事务ID,用于标记全局事务,必须唯一。该ID可以采用UUID生成,也可以使用业务上的ID,例如订单ID等
| 模式 | 场景 | 典型使用 |
| ---------- | ---------------------------------------- | --------------------------------- |
| 二阶段消息 | 需要确保消息通知或缓存更新与本地事务同步 | 发短信/推送、消息投递、缓存一致性 |
| Saga | 流程复杂、长链路、不需强一致性 | 电商下单→扣库存→支付等微服务流程 |
| TCC | 强一致、高隔离资源场景 | 金融系统、库存冻结、大额支付 |
## 二阶段消息
是dtm首创的事务模式,用于替换本地事务表和事务消息这两种现有的方案。它能够保证本地事务的提交和全局事务提交是“原子的”,适合解决不需要回滚的分布式事务场景,比如发消息、更新缓存等
- **准备阶段**(Prepare) ,应用先发一个准备信号给 DTM Server,表示“我现在要做本地事务了”。DTM Server 接收到后先记录这个请求,但不触发后续通知。
- 执行本地事务, 应用开始执行本地事务(如写数据库、扣款),并在同一个数据库里,记录这次全局事务的 gid(全局事务 ID)。这保证了后续可以根据 gid 判断本地事务是否已成功。
- **提交阶段**(Submit), 本地事务成功后,应用再向 DTM 提交提交请求。DTM 执行这个操作后,才真正触发下游服务或消息投递。开始调用其他微服务或消息队列,实现完整的全局动作。
**优点:**
- 保证“本地事务 + 消息”业务要么同时成功,否则都不执行。(强触发保障)
- 对开发者透明,业务侵入小。
**缺点:**
- 需要开发一套 Prepare/Submit 调用,系统需定期监控、回查状态。
- 不适用于需要复杂补偿的场景。
## SAGA
核心思想是将长事务拆分为多个短事务,由Saga事务协调器协调,如果每个短事务都成功提交完成,那么全局事务就正常完成,如果某个步骤失败,则根据相反顺序一次调用补偿操作。例如我们要进行一个类似于银行跨行转账的业务,将A中的30元转给B,根据Saga事务的原理,我们将整个全局事务,切分为以下服务:
- **转出**(TransOut)服务,这里转出将会进行操作A-30
- **转出补偿**(TransOutCompensate)服务,回滚上面的转出操作,即A+30
- **转入**(TransIn)服务,转入将会进行B+30
- **转入补偿**(TransInCompensate)服务,回滚上面的转入操作,即B-30
**优点:**
- 流程自然、开发简单,只要为操作实现补偿逻辑即可。
- 对资源无锁设计,支持高并发、长流程,性能佳。
**缺点:**
- 是最终一致性,存在临时中间状态(如库存已扣但未支付)。
- 无事务隔离,可能出现脏写或并发冲突。
- 补偿逻辑可能复杂易错(幂等、防重复)。
## TCC
TCC 是一种“预留-确认-取消”的事务模式,将事务分为三个阶段,高精度控制点 的场景,比如:支付系统、资金冻结、库存预扣等
- **Try** 阶段:尝试执行,完成所有业务检查(一致性), 预留必须业务资源(准隔离性)
- **Confirm** 阶段:如果所有分支的Try都成功了,则走到Confirm阶段。Confirm真正执行业务,不作任何业务检查,只使用 Try 阶段预留的业务资源
- **Cancel** 阶段:如果所有分支的Try有一个失败了,则走到Cancel阶段。Cancel释放 Try 阶段预留的业务资源。
**优点:**
- 强一致性:通过 Try 预留资源,Confirm/Cancel 明确执行或回滚,避免中间状态。
- 高并发友好,资源锁粒度小;性能优于传统 XA/2PC。
**缺点:**
- 业务侵入性高,每个参与方需实现三个操作(Try/Confirm/Cancel)。
- 实现复杂:需处理悬挂、空回滚、幂等、补偿重试等问题。
## SAGA实践
- 准备RM和DTM数据表
[GitHub地址](https://github.com/dtm-labs/dtm/tree/main/sqls)
- DTM配置
[GitHub地址](https://github.com/dtm-labs/dtm/blob/main/conf.sample.yml)
- docker部署参考配置
```yaml
LogLevel: 'info'
Server:
HttpPort: 36789 #HTTP port
GrpcPort: 36790 #Grpc port
Store:
Driver: "mysql" # 数据库类型
Host: "mysql" # 使用容器名
Port: 3306
User: "root"
Password: "root"
Db: "dtm"
MicroService:
Driver: "dtm-driver-gozero" #使用go-zero的注册服务发现驱动
Target: "etcd://etcd:2379/dtmservice" # 使用容器名,当前dtm的server直接注册到微服务所在的etcd集群中使用
EndPoint: "dtm:36790" # 使用容器名,集群中的微服务可以直接通过etcd获得此地址跟dtm交互
```
- go-zero的yaml参考配置
```yaml
#AP使用etcd的配置
Etcd:
Hosts:
- etcd:2379
Key: order.api
#RM使用etcd的配置
OrderRpc:
Etcd:
Hosts:
- etcd:2379
Key: order.rpc
#RM使用etcd的配置
StockRpc:
Etcd:
Hosts:
- etcd:2379
Key: stock.rpc
```
SAGA 事务编排使用步骤
```go
// dtm服务在etcd中的调用地址
var dtmServer = "etcd://localhost:2379/dtmservice"
// 从配置中通过BuildTarget获得对应RM的etcd调用地址
orderTarget, err := l.svcCtx.Config.OrderRpcConf.BuildTarget()
stockTarget, err := l.svcCtx.Config.StockRpcConf.BuildTarget()
// 构造对应的请求结构体,用于后面的本地事务的执行或者回滚补偿
createOrderReq := &order.CreateReq{UserId: req.UserId, GoodsId: req.GoodsId, Num: req.Num}
deductReq := &stock.DecuctReq{GoodsId: req.GoodsId, Num: req.Num}
// 使用dtmgrpc注册一个全局事务ID,用于标记全局事务
gid := dtmgrpc.MustGenGid(dtmServer)
// 开启全局事务,并提交
// dtm需要从dtm服务器调用该方法,所以不走强类型,而是走动态的url: busiServer+"/trans.TransSvc/TransOut"
// "/trans.TransSvc/TransOut"对应的是grpc_pb.go文件中Invoke方法所使用的路径
saga := dtmgrpc.NewSagaGrpc(dtmServer, gid).
Add(
orderTarget+"/pb.order/create",
orderTarget+"/pb.order/createRollback",
createOrderReq,
).
Add(
stockTarget+"/pb.stock/deduct",
stockTarget+"/pb.stock/deductRollback",
deductReq,
)
err = saga.Submit()
```
服务实现(包含子事务屏障)
```go
// 从 gRPC 请求上下文中提取分布式事务信息,构建一个用于管理子事务屏障的对象
barrier, err := dtmgrpc.BarrierFromGrpc(l.ctx)
// 获取一个sqlx的数据库连接
db, err := sqlx.NewMysql(l.svcCtx.Config.DB.DataSource).RawDB()
if err != nil {
// 一般数据库不会错误不需要dtm回滚,就让他一直重试
// 返回为status.Error(codes.Internal, err.Error()),会让dtm一直重试,不会回滚,直到
// 返回为status.Error(codes.Aborted, dtmcli.ResultFailure),会让dtm立刻调用回滚方法
return nil, status.Error(codes.Internal, err.Error())
}
// 开启本地事务(这里没写,正常应该在本地事务中再开启子事务屏障进行业务处理
// 将业务逻辑包裹在子事务屏障的保护下执行
if err := barrier.CallWithDB(db, func(tx *sql.Tx) error {
order := new(model.Order)
order.GoodsId = in.GoodsId
order.Num = in.Num
order.UserId = in.UserId
_, err = l.svcCtx.OrderModel.Insert(tx, order)
if err != nil {
return fmt.Errorf("创建订单失败 err : %v , order:%+v \n", err, order)
}
return nil
}); err != nil {
// 同上,让dtm一直重试,不回滚
return nil, status.Error(codes.Internal, err.Error())
}
// 补偿操作跟上面差不多,就是业务逻辑修改
```
+70
View File
@@ -0,0 +1,70 @@
​
## oh-my-openagent
### Agent角色
| **Agent 名称** | **属性** | **一句话特点** |
| ----------------- | -------- | ------------------------------------------------------------ |
| Sisyphus | 台前主控 | 项目总管、日常聊天与轻量开发入口,居中调度,维持记忆的多面手 |
| Prometheus | 台前主控 | 复杂需求的规划者、Gap(差距)分析师,只出谋划策(写 Plan),不动手写代码 |
| Atlas | 台前主控 | 流水线执行者、项目经理,盯着 TODO 督战的施工队包工头 |
| Hephaestus | 台前主控 | 火神、地狱级 Bug 终结者,单兵作战,不计成本死磕疑难杂症 |
| Oracle | 幕后工具 | 代码审查员、安全顾问,挑刺、CR、架构安全性把关 |
| Momus | 幕后工具 | Debug 专家,专门吃报错日志并给出修复建议 |
| Metis | 幕后工具 | 质检员、对齐审计,检查代码跟计划对不对齐,防止跑偏 |
| Sisyphus-junior | 幕后工具 | 基层打工人、代码搬砖牛马,真正动手改文件的“牛马码农” |
| Explore | 幕后工具 | 代码库检索员,在代码库里疯狂乱窜、扫图、找文件 |
| Librarian | 幕后工具 | 文档与知识检索员,专门翻文档、查第三方库用法 |
| Multimodal-looker | 幕后工具 | 视觉质检员,负责看截图、核对 UI 视觉问题 |
### 内置分类预设
| 分类名称(Category) | 预设心智与应用场景 |
| ------------------ | ------------------------------------------------------------ |
| visual-engineering | **像素级前端 UI 专家**。专门处理前端组件、页面样式美化、CSS 动画调整,默认偏向调度带有视觉能力的模型。 |
| deep | **目标导向的硬核攻坚战**。针对棘手难啃的底层问题,严格遵循“单次调用:一个目标 + 一个交付物”原则,死磕到底。 |
| quick | **低成本牛马杂活线**。处理单文件小改动、错别字修复、简单注释补充。默认绑定极便宜的 Mini 或 Flash 模型来帮你疯狂省钱。 |
| ultrabrain | 高浓度脑力推演 |
| writing | **散文与技术文档写手**。专门用来编写官方 Markdown 文档、技术方案白皮书或代码内说明。 |
## oh-my-opencode-slim
### 预设
`oh-my-opencode-slim.json`中的顶级字段`presets`为预设对象数组,默认设置了两套:一套是`openai`,一套是`opencode-go`。可以自行增删预设或者修改`agent`角色的模型。
```
# 在opencode会话中输入
/preset <name>
```
### Agent角色
| **Agent 名称** | **角色** | **模型选择** |
| -------------- | ----------------------- | ------------------------------------------------------------ |
| Orchestrator | 首席委派者和战略协调员 | 选择您的默认、最强的全能型编程模型。Orchestrator 既是核心编程智能体,又是委派者,因此它需要强大的实现能力、出色的判断力和可靠的指令遵循度。 |
| Explorer | 代码库侦察 | 选择快速、低成本的模型。Explorer 处理宽泛的侦察工作,因此速度 and 效率通常比使用最强推理模型更重要。 |
| Oracle | 战略顾问和终极调试者 | 选择您最强的高推理模型,用于架构设计、疑难调试、方案权衡以及代码审查。 |
| Council | 多LLM共识与提炼 | 使用一个强大的综合提炼模型作为 Council 智能体本身,并选择多样化的模型作为议员。Council 的价值在于对比不同的模型视角,而不仅仅是在所有地方都选择同一个最强的模型。 |
| Librarian | 外部知识检索 | 选择快速、低成本的模型。Librarian 处理调研和文档查询,因此速度和效率通常比使用最强推理模型更重要。 |
| Designer | UI/UX实现和极致视觉呈现 | 选择在 UI/UX 判断、前端实现和视觉打磨方面表现强劲的模型。 |
| Fixer | 快速实现专家 | 选择一个快速、可靠的编程模型来执行常规且范围明确的开发工作。Fixer 通常从 Orchestrator 接收具体的计划或受限的指令,非常适合高效执行诸如编写测试、更新测试和直接的代码更改等任务。 |
| Observer | 多模态处理 | 如果Orchestrator模型不是多模态模型,可以启用 Observer 来处理图像、屏幕截图、PDF 以及其他视觉文件。 |
### Skill分配
## 技能分配
控制每个代理人可以使用的技能`~/.config/opencode/oh-my-opencode-slim.json`(或`.jsonc`):
| 句法 | 意义 |
| ------------------- | ------------------------------- |
| `["*"]` | 所有已安装的技能 |
| `["*", "!codemap"]` | 除以下技能外的所有技能`codemap` |
| `["simplify"]` | 仅有的`simplify` |
| `[]` | 没有技能 |
| `["!*"]` | 拒绝所有技能 |
+70
View File
@@ -0,0 +1,70 @@
### 常用命令
#### 一、分支管理
| 操作 | 命令 | 说明 |
| -------------------- | -------------------------------------- | ---------------------------- |
| 查看本地分支 | `git branch` | 显示所有本地分支 |
| 查看远程分支 | `git branch -r` | 显示远程分支 |
| 创建新分支 | `git checkout -b feature/xxx` | 基于当前分支创建并切换新分支 |
| 基于远程创建本地分支 | `git checkout -b dev origin/dev` | 跟踪远程分支创建 |
| 删除本地分支 | `git branch -d feature/xxx` | 删除(已合并)本地分支 |
| 删除远程分支 | `git push origin --delete feature/xxx` | 删除远程分支 |
| 重命名分支 | `git branch -m old new` | 修改分支名 |
#### 二、提交相关
| 操作 | 命令 | 说明 |
| ------------------ | ------------------------------- | ------------------------------------------ |
| 添加到暂存区 | `git add .` 或 `git add <file>` | 添加改动文件 |
| 提交代码 | `git commit` | 使用文本编辑器编写提交信息后提交 |
| 提交代码 | `git commit -m "描述信息"` | 提交到本地仓库,可使用多个`-m`提交多行信息 |
| 修改上一次提交信息 | `git commit --amend` | 用于补充或改描述 |
| 查看提交历史 | `git log --oneline` | 简洁模式显示历史 |
#### 三、远程操作
| 操作 | 命令 | 说明 |
| ---------------- | --------------------------------------------------------- | ------------------- |
| 克隆仓库 | `git clone <url>` | 拉取远程项目 |
| 拉取远程代码 | `git pull` | 获取并合并远程分支 |
| 推送代码 | `git push` | 推送当前分支到远程 |
| 推送并设置上游 | `git push -u origin <branch>` | 建立跟踪关系 |
| 查看远程地址 | `git remote -v` | 显示远程仓库地址 |
| 远程查询所有标签 | git ls-remote --tags https://github.com/xxx.git \| grep X | 查看X相关的所有标签 |
#### 四、合并与变基
| 操作 | 命令 | 说明 |
| -------------- | ------------------------------------ | ------------------------------- |
| 合并分支 | `git merge feature/xxx` | 把目标分支合并进当前分支 |
| 取消合并 | `git merge --abort` | 终止合并,直接回退到合并前的状态 |
| 变基(rebase) | `git rebase dev` | 让你的提交在最新 `dev` 上 |
| 解决冲突后继续 | `git add . && git rebase --continue` | 继续变基或合并流程 |
#### 五、查看状态和差异
| 操作 | 命令 | 说明 |
| ---------------- | ------------------- | ---------------------------- |
| 查看状态 | `git status` | 当前分支、暂存区、工作区状态 |
| 查看修改内容 | `git diff` | 查看未暂存的更改 |
| 查看暂存内容差异 | `git diff --cached` | 查看已 add 的更改 |
#### 六、撤销操作
| 操作 | 命令 | 说明 |
| ----------------------- | ------------------------- | -------------------- |
| 撤销 add | `git reset <file>` | 把文件移出暂存区 |
| 撤销 commit(保留修改) | `git reset --soft HEAD~1` | 撤销最近一次提交 |
| 丢弃修改 | `git checkout -- <file>` | 丢弃对文件的本地更改 |
| 丢弃所有工作区修改 | `git reset --hard` | 恢复到最后一次提交 |
#### 七、其他常用
| 操作 | 命令 | 说明 |
| ---------------------- | ------------------------------------------------------------ | ---- |
| 查看当前分支 | `git branch --show-current` 或 `git rev-parse --abbrev-ref HEAD` | |
| 查看某次提交内容 | `git show <commit-id>` | |
| 切换到上一个分支 | `git checkout -` | |
| 清理无效引用 | `git remote prune origin` | |
| 从远程仓库获取所有标签 | `git fetch origin --tags` | |
+117
View File
@@ -0,0 +1,117 @@
[来源网站](https://www.conventionalcommits.org/zh-hant/v1.0.0/)
## 提交说明
### 基础结构
```
<type>[optional scope]: <description>
[optional body]
[optional footer(s)]
# 译文
<类型>[可选 范围]: <描述>
[可选 正文]
[可选 脚注]
```
### 类型(type)
- feat: 表示在代码库中新增了一个功能;
- fix: 表示在代码库中修复了一个 bug;
- build: 用于修改项目构建系统或外部依赖,例如升级依赖版本等;
- chore: 用于其他不修改`src`或`test`文件的杂项任务,例如更新`.gitignore`、修改编辑器配置等;
- ci: 用于修改持续集成/持续部署的配置与脚本,例如`GitHub Actions`、`Jenkins`、`Travis` 等工作流;
- docs: 用于修改文档,例如修改`README`文件、API文档等;
- style: 用于修改代码格式(不影响代码逻辑),例如调整缩进、空格、分号、引号风格等;
- refactor: 用于重构代码,例如修改代码结构、变量名、函数名等但不修改功能逻辑;
- improvement: 用于对现有实现进行改进,例如优化算法、增强健壮性等(不涉及新功能或 bug 修复);
- revert: 用于回退之前的提交,描述应使用被回退提交的标题,并在脚注中用 `Refs:` 引用被回退的 commit SHA;
- perf: 用于优化性能,例如提升代码的性能、减少内存占用等;
- wip: 用于保存尚未完成的工作进度(Work In Progress);
- test: 用于修改测试用例,例如添加、删除、修改代码的测试用例等;
> 破坏性变更**必须**在提交信息中标记出来,有两种方式:
>
> 1.在 `<类型>[可选 范围]` 后、冒号前添加 `!`,例如:`feat!: ...` 或 `refactor(api)!: ...`
>
> 2.*在脚注中添加* `BREAKING CHANGE: <描述>`
>
> 这两种方式可以同时使用,也可以只用其一。
### 范围(Scope)
范围用于标识本次修改影响的模块或组件,例如:
- feat(auth): ... (auth 模块)
- fix(parser): ... (parser 模块)
- build(deps): ... (依赖管理)
范围名称建议使用小写英文,多个单词用 `-` 连接,例如 `user-profile`。
### 描述(description)
描述用于简要概括本次变更的内容,要求:
- 使用祈使语气、现在时态(例如用 "add" 而非 "added" 或 "adds");
- 小写开头,末尾不加句号;
- 长度建议控制在50个字符以内(中文约 25 个字);
### 正文(Body)
正文是可选的,用于对本次变更进行更详细的说明,包括:
- 变更的动机(为什么这样做)
- 与之前行为的对比
- 实现方案的简要说明
正文与描述之间**必须**空一行,正文内部段落之间也可以空行分隔。
### 脚注(Footer)
脚注用于记录元信息,常见用法:
- `BREAKING CHANGE: <描述>` — 标记破坏性变更;
- `Closes #123` 或 `Fixes #456` — 关联 Issue/PR;
- `Refs: abc123` — 引用相关提交;
- `Reviewed-by: <name>` — 标记审查者;
- `Co-authored-by: <name> <email>` — 标记共同作者;
## 示例
### 基础功能提交
feat(auth): add JWT token refresh mechanism
### Bug 修复(含关联 issue)
fix(parser): handle empty string input edge case
Closes #123
### 破坏性变更(使用 ! 前缀)
feat(api)!: change user endpoint response format
BREAKING CHANGE: the `GET /api/users` endpoint now returns
a paginated response instead of a flat array.
### 回退提交
revert: remove experimental cache layer
Refs: a1b2c3d
+24
View File
@@ -0,0 +1,24 @@
### SSH
#### 快速连接服务器
在ssh的配置文件`~/.ssh/config`中可以设置常用的服务配置,这样就可以直接使用`ssh myserver`快速连接服务器了
```
Host myserver
HostName your-server.com
Port 2222
User ubuntu
IdentityFile ~/.ssh/mykey
```
#### 本地转发
```bash
# 使用示例
ssh -L 25432:localhost:5432 ubuntu@your-server.com
```
**作用**:以本地端口25432请求远程服务器,在远程服务器看来,这是以远程`localhost:5432`发起的请求
**使用场景**:当远程服务器某服务限制了只接收远程服务器的127.0.0.1连接,这时候又需要远程连接这个服务,这样的话就能和访问本地服务一样访问了
+84
View File
@@ -0,0 +1,84 @@
## 程序、进程与线程
### 程序
#### 定义
程序是存储在硬盘的一组指令集和,它是对计算任务的完整描述,是一个被动实体。类似于一本菜谱,记录了完整的烹饪步骤。
#### 核心特征
| 属性 | 说明 |
| :------------- | :-------------------------------------------------------- |
| **静态性** | 程序是一个文件(如 .exe、.go),不占用 CPU 或内存执行资源 |
| **持久性** | 可长期存在于存储介质上 |
| **无生命周期** | 没有创建、运行、消亡的状态变迁 |
### 进程
#### 定义
进程是程序的一次动态执行过程,是操作系统进行资源分配的基本单位。它就像按照菜谱进行烹饪的过程,需要占用资源,有始有终。它由以下三部分构成。
| 组成部分 | 作用 |
| :-------------------- | :----------------------------------------------------- |
| **程序段** | 描述进程要完成的功能,是控制进程执行的指令集 |
| **数据段** | 程序执行时所需的数据和工作区(全局变量、堆等) |
| **进程控制块(PCB)** | 包含进程的描述信息和控制信息,是**进程存在的唯一标志** |
#### 核心特征
- **动态性**:进程有生命周期(创建 → 就绪 → 运行 → 阻塞 → 终止)
- **独立性**:每个进程拥有独立的地址空间(虚拟内存空间、代码、数据、系统资源的集合)
- **并发性**:多个进程可在宏观上同时运行
- **资源拥有者**:进程拥有独立的文件描述符、环境变量、用户ID等资源
### 线程
#### 定义
线程是CPU进行运算调度的最小单位,是进程中的一条执行路径,一个进程至少有一个线程(也就是主线程)。它就像多位厨师共用厨房的资源(水、灶台、食材等),但是每人也都有自己的独有的刀具、围裙等,他们需要协调使用厨房做菜。
#### 核心特征
线程虽然共享进程的资源,但每个线程拥有自己的**私有数据**:
| **独有内容** | **说明** |
| :------------------ | :--------------------------------- |
| 线程 ID | 唯一标识 |
| 寄存器状态 | 包括程序计数器(PC)和栈指针 |
| 栈 | 独立运行栈,用于函数调用和局部变量 |
| 信号掩码 | 线程级别的信号处理设置 |
| 优先级 | 调度优先级 |
| 线程专用存储(TLS) | 线程私有的全局变量 |
## 用户态与内核态
### 内核态
运行操作系统内核代码,拥有对硬件的完全控制权,负责管理内存、进程、文件系统、网络等底层资源。
### 用户态
运行用户应用程序,无法直接访问硬件或内核数据结构,必须通过 系统调用(System Call)请求内核代为执行特权操作。
### 切换过程
为了保护系统安全与稳定,操作系统会对CPU的执行权限进行分级,不同的CPU架构有不同的等级,但是通常都遵循应用在最低特权级(用户态)运行,内核在次高特权级(内核态)运行,通过CPU硬件强制隔离特权操作。当在X86架构的CPU运行时,一个应用程序需要执行特权操作(比如读写文件),过程大致是这样的:
1. **触发**
程序调用 `read()` 等库函数,最终执行 CPU 的 `syscall` 指令。这条指令告诉 CPU:“我要切换到内核态干正事”。
2. **CPU 硬件自动切换**
CPU 在硬件层面做三件关键事:
- 把特权级从 **Ring 3(用户态)** 切换为 **Ring 0(内核态)**。
- 将栈指针从用户栈切换到**内核栈**(从 MSR 中读取预设的内核栈指针)。
- 把返回地址(下一条指令)和标志寄存器保存到特定寄存器(如 `rcx`、`r11`),然后跳转到内核预先设置好的**入口函数**。
3. **内核软处理**
入口函数将刚才没来得及保存的全部寄存器压入内核栈(形成 `pt_regs` 结构),然后根据**系统调用号**(放在 `rax` 通用寄存器中)从系统调用表里找到对应的内核函数(如 `ksys_read`)并执行。
4. **内核执行 & 返回**
内核函数完成实际工作(检查权限、读取数据等),把结果放在 `rax` 通用寄存器中。返回前,内核检查是否有其他任务需要处理(如信号、调度),然后通过 `sysret` 指令恢复之前保存的寄存器、用户栈和返回地址,特权级自动切回 **Ring 3**,程序从当初的下一条指令继续运行。
+45
View File
@@ -0,0 +1,45 @@
## Nginx 反代未透传客户端 IP 导致服务端日志全量变为 127.0.0.1
### 现象
后端解析IP后全量变成 `127.0.0.1`。
### 原因
后端解析IP的顺序是
```
X-Forwarded-For → X-Real-IP → RemoteAddr
```
**Nginx**转发至后端服务的 location 块缺少`proxy_set_header`指令:
```nginx
location /olive3 {
proxy_pass http://127.0.0.1:55430;
proxy_connect_timeout 300s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
}
```
### 修复
在缺失的 location 块中补充 header 透传:
```nginx
location /api {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
proxy_pass http://127.0.0.1:55430;
# ... 其他配置不变
}
```
- `$proxy_add_x_forwarded_for`:将客户端真实 IP 追加到已有 `X-Forwarded-For` 尾部,而非覆盖
- `$remote_addr`:Nginx 直接看到的对端地址,作为 `X-Real-IP` 传递
+18
View File
@@ -0,0 +1,18 @@
## 快速复制文件路径
### Shift + 右键菜单(通用)
1. 在文件资源管理器中,**按住 `Shift` 键不放**
2. 右键点击目标文件
3. 选择 **「复制为路径(A)」**
### 功能区按钮
1. 选中文件
2. 点击资源管理器顶部的 **「主页」** 选项卡
3. 点击 **「复制路径」** 按钮(剪贴板图标)
### 拖拽到终端
- 直接把文件**拖入** PowerShell 或 CMD 窗口,会自动粘贴完整路径。
+123
View File
@@ -0,0 +1,123 @@
配置文件位置
```
C:\Users\admin\AppData\Roaming\Code\User\keybindings.json
```
该文件保存的是用户级快捷键列表,每一个快捷键配置都是包含`key`、`command`、`when`(可选)字段
| 字段 | 作用 |
| :------------ | :------------------------------- |
| **`key`** | 你按下的键盘组合 |
| **`command`** | 要执行的命令 ID |
| **`when`** | 规定这条快捷键在什么“语境”下生效 |
完整示例:
```json
// markdown 代码块
{
"key": "ctrl+shift+`",
"command": "markdown.extension.editing.toggleCodeBlock",
"when": "editorTextFocus && editorLangId == 'markdown'"
},
```
## 按键的书写规则
### 基本格式
- **全小写**:修饰键和字母键都要小写,写 `ctrl` 而不是 `Ctrl`,写 `a` 而不是 `A`。
- **用 `+` 连接**:按键之间用加号连接,不带空格。例如 `ctrl+shift+a`。
- **修饰键顺序任意**:`ctrl+alt+a` 和 `alt+ctrl+a` 效果相同,但习惯按 `ctrl -> shift -> alt -> 字母` 的顺序写
### 支持的修饰键
| 修饰键 | 写法 | 说明 |
| :------------ | :------ | :---------------------------------------------- |
| Ctrl | `ctrl` | Windows / Linux 上常用 `ctrl`,macOS 对应 `cmd` |
| Shift | `shift` | |
| Alt | `alt` | macOS 上是 `opt`(或者也支持 `alt`) |
| Cmd (Mac) | `cmd` | 在 Windows 上无效,建议用 `ctrl` 兼顾跨平台 |
| Win (Windows) | `win` | 很少用 |
| Meta (macOS) | `meta` | 通常等同 `cmd` |
### 特殊按键名称
| 按键 | 写法 |
| :----------- | :------------------------------------- |
| 方向键 | `up` `down` `left` `right` |
| 功能键 | `f1` ~ `f19` |
| 回车 | `enter` |
| 空格 | `space` |
| 退格 | `backspace` |
| Tab | `tab` |
| 反引号 | ``` (就是 backtick) |
| 逗号、句号 | `,` `.` 等直接写符号本身 |
| 数字键 | `0`~`9` |
| 计算器小键盘 | `numpad0` ~ `numpad9`,`numpad_add` 等 |
### 按键组合
VS Code 支持**二段按键**(一次按键序列),写法是:
```json
"key": "ctrl+k ctrl+b"
```
意思是先按 `Ctrl+K`,松开后再按 `Ctrl+B`。
## 命令ID
`command` 是一个**全局唯一的字符串**,由扩展或 VS Code 自己注册。不能自己编造,必须使用确切的 ID。
通过`ctrl+k ctrl+b`按键组合或者在设置中点击“键盘快捷方式”即可查看当前能设置的所有的命令。虽然显示的是命令,但是实际上是命令的名字,右键可以获取命令的ID。也可以不通过修改配置文件,在图形化界面设置快捷键。
## 上下文条件表达式
`when` 是一个**布尔表达式**,只有计算结果为 `true` 时,快捷键才会触发,可以组合变量使用逻辑运算符。
### 常用上下文变量
| 变量 | 说明 | 常见值 |
| :------------------------------ | :------------------------------------------------- | :------------------------------------------ |
| `editorTextFocus` | 焦点是否在编辑器的**文本区域**(光标可以输入文字) | `true` / `false` |
| `editorLangId` | 当前编辑器的语言 ID(不是文件后缀) | `"markdown"`, `"javascript"`, `"python"` 等 |
| `editorHasSelection` | 编辑器里是否有文本被选中 | `true` / `false` |
| `isLinux`, `isWindows`, `isMac` | 当前操作系统 | `true` / `false` |
| `resourceExtname` | 当前文件的后缀名(带`.`) | `".md"`, `".js"` 等 |
| `activeEditor` | 是否有激活的编辑器 | `true` / `false` |
| `terminalFocus` | 焦点是否在集成终端 | `true` / `false` |
| `listFocus` | 焦点在列表/树视图吗 | `true` / `false` |
### 逻辑运算符
| 运算符 | 含义 |
| :--------- | :----------------------------------------------------------- |
| `&&` | 与 |
| ` | |
| `!` | 非 |
| `==`, `!=` | 等于 / 不等于(字符串) |
| `=~` | 正则匹配(例如:`editorLangId =~ /^markdown$ |
| `in` | 测试左侧值是否在给定的右值数组里,如 `editorLangId in ['markdown', 'rmd']` |
### 例子分析
```json
"when": "editorTextFocus && editorLangId == 'markdown'"
```
表达式含义:确保你正在编辑文本,而不是在侧边栏、终端或其他地方**并且**当前文件被 VS Code 识别为 Markdown 语言
## 常见问题排查
| 现象 | 可能原因 |
| :------------------------------- | :------------------------------------------------------ |
| 按键完全没反应 | `command` 拼写错误;`when` 条件一直为 false;扩展未激活 |
| 在其他语言文件里也生效了 | 缺少 `editorLangId == 'xxx'` 限制,考虑加上 |
| 一次性按了没反应,但按键序列可以 | 可能你定义成了 chord,但习惯是单键 |
| 提示 “command 'xxx' not found” | 命令 ID 不存在,检查扩展是否已安装并启用 |
File diff suppressed because it is too large Load Diff
+225
View File
@@ -0,0 +1,225 @@
### **小六壬时课 · 知识库**
#### **一、 简介:什么是小六壬?**
小六壬,相传为三国时期诸葛亮、唐代李淳风所创,是一种简便、快速的预测方法,因其起课方便、便于携带(仅需掐指一算),故有“袖珍式预测学”或“马前课”之称。
其核心思想是,根据事发或问事时的“时间”或“心念”所触发的即时信息,来推演事物的状态和发展趋势。它主要用于对**单一、具体、短期**的事情进行快速占断,属于“一事一占”的动念占法。
**核心特点**:
- **简便快捷**:无需复杂工具,仅凭掌诀即可推算。
- **随机性强**:动念即可成课,捕捉瞬间天机。
- **应用广泛**:适用于寻物、问事、决疑、出行等日常生活的方方面面。
#### **二、 核心概念:六神宫位与掌诀**
小六壬共有六个基本“角色”,称为“六神”(或六宫),它们分布在食指、中指和无名指的指节上,形成一个固定的“掌上地盘”,即“掌诀”。
**掌诀位置图示**: 您可以伸出左手,按照下图在手指上定位六神:
- **大安**:食指根部
- **留连**:食指尖部
- **速喜**:中指尖部
- **赤口**:无名指尖部
- **小吉**:无名指根部
- **空亡**:中指根部
(其顺序为顺时针循环)
**六神基础信息表**:
| 宫位/六神 | 五行 | 吉凶 | 核心意象与关键词 |
| :------------------ | :--- | :--- | :--------------------------------- |
| **大安 (Dà'ān)** | 木 | 吉 | 安泰、平稳、静止、官方、身体、龙 |
| **留连 (Liúlián)** | 水 | 凶 | 延迟、纠缠、牵绊、缓慢、暧昧、暗昧 |
| **速喜 (Sùxǐ)** | 火 | 吉 | 快速、喜讯、文书、宴饮、光明、朱雀 |
| **赤口 (Chìkǒu)** | 金 | 凶 | 口舌、官非、争斗、惊恐、凶险、白虎 |
| **小吉 (Xiǎojí)** | 木 | 吉 | 合作、财利、和合、顺畅、六合 |
| **空亡 (Kōngwáng)** | 土 | 凶 | 落空、无果、忧虑、阴私、迷茫、玄武 |
#### **三、 主要起课方法**
起课,就是根据参数确定最终落在哪一个宫位上。
**1. 传统时间起课法(月、日、时)** 这是最正统的方法,以农历时间为准。
- 第一步:月上起日
- 从**大安**宫开始,顺时针将月份数代入。例如,一月为大安,二月为留连,三月为速喜……以此类推。
- 第二步:日上起时
- 从上一步月份的落宫开始,顺时针将日期数(初一、初二……)代入。
- 第三步:时上定宫
- 从上一步日期的落宫开始,顺时针将时辰数(子时为1,丑时为2……)代入,最终落下的宫位即为结果。
**【示例】**:占测农历八月初十下午4点(申时)。
1. **定月**:从大安宫数8个月,落点在**赤口**。(1大安→2留连→3速喜→4赤口→5小吉→6空亡→7大安→8赤口)
2. **定日**:从赤口宫数10日,落点在**小吉**。(1小吉→2空亡→...→6赤口→...→10小吉)
3. 定时
:申时是第9个地支。从上一步的小吉宫数9个时辰,最终落点在
速喜
。
- **结果**:速喜。
**2. 报数起课法** 当不知晓具体时辰或为求心念感应时使用。
- 让问事者随意报出**三个数字**(1位数到3位数均可)。
- 将每个数字分别**除以6,取其余数**(若余数为0,则按6计算)。
- **第一步**:从“大安”宫起,按第一个余数顺时针数,定下基点。
- **第二步**:从基点宫位起,按第二个余数顺时针数,定下过程点。
- **第三步**:从过程点起,按第三个余数顺时针数,定下最终结果。
**【示例】**:报数为 7, 11, 32。
1. **第一数**:`7 ÷ 6 = 1 ... 余 1`。从大安数1,落在**大安**。
2. **第二数**:`11 ÷ 6 = 1 ... 余 5`。从大安数5,落在**小吉**。
3. 第三数
:
```
32 ÷ 6 = 5 ... 余 2
```
。从小吉数2,落在
空亡
。
- **结果**:空亡。
#### **四、 六神象义详解与分类占断**
这是解卦的核心,需要结合所问之事灵活判断。
**1. 大安**
- **核心**:身不动,心安宁。静待为吉,妄动有失。
- **问事**:事可成,但需时日,不可急躁。过程平顺。
- **寻物/寻人**:人或物未走远,在家中或原地,或在东方、东北方,多在静态、稳定的环境中(如办公室、卧室)。
- **感情**:关系稳定,感情安泰,但可能缺乏激情。
- **财运**:财运稳定,宜守成,不宜投机。
- **健康**:身体安康,即使有病也易于恢复。
**2. 留连**
- **核心**:事未了,心纠结。拖延、纠缠、暧昧不清。
- **问事**:事情进展缓慢,有阻碍,难以立刻解决,容易节外生枝。
- **寻物/寻人**:难寻,或被某事/某人牵绊住。物品可能在潮湿、脏乱或纠缠之地。
- **感情**:关系纠缠不清,或处于冷战、延迟状态。有藕断丝连之象。
- **财运**:财运不畅,资金周转困难,或有债务纠纷。
- **健康**:病情迁延,难以速愈,多为慢性病或旧病复发。
**3. 速喜**
- **核心**:火光之象,喜信速至。宜速战速决,不宜拖延。
- **问事**:事能速成,有喜讯或好结果。尤其利于文书、考试、信息类事宜。
- **寻物/寻人**:能快速找到。人或物在南方,或在热闹、明亮之处。
- **感情**:进展神速,热情洋溢,或有喜事临门(如订婚、结婚)。
- **财运**:偏财运旺,有意外之财,或短期投资获利。
- **健康**:多为急症、热症(如发烧、炎症),但来得快去得也快。
**4. 赤口**
- **核心**:白虎开口,是非官讼。主凶险、争斗、惊扰。
- **问事**:事多纷争,易有口舌是非,甚至官司诉讼。过程不顺,需防小人。
- **寻物/寻人**:难寻。多有争吵或处于危险境地。物品可能已损坏。
- **感情**:关系紧张,争吵频繁,易有分离之象。
- **财运**:破财之象,谨防因纠纷、官非而损财。
- **健康**:需防意外伤害、手术、或急性恶疾。有血光之灾的警示。
**5. 小吉**
- **核心**:六合之象,诸事和合。代表合作、财利、转机。
- **问事**:事情顺利,有贵人相助,适宜合作、商议。
- **寻物/寻人**:易寻。人或物在附近,或与友人、同伴在一起。
- **感情**:关系和谐,琴瑟和鸣,是发展的良好时机。
- **财运**:正财、合伙之财运佳,交易顺利。
- **健康**:病情好转,遇到良医,康复有望。
**6. 空亡**
- **核心**:万事成空,虚无之象。信息不实,劳而无功。
- **问事**:事情没有头绪,心中没底,最终可能不了了之或毫无结果。有虚假、欺骗的成分。
- **寻物/寻人**:人已远去,物已丢失,找回希望渺茫。
- **感情**:关系空虚,前景不明,或对方心意不诚。
- **财运**:财运空空,投资无望,谨防被骗。
- **健康**:病情诊断不明,或查无此症,也可能指精神、心理层面的问题。
#### **五、 断辞内容**
1、大安:身不动时,属木,为青龙,凡谋事主一、五、七。
断辞曰:
大安事事昌,求谋在东方,失物去不远,
宅舍保安康,行人身未动,病者主无妨,
将军回田野,仔细与推详。
2、留连:卒未归时,属水,为玄武,凡谋事主二、八、十。
断辞曰:
留连事难成,求谋曰未明,官事只宜缓,
去者未回程,失物南方见,急讨方称心,
更须防口舌,人口且平平。
3、速喜:人便至时,属火,为朱雀,凡谋事主三、六、九。
断辞曰:
速喜喜来临,求财向南行,失物申午未,
逢人路上寻,官事有福德,病者无祸侵,
田家六畜吉,行人有信音。
4、赤口:官事凶时,属金,为白虎,凡谋事主四、七、十。
断辞曰:
赤口主口舌,官非切要防,失物急去寻,
行人有惊慌,鸡犬多作怪,病者出西方,
更须防咒诅,恐怕染蕴疾。
5、小吉:人来喜时,属木,为六合,凡谋事主一、五、七。
断辞曰:
小吉最吉昌,路上好商良,阳人来报喜,
失物在坤方,行人立便至,交关真是强,
凡事皆和合,病者事无仿。
6、空亡:音信稀时,属土,为勾陈,凡谋事主一、五、七。
断辞曰:
空亡事不长,阴人小乘张,求财无有利,
行人有灾殃,失物寻不见,官事主刑伤,
病人逢暗鬼,乞解保安康。
#### **六、 使用原则与心法**
- **心诚意专**:占测时心态必须虔诚、专注,意念集中于所问之事。
- **一事一占**:同一件事在短期内不应反复占问,以第一次的结果为准。所谓“再三渎,渎则不告”。
- **动念即占**:当某个问题或念头强烈地出现在脑海中时,是最佳的占测时机。
- **趋吉避凶**:小六壬提供的是一种预警和参考。得到吉卦,应顺势而为;得到凶卦,则应反思、谨慎、规避,而非坐以待毙。它是一种积极的“行为指导”,而非消极的“宿命论”。
+21
View File
@@ -0,0 +1,21 @@
## CONCAT函数
`CONCAT` 是 SQL 中用于**字符串拼接**的函数,它可以将两个或多个字符串合并成一个新的字符串。
------
### 基本语法
```sql
CONCAT(string1, string2, ..., stringN)
```
### 不同数据库的 CONCAT 行为
| 数据库 | 行为 |
| :--------------------- | :----------------------------------------------- |
| **MySQL** | 支持多参数;**任一参数为 NULL,整个结果为 NULL** |
| **SQL Server** (2012+) | 支持多参数;**NULL 会被自动转为空字符串** `''` |
| **Oracle** | 只支持 **2 个参数**;NULL 视为空字符串 |
| **PostgreSQL** | 支持多参数;NULL 视为空字符串 |
+44
View File
@@ -0,0 +1,44 @@
## B-Tree
**适用场景**
| 场景 | 示例 |
| :------- | :-------------------------- |
| 等值查询 | WHERE user_id = 100 |
| 范围查询 | WHERE age BETWEEN 20 AND 30 |
| 排序优化 | ORDER BY created_at DESC |
| 前缀匹配 | WHERE name LIKE '张%' |
**不适用场景**
- LIKE '%关键词%'(后缀/中间模糊匹配)
- 全文搜索
- 数组包含查询
## GIN
**结构特点**
- 倒排索引(Inverted Index) 机制:从"内容"映射到"行"
- 索引的是复合值内部的独立元素
- 索引构建较慢,但查询性能极佳
**适用场景**
> `@>`表示包含、`<@`表示被包含、`&&`表示重叠、`@@`表示全文搜索匹配、`?`表示JSONB 键存在
| 场景 | 示例 |
| :----------- | :----------------------------------------------------- |
| 全文搜索 | WHERE to_tsvector(content) @@ to_tsquery('postgresql') |
| 数组包含 | WHERE tags @> ARRAY['java', 'sql'] |
| JSONB 查询 | WHERE data @> '{"status": "active"}' |
| 多值元素搜索 | WHERE arr && ARRAY[1, 2] |
+60
View File
@@ -0,0 +1,60 @@
## 权重抽奖
### 轮盘赌
适合奖品数量较少,一次性抽奖的场景
核心顺序遍历,逐个减权重:是用一个随机数 `r`,依次减去每个奖品的权重,当 `r` 变成负数的那一刻,说明它“落入了”当前奖品的权重区间。时间复杂度 **O(n)**,空间复杂度**O(1)**
```go
func Draw(rewards []*Reward) *Reward {
totalWeight := 0
for _, r := range rewards {
totalWeight += r.Weight
}
r := rand.Intn(totalWeight) // [0, totalWeight)
for _, reward := range rewards {
r -= reward.Weight
if r < 0 {
return reward
}
}
return rewards[len(rewards)-1] // 兜底,理论上不会走到
}
```
### 前缀和 + 二分查找
适合奖品数量较多,需要频繁抽奖的通用场景
核心是构建前缀和数组,二分查找:把权重想象成一条线段,每个奖品占据一段,生成 `[0, 100)` 的随机数,落在哪个区间就中哪个奖品。二分查找快速定位区间,时间复杂度 **O(log n)**,空间复杂度**O(n)**
```
权重: 50 25 15 7 2 1
├─────────┼────────┼──────┼────┼──┼──┤
0 50 75 90 97 99 100
← 谢谢参与 →←5元券→←10元券→←月卡→←年卡→←大奖→
```
```go
type Reward struct {
Name string
Weight int
}
func Draw(rewards []*Reward) *Reward {
prefixSumArray := make([]int, len(rewards)) // 前缀和数组
totalWeight := 0 // 总权重
for i, p := range rewards {
totalWeight += p.Weight
prefixSumArray[i] = totalWeight
fmt.Println(totalWeight)
}
r := rand.Intn(totalWeight) // r ∈ [0, totalWeight-1]
idx := sort.SearchInts(prefixSumArray, r+1) // 查找目标 ∈ [1, totalWeight]
return rewards[idx]
}
```
+56
View File
@@ -0,0 +1,56 @@
## 轮询算法
### 平滑加权轮询
#### 原理
每台服务器维护一个 `当前权重`(currentWeight),每次选择 `currentWeight` 最大的服务器,选中后将其 `currentWeight` 减去总权重,然后**所有服务器都加上自己的固定权重**。如此循环,高权重节点被选中的频率更高,但不会连续出现。
#### 实际应用
- **Nginx** 的 `upstream` 默认负载均衡算法
- **LVS**(Linux Virtual Server)
- 几乎所有需要「按比例分配且平滑」的场景
#### 轮询示例
```go
type server struct {
name string
weight int
curWeight int
}
func NextServer(servers []*server) *server {
var totalWeight int
// 1.先给所有服务器加上各自的固定权重
for i := range servers {
servers[i].curWeight += servers[i].weight
totalWeight += servers[i].weight
}
// 2.再找出 curWeight 最大的服务器
maxIdx := 0
for i := 1; i < len(servers); i++ {
if servers[i].curWeight > servers[maxIdx].curWeight {
maxIdx = i
}
}
// 3.选中的服务器减去总权重
servers[maxIdx].curWeight -= totalWeight
return servers[maxIdx]
}
```
| 轮次 | 操作前 (currentWeight) | 选中 | 操作后 |
| ---- | ---------------------- | ----- | --------------- |
| 初始 | A=0, B=0, C=0 | — | — |
| 1 | A=5, B=1, C=1 | **A** | A=−2, B=1, C=1 |
| 2 | A=3, B=2, C=2 | **A** | A=−4, B=2, C=2 |
| 3 | A=1, B=3, C=3 | **B** | A=1, B=−4, C=3 |
| 4 | A=6, B=−3, C=4 | **A** | A=−1, B=−3, C=4 |
| 5 | A=4, B=−2, C=5 | **C** | A=4, B=−2, C=−2 |
| 6 | A=9, B=−1, C=−1 | **A** | A=2, B=−1, C=−1 |
| 7 | A=7, B=0, C=0 | **A** | A=0, B=0, C=0 |
+123
View File
@@ -0,0 +1,123 @@
## API
### goctl代码生成
可以在doc/genCode.md查看
```powershell
# 生成多个types类型的话需要关闭这个实验性功能
goctl env -w GOCTL_EXPERIMENTAL=off
# 代码生成,会自动处理引入的子api文件
goctl api go -api background.api -dir . --home D:/zhangyuxin/template/1.8.3
goctl api go -api login.api -dir . --home ../../../deploy/goctl
goctl api go -api main.api -dir . --home ../../deploy/goctl
```
### 参数校验
| 接收规则 | 说明 | 示例 |
| -------- | ------------------------------------------ | ------------------------------- |
| optional | 当前字段是可选参数,允许为零值(zero value) | `json:"foo,optional"` |
| options | 当前参数仅可接收的枚举值 | `json:"gender,options=foo|bar"` |
| default | 当前参数默认值 | `json:"gender,default=male"` |
| range | 当前参数数值有效范围,支持开/闭区间 | `json:"age,range=[0:120]"` |
#### **`optional`** **与** **`omitempty`** **的区别**
两者都写在 json/form tag 的 options 里,但作用域完全不同,不可互相替代
##### `optional` —— goctl 专用,控制 Swagger 与请求校验
- **Swagger 生成**:标了 `optional` 的字段不进 `required` 数组,Apifox/Swagger UI 显示为可选;未标的字段进 `required` 数组,显示为必填
- **请求校验**:goctl 生成的 logic 不会对标了 `optional` 的字段做必填校验
- **判定逻辑**:goctl 遍历 tag options,找到 `optional` 关键字即判定为可选
##### `omitempty` —— Go 标准库指令,控制 JSON 序列化
- **JSON 序列化**:字段为零值(0、""、nil、false)时,响应 JSON 中**不输出**该字段
- **Swagger 生成**:**goctl 完全不识别**,字段仍按未标 `optional` 处理,即判定为 required
- **请求校验**:无影响
## RPC
### goctl代码生成
```powershell
# 主proto文件代码生成
goctl rpc protoc user.proto --go_out=. --go-grpc_out=. --zrpc_out=. --proto_path=. -m --home ../../../deploy/goctl
# 主proto文件中引入的proto代码生成
protoc.exe -I ./ --go_out=. --go-grpc_out=. ./user_model.proto
```
当`proto`文件里面定义了多个 service的时候,需要额外注意,还要再main函数中手动注册一下
> 每个 proto 文件应该聚焦于一个核心概念/实体。一个文件里定义太多 service 通常是设计需要重新考虑的信号
注册示例
```go
import (
ebookServer "olive/services/product/rpc/internal/server/ebookservice"
)
var configFile = flag.String("f", "etc/product.yaml", "the config file")
func main() {
flag.Parse()
var c config.Config
conf.MustLoad(*configFile, &c)
ctx := svc.NewServiceContext(c)
s := zrpc.MustNewServer(c.RpcServerConf, func(grpcServer *grpc.Server) {
// 新服务
productrpcmodel.RegisterEbookServiceServer(grpcServer, ebookServer.NewEbookServiceServer(ctx))
})
defer s.Stop()
fmt.Printf("Starting product RPC server at %s...\n", c.ListenOn)
s.Start()
}
```
### proto代码生成错误:File not found错误
- 错误描述
执行 protoc 报 google/protobuf/timestamp.proto: File not found. 的错提示File not found
- 解决方式
重新下载[protoc](https://github.com/protocolbuffers/protobuf/releases),解压后,将整个include目录复制到protoc执行文件同一级目录下
## model
可以在doc/genCode.md查看
**Model 层**:只做 **“业务循环”** + **“SQL 查询”** + **“结果封装”**,不关心字符串到时间的转换和 Protobuf 类型。
### goctl代码生成
```powershell
# mysql根据远程数据库表生成
goctl model mysql datasource --url="olive:xxvkikaDDx01&&99@tcp(127.0.0.1:3306)/ebming" --table="user_invitation_record" --dir="." -c --home ../../../../deploy/goctl/1.8.3
# mysql根据ddl生成
goctl model mysql ddl --src h5.sql --dir . -c --home D:/zhangyuxin/template/1.8.3
# pgsql根据远程数据库表生成
goctl model pg datasource -url "postgres://labman:tN5bWz2KpL9Fy4@127.0.0.1:15432/sern" -table user -dir ./user --cache --home ../../goctl_template/1.8.3
# pgsql根据远程数据库表生成,带模式(schema)
goctl model pg datasource -url "postgres://admin:zhangyuxin@127.0.0.1:25432/test?sslmode=disable" -s "users" -table "profile" -dir ./go-zero/model/users/ --cache --home ../../deploy/goctl
```
+263
View File
@@ -0,0 +1,263 @@
## 正确关闭`HTTP`响应
> **注意事项**
>
> - 先检查 `err`,再 `defer close`
>
> - 必须消费完响应体,否则连接无法复用
>
> - `defer` 会延迟到函数结束,如果需要在循环中处理大量响应,建议手动关闭(避免 `defer` 累积)
```go
resp, err := http.Get("https://example.com")
if err != nil {
// 处理请求错误(此时 resp 可能为 nil)
return err
}
// 确保在函数退出时关闭响应体
defer resp.Body.Close()
// 手动关闭,不用 defer
// io.Copy(io.Discard, resp.Body)
// resp.Body.Close()
// 读取或处理响应体(即使不需要内容也建议消费完)
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
// 使用 body ...
```
## 切片内存泄漏
> 即使原切片变量本身被置为 `nil` 或超出作用域,只要**还存在任何一个指向其底层数组的子切片**,整个底层数组就不会被回收
如果新切片需要长期保存,而原切片很大且即将被丢弃,那么**直接使用原切片[:n] 会导致新切片底层仍然引用原切片的整个底层数组**,使得那个大数组无法被 GC 回收,造成内存泄漏。此时应该用 `copy` 创建一份独立的、只包含所需元素的新切片。
```go
func getFirstLines() []string {
hugeLines := readHugeFile() // 假设这个切片有 100 万行,占用 100MB 内存
// 错误做法:返回子切片
return hugeLines[:3] // 虽然只返回3行,但底层数组的 100MB 内存无法被回收!
}
// 正确做法
func getFirstLines() []string {
hugeLines := readHugeFile()
// 只拷贝需要的3行
result := make([]string, 3)
copy(result, hugeLines[:3])
return result // 原来的 hugeLines 可以被GC回收,只占用很小的内存
}
```
## 堆栈
### 核心区别
| 特性 | 栈 | 堆 |
| --------- | -------------- | ------------------ |
| 分配/释放 | 移动指针,极快 | 需要查找 + GC 回收 |
| 生命周期 | 函数返回即销毁 | GC 决定何时回收 |
| 内存布局 | 连续,无碎片 | 分散,有碎片 |
| 大小限制 | 较小(MB级) | 较大(GB级) |
### 逃逸判断规则
```
变量在以下情况逃逸到堆:
1. 返回指针 → func f() *int { x := 42; return &x }
2. 存入全局变量 → globalPtr = &x
3. 闭包捕获 → return func() { return x }
4. 发送到 channel → ch <- &x
5. 存入外部结构体 → c.Value = &x
6. 接口装箱逃逸 → return interface{}(x)
7. 反射调用 → fmt.Println(x)
8. 大对象 → make([]int, 10000)
```
### 常见误区澄清
| 误区 | 正解 |
| ------------------ | -------------------------------- |
| 指针一定在堆上 | ❌ 函数内创建和使用的指针可在栈上 |
| 接口一定在堆上 | ❌ 不逃逸的接口内部值可在栈上 |
| 结构体指针一定逃逸 | ❌ 小结构体 + 内联优化可能栈分配 |
### 验证方法
```bash
# 查看逃逸分析
go build -gcflags="-m" main.go
# 更详细信息
go build -gcflags="-m -m" main.go
```
### 优化建议
```go
// ✅ 推荐:小对象返回值
func getUser() User {
return User{
ID: 1
}
}
// ❌ 避免:小对象返回指针(增加 GC 压力)
func getUser() *User {
return &User{
ID: 1
}
}
// ✅ 推荐:传指针给只读函数
func read(p *Data) int {
return p.Value
}
// ❌ 避免:在热路径使用 fmt.Print
// 用 strconv 或 log 代替
```
### 记忆口诀
```
生命周期越界 → 堆
外部有引用 → 堆
闭包捕获 → 堆
反射调用 → 堆
函数内使用 → 栈
返回值拷贝 → 栈
只读指针 → 栈
```
### 本质理解
**栈** = 函数的私有工作区,函数结束自动清理
**堆** = 共享仓库,需要 GC 定期打扫
逃逸分析 = 编译器判断"这个变量能不能安全地放在栈上"
## 类型转换/序列化
```
//int64转换string
strconv.FormatInt(num,10)
//序列化为string
jsoniter.ConfigCompatibleWithStandardLibrary.MarshalToString(input)
```
在 encode/decode JSON 数据并且数据类型是interface时,Go 默认会将数值当做 float64 处理
```go
type User struct {
Name string `json:"name"`
Age int `json:"age"`
Height float64 `json:"height"`
}
type UserInter struct {
Name interface{} `json:"name"`
Age interface{} `json:"age"`
Height interface{} `json:"height"`
}
func TestJson(t *testing.T) {
one := &User{
Name: "tom",
Age: 18,
}
oneJson, err := jsoniter.Marshal(one)
if err != nil {
fmt.Println(err)
return
}
var two UserInter
var three map[string]interface{}
err = jsoniter.Unmarshal(oneJson, &two)
err = jsoniter.Unmarshal(oneJson, &three)
if err != nil {
fmt.Println(err)
return
}
fmt.Println(reflect.TypeOf(two.Name))
fmt.Println(reflect.TypeOf(two.Age)) //会打印float64
fmt.Println(reflect.TypeOf(two.Height)) //会打印float64
fmt.Println(reflect.TypeOf(three["name"]))
fmt.Println(reflect.TypeOf(three["age"])) //会打印float64
fmt.Println(reflect.TypeOf(three["height"])) //会打印float64
}
```
## `HTTP`阅后即焚
处理 HTTP Body 只能读一次的问题时,根据你的**数据量大小**选择武器:
1. **小请求(配置、JSON)**:使用 `io.ReadAll` 存入内存,再通过 `io.NopCloser` 塞回 `r.Body` 或设置 `r.GetBody`。
2. **中等请求(签名校验)**:使用 `io.TeeReader` 边读边处理,节省一次全量扫描。
3. **大请求(文件上传)**:**禁止 ReadAll**。直接流式 `io.Copy` 到目的地,或使用临时文件做中转。
## map的坑
go的map内部是哈希表,元素会因 rehash(扩容)而移动到新的内存位置,如果允许 `&m[key]` 拿到一个指针,map 一旦扩容,这个指针就变成悬空指针(指向已失效的内存),为了避免这种内存安全问题,Go 直接禁止对 map 值取地址,下面是示例代码:
```go
type User struct {
Name string
Age int
}
func main() {
m := map[int]User{1: {Name: "张三", Age: 20}}
// ❌ 编译错误:cannot assign to struct field m[1].Name in map
m[1].Name = "李四"
// ✅ 正确的替代写法
temp:=m[1]
temp.Name = "李四"
m[1]=temp
}
```
> **可寻址(addressable)**:能通过 `&` 拿到它内存地址的操作数。
>
> **不可寻址(not addressable)**:不能通过 `&` 拿到地址的操作数。
## 句点引入
一个完整引入声明语句形式的引入名`importname`可以是一个句点(`.`)。 这样的引入称为句点引入。使用被句点引入的包中的导出代码要素时,限定标识符的前缀必须省略。
比如在下面这个例子中,`Println`和`Now`函数调用不需要带任何前缀。
```go
package main
import (
. "fmt"
. "time"
)
func main() {
Println("Current time:", Now())
}
```
> 一般来说,句点引入不推荐使用,因为它们会导致较低的代码可读性
+100
View File
@@ -0,0 +1,100 @@
## 依赖与模块管理
| 命令 | 作用 | 示例 |
| ----------------- | ---------------------------------------- | ---------------------------------------- |
| `go mod init` | 初始化一个新模块(生成 go.mod) | `go mod init myapp` |
| `go mod tidy` | 自动添加缺失的依赖、删除未使用的依赖 | `go mod tidy` |
| `go get` | 为当前模块添加/升级/降级依赖 | `go get github.com/gin-gonic/gin@v1.9.0` |
| `go mod download` | 预下载 go.mod 中声明的所有依赖到本地缓存 | `go mod download` |
| `go mod vendor` | 将依赖复制到 vendor 目录(离线构建用) | `go mod vendor` |
| `go list` | 列出包或模块信息 | `go list -m all`(查看所有依赖版本) |
## 运行与安装
| 命令 | 作用 | 示例 |
| :----------- | :---------------------------------- | :----------------------------------- |
| `go run` | 编译并直接运行程序(适合调试) | `go run .` |
| `go install` | 编译并安装到 GOBIN 目录(全局可用) | `go install example.com/tool@latest` |
## 代码质量与格式化
| 命令 | 作用 | 示例 |
| :------- | :--------------------------- | :------------- |
| `go fmt` | 自动格式化代码,符合官方风格 | `go fmt ./...` |
| `go vet` | 静态检查代码中潜在的错误 | `go vet ./...` |
| `go fix` | 自动把旧版 API 更新为新 API | `go fix ./...` |
## 查看信息
| 命令 | 作用 | 示例 |
| :----------- | :--------------------- | :------------------------------------- |
| `go doc` | 查看包或函数的文档 | `go doc fmt.Println` |
| `go env` | 查看/修改 Go 环境配置 | `go env -w GOPROXY=https://goproxy.cn` |
| `go version` | 查看当前 Go 版本 | `go version` |
| `go help` | 查看某个命令的详细帮助 | `go help build` |
## 其他进阶命令
| 命令 | 作用 | 说明 |
| :------------ | :---------------------------------------------- | :------------------------------------------------------- |
| `go clean` | 清除缓存和编译产物 | `go clean -cache`、`go clean -modcache` |
| `go generate` | 运行源代码中的 `//go:generate` 指令自动生成代码 | 常用于生成代码模板 |
| `go tool` | 调用 Go 自带工具 | `go tool pprof`(性能分析)、`go tool trace`(协程跟踪) |
| `go work` | 多模块工作区管理 | `go work init`、`go work use ./xxx` |
| `go bug` | 打开浏览器提交 bug 报告 | 会自动附带系统信息 |
## 交叉编译
### 基本用法
```bash
# Linux/macOS
GOOS=目标系统 GOARCH=目标架构 go build -o 输出文件名 源文件
# Windows (PowerShell)
$env:GOOS="目标系统"; $env:GOARCH="目标架构"; go build -o 输出文件名 源文件
# Windows (CMD)
set GOOS=目标系统 && set GOARCH=目标架构 && go build -o 输出文件名 源文件
```
查看所有支持的平台
```bash
go tool dist list
```
### 常用组合
| 目标平台 | GOOS | GOARCH |
| ------------ | ------- | ------ |
| Linux 64位 | linux | amd64 |
| Linux ARM64 | linux | arm64 |
| Windows 64位 | windows | amd64 |
| macOS Intel | darwin | amd64 |
| macOS M1/M2 | darwin | arm64 |
### 示例
```bash
# Linux/Mac (Bash)
# 编译 Linux 64位
GOOS=linux GOARCH=amd64 go build -o app ./app/api/main.go
# 编译 Windows 64位
GOOS=windows GOARCH=amd64 go build -o app.exe ./app/api/main.go
# 编译 macOS ARM64 (M1/M2)
GOOS=darwin GOARCH=arm64 go build -o app ./app/api/main.go
# Windows (PowerShell)
# 编译 Linux 64位
$env:GOOS="linux"; $env:GOARCH="amd64"; go build -o app ./app/api/main.go
# 编译 Windows 64位
$env:GOOS="windows"; $env:GOARCH="amd64"; go build -o app.exe ./app/api/main.go
# 编译 macOS ARM64 (M1/M2)
$env:GOOS="darwin"; $env:GOARCH="arm64"; go build -o app ./app/api/main.go
```
@@ -0,0 +1,200 @@
### validator
[GitHub地址](github.com/go-playground/validator)
包验证器,可以用于验证参数是否符合规则,比如下方的比较验证,还有其他可以查看网址的详细介绍
go-zero自带的有校验器,可以使用
| Tag | Description |
| ---- | --------------------- |
| eq | Equals |
| gt | Greater than |
| gte | Greater than or equal |
| lt | Less Than |
| lte | Less Than or Equal |
| ne | Not Equal |
### goverter
[GitHub地址](https://github.com/jmattheis/goverter)
类型转换器,可以用于类型的转换,比如下面的代码
```go
func (l *CreatePopupLogic) CreatePopup(in *backgroundrpcmodel.ResPopup) (*backgroundrpcmodel.ResResult, error) {
_, err := l.svcCtx.PopupModel.Insert(l.ctx, u.Convert.SysPopupR2S(in.Info))
if err != nil {
return nil, rerr.NewFromSQL(err)
}
return &backgroundrpcmodel.ResResult{
Result: true,
}, nil
}
```
使用
```
goverter gen .
```
### asynq
[GitHub网址](https://github.com/hibiken/asynq)
- 入队流程
```go
payload := &jobtype.xxxPayload{}
payloadByte, err := jsoniter.Marshal(payload)
if err != nil {
logt.Error("xxx jsoniter.Marshal err:", err)
return nil, zerr.New(rescode.DataFormatConversionError)
}
_, err = l.svcCtx.AsynqClient.Enqueue(asynq.NewTask(jobtype.xxx, payloadBytes, asynq.Queue(consts.QueueLow)))
if err != nil {
logt.Error("xxx enqueue err:", err)
return nil, zerr.New(rescode.EnqueueErr)
}
```
- 自行定义了队列优先级,在入队的时候可以加参数进行选择
```
consts.QueueCritical: 6,
consts.QueueDefault: 3,
consts.QueueLow: 1,
```
- 处理前流程(反序列化,获取数据)
```go
var payload jobtype.xxx
err := json.Unmarshal(task.Payload(), &payload)
if err != nil {
logt.Error("xxx unmarshal payload err:", err)
return zerr.New(rescode.DataFormatConversionError)
}
```
### gorm
[官网文档](https://gorm.io/zh_CN/docs/)
**使用gorm + sqlx,但是复用sql连接**
```go
func NewServiceContext(c config.Config) *ServiceContext {
logx.Infof("Cache config: %+v", c.CacheRedis)
gormDB, err := gorm.Open(postgres.Open(c.Database.DataSource), &gorm.Config{})
if err != nil {
msg := fmt.Sprintf("连接数据库失败 %s ", err.Error())
logx.Error(msg)
panic(msg)
}
sqlDB, err := gormDB.DB()
if err != nil {
msg := fmt.Sprintf("获取底层连接失败: %s", err.Error())
logx.Error(msg)
panic(msg)
}
sqlxConn := sqlx.NewSqlConnFromDB(sqlDB)
return &ServiceContext{
Config: c,
UserAuthModel: models.NewUserAuthModel(sqlxConn, c.CacheRedis, gormDB),
}
}
```
### excelize
读写Excel的Go库
[github](https://github.com/qax-os/excelize)
#### 创建工作簿
```go
f := excelize.NewFile()
defer func() {
if err := f.Close(); err != nil {
fmt.Println(err)
}
}()
// 创建一个新的Sheet,返回的index为该工作簿的索引
index, err := f.NewSheet("SheetName")
if err != nil {
return fmt.Errorf("创建Sheet失败: %w", err)
}
```
#### 打开文件
```go
f, err := excelize.OpenFile(filePath)
if err != nil {
return fmt.Errorf("打开 Excel失败: %w", err)
}
defer func() {
if err := f.Close(); err != nil {
fmt.Println(err)
}
}()
```
#### 写入cell值
```go
f.SetCellValue("SheetName", "B2", 100)
```
#### 设置工作簿默认活动工作表
```go
f.SetActiveSheet(index)
```
#### 创建或更新excel表
```go
if err := f.SaveAs("Book1.xlsx"); err != nil {
return fmt.Errorf("保存失败: %w", err)
}
```
#### 获取指定cell值
```go
cell, err := f.GetCellValue("Sheet1", "B2")
if err != nil {
return fmt.Errorf("读取cell失败: %w", err)
}
fmt.Println(cell)
```
#### 获取所有工作簿的cell值(二维数组)
```go
rows, err := f.GetRows("Sheet1")
if err != nil {
return fmt.Errorf("读取失败: %w", err)
}
for _, row := range rows {
for _, colCell := range row {
fmt.Print(colCell, "\t")
}
fmt.Println()
}
```
+73
View File
@@ -0,0 +1,73 @@
> Go 1.24 起,`map` 的底层实现从传统的 `hmap` + `bmap` 哈希表,彻底切换到了 Swiss Table(瑞士表)
![Gemini_Generated_Image_jhqwi7jhqwi7jhqw](D:\zhangyuxin\code\lab\note\assets\golang-map[swiss table].png)
### Group内存布局图
你可以直接复制以下文本块到你的 Markdown 笔记中(使用 text 包裹):
codeText
```
+-------------------------------------------------------------------------------+
| 单 Group 内存布局 |
+--------------------------+----------------------------+-----------------------+
| 1. Metadata 控制字节区 | 2. 连续 Key 存储区 | 3. 连续 Value 存储区 |
+--------------------------+----------------------------+-----------------------+
| [M0][M1][M2]...[M7] | [K0][K1][K2]...[K7] | [V0][V1][V2]...[V7] |
+--------------------------+----------------------------+-----------------------+
| <- 固定 8 个字节 -> | <- 长度取决于 Key 类型 -> | <- 长度取决于 Val -> |
| <- 一次性载入寄存器对比 -> | |
```
**为什么这样设计?(设计优势):**
1.
2. **避免内存对齐填充(Padding)**:如果采用 [Meta][Key][Value] 交叉排列,由于不同类型的 Key/Value 字节大小不同,为了对齐,Go 必须在它们之间插入大量空白填充字节,浪费空间。分块连续存储消除了组内的对齐损耗。
3. **对 CPU 缓存友好**:8 字节的 Metadata 紧密相连,一次 Cache Line 填充就能把整组的控制信息全部读入,极大地提高了缓存命中率。
------
### 读取流程
以下为您整理的完整、正确的读取逻辑,您可以直接复制使用:
#### 1.哈希计算与分段 (Hash Splitting)
- 将 Key 传入哈希函数,计算出一个 **64 位的哈希值**。
- 该哈希值被拆分为两部分:**H1(高 57 位)**:用于定位哈希表中的初始 Group 索引。**H2(低 7 位)**:作为特征值(指纹),存储在 Group 的 Metadata 中,用于快速匹配。
#### 2.定位初始 Group (Locate Group)
- 计算初始 Group 索引:group_index = H1 & (Group数量 - 1)。
> 可以理解与$2^B$进行取模运算,但是**位与运算**效率更高且等价。
#### 3.组内快速匹配 (Group-level SWAR Match)
- **一次性加载**:将目标 Group 的 8 字节 Metadata 一次性载入一个 64 位寄存器中。
- **并行对比(SWAR 技术)**:利用位运算,将 7 位的 H2 同时与这 8 个 Metadata 字节进行并行对比,输出一个 8 位的位掩码(Bitmask)。位掩码中为 1 的位,代表该位置的 H2 与目标 H2 匹配成功。这一步极快,完全避免了循环遍历。
#### 4.冲突解决与探测循环 (Verification & Probing Loop)
根据位掩码的匹配结果,执行以下逻辑:
1. 依次根据匹配成功的索引,去 **Key 存储区** 找到对应的真实 Key。
2. 进行完整的 Key 等值对比(如 if key == targetKey):**若匹配成功**:直接去 **Value 存储区** 对应偏移量取出 Value 并返回。**若匹配失败**:说明发生了 **H2 哈希冲突**(即不同的 Key 产生了相同的 H2),继续对比下一个匹配的槽位。
3. 如果当前 Group 中所有匹配了 H2 的槽位都对比完了,仍未找到匹配的 Key,则**跳转至【情况 B】**。
此时需要检查当前 Group 的 Metadata 状态,决定是继续向后寻找还是停止:
- **若当前 Group 中存在“空位”(Empty,0xFF)**:
说明探测链在此处断开,该 Key 在 Map 中**一定不存在**,直接返回 nil 或零值。
- **若当前 Group 中无空位,但存在“墓碑”(Tombstone,0x80)或已满**:
虽然当前 Group 没找到,但可能因为历史删除操作(留下了墓碑)或哈希冲突,导致数据被存到了后续的 Group。不能停止!使用**二次探测算法(Quadratic Probing)**计算下一个 Group 的索引,跳转回 **【3.组内快速匹配】** 继续循环寻找。
@@ -0,0 +1,17 @@
## 命名规则
> [《布尔变量起名的艺术》](https://thatamazingprogrammer.com/posts/stop-naming-your-variables-flag-the-art-of-boolean-prefixes/)
### 使用正确的前缀
(1)**is**:描述事物的状态,后面跟形容词,比如 isActive,isDeleted,isEmpty。
(2)**has**:描述事物的所有权或包含关系,后面跟名词,比如 hasAccess,hasChildren,hasValidationErrors。
(3)**can**:描述事物的能力或权限,比如 canEdit,canDelete,canRetry。
(4)**should**:描述事物的意图或逻辑,比如 shouldRetry,shouldCacheResponse。
### 其他
永远不在布尔变量名中使用否定词,比如isDisabled,因为当使用在if语句中会让大脑进行双重否定运算
+120
View File
@@ -0,0 +1,120 @@
### URL设计规范
URL为统一资源定位器 ,接口属于服务端资源,通常一个完整的URL组成由以下几个部分构成:
```
URI = scheme "://" host ":" port "/" path [ "?" query ][ "#" fragment ]
```
- **scheme**: 指底层用的协议,如http、https、ftp
- **host**: 服务器的IP地址或者域名
- **port**: 端口,http默认为80端口
- **path**: 访问资源的路径,就是各种web 框架中定义的route路由
- **query**: 查询字符串,为发送给服务器的参数,在这里更多发送数据分页、排序等参数。
- **fragment**: 锚点,定位到页面的资源
**通常一个RESTful API的path组成**如下:
```undefined
/{version}/{resources}/{resource_id}
```
- **version**:API版本号,有些版本号放置在头信息中也可以,通过控制版本号有利于应用迭代。
- **resources**:资源,RESTful API推荐用小写英文单词的复数形式。
- **resource_id**:资源的id,访问或操作该资源。
有时候可能资源级别较大,其下还可细分很多子资源也可以灵活设计URL的path,例如:
```undefined
/{version}/{resources}/{resource_id}/{subresources}/{subresource_id}
```
当增删改查无法满足业务要求,可以在URL末尾加上action,例如
```
/{version}/{resources}/{resource_id}/action
```
其中action就是对资源的操作。
**RESTful API的URL具体设计的规范**如下:
1. 不用大写字母,所有单词使用英文且小写。
2. 连字符用中杠`"-"`而不用下杠`"_"`
3. 正确使用 `"/"`表示层级关系,URL的层级不要过深,并且越靠前的层级应该相对越稳定
4. 结尾不要包含正斜杠分隔符`"/"`
5. URL中不出现动词,用请求方式表示动作
6. 资源表示用复数不要用单数
7. 不要使用文件扩展名
**[参考网址](https://www.cnblogs.com/bigsai/p/14099154.html)**
---
### HTTP方法
- **GET**
GET请求会向数据库发索取数据的请求,从而来获取资源,该请求就像数据库的select操作一样,**只是用来查询数据,不会影响资源的内容**。无论进行多少次操作,结果都是一样的
- **POST**
POST请求向服务器发送数据,但是该请求**会改变数据的内容(新添)**,就像数据库的`insert`操作一样,会创建新的内容,且POST请求的请求参数都是请求体中,其大小是没有限制的
- **PUT**
PUT请求是向服务器端发送数据的, 与POST请求不同的是,PUT请求**侧重于数据的修改 ,就像数据库中update一样**,而POST请求侧重于数据的增加
- **DELETE**
用来**删除资源**,和数据库中`delete`相对应
### 参数填写位置
- **Params(Query 参数)**:用于 GET 请求或其他将参数**附加在 URL 中**的情况。
- **Body(请求体)**:用于 POST、PUT 等请求方法,将参数**包含在请求体**中。
### 常见的请求文件类型及其适用场景
- **application/json**
用途:用于传输结构化数据,如对象和数组。
场景:客户端与服务器之间交换数据时,尤其是在前后端分离的应用中。
- **multipart/form-data**
用途:用于上传文件或包含文件的表单数据。
场景:用户上传图片、视频、文档等文件。
- **application/x-www-form-urlencoded**
用途:用于提交简单的表单数据。
场景:传统的表单提交,数据量较小且不包含文件。
- **application/octet-stream**
用途:用于传输二进制数据。
场景:下载文件或通过 API 提供文件流。
+92
View File
@@ -0,0 +1,92 @@
## Cookie
| 维度 | 说明 |
| :----------- | :----------------------------------------------------------- |
| **本质** | 存储在浏览器端的一小段文本数据(通常 4KB 以内) |
| **核心机制** | 浏览器在每次 HTTP 请求中**自动携带**符合域名/路径规则的 Cookie |
| **关键属性** | `HttpOnly`(防 JS 读取)、`Secure`(仅 HTTPS)、`SameSite`(防 CSRF)、`Expires/Max-Age` |
| **定位** | **传输载体,而非认证方案本身**——它负责把令牌(Session ID 或 Token)在客户端和服务器之间来回搬运 |
**优点**:
- 简单成熟,几乎所有 Web 框架默认集成
- 服务端可随时销毁会话,控制力强
- 敏感信息存在服务端,不暴露给客户端
**缺点**:
- 扩展性受限——多服务器需共享 Session(引入 Redis 等外部存储)
- 依赖 Cookie,纯移动端/非浏览器环境不友好
- 容易受到 CSRF 攻击(需额外防护)
## Session
| 维度 | 说明 |
| :----------- | :----------------------------------------------------------- |
| **本质** | 服务器端维护的用户会话数据(通常以 Map 结构存在内存、Redis 或数据库中) |
| **认证流程** | 用户登录 → 服务器创建 Session → 将 Session ID 通过 `Set-Cookie` 返回 → 浏览器后续请求自动携带该 Cookie → 服务器通过 Session ID 查找会话数据验证身份 |
| **核心特点** | **有状态**:服务器持有用户上下文,可随时主动吊销 |
| **定位** | 经典的**服务端主导**的认证方式,适合传统 Web 应用和前后端未彻底分离的场景 |
**优点**:
- 简单成熟,几乎所有 Web 框架默认集成
- 服务端可随时销毁会话,控制力强
- 敏感信息存在服务端,不暴露给客户端
**缺点**:
- 扩展性受限——多服务器需共享 Session(引入 Redis 等外部存储)
- 依赖 Cookie,纯移动端/非浏览器环境不友好
- 容易受到 CSRF 攻击(需额外防护)
## JWT
| **本质** | 一段经过签名(或加密)的 JSON 数据,由 Header、Payload、Signature 三部分组成,以 Base64URL 编码 |
| ------------ | ------------------------------------------------------------ |
| **认证流程** | 用户登录 → 服务器验证凭据并签发 JWT → 客户端存储 Token → 后续请求在 `Authorization: Bearer <token>` 头中携带 → 服务器**仅验证签名和有效期**,无需查库 |
| **核心特点** | **无状态**:Token 自身包含用户身份信息,服务器不需要维护会话 |
| **定位** | 现代**前后端分离架构**和**多端(Web/iOS/Android)统一接入**的事实标准 |
**优点**:
- 天然无状态,服务端可随意水平扩展
- 不依赖 Cookie,可用于移动端、小程序等非浏览器环境
- 跨域友好(CORS 场景下比 Cookie 更灵活)
- Payload 可携带少量业务数据,减少数据库查询
**缺点**:
- Token 一旦签发,在过期前无法主动撤销(需引入黑名单/版本号等机制)
- Payload 仅 Base64 编码(非加密),敏感信息绝对不能放入
- Token 体积比 Session ID 大,每次请求都传输有一定带宽开销
## SSO
| 维度 | 说明 |
| :----------- | :----------------------------------------------------------- |
| **本质** | 一种架构模式,通过**统一的认证中心(IdP)**,让用户在多个独立系统间共享登录状态 |
| **认证流程** | 用户访问应用A → 重定向到 SSO 认证中心 → 登录并获取令牌 → 带回令牌访问应用A → 再访问应用B时,认证中心识别已登录状态,直接放行 |
| **常见实现** | CAS、SAML、OAuth 2.0 + OpenID Connect |
| **定位** | **企业级、多系统生态**的统一身份认证方案 |
**优点**:
- 用户体验极佳:一次登录,访问所有关联系统
- 集中管理:统一账号体系、权限策略、安全审计
- 安全性提升:减少密码泄露面,可配合多因素认证(MFA)
**缺点**:
- **单点故障风险**:认证中心一旦宕机,所有系统登录受影响
- 实现复杂:涉及协议配置、令牌传递、跨域重定向等
- 对认证中心的高可用要求极高
## OAuth 2.0
| 维度 | 说明 |
| :----------- | :----------------------------------------------------------- |
| **本质** | 一种**授权框架**(而非纯粹的认证协议),允许用户授权第三方应用在**不暴露自己密码**的情况下访问受保护资源 |
| **核心角色** | 资源拥有者(用户)、客户端(第三方应用)、授权服务器、资源服务器 |
| **核心流程** | 用户授权 → 第三方获取授权码 → 换取 Access Token → 持 Token 访问资源 |
| **定位** | **第三方接入**和**开放平台**的标准授权方案(微信登录、Google 登录等) |
+22
View File
@@ -0,0 +1,22 @@
## `errors.AsType`
> *用泛型把* `errors.As` *的“先声明变量再传指针”简化为“直接返回* `(E, bool)`*”,是* `errors.As` *的语法糖升级*
**使用场景:**
- 与 `errors.As` 完全相同(按类型提取错误),但写起来更简洁
- 由于类型参数 `E` 必须是 `error` 接口的实现类型,**能在编译期避免 `errors.As` 那种“传错指针类型导致运行时 panic”的问题**
```go
// 传统 errors.As:需要先声明变量
var mysqlErr *mysql.MySQLError
if errors.As(err, &mysqlErr) {
return true, mysqlErr.Number
}
// errors.AsType(Go 1.26+):无需预先声明变量
if mysqlErr, ok := errors.AsType[*mysql.MySQLError](err); ok {
return true, mysqlErr.Number
}
```
+83
View File
@@ -0,0 +1,83 @@
### excelize库
读写Excel的Go库
[github](https://github.com/qax-os/excelize)
## 基本用法
#### 创建工作簿
```go
f := excelize.NewFile()
defer func() {
if err := f.Close(); err != nil {
fmt.Println(err)
}
}()
// 创建一个新的Sheet,返回的index为该工作簿的索引
index, err := f.NewSheet("SheetName")
if err != nil {
return fmt.Errorf("创建Sheet失败: %w", err)
}
```
#### 打开文件
```go
f, err := excelize.OpenFile(filePath)
if err != nil {
return fmt.Errorf("打开 Excel失败: %w", err)
}
defer func() {
if err := f.Close(); err != nil {
fmt.Println(err)
}
}()
```
#### 写入cell值
```go
f.SetCellValue("SheetName", "B2", 100)
```
#### 设置工作簿默认活动工作表
```go
f.SetActiveSheet(index)
```
#### 创建或更新excel表
```go
if err := f.SaveAs("Book1.xlsx"); err != nil {
return fmt.Errorf("保存失败: %w", err)
}
```
#### 获取指定cell值
```go
cell, err := f.GetCellValue("Sheet1", "B2")
if err != nil {
return fmt.Errorf("读取cell失败: %w", err)
}
fmt.Println(cell)
```
#### 获取所有工作簿的cell值(二维数组)
```go
rows, err := f.GetRows("Sheet1")
if err != nil {
return fmt.Errorf("读取失败: %w", err)
}
for _, row := range rows {
for _, colCell := range row {
fmt.Print(colCell, "\t")
}
fmt.Println()
}
```
+23
View File
@@ -0,0 +1,23 @@
# MySQL 字符集的终极巨坑 —— `utf8` vs `utf8mb4`
### 一句话总结
MySQL 中的 `utf8` 是个“假货”(最多只支持 3 字节),`utf8mb4` 才是真正的 UTF-8(支持 4 字节)。
### 为什么会有这个坑
- **历史遗留**:早期 MySQL 开发时,Unicode 字符确实只用 3 个字节就能存下。
- **现象**:一旦用户输入 **Emoji 表情**(如 😀)或部分**生僻字**(如 𰻝),数据库会直接抛出错误:`Incorrect string value`。
### 📊 核心对比
| 字符集 | 真实身份 | 最大字节数 | 能否存 Emoji | 状态 |
| :------------ | :----------------- | :--------- | :----------- | :--------- |
| **`utf8`** | `utf8mb3` (阉割版) | 3 字节 | ❌ 报错 | ⚠️ 已废弃 |
| **`utf8mb4`** | 真正的 UTF-8 | 4 字节 | ✅ 随便存 | ✅ 推荐使用 |
### 🛠️ 各版本现状与未来
- **MySQL 5.7 及以前**:默认字符集是 `latin1`,`utf8` 是重灾区。
- **MySQL 8.0 及以后**:**默认字符集已改为 `utf8mb4`**(新项目安全系数提升)。
- **官方态度**:目前 `utf8`(即 `utf8mb3`)已被标记为 Deprecated(废弃)。官方计划在**未来的某个大版本中彻底删除 `utf8mb3`**,并让 `utf8` 这个名字正式指向 `utf8mb4`。
+82
View File
@@ -0,0 +1,82 @@
> 私钥永不出生(生成)于服务器,且永不离开客户端
### 客户端生成密钥对
```bash
ssh-keygen -t ed25519 -C "mail"
```
- `-t`:指定密钥算法。`ed25519` 在同等安全强度下密钥更短、性能更好、是当前最佳实践;`RSA 4096` 依然安全,且兼容性更广,如果是很老的系统(OpenSSH < 6.5),可能需要`RSA`
- `-C`:添加注释,不影响加密、解密或登录认证的安全性,纯粹是一个给人看的备注字段
使用上述命令会生成一对密钥,通常位于`~/.ssh`目录下:公钥是`id_ed25519.pub`,私钥是`id_ed25519`。
### 上传公钥到目标服务器
方式1
```bash
ssh-copy-id -i ~/.ssh/id_ed25519.pub root@服务器IP
```
方式2
```bash
cd ~/.ssh
echo "公钥内容" >> ~/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keys
chmod 700 ~/.ssh
```
> 手动 echo 是通用方法,但更推荐 `ssh-copy-id`,它会自动处理目录创建、权限设置和追加
### 测试密钥登录
```bash
ssh root@服务器IP
```
- 如果直接登录成功,没有询问密码,说明密钥配置完全正确,继续下一步。
- 如果仍然要求输密码,需要检查上一步的公钥是否粘贴完整,或 `authorized_keys` 权限是否为 `600`。
### 修改`sshd_config`
修改服务器的`/etc/ssh/sshd_config`文件,将下方这些行取消注释或者添加:
```
PubkeyAuthentication yes # 开启密钥认证
PasswordAuthentication no # 关闭密码认证
PermitRootLogin prohibit-password # root账户禁用密码登录
```
保存退出后,使用`sshd -t`检查配置文件语法是否正确
重启SSH服务
> SSH服务名因发行版而异:
>
> `Debian`/`Ubuntu`使用`systemctl restart ssh`
>
> ``RHEL`/`CentOS`/`Fedora` 上使用`systemctl restart sshd`
### 最终验证
1. 使用SSH发起连接后没有输入密码的提示,能直接连接服务器
```bash
ssh root@服务器IP
```
2. 临时删除私钥后再次登录会报错,大概如下
```
root@服务器IP: Permission denied (publickey,gssapi-keyex,gssapi-with-mic).
+476
View File
@@ -0,0 +1,476 @@
> 感觉还是不如typora好使啊,以后买个正版吧
```css
/**
* MarkText One Light Dark 主题 (仿 Typora onelight-dark:https://github.com/caolib/typora-onelight-theme)
*
* 使用方法:
* 在 MarkText 中打开 设置 → 主题 → 自定义 CSS,
* 将本文件全部内容粘贴进去,保存即可。
*
* 配色来源: Typora onelight-dark theme by caolib
* 兼容: MarkText v0.19.x (旧引擎) / v0.20+ (新引擎)
*/
/* ====================================================================
* CSS 变量 — 使用 camelCase 命名 (旧引擎 v0.19.x 兼容)
* ==================================================================== */
:root {
/* —— 编辑器核心 —— */
--themeColor: #5c99f3;
--themeColor90: rgba(92, 153, 243, 0.9);
--themeColor80: rgba(92, 153, 243, 0.8);
--themeColor70: rgba(92, 153, 243, 0.7);
--themeColor60: rgba(92, 153, 243, 0.6);
--themeColor50: rgba(92, 153, 243, 0.5);
--themeColor40: rgba(92, 153, 243, 0.4);
--themeColor30: rgba(92, 153, 243, 0.3);
--themeColor20: rgba(92, 153, 243, 0.2);
--themeColor10: rgba(92, 153, 243, 0.1);
--highlightThemeColor: #e94ab9;
--highlightColor: #ffffff10;
--selectionColor: #3266d0;
--editorColor: #c3c3c3;
--editorColor80: #c3c3c3;
--editorColor60: rgba(195, 195, 195, 0.6);
--editorColor50: rgba(195, 195, 195, 0.5);
--editorColor40: rgba(195, 195, 195, 0.4);
--editorColor30: rgba(195, 195, 195, 0.3);
--editorColor10: rgba(195, 195, 195, 0.1);
--editorColor04: rgba(195, 195, 195, 0.04);
--editorBgColor: #1f1f1e;
--editorAreaWidth: 900px;
--deleteColor: #ff6b6b;
--iconColor: rgba(195, 195, 195, 0.56);
--codeBgColor: #282c34;
--codeBlockBgColor: #282c34;
--footnoteBgColor: rgba(51, 51, 51, 0.5);
--inputBgColor: #3d404e;
--focusColor: #5c99f3;
/* —— 按钮 —— */
--buttonFontColor: #c3c3c3;
--buttonBgColor: #333333;
--buttonBorder: 1px solid #353a45;
--buttonShadow: none;
--buttonFontColorHover: #ffffff;
--buttonBgColorHover: #414855;
--buttonBorderHover: var(--buttonBorder);
--buttonFontColorActive: var(--buttonFontColor);
--buttonBgColorActive: #2a2d35;
--buttonBorderActive: var(--buttonBorder);
--buttonFocusBorder: 1px solid #5c99f3;
--buttonFocusShadow: 0 0 0 1px rgba(92, 153, 243, 0.3);
--buttonPrimaryFontColor: #ffffff;
--buttonPrimaryBgColor: #5c99f3;
--buttonPrimaryBorder: 1px solid #4a8ad9;
--buttonPrimaryShadow: none;
--buttonPrimaryFontColorHover: var(--buttonPrimaryFontColor);
--buttonPrimaryBgColorHover: #4a8ad9;
--buttonPrimaryBorderHover: var(--buttonPrimaryBorder);
--buttonPrimaryFontColorActive: var(--buttonPrimaryFontColor);
--buttonPrimaryBgColorActive: #3d7bc4;
--buttonPrimaryBorderActive: var(--buttonPrimaryBorder);
--buttonPrimaryFocusBorder: none;
--buttonPrimaryFocusShadow: inset 0 0 0 1px rgba(24, 26, 31, 0.5), 0 0 0 1px #5c99f3;
/* —— Markdown 元素 —— */
--headingColor: #c3c3c3;
--h1Color: #c3c3c3;
--h2Color: #c3c3c3;
--h3Color: #c3c3c3;
--h4Color: #ffffff;
--h5Color: #c3c3c3;
--h6Color: #c3c3c3;
--blockquoteTextColor: rgba(195, 195, 195, 0.8);
--blockquoteBorderColor: #5c99f3;
--hrColor: #5c99f3;
--linkColor: #e94ab9;
--strongColor: #5c99f3;
--emColor: #e94ab9;
--listMarkerColor: #5c99f3;
--tableBorderColor: #393e49;
/* —— 侧边栏 —— */
--sideBarColor: #c3c3c3;
--sideBarIconColor: var(--iconColor);
--sideBarTitleColor: #c3c3c3;
--sideBarTextColor: #7a7a7a;
--sideBarBgColor: #1f1f1e;
--sideBarItemHoverBgColor: #333333;
--itemBgColor: #333333;
/* —— 浮动层 —— */
--floatFontColor: #c3c3c3;
--floatBgColor: #353a45;
--floatHoverColor: #414855;
--floatBorderColor: #353a45;
--floatShadow: rgba(0, 0, 0, 0.3);
--maskColor: rgba(0, 0, 0, 0.7);
}
/* ====================================================================
* 全局背景 — 统一为 Typora onelight-dark 底色 #1f1f1e
* ==================================================================== */
html, body {
background-color: #1f1f1e;
}
.editor-wrapper {
background-color: #1f1f1e;
}
.editor-component {
background-color: #1f1f1e;
}
#ag-editor-id {
background-color: #1f1f1e;
}
/* ====================================================================
* 滚动条
* ==================================================================== */
::-webkit-scrollbar,
::-webkit-scrollbar-corner {
background: var(--editorBgColor);
}
::-webkit-scrollbar:vertical { width: 8px; }
::-webkit-scrollbar:vertical:hover { width: 10px; }
::-webkit-scrollbar-thumb {
background: rgba(134, 134, 134, 0.27);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.5);
}
/* ====================================================================
* 弹窗 (Element UI)
* ==================================================================== */
.v-modal { background: #08090a !important; opacity: 0.65 !important; }
.el-dialog,
.el-dialog.ag-dialog-table {
border-radius: 6px !important;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3) !important;
border: 1px solid #353a45 !important;
background: #2a2a29 !important;
}
.el-button:focus { color: var(--buttonFontColor); }
.el-button--primary:focus {
color: var(--buttonPrimaryFontColor);
background: var(--buttonPrimaryBgColor);
}
/* ====================================================================
* 标题栏 / 浮动菜单
* ==================================================================== */
.title-bar .frameless-titlebar-button > div > svg { fill: #c3c3c3; }
.title-bar .frameless-titlebar-minimize:hover,
.title-bar .frameless-titlebar-toggle:hover {
background-color: rgba(195, 195, 195, 0.05) !important;
}
.input-wrapper {
border-radius: 5px !important;
border: 1px solid #353a45 !important;
background: #2a2a29 !important;
}
.ag-front-menu,
.ag-float-wrapper {
box-shadow: 0 4px 8px 0 var(--floatShadow) !important;
}
/* ====================================================================
* 侧边栏
* ==================================================================== */
.side-bar { border-right: 1px solid #333333 !important; }
.left-column ul > li > svg { fill: rgba(195, 195, 195, 0.6) !important; }
.left-column ul > li:hover > svg { fill: #ffffff !important; }
.left-column ul > li.active > svg { fill: #5c99f3 !important; }
/* ====================================================================
* 标签栏
* ==================================================================== */
.editor-tabs {
box-shadow: none !important;
background: var(--editorBgColor) !important;
}
.editor-tabs:after {
position: absolute;
content: '';
border-bottom: 1px solid #333333;
bottom: 0; left: 0; right: 0; z-index: 1;
}
.editor-tabs ul.tabs-container:after {
position: absolute;
content: '';
border-bottom: 1px solid #333333;
bottom: 0; left: 0; right: 0; z-index: 2;
}
.tabs-container > li,
.tabs-container > li.active { background: var(--editorBgColor) !important; }
.tabs-container > li.active {
border: 1px solid #333333;
border-bottom: none;
}
.tabs-container > li.active:after {
top: 0 !important; right: auto !important;
width: 2px !important; height: auto !important;
background: #5c99f3 !important;
}
.tabs-container svg.close-icon #unsaved-circle-icon { fill: #5c99f3; }
/* ====================================================================
* 编辑器内容区 (v0.19.x 旧引擎: 容器是 #ag-editor-id)
* ==================================================================== */
/* ---- 标题 ---- */
#ag-editor-id h1 {
text-align: center;
padding-bottom: 0.3em;
}
#ag-editor-id h2 {
border-bottom: 2px solid var(--themeColor);
}
#ag-editor-id h3 {
border-left: 4px solid var(--themeColor);
padding-left: 8px;
}
#ag-editor-id h4 {
color: #ffffff !important;
background: var(--themeColor);
border-radius: 5px;
width: fit-content;
padding: 2px 12px;
}
/* ---- 粗体 / 斜体 ---- */
#ag-editor-id strong { color: var(--strongColor); font-weight: 700; }
#ag-editor-id em { color: var(--emColor); }
/* ---- 链接 ---- */
#ag-editor-id a {
color: var(--linkColor);
font-weight: 700;
text-decoration: underline;
}
/* ---- 行内代码 ---- */
#ag-editor-id code {
background-color: var(--codeBlockBgColor);
color: #eb4c37;
border-radius: 3px;
padding: 2px 4px;
}
/* ---- 代码块 ---- */
#ag-editor-id :not(pre) > code[class*='language-'],
#ag-editor-id pre:not(.CodeMirror-line),
#ag-editor-id pre[class*='language-'],
#ag-editor-id pre.ag-paragraph {
background: var(--codeBlockBgColor) !important;
border: none !important;
border-radius: 5px !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
transition: box-shadow 0.3s ease;
}
/* ---- 引用块 (四面边框 + 左侧加粗) ---- */
#ag-editor-id blockquote {
padding: 10px 10px 10px 20px;
font-size: 0.9em;
border: 2px solid var(--blockquoteBorderColor);
border-left: 5px solid var(--blockquoteBorderColor);
border-radius: 0 5px 5px 0;
background: transparent;
overflow: auto;
transition: background-color 0.3s ease, box-shadow 0.3s ease;
}
#ag-editor-id blockquote:hover {
background: var(--editorBgColor);
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
}
/* 隐藏引擎默认的 ::before 竖条,改用 border-left */
#ag-editor-id blockquote::before {
content: none;
}
/* ---- 分割线 ---- */
#ag-editor-id hr {
height: 2px;
background-color: var(--hrColor);
}
/* ---- 表格 ---- */
#ag-editor-id table tr th::before,
#ag-editor-id table tr td::before {
border-color: var(--tableBorderColor);
}
/* ---- 图片 ---- */
#ag-editor-id img {
border-radius: 10px;
display: block;
margin: 0 auto;
max-width: 100%;
}
/* ---- 任务列表复选框 ---- */
#ag-editor-id li.ag-task-list-item {
list-style-type: none;
position: relative;
}
#ag-editor-id li.ag-task-list-item > input[type='checkbox'] {
position: absolute;
cursor: pointer;
width: 16px; height: 16px; top: 0.1em;
margin: 0; left: -24px;
transform-origin: center;
transition: all 0.2s ease;
appearance: none;
-webkit-appearance: none;
}
#ag-editor-id li.ag-task-list-item > input[type='checkbox']::before {
content: '';
width: 16px; height: 16px;
box-sizing: border-box;
display: inline-block;
border: 2px solid var(--editorColor50);
border-radius: 2px;
background-color: var(--editorBgColor);
position: absolute; top: 0; left: 0;
transition: all 0.2s ease;
}
#ag-editor-id li.ag-task-list-item > input[type='checkbox']:checked::before {
border-color: transparent;
background-color: var(--themeColor);
}
#ag-editor-id li.ag-task-list-item > input[type='checkbox']::after {
content: '';
transform: rotate(-45deg) scale(0);
width: 9px; height: 5px;
border: 2px solid #fff;
border-top: none; border-right: none;
position: absolute; display: inline-block;
top: 1px; left: 5px;
transition: all 0.2s ease;
}
#ag-editor-id li.ag-task-list-item > input[type='checkbox']:checked::after {
transform: rotate(-45deg) scale(1);
}
/* ---- 段落悬停 ---- */
#ag-editor-id p.ag-paragraph {
padding: 5px;
margin: 0;
border-radius: 5px;
transition: background-color 0.3s ease, box-shadow 0.3s ease;
}
#ag-editor-id p.ag-paragraph:hover {
background: #141414;
box-shadow: 0 4px 4px rgba(0, 0, 0, 0.3);
}
/* ---- 已完成任务文字 ---- */
#ag-editor-id li.ag-task-list-item > input.ag-checkbox-checked ~ * {
color: var(--editorColor50);
}
/* ---- 列表标记 ---- */
#ag-editor-id ul > li::marker,
#ag-editor-id ol > li::marker {
color: var(--listMarkerColor);
}
/* ---- 搜索高亮 ---- */
.ag-highlight {
background: #ff0 !important;
color: red !important;
border-radius: 3px;
padding: 0 2px;
height: auto !important;
line-height: inherit !important;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
/* ---- 表格表头 ---- */
#ag-editor-id table tr th {
background: var(--editorColor04);
}
#ag-editor-id table tr:first-child td {
background: var(--editorColor04);
}
/* ---- 脚注 ---- */
#ag-editor-id sup.md-footnote {
color: var(--strongColor);
font-weight: 700;
}
/* ---- 前置图标 ---- */
.ag-front-icon {
fill: var(--editorColor30);
}
/* ====================================================================
* CodeMirror 源码模式 (v0.19.x 旧引擎)
* ==================================================================== */
.source-code {
background: var(--editorBgColor);
}
.source-code .CodeMirror {
color: var(--editorColor);
background: transparent;
}
.source-code .CodeMirror-gutters {
background: transparent;
border-right-color: #333333;
}
.source-code .CodeMirror-linenumber {
color: var(--editorColor30);
}
.source-code .CodeMirror-cursor {
border-left-color: var(--themeColor);
}
.source-code .CodeMirror-activeline-background {
background: var(--editorColor04);
}
.source-code .CodeMirror-selected {
background: var(--selectionColor) !important;
}
/* CodeMirror 语法高亮色 — 参考 onelight 配色 */
.cm-s-one-dark .cm-comment { color: #7f848e; font-style: italic; }
.cm-s-one-dark .cm-keyword { color: #c678dd; }
.cm-s-one-dark .cm-operator { color: #56b6c2; }
.cm-s-one-dark .cm-builtin { color: #e5c07b; }
.cm-s-one-dark .cm-atom { color: #d19a66; }
.cm-s-one-dark .cm-number { color: #d19a66; }
.cm-s-one-dark .cm-def { color: #61afef; }
.cm-s-one-dark .cm-string { color: #98c379; }
.cm-s-one-dark .cm-string-2 { color: #e06c75; }
.cm-s-one-dark .cm-variable { color: #e06c75; }
.cm-s-one-dark .cm-variable-2 { color: #e5c07b; }
.cm-s-one-dark .cm-variable-3 { color: #d19a66; }
.cm-s-one-dark .cm-type { color: #e5c07b; }
.cm-s-one-dark .cm-meta { color: #e06c75; }
.cm-s-one-dark .cm-attribute { color: #d19a66; }
.cm-s-one-dark .cm-property { color: #e06c75; }
.cm-s-one-dark .cm-qualifier { color: #e5c07b; }
.cm-s-one-dark .cm-tag { color: #e06c75; }
.cm-s-one-dark .cm-link { color: #c678dd; }
.cm-s-one-dark .cm-header { color: #61afef; }
.cm-s-one-dark .cm-quote { color: #98c379; }
/* ====================================================================
* Mermaid/图表 SVG 暗色适配
* ==================================================================== */
figure[data-role] svg rect[fill='#ffffff'] { fill: var(--editorBgColor); }
figure[data-role] svg rect[stroke='#000000'] { stroke: var(--editorColor); }
figure[data-role] svg text[fill='#000000'] { fill: var(--editorColor); }
figure[data-role] svg path[stroke='#000000'] { stroke: var(--editorColor); }
figure[data-role] svg path[fill='#ffffff'] { fill: var(--editorBgColor); }
figure[data-role] svg path[fill='#000000'] { fill: var(--editorColor); }
figure[data-role] svg use[fill='black'] { fill: var(--editorColor); }
figure[data-role] svg use[fill='#000000'] { fill: var(--editorColor); }
```
+64
View File
@@ -0,0 +1,64 @@
# 正则表达式
正则表达式是一种用于匹配和操作文本的强大工具,用于描述要匹配的文本模式。
## 匹配字符
### 普通匹配字符
| 字符 | 描述 |
| :------- | :----------------------------------------------------------- |
| `[xyz]` | 匹配 `[xyz]` 中列出的所有字符。例如 `[xyz]`,能够匹配目标的所有 x、y、z 字符。 |
| `[^xyz]` | 匹配 `[^xyz]` 中列出的字符外的所有字符。例如 `[^xyz]`,能够匹配目标的除去 x、y、z 外的所有字符。 |
| `[A-Z]` | `[A-Z]` 表示一个区间,匹配所有大写字母;`[a-z]` 匹配所有小写字母;`[0-9]` 匹配所有数字。 |
| `[\s\S]` | 匹配所有字符(包括换行符)。`\s` 匹配所有空白符(包括换行),`\S` 匹配所有非空白符(不包括换行),两者合并即可匹配任意字符。 |
| `.` | 匹配除换行符 `\n`、`\r` 之外的任何单个字符,等价于 `[^\n\r]`。 |
| `\w` | 匹配字母、数字、下划线,等价于 `[A-Za-z0-9_]`。 |
| `\d` | 匹配任意一个阿拉伯数字(0 到 9),等价于 `[0-9]`。 |
### 非打印字符
| 字符 | 描述 |
| :---- | :----------------------------------------------------------- |
| `\cx` | 匹配由 x 指明的控制字符。例如,`\cM` 匹配一个 Control-M 或回车符。x 的值必须为 A-Z 或 a-z 之一,否则将 c 视为一个原义的 'c' 字符。 |
| `\f` | 匹配一个换页符,等价于 `\x0c` 和 `\cL`。 |
| `\n` | 匹配一个换行符,等价于 `\x0a` 和 `\cJ`。 |
| `\r` | 匹配一个回车符,等价于 `\x0d` 和 `\cM`。 |
| `\s` | 匹配任何空白字符,包括空格、制表符、换页符等,等价于 `[ \f\n\r\t\v]`。注意 Unicode 正则表达式会匹配全角空格符。 |
| `\S` | 匹配任何非空白字符,等价于 `[^ \f\n\r\t\v]`。 |
| `\t` | 匹配一个制表符,等价于 `\x09` 和 `\cI`。 |
| `\v` | 匹配一个垂直制表符,等价于 `\x0b` 和 `\cK`。 |
### 特殊字符
| 特殊字符 | 描述 |
| :------- | :----------------------------------------------------------- |
| `$` | 匹配输入字符串的结尾位置。如果设置了 RegExp 对象的 Multiline 属性,则 `$` 也匹配 `\n` 或 `\r`。要匹配 `$` 字符本身,请使用 `\$`。 |
| `( )` | 标记一个子表达式的开始和结束位置。子表达式可以获取供以后使用。要匹配这些字符,请使用 `\(` 和 `\)`。 |
| `*` | 匹配前面的子表达式零次或多次。要匹配 `*` 字符本身,请使用 `\*`。 |
| `+` | 匹配前面的子表达式一次或多次。要匹配 `+` 字符本身,请使用 `\+`。 |
| `.` | 匹配除换行符 `\n` 之外的任何单字符。要匹配 `.` 本身,请使用 `\.`。 |
| `[` | 标记一个中括号表达式的开始。要匹配 `[`,请使用 `\[`。 |
| `?` | 匹配前面的子表达式零次或一次,等价于 `{0,1}`。要匹配 `?` 字符本身,请使用 `\?`。例如,`do(es)?` 可以匹配 "do" 和 "does",但不能匹配 "dog"。 |
| `\` | 将下一个字符标记为特殊字符、原义字符、向后引用或八进制转义符。例如,`\\` 匹配 `\`。 |
| `^` | 匹配输入字符串的开始位置。在方括号表达式中使用时,表示不接受方括号内的字符集合(取反)。要匹配 `^` 字符本身,请使用 `\^`。 |
| `{` | 标记限定符表达式的开始。要匹配 `{`,请使用 `\{`。 |
| `|` | 指明两项之间的一个选择。要匹配 `|`,请使用 `\|`。 |
### 限定符
| 字符 | 描述 |
| :------ | :----------------------------------------------------------- |
| `{n}` | n 是一个非负整数,匹配确定的 n 次。例如,`o{2}` 不能匹配 "Bob" 中的 o,但是能匹配 "food" 中的两个 o。 |
| `{n,}` | n 是一个非负整数,至少匹配 n 次。例如,`o{2,}` 不能匹配 "Bob" 中的 o,但能匹配 "foooood" 中的所有 o。`o{1,}` 等价于 `o+`,`o{0,}` 等价于 `o*`。 |
| `{n,m}` | m 和 n 均为非负整数,其中 n <= m。最少匹配 n 次且最多匹配 m 次。例如,`o{1,3}` 将匹配 "fooooood" 中的前三个 o。`o{0,1}` 等价于 `o?`。 |
## 常用正则表达式
| 正则表达式 | 匹配内容 |
| ------------------------------------------------ | ------------ |
| `[\u4e00-\u9fa5]` | 中文字符 |
| `^1[3-9]\d{9}$` | 国内手机号 |
| `[1-9]\d{5}(?!\d)` | 国内邮政编码 |
| `^(\d{6})(\d{4})(\d{2})(\d{2})(\d{3})([0-9]|X)$` | 18位身份证号 |
+9
View File
@@ -0,0 +1,9 @@
[Typora onelight 主题](https://github.com/caolib/typora-onelight-theme?tab=readme-ov-file#4%E5%85%B3%E4%BA%8E%E5%AD%97%E4%BD%93)
[Typora自定义主题](https://theme.typora.io/doc/zh/Write-Custom-Theme/)
> 通过自定义的CSS变量来实现
[CSDN增强助手](https://chromewebstore.google.com/detail/csdn%E5%A2%9E%E5%BC%BA%E5%8A%A9%E6%89%8B/fpgaealcfgjbodghojppfmohfcoajled)
> 能够智能屏蔽CSDN搜索结果、屏蔽其广告、自动展开代码
+32
View File
@@ -0,0 +1,32 @@
# TypeScript / JavaScript 练习区
本目录是 TS/JS 的学习 / 练手代码。与 Go 那边共用一个 module 不同,这里**每个实验是独立的 npm 项目**(各自的 `package.json`、lock 文件)。
## 约定
- **每个叶子目录 = 一个独立实验**:自带 `package.json`,依赖自行管理。
- 多个实验并存互不冲突,框架和版本可以各不相同(一个 Astro 4、一个 Astro 5 都行)。
- `node_modules/`、构建产物(`dist/`、`.astro/` 等)已在根 `.gitignore` 忽略,不会提交。
## 主题目录
| 目录 | 放什么 |
|---|---|
| `basics/` | 语法基础、类型系统、ES 新特性 |
| `web/` | 前端框架:Next.js、Astro、Vite 等 |
| `node/` | Node.js 后端练习(http、fs、CLI…) |
| `testing/` | 测试机制(Vitest、Playwright…) |
| `project/` | 完整小项目(多文件) |
> 主题目录可随时增删。写新实验时挑最贴的主题,在里面建一个新子目录即可。
## 新建一个 Astro 实验
```bash
cd ts/web/astro
npm create astro@latest my-lab -- --template basics --no-git --install
cd my-lab
npm run dev
```
> `my-lab` 换成实验名;想完全交互式地选模板/TypeScript 严格度,去掉 `--` 后面的参数即可。
+5
View File
@@ -0,0 +1,5 @@
.next/
out/
node_modules/
coverage/
next-env.d.ts
+9
View File
@@ -0,0 +1,9 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

@@ -0,0 +1,453 @@
# Polaris 首页实现规格
> 日期:2026-08-26
>
> 状态:用户已批准,可进入实施
>
> 实现分支:`feat/polaris-homepage`
## 1. 背景与目标
Polaris 是一个轻量、安静、高效的个人浏览器起始页,不是后台管理系统或信息密集型 Dashboard。
本次工作只建立推荐的项目结构并实现完整可交互首页。用户验收首页视觉和交互后,才会明确决定是否继续实现分类页及第一阶段其余目标。
## 2. 范围
### 2.1 本次实现
- 初始化 Next.js App Router 项目;
- 建立 Feature-first 混合目录;
- 整理现有设计文档和资产位置;
- 实现低饱和青蓝 CSS 渐变背景与浅色玻璃视觉;
- 实现可折叠且持久化的桌面侧栏;
- 实现实时时钟、日期和分时问候语;
- 实现 Google、Bing、GitHub、Local 四种严格分离的搜索模式;
- 实现 Local 搜索结果、键盘导航和空状态;
- 实现 8 个常用链接;
- 实现统一图标 fallback;
- 建立本地 JSON、service、类型与数据校验边界;
- 完成单元测试、组件交互测试、静态构建和真实页面视觉检查。
### 2.2 本次不实现
- 正式分类页;
- Go API 与 PostgreSQL;
- 管理页或隐藏管理入口;
- 深色模式;
- 移动端与平板布局;
- 自定义背景上传;
- 自定义搜索引擎、URL 直达、AI 搜索;
- 加载骨架和后端错误重试。
## 3. 已确认决策
| 决策项 | 结论 |
| ---------- | ----------------------------------------------------------------- |
| 首页范围 | 完整可交互首页 |
| 目录方案 | Feature-first + 共享基础层 |
| 分支粒度 | 项目骨架与首页位于同一个 `feat/polaris-homepage` 分支 |
| 文档整理 | 三份文档迁入 `docs/design/`,设计图迁入 `docs/assets/` |
| Logo | 现有占位 Logo 迁入 `public/brand/`,不追加品牌设计 |
| 运行时背景 | CSS 低饱和青蓝渐变;设计图只作构图参考 |
| 外部搜索 | Google、Bing、GitHub 在当前标签页跳转 |
| 本地链接 | Local 结果与常用链接在新标签页打开 |
| 排序模型 | `sort` 管理分类内顺序;可选 `pinnedSort` 独立管理首页常用链接顺序 |
| 分类导航 | 首页验收阶段展示但禁用,避免进入未实现路由;分类页完成后启用 |
| 注释 | 关键规则、非显然决策、边界条件和阶段性限制使用简洁中文注释 |
| 提交时机 | 分支全部目标完成并验证后再提交 |
## 4. 技术基线
- Next.js App Router;
- React;
- TypeScript strict;
- Tailwind CSS v4,使用 CSS-first 配置;
- shadcn/ui,只引入实际使用的基础原语;
- lucide-react;
- simple-icons;
- pnpm;
- Vitest;
- Testing Library;
- ESLint;
- Prettier。
`next.config.ts` 使用 `output: "export"`,构建产物必须能作为纯静态文件部署。
不引入 Redux、Zustand、React Query、Zod、Storybook、Repository interface 或依赖注入容器。当前规模下这些抽象没有必要。
## 5. 推荐目录
```text
polaris/
├─ docs/
│ ├─ design/
│ │ ├─ Polaris 项目说明文档.md
│ │ ├─ Polaris 首页设计文档.md
│ │ ├─ Polaris 分类页设计文档.md
│ │ └─ 2026-08-26-Polaris-首页实现规格.md
│ └─ assets/
│ └─ Polaris页面设计图.png
├─ public/
│ ├─ brand/
│ │ └─ logo.svg
│ └─ icons/
├─ src/
│ ├─ app/
│ │ ├─ globals.css
│ │ ├─ layout.tsx
│ │ └─ page.tsx
│ ├─ components/
│ │ ├─ ui/
│ │ └─ shared/
│ ├─ features/
│ │ ├─ home/
│ │ │ ├─ components/
│ │ │ ├─ hooks/
│ │ │ └─ lib/
│ │ └─ navigation/
│ │ └─ components/
│ ├─ data/
│ │ ├─ categories.json
│ │ ├─ links.json
│ │ └─ settings.json
│ ├─ services/
│ │ ├─ categories.ts
│ │ ├─ links.ts
│ │ └─ settings.ts
│ ├─ types/
│ │ ├─ navigation.ts
│ │ ├─ search.ts
│ │ └─ settings.ts
│ ├─ lib/
│ │ └─ utils.ts
│ └─ test/
│ └─ setup.ts
├─ components.json
├─ eslint.config.mjs
├─ next.config.ts
├─ package.json
├─ pnpm-lock.yaml
├─ postcss.config.mjs
├─ prettier.config.mjs
├─ tsconfig.json
└─ vitest.config.ts
```
### 5.1 目录职责
- `app/`:路由、metadata、根布局、全局样式和页面组合;
- `features/`:按功能聚合组件、hooks 和专属纯函数;
- `components/ui/`:shadcn/ui 基础原语;
- `components/shared/`:跨功能复用组件,例如 `GlassPanel`、`SiteIcon`;
- `data/`:第一版本地 JSON;
- `services/`:页面与数据源之间的唯一访问边界;
- `types/`:跨功能共享的数据契约;
- `lib/`:真正通用且不依赖业务功能的工具;
- `test/`:全局测试环境与共享测试辅助代码。
测试文件与被测试实现就近放置,`src/test/` 不作为所有测试的集中目录。
## 6. 渲染与组件边界
`src/app/page.tsx` 保持为 Server Component,在构建时调用 service 层读取数据,再将可序列化数据传给页面组件。
```text
app/page.tsx
└─ AppShell
├─ Sidebar
│ ├─ SidebarHeader
│ └─ SidebarNav
└─ HomeContent
├─ ClockGreeting
├─ SearchBox
│ ├─ SearchProviderMenu
│ └─ LocalSearchResults
└─ QuickLinks
└─ QuickLinkCard
└─ SiteIcon
```
Client Component 边界仅用于浏览器状态:
- `AppShell`:侧栏折叠与持久化;
- `ClockGreeting`:当前时间;
- `SearchBox`:Provider、query、结果选中项和快捷键;
- `SiteIcon`:图标加载失败后的 fallback。
把 Server Component 作为 `AppShell` 的 `children` 传入,不因为外层存在 Client Component 就将全部展示代码改成客户端代码。
不为每个小标签或图标创建组件。只有存在独立状态、复用价值或清晰职责时才拆分。
## 7. 数据模型与数据流
### 7.1 数据规模
- 7 个分类;
- 开发工具 15 条;
- ChatGPT、Gmail、Notion、YouTube、Bilibili 5 条跨分类链接;
- 共 20 条链接;
- 8 条 `pinned` 常用链接;
- `sort` 只表示分类内顺序,可选 `pinnedSort` 只表示首页常用链接顺序。
首页常用链接固定按以下 `pinnedSort` 顺序展示:GitHub、ChatGPT、Gmail、Notion、Figma、YouTube、Vercel、Bilibili。该字段解决开发工具分类中 Vercel 位于 Figma 之前、但首页中 Figma 位于 Vercel 之前的双重排序需求。
设计资源、学习资料、生活服务保持无链接;AI 工具、效率工具和娱乐媒体因常用链接而有少量数据。
### 7.2 访问路径
1. `page.tsx` 调用 `getCategories()`、`getLinks()` 和 `getSettings()`;
2. service 层复制、排序和筛选数据;
3. 页面不直接 import JSON;
4. 首页只把所需的分类、全部搜索候选、8 条常用链接和设置传给客户端;
5. 未来接 Go API 时保持 service 的对外职责不变,只替换其内部数据来源。
### 7.3 数据校验
Vitest 必须校验:
- 必填字段类型;
- 分类和链接 ID 唯一;
- URL 合法;
- `categoryId` 必须指向现有分类;
- `sort` 与可选 `pinnedSort` 合法;
- Provider 配置有效;
- `pinned` 数量为 8,且每条 pinned 链接都有唯一 `pinnedSort`。
## 8. 视觉系统
### 8.1 背景
使用多层 CSS 渐变和柔和光斑模拟晨雾、天空和水面氛围,不把 `Polaris页面设计图.png` 作为运行时背景。
增加浅色遮罩和适度模糊,保证未来替换背景时仍具有可读性。
### 8.2 布局
- 侧栏外边距:`20px`;
- 展开宽度:`240px`;
- 折叠宽度:`72px`;
- 主内容视觉重心位于中上方;
- 使用 `minmax()` 与 `clamp()` 适配 1024px 以上桌面窗口;
- 搜索框最大宽度约 `760px`、高度约 `68px`;
- 常用链接最大四列、共两行,缩窄窗口时允许减少列数;
- 第一版不实现移动端抽屉侧栏。
### 8.3 Design tokens
在 `globals.css` 中定义 Polaris 语义变量,并通过 Tailwind v4 `@theme inline` 映射:
- 主文本、次级文本、弱文本;
- 主色、hover 色、柔和主色;
- 侧栏、搜索框、卡片表面;
- 玻璃边框;
- 软阴影、中等阴影;
- 圆角与模糊强度。
第一版只实现浅色值,但变量命名允许以后通过主题选择器覆盖。
### 8.4 动效与无障碍
- hover、focus 和折叠过渡约为 180ms;
- 不使用夸张缩放或弹跳;
- 尊重 `prefers-reduced-motion`;
- 所有图标按钮有可访问名称;
- 键盘焦点清晰;
- 搜索输入与结果使用合理的 combobox/listbox 语义;
- 禁用分类项使用 `aria-disabled` 并提供“分类页将在下一阶段提供”的轻量提示。
## 9. 首页交互
### 9.1 时钟与问候
问候规则:
```text
05:00 - 11:59 早上好
12:00 - 13:59 中午好
14:00 - 17:59 下午好
18:00 - 23:59 晚上好
00:00 - 04:59 夜深了
```
客户端按分钟更新;跨午夜后日期、星期和问候语同步变化。
### 9.2 Provider 状态
- 初始值来自 `settings.search.defaultProvider`;
- 用户切换后保存到 `localStorage`;
- 无效值或存储不可用时回退到设置默认值;
- 外部 Provider 与 Local 模式严格分离。
### 9.3 外部搜索
Google、Bing、GitHub:
- 输入先 `trim()`;
- 空内容不提交;
- 使用 `URLSearchParams` 编码;
- 在当前标签页跳转;
- 不显示 Local 结果下拉。
### 9.4 Local 搜索
匹配字段:
- 链接标题;
- 关键词;
- 描述;
- 分类名称;
- 分类 ID。
优先级:
```text
标题完全匹配
标题前缀或包含
关键词匹配
描述匹配
分类匹配
```
同分时按链接 `sort` 和标题稳定排序,最多返回 8 条。
交互:
- 默认选中第一项;
- `↑` / `↓` 移动;
- `Enter` 打开选中项;
- 鼠标点击直接打开;
- 结果在新标签页打开;
- `Esc` 清空输入并关闭面板;
- 无结果显示“没有匹配的网站”。
### 9.5 快捷键
- 首页加载后自动聚焦搜索框;
- Windows/Linux:`Ctrl+K`;
- macOS:`⌘K`;
- 快捷键阻止浏览器默认行为后聚焦;
- 监听器只存在于首页生命周期内。
### 9.6 侧栏
- 折叠状态保存到 `localStorage`;
- 存储不可用或值损坏时回退到设置默认值;
- hydration 后同步持久化值;
- 同步完成前禁用布局过渡,减少闪动;
- 首页项为当前选中态;
- 分类项本次显示但禁用,不进入 404。
### 9.7 常用链接
- 对 `pinned` 链接按 `pinnedSort` 排序取前 8 条;
- `pinned: true` 时必须提供唯一的 `pinnedSort`;
- 使用真实 `<a>`;
- `target="_blank"`;
- `rel="noopener noreferrer"`;
- 卡片仅显示图标与名称。
## 10. 图标策略
运行时优先级:
```text
自定义本地图标
明确映射的 Simple Icons
favicon
标题首字符文字占位
```
当前 20 条数据尽量命中显式 Simple Icons 映射,避免正常页面依赖远程 favicon。远程图标失败时不得显示破图。
侧栏分类图标使用 lucide-react,不参与站点图标 fallback。
## 11. 状态与故障处理
- 静态 JSON 无 Loading UI;
- 数据结构错误由测试和构建阶段阻止;
- `localStorage` 读写使用容错封装;
- favicon 失败显示文字占位;
- 空搜索不跳转;
- Local 无结果显示轻量空状态;
- 非法 Provider 回退到配置默认值;
- Server 初始值与 Client 首次渲染保持一致,避免 hydration error。
## 12. 注释规范
必须为以下非显然逻辑添加简洁中文注释,解释“为什么”:
- Local 搜索权重及稳定排序;
- Server/Client 边界选择;
- 图标 fallback 顺序;
- hydration 后同步 `localStorage`;
- 分类导航临时禁用;
- 数据校验中的跨分类引用规则。
普通 JSX、显然的变量赋值和框架惯用代码不添加复述式注释。
## 13. 测试设计
### 13.1 纯函数
- 问候语全部时间边界;
- 日期格式;
- 搜索 URL 与特殊字符编码;
- Local 搜索字段、权重、排序和数量上限;
- 图标引用解析;
- 数据结构及交叉引用。
### 13.2 组件交互
- 自动聚焦;
- Provider 严格分离;
- Provider 持久化与非法值回退;
- Local 键盘导航、Enter、Esc、点击和无结果;
- `Ctrl+K` / `⌘K`;
- 侧栏折叠、恢复和异常存储回退;
- 新标签页链接安全属性;
- 分类项禁用状态。
## 14. 验证与视觉验收
完成后必须运行:
```text
pnpm lint
pnpm typecheck
pnpm test
pnpm build
```
随后实际启动页面并检查:
- `1440 × 900`;
- `1920 × 1080`;
- `1024 × 768`。
检查项:
- 无横向溢出;
- 背景渐变层次和文本对比度;
- 玻璃透明度、边框与阴影一致;
- 侧栏展开/折叠比例;
- 搜索聚焦与 Provider 交互;
- Local 结果定位和键盘交互;
- 卡片排列、hover 和 focus;
- `prefers-reduced-motion`;
- 控制台无 hydration error 或运行时错误。
设计图用于构图和氛围参照,不要求像素级复制图片背景。
## 15. 完成定义
只有以下条件全部满足,`feat/polaris-homepage` 才完成:
1. 推荐目录和工程配置建立;
2. 文档及资产迁移完成,内部链接有效;
3. 完整可交互首页实现;
4. 自动化检查全部通过;
5. 真实页面完成三个尺寸的视觉检查;
6. 实际结果、视觉观察和已知偏差如实报告;
7. 未提前实现正式分类页;
8. 完成后再执行提交操作。
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,433 @@
# Polaris 项目说明文档
> 关联文档:
> [Polaris 首页设计文档](./Polaris%20首页设计文档.md) —— 首页布局、搜索交互、视觉规范
> [Polaris 分类页设计文档](./Polaris%20分类页设计文档.md) —— 分类页布局、卡片、状态设计
>
> 配套资产:[占位 Logo](../../public/brand/logo.svg)(后续替换)、[Polaris 页面设计图](../assets/Polaris页面设计图.png)
## 1. 项目名称
**Polaris**
Polaris 原意为“北极星”,具有方向、导航、定位、指引的含义。该名称与个人导航站的产品定位非常契合。
本项目的核心目标是打造一个轻量、安静、高效、可长期维护的个人导航站,用于集中管理和访问常用网站、分类资源与个人入口。
---
## 2. 项目定位
Polaris 不是后台管理系统,也不是信息密集型 Dashboard。
> 一个具有个人审美、可自定义背景、可快速搜索和访问常用链接的浏览器起始页。
产品形态是**动态可配置的个人导航站**:第一版可以静态部署,数据写死在本地配置中,但页面具有动态交互(实时时钟、搜索、侧边栏折叠、快捷键等),后续将本地数据源替换为 Go 后端 API,不需要推翻前端整体结构。
---
## 3. 项目目标
第一阶段目标:
1. 提供一个美观的个人导航首页;
2. 支持快速访问常用链接;
3. 支持左侧分类导航与分类页;
4. 支持搜索框(Google / Bing / GitHub / 本地);
5. 支持当前时间与日期展示;
6. 支持左侧边栏折叠(状态持久化);
7. 支持背景自定义的设计预留;
8. 数据第一版写死在本地 JSON 数据配置中;
9. 代码结构预留未来接入 Go 后端接口的能力。
后续阶段目标:
1. 从 Go 后端 API 获取链接、分类、设置等数据;
2. 使用数据库持久化配置;
3. 支持后台或隐藏入口管理链接;
4. 支持用户自定义背景;
5. 支持主题色、深色模式、导入导出、移动端适配等个性化能力。
---
## 4. 已确认的关键决策
以下决策已于 2026-08-21 确认,作为实施基线:
| # | 决策项 | 结论 |
| --- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| 1 | 技术栈 | 以本文档为准:Next.js + TypeScript + Tailwind CSS + shadcn/ui + lucide-react(早期 README 中 Astro 设想作废) |
| 2 | 渲染策略 | 静态导出(`output: 'export'`),时钟/搜索/侧边栏折叠为 client 岛屿 |
| 3 | 包管理器 | pnpm |
| 4 | 项目位置 | `ts/web/nextjs/polaris/` |
| 5 | Logo | 现有 `logo.svg` 仅作占位,后续替换,不投入设计精力 |
| 6 | 数据载体 | 本地 JSON 数据文件(`links.json` / `categories.json` / `settings.json`),结构以 Vitest 校验测试兜底 |
| 7 | 第一版数据范围 | 「开发工具」15 条 + 首页常用链接 8 条(跨分类条目归入真实分类,见首页设计文档 8.1);设计资源、学习资料、生活服务三个分类展示空状态 |
| 8 | 默认背景 | CSS 低饱和青蓝渐变,不依赖图片资产;`settings.background.type` 预留 `image` |
| 9 | 深色模式 | 第一版不做;CSS 变量按双主题友好组织,后续以 `[data-theme="dark"]` 覆盖 |
| 10 | 移动端 | 第一版不适配,仅桌面端 |
| 11 | 快捷键 | Ctrl+K(macOS ⌘K)聚焦搜索框,仅首页生效;提示按平台检测显示 |
| 12 | 图标方案 | 四级 fallback:自定义图标 → Simple Icons / 本地图标库 → favicon 自动抓取 → 文字占位(见 6.4) |
| 13 | 后端数据库 | PostgreSQL(后续阶段);表结构不锁死 PG 特有语法(JSON 存通用 TEXT) |
| 14 | 搜索行为 | 按模式严格分离:选中哪个引擎就只用哪个引擎搜索(回车直跳);仅 Local 模式显示本地结果下拉 |
---
## 5. 技术选型
### 5.1 前端技术栈
```text
Next.js(App Router)
TypeScript
Tailwind CSS
shadcn/ui
lucide-react
simple-icons
```
说明:
1. **Next.js**:适合从静态页面起步,后续接入动态接口;
2. **TypeScript**:适合维护链接、分类、配置等结构化数据;
3. **Tailwind CSS**:适合快速实现玻璃感、响应式、主题变量;
4. **shadcn/ui**:用于基础组件,例如 Button、Input、Dialog、Command;
5. **lucide-react**:用于侧边栏和操作图标;
6. **simple-icons**:品牌图标库(npm 包,按 slug 引用)。
测试:Vitest + Testing Library,重点覆盖纯函数(问候语分段、本地搜索匹配排序、icon 解析、搜索 URL 模板)与核心交互组件。
### 5.2 渲染策略
采用静态导出:
```ts
// next.config.ts
const nextConfig = { output: "export" }
```
- 首页与全部分类页在构建时从本地数据生成(`generateStaticParams`);
- 时钟、搜索、侧边栏折叠等交互以 client 组件实现;
- 产物为纯静态文件,可部署到任意静态服务器;
- 后续接 Go 后端时,改造 service 层为 fetch,数据页面改为客户端获取。
### 5.3 数据访问层
**页面组件不直接依赖数据文件,一律通过 service 层获取数据。**
第一版:
```ts
import links from "@/data/links.json"
export function getLinks(): NavLink[] {
return links
}
```
后续接 Go 后端时,只需要修改 service 层:
```ts
export async function getLinks() {
const res = await fetch("/api/links")
if (!res.ok) {
throw new Error("Failed to fetch links")
}
return res.json()
}
```
这样页面组件不用大规模重构。
JSON 缺少编译期类型检查,用 **Vitest 数据校验测试**兜底:校验 `data/*.json` 满足 `Category[]` / `NavLink[]` / `AppSettings` 结构(必填字段齐全、类型正确、categoryId 存在于分类中),字段拼错或漏填在测试阶段即报错。
### 5.4 后端技术栈(后续阶段)
后端由 Go 实现,数据库 PostgreSQL。
候选组合:
```text
Go + Chi + sqlc —— 结构清晰、SQL 可控(推荐)
Go + Gin + GORM —— 开发更快、上手成本低
```
---
## 6. 数据结构
### 6.1 分类数据
```ts
{
id: "dev",
title: "开发工具",
description: "高效的开发工具和资源集合,提升你的开发体验和效率。",
icon: "code",
sort: 20
}
```
字段说明:
```text
id: 分类唯一标识
title: 分类名称
description: 分类描述(分类页标题下方展示)
icon: 图标名称(侧边栏,lucide 图标名)
sort: 排序值
```
### 6.2 链接数据
```ts
{
id: "github",
title: "GitHub",
description: "全球最大的代码托管平台",
url: "https://github.com",
icon: "si:github",
categoryId: "dev",
pinned: true,
pinnedSort: 10,
keywords: ["代码", "仓库", "开源", "git"],
sort: 10
}
```
字段说明:
```text
id: 链接唯一标识
title: 链接名称
description: 简短描述(分类页卡片展示,8-16 个中文字)
url: 链接地址
icon: 图标引用,格式见 6.4
categoryId: 所属分类
pinned: 是否展示在首页常用链接区域
pinnedSort: 首页常用链接顺序(仅 pinned 为 true 时必填)
keywords: 本地搜索关键词
sort: 分类内排序值
```
### 6.3 设置数据
```ts
{
siteName: "Polaris",
theme: "light-glass",
search: {
defaultProvider: "google",
enabledProviders: ["google", "bing", "github", "local"]
},
background: {
type: "gradient", // "gradient" | "image"
url: null, // type 为 image 时生效
blur: 8,
overlay: 0.32
},
sidebar: {
defaultCollapsed: false
}
}
```
### 6.4 统一图标方案
链接卡片与搜索结果的图标按以下顺序解析,逐级 fallback:
```text
1. 自定义图标 icon: "local:xxx" → public/icons/xxx.svg
2. Simple Icons icon: "si:github" → simple-icons npm 包按 slug 渲染品牌图标
3. favicon 抓取 icon: "url:https://..." → 远程图标;icon 缺省时按链接 url 域名自动抓取
4. 文字占位 以上全部失败 → 标题首字符 + 圆角玻璃底兜底
```
侧边栏分类导航图标使用 lucide-react(如 `home` / `code` / `palette`),不参与此 fallback 链。
---
## 7. 未来 Go 后端 API 设计
后续接入 Go 后端时,推荐 API:
```text
GET /api/categories
GET /api/links
GET /api/links?categoryId=dev
GET /api/settings
```
后续管理能力:
```text
POST /api/links
PUT /api/links/:id
DELETE /api/links/:id
POST /api/categories
PUT /api/categories/:id
DELETE /api/categories/:id
PUT /api/settings
```
背景相关:
```text
POST /api/backgrounds
GET /api/backgrounds
PUT /api/settings/background
```
---
## 8. 数据库表设计草案
> 草案使用跨数据库通用语法;JSON 以 TEXT 存储。PostgreSQL 下可将 `TEXT`(存 JSON)字段替换为 `JSONB` 以获得索引与查询能力。
### 8.1 categories
```sql
CREATE TABLE categories (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
icon TEXT NOT NULL,
sort INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
```
### 8.2 links
```sql
CREATE TABLE links (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
url TEXT NOT NULL,
icon TEXT,
category_id TEXT NOT NULL,
pinned BOOLEAN NOT NULL DEFAULT FALSE,
pinned_sort INTEGER,
keywords TEXT NOT NULL DEFAULT '[]', -- JSON 数组字符串
sort INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_links_category
FOREIGN KEY (category_id)
REFERENCES categories(id)
);
```
### 8.3 settings
```sql
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL, -- JSON 字符串
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
```
---
## 9. 开发阶段
### 第一阶段:前端静态版(当前阶段)
目标:完成首页与分类页基础体验。
```text
页面背景(CSS 渐变)与背景遮罩
左侧边栏 + 折叠(localStorage 持久化)+ 导航选中态
时间 / 日期 / 问候语
搜索框(Google / Bing / GitHub / 本地搜索)+ Ctrl+K 快捷键
常用链接(pinned,8 个)
分类页路由 + 卡片网格 + 空状态
桌面端响应式
```
数据范围:开发工具 15 个链接 + 首页 8 个常用链接(跨分类条目归入真实分类:ChatGPT → AI 工具,Gmail / Notion → 效率工具,YouTube / Bilibili → 娱乐媒体);设计资源、学习资料、生活服务三个分类展示空状态。
### 第二阶段:数据补全与体验完善
```text
补全其余 6 个分类的链接数据
深色玻璃主题
移动端 / 平板适配
```
### 第三阶段:数据访问层抽象
```text
封装 links / categories / settings service
统一 TypeScript 类型
统一错误处理与 loading 状态
```
此阶段完成后,页面组件不直接关心数据来自本地配置还是 API。
### 第四阶段:Go 后端 API
```text
Go API 服务
数据库连接(PostgreSQL)
links / categories / settings 查询接口
基础 CORS 配置
Docker 部署预留
```
### 第五阶段:管理能力
```text
新增 / 编辑 / 删除链接
调整排序、修改分类
修改背景、主题
导入导出配置
```
管理能力不进入首页主视觉,放到独立管理页或隐藏入口。
---
## 10. 项目目录结构
```text
polaris/
public/
icons/ # 自定义 SVG 图标(icon: "local:xxx" 引用)
logo.svg
src/
app/
layout.tsx # 背景层 + 字体
page.tsx # 首页
categories/
[categoryId]/
page.tsx # 分类页(generateStaticParams 生成)
globals.css # Tailwind + CSS 变量(浅色值,深色预留覆盖位)
components/
layout/ # AppShell / Sidebar / SidebarNavItem
home/ # ClockGreeting / SearchBox / SearchResults / QuickLinks
category/ # CategoryHeader / ToolGrid / ToolCard
common/ # SiteIcon(四级 fallback)/ GlassPanel
data/ # links.json / categories.json / settings.json
services/ # links.ts / categories.ts / settings.ts / search.ts
types/ # navigation.ts / settings.ts / search.ts
lib/ # cn.ts / greeting.ts / local-search.ts / search-providers.ts / icon.ts(纯函数)
next.config.ts # output: 'export'
vitest.config.ts
```
---
## 11. 最终目标
> 打开 Polaris 后,用户可以立刻搜索、立刻访问常用网站,也可以通过侧边栏进入分类页面。页面具有个人氛围,但保持干净、高效、克制。第一版数据本地配置,后续平滑切换到 Go 后端。
@@ -0,0 +1,780 @@
# Polaris 首页设计文档
> 关联文档:
> [Polaris 项目说明文档](./Polaris%20项目说明文档.md) —— 项目定位、技术选型、数据结构、开发阶段
> [Polaris 分类页设计文档](./Polaris%20分类页设计文档.md) —— 分类页布局与交互
>
> 配套设计图:[Polaris 页面设计图](../assets/Polaris页面设计图.png)(示意图,视觉细节以本文档文字规范为准)
## 1. 页面定位
Polaris 首页是一个**浏览器起始页**,不是后台管理系统,也不是信息密集型 Dashboard。
> 一个具有个人审美、可自定义背景、可快速搜索和访问常用链接的浏览器起始页。
首页应给人的感觉是:
```text
私人入口
浏览器首页
轻量工具启动页
个人网址导航
安静、高效、有氛围感
```
而不是:
```text
后台系统
资源管理平台
复杂书签管理器
信息看板
数据仪表盘
```
---
## 2. 整体视觉风格
首页推荐采用:
> 轻量玻璃感 + 极简浏览器起始页 + 柔和个人空间感
设计关键词:
```text
极简
半透明
玻璃感
柔和阴影
大留白
低饱和
可换背景
个人化
安静
高级感
```
整体视觉应避免过重的管理系统风格。
首页不应常驻展示以下元素:
```text
设置按钮
编辑按钮
管理按钮
新增按钮
统计数据
用户头像
访问次数
站点域名副标题
复杂分类卡片
```
这些能力在后续阶段通过隐藏入口、独立配置页或快捷键面板实现。
---
## 3. 首页整体布局
首页采用左右结构:
```text
┌──────────────────┬────────────────────────────────────────────┐
│ 左侧边栏 │ 主内容区域 │
│ │ │
│ Logo + 折叠按钮 │ 问候语 + 当前时间 │
│ 首页 │ 日期 / 星期 │
│ AI 工具 │ 搜索框 │
│ 开发工具 │ 常用链接 │
│ 设计资源 │ │
│ 学习资料 │ │
│ 效率工具 │ │
│ 娱乐媒体 │ │
│ 生活服务 │ │
└──────────────────┴────────────────────────────────────────────┘
```
布局原则:
1. 左侧边栏负责分类导航;
2. 主内容区域只展示最高频内容;
3. 首页不展示分类卡片;
4. 首页不展示编辑、设置、管理类按钮;
5. 常用链接只做轻量展示。
主内容整体居中,视觉重心位于页面中上方。
推荐信息层级:
```text
1. 当前时间 / 问候语
2. 搜索框
3. 常用链接
4. 左侧分类导航
5. 背景氛围
```
---
## 4. 左侧边栏设计
### 4.1 边栏内容
左侧边栏从上到下依次展示:
```text
Logo
折叠按钮
首页
AI 工具
开发工具
设计资源
学习资料
效率工具
娱乐媒体
生活服务
```
「首页」指向 `/`,各分类指向 `/categories/{categoryId}`。当前路由对应的导航项处于选中态,选中样式见[分类页设计文档](./Polaris%20分类页设计文档.md) 6.2 节。
暂时不展示:
```text
设置
关于
编辑
管理
新增分类
账户信息
```
### 4.2 折叠按钮位置
折叠按钮放在侧边栏顶部右侧,与 Logo 位于同一行。
示意:
```text
┌──────────────────────┐
│ Logo ≪ │
└──────────────────────┘
```
不采用侧边栏中间悬浮按钮。
原因:
1. 中间悬浮按钮视觉上突兀;
2. 容易产生后台管理系统的感觉;
3. 会破坏页面边界的干净感;
4. 顶部右侧更符合用户对全局控制按钮的认知。
### 4.3 边栏宽度
展开状态:
```text
推荐宽度:240px
可选范围:220px - 260px
```
折叠状态:
```text
推荐宽度:72px
可选范围:68px - 80px
```
展开时显示图标和文字。
折叠时只显示图标。
折叠状态持久化到 `localStorage`,刷新后保持。
### 4.4 边栏视觉样式
推荐样式:
```css
background: rgba(255, 255, 255, 0.68);
backdrop-filter: blur(24px);
border: 1px solid rgba(255, 255, 255, 0.45);
box-shadow: 0 24px 80px rgba(15, 23, 42, 0.1);
border-radius: 24px;
```
边栏与页面边缘保持一定距离,形成悬浮玻璃面板感。
推荐边距:
```text
left: 20px;
top: 20px;
bottom: 20px;
```
---
## 5. 时间与问候语
首页顶部展示问候语和当前时间。
示例:
```text
晚上好,21:56
2026 年 8 月 20 日 星期四
```
问候语规则:
```text
05:00 - 11:59 早上好
12:00 - 13:59 中午好
14:00 - 17:59 下午好
18:00 - 23:59 晚上好
00:00 - 04:59 夜深了
```
推荐样式:
```css
font-size: 56px;
font-weight: 700;
color: #0f172a;
```
日期样式:
```css
font-size: 16px;
color: #64748b;
```
时间为客户端实时时钟,每分钟更新。
---
## 6. 搜索框设计
### 6.1 搜索框定位
搜索框是 Polaris 首页最核心的交互组件。
搜索框位于时间与日期下方,居中展示,首页加载后**自动聚焦**。
### 6.2 搜索范围
首页搜索框支持以下四种搜索方式:
```text
Google 搜索
Bing 搜索
GitHub 搜索
本地搜索
```
其中:
1. **Google 搜索**:使用 Google 搜索引擎搜索输入内容;
2. **Bing 搜索**:使用 Bing 搜索引擎搜索输入内容;
3. **GitHub 搜索**:在 GitHub 中搜索输入内容;
4. **本地搜索**:搜索 Polaris 本地配置中的链接、分类和名称。
第一版不支持:
```text
直接输入 URL 跳转
自定义搜索引擎
多搜索引擎聚合结果
AI 搜索
站外爬取搜索
全文索引搜索
```
这些能力作为后续扩展。
### 6.3 搜索框尺寸
桌面端推荐:
```text
width: 760px;
height: 68px;
```
可选范围:
```text
宽度:720px - 860px
高度:64px - 72px
```
> 移动端尺寸第一版不定义,移动端整体适配为后续阶段工作。
### 6.4 搜索框视觉样式
推荐样式:
```css
background: rgba(255, 255, 255, 0.72);
backdrop-filter: blur(22px);
border: 1px solid rgba(255, 255, 255, 0.65);
border-radius: 24px;
box-shadow: 0 20px 60px rgba(15, 23, 42, 0.12);
```
placeholder 统一为:
```text
搜索网站、书签或关键词...
```
右侧展示快捷键提示,**按平台检测显示**:
```text
macOS:⌘K
Windows / Linux:Ctrl K
```
快捷键行为:按下后聚焦搜索框(详见 7.4)。
### 6.5 搜索模式切换
搜索模式可以通过以下方式切换:
```text
搜索框左侧下拉选择
快捷键切换
命令面板中选择
默认使用上一次选择的搜索方式
```
第一版采用简单方案:
```text
搜索框左侧或右侧提供当前搜索模式标识
点击后可切换 Google / Bing / GitHub / 本地
```
### 6.6 本地搜索范围
本地搜索的数据来自本地数据配置。
搜索范围包括:
```text
链接标题
链接关键词
链接描述
分类名称
分类 ID
```
链接数据预留 `keywords` 字段,完整数据结构见[项目说明文档](./Polaris%20项目说明文档.md)。
---
## 7. 搜索功能设计
### 7.1 搜索 Provider
支持四个 provider:
```ts
export type SearchProvider = "google" | "bing" | "github" | "local"
```
### 7.2 搜索行为
Google:
```text
https://www.google.com/search?q={keyword}
```
Bing:
```text
https://www.bing.com/search?q={keyword}
```
GitHub:
```text
https://github.com/search?q={keyword}
```
Local:
```text
在本地 links / categories 数据中搜索
```
### 7.3 本地搜索匹配规则
本地搜索匹配字段:
```text
title
keywords
categoryId
category title
```
推荐匹配逻辑(权重从高到低):
```text
完全匹配优先
标题匹配其次
关键词匹配再次
分类匹配最后
```
搜索结果展示:
```text
图标
标题
所属分类
```
搜索行为**按模式严格分离**:选择使用哪个搜索引擎,就只用它搜索。
```text
Google / Bing / GitHub 模式:回车直接跳转对应引擎搜索,不显示任何下拉
Local 模式:输入时实时显示本地结果下拉(见 7.5)
```
Local 模式回车行为:
```text
如果只有一个高匹配本地结果,回车打开该链接
如果有多个结果,↑ / ↓ 选择后回车打开选中项(默认选中第一项)
如果没有结果,展示空状态
```
### 7.4 快捷键
```text
Ctrl K(macOS 为 ⌘K):聚焦搜索框
Esc:清空输入 / 关闭结果列表
```
快捷键仅在首页生效,分类页不响应。
---
### 7.5 结果下拉面板(仅 Local 模式)
面板出现在搜索框正下方,与搜索框同宽,间距 8px,视觉上与搜索框衔接为一体。
面板样式:
```css
background: rgba(255, 255, 255, 0.72);
backdrop-filter: blur(22px);
border: 1px solid rgba(255, 255, 255, 0.65);
border-radius: 20px;
box-shadow: 0 20px 60px rgba(15, 23, 42, 0.12);
```
列表项内容:
```text
图标(遵循统一图标方案)
标题
所属分类
```
交互:
```text
↑ / ↓ 在结果间移动选中项
回车打开选中项(新标签页)
Esc 关闭面板
鼠标点击直接打开
选中项背景 rgba(59, 130, 246, 0.10)
```
结果数量上限 8 条;无匹配时面板内展示空状态「没有匹配的网站」。
---
## 8. 常用链接区域
### 8.1 区域定位
常用链接用于展示用户最高频访问的网站。
首页不展示分类卡片,以保持页面干净。
第一版展示 8 个常用链接:
```text
GitHub
ChatGPT
Gmail
Notion
Figma
YouTube
Vercel
Bilibili
```
实际项目中根据个人习惯调整。
数据来源:`pinned: true` 的链接,按 `pinnedSort` 排序取前 8 个;超出 8 个时按 4 列网格换行展示。`sort` 仅用于分类内排序,避免首页顺序与分类页顺序相互约束。
第一版数据构成(8 条 pinned 分布于各分类,归入真实分类):
```text
GitHub → 开发工具(dev)
Vercel → 开发工具(dev)
Figma → 开发工具(dev)
ChatGPT → AI 工具(ai)
Gmail → 效率工具(efficiency)
Notion → 效率工具(efficiency)
YouTube → 娱乐媒体(media)
Bilibili → 娱乐媒体(media)
```
其中 ChatGPT、Gmail、Notion、YouTube、Bilibili 5 条是为首页常用链接补入的跨分类条目。受此影响,AI 工具、效率工具、娱乐媒体三个分类页会展示少量链接而非空状态;纯空状态分类为设计资源、学习资料、生活服务。
### 8.2 链接卡片内容
常用链接卡片只展示:
```text
图标
名称
```
不展示:
```text
域名
描述
编辑按钮
更多按钮
拖拽按钮
访问统计
```
图标来源遵循统一图标方案(见[项目说明文档](./Polaris%20项目说明文档.md)):自定义图标 → Simple Icons / 本地图标库 → favicon 自动抓取 → 文字占位兜底。
### 8.3 链接卡片样式
推荐样式:
```css
background: rgba(255, 255, 255, 0.62);
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.56);
border-radius: 20px;
box-shadow: 0 14px 40px rgba(15, 23, 42, 0.1);
```
推荐尺寸:
```text
width: 200px;
height: 76px;
```
推荐布局:
```text
两行四列
```
示意:
```text
┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐
│ GitHub │ │ChatGPT │ │ Gmail │ │ Notion │
└────────┘ └────────┘ └────────┘ └────────┘
┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐
│ Figma │ │YouTube │ │ Vercel │ │Bilibili│
└────────┘ └────────┘ └────────┘ └────────┘
```
Hover 效果:
```css
transform: translateY(-2px);
background: rgba(255, 255, 255, 0.78);
box-shadow: 0 18px 50px rgba(15, 23, 42, 0.14);
transition: all 180ms ease;
```
点击卡片在新标签页打开链接。
---
## 9. 背景设计
### 9.1 背景类型
Polaris 支持背景自定义的设计预留。
**第一版默认背景为 CSS 低饱和青蓝渐变**(模拟晨雾 / 天空氛围),不依赖图片资产。
后续支持:
```text
静态图片背景
渐变背景
纯色背景
本地上传背景
远程图片背景
深色背景
```
图片背景建议:
```text
湖面
雪山
晨雾
天空
抽象渐变
低对比度风景图
```
背景类型通过 `settings.background.type`(`gradient` / `image`)配置。
### 9.2 背景遮罩
由于用户未来可能更换任意背景,因此需要增加背景遮罩层,保证内容可读。
浅色主题:
```css
background-overlay: rgba(255, 255, 255, 0.32);
backdrop-filter: blur(8px);
```
深色主题(后续阶段预留):
```css
background-overlay: rgba(15, 23, 42, 0.42);
backdrop-filter: blur(8px);
```
---
## 10. 配色系统
默认采用浅色玻璃主题。
推荐 CSS 变量:
```css
:root {
--color-text-primary: #0f172a;
--color-text-secondary: #64748b;
--color-text-muted: #94a3b8;
--color-primary: #3b82f6;
--color-primary-hover: #2563eb;
--color-primary-soft: rgba(59, 130, 246, 0.1);
--color-surface-sidebar: rgba(255, 255, 255, 0.68);
--color-surface-card: rgba(255, 255, 255, 0.62);
--color-surface-search: rgba(255, 255, 255, 0.72);
--color-border-light: rgba(255, 255, 255, 0.56);
--color-border-subtle: rgba(15, 23, 42, 0.08);
--shadow-soft: 0 14px 40px rgba(15, 23, 42, 0.1);
--shadow-medium: 0 20px 60px rgba(15, 23, 42, 0.12);
}
```
变量按双主题友好方式组织:第一版只实现浅色值;深色玻璃主题为后续阶段,届时以 `[data-theme="dark"]` 覆盖同名变量,组件无需改动。
---
## 11. 字体建议
推荐字体栈:
```css
font-family:
Inter,
"SF Pro Display",
"SF Pro Text",
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
"PingFang SC",
"Hiragino Sans GB",
"Microsoft YaHei",
sans-serif;
```
字体原则:
```text
标题清晰
正文轻盈
导航可读
不要使用过度装饰性字体
```
---
## 12. 首页线框
```text
┌──────────────────────┬────────────────────────────────────────────────────┐
│ │ │
│ LOGO ≪ │ │
│ │ │
│ ⌂ 首页 │ │
│ ✦ AI 工具 │ 晚上好,21:56 │
│ <> 开发工具 │ 2026 年 8 月 20 日 星期四 │
│ ◌ 设计资源 │ │
│ 📖 学习资料 │ ┌────────────────────────────────────┐ │
│ ⚡ 效率工具 │ │ 🔍 搜索网站、书签或关键词... Ctrl K │ │
│ ▶ 娱乐媒体 │ └────────────────────────────────────┘ │
│ ☕ 生活服务 │ │
│ │ 常用链接 │
│ │ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │
│ │ │ GitHub │ │ChatGPT │ │ Gmail │ │ Notion │ │
│ │ └────────┘ └────────┘ └────────┘ └────────┘ │
│ │ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │
│ │ │ Figma │ │YouTube │ │ Vercel │ │Bilibili│ │
│ │ └────────┘ └────────┘ └────────┘ └────────┘ │
│ │ │
└──────────────────────┴────────────────────────────────────────────────────┘
```
---
## 13. 设计原则总结
首页遵循以下原则:
```text
首页只放最高频内容
搜索框是核心入口
搜索范围保持克制
常用链接轻量展示
分类入口放在左侧边栏
首页不展示分类卡片
首页不展示编辑和设置按钮
背景可换,但内容必须始终清晰
玻璃感要轻,不要炫技
交互动效要柔和,不要夸张
整体像浏览器起始页,不像后台系统
第一版数据本地配置,后续平滑切换到 Go 后端
第一版仅实现浅色主题、仅适配桌面端
```
最终目标:
> 打开 Polaris 后,用户可以立刻搜索、立刻访问常用网站,也可以通过侧边栏进入分类页面。页面具有个人氛围,但保持干净、高效、克制。
File diff suppressed because it is too large Load Diff
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig, globalIgnores } from "eslint/config"
import nextVitals from "eslint-config-next/core-web-vitals"
import nextTypeScript from "eslint-config-next/typescript"
export default defineConfig([
...nextVitals,
...nextTypeScript,
globalIgnores([".next/**", "out/**", "coverage/**", "next-env.d.ts"]),
])
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/dev/types/root-params.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+11
View File
@@ -0,0 +1,11 @@
import type { NextConfig } from "next"
const nextConfig: NextConfig = {
output: "export",
trailingSlash: true,
images: {
unoptimized: true,
},
}
export default nextConfig
+52
View File
@@ -0,0 +1,52 @@
{
"name": "polaris",
"version": "0.1.0",
"private": true,
"type": "module",
"packageManager": "pnpm@11.24.0",
"scripts": {
"dev": "next dev",
"build": "next build",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"format": "prettier --write .",
"format:check": "prettier --check ."
},
"engines": {
"node": ">=22.22.2"
},
"dependencies": {
"@radix-ui/react-dropdown-menu": "^2.1.24",
"@radix-ui/react-slot": "^1.3.3",
"@radix-ui/react-tooltip": "^1.2.16",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.34.0",
"next": "^16.3.3",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"simple-icons": "^16.28.0",
"tailwind-merge": "^3.6.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.3.3",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.6",
"@types/node": "^26.3.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.5",
"@vitejs/plugin-react": "^6.1.0",
"eslint": "^9.39.5",
"eslint-config-next": "^16.3.3",
"jsdom": "^30.0.1",
"postcss": "^8.5.26",
"prettier": "^3.9.6",
"prettier-plugin-tailwindcss": "^0.8.1",
"tailwindcss": "^4.3.3",
"typescript": "^5.9.3",
"vitest": "^4.1.11"
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,5 @@
packages:
- "."
allowBuilds:
unrs-resolver: true
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
}
export default config
@@ -0,0 +1,9 @@
/** @type {import("prettier").Config} */
const config = {
plugins: ["prettier-plugin-tailwindcss"],
semi: false,
singleQuote: false,
trailingComma: "all",
}
export default config
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 MiB

+111
View File
@@ -0,0 +1,111 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="100%" height="100%">
<!-- 背景透明 -->
<defs>
<!-- 渐变:高亮发光青蓝 到 科技蓝 -->
<linearGradient id="magicCyan" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#00F2FE" />
<stop offset="100%" stop-color="#3B82F6" />
</linearGradient>
<!-- 渐变:深邃蓝 (指针反向切面与暗部) -->
<linearGradient id="magicDark" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#1E293B" />
<stop offset="100%" stop-color="#0F172A" />
</linearGradient>
<!-- 霓虹发光 Glow 滤镜 -->
<filter id="magicGlow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="4" result="blur" />
<feComposite in="SourceGraphic" in2="blur" operator="over" />
</filter>
</defs>
<!-- ==================== 丰富高精度的极客魔法阵 (Geek Magic Array) ==================== -->
<!-- 1. 最外层:外边界细环与四角法术引线节点 -->
<circle cx="256" cy="256" r="242" fill="none" stroke="#00F2FE" stroke-width="1.5" stroke-opacity="0.5" />
<circle cx="256" cy="256" r="236" fill="none" stroke="#3B82F6" stroke-width="2.5" stroke-opacity="0.8" />
<!-- 外圈四极极客指示顶点 -->
<path d="M 256 8 L 266 26 L 246 26 Z" fill="#00F2FE" filter="url(#magicGlow)" />
<path d="M 256 504 L 266 486 L 246 486 Z" fill="#2563EB" opacity="0.6" />
<path d="M 8 256 L 26 246 L 26 266 Z" fill="#2563EB" opacity="0.6" />
<path d="M 504 256 L 486 246 L 486 266 Z" fill="#2563EB" opacity="0.6" />
<!-- 2. 精密仪表与符文刻度带 (外环与内环间) -->
<circle cx="256" cy="256" r="226" fill="none" stroke="#00F2FE" stroke-width="6" stroke-dasharray="2 6" stroke-opacity="0.75" />
<circle cx="256" cy="256" r="216" fill="none" stroke="#3B82F6" stroke-width="1.5" stroke-opacity="0.4" />
<!-- 精算刻度环:6个长粗虚线的中心点完全对齐正六边形与角点的 60° 几何角 -->
<circle cx="256" cy="256" r="210" fill="none" stroke="#00F2FE" stroke-width="4.5"
stroke-dasharray="25 8 6 15.977872" stroke-dashoffset="-12.5" stroke-opacity="0.85" filter="url(#magicGlow)" />
<!-- 3. 魔法阵三重嵌套多边形结界 (正三角形 + 正六边形 + 12角星线拓扑) -->
<g fill="none" stroke="#3B82F6" stroke-width="1.5" stroke-opacity="0.5">
<!-- 正六边形 A (顶角精确落于 r=210 粗虚线长段) -->
<polygon points="256,46 437.86,151 437.86,361 256,466 74.14,361 74.14,151" stroke="#00F2FE" stroke-width="1.8" stroke-opacity="0.7" />
<!-- 正六边形 B (旋转30度) -->
<polygon points="361,74.14 466,256 361,437.86 151,437.86 46,256 151,74.14" />
<!-- 正三角形 C (向上) -->
<polygon points="256,46 437.86,361 74.14,361" stroke="#00F2FE" stroke-width="1" stroke-opacity="0.4" />
<!-- 正三角形 D (向下) -->
<polygon points="256,466 74.14,151 437.86,151" stroke="#00F2FE" stroke-width="1" stroke-opacity="0.4" />
</g>
<!-- 4. 六方位魔法法阵节点圈 (在多边形顶点上的极客元素) -->
<g fill="#0F172A" stroke="#00F2FE" stroke-width="2" opacity="0.95">
<circle cx="256" cy="46" r="10" />
<circle cx="437.86" cy="151" r="10" />
<circle cx="437.86" cy="361" r="10" />
<circle cx="256" cy="466" r="10" />
<circle cx="74.14" cy="361" r="10" />
<circle cx="74.14" cy="151" r="10" />
</g>
<!-- 节点内发光核 -->
<g fill="#00F2FE" filter="url(#magicGlow)">
<circle cx="256" cy="46" r="4" />
<circle cx="437.86" cy="151" r="4" />
<circle cx="437.86" cy="361" r="4" />
<circle cx="256" cy="466" r="4" />
<circle cx="74.14" cy="361" r="4" />
<circle cx="74.14" cy="151" r="4" />
</g>
<!-- 5. 中阶魔法阵环与细分刻度 -->
<circle cx="256" cy="256" r="186" fill="none" stroke="#00F2FE" stroke-width="2" stroke-opacity="0.8" filter="url(#magicGlow)" />
<circle cx="256" cy="256" r="176" fill="none" stroke="#2563EB" stroke-width="3" stroke-dasharray="12 6 4 6" stroke-opacity="0.6" />
<circle cx="256" cy="256" r="166" fill="none" stroke="#00F2FE" stroke-width="1" stroke-dasharray="2 4" stroke-opacity="0.5" />
<!-- 6. 魔法阵放射状流向线 (16等分星射线) -->
<g stroke="#00F2FE" stroke-width="1" stroke-opacity="0.2">
<line x1="256" y1="26" x2="256" y2="486" />
<line x1="26" y1="256" x2="486" y2="256" />
<line x1="93" y1="93" x2="419" y2="419" />
<line x1="419" y1="93" x2="93" y2="419" />
</g>
<!-- ==================== 中心:硬核极客机甲指针 (中间区域干净无符号) ==================== -->
<g transform="rotate(28, 256, 256)">
<!-- 北向主指针(立体几何亮蓝切面) -->
<path d="M 256 70 L 306 226 L 256 256 Z" fill="url(#magicCyan)" filter="url(#magicGlow)" />
<!-- 北向副切面(深蓝阴影) -->
<path d="M 256 70 L 206 226 L 256 256 Z" fill="#1D4ED8" />
<!-- 指针侧翼机甲组件 -->
<path d="M 312 236 L 332 266 L 282 256 Z" fill="#00F2FE" opacity="0.7" />
<path d="M 200 236 L 180 266 L 230 256 Z" fill="#2563EB" opacity="0.7" />
<!-- 南向反向指针(深色金属质感) -->
<path d="M 256 442 L 220 286 L 256 256 Z" fill="url(#magicDark)" />
<path d="M 256 442 L 292 286 L 256 256 Z" fill="#334155" />
</g>
<!-- 中心核心:反应堆式同心轴 -->
<circle cx="256" cy="256" r="32" fill="#0F172A" stroke="#00F2FE" stroke-width="3" />
<circle cx="256" cy="256" r="18" fill="none" stroke="#3B82F6" stroke-width="2" stroke-dasharray="6 3" />
<circle cx="256" cy="256" r="8" fill="#00F2FE" filter="url(#magicGlow)" />
<circle cx="256" cy="256" r="3" fill="#FFFFFF" />
</svg>

After

Width:  |  Height:  |  Size: 5.6 KiB

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1788674931363" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1253" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M340.5824 70.109867L102.536533 0.682667v851.217066L340.650667 643.345067V70.109867zM102.536533 851.7632l238.045867 171.6224 580.881067-340.923733V411.784533L102.536533 851.831467z" fill="#409EFF" p-id="1254"></path><path d="M409.463467 255.3856l113.732266 238.933333 138.8544 56.866134 259.413334-139.400534-506.0608-156.330666z" fill="#409EFF" p-id="1255"></path></svg>

After

Width:  |  Height:  |  Size: 703 B

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1788674911609" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1089" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M950.930286 512q0 143.433143-83.748571 257.974857t-216.283429 158.573714q-15.433143 2.852571-22.601143-4.022857t-7.168-17.115429l0-120.539429q0-55.442286-29.696-81.115429 32.548571-3.437714 58.587429-10.313143t53.686857-22.308571 46.299429-38.034286 30.281143-59.977143 11.702857-86.016q0-69.12-45.129143-117.686857 21.138286-52.004571-4.534857-116.589714-16.018286-5.12-46.299429 6.290286t-52.589714 25.161143l-21.723429 13.677714q-53.174857-14.848-109.714286-14.848t-109.714286 14.848q-9.142857-6.290286-24.283429-15.433143t-47.689143-22.016-49.152-7.68q-25.161143 64.585143-4.022857 116.589714-45.129143 48.566857-45.129143 117.686857 0 48.566857 11.702857 85.723429t29.988571 59.977143 46.006857 38.253714 53.686857 22.308571 58.587429 10.313143q-22.820571 20.553143-28.013714 58.88-11.995429 5.705143-25.746286 8.557714t-32.548571 2.852571-37.449143-12.288-31.744-35.693714q-10.825143-18.285714-27.721143-29.696t-28.306286-13.677714l-11.410286-1.682286q-11.995429 0-16.603429 2.56t-2.852571 6.582857 5.12 7.972571 7.460571 6.875429l4.022857 2.852571q12.580571 5.705143 24.868571 21.723429t17.993143 29.110857l5.705143 13.165714q7.460571 21.723429 25.161143 35.108571t38.253714 17.115429 39.716571 4.022857 31.744-1.974857l13.165714-2.267429q0 21.723429 0.292571 50.834286t0.292571 30.866286q0 10.313143-7.460571 17.115429t-22.820571 4.022857q-132.534857-44.032-216.283429-158.573714t-83.748571-257.974857q0-119.442286 58.88-220.306286t159.744-159.744 220.306286-58.88 220.306286 58.88 159.744 159.744 58.88 220.306286z" fill="#444444" p-id="1090"></path></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1788674935631" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1414" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M222.087529 515.915294c0-32.527059 5.481412-63.698824 15.179295-92.973176l-170.285177-128.90353A496.941176 496.941176 0 0 0 15.058824 515.945412c0 79.721412 18.672941 154.955294 51.802352 221.726117l170.255059-129.114353a293.767529 293.767529 0 0 1-15.028706-92.611764" fill="#FBBC05" p-id="1415"></path><path d="M521.246118 219.979294a294.460235 294.460235 0 0 1 186.337882 65.957647l147.245176-145.648941C765.108706 62.885647 650.089412 15.058824 521.246118 15.058824 321.204706 15.058824 149.293176 128.421647 66.981647 294.038588l170.405647 128.90353c39.243294-118.061176 151.100235-202.992941 283.919059-202.992942" fill="#EA4335" p-id="1416"></path><path d="M523.414588 805.315765c-133.511529 0-245.940706-84.359529-285.394823-201.728l-171.188706 128.12047C149.564235 896.301176 322.349176 1008.941176 523.444706 1008.941176c124.024471 0 242.447059-43.128471 331.38447-124.054588l-162.514823-123.090823c-45.808941 28.310588-103.574588 43.550118-168.929882 43.550117" fill="#34A853" p-id="1417"></path><path d="M1008.941176 511.216941c0-29.394824-4.638118-61.108706-11.565176-90.503529H523.354353v192.301176h272.835765c-13.613176 65.596235-50.718118 115.983059-103.875765 148.781177l162.484706 123.090823c93.394824-84.901647 154.142118-211.425882 154.142117-373.669647" fill="#4285F4" p-id="1418"></path></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1788675424920" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1788" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M468.598154 0a156.750769 156.750769 0 0 1 124.849231 62.464l90.820923 121.304615h183.689846A156.593231 156.593231 0 0 1 1024 340.598154v526.808615A156.593231 156.593231 0 0 1 867.879385 1024H156.120615A156.593231 156.593231 0 0 1 0 867.328V156.672C0 70.262154 69.868308 0.236308 156.199385 0z m399.36 265.688615H81.447385v601.639385c0 41.353846 33.398154 74.830769 74.830769 74.988308h711.68a74.988308 74.988308 0 0 0 74.752-74.988308V340.519385a74.909538 74.909538 0 0 0-74.752-74.83077zM477.971692 315.076923a241.664 241.664 0 0 1 241.664 241.664c0 47.891692-14.414769 92.317538-38.518154 129.969231l91.372308 91.372308a51.751385 51.751385 0 1 1-73.255384 73.255384l-91.372308-91.372308a240.009846 240.009846 0 0 1-129.969231 38.360616 241.664 241.664 0 1 1 0-483.249231z m0 69.001846a172.583385 172.583385 0 1 0 0 345.245539 172.583385 172.583385 0 0 0 0-345.245539z m-9.452307-302.395077H156.041846a74.909538 74.909538 0 0 0-74.673231 74.988308v27.096615h500.972308l-54.272-72.152615a74.988308 74.988308 0 0 0-59.707077-29.932308z" p-id="1789" fill="#e98f36"></path></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+652
View File
@@ -0,0 +1,652 @@
@import "tailwindcss";
@theme inline {
--color-background: var(--background);
--color-foreground: var(--text-primary);
--color-primary: var(--primary);
--color-primary-foreground: #ffffff;
--color-muted-foreground: var(--text-secondary);
--color-border: var(--border-subtle);
--radius-lg: var(--radius-card);
--radius-xl: var(--radius-panel);
}
:root {
color-scheme: light;
--background: #dcebf5;
--text-primary: #0f172a;
--text-secondary: #64748b;
--text-muted: #94a3b8;
--primary: #3b82f6;
--primary-hover: #2563eb;
--primary-soft: rgba(59, 130, 246, 0.1);
--surface-sidebar: linear-gradient(
180deg,
rgba(235, 243, 251, 0.52),
rgba(204, 220, 238, 0.34)
);
--surface-search: linear-gradient(
135deg,
rgba(240, 246, 252, 0.56),
rgba(210, 225, 241, 0.4)
);
--surface-card: linear-gradient(
135deg,
rgba(231, 240, 249, 0.43),
rgba(198, 216, 236, 0.32)
);
--surface-card-hover: linear-gradient(
135deg,
rgba(242, 248, 253, 0.6),
rgba(216, 230, 244, 0.48)
);
--border-glass: rgba(255, 255, 255, 0.38);
--border-subtle: rgba(15, 23, 42, 0.08);
--shadow-soft: inset 0 1px 0 rgba(255, 255, 255, 0.34);
--shadow-medium:
0 22px 58px rgba(4, 12, 34, 0.17), inset 0 1px 0 rgba(255, 255, 255, 0.4);
--shadow-panel:
0 28px 72px rgba(4, 12, 34, 0.18), inset 0 1px 0 rgba(255, 255, 255, 0.42);
--radius-card: 20px;
--radius-panel: 24px;
--sidebar-expanded: 240px;
--sidebar-collapsed: 72px;
--layout-gap: 20px;
}
* {
box-sizing: border-box;
}
html,
body {
min-height: 100%;
}
body {
isolation: isolate;
margin: 0;
overflow-x: hidden;
color: var(--text-primary);
background-color: #0b102b;
background-image: url("/backgrounds/test.jpg");
background-position: center;
background-size: cover;
background-repeat: no-repeat;
background-attachment: fixed;
font-family:
Inter,
"SF Pro Display",
"SF Pro Text",
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
"PingFang SC",
"Hiragino Sans GB",
"Microsoft YaHei",
sans-serif;
}
body::before {
position: fixed;
inset: 0;
z-index: -1;
content: "";
pointer-events: none;
background: linear-gradient(
180deg,
rgba(3, 7, 28, 0.2) 0%,
rgba(9, 18, 54, 0.07) 48%,
rgba(216, 230, 244, 0.08) 100%
);
-webkit-backdrop-filter: none;
backdrop-filter: none;
}
button,
input,
a {
font: inherit;
}
button,
a {
-webkit-tap-highlight-color: transparent;
}
:focus-visible {
outline: 3px solid rgba(59, 130, 246, 0.34);
outline-offset: 3px;
}
.glass-panel {
border: 1px solid var(--border-glass);
border-radius: var(--radius-panel);
background: var(--surface-card);
box-shadow: var(--shadow-soft);
-webkit-backdrop-filter: blur(16px) saturate(134%);
backdrop-filter: blur(16px) saturate(134%);
}
.app-shell {
--sidebar-width: var(--sidebar-expanded);
min-height: 100svh;
}
.app-shell[data-sidebar-collapsed="true"] {
--sidebar-width: var(--sidebar-collapsed);
}
.app-shell:not([data-hydrated="true"]) *,
.app-shell:not([data-hydrated="true"]) *::before,
.app-shell:not([data-hydrated="true"]) *::after {
transition: none !important;
}
.sidebar {
position: fixed;
inset: 20px auto 20px 20px;
z-index: 20;
display: flex;
width: var(--sidebar-width);
overflow: visible;
flex-direction: column;
border: 1px solid var(--border-glass);
border-radius: var(--radius-panel);
background: var(--surface-sidebar);
box-shadow: var(--shadow-panel);
-webkit-backdrop-filter: blur(18px) saturate(132%);
backdrop-filter: blur(18px) saturate(132%);
transition: width 180ms ease;
}
.sidebar__header {
position: relative;
display: flex;
min-height: 72px;
align-items: center;
gap: 10px;
padding: 0 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.26);
}
.sidebar__brand {
display: flex;
min-width: 0;
flex: 1;
align-items: center;
gap: 12px;
color: var(--text-primary);
text-decoration: none;
}
.sidebar__brand img {
display: block;
flex: none;
}
.sidebar__site-name {
overflow: hidden;
font-size: 18px;
font-weight: 700;
letter-spacing: -0.025em;
text-overflow: ellipsis;
white-space: nowrap;
}
.sidebar__header > button {
flex: none;
color: var(--text-secondary);
border-radius: 12px;
transition:
color 180ms ease,
background 180ms ease;
}
.sidebar__header > button:hover {
color: var(--primary-hover);
background: var(--primary-soft);
}
.sidebar__nav {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
gap: 8px;
overflow-y: auto;
padding: 12px 12px 16px;
}
.sidebar__categories {
display: flex;
flex-direction: column;
gap: 6px;
}
.sidebar__nav-item {
display: flex;
width: 100%;
height: 48px;
min-height: 48px;
align-items: center;
justify-content: flex-start;
gap: 12px;
padding: 0 14px;
color: #334155;
text-decoration: none;
border: 0;
border-radius: 16px;
background: transparent;
transition:
color 180ms ease,
background 180ms ease;
}
.sidebar__nav-item svg {
width: 20px;
height: 20px;
flex: none;
}
.sidebar__nav-item--active {
color: var(--primary-hover);
background: var(--primary-soft);
font-weight: 650;
}
.sidebar__nav-item[aria-disabled="true"] {
cursor: not-allowed;
opacity: 0.82;
}
.sidebar__nav-item[aria-disabled="true"]:hover {
color: #334155;
background: rgba(255, 255, 255, 0.28);
}
.sidebar__label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.app-shell[data-sidebar-collapsed="true"] .sidebar__header {
justify-content: center;
padding-inline: 10px;
}
.app-shell[data-sidebar-collapsed="true"] .sidebar__brand {
justify-content: center;
}
.app-shell[data-sidebar-collapsed="true"] .sidebar__header > button {
position: absolute;
top: 22px;
right: -14px;
width: 28px;
height: 28px;
border: 1px solid var(--border-glass);
background: rgba(255, 255, 255, 0.9);
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);
}
.app-shell[data-sidebar-collapsed="true"] .sidebar__nav {
padding-inline: 10px;
}
.app-shell[data-sidebar-collapsed="true"] .sidebar__nav-item {
justify-content: center;
padding-inline: 0;
}
.app-shell__content {
min-height: 100svh;
margin-left: calc(var(--sidebar-width) + 40px);
transition: margin-left 180ms ease;
}
.home-content {
display: grid;
min-height: 100svh;
place-items: start center;
padding: clamp(108px, 14vh, 156px) 40px 64px;
}
.home-content__inner {
width: min(100%, 880px);
}
.clock-greeting {
text-align: center;
}
.clock-greeting__time {
min-height: 1.2em;
margin: 0;
color: rgba(248, 250, 252, 0.98);
font-size: clamp(44px, 4.2vw, 56px);
font-weight: 700;
line-height: 1.16;
letter-spacing: -0.035em;
text-shadow: 0 3px 24px rgba(2, 6, 23, 0.48);
text-wrap: balance;
}
.clock-greeting__date {
margin: 12px 0 0;
color: rgba(241, 245, 249, 0.86);
font-size: 16px;
line-height: 1.6;
text-shadow: 0 2px 16px rgba(2, 6, 23, 0.42);
}
.search-box {
position: relative;
z-index: 10;
width: min(100%, 760px);
margin: 40px auto 0;
}
.search-box__form {
display: flex;
height: 68px;
align-items: center;
border: 1px solid var(--border-glass);
border-radius: var(--radius-panel);
background: var(--surface-search);
box-shadow: var(--shadow-medium);
-webkit-backdrop-filter: blur(18px) saturate(136%);
backdrop-filter: blur(18px) saturate(136%);
transition:
border-color 180ms ease,
box-shadow 180ms ease,
background 180ms ease;
}
.search-box__form:focus-within {
border-color: rgba(191, 219, 254, 0.46);
background: linear-gradient(
135deg,
rgba(245, 249, 253, 0.66),
rgba(218, 231, 245, 0.52)
);
box-shadow:
0 0 0 4px rgba(147, 197, 253, 0.12),
var(--shadow-medium);
}
.search-provider-menu__trigger {
width: 48px;
min-width: 48px;
height: 48px;
justify-content: center;
margin-left: 10px;
padding: 0;
color: #334155;
border-radius: 16px;
}
.search-provider-menu__icon {
width: 24px;
height: 24px;
flex: none;
object-fit: contain;
}
.search-provider-menu__trigger:hover,
.search-provider-menu__trigger[aria-expanded="true"] {
color: var(--primary-hover);
background: var(--primary-soft);
}
.search-box__icon {
width: 20px;
height: 20px;
flex: none;
margin-left: 10px;
color: rgba(51, 65, 85, 0.58);
}
.search-box__input {
min-width: 0;
height: 100%;
flex: 1;
padding: 0 14px;
color: var(--text-primary);
font-size: 16px;
line-height: 1.5;
border: 0;
outline: 0;
background: transparent;
}
.search-box__input::placeholder {
color: rgba(51, 65, 85, 0.62);
opacity: 1;
}
.search-box__shortcut {
display: inline-flex;
min-width: 50px;
height: 30px;
flex: none;
align-items: center;
justify-content: center;
margin-right: 16px;
padding-inline: 9px;
color: var(--text-muted);
font-family: inherit;
font-size: 12px;
line-height: 1;
border: 1px solid rgba(100, 116, 139, 0.2);
border-radius: 9px;
background: rgba(255, 255, 255, 0.42);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.68);
}
.local-search-results {
position: absolute;
top: calc(100% + 8px);
left: 0;
z-index: 30;
width: 100%;
max-height: min(520px, calc(100vh - 220px));
overflow-y: auto;
padding: 8px;
border: 1px solid var(--border-glass);
border-radius: 20px;
background: rgba(255, 255, 255, 0.9);
box-shadow: var(--shadow-medium);
-webkit-backdrop-filter: blur(24px);
backdrop-filter: blur(24px);
}
.local-search-results__option {
display: flex;
min-height: 58px;
align-items: center;
gap: 12px;
padding: 9px 12px;
color: var(--text-primary);
text-decoration: none;
border: 1px solid transparent;
border-radius: 14px;
transition:
color 180ms ease,
background 180ms ease,
border-color 180ms ease;
}
.local-search-results__option[aria-selected="true"] {
color: var(--primary-hover);
border-color: rgba(59, 130, 246, 0.08);
background: var(--primary-soft);
}
.local-search-results__copy {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 3px;
}
.local-search-results__title,
.local-search-results__category {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.local-search-results__title {
font-size: 14px;
font-weight: 650;
}
.local-search-results__category {
color: var(--text-secondary);
font-size: 12px;
}
.local-search-results__empty {
display: grid;
min-height: 96px;
place-items: center;
margin: 0;
color: var(--text-secondary);
font-size: 14px;
}
.quick-links {
width: min(100%, 880px);
margin-top: 44px;
}
.quick-links__title {
margin: 0 0 18px;
color: rgba(248, 250, 252, 0.96);
font-size: 16px;
font-weight: 650;
text-shadow: 0 2px 14px rgba(2, 6, 23, 0.44);
}
.quick-links__grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px 18px;
}
.quick-link-card {
display: flex;
min-width: 0;
height: 76px;
align-items: center;
gap: 14px;
padding: 0 20px;
color: var(--text-primary);
text-decoration: none;
border: 1px solid var(--border-glass);
border-radius: var(--radius-card);
background: var(--surface-card);
box-shadow: var(--shadow-soft);
-webkit-backdrop-filter: blur(16px) saturate(134%);
backdrop-filter: blur(16px) saturate(134%);
transition:
transform 180ms ease,
background 180ms ease,
border-color 180ms ease,
box-shadow 180ms ease;
}
.quick-link-card:hover {
transform: translateY(-2px);
border-color: rgba(255, 255, 255, 0.54);
background: var(--surface-card-hover);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5);
}
.quick-link-card > span:last-child {
min-width: 0;
overflow: hidden;
font-size: 15px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.site-icon {
position: relative;
display: inline-flex;
overflow: hidden;
flex: none;
align-items: center;
justify-content: center;
border-radius: 11px;
}
.site-icon svg,
.site-icon img {
display: block;
max-width: 100%;
max-height: 100%;
}
.site-icon img {
width: 100%;
height: 100%;
object-fit: contain;
border-radius: 10px;
}
.site-icon__image {
position: absolute;
inset: 0;
}
.site-icon__image--loading {
opacity: 0;
}
.site-icon--initial::before {
content: attr(data-initial);
}
.site-icon--initial {
color: var(--primary-hover);
font-size: 15px;
font-weight: 700;
background: rgba(219, 234, 254, 0.7);
box-shadow: inset 0 0 0 1px rgba(59, 130, 246, 0.08);
}
.local-search-results,
.sidebar__nav {
scrollbar-color: rgba(100, 116, 139, 0.32) transparent;
scrollbar-width: thin;
}
@media (max-width: 1199px) {
:root {
--sidebar-expanded: 220px;
}
.home-content {
padding-inline: 28px;
}
.quick-links__grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
}
+26
View File
@@ -0,0 +1,26 @@
import type { Metadata, Viewport } from "next"
import "./globals.css"
export const metadata: Metadata = {
title: "Polaris — 个人导航起始页",
description: "快速搜索并访问常用网站的个人导航起始页。",
icons: {
icon: "/brand/logo.svg",
},
}
export const viewport: Viewport = {
colorScheme: "light",
themeColor: "#dcebf5",
}
export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="zh-CN">
<body>{children}</body>
</html>
)
}
+27
View File
@@ -0,0 +1,27 @@
import { HomeContent } from "@/features/home/components/home-content"
import { AppShell } from "@/features/navigation/components/app-shell"
import { getCategories } from "@/services/categories"
import { getLinks, getPinnedLinks } from "@/services/links"
import { getSettings } from "@/services/settings"
export default function HomePage() {
const categories = getCategories()
const links = getLinks()
const pinnedLinks = getPinnedLinks()
const settings = getSettings()
return (
<AppShell
categories={categories}
siteName={settings.siteName}
defaultCollapsed={settings.sidebar.defaultCollapsed}
>
<HomeContent
categories={categories}
links={links}
pinnedLinks={pinnedLinks}
settings={settings}
/>
</AppShell>
)
}
@@ -0,0 +1,18 @@
import { createElement, type ComponentPropsWithoutRef } from "react"
import { cn } from "@/lib/utils"
type GlassPanelProps = ComponentPropsWithoutRef<"div"> & {
as?: "div" | "section" | "aside"
}
export function GlassPanel({
as = "div",
className,
...props
}: GlassPanelProps) {
return createElement(as, {
className: cn("glass-panel", className),
...props,
})
}
@@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest"
import type { NavLink } from "@/types/navigation"
import { getIconCandidates } from "./icon-candidates"
const baseLink: NavLink = {
id: "example",
title: "Example",
description: "Example site",
url: "https://example.com/path",
categoryId: "dev",
pinned: false,
keywords: [],
sort: 10,
}
describe("站点图标候选链", () => {
it("local 图标后依次回退 favicon 和文字", () => {
expect(getIconCandidates({ ...baseLink, icon: "local:custom" })).toEqual([
{ kind: "image", src: "/icons/custom.svg" },
{ kind: "image", src: "https://example.com/favicon.ico" },
{ kind: "initial", value: "E" },
])
})
it("不允许 local 图标名越出 icons 目录", () => {
expect(
getIconCandidates({ ...baseLink, icon: "local:../brand/logo" })[0],
).toEqual({
kind: "image",
src: "https://example.com/favicon.ico",
})
})
it("已知 simple icon 优先使用本地 SVG 数据", () => {
expect(
getIconCandidates({ ...baseLink, icon: "si:github" })[0],
).toMatchObject({
kind: "simple",
slug: "github",
})
})
it("未知 simple icon 跳过无效映射", () => {
expect(getIconCandidates({ ...baseLink, icon: "si:not-real" })[0]).toEqual({
kind: "image",
src: "https://example.com/favicon.ico",
})
})
it("去重写法不同但语义相同的 favicon URL", () => {
expect(
getIconCandidates({
...baseLink,
icon: "url:https://example.com:443/favicon.ico",
}),
).toEqual([
{ kind: "image", src: "https://example.com/favicon.ico" },
{ kind: "initial", value: "E" },
])
})
it("显式 URL 图标失败后仍回退 favicon 和文字", () => {
expect(
getIconCandidates({
...baseLink,
icon: "url:https://cdn.example.com/icon.png",
}),
).toEqual([
{ kind: "image", src: "https://cdn.example.com/icon.png" },
{ kind: "image", src: "https://example.com/favicon.ico" },
{ kind: "initial", value: "E" },
])
})
})
@@ -0,0 +1,62 @@
import { SIMPLE_ICON_MAP } from "@/components/shared/simple-icon-map"
import type { NavLink } from "@/types/navigation"
const LOCAL_ICON_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]*$/
export type IconCandidate =
| { kind: "simple"; slug: string }
| { kind: "image"; src: string }
| { kind: "initial"; value: string }
function normalizeImageUrl(value: string): string | null {
try {
return new URL(value).toString()
} catch {
return null
}
}
function getFaviconUrl(url: string): string | null {
try {
return new URL("/favicon.ico", url).toString()
} catch {
return null
}
}
export function getIconCandidates(
link: Pick<NavLink, "title" | "url" | "icon">,
): IconCandidate[] {
const candidates: IconCandidate[] = []
const icon = link.icon?.trim()
if (icon?.startsWith("local:")) {
const name = icon.slice("local:".length)
// 本地图标只接受单个文件名,避免路径段将请求规范化到 /icons/ 之外。
if (LOCAL_ICON_NAME_PATTERN.test(name)) {
candidates.push({ kind: "image", src: `/icons/${name}.svg` })
}
} else if (icon?.startsWith("si:")) {
const slug = icon.slice("si:".length).toLowerCase()
if (SIMPLE_ICON_MAP[slug]) candidates.push({ kind: "simple", slug })
} else if (icon?.startsWith("url:")) {
const src = normalizeImageUrl(icon.slice("url:".length))
if (src) candidates.push({ kind: "image", src })
}
const favicon = getFaviconUrl(link.url)
if (
favicon &&
!candidates.some(
(candidate) => candidate.kind === "image" && candidate.src === favicon,
)
) {
candidates.push({ kind: "image", src: favicon })
}
candidates.push({
kind: "initial",
value: link.title.trim().charAt(0).toUpperCase() || "?",
})
return candidates
}
@@ -0,0 +1,44 @@
import {
siBilibili,
siBitbucket,
siDocker,
siFigma,
siGithub,
siGitlab,
siGmail,
siGoland,
siJetbrains,
siKubernetes,
siNetlify,
siNotion,
siNpm,
siPnpm,
siPostman,
siRender,
siVercel,
siYoutube,
type SimpleIcon,
} from "simple-icons"
export const SIMPLE_ICON_MAP: Readonly<Record<string, SimpleIcon>> = {
bilibili: siBilibili,
bitbucket: siBitbucket,
docker: siDocker,
figma: siFigma,
github: siGithub,
gitlab: siGitlab,
gmail: siGmail,
goland: siGoland,
jetbrains: siJetbrains,
kubernetes: siKubernetes,
netlify: siNetlify,
notion: siNotion,
npm: siNpm,
pnpm: siPnpm,
postman: siPostman,
render: siRender,
vercel: siVercel,
youtube: siYoutube,
}
// 上游 v16 不再提供 OpenAI 与 VS Code;这两项按统一候选链回退到 favicon 和文字。
@@ -0,0 +1,84 @@
import { fireEvent, render, screen } from "@testing-library/react"
import { describe, expect, it } from "vitest"
import { SiteIcon } from "./site-icon"
describe("SiteIcon", () => {
it("渲染已知 Simple Icon", () => {
const { container } = render(
<SiteIcon
link={{ title: "GitHub", url: "https://github.com", icon: "si:github" }}
size={28}
/>,
)
const icon = container.querySelector("svg")
expect(icon).toBeInTheDocument()
expect(icon).toHaveAttribute("width", "28")
expect(icon).toHaveAttribute("height", "28")
})
it("远程图片加载完成前显示首字符占位", () => {
const { container } = render(
<SiteIcon
link={{
title: "Example",
url: "https://example.com",
icon: "url:https://cdn.example.com/icon.png",
}}
/>,
)
const wrapper = container.querySelector(".site-icon")
const image = container.querySelector("img")
expect(wrapper).toHaveAttribute("data-initial", "E")
expect(image).toHaveClass("site-icon__image--loading")
fireEvent.load(image!)
expect(wrapper).not.toHaveAttribute("data-initial")
expect(image).not.toHaveClass("site-icon__image--loading")
})
it("链接变化后从新候选链的首项重新开始", () => {
const { container, rerender } = render(
<SiteIcon
link={{
title: "Example",
url: "https://example.com",
icon: "url:https://cdn.example.com/icon.png",
}}
/>,
)
fireEvent.error(container.querySelector("img")!)
rerender(
<SiteIcon
link={{
title: "Other",
url: "https://other.example.com",
icon: "url:https://cdn.example.com/other.png",
}}
/>,
)
expect(container.querySelector("img")).toHaveAttribute(
"src",
"https://cdn.example.com/other.png",
)
})
it("远程图片连续失败后显示首字符", () => {
const { container } = render(
<SiteIcon
link={{
title: "Example",
url: "https://example.com",
icon: "url:https://cdn.example.com/icon.png",
}}
/>,
)
fireEvent.error(container.querySelector("img")!)
fireEvent.error(container.querySelector("img")!)
expect(screen.getByText("E")).toBeInTheDocument()
})
})
@@ -0,0 +1,111 @@
"use client"
/* eslint-disable @next/next/no-img-element -- favicon 来源由数据动态决定,静态 Image 配置无法预枚举域名。 */
import { useState } from "react"
import {
getIconCandidates,
type IconCandidate,
} from "@/components/shared/icon-candidates"
import { SIMPLE_ICON_MAP } from "@/components/shared/simple-icon-map"
import { cn } from "@/lib/utils"
import type { NavLink } from "@/types/navigation"
type SiteIconProps = {
link: Pick<NavLink, "title" | "url" | "icon">
size?: number
className?: string
}
type SiteIconContentProps = SiteIconProps & {
candidates: IconCandidate[]
}
function SiteIconContent({
link,
candidates,
size = 36,
className,
}: SiteIconContentProps) {
const [candidateIndex, setCandidateIndex] = useState(0)
const [loadedImageSrc, setLoadedImageSrc] = useState<string | null>(null)
const candidate = candidates[candidateIndex]
const initialCandidate = candidates.at(-1)
const initialValue =
initialCandidate?.kind === "initial" ? initialCandidate.value : "?"
const wrapperClassName = cn("site-icon", className)
const style = { width: size, height: size }
if (candidate.kind === "simple") {
const icon = SIMPLE_ICON_MAP[candidate.slug]
return (
<span className={wrapperClassName} style={style} aria-hidden="true">
<svg
viewBox="0 0 24 24"
role="presentation"
width={size}
height={size}
style={{ color: `#${icon.hex}` }}
>
<path d={icon.path} fill="currentColor" />
</svg>
</span>
)
}
if (candidate.kind === "image") {
const imageLoaded = loadedImageSrc === candidate.src
return (
<span
className={cn(wrapperClassName, !imageLoaded && "site-icon--initial")}
style={style}
data-initial={!imageLoaded ? initialValue : undefined}
aria-hidden="true"
>
<img
className={cn(
"site-icon__image",
!imageLoaded && "site-icon__image--loading",
)}
src={candidate.src}
alt={`${link.title} 图标`}
width={size}
height={size}
onLoad={() => setLoadedImageSrc(candidate.src)}
onError={() =>
setCandidateIndex((index) =>
Math.min(index + 1, candidates.length - 1),
)
}
/>
</span>
)
}
return (
<span
className={cn(wrapperClassName, "site-icon--initial")}
style={style}
aria-hidden="true"
>
{candidate.value}
</span>
)
}
export function SiteIcon({ link, size = 36, className }: SiteIconProps) {
const candidates = getIconCandidates(link)
const candidateKey = JSON.stringify([link.icon ?? "", link.title, link.url])
// 链接变化时通过 key 重建候选状态,避免 effect 中同步 setState 和旧链接的失败记录泄漏。
return (
<SiteIconContent
key={candidateKey}
link={link}
candidates={candidates}
size={size}
className={className}
/>
)
}
@@ -0,0 +1,50 @@
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import * as React from "react"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors outline-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
ghost: "hover:bg-primary/10 hover:text-primary",
outline:
"border border-border bg-transparent hover:bg-primary/10 hover:text-primary",
},
size: {
default: "h-9 px-4 py-2",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
@@ -0,0 +1,194 @@
"use client"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import * as React from "react"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"z-50 min-w-40 overflow-hidden rounded-xl border border-white/60 bg-white/90 p-1.5 text-slate-900 shadow-xl backdrop-blur-xl",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuItem({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-2 rounded-lg px-2.5 py-2 text-sm outline-none select-none focus:bg-blue-50 focus:text-blue-700 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8",
className,
)}
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-lg py-2 pr-2.5 pl-8 text-sm outline-none select-none focus:bg-blue-50 focus:text-blue-700 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
>
<span className="absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2.5 py-1.5 text-xs font-medium text-slate-500 data-[inset]:pl-8",
className,
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-slate-200/80", className)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-lg py-2 pr-2.5 pl-8 text-sm outline-none select-none focus:bg-blue-50 focus:text-blue-700",
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center rounded-lg px-2.5 py-2 text-sm outline-none select-none focus:bg-blue-50 data-[inset]:pl-8 data-[state=open]:bg-blue-50",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"z-50 min-w-40 overflow-hidden rounded-xl border border-white/60 bg-white/90 p-1.5 text-slate-900 shadow-xl backdrop-blur-xl",
className,
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
}
@@ -0,0 +1,48 @@
"use client"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import * as React from "react"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 240,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
function TooltipContent({
className,
sideOffset = 8,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"z-50 rounded-lg bg-slate-900 px-3 py-1.5 text-xs text-white shadow-lg",
className,
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="fill-slate-900" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
@@ -0,0 +1,51 @@
[
{
"id": "ai",
"title": "AI 工具",
"description": "精选常用 AI 工具与服务,辅助创作、开发和效率提升。",
"icon": "sparkles",
"sort": 10
},
{
"id": "dev",
"title": "开发工具",
"description": "高效的开发工具和资源集合,提升你的开发体验和效率。",
"icon": "code",
"sort": 20
},
{
"id": "design",
"title": "设计资源",
"description": "常用设计工具、素材与灵感资源集合。",
"icon": "palette",
"sort": 30
},
{
"id": "learning",
"title": "学习资料",
"description": "整理常用学习平台、文档、课程和知识资源。",
"icon": "book-open",
"sort": 40
},
{
"id": "efficiency",
"title": "效率工具",
"description": "提升日常工作流效率的工具入口。",
"icon": "zap",
"sort": 50
},
{
"id": "media",
"title": "娱乐媒体",
"description": "常用影音、社区和内容平台。",
"icon": "circle-play",
"sort": 60
},
{
"id": "life",
"title": "生活服务",
"description": "日常生活、服务查询和实用网站入口。",
"icon": "coffee",
"sort": 70
}
]
@@ -0,0 +1,89 @@
import { describe, expect, it } from "vitest"
import categories from "@/data/categories.json"
import links from "@/data/links.json"
import settings from "@/data/settings.json"
import { SEARCH_PROVIDERS } from "@/types/search"
const hasUniqueValues = (values: string[]) =>
new Set(values).size === values.length
describe("Polaris 静态数据", () => {
it("包含 7 个唯一且结构完整的分类", () => {
expect(categories).toHaveLength(7)
expect(hasUniqueValues(categories.map(({ id }) => id))).toBe(true)
for (const category of categories) {
expect(category).toEqual({
id: expect.any(String),
title: expect.any(String),
description: expect.any(String),
icon: expect.any(String),
sort: expect.any(Number),
})
expect(category.id).not.toBe("")
expect(category.title).not.toBe("")
expect(Number.isFinite(category.sort)).toBe(true)
}
})
it("包含 20 条合法链接且分类引用均存在", () => {
const categoryIds = new Set(categories.map(({ id }) => id))
expect(links).toHaveLength(20)
expect(hasUniqueValues(links.map(({ id }) => id))).toBe(true)
for (const link of links) {
expect(link.id).toEqual(expect.any(String))
expect(link.title).toEqual(expect.any(String))
expect(link.description).toEqual(expect.any(String))
expect(link.url).toEqual(expect.any(String))
expect(link.categoryId).toEqual(expect.any(String))
expect(link.pinned).toEqual(expect.any(Boolean))
expect(link.keywords).toEqual(expect.any(Array))
expect(link.sort).toEqual(expect.any(Number))
expect(link.id).not.toBe("")
expect(link.title).not.toBe("")
expect(() => new URL(link.url)).not.toThrow()
// 跨分类常用链接仍必须归属于真实分类,避免未来 API 出现悬空外键。
expect(categoryIds.has(link.categoryId)).toBe(true)
expect(
link.keywords.every((keyword) => typeof keyword === "string"),
).toBe(true)
expect(Number.isFinite(link.sort)).toBe(true)
if (link.icon !== undefined) expect(typeof link.icon).toBe("string")
if (!link.pinned) expect(link.pinnedSort).toBeUndefined()
}
})
it("8 条常用链接拥有唯一 pinnedSort", () => {
const pinned = links.filter(({ pinned }) => pinned)
const pinnedSorts = pinned.map(({ pinnedSort }) => pinnedSort)
expect(pinned).toHaveLength(8)
expect(pinnedSorts.every(Number.isFinite)).toBe(true)
expect(new Set(pinnedSorts).size).toBe(8)
})
it("设置包含完整且合法的嵌套契约", () => {
expect(settings).toEqual({
siteName: "Polaris",
theme: "light-glass",
search: {
defaultProvider: "google",
enabledProviders: [...SEARCH_PROVIDERS],
},
background: {
type: "gradient",
url: null,
blur: expect.any(Number),
overlay: expect.any(Number),
},
sidebar: {
defaultCollapsed: expect.any(Boolean),
},
})
expect(SEARCH_PROVIDERS).toContain(settings.search.defaultProvider)
expect(Number.isFinite(settings.background.blur)).toBe(true)
expect(Number.isFinite(settings.background.overlay)).toBe(true)
})
})
+230
View File
@@ -0,0 +1,230 @@
[
{
"id": "github",
"title": "GitHub",
"description": "全球最大的代码托管平台",
"url": "https://github.com",
"icon": "si:github",
"categoryId": "dev",
"pinned": true,
"pinnedSort": 10,
"keywords": ["代码", "仓库", "开源", "git"],
"sort": 10
},
{
"id": "gitlab",
"title": "GitLab",
"description": "一体化 DevOps 平台",
"url": "https://gitlab.com",
"icon": "si:gitlab",
"categoryId": "dev",
"pinned": false,
"keywords": ["代码", "仓库", "devops", "ci", "cd"],
"sort": 20
},
{
"id": "bitbucket",
"title": "Bitbucket",
"description": "代码托管和协作开发",
"url": "https://bitbucket.org",
"icon": "si:bitbucket",
"categoryId": "dev",
"pinned": false,
"keywords": ["代码", "仓库", "git"],
"sort": 30
},
{
"id": "vscode",
"title": "VS Code",
"description": "强大的代码编辑器",
"url": "https://code.visualstudio.com",
"icon": "si:vscode",
"categoryId": "dev",
"pinned": false,
"keywords": ["编辑器", "ide", "代码"],
"sort": 40
},
{
"id": "jetbrains",
"title": "JetBrains",
"description": "智能开发工具套件",
"url": "https://www.jetbrains.com",
"icon": "si:jetbrains",
"categoryId": "dev",
"pinned": false,
"keywords": ["ide", "开发工具", "编辑器"],
"sort": 50
},
{
"id": "goland",
"title": "GoLand",
"description": "Go 语言 IDE",
"url": "https://www.jetbrains.com/go/",
"icon": "si:goland",
"categoryId": "dev",
"pinned": false,
"keywords": ["go", "golang", "ide"],
"sort": 60
},
{
"id": "postman",
"title": "Postman",
"description": "API 开发和测试工具",
"url": "https://www.postman.com",
"icon": "si:postman",
"categoryId": "dev",
"pinned": false,
"keywords": ["api", "接口", "测试"],
"sort": 70
},
{
"id": "docker",
"title": "Docker",
"description": "容器化平台",
"url": "https://www.docker.com",
"icon": "si:docker",
"categoryId": "dev",
"pinned": false,
"keywords": ["容器", "部署", "镜像"],
"sort": 80
},
{
"id": "kubernetes",
"title": "Kubernetes",
"description": "容器编排平台",
"url": "https://kubernetes.io",
"icon": "si:kubernetes",
"categoryId": "dev",
"pinned": false,
"keywords": ["k8s", "容器", "编排", "云原生"],
"sort": 90
},
{
"id": "vercel",
"title": "Vercel",
"description": "前端部署与托管平台",
"url": "https://vercel.com",
"icon": "si:vercel",
"categoryId": "dev",
"pinned": true,
"pinnedSort": 70,
"keywords": ["部署", "前端", "托管"],
"sort": 100
},
{
"id": "netlify",
"title": "Netlify",
"description": "现代化的开发平台",
"url": "https://www.netlify.com",
"icon": "si:netlify",
"categoryId": "dev",
"pinned": false,
"keywords": ["部署", "前端", "托管"],
"sort": 110
},
{
"id": "render",
"title": "Render",
"description": "云应用托管平台",
"url": "https://render.com",
"icon": "si:render",
"categoryId": "dev",
"pinned": false,
"keywords": ["部署", "后端", "托管", "云服务"],
"sort": 120
},
{
"id": "npm",
"title": "npm",
"description": "JavaScript 包管理器",
"url": "https://www.npmjs.com",
"icon": "si:npm",
"categoryId": "dev",
"pinned": false,
"keywords": ["javascript", "node", "包管理"],
"sort": 130
},
{
"id": "pnpm",
"title": "pnpm",
"description": "快速的包管理器",
"url": "https://pnpm.io",
"icon": "si:pnpm",
"categoryId": "dev",
"pinned": false,
"keywords": ["javascript", "node", "包管理"],
"sort": 140
},
{
"id": "figma",
"title": "Figma",
"description": "界面设计协作工具",
"url": "https://www.figma.com",
"icon": "si:figma",
"categoryId": "dev",
"pinned": true,
"pinnedSort": 50,
"keywords": ["设计", "ui", "协作"],
"sort": 150
},
{
"id": "chatgpt",
"title": "ChatGPT",
"description": "智能对话与创作助手",
"url": "https://chatgpt.com",
"icon": "si:openai",
"categoryId": "ai",
"pinned": true,
"pinnedSort": 20,
"keywords": ["ai", "人工智能", "对话", "写作"],
"sort": 10
},
{
"id": "gmail",
"title": "Gmail",
"description": "Google 电子邮件服务",
"url": "https://mail.google.com",
"icon": "si:gmail",
"categoryId": "efficiency",
"pinned": true,
"pinnedSort": 30,
"keywords": ["邮件", "邮箱", "google", "mail"],
"sort": 10
},
{
"id": "notion",
"title": "Notion",
"description": "一体化知识与协作空间",
"url": "https://www.notion.so",
"icon": "si:notion",
"categoryId": "efficiency",
"pinned": true,
"pinnedSort": 40,
"keywords": ["笔记", "知识库", "协作", "文档"],
"sort": 20
},
{
"id": "youtube",
"title": "YouTube",
"description": "全球视频内容平台",
"url": "https://www.youtube.com",
"icon": "si:youtube",
"categoryId": "media",
"pinned": true,
"pinnedSort": 60,
"keywords": ["视频", "影音", "直播", "google"],
"sort": 10
},
{
"id": "bilibili",
"title": "Bilibili",
"description": "年轻人的视频社区",
"url": "https://www.bilibili.com",
"icon": "si:bilibili",
"categoryId": "media",
"pinned": true,
"pinnedSort": 80,
"keywords": ["哔哩哔哩", "视频", "动漫", "社区"],
"sort": 20
}
]
@@ -0,0 +1,17 @@
{
"siteName": "Polaris",
"theme": "light-glass",
"search": {
"defaultProvider": "google",
"enabledProviders": ["google", "bing", "github", "local"]
},
"background": {
"type": "gradient",
"url": null,
"blur": 8,
"overlay": 0.32
},
"sidebar": {
"defaultCollapsed": false
}
}
@@ -0,0 +1,60 @@
import { act, render, screen } from "@testing-library/react"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { ClockGreeting } from "./clock-greeting"
describe("ClockGreeting", () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date(2026, 7, 20, 21, 56, 30))
})
afterEach(() => {
vi.restoreAllMocks()
vi.useRealTimers()
})
it("挂载后显示当前问候、时间和日期", () => {
render(<ClockGreeting />)
expect(screen.getByRole("timer")).toHaveTextContent("晚上好,21:56")
expect(screen.getByText("2026 年 8 月 20 日 星期四")).toBeInTheDocument()
})
it("在下一分钟边界刷新", () => {
render(<ClockGreeting />)
act(() => {
vi.advanceTimersByTime(30_000)
})
expect(screen.getByRole("timer")).toHaveTextContent("21:57")
})
it("延迟执行后重新对齐下一个真实分钟边界", () => {
const setTimeoutSpy = vi.spyOn(window, "setTimeout")
vi.setSystemTime(new Date(2026, 7, 20, 9, 6, 30))
render(<ClockGreeting />)
const delayedTick = setTimeoutSpy.mock.calls[0]?.[0]
expect(typeof delayedTick).toBe("function")
if (typeof delayedTick !== "function") throw new Error("Missing clock tick")
vi.setSystemTime(new Date(2026, 7, 20, 9, 7, 12))
act(() => delayedTick())
expect(setTimeoutSpy).toHaveBeenLastCalledWith(expect.any(Function), 48_000)
})
it("跨午夜同步更新日期与问候语", () => {
vi.setSystemTime(new Date(2026, 7, 20, 23, 59, 30))
render(<ClockGreeting />)
act(() => {
vi.advanceTimersByTime(30_000)
})
expect(screen.getByRole("timer")).toHaveTextContent("夜深了,00:00")
expect(screen.getByText("2026 年 8 月 21 日 星期五")).toBeInTheDocument()
})
})
@@ -0,0 +1,34 @@
"use client"
import { useClock } from "@/features/home/hooks/use-clock"
import {
formatDate,
formatTime,
getGreeting,
} from "@/features/home/lib/date-time"
export function ClockGreeting() {
const now = useClock()
if (!now) {
return (
<section className="clock-greeting" aria-label="当前时间">
<p className="clock-greeting__time" role="timer">
正在同步时间…
</p>
<p className="clock-greeting__date" aria-hidden="true">
&nbsp;
</p>
</section>
)
}
return (
<section className="clock-greeting" aria-label="当前时间">
<p className="clock-greeting__time" role="timer" aria-live="off">
{getGreeting(now.getHours())},{formatTime(now)}
</p>
<p className="clock-greeting__date">{formatDate(now)}</p>
</section>
)
}
@@ -0,0 +1,34 @@
import { ClockGreeting } from "@/features/home/components/clock-greeting"
import { QuickLinks } from "@/features/home/components/quick-links"
import { SearchBox } from "@/features/home/components/search-box"
import type { Category, NavLink } from "@/types/navigation"
import type { AppSettings } from "@/types/settings"
type HomeContentProps = {
categories: Category[]
links: NavLink[]
pinnedLinks: NavLink[]
settings: AppSettings
}
export function HomeContent({
categories,
links,
pinnedLinks,
settings,
}: HomeContentProps) {
return (
<main className="home-content">
<div className="home-content__inner">
<ClockGreeting />
<SearchBox
links={links}
categories={categories}
defaultProvider={settings.search.defaultProvider}
enabledProviders={settings.search.enabledProviders}
/>
<QuickLinks links={pinnedLinks} />
</div>
</main>
)
}
@@ -0,0 +1,53 @@
import type { MutableRefObject } from "react"
import { SiteIcon } from "@/components/shared/site-icon"
import type { LocalSearchResult } from "@/types/search"
type LocalSearchResultsProps = {
listboxId: string
results: LocalSearchResult[]
activeIndex: number
optionRefs: MutableRefObject<Array<HTMLAnchorElement | null>>
onActiveIndexChange: (index: number) => void
}
export function LocalSearchResults({
listboxId,
results,
activeIndex,
optionRefs,
onActiveIndexChange,
}: LocalSearchResultsProps) {
return (
<div className="local-search-results" role="listbox" id={listboxId}>
{results.length === 0 ? (
<p className="local-search-results__empty">没有匹配的网站</p>
) : (
results.map(({ link, categoryTitle }, index) => (
<a
key={link.id}
ref={(element) => {
optionRefs.current[index] = element
}}
id={`${listboxId}-option-${index}`}
className="local-search-results__option"
role="option"
aria-selected={index === activeIndex}
href={link.url}
target="_blank"
rel="noopener noreferrer"
onMouseMove={() => onActiveIndexChange(index)}
>
<SiteIcon link={link} size={32} />
<span className="local-search-results__copy">
<span className="local-search-results__title">{link.title}</span>
<span className="local-search-results__category">
{categoryTitle}
</span>
</span>
</a>
))
)}
</div>
)
}
@@ -0,0 +1,55 @@
import { render, screen } from "@testing-library/react"
import { describe, expect, it } from "vitest"
import type { NavLink } from "@/types/navigation"
import { QuickLinks } from "./quick-links"
const links: NavLink[] = [
{
id: "github",
title: "GitHub",
description: "代码托管",
url: "https://github.com",
icon: "si:github",
categoryId: "dev",
pinned: true,
pinnedSort: 10,
keywords: [],
sort: 10,
},
{
id: "chatgpt",
title: "ChatGPT",
description: "AI 助手",
url: "https://chatgpt.com",
icon: "si:openai",
categoryId: "ai",
pinned: true,
pinnedSort: 20,
keywords: [],
sort: 10,
},
]
describe("QuickLinks", () => {
it("按输入顺序渲染安全的新标签页链接", () => {
render(<QuickLinks links={links} />)
const renderedLinks = screen.getAllByRole("link")
expect(renderedLinks.map((link) => link.textContent)).toEqual([
"GitHub",
"ChatGPT",
])
for (const link of renderedLinks) {
expect(link).toHaveAttribute("target", "_blank")
expect(link).toHaveAttribute("rel", "noopener noreferrer")
}
})
it("只展示图标和名称,不展示描述", () => {
render(<QuickLinks links={links} />)
expect(screen.queryByText("代码托管")).not.toBeInTheDocument()
expect(screen.queryByText("AI 助手")).not.toBeInTheDocument()
})
})
@@ -0,0 +1,26 @@
import { SiteIcon } from "@/components/shared/site-icon"
import type { NavLink } from "@/types/navigation"
export function QuickLinks({ links }: { links: NavLink[] }) {
return (
<section className="quick-links" aria-labelledby="quick-links-title">
<h2 id="quick-links-title" className="quick-links__title">
常用链接
</h2>
<div className="quick-links__grid">
{links.map((link) => (
<a
key={link.id}
className="quick-link-card"
href={link.url}
target="_blank"
rel="noopener noreferrer"
>
<SiteIcon link={link} size={38} />
<span>{link.title}</span>
</a>
))}
</div>
</section>
)
}
@@ -0,0 +1,200 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import type { ComponentProps } from "react"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import type { Category, NavLink } from "@/types/navigation"
import { SearchBox } from "./search-box"
const categories: Category[] = [
{ id: "dev", title: "开发工具", description: "", icon: "code", sort: 10 },
]
const links: NavLink[] = [
{
id: "github",
title: "GitHub",
description: "代码托管平台",
url: "https://github.com",
icon: "si:github",
categoryId: "dev",
pinned: true,
pinnedSort: 10,
keywords: ["git", "仓库"],
sort: 10,
},
{
id: "gitlab",
title: "GitLab",
description: "DevOps 平台",
url: "https://gitlab.com",
icon: "si:gitlab",
categoryId: "dev",
pinned: false,
keywords: ["git", "ci"],
sort: 20,
},
]
function renderSearchBox(
overrides: Partial<ComponentProps<typeof SearchBox>> = {},
) {
const onExternalNavigate = vi.fn()
const renderResult = render(
<SearchBox
links={links}
categories={categories}
defaultProvider="google"
enabledProviders={["google", "bing", "github", "local"]}
onExternalNavigate={onExternalNavigate}
{...overrides}
/>,
)
return { onExternalNavigate, ...renderResult }
}
describe("SearchBox", () => {
beforeEach(() => window.localStorage.clear())
afterEach(() => vi.restoreAllMocks())
it("挂载后自动聚焦,并响应 Ctrl+K 与 Meta+K", async () => {
renderSearchBox()
const input = screen.getByRole("combobox", {
name: "搜索网站、书签或关键词",
})
await waitFor(() => expect(input).toHaveFocus())
input.blur()
fireEvent.keyDown(window, { key: "k", ctrlKey: true })
expect(input).toHaveFocus()
input.blur()
fireEvent.keyDown(window, { key: "k", metaKey: true })
expect(input).toHaveFocus()
})
it("闭合态只显示当前图标,展开后显示 Provider 名称", async () => {
const user = userEvent.setup()
renderSearchBox()
const trigger = screen.getByRole("button", {
name: "选择搜索方式,当前为 Google",
})
expect(trigger.textContent).toBe("")
expect(trigger.querySelector("img")).toHaveAttribute(
"src",
"/icons/google.svg",
)
await user.click(trigger)
for (const [label, icon] of [
["Google", "google"],
["Bing", "bing"],
["GitHub", "github"],
["本地", "local"],
] as const) {
const item = screen.getByRole("menuitemradio", { name: label })
expect(item.querySelector("img")).toHaveAttribute(
"src",
`/icons/${icon}.svg`,
)
}
})
it("Google 模式回车只执行当前页外部导航", async () => {
const user = userEvent.setup()
const { onExternalNavigate } = renderSearchBox()
const input = screen.getByRole("combobox", {
name: "搜索网站、书签或关键词",
})
await user.type(input, "Next.js 中文{Enter}")
const url = new URL(onExternalNavigate.mock.calls[0][0])
expect(url.hostname).toBe("www.google.com")
expect(url.searchParams.get("q")).toBe("Next.js 中文")
expect(screen.queryByRole("listbox")).not.toBeInTheDocument()
})
it("空白查询不导航", async () => {
const user = userEvent.setup()
const { onExternalNavigate } = renderSearchBox()
await user.type(screen.getByRole("combobox"), " {Enter}")
expect(onExternalNavigate).not.toHaveBeenCalled()
})
it("切换 Local 后实时显示结果并持久化 Provider", async () => {
const user = userEvent.setup()
renderSearchBox()
await user.click(
screen.getByRole("button", { name: "选择搜索方式,当前为 Google" }),
)
await user.click(screen.getByRole("menuitemradio", { name: "本地" }))
await user.type(screen.getByRole("combobox"), "git")
expect(screen.getByRole("listbox")).toBeInTheDocument()
expect(screen.getAllByRole("option")).toHaveLength(2)
expect(window.localStorage.getItem("polaris.search.provider")).toBe("local")
})
it("Local 模式支持上下选择、Enter 与 Esc", async () => {
const user = userEvent.setup()
renderSearchBox({ defaultProvider: "local" })
const input = screen.getByRole("combobox")
await user.type(input, "git")
const options = screen.getAllByRole("option")
expect(options[0]).toHaveAttribute("aria-selected", "true")
expect(options[0]).toHaveAttribute("target", "_blank")
expect(options[0]).toHaveAttribute("rel", "noopener noreferrer")
await user.keyboard("{ArrowUp}")
expect(options[1]).toHaveAttribute("aria-selected", "true")
await user.keyboard("{ArrowDown}")
expect(options[0]).toHaveAttribute("aria-selected", "true")
fireEvent.mouseMove(options[1])
expect(options[1]).toHaveAttribute("aria-selected", "true")
const clickSpy = vi
.spyOn(HTMLAnchorElement.prototype, "click")
.mockImplementation(() => {})
await user.keyboard("{Enter}")
expect(clickSpy).toHaveBeenCalledOnce()
await user.keyboard("{Escape}")
expect(input).toHaveValue("")
expect(screen.queryByRole("listbox")).not.toBeInTheDocument()
})
it("Local 无结果显示空状态", async () => {
const user = userEvent.setup()
renderSearchBox({ defaultProvider: "local" })
await user.type(screen.getByRole("combobox"), "不存在的网站")
expect(screen.getByText("没有匹配的网站")).toBeInTheDocument()
})
it("恢复合法 Provider,非法或未启用值回退默认 Provider", async () => {
window.localStorage.setItem("polaris.search.provider", "local")
const { unmount } = renderSearchBox()
await waitFor(() =>
expect(
screen.getByRole("button", { name: /当前为 本地/ }),
).toBeInTheDocument(),
)
unmount()
window.localStorage.setItem("polaris.search.provider", "invalid")
const secondRender = renderSearchBox()
await waitFor(() =>
expect(
screen.getByRole("button", { name: /当前为 Google/ }),
).toBeInTheDocument(),
)
secondRender.unmount()
window.localStorage.setItem("polaris.search.provider", "local")
renderSearchBox({ enabledProviders: ["google", "bing"] })
await waitFor(() =>
expect(
screen.getByRole("button", { name: /当前为 Google/ }),
).toBeInTheDocument(),
)
})
})
@@ -0,0 +1,217 @@
"use client"
import { Search } from "lucide-react"
import {
useEffect,
useId,
useMemo,
useRef,
useState,
useSyncExternalStore,
type FormEvent,
type KeyboardEvent,
} from "react"
import { LocalSearchResults } from "@/features/home/components/local-search-results"
import { SearchProviderMenu } from "@/features/home/components/search-provider-menu"
import { searchLocalLinks } from "@/features/home/lib/local-search"
import {
parseStoredProvider,
SEARCH_PROVIDER_STORAGE_KEY,
} from "@/features/home/lib/provider-preference"
import { buildSearchUrl } from "@/features/home/lib/search-providers"
import type { Category, NavLink } from "@/types/navigation"
import type { SearchProvider } from "@/types/search"
type SearchBoxProps = {
links: NavLink[]
categories: Category[]
defaultProvider: SearchProvider
enabledProviders: SearchProvider[]
onExternalNavigate?: (url: string) => void
}
function subscribeHydration() {
return () => undefined
}
function getClientHydrationSnapshot() {
return true
}
function getServerHydrationSnapshot() {
return false
}
function readStoredProvider(
defaultProvider: SearchProvider,
enabledProviders: SearchProvider[],
): SearchProvider {
try {
const storedProvider = parseStoredProvider(
window.localStorage.getItem(SEARCH_PROVIDER_STORAGE_KEY),
defaultProvider,
)
return enabledProviders.includes(storedProvider)
? storedProvider
: defaultProvider
} catch {
return defaultProvider
}
}
export function SearchBox({
links,
categories,
defaultProvider,
enabledProviders,
onExternalNavigate,
}: SearchBoxProps) {
const inputRef = useRef<HTMLInputElement>(null)
const optionRefs = useRef<Array<HTMLAnchorElement | null>>([])
const listboxId = useId()
const [query, setQuery] = useState("")
const [selectedProvider, setSelectedProvider] =
useState<SearchProvider | null>(null)
const [activeIndex, setActiveIndex] = useState(0)
const hydrated = useSyncExternalStore(
subscribeHydration,
getClientHydrationSnapshot,
getServerHydrationSnapshot,
)
// Server 与 hydration 首帧保持默认 Provider;hydration 后再容错读取浏览器偏好。
const restoredProvider = hydrated
? readStoredProvider(defaultProvider, enabledProviders)
: defaultProvider
const provider = selectedProvider ?? restoredProvider
const results = useMemo(
() =>
provider === "local" ? searchLocalLinks(query, links, categories, 8) : [],
[categories, links, provider, query],
)
const panelOpen = provider === "local" && query.trim().length > 0
const resolvedActiveIndex =
results.length > 0 ? Math.min(activeIndex, results.length - 1) : 0
const activeOptionId =
panelOpen && results.length > 0
? `${listboxId}-option-${resolvedActiveIndex}`
: undefined
const shortcutHint =
hydrated && navigator.userAgent.includes("Mac") ? "⌘K" : "Ctrl K"
useEffect(() => {
inputRef.current?.focus()
const focusSearch = (event: globalThis.KeyboardEvent) => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") {
event.preventDefault()
inputRef.current?.focus()
}
}
window.addEventListener("keydown", focusSearch)
return () => window.removeEventListener("keydown", focusSearch)
}, [])
const changeProvider = (nextProvider: SearchProvider) => {
setSelectedProvider(nextProvider)
setActiveIndex(0)
try {
window.localStorage.setItem(SEARCH_PROVIDER_STORAGE_KEY, nextProvider)
} catch {
// Provider 已保留在当前组件状态,存储受限时不影响本次交互。
}
}
const openActiveLocalResult = () => {
if (results.length === 0) return
// 复用真实 anchor 的导航语义,键盘 Enter 不另造 click-only 跳转路径。
optionRefs.current[resolvedActiveIndex]?.click()
}
const submitSearch = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
// Local 只打开站内结果;外部 Provider 只构建搜索 URL,两条路径严格分离。
if (provider === "local") {
openActiveLocalResult()
return
}
const url = buildSearchUrl(provider, query)
if (!url) return
if (onExternalNavigate) onExternalNavigate(url)
else window.location.assign(url)
}
const handleInputKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Escape") {
event.preventDefault()
setQuery("")
setActiveIndex(0)
return
}
if (provider !== "local") return
if (event.key === "Enter") {
event.preventDefault()
openActiveLocalResult()
return
}
if (results.length === 0) return
if (event.key === "ArrowDown") {
event.preventDefault()
setActiveIndex((index) => (index + 1) % results.length)
} else if (event.key === "ArrowUp") {
event.preventDefault()
setActiveIndex((index) => (index - 1 + results.length) % results.length)
}
}
return (
<div className="search-box">
<form className="search-box__form" role="search" onSubmit={submitSearch}>
<SearchProviderMenu
provider={provider}
enabledProviders={enabledProviders}
onProviderChange={changeProvider}
/>
<Search className="search-box__icon" aria-hidden="true" />
<input
ref={inputRef}
className="search-box__input"
role="combobox"
aria-label="搜索网站、书签或关键词"
aria-autocomplete="list"
aria-expanded={panelOpen}
aria-controls={panelOpen ? listboxId : undefined}
aria-activedescendant={activeOptionId}
placeholder="搜索网站、书签或关键词..."
value={query}
onChange={(event) => {
setQuery(event.target.value)
setActiveIndex(0)
}}
onKeyDown={handleInputKeyDown}
/>
<kbd className="search-box__shortcut" aria-hidden="true">
{shortcutHint}
</kbd>
</form>
{panelOpen ? (
<LocalSearchResults
listboxId={listboxId}
results={results}
activeIndex={resolvedActiveIndex}
optionRefs={optionRefs}
onActiveIndexChange={setActiveIndex}
/>
) : null}
</div>
)
}
@@ -0,0 +1,88 @@
"use client"
import Image from "next/image"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { isSearchProvider } from "@/features/home/lib/search-providers"
import type { SearchProvider } from "@/types/search"
const PROVIDER_LABELS: Record<SearchProvider, string> = {
google: "Google",
bing: "Bing",
github: "GitHub",
local: "本地",
}
const PROVIDER_ICON_PATHS: Record<SearchProvider, string> = {
google: "/icons/google.svg",
bing: "/icons/bing.svg",
github: "/icons/github.svg",
local: "/icons/local.svg",
}
function ProviderIcon({ provider }: { provider: SearchProvider }) {
return (
<Image
className="search-provider-menu__icon"
src={PROVIDER_ICON_PATHS[provider]}
alt=""
width={24}
height={24}
aria-hidden="true"
/>
)
}
type SearchProviderMenuProps = {
provider: SearchProvider
enabledProviders: SearchProvider[]
onProviderChange: (provider: SearchProvider) => void
}
export function SearchProviderMenu({
provider,
enabledProviders,
onProviderChange,
}: SearchProviderMenuProps) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
className="search-provider-menu__trigger"
aria-label={`选择搜索方式,当前为 ${PROVIDER_LABELS[provider]}`}
>
<ProviderIcon provider={provider} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuRadioGroup
value={provider}
onValueChange={(value) => {
if (isSearchProvider(value)) onProviderChange(value)
}}
>
{enabledProviders.map((enabledProvider) => {
return (
<DropdownMenuRadioItem
key={enabledProvider}
value={enabledProvider}
>
<ProviderIcon provider={enabledProvider} />
{PROVIDER_LABELS[enabledProvider]}
</DropdownMenuRadioItem>
)
})}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -0,0 +1,61 @@
"use client"
import { useSyncExternalStore } from "react"
import { getDelayUntilNextMinute } from "@/features/home/lib/date-time"
type Listener = () => void
const listeners = new Set<Listener>()
let currentTime: Date | null = null
let timeoutId: number | undefined
function emitCurrentTime(): Date {
const nextTime = new Date()
currentTime = nextTime
listeners.forEach((listener) => listener())
return nextTime
}
function scheduleNextTick() {
const observedTime = emitCurrentTime()
// 每次 tick 都重新对齐;标签页休眠或主线程阻塞后不会把延迟永久带入后续分钟。
timeoutId = window.setTimeout(
scheduleNextTick,
getDelayUntilNextMinute(observedTime),
)
}
function startClock() {
scheduleNextTick()
}
function stopClock() {
if (timeoutId !== undefined) window.clearTimeout(timeoutId)
timeoutId = undefined
currentTime = null
}
function subscribe(listener: Listener) {
listeners.add(listener)
if (listeners.size === 1) startClock()
return () => {
listeners.delete(listener)
if (listeners.size === 0) stopClock()
}
}
function getSnapshot() {
return currentTime
}
function getServerSnapshot() {
return null
}
export function useClock(): Date | null {
// 服务端与 hydration 首帧固定为空;订阅建立后再读取浏览器时间,避免首屏不一致。
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
}
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest"
import {
formatDate,
formatTime,
getDelayUntilNextMinute,
getGreeting,
} from "./date-time"
describe("首页日期与问候", () => {
it.each([
[0, "夜深了"],
[4, "夜深了"],
[5, "早上好"],
[11, "早上好"],
[12, "中午好"],
[13, "中午好"],
[14, "下午好"],
[17, "下午好"],
[18, "晚上好"],
[23, "晚上好"],
])("%i 时显示 %s", (hour, expected) => {
expect(getGreeting(hour)).toBe(expected)
})
it("拒绝无效小时", () => {
expect(() => getGreeting(-1)).toThrow("hour must be between 0 and 23")
expect(() => getGreeting(24)).toThrow("hour must be between 0 and 23")
})
it("格式化中文日期与两位时间", () => {
const date = new Date(2026, 7, 20, 9, 6, 30)
expect(formatTime(date)).toBe("09:06")
expect(formatDate(date)).toBe("2026 年 8 月 20 日 星期四")
})
it("计算到下一分钟边界的延迟", () => {
expect(getDelayUntilNextMinute(new Date(2026, 7, 20, 9, 6, 30, 250))).toBe(
29_750,
)
})
})
@@ -0,0 +1,34 @@
const WEEKDAYS = [
"星期日",
"星期一",
"星期二",
"星期三",
"星期四",
"星期五",
"星期六",
] as const
export function getGreeting(hour: number): string {
if (!Number.isInteger(hour) || hour < 0 || hour > 23) {
throw new RangeError("hour must be between 0 and 23")
}
if (hour < 5) return "夜深了"
if (hour < 12) return "早上好"
if (hour < 14) return "中午好"
if (hour < 18) return "下午好"
return "晚上好"
}
export function formatTime(date: Date): string {
const hours = String(date.getHours()).padStart(2, "0")
const minutes = String(date.getMinutes()).padStart(2, "0")
return `${hours}:${minutes}`
}
export function formatDate(date: Date): string {
return `${date.getFullYear()} 年 ${date.getMonth() + 1} 月 ${date.getDate()} 日 ${WEEKDAYS[date.getDay()]}`
}
export function getDelayUntilNextMinute(date: Date): number {
return 60_000 - (date.getSeconds() * 1_000 + date.getMilliseconds())
}
@@ -0,0 +1,112 @@
import { describe, expect, it } from "vitest"
import type { Category, NavLink } from "@/types/navigation"
import { searchLocalLinks } from "./local-search"
const categories: Category[] = [
{ id: "dev", title: "开发工具", description: "", icon: "code", sort: 10 },
{ id: "ai", title: "AI 工具", description: "", icon: "sparkles", sort: 20 },
]
const links: NavLink[] = [
{
id: "github",
title: "GitHub",
description: "代码托管平台",
url: "https://github.com",
icon: "si:github",
categoryId: "dev",
pinned: true,
pinnedSort: 10,
keywords: ["git", "仓库"],
sort: 20,
},
{
id: "gitlab",
title: "GitLab",
description: "DevOps 平台",
url: "https://gitlab.com",
icon: "si:gitlab",
categoryId: "dev",
pinned: false,
keywords: ["git", "ci"],
sort: 10,
},
{
id: "chatgpt",
title: "ChatGPT",
description: "智能对话与写作",
url: "https://chatgpt.com",
icon: "si:openai",
categoryId: "ai",
pinned: true,
pinnedSort: 20,
keywords: ["人工智能", "写作"],
sort: 10,
},
]
describe("Local 搜索", () => {
it("标题完全匹配优先于标题包含和关键词", () => {
expect(searchLocalLinks("github", links, categories)[0].link.id).toBe(
"github",
)
})
it("匹配关键词、描述、分类标题和分类 ID", () => {
expect(searchLocalLinks("人工智能", links, categories)[0].link.id).toBe(
"chatgpt",
)
expect(searchLocalLinks("DevOps", links, categories)[0].link.id).toBe(
"gitlab",
)
expect(
searchLocalLinks("开发工具", links, categories).map(
({ link }) => link.id,
),
).toEqual(["gitlab", "github"])
expect(searchLocalLinks("ai", links, categories)[0].link.id).toBe("chatgpt")
})
it("同分时按 sort 再按标题稳定排序", () => {
expect(
searchLocalLinks("git", links, categories).map(({ link }) => link.id),
).toEqual(["gitlab", "github"])
const titleTies: NavLink[] = [
{
...links[0],
id: "beta",
title: "贝塔",
keywords: ["工具"],
sort: 10,
},
{
...links[0],
id: "alpha",
title: "阿尔法",
keywords: ["工具"],
sort: 10,
},
]
expect(
searchLocalLinks("工具", titleTies, categories).map(
({ link }) => link.id,
),
).toEqual(["alpha", "beta"])
})
it("规范化大小写、全角字符和首尾空白并限制结果数量", () => {
expect(
searchLocalLinks(" GitHub ", links, categories, 1).map(
({ link }) => link.id,
),
).toEqual(["github"])
})
it("空查询返回空数组", () => {
expect(searchLocalLinks(" ", links, categories)).toEqual([])
})
})
@@ -0,0 +1,74 @@
import type { Category, NavLink } from "@/types/navigation"
import type { LocalSearchResult } from "@/types/search"
function normalize(value: string): string {
return value.normalize("NFKC").trim().toLocaleLowerCase("zh-CN")
}
function textScore(
value: string,
query: string,
exact: number,
prefix: number,
contains: number,
): number {
if (value === query) return exact
if (value.startsWith(query)) return prefix
if (value.includes(query)) return contains
return 0
}
function scoreLink(
link: NavLink,
category: Category | undefined,
query: string,
): number {
const title = normalize(link.title)
const keywordScore = Math.max(
0,
...link.keywords.map((keyword) =>
textScore(normalize(keyword), query, 300, 260, 240),
),
)
// 各区间不重叠,保证“标题 > 关键词 > 描述 > 分类”的规则不会被字段数量反转。
return Math.max(
textScore(title, query, 500, 400, 350),
keywordScore,
normalize(link.description).includes(query) ? 180 : 0,
category ? textScore(normalize(category.title), query, 140, 130, 120) : 0,
category ? textScore(normalize(category.id), query, 100, 90, 80) : 0,
)
}
export function searchLocalLinks(
query: string,
links: NavLink[],
categories: Category[],
limit = 8,
): LocalSearchResult[] {
const normalizedQuery = normalize(query)
if (!normalizedQuery || limit <= 0) return []
const categoryById = new Map(
categories.map((category) => [category.id, category]),
)
return links
.map((link) => {
const category = categoryById.get(link.categoryId)
return {
link,
categoryTitle: category?.title ?? "未分类",
score: scoreLink(link, category, normalizedQuery),
}
})
.filter(({ score }) => score > 0)
.sort(
(a, b) =>
b.score - a.score ||
a.link.sort - b.link.sort ||
a.link.title.localeCompare(b.link.title, "zh-CN"),
)
.slice(0, limit)
}
@@ -0,0 +1,14 @@
import { describe, expect, it } from "vitest"
import { parseStoredProvider } from "./provider-preference"
describe("Provider 持久化值", () => {
it("返回合法存储值", () => {
expect(parseStoredProvider("local", "google")).toBe("local")
})
it("非法或空值回退默认值", () => {
expect(parseStoredProvider("duckduckgo", "google")).toBe("google")
expect(parseStoredProvider(null, "bing")).toBe("bing")
})
})
@@ -0,0 +1,11 @@
import { isSearchProvider } from "@/features/home/lib/search-providers"
import type { SearchProvider } from "@/types/search"
export const SEARCH_PROVIDER_STORAGE_KEY = "polaris.search.provider"
export function parseStoredProvider(
value: string | null,
fallback: SearchProvider,
): SearchProvider {
return isSearchProvider(value) ? value : fallback
}
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest"
import { buildSearchUrl, isSearchProvider } from "./search-providers"
describe("外部搜索 Provider", () => {
it.each([
["google", "https://www.google.com/search"],
["bing", "https://www.bing.com/search"],
["github", "https://github.com/search"],
] as const)("为 %s 构建正确 URL", (provider, expectedBase) => {
const result = buildSearchUrl(provider, "Next.js 中文")
const url = new URL(result!)
expect(`${url.origin}${url.pathname}`).toBe(expectedBase)
expect(url.searchParams.get("q")).toBe("Next.js 中文")
})
it("对查询参数分隔符和百分号执行 URL 编码", () => {
const query = "a&next=1 %= #中文"
const url = new URL(buildSearchUrl("google", query)!)
expect(url.searchParams.get("q")).toBe(query)
expect([...url.searchParams.keys()]).toEqual(["q"])
})
it("空白查询不生成 URL", () => {
expect(buildSearchUrl("google", " ")).toBeNull()
})
it("识别合法 Provider", () => {
expect(isSearchProvider("local")).toBe(true)
expect(isSearchProvider("duckduckgo")).toBe(false)
expect(isSearchProvider(null)).toBe(false)
})
})
@@ -0,0 +1,30 @@
import {
SEARCH_PROVIDERS,
type ExternalSearchProvider,
type SearchProvider,
} from "@/types/search"
const SEARCH_ENDPOINTS: Record<ExternalSearchProvider, string> = {
google: "https://www.google.com/search",
bing: "https://www.bing.com/search",
github: "https://github.com/search",
}
export function isSearchProvider(value: unknown): value is SearchProvider {
return (
typeof value === "string" &&
SEARCH_PROVIDERS.includes(value as SearchProvider)
)
}
export function buildSearchUrl(
provider: ExternalSearchProvider,
query: string,
): string | null {
const normalizedQuery = query.trim()
if (!normalizedQuery) return null
const url = new URL(SEARCH_ENDPOINTS[provider])
url.searchParams.set("q", normalizedQuery)
return url.toString()
}

Some files were not shown because too many files have changed in this diff Show More