├── .github └── workflows │ └── go.yml ├── .gitignore ├── LICENSE ├── README.md ├── assets ├── 1.gif ├── 2.png └── 3.png ├── go.mod ├── go.sum ├── internal ├── api │ ├── api.go │ ├── topics.go │ ├── v2_detail.go │ ├── v2_replies.go │ └── v2_token.go ├── config │ ├── file.go │ ├── node.go │ ├── screen_size.go │ └── session.go ├── consts │ ├── keymap.go │ ├── showmode.go │ └── version.go ├── pkg │ ├── html.go │ └── strings.go ├── types │ ├── error.go │ ├── pagination.go │ ├── v1_topic.go │ ├── v2_detail.go │ ├── v2_replies.go │ ├── v2_token.go │ └── v2_topic.go └── ui │ ├── components │ ├── boss │ │ └── boss.go │ ├── detail │ │ └── detail.go │ ├── footer │ │ └── footer.go │ ├── help │ │ └── help.go │ ├── setting │ │ └── setting.go │ ├── splash │ │ └── splash.go │ └── topics │ │ └── topics.go │ ├── messages │ ├── detail.go │ ├── error.go │ ├── get_topics.go │ ├── init.go │ ├── loading.go │ ├── post.go │ ├── redirect.go │ ├── replies.go │ └── tips.go │ ├── routes │ └── routes.go │ ├── styles │ ├── bold.go │ ├── error.go │ └── hint.go │ └── ui.go └── main.go /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: build-go-binary 2 | 3 | on: 4 | release: 5 | types: [created] # 表示在创建新的 Release 时触发 6 | 7 | jobs: 8 | build-go-binary: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | goos: [linux, windows, darwin] 13 | goarch: [amd64, arm64] 14 | exclude: 15 | - goarch: arm64 16 | goos: windows 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: wangyoucao577/go-release-action@v1 20 | with: 21 | github_token: ${{ secrets.GITHUB_TOKEN }} 22 | goos: ${{ matrix.goos }} 23 | goarch: ${{ matrix.goarch }} 24 | goversion: 1.24 25 | md5sum: false 26 | overwrite: true 27 | asset_name: go-v2ex-${{ matrix.goos }}-${{ matrix.goarch }} 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, built with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | 17 | # Dependency directories (remove the comment below to include it) 18 | # vendor/ 19 | 20 | # Go workspace file 21 | go.work 22 | go.work.sum 23 | 24 | # env file 25 | config.json 26 | *.log 27 | .idea/ 28 | .vscode/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 seth-shi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## go-v2ex 2 | 一个基于**Go** 语言 开发的命令行版 **V2EX** 客户端,支持在终端内快速浏览主题、查看评论、切换节点及基础配置管理,为极客用户提供高效的 **V2EX** 访问体验(摸鱼必备)。 3 | 4 | 5 | ## 安装使用 6 | 7 | * 下载最新版本二进制文件 8 | 9 | | 系统 | CPU 架构 | 下载链接 | 10 | |---------|------------|-------------------------------------------------------------------------------------------------------------------------| 11 | | Mac | amd64 | [go-v2ex-darwin-amd64.tar.gz](https://github.com/seth-shi/go-v2ex/releases/latest/download/go-v2ex-darwin-amd64.tar.gz) | 12 | | Mac | arm64 | [go-v2ex-darwin-arm64.tar.gz](https://github.com/seth-shi/go-v2ex/releases/latest/download/go-v2ex-darwin-arm64.tar.gz) | 13 | | Linux | amd64 | [go-v2ex-linux-amd64.tar.gz](https://github.com/seth-shi/go-v2ex/releases/latest/download/go-v2ex-linux-amd64.tar.gz) | 14 | | Linux | arm64 | [go-v2ex-linux-arm64.tar.gz](https://github.com/seth-shi/go-v2ex/releases/latest/download/go-v2ex-linux-arm64.tar.gz) | 15 | | Windows | amd64 | [go-v2ex-windows-amd64.zip](https://github.com/seth-shi/go-v2ex/releases/latest/download/go-v2ex-windows-amd64.zip) | 16 | | Go | 直装(免配环境变量) | `go install github.com/seth-shi/go-v2ex@latest` | 17 | * 解压压缩包中的二进制文件放到环境变量目录 18 | * 运行 `go-v2ex` 命令即可启动程序。 19 | 20 | ## 功能特性 21 | - **多节点切换**:支持自定义节点列表 22 | - **主题浏览**:查看最新/热门主题,支持分页翻页 23 | - **详情查看**:查看主题完整内容及评论列表(支持加载更多评论) 24 | - **快捷操作**:通过快捷键快速切换页面、退出程序等 25 | - **配置管理**:支持设置 API 令牌(用于部分高级功能)和自定义节点列表 26 | 27 | ## 预览图 28 | ![列表页](assets/1.gif) 29 | ![列表页](assets/2.png) 30 | ![详情页](assets/3.png) 31 | 32 | 33 | 34 | ## 帮助 35 | ### 设置默认终端代理 36 | * 确认已开启**Clash**等代理软件, 37 | #### JetBrains 38 | * **设置** -> **工具** -> **终端** --> **项目设置:代环境变量**填入以下值 39 | * `http_proxy=http://127.0.0.1:7897;https_proxy=http://127.0.0.1:7897` 40 | * 随后打开一个新的终端窗口,开始享受~~~ 41 | #### vscode 42 | * 打开设置, 搜索`terminal.integrated.env` 43 | * 点击**在 settings.json 中编辑**, 增加以下配置 44 | ```json 45 | { 46 | "terminal.integrated.env.linux": { 47 | "HTTP_PROXY": "http://127.0.0.1:7897", 48 | "HTTPS_PROXY": "http://127.0.0.1:7897" 49 | }, 50 | "terminal.integrated.env.windows": { 51 | "HTTP_PROXY": "http://127.0.0.1:7897", 52 | "HTTPS_PROXY": "http://127.0.0.1:7897" 53 | } 54 | } 55 | ``` 56 | 57 | ## TODO 58 | - [ ] 终端显示图片 59 | 60 | ## 感谢 61 | * [https://github.com/charmbracelet/bubbletea](https://github.com/charmbracelet/bubbletea) 62 | * [https://github.com/charmbracelet/bubbles](https://github.com/charmbracelet/bubbles) 63 | -------------------------------------------------------------------------------- /assets/1.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seth-shi/go-v2ex/96424d69c292b8b009aca8a225076343d9ea8ffd/assets/1.gif -------------------------------------------------------------------------------- /assets/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seth-shi/go-v2ex/96424d69c292b8b009aca8a225076343d9ea8ffd/assets/2.png -------------------------------------------------------------------------------- /assets/3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seth-shi/go-v2ex/96424d69c292b8b009aca8a225076343d9ea8ffd/assets/3.png -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/seth-shi/go-v2ex 2 | 3 | go 1.24.0 4 | 5 | require ( 6 | github.com/JohannesKaufmann/html-to-markdown/v2 v2.3.3 7 | github.com/charmbracelet/bubbles v0.21.0 8 | github.com/charmbracelet/bubbletea v1.3.5 9 | github.com/charmbracelet/glamour v0.10.0 10 | github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 11 | github.com/dromara/carbon/v2 v2.6.7 12 | github.com/go-resty/resty/v2 v2.16.5 13 | github.com/mcuadros/go-defaults v1.2.0 14 | github.com/mitchellh/go-homedir v1.1.0 15 | github.com/muesli/reflow v0.3.0 16 | github.com/samber/lo v1.50.0 17 | github.com/sirupsen/logrus v1.9.3 18 | ) 19 | 20 | require ( 21 | github.com/JohannesKaufmann/dom v0.2.0 // indirect 22 | github.com/alecthomas/chroma/v2 v2.14.0 // indirect 23 | github.com/atotto/clipboard v0.1.4 // indirect 24 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 25 | github.com/aymerick/douceur v0.2.0 // indirect 26 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect 27 | github.com/charmbracelet/x/ansi v0.8.0 // indirect 28 | github.com/charmbracelet/x/cellbuf v0.0.13 // indirect 29 | github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect 30 | github.com/charmbracelet/x/term v0.2.1 // indirect 31 | github.com/dlclark/regexp2 v1.11.0 // indirect 32 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 33 | github.com/gorilla/css v1.0.1 // indirect 34 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 35 | github.com/mattn/go-isatty v0.0.20 // indirect 36 | github.com/mattn/go-localereader v0.0.1 // indirect 37 | github.com/mattn/go-runewidth v0.0.16 // indirect 38 | github.com/microcosm-cc/bluemonday v1.0.27 // indirect 39 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 40 | github.com/muesli/cancelreader v0.2.2 // indirect 41 | github.com/muesli/termenv v0.16.0 // indirect 42 | github.com/rivo/uniseg v0.4.7 // indirect 43 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 44 | github.com/yuin/goldmark v1.7.11 // indirect 45 | github.com/yuin/goldmark-emoji v1.0.5 // indirect 46 | golang.org/x/exp v0.0.0-20240213143201-ec583247a57a // indirect 47 | golang.org/x/net v0.40.0 // indirect 48 | golang.org/x/sync v0.14.0 // indirect 49 | golang.org/x/sys v0.33.0 // indirect 50 | golang.org/x/term v0.32.0 // indirect 51 | golang.org/x/text v0.25.0 // indirect 52 | ) 53 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/JohannesKaufmann/dom v0.2.0 h1:1bragmEb19K8lHAqgFgqCpiPCFEZMTXzOIEjuxkUfLQ= 2 | github.com/JohannesKaufmann/dom v0.2.0/go.mod h1:57iSUl5RKric4bUkgos4zu6Xt5LMHUnw3TF1l5CbGZo= 3 | github.com/JohannesKaufmann/html-to-markdown/v2 v2.3.3 h1:r3fokGFRDk/8pHmwLwJ8zsX4qiqfS1/1TZm2BH8ueY8= 4 | github.com/JohannesKaufmann/html-to-markdown/v2 v2.3.3/go.mod h1:HtsP+1Fchp4dVvaiIsLHAl/yqL3H1YLwqLC9kNwqQEg= 5 | github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE= 6 | github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= 7 | github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E= 8 | github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I= 9 | github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= 10 | github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= 11 | github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= 12 | github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 13 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 14 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 15 | github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= 16 | github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= 17 | github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= 18 | github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= 19 | github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= 20 | github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= 21 | github.com/charmbracelet/bubbletea v1.3.5 h1:JAMNLTbqMOhSwoELIr0qyP4VidFq72/6E9j7HHmRKQc= 22 | github.com/charmbracelet/bubbletea v1.3.5/go.mod h1:TkCnmH+aBd4LrXhXcqrKiYwRs7qyQx5rBgH5fVY3v54= 23 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= 24 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= 25 | github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY= 26 | github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk= 27 | github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= 28 | github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= 29 | github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= 30 | github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= 31 | github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= 32 | github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= 33 | github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= 34 | github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= 35 | github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= 36 | github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= 37 | github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= 38 | github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 39 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 40 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 41 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 42 | github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= 43 | github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 44 | github.com/dromara/carbon/v2 v2.6.7 h1:seSMHv6SbVKWXRF2WMCm2JQCIQMy39aeIXq7aR3g82A= 45 | github.com/dromara/carbon/v2 v2.6.7/go.mod h1:7GXqCUplwN1s1b4whGk2zX4+g4CMCoDIZzmjlyt0vLY= 46 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= 47 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 48 | github.com/go-resty/resty/v2 v2.16.5 h1:hBKqmWrr7uRc3euHVqmh1HTHcKn99Smr7o5spptdhTM= 49 | github.com/go-resty/resty/v2 v2.16.5/go.mod h1:hkJtXbA2iKHzJheXYvQ8snQES5ZLGKMwQ07xAwp/fiA= 50 | github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= 51 | github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= 52 | github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= 53 | github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= 54 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 55 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 56 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 57 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 58 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 59 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 60 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 61 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 62 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 63 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 64 | github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 65 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 66 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 67 | github.com/mcuadros/go-defaults v1.2.0 h1:FODb8WSf0uGaY8elWJAkoLL0Ri6AlZ1bFlenk56oZtc= 68 | github.com/mcuadros/go-defaults v1.2.0/go.mod h1:WEZtHEVIGYVDqkKSWBdWKUVdRyKlMfulPaGDWIVeCWY= 69 | github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= 70 | github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= 71 | github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= 72 | github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= 73 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= 74 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 75 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 76 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 77 | github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= 78 | github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= 79 | github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 80 | github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 81 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= 82 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= 83 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 84 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 85 | github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 86 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 87 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 88 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 89 | github.com/samber/lo v1.50.0 h1:XrG0xOeHs+4FQ8gJR97zDz5uOFMW7OwFWiFVzqopKgY= 90 | github.com/samber/lo v1.50.0/go.mod h1:RjZyNk6WSnUFRKK6EyOhsRJMqft3G+pg7dCWHQCWvsc= 91 | github.com/sebdah/goldie/v2 v2.5.5 h1:rx1mwF95RxZ3/83sdS4Yp7t2C5TCokvWP4TBRbAyEWY= 92 | github.com/sebdah/goldie/v2 v2.5.5/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI= 93 | github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= 94 | github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= 95 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 96 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 97 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 98 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 99 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 100 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 101 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 102 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 103 | github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= 104 | github.com/yuin/goldmark v1.7.11 h1:ZCxLyDMtz0nT2HFfsYG8WZ47Trip2+JyLysKcMYE5bo= 105 | github.com/yuin/goldmark v1.7.11/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= 106 | github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk= 107 | github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= 108 | golang.org/x/exp v0.0.0-20240213143201-ec583247a57a h1:HinSgX1tJRX3KsL//Gxynpw5CTOAIPhgL4W8PNiIpVE= 109 | golang.org/x/exp v0.0.0-20240213143201-ec583247a57a/go.mod h1:CxmFvTBINI24O/j8iY7H1xHzx2i4OsyguNBmN/uPtqc= 110 | golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= 111 | golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= 112 | golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= 113 | golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 114 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 115 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 116 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 117 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 118 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 119 | golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= 120 | golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= 121 | golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= 122 | golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= 123 | golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= 124 | golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 125 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 126 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= 127 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 128 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 129 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 130 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 131 | -------------------------------------------------------------------------------- /internal/api/api.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "io" 5 | "strconv" 6 | "sync/atomic" 7 | "time" 8 | 9 | "github.com/seth-shi/go-v2ex/internal/config" 10 | 11 | "github.com/go-resty/resty/v2" 12 | "github.com/sirupsen/logrus" 13 | ) 14 | 15 | var ( 16 | Client = newClient() 17 | LimitRemainCount = &atomic.Int64{} 18 | LimitTotalCount = &atomic.Int64{} 19 | ) 20 | 21 | type v2exClient struct { 22 | client *resty.Client 23 | } 24 | 25 | func newClient() *v2exClient { 26 | 27 | logger := logrus.New() 28 | logger.Out = io.Discard 29 | return &v2exClient{ 30 | client: resty. 31 | New(). 32 | SetBaseURL("https://www.v2ex.com"). 33 | SetTimeout(time.Second * 10). 34 | OnAfterResponse(func(c *resty.Client, r *resty.Response) error { 35 | 36 | limit, err := strconv.ParseInt(r.Header().Get("x-rate-limit-limit"), 10, 64) 37 | if err == nil { 38 | LimitTotalCount.Store(limit) 39 | } 40 | remain, err := strconv.ParseInt(r.Header().Get("x-rate-limit-remaining"), 10, 64) 41 | if err == nil { 42 | LimitRemainCount.Store(remain) 43 | } 44 | 45 | return nil 46 | }). 47 | SetLogger(logger), 48 | } 49 | } 50 | 51 | func (client *v2exClient) RefreshConfig() *v2exClient { 52 | 53 | client.client.SetTimeout(time.Second * time.Duration(config.G.Timeout)) 54 | client.client.SetAuthToken(config.G.Token) 55 | return client 56 | } 57 | -------------------------------------------------------------------------------- /internal/api/topics.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "sync" 8 | 9 | tea "github.com/charmbracelet/bubbletea" 10 | "github.com/go-resty/resty/v2" 11 | "github.com/samber/lo" 12 | "github.com/seth-shi/go-v2ex/internal/config" 13 | "github.com/seth-shi/go-v2ex/internal/types" 14 | "github.com/seth-shi/go-v2ex/internal/ui/messages" 15 | ) 16 | 17 | const ( 18 | latestNode = "latest" 19 | latestUri = "/api/topics/latest.json" 20 | hotNode = "hot" 21 | hotUri = "/api/topics/hot.json" 22 | v2TopicsUri = "/api/v2/nodes/%s/topics?p=%d" 23 | perPage = 10 24 | ) 25 | 26 | var ( 27 | // 保证每页都是 10 个 28 | v1CacheTopics = make(map[string][]types.TopicComResult) 29 | v1CacheLocker sync.Mutex 30 | // v2 只预缓存一页 31 | v2CacheTopicsRes types.V2TopicResponse 32 | v2CacheTopicsKey string 33 | v2CacheLocker sync.Mutex 34 | ) 35 | 36 | func (client *v2exClient) GetTopics(nodeIndex int, page int) tea.Cmd { 37 | 38 | return func() tea.Msg { 39 | 40 | if page <= 0 { 41 | page = 1 42 | } 43 | 44 | var ( 45 | nodeName = lo.NthOr(config.G.GetNodes(), nodeIndex, latestNode) 46 | res []types.TopicComResult 47 | err error 48 | pagination *types.Pagination 49 | ) 50 | 51 | // 请求的时候, 用数据的分页数据 52 | switch nodeName { 53 | case latestNode, hotNode: 54 | pagination, res, err = client.getV1Topics(nodeName, page) 55 | default: 56 | pagination, res, err = client.getV2Topics(nodeName, page) 57 | } 58 | 59 | if err != nil { 60 | return messages.GetTopicsResult{Error: err} 61 | } 62 | 63 | retPage := lo.FromPtr(pagination) 64 | retPage.CurrPage = page 65 | return messages.GetTopicsResult{ 66 | Topics: res, 67 | Pagination: retPage, 68 | } 69 | } 70 | } 71 | 72 | func (client *v2exClient) getV2Topics(nodeName string, page int) (*types.Pagination, []types.TopicComResult, error) { 73 | 74 | // 使用 V2 的接口 75 | var ( 76 | v2Res types.V2TopicResponse 77 | rr *resty.Response 78 | err error 79 | // 从缓存中获取 key 80 | cacheKey = fmt.Sprintf("%s_%d", nodeName, page) 81 | // 第一页返回: 0~0, 否则返回: 10~20 82 | retOffset = lo.If(page%2 == 1, 0).Else(perPage) 83 | ) 84 | 85 | // 先从缓存中获取下一页的数据, 如果是偶数页, 则取本页数据, 否则取下一页数据 86 | v2CacheLocker.Lock() 87 | if cacheKey == v2CacheTopicsKey { 88 | // 截断前 10 个放在本页, 后 10 个缓存到下一页 89 | v2Res = v2CacheTopicsRes 90 | } 91 | v2CacheLocker.Unlock() 92 | 93 | // 如果没有缓存, 去接口里请求数据 94 | if len(v2Res.Result) == 0 { 95 | // 去请求 API 获取数据, api 分页需要处理一下 96 | var ( 97 | apiRequestPage = (page + 1) / 2 98 | requestUri = fmt.Sprintf(v2TopicsUri, nodeName, apiRequestPage) 99 | ) 100 | rr, err = client. 101 | client. 102 | R(). 103 | SetContext(context.Background()). 104 | SetResult(&v2Res). 105 | SetError(&v2Res). 106 | Get(requestUri) 107 | if err != nil { 108 | return nil, nil, err 109 | } 110 | 111 | if !v2Res.Success { 112 | return nil, nil, fmt.Errorf("[%s]%s", rr.Status(), v2Res.Message) 113 | } 114 | 115 | // 预先缓存一页, 由于接口返回 20 个一页, 这边使用切换调整成 10 个一页 116 | v2Res.Pagination.ResetPages(perPage, v2Res.Pagination.Total) 117 | v2CacheLocker.Lock() 118 | defer v2CacheLocker.Unlock() 119 | v2CacheTopicsKey = requestUri 120 | v2CacheTopicsRes = v2Res 121 | } 122 | 123 | res := lo.Map( 124 | v2Res.Result, func(item types.V2TopicResult, index int) types.TopicComResult { 125 | return types.TopicComResult{ 126 | Id: item.Id, 127 | Node: nodeName, 128 | Title: item.Title, 129 | Member: item.LastReplyBy, 130 | LastTouched: item.LastTouched, 131 | Replies: item.Replies, 132 | } 133 | }, 134 | ) 135 | res = lo.Subset(res, retOffset, perPage) 136 | return lo.ToPtr(v2Res.Pagination), res, nil 137 | } 138 | 139 | func (client *v2exClient) getV1Topics(nodeName string, page int) (*types.Pagination, []types.TopicComResult, error) { 140 | 141 | var ( 142 | v1Error types.V1ApiError 143 | v1Res []types.V1TopicResult 144 | rr *resty.Response 145 | err error 146 | uri = lo.If(nodeName == hotNode, hotUri).Else(latestUri) 147 | ) 148 | 149 | // v1 接口没有分页, 所以我们伪造出来 150 | pagination := &types.Pagination{} 151 | 152 | // 大于第一页的, 只能从缓存中获取 153 | v1CacheLocker.Lock() 154 | res, exists := v1CacheTopics[uri] 155 | v1CacheLocker.Unlock() 156 | if exists { 157 | pagination.ResetPages(perPage, len(res)) 158 | res = lo.Subset(res, (page-1)*perPage, perPage) 159 | if len(res) > 0 { 160 | return pagination, res, nil 161 | } 162 | 163 | return nil, nil, errors.New("无更多数据") 164 | } 165 | 166 | rr, err = client. 167 | client. 168 | R(). 169 | SetContext(context.Background()). 170 | SetResult(&v1Res). 171 | SetError(&v1Error). 172 | Get(uri) 173 | if err != nil { 174 | return nil, nil, err 175 | } 176 | 177 | if !v1Error.Success() { 178 | return nil, nil, fmt.Errorf("[%s]%s", rr.Status(), v1Error.Message) 179 | } 180 | 181 | res = lo.Map( 182 | v1Res, func(item types.V1TopicResult, index int) types.TopicComResult { 183 | return types.TopicComResult{ 184 | Id: item.Id, 185 | Node: item.Node.Title, 186 | Title: item.Title, 187 | Member: item.Member.Username, 188 | LastTouched: item.LastTouched, 189 | Replies: item.Replies, 190 | } 191 | }, 192 | ) 193 | 194 | // 开始处理缓存的数据 195 | v1CacheLocker.Lock() 196 | defer v1CacheLocker.Unlock() 197 | v1CacheTopics[uri] = res 198 | pagination.ResetPages(perPage, len(res)) 199 | 200 | return pagination, lo.Subset(res, (page-1)*perPage, perPage), nil 201 | } 202 | -------------------------------------------------------------------------------- /internal/api/v2_detail.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | tea "github.com/charmbracelet/bubbletea" 8 | "github.com/seth-shi/go-v2ex/internal/types" 9 | "github.com/seth-shi/go-v2ex/internal/ui/messages" 10 | ) 11 | 12 | func (client *v2exClient) GetDetail(id int64) tea.Cmd { 13 | return func() tea.Msg { 14 | var res types.V2DetailResponse 15 | rr, err := client.client.R(). 16 | SetContext(context.Background()). 17 | SetResult(&res). 18 | SetError(&res). 19 | Get(fmt.Sprintf("/api/v2/topics/%d", id)) 20 | 21 | if err != nil { 22 | return err 23 | } 24 | 25 | if !res.Success { 26 | return fmt.Errorf("[%s]%s", rr.Status(), res.Message) 27 | } 28 | 29 | return messages.GetDetailResult{Detail: res.Result} 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /internal/api/v2_replies.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | tea "github.com/charmbracelet/bubbletea" 8 | "github.com/seth-shi/go-v2ex/internal/types" 9 | "github.com/seth-shi/go-v2ex/internal/ui/messages" 10 | ) 11 | 12 | func (client *v2exClient) GetReply(id int64, page int) tea.Cmd { 13 | return func() tea.Msg { 14 | var res types.V2ReplyResponse 15 | rr, err := client.client.R(). 16 | SetContext(context.Background()). 17 | SetResult(&res). 18 | SetError(&res). 19 | Get(fmt.Sprintf("/api/v2/topics/%d/replies?p=%d", id, page)) 20 | 21 | if err != nil { 22 | return messages.GetRepliesResult{Error: err} 23 | } 24 | 25 | if !res.Success { 26 | return messages.GetRepliesResult{Error: fmt.Errorf("[%s]%s", rr.Status(), res.Message)} 27 | } 28 | 29 | res.Pagination.CurrPage = page 30 | return messages.GetRepliesResult{Replies: res.Result, Pagination: res.Pagination} 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /internal/api/v2_token.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | tea "github.com/charmbracelet/bubbletea" 8 | "github.com/dromara/carbon/v2" 9 | "github.com/seth-shi/go-v2ex/internal/types" 10 | ) 11 | 12 | func (client *v2exClient) GetToken() tea.Msg { 13 | var res types.V2TokenResponse 14 | rr, err := client.client.R(). 15 | SetContext(context.Background()). 16 | SetResult(&res). 17 | SetError(&res). 18 | Get("/api/v2/token") 19 | 20 | if err != nil { 21 | return err 22 | } 23 | 24 | if !res.Success { 25 | return fmt.Errorf("[%s]%s", rr.Status(), res.Message) 26 | } 27 | 28 | // 准备过期的话, 发送提醒 29 | expireAt := carbon.CreateFromTimestamp(res.Result.Created + res.Result.Expiration) 30 | if !carbon.Now().AddDays(14).Gte(expireAt) { 31 | return nil 32 | } 33 | 34 | return fmt.Errorf("token 将在%s过期,请注意更换", expireAt.String()) 35 | } 36 | -------------------------------------------------------------------------------- /internal/config/file.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "os" 7 | "path" 8 | "strings" 9 | 10 | tea "github.com/charmbracelet/bubbletea" 11 | "github.com/mcuadros/go-defaults" 12 | "github.com/mitchellh/go-homedir" 13 | "github.com/seth-shi/go-v2ex/internal/consts" 14 | "github.com/seth-shi/go-v2ex/internal/ui/messages" 15 | ) 16 | 17 | var ( 18 | G = newFileConfig() 19 | ) 20 | 21 | type fileConfig struct { 22 | // NOTE: 增加默认秘钥, 方便用户快速使用, 用户以后还是要自己配置 23 | Token string `json:"personal_access_token" default:"35bbd155-df12-4778-9916-5dd59d967fef"` 24 | Nodes string `json:"nodes" default:"latest,hot,qna,all4all,programmer,jobs,share,apple,create,macos,career,pointless"` 25 | Timeout uint `json:"timeout" default:"5"` 26 | ActiveTab int `json:"active_tab"` 27 | ShowMode int `json:"show_mode" default:"4"` 28 | } 29 | 30 | func newFileConfig() fileConfig { 31 | var cfg fileConfig 32 | defaults.SetDefaults(&cfg) 33 | return cfg 34 | } 35 | 36 | func (c *fileConfig) SwitchShowMode() { 37 | c.ShowMode++ 38 | if c.ShowMode > consts.ShowModeAll { 39 | c.ShowMode = consts.ShowModeHidden 40 | } 41 | } 42 | func (c *fileConfig) GetShowModeText() string { 43 | var ( 44 | m = map[int]string{ 45 | consts.ShowModeHidden: "隐藏所有底部", 46 | consts.ShowModeLeftAndRight: "不显示帮助", 47 | consts.ShowModeLeftAndRightWithLimit: "显示左侧和右侧+请求限制量", 48 | consts.ShowModeAll: "显示所有", 49 | } 50 | ) 51 | 52 | return m[c.ShowMode] 53 | } 54 | 55 | func (c *fileConfig) ShowFooter() bool { 56 | return c.ShowMode != consts.ShowModeHidden 57 | } 58 | 59 | func (c *fileConfig) ShowHelp() bool { 60 | return c.ShowMode == consts.ShowModeAll 61 | } 62 | 63 | func (c *fileConfig) ShowPage() bool { 64 | return c.ShowMode == consts.ShowModeLeftAndRight || 65 | c.ShowMode == consts.ShowModeLeftAndRightWithLimit || 66 | c.ShowMode == consts.ShowModeAll 67 | } 68 | 69 | func (c *fileConfig) ShowLimit() bool { 70 | return c.ShowMode == consts.ShowModeLeftAndRightWithLimit || 71 | c.ShowMode == consts.ShowModeAll 72 | } 73 | 74 | func (c *fileConfig) GetNodes() []string { 75 | return strings.Split(c.Nodes, ",") 76 | } 77 | 78 | func LoadFileConfig() tea.Msg { 79 | 80 | bf, err := os.ReadFile(SavePath()) 81 | if err != nil { 82 | if errors.Is(err, os.ErrNotExist) { 83 | return messages.LoadConfigResult{Error: nil} 84 | } 85 | } 86 | 87 | return messages.LoadConfigResult{Error: json.Unmarshal(bf, &G)} 88 | } 89 | 90 | func SaveToFile(title string) tea.Cmd { 91 | return func() tea.Msg { 92 | bytesData, err := json.Marshal(G) 93 | if err != nil { 94 | return err 95 | } 96 | 97 | if err = os.WriteFile(SavePath(), bytesData, 0644); err != nil { 98 | return err 99 | } 100 | 101 | if title == "" { 102 | return nil 103 | } 104 | 105 | return messages.ShowAutoTipsRequest{Text: title} 106 | } 107 | } 108 | 109 | func SavePath() string { 110 | home, err := homedir.Dir() 111 | if err != nil { 112 | home = "." 113 | } 114 | 115 | return path.Join(home, ".go-v2ex.json") 116 | } 117 | -------------------------------------------------------------------------------- /internal/config/node.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | var ( 4 | NodeMap = map[string]string{"babel": "Project Babel", "v2ex": "V2EX", "olivida": "OLIVIDA", "music": "音乐", "movie": "电影", "earth": "地球", "bfbc2": "BFBC2", "iphone": "iPhone", "ipad": "iPad", "mbp": "MacBook Pro", "linux": "Linux", "qna": "问与答", "idev": "iDev", "gae": "Google App Engine", "twitter": "Twitter", "share": "分享发现", "create": "分享创造", "shanghai": "上海", "beijing": "北京", "guangzhou": "广州", "shenzhen": "深圳", "macos": "macOS", "kk": "Kevin Kelly", "math": "数学", "lijiang": "丽江", "hangzhou": "杭州", "photograph": "摄影", "picky": "Project Picky", "guilin": "桂林", "chengdu": "成都", "chongqing": "重庆", "nds": "NDS", "ps3": "PlayStation 3", "xbox360": "Xbox 360", "psp": "PSP", "wii": "Wii", "kindle": "Kindle", "tokyo": "东京", "android": "Android", "imac": "iMac", "kunming": "昆明", "guiyang": "贵阳", "jobs": "酷工作", "tianjin": "天津", "re5": "Resident Evil 5", "icode": "iCode", "redis": "Redis", "tv": "剧集", "steam": "Steam", "nyc": "New York", "nosql": "NoSQL", "hardware": "硬件", "autistic": "自言自语", "adobe": "Adobe", "games": "游戏", "internet": "互联网", "c": "C", "photoshop": "Photoshop", "random": "随想", "html": "HTML", "mysql": "MySQL", "php": "PHP", "java": "Java", "youtube": "YouTube", "google": "Google", "3g": "3G", "taste": "美酒与美食", "iad": "iAd", "all4all": "二手交易", "ilife": "iLife", "iwork": "iWork", "vpn": "VPN", "typography": "字体排印", "macpro": "Mac Pro", "uniqlo": "UNIQLO", "levis": "Levi's", "gstar": "G-Star", "converse": "Converse", "server": "服务器", "wuhan": "武汉", "ror": "Ruby on Rails", "nginx": "NGINX", "macmini": "Mac mini", "macbook": "MacBook", "moh": "Medal of Honor", "vimeo": "Vimeo", "wordpress": "WordPress", "muji": "无印良品", "4sq": "foursquare", "python": "Python", "firefox": "Firefox", "chrome": "Chrome", "safari": "Safari", "acg": "ACG", "plant": "植物", "feedback": "反馈", "opera": "Opera", "sc2": "StarCraft 2", "in": "分享邀请码", "openstack": "OpenStack", "animal": "动物", "git": "git", "blogger": "Blogger", "cloud": "云计算", "bzr": "bzr", "baby": "Baby", "fml": "糗事分享", "bb": "宽带症候群", "search": "搜索引擎技术研究", "oauth": "OAuth", "reading": "阅读", "x": "翻译", "hongkong": "香港", "macau": "澳门", "bayarea": "湾区", "wikipedia": "Wikipedia", "berlin": "Berlin", "writing": "文学", "gomoku": "五子棋", "digg": "Digg", "jiong": "囧", "starter": "创造者", "perl": "Perl", "scala": "Scala", "eco": "经济", "edu": "教育", "io": "io", "apache": "Apache", "couchdb": "CouchDB", "boardgame": "桌游", "imarketing": "iMarketing", "arcade": "街机游戏", "show": "晒晒更健康", "zh": "中文", "http": "HTTP", "dos": "DOS", "re": "正则表达式", "taipei": "台北", "css": "CSS", "newbalance": "New Balance", "nike": "Nike", "adidas": "Adidas", "otaku": "宅", "dell": "Dell", "livid": "Livid", "js": "JavaScript", "lua": "Lua", "pointless": "无要点", "treehole": "树洞", "tengzhou": "滕州", "xiamen": "厦门", "hohhot": "呼和浩特", "blackberry": "BlackBerry", "nanjing": "南京", "xian": "西安", "daqing": "大庆", "eggpain": "强迫症", "80days": "80 天环游地球", "ss": "沉默的螺旋", "diesel": "Diesel", "pixelart": "像素艺术", "hadoop": "Hadoop", "mapreduce": "MapReduce", "sanya": "三亚", "haikou": "海口", "zhuhai": "珠海", "cocos2d": "cocos2d", "ipod": "iPod", "appletv": "Apple TV", "matrix": "Matrix", "invest": "投资", "igame": "iGame", "accessory": "配件", "erlang": "Erlang", "dalian": "大连", "car": "汽车", "copter": "直升机", "linkinpark": "Linkin Park", "gyyz": "贵阳一中", "ynsdfz": "云南师大附中", "travel": "旅行", "opensolaris": "OpenSolaris", "moinmoin": "MoinMoin", "apple": "Apple", "bluray": "Blu-ray", "assembly": "汇编", "dream": "梦", "inception": "Inception", "ssh": "SSH", "outsourcing": "外包", "bmw": "BMW", "camino": "Camino", "mobileme": "MobileMe", "pinkfloyd": "Pink Floyd", "u2": "U2", "nirvana": "Nirvana", "rammstein": "Rammstein", "flash": "Flash", "dns": "DNS", "london": "London", "macgaming": "Mac 游戏", "mileage": "行程控", "wuxi": "无锡", "xen": "Xen", "creditcard": "信用卡", "zhengzhou": "郑州", "riak": "Riak", "linode": "Linode", "fuzhou": "福州", "jinan": "济南", "lanzhou": "兰州", "qingdao": "青岛", "suzhou": "苏州", "yangzhou": "扬州", "design": "设计", "kyoto": "京都", "corsair": "海盗船", "podcast": "Podcast", "lacrimosa": "Lacrimosa", "ideology": "Ideology", "tc": "TechCrunch", "quora": "Quora", "rework": "REWORK", "inbox": "Inbox", "audi": "Audi", "autocad": "AutoCAD", "diablo3": "Diablo III", "ja": "日本語", "en": "English", "fengshui": "风水", "iconfactory": "Iconfactory", "nokia": "Nokia", "hishim": "他他", "herher": "她她", "24": "24 小时", "green": "绿色低碳", "seattle": "Seattle", "gq": "GQ", "sydney": "Sydney", "changsha": "长沙", "lifestyle": "生活方式", "paris": "Paris", "dubai": "Dubai", "singapore": "Singapore", "mozilla": "Mozilla", "cod": "Call of Duty", "stop": "STOP", "whu": "武汉大学", "vim": "Vim", "lamy": "Lamy", "moleskine": "Moleskine", "pku": "北京大学", "tsinghua": "清华大学", "emacs": "Emacs", "sjtu": "上海交通大学", "sysu": "中山大学", "draw": "画画", "fcp": "Final Cut Pro", "motion": "Motion", "security": "信息安全", "harukimurakami": "村上春树", "thebeatles": "The Beatles", "entropy": "熵", "aden": "亚丁湾", "lotr": "指环王", "2012": "2012", "hsbc": "汇丰银行", "toyota": "丰田", "webos": "webOS", "standardchartered": "渣打银行", "citi": "花旗银行", "abnamro": "荷兰银行", "deutschebank": "德意志银行", "ubs": "瑞士银行", "lisp": "Lisp", "wired": "WIRED", "energy": "能源", "condenast": "Condé Nast", "samsonite": "Samsonite", "dior": "Dior", "omega": "OMEGA", "nfs": "Need for Speed", "ef": "EF", "textie": "Textie", "exchange": "物物交换", "afterdark": "天黑以后", "dropbox": "Dropbox", "bicycle": "自行车", "ikea": "宜家", "alienware": "Alienware", "facebook": "Facebook", "sqlite": "SQLite", "tuan": "团购", "sony": "SONY", "oslo": "Oslo", "stockholm": "Stockholm", "portland": "Portland", "gamedevstory": "Game Dev Story", "aws": "Amazon Web Services", "programmer": "程序员", "designer": "设计师", "transformers": "变形金刚", "cartier": "Cartier", "davidoff": "Davidoff", "gap": "Gap", "diary": "日记", "api": "API", "love": "非诚勿扰", "angel": "天使投资", "business": "商业模式", "mongodb": "MongoDB", "newbie": "新手求助", "ted": "TED", "flamewar": "水深火热", "standard": "标准", "fanfou": "饭否 API", "volkswagen": "Volkswagen", "scifi": "科幻", "lohas": "乐活", "wtf": "不靠谱茶话会", "528491": "528491", "lbp": "小小大星球", "3ds": "3DS", "bf3": "Battlefield 3", "curl": "cURL", "mba": "MacBook Air", "origin": "Origin", "asana": "Asana", "asdf": "asdf", "1q84": "1Q84", "adsense": "AdSense", "adwords": "AdWords", "ff": "最终幻想", "cuttherope": "Cut the Rope", "hm": "H&M", "killzone": "Killzone", "webmaster": "站长", "blackmagic": "黑魔法", "yippeearts": "Yippee Arts", "minecraft": "Minecraft", "muse": "MUSE", "leica": "莱卡", "canon": "佳能", "nikon": "尼康", "bento": "Bento", "instapaper": "Instapaper", "dribbble": "Dribbble", "ec": "EC", "dotnet": ".NET", "ontology": "本体论", "zakka": "雑貨", "loreal": "L'Oréal", "alexa": "Alexa", "garageband": "GarageBand", "3dsmax": "3ds Max", "lancome": "LANCÔME", "jp": "日本", "findpeople": "寻人", "admob": "AdMob", "windows": "Windows", "tornado": "Tornado", "path": "Path", "gw2": "Guild Wars 2", "chamber": "Chamber", "postgresql": "PostgreSQL", "guide": "使用指南", "ruby": "Ruby", "beforesunrise": "早睡早起身体好俱乐部", "portal": "Portal series", "logitech": "罗技", "amazon": "Amazon", "minio": "MinIO", "blog": "Blog", "irobot": "iRobot", "go": "Go 编程语言", "starbucks": "Starbucks", "pal": "仙剑奇侠传", "wow": "World of Warcraft", "cc": "Creative Commons", "flood": "水", "xinyu": "新余", "yueyang": "岳阳", "dali": "大理", "changchun": "长春", "zunyi": "遵义", "xining": "西宁", "harbin": "哈尔滨", "nanchang": "南昌", "baoding": "保定", "crysis": "Crysis", "wp": "Windows Phone", "arch": "Arch", "tongren": "铜仁", "gtd": "Getting Things Done", "opensource": "开源软件", "gcc": "GCC", "llvm": "LLVM", "amd": "AMD", "intel": "Intel", "nvidia": "NVIDIA", "maya": "Maya", "svelte": "Svelte", "bitcoin": "Bitcoin", "porsche": "Porsche", "stormwind": "Project Stormwind", "reddit": "Reddit", "oracle": "Oracle", "bing": "Bing", "pixelmator": "Pixelmator", "jekyll": "Jekyll", "yc": "Y Combinator", "arc": "Arc", "programming": "编程", "os": "操作系统", "vcs": "版本控制系统", "frameworks": "编程框架", "software": "软件", "computers": "计算机", "consoles": "游戏主机", "basic": "BASIC", "db": "数据库", "browsers": "浏览器", "mercurial": "Mercurial", "sqlserver": "SQL Server", "jquery": "jQuery", "wwdc": "WWDC", "psvita": "PlayStation Vita", "ea": "EA", "nintendo": "Nintendo", "icloud": "iCloud", "django": "Django", "zh2": "中二病", "1990": "1990", "celery": "Celery", "instagram": "Instagram", "nodejs": "Node.js", "dotcloud": "DotCloud", "wave": "Google Wave Protocol", "ifttt": "IFTTT", "media": "媒体", "lumix": "LUMIX", "gopro": "GoPro", "itunes": "iTunes", "appstore": "App Store", "tech": "科技", "rage": "RAGE", "chicago": "Chicago", "opengl": "OpenGL", "directx": "DirectX", "dust514": "DUST 514", "gnome": "Gnome", "dota": "DotA", "cdn": "CDN", "lol": "英雄联盟", "scrum": "Scrum", "centos": "CentOS", "gamedev": "游戏开发", "udk": "Unreal Development Kit", "vmware": "VMware", "glassfish": "GlassFish", "jinja": "Jinja", "monocle": "Monocle", "unity": "UNITY", "blender": "Blender", "3d": "3D", "zeppelin": "Project Zeppelin", "flask": "Flask", "iama": "I Am A", "razer": "Razer", "netbeans": "NetBeans", "running": "跑步", "clojure": "Clojure", "coldfusion": "ColdFusion", "whatsapp": "WhatsApp", "backbone": "Backbone.js", "textmate": "TextMate", "editors": "编辑器", "jlu": "吉林大学", "500px": "500px", "pinterest": "Pinterest", "sae": "Sina App Engine", "svn": "Subversion", "global": "海外留学", "air": "空气", "lego": "LEGO", "asteroid": "Project Asteroid", "inc": "公司运营", "alipay": "支付宝", "xcode": "Xcode", "skyrim": "上古卷轴 V", "mgs": "Metal Gear Solid", "homebrew": "Homebrew", "rabbitmq": "RabbitMQ", "gossip": "业界八卦", "gta": "Grand Theft Auto", "starwars": "星球大战", "indesign": "InDesign", "illustrator": "Illustrator", "moe": "分享萌物", "fling": "Fling", "pogo": "Pogo", "freebsd": "FreeBSD", "video": "视频技术", "trello": "Trello", "lighttpd": "Lighttpd", "arduino": "Arduino", "cv": "求职", "gt": "Gran Turismo", "pet": "宠物", "charles": "Charles", "eve": "EVE", "wacom": "Wacom", "markdown": "Markdown", "zope": "Zope", "ibook": "iBook", "weibo": "微博", "galaxy": "Project Galaxy", "museum": "Project Museum", "ideas": "奇思妙想", "ustc": "中国科学技术大学", "closed": "关闭交易", "bike": "骑行", "0x10c": "0x10c", "mushroom": "蘑菇", "paper": "Paper", "homme": "Homme", "dn": "域名", "ruofan": "若饭", "techstars": "TechStars", "meteor": "Meteor", "haskell": "Haskell", "sqlalchemy": "SQLAlchemy", "storm": "Storm", "soccer": "绿茵场", "sublime": "Sublime Text", "ubuntu": "Ubuntu", "fedora": "Fedora", "gentoo": "Gentoo", "la": "Los Angeles", "evernote": "Evernote", "tangcha": "唐茶", "sandbox": "沙盒", "downvoted": "Down Voted", "reprocess": "信息处理中心", "guitar": "吉他", "time": "时间", "visa": "签证", "dev": "Dev", "lightroom": "Lightroom", "aperture": "Aperture", "free": "免费赠送", "stripe": "Stripe", "coffee": "咖啡", "gwan": "G-WAN", "squid": "Squid", "velocity": "Velocity", "life": "生活", "europe": "欧洲", "uk": "英国", "us": "美国", "se": "瑞典", "de": "德国", "openbsd": "OpenBSD", "haproxy": "HAProxy", "sd": "Stable Diffusion", "c3edge": "C3Edge", "netlify": "Netlify", "ssl": "SSL", "rrdtool": "RRDtool", "xenserver": "XenServer", "puppet": "Puppet", "doitim": "Doit.im", "cloudstack": "CloudStack", "simcity": "SimCity", "varnish": "Varnish", "rq": "RQ", "kde": "KDE", "hawken": "Hawken", "survey": "调查", "ios": "iOS", "azure": "Microsoft Azure", "iis": "IIS", "newrelic": "New Relic", "startupvisa": "Startup Visa", "make": "make", "oreilly": "O'Reilly", "delphi": "Delphi", "ningbo": "宁波", "4g": "4G", "memsql": "MemSQL", "openvz": "OpenVZ", "nba": "NBA", "edgecast": "EdgeCast", "fitbit": "Fitbit", "couchbase": "Couchbase", "cassandra": "Cassandra", "hbase": "HBase", "lucene": "Lucene", "erp": "ERP", "cisco": "Cisco", "solr": "Solr", "sv": "硅谷", "openshift": "OpenShift", "xindanwei": "新单位", "orca": "ORCA", "meizu": "魅族", "naq": "不是问题的问题", "computervision": "Computer vision", "pasadena": "Pasadena", "olympics": "奥运会", "stackoverflow": "Stack Overflow", "nexus": "Nexus", "monthly": "每个月都会出现的那种主题", "appnet": "App.net", "elasticsearch": "Elasticsearch", "santamonica": "Santa Monica", "passenger": "Phusion Passenger", "anno": "Anno", "chocolat": "Chocolat", "sanfrancisco": "San Francisco", "bigdata": "Big Data", "devops": "DevOps", "virtualbox": "VirtualBox", "cherokee": "Cherokee", "openra": "OpenRA", "chef": "Chef", "wesnoth": "Battle for Wesnoth", "irc": "IRC", "munin": "Munin", "solaris": "Solaris", "heroku": "Heroku", "ingress": "Ingress", "lasvegas": "Las Vegas", "netbsd": "NetBSD", "opennebula": "OpenNebula", "juniper": "Juniper", "ceph": "Ceph", "swift": "Swift", "splunk": "Splunk", "stunnel": "stunnel", "california": "California", "smartos": "SmartOS", "zfs": "ZFS", "atlassian": "Atlassian", "sdn": "SDN", "unix": "Unix", "coc": "Clash of Clans", "shadowsocks": "shadowsocks", "wechat": "微信", "pi": "Raspberry Pi", "square": "Square", "opensuse": "openSUSE", "vps": "VPS", "lxc": "LXC", "confluence": "Confluence", "wiki": "Wiki", "plone": "Plone", "sphinx": "Sphinx", "rst": "reStructuredText", "hubot": "HUBOT", "vagrant": "Vagrant", "ansible": "Ansible", "ats": "Apache Traffic Server", "ca": "加拿大", "cn": "中国", "mx": "墨西哥", "kr": "韩国", "asia": "亚洲", "discourse": "Discourse", "fluentd": "Fluentd", "debian": "Debian", "alfred": "Alfred", "re6": "Resident Evil 6", "cloudera": "Cloudera", "ps4": "PlayStation 4", "immt": "我叫 MT", "euca": "Eucalyptus", "ml": "机器学习", "tw": "台湾", "paypal": "PayPal", "openresty": "OpenResty", "stash": "Stash", "xehost": "XeHost", "voltdb": "VoltDB", "ovh": "OVH", "bf4": "Battlefield 4", "smartisanos": "Smartisan OS", "ssd": "SSD", "aerofs": "AeroFS", "miui": "MIUI", "feo": "前端优化", "sailfish": "Sailfish", "mechanical": "机械键盘", "line": "LINE", "spark": "Spark", "glass": "Google Glass", "hardcore": "发烧友", "emc": "EMC", "microsoft": "微软", "fit": "健康", "webrtc": "WebRTC", "braun": "Braun", "soho": "SOHO", "4k": "4K", "offworld": "重口味问与答", "german": "德语", "duolingo": "Duolingo", "core": "Core", "lvm": "LVM", "jira": "Jira", "bash": "Bash", "oculusvr": "Oculus VR", "btsync": "BTSync", "itransfer": "iTransfer", "zed": "Zed", "docker": "Docker", "hyperloop": "Hyperloop", "bootstrap": "Bootstrap", "zookeeper": "ZooKeeper", "ie": "IE", "pdns": "PowerDNS", "localllm": "Local LLM", "salt": "Salt Stack", "sap": "SAP", "documentary": "纪录片", "taobao": "淘宝", "dogma": "Dogma", "diamondbar": "Diamond Bar", "rowlandheights": "Rowland Heights", "walnut": "Walnut", "borderlands": "Borderlands", "ghost": "Ghost", "laiwang": "来往", "nissan": "Nissan", "chevrolet": "Chevrolet", "oversea": "海外运营", "hearthstone": "Hearthstone", "hos": "Heroes of the Storm", "projects": "项目管理", "bns": "剑灵", "fdb": "FoundationDB", "dart": "Dart", "xboxone": "Xbox One", "hermanmiller": "Herman Miller", "ripple": "Ripple", "boinc": "BOINC", "deals": "优惠信息", "wiiu": "Wii U", "mint": "Linux Mint", "forex": "外汇交易", "warcraft": "魔兽争霸", "modo": "MODO", "lynda": "Lynda", "mudbox": "Mudbox", "libido": "情感问题", "titanfall": "Titanfall", "percona": "Percona", "kvm": "KVM", "sentry": "Sentry", "playrust": "生存游戏 Rust", "serf": "Serf", "boston": "Boston", "coreos": "CoreOS", "sports": "体育运动", "pwa": "PWA", "opencl": "OpenCL", "webp": "WebP", "wagas": "Wagas", "mbti": "MBTI", "career": "职场话题", "ibeacon": "iBeacon", "github": "GitHub", "atom": "Atom", "bose": "Bose", "sputnik": "Sputnik", "powershell": "PowerShell", "seo": "搜索引擎优化", "ace": "Ace", "solarcity": "SolarCity", "spacex": "SpaceX", "hack": "Hack", "avocado": "Avocado", "sketch": "Sketch", "rfc": "RFC", "medium": "Medium", "samsung": "Samsung", "tarsnap": "Tarsnap", "shenyang": "沈阳", "ssdb": "SSDB", "rust": "Rust", "fe": "前端开发", "servo": "Servo", "phabricator": "Phabricator", "bong": "bong", "leancloud": "LeanCloud", "thinkpad": "ThinkPad", "solar": "太阳能", "log": "日志处理", "ielts": "雅思", "whv": "工作假期签证", "syslog": "Syslog", "uber": "Uber", "twitch": "Twitch", "mmm": "买买买", "notes": "Notes", "openwrt": "OpenWrt", "ford": "福特", "mb": "奔驰", "bfh": "Battlefield Hardline", "simracing": "模拟驾驶", "oneapm": "OneAPM", "drafts": "草稿箱", "depression": "抑郁症", "angular": "Angular", "tesla": "Tesla", "destiny": "Destiny", "waze": "Waze", "factorio": "Factorio", "watch": " WATCH", "corvette": "Corvette", "gotye": "亲加通讯云", "fir": "fir.im", "coding": "Coding", "router": "路由器", "gdg": "GDG", "pingpp": "Ping++", "goban": "围棋", "cheap": "穷", "pm": "产品经理茶话会", "2015": "2015", "webgl": "WebGL", "gitcafe": "GitCafe", "learn": "学点什么", "xiaomi": "小米", "dji": "DJI", "telegram": "Telegram", "react": "React", "dotgeek": "DotGeek", "fitness": "健身", "wunderlist": "奇妙清单", "spg": "SPG", "pay": " Pay", "shokunin": "职人", "ionic": "Ionic", "outdoor": "户外运动", "rethinkdb": "RethinkDB", "mobiledev": "移动开发", "barcelona": "Barcelona", "drones": "无人机", "status": "V2EX 站点状态", "jiankongbao": "监控宝", "polymer": "Polymer", "mesos": "Mesos", "udacity": "Udacity", "changes": "V2EX 站点更新", "airmech": "AirMech Arena", "besiege": "Besiege", "u": "大学", "pronunciation": "发音", "imperfect": "这个世界不完美", "dashcam": "行车记录仪", "surface": "Surface", "qt": "Qt", "promotions": "推广", "however": "然而并没有", "synology": "Synology", "vscode": "Visual Studio Code", "cement": "Cement", "smartisan": "锤子手机", "earphone": "耳机", "smokeping": "SmokePing", "elixir": "Elixir 编程语言", "hubspot": "HubSpot", "tvos": "tvOS", "catchpoint": "Catchpoint", "rpgmaker": "RPG Maker", "bgp": "BGP", "excel": "Excel", "soylent": "Soylent", "touhou": "东方 Project", "vue": "Vue.js", "surge": "Surge", "nlp": "自然语言处理", "koukaku": "Ghost in the Shell", "2016": "2016", "tmux": "tmux", "iceland": "冰岛", "processing": "Processing", "ivalice": "Ivalice", "iray": "iray", "edge": "Edge", "cg": "CG", "mentalray": "mental ray", "stingray": "Stingray", "launchbar": "LaunchBar", "cuda": "CUDA", "wilddog": "野狗实时通讯云", "khan": "可汗学院", "otto": "Otto", "civ": "文明系列", "motorsport": "汽车运动", "monetdb": "MonetDB", "xcom": "XCOM", "caddy": "Caddy", "firebase": "Firebase", "vr": "虚拟现实", "1984": "1984", "hls": "HLS", "wubi": "五笔字型输入法", "ffmpeg": "FFmpeg", "pgyer": "蒲公英", "jetbrains": "JetBrains", "overwatch": "Overwatch", "cardboard": "Cardboard", "obs": "OBS", "c9": "Cloud9", "bf1": "Battlefield 1", "hyper": "Hyper_", "hexo": "Hexo", "codemirror": "CodeMirror", "rog": "玩家国度", "asus": "华硕", "cmb": "招商银行", "keybase": "Keybase", "ime": "输入法", "pokemon": "精灵宝可梦", "meet": "创业组队", "gis": "地理信息系统", "metal": "Metal", "france": "法国", "remodel": "装修", "vive": "Vive", "modsecurity": "ModSecurity", "monero": "Monero", "discord": "Discord", "influxdb": "InfluxDB", "zhihu": "知乎", "gts": "全球工单系统", "iot": "物联网", "gcloud": "Google Cloud", "rime": "中州韻", "wargaming": "Wargaming", "gitlab": "GitLab", "gitbook": "GitBook", "pomodoro": "番茄工作法", "mermaid": "Mermaid", "stats": "统计学", "pixel": "Pixel", "nas": "NAS", "wireshark": "Wireshark", "daocloud": "DaoCloud", "rescuetime": "RescueTime", "huawei": "华为", "algorithm": "算法", "electron": "Electron", "daydream": "Daydream", "amp": "AMP", "2017": "2017", "switch": "Nintendo Switch", "kirby": "Kirby", "nes": "NES", "retro": "怀旧游戏", "snes": "SNES", "mactype": "MacType", "hue": "Hue", "tamiya": "田宫模型", "memcached": "memcached", "zelda": "塞尔达传说", "retroarch": "RetroArch", "immigration": "移民", "algolia": "Algolia", "vivaldi": "Vivaldi", "amiibo": "Amiibo", "tensorflow": "TensorFlow", "kyototycoon": "Kyoto Tycoon", "quip": "Quip", "igetget": "得到", "racket": "Racket", "ubnt": "UBNT", "kafka": "Kafka", "5v5": "王者荣耀", "kotlin": "Kotlin", "ohno": "请不要再发这样的文章", "pytest": "pytest", "serverless": "Serverless", "alphago": "AlphaGo", "irvine": "Irvine", "webpack": "webpack", "arkit": "ARKit", "coreml": "Core ML", "musickit": "MusicKit", "homepod": "HomePod", "keras": "Keras", "caffe": "Caffe", "torch": "Torch", "jupyter": "Jupyter", "scikit": "scikit-learn", "theano": "Theano", "msoffice": "Microsoft Office", "smarthome": "智能家电", "logstash": "Logstash", "starcraft": "星际争霸", "kibana": "Kibana", "pubg": "PUBG", "csharp": "C#", "emoji": "Emoji", "ada": "ADA", "homekit": "HomeKit", "2018": "2018", "naturalist": "博物学", "ipfs": "IPFS", "upyun": "又拍云", "jd": "京东", "timescale": "Timescale", "bitbucket": "Bitbucket", "fortnite": "Fortnite", "blockchain": "区块链", "qi": "Qi 无线充电", "webdev": "Web Dev", "netflix": "Netflix", "ucla": "UCLA", "ipv6": "IPv6", "5g": "5G", "play": "Google Play", "mastodon": "Mastodon", "grafana": "Grafana", "flutter": "Flutter", "spotify": "Spotify", "turi": "Turi", "succulents": "多肉植物", "onmyoji": "阴阳师", "mec": "MEC", "danshari": "断舍离", "ev": "电动汽车", "taiwu": "太吾绘卷", "airbnb": "Airbnb", "bfv": "Battlefield V", "bf": "Battlefield 系列", "k8s": "Kubernetes", "leetcode": "LeetCode", "clojurescript": "ClojureScript", "2019": "2019", "bujo": "子弹笔记", "apex": "Apex Legends", "stadia": "Stadia", "tex": "TeX", "typescript": "TypeScript", "libra": "Libra", "cloudflare": "Cloudflare", "weekly": "写周报", "remote": "远程工作", "terraform": "Terraform", "vtuber": "Virtual YouTubers", "jsonfeed": "JSON Feed", "rss": "RSS", "quake": "雷神之锤系列", "darkmode": "夜间模式", "applearcade": "Apple Arcade", "zsh": "Z shell", "wg": "WireGuard", "ws": "WebSocket", "meraki": "Meraki", "cpp": "C++", "objc": "Objective-C", "nebula": "Nebula", "2020": "2020", "graphql": "GraphQL", "wenyan": "文言文编程语言", "testflight": "TestFlight", "bilibili": "哔哩哔哩", "miracleplus": "奇绩创坛", "terminal": "Terminal", "ipados": "iPadOS", "watchos": "watchOS", "ps5": "PlayStation 5", "neovim": "Neovim", "notion": "Notion", "busuu": "Busuu", "godot": "Godot", "pygame": "PyGame", "msfs": "微软飞行模拟", "ac": "动物之森", "alpine": "Alpine Linux", "ish": "iSH", "vapor": "Vapor", "figma": "Figma", "2077": "赛博朋克 2077", "2021": "2021", "eagle": "Eagle", "brave": "Brave", "dyson": "戴森球计划", "ifix": "云修电脑", "embedded": "嵌入式开发", "gamedb": "GameDB", "nextjs": "Next.js", "nuxtjs": "Nuxt.js", "dallas": "Dallas", "dune": "沙丘", "clickhouse": "ClickHouse", "diablo2": "Diablo II", "copilot": "GitHub Copilot", "taskade": "Taskade", "airpods": "AirPods", "psychology": "心理学", "terraria": "泰拉瑞亚", "opencv": "OpenCV", "vercel": "Vercel", "web3": "Web3", "nft": "NFT", "ethereum": "以太坊", "v2exapi": "V2EX API", "vite": "Vite", "crypto": "加密货币", "solana": "Solana", "solidity": "Solidity", "ton": "TON", "stc": "Starcoin", "mina": "Mina Protocol", "near": "NEAR Protocol", "cardano": "Cardano", "polkadot": "Polkadot", "metamask": "MetaMask", "xrc": "X Rabbits Club", "2022": "2022", "nixos": "NixOS", "xboxseries": "Xbox Series", "imovie": "iMovie", "zoom": "Zoom", "macstudio": "Mac Studio", "soulslike": "魂系游戏", "planet": "Planet", "logseq": "Logseq", "yubikey": "YubiKey", "handheld": "掌机", "openai": "OpenAI", "freeform": "Freeform", "nostr": "nostr", "diablo4": "Diablo IV", "macos9": "Mac OS 9", "cosub": "拼车", "genshin": "原神", "ens": "ENS", "vxna": "VXNA", "fishing": "钓鱼"} 5 | ) 6 | -------------------------------------------------------------------------------- /internal/config/screen_size.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import tea "github.com/charmbracelet/bubbletea" 4 | 5 | var ( 6 | Screen screen 7 | ) 8 | 9 | type screen struct { 10 | Height int 11 | Width int 12 | } 13 | 14 | func (s *screen) Sync(msg tea.WindowSizeMsg) { 15 | s.Height = msg.Height 16 | s.Width = msg.Width 17 | } 18 | 19 | func (s *screen) GetContentWidth() int { 20 | // left + right padding 21 | return s.Width - 2 22 | } 23 | -------------------------------------------------------------------------------- /internal/config/session.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | var ( 4 | Session = &sessionData{} 5 | ) 6 | 7 | // 跳转的时候模型和 routes.x 不是同一个, 被复制了, 所以存储到全局中 8 | type sessionData struct { 9 | TopicPage int 10 | TopicActiveIndex int 11 | BossComingMode bool 12 | } 13 | -------------------------------------------------------------------------------- /internal/consts/keymap.go: -------------------------------------------------------------------------------- 1 | package consts 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/key" 5 | ) 6 | 7 | type KeyMap struct { 8 | Up key.Binding 9 | Down key.Binding 10 | Left key.Binding 11 | Right key.Binding 12 | HelpPage key.Binding 13 | SettingPage key.Binding 14 | Space key.Binding 15 | Quit key.Binding 16 | Tab key.Binding 17 | Back key.Binding 18 | ShiftTab key.Binding 19 | Enter key.Binding 20 | SwitchShowMode key.Binding 21 | } 22 | 23 | // ShortHelp returns keybindings to be shown in the mini help view. It's part 24 | // of the key.Map interface. 25 | func (k KeyMap) ShortHelp() []key.Binding { 26 | return []key.Binding{ 27 | k.Up, k.Down, k.Left, k.Right, k.Tab, k.ShiftTab, k.Enter, // first column 28 | k.HelpPage, k.SettingPage, k.Quit, // second column 29 | k.SwitchShowMode, 30 | } 31 | } 32 | 33 | // FullHelp returns keybindings for the expanded help view. It's part of the 34 | // key.Map interface. 35 | func (k KeyMap) FullHelp() [][]key.Binding { 36 | return [][]key.Binding{ 37 | {k.Up, k.Down, k.Left, k.Right, k.Tab, k.ShiftTab, k.Enter, k.Back}, // first column 38 | {k.Quit, k.HelpPage, k.SettingPage, k.SwitchShowMode, k.Space}, // second column 39 | } 40 | } 41 | 42 | var AppKeyMap = KeyMap{ 43 | Up: key.NewBinding( 44 | key.WithKeys("w", "up"), 45 | key.WithHelp("w/↑", "[主题页]移动到上一个"), 46 | ), 47 | Down: key.NewBinding( 48 | key.WithKeys("s", "down"), 49 | key.WithHelp("s/↓", "[主题页]列表移动到下一个"), 50 | ), 51 | Left: key.NewBinding( 52 | key.WithKeys("a", "left"), 53 | key.WithHelp("a/←", "[主题页]上一页"), 54 | ), 55 | Right: key.NewBinding( 56 | key.WithKeys("d", "right"), 57 | key.WithHelp("d/→", "[主题页]下一页"), 58 | ), 59 | Back: key.NewBinding( 60 | key.WithKeys("q"), 61 | key.WithHelp("q", "返回上一页"), 62 | ), 63 | HelpPage: key.NewBinding( 64 | key.WithKeys("?"), 65 | key.WithHelp("?", "查看帮助页面(再按一次返回首页)"), 66 | ), 67 | SettingPage: key.NewBinding( 68 | key.WithKeys("`"), 69 | key.WithHelp("`", "[反引号]进入配置页面(再按一次返回首页)"), 70 | ), 71 | Tab: key.NewBinding( 72 | key.WithKeys("tab"), 73 | key.WithHelp("tab", "[主题页]切换下一个节点"), 74 | ), 75 | Space: key.NewBinding( 76 | key.WithKeys(" "), 77 | key.WithHelp("空格键", "老板键"), 78 | ), 79 | ShiftTab: key.NewBinding( 80 | key.WithKeys("shift+tab"), 81 | key.WithHelp("shift+tab", "[主题页]切换上一个切点"), 82 | ), 83 | Quit: key.NewBinding( 84 | key.WithKeys("esc", "ctrl+c"), 85 | key.WithHelp("esc", "退出程序"), 86 | ), 87 | Enter: key.NewBinding( 88 | key.WithKeys("e", "enter"), 89 | key.WithHelp("e/enter", "[主题页]查看主题详情"), 90 | ), 91 | SwitchShowMode: key.NewBinding( 92 | key.WithKeys("-"), 93 | key.WithHelp("-", "[减号]切换底部显示隐藏"), 94 | ), 95 | } 96 | -------------------------------------------------------------------------------- /internal/consts/showmode.go: -------------------------------------------------------------------------------- 1 | package consts 2 | 3 | const ( 4 | ShowModeHidden = 0 5 | ShowModeLeftAndRight = 1 6 | ShowModeLeftAndRightWithLimit = 2 7 | ShowModeAll = 3 8 | ) 9 | -------------------------------------------------------------------------------- /internal/consts/version.go: -------------------------------------------------------------------------------- 1 | package consts 2 | 3 | const ( 4 | AppName = "go-v2ex" 5 | AppVersion = "v1.3.1" 6 | ) 7 | -------------------------------------------------------------------------------- /internal/pkg/html.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | htmltomarkdown "github.com/JohannesKaufmann/html-to-markdown/v2" 5 | "github.com/JohannesKaufmann/html-to-markdown/v2/converter" 6 | "github.com/charmbracelet/glamour" 7 | "github.com/charmbracelet/glamour/styles" 8 | ) 9 | 10 | func SafeRenderHtml(input string) string { 11 | 12 | markdown, err := htmltomarkdown.ConvertString( 13 | input, 14 | converter.WithDomain("https://www.v2ex.com"), 15 | ) 16 | if err != nil { 17 | return input 18 | } 19 | 20 | out, err := glamour.Render(markdown, styles.AsciiStyle) 21 | if err != nil { 22 | return input 23 | } 24 | 25 | return out 26 | } 27 | -------------------------------------------------------------------------------- /internal/pkg/strings.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | func CutString(str string, length int) string { 4 | if length <= 0 { 5 | return "" 6 | } 7 | // 将字符串转为rune切片(按Unicode字符处理) 8 | runes := []rune(str) 9 | // 若原字符串长度小于等于目标长度,直接返回 10 | if len(runes) <= length { 11 | return str 12 | } 13 | // 截断并添加... 14 | return string(runes[:length]) + "..." 15 | } 16 | -------------------------------------------------------------------------------- /internal/types/error.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | type V2ApiError struct { 4 | Message string `json:"message"` 5 | Success bool `json:"success"` 6 | } 7 | 8 | type V1ApiError struct { 9 | Status string `json:"status"` 10 | Message string `json:"message"` 11 | } 12 | 13 | func (e V1ApiError) Success() bool { 14 | return e.Status == "" 15 | } 16 | -------------------------------------------------------------------------------- /internal/types/pagination.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/seth-shi/go-v2ex/internal/ui/styles" 7 | ) 8 | 9 | type Pagination struct { 10 | PerPage int `json:"per_page"` 11 | Total int `json:"total"` 12 | Pages int `json:"pages"` 13 | CurrPage int `json:"currPage"` 14 | } 15 | 16 | func (p *Pagination) ResetPages(perPage, total int) *Pagination { 17 | p.PerPage = perPage 18 | p.Total = total 19 | if p.Total <= 0 { 20 | p.Pages = 0 21 | return p 22 | } 23 | 24 | p.Pages = (p.Total + p.PerPage - 1) / p.PerPage 25 | return p 26 | } 27 | 28 | func (p Pagination) ToString(ext string) string { 29 | return fmt.Sprintf( 30 | "╭─ %d/%d • %d条 %s", 31 | p.CurrPage, 32 | p.Pages, 33 | p.Total, 34 | styles.Hint.Render(ext), 35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /internal/types/v1_topic.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | type V1TopicResult struct { 4 | Id int64 `json:"id"` 5 | Title string `json:"title"` 6 | Url string `json:"url"` 7 | Content string `json:"content"` 8 | ContentRendered string `json:"content_rendered"` 9 | Replies int `json:"replies"` 10 | Member struct { 11 | Id int `json:"id"` 12 | Username string `json:"username"` 13 | Tagline string `json:"tagline"` 14 | AvatarMini string `json:"avatar_mini"` 15 | AvatarNormal string `json:"avatar_normal"` 16 | AvatarLarge string `json:"avatar_large"` 17 | } `json:"member"` 18 | Node struct { 19 | Id int `json:"id"` 20 | Name string `json:"name"` 21 | Title string `json:"title"` 22 | TitleAlternative string `json:"title_alternative"` 23 | Url string `json:"url"` 24 | Topics int `json:"topics"` 25 | AvatarMini string `json:"avatar_mini"` 26 | AvatarNormal string `json:"avatar_normal"` 27 | AvatarLarge string `json:"avatar_large"` 28 | } `json:"node"` 29 | Created int64 `json:"created"` 30 | LastModified int64 `json:"last_modified"` 31 | LastTouched int64 `json:"last_touched"` 32 | } 33 | -------------------------------------------------------------------------------- /internal/types/v2_detail.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "github.com/seth-shi/go-v2ex/internal/pkg" 5 | ) 6 | 7 | type V2DetailResponse struct { 8 | V2ApiError 9 | Result V2DetailResult `json:"result"` 10 | } 11 | type V2DetailResult struct { 12 | Id int `json:"id"` 13 | Title string `json:"title"` 14 | Content string `json:"content"` 15 | ContentRendered string `json:"content_rendered"` 16 | Syntax int `json:"syntax"` 17 | Url string `json:"url"` 18 | Replies int `json:"replies"` 19 | LastReplyBy string `json:"last_reply_by"` 20 | Created int64 `json:"created"` 21 | LastModified int64 `json:"last_modified"` 22 | LastTouched int64 `json:"last_touched"` 23 | Member struct { 24 | Id int `json:"id"` 25 | Username string `json:"username"` 26 | Bio string `json:"bio"` 27 | Website string `json:"website"` 28 | Github string `json:"github"` 29 | Url string `json:"url"` 30 | Avatar string `json:"avatar"` 31 | Created int64 `json:"created"` 32 | } `json:"member"` 33 | Node struct { 34 | Id int `json:"id"` 35 | Url string `json:"url"` 36 | Name string `json:"name"` 37 | Title string `json:"title"` 38 | Header string `json:"header"` 39 | Footer string `json:"footer"` 40 | Avatar string `json:"avatar"` 41 | Topics int `json:"topics"` 42 | Created int64 `json:"created"` 43 | LastModified int64 `json:"last_modified"` 44 | } `json:"node"` 45 | Supplements []SupplementResult `json:"supplements"` 46 | } 47 | 48 | type SupplementResult struct { 49 | Id int `json:"id"` 50 | Content string `json:"content"` 51 | ContentRendered string `json:"content_rendered"` 52 | Syntax int `json:"syntax"` 53 | Created int64 `json:"created"` 54 | } 55 | 56 | func (r V2DetailResult) GetContent() string { 57 | 58 | var content = r.ContentRendered 59 | if r.ContentRendered == "" { 60 | content = r.Content 61 | } 62 | 63 | return pkg.SafeRenderHtml(content) 64 | } 65 | 66 | func (r SupplementResult) GetContent() string { 67 | var content = r.ContentRendered 68 | if r.ContentRendered == "" { 69 | content = r.Content 70 | } 71 | 72 | return pkg.SafeRenderHtml(content) 73 | } 74 | -------------------------------------------------------------------------------- /internal/types/v2_replies.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "github.com/seth-shi/go-v2ex/internal/pkg" 5 | ) 6 | 7 | type V2ReplyResponse struct { 8 | V2ApiError 9 | Result []V2ReplyResult `json:"result"` 10 | Pagination Pagination `json:"pagination"` 11 | } 12 | type V2ReplyResult struct { 13 | Id int `json:"id"` 14 | Content string `json:"content"` 15 | ContentRendered string `json:"content_rendered"` 16 | Created int64 `json:"created"` 17 | Member struct { 18 | Id int `json:"id"` 19 | Username string `json:"username"` 20 | Bio string `json:"bio"` 21 | Website string `json:"website"` 22 | Github string `json:"github"` 23 | Url string `json:"url"` 24 | Avatar string `json:"avatar"` 25 | Created int64 `json:"created"` 26 | } `json:"member"` 27 | } 28 | 29 | func (r V2ReplyResult) GetContent() string { 30 | var content = r.ContentRendered 31 | if r.ContentRendered == "" { 32 | content = r.Content 33 | } 34 | 35 | return pkg.SafeRenderHtml(content) 36 | } 37 | -------------------------------------------------------------------------------- /internal/types/v2_token.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | type V2TokenResponse struct { 4 | V2ApiError 5 | Result *V2TokenResult `json:"result"` 6 | } 7 | 8 | type V2TokenResult struct { 9 | Token string `json:"token"` 10 | Scope string `json:"scope"` 11 | Expiration int64 `json:"expiration"` 12 | GoodForDays int `json:"good_for_days"` 13 | TotalUsed int `json:"total_used"` 14 | LastUsed int `json:"last_used"` 15 | Created int64 `json:"created"` 16 | } 17 | -------------------------------------------------------------------------------- /internal/types/v2_topic.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | type V2TopicResponse struct { 4 | V2ApiError 5 | Result []V2TopicResult `json:"result"` 6 | Pagination Pagination `json:"pagination"` 7 | } 8 | type V2TopicResult struct { 9 | Id int64 `json:"id"` 10 | Title string `json:"title"` 11 | Content string `json:"content"` 12 | ContentRendered string `json:"content_rendered"` 13 | Syntax int `json:"syntax"` 14 | Url string `json:"url"` 15 | Replies int `json:"replies"` 16 | LastReplyBy string `json:"last_reply_by"` 17 | Created int `json:"created"` 18 | LastModified int64 `json:"last_modified"` 19 | LastTouched int64 `json:"last_touched"` 20 | } 21 | 22 | type TopicComResult struct { 23 | Id int64 `json:"id"` 24 | Node string `json:"node"` 25 | Title string `json:"title"` 26 | Member string `json:"member"` 27 | LastTouched int64 `json:"last_touched"` 28 | Replies int `json:"replies"` 29 | } 30 | -------------------------------------------------------------------------------- /internal/ui/components/boss/boss.go: -------------------------------------------------------------------------------- 1 | package boss 2 | 3 | import ( 4 | tea "github.com/charmbracelet/bubbletea" 5 | "github.com/charmbracelet/lipgloss" 6 | "github.com/seth-shi/go-v2ex/internal/config" 7 | ) 8 | 9 | type Model struct { 10 | } 11 | 12 | func New() Model { 13 | return Model{} 14 | } 15 | 16 | func (m Model) Init() tea.Cmd { 17 | return nil 18 | } 19 | 20 | func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 21 | 22 | return m, nil 23 | } 24 | 25 | func (m Model) View() string { 26 | // 深色背景 + 居中提示文字 27 | shieldStyle := lipgloss.NewStyle(). 28 | Width(config.Screen.Width). 29 | Height(config.Screen.Height). 30 | Align(lipgloss.Center). 31 | AlignVertical(lipgloss.Center) 32 | return shieldStyle.Render("") 33 | } 34 | -------------------------------------------------------------------------------- /internal/ui/components/detail/detail.go: -------------------------------------------------------------------------------- 1 | package detail 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "math" 7 | "strings" 8 | 9 | "github.com/charmbracelet/bubbles/key" 10 | "github.com/samber/lo" 11 | "github.com/seth-shi/go-v2ex/internal/consts" 12 | 13 | "github.com/dromara/carbon/v2" 14 | "github.com/muesli/reflow/wrap" 15 | "github.com/seth-shi/go-v2ex/internal/api" 16 | "github.com/seth-shi/go-v2ex/internal/types" 17 | 18 | "github.com/charmbracelet/bubbles/viewport" 19 | tea "github.com/charmbracelet/bubbletea" 20 | "github.com/charmbracelet/lipgloss" 21 | "github.com/seth-shi/go-v2ex/internal/config" 22 | "github.com/seth-shi/go-v2ex/internal/ui/messages" 23 | ) 24 | 25 | const ( 26 | keyHelp = "[q:返回 e:加载评论 w/s/鼠标:滑动 a/d:翻页 -:隐藏页脚]" 27 | ) 28 | 29 | var ( 30 | titleStyle = func() lipgloss.Style { 31 | b := lipgloss.RoundedBorder() 32 | b.Right = "├" 33 | return lipgloss.NewStyle().BorderStyle(b).Padding(0, 1) 34 | }() 35 | 36 | infoStyle = func() lipgloss.Style { 37 | b := lipgloss.RoundedBorder() 38 | b.Left = "┤" 39 | return titleStyle.BorderStyle(b) 40 | }() 41 | sectionStyle = lipgloss. 42 | NewStyle(). 43 | Border(lipgloss.RoundedBorder()) 44 | ) 45 | 46 | type Model struct { 47 | viewport viewport.Model 48 | viewportReady bool 49 | detail types.V2DetailResult 50 | replies []types.V2ReplyResult 51 | canRequestReply bool 52 | 53 | id int64 54 | replyPage int 55 | requestingReply bool 56 | } 57 | 58 | func New() Model { 59 | return Model{} 60 | } 61 | 62 | func (m Model) Init() tea.Cmd { 63 | return nil 64 | } 65 | 66 | func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 67 | var ( 68 | cmd tea.Cmd 69 | cmds []tea.Cmd 70 | ) 71 | 72 | switch msgType := msg.(type) { 73 | case messages.GetDetailRequest: 74 | // 获取内容 + 第一页的评论 75 | m.viewportReady = false 76 | m.canRequestReply = true 77 | m.id = msgType.ID 78 | m.replyPage = 1 79 | m.viewport = viewport.New(config.Screen.Width-2, config.Screen.Height-lipgloss.Height(m.headerView())-2) 80 | return m, tea.Batch( 81 | m.getDetail(msgType.ID), 82 | m.getReply(msgType.ID), 83 | ) 84 | case messages.GetDetailResult: 85 | m.detail = msgType.Detail 86 | m.initViewport() 87 | case messages.GetRepliesResult: 88 | cmds = append(cmds, m.onReplyResult(msgType)) 89 | m.initViewport() 90 | case tea.WindowSizeMsg: 91 | m.initViewport() 92 | case tea.KeyMsg: 93 | switch { 94 | case key.Matches(msgType, consts.AppKeyMap.Enter): 95 | return m, m.getReply(m.id) 96 | // 回到首页 97 | case key.Matches(msgType, consts.AppKeyMap.Back): 98 | return m, messages.Post(messages.RedirectTopicsPage{}) 99 | case key.Matches(msgType, consts.AppKeyMap.Up): 100 | msg = tea.KeyMsg{Type: tea.KeyUp} 101 | case key.Matches(msgType, consts.AppKeyMap.Down): 102 | msg = tea.KeyMsg{Type: tea.KeyDown} 103 | case key.Matches(msgType, consts.AppKeyMap.Left): 104 | msg = tea.KeyMsg{Type: tea.KeyPgUp} 105 | case key.Matches(msgType, consts.AppKeyMap.Right): 106 | msg = tea.KeyMsg{Type: tea.KeyPgDown} 107 | } 108 | } 109 | 110 | m.viewport, cmd = m.viewport.Update(msg) 111 | cmds = append(cmds, cmd) 112 | return m, tea.Batch(cmds...) 113 | } 114 | 115 | func (m Model) View() string { 116 | return fmt.Sprintf("%s\n%s", m.headerView(), m.viewport.View()) 117 | } 118 | 119 | func (m Model) headerView() string { 120 | var p = 0.0 121 | if m.viewportReady { 122 | p = m.viewport.ScrollPercent() * 100 123 | } 124 | info := infoStyle.Render(fmt.Sprintf("%3.f%%", p)) 125 | line := strings.Repeat("─", max(0, int(math.Ceil(float64(m.viewport.Width-lipgloss.Width(info))*p/100)))) 126 | return lipgloss.JoinHorizontal(lipgloss.Center, line, info) 127 | } 128 | 129 | func (m Model) getDetail(id int64) tea.Cmd { 130 | return tea.Sequence( 131 | messages.Post(messages.LoadingRequestDetail.Start), api.Client.GetDetail(id), 132 | messages.Post(messages.LoadingRequestDetail.End), 133 | ) 134 | } 135 | 136 | func (m *Model) onReplyResult(msgType messages.GetRepliesResult) tea.Cmd { 137 | 138 | m.requestingReply = false 139 | if msgType.Error != nil { 140 | return messages.Post(msgType.Error) 141 | } 142 | 143 | // 请求之后增加分页, 防止网络失败, 增加了分页 144 | m.replyPage++ 145 | m.replies = append(m.replies, msgType.Replies...) 146 | 147 | var cmds []tea.Cmd 148 | 149 | if msgType.Pagination.Total > 0 && config.G.ShowPage() { 150 | help := lo.If(config.G.ShowHelp(), keyHelp).Else("") 151 | cmds = append( 152 | cmds, messages.Post( 153 | messages.ShowTipsRequest{ 154 | Text: msgType.Pagination.ToString(help), 155 | }, 156 | ), 157 | ) 158 | } 159 | 160 | if m.replyPage > msgType.Pagination.Pages { 161 | m.canRequestReply = false 162 | cmds = append(cmds, messages.Post(messages.ShowTipsRequest{Text: "没有更多了"})) 163 | } 164 | 165 | return tea.Batch(cmds...) 166 | } 167 | 168 | func (m *Model) getReply(id int64) tea.Cmd { 169 | 170 | if m.requestingReply { 171 | return messages.Post(errors.New("评论请求中")) 172 | } 173 | 174 | if !m.canRequestReply { 175 | return nil 176 | } 177 | m.requestingReply = true 178 | return tea.Sequence( 179 | messages.Post(messages.LoadingRequestReply.Start), api.Client.GetReply(id, m.replyPage), 180 | messages.Post(messages.LoadingRequestReply.End), 181 | ) 182 | } 183 | 184 | func (m *Model) initViewport() { 185 | // 获取详情 186 | var ( 187 | contentWidth = config.Screen.Width - 2 188 | content strings.Builder 189 | ) 190 | // 组装文案 191 | // 找到所有图片去动态替换成字符串 192 | content.WriteString( 193 | sectionStyle. 194 | Width(config.Screen.Width). 195 | Render( 196 | fmt.Sprintf( 197 | "V2EX > %s %s\n%s · %s · %d 回复\n\n%s\n\n%s", 198 | m.detail.Node.Title, m.detail.Url, 199 | m.detail.Member.Username, carbon.CreateFromTimestamp(m.detail.Created), 200 | m.detail.Replies, 201 | lipgloss.NewStyle(). 202 | Bold(true). 203 | Border(lipgloss.RoundedBorder(), false, false, true, false). 204 | Render(m.detail.Title), 205 | wrap.String(m.detail.GetContent(), contentWidth), 206 | ), 207 | ), 208 | ) 209 | content.WriteString("\n\n") 210 | 211 | // 附言 212 | for i, c := range m.detail.Supplements { 213 | 214 | desc := fmt.Sprintf( 215 | "第 %d 条附言 · %s\n%s", i+1, carbon.CreateFromTimestamp(c.Created), 216 | c.GetContent(), 217 | ) 218 | content.WriteString(sectionStyle.Width(config.Screen.Width).Render(desc)) 219 | } 220 | 221 | // 开始渲染评论 222 | content.WriteString("\n\n") 223 | if len(m.replies) > 0 { 224 | var replies strings.Builder 225 | for i, r := range m.replies { 226 | floor := fmt.Sprintf( 227 | "#%d · %s @%s", 228 | i+1, 229 | carbon.CreateFromTimestamp(r.Created), 230 | r.Member.Username, 231 | ) 232 | replies.WriteString(lipgloss.NewStyle().Bold(true).Render(floor)) 233 | replies.WriteString("\n") 234 | replies.WriteString(r.GetContent()) 235 | replies.WriteString("\n\n") 236 | } 237 | content.WriteString(sectionStyle.Width(config.Screen.Width).Render(replies.String())) 238 | } 239 | 240 | m.viewport.SetContent(content.String()) 241 | m.viewportReady = true 242 | } 243 | -------------------------------------------------------------------------------- /internal/ui/components/footer/footer.go: -------------------------------------------------------------------------------- 1 | package footer 2 | 3 | import ( 4 | "fmt" 5 | "math" 6 | "slices" 7 | "strings" 8 | "time" 9 | 10 | "github.com/charmbracelet/bubbles/spinner" 11 | tea "github.com/charmbracelet/bubbletea" 12 | "github.com/charmbracelet/lipgloss" 13 | "github.com/samber/lo" 14 | "github.com/seth-shi/go-v2ex/internal/api" 15 | "github.com/seth-shi/go-v2ex/internal/config" 16 | "github.com/seth-shi/go-v2ex/internal/consts" 17 | "github.com/seth-shi/go-v2ex/internal/ui/messages" 18 | "github.com/seth-shi/go-v2ex/internal/ui/styles" 19 | ) 20 | 21 | var ( 22 | rightText = fmt.Sprintf("%s@%s Powered by seth-shi", consts.AppName, consts.AppVersion) 23 | ) 24 | 25 | type Model struct { 26 | // 只在 update view 读写, 无需上锁 27 | // 会自动删除 28 | loadings map[int]string 29 | errors []string 30 | tips []string 31 | // 固定文案, 不会修改 (例如用来显示页码) 32 | leftText string 33 | spinner spinner.Model 34 | } 35 | 36 | func New() Model { 37 | 38 | return Model{ 39 | // 最大加载数限定 40 | loadings: make(map[int]string, 10), 41 | spinner: spinner.New(spinner.WithSpinner(spinner.Points)), 42 | } 43 | } 44 | 45 | func (m Model) Init() tea.Cmd { 46 | return tea.Batch( 47 | m.spinner.Tick, 48 | ) 49 | } 50 | 51 | func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 52 | 53 | switch msgType := msg.(type) { 54 | case messages.StartLoading: 55 | m.loadings[msgType.ID] = msgType.Text 56 | return m, nil 57 | case messages.EndLoading: 58 | delete(m.loadings, msgType.ID) 59 | return m, nil 60 | case messages.ShowTipsRequest: 61 | m.leftText = msgType.Text 62 | return m, nil 63 | // 消息处理 64 | case messages.ShowAutoTipsRequest: 65 | return m, m.addAutoClearTips(msgType.Text) 66 | case messages.ShiftAutoTipsRequest: 67 | // 删除第一个元素 68 | m.tips = lo.Slice(m.tips, 1, len(m.tips)) 69 | return m, nil 70 | case error: 71 | return m, m.addError(msgType) 72 | case messages.ShiftErrorRequest: 73 | // 删除第一个元素 74 | m.errors = lo.Slice(m.errors, 1, len(m.errors)) 75 | return m, nil 76 | case spinner.TickMsg: 77 | var cmd tea.Cmd 78 | m.spinner, cmd = m.spinner.Update(msgType) 79 | return m, cmd 80 | } 81 | 82 | return m, nil 83 | } 84 | 85 | func (m Model) View() string { 86 | 87 | var ( 88 | leftSection []string 89 | ) 90 | 91 | if len(m.errors) > 0 || len(m.loadings) > 0 || len(m.tips) > 0 || m.leftText != "" { 92 | 93 | if m.leftText != "" { 94 | leftSection = append(leftSection, styles.Hint.Render(m.leftText)) 95 | } 96 | 97 | leftSection = append( 98 | leftSection, styles.Err.Render(strings.Join(m.errors, " / ")), 99 | ) 100 | 101 | leftSection = append( 102 | leftSection, styles.Hint.Render(strings.Join(m.tips, " / ")), 103 | ) 104 | 105 | loadingKeys := lo.Keys(m.loadings) 106 | slices.Sort(loadingKeys) 107 | loadingText := lo.Map( 108 | loadingKeys, func(key int, index int) string { 109 | return fmt.Sprintf( 110 | "%s %s", 111 | lipgloss.NewStyle().PaddingLeft(1).Render( 112 | m.spinner.View(), 113 | ), 114 | m.loadings[key], 115 | ) 116 | }, 117 | ) 118 | leftSection = append(leftSection, styles.Hint.Render(strings.Join(loadingText, ""))) 119 | } else if config.G.ShowFooter() { 120 | helpKey := consts.AppKeyMap.HelpPage.Help() 121 | leftSection = append(leftSection, styles.Hint.Render(fmt.Sprintf(" %s %s", helpKey.Key, helpKey.Desc))) 122 | } 123 | 124 | padding := 1 125 | leftContent := strings.Join(leftSection, " ") 126 | footer := leftContent 127 | if config.G.ShowFooter() { 128 | 129 | var canHiddenFooter strings.Builder 130 | canHiddenFooter.WriteString( 131 | lipgloss.JoinHorizontal( 132 | lipgloss.Top, 133 | leftContent, 134 | lipgloss.PlaceHorizontal( 135 | config.Screen.Width-lipgloss.Width(leftContent)-2*padding, 136 | lipgloss.Right, 137 | styles.Hint.Render(rightText), 138 | ), 139 | ), 140 | ) 141 | 142 | if config.G.ShowLimit() && api.LimitTotalCount.Load() > 0 { 143 | 144 | screenWidth := config.Screen.GetContentWidth() 145 | rate := float64(screenWidth) * float64(api.LimitRemainCount.Load()) / float64(api.LimitTotalCount.Load()) 146 | borderWidth := int(math.Round(rate)) 147 | canHiddenFooter.WriteString("\n") 148 | canHiddenFooter.WriteString(strings.Repeat("♡", borderWidth)) 149 | canHiddenFooter.WriteString(strings.Repeat("_", screenWidth-borderWidth)) 150 | } 151 | footer = canHiddenFooter.String() 152 | } 153 | 154 | return styles. 155 | Hint. 156 | Width(config.Screen.Width). 157 | Render(footer) 158 | } 159 | 160 | func (m *Model) addAutoClearTips(text string) tea.Cmd { 161 | 162 | m.tips = append(m.tips, text) 163 | // 3s 后删除一个 164 | return tea.Tick( 165 | time.Second*3, func(time.Time) tea.Msg { 166 | return messages.ShiftAutoTipsRequest{} 167 | }, 168 | ) 169 | } 170 | 171 | func (m *Model) addError(err error) tea.Cmd { 172 | 173 | if err == nil { 174 | return nil 175 | } 176 | 177 | m.errors = append(m.errors, err.Error()) 178 | // 3s 后删除一个 179 | return tea.Tick( 180 | time.Second*3, func(time.Time) tea.Msg { 181 | return messages.ShiftErrorRequest{} 182 | }, 183 | ) 184 | } 185 | -------------------------------------------------------------------------------- /internal/ui/components/help/help.go: -------------------------------------------------------------------------------- 1 | package help 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/help" 5 | tea "github.com/charmbracelet/bubbletea" 6 | "github.com/charmbracelet/lipgloss" 7 | "github.com/seth-shi/go-v2ex/internal/config" 8 | "github.com/seth-shi/go-v2ex/internal/consts" 9 | "github.com/seth-shi/go-v2ex/internal/ui/styles" 10 | ) 11 | 12 | type Model struct { 13 | keys consts.KeyMap 14 | help help.Model 15 | } 16 | 17 | func New() Model { 18 | helpModel := help.New() 19 | helpModel.ShowAll = true 20 | m := Model{ 21 | help: helpModel, 22 | keys: consts.AppKeyMap, 23 | } 24 | 25 | return m 26 | } 27 | 28 | func (m Model) Init() tea.Cmd { 29 | return nil 30 | } 31 | 32 | func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 33 | return m, nil 34 | } 35 | 36 | func (m Model) View() string { 37 | 38 | more := styles.Bold.Render("\n如有请求超时, 请设置 clash 全局代理, 或者复制代理环境变量到终端执行") 39 | 40 | return lipgloss. 41 | NewStyle(). 42 | Border(lipgloss.RoundedBorder()). 43 | Width(config.Screen.Width - 2). 44 | Height(config.Screen.Height - 4). 45 | Padding(1). 46 | Render(lipgloss.JoinVertical(lipgloss.Top, m.help.View(m.keys), more)) 47 | } 48 | -------------------------------------------------------------------------------- /internal/ui/components/setting/setting.go: -------------------------------------------------------------------------------- 1 | package setting 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/seth-shi/go-v2ex/internal/api" 8 | "github.com/seth-shi/go-v2ex/internal/config" 9 | "github.com/seth-shi/go-v2ex/internal/ui/messages" 10 | "github.com/seth-shi/go-v2ex/internal/ui/styles" 11 | 12 | "github.com/charmbracelet/bubbles/textinput" 13 | tea "github.com/charmbracelet/bubbletea" 14 | "github.com/charmbracelet/lipgloss" 15 | ) 16 | 17 | var ( 18 | focusedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#31bdec")) 19 | blurredStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#c2c2c2")) 20 | cursorStyle = focusedStyle 21 | noStyle = lipgloss.NewStyle() 22 | 23 | focusedButton = focusedStyle.Render("[ 保存 ]") 24 | blurredButton = fmt.Sprintf("[ %s ]", blurredStyle.Render("保存")) 25 | 26 | homeFocusedButton = focusedStyle.Render("[ 回到首页 ]") 27 | homeButton = fmt.Sprintf("[ %s ]", blurredStyle.Render("回到首页")) 28 | 29 | tipStyle = lipgloss.NewStyle(). 30 | Padding(1, 1, 0, 1) 31 | 32 | formsCount = 4 33 | ) 34 | 35 | type Model struct { 36 | focusIndex int 37 | inputs []textinput.Model 38 | } 39 | 40 | func New() Model { 41 | m := Model{ 42 | inputs: make([]textinput.Model, 2), 43 | } 44 | 45 | var t textinput.Model 46 | for i := range m.inputs { 47 | t = textinput.New() 48 | t.Cursor.Style = cursorStyle 49 | t.CharLimit = 500 50 | 51 | switch i { 52 | case 0: 53 | t.Placeholder = "" 54 | t.Prompt = "认证令牌:" 55 | t.Focus() 56 | t.PromptStyle = focusedStyle 57 | t.TextStyle = focusedStyle 58 | case 1: 59 | t.Prompt = "列表节点:" 60 | } 61 | 62 | m.inputs[i] = t 63 | } 64 | 65 | return m 66 | } 67 | 68 | func (m Model) Init() tea.Cmd { 69 | return textinput.Blink 70 | } 71 | 72 | func (m Model) RefreshConfig() { 73 | // 当前不在 body 页, 无法通过消息更新 74 | if len(m.inputs) > 0 { 75 | m.inputs[0].SetValue(config.G.Token) 76 | } 77 | 78 | if len(m.inputs) > 1 { 79 | m.inputs[1].SetValue(config.G.Nodes) 80 | } 81 | } 82 | 83 | func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 84 | switch msgType := msg.(type) { 85 | case tea.KeyMsg: 86 | switch msgType.String() { 87 | case "tab", "shift+tab", "enter", "up", "down": 88 | s := msgType.String() 89 | 90 | // Did the user press enter while the submit button was focused? 91 | // If so, exit. 92 | if s == "enter" { 93 | 94 | if m.focusIndex == len(m.inputs) { 95 | return m, m.saveSettings() 96 | } 97 | 98 | if m.focusIndex == formsCount-1 { 99 | return m, messages.Post( 100 | messages.RedirectTopicsPage{ 101 | Page: 1, 102 | }, 103 | ) 104 | } 105 | } 106 | 107 | // Cycle indexes 108 | if s == "up" || s == "shift+tab" { 109 | m.focusIndex-- 110 | } else { 111 | m.focusIndex++ 112 | } 113 | 114 | if m.focusIndex > formsCount { 115 | m.focusIndex = 0 116 | } else if m.focusIndex < 0 { 117 | m.focusIndex = formsCount - 1 118 | } 119 | 120 | // 更新表单的值 121 | cmds := make([]tea.Cmd, formsCount) 122 | for i := 0; i <= len(m.inputs)-1; i++ { 123 | if i == m.focusIndex { 124 | // Set focused state 125 | cmds[i] = m.inputs[i].Focus() 126 | m.inputs[i].PromptStyle = focusedStyle 127 | m.inputs[i].TextStyle = focusedStyle 128 | continue 129 | } 130 | // Remove focused state 131 | m.inputs[i].Blur() 132 | m.inputs[i].PromptStyle = noStyle 133 | m.inputs[i].TextStyle = noStyle 134 | } 135 | 136 | return m, tea.Batch(cmds...) 137 | } 138 | } 139 | 140 | // Handle character input and blinking 141 | cmd := m.updateInputs(msg) 142 | 143 | return m, cmd 144 | } 145 | 146 | func (m Model) updateInputs(msg tea.Msg) tea.Cmd { 147 | cmds := make([]tea.Cmd, len(m.inputs)) 148 | 149 | // Only text inputs with Focus() set will respond, so it's safe to simply 150 | // update all of them here without any further logic. 151 | for i := range m.inputs { 152 | m.inputs[i], cmds[i] = m.inputs[i].Update(msg) 153 | } 154 | 155 | return tea.Batch(cmds...) 156 | } 157 | 158 | func (m Model) saveSettings() tea.Cmd { 159 | if len(m.inputs) > 0 { 160 | config.G.Token = strings.TrimSpace(m.inputs[0].Value()) 161 | } 162 | 163 | if len(m.inputs) > 1 { 164 | config.G.Nodes = strings.TrimSpace(m.inputs[1].Value()) 165 | } 166 | 167 | api.Client.RefreshConfig() 168 | return config.SaveToFile("配置保存成功") 169 | } 170 | 171 | func (m Model) View() string { 172 | var b strings.Builder 173 | 174 | b.WriteString(styles.Err.PaddingLeft(1).Render("tab 切换表单, 回车确认(如有请求超时, 请设置 clash 全局代理, 或者复制代理环境变量到终端执行)")) 175 | b.WriteString("\n") 176 | text := fmt.Sprintf("配置文件路径: %s", config.SavePath()) 177 | b.WriteString(styles.Bold.PaddingLeft(1).Render(text)) 178 | 179 | if len(m.inputs) > 0 { 180 | text := fmt.Sprintf( 181 | "\n%s\n%s", 182 | "点此创建秘钥: https://www.v2ex.com/settings/tokens", 183 | m.inputs[0].View(), 184 | ) 185 | b.WriteString(tipStyle.Render(text)) 186 | } 187 | 188 | if len(m.inputs) > 1 { 189 | text := fmt.Sprintf( 190 | "\n%s\n%s", 191 | "所有分类此处查看: https://v2ex.com/planes (多个分类使用英文逗号隔开, URL 上的 https://v2ex.com/go/{name})", 192 | m.inputs[1].View(), 193 | ) 194 | b.WriteString(tipStyle.Render(text)) 195 | } 196 | 197 | btn1 := &blurredButton 198 | // 最后一个 input 199 | if m.focusIndex == len(m.inputs) { 200 | btn1 = &focusedButton 201 | } 202 | 203 | btn2 := &homeButton 204 | // 最后一个 input 205 | if m.focusIndex == formsCount-1 { 206 | btn2 = &homeFocusedButton 207 | } 208 | 209 | b.WriteString(tipStyle.Render(fmt.Sprintf("\n%s %s\n", *btn1, *btn2))) 210 | return lipgloss.NewStyle(). 211 | Border(lipgloss.RoundedBorder()). 212 | Width(config.Screen.Width - 2). 213 | Height(config.Screen.Height - 4). 214 | Padding(1). 215 | Render(b.String()) 216 | } 217 | -------------------------------------------------------------------------------- /internal/ui/components/splash/splash.go: -------------------------------------------------------------------------------- 1 | package splash 2 | 3 | import ( 4 | tea "github.com/charmbracelet/bubbletea" 5 | "github.com/charmbracelet/lipgloss" 6 | "github.com/seth-shi/go-v2ex/internal/config" 7 | ) 8 | 9 | type Model struct { 10 | } 11 | 12 | func New() Model { 13 | return Model{} 14 | } 15 | 16 | func (m Model) Init() tea.Cmd { 17 | return nil 18 | } 19 | 20 | func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 21 | 22 | return m, nil 23 | } 24 | 25 | func (m Model) View() string { 26 | return lipgloss. 27 | NewStyle(). 28 | Width(config.Screen.Width). 29 | Bold(true). 30 | Height(1). 31 | Align(lipgloss.Center). 32 | Foreground(lipgloss.Color("#ff5722")). 33 | Render("载入中...") 34 | } 35 | -------------------------------------------------------------------------------- /internal/ui/components/topics/topics.go: -------------------------------------------------------------------------------- 1 | package topics 2 | 3 | import ( 4 | "errors" 5 | "strconv" 6 | "strings" 7 | 8 | "github.com/samber/lo" 9 | 10 | "github.com/charmbracelet/bubbles/key" 11 | "github.com/seth-shi/go-v2ex/internal/config" 12 | "github.com/seth-shi/go-v2ex/internal/consts" 13 | 14 | "github.com/seth-shi/go-v2ex/internal/api" 15 | "github.com/seth-shi/go-v2ex/internal/types" 16 | "github.com/seth-shi/go-v2ex/internal/ui/messages" 17 | 18 | tea "github.com/charmbracelet/bubbletea" 19 | "github.com/charmbracelet/lipgloss" 20 | "github.com/charmbracelet/lipgloss/table" 21 | "github.com/dromara/carbon/v2" 22 | ) 23 | 24 | const keyHelp = "[a/d:翻页 w/s:选择 e:详情 tab/shift+tab:节点 空格:老板键 `:设置页 ?:帮助页]" 25 | 26 | var ( 27 | cellStyle = lipgloss.NewStyle().Padding(0, 1).Width(5) 28 | headerStyle = lipgloss.NewStyle().Bold(true).Align(lipgloss.Center) 29 | inactiveTabBorder = tabBorderWithBottom("┴", "─", "┴") 30 | activeTabBorder = tabBorderWithBottom("┘", " ", "└") 31 | inactiveTabStyle = lipgloss.NewStyle().Border(inactiveTabBorder, true).Padding(0, 1) 32 | activeTabStyle = inactiveTabStyle.Border(activeTabBorder, true) 33 | ) 34 | 35 | type Model struct { 36 | requesting bool 37 | topics []types.TopicComResult 38 | } 39 | 40 | func New() Model { 41 | return Model{} 42 | } 43 | 44 | func (m *Model) SetTopics(topics []types.TopicComResult) { 45 | m.topics = topics 46 | } 47 | 48 | func (m Model) Init() tea.Cmd { 49 | return nil 50 | } 51 | 52 | func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 53 | 54 | switch msgType := msg.(type) { 55 | // 其它地方负责回调这里去请求数据, 56 | case messages.GetTopicsRequest: 57 | m.requesting = true 58 | // 默认进来是要给节点 59 | return m, tea.Sequence( 60 | messages.Post(messages.LoadingRequestTopics.Start), 61 | api.Client.GetTopics(config.G.ActiveTab, msgType.Page), 62 | messages.Post(messages.LoadingRequestTopics.End), 63 | ) 64 | case messages.GetTopicsResult: 65 | return m, m.onTopicResult(msgType) 66 | case tea.KeyMsg: 67 | // 如果在请求中, 不处理键盘事件 68 | if m.requesting { 69 | return m, messages.Post(errors.New("请求中")) 70 | } 71 | 72 | switch { 73 | case key.Matches(msgType, consts.AppKeyMap.Tab): 74 | return m, m.moveTabs(1) 75 | case key.Matches(msgType, consts.AppKeyMap.ShiftTab): 76 | return m, m.moveTabs(-1) 77 | case key.Matches(msgType, consts.AppKeyMap.Enter): 78 | // 查看详情 79 | curr := lo.NthOrEmpty(m.topics, config.Session.TopicActiveIndex) 80 | if curr.Id == 0 { 81 | return m, messages.Post(errors.New("查看无效的主题")) 82 | } 83 | return m, messages.Post(messages.RedirectDetailRequest{Id: curr.Id}) 84 | case key.Matches(msgType, consts.AppKeyMap.Up): 85 | config.Session.TopicActiveIndex-- 86 | if config.Session.TopicActiveIndex < 0 { 87 | config.Session.TopicActiveIndex = max(0, len(m.topics)-1) 88 | } 89 | return m, nil 90 | case key.Matches(msgType, consts.AppKeyMap.Down): 91 | config.Session.TopicActiveIndex++ 92 | if config.Session.TopicActiveIndex >= len(m.topics) { 93 | config.Session.TopicActiveIndex = 0 94 | } 95 | return m, nil 96 | case key.Matches(msgType, consts.AppKeyMap.Left): 97 | if config.Session.TopicPage > 1 { 98 | m.requesting = true 99 | return m, messages.Post(messages.GetTopicsRequest{Page: config.Session.TopicPage - 1}) 100 | } 101 | return m, nil 102 | case key.Matches(msgType, consts.AppKeyMap.Right): 103 | m.requesting = true 104 | return m, messages.Post(messages.GetTopicsRequest{Page: config.Session.TopicPage + 1}) 105 | default: 106 | return m, nil 107 | } 108 | } 109 | 110 | return m, nil 111 | } 112 | 113 | func (m Model) View() string { 114 | 115 | var ( 116 | doc strings.Builder 117 | ) 118 | doc.WriteString(m.renderTabs()) 119 | doc.WriteString(m.renderTables()) 120 | return doc.String() 121 | } 122 | 123 | func (m *Model) moveTabs(add int) tea.Cmd { 124 | config.Session.TopicPage = 1 125 | config.G.ActiveTab += add 126 | 127 | nodesSize := len(config.G.GetNodes()) 128 | if nodesSize == 0 { 129 | return nil 130 | } 131 | 132 | if config.G.ActiveTab >= nodesSize { 133 | config.G.ActiveTab = 0 134 | } 135 | if config.G.ActiveTab < 0 { 136 | config.G.ActiveTab = nodesSize - 1 137 | } 138 | 139 | return tea.Batch( 140 | config.SaveToFile(""), 141 | messages.Post(messages.GetTopicsRequest{Page: config.Session.TopicPage}), 142 | ) 143 | } 144 | 145 | func (m *Model) onTopicResult(msgType messages.GetTopicsResult) tea.Cmd { 146 | m.requesting = false 147 | if msgType.Error != nil { 148 | return messages.Post(msgType.Error) 149 | } 150 | m.topics = msgType.Topics 151 | config.Session.TopicPage = msgType.Pagination.CurrPage 152 | // 显示错误和页码 153 | if config.G.ShowPage() { 154 | help := lo.If(config.G.ShowHelp(), keyHelp).Else("") 155 | pageInfo := msgType.Pagination.ToString(help) 156 | return messages.Post(messages.ShowTipsRequest{Text: pageInfo}) 157 | } 158 | 159 | return nil 160 | } 161 | 162 | func (m *Model) renderTabs() string { 163 | var ( 164 | doc strings.Builder 165 | renderedTabs []string 166 | tabs = config.G.GetNodes() 167 | ) 168 | 169 | for i, t := range tabs { 170 | var style lipgloss.Style 171 | isFirst, isLast, isActive := i == 0, i == len(tabs)-1, i == config.G.ActiveTab 172 | if isActive { 173 | style = activeTabStyle 174 | } else { 175 | style = inactiveTabStyle 176 | } 177 | border, _, _, _, _ := style.GetBorder() 178 | if isFirst && isActive { 179 | border.BottomLeft = "│" 180 | } else if isFirst { 181 | border.BottomLeft = "├" 182 | } else if isLast && isActive { 183 | border.BottomRight = "│" 184 | } else if isLast { 185 | border.BottomRight = "┤" 186 | } 187 | style = style.Border(border) 188 | renderedTabs = append(renderedTabs, style.Render(lo.ValueOr(config.NodeMap, t, t))) 189 | } 190 | 191 | row := lipgloss.JoinHorizontal(lipgloss.Top, renderedTabs...) 192 | doc.WriteString(row) 193 | doc.WriteString("\n") 194 | return doc.String() 195 | } 196 | 197 | func (m *Model) renderTables() string { 198 | if len(m.topics) == 0 { 199 | return "" 200 | } 201 | // 表格 202 | var ( 203 | rows [][]string 204 | columnWidth = []int{ 205 | 3, // 序号 206 | 0, 207 | 0, 208 | 0, 209 | 7, // 回复数 210 | 20, // 时间 211 | } 212 | ) 213 | for i, topic := range m.topics { 214 | 215 | // 设置列自适应宽度 216 | nodeTitle := lo.ValueOr(config.NodeMap, topic.Node, topic.Node) 217 | if len(nodeTitle) > columnWidth[1] { 218 | // lipgloss.Width 处理中文, len 处理空格 219 | columnWidth[1] = max(lipgloss.Width(nodeTitle), len(nodeTitle)) 220 | } 221 | if len(topic.Member) > columnWidth[3] { 222 | columnWidth[3] = max(lipgloss.Width(topic.Member), len(topic.Member)) 223 | } 224 | 225 | rows = append( 226 | rows, []string{ 227 | strconv.Itoa(i + 1), 228 | nodeTitle, 229 | topic.Title, 230 | topic.Member, 231 | strconv.Itoa(topic.Replies), 232 | carbon.CreateFromTimestamp(topic.LastTouched).String(), 233 | }, 234 | ) 235 | } 236 | 237 | // len(tableStyles) + 1 = 列数 (再 +1 等于边框数) 238 | titleWidth := config.Screen.Width - (len(columnWidth) + 1 + 1) - lo.Sum(columnWidth) 239 | t := table.New(). 240 | Border(lipgloss.RoundedBorder()). 241 | BorderStyle(lipgloss.NewStyle()). 242 | StyleFunc( 243 | func(row, col int) lipgloss.Style { 244 | if row == table.HeaderRow { 245 | return headerStyle 246 | } 247 | 248 | style := cellStyle 249 | if col == 2 { 250 | style = lipgloss.NewStyle().Width(titleWidth) 251 | } else if col < len(columnWidth) { 252 | style = lipgloss.NewStyle().Width(columnWidth[col]) 253 | } 254 | 255 | if row == config.Session.TopicActiveIndex { 256 | style = style.Foreground(lipgloss.Color("#1e9fff")).Bold(true) 257 | rows[row][0] = "*" 258 | } 259 | 260 | return style 261 | }, 262 | ). 263 | Headers("#", "节点", "标题", "member", "评论数", "最后回复时间"). 264 | Rows(rows...) 265 | return t.String() 266 | } 267 | 268 | func tabBorderWithBottom(left, middle, right string) lipgloss.Border { 269 | border := lipgloss.RoundedBorder() 270 | border.BottomLeft = left 271 | border.Bottom = middle 272 | border.BottomRight = right 273 | return border 274 | } 275 | -------------------------------------------------------------------------------- /internal/ui/messages/detail.go: -------------------------------------------------------------------------------- 1 | package messages 2 | 3 | import "github.com/seth-shi/go-v2ex/internal/types" 4 | 5 | type GetDetailRequest struct { 6 | ID int64 7 | } 8 | 9 | type GetDetailResult struct { 10 | Detail types.V2DetailResult 11 | } 12 | -------------------------------------------------------------------------------- /internal/ui/messages/error.go: -------------------------------------------------------------------------------- 1 | package messages 2 | 3 | type ShiftErrorRequest struct { 4 | } 5 | -------------------------------------------------------------------------------- /internal/ui/messages/get_topics.go: -------------------------------------------------------------------------------- 1 | package messages 2 | 3 | import ( 4 | "github.com/seth-shi/go-v2ex/internal/types" 5 | ) 6 | 7 | type GetTopicsRequest struct { 8 | Page int 9 | } 10 | 11 | type GetTopicsResult struct { 12 | Topics []types.TopicComResult 13 | Pagination types.Pagination 14 | // 监听者需要处理请求回调 (请求拦截) 15 | Error error 16 | } 17 | -------------------------------------------------------------------------------- /internal/ui/messages/init.go: -------------------------------------------------------------------------------- 1 | package messages 2 | 3 | type LoadConfigResult struct { 4 | Error error 5 | } 6 | -------------------------------------------------------------------------------- /internal/ui/messages/loading.go: -------------------------------------------------------------------------------- 1 | package messages 2 | 3 | import ( 4 | "sync/atomic" 5 | ) 6 | 7 | var ( 8 | lastLoadingId int64 9 | LoadingGetToken = newLoadingKey("获取 token 信息中") 10 | LoadingRequestTopics = newLoadingKey("获取主题中") 11 | LoadingRequestDetail = newLoadingKey("获取内容中") 12 | LoadingRequestReply = newLoadingKey("获取评论中") 13 | ) 14 | 15 | func newLoadingKey(text string) loadingCombine { 16 | id := nextLoadingId() 17 | return loadingCombine{ 18 | Start: StartLoading{ 19 | ID: id, 20 | Text: text, 21 | }, 22 | End: EndLoading{ 23 | ID: id, 24 | }, 25 | } 26 | } 27 | 28 | type StartLoading struct { 29 | Text string 30 | ID int 31 | } 32 | 33 | type EndLoading struct { 34 | ID int 35 | } 36 | 37 | func nextLoadingId() int { 38 | return int(atomic.AddInt64(&lastLoadingId, 1)) 39 | } 40 | 41 | type loadingCombine struct { 42 | Start StartLoading 43 | End EndLoading 44 | } 45 | -------------------------------------------------------------------------------- /internal/ui/messages/post.go: -------------------------------------------------------------------------------- 1 | package messages 2 | 3 | import ( 4 | tea "github.com/charmbracelet/bubbletea" 5 | ) 6 | 7 | func Post(msg tea.Msg) tea.Cmd { 8 | 9 | if msg == nil { 10 | return nil 11 | } 12 | 13 | return func() tea.Msg { 14 | return msg 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /internal/ui/messages/redirect.go: -------------------------------------------------------------------------------- 1 | package messages 2 | 3 | import tea "github.com/charmbracelet/bubbletea" 4 | 5 | type RedirectPageRequest struct { 6 | ContentModel tea.Model 7 | } 8 | 9 | type RedirectDetailRequest struct { 10 | Id int64 11 | } 12 | 13 | type RedirectTopicsPage struct { 14 | Page int 15 | } 16 | -------------------------------------------------------------------------------- /internal/ui/messages/replies.go: -------------------------------------------------------------------------------- 1 | package messages 2 | 3 | import "github.com/seth-shi/go-v2ex/internal/types" 4 | 5 | type GetRepliesResult struct { 6 | Replies []types.V2ReplyResult 7 | Pagination types.Pagination 8 | Error error 9 | } 10 | -------------------------------------------------------------------------------- /internal/ui/messages/tips.go: -------------------------------------------------------------------------------- 1 | package messages 2 | 3 | type ShowTipsRequest struct { 4 | Text string 5 | } 6 | 7 | type ShowAutoTipsRequest struct { 8 | Text string 9 | } 10 | 11 | type ShiftAutoTipsRequest struct { 12 | Text string 13 | } 14 | -------------------------------------------------------------------------------- /internal/ui/routes/routes.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import ( 4 | "github.com/seth-shi/go-v2ex/internal/ui/components/boss" 5 | "github.com/seth-shi/go-v2ex/internal/ui/components/detail" 6 | "github.com/seth-shi/go-v2ex/internal/ui/components/help" 7 | "github.com/seth-shi/go-v2ex/internal/ui/components/setting" 8 | "github.com/seth-shi/go-v2ex/internal/ui/components/splash" 9 | "github.com/seth-shi/go-v2ex/internal/ui/components/topics" 10 | ) 11 | 12 | var ( 13 | HelpModel = help.New() 14 | SettingModel = setting.New() 15 | TopicsModel = topics.New() 16 | SplashModel = splash.New() 17 | BossComingModel = boss.New() 18 | DetailModel = detail.New() 19 | ) 20 | -------------------------------------------------------------------------------- /internal/ui/styles/bold.go: -------------------------------------------------------------------------------- 1 | package styles 2 | 3 | import ( 4 | "github.com/charmbracelet/lipgloss" 5 | ) 6 | 7 | var ( 8 | Bold = lipgloss.NewStyle().Bold(true) 9 | ) 10 | -------------------------------------------------------------------------------- /internal/ui/styles/error.go: -------------------------------------------------------------------------------- 1 | package styles 2 | 3 | import ( 4 | "github.com/charmbracelet/lipgloss" 5 | ) 6 | 7 | var ( 8 | Err = lipgloss.NewStyle().Foreground(lipgloss.Color("#ff5722")) 9 | ) 10 | -------------------------------------------------------------------------------- /internal/ui/styles/hint.go: -------------------------------------------------------------------------------- 1 | package styles 2 | 3 | import ( 4 | "github.com/charmbracelet/lipgloss" 5 | ) 6 | 7 | var ( 8 | Hint = lipgloss.NewStyle().Foreground(lipgloss.Color("#999999")) 9 | ) 10 | -------------------------------------------------------------------------------- /internal/ui/ui.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "reflect" 5 | "strings" 6 | 7 | "github.com/seth-shi/go-v2ex/internal/config" 8 | 9 | "github.com/charmbracelet/bubbles/key" 10 | tea "github.com/charmbracelet/bubbletea" 11 | "github.com/charmbracelet/lipgloss" 12 | "github.com/seth-shi/go-v2ex/internal/api" 13 | "github.com/seth-shi/go-v2ex/internal/consts" 14 | "github.com/seth-shi/go-v2ex/internal/ui/components/footer" 15 | "github.com/seth-shi/go-v2ex/internal/ui/messages" 16 | "github.com/seth-shi/go-v2ex/internal/ui/routes" 17 | ) 18 | 19 | type Model struct { 20 | contentModel tea.Model 21 | footerModel tea.Model 22 | } 23 | 24 | func NewModel() Model { 25 | return Model{ 26 | contentModel: routes.SplashModel, 27 | footerModel: footer.New(), 28 | } 29 | } 30 | 31 | func (m Model) Init() tea.Cmd { 32 | return tea.Batch( 33 | tea.EnterAltScreen, 34 | // 加载配置 35 | config.LoadFileConfig, 36 | // 其它不要用 init 初始化, 使用消息去刷新 37 | m.contentModel.Init(), 38 | m.footerModel.Init(), 39 | ) 40 | } 41 | 42 | func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 43 | 44 | switch msgType := msg.(type) { 45 | // 全局监听 46 | case tea.WindowSizeMsg: 47 | config.Screen.Width = msgType.Width 48 | config.Screen.Height = msgType.Height 49 | case messages.LoadConfigResult: 50 | return m, m.initHomePage(msgType.Error) 51 | case messages.RedirectPageRequest: 52 | // 切换页面 53 | m.contentModel = msgType.ContentModel 54 | return m, tea.Sequence(messages.Post(messages.ShowTipsRequest{Text: ""})) 55 | case messages.GetTopicsResult: 56 | // 缓存这个列表, 进到详情页回来还有数据, 并且消息传递给子级 57 | routes.TopicsModel.SetTopics(msgType.Topics) 58 | case messages.RedirectDetailRequest: 59 | return m, tea.Sequence( 60 | messages.Post(messages.RedirectPageRequest{ContentModel: routes.DetailModel}), 61 | messages.Post(messages.GetDetailRequest{ID: msgType.Id}), 62 | ) 63 | case messages.RedirectTopicsPage: 64 | var cmds = []tea.Cmd{ 65 | messages.Post(messages.RedirectPageRequest{ContentModel: routes.TopicsModel}), 66 | } 67 | if msgType.Page > 0 { 68 | cmds = append(cmds, messages.Post(messages.GetTopicsRequest{Page: msgType.Page})) 69 | } 70 | return m, tea.Sequence(cmds...) 71 | case tea.KeyMsg: 72 | switch { 73 | case key.Matches(msgType, consts.AppKeyMap.Space): 74 | config.Session.BossComingMode = !config.Session.BossComingMode 75 | return m, m.returnPage(routes.BossComingModel) 76 | case key.Matches(msgType, consts.AppKeyMap.SettingPage): 77 | return m, m.returnPage(routes.SettingModel) 78 | case key.Matches(msgType, consts.AppKeyMap.HelpPage): 79 | return m, m.returnPage(routes.HelpModel) 80 | case key.Matches(msgType, consts.AppKeyMap.SwitchShowMode): 81 | config.G.SwitchShowMode() 82 | return m, tea.Batch( 83 | config.SaveToFile(""), 84 | messages.Post(messages.ShowTipsRequest{Text: ""}), 85 | messages.Post(messages.ShowAutoTipsRequest{Text: config.G.GetShowModeText()}), 86 | ) 87 | case key.Matches(msgType, consts.AppKeyMap.Quit): 88 | return m, tea.Quit 89 | } 90 | } 91 | 92 | // 更新当前的主要三个部分组件 93 | var ( 94 | cmds []tea.Cmd 95 | cmd tea.Cmd 96 | ) 97 | m.contentModel, cmd = m.contentModel.Update(msg) 98 | cmds = append(cmds, cmd) 99 | m.footerModel, cmd = m.footerModel.Update(msg) 100 | cmds = append(cmds, cmd) 101 | return m, tea.Batch(cmds...) 102 | } 103 | 104 | func (m Model) returnPage(contentModel tea.Model) tea.Cmd { 105 | if reflect.DeepEqual(m.contentModel, contentModel) { 106 | return m.initHomePage(nil) 107 | } 108 | return messages.Post(messages.RedirectPageRequest{ContentModel: contentModel}) 109 | } 110 | 111 | func (m Model) View() string { 112 | 113 | var ( 114 | output strings.Builder 115 | ) 116 | 117 | output.WriteString(m.contentModel.View()) 118 | 119 | // 底部增加一个 padding, 来固定在底部 120 | if !config.Session.BossComingMode { 121 | output.WriteRune('\n') 122 | ff := m.footerModel.View() 123 | paddingTop := config.Screen.Height - lipgloss.Height(output.String()) - lipgloss.Height(ff) 124 | output.WriteString(lipgloss.NewStyle().PaddingTop(paddingTop).Render(ff)) 125 | } 126 | 127 | return output.String() 128 | } 129 | 130 | func (m Model) initHomePage(err error) tea.Cmd { 131 | 132 | // 把配置注入到其他页面 133 | api.Client.RefreshConfig() 134 | routes.SettingModel.RefreshConfig() 135 | 136 | var cmds = []tea.Cmd{ 137 | // 读取配置文件有错误, 不影响后续流程, 可以让用户自己抉择 138 | messages.Post(err), 139 | } 140 | 141 | // 没 token 去配置页面 142 | if config.G.Token == "" { 143 | cmds = append( 144 | cmds, 145 | messages.Post(messages.RedirectPageRequest{ContentModel: routes.SettingModel}), 146 | messages.Post(messages.ShowAutoTipsRequest{Text: "请先按照说明配置秘钥和节点"}), 147 | ) 148 | return tea.Sequence(cmds...) 149 | } 150 | 151 | // 去触发对应的地方获取数据 152 | cmds = append( 153 | cmds, 154 | // 先跳转到主题页, 然后获取第一页的数据 155 | tea.Sequence( 156 | messages.Post(messages.RedirectPageRequest{ContentModel: routes.TopicsModel}), 157 | messages.Post(messages.GetTopicsRequest{Page: 1}), 158 | ), 159 | // 获取个人信息 160 | tea.Sequence( 161 | messages.Post(messages.LoadingGetToken.Start), api.Client.GetToken, 162 | messages.Post(messages.LoadingGetToken.End), 163 | ), 164 | ) 165 | return tea.Sequence(cmds...) 166 | } 167 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | tea "github.com/charmbracelet/bubbletea" 7 | "github.com/dromara/carbon/v2" 8 | "github.com/samber/lo" 9 | "github.com/seth-shi/go-v2ex/internal/ui" 10 | ) 11 | 12 | func init() { 13 | carbon.SetLayout(carbon.DateTimeLayout) 14 | carbon.SetTimezone(carbon.PRC) 15 | } 16 | func main() { 17 | 18 | if len(os.Getenv("DEBUG")) > 0 { 19 | f := lo.Must1(tea.LogToFile("debug.log", "debug")) 20 | defer f.Close() 21 | } 22 | 23 | lo.Must1(tea.NewProgram(ui.NewModel(), tea.WithMouseCellMotion()).Run()) 24 | } 25 | --------------------------------------------------------------------------------