[{"data":1,"prerenderedAt":13951},["ShallowReactive",2],{"post-go-embed-frontend":3,"series-source-go-embed-frontend":1104},{"id":4,"title":5,"author":6,"body":7,"description":1086,"difficulty":1087,"draft":1088,"extension":1089,"image":1090,"meta":1091,"navigation":82,"path":1092,"pinned":1088,"published":1093,"seo":1094,"series":1095,"seriesOrder":68,"stem":1096,"tags":1097,"updated":1102,"__hash__":1103},"posts\u002Fposts\u002Fgo-embed-frontend\u002Findex.md","Go 单文件打包实战：把前端塞进二进制",null,{"type":8,"value":9,"toc":1069},"minimark",[10,14,23,26,29,40,43,48,458,462,573,577,718,721,725,734,752,763,783,797,803,811,919,925,929,936,945,948,986,989,994,1039,1042,1048,1062,1065],[11,12,13],"h2",{"id":13},"背景",[15,16,17,18,22],"p",{},"一个 Gin + Vue 3 的全栈项目，之前的发布方式是：一个 Go 二进制 + 一个 ",[19,20,21],"code",{},"web\u002F"," 前端静态目录，必须放一起才能运行。",[15,24,25],{},"为了让部署更丝滑——一个 exe 拖哪都能跑——决定把前端构建产物嵌入到 Go 二进制里。",[11,27,28],{"id":28},"核心思路",[15,30,31,32,35,36,39],{},"利用 Go 1.16 引入的 ",[19,33,34],{},"\u002F\u002Fgo:embed"," 指令，在编译时将前端 ",[19,37,38],{},"dist\u002F"," 目录嵌入二进制，运行时通过嵌入的文件系统提供 SPA 服务。",[11,41,42],{"id":42},"逐步实现",[44,45,47],"h3",{"id":46},"_1-创建嵌入模块","1. 创建嵌入模块",[49,50,55],"pre",{"className":51,"code":52,"language":53,"meta":54,"style":54},"language-go shiki shiki-themes github-light github-dark","\u002F\u002F service\u002Fweb\u002Fembed.go\npackage web\n\nimport (\n    \"embed\"\n    \"io\u002Ffs\"\n    \"mime\"\n    \"net\u002Fhttp\"\n    \"path\u002Ffilepath\"\n    \"strings\"\n\n    \"github.com\u002Fgin-gonic\u002Fgin\"\n)\n\n\u002F\u002Fgo:embed dist\nvar embedFS embed.FS\n\nfunc SPAHandler() gin.HandlerFunc {\n    sub, _ := fs.Sub(embedFS, \"dist\")\n\n    return func(c *gin.Context) {\n        p := strings.TrimPrefix(c.Request.URL.Path, \"\u002F\")\n        if p == \"\" {\n            p = \"index.html\"\n        }\n\n        data, err := fs.ReadFile(sub, p)\n        if err != nil {\n            \u002F\u002F SPA fallback：文件不存在则返回 index.html\n            data, _ = fs.ReadFile(sub, \"index.html\")\n        }\n\n        c.Data(http.StatusOK, mime.TypeByExtension(filepath.Ext(p)), data)\n    }\n}\n","go","",[19,56,57,66,77,84,94,107,117,127,137,147,157,162,172,178,183,189,206,211,234,257,262,291,313,330,342,348,353,369,386,392,412,417,422,446,452],{"__ignoreMap":54},[58,59,62],"span",{"class":60,"line":61},"line",1,[58,63,65],{"class":64},"sHbNN","\u002F\u002F service\u002Fweb\u002Fembed.go\n",[58,67,69,73],{"class":60,"line":68},2,[58,70,72],{"class":71},"siTax","package",[58,74,76],{"class":75},"sw2iP"," web\n",[58,78,80],{"class":60,"line":79},3,[58,81,83],{"emptyLinePlaceholder":82},true,"\n",[58,85,87,90],{"class":60,"line":86},4,[58,88,89],{"class":71},"import",[58,91,93],{"class":92},"sIX_F"," (\n",[58,95,97,101,104],{"class":60,"line":96},5,[58,98,100],{"class":99},"scXbn","    \"",[58,102,103],{"class":75},"embed",[58,105,106],{"class":99},"\"\n",[58,108,110,112,115],{"class":60,"line":109},6,[58,111,100],{"class":99},[58,113,114],{"class":75},"io\u002Ffs",[58,116,106],{"class":99},[58,118,120,122,125],{"class":60,"line":119},7,[58,121,100],{"class":99},[58,123,124],{"class":75},"mime",[58,126,106],{"class":99},[58,128,130,132,135],{"class":60,"line":129},8,[58,131,100],{"class":99},[58,133,134],{"class":75},"net\u002Fhttp",[58,136,106],{"class":99},[58,138,140,142,145],{"class":60,"line":139},9,[58,141,100],{"class":99},[58,143,144],{"class":75},"path\u002Ffilepath",[58,146,106],{"class":99},[58,148,150,152,155],{"class":60,"line":149},10,[58,151,100],{"class":99},[58,153,154],{"class":75},"strings",[58,156,106],{"class":99},[58,158,160],{"class":60,"line":159},11,[58,161,83],{"emptyLinePlaceholder":82},[58,163,165,167,170],{"class":60,"line":164},12,[58,166,100],{"class":99},[58,168,169],{"class":75},"github.com\u002Fgin-gonic\u002Fgin",[58,171,106],{"class":99},[58,173,175],{"class":60,"line":174},13,[58,176,177],{"class":92},")\n",[58,179,181],{"class":60,"line":180},14,[58,182,83],{"emptyLinePlaceholder":82},[58,184,186],{"class":60,"line":185},15,[58,187,188],{"class":64},"\u002F\u002Fgo:embed dist\n",[58,190,192,195,198,200,203],{"class":60,"line":191},16,[58,193,194],{"class":71},"var",[58,196,197],{"class":92}," embedFS ",[58,199,103],{"class":75},[58,201,202],{"class":92},".",[58,204,205],{"class":75},"FS\n",[58,207,209],{"class":60,"line":208},17,[58,210,83],{"emptyLinePlaceholder":82},[58,212,214,217,220,223,226,228,231],{"class":60,"line":213},18,[58,215,216],{"class":71},"func",[58,218,219],{"class":75}," SPAHandler",[58,221,222],{"class":92},"() ",[58,224,225],{"class":75},"gin",[58,227,202],{"class":92},[58,229,230],{"class":75},"HandlerFunc",[58,232,233],{"class":92}," {\n",[58,235,237,240,243,246,249,252,255],{"class":60,"line":236},19,[58,238,239],{"class":92},"    sub, _ ",[58,241,242],{"class":71},":=",[58,244,245],{"class":92}," fs.",[58,247,248],{"class":75},"Sub",[58,250,251],{"class":92},"(embedFS, ",[58,253,254],{"class":99},"\"dist\"",[58,256,177],{"class":92},[58,258,260],{"class":60,"line":259},20,[58,261,83],{"emptyLinePlaceholder":82},[58,263,265,268,271,274,278,281,283,285,288],{"class":60,"line":264},21,[58,266,267],{"class":71},"    return",[58,269,270],{"class":71}," func",[58,272,273],{"class":92},"(",[58,275,277],{"class":276},"sPK6S","c",[58,279,280],{"class":71}," *",[58,282,225],{"class":75},[58,284,202],{"class":92},[58,286,287],{"class":75},"Context",[58,289,290],{"class":92},") {\n",[58,292,294,297,299,302,305,308,311],{"class":60,"line":293},22,[58,295,296],{"class":92},"        p ",[58,298,242],{"class":71},[58,300,301],{"class":92}," strings.",[58,303,304],{"class":75},"TrimPrefix",[58,306,307],{"class":92},"(c.Request.URL.Path, ",[58,309,310],{"class":99},"\"\u002F\"",[58,312,177],{"class":92},[58,314,316,319,322,325,328],{"class":60,"line":315},23,[58,317,318],{"class":71},"        if",[58,320,321],{"class":92}," p ",[58,323,324],{"class":71},"==",[58,326,327],{"class":99}," \"\"",[58,329,233],{"class":92},[58,331,333,336,339],{"class":60,"line":332},24,[58,334,335],{"class":92},"            p ",[58,337,338],{"class":71},"=",[58,340,341],{"class":99}," \"index.html\"\n",[58,343,345],{"class":60,"line":344},25,[58,346,347],{"class":92},"        }\n",[58,349,351],{"class":60,"line":350},26,[58,352,83],{"emptyLinePlaceholder":82},[58,354,356,359,361,363,366],{"class":60,"line":355},27,[58,357,358],{"class":92},"        data, err ",[58,360,242],{"class":71},[58,362,245],{"class":92},[58,364,365],{"class":75},"ReadFile",[58,367,368],{"class":92},"(sub, p)\n",[58,370,372,374,377,380,384],{"class":60,"line":371},28,[58,373,318],{"class":71},[58,375,376],{"class":92}," err ",[58,378,379],{"class":71},"!=",[58,381,383],{"class":382},"suQ91"," nil",[58,385,233],{"class":92},[58,387,389],{"class":60,"line":388},29,[58,390,391],{"class":64},"            \u002F\u002F SPA fallback：文件不存在则返回 index.html\n",[58,393,395,398,400,402,404,407,410],{"class":60,"line":394},30,[58,396,397],{"class":92},"            data, _ ",[58,399,338],{"class":71},[58,401,245],{"class":92},[58,403,365],{"class":75},[58,405,406],{"class":92},"(sub, ",[58,408,409],{"class":99},"\"index.html\"",[58,411,177],{"class":92},[58,413,415],{"class":60,"line":414},31,[58,416,347],{"class":92},[58,418,420],{"class":60,"line":419},32,[58,421,83],{"emptyLinePlaceholder":82},[58,423,425,428,431,434,437,440,443],{"class":60,"line":424},33,[58,426,427],{"class":92},"        c.",[58,429,430],{"class":75},"Data",[58,432,433],{"class":92},"(http.StatusOK, mime.",[58,435,436],{"class":75},"TypeByExtension",[58,438,439],{"class":92},"(filepath.",[58,441,442],{"class":75},"Ext",[58,444,445],{"class":92},"(p)), data)\n",[58,447,449],{"class":60,"line":448},34,[58,450,451],{"class":92},"    }\n",[58,453,455],{"class":60,"line":454},35,[58,456,457],{"class":92},"}\n",[44,459,461],{"id":460},"_2-改造路由","2. 改造路由",[49,463,465],{"className":51,"code":464,"language":53,"meta":54,"style":54},"\u002F\u002F router.go\nimport \"myproject\u002Fweb\"\n\n\u002F\u002F 替换原来的磁盘静态文件服务\nwebHandler := web.SPAHandler()\nrouter.GET(\"\u002F\", webHandler)\nrouter.GET(\"\u002Fassets\u002F*filepath\", webHandler)\nrouter.GET(\"\u002Ffavicon.ico\", webHandler)\nrouter.GET(\"\u002Ffavicon.svg\", webHandler)\nrouter.NoRoute(webHandler)\n",[19,466,467,472,484,488,493,509,524,537,550,563],{"__ignoreMap":54},[58,468,469],{"class":60,"line":61},[58,470,471],{"class":64},"\u002F\u002F router.go\n",[58,473,474,476,479,482],{"class":60,"line":68},[58,475,89],{"class":71},[58,477,478],{"class":99}," \"",[58,480,481],{"class":75},"myproject\u002Fweb",[58,483,106],{"class":99},[58,485,486],{"class":60,"line":79},[58,487,83],{"emptyLinePlaceholder":82},[58,489,490],{"class":60,"line":86},[58,491,492],{"class":64},"\u002F\u002F 替换原来的磁盘静态文件服务\n",[58,494,495,498,500,503,506],{"class":60,"line":96},[58,496,497],{"class":92},"webHandler ",[58,499,242],{"class":71},[58,501,502],{"class":92}," web.",[58,504,505],{"class":75},"SPAHandler",[58,507,508],{"class":92},"()\n",[58,510,511,514,517,519,521],{"class":60,"line":109},[58,512,513],{"class":92},"router.",[58,515,516],{"class":75},"GET",[58,518,273],{"class":92},[58,520,310],{"class":99},[58,522,523],{"class":92},", webHandler)\n",[58,525,526,528,530,532,535],{"class":60,"line":119},[58,527,513],{"class":92},[58,529,516],{"class":75},[58,531,273],{"class":92},[58,533,534],{"class":99},"\"\u002Fassets\u002F*filepath\"",[58,536,523],{"class":92},[58,538,539,541,543,545,548],{"class":60,"line":129},[58,540,513],{"class":92},[58,542,516],{"class":75},[58,544,273],{"class":92},[58,546,547],{"class":99},"\"\u002Ffavicon.ico\"",[58,549,523],{"class":92},[58,551,552,554,556,558,561],{"class":60,"line":139},[58,553,513],{"class":92},[58,555,516],{"class":75},[58,557,273],{"class":92},[58,559,560],{"class":99},"\"\u002Ffavicon.svg\"",[58,562,523],{"class":92},[58,564,565,567,570],{"class":60,"line":149},[58,566,513],{"class":92},[58,568,569],{"class":75},"NoRoute",[58,571,572],{"class":92},"(webHandler)\n",[44,574,576],{"id":575},"_3-一键构建脚本","3. 一键构建脚本",[49,578,582],{"className":579,"code":580,"language":581,"meta":54,"style":54},"language-bash shiki shiki-themes github-light github-dark","#!\u002Fbin\u002Fbash\n# build-standalone.sh\n\nset -e\n\n# 1. 构建前端\npnpm install --no-frozen-lockfile\nnpx vite build\n\n# 2. 复制到嵌入目录\nrm -rf service\u002Fweb\u002Fdist\ncp -r dist service\u002Fweb\u002Fdist\n\n# 3. 编译 Go 二进制\ncd service\ngo build -o myapp.exe \\\n    -ldflags=\"-X myproject\u002Fglobal.RUNCODE=release\" \\\n    main.go\n","bash",[19,583,584,589,594,598,606,610,615,626,637,641,646,657,670,674,679,687,703,713],{"__ignoreMap":54},[58,585,586],{"class":60,"line":61},[58,587,588],{"class":64},"#!\u002Fbin\u002Fbash\n",[58,590,591],{"class":60,"line":68},[58,592,593],{"class":64},"# build-standalone.sh\n",[58,595,596],{"class":60,"line":79},[58,597,83],{"emptyLinePlaceholder":82},[58,599,600,603],{"class":60,"line":86},[58,601,602],{"class":382},"set",[58,604,605],{"class":382}," -e\n",[58,607,608],{"class":60,"line":96},[58,609,83],{"emptyLinePlaceholder":82},[58,611,612],{"class":60,"line":109},[58,613,614],{"class":64},"# 1. 构建前端\n",[58,616,617,620,623],{"class":60,"line":119},[58,618,619],{"class":75},"pnpm",[58,621,622],{"class":99}," install",[58,624,625],{"class":382}," --no-frozen-lockfile\n",[58,627,628,631,634],{"class":60,"line":129},[58,629,630],{"class":75},"npx",[58,632,633],{"class":99}," vite",[58,635,636],{"class":99}," build\n",[58,638,639],{"class":60,"line":139},[58,640,83],{"emptyLinePlaceholder":82},[58,642,643],{"class":60,"line":149},[58,644,645],{"class":64},"# 2. 复制到嵌入目录\n",[58,647,648,651,654],{"class":60,"line":159},[58,649,650],{"class":75},"rm",[58,652,653],{"class":382}," -rf",[58,655,656],{"class":99}," service\u002Fweb\u002Fdist\n",[58,658,659,662,665,668],{"class":60,"line":164},[58,660,661],{"class":75},"cp",[58,663,664],{"class":382}," -r",[58,666,667],{"class":99}," dist",[58,669,656],{"class":99},[58,671,672],{"class":60,"line":174},[58,673,83],{"emptyLinePlaceholder":82},[58,675,676],{"class":60,"line":180},[58,677,678],{"class":64},"# 3. 编译 Go 二进制\n",[58,680,681,684],{"class":60,"line":185},[58,682,683],{"class":382},"cd",[58,685,686],{"class":99}," service\n",[58,688,689,691,694,697,700],{"class":60,"line":191},[58,690,53],{"class":75},[58,692,693],{"class":99}," build",[58,695,696],{"class":382}," -o",[58,698,699],{"class":99}," myapp.exe",[58,701,702],{"class":382}," \\\n",[58,704,705,708,711],{"class":60,"line":208},[58,706,707],{"class":382},"    -ldflags=",[58,709,710],{"class":99},"\"-X myproject\u002Fglobal.RUNCODE=release\"",[58,712,702],{"class":382},[58,714,715],{"class":60,"line":213},[58,716,717],{"class":99},"    main.go\n",[11,719,720],{"id":720},"踩坑记录",[44,722,724],{"id":723},"坑一路由不匹配静态资源-404","坑一：路由不匹配，静态资源 404",[15,726,727,728,730,731,733],{},"首次实现时用了 Gin 的 ",[19,729,569],{}," 兜底，但部分静态资源返回 404。排查发现 ",[19,732,569],{}," 在某些路由未显式注册时不会按预期触发。",[15,735,736,740,741,744,745,748,749,751],{},[737,738,739],"strong",{},"解决","：显式注册 ",[19,742,743],{},"\u002Fassets\u002F*filepath","、",[19,746,747],{},"\u002Ffavicon.ico"," 等路由，",[19,750,569],{}," 仅作为兜底。",[44,753,755,756,758,759,762],{"id":754},"坑二goembed-排除-_-开头文件","坑二：",[19,757,34],{}," 排除 ",[19,760,761],{},"_"," 开头文件",[15,764,765,766,769,770,772,773,782],{},"这是最坑的。Vite 构建产出了一个 ",[19,767,768],{},"_plugin-vue_export-helper-xxx.js","，Go 的 ",[19,771,34],{}," 当模式为目录时，",[737,774,775,776,778,779,781],{},"硬编码排除了 ",[19,777,761],{}," 和 ",[19,780,202],{}," 开头的文件","。",[49,784,786],{"className":51,"code":785,"language":53,"meta":54,"style":54},"\u002F\u002F 这行会跳过 dist\u002Fassets\u002F_plugin-xxx.js\n\u002F\u002Fgo:embed dist\n",[19,787,788,793],{"__ignoreMap":54},[58,789,790],{"class":60,"line":61},[58,791,792],{"class":64},"\u002F\u002F 这行会跳过 dist\u002Fassets\u002F_plugin-xxx.js\n",[58,794,795],{"class":60,"line":68},[58,796,188],{"class":64},[15,798,799,800,782],{},"错误表现：JS 文件返回的是 HTML 内容（SPA fallback），浏览器报 ",[19,801,802],{},"Unexpected token '\u003C'",[15,804,805,807,808,810],{},[737,806,739],{},"：改 Vite 配置，去除了 ",[19,809,761],{}," 前缀：",[49,812,816],{"className":813,"code":814,"language":815,"meta":54,"style":54},"language-ts shiki shiki-themes github-light github-dark","\u002F\u002F vite.config.ts\nbuild: {\n  rollupOptions: {\n    output: {\n      chunkFileNames(chunkInfo) {\n        return `assets\u002F${chunkInfo.name.replace(\u002F^_\u002F, '')}-[hash].js`\n      },\n    },\n  },\n},\n","ts",[19,817,818,823,831,838,845,853,899,904,909,914],{"__ignoreMap":54},[58,819,820],{"class":60,"line":61},[58,821,822],{"class":64},"\u002F\u002F vite.config.ts\n",[58,824,825,828],{"class":60,"line":68},[58,826,827],{"class":75},"build",[58,829,830],{"class":92},": {\n",[58,832,833,836],{"class":60,"line":79},[58,834,835],{"class":75},"  rollupOptions",[58,837,830],{"class":92},[58,839,840,843],{"class":60,"line":86},[58,841,842],{"class":75},"    output",[58,844,830],{"class":92},[58,846,847,850],{"class":60,"line":96},[58,848,849],{"class":75},"      chunkFileNames",[58,851,852],{"class":92},"(chunkInfo) {\n",[58,854,855,858,861,864,866,869,871,874,876,879,882,885,887,890,893,896],{"class":60,"line":109},[58,856,857],{"class":71},"        return",[58,859,860],{"class":99}," `assets\u002F${",[58,862,863],{"class":92},"chunkInfo",[58,865,202],{"class":99},[58,867,868],{"class":92},"name",[58,870,202],{"class":99},[58,872,873],{"class":75},"replace",[58,875,273],{"class":99},[58,877,878],{"class":99},"\u002F",[58,880,881],{"class":71},"^",[58,883,761],{"class":884},"sPKmS",[58,886,878],{"class":99},[58,888,889],{"class":99},", ",[58,891,892],{"class":99},"''",[58,894,895],{"class":99},")",[58,897,898],{"class":99},"}-[hash].js`\n",[58,900,901],{"class":60,"line":119},[58,902,903],{"class":92},"      },\n",[58,905,906],{"class":60,"line":129},[58,907,908],{"class":92},"    },\n",[58,910,911],{"class":60,"line":139},[58,912,913],{"class":92},"  },\n",[58,915,916],{"class":60,"line":149},[58,917,918],{"class":92},"},\n",[15,920,921,922,924],{},"这样就避免产生 ",[19,923,761],{}," 前缀文件名的同时，保持构建产物的一致性和引用关系的正确性。",[44,926,928],{"id":927},"坑三构建缓存导致-embed-内容未更新","坑三：构建缓存导致 embed 内容未更新",[15,930,931,932,935],{},"多次 ",[19,933,934],{},"go build"," 后，Go 的构建缓存可能使用了旧版本的 embed 内容，导致实际运行的文件不是最新的。",[15,937,938,940,941,944],{},[737,939,739],{},"：使用 ",[19,942,943],{},"go build -a"," 强制重新编译所有包。",[11,946,947],{"id":947},"最终效果",[49,949,951],{"className":579,"code":950,"language":581,"meta":54,"style":54},"$ bash build-standalone.sh\n=== 构建成功 ===\n输出: service\u002Fmyapp.exe  41M\n",[19,952,953,964,975],{"__ignoreMap":54},[58,954,955,958,961],{"class":60,"line":61},[58,956,957],{"class":75},"$",[58,959,960],{"class":99}," bash",[58,962,963],{"class":99}," build-standalone.sh\n",[58,965,966,969,972],{"class":60,"line":68},[58,967,968],{"class":99},"===",[58,970,971],{"class":99}," 构建成功",[58,973,974],{"class":99}," ===\n",[58,976,977,980,983],{"class":60,"line":79},[58,978,979],{"class":75},"输出:",[58,981,982],{"class":99}," service\u002Fmyapp.exe",[58,984,985],{"class":99},"  41M\n",[15,987,988],{},"一个 41MB 的 exe，内置前端页面、API 后端、SQLite 数据库（运行时初始化），拖到哪都能跑。",[15,990,991],{},[737,992,993],{},"部署对比：",[995,996,997,1013],"table",{},[998,999,1000],"thead",{},[1001,1002,1003,1007,1010],"tr",{},[1004,1005,1006],"th",{},"方式",[1004,1008,1009],{},"文件数",[1004,1011,1012],{},"操作",[1014,1015,1016,1028],"tbody",{},[1001,1017,1018,1022,1025],{},[1019,1020,1021],"td",{},"旧方案",[1019,1023,1024],{},"1 exe + 1 web 目录",[1019,1026,1027],{},"解压→保持目录结构",[1001,1029,1030,1033,1036],{},[1019,1031,1032],{},"新方案",[1019,1034,1035],{},"1 个 exe",[1019,1037,1038],{},"直接扔到服务器运行",[11,1040,1041],{"id":1041},"总结",[15,1043,1044,1045,1047],{},"Go 的 ",[19,1046,34],{}," 是一个强大的工具，但有两个隐藏行为需要注意：",[1049,1050,1051,1059],"ol",{},[1052,1053,1054,1055,778,1057,781],"li",{},"嵌入目录时自动排除 ",[19,1056,761],{},[19,1058,202],{},[1052,1060,1061],{},"构建缓存可能导致 embed 不是最新的",[15,1063,1064],{},"对于前端+后端合体的场景，这是性价比最高的方案——不需要额外的打包工具，纯标准库搞定。",[1066,1067,1068],"style",{},"html pre.shiki code .sHbNN, html code.shiki .sHbNN{--shiki-light:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .suQ91, html code.shiki .suQ91{--shiki-light:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sw2iP, html code.shiki .sw2iP{--shiki-light:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .scXbn, html code.shiki .scXbn{--shiki-light:#032F62;--shiki-dark:#9ECBFF}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .siTax, html code.shiki .siTax{--shiki-light:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sIX_F, html code.shiki .sIX_F{--shiki-light:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sPK6S, html code.shiki .sPK6S{--shiki-light:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sPKmS, html code.shiki .sPKmS{--shiki-light:#032F62;--shiki-dark:#DBEDFF}",{"title":54,"searchDepth":68,"depth":79,"links":1070},[1071,1072,1073,1078,1084,1085],{"id":13,"depth":68,"text":13},{"id":28,"depth":68,"text":28},{"id":42,"depth":68,"text":42,"children":1074},[1075,1076,1077],{"id":46,"depth":79,"text":47},{"id":460,"depth":79,"text":461},{"id":575,"depth":79,"text":576},{"id":720,"depth":68,"text":720,"children":1079},[1080,1081,1083],{"id":723,"depth":79,"text":724},{"id":754,"depth":79,"text":1082},"坑二：\u002F\u002Fgo:embed 排除 _ 开头文件",{"id":927,"depth":79,"text":928},{"id":947,"depth":68,"text":947},{"id":1041,"depth":68,"text":1041},"记录将 Vue 3 前端嵌入 Go 二进制实现单 exe 发布的完整流程，以及遇到的坑和解决方案。","进阶",false,"md","\u002Fposts\u002Fgo-embed-frontend\u002Fimg\u002Fcover.jpg",{},"\u002Fposts\u002Fgo-embed-frontend","2026-06-06T16:00:00",{"title":5,"description":1086},"Go 工程实践","posts\u002Fgo-embed-frontend\u002Findex",[1098,1099,1100,1101],"Go","Vue","编译","埋坑","2026-06-06","SIQNL24wVAFb18wIGWSB3lNVZUQ9B76jWgQ55TASTYk",[1105,1899,2309,2532,2948,4122,4400,6443,7225,8007,9416,9976,11155,12691],{"id":1106,"title":1107,"author":6,"body":1108,"description":1887,"difficulty":1087,"draft":1088,"extension":1089,"image":1888,"meta":1889,"navigation":82,"path":1890,"pinned":1088,"published":1891,"seo":1892,"series":1893,"seriesOrder":61,"stem":1894,"tags":1895,"updated":1891,"__hash__":1898},"posts\u002Fposts\u002Fdocker-musl-build\u002Findex.md","Docker 编译 musl 可执行文件",{"type":8,"value":1109,"toc":1876},[1110,1113,1116,1119,1336,1339,1381,1384,1402,1405,1553,1556,1692,1695,1698,1756,1759,1873],[11,1111,1112],{"id":1112},"解决问题",[15,1114,1115],{},"用 Go 进行跨平台编译时往往步骤复杂，用 Docker 可以轻松解决。但 Docker 的当前最小镜像 Alpine 其动态链接库采用 musl，与主流的 glibc 不同，往往不能方便执行。",[11,1117,1118],{"id":1118},"查阅资料",[49,1120,1122],{"className":579,"code":1121,"language":581,"meta":54,"style":54},"# 启动容器并挂载当前目录到 `\u002Fapp`\ndocker run -it --rm -v \"$PWD\":\u002Fapp -w \u002Fapp ubuntu:20.04 bash\n\n# 在容器内执行以下操作：\napt-get update && apt-get install -y musl-tools golang-go  # 安装 musl 和 Go\nexport CC=musl-gcc  # 指定 musl 编译器\nexport CGO_ENABLED=1  # 启用 CGO\n# 设置代理\ngo env -w GOPROXY=https:\u002F\u002Fgoproxy.cn,direct\ngo build -ldflags '-linkmode external -extldflags \"-static\"' -o myapp  # 静态编译\nexit  # 退出容器\n\n# 验证结果（在宿主机执行）\nfile myapp  # 应显示 \"statically linked\"\nldd myapp   # 应显示 \"not a dynamic executable\"\n\nwget https:\u002F\u002Fgo.dev\u002Fdl\u002Fgo1.21.linux-amd64.tar.gz\n\nENV PATH=\"\u002Fdata\u002Fgo\u002Fbin:$PATH\"\n",[19,1123,1124,1129,1166,1170,1175,1202,1218,1233,1238,1250,1270,1278,1282,1287,1297,1307,1311,1319,1323],{"__ignoreMap":54},[58,1125,1126],{"class":60,"line":61},[58,1127,1128],{"class":64},"# 启动容器并挂载当前目录到 `\u002Fapp`\n",[58,1130,1131,1134,1137,1140,1143,1146,1148,1151,1154,1157,1160,1163],{"class":60,"line":68},[58,1132,1133],{"class":75},"docker",[58,1135,1136],{"class":99}," run",[58,1138,1139],{"class":382}," -it",[58,1141,1142],{"class":382}," --rm",[58,1144,1145],{"class":382}," -v",[58,1147,478],{"class":99},[58,1149,1150],{"class":92},"$PWD",[58,1152,1153],{"class":99},"\":\u002Fapp",[58,1155,1156],{"class":382}," -w",[58,1158,1159],{"class":99}," \u002Fapp",[58,1161,1162],{"class":99}," ubuntu:20.04",[58,1164,1165],{"class":99}," bash\n",[58,1167,1168],{"class":60,"line":79},[58,1169,83],{"emptyLinePlaceholder":82},[58,1171,1172],{"class":60,"line":86},[58,1173,1174],{"class":64},"# 在容器内执行以下操作：\n",[58,1176,1177,1180,1183,1186,1188,1190,1193,1196,1199],{"class":60,"line":96},[58,1178,1179],{"class":75},"apt-get",[58,1181,1182],{"class":99}," update",[58,1184,1185],{"class":92}," && ",[58,1187,1179],{"class":75},[58,1189,622],{"class":99},[58,1191,1192],{"class":382}," -y",[58,1194,1195],{"class":99}," musl-tools",[58,1197,1198],{"class":99}," golang-go",[58,1200,1201],{"class":64},"  # 安装 musl 和 Go\n",[58,1203,1204,1207,1210,1212,1215],{"class":60,"line":109},[58,1205,1206],{"class":71},"export",[58,1208,1209],{"class":92}," CC",[58,1211,338],{"class":71},[58,1213,1214],{"class":92},"musl-gcc  ",[58,1216,1217],{"class":64},"# 指定 musl 编译器\n",[58,1219,1220,1222,1225,1227,1230],{"class":60,"line":119},[58,1221,1206],{"class":71},[58,1223,1224],{"class":92}," CGO_ENABLED",[58,1226,338],{"class":71},[58,1228,1229],{"class":382},"1",[58,1231,1232],{"class":64},"  # 启用 CGO\n",[58,1234,1235],{"class":60,"line":129},[58,1236,1237],{"class":64},"# 设置代理\n",[58,1239,1240,1242,1245,1247],{"class":60,"line":139},[58,1241,53],{"class":75},[58,1243,1244],{"class":99}," env",[58,1246,1156],{"class":382},[58,1248,1249],{"class":99}," GOPROXY=https:\u002F\u002Fgoproxy.cn,direct\n",[58,1251,1252,1254,1256,1259,1262,1264,1267],{"class":60,"line":149},[58,1253,53],{"class":75},[58,1255,693],{"class":99},[58,1257,1258],{"class":382}," -ldflags",[58,1260,1261],{"class":99}," '-linkmode external -extldflags \"-static\"'",[58,1263,696],{"class":382},[58,1265,1266],{"class":99}," myapp",[58,1268,1269],{"class":64},"  # 静态编译\n",[58,1271,1272,1275],{"class":60,"line":159},[58,1273,1274],{"class":382},"exit",[58,1276,1277],{"class":64},"  # 退出容器\n",[58,1279,1280],{"class":60,"line":164},[58,1281,83],{"emptyLinePlaceholder":82},[58,1283,1284],{"class":60,"line":174},[58,1285,1286],{"class":64},"# 验证结果（在宿主机执行）\n",[58,1288,1289,1292,1294],{"class":60,"line":180},[58,1290,1291],{"class":75},"file",[58,1293,1266],{"class":99},[58,1295,1296],{"class":64},"  # 应显示 \"statically linked\"\n",[58,1298,1299,1302,1304],{"class":60,"line":185},[58,1300,1301],{"class":75},"ldd",[58,1303,1266],{"class":99},[58,1305,1306],{"class":64},"   # 应显示 \"not a dynamic executable\"\n",[58,1308,1309],{"class":60,"line":191},[58,1310,83],{"emptyLinePlaceholder":82},[58,1312,1313,1316],{"class":60,"line":208},[58,1314,1315],{"class":75},"wget",[58,1317,1318],{"class":99}," https:\u002F\u002Fgo.dev\u002Fdl\u002Fgo1.21.linux-amd64.tar.gz\n",[58,1320,1321],{"class":60,"line":213},[58,1322,83],{"emptyLinePlaceholder":82},[58,1324,1325,1328,1331,1334],{"class":60,"line":236},[58,1326,1327],{"class":75},"ENV",[58,1329,1330],{"class":99}," PATH=\"\u002Fdata\u002Fgo\u002Fbin:",[58,1332,1333],{"class":92},"$PATH",[58,1335,106],{"class":99},[44,1337,1338],{"id":1338},"遇到问题",[1049,1340,1341,1367],{},[1052,1342,1343,1346,1347,1350,1353,1354,1357,1359,1360,1362],{},[19,1344,1345],{},"go mod tidy"," 时报错 ",[19,1348,1349],{},"tls: failed to verify certificate: x509: certificate",[1351,1352],"br",{},"解决：",[19,1355,1356],{},"RUN apt-get update && apt-get install -y ca-certificates && rm -rf \u002Fvar\u002Flib\u002Fapt\u002Flists\u002F*",[1351,1358],{},"基础镜像的证书未更新，导致 https 的请求出现类似错误。",[1351,1361],{},[1363,1364],"img",{"alt":1365,"src":1366},"image-20250327100813512","\u002Fposts\u002Fdocker-musl-build\u002Fimg\u002Fimage-20250327100813512.png",[1052,1368,1369,1370,1373,1374,1376,1377,1380],{},"直接安装 ",[19,1371,1372],{},"golang-go"," 其版本过低",[1351,1375],{},"解决: ",[19,1378,1379],{},"PATH=\"\u002Fdata\u002Fgo\u002Fbin:$PATH\""," 手动指定环境",[11,1382,1383],{"id":1383},"最终方案",[1049,1385,1386,1393,1396],{},[1052,1387,1388,1389,1392],{},"下载 ",[19,1390,1391],{},"go1.24.1.linux-amd64.tar.gz"," 解压",[1052,1394,1395],{},"编写 Dockerfile",[1052,1397,1398,1399],{},"执行 ",[19,1400,1401],{},"docker build -t go-build:v1 .",[15,1403,1404],{},"Dockerfile 内容：",[49,1406,1410],{"className":1407,"code":1408,"language":1409,"meta":54,"style":54},"language-dockerfile shiki shiki-themes github-light github-dark","FROM ubuntu:20.04\n\nLABEL org.opencontainers.image.authors=\"sutong\"\n\nCOPY go\u002F \u002Fgo\u002F\n\nENV PATH=\"\u002Fgo\u002Fbin:$PATH\"\n\nRUN apt-get update && apt-get install -y musl-tools\n\n## go mod tidy 时报错 tls: failed to verify certificate: x509: certificate\nRUN apt-get update && apt-get install -y ca-certificates && rm -rf \u002Fvar\u002Flib\u002Fapt\u002Flists\u002F*\n# 指定 musl 编译器\nENV CC=musl-gcc\n# 启用 CGO\nENV CGO_ENABLED=1\n\nVOLUME \u002Fdata\n\nWORKDIR \u002Fdata\n\nCMD [\"\u002Fbin\u002Fbash\"]\n","dockerfile",[19,1411,1412,1420,1424,1435,1439,1447,1451,1461,1465,1473,1477,1482,1489,1493,1500,1505,1512,1516,1524,1528,1535,1539],{"__ignoreMap":54},[58,1413,1414,1417],{"class":60,"line":61},[58,1415,1416],{"class":71},"FROM",[58,1418,1419],{"class":92}," ubuntu:20.04\n",[58,1421,1422],{"class":60,"line":68},[58,1423,83],{"emptyLinePlaceholder":82},[58,1425,1426,1429,1432],{"class":60,"line":79},[58,1427,1428],{"class":71},"LABEL",[58,1430,1431],{"class":92}," org.opencontainers.image.authors=",[58,1433,1434],{"class":99},"\"sutong\"\n",[58,1436,1437],{"class":60,"line":86},[58,1438,83],{"emptyLinePlaceholder":82},[58,1440,1441,1444],{"class":60,"line":96},[58,1442,1443],{"class":71},"COPY",[58,1445,1446],{"class":92}," go\u002F \u002Fgo\u002F\n",[58,1448,1449],{"class":60,"line":109},[58,1450,83],{"emptyLinePlaceholder":82},[58,1452,1453,1455,1458],{"class":60,"line":119},[58,1454,1327],{"class":71},[58,1456,1457],{"class":92}," PATH=",[58,1459,1460],{"class":99},"\"\u002Fgo\u002Fbin:$PATH\"\n",[58,1462,1463],{"class":60,"line":129},[58,1464,83],{"emptyLinePlaceholder":82},[58,1466,1467,1470],{"class":60,"line":139},[58,1468,1469],{"class":71},"RUN",[58,1471,1472],{"class":92}," apt-get update && apt-get install -y musl-tools\n",[58,1474,1475],{"class":60,"line":149},[58,1476,83],{"emptyLinePlaceholder":82},[58,1478,1479],{"class":60,"line":159},[58,1480,1481],{"class":64},"## go mod tidy 时报错 tls: failed to verify certificate: x509: certificate\n",[58,1483,1484,1486],{"class":60,"line":164},[58,1485,1469],{"class":71},[58,1487,1488],{"class":92}," apt-get update && apt-get install -y ca-certificates && rm -rf \u002Fvar\u002Flib\u002Fapt\u002Flists\u002F*\n",[58,1490,1491],{"class":60,"line":174},[58,1492,1217],{"class":64},[58,1494,1495,1497],{"class":60,"line":180},[58,1496,1327],{"class":71},[58,1498,1499],{"class":92}," CC=musl-gcc\n",[58,1501,1502],{"class":60,"line":185},[58,1503,1504],{"class":64},"# 启用 CGO\n",[58,1506,1507,1509],{"class":60,"line":191},[58,1508,1327],{"class":71},[58,1510,1511],{"class":92}," CGO_ENABLED=1\n",[58,1513,1514],{"class":60,"line":208},[58,1515,83],{"emptyLinePlaceholder":82},[58,1517,1518,1521],{"class":60,"line":213},[58,1519,1520],{"class":71},"VOLUME",[58,1522,1523],{"class":92}," \u002Fdata\n",[58,1525,1526],{"class":60,"line":236},[58,1527,83],{"emptyLinePlaceholder":82},[58,1529,1530,1533],{"class":60,"line":259},[58,1531,1532],{"class":71},"WORKDIR",[58,1534,1523],{"class":92},[58,1536,1537],{"class":60,"line":264},[58,1538,83],{"emptyLinePlaceholder":82},[58,1540,1541,1544,1547,1550],{"class":60,"line":293},[58,1542,1543],{"class":71},"CMD",[58,1545,1546],{"class":92}," [",[58,1548,1549],{"class":99},"\"\u002Fbin\u002Fbash\"",[58,1551,1552],{"class":92},"]\n",[44,1554,1555],{"id":1555},"构建",[49,1557,1559],{"className":579,"code":1558,"language":581,"meta":54,"style":54},"# docker build -t go-build:v1 .\n\n# 直接执行编译 编译后删除\ndocker run --rm -v .:\u002Fdata go-build:v1 go build -o app_musl\n\n# 构建编译环境\ndocker run -it -d -v .:\u002Fdata --name \u003C容器名称> go-build:v1 \u002Fbin\u002Fbash\n# eg：docker run -it -d -v .:\u002Fdata --name logbus-build go-build:v1 \u002Fbin\u002Fbash\n\ndocker exec -it \u003C容器名称> go build -o \u003Cmyapp>\n# eg：docker exec -it logbus-build go build -o logbus_musl_1\n",[19,1560,1561,1566,1570,1575,1601,1605,1610,1645,1650,1654,1687],{"__ignoreMap":54},[58,1562,1563],{"class":60,"line":61},[58,1564,1565],{"class":64},"# docker build -t go-build:v1 .\n",[58,1567,1568],{"class":60,"line":68},[58,1569,83],{"emptyLinePlaceholder":82},[58,1571,1572],{"class":60,"line":79},[58,1573,1574],{"class":64},"# 直接执行编译 编译后删除\n",[58,1576,1577,1579,1581,1583,1585,1588,1591,1594,1596,1598],{"class":60,"line":86},[58,1578,1133],{"class":75},[58,1580,1136],{"class":99},[58,1582,1142],{"class":382},[58,1584,1145],{"class":382},[58,1586,1587],{"class":99}," .:\u002Fdata",[58,1589,1590],{"class":99}," go-build:v1",[58,1592,1593],{"class":99}," go",[58,1595,693],{"class":99},[58,1597,696],{"class":382},[58,1599,1600],{"class":99}," app_musl\n",[58,1602,1603],{"class":60,"line":96},[58,1604,83],{"emptyLinePlaceholder":82},[58,1606,1607],{"class":60,"line":109},[58,1608,1609],{"class":64},"# 构建编译环境\n",[58,1611,1612,1614,1616,1618,1621,1623,1625,1628,1631,1634,1637,1640,1642],{"class":60,"line":119},[58,1613,1133],{"class":75},[58,1615,1136],{"class":99},[58,1617,1139],{"class":382},[58,1619,1620],{"class":382}," -d",[58,1622,1145],{"class":382},[58,1624,1587],{"class":99},[58,1626,1627],{"class":382}," --name",[58,1629,1630],{"class":71}," \u003C",[58,1632,1633],{"class":99},"容器名",[58,1635,1636],{"class":92},"称",[58,1638,1639],{"class":71},">",[58,1641,1590],{"class":99},[58,1643,1644],{"class":99}," \u002Fbin\u002Fbash\n",[58,1646,1647],{"class":60,"line":129},[58,1648,1649],{"class":64},"# eg：docker run -it -d -v .:\u002Fdata --name logbus-build go-build:v1 \u002Fbin\u002Fbash\n",[58,1651,1652],{"class":60,"line":139},[58,1653,83],{"emptyLinePlaceholder":82},[58,1655,1656,1658,1661,1663,1665,1667,1669,1671,1673,1675,1677,1679,1682,1684],{"class":60,"line":149},[58,1657,1133],{"class":75},[58,1659,1660],{"class":99}," exec",[58,1662,1139],{"class":382},[58,1664,1630],{"class":71},[58,1666,1633],{"class":99},[58,1668,1636],{"class":92},[58,1670,1639],{"class":71},[58,1672,1593],{"class":99},[58,1674,693],{"class":99},[58,1676,696],{"class":382},[58,1678,1630],{"class":71},[58,1680,1681],{"class":99},"myap",[58,1683,15],{"class":92},[58,1685,1686],{"class":71},">\n",[58,1688,1689],{"class":60,"line":159},[58,1690,1691],{"class":64},"# eg：docker exec -it logbus-build go build -o logbus_musl_1\n",[44,1693,1694],{"id":1694},"额外注意",[15,1696,1697],{},"若没科学上网需要设置代理：",[49,1699,1701],{"className":579,"code":1700,"language":581,"meta":54,"style":54},"docker exec -it \u003C容器名称> \u002Fbin\u002Fbash\ngo env -w GOPROXY=https:\u002F\u002Fgoproxy.cn,direct\ngo build -ldflags '-linkmode external -extldflags \"-static\"' -o myapp  # 静态编译\ngo build # musl 库编译\n",[19,1702,1703,1721,1731,1747],{"__ignoreMap":54},[58,1704,1705,1707,1709,1711,1713,1715,1717,1719],{"class":60,"line":61},[58,1706,1133],{"class":75},[58,1708,1660],{"class":99},[58,1710,1139],{"class":382},[58,1712,1630],{"class":71},[58,1714,1633],{"class":99},[58,1716,1636],{"class":92},[58,1718,1639],{"class":71},[58,1720,1644],{"class":99},[58,1722,1723,1725,1727,1729],{"class":60,"line":68},[58,1724,53],{"class":75},[58,1726,1244],{"class":99},[58,1728,1156],{"class":382},[58,1730,1249],{"class":99},[58,1732,1733,1735,1737,1739,1741,1743,1745],{"class":60,"line":79},[58,1734,53],{"class":75},[58,1736,693],{"class":99},[58,1738,1258],{"class":382},[58,1740,1261],{"class":99},[58,1742,696],{"class":382},[58,1744,1266],{"class":99},[58,1746,1269],{"class":64},[58,1748,1749,1751,1753],{"class":60,"line":86},[58,1750,53],{"class":75},[58,1752,693],{"class":99},[58,1754,1755],{"class":64}," # musl 库编译\n",[44,1757,1758],{"id":1758},"运行环境示例",[49,1760,1762],{"className":1407,"code":1761,"language":1409,"meta":54,"style":54},"FROM alpine:latest\n\nLABEL org.opencontainers.image.authors=\"sutong\"\n\nCOPY glog_musl \u002Fapp\u002F\n\nCOPY conf.ini \u002Fdata\u002F\nCOPY ipname.ini \u002Fdata\u002F\n\nWORKDIR \u002Fapp\n\nRUN chmod 777 \u002Fapp\u002Fglog_musl\n\nCMD [\".\u002Fglog_musl\", \"logsweb\", \"\u002Fdata\u002Fconf.ini\"]\n\n# docker build -t glog:v1 .\n# docker run -p 6801:6801 glog:v1\n",[19,1763,1764,1771,1775,1783,1787,1794,1798,1805,1812,1816,1823,1827,1834,1838,1859,1863,1868],{"__ignoreMap":54},[58,1765,1766,1768],{"class":60,"line":61},[58,1767,1416],{"class":71},[58,1769,1770],{"class":92}," alpine:latest\n",[58,1772,1773],{"class":60,"line":68},[58,1774,83],{"emptyLinePlaceholder":82},[58,1776,1777,1779,1781],{"class":60,"line":79},[58,1778,1428],{"class":71},[58,1780,1431],{"class":92},[58,1782,1434],{"class":99},[58,1784,1785],{"class":60,"line":86},[58,1786,83],{"emptyLinePlaceholder":82},[58,1788,1789,1791],{"class":60,"line":96},[58,1790,1443],{"class":71},[58,1792,1793],{"class":92}," glog_musl \u002Fapp\u002F\n",[58,1795,1796],{"class":60,"line":109},[58,1797,83],{"emptyLinePlaceholder":82},[58,1799,1800,1802],{"class":60,"line":119},[58,1801,1443],{"class":71},[58,1803,1804],{"class":92}," conf.ini \u002Fdata\u002F\n",[58,1806,1807,1809],{"class":60,"line":129},[58,1808,1443],{"class":71},[58,1810,1811],{"class":92}," ipname.ini \u002Fdata\u002F\n",[58,1813,1814],{"class":60,"line":139},[58,1815,83],{"emptyLinePlaceholder":82},[58,1817,1818,1820],{"class":60,"line":149},[58,1819,1532],{"class":71},[58,1821,1822],{"class":92}," \u002Fapp\n",[58,1824,1825],{"class":60,"line":159},[58,1826,83],{"emptyLinePlaceholder":82},[58,1828,1829,1831],{"class":60,"line":164},[58,1830,1469],{"class":71},[58,1832,1833],{"class":92}," chmod 777 \u002Fapp\u002Fglog_musl\n",[58,1835,1836],{"class":60,"line":174},[58,1837,83],{"emptyLinePlaceholder":82},[58,1839,1840,1842,1844,1847,1849,1852,1854,1857],{"class":60,"line":180},[58,1841,1543],{"class":71},[58,1843,1546],{"class":92},[58,1845,1846],{"class":99},"\".\u002Fglog_musl\"",[58,1848,889],{"class":92},[58,1850,1851],{"class":99},"\"logsweb\"",[58,1853,889],{"class":92},[58,1855,1856],{"class":99},"\"\u002Fdata\u002Fconf.ini\"",[58,1858,1552],{"class":92},[58,1860,1861],{"class":60,"line":185},[58,1862,83],{"emptyLinePlaceholder":82},[58,1864,1865],{"class":60,"line":191},[58,1866,1867],{"class":64},"# docker build -t glog:v1 .\n",[58,1869,1870],{"class":60,"line":208},[58,1871,1872],{"class":64},"# docker run -p 6801:6801 glog:v1\n",[1066,1874,1875],{},"html pre.shiki code .sHbNN, html code.shiki .sHbNN{--shiki-light:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sw2iP, html code.shiki .sw2iP{--shiki-light:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .scXbn, html code.shiki .scXbn{--shiki-light:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .suQ91, html code.shiki .suQ91{--shiki-light:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sIX_F, html code.shiki .sIX_F{--shiki-light:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .siTax, html code.shiki .siTax{--shiki-light:#D73A49;--shiki-dark:#F97583}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":54,"searchDepth":68,"depth":79,"links":1877},[1878,1879,1882],{"id":1112,"depth":68,"text":1112},{"id":1118,"depth":68,"text":1118,"children":1880},[1881],{"id":1338,"depth":79,"text":1338},{"id":1383,"depth":68,"text":1383,"children":1883},[1884,1885,1886],{"id":1555,"depth":79,"text":1555},{"id":1694,"depth":79,"text":1694},{"id":1758,"depth":79,"text":1758},"解决 Go 语言跨平台编译问题，使用 Docker + musl 编译静态链接的可执行文件。","\u002Fposts\u002Fdocker-musl-build\u002Fimg\u002Fcover.svg",{},"\u002Fposts\u002Fdocker-musl-build","2025-03-27",{"title":1107,"description":1887},"Docker 实践笔记","posts\u002Fdocker-musl-build\u002Findex",[1896,1098,1897],"Docker","运维","YLtdOOc-gMVDSNjE-ntKXz-7hff-VtvMB2dB5RXNi1o",{"id":1900,"title":1901,"author":6,"body":1902,"description":2298,"difficulty":2299,"draft":1088,"extension":1089,"image":2300,"meta":2301,"navigation":82,"path":2302,"pinned":1088,"published":2303,"seo":2304,"series":1893,"seriesOrder":68,"stem":2305,"tags":2306,"updated":2303,"__hash__":2308},"posts\u002Fposts\u002Fdocker-user-start\u002Findex.md","Docker 构建时指定用户启动",{"type":8,"value":1903,"toc":2288},[1904,1907,1910,1913,1917,1920,1937,1941,1974,1978,1985,2035,2038,2247,2249,2252,2282,2285],[11,1905,1906],{"id":1906},"前言",[15,1908,1909],{},"我们在 Linux 使用应用时，往往不会直接使用 root 用户作为应用启动项，但 Dockerfile 默认就是 root 用户，导致挂载生成的文件路径往往是 root 权限，非 root 用户访问就很不方便。",[11,1911,1912],{"id":1912},"解决方案",[44,1914,1916],{"id":1915},"_1-安装用户管理工具","1. 安装用户管理工具",[15,1918,1919],{},"若镜像非自带用户管理：",[49,1921,1923],{"className":1407,"code":1922,"language":1409,"meta":54,"style":54},"# 权限管理\nRUN apk add --no-cache shadow\n",[19,1924,1925,1930],{"__ignoreMap":54},[58,1926,1927],{"class":60,"line":61},[58,1928,1929],{"class":64},"# 权限管理\n",[58,1931,1932,1934],{"class":60,"line":68},[58,1933,1469],{"class":71},[58,1935,1936],{"class":92}," apk add --no-cache shadow\n",[44,1938,1940],{"id":1939},"_2-创建用户并修改权限","2. 创建用户并修改权限",[49,1942,1944],{"className":1407,"code":1943,"language":1409,"meta":54,"style":54},"# 创建用户组和用户\nRUN addgroup -S \u003Capp_user_group> && adduser -S -G \u003Capp_user_group> \u003Capp_user>\n\n# 修改目录权限\nRUN chown -R \u003Capp_user_group>:\u003Capp_user> \u002Fdata\n",[19,1945,1946,1951,1958,1962,1967],{"__ignoreMap":54},[58,1947,1948],{"class":60,"line":61},[58,1949,1950],{"class":64},"# 创建用户组和用户\n",[58,1952,1953,1955],{"class":60,"line":68},[58,1954,1469],{"class":71},[58,1956,1957],{"class":92}," addgroup -S \u003Capp_user_group> && adduser -S -G \u003Capp_user_group> \u003Capp_user>\n",[58,1959,1960],{"class":60,"line":79},[58,1961,83],{"emptyLinePlaceholder":82},[58,1963,1964],{"class":60,"line":86},[58,1965,1966],{"class":64},"# 修改目录权限\n",[58,1968,1969,1971],{"class":60,"line":96},[58,1970,1469],{"class":71},[58,1972,1973],{"class":92}," chown -R \u003Capp_user_group>:\u003Capp_user> \u002Fdata\n",[44,1975,1977],{"id":1976},"_3-指定用户启动","3. 指定用户启动",[15,1979,1980,1981,1984],{},"执行 docker 时加 ",[19,1982,1983],{},"--user"," 参数：",[49,1986,1988],{"className":579,"code":1987,"language":581,"meta":54,"style":54},"docker run -it --user \u003Capp_user> \u003Capp_image>:\u003Cimage_version>\n",[19,1989,1990],{"__ignoreMap":54},[58,1991,1992,1994,1996,1998,2001,2003,2006,2009,2011,2013,2016,2019,2021,2024,2027,2030,2033],{"class":60,"line":61},[58,1993,1133],{"class":75},[58,1995,1136],{"class":99},[58,1997,1139],{"class":382},[58,1999,2000],{"class":382}," --user",[58,2002,1630],{"class":71},[58,2004,2005],{"class":99},"app_use",[58,2007,2008],{"class":92},"r",[58,2010,1639],{"class":71},[58,2012,1630],{"class":71},[58,2014,2015],{"class":99},"app_imag",[58,2017,2018],{"class":92},"e",[58,2020,1639],{"class":71},[58,2022,2023],{"class":99},":",[58,2025,2026],{"class":71},"\u003C",[58,2028,2029],{"class":99},"image_versio",[58,2031,2032],{"class":92},"n",[58,2034,1686],{"class":71},[11,2036,2037],{"id":2037},"完整示例",[49,2039,2041],{"className":1407,"code":2040,"language":1409,"meta":54,"style":54},"FROM alpine:latest\n\n# 国内源\nRUN sed -i 's\u002Fdl-cdn.alpinelinux.org\u002Fmirrors.ustc.edu.cn\u002Fg' \u002Fetc\u002Fapk\u002Frepositories\n\nRUN apk update --no-cache\n# 权限管理\nRUN apk add --no-cache shadow\n# 调试\nRUN apk add --no-cache bash\n# 设置时区\nRUN apk add --no-cache tzdata\n\nENV TZ=Asia\u002FShanghai\n\n# 创建用户组和用户\nRUN addgroup -S glog && adduser -S -G glog glog\n\nCOPY .\u002Fglog_static_musl_1 \u002Fapp\u002F\n\nRUN mkdir \u002Fdata\n\nVOLUME [ \"\u002Fdata\" ]\nWORKDIR \u002Fdata\n\n# 修改权限\nRUN chown -R glog:glog \u002Fdata\nRUN chown -R glog:glog \u002Fapp\n\n# 指定用户启动\nUSER glog\nCMD [ \"\u002Fapp\u002Fglog_static_musl_1\", \"logsweb\", \".\u002Fconf.ini\" ]\n",[19,2042,2043,2049,2053,2058,2071,2075,2082,2086,2092,2097,2104,2109,2116,2120,2127,2131,2135,2142,2146,2153,2157,2164,2168,2181,2187,2191,2196,2203,2210,2214,2219,2227],{"__ignoreMap":54},[58,2044,2045,2047],{"class":60,"line":61},[58,2046,1416],{"class":71},[58,2048,1770],{"class":92},[58,2050,2051],{"class":60,"line":68},[58,2052,83],{"emptyLinePlaceholder":82},[58,2054,2055],{"class":60,"line":79},[58,2056,2057],{"class":64},"# 国内源\n",[58,2059,2060,2062,2065,2068],{"class":60,"line":86},[58,2061,1469],{"class":71},[58,2063,2064],{"class":92}," sed -i ",[58,2066,2067],{"class":99},"'s\u002Fdl-cdn.alpinelinux.org\u002Fmirrors.ustc.edu.cn\u002Fg'",[58,2069,2070],{"class":92}," \u002Fetc\u002Fapk\u002Frepositories\n",[58,2072,2073],{"class":60,"line":96},[58,2074,83],{"emptyLinePlaceholder":82},[58,2076,2077,2079],{"class":60,"line":109},[58,2078,1469],{"class":71},[58,2080,2081],{"class":92}," apk update --no-cache\n",[58,2083,2084],{"class":60,"line":119},[58,2085,1929],{"class":64},[58,2087,2088,2090],{"class":60,"line":129},[58,2089,1469],{"class":71},[58,2091,1936],{"class":92},[58,2093,2094],{"class":60,"line":139},[58,2095,2096],{"class":64},"# 调试\n",[58,2098,2099,2101],{"class":60,"line":149},[58,2100,1469],{"class":71},[58,2102,2103],{"class":92}," apk add --no-cache bash\n",[58,2105,2106],{"class":60,"line":159},[58,2107,2108],{"class":64},"# 设置时区\n",[58,2110,2111,2113],{"class":60,"line":164},[58,2112,1469],{"class":71},[58,2114,2115],{"class":92}," apk add --no-cache tzdata\n",[58,2117,2118],{"class":60,"line":174},[58,2119,83],{"emptyLinePlaceholder":82},[58,2121,2122,2124],{"class":60,"line":180},[58,2123,1327],{"class":71},[58,2125,2126],{"class":92}," TZ=Asia\u002FShanghai\n",[58,2128,2129],{"class":60,"line":185},[58,2130,83],{"emptyLinePlaceholder":82},[58,2132,2133],{"class":60,"line":191},[58,2134,1950],{"class":64},[58,2136,2137,2139],{"class":60,"line":208},[58,2138,1469],{"class":71},[58,2140,2141],{"class":92}," addgroup -S glog && adduser -S -G glog glog\n",[58,2143,2144],{"class":60,"line":213},[58,2145,83],{"emptyLinePlaceholder":82},[58,2147,2148,2150],{"class":60,"line":236},[58,2149,1443],{"class":71},[58,2151,2152],{"class":92}," .\u002Fglog_static_musl_1 \u002Fapp\u002F\n",[58,2154,2155],{"class":60,"line":259},[58,2156,83],{"emptyLinePlaceholder":82},[58,2158,2159,2161],{"class":60,"line":264},[58,2160,1469],{"class":71},[58,2162,2163],{"class":92}," mkdir \u002Fdata\n",[58,2165,2166],{"class":60,"line":293},[58,2167,83],{"emptyLinePlaceholder":82},[58,2169,2170,2172,2175,2178],{"class":60,"line":315},[58,2171,1520],{"class":71},[58,2173,2174],{"class":92}," [ ",[58,2176,2177],{"class":99},"\"\u002Fdata\"",[58,2179,2180],{"class":92}," ]\n",[58,2182,2183,2185],{"class":60,"line":332},[58,2184,1532],{"class":71},[58,2186,1523],{"class":92},[58,2188,2189],{"class":60,"line":344},[58,2190,83],{"emptyLinePlaceholder":82},[58,2192,2193],{"class":60,"line":350},[58,2194,2195],{"class":64},"# 修改权限\n",[58,2197,2198,2200],{"class":60,"line":355},[58,2199,1469],{"class":71},[58,2201,2202],{"class":92}," chown -R glog:glog \u002Fdata\n",[58,2204,2205,2207],{"class":60,"line":371},[58,2206,1469],{"class":71},[58,2208,2209],{"class":92}," chown -R glog:glog \u002Fapp\n",[58,2211,2212],{"class":60,"line":388},[58,2213,83],{"emptyLinePlaceholder":82},[58,2215,2216],{"class":60,"line":394},[58,2217,2218],{"class":64},"# 指定用户启动\n",[58,2220,2221,2224],{"class":60,"line":414},[58,2222,2223],{"class":71},"USER",[58,2225,2226],{"class":92}," glog\n",[58,2228,2229,2231,2233,2236,2238,2240,2242,2245],{"class":60,"line":419},[58,2230,1543],{"class":71},[58,2232,2174],{"class":92},[58,2234,2235],{"class":99},"\"\u002Fapp\u002Fglog_static_musl_1\"",[58,2237,889],{"class":92},[58,2239,1851],{"class":99},[58,2241,889],{"class":92},[58,2243,2244],{"class":99},"\".\u002Fconf.ini\"",[58,2246,2180],{"class":92},[11,2248,1041],{"id":1041},[15,2250,2251],{},"关键步骤：",[1049,2253,2254,2260,2270,2276],{},[1052,2255,2256,2259],{},[19,2257,2258],{},"apk add --no-cache shadow"," - 安装用户管理工具",[1052,2261,2262,2265,2266,2269],{},[19,2263,2264],{},"addgroup"," + ",[19,2267,2268],{},"adduser"," - 创建用户",[1052,2271,2272,2275],{},[19,2273,2274],{},"chown -R"," - 修改目录权限",[1052,2277,2278,2281],{},[19,2279,2280],{},"USER \u003Cusername>"," - 指定运行用户",[15,2283,2284],{},"这样容器内的应用就不再以 root 身份运行，更加安全。",[1066,2286,2287],{},"html pre.shiki code .sHbNN, html code.shiki .sHbNN{--shiki-light:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .siTax, html code.shiki .siTax{--shiki-light:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sIX_F, html code.shiki .sIX_F{--shiki-light:#24292E;--shiki-dark:#E1E4E8}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sw2iP, html code.shiki .sw2iP{--shiki-light:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .scXbn, html code.shiki .scXbn{--shiki-light:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .suQ91, html code.shiki .suQ91{--shiki-light:#005CC5;--shiki-dark:#79B8FF}",{"title":54,"searchDepth":68,"depth":79,"links":2289},[2290,2291,2296,2297],{"id":1906,"depth":68,"text":1906},{"id":1912,"depth":68,"text":1912,"children":2292},[2293,2294,2295],{"id":1915,"depth":79,"text":1916},{"id":1939,"depth":79,"text":1940},{"id":1976,"depth":79,"text":1977},{"id":2037,"depth":68,"text":2037},{"id":1041,"depth":68,"text":1041},"解决 Docker 容器默认以 root 用户运行导致的权限问题，指定非 root 用户启动应用。","入门","\u002Fposts\u002Fdocker-user-start\u002Fimg\u002Fcover.svg",{},"\u002Fposts\u002Fdocker-user-start","2025-04-01",{"title":1901,"description":2298},"posts\u002Fdocker-user-start\u002Findex",[1896,1897,2307],"Linux","b4Uyw5ms87xr7NhpIkavHnNbT20oaEPtXz8k1w6UPj0",{"id":2310,"title":2311,"author":6,"body":2312,"description":2522,"difficulty":6,"draft":1088,"extension":1089,"image":2523,"meta":2524,"navigation":82,"path":2525,"pinned":1088,"published":2526,"seo":2527,"series":6,"seriesOrder":6,"stem":2528,"tags":2529,"updated":6,"__hash__":2531},"posts\u002Fposts\u002Fsveltekit-intro\u002Findex.md","SvelteKit 入门：为什么它比你想象的更简单",{"type":8,"value":2313,"toc":2512},[2314,2318,2321,2327,2330,2334,2341,2392,2396,2402,2410,2414,2417,2420,2490,2492,2503,2509],[11,2315,2317],{"id":2316},"什么是-sveltekit","什么是 SvelteKit？",[15,2319,2320],{},"SvelteKit 是 Svelte 的官方全栈框架，类似于 React 之于 Next.js 的关系。它提供了路由、SSR、SSG 等能力，让开发者可以快速构建现代 Web 应用。",[15,2322,2323],{},[1363,2324],{"alt":2325,"src":2326},"SvelteKit Logo","https:\u002F\u002Fraw.githubusercontent.com\u002Fsveltejs\u002Fbranding\u002Fmaster\u002Fsvelte-logo.svg",[11,2328,2329],{"id":2329},"核心特点",[44,2331,2333],{"id":2332},"_1-编译时框架","1. 编译时框架",[15,2335,2336,2337,2340],{},"Svelte 和 React\u002FVue 最大的区别在于：",[737,2338,2339],{},"它在编译阶段就把组件转成原生 JS","，运行时没有虚拟 DOM。",[49,2342,2346],{"className":2343,"code":2344,"language":2345,"meta":54,"style":54},"language-typescript shiki shiki-themes github-light github-dark","\u002F\u002F Svelte 组件（编译后变成直接的 DOM 操作）\nlet count = 0\nfunction increment() {\n  count += 1\n}\n","typescript",[19,2347,2348,2353,2366,2377,2388],{"__ignoreMap":54},[58,2349,2350],{"class":60,"line":61},[58,2351,2352],{"class":64},"\u002F\u002F Svelte 组件（编译后变成直接的 DOM 操作）\n",[58,2354,2355,2358,2361,2363],{"class":60,"line":68},[58,2356,2357],{"class":71},"let",[58,2359,2360],{"class":92}," count ",[58,2362,338],{"class":71},[58,2364,2365],{"class":382}," 0\n",[58,2367,2368,2371,2374],{"class":60,"line":79},[58,2369,2370],{"class":71},"function",[58,2372,2373],{"class":75}," increment",[58,2375,2376],{"class":92},"() {\n",[58,2378,2379,2382,2385],{"class":60,"line":86},[58,2380,2381],{"class":92},"  count ",[58,2383,2384],{"class":71},"+=",[58,2386,2387],{"class":382}," 1\n",[58,2389,2390],{"class":60,"line":96},[58,2391,457],{"class":92},[44,2393,2395],{"id":2394},"_2-文件路由","2. 文件路由",[15,2397,2398,2401],{},[19,2399,2400],{},"src\u002Froutes\u002F"," 目录结构即 URL，不需要手动配置路由表：",[49,2403,2408],{"className":2404,"code":2406,"language":2407},[2405],"language-text","src\u002Froutes\u002F\n├── +page.svelte        → \u002F\n├── about\u002F\n│   └── +page.svelte    → \u002Fabout\n└── posts\u002F\n    ├── +page.svelte    → \u002Fposts\n    └── [slug]\u002F\n        └── +page.svelte → \u002Fposts\u002F:slug\n","text",[19,2409,2406],{"__ignoreMap":54},[44,2411,2413],{"id":2412},"_3-全栈能力","3. 全栈能力",[15,2415,2416],{},"SvelteKit 内置了服务端渲染（SSR）、静态站点生成（SSG）、API 路由等功能，开箱即用。",[11,2418,2419],{"id":2419},"和其他框架对比",[995,2421,2422,2438],{},[998,2423,2424],{},[1001,2425,2426,2429,2432,2435],{},[1004,2427,2428],{},"特性",[1004,2430,2431],{},"SvelteKit",[1004,2433,2434],{},"Next.js",[1004,2436,2437],{},"Nuxt",[1014,2439,2440,2453,2466,2479],{},[1001,2441,2442,2445,2448,2451],{},[1019,2443,2444],{},"学习曲线",[1019,2446,2447],{},"低",[1019,2449,2450],{},"中",[1019,2452,2450],{},[1001,2454,2455,2458,2461,2464],{},[1019,2456,2457],{},"包体积",[1019,2459,2460],{},"小",[1019,2462,2463],{},"大",[1019,2465,2450],{},[1001,2467,2468,2471,2474,2477],{},[1019,2469,2470],{},"运行时开销",[1019,2472,2473],{},"无",[1019,2475,2476],{},"有",[1019,2478,2476],{},[1001,2480,2481,2484,2486,2488],{},[1019,2482,2483],{},"生态",[1019,2485,2460],{},[1019,2487,2463],{},[1019,2489,2463],{},[11,2491,1041],{"id":1041},[15,2493,2494,2495,2498,2499,2502],{},"如果你追求",[737,2496,2497],{},"简洁的代码","和",[737,2500,2501],{},"极致的性能","，SvelteKit 是一个非常值得尝试的选择。它的学习曲线平缓，写起来就像在写原生 HTML\u002FCSS\u002FJS。",[15,2504,2505],{},[1363,2506],{"alt":2507,"src":2508},"Svelte 编译过程","https:\u002F\u002Fsvelte.dev\u002Fsvelte-logo-horizontal.svg",[1066,2510,2511],{},"html pre.shiki code .sHbNN, html code.shiki .sHbNN{--shiki-light:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .siTax, html code.shiki .siTax{--shiki-light:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sIX_F, html code.shiki .sIX_F{--shiki-light:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .suQ91, html code.shiki .suQ91{--shiki-light:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sw2iP, html code.shiki .sw2iP{--shiki-light:#6F42C1;--shiki-dark:#B392F0}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":54,"searchDepth":68,"depth":79,"links":2513},[2514,2515,2520,2521],{"id":2316,"depth":68,"text":2317},{"id":2329,"depth":68,"text":2329,"children":2516},[2517,2518,2519],{"id":2332,"depth":79,"text":2333},{"id":2394,"depth":79,"text":2395},{"id":2412,"depth":79,"text":2413},{"id":2419,"depth":68,"text":2419},{"id":1041,"depth":68,"text":1041},"SvelteKit 是一个全栈 Svelte 框架，这篇文章带你了解它的核心概念和优势。","\u002Fposts\u002Fsveltekit-intro\u002Fimg\u002Fcover.jpg",{},"\u002Fposts\u002Fsveltekit-intro","2026-05-30",{"title":2311,"description":2522},"posts\u002Fsveltekit-intro\u002Findex",[2431,2530],"前端框架","vsEGyunpCWR7DiJAdyV1SPDfa57xXLITBI-lKM8UeGQ",{"id":2533,"title":2534,"author":6,"body":2535,"description":2936,"difficulty":2299,"draft":1088,"extension":1089,"image":2937,"meta":2938,"navigation":82,"path":2939,"pinned":82,"published":2940,"seo":2941,"series":2942,"seriesOrder":61,"stem":2943,"tags":2944,"updated":2946,"__hash__":2947},"posts\u002Fposts\u002Fhello-world\u002Findex.md","Hello World - 欢迎使用 SuBlog",{"type":8,"value":2536,"toc":2927},[2537,2541,2548,2551,2554,2557,2631,2634,2637,2700,2703,2706,2771,2774,2840,2843,2846,2861,2868,2871,2902,2905,2916,2919,2924],[11,2538,2540],{"id":2539},"博客上线啦","博客上线啦！",[15,2542,2543,2544,2547],{},"Hello World！欢迎使用 ",[737,2545,2546],{},"SuBlog","，这是站点的第一篇示例文章。",[15,2549,2550],{},"经过一段时间的折腾，这个基于 Nuxt 3 的个人博客终于上线了。在这里记录一下这个项目的技术选型和功能特性。",[11,2552,2553],{"id":2553},"技术栈",[15,2555,2556],{},"本站使用了以下技术：",[995,2558,2559,2569],{},[998,2560,2561],{},[1001,2562,2563,2566],{},[1004,2564,2565],{},"技术",[1004,2567,2568],{},"用途",[1014,2570,2571,2581,2591,2601,2611,2621],{},[1001,2572,2573,2578],{},[1019,2574,2575],{},[737,2576,2577],{},"Nuxt 3",[1019,2579,2580],{},"Vue 3 全栈框架，提供 SSG、路由、API 等能力",[1001,2582,2583,2588],{},[1019,2584,2585],{},[737,2586,2587],{},"TypeScript",[1019,2589,2590],{},"类型安全，提升开发体验",[1001,2592,2593,2598],{},[1019,2594,2595],{},[737,2596,2597],{},"Tailwind CSS v4",[1019,2599,2600],{},"原子化 CSS，快速构建 UI",[1001,2602,2603,2608],{},[1019,2604,2605],{},[737,2606,2607],{},"@nuxt\u002Fcontent",[1019,2609,2610],{},"Markdown 驱动的内容管理",[1001,2612,2613,2618],{},[1019,2614,2615],{},[737,2616,2617],{},"Shiki",[1019,2619,2620],{},"代码语法高亮",[1001,2622,2623,2628],{},[1019,2624,2625],{},[737,2626,2627],{},"Giscus",[1019,2629,2630],{},"基于 GitHub Discussions 的评论系统",[11,2632,2633],{"id":2633},"功能特性",[15,2635,2636],{},"目前已实现的功能：",[2638,2639,2640,2646,2652,2658,2664,2670,2676,2682,2688,2694],"ul",{},[1052,2641,2642,2645],{},[737,2643,2644],{},"博客系统"," - Markdown 文章，支持代码高亮、目录导航、搜索",[1052,2647,2648,2651],{},[737,2649,2650],{},"暗色模式"," - 跟随系统或手动切换",[1052,2653,2654,2657],{},[737,2655,2656],{},"文章搜索"," - 支持标题、描述、正文、标签多维度搜索",[1052,2659,2660,2663],{},[737,2661,2662],{},"标签系统"," - 文章标签分类，高频标签快速筛选",[1052,2665,2666,2669],{},[737,2667,2668],{},"友链页面"," - 展示友情链接，支持申请",[1052,2671,2672,2675],{},[737,2673,2674],{},"赞助页面"," - 微信收款码展示",[1052,2677,2678,2681],{},[737,2679,2680],{},"封面制作"," - 在线生成封面图片工具",[1052,2683,2684,2687],{},[737,2685,2686],{},"网易云热评"," - 首页随机展示音乐评论",[1052,2689,2690,2693],{},[737,2691,2692],{},"访客统计"," - 不蒜子计数器，带数字滚动动画",[1052,2695,2696,2699],{},[737,2697,2698],{},"SEO 优化"," - 自动生成站点地图、Open Graph 标签",[11,2701,2702],{"id":2702},"代码示例",[15,2704,2705],{},"本站的配置非常集中，一个文件搞定全站定制：",[49,2707,2709],{"className":2343,"code":2708,"language":2345,"meta":54,"style":54},"\u002F\u002F config\u002Fsite.ts\nexport const siteConfig = {\n  siteName: '我的博客',\n  url: 'https:\u002F\u002Fexample.com\u002F',\n  avatar: 'https:\u002F\u002Fq2.qlogo.cn\u002Fheadimg_dl?dst_uin=xxx&spec=100',\n  \u002F\u002F ... 改这一个文件，全站生效\n}\n",[19,2710,2711,2716,2731,2742,2752,2762,2767],{"__ignoreMap":54},[58,2712,2713],{"class":60,"line":61},[58,2714,2715],{"class":64},"\u002F\u002F config\u002Fsite.ts\n",[58,2717,2718,2720,2723,2726,2729],{"class":60,"line":68},[58,2719,1206],{"class":71},[58,2721,2722],{"class":71}," const",[58,2724,2725],{"class":382}," siteConfig",[58,2727,2728],{"class":71}," =",[58,2730,233],{"class":92},[58,2732,2733,2736,2739],{"class":60,"line":79},[58,2734,2735],{"class":92},"  siteName: ",[58,2737,2738],{"class":99},"'我的博客'",[58,2740,2741],{"class":92},",\n",[58,2743,2744,2747,2750],{"class":60,"line":86},[58,2745,2746],{"class":92},"  url: ",[58,2748,2749],{"class":99},"'https:\u002F\u002Fexample.com\u002F'",[58,2751,2741],{"class":92},[58,2753,2754,2757,2760],{"class":60,"line":96},[58,2755,2756],{"class":92},"  avatar: ",[58,2758,2759],{"class":99},"'https:\u002F\u002Fq2.qlogo.cn\u002Fheadimg_dl?dst_uin=xxx&spec=100'",[58,2761,2741],{"class":92},[58,2763,2764],{"class":60,"line":109},[58,2765,2766],{"class":64},"  \u002F\u002F ... 改这一个文件，全站生效\n",[58,2768,2769],{"class":60,"line":119},[58,2770,457],{"class":92},[15,2772,2773],{},"文章的 frontmatter 也很简洁：",[49,2775,2779],{"className":2776,"code":2777,"language":2778,"meta":54,"style":54},"language-yaml shiki shiki-themes github-light github-dark","---\ntitle: 文章标题\npublished: 2026-05-31T08:00:00\ndescription: 文章简介\ntags: ['Nuxt', 'TypeScript']\n---\n","yaml",[19,2780,2781,2786,2798,2808,2818,2836],{"__ignoreMap":54},[58,2782,2783],{"class":60,"line":61},[58,2784,2785],{"class":75},"---\n",[58,2787,2788,2792,2795],{"class":60,"line":68},[58,2789,2791],{"class":2790},"sbB4o","title",[58,2793,2794],{"class":92},": ",[58,2796,2797],{"class":99},"文章标题\n",[58,2799,2800,2803,2805],{"class":60,"line":79},[58,2801,2802],{"class":2790},"published",[58,2804,2794],{"class":92},[58,2806,2807],{"class":382},"2026-05-31T08:00:00\n",[58,2809,2810,2813,2815],{"class":60,"line":86},[58,2811,2812],{"class":2790},"description",[58,2814,2794],{"class":92},[58,2816,2817],{"class":99},"文章简介\n",[58,2819,2820,2823,2826,2829,2831,2834],{"class":60,"line":96},[58,2821,2822],{"class":2790},"tags",[58,2824,2825],{"class":92},": [",[58,2827,2828],{"class":99},"'Nuxt'",[58,2830,889],{"class":92},[58,2832,2833],{"class":99},"'TypeScript'",[58,2835,1552],{"class":92},[58,2837,2838],{"class":60,"line":109},[58,2839,2785],{"class":75},[11,2841,2842],{"id":2842},"部署",[15,2844,2845],{},"本站使用 SSG 静态生成，构建后直接部署到静态托管即可：",[49,2847,2849],{"className":579,"code":2848,"language":581,"meta":54,"style":54},"npx nuxi generate\n",[19,2850,2851],{"__ignoreMap":54},[58,2852,2853,2855,2858],{"class":60,"line":61},[58,2854,630],{"class":75},[58,2856,2857],{"class":99}," nuxi",[58,2859,2860],{"class":99}," generate\n",[15,2862,2863,2864,2867],{},"构建产物在 ",[19,2865,2866],{},".output\u002Fpublic"," 目录下，可以直接丢到 Nginx、Vercel、Netlify 等平台。",[11,2869,2870],{"id":2870},"后续计划",[2638,2872,2875,2884,2890,2896],{"className":2873},[2874],"contains-task-list",[1052,2876,2879,2883],{"className":2877},[2878],"task-list-item",[2880,2881],"input",{"disabled":82,"type":2882},"checkbox"," RSS 订阅",[1052,2885,2887,2889],{"className":2886},[2878],[2880,2888],{"disabled":82,"type":2882}," 文章目录优化",[1052,2891,2893,2895],{"className":2892},[2878],[2880,2894],{"disabled":82,"type":2882}," 更多小工具",[1052,2897,2899,2901],{"className":2898},[2878],[2880,2900],{"disabled":82,"type":2882}," 持续输出内容",[11,2903,2904],{"id":2904},"致谢",[15,2906,2907,2908,2915],{},"感谢 ",[2909,2910,2914],"a",{"href":2911,"rel":2912},"https:\u002F\u002F2x.nz",[2913],"nofollow","二叉树树"," 的开源项目，本站的 UI 设计参考了他的项目。",[2917,2918],"hr",{},[15,2920,2921],{},[737,2922,2923],{},"感谢阅读！如果觉得不错，欢迎点个 Star 或者留个评论 (☞ﾟヮﾟ)☞",[1066,2925,2926],{},"html pre.shiki code .sHbNN, html code.shiki .sHbNN{--shiki-light:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .siTax, html code.shiki .siTax{--shiki-light:#D73A49;--shiki-dark:#F97583}html pre.shiki code .suQ91, html code.shiki .suQ91{--shiki-light:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sIX_F, html code.shiki .sIX_F{--shiki-light:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .scXbn, html code.shiki .scXbn{--shiki-light:#032F62;--shiki-dark:#9ECBFF}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sw2iP, html code.shiki .sw2iP{--shiki-light:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sbB4o, html code.shiki .sbB4o{--shiki-light:#22863A;--shiki-dark:#85E89D}",{"title":54,"searchDepth":68,"depth":79,"links":2928},[2929,2930,2931,2932,2933,2934,2935],{"id":2539,"depth":68,"text":2540},{"id":2553,"depth":68,"text":2553},{"id":2633,"depth":68,"text":2633},{"id":2702,"depth":68,"text":2702},{"id":2842,"depth":68,"text":2842},{"id":2870,"depth":68,"text":2870},{"id":2904,"depth":68,"text":2904},"博客上线啦！这篇文章介绍本站的技术架构、功能特性和搭建过程中的一些思考。","\u002Fposts\u002Fhello-world\u002Fimg\u002Fcover.jpg",{},"\u002Fposts\u002Fhello-world","2026-05-31T08:00:00",{"title":2534,"description":2936},"Nuxt 博客开发","posts\u002Fhello-world\u002Findex",[2945,2437],"博客","2026-05-31","f9MCboCBfQIUxT8aKv6aKGDFxysiG8ZG0YXr5X5LTFo",{"id":2949,"title":2950,"author":6,"body":2951,"description":4111,"difficulty":1087,"draft":1088,"extension":1089,"image":4112,"meta":4113,"navigation":82,"path":4114,"pinned":1088,"published":4115,"seo":4116,"series":2942,"seriesOrder":68,"stem":4117,"tags":4118,"updated":2946,"__hash__":4121},"posts\u002Fposts\u002Fnuxt-content-lessons\u002Findex.md","Nuxt Content v3 踩坑记录：目录、图片与那些反直觉的设计",{"type":8,"value":2952,"toc":4101},[2953,2956,2959,2962,2966,2969,2979,2982,2989,3055,3062,3064,3071,3341,3351,3355,3358,3361,3386,3400,3403,3426,3432,3435,3438,3441,3446,3453,3678,3683,3727,3731,3734,3741,3744,3794,3800,4028,4038,4040,4092,4095,4098],[2954,2955,13],"h1",{"id":13},[15,2957,2958],{},"最近在用 Nuxt 3 + Nuxt Content v3 搭建一个个人博客，参考了一个 SvelteKit 项目（svaf）的架构。原以为换个框架只是语法差异，没想到在几个关键功能上踩了不少坑。",[15,2960,2961],{},"这篇文章记录了三个核心问题的排查过程和最终方案。",[2954,2963,2965],{"id":2964},"问题一目录导航toc只有-h2","问题一：目录导航（TOC）只有 h2",[11,2967,2968],{"id":2968},"现象",[15,2970,2971,2972,2975,2976,782],{},"Nuxt Content 内置了 TOC 数据，挂在 ",[19,2973,2974],{},"post.body.toc"," 上。直接用它渲染目录，发现",[737,2977,2978],{},"只有 h2 标题，h3 全部丢失",[11,2980,2981],{"id":2981},"排查",[15,2983,2984,2985,2988],{},"查了官方文档，发现 ",[19,2986,2987],{},"build.markdown.toc.depth"," 可以控制深度：",[49,2990,2992],{"className":2343,"code":2991,"language":2345,"meta":54,"style":54},"\u002F\u002F nuxt.config.ts\ncontent: {\n  build: {\n    markdown: {\n      toc: { depth: 3 }  \u002F\u002F 包含 h3\n    }\n  }\n}\n",[19,2993,2994,2999,3006,3013,3020,3042,3046,3051],{"__ignoreMap":54},[58,2995,2996],{"class":60,"line":61},[58,2997,2998],{"class":64},"\u002F\u002F nuxt.config.ts\n",[58,3000,3001,3004],{"class":60,"line":68},[58,3002,3003],{"class":75},"content",[58,3005,830],{"class":92},[58,3007,3008,3011],{"class":60,"line":79},[58,3009,3010],{"class":75},"  build",[58,3012,830],{"class":92},[58,3014,3015,3018],{"class":60,"line":86},[58,3016,3017],{"class":75},"    markdown",[58,3019,830],{"class":92},[58,3021,3022,3025,3028,3031,3033,3036,3039],{"class":60,"line":96},[58,3023,3024],{"class":75},"      toc",[58,3026,3027],{"class":92},": { ",[58,3029,3030],{"class":75},"depth",[58,3032,2794],{"class":92},[58,3034,3035],{"class":382},"3",[58,3037,3038],{"class":92}," }  ",[58,3040,3041],{"class":64},"\u002F\u002F 包含 h3\n",[58,3043,3044],{"class":60,"line":109},[58,3045,451],{"class":92},[58,3047,3048],{"class":60,"line":119},[58,3049,3050],{"class":92},"  }\n",[58,3052,3053],{"class":60,"line":129},[58,3054,457],{"class":92},[15,3056,3057,3058,3061],{},"配置改了，清了缓存重启，",[737,3059,3060],{},"依然只有 h2","。这个配置似乎不起作用。",[11,3063,1383],{"id":1383},[15,3065,3066,3067,3070],{},"放弃依赖 Nuxt Content 的 toc，直接从 ",[19,3068,3069],{},"post.body"," 的 AST 里递归提取所有标题：",[49,3072,3074],{"className":2343,"code":3073,"language":2345,"meta":54,"style":54},"function extractHeadings(node: any, list: Heading[] = []): Heading[] {\n  if (!node) return list\n  if (Array.isArray(node)) {\n    \u002F\u002F minimark 格式：[\"h2\", {id: \"xxx\"}, \"文本\"]\n    if (typeof node[0] === 'string' && \u002F^h[1-6]$\u002F.test(node[0])) {\n      const level = Number(node[0].charAt(1))\n      const text = extractText(node.slice(2)).trim()\n      list.push({ id: node[1]?.id || slugify(text), text, level })\n    }\n    for (const child of node) extractHeadings(child, list)\n  }\n  return list\n}\n",[19,3075,3076,3118,3138,3151,3156,3211,3241,3272,3297,3301,3326,3330,3337],{"__ignoreMap":54},[58,3077,3078,3080,3083,3085,3088,3090,3093,3095,3098,3100,3103,3106,3108,3111,3113,3115],{"class":60,"line":61},[58,3079,2370],{"class":71},[58,3081,3082],{"class":75}," extractHeadings",[58,3084,273],{"class":92},[58,3086,3087],{"class":276},"node",[58,3089,2023],{"class":71},[58,3091,3092],{"class":382}," any",[58,3094,889],{"class":92},[58,3096,3097],{"class":276},"list",[58,3099,2023],{"class":71},[58,3101,3102],{"class":75}," Heading",[58,3104,3105],{"class":92},"[] ",[58,3107,338],{"class":71},[58,3109,3110],{"class":92}," [])",[58,3112,2023],{"class":71},[58,3114,3102],{"class":75},[58,3116,3117],{"class":92},"[] {\n",[58,3119,3120,3123,3126,3129,3132,3135],{"class":60,"line":68},[58,3121,3122],{"class":71},"  if",[58,3124,3125],{"class":92}," (",[58,3127,3128],{"class":71},"!",[58,3130,3131],{"class":92},"node) ",[58,3133,3134],{"class":71},"return",[58,3136,3137],{"class":92}," list\n",[58,3139,3140,3142,3145,3148],{"class":60,"line":79},[58,3141,3122],{"class":71},[58,3143,3144],{"class":92}," (Array.",[58,3146,3147],{"class":75},"isArray",[58,3149,3150],{"class":92},"(node)) {\n",[58,3152,3153],{"class":60,"line":86},[58,3154,3155],{"class":64},"    \u002F\u002F minimark 格式：[\"h2\", {id: \"xxx\"}, \"文本\"]\n",[58,3157,3158,3161,3163,3166,3169,3172,3175,3177,3180,3183,3186,3188,3191,3194,3196,3198,3200,3203,3206,3208],{"class":60,"line":96},[58,3159,3160],{"class":71},"    if",[58,3162,3125],{"class":92},[58,3164,3165],{"class":71},"typeof",[58,3167,3168],{"class":92}," node[",[58,3170,3171],{"class":382},"0",[58,3173,3174],{"class":92},"] ",[58,3176,968],{"class":71},[58,3178,3179],{"class":99}," 'string'",[58,3181,3182],{"class":71}," &&",[58,3184,3185],{"class":99}," \u002F",[58,3187,881],{"class":71},[58,3189,3190],{"class":884},"h",[58,3192,3193],{"class":382},"[1-6]",[58,3195,957],{"class":71},[58,3197,878],{"class":99},[58,3199,202],{"class":92},[58,3201,3202],{"class":75},"test",[58,3204,3205],{"class":92},"(node[",[58,3207,3171],{"class":382},[58,3209,3210],{"class":92},"])) {\n",[58,3212,3213,3216,3219,3221,3224,3226,3228,3231,3234,3236,3238],{"class":60,"line":109},[58,3214,3215],{"class":71},"      const",[58,3217,3218],{"class":382}," level",[58,3220,2728],{"class":71},[58,3222,3223],{"class":75}," Number",[58,3225,3205],{"class":92},[58,3227,3171],{"class":382},[58,3229,3230],{"class":92},"].",[58,3232,3233],{"class":75},"charAt",[58,3235,273],{"class":92},[58,3237,1229],{"class":382},[58,3239,3240],{"class":92},"))\n",[58,3242,3243,3245,3248,3250,3253,3256,3259,3261,3264,3267,3270],{"class":60,"line":119},[58,3244,3215],{"class":71},[58,3246,3247],{"class":382}," text",[58,3249,2728],{"class":71},[58,3251,3252],{"class":75}," extractText",[58,3254,3255],{"class":92},"(node.",[58,3257,3258],{"class":75},"slice",[58,3260,273],{"class":92},[58,3262,3263],{"class":382},"2",[58,3265,3266],{"class":92},")).",[58,3268,3269],{"class":75},"trim",[58,3271,508],{"class":92},[58,3273,3274,3277,3280,3283,3285,3288,3291,3294],{"class":60,"line":129},[58,3275,3276],{"class":92},"      list.",[58,3278,3279],{"class":75},"push",[58,3281,3282],{"class":92},"({ id: node[",[58,3284,1229],{"class":382},[58,3286,3287],{"class":92},"]?.id ",[58,3289,3290],{"class":71},"||",[58,3292,3293],{"class":75}," slugify",[58,3295,3296],{"class":92},"(text), text, level })\n",[58,3298,3299],{"class":60,"line":139},[58,3300,451],{"class":92},[58,3302,3303,3306,3308,3311,3314,3317,3320,3323],{"class":60,"line":149},[58,3304,3305],{"class":71},"    for",[58,3307,3125],{"class":92},[58,3309,3310],{"class":71},"const",[58,3312,3313],{"class":382}," child",[58,3315,3316],{"class":71}," of",[58,3318,3319],{"class":92}," node) ",[58,3321,3322],{"class":75},"extractHeadings",[58,3324,3325],{"class":92},"(child, list)\n",[58,3327,3328],{"class":60,"line":159},[58,3329,3050],{"class":92},[58,3331,3332,3335],{"class":60,"line":164},[58,3333,3334],{"class":71},"  return",[58,3336,3137],{"class":92},[58,3338,3339],{"class":60,"line":174},[58,3340,457],{"class":92},[15,3342,3343,3344,3346,3347,3350],{},"关键点：Nuxt Content v3 的 body 使用 ",[737,3345,8],{}," 格式，标题节点是数组 ",[19,3348,3349],{},"[\"h2\", {id: \"xxx\"}, \"标题文本\"]","，不是对象。",[2954,3352,3354],{"id":3353},"问题二图片路径解析错误","问题二：图片路径解析错误",[11,3356,2968],{"id":3357},"现象-1",[15,3359,3360],{},"markdown 里用相对路径引用图片：",[49,3362,3366],{"className":3363,"code":3364,"language":3365,"meta":54,"style":54},"language-markdown shiki shiki-themes github-light github-dark","![SSHFS挂载效果](img\u002Fsshfs.avif)\n","markdown",[19,3367,3368],{"__ignoreMap":54},[58,3369,3370,3373,3377,3380,3384],{"class":60,"line":61},[58,3371,3372],{"class":92},"![",[58,3374,3376],{"class":3375},"smiIW","SSHFS挂载效果",[58,3378,3379],{"class":92},"](",[58,3381,3383],{"class":3382},"szbl0","img\u002Fsshfs.avif",[58,3385,177],{"class":92},[15,3387,3388,3389,3392,3393,3396,3397,782],{},"HTML 输出的 ",[19,3390,3391],{},"\u003Cimg src=\"img\u002Fsshfs.avif\">"," 看起来没问题，但浏览器请求的却是 ",[19,3394,3395],{},"\u002Fposts\u002Fimg\u002Fsshfs.avif"," 而不是 ",[19,3398,3399],{},"\u002Fposts\u002Fsshfs\u002Fimg\u002Fsshfs.avif",[11,3401,2981],{"id":3402},"排查-1",[1049,3404,3405,3412,3415,3421],{},[1052,3406,3407,3408,3411],{},"检查了 ",[19,3409,3410],{},"\u003Cbase>"," 标签 —— 没有",[1052,3413,3414],{},"检查了重定向 —— 没有",[1052,3416,3417,3418,3420],{},"直接访问 ",[19,3419,3399],{}," —— 返回 200 OK",[1052,3422,3417,3423,3425],{},[19,3424,3395],{}," —— 404",[15,3427,3428,3429,782],{},"服务器路由没问题，问题是",[737,3430,3431],{},"浏览器解析相对路径时基于了错误的基准 URL",[15,3433,3434],{},"这可能和 Nuxt 的客户端路由有关：SPA 导航时，浏览器的\"当前页面\"和 Vue Router 的\"当前路由\"不同步，导致相对路径解析出错。",[11,3436,1383],{"id":3437},"最终方案-1",[15,3439,3440],{},"两步解决：",[15,3442,3443],{},[737,3444,3445],{},"第一步：自定义 ProseImg 组件",[15,3447,3448,3449,3452],{},"创建 ",[19,3450,3451],{},"components\u002Fcontent\u002FProseImg.vue","，覆盖 Nuxt Content 默认的图片渲染：",[49,3454,3458],{"className":3455,"code":3456,"language":3457,"meta":54,"style":54},"language-vue shiki shiki-themes github-light github-dark","\u003Cscript setup>\nconst route = useRoute()\nconst resolvedSrc = computed(() => {\n  if (!props.src) return ''\n  if (props.src.startsWith('\u002F') || props.src.startsWith('http')) return props.src\n  const base = route.path.replace(\u002F\\\u002F$\u002F, '')\n  return `${base}\u002F${props.src}`\n})\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003Cimg :src=\"resolvedSrc\" :alt=\"alt\" \u002F>\n\u003C\u002Ftemplate>\n","vue",[19,3459,3460,3472,3486,3506,3522,3560,3593,3617,3622,3631,3635,3644,3670],{"__ignoreMap":54},[58,3461,3462,3464,3467,3470],{"class":60,"line":61},[58,3463,2026],{"class":92},[58,3465,3466],{"class":2790},"script",[58,3468,3469],{"class":75}," setup",[58,3471,1686],{"class":92},[58,3473,3474,3476,3479,3481,3484],{"class":60,"line":68},[58,3475,3310],{"class":71},[58,3477,3478],{"class":382}," route",[58,3480,2728],{"class":71},[58,3482,3483],{"class":75}," useRoute",[58,3485,508],{"class":92},[58,3487,3488,3490,3493,3495,3498,3501,3504],{"class":60,"line":79},[58,3489,3310],{"class":71},[58,3491,3492],{"class":382}," resolvedSrc",[58,3494,2728],{"class":71},[58,3496,3497],{"class":75}," computed",[58,3499,3500],{"class":92},"(() ",[58,3502,3503],{"class":71},"=>",[58,3505,233],{"class":92},[58,3507,3508,3510,3512,3514,3517,3519],{"class":60,"line":86},[58,3509,3122],{"class":71},[58,3511,3125],{"class":92},[58,3513,3128],{"class":71},[58,3515,3516],{"class":92},"props.src) ",[58,3518,3134],{"class":71},[58,3520,3521],{"class":99}," ''\n",[58,3523,3524,3526,3529,3532,3534,3537,3540,3542,3545,3547,3549,3552,3555,3557],{"class":60,"line":96},[58,3525,3122],{"class":71},[58,3527,3528],{"class":92}," (props.src.",[58,3530,3531],{"class":75},"startsWith",[58,3533,273],{"class":92},[58,3535,3536],{"class":99},"'\u002F'",[58,3538,3539],{"class":92},") ",[58,3541,3290],{"class":71},[58,3543,3544],{"class":92}," props.src.",[58,3546,3531],{"class":75},[58,3548,273],{"class":92},[58,3550,3551],{"class":99},"'http'",[58,3553,3554],{"class":92},")) ",[58,3556,3134],{"class":71},[58,3558,3559],{"class":92}," props.src\n",[58,3561,3562,3565,3568,3570,3573,3575,3577,3579,3583,3585,3587,3589,3591],{"class":60,"line":109},[58,3563,3564],{"class":71},"  const",[58,3566,3567],{"class":382}," base",[58,3569,2728],{"class":71},[58,3571,3572],{"class":92}," route.path.",[58,3574,873],{"class":75},[58,3576,273],{"class":92},[58,3578,878],{"class":99},[58,3580,3582],{"class":3581},"sCnZR","\\\u002F",[58,3584,957],{"class":71},[58,3586,878],{"class":99},[58,3588,889],{"class":92},[58,3590,892],{"class":99},[58,3592,177],{"class":92},[58,3594,3595,3597,3600,3603,3606,3609,3611,3614],{"class":60,"line":119},[58,3596,3334],{"class":71},[58,3598,3599],{"class":99}," `${",[58,3601,3602],{"class":92},"base",[58,3604,3605],{"class":99},"}\u002F${",[58,3607,3608],{"class":92},"props",[58,3610,202],{"class":99},[58,3612,3613],{"class":92},"src",[58,3615,3616],{"class":99},"}`\n",[58,3618,3619],{"class":60,"line":129},[58,3620,3621],{"class":92},"})\n",[58,3623,3624,3627,3629],{"class":60,"line":139},[58,3625,3626],{"class":92},"\u003C\u002F",[58,3628,3466],{"class":2790},[58,3630,1686],{"class":92},[58,3632,3633],{"class":60,"line":149},[58,3634,83],{"emptyLinePlaceholder":82},[58,3636,3637,3639,3642],{"class":60,"line":159},[58,3638,2026],{"class":92},[58,3640,3641],{"class":2790},"template",[58,3643,1686],{"class":92},[58,3645,3646,3649,3651,3654,3656,3659,3662,3664,3667],{"class":60,"line":164},[58,3647,3648],{"class":92},"  \u003C",[58,3650,1363],{"class":2790},[58,3652,3653],{"class":75}," :src",[58,3655,338],{"class":92},[58,3657,3658],{"class":99},"\"resolvedSrc\"",[58,3660,3661],{"class":75}," :alt",[58,3663,338],{"class":92},[58,3665,3666],{"class":99},"\"alt\"",[58,3668,3669],{"class":92}," \u002F>\n",[58,3671,3672,3674,3676],{"class":60,"line":174},[58,3673,3626],{"class":92},[58,3675,3641],{"class":2790},[58,3677,1686],{"class":92},[15,3679,3680],{},[737,3681,3682],{},"第二步：启用 MDC prose 组件",[49,3684,3686],{"className":2343,"code":3685,"language":2345,"meta":54,"style":54},"\u002F\u002F nuxt.config.ts\nmdc: {\n  components: {\n    prose: true  \u002F\u002F 让 Nuxt Content 使用自定义 ProseImg\n  }\n}\n",[19,3687,3688,3692,3699,3706,3719,3723],{"__ignoreMap":54},[58,3689,3690],{"class":60,"line":61},[58,3691,2998],{"class":64},[58,3693,3694,3697],{"class":60,"line":68},[58,3695,3696],{"class":75},"mdc",[58,3698,830],{"class":92},[58,3700,3701,3704],{"class":60,"line":79},[58,3702,3703],{"class":75},"  components",[58,3705,830],{"class":92},[58,3707,3708,3711,3713,3716],{"class":60,"line":86},[58,3709,3710],{"class":75},"    prose",[58,3712,2794],{"class":92},[58,3714,3715],{"class":382},"true",[58,3717,3718],{"class":64},"  \u002F\u002F 让 Nuxt Content 使用自定义 ProseImg\n",[58,3720,3721],{"class":60,"line":96},[58,3722,3050],{"class":92},[58,3724,3725],{"class":60,"line":109},[58,3726,457],{"class":92},[2954,3728,3730],{"id":3729},"问题三content-目录的图片如何服务","问题三：content 目录的图片如何服务",[11,3732,2968],{"id":3733},"现象-2",[15,3735,3736,3737,3740],{},"原站（SvelteKit）的图片放在 ",[19,3738,3739],{},"content\u002Fposts\u002F{slug}\u002Fimg\u002F"," 下，markdown 用相对路径引用，一切正常。Nuxt 项目里，content 目录的文件不会被当成静态资源服务。",[11,3742,3743],{"id":3743},"方案对比",[995,3745,3746,3759],{},[998,3747,3748],{},[1001,3749,3750,3753,3756],{},[1004,3751,3752],{},"方案",[1004,3754,3755],{},"优点",[1004,3757,3758],{},"缺点",[1014,3760,3761,3772,3783],{},[1001,3762,3763,3766,3769],{},[1019,3764,3765],{},"复制到 public\u002F",[1019,3767,3768],{},"简单",[1019,3770,3771],{},"文件重复，需同步",[1001,3773,3774,3777,3780],{},[1019,3775,3776],{},"Vite 插件拦截",[1019,3778,3779],{},"原站方案",[1019,3781,3782],{},"Nuxt 不直接支持",[1001,3784,3785,3788,3791],{},[1019,3786,3787],{},"Server Route",[1019,3789,3790],{},"不复制文件",[1019,3792,3793],{},"需要手动写路由",[15,3795,3796,3797,3799],{},"最终选择 ",[737,3798,3787],{},"，和原站的 Vite 插件思路一致：",[49,3801,3803],{"className":2343,"code":3802,"language":2345,"meta":54,"style":54},"\u002F\u002F server\u002Froutes\u002Fposts\u002F[slug]\u002Fimg\u002F[...file].ts\nexport default defineEventHandler(async (event) => {\n  const url = getRequestURL(event)\n  const match = url.pathname.match(\u002F^\\\u002Fposts\\\u002F([^\u002F]+)\\\u002Fimg\\\u002F(.+)$\u002F)\n  if (!match) return\n\n  const [, slug, filename] = match\n  const filePath = resolve('content\u002Fposts', slug, 'img', filename)\n  const data = await readFile(filePath)\n  setResponseHeader(event, 'content-type', MIME[ext])\n  return data\n})\n",[19,3804,3805,3810,3836,3851,3914,3928,3932,3954,3980,3998,4017,4024],{"__ignoreMap":54},[58,3806,3807],{"class":60,"line":61},[58,3808,3809],{"class":64},"\u002F\u002F server\u002Froutes\u002Fposts\u002F[slug]\u002Fimg\u002F[...file].ts\n",[58,3811,3812,3814,3817,3820,3822,3825,3827,3830,3832,3834],{"class":60,"line":68},[58,3813,1206],{"class":71},[58,3815,3816],{"class":71}," default",[58,3818,3819],{"class":75}," defineEventHandler",[58,3821,273],{"class":92},[58,3823,3824],{"class":71},"async",[58,3826,3125],{"class":92},[58,3828,3829],{"class":276},"event",[58,3831,3539],{"class":92},[58,3833,3503],{"class":71},[58,3835,233],{"class":92},[58,3837,3838,3840,3843,3845,3848],{"class":60,"line":79},[58,3839,3564],{"class":71},[58,3841,3842],{"class":382}," url",[58,3844,2728],{"class":71},[58,3846,3847],{"class":75}," getRequestURL",[58,3849,3850],{"class":92},"(event)\n",[58,3852,3853,3855,3858,3860,3863,3866,3868,3870,3872,3874,3877,3879,3881,3884,3886,3889,3892,3894,3896,3898,3900,3902,3904,3906,3908,3910,3912],{"class":60,"line":86},[58,3854,3564],{"class":71},[58,3856,3857],{"class":382}," match",[58,3859,2728],{"class":71},[58,3861,3862],{"class":92}," url.pathname.",[58,3864,3865],{"class":75},"match",[58,3867,273],{"class":92},[58,3869,878],{"class":99},[58,3871,881],{"class":71},[58,3873,3582],{"class":3581},[58,3875,3876],{"class":884},"posts",[58,3878,3582],{"class":3581},[58,3880,273],{"class":884},[58,3882,3883],{"class":382},"[",[58,3885,881],{"class":71},[58,3887,3888],{"class":382},"\u002F]",[58,3890,3891],{"class":71},"+",[58,3893,895],{"class":884},[58,3895,3582],{"class":3581},[58,3897,1363],{"class":884},[58,3899,3582],{"class":3581},[58,3901,273],{"class":884},[58,3903,202],{"class":382},[58,3905,3891],{"class":71},[58,3907,895],{"class":884},[58,3909,957],{"class":71},[58,3911,878],{"class":99},[58,3913,177],{"class":92},[58,3915,3916,3918,3920,3922,3925],{"class":60,"line":96},[58,3917,3122],{"class":71},[58,3919,3125],{"class":92},[58,3921,3128],{"class":71},[58,3923,3924],{"class":92},"match) ",[58,3926,3927],{"class":71},"return\n",[58,3929,3930],{"class":60,"line":109},[58,3931,83],{"emptyLinePlaceholder":82},[58,3933,3934,3936,3939,3942,3944,3947,3949,3951],{"class":60,"line":119},[58,3935,3564],{"class":71},[58,3937,3938],{"class":92}," [, ",[58,3940,3941],{"class":382},"slug",[58,3943,889],{"class":92},[58,3945,3946],{"class":382},"filename",[58,3948,3174],{"class":92},[58,3950,338],{"class":71},[58,3952,3953],{"class":92}," match\n",[58,3955,3956,3958,3961,3963,3966,3968,3971,3974,3977],{"class":60,"line":129},[58,3957,3564],{"class":71},[58,3959,3960],{"class":382}," filePath",[58,3962,2728],{"class":71},[58,3964,3965],{"class":75}," resolve",[58,3967,273],{"class":92},[58,3969,3970],{"class":99},"'content\u002Fposts'",[58,3972,3973],{"class":92},", slug, ",[58,3975,3976],{"class":99},"'img'",[58,3978,3979],{"class":92},", filename)\n",[58,3981,3982,3984,3987,3989,3992,3995],{"class":60,"line":139},[58,3983,3564],{"class":71},[58,3985,3986],{"class":382}," data",[58,3988,2728],{"class":71},[58,3990,3991],{"class":71}," await",[58,3993,3994],{"class":75}," readFile",[58,3996,3997],{"class":92},"(filePath)\n",[58,3999,4000,4003,4006,4009,4011,4014],{"class":60,"line":149},[58,4001,4002],{"class":75},"  setResponseHeader",[58,4004,4005],{"class":92},"(event, ",[58,4007,4008],{"class":99},"'content-type'",[58,4010,889],{"class":92},[58,4012,4013],{"class":382},"MIME",[58,4015,4016],{"class":92},"[ext])\n",[58,4018,4019,4021],{"class":60,"line":159},[58,4020,3334],{"class":71},[58,4022,4023],{"class":92}," data\n",[58,4025,4026],{"class":60,"line":164},[58,4027,3621],{"class":92},[15,4029,4030,4031,4033,4034,4037],{},"请求 ",[19,4032,3399],{}," → 读取 ",[19,4035,4036],{},"content\u002Fposts\u002Fsshfs\u002Fimg\u002Fsshfs.avif"," → 返回图片。",[2954,4039,1041],{"id":1041},[995,4041,4042,4054],{},[998,4043,4044],{},[1001,4045,4046,4049,4052],{},[1004,4047,4048],{},"问题",[1004,4050,4051],{},"根因",[1004,4053,3752],{},[1014,4055,4056,4070,4081],{},[1001,4057,4058,4061,4067],{},[1019,4059,4060],{},"TOC 只有 h2",[1019,4062,4063,4066],{},[19,4064,4065],{},"toc.depth"," 配置不生效",[1019,4068,4069],{},"从 body AST 手动提取",[1001,4071,4072,4075,4078],{},[1019,4073,4074],{},"图片路径错误",[1019,4076,4077],{},"SPA 路由导致相对路径解析基准错误",[1019,4079,4080],{},"ProseImg 组件 + mdc.prose 配置",[1001,4082,4083,4086,4089],{},[1019,4084,4085],{},"content 图片 404",[1019,4087,4088],{},"content 目录不暴露给浏览器",[1019,4090,4091],{},"Server Route 直接读取返回",[15,4093,4094],{},"Nuxt Content v3 的设计理念是\"图片放 public\u002F，用绝对路径\"。如果想像 SvelteKit 项目那样把图片和文章放一起，需要额外做不少工作。",[15,4096,4097],{},"不过一旦这些坑都踩过，整体开发体验还是很流畅的。Nuxt 的生态系统、自动导入、模块系统都比 SvelteKit 成熟很多。",[1066,4099,4100],{},"html pre.shiki code .sHbNN, html code.shiki .sHbNN{--shiki-light:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sw2iP, html code.shiki .sw2iP{--shiki-light:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sIX_F, html code.shiki .sIX_F{--shiki-light:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .suQ91, html code.shiki .suQ91{--shiki-light:#005CC5;--shiki-dark:#79B8FF}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .siTax, html code.shiki .siTax{--shiki-light:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sPK6S, html code.shiki .sPK6S{--shiki-light:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .scXbn, html code.shiki .scXbn{--shiki-light:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sPKmS, html code.shiki .sPKmS{--shiki-light:#032F62;--shiki-dark:#DBEDFF}html pre.shiki code .sCnZR, html code.shiki .sCnZR{--shiki-light:#22863A;--shiki-light-font-weight:bold;--shiki-dark:#85E89D;--shiki-dark-font-weight:bold}html pre.shiki code .smiIW, html code.shiki .smiIW{--shiki-light:#032F62;--shiki-light-text-decoration:underline;--shiki-dark:#DBEDFF;--shiki-dark-text-decoration:underline}html pre.shiki code .szbl0, html code.shiki .szbl0{--shiki-light:#24292E;--shiki-light-text-decoration:underline;--shiki-dark:#E1E4E8;--shiki-dark-text-decoration:underline}html pre.shiki code .sbB4o, html code.shiki .sbB4o{--shiki-light:#22863A;--shiki-dark:#85E89D}",{"title":54,"searchDepth":68,"depth":79,"links":4102},[4103,4104,4105,4106,4107,4108,4109,4110],{"id":2968,"depth":68,"text":2968},{"id":2981,"depth":68,"text":2981},{"id":1383,"depth":68,"text":1383},{"id":3357,"depth":68,"text":2968},{"id":3402,"depth":68,"text":2981},{"id":3437,"depth":68,"text":1383},{"id":3733,"depth":68,"text":2968},{"id":3743,"depth":68,"text":3743},"从 SvelteKit 迁移到 Nuxt 3 的过程中，遇到了不少 Nuxt Content v3 的坑。这篇文章记录了目录导航、图片路径、ProseImg 组件等问题的排查和解决过程。","\u002Fposts\u002Fnuxt-content-lessons\u002Fimg\u002Fcover.jpg",{},"\u002Fposts\u002Fnuxt-content-lessons","2026-05-31T14:00:00",{"title":2950,"description":4111},"posts\u002Fnuxt-content-lessons\u002Findex",[2437,4119,4120],"踩坑","Nuxt Content","vbSt6ND1z7xt7L8FxgZ8Qe3EdxV9InsRerN3E23GC7s",{"id":4123,"title":4124,"author":2546,"body":4125,"description":4389,"difficulty":1087,"draft":1088,"extension":1089,"image":4390,"meta":4391,"navigation":82,"path":4392,"pinned":1088,"published":4393,"seo":4394,"series":2942,"seriesOrder":79,"stem":4395,"tags":4396,"updated":2946,"__hash__":4399},"posts\u002Fposts\u002Fnuxt-tailwind-underline-fix\u002Findex.md","Nuxt 3 + Tailwind CSS 客户端导航链接下划线闪烁问题",{"type":8,"value":4126,"toc":4382},[4127,4130,4133,4148,4151,4154,4157,4198,4209,4216,4238,4245,4249,4252,4255,4257,4260,4283,4294,4350,4355,4368,4370,4376,4379],[11,4128,4129],{"id":4129},"问题描述",[15,4131,4132],{},"在 Nuxt 3 项目中使用 Tailwind CSS 时，发现一个奇怪的现象：",[2638,4134,4135,4145],{},[1052,4136,4137,4138,4141,4142],{},"首次通过客户端导航（点击 ",[19,4139,4140],{},"\u003CNuxtLink>","）跳转到某个页面时，",[737,4143,4144],{},"所有链接文字都会出现下划线",[1052,4146,4147],{},"手动刷新页面后，下划线消失，样式恢复正常",[15,4149,4150],{},"这个问题在开发模式和生产构建后都会出现。",[11,4152,4153],{"id":4153},"原因分析",[15,4155,4156],{},"Tailwind CSS 的 Preflight（基于 modern-normalize）会重置链接样式：",[49,4158,4162],{"className":4159,"code":4160,"language":4161,"meta":54,"style":54},"language-css shiki shiki-themes github-light github-dark","a {\n  color: inherit;\n  text-decoration: inherit;\n}\n","css",[19,4163,4164,4170,4183,4194],{"__ignoreMap":54},[58,4165,4166,4168],{"class":60,"line":61},[58,4167,2909],{"class":2790},[58,4169,233],{"class":92},[58,4171,4172,4175,4177,4180],{"class":60,"line":68},[58,4173,4174],{"class":382},"  color",[58,4176,2794],{"class":92},[58,4178,4179],{"class":382},"inherit",[58,4181,4182],{"class":92},";\n",[58,4184,4185,4188,4190,4192],{"class":60,"line":79},[58,4186,4187],{"class":382},"  text-decoration",[58,4189,2794],{"class":92},[58,4191,4179],{"class":382},[58,4193,4182],{"class":92},[58,4195,4196],{"class":60,"line":86},[58,4197,457],{"class":92},[15,4199,4200,4201,4204,4205,4208],{},"正常情况下，这会让链接继承父元素的 ",[19,4202,4203],{},"text-decoration","（通常是 ",[19,4206,4207],{},"none","），从而去掉浏览器默认的下划线。",[15,4210,4211,4212,4215],{},"问题出在 ",[737,4213,4214],{},"Nuxt 的客户端导航机制","：",[1049,4217,4218,4224,4227,4232,4235],{},[1052,4219,4220,4221,4223],{},"用户点击 ",[19,4222,4140],{},"，Vue Router 拦截导航",[1052,4225,4226],{},"新页面组件异步加载并渲染",[1052,4228,4229],{},[737,4230,4231],{},"在 CSS 完全加载\u002F应用之前，页面已经渲染了",[1052,4233,4234],{},"此时浏览器使用默认样式（链接带下划线）",[1052,4236,4237],{},"CSS 加载完成后，下划线消失",[15,4239,4240,4241,4244],{},"这就是典型的 ",[737,4242,4243],{},"FOUC（Flash of Unstyled Content）"," 问题。",[11,4246,4248],{"id":4247},"为什么刷新就正常了","为什么刷新就正常了？",[15,4250,4251],{},"刷新页面时，浏览器会等待所有 CSS 加载完成后再渲染页面（SSR 模式下 HTML 和 CSS 是一起返回的）。所以刷新后样式是正确的。",[15,4253,4254],{},"而客户端导航是 JavaScript 驱动的，CSS 的加载和页面的渲染是异步的，就出现了时序差。",[11,4256,1912],{"id":1912},[15,4258,4259],{},"尝试了多种方案：",[2638,4261,4262,4268,4274,4280],{},[1052,4263,4264,4267],{},[19,4265,4266],{},"@layer base"," 覆盖 — 无效，CSS layers 的优先级问题",[1052,4269,4270,4273],{},[19,4271,4272],{},"corePlugins.preflight: false"," — 无效，且破坏其他基础样式",[1052,4275,4276,4279],{},[19,4277,4278],{},"features.inlineStyles: true"," — 无效",[1052,4281,4282],{},"页面过渡动画 — 只是掩盖，不能根治",[15,4284,4285,4286,4289,4290,4293],{},"最终有效的方案是在 ",[19,4287,4288],{},"app.vue"," 中用 ",[19,4291,4292],{},"!important"," 强制覆盖：",[49,4295,4297],{"className":3455,"code":4296,"language":3457,"meta":54,"style":54},"\u003Cstyle>\na:not(.prose a) {\n  text-decoration-line: none !important;\n}\n\u003C\u002Fstyle>\n",[19,4298,4299,4307,4324,4338,4342],{"__ignoreMap":54},[58,4300,4301,4303,4305],{"class":60,"line":61},[58,4302,2026],{"class":92},[58,4304,1066],{"class":2790},[58,4306,1686],{"class":92},[58,4308,4309,4311,4314,4316,4319,4322],{"class":60,"line":68},[58,4310,2909],{"class":2790},[58,4312,4313],{"class":75},":not",[58,4315,273],{"class":92},[58,4317,4318],{"class":75},".prose",[58,4320,4321],{"class":2790}," a",[58,4323,290],{"class":92},[58,4325,4326,4329,4331,4333,4336],{"class":60,"line":79},[58,4327,4328],{"class":382},"  text-decoration-line",[58,4330,2794],{"class":92},[58,4332,4207],{"class":382},[58,4334,4335],{"class":71}," !important",[58,4337,4182],{"class":92},[58,4339,4340],{"class":60,"line":86},[58,4341,457],{"class":92},[58,4343,4344,4346,4348],{"class":60,"line":96},[58,4345,3626],{"class":92},[58,4347,1066],{"class":2790},[58,4349,1686],{"class":92},[15,4351,4352],{},[737,4353,4354],{},"关键点：",[2638,4356,4357,4362],{},[1052,4358,4359,4361],{},[19,4360,4292],{}," 是为了对抗 Tailwind CSS layers 的优先级",[1052,4363,4364,4367],{},[19,4365,4366],{},":not(.prose a)"," 排除文章正文区域，保留文章内链接的下划线（可读性需要）",[11,4369,1041],{"id":1041},[15,4371,4372,4373,4375],{},"这是 Nuxt 3 + Tailwind CSS 的一个已知问题，本质上是 CSS 加载时序导致的 FOUC。",[19,4374,4292],{}," 虽然不够优雅，但在这个场景下是务实的解决方案。",[15,4377,4378],{},"如果你有更好的方案，欢迎在项目仓库的 Issues 中提出！",[1066,4380,4381],{},"html pre.shiki code .sbB4o, html code.shiki .sbB4o{--shiki-light:#22863A;--shiki-dark:#85E89D}html pre.shiki code .sIX_F, html code.shiki .sIX_F{--shiki-light:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .suQ91, html code.shiki .suQ91{--shiki-light:#005CC5;--shiki-dark:#79B8FF}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sw2iP, html code.shiki .sw2iP{--shiki-light:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .siTax, html code.shiki .siTax{--shiki-light:#D73A49;--shiki-dark:#F97583}",{"title":54,"searchDepth":68,"depth":79,"links":4383},[4384,4385,4386,4387,4388],{"id":4129,"depth":68,"text":4129},{"id":4153,"depth":68,"text":4153},{"id":4247,"depth":68,"text":4248},{"id":1912,"depth":68,"text":1912},{"id":1041,"depth":68,"text":1041},"记录 Nuxt 3 配合 Tailwind CSS 时，客户端导航导致链接文字出现下划线闪烁的问题及解决方案。","\u002Fposts\u002Fnuxt-tailwind-underline-fix\u002Fimg\u002Fcover.png",{},"\u002Fposts\u002Fnuxt-tailwind-underline-fix","2026-05-31T18:47:00",{"title":4124,"description":4389},"posts\u002Fnuxt-tailwind-underline-fix\u002Findex",[2437,4397,4398,4119],"Tailwind CSS","CSS","WMLpTEsIoJqufwEfXyIiBgUYxcPY2XkvOwqAD4hbe6g",{"id":4401,"title":4402,"author":6,"body":4403,"description":6429,"difficulty":1087,"draft":1088,"extension":1089,"image":6430,"meta":6431,"navigation":82,"path":6432,"pinned":1088,"published":6433,"seo":6434,"series":1095,"seriesOrder":61,"stem":6435,"tags":6436,"updated":6441,"__hash__":6442},"posts\u002Fposts\u002Fgo-mcp-gin\u002Findex.md","Go-MCP 接入 Gin：从零搭建 MCP 服务器",{"type":8,"value":4404,"toc":6410},[4405,4409,4412,4415,4419,4426,4432,4435,4438,4458,4461,4465,4489,4493,4590,4603,4607,4768,4771,4984,4988,4999,5098,5105,5109,5378,5381,5461,5465,5468,5470,5481,5485,5491,5851,6039,6042,6098,6101,6254,6257,6260,6331,6333,6398,6407],[11,4406,4408],{"id":4407},"什么是-mcp","什么是 MCP",[15,4410,4411],{},"MCP（Model Context Protocol）是 Anthropic 提出的开放协议，用于让 AI 助手（如 Claude）与外部服务进行标准化交互。简单来说：你提供一个 MCP 服务器，AI 就能发现并调用你定义的工具（Tools），就像给 AI 装了一双手。",[15,4413,4414],{},"传统做法是写 REST API 然后让 AI 通过函数调用（Function Calling）间接使用，但 MCP 统一了协议层，AI 客户端只需要配置一个 URL 就能接入所有工具。",[11,4416,4418],{"id":4417},"为什么选-gin-mcp-go","为什么选 Gin + mcp-go",[15,4420,4421,4422,4425],{},"Go 生态里做 MCP 服务器最成熟的库是 ",[19,4423,4424],{},"mark3labs\u002Fmcp-go","，它支持 StreamableHTTP 传输模式，可以直接挂载到任意 HTTP 框架上。Gin 作为 Go 最流行的 HTTP 框架，天然适配。",[15,4427,4428,4429],{},"核心思路：",[737,4430,4431],{},"MCP 服务器本质上就是一个 HTTP Handler，挂到 Gin 路由上即可。",[11,4433,4434],{"id":4434},"需求分析",[15,4436,4437],{},"我们要搭建一个具备以下能力的 MCP 服务器：",[1049,4439,4440,4446,4452],{},[1052,4441,4442,4445],{},[737,4443,4444],{},"工具注册"," — 向 AI 暴露可调用的工具（列出资源、查看详情、创建\u002F删除）",[1052,4447,4448,4451],{},[737,4449,4450],{},"HTTP 集成"," — MCP 端点作为 Gin 路由的一部分，与业务 API 共存",[1052,4453,4454,4457],{},[737,4455,4456],{},"鉴权"," — MCP 请求必须经过身份验证，工具处理器能获取当前用户信息",[11,4459,4460],{"id":4460},"实现步骤",[44,4462,4464],{"id":4463},"_1-安装依赖","1. 安装依赖",[49,4466,4468],{"className":579,"code":4467,"language":581,"meta":54,"style":54},"go get github.com\u002Fmark3labs\u002Fmcp-go@v0.54.0\ngo get github.com\u002Fgin-gonic\u002Fgin@v1.12.0\n",[19,4469,4470,4480],{"__ignoreMap":54},[58,4471,4472,4474,4477],{"class":60,"line":61},[58,4473,53],{"class":75},[58,4475,4476],{"class":99}," get",[58,4478,4479],{"class":99}," github.com\u002Fmark3labs\u002Fmcp-go@v0.54.0\n",[58,4481,4482,4484,4486],{"class":60,"line":68},[58,4483,53],{"class":75},[58,4485,4476],{"class":99},[58,4487,4488],{"class":99}," github.com\u002Fgin-gonic\u002Fgin@v1.12.0\n",[44,4490,4492],{"id":4491},"_2-创建-mcp-服务器","2. 创建 MCP 服务器",[49,4494,4496],{"className":51,"code":4495,"language":53,"meta":54,"style":54},"import \"github.com\u002Fmark3labs\u002Fmcp-go\u002Fserver\"\n\nmcpServer := server.NewMCPServer(\n    \"my-app\",    \u002F\u002F 服务器名称\n    \"1.0.0\",     \u002F\u002F 版本\n    server.WithLogging(),\n    server.WithRecovery(),\n    server.WithInstructions(\"这是一个 MCP 服务器示例\"),\n)\n",[19,4497,4498,4509,4513,4529,4540,4551,4562,4571,4586],{"__ignoreMap":54},[58,4499,4500,4502,4504,4507],{"class":60,"line":61},[58,4501,89],{"class":71},[58,4503,478],{"class":99},[58,4505,4506],{"class":75},"github.com\u002Fmark3labs\u002Fmcp-go\u002Fserver",[58,4508,106],{"class":99},[58,4510,4511],{"class":60,"line":68},[58,4512,83],{"emptyLinePlaceholder":82},[58,4514,4515,4518,4520,4523,4526],{"class":60,"line":79},[58,4516,4517],{"class":92},"mcpServer ",[58,4519,242],{"class":71},[58,4521,4522],{"class":92}," server.",[58,4524,4525],{"class":75},"NewMCPServer",[58,4527,4528],{"class":92},"(\n",[58,4530,4531,4534,4537],{"class":60,"line":86},[58,4532,4533],{"class":99},"    \"my-app\"",[58,4535,4536],{"class":92},",    ",[58,4538,4539],{"class":64},"\u002F\u002F 服务器名称\n",[58,4541,4542,4545,4548],{"class":60,"line":96},[58,4543,4544],{"class":99},"    \"1.0.0\"",[58,4546,4547],{"class":92},",     ",[58,4549,4550],{"class":64},"\u002F\u002F 版本\n",[58,4552,4553,4556,4559],{"class":60,"line":109},[58,4554,4555],{"class":92},"    server.",[58,4557,4558],{"class":75},"WithLogging",[58,4560,4561],{"class":92},"(),\n",[58,4563,4564,4566,4569],{"class":60,"line":119},[58,4565,4555],{"class":92},[58,4567,4568],{"class":75},"WithRecovery",[58,4570,4561],{"class":92},[58,4572,4573,4575,4578,4580,4583],{"class":60,"line":129},[58,4574,4555],{"class":92},[58,4576,4577],{"class":75},"WithInstructions",[58,4579,273],{"class":92},[58,4581,4582],{"class":99},"\"这是一个 MCP 服务器示例\"",[58,4584,4585],{"class":92},"),\n",[58,4587,4588],{"class":60,"line":139},[58,4589,177],{"class":92},[15,4591,4592,4594,4595,4598,4599,4602],{},[19,4593,4525],{}," 创建一个 MCP 协议实例，它负责处理工具发现（",[19,4596,4597],{},"tools\u002Flist","）和工具调用（",[19,4600,4601],{},"tools\u002Fcall","）的协议逻辑。",[44,4604,4606],{"id":4605},"_3-注册工具","3. 注册工具",[49,4608,4610],{"className":51,"code":4609,"language":53,"meta":54,"style":54},"import \"github.com\u002Fmark3labs\u002Fmcp-go\u002Fmcp\"\n\n\u002F\u002F 定义工具\nlistTool := mcp.NewTool(\"list_items\",\n    mcp.WithDescription(\"列出所有项目\"),\n)\n\n\u002F\u002F 注册处理器\nmcpServer.AddTool(listTool, func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {\n    \u002F\u002F 业务逻辑\n    return mcp.NewToolResultText(\"返回结果\"), nil\n})\n",[19,4611,4612,4623,4627,4632,4652,4667,4671,4675,4680,4739,4744,4764],{"__ignoreMap":54},[58,4613,4614,4616,4618,4621],{"class":60,"line":61},[58,4615,89],{"class":71},[58,4617,478],{"class":99},[58,4619,4620],{"class":75},"github.com\u002Fmark3labs\u002Fmcp-go\u002Fmcp",[58,4622,106],{"class":99},[58,4624,4625],{"class":60,"line":68},[58,4626,83],{"emptyLinePlaceholder":82},[58,4628,4629],{"class":60,"line":79},[58,4630,4631],{"class":64},"\u002F\u002F 定义工具\n",[58,4633,4634,4637,4639,4642,4645,4647,4650],{"class":60,"line":86},[58,4635,4636],{"class":92},"listTool ",[58,4638,242],{"class":71},[58,4640,4641],{"class":92}," mcp.",[58,4643,4644],{"class":75},"NewTool",[58,4646,273],{"class":92},[58,4648,4649],{"class":99},"\"list_items\"",[58,4651,2741],{"class":92},[58,4653,4654,4657,4660,4662,4665],{"class":60,"line":96},[58,4655,4656],{"class":92},"    mcp.",[58,4658,4659],{"class":75},"WithDescription",[58,4661,273],{"class":92},[58,4663,4664],{"class":99},"\"列出所有项目\"",[58,4666,4585],{"class":92},[58,4668,4669],{"class":60,"line":109},[58,4670,177],{"class":92},[58,4672,4673],{"class":60,"line":119},[58,4674,83],{"emptyLinePlaceholder":82},[58,4676,4677],{"class":60,"line":129},[58,4678,4679],{"class":64},"\u002F\u002F 注册处理器\n",[58,4681,4682,4685,4688,4691,4693,4695,4698,4701,4703,4705,4707,4710,4713,4715,4718,4721,4724,4727,4729,4732,4734,4737],{"class":60,"line":139},[58,4683,4684],{"class":92},"mcpServer.",[58,4686,4687],{"class":75},"AddTool",[58,4689,4690],{"class":92},"(listTool, ",[58,4692,216],{"class":71},[58,4694,273],{"class":92},[58,4696,4697],{"class":276},"ctx",[58,4699,4700],{"class":75}," context",[58,4702,202],{"class":92},[58,4704,287],{"class":75},[58,4706,889],{"class":92},[58,4708,4709],{"class":276},"req",[58,4711,4712],{"class":75}," mcp",[58,4714,202],{"class":92},[58,4716,4717],{"class":75},"CallToolRequest",[58,4719,4720],{"class":92},") (",[58,4722,4723],{"class":71},"*",[58,4725,4726],{"class":75},"mcp",[58,4728,202],{"class":92},[58,4730,4731],{"class":75},"CallToolResult",[58,4733,889],{"class":92},[58,4735,4736],{"class":71},"error",[58,4738,290],{"class":92},[58,4740,4741],{"class":60,"line":149},[58,4742,4743],{"class":64},"    \u002F\u002F 业务逻辑\n",[58,4745,4746,4748,4750,4753,4755,4758,4761],{"class":60,"line":159},[58,4747,267],{"class":71},[58,4749,4641],{"class":92},[58,4751,4752],{"class":75},"NewToolResultText",[58,4754,273],{"class":92},[58,4756,4757],{"class":99},"\"返回结果\"",[58,4759,4760],{"class":92},"), ",[58,4762,4763],{"class":382},"nil\n",[58,4765,4766],{"class":60,"line":164},[58,4767,3621],{"class":92},[15,4769,4770],{},"带参数的工具：",[49,4772,4774],{"className":51,"code":4773,"language":53,"meta":54,"style":54},"getTool := mcp.NewTool(\"get_item\",\n    mcp.WithDescription(\"获取指定项目的详情\"),\n    mcp.WithString(\"item_id\",\n        mcp.Required(),\n        mcp.Description(\"项目 ID\"),\n    ),\n)\n\nmcpServer.AddTool(getTool, func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {\n    itemID, err := req.RequireString(\"item_id\")\n    if err != nil {\n        return mcp.NewToolResultError(\"缺少 item_id 参数\"), nil\n    }\n    \u002F\u002F 查询并返回\n    return mcp.NewToolResultText(\"项目详情...\"), nil\n})\n",[19,4775,4776,4794,4807,4821,4831,4845,4850,4854,4858,4905,4924,4936,4954,4958,4963,4980],{"__ignoreMap":54},[58,4777,4778,4781,4783,4785,4787,4789,4792],{"class":60,"line":61},[58,4779,4780],{"class":92},"getTool ",[58,4782,242],{"class":71},[58,4784,4641],{"class":92},[58,4786,4644],{"class":75},[58,4788,273],{"class":92},[58,4790,4791],{"class":99},"\"get_item\"",[58,4793,2741],{"class":92},[58,4795,4796,4798,4800,4802,4805],{"class":60,"line":68},[58,4797,4656],{"class":92},[58,4799,4659],{"class":75},[58,4801,273],{"class":92},[58,4803,4804],{"class":99},"\"获取指定项目的详情\"",[58,4806,4585],{"class":92},[58,4808,4809,4811,4814,4816,4819],{"class":60,"line":79},[58,4810,4656],{"class":92},[58,4812,4813],{"class":75},"WithString",[58,4815,273],{"class":92},[58,4817,4818],{"class":99},"\"item_id\"",[58,4820,2741],{"class":92},[58,4822,4823,4826,4829],{"class":60,"line":86},[58,4824,4825],{"class":92},"        mcp.",[58,4827,4828],{"class":75},"Required",[58,4830,4561],{"class":92},[58,4832,4833,4835,4838,4840,4843],{"class":60,"line":96},[58,4834,4825],{"class":92},[58,4836,4837],{"class":75},"Description",[58,4839,273],{"class":92},[58,4841,4842],{"class":99},"\"项目 ID\"",[58,4844,4585],{"class":92},[58,4846,4847],{"class":60,"line":109},[58,4848,4849],{"class":92},"    ),\n",[58,4851,4852],{"class":60,"line":119},[58,4853,177],{"class":92},[58,4855,4856],{"class":60,"line":129},[58,4857,83],{"emptyLinePlaceholder":82},[58,4859,4860,4862,4864,4867,4869,4871,4873,4875,4877,4879,4881,4883,4885,4887,4889,4891,4893,4895,4897,4899,4901,4903],{"class":60,"line":139},[58,4861,4684],{"class":92},[58,4863,4687],{"class":75},[58,4865,4866],{"class":92},"(getTool, ",[58,4868,216],{"class":71},[58,4870,273],{"class":92},[58,4872,4697],{"class":276},[58,4874,4700],{"class":75},[58,4876,202],{"class":92},[58,4878,287],{"class":75},[58,4880,889],{"class":92},[58,4882,4709],{"class":276},[58,4884,4712],{"class":75},[58,4886,202],{"class":92},[58,4888,4717],{"class":75},[58,4890,4720],{"class":92},[58,4892,4723],{"class":71},[58,4894,4726],{"class":75},[58,4896,202],{"class":92},[58,4898,4731],{"class":75},[58,4900,889],{"class":92},[58,4902,4736],{"class":71},[58,4904,290],{"class":92},[58,4906,4907,4910,4912,4915,4918,4920,4922],{"class":60,"line":149},[58,4908,4909],{"class":92},"    itemID, err ",[58,4911,242],{"class":71},[58,4913,4914],{"class":92}," req.",[58,4916,4917],{"class":75},"RequireString",[58,4919,273],{"class":92},[58,4921,4818],{"class":99},[58,4923,177],{"class":92},[58,4925,4926,4928,4930,4932,4934],{"class":60,"line":159},[58,4927,3160],{"class":71},[58,4929,376],{"class":92},[58,4931,379],{"class":71},[58,4933,383],{"class":382},[58,4935,233],{"class":92},[58,4937,4938,4940,4942,4945,4947,4950,4952],{"class":60,"line":164},[58,4939,857],{"class":71},[58,4941,4641],{"class":92},[58,4943,4944],{"class":75},"NewToolResultError",[58,4946,273],{"class":92},[58,4948,4949],{"class":99},"\"缺少 item_id 参数\"",[58,4951,4760],{"class":92},[58,4953,4763],{"class":382},[58,4955,4956],{"class":60,"line":174},[58,4957,451],{"class":92},[58,4959,4960],{"class":60,"line":180},[58,4961,4962],{"class":64},"    \u002F\u002F 查询并返回\n",[58,4964,4965,4967,4969,4971,4973,4976,4978],{"class":60,"line":185},[58,4966,267],{"class":71},[58,4968,4641],{"class":92},[58,4970,4752],{"class":75},[58,4972,273],{"class":92},[58,4974,4975],{"class":99},"\"项目详情...\"",[58,4977,4760],{"class":92},[58,4979,4763],{"class":382},[58,4981,4982],{"class":60,"line":191},[58,4983,3621],{"class":92},[44,4985,4987],{"id":4986},"_4-挂载到-gin-路由","4. 挂载到 Gin 路由",[15,4989,4990,4991,4994,4995,4998],{},"关键一步：用 ",[19,4992,4993],{},"server.NewStreamableHTTPServer"," 把 MCP 实例转成 HTTP Handler，再用 ",[19,4996,4997],{},"gin.WrapH"," 适配 Gin。",[49,5000,5002],{"className":51,"code":5001,"language":53,"meta":54,"style":54},"import \"github.com\u002Fmark3labs\u002Fmcp-go\u002Fserver\"\n\nmcpHTTPServer := server.NewStreamableHTTPServer(mcpServer,\n    server.WithEndpointPath(\"\u002Fmcp\"),\n    server.WithHeartbeatInterval(30*time.Second),\n)\n\n\u002F\u002F 挂载到 Gin\nr.Any(\"\u002Fmcp\", gin.WrapH(mcpHTTPServer))\n",[19,5003,5004,5014,5018,5033,5047,5064,5068,5072,5077],{"__ignoreMap":54},[58,5005,5006,5008,5010,5012],{"class":60,"line":61},[58,5007,89],{"class":71},[58,5009,478],{"class":99},[58,5011,4506],{"class":75},[58,5013,106],{"class":99},[58,5015,5016],{"class":60,"line":68},[58,5017,83],{"emptyLinePlaceholder":82},[58,5019,5020,5023,5025,5027,5030],{"class":60,"line":79},[58,5021,5022],{"class":92},"mcpHTTPServer ",[58,5024,242],{"class":71},[58,5026,4522],{"class":92},[58,5028,5029],{"class":75},"NewStreamableHTTPServer",[58,5031,5032],{"class":92},"(mcpServer,\n",[58,5034,5035,5037,5040,5042,5045],{"class":60,"line":86},[58,5036,4555],{"class":92},[58,5038,5039],{"class":75},"WithEndpointPath",[58,5041,273],{"class":92},[58,5043,5044],{"class":99},"\"\u002Fmcp\"",[58,5046,4585],{"class":92},[58,5048,5049,5051,5054,5056,5059,5061],{"class":60,"line":96},[58,5050,4555],{"class":92},[58,5052,5053],{"class":75},"WithHeartbeatInterval",[58,5055,273],{"class":92},[58,5057,5058],{"class":382},"30",[58,5060,4723],{"class":71},[58,5062,5063],{"class":92},"time.Second),\n",[58,5065,5066],{"class":60,"line":109},[58,5067,177],{"class":92},[58,5069,5070],{"class":60,"line":119},[58,5071,83],{"emptyLinePlaceholder":82},[58,5073,5074],{"class":60,"line":129},[58,5075,5076],{"class":64},"\u002F\u002F 挂载到 Gin\n",[58,5078,5079,5082,5085,5087,5089,5092,5095],{"class":60,"line":139},[58,5080,5081],{"class":92},"r.",[58,5083,5084],{"class":75},"Any",[58,5086,273],{"class":92},[58,5088,5044],{"class":99},[58,5090,5091],{"class":92},", gin.",[58,5093,5094],{"class":75},"WrapH",[58,5096,5097],{"class":92},"(mcpHTTPServer))\n",[15,5099,5100,5101,5104],{},"就这样，",[19,5102,5103],{},"POST \u002Fmcp"," 端点就生效了。AI 客户端通过这个 URL 发送 JSON-RPC 请求，MCP 服务器自动处理协议握手、工具列表返回、工具调用路由。",[44,5106,5108],{"id":5107},"_5-完整的服务启动代码","5. 完整的服务启动代码",[49,5110,5112],{"className":51,"code":5111,"language":53,"meta":54,"style":54},"func main() {\n    r := gin.Default()\n\n    \u002F\u002F 创建 MCP 服务器\n    mcpServer := server.NewMCPServer(\"my-app\", \"1.0.0\",\n        server.WithLogging(),\n        server.WithRecovery(),\n    )\n\n    \u002F\u002F 注册工具\n    registerTools(mcpServer)\n\n    \u002F\u002F 挂载到 Gin\n    mcpHTTP := server.NewStreamableHTTPServer(mcpServer,\n        server.WithEndpointPath(\"\u002Fmcp\"),\n        server.WithHeartbeatInterval(30*time.Second),\n    )\n    r.Any(\"\u002Fmcp\", gin.WrapH(mcpHTTP))\n\n    \u002F\u002F 其他业务路由\n    r.GET(\"\u002Fapi\u002Fhealth\", func(c *gin.Context) {\n        c.JSON(200, gin.H{\"status\": \"ok\"})\n    })\n\n    r.Run(\":8080\")\n}\n",[19,5113,5114,5123,5138,5142,5147,5170,5179,5187,5192,5196,5201,5209,5213,5218,5231,5243,5257,5261,5279,5283,5288,5317,5351,5356,5360,5374],{"__ignoreMap":54},[58,5115,5116,5118,5121],{"class":60,"line":61},[58,5117,216],{"class":71},[58,5119,5120],{"class":75}," main",[58,5122,2376],{"class":92},[58,5124,5125,5128,5130,5133,5136],{"class":60,"line":68},[58,5126,5127],{"class":92},"    r ",[58,5129,242],{"class":71},[58,5131,5132],{"class":92}," gin.",[58,5134,5135],{"class":75},"Default",[58,5137,508],{"class":92},[58,5139,5140],{"class":60,"line":79},[58,5141,83],{"emptyLinePlaceholder":82},[58,5143,5144],{"class":60,"line":86},[58,5145,5146],{"class":64},"    \u002F\u002F 创建 MCP 服务器\n",[58,5148,5149,5152,5154,5156,5158,5160,5163,5165,5168],{"class":60,"line":96},[58,5150,5151],{"class":92},"    mcpServer ",[58,5153,242],{"class":71},[58,5155,4522],{"class":92},[58,5157,4525],{"class":75},[58,5159,273],{"class":92},[58,5161,5162],{"class":99},"\"my-app\"",[58,5164,889],{"class":92},[58,5166,5167],{"class":99},"\"1.0.0\"",[58,5169,2741],{"class":92},[58,5171,5172,5175,5177],{"class":60,"line":109},[58,5173,5174],{"class":92},"        server.",[58,5176,4558],{"class":75},[58,5178,4561],{"class":92},[58,5180,5181,5183,5185],{"class":60,"line":119},[58,5182,5174],{"class":92},[58,5184,4568],{"class":75},[58,5186,4561],{"class":92},[58,5188,5189],{"class":60,"line":129},[58,5190,5191],{"class":92},"    )\n",[58,5193,5194],{"class":60,"line":139},[58,5195,83],{"emptyLinePlaceholder":82},[58,5197,5198],{"class":60,"line":149},[58,5199,5200],{"class":64},"    \u002F\u002F 注册工具\n",[58,5202,5203,5206],{"class":60,"line":159},[58,5204,5205],{"class":75},"    registerTools",[58,5207,5208],{"class":92},"(mcpServer)\n",[58,5210,5211],{"class":60,"line":164},[58,5212,83],{"emptyLinePlaceholder":82},[58,5214,5215],{"class":60,"line":174},[58,5216,5217],{"class":64},"    \u002F\u002F 挂载到 Gin\n",[58,5219,5220,5223,5225,5227,5229],{"class":60,"line":180},[58,5221,5222],{"class":92},"    mcpHTTP ",[58,5224,242],{"class":71},[58,5226,4522],{"class":92},[58,5228,5029],{"class":75},[58,5230,5032],{"class":92},[58,5232,5233,5235,5237,5239,5241],{"class":60,"line":185},[58,5234,5174],{"class":92},[58,5236,5039],{"class":75},[58,5238,273],{"class":92},[58,5240,5044],{"class":99},[58,5242,4585],{"class":92},[58,5244,5245,5247,5249,5251,5253,5255],{"class":60,"line":191},[58,5246,5174],{"class":92},[58,5248,5053],{"class":75},[58,5250,273],{"class":92},[58,5252,5058],{"class":382},[58,5254,4723],{"class":71},[58,5256,5063],{"class":92},[58,5258,5259],{"class":60,"line":208},[58,5260,5191],{"class":92},[58,5262,5263,5266,5268,5270,5272,5274,5276],{"class":60,"line":213},[58,5264,5265],{"class":92},"    r.",[58,5267,5084],{"class":75},[58,5269,273],{"class":92},[58,5271,5044],{"class":99},[58,5273,5091],{"class":92},[58,5275,5094],{"class":75},[58,5277,5278],{"class":92},"(mcpHTTP))\n",[58,5280,5281],{"class":60,"line":236},[58,5282,83],{"emptyLinePlaceholder":82},[58,5284,5285],{"class":60,"line":259},[58,5286,5287],{"class":64},"    \u002F\u002F 其他业务路由\n",[58,5289,5290,5292,5294,5296,5299,5301,5303,5305,5307,5309,5311,5313,5315],{"class":60,"line":264},[58,5291,5265],{"class":92},[58,5293,516],{"class":75},[58,5295,273],{"class":92},[58,5297,5298],{"class":99},"\"\u002Fapi\u002Fhealth\"",[58,5300,889],{"class":92},[58,5302,216],{"class":71},[58,5304,273],{"class":92},[58,5306,277],{"class":276},[58,5308,280],{"class":71},[58,5310,225],{"class":75},[58,5312,202],{"class":92},[58,5314,287],{"class":75},[58,5316,290],{"class":92},[58,5318,5319,5321,5324,5326,5329,5331,5333,5335,5338,5341,5344,5346,5349],{"class":60,"line":293},[58,5320,427],{"class":92},[58,5322,5323],{"class":75},"JSON",[58,5325,273],{"class":92},[58,5327,5328],{"class":382},"200",[58,5330,889],{"class":92},[58,5332,225],{"class":75},[58,5334,202],{"class":92},[58,5336,5337],{"class":75},"H",[58,5339,5340],{"class":92},"{",[58,5342,5343],{"class":99},"\"status\"",[58,5345,2794],{"class":92},[58,5347,5348],{"class":99},"\"ok\"",[58,5350,3621],{"class":92},[58,5352,5353],{"class":60,"line":315},[58,5354,5355],{"class":92},"    })\n",[58,5357,5358],{"class":60,"line":332},[58,5359,83],{"emptyLinePlaceholder":82},[58,5361,5362,5364,5367,5369,5372],{"class":60,"line":344},[58,5363,5265],{"class":92},[58,5365,5366],{"class":75},"Run",[58,5368,273],{"class":92},[58,5370,5371],{"class":99},"\":8080\"",[58,5373,177],{"class":92},[58,5375,5376],{"class":60,"line":350},[58,5377,457],{"class":92},[15,5379,5380],{},"此时启动服务，用 curl 测试：",[49,5382,5384],{"className":579,"code":5383,"language":581,"meta":54,"style":54},"# 发现工具列表\ncurl -X POST http:\u002F\u002Flocalhost:8080\u002Fmcp \\\n  -H \"Content-Type: application\u002Fjson\" \\\n  -d '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools\u002Flist\"}'\n\n# 调用工具\ncurl -X POST http:\u002F\u002Flocalhost:8080\u002Fmcp \\\n  -H \"Content-Type: application\u002Fjson\" \\\n  -d '{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools\u002Fcall\",\"params\":{\"name\":\"list_items\",\"arguments\":{}}}'\n",[19,5385,5386,5391,5407,5417,5425,5429,5434,5446,5454],{"__ignoreMap":54},[58,5387,5388],{"class":60,"line":61},[58,5389,5390],{"class":64},"# 发现工具列表\n",[58,5392,5393,5396,5399,5402,5405],{"class":60,"line":68},[58,5394,5395],{"class":75},"curl",[58,5397,5398],{"class":382}," -X",[58,5400,5401],{"class":99}," POST",[58,5403,5404],{"class":99}," http:\u002F\u002Flocalhost:8080\u002Fmcp",[58,5406,702],{"class":382},[58,5408,5409,5412,5415],{"class":60,"line":79},[58,5410,5411],{"class":382},"  -H",[58,5413,5414],{"class":99}," \"Content-Type: application\u002Fjson\"",[58,5416,702],{"class":382},[58,5418,5419,5422],{"class":60,"line":86},[58,5420,5421],{"class":382},"  -d",[58,5423,5424],{"class":99}," '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools\u002Flist\"}'\n",[58,5426,5427],{"class":60,"line":96},[58,5428,83],{"emptyLinePlaceholder":82},[58,5430,5431],{"class":60,"line":109},[58,5432,5433],{"class":64},"# 调用工具\n",[58,5435,5436,5438,5440,5442,5444],{"class":60,"line":119},[58,5437,5395],{"class":75},[58,5439,5398],{"class":382},[58,5441,5401],{"class":99},[58,5443,5404],{"class":99},[58,5445,702],{"class":382},[58,5447,5448,5450,5452],{"class":60,"line":129},[58,5449,5411],{"class":382},[58,5451,5414],{"class":99},[58,5453,702],{"class":382},[58,5455,5456,5458],{"class":60,"line":139},[58,5457,5421],{"class":382},[58,5459,5460],{"class":99}," '{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools\u002Fcall\",\"params\":{\"name\":\"list_items\",\"arguments\":{}}}'\n",[11,5462,5464],{"id":5463},"拓展接入鉴权","拓展：接入鉴权",[15,5466,5467],{},"裸奔的 MCP 端点任何人都能调用，生产环境必须加鉴权。思路和普通 API 鉴权一致：Bearer Token。",[44,5469,4048],{"id":4048},[15,5471,5472,5473,5476,5477,5480],{},"MCP 的工具处理器签名为 ",[19,5474,5475],{},"func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error)","，它不接收 Gin 的 ",[19,5478,5479],{},"*gin.Context","，所以无法直接在工具处理器里读取请求头。",[44,5482,5484],{"id":5483},"解决方案中间件传递用户信息","解决方案：中间件传递用户信息",[15,5486,5487,5488,782],{},"用两层中间件：第一层做 Gin 的 Token 验证，第二层把用户信息从 Gin Context 复制到 ",[19,5489,5490],{},"request.Context",[49,5492,5494],{"className":51,"code":5493,"language":53,"meta":54,"style":54},"\u002F\u002F Gin 鉴权中间件\nfunc AuthRequired(authService *AuthService) gin.HandlerFunc {\n    return func(c *gin.Context) {\n        authHeader := c.GetHeader(\"Authorization\")\n        if authHeader == \"\" {\n            c.AbortWithStatusJSON(401, gin.H{\"message\": \"缺少认证令牌\"})\n            return\n        }\n\n        parts := strings.SplitN(authHeader, \" \", 2)\n        if len(parts) != 2 || !strings.EqualFold(parts[0], \"Bearer\") {\n            c.AbortWithStatusJSON(401, gin.H{\"message\": \"格式错误，请使用 Bearer \u003Ctoken>\"})\n            return\n        }\n\n        user, err := authService.GetUserByToken(parts[1])\n        if err != nil {\n            c.AbortWithStatusJSON(401, gin.H{\"message\": \"无效的认证令牌\"})\n            return\n        }\n\n        c.Set(\"userID\", user.ID)\n        c.Set(\"username\", user.Username)\n        c.Next()\n    }\n}\n",[19,5495,5496,5501,5528,5548,5568,5581,5614,5619,5623,5627,5651,5691,5720,5724,5728,5732,5752,5764,5793,5797,5801,5805,5820,5834,5843,5847],{"__ignoreMap":54},[58,5497,5498],{"class":60,"line":61},[58,5499,5500],{"class":64},"\u002F\u002F Gin 鉴权中间件\n",[58,5502,5503,5505,5508,5510,5513,5515,5518,5520,5522,5524,5526],{"class":60,"line":68},[58,5504,216],{"class":71},[58,5506,5507],{"class":75}," AuthRequired",[58,5509,273],{"class":92},[58,5511,5512],{"class":276},"authService",[58,5514,280],{"class":71},[58,5516,5517],{"class":75},"AuthService",[58,5519,3539],{"class":92},[58,5521,225],{"class":75},[58,5523,202],{"class":92},[58,5525,230],{"class":75},[58,5527,233],{"class":92},[58,5529,5530,5532,5534,5536,5538,5540,5542,5544,5546],{"class":60,"line":79},[58,5531,267],{"class":71},[58,5533,270],{"class":71},[58,5535,273],{"class":92},[58,5537,277],{"class":276},[58,5539,280],{"class":71},[58,5541,225],{"class":75},[58,5543,202],{"class":92},[58,5545,287],{"class":75},[58,5547,290],{"class":92},[58,5549,5550,5553,5555,5558,5561,5563,5566],{"class":60,"line":86},[58,5551,5552],{"class":92},"        authHeader ",[58,5554,242],{"class":71},[58,5556,5557],{"class":92}," c.",[58,5559,5560],{"class":75},"GetHeader",[58,5562,273],{"class":92},[58,5564,5565],{"class":99},"\"Authorization\"",[58,5567,177],{"class":92},[58,5569,5570,5572,5575,5577,5579],{"class":60,"line":96},[58,5571,318],{"class":71},[58,5573,5574],{"class":92}," authHeader ",[58,5576,324],{"class":71},[58,5578,327],{"class":99},[58,5580,233],{"class":92},[58,5582,5583,5586,5589,5591,5594,5596,5598,5600,5602,5604,5607,5609,5612],{"class":60,"line":109},[58,5584,5585],{"class":92},"            c.",[58,5587,5588],{"class":75},"AbortWithStatusJSON",[58,5590,273],{"class":92},[58,5592,5593],{"class":382},"401",[58,5595,889],{"class":92},[58,5597,225],{"class":75},[58,5599,202],{"class":92},[58,5601,5337],{"class":75},[58,5603,5340],{"class":92},[58,5605,5606],{"class":99},"\"message\"",[58,5608,2794],{"class":92},[58,5610,5611],{"class":99},"\"缺少认证令牌\"",[58,5613,3621],{"class":92},[58,5615,5616],{"class":60,"line":119},[58,5617,5618],{"class":71},"            return\n",[58,5620,5621],{"class":60,"line":129},[58,5622,347],{"class":92},[58,5624,5625],{"class":60,"line":139},[58,5626,83],{"emptyLinePlaceholder":82},[58,5628,5629,5632,5634,5636,5639,5642,5645,5647,5649],{"class":60,"line":149},[58,5630,5631],{"class":92},"        parts ",[58,5633,242],{"class":71},[58,5635,301],{"class":92},[58,5637,5638],{"class":75},"SplitN",[58,5640,5641],{"class":92},"(authHeader, ",[58,5643,5644],{"class":99},"\" \"",[58,5646,889],{"class":92},[58,5648,3263],{"class":382},[58,5650,177],{"class":92},[58,5652,5653,5655,5658,5661,5663,5666,5669,5672,5675,5678,5681,5683,5686,5689],{"class":60,"line":159},[58,5654,318],{"class":71},[58,5656,5657],{"class":75}," len",[58,5659,5660],{"class":92},"(parts) ",[58,5662,379],{"class":71},[58,5664,5665],{"class":382}," 2",[58,5667,5668],{"class":71}," ||",[58,5670,5671],{"class":71}," !",[58,5673,5674],{"class":92},"strings.",[58,5676,5677],{"class":75},"EqualFold",[58,5679,5680],{"class":92},"(parts[",[58,5682,3171],{"class":382},[58,5684,5685],{"class":92},"], ",[58,5687,5688],{"class":99},"\"Bearer\"",[58,5690,290],{"class":92},[58,5692,5693,5695,5697,5699,5701,5703,5705,5707,5709,5711,5713,5715,5718],{"class":60,"line":164},[58,5694,5585],{"class":92},[58,5696,5588],{"class":75},[58,5698,273],{"class":92},[58,5700,5593],{"class":382},[58,5702,889],{"class":92},[58,5704,225],{"class":75},[58,5706,202],{"class":92},[58,5708,5337],{"class":75},[58,5710,5340],{"class":92},[58,5712,5606],{"class":99},[58,5714,2794],{"class":92},[58,5716,5717],{"class":99},"\"格式错误，请使用 Bearer \u003Ctoken>\"",[58,5719,3621],{"class":92},[58,5721,5722],{"class":60,"line":174},[58,5723,5618],{"class":71},[58,5725,5726],{"class":60,"line":180},[58,5727,347],{"class":92},[58,5729,5730],{"class":60,"line":185},[58,5731,83],{"emptyLinePlaceholder":82},[58,5733,5734,5737,5739,5742,5745,5747,5749],{"class":60,"line":191},[58,5735,5736],{"class":92},"        user, err ",[58,5738,242],{"class":71},[58,5740,5741],{"class":92}," authService.",[58,5743,5744],{"class":75},"GetUserByToken",[58,5746,5680],{"class":92},[58,5748,1229],{"class":382},[58,5750,5751],{"class":92},"])\n",[58,5753,5754,5756,5758,5760,5762],{"class":60,"line":208},[58,5755,318],{"class":71},[58,5757,376],{"class":92},[58,5759,379],{"class":71},[58,5761,383],{"class":382},[58,5763,233],{"class":92},[58,5765,5766,5768,5770,5772,5774,5776,5778,5780,5782,5784,5786,5788,5791],{"class":60,"line":213},[58,5767,5585],{"class":92},[58,5769,5588],{"class":75},[58,5771,273],{"class":92},[58,5773,5593],{"class":382},[58,5775,889],{"class":92},[58,5777,225],{"class":75},[58,5779,202],{"class":92},[58,5781,5337],{"class":75},[58,5783,5340],{"class":92},[58,5785,5606],{"class":99},[58,5787,2794],{"class":92},[58,5789,5790],{"class":99},"\"无效的认证令牌\"",[58,5792,3621],{"class":92},[58,5794,5795],{"class":60,"line":236},[58,5796,5618],{"class":71},[58,5798,5799],{"class":60,"line":259},[58,5800,347],{"class":92},[58,5802,5803],{"class":60,"line":264},[58,5804,83],{"emptyLinePlaceholder":82},[58,5806,5807,5809,5812,5814,5817],{"class":60,"line":293},[58,5808,427],{"class":92},[58,5810,5811],{"class":75},"Set",[58,5813,273],{"class":92},[58,5815,5816],{"class":99},"\"userID\"",[58,5818,5819],{"class":92},", user.ID)\n",[58,5821,5822,5824,5826,5828,5831],{"class":60,"line":315},[58,5823,427],{"class":92},[58,5825,5811],{"class":75},[58,5827,273],{"class":92},[58,5829,5830],{"class":99},"\"username\"",[58,5832,5833],{"class":92},", user.Username)\n",[58,5835,5836,5838,5841],{"class":60,"line":332},[58,5837,427],{"class":92},[58,5839,5840],{"class":75},"Next",[58,5842,508],{"class":92},[58,5844,5845],{"class":60,"line":344},[58,5846,451],{"class":92},[58,5848,5849],{"class":60,"line":350},[58,5850,457],{"class":92},[49,5852,5854],{"className":51,"code":5853,"language":53,"meta":54,"style":54},"\u002F\u002F 用户信息桥接中间件：Gin Context → request.Context\ntype ctxKey string\nconst (\n    ctxUserID   ctxKey = \"userID\"\n    ctxUsername ctxKey = \"username\"\n)\n\nfunc WithUserContext() gin.HandlerFunc {\n    return func(c *gin.Context) {\n        ctx := c.Request.Context()\n        ctx = context.WithValue(ctx, ctxUserID, c.GetUint(\"userID\"))\n        ctx = context.WithValue(ctx, ctxUsername, c.GetString(\"username\"))\n        c.Request = c.Request.WithContext(ctx)\n        c.Next()\n    }\n}\n",[19,5855,5856,5861,5872,5878,5891,5903,5907,5911,5928,5948,5962,5986,6008,6023,6031,6035],{"__ignoreMap":54},[58,5857,5858],{"class":60,"line":61},[58,5859,5860],{"class":64},"\u002F\u002F 用户信息桥接中间件：Gin Context → request.Context\n",[58,5862,5863,5866,5869],{"class":60,"line":68},[58,5864,5865],{"class":71},"type",[58,5867,5868],{"class":75}," ctxKey",[58,5870,5871],{"class":71}," string\n",[58,5873,5874,5876],{"class":60,"line":79},[58,5875,3310],{"class":71},[58,5877,93],{"class":92},[58,5879,5880,5883,5886,5888],{"class":60,"line":86},[58,5881,5882],{"class":382},"    ctxUserID",[58,5884,5885],{"class":75},"   ctxKey",[58,5887,2728],{"class":71},[58,5889,5890],{"class":99}," \"userID\"\n",[58,5892,5893,5896,5898,5900],{"class":60,"line":96},[58,5894,5895],{"class":382},"    ctxUsername",[58,5897,5868],{"class":75},[58,5899,2728],{"class":71},[58,5901,5902],{"class":99}," \"username\"\n",[58,5904,5905],{"class":60,"line":109},[58,5906,177],{"class":92},[58,5908,5909],{"class":60,"line":119},[58,5910,83],{"emptyLinePlaceholder":82},[58,5912,5913,5915,5918,5920,5922,5924,5926],{"class":60,"line":129},[58,5914,216],{"class":71},[58,5916,5917],{"class":75}," WithUserContext",[58,5919,222],{"class":92},[58,5921,225],{"class":75},[58,5923,202],{"class":92},[58,5925,230],{"class":75},[58,5927,233],{"class":92},[58,5929,5930,5932,5934,5936,5938,5940,5942,5944,5946],{"class":60,"line":139},[58,5931,267],{"class":71},[58,5933,270],{"class":71},[58,5935,273],{"class":92},[58,5937,277],{"class":276},[58,5939,280],{"class":71},[58,5941,225],{"class":75},[58,5943,202],{"class":92},[58,5945,287],{"class":75},[58,5947,290],{"class":92},[58,5949,5950,5953,5955,5958,5960],{"class":60,"line":149},[58,5951,5952],{"class":92},"        ctx ",[58,5954,242],{"class":71},[58,5956,5957],{"class":92}," c.Request.",[58,5959,287],{"class":75},[58,5961,508],{"class":92},[58,5963,5964,5966,5968,5971,5974,5977,5980,5982,5984],{"class":60,"line":159},[58,5965,5952],{"class":92},[58,5967,338],{"class":71},[58,5969,5970],{"class":92}," context.",[58,5972,5973],{"class":75},"WithValue",[58,5975,5976],{"class":92},"(ctx, ctxUserID, c.",[58,5978,5979],{"class":75},"GetUint",[58,5981,273],{"class":92},[58,5983,5816],{"class":99},[58,5985,3240],{"class":92},[58,5987,5988,5990,5992,5994,5996,5999,6002,6004,6006],{"class":60,"line":164},[58,5989,5952],{"class":92},[58,5991,338],{"class":71},[58,5993,5970],{"class":92},[58,5995,5973],{"class":75},[58,5997,5998],{"class":92},"(ctx, ctxUsername, c.",[58,6000,6001],{"class":75},"GetString",[58,6003,273],{"class":92},[58,6005,5830],{"class":99},[58,6007,3240],{"class":92},[58,6009,6010,6013,6015,6017,6020],{"class":60,"line":174},[58,6011,6012],{"class":92},"        c.Request ",[58,6014,338],{"class":71},[58,6016,5957],{"class":92},[58,6018,6019],{"class":75},"WithContext",[58,6021,6022],{"class":92},"(ctx)\n",[58,6024,6025,6027,6029],{"class":60,"line":180},[58,6026,427],{"class":92},[58,6028,5840],{"class":75},[58,6030,508],{"class":92},[58,6032,6033],{"class":60,"line":185},[58,6034,451],{"class":92},[58,6036,6037],{"class":60,"line":191},[58,6038,457],{"class":92},[44,6040,6041],{"id":6041},"组合使用",[49,6043,6045],{"className":51,"code":6044,"language":53,"meta":54,"style":54},"r.Any(\"\u002Fmcp\",\n    AuthRequired(authService),  \u002F\u002F 1. 验证 Token\n    WithUserContext(),           \u002F\u002F 2. 桥接用户信息到 request.Context\n    gin.WrapH(mcpHTTP),         \u002F\u002F 3. MCP 处理\n)\n",[19,6046,6047,6059,6070,6081,6094],{"__ignoreMap":54},[58,6048,6049,6051,6053,6055,6057],{"class":60,"line":61},[58,6050,5081],{"class":92},[58,6052,5084],{"class":75},[58,6054,273],{"class":92},[58,6056,5044],{"class":99},[58,6058,2741],{"class":92},[58,6060,6061,6064,6067],{"class":60,"line":68},[58,6062,6063],{"class":75},"    AuthRequired",[58,6065,6066],{"class":92},"(authService),  ",[58,6068,6069],{"class":64},"\u002F\u002F 1. 验证 Token\n",[58,6071,6072,6075,6078],{"class":60,"line":79},[58,6073,6074],{"class":75},"    WithUserContext",[58,6076,6077],{"class":92},"(),           ",[58,6079,6080],{"class":64},"\u002F\u002F 2. 桥接用户信息到 request.Context\n",[58,6082,6083,6086,6088,6091],{"class":60,"line":86},[58,6084,6085],{"class":92},"    gin.",[58,6087,5094],{"class":75},[58,6089,6090],{"class":92},"(mcpHTTP),         ",[58,6092,6093],{"class":64},"\u002F\u002F 3. MCP 处理\n",[58,6095,6096],{"class":60,"line":96},[58,6097,177],{"class":92},[15,6099,6100],{},"在工具处理器里就能拿到用户信息了：",[49,6102,6104],{"className":51,"code":6103,"language":53,"meta":54,"style":54},"func (h *Handler) handleListItems(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {\n    userID, ok := ctx.Value(ctxUserID).(uint)\n    if !ok {\n        return mcp.NewToolResultError(\"未认证\"), nil\n    }\n\n    items, _ := h.service.ListByUser(userID)\n    \u002F\u002F ...\n    return mcp.NewToolResultText(result), nil\n}\n",[19,6105,6106,6161,6182,6191,6208,6212,6216,6232,6237,6250],{"__ignoreMap":54},[58,6107,6108,6110,6112,6115,6117,6120,6122,6125,6127,6129,6131,6133,6135,6137,6139,6141,6143,6145,6147,6149,6151,6153,6155,6157,6159],{"class":60,"line":61},[58,6109,216],{"class":71},[58,6111,3125],{"class":92},[58,6113,6114],{"class":276},"h ",[58,6116,4723],{"class":71},[58,6118,6119],{"class":75},"Handler",[58,6121,3539],{"class":92},[58,6123,6124],{"class":75},"handleListItems",[58,6126,273],{"class":92},[58,6128,4697],{"class":276},[58,6130,4700],{"class":75},[58,6132,202],{"class":92},[58,6134,287],{"class":75},[58,6136,889],{"class":92},[58,6138,4709],{"class":276},[58,6140,4712],{"class":75},[58,6142,202],{"class":92},[58,6144,4717],{"class":75},[58,6146,4720],{"class":92},[58,6148,4723],{"class":71},[58,6150,4726],{"class":75},[58,6152,202],{"class":92},[58,6154,4731],{"class":75},[58,6156,889],{"class":92},[58,6158,4736],{"class":71},[58,6160,290],{"class":92},[58,6162,6163,6166,6168,6171,6174,6177,6180],{"class":60,"line":68},[58,6164,6165],{"class":92},"    userID, ok ",[58,6167,242],{"class":71},[58,6169,6170],{"class":92}," ctx.",[58,6172,6173],{"class":75},"Value",[58,6175,6176],{"class":92},"(ctxUserID).(",[58,6178,6179],{"class":71},"uint",[58,6181,177],{"class":92},[58,6183,6184,6186,6188],{"class":60,"line":79},[58,6185,3160],{"class":71},[58,6187,5671],{"class":71},[58,6189,6190],{"class":92},"ok {\n",[58,6192,6193,6195,6197,6199,6201,6204,6206],{"class":60,"line":86},[58,6194,857],{"class":71},[58,6196,4641],{"class":92},[58,6198,4944],{"class":75},[58,6200,273],{"class":92},[58,6202,6203],{"class":99},"\"未认证\"",[58,6205,4760],{"class":92},[58,6207,4763],{"class":382},[58,6209,6210],{"class":60,"line":96},[58,6211,451],{"class":92},[58,6213,6214],{"class":60,"line":109},[58,6215,83],{"emptyLinePlaceholder":82},[58,6217,6218,6221,6223,6226,6229],{"class":60,"line":119},[58,6219,6220],{"class":92},"    items, _ ",[58,6222,242],{"class":71},[58,6224,6225],{"class":92}," h.service.",[58,6227,6228],{"class":75},"ListByUser",[58,6230,6231],{"class":92},"(userID)\n",[58,6233,6234],{"class":60,"line":129},[58,6235,6236],{"class":64},"    \u002F\u002F ...\n",[58,6238,6239,6241,6243,6245,6248],{"class":60,"line":139},[58,6240,267],{"class":71},[58,6242,4641],{"class":92},[58,6244,4752],{"class":75},[58,6246,6247],{"class":92},"(result), ",[58,6249,4763],{"class":382},[58,6251,6252],{"class":60,"line":149},[58,6253,457],{"class":92},[44,6255,6256],{"id":6256},"客户端配置",[15,6258,6259],{},"AI 客户端（如 Claude Desktop）配置 MCP 服务器时带上 Authorization 头：",[49,6261,6265],{"className":6262,"code":6263,"language":6264,"meta":54,"style":54},"language-json shiki shiki-themes github-light github-dark","{\n  \"mcpServers\": {\n    \"my-app\": {\n      \"url\": \"http:\u002F\u002Flocalhost:8080\u002Fmcp\",\n      \"headers\": {\n        \"Authorization\": \"Bearer your-token-here\"\n      }\n    }\n  }\n}\n","json",[19,6266,6267,6272,6279,6285,6297,6304,6314,6319,6323,6327],{"__ignoreMap":54},[58,6268,6269],{"class":60,"line":61},[58,6270,6271],{"class":92},"{\n",[58,6273,6274,6277],{"class":60,"line":68},[58,6275,6276],{"class":382},"  \"mcpServers\"",[58,6278,830],{"class":92},[58,6280,6281,6283],{"class":60,"line":79},[58,6282,4533],{"class":382},[58,6284,830],{"class":92},[58,6286,6287,6290,6292,6295],{"class":60,"line":86},[58,6288,6289],{"class":382},"      \"url\"",[58,6291,2794],{"class":92},[58,6293,6294],{"class":99},"\"http:\u002F\u002Flocalhost:8080\u002Fmcp\"",[58,6296,2741],{"class":92},[58,6298,6299,6302],{"class":60,"line":96},[58,6300,6301],{"class":382},"      \"headers\"",[58,6303,830],{"class":92},[58,6305,6306,6309,6311],{"class":60,"line":109},[58,6307,6308],{"class":382},"        \"Authorization\"",[58,6310,2794],{"class":92},[58,6312,6313],{"class":99},"\"Bearer your-token-here\"\n",[58,6315,6316],{"class":60,"line":119},[58,6317,6318],{"class":92},"      }\n",[58,6320,6321],{"class":60,"line":129},[58,6322,451],{"class":92},[58,6324,6325],{"class":60,"line":139},[58,6326,3050],{"class":92},[58,6328,6329],{"class":60,"line":149},[58,6330,457],{"class":92},[11,6332,1041],{"id":1041},[995,6334,6335,6345],{},[998,6336,6337],{},[1001,6338,6339,6342],{},[1004,6340,6341],{},"步骤",[1004,6343,6344],{},"说明",[1014,6346,6347,6356,6367,6376,6385],{},[1001,6348,6349,6353],{},[1019,6350,6351],{},[19,6352,4525],{},[1019,6354,6355],{},"创建 MCP 协议实例",[1001,6357,6358,6364],{},[1019,6359,6360,2265,6362],{},[19,6361,4644],{},[19,6363,4687],{},[1019,6365,6366],{},"定义并注册工具",[1001,6368,6369,6373],{},[1019,6370,6371],{},[19,6372,5029],{},[1019,6374,6375],{},"转为 HTTP Handler",[1001,6377,6378,6382],{},[1019,6379,6380],{},[19,6381,4997],{},[1019,6383,6384],{},"挂载到 Gin 路由",[1001,6386,6387,6395],{},[1019,6388,6389,2265,6392],{},[19,6390,6391],{},"AuthRequired",[19,6393,6394],{},"WithUserContext",[1019,6396,6397],{},"鉴权 + 用户信息桥接",[15,6399,6400,6401,6404,6405,782],{},"核心就一句话：",[737,6402,6403],{},"MCP 是 HTTP Handler，挂上去就行。"," 鉴权复用你已有的中间件体系，只需要多写一个桥接函数把用户信息传进 ",[19,6406,5490],{},[1066,6408,6409],{},"html pre.shiki code .sw2iP, html code.shiki .sw2iP{--shiki-light:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .scXbn, html code.shiki .scXbn{--shiki-light:#032F62;--shiki-dark:#9ECBFF}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .siTax, html code.shiki .siTax{--shiki-light:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sIX_F, html code.shiki .sIX_F{--shiki-light:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sHbNN, html code.shiki .sHbNN{--shiki-light:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sPK6S, html code.shiki .sPK6S{--shiki-light:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .suQ91, html code.shiki .suQ91{--shiki-light:#005CC5;--shiki-dark:#79B8FF}",{"title":54,"searchDepth":68,"depth":79,"links":6411},[6412,6413,6414,6415,6422,6428],{"id":4407,"depth":68,"text":4408},{"id":4417,"depth":68,"text":4418},{"id":4434,"depth":68,"text":4434},{"id":4460,"depth":68,"text":4460,"children":6416},[6417,6418,6419,6420,6421],{"id":4463,"depth":79,"text":4464},{"id":4491,"depth":79,"text":4492},{"id":4605,"depth":79,"text":4606},{"id":4986,"depth":79,"text":4987},{"id":5107,"depth":79,"text":5108},{"id":5463,"depth":68,"text":5464,"children":6423},[6424,6425,6426,6427],{"id":4048,"depth":79,"text":4048},{"id":5483,"depth":79,"text":5484},{"id":6041,"depth":79,"text":6041},{"id":6256,"depth":79,"text":6256},{"id":1041,"depth":68,"text":1041},"介绍如何使用 mark3labs\u002Fmcp-go 与 Gin 框架集成，搭建一个带鉴权的 MCP（Model Context Protocol）服务器，让 AI Agent 能直接调用你的后端服务。","\u002Fposts\u002Fgo-mcp-gin\u002Fimg\u002Fcover.svg",{},"\u002Fposts\u002Fgo-mcp-gin","2026-06-01T00:50:00",{"title":4402,"description":6429},"posts\u002Fgo-mcp-gin\u002Findex",[1098,6437,6438,6439,6440],"MCP","Gin","AI Agent","后端","2026-06-01","5fZl1Vigs2lHb5fHPz-0YZFyFD38ws1heOouZUG1g6M",{"id":6444,"title":6445,"author":6,"body":6446,"description":7213,"difficulty":6,"draft":1088,"extension":1089,"image":7214,"meta":7215,"navigation":82,"path":7216,"pinned":1088,"published":7217,"seo":7218,"series":6,"seriesOrder":6,"stem":7219,"tags":7220,"updated":6,"__hash__":7224},"posts\u002Fposts\u002Fdws-dingtalk-cli\u002Findex.md","DWS — 钉钉官方出品的 AI 友好命令行工具",{"type":8,"value":6447,"toc":7199},[6448,6452,6455,6462,6588,6591,6594,6650,6653,6668,6671,6674,6681,6913,6920,6924,6927,6947,6950,6965,6968,6988,6991,6994,7001,7067,7070,7073,7077,7080,7132,7136,7157,7161,7164,7178,7181,7185,7188,7190,7193,7196],[11,6449,6451],{"id":6450},"dws-是什么","dws 是什么",[15,6453,6454],{},"dws（DingTalk Workspace CLI）是阿里巴巴 2026 年开源的钉钉命令行工具，Apache-2.0 协议，Go 语言编写。",[15,6456,6457,6458,6461],{},"它的定位很明确：",[737,6459,6460],{},"把钉钉的所有产品能力暴露给 AI Agent 和命令行用户","。不用打开钉钉 App，不用写 API 调用代码，一条命令搞定。",[49,6463,6465],{"className":579,"code":6464,"language":581,"meta":54,"style":54},"# 查看今天有什么会\ndws calendar event list --start 2026-06-01 --end 2026-06-01\n\n# 给群里发条消息\ndws chat send --group-id xxx --content \"今天发布推迟到下午3点\"\n\n# 创建一个待办\ndws todo task create --subject \"Review PR #123\" --due-date 2026-06-02\n\n# 读取钉钉文档\ndws doc read --url \"https:\u002F\u002Falidocs.dingtalk.com\u002Fi\u002Fnodes\u002Fxxx\"\n",[19,6466,6467,6472,6498,6502,6507,6529,6533,6538,6563,6567,6572],{"__ignoreMap":54},[58,6468,6469],{"class":60,"line":61},[58,6470,6471],{"class":64},"# 查看今天有什么会\n",[58,6473,6474,6477,6480,6483,6486,6489,6492,6495],{"class":60,"line":68},[58,6475,6476],{"class":75},"dws",[58,6478,6479],{"class":99}," calendar",[58,6481,6482],{"class":99}," event",[58,6484,6485],{"class":99}," list",[58,6487,6488],{"class":382}," --start",[58,6490,6491],{"class":99}," 2026-06-01",[58,6493,6494],{"class":382}," --end",[58,6496,6497],{"class":99}," 2026-06-01\n",[58,6499,6500],{"class":60,"line":79},[58,6501,83],{"emptyLinePlaceholder":82},[58,6503,6504],{"class":60,"line":86},[58,6505,6506],{"class":64},"# 给群里发条消息\n",[58,6508,6509,6511,6514,6517,6520,6523,6526],{"class":60,"line":96},[58,6510,6476],{"class":75},[58,6512,6513],{"class":99}," chat",[58,6515,6516],{"class":99}," send",[58,6518,6519],{"class":382}," --group-id",[58,6521,6522],{"class":99}," xxx",[58,6524,6525],{"class":382}," --content",[58,6527,6528],{"class":99}," \"今天发布推迟到下午3点\"\n",[58,6530,6531],{"class":60,"line":109},[58,6532,83],{"emptyLinePlaceholder":82},[58,6534,6535],{"class":60,"line":119},[58,6536,6537],{"class":64},"# 创建一个待办\n",[58,6539,6540,6542,6545,6548,6551,6554,6557,6560],{"class":60,"line":129},[58,6541,6476],{"class":75},[58,6543,6544],{"class":99}," todo",[58,6546,6547],{"class":99}," task",[58,6549,6550],{"class":99}," create",[58,6552,6553],{"class":382}," --subject",[58,6555,6556],{"class":99}," \"Review PR #123\"",[58,6558,6559],{"class":382}," --due-date",[58,6561,6562],{"class":99}," 2026-06-02\n",[58,6564,6565],{"class":60,"line":139},[58,6566,83],{"emptyLinePlaceholder":82},[58,6568,6569],{"class":60,"line":149},[58,6570,6571],{"class":64},"# 读取钉钉文档\n",[58,6573,6574,6576,6579,6582,6585],{"class":60,"line":159},[58,6575,6476],{"class":75},[58,6577,6578],{"class":99}," doc",[58,6580,6581],{"class":99}," read",[58,6583,6584],{"class":382}," --url",[58,6586,6587],{"class":99}," \"https:\u002F\u002Falidocs.dingtalk.com\u002Fi\u002Fnodes\u002Fxxx\"\n",[11,6589,6590],{"id":6590},"安装",[15,6592,6593],{},"三种方式，选你喜欢的：",[49,6595,6597],{"className":579,"code":6596,"language":581,"meta":54,"style":54},"# npm（推荐，跨平台自动下载二进制）\nnpm install -g dingtalk-workspace-cli\n\n# Homebrew（macOS\u002FLinux）\nbrew install dws\n\n# 直接下载二进制\n# https:\u002F\u002Fgithub.com\u002Fopen-dingtalk\u002Fdingtalk-workspace-cli\u002Freleases\n",[19,6598,6599,6604,6617,6621,6626,6636,6640,6645],{"__ignoreMap":54},[58,6600,6601],{"class":60,"line":61},[58,6602,6603],{"class":64},"# npm（推荐，跨平台自动下载二进制）\n",[58,6605,6606,6609,6611,6614],{"class":60,"line":68},[58,6607,6608],{"class":75},"npm",[58,6610,622],{"class":99},[58,6612,6613],{"class":382}," -g",[58,6615,6616],{"class":99}," dingtalk-workspace-cli\n",[58,6618,6619],{"class":60,"line":79},[58,6620,83],{"emptyLinePlaceholder":82},[58,6622,6623],{"class":60,"line":86},[58,6624,6625],{"class":64},"# Homebrew（macOS\u002FLinux）\n",[58,6627,6628,6631,6633],{"class":60,"line":96},[58,6629,6630],{"class":75},"brew",[58,6632,622],{"class":99},[58,6634,6635],{"class":99}," dws\n",[58,6637,6638],{"class":60,"line":109},[58,6639,83],{"emptyLinePlaceholder":82},[58,6641,6642],{"class":60,"line":119},[58,6643,6644],{"class":64},"# 直接下载二进制\n",[58,6646,6647],{"class":60,"line":129},[58,6648,6649],{"class":64},"# https:\u002F\u002Fgithub.com\u002Fopen-dingtalk\u002Fdingtalk-workspace-cli\u002Freleases\n",[15,6651,6652],{},"安装后认证：",[49,6654,6656],{"className":579,"code":6655,"language":581,"meta":54,"style":54},"dws auth login\n",[19,6657,6658],{"__ignoreMap":54},[58,6659,6660,6662,6665],{"class":60,"line":61},[58,6661,6476],{"class":75},[58,6663,6664],{"class":99}," auth",[58,6666,6667],{"class":99}," login\n",[15,6669,6670],{},"会打开浏览器走 OAuth 设备流认证，token 加密存储在系统钥匙串里（PBKDF2 + AES-256-GCM）。",[11,6672,6673],{"id":6673},"覆盖的钉钉产品",[15,6675,6676,6677,6680],{},"这是最夸张的部分 — ",[737,6678,6679],{},"19 个产品，213 个命令","，基本把钉钉全家桶都搬到了命令行：",[995,6682,6683,6695],{},[998,6684,6685],{},[1001,6686,6687,6690,6693],{},[1004,6688,6689],{},"产品",[1004,6691,6692],{},"命令数",[1004,6694,6344],{},[1014,6696,6697,6711,6725,6739,6753,6767,6781,6795,6808,6822,6835,6849,6863,6876,6889,6902],{},[1001,6698,6699,6705,6708],{},[1019,6700,6701,6702],{},"聊天 ",[19,6703,6704],{},"chat",[1019,6706,6707],{},"57",[1019,6709,6710],{},"消息收发、群管理、机器人、已读状态、Webhook",[1001,6712,6713,6719,6722],{},[1019,6714,6715,6716],{},"AI 表格 ",[19,6717,6718],{},"aitable",[1019,6720,6721],{},"42",[1019,6723,6724],{},"Base\u002F数据表\u002F字段\u002F记录\u002F视图\u002F图表\u002F仪表盘，完整 CRUD",[1001,6726,6727,6733,6736],{},[1019,6728,6729,6730],{},"电子表格 ",[19,6731,6732],{},"sheet",[1019,6734,6735],{},"34",[1019,6737,6738],{},"工作表读写、行列操作、合并、筛选视图、导出",[1001,6740,6741,6747,6750],{},[1019,6742,6743,6744],{},"文档 ",[19,6745,6746],{},"doc",[1019,6748,6749],{},"21",[1019,6751,6752],{},"搜索、读写、块级编辑、评论、复制移动",[1001,6754,6755,6761,6764],{},[1019,6756,6757,6758],{},"会议纪要 ",[19,6759,6760],{},"minutes",[1019,6762,6763],{},"19",[1019,6765,6766],{},"列表、摘要、转写、思维导图、发言人、热词",[1001,6768,6769,6775,6778],{},[1019,6770,6771,6772],{},"日历 ",[19,6773,6774],{},"calendar",[1019,6776,6777],{},"14",[1019,6779,6780],{},"事件 CRUD、会议室、忙闲查询、时间建议",[1001,6782,6783,6789,6792],{},[1019,6784,6785,6786],{},"审批 ",[19,6787,6788],{},"oa",[1019,6790,6791],{},"9",[1019,6793,6794],{},"待办、发起、同意\u002F拒绝\u002F撤销",[1001,6796,6797,6803,6805],{},[1019,6798,6799,6800],{},"云盘 ",[19,6801,6802],{},"drive",[1019,6804,6791],{},[1019,6806,6807],{},"列表、下载、上传、创建目录",[1001,6809,6810,6816,6819],{},[1019,6811,6812,6813],{},"知识库 ",[19,6814,6815],{},"wiki",[1019,6817,6818],{},"7",[1019,6820,6821],{},"空间管理、成员管理",[1001,6823,6824,6830,6832],{},[1019,6825,6826,6827],{},"汇报 ",[19,6828,6829],{},"report",[1019,6831,6818],{},[1019,6833,6834],{},"日志\u002F日报\u002F周报，按模板创建、统计",[1001,6836,6837,6843,6846],{},[1019,6838,6839,6840],{},"待办 ",[19,6841,6842],{},"todo",[1019,6844,6845],{},"6",[1019,6847,6848],{},"创建、完成、删除，支持优先级和循环",[1001,6850,6851,6857,6860],{},[1019,6852,6853,6854],{},"邮件 ",[19,6855,6856],{},"mail",[1019,6858,6859],{},"4",[1019,6861,6862],{},"邮箱查询、邮件搜索、发送",[1001,6864,6865,6871,6873],{},[1019,6866,6867,6868],{},"考勤 ",[19,6869,6870],{},"attendance",[1019,6872,6859],{},[1019,6874,6875],{},"打卡记录、排班、汇总",[1001,6877,6878,6884,6886],{},[1019,6879,6880,6881],{},"通讯录 ",[19,6882,6883],{},"contact",[1019,6885,3035],{},[1019,6887,6888],{},"用户搜索、部门查询",[1001,6890,6891,6897,6899],{},[1019,6892,6893,6894],{},"DING 消息 ",[19,6895,6896],{},"ding",[1019,6898,3263],{},[1019,6900,6901],{},"发送\u002F撤回（应用内、短信、电话）",[1001,6903,6904,6907,6910],{},[1019,6905,6906],{},"其他",[1019,6908,6909],{},"若干",[1019,6911,6912],{},"AI 搜索、AI 应用、直播、开放平台文档",[15,6914,6915,6916,6919],{},"还有一个万能命令 ",[19,6917,6918],{},"dws api","，可以直接调用任意钉钉 OpenAPI，不受这 213 个命令的限制。",[11,6921,6923],{"id":6922},"agent-skill-系统","Agent Skill 系统",[15,6925,6926],{},"dws 内置了一套完整的 Agent Skill，可以直接被 AI 编程工具发现和使用：",[2638,6928,6929,6935,6941],{},[1052,6930,6931,6934],{},[737,6932,6933],{},"Claude Code"," — 通过 SKILL.md 自动加载",[1052,6936,6937,6940],{},[737,6938,6939],{},"Cursor"," — 支持 MCP 协议接入",[1052,6942,6943,6946],{},[737,6944,6945],{},"Codex \u002F OpenCode \u002F Qoder"," — 通过 skill 安装命令接入",[15,6948,6949],{},"安装 skill：",[49,6951,6953],{"className":579,"code":6952,"language":581,"meta":54,"style":54},"dws skill setup\n",[19,6954,6955],{"__ignoreMap":54},[58,6956,6957,6959,6962],{"class":60,"line":61},[58,6958,6476],{"class":75},[58,6960,6961],{"class":99}," skill",[58,6963,6964],{"class":99}," setup\n",[15,6966,6967],{},"SKILL.md 里定义了：",[2638,6969,6970,6979,6982,6985],{},[1052,6971,6972,6973,6975,6976,6978],{},"16 个产品的意图判断决策树（用户说\"表格\"→ ",[19,6974,6718],{},"，说\"打卡\"→ ",[19,6977,6870],{},"）",[1052,6980,6981],{},"危险操作确认流程（删除、撤回等必须先确认）",[1052,6983,6984],{},"错误处理规范（verbose 重试、recovery 事件 ID）",[1052,6986,6987],{},"URL 分流规则（alidocs.dingtalk.com 的多种路径格式自动识别）",[15,6989,6990],{},"Agent 读到这份 SKILL.md 就知道什么时候该调什么命令，不需要额外的 prompt 工程。",[11,6992,6993],{"id":6993},"自动升级",[15,6995,6996,6997,7000],{},"内置 ",[19,6998,6999],{},"dws upgrade","，不需要重新下载安装包：",[49,7002,7004],{"className":579,"code":7003,"language":581,"meta":54,"style":54},"dws upgrade              # 交互式升级到最新版\ndws upgrade --check      # 仅检查是否有新版本\ndws upgrade --list       # 列出最近版本\ndws upgrade --version v1.0.5  # 升级到指定版本\ndws upgrade --rollback   # 回滚到上一版本\n",[19,7005,7006,7016,7028,7040,7055],{"__ignoreMap":54},[58,7007,7008,7010,7013],{"class":60,"line":61},[58,7009,6476],{"class":75},[58,7011,7012],{"class":99}," upgrade",[58,7014,7015],{"class":64},"              # 交互式升级到最新版\n",[58,7017,7018,7020,7022,7025],{"class":60,"line":68},[58,7019,6476],{"class":75},[58,7021,7012],{"class":99},[58,7023,7024],{"class":382}," --check",[58,7026,7027],{"class":64},"      # 仅检查是否有新版本\n",[58,7029,7030,7032,7034,7037],{"class":60,"line":79},[58,7031,6476],{"class":75},[58,7033,7012],{"class":99},[58,7035,7036],{"class":382}," --list",[58,7038,7039],{"class":64},"       # 列出最近版本\n",[58,7041,7042,7044,7046,7049,7052],{"class":60,"line":86},[58,7043,6476],{"class":75},[58,7045,7012],{"class":99},[58,7047,7048],{"class":382}," --version",[58,7050,7051],{"class":99}," v1.0.5",[58,7053,7054],{"class":64},"  # 升级到指定版本\n",[58,7056,7057,7059,7061,7064],{"class":60,"line":96},[58,7058,6476],{"class":75},[58,7060,7012],{"class":99},[58,7062,7063],{"class":382}," --rollback",[58,7065,7066],{"class":64},"   # 回滚到上一版本\n",[15,7068,7069],{},"升级流程：下载 → SHA256 校验 → 备份当前版本 → 原子替换 → 更新 Skill。失败可一键回滚。",[11,7071,7072],{"id":7072},"亮点",[44,7074,7076],{"id":7075},"_1-为-ai-agent-专门优化","1. 为 AI Agent 专门优化",[15,7078,7079],{},"这可能是目前对 AI 最友好的 CLI 工具：",[2638,7081,7082,7103,7113,7122],{},[1052,7083,7084,7087,7088,7091,7092,7095,7096,7091,7099,7102],{},[737,7085,7086],{},"智能纠错"," — AI 模型经常生成错误的参数格式（",[19,7089,7090],{},"--userId"," 应该是 ",[19,7093,7094],{},"--user-id","，",[19,7097,7098],{},"--limit100",[19,7100,7101],{},"--limit 100","），dws 的 5 阶段 Pipeline 会自动修正",[1052,7104,7105,7108,7109,7112],{},[737,7106,7107],{},"Schema 发现"," — ",[19,7110,7111],{},"dws schema"," 让 Agent 动态查询可用工具和参数，不需要硬编码",[1052,7114,7115,7108,7118,7121],{},[737,7116,7117],{},"jq 过滤",[19,7119,7120],{},"--jq '.data[] | {name, id}'"," 直接过滤输出，减少 Agent 消耗的 token",[1052,7123,7124,7127,7128,7131],{},[737,7125,7126],{},"JSON 输出"," — 所有命令支持 ",[19,7129,7130],{},"--format json","，方便程序解析",[44,7133,7135],{"id":7134},"_2-安全设计","2. 安全设计",[2638,7137,7138,7141,7144,7147,7154],{},[1052,7139,7140],{},"token 用 PBKDF2 + AES-256-GCM 加密，密钥绑定设备 MAC 地址",[1052,7142,7143],{},"域名白名单，只能访问钉钉官方 API",[1052,7145,7146],{},"强制 HTTPS",[1052,7148,7149,7150,7153],{},"写操作必须显式 ",[19,7151,7152],{},"--yes"," 确认",[1052,7155,7156],{},"完整审计链",[44,7158,7160],{"id":7159},"_3-发现驱动架构","3. 发现驱动架构",[15,7162,7163],{},"不是把所有命令硬编码在代码里，而是：",[1049,7165,7166,7169,7172,7175],{},[1052,7167,7168],{},"从钉钉 MCP 服务器注册中心拉取元数据",[1052,7170,7171],{},"运行时发现服务端能力",[1052,7173,7174],{},"转换成统一的中间表示（IR）",[1052,7176,7177],{},"动态挂载到命令树",[15,7179,7180],{},"这意味着钉钉新增产品能力时，dws 不需要发版就能支持。",[44,7182,7184],{"id":7183},"_4-多平台分发","4. 多平台分发",[15,7186,7187],{},"GoReleaser 交叉编译 + Homebrew formula + npm 包 + shell\u002FPowerShell 安装脚本，覆盖 Windows、macOS、Linux。",[11,7189,1041],{"id":1041},[15,7191,7192],{},"dws 把钉钉从一个\"必须打开 App 才能用\"的工具变成了一个\"命令行 + AI Agent 随时调用\"的平台。213 个命令覆盖日常办公的方方面面，Agent Skill 系统让 AI 编程工具可以直接理解和使用这些能力。",[15,7194,7195],{},"如果你是钉钉重度用户，或者在做企业内部的 AI Agent 集成，dws 值得一试。",[1066,7197,7198],{},"html pre.shiki code .sHbNN, html code.shiki .sHbNN{--shiki-light:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sw2iP, html code.shiki .sw2iP{--shiki-light:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .scXbn, html code.shiki .scXbn{--shiki-light:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .suQ91, html code.shiki .suQ91{--shiki-light:#005CC5;--shiki-dark:#79B8FF}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":54,"searchDepth":68,"depth":79,"links":7200},[7201,7202,7203,7204,7205,7206,7212],{"id":6450,"depth":68,"text":6451},{"id":6590,"depth":68,"text":6590},{"id":6673,"depth":68,"text":6673},{"id":6922,"depth":68,"text":6923},{"id":6993,"depth":68,"text":6993},{"id":7072,"depth":68,"text":7072,"children":7207},[7208,7209,7210,7211],{"id":7075,"depth":79,"text":7076},{"id":7134,"depth":79,"text":7135},{"id":7159,"depth":79,"text":7160},{"id":7183,"depth":79,"text":7184},{"id":1041,"depth":68,"text":1041},"介绍阿里巴巴官方的 DingTalk Workspace CLI（dws），一个覆盖钉钉 19 个产品、213 个命令的 AI 友好命令行工具，支持自动升级和 Agent Skill 系统。","\u002Fposts\u002Fdws-dingtalk-cli\u002Fimg\u002Fcover.svg",{},"\u002Fposts\u002Fdws-dingtalk-cli","2026-06-01T00:51:00",{"title":6445,"description":7213},"posts\u002Fdws-dingtalk-cli\u002Findex",[7221,7222,6439,1098,7223],"钉钉","CLI","效率工具","4ca65VUZ2Sz9yG8dNtftrBDDHE6QowA3ed9g4egQkKI",{"id":4,"title":5,"author":6,"body":7226,"description":1086,"difficulty":1087,"draft":1088,"extension":1089,"image":1090,"meta":8004,"navigation":82,"path":1092,"pinned":1088,"published":1093,"seo":8005,"series":1095,"seriesOrder":68,"stem":1096,"tags":8006,"updated":1102,"__hash__":1103},{"type":8,"value":7227,"toc":7988},[7228,7230,7234,7236,7238,7244,7246,7248,7536,7538,7632,7634,7744,7746,7748,7754,7764,7770,7782,7794,7798,7804,7886,7890,7892,7896,7902,7904,7932,7934,7938,7968,7970,7974,7984,7986],[11,7229,13],{"id":13},[15,7231,17,7232,22],{},[19,7233,21],{},[15,7235,25],{},[11,7237,28],{"id":28},[15,7239,31,7240,35,7242,39],{},[19,7241,34],{},[19,7243,38],{},[11,7245,42],{"id":42},[44,7247,47],{"id":46},[49,7249,7250],{"className":51,"code":52,"language":53,"meta":54,"style":54},[19,7251,7252,7256,7262,7266,7272,7280,7288,7296,7304,7312,7320,7324,7332,7336,7340,7344,7356,7360,7376,7392,7396,7416,7432,7444,7452,7456,7460,7472,7484,7488,7504,7508,7512,7528,7532],{"__ignoreMap":54},[58,7253,7254],{"class":60,"line":61},[58,7255,65],{"class":64},[58,7257,7258,7260],{"class":60,"line":68},[58,7259,72],{"class":71},[58,7261,76],{"class":75},[58,7263,7264],{"class":60,"line":79},[58,7265,83],{"emptyLinePlaceholder":82},[58,7267,7268,7270],{"class":60,"line":86},[58,7269,89],{"class":71},[58,7271,93],{"class":92},[58,7273,7274,7276,7278],{"class":60,"line":96},[58,7275,100],{"class":99},[58,7277,103],{"class":75},[58,7279,106],{"class":99},[58,7281,7282,7284,7286],{"class":60,"line":109},[58,7283,100],{"class":99},[58,7285,114],{"class":75},[58,7287,106],{"class":99},[58,7289,7290,7292,7294],{"class":60,"line":119},[58,7291,100],{"class":99},[58,7293,124],{"class":75},[58,7295,106],{"class":99},[58,7297,7298,7300,7302],{"class":60,"line":129},[58,7299,100],{"class":99},[58,7301,134],{"class":75},[58,7303,106],{"class":99},[58,7305,7306,7308,7310],{"class":60,"line":139},[58,7307,100],{"class":99},[58,7309,144],{"class":75},[58,7311,106],{"class":99},[58,7313,7314,7316,7318],{"class":60,"line":149},[58,7315,100],{"class":99},[58,7317,154],{"class":75},[58,7319,106],{"class":99},[58,7321,7322],{"class":60,"line":159},[58,7323,83],{"emptyLinePlaceholder":82},[58,7325,7326,7328,7330],{"class":60,"line":164},[58,7327,100],{"class":99},[58,7329,169],{"class":75},[58,7331,106],{"class":99},[58,7333,7334],{"class":60,"line":174},[58,7335,177],{"class":92},[58,7337,7338],{"class":60,"line":180},[58,7339,83],{"emptyLinePlaceholder":82},[58,7341,7342],{"class":60,"line":185},[58,7343,188],{"class":64},[58,7345,7346,7348,7350,7352,7354],{"class":60,"line":191},[58,7347,194],{"class":71},[58,7349,197],{"class":92},[58,7351,103],{"class":75},[58,7353,202],{"class":92},[58,7355,205],{"class":75},[58,7357,7358],{"class":60,"line":208},[58,7359,83],{"emptyLinePlaceholder":82},[58,7361,7362,7364,7366,7368,7370,7372,7374],{"class":60,"line":213},[58,7363,216],{"class":71},[58,7365,219],{"class":75},[58,7367,222],{"class":92},[58,7369,225],{"class":75},[58,7371,202],{"class":92},[58,7373,230],{"class":75},[58,7375,233],{"class":92},[58,7377,7378,7380,7382,7384,7386,7388,7390],{"class":60,"line":236},[58,7379,239],{"class":92},[58,7381,242],{"class":71},[58,7383,245],{"class":92},[58,7385,248],{"class":75},[58,7387,251],{"class":92},[58,7389,254],{"class":99},[58,7391,177],{"class":92},[58,7393,7394],{"class":60,"line":259},[58,7395,83],{"emptyLinePlaceholder":82},[58,7397,7398,7400,7402,7404,7406,7408,7410,7412,7414],{"class":60,"line":264},[58,7399,267],{"class":71},[58,7401,270],{"class":71},[58,7403,273],{"class":92},[58,7405,277],{"class":276},[58,7407,280],{"class":71},[58,7409,225],{"class":75},[58,7411,202],{"class":92},[58,7413,287],{"class":75},[58,7415,290],{"class":92},[58,7417,7418,7420,7422,7424,7426,7428,7430],{"class":60,"line":293},[58,7419,296],{"class":92},[58,7421,242],{"class":71},[58,7423,301],{"class":92},[58,7425,304],{"class":75},[58,7427,307],{"class":92},[58,7429,310],{"class":99},[58,7431,177],{"class":92},[58,7433,7434,7436,7438,7440,7442],{"class":60,"line":315},[58,7435,318],{"class":71},[58,7437,321],{"class":92},[58,7439,324],{"class":71},[58,7441,327],{"class":99},[58,7443,233],{"class":92},[58,7445,7446,7448,7450],{"class":60,"line":332},[58,7447,335],{"class":92},[58,7449,338],{"class":71},[58,7451,341],{"class":99},[58,7453,7454],{"class":60,"line":344},[58,7455,347],{"class":92},[58,7457,7458],{"class":60,"line":350},[58,7459,83],{"emptyLinePlaceholder":82},[58,7461,7462,7464,7466,7468,7470],{"class":60,"line":355},[58,7463,358],{"class":92},[58,7465,242],{"class":71},[58,7467,245],{"class":92},[58,7469,365],{"class":75},[58,7471,368],{"class":92},[58,7473,7474,7476,7478,7480,7482],{"class":60,"line":371},[58,7475,318],{"class":71},[58,7477,376],{"class":92},[58,7479,379],{"class":71},[58,7481,383],{"class":382},[58,7483,233],{"class":92},[58,7485,7486],{"class":60,"line":388},[58,7487,391],{"class":64},[58,7489,7490,7492,7494,7496,7498,7500,7502],{"class":60,"line":394},[58,7491,397],{"class":92},[58,7493,338],{"class":71},[58,7495,245],{"class":92},[58,7497,365],{"class":75},[58,7499,406],{"class":92},[58,7501,409],{"class":99},[58,7503,177],{"class":92},[58,7505,7506],{"class":60,"line":414},[58,7507,347],{"class":92},[58,7509,7510],{"class":60,"line":419},[58,7511,83],{"emptyLinePlaceholder":82},[58,7513,7514,7516,7518,7520,7522,7524,7526],{"class":60,"line":424},[58,7515,427],{"class":92},[58,7517,430],{"class":75},[58,7519,433],{"class":92},[58,7521,436],{"class":75},[58,7523,439],{"class":92},[58,7525,442],{"class":75},[58,7527,445],{"class":92},[58,7529,7530],{"class":60,"line":448},[58,7531,451],{"class":92},[58,7533,7534],{"class":60,"line":454},[58,7535,457],{"class":92},[44,7537,461],{"id":460},[49,7539,7540],{"className":51,"code":464,"language":53,"meta":54,"style":54},[19,7541,7542,7546,7556,7560,7564,7576,7588,7600,7612,7624],{"__ignoreMap":54},[58,7543,7544],{"class":60,"line":61},[58,7545,471],{"class":64},[58,7547,7548,7550,7552,7554],{"class":60,"line":68},[58,7549,89],{"class":71},[58,7551,478],{"class":99},[58,7553,481],{"class":75},[58,7555,106],{"class":99},[58,7557,7558],{"class":60,"line":79},[58,7559,83],{"emptyLinePlaceholder":82},[58,7561,7562],{"class":60,"line":86},[58,7563,492],{"class":64},[58,7565,7566,7568,7570,7572,7574],{"class":60,"line":96},[58,7567,497],{"class":92},[58,7569,242],{"class":71},[58,7571,502],{"class":92},[58,7573,505],{"class":75},[58,7575,508],{"class":92},[58,7577,7578,7580,7582,7584,7586],{"class":60,"line":109},[58,7579,513],{"class":92},[58,7581,516],{"class":75},[58,7583,273],{"class":92},[58,7585,310],{"class":99},[58,7587,523],{"class":92},[58,7589,7590,7592,7594,7596,7598],{"class":60,"line":119},[58,7591,513],{"class":92},[58,7593,516],{"class":75},[58,7595,273],{"class":92},[58,7597,534],{"class":99},[58,7599,523],{"class":92},[58,7601,7602,7604,7606,7608,7610],{"class":60,"line":129},[58,7603,513],{"class":92},[58,7605,516],{"class":75},[58,7607,273],{"class":92},[58,7609,547],{"class":99},[58,7611,523],{"class":92},[58,7613,7614,7616,7618,7620,7622],{"class":60,"line":139},[58,7615,513],{"class":92},[58,7617,516],{"class":75},[58,7619,273],{"class":92},[58,7621,560],{"class":99},[58,7623,523],{"class":92},[58,7625,7626,7628,7630],{"class":60,"line":149},[58,7627,513],{"class":92},[58,7629,569],{"class":75},[58,7631,572],{"class":92},[44,7633,576],{"id":575},[49,7635,7636],{"className":579,"code":580,"language":581,"meta":54,"style":54},[19,7637,7638,7642,7646,7650,7656,7660,7664,7672,7680,7684,7688,7696,7706,7710,7714,7720,7732,7740],{"__ignoreMap":54},[58,7639,7640],{"class":60,"line":61},[58,7641,588],{"class":64},[58,7643,7644],{"class":60,"line":68},[58,7645,593],{"class":64},[58,7647,7648],{"class":60,"line":79},[58,7649,83],{"emptyLinePlaceholder":82},[58,7651,7652,7654],{"class":60,"line":86},[58,7653,602],{"class":382},[58,7655,605],{"class":382},[58,7657,7658],{"class":60,"line":96},[58,7659,83],{"emptyLinePlaceholder":82},[58,7661,7662],{"class":60,"line":109},[58,7663,614],{"class":64},[58,7665,7666,7668,7670],{"class":60,"line":119},[58,7667,619],{"class":75},[58,7669,622],{"class":99},[58,7671,625],{"class":382},[58,7673,7674,7676,7678],{"class":60,"line":129},[58,7675,630],{"class":75},[58,7677,633],{"class":99},[58,7679,636],{"class":99},[58,7681,7682],{"class":60,"line":139},[58,7683,83],{"emptyLinePlaceholder":82},[58,7685,7686],{"class":60,"line":149},[58,7687,645],{"class":64},[58,7689,7690,7692,7694],{"class":60,"line":159},[58,7691,650],{"class":75},[58,7693,653],{"class":382},[58,7695,656],{"class":99},[58,7697,7698,7700,7702,7704],{"class":60,"line":164},[58,7699,661],{"class":75},[58,7701,664],{"class":382},[58,7703,667],{"class":99},[58,7705,656],{"class":99},[58,7707,7708],{"class":60,"line":174},[58,7709,83],{"emptyLinePlaceholder":82},[58,7711,7712],{"class":60,"line":180},[58,7713,678],{"class":64},[58,7715,7716,7718],{"class":60,"line":185},[58,7717,683],{"class":382},[58,7719,686],{"class":99},[58,7721,7722,7724,7726,7728,7730],{"class":60,"line":191},[58,7723,53],{"class":75},[58,7725,693],{"class":99},[58,7727,696],{"class":382},[58,7729,699],{"class":99},[58,7731,702],{"class":382},[58,7733,7734,7736,7738],{"class":60,"line":208},[58,7735,707],{"class":382},[58,7737,710],{"class":99},[58,7739,702],{"class":382},[58,7741,7742],{"class":60,"line":213},[58,7743,717],{"class":99},[11,7745,720],{"id":720},[44,7747,724],{"id":723},[15,7749,727,7750,730,7752,733],{},[19,7751,569],{},[19,7753,569],{},[15,7755,7756,740,7758,744,7760,748,7762,751],{},[737,7757,739],{},[19,7759,743],{},[19,7761,747],{},[19,7763,569],{},[44,7765,755,7766,758,7768,762],{"id":754},[19,7767,34],{},[19,7769,761],{},[15,7771,765,7772,769,7774,772,7776,782],{},[19,7773,768],{},[19,7775,34],{},[737,7777,775,7778,778,7780,781],{},[19,7779,761],{},[19,7781,202],{},[49,7783,7784],{"className":51,"code":785,"language":53,"meta":54,"style":54},[19,7785,7786,7790],{"__ignoreMap":54},[58,7787,7788],{"class":60,"line":61},[58,7789,792],{"class":64},[58,7791,7792],{"class":60,"line":68},[58,7793,188],{"class":64},[15,7795,799,7796,782],{},[19,7797,802],{},[15,7799,7800,807,7802,810],{},[737,7801,739],{},[19,7803,761],{},[49,7805,7806],{"className":813,"code":814,"language":815,"meta":54,"style":54},[19,7807,7808,7812,7818,7824,7830,7836,7870,7874,7878,7882],{"__ignoreMap":54},[58,7809,7810],{"class":60,"line":61},[58,7811,822],{"class":64},[58,7813,7814,7816],{"class":60,"line":68},[58,7815,827],{"class":75},[58,7817,830],{"class":92},[58,7819,7820,7822],{"class":60,"line":79},[58,7821,835],{"class":75},[58,7823,830],{"class":92},[58,7825,7826,7828],{"class":60,"line":86},[58,7827,842],{"class":75},[58,7829,830],{"class":92},[58,7831,7832,7834],{"class":60,"line":96},[58,7833,849],{"class":75},[58,7835,852],{"class":92},[58,7837,7838,7840,7842,7844,7846,7848,7850,7852,7854,7856,7858,7860,7862,7864,7866,7868],{"class":60,"line":109},[58,7839,857],{"class":71},[58,7841,860],{"class":99},[58,7843,863],{"class":92},[58,7845,202],{"class":99},[58,7847,868],{"class":92},[58,7849,202],{"class":99},[58,7851,873],{"class":75},[58,7853,273],{"class":99},[58,7855,878],{"class":99},[58,7857,881],{"class":71},[58,7859,761],{"class":884},[58,7861,878],{"class":99},[58,7863,889],{"class":99},[58,7865,892],{"class":99},[58,7867,895],{"class":99},[58,7869,898],{"class":99},[58,7871,7872],{"class":60,"line":119},[58,7873,903],{"class":92},[58,7875,7876],{"class":60,"line":129},[58,7877,908],{"class":92},[58,7879,7880],{"class":60,"line":139},[58,7881,913],{"class":92},[58,7883,7884],{"class":60,"line":149},[58,7885,918],{"class":92},[15,7887,921,7888,924],{},[19,7889,761],{},[44,7891,928],{"id":927},[15,7893,931,7894,935],{},[19,7895,934],{},[15,7897,7898,940,7900,944],{},[737,7899,739],{},[19,7901,943],{},[11,7903,947],{"id":947},[49,7905,7906],{"className":579,"code":950,"language":581,"meta":54,"style":54},[19,7907,7908,7916,7924],{"__ignoreMap":54},[58,7909,7910,7912,7914],{"class":60,"line":61},[58,7911,957],{"class":75},[58,7913,960],{"class":99},[58,7915,963],{"class":99},[58,7917,7918,7920,7922],{"class":60,"line":68},[58,7919,968],{"class":99},[58,7921,971],{"class":99},[58,7923,974],{"class":99},[58,7925,7926,7928,7930],{"class":60,"line":79},[58,7927,979],{"class":75},[58,7929,982],{"class":99},[58,7931,985],{"class":99},[15,7933,988],{},[15,7935,7936],{},[737,7937,993],{},[995,7939,7940,7950],{},[998,7941,7942],{},[1001,7943,7944,7946,7948],{},[1004,7945,1006],{},[1004,7947,1009],{},[1004,7949,1012],{},[1014,7951,7952,7960],{},[1001,7953,7954,7956,7958],{},[1019,7955,1021],{},[1019,7957,1024],{},[1019,7959,1027],{},[1001,7961,7962,7964,7966],{},[1019,7963,1032],{},[1019,7965,1035],{},[1019,7967,1038],{},[11,7969,1041],{"id":1041},[15,7971,1044,7972,1047],{},[19,7973,34],{},[1049,7975,7976,7982],{},[1052,7977,1054,7978,778,7980,781],{},[19,7979,761],{},[19,7981,202],{},[1052,7983,1061],{},[15,7985,1064],{},[1066,7987,1068],{},{"title":54,"searchDepth":68,"depth":79,"links":7989},[7990,7991,7992,7997,8002,8003],{"id":13,"depth":68,"text":13},{"id":28,"depth":68,"text":28},{"id":42,"depth":68,"text":42,"children":7993},[7994,7995,7996],{"id":46,"depth":79,"text":47},{"id":460,"depth":79,"text":461},{"id":575,"depth":79,"text":576},{"id":720,"depth":68,"text":720,"children":7998},[7999,8000,8001],{"id":723,"depth":79,"text":724},{"id":754,"depth":79,"text":1082},{"id":927,"depth":79,"text":928},{"id":947,"depth":68,"text":947},{"id":1041,"depth":68,"text":1041},{},{"title":5,"description":1086},[1098,1099,1100,1101],{"id":8008,"title":8009,"author":6,"body":8010,"description":9406,"difficulty":2299,"draft":1088,"extension":1089,"image":9407,"meta":9408,"navigation":82,"path":9409,"pinned":1088,"published":9410,"seo":9411,"series":6,"seriesOrder":6,"stem":9412,"tags":9413,"updated":9410,"__hash__":9415},"posts\u002Fposts\u002Fjira-cli-intro\u002Findex.md","jira_cli — 把企业 Jira 装进命令行，AI Agent 直接调用",{"type":8,"value":8011,"toc":9385},[8012,8016,8031,8098,8101,8104,8181,8183,8216,8223,8226,8230,8342,8346,8477,8481,8549,8552,8615,8618,8620,8624,8627,8647,8650,8665,8668,8672,8678,8816,8827,8858,8862,8865,8962,8966,8969,9087,9091,9094,9158,9161,9165,9168,9236,9239,9245,9248,9282,9289,9292,9352,9354,9357,9374,9382],[11,8013,8015],{"id":8014},"jira_cli-是什么","jira_cli 是什么",[15,8017,8018,8023,8024,778,8027,8030],{},[2909,8019,8022],{"href":8020,"rel":8021},"https:\u002F\u002Fgithub.com\u002Fyourname\u002Fjira_cli",[2913],"jira_cli"," 是一个面向企业 Jira（Server \u002F Data Center）的本地命令行工具，Go 语言编写，MIT 协议开源。它同时提供 ",[737,8025,8026],{},"Cobra CLI",[737,8028,8029],{},"MCP stdio server"," 两种入口，既能当日常命令行用，又能直接接入 AI 编程工具。",[49,8032,8034],{"className":579,"code":8033,"language":581,"meta":54,"style":54},"# CLI 日常使用\njira_cli issue search \"project = PROJ ORDER BY updated DESC\"\njira_cli issue get PROJ-123\njira_cli issue comment PROJ-123 \"处理完成，请测试验证\"\n\n# MCP 模式 — 给 AI 装上操作 Jira 的手\njira_cli mcp --user=work\n",[19,8035,8036,8041,8054,8065,8080,8084,8089],{"__ignoreMap":54},[58,8037,8038],{"class":60,"line":61},[58,8039,8040],{"class":64},"# CLI 日常使用\n",[58,8042,8043,8045,8048,8051],{"class":60,"line":68},[58,8044,8022],{"class":75},[58,8046,8047],{"class":99}," issue",[58,8049,8050],{"class":99}," search",[58,8052,8053],{"class":99}," \"project = PROJ ORDER BY updated DESC\"\n",[58,8055,8056,8058,8060,8062],{"class":60,"line":79},[58,8057,8022],{"class":75},[58,8059,8047],{"class":99},[58,8061,4476],{"class":99},[58,8063,8064],{"class":99}," PROJ-123\n",[58,8066,8067,8069,8071,8074,8077],{"class":60,"line":86},[58,8068,8022],{"class":75},[58,8070,8047],{"class":99},[58,8072,8073],{"class":99}," comment",[58,8075,8076],{"class":99}," PROJ-123",[58,8078,8079],{"class":99}," \"处理完成，请测试验证\"\n",[58,8081,8082],{"class":60,"line":96},[58,8083,83],{"emptyLinePlaceholder":82},[58,8085,8086],{"class":60,"line":109},[58,8087,8088],{"class":64},"# MCP 模式 — 给 AI 装上操作 Jira 的手\n",[58,8090,8091,8093,8095],{"class":60,"line":119},[58,8092,8022],{"class":75},[58,8094,4712],{"class":99},[58,8096,8097],{"class":382}," --user=work\n",[11,8099,8100],{"id":8100},"为什么需要它",[15,8102,8103],{},"大多数 Jira 命令行工具只解决\"开发者手动操作 Jira\"这一个场景。但 2026 年 AI 编程工具已经普及，你的 AI Agent（Claude Code、Codex、Cursor 等）也需要读写 Jira。痛点就来了：",[995,8105,8106,8116],{},[998,8107,8108],{},[1001,8109,8110,8113],{},[1004,8111,8112],{},"痛点",[1004,8114,8115],{},"jira_cli 怎么解",[1014,8117,8118,8126,8134,8146,8158,8169],{},[1001,8119,8120,8123],{},[1019,8121,8122],{},"企业 Jira 在内网，没有公网 API",[1019,8124,8125],{},"本地 stdio MCP server，不经过外网",[1001,8127,8128,8131],{},[1019,8129,8130],{},"AI 可能误删、误写 Jira 数据",[1019,8132,8133],{},"账号级只读策略，AI 只能查不能改",[1001,8135,8136,8139],{},[1019,8137,8138],{},"团队多人多 Jira 实例",[1019,8140,8141,8142,8145],{},"多 Profile 管理，",[19,8143,8144],{},"--user=work"," 切换",[1001,8147,8148,8151],{},[1019,8149,8150],{},"Windows 终端体验差",[1019,8152,8153,8154,8157],{},"原生 ",[19,8155,8156],{},".exe"," 构建 + 一键安装脚本",[1001,8159,8160,8163],{},[1019,8161,8162],{},"AI 生成 JQL 困难",[1019,8164,8165,8166],{},"字段元数据发现：",[19,8167,8168],{},"field list --query xxx",[1001,8170,8171,8174],{},[1019,8172,8173],{},"MCP 直接拿原始 JSON 太冗长",[1019,8175,8176,8177,8180],{},"紧凑视图 + ",[19,8178,8179],{},"--view summary"," 减少 token 消耗",[11,8182,6590],{"id":6590},[49,8184,8186],{"className":579,"code":8185,"language":581,"meta":54,"style":54},"# 从源码安装（要求 Go 1.26.3+）\ngo install github.com\u002Fyourname\u002Fjira_cli@latest\n\n# 或下载预编译二进制\n# https:\u002F\u002Fgithub.com\u002Fyourname\u002Fjira_cli\u002Freleases\n",[19,8187,8188,8193,8202,8206,8211],{"__ignoreMap":54},[58,8189,8190],{"class":60,"line":61},[58,8191,8192],{"class":64},"# 从源码安装（要求 Go 1.26.3+）\n",[58,8194,8195,8197,8199],{"class":60,"line":68},[58,8196,53],{"class":75},[58,8198,622],{"class":99},[58,8200,8201],{"class":99}," github.com\u002Fyourname\u002Fjira_cli@latest\n",[58,8203,8204],{"class":60,"line":79},[58,8205,83],{"emptyLinePlaceholder":82},[58,8207,8208],{"class":60,"line":86},[58,8209,8210],{"class":64},"# 或下载预编译二进制\n",[58,8212,8213],{"class":60,"line":96},[58,8214,8215],{"class":64},"# https:\u002F\u002Fgithub.com\u002Fyourname\u002Fjira_cli\u002Freleases\n",[15,8217,8218,8219,8222],{},"Windows 用户下载 zip 包解压后双击 ",[19,8220,8221],{},"install.bat"," 即可全局安装，自动加入 PATH。",[11,8224,8225],{"id":8225},"快速上手",[44,8227,8229],{"id":8228},"_1-配置认证","1. 配置认证",[49,8231,8233],{"className":579,"code":8232,"language":581,"meta":54,"style":54},"# 设置 Jira 地址\njira_cli set base-url https:\u002F\u002Fjira.your-company.com --global\n\n# Token 认证（推荐）\njira_cli set token \u003Cyour-token> --global\n\n# 或用户名密码认证\njira_cli set auth \u003Cusername> \u003Cpassword> --global\n\n# 验证一下\njira_cli user me\n",[19,8234,8235,8240,8256,8260,8265,8285,8289,8294,8323,8327,8332],{"__ignoreMap":54},[58,8236,8237],{"class":60,"line":61},[58,8238,8239],{"class":64},"# 设置 Jira 地址\n",[58,8241,8242,8244,8247,8250,8253],{"class":60,"line":68},[58,8243,8022],{"class":75},[58,8245,8246],{"class":99}," set",[58,8248,8249],{"class":99}," base-url",[58,8251,8252],{"class":99}," https:\u002F\u002Fjira.your-company.com",[58,8254,8255],{"class":382}," --global\n",[58,8257,8258],{"class":60,"line":79},[58,8259,83],{"emptyLinePlaceholder":82},[58,8261,8262],{"class":60,"line":86},[58,8263,8264],{"class":64},"# Token 认证（推荐）\n",[58,8266,8267,8269,8271,8274,8276,8279,8281,8283],{"class":60,"line":96},[58,8268,8022],{"class":75},[58,8270,8246],{"class":99},[58,8272,8273],{"class":99}," token",[58,8275,1630],{"class":71},[58,8277,8278],{"class":99},"your-toke",[58,8280,2032],{"class":92},[58,8282,1639],{"class":71},[58,8284,8255],{"class":382},[58,8286,8287],{"class":60,"line":109},[58,8288,83],{"emptyLinePlaceholder":82},[58,8290,8291],{"class":60,"line":119},[58,8292,8293],{"class":64},"# 或用户名密码认证\n",[58,8295,8296,8298,8300,8302,8304,8307,8309,8311,8313,8316,8319,8321],{"class":60,"line":129},[58,8297,8022],{"class":75},[58,8299,8246],{"class":99},[58,8301,6664],{"class":99},[58,8303,1630],{"class":71},[58,8305,8306],{"class":99},"usernam",[58,8308,2018],{"class":92},[58,8310,1639],{"class":71},[58,8312,1630],{"class":71},[58,8314,8315],{"class":99},"passwor",[58,8317,8318],{"class":92},"d",[58,8320,1639],{"class":71},[58,8322,8255],{"class":382},[58,8324,8325],{"class":60,"line":139},[58,8326,83],{"emptyLinePlaceholder":82},[58,8328,8329],{"class":60,"line":149},[58,8330,8331],{"class":64},"# 验证一下\n",[58,8333,8334,8336,8339],{"class":60,"line":159},[58,8335,8022],{"class":75},[58,8337,8338],{"class":99}," user",[58,8340,8341],{"class":99}," me\n",[44,8343,8345],{"id":8344},"_2-日常使用","2. 日常使用",[49,8347,8349],{"className":579,"code":8348,"language":581,"meta":54,"style":54},"# 查自己今天的任务\njira_cli issue search \"assignee = currentUser() ORDER BY updated DESC\"\n\n# 看某个任务详情\njira_cli issue get PROJ-123\n\n# 流转状态\njira_cli issue transitions PROJ-123                    # 可以转哪些\njira_cli issue do-transition PROJ-123 21               # 执行流转\n\n# 添加备注\njira_cli issue comment PROJ-123 \"已定位到问题，正在修复\"\n\n# 创建任务\njira_cli issue create PROJ Task --fields @fields.json\n",[19,8350,8351,8356,8367,8371,8376,8386,8390,8395,8409,8426,8430,8435,8448,8452,8457],{"__ignoreMap":54},[58,8352,8353],{"class":60,"line":61},[58,8354,8355],{"class":64},"# 查自己今天的任务\n",[58,8357,8358,8360,8362,8364],{"class":60,"line":68},[58,8359,8022],{"class":75},[58,8361,8047],{"class":99},[58,8363,8050],{"class":99},[58,8365,8366],{"class":99}," \"assignee = currentUser() ORDER BY updated DESC\"\n",[58,8368,8369],{"class":60,"line":79},[58,8370,83],{"emptyLinePlaceholder":82},[58,8372,8373],{"class":60,"line":86},[58,8374,8375],{"class":64},"# 看某个任务详情\n",[58,8377,8378,8380,8382,8384],{"class":60,"line":96},[58,8379,8022],{"class":75},[58,8381,8047],{"class":99},[58,8383,4476],{"class":99},[58,8385,8064],{"class":99},[58,8387,8388],{"class":60,"line":109},[58,8389,83],{"emptyLinePlaceholder":82},[58,8391,8392],{"class":60,"line":119},[58,8393,8394],{"class":64},"# 流转状态\n",[58,8396,8397,8399,8401,8404,8406],{"class":60,"line":129},[58,8398,8022],{"class":75},[58,8400,8047],{"class":99},[58,8402,8403],{"class":99}," transitions",[58,8405,8076],{"class":99},[58,8407,8408],{"class":64},"                    # 可以转哪些\n",[58,8410,8411,8413,8415,8418,8420,8423],{"class":60,"line":139},[58,8412,8022],{"class":75},[58,8414,8047],{"class":99},[58,8416,8417],{"class":99}," do-transition",[58,8419,8076],{"class":99},[58,8421,8422],{"class":382}," 21",[58,8424,8425],{"class":64},"               # 执行流转\n",[58,8427,8428],{"class":60,"line":149},[58,8429,83],{"emptyLinePlaceholder":82},[58,8431,8432],{"class":60,"line":159},[58,8433,8434],{"class":64},"# 添加备注\n",[58,8436,8437,8439,8441,8443,8445],{"class":60,"line":164},[58,8438,8022],{"class":75},[58,8440,8047],{"class":99},[58,8442,8073],{"class":99},[58,8444,8076],{"class":99},[58,8446,8447],{"class":99}," \"已定位到问题，正在修复\"\n",[58,8449,8450],{"class":60,"line":174},[58,8451,83],{"emptyLinePlaceholder":82},[58,8453,8454],{"class":60,"line":180},[58,8455,8456],{"class":64},"# 创建任务\n",[58,8458,8459,8461,8463,8465,8468,8471,8474],{"class":60,"line":185},[58,8460,8022],{"class":75},[58,8462,8047],{"class":99},[58,8464,6550],{"class":99},[58,8466,8467],{"class":99}," PROJ",[58,8469,8470],{"class":99}," Task",[58,8472,8473],{"class":382}," --fields",[58,8475,8476],{"class":99}," @fields.json\n",[44,8478,8480],{"id":8479},"_3-配给-ai-使用","3. 配给 AI 使用",[49,8482,8484],{"className":579,"code":8483,"language":581,"meta":54,"style":54},"# 先配置一个只读账号给 AI 用\njira_cli set base-url https:\u002F\u002Fjira.your-company.com --name ai --global\njira_cli set token \u003Ctoken> --name ai --global\njira_cli set readonly on --name ai --global\n",[19,8485,8486,8491,8508,8531],{"__ignoreMap":54},[58,8487,8488],{"class":60,"line":61},[58,8489,8490],{"class":64},"# 先配置一个只读账号给 AI 用\n",[58,8492,8493,8495,8497,8499,8501,8503,8506],{"class":60,"line":68},[58,8494,8022],{"class":75},[58,8496,8246],{"class":99},[58,8498,8249],{"class":99},[58,8500,8252],{"class":99},[58,8502,1627],{"class":382},[58,8504,8505],{"class":99}," ai",[58,8507,8255],{"class":382},[58,8509,8510,8512,8514,8516,8518,8521,8523,8525,8527,8529],{"class":60,"line":79},[58,8511,8022],{"class":75},[58,8513,8246],{"class":99},[58,8515,8273],{"class":99},[58,8517,1630],{"class":71},[58,8519,8520],{"class":99},"toke",[58,8522,2032],{"class":92},[58,8524,1639],{"class":71},[58,8526,1627],{"class":382},[58,8528,8505],{"class":99},[58,8530,8255],{"class":382},[58,8532,8533,8535,8537,8540,8543,8545,8547],{"class":60,"line":86},[58,8534,8022],{"class":75},[58,8536,8246],{"class":99},[58,8538,8539],{"class":99}," readonly",[58,8541,8542],{"class":99}," on",[58,8544,1627],{"class":382},[58,8546,8505],{"class":99},[58,8548,8255],{"class":382},[15,8550,8551],{},"然后在 AI 工具的 MCP 配置里加上：",[49,8553,8555],{"className":6262,"code":8554,"language":6264,"meta":54,"style":54},"{\n  \"mcpServers\": {\n    \"jira\": {\n      \"command\": \"jira_cli\",\n      \"args\": [\"mcp\", \"--user=ai\"]\n    }\n  }\n}\n",[19,8556,8557,8561,8567,8574,8586,8603,8607,8611],{"__ignoreMap":54},[58,8558,8559],{"class":60,"line":61},[58,8560,6271],{"class":92},[58,8562,8563,8565],{"class":60,"line":68},[58,8564,6276],{"class":382},[58,8566,830],{"class":92},[58,8568,8569,8572],{"class":60,"line":79},[58,8570,8571],{"class":382},"    \"jira\"",[58,8573,830],{"class":92},[58,8575,8576,8579,8581,8584],{"class":60,"line":86},[58,8577,8578],{"class":382},"      \"command\"",[58,8580,2794],{"class":92},[58,8582,8583],{"class":99},"\"jira_cli\"",[58,8585,2741],{"class":92},[58,8587,8588,8591,8593,8596,8598,8601],{"class":60,"line":96},[58,8589,8590],{"class":382},"      \"args\"",[58,8592,2825],{"class":92},[58,8594,8595],{"class":99},"\"mcp\"",[58,8597,889],{"class":92},[58,8599,8600],{"class":99},"\"--user=ai\"",[58,8602,1552],{"class":92},[58,8604,8605],{"class":60,"line":109},[58,8606,451],{"class":92},[58,8608,8609],{"class":60,"line":119},[58,8610,3050],{"class":92},[58,8612,8613],{"class":60,"line":129},[58,8614,457],{"class":92},[15,8616,8617],{},"这样 AI 就能查 Jira 但不能修改，既有了上下文，又不用担心误操作。",[11,8619,7072],{"id":7072},[44,8621,8623],{"id":8622},"_1-账号级只读策略","1. 账号级只读策略",[15,8625,8626],{},"这是 jira_cli 最实用的设计。给 AI Agent 配置只读账号：",[49,8628,8630],{"className":579,"code":8629,"language":581,"meta":54,"style":54},"jira_cli set readonly on --name ai\n",[19,8631,8632],{"__ignoreMap":54},[58,8633,8634,8636,8638,8640,8642,8644],{"class":60,"line":61},[58,8635,8022],{"class":75},[58,8637,8246],{"class":99},[58,8639,8539],{"class":99},[58,8641,8542],{"class":99},[58,8643,1627],{"class":382},[58,8645,8646],{"class":99}," ai\n",[15,8648,8649],{},"只读模式下：",[2638,8651,8652,8655,8658],{},[1052,8653,8654],{},"CLI 写命令被隐藏和拒绝",[1052,8656,8657],{},"MCP 的写入工具不会注册",[1052,8659,8660,8661,8664],{},"环境变量 ",[19,8662,8663],{},"JIRA_READONLY=true"," 也可以临时覆盖",[15,8666,8667],{},"AI 只能查 JQL、看任务、查字段、读评论，绝对不会误改数据。需要创建\u002F更新时，另配一个有写入权限的账号由开发者自己操作。",[44,8669,8671],{"id":8670},"_2-多-profile-切换","2. 多 Profile 切换",[15,8673,8674,8675,8677],{},"一个配置文件里管理多个 Jira 账号，",[19,8676,1983],{}," 切换：",[49,8679,8681],{"className":579,"code":8680,"language":581,"meta":54,"style":54},"# 工作账号\njira_cli set base-url https:\u002F\u002Fjira.company.com --name work --global\njira_cli set token \u003Cwork-token> --name work --global\n\n# 个人账号\njira_cli set base-url https:\u002F\u002Fjira.personal.com --name personal --global\njira_cli set token \u003Cpersonal-token> --name personal --global\n\n# 使用时切换\njira_cli --user=work issue search \"project = PROJ\"\njira_cli --user=personal issue search \"project = SIDE\"\n",[19,8682,8683,8688,8706,8729,8733,8738,8756,8779,8783,8788,8802],{"__ignoreMap":54},[58,8684,8685],{"class":60,"line":61},[58,8686,8687],{"class":64},"# 工作账号\n",[58,8689,8690,8692,8694,8696,8699,8701,8704],{"class":60,"line":68},[58,8691,8022],{"class":75},[58,8693,8246],{"class":99},[58,8695,8249],{"class":99},[58,8697,8698],{"class":99}," https:\u002F\u002Fjira.company.com",[58,8700,1627],{"class":382},[58,8702,8703],{"class":99}," work",[58,8705,8255],{"class":382},[58,8707,8708,8710,8712,8714,8716,8719,8721,8723,8725,8727],{"class":60,"line":79},[58,8709,8022],{"class":75},[58,8711,8246],{"class":99},[58,8713,8273],{"class":99},[58,8715,1630],{"class":71},[58,8717,8718],{"class":99},"work-toke",[58,8720,2032],{"class":92},[58,8722,1639],{"class":71},[58,8724,1627],{"class":382},[58,8726,8703],{"class":99},[58,8728,8255],{"class":382},[58,8730,8731],{"class":60,"line":86},[58,8732,83],{"emptyLinePlaceholder":82},[58,8734,8735],{"class":60,"line":96},[58,8736,8737],{"class":64},"# 个人账号\n",[58,8739,8740,8742,8744,8746,8749,8751,8754],{"class":60,"line":109},[58,8741,8022],{"class":75},[58,8743,8246],{"class":99},[58,8745,8249],{"class":99},[58,8747,8748],{"class":99}," https:\u002F\u002Fjira.personal.com",[58,8750,1627],{"class":382},[58,8752,8753],{"class":99}," personal",[58,8755,8255],{"class":382},[58,8757,8758,8760,8762,8764,8766,8769,8771,8773,8775,8777],{"class":60,"line":119},[58,8759,8022],{"class":75},[58,8761,8246],{"class":99},[58,8763,8273],{"class":99},[58,8765,1630],{"class":71},[58,8767,8768],{"class":99},"personal-toke",[58,8770,2032],{"class":92},[58,8772,1639],{"class":71},[58,8774,1627],{"class":382},[58,8776,8753],{"class":99},[58,8778,8255],{"class":382},[58,8780,8781],{"class":60,"line":129},[58,8782,83],{"emptyLinePlaceholder":82},[58,8784,8785],{"class":60,"line":139},[58,8786,8787],{"class":64},"# 使用时切换\n",[58,8789,8790,8792,8795,8797,8799],{"class":60,"line":149},[58,8791,8022],{"class":75},[58,8793,8794],{"class":382}," --user=work",[58,8796,8047],{"class":99},[58,8798,8050],{"class":99},[58,8800,8801],{"class":99}," \"project = PROJ\"\n",[58,8803,8804,8806,8809,8811,8813],{"class":60,"line":159},[58,8805,8022],{"class":75},[58,8807,8808],{"class":382}," --user=personal",[58,8810,8047],{"class":99},[58,8812,8050],{"class":99},[58,8814,8815],{"class":99}," \"project = SIDE\"\n",[15,8817,8818,8819,8822,8823,8826],{},"同时支持",[737,8820,8821],{},"项目级配置","（",[19,8824,8825],{},"--local","）和工作目录绑定：",[49,8828,8830],{"className":579,"code":8829,"language":581,"meta":54,"style":54},"# 在项目目录下，配置只对当前项目生效\ncd ~\u002Fprojects\u002Fbackend\njira_cli set base-url https:\u002F\u002Fjira-backend.company.com --local\n",[19,8831,8832,8837,8844],{"__ignoreMap":54},[58,8833,8834],{"class":60,"line":61},[58,8835,8836],{"class":64},"# 在项目目录下，配置只对当前项目生效\n",[58,8838,8839,8841],{"class":60,"line":68},[58,8840,683],{"class":382},[58,8842,8843],{"class":99}," ~\u002Fprojects\u002Fbackend\n",[58,8845,8846,8848,8850,8852,8855],{"class":60,"line":79},[58,8847,8022],{"class":75},[58,8849,8246],{"class":99},[58,8851,8249],{"class":99},[58,8853,8854],{"class":99}," https:\u002F\u002Fjira-backend.company.com",[58,8856,8857],{"class":382}," --local\n",[44,8859,8861],{"id":8860},"_3-对-ai-友好的输出","3. 对 AI 友好的输出",[15,8863,8864],{},"jira_cli 在输出设计上充分考虑了 AI Agent 的 token 消耗：",[49,8866,8868],{"className":579,"code":8867,"language":581,"meta":54,"style":54},"# 紧凑视图 — 去掉 self\u002Favatar\u002F缩略图等无用字段\njira_cli issue get PROJ-123 --view summary\n\n# 表格输出 — 一眼看到关键信息\njira_cli issue search \"project = PROJ\" --output table\n\n# JSON 输出 — 程序解析，不转义 Unicode\njira_cli --output json issue get PROJ-123\n\n# 自定义搜索列 — 只要需要的字段\njira_cli set search-columns key,summary,fixVersions\n",[19,8869,8870,8875,8891,8895,8900,8917,8921,8926,8941,8945,8950],{"__ignoreMap":54},[58,8871,8872],{"class":60,"line":61},[58,8873,8874],{"class":64},"# 紧凑视图 — 去掉 self\u002Favatar\u002F缩略图等无用字段\n",[58,8876,8877,8879,8881,8883,8885,8888],{"class":60,"line":68},[58,8878,8022],{"class":75},[58,8880,8047],{"class":99},[58,8882,4476],{"class":99},[58,8884,8076],{"class":99},[58,8886,8887],{"class":382}," --view",[58,8889,8890],{"class":99}," summary\n",[58,8892,8893],{"class":60,"line":79},[58,8894,83],{"emptyLinePlaceholder":82},[58,8896,8897],{"class":60,"line":86},[58,8898,8899],{"class":64},"# 表格输出 — 一眼看到关键信息\n",[58,8901,8902,8904,8906,8908,8911,8914],{"class":60,"line":96},[58,8903,8022],{"class":75},[58,8905,8047],{"class":99},[58,8907,8050],{"class":99},[58,8909,8910],{"class":99}," \"project = PROJ\"",[58,8912,8913],{"class":382}," --output",[58,8915,8916],{"class":99}," table\n",[58,8918,8919],{"class":60,"line":109},[58,8920,83],{"emptyLinePlaceholder":82},[58,8922,8923],{"class":60,"line":119},[58,8924,8925],{"class":64},"# JSON 输出 — 程序解析，不转义 Unicode\n",[58,8927,8928,8930,8932,8935,8937,8939],{"class":60,"line":129},[58,8929,8022],{"class":75},[58,8931,8913],{"class":382},[58,8933,8934],{"class":99}," json",[58,8936,8047],{"class":99},[58,8938,4476],{"class":99},[58,8940,8064],{"class":99},[58,8942,8943],{"class":60,"line":139},[58,8944,83],{"emptyLinePlaceholder":82},[58,8946,8947],{"class":60,"line":149},[58,8948,8949],{"class":64},"# 自定义搜索列 — 只要需要的字段\n",[58,8951,8952,8954,8956,8959],{"class":60,"line":159},[58,8953,8022],{"class":75},[58,8955,8246],{"class":99},[58,8957,8958],{"class":99}," search-columns",[58,8960,8961],{"class":99}," key,summary,fixVersions\n",[44,8963,8965],{"id":8964},"_4-字段元数据发现","4. 字段元数据发现",[15,8967,8968],{},"Jira 的 customfield 让人头疼，jira_cli 提供了完整的字段发现能力：",[49,8970,8972],{"className":579,"code":8971,"language":581,"meta":54,"style":54},"# 列出所有字段及其类型\njira_cli field list\n\n# 搜索包含特定关键词的字段\njira_cli field list --query \"版本\"\n\n# 只看自定义字段\njira_cli field list --custom-only\n\n# 创建任务前，先看看需要哪些字段\njira_cli issue create-meta PROJ --issue-type Task\n\n# 分页查看可选值\njira_cli issue create-meta-values PROJ Task priority --query High\n",[19,8973,8974,8979,8988,8992,8997,9011,9015,9020,9031,9035,9040,9057,9061,9066],{"__ignoreMap":54},[58,8975,8976],{"class":60,"line":61},[58,8977,8978],{"class":64},"# 列出所有字段及其类型\n",[58,8980,8981,8983,8986],{"class":60,"line":68},[58,8982,8022],{"class":75},[58,8984,8985],{"class":99}," field",[58,8987,3137],{"class":99},[58,8989,8990],{"class":60,"line":79},[58,8991,83],{"emptyLinePlaceholder":82},[58,8993,8994],{"class":60,"line":86},[58,8995,8996],{"class":64},"# 搜索包含特定关键词的字段\n",[58,8998,8999,9001,9003,9005,9008],{"class":60,"line":96},[58,9000,8022],{"class":75},[58,9002,8985],{"class":99},[58,9004,6485],{"class":99},[58,9006,9007],{"class":382}," --query",[58,9009,9010],{"class":99}," \"版本\"\n",[58,9012,9013],{"class":60,"line":109},[58,9014,83],{"emptyLinePlaceholder":82},[58,9016,9017],{"class":60,"line":119},[58,9018,9019],{"class":64},"# 只看自定义字段\n",[58,9021,9022,9024,9026,9028],{"class":60,"line":129},[58,9023,8022],{"class":75},[58,9025,8985],{"class":99},[58,9027,6485],{"class":99},[58,9029,9030],{"class":382}," --custom-only\n",[58,9032,9033],{"class":60,"line":139},[58,9034,83],{"emptyLinePlaceholder":82},[58,9036,9037],{"class":60,"line":149},[58,9038,9039],{"class":64},"# 创建任务前，先看看需要哪些字段\n",[58,9041,9042,9044,9046,9049,9051,9054],{"class":60,"line":159},[58,9043,8022],{"class":75},[58,9045,8047],{"class":99},[58,9047,9048],{"class":99}," create-meta",[58,9050,8467],{"class":99},[58,9052,9053],{"class":382}," --issue-type",[58,9055,9056],{"class":99}," Task\n",[58,9058,9059],{"class":60,"line":164},[58,9060,83],{"emptyLinePlaceholder":82},[58,9062,9063],{"class":60,"line":174},[58,9064,9065],{"class":64},"# 分页查看可选值\n",[58,9067,9068,9070,9072,9075,9077,9079,9082,9084],{"class":60,"line":180},[58,9069,8022],{"class":75},[58,9071,8047],{"class":99},[58,9073,9074],{"class":99}," create-meta-values",[58,9076,8467],{"class":99},[58,9078,8470],{"class":99},[58,9080,9081],{"class":99}," priority",[58,9083,9007],{"class":382},[58,9085,9086],{"class":99}," High\n",[44,9088,9090],{"id":9089},"_5-mcp-写入控制","5. MCP 写入控制",[15,9092,9093],{},"除了全局只读，MCP 还有更细粒度的控制：",[995,9095,9096,9106],{},[998,9097,9098],{},[1001,9099,9100,9103],{},[1004,9101,9102],{},"环境变量",[1004,9104,9105],{},"作用",[1014,9107,9108,9118,9128,9138,9148],{},[1001,9109,9110,9115],{},[1019,9111,9112],{},[19,9113,9114],{},"JIRA_CREATE_OPEN=false",[1019,9116,9117],{},"禁用创建\u002F更新工具",[1001,9119,9120,9125],{},[1019,9121,9122],{},[19,9123,9124],{},"JIRA_DISABLE_DISCLAIMER=true",[1019,9126,9127],{},"去掉 AI 自动添加的备注声明",[1001,9129,9130,9135],{},[1019,9131,9132],{},[19,9133,9134],{},"JIRA_CREATE_SUMMARY_MAX",[1019,9136,9137],{},"限制摘要长度",[1001,9139,9140,9145],{},[1019,9141,9142],{},[19,9143,9144],{},"JIRA_CREATE_DESCRIPTION_MAX",[1019,9146,9147],{},"限制描述长度",[1001,9149,9150,9155],{},[1019,9151,9152],{},[19,9153,9154],{},"JIRA_CREATE_FIELDS_MAX_BYTES",[1019,9156,9157],{},"限制创建字段的 JSON 大小",[15,9159,9160],{},"这些限制确保 AI 即使有写入权限也不会\"失控\"——例如防止 AI 往描述里塞几万字的废话。",[44,9162,9164],{"id":9163},"_6-中英双语帮助","6. 中英双语帮助",[15,9166,9167],{},"帮助信息默认中文，也支持一键切英文：",[49,9169,9171],{"className":579,"code":9170,"language":581,"meta":54,"style":54},"# 保存英文偏好\njira_cli set help-language en\n\n# 切回中文\njira_cli set help-language zh\n\n# 临时覆盖\njira_cli --lang en issue search -h\n",[19,9172,9173,9178,9190,9194,9199,9210,9214,9219],{"__ignoreMap":54},[58,9174,9175],{"class":60,"line":61},[58,9176,9177],{"class":64},"# 保存英文偏好\n",[58,9179,9180,9182,9184,9187],{"class":60,"line":68},[58,9181,8022],{"class":75},[58,9183,8246],{"class":99},[58,9185,9186],{"class":99}," help-language",[58,9188,9189],{"class":99}," en\n",[58,9191,9192],{"class":60,"line":79},[58,9193,83],{"emptyLinePlaceholder":82},[58,9195,9196],{"class":60,"line":86},[58,9197,9198],{"class":64},"# 切回中文\n",[58,9200,9201,9203,9205,9207],{"class":60,"line":96},[58,9202,8022],{"class":75},[58,9204,8246],{"class":99},[58,9206,9186],{"class":99},[58,9208,9209],{"class":99}," zh\n",[58,9211,9212],{"class":60,"line":109},[58,9213,83],{"emptyLinePlaceholder":82},[58,9215,9216],{"class":60,"line":119},[58,9217,9218],{"class":64},"# 临时覆盖\n",[58,9220,9221,9223,9226,9229,9231,9233],{"class":60,"line":129},[58,9222,8022],{"class":75},[58,9224,9225],{"class":382}," --lang",[58,9227,9228],{"class":99}," en",[58,9230,8047],{"class":99},[58,9232,8050],{"class":99},[58,9234,9235],{"class":382}," -h\n",[11,9237,9238],{"id":9238},"架构一览",[49,9240,9243],{"className":9241,"code":9242,"language":2407},[2405],"main.go\n  cmd\u002F                         CLI 命令、MCP 启动、访问策略\n  internal\u002Fconfig\u002F             运行时配置、Profile 持久化\n  internal\u002Ftools\u002Fjira_tools\u002F   MCP 输入\u002F输出适配层\n  pkg\u002Fjira\u002F                    可复用的 Jira REST 客户端\n",[19,9244,9242],{"__ignoreMap":54},[15,9246,9247],{},"设计原则清晰：",[2638,9249,9250,9258,9266,9274],{},[1052,9251,9252,9257],{},[737,9253,9254],{},[19,9255,9256],{},"pkg\u002Fjira"," 是纯 Jira 客户端，HTTP、认证、上下文处理都在这里",[1052,9259,9260,9265],{},[737,9261,9262],{},[19,9263,9264],{},"cmd"," 只管 Cobra 解析和呈现",[1052,9267,9268,9273],{},[737,9269,9270],{},[19,9271,9272],{},"internal\u002Ftools\u002Fjira_tools"," 只管 MCP schema 验证和结果适配",[1052,9275,9276,9281],{},[737,9277,9278],{},[19,9279,9280],{},"internal\u002Fconfig"," 拥有 Profile 的全部逻辑，不分散到 cmd",[15,9283,9284,9285,9288],{},"每一层职责单一，新增 Jira 能力时按 ",[19,9286,9287],{},"endpoints.go → Client 方法 → CLI 命令 → MCP tool → 策略注册"," 的路径走就行。",[11,9290,9291],{"id":9291},"和已有方案的对比",[995,9293,9294,9306],{},[998,9295,9296],{},[1001,9297,9298,9300,9303],{},[1004,9299,3752],{},[1004,9301,9302],{},"适合场景",[1004,9304,9305],{},"jira_cli 的区别",[1014,9307,9308,9319,9330,9341],{},[1001,9309,9310,9313,9316],{},[1019,9311,9312],{},"Atlassian Rovo MCP Server",[1019,9314,9315],{},"Jira Cloud、官方 OAuth",[1019,9317,9318],{},"jira_cli 是本地 stdio，适合内网 Server\u002FDC",[1001,9320,9321,9324,9327],{},[1019,9322,9323],{},"go-jira \u002F jira-cli 社区版",[1019,9325,9326],{},"纯命令行管理",[1019,9328,9329],{},"jira_cli 同时提供 MCP server，面向 AI 工作流",[1001,9331,9332,9335,9338],{},[1019,9333,9334],{},"直接调 REST API",[1019,9336,9337],{},"自定义集成",[1019,9339,9340],{},"jira_cli 封装了认证、只读策略、字段元数据",[1001,9342,9343,9346,9349],{},[1019,9344,9345],{},"Jira 网页",[1019,9347,9348],{},"日常使用",[1019,9350,9351],{},"jira_cli 把 Jira 变成终端里的一行命令",[11,9353,1041],{"id":1041},[15,9355,9356],{},"如果你的团队满足以下任一条件，jira_cli 值得一试：",[2638,9358,9359,9362,9365,9368,9371],{},[1052,9360,9361],{},"企业 Jira 部署在内网，想接入 AI 编程工具",[1052,9363,9364],{},"想给 AI Agent 只读权限来查 Jira 上下文",[1052,9366,9367],{},"需要命令行快速查看\u002F操作 Jira，不想切浏览器",[1052,9369,9370],{},"需要管理多个 Jira 实例和多套账号",[1052,9372,9373],{},"Windows 用户想要无摩擦的 CLI 体验",[15,9375,9376,9377,9381],{},"GitHub：",[2909,9378,9380],{"href":8020,"rel":9379},[2913],"github.com\u002Fyourname\u002Fjira_cli"," | MIT 协议 | Go 1.26.3+",[1066,9383,9384],{},"html pre.shiki code .sHbNN, html code.shiki .sHbNN{--shiki-light:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sw2iP, html code.shiki .sw2iP{--shiki-light:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .scXbn, html code.shiki .scXbn{--shiki-light:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .suQ91, html code.shiki .suQ91{--shiki-light:#005CC5;--shiki-dark:#79B8FF}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .siTax, html code.shiki .siTax{--shiki-light:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sIX_F, html code.shiki .sIX_F{--shiki-light:#24292E;--shiki-dark:#E1E4E8}",{"title":54,"searchDepth":68,"depth":79,"links":9386},[9387,9388,9389,9390,9395,9403,9404,9405],{"id":8014,"depth":68,"text":8015},{"id":8100,"depth":68,"text":8100},{"id":6590,"depth":68,"text":6590},{"id":8225,"depth":68,"text":8225,"children":9391},[9392,9393,9394],{"id":8228,"depth":79,"text":8229},{"id":8344,"depth":79,"text":8345},{"id":8479,"depth":79,"text":8480},{"id":7072,"depth":68,"text":7072,"children":9396},[9397,9398,9399,9400,9401,9402],{"id":8622,"depth":79,"text":8623},{"id":8670,"depth":79,"text":8671},{"id":8860,"depth":79,"text":8861},{"id":8964,"depth":79,"text":8965},{"id":9089,"depth":79,"text":9090},{"id":9163,"depth":79,"text":9164},{"id":9238,"depth":68,"text":9238},{"id":9291,"depth":68,"text":9291},{"id":1041,"depth":68,"text":1041},"介绍 jira_cli，一个开源的企业 Jira CLI 命令行工具和MCP，支持 Jira Server \u002F Data Center，自带 MCP stdio server，让 Claude Code、Codex 等 AI 编程工具直接读写 Jira 任务。","\u002Fposts\u002Fjira-cli-intro\u002Fimg\u002Fcover.svg",{},"\u002Fposts\u002Fjira-cli-intro","2026-07-17",{"title":8009,"description":9406},"posts\u002Fjira-cli-intro\u002Findex",[9414,7222,6437,1098,6439,7223],"Jira","vNH6sF2mMFmSjbHoEI2OqmYCgpFmaqyKt-MUfcQqXrU",{"id":9417,"title":9418,"author":6,"body":9419,"description":9963,"difficulty":2299,"draft":1088,"extension":1089,"image":9964,"meta":9965,"navigation":82,"path":9966,"pinned":1088,"published":9967,"seo":9968,"series":9969,"seriesOrder":61,"stem":9970,"tags":9971,"updated":9974,"__hash__":9975},"posts\u002Fposts\u002Fsublog-evolution\u002Findex.md","尼克的小窝 架构变迁：从纯静态到前后端分离",{"type":8,"value":9420,"toc":9943},[9421,9425,9428,9439,9443,9447,9453,9457,9488,9491,9505,9509,9512,9580,9584,9587,9593,9597,9607,9637,9640,9644,9652,9655,9699,9703,9824,9828,9835,9839,9845,9851,9855,9858,9909,9913,9920,9927,9933,9936,9938],[11,9422,9424],{"id":9423},"写在前面","📝 写在前面",[15,9426,9427],{},"SuBlog 最初只是一个简单的技术博客——Nuxt 3 静态生成，Markdown 写文章，部署到静态托管完事。但随着功能需求的增长，纯静态的架构开始显露出局限性。",[15,9429,9430,9431,9434,9435,9438],{},"这篇文章记录从 ",[737,9432,9433],{},"纯静态"," 到 ",[737,9436,9437],{},"前后端分离"," 的演进过程，带你了解每一步背后的思考。",[11,9440,9442],{"id":9441},"第一代纯静态nuxt-3-nuxtcontent","📦 第一代：纯静态（Nuxt 3 + @nuxt\u002Fcontent）",[44,9444,9446],{"id":9445},"️-架构图","🏗️ 架构图",[49,9448,9451],{"className":9449,"code":9450,"language":2407,"meta":54},[2405],"┌─────────────────────────────────────┐\n│          静态托管 (Nginx\u002FVercel)      │\n│                                     │\n│  ┌───────────────────────────────┐  │\n│  │     Nuxt 3 SSG 产物            │  │\n│  │                               │  │\n│  │  pages\u002F + content\u002F + public\u002F  │  │\n│  └───────────────────────────────┘  │\n│                                     │\n│  Giscus (GitHub Discussions 评论)    │\n└─────────────────────────────────────┘\n",[19,9452,9450],{"__ignoreMap":54},[44,9454,9456],{"id":9455},"做了什么","🔧 做了什么",[2638,9458,9459,9468,9473,9478],{},[1052,9460,9461,2265,9463,2265,9465,9467],{},[737,9462,2577],{},[737,9464,2587],{},[737,9466,2597],{}," 搭建前端",[1052,9469,9470,9472],{},[737,9471,2607],{}," 管理 Markdown 文章，编译时生成静态页面",[1052,9474,9475,9477],{},[737,9476,2627],{}," 嵌入评论，依赖 GitHub Discussions",[1052,9479,9480,9481,9484,9485,9487],{},"部署通过 ",[19,9482,9483],{},"nuxt generate"," 输出 ",[19,9486,2866],{},"，丢到 Nginx 或 Vercel",[44,9489,9490],{"id":3755},"✅ 优点",[2638,9492,9493,9499,9502],{},[1052,9494,9495,9496,9498],{},"部署极简，一个 ",[19,9497,9483],{}," 搞定",[1052,9500,9501],{},"加载飞快，全是 HTML，没有服务端开销",[1052,9503,9504],{},"无需后端，零运维成本",[44,9506,9508],{"id":9507},"局限","❌ 局限",[15,9510,9511],{},"随着时间的推移，纯静态的问题越来越明显：",[995,9513,9514,9523],{},[998,9515,9516],{},[1001,9517,9518,9520],{},[1004,9519,4048],{},[1004,9521,9522],{},"具体表现",[1014,9524,9525,9536,9547,9558,9569],{},[1001,9526,9527,9533],{},[1019,9528,9529,9530],{},"🐢 ",[737,9531,9532],{},"内容更新慢",[1019,9534,9535],{},"改一篇文章要重新构建、重新部署，不能实时发布",[1001,9537,9538,9544],{},[1019,9539,9540,9541],{},"👤 ",[737,9542,9543],{},"没有用户体系",[1019,9545,9546],{},"无法区分读者和管理员，所有内容对所有人一致可见",[1001,9548,9549,9555],{},[1019,9550,9551,9552],{},"🔒 ",[737,9553,9554],{},"评论受限",[1019,9556,9557],{},"Giscus 需要 GitHub 账号才能评论，门槛高",[1001,9559,9560,9566],{},[1019,9561,9562,9563],{},"🚫 ",[737,9564,9565],{},"无法做动态功能",[1019,9567,9568],{},"点赞、收藏、阅读统计、个性化推荐……统统做不了",[1001,9570,9571,9577],{},[1019,9572,9573,9574],{},"🔗 ",[737,9575,9576],{},"前端锁死",[1019,9578,9579],{},"被 Nuxt\u002FVue 生态绑定，想换技术栈得重写",[11,9581,9583],{"id":9582},"第二代前后端分离react-go","⚡ 第二代：前后端分离（React + Go）",[44,9585,9446],{"id":9586},"️-架构图-1",[49,9588,9591],{"className":9589,"code":9590,"language":2407,"meta":54},[2405],"┌───────────────────────┐       ┌────────────────────┐\n│    静态托管 (Vercel)    │       │   Go 服务端         │\n│                       │       │                    │\n│  ┌─────────────────┐  │       │  ┌──────────────┐  │\n│  │   sublog-react   │  │  API  │  │  HTTP API    │  │\n│  │  (Next.js SSG)  │──┼───────┼─>│              │  │\n│  │                  │  │       │  │ 文章 \u002F 用户  │  │\n│  │  首页 \u002F 文章 \u002F   │  │       │  └──────────────┘  │\n│  │  用户中心        │  │       │                    │\n│  └─────────────────┘  │       │  ┌──────────────┐  │\n│                       │       │  │   数据库      │  │\n└───────────────────────┘       │  └──────────────┘  │\n                                └────────────────────┘\n",[19,9592,9590],{"__ignoreMap":54},[44,9594,9596],{"id":9595},"️-前端sublog-react","🖥️ 前端：sublog-react",[15,9598,9599,9600,9602,9603,9606],{},"选用 ",[737,9601,2434],{},"（App Router） + ",[737,9604,9605],{},"React 19","，理由是：",[2638,9608,9609,9616,9623,9630],{},[1052,9610,9611,9612,9615],{},"🎨 ",[737,9613,9614],{},"React 生态","——组件库成熟，开发效率高",[1052,9617,9618,9619,9622],{},"📐 ",[737,9620,9621],{},"App Router","——支持布局嵌套，页面组织清晰",[1052,9624,9625,9626,9629],{},"📦 ",[737,9627,9628],{},"静态导出","——仍可部署到静态托管",[1052,9631,9632,9633,9636],{},"⚙️ ",[737,9634,9635],{},"运行时配置","——API 地址可动态注入，切换环境无需重新构建",[15,9638,9639],{},"前端不再直接读取 Markdown 文件，而是通过 HTTP API 获取文章数据。静态导出 + 客户端数据获取的组合，既保留了首屏加载速度，又实现了内容的动态更新。",[44,9641,9643],{"id":9642},"️-后端sublog-s","⚙️ 后端：sublog-s",[15,9645,9646,9647,2265,9649,9651],{},"后端使用 ",[737,9648,1098],{},[737,9650,6438],{},"，按业务逻辑分层组织代码。",[15,9653,9654],{},"核心技术选型：",[995,9656,9657,9665],{},[998,9658,9659],{},[1001,9660,9661,9663],{},[1004,9662,2565],{},[1004,9664,2568],{},[1014,9666,9667,9677,9688],{},[1001,9668,9669,9674],{},[1019,9670,9671,9672],{},"🚀 ",[737,9673,6438],{},[1019,9675,9676],{},"HTTP 框架",[1001,9678,9679,9685],{},[1019,9680,9681,9682],{},"🗄️ ",[737,9683,9684],{},"GORM",[1019,9686,9687],{},"ORM，数据库操作",[1001,9689,9690,9696],{},[1019,9691,9692,9693],{},"🔐 ",[737,9694,9695],{},"JWT",[1019,9697,9698],{},"用户认证",[11,9700,9702],{"id":9701},"前后对比","📊 前后对比",[995,9704,9705,9718],{},[998,9706,9707],{},[1001,9708,9709,9712,9715],{},[1004,9710,9711],{},"维度",[1004,9713,9714],{},"第一代（纯静态）",[1004,9716,9717],{},"第二代（前后端分离）",[1014,9719,9720,9732,9744,9758,9771,9784,9798,9810],{},[1001,9721,9722,9726,9729],{},[1019,9723,9611,9724],{},[737,9725,2530],{},[1019,9727,9728],{},"Nuxt 3 (Vue)",[1019,9730,9731],{},"Next.js (React 19)",[1001,9733,9734,9739,9741],{},[1019,9735,9736,9737],{},"🖥️ ",[737,9738,6440],{},[1019,9740,2473],{},[1019,9742,9743],{},"Go + Gin",[1001,9745,9746,9752,9755],{},[1019,9747,9748,9749],{},"📄 ",[737,9750,9751],{},"内容来源",[1019,9753,9754],{},"Markdown 文件",[1019,9756,9757],{},"API 数据库",[1001,9759,9760,9765,9768],{},[1019,9761,9671,9762],{},[737,9763,9764],{},"发布方式",[1019,9766,9767],{},"全量重新构建",[1019,9769,9770],{},"API 实时发布",[1001,9772,9773,9778,9781],{},[1019,9774,9540,9775],{},[737,9776,9777],{},"用户系统",[1019,9779,9780],{},"❌ 无",[1019,9782,9783],{},"✅ 注册\u002F登录",[1001,9785,9786,9792,9795],{},[1019,9787,9788,9789],{},"💬 ",[737,9790,9791],{},"评论",[1019,9793,9794],{},"Giscus（需 GitHub）",[1019,9796,9797],{},"自有评论系统（规划中）",[1001,9799,9800,9804,9807],{},[1019,9801,9625,9802],{},[737,9803,2842],{},[1019,9805,9806],{},"1 个静态目录",[1019,9808,9809],{},"前端静态 + 后端二进制",[1001,9811,9812,9818,9821],{},[1019,9813,9814,9815],{},"⚡ ",[737,9816,9817],{},"更新体验",[1019,9819,9820],{},"改文章 → 构建 → 部署",[1019,9822,9823],{},"改文章 → 立即生效",[11,9825,9827],{"id":9826},"新功能注册账号","🎉 新功能：注册账号",[15,9829,9830,9831,9834],{},"伴随着架构升级，",[737,9832,9833],{},"用户账号系统","也上线了。",[44,9836,9838],{"id":9837},"目前支持","📋 目前支持",[15,9840,9841,9842,4215],{},"现在你可以在尼克的小窝 ",[737,9843,9844],{},"注册一个账号",[49,9846,9849],{"className":9847,"code":9848,"language":2407},[2405],"1. ✏️ 填写用户名、邮箱、密码\n2. 📧 查收激活邮件\n3. 🔗 点击链接激活账号\n4. 🚪 登录后查看和管理个人信息\n",[19,9850,9848],{"__ignoreMap":54},[11,9852,9854],{"id":9853},"️-后续计划","🗓️ 后续计划",[15,9856,9857],{},"正在开发或规划中的功能：",[2638,9859,9861,9867,9873,9879,9885,9891,9897,9903],{"className":9860},[2874],[1052,9862,9864,9866],{"className":9863},[2878],[2880,9865],{"checked":82,"disabled":82,"type":2882}," 用户注册\u002F登录（已上线 🎉）",[1052,9868,9870,9872],{"className":9869},[2878],[2880,9871],{"checked":82,"disabled":82,"type":2882}," 个人资料编辑（已上线 🎉）",[1052,9874,9876,9878],{"className":9875},[2878],[2880,9877],{"disabled":82,"type":2882}," 💬 站内评论系统",[1052,9880,9882,9884],{"className":9881},[2878],[2880,9883],{"disabled":82,"type":2882}," 🔖 文章收藏",[1052,9886,9888,9890],{"className":9887},[2878],[2880,9889],{"disabled":82,"type":2882}," 📚 阅读历史",[1052,9892,9894,9896],{"className":9893},[2878],[2880,9895],{"disabled":82,"type":2882}," 👍 文章点赞",[1052,9898,9900,9902],{"className":9899},[2878],[2880,9901],{"disabled":82,"type":2882}," 🔔 关注通知",[1052,9904,9906,9908],{"className":9905},[2878],[2880,9907],{"disabled":82,"type":2882}," 🎨 个人首页定制",[11,9910,9912],{"id":9911},"结语","💡 结语",[15,9914,9915,9916,9919],{},"从 Nuxt 3 纯静态到 React + Go 前后端分离，SuBlog 的架构变迁本质上是一个",[737,9917,9918],{},"功能驱动","的过程。静态博客有它的位置——写写文章、展示内容完全够用。但当你想做的不仅仅是\"展示\"时，后端的能力就不可或缺了。",[15,9921,9922,9923,9926],{},"这次重构最大的感触是：",[737,9924,9925],{},"架构选型不是越复杂越好，而是刚好够用最好","。纯静态阶段用 Nuxt 3 一点问题没有，直到功能需求超出了静态的边界，才自然推动到前后端分离。",[15,9928,9929,9930],{},"如果你也在纠结\"要不要上后端\"，不妨先问问自己：",[737,9931,9932],{},"你要做的是\"展示内容\"还是\"提供服务\"？",[15,9934,9935],{},"前者，静态就够了；后者，来吧，后端欢迎你。😄",[2917,9937],{},[15,9939,9940],{},[737,9941,9942],{},"🎉 目前注册功能已经可以用了，后续更多好玩的功能正在路上。部分功能可能需要登录才能体验，快注册一个账号吧！",{"title":54,"searchDepth":68,"depth":79,"links":9944},[9945,9946,9952,9957,9958,9961,9962],{"id":9423,"depth":68,"text":9424},{"id":9441,"depth":68,"text":9442,"children":9947},[9948,9949,9950,9951],{"id":9445,"depth":79,"text":9446},{"id":9455,"depth":79,"text":9456},{"id":3755,"depth":79,"text":9490},{"id":9507,"depth":79,"text":9508},{"id":9582,"depth":68,"text":9583,"children":9953},[9954,9955,9956],{"id":9586,"depth":79,"text":9446},{"id":9595,"depth":79,"text":9596},{"id":9642,"depth":79,"text":9643},{"id":9701,"depth":68,"text":9702},{"id":9826,"depth":68,"text":9827,"children":9959},[9960],{"id":9837,"depth":79,"text":9838},{"id":9853,"depth":68,"text":9854},{"id":9911,"depth":68,"text":9912},"记录尼克的小窝从 Nuxt 3 纯静态博客演进到 React + Go 前后端分离架构的全过程，包括架构设计的思考、技术选型的变化，以及新增的账号注册功能。","\u002Fposts\u002Fsublog-evolution\u002Fimg\u002Fcover.jpg",{},"\u002Fposts\u002Fsublog-evolution","2026-07-23T12:00:00",{"title":9418,"description":9963},"尼克的小窝 开发记录","posts\u002Fsublog-evolution\u002Findex",[9972,9973,2434,1098],"架构","React","2026-07-23","VMQ6A2K8GQ5t3DSlezWPd3KgXnmDVMn9QVeaI28Hcp8",{"id":9977,"title":9978,"author":6,"body":9979,"description":11142,"difficulty":2299,"draft":1088,"extension":1089,"image":11143,"meta":11144,"navigation":82,"path":11145,"pinned":1088,"published":11146,"seo":11147,"series":6,"seriesOrder":6,"stem":11148,"tags":11149,"updated":11153,"__hash__":11154},"posts\u002Fposts\u002Fip-geolocation-guide\u002Findex.md","IP 属地是怎么查出来的？本地查询与在线查询方案对比",{"type":8,"value":9980,"toc":11118},[9981,9983,9989,9992,10003,10013,10017,10023,10036,10042,10053,10060,10063,10070,10074,10085,10089,10100,10103,10107,10113,10119,10122,10126,10133,10151,10154,10157,10161,10192,10231,10237,10241,10380,10416,10425,10429,10469,10474,10478,10489,10492,10526,10529,10533,10536,10540,10731,10735,10818,10822,10829,10936,10940,10949,11035,11038,11041,11077,11080,11083,11091,11112,11115],[11,9982,9423],{"id":9423},[15,9984,9985,9986],{},"很多网站会在「登录记录」「安全中心」里显示你的登录 IP 归属地，比如「福建·福州 · 中国移动」。你可能好奇过：",[737,9987,9988],{},"服务器是怎么根据一个 IP 就判断出我在哪个城市、哪个运营商的？",[15,9990,9991],{},"这篇科普文章讲清楚三件事：",[1049,9993,9994,9997,10000],{},[1052,9995,9996],{},"IP 属地到底查的是什么；",[1052,9998,9999],{},"纯本地查询为什么能做到毫秒级且不联网；",[1052,10001,10002],{},"几种主流查询方案的优缺点对比，方便你按场景选型。",[10004,10005,10006],"blockquote",{},[15,10007,10008,10009,10012],{},"说明：文中用到的 IP 均为 RFC 5737 保留的文档示例段（TEST-NET-2，",[19,10010,10011],{},"198.51.100.0\u002F24","），不会对应任何真实地址。",[11,10014,10016],{"id":10015},"先想清楚ip-属地查的是什么","先想清楚：IP 属地查的是什么",[15,10018,10019,10020,782],{},"一个常见误区是「每个 IP 对应一个具体地址」。实际上 IP 属地的本质是 ",[737,10021,10022],{},"IP 段与归属地的映射",[15,10024,10025,10026,4215],{},"运营商和机构在申请 IP 时，是按",[737,10027,10028,10029,10035],{},"网段（CIDR）",[737,10030,10031,10032,10034],{},"成块分配的。比如 ",[19,10033,10011],{}," 这一个段，一整块都归属同一地区、同一运营商。所以归属地数据天然可以","按段存储",[49,10037,10040],{"className":10038,"code":10039,"language":2407,"meta":54},[2405],"IP 起始          IP 结束         归属地\n────────────────────────────────────────\n198.51.100.0     198.51.100.255  中国|福建|福州|移动\n198.51.101.0     198.51.101.255  中国|浙江|杭州|电信\n",[19,10041,10039],{"__ignoreMap":54},[15,10043,10044,10045,10048,10049,10052],{},"全球公网 IP 约有 42 亿个，但",[737,10046,10047],{},"网段只有几十万条","。正因为存的是「段」而不是「每个 IP」，完整的归属地数据文件才只有几 MB（ip2region 的 ",[19,10050,10051],{},".xdb"," 约 11MB）。这是「本地离线查询」可行的第一前提。",[15,10054,10055,10056,10059],{},"所以，",[737,10057,10058],{},"IP 属地从来不是精确到街道的定位","，它返回的是段级的「省｜市｜运营商」粗粒度信息。",[11,10061,10062],{"id":10062},"纯本地查询的原理",[15,10064,10065,10066,10069],{},"以开源的 ",[737,10067,10068],{},"ip2region"," 为例，它的查询完全在本地完成，不联网、不请求第三方，几十微秒到毫秒级返回。",[44,10071,10073],{"id":10072},"第一步把-ip-变成整数","第一步：把 IP 变成整数",[15,10075,10076,10077,10080,10081,10084],{},"以 ",[19,10078,10079],{},"198.51.100.42"," 为例，它会转成一个 32 位整数 ",[19,10082,10083],{},"0xC633642A","。IP 的四个数字本质就是 4 个字节，拼起来就是一个整数，这一步没有任何玄学。",[44,10086,10088],{"id":10087},"第二步二分查找","第二步：二分查找",[15,10090,10091,10092,10095,10096,10099],{},"在按起始 IP 排序的段列表里做",[737,10093,10094],{},"二分查找","，找到满足 ",[19,10097,10098],{},"段的起始IP ≤ 目标整数 ≤ 段的结束IP"," 的那一段。",[15,10101,10102],{},"二分查找的复杂度是 O(log N)，N 是几十万条，所以很快——这就是「不靠网络也能毫秒返回」的核心。",[44,10104,10106],{"id":10105},"第三步xdb-文件的两级结构","第三步：.xdb 文件的两级结构",[15,10108,10109,10110,10112],{},"ip2region 的 ",[19,10111,10051],{}," 不是简单顺序文件，内部分两个区，进一步加速：",[49,10114,10117],{"className":10115,"code":10116,"language":2407,"meta":54},[2405],"┌─────────────────────────────┐\n│  索引区 (Index)             │  \u002F\u002F 每段一条定长记录：startIP \u002F endIP \u002F 数据区指针 + 数据长度\n│  …… 二分快速定位段 ……       │\n├─────────────────────────────┤\n│  数据区 (Data)              │  \u002F\u002F 真正存放各段的地点字符串\n│  ……                         │\n└─────────────────────────────┘\n",[19,10118,10116],{"__ignoreMap":54},[15,10120,10121],{},"先二分索引区定位到「是哪一段」，再按指针去数据区读出地点字符串，一次磁盘读就能拿到结果。",[44,10123,10125],{"id":10124},"为什么离线数据不会很快过期","为什么「离线数据」不会很快过期",[15,10127,10128,10129,10132],{},"IP 归属取决于",[737,10130,10131],{},"注册机构和运营商的分配","，一个网段通常长期稳定属于某地区、某运营商。所以：",[2638,10134,10135,10138,10148],{},[1052,10136,10137],{},"下载一次数据文件，可以用很久；",[1052,10139,10140,10141,10147],{},"归属偶尔变化时，",[737,10142,10143,10144,10146],{},"每月更新一次 ",[19,10145,10051],{}," 文件","即可（替换文件、不用重启服务，见下文实战）；",[1052,10149,10150],{},"数据源来自 IANA\u002FAPNIC 等机构的分配表与公开归属信息，不是实时探测。",[11,10152,10153],{"id":10153},"主流查询方案对比",[15,10155,10156],{},"实际开发中，查询 IP 属地主要有三条路线。",[44,10158,10160],{"id":10159},"方案一ip2region纯本地离线推荐","方案一：ip2region（纯本地离线，推荐）",[49,10162,10164],{"className":51,"code":10163,"language":53,"meta":54,"style":54},"\u002F\u002F Go 中的用法（github.com\u002Flionsoul2014\u002Fip2region\u002Fbinding\u002Fgolang）\nregion, err := searcher.SearchByStr(ip)\n\u002F\u002F 返回类似 \"中国|福建省|福州市|移动\"\n",[19,10165,10166,10171,10187],{"__ignoreMap":54},[58,10167,10168],{"class":60,"line":61},[58,10169,10170],{"class":64},"\u002F\u002F Go 中的用法（github.com\u002Flionsoul2014\u002Fip2region\u002Fbinding\u002Fgolang）\n",[58,10172,10173,10176,10178,10181,10184],{"class":60,"line":68},[58,10174,10175],{"class":92},"region, err ",[58,10177,242],{"class":71},[58,10179,10180],{"class":92}," searcher.",[58,10182,10183],{"class":75},"SearchByStr",[58,10185,10186],{"class":92},"(ip)\n",[58,10188,10189],{"class":60,"line":79},[58,10190,10191],{"class":64},"\u002F\u002F 返回类似 \"中国|福建省|福州市|移动\"\n",[995,10193,10194,10202],{},[998,10195,10196],{},[1001,10197,10198,10200],{},[1004,10199,3755],{},[1004,10201,3758],{},[1014,10203,10204,10212,10220],{},[1001,10205,10206,10209],{},[1019,10207,10208],{},"免费、无网络依赖、无 QPS 限制",[1019,10210,10211],{},"段级粒度，个别段可能显示到相邻地区",[1001,10213,10214,10217],{},[1019,10215,10216],{},"毫秒级查询，永不因外部服务挂掉",[1019,10218,10219],{},"数据按月更新，最新分配的段可能短暂缺失",[1001,10221,10222,10225],{},[1019,10223,10224],{},"数据文件可随版本更新，不重启",[1019,10226,10227,10228],{},"只返回省市区+运营商，无经纬度；",[737,10229,10230],{},"仅支持 IPv4",[15,10232,10233,10236],{},[737,10234,10235],{},"适用","：博客\u002F后台的「登录记录显示归属地」这类展示用途。",[44,10238,10240],{"id":10239},"方案二在线-apiip-api-高德-腾讯位置服务","方案二：在线 API（ip-api \u002F 高德 \u002F 腾讯位置服务）",[49,10242,10244],{"className":51,"code":10243,"language":53,"meta":54,"style":54},"\u002F\u002F 以 ip-api.com 为例（免费版约 45 次\u002F分钟，非商用）\nctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)\ndefer cancel()\nreq, _ := http.NewRequestWithContext(ctx, \"GET\", \"https:\u002F\u002Fip-api.com\u002Fjson\u002F198.51.100.42\", nil)\nresp, err := http.DefaultClient.Do(req)\nif err != nil {\n    return \"未知\" \u002F\u002F 断网\u002F超时降级：展示「未知」而不是报错\n}\ndefer resp.Body.Close()\n\u002F\u002F 解析 JSON，status != \"success\" 时同样降级\n",[19,10245,10246,10251,10279,10289,10320,10336,10349,10359,10363,10375],{"__ignoreMap":54},[58,10247,10248],{"class":60,"line":61},[58,10249,10250],{"class":64},"\u002F\u002F 以 ip-api.com 为例（免费版约 45 次\u002F分钟，非商用）\n",[58,10252,10253,10256,10258,10260,10263,10266,10269,10272,10274,10276],{"class":60,"line":68},[58,10254,10255],{"class":92},"ctx, cancel ",[58,10257,242],{"class":71},[58,10259,5970],{"class":92},[58,10261,10262],{"class":75},"WithTimeout",[58,10264,10265],{"class":92},"(context.",[58,10267,10268],{"class":75},"Background",[58,10270,10271],{"class":92},"(), ",[58,10273,3035],{"class":382},[58,10275,4723],{"class":71},[58,10277,10278],{"class":92},"time.Second)\n",[58,10280,10281,10284,10287],{"class":60,"line":79},[58,10282,10283],{"class":71},"defer",[58,10285,10286],{"class":75}," cancel",[58,10288,508],{"class":92},[58,10290,10291,10294,10296,10299,10302,10305,10308,10310,10313,10315,10318],{"class":60,"line":86},[58,10292,10293],{"class":92},"req, _ ",[58,10295,242],{"class":71},[58,10297,10298],{"class":92}," http.",[58,10300,10301],{"class":75},"NewRequestWithContext",[58,10303,10304],{"class":92},"(ctx, ",[58,10306,10307],{"class":99},"\"GET\"",[58,10309,889],{"class":92},[58,10311,10312],{"class":99},"\"https:\u002F\u002Fip-api.com\u002Fjson\u002F198.51.100.42\"",[58,10314,889],{"class":92},[58,10316,10317],{"class":382},"nil",[58,10319,177],{"class":92},[58,10321,10322,10325,10327,10330,10333],{"class":60,"line":96},[58,10323,10324],{"class":92},"resp, err ",[58,10326,242],{"class":71},[58,10328,10329],{"class":92}," http.DefaultClient.",[58,10331,10332],{"class":75},"Do",[58,10334,10335],{"class":92},"(req)\n",[58,10337,10338,10341,10343,10345,10347],{"class":60,"line":109},[58,10339,10340],{"class":71},"if",[58,10342,376],{"class":92},[58,10344,379],{"class":71},[58,10346,383],{"class":382},[58,10348,233],{"class":92},[58,10350,10351,10353,10356],{"class":60,"line":119},[58,10352,267],{"class":71},[58,10354,10355],{"class":99}," \"未知\"",[58,10357,10358],{"class":64}," \u002F\u002F 断网\u002F超时降级：展示「未知」而不是报错\n",[58,10360,10361],{"class":60,"line":129},[58,10362,457],{"class":92},[58,10364,10365,10367,10370,10373],{"class":60,"line":139},[58,10366,10283],{"class":71},[58,10368,10369],{"class":92}," resp.Body.",[58,10371,10372],{"class":75},"Close",[58,10374,508],{"class":92},[58,10376,10377],{"class":60,"line":149},[58,10378,10379],{"class":64},"\u002F\u002F 解析 JSON，status != \"success\" 时同样降级\n",[995,10381,10382,10390],{},[998,10383,10384],{},[1001,10385,10386,10388],{},[1004,10387,3755],{},[1004,10389,3758],{},[1014,10391,10392,10400,10408],{},[1001,10393,10394,10397],{},[1019,10395,10396],{},"数据最新、可能更精确",[1019,10398,10399],{},"有频率限制（免费版限 QPS）",[1001,10401,10402,10405],{},[1019,10403,10404],{},"部分服务还返回经纬度\u002F时区",[1019,10406,10407],{},"依赖第三方，断网\u002F被墙\u002F限流时拿不到",[1001,10409,10410,10413],{},[1019,10411,10412],{},"无需维护数据文件",[1019,10414,10415],{},"每次查询都要发一次 HTTP 请求",[15,10417,10418,10420,10421,10424],{},[737,10419,10235],{},"：对精度要求高、且能接受外部依赖与限流的场景。",[737,10422,10423],{},"务必加超时和降级","，否则第三方一抖，你自己的页面就卡住。",[44,10426,10428],{"id":10427},"方案三maxmind-geolite2离线-mmdb","方案三：MaxMind GeoLite2（离线 .mmdb）",[995,10430,10431,10439],{},[998,10432,10433],{},[1001,10434,10435,10437],{},[1004,10436,3755],{},[1004,10438,3758],{},[1014,10440,10441,10449,10461],{},[1001,10442,10443,10446],{},[1019,10444,10445],{},"全球数据，覆盖广、精度较好",[1019,10447,10448],{},"免费但需注册账号并遵守 EULA；商用需 GeoIP2 商业授权",[1001,10450,10451,10458],{},[1019,10452,10453,10454,10457],{},"标准 ",[19,10455,10456],{},".mmdb"," 格式，各语言都有库，支持 IPv6",[1019,10459,10460],{},"国内 IP 的省市粒度有时不如 ip2region 细",[1001,10462,10463,10466],{},[1019,10464,10465],{},"离线查询",[1019,10467,10468],{},"数据同样要定期更新",[15,10470,10471,10473],{},[737,10472,10235],{},"：面向全球用户的场景。",[11,10475,10477],{"id":10476},"ipv6本地离线方案的一个盲点","IPv6：本地离线方案的一个盲点",[15,10479,10480,10481,10484,10485,10488],{},"前面聊的都是 IPv4（32 位）。但家宽和移动网络越来越多地",[737,10482,10483],{},"只下发 IPv6","（128 位）地址，而 ",[737,10486,10487],{},"ip2region 目前只支持 IPv4","——拿到一个纯 IPv6 地址是查不出归属地的，本地库会返回空结果。",[15,10490,10491],{},"实际影响与常见应对：",[1049,10493,10494,10504,10510,10520],{},[1052,10495,10496,10499,10500,10503],{},[737,10497,10498],{},"判断版本","：Go 里 ",[19,10501,10502],{},"net.ParseIP(ip).To4() != nil"," 就是 IPv4，先判断再查询；",[1052,10505,10506,10509],{},[737,10507,10508],{},"降级展示","：IPv6 直接显示「IPv6 地址」，不显示属地，诚实第一；",[1052,10511,10512,10515,10516,10519],{},[737,10513,10514],{},"优先取 IPv4","：有反向代理时，从 ",[19,10517,10518],{},"X-Forwarded-For"," 里挑一个 IPv4 来查；",[1052,10521,10522,10525],{},[737,10523,10524],{},"需要 v6 归属","：换支持 IPv6 的数据源（如 MaxMind GeoLite2）。",[15,10527,10528],{},"这一点在接入时很容易漏掉，上线前记得拿一个纯 IPv6 地址回归一遍。",[11,10530,10532],{"id":10531},"实战给登录记录加-ip-属地","实战：给登录记录加 IP 属地",[15,10534,10535],{},"subLog 博客的登录记录就用了 ip2region。完整接入一共四步。",[44,10537,10539],{"id":10538},"_1-进程启动时加载数据文件","1. 进程启动时加载数据文件",[49,10541,10543],{"className":51,"code":10542,"language":53,"meta":54,"style":54},"import (\n    \"sync\u002Fatomic\"\n    \"github.com\u002Flionsoul2014\u002Fip2region\u002Fbinding\u002Fgolang\u002Fxdb\"\n)\n\n\u002F\u002F 用 atomic.Pointer 持有查询器，方便后面热更新\nvar searcher atomic.Pointer[xdb.Searcher]\n\nfunc initSearcher() error {\n    content, err := os.ReadFile(\".\u002Fdata\u002Fip2region.xdb\") \u002F\u002F 约 11MB，一次性读入内存\n    if err != nil {\n        return err\n    }\n    s, err := xdb.NewWithBuffer(content)\n    if err != nil {\n        return err\n    }\n    searcher.Store(s)\n    return nil\n}\n",[19,10544,10545,10551,10560,10569,10573,10577,10582,10609,10613,10626,10648,10660,10667,10671,10687,10699,10705,10709,10720,10727],{"__ignoreMap":54},[58,10546,10547,10549],{"class":60,"line":61},[58,10548,89],{"class":71},[58,10550,93],{"class":92},[58,10552,10553,10555,10558],{"class":60,"line":68},[58,10554,100],{"class":99},[58,10556,10557],{"class":75},"sync\u002Fatomic",[58,10559,106],{"class":99},[58,10561,10562,10564,10567],{"class":60,"line":79},[58,10563,100],{"class":99},[58,10565,10566],{"class":75},"github.com\u002Flionsoul2014\u002Fip2region\u002Fbinding\u002Fgolang\u002Fxdb",[58,10568,106],{"class":99},[58,10570,10571],{"class":60,"line":86},[58,10572,177],{"class":92},[58,10574,10575],{"class":60,"line":96},[58,10576,83],{"emptyLinePlaceholder":82},[58,10578,10579],{"class":60,"line":109},[58,10580,10581],{"class":64},"\u002F\u002F 用 atomic.Pointer 持有查询器，方便后面热更新\n",[58,10583,10584,10586,10589,10592,10594,10597,10599,10602,10604,10607],{"class":60,"line":119},[58,10585,194],{"class":71},[58,10587,10588],{"class":92}," searcher ",[58,10590,10591],{"class":75},"atomic",[58,10593,202],{"class":92},[58,10595,10596],{"class":75},"Pointer",[58,10598,3883],{"class":92},[58,10600,10601],{"class":75},"xdb",[58,10603,202],{"class":92},[58,10605,10606],{"class":75},"Searcher",[58,10608,1552],{"class":92},[58,10610,10611],{"class":60,"line":129},[58,10612,83],{"emptyLinePlaceholder":82},[58,10614,10615,10617,10620,10622,10624],{"class":60,"line":139},[58,10616,216],{"class":71},[58,10618,10619],{"class":75}," initSearcher",[58,10621,222],{"class":92},[58,10623,4736],{"class":71},[58,10625,233],{"class":92},[58,10627,10628,10631,10633,10636,10638,10640,10643,10645],{"class":60,"line":149},[58,10629,10630],{"class":92},"    content, err ",[58,10632,242],{"class":71},[58,10634,10635],{"class":92}," os.",[58,10637,365],{"class":75},[58,10639,273],{"class":92},[58,10641,10642],{"class":99},"\".\u002Fdata\u002Fip2region.xdb\"",[58,10644,3539],{"class":92},[58,10646,10647],{"class":64},"\u002F\u002F 约 11MB，一次性读入内存\n",[58,10649,10650,10652,10654,10656,10658],{"class":60,"line":159},[58,10651,3160],{"class":71},[58,10653,376],{"class":92},[58,10655,379],{"class":71},[58,10657,383],{"class":382},[58,10659,233],{"class":92},[58,10661,10662,10664],{"class":60,"line":164},[58,10663,857],{"class":71},[58,10665,10666],{"class":92}," err\n",[58,10668,10669],{"class":60,"line":174},[58,10670,451],{"class":92},[58,10672,10673,10676,10678,10681,10684],{"class":60,"line":180},[58,10674,10675],{"class":92},"    s, err ",[58,10677,242],{"class":71},[58,10679,10680],{"class":92}," xdb.",[58,10682,10683],{"class":75},"NewWithBuffer",[58,10685,10686],{"class":92},"(content)\n",[58,10688,10689,10691,10693,10695,10697],{"class":60,"line":185},[58,10690,3160],{"class":71},[58,10692,376],{"class":92},[58,10694,379],{"class":71},[58,10696,383],{"class":382},[58,10698,233],{"class":92},[58,10700,10701,10703],{"class":60,"line":191},[58,10702,857],{"class":71},[58,10704,10666],{"class":92},[58,10706,10707],{"class":60,"line":208},[58,10708,451],{"class":92},[58,10710,10711,10714,10717],{"class":60,"line":213},[58,10712,10713],{"class":92},"    searcher.",[58,10715,10716],{"class":75},"Store",[58,10718,10719],{"class":92},"(s)\n",[58,10721,10722,10724],{"class":60,"line":236},[58,10723,267],{"class":71},[58,10725,10726],{"class":382}," nil\n",[58,10728,10729],{"class":60,"line":259},[58,10730,457],{"class":92},[44,10732,10734],{"id":10733},"_2-查询一次","2. 查询一次",[49,10736,10738],{"className":51,"code":10737,"language":53,"meta":54,"style":54},"func ipRegion(ip string) string {\n    region, err := searcher.Load().SearchByStr(ip)\n    if err != nil {\n        return \"未知\"\n    }\n    return region \u002F\u002F \"中国|福建省|福州市|移动\"\n}\n",[19,10739,10740,10762,10781,10793,10800,10804,10814],{"__ignoreMap":54},[58,10741,10742,10744,10747,10749,10752,10755,10757,10760],{"class":60,"line":61},[58,10743,216],{"class":71},[58,10745,10746],{"class":75}," ipRegion",[58,10748,273],{"class":92},[58,10750,10751],{"class":276},"ip",[58,10753,10754],{"class":71}," string",[58,10756,3539],{"class":92},[58,10758,10759],{"class":71},"string",[58,10761,233],{"class":92},[58,10763,10764,10767,10769,10771,10774,10777,10779],{"class":60,"line":68},[58,10765,10766],{"class":92},"    region, err ",[58,10768,242],{"class":71},[58,10770,10180],{"class":92},[58,10772,10773],{"class":75},"Load",[58,10775,10776],{"class":92},"().",[58,10778,10183],{"class":75},[58,10780,10186],{"class":92},[58,10782,10783,10785,10787,10789,10791],{"class":60,"line":79},[58,10784,3160],{"class":71},[58,10786,376],{"class":92},[58,10788,379],{"class":71},[58,10790,383],{"class":382},[58,10792,233],{"class":92},[58,10794,10795,10797],{"class":60,"line":86},[58,10796,857],{"class":71},[58,10798,10799],{"class":99}," \"未知\"\n",[58,10801,10802],{"class":60,"line":96},[58,10803,451],{"class":92},[58,10805,10806,10808,10811],{"class":60,"line":109},[58,10807,267],{"class":71},[58,10809,10810],{"class":92}," region ",[58,10812,10813],{"class":64},"\u002F\u002F \"中国|福建省|福州市|移动\"\n",[58,10815,10816],{"class":60,"line":119},[58,10817,457],{"class":92},[44,10819,10821],{"id":10820},"_3-加一层内存缓存","3. 加一层内存缓存",[15,10823,10824,10825,10828],{},"登录记录里同一个 IP 可能反复出现，用 ",[19,10826,10827],{},"sync.Map"," 按 IP 缓存结果，一次查询终身复用：",[49,10830,10832],{"className":51,"code":10831,"language":53,"meta":54,"style":54},"var regionCache sync.Map\n\nfunc ipRegionCached(ip string) string {\n    if v, ok := regionCache.Load(ip); ok {\n        return v.(string)\n    }\n    region := ipRegion(ip)\n    regionCache.Store(ip, region)\n    return region\n}\n",[19,10833,10834,10849,10853,10872,10889,10900,10904,10915,10925,10932],{"__ignoreMap":54},[58,10835,10836,10838,10841,10844,10846],{"class":60,"line":61},[58,10837,194],{"class":71},[58,10839,10840],{"class":92}," regionCache ",[58,10842,10843],{"class":75},"sync",[58,10845,202],{"class":92},[58,10847,10848],{"class":75},"Map\n",[58,10850,10851],{"class":60,"line":68},[58,10852,83],{"emptyLinePlaceholder":82},[58,10854,10855,10857,10860,10862,10864,10866,10868,10870],{"class":60,"line":79},[58,10856,216],{"class":71},[58,10858,10859],{"class":75}," ipRegionCached",[58,10861,273],{"class":92},[58,10863,10751],{"class":276},[58,10865,10754],{"class":71},[58,10867,3539],{"class":92},[58,10869,10759],{"class":71},[58,10871,233],{"class":92},[58,10873,10874,10876,10879,10881,10884,10886],{"class":60,"line":86},[58,10875,3160],{"class":71},[58,10877,10878],{"class":92}," v, ok ",[58,10880,242],{"class":71},[58,10882,10883],{"class":92}," regionCache.",[58,10885,10773],{"class":75},[58,10887,10888],{"class":92},"(ip); ok {\n",[58,10890,10891,10893,10896,10898],{"class":60,"line":96},[58,10892,857],{"class":71},[58,10894,10895],{"class":92}," v.(",[58,10897,10759],{"class":71},[58,10899,177],{"class":92},[58,10901,10902],{"class":60,"line":109},[58,10903,451],{"class":92},[58,10905,10906,10909,10911,10913],{"class":60,"line":119},[58,10907,10908],{"class":92},"    region ",[58,10910,242],{"class":71},[58,10912,10746],{"class":75},[58,10914,10186],{"class":92},[58,10916,10917,10920,10922],{"class":60,"line":129},[58,10918,10919],{"class":92},"    regionCache.",[58,10921,10716],{"class":75},[58,10923,10924],{"class":92},"(ip, region)\n",[58,10926,10927,10929],{"class":60,"line":139},[58,10928,267],{"class":71},[58,10930,10931],{"class":92}," region\n",[58,10933,10934],{"class":60,"line":149},[58,10935,457],{"class":92},[44,10937,10939],{"id":10938},"_4-数据热更新不用重启","4. 数据热更新（不用重启）",[15,10941,10942,10943,10945,10946,10948],{},"ip2region 每月发布新数据。下载新文件后重新 ",[19,10944,10683],{}," 并 ",[19,10947,10716],{}," 覆盖即可，进程完全不用重启：",[49,10950,10952],{"className":51,"code":10951,"language":53,"meta":54,"style":54},"func reloadSearcher(newContent []byte) error {\n    s, err := xdb.NewWithBuffer(newContent)\n    if err != nil {\n        return err\n    }\n    searcher.Store(s) \u002F\u002F 原子切换，老查询继续走旧数据，新查询走新数据\n    return nil\n}\n",[19,10953,10954,10978,10991,11003,11009,11013,11025,11031],{"__ignoreMap":54},[58,10955,10956,10958,10961,10963,10966,10969,10972,10974,10976],{"class":60,"line":61},[58,10957,216],{"class":71},[58,10959,10960],{"class":75}," reloadSearcher",[58,10962,273],{"class":92},[58,10964,10965],{"class":276},"newContent",[58,10967,10968],{"class":92}," []",[58,10970,10971],{"class":71},"byte",[58,10973,3539],{"class":92},[58,10975,4736],{"class":71},[58,10977,233],{"class":92},[58,10979,10980,10982,10984,10986,10988],{"class":60,"line":68},[58,10981,10675],{"class":92},[58,10983,242],{"class":71},[58,10985,10680],{"class":92},[58,10987,10683],{"class":75},[58,10989,10990],{"class":92},"(newContent)\n",[58,10992,10993,10995,10997,10999,11001],{"class":60,"line":79},[58,10994,3160],{"class":71},[58,10996,376],{"class":92},[58,10998,379],{"class":71},[58,11000,383],{"class":382},[58,11002,233],{"class":92},[58,11004,11005,11007],{"class":60,"line":86},[58,11006,857],{"class":71},[58,11008,10666],{"class":92},[58,11010,11011],{"class":60,"line":96},[58,11012,451],{"class":92},[58,11014,11015,11017,11019,11022],{"class":60,"line":109},[58,11016,10713],{"class":92},[58,11018,10716],{"class":75},[58,11020,11021],{"class":92},"(s) ",[58,11023,11024],{"class":64},"\u002F\u002F 原子切换，老查询继续走旧数据，新查询走新数据\n",[58,11026,11027,11029],{"class":60,"line":119},[58,11028,267],{"class":71},[58,11030,10726],{"class":382},[58,11032,11033],{"class":60,"line":129},[58,11034,457],{"class":92},[11,11036,11037],{"id":11037},"局限与诚实提醒",[15,11039,11040],{},"无论用哪种方案，都要清楚 IP 属地的边界：",[2638,11042,11043,11049,11055,11061,11067],{},[1052,11044,11045,11048],{},[737,11046,11047],{},"段级精度","：网段归属是「绝大多数」准确，个别 IP（大型企业自建段、跨省调度段）可能显示到相邻地区或只到省。",[1052,11050,11051,11054],{},[737,11052,11053],{},"运营商级，不是定位","：拿到的是「省｜市｜运营商」，不是精确街道或基站位置——精确位置需要 GPS 或运营商三角定位，纯 IP 归属做不到。",[1052,11056,11057,11060],{},[737,11058,11059],{},"动态 IP","：家宽通常是动态 IP，重拨会变，所以「上次登录在福州，这次在广州」不一定是异常，可能是 IP 变了。",[1052,11062,11063,11066],{},[737,11064,11065],{},"别用于安全判定","：用 IP 段做封禁或风控判定风险自负，段级数据不适合当精确依据。",[1052,11068,11069,11072,11073,11076],{},[737,11070,11071],{},"隐私与合规","：IP 地址属于个人信息（《个人信息保护法》）。归属地展示建议只出现在",[737,11074,11075],{},"用户本人可见","的页面（登录记录、个人中心）；若在评论区等公开场合展示，建议只显示到省市并明确告知用户。",[11,11078,11079],{"id":11079},"小结",[15,11081,11082],{},"一句话总结：",[10004,11084,11085],{},[15,11086,11087,11090],{},[737,11088,11089],{},"IP 属地 = 几十万条「IP 段 → 归属地」映射 + 一次二分查找。"," 它查的是网段归属，不是精确位置。",[2638,11092,11093,11099,11106],{},[1052,11094,11095,11096,11098],{},"想省心、免费、稳定，用 ",[737,11097,10068],{}," 本地离线查询（注意 IPv6 盲点）；",[1052,11100,11101,11102,11105],{},"想要最新最细、能接受外部依赖，用",[737,11103,11104],{},"在线 API","（记得超时降级）；",[1052,11107,11108,11109,782],{},"面向全球用户、要 IPv6 覆盖，用 ",[737,11110,11111],{},"MaxMind GeoLite2",[15,11113,11114],{},"对个人博客「登录记录显示归属地」这种场景，本地离线方案通常是性价比最高的选择。",[1066,11116,11117],{},"html pre.shiki code .sHbNN, html code.shiki .sHbNN{--shiki-light:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sIX_F, html code.shiki .sIX_F{--shiki-light:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .siTax, html code.shiki .siTax{--shiki-light:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sw2iP, html code.shiki .sw2iP{--shiki-light:#6F42C1;--shiki-dark:#B392F0}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .suQ91, html code.shiki .suQ91{--shiki-light:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .scXbn, html code.shiki .scXbn{--shiki-light:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sPK6S, html code.shiki .sPK6S{--shiki-light:#E36209;--shiki-dark:#FFAB70}",{"title":54,"searchDepth":68,"depth":79,"links":11119},[11120,11121,11122,11128,11133,11134,11140,11141],{"id":9423,"depth":68,"text":9423},{"id":10015,"depth":68,"text":10016},{"id":10062,"depth":68,"text":10062,"children":11123},[11124,11125,11126,11127],{"id":10072,"depth":79,"text":10073},{"id":10087,"depth":79,"text":10088},{"id":10105,"depth":79,"text":10106},{"id":10124,"depth":79,"text":10125},{"id":10153,"depth":68,"text":10153,"children":11129},[11130,11131,11132],{"id":10159,"depth":79,"text":10160},{"id":10239,"depth":79,"text":10240},{"id":10427,"depth":79,"text":10428},{"id":10476,"depth":68,"text":10477},{"id":10531,"depth":68,"text":10532,"children":11135},[11136,11137,11138,11139],{"id":10538,"depth":79,"text":10539},{"id":10733,"depth":79,"text":10734},{"id":10820,"depth":79,"text":10821},{"id":10938,"depth":79,"text":10939},{"id":11037,"depth":68,"text":11037},{"id":11079,"depth":68,"text":11079},"科普 IP 属地查询的原理：IP 段与归属地的映射、纯本地离线查询为何能做到毫秒级，对比 ip2region、在线 API 与 MaxMind 三种方案的优劣，并附登录记录场景的 Go 实战与 IPv6 盲点提醒。","\u002Fposts\u002Fip-geolocation-guide\u002Fimg\u002Fcover.svg",{},"\u002Fposts\u002Fip-geolocation-guide","2026-08-07T12:00:00",{"title":9978,"description":11142},"posts\u002Fip-geolocation-guide\u002Findex",[11150,11151,11152],"IP","网络","科普","2026-08-16","RY1UPgJaiKsjpC51uqMyG7w_GpCRS13_CX5j1hDD_dM",{"id":11156,"title":11157,"author":6,"body":11158,"description":12680,"difficulty":1087,"draft":1088,"extension":1089,"image":12681,"meta":12682,"navigation":82,"path":12683,"pinned":1088,"published":12684,"seo":12685,"series":1095,"seriesOrder":79,"stem":12686,"tags":12687,"updated":11153,"__hash__":12690},"posts\u002Fposts\u002Fgo-embed-react-single-binary\u002Findex.md","Go 单文件打包进阶：React 前端 + 交叉编译，一次踩完所有坑",{"type":8,"value":11159,"toc":12659},[11160,11162,11173,11184,11191,11194,11230,11232,11241,11243,11247,11257,11301,11343,11361,11365,11386,11417,11421,11713,11716,11754,11758,12101,12104,12108,12305,12315,12317,12328,12342,12355,12366,12370,12377,12393,12406,12411,12415,12431,12436,12481,12495,12506,12510,12525,12534,12556,12565,12569,12579,12581,12608,12617,12619,12622,12649,12656],[11,11161,13],{"id":13},[15,11163,11164,11165,11168,11169,11172],{},"这是「Go 单文件打包」系列第三篇。上一篇（",[2909,11166,11167],{"href":1092},"把前端塞进二进制","）记录了一个 Gin + Vue 3 项目的 embed 方案。这次换了个项目继续踩坑：",[737,11170,11171],{},"Magic Img","——一个自托管、本地优先的 AI 生图提示词管理工具，技术栈是：",[2638,11174,11175,11178],{},[1052,11176,11177],{},"前端：React 19 + TypeScript + Vite + Tailwind v4（UI 库 Magic UI + shadcn\u002Fui）",[1052,11179,11180,11181,6978],{},"后端：Go + Gin + GORM + SQLite（",[737,11182,11183],{},"modernc 纯 Go 驱动，无 cgo",[15,11185,11186,11187,11190],{},"目标一样：",[737,11188,11189],{},"一个二进制拖到任何机器上就能跑","——前端、API、数据库全部内嵌。",[15,11192,11193],{},"和上篇不同的地方有三个，正好是这篇要讲的重点：",[1049,11195,11196,11210,11223],{},[1052,11197,11198,11199,11205,11206,11209],{},"前端用了 ",[737,11200,11201,11202],{},"HashRouter + Vite base ",[19,11203,11204],{},"\u002Fdist","，服务端",[737,11207,11208],{},"不再需要 SPA fallback","（上篇坑一的新解法）；",[1052,11211,11212,11213,11219,11220,11222],{},"embed 用 ",[737,11214,11215,11218],{},[19,11216,11217],{},"all:"," 前缀","一劳永逸解决 ",[19,11221,761],{}," 开头文件被排除的问题（上篇坑二的升级解法）；",[1052,11224,11225,11226,11229],{},"需要",[737,11227,11228],{},"在 Windows 上交叉编译 Linux 产物","，踩了「生成的文件无法在 Linux 执行」的坑。",[11,11231,28],{"id":28},[15,11233,11234,11235,11237,11238,11240],{},"和上篇一致：",[19,11236,34],{}," 在编译期把前端 ",[19,11239,38],{}," 塞进二进制，运行时由 Go 直接提供静态服务。差异在于路由策略和缓存策略，后面细说。",[11,11242,42],{"id":42},[44,11244,11246],{"id":11245},"_1-前端hash-路由-base-dist","1. 前端：hash 路由 + base \u002Fdist",[15,11248,11249,11250,11253,11254,4215],{},"vite.config.ts 里固定 ",[19,11251,11252],{},"base: \"\u002Fdist\u002F\"","，路由用 ",[19,11255,11256],{},"HashRouter",[49,11258,11260],{"className":813,"code":11259,"language":815,"meta":54,"style":54},"\u002F\u002F vite.config.ts\nexport default defineConfig({\n  base: \"\u002Fdist\u002F\",   \u002F\u002F 所有资源路径以 \u002Fdist\u002F 开头\n  ...\n})\n",[19,11261,11262,11266,11278,11292,11297],{"__ignoreMap":54},[58,11263,11264],{"class":60,"line":61},[58,11265,822],{"class":64},[58,11267,11268,11270,11272,11275],{"class":60,"line":68},[58,11269,1206],{"class":71},[58,11271,3816],{"class":71},[58,11273,11274],{"class":75}," defineConfig",[58,11276,11277],{"class":92},"({\n",[58,11279,11280,11283,11286,11289],{"class":60,"line":79},[58,11281,11282],{"class":92},"  base: ",[58,11284,11285],{"class":99},"\"\u002Fdist\u002F\"",[58,11287,11288],{"class":92},",   ",[58,11290,11291],{"class":64},"\u002F\u002F 所有资源路径以 \u002Fdist\u002F 开头\n",[58,11293,11294],{"class":60,"line":86},[58,11295,11296],{"class":71},"  ...\n",[58,11298,11299],{"class":60,"line":96},[58,11300,3621],{"class":92},[49,11302,11306],{"className":11303,"code":11304,"language":11305,"meta":54,"style":54},"language-tsx shiki shiki-themes github-light github-dark","\u002F\u002F App.tsx —— HashRouter：路径信息全在 location.hash 里\n\u003CHashRouter>\n  \u003CRoutes>...\u003C\u002FRoutes>\n\u003C\u002FHashRouter>\n","tsx",[19,11307,11308,11313,11321,11335],{"__ignoreMap":54},[58,11309,11310],{"class":60,"line":61},[58,11311,11312],{"class":64},"\u002F\u002F App.tsx —— HashRouter：路径信息全在 location.hash 里\n",[58,11314,11315,11317,11319],{"class":60,"line":68},[58,11316,2026],{"class":92},[58,11318,11256],{"class":382},[58,11320,1686],{"class":92},[58,11322,11323,11325,11328,11331,11333],{"class":60,"line":79},[58,11324,3648],{"class":92},[58,11326,11327],{"class":382},"Routes",[58,11329,11330],{"class":92},">...\u003C\u002F",[58,11332,11327],{"class":382},[58,11334,1686],{"class":92},[58,11336,11337,11339,11341],{"class":60,"line":86},[58,11338,3626],{"class":92},[58,11340,11256],{"class":382},[58,11342,1686],{"class":92},[15,11344,11345,11348,11349,11352,11353,11356,11357,11360],{},[737,11346,11347],{},"为什么这样选","：history 路由需要服务端把未知路径全部 fallback 回 ",[19,11350,11351],{},"index.html","（上篇坑一）。hash 路由下浏览器请求的永远是 ",[19,11354,11355],{},"\u002Fdist\u002F"," 这一个入口，",[737,11358,11359],{},"静态服务直接返回 index.html 就完事，一个 fallback 都不用写","。内嵌场景里这是最省心的组合。",[44,11362,11364],{"id":11363},"_2-构建产物复制进后端目录","2. 构建产物复制进后端目录",[15,11366,11367,11370,11371,11373,11374,11377,11378,11381,11382,11385],{},[19,11368,11369],{},"npm run build"," 除了编译，还会把 ",[19,11372,38],{}," 复制到 ",[19,11375,11376],{},"backend\u002Fweb\u002Fdist","（一个小脚本 ",[19,11379,11380],{},"scripts\u002Fcopy-dist.mjs"," 干这个活），供 ",[19,11383,11384],{},"go:embed"," 使用：",[49,11387,11389],{"className":6262,"code":11388,"language":6264,"meta":54,"style":54},"\u002F\u002F frontend\u002Fpackage.json\n\"scripts\": {\n  \"build\": \"tsc -b && vite build && node scripts\u002Fcopy-dist.mjs\"\n}\n",[19,11390,11391,11396,11403,11413],{"__ignoreMap":54},[58,11392,11393],{"class":60,"line":61},[58,11394,11395],{"class":64},"\u002F\u002F frontend\u002Fpackage.json\n",[58,11397,11398,11401],{"class":60,"line":68},[58,11399,11400],{"class":99},"\"scripts\"",[58,11402,830],{"class":92},[58,11404,11405,11408,11410],{"class":60,"line":79},[58,11406,11407],{"class":382},"  \"build\"",[58,11409,2794],{"class":92},[58,11411,11412],{"class":99},"\"tsc -b && vite build && node scripts\u002Fcopy-dist.mjs\"\n",[58,11414,11415],{"class":60,"line":86},[58,11416,457],{"class":92},[44,11418,11420],{"id":11419},"_3-后端embed-httpfilesystem-包装","3. 后端：embed + http.FileSystem 包装",[49,11422,11424],{"className":51,"code":11423,"language":53,"meta":54,"style":54},"\u002F\u002F backend\u002Fweb\u002Fembed.go\npackage web\n\nimport (\n    \"embed\"\n    \"net\u002Fhttp\"\n    \"strings\"\n)\n\n\u002F\u002Fgo:embed all:dist\nvar distFS embed.FS\n\n\u002F\u002F Dist 内嵌前端静态文件系统\nvar Dist http.FileSystem = &fileSystem{prefix: \"dist\", fs: &distFS}\n\ntype fileSystem struct {\n    prefix string\n    fs     *embed.FS\n}\n\nfunc (f *fileSystem) Open(name string) (http.File, error) {\n    if name == \"\u002F\" {\n        name = f.prefix\n    } else {\n        name = f.prefix + \"\u002F\" + strings.TrimPrefix(name, \"\u002F\")\n    }\n    return http.FS(f.fs).Open(name)\n}\n",[19,11425,11426,11431,11437,11441,11447,11455,11463,11471,11475,11479,11484,11497,11501,11506,11543,11547,11559,11567,11580,11584,11588,11627,11641,11651,11661,11688,11692,11709],{"__ignoreMap":54},[58,11427,11428],{"class":60,"line":61},[58,11429,11430],{"class":64},"\u002F\u002F backend\u002Fweb\u002Fembed.go\n",[58,11432,11433,11435],{"class":60,"line":68},[58,11434,72],{"class":71},[58,11436,76],{"class":75},[58,11438,11439],{"class":60,"line":79},[58,11440,83],{"emptyLinePlaceholder":82},[58,11442,11443,11445],{"class":60,"line":86},[58,11444,89],{"class":71},[58,11446,93],{"class":92},[58,11448,11449,11451,11453],{"class":60,"line":96},[58,11450,100],{"class":99},[58,11452,103],{"class":75},[58,11454,106],{"class":99},[58,11456,11457,11459,11461],{"class":60,"line":109},[58,11458,100],{"class":99},[58,11460,134],{"class":75},[58,11462,106],{"class":99},[58,11464,11465,11467,11469],{"class":60,"line":119},[58,11466,100],{"class":99},[58,11468,154],{"class":75},[58,11470,106],{"class":99},[58,11472,11473],{"class":60,"line":129},[58,11474,177],{"class":92},[58,11476,11477],{"class":60,"line":139},[58,11478,83],{"emptyLinePlaceholder":82},[58,11480,11481],{"class":60,"line":149},[58,11482,11483],{"class":64},"\u002F\u002Fgo:embed all:dist\n",[58,11485,11486,11488,11491,11493,11495],{"class":60,"line":159},[58,11487,194],{"class":71},[58,11489,11490],{"class":92}," distFS ",[58,11492,103],{"class":75},[58,11494,202],{"class":92},[58,11496,205],{"class":75},[58,11498,11499],{"class":60,"line":164},[58,11500,83],{"emptyLinePlaceholder":82},[58,11502,11503],{"class":60,"line":174},[58,11504,11505],{"class":64},"\u002F\u002F Dist 内嵌前端静态文件系统\n",[58,11507,11508,11510,11513,11516,11518,11521,11523,11526,11529,11532,11534,11537,11540],{"class":60,"line":180},[58,11509,194],{"class":71},[58,11511,11512],{"class":92}," Dist ",[58,11514,11515],{"class":75},"http",[58,11517,202],{"class":92},[58,11519,11520],{"class":75},"FileSystem",[58,11522,2728],{"class":71},[58,11524,11525],{"class":71}," &",[58,11527,11528],{"class":75},"fileSystem",[58,11530,11531],{"class":92},"{prefix: ",[58,11533,254],{"class":99},[58,11535,11536],{"class":92},", fs: ",[58,11538,11539],{"class":71},"&",[58,11541,11542],{"class":92},"distFS}\n",[58,11544,11545],{"class":60,"line":185},[58,11546,83],{"emptyLinePlaceholder":82},[58,11548,11549,11551,11554,11557],{"class":60,"line":191},[58,11550,5865],{"class":71},[58,11552,11553],{"class":75}," fileSystem",[58,11555,11556],{"class":71}," struct",[58,11558,233],{"class":92},[58,11560,11561,11564],{"class":60,"line":208},[58,11562,11563],{"class":92},"    prefix ",[58,11565,11566],{"class":71},"string\n",[58,11568,11569,11572,11574,11576,11578],{"class":60,"line":213},[58,11570,11571],{"class":92},"    fs     ",[58,11573,4723],{"class":71},[58,11575,103],{"class":75},[58,11577,202],{"class":92},[58,11579,205],{"class":75},[58,11581,11582],{"class":60,"line":236},[58,11583,457],{"class":92},[58,11585,11586],{"class":60,"line":259},[58,11587,83],{"emptyLinePlaceholder":82},[58,11589,11590,11592,11594,11597,11599,11601,11603,11606,11608,11610,11612,11614,11616,11618,11621,11623,11625],{"class":60,"line":264},[58,11591,216],{"class":71},[58,11593,3125],{"class":92},[58,11595,11596],{"class":276},"f ",[58,11598,4723],{"class":71},[58,11600,11528],{"class":75},[58,11602,3539],{"class":92},[58,11604,11605],{"class":75},"Open",[58,11607,273],{"class":92},[58,11609,868],{"class":276},[58,11611,10754],{"class":71},[58,11613,4720],{"class":92},[58,11615,11515],{"class":75},[58,11617,202],{"class":92},[58,11619,11620],{"class":75},"File",[58,11622,889],{"class":92},[58,11624,4736],{"class":71},[58,11626,290],{"class":92},[58,11628,11629,11631,11634,11636,11639],{"class":60,"line":293},[58,11630,3160],{"class":71},[58,11632,11633],{"class":92}," name ",[58,11635,324],{"class":71},[58,11637,11638],{"class":99}," \"\u002F\"",[58,11640,233],{"class":92},[58,11642,11643,11646,11648],{"class":60,"line":315},[58,11644,11645],{"class":92},"        name ",[58,11647,338],{"class":71},[58,11649,11650],{"class":92}," f.prefix\n",[58,11652,11653,11656,11659],{"class":60,"line":332},[58,11654,11655],{"class":92},"    } ",[58,11657,11658],{"class":71},"else",[58,11660,233],{"class":92},[58,11662,11663,11665,11667,11670,11672,11674,11677,11679,11681,11684,11686],{"class":60,"line":344},[58,11664,11645],{"class":92},[58,11666,338],{"class":71},[58,11668,11669],{"class":92}," f.prefix ",[58,11671,3891],{"class":71},[58,11673,11638],{"class":99},[58,11675,11676],{"class":71}," +",[58,11678,301],{"class":92},[58,11680,304],{"class":75},[58,11682,11683],{"class":92},"(name, ",[58,11685,310],{"class":99},[58,11687,177],{"class":92},[58,11689,11690],{"class":60,"line":350},[58,11691,451],{"class":92},[58,11693,11694,11696,11698,11701,11704,11706],{"class":60,"line":355},[58,11695,267],{"class":71},[58,11697,10298],{"class":92},[58,11699,11700],{"class":75},"FS",[58,11702,11703],{"class":92},"(f.fs).",[58,11705,11605],{"class":75},[58,11707,11708],{"class":92},"(name)\n",[58,11710,11711],{"class":60,"line":371},[58,11712,457],{"class":92},[15,11714,11715],{},"两个细节：",[2638,11717,11718,11741],{},[1052,11719,11720,11728,11729,778,11731,11733,11734,11736,11737,11740],{},[737,11721,11722,11725,11726,11218],{},[19,11723,11724],{},"all:dist"," 里的 ",[19,11727,11217],{},"：embed 目录时默认会排除 ",[19,11730,761],{},[19,11732,202],{}," 开头的文件（上篇坑二就是被这个坑了，当时靠改 Vite 输出名绕开）。",[19,11735,11217],{}," 前缀表示「全部包含」，前端构建产物里那些 ",[19,11738,11739],{},"_xxx.js"," 助手文件直接安全落地，不用再改前端配置；",[1052,11742,11743,11746,11747,11749,11750,11753],{},[19,11744,11745],{},"http.FileSystem"," 包装：加了一层 ",[19,11748,38],{}," 前缀映射，",[19,11751,11752],{},"http.FileServer"," 和 Gin 都能直接用。",[44,11755,11757],{"id":11756},"_4-路由静态服务-缓存策略","4. 路由：静态服务 + 缓存策略",[49,11759,11761],{"className":51,"code":11760,"language":53,"meta":54,"style":54},"\u002F\u002F backend\u002Finternal\u002Fhandler\u002Frouter.go\nfunc NewRouter(db *gorm.DB, uploadDir string) *gin.Engine {\n    r := gin.Default()\n    ...\n    dist := http.FileServer(web.Dist)\n    r.GET(\"\u002Fdist\", func(c *gin.Context) { c.Redirect(302, \"\u002Fdist\u002F\") })\n    r.GET(\"\u002Fdist\u002F*filepath\", func(c *gin.Context) {\n        p := c.Param(\"filepath\")\n        if p == \"\u002F\" || p == \"\u002Findex.html\" {\n            \u002F\u002F index.html 禁缓存：升级后浏览器立刻拿到新入口\n            c.Header(\"Cache-Control\", \"no-cache, no-store, must-revalidate\")\n        } else if strings.HasPrefix(p, \"\u002Fassets\u002F\") {\n            \u002F\u002F 带内容 hash 的资源可放心长缓存\n            c.Header(\"Cache-Control\", \"public, max-age=31536000, immutable\")\n        }\n        http.StripPrefix(\"\u002Fdist\", dist).ServeHTTP(c.Writer, c.Request)\n    })\n    r.GET(\"\u002F\", func(c *gin.Context) { c.Redirect(302, \"\u002Fdist\u002F\") })\n    ...\n}\n",[19,11762,11763,11768,11810,11822,11827,11842,11887,11916,11934,11955,11960,11979,12002,12007,12024,12028,12049,12053,12093,12097],{"__ignoreMap":54},[58,11764,11765],{"class":60,"line":61},[58,11766,11767],{"class":64},"\u002F\u002F backend\u002Finternal\u002Fhandler\u002Frouter.go\n",[58,11769,11770,11772,11775,11777,11780,11782,11785,11787,11790,11792,11795,11797,11799,11801,11803,11805,11808],{"class":60,"line":68},[58,11771,216],{"class":71},[58,11773,11774],{"class":75}," NewRouter",[58,11776,273],{"class":92},[58,11778,11779],{"class":276},"db",[58,11781,280],{"class":71},[58,11783,11784],{"class":75},"gorm",[58,11786,202],{"class":92},[58,11788,11789],{"class":75},"DB",[58,11791,889],{"class":92},[58,11793,11794],{"class":276},"uploadDir",[58,11796,10754],{"class":71},[58,11798,3539],{"class":92},[58,11800,4723],{"class":71},[58,11802,225],{"class":75},[58,11804,202],{"class":92},[58,11806,11807],{"class":75},"Engine",[58,11809,233],{"class":92},[58,11811,11812,11814,11816,11818,11820],{"class":60,"line":79},[58,11813,5127],{"class":92},[58,11815,242],{"class":71},[58,11817,5132],{"class":92},[58,11819,5135],{"class":75},[58,11821,508],{"class":92},[58,11823,11824],{"class":60,"line":86},[58,11825,11826],{"class":71},"    ...\n",[58,11828,11829,11832,11834,11836,11839],{"class":60,"line":96},[58,11830,11831],{"class":92},"    dist ",[58,11833,242],{"class":71},[58,11835,10298],{"class":92},[58,11837,11838],{"class":75},"FileServer",[58,11840,11841],{"class":92},"(web.Dist)\n",[58,11843,11844,11846,11848,11850,11853,11855,11857,11859,11861,11863,11865,11867,11869,11872,11875,11877,11880,11882,11884],{"class":60,"line":109},[58,11845,5265],{"class":92},[58,11847,516],{"class":75},[58,11849,273],{"class":92},[58,11851,11852],{"class":99},"\"\u002Fdist\"",[58,11854,889],{"class":92},[58,11856,216],{"class":71},[58,11858,273],{"class":92},[58,11860,277],{"class":276},[58,11862,280],{"class":71},[58,11864,225],{"class":75},[58,11866,202],{"class":92},[58,11868,287],{"class":75},[58,11870,11871],{"class":92},") { c.",[58,11873,11874],{"class":75},"Redirect",[58,11876,273],{"class":92},[58,11878,11879],{"class":382},"302",[58,11881,889],{"class":92},[58,11883,11285],{"class":99},[58,11885,11886],{"class":92},") })\n",[58,11888,11889,11891,11893,11895,11898,11900,11902,11904,11906,11908,11910,11912,11914],{"class":60,"line":119},[58,11890,5265],{"class":92},[58,11892,516],{"class":75},[58,11894,273],{"class":92},[58,11896,11897],{"class":99},"\"\u002Fdist\u002F*filepath\"",[58,11899,889],{"class":92},[58,11901,216],{"class":71},[58,11903,273],{"class":92},[58,11905,277],{"class":276},[58,11907,280],{"class":71},[58,11909,225],{"class":75},[58,11911,202],{"class":92},[58,11913,287],{"class":75},[58,11915,290],{"class":92},[58,11917,11918,11920,11922,11924,11927,11929,11932],{"class":60,"line":129},[58,11919,296],{"class":92},[58,11921,242],{"class":71},[58,11923,5557],{"class":92},[58,11925,11926],{"class":75},"Param",[58,11928,273],{"class":92},[58,11930,11931],{"class":99},"\"filepath\"",[58,11933,177],{"class":92},[58,11935,11936,11938,11940,11942,11944,11946,11948,11950,11953],{"class":60,"line":139},[58,11937,318],{"class":71},[58,11939,321],{"class":92},[58,11941,324],{"class":71},[58,11943,11638],{"class":99},[58,11945,5668],{"class":71},[58,11947,321],{"class":92},[58,11949,324],{"class":71},[58,11951,11952],{"class":99}," \"\u002Findex.html\"",[58,11954,233],{"class":92},[58,11956,11957],{"class":60,"line":149},[58,11958,11959],{"class":64},"            \u002F\u002F index.html 禁缓存：升级后浏览器立刻拿到新入口\n",[58,11961,11962,11964,11967,11969,11972,11974,11977],{"class":60,"line":159},[58,11963,5585],{"class":92},[58,11965,11966],{"class":75},"Header",[58,11968,273],{"class":92},[58,11970,11971],{"class":99},"\"Cache-Control\"",[58,11973,889],{"class":92},[58,11975,11976],{"class":99},"\"no-cache, no-store, must-revalidate\"",[58,11978,177],{"class":92},[58,11980,11981,11984,11986,11989,11991,11994,11997,12000],{"class":60,"line":164},[58,11982,11983],{"class":92},"        } ",[58,11985,11658],{"class":71},[58,11987,11988],{"class":71}," if",[58,11990,301],{"class":92},[58,11992,11993],{"class":75},"HasPrefix",[58,11995,11996],{"class":92},"(p, ",[58,11998,11999],{"class":99},"\"\u002Fassets\u002F\"",[58,12001,290],{"class":92},[58,12003,12004],{"class":60,"line":174},[58,12005,12006],{"class":64},"            \u002F\u002F 带内容 hash 的资源可放心长缓存\n",[58,12008,12009,12011,12013,12015,12017,12019,12022],{"class":60,"line":180},[58,12010,5585],{"class":92},[58,12012,11966],{"class":75},[58,12014,273],{"class":92},[58,12016,11971],{"class":99},[58,12018,889],{"class":92},[58,12020,12021],{"class":99},"\"public, max-age=31536000, immutable\"",[58,12023,177],{"class":92},[58,12025,12026],{"class":60,"line":185},[58,12027,347],{"class":92},[58,12029,12030,12033,12036,12038,12040,12043,12046],{"class":60,"line":191},[58,12031,12032],{"class":92},"        http.",[58,12034,12035],{"class":75},"StripPrefix",[58,12037,273],{"class":92},[58,12039,11852],{"class":99},[58,12041,12042],{"class":92},", dist).",[58,12044,12045],{"class":75},"ServeHTTP",[58,12047,12048],{"class":92},"(c.Writer, c.Request)\n",[58,12050,12051],{"class":60,"line":208},[58,12052,5355],{"class":92},[58,12054,12055,12057,12059,12061,12063,12065,12067,12069,12071,12073,12075,12077,12079,12081,12083,12085,12087,12089,12091],{"class":60,"line":213},[58,12056,5265],{"class":92},[58,12058,516],{"class":75},[58,12060,273],{"class":92},[58,12062,310],{"class":99},[58,12064,889],{"class":92},[58,12066,216],{"class":71},[58,12068,273],{"class":92},[58,12070,277],{"class":276},[58,12072,280],{"class":71},[58,12074,225],{"class":75},[58,12076,202],{"class":92},[58,12078,287],{"class":75},[58,12080,11871],{"class":92},[58,12082,11874],{"class":75},[58,12084,273],{"class":92},[58,12086,11879],{"class":382},[58,12088,889],{"class":92},[58,12090,11285],{"class":99},[58,12092,11886],{"class":92},[58,12094,12095],{"class":60,"line":236},[58,12096,11826],{"class":71},[58,12098,12099],{"class":60,"line":259},[58,12100,457],{"class":92},[15,12102,12103],{},"缓存策略是内嵌发布的必修课（坑二细说）：入口禁缓存、hash 资源长缓存。",[44,12105,12107],{"id":12106},"_5-打包脚本windows-版-linux-交叉编译版","5. 打包脚本：Windows 版 + Linux 交叉编译版",[49,12109,12111],{"className":579,"code":12110,"language":581,"meta":54,"style":54},"#!\u002Fusr\u002Fbin\u002Fenv bash\n# build-linux.sh —— 在 Windows\u002FmacOS\u002FLinux 任意平台交叉编译 linux\u002Famd64 产物\nset -euo pipefail\ncd \"$(dirname \"$0\")\"\n\nBINARY=\"${BINARY:-magic-img}\"\n\necho \"[1\u002F3] 构建前端并复制产物到 backend\u002Fweb\u002Fdist ...\"\n(cd frontend && npm run build)\n\necho \"[2\u002F3] 交叉编译后端并内嵌前端 (CGO_ENABLED=0 GOOS=linux GOARCH=amd64) ...\"\n(cd backend && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o \"$BINARY\" -ldflags=\"-s -w\" .\u002Fcmd\u002Fserver)\nchmod +x \"backend\u002F$BINARY\"\n\necho \"[3\u002F3] 打包完成: backend\u002F$BINARY\"\n",[19,12112,12113,12118,12123,12133,12151,12155,12176,12180,12188,12207,12211,12218,12275,12290,12294],{"__ignoreMap":54},[58,12114,12115],{"class":60,"line":61},[58,12116,12117],{"class":64},"#!\u002Fusr\u002Fbin\u002Fenv bash\n",[58,12119,12120],{"class":60,"line":68},[58,12121,12122],{"class":64},"# build-linux.sh —— 在 Windows\u002FmacOS\u002FLinux 任意平台交叉编译 linux\u002Famd64 产物\n",[58,12124,12125,12127,12130],{"class":60,"line":79},[58,12126,602],{"class":382},[58,12128,12129],{"class":382}," -euo",[58,12131,12132],{"class":99}," pipefail\n",[58,12134,12135,12137,12140,12143,12145,12148],{"class":60,"line":86},[58,12136,683],{"class":382},[58,12138,12139],{"class":99}," \"$(",[58,12141,12142],{"class":75},"dirname",[58,12144,478],{"class":99},[58,12146,12147],{"class":382},"$0",[58,12149,12150],{"class":99},"\")\"\n",[58,12152,12153],{"class":60,"line":96},[58,12154,83],{"emptyLinePlaceholder":82},[58,12156,12157,12160,12162,12165,12167,12170,12173],{"class":60,"line":109},[58,12158,12159],{"class":92},"BINARY",[58,12161,338],{"class":71},[58,12163,12164],{"class":99},"\"${",[58,12166,12159],{"class":92},[58,12168,12169],{"class":71},":-",[58,12171,12172],{"class":92},"magic-img",[58,12174,12175],{"class":99},"}\"\n",[58,12177,12178],{"class":60,"line":119},[58,12179,83],{"emptyLinePlaceholder":82},[58,12181,12182,12185],{"class":60,"line":129},[58,12183,12184],{"class":382},"echo",[58,12186,12187],{"class":99}," \"[1\u002F3] 构建前端并复制产物到 backend\u002Fweb\u002Fdist ...\"\n",[58,12189,12190,12192,12194,12197,12199,12201,12203,12205],{"class":60,"line":139},[58,12191,273],{"class":92},[58,12193,683],{"class":382},[58,12195,12196],{"class":99}," frontend",[58,12198,1185],{"class":92},[58,12200,6608],{"class":75},[58,12202,1136],{"class":99},[58,12204,693],{"class":99},[58,12206,177],{"class":92},[58,12208,12209],{"class":60,"line":149},[58,12210,83],{"emptyLinePlaceholder":82},[58,12212,12213,12215],{"class":60,"line":159},[58,12214,12184],{"class":382},[58,12216,12217],{"class":99}," \"[2\u002F3] 交叉编译后端并内嵌前端 (CGO_ENABLED=0 GOOS=linux GOARCH=amd64) ...\"\n",[58,12219,12220,12222,12224,12227,12230,12232,12234,12237,12239,12242,12245,12247,12250,12252,12254,12256,12258,12261,12264,12267,12270,12273],{"class":60,"line":164},[58,12221,273],{"class":92},[58,12223,683],{"class":382},[58,12225,12226],{"class":99}," backend",[58,12228,12229],{"class":92}," && CGO_ENABLED",[58,12231,338],{"class":71},[58,12233,3171],{"class":99},[58,12235,12236],{"class":92}," GOOS",[58,12238,338],{"class":71},[58,12240,12241],{"class":99},"linux",[58,12243,12244],{"class":92}," GOARCH",[58,12246,338],{"class":71},[58,12248,12249],{"class":99},"amd64",[58,12251,1593],{"class":75},[58,12253,693],{"class":99},[58,12255,696],{"class":382},[58,12257,478],{"class":99},[58,12259,12260],{"class":92},"$BINARY",[58,12262,12263],{"class":99},"\"",[58,12265,12266],{"class":382}," -ldflags=",[58,12268,12269],{"class":99},"\"-s -w\"",[58,12271,12272],{"class":99}," .\u002Fcmd\u002Fserver",[58,12274,177],{"class":92},[58,12276,12277,12280,12283,12286,12288],{"class":60,"line":174},[58,12278,12279],{"class":75},"chmod",[58,12281,12282],{"class":99}," +x",[58,12284,12285],{"class":99}," \"backend\u002F",[58,12287,12260],{"class":92},[58,12289,106],{"class":99},[58,12291,12292],{"class":60,"line":180},[58,12293,83],{"emptyLinePlaceholder":82},[58,12295,12296,12298,12301,12303],{"class":60,"line":185},[58,12297,12184],{"class":382},[58,12299,12300],{"class":99}," \"[3\u002F3] 打包完成: backend\u002F",[58,12302,12260],{"class":92},[58,12304,106],{"class":99},[15,12306,12307,12308,12311,12312,782],{},"Windows 版（",[19,12309,12310],{},"build-win.bat","）同理，只是产物叫 ",[19,12313,12314],{},"magic-img.exe",[11,12316,720],{"id":720},[44,12318,12320,12321,12324,12325,12327],{"id":12319},"坑一goembed-dist-静默漏掉-_-开头文件升级解法","坑一：",[19,12322,12323],{},"\u002F\u002Fgo:embed dist"," 静默漏掉 ",[19,12326,761],{}," 开头文件（升级解法）",[15,12329,12330,12331,12338,12339,12341],{},"上篇说过：embed 目录时",[737,12332,12333,12334,778,12336,762],{},"硬编码排除 ",[19,12335,761],{},[19,12337,202],{},"。上篇的解法是改 Vite 输出文件名去掉 ",[19,12340,761],{}," 前缀；这次直接用：",[49,12343,12345],{"className":51,"code":12344,"language":53,"meta":54,"style":54},"\u002F\u002Fgo:embed all:dist   \u002F\u002F all: 前缀 = 包含全部文件，不再静默跳过\n",[19,12346,12347],{"__ignoreMap":54},[58,12348,12349,12352],{"class":60,"line":61},[58,12350,12351],{"class":64},"\u002F\u002Fgo:embed all:dist",[58,12353,12354],{"class":64},"   \u002F\u002F all: 前缀 = 包含全部文件，不再静默跳过\n",[15,12356,12357],{},[737,12358,12359,12360,12362,12363,12365],{},"结论：能用 ",[19,12361,11217],{}," 就用 ",[19,12364,11217],{},"，从根上消灭这类\"文件莫名 404\"的问题。",[44,12367,12369],{"id":12368},"坑二开发版是新的打包版却是旧的","坑二：开发版是新的、打包版却是旧的",[15,12371,12372,12373,12376],{},"现象：Vite 开发服务一切正常，",[19,12374,12375],{},":8080\u002Fdist\u002F"," 打包版却是旧界面，强刷也不一定好。",[15,12378,12379,12380,12382,12383,12386,12387,12392],{},"排查过程值得记录：先是怀疑 ",[19,12381,934],{}," 缓存了旧 embed（上篇坑三的思路），重编重启无效；然后直接抓取 8080 返回的 HTML 和 JS，发现",[737,12384,12385],{},"服务端内容已经是新的","——问题出在",[737,12388,12389,12390],{},"浏览器缓存了 ",[19,12391,11351],{},"：它引用旧 hash 的资源，一直不更新。",[15,12394,12395,12397,12398,12401,12402,12405],{},[737,12396,739],{},"：入口文件响应 ",[19,12399,12400],{},"Cache-Control: no-store","（见上面路由代码），带 hash 的资源给 ",[19,12403,12404],{},"immutable"," 长缓存。升级后浏览器每次拉新入口，资源文件名一变就自动换新，两全其美。",[10004,12407,12408],{},[15,12409,12410],{},"教训：排查\"新旧不一致\"先分清是哪一层——服务端产物、浏览器入口缓存、还是资源缓存。抓包看响应内容再动手。",[44,12412,12414],{"id":12413},"坑三windows-上跑的-build-linuxsh产出的文件-linux-却执行不了","坑三：Windows 上跑的 build-linux.sh，产出的文件 Linux 却执行不了",[15,12416,12417,12418,7095,12421,12427,12428,12430],{},"最初脚本只写了 ",[19,12419,12420],{},"CGO_ENABLED=0 go build",[737,12422,12423,12424],{},"没指定 ",[19,12425,12426],{},"GOOS\u002FGOARCH","。在 Windows 上跑，产出的其实是个 Windows PE 文件（只是没叫 ",[19,12429,8156],{},"），传到 Linux 自然「无法执行」。",[15,12432,12433,12435],{},[737,12434,739],{},"：脚本里显式交叉编译：",[49,12437,12439],{"className":579,"code":12438,"language":581,"meta":54,"style":54},"CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o \"$BINARY\" -ldflags=\"-s -w\" .\u002Fcmd\u002Fserver\n",[19,12440,12441],{"__ignoreMap":54},[58,12442,12443,12446,12448,12450,12452,12454,12456,12458,12460,12462,12464,12466,12468,12470,12472,12474,12476,12478],{"class":60,"line":61},[58,12444,12445],{"class":92},"CGO_ENABLED",[58,12447,338],{"class":71},[58,12449,3171],{"class":99},[58,12451,12236],{"class":92},[58,12453,338],{"class":71},[58,12455,12241],{"class":99},[58,12457,12244],{"class":92},[58,12459,338],{"class":71},[58,12461,12249],{"class":99},[58,12463,1593],{"class":75},[58,12465,693],{"class":99},[58,12467,696],{"class":382},[58,12469,478],{"class":99},[58,12471,12260],{"class":92},[58,12473,12263],{"class":99},[58,12475,12266],{"class":382},[58,12477,12269],{"class":99},[58,12479,12480],{"class":99}," .\u002Fcmd\u002Fserver\n",[15,12482,12483,12484,12487,12488,12491,12492],{},"能这么干的前提是",[737,12485,12486],{},"全依赖树无 cgo","——SQLite 用的是 modernc 纯 Go 驱动，bcrypt 用 ",[19,12489,12490],{},"x\u002Fcrypto","。这也是选型时就要留意的点：",[737,12493,12494],{},"想跨平台交叉编译，先把 cgo 依赖清干净。",[15,12496,12497,12498,12501,12502,12505],{},"另外两个小提醒：Windows 上编出的文件没有 Unix 执行位，上传服务器后记得 ",[19,12499,12500],{},"chmod +x","；",[19,12503,12504],{},"-ldflags=\"-s -w\""," 剥离符号，体积从 30MB+ 降到 26MB。",[44,12507,12509],{"id":12508},"坑四dist-被-gitignore-后embed-直接编译失败","坑四：dist 被 gitignore 后，embed 直接编译失败",[15,12511,12512,12514,12515,12517,12518,12521,12522,12524],{},[19,12513,11376],{}," 是构建产物、入了 .gitignore，但 ",[19,12516,34],{}," 在",[737,12519,12520],{},"编译期","就要读它——刚 clone 下来直接 ",[19,12523,934],{}," 会报 embed 找不到文件。",[15,12526,12527,12529,12530,12533],{},[737,12528,739],{},"：gitignore 里保留 ",[19,12531,12532],{},"dist\u002F.gitkeep","（一个空文件），让目录存在于版本库中：",[49,12535,12539],{"className":12536,"code":12537,"language":12538,"meta":54,"style":54},"language-gitignore shiki shiki-themes github-light github-dark","backend\u002Fweb\u002Fdist\u002F*\n!backend\u002Fweb\u002Fdist\u002F\n!backend\u002Fweb\u002Fdist\u002F.gitkeep\n","gitignore",[19,12540,12541,12546,12551],{"__ignoreMap":54},[58,12542,12543],{"class":60,"line":61},[58,12544,12545],{},"backend\u002Fweb\u002Fdist\u002F*\n",[58,12547,12548],{"class":60,"line":68},[58,12549,12550],{},"!backend\u002Fweb\u002Fdist\u002F\n",[58,12552,12553],{"class":60,"line":79},[58,12554,12555],{},"!backend\u002Fweb\u002Fdist\u002F.gitkeep\n",[15,12557,12558,12559,12561,12562,12564],{},"这样 clone 后可以先 ",[19,12560,934],{}," 通过，再 ",[19,12563,11369],{}," 灌入真实前端。",[44,12566,12568],{"id":12567},"坑五500kb-chunk-警告别慌","坑五：>500KB chunk 警告，别慌",[15,12570,12571,12572,12575,12576,782],{},"Vite 构建会警告单 chunk 超过 500KB。这个项目里 Base UI 组件库全量引入，单 chunk 约 640KB——",[737,12573,12574],{},"属正常，不要为了消警告去拆配置","。真要优化就上路由级 ",[19,12577,12578],{},"React.lazy",[11,12580,947],{"id":947},[49,12582,12584],{"className":579,"code":12583,"language":581,"meta":54,"style":54},"$ build-win.bat        # backendmagic-img.exe\n$ bash build-linux.sh  # backendmagic-img（linux\u002Famd64 ELF）\n",[19,12585,12586,12596],{"__ignoreMap":54},[58,12587,12588,12590,12593],{"class":60,"line":61},[58,12589,957],{"class":75},[58,12591,12592],{"class":99}," build-win.bat",[58,12594,12595],{"class":64},"        # backendmagic-img.exe\n",[58,12597,12598,12600,12602,12605],{"class":60,"line":68},[58,12599,957],{"class":75},[58,12601,960],{"class":99},[58,12603,12604],{"class":99}," build-linux.sh",[58,12606,12607],{"class":64},"  # backendmagic-img（linux\u002Famd64 ELF）\n",[15,12609,12610,12611,12614,12615,782],{},"一个约 26MB 的二进制，内嵌 React 前端、Gin API、SQLite 数据初始化，拷到 Windows\u002FLinux 机器直接跑，浏览器访问 ",[19,12612,12613],{},"http:\u002F\u002Flocalhost:8080"," 自动重定向到 ",[19,12616,11355],{},[11,12618,1041],{"id":1041},[15,12620,12621],{},"这轮相比上篇的三点升级：",[1049,12623,12624,12630,12640],{},[1052,12625,12626,12629],{},[737,12627,12628],{},"hash 路由 + base \u002Fdist","：省掉 SPA fallback，静态服务即开即用；",[1052,12631,12632,4215,12637,12639],{},[737,12633,12634,12636],{},[19,12635,11217],{}," embed 前缀",[19,12638,761],{}," 开头文件问题一劳永逸；",[1052,12641,12642,12648],{},[737,12643,12644,12645,12647],{},"显式 ",[19,12646,12426],{}," 交叉编译","：让「一个脚本、任意平台、产出 Linux 二进制」成为现实。",[15,12650,12651,12652,12655],{},"加上",[737,12653,12654],{},"入口 no-store \u002F 资源 immutable"," 的缓存策略，单文件分发的体验才算闭环——不然每次升级都要和浏览器缓存打架。",[1066,12657,12658],{},"html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sHbNN, html code.shiki .sHbNN{--shiki-light:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .siTax, html code.shiki .siTax{--shiki-light:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sw2iP, html code.shiki .sw2iP{--shiki-light:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sIX_F, html code.shiki .sIX_F{--shiki-light:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .scXbn, html code.shiki .scXbn{--shiki-light:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .suQ91, html code.shiki .suQ91{--shiki-light:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sPK6S, html code.shiki .sPK6S{--shiki-light:#E36209;--shiki-dark:#FFAB70}",{"title":54,"searchDepth":68,"depth":79,"links":12660},[12661,12662,12663,12670,12678,12679],{"id":13,"depth":68,"text":13},{"id":28,"depth":68,"text":28},{"id":42,"depth":68,"text":42,"children":12664},[12665,12666,12667,12668,12669],{"id":11245,"depth":79,"text":11246},{"id":11363,"depth":79,"text":11364},{"id":11419,"depth":79,"text":11420},{"id":11756,"depth":79,"text":11757},{"id":12106,"depth":79,"text":12107},{"id":720,"depth":68,"text":720,"children":12671},[12672,12674,12675,12676,12677],{"id":12319,"depth":79,"text":12673},"坑一：\u002F\u002Fgo:embed dist 静默漏掉 _ 开头文件（升级解法）",{"id":12368,"depth":79,"text":12369},{"id":12413,"depth":79,"text":12414},{"id":12508,"depth":79,"text":12509},{"id":12567,"depth":79,"text":12568},{"id":947,"depth":68,"text":947},{"id":1041,"depth":68,"text":1041},"以 Magic Img（React 19 + Vite + Gin + SQLite）为实例，记录把前端塞进 Go 二进制、并在 Windows 上交叉编译 Linux 产物的完整过程：embed 的 all 前缀、hash 路由、缓存策略、GOOS 交叉编译等实操坑。","\u002Fposts\u002Fgo-embed-react-single-binary\u002Fimg\u002Fcover.svg",{},"\u002Fposts\u002Fgo-embed-react-single-binary","2026-08-16T00:00:00",{"title":11157,"description":12680},"posts\u002Fgo-embed-react-single-binary\u002Findex",[1098,9973,103,12688,12689],"交叉编译","打包","WV-T-MOO5Cu-aycw-Ony8s9hjA3_k0S4XU-T0QsCow0",{"id":12692,"title":12693,"author":6,"body":12694,"description":13939,"difficulty":2299,"draft":1088,"extension":1089,"image":13940,"meta":13941,"navigation":82,"path":13942,"pinned":1088,"published":12684,"seo":13943,"series":13944,"seriesOrder":61,"stem":13945,"tags":13946,"updated":11153,"__hash__":13950},"posts\u002Fposts\u002Freact-photo-album-masonry\u002Findex.md","用 react-photo-album 给生图图库做瀑布流排版",{"type":8,"value":12695,"toc":13922},[12696,12699,12702,12720,12727,12791,12802,12805,12828,12831,12835,12849,12853,12869,13047,13051,13448,13451,13485,13488,13492,13502,13508,13533,13538,13549,13555,13566,13569,13609,13622,13745,13757,13761,13768,13786,13791,13881,13887,13891,13902,13905,13908,13914,13919],[11,12697,12698],{"id":12698},"技术背景",[15,12700,12701],{},"Magic Img 是我做的一个 AI 生图提示词管理工具（自托管、本地优先），其中「生图图库」页面要把所有生成图片以图片墙的形式展示出来。图片墙有两个天然矛盾：",[1049,12703,12704,12710],{},[1052,12705,12706,12709],{},[737,12707,12708],{},"等宽网格","（CSS Grid）：整齐，但每行高度由最高的那张决定——不同比例的图片混排时，矮图下面会留下大洞，看起来\"很乱\"；",[1052,12711,12712,12715,12716,12719],{},[737,12713,12714],{},"原生比例展示","：图库必须不裁剪（这是产品约束），所以不能用 ",[19,12717,12718],{},"object-cover"," 强行统一比例。",[15,12721,12722,12723,12726],{},"业内的标准解法是 ",[737,12724,12725],{},"Masonry 瀑布流","：按列紧密堆放，每张图保持原始比例，列间高度错落但整体紧凑。实现瀑布流的常见方案有三类：",[995,12728,12729,12743],{},[998,12730,12731],{},[1001,12732,12733,12735,12738,12741],{},[1004,12734,3752],{},[1004,12736,12737],{},"依赖",[1004,12739,12740],{},"顺序",[1004,12742,6344],{},[1014,12744,12745,12762,12775],{},[1001,12746,12747,12754,12756,12759],{},[1019,12748,12749,12750,12753],{},"CSS ",[19,12751,12752],{},"columns"," 多列",[1019,12755,2473],{},[1019,12757,12758],{},"纵向阅读序（先下后右）",[1019,12760,12761],{},"最新图片不再出现在左上角，时间语义丢失",[1001,12763,12764,12767,12769,12772],{},[1019,12765,12766],{},"grid row-span 自研",[1019,12768,2473],{},[1019,12770,12771],{},"保持",[1019,12773,12774],{},"需固定列数 + 固定行高，逻辑要自己写",[1001,12776,12777,12782,12785,12788],{},[1019,12778,12779],{},[737,12780,12781],{},"react-photo-album",[1019,12783,12784],{},"~4KB，无依赖",[1019,12786,12787],{},"基本保持（逐列最短列填充）",[1019,12789,12790],{},"专为相册墙设计，TS 友好，维护活跃",[15,12792,12793,12794,12797,12798,12801],{},"最终选了 ",[737,12795,12796],{},"react-photo-album v3","：体积小、类型齐全，而且它的 Masonry 模式恰好需要「每张图的 ",[19,12799,12800],{},"width\u002Fheight","」——我们的数据库里本来就存了，零额外成本。",[11,12803,12804],{"id":12804},"需求梳理",[1049,12806,12807,12813,12819,12822,12825],{},[1052,12808,12809,12810,12501],{},"展示全部生成图（21 张起，会持续增长），",[737,12811,12812],{},"原比例、不裁剪",[1052,12814,12815,12816,12501],{},"不同尺寸（2304×1728、4608×3456 等）混排时",[737,12817,12818],{},"紧凑、不留洞",[1052,12820,12821],{},"顺序基本保持「新图靠前」；",[1052,12823,12824],{},"点击任意一张打开大图预览（YARL 灯箱），悬停显示所属提示词；",[1052,12826,12827],{},"平板性能：墙上只用 300px 缩略图，原图只进灯箱按需加载。",[11,12829,12830],{"id":12830},"安装与使用",[44,12832,12834],{"id":12833},"_1-安装","1. 安装",[49,12836,12838],{"className":579,"code":12837,"language":581,"meta":54,"style":54},"npm install react-photo-album\n",[19,12839,12840],{"__ignoreMap":54},[58,12841,12842,12844,12846],{"class":60,"line":61},[58,12843,6608],{"class":75},[58,12845,622],{"class":99},[58,12847,12848],{"class":99}," react-photo-album\n",[44,12850,12852],{"id":12851},"_2-准备数据关键输入是宽高","2. 准备数据（关键输入是宽高）",[15,12854,12855,12856,12859,12860,744,12862,744,12865,12868],{},"库要求的 ",[19,12857,12858],{},"Photo"," 对象只有三个必需字段：",[19,12861,3613],{},[19,12863,12864],{},"width",[19,12866,12867],{},"height","。我们的接口本来就返回每张图的原始宽高：",[49,12870,12872],{"className":11303,"code":12871,"language":11305,"meta":54,"style":54},"import PhotoAlbum, { type Photo } from \"react-photo-album\"\n\u002F\u002F 必须引入样式表！容器\u002F列的布局规则都在这里（坑 1 详述）\nimport \"react-photo-album\u002Fstyles.css\"\n\ninterface GalleryPhoto extends Photo {\n  prompt_text?: string\n}\n\nconst albumPhotos: GalleryPhoto[] = images.map((img) => ({\n  key: String(img.id),\n  src: assetUrl(img.thumb_path) || assetUrl(img.file_path),\n  width: img.width,\n  height: img.height,\n  alt: img.prompt_text || `生成图 ${img.id}`,\n  prompt_text: img.prompt_text,\n}))\n",[19,12873,12874,12892,12897,12904,12908,12924,12934,12938,12942,12975,12986,13005,13010,13015,13037,13042],{"__ignoreMap":54},[58,12875,12876,12878,12881,12883,12886,12889],{"class":60,"line":61},[58,12877,89],{"class":71},[58,12879,12880],{"class":92}," PhotoAlbum, { ",[58,12882,5865],{"class":71},[58,12884,12885],{"class":92}," Photo } ",[58,12887,12888],{"class":71},"from",[58,12890,12891],{"class":99}," \"react-photo-album\"\n",[58,12893,12894],{"class":60,"line":68},[58,12895,12896],{"class":64},"\u002F\u002F 必须引入样式表！容器\u002F列的布局规则都在这里（坑 1 详述）\n",[58,12898,12899,12901],{"class":60,"line":79},[58,12900,89],{"class":71},[58,12902,12903],{"class":99}," \"react-photo-album\u002Fstyles.css\"\n",[58,12905,12906],{"class":60,"line":86},[58,12907,83],{"emptyLinePlaceholder":82},[58,12909,12910,12913,12916,12919,12922],{"class":60,"line":96},[58,12911,12912],{"class":71},"interface",[58,12914,12915],{"class":75}," GalleryPhoto",[58,12917,12918],{"class":71}," extends",[58,12920,12921],{"class":75}," Photo",[58,12923,233],{"class":92},[58,12925,12926,12929,12932],{"class":60,"line":109},[58,12927,12928],{"class":276},"  prompt_text",[58,12930,12931],{"class":71},"?:",[58,12933,5871],{"class":382},[58,12935,12936],{"class":60,"line":119},[58,12937,457],{"class":92},[58,12939,12940],{"class":60,"line":129},[58,12941,83],{"emptyLinePlaceholder":82},[58,12943,12944,12946,12949,12951,12953,12955,12957,12960,12963,12966,12968,12970,12972],{"class":60,"line":139},[58,12945,3310],{"class":71},[58,12947,12948],{"class":382}," albumPhotos",[58,12950,2023],{"class":71},[58,12952,12915],{"class":75},[58,12954,3105],{"class":92},[58,12956,338],{"class":71},[58,12958,12959],{"class":92}," images.",[58,12961,12962],{"class":75},"map",[58,12964,12965],{"class":92},"((",[58,12967,1363],{"class":276},[58,12969,3539],{"class":92},[58,12971,3503],{"class":71},[58,12973,12974],{"class":92}," ({\n",[58,12976,12977,12980,12983],{"class":60,"line":149},[58,12978,12979],{"class":92},"  key: ",[58,12981,12982],{"class":75},"String",[58,12984,12985],{"class":92},"(img.id),\n",[58,12987,12988,12991,12994,12997,12999,13002],{"class":60,"line":159},[58,12989,12990],{"class":92},"  src: ",[58,12992,12993],{"class":75},"assetUrl",[58,12995,12996],{"class":92},"(img.thumb_path) ",[58,12998,3290],{"class":71},[58,13000,13001],{"class":75}," assetUrl",[58,13003,13004],{"class":92},"(img.file_path),\n",[58,13006,13007],{"class":60,"line":164},[58,13008,13009],{"class":92},"  width: img.width,\n",[58,13011,13012],{"class":60,"line":174},[58,13013,13014],{"class":92},"  height: img.height,\n",[58,13016,13017,13020,13022,13025,13027,13029,13032,13035],{"class":60,"line":180},[58,13018,13019],{"class":92},"  alt: img.prompt_text ",[58,13021,3290],{"class":71},[58,13023,13024],{"class":99}," `生成图 ${",[58,13026,1363],{"class":92},[58,13028,202],{"class":99},[58,13030,13031],{"class":92},"id",[58,13033,13034],{"class":99},"}`",[58,13036,2741],{"class":92},[58,13038,13039],{"class":60,"line":185},[58,13040,13041],{"class":92},"  prompt_text: img.prompt_text,\n",[58,13043,13044],{"class":60,"line":191},[58,13045,13046],{"class":92},"}))\n",[44,13048,13050],{"id":13049},"_3-使用-masonry-布局","3. 使用 Masonry 布局",[49,13052,13054],{"className":11303,"code":13053,"language":11305,"meta":54,"style":54},"\u003CPhotoAlbum\n  layout=\"masonry\"\n  columns={(cw) => (cw \u003C 640 ? 2 : cw \u003C 960 ? 3 : cw \u003C 1280 ? 4 : 5)}\n  spacing={16}\n  padding={0}\n  photos={albumPhotos}\n  onClick={({ index }) => openPreview(index)}\n  render={{\n    photo: (props, context) => {\n      const { onClick } = props\n      const { photo, index, width, height } = context\n      return (\n        \u003Cbutton type=\"button\" onClick={onClick} className=\"group relative block w-full ...\">\n          \u003Cimg\n            src={photo.src}\n            alt={photo.alt || \"\"}\n            decoding=\"async\"\n            style={{ width, height }}\n          \u002F>\n          {\u002F* hover 提示词浮层 *\u002F}\n          \u003Cdiv className=\"... opacity-0 group-hover:opacity-100\">\n            {photo.prompt_text}\n          \u003C\u002Fdiv>\n        \u003C\u002Fbutton>\n      )\n    },\n  }}\n\u002F>\n",[19,13055,13056,13063,13073,13141,13155,13168,13178,13202,13212,13233,13251,13279,13286,13320,13328,13338,13354,13364,13374,13379,13389,13406,13411,13420,13429,13434,13438,13443],{"__ignoreMap":54},[58,13057,13058,13060],{"class":60,"line":61},[58,13059,2026],{"class":92},[58,13061,13062],{"class":382},"PhotoAlbum\n",[58,13064,13065,13068,13070],{"class":60,"line":68},[58,13066,13067],{"class":75},"  layout",[58,13069,338],{"class":71},[58,13071,13072],{"class":99},"\"masonry\"\n",[58,13074,13075,13078,13080,13083,13086,13088,13090,13093,13095,13098,13101,13103,13106,13109,13111,13114,13116,13119,13121,13123,13125,13128,13130,13133,13135,13138],{"class":60,"line":79},[58,13076,13077],{"class":75},"  columns",[58,13079,338],{"class":71},[58,13081,13082],{"class":92},"{(",[58,13084,13085],{"class":276},"cw",[58,13087,3539],{"class":92},[58,13089,3503],{"class":71},[58,13091,13092],{"class":92}," (cw ",[58,13094,2026],{"class":71},[58,13096,13097],{"class":382}," 640",[58,13099,13100],{"class":71}," ?",[58,13102,5665],{"class":382},[58,13104,13105],{"class":71}," :",[58,13107,13108],{"class":92}," cw ",[58,13110,2026],{"class":71},[58,13112,13113],{"class":382}," 960",[58,13115,13100],{"class":71},[58,13117,13118],{"class":382}," 3",[58,13120,13105],{"class":71},[58,13122,13108],{"class":92},[58,13124,2026],{"class":71},[58,13126,13127],{"class":382}," 1280",[58,13129,13100],{"class":71},[58,13131,13132],{"class":382}," 4",[58,13134,13105],{"class":71},[58,13136,13137],{"class":382}," 5",[58,13139,13140],{"class":92},")}\n",[58,13142,13143,13146,13148,13150,13153],{"class":60,"line":86},[58,13144,13145],{"class":75},"  spacing",[58,13147,338],{"class":71},[58,13149,5340],{"class":92},[58,13151,13152],{"class":382},"16",[58,13154,457],{"class":92},[58,13156,13157,13160,13162,13164,13166],{"class":60,"line":96},[58,13158,13159],{"class":75},"  padding",[58,13161,338],{"class":71},[58,13163,5340],{"class":92},[58,13165,3171],{"class":382},[58,13167,457],{"class":92},[58,13169,13170,13173,13175],{"class":60,"line":109},[58,13171,13172],{"class":75},"  photos",[58,13174,338],{"class":71},[58,13176,13177],{"class":92},"{albumPhotos}\n",[58,13179,13180,13183,13185,13188,13191,13194,13196,13199],{"class":60,"line":119},[58,13181,13182],{"class":75},"  onClick",[58,13184,338],{"class":71},[58,13186,13187],{"class":92},"{({ ",[58,13189,13190],{"class":276},"index",[58,13192,13193],{"class":92}," }) ",[58,13195,3503],{"class":71},[58,13197,13198],{"class":75}," openPreview",[58,13200,13201],{"class":92},"(index)}\n",[58,13203,13204,13207,13209],{"class":60,"line":129},[58,13205,13206],{"class":75},"  render",[58,13208,338],{"class":71},[58,13210,13211],{"class":92},"{{\n",[58,13213,13214,13217,13220,13222,13224,13227,13229,13231],{"class":60,"line":139},[58,13215,13216],{"class":75},"    photo",[58,13218,13219],{"class":92},": (",[58,13221,3608],{"class":276},[58,13223,889],{"class":92},[58,13225,13226],{"class":276},"context",[58,13228,3539],{"class":92},[58,13230,3503],{"class":71},[58,13232,233],{"class":92},[58,13234,13235,13237,13240,13243,13246,13248],{"class":60,"line":149},[58,13236,3215],{"class":71},[58,13238,13239],{"class":92}," { ",[58,13241,13242],{"class":382},"onClick",[58,13244,13245],{"class":92}," } ",[58,13247,338],{"class":71},[58,13249,13250],{"class":92}," props\n",[58,13252,13253,13255,13257,13260,13262,13264,13266,13268,13270,13272,13274,13276],{"class":60,"line":159},[58,13254,3215],{"class":71},[58,13256,13239],{"class":92},[58,13258,13259],{"class":382},"photo",[58,13261,889],{"class":92},[58,13263,13190],{"class":382},[58,13265,889],{"class":92},[58,13267,12864],{"class":382},[58,13269,889],{"class":92},[58,13271,12867],{"class":382},[58,13273,13245],{"class":92},[58,13275,338],{"class":71},[58,13277,13278],{"class":92}," context\n",[58,13280,13281,13284],{"class":60,"line":164},[58,13282,13283],{"class":71},"      return",[58,13285,93],{"class":92},[58,13287,13288,13291,13294,13297,13299,13302,13305,13307,13310,13313,13315,13318],{"class":60,"line":174},[58,13289,13290],{"class":92},"        \u003C",[58,13292,13293],{"class":2790},"button",[58,13295,13296],{"class":75}," type",[58,13298,338],{"class":71},[58,13300,13301],{"class":99},"\"button\"",[58,13303,13304],{"class":75}," onClick",[58,13306,338],{"class":71},[58,13308,13309],{"class":92},"{onClick} ",[58,13311,13312],{"class":75},"className",[58,13314,338],{"class":71},[58,13316,13317],{"class":99},"\"group relative block w-full ...\"",[58,13319,1686],{"class":92},[58,13321,13322,13325],{"class":60,"line":180},[58,13323,13324],{"class":92},"          \u003C",[58,13326,13327],{"class":2790},"img\n",[58,13329,13330,13333,13335],{"class":60,"line":185},[58,13331,13332],{"class":75},"            src",[58,13334,338],{"class":71},[58,13336,13337],{"class":92},"{photo.src}\n",[58,13339,13340,13343,13345,13348,13350,13352],{"class":60,"line":191},[58,13341,13342],{"class":75},"            alt",[58,13344,338],{"class":71},[58,13346,13347],{"class":92},"{photo.alt ",[58,13349,3290],{"class":71},[58,13351,327],{"class":99},[58,13353,457],{"class":92},[58,13355,13356,13359,13361],{"class":60,"line":208},[58,13357,13358],{"class":75},"            decoding",[58,13360,338],{"class":71},[58,13362,13363],{"class":99},"\"async\"\n",[58,13365,13366,13369,13371],{"class":60,"line":213},[58,13367,13368],{"class":75},"            style",[58,13370,338],{"class":71},[58,13372,13373],{"class":92},"{{ width, height }}\n",[58,13375,13376],{"class":60,"line":236},[58,13377,13378],{"class":92},"          \u002F>\n",[58,13380,13381,13384,13387],{"class":60,"line":259},[58,13382,13383],{"class":92},"          {",[58,13385,13386],{"class":64},"\u002F* hover 提示词浮层 *\u002F",[58,13388,457],{"class":92},[58,13390,13391,13393,13396,13399,13401,13404],{"class":60,"line":264},[58,13392,13324],{"class":92},[58,13394,13395],{"class":2790},"div",[58,13397,13398],{"class":75}," className",[58,13400,338],{"class":71},[58,13402,13403],{"class":99},"\"... opacity-0 group-hover:opacity-100\"",[58,13405,1686],{"class":92},[58,13407,13408],{"class":60,"line":293},[58,13409,13410],{"class":92},"            {photo.prompt_text}\n",[58,13412,13413,13416,13418],{"class":60,"line":315},[58,13414,13415],{"class":92},"          \u003C\u002F",[58,13417,13395],{"class":2790},[58,13419,1686],{"class":92},[58,13421,13422,13425,13427],{"class":60,"line":332},[58,13423,13424],{"class":92},"        \u003C\u002F",[58,13426,13293],{"class":2790},[58,13428,1686],{"class":92},[58,13430,13431],{"class":60,"line":344},[58,13432,13433],{"class":92},"      )\n",[58,13435,13436],{"class":60,"line":350},[58,13437,908],{"class":92},[58,13439,13440],{"class":60,"line":355},[58,13441,13442],{"class":92},"  }}\n",[58,13444,13445],{"class":60,"line":371},[58,13446,13447],{"class":92},"\u002F>\n",[15,13449,13450],{},"几个要点：",[2638,13452,13453,13462,13475],{},[1052,13454,13455,13457,13458,13461],{},[19,13456,12752],{}," 可以是数字或 ",[19,13459,13460],{},"(containerWidth) => number"," 函数，做响应式列数很方便；",[1052,13463,13464,13465,13467,13468,13471,13472,13474],{},"点击回调 ",[19,13466,13242],{}," 里拿到的是",[737,13469,13470],{},"原始数组下标"," ",[19,13473,13190],{},"，直接传给灯箱即可；",[1052,13476,13477,13478,13481,13482,13484],{},"自定义 ",[19,13479,13480],{},"render.photo"," 后，图片尺寸要自己用 context 里的 ",[19,13483,12800],{},"（渲染尺寸）设置——这两个值库已经按列宽算好了。",[11,13486,13487],{"id":13487},"遇到的坑",[44,13489,13491],{"id":13490},"坑-1忘记引入样式表-一张一列","坑 1：忘记引入样式表 → 「一张一列」",[15,13493,13494,13495,13498,13499,782],{},"这是最坑的一个。装完包、写好 ",[19,13496,13497],{},"\u003CPhotoAlbum layout=\"masonry\">","，页面渲染出来却是：",[737,13500,13501],{},"每张图占一整行，右侧大片置灰空白",[15,13503,13504,13505,13507],{},"排查过程：先怀疑是 ",[19,13506,12752],{}," 函数没生效，改成固定数字无效；再检查 DOM——容器和 5 个列（track）都在，列的宽度也算对了，但图片全都堆在布局里。",[15,13509,13510,13511,13514,13515,744,13518,13521,13522,13528,13529,13532],{},"最后翻包的 ",[19,13512,13513],{},"package.json"," 发现它导出了独立的 CSS 文件（",[19,13516,13517],{},".\u002Fstyles.css",[19,13519,13520],{},".\u002Fmasonry.css"," 等），而",[737,13523,13524,13525],{},"官方 README 示例都带了一行 ",[19,13526,13527],{},"import \"react-photo-album\u002Fstyles.css\"","。容器是 flex、列是 flex-column、列宽按 ",[19,13530,13531],{},"--react-photo-album--columns"," 计算——这些规则全在这个样式表里，不引入就退化成普通块级堆叠。",[15,13534,13535,13537],{},[737,13536,739],{},"：顶部加一行：",[49,13539,13541],{"className":11303,"code":13540,"language":11305,"meta":54,"style":54},"import \"react-photo-album\u002Fstyles.css\"\n",[19,13542,13543],{"__ignoreMap":54},[58,13544,13545,13547],{"class":60,"line":61},[58,13546,89],{"class":71},[58,13548,12903],{"class":99},[15,13550,13551,13552],{},"经验：",[737,13553,13554],{},"用这类\"无运行时依赖\"的布局库，先看它的 exports 里有没有独立 CSS，有就一定要引。",[44,13556,13558,13559,13561,13562,13565],{"id":13557},"坑-2renderphoto-是-props-context-双参签名","坑 2：",[19,13560,13480],{}," 是 ",[19,13563,13564],{},"(props, context)"," 双参签名",[15,13567,13568],{},"自定义渲染时我凭直觉写了：",[49,13570,13572],{"className":11303,"code":13571,"language":11305,"meta":54,"style":54},"photo: ({ photo, index, width, height, onClick }) => ... \u002F\u002F ❌ 报错：Property 'photo' does not exist\n",[19,13573,13574],{"__ignoreMap":54},[58,13575,13576,13578,13581,13583,13585,13587,13589,13591,13593,13595,13597,13599,13601,13603,13606],{"class":60,"line":61},[58,13577,13259],{"class":75},[58,13579,13580],{"class":92},": ({ ",[58,13582,13259],{"class":276},[58,13584,889],{"class":92},[58,13586,13190],{"class":276},[58,13588,889],{"class":92},[58,13590,12864],{"class":276},[58,13592,889],{"class":92},[58,13594,12867],{"class":276},[58,13596,889],{"class":92},[58,13598,13242],{"class":276},[58,13600,13193],{"class":92},[58,13602,3503],{"class":71},[58,13604,13605],{"class":71}," ...",[58,13607,13608],{"class":64}," \u002F\u002F ❌ 报错：Property 'photo' does not exist\n",[15,13610,13611,13612,13614,13615,13618,13619,4215],{},"TS 报错提示 ",[19,13613,13259],{}," 等属性不存在于 ",[19,13616,13617],{},"RenderPhotoProps","。看类型定义才发现 v3 的渲染函数是",[737,13620,13621],{},"两个参数",[49,13623,13625],{"className":11303,"code":13624,"language":11305,"meta":54,"style":54},"type RenderFunction\u003CProps, Context> = (props: Props, context: Context) => ReactNode\n\u002F\u002F photo 的渲染上下文（photo\u002Findex\u002Fwidth\u002Fheight）在第二个参数里\nphoto: (props, context) => {\n  const { onClick } = props\n  const { photo, index, width, height } = context\n  ...\n}\n",[19,13626,13627,13673,13678,13698,13712,13737,13741],{"__ignoreMap":54},[58,13628,13629,13631,13634,13636,13639,13641,13643,13646,13648,13650,13652,13654,13657,13659,13661,13663,13666,13668,13670],{"class":60,"line":61},[58,13630,5865],{"class":71},[58,13632,13633],{"class":75}," RenderFunction",[58,13635,2026],{"class":92},[58,13637,13638],{"class":75},"Props",[58,13640,889],{"class":92},[58,13642,287],{"class":75},[58,13644,13645],{"class":92},"> ",[58,13647,338],{"class":71},[58,13649,3125],{"class":92},[58,13651,3608],{"class":276},[58,13653,2023],{"class":71},[58,13655,13656],{"class":75}," Props",[58,13658,889],{"class":92},[58,13660,13226],{"class":276},[58,13662,2023],{"class":71},[58,13664,13665],{"class":75}," Context",[58,13667,3539],{"class":92},[58,13669,3503],{"class":71},[58,13671,13672],{"class":75}," ReactNode\n",[58,13674,13675],{"class":60,"line":68},[58,13676,13677],{"class":64},"\u002F\u002F photo 的渲染上下文（photo\u002Findex\u002Fwidth\u002Fheight）在第二个参数里\n",[58,13679,13680,13682,13684,13686,13688,13690,13692,13694,13696],{"class":60,"line":79},[58,13681,13259],{"class":75},[58,13683,2023],{"class":71},[58,13685,3125],{"class":92},[58,13687,3608],{"class":276},[58,13689,889],{"class":92},[58,13691,13226],{"class":276},[58,13693,3539],{"class":92},[58,13695,3503],{"class":71},[58,13697,233],{"class":92},[58,13699,13700,13702,13704,13706,13709],{"class":60,"line":86},[58,13701,3564],{"class":75},[58,13703,13239],{"class":92},[58,13705,13242],{"class":276},[58,13707,13708],{"class":92}," } = ",[58,13710,13711],{"class":276},"props\n",[58,13713,13714,13716,13718,13720,13722,13724,13726,13728,13730,13732,13734],{"class":60,"line":96},[58,13715,3564],{"class":75},[58,13717,13239],{"class":92},[58,13719,13259],{"class":276},[58,13721,889],{"class":92},[58,13723,13190],{"class":276},[58,13725,889],{"class":92},[58,13727,12864],{"class":276},[58,13729,889],{"class":92},[58,13731,12867],{"class":276},[58,13733,13708],{"class":92},[58,13735,13736],{"class":276},"context\n",[58,13738,13739],{"class":60,"line":109},[58,13740,11296],{"class":71},[58,13742,13743],{"class":60,"line":119},[58,13744,457],{"class":92},[15,13746,13747,13749,13750,13752,13753,13756],{},[737,13748,739],{},"：按 ",[19,13751,13564],{}," 拆解即可。装新库先扫一眼 ",[19,13754,13755],{},".d.ts"," 里的 Render 类型签名，能少走很多弯路。",[44,13758,13760],{"id":13759},"坑-3测试断言把dom-顺序当成了视觉顺序","坑 3：测试断言把「DOM 顺序」当成了「视觉顺序」",[15,13762,13763,13764,13767],{},"布局修好后，我在浏览器自测脚本里加了一条断言：取前两个 ",[19,13765,13766],{},"\u003Cimg>","，比较它们的左坐标，认为应该并排（不同列）。",[15,13769,13770,13771,13774,13775,778,13778,13781,13782,13785],{},"结果断言失败——但页面明明是 5 列并排。排查发现：",[737,13772,13773],{},"瀑布流的 DOM 是按\"列\"组织的","（第 1 列的全部图片在前，第 2 列的在后），所以 ",[19,13776,13777],{},"img[0]",[19,13779,13780],{},"img[1]"," 属于",[737,13783,13784],{},"同一列","，左坐标当然相同。",[15,13787,13788,13790],{},[737,13789,739],{},"：断言改为比较「第 1 列的首张图 vs 第 2 列的首张图」：",[49,13792,13796],{"className":13793,"code":13794,"language":13795,"meta":54,"style":54},"language-js shiki shiki-themes github-light github-dark","const a = tracks[0].querySelector(\"img\")\nconst b = tracks[1].querySelector(\"img\")\nreturn Math.abs(a.getBoundingClientRect().left - b.getBoundingClientRect().left) > 10\n","js",[19,13797,13798,13823,13846],{"__ignoreMap":54},[58,13799,13800,13802,13804,13806,13809,13811,13813,13816,13818,13821],{"class":60,"line":61},[58,13801,3310],{"class":71},[58,13803,4321],{"class":382},[58,13805,2728],{"class":71},[58,13807,13808],{"class":92}," tracks[",[58,13810,3171],{"class":382},[58,13812,3230],{"class":92},[58,13814,13815],{"class":75},"querySelector",[58,13817,273],{"class":92},[58,13819,13820],{"class":99},"\"img\"",[58,13822,177],{"class":92},[58,13824,13825,13827,13830,13832,13834,13836,13838,13840,13842,13844],{"class":60,"line":68},[58,13826,3310],{"class":71},[58,13828,13829],{"class":382}," b",[58,13831,2728],{"class":71},[58,13833,13808],{"class":92},[58,13835,1229],{"class":382},[58,13837,3230],{"class":92},[58,13839,13815],{"class":75},[58,13841,273],{"class":92},[58,13843,13820],{"class":99},[58,13845,177],{"class":92},[58,13847,13848,13850,13853,13856,13859,13862,13865,13868,13871,13873,13876,13878],{"class":60,"line":79},[58,13849,3134],{"class":71},[58,13851,13852],{"class":92}," Math.",[58,13854,13855],{"class":75},"abs",[58,13857,13858],{"class":92},"(a.",[58,13860,13861],{"class":75},"getBoundingClientRect",[58,13863,13864],{"class":92},"().left ",[58,13866,13867],{"class":71},"-",[58,13869,13870],{"class":92}," b.",[58,13872,13861],{"class":75},[58,13874,13875],{"class":92},"().left) ",[58,13877,1639],{"class":71},[58,13879,13880],{"class":382}," 10\n",[15,13882,13551,13883,13886],{},[737,13884,13885],{},"验证布局类库时，先搞清楚它生成的 DOM 结构，再写断言","；另外把「真实浏览器探针」当排查手段（直接打印各 track 的 x 坐标和子元素数量），比盯着代码猜快得多。",[44,13888,13890],{"id":13889},"坑-4等宽网格的洞引入前就存在的产品问题","坑 4：等宽网格的\"洞\"（引入前就存在的产品问题）",[15,13892,13893,13894,13897,13898,13901],{},"顺带记录一下为什么换掉原来的布局：CSS Grid ",[19,13895,13896],{},"repeat(auto-fill, minmax(220px, 1fr))"," 等宽铺满，图片 ",[19,13899,13900],{},"w-full h-auto","。当一行里混有不同比例时，行高取最高的那张，矮图底部留空，几行错落下来就显得乱；另外窗口宽度\"差一点放不下下一张\"时，右侧会空出近一整列。",[15,13903,13904],{},"换成 Masonry 后这两个问题都消失了——列内紧密堆叠、列数自适应。",[11,13906,13907],{"id":13907},"效果与小结",[15,13909,13910,13911,13913],{},"最终效果：21 张不同比例的生成图在 5 列瀑布流里紧密排列，原比例不裁剪，悬停出提示词浮层，点击进 YARL 大图预览；缩略图总大小仅 360KB，进页面即全量加载 + ",[19,13912,12404],{}," 长缓存，平板上快速滚动也跟手。",[15,13915,11082,13916],{},[737,13917,13918],{},"react-photo-album 是个小但专业的相册布局库，只要记住三件事——引样式表、喂宽高、render 函数是双参——就能在十分钟内把图片墙升级成真正的瀑布流。",[1066,13920,13921],{},"html pre.shiki code .sw2iP, html code.shiki .sw2iP{--shiki-light:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .scXbn, html code.shiki .scXbn{--shiki-light:#032F62;--shiki-dark:#9ECBFF}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .siTax, html code.shiki .siTax{--shiki-light:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sIX_F, html code.shiki .sIX_F{--shiki-light:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sHbNN, html code.shiki .sHbNN{--shiki-light:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sPK6S, html code.shiki .sPK6S{--shiki-light:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .suQ91, html code.shiki .suQ91{--shiki-light:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sbB4o, html code.shiki .sbB4o{--shiki-light:#22863A;--shiki-dark:#85E89D}",{"title":54,"searchDepth":68,"depth":79,"links":13923},[13924,13925,13926,13931,13938],{"id":12698,"depth":68,"text":12698},{"id":12804,"depth":68,"text":12804},{"id":12830,"depth":68,"text":12830,"children":13927},[13928,13929,13930],{"id":12833,"depth":79,"text":12834},{"id":12851,"depth":79,"text":12852},{"id":13049,"depth":79,"text":13050},{"id":13487,"depth":68,"text":13487,"children":13932},[13933,13934,13936,13937],{"id":13490,"depth":79,"text":13491},{"id":13557,"depth":79,"text":13935},"坑 2：render.photo 是 (props, context) 双参签名",{"id":13759,"depth":79,"text":13760},{"id":13889,"depth":79,"text":13890},{"id":13907,"depth":68,"text":13907},"记录在 Magic Img 生图图库中引入 react-photo-album 实现 Masonry 瀑布流的过程：技术选型背景、需求梳理、安装使用，以及「忘记引入样式表导致一张一列」等踩坑与排查方法。","\u002Fposts\u002Freact-photo-album-masonry\u002Fimg\u002Fcover.svg",{},"\u002Fposts\u002Freact-photo-album-masonry",{"title":12693,"description":13939},"前端工程实践","posts\u002Freact-photo-album-masonry\u002Findex",[9973,12781,13947,13948,13949],"瀑布流","前端","Tailwind","-O-kAQLEyRzwVhOpuo-yBxZim9LtvhChPkpP92ipyjM",1786887349178]