Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
691a169fe9 | ||
|
|
7817e19783 | ||
|
|
babd8f2793 | ||
|
|
4a032ea742 | ||
|
|
64cc3ec381 | ||
|
|
e9f3bf6934 | ||
|
|
5a46186bde | ||
|
|
9754d61ffe | ||
|
|
20fa8f4d5a | ||
|
|
9714bd4ab5 | ||
|
|
b23bd2d5ec | ||
|
|
7eaa9e6f80 | ||
|
|
0913235382 | ||
|
|
0686bd0d87 | ||
|
|
a3272858ad | ||
|
|
2b442221b2 | ||
|
|
817344fc66 | ||
|
|
f175b3a66d | ||
|
|
123b607dc4 | ||
|
|
3902c6996c | ||
|
|
ef6d737d91 | ||
|
|
d4cad276e3 | ||
|
|
a1156d6977 | ||
|
|
f5f2314589 | ||
|
|
0ae0be2f87 | ||
|
|
88861a6da1 | ||
|
|
a52408862d | ||
|
|
6befa47926 |
@@ -0,0 +1,8 @@
|
||||
# 统一行尾为 LF:Windows / WSL 共享同一仓库时防止行尾伪差异
|
||||
* text=auto eol=lf
|
||||
|
||||
# 二进制文件不做任何转换
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.gif binary
|
||||
*.ico binary
|
||||
@@ -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/
|
||||
@@ -27,4 +41,7 @@ build/
|
||||
|
||||
# ---- 操作系统 ----
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# ---- Note 目录 ----
|
||||
note/项目/
|
||||
@@ -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 配置、操作系统文件
|
||||
@@ -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))
|
||||
|
||||
## 约定
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# Casbin v3 笔记更新方案
|
||||
|
||||
## 范围
|
||||
|
||||
- 仅更新 `note/随手记/casbin学习笔记.md`,不修改项目 Go 依赖。
|
||||
- 本文件记录方案;笔记实施前等待用户确认。
|
||||
|
||||
## 已核实依据
|
||||
|
||||
- GitHub 最新正式 release 为 v3.11.0(2026-08-20)。
|
||||
- v3.11.0 的 module 仍为 `github.com/casbin/casbin/v3`,尽管源码仓库已迁移至 apache/casbin。
|
||||
- 本次查询 Go proxy 的旧模块路径 `@latest` 返回 v3.10.0;不能将 GitHub 最新发布与代理解析结果混为一谈。
|
||||
- 来源:https://github.com/apache/casbin/releases/tag/v3.11.0
|
||||
- 来源:https://raw.githubusercontent.com/apache/casbin/v3.11.0/go.mod
|
||||
|
||||
## 实施顺序
|
||||
|
||||
1. 保留原有学习笔记结构,新增版本基线、v2 到 v3 的迁移注意事项与官方来源。
|
||||
2. 修改示例 import;纠正 CSV 加载错误处理、上下文生命周期、Watcher/自动重载、延迟加载、ABAC 能力边界等过时或绝对化描述。不要把既有能力或新增示例误写成 v3 新能力。
|
||||
3. 完善最小 RBAC 示例错误处理;检查差异、代码块和引用,条件允许时在临时目录验证示例,不修改仓库依赖。
|
||||
|
||||
## 职责与验收
|
||||
|
||||
- librarian:只读核实官方事实(已完成)。
|
||||
- fixer:用户确认后仅实施目标笔记,编辑前确认编码和换行,保持原有中文笔记风格。
|
||||
- 主会话:核对最终 diff 和关键事实,说明实际验证情况及外部依赖限制。
|
||||
|
||||
## 完成状态
|
||||
|
||||
- [x] 已更新 `note/随手记/casbin学习笔记.md`(2026-09-18):头部版本基线(v3.11.0、module path、官方来源、release 与 proxy `@latest` 区别)、三元组只是典型模型、g 的角色继承表述、SyncedEnforcer 并发与 matcher 编译缓存、adapter 表结构非核心标准、LoadPolicyArray/Line 校验责任、只读 adapter、ctx 生命周期与 ContextAdapter/ContextEnforcer、Watcher/Dispatcher/StartAutoLoadPolicy、快照时序保护与去性能硬承诺、最小示例改 `/v3` 并完整处理 error、ABAC 能力边界、govaluate 来源与 GetPolicy 签名、延迟加载顺序。
|
||||
- [x] 示例已抽取到 `/tmp/opencode/casbin-v3-verify` 独立 module(`github.com/casbin/casbin/v3 v3.11.0`)验证:`go run .` 输出 `true false false`;`NewSyncedEnforcer(m)` → `SetAdapter(a)` → `LoadPolicy()` 顺序 `go run ./delayed` 输出 `delayed-load: true`。
|
||||
- 未改动仓库 Go 依赖,未提交 git;最终 diff 与事实由主会话复核。
|
||||
|
||||
## 精简方案(2026-09-18 二次修订)
|
||||
|
||||
- 目标:从 213 行压到约 60–85 行(原长度约 1/3),定位为中文概念速记,不是教程;不写/少写具体 API 使用方法。
|
||||
- 保留:简短版本基线与 import 路径;“授权而非认证”一句话;PERM 四段简洁解释 + role_definition 扩展;核心组件 Model/Policy/Enforcer/Adapter/RoleManager/Watcher 职责表;一条判定链与 `alice/admin/report/read` 极短策略示意(无完整 Go 代码);关键提醒(deny/error 均 fail-closed、并发优先 SyncedEnforcer、内存策略不自动跨实例同步、ABAC 不自动 SQL 过滤、迁移核对 adapter `/v3` 依赖);2–3 条官方来源。
|
||||
- 删除:加载入口对比、SQL 表结构、接口签名、CSV/context 细节、完整程序、只读消费与快照架构、MaxStale/性能讨论、proxy `@latest` 偏差等非核心展开及其历史修订细节。
|
||||
- 约束:避免过度简化造成事实错误;不改代码与依赖;不提交;改动仅限本方案与目标笔记。
|
||||
@@ -0,0 +1,100 @@
|
||||
package linkedlist
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Node 是单向链表的一个节点,持有数据与指向下一个节点的指针。
|
||||
type Node[T comparable] struct {
|
||||
Data T
|
||||
Next *Node[T]
|
||||
}
|
||||
|
||||
// LinkedList 是持有头指针的单向链表,零值即为空表。
|
||||
type LinkedList[T comparable] struct {
|
||||
head *Node[T]
|
||||
}
|
||||
|
||||
// InsertHead 将新节点插入表头。
|
||||
func (l *LinkedList[T]) InsertHead(data T) {
|
||||
l.head = &Node[T]{Data: data, Next: l.head}
|
||||
}
|
||||
|
||||
// InsertTail 将新节点追加到表尾;空表时新节点直接成为表头。
|
||||
func (l *LinkedList[T]) InsertTail(data T) {
|
||||
newNode := &Node[T]{Data: data}
|
||||
if l.head == nil {
|
||||
l.head = newNode
|
||||
return
|
||||
}
|
||||
current := l.head
|
||||
for current.Next != nil {
|
||||
current = current.Next
|
||||
}
|
||||
current.Next = newNode
|
||||
}
|
||||
|
||||
// DeleteHead 删除表头节点;空表时无操作。
|
||||
func (l *LinkedList[T]) DeleteHead() {
|
||||
if l.head != nil {
|
||||
l.head = l.head.Next
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteTail 删除表尾节点;空表时无操作,仅剩一个节点时表变空。
|
||||
func (l *LinkedList[T]) DeleteTail() {
|
||||
switch {
|
||||
case l.head == nil:
|
||||
case l.head.Next == nil:
|
||||
l.head = nil
|
||||
default:
|
||||
current := l.head
|
||||
for current.Next.Next != nil {
|
||||
current = current.Next
|
||||
}
|
||||
current.Next = nil
|
||||
}
|
||||
}
|
||||
|
||||
// Search 返回首个 Data 等于 data 的节点;不存在时返回 nil。
|
||||
func (l *LinkedList[T]) Search(data T) *Node[T] {
|
||||
for current := l.head; current != nil; current = current.Next {
|
||||
if current.Data == data {
|
||||
return current
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Len 返回链表的节点个数。
|
||||
func (l *LinkedList[T]) Len() int {
|
||||
count := 0
|
||||
for current := l.head; current != nil; current = current.Next {
|
||||
count++
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// Reverse 原地反转链表。
|
||||
func (l *LinkedList[T]) Reverse() {
|
||||
var prev, current *Node[T]
|
||||
current = l.head
|
||||
for current != nil {
|
||||
next := current.Next
|
||||
current.Next = prev
|
||||
prev = current
|
||||
current = next
|
||||
}
|
||||
l.head = prev
|
||||
}
|
||||
|
||||
// String 以 "1 -> 2 -> nil" 的格式输出整条链,便于打印与调试。
|
||||
func (l LinkedList[T]) String() string {
|
||||
var sb strings.Builder
|
||||
for current := l.head; current != nil; current = current.Next {
|
||||
fmt.Fprintf(&sb, "%v -> ", current.Data)
|
||||
}
|
||||
sb.WriteString("nil")
|
||||
return sb.String()
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"lab/algorithms/linkedlist"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var list linkedlist.LinkedList[int] // 零值即为空表
|
||||
list.InsertTail(3)
|
||||
list.InsertTail(5)
|
||||
list.InsertHead(1)
|
||||
list.InsertHead(0)
|
||||
fmt.Println(&list) // 0 -> 1 -> 3 -> 5 -> nil
|
||||
|
||||
list.DeleteHead()
|
||||
list.DeleteTail()
|
||||
fmt.Println(&list) // 1 -> 3 -> nil
|
||||
|
||||
found := list.Search(3)
|
||||
if found != nil {
|
||||
fmt.Printf("找到节点,Data = %d\n", found.Data)
|
||||
}
|
||||
|
||||
list.Reverse()
|
||||
fmt.Println(&list) // 3 -> 1 -> nil
|
||||
fmt.Printf("链表长度: %d\n", list.Len())
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
_ "net/http/pprof"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 来自文章 https://www.cnblogs.com/hwnd/p/20682814
|
||||
func simulateRequest() {
|
||||
timeout := 500 * time.Millisecond
|
||||
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(timeout))
|
||||
defer cancel()
|
||||
resultCh := make(chan string) // 无缓冲!
|
||||
|
||||
// 启动 worker goroutine 处理"请求"
|
||||
go func() {
|
||||
time.Sleep(time.Duration(rand.Intn(500)+300) * time.Millisecond)
|
||||
select {
|
||||
case <-time.After(timeout):
|
||||
fmt.Println("worker 超时,放弃处理")
|
||||
case <-ctx.Done():
|
||||
case resultCh <- fmt.Sprintf("处理完成,耗时 %dms", rand.Intn(500)+300):
|
||||
|
||||
}
|
||||
}()
|
||||
|
||||
// TODO: 模拟请求取消或超时的情况(比如 500ms 后调用方不再关心结果)
|
||||
// 如果 worker 还没完成,它就 leak 了
|
||||
select {
|
||||
case resultCh := <-resultCh:
|
||||
fmt.Println(resultCh)
|
||||
case <-ctx.Done():
|
||||
fmt.Println("请求方取消了请求")
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
for i := 0; i < 5; i++ {
|
||||
simulateRequest()
|
||||
fmt.Printf("第 %d 个请求处理完毕\n\n", i+1)
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
fmt.Println("所有请求处理完毕")
|
||||
// 运行后观察:goroutine 数量只增不减
|
||||
http.ListenAndServe(":6060", nil)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
_ "net/http/pprof" // 注册 pprof 相关 handler
|
||||
"time"
|
||||
)
|
||||
|
||||
// memoryBlocks 保留一部分内存块,方便内存剖析观察。
|
||||
// 这个全局变量会一直存活,便于分析堆上的对象。
|
||||
var memoryBlocks [64][]byte
|
||||
|
||||
func main() {
|
||||
// 启动 pprof HTTP 服务
|
||||
go func() {
|
||||
log.Println("pprof server started at http://localhost:6060/debug/pprof/")
|
||||
if err := http.ListenAndServe("localhost:6060", nil); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}()
|
||||
|
||||
// 模拟 CPU 密集型业务
|
||||
go cpuIntensiveTask()
|
||||
|
||||
// 模拟内存分配型业务
|
||||
go memoryIntensiveTask()
|
||||
|
||||
// 阻塞主协程,让程序持续运行
|
||||
select {}
|
||||
}
|
||||
|
||||
// fib 递归计算斐波那契数列,非常耗费 CPU
|
||||
func fib(n int) int {
|
||||
if n <= 1 {
|
||||
return n
|
||||
}
|
||||
return fib(n-1) + fib(n-2)
|
||||
}
|
||||
|
||||
// cpuIntensiveTask 循环执行 CPU 密集型计算
|
||||
func cpuIntensiveTask() {
|
||||
for {
|
||||
_ = fib(30)
|
||||
}
|
||||
}
|
||||
|
||||
// memoryIntensiveTask 循环分配内存并保留在全局切片中
|
||||
func memoryIntensiveTask() {
|
||||
for i := 0; ; i++ {
|
||||
idx := i % len(memoryBlocks)
|
||||
// 每次分配 1MB,替换掉旧的内存块
|
||||
memoryBlocks[idx] = make([]byte, 1<<20) // 1 << 20 = 1MB
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-contrib/sessions/cookie"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
var (
|
||||
mockUsers = map[string]string{
|
||||
"zhangsan": "123456",
|
||||
"lisi": "654321",
|
||||
}
|
||||
// secretKey = "your-secret-key"
|
||||
hmacSecretKey = "5KiinhGcvoXFmgOttvgMWd9ugadE0KVP"
|
||||
blockSecretKey = "OLZmfTWHGyjCfJoH1Y8SjbSY"
|
||||
)
|
||||
|
||||
const sessionTTL = 60 * 5
|
||||
|
||||
func sessionOptions(maxAge int) sessions.Options {
|
||||
return sessions.Options{
|
||||
Path: "/",
|
||||
Domain: "", // 设置为 "" 表示当前域名
|
||||
MaxAge: maxAge, // 单位为秒。0 表示不写Max-Age(浏览器会话cookie); -1 表示删除 cookie
|
||||
Secure: false, // 生产走 HTTPS 时改 true
|
||||
HttpOnly: true, // true 表示禁止 JS 读取 cookie
|
||||
SameSite: http.SameSiteStrictMode, // 严格模式,仅在同源请求中发送 cookie
|
||||
}
|
||||
}
|
||||
|
||||
// 代码来自文章 https://www.cnblogs.com/XY-Heruo/p/22864951
|
||||
func main() {
|
||||
r := gin.Default()
|
||||
|
||||
// Create cookie-based session store with a secret key
|
||||
// 传单个密钥时, 只做签名认证,不加密cookie内容. 认证密钥建议 32 或 64 字节
|
||||
// 传一对密钥时,第一个用于认证(HMAC),第二个用于加密(AES-128, AES-192, AES-256)。加密密钥必须是16、24或32字节长度,对应 AES-128/192/256。
|
||||
// 多对密钥用于密钥轮换,第一对用于当前加密和认证,后续的用于解密和认证旧的cookie。Example: cookie.NewStore([]byte("newAuth"), []byte("newEnc"), []byte("oldAuth"), []byte("oldEnc"))
|
||||
store := cookie.NewStore([]byte(hmacSecretKey), []byte(blockSecretKey))
|
||||
store.Options(sessionOptions(sessionTTL)) // 全局设置 cookie 过期时间为 5 分钟
|
||||
r.Use(sessions.Sessions("mysession", store))
|
||||
|
||||
r.POST("/login", login)
|
||||
|
||||
auth := r.Group("/", authRequired())
|
||||
|
||||
auth.GET("/profile", profile)
|
||||
auth.POST("/logout", logout)
|
||||
|
||||
r.Run(":8080")
|
||||
}
|
||||
|
||||
func login(c *gin.Context) {
|
||||
session := sessions.Default(c)
|
||||
username := c.PostForm("username")
|
||||
password := c.PostForm("password")
|
||||
|
||||
if username == "" || password == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "username or password is empty"})
|
||||
return
|
||||
}
|
||||
|
||||
if mockUsers[username] != password {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid username or password"})
|
||||
return
|
||||
}
|
||||
|
||||
session.Set("user", username)
|
||||
session.Set("expire_at", time.Now().Add(time.Second*sessionTTL).Unix()) // 设置过期时间
|
||||
if err := session.Save(); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "save session failed"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "logged in"})
|
||||
}
|
||||
|
||||
func logout(c *gin.Context) {
|
||||
session := sessions.Default(c)
|
||||
session.Clear()
|
||||
session.Options(sessionOptions(-1)) // 让浏览器删除 cookie
|
||||
if err := session.Save(); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "save session failed"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "logged out"})
|
||||
}
|
||||
|
||||
func profile(c *gin.Context) {
|
||||
session := sessions.Default(c)
|
||||
user := session.Get("user").(string)
|
||||
c.JSON(http.StatusOK, gin.H{"user": user})
|
||||
}
|
||||
|
||||
func authRequired() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
session := sessions.Default(c)
|
||||
if session.Get("user") == nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
if expireAt, ok := session.Get("expire_at").(int64); !ok || time.Now().Unix() > expireAt {
|
||||
session.Clear()
|
||||
session.Options(sessionOptions(-1))
|
||||
session.Save()
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "expired session"})
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,43 @@
|
||||
module lab
|
||||
|
||||
go 1.26.2
|
||||
go 1.27.0
|
||||
|
||||
require (
|
||||
github.com/gin-contrib/sessions v1.1.2
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
golang.org/x/crypto v0.56.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/gopkg v0.1.4 // indirect
|
||||
github.com/bytedance/sonic v1.15.2 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.1 // indirect
|
||||
github.com/cloudwego/base64x v0.1.7 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||
github.com/gin-contrib/sse v1.1.1 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.3 // indirect
|
||||
github.com/goccy/go-json v0.10.6 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/gorilla/context v1.1.2 // indirect
|
||||
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||
github.com/gorilla/sessions v1.4.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.4.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.23 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.4.3 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.60.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.8.0 // indirect
|
||||
golang.org/x/arch v0.29.0 // indirect
|
||||
golang.org/x/net v0.57.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.41.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
)
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
|
||||
github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
|
||||
github.com/bytedance/sonic v1.15.2 h1:90H+rcF/FwLXwfB1cudOLq/je83n683Utf4Cbp0xHCo=
|
||||
github.com/bytedance/sonic v1.15.2/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA=
|
||||
github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI=
|
||||
github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI=
|
||||
github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4X2SQ8aWYg=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/sessions v1.1.2 h1:utWRqOHT/kbqxxqF/mtutV9wfGLi1+uu5YuvWUhj/Ow=
|
||||
github.com/gin-contrib/sessions v1.1.2/go.mod h1:qRxYwA/TYJw0wv28oFzs4gY/8G8z69id862SNgX9Gmw=
|
||||
github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko=
|
||||
github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s=
|
||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8=
|
||||
github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc=
|
||||
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
|
||||
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o=
|
||||
github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM=
|
||||
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
|
||||
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
|
||||
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
|
||||
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
|
||||
github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyiQ=
|
||||
github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
|
||||
github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0=
|
||||
github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+0=
|
||||
github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
go.mongodb.org/mongo-driver/v2 v2.8.0 h1:CxWDGQYY8QQwNjAl/aq2sfWakdnWZynnqJ9F4DhHbP8=
|
||||
go.mongodb.org/mongo-driver/v2 v2.8.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
golang.org/x/arch v0.29.0 h1:8sSET5wB0+exBm0FGmOtdHMqjlRdV2DRD3/IV6OZgho=
|
||||
golang.org/x/arch v0.29.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
|
||||
golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y=
|
||||
golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I=
|
||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -0,0 +1,149 @@
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidPassword = errors.New("invalid password")
|
||||
ErrInvalidHash = errors.New("invalid password hash")
|
||||
)
|
||||
|
||||
type Hasher struct {
|
||||
cost int // cost 可以理解成 bcrypt 的“计算难度”参数,cost 越高,计算 bcrypt hash 就越慢,攻击者暴力破解密码也越贵
|
||||
}
|
||||
|
||||
func NewHasher(cost int) (*Hasher, error) {
|
||||
if cost < bcrypt.MinCost || cost > bcrypt.MaxCost {
|
||||
return nil, errors.New("invalid bcrypt cost")
|
||||
}
|
||||
|
||||
return &Hasher{
|
||||
cost: cost,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Hash 用于注册、重置密码、修改密码。
|
||||
func (h *Hasher) Hash(password string) (string, error) {
|
||||
if len(password) == 0 {
|
||||
return "", ErrInvalidPassword
|
||||
}
|
||||
|
||||
// bcrypt 只使用密码前 72 bytes。
|
||||
// 生产环境建议显式拒绝,而不是静默截断。
|
||||
if len([]byte(password)) > 72 {
|
||||
return "", ErrInvalidPassword
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword(
|
||||
[]byte(password),
|
||||
h.cost,
|
||||
)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(hash), nil
|
||||
}
|
||||
|
||||
// Verify 用于登录时验证密码。
|
||||
func (h *Hasher) Verify(password, encodedHash string) (bool, error) {
|
||||
if len(password) == 0 || len([]byte(password)) > 72 {
|
||||
return false, ErrInvalidPassword
|
||||
}
|
||||
|
||||
if encodedHash == "" {
|
||||
return false, ErrInvalidHash
|
||||
}
|
||||
|
||||
err := bcrypt.CompareHashAndPassword(
|
||||
[]byte(encodedHash),
|
||||
[]byte(password),
|
||||
)
|
||||
|
||||
switch {
|
||||
case err == nil:
|
||||
return true, nil
|
||||
|
||||
case errors.Is(err, bcrypt.ErrMismatchedHashAndPassword):
|
||||
return false, nil
|
||||
|
||||
default:
|
||||
// 例如数据库里的 hash 已损坏或格式不合法。
|
||||
return false, ErrInvalidHash
|
||||
}
|
||||
}
|
||||
|
||||
// NeedsRehash 判断数据库中的 hash 是否使用了较旧的 cost。
|
||||
func (h *Hasher) NeedsRehash(encodedHash string) bool {
|
||||
cost, err := bcrypt.Cost([]byte(encodedHash))
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
|
||||
return cost != h.cost
|
||||
}
|
||||
|
||||
func TestBcrypt(t *testing.T) {
|
||||
oldHasher, err := NewHasher(10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
newHasher, err := NewHasher(12)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
password := "password"
|
||||
oldHash, err := oldHasher.Hash(password)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Log(oldHash)
|
||||
newHash, err := newHasher.Hash(password)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Log(newHash)
|
||||
err = bcrypt.CompareHashAndPassword([]byte(oldHash), []byte(password))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestHashPassword(t *testing.T) {
|
||||
hasher, err := NewHasher(12)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
password := "password"
|
||||
hash, err := hasher.Hash(password)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Log(hash)
|
||||
if ok, err := hasher.Verify(password, hash); err != nil || !ok {
|
||||
t.Fatal("verify failed")
|
||||
}
|
||||
if ok := hasher.NeedsRehash(hash); !ok {
|
||||
t.Fatal("needs rehash failed")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestGenerateTime(t *testing.T) {
|
||||
start := time.Now()
|
||||
|
||||
_, err := bcrypt.GenerateFromPassword(
|
||||
[]byte("TestPassword123!"),
|
||||
14,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Log(time.Since(start), err)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
// 引入两个包进行对比
|
||||
jsonV1 "encoding/json"
|
||||
jsonV2 "encoding/json/v2"
|
||||
)
|
||||
|
||||
func TestV2Null(t *testing.T) {
|
||||
type Product struct {
|
||||
Name string `json:"name"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
p := Product{
|
||||
Name: "product1",
|
||||
}
|
||||
data1, _ := jsonV1.Marshal(p)
|
||||
data2, _ := jsonV2.Marshal(p)
|
||||
t.Log(string(data1))
|
||||
t.Log(string(data2))
|
||||
}
|
||||
|
||||
func TestOmitZeroVsEmpty(t *testing.T) {
|
||||
// ===== V1 结构体:使用 omitempty =====
|
||||
type EventV1 struct {
|
||||
Title string `json:"title"`
|
||||
CreatedAt time.Time `json:"created_at,omitempty"` // ⚠️ omitempty 对 time.Time 无效
|
||||
}
|
||||
|
||||
// ===== V2 结构体:使用 omitzero =====
|
||||
type EventV2 struct {
|
||||
Title string `json:"title"`
|
||||
CreatedAt time.Time `json:"created_at,omitzero"` // ✅ omitzero 能识别 IsZero()
|
||||
}
|
||||
// 都不给 CreatedAt 赋值,保持 Go 的零值 time.Time{}
|
||||
|
||||
// 1. 测试 v1 + omitempty
|
||||
e1 := EventV1{Title: "Go Meetup"}
|
||||
data1, _ := jsonV1.Marshal(e1)
|
||||
fmt.Println("v1 + omitempty 输出:", string(data1))
|
||||
|
||||
// 2. 测试 v2 + omitzero
|
||||
e2 := EventV2{Title: "Go Meetup"}
|
||||
data2, _ := jsonV2.Marshal(e2)
|
||||
fmt.Println("v2 + omitzero 输出:", string(data2))
|
||||
}
|
||||
|
||||
func TestMultiStyle(t *testing.T) {
|
||||
type MultiStyleUser struct {
|
||||
// 1. 严格匹配:JSON key 必须精确是 "user_id"
|
||||
ID string `json:"user_id,case:strict"`
|
||||
|
||||
// 2. 忽略大小写:JSON key 是 "Name"、"NAME"、"name" 都能成功赋值
|
||||
Name string `json:"name,case:ignore"`
|
||||
}
|
||||
// 假设第三方传来的 JSON 风格混杂
|
||||
jsonData := []byte(`{
|
||||
"user_id": "1001",
|
||||
"NAME": "Gopher"
|
||||
}`)
|
||||
var m MultiStyleUser
|
||||
jsonV2.Unmarshal(jsonData, &m)
|
||||
t.Log(m)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package testing
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"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 // 永久阻塞在此
|
||||
}
|
||||
|
||||
func TestFeature1(t *testing.T) {
|
||||
type Person struct {
|
||||
Sex string
|
||||
Name string
|
||||
}
|
||||
type Student struct {
|
||||
StudentID int
|
||||
Person
|
||||
}
|
||||
|
||||
s1 := Student{
|
||||
StudentID: 1,
|
||||
Sex: "male",
|
||||
Name: "张三",
|
||||
}
|
||||
t.Log(s1)
|
||||
}
|
||||
|
||||
func TestFeature2(t *testing.T) {
|
||||
// Go 1.27 之前:必须手动写出类型实参 [int],否则编译报错
|
||||
fn1 := IntToString(FormatToString[int])
|
||||
fn2 := IntToString(FormatToString)
|
||||
|
||||
oldFormat := []IntToString{FormatToString[int]}
|
||||
newFormat := []IntToString{FormatToString}
|
||||
|
||||
t.Log(fn1(1))
|
||||
t.Log(fn2(2))
|
||||
for i, fn := range oldFormat {
|
||||
t.Log(fn(i))
|
||||
}
|
||||
for i, fn := range newFormat {
|
||||
t.Log(fn(i))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 一个泛型格式化函数
|
||||
func FormatToString[T any](v T) string {
|
||||
return fmt.Sprintf("value: %v", v)
|
||||
}
|
||||
|
||||
// 具名函数类型:接收一个 int,返回 string
|
||||
type IntToString func(int) string
|
||||
|
||||
func TestSort(t *testing.T) {
|
||||
slice := []int{8, 2, 4, 4}
|
||||
Sort1(slice)
|
||||
t.Log(slice)
|
||||
}
|
||||
func Sort1(slice []int) {
|
||||
for i := 0; i < len(slice)-1; i++ {
|
||||
for j := 0; j < len(slice)-1-i; j++ {
|
||||
if slice[j] > slice[j+1] {
|
||||
slice[j], slice[j+1] = slice[j+1], slice[j]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.5 MiB |
@@ -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())
|
||||
}
|
||||
|
||||
// 补偿操作跟上面差不多,就是业务逻辑修改
|
||||
```
|
||||
|
||||
@@ -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` |
|
||||
| `[]` | 没有技能 |
|
||||
| `["!*"]` | 拒绝所有技能 |
|
||||
|
||||
@@ -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` | |
|
||||
@@ -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
|
||||
@@ -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).
|
||||
|
||||
|
||||
|
||||
@@ -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连接,这时候又需要远程连接这个服务,这样的话就能和访问本地服务一样访问了
|
||||
@@ -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**,程序从当初的下一条指令继续运行。
|
||||
|
||||
@@ -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` 传递
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
## 快速复制文件路径
|
||||
|
||||
### Shift + 右键菜单(通用)
|
||||
|
||||
1. 在文件资源管理器中,**按住 `Shift` 键不放**
|
||||
2. 右键点击目标文件
|
||||
3. 选择 **「复制为路径(A)」**
|
||||
|
||||
### 功能区按钮
|
||||
|
||||
1. 选中文件
|
||||
2. 点击资源管理器顶部的 **「主页」** 选项卡
|
||||
3. 点击 **「复制路径」** 按钮(剪贴板图标)
|
||||
|
||||
### 拖拽到终端
|
||||
|
||||
- 直接把文件**拖入** PowerShell 或 CMD 窗口,会自动粘贴完整路径。
|
||||
|
||||
@@ -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 不存在,检查扩展是否已安装并启用 |
|
||||
@@ -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位身份证号 |
|
||||
|
||||
@@ -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、空亡:音信稀时,属土,为勾陈,凡谋事主一、五、七。
|
||||
|
||||
断辞曰:
|
||||
|
||||
空亡事不长,阴人小乘张,求财无有利,
|
||||
|
||||
行人有灾殃,失物寻不见,官事主刑伤,
|
||||
|
||||
病人逢暗鬼,乞解保安康。
|
||||
|
||||
#### **六、 使用原则与心法**
|
||||
|
||||
- **心诚意专**:占测时心态必须虔诚、专注,意念集中于所问之事。
|
||||
- **一事一占**:同一件事在短期内不应反复占问,以第一次的结果为准。所谓“再三渎,渎则不告”。
|
||||
- **动念即占**:当某个问题或念头强烈地出现在脑海中时,是最佳的占测时机。
|
||||
- **趋吉避凶**:小六壬提供的是一种预警和参考。得到吉卦,应顺势而为;得到凶卦,则应反思、谨慎、规避,而非坐以待毙。它是一种积极的“行为指导”,而非消极的“宿命论”。
|
||||
@@ -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 视为空字符串 |
|
||||
|
||||
@@ -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] |
|
||||
|
||||
@@ -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`。
|
||||
@@ -0,0 +1,286 @@
|
||||
# MySQL 空表 JOIN 导致 ORDER BY 索引失效——实验笔记
|
||||
|
||||
> 环境:MySQL 8.0.18+(hash join 自 8.0.18 引入,实测 8.0.28)
|
||||
> 现象:一条 `ORDER BY ... LIMIT` 查询原本走主表复合索引(免排序),LEFT JOIN 一张**空表**后,执行计划退化为全量读取 + `Using temporary; Using filesort`,主表索引"失效"。
|
||||
|
||||
---
|
||||
|
||||
## 一、一句话结论
|
||||
|
||||
**不是空表禁用了别的表的索引,而是空表让优化器为该 JOIN 选择了 hash join;hash join 不保序,导致"借主表索引序完成 ORDER BY"的整个计划作废**,主表随之退化为全量读取 + 临时表排序。
|
||||
|
||||
---
|
||||
|
||||
## 二、原理
|
||||
|
||||
### 1. 为什么嵌套循环(Nested Loop Join)能"免排序"
|
||||
|
||||
```
|
||||
按 idx(created_at, id) 倒序逐行读主表 → 每行对被 join 表做索引点查(eq_ref)→ 输出
|
||||
```
|
||||
|
||||
- 嵌套循环的输出顺序 = 驱动表(主表)的访问顺序
|
||||
- 主表走 `(created_at, id)` 复合索引倒序扫描,输出天然满足 `ORDER BY created_at DESC, id DESC`
|
||||
- `LIMIT 10` 可以提前终止:读到 10 行即停,`ORDER BY` 零成本(EXPLAIN 中体现为 `type=index` + `Backward index scan`,无 temporary/filesort)
|
||||
|
||||
### 2. hash join 为什么会破坏这一切
|
||||
|
||||
- hash join 把被 join 表整棵读进内存哈希表,再用驱动表逐行**探测**,输出顺序不再对应任何一方的索引序
|
||||
- 一旦计划中任何一个 JOIN 环节是 hash join,结果集都必须整体重排
|
||||
- 优化器权衡:"反正要排序,主表走哪个访问路径都无所谓" → 放弃排序专用索引,选最便宜的读取方式(全表扫或全索引扫,见第四节变体)
|
||||
|
||||
### 3. 为什么偏偏是空表触发 hash join(反直觉)
|
||||
|
||||
- 空表没有统计信息,优化器估算行数 ≈ 1
|
||||
- 估算"把空表整棵读进哈希表(≈1 次 IO)再探测" 比 "对主表每行做一次 B+ 树点查(N 次树遍历)" 更便宜
|
||||
- 于是选了 hash join —— 对空表本身这是**正确的成本决策**(0 行数据怎么访问都对),副作用是拖垮了主表的排序计划
|
||||
- 表有了真实数据后,统计信息更新,优化器改回索引点查(eq_ref),保序计划自动恢复
|
||||
|
||||
---
|
||||
|
||||
## 三、建表与造数(可直接执行)
|
||||
|
||||
### 1. 主表:10 万行,带 `(created_at, id)` 复合索引
|
||||
|
||||
```sql
|
||||
DROP TABLE IF EXISTS users, user_profiles, user_tags_empty, user_tags_noindex;
|
||||
|
||||
CREATE TABLE users (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
username VARCHAR(64) NOT NULL,
|
||||
nickname VARCHAR(64) NOT NULL DEFAULT '',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
KEY idx_created_id (created_at, id)
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
-- 递归 CTE 造数需要先放开深度限制(仅当前会话)
|
||||
SET SESSION cte_max_recursion_depth = 100001;
|
||||
|
||||
INSERT INTO users (username, nickname, created_at)
|
||||
WITH RECURSIVE seq AS (
|
||||
SELECT 1 AS n
|
||||
UNION ALL
|
||||
SELECT n + 1 FROM seq WHERE n < 100000
|
||||
)
|
||||
SELECT
|
||||
CONCAT('user_', n),
|
||||
CONCAT('nick_', n),
|
||||
DATE_ADD('2025-01-01 00:00:00', INTERVAL FLOOR(RAND() * 31536000) SECOND)
|
||||
FROM seq;
|
||||
```
|
||||
|
||||
### 2. 对照用被 join 表:有数据,join 列唯一索引
|
||||
|
||||
```sql
|
||||
CREATE TABLE user_profiles (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id BIGINT UNSIGNED NOT NULL,
|
||||
city VARCHAR(32) NOT NULL DEFAULT '',
|
||||
UNIQUE KEY uk_user_id (user_id)
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
INSERT INTO user_profiles (user_id, city)
|
||||
WITH RECURSIVE seq AS (
|
||||
SELECT 1 AS n
|
||||
UNION ALL
|
||||
SELECT n + 1 FROM seq WHERE n < 100000
|
||||
)
|
||||
SELECT n, CONCAT('city_', FLOOR(RAND() * 100)) FROM seq;
|
||||
```
|
||||
|
||||
### 3. "罪魁"空表:结构与 user_profiles 完全同构,只是**不插入任何数据**
|
||||
|
||||
```sql
|
||||
CREATE TABLE user_tags_empty (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id BIGINT UNSIGNED NOT NULL,
|
||||
tag VARCHAR(32) NOT NULL DEFAULT '',
|
||||
UNIQUE KEY uk_user_id (user_id)
|
||||
) ENGINE=InnoDB;
|
||||
-- 故意不插数据
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、对照实验
|
||||
|
||||
> 每组实验观察四处:主表 users 的 `type` / `key` / `rows` / `Extra`,以及被 join 表的访问方式。
|
||||
> **注意**:实验 SQL 故意 SELECT 了 `username`/`nickname` 等**索引外列**,原因见实验二的"变体"说明。
|
||||
|
||||
### 实验一(对照组):只 JOIN 有数据的表 → 索引完美生效
|
||||
|
||||
```sql
|
||||
EXPLAIN
|
||||
SELECT u.id, u.username, u.nickname, u.created_at, p.city
|
||||
FROM users u
|
||||
LEFT JOIN user_profiles p ON p.user_id = u.id
|
||||
ORDER BY u.created_at DESC, u.id DESC
|
||||
LIMIT 10;
|
||||
```
|
||||
|
||||
**预期结果:**
|
||||
|
||||
| table | type | key | rows | Extra |
|
||||
|---|---|---|---|---|
|
||||
| u | **index** | **idx_created_id** | **10** | **Backward index scan** |
|
||||
| p | eq_ref | uk_user_id | 1 | (空) |
|
||||
|
||||
主表倒序走复合索引只读 10 行(LIMIT 提前终止),被 join 表逐行点查,无排序开销。保序计划下 SELECT 索引外列不影响走索引(逐行回表而已)。
|
||||
|
||||
### 实验二:仅追加一个 LEFT JOIN 空表 → 计划全面崩坏
|
||||
|
||||
```sql
|
||||
EXPLAIN
|
||||
SELECT u.id, u.username, u.nickname, u.created_at, p.city, t.tag
|
||||
FROM users u
|
||||
LEFT JOIN user_profiles p ON p.user_id = u.id
|
||||
LEFT JOIN user_tags_empty t ON t.user_id = u.id
|
||||
ORDER BY u.created_at DESC, u.id DESC
|
||||
LIMIT 10;
|
||||
```
|
||||
|
||||
**预期结果(SELECT 含索引外列时):**
|
||||
|
||||
| table | type | key | rows | Extra |
|
||||
|---|---|---|---|---|
|
||||
| u | **ALL** | NULL | **100003** | **Using temporary; Using filesort** |
|
||||
| p | eq_ref | uk_user_id | 1 | (空) |
|
||||
| t | **ALL** | NULL | 1 | **Using where; Using join buffer (hash join)** |
|
||||
|
||||
三个观察点:
|
||||
|
||||
1. 空表 t 的索引 `uk_user_id` 明明存在,却 `type=ALL` + hash join(空表上这是合理的成本选择)
|
||||
2. 主表 u 的 `idx_created_id` 被**整个放弃**:全表扫描 + 临时表 + 文件排序,rows 从 10 涨到全量(LIMIT 提前终止失效)
|
||||
3. 注意 p 的 eq_ref 没有受影响——**别的表的索引并没有被禁用,坏掉的是主表的"排序专用访问路径"**
|
||||
|
||||
**变体:主表退化的两种形态(重要,实测踩过的坑)**
|
||||
|
||||
如果 SELECT **只查索引内的列**(如仅 `u.id, u.created_at`),主表退化后不是 `ALL`,而是:
|
||||
|
||||
```
|
||||
u: type=index, key=idx_created_id, rows=100003, Extra: Using index; Using temporary; Using filesort
|
||||
```
|
||||
|
||||
机制:排序计划作废后优化器选"最便宜的读取方式"——查询列全在索引里时,扫**窄的二级索引**(覆盖扫描,`Using index` 免回表)比扫**宽的聚簇索引**(ALL)更便宜。`type=index` 全索引扫描与 `ALL` 全表扫本质相同:**都是全量读取 + 显式排序**,同样没有 LIMIT 提前终止。
|
||||
|
||||
判断"是否退化"的可靠标志是 `rows` 是否涨到全量、`Using temporary; Using filesort` 是否出现,而**不是** type 显示 ALL 还是 index。生产查询通常 SELECT 大量业务列(覆盖不成立),多表现为 ALL 形态;本实验因此故意带上索引外列。
|
||||
|
||||
> 加 `FORMAT=TREE` 可以更直观看到 `Left (hash join)` 节点:
|
||||
> `EXPLAIN FORMAT=TREE <实验二的查询>;`
|
||||
|
||||
### 实验三:FORCE INDEX 把空表钉回点查 → 计划恢复
|
||||
|
||||
```sql
|
||||
EXPLAIN
|
||||
SELECT u.id, u.username, u.nickname, u.created_at, p.city, t.tag
|
||||
FROM users u
|
||||
LEFT JOIN user_profiles p ON p.user_id = u.id
|
||||
LEFT JOIN user_tags_empty t FORCE INDEX (uk_user_id) ON t.user_id = u.id
|
||||
ORDER BY u.created_at DESC, u.id DESC
|
||||
LIMIT 10;
|
||||
```
|
||||
|
||||
**预期结果:** t 变为 `eq_ref`,主表 u 恢复 `index + Backward index scan, rows=10`,temporary/filesort 消失。
|
||||
|
||||
结论:只要所有 JOIN 都是保序的嵌套循环点查,主表就能借索引序免排序。
|
||||
|
||||
### 实验四:空表填入数据 + 刷新统计信息 → 自愈
|
||||
|
||||
```sql
|
||||
INSERT INTO user_tags_empty (user_id, tag)
|
||||
WITH RECURSIVE seq AS (
|
||||
SELECT 1 AS n
|
||||
UNION ALL
|
||||
SELECT n + 1 FROM seq WHERE n < 100000
|
||||
)
|
||||
SELECT n, 'vip' FROM seq;
|
||||
|
||||
ANALYZE TABLE user_tags_empty;
|
||||
|
||||
-- 重跑实验二的 EXPLAIN
|
||||
```
|
||||
|
||||
**预期结果:** 与实验一/三相同,t 走 `eq_ref`,主表走 `idx_created_id`,无需任何 hint。
|
||||
|
||||
补充观察:只插入少量行(比如 50 行)时优化器可能仍选 hash join——对几十行的表,hash join 确实可能更便宜,这属于**合理决策**而非问题;关注点应是"数据量与计划是否匹配",而不是"必须永远走索引"。
|
||||
|
||||
### 进阶实验五:与空无关——任何原因导致的 hash join 都会触发同样的退化
|
||||
|
||||
证明"根因是 hash join 破坏保序计划,空表只是诱因之一":
|
||||
|
||||
```sql
|
||||
-- 有 10 万行、但 join 列上【没有索引】的表
|
||||
CREATE TABLE user_tags_noindex (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id BIGINT UNSIGNED NOT NULL,
|
||||
tag VARCHAR(32) NOT NULL DEFAULT ''
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
INSERT INTO user_tags_noindex (user_id, tag)
|
||||
WITH RECURSIVE seq AS (
|
||||
SELECT 1 AS n
|
||||
UNION ALL
|
||||
SELECT n + 1 FROM seq WHERE n < 100000
|
||||
)
|
||||
SELECT n, 'new' FROM seq;
|
||||
|
||||
EXPLAIN
|
||||
SELECT u.id, u.username, u.nickname, u.created_at, p.city, t.tag
|
||||
FROM users u
|
||||
LEFT JOIN user_profiles p ON p.user_id = u.id
|
||||
LEFT JOIN user_tags_noindex t ON t.user_id = u.id
|
||||
ORDER BY u.created_at DESC, u.id DESC
|
||||
LIMIT 10;
|
||||
```
|
||||
|
||||
**预期结果:** 与实验二相同的崩坏形态(t 为 `ALL + hash join`,主表全量读取 + temporary + filesort)。
|
||||
|
||||
这说明日常更常见的场景——**join 列漏建索引**——和空表是同一个病:hash join 一出现,依赖主表索引序的 ORDER BY + LIMIT 计划就没了。
|
||||
|
||||
---
|
||||
|
||||
## 五、机制小结(对照表)
|
||||
|
||||
| JOIN 实现方式 | 输出是否保序 | LIMIT 能否提前终止 | 对主表排序索引的影响 |
|
||||
|---|---|---|---|
|
||||
| 嵌套循环 + eq_ref 点查 | ✅ 顺序=驱动表访问序 | ✅ 读够即停 | 可借索引序免排序 |
|
||||
| hash join | ❌ 哈希探测序 | ❌ 需物化后整体排序 | 排序计划作废,主表退化为全量读取 |
|
||||
|
||||
主表退化后的具体形态由 SELECT 列集合决定:
|
||||
|
||||
| SELECT 列 | 退化形态 | 特征 |
|
||||
|---|---|---|
|
||||
| 含索引外列(生产常态) | `type=ALL` 全表扫 | key=NULL |
|
||||
| 全在索引内 | `type=index` 全索引扫 | key=该索引,`Using index`(覆盖免回表) |
|
||||
|
||||
两种形态共同点:rows ≈ 全量、`Using temporary; Using filesort`、LIMIT 无法提前终止。
|
||||
|
||||
优化器的行为链条:
|
||||
|
||||
```
|
||||
被 join 表选了 hash join
|
||||
→ 结果集顺序不可保证
|
||||
→ ORDER BY 必须显式排序(temporary + filesort)
|
||||
→ "主表走排序专用索引"失去意义
|
||||
→ 主表改用最便宜的读取方式(ALL 或覆盖索引全扫)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、实践建议
|
||||
|
||||
1. **排查口诀**:`ORDER BY ... LIMIT` 查询变慢时,先看 EXPLAIN 里是否出现 `Using join buffer (hash join)`——它常常是主表索引"无故失效"的信号
|
||||
2. **判断退化看 rows 和 temporary/filesort**,不要只盯 type:`type=index` 也可能是全索引扫描的退化形态(对比正常时 rows 应接近 LIMIT 值)
|
||||
3. **根治**:给被 join 表的 join 列建索引(唯一列建唯一索引,非唯一建普通索引),让点查比 hash join 更便宜
|
||||
4. **空表/新表场景**(刚上线、还没数据的表):如果线上已出现该退化且影响明显,可用 `FORCE INDEX` 过渡;表有数据后可移除 hint(低频后台查询甚至可以不处理)
|
||||
5. **不要盲信"必须走索引"**:空表/极小表走全表扫本身是正确的成本决策,要修的是"hash join 破坏排序计划"这个副作用,而不是全表扫本身
|
||||
6. **改动 join 结构后**(新增关联表、调整关联条件)重新 EXPLAIN 一次带排序的查询,确认 `Backward index scan` / rows 接近 LIMIT 值的计划仍在
|
||||
|
||||
---
|
||||
|
||||
## 七、清理
|
||||
|
||||
```sql
|
||||
DROP TABLE IF EXISTS users, user_profiles, user_tags_empty, user_tags_noindex;
|
||||
SET SESSION cte_max_recursion_depth = DEFAULT;
|
||||
```
|
||||
@@ -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]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
## 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="user:password@tcp(127.0.0.1:3306)/database" --table="user_table" --dir="." -c --home D:/template/1.8.3
|
||||
|
||||
# mysql根据ddl生成
|
||||
goctl model mysql ddl --src h5.sql --dir . -c --home D:/template/1.8.3
|
||||
|
||||
# pgsql根据远程数据库表生成
|
||||
goctl model pg datasource -url "postgres://user:password@127.0.0.1:15432/database" -table user -dir ./user --cache --home D:/template/1.8.3
|
||||
|
||||
# pgsql根据远程数据库表生成,带模式(schema)
|
||||
goctl model pg datasource -url "postgres://user:password@127.0.0.1:25432/database?sslmode=disable" -s "users" -table "profile" -dir ./go-zero/model/users/ --cache --home ../../deploy/goctl
|
||||
```
|
||||
|
||||
> 有的时候会遇到`unsupported database type`的错误,如果需要支持该字段,需要在`goctl.yaml`文件添加数据库类型映射配置,并保证goctl环境变量`GOCTL_EXPERIMENTAL=on`开启。
|
||||
|
||||
|
||||
|
||||
## config
|
||||
|
||||
`goctl config`是用于对goctl静态配置文件做管理。
|
||||
|
||||
```
|
||||
$ goctl config --help
|
||||
Usage:
|
||||
goctl config [command]
|
||||
|
||||
Available Commands:
|
||||
clean Clean goctl config file
|
||||
init Initialize goctl config file
|
||||
|
||||
Flags:
|
||||
-h, --help help for config
|
||||
|
||||
|
||||
Use "goctl config [command] --help" for more information about a command.
|
||||
```
|
||||
|
||||
`goctl.yaml`文件字段
|
||||
|
||||
目前暂时仅支持对 model 数据类型映射的配置
|
||||
|
||||
| 字段名称 | 说明 |
|
||||
| --------------- | ------------------------------------------------------------ |
|
||||
| model | model 配置 |
|
||||
| model.types_map | model 配置之类型映射规则,是一个 map<string,obj> 结构,key 为数据库类型,value 为映射对象 |
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
```
|
||||
|
||||
> 一般来说,句点引入不推荐使用,因为它们会导致较低的代码可读性
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
```
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
> Go 1.24 起,`map` 的底层实现从传统的 `hmap` + `bmap` 哈希表,彻底切换到了 Swiss Table(瑞士表)
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
### 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,27 @@
|
||||
## `errors`
|
||||
|
||||
### `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
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
||||
## `net/http/pprof`
|
||||
@@ -0,0 +1,294 @@
|
||||
## 1.27版本变化
|
||||
|
||||
### 支持泛型方法
|
||||
|
||||
Go 1.27 虽然开放了泛型方法,但有明确的限制:
|
||||
|
||||
1.接口定义中不允许包含泛型方法。
|
||||
|
||||
2.拥有泛型方法的方法,不能用于实现接口。
|
||||
|
||||
```go
|
||||
// 普通结构体,本身没有任何泛型参数
|
||||
type Serializer struct{}
|
||||
|
||||
// T 属于方法 Serialize,每个调用可以独立拥有不同的 T
|
||||
func (s *Serializer) Serialize[T any](data T) string {
|
||||
return fmt.Sprintf("%v", data)
|
||||
}
|
||||
|
||||
// 使用:
|
||||
sz := Serializer{}
|
||||
|
||||
// 同一个 sz 实例,每次调用都可以处理完全不同的类型!
|
||||
str1 := sz.Serialize("hello") // 自动推导 T = string
|
||||
str2 := sz.Serialize(123) // 自动推导 T = int
|
||||
str3 := sz.Serialize(true) // 自动推导 T = bool
|
||||
```
|
||||
|
||||
### 结构体字面量:字段选择器当 key
|
||||
|
||||
嵌套或匿名结构体中的字段,现在可以直接初始化,不必再一层层嵌套字面量
|
||||
|
||||
```go
|
||||
type Person struct {
|
||||
Sex string
|
||||
Name string
|
||||
}
|
||||
type Student struct {
|
||||
StudentID int
|
||||
Person
|
||||
}
|
||||
|
||||
// Go 1.27 之前:必须显式嵌套 Person
|
||||
s1 := Student{
|
||||
StudentID: 1,
|
||||
Person: Person{
|
||||
Sex: "male",
|
||||
Name: "张三",
|
||||
},
|
||||
}
|
||||
// Go 1.27:嵌套结构体Person的字段可以直接当 key 用
|
||||
s1 := Student{
|
||||
StudentID: 1,
|
||||
Sex: "male",
|
||||
Name: "张三",
|
||||
}
|
||||
```
|
||||
|
||||
### 函数类型推断:从“手动挡”到“自动挡”
|
||||
|
||||
```go
|
||||
// Go 1.27 之前:必须手动写出类型实参 [int],否则编译报错
|
||||
fn := IntToString(FormatToString[int])
|
||||
formatter:= []IntToString{FormatToString[int]}
|
||||
|
||||
// Go 1.27 后:自动推导,不用手写
|
||||
fn := IntToString(FormatToString)
|
||||
formatter := []IntToString{FormatToString}
|
||||
```
|
||||
|
||||
### 标准库更新:`encoding/json/v2`
|
||||
|
||||
> Go 1.27 标准库中的 v1 `encoding/json` 内部已经被改用 v2 引擎驱动,保留了 v1 的原始 API 签名和行为
|
||||
|
||||
新旧版本主要行为对比
|
||||
|
||||
| **场景** | **encoding/json (v1)** | **encoding/json/v2** |
|
||||
| ------------------------------- | -------------------------- | -------------------------------------------- |
|
||||
| **`nil` 切片/Map 序列化** | 转为 `null` | 转为 `[]` 和 `{}` *(符合语义,防前端空指针)* |
|
||||
| **重复键处理 (Duplicate Keys)** | 允许,后面的覆盖前面的 | **直接报错** *(增强安全性,防 JSON 注入)* |
|
||||
| **非法 UTF-8 字符** | 格式化替换,静默忽略 | **直接报错** *(要求严格符合规范)* |
|
||||
| **字段名匹配 (大小写敏感)** | 默认**忽略**大小写匹配 | 默认**严格匹配**大小写 *(提升解析性能)* |
|
||||
| **固定长度数组解包** | 长度不一致时自动截断或补零 | 必须与 JSON 数组长度严格相等,否则报错 |
|
||||
|
||||
支持可选项
|
||||
|
||||
| Options | 作用 |
|
||||
| -------------------------------------- | ----------------------- |
|
||||
| `json.FormatNilSliceAsNull(true)` | 允许使用 v1 的 nil 行为 |
|
||||
| `json.MatchCaseInsensitiveNames(true)` | 允许忽略大小写 |
|
||||
|
||||
新增标签`omitzero`
|
||||
|
||||
| **维度** | **omitempty** | **omitzero** |
|
||||
| ----------------------- | ------------------------------------------------------------ | ------------------------------------------------------------ |
|
||||
| **判断标准** | **是否为“空 (Empty)”** (如:空字符串 `""`、长度为 0 的切片/Map、`false`、`0`、指针 `nil`) | **是否为“ Go 类型的零值 (Zero Value)”** (满足 `reflect.Value.IsZero()` 或实现 `IsZero() bool` 接口) |
|
||||
| **结构体/时间类型处理** | **有重大缺陷**,对非指针的结构体(如 `time.Time`)无效!依然会被序列化。 | **完美支持** 未初始化的 `time.Time{}` 或任何嵌套结构体零值均会被正确跳过。 |
|
||||
| **自定义类型零值** | 不支持自定义零值判断逻辑。 | 支持只要实现 `IsZero() bool` 方法即可自定义是否跳过。 |
|
||||
|
||||
|
||||
|
||||
## 官方扩展库 `bcrypt`
|
||||
|
||||
### `bcrypt` 是什么
|
||||
|
||||
`bcrypt` 是专门用于**密码哈希**的算法,不是可逆加密。
|
||||
|
||||
```text
|
||||
password
|
||||
↓
|
||||
bcrypt
|
||||
↓
|
||||
hash
|
||||
```
|
||||
|
||||
#### 核心原则
|
||||
|
||||
```text
|
||||
数据库只存 hash
|
||||
bcrypt 自动处理 salt
|
||||
使用 CompareHashAndPassword 验证
|
||||
cost 根据服务器性能决定
|
||||
不要自己用 MD5 / SHA256 存密码
|
||||
```
|
||||
|
||||
------
|
||||
|
||||
#### cost
|
||||
|
||||
`cost` 是 bcrypt 的计算难度。
|
||||
|
||||
生产环境通常可以从 `10 ~ 12` 开始测试,具体值应根据服务器性能决定。单次 bcrypt 计算控制在大约 `100ms ~ 300ms` 是比较常见的工程范围。
|
||||
|
||||
`cost` 每增加 1,计算量大约接近翻倍。
|
||||
|
||||
cost 越高:
|
||||
|
||||
- 计算越慢
|
||||
- 暴力破解成本越高
|
||||
- 服务器 CPU 消耗也越高
|
||||
|
||||
bcrypt 生成的 hash 本身会记录当时使用的 `cost`。
|
||||
|
||||
因此,即使生产环境将当前配置从:
|
||||
|
||||
```text
|
||||
cost = 10
|
||||
```
|
||||
|
||||
升级为:
|
||||
|
||||
```text
|
||||
cost = 12
|
||||
```
|
||||
|
||||
数据库中原本使用 `cost = 10` 生成的旧 hash,仍然可以通过:
|
||||
|
||||
```go
|
||||
bcrypt.CompareHashAndPassword(...)
|
||||
```
|
||||
|
||||
正常验证。
|
||||
|
||||
`CompareHashAndPassword` 会根据已有 hash 中记录的信息进行密码验证,不依赖当前系统配置的 cost。
|
||||
|
||||
登录成功后,可以通过 `bcrypt.Cost()` 获取旧 hash 使用的 cost。如果低于当前配置,可以重新生成 hash 并更新数据库。
|
||||
|
||||
------
|
||||
|
||||
#### 72-byte 限制
|
||||
|
||||
bcrypt 密码输入最多支持约 `72 bytes`。
|
||||
|
||||
如果只允许 ASCII:
|
||||
|
||||
```text
|
||||
72 bytes ≈ 72 个字符
|
||||
```
|
||||
|
||||
通常已经足够。
|
||||
|
||||
建议业务层主动限制:
|
||||
|
||||
```go
|
||||
if len([]byte(password)) > 72 {
|
||||
return errors.New("password too long")
|
||||
}
|
||||
```
|
||||
|
||||
------
|
||||
|
||||
### 基础使用
|
||||
|
||||
#### 依赖导入
|
||||
|
||||
```go
|
||||
import "golang.org/x/crypto/bcrypt"
|
||||
```
|
||||
|
||||
`golang.org/x/crypto/bcrypt` 属于 Go 官方维护的扩展库,但不属于 Go 标准库。
|
||||
|
||||
------
|
||||
|
||||
#### 生成 hash
|
||||
|
||||
```go
|
||||
hash, err := bcrypt.GenerateFromPassword(
|
||||
[]byte(password),
|
||||
12,
|
||||
)
|
||||
```
|
||||
|
||||
生成的 hash 类似:
|
||||
|
||||
```text
|
||||
$2a$12$xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
```
|
||||
|
||||
其中大致包含:
|
||||
|
||||
```text
|
||||
$2a$ → bcrypt 版本
|
||||
12$ → cost
|
||||
后续内容 → 编码后的 salt + hash
|
||||
```
|
||||
|
||||
bcrypt 会自动生成随机 salt,因此不需要自己生成或额外保存 salt。
|
||||
|
||||
------
|
||||
|
||||
#### 验证密码
|
||||
|
||||
```go
|
||||
err := bcrypt.CompareHashAndPassword(
|
||||
[]byte(passwordHash),
|
||||
[]byte(password),
|
||||
)
|
||||
|
||||
if err == nil {
|
||||
// 密码正确
|
||||
}
|
||||
```
|
||||
|
||||
------
|
||||
|
||||
#### 判断密码是否相同
|
||||
|
||||
不能直接比较两个 bcrypt hash,因为 bcrypt 每次都会生成新的随机 salt。
|
||||
|
||||
即使密码完全相同:
|
||||
|
||||
```text
|
||||
hash1 != hash2
|
||||
```
|
||||
|
||||
判断某个密码是否和旧密码相同时,应使用:
|
||||
|
||||
```go
|
||||
err := bcrypt.CompareHashAndPassword(
|
||||
[]byte(oldHash),
|
||||
[]byte(password),
|
||||
)
|
||||
|
||||
if err == nil {
|
||||
// password 与 oldHash 对应的密码相同
|
||||
}
|
||||
```
|
||||
|
||||
------
|
||||
|
||||
#### 获取 hash 的 cost
|
||||
|
||||
```go
|
||||
cost, err := bcrypt.Cost(
|
||||
[]byte(user.PasswordHash),
|
||||
)
|
||||
```
|
||||
|
||||
通常用于**用户登录成功后**,判断当前密码 hash 是否使用了旧的 cost。
|
||||
|
||||
例如:
|
||||
|
||||
```text
|
||||
数据库 hash cost = 10
|
||||
当前系统 cost = 12
|
||||
↓
|
||||
登录成功
|
||||
↓
|
||||
重新生成 cost = 12 的 hash
|
||||
↓
|
||||
更新数据库
|
||||
```
|
||||
|
||||
这样可以在用户正常登录过程中逐步升级旧密码 hash。
|
||||
@@ -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语句中会让大脑进行双重否定运算
|
||||
@@ -0,0 +1,45 @@
|
||||
# squirrel 的坑
|
||||
|
||||
> `github.com/Masterminds/squirrel`,纯 SQL 查询构建器(query builder),2026-09 查证。
|
||||
|
||||
## 坑 1:Like 家族多 key 不排序,SQL 输出不确定(真雷)
|
||||
|
||||
`Eq` / `NotEq` / `Lt` / `Gt` 等 map 条件生成 SQL 前会对 key 做 `sort.Strings` 字典序排序(v1.1.0 起,`expr.go` 的 `getSortedKeys`),输出确定。
|
||||
|
||||
但 `Like` / `NotLike` / `ILike` / `NotILike` 的 `toSql` 是直接 `for key, val := range lk`——跟随 Go map 随机遍历序,**多 key 时每次生成的 SQL 条件顺序随机**:
|
||||
|
||||
```
|
||||
第1次: WHERE nickname LIKE ? AND mobile LIKE ? | args=[%张% %138%]
|
||||
第3次: WHERE mobile LIKE ? AND nickname LIKE ? | args=[%138% %张%] ← 顺序翻转
|
||||
```
|
||||
|
||||
### 后果
|
||||
|
||||
- 查询结果没错(AND 交换律),但 **SQL 字符串 + args 顺序在变**
|
||||
- 如果用「SQL 字符串 + 参数」生成缓存 key(hash 后当 Redis key 之类),同一查询会打出多个 key:
|
||||
- 命中率暴跌:n 个 Like 条件最多 n! 种排列 → n! 个缓存 key
|
||||
- 同一份数据存多份副本,TTL 内占内存
|
||||
- 依赖 SQL diff 的单测会随机失败
|
||||
|
||||
### 规避
|
||||
|
||||
- `Like` 只放**单个 key**
|
||||
- 多模糊条件拆成多次 `.Where(...)`(内部是 append 切片,天然保序)或用 `sq.And{...}` 显式控制顺序
|
||||
|
||||
### 上游状态
|
||||
|
||||
- 无任何 issue 报告过此事(全量搜索过,含 closed),作者从未回应
|
||||
- 当年修 Eq 排序的 PR #141 只改了 Eq/Lt 两处,紧邻的 Like 一行未动,讨论里也没人发现——纯遗漏,非有意设计(作者在 #135 明确说过支持排序)
|
||||
- 库近乎停滞:最新 release v1.5.4(2023-03),master 最后 commit 2024-02,**别指望上游修**
|
||||
|
||||
## 坑 2:Eq 排序按字典序,与联合索引列序无关(知道即可)
|
||||
|
||||
`Eq{"b":2, "a":1}` 永远生成 `WHERE a = ? AND b = ?`,与你书写的顺序无关(issue #321)。
|
||||
|
||||
对主流数据库**基本无影响**:MySQL / PostgreSQL / SQL Server / Oracle 的 CBO 都会重排谓词,索引选择看的是「条件覆盖了哪些列(最左前缀)、选择度、类型是否隐式转换」,不是 WHERE 里的书写顺序。只有对谓词顺序敏感的引擎/老优化器才会踩到。
|
||||
|
||||
## 顺带:这个库的亮点
|
||||
|
||||
- builder 是**值语义不可变**的(基于 lann/builder 持久化数据结构),每次链式调用返回新副本,可安全分叉复用,不像 GORM 那样共享 Statement、分叉前要 `Session()` 克隆
|
||||
- `Eq` 家族的确定性输出有源码保证(`expr.go` 排序逻辑),可放心用于缓存 key 场景
|
||||
- 子查询:`FromSelect(builder, alias)`,或任何 builder 作为 `Sqlizer` 嵌进 `Where/Having/Join`
|
||||
@@ -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 提供文件流。
|
||||
@@ -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 登录等) |
|
||||
@@ -0,0 +1,61 @@
|
||||
# Casbin 学习笔记
|
||||
|
||||
> 版本基线:casbin **v3.11.0**;Go import 路径 `github.com/casbin/casbin/v3`(源码仓库 `apache/casbin`)。本文是概念速记,不是教程。
|
||||
> Casbin 负责**授权**(能不能做某事),不负责**身份认证**(你是谁)。
|
||||
|
||||
## 1. 心智模型:PERM
|
||||
|
||||
| 段 | 作用 |
|
||||
|---|---|
|
||||
| `[request_definition]` `r = sub, obj, act` | 一次访问请求长什么样 |
|
||||
| `[policy_definition]` `p = sub, obj, act` | 一条策略规则长什么样 |
|
||||
| `[policy_effect]` `e = some(where (p.eft == allow))` | 多条策略如何合成结论 |
|
||||
| `[matchers]` `m = g(r.sub, p.sub) && r.obj == p.obj && r.act == p.act` | `r` 与 `p` 如何配对 |
|
||||
|
||||
- `sub, obj, act` 只是最典型的字段,不是硬编码:可加 `dom`、资源属性,也可用对象做 ABAC。
|
||||
- RBAC 再加 `[role_definition] g = _,_`,表示主体与角色的继承关系;`g(a, b)` 意为“a 属于 b”,角色链会递归解析。
|
||||
- 模型只描述“怎么判”,策略才决定“允许谁”,两者可分开存储与更新。
|
||||
|
||||
## 2. 核心组件
|
||||
|
||||
Casbin 运行时由以下组件构成,多数可在不同程度上替换或选配:
|
||||
|
||||
| 组件 | 职责 |
|
||||
|---|---|
|
||||
| Model | PERM 四段组成的规则模型,描述匹配逻辑,不含具体策略 |
|
||||
| Policy | 具体策略规则的集合,可在内存或外部存储 |
|
||||
| Enforcer | 判定入口:把请求套进模型与策略,得到 true/false |
|
||||
| Adapter | 策略的存储读写层(DB/文件等),由应用选配 |
|
||||
| RoleManager | 维护角色继承关系,支撑 `g()` |
|
||||
| Watcher | 感知其他实例的变更并触发重载;Dispatcher 负责派发变更事件 |
|
||||
|
||||
组件协作:Model + Policy 交给 Enforcer 判定;Adapter 负责把 Policy 读入/写出;RoleManager 供 `g()` 解析继承;Watcher/Dispatcher 让多实例感知变更。
|
||||
|
||||
## 3. 判定链与策略示意
|
||||
|
||||
请求 → matcher 匹配 → 命中的策略 effect 合成 → 结果。
|
||||
|
||||
```
|
||||
g, alice, admin # alice 属于 admin
|
||||
p, admin, report, read # admin 可读 report
|
||||
Enforce(alice, report, read) => true # 经角色继承
|
||||
Enforce(bob, report, read) => false # 无授权
|
||||
```
|
||||
|
||||
- 判定按请求逐条扫描策略:matcher 命中后,由 effect 合成最终结论。
|
||||
- `some(where (p.eft == allow))` 是白名单语义:只要有一条 allow 就放行。
|
||||
|
||||
## 4. 关键提醒
|
||||
|
||||
- **deny 与 error 都 fail-closed**:`false` 是判定为拒,`err != nil` 是判定失败;安全场景两者都当拒绝,别把错误当放行。
|
||||
- **并发优先 `SyncedEnforcer`**:常用 API 有锁;普通 `Enforcer` 需自行加锁,也不要假设所有接口都并发安全。
|
||||
- **内存策略不会自动跨实例同步**:Casbin 核心不内置同步,多副本要自己配置 Watcher 通知或周期重载/对账。
|
||||
- **ABAC 能判断单条对象的属性/所有权,但不会自动生成 SQL 查询过滤**,列表与行级数据范围仍需业务层实现。
|
||||
- **迁移 v2 → v3**:只确认 adapter 自身路径带 `/v3` 不够,要核对其 `go.mod` 是否依赖 `github.com/casbin/casbin/v3`;v2 与 v3 的 `model.Model` 是不同 Go 包类型。
|
||||
- `Enforce` 的参数要与 `r` 定义一致,类型/语义由模型与 matcher 决定,不是固定三元组。
|
||||
|
||||
## 官方来源
|
||||
|
||||
- v3.0.0 release(module path 变更):<https://github.com/apache/casbin/releases/tag/v3.0.0>
|
||||
- v3.11.0 `persist/adapter.go`:<https://github.com/apache/casbin/blob/v3.11.0/persist/adapter.go>
|
||||
- ABAC 文档:<https://casbin.org/docs/abac>
|
||||
@@ -0,0 +1,5 @@
|
||||
opencode添加mcp
|
||||
|
||||
```bash
|
||||
opencode mcp add postgres -- npx -y @microsoft/postgres-mcp run
|
||||
```
|
||||
@@ -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); }
|
||||
|
||||
```
|
||||
@@ -0,0 +1,12 @@
|
||||
常见图片格式的核心区别,主要在于 **是否压缩、是否支持透明、是否支持矢量、文件大小、适合什么场景**
|
||||
|
||||
| 格式 | 类型 | 透明背景 | 动画 | 放大是否模糊 | 文件大小 | 常见用途 |
|
||||
| -------------- | ---- | ---------- | ------ | ------------ | -------- | ------------------------ |
|
||||
| **JPG / JPEG** | 位图 | ❌ | ❌ | ✅ 会模糊 | 小 | 照片、摄影、电商图 |
|
||||
| **PNG** | 位图 | ✅ | ❌ | ✅ 会模糊 | 中~大 | Logo、截图、透明图片、UI |
|
||||
| **SVG** | 矢量 | ✅ | 可实现 | ❌ 不模糊 | 通常很小 | Logo、图标、网页插画 |
|
||||
| **WebP** | 位图 | ✅ | ✅ | ✅ 会模糊 | 很小 | 网站图片、App 图片 |
|
||||
| **GIF** | 位图 | ✅但有限 | ✅ | ✅ 会模糊 | 通常较大 | 简单动图、表情包 |
|
||||
| **AVIF** | 位图 | ✅ | ✅ | ✅ 会模糊 | 很小 | 新一代网页图片 |
|
||||
| **BMP** | 位图 | 通常不支持 | ❌ | ✅ 会模糊 | 非常大 | 较少使用 |
|
||||
| **TIFF** | 位图 | 视情况 | ❌ | ✅ 会模糊 | 很大 | 印刷、扫描、专业摄影 |
|
||||
@@ -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搜索结果、屏蔽其广告、自动展开代码
|
||||
@@ -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 严格度,去掉 `--` 后面的参数即可。
|
||||
@@ -0,0 +1,5 @@
|
||||
.next/
|
||||
out/
|
||||
node_modules/
|
||||
coverage/
|
||||
next-env.d.ts
|
||||
@@ -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 -->
|
||||
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
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. 完成后再执行提交操作。
|
||||
@@ -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 后,用户可以立刻搜索、立刻访问常用网站,也可以通过侧边栏进入分类页面。页面具有个人氛围,但保持干净、高效、克制。
|
||||
@@ -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"]),
|
||||
])
|
||||
@@ -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.
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { NextConfig } from "next"
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "export",
|
||||
trailingSlash: true,
|
||||
images: {
|
||||
unoptimized: true,
|
||||
},
|
||||
}
|
||||
|
||||
export default nextConfig
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"name": "polaris",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@11.24.0",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"verify:export": "node scripts/verify-static-export.mjs",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
# Next.js Windows 静态导出补丁
|
||||
|
||||
## 原因与范围
|
||||
|
||||
`next@16.3.3` 的静态导出器将 `path.relative()` 产生的文件系统路径直接用于 RSC segment 文件名编码。Windows 的反斜杠不会被编码器转换为点号,导致浏览器请求扁平文件名、实际文件却落在嵌套目录。
|
||||
|
||||
- 上游问题:<https://github.com/vercel/next.js/issues/85374>
|
||||
- Windows 根因复现:<https://github.com/vercel/next.js/issues/92339>
|
||||
- 2026-09-13 核对:`16.3.5` 的相应导出逻辑仍未包含路径标准化。
|
||||
|
||||
补丁只修改导出器的 CommonJS / ESM 两份入口,在文件名编码前用系统路径分隔符拆分并以 `/` 连接。读取源文件仍使用原始文件系统路径。Linux/macOS 的 `/` 路径保持不变,浏览器运行时代码不变。
|
||||
|
||||
## 安装与验证
|
||||
|
||||
补丁通过 `pnpm-workspace.yaml` 的精确版本 `patchedDependencies` 和锁文件登记,正常安装会自动应用,无需手动编辑 `node_modules`。
|
||||
|
||||
```bash
|
||||
corepack pnpm install --frozen-lockfile
|
||||
corepack pnpm build
|
||||
corepack pnpm verify:export
|
||||
```
|
||||
|
||||
`verify:export` 只读取产物:检查首页、全部分类页和 404,并将每个源 RSC segment 与浏览器所需扁平路径下的导出文件逐字节比较。它不会复制、移动或重命名 `out/` 文件。部署前还需在真实静态服务器上验证预取和客户端导航。
|
||||
|
||||
## 后续升级
|
||||
|
||||
补丁仅绑定 `next@16.3.3`,不会自动迁移到其他版本。升级 Next.js 时必须重新检查上游导出实现、运行构建与 `verify:export`,再进行静态浏览器验收;不能仅以 `next build` 成功判断问题已修复。
|
||||
|
||||
上游修复并验收通过后,使用 pnpm 的补丁移除命令清理对应登记和补丁文件,更新锁文件,保留产物回归检查。
|
||||
@@ -0,0 +1,28 @@
|
||||
diff --git a/dist/esm/export/index.js b/dist/esm/export/index.js
|
||||
index 6c35f4f47f704caea869a4f89721e0fc71f7f458..4c4e25fec3d1aecb439cea4994f8747ea68df0fe 100644
|
||||
--- a/dist/esm/export/index.js
|
||||
+++ b/dist/esm/export/index.js
|
||||
@@ -717,7 +717,8 @@ async function exportAppImpl(dir, options, span, staticWorker) {
|
||||
const segmentsDirDest = join(outDir, unnormalizedRoute);
|
||||
const segmentPaths = await collectSegmentPaths(segmentsDir);
|
||||
await Promise.all(segmentPaths.map(async (segmentFileSrc)=>{
|
||||
- const segmentPath = '/' + segmentFileSrc.slice(0, -RSC_SEGMENT_SUFFIX.length);
|
||||
+ // Windows 文件系统路径先转为 URL 路径,避免 RSC 文件被导出为嵌套目录。
|
||||
+ const segmentPath = '/' + segmentFileSrc.split(sep).join('/').slice(0, -RSC_SEGMENT_SUFFIX.length);
|
||||
const segmentFilename = convertSegmentPathToStaticExportFilename(segmentPath);
|
||||
const segmentFileDest = join(segmentsDirDest, segmentFilename);
|
||||
await fs.mkdir(dirname(segmentFileDest), {
|
||||
diff --git a/dist/export/index.js b/dist/export/index.js
|
||||
index a19941bec99b5c5673834872eaf8a95deb584a34..b7492b7ba76823380b0013bd1c08527388c82ed7 100644
|
||||
--- a/dist/export/index.js
|
||||
+++ b/dist/export/index.js
|
||||
@@ -785,7 +785,8 @@ async function exportAppImpl(dir, options, span, staticWorker) {
|
||||
const segmentsDirDest = (0, _path.join)(outDir, unnormalizedRoute);
|
||||
const segmentPaths = await collectSegmentPaths(segmentsDir);
|
||||
await Promise.all(segmentPaths.map(async (segmentFileSrc)=>{
|
||||
- const segmentPath = '/' + segmentFileSrc.slice(0, -_constants.RSC_SEGMENT_SUFFIX.length);
|
||||
+ // Windows 文件系统路径先转为 URL 路径,避免 RSC 文件被导出为嵌套目录。
|
||||
+ const segmentPath = '/' + segmentFileSrc.split(_path.sep).join('/').slice(0, -_constants.RSC_SEGMENT_SUFFIX.length);
|
||||
const segmentFilename = (0, _segmentvalueencoding.convertSegmentPathToStaticExportFilename)(segmentPath);
|
||||
const segmentFileDest = (0, _path.join)(segmentsDirDest, segmentFilename);
|
||||
await _fs.promises.mkdir((0, _path.dirname)(segmentFileDest), {
|
||||
@@ -0,0 +1,8 @@
|
||||
packages:
|
||||
- "."
|
||||
|
||||
allowBuilds:
|
||||
unrs-resolver: true
|
||||
|
||||
patchedDependencies:
|
||||
next@16.3.3: patches/next@16.3.3.patch
|
||||
@@ -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
|
||||
|
After Width: | Height: | Size: 3.6 MiB |
@@ -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 |
@@ -0,0 +1,46 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { readFile, readdir } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
const projectDir = fileURLToPath(new URL("../", import.meta.url))
|
||||
const appDir = path.join(projectDir, ".next/server/app")
|
||||
const outDir = path.join(projectDir, "out")
|
||||
const categories = JSON.parse(
|
||||
await readFile(path.join(projectDir, "src/data/categories.json"), "utf8"),
|
||||
)
|
||||
const routes = ["/", ...categories.map(({ id }) => `/categories/${id}`)]
|
||||
let checkedSegments = 0
|
||||
|
||||
// 只读构建产物:验证浏览器需要的扁平 URL,绝不修补或复制 out/ 文件。
|
||||
for (const route of routes) {
|
||||
const routeDir = path.join(outDir, route)
|
||||
assert((await readFile(path.join(routeDir, "index.html"))).length > 0)
|
||||
assert((await readFile(path.join(routeDir, "index.txt"))).length > 0)
|
||||
|
||||
const segmentsDir = path.join(
|
||||
appDir,
|
||||
`${route === "/" ? "index" : route}.segments`,
|
||||
)
|
||||
const files = (await readdir(segmentsDir, { recursive: true })).filter(
|
||||
(file) => file.endsWith(".segment.rsc"),
|
||||
)
|
||||
assert(files.length > 0, `${route} 缺少 RSC segment 构建产物`)
|
||||
|
||||
for (const file of files) {
|
||||
const segment = file
|
||||
.replaceAll("\\", "/")
|
||||
.slice(0, -".segment.rsc".length)
|
||||
.replaceAll("/", ".")
|
||||
const filename = `__next.${segment}.txt`
|
||||
const source = await readFile(path.join(segmentsDir, file))
|
||||
const exported = await readFile(path.join(routeDir, filename))
|
||||
assert.deepEqual(exported, source, `${route}/${filename} 内容不一致`)
|
||||
checkedSegments += 1
|
||||
}
|
||||
}
|
||||
|
||||
assert((await readFile(path.join(outDir, "404.html"))).length > 0)
|
||||
console.log(
|
||||
`静态导出检查通过:${routes.length} 个页面、${checkedSegments} 个 RSC 文件及 404.html。`,
|
||||
)
|
||||
@@ -0,0 +1,50 @@
|
||||
import { render, screen } from "@testing-library/react"
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import CategoryPage, { dynamicParams, generateStaticParams } from "./page"
|
||||
|
||||
describe("CategoryPage", () => {
|
||||
it("为全部 7 个分类生成静态参数并关闭运行时动态参数", () => {
|
||||
expect(generateStaticParams()).toEqual([
|
||||
{ categoryId: "ai" },
|
||||
{ categoryId: "dev" },
|
||||
{ categoryId: "design" },
|
||||
{ categoryId: "learning" },
|
||||
{ categoryId: "efficiency" },
|
||||
{ categoryId: "media" },
|
||||
{ categoryId: "life" },
|
||||
])
|
||||
expect(dynamicParams).toBe(false)
|
||||
})
|
||||
|
||||
it("通过 service 组合开发工具分类页", async () => {
|
||||
const page = await CategoryPage({
|
||||
params: Promise.resolve({ categoryId: "dev" }),
|
||||
})
|
||||
const { container } = render(page)
|
||||
|
||||
expect(
|
||||
screen.getByRole("heading", { level: 1, name: "开发工具" }),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText("共 15 个")).toBeInTheDocument()
|
||||
expect(container.querySelectorAll(".tool-card")).toHaveLength(15)
|
||||
expect(screen.getByRole("link", { name: "开发工具" })).toHaveAttribute(
|
||||
"aria-current",
|
||||
"page",
|
||||
)
|
||||
expect(screen.queryByRole("searchbox")).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("通过同一路由组合空分类页", async () => {
|
||||
const page = await CategoryPage({
|
||||
params: Promise.resolve({ categoryId: "life" }),
|
||||
})
|
||||
render(page)
|
||||
|
||||
expect(
|
||||
screen.getByRole("heading", { level: 1, name: "生活服务" }),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText("共 0 个")).toBeInTheDocument()
|
||||
expect(screen.getByText("暂无工具")).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import { notFound } from "next/navigation"
|
||||
|
||||
import { CategoryContent } from "@/features/category/components/category-content"
|
||||
import { AppShell } from "@/features/navigation/components/app-shell"
|
||||
import { getCategories, getCategoryById } from "@/services/categories"
|
||||
import { getLinksByCategory } from "@/services/links"
|
||||
import { getSettings } from "@/services/settings"
|
||||
|
||||
type CategoryPageProps = {
|
||||
params: Promise<{ categoryId: string }>
|
||||
}
|
||||
|
||||
// 静态导出不支持运行时补生成动态路径,未知分类统一返回 404。
|
||||
export const dynamicParams = false
|
||||
|
||||
export function generateStaticParams() {
|
||||
return getCategories().map((category) => ({
|
||||
categoryId: category.id,
|
||||
}))
|
||||
}
|
||||
|
||||
export default async function CategoryPage({ params }: CategoryPageProps) {
|
||||
const { categoryId } = await params
|
||||
const category = getCategoryById(categoryId)
|
||||
|
||||
if (!category) notFound()
|
||||
|
||||
const categories = getCategories()
|
||||
const links = getLinksByCategory(category.id)
|
||||
const settings = getSettings()
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
categories={categories}
|
||||
siteName={settings.siteName}
|
||||
defaultCollapsed={settings.sidebar.defaultCollapsed}
|
||||
activeCategoryId={category.id}
|
||||
>
|
||||
<CategoryContent category={category} links={links} />
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,830 @@
|
||||
@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:hover {
|
||||
color: var(--primary-hover);
|
||||
background: var(--primary-soft);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.category-page {
|
||||
min-height: 100svh;
|
||||
padding: 20px 20px 20px 0;
|
||||
}
|
||||
|
||||
.category-page__panel {
|
||||
min-height: calc(100svh - 40px);
|
||||
padding: 48px clamp(32px, 4vw, 64px) 56px;
|
||||
background: var(--surface-sidebar);
|
||||
box-shadow: var(--shadow-panel);
|
||||
-webkit-backdrop-filter: blur(18px) saturate(132%);
|
||||
backdrop-filter: blur(18px) saturate(132%);
|
||||
}
|
||||
|
||||
.category-header {
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.category-header__title {
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
font-size: clamp(36px, 3.4vw, 44px);
|
||||
font-weight: 700;
|
||||
line-height: 1.15;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.category-header__description {
|
||||
margin: 12px 0 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 16px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.category-tools {
|
||||
margin-top: 36px;
|
||||
}
|
||||
|
||||
.category-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.category-section-header__title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.category-section-header__count {
|
||||
padding: 4px 10px;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.24);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.tool-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
height: 96px;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 20px 22px;
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
border: 1px solid var(--border-glass);
|
||||
border-radius: 18px;
|
||||
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;
|
||||
}
|
||||
|
||||
.tool-card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(255, 255, 255, 0.54);
|
||||
background: var(--surface-card-hover);
|
||||
}
|
||||
|
||||
.tool-card__icon {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.tool-card__copy {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.tool-card__title,
|
||||
.tool-card__description {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tool-card__title {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tool-card__description {
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.tool-card__external {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex: none;
|
||||
align-self: flex-start;
|
||||
color: var(--text-secondary);
|
||||
opacity: 0.72;
|
||||
transition:
|
||||
color 180ms ease,
|
||||
opacity 180ms ease;
|
||||
}
|
||||
|
||||
.tool-card:hover .tool-card__external {
|
||||
color: var(--primary-hover);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tool-grid {
|
||||
display: grid;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 20px 24px;
|
||||
}
|
||||
|
||||
.tool-grid__item {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.category-empty-state {
|
||||
display: flex;
|
||||
min-height: 280px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.category-empty-state > svg {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.category-empty-state__title {
|
||||
margin: 16px 0 0;
|
||||
color: var(--text-primary);
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.category-empty-state__description {
|
||||
margin: 6px 0 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.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 (min-width: 1280px) {
|
||||
.tool-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||