113
.github/workflows/del-esa-code.yml
vendored
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
name: Clean ESA Versions on Main
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main ]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
clean-esa-versions:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# 下载阿里云CLI https://help.aliyun.com/zh/cli/
|
||||||
|
- name: Download Aliyun Cli
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
/bin/bash -c "$(curl -fsSL https://aliyuncli.alicdn.com/install.sh)"
|
||||||
|
|
||||||
|
# 配置阿里云CLI
|
||||||
|
- name: Configure Aliyun CLI
|
||||||
|
run: |
|
||||||
|
aliyun configure set \
|
||||||
|
--profile AkProfile \
|
||||||
|
--mode AK \
|
||||||
|
--access-key-id ${{ secrets.ALIYUN_ACCESS_KEY_ID }} \
|
||||||
|
--access-key-secret ${{ secrets.ALIYUN_ACCESS_KEY_SECRET }} \
|
||||||
|
--region "cn-hangzhou"
|
||||||
|
|
||||||
|
# 获取Pages名称(可配置)
|
||||||
|
- name: Get Pages Name
|
||||||
|
id: get-pages-name
|
||||||
|
run: |
|
||||||
|
# 从配置中读取Pages名称,这里使用默认值blog,可根据需要修改
|
||||||
|
echo "pages_name=fuwari" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
# 获取所有代码版本并清理旧版本
|
||||||
|
- name: Clean ESA Code Versions
|
||||||
|
env:
|
||||||
|
PAGES_NAME: ${{ steps.get-pages-name.outputs.pages_name }}
|
||||||
|
# 配置保留的版本数,默认为1(只保留最新版本)
|
||||||
|
RETAIN_VERSIONS: ${{ vars.RETAIN_VERSIONS || '1' }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
echo "开始清理阿里云ESA版本..."
|
||||||
|
echo "目标Pages: $PAGES_NAME"
|
||||||
|
echo "保留版本数: $RETAIN_VERSIONS"
|
||||||
|
|
||||||
|
# 获取所有版本信息
|
||||||
|
VERSIONS_JSON=$(aliyun esa ListRoutineCodeVersions --region cn-hangzhou --Name $PAGES_NAME)
|
||||||
|
|
||||||
|
# 解析版本数量
|
||||||
|
TOTAL_COUNT=$(echo $VERSIONS_JSON | jq -r '.TotalCount')
|
||||||
|
echo "当前共有 $TOTAL_COUNT 个版本"
|
||||||
|
|
||||||
|
# 如果版本数不超过保留数量,不需要清理
|
||||||
|
if [ "$TOTAL_COUNT" -le "$RETAIN_VERSIONS" ]; then
|
||||||
|
echo "版本数 ($TOTAL_COUNT) 未超过保留数量 ($RETAIN_VERSIONS),无需清理"
|
||||||
|
echo "ESA会自动检测main分支更新并部署"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 计算需要删除的版本数(保留指定数量的最新版本)
|
||||||
|
DELETE_COUNT=$((TOTAL_COUNT - RETAIN_VERSIONS))
|
||||||
|
echo "需要删除 $DELETE_COUNT 个旧版本,保留最新的 $RETAIN_VERSIONS 个版本"
|
||||||
|
|
||||||
|
# 解析版本列表,按创建时间排序(最早的在前)
|
||||||
|
VERSIONS_TO_DELETE=$(echo $VERSIONS_JSON | jq -r '.CodeVersions | sort_by(.CreateTime) | .[0:'$DELETE_COUNT'] | .[].CodeVersion')
|
||||||
|
|
||||||
|
echo "将要删除的版本: $VERSIONS_TO_DELETE"
|
||||||
|
|
||||||
|
# 删除旧版本
|
||||||
|
DELETED_COUNT=0
|
||||||
|
for VERSION in $VERSIONS_TO_DELETE; do
|
||||||
|
echo "正在删除版本: $VERSION"
|
||||||
|
DELETE_RESULT=$(aliyun esa DeleteRoutineCodeVersion --region cn-hangzhou --Name $PAGES_NAME --CodeVersion $VERSION)
|
||||||
|
STATUS=$(echo $DELETE_RESULT | jq -r '.Status')
|
||||||
|
|
||||||
|
if [ "$STATUS" = "OK" ]; then
|
||||||
|
echo "版本 $VERSION 删除成功"
|
||||||
|
DELETED_COUNT=$((DELETED_COUNT + 1))
|
||||||
|
else
|
||||||
|
echo "版本 $VERSION 删除失败: $DELETE_RESULT"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "版本清理完成!成功删除 $DELETED_COUNT 个旧版本"
|
||||||
|
|
||||||
|
# 验证清理结果
|
||||||
|
- name: Verify Clean Result
|
||||||
|
env:
|
||||||
|
PAGES_NAME: ${{ steps.get-pages-name.outputs.pages_name }}
|
||||||
|
RETAIN_VERSIONS: ${{ vars.RETAIN_VERSIONS || '1' }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
echo "验证清理结果..."
|
||||||
|
RESULT_JSON=$(aliyun esa ListRoutineCodeVersions --region cn-hangzhou --Name $PAGES_NAME)
|
||||||
|
REMAINING_COUNT=$(echo $RESULT_JSON | jq -r '.TotalCount')
|
||||||
|
|
||||||
|
echo "清理后剩余版本数: $REMAINING_COUNT"
|
||||||
|
|
||||||
|
if [ "$REMAINING_COUNT" -le "$RETAIN_VERSIONS" ]; then
|
||||||
|
echo "版本清理成功,当前版本数: $REMAINING_COUNT (保留设置: $RETAIN_VERSIONS)"
|
||||||
|
echo "ESA会自动检测main分支更新并开始部署"
|
||||||
|
else
|
||||||
|
echo "版本清理失败,仍有 $REMAINING_COUNT 个版本,期望保留: $RETAIN_VERSIONS"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
27
.github/workflows/deploy.yml-nouse
vendored
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
name: Build and Deploy
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
jobs:
|
||||||
|
build-and-deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Deploy to page branch
|
||||||
|
uses: peaceiris/actions-gh-pages@v3
|
||||||
|
with:
|
||||||
|
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
publish_dir: ./dist
|
||||||
|
publish_branch: page
|
||||||
|
cname: ${{ secrets.CNAME }}
|
||||||
32
.gitignore
vendored
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# build output
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# generated types
|
||||||
|
.astro/
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# logs
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# environment variables
|
||||||
|
.env
|
||||||
|
.env.production
|
||||||
|
|
||||||
|
# macOS-specific files
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
package-lock.json
|
||||||
|
bun.lockb
|
||||||
|
yarn.lock
|
||||||
|
|
||||||
|
.vscode
|
||||||
|
|
||||||
|
# 忽略上游仓库
|
||||||
|
ori
|
||||||
1
.obsidian/app.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
1
.obsidian/appearance.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
32
.obsidian/core-plugins.json
vendored
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"file-explorer": true,
|
||||||
|
"global-search": true,
|
||||||
|
"switcher": true,
|
||||||
|
"graph": true,
|
||||||
|
"backlink": true,
|
||||||
|
"canvas": true,
|
||||||
|
"outgoing-link": true,
|
||||||
|
"tag-pane": true,
|
||||||
|
"footnotes": false,
|
||||||
|
"properties": true,
|
||||||
|
"page-preview": true,
|
||||||
|
"daily-notes": true,
|
||||||
|
"templates": true,
|
||||||
|
"note-composer": true,
|
||||||
|
"command-palette": true,
|
||||||
|
"slash-command": false,
|
||||||
|
"editor-status": true,
|
||||||
|
"bookmarks": true,
|
||||||
|
"markdown-importer": false,
|
||||||
|
"zk-prefixer": false,
|
||||||
|
"random-note": false,
|
||||||
|
"outline": true,
|
||||||
|
"word-count": true,
|
||||||
|
"slides": false,
|
||||||
|
"audio-recorder": false,
|
||||||
|
"workspaces": false,
|
||||||
|
"file-recovery": true,
|
||||||
|
"publish": false,
|
||||||
|
"sync": true,
|
||||||
|
"bases": true
|
||||||
|
}
|
||||||
166
.obsidian/workspace-mobile.json
vendored
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
{
|
||||||
|
"main": {
|
||||||
|
"id": "29fed73056d4c788",
|
||||||
|
"type": "split",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "fa71f00e822e8432",
|
||||||
|
"type": "tabs",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "ca9029e77020d44e",
|
||||||
|
"type": "leaf",
|
||||||
|
"state": {
|
||||||
|
"type": "empty",
|
||||||
|
"state": {},
|
||||||
|
"icon": "lucide-file",
|
||||||
|
"title": "New tab"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"direction": "vertical"
|
||||||
|
},
|
||||||
|
"left": {
|
||||||
|
"id": "c058602dcce78a26",
|
||||||
|
"type": "mobile-drawer",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "d3fd5617bfecca06",
|
||||||
|
"type": "leaf",
|
||||||
|
"state": {
|
||||||
|
"type": "file-explorer",
|
||||||
|
"state": {
|
||||||
|
"sortOrder": "alphabetical",
|
||||||
|
"autoReveal": false
|
||||||
|
},
|
||||||
|
"icon": "lucide-folder-closed",
|
||||||
|
"title": "Files"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "4d4379f2270ea3e0",
|
||||||
|
"type": "leaf",
|
||||||
|
"state": {
|
||||||
|
"type": "search",
|
||||||
|
"state": {
|
||||||
|
"query": "",
|
||||||
|
"matchingCase": false,
|
||||||
|
"explainSearch": false,
|
||||||
|
"collapseAll": false,
|
||||||
|
"extraContext": false,
|
||||||
|
"sortOrder": "alphabetical"
|
||||||
|
},
|
||||||
|
"icon": "lucide-search",
|
||||||
|
"title": "Search"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "d99abf483fcdcb1a",
|
||||||
|
"type": "leaf",
|
||||||
|
"state": {
|
||||||
|
"type": "tag",
|
||||||
|
"state": {
|
||||||
|
"sortOrder": "frequency",
|
||||||
|
"useHierarchy": true,
|
||||||
|
"showSearch": false,
|
||||||
|
"searchQuery": ""
|
||||||
|
},
|
||||||
|
"icon": "lucide-tags",
|
||||||
|
"title": "Tags"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e546445f9f92dc38",
|
||||||
|
"type": "leaf",
|
||||||
|
"state": {
|
||||||
|
"type": "all-properties",
|
||||||
|
"state": {
|
||||||
|
"sortOrder": "frequency",
|
||||||
|
"showSearch": false,
|
||||||
|
"searchQuery": ""
|
||||||
|
},
|
||||||
|
"icon": "lucide-archive",
|
||||||
|
"title": "All properties"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e6c7e20438cddc79",
|
||||||
|
"type": "leaf",
|
||||||
|
"state": {
|
||||||
|
"type": "bookmarks",
|
||||||
|
"state": {},
|
||||||
|
"icon": "lucide-bookmark",
|
||||||
|
"title": "Bookmarks"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"currentTab": 0
|
||||||
|
},
|
||||||
|
"right": {
|
||||||
|
"id": "86a48de8a457f6a9",
|
||||||
|
"type": "mobile-drawer",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "35be497b23adb2a9",
|
||||||
|
"type": "leaf",
|
||||||
|
"state": {
|
||||||
|
"type": "backlink",
|
||||||
|
"state": {
|
||||||
|
"collapseAll": false,
|
||||||
|
"extraContext": false,
|
||||||
|
"sortOrder": "alphabetical",
|
||||||
|
"showSearch": false,
|
||||||
|
"searchQuery": "",
|
||||||
|
"backlinkCollapsed": false,
|
||||||
|
"unlinkedCollapsed": true
|
||||||
|
},
|
||||||
|
"icon": "links-coming-in",
|
||||||
|
"title": "Backlinks"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "612fb695f1f31b4f",
|
||||||
|
"type": "leaf",
|
||||||
|
"state": {
|
||||||
|
"type": "outgoing-link",
|
||||||
|
"state": {
|
||||||
|
"linksCollapsed": false,
|
||||||
|
"unlinkedCollapsed": true
|
||||||
|
},
|
||||||
|
"icon": "links-going-out",
|
||||||
|
"title": "Outgoing links"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "1d0fadefe7de7b83",
|
||||||
|
"type": "leaf",
|
||||||
|
"state": {
|
||||||
|
"type": "outline",
|
||||||
|
"state": {
|
||||||
|
"followCursor": false,
|
||||||
|
"showSearch": false,
|
||||||
|
"searchQuery": ""
|
||||||
|
},
|
||||||
|
"icon": "lucide-list",
|
||||||
|
"title": "Outline"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"currentTab": 0
|
||||||
|
},
|
||||||
|
"left-ribbon": {
|
||||||
|
"hiddenItems": {
|
||||||
|
"switcher:Open quick switcher": false,
|
||||||
|
"graph:Open graph view": false,
|
||||||
|
"canvas:Create new canvas": false,
|
||||||
|
"daily-notes:Open today's daily note": false,
|
||||||
|
"templates:Insert template": false,
|
||||||
|
"command-palette:Open command palette": false,
|
||||||
|
"bases:Create new base": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"active": "ca9029e77020d44e",
|
||||||
|
"lastOpenFiles": []
|
||||||
|
}
|
||||||
136
CLAUDE.md
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
**Fuwari** is a personal blog theme built with Astro 5.7, customized for AcoFork. It's a static site generator focused on technical blogging with Chinese language support.
|
||||||
|
|
||||||
|
## Common Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies (uses pnpm)
|
||||||
|
pnpm install
|
||||||
|
|
||||||
|
# Development server
|
||||||
|
pnpm dev # or pnpm start
|
||||||
|
|
||||||
|
# Build for production
|
||||||
|
pnpm build
|
||||||
|
|
||||||
|
# Preview production build
|
||||||
|
pnpm preview
|
||||||
|
|
||||||
|
# Type checking
|
||||||
|
pnpm type-check
|
||||||
|
|
||||||
|
# Create new blog post (creates file in src/content/posts/)
|
||||||
|
pnpm new-post <title>
|
||||||
|
|
||||||
|
# Clean unused images from src/content/assets/
|
||||||
|
pnpm clean
|
||||||
|
|
||||||
|
# Format code (uses Biome)
|
||||||
|
pnpm format
|
||||||
|
|
||||||
|
# Lint and fix code (uses Biome)
|
||||||
|
pnpm lint
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Framework & Build System
|
||||||
|
- **Astro 5.7** - Static site generator with component islands architecture
|
||||||
|
- **Vite** - Build tool (configured via astro.config.mjs)
|
||||||
|
- **Output**: Static HTML/CSS/JS to `dist/` directory
|
||||||
|
|
||||||
|
### Styling Architecture
|
||||||
|
- **Tailwind CSS** - Utility-first CSS with Nesting plugin
|
||||||
|
- **Stylus** - Preprocessor for global styles and CSS variables
|
||||||
|
- **Theme System**: HSL-based color system with configurable hue in `src/config.ts`
|
||||||
|
- Uses CSS custom properties defined in `src/styles/variables.styl`
|
||||||
|
- Dark/light mode toggle via `data-theme` attribute
|
||||||
|
|
||||||
|
### Component Architecture
|
||||||
|
- **Svelte 5** - Interactive components (`.svelte` files in `src/components/`)
|
||||||
|
- **Astro components** - Static components (`.astro` files)
|
||||||
|
- **Layout hierarchy**: `Layout.astro` (root) → page-specific layouts
|
||||||
|
|
||||||
|
### Content System
|
||||||
|
- **Blog posts**: `src/content/posts/*.md` with frontmatter schema validation
|
||||||
|
- **Assets**: `src/content/assets/` for images/media
|
||||||
|
- **Content collections**: Defined in Astro config with TypeScript schemas
|
||||||
|
|
||||||
|
### Plugin System (src/plugins/)
|
||||||
|
Custom remark/rehype plugins for Markdown processing:
|
||||||
|
- `remark-reading-time.mjs` - Calculates reading time
|
||||||
|
- `remark-excerpt.js` - Generates post excerpts
|
||||||
|
- `remark-directive-rehype.js` - Parses custom directives
|
||||||
|
- `rehype-component-admonition.mjs` - GitHub-style callouts (note, tip, warning, etc.)
|
||||||
|
- `rehype-component-github-card.mjs` - GitHub repo embedding
|
||||||
|
- `rehype-image-fallback.mjs` - Image fallback on failure
|
||||||
|
- `expressive-code/custom-copy-button.ts` - Custom code block copy button
|
||||||
|
|
||||||
|
## Configuration Files
|
||||||
|
|
||||||
|
- **`src/config.ts`** - Main site configuration
|
||||||
|
- Site metadata (title, description, lang)
|
||||||
|
- Theme colors (hue, dark mode)
|
||||||
|
- Navigation links
|
||||||
|
- Profile info
|
||||||
|
- Banner/background settings
|
||||||
|
- License, analytics, edit links
|
||||||
|
|
||||||
|
- **`astro.config.mjs`** - Build configuration
|
||||||
|
- Integrations (Tailwind, Svelte, Swup, Sitemap, Icon)
|
||||||
|
- Markdown remark/rehype plugins pipeline
|
||||||
|
- Expressive Code settings
|
||||||
|
- Redirects
|
||||||
|
- Image service (passthrough mode)
|
||||||
|
|
||||||
|
- **`tailwind.config.cjs`** - Tailwind configuration
|
||||||
|
- Dark mode (`class` strategy)
|
||||||
|
- Typography plugin
|
||||||
|
- Font family extensions
|
||||||
|
|
||||||
|
## Key Integrations
|
||||||
|
|
||||||
|
- **Swup** - Page transitions with SPA-like navigation (configured containers: `main`, `#toc`)
|
||||||
|
- **astro-icon** - Icon system with multiple icon sets (FontAwesome, Simple Icons, Material Symbols)
|
||||||
|
- **@astrojs/sitemap** - Automatic sitemap generation
|
||||||
|
- **Expressive Code** - Code highlighting with GitHub dark theme, collapsible sections, line numbers
|
||||||
|
|
||||||
|
## Code Quality
|
||||||
|
|
||||||
|
- **Biome** - Primary linter and formatter (replaces ESLint/Prettier)
|
||||||
|
- **TypeScript** - Strict mode enabled, path aliases configured
|
||||||
|
- **Type checking**: `pnpm type-check` uses `tsc --noEmit --isolatedDeclarations`
|
||||||
|
|
||||||
|
## Content Frontmatter Schema
|
||||||
|
|
||||||
|
Posts use this frontmatter format:
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
title: Post Title
|
||||||
|
published: 2024-01-01T12:00:00
|
||||||
|
description: Post description
|
||||||
|
image: ./cover.jpg # Relative to post
|
||||||
|
tags: [tag1, tag2]
|
||||||
|
category: Category
|
||||||
|
draft: false
|
||||||
|
lang: zh_CN # Optional, overrides site default
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
## Custom Markdown Features
|
||||||
|
|
||||||
|
- **GitHub Admonitions**: `> [!NOTE]`, `> [!WARNING]`, etc.
|
||||||
|
- **Math**: KaTeX support via `$...$` (inline) and `$$...$$` (block)
|
||||||
|
- **Custom directives**: `:::github[repo](url)` for repo cards
|
||||||
|
- **Auto TOC**: Generated from headings (configurable depth in `siteConfig.toc.depth`)
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
- **Static site**: Builds to `dist/` directory
|
||||||
|
- **CI/CD**: GitHub Actions for Alibaba Cloud ESA deployment
|
||||||
|
- **Redirects**: Configured in `astro.config.mjs` redirects section
|
||||||
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2024 saicaca
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
179
README.md
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
# Fuwari For AcoFork
|
||||||
|
|
||||||
|
> [!CAUTION]
|
||||||
|
> 该仓库由 AcoFork 深度定制,并包含了最新的文章,如果你想以此为模板进行二改,需要一定的动手能力。
|
||||||
|
|
||||||
|
<img width="1858" height="948" alt="image" src="https://github.com/user-attachments/assets/55c2c63b-0dac-436e-aaa0-451ad2dfb65a" />
|
||||||
|
|
||||||
|
|
||||||
|
一个基于 Astro 构建的现代化个人博客主题,专注于技术分享与实践。
|
||||||
|
|
||||||
|
## ✨ 特性
|
||||||
|
|
||||||
|
- 🚀 基于 Astro 4.0+ 构建,性能卓越
|
||||||
|
- 📱 完全响应式设计,支持移动端
|
||||||
|
- 🌙 支持深色/浅色主题切换
|
||||||
|
- 📝 支持 Markdown 和 MDX 格式
|
||||||
|
- 🔍 内置搜索功能
|
||||||
|
- 📊 文章阅读时间统计
|
||||||
|
- 🏷️ 标签和分类系统
|
||||||
|
- 📈 SEO 优化
|
||||||
|
- 🎨 可自定义配置
|
||||||
|
- 💬 评论系统支持
|
||||||
|
- 📡 RSS 订阅支持
|
||||||
|
|
||||||
|
## 🛠️ 技术栈
|
||||||
|
|
||||||
|
- **框架**: Astro
|
||||||
|
- **样式**: Tailwind CSS + Stylus
|
||||||
|
- **交互**: Svelte
|
||||||
|
- **构建工具**: Vite
|
||||||
|
- **包管理**: pnpm
|
||||||
|
- **代码规范**: Biome
|
||||||
|
|
||||||
|
## 🚀 快速开始
|
||||||
|
|
||||||
|
### 环境要求
|
||||||
|
|
||||||
|
- Node.js 18+
|
||||||
|
- pnpm
|
||||||
|
|
||||||
|
### 安装依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 开发模式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### 构建生产版本
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
### 预览构建结果
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm preview
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 使用指南
|
||||||
|
|
||||||
|
### 创建新文章
|
||||||
|
|
||||||
|
使用内置脚本快速创建新文章:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm new-post helloword
|
||||||
|
```
|
||||||
|
|
||||||
|
### 清理未使用的图片
|
||||||
|
|
||||||
|
清理 `src/content/assets` 目录下未被引用的图片文件:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm clean
|
||||||
|
```
|
||||||
|
|
||||||
|
### 配置博客
|
||||||
|
|
||||||
|
编辑 `src/config.ts` 文件来自定义博客配置:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const siteConfig: SiteConfig = {
|
||||||
|
title: "Fuwari",
|
||||||
|
subtitle: "技术分享与实践",
|
||||||
|
lang: "zh_CN",
|
||||||
|
themeColor: {
|
||||||
|
hue: 250,
|
||||||
|
fixed: false,
|
||||||
|
},
|
||||||
|
banner: {
|
||||||
|
enable: false,
|
||||||
|
src: "assets/images/demo-banner.png",
|
||||||
|
position: "center",
|
||||||
|
},
|
||||||
|
favicon: [
|
||||||
|
{
|
||||||
|
src: "/favicon/icon.png",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 文章格式
|
||||||
|
|
||||||
|
文章使用 Markdown 格式,支持 frontmatter:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
title: 文章标题
|
||||||
|
published: 2024-01-01
|
||||||
|
description: 文章描述
|
||||||
|
image: ./cover.jpg
|
||||||
|
tags: [标签1, 标签2]
|
||||||
|
category: 分类
|
||||||
|
draft: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# 文章内容
|
||||||
|
|
||||||
|
这里是文章正文...
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📁 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
├── public/ # 静态资源
|
||||||
|
├── src/
|
||||||
|
│ ├── components/ # 组件
|
||||||
|
│ ├── content/ # 内容
|
||||||
|
│ │ ├── posts/ # 博客文章
|
||||||
|
│ │ └── assets/ # 资源文件
|
||||||
|
│ ├── layouts/ # 布局
|
||||||
|
│ ├── pages/ # 页面
|
||||||
|
│ ├── styles/ # 样式
|
||||||
|
│ └── config.ts # 配置文件
|
||||||
|
├── scripts/ # 脚本工具
|
||||||
|
└── package.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎨 自定义
|
||||||
|
|
||||||
|
### 主题颜色
|
||||||
|
|
||||||
|
在 `src/config.ts` 中修改 `themeColor` 配置:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
themeColor: {
|
||||||
|
hue: 250, // 主色调 (0-360)
|
||||||
|
fixed: false, // 是否固定颜色
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 样式定制
|
||||||
|
|
||||||
|
- 全局样式:`src/styles/main.css`
|
||||||
|
- Markdown 样式:`src/styles/markdown.css`
|
||||||
|
- 变量定义:`src/styles/variables.styl`
|
||||||
|
|
||||||
|
## 📦 部署
|
||||||
|
|
||||||
|
构建后的静态文件位于 `dist/` 目录,可部署到任何静态托管平台。
|
||||||
|
|
||||||
|
## 🤝 贡献
|
||||||
|
|
||||||
|
欢迎提交 Issue 和 Pull Request!
|
||||||
|
|
||||||
|
## 📄 许可证
|
||||||
|
|
||||||
|
[MIT License](LICENSE)
|
||||||
|
|
||||||
|
## 🙏 致谢
|
||||||
|
|
||||||
|
感谢所有为这个项目做出贡献的开发者们!尤其感谢[上游仓库](https://github.com/saicaca/fuwari)
|
||||||
212
astro.config.mjs
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
import sitemap from "@astrojs/sitemap";
|
||||||
|
import svelte from "@astrojs/svelte";
|
||||||
|
import tailwind from "@astrojs/tailwind";
|
||||||
|
import swup from "@swup/astro";
|
||||||
|
import icon from "astro-icon";
|
||||||
|
import { defineConfig } from "astro/config";
|
||||||
|
import rehypeAutolinkHeadings from "rehype-autolink-headings";
|
||||||
|
import rehypeComponents from "rehype-components"; /* Render the custom directive content */
|
||||||
|
import rehypeKatex from "rehype-katex";
|
||||||
|
import rehypeSlug from "rehype-slug";
|
||||||
|
import remarkDirective from "remark-directive"; /* Handle directives */
|
||||||
|
import remarkGithubAdmonitionsToDirectives from "remark-github-admonitions-to-directives";
|
||||||
|
import remarkMath from "remark-math";
|
||||||
|
import remarkSectionize from "remark-sectionize";
|
||||||
|
import { imageFallbackConfig, siteConfig } from "./src/config.ts";
|
||||||
|
import { AdmonitionComponent } from "./src/plugins/rehype-component-admonition.mjs";
|
||||||
|
import { GithubCardComponent } from "./src/plugins/rehype-component-github-card.mjs";
|
||||||
|
import rehypeImageFallback from "./src/plugins/rehype-image-fallback.mjs";
|
||||||
|
import { parseDirectiveNode } from "./src/plugins/remark-directive-rehype.js";
|
||||||
|
import { remarkExcerpt } from "./src/plugins/remark-excerpt.js";
|
||||||
|
import { remarkReadingTime } from "./src/plugins/remark-reading-time.mjs";
|
||||||
|
import rehypeExternalLinks from "rehype-external-links";
|
||||||
|
import expressiveCode from "astro-expressive-code";
|
||||||
|
import { pluginCollapsibleSections } from "@expressive-code/plugin-collapsible-sections";
|
||||||
|
import { pluginLineNumbers } from "@expressive-code/plugin-line-numbers";
|
||||||
|
import { expressiveCodeConfig } from "./src/config.ts";
|
||||||
|
// import { pluginLanguageBadge } from "./src/plugins/expressive-code/language-badge.ts";
|
||||||
|
import { pluginCustomCopyButton } from "./src/plugins/expressive-code/custom-copy-button.js";
|
||||||
|
import { defineConfig, passthroughImageService } from "astro/config";
|
||||||
|
|
||||||
|
// https://astro.build/config
|
||||||
|
export default defineConfig({
|
||||||
|
server: {
|
||||||
|
port: 25544,
|
||||||
|
},
|
||||||
|
image: {
|
||||||
|
service: passthroughImageService(),
|
||||||
|
},
|
||||||
|
site: "https://blog.acofork.com",
|
||||||
|
base: "/",
|
||||||
|
trailingSlash: "always",
|
||||||
|
output: "static",
|
||||||
|
redirects: {
|
||||||
|
"/donate": "/sponsors",
|
||||||
|
"/ak":
|
||||||
|
"https://akile.io/register?aff_code=503fe5ea-e7c5-4d68-ae05-6de99513680e",
|
||||||
|
"/kook": "https://kook.vip/K29zpT",
|
||||||
|
"/long":
|
||||||
|
"https://iiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiii.iiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiii.in/",
|
||||||
|
"/mly": "https://muleyun.com/aff/GOTRJLPN",
|
||||||
|
"/tg": "https://t.me/+_07DERp7k1ljYTc1",
|
||||||
|
"/tit": "/posts/pin/",
|
||||||
|
"/tly": "https://tianlicloud.cn/aff/HNNCFKGP",
|
||||||
|
"/wly": "https://wl.awcmam.com/#/register?code=FNQwOQBM",
|
||||||
|
"/yyb": "https://www.rainyun.com/acofork_?s=bilibili",
|
||||||
|
"/iku": "https://ikuuu.de/auth/register?code=Bjou",
|
||||||
|
"/esa":
|
||||||
|
"https://tianchi.aliyun.com/specials/promotion/freetier/esa?taskCode=25254&recordId=c856e61228828a0423417a767828d166",
|
||||||
|
},
|
||||||
|
integrations: [
|
||||||
|
tailwind({
|
||||||
|
nesting: true,
|
||||||
|
}),
|
||||||
|
swup({
|
||||||
|
theme: false,
|
||||||
|
animationClass: "transition-swup-", // see https://swup.js.org/options/#animationselector
|
||||||
|
// the default value `transition-` cause transition delay
|
||||||
|
// when the Tailwind class `transition-all` is used
|
||||||
|
containers: ["main", "#toc"],
|
||||||
|
smoothScrolling: true,
|
||||||
|
cache: true,
|
||||||
|
preload: true,
|
||||||
|
accessibility: true,
|
||||||
|
updateHead: true,
|
||||||
|
updateBodyClass: false,
|
||||||
|
globalInstance: true,
|
||||||
|
}),
|
||||||
|
icon({
|
||||||
|
include: {
|
||||||
|
"preprocess: vitePreprocess(),": ["*"],
|
||||||
|
"fa6-brands": ["*"],
|
||||||
|
"fa6-regular": ["*"],
|
||||||
|
"fa6-solid": ["*"],
|
||||||
|
"simple-icons": ["*"],
|
||||||
|
"material-symbols-light": ["*"],
|
||||||
|
"material-symbols": ["*"],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
svelte(),
|
||||||
|
sitemap(),
|
||||||
|
expressiveCode({
|
||||||
|
themes: [expressiveCodeConfig.theme, expressiveCodeConfig.theme],
|
||||||
|
plugins: [
|
||||||
|
pluginCollapsibleSections(),
|
||||||
|
pluginLineNumbers(),
|
||||||
|
// pluginLanguageBadge(),
|
||||||
|
pluginCustomCopyButton(),
|
||||||
|
],
|
||||||
|
defaultProps: {
|
||||||
|
wrap: true,
|
||||||
|
overridesByLang: {
|
||||||
|
shellsession: {
|
||||||
|
showLineNumbers: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
styleOverrides: {
|
||||||
|
codeBackground: "var(--codeblock-bg)",
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
borderColor: "none",
|
||||||
|
codeFontSize: "0.875rem",
|
||||||
|
codeFontFamily:
|
||||||
|
"'JetBrains Mono Variable', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
|
||||||
|
codeLineHeight: "1.5rem",
|
||||||
|
frames: {
|
||||||
|
editorBackground: "var(--codeblock-bg)",
|
||||||
|
terminalBackground: "var(--codeblock-bg)",
|
||||||
|
terminalTitlebarBackground: "var(--codeblock-topbar-bg)",
|
||||||
|
editorTabBarBackground: "var(--codeblock-topbar-bg)",
|
||||||
|
editorActiveTabBackground: "none",
|
||||||
|
editorActiveTabIndicatorBottomColor: "var(--primary)",
|
||||||
|
editorActiveTabIndicatorTopColor: "none",
|
||||||
|
editorTabBarBorderBottomColor: "var(--codeblock-topbar-bg)",
|
||||||
|
terminalTitlebarBorderBottomColor: "none",
|
||||||
|
},
|
||||||
|
textMarkers: {
|
||||||
|
delHue: 0,
|
||||||
|
insHue: 180,
|
||||||
|
markHue: 250,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
frames: {
|
||||||
|
showCopyToClipboardButton: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
markdown: {
|
||||||
|
remarkPlugins: [
|
||||||
|
remarkMath,
|
||||||
|
remarkReadingTime,
|
||||||
|
remarkExcerpt,
|
||||||
|
remarkGithubAdmonitionsToDirectives,
|
||||||
|
remarkDirective,
|
||||||
|
remarkSectionize,
|
||||||
|
parseDirectiveNode,
|
||||||
|
],
|
||||||
|
rehypePlugins: [
|
||||||
|
rehypeKatex,
|
||||||
|
rehypeSlug,
|
||||||
|
[rehypeImageFallback, imageFallbackConfig],
|
||||||
|
[
|
||||||
|
rehypeComponents,
|
||||||
|
{
|
||||||
|
components: {
|
||||||
|
github: GithubCardComponent,
|
||||||
|
note: (x, y) => AdmonitionComponent(x, y, "note"),
|
||||||
|
tip: (x, y) => AdmonitionComponent(x, y, "tip"),
|
||||||
|
important: (x, y) => AdmonitionComponent(x, y, "important"),
|
||||||
|
caution: (x, y) => AdmonitionComponent(x, y, "caution"),
|
||||||
|
warning: (x, y) => AdmonitionComponent(x, y, "warning"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
rehypeExternalLinks,
|
||||||
|
{
|
||||||
|
target: "_blank",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
rehypeAutolinkHeadings,
|
||||||
|
{
|
||||||
|
behavior: "append",
|
||||||
|
properties: {
|
||||||
|
className: ["anchor"],
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
type: "element",
|
||||||
|
tagName: "span",
|
||||||
|
properties: {
|
||||||
|
className: ["anchor-icon"],
|
||||||
|
"data-pagefind-ignore": true,
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
value: "#",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
vite: {
|
||||||
|
assetsInclude: ["**/*.awebp"],
|
||||||
|
build: {
|
||||||
|
rollupOptions: {
|
||||||
|
onwarn(warning, warn) {
|
||||||
|
// temporarily suppress this warning
|
||||||
|
if (
|
||||||
|
warning.message.includes("is dynamically imported by") &&
|
||||||
|
warning.message.includes("but also statically imported by")
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
warn(warning);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
43
biome.json
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
||||||
|
"vcs": {
|
||||||
|
"enabled": false,
|
||||||
|
"clientKind": "git",
|
||||||
|
"useIgnoreFile": false
|
||||||
|
},
|
||||||
|
"files": {
|
||||||
|
"ignoreUnknown": false,
|
||||||
|
"ignore": ["src/**/*.css","src/public/**/*", "dist/**/*", "node_modules/**/*"]
|
||||||
|
},
|
||||||
|
"formatter": {
|
||||||
|
"enabled": true,
|
||||||
|
"indentStyle": "tab"
|
||||||
|
},
|
||||||
|
"organizeImports": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"linter": {
|
||||||
|
"enabled": true,
|
||||||
|
"rules": {
|
||||||
|
"recommended": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"javascript": {
|
||||||
|
"formatter": {
|
||||||
|
"quoteStyle": "double"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"include": ["*.svelte", "*.astro", "*.vue"],
|
||||||
|
"linter": {
|
||||||
|
"rules": {
|
||||||
|
"style": {
|
||||||
|
"useConst": "off",
|
||||||
|
"useImportType": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
16
docker-compose.yml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
version: '3.3'
|
||||||
|
services:
|
||||||
|
easyimage:
|
||||||
|
image: ddsderek/easyimage:latest
|
||||||
|
container_name: easyimage
|
||||||
|
ports:
|
||||||
|
- '8087:80'
|
||||||
|
environment:
|
||||||
|
- TZ=Asia/Shanghai
|
||||||
|
- PUID=1001
|
||||||
|
- PGID=1001
|
||||||
|
- DEBUG=false
|
||||||
|
volumes:
|
||||||
|
- './public/config:/app/web/config'
|
||||||
|
- './public/api/i:/app/web/i'
|
||||||
|
restart: unless-stopped
|
||||||
59
edgeone.json
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
{
|
||||||
|
"redirects": [
|
||||||
|
{
|
||||||
|
"source": "/ak",
|
||||||
|
"destination": "https://akile.io/register?aff_code=503fe5ea-e7c5-4d68-ae05-6de99513680e",
|
||||||
|
"statusCode": 302
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "/kook",
|
||||||
|
"destination": "https://kook.vip/K29zpT",
|
||||||
|
"statusCode": 302
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "/long",
|
||||||
|
"destination": "https://iiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiii.iiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiii.in/",
|
||||||
|
"statusCode": 302
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "/mly",
|
||||||
|
"destination": "https://muleyun.com/aff/GOTRJLPN",
|
||||||
|
"statusCode": 302
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "/tg",
|
||||||
|
"destination": "https://t.me/+_07DERp7k1ljYTc1",
|
||||||
|
"statusCode": 302
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "/tit",
|
||||||
|
"destination": "/posts/pin/",
|
||||||
|
"statusCode": 302
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "/tly",
|
||||||
|
"destination": "https://tianlicloud.cn/aff/HNNCFKGP",
|
||||||
|
"statusCode": 302
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "/wly",
|
||||||
|
"destination": "https://wl.awcmam.com/#/register?code=FNQwOQBM",
|
||||||
|
"statusCode": 302
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "/yyb",
|
||||||
|
"destination": "https://www.rainyun.com/acofork_?s=bilibili",
|
||||||
|
"statusCode": 302
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "/iku",
|
||||||
|
"destination": "https://ikuuu.de/auth/register?code=Bjou",
|
||||||
|
"statusCode": 302
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "/esa",
|
||||||
|
"destination": "https://tianchi.aliyun.com/specials/promotion/freetier/esa?taskCode=25254&recordId=c856e61228828a0423417a767828d166",
|
||||||
|
"statusCode": 302
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
62
frontmatter.json
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://frontmatter.codes/frontmatter.schema.json",
|
||||||
|
"frontMatter.framework.id": "astro",
|
||||||
|
"frontMatter.preview.host": "http://localhost:4321",
|
||||||
|
"frontMatter.content.publicFolder": "public",
|
||||||
|
"frontMatter.content.pageFolders": [
|
||||||
|
{
|
||||||
|
"title": "posts",
|
||||||
|
"path": "[[workspace]]/src/content/posts"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"frontMatter.taxonomy.contentTypes": [
|
||||||
|
{
|
||||||
|
"name": "default",
|
||||||
|
"pageBundle": true,
|
||||||
|
"previewPath": "'blog'",
|
||||||
|
"filePrefix": null,
|
||||||
|
"clearEmpty": true,
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"title": "title",
|
||||||
|
"name": "title",
|
||||||
|
"type": "string",
|
||||||
|
"single": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "description",
|
||||||
|
"name": "description",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "published",
|
||||||
|
"name": "published",
|
||||||
|
"type": "datetime",
|
||||||
|
"default": "{{now}}",
|
||||||
|
"isPublishDate": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "preview",
|
||||||
|
"name": "image",
|
||||||
|
"type": "image",
|
||||||
|
"isPreviewImage": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "tags",
|
||||||
|
"name": "tags",
|
||||||
|
"type": "list"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "draft",
|
||||||
|
"name": "draft",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "language",
|
||||||
|
"name": "language",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
88
package.json
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
{
|
||||||
|
"name": "fuwari",
|
||||||
|
"type": "module",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "astro dev",
|
||||||
|
"start": "astro dev",
|
||||||
|
"prebuild": "node scripts/generate-gallery-index.js",
|
||||||
|
"build": "astro build",
|
||||||
|
"preview": "astro preview",
|
||||||
|
"astro": "astro",
|
||||||
|
"type-check": "tsc --noEmit --isolatedDeclarations",
|
||||||
|
"new-post": "node scripts/new-post.js",
|
||||||
|
"clean": "node scripts/clean-unused-images.js",
|
||||||
|
"format": "biome format --write ./src",
|
||||||
|
"lint": "biome check --write ./src"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@astrojs/check": "^0.9.4",
|
||||||
|
"@astrojs/rss": "^4.0.11",
|
||||||
|
"@astrojs/sitemap": "^3.3.1",
|
||||||
|
"@astrojs/svelte": "7.0.12",
|
||||||
|
"@astrojs/tailwind": "^6.0.2",
|
||||||
|
"@astrojs/vercel": "^8.2.7",
|
||||||
|
"@expressive-code/core": "^0.41.3",
|
||||||
|
"@expressive-code/plugin-collapsible-sections": "^0.41.3",
|
||||||
|
"@expressive-code/plugin-line-numbers": "^0.41.3",
|
||||||
|
"@fancyapps/ui": "^6.0.5",
|
||||||
|
"@fontsource-variable/jetbrains-mono": "^5.2.5",
|
||||||
|
"@fontsource/roboto": "^5.2.5",
|
||||||
|
"@iconify-json/fa6-brands": "^1.2.5",
|
||||||
|
"@iconify-json/fa6-regular": "^1.2.3",
|
||||||
|
"@iconify-json/fa6-solid": "^1.2.3",
|
||||||
|
"@iconify-json/material-symbols": "^1.2.20",
|
||||||
|
"@iconify-json/simple-icons": "^1.2.42",
|
||||||
|
"@iconify/svelte": "^4.2.0",
|
||||||
|
"@swup/astro": "^1.6.0",
|
||||||
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
|
"@vercel/analytics": "^1.5.0",
|
||||||
|
"astro": "5.7.9",
|
||||||
|
"astro-expressive-code": "^0.41.3",
|
||||||
|
"astro-icon": "^1.1.5",
|
||||||
|
"glob": "^11.0.3",
|
||||||
|
"hastscript": "^9.0.1",
|
||||||
|
"katex": "^0.16.22",
|
||||||
|
"markdown-it": "^14.1.0",
|
||||||
|
"mdast-util-to-string": "^4.0.0",
|
||||||
|
"node-html-parser": "^7.0.1",
|
||||||
|
"overlayscrollbars": "^2.11.1",
|
||||||
|
"photoswipe": "^5.4.4",
|
||||||
|
"reading-time": "^1.5.0",
|
||||||
|
"rehype-autolink-headings": "^7.1.0",
|
||||||
|
"rehype-components": "^0.3.0",
|
||||||
|
"rehype-external-links": "^3.0.0",
|
||||||
|
"rehype-katex": "^7.0.1",
|
||||||
|
"rehype-slug": "^6.0.0",
|
||||||
|
"remark-directive": "^3.0.1",
|
||||||
|
"remark-directive-rehype": "^0.4.2",
|
||||||
|
"remark-github-admonitions-to-directives": "^1.0.5",
|
||||||
|
"remark-math": "^6.0.0",
|
||||||
|
"remark-sectionize": "^2.1.0",
|
||||||
|
"sanitize-html": "^2.16.0",
|
||||||
|
"sharp": "^0.34.1",
|
||||||
|
"stylus": "^0.64.0",
|
||||||
|
"svelte": "^5.28.2",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
|
"typescript": "^5.8.3",
|
||||||
|
"unist-util-visit": "^5.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@astrojs/ts-plugin": "^1.10.4",
|
||||||
|
"@biomejs/biome": "1.9.4",
|
||||||
|
"@iconify-json/material-symbols-light": "^1.2.49",
|
||||||
|
"@rollup/plugin-yaml": "^4.1.2",
|
||||||
|
"@types/markdown-it": "^14.1.2",
|
||||||
|
"@types/mdast": "^4.0.4",
|
||||||
|
"@types/sanitize-html": "^2.15.0",
|
||||||
|
"postcss-import": "^16.1.0",
|
||||||
|
"postcss-nesting": "^13.0.1",
|
||||||
|
"rehype": "^13.0.2"
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@9.14.4",
|
||||||
|
"pnpm": {
|
||||||
|
"patchedDependencies": {
|
||||||
|
"astro": "patches/astro.patch"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
29
patches/astro.patch
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
diff --git a/dist/assets/utils/transformToPath.js b/dist/assets/utils/transformToPath.js
|
||||||
|
index cca8548dec42090b0621d1f21c86f503d5bba1be..8b0a3cfcea73abc4d63592709bb9ba2b2f83989a 100644
|
||||||
|
--- a/dist/assets/utils/transformToPath.js
|
||||||
|
+++ b/dist/assets/utils/transformToPath.js
|
||||||
|
@@ -13,7 +13,9 @@ function propsToFilename(filePath, transform, hash) {
|
||||||
|
}
|
||||||
|
const prefixDirname = isESMImportedImage(transform.src) ? dirname(filePath) : "";
|
||||||
|
let outputExt = transform.format ? `.${transform.format}` : ext;
|
||||||
|
- return decodeURIComponent(`${prefixDirname}/${filename}_${hash}${outputExt}`);
|
||||||
|
+
|
||||||
|
+ // Force disable image optimization - return original path without hash and format conversion
|
||||||
|
+ return decodeURIComponent(`${prefixDirname}/${filename}${ext}`);
|
||||||
|
}
|
||||||
|
function hashTransform(transform, imageService, propertiesToHash) {
|
||||||
|
const hashFields = propertiesToHash.reduce(
|
||||||
|
diff --git a/dist/core/build/generate.js b/dist/core/build/generate.js
|
||||||
|
index 3144f4c058b161b9e6eb3c8d891b743b34783653..0ba275b320204e154307c6aff75452e9dcb2300d 100644
|
||||||
|
--- a/dist/core/build/generate.js
|
||||||
|
+++ b/dist/core/build/generate.js
|
||||||
|
@@ -91,7 +91,8 @@ ${bgGreen(black(` ${verb} static routes `))}`);
|
||||||
|
`)
|
||||||
|
);
|
||||||
|
const staticImageList = getStaticImageList();
|
||||||
|
- if (staticImageList.size) {
|
||||||
|
+ // Force disable image optimization - hardcoded
|
||||||
|
+ if (false) {
|
||||||
|
logger.info("SKIP_FORMAT", `${bgGreen(black(` generating optimized images `))}`);
|
||||||
|
const totalCount = Array.from(staticImageList.values()).map((x) => x.transforms.size).reduce((a, b) => a + b, 0);
|
||||||
|
const cpuCount = os.cpus().length;
|
||||||
11643
pnpm-lock.yaml
generated
Normal file
11
postcss.config.mjs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import postcssImport from 'postcss-import';
|
||||||
|
import postcssNesting from 'tailwindcss/nesting/index.js';
|
||||||
|
import tailwindcss from 'tailwindcss';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
'postcss-import': postcssImport, // to combine multiple css files
|
||||||
|
'tailwindcss/nesting': postcssNesting,
|
||||||
|
tailwindcss: tailwindcss,
|
||||||
|
}
|
||||||
|
};
|
||||||
12
public/_redirects
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/donate /sponsors 302
|
||||||
|
/ak https://akile.io/register?aff_code=503fe5ea-e7c5-4d68-ae05-6de99513680e 302
|
||||||
|
/kook https://kook.vip/K29zpT 302
|
||||||
|
/long https://iiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiii.iiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiii.in/ 302
|
||||||
|
/mly https://muleyun.com/aff/GOTRJLPN 302
|
||||||
|
/tg https://t.me/+_07DERp7k1ljYTc1 302
|
||||||
|
/tit /posts/pin/ 302
|
||||||
|
/tly https://tianlicloud.cn/aff/HNNCFKGP 302
|
||||||
|
/wly https://wl.awcmam.com/#/register?code=FNQwOQBM 302
|
||||||
|
/yyb https://www.rainyun.com/acofork_?s=bilibili 302
|
||||||
|
/iku https://ikuuu.de/auth/register?code=Bjou 302
|
||||||
|
/esa https://tianchi.aliyun.com/specials/promotion/freetier/esa?taskCode=25254&recordId=c856e61228828a0423417a767828d166 302
|
||||||
1
public/ads.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
google.com, pub-1683686345039700, DIRECT, f08c47fec0942fa0
|
||||||
BIN
public/aff/secbit-banner-1.gif
Normal file
|
After Width: | Height: | Size: 15 KiB |
1
public/config/EasyIamge.lock
Executable file
@@ -0,0 +1 @@
|
|||||||
|
安装环境检测锁定文件,如需再次展示请删除此文件!
|
||||||
16
public/config/api_key.php
Executable file
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
$tokenList=Array
|
||||||
|
(
|
||||||
|
'1c17b11693cb5ec63859b091c5b9c1b2'=>Array
|
||||||
|
(
|
||||||
|
'id'=>0,
|
||||||
|
'expired'=>1751608714,
|
||||||
|
'add_time'=>1680497928
|
||||||
|
),
|
||||||
|
'4ab8829b49738fc562b40e66991b4504'=>Array
|
||||||
|
(
|
||||||
|
'id'=>1,
|
||||||
|
'expired'=>'1.728E+23',
|
||||||
|
'add_time'=>1751608724
|
||||||
|
)
|
||||||
|
);
|
||||||
10
public/config/config.guest.php
Executable file
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
$guestConfig=Array
|
||||||
|
(
|
||||||
|
'guest'=>Array
|
||||||
|
(
|
||||||
|
'password'=>'84983c60f7daadc1cb8698621f802c0d9f9a3c3c295c810748fb048115c186ec',
|
||||||
|
'expired'=>1752905698,
|
||||||
|
'add_time'=>1678988356
|
||||||
|
)
|
||||||
|
);
|
||||||
148
public/config/config.manager.php
Executable file
@@ -0,0 +1,148 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/*---------------tinyfilemanager管理配置(默认已经配置好了 你也可以自定义)-------------------*/
|
||||||
|
|
||||||
|
require __DIR__ . '/config.php';
|
||||||
|
|
||||||
|
/* Default Configuration
|
||||||
|
* 默认配置
|
||||||
|
*/
|
||||||
|
// $CONFIG = '{"lang":"zh-CN","error_reporting":false,"show_hidden":false,"hide_Cols":false,"calc_folder":false}';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Auth with login/password
|
||||||
|
* set true/false to enable/disable it
|
||||||
|
* Is independent from IP white- and blacklisting
|
||||||
|
* 开启登录
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Auth with login/password
|
||||||
|
// set true/false to enable/disable it
|
||||||
|
// Is independent from IP white- and blacklisting
|
||||||
|
$use_auth = false;
|
||||||
|
|
||||||
|
// Login user name and password
|
||||||
|
// Users: array('Username' => 'Password', 'Username2' => 'Password2', ...)
|
||||||
|
// Generate secure password hash - https://tinyfilemanager.github.io/docs/pwd.html
|
||||||
|
// 登录和管理密码 - Admin管理密码请在图床配置中修改
|
||||||
|
$auth_users = array(
|
||||||
|
'admin' => password_hash($config['password'], PASSWORD_DEFAULT), // 登录密码
|
||||||
|
'user' => '$2y$10$iPtSuvQnv0FnqdWdQsuWMOGxlul/VQzcKl3q1K7VU/QTw102IU5yi' //密码:CQ4CdBGjGJnA
|
||||||
|
// 先写一个密码然后获取密码Hash填上去- https://tinyfilemanager.github.io/docs/pwd.html
|
||||||
|
);
|
||||||
|
|
||||||
|
// Readonly users
|
||||||
|
// e.g. array('users', 'guest', ...)
|
||||||
|
// 只读的用户
|
||||||
|
$readonly_users = array(
|
||||||
|
'user'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Enable highlight.js (https://highlightjs.org/) on view's page
|
||||||
|
$use_highlightjs = true;
|
||||||
|
|
||||||
|
// highlight.js style
|
||||||
|
// for dark theme use 'ir-black'
|
||||||
|
// 主题 白天 vs/ 黑夜 ir-black
|
||||||
|
$highlightjs_style = 'vs';
|
||||||
|
|
||||||
|
// Enable ace.js (https://ace.c9.io/) on view's page
|
||||||
|
$edit_files = true;
|
||||||
|
|
||||||
|
// Default timezone for date() and time()
|
||||||
|
// Doc - http://php.net/manual/en/timezones.php
|
||||||
|
// 时区
|
||||||
|
$default_timezone = 'Asia/Shanghai'; // UTC
|
||||||
|
|
||||||
|
// Root path for file manager
|
||||||
|
// use absolute path of directory i.e: '/var/www/folder' or $_SERVER['DOCUMENT_ROOT'].'/folder'
|
||||||
|
// 管理的目录
|
||||||
|
$root_path = $_SERVER['DOCUMENT_ROOT'] . $config['path'];
|
||||||
|
|
||||||
|
// Root url for links in file manager.Relative to $http_host. Variants: '', 'path/to/subfolder'
|
||||||
|
// Will not working if $root_path will be outside of server document root
|
||||||
|
// 文件的路径
|
||||||
|
$root_url = $config['path'];
|
||||||
|
|
||||||
|
// Server hostname. Can set manually if wrong
|
||||||
|
$http_host = $_SERVER['HTTP_HOST'];
|
||||||
|
// $http_host = $config['imgurl'];
|
||||||
|
|
||||||
|
|
||||||
|
// user specific directories
|
||||||
|
// array('Username' => 'Directory path', 'Username2' => 'Directory path', ...)
|
||||||
|
// 用户路径
|
||||||
|
$directories_users = array();
|
||||||
|
|
||||||
|
// input encoding for iconv
|
||||||
|
$iconv_input_encoding = 'UTF-8';
|
||||||
|
|
||||||
|
// date() format for file modification date
|
||||||
|
// Doc - https://www.php.net/manual/en/datetime.format.php
|
||||||
|
$datetime_format = 'Y.m.d H:i:s';
|
||||||
|
|
||||||
|
// Allowed file extensions for create and rename files
|
||||||
|
// e.g. 'txt,html,css,js'
|
||||||
|
// 允许创建的文件格式
|
||||||
|
$allowed_file_extensions = '';
|
||||||
|
|
||||||
|
// Allowed file extensions for upload files
|
||||||
|
// e.g. 'gif,png,jpg,html,txt'
|
||||||
|
// 允许上传的文件格式
|
||||||
|
$allowed_upload_extensions = '';
|
||||||
|
|
||||||
|
// Favicon path. This can be either a full url to an .PNG image, or a path based on the document root.
|
||||||
|
// full path, e.g http://example.com/favicon.png
|
||||||
|
// local path, e.g images/icons/favicon.png
|
||||||
|
// Favicon图标路径
|
||||||
|
$favicon_path = $config['domain'] . '/favicon.ico';
|
||||||
|
|
||||||
|
// Files and folders to excluded from listing
|
||||||
|
// e.g. array('myfile.html', 'personal-folder', '*.php', ...)
|
||||||
|
// 不显示的文件类型或文件夹
|
||||||
|
$exclude_items = array('');
|
||||||
|
|
||||||
|
// Online office Docs Viewer
|
||||||
|
// Availabe rules are 'google', 'microsoft' or false
|
||||||
|
// google => View documents using Google Docs Viewer
|
||||||
|
// microsoft => View documents using Microsoft Web Apps Viewer
|
||||||
|
// false => disable online doc viewer
|
||||||
|
// 文档查看引擎 'google', 'microsoft' or false
|
||||||
|
$online_viewer = 'microsoft';
|
||||||
|
|
||||||
|
// Sticky Nav bar
|
||||||
|
// true => enable sticky header
|
||||||
|
// false => disable sticky header
|
||||||
|
// 启用导航栏?
|
||||||
|
$sticky_navbar = false;
|
||||||
|
|
||||||
|
|
||||||
|
// max upload file size
|
||||||
|
// 文件最大上传大小
|
||||||
|
$max_upload_size_bytes = 5000;
|
||||||
|
|
||||||
|
// Possible rules are 'OFF', 'AND' or 'OR'
|
||||||
|
// OFF => Don't check connection IP, defaults to OFF
|
||||||
|
// AND => Connection must be on the whitelist, and not on the blacklist
|
||||||
|
// OR => Connection must be on the whitelist, or not on the blacklist
|
||||||
|
// 开启登录IP管理
|
||||||
|
// OFF 关闭 AND 需在白名单内 OR 必须是白名单内或者不是黑名单内
|
||||||
|
$ip_ruleset = 'OFF';
|
||||||
|
|
||||||
|
// Should users be notified of their block?
|
||||||
|
// 告诉用户当前IP不可访问?
|
||||||
|
$ip_silent = true;
|
||||||
|
|
||||||
|
// IP-addresses, both ipv4 and ipv6
|
||||||
|
// 登录白名单
|
||||||
|
$ip_whitelist = array(
|
||||||
|
'127.0.0.1', // local ipv4
|
||||||
|
'::1' // local ipv6
|
||||||
|
);
|
||||||
|
|
||||||
|
// IP-addresses, both ipv4 and ipv6
|
||||||
|
// 登录黑名单
|
||||||
|
$ip_blacklist = array(
|
||||||
|
'0.0.0.0', // non-routable meta ipv4
|
||||||
|
'::' // non-routable meta ipv6
|
||||||
|
);
|
||||||
145
public/config/config.php
Executable file
1
public/config/install.lock
Executable file
@@ -0,0 +1 @@
|
|||||||
|
安装程序锁定文件。
|
||||||
BIN
public/favicon/22.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
public/favicon/foot-ga.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
public/favicon/foot-icp.png
Normal file
|
After Width: | Height: | Size: 691 B |
BIN
public/favicon/ie-cx.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
public/favicon/nodeseek.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
public/favicon/yurn.png
Normal file
|
After Width: | Height: | Size: 149 KiB |
134
public/js/random.js
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
/**
|
||||||
|
* Static Random Pic API
|
||||||
|
* Generated by build script
|
||||||
|
*/
|
||||||
|
(function() {
|
||||||
|
var counts = {"h":979,"v":3596};
|
||||||
|
var domain = 'https://pic.acofork.com';
|
||||||
|
|
||||||
|
// State management for session consistency
|
||||||
|
var sessionRandomH = null;
|
||||||
|
var sessionRandomV = null;
|
||||||
|
|
||||||
|
// Helper: Get random URL for a type (h or v), persistent per session
|
||||||
|
function getRandomUrl(type) {
|
||||||
|
if (!counts[type] || counts[type] === 0) return '';
|
||||||
|
|
||||||
|
// Return existing session URL if available
|
||||||
|
if (type === 'h' && sessionRandomH) return sessionRandomH;
|
||||||
|
if (type === 'v' && sessionRandomV) return sessionRandomV;
|
||||||
|
|
||||||
|
// Generate new if not exists
|
||||||
|
var num = Math.floor(Math.random() * counts[type]) + 1;
|
||||||
|
var url = domain + '/ri/' + type + '/' + num + '.webp';
|
||||||
|
|
||||||
|
// Save to session state
|
||||||
|
if (type === 'h') sessionRandomH = url;
|
||||||
|
if (type === 'v') sessionRandomV = url;
|
||||||
|
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expose global functions
|
||||||
|
window.getRandomPicH = function() { return getRandomUrl('h'); };
|
||||||
|
window.getRandomPicV = function() { return getRandomUrl('v'); };
|
||||||
|
|
||||||
|
// 1. Logic for Background (Customized based on user request)
|
||||||
|
function setRandomBackground() {
|
||||||
|
// Get random URL using the helper (Dynamic count & domain)
|
||||||
|
const bgUrl = getRandomUrl('h');
|
||||||
|
|
||||||
|
// Find the background box element
|
||||||
|
const bgBox = document.getElementById('bg-box');
|
||||||
|
|
||||||
|
if (bgBox) {
|
||||||
|
// Preload image
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = function() {
|
||||||
|
bgBox.style.backgroundImage = `url('${bgUrl}')`;
|
||||||
|
bgBox.classList.add('loaded');
|
||||||
|
console.log('Random background loaded:', bgUrl);
|
||||||
|
|
||||||
|
// Set CSS variables for transparency effects
|
||||||
|
document.documentElement.style.setProperty('--card-bg', 'var(--card-bg-transparent)');
|
||||||
|
document.documentElement.style.setProperty('--float-panel-bg', 'var(--float-panel-bg-transparent)');
|
||||||
|
};
|
||||||
|
img.onerror = function() {
|
||||||
|
console.error('Failed to load background image:', bgUrl);
|
||||||
|
};
|
||||||
|
img.src = bgUrl;
|
||||||
|
} else {
|
||||||
|
// Fallback: If no #bg-box, check for data-random-bg for backward compatibility/other elements
|
||||||
|
// This keeps the generic functionality available if needed, but prioritizes the user's specific logic above.
|
||||||
|
initGenericBackgrounds();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Logic for Image Tags (Generic)
|
||||||
|
function initImgTags() {
|
||||||
|
var imgTags = document.getElementsByTagName('img');
|
||||||
|
for (var i = 0; i < imgTags.length; i++) {
|
||||||
|
var img = imgTags[i];
|
||||||
|
var alt = img.getAttribute('alt');
|
||||||
|
var src = img.getAttribute('src');
|
||||||
|
|
||||||
|
if (alt === 'random:h' || (src && src.indexOf('/random/h') !== -1)) {
|
||||||
|
img.src = getRandomUrl('h');
|
||||||
|
} else if (alt === 'random:v' || (src && src.indexOf('/random/v') !== -1)) {
|
||||||
|
img.src = getRandomUrl('v');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper for generic data-random-bg (as a backup or secondary feature)
|
||||||
|
function initGenericBackgrounds() {
|
||||||
|
var bgElements = document.querySelectorAll('[data-random-bg]');
|
||||||
|
bgElements.forEach(function(el) {
|
||||||
|
// Skip if it is the bg-box we already handled (though setRandomBackground handles #bg-box specifically)
|
||||||
|
if (el.id === 'bg-box') return;
|
||||||
|
|
||||||
|
var type = el.getAttribute('data-random-bg');
|
||||||
|
if (type === 'h' || type === 'v') {
|
||||||
|
var url = getRandomUrl(type);
|
||||||
|
if (url) {
|
||||||
|
var img = new Image();
|
||||||
|
img.onload = function() {
|
||||||
|
el.style.backgroundImage = 'url("' + url + '")';
|
||||||
|
el.classList.add('loaded');
|
||||||
|
};
|
||||||
|
img.src = url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
setRandomBackground();
|
||||||
|
initImgTags();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run on initial load
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swup integration
|
||||||
|
function setupSwup() {
|
||||||
|
if (window.swup && window.swup.hooks) {
|
||||||
|
// Register hook for content replacement
|
||||||
|
window.swup.hooks.on('content:replace', init);
|
||||||
|
console.log('Random Pic API: Registered with Swup hooks.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.swup) {
|
||||||
|
setupSwup();
|
||||||
|
} else {
|
||||||
|
document.addEventListener('swup:enable', setupSwup);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy Swup support
|
||||||
|
document.addEventListener('swup:contentReplaced', init);
|
||||||
|
})();
|
||||||
91
public/js/umami-share.js
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
(function (global) {
|
||||||
|
const cacheKey = 'umami-share-cache';
|
||||||
|
const cacheTTL = 3600_000; // 1h
|
||||||
|
|
||||||
|
async function fetchShareData(baseUrl, shareId) {
|
||||||
|
const cached = localStorage.getItem(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(cached);
|
||||||
|
if (Date.now() - parsed.timestamp < cacheTTL) {
|
||||||
|
return parsed.value;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
localStorage.removeItem(cacheKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const res = await fetch(`${baseUrl}/api/share/${shareId}`);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error('获取 Umami 分享信息失败');
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
localStorage.setItem(cacheKey, JSON.stringify({ timestamp: Date.now(), value: data }));
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 Umami 分享数据(websiteId、token)
|
||||||
|
* 在缓存 TTL 内复用;并用全局 Promise 避免并发请求
|
||||||
|
* @param {string} baseUrl
|
||||||
|
* @param {string} shareId
|
||||||
|
* @returns {Promise<{websiteId: string, token: string}>}
|
||||||
|
*/
|
||||||
|
global.getUmamiShareData = function (baseUrl, shareId) {
|
||||||
|
if (!global.__umamiSharePromise) {
|
||||||
|
global.__umamiSharePromise = fetchShareData(baseUrl, shareId).catch((err) => {
|
||||||
|
delete global.__umamiSharePromise;
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return global.__umamiSharePromise;
|
||||||
|
};
|
||||||
|
|
||||||
|
global.clearUmamiShareCache = function () {
|
||||||
|
localStorage.removeItem(cacheKey);
|
||||||
|
delete global.__umamiSharePromise;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 Umami 统计数据
|
||||||
|
* 自动处理 token 获取和过期重试
|
||||||
|
* @param {string} baseUrl
|
||||||
|
* @param {string} shareId
|
||||||
|
* @param {object} queryParams
|
||||||
|
* @returns {Promise<any>}
|
||||||
|
*/
|
||||||
|
global.fetchUmamiStats = async function (baseUrl, shareId, queryParams) {
|
||||||
|
async function doFetch(isRetry = false) {
|
||||||
|
const { websiteId, token } = await global.getUmamiShareData(baseUrl, shareId);
|
||||||
|
const currentTimestamp = Date.now();
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
startAt: 0,
|
||||||
|
endAt: currentTimestamp,
|
||||||
|
unit: 'hour',
|
||||||
|
timezone: queryParams.timezone || 'Asia/Shanghai',
|
||||||
|
compare: false,
|
||||||
|
...queryParams
|
||||||
|
});
|
||||||
|
|
||||||
|
const statsUrl = `${baseUrl}/api/websites/${websiteId}/stats?${params.toString()}`;
|
||||||
|
|
||||||
|
const res = await fetch(statsUrl, {
|
||||||
|
headers: {
|
||||||
|
'x-umami-share-token': token
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
if (res.status === 401 && !isRetry) {
|
||||||
|
global.clearUmamiShareCache();
|
||||||
|
return doFetch(true);
|
||||||
|
}
|
||||||
|
throw new Error('获取统计数据失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
return await res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
return doFetch();
|
||||||
|
};
|
||||||
|
|
||||||
|
})(window);
|
||||||
867
public/sponsors/alipay.svg
Normal file
@@ -0,0 +1,867 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<svg width="540" height="540" viewBox="0 0 540 540" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<rect width="540" height="540" fill="#ffffff"/>
|
||||||
|
<defs>
|
||||||
|
<rect id="p" width="12" height="12" />
|
||||||
|
</defs>
|
||||||
|
<g>
|
||||||
|
<use x="24" y="24" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="36" y="24" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="48" y="24" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="60" y="24" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="72" y="24" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="84" y="24" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="96" y="24" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="120" y="24" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="156" y="24" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="180" y="24" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="204" y="24" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="216" y="24" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="228" y="24" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="252" y="24" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="276" y="24" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="288" y="24" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="300" y="24" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="336" y="24" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="360" y="24" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="372" y="24" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="384" y="24" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="396" y="24" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="432" y="24" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="444" y="24" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="456" y="24" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="468" y="24" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="480" y="24" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="492" y="24" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="504" y="24" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="24" y="36" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="96" y="36" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="132" y="36" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="192" y="36" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="216" y="36" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="240" y="36" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="252" y="36" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="300" y="36" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="312" y="36" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="324" y="36" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="348" y="36" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="360" y="36" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="384" y="36" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="432" y="36" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="504" y="36" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="24" y="48" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="48" y="48" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="60" y="48" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="72" y="48" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="96" y="48" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="132" y="48" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="156" y="48" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="192" y="48" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="216" y="48" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="240" y="48" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="252" y="48" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="264" y="48" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="276" y="48" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="288" y="48" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="348" y="48" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="372" y="48" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="384" y="48" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="432" y="48" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="456" y="48" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="468" y="48" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="480" y="48" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="504" y="48" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="24" y="60" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="48" y="60" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="60" y="60" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="72" y="60" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="96" y="60" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="120" y="60" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="132" y="60" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="144" y="60" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="168" y="60" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="180" y="60" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="192" y="60" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="216" y="60" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="240" y="60" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="252" y="60" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="264" y="60" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="288" y="60" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="300" y="60" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="312" y="60" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="324" y="60" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="360" y="60" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="372" y="60" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="384" y="60" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="432" y="60" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="456" y="60" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="468" y="60" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="480" y="60" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="504" y="60" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="24" y="72" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="48" y="72" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="60" y="72" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="72" y="72" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="96" y="72" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="132" y="72" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="204" y="72" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="240" y="72" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="276" y="72" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="288" y="72" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="348" y="72" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="384" y="72" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="396" y="72" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="432" y="72" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="456" y="72" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="468" y="72" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="480" y="72" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="504" y="72" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="24" y="84" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="96" y="84" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="180" y="84" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="192" y="84" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="204" y="84" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="216" y="84" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="228" y="84" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="240" y="84" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="264" y="84" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="276" y="84" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="288" y="84" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="312" y="84" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="324" y="84" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="336" y="84" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="348" y="84" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="360" y="84" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="372" y="84" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="396" y="84" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="432" y="84" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="504" y="84" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="24" y="96" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="36" y="96" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="48" y="96" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="60" y="96" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="72" y="96" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="84" y="96" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="96" y="96" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="120" y="96" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="144" y="96" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="168" y="96" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="192" y="96" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="216" y="96" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="240" y="96" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="264" y="96" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="288" y="96" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="312" y="96" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="336" y="96" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="360" y="96" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="384" y="96" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="408" y="96" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="432" y="96" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="444" y="96" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="456" y="96" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="468" y="96" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="480" y="96" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="492" y="96" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="504" y="96" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="132" y="108" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="168" y="108" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="180" y="108" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="192" y="108" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="252" y="108" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="264" y="108" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="276" y="108" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="300" y="108" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="312" y="108" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="336" y="108" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="348" y="108" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="360" y="108" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="372" y="108" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="384" y="108" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="408" y="108" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="48" y="120" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="72" y="120" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="84" y="120" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="96" y="120" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="120" y="120" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="144" y="120" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="168" y="120" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="180" y="120" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="192" y="120" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="204" y="120" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="216" y="120" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="228" y="120" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="240" y="120" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="288" y="120" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="300" y="120" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="336" y="120" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="360" y="120" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="372" y="120" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="420" y="120" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="468" y="120" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="504" y="120" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="36" y="132" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="48" y="132" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="60" y="132" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="72" y="132" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="84" y="132" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="108" y="132" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="132" y="132" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="168" y="132" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="180" y="132" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="192" y="132" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="204" y="132" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="264" y="132" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="276" y="132" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="288" y="132" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="312" y="132" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="360" y="132" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="384" y="132" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="408" y="132" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="420" y="132" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="432" y="132" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="444" y="132" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="456" y="132" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="468" y="132" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="480" y="132" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="492" y="132" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="24" y="144" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="60" y="144" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="96" y="144" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="108" y="144" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="144" y="144" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="180" y="144" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="192" y="144" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="204" y="144" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="240" y="144" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="264" y="144" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="276" y="144" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="288" y="144" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="300" y="144" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="312" y="144" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="324" y="144" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="360" y="144" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="372" y="144" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="384" y="144" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="420" y="144" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="456" y="144" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="480" y="144" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="24" y="156" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="60" y="156" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="84" y="156" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="108" y="156" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="120" y="156" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="132" y="156" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="156" y="156" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="180" y="156" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="192" y="156" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="204" y="156" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="240" y="156" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="252" y="156" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="288" y="156" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="348" y="156" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="360" y="156" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="372" y="156" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="384" y="156" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="408" y="156" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="420" y="156" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="432" y="156" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="456" y="156" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="492" y="156" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="504" y="156" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="24" y="168" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="36" y="168" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="96" y="168" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="108" y="168" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="120" y="168" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="132" y="168" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="168" y="168" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="180" y="168" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="192" y="168" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="276" y="168" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="288" y="168" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="336" y="168" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="348" y="168" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="360" y="168" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="372" y="168" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="396" y="168" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="420" y="168" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="468" y="168" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="492" y="168" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="504" y="168" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="36" y="180" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="48" y="180" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="60" y="180" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="84" y="180" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="156" y="180" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="180" y="180" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="192" y="180" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="204" y="180" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="240" y="180" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="264" y="180" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="288" y="180" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="300" y="180" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="324" y="180" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="348" y="180" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="360" y="180" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="384" y="180" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="396" y="180" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="420" y="180" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="468" y="180" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="492" y="180" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="504" y="180" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="48" y="192" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="96" y="192" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="108" y="192" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="132" y="192" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="144" y="192" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="180" y="192" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="204" y="192" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="240" y="192" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="276" y="192" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="288" y="192" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="312" y="192" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="336" y="192" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="348" y="192" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="360" y="192" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="432" y="192" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="444" y="192" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="468" y="192" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="480" y="192" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="492" y="192" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="36" y="204" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="60" y="204" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="120" y="204" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="228" y="204" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="264" y="204" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="276" y="204" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="288" y="204" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="312" y="204" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="324" y="204" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="336" y="204" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="360" y="204" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="372" y="204" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="384" y="204" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="396" y="204" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="432" y="204" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="444" y="204" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="24" y="216" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="48" y="216" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="96" y="216" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="120" y="216" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="180" y="216" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="204" y="216" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="228" y="216" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="252" y="216" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="264" y="216" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="288" y="216" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="300" y="216" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="324" y="216" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="348" y="216" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="360" y="216" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="372" y="216" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="408" y="216" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="432" y="216" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="444" y="216" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="468" y="216" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="504" y="216" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="24" y="228" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="60" y="228" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="84" y="228" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="108" y="228" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="120" y="228" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="156" y="228" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="168" y="228" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="180" y="228" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="216" y="228" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="228" y="228" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="240" y="228" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="252" y="228" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="264" y="228" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="276" y="228" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="288" y="228" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="324" y="228" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="336" y="228" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="372" y="228" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="432" y="228" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="480" y="228" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="492" y="228" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="504" y="228" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="24" y="240" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="84" y="240" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="96" y="240" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="120" y="240" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="132" y="240" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="144" y="240" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="180" y="240" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="204" y="240" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="228" y="240" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="240" y="240" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="252" y="240" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="276" y="240" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="348" y="240" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="360" y="240" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="384" y="240" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="420" y="240" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="456" y="240" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="468" y="240" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="480" y="240" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="492" y="240" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="504" y="240" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="24" y="252" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="48" y="252" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="60" y="252" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="108" y="252" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="132" y="252" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="144" y="252" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="168" y="252" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="264" y="252" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="312" y="252" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="324" y="252" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="360" y="252" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="372" y="252" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="384" y="252" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="408" y="252" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="432" y="252" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="444" y="252" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="480" y="252" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="492" y="252" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="24" y="264" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="36" y="264" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="48" y="264" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="84" y="264" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="96" y="264" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="132" y="264" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="144" y="264" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="168" y="264" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="180" y="264" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="192" y="264" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="228" y="264" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="252" y="264" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="288" y="264" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="300" y="264" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="348" y="264" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="384" y="264" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="396" y="264" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="408" y="264" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="420" y="264" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="432" y="264" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="444" y="264" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="456" y="264" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="480" y="264" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="504" y="264" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="24" y="276" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="36" y="276" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="48" y="276" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="60" y="276" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="84" y="276" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="120" y="276" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="132" y="276" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="144" y="276" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="168" y="276" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="180" y="276" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="192" y="276" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="204" y="276" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="216" y="276" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="228" y="276" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="240" y="276" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="276" y="276" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="312" y="276" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="336" y="276" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="372" y="276" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="384" y="276" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="408" y="276" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="420" y="276" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="432" y="276" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="444" y="276" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="468" y="276" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="492" y="276" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="504" y="276" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="84" y="288" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="96" y="288" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="108" y="288" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="168" y="288" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="180" y="288" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="192" y="288" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="204" y="288" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="228" y="288" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="264" y="288" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="288" y="288" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="300" y="288" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="312" y="288" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="324" y="288" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="348" y="288" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="360" y="288" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="372" y="288" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="384" y="288" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="420" y="288" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="444" y="288" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="456" y="288" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="480" y="288" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="492" y="288" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="504" y="288" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="48" y="300" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="60" y="300" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="84" y="300" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="132" y="300" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="144" y="300" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="168" y="300" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="180" y="300" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="192" y="300" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="228" y="300" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="240" y="300" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="252" y="300" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="264" y="300" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="300" y="300" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="324" y="300" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="336" y="300" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="348" y="300" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="360" y="300" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="372" y="300" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="408" y="300" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="420" y="300" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="444" y="300" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="468" y="300" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="24" y="312" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="72" y="312" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="96" y="312" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="108" y="312" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="120" y="312" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="168" y="312" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="180" y="312" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="192" y="312" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="204" y="312" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="240" y="312" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="264" y="312" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="276" y="312" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="300" y="312" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="324" y="312" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="336" y="312" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="360" y="312" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="372" y="312" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="384" y="312" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="396" y="312" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="408" y="312" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="432" y="312" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="456" y="312" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="480" y="312" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="36" y="324" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="48" y="324" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="84" y="324" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="120" y="324" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="144" y="324" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="156" y="324" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="204" y="324" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="216" y="324" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="252" y="324" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="360" y="324" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="420" y="324" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="432" y="324" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="492" y="324" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="504" y="324" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="48" y="336" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="60" y="336" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="96" y="336" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="132" y="336" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="204" y="336" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="216" y="336" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="240" y="336" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="252" y="336" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="300" y="336" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="312" y="336" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="336" y="336" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="384" y="336" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="432" y="336" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="444" y="336" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="480" y="336" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="492" y="336" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="504" y="336" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="24" y="348" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="60" y="348" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="84" y="348" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="108" y="348" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="120" y="348" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="144" y="348" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="156" y="348" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="180" y="348" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="192" y="348" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="216" y="348" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="228" y="348" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="240" y="348" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="252" y="348" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="288" y="348" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="300" y="348" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="312" y="348" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="336" y="348" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="348" y="348" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="372" y="348" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="408" y="348" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="456" y="348" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="492" y="348" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="504" y="348" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="84" y="360" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="96" y="360" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="120" y="360" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="156" y="360" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="192" y="360" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="204" y="360" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="228" y="360" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="252" y="360" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="264" y="360" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="276" y="360" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="300" y="360" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="384" y="360" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="396" y="360" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="408" y="360" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="432" y="360" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="468" y="360" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="492" y="360" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="504" y="360" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="48" y="372" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="60" y="372" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="144" y="372" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="156" y="372" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="168" y="372" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="204" y="372" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="252" y="372" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="264" y="372" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="324" y="372" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="336" y="372" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="348" y="372" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="384" y="372" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="396" y="372" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="408" y="372" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="468" y="372" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="480" y="372" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="492" y="372" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="24" y="384" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="48" y="384" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="72" y="384" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="96" y="384" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="108" y="384" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="132" y="384" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="156" y="384" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="192" y="384" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="204" y="384" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="216" y="384" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="228" y="384" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="240" y="384" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="252" y="384" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="312" y="384" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="396" y="384" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="408" y="384" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="468" y="384" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="480" y="384" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="36" y="396" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="72" y="396" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="108" y="396" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="120" y="396" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="132" y="396" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="144" y="396" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="156" y="396" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="180" y="396" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="204" y="396" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="228" y="396" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="240" y="396" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="252" y="396" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="264" y="396" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="276" y="396" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="300" y="396" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="312" y="396" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="324" y="396" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="348" y="396" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="408" y="396" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="420" y="396" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="432" y="396" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="456" y="396" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="468" y="396" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="492" y="396" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="24" y="408" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="48" y="408" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="60" y="408" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="72" y="408" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="96" y="408" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="108" y="408" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="144" y="408" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="180" y="408" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="192" y="408" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="216" y="408" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="228" y="408" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="240" y="408" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="252" y="408" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="264" y="408" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="288" y="408" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="300" y="408" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="324" y="408" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="336" y="408" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="360" y="408" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="396" y="408" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="408" y="408" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="420" y="408" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="432" y="408" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="444" y="408" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="456" y="408" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="492" y="408" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="120" y="420" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="168" y="420" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="180" y="420" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="204" y="420" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="252" y="420" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="264" y="420" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="288" y="420" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="312" y="420" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="396" y="420" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="408" y="420" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="456" y="420" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="480" y="420" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="504" y="420" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="24" y="432" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="36" y="432" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="48" y="432" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="60" y="432" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="72" y="432" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="84" y="432" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="96" y="432" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="156" y="432" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="168" y="432" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="192" y="432" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="204" y="432" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="240" y="432" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="252" y="432" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="264" y="432" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="288" y="432" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="300" y="432" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="336" y="432" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="348" y="432" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="360" y="432" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="372" y="432" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="408" y="432" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="432" y="432" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="456" y="432" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="492" y="432" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="504" y="432" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="24" y="444" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="96" y="444" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="120" y="444" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="144" y="444" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="156" y="444" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="180" y="444" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="216" y="444" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="228" y="444" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="252" y="444" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="288" y="444" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="300" y="444" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="312" y="444" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="324" y="444" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="348" y="444" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="360" y="444" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="396" y="444" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="408" y="444" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="456" y="444" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="24" y="456" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="48" y="456" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="60" y="456" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="72" y="456" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="96" y="456" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="120" y="456" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="216" y="456" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="240" y="456" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="252" y="456" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="300" y="456" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="324" y="456" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="336" y="456" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="348" y="456" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="408" y="456" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="420" y="456" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="432" y="456" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="444" y="456" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="456" y="456" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="24" y="468" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="48" y="468" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="60" y="468" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="72" y="468" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="96" y="468" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="144" y="468" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="156" y="468" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="180" y="468" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="192" y="468" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="204" y="468" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="216" y="468" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="240" y="468" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="252" y="468" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="264" y="468" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="288" y="468" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="312" y="468" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="336" y="468" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="348" y="468" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="408" y="468" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="420" y="468" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="456" y="468" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="480" y="468" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="24" y="480" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="48" y="480" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="60" y="480" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="72" y="480" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="96" y="480" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="120" y="480" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="132" y="480" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="144" y="480" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="168" y="480" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="204" y="480" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="228" y="480" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="276" y="480" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="288" y="480" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="336" y="480" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="360" y="480" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="372" y="480" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="396" y="480" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="408" y="480" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="444" y="480" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="456" y="480" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="504" y="480" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="24" y="492" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="96" y="492" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="144" y="492" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="156" y="492" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="192" y="492" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="204" y="492" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="240" y="492" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="276" y="492" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="336" y="492" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="348" y="492" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="372" y="492" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="396" y="492" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="408" y="492" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="432" y="492" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="444" y="492" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="480" y="492" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="492" y="492" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="24" y="504" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="36" y="504" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="48" y="504" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="60" y="504" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="72" y="504" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="84" y="504" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="96" y="504" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="132" y="504" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="168" y="504" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="180" y="504" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="192" y="504" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="204" y="504" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="216" y="504" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="228" y="504" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="240" y="504" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="252" y="504" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="300" y="504" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="324" y="504" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="360" y="504" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="372" y="504" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="408" y="504" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="420" y="504" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="432" y="504" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="444" y="504" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="456" y="504" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="480" y="504" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="492" y="504" xlink:href="#p" fill="#000000" />
|
||||||
|
<use x="504" y="504" xlink:href="#p" fill="#000000" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 46 KiB |
BIN
public/sponsors/mjt.jpg
Normal file
|
After Width: | Height: | Size: 76 KiB |
1257
public/sponsors/wechat.svg
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
public/test
Normal file
246
scripts/clean-unused-images.js
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { glob } from 'glob';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理未使用的图片资源脚本
|
||||||
|
* 扫描 src/content/posts 下的所有 markdown 文件,
|
||||||
|
* 查找 src/content/assets 中未被引用的图片并删除
|
||||||
|
*/
|
||||||
|
|
||||||
|
const CONTENT_DIR = path.join(process.cwd(), 'src/content');
|
||||||
|
const POSTS_DIR = path.join(CONTENT_DIR, 'posts');
|
||||||
|
const ASSETS_DIR = path.join(CONTENT_DIR, 'assets');
|
||||||
|
|
||||||
|
// 支持的图片格式
|
||||||
|
const IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.avif'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有 markdown 文件
|
||||||
|
*/
|
||||||
|
async function getAllMarkdownFiles() {
|
||||||
|
try {
|
||||||
|
const pattern = path.join(POSTS_DIR, '**/*.md').replace(/\\/g, '/');
|
||||||
|
return await glob(pattern);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取 markdown 文件失败:', error.message);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有图片文件
|
||||||
|
*/
|
||||||
|
async function getAllImageFiles() {
|
||||||
|
try {
|
||||||
|
const extensions = IMAGE_EXTENSIONS.join(',');
|
||||||
|
const pattern = path.join(ASSETS_DIR, `**/*{${extensions}}`).replace(/\\/g, '/');
|
||||||
|
return await glob(pattern);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取图片文件失败:', error.message);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 markdown 内容中提取图片引用
|
||||||
|
*/
|
||||||
|
function extractImageReferences(content) {
|
||||||
|
const references = new Set();
|
||||||
|
|
||||||
|
// 匹配 YAML frontmatter 中的 image 字段(支持带引号和不带引号的值)
|
||||||
|
const yamlImageRegex = /^---[\s\S]*?image:\s*(?:['"]([^'"]+)['"]|([^\s\n]+))[\s\S]*?^---/m;
|
||||||
|
let match = yamlImageRegex.exec(content);
|
||||||
|
if (match) {
|
||||||
|
// match[1] 是带引号的值,match[2] 是不带引号的值
|
||||||
|
references.add(match[1] || match[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 匹配 markdown 图片语法: 
|
||||||
|
const markdownImageRegex = /!\[.*?\]\(([^)]+)\)/g;
|
||||||
|
while ((match = markdownImageRegex.exec(content)) !== null) {
|
||||||
|
references.add(match[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 匹配 HTML img 标签: <img src="path">
|
||||||
|
const htmlImageRegex = /<img[^>]+src=["']([^"']+)["'][^>]*>/gi;
|
||||||
|
while ((match = htmlImageRegex.exec(content)) !== null) {
|
||||||
|
references.add(match[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 匹配 Astro Image 组件引用
|
||||||
|
const astroImageRegex = /import\s+.*?\s+from\s+["']([^"']+\.(jpg|jpeg|png|gif|webp|svg|avif))["']/gi;
|
||||||
|
while ((match = astroImageRegex.exec(content)) !== null) {
|
||||||
|
references.add(match[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(references);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 规范化路径,处理相对路径和绝对路径
|
||||||
|
*/
|
||||||
|
function normalizePath(imagePath, markdownFilePath) {
|
||||||
|
// 跳过外部 URL
|
||||||
|
if (imagePath.startsWith('http://') || imagePath.startsWith('https://')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 跳过以 / 开头的绝对路径(通常指向 public 目录)
|
||||||
|
if (imagePath.startsWith('/')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理相对路径
|
||||||
|
if (imagePath.startsWith('./') || imagePath.startsWith('../')) {
|
||||||
|
const markdownDir = path.dirname(markdownFilePath);
|
||||||
|
return path.resolve(markdownDir, imagePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理直接的文件名或相对路径
|
||||||
|
const markdownDir = path.dirname(markdownFilePath);
|
||||||
|
return path.resolve(markdownDir, imagePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主函数
|
||||||
|
*/
|
||||||
|
async function cleanUnusedImages() {
|
||||||
|
console.log('🔍 开始扫描未使用的图片资源...');
|
||||||
|
|
||||||
|
// 检查目录是否存在
|
||||||
|
if (!fs.existsSync(POSTS_DIR)) {
|
||||||
|
console.error(`❌ Posts 目录不存在: ${POSTS_DIR}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(ASSETS_DIR)) {
|
||||||
|
console.log(`ℹ️ Assets 目录不存在: ${ASSETS_DIR}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取所有文件
|
||||||
|
const markdownFiles = await getAllMarkdownFiles();
|
||||||
|
const imageFiles = await getAllImageFiles();
|
||||||
|
|
||||||
|
console.log(`📄 找到 ${markdownFiles.length} 个 markdown 文件`);
|
||||||
|
console.log(`🖼️ 找到 ${imageFiles.length} 个图片文件`);
|
||||||
|
|
||||||
|
if (imageFiles.length === 0) {
|
||||||
|
console.log('✅ 没有找到图片文件,无需清理');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 收集所有被引用的图片
|
||||||
|
const referencedImages = new Set();
|
||||||
|
|
||||||
|
for (const mdFile of markdownFiles) {
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(mdFile, 'utf-8');
|
||||||
|
const references = extractImageReferences(content);
|
||||||
|
|
||||||
|
for (const ref of references) {
|
||||||
|
const normalizedPath = normalizePath(ref, mdFile);
|
||||||
|
if (normalizedPath) {
|
||||||
|
const resolvedPath = path.resolve(normalizedPath);
|
||||||
|
referencedImages.add(resolvedPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`⚠️ 读取文件失败: ${mdFile} - ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`🔗 找到 ${referencedImages.size} 个被引用的图片`);
|
||||||
|
|
||||||
|
// 找出未被引用的图片
|
||||||
|
const unusedImages = [];
|
||||||
|
|
||||||
|
for (const imageFile of imageFiles) {
|
||||||
|
const resolvedImagePath = path.resolve(imageFile);
|
||||||
|
const isReferenced = referencedImages.has(resolvedImagePath);
|
||||||
|
|
||||||
|
if (!isReferenced) {
|
||||||
|
unusedImages.push(imageFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`🗑️ 找到 ${unusedImages.length} 个未使用的图片`);
|
||||||
|
|
||||||
|
if (unusedImages.length === 0) {
|
||||||
|
console.log('✅ 所有图片都在使用中,无需清理');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除未使用的图片
|
||||||
|
let deletedCount = 0;
|
||||||
|
|
||||||
|
for (const unusedImage of unusedImages) {
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(unusedImage);
|
||||||
|
console.log(`🗑️ 已删除: ${path.relative(process.cwd(), unusedImage)}`);
|
||||||
|
deletedCount++;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`❌ 删除失败: ${unusedImage} - ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理空目录
|
||||||
|
try {
|
||||||
|
cleanEmptyDirectories(ASSETS_DIR);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`⚠️ 清理空目录时出错: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n✅ 清理完成!删除了 ${deletedCount} 个未使用的图片文件`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 递归清理空目录
|
||||||
|
*/
|
||||||
|
function cleanEmptyDirectories(dir) {
|
||||||
|
if (!fs.existsSync(dir)) return;
|
||||||
|
|
||||||
|
const files = fs.readdirSync(dir);
|
||||||
|
|
||||||
|
if (files.length === 0) {
|
||||||
|
fs.rmdirSync(dir);
|
||||||
|
console.log(`🗑️ 已删除空目录: ${path.relative(process.cwd(), dir)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const filePath = path.join(dir, file);
|
||||||
|
if (fs.statSync(filePath).isDirectory()) {
|
||||||
|
cleanEmptyDirectories(filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 再次检查目录是否为空
|
||||||
|
const remainingFiles = fs.readdirSync(dir);
|
||||||
|
if (remainingFiles.length === 0) {
|
||||||
|
fs.rmdirSync(dir);
|
||||||
|
console.log(`🗑️ 已删除空目录: ${path.relative(process.cwd(), dir)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 运行脚本
|
||||||
|
// 检查是否直接运行此脚本
|
||||||
|
const scriptPath = fileURLToPath(import.meta.url);
|
||||||
|
const isMainModule = process.argv[1] && path.resolve(process.argv[1]) === path.resolve(scriptPath);
|
||||||
|
|
||||||
|
if (isMainModule) {
|
||||||
|
cleanUnusedImages().catch(error => {
|
||||||
|
console.error('❌ 脚本执行失败:', error.message);
|
||||||
|
console.error(error.stack);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export { cleanUnusedImages };
|
||||||
74
scripts/convert_friends.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import re
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
def extract_friend_data(html_content):
|
||||||
|
"""从HTML内容中提取友链数据"""
|
||||||
|
# 匹配友链卡片的正则表达式
|
||||||
|
pattern = r'<a href="([^"]+)"[^>]*class="friend-card">\s*' \
|
||||||
|
r'<div class="flex items-center gap-2">\s*' \
|
||||||
|
r'<img src="([^"]+)"[^>]*>\s*' \
|
||||||
|
r'<div class="font-bold[^"]*">([^<]+)</div>\s*' \
|
||||||
|
r'</div>\s*' \
|
||||||
|
r'<div class="text-sm[^"]*">([^<]+)</div>'
|
||||||
|
|
||||||
|
friends = []
|
||||||
|
for match in re.finditer(pattern, html_content, re.DOTALL):
|
||||||
|
url, avatar, name, description = match.groups()
|
||||||
|
friend = {
|
||||||
|
"name": name.strip(),
|
||||||
|
"avatar": avatar.strip(),
|
||||||
|
"description": description.strip(),
|
||||||
|
"url": url.strip()
|
||||||
|
}
|
||||||
|
friends.append(friend)
|
||||||
|
return friends
|
||||||
|
|
||||||
|
def read_friends_astro():
|
||||||
|
"""读取friends.astro文件内容"""
|
||||||
|
file_path = os.path.join(os.path.dirname(os.path.dirname(__file__)),
|
||||||
|
'src', 'pages', 'friends.astro')
|
||||||
|
with open(file_path, 'r', encoding='utf-8') as f:
|
||||||
|
return f.read()
|
||||||
|
|
||||||
|
def read_existing_friends_json():
|
||||||
|
"""读取现有的friends.json文件内容"""
|
||||||
|
file_path = os.path.join(os.path.dirname(os.path.dirname(__file__)),
|
||||||
|
'src', 'data', 'friends.json')
|
||||||
|
try:
|
||||||
|
with open(file_path, 'r', encoding='utf-8') as f:
|
||||||
|
return json.load(f)
|
||||||
|
except (FileNotFoundError, json.JSONDecodeError):
|
||||||
|
return {"friends": []}
|
||||||
|
|
||||||
|
def write_friends_json(friends_data):
|
||||||
|
"""将友链数据写入friends.json文件"""
|
||||||
|
file_path = os.path.join(os.path.dirname(os.path.dirname(__file__)),
|
||||||
|
'src', 'data', 'friends.json')
|
||||||
|
with open(file_path, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(friends_data, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# 读取friends.astro内容
|
||||||
|
astro_content = read_friends_astro()
|
||||||
|
|
||||||
|
# 提取友链数据
|
||||||
|
new_friends = extract_friend_data(astro_content)
|
||||||
|
|
||||||
|
# 读取现有的friends.json
|
||||||
|
existing_data = read_existing_friends_json()
|
||||||
|
|
||||||
|
# 将新的友链数据添加到现有数据中
|
||||||
|
# 使用URL作为唯一标识符,避免重复
|
||||||
|
existing_urls = {friend["url"] for friend in existing_data["friends"]}
|
||||||
|
for friend in new_friends:
|
||||||
|
if friend["url"] not in existing_urls:
|
||||||
|
existing_data["friends"].append(friend)
|
||||||
|
existing_urls.add(friend["url"])
|
||||||
|
|
||||||
|
# 写入更新后的数据
|
||||||
|
write_friends_json(existing_data)
|
||||||
|
print(f"成功提取并添加了 {len(new_friends)} 个友链数据")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
81
scripts/generate-gallery-index.js
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { glob } from 'glob';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
const rootDir = path.resolve(__dirname, '..');
|
||||||
|
|
||||||
|
const IMAGE_DIR = path.join(rootDir, 'public', 'api', 'i');
|
||||||
|
const OUTPUT_FILE = path.join(IMAGE_DIR, 'images.json');
|
||||||
|
|
||||||
|
// 支持的图片扩展名
|
||||||
|
const IMAGE_EXTENSIONS = new Set([
|
||||||
|
'.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.bmp', '.ico', '.avif'
|
||||||
|
]);
|
||||||
|
|
||||||
|
function parseImagePath(relativePath) {
|
||||||
|
// 路径格式: public/api/i/YYYY/MM/DD/filename.ext
|
||||||
|
const match = relativePath.match(/(\d{4})\/(\d{1,2})\/(\d{1,2})\/([^/]+)\.(.+)$/);
|
||||||
|
if (!match) return null;
|
||||||
|
|
||||||
|
const [, year, month, day, filename, ext] = match;
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: `/api/i/${year}/${month}/${day}/${filename}.${ext}`,
|
||||||
|
filename: `${filename}.${ext}`,
|
||||||
|
year,
|
||||||
|
month: month.padStart(2, '0'),
|
||||||
|
day: day.padStart(2, '0'),
|
||||||
|
date: `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateGalleryIndex() {
|
||||||
|
console.log('正在扫描图片目录...');
|
||||||
|
|
||||||
|
// 扫描所有图片文件
|
||||||
|
const pattern = path.join(IMAGE_DIR, '**/*.*').replace(/\\/g, '/');
|
||||||
|
const files = await glob(pattern, {
|
||||||
|
ignore: [
|
||||||
|
'**/images.json', // 忽略索引文件本身
|
||||||
|
'**/cache/**', // 忽略缓存目录
|
||||||
|
'**/gallery-meow/**', // 忽略其他目录
|
||||||
|
'**/favicon.ico', // 忽略 favicon
|
||||||
|
'**/index.html' // 忽略 index.html
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`找到 ${files.length} 个文件`);
|
||||||
|
|
||||||
|
// 解析并过滤图片
|
||||||
|
const images = [];
|
||||||
|
for (const file of files) {
|
||||||
|
// 获取相对路径
|
||||||
|
const relativePath = path.relative(IMAGE_DIR, file).replace(/\\/g, '/');
|
||||||
|
|
||||||
|
// 检查文件扩展名
|
||||||
|
const ext = path.extname(file).toLowerCase();
|
||||||
|
if (!IMAGE_EXTENSIONS.has(ext)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析路径信息
|
||||||
|
const parsed = parseImagePath(relativePath);
|
||||||
|
if (parsed) {
|
||||||
|
images.push(parsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按日期倒序排序
|
||||||
|
images.sort((a, b) => b.date.localeCompare(a.date));
|
||||||
|
|
||||||
|
console.log(`共找到 ${images.length} 张图片`);
|
||||||
|
|
||||||
|
// 写入 JSON 文件
|
||||||
|
fs.writeFileSync(OUTPUT_FILE, JSON.stringify(images, null, 2), 'utf-8');
|
||||||
|
console.log(`已生成索引文件: ${OUTPUT_FILE}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
generateGalleryIndex().catch(console.error);
|
||||||
61
scripts/new-post.js
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
/* This is a script to create a new post markdown file with front-matter */
|
||||||
|
|
||||||
|
import fs from "fs"
|
||||||
|
import path from "path"
|
||||||
|
|
||||||
|
function getDate() {
|
||||||
|
const today = new Date()
|
||||||
|
const year = today.getFullYear()
|
||||||
|
const month = String(today.getMonth() + 1).padStart(2, "0")
|
||||||
|
const day = String(today.getDate()).padStart(2, "0")
|
||||||
|
const hours = String(today.getHours()).padStart(2, "0")
|
||||||
|
const minutes = String(today.getMinutes()).padStart(2, "0")
|
||||||
|
const seconds = String(today.getSeconds()).padStart(2, "0")
|
||||||
|
|
||||||
|
return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = process.argv.slice(2)
|
||||||
|
|
||||||
|
if (args.length === 0) {
|
||||||
|
console.error(`Error: No filename argument provided
|
||||||
|
Usage: npm run new-post -- <filename>`)
|
||||||
|
process.exit(1) // Terminate the script and return error code 1
|
||||||
|
}
|
||||||
|
|
||||||
|
let fileName = args[0]
|
||||||
|
|
||||||
|
// Add .md extension if not present
|
||||||
|
const fileExtensionRegex = /\.(md|mdx)$/i
|
||||||
|
if (!fileExtensionRegex.test(fileName)) {
|
||||||
|
fileName += ".md"
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetDir = "./src/content/posts/"
|
||||||
|
const fullPath = path.join(targetDir, fileName)
|
||||||
|
|
||||||
|
if (fs.existsSync(fullPath)) {
|
||||||
|
console.error(`Error: File ${fullPath} already exists `)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// recursive mode creates multi-level directories
|
||||||
|
const dirPath = path.dirname(fullPath)
|
||||||
|
if (!fs.existsSync(dirPath)) {
|
||||||
|
fs.mkdirSync(dirPath, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = `---
|
||||||
|
title: ${args[0]}
|
||||||
|
published: ${getDate()}
|
||||||
|
description: ''
|
||||||
|
image: ''
|
||||||
|
|
||||||
|
draft: false
|
||||||
|
lang: ''
|
||||||
|
---
|
||||||
|
`
|
||||||
|
|
||||||
|
fs.writeFileSync(path.join(targetDir, fileName), content)
|
||||||
|
|
||||||
|
console.log(`Post ${fullPath} created`)
|
||||||
78
scripts/test-category-logic.js
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
const CATEGORY_SEPARATOR = " > ";
|
||||||
|
|
||||||
|
function parseCategoryPath(categoryString) {
|
||||||
|
return categoryString
|
||||||
|
.split(CATEGORY_SEPARATOR)
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringifyCategoryPath(path) {
|
||||||
|
return path.join(CATEGORY_SEPARATOR);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPostsByCategory(posts, categoryPath) {
|
||||||
|
const targetPathString = stringifyCategoryPath(categoryPath);
|
||||||
|
console.log(`Target Path: "${targetPathString}"`);
|
||||||
|
|
||||||
|
return posts.filter((post) => {
|
||||||
|
const categories = post.data.category;
|
||||||
|
const categoryArray =
|
||||||
|
typeof categories === "string" ? [categories] : categories;
|
||||||
|
|
||||||
|
return categoryArray.some((cat) => {
|
||||||
|
const catPath = parseCategoryPath(cat);
|
||||||
|
const catPathString = stringifyCategoryPath(catPath);
|
||||||
|
console.log(
|
||||||
|
` Checking Post: "${post.data.title}", Category: "${cat}", Parsed: "${catPathString}"`
|
||||||
|
);
|
||||||
|
|
||||||
|
const match =
|
||||||
|
catPathString === targetPathString ||
|
||||||
|
catPathString.startsWith(targetPathString + CATEGORY_SEPARATOR);
|
||||||
|
console.log(` Match: ${match}`);
|
||||||
|
return match;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock Data
|
||||||
|
const posts = [
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
title: "Post 1",
|
||||||
|
category: "Java > Spring",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
title: "Post 2",
|
||||||
|
category: ["Java > Spring"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
title: "Post 3",
|
||||||
|
category: "Java > Spring > Boot",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
title: "Post 4",
|
||||||
|
category: "Java",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
title: "Post 5",
|
||||||
|
category: "Other > Spring",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const targetPath = ["Java", "Spring"];
|
||||||
|
|
||||||
|
console.log("Testing getPostsByCategory...");
|
||||||
|
const result = getPostsByCategory(posts, targetPath);
|
||||||
|
console.log(`Found ${result.length} posts.`);
|
||||||
|
result.forEach((p) => console.log(` - ${p.data.title}`));
|
||||||
93
src/components/ArchivePanel.astro
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
---
|
||||||
|
|
||||||
|
import { getSortedPosts } from "../utils/content-utils";
|
||||||
|
import { getPostUrlBySlug } from "../utils/url-utils";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
keyword?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let posts = await getSortedPosts();
|
||||||
|
|
||||||
|
const groups: { year: number; posts: typeof posts }[] = (() => {
|
||||||
|
const groupedPosts = posts.reduce(
|
||||||
|
(grouped: { [year: number]: typeof posts }, post) => {
|
||||||
|
const year = post.data.published.getFullYear();
|
||||||
|
if (!grouped[year]) {
|
||||||
|
grouped[year] = [];
|
||||||
|
}
|
||||||
|
grouped[year].push(post);
|
||||||
|
return grouped;
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
// convert the object to an array
|
||||||
|
const groupedPostsArray = Object.keys(groupedPosts).map((key) => ({
|
||||||
|
year: Number.parseInt(key),
|
||||||
|
posts: groupedPosts[Number.parseInt(key)],
|
||||||
|
}));
|
||||||
|
|
||||||
|
// sort years by latest first
|
||||||
|
groupedPostsArray.sort((a, b) => b.year - a.year);
|
||||||
|
return groupedPostsArray;
|
||||||
|
})();
|
||||||
|
|
||||||
|
function formatDate(date: Date) {
|
||||||
|
const month = (date.getMonth() + 1).toString().padStart(2, "0");
|
||||||
|
const day = date.getDate().toString().padStart(2, "0");
|
||||||
|
return `${month}-${day}`;
|
||||||
|
}
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class="card-base px-8 py-6">
|
||||||
|
{
|
||||||
|
groups.map(group => (
|
||||||
|
<div>
|
||||||
|
<div class="flex flex-row w-full items-center h-[3.75rem]">
|
||||||
|
<div class="w-[15%] md:w-[10%] transition text-2xl font-bold text-right text-75">{group.year}</div>
|
||||||
|
<div class="w-[15%] md:w-[10%]">
|
||||||
|
<div class="h-3 w-3 bg-none rounded-full outline outline-[var(--primary)] mx-auto -outline-offset-[2px] z-50 outline-3"></div>
|
||||||
|
</div>
|
||||||
|
<div class="w-[70%] md:w-[80%] transition text-left text-50">{group.posts.length} 篇文章</div>
|
||||||
|
</div>
|
||||||
|
{group.posts.map(post => (
|
||||||
|
<a href={getPostUrlBySlug(post.slug)}
|
||||||
|
aria-label={post.data.title}
|
||||||
|
class="group btn-plain !block h-10 w-full rounded-lg hover:text-[initial]"
|
||||||
|
>
|
||||||
|
<div class="flex flex-row justify-start items-center h-full">
|
||||||
|
<!-- date -->
|
||||||
|
<div class="w-[15%] md:w-[10%] transition text-sm text-right text-50">
|
||||||
|
{formatDate(post.data.published)}
|
||||||
|
</div>
|
||||||
|
<!-- dot and line -->
|
||||||
|
<div class="w-[15%] md:w-[10%] relative dash-line h-full flex items-center">
|
||||||
|
<div class="transition-all mx-auto w-1 h-1 rounded group-hover:h-5
|
||||||
|
bg-[oklch(0.5_0.05_var(--hue))] group-hover:bg-[var(--primary)]
|
||||||
|
outline outline-4 z-50
|
||||||
|
outline-[var(--card-bg)]
|
||||||
|
group-hover:outline-[var(--btn-plain-bg-hover)]
|
||||||
|
group-active:outline-[var(--btn-plain-bg-active)]
|
||||||
|
"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<!-- post title -->
|
||||||
|
<div class="w-[70%] md:max-w-[65%] md:w-[65%] text-left font-bold
|
||||||
|
group-hover:translate-x-1 transition-all group-hover:text-[var(--primary)]
|
||||||
|
text-75 pr-8 whitespace-nowrap overflow-ellipsis overflow-hidden"
|
||||||
|
>
|
||||||
|
{post.data.title}
|
||||||
|
</div>
|
||||||
|
<!-- tag list -->
|
||||||
|
<div class="hidden md:block md:w-[15%] text-left text-sm transition
|
||||||
|
whitespace-nowrap overflow-ellipsis overflow-hidden
|
||||||
|
text-30"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
37
src/components/CategoryPanel.astro
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
import type { CollectionEntry } from "astro:content";
|
||||||
|
import { getPostUrlBySlug } from "../utils/url-utils";
|
||||||
|
import { formatDateToYYYYMMDD } from "../utils/date-utils";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
posts: CollectionEntry<"posts">[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { posts } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
{posts.map((post) => (
|
||||||
|
<a
|
||||||
|
href={getPostUrlBySlug(post.slug)}
|
||||||
|
class="card-base px-6 py-4 rounded-lg hover:border-[var(--primary)] transition-all group"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<div class="text-sm text-50 whitespace-nowrap">
|
||||||
|
{formatDateToYYYYMMDD(post.data.published)}
|
||||||
|
</div>
|
||||||
|
<div class="w-px h-5 bg-[var(--line-divider)]"></div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<h3 class="font-bold text-75 group-hover:text-[var(--primary)] transition-all">
|
||||||
|
{post.data.title}
|
||||||
|
</h3>
|
||||||
|
{post.data.description && (
|
||||||
|
<p class="text-sm text-50 mt-1 line-clamp-1">
|
||||||
|
{post.data.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
62
src/components/CategoryTree.astro
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
---
|
||||||
|
import type { CategoryNode } from "@/types/config";
|
||||||
|
import { Icon } from "astro-icon/components";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
node: CategoryNode;
|
||||||
|
depth?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { node, depth = 0 } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class="relative transition-all">
|
||||||
|
<div
|
||||||
|
class:list={[
|
||||||
|
"flex items-center justify-between rounded-lg hover:bg-[var(--btn-plain-bg-hover)] transition-all group select-none",
|
||||||
|
depth === 0
|
||||||
|
? "p-4 bg-[var(--card-bg)] border border-[var(--line-divider)] mb-2"
|
||||||
|
: "p-2",
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<a href={node.url} class="flex items-center gap-2 flex-1 min-w-0">
|
||||||
|
<Icon
|
||||||
|
name={depth === 0
|
||||||
|
? "material-symbols:folder-open-rounded"
|
||||||
|
: "material-symbols:folder-outline-rounded"}
|
||||||
|
class:list={[
|
||||||
|
"text-[var(--primary)] shrink-0",
|
||||||
|
depth === 0 ? "text-2xl" : "text-xl",
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
class:list={[
|
||||||
|
"truncate group-hover:text-[var(--primary)] transition-all",
|
||||||
|
depth === 0 ? "font-bold text-lg text-90" : "font-medium text-75",
|
||||||
|
]}>{node.name}</span
|
||||||
|
>
|
||||||
|
</a>
|
||||||
|
<span
|
||||||
|
class="text-sm text-50 bg-black/5 dark:bg-white/10 px-2 py-0.5 rounded shrink-0 ml-2"
|
||||||
|
>
|
||||||
|
{node.count}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{
|
||||||
|
node.children && node.children.length > 0 && (
|
||||||
|
<div
|
||||||
|
class:list={[
|
||||||
|
"flex flex-col gap-1",
|
||||||
|
depth === 0
|
||||||
|
? "mt-1 mb-3 ml-6 pl-4 border-l-2 border-[var(--line-divider)]"
|
||||||
|
: "ml-4 pl-4 border-l-2 border-[var(--line-divider)]",
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{node.children.map((child) => (
|
||||||
|
<Astro.self node={child} depth={depth + 1} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
7
src/components/ConfigCarrier.astro
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
|
||||||
|
import { siteConfig } from "../config";
|
||||||
|
---
|
||||||
|
|
||||||
|
<div id="config-carrier" data-hue={siteConfig.themeColor.hue}>
|
||||||
|
</div>
|
||||||
170
src/components/Footer.astro
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
---
|
||||||
|
import { profileConfig } from "../config";
|
||||||
|
import { url } from "../utils/url-utils";
|
||||||
|
import { execSync } from "child_process";
|
||||||
|
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
|
||||||
|
let commitHash = "unknown";
|
||||||
|
let buildDate = "unknown";
|
||||||
|
|
||||||
|
try {
|
||||||
|
commitHash = execSync("git rev-parse --short=7 HEAD").toString().trim();
|
||||||
|
|
||||||
|
const date = new Date();
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = (date.getMonth() + 1).toString().padStart(2, "0");
|
||||||
|
const day = date.getDate().toString().padStart(2, "0");
|
||||||
|
const hours = date.getHours().toString().padStart(2, "0");
|
||||||
|
const minutes = date.getMinutes().toString().padStart(2, "0");
|
||||||
|
const seconds = date.getSeconds().toString().padStart(2, "0");
|
||||||
|
buildDate = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Failed to get git info", e);
|
||||||
|
}
|
||||||
|
---
|
||||||
|
|
||||||
|
<!--<div class="border-t border-[var(--primary)] mx-16 border-dashed py-8 max-w-[var(--page-width)] flex flex-col items-center justify-center px-6">-->
|
||||||
|
<div
|
||||||
|
class="transition border-t border-black/10 dark:border-white/15 my-10 border-dashed mx-4 md:mx-32"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="card-base w-fit mx-auto rounded-xl mt-4 mb-4">
|
||||||
|
<div class="transition text-50 text-sm text-center p-6">
|
||||||
|
© <span id="copyright-year">2024 - {currentYear}</span>
|
||||||
|
<a
|
||||||
|
class="transition link text-[var(--primary)] font-medium"
|
||||||
|
target="_blank"
|
||||||
|
href="https://space.bilibili.com/325903362"
|
||||||
|
>
|
||||||
|
{profileConfig.name}
|
||||||
|
</a>,采用
|
||||||
|
<a
|
||||||
|
class="transition link text-[var(--primary)] font-medium"
|
||||||
|
target="_blank"
|
||||||
|
href="https://creativecommons.org/licenses/by-nc-sa/4.0/"
|
||||||
|
>CC BY-NC-SA 4.0</a
|
||||||
|
> 许可
|
||||||
|
<br />
|
||||||
|
<a
|
||||||
|
class="transition link text-[var(--primary)] font-medium"
|
||||||
|
target="_blank"
|
||||||
|
href={url("rss.xml")}>RSS</a
|
||||||
|
> /
|
||||||
|
<a
|
||||||
|
class="transition link text-[var(--primary)] font-medium"
|
||||||
|
target="_blank"
|
||||||
|
href={url("sitemap-index.xml")}>网站地图</a
|
||||||
|
>
|
||||||
|
<br />
|
||||||
|
由
|
||||||
|
<a
|
||||||
|
class="transition link text-[var(--primary)] font-medium"
|
||||||
|
target="_blank"
|
||||||
|
href="https://astro.build">Astro</a
|
||||||
|
> 和
|
||||||
|
<a
|
||||||
|
class="transition link text-[var(--primary)] font-medium"
|
||||||
|
target="_blank"
|
||||||
|
href="https://github.com/saicaca/fuwari">Fuwari</a
|
||||||
|
> 强力驱动
|
||||||
|
<br />
|
||||||
|
本网站代码
|
||||||
|
<a
|
||||||
|
class="transition link text-[var(--primary)] font-medium"
|
||||||
|
target="_blank"
|
||||||
|
href="https://github.com/afoim/fuwari">已开源</a
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
class="transition link text-black/30 dark:text-white/30 hover:text-[var(--primary)] text-xs ml-1"
|
||||||
|
target="_blank"
|
||||||
|
href={`https://github.com/afoim/fuwari/commit/${commitHash}`}
|
||||||
|
>({commitHash} @ {buildDate})</a
|
||||||
|
>
|
||||||
|
<br />
|
||||||
|
|
||||||
|
<a
|
||||||
|
class="transition link text-[var(--primary)] font-medium inline-flex items-center"
|
||||||
|
href="https://beian.miit.gov.cn/#/Integrated/index"
|
||||||
|
target="_blank"
|
||||||
|
><img
|
||||||
|
alt=""
|
||||||
|
src="/favicon/foot-icp.png"
|
||||||
|
class="h-4 mr-1"
|
||||||
|
/>晋ICP备2025071728号-1</a
|
||||||
|
>
|
||||||
|
<br />
|
||||||
|
<div class="server-info-wrapper flex items-center justify-center">
|
||||||
|
<span class="server-info text-black/30 dark:text-white/30 text-xs"></span>
|
||||||
|
<img
|
||||||
|
class="server-icon h-5 ml-1 hidden bg-white rounded p-0.5"
|
||||||
|
alt="Server Icon"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- <a class="transition link text-[var(--primary)] font-medium inline-flex items-center" href="https://beian.mps.gov.cn/#/query/webSearch?code=34010302002608" target="_blank"><img alt="" src="/favicon/foot-ga.png" class="h-4 mr-1">皖公网安备34010302002608号</a>
|
||||||
|
<br> -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function updateServerInfo(server) {
|
||||||
|
const wrappers = document.querySelectorAll(".server-info-wrapper");
|
||||||
|
wrappers.forEach((wrapper) => {
|
||||||
|
const serverInfo = wrapper.querySelector(".server-info");
|
||||||
|
const serverIcon = wrapper.querySelector(".server-icon");
|
||||||
|
if (serverInfo) {
|
||||||
|
if (server) {
|
||||||
|
serverInfo.innerText = `访问节点:${server}`;
|
||||||
|
if (serverIcon) {
|
||||||
|
const serverLower = server.toLowerCase();
|
||||||
|
if (serverLower === "edgeone-pages") {
|
||||||
|
serverIcon.src = "/cdn/eo.png";
|
||||||
|
serverIcon.classList.remove("hidden");
|
||||||
|
serverInfo.classList.add("hidden");
|
||||||
|
} else if (serverLower === "cloudflare") {
|
||||||
|
serverIcon.src = "/cdn/cf.svg";
|
||||||
|
serverIcon.classList.remove("hidden");
|
||||||
|
serverInfo.classList.add("hidden");
|
||||||
|
} else if (serverLower === "esa") {
|
||||||
|
serverIcon.src = "/cdn/esa.svg";
|
||||||
|
serverIcon.classList.remove("hidden");
|
||||||
|
serverInfo.classList.add("hidden");
|
||||||
|
} else {
|
||||||
|
serverIcon.classList.add("hidden");
|
||||||
|
serverInfo.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
serverInfo.innerText = "访问节点:未知";
|
||||||
|
serverInfo.classList.remove("hidden");
|
||||||
|
if (serverIcon) serverIcon.classList.add("hidden");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function initServerInfo() {
|
||||||
|
const isDevMode = localStorage.getItem("dev-mode") === "true";
|
||||||
|
if (isDevMode) {
|
||||||
|
const devServer = localStorage.getItem("dev-server");
|
||||||
|
updateServerInfo(devServer);
|
||||||
|
} else {
|
||||||
|
const url = window.location.href;
|
||||||
|
fetch(url, { method: "HEAD" })
|
||||||
|
.then((response) => {
|
||||||
|
const server = response.headers.get("server");
|
||||||
|
updateServerInfo(server);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Failed to fetch server info:", error);
|
||||||
|
updateServerInfo(null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
initServerInfo();
|
||||||
|
document.addEventListener("astro:after-swap", initServerInfo); // Support View Transitions if enabled
|
||||||
|
// Support Swup if enabled (listen to content replacement)
|
||||||
|
document.addEventListener("content:replace", initServerInfo);
|
||||||
|
</script>
|
||||||
127
src/components/Navbar.astro
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
---
|
||||||
|
import { Icon } from "astro-icon/components";
|
||||||
|
import { navBarConfig, siteConfig } from "../config";
|
||||||
|
import { LinkPresets } from "../constants/link-presets";
|
||||||
|
import { LinkPreset, type NavBarLink } from "../types/config";
|
||||||
|
import { url } from "../utils/url-utils";
|
||||||
|
|
||||||
|
import Search from "./Search.svelte";
|
||||||
|
import DisplaySettings from "./widget/DisplaySettings.svelte";
|
||||||
|
import NavMenuPanel from "./widget/NavMenuPanel.astro";
|
||||||
|
const className = Astro.props.class;
|
||||||
|
|
||||||
|
let links: NavBarLink[] = navBarConfig.links.map(
|
||||||
|
(item: NavBarLink | LinkPreset): NavBarLink => {
|
||||||
|
if (typeof item === "number") {
|
||||||
|
return LinkPresets[item];
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
---
|
||||||
|
|
||||||
|
<div id="navbar" class="z-50 onload-animation">
|
||||||
|
<div
|
||||||
|
class="absolute h-8 left-0 right-0 -top-8 bg-[var(--card-bg)] transition"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<!-- used for onload animation -->
|
||||||
|
<div
|
||||||
|
class:list={[
|
||||||
|
className,
|
||||||
|
"card-base border border-black/10 dark:border-white/10 !overflow-visible max-w-[var(--page-width)] h-[4.5rem] !rounded-t-none mx-auto flex items-center justify-between px-4",
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={url("/")}
|
||||||
|
class="btn-plain scale-animation rounded-lg h-[3.25rem] px-5 font-bold active:scale-95"
|
||||||
|
>
|
||||||
|
<div class="flex flex-row text-[var(--primary)] items-center text-md">
|
||||||
|
<Icon
|
||||||
|
name="material-symbols:home-outline-rounded"
|
||||||
|
class="text-[1.75rem] mb-1 mr-2"
|
||||||
|
/>
|
||||||
|
{siteConfig.title}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<div class="hidden md:flex">
|
||||||
|
{
|
||||||
|
links.map((l) => {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
aria-label={l.name}
|
||||||
|
href={l.external ? l.url : url(l.url)}
|
||||||
|
target={l.external ? "_blank" : null}
|
||||||
|
class="btn-plain scale-animation rounded-lg h-11 font-bold px-5 active:scale-95"
|
||||||
|
>
|
||||||
|
<div class="flex items-center">
|
||||||
|
{l.name}
|
||||||
|
{l.external && (
|
||||||
|
<Icon
|
||||||
|
name="fa6-solid:arrow-up-right-from-square"
|
||||||
|
class="text-[0.875rem] transition -translate-y-[1px] ml-1 text-black/[0.2] dark:text-white/[0.2]"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="flex">
|
||||||
|
<!--<SearchPanel client:load>-->
|
||||||
|
<Search client:only="svelte" />
|
||||||
|
{
|
||||||
|
!siteConfig.themeColor.fixed && (
|
||||||
|
<button
|
||||||
|
aria-label="Display Settings"
|
||||||
|
class="btn-plain scale-animation rounded-lg h-11 w-11 active:scale-90"
|
||||||
|
id="display-settings-switch"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
name="material-symbols:palette-outline"
|
||||||
|
class="text-[1.25rem]"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
<button
|
||||||
|
aria-label="Menu"
|
||||||
|
name="Nav Menu"
|
||||||
|
class="btn-plain scale-animation rounded-lg w-11 h-11 active:scale-90 md:!hidden"
|
||||||
|
id="nav-menu-switch"
|
||||||
|
>
|
||||||
|
<Icon name="material-symbols:menu-rounded" class="text-[1.25rem]" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<NavMenuPanel links={links} />
|
||||||
|
<DisplaySettings client:only="svelte" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function loadButtonScript() {
|
||||||
|
let settingBtn = document.getElementById("display-settings-switch");
|
||||||
|
if (settingBtn) {
|
||||||
|
settingBtn.onclick = function () {
|
||||||
|
let settingPanel = document.getElementById("display-setting");
|
||||||
|
if (settingPanel) {
|
||||||
|
settingPanel.classList.toggle("float-panel-closed");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let menuBtn = document.getElementById("nav-menu-switch");
|
||||||
|
if (menuBtn) {
|
||||||
|
menuBtn.onclick = function () {
|
||||||
|
let menuPanel = document.getElementById("nav-menu-panel");
|
||||||
|
if (menuPanel) {
|
||||||
|
menuPanel.classList.toggle("float-panel-closed");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadButtonScript();
|
||||||
|
</script>
|
||||||
112
src/components/PostCard.astro
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
---
|
||||||
|
import path from "node:path";
|
||||||
|
import type { CollectionEntry } from "astro:content";
|
||||||
|
import { Icon } from "astro-icon/components";
|
||||||
|
|
||||||
|
import { getDir } from "../utils/url-utils";
|
||||||
|
import { formatDateToYYYYMMDD } from "../utils/date-utils";
|
||||||
|
import PostMetadata from "./PostMeta.astro";
|
||||||
|
import ImageWrapper from "./misc/ImageWrapper.astro";
|
||||||
|
import { umamiConfig } from "../config";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
class?: string;
|
||||||
|
entry: CollectionEntry<"posts">;
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
published: Date;
|
||||||
|
updated?: Date;
|
||||||
|
image: string;
|
||||||
|
description: string;
|
||||||
|
draft: boolean;
|
||||||
|
style: string;
|
||||||
|
category?: string | string[];
|
||||||
|
}
|
||||||
|
const {
|
||||||
|
entry,
|
||||||
|
title,
|
||||||
|
url,
|
||||||
|
published,
|
||||||
|
updated,
|
||||||
|
image,
|
||||||
|
description,
|
||||||
|
style,
|
||||||
|
category,
|
||||||
|
} = Astro.props;
|
||||||
|
|
||||||
|
const isPinned = entry.data.pinned === true;
|
||||||
|
const className = Astro.props.class;
|
||||||
|
|
||||||
|
const hasCover = image !== undefined && image !== null && image !== "";
|
||||||
|
|
||||||
|
const coverWidth = "28%";
|
||||||
|
|
||||||
|
const { remarkPluginFrontmatter } = await entry.render();
|
||||||
|
---
|
||||||
|
<div class:list={["card-base border border-black/10 dark:border-white/10 flex flex-col w-full rounded-[var(--radius-large)] overflow-hidden relative", className]} style={style}>
|
||||||
|
<div class:list={["pl-9 pr-2 pt-6 pb-6 relative", {"w-[calc(100%_-_52px_-_12px)]": !hasCover, "w-[calc(100%_-_var(--coverWidth)_-_12px)]": hasCover}]}>
|
||||||
|
<a href={url}
|
||||||
|
class="transition group w-full block font-bold mb-2 md:mb-3 text-xl md:text-3xl text-90 relative
|
||||||
|
hover:text-[var(--primary)] dark:hover:text-[var(--primary)]
|
||||||
|
active:text-[var(--title-active)] dark:active:text-[var(--title-active)]
|
||||||
|
before:w-1 before:h-5 before:rounded-md before:bg-[var(--primary)]
|
||||||
|
before:absolute before:top-1 md:before:top-2 before:-left-[1.125rem] before:block
|
||||||
|
">
|
||||||
|
|
||||||
|
{title}
|
||||||
|
<Icon class="text-[var(--primary)] text-[2rem] transition inline absolute translate-y-0.5 opacity-0 group-hover:opacity-100 -translate-x-1 group-hover:translate-x-0" name="material-symbols:chevron-right-rounded"></Icon>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- description -->
|
||||||
|
<div class:list={["transition text-75 mb-3.5 pr-4 line-clamp-1 md:line-clamp-2"]}>
|
||||||
|
{ description || remarkPluginFrontmatter.excerpt }
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- metadata -->
|
||||||
|
<PostMetadata published={published} updated={updated} hideUpdateDate={true} hidePublishedDate={true} slug={entry.slug} category={category} class="mb-2 md:mb-4"></PostMetadata>
|
||||||
|
|
||||||
|
<!-- word count, read time and page views -->
|
||||||
|
<div class="text-sm text-black/30 dark:text-white/30 flex gap-4 transition">
|
||||||
|
<div>{formatDateToYYYYMMDD(published)}</div>
|
||||||
|
<div>|</div>
|
||||||
|
<div>{remarkPluginFrontmatter.words} 字</div>
|
||||||
|
<div>|</div>
|
||||||
|
<div>{remarkPluginFrontmatter.minutes} 分钟</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasCover && <a href={url} aria-label={title}
|
||||||
|
class:list={["group",
|
||||||
|
"max-h-none mx-0 mt-0",
|
||||||
|
"w-[var(--coverWidth)] absolute top-3 bottom-3 right-3 rounded-xl overflow-hidden active:scale-95"
|
||||||
|
]} >
|
||||||
|
<div class="absolute pointer-events-none z-10 w-full h-full group-hover:bg-black/30 group-active:bg-black/50 transition"></div>
|
||||||
|
<!-- 封面图上的箭头 -->
|
||||||
|
<div class="absolute pointer-events-none z-20 w-full h-full flex items-center justify-center ">
|
||||||
|
<Icon name="material-symbols:chevron-right-rounded"
|
||||||
|
class="transition opacity-0 group-hover:opacity-100 scale-50 group-hover:scale-100 text-white text-5xl">
|
||||||
|
</Icon>
|
||||||
|
</div>
|
||||||
|
<ImageWrapper src={image} basePath={path.join("content/posts/", getDir(entry.id))} alt="Cover Image of the Post"
|
||||||
|
class="w-full h-full">
|
||||||
|
</ImageWrapper>
|
||||||
|
</a>}
|
||||||
|
|
||||||
|
{!hasCover &&
|
||||||
|
<a href={url} aria-label={title} class="!flex btn-regular w-[3.25rem]
|
||||||
|
absolute right-3 top-3 bottom-3 rounded-xl bg-[var(--enter-btn-bg)]
|
||||||
|
hover:bg-[var(--enter-btn-bg-hover)] active:bg-[var(--enter-btn-bg-active)] active:scale-95
|
||||||
|
">
|
||||||
|
<Icon name="material-symbols:chevron-right-rounded"
|
||||||
|
class="transition text-[var(--primary)] text-4xl mx-auto">
|
||||||
|
</Icon>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="transition border-t-[1px] border-dashed mx-6 border-black/10 dark:border-white/[0.15] last:border-t-0 hidden"></div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<style define:vars={{coverWidth}}>
|
||||||
|
</style>
|
||||||
136
src/components/PostMeta.astro
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
---
|
||||||
|
import { Icon } from "astro-icon/components";
|
||||||
|
|
||||||
|
import { getDir, url } from "../utils/url-utils";
|
||||||
|
import { formatDateToYYYYMMDD } from "../utils/date-utils";
|
||||||
|
import { umamiConfig } from "../config";
|
||||||
|
import { getPostCategories, getCategoryUrl, parseCategoryPath } from "../utils/category-utils";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
class: string;
|
||||||
|
published: Date;
|
||||||
|
updated?: Date;
|
||||||
|
hideUpdateDate?: boolean;
|
||||||
|
hidePublishedDate?: boolean;
|
||||||
|
slug?: string;
|
||||||
|
category?: string | string[];
|
||||||
|
}
|
||||||
|
const {
|
||||||
|
published,
|
||||||
|
updated,
|
||||||
|
hideUpdateDate = false,
|
||||||
|
hidePublishedDate = false,
|
||||||
|
slug,
|
||||||
|
category,
|
||||||
|
} = Astro.props;
|
||||||
|
const className = Astro.props.class;
|
||||||
|
|
||||||
|
// Process category data
|
||||||
|
const categories = typeof category === "string"
|
||||||
|
? [category]
|
||||||
|
: category || [];
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class:list={["flex flex-wrap text-neutral-500 dark:text-neutral-400 items-center gap-4 gap-x-4 gap-y-2", className]}>
|
||||||
|
<!-- publish date -->
|
||||||
|
{!hidePublishedDate && (
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="meta-icon">
|
||||||
|
<Icon name="material-symbols:calendar-today-outline-rounded" class="text-xl"></Icon>
|
||||||
|
</div>
|
||||||
|
<span class="text-50 text-sm font-medium">{formatDateToYYYYMMDD(published)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<!-- update date -->
|
||||||
|
{!hideUpdateDate && updated && updated.getTime() !== published.getTime() && (
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="meta-icon"
|
||||||
|
>
|
||||||
|
<Icon name="material-symbols:edit-calendar-outline-rounded" class="text-xl"></Icon>
|
||||||
|
</div>
|
||||||
|
<span class="text-50 text-sm font-medium">{formatDateToYYYYMMDD(updated)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<!-- page views & visitors -->
|
||||||
|
{slug && (
|
||||||
|
<>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="meta-icon">
|
||||||
|
<Icon name="material-symbols:visibility-outline-rounded" class="text-xl"></Icon>
|
||||||
|
</div>
|
||||||
|
<span class="text-50 text-sm font-medium" id={`page-views-${slug}`}>-</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="meta-icon">
|
||||||
|
<Icon name="material-symbols:person" class="text-xl"></Icon>
|
||||||
|
</div>
|
||||||
|
<span class="text-50 text-sm font-medium" id={`page-visitors-${slug}`}>-</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<!-- categories -->
|
||||||
|
{categories.length > 0 && (
|
||||||
|
<div class="flex items-center flex-wrap gap-2">
|
||||||
|
<div class="meta-icon">
|
||||||
|
<Icon name="material-symbols:folder-outline-rounded" class="text-xl"></Icon>
|
||||||
|
</div>
|
||||||
|
{categories.map((cat, index) => {
|
||||||
|
const catPath = parseCategoryPath(cat);
|
||||||
|
const catUrl = getCategoryUrl(catPath);
|
||||||
|
const catName = catPath[catPath.length - 1]; // Show last level only
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{index > 0 && <span class="text-30">,</span>}
|
||||||
|
<a
|
||||||
|
href={catUrl}
|
||||||
|
class="text-50 text-sm font-medium hover:text-[var(--primary)] transition"
|
||||||
|
>
|
||||||
|
{catName}
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{slug && (
|
||||||
|
<script define:vars={{ slug, umamiConfig }}>
|
||||||
|
|
||||||
|
// 获取访问量统计
|
||||||
|
async function fetchPageViews() {
|
||||||
|
if (!umamiConfig.enable) return;
|
||||||
|
try {
|
||||||
|
const statsData = await fetchUmamiStats(umamiConfig.baseUrl, umamiConfig.shareId, {
|
||||||
|
timezone: umamiConfig.timezone,
|
||||||
|
path: `eq./posts/${slug}/`
|
||||||
|
});
|
||||||
|
|
||||||
|
const pageViews = statsData.pageviews || 0;
|
||||||
|
const visits = statsData.visitors || 0;
|
||||||
|
|
||||||
|
const viewsElement = document.getElementById(`page-views-${slug}`);
|
||||||
|
const visitorsElement = document.getElementById(`page-visitors-${slug}`);
|
||||||
|
if (viewsElement) viewsElement.textContent = `${pageViews} 次`;
|
||||||
|
if (visitorsElement) visitorsElement.textContent = `${visits} 人`;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching page views:', error);
|
||||||
|
const viewsElement = document.getElementById(`page-views-${slug}`);
|
||||||
|
const visitorsElement = document.getElementById(`page-visitors-${slug}`);
|
||||||
|
if (viewsElement) viewsElement.textContent = '-';
|
||||||
|
if (visitorsElement) visitorsElement.textContent = '-';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 页面加载完成后获取统计数据
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', fetchPageViews);
|
||||||
|
} else {
|
||||||
|
fetchPageViews();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
)}
|
||||||
29
src/components/PostPage.astro
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
import type { CollectionEntry } from "astro:content";
|
||||||
|
import { getPostUrlBySlug } from "@utils/url-utils";
|
||||||
|
import PostCard from "./PostCard.astro";
|
||||||
|
|
||||||
|
const { page } = Astro.props;
|
||||||
|
|
||||||
|
let delay = 0;
|
||||||
|
const interval = 50;
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class="transition flex flex-col rounded-[var(--radius-large)] bg-transparent gap-4 mb-4">
|
||||||
|
|
||||||
|
{page.data.map((entry: CollectionEntry<"posts">) => (
|
||||||
|
<PostCard
|
||||||
|
entry={entry}
|
||||||
|
title={entry.data.title}
|
||||||
|
published={entry.data.published}
|
||||||
|
updated={entry.data.updated}
|
||||||
|
url={getPostUrlBySlug(entry.slug)}
|
||||||
|
image={entry.data.image}
|
||||||
|
description={entry.data.description}
|
||||||
|
draft={entry.data.draft}
|
||||||
|
category={entry.data.category}
|
||||||
|
class:list="onload-animation"
|
||||||
|
style={`animation-delay: calc(var(--content-delay) + ${delay++ * interval}ms);`}
|
||||||
|
></PostCard>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
206
src/components/Search.svelte
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
|
||||||
|
import Icon from "@iconify/svelte";
|
||||||
|
import { url } from "@utils/url-utils.ts";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
|
||||||
|
interface SearchResult {
|
||||||
|
url: string;
|
||||||
|
meta: {
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
excerpt: string;
|
||||||
|
urlPath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let keywordDesktop = "";
|
||||||
|
let keywordMobile = "";
|
||||||
|
let result: SearchResult[] = [];
|
||||||
|
let isSearching = false;
|
||||||
|
let posts: any[] = [];
|
||||||
|
|
||||||
|
const togglePanel = () => {
|
||||||
|
const panel = document.getElementById("search-panel");
|
||||||
|
panel?.classList.toggle("float-panel-closed");
|
||||||
|
};
|
||||||
|
|
||||||
|
const setPanelVisibility = (show: boolean, isDesktop: boolean): void => {
|
||||||
|
const panel = document.getElementById("search-panel");
|
||||||
|
if (!panel || !isDesktop) return;
|
||||||
|
|
||||||
|
if (show) {
|
||||||
|
panel.classList.remove("float-panel-closed");
|
||||||
|
} else {
|
||||||
|
panel.classList.add("float-panel-closed");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const highlightText = (text: string, keyword: string): string => {
|
||||||
|
if (!keyword) return text;
|
||||||
|
const regex = new RegExp(`(${keyword})`, "gi");
|
||||||
|
return text.replace(regex, "<mark>$1</mark>");
|
||||||
|
};
|
||||||
|
|
||||||
|
const search = async (keyword: string, isDesktop: boolean): Promise<void> => {
|
||||||
|
if (!keyword) {
|
||||||
|
setPanelVisibility(false, isDesktop);
|
||||||
|
result = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isSearching = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const searchResults = posts
|
||||||
|
.filter((post) => {
|
||||||
|
const keywordLower = keyword.toLowerCase();
|
||||||
|
const searchText =
|
||||||
|
`${post.title} ${post.description} ${post.content}`.toLowerCase();
|
||||||
|
const urlPath = `/posts/${post.link}`;
|
||||||
|
|
||||||
|
// 支持内容搜索和URL后缀搜索
|
||||||
|
return searchText.includes(keywordLower) ||
|
||||||
|
urlPath.toLowerCase().includes(keywordLower) ||
|
||||||
|
post.link.toLowerCase().includes(keywordLower);
|
||||||
|
})
|
||||||
|
.map((post) => {
|
||||||
|
const contentLower = post.content.toLowerCase();
|
||||||
|
const keywordLower = keyword.toLowerCase();
|
||||||
|
const contentIndex = contentLower.indexOf(keywordLower);
|
||||||
|
|
||||||
|
let excerpt = '';
|
||||||
|
if (contentIndex !== -1) {
|
||||||
|
const start = Math.max(0, contentIndex - 50);
|
||||||
|
const end = Math.min(post.content.length, contentIndex + 100);
|
||||||
|
excerpt = post.content.substring(start, end);
|
||||||
|
if (start > 0) excerpt = '...' + excerpt;
|
||||||
|
if (end < post.content.length) excerpt = excerpt + '...';
|
||||||
|
} else {
|
||||||
|
excerpt = post.description || post.content.substring(0, 150) + '...';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: url(`/posts/${post.link}/`),
|
||||||
|
meta: {
|
||||||
|
title: post.title
|
||||||
|
},
|
||||||
|
excerpt: highlightText(excerpt, keyword),
|
||||||
|
urlPath: `/posts/${post.link}`
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
result = searchResults;
|
||||||
|
setPanelVisibility(result.length > 0, isDesktop);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Search error:", error);
|
||||||
|
result = [];
|
||||||
|
setPanelVisibility(false, isDesktop);
|
||||||
|
} finally {
|
||||||
|
isSearching = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/rss.xml");
|
||||||
|
const text = await response.text();
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const xml = parser.parseFromString(text, "text/xml");
|
||||||
|
const items = xml.querySelectorAll("item");
|
||||||
|
|
||||||
|
posts = Array.from(items).map((item) => {
|
||||||
|
// 尝试多种方式获取content:encoded内容
|
||||||
|
let content = "";
|
||||||
|
const contentEncoded =
|
||||||
|
item.getElementsByTagNameNS("*", "encoded")[0]?.textContent ||
|
||||||
|
item.querySelector("*|encoded")?.textContent ||
|
||||||
|
"";
|
||||||
|
|
||||||
|
if (contentEncoded) {
|
||||||
|
content = contentEncoded.replace(/<[^>]*>/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: item.querySelector("title")?.textContent || "",
|
||||||
|
description: item.querySelector("description")?.textContent || "",
|
||||||
|
content: content,
|
||||||
|
link: item.querySelector("link")?.textContent?.replace(/.*\/posts\/(.*?)\//, "$1") || "",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching RSS:", error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$: search(keywordDesktop, true);
|
||||||
|
$: search(keywordMobile, false);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- search bar for desktop view -->
|
||||||
|
<div id="search-bar" class="hidden lg:flex transition-all items-center h-11 mr-2 rounded-lg
|
||||||
|
bg-black/[0.04] hover:bg-black/[0.06] focus-within:bg-black/[0.06]
|
||||||
|
dark:bg-white/5 dark:hover:bg-white/10 dark:focus-within:bg-white/10
|
||||||
|
">
|
||||||
|
<Icon icon="material-symbols:search" class="absolute text-[1.25rem] pointer-events-none ml-3 transition my-auto text-black/30 dark:text-white/30"></Icon>
|
||||||
|
<input placeholder="搜索" bind:value={keywordDesktop} on:focus={() => search(keywordDesktop, true)}
|
||||||
|
class="transition-all pl-10 text-sm bg-transparent outline-0
|
||||||
|
h-full w-40 active:w-60 focus:w-60 text-black/50 dark:text-white/50"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- toggle btn for phone/tablet view -->
|
||||||
|
<button on:click={togglePanel} aria-label="Search Panel" id="search-switch"
|
||||||
|
class="btn-plain scale-animation lg:!hidden rounded-lg w-11 h-11 active:scale-90">
|
||||||
|
<Icon icon="material-symbols:search" class="text-[1.25rem]"></Icon>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- search panel -->
|
||||||
|
<div id="search-panel" class="float-panel float-panel-closed search-panel absolute md:w-[30rem]
|
||||||
|
top-20 left-4 md:left-[unset] right-4 shadow-2xl rounded-2xl p-2">
|
||||||
|
|
||||||
|
<!-- search bar inside panel for phone/tablet -->
|
||||||
|
<div id="search-bar-inside" class="flex relative lg:hidden transition-all items-center h-11 rounded-xl
|
||||||
|
bg-black/[0.04] hover:bg-black/[0.06] focus-within:bg-black/[0.06]
|
||||||
|
dark:bg-white/5 dark:hover:bg-white/10 dark:focus-within:bg-white/10
|
||||||
|
">
|
||||||
|
<Icon icon="material-symbols:search" class="absolute text-[1.25rem] pointer-events-none ml-3 transition my-auto text-black/30 dark:text-white/30"></Icon>
|
||||||
|
<input placeholder="Search" bind:value={keywordMobile}
|
||||||
|
class="pl-10 absolute inset-0 text-sm bg-transparent outline-0
|
||||||
|
focus:w-60 text-black/50 dark:text-white/50"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- search results -->
|
||||||
|
{#each result as item}
|
||||||
|
<a href={item.url}
|
||||||
|
class="transition first-of-type:mt-2 lg:first-of-type:mt-0 group block
|
||||||
|
rounded-xl text-lg px-3 py-2 hover:bg-[var(--btn-plain-bg-hover)] active:bg-[var(--btn-plain-bg-active)]">
|
||||||
|
<div class="transition text-90 inline-flex font-bold group-hover:text-[var(--primary)]">
|
||||||
|
{item.meta.title}<Icon icon="fa6-solid:chevron-right" class="transition text-[0.75rem] translate-x-1 my-auto text-[var(--primary)]"></Icon>
|
||||||
|
</div>
|
||||||
|
<div class="transition text-xs text-white mb-1 font-mono">
|
||||||
|
{item.urlPath}
|
||||||
|
</div>
|
||||||
|
<div class="transition text-sm text-50">
|
||||||
|
{@html item.excerpt}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
input:focus {
|
||||||
|
outline: 0;
|
||||||
|
}
|
||||||
|
.search-panel {
|
||||||
|
background-color: var(--float-panel-bg-opaque);
|
||||||
|
max-height: calc(100vh - 100px);
|
||||||
|
overflow-y: auto;
|
||||||
|
scrollbar-width: none; /* Firefox */
|
||||||
|
-ms-overflow-style: none; /* IE and Edge */
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-panel::-webkit-scrollbar {
|
||||||
|
display: none; /* Chrome, Safari and Opera */
|
||||||
|
}
|
||||||
|
</style>
|
||||||
49
src/components/control/BackToTop.astro
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
---
|
||||||
|
import { Icon } from "astro-icon/components";
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- There can't be a filter on parent element, or it will break `fixed` -->
|
||||||
|
<div class="back-to-top-wrapper hidden lg:block">
|
||||||
|
<div id="back-to-top-btn" class="back-to-top-btn hide flex items-center rounded-2xl overflow-hidden transition" onclick="backToTop()">
|
||||||
|
<button aria-label="Back to Top" class="btn-card h-[3.75rem] w-[3.75rem]">
|
||||||
|
<Icon name="material-symbols:keyboard-arrow-up-rounded" class="mx-auto"></Icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="stylus">
|
||||||
|
.back-to-top-wrapper
|
||||||
|
width: 3.75rem
|
||||||
|
height: 3.75rem
|
||||||
|
position: absolute
|
||||||
|
right: 0
|
||||||
|
top: 0
|
||||||
|
pointer-events: none
|
||||||
|
|
||||||
|
.back-to-top-btn
|
||||||
|
color: var(--primary)
|
||||||
|
font-size: 2.25rem
|
||||||
|
font-weight: bold
|
||||||
|
border: none
|
||||||
|
position: fixed
|
||||||
|
bottom: 10rem
|
||||||
|
opacity: 1
|
||||||
|
cursor: pointer
|
||||||
|
transform: translateX(5rem)
|
||||||
|
pointer-events: auto
|
||||||
|
i
|
||||||
|
font-size: 1.75rem
|
||||||
|
&.hide
|
||||||
|
transform: translateX(5rem) scale(0.9)
|
||||||
|
opacity: 0
|
||||||
|
pointer-events: none
|
||||||
|
&:active
|
||||||
|
transform: translateX(5rem) scale(0.9)
|
||||||
|
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script is:raw is:inline>
|
||||||
|
function backToTop() {
|
||||||
|
window.scroll({ top: 0, behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
83
src/components/control/Pagination.astro
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
---
|
||||||
|
import type { Page } from "astro";
|
||||||
|
import { Icon } from "astro-icon/components";
|
||||||
|
import { url } from "../../utils/url-utils";
|
||||||
|
interface Props {
|
||||||
|
page: Page;
|
||||||
|
class?: string;
|
||||||
|
style?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { page, style } = Astro.props;
|
||||||
|
|
||||||
|
const HIDDEN = -1;
|
||||||
|
|
||||||
|
const className = Astro.props.class;
|
||||||
|
|
||||||
|
const ADJ_DIST = 2;
|
||||||
|
const VISIBLE = ADJ_DIST * 2 + 1;
|
||||||
|
|
||||||
|
// for test
|
||||||
|
let count = 1;
|
||||||
|
let l = page.currentPage;
|
||||||
|
let r = page.currentPage;
|
||||||
|
while (0 < l - 1 && r + 1 <= page.lastPage && count + 2 <= VISIBLE) {
|
||||||
|
count += 2;
|
||||||
|
l--;
|
||||||
|
r++;
|
||||||
|
}
|
||||||
|
while (0 < l - 1 && count < VISIBLE) {
|
||||||
|
count++;
|
||||||
|
l--;
|
||||||
|
}
|
||||||
|
while (r + 1 <= page.lastPage && count < VISIBLE) {
|
||||||
|
count++;
|
||||||
|
r++;
|
||||||
|
}
|
||||||
|
|
||||||
|
let pages: number[] = [];
|
||||||
|
if (l > 1) pages.push(1);
|
||||||
|
if (l === 3) pages.push(2);
|
||||||
|
if (l > 3) pages.push(HIDDEN);
|
||||||
|
for (let i = l; i <= r; i++) pages.push(i);
|
||||||
|
if (r < page.lastPage - 2) pages.push(HIDDEN);
|
||||||
|
if (r === page.lastPage - 2) pages.push(page.lastPage - 1);
|
||||||
|
if (r < page.lastPage) pages.push(page.lastPage);
|
||||||
|
|
||||||
|
const getPageUrl = (p: number) => {
|
||||||
|
if (p === 1) return "/";
|
||||||
|
return `/${p}/`;
|
||||||
|
};
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class:list={[className, "flex flex-row gap-3 justify-center"]} style={style}>
|
||||||
|
<a href={page.url.prev || ""} aria-label={page.url.prev ? "Previous Page" : null}
|
||||||
|
class:list={["btn-card overflow-hidden rounded-lg text-[var(--primary)] w-11 h-11",
|
||||||
|
{"disabled": page.url.prev == undefined}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Icon name="material-symbols:chevron-left-rounded" class="text-[1.75rem]"></Icon>
|
||||||
|
</a>
|
||||||
|
<div class="bg-[var(--card-bg)] flex flex-row rounded-lg items-center text-neutral-700 dark:text-neutral-300 font-bold" style="backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);">
|
||||||
|
{pages.map((p) => {
|
||||||
|
if (p == HIDDEN)
|
||||||
|
return <Icon name="material-symbols:more-horiz" class="mx-1"/>;
|
||||||
|
if (p == page.currentPage)
|
||||||
|
return <div class="h-11 w-11 rounded-lg bg-[var(--primary)] flex items-center justify-center
|
||||||
|
font-bold text-white dark:text-black/70"
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
</div>
|
||||||
|
return <a href={url(getPageUrl(p))} aria-label=`Page ${p}`
|
||||||
|
class="transition flex items-center justify-center w-11 h-11 rounded-lg overflow-hidden active:scale-[0.85] hover:bg-[var(--btn-card-bg-hover)] active:bg-[var(--btn-card-bg-active)] text-black/75 dark:text-white/75"
|
||||||
|
>{p}</a>
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<a href={page.url.next || ""} aria-label={page.url.next ? "Next Page" : null}
|
||||||
|
class:list={["btn-card overflow-hidden rounded-lg text-[var(--primary)] w-11 h-11",
|
||||||
|
{"disabled": page.url.next == undefined}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Icon name="material-symbols:chevron-right-rounded" class="text-[1.75rem]"></Icon>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
90
src/components/misc/ImageWrapper.astro
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
---
|
||||||
|
import path from "node:path";
|
||||||
|
interface Props {
|
||||||
|
id?: string;
|
||||||
|
src: string;
|
||||||
|
class?: string;
|
||||||
|
alt?: string;
|
||||||
|
position?: string;
|
||||||
|
basePath?: string;
|
||||||
|
}
|
||||||
|
import { Image } from "astro:assets";
|
||||||
|
import { url } from "../../utils/url-utils";
|
||||||
|
import { imageFallbackConfig, siteConfig } from "../../config";
|
||||||
|
|
||||||
|
const { id, src, alt, position = "center", basePath = "/" } = Astro.props;
|
||||||
|
const className = Astro.props.class;
|
||||||
|
|
||||||
|
const isLocal = !(
|
||||||
|
src.startsWith("/") ||
|
||||||
|
src.startsWith("http") ||
|
||||||
|
src.startsWith("https") ||
|
||||||
|
src.startsWith("data:")
|
||||||
|
);
|
||||||
|
const isPublic = src.startsWith("/");
|
||||||
|
|
||||||
|
// TODO temporary workaround for images dynamic import
|
||||||
|
// https://github.com/withastro/astro/issues/3373
|
||||||
|
// biome-ignore lint/suspicious/noImplicitAnyLet: <explanation>
|
||||||
|
let img;
|
||||||
|
if (isLocal) {
|
||||||
|
const files = import.meta.glob<ImageMetadata>("../../**", {
|
||||||
|
import: "default",
|
||||||
|
});
|
||||||
|
let normalizedPath = path
|
||||||
|
.normalize(path.join("../../", basePath, src))
|
||||||
|
.replace(/\\/g, "/");
|
||||||
|
const file = files[normalizedPath];
|
||||||
|
if (!file) {
|
||||||
|
console.error(
|
||||||
|
`\n[ERROR] Image file not found: ${normalizedPath.replace("../../", "src/")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
img = await file();
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageClass = "w-full h-full object-cover";
|
||||||
|
const imageStyle = `object-position: ${position}`;
|
||||||
|
---
|
||||||
|
<div id={id} class:list={[className, 'overflow-hidden relative image-wrapper']} style={`--theme-hue: ${siteConfig.themeColor.hue}`}>
|
||||||
|
<!-- 加载条 -->
|
||||||
|
<div class="loading-bar absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-32 h-1 bg-gray-200 dark:bg-gray-700 z-10 rounded-full overflow-hidden">
|
||||||
|
<div class="loading-progress h-full w-8 bg-[oklch(0.70_0.14_var(--theme-hue))] animate-loading-progress rounded-full"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 图片内容 -->
|
||||||
|
{isLocal && img && <Image src={img} alt={alt || ""} class={`${imageClass} image-content opacity-0 transition-opacity duration-500`} style={imageStyle} onload="this.style.opacity='1'; this.parentElement.querySelector('.loading-bar').style.opacity='0';"/>}
|
||||||
|
{!isLocal && (
|
||||||
|
imageFallbackConfig.enable && src.includes(imageFallbackConfig.originalDomain) ?
|
||||||
|
<img src={isPublic ? url(src) : src} alt={alt || ""} class={`${imageClass} image-content opacity-0 transition-opacity duration-500`} style={imageStyle} onload="this.style.opacity='1'; this.parentElement.querySelector('.loading-bar').style.opacity='0';" onerror={`this.onerror=null; this.src='${(isPublic ? url(src) : src).replace(imageFallbackConfig.originalDomain, imageFallbackConfig.fallbackDomain)}';`}/> :
|
||||||
|
<img src={isPublic ? url(src) : src} alt={alt || ""} class={`${imageClass} image-content opacity-0 transition-opacity duration-500`} style={imageStyle} onload="this.style.opacity='1'; this.parentElement.querySelector('.loading-bar').style.opacity='0';"/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.loading-bar {
|
||||||
|
transition: opacity 0.5s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes loading-progress {
|
||||||
|
0% {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateX(400%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-loading-progress {
|
||||||
|
animation: loading-progress 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-content {
|
||||||
|
transition: transform 0.3s ease-out, opacity 0.5s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-wrapper:hover .image-content {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
60
src/components/misc/License.astro
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
---
|
||||||
|
import { Icon } from "astro-icon/components";
|
||||||
|
import { licenseConfig, profileConfig } from "../../config";
|
||||||
|
|
||||||
|
import { formatDateToYYYYMMDD } from "../../utils/date-utils";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
pubDate: Date;
|
||||||
|
class: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { title, pubDate } = Astro.props;
|
||||||
|
const className = Astro.props.class;
|
||||||
|
const profileConf = profileConfig;
|
||||||
|
const licenseConf = licenseConfig;
|
||||||
|
const postUrl = decodeURIComponent(Astro.url.toString());
|
||||||
|
---
|
||||||
|
<div class=`relative transition overflow-hidden bg-[var(--license-block-bg)] py-5 px-6 ${className}`>
|
||||||
|
<div class="transition font-bold text-black/75 dark:text-white/75">
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a id="current-link" style="display:none;" class="link text-[var(--primary)]"></a>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const currentLink = document.getElementById('current-link');
|
||||||
|
|
||||||
|
if (currentLink) {
|
||||||
|
console.log(`[license] old url: ${window.location.href}`);
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.search = '';
|
||||||
|
const address = url.toString();
|
||||||
|
console.log(`[license] new url: ${address}`);
|
||||||
|
|
||||||
|
currentLink.textContent = address; // 显示文本 = 当前 URL
|
||||||
|
currentLink.href = address; // 点击跳转 = 当前 URL
|
||||||
|
currentLink.style.display = 'inline'; // 显示出来
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex gap-6 mt-2">
|
||||||
|
<div>
|
||||||
|
<div class="transition text-black/30 dark:text-white/30 text-sm">作者</div>
|
||||||
|
<div class="transition text-black/75 dark:text-white/75 line-clamp-2">{profileConf.name}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="transition text-black/30 dark:text-white/30 text-sm">发布于</div>
|
||||||
|
<div class="transition text-black/75 dark:text-white/75 line-clamp-2">{formatDateToYYYYMMDD(pubDate)}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="transition text-black/30 dark:text-white/30 text-sm">许可协议</div>
|
||||||
|
<a href={licenseConf.url} target="_blank" class="link text-[var(--primary)] line-clamp-2">{licenseConf.name}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Icon name="fa6-brands:creative-commons" class="transition text-[15rem] absolute pointer-events-none right-6 top-1/2 -translate-y-1/2 text-black/5 dark:text-white/5"></Icon>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
42
src/components/misc/Markdown.astro
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
---
|
||||||
|
import "@fontsource-variable/jetbrains-mono";
|
||||||
|
import "@fontsource-variable/jetbrains-mono/wght-italic.css";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
class: string;
|
||||||
|
}
|
||||||
|
const className = Astro.props.class;
|
||||||
|
---
|
||||||
|
<div data-pagefind-body class=`prose dark:prose-invert prose-base !max-w-none custom-md ${className}`>
|
||||||
|
<!--<div class="prose dark:prose-invert max-w-none custom-md">-->
|
||||||
|
<!--<div class="max-w-none custom-md">-->
|
||||||
|
<slot/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener("click", function (e: MouseEvent) {
|
||||||
|
const target = e.target as Element | null;
|
||||||
|
if (target && target.classList.contains("copy-btn")) {
|
||||||
|
const preEle = target.closest("pre");
|
||||||
|
const codeEle = preEle?.querySelector("code");
|
||||||
|
const code = Array.from(codeEle?.querySelectorAll(".code:not(summary *)") ?? [])
|
||||||
|
.map(el => el.textContent)
|
||||||
|
.map(t => t === "\n" ? "" : t)
|
||||||
|
.join("\n");
|
||||||
|
navigator.clipboard.writeText(code);
|
||||||
|
|
||||||
|
const timeoutId = target.getAttribute("data-timeout-id");
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(parseInt(timeoutId));
|
||||||
|
}
|
||||||
|
|
||||||
|
target.classList.add("success");
|
||||||
|
|
||||||
|
// 设置新的timeout并保存ID到按钮的自定义属性中
|
||||||
|
const newTimeoutId = setTimeout(() => {
|
||||||
|
target.classList.remove("success");
|
||||||
|
}, 1000);
|
||||||
|
target.setAttribute("data-timeout-id", newTimeoutId.toString());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
61
src/components/widget/CategoryDrawer.astro
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
---
|
||||||
|
import type { CategoryNode } from "@/types/config";
|
||||||
|
import { Icon } from "astro-icon/components";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
category: CategoryNode;
|
||||||
|
depth?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { category, depth = 0 } = Astro.props;
|
||||||
|
const hasChildren = category.children && category.children.length > 0;
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class="category-drawer relative">
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between px-3 py-2 rounded hover:bg-[var(--btn-plain-bg-hover)] transition-all group w-full"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2 overflow-hidden w-full">
|
||||||
|
{
|
||||||
|
hasChildren ? (
|
||||||
|
<button
|
||||||
|
class="drawer-toggle text-50 hover:text-[var(--primary)] transition-colors cursor-pointer shrink-0 flex items-center justify-center h-6 w-6 rounded-md hover:bg-black/5 dark:hover:bg-white/10"
|
||||||
|
aria-label="Toggle children"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
name="material-symbols:chevron-right-rounded"
|
||||||
|
class="text-xl transition-transform duration-200"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span class="w-6 shrink-0" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
<a
|
||||||
|
href={category.url}
|
||||||
|
class="text-75 group-hover:text-[var(--primary)] transition-all truncate flex-1"
|
||||||
|
>
|
||||||
|
{category.name}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span
|
||||||
|
class="text-sm text-50 bg-black/5 dark:bg-white/10 px-2 py-0.5 rounded shrink-0 ml-2"
|
||||||
|
>
|
||||||
|
{category.count}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{
|
||||||
|
hasChildren && (
|
||||||
|
<div class="drawer-content grid grid-rows-[0fr] transition-[grid-template-rows] duration-200 ease-out pl-3 ml-3 border-l border-[var(--line-divider)]">
|
||||||
|
<div class="overflow-hidden">
|
||||||
|
{category.children.map((child) => (
|
||||||
|
<Astro.self category={child} depth={depth + 1} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
85
src/components/widget/CategoryList.astro
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
---
|
||||||
|
import { getSortedPosts } from "@utils/content-utils";
|
||||||
|
import { buildCategoryTree } from "@utils/category-utils";
|
||||||
|
import { Icon } from "astro-icon/components";
|
||||||
|
import CategoryDrawer from "./CategoryDrawer.astro";
|
||||||
|
|
||||||
|
const allPosts = await getSortedPosts();
|
||||||
|
const categoryTree = await buildCategoryTree(allPosts);
|
||||||
|
|
||||||
|
function getTotalCount(cat: any): number {
|
||||||
|
let count = cat.count;
|
||||||
|
if (cat.children) {
|
||||||
|
for (const child of cat.children) {
|
||||||
|
count += getTotalCount(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get only top-level categories, sort by total post count (including children), max 15
|
||||||
|
const topCategories = Object.values(categoryTree)
|
||||||
|
.filter((cat) => cat.path.length === 1)
|
||||||
|
.map((cat) => ({ ...cat, totalCount: getTotalCount(cat) }))
|
||||||
|
.sort((a, b) => b.totalCount - a.totalCount)
|
||||||
|
.slice(0, 15);
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class="card-base px-6 py-5">
|
||||||
|
<div class="flex items-center gap-2 mb-4">
|
||||||
|
<Icon
|
||||||
|
name="material-symbols:folder-outline-rounded"
|
||||||
|
class="text-[var(--primary)]"
|
||||||
|
/>
|
||||||
|
<h2 class="text-lg font-bold text-90">分类</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-0">
|
||||||
|
{topCategories.map((category) => <CategoryDrawer category={category} />)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="/category/"
|
||||||
|
class="mt-4 text-center text-sm text-[var(--primary)] hover:underline block"
|
||||||
|
>
|
||||||
|
查看全部分类 →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function setupCategoryDrawer() {
|
||||||
|
const toggles = document.querySelectorAll(".drawer-toggle");
|
||||||
|
toggles.forEach((toggle) => {
|
||||||
|
if (toggle.hasAttribute("data-bound")) return;
|
||||||
|
toggle.setAttribute("data-bound", "true");
|
||||||
|
|
||||||
|
toggle.addEventListener("click", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
const drawer = toggle.closest(".category-drawer");
|
||||||
|
const content = drawer.querySelector(".drawer-content");
|
||||||
|
const icon = toggle.querySelector("svg");
|
||||||
|
|
||||||
|
if (!content || !icon) return;
|
||||||
|
|
||||||
|
const isExpanded = content.style.gridTemplateRows === "1fr";
|
||||||
|
|
||||||
|
if (isExpanded) {
|
||||||
|
content.style.gridTemplateRows = "0fr";
|
||||||
|
icon.style.transform = "rotate(0deg)";
|
||||||
|
} else {
|
||||||
|
content.style.gridTemplateRows = "1fr";
|
||||||
|
icon.style.transform = "rotate(90deg)";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setupCategoryDrawer();
|
||||||
|
|
||||||
|
// Re-run on Swup navigation
|
||||||
|
if (window.swup) {
|
||||||
|
window.swup.hooks.on("content:replace", setupCategoryDrawer);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
331
src/components/widget/DisplaySettings.svelte
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import Icon from "@iconify/svelte";
|
||||||
|
import {
|
||||||
|
getDefaultHue,
|
||||||
|
getHue,
|
||||||
|
setHue,
|
||||||
|
getStoredTheme,
|
||||||
|
setTheme,
|
||||||
|
getRainbowMode,
|
||||||
|
setRainbowMode,
|
||||||
|
getRainbowSpeed,
|
||||||
|
setRainbowSpeed,
|
||||||
|
getBgBlur,
|
||||||
|
setBgBlur,
|
||||||
|
setBgHueRotate,
|
||||||
|
getHideBg,
|
||||||
|
setHideBg,
|
||||||
|
getDevMode,
|
||||||
|
setDevMode,
|
||||||
|
getDevServer,
|
||||||
|
setDevServer,
|
||||||
|
} from "@utils/setting-utils";
|
||||||
|
import { AUTO_MODE, DARK_MODE, LIGHT_MODE } from "@constants/constants";
|
||||||
|
|
||||||
|
let hue = getHue();
|
||||||
|
let theme = getStoredTheme();
|
||||||
|
let isRainbowMode = getRainbowMode();
|
||||||
|
let rainbowSpeed = getRainbowSpeed();
|
||||||
|
let bgBlur = getBgBlur();
|
||||||
|
let hideBg = getHideBg();
|
||||||
|
let isDevMode = getDevMode();
|
||||||
|
let devServer = getDevServer();
|
||||||
|
let animationId: number;
|
||||||
|
let lastUpdate = 0;
|
||||||
|
let rainbowHue = 0; // Independent hue for background rotation
|
||||||
|
|
||||||
|
const defaultHue = getDefaultHue();
|
||||||
|
|
||||||
|
function resetHue() {
|
||||||
|
hue = getDefaultHue();
|
||||||
|
}
|
||||||
|
|
||||||
|
$: if ((hue || hue === 0) && !isRainbowMode) {
|
||||||
|
setHue(hue);
|
||||||
|
}
|
||||||
|
|
||||||
|
$: {
|
||||||
|
setBgBlur(bgBlur);
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchTheme(newTheme: string) {
|
||||||
|
theme = newTheme;
|
||||||
|
setTheme(newTheme);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRainbow() {
|
||||||
|
if (!isRainbowMode) return;
|
||||||
|
|
||||||
|
hue = (hue + rainbowSpeed * 0.05) % 360;
|
||||||
|
setHue(hue, false);
|
||||||
|
|
||||||
|
animationId = requestAnimationFrame(updateRainbow);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleRainbow() {
|
||||||
|
isRainbowMode = !isRainbowMode;
|
||||||
|
setRainbowMode(isRainbowMode);
|
||||||
|
|
||||||
|
if (isRainbowMode) {
|
||||||
|
lastUpdate = performance.now();
|
||||||
|
rainbowHue = 0; // Reset rotation start
|
||||||
|
animationId = requestAnimationFrame(updateRainbow);
|
||||||
|
} else {
|
||||||
|
cancelAnimationFrame(animationId);
|
||||||
|
// Reset background rotation to 0 when stopped
|
||||||
|
setBgHueRotate(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleHideBg() {
|
||||||
|
hideBg = !hideBg;
|
||||||
|
setHideBg(hideBg);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleDevMode() {
|
||||||
|
isDevMode = !isDevMode;
|
||||||
|
setDevMode(isDevMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDevServerChange() {
|
||||||
|
setDevServer(devServer);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSpeedChange() {
|
||||||
|
setRainbowSpeed(rainbowSpeed);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (isRainbowMode) {
|
||||||
|
updateRainbow();
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (animationId) cancelAnimationFrame(animationId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div id="display-setting" class="float-panel float-panel-closed absolute transition-all w-80 right-4 px-4 py-4">
|
||||||
|
<div class="flex flex-row gap-2 mb-3 items-center justify-between">
|
||||||
|
<div class="flex gap-2 font-bold text-lg text-neutral-900 dark:text-neutral-100 transition relative ml-3
|
||||||
|
before:w-1 before:h-4 before:rounded-md before:bg-[var(--primary)]
|
||||||
|
before:absolute before:-left-3 before:top-[0.33rem]"
|
||||||
|
>
|
||||||
|
主题模式
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<button aria-label="Light Mode"
|
||||||
|
class="w-10 h-7 rounded-md transition flex items-center justify-center active:scale-90
|
||||||
|
{theme === LIGHT_MODE ? 'bg-[var(--primary)] text-white' : 'bg-[var(--btn-regular-bg)] text-[var(--btn-content)] hover:bg-[var(--btn-regular-bg-hover)]'}"
|
||||||
|
on:click={() => switchTheme(LIGHT_MODE)}
|
||||||
|
>
|
||||||
|
<Icon icon="material-symbols:wb-sunny-rounded" class="text-[1.1rem]"></Icon>
|
||||||
|
</button>
|
||||||
|
<button aria-label="Dark Mode"
|
||||||
|
class="w-10 h-7 rounded-md transition flex items-center justify-center active:scale-90
|
||||||
|
{theme === DARK_MODE ? 'bg-[var(--primary)] text-white' : 'bg-[var(--btn-regular-bg)] text-[var(--btn-content)] hover:bg-[var(--btn-regular-bg-hover)]'}"
|
||||||
|
on:click={() => switchTheme(DARK_MODE)}
|
||||||
|
>
|
||||||
|
<Icon icon="material-symbols:dark-mode-rounded" class="text-[1.1rem]"></Icon>
|
||||||
|
</button>
|
||||||
|
<button aria-label="Auto Mode"
|
||||||
|
class="w-10 h-7 rounded-md transition flex items-center justify-center active:scale-90
|
||||||
|
{theme === AUTO_MODE ? 'bg-[var(--primary)] text-white' : 'bg-[var(--btn-regular-bg)] text-[var(--btn-content)] hover:bg-[var(--btn-regular-bg-hover)]'}"
|
||||||
|
on:click={() => switchTheme(AUTO_MODE)}
|
||||||
|
>
|
||||||
|
<Icon icon="material-symbols:hdr-auto-rounded" class="text-[1.1rem]"></Icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-row gap-2 mb-3 items-center justify-between">
|
||||||
|
<div class="flex gap-2 font-bold text-lg text-neutral-900 dark:text-neutral-100 transition relative ml-3
|
||||||
|
before:w-1 before:h-4 before:rounded-md before:bg-[var(--primary)]
|
||||||
|
before:absolute before:-left-3 before:top-[0.33rem]"
|
||||||
|
>
|
||||||
|
主题色彩
|
||||||
|
<button aria-label="Reset to Default" class="btn-regular w-7 h-7 rounded-md active:scale-90"
|
||||||
|
class:opacity-0={hue === defaultHue} class:pointer-events-none={hue === defaultHue} on:click={resetHue}>
|
||||||
|
<div class="text-[var(--btn-content)]">
|
||||||
|
<Icon icon="fa6-solid:arrow-rotate-left" class="text-[0.875rem]"></Icon>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<input aria-label="Hue Value" id="hueValue" type="number" min="0" max="360" value={Math.round(hue)} on:input={(e) => hue = e.currentTarget.valueAsNumber} disabled={isRainbowMode}
|
||||||
|
class="transition bg-[var(--btn-regular-bg)] w-12 h-7 rounded-md text-center font-bold text-sm text-[var(--btn-content)] outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="w-full h-6 px-1 bg-[oklch(0.80_0.10_0)] dark:bg-[oklch(0.70_0.10_0)] rounded select-none mb-3">
|
||||||
|
<input aria-label="主题色彩" type="range" min="0" max="360" bind:value={hue} disabled={isRainbowMode}
|
||||||
|
class="slider" id="colorSlider" step="1" style="width: 100%">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-row gap-2 mb-3 items-center justify-between">
|
||||||
|
<div class="flex gap-2 font-bold text-lg text-neutral-900 dark:text-neutral-100 transition relative ml-3
|
||||||
|
before:w-1 before:h-4 before:rounded-md before:bg-[var(--primary)]
|
||||||
|
before:absolute before:-left-3 before:top-[0.33rem]"
|
||||||
|
>
|
||||||
|
禁用背景
|
||||||
|
</div>
|
||||||
|
<input type="checkbox" class="toggle-switch" checked={hideBg} on:change={toggleHideBg} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-row gap-2 mb-3 items-center justify-between">
|
||||||
|
<div class="flex gap-2 font-bold text-lg text-neutral-900 dark:text-neutral-100 transition relative ml-3
|
||||||
|
before:w-1 before:h-4 before:rounded-md before:bg-[var(--primary)]
|
||||||
|
before:absolute before:-left-3 before:top-[0.33rem]"
|
||||||
|
>
|
||||||
|
彩虹模式
|
||||||
|
</div>
|
||||||
|
<input type="checkbox" class="toggle-switch" checked={isRainbowMode} on:change={toggleRainbow} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if isRainbowMode}
|
||||||
|
<div class="flex flex-row gap-2 mb-3 items-center justify-between transition-all" >
|
||||||
|
<div class="flex gap-2 font-bold text-lg text-neutral-900 dark:text-neutral-100 transition relative ml-3
|
||||||
|
before:w-1 before:h-4 before:rounded-md before:bg-[var(--primary)]
|
||||||
|
before:absolute before:-left-3 before:top-[0.33rem]"
|
||||||
|
>
|
||||||
|
变换速率
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<div class="transition bg-[var(--btn-regular-bg)] w-10 h-7 rounded-md flex justify-center
|
||||||
|
font-bold text-sm items-center text-[var(--btn-content)]">
|
||||||
|
{rainbowSpeed}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="w-full h-6 px-1 bg-[var(--btn-regular-bg)] rounded select-none">
|
||||||
|
<input aria-label="变换速率" type="range" min="1" max="100" bind:value={rainbowSpeed} on:change={onSpeedChange}
|
||||||
|
class="slider" step="1" style="width: 100%">
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex flex-row gap-2 mb-3 mt-3 items-center justify-between">
|
||||||
|
<div class="flex gap-2 font-bold text-lg text-neutral-900 dark:text-neutral-100 transition relative ml-3
|
||||||
|
before:w-1 before:h-4 before:rounded-md before:bg-[var(--primary)]
|
||||||
|
before:absolute before:-left-3 before:top-[0.33rem]"
|
||||||
|
>
|
||||||
|
背景模糊
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<div class="transition bg-[var(--btn-regular-bg)] w-10 h-7 rounded-md flex justify-center
|
||||||
|
font-bold text-sm items-center text-[var(--btn-content)]">
|
||||||
|
{bgBlur}px
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="w-full h-6 px-1 bg-[var(--btn-regular-bg)] rounded select-none">
|
||||||
|
<input aria-label="背景模糊" type="range" min="0" max="20" bind:value={bgBlur}
|
||||||
|
class="slider" step="1" style="width: 100%">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-row gap-2 mb-3 mt-3 items-center justify-between">
|
||||||
|
<div class="flex gap-2 font-bold text-lg text-neutral-900 dark:text-neutral-100 transition relative ml-3
|
||||||
|
before:w-1 before:h-4 before:rounded-md before:bg-[var(--primary)]
|
||||||
|
before:absolute before:-left-3 before:top-[0.33rem]"
|
||||||
|
>
|
||||||
|
开发模式
|
||||||
|
</div>
|
||||||
|
<input type="checkbox" class="toggle-switch" checked={isDevMode} on:change={toggleDevMode} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if isDevMode}
|
||||||
|
<div class="flex flex-row gap-2 mb-3 items-center justify-between transition-all" >
|
||||||
|
<div class="flex gap-2 font-bold text-lg text-neutral-900 dark:text-neutral-100 transition relative ml-3
|
||||||
|
before:w-1 before:h-4 before:rounded-md before:bg-[var(--primary)]
|
||||||
|
before:absolute before:-left-3 before:top-[0.33rem]"
|
||||||
|
>
|
||||||
|
Server
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<input aria-label="Server Value" type="text" bind:value={devServer} on:input={onDevServerChange}
|
||||||
|
class="transition bg-[var(--btn-regular-bg)] w-32 h-7 rounded-md text-center font-bold text-sm text-[var(--btn-content)] outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<style lang="stylus">
|
||||||
|
#display-setting
|
||||||
|
input[type="number"]
|
||||||
|
-moz-appearance textfield
|
||||||
|
&::-webkit-inner-spin-button
|
||||||
|
&::-webkit-outer-spin-button
|
||||||
|
-webkit-appearance none
|
||||||
|
margin 0
|
||||||
|
|
||||||
|
input[type="range"]
|
||||||
|
-webkit-appearance none
|
||||||
|
height 1.5rem
|
||||||
|
background-image var(--color-selection-bar)
|
||||||
|
transition background-image 0.15s ease-in-out
|
||||||
|
|
||||||
|
/* Input Thumb */
|
||||||
|
&::-webkit-slider-thumb
|
||||||
|
-webkit-appearance none
|
||||||
|
height 1rem
|
||||||
|
width 0.5rem
|
||||||
|
border-radius 0.125rem
|
||||||
|
background rgba(255, 255, 255, 0.7)
|
||||||
|
box-shadow none
|
||||||
|
&:hover
|
||||||
|
background rgba(255, 255, 255, 0.8)
|
||||||
|
&:active
|
||||||
|
background rgba(255, 255, 255, 0.6)
|
||||||
|
|
||||||
|
&::-moz-range-thumb
|
||||||
|
-webkit-appearance none
|
||||||
|
height 1rem
|
||||||
|
width 0.5rem
|
||||||
|
border-radius 0.125rem
|
||||||
|
border-width 0
|
||||||
|
background rgba(255, 255, 255, 0.7)
|
||||||
|
box-shadow none
|
||||||
|
&:hover
|
||||||
|
background rgba(255, 255, 255, 0.8)
|
||||||
|
&:active
|
||||||
|
background rgba(255, 255, 255, 0.6)
|
||||||
|
|
||||||
|
&::-ms-thumb
|
||||||
|
-webkit-appearance none
|
||||||
|
height 1rem
|
||||||
|
width 0.5rem
|
||||||
|
border-radius 0.125rem
|
||||||
|
background rgba(255, 255, 255, 0.7)
|
||||||
|
box-shadow none
|
||||||
|
&:hover
|
||||||
|
background rgba(255, 255, 255, 0.8)
|
||||||
|
&:active
|
||||||
|
background rgba(255, 255, 255, 0.6)
|
||||||
|
|
||||||
|
.toggle-switch
|
||||||
|
appearance none
|
||||||
|
width 3rem
|
||||||
|
height 1.5rem
|
||||||
|
background var(--btn-regular-bg)
|
||||||
|
border-radius 999px
|
||||||
|
position relative
|
||||||
|
cursor pointer
|
||||||
|
transition background 0.3s
|
||||||
|
&::after
|
||||||
|
content ''
|
||||||
|
position absolute
|
||||||
|
top 0.25rem
|
||||||
|
left 0.25rem
|
||||||
|
width 1rem
|
||||||
|
height 1rem
|
||||||
|
background var(--btn-content)
|
||||||
|
border-radius 50%
|
||||||
|
transition transform 0.3s
|
||||||
|
&:checked
|
||||||
|
background var(--primary)
|
||||||
|
&::after
|
||||||
|
transform translateX(1.5rem)
|
||||||
|
background white
|
||||||
|
</style>
|
||||||
32
src/components/widget/NavMenuPanel.astro
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
---
|
||||||
|
import { Icon } from "astro-icon/components";
|
||||||
|
import { type NavBarLink } from "../../types/config";
|
||||||
|
import { url } from "../../utils/url-utils";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
links: NavBarLink[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const links = Astro.props.links;
|
||||||
|
---
|
||||||
|
<div id="nav-menu-panel" class:list={["float-panel float-panel-closed absolute transition-all fixed right-4 px-2 py-2"]}>
|
||||||
|
{links.map((link) => (
|
||||||
|
<a href={link.external ? link.url : url(link.url)} class="group flex justify-between items-center py-2 pl-3 pr-1 rounded-lg gap-8
|
||||||
|
hover:bg-[var(--btn-plain-bg-hover)] active:bg-[var(--btn-plain-bg-active)] transition
|
||||||
|
"
|
||||||
|
target={link.external ? "_blank" : null}
|
||||||
|
>
|
||||||
|
<div class="transition text-black/75 dark:text-white/75 font-bold group-hover:text-[var(--primary)] group-active:text-[var(--primary)]">
|
||||||
|
{link.name}
|
||||||
|
</div>
|
||||||
|
{!link.external && <Icon name="material-symbols:chevron-right-rounded"
|
||||||
|
class="transition text-[1.25rem] text-[var(--primary)]"
|
||||||
|
>
|
||||||
|
</Icon>}
|
||||||
|
{link.external && <Icon name="fa6-solid:arrow-up-right-from-square"
|
||||||
|
class="transition text-[0.75rem] text-black/25 dark:text-white/25 -translate-x-1"
|
||||||
|
>
|
||||||
|
</Icon>}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
93
src/components/widget/Profile.astro
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
---
|
||||||
|
import { Icon } from "astro-icon/components";
|
||||||
|
import { profileConfig, umamiConfig, siteConfig } from "../../config";
|
||||||
|
|
||||||
|
const config = profileConfig;
|
||||||
|
---
|
||||||
|
<div class="card-base p-3 border border-black/10 dark:border-white/10">
|
||||||
|
<div class="relative mx-auto mt-1 lg:mx-0 lg:mt-0 mb-3 max-w-[12rem] lg:max-w-none rounded-xl overflow-hidden" style={`--theme-hue: ${siteConfig.themeColor.hue}`}>
|
||||||
|
<div class="loading-bar absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-32 h-1 bg-gray-200 dark:bg-gray-700 z-10 rounded-full overflow-hidden">
|
||||||
|
<div class="loading-progress h-full w-8 bg-[oklch(0.70_0.14_var(--theme-hue))] animate-loading-progress rounded-full"></div>
|
||||||
|
</div>
|
||||||
|
<img src={config.avatar} alt="Profile Image of the Author" class="w-full h-full object-cover opacity-0 transition-opacity duration-500" onload="this.style.opacity='1'; this.parentElement.querySelector('.loading-bar').style.opacity='0';"/>
|
||||||
|
</div>
|
||||||
|
<div class="px-2">
|
||||||
|
<div class="font-bold text-xl text-center mb-1 dark:text-neutral-50 transition">{config.name}</div>
|
||||||
|
<div class="h-1 w-5 bg-[var(--primary)] mx-auto rounded-full mb-2 transition"></div>
|
||||||
|
<div class="text-center text-neutral-400 mb-2.5 transition">{config.bio}</div>
|
||||||
|
<div class="flex flex-wrap gap-2 justify-center mb-1">
|
||||||
|
{config.links.length > 1 && config.links.map(item =>
|
||||||
|
<a rel="me" aria-label={item.name} href={item.url} target="_blank" class="btn-regular rounded-lg h-10 w-10 active:scale-90">
|
||||||
|
<Icon name={item.icon} class="text-[1.5rem]"></Icon>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{config.links.length == 1 && <a rel="me" aria-label={config.links[0].name} href={config.links[0].url} target="_blank"
|
||||||
|
class="btn-regular rounded-lg h-10 gap-2 px-3 font-bold active:scale-95">
|
||||||
|
<Icon name={config.links[0].icon} class="text-[1.5rem]"></Icon>
|
||||||
|
{config.links[0].name}
|
||||||
|
</a>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 全站访问量统计 -->
|
||||||
|
<div class="grid grid-cols-2 mt-3 pt-3 border-t border-neutral-200 dark:border-neutral-700">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-xs text-neutral-500 mb-1 flex items-center justify-center gap-1">
|
||||||
|
<Icon name="material-symbols:visibility-outline" class="text-base"></Icon>
|
||||||
|
<span class="text-xs">访问量</span>
|
||||||
|
</div>
|
||||||
|
<div id="site-views" class="font-bold text-lg text-neutral-700 dark:text-neutral-300">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-center border-l border-neutral-200 dark:border-neutral-700">
|
||||||
|
<div class="text-xs text-neutral-500 mb-1 flex items-center justify-center gap-1">
|
||||||
|
<Icon name="material-symbols:person" class="text-base"></Icon>
|
||||||
|
<span class="text-xs">访客数</span>
|
||||||
|
</div>
|
||||||
|
<div id="site-visitors" class="font-bold text-lg text-neutral-700 dark:text-neutral-300">-</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.loading-bar {
|
||||||
|
transition: opacity 0.5s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes loading-progress {
|
||||||
|
0% {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateX(400%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-loading-progress {
|
||||||
|
animation: loading-progress 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script define:vars={{ umamiConfig}}>
|
||||||
|
// 获取全站访问量统计
|
||||||
|
async function loadSiteStats() {
|
||||||
|
if (!umamiConfig.enable) return;
|
||||||
|
try {
|
||||||
|
const statsData = await fetchUmamiStats(umamiConfig.baseUrl, umamiConfig.shareId, {
|
||||||
|
timezone: umamiConfig.timezone
|
||||||
|
});
|
||||||
|
|
||||||
|
const pageviews = statsData.pageviews || 0;
|
||||||
|
const visitors = statsData.visitors || 0;
|
||||||
|
|
||||||
|
const viewsElement = document.getElementById('site-views');
|
||||||
|
const visitorsElement = document.getElementById('site-visitors');
|
||||||
|
if (viewsElement) viewsElement.textContent = pageviews;
|
||||||
|
if (visitorsElement) visitorsElement.textContent = visitors;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取全站统计失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 页面加载完成后获取统计数据
|
||||||
|
document.addEventListener('DOMContentLoaded', loadSiteStats);
|
||||||
|
</script>
|
||||||
24
src/components/widget/SideBar.astro
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
---
|
||||||
|
import type { MarkdownHeading } from "astro";
|
||||||
|
|
||||||
|
import Profile from "./Profile.astro";
|
||||||
|
import CategoryList from "./CategoryList.astro";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
class?: string;
|
||||||
|
headings?: MarkdownHeading[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const className = Astro.props.class;
|
||||||
|
---
|
||||||
|
<div id="sidebar" class:list={[className, "w-full"]}>
|
||||||
|
<div class="flex flex-col w-full gap-4 mb-4">
|
||||||
|
<Profile></Profile>
|
||||||
|
<CategoryList />
|
||||||
|
</div>
|
||||||
|
<div id="sidebar-sticky" class="transition-all duration-700 flex flex-col w-full gap-4 top-4 sticky top-4">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
268
src/components/widget/TOC.astro
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
---
|
||||||
|
import type { MarkdownHeading } from "astro";
|
||||||
|
import { siteConfig } from "../../config";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
class?: string;
|
||||||
|
headings: MarkdownHeading[];
|
||||||
|
}
|
||||||
|
|
||||||
|
let { headings = [] } = Astro.props;
|
||||||
|
|
||||||
|
let minDepth = 10;
|
||||||
|
for (const heading of headings) {
|
||||||
|
minDepth = Math.min(minDepth, heading.depth);
|
||||||
|
}
|
||||||
|
|
||||||
|
const className = Astro.props.class;
|
||||||
|
|
||||||
|
const isPostsRoute = Astro.url.pathname.startsWith("/posts/");
|
||||||
|
|
||||||
|
const removeTailingHash = (text: string) => {
|
||||||
|
let lastIndexOfHash = text.lastIndexOf("#");
|
||||||
|
if (lastIndexOfHash !== text.length - 1) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
return text.substring(0, lastIndexOfHash);
|
||||||
|
};
|
||||||
|
|
||||||
|
let heading1Count = 1;
|
||||||
|
|
||||||
|
const maxLevel = siteConfig.toc.depth;
|
||||||
|
---
|
||||||
|
{isPostsRoute &&
|
||||||
|
<table-of-contents class:list={[className, "group"]}>
|
||||||
|
{headings.filter((heading) => heading.depth < minDepth + maxLevel).map((heading) =>
|
||||||
|
<a href={`#${heading.slug}`} class="px-2 flex gap-2 relative transition w-full min-h-9 rounded-xl
|
||||||
|
hover:bg-[var(--toc-btn-hover)] active:bg-[var(--toc-btn-active)] py-2
|
||||||
|
">
|
||||||
|
<div class:list={["transition w-5 h-5 shrink-0 rounded-lg text-xs flex items-center justify-center font-bold",
|
||||||
|
{
|
||||||
|
"bg-[var(--toc-badge-bg)] text-[var(--btn-content)]": heading.depth == minDepth,
|
||||||
|
"ml-4": heading.depth == minDepth + 1,
|
||||||
|
"ml-8": heading.depth == minDepth + 2,
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{heading.depth == minDepth && heading1Count++}
|
||||||
|
{heading.depth == minDepth + 1 && <div class="transition w-2 h-2 rounded-[0.1875rem] bg-[var(--toc-badge-bg)]"></div>}
|
||||||
|
{heading.depth == minDepth + 2 && <div class="transition w-1.5 h-1.5 rounded-sm bg-black/5 dark:bg-white/10"></div>}
|
||||||
|
</div>
|
||||||
|
<div class:list={["transition text-sm", {
|
||||||
|
"text-50": heading.depth == minDepth || heading.depth == minDepth + 1,
|
||||||
|
"text-30": heading.depth == minDepth + 2,
|
||||||
|
}]}>{removeTailingHash(heading.text)}</div>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<div id="active-indicator" class:list={[{'hidden': headings.length == 0}, "-z-10 absolute bg-[var(--toc-btn-hover)] left-0 right-0 rounded-xl transition-all " +
|
||||||
|
"group-hover:bg-transparent border-2 border-[var(--toc-btn-hover)] group-hover:border-[var(--toc-btn-active)] border-dashed"]}></div>
|
||||||
|
</table-of-contents>}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
class TableOfContents extends HTMLElement {
|
||||||
|
tocEl: HTMLElement | null = null;
|
||||||
|
visibleClass = "visible";
|
||||||
|
observer: IntersectionObserver;
|
||||||
|
anchorNavTarget: HTMLElement | null = null;
|
||||||
|
headingIdxMap = new Map<string, number>();
|
||||||
|
headings: HTMLElement[] = [];
|
||||||
|
sections: HTMLElement[] = [];
|
||||||
|
tocEntries: HTMLAnchorElement[] = [];
|
||||||
|
active: boolean[] = [];
|
||||||
|
activeIndicator: HTMLElement | null = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.observer = new IntersectionObserver(
|
||||||
|
this.markVisibleSection, { threshold: 0 }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
markVisibleSection = (entries: IntersectionObserverEntry[]) => {
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
const id = entry.target.children[0]?.getAttribute("id");
|
||||||
|
const idx = id ? this.headingIdxMap.get(id) : undefined;
|
||||||
|
if (idx != undefined)
|
||||||
|
this.active[idx] = entry.isIntersecting;
|
||||||
|
|
||||||
|
if (entry.isIntersecting && this.anchorNavTarget == entry.target.firstChild)
|
||||||
|
this.anchorNavTarget = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!this.active.includes(true)) {
|
||||||
|
// 当没有任何标题在视窗中时,隐藏 active-indicator
|
||||||
|
this.activeIndicator?.setAttribute("style", "display: none;");
|
||||||
|
this.fallback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.update();
|
||||||
|
};
|
||||||
|
|
||||||
|
toggleActiveHeading = () => {
|
||||||
|
let i = this.active.length - 1;
|
||||||
|
let min = this.active.length - 1, max = 0;
|
||||||
|
while (i >= 0 && !this.active[i]) {
|
||||||
|
this.tocEntries[i].classList.remove(this.visibleClass);
|
||||||
|
i--;
|
||||||
|
}
|
||||||
|
while (i >= 0 && this.active[i]) {
|
||||||
|
this.tocEntries[i].classList.add(this.visibleClass);
|
||||||
|
min = Math.min(min, i);
|
||||||
|
max = Math.max(max, i);
|
||||||
|
i--;
|
||||||
|
}
|
||||||
|
while (i >= 0) {
|
||||||
|
this.tocEntries[i].classList.remove(this.visibleClass);
|
||||||
|
i--;
|
||||||
|
}
|
||||||
|
let parentOffset = this.tocEl?.getBoundingClientRect().top || 0;
|
||||||
|
let scrollOffset = this.tocEl?.scrollTop || 0;
|
||||||
|
let top = this.tocEntries[min].getBoundingClientRect().top - parentOffset + scrollOffset;
|
||||||
|
let bottom = this.tocEntries[max].getBoundingClientRect().bottom - parentOffset + scrollOffset;
|
||||||
|
this.activeIndicator?.setAttribute("style", `display: block; top: ${top}px; height: ${bottom - top}px`);
|
||||||
|
};
|
||||||
|
|
||||||
|
scrollToActiveHeading = () => {
|
||||||
|
// If the TOC widget can accommodate both the topmost
|
||||||
|
// and bottommost items, scroll to the topmost item.
|
||||||
|
// Otherwise, scroll to the bottommost one.
|
||||||
|
|
||||||
|
if (this.anchorNavTarget || !this.tocEl) return;
|
||||||
|
const activeHeading =
|
||||||
|
document.querySelectorAll<HTMLDivElement>(`#toc .${this.visibleClass}`);
|
||||||
|
if (!activeHeading.length) return;
|
||||||
|
|
||||||
|
const topmost = activeHeading[0];
|
||||||
|
const bottommost = activeHeading[activeHeading.length - 1];
|
||||||
|
const tocHeight = this.tocEl.clientHeight;
|
||||||
|
|
||||||
|
let top;
|
||||||
|
if (bottommost.getBoundingClientRect().bottom -
|
||||||
|
topmost.getBoundingClientRect().top < 0.9 * tocHeight)
|
||||||
|
top = topmost.offsetTop - 32;
|
||||||
|
else
|
||||||
|
top = bottommost.offsetTop - tocHeight * 0.8;
|
||||||
|
|
||||||
|
this.tocEl.scrollTo({
|
||||||
|
top,
|
||||||
|
left: 0,
|
||||||
|
behavior: "smooth",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
update = () => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
this.toggleActiveHeading();
|
||||||
|
// requestAnimationFrame(() => {
|
||||||
|
this.scrollToActiveHeading();
|
||||||
|
// });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
fallback = () => {
|
||||||
|
if (!this.sections.length) return;
|
||||||
|
|
||||||
|
for (let i = 0; i < this.sections.length; i++) {
|
||||||
|
let offsetTop = this.sections[i].getBoundingClientRect().top;
|
||||||
|
let offsetBottom = this.sections[i].getBoundingClientRect().bottom;
|
||||||
|
|
||||||
|
if (this.isInRange(offsetTop, 0, window.innerHeight)
|
||||||
|
|| this.isInRange(offsetBottom, 0, window.innerHeight)
|
||||||
|
|| (offsetTop < 0 && offsetBottom > window.innerHeight)) {
|
||||||
|
this.markActiveHeading(i);
|
||||||
|
}
|
||||||
|
else if (offsetTop > window.innerHeight) break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
markActiveHeading = (idx: number)=> {
|
||||||
|
this.active[idx] = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
handleAnchorClick = (event: Event) => {
|
||||||
|
const anchor = event
|
||||||
|
.composedPath()
|
||||||
|
.find((element) => element instanceof HTMLAnchorElement);
|
||||||
|
|
||||||
|
if (anchor) {
|
||||||
|
const id = decodeURIComponent(anchor.hash?.substring(1));
|
||||||
|
const idx = this.headingIdxMap.get(id);
|
||||||
|
if (idx !== undefined) {
|
||||||
|
this.anchorNavTarget = this.headings[idx];
|
||||||
|
} else {
|
||||||
|
this.anchorNavTarget = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
isInRange(value: number, min: number, max: number) {
|
||||||
|
return min < value && value < max;
|
||||||
|
};
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
// wait for the onload animation to finish, which makes the `getBoundingClientRect` return correct values
|
||||||
|
const element = document.querySelector('.prose');
|
||||||
|
if (element) {
|
||||||
|
element.addEventListener('animationend', () => {
|
||||||
|
this.init();
|
||||||
|
}, { once: true });
|
||||||
|
} else {
|
||||||
|
console.debug('Animation element not found');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.tocEl = document.getElementById(
|
||||||
|
"toc-inner-wrapper"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!this.tocEl) return;
|
||||||
|
|
||||||
|
this.tocEl.addEventListener("click", this.handleAnchorClick, {
|
||||||
|
capture: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.activeIndicator = document.getElementById("active-indicator");
|
||||||
|
|
||||||
|
this.tocEntries = Array.from(
|
||||||
|
document.querySelectorAll<HTMLAnchorElement>("#toc a[href^='#']")
|
||||||
|
);
|
||||||
|
|
||||||
|
if (this.tocEntries.length === 0) return;
|
||||||
|
|
||||||
|
this.sections = new Array(this.tocEntries.length);
|
||||||
|
this.headings = new Array(this.tocEntries.length);
|
||||||
|
for (let i = 0; i < this.tocEntries.length; i++) {
|
||||||
|
const id = decodeURIComponent(this.tocEntries[i].hash?.substring(1));
|
||||||
|
const heading = document.getElementById(id);
|
||||||
|
const section = heading?.parentElement;
|
||||||
|
if (heading instanceof HTMLElement && section instanceof HTMLElement) {
|
||||||
|
this.headings[i] = heading;
|
||||||
|
this.sections[i] = section;
|
||||||
|
this.headingIdxMap.set(id, i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.active = new Array(this.tocEntries.length).fill(false);
|
||||||
|
|
||||||
|
this.sections.forEach((section) =>
|
||||||
|
this.observer.observe(section)
|
||||||
|
);
|
||||||
|
|
||||||
|
this.fallback();
|
||||||
|
this.update();
|
||||||
|
};
|
||||||
|
|
||||||
|
disconnectedCallback() {
|
||||||
|
this.sections.forEach((section) =>
|
||||||
|
this.observer.unobserve(section)
|
||||||
|
);
|
||||||
|
this.observer.disconnect();
|
||||||
|
this.tocEl?.removeEventListener("click", this.handleAnchorClick);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!customElements.get("table-of-contents")) {
|
||||||
|
customElements.define("table-of-contents", TableOfContents);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
158
src/config.ts
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import type {
|
||||||
|
ExpressiveCodeConfig,
|
||||||
|
GitHubEditConfig,
|
||||||
|
ImageFallbackConfig,
|
||||||
|
LicenseConfig,
|
||||||
|
NavBarConfig,
|
||||||
|
ProfileConfig,
|
||||||
|
SiteConfig,
|
||||||
|
UmamiConfig,
|
||||||
|
} from "./types/config";
|
||||||
|
import { LinkPreset } from "./types/config";
|
||||||
|
|
||||||
|
export const siteConfig: SiteConfig = {
|
||||||
|
title: "MeowRain的技术博客",
|
||||||
|
subtitle: "技术分享与实践",
|
||||||
|
description:
|
||||||
|
"分享软件开发、编程语言、框架和工具的技术博客,涵盖实用教程、最佳实践和行业动态,帮助开发者提升技能。",
|
||||||
|
|
||||||
|
keywords: [],
|
||||||
|
lang: "zh_CN", // 'en', 'zh_CN', 'zh_TW', 'ja', 'ko', 'es', 'th'
|
||||||
|
themeColor: {
|
||||||
|
hue: 361, // Default hue for the theme color, from 0 to 360. e.g. red: 0, teal: 200, cyan: 250, pink: 345
|
||||||
|
fixed: false, // Hide the theme color picker for visitors
|
||||||
|
forceDarkMode: false, // Force dark mode and hide theme switcher
|
||||||
|
},
|
||||||
|
banner: {
|
||||||
|
enable: false,
|
||||||
|
src: "/xinghui.avif", // Relative to the /src directory. Relative to the /public directory if it starts with '/'
|
||||||
|
|
||||||
|
position: "center", // Equivalent to object-position, only supports 'top', 'center', 'bottom'. 'center' by default
|
||||||
|
credit: {
|
||||||
|
enable: true, // Display the credit text of the banner image
|
||||||
|
text: "Pixiv @chokei", // Credit text to be displayed
|
||||||
|
|
||||||
|
url: "https://www.pixiv.net/artworks/122782209", // (Optional) URL link to the original artwork or artist's page
|
||||||
|
},
|
||||||
|
},
|
||||||
|
background: {
|
||||||
|
enable: true, // Enable background image
|
||||||
|
src: "", // Background image URL (supports HTTPS)
|
||||||
|
position: "center", // Background position: 'top', 'center', 'bottom'
|
||||||
|
size: "cover", // Background size: 'cover', 'contain', 'auto'
|
||||||
|
repeat: "no-repeat", // Background repeat: 'no-repeat', 'repeat', 'repeat-x', 'repeat-y'
|
||||||
|
attachment: "fixed", // Background attachment: 'fixed', 'scroll', 'local'
|
||||||
|
opacity: 1, // Background opacity (0-1)
|
||||||
|
},
|
||||||
|
toc: {
|
||||||
|
enable: true, // Display the table of contents on the right side of the post
|
||||||
|
depth: 2, // Maximum heading depth to show in the table, from 1 to 3
|
||||||
|
},
|
||||||
|
favicon: [
|
||||||
|
// Leave this array empty to use the default favicon
|
||||||
|
{
|
||||||
|
src: "https://q2.qlogo.cn/headimg_dl?dst_uin=2726730791&spec=0", // Path of the favicon, relative to the /public directory
|
||||||
|
// theme: 'light', // (Optional) Either 'light' or 'dark', set only if you have different favicons for light and dark mode
|
||||||
|
// sizes: '32x32', // (Optional) Size of the favicon, set only if you have favicons of different sizes
|
||||||
|
},
|
||||||
|
],
|
||||||
|
officialSites: [
|
||||||
|
{ url: "https://blog.meowrain.cn", alias: "EdgeOne CN" },
|
||||||
|
{ url: "https://blog2.meowrain.cn", alias: "Global" },
|
||||||
|
{ url: "https://www.meowrain.cn", alias: "Global" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const navBarConfig: NavBarConfig = {
|
||||||
|
links: [
|
||||||
|
LinkPreset.Home,
|
||||||
|
LinkPreset.Archive,
|
||||||
|
{
|
||||||
|
name: "分类",
|
||||||
|
url: "/category/",
|
||||||
|
external: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "相册",
|
||||||
|
url: "/gallery/",
|
||||||
|
external: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "友链",
|
||||||
|
url: "/friends/", // Internal links should not include the base path, as it is automatically added
|
||||||
|
external: false, // Show an external link icon and will open in a new tab
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "赞助",
|
||||||
|
url: "/sponsors/", // Internal links should not include the base path, as it is automatically added
|
||||||
|
external: false, // Show an external link icon and will open in a new tab
|
||||||
|
},
|
||||||
|
// {
|
||||||
|
// name: "统计",
|
||||||
|
// url: "https://umami.acofork.com/share/CdkXbGgZr6ECKOyK", // Internal links should not include the base path, as it is automatically added
|
||||||
|
// external: true, // Show an external link icon and will open in a new tab
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// name: "监控",
|
||||||
|
// url: "https://eoddos.2x.nz", // Internal links should not include the base path, as it is automatically added
|
||||||
|
// external: true, // Show an external link icon and will open in a new tab
|
||||||
|
// },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const profileConfig: ProfileConfig = {
|
||||||
|
avatar: "https://blog.meowrain.cn/api/i/2025/07/18/zn3t6t-1.webp", // Relative to the /src directory. Relative to the /public directory if it starts with '/'
|
||||||
|
name: "MeowRain",
|
||||||
|
bio: "A developer who loves to code and learn new things,build code for love❤️ and fun🎉",
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
name: "GitHub",
|
||||||
|
icon: "fa6-brands:github",
|
||||||
|
url: "https://github.com/meowrain",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "我的OpenWebUI站",
|
||||||
|
icon: "fa6-brands:airbnb",
|
||||||
|
url: "https://ai.meowrain.cn",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "服务器状态监控",
|
||||||
|
icon: "fa6-solid:server",
|
||||||
|
url: "https://status.meowrain.cn",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bilibili",
|
||||||
|
icon: "fa6-brands:bilibili",
|
||||||
|
url: "https://space.bilibili.com/386388600",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
export const licenseConfig: LicenseConfig = {
|
||||||
|
enable: true,
|
||||||
|
name: "CC BY-NC-SA 4.0",
|
||||||
|
url: "https://creativecommons.org/licenses/by-nc-sa/4.0/",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const imageFallbackConfig: ImageFallbackConfig = {
|
||||||
|
enable: false,
|
||||||
|
originalDomain: "https://eopfapi.acofork.com/pic?img=ua",
|
||||||
|
fallbackDomain: "https://eopfapi.acofork.com/pic?img=ua",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const umamiConfig: UmamiConfig = {
|
||||||
|
enable: true,
|
||||||
|
baseUrl: "https://umami.acofork.com",
|
||||||
|
shareId: "CdkXbGgZr6ECKOyK",
|
||||||
|
timezone: "Asia/Shanghai",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const expressiveCodeConfig: ExpressiveCodeConfig = {
|
||||||
|
theme: "github-dark",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const gitHubEditConfig: GitHubEditConfig = {
|
||||||
|
enable: true,
|
||||||
|
baseUrl: "https://github.com/afoim/fuwari/blob/main/src/content/posts",
|
||||||
|
};
|
||||||
|
|
||||||
|
// todoConfig removed from here
|
||||||
17
src/constants/constants.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
export const PAGE_SIZE = 8;
|
||||||
|
|
||||||
|
export const LIGHT_MODE = "light",
|
||||||
|
DARK_MODE = "dark",
|
||||||
|
AUTO_MODE = "auto";
|
||||||
|
export const DEFAULT_THEME = AUTO_MODE;
|
||||||
|
|
||||||
|
// Banner height unit: vh
|
||||||
|
export const BANNER_HEIGHT = 35;
|
||||||
|
export const BANNER_HEIGHT_EXTEND = 30;
|
||||||
|
export const BANNER_HEIGHT_HOME = BANNER_HEIGHT + BANNER_HEIGHT_EXTEND;
|
||||||
|
|
||||||
|
// The height the main panel overlaps the banner, unit: rem
|
||||||
|
export const MAIN_PANEL_OVERLAPS_BANNER_HEIGHT = 3.5;
|
||||||
|
|
||||||
|
// Page width: rem
|
||||||
|
export const PAGE_WIDTH = 75;
|
||||||
44
src/constants/icon.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import type { Favicon } from "@/types/config.ts";
|
||||||
|
|
||||||
|
export const defaultFavicons: Favicon[] = [
|
||||||
|
{
|
||||||
|
src: "/favicon/favicon-light-32.png",
|
||||||
|
theme: "light",
|
||||||
|
sizes: "32x32",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "/favicon/favicon-light-128.png",
|
||||||
|
theme: "light",
|
||||||
|
sizes: "128x128",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "/favicon/favicon-light-180.png",
|
||||||
|
theme: "light",
|
||||||
|
sizes: "180x180",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "/favicon/favicon-light-192.png",
|
||||||
|
theme: "light",
|
||||||
|
sizes: "192x192",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "/favicon/favicon-dark-32.png",
|
||||||
|
theme: "dark",
|
||||||
|
sizes: "32x32",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "/favicon/favicon-dark-128.png",
|
||||||
|
theme: "dark",
|
||||||
|
sizes: "128x128",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "/favicon/favicon-dark-180.png",
|
||||||
|
theme: "dark",
|
||||||
|
sizes: "180x180",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "/favicon/favicon-dark-192.png",
|
||||||
|
theme: "dark",
|
||||||
|
sizes: "192x192",
|
||||||
|
},
|
||||||
|
];
|
||||||
13
src/constants/link-presets.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { LinkPreset, type NavBarLink } from "@/types/config";
|
||||||
|
|
||||||
|
|
||||||
|
export const LinkPresets: { [key in LinkPreset]: NavBarLink } = {
|
||||||
|
[LinkPreset.Home]: {
|
||||||
|
name: "首页",
|
||||||
|
url: "/",
|
||||||
|
},
|
||||||
|
[LinkPreset.Archive]: {
|
||||||
|
name: "归档",
|
||||||
|
url: "/archive/",
|
||||||
|
},
|
||||||
|
};
|
||||||
6
src/content/.obsidian/app.json
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"attachmentFolderPath": "assets/images",
|
||||||
|
"newLinkFormat": "relative",
|
||||||
|
"useMarkdownLinks": true,
|
||||||
|
"uriCallbacks": false
|
||||||
|
}
|
||||||
1
src/content/.obsidian/appearance.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
3
src/content/.obsidian/community-plugins.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[
|
||||||
|
"obsidian-paste-image-rename"
|
||||||
|
]
|
||||||
33
src/content/.obsidian/core-plugins.json
vendored
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"file-explorer": true,
|
||||||
|
"global-search": true,
|
||||||
|
"switcher": true,
|
||||||
|
"graph": true,
|
||||||
|
"backlink": true,
|
||||||
|
"canvas": true,
|
||||||
|
"outgoing-link": true,
|
||||||
|
"tag-pane": true,
|
||||||
|
"footnotes": false,
|
||||||
|
"properties": false,
|
||||||
|
"page-preview": true,
|
||||||
|
"daily-notes": true,
|
||||||
|
"templates": true,
|
||||||
|
"note-composer": true,
|
||||||
|
"command-palette": true,
|
||||||
|
"slash-command": false,
|
||||||
|
"editor-status": true,
|
||||||
|
"bookmarks": true,
|
||||||
|
"markdown-importer": false,
|
||||||
|
"zk-prefixer": false,
|
||||||
|
"random-note": false,
|
||||||
|
"outline": true,
|
||||||
|
"word-count": true,
|
||||||
|
"slides": false,
|
||||||
|
"audio-recorder": false,
|
||||||
|
"workspaces": false,
|
||||||
|
"file-recovery": true,
|
||||||
|
"publish": false,
|
||||||
|
"sync": true,
|
||||||
|
"bases": true,
|
||||||
|
"webviewer": false
|
||||||
|
}
|
||||||
10
src/content/.obsidian/plugins/obsidian-paste-image-rename/data.json
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"imageNamePattern": "{{fileName}}",
|
||||||
|
"dupNumberAtStart": false,
|
||||||
|
"dupNumberDelimiter": "-",
|
||||||
|
"dupNumberAlways": false,
|
||||||
|
"autoRename": true,
|
||||||
|
"handleAllAttachments": false,
|
||||||
|
"excludeExtensionPattern": "",
|
||||||
|
"disableRenameNotice": false
|
||||||
|
}
|
||||||
944
src/content/.obsidian/plugins/obsidian-paste-image-rename/main.js
vendored
Normal file
@@ -0,0 +1,944 @@
|
|||||||
|
/* THIS IS A GENERATED/BUNDLED FILE BY ESBUILD */
|
||||||
|
var __defProp = Object.defineProperty;
|
||||||
|
var __defProps = Object.defineProperties;
|
||||||
|
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
||||||
|
var __getOwnPropDescs = Object.getOwnPropertyDescriptors;
|
||||||
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
||||||
|
var __getOwnPropSymbols = Object.getOwnPropertySymbols;
|
||||||
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
||||||
|
var __propIsEnum = Object.prototype.propertyIsEnumerable;
|
||||||
|
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
|
||||||
|
var __spreadValues = (a, b) => {
|
||||||
|
for (var prop in b || (b = {}))
|
||||||
|
if (__hasOwnProp.call(b, prop))
|
||||||
|
__defNormalProp(a, prop, b[prop]);
|
||||||
|
if (__getOwnPropSymbols)
|
||||||
|
for (var prop of __getOwnPropSymbols(b)) {
|
||||||
|
if (__propIsEnum.call(b, prop))
|
||||||
|
__defNormalProp(a, prop, b[prop]);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
};
|
||||||
|
var __spreadProps = (a, b) => __defProps(a, __getOwnPropDescs(b));
|
||||||
|
var __commonJS = (cb, mod) => function __require() {
|
||||||
|
return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
|
||||||
|
};
|
||||||
|
var __export = (target, all) => {
|
||||||
|
for (var name in all)
|
||||||
|
__defProp(target, name, { get: all[name], enumerable: true });
|
||||||
|
};
|
||||||
|
var __copyProps = (to, from, except, desc) => {
|
||||||
|
if (from && typeof from === "object" || typeof from === "function") {
|
||||||
|
for (let key of __getOwnPropNames(from))
|
||||||
|
if (!__hasOwnProp.call(to, key) && key !== except)
|
||||||
|
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
||||||
|
}
|
||||||
|
return to;
|
||||||
|
};
|
||||||
|
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
||||||
|
var __async = (__this, __arguments, generator) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
var fulfilled = (value) => {
|
||||||
|
try {
|
||||||
|
step(generator.next(value));
|
||||||
|
} catch (e) {
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
var rejected = (value) => {
|
||||||
|
try {
|
||||||
|
step(generator.throw(value));
|
||||||
|
} catch (e) {
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
var step = (x) => x.done ? resolve(x.value) : Promise.resolve(x.value).then(fulfilled, rejected);
|
||||||
|
step((generator = generator.apply(__this, __arguments)).next());
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// package.json
|
||||||
|
var require_package = __commonJS({
|
||||||
|
"package.json"(exports, module2) {
|
||||||
|
module2.exports = {
|
||||||
|
name: "obsidian-paste-image-rename",
|
||||||
|
version: "1.6.1",
|
||||||
|
main: "main.js",
|
||||||
|
scripts: {
|
||||||
|
start: "node esbuild.config.mjs",
|
||||||
|
build: "tsc -noEmit -skipLibCheck && BUILD_ENV=production node esbuild.config.mjs && cp manifest.json build",
|
||||||
|
version: "node version-bump.mjs && git add manifest.json versions.json",
|
||||||
|
release: "npm run build && gh release create ${npm_package_version} build/*"
|
||||||
|
},
|
||||||
|
keywords: [],
|
||||||
|
author: "Reorx",
|
||||||
|
license: "MIT",
|
||||||
|
devDependencies: {
|
||||||
|
"@types/node": "^18.11.18",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^5.49.0",
|
||||||
|
"@typescript-eslint/parser": "^5.49.0",
|
||||||
|
"builtin-modules": "^3.3.0",
|
||||||
|
esbuild: "0.16.17",
|
||||||
|
obsidian: "^1.1.1",
|
||||||
|
tslib: "2.5.0",
|
||||||
|
typescript: "4.9.4"
|
||||||
|
},
|
||||||
|
dependencies: {
|
||||||
|
"cash-dom": "^8.1.2"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// src/main.ts
|
||||||
|
var main_exports = {};
|
||||||
|
__export(main_exports, {
|
||||||
|
default: () => PasteImageRenamePlugin
|
||||||
|
});
|
||||||
|
module.exports = __toCommonJS(main_exports);
|
||||||
|
var import_obsidian2 = require("obsidian");
|
||||||
|
|
||||||
|
// src/batch.ts
|
||||||
|
var import_obsidian = require("obsidian");
|
||||||
|
|
||||||
|
// src/utils.ts
|
||||||
|
var DEBUG = false;
|
||||||
|
if (DEBUG)
|
||||||
|
console.log("DEBUG is enabled");
|
||||||
|
function debugLog(...args) {
|
||||||
|
if (DEBUG) {
|
||||||
|
console.log(new Date().toISOString().slice(11, 23), ...args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function createElementTree(rootEl, opts) {
|
||||||
|
const result = {
|
||||||
|
el: rootEl.createEl(opts.tag, opts),
|
||||||
|
children: []
|
||||||
|
};
|
||||||
|
const children = opts.children || [];
|
||||||
|
for (const child of children) {
|
||||||
|
result.children.push(createElementTree(result.el, child));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
var path = {
|
||||||
|
// Credit: @creationix/path.js
|
||||||
|
join(...partSegments) {
|
||||||
|
let parts = [];
|
||||||
|
for (let i = 0, l = partSegments.length; i < l; i++) {
|
||||||
|
parts = parts.concat(partSegments[i].split("/"));
|
||||||
|
}
|
||||||
|
const newParts = [];
|
||||||
|
for (let i = 0, l = parts.length; i < l; i++) {
|
||||||
|
const part = parts[i];
|
||||||
|
if (!part || part === ".")
|
||||||
|
continue;
|
||||||
|
else
|
||||||
|
newParts.push(part);
|
||||||
|
}
|
||||||
|
if (parts[0] === "")
|
||||||
|
newParts.unshift("");
|
||||||
|
return newParts.join("/");
|
||||||
|
},
|
||||||
|
// returns the last part of a path, e.g. 'foo.jpg'
|
||||||
|
basename(fullpath) {
|
||||||
|
const sp = fullpath.split("/");
|
||||||
|
return sp[sp.length - 1];
|
||||||
|
},
|
||||||
|
// return extension without dot, e.g. 'jpg'
|
||||||
|
extension(fullpath) {
|
||||||
|
const positions = [...fullpath.matchAll(new RegExp("\\.", "gi"))].map((a) => a.index);
|
||||||
|
return fullpath.slice(positions[positions.length - 1] + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
var filenameNotAllowedChars = /[^\p{L}0-9~`!@$&*()\-_=+{};'",<.>? ]/ug;
|
||||||
|
var sanitizer = {
|
||||||
|
filename(s) {
|
||||||
|
return s.replace(filenameNotAllowedChars, "").trim();
|
||||||
|
},
|
||||||
|
delimiter(s) {
|
||||||
|
s = this.filename(s);
|
||||||
|
if (!s)
|
||||||
|
s = "-";
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
function escapeRegExp(s) {
|
||||||
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
}
|
||||||
|
function lockInputMethodComposition(el) {
|
||||||
|
const state = {
|
||||||
|
lock: false
|
||||||
|
};
|
||||||
|
el.addEventListener("compositionstart", () => {
|
||||||
|
state.lock = true;
|
||||||
|
});
|
||||||
|
el.addEventListener("compositionend", () => {
|
||||||
|
state.lock = false;
|
||||||
|
});
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
// src/batch.ts
|
||||||
|
var ImageBatchRenameModal = class extends import_obsidian.Modal {
|
||||||
|
constructor(app, activeFile, renameFunc, onClose) {
|
||||||
|
super(app);
|
||||||
|
this.activeFile = activeFile;
|
||||||
|
this.renameFunc = renameFunc;
|
||||||
|
this.onCloseExtra = onClose;
|
||||||
|
this.state = {
|
||||||
|
namePattern: "",
|
||||||
|
extPattern: "",
|
||||||
|
nameReplace: "",
|
||||||
|
renameTasks: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
onOpen() {
|
||||||
|
this.containerEl.addClass("image-rename-modal");
|
||||||
|
const { contentEl, titleEl } = this;
|
||||||
|
titleEl.setText("Batch rename embeded files");
|
||||||
|
const namePatternSetting = new import_obsidian.Setting(contentEl).setName("Name pattern").setDesc("Please input the name pattern to match files (regex)").addText((text) => text.setValue(this.state.namePattern).onChange(
|
||||||
|
(value) => __async(this, null, function* () {
|
||||||
|
this.state.namePattern = value;
|
||||||
|
})
|
||||||
|
));
|
||||||
|
const npInputEl = namePatternSetting.controlEl.children[0];
|
||||||
|
npInputEl.focus();
|
||||||
|
const npInputState = lockInputMethodComposition(npInputEl);
|
||||||
|
npInputEl.addEventListener("keydown", (e) => __async(this, null, function* () {
|
||||||
|
if (e.key === "Enter" && !npInputState.lock) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!this.state.namePattern) {
|
||||||
|
errorEl.innerText = 'Error: "Name pattern" could not be empty';
|
||||||
|
errorEl.style.display = "block";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.matchImageNames(tbodyEl);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
const extPatternSetting = new import_obsidian.Setting(contentEl).setName("Extension pattern").setDesc("Please input the extension pattern to match files (regex)").addText((text) => text.setValue(this.state.extPattern).onChange(
|
||||||
|
(value) => __async(this, null, function* () {
|
||||||
|
this.state.extPattern = value;
|
||||||
|
})
|
||||||
|
));
|
||||||
|
const extInputEl = extPatternSetting.controlEl.children[0];
|
||||||
|
extInputEl.addEventListener("keydown", (e) => __async(this, null, function* () {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
this.matchImageNames(tbodyEl);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
const nameReplaceSetting = new import_obsidian.Setting(contentEl).setName("Name replace").setDesc("Please input the string to replace the matched name (use $1, $2 for regex groups)").addText((text) => text.setValue(this.state.nameReplace).onChange(
|
||||||
|
(value) => __async(this, null, function* () {
|
||||||
|
this.state.nameReplace = value;
|
||||||
|
})
|
||||||
|
));
|
||||||
|
const nrInputEl = nameReplaceSetting.controlEl.children[0];
|
||||||
|
const nrInputState = lockInputMethodComposition(nrInputEl);
|
||||||
|
nrInputEl.addEventListener("keydown", (e) => __async(this, null, function* () {
|
||||||
|
if (e.key === "Enter" && !nrInputState.lock) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.matchImageNames(tbodyEl);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
const matchedContainer = contentEl.createDiv({
|
||||||
|
cls: "matched-container"
|
||||||
|
});
|
||||||
|
const tableET = createElementTree(matchedContainer, {
|
||||||
|
tag: "table",
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
tag: "thead",
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
tag: "tr",
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
tag: "td",
|
||||||
|
text: "Original path"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: "td",
|
||||||
|
text: "Renamed Name"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: "tbody"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
const tbodyEl = tableET.children[1].el;
|
||||||
|
const errorEl = contentEl.createDiv({
|
||||||
|
cls: "error",
|
||||||
|
attr: {
|
||||||
|
style: "display: none;"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
new import_obsidian.Setting(contentEl).addButton((button) => {
|
||||||
|
button.setButtonText("Rename all").setClass("mod-cta").onClick(() => {
|
||||||
|
new ConfirmModal(
|
||||||
|
this.app,
|
||||||
|
"Confirm rename all",
|
||||||
|
`Are you sure? This will rename all the ${this.state.renameTasks.length} images matched the pattern.`,
|
||||||
|
() => {
|
||||||
|
this.renameAll();
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
).open();
|
||||||
|
});
|
||||||
|
}).addButton((button) => {
|
||||||
|
button.setButtonText("Cancel").onClick(() => {
|
||||||
|
this.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
onClose() {
|
||||||
|
const { contentEl } = this;
|
||||||
|
contentEl.empty();
|
||||||
|
this.onCloseExtra();
|
||||||
|
}
|
||||||
|
renameAll() {
|
||||||
|
return __async(this, null, function* () {
|
||||||
|
debugLog("renameAll", this.state);
|
||||||
|
for (const task of this.state.renameTasks) {
|
||||||
|
yield this.renameFunc(task.file, task.name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
matchImageNames(tbodyEl) {
|
||||||
|
const { state } = this;
|
||||||
|
const renameTasks = [];
|
||||||
|
tbodyEl.empty();
|
||||||
|
const fileCache = this.app.metadataCache.getFileCache(this.activeFile);
|
||||||
|
if (!fileCache || !fileCache.embeds)
|
||||||
|
return;
|
||||||
|
const namePatternRegex = new RegExp(state.namePattern, "g");
|
||||||
|
const extPatternRegex = new RegExp(state.extPattern);
|
||||||
|
fileCache.embeds.forEach((embed) => {
|
||||||
|
const file = this.app.metadataCache.getFirstLinkpathDest(embed.link, this.activeFile.path);
|
||||||
|
if (!file) {
|
||||||
|
console.warn("file not found", embed.link);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (state.extPattern) {
|
||||||
|
const m0 = extPatternRegex.exec(file.extension);
|
||||||
|
if (!m0)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const stem = file.basename;
|
||||||
|
namePatternRegex.lastIndex = 0;
|
||||||
|
const m1 = namePatternRegex.exec(stem);
|
||||||
|
if (!m1)
|
||||||
|
return;
|
||||||
|
let renamedName = file.name;
|
||||||
|
if (state.nameReplace) {
|
||||||
|
namePatternRegex.lastIndex = 0;
|
||||||
|
renamedName = stem.replace(namePatternRegex, state.nameReplace);
|
||||||
|
renamedName = `${renamedName}.${file.extension}`;
|
||||||
|
}
|
||||||
|
renameTasks.push({
|
||||||
|
file,
|
||||||
|
name: renamedName
|
||||||
|
});
|
||||||
|
createElementTree(tbodyEl, {
|
||||||
|
tag: "tr",
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
tag: "td",
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
tag: "span",
|
||||||
|
text: file.name
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: "div",
|
||||||
|
text: file.path,
|
||||||
|
attr: {
|
||||||
|
class: "file-path"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: "td",
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
tag: "span",
|
||||||
|
text: renamedName
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: "div",
|
||||||
|
text: path.join(file.parent.path, renamedName),
|
||||||
|
attr: {
|
||||||
|
class: "file-path"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
debugLog("new renameTasks", renameTasks);
|
||||||
|
state.renameTasks = renameTasks;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
var ConfirmModal = class extends import_obsidian.Modal {
|
||||||
|
constructor(app, title, message, onConfirm) {
|
||||||
|
super(app);
|
||||||
|
this.title = title;
|
||||||
|
this.message = message;
|
||||||
|
this.onConfirm = onConfirm;
|
||||||
|
}
|
||||||
|
onOpen() {
|
||||||
|
const { contentEl, titleEl } = this;
|
||||||
|
titleEl.setText(this.title);
|
||||||
|
contentEl.createEl("p", {
|
||||||
|
text: this.message
|
||||||
|
});
|
||||||
|
new import_obsidian.Setting(contentEl).addButton((button) => {
|
||||||
|
button.setButtonText("Yes").setClass("mod-warning").onClick(() => {
|
||||||
|
this.onConfirm();
|
||||||
|
this.close();
|
||||||
|
});
|
||||||
|
}).addButton((button) => {
|
||||||
|
button.setButtonText("No").onClick(() => {
|
||||||
|
this.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// src/template.ts
|
||||||
|
var dateTmplRegex = /{{DATE:([^}]+)}}/gm;
|
||||||
|
var frontmatterTmplRegex = /{{frontmatter:([^}]+)}}/gm;
|
||||||
|
var replaceDateVar = (s, date) => {
|
||||||
|
const m = dateTmplRegex.exec(s);
|
||||||
|
if (!m)
|
||||||
|
return s;
|
||||||
|
return s.replace(m[0], date.format(m[1]));
|
||||||
|
};
|
||||||
|
var replaceFrontmatterVar = (s, frontmatter) => {
|
||||||
|
if (!frontmatter)
|
||||||
|
return s;
|
||||||
|
const m = frontmatterTmplRegex.exec(s);
|
||||||
|
if (!m)
|
||||||
|
return s;
|
||||||
|
return s.replace(m[0], frontmatter[m[1]] || "");
|
||||||
|
};
|
||||||
|
var renderTemplate = (tmpl, data, frontmatter) => {
|
||||||
|
const now = window.moment();
|
||||||
|
let text = tmpl;
|
||||||
|
let newtext;
|
||||||
|
while ((newtext = replaceDateVar(text, now)) != text) {
|
||||||
|
text = newtext;
|
||||||
|
}
|
||||||
|
while ((newtext = replaceFrontmatterVar(text, frontmatter)) != text) {
|
||||||
|
text = newtext;
|
||||||
|
}
|
||||||
|
text = text.replace(/{{imageNameKey}}/gm, data.imageNameKey).replace(/{{fileName}}/gm, data.fileName).replace(/{{dirName}}/gm, data.dirName).replace(/{{firstHeading}}/gm, data.firstHeading);
|
||||||
|
return text;
|
||||||
|
};
|
||||||
|
|
||||||
|
// src/main.ts
|
||||||
|
var DEFAULT_SETTINGS = {
|
||||||
|
imageNamePattern: "{{fileName}}",
|
||||||
|
dupNumberAtStart: false,
|
||||||
|
dupNumberDelimiter: "-",
|
||||||
|
dupNumberAlways: false,
|
||||||
|
autoRename: false,
|
||||||
|
handleAllAttachments: false,
|
||||||
|
excludeExtensionPattern: "",
|
||||||
|
disableRenameNotice: false
|
||||||
|
};
|
||||||
|
var PASTED_IMAGE_PREFIX = "Pasted image ";
|
||||||
|
var PasteImageRenamePlugin = class extends import_obsidian2.Plugin {
|
||||||
|
constructor() {
|
||||||
|
super(...arguments);
|
||||||
|
this.modals = [];
|
||||||
|
}
|
||||||
|
onload() {
|
||||||
|
return __async(this, null, function* () {
|
||||||
|
const pkg = require_package();
|
||||||
|
console.log(`Plugin loading: ${pkg.name} ${pkg.version} BUILD_ENV=${"production"}`);
|
||||||
|
yield this.loadSettings();
|
||||||
|
this.registerEvent(
|
||||||
|
this.app.vault.on("create", (file) => {
|
||||||
|
if (!(file instanceof import_obsidian2.TFile))
|
||||||
|
return;
|
||||||
|
const timeGapMs = new Date().getTime() - file.stat.ctime;
|
||||||
|
if (timeGapMs > 1e3)
|
||||||
|
return;
|
||||||
|
if (isMarkdownFile(file))
|
||||||
|
return;
|
||||||
|
if (isPastedImage(file)) {
|
||||||
|
debugLog("pasted image created", file);
|
||||||
|
this.startRenameProcess(file, this.settings.autoRename);
|
||||||
|
} else {
|
||||||
|
if (this.settings.handleAllAttachments) {
|
||||||
|
debugLog("handleAllAttachments for file", file);
|
||||||
|
if (this.testExcludeExtension(file)) {
|
||||||
|
debugLog("excluded file by ext", file);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.startRenameProcess(file, this.settings.autoRename);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const startBatchRenameProcess = () => {
|
||||||
|
this.openBatchRenameModal();
|
||||||
|
};
|
||||||
|
this.addCommand({
|
||||||
|
id: "batch-rename-embeded-files",
|
||||||
|
name: "Batch rename embeded files (in the current file)",
|
||||||
|
callback: startBatchRenameProcess
|
||||||
|
});
|
||||||
|
if (DEBUG) {
|
||||||
|
this.addRibbonIcon("wand-glyph", "Batch rename embeded files", startBatchRenameProcess);
|
||||||
|
}
|
||||||
|
const batchRenameAllImages = () => {
|
||||||
|
this.batchRenameAllImages();
|
||||||
|
};
|
||||||
|
this.addCommand({
|
||||||
|
id: "batch-rename-all-images",
|
||||||
|
name: "Batch rename all images instantly (in the current file)",
|
||||||
|
callback: batchRenameAllImages
|
||||||
|
});
|
||||||
|
if (DEBUG) {
|
||||||
|
this.addRibbonIcon("wand-glyph", "Batch rename all images instantly (in the current file)", batchRenameAllImages);
|
||||||
|
}
|
||||||
|
this.addSettingTab(new SettingTab(this.app, this));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
startRenameProcess(file, autoRename = false) {
|
||||||
|
return __async(this, null, function* () {
|
||||||
|
const activeFile = this.getActiveFile();
|
||||||
|
if (!activeFile) {
|
||||||
|
new import_obsidian2.Notice("Error: No active file found.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { stem, newName, isMeaningful } = this.generateNewName(file, activeFile);
|
||||||
|
debugLog("generated newName:", newName, isMeaningful);
|
||||||
|
if (!isMeaningful || !autoRename) {
|
||||||
|
this.openRenameModal(file, isMeaningful ? stem : "", activeFile.path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.renameFile(file, newName, activeFile.path, true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
renameFile(file, inputNewName, sourcePath, replaceCurrentLine) {
|
||||||
|
return __async(this, null, function* () {
|
||||||
|
const { name: newName } = yield this.deduplicateNewName(inputNewName, file);
|
||||||
|
debugLog("deduplicated newName:", newName);
|
||||||
|
const originName = file.name;
|
||||||
|
const linkText = this.app.fileManager.generateMarkdownLink(file, sourcePath);
|
||||||
|
const newPath = path.join(file.parent.path, newName);
|
||||||
|
try {
|
||||||
|
yield this.app.fileManager.renameFile(file, newPath);
|
||||||
|
} catch (err) {
|
||||||
|
new import_obsidian2.Notice(`Failed to rename ${newName}: ${err}`);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
if (!replaceCurrentLine) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newLinkText = this.app.fileManager.generateMarkdownLink(file, sourcePath);
|
||||||
|
debugLog("replace text", linkText, newLinkText);
|
||||||
|
const editor = this.getActiveEditor();
|
||||||
|
if (!editor) {
|
||||||
|
new import_obsidian2.Notice(`Failed to rename ${newName}: no active editor`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cursor = editor.getCursor();
|
||||||
|
const line = editor.getLine(cursor.line);
|
||||||
|
const replacedLine = line.replace(linkText, newLinkText);
|
||||||
|
debugLog("current line -> replaced line", line, replacedLine);
|
||||||
|
editor.transaction({
|
||||||
|
changes: [
|
||||||
|
{
|
||||||
|
from: __spreadProps(__spreadValues({}, cursor), { ch: 0 }),
|
||||||
|
to: __spreadProps(__spreadValues({}, cursor), { ch: line.length }),
|
||||||
|
text: replacedLine
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
if (!this.settings.disableRenameNotice) {
|
||||||
|
new import_obsidian2.Notice(`Renamed ${originName} to ${newName}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
openRenameModal(file, newName, sourcePath) {
|
||||||
|
const modal = new ImageRenameModal(
|
||||||
|
this.app,
|
||||||
|
file,
|
||||||
|
newName,
|
||||||
|
(confirmedName) => {
|
||||||
|
debugLog("confirmedName:", confirmedName);
|
||||||
|
this.renameFile(file, confirmedName, sourcePath, true);
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
this.modals.splice(this.modals.indexOf(modal), 1);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
this.modals.push(modal);
|
||||||
|
modal.open();
|
||||||
|
debugLog("modals count", this.modals.length);
|
||||||
|
}
|
||||||
|
openBatchRenameModal() {
|
||||||
|
const activeFile = this.getActiveFile();
|
||||||
|
const modal = new ImageBatchRenameModal(
|
||||||
|
this.app,
|
||||||
|
activeFile,
|
||||||
|
(file, name) => __async(this, null, function* () {
|
||||||
|
yield this.renameFile(file, name, activeFile.path);
|
||||||
|
}),
|
||||||
|
() => {
|
||||||
|
this.modals.splice(this.modals.indexOf(modal), 1);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
this.modals.push(modal);
|
||||||
|
modal.open();
|
||||||
|
}
|
||||||
|
batchRenameAllImages() {
|
||||||
|
return __async(this, null, function* () {
|
||||||
|
const activeFile = this.getActiveFile();
|
||||||
|
const fileCache = this.app.metadataCache.getFileCache(activeFile);
|
||||||
|
if (!fileCache || !fileCache.embeds)
|
||||||
|
return;
|
||||||
|
const extPatternRegex = /jpe?g|png|gif|tiff|webp/i;
|
||||||
|
for (const embed of fileCache.embeds) {
|
||||||
|
const file = this.app.metadataCache.getFirstLinkpathDest(embed.link, activeFile.path);
|
||||||
|
if (!file) {
|
||||||
|
console.warn("file not found", embed.link);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const m0 = extPatternRegex.exec(file.extension);
|
||||||
|
if (!m0)
|
||||||
|
return;
|
||||||
|
const { newName, isMeaningful } = this.generateNewName(file, activeFile);
|
||||||
|
debugLog("generated newName:", newName, isMeaningful);
|
||||||
|
if (!isMeaningful) {
|
||||||
|
new import_obsidian2.Notice("Failed to batch rename images: the generated name is not meaningful");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
yield this.renameFile(file, newName, activeFile.path, false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// returns a new name for the input file, with extension
|
||||||
|
generateNewName(file, activeFile) {
|
||||||
|
let imageNameKey = "";
|
||||||
|
let firstHeading = "";
|
||||||
|
let frontmatter;
|
||||||
|
const fileCache = this.app.metadataCache.getFileCache(activeFile);
|
||||||
|
if (fileCache) {
|
||||||
|
debugLog("frontmatter", fileCache.frontmatter);
|
||||||
|
frontmatter = fileCache.frontmatter;
|
||||||
|
imageNameKey = (frontmatter == null ? void 0 : frontmatter.imageNameKey) || "";
|
||||||
|
firstHeading = getFirstHeading(fileCache.headings);
|
||||||
|
} else {
|
||||||
|
console.warn("could not get file cache from active file", activeFile.name);
|
||||||
|
}
|
||||||
|
const stem = renderTemplate(
|
||||||
|
this.settings.imageNamePattern,
|
||||||
|
{
|
||||||
|
imageNameKey,
|
||||||
|
fileName: activeFile.basename,
|
||||||
|
dirName: activeFile.parent.name,
|
||||||
|
firstHeading
|
||||||
|
},
|
||||||
|
frontmatter
|
||||||
|
);
|
||||||
|
const meaninglessRegex = new RegExp(`[${this.settings.dupNumberDelimiter}\\s]`, "gm");
|
||||||
|
return {
|
||||||
|
stem,
|
||||||
|
newName: stem + "." + file.extension,
|
||||||
|
isMeaningful: stem.replace(meaninglessRegex, "") !== ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// newName: foo.ext
|
||||||
|
deduplicateNewName(newName, file) {
|
||||||
|
return __async(this, null, function* () {
|
||||||
|
const dir = file.parent.path;
|
||||||
|
const listed = yield this.app.vault.adapter.list(dir);
|
||||||
|
debugLog("sibling files", listed);
|
||||||
|
const newNameExt = path.extension(newName), newNameStem = newName.slice(0, newName.length - newNameExt.length - 1), newNameStemEscaped = escapeRegExp(newNameStem), delimiter = this.settings.dupNumberDelimiter, delimiterEscaped = escapeRegExp(delimiter);
|
||||||
|
let dupNameRegex;
|
||||||
|
if (this.settings.dupNumberAtStart) {
|
||||||
|
dupNameRegex = new RegExp(
|
||||||
|
`^(?<number>\\d+)${delimiterEscaped}(?<name>${newNameStemEscaped})\\.${newNameExt}$`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
dupNameRegex = new RegExp(
|
||||||
|
`^(?<name>${newNameStemEscaped})${delimiterEscaped}(?<number>\\d+)\\.${newNameExt}$`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
debugLog("dupNameRegex", dupNameRegex);
|
||||||
|
const dupNameNumbers = [];
|
||||||
|
let isNewNameExist = false;
|
||||||
|
for (let sibling of listed.files) {
|
||||||
|
sibling = path.basename(sibling);
|
||||||
|
if (sibling == newName) {
|
||||||
|
isNewNameExist = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const m = dupNameRegex.exec(sibling);
|
||||||
|
if (!m)
|
||||||
|
continue;
|
||||||
|
dupNameNumbers.push(parseInt(m.groups.number));
|
||||||
|
}
|
||||||
|
if (isNewNameExist || this.settings.dupNumberAlways) {
|
||||||
|
const newNumber = dupNameNumbers.length > 0 ? Math.max(...dupNameNumbers) + 1 : 1;
|
||||||
|
if (this.settings.dupNumberAtStart) {
|
||||||
|
newName = `${newNumber}${delimiter}${newNameStem}.${newNameExt}`;
|
||||||
|
} else {
|
||||||
|
newName = `${newNameStem}${delimiter}${newNumber}.${newNameExt}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name: newName,
|
||||||
|
stem: newName.slice(0, newName.length - newNameExt.length - 1),
|
||||||
|
extension: newNameExt
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
getActiveFile() {
|
||||||
|
const view = this.app.workspace.getActiveViewOfType(import_obsidian2.MarkdownView);
|
||||||
|
const file = view == null ? void 0 : view.file;
|
||||||
|
debugLog("active file", file == null ? void 0 : file.path);
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
getActiveEditor() {
|
||||||
|
const view = this.app.workspace.getActiveViewOfType(import_obsidian2.MarkdownView);
|
||||||
|
return view == null ? void 0 : view.editor;
|
||||||
|
}
|
||||||
|
onunload() {
|
||||||
|
this.modals.map((modal) => modal.close());
|
||||||
|
}
|
||||||
|
testExcludeExtension(file) {
|
||||||
|
const pattern = this.settings.excludeExtensionPattern;
|
||||||
|
if (!pattern)
|
||||||
|
return false;
|
||||||
|
return new RegExp(pattern).test(file.extension);
|
||||||
|
}
|
||||||
|
loadSettings() {
|
||||||
|
return __async(this, null, function* () {
|
||||||
|
this.settings = Object.assign({}, DEFAULT_SETTINGS, yield this.loadData());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
saveSettings() {
|
||||||
|
return __async(this, null, function* () {
|
||||||
|
yield this.saveData(this.settings);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
function getFirstHeading(headings) {
|
||||||
|
if (headings && headings.length > 0) {
|
||||||
|
for (const heading of headings) {
|
||||||
|
if (heading.level === 1) {
|
||||||
|
return heading.heading;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
function isPastedImage(file) {
|
||||||
|
if (file instanceof import_obsidian2.TFile) {
|
||||||
|
if (file.name.startsWith(PASTED_IMAGE_PREFIX)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
function isMarkdownFile(file) {
|
||||||
|
if (file instanceof import_obsidian2.TFile) {
|
||||||
|
if (file.extension === "md") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
var ImageRenameModal = class extends import_obsidian2.Modal {
|
||||||
|
constructor(app, src, stem, renameFunc, onClose) {
|
||||||
|
super(app);
|
||||||
|
this.src = src;
|
||||||
|
this.stem = stem;
|
||||||
|
this.renameFunc = renameFunc;
|
||||||
|
this.onCloseExtra = onClose;
|
||||||
|
}
|
||||||
|
onOpen() {
|
||||||
|
this.containerEl.addClass("image-rename-modal");
|
||||||
|
const { contentEl, titleEl } = this;
|
||||||
|
titleEl.setText("Rename image");
|
||||||
|
const imageContainer = contentEl.createDiv({
|
||||||
|
cls: "image-container"
|
||||||
|
});
|
||||||
|
imageContainer.createEl("img", {
|
||||||
|
attr: {
|
||||||
|
src: this.app.vault.getResourcePath(this.src)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let stem = this.stem;
|
||||||
|
const ext = this.src.extension;
|
||||||
|
const getNewName = (stem2) => stem2 + "." + ext;
|
||||||
|
const getNewPath = (stem2) => path.join(this.src.parent.path, getNewName(stem2));
|
||||||
|
const infoET = createElementTree(contentEl, {
|
||||||
|
tag: "ul",
|
||||||
|
cls: "info",
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
tag: "li",
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
tag: "span",
|
||||||
|
text: "Origin path"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: "span",
|
||||||
|
text: this.src.path
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: "li",
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
tag: "span",
|
||||||
|
text: "New path"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: "span",
|
||||||
|
text: getNewPath(stem)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
const doRename = () => __async(this, null, function* () {
|
||||||
|
debugLog("doRename", `stem=${stem}`);
|
||||||
|
this.renameFunc(getNewName(stem));
|
||||||
|
});
|
||||||
|
const nameSetting = new import_obsidian2.Setting(contentEl).setName("New name").setDesc("Please input the new name for the image (without extension)").addText((text) => text.setValue(stem).onChange(
|
||||||
|
(value) => __async(this, null, function* () {
|
||||||
|
stem = sanitizer.filename(value);
|
||||||
|
infoET.children[1].children[1].el.innerText = getNewPath(stem);
|
||||||
|
})
|
||||||
|
));
|
||||||
|
const nameInputEl = nameSetting.controlEl.children[0];
|
||||||
|
nameInputEl.focus();
|
||||||
|
const nameInputState = lockInputMethodComposition(nameInputEl);
|
||||||
|
nameInputEl.addEventListener("keydown", (e) => __async(this, null, function* () {
|
||||||
|
if (e.key === "Enter" && !nameInputState.lock) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!stem) {
|
||||||
|
errorEl.innerText = 'Error: "New name" could not be empty';
|
||||||
|
errorEl.style.display = "block";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
doRename();
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
const errorEl = contentEl.createDiv({
|
||||||
|
cls: "error",
|
||||||
|
attr: {
|
||||||
|
style: "display: none;"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
new import_obsidian2.Setting(contentEl).addButton((button) => {
|
||||||
|
button.setButtonText("Rename").onClick(() => {
|
||||||
|
doRename();
|
||||||
|
this.close();
|
||||||
|
});
|
||||||
|
}).addButton((button) => {
|
||||||
|
button.setButtonText("Cancel").onClick(() => {
|
||||||
|
this.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
onClose() {
|
||||||
|
const { contentEl } = this;
|
||||||
|
contentEl.empty();
|
||||||
|
this.onCloseExtra();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
var imageNamePatternDesc = `
|
||||||
|
The pattern indicates how the new name should be generated.
|
||||||
|
|
||||||
|
Available variables:
|
||||||
|
- {{fileName}}: name of the active file, without ".md" extension.
|
||||||
|
- {{imageNameKey}}: this variable is read from the markdown file's frontmatter, from the same key "imageNameKey".
|
||||||
|
- {{DATE:$FORMAT}}: use "$FORMAT" to format the current date, "$FORMAT" must be a Moment.js format string, e.g. {{DATE:YYYY-MM-DD}}.
|
||||||
|
|
||||||
|
Here are some examples from pattern to image names (repeat in sequence), variables: fileName = "My note", imageNameKey = "foo":
|
||||||
|
- {{fileName}}: My note, My note-1, My note-2
|
||||||
|
- {{imageNameKey}}: foo, foo-1, foo-2
|
||||||
|
- {{imageNameKey}}-{{DATE:YYYYMMDD}}: foo-20220408, foo-20220408-1, foo-20220408-2
|
||||||
|
`;
|
||||||
|
var SettingTab = class extends import_obsidian2.PluginSettingTab {
|
||||||
|
constructor(app, plugin) {
|
||||||
|
super(app, plugin);
|
||||||
|
this.plugin = plugin;
|
||||||
|
}
|
||||||
|
display() {
|
||||||
|
const { containerEl } = this;
|
||||||
|
containerEl.empty();
|
||||||
|
new import_obsidian2.Setting(containerEl).setName("Image name pattern").setDesc(imageNamePatternDesc).setClass("long-description-setting-item").addText((text) => text.setPlaceholder("{{imageNameKey}}").setValue(this.plugin.settings.imageNamePattern).onChange(
|
||||||
|
(value) => __async(this, null, function* () {
|
||||||
|
this.plugin.settings.imageNamePattern = value;
|
||||||
|
yield this.plugin.saveSettings();
|
||||||
|
})
|
||||||
|
));
|
||||||
|
new import_obsidian2.Setting(containerEl).setName("Duplicate number at start (or end)").setDesc(`If enabled, duplicate number will be added at the start as prefix for the image name, otherwise it will be added at the end as suffix for the image name.`).addToggle((toggle) => toggle.setValue(this.plugin.settings.dupNumberAtStart).onChange(
|
||||||
|
(value) => __async(this, null, function* () {
|
||||||
|
this.plugin.settings.dupNumberAtStart = value;
|
||||||
|
yield this.plugin.saveSettings();
|
||||||
|
})
|
||||||
|
));
|
||||||
|
new import_obsidian2.Setting(containerEl).setName("Duplicate number delimiter").setDesc(`The delimiter to generate the number prefix/suffix for duplicated names. For example, if the value is "-", the suffix will be like "-1", "-2", "-3", and the prefix will be like "1-", "2-", "3-". Only characters that are valid in file names are allowed.`).addText((text) => text.setValue(this.plugin.settings.dupNumberDelimiter).onChange(
|
||||||
|
(value) => __async(this, null, function* () {
|
||||||
|
this.plugin.settings.dupNumberDelimiter = sanitizer.delimiter(value);
|
||||||
|
yield this.plugin.saveSettings();
|
||||||
|
})
|
||||||
|
));
|
||||||
|
new import_obsidian2.Setting(containerEl).setName("Always add duplicate number").setDesc(`If enabled, duplicate number will always be added to the image name. Otherwise, it will only be added when the name is duplicated.`).addToggle((toggle) => toggle.setValue(this.plugin.settings.dupNumberAlways).onChange(
|
||||||
|
(value) => __async(this, null, function* () {
|
||||||
|
this.plugin.settings.dupNumberAlways = value;
|
||||||
|
yield this.plugin.saveSettings();
|
||||||
|
})
|
||||||
|
));
|
||||||
|
new import_obsidian2.Setting(containerEl).setName("Auto rename").setDesc(`By default, the rename modal will always be shown to confirm before renaming, if this option is set, the image will be auto renamed after pasting.`).addToggle((toggle) => toggle.setValue(this.plugin.settings.autoRename).onChange(
|
||||||
|
(value) => __async(this, null, function* () {
|
||||||
|
this.plugin.settings.autoRename = value;
|
||||||
|
yield this.plugin.saveSettings();
|
||||||
|
})
|
||||||
|
));
|
||||||
|
new import_obsidian2.Setting(containerEl).setName("Handle all attachments").setDesc(`By default, the plugin only handles images that starts with "Pasted image " in name,
|
||||||
|
which is the prefix Obsidian uses to create images from pasted content.
|
||||||
|
If this option is set, the plugin will handle all attachments that are created in the vault.`).addToggle((toggle) => toggle.setValue(this.plugin.settings.handleAllAttachments).onChange(
|
||||||
|
(value) => __async(this, null, function* () {
|
||||||
|
this.plugin.settings.handleAllAttachments = value;
|
||||||
|
yield this.plugin.saveSettings();
|
||||||
|
})
|
||||||
|
));
|
||||||
|
new import_obsidian2.Setting(containerEl).setName("Exclude extension pattern").setDesc(`This option is only useful when "Handle all attachments" is enabled.
|
||||||
|
Write a Regex pattern to exclude certain extensions from being handled. Only the first line will be used.`).setClass("single-line-textarea").addTextArea((text) => text.setPlaceholder("docx?|xlsx?|pptx?|zip|rar").setValue(this.plugin.settings.excludeExtensionPattern).onChange(
|
||||||
|
(value) => __async(this, null, function* () {
|
||||||
|
this.plugin.settings.excludeExtensionPattern = value;
|
||||||
|
yield this.plugin.saveSettings();
|
||||||
|
})
|
||||||
|
));
|
||||||
|
new import_obsidian2.Setting(containerEl).setName("Disable rename notice").setDesc(`Turn off this option if you don't want to see the notice when renaming images.
|
||||||
|
Note that Obsidian may display a notice when a link has changed, this option cannot disable that.`).addToggle((toggle) => toggle.setValue(this.plugin.settings.disableRenameNotice).onChange(
|
||||||
|
(value) => __async(this, null, function* () {
|
||||||
|
this.plugin.settings.disableRenameNotice = value;
|
||||||
|
yield this.plugin.saveSettings();
|
||||||
|
})
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/* nosourcemap */
|
||||||
10
src/content/.obsidian/plugins/obsidian-paste-image-rename/manifest.json
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"id": "obsidian-paste-image-rename",
|
||||||
|
"name": "Paste image rename",
|
||||||
|
"version": "1.6.1",
|
||||||
|
"minAppVersion": "0.12.0",
|
||||||
|
"description": "Rename pasted images and all the other attchments added to the vault",
|
||||||
|
"author": "Reorx",
|
||||||
|
"authorUrl": "https://github.com/reorx",
|
||||||
|
"isDesktopOnly": false
|
||||||
|
}
|
||||||
79
src/content/.obsidian/plugins/obsidian-paste-image-rename/styles.css
vendored
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
/* src/styles.css */
|
||||||
|
:root {
|
||||||
|
--shadow-color: 0deg 0% 0%;
|
||||||
|
--shadow-elevation-medium:
|
||||||
|
0.5px 0.5px 0.7px hsl(var(--shadow-color) / 0.14),
|
||||||
|
1.1px 1.1px 1.5px -0.9px hsl(var(--shadow-color) / 0.12),
|
||||||
|
2.4px 2.5px 3.3px -1.8px hsl(var(--shadow-color) / 0.1),
|
||||||
|
5.3px 5.6px 7.3px -2.7px hsl(var(--shadow-color) / 0.09),
|
||||||
|
11px 11.4px 15.1px -3.6px hsl(var(--shadow-color) / 0.07);
|
||||||
|
}
|
||||||
|
.image-rename-modal .modal {
|
||||||
|
width: 65%;
|
||||||
|
min-width: 600px;
|
||||||
|
}
|
||||||
|
.image-rename-modal .modal-content {
|
||||||
|
padding: 10px 5px;
|
||||||
|
}
|
||||||
|
.image-rename-modal .image-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.image-rename-modal .info {
|
||||||
|
padding: 10px 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
user-select: text;
|
||||||
|
}
|
||||||
|
.image-rename-modal .info li > span:nth-of-type(1) {
|
||||||
|
display: inline-block;
|
||||||
|
width: 6em;
|
||||||
|
margin-right: .5em;
|
||||||
|
}
|
||||||
|
.image-rename-modal .info li > span:nth-of-type(1):after {
|
||||||
|
content: ":";
|
||||||
|
float: right;
|
||||||
|
}
|
||||||
|
.image-rename-modal .image-container img {
|
||||||
|
display: block;
|
||||||
|
max-height: 300px;
|
||||||
|
box-shadow: var(--shadow-elevation-medium);
|
||||||
|
}
|
||||||
|
.image-rename-modal .setting-item-control input {
|
||||||
|
min-width: 300px;
|
||||||
|
}
|
||||||
|
.image-rename-modal .error {
|
||||||
|
border: 1px solid rgb(201, 90, 90);
|
||||||
|
color: rgb(134, 22, 22);
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
.image-rename-modal table {
|
||||||
|
font-size: .9em;
|
||||||
|
line-height: 1.8;
|
||||||
|
margin-bottom: 1.5em;
|
||||||
|
user-select: text;
|
||||||
|
}
|
||||||
|
.image-rename-modal table td {
|
||||||
|
padding-right: 1em;
|
||||||
|
}
|
||||||
|
.image-rename-modal table thead td {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.image-rename-modal table tbody td .file-path {
|
||||||
|
font-size: .8em;
|
||||||
|
color: var(--text-faint);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.long-description-setting-item {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.long-description-setting-item .setting-item-description {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
line-height: 1.3em;
|
||||||
|
}
|
||||||
|
.long-description-setting-item .setting-item-control {
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
.long-description-setting-item .setting-item-control input {
|
||||||
|
min-width: 300px;
|
||||||
|
width: 50%;
|
||||||
|
}
|
||||||
181
src/content/.obsidian/workspace-mobile.json
vendored
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
{
|
||||||
|
"main": {
|
||||||
|
"id": "11c40c0eff369230",
|
||||||
|
"type": "split",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "a3b78448b99fd05e",
|
||||||
|
"type": "tabs",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "a48cfb809f321ac1",
|
||||||
|
"type": "leaf",
|
||||||
|
"state": {
|
||||||
|
"type": "markdown",
|
||||||
|
"state": {
|
||||||
|
"file": "posts/mobile-git.md",
|
||||||
|
"mode": "source",
|
||||||
|
"source": false
|
||||||
|
},
|
||||||
|
"icon": "lucide-file",
|
||||||
|
"title": "mobile-git"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"direction": "vertical"
|
||||||
|
},
|
||||||
|
"left": {
|
||||||
|
"id": "084d99fc7dfae6d5",
|
||||||
|
"type": "mobile-drawer",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "913290ce74f3c790",
|
||||||
|
"type": "leaf",
|
||||||
|
"state": {
|
||||||
|
"type": "file-explorer",
|
||||||
|
"state": {
|
||||||
|
"sortOrder": "alphabetical",
|
||||||
|
"autoReveal": false
|
||||||
|
},
|
||||||
|
"icon": "lucide-folder-closed",
|
||||||
|
"title": "文件列表"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "715c6bf7efb3af34",
|
||||||
|
"type": "leaf",
|
||||||
|
"state": {
|
||||||
|
"type": "search",
|
||||||
|
"state": {
|
||||||
|
"query": "",
|
||||||
|
"matchingCase": false,
|
||||||
|
"explainSearch": false,
|
||||||
|
"collapseAll": false,
|
||||||
|
"extraContext": false,
|
||||||
|
"sortOrder": "alphabetical"
|
||||||
|
},
|
||||||
|
"icon": "lucide-search",
|
||||||
|
"title": "搜索"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "1867f88e0e5be91e",
|
||||||
|
"type": "leaf",
|
||||||
|
"state": {
|
||||||
|
"type": "tag",
|
||||||
|
"state": {
|
||||||
|
"sortOrder": "frequency",
|
||||||
|
"useHierarchy": true,
|
||||||
|
"showSearch": false,
|
||||||
|
"searchQuery": ""
|
||||||
|
},
|
||||||
|
"icon": "lucide-tags",
|
||||||
|
"title": "标签"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "c81497cc77ac87dc",
|
||||||
|
"type": "leaf",
|
||||||
|
"state": {
|
||||||
|
"type": "bookmarks",
|
||||||
|
"state": {},
|
||||||
|
"icon": "lucide-bookmark",
|
||||||
|
"title": "书签"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"currentTab": 0
|
||||||
|
},
|
||||||
|
"right": {
|
||||||
|
"id": "00f2d94402e22f34",
|
||||||
|
"type": "mobile-drawer",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "c18847a8fc329542",
|
||||||
|
"type": "leaf",
|
||||||
|
"state": {
|
||||||
|
"type": "backlink",
|
||||||
|
"state": {
|
||||||
|
"collapseAll": false,
|
||||||
|
"extraContext": false,
|
||||||
|
"sortOrder": "alphabetical",
|
||||||
|
"showSearch": false,
|
||||||
|
"searchQuery": "",
|
||||||
|
"backlinkCollapsed": false,
|
||||||
|
"unlinkedCollapsed": true
|
||||||
|
},
|
||||||
|
"icon": "links-coming-in",
|
||||||
|
"title": "反向链接"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "a0983a2e5ea8f256",
|
||||||
|
"type": "leaf",
|
||||||
|
"state": {
|
||||||
|
"type": "outgoing-link",
|
||||||
|
"state": {
|
||||||
|
"linksCollapsed": false,
|
||||||
|
"unlinkedCollapsed": true
|
||||||
|
},
|
||||||
|
"icon": "links-going-out",
|
||||||
|
"title": "出链"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "289ab02c28f6d4b5",
|
||||||
|
"type": "leaf",
|
||||||
|
"state": {
|
||||||
|
"type": "outline",
|
||||||
|
"state": {
|
||||||
|
"followCursor": false,
|
||||||
|
"showSearch": false,
|
||||||
|
"searchQuery": ""
|
||||||
|
},
|
||||||
|
"icon": "lucide-list",
|
||||||
|
"title": "大纲"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"currentTab": 0
|
||||||
|
},
|
||||||
|
"left-ribbon": {
|
||||||
|
"hiddenItems": {
|
||||||
|
"switcher:打开快速切换": false,
|
||||||
|
"graph:查看关系图谱": false,
|
||||||
|
"canvas:新建白板": false,
|
||||||
|
"daily-notes:打开/创建今天的日记": false,
|
||||||
|
"templates:插入模板": false,
|
||||||
|
"command-palette:打开命令面板": false,
|
||||||
|
"bases:创建新数据库": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"active": "a48cfb809f321ac1",
|
||||||
|
"lastOpenFiles": [
|
||||||
|
"posts/163-free-domain-email.md",
|
||||||
|
"posts/acg-randompic-api.md",
|
||||||
|
"posts/mobile-git.md",
|
||||||
|
"posts/warden-worker.md",
|
||||||
|
"posts/record.md",
|
||||||
|
"assets/images/Screenshot_2025-11-24-07-56-33-62_a2e3670364a4153bdb03dad30c8d4108.jpg",
|
||||||
|
"assets/images/Screenshot_2025-11-24-07-56-23-48_a2e3670364a4153bdb03dad30c8d4108 1.jpg",
|
||||||
|
"assets/images/Screenshot_2025-11-24-07-55-54-35_df198e732186825c8df26e3c5a10d7cd 1.jpg",
|
||||||
|
"assets/images/Screenshot_2025-11-24-07-56-23-48_a2e3670364a4153bdb03dad30c8d4108.jpg",
|
||||||
|
"assets/images/Screenshot_2025-11-24-07-55-54-35_df198e732186825c8df26e3c5a10d7cd.jpg",
|
||||||
|
"posts/wx-zfb-card.md",
|
||||||
|
"assets/images/Screenshot_2025-11-11-14-18-53-34_51606159b24eff83e24a54116878fe3e.jpg",
|
||||||
|
"assets/images/Screenshot_2025-11-11-14-17-32-08_51606159b24eff83e24a54116878fe3e.jpg",
|
||||||
|
"assets/images/Screenshot_2025-11-11-14-15-59-46_51606159b24eff83e24a54116878fe3e.jpg",
|
||||||
|
"assets/images/Screenshot_2025-11-11-14-15-01-63_b5a5c5cb02ca09c784c5d88160e2ec24.jpg",
|
||||||
|
"assets/images/Screenshot_2025-11-11-14-13-03-99_a2e3670364a4153bdb03dad30c8d4108.jpg",
|
||||||
|
"posts/check-notebook.md",
|
||||||
|
"未命名.md",
|
||||||
|
"posts/zte-f450-bridge.md",
|
||||||
|
"posts/unknown-upload.md",
|
||||||
|
"posts/rvc.md",
|
||||||
|
"posts/index-tts2.md",
|
||||||
|
"posts/hook-steam-drm.md",
|
||||||
|
"posts/first-pc.md"
|
||||||
|
]
|
||||||
|
}
|
||||||
211
src/content/.obsidian/workspace.json
vendored
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
{
|
||||||
|
"main": {
|
||||||
|
"id": "c6989ce27d23c45d",
|
||||||
|
"type": "split",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "7f06a11af95175a9",
|
||||||
|
"type": "tabs",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "96b79d1156396696",
|
||||||
|
"type": "leaf",
|
||||||
|
"state": {
|
||||||
|
"type": "markdown",
|
||||||
|
"state": {
|
||||||
|
"file": "posts/random-url-gen.md",
|
||||||
|
"mode": "source",
|
||||||
|
"source": false
|
||||||
|
},
|
||||||
|
"icon": "lucide-file",
|
||||||
|
"title": "random-url-gen"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"direction": "vertical"
|
||||||
|
},
|
||||||
|
"left": {
|
||||||
|
"id": "adad91cfb262dcee",
|
||||||
|
"type": "split",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "03f1023b2d857cdc",
|
||||||
|
"type": "tabs",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "7be4ba729d2aae5a",
|
||||||
|
"type": "leaf",
|
||||||
|
"state": {
|
||||||
|
"type": "file-explorer",
|
||||||
|
"state": {
|
||||||
|
"sortOrder": "alphabetical",
|
||||||
|
"autoReveal": false
|
||||||
|
},
|
||||||
|
"icon": "lucide-folder-closed",
|
||||||
|
"title": "文件列表"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "5174a9cd87b0f8f9",
|
||||||
|
"type": "leaf",
|
||||||
|
"state": {
|
||||||
|
"type": "search",
|
||||||
|
"state": {
|
||||||
|
"query": "",
|
||||||
|
"matchingCase": false,
|
||||||
|
"explainSearch": false,
|
||||||
|
"collapseAll": false,
|
||||||
|
"extraContext": false,
|
||||||
|
"sortOrder": "alphabetical"
|
||||||
|
},
|
||||||
|
"icon": "lucide-search",
|
||||||
|
"title": "搜索"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "f33d418216a86348",
|
||||||
|
"type": "leaf",
|
||||||
|
"state": {
|
||||||
|
"type": "bookmarks",
|
||||||
|
"state": {},
|
||||||
|
"icon": "lucide-bookmark",
|
||||||
|
"title": "书签"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"direction": "horizontal",
|
||||||
|
"width": 300
|
||||||
|
},
|
||||||
|
"right": {
|
||||||
|
"id": "f6608acce08382a1",
|
||||||
|
"type": "split",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "c4b67f4636c6ac01",
|
||||||
|
"type": "tabs",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "a6b54f415fc99d0e",
|
||||||
|
"type": "leaf",
|
||||||
|
"state": {
|
||||||
|
"type": "backlink",
|
||||||
|
"state": {
|
||||||
|
"file": "posts/obsidian.md",
|
||||||
|
"collapseAll": false,
|
||||||
|
"extraContext": false,
|
||||||
|
"sortOrder": "alphabetical",
|
||||||
|
"showSearch": false,
|
||||||
|
"searchQuery": "",
|
||||||
|
"backlinkCollapsed": false,
|
||||||
|
"unlinkedCollapsed": true
|
||||||
|
},
|
||||||
|
"icon": "links-coming-in",
|
||||||
|
"title": "obsidian 的反向链接列表"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "630fd596ceed61e1",
|
||||||
|
"type": "leaf",
|
||||||
|
"state": {
|
||||||
|
"type": "outgoing-link",
|
||||||
|
"state": {
|
||||||
|
"file": "posts/obsidian.md",
|
||||||
|
"linksCollapsed": false,
|
||||||
|
"unlinkedCollapsed": true
|
||||||
|
},
|
||||||
|
"icon": "links-going-out",
|
||||||
|
"title": "obsidian 的出链列表"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "52896168f27b3092",
|
||||||
|
"type": "leaf",
|
||||||
|
"state": {
|
||||||
|
"type": "tag",
|
||||||
|
"state": {
|
||||||
|
"sortOrder": "frequency",
|
||||||
|
"useHierarchy": true,
|
||||||
|
"showSearch": false,
|
||||||
|
"searchQuery": ""
|
||||||
|
},
|
||||||
|
"icon": "lucide-tags",
|
||||||
|
"title": "标签"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "6f2f79f39b3c9de9",
|
||||||
|
"type": "leaf",
|
||||||
|
"state": {
|
||||||
|
"type": "outline",
|
||||||
|
"state": {
|
||||||
|
"file": "posts/obsidian.md",
|
||||||
|
"followCursor": false,
|
||||||
|
"showSearch": false,
|
||||||
|
"searchQuery": ""
|
||||||
|
},
|
||||||
|
"icon": "lucide-list",
|
||||||
|
"title": "obsidian 的大纲"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"direction": "horizontal",
|
||||||
|
"width": 300,
|
||||||
|
"collapsed": true
|
||||||
|
},
|
||||||
|
"left-ribbon": {
|
||||||
|
"hiddenItems": {
|
||||||
|
"switcher:打开快速切换": false,
|
||||||
|
"graph:查看关系图谱": false,
|
||||||
|
"canvas:新建白板": false,
|
||||||
|
"daily-notes:打开/创建今天的日记": false,
|
||||||
|
"templates:插入模板": false,
|
||||||
|
"command-palette:打开命令面板": false,
|
||||||
|
"bases:创建新数据库": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"active": "96b79d1156396696",
|
||||||
|
"lastOpenFiles": [
|
||||||
|
"assets/images/random-url-gen-11.png",
|
||||||
|
"assets/images/random-url-gen-10.png",
|
||||||
|
"assets/images/random-url-gen-9.png",
|
||||||
|
"assets/images/random-url-gen-8.png",
|
||||||
|
"assets/images/random-url-gen-7.png",
|
||||||
|
"assets/images/random-url-gen-6.png",
|
||||||
|
"assets/images/random-url-gen-5.png",
|
||||||
|
"assets/images/random-url-gen-4.png",
|
||||||
|
"assets/images/random-url-gen-3.png",
|
||||||
|
"assets/images/random-url-gen-2.png",
|
||||||
|
"assets/images/random-url-gen-1.png",
|
||||||
|
"posts/eo-umami.md",
|
||||||
|
"posts/fuwari.md",
|
||||||
|
"posts/ddos-6t.md",
|
||||||
|
"posts/py-uploadserver.md",
|
||||||
|
"posts/umami-migration.md",
|
||||||
|
"posts/serverless-function.md",
|
||||||
|
"posts/pin.md",
|
||||||
|
"posts/anuneko.md",
|
||||||
|
"posts/win11-to-win10.md",
|
||||||
|
"posts/remote.md",
|
||||||
|
"posts/warden-worker.md",
|
||||||
|
"posts/expressive-code.md",
|
||||||
|
"posts/lskypro-local.md",
|
||||||
|
"posts/ms-e3.md",
|
||||||
|
"posts/onedrive-index.md",
|
||||||
|
"posts/why-not-icp.md",
|
||||||
|
"posts/ipfs.md",
|
||||||
|
"posts/swup-js.md",
|
||||||
|
"posts/static-view.md",
|
||||||
|
"posts/oci.md",
|
||||||
|
"posts/wx-zfb-card.md",
|
||||||
|
"posts/mobile-git.md",
|
||||||
|
"posts/check-notebook.md",
|
||||||
|
"posts/unknown-upload.md",
|
||||||
|
"posts/zte-f450-bridge.md"
|
||||||
|
]
|
||||||
|
}
|
||||||
38
src/content/config.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { defineCollection, z } from "astro:content";
|
||||||
|
|
||||||
|
const postsCollection = defineCollection({
|
||||||
|
schema: z.object({
|
||||||
|
title: z.string(),
|
||||||
|
published: z.date(),
|
||||||
|
updated: z.date().optional(),
|
||||||
|
draft: z.boolean().optional().default(false),
|
||||||
|
description: z.string().optional().default(""),
|
||||||
|
image: z.string().optional().default(""),
|
||||||
|
tags: z.array(z.string()).optional().default([]),
|
||||||
|
category: z.union([
|
||||||
|
z.string(),
|
||||||
|
z.array(z.string()),
|
||||||
|
]).optional(),
|
||||||
|
lang: z.string().optional().default(""),
|
||||||
|
pinned: z.boolean().optional().default(false),
|
||||||
|
|
||||||
|
/* For internal use */
|
||||||
|
prevTitle: z.string().default(""),
|
||||||
|
prevSlug: z.string().default(""),
|
||||||
|
nextTitle: z.string().default(""),
|
||||||
|
nextSlug: z.string().default(""),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const assetsCollection = defineCollection({
|
||||||
|
type: 'data',
|
||||||
|
schema: z.object({
|
||||||
|
title: z.string().optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const collections = {
|
||||||
|
posts: postsCollection,
|
||||||
|
assets: assetsCollection,
|
||||||
|
};
|
||||||
1969
src/content/posts/Golang/Gin框架快速入门.md
Normal file
326
src/content/posts/Golang/Go_map底层结构.md
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
---
|
||||||
|
title: Go_map底层结构
|
||||||
|
published: 2025-07-19
|
||||||
|
description: ''
|
||||||
|
image: 'https://blog.meowrain.cn/api/i/2025/07/19/uje4vo-1.webp'
|
||||||
|
tags: [切片, Golang, Go]
|
||||||
|
category: 'Go'
|
||||||
|
draft: false
|
||||||
|
lang: ''
|
||||||
|
---
|
||||||
|
# Golang map底层数据结构
|
||||||
|
|
||||||
|
<https://golang.design/go-questions/map/principal/>
|
||||||
|
|
||||||
|
[Golang map 实现原理](https://mp.weixin.qq.com/s?__biz=MzkxMjQzMjA0OQ==&mid=2247483868&idx=1&sn=6e954af8e5e98ec0a9d9fc5c8ceb9072&chksm=c10c4f02f67bc614ff40a152a848508aa1631008eb5a600006c7552915d187179c08d4adf8d7&scene=0&xtrack=1&subscene=90#rd)
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
map是一种常用的数据结构,核心特征包括下面三点:
|
||||||
|
|
||||||
|
- 存储基于key-value对映射的模式
|
||||||
|
- 基于key维度实现存储数据的去重
|
||||||
|
- 读,写,删操作控制,时间复杂度O(1)
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 初始化方法
|
||||||
|
|
||||||
|
```go
|
||||||
|
map1 := make(map[string]int)
|
||||||
|
|
||||||
|
map2 := map[string]int{
|
||||||
|
"m1": 1,
|
||||||
|
"m2":2,
|
||||||
|
}
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### key 类型要求
|
||||||
|
|
||||||
|
map中,key的数据类型必须是可以比较的类型,slice,chan,func,map不可比较,所以不能作为map的key
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
# 核心原理
|
||||||
|
|
||||||
|
map又称为hash map,算法上基于hash实现key的映射和寻址,在数据结构上基于桶数组实现key-value对的存储
|
||||||
|
|
||||||
|
以一组key-value对写入map的流程进行简述:
|
||||||
|
|
||||||
|
1. 通过哈希方法去的key的hash值‘
|
||||||
|
2. hash值对同数组长度取模,确定它所属的桶
|
||||||
|
3. 在桶中插入key value对
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## hash
|
||||||
|
|
||||||
|
hash 译作散列,是一种将任意长度的输入压缩到某一固定长度的输出摘要的过程,由于这种转换属于压缩映射,输入空间远大于输出空间,因此不同输入可能会映射成相同的输出结果. 此外,hash在压缩过程中会存在部分信息的遗失,因此这种映射关系具有不可逆的特质.
|
||||||
|
|
||||||
|
1. hash的可重入性: 相同的key,必然产生相同的hash值
|
||||||
|
2. hash的离散性: 只要两个key不相同,不论他们相似度的高低,产生的hash值会在整个输出域内均匀地离散化
|
||||||
|
3. hash的单向性: 企图通过hash值反向映射会key是无迹可寻的。
|
||||||
|
4. hash冲突: 由于输入域无穷大,输出域有限,必然存在不同key映射到相同hash值的情况,这种情况叫做哈希冲突
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 桶数组
|
||||||
|
|
||||||
|
map中,会通过长度为2的整数次幂的桶数组进行key-value对的存储
|
||||||
|
|
||||||
|
1. 每个桶固定可以存放8个key-value对
|
||||||
|
2. 倘若超过8个key-value对打到桶数组的同一个索引当中,此时会通过创建桶链表的方式来化解这个问题。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 拉链法解决hash冲突
|
||||||
|
|
||||||
|
首先,由于hash冲突的存在,不同的key可能存在相同的hash值
|
||||||
|
|
||||||
|
再者,hash值会对桶数组长度取模,因此不同的hash值可能被打到同一个桶中
|
||||||
|
|
||||||
|
综上,不同的key-value可能被映射到map的同一个桶当中。
|
||||||
|
|
||||||
|
拉链法中,将命中同一个桶的元素通过链表的形式进行连接,因此便于动态扩展
|
||||||
|
|
||||||
|
> 只有当一个桶已经满了(8 个 kv 对),并且又有新的 key 哈希到这个桶时,才会创建溢出桶,并将新的 key-value 对存储到溢出桶中,然后将该溢出桶链接到原桶的尾部。 后续再有冲突的 kv 对,也会被添加到溢出桶或者新的溢出桶中,形成一个链表。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 开放寻址法解决hash冲突
|
||||||
|
|
||||||
|
> 开放寻址法是一种解决哈希冲突的方法,它在哈希表中寻找另一个空闲位置存储冲突的元素,也就是说,所有元素都直接存储在哈希表的桶中
|
||||||
|
>
|
||||||
|
> 开放寻址法是一种在哈希表中解决冲突的方法。当两个不同的键映射到同一个索引位置时,就会发生冲突。开放寻址法不是使用链表等额外的数据结构来存储冲突的键值对,而是尝试在哈希表本身中寻找一个空闲的位置来存储新的键值对。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
常见开放寻址技术:
|
||||||
|
|
||||||
|
- 线性寻址: 如果在索引`i`发生冲突,线性探测会依次检查`i+1`,`i+2`,`i+3`等位置,直到找到一个空闲的槽位
|
||||||
|
- 二次探测检查 `i + 1^2`、`i + 2^2`、`i + 3^2` 等位置。与线性探测相比,这有助于减少聚集现象。
|
||||||
|
- 双重哈希: 双重哈希使用第二个哈希函数来确定探测的步长。如果第一个哈希函数在索引`i`导致哈希冲突,第二个哈希函数hash2(key)用于确定探测的间隔(例如,`i + hash2(key)`、`i + 2*hash2(key)`、`i + 3*hash2(key)` 等)。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
我们的golang map解决哈希冲突的方式结合了拉链法和开放寻址法。
|
||||||
|
|
||||||
|
- 桶: map的底层数据结构是一个桶数组,每个桶严格意义上是一个单向桶链表
|
||||||
|
- 桶的大小: 每个桶可以固定存放8个key value对
|
||||||
|
- 当key命中一个桶的时候,首先根据开放寻址法,在桶的8个位置中寻找空位进行插入
|
||||||
|
- 倘若8个位置都已经被占满,就基于桶的溢出桶指针,找到下一个桶(重复第三步)
|
||||||
|
- 倘若遍历到链表尾部,还没找到空位,就用拉链法,在桶链表尾部接入新桶,并且插入key-value对
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 扩容性能优化
|
||||||
|
|
||||||
|
倘若map的桶数组长度固定不变,那么随着key-value对数量的增长,当一个桶下挂载的key-value达到一定的量级,此时操作的时间复杂度会趋于线性,无法满足诉求。
|
||||||
|
|
||||||
|
**桶数组长度固定不变 + key-value 对数量持续增加 => 哈希冲突加剧 => Bucket 链表变长 => 查找/插入/删除 需要遍历长链表 => 操作时间复杂度接近 O(n) (线性)**
|
||||||
|
|
||||||
|
因此在设计上,map桶的数组长度会随着key-value对的数量变化而实时调整。保证每个桶内的key-value对数量始终控制在常量级别。
|
||||||
|
|
||||||
|
扩容类型分为:
|
||||||
|
|
||||||
|
- 增量扩容
|
||||||
|
- 等量扩容
|
||||||
|
|
||||||
|
### 增量扩容
|
||||||
|
|
||||||
|
触发条件: `key-value总数 / 桶数组长度 > 6.5`的时候,发生增量扩容
|
||||||
|
|
||||||
|
扩容方式: 桶数组长度增长为原来的`两倍`
|
||||||
|
|
||||||
|
目的: 减少负载因子,降低平均查找时间
|
||||||
|
|
||||||
|
负载因子: `key-value总数 / 桶的数量`
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 等量扩容
|
||||||
|
|
||||||
|
触发条件: 当桶内溢出桶数量大于等于2^B时(B 为桶数组长度的指数,B 最大取 15),发生等量扩容。)
|
||||||
|
|
||||||
|
扩容方式: 桶的长度保持为原来的值
|
||||||
|
|
||||||
|
**目的:** 解决哈希冲突严重的问题,可能由于哈希函数选择不佳导致大量 key 映射到相同的桶,即使负载因子不高,也会出现大量溢出桶。 等量扩容旨在重新组织数据,减少溢出桶的数量。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 渐进式扩容
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
# 数据结构
|
||||||
|
|
||||||
|
## hmap
|
||||||
|
|
||||||
|
```go
|
||||||
|
type hmap struct {
|
||||||
|
count int // map中键值对的数量
|
||||||
|
flags uint8 // map的状态标志位,用来指示map的当前状态(正在写入,正在扩容等)
|
||||||
|
B uint8 // buckets 数组的对数大小,2^B 是buckets数组的长度,比如B是5,那么桶数组的长度就是2^5 = 32
|
||||||
|
noverflow uint16 //溢出桶数量的近似值 用来判断是否需要扩容
|
||||||
|
hash0 uint32 // 哈希种子
|
||||||
|
buckets unsafe.Pointer //指向bucket数组的指针,数组大小为2 ^ B,如果count == 0,那么buckets可能为nil
|
||||||
|
oldbuckets unsafe.Pointer // 如果发生扩容,指向旧的buckets数组
|
||||||
|
nevacuate uintptr // 扩容的时候,表示旧buckcet数组已经迁移到新bucket数组的数量计数器
|
||||||
|
extra *mapextra // 可选字段,用来保存overflow buckets的信息
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
flags: map状态标识,其包含的主要状态为(这里面牵扯到很多概念还没有涉及,可以先大致的了解一下各自的含义)
|
||||||
|
|
||||||
|
- iterator(`0b0001`): 当前map可能正在被遍历
|
||||||
|
- oldIterator(`0b0010`): 当前map的旧桶可能正在被遍历
|
||||||
|
- hashWrting(`0b0100`): 一个goroutine正在向map中写入数据
|
||||||
|
- sameSizeGrow(`0b1000`): 等量扩容标志字段
|
||||||
|
|
||||||
|
## bmap
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
> bmap就是map中的桶,可以存储8组key-value对数据,以及一个只想下一个溢出桶的指针
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
每一组key-value对数据包含key高8位hash值tophash,key,value三部分
|
||||||
|
|
||||||
|
我们来看看bmap(桶)的内存模型
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
如果按照 `key/value/key/value/...` 这样的模式存储,那在每一个 key/value 对之后都要额外 padding 7 个字节;而将所有的 key,value 分别绑定到一起,这种形式 `key/key/.../value/value/...`,则只需要在最后添加 padding。
|
||||||
|
|
||||||
|
每个 bucket 设计成最多只能放 8 个 key-value 对,如果有第 9 个 key-value 落入当前的 bucket,那就需要再构建一个 bucket ,通过 `overflow` 指针连接起来。
|
||||||
|
|
||||||
|
### tophash的作用?
|
||||||
|
|
||||||
|
是key 哈希值的高8位
|
||||||
|
|
||||||
|
tophash的核心作用是**判断一个键是否可能存在于当前桶中,从而优化查询效率。**
|
||||||
|
|
||||||
|
## 溢出桶数据结构 mapextra
|
||||||
|
|
||||||
|
在map初始化的时候会根据初始数据量不同,自动创建不同数量的溢出桶。在物理结构上初始的正常同和溢出桶是连续存放的,正常桶和溢出桶之间的关系是靠链表来维护的。
|
||||||
|
|
||||||
|
> `mapextra` 就是在扩容时提供了一批预备的 `bmap`,然后利用 `bmap.overflow` 把它们链接起来。
|
||||||
|
|
||||||
|
```go
|
||||||
|
type mapextra struct {
|
||||||
|
overflow *[]*bmap // overflow buckets 的指针数组
|
||||||
|
oldoverflow *[]*bmap // 旧的 overflow buckets 的指针数组
|
||||||
|
|
||||||
|
nextOverflow *bmap // 指向空闲的 overflow bucket
|
||||||
|
}
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
在map初始化的时候,倘若容量过大,会提前申请好一批溢出桶,供后续使用,这部分溢出桶存放在hmap.mapextra当中:
|
||||||
|
|
||||||
|
mapextra.overflow 是一个指向溢出桶切片的指针,这个切片里面的溢出桶是当前使用的,用于存储hmap.buckets中的桶的溢出数据。
|
||||||
|
|
||||||
|
mapextra.oldoverflow 也是一个指向溢出桶切片的指针,但是它指向的是旧的桶数组的溢出桶。
|
||||||
|
|
||||||
|
nextOverflow指向下一个可用的溢出桶
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 什么是哈希种子?
|
||||||
|
|
||||||
|
哈希种子(hash seed)是一个随机生成的数值,被用作哈希函数的一部分,来增加哈希值的随机性和不可预测性,可以把它理解为哈希函数的“盐”
|
||||||
|
|
||||||
|
# go map 如何根据key的哈希值确定键值存储到哪个桶中?
|
||||||
|
|
||||||
|
## 哈希值的作用
|
||||||
|
|
||||||
|
- 首先,当你在 Go map 中插入一个键值对时,Go runtime 会对键进行哈希运算,生成一个哈希值(一个整数)。 优秀的哈希函数应该能够将不同的键尽可能均匀地映射到不同的哈希值,以减少哈希碰撞的概率。
|
||||||
|
- 这个哈希值是确定键值对存储位置的关键。
|
||||||
|
|
||||||
|
## go map 数据结构中hmap 中B的作用
|
||||||
|
|
||||||
|
我们通过哈希值的低B位作为bucket数组的索引, 来选择键值该存储到哪个bucket中。
|
||||||
|
|
||||||
|
公式 `bucketIndex = hash & ((1 << B) - 1)`
|
||||||
|
|
||||||
|
上面的公式 用来**保留 `hash` 的低 `B` 位,并将其他位设置为 0**。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
# key定位过程
|
||||||
|
|
||||||
|
key经过哈希计算后得到哈希值,共64个bit位,计算它到底要落在哪个桶的时候,只会用到最后B个bit位(log2BucketCount)
|
||||||
|
|
||||||
|
例如,现在有一个key经过哈希函数计算后,得到的哈希结果是:
|
||||||
|
|
||||||
|
```
|
||||||
|
10010111 | 000011110110110010001111001010100010010110010101010 │ 01010
|
||||||
|
```
|
||||||
|
|
||||||
|
而我们的B是5,也就是有2^5 = 32个桶
|
||||||
|
|
||||||
|
取最后五位,也就是 **01010** 转换为10进制也就是10,也就是 **10号桶**,这个操作其实是 **取余操作**,但是取余数开销太大,就用上面的位运算代替了。
|
||||||
|
|
||||||
|
接下来我们再用 **hash值的高8位**找到key在 **10号桶**中的位置 **1001011转换为10进制也就是 75**.最开始桶内还没有 key,新加入的 key 会找到第一个空位,放入。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
# 流程
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
# 写入流程
|
||||||
|
|
||||||
|
写入流程:
|
||||||
|
|
||||||
|
- 进行hmap是否为nil的检查,如果为空,就触发panic
|
||||||
|
- 进行并发读写的检查,倘若已经设置了并发读写标记,就抛出"concurrent map writes"异常。
|
||||||
|
- 处理桶迁移。如果正在扩容,把key所在的旧桶数据迁移到新桶,同时迁移index位h.nevacuate的桶,迁移完成后h.nevacuate自增。更新迁移进度。如果所有桶迁移完毕,清除正在扩容的标记。
|
||||||
|
- 查找 key 所在的位置,并记录桶链表的第一个空闲位置(若此 key 之前不存在,则将该位置作为插入位置)。
|
||||||
|
- 若此 key 在桶链表中不存在,判断是否需要扩容,若溢出桶过多,则进行相同容量的扩容,否则进行双倍容量的扩容。
|
||||||
|
- 若桶链表没有空闲位置,则申请溢出桶来存放 key - value 对。
|
||||||
|
- 设置 key 和 tophash[i] 的值。
|
||||||
|
- 返回 value 的地址。
|
||||||
|
|
||||||
|
# 删除流程
|
||||||
|
|
||||||
|
删除流程:
|
||||||
|
|
||||||
|
- 进行并发读写检查。
|
||||||
|
- 处理桶迁移,如果map处于正在扩容的状态,就迁移两个桶
|
||||||
|
- 定位key所在的位置
|
||||||
|
- 删除kv对的占用,这里是伪删除,只有在下次扩容的时候,被删除的key所占用的同空间才会得到释放。
|
||||||
|
- map首先会将对应位置的tophash[i]设置为emptyOne,表示该位置被删除
|
||||||
|
- 如果tophash[i]后面还有有效的节点,就仅设置为emptyOne标志,意味着这个节点后面仍然存在有效的key-value对 ,后续在查找某个key的时候,这个节点只后仍然需要继续查找
|
||||||
|
- 要是tophash[i]是桶链表的最后一个有效节点,那么从这个节点往前遍历,将链表最后面所有标志位emptyOne的位置,都设置为emptyRest。这样在查找某个key的时候,emptyRest之后的节点不需要继续查找。
|
||||||
|
|
||||||
|
> - **`emptyOne`:** 表示当前 cell 是空的,但**不能保证**后面的 cell 也是空的。
|
||||||
|
> - **`emptyRest`:** 表示当前 cell 是空的,并且**保证**后面的所有 cell 也是空的,直到遇到一个非空 cell 或者到达桶的末尾。
|
||||||
|
|
||||||
|
# 迭代流程
|
||||||
|
|
||||||
|
在每次对 map 进行循环时,会调用 mapiterinit 函数,以确定迭代从哪个桶以及桶内的哪个位置起始。由于 mapiterinit 内部是通过随机数来决定起始位置的,所以 map 循环是无序的,每次循环所返回的 key - value 对的顺序都各不相同。
|
||||||
|
|
||||||
|

|
||||||
189
src/content/posts/Golang/Go_slice切片原理.md
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
---
|
||||||
|
title: Go_slice切片原理
|
||||||
|
published: 2025-07-19
|
||||||
|
description: ''
|
||||||
|
image: 'https://blog.meowrain.cn/api/i/2025/07/19/uje4vo-1.webp'
|
||||||
|
tags: [切片, Golang, Go]
|
||||||
|
category: 'Go'
|
||||||
|
draft: false
|
||||||
|
lang: ''
|
||||||
|
---
|
||||||
|
# slice数据结构
|
||||||
|
|
||||||
|
数据结构
|
||||||
|
我们每定义一个slice变量,golang底层都会构建一个slice结构的对象。slice结构体由3个成员变量构成:
|
||||||
|
|
||||||
|
array表示数组指针,数组用于存储数据。
|
||||||
|
len表示切片长度,也就是数组index从0到len-1已存储数据。
|
||||||
|
cap表示切片容量,当切片长度超过最大容量时,需要扩容申请更大长度的数组。
|
||||||
|
|
||||||
|
```go
|
||||||
|
type slice struct {
|
||||||
|
array unsafe.Pointer // 数组指针
|
||||||
|
len int // 切片长度
|
||||||
|
cap int // 切片容量
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
# 扩容原理
|
||||||
|
|
||||||
|
切片的扩容流程源码位于 runtime/slice.go 文件的 growslice 方法当中,其中核心步骤如下:
|
||||||
|
|
||||||
|
• 倘若扩容后预期的新容量小于原切片的容量,则 panic
|
||||||
|
|
||||||
|
• 倘若切片元素大小为 0(元素类型为 struct{}),则直接复用一个全局的 zerobase 实例,直接返回
|
||||||
|
|
||||||
|
• 倘若预期的新容量超过老容量的两倍,则直接采用预期的新容量
|
||||||
|
|
||||||
|
• 倘若老容量小于 256,则直接采用老容量的2倍作为新容量
|
||||||
|
|
||||||
|
• 倘若老容量已经大于等于 256,则在老容量的基础上扩容 1/4 的比例并且累加上 192 的数值,持续这样处理,直到得到的新容量已经大于等于预期的新容量为止
|
||||||
|
|
||||||
|
• 结合 mallocgc 流程中,对内存分配单元 mspan 的等级制度,推算得到实际需要申请的内存空间大小
|
||||||
|
|
||||||
|
• 调用 mallocgc,对新切片进行内存初始化
|
||||||
|
|
||||||
|
• 调用 memmove 方法,将老切片中的内容拷贝到新切片中
|
||||||
|
|
||||||
|
• 返回扩容后的新切片
|
||||||
|
|
||||||
|
```go
|
||||||
|
// nextslicecap computes the next appropriate slice length.
|
||||||
|
func nextslicecap(newLen, oldCap int) int {
|
||||||
|
newcap := oldCap // 将新容量初始化为旧容量
|
||||||
|
doublecap := newcap + newcap // 计算旧容量的两倍
|
||||||
|
|
||||||
|
// 如果所需的新长度大于旧容量的两倍,则直接使用所需的新长度
|
||||||
|
if newLen > doublecap {
|
||||||
|
return newLen
|
||||||
|
}
|
||||||
|
|
||||||
|
const threshold = 256 // 定义一个阈值,用于区分小切片和大切片
|
||||||
|
|
||||||
|
// 如果旧容量小于阈值,则直接将新容量设置为旧容量的两倍
|
||||||
|
// 这种策略适用于小切片,可以快速扩容,减少扩容次数
|
||||||
|
if oldCap < threshold {
|
||||||
|
return doublecap
|
||||||
|
}
|
||||||
|
|
||||||
|
// 对于大切片,使用更平滑的扩容策略,避免过度分配内存
|
||||||
|
// 从 2 倍增长过渡到 1.25 倍增长。 此公式给出了两者之间的平滑过渡。
|
||||||
|
for {
|
||||||
|
// 每次循环,将新容量增加 (newcap + 3*threshold) / 4
|
||||||
|
// 相当于 newcap 增加 1/4 的比例,再加上 3/4 的 threshold(256),即 192
|
||||||
|
// 这样可以在一定程度上减少内存浪费,并保证切片的增长
|
||||||
|
newcap += (newcap + 3*threshold) >> 2
|
||||||
|
|
||||||
|
// Check for overflow and determine if the new calculated capacity
|
||||||
|
// is greater or equal to the required new length.
|
||||||
|
// newLen is guaranteed to be larger than zero, hence
|
||||||
|
// when newcap overflows then `uint(newcap) > uint(newLen)`.
|
||||||
|
// This allows to check for both with the same comparison.
|
||||||
|
|
||||||
|
// 我们需要检查`newcap >= newLen`以及`newcap`是否溢出。
|
||||||
|
// 保证 newLen 大于零,因此当 newcap 溢出时,'uint(newcap) > uint(newLen)'。
|
||||||
|
// 这允许使用相同的比较来检查两者。
|
||||||
|
|
||||||
|
// 检查新容量是否大于等于所需的新长度,并且检查是否发生了溢出
|
||||||
|
if uint(newcap) >= uint(newLen) {
|
||||||
|
break // 如果新容量足够大,或者发生了溢出,则退出循环
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 当新容量计算溢出时,将新容量设置为请求的容量。
|
||||||
|
// 如果计算过程中发生了溢出,则直接将新容量设置为所需的新长度,以确保切片能够容纳所有元素
|
||||||
|
if newcap <= 0 {
|
||||||
|
return newLen
|
||||||
|
}
|
||||||
|
|
||||||
|
return newcap // 返回计算得到的新容量
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
# Golang 切片原理
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 扩容规律
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 切片作为参数
|
||||||
|
|
||||||
|
Go 语言的函数参数传递,只有值传递,没有引用传递,切片作为参数也是如此
|
||||||
|
|
||||||
|
我们来验证这一点
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
sl := []int{6, 6, 6}
|
||||||
|
f(sl)
|
||||||
|
fmt.Println(sl)
|
||||||
|
}
|
||||||
|
|
||||||
|
func f(sl []int) {
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
sl = append(sl, i)
|
||||||
|
}
|
||||||
|
fmt.Println(sl)
|
||||||
|
}
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
可以看到,输出的 sl 的值是不一样的,也就是说,f 函数没能修改主函数中的 sl 变量,而只是修改了形参 sl 变量的内容
|
||||||
|
|
||||||
|
当我们传递一个切片给函数的时候,函数接收到的其实是这个切片的一个副本,但是他们的 array 字段指向的是同一个底层数组。
|
||||||
|
|
||||||
|
这意味着,如果我们修改底层数组,是会影响到实参和形参的。
|
||||||
|
|
||||||
|
我们看下面的例子:形参通过改变底层数组影响实参
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
sl := []int{6, 6, 6}
|
||||||
|
f(sl)
|
||||||
|
fmt.Println(sl)
|
||||||
|
}
|
||||||
|
|
||||||
|
func f(sl []int) {
|
||||||
|
sl[1] = 1
|
||||||
|
sl[2] = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 通过指针传递影响实参
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
sl := []int{6, 6, 6}
|
||||||
|
f(&sl)
|
||||||
|
fmt.Println(sl)
|
||||||
|
}
|
||||||
|
|
||||||
|
func f(sl *[]int) {
|
||||||
|
*sl = append(*sl, 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
244
src/content/posts/Golang/Golang垃圾回收机制.md
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
---
|
||||||
|
title: Golang垃圾回收机制
|
||||||
|
published: 2025-07-19
|
||||||
|
description: ''
|
||||||
|
image: 'https://blog.meowrain.cn/api/i/2025/07/19/uje4vo-1.webp'
|
||||||
|
tags: [垃圾回收, Golang, GC]
|
||||||
|
category: 'Go'
|
||||||
|
draft: false
|
||||||
|
lang: ''
|
||||||
|
---
|
||||||
|
|
||||||
|
# Go GC机制
|
||||||
|
|
||||||
|
> [5、Golang三色标记混合写屏障GC模式全分析 (yuque.com)](https://www.yuque.com/aceld/golang/zhzanb#77fdf35b)
|
||||||
|
|
||||||
|
> 垃圾回收(Garbage Collection,简称GC)是编程语言中提供的自动的内存管理机制,自动释放不需要的内存对象,让出存储器资源。GC过程中无需程序员手动执行。GC机制在现代很多编程语言都支持,GC能力的性能与优劣也是不同语言之间对比度指标之一。
|
||||||
|
|
||||||
|
## 发展过程
|
||||||
|
|
||||||
|
Go V1.3之前的标记-清除(mark and sweep)算法,Go V1.3之前的标记-清扫(mark and sweep)的缺点
|
||||||
|
|
||||||
|
## Go V1.3之前的标记-清除(mark and sweep)算法
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
接下来我们来看一下在Golang1.3之前的时候主要用的普通的标记-清除算法,此算法主要有两个主要的步骤:
|
||||||
|
|
||||||
|
- 标记(Mark phase)
|
||||||
|
- 清除(Sweep phase)
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
> STW会对可达对象做上标记,然后对不可达对象进行GC回收
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
> 操作非常简单,但是有一点需要额外注意:mark and sweep算法在执行的时候,需要程序暂停!即 `STW(stop the world)`,STW的过程中,CPU不执行用户代码,全部用于垃圾回收,这个过程的影响很大,所以STW也是一些回收机制最大的难题和希望优化的点。所以在执行第三步的这段时间,程序会暂定停止任何工作,卡在那等待回收执行完毕。
|
||||||
|
|
||||||
|
### mark and sweep 算法 缺点
|
||||||
|
|
||||||
|
1. STW会让程序暂停,使程序出现卡顿(重要问题)
|
||||||
|
2. 标记需要扫描整个heap
|
||||||
|
3. 清除数据会产生heap碎片
|
||||||
|
|
||||||
|
stw暂停范围
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
从上图来看,全部的GC时间都是包裹在STW范围之内的,这样貌似程序暂停的时间过长,影响程序的运行性能。所以Go V1.3 做了简单的优化,将STW的步骤提前, 减少STW暂停的时间范围.如下所示
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
上图主要是将STW的步骤提前了一步,因为在Sweep清除的时候,可以不需要STW停止,因为这些对象已经是不可达对象了,不会出现回收写冲突等问题。
|
||||||
|
|
||||||
|
但是无论怎么优化,Go V1.3都面临这个一个重要问题,就是**mark-and-sweep 算法会暂停整个程序** 。
|
||||||
|
|
||||||
|
Go是如何面对并这个问题的呢?接下来G V1.5版本 就用**三色并发标记法**来优化这个问题.
|
||||||
|
|
||||||
|
## GoV1.5三色标记法
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 三色标记法无STW的问题
|
||||||
|
|
||||||
|
我们加入如果没有STW,那么也就不会再存在性能上的问题,那么接下来我们假设如果三色标记法不加入STW会发生什么事情?
|
||||||
|
我们还是基于上述的三色并发标记法来说, 他是一定要依赖STW的. 因为如果不暂停程序, 程序的逻辑改变对象引用关系, 这种动作如果在标记阶段做了修改,会影响标记结果的正确性,我们来看看一个场景,如果三色标记法, 标记过程不使用STW将会发生什么事情?
|
||||||
|
|
||||||
|
我们把初始状态设置为已经经历了第一轮扫描,目前黑色的有对象1和对象4, 灰色的有对象2和对象7,其他的为白色对象,且对象2是通过指针p指向对象3的,如图所示。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
现在如何三色标记过程不启动STW,那么在GC扫描过程中,任意的对象均可能发生读写操作,如图所示,在还没有扫描到对象2的时候,已经标记为黑色的对象4,此时创建指针q,并且指向白色的对象3。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
与此同时灰色的对象2将指针p移除,那么白色的对象3实则就是被挂在了已经扫描完成的黑色的对象4下,如图所示。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
然后我们正常指向三色标记的算法逻辑,将所有灰色的对象标记为黑色,那么对象2和对象7就被标记成了黑色,如图所示。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
那么就执行了三色标记的最后一步,将所有白色对象当做垃圾进行回收,如图所示。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
但是最后我们才发现,本来是对象4合法引用的对象3,却被GC给“误杀”回收掉了。
|
||||||
|
|
||||||
|
### GC误杀条件
|
||||||
|
|
||||||
|
可以看出,有两种情况,在三色标记法中,是不希望被发生的。
|
||||||
|
|
||||||
|
- 条件1: 一个白色对象被黑色对象引用**(白色被挂在黑色下)**
|
||||||
|
- 条件2: 灰色对象与它之间的可达关系的白色对象遭到破坏**(灰色同时丢了该白色)**
|
||||||
|
如果当以上两个条件同时满足时,就会出现对象丢失现象!
|
||||||
|
|
||||||
|
## 屏障机制
|
||||||
|
|
||||||
|
> 为了防止这种现象的发生,最简单的方式就是STW,直接禁止掉其他用户程序对对象引用关系的干扰,但是**STW的过程有明显的资源浪费,对所有的用户程序都有很大影响**。那么是否可以在保证对象不丢失的情况下合理的尽可能的提高GC效率,减少STW时间呢?答案是可以的,我们只要使用一种机制,尝试去破坏上面的两个必要条件就可以了。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 强三色不变式
|
||||||
|
|
||||||
|
强制性的不允许黑色对象引用白色对象
|
||||||
|
|
||||||
|
> 破坏条件1
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 弱三色不变式
|
||||||
|
|
||||||
|
黑色对象可以引用白色对象,但是要保证白色独享存在其它灰色对象对它的引用,或者可达它的链路上游存在灰色对象
|
||||||
|
|
||||||
|
> 破坏条件2
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
为了遵循上述的两个方式,GC算法演进到两种屏障方式,他们“插入屏障”, “删除屏障”。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 插入屏蔽
|
||||||
|
|
||||||
|
> 不在栈上使用
|
||||||
|
|
||||||
|
`具体操作`: 在A对象引用B对象的时候,B对象被标记为灰色。(将B挂在A下游,B必须被标记为灰色)
|
||||||
|
|
||||||
|
`满足`: **强三色不变式**. (不存在黑色对象引用白色对象的情况了, 因为白色会强制变成灰色)
|
||||||
|
|
||||||
|
```go
|
||||||
|
添加下游对象(当前下游对象slot, 新下游对象ptr) {
|
||||||
|
//1
|
||||||
|
标记灰色(新下游对象ptr)
|
||||||
|
|
||||||
|
//2
|
||||||
|
当前下游对象slot = 新下游对象ptr
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
这里说一下这个过程,首先因为插入屏障不在栈上使用
|
||||||
|
|
||||||
|
下面的图里面,已经进行了一次三色标记,外界向对象4添加对象8,对象1添加对象9,但是我们知道,对象1在栈上,所以它不会应用插入屏障,也就是说,这个时候对象 9不会按照插入屏障的规则设置为灰色,而对象4在堆上,因此它会应用插入屏障,所以会把对象8设置为灰色,然后我们进行第二次三色标记,从灰色对象出发(对象2,对象7,对象8) ,找可达对象(对象3),因此将对象3设置为灰色,然后对象2,7,8设置为黑色,接着进行第三次三色标记,从灰色对象出发(对象3),发现没有可达对象,因此设置对象3为黑色,这个时候我们有黑色对象: 对象1,对象2,对象3,对象4,对象7,对象8.
|
||||||
|
|
||||||
|
按照常理我们这个时候应该进行垃圾回收了对吧,其实不然,我们这个时候要把栈空间的对象全部设置为白色,然后使用STW暂停栈空间(对象1,对象2,对象3,对象9,对象5),防止外界干扰(再有对象被添加到黑色对象下)
|
||||||
|
|
||||||
|
然后我们对栈空间重新进行一次三色标记,直到没有灰色对象
|
||||||
|
|
||||||
|
过程如下:
|
||||||
|
|
||||||
|
从对象1出发,设置对象1为灰色,接下来看从对象1走的可达对象,发现可达对象有对象2和对象9,因此我们把对象2和对象9设置为灰色对象,把对象1设置为黑色对象,然后我们再从灰色对象出发(对象2和对象9),发现对象2可达对象3,对象9没有可达对象,因此把对象3设置为灰色对象,对象2,9设置为黑色对象,接下来从灰色对象(此时只有对象3)出发,发现对象3没有可达对象,设置对象3为黑色对象。至此栈里面已经没有灰色对象,我们先暂停STW,然后进行最后的GC回收,可以发现白色对象只有 对象5,对象6,因此对白色对象进行清除。
|
||||||
|
|
||||||
|
至此,GC三色标记并发情况下的插入屏障流程完毕
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 删除屏蔽
|
||||||
|
|
||||||
|
`具体操作`: 被删除的对象,如果自身为灰色或者白色,那么被标记为灰色。
|
||||||
|
|
||||||
|
`满足`: **弱三色不变式**. (保护灰色对象到白色对象的路径不会断)
|
||||||
|
|
||||||
|
```
|
||||||
|
添加下游对象(当前下游对象slot, 新下游对象ptr) {
|
||||||
|
//1
|
||||||
|
if (当前下游对象slot是灰色 || 当前下游对象slot是白色) {
|
||||||
|
标记灰色(当前下游对象slot) //slot为被删除对象, 标记为灰色
|
||||||
|
}
|
||||||
|
|
||||||
|
//2
|
||||||
|
当前下游对象slot = 新下游对象ptr
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
这种方式的回收精度低,一个对象即使被删除了最后一个指向它的指针也依旧可以活过这一轮,在下一轮GC中被清理掉。
|
||||||
|
|
||||||
|
### 混合屏障Go V1.8
|
||||||
|
|
||||||
|
插入写屏障和删除写屏障的短板:
|
||||||
|
|
||||||
|
● 插入写屏障:结束时需要STW来重新扫描栈,标记栈上引用的白色对象的存活;
|
||||||
|
● 删除写屏障:回收精度低,GC开始时STW扫描堆栈来记录初始快照,这个过程会保护开始时刻的所有存活对象。
|
||||||
|
|
||||||
|
Go V1.8版本引入了混合写屏障机制(hybrid write barrier),避免了对栈re-scan的过程,极大的减少了STW的时间。结合了两者的优点。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
`具体操作`:
|
||||||
|
|
||||||
|
1、GC开始将栈上的对象全部扫描并标记为黑色(之后不再进行第二次重复扫描,无需STW),
|
||||||
|
|
||||||
|
2、GC期间,任何在栈上创建的新对象,均为黑色。
|
||||||
|
|
||||||
|
3、被删除的对象标记为灰色。
|
||||||
|
|
||||||
|
4、被添加的对象标记为灰色。
|
||||||
374
src/content/posts/Java/BigDecimal高精度计算.md
Normal file
@@ -0,0 +1,374 @@
|
|||||||
|
---
|
||||||
|
title: BigDecimal高精度计算
|
||||||
|
published: 2025-07-26
|
||||||
|
description: ''
|
||||||
|
image: ''
|
||||||
|
tags: ['Java', '面试','高精度计算','BigDecimal']
|
||||||
|
category: 'Java > 面试题'
|
||||||
|
draft: false
|
||||||
|
lang: ''
|
||||||
|
---
|
||||||
|
https://javaguide.cn/java/basis/bigdecimal.html
|
||||||
|
|
||||||
|
# BigDecimal详解
|
||||||
|
Java中,浮点数的运算有精度丢失的风险
|
||||||
|
|
||||||
|
为什么浮点数运算的时候会有精度丢失的风险?
|
||||||
|
计算机是二进制的,浮点数在计算机中是通过二进制的方式来表示的。但是,浮点数的表示方式是有限的,所以在进行浮点数运算的时候,会存在精度丢失的风险。
|
||||||
|
|
||||||
|
例如,在Java中,浮点数的表示方式是 IEEE 754 标准,使用 64 位二进制来表示一个浮点数。其中,1 位用于表示符号位,11 位用于表示指数位,52 位用于表示尾数位。但是,浮点数的表示方式是有限的,所以在进行浮点数运算的时候,会存在精度丢失的风险。
|
||||||
|
|
||||||
|
# BigDecimal 类的常用方法
|
||||||
|
BigDecimal可以实现对小数的运算,不会造成精度损失
|
||||||
|
|
||||||
|
通常情况下,大部分需要小数精确运算结果的业务场景都是通过BigDecimal来做的。
|
||||||
|
|
||||||
|
《阿里巴巴 Java 开发手册》中提到:浮点数之间的等值判断,基本数据类型不能用 == 来比较,包装数据类型不能用 equals 来判断。
|
||||||
|
|
||||||
|
## 创建
|
||||||
|
我们在使用BigDecimal的时候,需要注意以下几点:
|
||||||
|
|
||||||
|
1. 不能使用new BigDecimal(double)的方式来创建BigDecimal对象,因为double类型的精度是有限的,所以在创建BigDecimal对象的时候,会存在精度丢失的风险。
|
||||||
|
|
||||||
|
2. 可以使用new BigDecimal(String)的方式来创建BigDecimal对象,因为String类型的精度是无限的,所以在创建BigDecimal对象的时候,不会存在精度丢失的风险。
|
||||||
|
|
||||||
|
3. 可以使用BigDecimal的valueOf()方法来创建BigDecimal对象,因为valueOf()方法的参数是double类型,但是在内部会将double类型的参数转换为String类型,所以在创建BigDecimal对象的时候,不会存在精度丢失的风险。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 加减乘除
|
||||||
|
add
|
||||||
|
subtract
|
||||||
|
multiply
|
||||||
|
divide
|
||||||
|
|
||||||
|
divide可以指定保留的小数位数,以及四舍五入的方式。
|
||||||
|
```java
|
||||||
|
public BigDecimal divide(BigDecimal divisor, int scale, RoundingMode roundingMode) {
|
||||||
|
return divide(divisor, scale, roundingMode.oldMode);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
我们使用 divide 方法的时候尽量使用 3 个参数版本,并且RoundingMode 不要选择 UNNECESSARY,否则很可能会遇到 ArithmeticException(无法除尽出现无限循环小数的时候),其中 scale 表示要保留几位小数,roundingMode 代表保留规则。
|
||||||
|
|
||||||
|
scale是保留几位小数,roundingMode是保留规则。
|
||||||
|
|
||||||
|
roundingMode:
|
||||||
|
- UP 向上四舍五入
|
||||||
|
- DOWN 向下截取
|
||||||
|
- CEILING 向上截取
|
||||||
|
- FLOOR 向下截取
|
||||||
|
- HALF_UP 四舍五入
|
||||||
|
- HALF_DOWN 五舍六入
|
||||||
|
- HALF_EVEN 四舍六入五取偶
|
||||||
|
|
||||||
|
# RoundingMode枚举详解 📚📊
|
||||||
|
|
||||||
|
## 各种舍入模式详细说明
|
||||||
|
|
||||||
|
### 1. UP - 向上舍入 ⬆️
|
||||||
|
```java
|
||||||
|
// 绝对值增大方向舍入,远离零的方向
|
||||||
|
2.4 -> 3 // 正数向上
|
||||||
|
1.6 -> 2 // 正数向上
|
||||||
|
-1.6 -> -2 // 负数向更小(绝对值更大)
|
||||||
|
-2.4 -> -3 // 负数向更小
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. DOWN - 向下舍入 ⬇️
|
||||||
|
```java
|
||||||
|
// 绝对值减小方向舍入,趋向零的方向
|
||||||
|
2.4 -> 2 // 正数向下
|
||||||
|
1.6 -> 1 // 正数向下
|
||||||
|
-1.6 -> -1 // 负数向更大(绝对值更小)
|
||||||
|
-2.4 -> -2 // 负数向更大
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. CEILING - 向正无穷舍入 ☁️
|
||||||
|
```java
|
||||||
|
// 向数轴右侧舍入
|
||||||
|
2.4 -> 3 // 正数向上
|
||||||
|
1.6 -> 2 // 正数向上
|
||||||
|
-1.6 -> -1 // 负数向更大(向右)
|
||||||
|
-2.4 -> -2 // 负数向更大
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. FLOOR - 向负无穷舍入 ⚡
|
||||||
|
```java
|
||||||
|
// 向数轴左侧舍入
|
||||||
|
2.4 -> 2 // 正数向下
|
||||||
|
1.6 -> 1 // 正数向下
|
||||||
|
-1.6 -> -2 // 负数向更小(向左)
|
||||||
|
-2.4 -> -3 // 负数向更小
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. HALF_UP - 四舍五入 🎯
|
||||||
|
```java
|
||||||
|
// 遇5向上舍入
|
||||||
|
2.4 -> 2 // 小于5,向下
|
||||||
|
2.5 -> 3 // 等于5,向上
|
||||||
|
2.6 -> 3 // 大于5,向上
|
||||||
|
-1.5 -> -2 // 负数也一样,-1.5 -> -2
|
||||||
|
-1.4 -> -1 // -1.4 -> -1
|
||||||
|
```
|
||||||
|
|
||||||
|
## 实际代码示例 💡
|
||||||
|
|
||||||
|
```java
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.math.RoundingMode;
|
||||||
|
|
||||||
|
public class RoundingModeDemo {
|
||||||
|
public static void main(String[] args) {
|
||||||
|
BigDecimal[] testNumbers = {
|
||||||
|
new BigDecimal("2.4"),
|
||||||
|
new BigDecimal("2.5"),
|
||||||
|
new BigDecimal("2.6"),
|
||||||
|
new BigDecimal("-1.4"),
|
||||||
|
new BigDecimal("-1.5"),
|
||||||
|
new BigDecimal("-1.6")
|
||||||
|
};
|
||||||
|
|
||||||
|
for (BigDecimal num : testNumbers) {
|
||||||
|
System.out.println("\n原数: " + num);
|
||||||
|
System.out.println("UP: " + num.setScale(0, RoundingMode.UP));
|
||||||
|
System.out.println("DOWN: " + num.setScale(0, RoundingMode.DOWN));
|
||||||
|
System.out.println("CEILING: " + num.setScale(0, RoundingMode.CEILING));
|
||||||
|
System.out.println("FLOOR: " + num.setScale(0, RoundingMode.FLOOR));
|
||||||
|
System.out.println("HALF_UP: " + num.setScale(0, RoundingMode.HALF_UP));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 输出结果展示 📊
|
||||||
|
|
||||||
|
```
|
||||||
|
原数: 2.4
|
||||||
|
UP: 3
|
||||||
|
DOWN: 2
|
||||||
|
CEILING: 3
|
||||||
|
FLOOR: 2
|
||||||
|
HALF_UP: 2
|
||||||
|
|
||||||
|
原数: 2.5
|
||||||
|
UP: 3
|
||||||
|
DOWN: 2
|
||||||
|
CEILING: 3
|
||||||
|
FLOOR: 2
|
||||||
|
HALF_UP: 3
|
||||||
|
|
||||||
|
原数: -1.5
|
||||||
|
UP: -2
|
||||||
|
DOWN: -1
|
||||||
|
CEILING: -1
|
||||||
|
FLOOR: -2
|
||||||
|
HALF_UP: -2
|
||||||
|
```
|
||||||
|
|
||||||
|
## 使用场景建议 🎯
|
||||||
|
|
||||||
|
```java
|
||||||
|
public class RoundingMode应用场景 {
|
||||||
|
public static void main(String[] args) {
|
||||||
|
// 金融计算 - 通常使用HALF_UP(银行家舍入)
|
||||||
|
BigDecimal money = new BigDecimal("123.455");
|
||||||
|
BigDecimal roundedMoney = money.setScale(2, RoundingMode.HALF_UP);
|
||||||
|
|
||||||
|
// 统计计算 - 可能使用HALF_EVEN(银行家舍入)
|
||||||
|
BigDecimal average = new BigDecimal("87.345");
|
||||||
|
BigDecimal roundedAvg = average.setScale(2, RoundingMode.HALF_EVEN);
|
||||||
|
|
||||||
|
// 科学计算 - 根据需要选择合适的模式
|
||||||
|
BigDecimal scientific = new BigDecimal("99.999");
|
||||||
|
BigDecimal ceilingResult = scientific.setScale(2, RoundingMode.CEILING);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# BigDecimal等值比较
|
||||||
|
使用compareTo进行比较,因为equals会比较值和精度,但是compareTo会忽略精度
|
||||||
|
|
||||||
|
compareTo() 方法可以比较两个 BigDecimal 的值,如果相等就返回 0,如果第 1 个数比第 2 个数大则返回 1,反之返回-1。
|
||||||
|
|
||||||
|
# BigDecimal工具类
|
||||||
|
```java
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.math.RoundingMode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 简化BigDecimal计算的小工具类
|
||||||
|
*/
|
||||||
|
public class BigDecimalUtil {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 默认除法运算精度
|
||||||
|
*/
|
||||||
|
private static final int DEF_DIV_SCALE = 10;
|
||||||
|
|
||||||
|
private BigDecimalUtil() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提供精确的加法运算。
|
||||||
|
*
|
||||||
|
* @param v1 被加数
|
||||||
|
* @param v2 加数
|
||||||
|
* @return 两个参数的和
|
||||||
|
*/
|
||||||
|
public static double add(double v1, double v2) {
|
||||||
|
BigDecimal b1 = BigDecimal.valueOf(v1);
|
||||||
|
BigDecimal b2 = BigDecimal.valueOf(v2);
|
||||||
|
return b1.add(b2).doubleValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提供精确的减法运算。
|
||||||
|
*
|
||||||
|
* @param v1 被减数
|
||||||
|
* @param v2 减数
|
||||||
|
* @return 两个参数的差
|
||||||
|
*/
|
||||||
|
public static double subtract(double v1, double v2) {
|
||||||
|
BigDecimal b1 = BigDecimal.valueOf(v1);
|
||||||
|
BigDecimal b2 = BigDecimal.valueOf(v2);
|
||||||
|
return b1.subtract(b2).doubleValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提供精确的乘法运算。
|
||||||
|
*
|
||||||
|
* @param v1 被乘数
|
||||||
|
* @param v2 乘数
|
||||||
|
* @return 两个参数的积
|
||||||
|
*/
|
||||||
|
public static double multiply(double v1, double v2) {
|
||||||
|
BigDecimal b1 = BigDecimal.valueOf(v1);
|
||||||
|
BigDecimal b2 = BigDecimal.valueOf(v2);
|
||||||
|
return b1.multiply(b2).doubleValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提供(相对)精确的除法运算,当发生除不尽的情况时,精确到
|
||||||
|
* 小数点以后10位,以后的数字四舍六入五成双。
|
||||||
|
*
|
||||||
|
* @param v1 被除数
|
||||||
|
* @param v2 除数
|
||||||
|
* @return 两个参数的商
|
||||||
|
*/
|
||||||
|
public static double divide(double v1, double v2) {
|
||||||
|
return divide(v1, v2, DEF_DIV_SCALE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提供(相对)精确的除法运算。当发生除不尽的情况时,由scale参数指
|
||||||
|
* 定精度,以后的数字四舍六入五成双。
|
||||||
|
*
|
||||||
|
* @param v1 被除数
|
||||||
|
* @param v2 除数
|
||||||
|
* @param scale 表示表示需要精确到小数点以后几位。
|
||||||
|
* @return 两个参数的商
|
||||||
|
*/
|
||||||
|
public static double divide(double v1, double v2, int scale) {
|
||||||
|
if (scale < 0) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"The scale must be a positive integer or zero");
|
||||||
|
}
|
||||||
|
BigDecimal b1 = BigDecimal.valueOf(v1);
|
||||||
|
BigDecimal b2 = BigDecimal.valueOf(v2);
|
||||||
|
return b1.divide(b2, scale, RoundingMode.HALF_EVEN).doubleValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提供精确的小数位四舍六入五成双处理。
|
||||||
|
*
|
||||||
|
* @param v 需要四舍六入五成双的数字
|
||||||
|
* @param scale 小数点后保留几位
|
||||||
|
* @return 四舍六入五成双后的结果
|
||||||
|
*/
|
||||||
|
public static double round(double v, int scale) {
|
||||||
|
if (scale < 0) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"The scale must be a positive integer or zero");
|
||||||
|
}
|
||||||
|
BigDecimal b = BigDecimal.valueOf(v);
|
||||||
|
BigDecimal one = new BigDecimal("1");
|
||||||
|
return b.divide(one, scale, RoundingMode.HALF_UP).doubleValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提供精确的类型转换(Float)
|
||||||
|
*
|
||||||
|
* @param v 需要被转换的数字
|
||||||
|
* @return 返回转换结果
|
||||||
|
*/
|
||||||
|
public static float convertToFloat(double v) {
|
||||||
|
BigDecimal b = new BigDecimal(v);
|
||||||
|
return b.floatValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提供精确的类型转换(Int)不进行四舍六入五成双
|
||||||
|
*
|
||||||
|
* @param v 需要被转换的数字
|
||||||
|
* @return 返回转换结果
|
||||||
|
*/
|
||||||
|
public static int convertsToInt(double v) {
|
||||||
|
BigDecimal b = new BigDecimal(v);
|
||||||
|
return b.intValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提供精确的类型转换(Long)
|
||||||
|
*
|
||||||
|
* @param v 需要被转换的数字
|
||||||
|
* @return 返回转换结果
|
||||||
|
*/
|
||||||
|
public static long convertsToLong(double v) {
|
||||||
|
BigDecimal b = new BigDecimal(v);
|
||||||
|
return b.longValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回两个数中大的一个值
|
||||||
|
*
|
||||||
|
* @param v1 需要被对比的第一个数
|
||||||
|
* @param v2 需要被对比的第二个数
|
||||||
|
* @return 返回两个数中大的一个值
|
||||||
|
*/
|
||||||
|
public static double returnMax(double v1, double v2) {
|
||||||
|
BigDecimal b1 = new BigDecimal(v1);
|
||||||
|
BigDecimal b2 = new BigDecimal(v2);
|
||||||
|
return b1.max(b2).doubleValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回两个数中小的一个值
|
||||||
|
*
|
||||||
|
* @param v1 需要被对比的第一个数
|
||||||
|
* @param v2 需要被对比的第二个数
|
||||||
|
* @return 返回两个数中小的一个值
|
||||||
|
*/
|
||||||
|
public static double returnMin(double v1, double v2) {
|
||||||
|
BigDecimal b1 = new BigDecimal(v1);
|
||||||
|
BigDecimal b2 = new BigDecimal(v2);
|
||||||
|
return b1.min(b2).doubleValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 精确对比两个数字
|
||||||
|
*
|
||||||
|
* @param v1 需要被对比的第一个数
|
||||||
|
* @param v2 需要被对比的第二个数
|
||||||
|
* @return 如果两个数一样则返回0,如果第一个数比第二个数大则返回1,反之返回-1
|
||||||
|
*/
|
||||||
|
public static int compareTo(double v1, double v2) {
|
||||||
|
BigDecimal b1 = BigDecimal.valueOf(v1);
|
||||||
|
BigDecimal b2 = BigDecimal.valueOf(v2);
|
||||||
|
return b1.compareTo(b2);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
```
|
||||||
271
src/content/posts/Java/JUC/ABA问题.md
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
---
|
||||||
|
title: ABA问题
|
||||||
|
published: 2025-07-19
|
||||||
|
description: ''
|
||||||
|
image: ''
|
||||||
|
tags: [JUC, ABA问题]
|
||||||
|
category: 'Java > JUC'
|
||||||
|
draft: false
|
||||||
|
lang: ''
|
||||||
|
---
|
||||||
|
|
||||||
|
# 介绍
|
||||||
|
|
||||||
|
ABA问题是并发编程中,在使用无锁(lock-free)算法,特别是基于 比较并交换(Compare-And-Swap, CAS) 操作时可能出现的一种逻辑错误。
|
||||||
|
|
||||||
|
它之所以被称为"ABA"问题,是因为一个变量的值从 A 变成了 B,然后又变回了 A。对于一个只检查当前值是否等于期望值的CAS操作来说,它会认为值没有发生变化,从而成功执行操作,但实际上变量在期间已经被修改过了。
|
||||||
|
|
||||||
|
## **ABA问题发生的场景及危害**
|
||||||
|
|
||||||
|
想象一个无锁的栈(Stack),其 `pop()` 操作需要原子地更新栈顶元素。
|
||||||
|
|
||||||
|
**假设初始状态:**
|
||||||
|
栈顶 `top` 指向元素 `A`。
|
||||||
|
|
||||||
|
**正常 `pop` 操作流程:**
|
||||||
|
|
||||||
|
1. 线程1读取当前栈顶元素 `A`。
|
||||||
|
2. 线程1准备将栈顶更新为 `A.next` (假设是 `null`)。
|
||||||
|
3. 线程1执行 `top.compareAndSet(A, A.next)`,如果成功,`A` 被弹出。
|
||||||
|
|
||||||
|
**ABA问题发生过程:**
|
||||||
|
|
||||||
|
1. **线程1** 读取当前栈顶元素,发现是 `A`。它记下 `A`,并准备执行 `CAS(A, C)`。
|
||||||
|
|
||||||
|
```
|
||||||
|
top -> A -> B -> D
|
||||||
|
Thread 1 reads top: A
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **线程2** 此时突然执行,它将 `A` 弹出。
|
||||||
|
|
||||||
|
```
|
||||||
|
top -> B -> D (A is now removed)
|
||||||
|
Thread 2 pops A
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **线程2** 又将一个**新的元素 `A` (或者一个值和 `A` 相同但实际上是不同对象的元素)**压入栈。
|
||||||
|
*注意:这里的“新的元素A”指的是一个与最开始的A值相同,但内存地址可能不同,或者即便内存地址相同,其内部状态已经发生过变化的对象。*
|
||||||
|
|
||||||
|
```
|
||||||
|
top -> A -> B -> D (This A is NOT the original A, it's a new one!)
|
||||||
|
Thread 2 pushes A back
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **线程1** 恢复执行 `CAS(A, C)`。它检查当前栈顶是否是它之前读取的 `A`。
|
||||||
|
由于栈顶现在又指向了 `A`(尽管是新的 `A`),`compareAndSet` 操作会认为当前值等于期望值 `A`,并成功将栈顶更新为 `C`。
|
||||||
|
|
||||||
|
```
|
||||||
|
top -> C (Thread 1's CAS(A, C) succeeds!)
|
||||||
|
```
|
||||||
|
|
||||||
|
**危害:**
|
||||||
|
尽管线程1的CAS操作成功了,但它操作的实际上是一个**新的 `A`**,而不是它最初读取的那个 `A`。如果 `A` 的内部状态(比如它的 `next` 指针)在这期间被改变了,那么线程1的后续操作可能会导致:
|
||||||
|
|
||||||
|
* **数据结构损坏**:例如,在链表中,节点指针可能指向错误的位置。
|
||||||
|
* **逻辑错误**:程序基于过时的或不正确的状态信息做出决策。
|
||||||
|
* **内存泄漏**:旧的 `A` (或其他被弹出又压入的元素)可能永远无法被垃圾回收。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
```java
|
||||||
|
package org.example.aba;
|
||||||
|
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
|
class Node {
|
||||||
|
public final String item; // 节点内容
|
||||||
|
public Node next; // 下一个节点的引用
|
||||||
|
|
||||||
|
public Node(String item) {
|
||||||
|
this.item = item;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class LockFreeStackABA {
|
||||||
|
private AtomicReference<Node> top = new AtomicReference<>();
|
||||||
|
|
||||||
|
// 压入栈顶
|
||||||
|
public void push(String item) {
|
||||||
|
Node newHead = new Node(item);
|
||||||
|
Node oldHead;
|
||||||
|
do {
|
||||||
|
oldHead = top.get();
|
||||||
|
newHead.next = oldHead;
|
||||||
|
} while (!top.compareAndSet(oldHead, newHead));
|
||||||
|
System.out.println(Thread.currentThread().getName() + " 压入: " + item + " (当前栈顶: " + top.get() + ")");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 弹出栈顶
|
||||||
|
public Node pop() {
|
||||||
|
Node oldHead;
|
||||||
|
Node newHead;
|
||||||
|
do {
|
||||||
|
oldHead = top.get();
|
||||||
|
if (oldHead == null) {
|
||||||
|
System.out.println(Thread.currentThread().getName() + " 尝试弹出,但栈为空!");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
newHead = oldHead.next;
|
||||||
|
System.out.println(Thread.currentThread().getName() + " 尝试弹出 " + oldHead.item +
|
||||||
|
" (期望栈顶: " + oldHead + ", 更新栈顶至: " + newHead + ")");
|
||||||
|
} while (!top.compareAndSet(oldHead, newHead)); // CAS操作:如果当前栈顶仍是oldHead,则更新为newHead
|
||||||
|
System.out.println(Thread.currentThread().getName() + " 成功弹出: " + oldHead.item + " (当前栈顶: " + top.get() + ")");
|
||||||
|
return oldHead;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打印栈内容
|
||||||
|
public void printStack() {
|
||||||
|
System.out.print("当前栈: ");
|
||||||
|
Node current = top.get();
|
||||||
|
if (current == null) {
|
||||||
|
System.out.println("空");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
while (current != null) {
|
||||||
|
sb.append(current.item).append(" -> ");
|
||||||
|
current = current.next;
|
||||||
|
}
|
||||||
|
sb.setLength(sb.length() - 4); // 移除最后的 " -> "
|
||||||
|
System.out.println(sb.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取栈顶节点
|
||||||
|
public Node getTop() {
|
||||||
|
return top.get();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AbaAppear {
|
||||||
|
public static void main(String[] args) throws InterruptedException {
|
||||||
|
LockFreeStackABA stack = new LockFreeStackABA();
|
||||||
|
|
||||||
|
// 1. 初始状态:栈中逐步压入 A、B、C
|
||||||
|
stack.push("C"); // 栈顶:C
|
||||||
|
stack.push("B"); // 栈顶:B → C
|
||||||
|
stack.push("A"); // 栈顶:A → B → C
|
||||||
|
|
||||||
|
Node originalNodeA = stack.getTop(); // 获取当前栈顶的 A 节点引用
|
||||||
|
|
||||||
|
System.out.println("\n--- 初始栈内容 ---");
|
||||||
|
stack.printStack();
|
||||||
|
|
||||||
|
// 2. 线程1 启动,读取栈顶元素后等待
|
||||||
|
Thread thread1 = new Thread(() -> {
|
||||||
|
Node readNode = stack.getTop(); // 线程1在原栈中看到栈顶元素 A
|
||||||
|
System.out.println("\n线程-1 读取到栈顶节点: " + readNode);
|
||||||
|
try {
|
||||||
|
Thread.sleep(200); // 等待线程2的干扰行为发生
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
System.out.println("\n线程-1 开始尝试弹出栈顶节点...");
|
||||||
|
Node popNode = stack.pop(); // 线程1尝试弹出栈顶(被线程2修改为新 A)
|
||||||
|
if (readNode == popNode) {
|
||||||
|
System.out.println("同一个节点");
|
||||||
|
}else {
|
||||||
|
System.out.println("不是同一个节点,ABA问题已重现!");
|
||||||
|
}
|
||||||
|
}, "线程-1");
|
||||||
|
|
||||||
|
// 3. 线程2 启动,执行 ABA 序列
|
||||||
|
Thread thread2 = new Thread(() -> {
|
||||||
|
System.out.println("\n--- 线程-2 执行 ABA 序列 ---");
|
||||||
|
stack.pop(); // 弹出 A,栈顶变为 B
|
||||||
|
stack.pop(); // 弹出 B,栈顶变为 C
|
||||||
|
stack.push("X"); // 压入一个新节点 X,栈顶变为 X → C
|
||||||
|
stack.push("A"); // 再压入一个新的 A,栈顶变为 A → X → C
|
||||||
|
System.out.println("--- 线程-2 完成 ABA 序列 ---");
|
||||||
|
stack.printStack();
|
||||||
|
}, "线程-2");
|
||||||
|
|
||||||
|
thread1.start(); // 启动线程1
|
||||||
|
thread2.start(); // 启动线程2
|
||||||
|
|
||||||
|
thread1.join(); // 等待线程1完成
|
||||||
|
thread2.join(); // 等待线程2完成
|
||||||
|
|
||||||
|
System.out.println("\n--- 最终栈内容 ---");
|
||||||
|
stack.printStack();
|
||||||
|
System.out.println("当前栈顶节点: " + stack.getTop());
|
||||||
|
if (stack.getTop() != null) {
|
||||||
|
System.out.println("栈顶节点的 next: " + stack.getTop().next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
# 如何解决ABA问题
|
||||||
|
|
||||||
|
解决ABA问题的主要方法是引入一个 版本号(或时间戳) 机制。每次修改变量时,不仅修改值,也同时修改版本号。CAS操作时,需要同时比较值和版本号。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
使用AtomicStampedReference解决问题
|
||||||
|
|
||||||
|
```java
|
||||||
|
package org.example.aba;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.atomic.AtomicStampedReference;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
public class AbaSolve {
|
||||||
|
static AtomicStampedReference<String> ref = new AtomicStampedReference<>("A", 0);
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
log.debug("main start ....");
|
||||||
|
String prev = ref.getReference();
|
||||||
|
int stamp = ref.getStamp();
|
||||||
|
log.debug("stamp: {}", stamp);
|
||||||
|
|
||||||
|
other();
|
||||||
|
try {
|
||||||
|
TimeUnit.SECONDS.sleep(1);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
log.debug("change A -> C {} ", ref.compareAndSet(prev, "C", stamp, stamp + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void other() {
|
||||||
|
new Thread(() -> {
|
||||||
|
int stamp = ref.getStamp();
|
||||||
|
log.debug("{} 's stamp is : {}",Thread.currentThread().getName(),stamp);
|
||||||
|
log.debug("change A-> B {} ", ref.compareAndSet(ref.getReference(), "B", stamp, stamp + 1));
|
||||||
|
stamp = ref.getStamp();
|
||||||
|
log.debug("{} 's changed stamp is : {}",Thread.currentThread().getName(),stamp);
|
||||||
|
}, "t1").start();
|
||||||
|
try {
|
||||||
|
TimeUnit.MILLISECONDS.sleep(500);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
new Thread(()->{
|
||||||
|
int stamp = ref.getStamp();
|
||||||
|
log.debug("{} 's stamp is : {}",Thread.currentThread().getName(),stamp);
|
||||||
|
log.debug("change B->A {}",ref.compareAndSet(ref.getReference(),"A",stamp,stamp + 1));
|
||||||
|
stamp = ref.getStamp();
|
||||||
|
log.debug("{} 's changed stamp is : {}",Thread.currentThread().getName(),stamp);
|
||||||
|
},"t2").start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
48
src/content/posts/Java/JUC/ConcurrentHashMap1.7和1.8的区别.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
---
|
||||||
|
title: ConcurrentHashMap1.7和1.8的区别
|
||||||
|
published: 2025-09-15
|
||||||
|
description: ''
|
||||||
|
image: ''
|
||||||
|
tags: [Java,ConcurrentHashMap]
|
||||||
|
category: 'Java'
|
||||||
|
draft: false
|
||||||
|
lang: ''
|
||||||
|
---
|
||||||
|
|
||||||
|
# JDK1.7
|
||||||
|
ConcurrentHashMap用的是分段锁,每个Segment是独立的,可以并发访问不同的Segment,默认是16个Segment,所以最多有16个线程可以并发执行。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
先通过key的hash判断得到Segment数组的下标,将这个Segment上锁,然后再次通过key的hash得到Segment里面HashEntry数组的下标。可以这么理解:每个Segment数组存放的就是一个单独的HashMap
|
||||||
|
|
||||||
|
缺点是Segment数组一旦初始化了之后就不会扩容,只有HashEntry数组会扩容,这就导致并发度过于死板
|
||||||
|
|
||||||
|
# JDK1.8
|
||||||
|
移除了分段锁,锁的粒度更加细化,锁只在链表或者红黑树**节点级别**上进行。通过CAS进行插入操作,只有在更新链表或者红黑树的时候才使用`synchronized`,并且只锁住链表或者树的头节点,进一步减少了锁的竞争,并发度大大增加。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
|
1.8版本的ConcurrentHashMap也不借助ReentrantLock了,直接用synchronized。
|
||||||
|
|
||||||
|
当塞入一个值的时候,先计算key的hash后的下标,如果计算到的下标还没有Node,那么就通过CAS塞入新的Node,如果已经有node,就通过synchronized给这个node上锁,这样别的线程就无法访问这个node和它之后的所有节点了。
|
||||||
|
然后判断key是不是相等,相等就直接替换value,反之新增一个node。
|
||||||
|
|
||||||
|
# 扩容上面的区别
|
||||||
|
JDK1.7的扩容:
|
||||||
|
- 基于Segment: ConcurrentHashMap是由多个Segment组成的,每个Segment中包含一个HashMap,当某个Segment内的HashMap达到扩容阈值的时候,单独为该Segment进行扩容,不会影响到其他Segment
|
||||||
|
- 扩容过程: 每个Segment维护自己的负载因子,当Segment中的元素数量超过阈值的时候,这个Segment的HashMap会扩容,整体的ConcurrentHashMap并不是一次性全部扩容。
|
||||||
|
|
||||||
|
JDK1.8的扩容:
|
||||||
|
- 全局扩容: ConcurrentHashMap取消了Segment,变成了一个全局的数组(类似于HashMap)。因此当ConcurrentHashMap中任意位置的元素超过阈值的时候,整个ConcurrentHashMap的数组都会被扩容。
|
||||||
|
|
||||||
|
- 基于CAS扩容: 扩容的时候,ConcurrentHashMap采用了类似HashMap的方式。通过CAS确保线程安全,避免锁住整个数组。扩容的时候,多个线程可以同时帮助完成扩容操作。
|
||||||
|
|
||||||
|
- 渐进性扩容: JDK1.8的ConcurrentHashMap引入了渐进式扩容机制,
|
||||||
|
|
||||||
|
|
||||||
|
# size逻辑区别
|
||||||
|
1.7 是尝试,调用size方法的时候不加锁,三次结果一样那说明没有线程竞争,如果不一样,就加锁计算。
|
||||||
|
|
||||||
|
1.8的话,是直接计算返回结果,用的是LongAdder完成的累加。
|
||||||
14403
src/content/posts/Java/JUC/JUC笔记.md
Normal file
119
src/content/posts/Java/JUC/volatile双重锁.md
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
---
|
||||||
|
title: volatile-实现单例模式的双重锁
|
||||||
|
published: 2025-08-07
|
||||||
|
description: ''
|
||||||
|
image: ''
|
||||||
|
tags: [JUC,JAVA,volatile,双重锁]
|
||||||
|
category: 'Java > JUC'
|
||||||
|
draft: false
|
||||||
|
lang: ''
|
||||||
|
---
|
||||||
|
|
||||||
|
# 什么是单例模式的双重锁
|
||||||
|
单例模式的双重锁是一种实现单例模式的技术,通过两次检查实例是否为null,结合同步锁来保证在多线程环境下只创建一个实例,并试图通过减少同步的次数来提高性能。为了确保线程安全,尤其在涉及到对象创建的指令重排的问题的时候,通常需要使用 `volatile`关键字来修饰单例类的实例变量。
|
||||||
|
|
||||||
|
# 非线程安全的单例模式
|
||||||
|
```java
|
||||||
|
public class Singleton {
|
||||||
|
private static Singleton instance;
|
||||||
|
private Singleton() {
|
||||||
|
|
||||||
|
}
|
||||||
|
public static Singleton getInstance() {
|
||||||
|
if (instance == null) {
|
||||||
|
instance = new Singleton();
|
||||||
|
}
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
> 多线程环境下,上面的简单实现在并发调用 `getInstance()`方法时候可能出现问题。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
常见的做法是使用synchronized
|
||||||
|
```java
|
||||||
|
public class Singleton {
|
||||||
|
private static Singleton instance;
|
||||||
|
private Singleton() {
|
||||||
|
|
||||||
|
}
|
||||||
|
public static synchronized Singleton getInstance() {
|
||||||
|
if (instance == null) {
|
||||||
|
instance = new Singleton();
|
||||||
|
}
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
这种同步方式能确保线程安全,因为在同一时间,只有一个线程能够进入getInstance方法,但是每次调用`getInstance`方法都需要获取锁,即使在实例已经创建之后也是如此,这样会带来额外的性能开销,尤其是在频繁调用`getInstance()`的情况下
|
||||||
|
# 什么是单例模式的双重检查锁定 -> 可能会导致半初始化问题
|
||||||
|
双重检查锁定就是为了保证在线程安全的前提下,尽量减少同步带来的性能开销
|
||||||
|
|
||||||
|
核心思想:
|
||||||
|
1. 第一次检查: 在进入同步块之前,先检查insatnce是否为null,如果不是null,说明实例已经创建,可以直接返回,避免进入同步块。
|
||||||
|
2. 同步块: 如果第一次检查发现instance是null,就进入同步块
|
||||||
|
3. 第二次检查: 在同步块内,再次检查instance是否为null,这是至关重要的一部,因为可能多个线程都通过了第一次检查,但只有一个线程进入同步块,在同步块内再次检查可以确保只有一个线程会智行对象的创建操作。
|
||||||
|
4. 创建实例:如果第二次检查发现instance仍然为null,才真正创建对象并把引用赋值给instance
|
||||||
|
|
||||||
|
|
||||||
|
```java
|
||||||
|
public class Singleton {
|
||||||
|
private static Singleton instance;
|
||||||
|
private Singleton() {
|
||||||
|
|
||||||
|
}
|
||||||
|
public static Singleton getInstance() {
|
||||||
|
if (instance == null) {
|
||||||
|
synchronized (Singleton.class) {
|
||||||
|
if (instance == null) {
|
||||||
|
instance = new Singleton();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
> 尽管双重检查锁看起来聪明地减少了同步磁化,但是在JMM(JAVA 内存模型)种,没有使用`volatile`的双重检查锁仍然存在`指令重排`的问题。
|
||||||
|
|
||||||
|
对象创建的过程 `instance = new SimpleSingleton();` 实际上能分解为三个步骤:
|
||||||
|
1. 为对象分配内存空间
|
||||||
|
2. 初始化对象
|
||||||
|
3. 将分配的内存空间的地址赋值给`instance`变量
|
||||||
|
|
||||||
|
在某些情况下,JVM为了优化性能,可能会对这三个步骤进行重排序,例如,可能会将步骤三排在步骤2之前
|
||||||
|
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
# 为什么用volatile?
|
||||||
|
1. 可见性: volatile确保了所有线程都能看到instance变量的最新值,当一个线程修改了instance值,这个改变会立即对其他线程可见。
|
||||||
|
2. 禁止指令重排:解决了半初始化的问题,确保instance变量被赋值为非null之前,对象已经被完全初始化。
|
||||||
|
|
||||||
|
```java
|
||||||
|
public class Singleton {
|
||||||
|
private static volatile Singleton instance;
|
||||||
|
private Singleton() {
|
||||||
|
|
||||||
|
}
|
||||||
|
public static Singleton getInstance() {
|
||||||
|
if (instance == null) {
|
||||||
|
synchronized (Singleton.class) {
|
||||||
|
if (instance == null) {
|
||||||
|
instance = new Singleton();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
```
|
||||||
308
src/content/posts/Java/JUC/线程安全单例.md
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
---
|
||||||
|
title: 线程安全单例
|
||||||
|
published: 2025-08-07
|
||||||
|
description: ''
|
||||||
|
image: ''
|
||||||
|
tags: [JUC,JAVA,volatile,线程安全,单例模式]
|
||||||
|
category: 'Java > JUC'
|
||||||
|
draft: false
|
||||||
|
lang: ''
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|

|
||||||
|
# 1 解决反序列化导致的单例破坏现象
|
||||||
|
|
||||||
|
这里的单例问题是,如果对一个可序列化对象进行反序列化,会创建一个新的对象,这就违背了我们想要全局单例的目标。因此要重写readResolve方法。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
```java
|
||||||
|
package org.example.sigletons;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
public class Singleton1 implements Serializable {
|
||||||
|
private Singleton1(){}
|
||||||
|
private static final Singleton1 INSTANCE = new Singleton1();
|
||||||
|
public Singleton1 getInstance() {
|
||||||
|
return INSTANCE;
|
||||||
|
}
|
||||||
|
public Object readResolve() {
|
||||||
|
return INSTANCE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
# 2 使用枚举实现单例模式
|
||||||
|

|
||||||
|
|
||||||
|
```java
|
||||||
|
package org.example.sigletons;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.util.Properties;
|
||||||
|
|
||||||
|
public enum Singleton2 {
|
||||||
|
INSTANCE;
|
||||||
|
private final Properties properties;
|
||||||
|
private Singleton2() {
|
||||||
|
properties = new Properties();
|
||||||
|
String configFile = "application.properties";
|
||||||
|
System.out.println("ConfigurationManager: Initializing and loading " + configFile);
|
||||||
|
try(InputStream inputStream = Singleton2.class.getClassLoader().getResourceAsStream(configFile)){
|
||||||
|
if(inputStream == null){
|
||||||
|
System.out.println("ConfigurationManager: Sorry, unable to find " + configFile);
|
||||||
|
// 在实际应用中,这里可能抛出异常或有更复杂的错误处理
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
properties.load(inputStream);
|
||||||
|
System.out.println("ConfigurationManager: Configuration loaded successfully.");
|
||||||
|
}catch (IOException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public String getProperty(String key) {
|
||||||
|
return properties.getProperty(key);
|
||||||
|
}
|
||||||
|
public String getProperty(String key,String defaultValue) {
|
||||||
|
return properties.getProperty(key, defaultValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 可以添加其他需要的方法,比如重新加载配置等(需要考虑线程安全)
|
||||||
|
public void listProperties() {
|
||||||
|
properties.forEach((key, value) -> System.out.println(key + "=" + value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class TestSingleton2 {
|
||||||
|
public static void main(String[] args) {
|
||||||
|
Singleton2 singleton2 = Singleton2.INSTANCE;
|
||||||
|
singleton2.listProperties();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
|
💎 枚举单例:全面解答这些问题!📜✨
|
||||||
|
|
||||||
|
枚举单例是一种非常推荐的单例实现方式,因为它不仅简单、易用,还天然地具备线程安全和防止反序列化、反射破坏单例的能力。接下来,我们重点针对 **枚举单例** 来回答这些问题!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### **问题 1:枚举单例是如何限制实例个数的?**
|
||||||
|
|
||||||
|
枚举单例通过枚举的机制天然地保证:
|
||||||
|
1. 枚举类的每一个枚举实例(如单例对象)都在 **类加载阶段** 就完成初始化,并且整个应用程序中只有一个实例。
|
||||||
|
2. 枚举类型底层由 JVM 的实现机制保证,它不像普通类那样允许通过反射或 `new` 额外创建实例。
|
||||||
|
|
||||||
|
#### 示例:
|
||||||
|
```java
|
||||||
|
public enum SingletonEnum {
|
||||||
|
INSTANCE; // 枚举单例实例
|
||||||
|
|
||||||
|
public void doSomething() {
|
||||||
|
System.out.println("Doing something...");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 使用方式:
|
||||||
|
即便通过 `SingletonEnum.INSTANCE` 多次获取,得到的始终是同一个实例。
|
||||||
|
```java
|
||||||
|
SingletonEnum instance1 = SingletonEnum.INSTANCE;
|
||||||
|
SingletonEnum instance2 = SingletonEnum.INSTANCE;
|
||||||
|
System.out.println(instance1 == instance2); // 输出:true
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### **问题 2:枚举单例在创建时是否有并发问题?**
|
||||||
|
|
||||||
|
枚举单例天然线程安全,因为:
|
||||||
|
1. 枚举类型的初始化由 JVM 保证,是在类加载时完成的。
|
||||||
|
2. 类加载过程是线程安全的,JVM 使用了类加载的同步机制,保证枚举单例的初始化不会因多线程而发生竞争。
|
||||||
|
|
||||||
|
#### 举例:
|
||||||
|
即使多个线程同时调用 `SingletonEnum.INSTANCE`,它们都会得到在类加载阶段构造好的唯一对象,无需额外同步。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### **问题 3:枚举单例能否被反射破坏单例?**
|
||||||
|
|
||||||
|
**不会!** 枚举类型的结构特殊,无法被反射破坏单例。这是因为:
|
||||||
|
1. 枚举的构造器是私有的,并且其底层会检测反射调用。
|
||||||
|
2. 如果尝试通过反射显式调用枚举类的构造器,JVM 会抛出 `IllegalArgumentException`。
|
||||||
|
|
||||||
|
#### 验证代码:
|
||||||
|
```java
|
||||||
|
import java.lang.reflect.Constructor;
|
||||||
|
|
||||||
|
public class EnumReflectionTest {
|
||||||
|
public static void main(String[] args) {
|
||||||
|
try {
|
||||||
|
// 获取枚举的构造器
|
||||||
|
Constructor<SingletonEnum> constructor = SingletonEnum.class.getDeclaredConstructor();
|
||||||
|
constructor.setAccessible(true);
|
||||||
|
SingletonEnum instance = constructor.newInstance(); // 反射创建枚举对象
|
||||||
|
} catch (Exception e) {
|
||||||
|
e.printStackTrace(); // 会抛出 IllegalArgumentException
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 运行结果:
|
||||||
|
```
|
||||||
|
java.lang.IllegalArgumentException: Cannot reflectively create enum objects
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### **问题 4:枚举单例能否被反序列化破坏单例?**
|
||||||
|
|
||||||
|
枚举单例天然具备防止反序列化破坏的特性,原因是:
|
||||||
|
1. `Enum` 类型的序列化机制是由 JVM 内部实现的,不走普通的对象序列化流程。
|
||||||
|
2. 反序列化枚举对象时,JVM 会直接返回枚举类中的现有实例,而不是从序列化流中创建新对象。
|
||||||
|
|
||||||
|
#### 验证代码:
|
||||||
|
```java
|
||||||
|
import java.io.*;
|
||||||
|
|
||||||
|
public class EnumSerializationTest {
|
||||||
|
public static void main(String[] args) throws IOException, ClassNotFoundException {
|
||||||
|
SingletonEnum instance1 = SingletonEnum.INSTANCE;
|
||||||
|
|
||||||
|
// 序列化枚举对象
|
||||||
|
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("enum_singleton.obj"));
|
||||||
|
oos.writeObject(instance1);
|
||||||
|
oos.close();
|
||||||
|
|
||||||
|
// 反序列化枚举对象
|
||||||
|
ObjectInputStream ois = new ObjectInputStream(new FileInputStream("enum_singleton.obj"));
|
||||||
|
SingletonEnum instance2 = (SingletonEnum) ois.readObject();
|
||||||
|
|
||||||
|
// 判断是否破坏单例
|
||||||
|
System.out.println(instance1 == instance2); // 输出:true,单例没有破坏
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### **问题 5:枚举单例属于懒汉式还是饿汉式?**
|
||||||
|
|
||||||
|
**枚举单例本质上属于饿汉式单例**。它的特点是**在类加载阶段完成初始化**:
|
||||||
|
- 枚举的实例在类加载时就被创建并初始化。
|
||||||
|
- 即使程序中从未访问过 `SingletonEnum.INSTANCE`,枚举实例依然会被加载。
|
||||||
|
|
||||||
|
#### 优点:
|
||||||
|
- 线程安全,无需为单例初始化额外编写同步代码。
|
||||||
|
- 实现简洁,JVM 自动保证。
|
||||||
|
|
||||||
|
#### 缺点:
|
||||||
|
- 如果枚举实例较多,并且包含较大的初始化逻辑,会导致类加载阶段性能开销增加。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### **问题 6:枚举单例如果希望加入一些初始化逻辑,该如何做?**
|
||||||
|
|
||||||
|
可以通过添加枚举的构造方法和静态方法来实现初始化逻辑。枚举的构造方法是私有的,可以用来在实例创建时执行初始化。
|
||||||
|
|
||||||
|
#### 修改代码:
|
||||||
|
```java
|
||||||
|
public enum SingletonEnum {
|
||||||
|
INSTANCE; // 枚举单例实例
|
||||||
|
|
||||||
|
private String configuration;
|
||||||
|
|
||||||
|
// 枚举的构造方法
|
||||||
|
SingletonEnum() {
|
||||||
|
// 初始化逻辑
|
||||||
|
configuration = "System Configuration Loaded";
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getConfiguration() {
|
||||||
|
return configuration;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 测试:
|
||||||
|
```java
|
||||||
|
public class TestEnumInitialization {
|
||||||
|
public static void main(String[] args) {
|
||||||
|
SingletonEnum instance = SingletonEnum.INSTANCE;
|
||||||
|
System.out.println(instance.getConfiguration()); // 输出:System Configuration Loaded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 分析:
|
||||||
|
- 枚举类型的构造器会在类加载时调用,且只调用一次。
|
||||||
|
- 可用枚举构造器实现单例实例的初始化逻辑。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 总结
|
||||||
|
|
||||||
|
**为何枚举单例完美适合单例模式?**
|
||||||
|
- 它是天生线程安全的,JVM 保障了枚举实例的唯一性。
|
||||||
|
- 枚举实例不能通过反射或序列化破坏。
|
||||||
|
- 枚举的初始化流程天然符合饿汉式单例的特点。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 3 Double Check
|
||||||
|
|
||||||
|
https://meowrain.cn/archives/volatile-shi-xian-dan-li-mo-shi-de-shuang-zhong-suo
|
||||||
|
|
||||||
|
|
||||||
|
```java
|
||||||
|
package cn.meowrain;
|
||||||
|
|
||||||
|
public class DoubleSingleton {
|
||||||
|
private static volatile DoubleSingleton INSTANCE = null;
|
||||||
|
public static DoubleSingleton getInstance() {
|
||||||
|
if(INSTANCE != null) {
|
||||||
|
return INSTANCE;
|
||||||
|
}
|
||||||
|
synchronized (DoubleSingleton.class){
|
||||||
|
if(INSTANCE != null) {
|
||||||
|
return INSTANCE;
|
||||||
|
}
|
||||||
|
INSTANCE = new DoubleSingleton();
|
||||||
|
return INSTANCE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
|
# 4 静态内部类懒汉式创建线程安全单例
|
||||||
|
|
||||||
|
```
|
||||||
|
package cn.meowrain;
|
||||||
|
|
||||||
|
public class Singleton2 {
|
||||||
|
private Singleton2(){}
|
||||||
|
// 问题1: 属于懒汉式还是饿汉式
|
||||||
|
private static class LazyLoader{
|
||||||
|
static final Singleton2 INSTANCE = new Singleton2();
|
||||||
|
}
|
||||||
|
// 在创建的时候是否有并发问题
|
||||||
|
public static Singleton2 getInstance() {
|
||||||
|
return LazyLoader.INSTANCE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
```
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
117
src/content/posts/Java/JVM/GC相关参数.md
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
---
|
||||||
|
title: JVM GC相关参数
|
||||||
|
published: 2025-07-18
|
||||||
|
description: ''
|
||||||
|
image: ''
|
||||||
|
tags: [GC,JAVA,JVM]
|
||||||
|
category: 'Java > JVM'
|
||||||
|
draft: false
|
||||||
|
lang: ''
|
||||||
|
---
|
||||||
|
# GC相关参数
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1. 堆初始大小 (`Xms`)
|
||||||
|
|
||||||
|
- **参数:** `Xms<size>`
|
||||||
|
- **含义:** 设置 JVM 启动时**初始分配的堆内存大小**。
|
||||||
|
- **作用:**
|
||||||
|
- 决定了 Java 程序一启动时,JVM 向操作系统申请的内存大小。
|
||||||
|
- 如果设置得太小,JVM 可能会在程序运行初期频繁地进行堆内存扩展,这会带来一定的性能开销。
|
||||||
|
- **示例:** `Xms512m` 表示设置初始堆大小为 512MB。
|
||||||
|
- **最佳实践:** 在生产环境中,通常建议将 `Xms` 和 `Xmx` 设置为相同的值,以避免堆的动态扩展和收缩带来的性能抖动。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. 堆最大大小 (`Xmx` 或 `XX:MaxHeapSize`)
|
||||||
|
|
||||||
|
- **参数:** `Xmx<size>` 或 `XX:MaxHeapSize=<size>`
|
||||||
|
- **含义:** 设置 JVM **允许分配的最大堆内存大小**。
|
||||||
|
- **作用:**
|
||||||
|
- 这是堆内存的硬性上限。如果应用程序需要的内存超过了这个值,就会抛出 `java.lang.OutOfMemoryError`。
|
||||||
|
- 合理设置此值可以防止应用程序因内存泄漏等问题耗尽所有服务器内存,从而影响其他进程。
|
||||||
|
- **示例:** `Xmx2g` 表示设置最大堆大小为 2GB。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. 新生代大小 (`Xmn` 或 `XX:NewSize` + `XX:MaxNewSize`)
|
||||||
|
|
||||||
|
- **参数:** `Xmn<size>`
|
||||||
|
- **含义:** 设置**新生代(Young Generation)的大小**。这是一个快捷参数。
|
||||||
|
- **作用:**
|
||||||
|
- 新生代是绝大多数新对象产生的地方,也是 Minor GC 发生的主要区域。
|
||||||
|
- 设置一个合理的新生代大小非常重要。
|
||||||
|
- **过小:** 会导致 Minor GC 过于频繁。
|
||||||
|
- **过大:** 会挤占老年代的空间,可能导致更频繁的 Full GC。同时,单次 Minor GC 的时间可能会变长。
|
||||||
|
- **补充:** `Xmn` 实际上是同时设置了 `XX:NewSize`(新生代初始大小)和 `XX:MaxNewSize`(新生代最大大小)。如果希望新生代大小动态变化,可以分别设置这两个参数。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. 幸存区比例 (`XX:SurvivorRatio`)
|
||||||
|
|
||||||
|
- **参数:** `XX:SurvivorRatio=<ratio>`
|
||||||
|
- **含义:** 设置新生代中 **Eden 区与一个 Survivor 区的大小比例**。
|
||||||
|
- **计算公式:** `ratio = Eden区大小 / Survivor区大小`。
|
||||||
|
- **作用:**
|
||||||
|
- 这个比例决定了新生代中用于创建新对象(Eden)和存放幸存对象(Survivor)的空间分配。
|
||||||
|
- 例如,`XX:SurvivorRatio=8` 表示 Eden:S0:S1 的比例是 8:1:1。这意味着 Eden 区将占用新生代 8/10 的空间,而每个 Survivor 区占用 1/10。
|
||||||
|
- **注意:** 这个参数在启用了自适应大小策略(`XX:+UseAdaptiveSizePolicy`,在某些 GC 算法中默认开启)时,其设置的固定比例可能会被 JVM 动态调整。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. 幸存区比例 (动态) (`XX:InitialSurvivorRatio` 和 `XX:+UseAdaptiveSizePolicy`)
|
||||||
|
|
||||||
|
- **参数:** `XX:+UseAdaptiveSizePolicy`
|
||||||
|
- **含义:** **启用 GC 自适应大小策略**。这个策略允许 JVM 根据应用程序的运行情况(如吞吐量、停顿时间目标)动态调整堆中各区域的大小,包括 Eden/Survivor 的比例。
|
||||||
|
- **作用:**
|
||||||
|
- 开启后,JVM 会自动优化内存分配,省去了手动精细调优的麻烦。这是 Parallel GC 等收集器默认开启的。
|
||||||
|
- `XX:InitialSurvivorRatio` 用于设定自适应策略下的**初始** SurvivorRatio 值,后续 JVM 可能会根据需要进行调整。
|
||||||
|
- **结论:** 如果你看到这个参数,意味着 JVM 正在自动管理新生代的比例,`XX:SurvivorRatio` 的静态设置可能不会生效。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. 晋升阈值 (`XX:MaxTenuringThreshold`)
|
||||||
|
|
||||||
|
- **参数:** `XX:MaxTenuringThreshold=<threshold>`
|
||||||
|
- **含义:** 设置对象从新生代晋升到老年代的**年龄阈值**。
|
||||||
|
- **作用:**
|
||||||
|
- 一个对象在 Survivor 区每熬过一次 Minor GC,其年龄就加 1。当年龄达到这个阈值时,就会被移动到老年代。
|
||||||
|
- 默认值通常是 15(或 6,取决于 GC)。
|
||||||
|
- 如果设置得太高,对象可能长时间停留在 Survivor 区,增加了复制成本;如果设置得太低,可能导致一些生命周期不长的对象过早进入老年代,增加了 Full GC 的压力。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. 晋升详情 (`XX:+PrintTenuringDistribution`)
|
||||||
|
|
||||||
|
- **参数:** `XX:+PrintTenuringDistribution`
|
||||||
|
- **含义:** 一个诊断参数,用于在每次 Minor GC 后**打印出 Survivor 区中对象的年龄分布情况**。
|
||||||
|
- **作用:**
|
||||||
|
- 这是调优 `XX:MaxTenuringThreshold` 的重要工具。
|
||||||
|
- 通过观察日志,你可以看到每个年龄段有多少对象,以及 JVM 计算出的动态晋升阈值,从而判断当前设置是否合理。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8. GC 详情 (`XX:+PrintGCDetails` 和 `verbose:gc`)
|
||||||
|
|
||||||
|
- **参数:** `XX:+PrintGCDetails` 或 `verbose:gc`
|
||||||
|
- **含义:** 打印详细的 GC 日志信息。
|
||||||
|
- **作用:**
|
||||||
|
- 这是进行 GC 性能分析和故障排查的**必备参数**。
|
||||||
|
- `verbose:gc` 是一个标准参数,输出基本的 GC 信息。
|
||||||
|
- `XX:+PrintGCDetails` 会提供更详尽的信息,包括每次 GC 前后堆各区域的大小、GC 耗时等。
|
||||||
|
- **推荐:** 通常与 `XX:+PrintGCTimeStamps` 或 `XX:+PrintGCDateStamps` 一起使用,为日志增加时间戳。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9. FullGC 前 MinorGC (`XX:+ScavengeBeforeFullGC`)
|
||||||
|
|
||||||
|
- **参数:** `XX:+ScavengeBeforeFullGC`
|
||||||
|
- **含义:** 指示 JVM 在执行 Full GC 之前,先强制进行一次 Minor GC。
|
||||||
|
- **作用:**
|
||||||
|
- 理论上,这可以清理掉新生代中大部分可以被回收的对象,从而减轻 Full GC 的负担,因为 Full GC 需要处理整个堆(包括新生代)。
|
||||||
|
- **注意:**
|
||||||
|
- 此参数在现代的 GC(如 G1)中已不推荐使用或被废弃,因为它们有更智能的回收策略。
|
||||||
|
- 在某些情况下,它可能会引入一次额外的、不必要的停顿(Minor GC 的停顿)。因此,除非有明确的测试数据支持,否则一般不建议开启。
|
||||||
31
src/content/posts/Java/JVM/JVM内存模型分区.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
---
|
||||||
|
title: JVM内存模型分区
|
||||||
|
published: 2025-07-18
|
||||||
|
description: ''
|
||||||
|
image: ''
|
||||||
|
tags: [JVM,内存模型]
|
||||||
|
category: 'Java > JVM'
|
||||||
|
draft: false
|
||||||
|
lang: ''
|
||||||
|
---
|
||||||
|
# JVM内存模型分⼏个区,每个区放什么对象
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
分为方法区,堆,本地方法栈,虚拟机栈,程序计数器
|
||||||
|
|
||||||
|
方法区(元空间):用于存储已经被虚拟机加载的类信息,常量,静态变量等数据。虽然方法区被描述为堆的逻辑部分,但有”非堆“的别名。方法区可以选择不实现垃圾收集,内存不足的时候,会抛出OutOfMemoryError异常。
|
||||||
|
|
||||||
|
程序计数器: 当前线程所执行的字节码的行号指示器,存储当前线程正在执行的Java方法的JVM指令地址。
|
||||||
|
|
||||||
|
JVM虚拟机栈:每个线程都有自己独立的Java虚拟机栈,生命周期和线程相同,每个方法在执行的时候都会创建一个栈帧,用来存储局部变量表,操作数栈,动态链接,方法出口等信息。
|
||||||
|
|
||||||
|
本地方法栈: 与Java虚拟机栈差不读多,执行本地方法,其中堆和方法区是线程共有的。
|
||||||
|
|
||||||
|
Java堆: 存放和管理对象实例,被所有线程共享。
|
||||||
148
src/content/posts/Java/JVM/JVM垃圾回收算法.md
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
---
|
||||||
|
title: JVM垃圾回收算法
|
||||||
|
published: 2025-07-18
|
||||||
|
description: ''
|
||||||
|
image: ''
|
||||||
|
tags: [JVM,垃圾回收,分代回收]
|
||||||
|
category: 'Java > JVM'
|
||||||
|
draft: false
|
||||||
|
lang: ''
|
||||||
|
---
|
||||||
|
# 垃圾回收算法
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
[【Java虚拟机】JVM垃圾回收机制和常见回收算法原理-腾讯云开发者社区-腾讯云](https://cloud.tencent.com/developer/article/2292267)
|
||||||
|
|
||||||
|
### **垃圾回收机制**
|
||||||
|
|
||||||
|
**(1)什么是垃圾回收机制(Garbage Collection, 简称GC)**
|
||||||
|
|
||||||
|
- 指自动管理动态分配的内存空间的机制,自动回收不再使用的内存,以避免内存泄漏和内存溢出的问题
|
||||||
|
- 最早是在1960年代提出的,程序员需要手动管理内存的分配和释放
|
||||||
|
- 这往往会导致内存泄漏和内存溢出等问题,同时也增加了程序员的工作量,特别是C++/C语言开发的时候
|
||||||
|
- Java语言是最早实现垃圾回收机制的语言之一,其他编程语言,如C#、Python和Ruby等,也都提供了垃圾回收机制
|
||||||
|
|
||||||
|
**(2)JVM自动垃圾回收机制**
|
||||||
|
|
||||||
|
- 指Java虚拟机在运行Java程序时,自动回收不再使用的对象所占用的内存空间的过程
|
||||||
|
- Java程序中的对象,一旦不再被引用会被标记为垃圾对象,JVM会在适当的时候自动回收这些垃圾对象所占用的内存空间
|
||||||
|
- 优点
|
||||||
|
- 减少了程序员的工作量,不需要手动管理内存
|
||||||
|
- 动态地管理内存,根据应用程序的需要进行分配和回收,提高了内存利用率
|
||||||
|
- 避免内存泄漏和野指针等问题,增加程序的稳定性和可靠
|
||||||
|
- 缺点
|
||||||
|
- 垃圾回收会占用一定的系统资源,可能会影响程序的性能
|
||||||
|
- 垃圾回收过程中会停止程序的执行,可能会导致程序出现卡顿等问题
|
||||||
|
- **不一定能够完全解决内存泄漏等问题,需要在编写代码时注意内存管理和编码规范**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 垃圾回收算法
|
||||||
|
|
||||||
|
## 引用计数法
|
||||||
|
|
||||||
|
跟踪每个对象被引用的次数,当引用次数为0 的时候,可以将该对象回收。
|
||||||
|
|
||||||
|
优点是实现简单,缺点是循环引用没办法回收,而且引用计数器消耗大。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## **可达性分析算法**
|
||||||
|
|
||||||
|
- 可达性分析算法的基本思想是通过一系列的“GC Roots”对象作为起点进行搜索。
|
||||||
|
- 如果“GC Roots”和一个对象之间没有可达路径,则称该对象是不可达的,不过要注意的是被判定为不可达的对象不一定就会成为可回收对象。
|
||||||
|
- 被判定为不可达的对象要成为回收对象,要至少经历两次标记过程。
|
||||||
|
- 如果在这两次标记过程中仍然没有逃脱成为可回收对象的可能性,则基本上就真的成为可回收对象了。
|
||||||
|
|
||||||
|
通过一系列称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索走过的路径称为“引用链”,当一个对象到 GC Roots 没有任何的引用链相连时(从 GC Roots 到这个对象不可达)时,证明此对象不可用。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 什么是GC ROOT
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## **垃圾回收算法之标记-复制算法**
|
||||||
|
|
||||||
|
- 标记算法是一种常见的垃圾回收算法,它的基本思路是将Java堆分为两个区域:一个活动区域和一个空闲区域
|
||||||
|
- 在垃圾回收过程中,首先标记所有被引用的对象
|
||||||
|
- 然后将所有被标记的对象复制到空闲区域中,最后交换两个区域的角色,完成垃圾回收
|
||||||
|
- 标记复制算法的详细实现步骤
|
||||||
|
- 将Java堆分为两个区域:一个活动区域和一个空闲区域,初始时,所有对象都分配在活动区域中
|
||||||
|
- 从GC Roots对象开始,遍历整个对象图,标记所有被引用的对象
|
||||||
|
- 对所有被标记存活的对象进行遍历,将它们复制到空闲区域中,并更新所有指向它们的引用,使它们指向新的地址
|
||||||
|
- 对所有未被标记的对象进行回收,将它们所占用的内存空间释放
|
||||||
|
- 交换活动区域和空闲区域的角色,空闲区域变为新的活动区域,原来的活动区域变为空闲区域
|
||||||
|
- 当空闲区域的内存空间不足时,进行一次垃圾回收,重复以上步骤。
|
||||||
|
- 优点
|
||||||
|
- 如果内存中的垃圾对象较多,需要复制的对象就较少,则效率高
|
||||||
|
- 清理后,内存碎片少
|
||||||
|
- 缺点
|
||||||
|
- 标记复制算法的效率较高,但是预留一半的内存区域用来存放存活的对象,占用额外的内存空间
|
||||||
|
- 如果出现存活对象数量比较多的时候,需要复制较多的对象 效率低
|
||||||
|
- 假如是在老年代区域,99%的对象都是存活的,则性能底,所以老年代不适合这个算法
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
复制过程如下,GC会将五个存活对象复制到to区,并且保证在to区内存空间上的连续性。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
最后,将from区中的垃圾对象清除。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## **垃圾回收算法之标记-整理算法**
|
||||||
|
|
||||||
|
标记-整理算法(Mark-Compact Algorithm) 是一种常见的垃圾回收(GC)算法,主要用于解决 标记-清除算法(Mark-Sweep) 产生的内存碎片问题。它通常被用于 Java 的老年代(Old Generation)垃圾回收中。
|
||||||
|
|
||||||
|
标记-整理算法主要分为两大阶段:
|
||||||
|
|
||||||
|
标记阶段(Mark Phase)
|
||||||
|
|
||||||
|
和标记-清除算法一样,从 GC Roots 出发,遍历所有可达对象,并将其标记为“存活”状态。
|
||||||
|
|
||||||
|
整理阶段(Compact Phase)
|
||||||
|
|
||||||
|
将所有存活对象向内存的一端移动(通常是低地址方向)。
|
||||||
|
|
||||||
|
移动后会更新对象引用地址,以保证程序继续正确运行。
|
||||||
|
|
||||||
|
移动完成后,直接清理边界以后的内存空间。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
| **特点** | **标记-清除算法** | **标记-整理算法** |
|
||||||
|
| ------ | ------------- | ------------- |
|
||||||
|
| 内存碎片 | 会产生碎片 | 不会产生碎片 |
|
||||||
|
| 效率 | 清除快(只清除不可达对象) | 较慢(需要移动对象) |
|
||||||
|
| 适用场景 | 适用于对象回收率较高的情况 | 适用于对象存活率较高的情况 |
|
||||||
|
|
||||||
|
## 垃圾回收算法之-分代算法
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
新生代分为eden区、from区、to区,老年代是一整块内存空间
|
||||||
|
|
||||||
|
分代算法将内存区域分为两部分:新生代和老年代。
|
||||||
|
|
||||||
|
根据新生代和老年代中对象的不同特点,使用不同的GC算法。
|
||||||
|
|
||||||
|
新生代对象的特点是:创建出来没多久就可以被回收(例如虚拟机栈中创建的对象,方法出栈就会销毁)。也就是说,每次回收时,大部分是垃圾对象,所以新生代适用于复制算法。
|
||||||
|
|
||||||
|
老年代的特点是:经过多次GC,依然存活。也就是说,每次GC时,大部分是存活对象,所以老年代适用于标记压缩算法。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 分代算法执行过程
|
||||||
|
|
||||||
|
---
|
||||||
40
src/content/posts/Java/JVM/Java类加载器与双亲委派机制.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
---
|
||||||
|
title: Java类加载器与双亲委派机制
|
||||||
|
published: 2025-07-18
|
||||||
|
description: ''
|
||||||
|
image: ''
|
||||||
|
tags: [Java,类加载器,ClassLoader,双亲委派机制]
|
||||||
|
category: 'Java > JVM'
|
||||||
|
draft: false
|
||||||
|
lang: ''
|
||||||
|
---
|
||||||
|
# Java类加载器和双亲委派机制
|
||||||
|
|
||||||
|
[Java 虚拟机之类加载](https://dunwu.github.io/waterdrop/pages/3e37ea6e/#%E7%B1%BB%E5%8A%A0%E8%BD%BD%E6%9C%BA%E5%88%B6)
|
||||||
|
|
||||||
|
## Java类加载器层级
|
||||||
|
|
||||||
|
Java类加载器从高到低分为以下层级(以JDK 8为例):
|
||||||
|
|
||||||
|
1. **启动类加载器(Bootstrap ClassLoader)** :加载JRE的`lib`目录下的核心类库(如`rt.jar`)。
|
||||||
|
2. **扩展类加载器(Extension ClassLoader)** :加载`lib/ext`目录下的扩展类。
|
||||||
|
3. **应用程序类加载器(Application ClassLoader)** :加载用户类路径(ClassPath)下的类。
|
||||||
|
4. **自定义类加载器**:用户可自定义类加载器(需继承`ClassLoader`)。
|
||||||
|
|
||||||
|
## 什么是双亲委派机制
|
||||||
|
|
||||||
|
**双亲委派机制** 是Java类加载器的核心工作机制。它的核心思想是:当一个类加载器需要加载某个类时,不会直接尝试自己加载,而是将这个请求**逐级向上委托给父类加载器**处理。只有当所有父类加载器都无法完成加载时,子类加载器才会尝试自己加载。
|
||||||
|
|
||||||
|
## ## 示意图
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
[深入理解Java双亲委派机制:原理、意义与实战示例 - 云熙橙 - 博客园](https://www.cnblogs.com/xchangting/articles/18744083)
|
||||||
|
|
||||||
|
## 双亲委派机制的好处
|
||||||
|
|
||||||
|
1. **保障核心类库的安全**防止用户自定义的类(如`java.lang.Object`)覆盖JVM核心类。例如,如果用户编写了一个恶意`String`类,双亲委派机制会优先加载核心库中的`String`,从而避免安全隐患。
|
||||||
|
2. **避免重复加载**同一个类只会被一个类加载器加载一次,防止内存中出现多个相同类的副本,确保类的唯一性。
|
||||||
|
3. **实现代码隔离**不同类加载器加载的类属于不同的命名空间,天然支持模块化(如Tomcat为每个Web应用分配独立的类加载器)。
|
||||||
79
src/content/posts/Java/JVM/Jvm分代回收机制.md
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
---
|
||||||
|
title: Jvm分代回收机制
|
||||||
|
published: 2025-07-18
|
||||||
|
description: ''
|
||||||
|
image: ''
|
||||||
|
tags: [分代回收,JVM]
|
||||||
|
category: 'Java > JVM'
|
||||||
|
draft: false
|
||||||
|
lang: ''
|
||||||
|
---
|
||||||
|
# 分代回收
|
||||||
|
|
||||||
|
[juejin.cn](https://juejin.cn/post/7474503566154858536)
|
||||||
|
|
||||||
|
[【GC系列】JVM堆内存分代模型及常见的垃圾回收器-腾讯云开发者社区-腾讯云](https://cloud.tencent.com/developer/article/1755848)
|
||||||
|
|
||||||
|
[Eden与Survivor区 · Homurax's Blog](https://blog.homurax.com/2018/09/17/eden-survivor/)
|
||||||
|
|
||||||
|
[Java 虚拟机之垃圾收集](https://dunwu.github.io/waterdrop/pages/587898a0/)
|
||||||
|
|
||||||
|
[JVM内存分配策略](https://linqiankun.github.io/hexoblog/md/jvm/JVM%E5%86%85%E5%AD%98%E5%88%86%E9%85%8D%E7%AD%96%E7%95%A5/)
|
||||||
|
|
||||||
|
现代JVM堆内存的典型划分:
|
||||||
|
|
||||||
|
1. 年轻代(Young Generation)
|
||||||
|
2. 老年代(Old Generation)
|
||||||
|
3. 永久代/元空间(Permanent Gen/Metaspace)
|
||||||
|
|
||||||
|
## JDK7堆空间内部结构
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
特点:
|
||||||
|
|
||||||
|
永久代位于堆内存中
|
||||||
|
|
||||||
|
字符串常量池存放在永久代
|
||||||
|
|
||||||
|
方法区使用永久代实现
|
||||||
|
|
||||||
|
## JDK8堆空间内部结构
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
永久代被元空间替换,元空间不属于堆内存。
|
||||||
|
|
||||||
|
元空间使用本地内存
|
||||||
|
|
||||||
|
字符串常量池移至堆内存
|
||||||
|
|
||||||
|
方法区改由元空间实现。
|
||||||
|
|
||||||
|
## 年轻代与老年代
|
||||||
|
|
||||||
|
JVM 内置的通用垃圾回收原则。堆内存划分为 Eden、Survivor(年轻代) , Tenured/Old (老年代)空间:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
核心规则:
|
||||||
|
|
||||||
|
1. 对象优先在Eden区分配
|
||||||
|
2. 大对象直接进入老年代
|
||||||
|
3. 长期存活对象进入老年代(默认年龄阈值15)
|
||||||
|
4. 动态年龄判断(Survivor区中相同年龄对象总和超过50%时候晋升)
|
||||||
|
|
||||||
|
在 JVM 中,**年龄阈值(Tenuring Threshold)** 是一个关键的参数,它决定了新生代(Young Generation)中的对象需要经历多少次垃圾回收(Minor GC)仍然存活,才会被晋升(Promotion)到老年代(Old Generation)。
|
||||||
|
|
||||||
|
年轻代分为Eden区和Survivor区,Survivor区又分为S0,S1,S0,S1其中一个作为使用区(from),一个作为空闲区(to)(不固定,可能S0是空闲区,也可能是使用区)
|
||||||
|
在Minor GC开始以后(会回收Eden区和使用区中的对象),逃过第一轮GC的,在Eden区和使用区中的对象,会被丢在空闲区,接下来将使用区和空闲区互换(空闲区变使用区,使用区变空闲区),等待下一次Eden区满进行Minor GC,以此不断循环(每复制一次,年龄就会 + 1)
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
# 堆空间大小设置
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
44
src/content/posts/Java/JVM/Jvm常见垃圾收集器.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
---
|
||||||
|
title: Jvm常见垃圾收集器
|
||||||
|
published: 2025-07-18
|
||||||
|
description: ''
|
||||||
|
image: ''
|
||||||
|
tags: [Java,JVM,垃圾收集器]
|
||||||
|
category: 'Java > JVM'
|
||||||
|
draft: false
|
||||||
|
lang: ''
|
||||||
|
---
|
||||||
|
# Java中常见的垃圾收集器
|
||||||
|
|
||||||
|
GC收集器有哪些?
|
||||||
|
|
||||||
|
1.serial收集器
|
||||||
|
单线程,工作时必须暂停其他工作线程。多用于client机器上,使用复制算法
|
||||||
|
2.ParNew收集器
|
||||||
|
serial收集器的多线程版本,server模式下虚拟机首选的新生代收集器。复制算法
|
||||||
|
3.Parallel Scavenge收集器
|
||||||
|
复制算法,可控制吞吐量的收集器。吞吐量即有效运行时间。
|
||||||
|
4.Serial Old收集器
|
||||||
|
serial的老年代版本,使用整理算法。
|
||||||
|
5.Parallel Old收集器
|
||||||
|
第三种收集器的老年代版本,多线程,标记整理
|
||||||
|
6.CMS收集器
|
||||||
|
目标是最短回收停顿时间。
|
||||||
|
|
||||||
|
7.G1收集器,基本思想是化整为零,将堆分为多个Region,优先收集回收价值最大的Region。
|
||||||
|
|
||||||
|
[垃圾收集器_java垃圾收集器-CSDN博客](https://blog.csdn.net/binbinxyz/article/details/141821712)
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
# G1垃圾回收器
|
||||||
|
|
||||||
|
[G1垃圾回收](Java%E4%B8%AD%E5%B8%B8%E8%A7%81%E7%9A%84%E5%9E%83%E5%9C%BE%E6%94%B6%E9%9B%86%E5%99%A8%2022a49a1194e98020a75ced52b5d871d7/G1%E5%9E%83%E5%9C%BE%E5%9B%9E%E6%94%B6%2022a49a1194e980e9bbf7e2a6c0f3e4c6.md)
|
||||||
24
src/content/posts/Java/Java内存模型.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
---
|
||||||
|
title: Java内存模型
|
||||||
|
published: 2025-09-13
|
||||||
|
description: ' Java内存模型 '
|
||||||
|
image: ''
|
||||||
|
tags: ['JMM','Java内存模型']
|
||||||
|
category: 'Java > 面试题'
|
||||||
|
draft: false
|
||||||
|
lang: ''
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
Java内存模型,是Java虚拟机定义的一种规范,用来描述多线程程序中的变量如何在内存中读取数据,何时会把数据写回主内存。
|
||||||
|
|
||||||
|
JMM的核心目标是确保多线程环境下的**可见性,有序性和原子性**,从而避免由于硬件和编译器优化带来的不一致问题。
|
||||||
|
- 可见性:确保一个线程对共享变量的修改,其他线程能够及时看到。关键字 `volatile`是用来保证可见性的,它强制线程每次读写的时候都从主内存中获取最新值。
|
||||||
|
- 有序性:确保程序执行的顺序符合代码的书写顺序。JMM允许某些指令重排序,来提高性能,但会保证线程内的操作顺序不会被破坏,通过happens-before关系保证跨线程的有序性。
|
||||||
|
- 原子性:确保操作的不可分割性,要么全部成功,要么全部失败。 例如synchronized关键字能确保方法或者代码块的原子性。
|
||||||
|
|
||||||
|
|
||||||
|
## 参考
|
||||||
|
JMM 会把内存分为本地内存和主存,每个线程都有它自己的私有化的本地内存,还有个存储共享数据的主存。
|
||||||
|
|
||||||
|

|
||||||