diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index fc50338..8521962 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -1,4 +1,12 @@ -import {defineConfig} from 'vitepress' +import { defineConfig } from 'vitepress' +import { useSidebar } from 'vitepress-openapi' +import spec from './theme/openapi.json' with { type: 'json' } + + +const sidebar = useSidebar({ + spec, + linkPrefix: '/dev/api-docs/' +}) // https://vitepress.dev/reference/site-config export default defineConfig({ @@ -7,29 +15,46 @@ export default defineConfig({ themeConfig: { // https://vitepress.dev/reference/default-theme-config nav: [ - {text: '主页', link: '/'}, - {text: '使用', link: '/use/start'} + { text: '主页', link: '/' }, + { text: '使用', link: '/use/start' }, + { text: '开发', link: '/dev/start' }, ], - sidebar: [ - { + sidebar: { + 'use': [{ text: '使用', items: [ - {text: '开始', link: '/use/start'}, - {text: '默认行为', link: '/use/default-action'}, - {text: '用户系统', link: '/use/user-system'}, + { text: '开始', link: '/use/start' }, + { text: '默认行为', link: '/use/default_action' }, + { text: '用户系统', link: '/use/user_system' }, ] }, - { - text: '开发', - items: [ - {text: '后端响应', link: '/dev/backend-response'}, - ] - }, - ], + ], + + 'dev': [ + { + text: '开发', + items: [ + { text: '开始', link: '/dev/start' }, + { text: '后端响应', link: '/dev/backend_response' }, + { text: '邮件模板', link: '/dev/email_templates' }, + ] + }, + { + text: 'API 文档', + items: [ + { text: 'API', link: '/dev/api-docs/api' }, + ...sidebar.generateSidebarGroups({}), + ] + },] + }, socialLinks: [ - {icon: 'github', link: 'https://github.com/vuejs/vitepress'} + { icon: 'github', link: 'https://github.com/vuejs/vitepress' } ] + }, + + sitemap: { + hostname: 'https://docs.nyahome.cn' } }) diff --git a/docs/.vitepress/theme/custom.css b/docs/.vitepress/theme/custom.css index 3c1e6bb..fcf32cc 100644 --- a/docs/.vitepress/theme/custom.css +++ b/docs/.vitepress/theme/custom.css @@ -1,4 +1,51 @@ :root { --vp-c-brand-1: #64ffc4; --vp-c-brand-2: #9354ff; +} + +/* ===== 简约滚动条美化 ===== */ + +/* 适用于 Webkit 内核(Chrome/Edge/Safari) */ +::-webkit-scrollbar { + width: 6px; + /* 垂直滚动条宽度 */ + height: 6px; + /* 水平滚动条高度 */ +} + +::-webkit-scrollbar-track { + background: transparent; + /* 轨道透明,极简 */ +} + +::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.15); + /* 滑块半透明灰 */ + border-radius: 3px; + /* 小圆角 */ +} + +::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.25); + /* 悬停稍微深一点 */ +} + +/* Firefox 兼容 */ +* { + scrollbar-width: thin; + scrollbar-color: rgba(255, 255, 255, 0.15) transparent; +} + +/* vitepress-openapi */ +div.vitepress-openapi { + border: 1px solid #64ffc4; + border-radius: 6px; + margin: 48px auto 0; + padding: 6px 20px; + + text-align: center; +} + +div.vitepress-openapi p { + line-height: 8px; } \ No newline at end of file diff --git a/docs/.vitepress/theme/index.ts b/docs/.vitepress/theme/index.ts index 58570a7..4715a15 100644 --- a/docs/.vitepress/theme/index.ts +++ b/docs/.vitepress/theme/index.ts @@ -1,5 +1,26 @@ import DefaultTheme from 'vitepress/theme' +import { theme, useOpenapi } from 'vitepress-openapi/client' +import 'vitepress-openapi/dist/style.css' // @ts-ignore import './custom.css' +import { Theme } from 'vitepress' -export default DefaultTheme \ No newline at end of file +import spec from './openapi.json' with { type: 'json' } + +export default { + extends: DefaultTheme, + async enhanceApp({ app }) { + useOpenapi({ + spec, + config: { + codeSamples: { + defaultLang: 'python', + }, + server: { + allowCustomServer: true, + }, + } + }) + theme.enhanceApp({ app }) + } +} satisfies Theme diff --git a/docs/dev/api-docs/[operationId].md b/docs/dev/api-docs/[operationId].md new file mode 100644 index 0000000..5797980 --- /dev/null +++ b/docs/dev/api-docs/[operationId].md @@ -0,0 +1,29 @@ +--- +aside: false +outline: false +title: NyaHome API Docs +--- + + + + + + + API 文档是基于最新代码自动生成的 + 由 VitePress OpenAPI 提供文档支持 + + + \ No newline at end of file diff --git a/docs/dev/api-docs/[operationId].paths.js b/docs/dev/api-docs/[operationId].paths.js new file mode 100644 index 0000000..b95685d --- /dev/null +++ b/docs/dev/api-docs/[operationId].paths.js @@ -0,0 +1,17 @@ +import { usePaths } from 'vitepress-openapi' +import spec from '../../.vitepress/theme/openapi.json' with {type: 'json'} + +export default { + paths() { + return usePaths({ spec }) + .getPathsByVerbs() + .map(({ operationId, summary }) => { + return { + params: { + operationId, + pageTitle: `${summary} - NyaHome API`, + }, + } + }) + }, +} \ No newline at end of file diff --git a/package.json b/package.json index 95de645..d2b2971 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,12 @@ { + "type": "module", "devDependencies": { "mjml": "^5.2.2", - "vitepress": "2.0.0-alpha.17" + "vitepress": "2.0.0-alpha.17", + "vitepress-openapi": "^0.2.0" }, "scripts": { - "dev": "vitepress dev docs", + "dev": "vitepress dev docs --port 6173", "build": "vitepress build docs", "preview": "vitepress preview docs" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e62e0c9..cfa3c43 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: vitepress: specifier: 2.0.0-alpha.17 version: 2.0.0-alpha.17(postcss@8.5.15) + vitepress-openapi: + specifier: ^0.2.0 + version: 0.2.0(vitepress@2.0.0-alpha.17(postcss@8.5.15))(vue@3.5.34) packages: @@ -210,12 +213,30 @@ packages: cpu: [x64] os: [win32] + '@floating-ui/core@1.7.5': + resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} + + '@floating-ui/dom@1.7.6': + resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} + + '@floating-ui/utils@0.2.11': + resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + + '@floating-ui/vue@1.1.11': + resolution: {integrity: sha512-HzHKCNVxnGS35r9fCHBc3+uCnjw9IWIlCPL683cGgM9Kgj2BiAl8x1mS7vtvP6F9S/e/q4O6MApwSHj8hNLGfw==} + '@iconify-json/simple-icons@1.2.83': resolution: {integrity: sha512-6Pp9V++XisT9RKH7FB4RLPqUDzcmLtSma0ovOEIoEWGrXtHwBFsH7oN1z8vvCVCb95fb87QgR46/zRLyN9Y3kg==} '@iconify/types@2.0.0': resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} + '@internationalized/date@3.12.1': + resolution: {integrity: sha512-6IedsVWXyq4P9Tj+TxuU8WGWM70hYLl12nbYU8jkikVpa6WXapFazPUcHUMDMoWftIDE2ILDkFFte6W2nFCkRQ==} + + '@internationalized/number@3.6.6': + resolution: {integrity: sha512-iFgmQaXHE0vytNfpLZWOC2mEJCBRzcUxt53Xf/yCXG93lRvqas237i3r7X4RKMwO3txiyZD4mQjKAByFv6UGSQ==} + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -395,6 +416,17 @@ packages: '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@swc/helpers@0.5.21': + resolution: {integrity: sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==} + + '@tanstack/virtual-core@3.15.0': + resolution: {integrity: sha512-0AwPGx0I8QxPYjAxShT/+z+ZOe9u8mW5rsXvivCTjRfRmz9a43+3mRyi4wwlyoUqOC56q/jatKa0Bh9M99BEHQ==} + + '@tanstack/vue-virtual@3.13.25': + resolution: {integrity: sha512-/ez+t68a5O4CgVysvk7Bav0XbSYSYufOVHZveXF+DYO9hvtg2UheYzR0YkniCeUtXmMjDne1dDqwBMkOmEUOow==} + peerDependencies: + vue: ^2.7.0 || ^3.0.0 + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -556,6 +588,10 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -616,10 +652,17 @@ packages: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -719,6 +762,9 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -986,12 +1032,21 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lucide-vue-next@1.0.0: + resolution: {integrity: sha512-V6SPvx1IHTj/UY+FrIYWV5faISsPSb8BnWSFDxAtezWKvWc9ZZ40PDrdu1/Qb5vg4lHWr1hs1BAMGVGm6V1Xdg==} + deprecated: Package deprecated. Please use @lucide/vue instead. + peerDependencies: + vue: '>=3.0.1' + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} mark.js@8.11.1: resolution: {integrity: sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==} + markdown-it-link-attributes@4.0.1: + resolution: {integrity: sha512-pg5OK0jPLg62H4k7M9mRJLT61gUp9nvG0XveKYHMOOluASo9OEF13WlXrpAp2aj35LbedAy3QOCgQCw0tkLKAQ==} + mdast-util-to-hast@13.2.1: resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} @@ -1151,6 +1206,9 @@ packages: nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + ohash@2.0.11: + resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + oniguruma-parser@0.12.2: resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==} @@ -1403,6 +1461,11 @@ packages: regex@6.1.0: resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + reka-ui@2.9.8: + resolution: {integrity: sha512-7dxaBJ6nQ0zOQZXPV45219tTEgZPstmihBLS9ABPhSiPiJ8SiF0sacfZHFaBptS0v9N4tzsevq+8MNBpE4p5JQ==} + peerDependencies: + vue: '>= 3.4.0' + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -1497,6 +1560,9 @@ packages: trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + undici@6.25.0: resolution: {integrity: sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==} engines: {node: '>=18.17'} @@ -1575,6 +1641,13 @@ packages: yaml: optional: true + vitepress-openapi@0.2.0: + resolution: {integrity: sha512-suCYD59HG0P+XKauEm2GayqeXiVqBnDfaltmwcqNS3zOUDgI/nDCN75z4zZqLGHLLw/YhryNko8LOykTaUGmtQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + peerDependencies: + vitepress: '>=1.0.0' + vue: ^3.0.0 + vitepress@2.0.0-alpha.17: resolution: {integrity: sha512-Z3VPUpwk/bHYqt1uMVOOK1/4xFiWQov1GNc2FvMdz6kvje4JRXEOngVI9C+bi5jeedMSHiA4dwKkff1NCvbZ9Q==} hasBin: true @@ -1590,6 +1663,17 @@ packages: postcss: optional: true + vue-demi@0.14.10: + resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==} + engines: {node: '>=12'} + hasBin: true + peerDependencies: + '@vue/composition-api': ^1.0.0-rc.1 + vue: ^3.0.0-0 || ^2.6.0 + peerDependenciesMeta: + '@vue/composition-api': + optional: true + vue@3.5.34: resolution: {integrity: sha512-WdLBG9gm02OgJIG9axd5Hpx0TFLdzVgfG2evFFu8Rur5O/IoGc5cMjnjh3tPL6GnRGsYvUhBSKVPYVcxRKpMCA==} peerDependencies: @@ -1748,12 +1832,40 @@ snapshots: '@esbuild/win32-x64@0.27.7': optional: true + '@floating-ui/core@1.7.5': + dependencies: + '@floating-ui/utils': 0.2.11 + + '@floating-ui/dom@1.7.6': + dependencies: + '@floating-ui/core': 1.7.5 + '@floating-ui/utils': 0.2.11 + + '@floating-ui/utils@0.2.11': {} + + '@floating-ui/vue@1.1.11(vue@3.5.34)': + dependencies: + '@floating-ui/dom': 1.7.6 + '@floating-ui/utils': 0.2.11 + vue-demi: 0.14.10(vue@3.5.34) + transitivePeerDependencies: + - '@vue/composition-api' + - vue + '@iconify-json/simple-icons@1.2.83': dependencies: '@iconify/types': 2.0.0 '@iconify/types@2.0.0': {} + '@internationalized/date@3.12.1': + dependencies: + '@swc/helpers': 0.5.21 + + '@internationalized/number@3.6.6': + dependencies: + '@swc/helpers': 0.5.21 + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -1885,6 +1997,17 @@ snapshots: '@shikijs/vscode-textmate@10.0.2': {} + '@swc/helpers@0.5.21': + dependencies: + tslib: 2.8.1 + + '@tanstack/virtual-core@3.15.0': {} + + '@tanstack/vue-virtual@3.13.25(vue@3.5.34)': + dependencies: + '@tanstack/virtual-core': 3.15.0 + vue: 3.5.34 + '@types/estree@1.0.8': {} '@types/hast@3.0.4': @@ -2027,6 +2150,10 @@ snapshots: argparse@2.0.1: {} + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + balanced-match@1.0.2: {} baseline-browser-mapping@2.10.31: {} @@ -2105,12 +2232,18 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + cliui@8.0.1: dependencies: string-width: 4.2.3 strip-ansi: 6.0.1 wrap-ansi: 7.0.0 + clsx@2.1.1: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -2229,6 +2362,8 @@ snapshots: csstype@3.2.3: {} + defu@6.1.7: {} + dequal@2.0.3: {} detect-node@2.1.0: {} @@ -2502,12 +2637,18 @@ snapshots: lru-cache@10.4.3: {} + lucide-vue-next@1.0.0(vue@3.5.34): + dependencies: + vue: 3.5.34 + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 mark.js@8.11.1: {} + markdown-it-link-attributes@4.0.1: {} + mdast-util-to-hast@13.2.1: dependencies: '@types/hast': 3.0.4 @@ -3026,6 +3167,8 @@ snapshots: dependencies: boolbase: 1.0.0 + ohash@2.0.11: {} + oniguruma-parser@0.12.2: {} oniguruma-to-es@4.3.6: @@ -3270,6 +3413,22 @@ snapshots: dependencies: regex-utilities: 2.3.0 + reka-ui@2.9.8(vue@3.5.34): + dependencies: + '@floating-ui/dom': 1.7.6 + '@floating-ui/vue': 1.1.11(vue@3.5.34) + '@internationalized/date': 3.12.1 + '@internationalized/number': 3.6.6 + '@tanstack/vue-virtual': 3.13.25(vue@3.5.34) + '@vueuse/core': 14.3.0(vue@3.5.34) + '@vueuse/shared': 14.3.0(vue@3.5.34) + aria-hidden: 1.2.6 + defu: 6.1.7 + ohash: 2.0.11 + vue: 3.5.34 + transitivePeerDependencies: + - '@vue/composition-api' + require-directory@2.1.1: {} resolve-from@4.0.0: {} @@ -3390,6 +3549,8 @@ snapshots: trim-lines@3.0.1: {} + tslib@2.8.1: {} + undici@6.25.0: {} unist-util-is@6.0.1: @@ -3446,6 +3607,19 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + vitepress-openapi@0.2.0(vitepress@2.0.0-alpha.17(postcss@8.5.15))(vue@3.5.34): + dependencies: + '@vueuse/core': 14.3.0(vue@3.5.34) + class-variance-authority: 0.7.1 + clsx: 2.1.1 + lucide-vue-next: 1.0.0(vue@3.5.34) + markdown-it-link-attributes: 4.0.1 + reka-ui: 2.9.8(vue@3.5.34) + vitepress: 2.0.0-alpha.17(postcss@8.5.15) + vue: 3.5.34 + transitivePeerDependencies: + - '@vue/composition-api' + vitepress@2.0.0-alpha.17(postcss@8.5.15): dependencies: '@docsearch/css': 4.6.3 @@ -3494,6 +3668,10 @@ snapshots: - universal-cookie - yaml + vue-demi@0.14.10(vue@3.5.34): + dependencies: + vue: 3.5.34 + vue@3.5.34: dependencies: '@vue/compiler-dom': 3.5.34 diff --git a/pyproject.toml b/pyproject.toml index b0b4949..ae68265 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ dependencies = [ [dependency-groups] dev = [ + "docstring-parser>=0.18.0", "mypy>=2.1.0", "ruff>=0.15.14", "taskipy>=1.14.1", @@ -110,3 +111,5 @@ migrate = "alembic upgrade head" rollback = "alembic downgrade -1" # 查看数据库版本 current = "alembic current" +# 为 docs 文档生成最新 openapi.json +openapi-docs = "nyahome openapi docs/.vitepress/theme/openapi.json" diff --git a/src/nyahome/cli/openapi_docstring.py b/src/nyahome/cli/openapi_docstring.py new file mode 100644 index 0000000..467b655 --- /dev/null +++ b/src/nyahome/cli/openapi_docstring.py @@ -0,0 +1,375 @@ +""" +FastAPI Docstring-to-OpenAPI Enricher +===================================== + +自动解析 Google Style docstring,将 Args / Returns / Raises 注入到 OpenAPI Schema 的对应位置, +让 vitepress-openapi 等工具能够正确渲染参数表格和返回值说明。 + +功能特性 +-------- +1. 参数描述注入:路径参数、查询参数、Header、Cookie、请求体字段 +2. 返回值描述注入:自动写入 2xx 响应的 description 及 schema.description +3. 异常描述注入:以 Markdown 格式附加到 operation description +4. 嵌套 Schema 递归处理:支持 Pydantic v1/v2 的嵌套模型、List、Optional 等 +5. \f 截断兼容:与 FastAPI 原生行为保持一致 +6. 全局 Schema 补全:为 components/schemas 中缺少描述的字段补充 docstring 说明 +7. 零侵入:只需替换 ``app.openapi``,业务代码无需任何修改 + +依赖安装 +-------- + pip install docstring-parser + +使用方式 +-------- + from fastapi import FastAPI + from fastapi_docstring_openapi import enrich_openapi_from_docstrings + + app = FastAPI() + enrich_openapi_from_docstrings(app) + + # 你的路由注册... + +完整示例见文件底部 ``if __name__ == "__main__":`` 块。 +""" + +from __future__ import annotations + +import inspect +import logging +from typing import Any, Dict, Optional, Set + +from docstring_parser import DocstringStyle, ParseError, parse +from fastapi import FastAPI +from fastapi.openapi.utils import get_openapi + +logger = logging.getLogger(__name__) + + +def _parse_docstring( + func: Any, style: DocstringStyle = DocstringStyle.AUTO +) -> Optional[Any]: + """解析函数的 Google Style docstring,失败时返回 None。""" + doc = inspect.getdoc(func) + if not doc: + return None + if "\f" in doc: + doc = doc.split("\f")[0].strip() + try: + return parse(doc, style=style) + except ParseError: + return None + except Exception: + logger.warning( + "Unexpected error parsing docstring for %s", + getattr(func, "__name__", func), + exc_info=True, + ) + return None + + +def _build_param_lookup(parsed_doc: Any) -> Dict[str, str]: + """从解析后的 docstring 构建 {参数名: 描述} 映射。""" + lookup: Dict[str, str] = {} + for param in getattr(parsed_doc, "params", []) or []: + name = getattr(param, "arg_name", None) + desc = getattr(param, "description", None) or "" + if name: + # 合并多行描述并清理缩进 + lookup[name] = _dedent_description(desc) + return lookup + + +def _build_returns_description(parsed_doc: Any) -> Optional[str]: + """从解析后的 docstring 构建返回值描述。""" + ret = getattr(parsed_doc, "returns", None) + if not ret: + return None + type_name = getattr(ret, "type_name", None) or "" + desc = getattr(ret, "description", None) or "" + if type_name and desc: + return f"**{type_name}**: {desc}" + return desc or None + + +def _build_raises_description(parsed_doc: Any) -> Optional[str]: + """从解析后的 docstring 构建异常描述(Markdown 列表)。""" + raises = getattr(parsed_doc, "raises", None) + if not raises: + return None + parts: list[str] = [] + for exc in raises: + type_name = getattr(exc, "type_name", None) or "" + desc = getattr(exc, "description", None) or "" + if type_name and desc: + parts.append(f"- **{type_name}**: {desc}") + elif type_name: + parts.append(f"- **{type_name}**") + elif desc: + parts.append(f"- {desc}") + return "\n".join(parts) if parts else None + + +def _dedent_description(text: str) -> str: + """清理 docstring 描述中的多余缩进,保留段落结构。""" + if not text: + return "" + lines = text.splitlines() + # 找到最小公共缩进(排除空行) + min_indent = min( + (len(line) - len(line.lstrip()) for line in lines if line.strip()), + default=0, + ) + cleaned = [line[min_indent:] if line.strip() else "" for line in lines] + return "\n".join(cleaned).strip() + + +def _get_clean_operation_description(parsed_doc: Any) -> str: + """ + 生成干净的 operation description。 + + 策略:保留 summary + long_description,去掉 Args/Returns/Raises 等机器块。 + """ + parts: list[str] = [] + short = getattr(parsed_doc, "short_description", None) + long_ = getattr(parsed_doc, "long_description", None) + + if short: + parts.append(short) + if long_: + parts.append(long_) + + return "\n\n".join(parts).strip() + + +def _inject_schema_properties( + schema: Dict[str, Any], + param_lookup: Dict[str, str], + visited_refs: Optional[Set[str]] = None, + openapi_schema: Optional[Dict[str, Any]] = None, +) -> None: + """ + 递归注入 schema properties 的描述。 + + 支持对象、数组、allOf/anyOf/oneOf、$ref 引用(全局 components/schemas 补全)。 + """ + if visited_refs is None: + visited_refs = set() + if not isinstance(schema, dict): + return + + # 处理 $ref:在 components/schemas 中查找并补全 + ref = schema.get("$ref") + if ref and openapi_schema: + if ref not in visited_refs: + visited_refs.add(ref) + # 提取 schema 名,例如 "#/components/schemas/User" -> "User" + schema_name = ref.split("/")[-1] + components = openapi_schema.get("components", {}).get("schemas", {}) + target = components.get(schema_name) + if target: + _inject_schema_properties(target, param_lookup, visited_refs, openapi_schema) + return + + # 处理 properties(对象字段) + properties = schema.get("properties") + if isinstance(properties, dict): + for prop_name, prop_schema in properties.items(): + if prop_name in param_lookup: + # 只有当 docstring 描述非空时才写入 + if param_lookup[prop_name]: + prop_schema["description"] = param_lookup[prop_name] + # 递归处理子 schema + _inject_schema_properties(prop_schema, param_lookup, visited_refs, openapi_schema) + + # 处理 items(数组类型) + items = schema.get("items") + if items: + _inject_schema_properties(items, param_lookup, visited_refs, openapi_schema) + + # 处理 allOf / anyOf / oneOf + for key in ("allOf", "anyOf", "oneOf"): + for sub in schema.get(key, []): + _inject_schema_properties(sub, param_lookup, visited_refs, openapi_schema) + + # 处理 additionalProperties + additional = schema.get("additionalProperties") + if isinstance(additional, dict): + _inject_schema_properties(additional, param_lookup, visited_refs, openapi_schema) + + +def enrich_openapi_from_docstrings( + app: FastAPI, + *, + prefer_docstring_over_field: bool = True, + append_raises_to_description: bool = True, + docstring_style: DocstringStyle = DocstringStyle.AUTO, +) -> None: + """ + 为 FastAPI 应用启用 docstring 驱动的 OpenAPI 增强。 + + 参数 + ---- + app : FastAPI + 目标应用实例。 + prefer_docstring_over_field : bool, 默认 True + 当 docstring 中的参数描述与 Pydantic Field(description=...) 冲突时, + 是否优先使用 docstring 的描述。 + append_raises_to_description : bool, 默认 True + 是否将 Raises 段落以 Markdown 格式追加到 operation description。 + docstring_style : DocstringStyle, 默认 AUTO + 解析风格。团队统一用 Google 时可显式传入 ``DocstringStyle.GOOGLE``。 + """ + + # 保存原始的 openapi 函数(如果有) + original_openapi = app.openapi + + def custom_openapi() -> Dict[str, Any]: + # 如果已经有缓存,直接返回 + if app.openapi_schema: + return app.openapi_schema + + # 生成基础 schema + openapi_schema = get_openapi( + title=app.title, + version=app.version, + openapi_version=app.openapi_version, + description=app.description, + routes=app.routes, + ) + + for route in app.routes: + if not hasattr(route, "endpoint") or not route.endpoint: + continue + + path = getattr(route, "path", None) + methods = [m.lower() for m in route.methods] if hasattr(route, "methods") else [] + if not path or not methods: + continue + + # 解析 docstring + parsed = _parse_docstring(route.endpoint, style=docstring_style) + if not parsed: + continue + + param_lookup = _build_param_lookup(parsed) + returns_desc = _build_returns_description(parsed) + raises_desc = _build_raises_description(parsed) + clean_desc = _get_clean_operation_description(parsed) + + for method in methods: + if method == "head": + # FastAPI 通常不把 HEAD 单独写入 OpenAPI,或者与 GET 共享 + continue + if method not in openapi_schema.get("paths", {}).get(path, {}): + continue + + op = openapi_schema["paths"][path][method] + + # ---------- 1. 更新 operation description ---------- + if clean_desc: + op["description"] = clean_desc + + # ---------- 2. 注入参数描述(路径/查询/Header/Cookie)---------- + for param in op.get("parameters", []): + param_name = param.get("name") + if param_name and param_name in param_lookup: + existing = param.get("description", "") + new_desc = param_lookup[param_name] + if new_desc and (prefer_docstring_over_field or not existing): + param["description"] = new_desc + + # ---------- 3. 注入请求体 schema 字段描述 ---------- + if "requestBody" in op: + content = op["requestBody"].get("content", {}) + for media_obj in content.values(): + schema = media_obj.get("schema", {}) + if schema: + _inject_schema_properties( + schema, param_lookup, openapi_schema=openapi_schema + ) + + # ---------- 4. 注入返回值描述 ---------- + if returns_desc: + for code, resp in op.get("responses", {}).items(): + if code.startswith("2"): # 2xx 成功响应 + # 更新响应级 description + resp["description"] = returns_desc + # 同时注入到响应 schema 的 description(如果存在) + for media_obj in resp.get("content", {}).values(): + schema = media_obj.get("schema", {}) + if schema and not schema.get("description"): + schema["description"] = returns_desc + # 递归注入 schema 内部字段 + _inject_schema_properties( + schema, param_lookup, openapi_schema=openapi_schema + ) + + # ---------- 5. 异常描述追加 ---------- + if append_raises_to_description and raises_desc: + current_desc = op.get("description", "") + raises_md = f"## 异常\n\n{raises_desc}" + # 避免重复追加 + if raises_md not in current_desc: + op["description"] = f"{current_desc}\n\n{raises_md}".strip() + + app.openapi_schema = openapi_schema + return app.openapi_schema + + app.openapi = custom_openapi + + +# ============================================================================= +# 完整使用示例 +# ============================================================================= +if __name__ == "__main__": + import json + from typing import Annotated, Optional + + from fastapi import FastAPI, Path, Query + from pydantic import BaseModel, Field + + # ---------- 定义 DTO ---------- + class EditChatDto(BaseModel): + old_message: str = Field(..., description="原始消息内容") + new_message: str = Field(..., description="修改后的消息内容") + change: str = Field(..., description="变更类型") + + class ReturnDto(BaseModel): + result: str = Field(..., description="操作结果") + content: str = Field(..., description="最新聊天记录内容") + + # ---------- 创建应用 ---------- + app = FastAPI(title="Chatroom API", version="1.0.0") + + # 启用 docstring 增强(必须在注册路由之前或之后都可以,但要在生成 schema 之前) + enrich_openapi_from_docstrings(app) + + # ---------- 注册路由 ---------- + @app.post("/api/chatroom/{id_}/chat/edit/", response_model=ReturnDto) + async def edit_chatroom_chat( + id_: Annotated[str, Path(description="聊天室 ID")], + body: EditChatDto, + force: Annotated[Optional[bool], Query(description="是否强制覆盖")] = None, + ) -> ReturnDto: + """编辑聊天室消息。 + + 此端点不负责调用 AI 生成输出,而是用于修改一条已经保存在聊天记录中的消息。 + 前端调用后应使用返回的 content 刷新当前聊天室界面。 + + Args: + id_: 聊天室唯一标识符,UUID 格式。 + body: 编辑请求体,包含旧消息、新消息和变更类型。 + force: 是否跳过冲突检测直接覆盖。 + + Returns: + ReturnDto: 操作结果,其中 result 字段表示状态,content 字段为最新聊天记录。 + + Raises: + HTTPException: 404 表明未找到聊天室。 + HTTPException: 400 表明聊天记录匹配失败,未更新。 + """ + return ReturnDto(result="ok", content="new content") + + # ---------- 输出生成的 OpenAPI schema ---------- + schema = app.openapi() + print(json.dumps(schema, indent=2, ensure_ascii=False)) \ No newline at end of file diff --git a/src/nyahome/manage.py b/src/nyahome/manage.py index 470fc25..84d1e3f 100644 --- a/src/nyahome/manage.py +++ b/src/nyahome/manage.py @@ -3,6 +3,8 @@ 避免在此文件中引用 router 和 service 模块内的代码。 """ +from typing import Annotated + import typer from rich.console import Console @@ -54,5 +56,18 @@ def run() -> None: ) +@app.command() +def openapi( + path: Annotated[str, typer.Argument(help="导出的 json 格式 openapi.json 应该保存为……")] = "openapi.json", +) -> None: + """ + 根据代码导出 NyaHome 的 openapi.json 。 + """ + from nyahome.server import save_openapi_json + + save_openapi_json(path) + console.print(f"[cyan]已经保存 openapi.json 到 {path} 。[/cyan]") + + if __name__ == "__main__": app() diff --git a/src/nyahome/server.py b/src/nyahome/server.py index 5bf6b93..b7565f9 100644 --- a/src/nyahome/server.py +++ b/src/nyahome/server.py @@ -1,4 +1,5 @@ import asyncio +import json import logging from contextlib import asynccontextmanager from pathlib import Path @@ -53,3 +54,15 @@ app.add_middleware( allow_methods=["*"], allow_headers=["*"], ) + + +def save_openapi_json(save_path: str | Path) -> None: + from docstring_parser import DocstringStyle + + from nyahome.cli.openapi_docstring import enrich_openapi_from_docstrings + + # 增强 openapi docstring 参数文档提取 + enrich_openapi_from_docstrings(app, docstring_style=DocstringStyle.GOOGLE) + + with open(save_path, "w") as f: + json.dump(app.openapi(), f, indent=2) diff --git a/uv.lock b/uv.lock index 62f28e1..2e05cec 100644 --- a/uv.lock +++ b/uv.lock @@ -355,6 +355,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, ] +[[package]] +name = "docstring-parser" +version = "0.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341, upload-time = "2026-04-14T04:09:19.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, +] + [[package]] name = "ecdsa" version = "0.19.2" @@ -740,6 +749,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "docstring-parser" }, { name = "mypy" }, { name = "ruff" }, { name = "taskipy" }, @@ -772,6 +782,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ + { name = "docstring-parser", specifier = ">=0.18.0" }, { name = "mypy", specifier = ">=2.1.0" }, { name = "ruff", specifier = ">=0.15.14" }, { name = "taskipy", specifier = ">=1.14.1" },
API 文档是基于最新代码自动生成的
由 VitePress OpenAPI 提供文档支持