From 7b1c087c00d1188044d72c92c1d986c066f4c8ad Mon Sep 17 00:00:00 2001 From: fonghehe <331002675@qq.com> Date: Wed, 2 Sep 2026 14:37:52 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=87=8D=E6=9E=84=E6=95=B4=E4=BD=93?= =?UTF-8?q?=E6=9E=B6=E6=9E=84=E5=B9=B6=E8=BF=81=E7=A7=BB=20oxlint/oxfmt=20?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E9=93=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 架构与目录: - 引入分层 API:transport/interceptors 收敛到 src/api/client.ts, 域名端点拆到 src/api/modules,页面不再直接依赖 Axios - 服务层(src/services)承载 AI 流式解析与框架无关的 provider - TanStack Query 接管服务端状态,Pinia 仅保留会话/主题等客户端状态 - 路由表显式懒加载,组件/组合式函数按职责拆分 功能: - AI 流式对话:fetchStreamProvider + useStreamingChat + 安全 Markdown 渲染 - 商店模块:商品列表/详情/购物车/后台管理,多语言(中/英/日) - 示例页:请求错误矩阵、query 缓存、移动端适配、图标演示 - 离线页、PWA、图片优化、eruda 调试等能力 工程化: - ESLint/Prettier 迁移到 oxlint/oxfmt,stylelint 保留 - 新增 oxlint.config.ts(移植自 monorepo 的 @vh5/oxlint-config 全套规则) - 新增 oxfmt.config.ts(移植自 @vh5/oxfmt-config,printWidth 80) - lint-staged、format、lint 脚本同步切换 - pnpm 作为唯一包管理器,删除 package-lock.json 与 yarn.lock - 引入 Vitest/Playwright/release-please,补充单元与组件测试 - OpenAPI schema 与生成类型入库 - 环境变量按 mode 拆分,重构 wrapperEnv 解析 清理: - 删除 .commitlintrc.js(CJS 配置在 type:module 下会崩溃),改用 commitlint.config.mjs - 删除 eslint.config.mjs、prettier.config.js、.prettierignore - 停止跟踪 .eslintrc-auto-import.json(已切 oxlint,无人引用) - 移除 iconfont、旧 demo/list 页与 useAxiosApi/useFetchApi 等遗留实现 - husky 钩子移除 DEPRECATED 的 shebang 写法 - .workbuddy/ 加入 .gitignore --- .commitlintrc.js | 91 - .env | 19 +- .env.development | 14 +- .env.example | 25 + .env.integration | 7 + .env.production | 12 +- .env.test | 9 +- .eslintrc-auto-import.json | 98 - .github/workflows/ci.yml | 35 + .github/workflows/release.yml | 18 + .gitignore | 14 +- .husky/commit-msg | 9 +- .husky/pre-commit | 6 +- .prettierignore | 10 - .release-please-manifest.json | 3 + .vscode/extensions.json | 4 +- AGENTS.md | 109 + CHANGELOG.md | 10 + README.md | 247 +- SECURITY.md | 7 + build/constant.ts | 11 - build/utils.ts | 59 +- build/vite/plugins/aiMock.ts | 90 + build/vite/plugins/autoImport.ts | 24 +- build/vite/plugins/component.ts | 16 +- build/vite/plugins/compress.ts | 2 +- build/vite/plugins/imageOptimizer.ts | 14 + build/vite/plugins/imagemin.ts | 37 - build/vite/plugins/index.ts | 75 +- build/vite/plugins/mock.ts | 2 +- build/vite/plugins/pages.ts | 14 - build/vite/plugins/pwa.ts | 49 +- build/vite/plugins/qrcode.ts | 10 - build/vite/plugins/restart.ts | 12 - build/vite/plugins/svgIcons.ts | 14 +- build/vite/plugins/visualizer.ts | 4 +- build/vite/proxy.ts | 44 +- commitlint.config.mjs | 1 + e2e/app.spec.ts | 160 + e2e/ui.spec.ts | 132 + eslint.config.mjs | 20 - index.html | 5 +- mock/index.ts | 758 +- nginx.conf | 7 +- openapi/schema.yaml | 784 + oxfmt.config.ts | 112 + oxlint.config.ts | 435 + package-lock.json | 23520 ------------------- package.json | 151 +- playwright.config.ts | 20 + pnpm-lock.yaml | 10120 +++----- pnpm-workspace.yaml | 7 + postcss.config.js | 34 - postcss.config.mjs | 7 + prettier.config.js | 18 - public/products/aurora-headphones.svg | 1 + public/products/mori-coffee.svg | 1 + public/products/nova-lamp.svg | 1 + public/products/product-placeholder.svg | 1 + public/products/trail-backpack.svg | 1 + release-please-config.json | 10 + src/App.vue | 14 +- src/api/client.ts | 158 + src/api/index.ts | 16 +- src/api/modules/auth.ts | 9 + src/api/modules/examples.ts | 25 + src/api/modules/products.ts | 62 + src/api/modules/projects.ts | 37 + src/api/modules/requestExamples.ts | 20 + src/api/modules/user.ts | 8 + src/assets/font/iconfont.css | 39 - src/assets/icons/ai.svg | 1 + src/assets/icons/cart.svg | 1 + src/assets/icons/examples.svg | 1 + src/assets/icons/home.svg | 1 + src/assets/icons/logo.svg | 1 + src/assets/icons/shop.svg | 1 + src/assets/icons/user.svg | 1 + src/components/ai/ChatComposer.vue | 118 + src/components/ai/SafeMarkdown.vue | 83 + src/components/auth/LoginForm.vue | 87 + src/components/common/AppErrorBoundary.vue | 60 + src/components/common/SvgIcon.vue | 23 + src/components/shop/ProductCard.vue | 201 + src/components/shop/ProductEditor.vue | 453 + src/composables/useNetworkStatus.ts | 21 + src/composables/usePullToRefresh.ts | 58 + src/composables/useStreamingChat.ts | 122 + src/composables/useVisualViewport.ts | 18 + src/layout/index.vue | 406 +- src/locales/index.ts | 28 +- src/locales/lang-base.ts | 16 +- src/locales/langs/en-US/common.json | 419 +- src/locales/langs/ja-JP/common.json | 421 + src/locales/langs/zh-CN/common.json | 413 +- src/main.ts | 43 +- src/plugins/query.ts | 30 + src/router/index.ts | 28 +- src/router/meta.d.ts | 16 + src/router/routes.ts | 150 +- src/services/ai/fetchStreamProvider.ts | 83 + src/services/ai/sse.ts | 62 + src/store/modules/cart.ts | 79 + src/store/modules/user.ts | 30 +- src/styles/index.scss | 399 +- src/styles/variable.scss | 18 +- src/types/ai.ts | 35 + src/types/api/common.ts | 63 + src/types/api/generated.d.ts | 866 + src/types/icon.ts | 8 + src/utils/markdown.ts | 21 + src/utils/product.ts | 34 + src/utils/request/index.ts | 79 +- src/utils/request/useAxiosApi.ts | 9 - src/utils/request/useFetchApi.ts | 41 - src/utils/url.ts | 12 + src/views/ai/chat.vue | 442 + src/views/demo/index.vue | 88 - src/views/examples/icons.vue | 75 + src/views/examples/index.vue | 164 + src/views/examples/mobile.vue | 124 + src/views/examples/projects.vue | 574 + src/views/examples/query.vue | 308 + src/views/examples/request.vue | 257 + src/views/home/index.vue | 282 +- src/views/list/data.ts | 59 - src/views/list/details/index.vue | 121 - src/views/list/index.vue | 33 - src/views/login/index.vue | 130 +- src/views/member/index.vue | 230 +- src/views/shop/admin.vue | 441 + src/views/shop/cart.vue | 338 + src/views/shop/detail.vue | 386 + src/views/shop/index.vue | 417 + src/views/system/offline.vue | 51 + src/views/ui/index.vue | 55 + src/views/ui/nutui.vue | 62 + src/views/ui/vant.vue | 63 + src/views/ui/varlet.vue | 58 + stylelint.config.js | 19 +- tests/api/client.test.ts | 83 + tests/api/modules.test.ts | 164 + tests/components/ChatComposer.test.ts | 19 + tests/components/LoginForm.test.ts | 35 + tests/composables/usePullToRefresh.test.ts | 48 + tests/composables/useStreamingChat.test.ts | 92 + tests/locales/i18n.test.ts | 25 + tests/router/routes.test.ts | 53 + tests/services/fetchStreamProvider.test.ts | 73 + tests/services/sse.test.ts | 57 + tests/setup.ts | 38 + tests/store/cart.test.ts | 44 + tests/store/user.test.ts | 28 + tests/utils/markdown.test.ts | 14 + tests/utils/product.test.ts | 35 + tests/utils/url.test.ts | 17 + tsconfig.app.json | 10 +- tsconfig.node.json | 7 +- types/auto-imports.d.ts | 110 + types/components.d.ts | 38 + types/global.d.ts | 120 +- types/index.d.ts | 27 - types/module.d.ts | 25 +- types/ui-demo.d.ts | 6 + types/utils.d.ts | 10 +- vite.config.mts | 118 +- vitest.config.ts | 32 + yarn.lock | 10533 --------- 168 files changed, 16979 insertions(+), 42637 deletions(-) delete mode 100644 .commitlintrc.js create mode 100644 .env.example create mode 100644 .env.integration delete mode 100644 .eslintrc-auto-import.json create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml delete mode 100644 .prettierignore create mode 100644 .release-please-manifest.json create mode 100644 AGENTS.md create mode 100644 CHANGELOG.md create mode 100644 SECURITY.md delete mode 100644 build/constant.ts create mode 100644 build/vite/plugins/aiMock.ts create mode 100644 build/vite/plugins/imageOptimizer.ts delete mode 100644 build/vite/plugins/imagemin.ts delete mode 100644 build/vite/plugins/pages.ts delete mode 100644 build/vite/plugins/qrcode.ts delete mode 100644 build/vite/plugins/restart.ts create mode 100644 commitlint.config.mjs create mode 100644 e2e/app.spec.ts create mode 100644 e2e/ui.spec.ts delete mode 100644 eslint.config.mjs create mode 100644 openapi/schema.yaml create mode 100644 oxfmt.config.ts create mode 100644 oxlint.config.ts delete mode 100644 package-lock.json create mode 100644 playwright.config.ts create mode 100644 pnpm-workspace.yaml delete mode 100644 postcss.config.js create mode 100644 postcss.config.mjs delete mode 100644 prettier.config.js create mode 100644 public/products/aurora-headphones.svg create mode 100644 public/products/mori-coffee.svg create mode 100644 public/products/nova-lamp.svg create mode 100644 public/products/product-placeholder.svg create mode 100644 public/products/trail-backpack.svg create mode 100644 release-please-config.json create mode 100644 src/api/client.ts create mode 100644 src/api/modules/auth.ts create mode 100644 src/api/modules/examples.ts create mode 100644 src/api/modules/products.ts create mode 100644 src/api/modules/projects.ts create mode 100644 src/api/modules/requestExamples.ts create mode 100644 src/api/modules/user.ts delete mode 100644 src/assets/font/iconfont.css create mode 100644 src/assets/icons/ai.svg create mode 100644 src/assets/icons/cart.svg create mode 100644 src/assets/icons/examples.svg create mode 100644 src/assets/icons/home.svg create mode 100644 src/assets/icons/logo.svg create mode 100644 src/assets/icons/shop.svg create mode 100644 src/assets/icons/user.svg create mode 100644 src/components/ai/ChatComposer.vue create mode 100644 src/components/ai/SafeMarkdown.vue create mode 100644 src/components/auth/LoginForm.vue create mode 100644 src/components/common/AppErrorBoundary.vue create mode 100644 src/components/common/SvgIcon.vue create mode 100644 src/components/shop/ProductCard.vue create mode 100644 src/components/shop/ProductEditor.vue create mode 100644 src/composables/useNetworkStatus.ts create mode 100644 src/composables/usePullToRefresh.ts create mode 100644 src/composables/useStreamingChat.ts create mode 100644 src/composables/useVisualViewport.ts create mode 100644 src/locales/langs/ja-JP/common.json create mode 100644 src/plugins/query.ts create mode 100644 src/router/meta.d.ts create mode 100644 src/services/ai/fetchStreamProvider.ts create mode 100644 src/services/ai/sse.ts create mode 100644 src/store/modules/cart.ts create mode 100644 src/types/ai.ts create mode 100644 src/types/api/common.ts create mode 100644 src/types/api/generated.d.ts create mode 100644 src/types/icon.ts create mode 100644 src/utils/markdown.ts create mode 100644 src/utils/product.ts delete mode 100644 src/utils/request/useAxiosApi.ts delete mode 100644 src/utils/request/useFetchApi.ts create mode 100644 src/utils/url.ts create mode 100644 src/views/ai/chat.vue delete mode 100644 src/views/demo/index.vue create mode 100644 src/views/examples/icons.vue create mode 100644 src/views/examples/index.vue create mode 100644 src/views/examples/mobile.vue create mode 100644 src/views/examples/projects.vue create mode 100644 src/views/examples/query.vue create mode 100644 src/views/examples/request.vue delete mode 100644 src/views/list/data.ts delete mode 100644 src/views/list/details/index.vue delete mode 100644 src/views/list/index.vue create mode 100644 src/views/shop/admin.vue create mode 100644 src/views/shop/cart.vue create mode 100644 src/views/shop/detail.vue create mode 100644 src/views/shop/index.vue create mode 100644 src/views/system/offline.vue create mode 100644 src/views/ui/index.vue create mode 100644 src/views/ui/nutui.vue create mode 100644 src/views/ui/vant.vue create mode 100644 src/views/ui/varlet.vue create mode 100644 tests/api/client.test.ts create mode 100644 tests/api/modules.test.ts create mode 100644 tests/components/ChatComposer.test.ts create mode 100644 tests/components/LoginForm.test.ts create mode 100644 tests/composables/usePullToRefresh.test.ts create mode 100644 tests/composables/useStreamingChat.test.ts create mode 100644 tests/locales/i18n.test.ts create mode 100644 tests/router/routes.test.ts create mode 100644 tests/services/fetchStreamProvider.test.ts create mode 100644 tests/services/sse.test.ts create mode 100644 tests/setup.ts create mode 100644 tests/store/cart.test.ts create mode 100644 tests/store/user.test.ts create mode 100644 tests/utils/markdown.test.ts create mode 100644 tests/utils/product.test.ts create mode 100644 tests/utils/url.test.ts create mode 100644 types/auto-imports.d.ts create mode 100644 types/components.d.ts delete mode 100644 types/index.d.ts create mode 100644 types/ui-demo.d.ts create mode 100644 vitest.config.ts delete mode 100644 yarn.lock diff --git a/.commitlintrc.js b/.commitlintrc.js deleted file mode 100644 index c7271ba..0000000 --- a/.commitlintrc.js +++ /dev/null @@ -1,91 +0,0 @@ -const fs = require('fs'); -const path = require('path'); -const { execSync } = require('child_process'); - -const scopes = fs - .readdirSync(path.resolve(__dirname, 'src'), { withFileTypes: true }) - .filter((dirent) => dirent.isDirectory()) - .map((dirent) => dirent.name.replace(/s$/, '')); - -// precomputed scope -const scopeComplete = execSync('git status --porcelain || true') - .toString() - .trim() - .split('\n') - .find((r) => ~r.indexOf('M src')) - ?.replace(/(\/)/g, '%%') - ?.match(/src%%((\w|-)*)/)?.[1] - ?.replace(/s$/, ''); - -/** @type {import('cz-git').UserConfig} */ -module.exports = { - ignores: [(commit) => commit.includes('init')], - extends: ['@commitlint/config-conventional'], - rules: { - 'body-leading-blank': [2, 'always'], - 'footer-leading-blank': [1, 'always'], - 'header-max-length': [2, 'always', 108], - 'subject-empty': [2, 'never'], - 'type-empty': [2, 'never'], - 'subject-case': [0], - 'type-enum': [ - 2, - 'always', - ['feat', 'fix', 'perf', 'style', 'docs', 'test', 'refactor', 'build', 'ci', 'chore', 'revert', 'wip', 'workflow', 'types', 'release'], - ], - }, - prompt: { - /** @use `yarn commit :f` */ - alias: { - f: 'docs: fix typos', - r: 'docs: update README', - s: 'style: update code format', - b: 'build: bump dependencies', - c: 'chore: update config', - }, - customScopesAlign: !scopeComplete ? 'top' : 'bottom', - defaultScope: scopeComplete, - scopes: [...scopes, 'mock'], - allowEmptyIssuePrefixs: false, - allowCustomIssuePrefixs: false, - - // English - typesAppend: [ - { value: 'wip', name: 'wip: work in process' }, - { value: 'workflow', name: 'workflow: workflow improvements' }, - { value: 'types', name: 'types: type definition file changes' }, - ], - - // 中英文对照版 - // messages: { - // type: '选择你要提交的类型 :', - // scope: '选择一个提交范围 (可选):', - // customScope: '请输入自定义的提交范围 :', - // subject: '填写简短精炼的变更描述 :\n', - // body: '填写更加详细的变更描述 (可选)。使用 "|" 换行 :\n', - // breaking: '列举非兼容性重大的变更 (可选)。使用 "|" 换行 :\n', - // footerPrefixsSelect: '选择关联issue前缀 (可选):', - // customFooterPrefixs: '输入自定义issue前缀 :', - // footer: '列举关联issue (可选) 例如: #31, #I3244 :\n', - // confirmCommit: '是否提交或修改commit ?', - // }, - // types: [ - // { value: 'feat', name: 'feat: 新增功能' }, - // { value: 'fix', name: 'fix: 修复缺陷' }, - // { value: 'docs', name: 'docs: 文档变更' }, - // { value: 'style', name: 'style: 代码格式' }, - // { value: 'refactor', name: 'refactor: 代码重构' }, - // { value: 'perf', name: 'perf: 性能优化' }, - // { value: 'test', name: 'test: 添加疏漏测试或已有测试改动' }, - // { value: 'build', name: 'build: 构建流程、外部依赖变更 (如升级 npm 包、修改打包配置等)' }, - // { value: 'ci', name: 'ci: 修改 CI 配置、脚本' }, - // { value: 'revert', name: 'revert: 回滚 commit' }, - // { value: 'chore', name: 'chore: 对构建过程或辅助工具和库的更改 (不影响源文件、测试用例)' }, - // { value: 'wip', name: 'wip: 正在开发中' }, - // { value: 'workflow', name: 'workflow: 工作流程改进' }, - // { value: 'types', name: 'types: 类型定义文件修改' }, - // ], - // emptyScopesAlias: 'empty: 不填写', - // customScopesAlias: 'custom: 自定义', - }, -}; diff --git a/.env b/.env index f061d5d..6ca23b6 100644 --- a/.env +++ b/.env @@ -1,4 +1,17 @@ -VITE_TOKEN_KEY=tokenKey - -VITE_URL_PREFIX=/api +# 所有环境共享的基础配置(可被 .env.[mode] 覆盖) +# 可用变量说明: +# VITE_API_BASE_URL - 接口请求基础路径(src/utils/request 读取) +# VITE_API_TARGET - 开发环境真实后端地址;配置后 /api 请求经 vite proxy 转发(默认不配置,走 mock) +# VITE_USE_MOCK - 是否开启数据 mock +# VITE_USE_ERUDA - 是否开启 eruda 调试工具 +# VITE_USE_COMPRESS - 是否开启 gzip 压缩 +# VITE_USE_REPORT - 是否生成打包体积报告 +# VITE_USE_HTTPS - 是否开启本地 https +# VITE_PWA_ENABLED - 是否开启 PWA +# VITE_IMAGE_OPTIMIZE - 是否在生产构建优化图片 +# VITE_UI_FRAMEWORK - vant / nutui / varlet,production 单选 +VITE_API_BASE_URL=/api +VITE_AI_API_BASE_URL=/api/ai +VITE_REQUEST_ID_ENABLED=true +VITE_UI_FRAMEWORK=vant diff --git a/.env.development b/.env.development index 22c0c68..9b275c7 100644 --- a/.env.development +++ b/.env.development @@ -1,11 +1,12 @@ # 是否开启数据mock VITE_USE_MOCK=true -# Token Key -VITE_TOKEN_KEY=Authorization +# 接入真实后端时关闭 Mock;AI 路径优先代理到 FastAPI,其他 /api 请求代理到 Gin +# VITE_AI_API_TARGET=http://localhost:8001 +# VITE_API_TARGET=http://localhost:8002 # 是否开启调试工具 -VITE_USE_ERUDA=true +VITE_USE_ERUDA=false # 是否开启压缩 VITE_USE_COMPRESS=false @@ -16,5 +17,8 @@ VITE_USE_REPORT=false # 是否开启https VITE_USE_HTTPS=false -# 是否开启PWA -VITE_USE_PWA=false \ No newline at end of file +# 开发环境不注册 Service Worker,避免缓存干扰调试 +VITE_PWA_ENABLED=false + +# 图片优化仅在 production build 执行 +VITE_IMAGE_OPTIMIZE=false diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f919b7c --- /dev/null +++ b/.env.example @@ -0,0 +1,25 @@ +# API 请求前缀。开发代理默认使用同一个前缀作为 proxy key。 +VITE_API_BASE_URL=/api +VITE_AI_API_BASE_URL=/api/ai + +# 开发环境真实后端地址。留空时使用 mock;配置后 /api 会被代理到该地址。 +# VITE_API_TARGET=http://localhost:8002 +# VITE_AI_API_TARGET=http://localhost:8001 + +# 功能开关。 +VITE_USE_MOCK=true +VITE_USE_ERUDA=false +VITE_USE_COMPRESS=false +VITE_USE_REPORT=false +VITE_USE_HTTPS=false +VITE_PWA_ENABLED=false + +# 仅在 production build 中运行 Sharp/SVGO;大型图片较多时建议开启。 +VITE_IMAGE_OPTIMIZE=true + +# 请求追踪与 production 单选 UI 框架。 +VITE_REQUEST_ID_ENABLED=true +VITE_UI_FRAMEWORK=vant + +# 仅当后端不保留 /api 前缀时开启代理重写。 +VITE_API_PROXY_REWRITE=false diff --git a/.env.integration b/.env.integration new file mode 100644 index 0000000..779e5a9 --- /dev/null +++ b/.env.integration @@ -0,0 +1,7 @@ +VITE_USE_MOCK=false +VITE_API_BASE_URL=/api +VITE_AI_API_BASE_URL=/api/ai +VITE_API_TARGET=http://127.0.0.1:8002 +VITE_AI_API_TARGET=http://127.0.0.1:8001 +VITE_REQUEST_ID_ENABLED=true +VITE_PWA_ENABLED=false diff --git a/.env.production b/.env.production index a3b39f3..f6ad1d2 100644 --- a/.env.production +++ b/.env.production @@ -1,8 +1,5 @@ # 是否开启数据mock -VITE_USE_MOCK=true - -# Token Key -VITE_TOKEN_KEY=Authorization +VITE_USE_MOCK=false # 是否开启调试工具 VITE_USE_ERUDA=false @@ -16,5 +13,8 @@ VITE_USE_REPORT=false # 是否开启https VITE_USE_HTTPS=false -# 是否开启PWA -VITE_USE_PWA=false \ No newline at end of file +# PWA 是可选能力,按部署需求开启 +VITE_PWA_ENABLED=false + +# 仅生产构建使用 Sharp/SVGO +VITE_IMAGE_OPTIMIZE=true diff --git a/.env.test b/.env.test index 1b72e67..5b1ace7 100644 --- a/.env.test +++ b/.env.test @@ -1,8 +1,5 @@ # 是否开启数据mock -VITE_USE_MOCK=true - -# Token Key -VITE_TOKEN_KEY=Authorization +VITE_USE_MOCK=false # 是否开启调试工具 VITE_USE_ERUDA=true @@ -16,5 +13,5 @@ VITE_USE_REPORT=false # 是否开启https VITE_USE_HTTPS=false -# 是否开启PWA -VITE_USE_PWA=false \ No newline at end of file +VITE_PWA_ENABLED=false +VITE_IMAGE_OPTIMIZE=false diff --git a/.eslintrc-auto-import.json b/.eslintrc-auto-import.json deleted file mode 100644 index a41ec0c..0000000 --- a/.eslintrc-auto-import.json +++ /dev/null @@ -1,98 +0,0 @@ -{ - "globals": { - "Component": true, - "ComponentPublicInstance": true, - "ComputedRef": true, - "DirectiveBinding": true, - "EffectScope": true, - "ExtractDefaultPropTypes": true, - "ExtractPropTypes": true, - "ExtractPublicPropTypes": true, - "InjectionKey": true, - "MaybeRef": true, - "MaybeRefOrGetter": true, - "PropType": true, - "Ref": true, - "ShallowRef": true, - "Slot": true, - "Slots": true, - "Snackbar": true, - "VNode": true, - "WritableComputedRef": true, - "acceptHMRUpdate": true, - "computed": true, - "createApp": true, - "createPinia": true, - "customRef": true, - "defineAsyncComponent": true, - "defineComponent": true, - "defineStore": true, - "effectScope": true, - "getActivePinia": true, - "getCurrentInstance": true, - "getCurrentScope": true, - "getCurrentWatcher": true, - "h": true, - "inject": true, - "isProxy": true, - "isReactive": true, - "isReadonly": true, - "isRef": true, - "isShallow": true, - "mapActions": true, - "mapGetters": true, - "mapState": true, - "mapStores": true, - "mapWritableState": true, - "markRaw": true, - "nextTick": true, - "onActivated": true, - "onBeforeMount": true, - "onBeforeRouteLeave": true, - "onBeforeRouteUpdate": true, - "onBeforeUnmount": true, - "onBeforeUpdate": true, - "onDeactivated": true, - "onErrorCaptured": true, - "onMounted": true, - "onRenderTracked": true, - "onRenderTriggered": true, - "onScopeDispose": true, - "onServerPrefetch": true, - "onUnmounted": true, - "onUpdated": true, - "onWatcherCleanup": true, - "provide": true, - "reactive": true, - "readonly": true, - "ref": true, - "resolveComponent": true, - "setActivePinia": true, - "setMapStoreSuffix": true, - "shallowReactive": true, - "shallowReadonly": true, - "shallowRef": true, - "showToast": true, - "storeToRefs": true, - "toRaw": true, - "toRef": true, - "toRefs": true, - "toValue": true, - "triggerRef": true, - "unref": true, - "useAttrs": true, - "useCssModule": true, - "useCssVars": true, - "useId": true, - "useLink": true, - "useModel": true, - "useRoute": true, - "useRouter": true, - "useSlots": true, - "useTemplateRef": true, - "watch": true, - "watchEffect": true, - "watchPostEffect": true, - "watchSyncEffect": true - } -} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4af1a8e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,35 @@ +name: CI + +on: + push: + pull_request: + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + + # 不要在这里写 version:pnpm/action-setup 会读 package.json 的 packageManager + # 字段,两处同时声明会以 ERR_PNPM_BAD_PM_VERSION 失败。 + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v5 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Verify lint, types, unit tests and build + run: pnpm check + + - name: Install Playwright Chromium + run: pnpm exec playwright install --with-deps chromium + + - name: End-to-end tests + run: pnpm test:e2e diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..a8538eb --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,18 @@ +name: Release Please + +on: + push: + branches: [vue-h5-template] + +permissions: + contents: write + pull-requests: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: googleapis/release-please-action@v4 + with: + config-file: release-please-config.json + manifest-file: .release-please-manifest.json diff --git a/.gitignore b/.gitignore index a6ba508..25776b0 100644 --- a/.gitignore +++ b/.gitignore @@ -13,8 +13,18 @@ dev-dist dist-ssr *.local .eslintcache -types/auto-imports.d.ts -types/components.d.ts +coverage +playwright-report +test-results + +# 说明:types/auto-imports.d.ts 与 types/components.d.ts 由 unplugin 在 dev/build 时 +# 自动重新生成,但需要提交到仓库,否则新 clone 的项目 pnpm typecheck 会失败 + +# unplugin-auto-import 生成的 eslintrc 片段,项目已改用 oxlint,无人引用 +.eslintrc-auto-import.json + +# WorkBuddy 的项目记忆目录(本机工作日志与长期笔记,不入库) +.workbuddy/ # Editor directories and files !.vscode/extensions.json diff --git a/.husky/commit-msg b/.husky/commit-msg index 274d2d8..2e6b87e 100755 --- a/.husky/commit-msg +++ b/.husky/commit-msg @@ -1,8 +1 @@ -#!/bin/sh - -# shellcheck source=./_/husky.sh -. "$(dirname "$0")/_/husky.sh" - -PATH="/usr/local/bin:$PATH" - -npx --no-install commitlint --edit "$1" +pnpm exec commitlint --edit "$1" diff --git a/.husky/pre-commit b/.husky/pre-commit index d506cff..954be25 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,7 +1,3 @@ -#!/bin/sh -. "$(dirname "$0")/_/husky.sh" - [ -n "$CI" ] && exit 0 -# Format and submit code according to lintstagedrc.js configuration -npm run lint:lint-staged +pnpm lint:lint-staged diff --git a/.prettierignore b/.prettierignore deleted file mode 100644 index 68f8ff0..0000000 --- a/.prettierignore +++ /dev/null @@ -1,10 +0,0 @@ -/dist/* -.local -.output.js -/node_modules/** -.npmrc - -**/*.svg -**/*.sh - -/public/* diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..895bf0e --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "2.0.0" +} diff --git a/.vscode/extensions.json b/.vscode/extensions.json index ad5d267..a01de2c 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -13,10 +13,10 @@ // i18n 插件 "Lokalise.i18n-ally", // CSS 变量提示 - "vunguyentuan.vscode-css-variables", + "vunguyentuan.vscode-css-variables" ], "unwantedRecommendations": [ // 和 volar 冲突 "octref.vetur" ] -} \ No newline at end of file +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..4443942 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,109 @@ +# AGENTS.md + +This file is the source of truth for AI coding agents and human contributors working in this repository. + +## Architecture + +Vue 3 SFC pages are lazy-loaded by an explicit Vue Router table. Pinia owns client state. TanStack Query owns remote server state. Axios is hidden behind typed domain modules. Streaming AI uses a provider-neutral async iterable service and a Vue composable. UI framework selection happens at build time through `VITE_UI_FRAMEWORK`. + +Dependency direction: + +```text +views -> components/composables -> api modules/services -> transport/types +main -> plugins/router/store +build -> Vite plugins only +``` + +Lower layers must not import pages, Router instances or UI framework toast APIs. + +## Directory Structure + +- `src/api/client.ts`: transport, interceptors and error normalization only. +- `src/api/modules`: domain endpoints; no component behavior. +- `src/components`: reusable presentation and interaction components. +- `src/composables`: Vue lifecycle/reactive orchestration. +- `src/services`: framework-independent providers, parsers and adapters. +- `src/store`: Pinia client state only. +- `src/types`: shared domain types and generated OpenAPI types. +- `src/views`: route-level composition; all non-core pages should be lazy. +- `mock`: non-production JSON mocks. Streaming middleware lives in `build/vite/plugins/aiMock.ts`. +- `tests` / `e2e`: unit/component and browser tests. + +## Coding Style + +- Use strict TypeScript and type-only imports. Do not add `any` to bypass contracts. +- Prefer small domain modules over generic `utils` or a single large composable. +- Keep reusable pure code outside Vue SFCs. Scoped SCSS is allowed and preferred for component-specific layout. +- Use design tokens from `src/styles/index.scss`; preserve safe-area and dynamic viewport behavior. +- Do not introduce a dependency for logic that is short, tested and standards-based. + +## API Rules + +- Pages never import Axios directly. Add or change endpoints in `src/api/modules`. +- All JSON APIs use `ApiResponse` and reject with `ApiError`. +- Narrow caught values with `isApiError`; never use `catch (error: any)`. +- Forward `AbortSignal` from TanStack Query and streaming operations. +- Update `openapi/schema.yaml`, run `pnpm api:generate`, and commit generated types when contracts change. +- Never put secrets in `VITE_*`. AI provider credentials belong on a trusted backend. +- `VITE_AI_API_BASE_URL` / `VITE_AI_API_TARGET` route streaming AI to FastAPI; conventional `/api` traffic targets Gin. + +## State Management Rules + +- Pinia: auth session, theme, feature flags and other client-owned state. +- TanStack Query: server cache, request status, retry, mutations and pagination. +- Local component state: form input and ephemeral UI. +- AI conversation state: `useStreamingChat`; do not duplicate it in Pinia. + +## Testing Rules + +- Test behavior and public contracts, not implementation trivia. +- Add unit tests for parsers, request errors, stores and composables. +- Add Vue Test Utils tests for reusable form/input interaction. +- Add Playwright coverage for changed critical user flows. +- Keep core coverage at or above the thresholds in `vitest.config.ts`. + +## UI Rules + +- Core business pages must not mix Vant, NutUI and Varlet. +- `VITE_UI_FRAMEWORK` selects one resolver and one demo alias for a production build. +- Product SVG icons go in `src/assets/icons` and use typed ``; UI library icons remain local to that framework. +- Preserve 44 CSS-pixel touch targets, keyboard resizing, safe-area padding, accessible labels and focus-visible styles. +- Use the typography, spacing, color, radius, control and motion tokens in `src/styles/index.scss`; do not recreate a local token scale in a page. +- Project CSS uses standard `rem`/CSS pixels and responsive breakpoints. Do not restore global `px`-to-viewport conversion: it makes tablet and desktop layouts scale incorrectly. +- Prefer document flow, dividers and spacing over nested cards. Gradients, large decorative shadows, pill controls and colored icon containers require a product reason. +- Keep controls at 6–8px radius, cards/dialogs at 8–12px, and routine transitions between 120–200ms. Shadows are reserved for floating navigation, popovers and dialogs. + +## Commands + +```bash +pnpm lint +pnpm typecheck +pnpm test +pnpm test:coverage +pnpm test:e2e +pnpm build +pnpm check +pnpm api:generate +``` + +Run the smallest relevant check while iterating, then `pnpm check`. Run E2E when a route, auth, request mock or AI stream changes. + +## Protected Decisions + +- Do not restore implicit file routing without a measured benefit and a migration plan. +- Do not cache authenticated API requests in the Service Worker by default. +- Do not globally load all three UI frameworks. +- Do not render AI or Markdown HTML without sanitization. +- Do not move server state into Pinia or couple the request client to Router/UI feedback. +- Preserve pnpm as the only package manager and `pnpm-lock.yaml` as the only lockfile. + +## PR Checklist + +- [ ] Change is placed in the correct architectural layer. +- [ ] Public types and OpenAPI output are updated. +- [ ] Loading, empty, error, offline and aborted states are considered. +- [ ] Mobile keyboard, safe-area, touch target and accessibility behavior are preserved. +- [ ] Security review covers HTML, URLs, tokens, env values and production Mock leakage. +- [ ] Unit/component tests cover core logic; critical flows have E2E coverage. +- [ ] Chinese, English, and Japanese docs stay synchronized. +- [ ] `pnpm check` passes; relevant Playwright tests pass. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..17864dc --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog + +All notable changes are generated by Release Please from Conventional Commits. + +## 2.0.0 + +- Rebuilt the template around a type-safe API client, TanStack Query, streaming AI chat, selectable UI frameworks, SVG sprites, optional PWA and production image optimization. +- Added OpenAPI type generation, meaningful unit/component/E2E tests, an AI-agent guide and bilingual architecture documentation. +- Added reactive Chinese, English and Japanese localization, richer UI framework examples, and split companion backends for AI streaming (FastAPI) and business CRUD (Gin). +- Added a real Project CRUD workspace, shared Gin-to-FastAPI JWT validation, versioned SQLite/PostgreSQL migrations, integration mode, and an end-to-end backend smoke script. diff --git a/README.md b/README.md index b6f3da5..55beaad 100644 --- a/README.md +++ b/README.md @@ -1,146 +1,163 @@ -
-

Vue H5 Template

-

基于 Vue 3 + Vite 7 + TypeScript + 多 UI 组件库 + Pinia + viewport 适配方案,构建移动端快速开发脚手架

+# Vue H5 Template v2 -

- license - stars - forks -

+面向真实移动 H5 业务的 Vue 3 工程模板。v2 不是组件库 Playground:它提供类型安全请求、OpenAPI 类型生成、TanStack Query、可中止的 Streaming AI Chat、PWA、SVG Sprite、production 图片优化、单选 UI 框架以及有意义的测试基线。 -

- 在线文档 · - 问题反馈 -

-
+[在线文档(简体中文)](https://sunniejs.github.io/vue-h5-template/) · [English docs](https://sunniejs.github.io/vue-h5-template/en/) · [日本語ドキュメント](https://sunniejs.github.io/vue-h5-template/ja/) · [迁移到 v2](https://sunniejs.github.io/vue-h5-template/guide/migration-v2) ---- +## 核心能力 -## 特性 - -- **Vue 3.5** + **Vite 7** + **TypeScript 5.9** — 最新技术栈 -- **多 UI 组件库** — 同时支持 Vant、NutUI、Varlet,按需自动引入 -- **Pinia 状态管理** — 配合 `pinia-plugin-persistedstate` 实现持久化 -- **viewport(vw)适配** — 基于 `cnjm-postcss-px-to-viewport`,自动处理 UI 库 375/750 设计稿差异 -- **Axios + useFetch 双请求方案** — 支持传统 Axios 和 `@vueuse/core` 的 `createFetch` -- **vue-i18n 多语言** — 按需懒加载语言包 -- **文件路由** — 基于 `vite-plugin-pages` 自动生成路由 -- **丰富的 Vite 插件** — Mock、Eruda 调试、PWA、QRCode、图片压缩、gzip 压缩、打包分析等 -- **代码规范** — ESLint flat config + Prettier + Stylelint + Husky + lint-staged -- **Docker 部署** — 内置 Dockerfile + Nginx 配置 - -## 环境要求 - -| 工具 | 版本 | -| ------- | ---------- | -| Node.js | >= 20.10.0 | -| pnpm | >= 9.12.0 | +- Vue 3.5、Vite 8 / Rolldown、TypeScript 5.9、Vue Router 4、Pinia 3 +- `@tanstack/vue-query` 管理 Server State,Pinia 只管理 Client State +- Axios Type-safe Client:`ApiResponse`、`ApiError`、Request ID、Token、401、Timeout 与 Network Error +- `pnpm api:generate` 从本地 OpenAPI Schema 生成后端契约类型 +- AI Chat:POST SSE、`ReadableStream`、`AbortController`、Regenerate、Retry、Markdown、代码块与 DOMPurify +- 真实商城:12+ 三语商品、搜索分类、下拉刷新、分页加载、详情、购物车与商品管理 +- 交付项目管理:查看、新建、编辑、删除和状态筛选,Vite Mock / Gin + PostgreSQL 共用契约 +- SVG Sprite:`src/assets/icons` 自动加载及类型化 `` +- Vant / NutUI / Varlet 构建期单选,不把三套 UI 同时放入 production graph +- 可选 PWA;Service Worker 只缓存 App Shell 和静态图片,不默认缓存 API +- Sharp + SVGO 仅在 production build 按开关优化 png/jpeg/webp/svg +- Vitest + Vue Test Utils + Playwright;当前核心逻辑 line coverage 81.31% +- 克制的响应式 Design System:统一 typography、spacing、radius、color、control 和 motion tokens,移动端底栏在桌面自动切换为紧凑顶栏 +- 中文、English、日本語三语懒加载,正文、导航和页面标题响应式同步 +- ESLint、Prettier、Stylelint、Husky、lint-staged、Commitlint、GitHub Actions 与 Release Please ## 快速开始 +要求 Node.js `>=22.12.0`、pnpm `>=9.12.0`。 + ```bash -# 拉取项目 -git clone https://github.com/sunniejs/vue-h5-template.git - -# 进入项目目录 -cd vue-h5-template - -# 安装依赖 pnpm install - -# 启动项目 +cp .env.example .env.local pnpm dev - -# 打包 -pnpm build - -# 预览打包结果 -pnpm preview ``` -## 项目结构 +开发环境默认启用 Mock,登录可以输入任意非空用户名和密码。底部第三个标签是工程示例;右下角 AI 悬浮入口打开 `/ai/chat`,Mock Server 会从 `POST /api/ai/chat` 逐 chunk 返回 SSE。 -``` -├── build/ # Vite 构建相关配置 -│ ├── vite/plugins/ # Vite 插件配置(auto-import、component、mock、eruda 等) -│ └── utils.ts # 构建工具函数 -├── mock/ # Mock 数据 -├── public/ # 静态资源 -├── src/ -│ ├── api/ # 接口管理 -│ ├── assets/ # 项目资源(字体、图片等) -│ ├── layout/ # 布局组件 -│ ├── locales/ # 国际化语言包 -│ ├── router/ # 路由配置 -│ ├── store/ # Pinia 状态管理 -│ ├── styles/ # 全局样式 & SCSS 变量 -│ ├── utils/ # 工具函数(Axios 封装、useFetch 封装) -│ ├── views/ # 页面组件 -│ ├── App.vue # 根组件 -│ └── main.ts # 入口文件 -├── types/ # TypeScript 类型声明 -├── .env.* # 多环境变量配置 -├── vite.config.mts # Vite 配置 -├── postcss.config.js # PostCSS 配置(viewport 适配) -├── Dockerfile # Docker 部署配置 -└── nginx.conf # Nginx 配置 +可直接访问 `/shop`、`/shop/cart`、`/examples/request` 和 `/examples/workspace`。请求示例包含 400/401/403/404/409、业务 422、500 和 timeout;交付项目页在 Mock 与真实后端两种模式下都支持完整增删改查。 + +## 常用命令 + +```bash +pnpm lint # ESLint + Stylelint +pnpm typecheck # vue-tsc project references +pnpm test # Vitest unit/component tests +pnpm test:coverage # 60%+ core coverage gate +pnpm test:e2e # Playwright 核心流程、响应式和 dark mode +pnpm build # production build +pnpm check # lint + typecheck + test + build +pnpm api:generate # OpenAPI -> TypeScript ``` -## 集成的 Vite 插件 +`pnpm check` 默认不含 E2E,避免每次本地提交都启动浏览器;CI 在 `check` 之后单独运行 Playwright。 -| 插件 | 说明 | -| ------------------------------- | -------------------- | -| `unplugin-auto-import` | 按需自动引入 API | -| `unplugin-vue-components` | 按需自动引入组件 | -| `vite-plugin-pages` | 文件系统路由 | -| `vite-plugin-mock` | 本地 Mock 数据 | -| `@zhaojjiang/vite-plugin-eruda` | 移动端调试工具 | -| `vite-plugin-svg-icons` | SVG 图标 | -| `vite-plugin-compression` | Gzip 压缩 | -| `vite-plugin-imagemin` | 图片压缩 | -| `vite-plugin-pwa` | PWA 支持 | -| `vite-plugin-qrcode` | 开发时二维码 | -| `vite-plugin-restart` | 配置文件修改自动重启 | -| `vite-plugin-progress` | 构建进度条 | -| `@vitejs/plugin-basic-ssl` | 本地 HTTPS | -| `rollup-plugin-visualizer` | 打包分析 | +## 目录边界 -## 其他版本 +```text +src/ +├── api/ # Axios client 与按业务域拆分的 API modules +├── components/ # 可复用、无页面路由职责的 UI +├── composables/ # Vue 生命周期/响应式编排 +├── layout/ # App Shell、导航、safe-area +├── plugins/ # Vue Query 等 App 插件 +├── router/ # 显式路由表、meta 类型与守卫 +├── services/ # Provider/transport 等框架无关业务服务 +├── store/ # Pinia client state +├── types/ # API、AI、Icon 领域类型 +├── utils/ # 无 Vue 生命周期的纯函数 +└── views/ # 懒加载页面 +``` -- **vue-h5-template-lite**(纯 JS 版)— [点击查看](https://github.com/sunniejs/vue-h5-template/tree/vue-h5-template-lite) -- **vue2-h5-template**(Vue 2 版)— [点击查看](https://github.com/sunniejs/vue-h5-template/tree/vue2-h5-template) -- **monorepo 版**(Monorepo 架构)— [点击查看](https://github.com/fonghehe/vue-h5-template) +完整规则见 [`AGENTS.md`](./AGENTS.md)。该文件也是 Codex、Claude Code、Cursor 和 Copilot 的首要工程上下文。 -## 文档 +## 状态管理 -详细的使用文档请查看 [在线文档](https://sunniejs.github.io/vue-h5-template/) +- Pinia:登录会话、购物车、主题、Feature Flags 等客户端状态。 +- TanStack Query:远程数据、缓存、重试、取消、Mutation、Pagination 与 Infinite Query。 +- AI 对话:由 `useStreamingChat` 编排会话生命周期;Provider 传输协议位于 `services/ai`,不放进 Pinia 或 Query Cache。 -如果对你有帮助,欢迎 Star 支持 ⭐ +## API 与 OpenAPI -## 关于我 +页面不直接调用 Axios,也不写 `catch (error: any)`。在 `src/api/modules` 增加函数,在页面/Query composable 中消费,并用 `isApiError` 收窄错误。 -扫描添加下方的微信并备注加交流群,交流学习,及时获取代码最新动态。 +替换后端 Schema: -

- -

+1. 用真实 `openapi.yaml` 替换 `openapi/schema.yaml`,或修改 `api:generate` 的输入 URL; +2. 运行 `pnpm api:generate`; +3. API module 从 `src/types/api/generated.d.ts` 引用 schema; +4. 提交 Schema 与生成文件,让 CI 的 typecheck 检查契约漂移。 -如果你觉得该项目有给你带来帮助,可以请作者喝一杯 ☕ 支持持续的迭代 +## AI Provider - - - - - - - - - -
WechatPayAliPay
+模板采用轻量、供应商无关的接口: -## Star History +```ts +interface ChatProvider { + chat( + messages: readonly ChatMessage[], + options?: ChatOptions, + ): AsyncIterable; +} +``` -[![Star History Chart](https://api.star-history.com/svg?repos=sunniejs/vue-h5-template&type=Timeline)](https://star-history.com/#sunniejs/vue-h5-template&Timeline) +默认 `FetchStreamChatProvider` 解析标准 SSE。真实项目应让服务端代理 OpenAI、Claude、Gemini 或 DeepSeek,不要把供应商密钥放在 `VITE_*` 环境变量或浏览器请求中。若后端使用 Vercel AI SDK data stream protocol,可新增 Provider adapter,无需重写页面状态机。 + +## UI 框架单选 + +```env +VITE_UI_FRAMEWORK=vant # vant | nutui | varlet +``` + +Vite 会同时选择对应 resolver 和 `#ui-demo` 别名。三套依赖留在模板中供创建项目时选择,但 production bundle 只包含当前值对应的框架。未来 `create-vue-h5-template` CLI 可以在生成阶段删除另外两套依赖。 + +每套框架示例都包含 Button、Cell/List、Switch、Tag/Chip 与 Progress,不再只是单按钮占位。 + +## 国际化 + +`src/locales/langs` 内置 `zh-CN`、`en-US`、`ja-JP`。首页语言选择器会懒加载语言包,并同步更新页面正文、底部导航、Router title、`document.title` 和 ``。新增语言时还需要加入 `SUPPORTED_LOCALES`,并保持 `common.json` 键结构一致。 + +## 配套后端 + +后端已从前端仓库拆分,并整理为两个独立 Git 仓库:`gin-service-template`(建议 GitHub 仓库名 `vue-h5-template-business-service`)负责认证、用户、交付项目、商品业务和 PostgreSQL;`fastapi-service-template`(建议仓库名 `vue-h5-template-ai-service`)负责 AI Provider、POST SSE 和 Redis 限流。上层目录只负责本地 Compose 联调。 + +```bash +# 终端 1:启动 PostgreSQL + Gin + FastAPI + Redis +cd /backend/vue-h5-template +docker compose up --build + +# 终端 2:使用已提交的 .env.integration 启动真实联调模式 +cd /frontend/vue-h5-template +pnpm dev:integration +``` + +访问 Vite 输出的地址,使用 `demo / demo1234` 登录。`/shop` 读取公开商品,`/shop/admin/products` 提供三语商品管理,`/examples/workspace` 提供交付项目的完整增删改查,`/ai/chat` 把同一个 JWT 随 SSE 请求发送给 FastAPI。也可在后端目录运行 `python3 scripts/smoke.py` 自动验证 Vue 代理 → Gin 业务接口 → PostgreSQL → FastAPI SSE。 + +默认 `pnpm dev` 仍使用前端 Mock,不要求安装后端。Compose 默认数据库连接是 `localhost:5432 / vue_h5_business / vue_h5 / vue_h5_local`,可直接加入 DataGrip;这些凭证仅供本地使用。两个后端仓库的 `docs` 分支和前端 `docs` 分支都提供中、英、日开发文档。AI 密钥只放 FastAPI 服务,不进入任何 `VITE_*` 变量。 + +## PWA 与图片优化 + +```env +VITE_PWA_ENABLED=false +VITE_IMAGE_OPTIMIZE=true +``` + +PWA 默认关闭。开启后会缓存构建 App Shell 与静态图片,`/api` 被明确排除。图片优化仅在 production build 运行;若图片已经由 CDN image pipeline 处理,可关闭以缩短 CI。 + +## 浏览器与安全基线 + +目标为 iOS Safari 15+、Android Chrome 80+、微信与企业微信的现代 WebView,不为 IE 或极老 Android 注入整包 polyfill。Markdown 禁止原始 HTML并经过 DOMPurify;Redirect 仅接受站内绝对路径;Mock 生产默认关闭;鉴权 Token 示例使用 `sessionStorage`。生产项目优先采用 SameSite + Secure + HttpOnly Cookie/BFF,并在网关配置 CSP、CSRF 和速率限制。 + +## 文档与发布 + +架构、后端联调、商城、请求、状态、AI Chat、UI、测试、部署和 v2 迁移文档维护在仓库 `docs` 分支,并提供中文、英文和日文同步内容。合并 Conventional Commits 后,Release Please 自动维护版本、CHANGELOG 与 GitHub Release。 + +本轮完整决策、性能数据、Breaking Changes 和后续路线见 [`V2_UPGRADE_REPORT.md`](./V2_UPGRADE_REPORT.md)。 + +## Roadmap + +- P0 已实现:Auth 基线、路由权限、错误边界、网络状态、深色系统主题、懒路由、安全 Markdown、PWA/API 缓存隔离、依赖/提交/测试基线。 +- P1 推荐按业务引入:Gateway/JWKS 身份、自动导出 Gin OpenAPI、数据库备份/回滚、Feature Flags、Analytics/Error Reporting adapter、Upload、Virtual List、WebView Bridge、Performance Monitoring、显式主题切换。 +- P2 可选:QR/Camera/Web Share、Deep Link、SSR/SSG、CDN external、原生 App Bridge。它们依赖产品与部署环境,不应成为模板默认负担。 ## License diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..8ea9259 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,7 @@ +# Security Policy + +Please report vulnerabilities privately through GitHub Security Advisories instead of opening a public issue. + +The template treats browser input, API payloads and AI output as untrusted. Markdown is rendered without raw HTML and sanitized with DOMPurify; external redirects are rejected; production Mock and PWA API caching are disabled by default. Never expose provider keys through `VITE_*` variables. + +Applications generated from this template remain responsible for backend authorization, CSP, CSRF protection, rate limiting, secure cookies, dependency review and platform-specific WebView controls. diff --git a/build/constant.ts b/build/constant.ts deleted file mode 100644 index ab8d680..0000000 --- a/build/constant.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** API request prefix — matches the proxy key in vite server config */ -export const API_BASE_URL = '/api'; - -/** Mock API prefix — used by vite-plugin-mock */ -export const MOCK_API_BASE_URL = '/mock-api'; - -/** Real backend URL for proxy — set to your actual backend address, e.g. 'http://localhost:8080' */ -export const API_TARGET_URL = ''; - -/** Mock server URL for proxy — leave empty when using vite-plugin-mock locally */ -export const MOCK_API_TARGET_URL = ''; diff --git a/build/utils.ts b/build/utils.ts index 91ae322..004d06f 100644 --- a/build/utils.ts +++ b/build/utils.ts @@ -1,27 +1,50 @@ +const DEFAULT_ENV: ViteEnv = { + VITE_API_BASE_URL: '/api', + VITE_AI_API_BASE_URL: '/api/ai', + VITE_USE_MOCK: false, + VITE_USE_ERUDA: false, + VITE_USE_COMPRESS: false, + VITE_USE_REPORT: false, + VITE_USE_HTTPS: false, + VITE_PWA_ENABLED: false, + VITE_IMAGE_OPTIMIZE: false, + VITE_REQUEST_ID_ENABLED: true, + VITE_UI_FRAMEWORK: 'vant', +}; + +const booleanKeys = new Set([ + 'VITE_USE_MOCK', + 'VITE_USE_ERUDA', + 'VITE_USE_COMPRESS', + 'VITE_USE_REPORT', + 'VITE_USE_HTTPS', + 'VITE_PWA_ENABLED', + 'VITE_IMAGE_OPTIMIZE', + 'VITE_REQUEST_ID_ENABLED', + 'VITE_API_PROXY_REWRITE', +]); + +function parseEnvValue( + envName: string, + value: string, +): string | boolean | number { + const normalized = value.replace(/\\n/g, '\n'); + if (booleanKeys.has(envName as keyof ViteEnv)) return normalized === 'true'; + if (envName === 'VITE_PORT') return Number(normalized); + return normalized; +} + // Read all environment variable configuration files to process.env -export function wrapperEnv(envConf: Recordable): ViteEnv { - const ret: any = {}; +export function wrapperEnv(envConf: Recordable): ViteEnv { + const ret: ViteEnv = { ...DEFAULT_ENV }; for (const envName of Object.keys(envConf)) { - let realName = envConf[envName].replace(/\\n/g, '\n'); - realName = realName === 'true' ? true : realName === 'false' ? false : realName; - - if (envName === 'VITE_PORT') { - realName = Number(realName); - } - if (envName === 'VITE_PROXY' && realName) { - try { - realName = JSON.parse(realName.replace(/'/g, '"')); - } catch { - realName = ''; - } - } - ret[envName] = realName; + const realName = parseEnvValue(envName, envConf[envName] ?? ''); + ret[envName as keyof ViteEnv] = realName as never; if (typeof realName === 'string') { process.env[envName] = realName; - } else if (typeof realName === 'object') { - process.env[envName] = JSON.stringify(realName); } } + return ret; } diff --git a/build/vite/plugins/aiMock.ts b/build/vite/plugins/aiMock.ts new file mode 100644 index 0000000..9376ced --- /dev/null +++ b/build/vite/plugins/aiMock.ts @@ -0,0 +1,90 @@ +import type { IncomingMessage, ServerResponse } from 'node:http'; +import type { Plugin } from 'vite'; + +const ANSWER = `Vue is a progressive JavaScript framework for building user interfaces. + +It is especially useful for mobile H5 applications because it combines a small runtime with an approachable component model: + +\`\`\`vue + +\`\`\` + +Use **Composition API** for reusable logic and keep server state outside Pinia.`; + +function readBody(request: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + let body = ''; + request.setEncoding('utf8'); + request.on('data', (chunk: string) => { + body += chunk; + }); + request.on('end', () => resolve(body)); + request.on('error', reject); + }); +} + +function writeEvent(response: ServerResponse, data: object) { + response.write(`data: ${JSON.stringify(data)}\n\n`); +} + +export function ConfigAiMockPlugin(): Plugin { + return { + name: 'vue-h5-template:ai-stream-mock', + configureServer(server) { + // Vite 中间件(非 Express),下方 async handler 已有完整 try/catch 兜底,不会产生未捕获拒绝 + // oxlint-disable-next-line oxc/no-async-endpoint-handlers + server.middlewares.use(async (request, response, next) => { + const pathname = new URL(request.url ?? '/', 'http://localhost') + .pathname; + if (request.method !== 'POST' || pathname !== '/api/ai/chat') { + next(); + return; + } + + try { + const body = JSON.parse(await readBody(request)) as { + messages?: Array<{ content?: string }>; + }; + const prompt = + body.messages?.[body.messages.length - 1]?.content?.trim(); + const answer = prompt ? ANSWER : 'Please send a message to begin.'; + const chunks = answer.match(/\S+\s*|\n/g) ?? [answer]; + let closed = false; + + request.on('aborted', () => { + closed = true; + }); + response.on('close', () => { + closed = true; + }); + response.writeHead(200, { + 'Content-Type': 'text/event-stream; charset=utf-8', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }); + + for (const delta of chunks) { + if (closed) return; + writeEvent(response, { type: 'delta', delta }); + await new Promise((resolve) => setTimeout(resolve, 45)); + } + + if (!closed) { + writeEvent(response, { type: 'done' }); + response.end(); + } + } catch (error) { + response.statusCode = 400; + writeEvent(response, { + type: 'error', + message: error instanceof Error ? error.message : 'Invalid request', + }); + response.end(); + } + }); + }, + }; +} diff --git a/build/vite/plugins/autoImport.ts b/build/vite/plugins/autoImport.ts index d41a68b..5784e91 100644 --- a/build/vite/plugins/autoImport.ts +++ b/build/vite/plugins/autoImport.ts @@ -6,21 +6,25 @@ import AutoImport from 'unplugin-auto-import/vite'; import { VarletImportResolver } from '@varlet/import-resolver'; import { VantResolver } from '@vant/auto-import-resolver'; +import type { Options } from 'unplugin-auto-import/types'; -export const ConfigAutoImportPlugin = () => { +function createUiResolvers( + framework: ViteEnv['VITE_UI_FRAMEWORK'], +): NonNullable { + if (framework === 'varlet') return VarletImportResolver({ autoImport: true }); + if (framework === 'vant') return [VantResolver()]; + return []; +} + +export const ConfigAutoImportPlugin = ( + framework: ViteEnv['VITE_UI_FRAMEWORK'], +) => { return AutoImport({ dts: 'types/auto-imports.d.ts', - imports: [ - 'vue', - 'pinia', - 'vue-router', - { - '@vueuse/core': [], - }, - ], + imports: ['vue', 'pinia', 'vue-router'], eslintrc: { enabled: true, }, - resolvers: [VarletImportResolver({ autoImport: true }), VantResolver()], + resolvers: createUiResolvers(framework), }); }; diff --git a/build/vite/plugins/component.ts b/build/vite/plugins/component.ts index e35161b..a51ab69 100644 --- a/build/vite/plugins/component.ts +++ b/build/vite/plugins/component.ts @@ -5,12 +5,22 @@ */ import Components from 'unplugin-vue-components/vite'; -import { VueUseComponentsResolver } from 'unplugin-vue-components/resolvers'; import NutUIResolver from '@nutui/auto-import-resolver'; import { VarletImportResolver } from '@varlet/import-resolver'; import { VantResolver } from '@vant/auto-import-resolver'; +import type { ComponentResolver } from 'unplugin-vue-components'; -export const ConfigAutoComponentsPlugin = () => { +function createUiResolvers( + framework: ViteEnv['VITE_UI_FRAMEWORK'], +): ComponentResolver[] { + if (framework === 'nutui') return [NutUIResolver()]; + if (framework === 'varlet') return VarletImportResolver(); + return [VantResolver()]; +} + +export const ConfigAutoComponentsPlugin = ( + framework: ViteEnv['VITE_UI_FRAMEWORK'], +) => { return Components({ dirs: ['src/components'], extensions: ['vue', 'md'], @@ -21,6 +31,6 @@ export const ConfigAutoComponentsPlugin = () => { directives: true, include: [/\.vue$/, /\.vue\?vue/, /\.md$/], exclude: [/[\\/]node_modules[\\/]/, /[\\/]\.git[\\/]/, /[\\/]\.nuxt[\\/]/], - resolvers: [VueUseComponentsResolver(), VantResolver(), VarletImportResolver(), NutUIResolver()], + resolvers: createUiResolvers(framework), }); }; diff --git a/build/vite/plugins/compress.ts b/build/vite/plugins/compress.ts index d2178cb..88efecb 100644 --- a/build/vite/plugins/compress.ts +++ b/build/vite/plugins/compress.ts @@ -10,7 +10,7 @@ export const ConfigCompressPlugin = () => { verbose: true, // 默认即可 disable: false, //开启压缩(不禁用),默认即可 deleteOriginFile: false, //删除源文件 - threshold: 10240, //压缩前最小文件大小 + threshold: 10_240, //压缩前最小文件大小 algorithm: 'gzip', //压缩算法 ext: '.gz', //文件类型 }); diff --git a/build/vite/plugins/imageOptimizer.ts b/build/vite/plugins/imageOptimizer.ts new file mode 100644 index 0000000..992aad8 --- /dev/null +++ b/build/vite/plugins/imageOptimizer.ts @@ -0,0 +1,14 @@ +import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'; + +export function ConfigImageOptimizerPlugin() { + return ViteImageOptimizer({ + includePublic: true, + cache: true, + cacheLocation: 'node_modules/.cache/vite-image-optimizer', + png: { quality: 82 }, + jpeg: { quality: 82 }, + jpg: { quality: 82 }, + webp: { quality: 82 }, + svg: { multipass: true }, + }); +} diff --git a/build/vite/plugins/imagemin.ts b/build/vite/plugins/imagemin.ts deleted file mode 100644 index 7e15d68..0000000 --- a/build/vite/plugins/imagemin.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * @name ConfigImageminPlugin - * @description 图片压缩 - */ - -import viteImagemin from 'vite-plugin-imagemin'; - -export function ConfigImageminPlugin() { - const plugin = viteImagemin({ - gifsicle: { - optimizationLevel: 7, - interlaced: false, - }, - mozjpeg: { - quality: 20, - }, - optipng: { - optimizationLevel: 7, - }, - pngquant: { - quality: [0.8, 0.9], - speed: 4, - }, - svgo: { - plugins: [ - { - name: 'removeViewBox', - }, - { - name: 'removeEmptyAttrs', - active: false, - }, - ], - }, - }); - return plugin; -} diff --git a/build/vite/plugins/index.ts b/build/vite/plugins/index.ts index 16f4d15..c7302db 100644 --- a/build/vite/plugins/index.ts +++ b/build/vite/plugins/index.ts @@ -6,48 +6,45 @@ import type { PluginOption } from 'vite'; import vue from '@vitejs/plugin-vue'; import vueJsx from '@vitejs/plugin-vue-jsx'; -import { ConfigSvgIconsPlugin } from './svgIcons'; -import { ConfigAutoComponentsPlugin } from './component'; -import { ConfigAutoImportPlugin } from './autoImport'; -import { ConfigMockPlugin } from './mock'; -import { ConfigCompressPlugin } from './compress'; -import { ConfigPagesPlugin } from './pages'; -import { ConfigRestartPlugin } from './restart'; -import { ConfigProgressPlugin } from './progress'; -import { ConfigErudaPlugin } from './eruda'; -import { ConfigImageminPlugin } from './imagemin'; -import { ConfigVisualizerPlugin } from './visualizer'; -import { ConfigSslPlugin } from './ssl'; -import { ConfigQrcodePlugin } from './qrcode'; -import { ConfigPwaPlugin } from './pwa'; +import { ConfigAutoComponentsPlugin } from './component.ts'; +import { ConfigAutoImportPlugin } from './autoImport.ts'; +import { ConfigMockPlugin } from './mock.ts'; +import { ConfigCompressPlugin } from './compress.ts'; +import { ConfigProgressPlugin } from './progress.ts'; +import { ConfigErudaPlugin } from './eruda.ts'; +import { ConfigVisualizerPlugin } from './visualizer.ts'; +import { ConfigSslPlugin } from './ssl.ts'; +import { ConfigPwaPlugin } from './pwa.ts'; +import { ConfigSvgIconsPlugin } from './svgIcons.ts'; +import { ConfigImageOptimizerPlugin } from './imageOptimizer.ts'; +import { ConfigAiMockPlugin } from './aiMock.ts'; export function createVitePlugins(env: ViteEnv, isBuild: boolean) { - const { VITE_USE_MOCK, VITE_USE_ERUDA, VITE_USE_COMPRESS, VITE_USE_REPORT, VITE_USE_HTTPS, VITE_USE_PWA } = env; + const { + VITE_USE_MOCK, + VITE_USE_ERUDA, + VITE_USE_COMPRESS, + VITE_USE_REPORT, + VITE_USE_HTTPS, + VITE_PWA_ENABLED, + VITE_IMAGE_OPTIMIZE, + VITE_UI_FRAMEWORK, + } = env; const vitePlugins: (PluginOption | PluginOption[])[] = [ // vue支持 vue(), // JSX支持 vueJsx(), + ConfigSvgIconsPlugin(), ]; - // 自动按需引入组件 - vitePlugins.push(ConfigAutoComponentsPlugin()); - - // 自动按需引入依赖 - vitePlugins.push(ConfigAutoImportPlugin()); - - // 自动生成路由 - vitePlugins.push(ConfigPagesPlugin()); - - // 监听配置文件改动重启 - vitePlugins.push(ConfigRestartPlugin()); - - // 构建时显示进度条 - vitePlugins.push(ConfigProgressPlugin()); - - // svg 图标 - vitePlugins.push(ConfigSvgIconsPlugin(isBuild)); + // 自动按需引入组件、依赖 + 构建时显示进度条 + vitePlugins.push( + ConfigAutoComponentsPlugin(VITE_UI_FRAMEWORK), + ConfigAutoImportPlugin(VITE_UI_FRAMEWORK), + ConfigProgressPlugin(), + ); // eruda调试工具 if (VITE_USE_ERUDA) { @@ -61,7 +58,7 @@ export function createVitePlugins(env: ViteEnv, isBuild: boolean) { // 数据 mock if (VITE_USE_MOCK) { - vitePlugins.push(ConfigMockPlugin(isBuild)); + vitePlugins.push(ConfigAiMockPlugin(), ConfigMockPlugin(isBuild)); } if (VITE_USE_HTTPS) { @@ -69,23 +66,19 @@ export function createVitePlugins(env: ViteEnv, isBuild: boolean) { vitePlugins.push(ConfigSslPlugin()); } - if (VITE_USE_PWA) { + if (VITE_PWA_ENABLED) { vitePlugins.push(ConfigPwaPlugin()); } if (isBuild) { + if (VITE_IMAGE_OPTIMIZE) { + vitePlugins.push(ConfigImageOptimizerPlugin()); + } // 开启.gz压缩 if (VITE_USE_COMPRESS) { vitePlugins.push(ConfigCompressPlugin()); - // 图片压缩 - vitePlugins.push(ConfigImageminPlugin()); } } - if (!isBuild) { - // 开启二维码插件 - vitePlugins.push(ConfigQrcodePlugin()); - } - return vitePlugins; } diff --git a/build/vite/plugins/mock.ts b/build/vite/plugins/mock.ts index ee74193..0ee87dc 100644 --- a/build/vite/plugins/mock.ts +++ b/build/vite/plugins/mock.ts @@ -6,7 +6,7 @@ import { viteMockServe } from 'vite-plugin-mock'; export const ConfigMockPlugin = (isBuild: boolean) => { return viteMockServe({ - ignore: /^\_/, + ignore: /^_/, mockPath: 'mock', enable: !isBuild, logger: !isBuild, diff --git a/build/vite/plugins/pages.ts b/build/vite/plugins/pages.ts deleted file mode 100644 index 8b385e3..0000000 --- a/build/vite/plugins/pages.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * @name ConfigPagesPlugin - * @description 动态生成路由 - */ - -import Pages from 'vite-plugin-pages'; - -export const ConfigPagesPlugin = () => { - return Pages({ - dirs: 'src/pages', - extensions: ['vue', 'ts'], - importMode: 'async', - }); -}; diff --git a/build/vite/plugins/pwa.ts b/build/vite/plugins/pwa.ts index 51a7b84..e56f558 100644 --- a/build/vite/plugins/pwa.ts +++ b/build/vite/plugins/pwa.ts @@ -8,34 +8,20 @@ import { VitePWA } from 'vite-plugin-pwa'; export const ConfigPwaPlugin = () => { return VitePWA({ registerType: 'autoUpdate', - includeAssets: ['favicon.svg', 'robots.txt', 'apple-touch-icon.png'], + includeAssets: ['favicon.ico', 'logo-320.png', 'logo-512.png'], devOptions: { - enabled: true, - type: 'module', + enabled: false, }, manifest: { name: 'Vue-H5-Template', short_name: 'Vue-H5-Template', - description: '一个使用 Vite 和 Vue3构建的应用', - theme_color: '#ffffff', - background_color: '#ffffff', + description: 'Modern Vue 3 mobile H5 application template', + theme_color: '#4f46e5', + background_color: '#f6f7fb', display: 'standalone', orientation: 'portrait', scope: '/', start_url: '/', - screenshots: [ - { - src: 'logo-320.png', - sizes: '320x320', - type: 'image/png', - }, - { - src: 'logo-512.png', - sizes: '512x512', - type: 'image/png', - form_factor: 'wide', - }, - ], icons: [ { src: 'logo-320.png', @@ -50,33 +36,18 @@ export const ConfigPwaPlugin = () => { ], }, workbox: { - // 全局模式匹配 - globPatterns: ['**/*.{css,js,html,svg,png,ico,txt,woff2}'], // 运行时缓存配置 + globPatterns: ['**/*.{css,js,html,svg,png,webp,ico,txt,woff2}'], + navigateFallback: '/index.html', + navigateFallbackDenylist: [/^\/api\//], runtimeCaching: [ { - // API 请求缓存 - urlPattern: ({ url }) => url.pathname.startsWith('/api'), - handler: 'CacheFirst', - options: { - cacheName: 'api-cache', - expiration: { - maxEntries: 10, - maxAgeSeconds: 60 * 60 * 24, // 1天 - }, - cacheableResponse: { - statuses: [0, 200], - }, - }, - }, - { - // 图片缓存 urlPattern: /\.(?:png|jpg|jpeg|svg|gif|webp)$/, - handler: 'CacheFirst', + handler: 'StaleWhileRevalidate', options: { cacheName: 'images-cache', expiration: { maxEntries: 60, - maxAgeSeconds: 60 * 60 * 24 * 30, // 30天 + maxAgeSeconds: 60 * 60 * 24 * 30, }, }, }, diff --git a/build/vite/plugins/qrcode.ts b/build/vite/plugins/qrcode.ts deleted file mode 100644 index a1edf8a..0000000 --- a/build/vite/plugins/qrcode.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * @name ConfigQrcodePlugin - * @description 引入qrcode插件,用于在浏览器中显示当前页面的二维码 - */ - -import { qrcode } from 'vite-plugin-qrcode'; - -export const ConfigQrcodePlugin = () => { - return qrcode(); -}; diff --git a/build/vite/plugins/restart.ts b/build/vite/plugins/restart.ts deleted file mode 100644 index 8bc8c26..0000000 --- a/build/vite/plugins/restart.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * @name ConfigRestartPlugin - * @description 监听配置文件修改自动重启Vite - */ - -import ViteRestart from 'vite-plugin-restart'; - -export const ConfigRestartPlugin = () => { - return ViteRestart({ - restart: ['*.config.[jt]s', '**/config/*.[jt]s'], - }); -}; diff --git a/build/vite/plugins/svgIcons.ts b/build/vite/plugins/svgIcons.ts index 72f62ab..75886d5 100644 --- a/build/vite/plugins/svgIcons.ts +++ b/build/vite/plugins/svgIcons.ts @@ -1,17 +1,9 @@ -/** - * @name ConfigSvgIconsPlugin - * @description 加载SVG文件,自动引入 - */ - +import path from 'node:path'; import { createSvgIconsPlugin } from 'vite-plugin-svg-icons'; -import path from 'path'; -export const ConfigSvgIconsPlugin = (isBuild: boolean) => { +export function ConfigSvgIconsPlugin() { return createSvgIconsPlugin({ - // 指定需要缓存的图标文件夹 iconDirs: [path.resolve(process.cwd(), 'src/assets/icons')], - // 指定symbolId格式 symbolId: 'icon-[dir]-[name]', - svgoOptions: isBuild, }); -}; +} diff --git a/build/vite/plugins/visualizer.ts b/build/vite/plugins/visualizer.ts index 776313f..a3dc0ae 100644 --- a/build/vite/plugins/visualizer.ts +++ b/build/vite/plugins/visualizer.ts @@ -8,8 +8,8 @@ import type { PluginOption } from 'vite'; export const ConfigVisualizerPlugin = () => { return visualizer({ - filename: './node_modules/.cache/visualizer/stats.html', - open: true, + filename: './dist/stats.html', + open: false, gzipSize: true, brotliSize: true, }) as PluginOption; diff --git a/build/vite/proxy.ts b/build/vite/proxy.ts index 4a2511a..a6521c3 100644 --- a/build/vite/proxy.ts +++ b/build/vite/proxy.ts @@ -1,21 +1,31 @@ -import { API_BASE_URL, API_TARGET_URL, MOCK_API_BASE_URL, MOCK_API_TARGET_URL } from '../constant'; import type { ProxyOptions } from 'vite'; -type ProxyTargetList = Record; +/** + * 开发环境代理配置 + * - AI 路径可优先转发给独立 FastAPI 服务,其他 /api 请求转发给 Gin + * - 未配置目标时,/api 请求由 vite-plugin-mock 在开发环境响应 + */ +export function createViteProxy(env: ViteEnv): Record { + const proxy: Record = {}; + const apiBaseUrl = env.VITE_API_BASE_URL || '/api'; + const aiApiBaseUrl = env.VITE_AI_API_BASE_URL || `${apiBaseUrl}/ai`; -const init: ProxyTargetList = { - // test - [API_BASE_URL]: { - target: API_TARGET_URL, - changeOrigin: true, - rewrite: (path) => path.replace(new RegExp(`^${API_BASE_URL}`), ''), - }, - // mock - [MOCK_API_BASE_URL]: { - target: MOCK_API_TARGET_URL, - changeOrigin: true, - rewrite: (path) => path.replace(new RegExp(`^${MOCK_API_BASE_URL}`), ''), - }, -}; + if (env.VITE_AI_API_TARGET) { + proxy[aiApiBaseUrl] = { + target: env.VITE_AI_API_TARGET, + changeOrigin: true, + }; + } -export default init; + if (env.VITE_API_TARGET) { + proxy[apiBaseUrl] = { + target: env.VITE_API_TARGET, + changeOrigin: true, + rewrite: env.VITE_API_PROXY_REWRITE + ? (path) => path.replace(new RegExp(`^${apiBaseUrl}`), '') + : undefined, + }; + } + + return proxy; +} diff --git a/commitlint.config.mjs b/commitlint.config.mjs new file mode 100644 index 0000000..3f5e287 --- /dev/null +++ b/commitlint.config.mjs @@ -0,0 +1 @@ +export default { extends: ['@commitlint/config-conventional'] }; diff --git a/e2e/app.spec.ts b/e2e/app.spec.ts new file mode 100644 index 0000000..45b5d76 --- /dev/null +++ b/e2e/app.spec.ts @@ -0,0 +1,160 @@ +import { expect, test } from '@playwright/test'; + +test('home presents the product template', async ({ page }) => { + await page.goto('/home'); + await expect(page.getByRole('heading', { name: /Vue 3 起点/ })).toBeVisible(); + await expect(page.getByRole('navigation').getByText('示例')).toBeVisible(); + await expect(page.getByRole('link', { name: 'AI 助手' })).toBeVisible(); +}); + +test('language switch updates content, navigation, title and html lang', async ({ + page, +}) => { + await page.goto('/home'); + await page.getByRole('combobox', { name: '语言' }).selectOption('en-US'); + await expect( + page.getByRole('heading', { name: /real mobile products/i }), + ).toBeVisible(); + await expect(page.getByRole('navigation').getByText('Shop')).toBeVisible(); + await expect(page).toHaveTitle('Home · Vue H5'); + await expect(page.locator('html')).toHaveAttribute('lang', 'en-US'); + + await page.getByRole('combobox', { name: 'Language' }).selectOption('ja-JP'); + await expect( + page.getByRole('heading', { name: /モバイルプロダクト/ }), + ).toBeVisible(); + await expect(page).toHaveTitle('ホーム · Vue H5'); + await expect(page.locator('html')).toHaveAttribute('lang', 'ja-JP'); +}); + +test('shop supports a localized catalog and product detail flow', async ({ + page, +}) => { + await page.goto('/shop'); + await expect( + page.getByRole('heading', { name: '把日常好物带回生活' }), + ).toBeVisible(); + await expect(page.getByText('Aurora 降噪耳机')).toBeVisible(); + await page.getByText('Aurora 降噪耳机').click(); + await expect(page).toHaveURL(/\/shop\/products\/1$/); + await expect(page.getByRole('button', { name: '加入购物车' })).toBeVisible(); +}); + +test('shop adds products to a persistent cart', async ({ page }) => { + await page.goto('/shop'); + await page + .getByRole('button', { name: /将Aurora 降噪耳机加入购物车/ }) + .click(); + await page.getByRole('link', { name: '打开购物车' }).click(); + await expect(page).toHaveURL(/\/shop\/cart$/); + await expect(page.getByText('Aurora 降噪耳机')).toBeVisible(); + await expect(page.getByRole('button', { name: /结算(1)/ })).toBeEnabled(); +}); + +test('login uses the mock API and reaches profile', async ({ page }) => { + await page.goto('/login'); + await expect(page.getByLabel('用户名')).toHaveValue(''); + await expect(page.getByLabel('密码')).toHaveValue(''); + await page.getByLabel('用户名').fill('Ada'); + await page.getByLabel('密码').fill('secret'); + await page.getByRole('button', { name: '登录', exact: true }).click(); + await expect(page).toHaveURL(/\/member$/); + await expect(page.getByRole('heading', { name: 'Ada' })).toBeVisible(); +}); + +test('mock API returns type-safe server data', async ({ request }) => { + const response = await request.get('/api/examples/tasks'); + expect(response.ok()).toBeTruthy(); + const payload = (await response.json()) as { code: number; data: unknown[] }; + expect(payload.code).toBe(200); + expect(payload.data).toHaveLength(3); +}); + +test('AI chat renders streaming chunks and can stop', async ({ page }) => { + await page.goto('/ai/chat'); + await page.getByRole('button', { name: '解释这个模板的流式架构' }).click(); + await expect(page.getByText(/Vue is a progressive/)).toBeVisible(); + const stop = page.getByRole('button', { name: '停止生成' }); + if (await stop.isVisible()) await stop.click(); +}); + +test('401 clears auth and redirects to login', async ({ page }) => { + await page.goto('/login'); + await page.getByLabel('用户名').fill('Ada'); + await page.getByLabel('密码').fill('secret'); + await page.getByRole('button', { name: '登录', exact: true }).click(); + await page.goto('/examples/request'); + await page + .locator('.error-list article') + .filter({ hasText: '登录已失效' }) + .getByRole('button', { name: '触发错误' }) + .click(); + await expect(page).toHaveURL(/\/login\?redirect=/); +}); + +test('request examples normalize common HTTP, business and timeout errors', async ({ + page, +}) => { + await page.goto('/examples/request'); + const scenarios = [ + { title: '请求参数错误', kind: 'http' }, + { title: '权限不足', kind: 'http' }, + { title: '资源不存在', kind: 'http' }, + { title: '数据版本冲突', kind: 'http' }, + { title: '业务校验失败', kind: 'business' }, + { title: '服务端异常', kind: 'http' }, + { title: '请求超时', kind: 'timeout' }, + ]; + + for (const scenario of scenarios) { + const row = page + .locator('.error-list article') + .filter({ hasText: scenario.title }); + await row.getByRole('button', { name: '触发错误' }).click(); + await expect(row.locator('dl')).toContainText(scenario.kind); + } +}); + +test('delivery workspace supports create, view, edit, filter and delete', async ({ + page, +}) => { + await page.goto('/login'); + await page.getByLabel('用户名').fill('workspace-tester'); + await page.getByLabel('密码').fill('secret'); + await page.getByRole('button', { name: '登录', exact: true }).click(); + await page.goto('/examples/workspace'); + + const projectName = `Playwright delivery ${Date.now()}`; + const updatedName = `${projectName} updated`; + await page.getByRole('button', { name: /新建项目/ }).click(); + await page.getByLabel('项目名称').fill(projectName); + await page.getByLabel('项目说明').fill('验证 Mock 与页面交互的完整操作。'); + await page.getByLabel('项目状态').selectOption('paused'); + await page.getByRole('button', { name: '保存项目' }).click(); + + let row = page + .locator('.workspace-list article') + .filter({ hasText: projectName }); + await expect(row).toBeVisible(); + await row.getByRole('button', { name: '查看' }).click(); + await expect(page.getByRole('dialog')).toContainText(projectName); + await page + .getByRole('dialog') + .locator('footer') + .getByRole('button', { name: '关闭' }) + .click(); + + await row.getByRole('button', { name: '编辑' }).click(); + await page.getByLabel('项目名称').fill(updatedName); + await page.getByRole('button', { name: '保存项目' }).click(); + row = page + .locator('.workspace-list article') + .filter({ hasText: updatedName }); + await expect(row).toBeVisible(); + + await page.getByLabel('状态筛选').selectOption('paused'); + await expect(row).toBeVisible(); + page.once('dialog', (dialog) => dialog.accept()); + await row.getByRole('button', { name: '删除' }).click(); + await expect(row).toHaveCount(0); +}); diff --git a/e2e/ui.spec.ts b/e2e/ui.spec.ts new file mode 100644 index 0000000..0165737 --- /dev/null +++ b/e2e/ui.spec.ts @@ -0,0 +1,132 @@ +import { expect, test } from '@playwright/test'; + +test('layout remains restrained on mobile, tablet and desktop', async ({ + page, +}) => { + for (const viewport of [ + { width: 390, height: 844 }, + { width: 768, height: 1024 }, + { width: 1440, height: 900 }, + ]) { + await page.setViewportSize(viewport); + await page.goto('/home'); + await expect(page.locator('h1')).toBeVisible(); + await expect(page.getByRole('navigation')).toBeVisible(); + + const metrics = await page.evaluate(() => { + const title = document.querySelector('h1'); + const navigation = document.querySelector('nav'); + const page = document.querySelector('.page'); + if (!title || !navigation || !page) + throw new Error('Expected home title, page and navigation'); + const titleStyle = getComputedStyle(title); + const pageStyle = getComputedStyle(page); + const navigationBox = navigation.getBoundingClientRect(); + return { + titleSize: Number.parseFloat(titleStyle.fontSize), + pagePadding: Number.parseFloat(pageStyle.paddingLeft), + scrollWidth: document.documentElement.scrollWidth, + clientWidth: document.documentElement.clientWidth, + navigationTop: navigationBox.top, + navigationBottom: navigationBox.bottom, + }; + }); + + expect(metrics.titleSize).toBeLessThanOrEqual(44); + expect(metrics.scrollWidth).toBeLessThanOrEqual(metrics.clientWidth); + if (viewport.width >= 768) { + expect(metrics.pagePadding).toBeLessThanOrEqual(24); + expect(metrics.navigationTop).toBeLessThan(24); + } else { + expect(metrics.pagePadding).toBeLessThanOrEqual(16); + expect( + Math.abs(metrics.navigationBottom - viewport.height), + ).toBeLessThanOrEqual(2); + } + } +}); + +test('dark mode uses the dark design tokens without changing layout', async ({ + page, +}) => { + await page.emulateMedia({ colorScheme: 'dark', reducedMotion: 'reduce' }); + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto('/shop'); + await expect(page.locator('.product-card').first()).toBeVisible(); + + const theme = await page.evaluate(() => { + const root = getComputedStyle(document.documentElement); + const card = document.querySelector('.product-card'); + if (!card) throw new Error('Expected a product card'); + const cardStyle = getComputedStyle(card); + return { + colorScheme: root.colorScheme, + background: root.getPropertyValue('--color-background').trim(), + surface: cardStyle.backgroundColor, + radius: Number.parseFloat(cardStyle.borderRadius), + scrollWidth: document.documentElement.scrollWidth, + clientWidth: document.documentElement.clientWidth, + }; + }); + + expect(theme.colorScheme).toBe('dark'); + expect(theme.background).toBe('#111113'); + expect(theme.surface).not.toBe('rgb(255, 255, 255)'); + expect(theme.radius).toBeLessThanOrEqual(12); + expect(theme.scrollWidth).toBeLessThanOrEqual(theme.clientWidth); +}); + +test('mobile routes use consistent page gutters without horizontal overflow', async ({ + page, +}) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto('/login'); + await page.getByLabel('用户名').fill('layout-tester'); + await page.getByLabel('密码').fill('secret'); + await page.getByRole('button', { name: '登录', exact: true }).click(); + const routes = [ + { path: '/home', gutter: 16 }, + { path: '/shop', gutter: 16 }, + { path: '/shop/cart', gutter: 16 }, + { path: '/examples', gutter: 16 }, + { path: '/examples/query', gutter: 16 }, + { path: '/examples/request', gutter: 16 }, + { path: '/examples/workspace', gutter: 16 }, + { path: '/examples/mobile', gutter: 16 }, + { path: '/examples/icons', gutter: 16 }, + { path: '/ui-framework', gutter: 16 }, + { path: '/shop/admin/products', gutter: 16 }, + { path: '/member', gutter: 16 }, + ]; + + for (const route of routes) { + await page.goto(route.path); + const metrics = await page.locator('.page').evaluate((element) => { + const style = getComputedStyle(element); + return { + left: Number.parseFloat(style.paddingLeft), + right: Number.parseFloat(style.paddingRight), + overflow: + document.documentElement.scrollWidth - + document.documentElement.clientWidth, + }; + }); + expect(metrics.left, route.path).toBe(route.gutter); + expect(metrics.right, route.path).toBe(route.gutter); + expect(metrics.overflow, route.path).toBeLessThanOrEqual(0); + } +}); + +test('desktop root pages use one navigation layer and expose language switching', async ({ + page, +}) => { + await page.setViewportSize({ width: 1280, height: 800 }); + await page.goto('/examples'); + + await expect(page.locator('.top-bar')).toBeHidden(); + await expect(page.getByRole('combobox', { name: '语言' })).toBeVisible(); + await page.getByRole('combobox', { name: '语言' }).selectOption('en-US'); + await expect( + page.getByRole('heading', { name: 'More than a component playground' }), + ).toBeVisible(); +}); diff --git a/eslint.config.mjs b/eslint.config.mjs deleted file mode 100644 index 44da5aa..0000000 --- a/eslint.config.mjs +++ /dev/null @@ -1,20 +0,0 @@ -import { globalIgnores } from 'eslint/config'; -import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'; -import pluginVue from 'eslint-plugin-vue'; -import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'; - -export default defineConfigWithVueTs( - pluginVue.configs['flat/essential'], - vueTsConfigs.recommended, - skipFormatting, - { - name: 'app/files-to-lint', - files: ['**/*.{ts,mts,tsx,vue}'], - rules: { - '@typescript-eslint/no-explicit-any': 'off', - '@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports' }], - 'vue/multi-word-component-names': 'off', - }, - }, - globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']), -); diff --git a/index.html b/index.html index 72e17e0..cd502cb 100644 --- a/index.html +++ b/index.html @@ -7,7 +7,10 @@ name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, viewport-fit=cover" /> - + diff --git a/mock/index.ts b/mock/index.ts index 71bcacb..7b57bd4 100644 --- a/mock/index.ts +++ b/mock/index.ts @@ -1,22 +1,754 @@ -import type { MockMethod, Recordable } from 'vite-plugin-mock'; +import type { MockMethod } from 'vite-plugin-mock'; +import type { Product, ProductInput } from '../src/api/modules/products'; +import type { + Project, + ProjectInput, + ProjectUpdateInput, +} from '../src/api/modules/projects'; -interface Response { - body: Recordable; - query: Recordable; +interface MockContext { + body: Record; + query: Record; + headers: Record; + url: string; +} + +const tasks = [ + { id: 1, title: 'Review mobile checkout flow', done: true }, + { id: 2, title: 'Connect the generated API types', done: false }, + { id: 3, title: 'Test the offline experience', done: false }, +]; +let currentUserName = 'Demo User'; + +const localized = (zh: string, en: string, ja: string) => ({ + 'zh-CN': zh, + 'en-US': en, + 'ja-JP': ja, +}); +const now = new Date().toISOString(); +let nextProductId = 13; +let nextProjectId = 4; +let projects: Project[] = [ + { + id: 1, + ownerId: 1, + name: '移动商城首发', + description: '完成商品目录、购物车与结算前流程。', + status: 'active', + createdAt: now, + updatedAt: now, + }, + { + id: 2, + ownerId: 1, + name: 'AI 客服接入', + description: '验证 SSE 流式响应和中止能力。', + status: 'paused', + createdAt: now, + updatedAt: now, + }, + { + id: 3, + ownerId: 1, + name: 'H5 性能基线', + description: '记录核心路由包体与移动端体验指标。', + status: 'archived', + createdAt: now, + updatedAt: now, + }, +]; +let products: Product[] = [ + { + id: 1, + sku: 'AURORA-01', + name: localized( + 'Aurora 降噪耳机', + 'Aurora Noise-Cancelling Headphones', + 'Aurora ノイズキャンセリングヘッドホン', + ), + subtitle: localized( + '沉浸声音,也听见生活', + 'Immersive sound with awareness when you need it', + '没入感のある音と、必要なときの外音取り込み', + ), + description: localized( + '轻量头戴设计,支持主动降噪、通透模式与长续航。', + 'A lightweight over-ear design with active noise cancellation, transparency mode, and all-day battery life.', + '軽量なオーバーイヤー設計。アクティブノイズキャンセリング、外音取り込み、長時間再生に対応します。', + ), + category: 'digital', + brand: 'Aurora', + coverUrl: '/products/aurora-headphones.svg', + priceCents: 129_900, + originalPriceCents: 159_900, + stock: 86, + sales: 1280, + rating: 4.8, + status: 'on_sale', + featured: true, + createdAt: now, + updatedAt: now, + }, + { + id: 2, + sku: 'MORI-COFFEE-02', + name: localized( + 'Mori 手冲咖啡礼盒', + 'Mori Pour-over Coffee Set', + 'Mori ハンドドリップコーヒーセット', + ), + subtitle: localized( + '把清晨交给一杯好咖啡', + 'A calmer morning, one cup at a time', + '一杯のコーヒーから、穏やかな朝を', + ), + description: localized( + '包含精选咖啡豆、滤杯与分享壶,适合居家和办公室。', + 'A curated coffee, dripper, and sharing server set for home or office.', + '厳選豆、ドリッパー、サーバーを揃えた、自宅やオフィス向けのセットです。', + ), + category: 'lifestyle', + brand: 'Mori', + coverUrl: '/products/mori-coffee.svg', + priceCents: 26_900, + originalPriceCents: 32_900, + stock: 42, + sales: 694, + rating: 4.7, + status: 'on_sale', + featured: true, + createdAt: now, + updatedAt: now, + }, + { + id: 3, + sku: 'NOVA-LAMP-03', + name: localized( + 'Nova 氛围台灯', + 'Nova Ambient Desk Lamp', + 'Nova アンビエントデスクライト', + ), + subtitle: localized( + '工作专注,夜晚柔和', + 'Focused by day and gentle at night', + '昼は集中、夜はやさしい光', + ), + description: localized( + '无级调光与三档色温,简洁灯体适合床头或书桌。', + 'Stepless dimming and three color temperatures in a compact bedside or desk design.', + '無段階調光と3段階の色温に対応した、ベッドサイドやデスク向けのコンパクトなライトです。', + ), + category: 'home', + brand: 'Nova', + coverUrl: '/products/nova-lamp.svg', + priceCents: 39_900, + originalPriceCents: 45_900, + stock: 18, + sales: 438, + rating: 4.6, + status: 'on_sale', + featured: false, + createdAt: now, + updatedAt: now, + }, + { + id: 4, + sku: 'TRAIL-PACK-04', + name: localized( + 'Trail 城市轻量背包', + 'Trail Lightweight City Backpack', + 'Trail 軽量シティバックパック', + ), + subtitle: localized( + '通勤与周末,一包装下', + 'One pack for commutes and weekends', + '通勤も週末も、これ一つで', + ), + description: localized( + '防泼水面料、独立电脑仓与透气背板,容量适合一日出行。', + 'Water-resistant fabric, a dedicated laptop sleeve, and a breathable back panel for day trips.', + '撥水生地、独立PCスリーブ、通気性の高い背面を備えたデイパックです。', + ), + category: 'outdoor', + brand: 'Trail', + coverUrl: '/products/trail-backpack.svg', + priceCents: 55_900, + originalPriceCents: 69_900, + stock: 31, + sales: 820, + rating: 4.9, + status: 'on_sale', + featured: true, + createdAt: now, + updatedAt: now, + }, + { + id: 5, + sku: 'LUMI-CHARGER-05', + name: localized( + 'Lumi 三合一磁吸充电座', + 'Lumi 3-in-1 Magnetic Charger', + 'Lumi 3-in-1 マグネット充電スタンド', + ), + subtitle: localized( + '一处收纳,整夜满电', + 'One place for an overnight charge', + '一か所ですっきり、朝にはフル充電', + ), + description: localized( + '同时为手机、耳机与手表充电,折叠结构方便差旅携带。', + 'Charges a phone, earbuds, and watch together in a foldable travel-ready body.', + 'スマートフォン、イヤホン、ウォッチを同時充電。折りたたんで旅行にも持ち運べます。', + ), + category: 'digital', + brand: 'Lumi', + coverUrl: '/products/product-placeholder.svg', + priceCents: 42_900, + originalPriceCents: 49_900, + stock: 75, + sales: 1034, + rating: 4.7, + status: 'on_sale', + featured: true, + createdAt: now, + updatedAt: now, + }, + { + id: 6, + sku: 'KINTO-BOTTLE-06', + name: localized( + 'Kinto 随行保温杯', + 'Kinto Travel Tumbler', + 'Kinto トラベルタンブラー', + ), + subtitle: localized( + '轻量防漏,冷热皆宜', + 'Lightweight, leakproof, hot or cold', + '軽量で漏れにくく、温冷どちらにも', + ), + description: localized( + '磨砂杯身与可拆洗杯盖,适合通勤和户外使用。', + 'A matte body and washable lid made for commutes and weekends outside.', + 'マットな本体と洗いやすい蓋で、通勤にもアウトドアにも最適です。', + ), + category: 'lifestyle', + brand: 'Kinto', + coverUrl: '/products/mori-coffee.svg', + priceCents: 23_900, + originalPriceCents: 26_900, + stock: 144, + sales: 1865, + rating: 4.8, + status: 'on_sale', + featured: false, + createdAt: now, + updatedAt: now, + }, + { + id: 7, + sku: 'PICO-SPEAKER-07', + name: localized( + 'Pico 便携蓝牙音箱', + 'Pico Portable Speaker', + 'Pico ポータブルスピーカー', + ), + subtitle: localized( + '小体积,也有完整声场', + 'Compact body, room-filling sound', + '小さなボディで、部屋いっぱいの音', + ), + description: localized( + 'IP67 防水、12 小时续航,并支持双音箱立体声配对。', + 'IP67 water resistance, 12-hour battery, and stereo pairing.', + 'IP67防水、12時間再生、2台でのステレオペアリングに対応します。', + ), + category: 'digital', + brand: 'Pico', + coverUrl: '/products/aurora-headphones.svg', + priceCents: 31_900, + originalPriceCents: 36_900, + stock: 63, + sales: 748, + rating: 4.6, + status: 'on_sale', + featured: false, + createdAt: now, + updatedAt: now, + }, + { + id: 8, + sku: 'NEST-THROW-08', + name: localized( + 'Nest 羊毛混纺盖毯', + 'Nest Wool-blend Throw', + 'Nest ウールブレンドブランケット', + ), + subtitle: localized( + '柔软亲肤,四季可用', + 'Soft comfort for every season', + 'やさしい肌触りで、四季を通して', + ), + description: localized( + '细密织法与低饱和配色,可用于沙发、床尾或阅读角。', + 'A finely woven, muted throw for sofas, beds, and reading corners.', + '繊細な織りと落ち着いた色合いで、ソファやベッド、読書スペースに。', + ), + category: 'home', + brand: 'Nest', + coverUrl: '/products/nova-lamp.svg', + priceCents: 48_900, + originalPriceCents: 56_900, + stock: 27, + sales: 392, + rating: 4.7, + status: 'on_sale', + featured: false, + createdAt: now, + updatedAt: now, + }, + { + id: 9, + sku: 'FIELD-CHAIR-09', + name: localized( + 'Field 折叠露营椅', + 'Field Folding Camp Chair', + 'Field 折りたたみキャンプチェア', + ), + subtitle: localized( + '快速收纳,稳固承托', + 'Quick setup with dependable support', + 'すぐに広げて、しっかり支える', + ), + description: localized( + '铝合金支架与耐磨座布,收纳后可放进汽车后备箱。', + 'An aluminum frame and durable seat that packs neatly into the car.', + 'アルミフレームと丈夫なシート。収納すれば車にもすっきり収まります。', + ), + category: 'outdoor', + brand: 'Field', + coverUrl: '/products/trail-backpack.svg', + priceCents: 36_900, + originalPriceCents: 42_900, + stock: 48, + sales: 516, + rating: 4.6, + status: 'on_sale', + featured: false, + createdAt: now, + updatedAt: now, + }, + { + id: 10, + sku: 'MORI-TEA-10', + name: localized( + 'Mori 冷泡茶组合', + 'Mori Cold Brew Tea Set', + 'Mori 水出しティーセット', + ), + subtitle: localized( + '六种风味,清爽一整天', + 'Six refreshing blends for the day', + '6つの味わいで、一日を爽やかに', + ), + description: localized( + '独立茶包与耐热冷泡壶,适合办公室和居家饮用。', + 'Individual tea bags and a heat-safe pitcher for home or office.', + '個包装のティーバッグと耐熱ピッチャーで、自宅やオフィスに。', + ), + category: 'lifestyle', + brand: 'Mori', + coverUrl: '/products/mori-coffee.svg', + priceCents: 18_900, + originalPriceCents: 21_900, + stock: 98, + sales: 1108, + rating: 4.8, + status: 'on_sale', + featured: true, + createdAt: now, + updatedAt: now, + }, + { + id: 11, + sku: 'NOVA-CLOCK-11', + name: localized( + 'Nova 极简床头钟', + 'Nova Minimal Bedside Clock', + 'Nova ミニマルベッドサイドクロック', + ), + subtitle: localized( + '自动调光,不打扰睡眠', + 'Automatic dimming for calmer sleep', + '自動調光で、眠りを妨げない', + ), + description: localized( + '环境光感应、双闹钟与静音按键,夜间读数清晰。', + 'Ambient sensing, dual alarms, and quiet controls with a clear night display.', + '環境光センサー、デュアルアラーム、静音ボタンを備え、夜も見やすい表示です。', + ), + category: 'home', + brand: 'Nova', + coverUrl: '/products/nova-lamp.svg', + priceCents: 21_900, + originalPriceCents: 25_900, + stock: 55, + sales: 667, + rating: 4.5, + status: 'on_sale', + featured: false, + createdAt: now, + updatedAt: now, + }, + { + id: 12, + sku: 'TRAIL-POUCH-12', + name: localized( + 'Trail 防水收纳包', + 'Trail Waterproof Organizer', + 'Trail 防水オーガナイザー', + ), + subtitle: localized( + '分区清晰,旅行更轻松', + 'Clear organization for easier travel', + '仕分けしやすく、旅をもっと軽快に', + ), + description: localized( + '防泼水拉链与可视网袋,适合收纳线材、洗漱或户外小物。', + 'Water-resistant zips and mesh dividers for cables, toiletries, and trail essentials.', + '撥水ファスナーとメッシュ仕切りで、ケーブルや洗面用品、小物を整理できます。', + ), + category: 'outdoor', + brand: 'Trail', + coverUrl: '/products/trail-backpack.svg', + priceCents: 15_900, + originalPriceCents: 18_900, + stock: 132, + sales: 975, + rating: 4.7, + status: 'on_sale', + featured: false, + createdAt: now, + updatedAt: now, + }, +]; + +const feed = Array.from({ length: 18 }, (_, index) => ({ + id: index + 1, + title: + [ + 'Mobile performance budget', + 'Streaming UI patterns', + 'Type-safe request layers', + ][index % 3] ?? 'Vue H5 practice', + summary: `A practical note for production mobile H5 teams · #${index + 1}`, + category: ['Performance', 'AI', 'Architecture'][index % 3] ?? 'Vue', +})); + +function success(data: T, requestId?: string) { + return { code: 200, msg: 'ok', data, requestId }; } export default [ { - url: '/mock-api/login', + url: '/api/auth/login', method: 'post', - response: ({ body, query }: Response) => { - console.log('body>>>>>>>>', body); - console.log('query>>>>>>>>', query); - return { - code: 200, - message: 'ok', - data: { name: 'Evan', age: 26, token: 'mock-token-123456' }, - }; + response: ({ body, headers }: MockContext) => { + const name = + typeof body.name === 'string' && body.name.trim() + ? body.name.trim() + : 'Demo User'; + currentUserName = name; + return success( + { name, token: 'mock-token-v2', expiresIn: 7200 }, + headers['x-request-id'], + ); }, }, + { + url: '/api/user/profile', + method: 'get', + response: ({ headers }: MockContext) => + success( + { id: 1, name: currentUserName, role: 'admin', plan: 'pro' }, + headers['x-request-id'], + ), + }, + { + url: '/api/examples/tasks', + method: 'get', + response: ({ headers }: MockContext) => + success(tasks, headers['x-request-id']), + }, + { + url: '/api/examples/tasks/:id/toggle', + method: 'post', + response: ({ query, headers }: MockContext) => { + const task = + tasks.find((item) => item.id === Number(query.id)) ?? tasks[0]; + if (task) task.done = !task.done; + return success(task, headers['x-request-id']); + }, + }, + { + url: '/api/examples/feed', + method: 'get', + response: ({ query, headers }: MockContext) => { + const cursor = Math.max(0, Number(query.cursor) || 0); + const limit = Math.min(20, Math.max(1, Number(query.limit) || 6)); + const list = feed.slice(cursor, cursor + limit); + return success( + { + list, + total: feed.length, + page: Math.floor(cursor / limit) + 1, + pageSize: limit, + hasMore: cursor + list.length < feed.length, + }, + headers['x-request-id'], + ); + }, + }, + { + url: '/api/projects', + method: 'get', + response: ({ query, headers }: MockContext) => { + const page = Math.max(1, Number(query.page) || 1); + const pageSize = Math.min(100, Math.max(1, Number(query.pageSize) || 20)); + const list = projects.slice((page - 1) * pageSize, page * pageSize); + return success( + { + list, + total: projects.length, + page, + pageSize, + hasMore: page * pageSize < projects.length, + }, + headers['x-request-id'], + ); + }, + }, + { + url: '/api/projects', + method: 'post', + response: ({ body, headers }: MockContext) => { + const input = body as unknown as ProjectInput; + const timestamp = new Date().toISOString(); + const project: Project = { + id: nextProjectId++, + ownerId: 1, + name: input.name.trim(), + description: input.description ?? '', + status: input.status ?? 'active', + createdAt: timestamp, + updatedAt: timestamp, + }; + projects = [project, ...projects]; + return success(project, headers['x-request-id']); + }, + }, + { + url: '/api/projects/:id', + method: 'get', + response: ({ query, headers }: MockContext) => { + const project = projects.find((item) => item.id === Number(query.id)); + return project + ? success(project, headers['x-request-id']) + : { code: 404, msg: 'Project not found', data: null }; + }, + }, + { + url: '/api/projects/:id', + method: 'patch', + response: ({ body, query, headers }: MockContext) => { + const index = projects.findIndex((item) => item.id === Number(query.id)); + if (index === -1) + return { code: 404, msg: 'Project not found', data: null }; + const current = projects[index]; + if (!current) return { code: 404, msg: 'Project not found', data: null }; + projects[index] = { + ...current, + ...(body as unknown as ProjectUpdateInput), + updatedAt: new Date().toISOString(), + }; + return success(projects[index], headers['x-request-id']); + }, + }, + { + url: '/api/projects/:id', + method: 'delete', + response: ({ query, headers }: MockContext) => { + const id = Number(query.id); + projects = projects.filter((item) => item.id !== id); + return success({ deleted: true, id }, headers['x-request-id']); + }, + }, + { + url: '/api/products', + method: 'get', + response: ({ query, headers }: MockContext) => { + const page = Math.max(1, Number(query.page) || 1); + const pageSize = Math.min(50, Math.max(1, Number(query.pageSize) || 8)); + const keyword = (query.keyword ?? '').trim().toLowerCase(); + let list = products.filter((item) => item.status === 'on_sale'); + if (query.category) + list = list.filter((item) => item.category === query.category); + if (keyword) + list = list.filter((item) => + `${item.sku} ${item.brand} ${Object.values(item.name).join(' ')}` + .toLowerCase() + .includes(keyword), + ); + if (query.sort === 'sales') list.sort((a, b) => b.sales - a.sales); + else if (query.sort === 'price_asc') + list.sort((a, b) => a.priceCents - b.priceCents); + else if (query.sort === 'price_desc') + list.sort((a, b) => b.priceCents - a.priceCents); + else + list.sort( + (a, b) => + Number(b.featured) - Number(a.featured) || b.sales - a.sales, + ); + const total = list.length; + const pageList = list.slice((page - 1) * pageSize, page * pageSize); + return success( + { + list: pageList, + total, + page, + pageSize, + hasMore: page * pageSize < total, + }, + headers['x-request-id'], + ); + }, + }, + { + url: '/api/products/:id', + method: 'get', + response: ({ query, headers }: MockContext) => + success( + products.find((item) => item.id === Number(query.id)), + headers['x-request-id'], + ), + }, + { + url: '/api/admin/products', + method: 'get', + response: ({ query, headers }: MockContext) => { + const keyword = (query.keyword ?? '').trim().toLowerCase(); + let list = [...products]; + if (query.status) + list = list.filter((item) => item.status === query.status); + if (keyword) + list = list.filter((item) => + `${item.sku} ${item.brand} ${Object.values(item.name).join(' ')}` + .toLowerCase() + .includes(keyword), + ); + return success( + { list, total: list.length, page: 1, pageSize: 50, hasMore: false }, + headers['x-request-id'], + ); + }, + }, + { + url: '/api/admin/products', + method: 'post', + response: ({ body, headers }: MockContext) => { + const timestamp = new Date().toISOString(); + const product: Product = { + ...(body as unknown as ProductInput), + id: nextProductId++, + createdAt: timestamp, + updatedAt: timestamp, + }; + products = [product, ...products]; + return success(product, headers['x-request-id']); + }, + }, + { + url: '/api/admin/products/:id', + method: 'patch', + response: ({ body, query, headers }: MockContext) => { + const index = products.findIndex((item) => item.id === Number(query.id)); + if (index === -1) + return { code: 404, msg: 'Product not found', data: null }; + const current = products[index]; + if (!current) return { code: 404, msg: 'Product not found', data: null }; + products[index] = { + ...current, + ...(body as Partial), + updatedAt: new Date().toISOString(), + }; + return success(products[index], headers['x-request-id']); + }, + }, + { + url: '/api/admin/products/:id', + method: 'delete', + response: ({ query, headers }: MockContext) => { + const id = Number(query.id); + products = products.filter((item) => item.id !== id); + return success({ deleted: true, id }, headers['x-request-id']); + }, + }, + { + url: '/api/examples/unauthorized', + method: 'get', + statusCode: 401, + response: () => ({ code: 401, msg: 'Session expired', data: null }), + }, + { + url: '/api/examples/bad-request', + method: 'get', + statusCode: 400, + response: () => ({ + code: 400, + msg: 'Invalid request parameters', + data: { field: 'keyword' }, + }), + }, + { + url: '/api/examples/forbidden', + method: 'get', + statusCode: 403, + response: () => ({ code: 403, msg: 'Insufficient permission', data: null }), + }, + { + url: '/api/examples/not-found', + method: 'get', + statusCode: 404, + response: () => ({ code: 404, msg: 'Resource not found', data: null }), + }, + { + url: '/api/examples/conflict', + method: 'get', + statusCode: 409, + response: () => ({ + code: 409, + msg: 'Resource version conflict', + data: null, + }), + }, + { + url: '/api/examples/validation', + method: 'get', + response: () => ({ + code: 422, + msg: 'Business validation failed', + data: { fields: ['name'] }, + }), + }, + { + url: '/api/examples/server-error', + method: 'get', + statusCode: 500, + response: () => ({ code: 500, msg: 'Internal server error', data: null }), + }, + { + url: '/api/examples/timeout', + method: 'get', + timeout: 350, + response: () => success({ completed: true }), + }, ] as MockMethod[]; diff --git a/nginx.conf b/nginx.conf index 45ccc2e..55c6c98 100644 --- a/nginx.conf +++ b/nginx.conf @@ -21,8 +21,9 @@ http { # Security headers add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Content-Type-Options "nosniff" always; - add_header X-XSS-Protection "1; mode=block" always; - add_header Referrer-Policy "no-referrer-when-downgrade" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https: wss:; worker-src 'self' blob:; manifest-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'" always; location / { root /usr/share/nginx/html; @@ -50,4 +51,4 @@ http { root html; } } -} \ No newline at end of file +} diff --git a/openapi/schema.yaml b/openapi/schema.yaml new file mode 100644 index 0000000..1ba7e7a --- /dev/null +++ b/openapi/schema.yaml @@ -0,0 +1,784 @@ +openapi: 3.0.3 +info: + title: Vue H5 Template API Contract + version: 2.0.0 +servers: + - url: /api +paths: + /auth/login: + post: + operationId: login + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/LoginRequest' + responses: + '200': + description: Login result + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiResponse' + - type: object + properties: + data: + $ref: '#/components/schemas/LoginResult' + /user/profile: + get: + operationId: getUserProfile + responses: + '200': + description: Current user + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiResponse' + - type: object + properties: + data: + $ref: '#/components/schemas/UserProfile' + /examples/tasks: + get: + operationId: getTasks + responses: + '200': + description: Task list + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiResponse' + - type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/Task' + /examples/tasks/{id}/toggle: + post: + operationId: toggleTask + parameters: + - in: path + name: id + required: true + schema: + type: integer + responses: + '200': + description: Updated task + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiResponse' + - type: object + properties: + data: + $ref: '#/components/schemas/Task' + /examples/feed: + get: + operationId: getFeed + parameters: + - in: query + name: cursor + schema: + type: integer + minimum: 0 + - in: query + name: limit + schema: + type: integer + minimum: 1 + maximum: 20 + responses: + '200': + description: Cursor feed + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + /examples/{scenario}: + get: + operationId: triggerRequestErrorExample + description: Development-only request error fixtures exposed by Mock and Gin. + parameters: + - in: path + name: scenario + required: true + schema: + type: string + enum: + [ + bad-request, + unauthorized, + forbidden, + not-found, + conflict, + validation, + server-error, + timeout, + ] + responses: + '200': + description: Business validation or delayed success fixture + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + '400': + description: Bad request fixture + '401': + description: Unauthorized fixture + '403': + description: Forbidden fixture + '404': + description: Not found fixture + '409': + description: Conflict fixture + '500': + description: Server error fixture + /projects: + get: + operationId: listProjects + security: + - bearerAuth: [] + parameters: + - in: query + name: page + schema: + type: integer + minimum: 1 + - in: query + name: pageSize + schema: + type: integer + minimum: 1 + maximum: 100 + responses: + '200': + description: Paginated projects owned by the current user + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiResponse' + - type: object + properties: + data: + $ref: '#/components/schemas/ProjectPage' + post: + operationId: createProject + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ProjectInput' + responses: + '201': + description: Created project + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiResponse' + - type: object + properties: + data: + $ref: '#/components/schemas/Project' + /projects/{id}: + get: + operationId: getProject + security: + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/ProjectId' + responses: + '200': + description: Project + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + patch: + operationId: updateProject + security: + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/ProjectId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ProjectUpdateInput' + responses: + '200': + description: Updated project + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + delete: + operationId: deleteProject + security: + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/ProjectId' + responses: + '200': + description: Deletion result + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + /products: + get: + operationId: listProducts + parameters: + - $ref: '#/components/parameters/Page' + - $ref: '#/components/parameters/PageSize' + - in: query + name: keyword + schema: + type: string + - in: query + name: category + schema: + type: string + - in: query + name: sort + schema: + type: string + enum: [featured, sales, price_asc, price_desc] + responses: + '200': + description: Public on-sale product catalog + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiResponse' + - type: object + properties: + data: + $ref: '#/components/schemas/ProductPage' + /products/{id}: + get: + operationId: getProduct + parameters: + - $ref: '#/components/parameters/ProductId' + responses: + '200': + description: Public product detail + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiResponse' + - type: object + properties: + data: + $ref: '#/components/schemas/Product' + /admin/products: + get: + operationId: listAdminProducts + security: + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/Page' + - $ref: '#/components/parameters/PageSize' + - in: query + name: keyword + schema: + type: string + - in: query + name: status + schema: + $ref: '#/components/schemas/ProductStatus' + responses: + '200': + description: Product management list + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiResponse' + - type: object + properties: + data: + $ref: '#/components/schemas/ProductPage' + post: + operationId: createProduct + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ProductInput' + responses: + '201': + description: Created product + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + /admin/products/{id}: + patch: + operationId: updateProduct + security: + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/ProductId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ProductUpdateInput' + responses: + '200': + description: Updated product + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + delete: + operationId: deleteProduct + security: + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/ProductId' + responses: + '200': + description: Soft-deleted product + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + /ai/chat: + post: + operationId: streamChat + security: + - bearerAuth: [] + - {} + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ChatRequest' + responses: + '200': + description: Server-sent ChatChunk events + content: + text/event-stream: + schema: + type: string +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + parameters: + Page: + in: query + name: page + schema: + type: integer + minimum: 1 + PageSize: + in: query + name: pageSize + schema: + type: integer + minimum: 1 + maximum: 50 + ProjectId: + in: path + name: id + required: true + schema: + type: integer + minimum: 1 + ProductId: + in: path + name: id + required: true + schema: + type: integer + minimum: 1 + schemas: + ApiResponse: + type: object + required: [code, data, msg] + properties: + code: + type: integer + data: {} + msg: + type: string + requestId: + type: string + LoginRequest: + type: object + required: [name, password] + properties: + name: + type: string + minLength: 1 + password: + type: string + minLength: 1 + LoginResult: + type: object + required: [name, token] + properties: + name: + type: string + token: + type: string + expiresIn: + type: integer + UserProfile: + type: object + required: [id, name, role, plan] + properties: + id: + type: integer + name: + type: string + role: + type: string + enum: [admin, member] + plan: + type: string + enum: [free, pro] + Task: + type: object + required: [id, title, done] + properties: + id: + type: integer + title: + type: string + done: + type: boolean + FeedItem: + type: object + required: [id, title, summary, category] + properties: + id: + type: integer + title: + type: string + summary: + type: string + category: + type: string + Project: + type: object + required: [id, ownerId, name, description, status, createdAt, updatedAt] + properties: + id: + type: integer + ownerId: + type: integer + name: + type: string + description: + type: string + status: + $ref: '#/components/schemas/ProjectStatus' + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + ProjectStatus: + type: string + enum: [active, paused, archived] + ProjectInput: + type: object + required: [name] + properties: + name: + type: string + minLength: 1 + maxLength: 120 + description: + type: string + maxLength: 5000 + status: + $ref: '#/components/schemas/ProjectStatus' + ProjectUpdateInput: + type: object + properties: + name: + type: string + minLength: 1 + maxLength: 120 + description: + type: string + maxLength: 5000 + status: + $ref: '#/components/schemas/ProjectStatus' + ProjectPage: + type: object + required: [list, total, page, pageSize, hasMore] + properties: + list: + type: array + items: + $ref: '#/components/schemas/Project' + total: + type: integer + page: + type: integer + pageSize: + type: integer + hasMore: + type: boolean + Product: + type: object + required: + [ + id, + sku, + name, + subtitle, + description, + category, + brand, + coverUrl, + priceCents, + originalPriceCents, + stock, + sales, + rating, + status, + featured, + createdAt, + updatedAt, + ] + properties: + id: + type: integer + sku: + type: string + name: + $ref: '#/components/schemas/LocalizedText' + subtitle: + $ref: '#/components/schemas/LocalizedText' + description: + $ref: '#/components/schemas/LocalizedText' + category: + type: string + brand: + type: string + coverUrl: + type: string + priceCents: + type: integer + format: int64 + originalPriceCents: + type: integer + format: int64 + stock: + type: integer + sales: + type: integer + rating: + type: number + format: double + status: + $ref: '#/components/schemas/ProductStatus' + featured: + type: boolean + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + ProductStatus: + type: string + enum: [draft, on_sale, sold_out] + LocalizedText: + type: object + required: [zh-CN, en-US, ja-JP] + properties: + zh-CN: + type: string + minLength: 1 + en-US: + type: string + minLength: 1 + ja-JP: + type: string + minLength: 1 + ProductInput: + type: object + required: + [ + sku, + name, + subtitle, + description, + category, + brand, + coverUrl, + priceCents, + originalPriceCents, + stock, + sales, + rating, + status, + featured, + ] + properties: + sku: + type: string + minLength: 1 + maxLength: 64 + name: + $ref: '#/components/schemas/LocalizedText' + subtitle: + $ref: '#/components/schemas/LocalizedText' + description: + $ref: '#/components/schemas/LocalizedText' + category: + type: string + minLength: 1 + maxLength: 60 + brand: + type: string + minLength: 1 + maxLength: 80 + coverUrl: + type: string + minLength: 1 + maxLength: 500 + priceCents: + type: integer + minimum: 0 + originalPriceCents: + type: integer + minimum: 0 + stock: + type: integer + minimum: 0 + sales: + type: integer + minimum: 0 + rating: + type: number + minimum: 0 + maximum: 5 + status: + $ref: '#/components/schemas/ProductStatus' + featured: + type: boolean + ProductUpdateInput: + type: object + properties: + sku: + type: string + minLength: 1 + maxLength: 64 + name: + $ref: '#/components/schemas/LocalizedText' + subtitle: + $ref: '#/components/schemas/LocalizedText' + description: + $ref: '#/components/schemas/LocalizedText' + category: + type: string + minLength: 1 + maxLength: 60 + brand: + type: string + minLength: 1 + maxLength: 80 + coverUrl: + type: string + minLength: 1 + maxLength: 500 + priceCents: + type: integer + minimum: 0 + originalPriceCents: + type: integer + minimum: 0 + stock: + type: integer + minimum: 0 + sales: + type: integer + minimum: 0 + rating: + type: number + minimum: 0 + maximum: 5 + status: + $ref: '#/components/schemas/ProductStatus' + featured: + type: boolean + ProductPage: + type: object + required: [list, total, page, pageSize, hasMore] + properties: + list: + type: array + items: + $ref: '#/components/schemas/Product' + total: + type: integer + page: + type: integer + pageSize: + type: integer + hasMore: + type: boolean + DeleteResult: + type: object + required: [deleted, id] + properties: + deleted: + type: boolean + id: + type: integer + ChatMessage: + type: object + required: [role, content] + properties: + role: + type: string + enum: [system, user, assistant] + content: + type: string + minLength: 1 + maxLength: 20000 + ChatRequest: + type: object + required: [messages] + properties: + conversationId: + type: string + maxLength: 120 + messages: + type: array + minItems: 1 + maxItems: 100 + items: + $ref: '#/components/schemas/ChatMessage' diff --git a/oxfmt.config.ts b/oxfmt.config.ts new file mode 100644 index 0000000..a54323d --- /dev/null +++ b/oxfmt.config.ts @@ -0,0 +1,112 @@ +import { defineConfig } from 'oxfmt'; + +/** + * oxfmt 配置文件,移植自 vue-h5-template monorepo(turborepo-demo)的 @vh5/oxfmt-config。 + * 详见:https://oxc.rs/docs/guide/usage/formatter/config-file-reference.html + */ +export default defineConfig({ + /** + * 单行长度,适配 prettier 的 80 + * Default:100 + */ + printWidth: 80, + /** + * 缩进宽度 + * Default:2 + */ + tabWidth: 2, + /** + * Markdown、MDX、YAML 文件格式化包裹 + * type: always | never | preserve + * Default: preserve + */ + proseWrap: 'never', + /** + * 结尾添加分号 + * Default:true + */ + semi: true, + /** + * 使用单引号 + * Default:false + */ + singleQuote: true, + /** + * 对象属性添加引号 + * Default:as-needed + */ + quoteProps: 'as-needed', + /** + * 将多行元素的 > 放在最后一行的末尾,而不是单独放在下一行 + * Default:false + */ + bracketSameLine: false, + /** + * 对象字面量的大括号间添加空格 + * Default:true + */ + bracketSpacing: true, + /** + * 箭头函数参数总是使用括号 + * type: always | avoid + * Default:always + */ + arrowParens: 'always', + /** + * 配置 package.json 排序,但 oxfmt 不支持 pnpm-workspace + * Default:true + */ + sortPackageJson: false, + /** + * 配置 import 排序 + * Default:false + */ + sortImports: false, + /** + * 多行结构中的后置逗号 + * Default:all + */ + trailingComma: 'all', + /** + * 行尾换行符 + * type: lf | crlf | cr + * Default: lf + */ + endOfLine: 'lf', + /** + * 在文件最后插入一个换行 + * Default:true + */ + insertFinalNewline: true, + /** + * 控制格式化文件中的嵌入语言(如 CSS-in-JS 或 JS-in-Vue) + * Default:auto + */ + embeddedLanguageFormatting: 'auto', + /** + * Vue/HTML/Angular/Handlebars 的空白敏感度(oxfmt 会格式化