# 异步编程
## Promise
`Promise` 是一个对象,代表一个尚未完成但最终会完成(或失败)的异步操作的结果。它有三种状态:
- **Pending (进行中)**: 初始状态,既不是成功,也不是失败。
- **Fulfilled (已成功)**: 意味着操作成功完成。
- **Rejected (已失败)**: 意味着操作失败。
> \[!WARNING]
>
> Promise
>
> 的状态一旦从
>
> Pending
>
> 变为
>
> Fulfilled
>
> 或
>
> Rejected
>
> ,就不可再改变。这确保了异步结果的稳定性和一致性。
```ts [example.ts]
// 创建一个 Promise,模拟一个耗时 1 秒的异步操作
const myPromise = new Promise((resolve, reject) => {
console.log('Promise 开始执行')
setTimeout(() => {
// 模拟成功,并返回结果
resolve('操作成功')
// 以下调用将被忽略,因为 Promise 状态已确定
// reject('操作失败');
}, 1000)
})
// 使用 .then() 处理成功情况,.catch() 处理失败情况
myPromise
.then((result) => {
// result 的值是 '操作成功'
console.log(`成功: ${result}`)
})
.catch((error) => {
// 如果 Promise 被 reject,这里会执行
console.error(`失败: ${error}`)
})
.finally(() => {
// 无论成功还是失败,都会执行
console.log('Promise 执行完毕')
})
```
## Async/Await
`async/await` 是基于 `Promise` 的语法糖,它让异步代码看起来和同步代码一样直观易读。
- **`async` 函数**: `async` 关键字用于声明一个异步函数。该函数会隐式地返回一个 `Promise`。
- **`await` 操作符**: `await` 关键字只能在 `async` 函数内部使用,它会暂停函数的执行,等待一个 `Promise` 被 `resolve`,然后返回 `Promise` 的结果。如果 `Promise` 被 `reject`,它会抛出异常。
```ts [example.ts]
// 定义一个返回 Promise 的函数
function delayedMessage(message, delay) {
return new Promise(resolve => setTimeout(() => resolve(message), delay))
}
// 使用 async/await 调用
async function greet() {
console.log('开始打招呼...')
try {
const message = await delayedMessage('你好,世界!', 2000)
console.log(message) // 2秒后输出: 你好,世界!
}
catch (error) {
console.error('打招呼时发生错误:', error)
}
finally {
console.log('打招呼流程结束。')
}
}
greet()
```
> \[!TIP]
>
> 使用
>
> try...catch
>
> 结构来处理
>
> await
>
> 可能抛出的错误,这比
>
> .catch()
>
> 链式调用更符合传统同步代码的错误处理逻辑。
## 并行与串行执行
借助 `Promise` 的能力,我们可以灵活控制多个异步操作的执行顺序。
### 串行执行
使用 `await` 可以轻松实现异步操作的串行执行,即一个操作完成后再开始下一个。
```ts [example.ts]
async function serialTasks() {
console.time('serialTasks')
console.log('开始执行串行任务')
const result1 = await delayedMessage('任务1完成', 1000)
console.log(result1)
const result2 = await delayedMessage('任务2完成', 1000)
console.log(result2)
console.log('串行任务全部完成')
console.timeEnd('serialTasks') // 大约 2000ms
}
serialTasks()
```
### 并行执行
当多个异步操作互不依赖时,使用 `Promise.all()` 可以让它们并行执行,从而提高效率。`Promise.all()` 接收一个 `Promise` 数组,当所有 `Promise` 都成功时,它会返回一个包含所有结果的数组。
```ts [example.ts]
async function parallelTasks() {
console.time('parallelTasks')
console.log('开始执行并行任务')
const tasks = [
delayedMessage('任务A完成', 1000),
delayedMessage('任务B完成', 1500)
]
try {
const results = await Promise.all(tasks)
console.log('并行任务全部完成:', results) // ['任务A完成', '任务B完成']
}
catch (error) {
console.error('并行任务中出现错误:', error)
}
console.timeEnd('parallelTasks') // 大约 1500ms
}
parallelTasks()
```
# CSS 高级特性
## CSS 变量
CSS 自定义属性(CSS 变量)允许我们在样式表中声明可复用的值,极大地增强了代码的灵活性和可维护性,特别是在主题切换和组件化开发中。
### 变量回退
`var()` 函数支持回退值,当主要变量未定义时,浏览器会使用第二个参数作为备用值。
```css
:root {
--primary-color: red; /* 主要颜色 */
--secondary-color: blue; /* 备用颜色 */
/* 如果 --primary-color 未定义,则使用 --secondary-color */
--chosen-color: var(--primary-color, var(--secondary-color));
}
.element {
background-color: var(--chosen-color);
}
```
> \[!TIP]
>
> var()
>
> 函数的第二个参数是回退值,它仅在第一个变量无效或未定义时生效。
### 条件样式
通过属性选择器,我们可以根据 DOM 状态(如 `data-theme`)动态地改变 CSS 变量的值,从而实现主题切换等条件样式。
```css [conditional-selection.css]
:root {
--primary-color: red;
}
/* 当 body 具有 data-theme="primary" 属性时应用 */
body[data-theme='primary'] {
--chosen-color: var(--primary-color);
}
.element {
background-color: var(--chosen-color);
}
```
```html [structure.html]
This element has the primary color as background.
```
## Flexbox 响应式布局
Flexbox 提供了一套强大的工具集,用于在不同屏幕尺寸下创建灵活且响应迅速的布局。
### 自适应网格
通过 `flex` 属性可以实现子元素根据容器宽度自适应排列,常用于创建响应式网格布局。
```css [layout.css]
.container {
display: flex;
flex-wrap: wrap;
gap: 10px; /* 子元素之间的间距 */
}
.item {
flex: 1 1 calc(25% - 10px); /* 基于 4 列布局,自动换行 */
box-sizing: border-box; /* 包含 padding 和 border */
}
```
```html [structure.html]
```
> \[!NOTE]
>
> 使用
>
> calc()
>
> 函数可以精确计算包含
>
> gap
>
> 在内的子元素宽度,
>
> box-sizing: border-box
>
> 能确保
>
> padding
>
> 和
>
> border
>
> 被包含在宽度计算之内,简化布局逻辑。
# 全局变量
> \[!NOTE]
> See: https\://ts.xcatliu.com/basics/declaration-files.html#%E5%9C%A8-npm-%E5%8C%85%E6%88%96-umd-%E5%BA%93%E4%B8%AD%E6%89%A9%E5%B1%95%E5%85%A8%E5%B1%80%E5%8F%98%E9%87%8F
>
> TypeScript 声明文件 - 在 NPM 包或 UMD 库中扩展全局变量
## 扩展全局变量
> \[!TIP]
>
> 使用
>
> declare global
>
> 。这对于为未提供类型定义的第三方库补充类型非常有用。
```ts [index.ts]
import JSEncrypt from 'jsencrypt'
const encrypt = new JSEncrypt()
encrypt.setPublicKey('publicKey')
encrypt.encrypt('hello')
```
```ts [d.ts]
declare global {
interface JSEncrypt {
setPublicKey: (publicKey: string) => void
setPrivateKey: (privateKey: string) => void
encrypt: (value: string) => string
decrypt: (value: string) => string
getPublicKey: () => string
getPrivateKey: () => string
}
}
```
## 全局组件类型
> \[!NOTE]
>
> 为了让 TypeScript 识别并正确提示全局注册的组件(如
>
> Element Plus
>
> 或
>
> Ant Design Vue
>
> 的组件),可以在
>
> tsconfig.json
>
> 中通过
>
> types
>
> 字段指定全局组件类型定义文件的位置。
```json [tsconfig.json]
{
"compilerOptions": {
"types": [
"element-plus/global",
"ant-design-vue/typings/global"
]
}
}
```
# 网络请求
## 基本用法
`fetch()` 方法的第一个参数是要请求的资源的 URL。它会返回一个 `Promise`,该 `Promise` 在接收到服务器的响应头后 `resolve` 为一个 `Response` 对象。
```ts
async function fetchData(url) {
try {
const response = await fetch(url)
// response.ok 检查 HTTP 状态码是否在 200-299 范围内
if (!response.ok) {
throw new Error(`HTTP 错误!状态: ${response.status}`)
}
// response.json() 读取响应体并解析为 JSON
const data = await response.json()
console.log(data)
return data
}
catch (error) {
console.error('无法获取数据:', error)
}
}
// 示例:从公共 API 获取用户数据
fetchData('https://jsonplaceholder.typicode.com/users/1')
```
## 处理响应
`Response` 对象提供了多种方法来处理不同格式的响应体:
- **`response.json()`**: 解析响应体为 JSON 对象。
- **`response.text()`**: 将响应体作为纯文本读取。
- **`response.blob()`**: 将响应体处理为 `Blob` 对象,用于处理图片、音频等二进制文件。
- **`response.formData()`**: 将响应体处理为 `FormData` 对象。
- **`response.arrayBuffer()`**: 将响应体处理为 `ArrayBuffer` 对象,用于处理通用的二进制数据。
## 配置请求
`fetch()` 方法可以接受第二个可选参数,一个 `init` 配置对象,用于自定义请求。
```ts
async function postData(url, data) {
try {
const response = await fetch(url, {
// 请求方法
method: 'POST',
// 请求头
headers: {
'Content-Type': 'application/json'
},
// 请求体,必须是字符串
body: JSON.stringify(data)
})
if (!response.ok) {
throw new Error(`HTTP 错误!状态: ${response.status}`)
}
const responseData = await response.json()
console.log('成功:', responseData)
return responseData
}
catch (error) {
console.error('无法发送数据:', error)
}
}
// 示例:向 API 发送一个新的帖子
const newPost = {
title: 'foo',
body: 'bar',
userId: 1
}
postData('https://jsonplaceholder.typicode.com/posts', newPost)
```
### `init` 对象常用选项
- **`method`**: 请求方法,如 `GET`, `POST`, `PUT`, `DELETE`。
- **`headers`**: 一个包含请求头的 `Headers` 对象或普通对象。
- **`body`**: 请求体,可以是 `Blob`, `BufferSource`, `FormData`, `URLSearchParams` 或 `ReadableStream` 对象。`GET` 或 `HEAD` 方法不能有请求体。
- **`mode`**: 请求模式,如 `cors`, `no-cors`, `same-origin`。
- **`cache`**: 缓存模式,如 `default`, `no-store`, `reload`。
- **`credentials`**: 是否发送 `cookies`,如 `include`, `same-origin`, `omit`。
## 错误处理
`fetch()` 返回的 `Promise` 只有在遇到网络故障时才会 `reject`。对于服务器返回的 HTTP 错误状态(如 404 或 500),`fetch()` **不会** `reject`。
因此,必须始终检查 `response.ok` 属性来判断请求是否成功。
```ts
async function checkStatus(url) {
try {
const response = await fetch(url)
// 对于 404 等 HTTP 错误,fetch 不会抛出异常
// 需要手动检查状态
if (!response.ok) {
// 创建一个包含状态信息的错误,以便后续处理
throw new Error(`服务器响应错误: ${response.status} ${response.statusText}`)
}
console.log('请求成功!')
const data = await response.json()
return data
}
catch (error) {
// 这里会捕获网络错误和我们手动抛出的 HTTP 状态错误
console.error('Fetch 操作失败:', error.message)
}
}
// 示例:请求一个不存在的资源
checkStatus('https://jsonplaceholder.typicode.com/invalid-url')
```
# Sass 预处理器
## 模块化与项目结构
### 推荐用法
- `@use`:引入模块,成员默认有命名空间,避免冲突
- `@forward`:转发模块成员,构建聚合 API
- `pkg:` 语法:直接从依赖包导入样式
```scss
// 推荐:模块化引入
@use "bootstrap" as b;
.element {
@include b.float-left;
border: 1px solid b.theme-color("dark");
margin-bottom: b.$spacer;
}
// 转发用法
@forward "functions";
@forward "variables";
@forward "mixins";
```
### 命名空间与配置
- `@use "lib" as *;` 取消命名空间(不推荐,易冲突)
- `@use "lib" with ($color: blue);` 传递配置变量
```scss
@use "sass:color";
$base-color: #abc;
@use "library" with (
$base-color: $base-color,
$secondary-color: color.scale($base-color, $lightness: -10%)
);
```
### 包导入与 package.json 配置
- 推荐包作者在 `package.json` 增加 `sass` 字段
- 消费者可用 `@use 'pkg:library';` 导入依赖包样式
```json [package.json]
{
"exports": {
".": {
"sass": "./dist/scss/index.scss",
"import": "./dist/js/index.mjs",
"default": "./dist/js/index.js"
}
}
}
```
```scss [package-import-demo.scss]
@use 'pkg:bootstrap';
```
## 主流构建工具配置
### Vite
在 `vite.config.ts` 配置 Sass 选项:
```ts [vite.config.ts]
import { defineConfig } from 'vite'
export default defineConfig({
css: {
preprocessorOptions: {
scss: {
// 推荐使用 modern-compiler API
api: 'modern-compiler'
}
}
}
})
```
### Webpack
需安装 `sass-loader`,自动支持 `@use`/`@forward` 语法。
## 弃用警告与应对
Sass 正在逐步淘汰部分旧特性,常见弃用警告包括:
- **@import**:已弃用,推荐使用 `@use` 和 `@forward`
- **slash-div**:`/` 作为除法符号已弃用,建议用 `math.div()`
- **legacy-js-api**:旧版 JS API 已弃用
- **type-function**、**call-string**、**@elseif**、**new-global** 等
可通过编译器参数如 `fatalDeprecations`、`futureDeprecations`、`silenceDeprecations` 控制警告行为。
```text
[Deprecation] '@import' is deprecated. Use '@use' or '@forward' instead.
```
# 踩坑笔记
## 打包失败 Missing "./preload-helper" export in "vite" package
> \[!CAUTION]
>
> 打包失败:
>
> Missing "./preload-helper" export in "vite" package
搜索 `vite/preload-helper` 替换为 `\0vite/preload-helper`
## vue-element-admin 安装第三包(npm install)时报错
> \[!CAUTION]
>
> 控制台报错:
>
> ls-remote -h -t git://github.com/adobe-webplatform/eve.git
- 修改 Git 的协议(ssh 替换为 https)
```sh
git config --global url."https://github.com/".insteadOf "ssh://git@github.com/"
```
- 切换镜像网站
```sh
git config --global url."https://hub.fastgit.xyz/".insteadOf "ssh://git@github.com/"
```
## 使用 import.meta.env 获取环境变量提示类型 “ImportMeta” 上不存在属性 “env”
在 `tsconfig.json` 中添加 `"types": ["vite/client"]`
```json [tsconfig.json]
{
"compilerOptions": {
"types": ["vite/client"]
}
}
```
> \[!TIP]
>
> 实际上启用了 Vite 提供的类型支持,这让 TypeScript 能够理解并正确处理 Vite 特有的代码结构,如环境变量访问。这是确保 TypeScript 项目中 Vite 功能正确工作的关键配置。
## JSX 元素隐式具有类型 "any",因为不存在接口 "JSX.IntrinsicElements" 的索引签名
在 `tsconfig.json` 中添加 `"jsx": "preserve"` 和 `"jsxImportSource": "vue"`
```json [tsconfig.json]
{
"compilerOptions": {
"jsx": "preserve",
"jsxImportSource": "vue"
}
}
```
> \[!NOTE]
> See: https\://vuejs.org/guide/extras/render-function.html#jsx-type-inference
>
> 根据 vue 指南,这是由以下更改引起的:
>
> > Starting in Vue 3.4, Vue no longer implicitly registers the global JSX namespace,从 Vue 3.4 开始,Vue 不再隐式注册全局 JSX 命名空间
## Big integer literals are not available in the configured target environment (“chrome87“, “edge88“)
在 `vite.config.ts` 中添加:
```ts [vite.config.ts]
export default defineConfig({
// ...
build: {
target: 'esnext', // you can also use 'es2020' here
},
optimizeDeps: {
esbuildOptions: {
target: 'esnext', // you can also use 'es2020' here
},
},
})
```
另外,请确保你的 Typescript 目标足够高:
```json [tsconfig.json]
{
"compilerOptions": {
"target": "ES2020" // you can also use higher value
// ...
}
}
```
# 按需自动导入
## 自动导入组件和 API
> \[!NOTE]
>
> unplugin-vue-components
>
> 、
>
> unplugin-auto-import
通过以下配置,可以实现 `vue`、`@vueuse/core` 的 API 以及 `Element Plus` 组件的自动导入。
```sh [sh]
pnpm add -D unplugin-vue-components unplugin-auto-import
```
```ts [vite.config.ts]
import AutoImport from 'unplugin-auto-import/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
import Components from 'unplugin-vue-components/vite'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [
AutoImport({
imports: ['vue', '@vueuse/core'],
resolvers: [ElementPlusResolver()],
}),
Components({
resolvers: [
ElementPlusResolver(),
],
}),
],
})
```
## 自动导入图标
结合 [`unplugin-icons`](https://github.com/unplugin/unplugin-icons){rel=""nofollow""} 可以在项目中方便地使用 [Iconify](https://icon-sets.iconify.design/){rel=""nofollow""} 中的海量图标。
### 安装图标集
> \[!NOTE]
>
> 如果只需要使用特定图标集,可以单独安装,例如
>
> ep
>
> (Element Plus) 和
>
> maki
>
> 图标集。
```sh [sh]
pnpm add -D @iconify-json/ep @iconify-json/maki
```
### 配置 Vite 插件
在 `vite.config.ts` 中配置 `unplugin-icons` 的 `IconsResolver` 和 `Icons` 插件。
```ts [vite.config.ts]
import AutoImport from 'unplugin-auto-import/vite'
import IconsResolver from 'unplugin-icons/resolver'
import Icons from 'unplugin-icons/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
import Components from 'unplugin-vue-components/vite'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [
AutoImport({
imports: ['vue', '@vueuse/core'],
resolvers: [
ElementPlusResolver(),
IconsResolver({
prefix: 'icon',
}),
],
}),
Components({
resolvers: [
ElementPlusResolver(),
IconsResolver({
enabledCollections: ['ep', 'maki'],
}),
],
}),
Icons({
autoInstall: true,
}),
],
})
```
### 在 `tsconfig.json` 中添加类型
> \[!NOTE]
>
> 为了让 TypeScript 识别通过
>
> \~icons/...
>
> 导入的图标类型,需要添加相应的类型声明。
```json [tsconfig.json]
{
"compilerOptions": {
"types": [
"unplugin-icons/types/vue"
]
}
}
```
### 使用图标
配置完成后,可以直接在模板中使用 `` 这样的组件,或者通过 `import IconMakiAnimalShelter from '~icons/maki/animal-shelter'` 的方式在脚本中导入图标。
# Element Plus
## Tree 树节点过滤时保留父节点和子节点
::__flatten
```vue [Component.vue]
```
::
## 无法找到模块 `element-plus/dist/locale/zh-cn.mjs` 的声明文件
> \[!CAUTION]
>
> 无法找到模块
>
> element-plus/dist/locale/zh-cn.mjs
>
> 的声明文件。
>
> /node\_modules/element-plus/dist/locale/zh-cn.mjs
>
> 隐式拥有 "any" 类型。
> 如果
>
> element-plus
>
> 包实际公开了此模块,请尝试添加包含
>
> declare module 'element-plus/dist/locale/zh-cn.mjs';
>
> 的新声明(.d.ts)文件ts-plugin
- 使用正确的 Locale 模块路径 (推荐) :br element-plus 从 `2.2.0` 开始,推荐从 `element-plus/es/locale/lang/` 导入语言包。修改你的导入语句如下:
```ts \[main.ts]
import zhCn from 'element-plus/es/locale/lang/zh-cn'
```
- 添加手动类型声明
```ts
declare module 'element-plus/dist/locale/zh-cn.mjs' {
import { Language } from 'element-plus/es/locale'
const zhCn: Language
export default zhCn
}
```
# Nuxt UI (Vue + UnoCSS)
## 为什么选择 Nuxt UI
[Nuxt UI](https://ui.nuxt.com){rel=""nofollow""} v4 是一个基于 Reka UI(原 Radix Vue)构建的高质量 Vue 组件库,提供统一的设计系统、内置 light/dark 模式和灵活的主题定制能力。
> \[!NOTE]
>
> Nuxt UI 官方依赖 Tailwind CSS,但通过社区方案
>
> unocss-preset-nuxt-ui
>
> ,可以在 UnoCSS 项目中直接使用 Nuxt UI,无需引入 Tailwind CSS。
选择 Nuxt UI 的理由:
- **无障碍支持**:基于 Reka UI,a11y 开箱即用
- **设计系统**:CSS 变量主题令牌,与 UnoCSS 体系无缝衔接
- **Vite 原生**:提供 `@nuxt/ui/vite` 插件,无需 Nuxt 框架
- **自动导入**:组件和 composables 零配置自动导入
## 依赖安装
```sh [sh]
pnpm add @nuxt/ui unocss-preset-nuxt-ui
```
| 包名 | 用途 |
| ----------------------- | ----------------------------------------- |
| `@nuxt/ui` | Nuxt UI 组件库 |
| `unocss-preset-nuxt-ui` | UnoCSS 预设,替代 Tailwind CSS 提供 Nuxt UI 所需样式 |
## Vite 插件配置
引入 `@nuxt/ui/vite` 插件,它内部集成了 `unplugin-auto-import` 和 `unplugin-vue-components`:
```ts [vite.config.ts]
import vue from '@vitejs/plugin-vue'
import ui from '@nuxt/ui/vite'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [
vue(),
ui({
// 自动导入 Vue、VueUse 等 API
autoImport: {
imports: ['vue', '@vueuse/core', 'vue-router'],
},
// 主题默认变量
theme: {
defaultVariants: {
size: 'md',
color: 'primary',
},
},
}),
],
})
```
> \[!WARNING]
>
> @nuxt/ui/vite
>
> 内部已集成
>
> unplugin-auto-import
>
> 和
>
> unplugin-vue-components
>
> 。如果项目中原先单独配置了这两个插件,需要将配置合并到
>
> ui()
>
> 的
>
> autoImport
>
> 和
>
> components
>
> 选项中,避免重复注册。
## UnoCSS 预设配置
```ts [uno.config.ts]
import {
defineConfig,
transformerDirectives,
transformerVariantGroup,
} from 'unocss'
import presetWind4 from '@unocss/preset-wind4'
import { presetNuxtUI, presetNuxtUIExtra } from 'unocss-preset-nuxt-ui'
export default defineConfig({
presets: [
presetNuxtUI(), // 必须在 presetWind4 之前
presetWind4(),
presetNuxtUIExtra(), // 必须在 presetWind4 之后
],
transformers: [
transformerDirectives(), // 支持 @apply 等 CSS 指令
transformerVariantGroup(), // 支持 hover:(bg-red text-white) 变体分组
],
})
```
> \[!WARNING]
>
> 预设加载顺序至关重要:
>
> presetNuxtUI()
>
> 必须在
>
> presetWind4()
>
> 之前
>
> ,
>
> presetNuxtUIExtra()
>
> 必须在
>
> presetWind4()
>
> 之后
>
> 。顺序错误将导致样式异常。
> \[!TIP]
>
> presetNuxtUI()
>
> 先注册 CSS 变量和基础 token,
>
> presetWind4()
>
> 随后解析引用这些变量的工具类,
>
> presetNuxtUIExtra()
>
> 最后补充 Wind4 无法覆盖的额外样式规则。
## TypeScript 配置
添加 `#build/ui` 别名以获得主题配置的类型提示:
```json [tsconfig.json]
{
"compilerOptions": {
"paths": {
"#build/ui": ["./node_modules/.nuxt-ui/ui"],
"#build/ui/*": ["./node_modules/.nuxt-ui/ui/*"]
}
},
"include": [
"src/**/*.ts",
"src/**/*.vue"
]
}
```
> \[!NOTE]
>
> @nuxt/ui/vite
>
> 插件会自动生成
>
> auto-imports.d.ts
>
> 和
>
> components.d.ts
>
> 类型声明文件,建议将它们加入
>
> .gitignore
>
> 。
## Vue 插件注册
```ts [main.ts]
import { createApp } from 'vue'
import { createRouter, createWebHistory } from 'vue-router'
import ui from '@nuxt/ui/vue-plugin'
import App from './App.vue'
const app = createApp(App)
const router = createRouter({
routes: [],
history: createWebHistory(),
})
app.use(router)
app.use(ui)
app.mount('#app')
```
## 根组件包装
使用 `` 包裹应用内容,提供 Nuxt UI 的全局配置上下文:
```vue [Component.vue\\] [App.vue]
```
> \[!NOTE]
>
> \
>
> 是 Toast、Tooltip 和程序化覆盖层(Overlay)正常工作的前提。
## 样式文件
由于使用 UnoCSS 而非 Tailwind CSS,不能直接 `@import "@nuxt/ui"`。需要手动处理样式:
### 目录结构
```text
src/styles/
nuxt-ui/
main.scss # Nuxt UI 主样式入口
keyframes.css # 动画关键帧(从 node_modules 复制)
index.ts # 样式总入口
```
### keyframes.css
从 `node_modules/@nuxt/ui/dist/runtime/keyframes.css` 复制到项目中,包含手风琴展开/折叠、Toast 弹出、滑动面板等 30+ 个动画关键帧。
### main.scss
```scss [src/styles/nuxt-ui/main.scss]
@use './keyframes.css';
@layer components, base, properties;
body {
background-color: var(--ui-bg);
color: var(--ui-text-highlighted);
-webkit-font-smoothing: antialiased;
}
// Nuxt UI gap 类预置值
.\[\--gap\:\--spacing\(4\)\] {
--gap: calc(var(--spacing) * 4);
}
.\[\--gap\:\--spacing\(16\)\] {
--gap: calc(var(--spacing) * 16);
}
```
### 导入样式
```ts [src/styles/index.ts]
import './nuxt-ui/main.scss'
```
### HTML 入口
在根容器上添加 `isolate` 类,确保样式作用域隔离:
```html [index.html]
```
### 自定义主题色
在 `main.scss` 中覆盖 CSS 变量即可自定义颜色:
```scss [src/styles/nuxt-ui/main.scss]
:root {
--color-green-50: #effdf5;
--color-green-100: #d9fbe8;
--color-green-200: #b3f5d1;
// ... 完整色阶
--color-green-950: #052e16;
}
```
## 组件使用
Nuxt UI 组件通过自动导入直接在模板中使用,所有组件以 `U` 为前缀:
```vue [Component.vue]
提交
取消
```
> \[!NOTE]
> See: https\://ui.nuxt.com/docs/getting-started/theme/components
>
> 查看 Nuxt UI 组件主题自定义文档了解更多变体和配色选项。
## 参考资料
- [Nuxt UI 官方文档 - Vue 安装](https://ui.nuxt.com/docs/getting-started/installation/vue){rel=""nofollow""}
- [unocss-preset-nuxt-ui](https://github.com/lehuuphuc/unocss-preset-nuxt-ui){rel=""nofollow""} - UnoCSS 预设
- [Nuxt UI 设计系统](https://ui.nuxt.com/docs/getting-started/theme/design-system){rel=""nofollow""}
- [Nuxt UI CSS 变量](https://ui.nuxt.com/docs/getting-started/theme/css-variables){rel=""nofollow""}
# Vite 资源导入
## 常用导入方式
| 导入后缀 | 用途 | 示例 |
| --------- | ----------------- | ----------------------------------------- |
| `?url` | 获取资源处理后的 URL | `import url from './img.png?url'` |
| `?raw` | 获取文件原始内容字符串 | `import text from './data.txt?raw'` |
| `?inline` | 将文件内容内联为 base64 | `import json from './data.json?inline'` |
| `?worker` | 创建一个新的 Web Worker | `import Worker from './worker.js?worker'` |
## 应用场景
下面的示例展示了如何在 Vue 组件中利用 `?url` 后缀导入静态资源 URL。
```vue [Component.vue]
```
# Nuxt Layer ESM/CJS 兼容问题
## 问题描述
在 Nuxt Layer 架构中,使用 pnpm 作为包管理器时,遇到了 ESM/CJS 模块互操作错误:
```log
SyntaxError: The requested module does not provide an export named 'default'
```
典型错误包括:
- `extend` 包(`unified` 的依赖)
- `debug` 包(`micromark` 的依赖)
- `@vercel/oidc` 包(`@ai-sdk/gateway` 的依赖)
## 根本原因
### pnpm 依赖隔离
pnpm 使用严格的依赖隔离机制,子依赖的子依赖无法从项目根目录直接访问。
### Layer 路径解析问题
Nuxt 模块(如 `@nuxtjs/mdc`)在配置 Vite 的 `optimizeDeps.include` 时,使用了简化的依赖路径:
```ts [layer/nuxt.config.ts]
// @nuxtjs/mdc 模块内部添加的路径
'@nuxtjs/mdc > debug'
'@nuxtjs/mdc > unified'
```
但在 Layer 架构中,正确的路径应该包含完整的依赖链:
```ts [layer/nuxt.config.ts]
// Layer 架构中的正确路径
'@movk/nuxt-docs > @nuxtjs/mdc > debug'
'@movk/nuxt-docs > @nuxtjs/mdc > unified'
```
### CJS 包的 ESM 兼容性
部分 CommonJS 包(如 `extend`、`debug`)在被 Vite 作为 ESM 导入时,导出不兼容,需要预构建转换。
## 解决方案
在 Layer 的 `nuxt.config.ts` 中使用 `vite:extendConfig` hook,重写模块配置的依赖路径。
### 配置代码
```ts [layer/nuxt.config.ts]
export default defineNuxtConfig({
hooks: {
// Rewrite optimizeDeps paths for layer architecture
'vite:extendConfig': (config) => {
const include = config.optimizeDeps?.include
if (!include) return
// 重写 layer 子模块的依赖路径
const layerPkgs = /^(?:@nuxt\/content|@nuxtjs\/mdc|@nuxt\/a11y) > /
include.forEach((id, i) => {
if (layerPkgs.test(id)) include[i] = `@movk/nuxt-docs > ${id}`
})
// 手动添加需要预构建的 CJS 依赖
include.push(
'@movk/nuxt-docs > @nuxt/content > slugify',
'@movk/nuxt-docs > @ai-sdk/gateway > @vercel/oidc'
)
}
}
})
```
### 工作原理
1. **`vite:extendConfig` hook**:在所有模块配置完成后执行,确保能捕获所有依赖
2. **正则匹配重写**:使用 `(?:...)` 非捕获组匹配需要重写的包前缀
3. **路径前缀追加**:将 `@movk/nuxt-docs >` 前缀添加到匹配的依赖路径前
4. **手动添加深层依赖**:对于子依赖的 CJS 包,显式添加完整路径
## 扩展说明
### 添加新的 Layer 模块
如果 Layer 添加了新的 Nuxt 模块,需要在正则中添加对应的包名:
```ts [layer/nuxt.config.ts]
const layerPkgs = /^(?:@nuxt\/content|@nuxtjs\/mdc|@nuxt\/a11y|@新模块名称) > /
```
### 添加新的 CJS 依赖
如果遇到新的 CJS 兼容性问题,在 `include.push()` 中添加完整路径:
```ts [layer/nuxt.config.ts]
include.push(
'@movk/nuxt-docs > @nuxt/content > slugify',
'@movk/nuxt-docs > @ai-sdk/gateway > @vercel/oidc',
'@movk/nuxt-docs > @父包 > @问题包' // 新增的依赖
)
```
## 注意事项
1. **清除缓存**:修改配置后需要清除所有缓存:
```bash
rm -rf docs/.nuxt layer/.nuxt docs/node_modules/.cache node_modules/.vite
```
2. **使用者项目的依赖**:Layer 只能处理自身依赖的路径问题,使用者项目自己的依赖需要在他们的项目中配置
# Node.js 版本兼容
## Node.js ABI (Application Binary Interface) 版本不兼容
> \[!CAUTION]
>
> Node.js 的 ABI 版本不兼容问题。
>
> ```log [log]
> The module '/Users/yixuanmiao/MOVK/mhaibaraai.cn/node_modules/.pnpm/better-sqlite3@12.2.0/node_modules/better-sqlite3/build/Release/better_sqlite3.node'
> was compiled against a different Node.js version using
> NODE_MODULE_VERSION 127. This version of Node.js requires
> NODE_MODULE_VERSION 137. Please try re-compiling or re-installing
> the module (for instance, using npm rebuild or npm install).
> ```
### 问题分析
报错信息核心是:
- `better_sqlite3.node` 是用 **NODE\_MODULE\_VERSION 127** 编译的
- 当前运行的 Node.js 版本需要 **NODE\_MODULE\_VERSION 137**
- 升级了 Node.js(或者切换了版本),但本地依赖里的原生模块 `better-sqlite3` 没有重新编译
### 最优解决方案(推荐顺序执行):
1. **删除依赖并重装**
```sh \[sh]
rm -rf node_modules
pnpm store prune
pnpm install
```
2. **强制重编译 better-sqlite3**
```sh \[sh]
pnpm rebuild better-sqlite3
```
:br或者全局重编译所有原生依赖:
```sh \[sh]
pnpm rebuild
```
要快速验证是否修复,可以运行:
```sh [sh]
node -e "require('better-sqlite3')"
```
如果没有报错,就说明 ABI 版本对上了。
# Nuxt Layer reka-ui SSR 500 错误
## 问题现象
> \[!CAUTION]
>
> 在 Vercel 上部署使用 Nuxt Layer 的项目时,所有页面返回 500 错误,且 **Vercel 运行时日志为空**。
>
> - 浏览器控制台报:`Hydration completed but contains mismatches`
> - Vercel 返回:`Cannot read properties of null (reading 'ce')`
> - 本地开发(macOS)完全正常,切换 Node.js 版本无效
> - 路由显示为 `/__fallback`(Nitro SSR catch-all),执行耗时约 77ms
>
> ```json [error-response]
> {
> "statusCode": 500,
> "statusMessage": "Server Error",
> "message": "Cannot read properties of null (reading 'ce')"
> }
> ```
## 根本原因
### Nuxt Layer 的模块路径重复问题
典型的出问题的依赖链:
```plaintext
your-app/
└─ extends: some-layer # Nuxt Layer
└─ @nuxt/ui
└─ reka-ui # 通过 Layer 的 node_modules 解析
your-app/
└─ node_modules/reka-ui # 直接依赖,通过项目自身 node_modules 解析
```
Nitro 构建 SSR bundle 时,Layer 中的组件和项目中的组件通过**不同的文件系统路径**引用 `reka-ui`:
```plaintext
# Layer 组件 resolve 到:
node_modules/some-layer/node_modules/reka-ui/dist/ConfigProvider.js
# 项目组件 resolve 到:
node_modules/reka-ui/dist/ConfigProvider.js
```
> \[!NOTE]
>
> reka-ui
>
> 的
>
> ConfigProvider
>
> 使用 Vue 的
>
> provide/inject
>
> 机制传递上下文。两个不同路径被视为
>
> 两个独立的模块实例
>
> ,
>
> ConfigProvider
>
> 在路径 A 中
>
> provide
>
> 的上下文,子组件通过路径 B
>
> inject
>
> 时拿到的是
>
> null
>
> ,导致
>
> renderSlot
>
> 阶段访问
>
> null.ce
>
> 报错。
### 为什么 macOS 正常而 Vercel(Linux)崩溃
macOS 的 APFS 文件系统大小写不敏感,加上 Vite 的模块缓存,两条路径碰巧 resolve 到同一物理文件。Linux(Vercel 运行环境)严格区分路径,两个不同的符号链接路径会被视为两个独立模块实例。
### 为什么日志为空
错误发生在 Vue 的 `renderSlot` 内部,被 Nitro 的 error handler 直接捕获并以 500 JSON 返回,不经过 `console.error`,因此 Vercel 运行时日志中看不到任何报错。
## 解决方案
在项目的 `nuxt.config.ts` 中将 `reka-ui` 添加到 `build.transpile`:
```typescript [nuxt.config.ts]
export default defineNuxtConfig({
build: {
transpile: ['reka-ui']
}
})
```
> \[!TIP]
>
> 这会告诉 Vite/Nitro 不直接引用
>
> reka-ui
>
> 的预编译产物,而是将其源码纳入构建管线统一编译,所有对
>
> reka-ui
>
> 的引用(无论来自哪个 Layer)都解析为同一份代码,确保
>
> provide/inject
>
> 上下文在同一模块实例中流转。
## 适用范围
任何通过 `extends` 使用 Nuxt Layer,且 Layer 中包含使用 `provide/inject` 的 UI 库时,均可能遇到此问题。同样的修复方式适用于其他受影响的包:
```typescript [nuxt.config.ts]
export default defineNuxtConfig({
build: {
transpile: ['reka-ui', 'radix-vue'] // 按需添加
}
})
```
> \[!NOTE]
>
> 不只限于
>
> reka-ui
>
> ,所有依赖
>
> provide/inject
>
> 的 UI 库(如
>
> radix-vue
>
> )在 Nuxt Layer 架构下均可能遇到相同问题。
## 相关 Issue
> \[!NOTE]
> See: https\://github.com/nuxt/nuxt/issues/33677
>
> nuxt/nuxt#33677
>
> — 问题报告
> \[!NOTE]
> See: https\://github.com/unovue/reka-ui/issues/1239
>
> unovue/reka-ui#1239
>
> — reka-ui 层兼容性
# Nuxt UI componentDetection 漏检
## 问题现象
> \[!CAUTION]
>
> 某个 `@nuxt/ui` 组件能正常渲染,但样式完全缺失,尤其是依赖 CSS 状态类的交互行为失效(如折叠/展开、激活态等)。
>
> 典型特征:
>
> - 无 JavaScript 报错,组件 DOM 结构正常
> - 仅视觉/交互行为异常(如侧边栏收起后仍可见、弹层位置不对)
> - 本地开发与生产构建均能复现
> - 禁用 `componentDetection` 后恢复正常
## 定位方式
首先确认是否真的是 `componentDetection` 漏检导致的。检查 `.nuxt/ui.css` 的 `@source` 列表:
```bash
# 以 Sidebar 组件为例,查找对应主题文件是否被引入
grep "sidebar" .nuxt/ui.css
```
若输出为空,说明 `sidebar.ts` 主题文件未被纳入构建,`Sidebar` 相关的所有 CSS 类均未生成。
更通用的排查方式:直接查看 `@source` 列表中包含哪些组件:
```bash
grep "@source" .nuxt/ui.css
```
对照缺失样式的组件名,若 `./ui/.ts` 不在列表中,即可确认是漏检问题。
## 根本原因
### componentDetection 的扫描边界
`@nuxt/ui` 的 `componentDetection` 功能通过扫描各 Layer 的 `app/` 目录来判断哪些 UI 组件被实际使用,并只为检测到的组件生成主题 CSS,以减小产物体积。
```plaintext
# 会扫描的目录(✅)
your-layer/
app/
components/ ← 扫描
pages/ ← 扫描
layouts/ ← 扫描
# 不会扫描的目录(❌)
your-layer/
modules/
your-module/
runtime/
components/ ← 不扫描
pages/ ← 不扫描
```
当 `@nuxt/ui` 组件的实际使用位置在 `modules/*/runtime/` 目录中时,`componentDetection` 无法检测到,对应的主题文件不会出现在 `.nuxt/ui.css` 的 `@source` 列表里,相关 CSS 类从未生成。
### 同样受影响的场景
除了 `modules/` runtime,以下用法也会绕过静态扫描:
- `` — 动态组件,编译期无法确定组件名
- 通过 `provide/inject` 或插槽间接渲染的组件
- 在 `server/` 目录中引用的 UI 组件
## 解决方案
将 `componentDetection` 从 `true` 改为显式数组,把无法被自动检测到的组件名加入其中。`app/` 目录的自动扫描仍正常进行,不影响已有组件的检测。
```typescript [nuxt.config.ts]
export default defineNuxtConfig({
ui: {
experimental: {
// 将无法自动检测到的组件名加入数组
// app/ 目录中使用的组件仍由自动扫描覆盖
componentDetection: [
'Sidebar', // 替换为你实际缺失样式的组件名
'YourWidget'
]
}
}
})
```
> \[!TIP]
>
> 组件名使用 PascalCase,与
>
> @nuxt/ui
>
> 组件名保持一致(去掉
>
> U
>
> 前缀)。例如
>
> \
>
> 对应
>
> 'Sidebar'
>
> ,
>
> \
>
> 对应
>
> 'ChatMessages'
>
> 。
修改后重启开发服务器,验证 `.nuxt/ui.css` 中出现对应的 `@source` 条目:
```bash
grep "sidebar" .nuxt/ui.css
# 预期输出:@source "./ui/sidebar.ts";
```
## 适用范围
满足以下任一条件的项目均可能遇到此问题:
- 使用 Nuxt Layer(`extends`),且 Layer 内有 `modules/*/runtime/` 目录中使用了 `@nuxt/ui` 组件
- 在项目中使用 `` 动态渲染 `@nuxt/ui` 组件
- 开启了 `ui.experimental.componentDetection: true` 后发现部分组件样式缺失
> \[!NOTE]
>
> 若你是 Nuxt Layer 的维护者,建议直接在 Layer 的
>
> nuxt.config.ts
>
> 中配置数组,避免 consumer 项目逐一修补。
# 自建服务器 GitHub OAuth 回调 500 排查
## 三种根因速查表
三次报错都发生在同一个回调路径上,但成因完全独立,不要因为报错路径相同就假设是同一个原因——先对照日志证据定位到具体是哪一种:
| 现象特征 | 根因 | 关键证据 | 对应章节 |
| ----------------------------------- | ------------------------------------- | ------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| nginx 返回 500,应用自身日志**没有**任何异常堆栈 | 容器出海请求 GitHub 挂起,被 nginx 超时掐断 | nginx 日志:`upstream timed out (110: Connection timed out)`,路径为 `/auth/github?code=...` | [出海请求超时:容器访问 GitHub 挂起](https://mhaibaraai.cn/#%E5%87%BA%E6%B5%B7%E8%AF%B7%E6%B1%82%E8%B6%85%E6%97%B6%E5%AE%B9%E5%99%A8%E8%AE%BF%E9%97%AE-github-%E6%8C%82%E8%B5%B7) |
| 应用日志报 `ECONNREFUSED 127.0.0.1:5432` | 数据库连接串在构建期被固化成字面量 | `docker logs`:`connect ECONNREFUSED 127.0.0.1:5432` | [连接串在构建期被固化成字面量](https://mhaibaraai.cn/#%E8%BF%9E%E6%8E%A5%E4%B8%B2%E5%9C%A8%E6%9E%84%E5%BB%BA%E6%9C%9F%E8%A2%AB%E5%9B%BA%E5%8C%96%E6%88%90%E5%AD%97%E9%9D%A2%E9%87%8F) |
| 应用日志报 `EAI_AGAIN postgres` | 应用容器与数据库容器不在同一个 Docker 网络,DNS 解析不到主机名 | `docker logs`:`getaddrinfo EAI_AGAIN postgres` | [跨 Docker 网络 DNS 解析失败](https://mhaibaraai.cn/#%E8%B7%A8-docker-%E7%BD%91%E7%BB%9C-dns-%E8%A7%A3%E6%9E%90%E5%A4%B1%E8%B4%A5) |
## 出海请求超时:容器访问 GitHub 挂起
本地 `pnpm dev` 走通登录不代表部署到自建服务器(Docker + nginx 反代)上也能走通——这类环境常见一种和数据库、代码都无关的坑:`/auth/github` 回调稳定返回 500,且应用日志里看不到任何异常。
### 现象与根因
`defineOAuthGitHubEventHandler` 的令牌交换与用户信息拉取发生在 `nuxt-auth-utils` 库内部(`$fetch` 依次 POST `github.com/login/oauth/access_token`、GET `api.github.com/user`),不是应用代码手写的 fetch。境内服务器出海链路对 `github.com`/`api.github.com` 这类站点经常不稳定:TCP 三次握手能成功,但完整的 HTTPS 请求会挂起不返回,最终由 nginx 的 `proxy_read_timeout` 先触发,对浏览器返回 500。
> \[!WARNING]
>
> 因为是 fetch 挂死超过 nginx 超时才被动断开,而不是应用主动抛异常,
>
> onError
>
> 里的
>
> console.error
>
> 往往来不及执行——应用日志、Nitro 错误页都看不到任何线索,很容易被误判为代码或环境变量配置问题。真正的诊断依据是 nginx access/error log:
>
> upstream timed out (110: Connection timed out) while reading response header from upstream
>
> ,对应请求路径是
>
> /auth/github?code=...
>
> 。
### 修复:让容器出站请求走代理
Node 22 的全局 `fetch`(以及 `ofetch`)不会自动读取 `HTTP_PROXY`/`HTTPS_PROXY` 环境变量,需要显式接入。新增一个 Nitro 插件,用 [undici](https://github.com/nodejs/undici){rel=""nofollow""} 的 `EnvHttpProxyAgent` 接管全局 dispatcher:
```sh [sh]
pnpm add undici
```
```ts [server/plugins/proxy.ts]
import { EnvHttpProxyAgent, setGlobalDispatcher } from 'undici'
export default defineNitroPlugin(() => {
if (!process.env.HTTPS_PROXY && !process.env.HTTP_PROXY)
return
setGlobalDispatcher(new EnvHttpProxyAgent({
headersTimeout: 10_000,
bodyTimeout: 10_000
}))
})
```
- `EnvHttpProxyAgent` 自动读取 `HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY`,按目标请求的 scheme 逐请求路由,不需要手动解析环境变量。
- 显式设置 `headersTimeout`/`bodyTimeout`(约 10 秒):即使代理链路本身也偶发异常,也能快速失败落到 `onError` 走干净的重定向,而不是继续挂到 nginx 超时变成一次不可控的 500。
- 未设置 `HTTPS_PROXY`/`HTTP_PROXY` 时函数体直接返回,插件是空操作,本地 `pnpm dev` 不受影响。
`setGlobalDispatcher` 对 Node 内置 undici 的全局 `fetch`、以及 `ofetch`(`nuxt-auth-utils` 内部用它做令牌交换)统一生效;数据库连接走的是 `postgres` 包的原生 TCP/TLS,不经过 undici,不受这次改动影响。
### 光改代码不够:服务器也要有能连上 GitHub 的出口
这段插件只是把「走不走代理」的开关交给了 `HTTPS_PROXY` 环境变量本身,真正让请求连上 GitHub 靠的是服务器侧已经在跑的代理客户端(如 mihomo/clash 系)。这里有两个容易漏掉的点:
> \[!NOTE]
>
> 如果服务器上已经给
>
> Docker daemon
>
> 配置过
>
> HTTP\_PROXY
>
> (常见做法是
>
> /etc/systemd/system/docker.service.d/http-proxy.conf
>
> ,用来加速
>
> docker pull
>
> /
>
> docker build
>
> ),
>
> 不要以为这样应用容器内部的请求也会走代理
>
> ——那只加速 Docker 引擎自身发起的请求,和容器进程内部的出站请求是两条完全独立的链路。
1. **代理客户端要监听到容器网络能访问的地址。** mihomo 等工具默认 `allow-lan: false`,只监听 `127.0.0.1`,容器在 Docker 网桥(如 `webnet`)里连不到宿主机的回环地址。需要把 `allow-lan` 改成 `true`(监听 `0.0.0.0`),并用防火墙(ufw/iptables)只放行 Docker 网桥子网访问代理端口,公网网卡上这些端口一律拒绝,避免把代理订阅暴露给公网。
2. **`docker-compose.yml` 给应用容器显式声明代理地址:**
```yaml \[docker-compose.yml]
services:
app:
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
- HTTPS_PROXY=http://host.docker.internal:7893 # 端口对应代理的 mixed-port
```
:br用 `extra_hosts: host.docker.internal:host-gateway` 而不是硬编码网桥网关 IP,网络重建后网关 IP 变化也不会失效。
> \[!TIP]
>
> 如果代理客户端的规则表本身按域名分流(比如
>
> DOMAIN-SUFFIX,github.com,Proxies
>
> ),记得确认
>
> ghcr.io
>
> 、
>
> pkg-containers.githubusercontent.com
>
> 是否也在规则里——它们不是
>
> github.com
>
> 的子域名,不会被通配到,容器化部署里如果还从 GHCR 拉取镜像,遗漏这两条规则会导致这部分流量落回直连,同样可能遇到不稳定问题。
### 也考虑过的方案:透明代理
除了显式 `HTTPS_PROXY`,如果代理客户端本身开着 `redir-port` + TLS SNI 嗅探(`sniffer.enable: true`),理论上可以用宿主机 iptables 把容器出站的 443 流量 `REDIRECT` 到 `redir-port`,做到应用完全无感知、零代码改动。实际权衡下来放弃了这条路:iptables 规则要按容器固定 IP 匹配(否则容器重建后 IP 漂移会让规则失效),还要处理和 Docker 自身管理的 nat 表规则的顺序冲突、规则持久化等问题,运维复杂度和可调试性都不如显式 `HTTPS_PROXY`——出了问题容器里 `echo $HTTPS_PROXY` 就能立刻确认是否生效,iptables 规则则只能靠 `iptables -t nat -L` 慢慢排查。如果你的场景更看重零代码改动,透明代理仍然是可行选项,只是需要更细致的运维配套。
### 验证
```sh [sh]
docker exec sh -c 'echo $HTTPS_PROXY' # 确认环境变量注入生效
```
走一遍真实的 GitHub 登录,确认 `/auth/github` 回调不再挂起超时;观察 nginx 日志确认不再出现 `upstream timed out ... /auth/github` 记录。
## 连接串在构建期被固化成字面量
解决了出海代理超时之后,同一个 `/auth/github` 回调又以另外两种方式连续报过 500,而且两次都是**确定性失败**(每次报错完全一致,不是网络抖动),和前面的代理问题成因完全不同,容易被合并误判成"还是代理没配好"。两次报错都发生在 `onSuccess` 回调里查 `users` 表这一步——也就是说 GitHub 令牌交换和用户信息拉取其实都已经成功了,`ghUser` 已经拿到手,纯粹是应用连不上自己的数据库。
### 现象
`docker logs` 能看到应用自己打出的异常堆栈(没有被 nginx 吞掉):
```text
H3Error: Failed query: select ... from "users" ...
cause: Error: connect ECONNREFUSED 127.0.0.1:5432
```
明明 `docker-compose.yml` 里 `DATABASE_URL` 写的是 `postgresql://user:pass@postgres:5432/db`,运行时却固定连到 `127.0.0.1:5432` 并被拒绝——不是配置写错了,是**根本没有生效**。
### 根因
`@nuxthub/core` 在 `postgres-js` driver、非 dev、非 Cloudflare 托管场景下,会在 Nuxt 构建期读一次 `POSTGRES_URL`/`POSTGRESQL_URL`/`DATABASE_URL`,如果读到的值非空,直接把这个值当**字符串字面量**写死进编译产物(`.output/server` 里生成的 `hub:db` 运行时模块),而不是保留 `process.env.DATABASE_URL` 这种运行时读取的代码。只有构建期这三个变量都为空,产物里才会是运行时读取的写法。
CI 流水线里,`pnpm build` 之前用 SSH 隧道打通了到生产库的连接(`ssh -L 5432:localhost:5432 ...`),并且顺手把 `DATABASE_URL=postgresql://user:pass@127.0.0.1:5432/db`(只在 CI 这台机器、经隧道才有效的地址)设进了同一个 `Build` 步骤的 `env`,本意是让 `applyMigrationsDuringBuild`(默认开启)能在构建时顺便把 migration 跑了。副作用是这个只在 CI 里临时有效的 `127.0.0.1:5432`,被当成"构建期已知的生产连接串"永久烧进了镜像,运行时容器里 docker-compose 传入的正确 `DATABASE_URL` 被完全无视。
### 修复
把"跑 migration"和"编译"彻底拆开,`Build` 步骤不再感知 `DATABASE_URL`:
```yaml [.github/workflows/deploy.yml]
- name: Run database migrations
env:
DATABASE_URL: postgresql://${{ secrets.DB_USER }}:${{ secrets.DB_PASSWORD }}@127.0.0.1:5432/${{ secrets.DB_NAME }}
run: pnpm db:migrate # 对应 nuxt db migrate,独立命令,不牵扯 nuxt build
- name: Build
env:
NUXT_SESSION_PASSWORD: ${{ secrets.NUXT_SESSION_PASSWORD }}
# 不再设置 DATABASE_URL
run: pnpm build
```
> \[!TIP]
>
> 本地验证这条修复时,
>
> pnpm build
>
> 很容易被本机
>
> .env
>
> 里为
>
> pnpm dev
>
> 准备的
>
> DATABASE\_URL
>
> 悄悄污染,得出"还是被固化了"的假阳性结论。用
>
> nuxi build --dotenv=/一个不存在的路径
>
> 可以让本次构建完全不读
>
> .env
>
> ,模拟 CI 干净环境,再检查产物里的
>
> hub\:db
>
> 模块是不是
>
> process.env.DATABASE\_URL
>
> 这种运行时读取写法。
## 跨 Docker 网络 DNS 解析失败
### 现象
修复上一阶段并重新部署后,`ECONNREFUSED 127.0.0.1:5432` 消失,变成另一个同样确定性的报错:
```text
cause: Error: getaddrinfo EAI_AGAIN postgres
code: 'EAI_AGAIN', syscall: 'getaddrinfo', hostname: 'postgres'
```
`DATABASE_URL` 这次确实是运行时读取的、指向 `postgres:5432` 了(上一阶段修复生效),但容器内解析不出 `postgres` 这个主机名。
### 根因
`docker inspect` 两个容器的网络信息就能直接看出问题:
```sh [sh]
docker inspect postgres --format '{{json .NetworkSettings.Networks}}'
# → 网络 database_default(另一份独立的 docker-compose.yml 管理 postgres/redis/mongodb,
# 用 `networks: default: name: database_default` 显式命名)
docker inspect movk-studio --format '{{json .NetworkSettings.Networks}}'
# → 网络 webnet(应用栈那份 compose 文件,`webnet: external: true`)
```
两个容器分别属于两个互不相交的 Docker 用户自定义网络。Docker 内置 DNS 只在**同一个**用户自定义网络内的容器之间生效,不同网络之间即使在同一台宿主机上也互相解析不到——这和 CI 里 SSH 隧道能连通(那是从宿主机自身的回环地址访问,走的是端口发布,完全不经过容器间网络)是两条不相干的链路,此前 SSH 隧道迁移一直正常,恰恰说明和网络本身的连通性无关,纯粹是 DNS 解析不到。
### 修复
给应用容器所在的 compose 服务追加数据库所在的网络,两边都声明成 `external: true`(各自由对方那份 compose 文件创建,谁也不属于当前这份文件):
```yaml [docker-compose.yml]
services:
movk-studio:
# ...
networks:
- webnet
- database_default # 新增
networks:
webnet:
external: true
database_default: # 新增:引用另一份 compose 文件里 name: database_default 出来的网络
external: true
```
一个容器可以同时属于多个 Docker 网络,只需要改动需要访问数据库的这一个服务,其余纯前端服务不用动。改完执行 `docker compose up -d movk-studio` 让 Compose 检测到网络变化并重建这一个容器(不影响同 compose 文件里的其他服务)。
> \[!TIP]
>
> 不改 compose 文件、只想立刻验证的话,可以先对正在运行的容器执行
>
> docker network connect database\_default movk-studio
>
> 临时接入网络,无需重启,缺点是下次容器被重建(比如 watchtower 拉新镜像)后这个临时连接会丢失,必须把改动落回 compose 文件才能长期生效。
### 验证
```sh [sh]
docker inspect movk-studio --format '{{json .NetworkSettings.Networks}}'
# 应该同时看到 webnet 和 database_default 两个网络
docker exec movk-studio getent hosts postgres
# 应该能解析出 database_default 网络里 postgres 的内网 IP
```
再走一遍真实 GitHub 登录,`docker logs movk-studio` 里不应再出现 `ECONNREFUSED` 或 `EAI_AGAIN`。
> \[!NOTE]
>
> 三种根因相互独立:出海代理解决的是容器访问
>
> github.com
>
> /
>
> api.github.com
>
> 这类公网出海请求,后两种数据库连通性问题走的是宿主机内网。同一个回调路由报过一次 500,不代表下一次也是同一个原因——按本文开头的速查表逐条核对日志证据,而不是直接套用上一次的修复方案。
# Vercel 部署 llms-full.txt
## 官方已修复
- `compatibilityDate >= 2025-07-15` 启用 Vercel Functions Observability(路由级性能监控)
- 但会导致 **ISR 缓存失效**(cache 总是 miss)
- 相关 issue:[nuxt/nuxt#33140](https://github.com/nuxt/nuxt/issues/33140){rel=""nofollow""}
> \[!TIP]
>
> 官方已修复 bug,推荐使用
>
> ISR
>
> 方案:
```ts
// nuxt.config.ts
export default defineNuxtConfig({
compatibilityDate: 'latest',
modules: ['@nuxt/content', 'nuxt-llms'],
llms: {
domain: 'https://your-site.com',
title: 'Your Documentation',
full: {
title: 'Full Documentation',
description: 'Complete documentation'
}
},
routeRules: {
'/llms.txt': { isr: true }, // 永久缓存,部署时更新
'/llms-full.txt': { isr: true } // 同上
}
})
```
## 问题背景(已过期)
在 Vercel 平台上部署使用 `nuxt-llms` 模块生成的文档站点时,访问 `/llms-full.txt` 会遇到 500 错误。
> \[!CAUTION]
>
> Vercel 会将所有动态路由自动转换为 Serverless Function,导致 `llms-full.txt` 在生产环境无法正常访问。
>
> ```log [error-log]
> 2025-10-29T02:54:56.658Z [error] [request error] [unhandled] [GET] https://movk-nuxt-docs-docs-4trjpa04t-yixuans-projects-ca20164e.vercel.app/llms-full.txt
> Error [ERR_MODULE_NOT_FOUND]: Cannot find package '@nuxtjs/mdc' imported from /var/task/chunks/nitro/nitro.mjs
> at Object.getPackageJSONURL (node:internal/modules/package_json_reader:255:9)
> ... 8 lines matching cause stack trace ...
> at onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:646:36) {
> cause: Error [ERR_MODULE_NOT_FOUND]: Cannot find package '@nuxtjs/mdc' imported from /var/task/chunks/nitro/nitro.mjs
> at Object.getPackageJSONURL (node:internal/modules/package_json_reader:255:9)
> at packageResolve (node:internal/modules/esm/resolve:773:81)
> at moduleResolve (node:internal/modules/esm/resolve:859:18)
> at moduleResolveWithNodePath (node:internal/modules/esm/resolve:989:14)
> at defaultResolve (node:internal/modules/esm/resolve:1032:79)
> at ModuleLoader.defaultResolve (node:internal/modules/esm/loader:783:12)
> at #cachedDefaultResolve (node:internal/modules/esm/loader:707:25)
> at ModuleLoader.resolve (node:internal/modules/esm/loader:690:38)
> at ModuleLoader.getModuleJobForImport (node:internal/modules/esm/loader:307:38)
> at onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:646:36) {
> code: 'ERR_MODULE_NOT_FOUND'
> },
> statusCode: 500,
> fatal: false,
> unhandled: true,
> statusMessage: undefined,
> data: undefined
> }
> ```
### 问题原因分析
1. **Vercel 路由机制**:Vercel 将动态路由(包括某些静态资源)转为 Serverless Function
2. **文件生成时机**:`llms-full.txt` 在构建时生成,但被误识别为动态路由
3. **访问路径冲突**:LLM 工具期望直接访问静态文本文件,而非通过 Function 处理
## 解决方案
通过创建 Nuxt 模块,在构建时将 `llms-full.txt` 复制为 `_llms-full.txt`,并配置路由规则实现本地代理。
### 1. 创建 Nuxt 模块
创建 `modules/llms.ts` 文件,在 `nitro:build:public-assets` 钩子中复制文件:
```ts [modules/llms.ts]
import { defineNuxtModule } from '@nuxt/kit'
import { copyFile } from 'node:fs/promises'
import { join } from 'node:path'
export default defineNuxtModule({
meta: {
name: 'llms'
},
async setup(_options, nuxt) {
/**
* Vercel 部署优化
* @see https://vercel.com/docs/functions/configuring-functions/advanced-configuration
*
* 问题:Vercel 会将所有动态路由转为 Serverless Function,导致 500 错误
* 方案:访问 '_llms-full.txt' 静态资源以绕过此问题
*/
nuxt.hook('nitro:build:public-assets', async ({ options }) => {
const outputDir = options.output.publicDir
try {
const source = join(outputDir, 'llms-full.txt')
const dest = join(outputDir, '_llms-full.txt')
await copyFile(source, dest)
console.log(`✅ Copied: ${source} → ${dest}`)
} catch (err) {
console.warn(
`⚠️ Failed to process:`,
err instanceof Error ? err.message : String(err)
)
}
})
}
})
```
> \[!TIP]
>
> **关键钩子说明**
>
> - `nitro:build:public-assets`:在 Nitro 构建公共资源后触发
> - 此时 `llms-full.txt` 已生成,可安全复制
> - 错误处理:使用 `console.warn` 避免构建中断
### 2. 配置路由规则
在 `nuxt.config.ts` 中注册模块并配置本地开发代理:
```ts [nuxt.config.ts]
import { createResolver } from '@nuxt/kit'
const { resolve } = createResolver(import.meta.url)
export default defineNuxtConfig({
modules: [
resolve('./modules/llms') // [!code ++]
],
routeRules: {
// 本地开发环境:代理 _llms-full.txt 到原始路径
...process.env.NODE_ENV === 'development'
? {
'/_llms-full.txt': { proxy: '/llms-full.txt' }
}
: {}
}
})
```
> \[!NOTE]
>
> **路由规则说明**
>
> - **开发环境**:`/_llms-full.txt` 代理到 `/llms-full.txt`,保持一致性
> - **生产环境**:直接访问 `/_llms-full.txt` 静态文件
> - 使用条件表达式确保配置仅在开发模式生效
### 3. 文档中引用
在 Markdown 文档中添加访问链接:
```mdc [content/docs/llms.md]
::note{to="/llms.txt" target="_blank"}
查看为 Movk Nuxt Docs 文档本身生成的 `/llms.txt` 文件。
::
::note{to="/_llms-full.txt" target="_blank"}
查看为 Movk Nuxt Docs 文档本身生成的 `/_llms-full.txt` 文件。
::
```
### 环境差异处理
| 环境 | 访问路径 | 实际文件 | 说明 |
| ------ | ----------------- | ---------------------- | ------------------ |
| **开发** | `/_llms-full.txt` | `/llms-full.txt` (代理) | 保持开发一致性 |
| **生产** | `/_llms-full.txt` | `/_llms-full.txt` (静态) | 绕过 Vercel Function |
## 验证方法
### 本地测试
```bash [bash]
# 构建项目
pnpm build
# 检查生成的文件
ls -la .output/public/*llms*.txt
# 预期输出:
# llms.txt
# llms-full.txt
# _llms-full.txt
```
### 生产环境验证
部署到 Vercel 后,访问以下 URL:
- ✅ `https://your-domain.com/llms.txt`
- ✅ `https://your-domain.com/_llms-full.txt`
- ❌ ~~`https://your-domain.com/llms-full.txt`~~ (可能 500 错误)
# Copy Page
## 路由
- 路径:`/raw/[...slug].md`
- 返回:`text/markdown; charset=utf-8`
- 行为:基于 `@nuxt/content` 查询页面,缺少 H1/描述时自动注入,再用 `minimark/stringify` 输出为 Markdown。
> \[!NOTE]
> See: https\://github.com/nuxt/ui/blob/a32cc37f7392499ab02558e4d58b46195f7ffad4/docs/server/routes/raw/%5B...slug%5D.md.get.ts
>
> 服务器端实现
>
> server/routes/raw/\[...slug].md.get.ts
>
> 参考了 Nuxt UI 文档站的同名路由实现(思想与结构),以适配本项目需求。
### 关键点(精简)
- 仅处理以 `.md` 结尾的请求;非 `.md` 返回 404。
- 通过 `queryCollection('docs').path(route)` 查询对应文档。
- 统一输出为 Markdown,便于复制、下载与 LLM 抓取。
```ts [server/routes/raw/[...slug\\].md.get.ts]
// 仅示意关键步骤
setHeader(event, 'Content-Type', 'text/markdown; charset=utf-8')
return stringify({ ...page.body, type: 'minimark' }, { format: 'markdown/html' })
```
## 页面工具(PageHeaderLinks)
`app/components/PageHeaderLinks.vue` 提供便捷入口:
- **Copy page**:复制当前文档的 Markdown 原文
- **View as Markdown**:在新标签页打开 `/raw...[slug].md`
- **Open in ChatGPT / Claude**:以提示语引导模型抓取原文链接
```ts [app/components/PageHeaderLinks.vue]
// 复制当前文档原文(调用 /raw 路由)
async function copyPage() {
copy(await $fetch(`/raw${route.path}.md`))
}
```
## 使用建议
- 站内引用原文时,优先使用 `/raw... .md`,提升跨工具可读性。
- 若需禁止被搜索引擎索引,请结合 Robots 策略按需处理。
> \[!TIP]
> See: /docs/ecosystem/nuxt-llms
>
> 结合 LLM 链接规范化使用。
# CSRF 防护(nuxt-csurf)
`nuxt-csurf` 是一个开箱即用的 CSRF(跨站请求伪造)防护模块,通过「双提交 Cookie」(double submit cookie)模式保护会修改服务端状态的接口。
## 注册模块
```ts [nuxt.config.ts]
export default defineNuxtConfig({
modules: ['nuxt-csurf']
})
```
模块启用后,服务端中间件会自动为每个会话签发一个 CSRF token,写入 `httpOnly` Cookie,同时通过响应头将明文 token 暴露给客户端。
## 使用 useCsrf
```ts
const { csrf, headerName } = useCsrf()
```
| 返回值 | 说明 |
| ------------ | -------------------------------------------- |
| `csrf` | 当前会话的 CSRF token 字符串 |
| `headerName` | 携带 token 的请求头名称,可在模块配置中自定义,默认类似 `csrf-token` |
在发起 `POST`、`PATCH`、`DELETE` 等会修改服务端状态的请求时,将 token 放入对应请求头:
```ts
await $fetch(`/api/chats/${id}`, {
method: 'PATCH',
headers: { [headerName]: csrf },
body: { title: result }
})
```
服务端中间件会拦截这些方法,比对请求头中的 token 与 Cookie 中签发的 token 是否一致,不一致则返回 `403`。
> \[!NOTE]
>
> GET
>
> 请求通常无需携带该请求头,因为 CSRF 防护只针对会改变状态的方法,
>
> GET
>
> 语义上应保持幂等且无副作用。
## 工作原理
CSRF 攻击利用浏览器「自动携带 Cookie」的特性,诱导用户在不知情的情况下向目标站点发起状态变更请求。双提交 Cookie 模式的防御思路是:服务端签发的 token 同时存在于 Cookie 和一个自定义请求头中,攻击者发起的跨站请求虽然会自动带上 Cookie,却无法读取或伪造这个请求头的值,因此会被服务端拒绝。
> \[!TIP]
>
> useCsrf()
>
> 每次调用都是读取已签发的 token,而非生成新 token,因此在同一次页面会话中多处调用(例如不同组件或组合式函数各自调用)拿到的值是一致的,无需担心 token 不同步的问题。
# Nuxt LLMs
## 最小配置
> \[!NOTE]
> See: https\://nuxt.com/modules/llms
>
> nuxt-llms
>
> 自动生成
>
> llms.txt
>
> ,用于向 LLM 提供结构化站点说明,可选启用
>
> llms-full.txt
>
> 。
```ts [nuxt.config.ts]
export default defineNuxtConfig({
modules: ['nuxt-llms'],
llms: {
domain: 'https://mhaibaraai.cn',
title: 'YiXuan 的开发随笔',
description: '一个专注于技术分享与知识沉淀的个人网站。',
},
})
```
## 链接规范化(LLM 友好)
为保证 LLM 获取「可直接抓取的 Markdown 原文」,在服务端 Hook 中将站内链接重写为 `/raw...[slug].md`:
```ts [server/plugins/llms.ts]
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('llms:generate', (_, { sections }) => {
sections.forEach((s) => {
if (!s.links) return
s.links = s.links.map((l) => ({
...l,
href: `${l.href.replace(/^https:\/\/mhaibaraai.cn/, 'https://mhaibaraai.cn/raw')}.md`,
}))
})
})
})
```
> \[!TIP]
>
> 仅转换本站链接,避免误改外域;本地与生产的域名前缀需一致或做条件处理。
# NuxtHub + Drizzle ORM 数据库集成
> \[!NOTE]
>
> 本文讲的是
>
> 应用层
>
> ——Nuxt 项目如何通过 NuxtHub 接入 Drizzle ORM。PostgreSQL/Redis 本身用 Docker Compose 部署、通过 SSH 隧道安全连接的基础设施部分,见「数据库部署」一文,这里不重复。
## NuxtHub 与 Drizzle ORM 是什么
**[Drizzle ORM](https://orm.drizzle.team/){rel=""nofollow""}** 是一个 TypeScript 查询构造器:本质是把方法链编译成 SQL,没有像 Prisma 那样的独立 codegen 引擎;用 `pgTable`/`sqliteTable` 定义表结构后,类型和查询结果自动推导;同时提供贴近原生 SQL 的构造器语法(`db.select().from(table).where(...)`)和关系型查询 API(`db.query.xxx.findFirst()`)。
**[NuxtHub](https://hub.nuxt.com/){rel=""nofollow""}**(`@nuxthub/core`)不自己造 ORM,而是把 Drizzle 包装成开箱即用的能力:自动读取 `server/db/schema.ts`、自动管理迁移、并通过虚拟导入 `hub:db` 直接暴露一个配置好的 `db` 实例——省去手写 `drizzle(...)` 连接样板代码的过程。写代码时面对的还是 Drizzle 原生 API,NuxtHub 只负责连接、schema 注册、迁移这套自动化。
## 接入步骤
### 安装依赖
```sh [sh]
npx nuxi module add hub
pnpm add drizzle-orm postgres
pnpm add -D drizzle-kit
```
`postgres` 是 `postgres-js` 驱动包,负责建立标准 TCP 连接;如果用 SQLite(本地文件或 Turso),驱动换成 `@libsql/client`。
### nuxt.config.ts
`dialect` 必须显式声明,sqlite 与 postgresql 之间不会自动切换:
```ts [postgresql]
export default defineNuxtConfig({
modules: ['@nuxthub/core'],
hub: {
db: {
dialect: 'postgresql',
driver: 'postgres-js'
}
}
})
```
```ts [sqlite]
export default defineNuxtConfig({
modules: ['@nuxthub/core'],
hub: {
db: 'sqlite'
}
})
```
没有写死 `connection`,NuxtHub 会按环境变量优先级自动读取连接串:
| 方言 | 优先级 |
| -------------- | ------------------------------------------------------------------------------------- |
| PostgreSQL | `POSTGRES_URL` > `POSTGRESQL_URL` > `DATABASE_URL` |
| SQLite / Turso | `TURSO_DATABASE_URL` > `LIBSQL_URL` > `DATABASE_URL`,均未设置则落到本地文件 `.data/db/sqlite.db` |
本地开发不设 PostgreSQL 连接串时,会自动 fallback 到 **PGlite**(内嵌 WASM 版 Postgres),行为兼容,不需要额外装本地 Postgres 服务。
### server/db/schema.ts
Schema 必须放在这个约定路径(或拆到 `server/db/schema/` 目录),会被自动扫描注册到 `hub:db` 的 `schema` 对象上:
```ts [server/db/schema.ts]
import { pgTable, text, timestamp, uniqueIndex, index } from 'drizzle-orm/pg-core'
import { relations } from 'drizzle-orm'
export const users = pgTable('users', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
email: text('email').notNull(),
provider: text('provider', { enum: ['github'] }).notNull(),
providerId: text('provider_id').notNull(),
createdAt: timestamp('created_at').notNull().defaultNow()
}, table => [
uniqueIndex('users_provider_id_idx').on(table.provider, table.providerId)
])
export const chats = pgTable('chats', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
userId: text('user_id').notNull(),
createdAt: timestamp('created_at').notNull().defaultNow()
}, table => [
index('chats_user_id_idx').on(table.userId)
])
export const chatsRelations = relations(chats, ({ one }) => ({
user: one(users, { fields: [chats.userId], references: [users.id] })
}))
```
> \[!TIP]
>
> 主键用应用层生成的
>
> text
>
> (
>
> crypto.randomUUID()
>
> )而不是数据库自增列,是因为插入时经常需要显式指定
>
> id
>
> (比如把某个已知的会话 ID 当作用户 ID),生成权放在应用层更灵活。
### 在服务端路由中使用
```ts [server/api/example.get.ts]
import { db, schema } from 'hub:db'
import { eq } from 'drizzle-orm'
export default eventHandler(async () => {
return await db.select().from(schema.users).where(eq(schema.users.id, '1'))
})
```
## schema → migrations → 数据库:工作流程原理
这套机制经常让人困惑,核心是分清楚四个角色各自的职责:
```text
server/db/schema.ts ← 「想要」的表结构(TS 描述,目标状态,不操作数据库)
│ nuxt db generate(纯本地 diff,不连数据库)
▼
migrations/*.sql ← schema 差异翻译出的 SQL 建表/改表语句
migrations/meta/snapshot.json ← 上次生成迁移时 schema 的快照,供下次 diff 用
migrations/meta/_journal.json ← 迁移文件的执行顺序清单
│ nuxt db migrate(这一步才真正连接数据库)
▼
真实数据库 ← 按顺序执行「还没跑过」的 .sql,并在库内建一张
记账表记录已执行的文件名,避免重复执行
│
▼
hub:db(运行时) ← 纯连接池 + 类型映射,不参与建表,
import { db, schema } from 'hub:db' 表必须已经被迁移建好它才查得到
```
### 生成迁移
```sh [sh]
pnpm db:generate
```
对应 `package.json` 里的 `"db:generate": "nuxt db generate"`。这一步是离线操作:拿 `schema.ts` 现在的样子和 `meta/snapshot.json` 记录的「上次生成迁移时」的样子做 diff,把差异写成新的 `.sql` 文件,同时更新快照。因为不连接真实数据库,哪怕没配连接串也能跑通。
### 应用迁移
```sh [sh]
pnpm db:migrate
```
对应 `"db:migrate": "nuxt db migrate"`。这一步才真正连上 `DATABASE_URL` 指向的数据库:检查库里的记账表,把没跑过的迁移文件按顺序执行,再把执行记录写回记账表。这样同一份迁移不会被重复执行,团队协作或多环境部署时表结构演进历史也能保持一致。
> \[!WARNING]
>
> meta/
>
> 目录下的快照和 journal 文件不要手动删除或修改,它们是
>
> db\:generate
>
> 做增量 diff 的依据,删掉会导致下次生成的迁移文件把所有表当成新表重新建一遍。
这套流程存在的意义:让「数据库长什么样」这件事有版本历史、可追溯,能在本机、测试服、生产服之间以同样的步骤重放,而不是靠手工在 Navicat 里点点点建表——那样改了什么、什么时候改的,完全没有记录。
## SQLite 与 PostgreSQL 怎么选
| | SQLite(NuxtHub,libsql) | PostgreSQL |
| -------- | ----------------------------------- | --------------------------------- |
| 部署复杂度 | 零外部依赖,本地文件或 Turso 托管 | 需要单独维护一个数据库服务 |
| 并发写入 | 单文件写锁,高并发写入会排队 | 原生支持多连接并发读写 |
| 多副本/横向扩展 | 本地文件方案不支持多副本,要扩展得接 Turso 或自建 `sqld` | 天然支持多个应用实例连同一库 |
| 类型/特性丰富度 | 基础类型为主 | JSON/JSONB、数组、全文检索、`pgvector` 等扩展 |
| 适合场景 | 中小流量、单实例、想少运维一个服务 | 已有成熟 Postgres 基建、需要扩展性或高级特性 |
如果服务器上已经装了 Postgres,直接复用是最省事的路径——不用再考虑 SQLite 文件持久化、卷挂载、单实例写锁这些问题。这不算“Postgres 更优”,而是运维成本已经沉没时的现实选择。
## 部署篇
### Vercel
Vercel 是无状态 serverless,不能写本地文件,数据库要接远程服务:
1. 在项目 **Storage → Marketplace** 添加 **Turso**(SQLite 场景)或直接用托管 Postgres,会自动注入连接所需的环境变量(如 `TURSO_DATABASE_URL`/`TURSO_AUTH_TOKEN`)。
2. NuxtHub 按前文的优先级探测到这些环境变量后自动切换驱动,**代码不用改**。
3. Vercel 在构建阶段就已注入这些环境变量,默认的 `applyMigrationsDuringBuild`(默认开启)会在构建时自动跑迁移;也可以 `vercel env pull` 拉到本地后手动 `pnpm db:migrate`。
4. 如果用到 `hub.blob`,同理在 Storage 里挂一个 Vercel Blob store,自动注入 `BLOB_READ_WRITE_TOKEN`,NuxtHub 自动切到 `vercel-blob` driver。
### GitHub CI + Docker 自建服务器
自建场景的核心问题是:**镜像构建阶段通常拿不到、也不该拿到生产数据库凭证**。所以要显式关掉构建期自动迁移:
```ts [nuxt.config.ts]
export default defineNuxtConfig({
hub: {
db: {
dialect: 'postgresql', // 或 sqlite + driver: 'libsql'
driver: 'postgres-js',
applyMigrationsDuringBuild: false,
applyMigrationsDuringDev: false
}
},
nitro: {
preset: 'node-server'
}
})
```
数据库落地方式常见两种:
- **直连服务器上已有的 Postgres**:应用容器和 Postgres 在同一台机器/同一网络,运行时用 `DATABASE_URL=postgresql://user:pass@localhost:5432/db` 直连,不需要额外起数据库服务。
- **本地 SQLite 文件 + 持久化卷**:容器挂一个 volume,`DATABASE_URL=file:/data/sqlite.db`,简单但只适合单实例,多副本会有写冲突。
因为关掉了自动迁移,迁移动作要挪到部署流水线里单独执行(镜像 push 完、应用容器重启前):
```sh [sh]
docker run --rm --env-file .env.production your-image:latest node_modules/.bin/nuxt db migrate
```
> \[!WARNING]
>
> applyMigrationsDuringBuild: false
>
> 只是关掉了「构建期缺少连接串就报错」这个检查,
>
> 不能
>
> 阻止 NuxtHub 把连接串固化进产物——只要构建期
>
> process.env
>
> 里
>
> POSTGRES\_URL
>
> /
>
> POSTGRESQL\_URL
>
> /
>
> DATABASE\_URL
>
> 三者之一非空,NuxtHub 对
>
> postgres-js
>
> driver(非 dev、非 Cloudflare 托管场景)就会把它当字符串字面量写死进编译产物,和这个开关无关。真正要保证的是:
>
> 跑
>
> nuxt build
>
> 的那一刻,构建环境里这三个变量必须全部不存在
>
> ,产物里才会保留
>
> process.env.DATABASE\_URL
>
> 这行运行时读取代码。详细踩坑过程见「
>
> 自建服务器 GitHub OAuth 回调 500 排查 · 连接串在构建期被固化成字面量
>
> 」。
## 连接已有远程 PostgreSQL 的坑
用 Navicat 之类的 GUI 工具能通过 SSH 隧道连上服务器的 Postgres,是因为工具内置了 SSH 客户端,帮你在本机和服务器之间建了隧道,再把流量转发给只监听 `127.0.0.1:5432` 的数据库进程。**应用层的驱动(`postgres`/`@libsql/client`)不会自动建隧道**,本地开发要自己手动开一条:
```sh [sh]
ssh -N -L 5433:localhost:5432 @<服务器地址>
```
- `-L 本地端口:目标主机:目标端口`,冒号后面那部分是**从 SSH 服务器的视角**看的,不是本机视角——Postgres 实际监听的是服务器上的 `5432`,写成 `-L 5433:localhost:5433` 这种「本地端口和目标端口对齐」是常见的手误,隧道照样能建立,但转发到了服务器一个没人监听的端口,实际连接时才会报 `connection refused`。
- 本机端口特意避开 `5432`,防止和本机可能装的 Postgres 冲突。
- `-N` 表示只做端口转发不进远程 shell,敲完密码后**终端会一直没有任何输出、卡在那不返回提示符**——这是正常现象,不是卡死,只要没报错断开就说明隧道已经建立并挂在后台,另开一个新终端继续操作即可。
- `.env` 里连接串写本机隧道端口:`DATABASE_URL=postgresql://user:pass@localhost:5433/db`。
三段式拆开看会更清楚每一跳分别落在谁的视角上:
```text
本机 localhost:5433 → SSH 隧道 → 服务器视角的 localhost:5432 → Postgres 进程
(应用驱动实际连接的地址) (加密转发,不经过公网端口) (-L 冒号右侧写的是这里,不是本机)
```
> \[!TIP]
>
> 另开一个终端用
>
> lsof -iTCP:5433 -sTCP\:LISTEN
>
> 验证隧道是否建立成功,能看到一条
>
> ssh
>
> 进程占用该端口就说明本地转发端已经就绪。
> \[!WARNING]
>
> 不要为了省事直接把 Postgres 对公网开放端口,或在
>
> pg\_hba.conf
>
> 里允许任意来源 IP 连接——这是不必要的攻击面。等应用真正部署到同一台服务器上跑起来后,直连
>
> localhost:5432
>
> 即可,隧道只是本机开发访问远程内网服务的临时手段。
新项目建议单独建库、单独建账号,不要和服务器上已有项目共用同一个数据库/账号:
```sql [psql]
CREATE DATABASE movk_studio;
CREATE USER movk_app WITH PASSWORD '<强密码>';
GRANT ALL PRIVILEGES ON DATABASE movk_studio TO movk_app;
-- PostgreSQL 15+ 默认收回了 public schema 的 CREATE 权限,
-- 上面这条只是库级别的授权,还要单独给 public schema 建表权限,
-- 否则跑迁移时会报 permission denied for schema public
\c movk_studio
GRANT USAGE, CREATE ON SCHEMA public TO movk_app;
```
## 常见报错排查
两个报错都发生在跑 migration 或应用第一次建表时,都和 `public` schema 的权限相关,容易混淆,先看区别:
| 报错信息 | 触发场景 | 根本原因 |
| ------------------------------------------ | ---------------------------------------- | ------------------------------------------ |
| `permission denied for schema public` | 连接角色能定位到 `public` schema,但没有 `CREATE` 权限 | PostgreSQL 15+ 默认收回了 `public` schema 的建表权限 |
| `no schema has been selected to create in` | 连接角色的 `search_path` 解析不出任何可用 schema | `public` schema 缺失,或角色 `search_path` 未配置 |
### permission denied for schema public
从 PostgreSQL 15 开始,新建数据库的 `public` schema 默认不再给普通用户 `CREATE` 权限,只有 schema 所有者(通常是建库用的 `postgres` 超级用户)和超级用户能在里面建表。`GRANT ALL PRIVILEGES ON DATABASE` 只到数据库层面,不包含这条。上面「建库」步骤里已经补上了对应的 `GRANT USAGE, CREATE ON SCHEMA public`;如果是已经建好的老库遇到这个报错,直接补跑这一句就行。
不想处理权限的话,`.env` 里 `DATABASE_URL` 直接用 `postgres` 超级用户连接也能绕开——超级用户不受 schema 权限限制,个人项目短期用没问题,只是权限隔离弱一些。
### no schema has been selected to create in
比上面更底层的一种情况:连接角色的 `search_path`(决定不写 schema 名时去哪建表的搜索路径)解析不出可用的 schema。先诊断 `public` 是否存在:
```sql [psql]
SELECT schema_name FROM information_schema.schemata;
```
- 如果有 `public` 但还是报错,通常是权限/`search_path` 没配全:
```sql \[psql]
GRANT USAGE, CREATE ON SCHEMA public TO movk_app;
ALTER ROLE movk_app SET search_path TO public;
```
:br`ALTER ROLE` 对已经打开的连接不生效,重新执行 `pnpm db:migrate` 建立新连接时才会应用。
- 如果没有 `public`(比如库是从精简模板建的,或者被误删过),建回来:
```sql \[psql]
CREATE SCHEMA public;
GRANT USAGE, CREATE ON SCHEMA public TO movk_app;
```
## 端到端验证:走通一次 GitHub 登录
`users`/`chats` 表建出来之后,真正验证「写入」这条链路是否走通,最直接的办法是走一遍 GitHub OAuth 登录——路由文件用到的 `defineOAuthGitHubEventHandler`/`getUserSession`/`setUserSession` 来自 [nuxt-auth-utils](https://github.com/atinux/nuxt-auth-utils){rel=""nofollow""},和数据库配置是两回事,但常常一起踩坑,所以放这里一并记录。
> \[!NOTE]
>
> 如果项目基于
>
> @movk/nuxt
>
> ,
>
> nuxt-auth-utils
>
> 已经作为依赖内置,不需要额外安装或在
>
> modules
>
> 里注册,直接用
>
> defineOAuthGitHubEventHandler
>
> 等全局方法即可。
`nuxt-auth-utils` 启动时会检查两个环境变量,缺了会直接报错 `Missing NUXT_OAUTH_GITHUB_CLIENT_ID or NUXT_OAUTH_GITHUB_CLIENT_SECRET env variables`:
### 创建 GitHub OAuth App
打开 [github.com/settings/applications/new](https://github.com/settings/applications/new){rel=""nofollow""},填:
| 字段 | 值 |
| -------------------------- | -------------------------------------- |
| Homepage URL | `http://localhost:` |
| Authorization callback URL | `http://localhost:/auth/github` |
回调路径固定是 `/auth/github`,对应约定路由文件 `server/routes/auth/github.get.ts`;端口要填你本地实际跑的 dev 端口(默认 `3000`,项目里改过 `--port` 就用改过的那个)。
### 填入环境变量
创建完在 App 详情页拿到 **Client ID**,点 **Generate a new client secret** 生成 **Client Secret**:
```bash [.env]
NUXT_OAUTH_GITHUB_CLIENT_ID=
NUXT_OAUTH_GITHUB_CLIENT_SECRET=
```
环境变量是启动时读取的,改完 `.env` 要重启 `pnpm dev` 才生效。
### 验证写入
走一遍登录流程,回 Navicat 里刷新 `users` 表,能看到插进去的一条记录,说明「登录路由 → Drizzle → PostgreSQL」整条链路都通了。
# Nuxt SEO
## 安装 Nuxt SEO
Nuxt SEO 模块:`@nuxtjs/seo` 集合了多个 SEO 相关的模块,包括:
- [`Robots`](https://nuxt.com/modules/robots){rel=""nofollow""}:生成 `robots.txt` 文件,控制搜索引擎爬虫的抓取行为。
- [`Sitemap`](https://nuxt.com/modules/sitemap){rel=""nofollow""}:自动生成 `sitemap.xml` 站点地图,支持与 Nuxt Content 集成。
- [`OG Image`](https://nuxt.com/modules/og-image){rel=""nofollow""}:动态生成社交媒体分享图片,用于微信、Twitter 等平台预览。
- [`Schema.Org`](https://nuxt.com/modules/schema-org){rel=""nofollow""}:注入 Schema.org 结构化数据,帮助搜索引擎理解页面内容。
- [`Link Checker`](https://nuxt.com/modules/link-checker){rel=""nofollow""}:构建时检查并报告网站中的死链。
- [`SEO Utils`](https://nuxtseo.com/docs/seo-utils/getting-started/introduction){rel=""nofollow""}:提供一些实用的 SEO 工具函数,例如 `findPageHeadline` 用于从导航数据中提取页面标题。
> \[!NOTE]
> See: https\://nuxtseo.com/docs/nuxt-seo/getting-started/installation
>
> 详见
>
> @nuxtjs/seo
>
> 官方安装文档。
## 建立站点元数据中心
在 `nuxt.config.ts` 中引入新的顶层 `site` 配置块。这是 `@nuxtjs/seo` 的基石。
> \[!NOTE]
> See: https\://nuxtseo.com/docs/nuxt-seo/guides/using-the-modules#shared-configuration
>
> Nuxt SEO 站点配置
```ts [nuxt.config.ts]
export default defineNuxtConfig({
site: {
url: 'https://mhaibaraai.cn',
name: 'YiXuan 的开发随笔',
logo: '/avatar.png',
description: '一个专注于技术分享与知识沉淀的个人网站。'
}
})
```
> \[!TIP]
>
> trailingSlash: true
>
> 将 URL 统一为以斜杠(
>
> /
>
> )结尾,避免搜索引擎因视
>
> /page
>
> 与
>
> /page/
>
> 为不同页面而产生重复内容问题,从而集中页面权重。
## Nuxt Content 集成
> \[!NOTE]
> See: https\://nuxtseo.com/docs/nuxt-seo/guides/using-the-modules#nuxt-content-integration
>
> Nuxt SEO 与 Nuxt Content 集成
```ts [content.config.ts]
import { defineCollection, defineContentConfig } from '@nuxt/content'
import { asSeoCollection } from '@nuxtjs/seo/content'
export default defineContentConfig({
collections: {
landing: defineCollection(
asSeoCollection({
type: 'page',
source: 'index.md',
}),
),
docs: defineCollection(
asSeoCollection({
type: 'page',
source: {
include: '**',
exclude: ['index.md'],
},
}),
),
},
})
```
## 配置 Robots
> \[!NOTE]
> See: https\://nuxtseo.com/docs/robots/getting-started/introduction
>
> 配置 Robots 模块
```ts [nuxt.config.ts]
import packageJson from './package.json'
defineNuxtConfig({
robots: {
sitemap: `${packageJson.homepage}/sitemap.xml`, // 指向你的站点地图
},
})
```
## 配置 Sitemap
> \[!NOTE]
> See: https\://nuxtseo.com/docs/sitemap/getting-started/introduction
>
> 配置 Sitemap 模块
示例:更改列数并添加优先级和 **changeFreq** 字段
```ts [nuxt.config.ts]
export default defineNuxtConfig({
sitemap: {
xslColumns: [
{ label: 'URL', width: '50%' },
{ label: 'Last Modified', select: 'sitemap:lastmod', width: '25%' },
{ label: 'Priority', select: 'sitemap:priority', width: '12.5%' },
{ label: 'Change Frequency', select: 'sitemap:changefreq', width: '12.5%' },
],
},
})
```
> \[!TIP]
> See: https\://nuxt.com/docs/4.x/getting-started/prerendering#selective-pre-rendering
>
> 你可以结合 `crawlLinks` 选项来预渲染一些爬虫无法发现的路由,比如你的 `/sitemap.xml` 或 `/robots.txt`。
>
> ```ts [nuxt.config.ts]
> export default defineNuxtConfig({
> // https://nitro.build/config
> nitro: {
> prerender: {
> routes: ['/', '/sitemap.xml', '/robots.txt'],
> crawlLinks: true,
> autoSubfolderIndex: false,
> },
> },
> })
> ```
## 配置 OG Image
> \[!NOTE]
> See: https\://nuxtseo.com/docs/og-image/getting-started/introduction
>
> 配置 OG Image 模块
> \[!WARNING]
> See: https\://nuxtseo.com/docs/og-image/guides/non-english-locales
>
> 中文网站需要配置字体,否则会显示乱码。
```ts [nuxt.config.ts]
export default defineNuxtConfig({
ogImage: {
zeroRuntime: true,
googleFontMirror: 'fonts.loli.net',
fonts: [
// 思源黑体 - 支持中文
'Noto+Sans+SC:400',
'Noto+Sans+SC:500',
'Noto+Sans+SC:700',
// 如果需要英文字体
'Inter:400',
'Inter:700'
]
}
})
```
[](https://mhaibaraai.cn/){rel=""nofollow""}
## 配置 Link Checker
> \[!NOTE]
> See: https\://nuxtseo.com/docs/link-checker/getting-started/introduction
>
> 配置 Link Checker 模块
```ts [nuxt.config.ts]
export default defineNuxtConfig({
linkChecker: {
// 配置报告输出
report: {
publish: true, // 是否发布报告
html: true,
markdown: true,
json: true,
},
},
})
```
## 配置 Schema.Org
> \[!NOTE]
> See: https\://nuxtseo.com/docs/schema-org/getting-started/introduction
>
> 配置 Schema.Org 模块
当您的网站是关于个人、个人品牌或个人博客时,应使用 `Person` 身份。
```ts [nuxt.config.ts]
export default defineNuxtConfig({
schemaOrg: {
identity: definePerson({
name: 'YiXuan',
image: '/avatar.png',
url: 'https://mhaibaraai.cn',
description: '一个专注于技术分享与知识沉淀的个人网站。',
email: 'mhaibaraai@gmail.com',
sameAs: [
'https://github.com/mhaibaraai',
],
}),
},
})
```
# Nuxt SSR + PM2 部署
## 前置要求与架构说明
- **部署目标**:本项目以 SSR 方式运行,主站由 Nginx(Docker)反代至 Node 服务(PM2 集群托管)。
- **监听策略**:Node 监听 `0.0.0.0:3000`(容器通过 `host.docker.internal` 访问宿主机端口)。
- **域名与 CDN**:域名走 Cloudflare,需关闭 Rocket Loader/Mirage/Email Obfuscation 以避免注入脚本导致水合异常。
> \[!NOTE]
> See: https\://nuxt.com/docs/4.x/getting-started/deployment#nodejs-server
>
> 参考 Nuxt 官方文档(Node.js Server 入口:
>
> node .output/server/index.mjs
>
> ,可用
>
> HOST/PORT
>
> 或
>
> NITRO\_HOST/NITRO\_PORT
>
> 控制)。
> \[!NOTE]
> See: https\://pm2.keymetrics.io/docs/usage/quick-start/
>
> 参考 PM2 官方文档(集群模式
>
> instances: 'max'
>
> ,守护、日志、开机自启、零停机重载)。
## 服务器环境准备
### 安装 PM2
> \[!TIP]
> See: /docs/guides/runtime/node#直接下载安装
>
> 在 Linux 上安装 Node 22、pnpm 请参考 Node.js 安装指南
```sh [sh]
# 安装
npm i -g pm2
# 查看 pm2 版本
pm2 --version
# 查看 pm2 状态
pm2 status
```

### 配置 PM2 开机自启
为确保服务器重启后应用自动恢复,需要配置 PM2 开机自启:
```sh [sh]
# 1. 生成并配置启动脚本
pm2 startup
# 2. 保存当前进程列表
pm2 save
```
### 验证 PM2 自启配置
```sh [sh]
# 测试重启后恢复
sudo reboot
# 重启后检查应用状态
pm2 list
pm2 logs
```
> \[!WARNING]
>
> **`pm2 startup` 行为说明**:
>
> **Root 用户**:
>
> - PM2 会自动配置系统服务,无需手动执行额外命令
> - 直接创建 `/etc/systemd/system/pm2-root.service` 并启用
>
> **普通用户**:
>
> - PM2 会输出需要手动执行的 sudo 命令
> - 需要复制完整的 sudo 命令并执行,例如:
> ```sh
> sudo env PATH=$PATH:/usr/bin /usr/local/lib/node_modules/pm2/bin/pm2 startup systemd -u username --hp /home/username
> ```
>
> **通用注意事项**:
>
> - `pm2 startup` 只需在服务器初次配置时执行一次
> - `pm2 save` 在每次应用更新后执行,保持进程列表同步
> - 使用 `pm2 unstartup systemd` 可以移除开机自启配置
### (可选)UFW 防火墙策略
```sh [sh]
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw deny 3000/tcp
```
## Nginx(Docker)反代至 Node(宿主机)
> \[!TIP]
> See: /docs/guides/deployment/docker
>
> 参考 Docker 安装和使用指南
确保 `docker-compose.yml` 已配置:
```yml [docker-compose.yml]
services:
nginx:
image: nginx:latest
container_name: my-nginx
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
- ./html:/usr/share/nginx/html:ro
- ./etc/ssl:/etc/ssl:ro
extra_hosts:
- "host.docker.internal:host-gateway" # 添加这一行,用于容器内访问宿主机端口
restart: unless-stopped
```
将主站从静态目录切换为反代 Node :
```conf
server {
# 主站改为反代到 Node SSR
location / {
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://host.docker.internal:3000;
}
}
```
重启:
```sh [sh]
docker compose -f /root/my-nginx/docker-compose.yml restart nginx
```
## PM2 配置(集群模式)
在项目根目录新增 `ecosystem.config.cjs`:
```js [ecosystem.config.cjs]
module.exports = {
apps: [
{
name: 'mhaibaraai.cn',
script: './.output/server/index.mjs',
exec_mode: 'cluster',
instances: 'max',
env: {
HOST: '0.0.0.0',
PORT: '3000',
NITRO_HOST: '0.0.0.0',
NITRO_PORT: '3000'
},
max_memory_restart: '512M',
out_file: './.pm2/out.log',
error_file: './.pm2/error.log',
time: true
}
]
}
```
首启与持久化:
```sh [sh]
pm2 start ecosystem.config.cjs
pm2 save
pm2 startup
```
## GitHub Actions(CI)与 SSH 发布
> \[!NOTE]
>
> CI Secrets 最小集,其余参数在工作流中直接定义:
>
> - `SSH_PRIVATE_KEY` - SSH 私钥
> - `SSH_HOST` - SSH 主机
```yml [.github/workflows/deploy.yml]
name: Deploy
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
env:
NODE_OPTIONS: --max_old_space_size=4096
permissions:
contents: write
id-token: write
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: lts/*
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Prepare build
run: pnpm run dev:prepare
- name: Run build (SSR)
run: pnpm run build
- name: Setup SSH agent
uses: webfactory/ssh-agent@v0.9.1
with:
ssh-private-key: |
${{ secrets.SSH_PRIVATE_KEY }}
- name: Add known_hosts
env:
SSH_HOST: ${{ secrets.SSH_HOST }}
SSH_PORT: 22
run: |
mkdir -p ~/.ssh
ssh-keyscan -H "$SSH_HOST" -p "$SSH_PORT" >> ~/.ssh/known_hosts
- name: Deploy to server via SSH
env:
SSH_USER: root
SSH_HOST: ${{ secrets.SSH_HOST }}
SSH_PORT: 22
DEPLOY_DIR: /root/my-nginx/html/www/mhaibaraai.cn
PM2_APP_NAME: mhaibaraai.cn
run: |
bash scripts/deploy.sh
- name: Deploy build artifacts to gh-pages
uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./.output
publish_branch: gh-pages
force_orphan: true
commit_message: 'chore(release): build artifacts'
```
```sh [scripts/deploy.sh]
#!/usr/bin/env bash
set -euo pipefail
# Required environment variables
SSH_USER="${SSH_USER:?missing}"
SSH_HOST="${SSH_HOST:?missing}"
SSH_PORT="${SSH_PORT:?missing}"
DEPLOY_DIR="${DEPLOY_DIR:?missing}"
PM2_APP_NAME="${PM2_APP_NAME:-nuxt-app}"
# Validate environment variables
[[ "$SSH_PORT" =~ ^[0-9]+$ ]] && [[ "$SSH_PORT" -ge 1 ]] && [[ "$SSH_PORT" -le 65535 ]] || { echo "[deploy] Invalid SSH_PORT" >&2; exit 1; }
[[ "$DEPLOY_DIR" =~ \.\. ]] || [[ "$DEPLOY_DIR" == */ ]] && { echo "[deploy] Invalid DEPLOY_DIR" >&2; exit 1; }
[[ "$SSH_HOST" =~ ^[a-zA-Z0-9.-]+$ ]] || { echo "[deploy] Invalid SSH_HOST" >&2; exit 1; }
echo "[deploy] Host=${SSH_USER}@${SSH_HOST}:${SSH_PORT}"
echo "[deploy] Target dir=${DEPLOY_DIR}"
# Local safety checks
TARGET_DIR="${DEPLOY_DIR%/}/.output"
[[ -z "$TARGET_DIR" ]] || [[ "$TARGET_DIR" == "/" ]] || [[ "$(basename -- "$TARGET_DIR")" != ".output" ]] && { echo "[deploy] invalid TARGET_DIR='$TARGET_DIR'" >&2; exit 3; }
[[ ! -d ./.output ]] || [[ -z "$(ls -A ./.output 2>/dev/null || true)" ]] && { echo "[deploy] local ./.output missing or empty" >&2; exit 4; }
# Connection options with safe escaping
SSH_OPTS="-p $(printf '%q' "$SSH_PORT")"
RSYNC_SSH="ssh ${SSH_OPTS}"
SSH_TARGET="$(printf '%q' "$SSH_USER")@$(printf '%q' "$SSH_HOST")"
DEPLOY_DIR_ESC=$(printf '%q' "$DEPLOY_DIR")
# Sync files
RSYNC_PATH="mkdir -p ${DEPLOY_DIR_ESC}/.output && rsync"
rsync -az --delete-after -e "${RSYNC_SSH}" --rsync-path "${RSYNC_PATH}" ./.output/ "${SSH_TARGET}:${DEPLOY_DIR_ESC}/.output/"
[[ -f ./ecosystem.config.cjs ]] && rsync -az -e "${RSYNC_SSH}" ./ecosystem.config.cjs "${SSH_TARGET}:${DEPLOY_DIR_ESC}/ecosystem.config.cjs"
# Remote pm2 reload/start
ssh ${SSH_OPTS} "${SSH_TARGET}" "export DEPLOY_DIR='${DEPLOY_DIR}' PM2_APP_NAME='${PM2_APP_NAME}'; bash -s" <<'REMOTE_EOF'
set -eo pipefail
export NODE_OPTIONS=--max_old_space_size=1024
export PATH="/usr/local/bin:/usr/bin:/bin:$PATH"
# Resolve Node/npm global prefix bin
PREFIX_BIN=""
if command -v npm >/dev/null 2>&1; then
PREFIX_BIN="$(npm config get prefix 2>/dev/null)/bin"
elif [[ -d "$HOME/.local/share/fnm/node-versions" ]]; then
LATEST_NODE_DIR="$(ls -1dt "$HOME/.local/share/fnm/node-versions"/*/installation 2>/dev/null | head -n 1 || true)"
[[ -n "$LATEST_NODE_DIR" ]] && PREFIX_BIN="$LATEST_NODE_DIR/bin"
fi
[[ -z "$PREFIX_BIN" ]] && PREFIX_BIN="/usr/local/bin"
export PATH="$PREFIX_BIN:$PATH"
echo "[remote] prefix bin: $PREFIX_BIN"
# Locate pm2
PM2_BIN="$(command -v pm2 || true)"
[[ -z "$PM2_BIN" ]] && { echo "[remote] pm2 not found" >&2; exit 127; }
echo "[remote] using pm2: $PM2_BIN"
cd "${DEPLOY_DIR}"
mkdir -p .pm2
echo "[remote] PM2 operation for $PM2_APP_NAME..."
if "$PM2_BIN" describe "$PM2_APP_NAME" >/dev/null 2>&1; then
"$PM2_BIN" reload "$PM2_APP_NAME" --update-env
else
PM2_APP_NAME="$PM2_APP_NAME" "$PM2_BIN" start ecosystem.config.cjs
"$PM2_BIN" save
fi
"$PM2_BIN" status | cat
# Health check
if "$PM2_BIN" describe "$PM2_APP_NAME" | grep -q "status.*online"; then
echo "[remote] health check passed"
else
echo "[remote] health check failed" >&2
"$PM2_BIN" logs "$PM2_APP_NAME" --lines 20 --nostream || true
exit 1
fi
REMOTE_EOF
echo "[deploy] Completed"
```
## Nuxt 本地调试脚本
`package.json` 增加 `start` 便于本地/服务调试:
```json [package.json]
{
"scripts": {
"start": "node .output/server/index.mjs"
}
}
```
## 提交站点地图
> \[!TIP]
> See: https\://nuxtseo.com/docs/sitemap/guides/submitting-sitemap
>
> 参考 SEO 指南(站点地图提交)
首先确保站点地图已生成,并上传到 `./public/sitemap.xml` 目录下。例如:[`https://mhaibaraai.cn/sitemap.xml`](https://mhaibaraai.cn/sitemap.xml){rel=""nofollow""}

## 验证与排错
- 容器内验证:`curl -I http://host.docker.internal:3000` 应返回 200。
- 通过域名访问页面,关注 `pm2 logs` 与 Nginx 访问/错误日志。
- 若出现 `better-sqlite3` ABI 不匹配,确保在目标机上重新安装与构建(见项目的 Nuxt 踩坑笔记)。
> \[!TIP]
> See: https\://nuxt.com/docs/4.x/getting-started/deployment#cdn-proxy
>
> 如果经 Cloudflare,确保关闭 Rocket Loader/Mirage/Email Obfuscation,防止注入脚本引发水合异常。
# Vercel + Cloudflare 部署
> \[!NOTE]
>
> 在开始之前,确保你已拥有:
>
> - 一个 Nuxt 项目
> - GitHub/GitLab/Bitbucket 账号
> - Vercel 账号(可使用 GitHub 登录)
> - Cloudflare 账号
> - 已在 Cloudflare 托管的域名(如 `mhaibaraai.cn`)
## 第一步:部署到 Vercel
> \[!NOTE]
> See: https\://vercel.com
>
> 访问 Vercel,选择
>
> Continue with GitHub
>
> 登录。
### 导入项目
1. 进入 Vercel 控制台
2. 点击 **Add New\...** → **Project**
3. 在列表中找到你的仓库,点击 **Import**
### 配置部署设置
Vercel 会自动检测 Nuxt 框架,通常无需修改默认配置:

### 配置环境变量(可选)
如果项目需要环境变量,在 **Environment Variables** 区域添加:
```bash
# 示例环境变量
NUXT_PUBLIC_API_BASE=https://api.mhaibaraai.cn
DATABASE_URL=postgresql://user:pass@host:5432/db
```
> \[!TIP]
>
> 敏感信息建议使用 Vercel 的环境变量管理,不要提交到 Git。
## 第二步:配置 Cloudflare DNS
### 登录 Cloudflare
> \[!NOTE]
> See: https\://dash.cloudflare.com
>
> 访问 Cloudflare Dashboard
在域名列表中选择 `mhaibaraai.cn`
### 添加 DNS 记录
进入 **DNS** → **Records**,根据域名类型添加相应记录:
**子域名配置(如 docs.mhaibaraai.cn):**
| 类型 | 名称 | 目标 | 代理状态 | TTL |
| ----- | ---- | -------------------- | --------- | --- |
| CNAME | docs | cname.vercel-dns.com | **仅 DNS** | 自动 |
**根域名配置(如 mhaibaraai.cn):**
在 Vercel 添加域名时查看推荐的 A 记录配置:

| 类型 | 名称 | 目标 | 代理状态 | TTL |
| -- | ------------- | ------- | --------- | --- |
| A | mhaibaraai.cn | IPv4 地址 | **仅 DNS** | 自动 |
> \[!WARNING]
>
> **重要提示**
>
> - 代理状态**必须**设置为 **"仅 DNS"**(灰色云朵图标)
> - 如果开启代理(橙色云朵),会导致 SSL 证书验证失败
> - 目标地址固定为 `cname.vercel-dns.com`
> - `@` 符号代表根域名,Cloudflare 会自动将 CNAME 展平为 A 记录
## 第三步:绑定自定义域名
### 进入 Vercel 域名设置
1. 在 Vercel 控制台,进入项目页面
2. 点击 **Settings** → **Domains**
### 添加自定义域名
在输入框中输入你的域名:
- 子域名:`docs.mhaibaraai.cn`
- 根域名:`mhaibaraai.cn`
- www 域名:`www.mhaibaraai.cn`

> \[!NOTE]
>
> **多域名配置建议**
>
> - 可以同时添加根域名和 www 子域名
> - Vercel 会自动处理 www 到根域名的重定向
> - 推荐配置:`mhaibaraai.cn`(主站)+ `docs.mhaibaraai.cn`(文档站)
### 等待域名验证
Vercel 会自动检测 DNS 配置:
- **配置正确**:显示绿色对勾,开始申请 SSL 证书
- **配置错误**:显示红色错误,并提示需要的 DNS 记录
## 第四步:验证部署
### 检查 DNS 解析
使用命令行工具检查 DNS 是否正确解析:
```bash
# 检查子域名
nslookup docs.mhaibaraai.cn
# 检查根域名
nslookup mhaibaraai.cn
# 使用 dig(Linux/macOS)
dig docs.mhaibaraai.cn
# 预期结果应包含
# docs.mhaibaraai.cn CNAME cname.vercel-dns.com
```
### 访问网站
在浏览器中访问:`https://docs.mhaibaraai.cn`
检查项:
- 网站正常加载
- 地址栏显示绿色锁图标(SSL 有效)
- 内容显示正确
### 测试 HTTPS 连接
```bash
# 检查 HTTP 响应头
curl -I https://docs.mhaibaraai.cn
# 预期输出包含
# HTTP/2 200
# server: Vercel
```
## 常见问题
### SSL 证书错误
**症状**:访问域名显示 "您的连接不是私密连接"
**解决方案**:
1. 检查 Cloudflare DNS 代理状态是否为 **"仅 DNS"**(灰色云朵)
2. 等待 10-15 分钟让证书生成完成
3. 在 Vercel **Settings** → **Domains** 检查域名状态
4. 如果仍失败,尝试删除域名后重新添加
### DNS 解析不生效
**症状**:域名无法访问或解析到错误的地址
**解决方案**:
1. 确认 DNS 记录类型正确(子域名用 CNAME,根域名用 A 记录)
2. 使用 `nslookup` 或 `dig` 验证 DNS 解析
3. DNS 传播可能需要 24-48 小时,但通常 5-10 分钟即可生效
4. 清除本地 DNS 缓存:`ipconfig /flushdns`(Windows)或 `sudo killall -HUP mDNSResponder`(macOS)
### Vercel 部署失败
**症状**:推送代码后部署失败
**解决方案**:
1. 检查 Vercel 控制台的部署日志
2. 确认 `package.json` 中的构建命令正确
3. 验证环境变量配置完整
4. 检查 Node.js 版本是否兼容(建议使用 LTS 版本)
## 相关资源
> \[!NOTE]
>
> - [Nuxt 官方文档](https://nuxt.com){rel=""nofollow""}
> - [Vercel 文档](https://vercel.com/docs){rel=""nofollow""}
> - [Cloudflare 文档](https://developers.cloudflare.com){rel=""nofollow""}
> - [Nuxt 部署指南](https://nuxt.com/docs/getting-started/deployment){rel=""nofollow""}
# 全局依赖缓存位置
## 全局依赖缓存位置
### Maven
- 路径:`~/.m2/repository`
- 按 `groupId/artifactId/version` 目录结构存放 JAR 文件
- 配置文件位置:`~/.m2/settings.xml`,可通过 `` 自定义存储路径
- 示例:`~/.m2/repository/org/springframework/boot/spring-boot-starter-web/3.1.0/`
### Gradle
- 路径:`~/.gradle/caches/modules-2/files-2.1/`
- 使用哈希目录存储
- 可通过 `GRADLE_USER_HOME` 修改缓存位置
- 示例:`~/.gradle/caches/modules-2/files-2.1/org.springframework.boot/spring-boot-starter-web/3.1.0/xxxx.jar`
> \[!NOTE]
>
> IntelliJ IDEA 不会额外保存依赖,所有依赖来自 Maven 或 Gradle 缓存;项目本地
>
> libs
>
> 目录中的 JAR 为私有依赖,不会进入全局缓存。
# 安装 Java 与构建工具
在 macOS 上,推荐使用 [Homebrew](https://brew.sh/){rel=""nofollow""} 来安装和管理软件包。
## 安装 Java
您可以选择安装最新版本的 OpenJDK,或者安装特定的 LTS (长期支持) 版本,如 Java 17。
```sh [sh]
# 安装最新版本 (例如 Java 21)
brew install openjdk
# 安装 LTS 版本 (例如 Java 17)
brew install openjdk@17
```
> \[!NOTE]
>
> Homebrew 会将它们安装在 Apple Silicon (
>
> /opt/homebrew/opt/
>
> ) 或 Intel (
>
> /usr/local/opt/
>
> ) 芯片的对应目录下。
## 配置环境变量
安装完成后,Homebrew 会提示您如何配置。以 OpenJDK 17 为例,您需要执行以下步骤:
```text
==> openjdk@17
For the system Java wrappers to find this JDK, symlink it with
sudo ln -sfn /opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk /Library/Java/JavaVirtualMachines/openjdk-17.jdk
openjdk@17 is keg-only, which means it was not symlinked into /opt/homebrew,
because this is an alternate version of another formula.
If you need to have openjdk@17 first in your PATH, run:
echo 'export PATH="/opt/homebrew/opt/openjdk@17/bin:$PATH"' >> ~/.zshrc
For compilers to find openjdk@17 you may need to set:
export CPPFLAGS="-I/opt/homebrew/opt/openjdk@17/include"
```
根据提示,将 Java 添加到系统环境变量中:
```sh [sh]
# 1. 创建符号链接,让系统能识别 JDK
sudo ln -sfn /opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk /Library/Java/JavaVirtualMachines/openjdk-17.jdk
# 2. 配置 PATH 环境变量
echo 'export PATH="/opt/homebrew/opt/openjdk@17/bin:$PATH"' >> ~/.zshrc
# 3. 设置 JAVA_HOME (推荐)
echo 'export JAVA_HOME=$(/usr/libexec/java_home -v 17)' >> ~/.zshrc
# 4. 重新加载配置
source ~/.zshrc
```
## 使用 jenv 管理多版本
当您需要频繁切换多个 Java 版本时,`jenv` 是一个强大的工具。
### 1. 安装和配置 jenv
```sh [sh]
# 安装 jenv
brew install jenv
# 添加到 Zsh (推荐)
echo 'export PATH="$HOME/.jenv/bin:$PATH"' >> ~/.zshrc
echo 'eval "$(jenv init -)"' >> ~/.zshrc
# 重新加载配置
source ~/.zshrc
```
### 2. 添加 Java 版本到 jenv
将已安装的 Java 版本添加到 `jenv` 进行管理:
```sh [sh]
# 添加 Java 17
jenv add $(/usr/libexec/java_home -v 17)
# 添加最新版 Java
jenv add $(/usr/libexec/java_home)
```
### 3. 查看和设置版本
```sh [sh]
# 查看所有可用版本
jenv versions
# 设置全局默认版本
jenv global 17.0
# 查看当前版本
jenv version
```
# Docker
## 安装 Docker 环境
在 Ubuntu 系统上安装 Docker 和 Docker Compose。
> \[!NOTE]
>
> Docker Compose V2 已集成为 Docker CLI 插件,命令格式为
>
> docker compose
>
> (空格),而非旧版的
>
> docker-compose
>
> (连字符)。
### 方式一:官方源安装
```sh [sh]
sudo apt-get update
sudo apt-get install -y ca-certificates curl gnupg
# 添加 Docker 官方 GPG key
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | \
sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg
# 添加 Docker 官方稳定源
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
# 更新软件包索引
sudo apt-get update
# 安装 Docker 相关组件
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
# 测试 Docker 是否正常
docker --version
sudo docker run hello-world
```
### 方式二:国内镜像源安装(推荐)
适用于国内服务器,使用腾讯云镜像源加速安装。
```sh [sh]
# 1. 卸载旧版本(如果有,报错可忽略)
sudo apt remove docker docker-engine docker.io containerd runc
# 2. 更新系统并安装必要依赖
sudo apt update
sudo apt install -y ca-certificates curl gnupg lsb-release
# 3. 创建密钥目录
sudo install -m 0755 -d /etc/apt/keyrings
# 4. 添加 Docker GPG 密钥(腾讯云镜像)
curl -fsSL https://mirrors.cloud.tencent.com/docker-ce/linux/ubuntu/gpg | \
sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg
# 5. 添加 Docker APT 仓库
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
https://mirrors.cloud.tencent.com/docker-ce/linux/ubuntu \
$(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
# 6. 更新包索引并安装
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
# 7. 启动 Docker 服务
sudo systemctl start docker
sudo systemctl enable docker
# 8. 验证安装
sudo docker --version
sudo docker compose version
sudo docker run hello-world
```
### 配置用户权限
```sh [sh]
# 将当前用户加入 docker 用户组(避免每次都要 sudo)
sudo usermod -aG docker $USER
# 应用用户组变更(需要重新登录或执行)
newgrp docker
# 验证权限
docker ps
docker compose version
```
> \[!WARNING]
>
> 执行
>
> usermod
>
> 后需要退出终端并重新登录才能使 docker 组权限生效。或者执行
>
> newgrp docker
>
> 临时激活。
### 配置镜像加速(可选)
国内服务器建议配置镜像加速,提升拉取速度:
```sh [sh]
sudo mkdir -p /etc/docker
sudo tee /etc/docker/daemon.json <<-'EOF'
{
"registry-mirrors": [
"https://docker.mirrors.ustc.edu.cn",
"https://mirror.ccs.tencentyun.com"
]
}
EOF
# 重启 Docker 服务使配置生效
sudo systemctl daemon-reload
sudo systemctl restart docker
# 验证镜像加速配置
sudo docker info | grep -A 5 "Registry Mirrors"
```
## Nginx Docker 部署
### 创建项目结构
```sh [sh]
mkdir -p ~/nginx/{conf,html,logs,ssl}
cd ~/nginx
```
### 配置文件
::code-tree{expand-all default-value="docker-compose.yml"}
```yaml [docker-compose.yml]
services:
nginx:
image: nginx:latest
container_name: nginx
restart: always
ports:
- "80:80"
- "443:443"
volumes:
- ./conf/nginx.conf:/etc/nginx/nginx.conf:ro
- ./conf/default.conf:/etc/nginx/conf.d/default.conf:ro
- ./html:/usr/share/nginx/html:ro
- ./ssl:/etc/nginx/ssl:ro
- ./logs:/var/log/nginx
networks:
- webnet
networks:
webnet:
driver: bridge
```
```nginx [conf/nginx.conf]
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
keepalive_timeout 65;
include /etc/nginx/conf.d/*.conf;
}
```
```nginx [conf/default.conf]
server {
listen 80;
server_name localhost;
location / {
root /usr/share/nginx/html;
index index.html index.htm;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}
```
```html [html/index.html]
Nginx Docker
Nginx 运行成功!
通过 Docker 部署
```
::
### 启动服务
```sh [sh]
cd ~/nginx
docker compose up -d
# 查看容器状态
docker compose ps
# 查看日志
docker compose logs -f nginx
# 测试配置
docker exec nginx nginx -t
```
访问 `http://your_server_ip` 验证部署成功。
## Cloudflare SSL 配置
### 添加 DNS 记录
在 Cloudflare 控制台添加 DNS 记录:
1. 登录 [Cloudflare Dashboard](https://dash.cloudflare.com){rel=""nofollow""}
2. 选择域名 → DNS → Records → Add record
3. 配置:
- Type: `A`
- Name: `server`(或其他子域名)
- IPv4 address: 服务器公网 IP
- Proxy status: 🟠 Proxied(推荐)
- TTL: Auto
### 创建 Origin 证书
### 进入证书管理
Cloudflare Dashboard → SSL/TLS → Origin Server → Create Certificate
### 配置证书选项
- Private key type: `RSA (2048)`
- Hostnames: `example.com` 和 `*.example.com`
- Certificate Validity: `15 years`
### 保存证书文件
将证书和私钥保存到服务器 `~/nginx/ssl/` 目录:
```sh [sh]
# 创建证书文件
cat > ~/nginx/ssl/example.com.pem << 'EOF'
-----BEGIN CERTIFICATE-----
[粘贴 Origin Certificate 内容]
-----END CERTIFICATE-----
EOF
# 创建私钥文件
cat > ~/nginx/ssl/example.com.key << 'EOF'
-----BEGIN PRIVATE KEY-----
[粘贴 Private Key 内容]
-----END PRIVATE KEY-----
EOF
# 设置权限
chmod 600 ~/nginx/ssl/*.key
chmod 644 ~/nginx/ssl/*.pem
```
### 配置 Nginx SSL
创建 SSL 站点配置:
```nginx [conf/server.example.com.conf]
server {
listen 80;
server_name server.example.com;
# 强制 HTTPS
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name server.example.com;
# SSL 证书配置
ssl_certificate /etc/nginx/ssl/example.com.pem;
ssl_certificate_key /etc/nginx/ssl/example.com.key;
# SSL 优化配置
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
# 安全头
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
# 网站根目录
root /usr/share/nginx/html;
index index.html index.htm;
location / {
try_files $uri $uri/ =404;
}
# 日志
access_log /var/log/nginx/server.example.com.access.log;
error_log /var/log/nginx/server.example.com.error.log;
}
```
更新 `docker-compose.yml` 添加配置挂载:
```diff [docker-compose.yml]
services:
nginx:
image: nginx:latest
container_name: nginx
restart: always
ports:
- "80:80"
- "443:443"
volumes:
- ./conf/nginx.conf:/etc/nginx/nginx.conf:ro
- ./conf/default.conf:/etc/nginx/conf.d/default.conf:ro
+ - ./conf/server.example.com.conf:/etc/nginx/conf.d/server.example.com.conf:ro
- ./html:/usr/share/nginx/html:ro
- ./ssl:/etc/nginx/ssl:ro
- ./logs:/var/log/nginx
networks:
- webnet
networks:
webnet:
driver: bridge
```
重启服务:
```sh [sh]
docker compose down
docker compose up -d
docker exec nginx nginx -t
```
### 设置 SSL/TLS 模式
> \[!WARNING]
>
> 必须在 Cloudflare 控制台设置正确的 SSL/TLS 模式,否则会出现错误。
在 Cloudflare Dashboard → SSL/TLS → Overview 中选择:
| 模式 | 说明 | 推荐 |
| ------------- | --------------------- | ---------- |
| Off | 不加密 | ❌ |
| Flexible | Cloudflare 到源站用 HTTP | ❌ 会导致重定向循环 |
| Full | Cloudflare 到源站用 HTTPS | ✅ |
| Full (strict) | 验证源站证书有效性 | ✅ 推荐 |
### 验证配置
```sh [sh]
# 测试 HTTPS
curl -I https://server.example.com
# 查看证书信息
curl -vI https://server.example.com 2>&1 | grep -E "(subject|issuer|expire)"
```
## 常用命令
```sh [sh]
# 容器管理
docker compose up -d # 启动
docker compose down # 停止
docker compose restart # 重启
docker compose ps # 查看状态
docker compose logs -f nginx # 查看日志
# Nginx 操作
docker exec nginx nginx -t # 测试配置
docker exec nginx nginx -s reload # 重载配置
# 进入容器
docker exec -it nginx /bin/sh
```
## 完整项目结构
```text
~/nginx/
├── docker-compose.yml
├── conf/
│ ├── nginx.conf
│ ├── default.conf
│ └── server.example.com.conf
├── html/
│ └── index.html
├── ssl/
│ ├── example.com.pem
│ └── example.com.key
└── logs/
├── access.log
└── error.log
```
# 数据库部署
> \[!NOTE]
>
> 本指南使用 Docker Compose v2+ 部署 PostgreSQL 和 Redis,并通过 SSH 隧道实现安全的远程连接。
## 项目结构
```sh [sh]
mkdir -p ~/database
cd ~/database
touch docker-compose.yml
```
## 编写 Compose 文件
> \[!WARNING]
>
> 端口绑定
>
> 127.0.0.1
>
> 仅允许本地访问,这是推荐的安全配置。远程连接请使用 SSH 隧道。
```yaml [~/database/docker-compose.yml]
services:
postgres:
image: postgres:16-alpine
container_name: postgres
restart: always
ports:
- "127.0.0.1:5432:5432"
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: your_postgres_password
POSTGRES_DB: app
TZ: Asia/Shanghai
volumes:
- postgres_data:/var/lib/postgresql/data
redis:
image: redis:7-alpine
container_name: redis
restart: always
ports:
- "127.0.0.1:6379:6379"
command: redis-server --requirepass your_redis_password
volumes:
- redis_data:/var/lib/redis/data
volumes:
postgres_data:
redis_data:
```
## 启动服务
```sh [sh]
cd ~/database
docker compose up -d
```
## 验证安装
```sh [sh]
# 检查容器状态
docker ps
# 测试 PostgreSQL
docker exec -it postgres psql -U postgres -c "SELECT version();"
# 测试 Redis
docker exec -it redis redis-cli -a 'your_redis_password' ping
```
PostgreSQL 成功时显示版本信息,Redis 成功时返回 `PONG`。
## 远程连接数据库
> \[!WARNING]
>
> 不建议将数据库端口直接暴露到公网。强烈推荐通过 SSH 隧道连接。
### 为什么使用 SSH 隧道
| 方案 | 安全性 | 适用场景 |
| ------ | ---- | ------------ |
| 直接暴露端口 | ❌ 低 | 仅测试环境 |
| IP 白名单 | ⚠️ 中 | 固定 IP 用户 |
| SSH 隧道 | ✅ 高 | 动态 IP 用户(推荐) |
### Navicat 配置 PostgreSQL
### 新建连接
打开 Navicat,点击 **连接** → **PostgreSQL**
### 配置常规选项卡
| 参数 | 值 |
| ----- | ------------------------ |
| 主机 | `127.0.0.1` |
| 端口 | `5432` |
| 初始数据库 | `app` |
| 用户名 | `postgres` |
| 密码 | `your_postgres_password` |
### 配置 SSH 选项卡
勾选 **使用 SSH 通道**:
| 参数 | 值 |
| ---- | ------------------ |
| 主机 | 服务器公网 IP |
| 端口 | `22` |
| 用户名 | `ubuntu`(或 `root`) |
| 验证方法 | 密码 或 公钥 |
### 测试连接
点击 **连接测试**,成功后保存连接。
> \[!NOTE]
>
> Redis 配置类似,端口为
>
> 6379
>
> ,密码为
>
> your\_redis\_password
>
> 。
## PostgreSQL 管理
### 常用命令
```sh [sh]
# 进入 psql
docker exec -it postgres psql -U postgres
# 执行 SQL 文件
docker exec -i postgres psql -U postgres -d app < script.sql
# 查看数据库大小
docker exec -it postgres psql -U postgres -c \
"SELECT pg_database.datname, pg_size_pretty(pg_database_size(pg_database.datname)) FROM pg_database;"
```
### psql 内部命令
```sql [psql]
\l -- 列出所有数据库
\c dbname -- 切换数据库
\dt -- 列出当前数据库的表
\d tablename -- 查看表结构
\du -- 列出所有用户
\dx -- 列出已安装扩展
\q -- 退出
```
### 备份与恢复
```sh [sh]
# 备份数据库
docker exec -it postgres pg_dump -U postgres -d app > backup.sql
# 压缩备份
docker exec -it postgres pg_dump -U postgres -d app | gzip > backup.sql.gz
# 恢复数据库
cat backup.sql | docker exec -i postgres psql -U postgres -d app
# 从压缩文件恢复
gunzip -c backup.sql.gz | docker exec -i postgres psql -U postgres -d app
```
### 添加 PostGIS 扩展
如需处理地理数据,修改镜像并激活扩展:
```yaml [docker-compose.yml]
services:
postgres:
image: postgis/postgis:16-3.4-alpine
# ... 其余配置保持不变
```
```sh [sh]
# 重启服务
docker compose down
docker compose up -d
# 激活扩展
docker exec -it postgres psql -U postgres -d app -c "CREATE EXTENSION postgis;"
```
## Redis 管理
### 常用命令
```sh [sh]
# 进入 Redis CLI
docker exec -it redis redis-cli -a 'your_redis_password'
```
### redis-cli 内部命令
```redis [redis-cli]
# 基本操作
SET key "value" # 设置键值
GET key # 获取值
DEL key # 删除键
EXPIRE key 60 # 设置过期时间(秒)
# 查看信息
KEYS * # 查看所有键(生产环境慎用)
DBSIZE # 当前数据库键数量
INFO # 服务器信息
INFO memory # 内存使用信息
# 数据库操作
FLUSHDB # 清空当前数据库
# 退出
QUIT
```
### 数据持久化
Redis 默认使用 RDB 快照。如需 AOF 持久化,创建自定义配置:
```sh [sh]
mkdir -p ~/database/redis-conf
```
```conf [~/database/redis-conf/redis.conf]
# 启用 AOF 持久化
appendonly yes
appendfsync everysec
# RDB 配置
save 900 1
save 300 10
save 60 10000
# 内存限制
maxmemory 256mb
maxmemory-policy allkeys-lru
```
更新 `docker-compose.yml`:
```yaml [docker-compose.yml]
services:
redis:
image: redis:7-alpine
container_name: redis
restart: always
ports:
- "127.0.0.1:6379:6379"
command: redis-server /usr/local/etc/redis/redis.conf --requirepass your_redis_password
volumes:
- redis_data:/data
- ./redis-conf/redis.conf:/usr/local/etc/redis/redis.conf:ro
```
## 常用 Docker 命令
```sh [sh]
# 查看容器状态
docker ps
# 查看日志
docker logs postgres --tail 50
docker logs redis --tail 50
# 重启服务
cd ~/database
docker compose restart
# 停止服务
docker compose down
# 停止并删除数据卷(危险:会丢失数据)
docker compose down -v
```
## 安全建议
1. **使用强密码**:建议使用 `openssl rand -base64 16` 生成
2. **仅本地监听**:端口绑定 `127.0.0.1` 而非 `0.0.0.0`
3. **SSH 隧道连接**:避免直接暴露数据库端口
4. **定期备份**:配置自动备份脚本
5. **及时更新**:定期更新 Docker 镜像修复安全漏洞
```sh [sh]
# 更新镜像
docker compose pull
docker compose up -d
```
# GitHub Actions 自动部署
## 架构概览
```text
GitHub 仓库
└── push to main
│
▼
GitHub Actions(构建 Docker 镜像)
└── 推送至 GHCR(GitHub Container Registry)
│
▼
Watchtower(每 60 秒轮询 GHCR)
└── 检测到新镜像 → 自动拉取并重启容器
│
▼
Docker 容器(加入 webnet 网络)
└── nginx 通过容器名反向代理
│
▼
用户访问(HTTPS)
```
## 项目端配置
### Dockerfile
三阶段构建:`deps` 安装依赖,`build` 编译产物,`runtime` 只包含 `.output/`,不含源码和 node\_modules:
```dockerfile [Dockerfile]
FROM node:24-alpine AS base
RUN corepack enable
FROM base AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \
corepack install && pnpm install --frozen-lockfile --ignore-scripts
FROM deps AS build
WORKDIR /app
ENV NODE_OPTIONS=--max-old-space-size=8192
COPY . .
RUN --mount=type=secret,id=SECRET_1 \
--mount=type=secret,id=SECRET_2 \
for f in /run/secrets/*; do echo "$(basename $f)=$(cat $f)"; done > .env && \
pnpm build && rm -f .env
FROM node:24-alpine AS runtime
WORKDIR /app
RUN addgroup -S app && adduser -S app -G app
COPY --from=build --chown=app:app /app/.output ./
USER app
ENV NODE_ENV=production \
HOST=0.0.0.0 \
PORT=3000
EXPOSE 3000
CMD ["node", "server/index.mjs"]
```
> \[!NOTE]
>
> - `--mount=type=cache` 缓存 pnpm store,源码变更时无需重新下载依赖。
> - `--mount=type=secret` 将 GitHub Secrets 以文件形式挂载到 `/run/secrets/`,只在该 `RUN` 指令执行期间可见,不会写入任何镜像层。
> - secrets 通过遍历写入 `.env`,Nuxt CLI 构建时自动加载,构建完成后立即删除。
> - `NODE_OPTIONS=--max-old-space-size=8192` 将 Node.js 堆内存上限设为 8GB,避免 Nitro 打包阶段 OOM。
### GitHub Actions 工作流
```yaml [.github/workflows/deploy.yml]
name: Deploy
on:
push:
branches:
- main
env:
REGISTRY: ghcr.io
IMAGE: ghcr.io/${{ github.repository }}
permissions:
contents: read
packages: write
jobs:
deploy:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v6
- uses: docker/setup-buildx-action@v4
- name: Log in to GHCR
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v7
with:
context: .
push: true
tags: |
${{ env.IMAGE }}:latest
${{ env.IMAGE }}:${{ github.sha }}
secrets: |
SECRET_1=${{ secrets.SECRET_1 }}
SECRET_2=${{ secrets.SECRET_2 }}
cache-from: type=gha
cache-to: type=gha,mode=max
```
### GitHub Secrets 配置
前往仓库 **Settings → Secrets and variables → Actions**,根据项目需要新增 Repository secrets:
| Secret 名称 | 用途 |
| ---------- | --------------- |
| `SECRET_1` | 构建时和运行时所需的第一个密钥 |
| `SECRET_2` | 构建时和运行时所需的第二个密钥 |
> \[!NOTE]
>
> GITHUB\_TOKEN
>
> 由 GitHub 自动提供,无需配置,仅用于推送镜像到 GHCR。其余 secrets 需要手动创建。
> \[!WARNING]
>
> **GHCR 镜像命名规则**:`GITHUB_TOKEN` 只能写入**当前仓库关联的包**。镜像名必须是 `ghcr.io//` 或其子路径(如 `ghcr.io///app`)。
>
> 如果使用与仓库名不匹配的独立包名,会被 GHCR 视为**另一个独立包**,`GITHUB_TOKEN` 无权写入,推送时报 `permission_denied: write_package`。
>
> 正确做法是使用 `ghcr.io/${{ github.repository }}` 或其子路径格式。
> \[!TIP]
>
> **为什么不用 `build-args` 传递 secrets?**
>
> `ARG` + `ENV` 会将变量值固化进镜像层,任何有镜像访问权限的人都可以通过 `docker inspect` 读取。`--mount=type=secret` 是 BuildKit 提供的安全方案,secret 仅在构建期间临时挂载,不留痕迹。
## 服务器端配置
### Watchtower(自动更新)
```yaml [~/watchtower/docker-compose.yml]
services:
watchtower:
image: containrrr/watchtower:latest
container_name: watchtower
restart: unless-stopped
environment:
- WATCHTOWER_POLL_INTERVAL=60
- WATCHTOWER_CLEANUP=true
- WATCHTOWER_INCLUDE_RESTARTING=true
- DOCKER_CONFIG=/config
- DOCKER_API_VERSION=1.41
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /root/.docker:/config:ro
command:
```
- `WATCHTOWER_POLL_INTERVAL=60`:每 60 秒检查一次新镜像
- `WATCHTOWER_CLEANUP=true`:更新后自动删除旧镜像
- `command: `:只监控指定容器,多个容器用空格分隔
- `/root/.docker:/config:ro`:挂载 GHCR 登录凭证
> \[!NOTE]
>
> 首次使用前需在服务器上登录 GHCR(如果镜像设为私有):
>
> ```sh
> docker login ghcr.io -u -p
> ```
>
> GitHub PAT 只需 `read:packages` 权限。
### 应用容器
```yaml [~/webs//docker-compose.yml]
services:
:
image: ghcr.io//:latest
container_name:
restart: unless-stopped
env_file: .env
networks:
- webnet
networks:
webnet:
external: true
```
```sh [~/webs//.env]
SECRET_1=your_value_here
SECRET_2=your_value_here
```
> \[!NOTE]
>
> 运行时环境变量(
>
> .env
>
> )与构建时 secrets 是两条独立的注入链路。Nuxt 生产服务器不读取
>
> .env
>
> 文件,必须通过容器环境变量传入,
>
> env\_file
>
> 是 Docker Compose 的标准做法。
### nginx 反向代理
```nginx [~/nginx/conf.d/.conf]
server {
listen 80;
server_name ;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl;
http2 on;
server_name ;
ssl_certificate /etc/nginx/ssl/.pem;
ssl_certificate_key /etc/nginx/ssl/.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
location / {
resolver 127.0.0.11 valid=30s;
set $upstream :3000;
proxy_pass http://$upstream;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
access_log /var/log/nginx/.access.log;
error_log /var/log/nginx/.error.log;
}
```
> \[!NOTE]
>
> resolver 127.0.0.11
>
> 使用 Docker 内置 DNS,配合
>
> set $upstream
>
> 变量实现动态解析。容器不存在时 nginx 仍可正常启动(返回 502),避免了
>
> upstream
>
> 块在启动时强制解析导致的报错。
## 首次部署步骤
### 登录 GHCR(如果镜像私有)
```sh
sudo docker login ghcr.io -u -p
```
### 启动 Watchtower
```sh
cd ~/watchtower && sudo docker compose up -d
```
### 手动首次启动应用容器
```sh
mkdir -p ~/webs/
```
创建 `.env` 和 `docker-compose.yml`(参照上方配置),然后启动:
```sh
cd ~/webs/ && sudo docker compose up -d
```
Watchtower 只负责更新已有容器,不负责首次创建。
### 上传 nginx 配置并重载
```sh
sudo docker exec nginx nginx -t && sudo docker exec nginx nginx -s reload
```
### 触发首次 CI 构建
```sh
git commit --allow-empty -m "chore: 触发首次部署"
git push
```
之后每次推送 `main`,全流程自动完成。
## 新增项目部署模板
每新增一个需要部署的 Nuxt/Node 项目,重复以下步骤:
**项目仓库**:新建 `Dockerfile` + `.github/workflows/deploy.yml`,修改镜像名和容器名。
**Watchtower**:在 `~/watchtower/docker-compose.yml` 的 `command` 里追加容器名:
```yaml [~/watchtower/docker-compose.yml]
command: existing-container new-container-name
```
```sh
sudo docker compose up -d
```
**nginx**:新建 `conf.d/.conf`,参照现有配置修改 `server_name` 和 `set $upstream`:
```sh
sudo docker exec nginx nginx -t && sudo docker exec nginx nginx -s reload
```
**首次启动容器**:创建项目目录,参照模板创建 `.env` 和 `docker-compose.yml`,执行 `sudo docker compose up -d`。
**DNS**:域名解析控制台添加 ` → 服务器 IP` 的 A 记录。
## 常用运维命令
```sh
# 查看所有容器状态
sudo docker ps
# 查看应用日志
sudo docker logs -f --tail 100
# 查看 Watchtower 日志(确认自动更新是否生效)
sudo docker logs watchtower -f --tail 50
# 重载 nginx 配置(无停机)
sudo docker exec nginx nginx -t && sudo docker exec nginx nginx -s reload
# 手动触发镜像更新(无需等待 Watchtower 轮询)
cd ~/webs/ && sudo docker compose pull && sudo docker compose up -d
# 进入容器内部调试
sudo docker exec -it sh
```
# DigitalOcean
## 创建配置 Droplets
创建 Droplets 的入口:

创建 Droplets 的配置:

## Droplets 仪表盘
管理界面仪表盘:

## SSH 登录
使用 `your_ipv4_address` IP 地址进行 SSH 连接:
```sh [sh]
ssh root@your_ipv4_address
```
### 系统更新提示
> \[!NOTE]
>
> 当看到以下更新提示时:
>
> ```text
> 107 of these updates are standard security updates.
> To see these additional updates run: apt list --upgradable
> ```
这是 **Ubuntu** 或 **Debian** 系 Linux 系统在执行 `sudo apt update` 或类似命令后给出的信息。
最优做法如下(一步到位):
```sh [sh]
sudo apt update
sudo apt upgrade -y
```
完成后重启系统:
```sh [sh]
sudo reboot
```
## SSH 密钥配置
> \[!NOTE]
> See: https\://docs.digitalocean.com/products/droplets/how-to/add-ssh-keys/
>
> 参考官方文档
### 创建 SSH 密钥
```sh [sh]
ssh-keygen
```
### 获取公钥内容
```sh [sh]
cat ~/.ssh/id_ed25519.pub
```
> \[!NOTE]
>
> 复制输出内容,格式类似:
>
> ```text
> ssh-ed25519 EXAMPLEzaC1lZDI1NTE5AAAAIGKy65/WWrFKeWdpJKJAuLqev9bb9ZNofcMrR/OnC9BM username@203.0.113.0
> ```
### 配置 SSH 密钥
在 Droplet 上,创建 `~/.ssh` 目录(如果不存在):
```sh [sh]
mkdir -p ~/.ssh
```
> \[!NOTE]
>
> 将 SSH 密钥添加到 `~/.ssh/authorized_keys` 文件中,替换引号中的示例键:
>
> ```text
> echo "ssh-ed25519 EXAMPLEzaC1yc2E...GvaQ== username@203.0.113.0" >> ~/.ssh/authorized_keys
> ```
设置正确的权限:
```sh [sh]
chmod -R go= ~/.ssh
chown -R $USER:$USER ~/.ssh
```
### 测试 SSH 连接
```sh [sh]
ssh root@your_ipv4_address
```
> \[!WARNING]
>
> 一定要关闭任何 **VPN** 或 **代理**,否则会连接失败:
>
> ```text
> ssh root@your_ipv4_address
> Connection closed by your_ipv4_address port 22
> ```
## 安装 Docker 环境
> \[!NOTE]
> See: /docs/guides/deployment/docker
>
> 参考 Docker 安装和使用指南
# GitHub CI 镜像加速
## 方案对比
| 方案 | 速度 | 稳定性 | 成本 | 复杂度 |
| ------------- | ------ | --- | ----- | --- |
| 直连 GHCR | 慢/超时 | 差 | 无 | 无 |
| 同步到腾讯云 TCR | 最快(内网) | 最高 | 免费个人版 | 中 |
| Mihomo 代理 | 较快 | 中 | 需代理节点 | 低 |
| CF Workers 代理 | 较快 | 中 | 需域名 | 低 |
## 方案一:同步到腾讯云 TCR
CI 构建完成后同时推送到 GHCR 和腾讯云 TCR,服务器从 TCR 内网拉取。
### 添加 Secrets
在仓库 **Settings → Secrets** 中添加以下配置:
| Key | Value |
| --------------- | ------------------------ |
| `TCR_REGISTRY` | `ccr.ccs.tencentyun.com` |
| `TCR_NAMESPACE` | TCR 命名空间 |
| `TCR_USERNAME` | 腾讯云账号 ID |
| `TCR_PASSWORD` | TCR 访问凭证密码 |
### 配置 CI 工作流
`docker/metadata-action` 的 `images` 字段支持多个仓库,`build-push-action` 会在构建完成后同时推送到两个仓库,不重复构建。
```yaml [.github/workflows/build.yml]
name: Build & Deploy
on:
push:
branches: [main]
workflow_dispatch:
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
TCR_IMAGE: ${{ secrets.TCR_REGISTRY }}/${{ secrets.TCR_NAMESPACE }}/movk-backend
permissions:
contents: read
packages: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/login-action@v3
with:
registry: ${{ secrets.TCR_REGISTRY }}
username: ${{ secrets.TCR_USERNAME }}
password: ${{ secrets.TCR_PASSWORD }}
- id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
${{ env.TCR_IMAGE }}
tags: |
type=sha,prefix=
type=raw,value=latest,enable={{is_default_branch}}
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.PROD_SSH_HOST }}
username: ${{ secrets.PROD_SSH_USER }}
password: ${{ secrets.PROD_SSH_PASSWORD }}
port: ${{ vars.PROD_SSH_PORT || 22 }}
script: |
cd ~/apps/movk-backend
docker compose -f docker-compose.yml pull
docker compose -f docker-compose.yml up -d
docker image prune -f
```
### 修改 docker-compose.yml
```yaml [docker-compose.yml]
services:
app:
image: ccr.ccs.tencentyun.com//movk-backend:latest
```
### 登录 TCR(一次性操作)
```sh [sh]
docker login ccr.ccs.tencentyun.com \
-u \
-p
```
## 方案二:Mihomo 代理
在服务器上部署 Mihomo(Clash Meta 内核),让 Docker daemon 通过代理拉取 GHCR 镜像。
> \[!NOTE]
>
> 需要有可用的代理节点(境外 VPS 或代理订阅),订阅格式必须为 Clash 格式。
### 安装 Mihomo
查看 CPU 等级,决定下载版本:
```sh [sh]
grep -m1 flags /proc/cpuinfo | grep -o 'avx2\|sse4_2'
# 有 avx2 → 用 v3,有 sse4_2 → 用 v2,都没有 → 用 compatible
```
以 `v1.19.20` 为例,`amd64-compatible` 版本兼容性最好:
```sh [sh]
wget https://github.com/MetaCubeX/mihomo/releases/download/v1.19.20/mihomo-linux-amd64-compatible-v1.19.20.gz
gzip -d mihomo-linux-amd64-compatible-v1.19.20.gz
sudo mv mihomo-linux-amd64-compatible-v1.19.20 /usr/local/bin/mihomo
sudo chmod +x /usr/local/bin/mihomo
mihomo -v
```
### 配置订阅文件
> \[!WARNING]
>
> 订阅链接请在本地下载后再上传到服务器,大多数订阅服务会拦截云服务器 IP 的直接请求。
在**本地**下载订阅配置:
```sh [sh]
curl -o config.yaml "https://你的订阅链接" \
-H "User-Agent: clash-verge/v2.0.0"
```
上传到服务器:
```sh [sh]
scp -P <端口> config.yaml ubuntu@<服务器IP>:/tmp/config.yaml
```
在**服务器**上移动至配置目录:
```sh [sh]
sudo mkdir -p /etc/mihomo
sudo mv /tmp/config.yaml /etc/mihomo/config.yaml
sudo chmod 644 /etc/mihomo/config.yaml
```
测试启动,看到 `Mixed(http+socks) proxy listening at: 127.0.0.1:7890` 即为成功:
```sh [sh]
sudo mihomo -d /etc/mihomo
```
### 配置 systemd 服务
```sh [sh]
sudo tee /etc/systemd/system/mihomo.service << 'EOF'
[Unit]
Description=Mihomo Proxy
After=network.target
[Service]
Type=simple
ExecStart=/usr/local/bin/mihomo -d /etc/mihomo
Restart=on-failure
RestartSec=5s
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable mihomo
sudo systemctl start mihomo
```
### 配置 Docker 代理
根据 `config.yaml` 中实际的 `mixed-port` 字段填写端口号:
```sh [sh]
sudo mkdir -p /etc/systemd/system/docker.service.d
sudo tee /etc/systemd/system/docker.service.d/http-proxy.conf << 'EOF'
[Service]
Environment="HTTP_PROXY=http://127.0.0.1:7893"
Environment="HTTPS_PROXY=http://127.0.0.1:7893"
Environment="NO_PROXY=localhost,127.0.0.1,ccr.ccs.tencentyun.com"
EOF
sudo systemctl daemon-reload
sudo systemctl restart docker
```
### 验证连通性
```sh [sh]
# 测试代理连通性
curl -x http://127.0.0.1:7893 https://www.google.com -I
# 测试 Docker 拉取
docker pull ghcr.io//:latest
```
> \[!TIP]
>
> Mihomo 已设置
>
> systemctl enable
>
> ,服务器重启后会自动启动代理,Docker 代理配置持久生效,无需额外操作。
### 更新订阅
Mihomo 在服务器上无法直接拉取订阅(云服务器 IP 被拦截),每次订阅更新需在本地重新下载后上传:
```sh [sh]
# 本地
curl -o config.yaml "https://你的订阅链接" -H "User-Agent: clash-verge/v2.0.0"
scp -P <端口> config.yaml ubuntu@<服务器IP>:/tmp/config.yaml
# 服务器
sudo mv /tmp/config.yaml /etc/mihomo/config.yaml
sudo systemctl restart mihomo
```
# SSL 证书配置
## 方案选择
| 方案 | 适用场景 | 优点 | 缺点 |
| ----------------------- | ---------------- | -------------- | ------------------ |
| Cloudflare Origin 证书 | 使用 Cloudflare 代理 | 配置简单,有效期长(15年) | 只能通过 Cloudflare 访问 |
| Let's Encrypt + acme.sh | 直接访问源站或需要公共信任证书 | 公共信任,免费 | 需要自动续签 |
## Cloudflare Origin 证书
> \[!WARNING]
>
> Cloudflare Origin 证书
>
> 只被 Cloudflare 信任
>
> ,不被浏览器信任。直接访问源站 IP 会显示证书错误。
### 创建证书
### 进入 Cloudflare 控制台
1. 登录 [Cloudflare Dashboard](https://dash.cloudflare.com){rel=""nofollow""}
2. 选择域名 → SSL/TLS → 源服务器(Origin Server)
3. 点击"创建证书"
### 配置证书选项
- 私钥类型:RSA (2048)
- 主机名:`*.example.com` 和 `example.com`
- 证书有效期:15 年
### 保存证书文件
将证书和私钥保存到服务器:
```sh [sh]
mkdir -p ~/nginx/ssl
# 创建证书文件(粘贴 Origin Certificate 内容)
nano ~/nginx/ssl/example.com.pem
# 创建私钥文件(粘贴 Private Key 内容)
nano ~/nginx/ssl/example.com.key
```
### 配置 SSL/TLS 模式
> \[!WARNING]
>
> 必须将 SSL/TLS 模式设置为 **Full** 或 **Full (strict)**,否则会出现以下问题:
>
> - **Off**:不加密
> - **Flexible**:导致重定向循环(ERR\_TOO\_MANY\_REDIRECTS)或 SSL 525 错误
> - **Full**:正确 ✓
> - **Full (strict)**:正确 ✓(推荐)
在 Cloudflare Dashboard → SSL/TLS → Overview 中设置。
### 常见错误
#### HTTP 525 SSL Handshake Failed
原因:Cloudflare 无法与源站建立 SSL 连接。
排查步骤:
```sh [sh]
# 1. 检查 nginx 是否运行
docker ps
# 2. 检查 443 端口
sudo ss -tlnp | grep 443
# 3. 本地测试 SSL
curl -vk https://127.0.0.1 -H "Host: example.com"
# 4. 检查证书文件
openssl x509 -in ~/nginx/ssl/example.com.pem -noout -issuer -dates
```
#### 证书链不完整
上传到腾讯云等平台时提示"证书链不完整"是正常的,因为 Origin 证书只被 Cloudflare 信任。**无需上传到第三方平台**。
## Let's Encrypt + acme.sh 自动续签
适用于需要公共信任证书或直接访问源站的场景。
### 安装 acme.sh
```sh [sh]
curl https://get.acme.sh | sh -s email=your-email@example.com
source ~/.bashrc
```
### 配置 Cloudflare API
### 获取 API Token
1. 访问 [Cloudflare API Tokens](https://dash.cloudflare.com/profile/api-tokens){rel=""nofollow""}
2. 创建 Token → 使用模板 **Edit zone DNS**
3. 权限设置:
- Zone - DNS - Edit
- Zone - Zone - Read
4. 区域资源:选择目标域名
5. 保存 Token 和 Zone ID
### 配置凭证
```sh [sh]
cat >> ~/.acme.sh/account.conf << 'EOF'
CF_Token="your_api_token"
CF_Zone_ID="your_zone_id"
EOF
```
### 申请证书
```sh [sh]
# 使用 Let's Encrypt(推荐)
~/.acme.sh/acme.sh --issue --dns dns_cf \
-d example.com \
-d "*.example.com" \
--server letsencrypt
```
> \[!NOTE]
>
> 如果遇到 ZeroSSL 速率限制错误(
>
> retryafter=86400
>
> ),添加
>
> \--server letsencrypt
>
> 参数切换 CA。
### 安装证书
```sh [sh]
~/.acme.sh/acme.sh --install-cert -d example.com \
--key-file ~/nginx/ssl/example.com.key \
--fullchain-file ~/nginx/ssl/example.com.pem \
--reloadcmd "docker exec nginx nginx -s reload"
```
### 验证自动续签
```sh [sh]
# 查看 cron job
crontab -l | grep acme
# 手动测试续签
~/.acme.sh/acme.sh --cron --home ~/.acme.sh
```
### 卸载 acme.sh
如果需要完全卸载:
```sh [sh]
~/.acme.sh/acme.sh --uninstall
rm -rf ~/.acme.sh
```
## Nginx SSL 配置
```nginx [nginx/server.conf]
server {
listen 80;
server_name example.com;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl;
http2 on;
server_name example.com;
ssl_certificate /etc/nginx/ssl/example.com.pem;
ssl_certificate_key /etc/nginx/ssl/example.com.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ =404;
}
}
```
## 验证证书
```sh [sh]
# 查看证书信息
openssl x509 -in ~/nginx/ssl/example.com.pem -noout -issuer -subject -dates
# 测试 HTTPS 连接
curl -vI https://example.com 2>&1 | grep -E "(subject|issuer|expire)"
# 使用在线工具
# https://www.sslshopper.com/ssl-checker.html
```
# 故障排查
## SSL/TLS 相关问题
### HTTP 525 SSL Handshake Failed
**现象**:通过 Cloudflare 访问时返回 525 错误。
**原因**:Cloudflare 无法与源站建立 SSL 连接。
**排查步骤**:
```sh [sh]
# 1. 检查 nginx 容器是否运行
docker ps | grep nginx
# 2. 检查 443 端口是否监听
sudo ss -tlnp | grep 443
# 3. 本地测试 SSL 连接
curl -vk https://127.0.0.1 -H "Host: example.com" 2>&1 | head -20
# 4. 从公网测试(使用在线工具)
# https://www.sslshopper.com/ssl-checker.html#hostname=your_server_ip
```
**解决方案**:
1. 确认 Cloudflare SSL/TLS 模式为 **Full** 或 **Full (strict)**
2. 检查证书文件是否正确放置
3. 检查 nginx 配置语法:`docker exec nginx nginx -t`
### ERR\_TOO\_MANY\_REDIRECTS
**现象**:浏览器显示重定向过多。
**原因**:Cloudflare SSL 模式设置为 **Flexible**,导致循环重定向。
```text
Cloudflare (HTTPS) → Nginx (HTTP:80) → 301 重定向到 HTTPS → Cloudflare → 循环
```
**解决方案**:将 Cloudflare SSL/TLS 模式改为 **Full** 或 **Full (strict)**。
### 证书链不完整
**现象**:上传 Cloudflare Origin 证书到腾讯云等平台时提示"证书链不完整"。
**原因**:Cloudflare Origin 证书只被 Cloudflare 信任,不是公共 CA 证书。
**解决方案**:这是正常现象。Origin 证书无需上传到第三方平台,直接放在 nginx 服务器上使用。
### acme.sh 申请证书失败
**现象**:`retryafter=86400` 错误。
**原因**:ZeroSSL 速率限制。
**解决方案**:
```sh [sh]
# 切换到 Let's Encrypt CA
~/.acme.sh/acme.sh --issue --dns dns_cf \
-d example.com \
-d "*.example.com" \
--server letsencrypt \
--force
```
### Cloudflare API Token 无效
**现象**:`invalid domain` 错误。
**原因**:API Token 权限不足或 Zone ID 错误。
**解决方案**:
1. 确认 Token 权限包含:
- Zone - DNS - Edit
- Zone - Zone - Read
2. 确认 Zone ID 正确(在 Cloudflare 域名概览页右下角)
3. 重新配置凭证:
```sh [sh]
# 清除旧配置
sed -i '/CF_Token/d' ~/.acme.sh/account.conf
sed -i '/CF_Zone_ID/d' ~/.acme.sh/account.conf
# 添加新配置
cat >> ~/.acme.sh/account.conf << 'EOF'
CF_Token="new_token"
CF_Zone_ID="zone_id"
EOF
```
## Docker 网络问题
### IPv4 连接失败,IPv6 正常
**现象**:通过 IPv6 可以正常访问,IPv4 返回连接错误或超时。
**原因**:Docker 对 IPv4 使用 NAT(iptables DNAT),可能与服务器防火墙或安全组冲突。
**排查步骤**:
```sh [sh]
# 检查 Docker iptables 规则
sudo iptables -t nat -L -n | grep 443
# 检查端口映射
docker port nginx
```
**解决方案**:
1. **使用 IPv6**:如果 IPv6 可用,优先使用 AAAA 记录
2. **检查安全组**:确保腾讯云/阿里云安全组允许相应端口
3. **使用 host 网络模式**(不推荐):
```yaml [docker-compose.yml]
services:
nginx:
image: nginx:latest
network_mode: host
# 不需要 ports 映射
```
### 容器内无法访问宿主机端口
**现象**:容器内 `curl host.docker.internal:3000` 失败。
**解决方案**:在 `docker-compose.yml` 中添加 `extra_hosts`:
```yaml [docker-compose.yml]
services:
nginx:
image: nginx:latest
extra_hosts:
- "host.docker.internal:host-gateway"
```
## 数据库连接问题
### 无法远程连接 PostgreSQL/Redis
**现象**:Navicat 连接超时或被拒绝。
**排查步骤**:
1. **检查服务是否运行**:
```sh \[sh]
docker ps | grep -E "postgres|redis"
```
2. **检查端口监听**:
```sh \[sh]
# 如果绑定 127.0.0.1,只能本地访问
sudo ss -tlnp | grep -E "5432|6379"
```
3. **检查安全组**:确保云服务商安全组允许相应端口入站
4. **检查防火墙**:
```sh \[sh]
sudo ufw status
sudo iptables -L -n | grep -E "5432|6379"
```
**解决方案**:
> \[!WARNING]
>
> 不建议将数据库端口直接暴露到公网。推荐使用 SSH 隧道连接。
**SSH 隧道配置**(推荐):
1. 数据库只监听本地:
```yaml \[docker-compose.yml]
ports:
- '127.0.0.1:5432:5432'
```
2. Navicat 中配置 SSH 隧道连接
### 数据库密码错误
**现象**:认证失败。
**解决方案**:
```sh [sh]
# 查看当前配置的密码
cat ~/database/.env
# 或直接在容器内测试
docker exec -it postgres psql -U postgres
docker exec -it redis redis-cli -a 'password' ping
```
## 通用排查命令
```sh [sh]
# 查看所有运行中的容器
docker ps
# 查看容器日志
docker logs --tail 50
# 进入容器
docker exec -it /bin/sh
# 检查端口占用
sudo ss -tlnp
# 测试端口连通性
nc -zv
# 检查 DNS 解析
dig +short
# 测试 HTTPS 连接
curl -vI https:// 2>&1 | head -30
```
# 浙政钉开发
## H5 应用 Console 调试功能
浙政钉 `H5` 应用开发中,为了方便调试,可以在页面中加入 `VConsole` 调试工具,方便查看日志、调试代码。
> \[!TIP]
> See: https\://github.com/Tencent/vConsole/tree/master
>
> vConsole 是一个轻量、可拓展、针对手机网页的前端开发者调试面板
```sh [pnpm]
pnpm add vconsole
```
```sh [npm]
npm install vconsole
```
```ts [pc.ts]
import VConsole from 'vconsole'
const vConsole = null
// 当鼠标按下中键时,显示vConsole,结束后销毁
document.addEventListener('keydown', (e) => {
if (e.keyCode === 123) {
if (!vConsole)
vConsole = new VConsole()
else if (vConsole)
vConsole.destroy()
}
})
```
```ts [ios-android.ts]
const vConsole = null
const pressTimer = null
function handleTouchStart() {
pressTimer = setTimeout(() => {
if (!vConsole)
vConsole = new VConsole()
else if (vConsole)
vConsole.destroy()
}, 3000) // 长按时间阈值
}
function handleTouchEnd() {
clearTimeout(pressTimer)
}
```
## 浙政钉应用埋点
> \[!TIP]
> See: https\://wetx6c6wxe.feishu.cn/wiki/wikcnu9v1TpnP34dShwEyPzNife
>
> 浙政钉埋点文档
埋点需要三个参数:
- `sapp_name` :应用标识
- `bid` :`sapp_name`\_zzdpro
- `sapp_id` :应用ID(可以去浙政钉支持群咨询)、[官网查看埋点参数](https://yida-pro.ding.zj.gov.cn/alibaba/web/APP_VTZ4TZZSGZXB37IUIUM6/inst/homepage/#/REPORT-GWLBVYNV25OXGEY68AOOWR7GIXSVZ2B75HH1SLC6){rel=""nofollow""}
::code-tree{expand-all default-value="app/permission.ts"}
```ts [app/permission.ts]
import aplus_push from './gdt_aplus'
router.beforeEach(async (to, from, next) => {
if (token) {
/** 开始埋点 */
const { meta: { title }, path, fullPath } = to
const pageId = (path.replace('/', '') || 'app').toUpperCase()
const userId = userStore.getUserInfo()?.dingId
aplus_push(pageId, title as string, fullPath, userId)
/** 结束埋点 */
}
})
```
```ts [app/gdt_aplus.ts]
// 浙政钉应用配置信息
const gdt_config = {
sapp_id: 'xxx', // 43832
sapp_name: 'xxx', // gxq_msgd01
}
/**
* 浙政钉埋点-流量分析代码(基础埋点、用户信息埋点)
* @param page_id 页面ID, 保证唯一性
* @param page_name 页面名称
* @param page_url 页面 url
* @param _user_id 用户id
* 浙政钉-H5&小程序应用采集开发手册文档:
* https://www.yuque.com/sisialing/bcg47r/ywfbnk?#YmwM5
*/
export default function aplus_queue_push(
page_id: number | string,
page_name = 'app',
page_url: string,
_user_id: number | string,
) {
/**
* 基础埋点
*/
// 单页应用或“单个页面”需异步补充PV日志参数还需进行如下埋点:
window.aplus_queue.push({
action: 'aplus.setMetaInfo',
arguments: ['aplus-waiting', 'MAN'],
})
// 单页应用路由切换后或在异步获取到pv日志所需的参数后再执行sendPV:
window.aplus_queue.push({
action: 'aplus.sendPV',
arguments: [
{
is_auto: false,
},
{
// 当前你的应用信息,此两行按应用实际参数修改,不可自定义。
sapp_id: gdt_config.sapp_id,
sapp_name: gdt_config.sapp_name,
// 自定义PV参数key-value键值对(只能是这种平铺的json,不能做多层嵌套)
page_id,
page_name,
page_url,
},
],
})
/**
* 用户信息埋点
*/
// 如采集用户信息是异步行为需要先执行这个BLOCK埋点
window.aplus_queue.push({
action: 'aplus.setMetaInfo',
arguments: ['_hold', 'BLOCK'],
})
// 用户ID
window.aplus_queue.push({
action: 'aplus.setMetaInfo',
arguments: ['_user_id', _user_id],
})
// 如采集用户信息是异步行为,需要先设置完用户信息后再执行这个START埋点
// 此时被block住的日志会携带上用户信息逐条发出
window.aplus_queue.push({
action: 'aplus.setMetaInfo',
arguments: ['_hold', 'START'],
})
}
```
```html [index.html]
```
```html [index-multi.html]
Document
```
::
# GitLab CI/CD 完全指南
## 参考文档
> \[!NOTE]
> See: https\://gitlab.cn/docs/jh/topics/build\_your\_application/
>
> GitLab CI/CD: 使用 CI/CD 构建您的应用程序
> \[!NOTE]
> See: https\://developer.work.weixin.qq.com/document/path/99110
>
> 企业微信机器人: 消息推送配置说明
## 核心流程
项目的流水线被划分为多个阶段 (Stages),确保任务按预定顺序执行,完整流程如下:
`notify_start` -> `lint` -> `sonar` -> `build` -> `deploy` -> `notify_end`
- **lint & sonar**: 在合并请求 (Merge Request) 场景下运行,进行代码规范检查和静态质量分析,保障代码质量。
- **build & deploy**: 在推送到 `dev` 或 `master` 分支,或手动触发时执行,完成应用的构建和部署。
```vue
<__flatten />
```
\::
## 配置详解
### CI/CD 文件结构
```md
.
├── .gitlab-ci.yml # 主配置文件,定义 stages, workflow, variables, include 规则
├── .gitlab-dev.yml # dev 环境的作业 (build, deploy, notify)
├── .gitlab-prod.yml # prod 环境的作业 (build, notify)
└── scripts/
└── ci/
├── package-zip.sh # 打包构建产物为 .zip 并生成环境变量
├── deploy-zip.sh # 部署 .zip 包到目标服务器
└── wechat-notify.js # 发送企业微信通知
```
::code-tree{expand-all default-value=".gitlab-ci.yml"}
```yaml [.gitlab-ci.yml]
# 全局规则:只在指定分支或合并到指定分支的请求中运行
workflow:
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
when: always
- if: $CI_PIPELINE_SOURCE == "push" && ($CI_COMMIT_BRANCH == "dev" || $CI_COMMIT_BRANCH == "master")
when: always
- if: $CI_PIPELINE_SOURCE == "web"
when: always
- when: never
# 定义流水线的阶段
stages:
- notify_start
- lint
- sonar
- build
- deploy
- notify_end
# 定义变量
variables:
NODE_VERSION: lts
PNPM_VERSION: latest
GIT_DEPTH: 0
GIT_STRATEGY: clone
BUILD_ENV:
value: dev
options:
- dev
- prod
description: 选择构建环境(dev/prod)
SONAR_HOST_URL: # SonarQube 主机地址(自定义)
value: 'http://10.0.0.100:9000'
description: SonarQube主机地址
WECHAT_WEBHOOK_URL: # 企业微信webhook地址,用于通知(自定义)
value: 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=your-webhook-key-here'
description: 企业微信webhook地址
BUILD_DIR: # 构建目录(自定义)
value: app-gen/dist/web/your-app-name
description: 构建目录
DEPLOY_HOST: # 测试部署主机(一般固定,无需修改)
value: 10.0.0.100
description: 测试部署主机
DEPLOY_DIR: # 测试部署目录(自定义)
value: /home/user/nginx/html/
description: 测试部署目录
default:
image: node:${NODE_VERSION}
tags:
- sonarqube
include:
- local: .gitlab-dev.yml
rules:
- if: $CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "dev"
- if: $CI_PIPELINE_SOURCE == "web" && $BUILD_ENV == "dev"
- local: .gitlab-prod.yml
rules:
- if: $CI_PIPELINE_SOURCE == "web" && $BUILD_ENV == "prod"
# 代码检查作业
lint:
stage: lint
script:
- npm install -g pnpm@${PNPM_VERSION}
- pnpm install --frozen-lockfile
- pnpm eslint 'app/**/*.{ts,tsx,vue}'
artifacts:
when: always
reports:
junit: .eslintcache
paths:
- .eslintcache
expire_in: 1 week
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
allow_failure: true
# SonarQube 代码分析
sonarqube-check:
stage: sonar
image: openjdk:11-jre-slim
variables:
SONAR_PROJECT_KEY: ${CI_PROJECT_ID}
SONAR_PROJECT_NAME: ${CI_PROJECT_TITLE}
SONAR_PROJECT_VERSION: ${CI_COMMIT_SHORT_SHA}
SONAR_USER_HOME: '${CI_PROJECT_DIR}/.sonar'
SONAR_SCANNER_VERSION: 4.6.2.2472
cache:
key: 'sonarqube-${SONAR_SCANNER_VERSION}'
paths:
- .sonar/cache
- sonar-scanner/
before_script:
- echo "准备 SonarScanner CLI ${SONAR_SCANNER_VERSION}..."
- |
if [ ! -d "sonar-scanner" ]; then
echo "下载并安装 SonarScanner CLI..."
apt-get update && apt-get install -y wget unzip
wget -O sonar-scanner.zip "https://binaries.sonarsource.com/Distribution/sonar-scanner-cli/sonar-scanner-cli-${SONAR_SCANNER_VERSION}-linux.zip"
unzip sonar-scanner.zip
mv sonar-scanner-${SONAR_SCANNER_VERSION}-linux sonar-scanner
else
echo "使用缓存的 SonarScanner CLI..."
fi
- export PATH="$PWD/sonar-scanner/bin:$PATH"
script:
- echo "运行测试并生成覆盖率报告..."
- export PATH="$PWD/sonar-scanner/bin:$PATH"
- |
sonar-scanner \
-Dsonar.projectKey=${SONAR_PROJECT_KEY} \
-Dsonar.projectName="${SONAR_PROJECT_NAME}" \
-Dsonar.projectVersion=${SONAR_PROJECT_VERSION} \
-Dsonar.host.url=${SONAR_HOST_URL} \
-Dsonar.login=${SONAR_TOKEN}
allow_failure: true
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
# 流水线开始通知
notify-start:
stage: notify_start
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
script:
- node scripts/ci/wechat-notify.js --label "${CI_MERGE_REQUEST_TITLE}" --type CI-start
when: on_success
# 流水线结束通知成功
notify-end-success:
stage: notify_end
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
script:
- node scripts/ci/wechat-notify.js --label "${CI_MERGE_REQUEST_TITLE}" --type CI-end --success
when: on_success
# 流水线结束通知失败
notify-end-failed:
stage: notify_end
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
script:
- node scripts/ci/wechat-notify.js --label "${CI_MERGE_REQUEST_TITLE}" --type CI-end --failed
when: on_failure
```
```yaml [.gitlab-dev.yml]
# dev 环境作业定义(通过根 .gitlab-ci.yml 的 include.rules 条件加载)
# 构建作业
build:
stage: build
before_script:
- npm install -g pnpm@${PNPM_VERSION}
- pnpm install --frozen-lockfile
- pnpm run build:dev
script:
- bash scripts/ci/package-zip.sh
artifacts:
paths:
- '*.zip'
reports:
dotenv: zip.env
expire_in: 1 week
# 部署作业
deploy:
stage: deploy
needs:
- job: build
artifacts: true
variables:
DEPLOY_PORT: 22
DEPLOY_USER: root
DEPLOY_PASSWORD: your-password-here
script:
- bash scripts/ci/deploy-zip.sh
when: on_success
notify-deploy-start:
stage: notify_start
script:
- node scripts/ci/wechat-notify.js --label "测试环境部署开始" --type deploy-start
when: on_success
notify-deploy-end-success:
stage: notify_end
needs:
- job: build
artifacts: true
- job: deploy
script:
- node scripts/ci/wechat-notify.js --label "测试环境部署 成功 🎉" --type deploy-end
when: on_success
notify-deploy-end-failed:
stage: notify_end
script:
- node scripts/ci/wechat-notify.js --label "测试环境部署 失败 😭" --type deploy-end
when: on_failure
```
```yaml [.gitlab-prod.yml]
# prod 环境作业定义(通过根 .gitlab-ci.yml 的 include.rules 条件加载)
# 构建作业
build:
stage: build
before_script:
- npm install -g pnpm@${PNPM_VERSION}
- pnpm install --frozen-lockfile
- pnpm run build
script:
- bash scripts/ci/package-zip.sh
artifacts:
paths:
- '*.zip'
reports:
dotenv: zip.env
expire_in: 1 week
notify-deploy-start:
stage: notify_start
script:
- node scripts/ci/wechat-notify.js --label "正式环境打包开始" --type deploy:prod-start
when: on_success
notify-deploy-end-success:
stage: notify_end
needs:
- job: build
artifacts: true
script:
- node scripts/ci/wechat-notify.js --label "正式环境打包 成功 🎉" --type deploy:prod-end
when: on_success
notify-deploy-end-failed:
stage: notify_end
script:
- node scripts/ci/wechat-notify.js --label "正式环境打包 失败 😭" --type deploy:prod-end
when: on_failure
```
```sh [scripts/ci/deploy-zip.sh]
#!/usr/bin/env bash
set -euo pipefail
# required envs
for v in CI_PROJECT_DIR DEPLOY_HOST DEPLOY_DIR DEPLOY_USER DEPLOY_PASSWORD; do
if [ -z "${!v:-}" ]; then
echo "[deploy-zip] missing env: $v" >&2
exit 1
fi
done
DEPLOY_PORT="${DEPLOY_PORT:-22}"
# determine zip file
if [ -z "${ZIP_FILE:-}" ]; then
if [ -z "${BUILD_DIR:-}" ]; then
echo "[deploy-zip] ZIP_FILE and BUILD_DIR both unset" >&2
exit 1
fi
ZIP_BASENAME="$(basename "$BUILD_DIR")"
ZIP_FILE="${ZIP_BASENAME}.zip"
fi
cd "$CI_PROJECT_DIR"
if [ ! -f "$ZIP_FILE" ]; then
echo "[deploy-zip] ZIP_FILE not found: $ZIP_FILE" >&2
ls -la
exit 1
fi
# askpass for non-interactive ssh/scp
mkdir -p /tmp
echo '#!/bin/sh' > /tmp/askpass.sh && echo 'echo "$DEPLOY_PASSWORD"' >> /tmp/askpass.sh && chmod +x /tmp/askpass.sh
export SSH_ASKPASS=/tmp/askpass.sh
export DISPLAY=:0
SSH_OPTS="-p ${DEPLOY_PORT} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=no"
# ensure remote dir exists
setsid ssh $SSH_OPTS "${DEPLOY_USER}@${DEPLOY_HOST}" "mkdir -p '${DEPLOY_DIR}'"
# upload zip
setsid scp -P "${DEPLOY_PORT}" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=no \
"$ZIP_FILE" "${DEPLOY_USER}@${DEPLOY_HOST}:${DEPLOY_DIR}/"
# unzip remotely and optional cleanup
setsid ssh $SSH_OPTS "${DEPLOY_USER}@${DEPLOY_HOST}" \
"cd '${DEPLOY_DIR}' && (command -v unzip >/dev/null 2>&1 || (apt-get update && apt-get install -y unzip || yum install -y unzip || true)) && unzip -o '${ZIP_FILE}' -d '${DEPLOY_DIR}' && rm -f '${ZIP_FILE}'"
echo "[deploy-zip] deployed $ZIP_FILE to ${DEPLOY_USER}@${DEPLOY_HOST}:${DEPLOY_DIR}"
```
```sh [scripts/ci/package-zip.sh]
#!/usr/bin/env bash
set -euo pipefail
# required envs
for v in BUILD_DIR CI_PROJECT_DIR CI_PROJECT_ID CI_SERVER_URL CI_JOB_ID; do
if [ -z "${!v:-}" ]; then
echo "[package-zip] missing env: $v" >&2
exit 1
fi
done
# ensure zip exists (install once if missing)
if ! command -v zip >/dev/null 2>&1; then
apt-get update && apt-get install -y zip && rm -rf /var/lib/apt/lists/*
fi
ZIP_BASENAME="$(basename "$BUILD_DIR")"
ZIP_FILE="${ZIP_BASENAME}.zip"
cd "$(dirname "$BUILD_DIR")"
zip -rq "$CI_PROJECT_DIR/$ZIP_FILE" "$ZIP_BASENAME"
cat > "$CI_PROJECT_DIR/zip.env" < parts.find(p => p.type === type)?.value || '00'
const yyyy = get('year')
const MM = get('month')
const dd = get('day')
const HH = get('hour')
const mm = get('minute')
const ss = get('second')
return `${yyyy}-${MM}-${dd} ${HH}:${mm}:${ss}`
}
function formatHMS(totalSeconds) {
const pad = n => String(n).padStart(2, '0')
const s = Math.max(0, Math.floor(totalSeconds))
const hh = pad(Math.floor(s / 3600))
const mm = pad(Math.floor((s % 3600) / 60))
const ss = pad(s % 60)
return `${hh}:${mm}:${ss}`
}
async function send(payload) {
const url = process.env.WECHAT_WEBHOOK_URL
if (!url) {
console.log('[wechat-notify] WECHAT_WEBHOOK_URL not set, skip sending.')
return { skipped: true }
}
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
const text = await res.text()
console.log('[wechat-notify] response:', res.status, text)
return { status: res.status, body: text }
}
catch (e) {
console.error('[wechat-notify] error:', e && e.message ? e.message : e)
return { error: true }
}
}
function buildContentMarkdown({ type, label, success, failed }) {
const CI_MERGE_REQUEST_PROJECT_URL = `${process.env.CI_MERGE_REQUEST_PROJECT_URL}/-/merge_requests/${process.env.CI_MERGE_REQUEST_IID}`
const DEPLOY_HOST = process.env.DEPLOY_HOST
const DEPLOY_DIR = process.env.DEPLOY_DIR
const BUILD_DIR = process.env.BUILD_DIR
const ZIP_ARTIFACT_URL = process.env.ZIP_ARTIFACT_URL
// 提交者
const author = (process.env.GITLAB_USER_NAME || '').trim()
// 审核者
const reviewers = author === '张三' ? 'reviewer1' : 'reviewer2'
// 选择 @ 成员(即 userid)
const authorMap = {
张三: 'zhang.san',
李四: 'li.si',
王五: 'wang.wu',
赵六: 'zhao.liu',
孙七: 'sun.qi',
周八: 'zhou.ba',
}
// 时间计算
const startISO = process.env.CI_PIPELINE_CREATED_AT || new Date().toISOString()
const start = new Date(startISO)
const now = new Date()
const elapsedSec = (now - start) / 1000
// 状态处理(失败优先)
const isFailed = !!failed
const isSuccess = !!success && !failed
// 标题与基础信息
const title = `【${process.env.CI_PROJECT_TITLE}】 ${label}`
const lines = []
lines.push(title)
// 公共信息区块
lines.push(`> 提交者:${author}`)
// 类型分支逻辑
if (type === 'CI-start') {
lines.push(`> 开始时间:${formatShanghai(start)}`)
lines.push(`<@${reviewers}> [查看合并请求](${CI_MERGE_REQUEST_PROJECT_URL})`)
}
else if (type === 'CI-end') {
lines.push(`> 开始-结束时间:${formatShanghai(start)} 至 ${formatShanghai(now)}`)
lines.push(`> 总耗时:${formatHMS(elapsedSec)}`)
if (isSuccess) {
lines.push(`> 状态:成功 🎉`)
lines.push(`<@${reviewers}> [查看合并请求](${CI_MERGE_REQUEST_PROJECT_URL})`)
}
else if (isFailed) {
lines.push(`> 状态:失败 😭`)
lines.push(`<@${authorMap[author]}> <@${reviewers}> [查看合并请求](${CI_MERGE_REQUEST_PROJECT_URL})`)
}
}
else if (type === 'deploy-start') {
lines.push(`> 开始时间:${formatShanghai(start)}`)
lines.push(`> 打包目录:${BUILD_DIR}`)
lines.push(`> 目标主机:${DEPLOY_HOST}`)
lines.push(`> 目标目录:${DEPLOY_DIR}`)
}
else if (type === 'deploy-end' || type === 'deploy:prod-end') {
lines.push(`> 开始-结束时间:${formatShanghai(start)} 至 ${formatShanghai(now)}`)
lines.push(`> 总耗时:${formatHMS(elapsedSec)}`)
if (ZIP_ARTIFACT_URL) {
lines.push(`> [下载部署包](${ZIP_ARTIFACT_URL})`)
}
else {
lines.push(`<@${authorMap[author]}> <@${reviewers}>`)
}
}
else if (type === 'deploy:prod-start') {
lines.push(`> 开始时间:${formatShanghai(start)}`)
lines.push(`> 打包目录:${BUILD_DIR}`)
}
lines.push(`> [查看流水线](${process.env.CI_PIPELINE_URL})`)
return lines.join('\n')
}
async function main() {
const { type, label, success, failed } = parseArgs(args)
if (!type) {
console.log('[wechat-notify] no --type provided, skip.')
return
}
const md = buildContentMarkdown({ type, label, success, failed })
await send({ msgtype: 'markdown', markdown: { content: md } })
}
main().catch((e) => {
console.error('[wechat-notify] unexpected error:', e)
}).finally(() => {
// 不阻塞流水线
process.exit(0)
})
```
::
> \[!TIP]
>
> 为了隔离不同环境的构建逻辑,采用了多脚本和动态配置的策略。
>
> - **构建脚本分离**: `package.json`中定义了两个构建脚本:
> - `build`: 用于 `prod` 环境,执行生产环境的完整构建。
> - `build:dev`: 用于 `dev` 环境,执行测试环境的特定构建。
> - **动态加载配置**: `.gitlab-ci.yml` 会根据 `$BUILD_ENV` 变量的值,通过 `include` 规则动态加载 `.gitlab-dev.yml` 或 `.gitlab-prod.yml`,从而执行对应环境的作业。
### 全局环境变量
| 变量名 | 用途 |
| :------------------- | :------------------------------- |
| `BUILD_ENV` | 控制构建环境 (`dev`/`prod`),流水线自动或手动选择 |
| `BUILD_DIR` | 指定构建产物的输出目录,根据项目调整 |
| `SONAR_HOST_URL` | SonarQube 服务器地址 |
| `WECHAT_WEBHOOK_URL` | 企业微信 WebHook地址,用于通知 |
| `DEPLOY_HOST` | 部署目标服务器 IP 地址 |
| `DEPLOY_DIR` | 部署到服务器上的目标目录 |
> \[!WARNING]
>
> BUILD\_DIR
>
> 等变量需要根据不同项目进行修改,请确保其指向正确的构建产物目录。
### 企业微信通知
流水线的关键节点会通过企业微信机器人发送实时通知。
- **通知脚本**: `scripts/ci/wechat-notify.js` 负责组装消息内容并发送。
- **@ 成员**: 在消息中可以使用 `<@userid>` 的语法来提及指定成员,请确保填入的是成员的**账号 (userid)**,而不是手机号或姓名。

## 手动执行流水线
除了自动化触发,你也可以手动运行流水线,并指定构建环境或者其他定义的全局变量。
1. 进入项目的 `构建` -> `流水线` 页面。
2. 点击 `运行流水线` 按钮。
3. 选择对应分支,在 `变量` 区域,`BUILD_ENV` 变量会提供一个下拉框,你可以选择 `dev` 或 `prod` 环境。

## 常见问题 (FAQ)
```vue
<__flatten>
Q: CI 脚本报错 `No such file or directory`,找不到构建产物目录?
这是因为
.gitlab-ci.yml
中的
BUILD_DIR
变量被配置为了
绝对路径
(例如
/app/dist/...
)。
[!NOTE]
CI Runner 的工作目录是
$CI_PROJECT_DIR
,构建产物路径应该是相对于该目录的
相对路径
。
解决方案:
修改
.gitlab-ci.yml
中的
BUILD_DIR
变量,将其改为
相对路径
(例如
app/dist/...
)。
Q: 部署成功通知 (`notify-deploy-end-success`) 为什么在部署完成前就发送了?
因为该通知作业的
needs
依赖中只包含了
build
作业,而没有包含
deploy
作业,导致它在
build
完成后就立即执行。
Q: 为什么合并到 `dev` 分支的 Merge Request 没有触发 `dev` 环境的构建和部署流程?
因为
.gitlab-dev.yml
的
include
规则依赖于
$BUILD_ENV
变量,但在自动触发的 MR 流水线中,定义在
.gitlab-ci.yml
文件内部的
variables
在
include
解析阶段是不可用的
。
Q: 部署时,服务器上只出现了 `dist` 目录里的文件,而没有包含父文件夹?
这是因为部署脚本中的
scp
命令源路径使用了
.../.
结尾,这表示只复制目录的
内容
,而不是目录本身。
Q: `build` 或 `deploy` 失败后,为什么收不到失败通知?
因为失败通知作业 (
notify-deploy-end-failed
) 的
needs
依赖了上游作业。一旦上游作业失败,该通知作业自身会被 GitLab 跳过 (skipped)。
Q: 企业微信通知中的 `@` 为什么不生效?
企业微信的 Markdown 消息中,
@
成员有严格的语法要求。
```
# IServer 踩坑归集
## 参考文档
> \[!NOTE]
> See: http\://support.supermap.com.cn/DataWarehouse/WebDocHelp/iServer/API/iServer\_API\_reference.htm
>
> iServer 开发指南: API 参考
> \[!NOTE]
> See: https\://iclient.supermap.io/examples/mapboxgl/examples.html#iServer
>
> iClient for MapboxGL 示范程序
> \[!NOTE]
> See: https\://support.supermap.com/DataWarehouse/WebDocHelp/iServer/mergedProjects/SuperMapiServerRESTAPI/resource\_hierarchy.htm
>
> iServer 服务资源层次结构
## 地图服务
地图服务名称通常以 **map-xxx** 开头, 如 `https://iserver.supermap.io/iserver/services/map-world/`
配置通用的服务接口:

目前项目主要使用的接口有: `rest` 和 `wms130`
> \[!WARNING]
>
> SuperMap 的
>
> wms110
>
> 版本服务支持该值的目的是向后兼容
### zxyTileImage 瓦片服务
> \[!NOTE]
> See: http\://support.supermap.com.cn/DataWarehouse/WebDocHelp/iServer/mergedProjects/SuperMapiServerRESTAPI/root/maps/map/zxyTileImage/zxyTileImage.htm
>
> iServe zxyTileImage 瓦片服务

示例服务地址: `https://iserver.supermap.io/iserver/services/map-china400/rest/maps/China/zxyTileImage`
### wms130 服务
> \[!NOTE]
> See: http\://support.supermap.com.cn/DataWarehouse/WebDocHelp/iServer/API/WMS/WMS\_introduce.htm
>
> iServer WMS 服务

示例服务地址: `https://iserver.supermap.io/iserver/services/map-china400/wms130`
## 数据服务
数据服务名称通常以 **data-xxx** 开头, 如 `https://iserver.supermap.io/iserver/services/data-jingjin`
配置通用的服务接口:

### rest 服务
> \[!NOTE]
> See: https\://support.supermap.com/DataWarehouse/WebDocHelp/iServer/mergedProjects/SuperMapiServerRESTAPI/root/data/featureResults/featureResults.htm
>
> featureResults 资源
- 数据服务: `https://iserver.supermap.io/iserver/services/data-jingjin/rest/data/featureResults.geojson`
- 数据集格式: 数据源名称:数据集名称, 如 `Jingjin:County_L`
### wfs2.0 服务
> \[!NOTE]
> See: http\://support.supermap.com.cn/DataWarehouse/WebDocHelp/iServer/API/WFS/WFS\_introduce.htm
>
> iServer WFS 服务
示例服务地址: `https://iserver.supermap.io/iserver/services/data-world/wfs200`
## 问题归集 (FAQ)
```vue
<__flatten>
Q: wms 服务通过 sld_body 修改样式不生效
[!CAUTION]
尝试用 SLD_BODY 自定义 wms 服务的样式, GetMap 请求格式如下,图层样式没有渲染
```
````text

问题原因:
wms 服务目前只支持已定义的图层样式
::note{icon="i-lucide-book" to="http://support.supermap.com.cn/DataWarehouse/WebDocHelp/iServer/API/WMS/WMS130/GetMap/GetMap_request.htm"}
iServer GetMap 请求
::

:::
:::accordion-item{label="地图服务获取 geojson 表述格式错误" icon="i-lucide-circle-help"}
::caution
请求url /iserver/services/map-text/rest/FZJZSSD@cs.geojson 与资源 root 的 url 模板不匹配
::

问题原因:
- 数据服务的要素才有 geojson 表述格式,是否支持 geojson 格式,可以看右侧目录栏
- 地图服务和数据服务属于不同的服务类型,需要重新发布服务并勾选 rest 接口
:::
:::accordion-item{label="报错:400 ,msg:对象已经被释放" icon="i-lucide-circle-help"}

问题原因:
可能是数据库数据不同步,先用文件型数据源试下接口请求是否正确
:::
:::accordion-item{label="wfs2.0 服务获取描述文档成功,获取要素的时候报错" icon="i-lucide-circle-help"}


问题原因:
- iserver 版本为 `iserver 2023 11i` ,不支持 geojson 输出
supermap wfs2.0 执行 GetFeature 操作支持 `outputFormat=json` 输出,但是 xml 表述文档中没有加上`json`, 猜测是这个原因导致 arcgis 提示不支持
:::
:::accordion-item{label="wfs2.0服务的点击事件拿不到要素全部属性值" icon="i-lucide-circle-help"}
有个需求为点击地块展示详情,但是获取到的要素只有部分属性值


问题原因:
`iServer` 的 `GetFeature` 请求使用 `FILTER` 参数,编码语言为 `urn:ogc:def:query Language:OGC-FES:Filter`
::note{icon="i-lucide-book" to="http://support.supermap.com.cn/DataWarehouse/WebDocHelp/iServer/API/WFS/WFS200/GetFeature/FILTER.htm"}
iServer FILTER 示例
::
可以通过 `esri_wfs_id` 与 `表名` 传给后端,后端根据 `esri_wfs_id` 查询数据库,返回结果
:::
:::accordion-item{label="如何获取地图当前状态的基本信息" icon="i-lucide-circle-help"}
[iServer map 资源](http://support.supermap.com.cn/DataWarehouse/WebDocHelp/iServer/mergedProjects/SuperMapiServerRESTAPI/root/maps/map/map.htm)
获取服务的四至范围,用来实现服务跳转定位
:::
:::accordion-item{label="列出当前地图中所有图层的图例" icon="i-lucide-circle-help"}
利用上面的问题6,获取到服务的四至范围,然后拼接成 `BBOX` 参数,
```ts
const bbox = `${bounds.left},${bounds.bottom},${bounds.right},${bounds.top}`
const url = `https://iserver.supermap.io/iserver/services/map-china400/rest/maps/China/legend.rjson?returnVisibleOnly=true&bbox=-20037508.34,-20037508.34,20037508.34,20037508.34`
```
:::
````
\::
\::
# Linux
## 常用命令
| 命令 | 功能说明 |
| -------------------------------- | --------------------------------- |
| `mkdir include` | 创建一个 `include` 文件夹 |
| `unzip -d ./include include.zip` | 将 `include.zip` 解压到 `include` 文件夹 |
| `mv old_folder new_folder` | 文件夹重命名 |
# macOS
> \[!NOTE]
> See: https\://brew\.sh/zh-cn/
>
> Homebrew : macOS(或 Linux)缺失的软件包的管理器
> \[!NOTE]
> See: https\://iterm2.com/
>
> iTerm2 : macOS Terminal Replacement
> \[!NOTE]
> See: https\://ohmyz.sh/
>
> Oh My Zsh : a delightful & open source framework for Zsh
## 配置 Homebrew
[Homebrew](https://brew.sh/zh-cn/){rel=""nofollow""}(通常称为 Brew)是 macOS 和 Linux 上的一个流行的包管理器,用于简化软件的安装和管理。
### 打开终端
在应用程序文件夹中找到终端(Terminal),或使用 Spotlight 搜索“终端”,在终端中输入以下命令并按回车
```sh [sh]
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
```
### 配置环境变量
安装完成后,可能需要将 Homebrew 添加到你的 PATH 中,按提示操作,例如:
```text {4-5}
==> Next steps:
- Run these two commands in your terminal to add Homebrew to your PATH:
(echo; echo 'eval "$(/opt/homebrew/bin/brew shellenv)"') >> /Users/yixuanmiao/.zprofile // [!code focus]
eval "$(/opt/homebrew/bin/brew shellenv)" // [!code focus]
- Run brew help to get started
- Further documentation:
https://docs.brew.sh
```
```sh [sh]
(echo; echo 'eval "$(/opt/homebrew/bin/brew shellenv)"') >> /Users/yixuanmiao/.zprofile
eval "$(/opt/homebrew/bin/brew shellenv)"
```
### 验证安装
输入以下命令检查 Homebrew 是否安装成功:
```sh [sh]
brew --version
```
输出:
```text
Homebrew 4.3.23-56-g9160445
```
## 安装 Iterm2
[Iterm2](https://iterm2.com/){rel=""nofollow""} 是 macOS 上一款功能强大的终端仿真器,提供了许多增强的功能和自定义选项。

### 下载
- 访问 [Iterm2 官网](https://iterm2.com/){rel=""nofollow""} 下载最新版本的 Iterm2
- 或者使用 Homebrew 安装
```sh \[sh]
brew install --cask iterm2
```
### 将 iTerm2设置为默认终端
打开 iTerm2,选择菜单栏中的 `iTerm2 -> Make iTerm2 Default Term`

### 配置迁移(可选)
如果你之前使用过终端,可以将配置迁移过来,并将其设置为默认配置。
- iTerm2 -> Setting -> Profiles -> Other Actions
- Save Profile as JSON (保存配置文件)
- Import JSON Profiles (导入配置文件)
- Set as Default (设置为默认配置)

### 更改主题
- iTerm2 -> Setting -> Profiles -> Colors-> Color Presets
- 选择你喜欢的主题 (例如:Solarized)

### 调整 Status Bar
- iTerm2 -> Setting -> Profiles -> Session -> Status Bar
- 勾选 Enable status bar
- 配置你需要的信息

将上方 Status Bar Component Menu 中的内容拖动到 Active Components 中,即可显示在状态栏中。

## Oh My Zsh
[`Oh My Zsh`](https://ohmyz.sh/){rel=""nofollow""} 是一个流行的 Zsh 配置管理工具,旨在简化 Zsh 的配置和管理。它提供了许多预配置的插件、主题以及一些便捷的功能,帮助用户提高终端使用效率。
```sh [sh]
sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)"
```
安装完成后,`Oh My Zsh` 会自动创建一个配置文件 `~/.zshrc`。你可以在这个文件中进行自定义:
- **修改主题**: 找到 `ZSH_THEME` 这一行,修改为你喜欢的主题(例如 `robbyrussell`、`powerlevel10k/powerlevel10k` 等)。
- **启用插件**: 在 `plugins=(...)` 一行中添加你需要的插件,如 `git`、`zsh-autosuggestions`、`zsh-syntax-highlighting` 等。
> \[!TIP]
>
> 每次修改完 `~/.zshrc` 后,运行以下命令以应用更改:
>
> ```sh [sh]
> source ~/.zshrc
> ```
### 修改主题 Powerlevel10k
`Powerlevel10k` 是一个非常流行的 Zsh 主题,它以速度和高度可定制化著称,能够为你的终端提供丰富的信息和美观的外观。
#### 克隆 Powerlevel10k 到 `oh-my-zsh` 主题目录
```sh [sh]
git clone --depth=1 https://github.com/romkatv/powerlevel10k.git ${ZSH_CUSTOM:-~/.oh-my-zsh/custom}/themes/powerlevel10k
```
#### 启用 Powerlevel10k 主题
- 打开 `~/.zshrc` 文件
```sh \[sh]
code ~/.zshrc
```
- 找到 `ZSH_THEME="..."` 这一行,修改为:
```sh \[sh]
ZSH_THEME="powerlevel10k/powerlevel10k"
```
- 保存并退出编辑器,然后运行以下命令以应用更改:
```sh \[sh]
source ~/.zshrc
```
#### 配置 Powerlevel10k
安装完成后,首次启动 Zsh 时会自动启动 Powerlevel10k 配置向导,它会引导你选择主题的外观和信息显示内容。
如果你想重新配置 Powerlevel10k,可以运行以下命令:
```sh [sh]
p10k configure
```
以下是配置向导的示例:
- 自动安装所需字体
```text
This is Powerlevel10k configuration wizard. It will ask you a few questions and
configure your prompt.
Install Meslo Nerd Font?
(y) Yes (recommended).
(n) No. Use the current font.
(q) Quit and do nothing.
Choice [ynq]: y
```
- 手动安装字体 [MesloLGS Nerd Font GitHub](https://github.com/romkatv/powerlevel10k#manual-font-installation){rel=""nofollow""}
- 生成配置文件
配置完成后,向导会生成一个 `~/.p10k.zsh` 文件,保存用户的所有设置。
### 安装插件
Oh My Zsh 提供了多种实用插件,能够提升你的开发效率。以下是一些常用的插件:
- **git**:方便 git 操作,缩短命令长度
- **zsh-autosuggestions**:根据历史命令自动补全
- **zsh-syntax-highlighting**:根据语法高亮显示命令
```sh [sh]
git clone https://github.com/zsh-users/zsh-autosuggestions ${ZSH_CUSTOM:-~/.oh-my-zsh/custom}/plugins/zsh-autosuggestions
git clone https://github.com/zsh-users/zsh-syntax-highlighting.git ${ZSH_CUSTOM:-~/.oh-my-zsh/custom}/plugins/zsh-syntax-highlighting
```
然后在 `~/.zshrc` 文件中启用这两个插件:
```sh [sh]
plugins=(git zsh-autosuggestions zsh-syntax-highlighting)
```
启用插件后,运行以下命令以应用更改:
```sh [sh]
source ~/.zshrc
```
# Node
## Node 的版本选择
- **LTS 版本**(长期支持版本):稳定版本,通常用于生产环境。
- **Current 版本**(当前版本):最新的主要版本,加入最新特性和改进,通常用于开发和测试。
## 安装配置
### 使用包管理器安装
> \[!NOTE]
>
> 包版本管理工具的主要好处在于帮助开发者更方便地管理多个版本的 Node 和 npm。
- [nvm](https://github.com/nvm-sh/nvm){rel=""nofollow""} 最受欢迎的 Node 版本管理工具,适用于 macOS 和 Linux。
- [nvm-windows](https://github.com/coreybutler/nvm-windows){rel=""nofollow""} `nvm` 的 Windows 版本,专为 Windows 开发者设计。
- [fnm](https://github.com/Schniz/fnm){rel=""nofollow""} 也是 macOS 的优秀选择,具备轻量和高效的特点,适合那些不想耗费系统资源的开发者。
### 直接下载安装
> \[!NOTE]
> See: https\://nodejs.org/
>
> 从 Node.js 官网下载安装包(
>
> .pkg
>
> 、
>
> .msi
>
> 、
>
> .tar.gz
>
> 文件)
```sh [sh]
# 安装 unzip
sudo apt-get update && sudo apt-get install -y unzip
# 安装 fnm
curl -o- https://fnm.vercel.app/install | bash
# 重新加载环境
source /root/.bashrc
# 安装 Node.js
fnm install 22
# 验证 Node.js 版本
node -v # Should print "v22.18.0".
# 验证 npm 版本
npm -v # Should print "10.9.3".
# 启用 pnpm
corepack enable pnpm
```
```sh [sh]
brew install node
```
```sh [sh]
winget install --id=OpenJS.Nodejs
```
## 实用命令
- 删除所有 `node_modules` 文件夹
```sh \[sh]
find . -name 'node_modules' -type d -prune -execdir rm -rf '{}' +
```
- 递归删除 `packages` 和 `internal` 目录下的 `dist` 文件夹,同时忽略 `node_modules` 目录
```sh \[sh]
find packages internal -path '*/node_modules/*' -prune -o -name 'dist' -type d -exec rm -rf {} + || true
```
- `postinstall` 钩子在安装依赖后执行,可以用来执行一些构建操作,比如构建、设置环境或修复依赖关系。
```json \[package.json]
{
"scripts": {
"postinstall": "pnpm build",
"build": "pnpm clean && pnpm -r -F='./packages/**' -F='./internal/**' run build",
"clean": "find packages internal -path '*/node_modules/*' -prune -o -name 'dist' -type d -exec rm -rf {} + || true"
}
}
```
## 笔记
### 参数传递
- 当你使用 `npm run` 命令时,如果你想要传递参数给你的脚本,你需要在参数前加上 `--` , 例如:
```sh [sh]
npm run gen:cc -- --path ol-cesium-map --name demo
```
这样,`--path ol-cesium-map --name demo` 就会被传递给你的脚本,而不是 `npm run` 命令。
- 使用 `mri` 来解析这些参数:
```ts [index.ts]
const argv = process.argv.slice(2)
const mriData = mri(argv)
// mriData : { _: [], path: 'ol-cesium-map', name: 'demo' }
```
### 增加 node 内存限制
通过 `--max_old_space_size` 选项,你可以指定更大的内存使用限制,构建大项目时能有效避免内存不足导致的 `JavaScript heap out of memory` 错误
```sh [sh]
export NODE_OPTIONS=--max_old_space_size=10240
```
或者在 `package.json` 中的 `scripts` 中指定:
```json [package.json]
{
"scripts": {
"build": "NODE_OPTIONS=--max_old_space_size=10240 react-scripts build"
}
}
```
# Homebrew
> \[!NOTE]
> See: https\://brew\.sh/zh-cn/
>
> Homebrew : macOS(或 Linux)缺失的软件包的管理器
## 安装 Homebrew
### 安装命令
```sh [sh]
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
```
### 添加 Homebrew 到系统路径
> \[!TIP]
>
> 安装完成后,Homebrew 可能不会自动添加到你的系统路径中。根据你的 Mac 使用的是 Intel 处理器还是 Apple Silicon(M1/M2 等),路径有所不同。
```sh [sh]
echo 'eval "$(/usr/local/bin/brew shellenv)"' >> /Users/$(whoami)/.zprofile
eval "$(/usr/local/bin/brew shellenv)"
```
```sh [sh]
echo 'eval "$(/opt/homebrew/bin/brew shellenv)"' >> /Users/$(whoami)/.zprofile
eval "$(/opt/homebrew/bin/brew shellenv)"
```
### 验证安装
```sh [sh]
brew --version
```
输出:
```sh [sh]
Homebrew 4.3.23-56-g9160445
```
## 配置文件导出
在当前目录下导出 Homebrew 的软件包列表和配置文件。
```sh [sh]
# 导出已安装的软件包列表
brew list --formula > brew-packages.txt
# 导出已安装的 Cask 软件包列表
brew list --cask > brew-cask-packages.txt
# 导出 Homebrew 的配置
brew config > brew-config.txt
```
将文件保存到指定目录: `/path/to/your/directory/`
```sh [sh]
brew list --formula > /path/to/your/directory/brew-packages.txt
```
## 导入软件包和配置
```sh [sh]
# 导入已安装的软件包列表
xargs brew install < brew-packages.txt
# 导入已安装的 Cask 软件包列表
xargs brew install --cask < brew-cask-packages.txt
```
> \[!TIP]
>
> 一般情况下,Homebrew 的配置不会随着系统的重装等过程丢失,因此不需要专门导入配置
# IntelliJ IDEA
## 安装 IntelliJ IDEA
直接下载来安装 IntelliJ IDEA。
1. **访问官网**:
前往 [IntelliJ IDEA 官方下载页面](https://www.jetbrains.com/idea/download/){rel=""nofollow""}。
2. **选择版本**:
根据你的需求下载 `.dmg` 文件。
3. **安装**:
打开下载的 `.dmg` 文件,将 `IntelliJ IDEA.app` 拖入 `Applications` 文件夹。
## 添加版权声明
> \[!TIP]
> See: https\://www\.jetbrains.com/zh-cn/help/idea/copyright.html
>
> IDEA 官方文档
我的版权声明模板:
```text [md]
@Author $username
@Date $file.lastModified.format("yyyy/MM/dd HH:mm")
```
代码文件生成的版权如下:
```java [java]
/*
* @Author yixuanmiao
* @Date 2025/08/11 17:07
*/
```

# Pnpm
## 安装
::__flatten
```sh [corepack (推荐)]
corepack enable pnpm
corepack use pnpm@latest
```
```sh [npm]
npm install -g pnpm
```
```sh [brew]
brew install pnpm
```
::
## 工作空间
`pnpm-workspace.yaml` 定义了工作空间的根目录,并能够使您从工作空间中包含 `/` 排除目录。默认情况下,包含所有子目录。
```yaml [pnpm-workspace.yaml]
packages:
- packages/*
- docs
- packages/playground/**
```
## 常用命令
| 命令 | 描述 |
| ------------------ | ---- |
| `pnpm install` | 安装依赖 |
| `pnpm store prune` | 清理缓存 |
## 故障排查
### 信任降级错误(Trust Downgrade)
> \[!NOTE]
>
> pnpm 10.21+ 引入了
>
> trustPolicy
>
> 功能用于防范供应链攻击。当包的信任级别降低时(如从有来源证明降为无证明),安装会失败。
**典型错误信息**:
```text
ERR_PNPM_TRUST_DOWNGRADE High-risk trust downgrade for "package-name@x.x.x" (possible package takeover)
Earlier versions had provenance attestation, but this version has no trust evidence.
```
**问题原因**:
npm 包的信任级别分为三个层次:
1. 可信发布者(Trusted Publisher) - 最高
2. 来源证明(Provenance Attestation) - 中等
3. 无证据(No Evidence) - 最低
当新版本的信任级别低于早期版本时,pnpm 认为这可能是包被劫持的信号,会阻止安装。
**常见受影响的包**:
- `undici` / `undici-types` - [相关 Issue](https://github.com/nodejs/undici/issues/4666){rel=""nofollow""}
- `chokidar`
- 其他未配置发布流程证明的包
**解决方案**:
在 `pnpm-workspace.yaml` 中添加信任策略例外:
```yaml [pnpm-workspace.yaml]
trustPolicy: no-downgrade
trustPolicyExclude:
- undici
- undici-types
- chokidar
```
**安全建议**:
> \[!NOTE]
>
> - 仅为知名包添加例外
> - 定期检查 [GitHub Advisory Database](https://github.com/advisories){rel=""nofollow""} 了解已知漏洞
> - 优先考虑联系包维护者配置 npm provenance
> - 可参考 [pnpm 文档](https://github.com/pnpm/pnpm/issues/10329){rel=""nofollow""}了解更多信息
### 自举失败(integrity in undefined)
> \[!NOTE]
>
> pnpm 10 起
>
> managePackageManagerVersions
>
> 默认开启。当当前 pnpm 版本与
>
> package.json
>
> 中
>
> packageManager
>
> 声明的版本不一致时,pnpm 会先把目标版本装到全局目录再执行命令。这条自更新路径在 pnpm 11.12.0 上存在缺陷,会直接崩溃。
**典型错误信息**:
```text
[ERROR] Cannot use 'in' operator to search for 'integrity' in undefined
pnpm: Cannot use 'in' operator to search for 'integrity' in undefined
at createFullPkgId (.../pnpm/dist/pnpm.mjs)
at lockfileToDepGraph (.../pnpm/dist/pnpm.mjs)
at headlessInstall (.../pnpm/dist/pnpm.mjs)
at async installPnpmToGlobalDir (.../pnpm/dist/pnpm.mjs)
```
**关键判据**:调用栈里出现 `installPnpmToGlobalDir`,本地执行 `pnpm -v` 也报同样的错。这说明失败发生在 pnpm 自举阶段,与项目的依赖树、lockfile 完全无关,不要浪费时间去删 `node_modules` 或重新生成 lockfile。
**问题原因**:
自更新时 pnpm 会用全局环境 lockfile 重建一份依赖图,`buildLockfileFromEnvLockfile` 对带 peer 依赖的快照(形如 `fdir@6.5.0(picomatch@4.0.5)`)取不到对应的 `packages[depPath]` 条目,又没有回退到基础包,导致合并出的包对象缺少 `resolution` 块;随后 headless install 读取 `resolution.integrity`,`in` 运算符在 `undefined` 上抛出 TypeError。
上游追踪:[pnpm/pnpm#12959](https://github.com/pnpm/pnpm/issues/12959){rel=""nofollow""}(根因,修复 PR [#12960](https://github.com/pnpm/pnpm/pull/12960){rel=""nofollow""} 待合并)、[pnpm/action-setup#276](https://github.com/pnpm/action-setup/issues/276){rel=""nofollow""}(CI 场景)。
触发条件只有一个:**当前运行的 pnpm 版本 ≠ `packageManager` 声明的目标版本**。常见于两个场景:
1. 本地:Renovate 把 `packageManager` 从 `pnpm@11.10.0` 升到 `pnpm@11.12.0`,而本机(如 Homebrew 安装的)仍是旧版本。
2. CI:`pnpm/action-setup@v6` 会先装一个固定的 bootstrap pnpm(v11.7.0),再无条件执行 `pnpm self-update <目标版本>`。因此在 v6 上即使显式写 `with: version:` 也绕不开这条路径。
**解决方案**:
```sh [本地:升级 pnpm]
# 让本机版本与 packageManager 对齐,直接跳过自更新路径
brew upgrade pnpm
pnpm -v # 确认输出与 packageManager 一致
```
```yaml [CI:改用 action-setup@v5]
# v5 直接用 npm 安装目标版本,不走 self-update
- uses: pnpm/action-setup@v5
```
> \[!NOTE]
>
> 不要为规避此问题去改
>
> package.json
>
> 或加
>
> .npmrc
>
> 关闭
>
> managePackageManagerVersions
>
> ,保持默认行为对 CI 与本地的版本一致性更有利。Renovate 每次 bump
>
> packageManager
>
> 都会让本机版本落后一次,因此这个坑会周期性复现,版本对齐是常规应对。CI 侧若用 Renovate 自动升级 Actions,需要临时锁住
>
> pnpm/action-setup
>
> 的 major 版本,否则会被重新顶回 v6。
# VS Code
## 安装
- 打开浏览器,访问 [VSCode 官方下载页面](https://code.visualstudio.com/){rel=""nofollow""}
- 点击页面中的 "Download for macOS" 按钮。
- 下载完成后,你将获得一个 `.zip` 文件。
- 双击 `.zip` 文件进行解压,你会得到一个 `Visual Studio Code.app` 应用程序。
- 将 `Visual Studio Code.app` 拖动到 **Applications** 文件夹中,这样你就可以从应用程序目录运行它。
## 在提取后删除远程分支
删除操作会删除不再存在于远程库上的远程跟踪分支,有助于将分支列表保持干净和最新,对应于 `git fetch --prune`。
1. 打开 VS Code 的设置,搜索 `git prune`。
2. 启用“提取时修剪”选项。

## GitLens 提交消息自定义指令
在 VS Code 的 `settings.json` 文件中添加 `gitlens.ai.generateCommitMessage.customInstructions` 配置项:
```json [settings.json]
{
"gitlens.ai.generateCommitMessage.customInstructions": "Generate a Conventional Commit message. The commit type (e.g., feat, fix, chore) and any optional scope can be in English, but the main description of the commit must be written in Chinese."
}
```
### 使用方法
1. 完成代码修改后,在 GitLens 面板中选择 "Generate Commit Message with GitLens"
2. AI 将根据自定义模板生成符合规范的提交消息
3. 根据需要微调生成的消息内容
4. 提交代码

## 使用 VSCode 打开
为了能够在终端中使用 `code` 命令来快速打开文件和文件夹,你可以安装 `code` 命令行工具:
打开 VSCode,按 `Cmd + Shift + P`,然后输入 `Shell Command: Install 'code' command in PATH`,选择该选项并执行。
安装完成后,你可以在终端中运行 code 命令。例如:
```sh [sh]
# 打开当前目录
code .
```
## 添加 "使用 VSCode 打开" 的右键菜单选项
1. 打开 Automator 应用程序
- 你可以通过 Spotlight 搜索 `Automator` 打开它
2. 选择 "快速操作" 类型 :br
3. 配置服务
- 在窗口顶部,将 “工作流程收到当前” 更改为 “文件或文件夹”
- 将 “位于” 设置为 “访达.app”
:br
4. 添加 VSCode 动作:
- 在左侧搜索框中输入 `运行 Shell 脚本`,将其拖动到右侧的工作区
- 在 `Shell` 下拉菜单中选择 `/bin/zsh`
- 在 `传递输入` 下拉菜单中选择 `作为自变量`
- 在脚本框中输入以下内容:
```sh \[sh]
for f in "$@"
do
open -a "Visual Studio Code" "$f"
done
```
:br
5. 保存服务
- 点击左上角的保存按钮,输入服务名称,例如 `使用 VSCode 打开`
6. 使用服务
- 在 Finder 中,右键单击文件或文件夹,选择 `服务` -> `使用 VSCode 打开`
:br
# Fnm
> \[!NOTE]
> See: https\://github.com/Schniz/fnm
>
> Fast Node Manager (fnm) : 一个快速的 Node.js 版本管理器,它可以帮助你在不同项目中切换 Node.js 版本。
## 安装
```sh [curl.sh]
curl -fsSL https://fnm.vercel.app/install | bash
```
```sh [brew.sh]
brew install fnm
```
## 配置环境
需要将 fnm 集成到你的 Shell(如 bash、zsh)。可以参考输出的安装脚本,或手动添加以下命令到你的 `.zshrc` 或 `.bashrc` 文件中:
```sh [sh]
eval "$(fnm env)"
source ~/.zshrc
```
> \[!NOTE]
>
> brew 在安装 fnm 后给出了环境配置的提示,并自动将 fnm 的路径和相关配置追加到 `~/.zshrc` 文件中
>
> ```text
> ==> Running `brew cleanup fnm`...
> Disable this behaviour by setting HOMEBREW_NO_INSTALL_CLEANUP.
> Hide these hints with HOMEBREW_NO_ENV_HINTS (see `man brew`).
> Installing for Zsh. Appending the following to /Users/yixuanmiao/.zshrc:
>
> # fnm
>
> FNM_PATH="/Users/yixuanmiao/Library/Application Support/fnm"
> if [ -d "$FNM_PATH" ]; then
> export PATH="/Users/yixuanmiao/Library/Application Support/fnm:$PATH"
> eval "`fnm env`"
> fi
>
> In order to apply the changes, open a new terminal or run the following command:
>
> source /Users/yixuanmiao/.zshrc
> ```
## 安装 Node.js
```sh [sh]
fnm install
fnm use
```
## 功能参数
- `--use-on-cd`:在每次进入目录时自动切换 Node.js 版本 (✅ 推荐)
- `--version-file-strategy=recursive`:递归查找 `.node-version` 或 `.nvmrc` 文件 (✅ 推荐)
- `--resolve-engines`:解析 `package.json` 中的 `engines.node` 字段 (🧪 实验)
```json \[package.json]
{
"engines": {
"node": ">=18.0.0"
}
}
```
- `--corepack-enabled`: 使用 Corepack 作为包管理器 (🧪 实验)
## 常用命令
| 命令 | 功能说明 |
| ------------------------- | ------------------- |
| `fnm ls-remote` | 查询所有 Node.js 版本 |
| `fnm install ` | 安装特定版本的 Node.js |
| `fnm install --lts` | 安装最新的 LTS 版本 |
| `fnm use ` | 切换 Node.js 版本 |
| `fnm current` | 查看当前使用的 Node.js 版本 |
| `fnm default ` | 设置默认版本 |
| `fnm ls` | 查看所有已安装的 Node.js 版本 |
| `fnm uninstall ` | 卸载 Node.js |
## 报错处理
```vue
<__flatten>
Q: zsh: command not found: node
[!WARNING]
See: https://github.com/Schniz/fnm/issues/1279
github issues : Zsh shell setup command did not work for me
```
如果在使用 `node` 命令时出现 `zsh: command not found: node` 错误,可以尝试在 `.zshrc` 文件中替换以下配置:
```diff
FNM_PATH="/Users/yixuanmiao/Library/Application Support/fnm"
- if [ -d "$FNM_PATH" ]; then
export PATH="/Users/yixuanmiao/Library/Application Support/fnm:$PATH"
eval "`fnm env`"
- fi
```
\:::
\::
\::
# Git
## Mac
Mac 通常自带 Git ,但如果没有安装,或者你想更新到最新版本,可以通过以下几种方式安装:
```sh [sh]
brew install git
```
```sh [sh]
xcode-select --install
```
## Windows
通过 Git 官网下载安装包:{rel=""nofollow""}
验证是否安装成功:
```sh [sh]
git --version
```
## 配置 Git 用户信息
Git 需要知道提交者的身份信息。通过以下命令设置全局用户信息:
```sh [sh]
# 设置用户名
git config --global user.name "Your Name"
# 设置邮箱
git config --global user.email "Your Email"
```
## 设置默认编辑器(可选)
```sh [sh]
git config --global core.editor vim
```
```sh [sh]
git config --global core.editor "code --wait"
```
## 配置 SSH 密钥(用于 GitHub、GitLab 等远程仓库)
### 生成 SSH 密钥
```sh [sh]
ssh-keygen -t rsa -b 4096 -C "你的邮箱地址"
```
- `-t rsa`:指定密钥类型为 RSA
- `-b 4096`:指定密钥长度为 4096 位
- `-C "你的邮箱地址"`:指定注释信息为你的邮箱地址,通常是你的 GitHub 邮箱地址
执行命令后,会提示你输入保存密钥的文件路径,按回车键默认保存在 `~/.ssh/id_rsa`。
### 设置密码(可选)
> \[!TIP]
>
> 系统会提示你设置一个密码,这个密码用来加密你的私钥文件。如果你不想设置密码,直接按回车键即可。
### 添加 SSH 密钥到 ssh-agent
```sh [sh]
eval "$(ssh-agent -s)"
ssh-add ~/.ssh/id_rsa
```
> \[!NOTE]
>
> \~/.ssh/id\_rsa
>
> 是你生成的 SSH 密钥的路径。
### 复制 SSH 公钥到剪贴板
```sh [sh]
pbcopy < ~/.ssh/id_rsa.pub
```
或者对于 Linux 系统:
```sh [sh]
cat ~/.ssh/id_rsa.pub | xclip -selection clipboard
```
### 测试 SSH 连接
```sh [sh]
ssh -T git@github.com
```
## 配置 GPG 密钥
### 安装 gnupg 并生成 GPG 密钥
```sh [sh]
brew install gnupg
gpg --full-generate-key
```
- **选择密钥类型**:默认情况下,现在的 GPG 会选择 `ECC and ECC` 。您可以直接按 `Enter` 选择默认选项,生成 `ECC`密钥。
> \[!WARNING]
>
> ECC
>
> 密钥(如
>
> Ed25519
>
> )提供了更高的安全性和更小的密钥尺寸。但在某些旧系统或软件中,可能存在兼容性问题。如果需要最大兼容性,可以选择
>
> RSA and RSA
>
> ,然后将密钥长度设置为
>
> 4096
>
> 位。
- **选择曲线类型**: 如果选择了 `ECC`,系统会提示您选择曲线。默认的 `Curve 25519(Ed25519)`是推荐的选项,直接按 `Enter` 即可。
- **设置密钥的有效期**: 输入 `0` 表示密钥永不过期,或者根据需要设置。
- **用户信息**: 输入您的姓名、邮箱地址(必须与 GitHub 上的邮箱一致)和可选的注释。
- **确认信息**: 检查所有信息是否正确,输入 `O` 确认。
- **设置密码短语**: 为您的密钥设置一个安全的密码短语。
### 查看 GPG 密钥
```sh [sh]
gpg --list-secret-keys --keyid-format LONG
```
> \[!NOTE]
>
> 会看到类似以下的输出:
>
> ```text
> [keyboxd]
> ----------------
> sec ed25519/密钥ID 日期 [SC]
> 密钥指纹
> uid [ultimate] 姓名 <邮箱>
> ssb cv25519/子密钥ID 日期 [E]
> ```
记录下 **ed25519/** 后面的长密钥 ID。例如,`ABCD1234EFGH5678`。
### 导出并复制公钥
```sh [sh]
gpg --armor --export 密钥ID | pbcopy
```
> \[!NOTE]
>
> 复制的内容类似于:
>
> ```text
> -----BEGIN PGP PUBLIC KEY BLOCK-----
> mDMEY...
> ...
> -----END PGP PUBLIC KEY BLOCK-----
> ```
### 配置 Git 使用 GPG 签名
```sh [sh]
git config --global user.signingkey 密钥ID
git config --global commit.gpgsign true
git config --global gpg.program $(which gpg)
git config --global --unset gpg.format
```
### 安装 pinentry-mac
pinentry 程序用于提示您输入 GPG 密钥的密码。
```sh [sh]
brew install pinentry-mac
echo "pinentry-program $(which pinentry-mac)" >> ~/.gnupg/gpg-agent.conf
killall gpg-agent
```
为避免每次提交都输入密码,可以配置 GPG 缓存密码:
```sh [sh]
code ~/.gnupg/gpg-agent.conf
```
添加以下内容,代表把密码缓存 1 小时,最大缓存时间为 2 小时。
```sh [sh]
default-cache-ttl 3600
max-cache-ttl 7200
```
重启代理:
```sh [sh]
killall gpg-agent
```
### 在 VSCode 打开 `"git.enableCommitSigning": true,` 选项。

## 常用命令
| 命令 | 功能说明 |
| -------------------------------------------------- | ------------------------ |
| `git config --global -l` | 查看所有配置 |
| `git config --global user.name` | 查看某个特定的全局配置项 |
| `git rebase --abort` | 取消变基操作 |
| `git branch | grep -v "^\*" | xargs git branch -D` | 删除除当前分支外的所有分支 |
| `git branch | xargs git branch -D` | 删除所有本地分支,包括当前分支 |
| `git fetch --prune` | 从远程仓库获取最新的代码,并删除已经被删除的分支 |
| `git branch -m ` | 重命名本地分支 |
| `git push origin --delete ` | 删除远程分支 |
## 清理远端已删除的本地分支
`git branch | grep -v "^\*" | xargs git branch -D` 会删除**所有**本地分支(除当前分支),包括尚未推送的本地分支,存在误删风险。
更精准的方式是只删除远端已删除(`[gone]`)的分支:
```sh [sh]
git fetch --prune
git branch -vv | grep ': gone]' | awk '{print $1}' | xargs git branch -D
```
`git branch -vv` 会在分支列表中标注远端跟踪状态,远端已删除的分支显示为 `[origin/xxx: gone]`,用 `grep` 精确筛选后再批量删除。
配置为 git alias 更方便:
```sh [sh]
git config --global alias.cleanup '!git fetch --prune && git branch -vv | grep ": gone]" | awk "{print \$1}" | xargs git branch -D'
```
之后直接运行 `git cleanup` 即可。
## 常见问题
```vue
<__flatten>
Q: RPC failed; HTTP 500 curl 22 The requested URL returned error: 500
原因:使用 http 协议进行传输的缓存区太小
git
config
--global
http.postBuffer
524288000
[!TIP]
将缓存区提高到500MB或者更高,看自己的项目需要。
```
# 个人配置
> \[!NOTE]
> See: https\://github.com/mhaibaraai/editors-settings
>
> 集中维护 Claude、Codex 与 VS Code 等开发工具的个性化设置,便于在不同设备间快速同步环境。
> \[!NOTE]
> See: https\://code.claude.com/docs
>
> Anthropic Claude 官方文档
# OpenRouter
## OpenRouter 注册

1. 访问 [OpenRouter](https://openrouter.ai/){rel=""nofollow""} 并注册一个账户。
2. 登录后,右上角头像导航到 "Keys" 页面。
3. 创建一个新的 API 密钥,并将其保存以备后用。
### 查找免费模型
点击 [Models](https://openrouter.ai/models){rel=""nofollow""} 页面,浏览可用的免费模型。

> \[!WARNING]
>
> OpenRouter 上有很多免费模型,需要注意的是这些模型速率限制较低(每天总共 50 次请求),通常不适合用于生产环境。可以选择充值 10 美元,以每天获得 1000 次请求。(免费模型)

### 使用付费模型
点击右上角头像导航到 "Credits" 页面,选择 "Add Credits",可以通过 “支付宝、微信” 付费充值
> \[!WARNING]
>
> 在使用 OpenRouter 购买积分时,会收取 5.5%(最低 0.80 美元)的手续费。不增加任何附加费用,直接转介底层模型提供者的定价,因此支付的费率与直接向提供者支付相同。

# 2025 年度工作总结
## 一、核心工作成果
**AI 大模型应用开发**
- 完成 AI 辅助开发工具链建设,包括自定义 Claude Code 命令、Git 工作流自动化,解决了团队在权限管理和 markdown 语法冲突的实际问题
- [GitLab CI/CD 完全指南](http://wiki.zjsophon.com/pages/viewpage.action?pageId=78908948){rel=""nofollow""}
- [自定义 Github Copilot 聊天响应](http://wiki.zjsophon.com/pages/viewpage.action?pageId=78907798){rel=""nofollow""}
- [在 VScode 使用 Gitlens 生成提交信息](http://wiki.zjsophon.com/pages/viewpage.action?pageId=78907245){rel=""nofollow""}
- 重构前端项目架构(skzz/前端框架),提升了开发体验
- [`zz-platform-template` 的 ESLint 配置](http://wiki.zjsophon.com/pages/viewpage.action?pageId=41553966){rel=""nofollow""}
- AI 智能体的简单实现:(参见 “AI 造价“ 中 ”风口智能体“)
**技术架构优化(空余时间完成)**
- 学习完成服务器基础设施配置(SSL 证书、Docker 部署、数据库集群)等
- 在域名中通过配置 DNS 和 SSL 证书实现安全访问
- 学习 Nuxt 框架项目,搭建部署了 [**@movk/nuxt-docs**](https://docs.mhaibaraai.cn/){rel=""nofollow""} 等项目
- 研究实现了在项目中提供基于 MCP 工具的 AI 聊天界面的模块(后续可以应用到实际开发项目中,节省开发查询时间)
## 二、个人成长与反思
**做得好的方面**
- **技术应用**:使用了不同的 AI 模型辅助完成了多个项目的开发工作(Claude Code 和 GitHub Copilot 等)
- **系统性思维**:不只关注功能实现,开始主动思考架构设计、工具链建设、团队效率提升,建立了开发规范
- **问题解决能力**:面对新技术栈遇到的问题(如 Tailwind v4 兼容、项目环境的部署配置),能够独立排查并形成解决方案
**不足与改进**
- **技术广度有限**:AI 模型应用较多,但对底层算法和原理理解不够深入。**改进计划**:学习深度学习的基础知识,阅读相关论文
- **项目开发管理经验欠缺**:缺乏大型项目的管理经验,导致在时间管理和任务分配上存在不足。**改进计划**:参与更多团队项目,学习项目管理方法
## 三、2026 年度规划
**AI 技术深化**
- **理论基础补齐**:完成深度学习,能独立评估 AI 方案可行性
- **工程能力提升**:基于现有 MCP 模块,实现项目级 AI 助手(代码审查、文档生成、接口用例生成),在 2-3 个项目中落地验证
- **模型应用拓展**:探索多模态模型(图像识别、语音处理)在业务场景中的应用
**技术突破**
- **架构能力**:系统学习微前端和组件库设计
- **技术栈扩展**:深入学习服务端渲染优化,提升全栈能力
**项目管理能力**
- **方法论学习**:建立需求管理、迭代规划的标准流程
- **团队协作**:实践项目管理方法,积累沟通协调经验
## 四、全年总结
2025 年是技术转型的关键一年,从传统前端开发转向 AI 驱动的全栈开发,既有突破也有不足。核心收获是**建立了技术体系思维**,不再局限于单点技术,而是从工具链、架构、效率的全局视角思考问题。
未来方向更清晰:**深耕 AI 工程化方向**,将 AI 能力深度融入开发流程和业务场景,同时补齐前端架构和性能优化的短板,争取在 2026 年成为团队的技术骨干和方案设计者。
# 2026 年上半年述职报告
## 一、核心目标完成情况回顾
上半年工作聚焦三条主线:汇能优算大模型平台的迭代交付、低空项目的原型验证与工程化落地、企业 EHS 综合管控 AI 平台的功能深化。三项工作均围绕「验证方案可行性—打磨工程质量—保障稳定交付」展开,核心目标基本完成。
## 二、重点项目成果
### 汇能优算科技 AI 智慧优算大模型平台软件项目(一期)
- 完成系统缺陷排查与修复,保障一期版本的稳定交付
- 优化标准材料页面的功能与交互,改善页面配置管理体验
### 低空项目
**原型设计与技术方案验证**
- 完成低空项目原型系统的设计与开发,覆盖核心业务流程,为后续需求验证和方案迭代提供可运行的基础版本
- 对多套候选技术方案进行系统性测试与对比,评估可行性、性能表现和集成成本,为技术选型提供依据
**第三方对接**
- 完成与「飞渡」三维可视化平台的对接联调,重点验证低空场景下三维服务数据格式的兼容性与稳定性,打通跨平台集成的关键链路
**大屏问题处理与工程质量保障**
- 排查并修复数据大屏在展示、渲染、数据同步等环节的问题,保障对外演示和验收环境的稳定性
- 把关代码审查和测试环节,降低缺陷流入生产环境的风险
- 完成低空项目相关服务的部署上线,保障运行环境的一致性与可维护性
### 企业 EHS 综合管控 AI 平台
- 完成职业健康培训版本的功能集成,将培训管理模块纳入平台整体架构,形成培训计划、过程、考核的管理闭环
- 完成化工 PPT 自动生成功能的多模版支持开发,覆盖不同场景的排版与内容需求
### 个人技术生态建设(业余时间)
围绕 Nuxt / Vue 生态自研了一套可复用的 movk 系列工程套件,目前已发布:
- [**@movk/nuxt**](https://nuxt.mhaibaraai.cn/){rel=""nofollow""}:构建在 Nuxt UI 之上的 UI 工程套件,提供 Schema 驱动的 AutoForm(Zod v4 校验)、功能完备的 DataTable,以及独立组件与 Composables;在 Nuxt 4 中提供含认证与进度追踪的完整 API 集成能力,UI、表单、表格与主题部分同时支持通过 Vite 插件用于纯 Vue + Vite 项目(API 集成能力仅限 Nuxt)
- [**@movk/mapbox**](https://mapbox.mhaibaraai.cn/){rel=""nofollow""}:声明式 Mapbox GL v3 封装库,提供 MapboxMap / Source / Layer 等组件与 composables,原生支持 Nuxt 4 模块,并可通过 Vite 插件用于纯 Vue + Vite 项目;内置 3D 建筑、雷达 / 扩散 / 辉光等动态效果,fog / terrain / 天气环境,绘制(mapbox-gl-draw)、天地图、WMS/WMTS 与多坐标系本地化支持
- [**movk-dashboard**](https://dashboard.mhaibaraai.cn/){rel=""nofollow""}:基于 Nuxt 4 + @movk/nuxt + @nuxt/ui v4 搭建的权限管理后台,验证了套件在真实后台场景下的可用性
在此基础上,正在推进 [**movk-studio**](https://github.com/mhaibaraai/movk-studio){rel=""nofollow""} —— 一个用自然语言驱动地图 / 表单 / 数据模块的 AI Copilot 工作台,目前已完成交互原型与系统设计文档(需求设计、技术架构、任务拆解)。
## 三、不足与改进
- **多项目并行的时间分配**:低空项目、EHS 平台、汇能优算三条线并行推进,任务切换成本较高,部分工作存在优先级判断不够清晰的情况。
- **跨平台对接的协调经验**:与「飞渡」等第三方平台对接过程中,在联调排期和问题定位上仍有磨合空间。
- **公司 AI 基础设施的复用意识不足**:低空项目、EHS 平台的模型与工具能力建设上半年更多依赖自建方案,对公司自研「九思」平台已具备的模型网关、知识库、MCP 工具服务等能力关注和复用不够,存在重复造轮子的风险。
## 四、下半年规划
**AI 工程化能力建设**
- 系统梳理上半年在低空项目、EHS 平台、汇能优算项目中积累的工程实践,沉淀可复用的方法与工具
- 推动 AI 能力在数据治理、报告生成、方案验证等环节的进一步落地,提升开发与交付效率
**探索与「九思」平台结合**
公司自研的「九思」智能体平台已具备大模型网关、知识库(向量库 + 图数据库)、MCP 工具服务(含 MCP 地图服务)等私有化部署能力,为业务项目提供了统一的 AI 基础设施。针对上述不足,计划从以下方向探索结合,减少各项目重复建设的成本:
- **MCP 工具生态对接**:项目涉及的地图能力可探索直接复用九思已提供的 MCP 地图服务,验证能否替代或补齐自建地图工具链,降低开发投入
- **Copilot 交互范式迁移**:将 Frontend Tools、Generative UI、HITL 等已验证的人机交互范式应用到接入九思的业务前端,形成「九思提供模型 / 知识库 / 工具能力,业务前端提供自然语言操作体验」的分层协作模式
- **接入标准沉淀**:选择 1 个试点项目验证九思 MCP 协议和模型网关的对接方式,输出可复用的接入 SOP,为后续项目减少重复摸索成本
## 五、总结
上半年围绕低空项目、企业 EHS 综合管控 AI 平台、汇能优算大模型平台三项工作展开,核心目标基本完成,在方案验证、第三方对接、工程质量保障等方面积累了具体经验;同时也发现对公司现有 AI 基础设施的复用意识不足。下半年将以 AI 工程化能力建设为重点,推进各项目深化落地,并探索与公司「九思」平台的深度结合,进一步提升技术方案的稳定性和交付效率。
# YiXuan - 开发随笔
::u-page-hero
---
ui:
container: lg:py-20
class: dark:bg-gradient-to-b from-neutral-900 to-neutral-950
orientation: horizontal
---
:::motion
---
transition:
duration: 0.6
delay: 0.1
---
:nuxt-img{.rounded-lg.shadow-2xl.ring.ring-default.mx-auto alt="Illustration" src="https://mhaibaraai.cn/i-llustration.png" width="400"}
:::
#top
:hero-background
#title
:::motion
👋 开发随笔
:::
#description
:::motion
---
transition:
duration: 0.6
delay: 0.3
---
从代码片段到架构思考,这里是我在成为更优秀全栈工程师路上的所有笔记。
:::
#links
:::motion
---
transition:
duration: 0.6
delay: 0.5
class: flex flex-wrap gap-x-6 gap-y-3
---
[](https://mhaibaraai.cn/docs)
[](https://github.com/mhaibaraai/mhaibaraai.cn)
:::
::
:page-section{.dark:bg-neutral-950}