feat: 重构整体架构并迁移 oxlint/oxfmt 工具链

架构与目录:
- 引入分层 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
This commit is contained in:
fonghehe 2026-09-02 14:37:52 +08:00
parent bfca8a8858
commit 7b1c087c00
168 changed files with 16979 additions and 42637 deletions

View File

@ -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: 自定义',
},
};

19
.env
View File

@ -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 / varletproduction 单选
VITE_API_BASE_URL=/api
VITE_AI_API_BASE_URL=/api/ai
VITE_REQUEST_ID_ENABLED=true
VITE_UI_FRAMEWORK=vant

View File

@ -1,11 +1,12 @@
# 是否开启数据mock
VITE_USE_MOCK=true
# Token Key
VITE_TOKEN_KEY=Authorization
# 接入真实后端时关闭 MockAI 路径优先代理到 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
# 开发环境不注册 Service Worker避免缓存干扰调试
VITE_PWA_ENABLED=false
# 图片优化仅在 production build 执行
VITE_IMAGE_OPTIMIZE=false

25
.env.example Normal file
View File

@ -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

7
.env.integration Normal file
View File

@ -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

View File

@ -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
# PWA 是可选能力,按部署需求开启
VITE_PWA_ENABLED=false
# 仅生产构建使用 Sharp/SVGO
VITE_IMAGE_OPTIMIZE=true

View File

@ -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
VITE_PWA_ENABLED=false
VITE_IMAGE_OPTIMIZE=false

View File

@ -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
}
}

35
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,35 @@
name: CI
on:
push:
pull_request:
jobs:
verify:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
# 不要在这里写 versionpnpm/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

18
.github/workflows/release.yml vendored Normal file
View File

@ -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

14
.gitignore vendored
View File

@ -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

View File

@ -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"

View File

@ -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

View File

@ -1,10 +0,0 @@
/dist/*
.local
.output.js
/node_modules/**
.npmrc
**/*.svg
**/*.sh
/public/*

View File

@ -0,0 +1,3 @@
{
".": "2.0.0"
}

View File

@ -13,10 +13,10 @@
// i18n
"Lokalise.i18n-ally",
// CSS
"vunguyentuan.vscode-css-variables",
"vunguyentuan.vscode-css-variables"
],
"unwantedRecommendations": [
// volar
"octref.vetur"
]
}
}

109
AGENTS.md Normal file
View File

@ -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<T>` 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 `<SvgIcon />`; 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 68px radius, cards/dialogs at 812px, and routine transitions between 120200ms. 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.

10
CHANGELOG.md Normal file
View File

@ -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.

247
README.md
View File

@ -1,146 +1,163 @@
<div align="center">
<h1>Vue H5 Template</h1>
<p>基于 Vue 3 + Vite 7 + TypeScript + 多 UI 组件库 + Pinia + viewport 适配方案,构建移动端快速开发脚手架</p>
# Vue H5 Template v2
<p>
<img src="https://img.shields.io/github/license/sunniejs/vue-h5-template" alt="license" />
<img src="https://img.shields.io/github/stars/sunniejs/vue-h5-template?style=social" alt="stars" />
<img src="https://img.shields.io/github/forks/sunniejs/vue-h5-template?style=social" alt="forks" />
</p>
面向真实移动 H5 业务的 Vue 3 工程模板。v2 不是组件库 Playground它提供类型安全请求、OpenAPI 类型生成、TanStack Query、可中止的 Streaming AI Chat、PWA、SVG Sprite、production 图片优化、单选 UI 框架以及有意义的测试基线。
<p>
<a href="https://sunniejs.github.io/vue-h5-template/">在线文档</a> ·
<a href="https://github.com/sunniejs/vue-h5-template/issues">问题反馈</a>
</p>
</div>
[在线文档(简体中文)](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` 实现持久化
- **viewportvw适配** — 基于 `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 StatePinia 只管理 Client State
- Axios Type-safe Client`ApiResponse<T>``ApiError`、Request ID、Token、401、Timeout 与 Network Error
- `pnpm api:generate` 从本地 OpenAPI Schema 生成后端契约类型
- AI ChatPOST SSE、`ReadableStream``AbortController`、Regenerate、Retry、Markdown、代码块与 DOMPurify
- 真实商城12+ 三语商品、搜索分类、下拉刷新、分页加载、详情、购物车与商品管理
- 交付项目管理查看、新建、编辑、删除和状态筛选Vite Mock / Gin + PostgreSQL 共用契约
- SVG Sprite`src/assets/icons` 自动加载及类型化 `<SvgIcon />`
- Vant / NutUI / Varlet 构建期单选,不把三套 UI 同时放入 production graph
- 可选 PWAService 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
<p>
<img src="https://cdn.jsdelivr.net/gh/fonghehe/picture/personal/account.jpg" width="256" />
</p>
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
<table>
<tr align="center">
<td>WechatPay</td>
<td>AliPay</td>
</tr>
<tr align="center">
<td><img src="https://cdn.jsdelivr.net/gh/fonghehe/picture/contribute/wechatPay.jpeg" width="256" /></td>
<td><img src="https://cdn.jsdelivr.net/gh/fonghehe/picture/contribute/aliPay.jpeg" width="256" /></td>
</tr>
</table>
模板采用轻量、供应商无关的接口:
## Star History
```ts
interface ChatProvider {
chat(
messages: readonly ChatMessage[],
options?: ChatOptions,
): AsyncIterable<ChatChunk>;
}
```
[![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``<html lang>`。新增语言时还需要加入 `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 <workspace>/backend/vue-h5-template
docker compose up --build
# 终端 2使用已提交的 .env.integration 启动真实联调模式
cd <workspace>/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并经过 DOMPurifyRedirect 仅接受站内绝对路径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

7
SECURITY.md Normal file
View File

@ -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.

View File

@ -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 = '';

View File

@ -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<keyof ViteEnv>([
'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<string>): 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;
}

View File

@ -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
<script setup lang="ts">
const message = ref('Hello Vue')
</script>
\`\`\`
Use **Composition API** for reusable logic and keep server state outside Pinia.`;
function readBody(request: IncomingMessage): Promise<string> {
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();
}
});
},
};
}

View File

@ -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<Options['resolvers']> {
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),
});
};

View File

@ -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),
});
};

View File

@ -10,7 +10,7 @@ export const ConfigCompressPlugin = () => {
verbose: true, // 默认即可
disable: false, //开启压缩(不禁用),默认即可
deleteOriginFile: false, //删除源文件
threshold: 10240, //压缩前最小文件大小
threshold: 10_240, //压缩前最小文件大小
algorithm: 'gzip', //压缩算法
ext: '.gz', //文件类型
});

View File

@ -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 },
});
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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,

View File

@ -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',
});
};

View File

@ -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,
},
},
},

View File

@ -1,10 +0,0 @@
/**
* @name ConfigQrcodePlugin
* @description qrcode插件
*/
import { qrcode } from 'vite-plugin-qrcode';
export const ConfigQrcodePlugin = () => {
return qrcode();
};

View File

@ -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'],
});
};

View File

@ -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,
});
};
}

View File

@ -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;

View File

@ -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<string, ProxyOptions>;
/**
*
* - AI FastAPI /api Gin
* - /api vite-plugin-mock
*/
export function createViteProxy(env: ViteEnv): Record<string, ProxyOptions> {
const proxy: Record<string, ProxyOptions> = {};
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;
}

1
commitlint.config.mjs Normal file
View File

@ -0,0 +1 @@
export default { extends: ['@commitlint/config-conventional'] };

160
e2e/app.spec.ts Normal file
View File

@ -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);
});

132
e2e/ui.spec.ts Normal file
View File

@ -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();
});

View File

@ -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/**']),
);

View File

@ -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"
/>
<meta name="format-detection" content="telephone=no, email=no, date=no, address=no" />
<meta
name="format-detection"
content="telephone=no, email=no, date=no, address=no"
/>
<meta name="theme-color" content="#ffffff" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />

View File

@ -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<string, unknown>;
query: Record<string, string>;
headers: Record<string, string>;
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<T>(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<ProductInput>),
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[];

View File

@ -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;
}
}
}
}

784
openapi/schema.yaml Normal file
View File

@ -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'

112
oxfmt.config.ts Normal file
View File

@ -0,0 +1,112 @@
import { defineConfig } from 'oxfmt';
/**
* oxfmt vue-h5-template monorepoturborepo-demo @vh5/oxfmt-config
* https://oxc.rs/docs/guide/usage/formatter/config-file-reference.html
*/
export default defineConfig({
/**
* prettier 80
* Default100
*/
printWidth: 80,
/**
*
* Default2
*/
tabWidth: 2,
/**
* MarkdownMDXYAML
* type: always | never | preserve
* Default: preserve
*/
proseWrap: 'never',
/**
*
* Defaulttrue
*/
semi: true,
/**
* 使
* Defaultfalse
*/
singleQuote: true,
/**
*
* Defaultas-needed
*/
quoteProps: 'as-needed',
/**
* >
* Defaultfalse
*/
bracketSameLine: false,
/**
*
* Defaulttrue
*/
bracketSpacing: true,
/**
* 使
* type: always | avoid
* Defaultalways
*/
arrowParens: 'always',
/**
* package.json oxfmt pnpm-workspace
* Defaulttrue
*/
sortPackageJson: false,
/**
* import
* Defaultfalse
*/
sortImports: false,
/**
*
* Defaultall
*/
trailingComma: 'all',
/**
*
* type: lf | crlf | cr
* Default: lf
*/
endOfLine: 'lf',
/**
*
* Defaulttrue
*/
insertFinalNewline: true,
/**
* CSS-in-JS JS-in-Vue
* Defaultauto
*/
embeddedLanguageFormatting: 'auto',
/**
* Vue/HTML/Angular/Handlebars oxfmt <template>
* type: css | strict | ignore
* Defaultcss
*/
htmlWhitespaceSensitivity: 'css',
overrides: [
{
files: [
'*.json',
'*.json5',
'*.jsonc',
'*.code-workspace',
'**/*.json',
'**/*.json5',
'**/*.jsonc',
'**/*.code-workspace',
],
options: {
trailingComma: 'none',
quoteProps: 'preserve',
singleQuote: false,
},
},
],
});

435
oxlint.config.ts Normal file
View File

@ -0,0 +1,435 @@
import { defineConfig } from 'oxlint';
/**
* oxlint vue-h5-template monorepoturborepo-demo @vh5/oxlint-config
* workspace
*
* eslint correctness/suspicious+ typescript strict
* + unicorn + import + vue + vitest + node
*/
export default defineConfig({
// —— pluginsoxlint 支持的插件,将默认开启的也显式配置 ——
// type: eslint | react | unicorn | typescript | oxc |
// import | jsdoc | jest | vitest | jsx-a11y | nextjs |
// react-perf | promise | node | vue
plugins: [
'eslint',
'import',
'node',
'oxc',
'typescript',
'unicorn',
'vitest',
'vue',
],
// 通过 npm 包加载的外部 ESLint 插件oxlint 的 jsPlugins 机制)
jsPlugins: [
{
name: 'command',
specifier: 'eslint-plugin-command',
},
{
name: 'eslint-comments',
specifier: '@eslint-community/eslint-plugin-eslint-comments',
},
],
categories: {
correctness: 'error',
suspicious: 'warn',
},
env: {
browser: true,
es2021: true,
node: true,
},
globals: {
document: 'readonly',
navigator: 'readonly',
window: 'readonly',
},
ignorePatterns: [
'**/dist/**',
'**/node_modules/**',
'docs/**',
'**/*.json',
'**/*.md',
'**/*.svg',
'**/*.yaml',
'**/*.yml',
'**/*.d.ts',
],
rules: {
// ===== 核心 javascript 规则 =====
'accessor-pairs': [
'error',
{ enforceForClassMembers: true, setWithoutGet: true },
],
'array-callback-return': 'error',
'block-scoped-var': 'error',
'default-case-last': 'error',
eqeqeq: ['error', 'always'],
'eslint/no-unreachable': 'error',
'new-cap': ['error', { capIsNew: false, newIsCap: true, properties: true }],
'no-alert': 'error',
'no-array-constructor': 'error',
'no-caller': 'error',
'no-case-declarations': 'error',
'no-console': ['error', { allow: ['warn', 'error'] }],
'no-control-regex': 'error',
'no-debugger': 'error',
'no-empty': ['error', { allowEmptyCatch: true }],
'no-fallthrough': 'error',
'no-new-func': 'error',
'no-object-constructor': 'error',
'no-new-native-nonconstructor': 'error',
'no-labels': ['error', { allowLoop: false, allowSwitch: false }],
'no-lone-blocks': 'error',
'no-multi-str': 'error',
'no-nonoctal-decimal-escape': 'error',
'no-proto': 'error',
'no-prototype-builtins': 'error',
'no-redeclare': ['error', { builtinGlobals: false }],
'no-regex-spaces': 'error',
'no-self-compare': 'error',
'no-sequences': 'error',
'no-shadow': 'off',
'no-shadow-restricted-names': 'error',
'eslint/no-empty-function': [
'error',
{ allow: ['arrowFunctions', 'functions', 'methods'] },
],
'no-template-curly-in-string': 'error',
'no-throw-literal': 'error',
'no-unassigned-vars': 'error',
'no-unexpected-multiline': 'error',
'no-unused-expressions': [
'error',
{
allowShortCircuit: true,
allowTaggedTemplates: true,
allowTernary: true,
},
],
'eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
],
'no-var': 'error',
'no-eval': 'error',
'no-iterator': 'error',
'no-new-wrappers': 'error',
'no-restricted-globals': [
'error',
{ message: 'Use `globalThis` instead.', name: 'global' },
{ message: 'Use `globalThis` instead.', name: 'self' },
],
'no-useless-call': 'error',
'no-useless-computed-key': 'error',
'no-useless-constructor': 'error',
'no-useless-return': 'error',
'prefer-const': [
'error',
{ destructuring: 'all', ignoreReadBeforeAssign: true },
],
'prefer-exponentiation-operator': 'error',
'prefer-promise-reject-errors': 'error',
'prefer-rest-params': 'error',
'prefer-spread': 'error',
'prefer-template': 'error',
'preserve-caught-error': ['error', { requireCatchParameter: false }],
'symbol-description': 'error',
'unicode-bom': ['error', 'never'],
'use-isnan': [
'error',
{ enforceForIndexOf: true, enforceForSwitchCase: true },
],
'valid-typeof': ['error', { requireStringLiterals: true }],
'vars-on-top': 'error',
yoda: ['error', 'never'],
// ===== command 插件 =====
'command/command': 'error',
// ===== eslint-comments 插件 =====
'eslint-comments/no-aggregating-enable': 'error',
'eslint-comments/no-duplicate-disable': 'error',
'eslint-comments/no-unlimited-disable': 'error',
'eslint-comments/no-unused-enable': 'error',
'eslint/no-underscore-dangle': 'off',
// ===== import 插件 =====
'import/consistent-type-specifier-style': ['error', 'prefer-top-level'],
'import/first': 'error',
'import/no-duplicates': 'error',
'import/no-mutable-exports': 'error',
'import/no-named-as-default': 'off',
'import/no-named-as-default-member': 'off',
'import/no-named-default': 'error',
'import/no-self-import': 'error',
'import/no-unassigned-import': 'off',
'import/no-webpack-loader-syntax': 'error',
// ===== node 插件 =====
'node/no-exports-assign': 'error',
'node/no-new-require': 'error',
'node/no-path-concat': 'error',
// ===== typescript 插件(从 @typescript-eslint strict 预设迁移,非类型感知)=====
'typescript/ban-ts-comment': 'error',
'typescript/no-duplicate-enum-values': 'error',
'typescript/no-dynamic-delete': 'error',
'typescript/no-empty-object-type': 'error',
'typescript/no-extra-non-null-assertion': 'error',
'typescript/no-extraneous-class': 'error',
'typescript/no-invalid-void-type': 'error',
'typescript/no-misused-new': 'error',
'typescript/no-non-null-asserted-nullish-coalescing': 'error',
'typescript/no-non-null-asserted-optional-chain': 'error',
'typescript/no-non-null-assertion': 'error',
'typescript/no-require-imports': 'error',
'typescript/no-this-alias': 'error',
'typescript/no-unnecessary-type-constraint': 'error',
'typescript/no-unsafe-declaration-merging': 'error',
'typescript/no-unsafe-function-type': 'error',
'typescript/no-var-requires': 'error',
'typescript/no-wrapper-object-types': 'error',
'typescript/prefer-as-const': 'error',
'typescript/prefer-literal-enum-member': 'error',
'typescript/prefer-namespace-keyword': 'error',
'typescript/triple-slash-reference': 'error',
'typescript/unified-signatures': 'error',
// typescript 类型感知规则oxlint 暂不支持,关闭
'typescript/await-thenable': 'off',
'typescript/consistent-return': 'off',
'typescript/no-base-to-string': 'off',
'typescript/no-duplicate-type-constituents': 'off',
'typescript/no-floating-promises': 'off',
'typescript/no-misused-spread': 'off',
'typescript/no-redundant-type-constituents': 'off',
'typescript/no-unnecessary-boolean-literal-compare': 'off',
'typescript/no-unnecessary-template-expression': 'off',
'typescript/no-unnecessary-type-arguments': 'off',
'typescript/no-unnecessary-type-assertion': 'off',
'typescript/no-unnecessary-type-conversion': 'off',
'typescript/no-unnecessary-type-parameters': 'off',
'typescript/no-unsafe-enum-comparison': 'off',
'typescript/no-unsafe-type-assertion': 'off',
'typescript/no-useless-default-assignment': 'off',
'typescript/restrict-template-expressions': 'off',
'typescript/unbound-method': 'off',
// ===== unicorn 插件 =====
'unicorn/consistent-function-scoping': 'off',
'unicorn/no-process-exit': 'error',
'unicorn/no-single-promise-in-promise-methods': 'off',
'unicorn/no-useless-spread': 'off',
'unicorn/prefer-global-this': 'off',
'unicorn/prefer-module': 'error',
'unicorn/catch-error-name': 'error',
'unicorn/consistent-assert': 'error',
'unicorn/consistent-date-clone': 'error',
'unicorn/consistent-empty-array-spread': 'error',
'unicorn/consistent-existence-index-check': 'error',
'unicorn/consistent-template-literal-escape': 'error',
'unicorn/empty-brace-spaces': 'error',
'unicorn/error-message': 'error',
'unicorn/escape-case': 'error',
'unicorn/explicit-length-check': 'error',
'unicorn/new-for-builtins': 'error',
'unicorn/no-abusive-eslint-disable': 'error',
'unicorn/no-accessor-recursion': 'error',
'unicorn/no-anonymous-default-export': 'error',
'unicorn/no-array-callback-reference': 'error',
'unicorn/no-array-method-this-argument': 'error',
'unicorn/no-array-reduce': 'error',
'unicorn/no-array-reverse': 'error',
'unicorn/no-array-sort': 'error',
'unicorn/no-await-expression-member': 'error',
'unicorn/no-await-in-promise-methods': 'error',
'unicorn/no-console-spaces': 'error',
'unicorn/no-document-cookie': 'error',
'unicorn/no-empty-file': 'error',
'unicorn/no-hex-escape': 'error',
// oxlint 实现较 eslint 更严格,会误报既有代码,暂关闭
'unicorn/no-immediate-mutation': 'off',
'unicorn/no-instanceof-builtins': 'error',
'unicorn/no-invalid-fetch-options': 'error',
'unicorn/no-invalid-remove-event-listener': 'error',
'unicorn/no-lonely-if': 'error',
'unicorn/no-magic-array-flat-depth': 'error',
'unicorn/no-negated-condition': 'error',
'unicorn/no-negation-in-equality-check': 'error',
'unicorn/no-nested-ternary': 'error',
'unicorn/no-new-array': 'error',
'unicorn/no-new-buffer': 'error',
'unicorn/no-object-as-default-parameter': 'error',
'unicorn/no-static-only-class': 'error',
'unicorn/no-thenable': 'error',
'unicorn/no-this-assignment': 'error',
'unicorn/no-typeof-undefined': 'error',
'unicorn/no-unnecessary-array-flat-depth': 'error',
'unicorn/no-unnecessary-array-splice-count': 'error',
'unicorn/no-unnecessary-await': 'error',
'unicorn/no-unnecessary-slice-end': 'error',
'unicorn/no-unreadable-array-destructuring': 'error',
'unicorn/no-unreadable-iife': 'error',
'unicorn/no-useless-collection-argument': 'error',
'unicorn/no-useless-error-capture-stack-trace': 'error',
'unicorn/no-useless-fallback-in-spread': 'error',
'unicorn/no-useless-iterator-to-array': 'error',
'unicorn/no-useless-length-check': 'error',
'unicorn/no-useless-promise-resolve-reject': 'error',
'unicorn/no-useless-switch-case': 'error',
'unicorn/no-zero-fractions': 'error',
'unicorn/number-literal-case': 'error',
'unicorn/numeric-separators-style': 'error',
'unicorn/prefer-add-event-listener': 'error',
'unicorn/prefer-array-find': 'error',
'unicorn/prefer-array-flat': 'error',
'unicorn/prefer-array-flat-map': 'error',
'unicorn/prefer-array-index-of': 'error',
'unicorn/prefer-array-some': 'error',
'unicorn/prefer-bigint-literals': 'error',
'unicorn/prefer-blob-reading-methods': 'error',
'unicorn/prefer-class-fields': 'error',
'unicorn/prefer-classlist-toggle': 'error',
'unicorn/prefer-code-point': 'error',
'unicorn/prefer-date-now': 'error',
'unicorn/prefer-default-parameters': 'error',
'unicorn/prefer-dom-node-append': 'error',
'unicorn/prefer-dom-node-dataset': 'error',
'unicorn/prefer-dom-node-remove': 'error',
'unicorn/prefer-event-target': 'error',
'unicorn/prefer-export-from': ['error', { checkUsedVariables: false }],
'unicorn/prefer-includes': 'error',
'unicorn/prefer-keyboard-event-key': 'error',
'unicorn/prefer-logical-operator-over-ternary': 'error',
'unicorn/prefer-math-min-max': 'error',
'unicorn/prefer-math-trunc': 'error',
'unicorn/prefer-modern-dom-apis': 'error',
'unicorn/prefer-modern-math-apis': 'error',
'unicorn/prefer-native-coercion-functions': 'error',
'unicorn/prefer-negative-index': 'error',
'unicorn/prefer-node-protocol': 'error',
'unicorn/prefer-number-properties': 'error',
'unicorn/prefer-object-from-entries': 'error',
'unicorn/prefer-optional-catch-binding': 'error',
'unicorn/prefer-prototype-methods': 'error',
'unicorn/prefer-query-selector': 'error',
'unicorn/prefer-reflect-apply': 'error',
'unicorn/prefer-regexp-test': 'error',
'unicorn/prefer-response-static-json': 'error',
'unicorn/prefer-set-has': 'error',
'unicorn/prefer-set-size': 'error',
'unicorn/prefer-single-call': 'error',
'unicorn/prefer-spread': 'error',
'unicorn/prefer-string-raw': 'error',
// 项目 target ES2020对齐 Vite build targetreplaceAll 是 ES2021 API故关闭此规则
'unicorn/prefer-string-replace-all': 'off',
'unicorn/prefer-string-slice': 'error',
'unicorn/prefer-string-starts-ends-with': 'error',
'unicorn/prefer-string-trim-start-end': 'error',
// oxlint 实现较 eslint 更严格(含 cloneDeep暂关闭以保持迁移前行为
'unicorn/prefer-structured-clone': 'off',
'unicorn/prefer-ternary': 'error',
'unicorn/prefer-type-error': 'error',
'unicorn/relative-url-style': 'error',
'unicorn/require-array-join-separator': 'error',
'unicorn/require-module-attributes': 'error',
'unicorn/require-module-specifiers': 'error',
'unicorn/require-number-to-fixed-digits-argument': 'error',
'unicorn/switch-case-braces': 'error',
'unicorn/switch-case-break-position': 'error',
'unicorn/text-encoding-identifier-case': 'error',
'unicorn/throw-new-error': 'error',
// ===== vitest / test 插件 =====
'jest/no-conditional-expect': 'off',
'jest/require-to-throw-message': 'off',
'vitest/consistent-test-it': ['error', { fn: 'it', withinDescribe: 'it' }],
'vitest/hoisted-apis-on-top': 'off',
'vitest/no-focused-tests': 'error',
'vitest/no-identical-title': 'error',
'vitest/no-import-node-test': 'error',
'vitest/prefer-hooks-in-order': 'error',
'vitest/prefer-lowercase-title': 'error',
'vitest/require-mock-type-parameters': 'off',
// ===== vue 插件 =====
'vue/no-reserved-component-names': 'off',
'vue/prefer-import-from-vue': 'error',
},
overrides: [
{
files: ['*.d.ts', '**/*.d.ts'],
rules: {
'import/no-unassigned-import': 'off',
'typescript/triple-slash-reference': 'off',
},
},
{
// 这些 @typescript-eslint 规则此前不作用于 .vue旧 eslint glob 不含 .vue
// Vue 组件惯用 `interface Props extends XxxProps {}` 声明 props保持迁移前行为放行。
files: ['*.vue', '**/*.vue'],
rules: {
'typescript/no-empty-object-type': 'off',
'typescript/no-unsafe-function-type': 'off',
},
},
{
files: [
'**/__tests__/**/*.js',
'**/__tests__/**/*.cjs',
'**/__tests__/**/*.mjs',
'**/__tests__/**/*.jsx',
'**/__tests__/**/*.ts',
'**/__tests__/**/*.cts',
'**/__tests__/**/*.mts',
'**/__tests__/**/*.tsx',
'**/*.spec.js',
'**/*.spec.cjs',
'**/*.spec.mjs',
'**/*.spec.jsx',
'**/*.spec.ts',
'**/*.spec.cts',
'**/*.spec.mts',
'**/*.spec.tsx',
'**/*.test.js',
'**/*.test.cjs',
'**/*.test.mjs',
'**/*.test.jsx',
'**/*.test.ts',
'**/*.test.cts',
'**/*.test.mts',
'**/*.test.tsx',
'**/*.bench.js',
'**/*.bench.cjs',
'**/*.bench.mjs',
'**/*.bench.jsx',
'**/*.bench.ts',
'**/*.bench.cts',
'**/*.bench.mts',
'**/*.bench.tsx',
'**/*.benchmark.js',
'**/*.benchmark.cjs',
'**/*.benchmark.mjs',
'**/*.benchmark.jsx',
'**/*.benchmark.ts',
'**/*.benchmark.cts',
'**/*.benchmark.mts',
'**/*.benchmark.tsx',
],
rules: {
'no-console': 'off',
},
},
],
});

23520
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,81 +1,85 @@
{
"name": "vue-h5-template",
"version": "1.0.0",
"version": "2.0.0",
"private": true,
"type": "module",
"packageManager": "pnpm@9.15.9",
"scripts": {
"dev": "vite",
"dev:integration": "vite --mode integration",
"build": "vite build",
"preview": "vite preview",
"lint:eslint": "eslint --cache --max-warnings 0 \"{src,mock}/**/*.{vue,ts,tsx}\" --fix",
"lint:prettier": "prettier --write \"src/**/*.{js,json,tsx,css,less,scss,vue,html,md}\"",
"lint:stylelint": "stylelint --cache --fix \"**/*.{vue,less,postcss,css,scss}\" --cache --cache-location node_modules/.cache/stylelint/",
"test": "vitest run",
"test:unit": "vitest run",
"test:coverage": "vitest run --coverage",
"test:e2e": "playwright test",
"lint": "pnpm lint:code && pnpm lint:styles",
"lint:code": "oxlint",
"lint:styles": "stylelint --cache \"src/**/*.{vue,scss,css}\" --cache-location node_modules/.cache/stylelint/",
"lint:fix": "oxlint --fix && pnpm lint:styles --fix",
"typecheck": "vue-tsc --build",
"check": "pnpm lint && pnpm typecheck && pnpm test && pnpm build",
"api:generate": "openapi-typescript openapi/schema.yaml -o src/types/api/generated.d.ts && oxfmt --write src/types/api/generated.d.ts",
"clean": "rm -rf dist",
"format": "oxfmt --write .",
"format:check": "oxfmt --check .",
"lint:lint-staged": "lint-staged",
"prepare": "husky install",
"prepare": "husky",
"deps": "pnpm up -i",
"commit": "git add . && git-cz"
},
"dependencies": {
"@nutui/icons-vue": "^0.1.1",
"@nutui/nutui": "^4.3.13",
"@varlet/ui": "^3.15.1",
"@vueuse/core": "14.1.0",
"@vueuse/integrations": "14.1.0",
"axios": "1.13.2",
"dayjs": "^1.11.20",
"mitt": "^3.0.1",
"@tanstack/vue-query": "^5.102.8",
"@varlet/ui": "^3.20.6",
"axios": "1.20.0",
"dompurify": "^3.4.14",
"markdown-it": "^15.0.0",
"pinia": "^3.0.4",
"pinia-plugin-persistedstate": "^4.7.1",
"universal-cookie": "^8.1.0",
"vant": "^4.9.24",
"vue": "^3.5.33",
"vue-i18n": "^11.4.0",
"vant": "^4.10.0",
"vue": "^3.5.42",
"vue-i18n": "^11.4.10",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@commitlint/cli": "^20.5.2",
"@commitlint/config-conventional": "^20.5.0",
"@eslint-community/eslint-plugin-eslint-comments": "^4.7.2",
"@nutui/auto-import-resolver": "^1.0.0",
"@playwright/test": "^1.62.1",
"@stylistic/stylelint-plugin": "^4.0.1",
"@tsconfig/node22": "^22.0.5",
"@types/jsdom": "^21.1.7",
"@types/markdown-it": "^14.2.0",
"@types/node": "^24.12.2",
"@typescript-eslint/parser": "^8.59.1",
"@vant/auto-import-resolver": "^1.3.0",
"@varlet/import-resolver": "^3.15.1",
"@varlet/import-resolver": "^3.20.6",
"@vitejs/plugin-basic-ssl": "^2.3.0",
"@vitejs/plugin-legacy": "^7.2.1",
"@vitejs/plugin-vue": "^6.0.6",
"@vitejs/plugin-vue-jsx": "^5.1.5",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.7.0",
"@vue/test-utils": "^2.4.9",
"@vitejs/plugin-vue": "^6.0.8",
"@vitejs/plugin-vue-jsx": "^5.1.6",
"@vitest/coverage-v8": "^4.1.11",
"@vue/test-utils": "^2.5.0",
"@vue/tsconfig": "^0.8.1",
"@zhaojjiang/vite-plugin-eruda": "^0.0.5",
"amfe-flexible": "^2.2.1",
"autoprefixer": "^10.5.0",
"cnjm-postcss-px-to-viewport": "^1.0.1",
"consola": "^3.4.2",
"cross-env": "^10.1.0",
"autoprefixer": "^10.5.4",
"cz-git": "^1.13.0",
"czg": "^1.13.0",
"eruda": "^3.4.1",
"eslint": "^9.39.4",
"eslint-define-config": "^2.1.0",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-prettier": "^5.5.5",
"eslint-plugin-simple-import-sort": "^12.1.1",
"eslint-plugin-vue": "^10.9.0",
"esbuild": "^0.28.0",
"eslint-plugin-command": "^3.5.3",
"git-cz": "^4.9.0",
"husky": "9.1.7",
"jsdom": "^27.4.0",
"lint-staged": "16.2.7",
"mockjs": "^1.1.0",
"node": "^22.22.2",
"npm-run-all": "^4.1.5",
"postcss": "^8.5.12",
"openapi-typescript": "^7.13.0",
"oxfmt": "^0.61.0",
"oxlint": "^1.76.0",
"postcss": "8.5.26",
"postcss-html": "1.8.1",
"postcss-scss": "^4.0.9",
"prettier": "^3.8.3",
"rollup-plugin-visualizer": "^6.0.11",
"sharp": "^0.35.4",
"stylelint": "^16.26.1",
"stylelint-config-recess-order": "^7.7.0",
"stylelint-config-recommended": "^17.0.0",
@ -83,62 +87,69 @@
"stylelint-config-recommended-vue": "^1.5.0",
"stylelint-config-standard": "^39.0.1",
"stylelint-order": "^7.0.1",
"stylelint-prettier": "^5.0.3",
"stylelint-scss": "^6.14.0",
"terser": "^5.46.2",
"svgo": "^4.1.0",
"typescript": "5.9.3",
"unplugin-auto-import": "^21.0.0",
"unplugin-vue-components": "^31.1.0",
"vite": "^7.3.2",
"unplugin-auto-import": "^21.1.0",
"unplugin-vue-components": "^32.1.0",
"vite": "^8.2.2",
"vite-plugin-compression": "^0.5.1",
"vite-plugin-imagemin": "^0.6.1",
"vite-plugin-image-optimizer": "^2.0.3",
"vite-plugin-mock": "^3.0.2",
"vite-plugin-pages": "^0.33.3",
"vite-plugin-progress": "^0.0.7",
"vite-plugin-pwa": "^1.2.0",
"vite-plugin-qrcode": "^0.3.0",
"vite-plugin-restart": "^2.0.0",
"vite-plugin-svg-icons": "^2.0.1",
"vite-plugin-vue-setup-extend-plus": "^0.1.0",
"vitest": "^4.1.5",
"vue-eslint-parser": "^10.4.0",
"vue-tsc": "^3.2.7"
"vitest": "^4.1.11",
"vue-tsc": "^3.3.11"
},
"engines": {
"node": ">=20.10.0",
"node": ">=22.12.0",
"pnpm": ">=9.12.0"
},
"husky": {
"hooks": {
"pre-commit": "lint-staged"
"browserslist": [
"iOS >= 15",
"Android >= 8",
"Chrome >= 80"
],
"pnpm": {
"overrides": {
"axios": "1.20.0",
"brace-expansion@>=2 <3": "2.1.4",
"brace-expansion@>=5 <6": "5.0.9",
"defu": "6.1.5",
"esbuild": "0.28.2",
"js-cookie": "3.0.7",
"js-yaml": "4.3.1",
"lodash": "4.18.0",
"picomatch@<3": "2.3.2",
"picomatch@>=4 <5": "4.0.4",
"postcss": "8.5.26",
"qs": "6.15.2",
"uuid": "13.0.1"
}
},
"resolutions": {
"bin-wrapper": "npm:bin-wrapper-china",
"gifsicle": "5.2.0"
},
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
"eslint --fix",
"prettier --write"
"*.{js,jsx,ts,tsx,mts,cts}": [
"oxlint --fix",
"oxfmt --write"
],
"{!(package)*.json,*.code-snippets,.!(browserslist)*rc}": [
"prettier --write --parser json"
"oxfmt --write"
],
"package.json": [
"prettier --write"
"oxfmt --write"
],
"*.vue": [
"eslint --fix",
"prettier --write",
"oxlint --fix",
"oxfmt --write",
"stylelint --fix"
],
"*.{scss,less,styl,html}": [
"stylelint --fix",
"prettier --write"
"oxfmt --write"
],
"*.md": [
"prettier --write"
"oxfmt --write"
]
},
"config": {

20
playwright.config.ts Normal file
View File

@ -0,0 +1,20 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
retries: process.env.CI ? 2 : 0,
reporter: process.env.CI ? 'github' : 'list',
use: {
baseURL: 'http://127.0.0.1:4173',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [{ name: 'mobile-chrome', use: { ...devices['Pixel 7'] } }],
webServer: {
command: 'pnpm dev --host 127.0.0.1 --port 4173',
url: 'http://127.0.0.1:4173/home',
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
});

10120
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

7
pnpm-workspace.yaml Normal file
View File

@ -0,0 +1,7 @@
packages:
- '.'
allowBuilds:
'@parcel/watcher': true
core-js: true
esbuild: true

View File

@ -1,34 +0,0 @@
// eslint-disable-next-line @typescript-eslint/no-require-imports
const path = require('path');
const judgeComponent = (file) => {
const ignore = ['vant', '@nutui', '@varlet'];
return ignore.some((item) => path.join(file).includes(path.join('node_modules', item)));
};
module.exports = {
plugins: {
autoprefixer: { overrideBrowserslist: ['Android 4.1', 'iOS 7.1', 'Chrome > 31', 'ff > 31', 'ie >= 8'] },
'cnjm-postcss-px-to-viewport': {
unitToConvert: 'px', // 要转化的单位
viewportWidth: 750, // UI设计稿的宽度
unitPrecision: 6, // 转换后的精度,即小数点位数
propList: ['*'], // 指定转换的css属性的单位*代表全部css属性的单位都进行转换
viewportUnit: 'vw', // 指定需要转换成的视窗单位默认vw
fontViewportUnit: 'vw', // 指定字体需要转换成的视窗单位默认vw
minPixelValue: 1, // 默认值1小于或等于1px则不进行转换
mediaQuery: true, // 是否在媒体查询的css代码中也进行转换默认false
replace: true, // 是否转换后直接更换属性值
landscape: false, //是否添加根据 landscapeWidth 生成的媒体查询条件 @media (orientation: landscape)
landscapeUnit: 'rem', //横屏时使用的单位
landscapeWidth: 1134, //横屏时使用的视口宽度
include: [],
exclude: [], // 设置忽略文件,用正则做目录名匹配
customFun: ({ file }) => {
// 这个自定义的方法是针对处理vant组件下的设计稿为375问题
const designWidth = judgeComponent(file) ? 375 : 750;
return designWidth;
},
},
},
};

7
postcss.config.mjs Normal file
View File

@ -0,0 +1,7 @@
export default {
plugins: {
autoprefixer: {
overrideBrowserslist: ['Android >= 8', 'iOS >= 15', 'Chrome >= 80'],
},
},
};

View File

@ -1,18 +0,0 @@
module.exports = {
printWidth: 140,
semi: true,
vueIndentScriptAndStyle: true,
singleQuote: true,
trailingComma: 'all',
proseWrap: 'never',
htmlWhitespaceSensitivity: 'strict',
endOfLine: 'auto',
overrides: [
{
files: '.*rc',
options: {
parser: 'json',
},
},
],
};

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 800" role="img" aria-labelledby="t"><title id="t">Aurora headphones</title><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#dcd7ff"/><stop offset="1" stop-color="#7666e8"/></linearGradient></defs><rect width="800" height="800" rx="72" fill="url(#g)"/><circle cx="400" cy="388" r="224" fill="none" stroke="#fff" stroke-width="64" stroke-linecap="round" stroke-dasharray="520 190" transform="rotate(25 400 388)"/><rect x="174" y="356" width="130" height="248" rx="60" fill="#282344"/><rect x="496" y="356" width="130" height="248" rx="60" fill="#282344"/><rect x="203" y="388" width="72" height="184" rx="34" fill="#fff" fill-opacity=".22"/><rect x="525" y="388" width="72" height="184" rx="34" fill="#fff" fill-opacity=".22"/><text x="400" y="700" text-anchor="middle" font-family="Arial,sans-serif" font-size="44" font-weight="700" fill="#282344">AURORA</text></svg>

After

Width:  |  Height:  |  Size: 951 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 800" role="img" aria-labelledby="t"><title id="t">Mori coffee set</title><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#f7dfbd"/><stop offset="1" stop-color="#b56d43"/></linearGradient></defs><rect width="800" height="800" rx="72" fill="url(#g)"/><path d="M242 290h316l-42 300H284l-42-300Z" fill="#fff" fill-opacity=".9"/><path d="M300 290c14-102 186-102 200 0" fill="none" stroke="#493022" stroke-width="34"/><path d="M329 391c40-48 102-48 142 0-14 80-128 80-142 0Z" fill="#6e3e27"/><path d="M516 360h56c94 0 94 150 0 150h-35" fill="none" stroke="#fff" stroke-width="38"/><path d="M375 202c-36-50 42-66 5-118M440 202c-36-50 42-66 5-118" fill="none" stroke="#fff" stroke-width="18" stroke-linecap="round" opacity=".7"/><text x="400" y="690" text-anchor="middle" font-family="Arial,sans-serif" font-size="44" font-weight="700" fill="#493022">MORI</text></svg>

After

Width:  |  Height:  |  Size: 945 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 800" role="img" aria-labelledby="t"><title id="t">Nova ambient lamp</title><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#d6f5ee"/><stop offset="1" stop-color="#41aa9a"/></linearGradient><radialGradient id="l"><stop stop-color="#fffbd8"/><stop offset="1" stop-color="#ffd36a"/></radialGradient></defs><rect width="800" height="800" rx="72" fill="url(#g)"/><ellipse cx="400" cy="590" rx="210" ry="54" fill="#157368" opacity=".25"/><path d="M292 472h216l66 142H226l66-142Z" fill="#244f4b"/><path d="M326 188h148l54 286H272l54-286Z" fill="url(#l)"/><path d="M326 188h148" stroke="#fff" stroke-width="30" stroke-linecap="round"/><circle cx="400" cy="580" r="22" fill="#ffd36a"/><text x="400" y="704" text-anchor="middle" font-family="Arial,sans-serif" font-size="44" font-weight="700" fill="#244f4b">NOVA</text></svg>

After

Width:  |  Height:  |  Size: 900 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 800" role="img" aria-labelledby="t"><title id="t">Product placeholder</title><rect width="800" height="800" rx="72" fill="#ececf4"/><path d="M240 270h320v300H240z" fill="#fff"/><path d="M320 270c0-106 160-106 160 0" fill="none" stroke="#817b9e" stroke-width="28"/><circle cx="400" cy="420" r="56" fill="#dedbea"/><text x="400" y="680" text-anchor="middle" font-family="Arial,sans-serif" font-size="40" font-weight="700" fill="#817b9e">PRODUCT</text></svg>

After

Width:  |  Height:  |  Size: 513 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 800" role="img" aria-labelledby="t"><title id="t">Trail backpack</title><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#d8e5ef"/><stop offset="1" stop-color="#607d91"/></linearGradient></defs><rect width="800" height="800" rx="72" fill="url(#g)"/><path d="M292 245c0-142 216-142 216 0" fill="none" stroke="#21343f" stroke-width="38"/><rect x="210" y="226" width="380" height="390" rx="92" fill="#263b47"/><path d="M240 360h320v194c0 40-32 72-72 72H312c-40 0-72-32-72-72V360Z" fill="#385361"/><path d="M270 330h260" stroke="#8eb3c2" stroke-width="24" stroke-linecap="round"/><rect x="318" y="414" width="164" height="106" rx="28" fill="#1d2e37"/><path d="M210 322c-76 74-74 194-28 272M590 322c76 74 74 194 28 272" fill="none" stroke="#21343f" stroke-width="32" stroke-linecap="round"/><text x="400" y="706" text-anchor="middle" font-family="Arial,sans-serif" font-size="44" font-weight="700" fill="#21343f">TRAIL</text></svg>

After

Width:  |  Height:  |  Size: 1010 B

View File

@ -0,0 +1,10 @@
{
"packages": {
".": {
"release-type": "node",
"package-name": "vue-h5-template",
"changelog-path": "CHANGELOG.md",
"include-component-in-tag": false
}
}
}

View File

@ -1,13 +1,5 @@
<template>
<router-view />
<AppErrorBoundary>
<RouterView />
</AppErrorBoundary>
</template>
<script setup lang="ts"></script>
<style>
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
color: #2c3e50;
}
</style>

158
src/api/client.ts Normal file
View File

@ -0,0 +1,158 @@
import axios from 'axios';
import type {
AxiosError,
AxiosInstance,
AxiosRequestConfig,
AxiosResponse,
InternalAxiosRequestConfig,
} from 'axios';
import { ApiError } from '@/types/api/common';
import type { ApiResponse } from '@/types/api/common';
interface ApiClientRuntime {
getAccessToken?: () => string | undefined;
onUnauthorized?: () => unknown;
}
const runtime: ApiClientRuntime = {};
export function configureApiClient(config: ApiClientRuntime) {
Object.assign(runtime, config);
}
function createRequestId() {
return (
globalThis.crypto?.randomUUID?.() ??
`req-${Date.now()}-${Math.random().toString(16).slice(2)}`
);
}
export function normalizeApiError(error: unknown): ApiError {
if (error instanceof ApiError) return error;
if (!axios.isAxiosError(error)) {
return new ApiError(
error instanceof Error ? error.message : 'Unknown error',
{ kind: 'unknown', cause: error },
);
}
const axiosError = error as AxiosError<Partial<ApiResponse<unknown>>>;
if (axiosError.code === 'ERR_CANCELED')
return new ApiError('Request cancelled', {
kind: 'cancelled',
cause: error,
});
if (axiosError.code === 'ECONNABORTED' || axiosError.code === 'ETIMEDOUT') {
return new ApiError('Request timed out', { kind: 'timeout', cause: error });
}
if (!axiosError.response)
return new ApiError('Network unavailable', {
kind: 'network',
cause: error,
});
const { status, data, headers } = axiosError.response;
return new ApiError(data?.msg || axiosError.message || `HTTP ${status}`, {
kind: 'http',
code: data?.code,
status,
requestId: data?.requestId ?? headers['x-request-id'],
details: data,
cause: error,
});
}
export const axiosInstance: AxiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
timeout: 15_000,
withCredentials: false,
});
axiosInstance.interceptors.request.use((config: InternalAxiosRequestConfig) => {
const token = runtime.getAccessToken?.();
if (token) config.headers.set('Authorization', `Bearer ${token}`);
if (
import.meta.env.VITE_REQUEST_ID_ENABLED &&
!config.headers.has('X-Request-ID')
) {
config.headers.set('X-Request-ID', createRequestId());
}
return config;
});
axiosInstance.interceptors.response.use(
(response: AxiosResponse<ApiResponse<unknown>>) => {
const payload = response.data;
if (payload.code < 200 || payload.code >= 300) {
const error = new ApiError(payload.msg || 'Request failed', {
kind: 'business',
code: payload.code,
status: response.status,
requestId: payload.requestId,
details: payload,
});
if (payload.code === 401) void runtime.onUnauthorized?.();
return Promise.reject(error);
}
return response;
},
(error: unknown) => {
const apiError = normalizeApiError(error);
if (apiError.status === 401) void runtime.onUnauthorized?.();
return Promise.reject(apiError);
},
);
function unwrap<T>(
request: Promise<AxiosResponse<ApiResponse<T>>>,
): Promise<T> {
return request.then((response) => response.data.data);
}
export const apiClient = {
get<T>(url: string, config?: AxiosRequestConfig) {
return unwrap(axiosInstance.get<ApiResponse<T>>(url, config));
},
post<T, TBody = unknown>(
url: string,
data?: TBody,
config?: AxiosRequestConfig<TBody>,
) {
return unwrap(
axiosInstance.post<ApiResponse<T>, AxiosResponse<ApiResponse<T>>, TBody>(
url,
data,
config,
),
);
},
put<T, TBody = unknown>(
url: string,
data?: TBody,
config?: AxiosRequestConfig<TBody>,
) {
return unwrap(
axiosInstance.put<ApiResponse<T>, AxiosResponse<ApiResponse<T>>, TBody>(
url,
data,
config,
),
);
},
patch<T, TBody = unknown>(
url: string,
data?: TBody,
config?: AxiosRequestConfig<TBody>,
) {
return unwrap(
axiosInstance.patch<ApiResponse<T>, AxiosResponse<ApiResponse<T>>, TBody>(
url,
data,
config,
),
);
},
delete<T>(url: string, config?: AxiosRequestConfig) {
return unwrap(axiosInstance.delete<ApiResponse<T>>(url, config));
},
};

View File

@ -1,11 +1,5 @@
import { http } from '@/utils/request';
/**
*
* @returns UseAxiosReturn
*/
export function loginPassword() {
return http.post(`/mock-api/login`, {
data: { name: '123' },
});
}
export * from './client';
export * from './modules/auth';
export * from './modules/examples';
export * from './modules/projects';
export * from './modules/user';

9
src/api/modules/auth.ts Normal file
View File

@ -0,0 +1,9 @@
import { apiClient } from '@/api/client';
import type { components } from '@/types/api/generated';
export type LoginParams = components['schemas']['LoginRequest'];
export type LoginResult = components['schemas']['LoginResult'];
export function loginPassword(params: LoginParams): Promise<LoginResult> {
return apiClient.post<LoginResult, LoginParams>('/auth/login', params);
}

View File

@ -0,0 +1,25 @@
import { apiClient } from '@/api/client';
import type { PaginationResponse } from '@/types/api/common';
import type { components } from '@/types/api/generated';
export type Task = components['schemas']['Task'];
export type FeedItem = components['schemas']['FeedItem'];
export function getTasks(signal?: AbortSignal): Promise<Task[]> {
return apiClient.get<Task[]>('/examples/tasks', { signal });
}
export function toggleTask(id: number): Promise<Task> {
return apiClient.post<Task>(`/examples/tasks/${id}/toggle`);
}
export function getFeed(
cursor = 0,
limit = 6,
signal?: AbortSignal,
): Promise<PaginationResponse<FeedItem>> {
return apiClient.get<PaginationResponse<FeedItem>>('/examples/feed', {
params: { cursor, limit },
signal,
});
}

View File

@ -0,0 +1,62 @@
import { apiClient } from '@/api/client';
import type { PaginationParams, PaginationResponse } from '@/types/api/common';
import type { components } from '@/types/api/generated';
export type Product = components['schemas']['Product'];
export type ProductInput = components['schemas']['ProductInput'];
export type ProductUpdateInput = components['schemas']['ProductUpdateInput'];
export type ProductStatus = components['schemas']['ProductStatus'];
export type LocalizedText = components['schemas']['LocalizedText'];
export interface ProductListParams extends PaginationParams {
keyword?: string;
category?: string;
status?: ProductStatus;
sort?: 'featured' | 'sales' | 'price_asc' | 'price_desc';
}
export function getProducts(
params: ProductListParams = {},
signal?: AbortSignal,
): Promise<PaginationResponse<Product>> {
return apiClient.get<PaginationResponse<Product>>('/products', {
params,
signal,
});
}
export function getProduct(id: number, signal?: AbortSignal): Promise<Product> {
return apiClient.get<Product>(`/products/${id}`, { signal });
}
export function getAdminProducts(
params: ProductListParams = {},
signal?: AbortSignal,
): Promise<PaginationResponse<Product>> {
return apiClient.get<PaginationResponse<Product>>('/admin/products', {
params,
signal,
});
}
export function createProduct(input: ProductInput): Promise<Product> {
return apiClient.post<Product, ProductInput>('/admin/products', input);
}
export function updateProduct(
id: number,
input: ProductUpdateInput,
): Promise<Product> {
return apiClient.patch<Product, ProductUpdateInput>(
`/admin/products/${id}`,
input,
);
}
export function deleteProduct(
id: number,
): Promise<{ deleted: boolean; id: number }> {
return apiClient.delete<{ deleted: boolean; id: number }>(
`/admin/products/${id}`,
);
}

View File

@ -0,0 +1,37 @@
import { apiClient } from '@/api/client';
import type { PaginationParams, PaginationResponse } from '@/types/api/common';
import type { components } from '@/types/api/generated';
export type Project = components['schemas']['Project'];
export type ProjectInput = components['schemas']['ProjectInput'];
export type ProjectUpdateInput = components['schemas']['ProjectUpdateInput'];
export type DeleteResult = components['schemas']['DeleteResult'];
export function getProjects(
params: PaginationParams = {},
signal?: AbortSignal,
): Promise<PaginationResponse<Project>> {
return apiClient.get<PaginationResponse<Project>>('/projects', {
params,
signal,
});
}
export function getProject(id: number, signal?: AbortSignal): Promise<Project> {
return apiClient.get<Project>(`/projects/${id}`, { signal });
}
export function createProject(input: ProjectInput): Promise<Project> {
return apiClient.post<Project, ProjectInput>('/projects', input);
}
export function updateProject(
id: number,
input: ProjectUpdateInput,
): Promise<Project> {
return apiClient.patch<Project, ProjectUpdateInput>(`/projects/${id}`, input);
}
export function deleteProject(id: number): Promise<DeleteResult> {
return apiClient.delete<DeleteResult>(`/projects/${id}`);
}

View File

@ -0,0 +1,20 @@
import { apiClient } from '@/api/client';
export type RequestErrorScenario =
| 'bad-request'
| 'unauthorized'
| 'forbidden'
| 'not-found'
| 'conflict'
| 'validation'
| 'server-error'
| 'timeout';
export function triggerRequestError(
scenario: RequestErrorScenario,
): Promise<unknown> {
return apiClient.get(
`/examples/${scenario}`,
scenario === 'timeout' ? { timeout: 100 } : undefined,
);
}

8
src/api/modules/user.ts Normal file
View File

@ -0,0 +1,8 @@
import { apiClient } from '@/api/client';
import type { components } from '@/types/api/generated';
export type UserProfile = components['schemas']['UserProfile'];
export function getUserProfile(signal?: AbortSignal): Promise<UserProfile> {
return apiClient.get<UserProfile>('/user/profile', { signal });
}

View File

@ -1,39 +0,0 @@
@font-face {
font-family: iconfont; /* Project id 3210904 */
src:
url('iconfont.woff2?t=1646452970429') format('woff2'),
url('iconfont.woff?t=1646452970429') format('woff'),
url('iconfont.ttf?t=1646452970429') format('truetype');
}
.iconfont {
font-family: iconfont !important;
font-size: 16px;
font-style: normal;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.icon-custom-ok::before {
content: '\e631';
}
.icon-github-fill::before {
content: '\e885';
}
.icon-l-search::before {
content: '\e79e';
}
.icon-home::before {
content: '\e603';
}
.icon-member::before {
content: '\e602';
}
.icon-list::before {
content: '\e601';
}

1
src/assets/icons/ai.svg Normal file
View File

@ -0,0 +1 @@
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12 2a2 2 0 0 1 2 2v.4A7.8 7.8 0 0 1 19.6 10H20a2 2 0 1 1 0 4h-.4A7.8 7.8 0 0 1 14 19.6v.4a2 2 0 1 1-4 0v-.4A7.8 7.8 0 0 1 4.4 14H4a2 2 0 1 1 0-4h.4A7.8 7.8 0 0 1 10 4.4V4a2 2 0 0 1 2-2Zm-3 9a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Zm6 0a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Zm-6.2 3.2a4.6 4.6 0 0 0 6.4 0l-1.4-1.4a2.6 2.6 0 0 1-3.6 0l-1.4 1.4Z"/></svg>

After

Width:  |  Height:  |  Size: 416 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M3 3h2.3l.7 3h13.7l-1.6 7.1A2.5 2.5 0 0 1 15.7 15H9a2.5 2.5 0 0 1-2.4-1.9L4.2 5H3V3Zm6.1 10h6.6c.2 0 .4-.2.5-.4L17.2 8H6.5l1.1 4.6c.1.2.3.4.5.4ZM9 21a2 2 0 1 1 0-4 2 2 0 0 1 0 4Zm7 0a2 2 0 1 1 0-4 2 2 0 0 1 0 4Z"/></svg>

After

Width:  |  Height:  |  Size: 290 B

View File

@ -0,0 +1 @@
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2Zm2.2 5.4 3.6 3.6-3.6 3.6 1.4 1.4 5-5-5-5-1.4 1.4ZM13 17h5v-2h-5v2Z"/></svg>

After

Width:  |  Height:  |  Size: 216 B

View File

@ -0,0 +1 @@
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M3 10.8 12 3l9 7.8v9.7a.5.5 0 0 1-.5.5h-5.2v-6.4H8.7V21H3.5a.5.5 0 0 1-.5-.5v-9.7Z"/></svg>

After

Width:  |  Height:  |  Size: 161 B

View File

@ -0,0 +1 @@
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M2 4h5l5 8 5-8h5L12 22 2 4Zm5.2 0h3L12 7l1.8-3h3L12 12.3 7.2 4Z"/></svg>

After

Width:  |  Height:  |  Size: 142 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M4 3h16l1 5a4 4 0 0 1-2 3.47V21H5v-9.53A4 4 0 0 1 3 8l1-5Zm3 9v6h10v-6a4 4 0 0 1-5-1.17A4 4 0 0 1 7 12Zm-1.36-7-.6 3A2 2 0 0 0 9 8.39L9.17 5H5.64Zm5.53 0L11 8.39a2 2 0 0 0 2 0L12.83 5h-1.66Zm4.66 0L16 8.39A2 2 0 0 0 19.96 8l-.6-3h-3.53Z"/></svg>

After

Width:  |  Height:  |  Size: 315 B

View File

@ -0,0 +1 @@
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12 12a5 5 0 1 0 0-10 5 5 0 0 0 0 10Zm0 2c-5 0-9 2.5-9 5.5V22h18v-2.5c0-3-4-5.5-9-5.5Z"/></svg>

After

Width:  |  Height:  |  Size: 165 B

View File

@ -0,0 +1,118 @@
<template>
<form class="chat-composer" @submit.prevent="submit">
<textarea
ref="textarea"
v-model="input"
rows="1"
:disabled="disabled"
:aria-label="t('common.ai.inputLabel')"
:placeholder="t('common.ai.placeholder')"
@input="resize"
@keydown="handleKeydown"
@compositionstart="composing = true"
@compositionend="composing = false"
/>
<button
v-if="streaming"
class="stop-button"
type="button"
:aria-label="t('common.ai.stop')"
@click="$emit('stop')"
>
<span />
</button>
<button
v-else
class="send-button"
type="submit"
:disabled="disabled || !input.trim()"
:aria-label="t('common.ai.send')"
>
</button>
</form>
</template>
<script setup lang="ts">
import { nextTick, ref, useTemplateRef } from 'vue';
import { useI18n } from 'vue-i18n';
defineProps<{ disabled?: boolean; streaming?: boolean }>();
const emit = defineEmits<{ send: [content: string]; stop: [] }>();
const { t } = useI18n();
const input = ref('');
const composing = ref(false);
const textarea = useTemplateRef<HTMLTextAreaElement>('textarea');
const resize = () => {
const element = textarea.value;
if (!element) return;
element.style.height = 'auto';
element.style.height = `${Math.min(element.scrollHeight, 220)}px`;
};
const submit = () => {
const content = input.value.trim();
if (!content) return;
emit('send', content);
input.value = '';
void nextTick(resize);
};
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === 'Enter' && !event.shiftKey && !composing.value) {
event.preventDefault();
submit();
}
};
</script>
<style scoped lang="scss">
.chat-composer {
display: flex;
gap: var(--space-2);
align-items: end;
padding: var(--space-2);
background: var(--color-surface);
border: 1px solid var(--color-border-strong);
border-radius: var(--radius-lg);
}
textarea {
flex: 1;
min-height: var(--touch-target);
max-height: 220px;
padding: 0.625rem var(--space-2);
overflow: auto;
line-height: 1.45;
color: var(--color-text);
resize: none;
outline: 0;
background: transparent;
border: 0;
}
button {
display: grid;
flex: 0 0 auto;
place-items: center;
width: 2.25rem;
height: 2.25rem;
color: var(--color-primary-contrast);
background: var(--color-primary);
border: 0;
border-radius: var(--radius-md);
&:disabled {
opacity: 0.4;
}
}
.send-button {
font-size: 1.25rem;
font-weight: 700;
}
.stop-button span {
width: 0.75rem;
height: 0.75rem;
background: currentcolor;
border-radius: 0.125rem;
}
</style>

View File

@ -0,0 +1,83 @@
<template>
<!-- Content is sanitized by DOMPurify in renderSafeMarkdown. -->
<div class="markdown-body" v-html="html" />
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import { renderSafeMarkdown } from '@/utils/markdown';
const props = defineProps<{ source: string }>();
const html = ref('');
let renderVersion = 0;
watch(
() => props.source,
async (source) => {
const version = ++renderVersion;
const rendered = await renderSafeMarkdown(source);
if (version === renderVersion) html.value = rendered;
},
{ immediate: true },
);
</script>
<style lang="scss">
.markdown-body {
line-height: 1.7;
overflow-wrap: anywhere;
> :first-child {
margin-top: 0;
}
> :last-child {
margin-bottom: 0;
}
p {
margin: 0 0 var(--space-3);
}
ul,
ol {
padding-left: var(--space-5);
}
code {
padding: 0.125rem 0.375rem;
font-size: 0.9em;
color: var(--color-danger);
background: var(--color-background-soft);
border-radius: var(--radius-sm);
}
pre {
max-width: 100%;
padding: var(--space-4);
overflow: auto;
color: #e7e7ec;
background: #202024;
border: 1px solid #303036;
border-radius: var(--radius-md);
-webkit-overflow-scrolling: touch;
code {
padding: 0;
color: inherit;
background: transparent;
}
}
a {
color: var(--color-primary);
text-decoration-thickness: 1px;
text-underline-offset: 0.1875rem;
}
blockquote {
padding-left: var(--space-4);
margin-left: 0;
color: var(--color-text-secondary);
border-left: 2px solid var(--color-border-strong);
}
}
</style>

View File

@ -0,0 +1,87 @@
<template>
<form class="login-form" autocomplete="off" @submit.prevent="submit">
<label for="username">{{ t('common.login.username') }}</label>
<input
id="username"
v-model.trim="name"
name="demo-account"
autocomplete="off"
required
:placeholder="t('common.login.usernamePlaceholder')"
/>
<label for="password">{{ t('common.login.password') }}</label>
<input
id="password"
v-model="password"
name="demo-password"
type="password"
autocomplete="new-password"
required
:placeholder="t('common.login.passwordPlaceholder')"
/>
<p v-if="error" class="form-error" role="alert">{{ error }}</p>
<button type="submit" :disabled="loading || !canSubmit">
{{ loading ? t('common.login.submitting') : t('common.login.submit') }}
</button>
</form>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import type { LoginParams } from '@/api/modules/auth';
defineProps<{ loading?: boolean; error?: string }>();
const emit = defineEmits<{ submit: [params: LoginParams] }>();
const { t } = useI18n();
const name = ref('');
const password = ref('');
const canSubmit = computed(() => Boolean(name.value && password.value));
const submit = () => {
if (canSubmit.value)
emit('submit', { name: name.value, password: password.value });
};
</script>
<style scoped lang="scss">
.login-form {
display: grid;
gap: var(--space-3);
label {
margin-top: var(--space-2);
font-size: var(--text-secondary);
font-weight: 600;
}
input {
min-height: var(--touch-target);
padding: 0 var(--space-3);
color: var(--color-text);
background: var(--color-surface);
border: 1px solid var(--color-border-strong);
border-radius: var(--radius-md);
}
button {
min-height: var(--touch-target);
margin-top: var(--space-3);
font-weight: 600;
color: var(--color-primary-contrast);
background: var(--color-primary);
border: 0;
border-radius: var(--radius-md);
&:disabled {
cursor: not-allowed;
opacity: 0.55;
}
}
}
.form-error {
margin: var(--space-1) 0 0;
font-size: var(--text-secondary);
color: var(--color-danger);
}
</style>

View File

@ -0,0 +1,60 @@
<template>
<section v-if="error" class="error-boundary" role="alert">
<SvgIcon name="logo" />
<h1>{{ t('common.errorBoundary.title') }}</h1>
<p>{{ t('common.errorBoundary.description') }}</p>
<button type="button" @click="recover">
{{ t('common.errorBoundary.reload') }}
</button>
</section>
<slot v-else />
</template>
<script setup lang="ts">
import { onErrorCaptured, ref } from 'vue';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const error = ref<Error | null>(null);
onErrorCaptured((captured) => {
error.value = captured;
return false;
});
const recover = () => window.location.reload();
</script>
<style scoped lang="scss">
.error-boundary {
display: grid;
place-content: center;
min-height: 100dvh;
padding: var(--space-10) var(--space-8);
text-align: center;
.svg-icon {
margin: 0 auto var(--space-5);
font-size: 2rem;
color: var(--color-primary);
}
h1 {
font-size: var(--text-page-title);
font-weight: 680;
}
p {
max-width: 30rem;
margin: var(--space-4) 0 var(--space-6);
color: var(--color-text-secondary);
}
button {
min-height: var(--touch-target);
padding: 0 var(--space-4);
color: var(--color-primary-contrast);
background: var(--color-primary);
border: 0;
border-radius: var(--radius-md);
}
}
</style>

View File

@ -0,0 +1,23 @@
<template>
<svg class="svg-icon" :aria-hidden="!title" :role="title ? 'img' : undefined">
<title v-if="title">{{ title }}</title>
<use :href="symbolId" />
</svg>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import type { SvgIconName } from '@/types/icon';
const props = defineProps<{ name: SvgIconName; title?: string }>();
const symbolId = computed(() => `#icon-${props.name}`);
</script>
<style scoped>
.svg-icon {
width: 1em;
height: 1em;
overflow: hidden;
fill: currentcolor;
}
</style>

View File

@ -0,0 +1,201 @@
<template>
<article class="product-card">
<RouterLink class="product-card__link" :to="`/shop/products/${product.id}`">
<span class="product-card__media">
<img
:src="getSafeProductImage(product.coverUrl)"
:alt="name"
loading="lazy"
/>
<small v-if="product.featured">{{ t('common.shop.featured') }}</small>
</span>
<span class="product-card__content">
<small
>{{ product.brand }} ·
{{ t(`common.shop.categories.${product.category}`) }}</small
>
<b>{{ name }}</b>
<span>{{ subtitle }}</span>
<span class="product-card__rating"
> {{ product.rating.toFixed(1) }} ·
{{ t('common.shop.sales', { count: product.sales }) }}</span
>
</span>
</RouterLink>
<footer>
<span class="product-card__price">
<strong>{{ formatProductPrice(product.priceCents, locale) }}</strong>
<del v-if="product.originalPriceCents > product.priceCents">{{
formatProductPrice(product.originalPriceCents, locale)
}}</del>
</span>
<button
type="button"
:aria-label="t('common.shop.addToCartLabel', { name })"
@click="emit('add-to-cart', product)"
>
<SvgIcon name="cart" />
</button>
</footer>
</article>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import type { Product } from '@/api/modules/products';
import {
formatProductPrice,
getLocalizedText,
getSafeProductImage,
} from '@/utils/product';
const props = defineProps<{ product: Product }>();
const emit = defineEmits<{ 'add-to-cart': [product: Product] }>();
const { locale, t } = useI18n();
const name = computed(() => getLocalizedText(props.product.name, locale.value));
const subtitle = computed(() =>
getLocalizedText(props.product.subtitle, locale.value),
);
</script>
<style scoped lang="scss">
.product-card {
display: flex;
flex-direction: column;
overflow: hidden;
color: var(--color-text);
text-decoration: none;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
transition: border-color var(--motion-fast) ease;
&:hover {
border-color: var(--color-border-strong);
}
}
.product-card__link {
display: block;
flex: 1;
color: inherit;
text-decoration: none;
}
.product-card__media {
position: relative;
display: block;
aspect-ratio: 1;
overflow: hidden;
background: var(--color-background-soft);
img {
width: 100%;
height: 100%;
object-fit: cover;
transition: opacity var(--motion-base) ease;
}
small {
position: absolute;
top: var(--space-2);
left: var(--space-2);
padding: 0.125rem 0.375rem;
font-size: var(--text-caption);
font-weight: 600;
color: var(--color-primary-contrast);
background: var(--color-primary);
border-radius: var(--radius-sm);
}
}
.product-card:hover img {
opacity: 0.92;
}
.product-card__content {
display: grid;
gap: var(--space-1);
padding: var(--space-3);
> small,
> span {
color: var(--color-text-secondary);
}
> small {
font-size: var(--text-caption);
}
> b {
display: -webkit-box;
overflow: hidden;
-webkit-line-clamp: 2;
font-size: var(--text-card-title);
font-weight: 650;
line-height: 1.35;
-webkit-box-orient: vertical;
}
> span:not(.product-card__price) {
display: -webkit-box;
overflow: hidden;
-webkit-line-clamp: 2;
font-size: var(--text-secondary);
line-height: 1.4;
-webkit-box-orient: vertical;
}
.product-card__rating {
margin-top: var(--space-1);
color: var(--color-warning);
}
}
.product-card__price {
display: flex;
gap: var(--space-2);
align-items: baseline;
margin-top: var(--space-2);
strong {
font-size: 1rem;
color: var(--color-text);
}
del {
font-size: var(--text-caption);
}
}
footer {
display: flex;
gap: var(--space-2);
align-items: center;
justify-content: space-between;
min-height: calc(var(--touch-target) + var(--space-2));
padding: 0 var(--space-3) var(--space-2);
.product-card__price {
min-width: 0;
margin-top: 0;
}
button {
display: grid;
flex: 0 0 auto;
place-items: center;
width: var(--touch-target);
min-height: var(--touch-target);
color: var(--color-primary-contrast);
background: var(--color-primary);
border: 0;
border-radius: var(--radius-md);
.svg-icon {
font-size: 1rem;
}
}
}
</style>

View File

@ -0,0 +1,453 @@
<template>
<form class="product-editor" autocomplete="off" @submit.prevent="submit">
<header>
<div>
<small>{{
product
? t('common.shop.editor.editEyebrow')
: t('common.shop.editor.createEyebrow')
}}</small>
<h2>
{{
product
? t('common.shop.editor.editTitle')
: t('common.shop.editor.createTitle')
}}
</h2>
</div>
<button
type="button"
:aria-label="t('common.shop.editor.cancel')"
@click="emit('cancel')"
>
×
</button>
</header>
<div class="form-grid form-grid--two">
<label
><span>SKU</span><input v-model.trim="form.sku" required maxlength="64"
/></label>
<label
><span>{{ t('common.shop.editor.brand') }}</span
><input v-model.trim="form.brand" required maxlength="80"
/></label>
<label
><span>{{ t('common.shop.editor.category') }}</span>
<select v-model="form.category" required>
<option v-for="item in categories" :key="item" :value="item">
{{ t(`common.shop.categories.${item}`) }}
</option>
</select>
</label>
<label
><span>{{ t('common.shop.editor.status') }}</span>
<select v-model="form.status" required>
<option value="draft">{{ t('common.shop.status.draft') }}</option>
<option value="on_sale">{{ t('common.shop.status.on_sale') }}</option>
<option value="sold_out">
{{ t('common.shop.status.sold_out') }}
</option>
</select>
</label>
</div>
<section class="locale-section">
<div
class="locale-tabs"
role="tablist"
:aria-label="t('common.shop.editor.contentLanguage')"
>
<button
v-for="item in localeTabs"
:key="item.value"
type="button"
role="tab"
:aria-selected="activeLocale === item.value"
:class="{ active: activeLocale === item.value }"
@click="activeLocale = item.value"
>
{{ item.label }}
</button>
</div>
<label
><span>{{ t('common.shop.editor.name') }}</span
><input v-model.trim="form.name[activeLocale]" required maxlength="180"
/></label>
<label
><span>{{ t('common.shop.editor.subtitle') }}</span
><input
v-model.trim="form.subtitle[activeLocale]"
required
maxlength="240"
/></label>
<label
><span>{{ t('common.shop.editor.description') }}</span
><textarea
v-model.trim="form.description[activeLocale]"
required
maxlength="10000"
rows="4"
/>
</label>
</section>
<label
><span>{{ t('common.shop.editor.coverUrl') }}</span
><input
v-model.trim="form.coverUrl"
required
maxlength="500"
placeholder="/products/product-placeholder.svg"
/></label>
<div class="form-grid form-grid--two">
<label
><span>{{ t('common.shop.editor.price') }}</span
><input
v-model.number="form.price"
type="number"
required
min="0"
step="0.01"
inputmode="decimal"
/></label>
<label
><span>{{ t('common.shop.editor.originalPrice') }}</span
><input
v-model.number="form.originalPrice"
type="number"
required
min="0"
step="0.01"
inputmode="decimal"
/></label>
<label
><span>{{ t('common.shop.editor.stock') }}</span
><input
v-model.number="form.stock"
type="number"
required
min="0"
step="1"
inputmode="numeric"
/></label>
<label
><span>{{ t('common.shop.editor.sales') }}</span
><input
v-model.number="form.sales"
type="number"
required
min="0"
step="1"
inputmode="numeric"
/></label>
<label
><span>{{ t('common.shop.editor.rating') }}</span
><input
v-model.number="form.rating"
type="number"
required
min="0"
max="5"
step="0.1"
inputmode="decimal"
/></label>
<label class="checkbox"
><input v-model="form.featured" type="checkbox" /><span>{{
t('common.shop.editor.featured')
}}</span></label
>
</div>
<p v-if="validationError" class="form-error" role="alert">
{{ validationError }}
</p>
<footer>
<button type="button" @click="emit('cancel')">
{{ t('common.shop.editor.cancel') }}</button
><button class="primary" type="submit" :disabled="saving">
{{
saving ? t('common.shop.editor.saving') : t('common.shop.editor.save')
}}
</button>
</footer>
</form>
</template>
<script setup lang="ts">
import { reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import type {
LocalizedText,
Product,
ProductInput,
ProductStatus,
} from '@/api/modules/products';
type LocaleKey = keyof LocalizedText;
interface EditorForm {
sku: string;
name: LocalizedText;
subtitle: LocalizedText;
description: LocalizedText;
category: string;
brand: string;
coverUrl: string;
price: number;
originalPrice: number;
stock: number;
sales: number;
rating: number;
status: ProductStatus;
featured: boolean;
}
const props = defineProps<{ product?: Product | null; saving?: boolean }>();
const emit = defineEmits<{ save: [value: ProductInput]; cancel: [] }>();
const { t } = useI18n();
const localeTabs: Array<{ value: LocaleKey; label: string }> = [
{ value: 'zh-CN', label: '中文' },
{ value: 'en-US', label: 'English' },
{ value: 'ja-JP', label: '日本語' },
];
const locales = localeTabs.map((item) => item.value);
const categories = ['digital', 'lifestyle', 'home', 'outdoor'] as const;
const activeLocale = ref<LocaleKey>('zh-CN');
const validationError = ref('');
const emptyLocalized = (): LocalizedText => ({
'zh-CN': '',
'en-US': '',
'ja-JP': '',
});
const blankForm = (): EditorForm => ({
sku: '',
name: emptyLocalized(),
subtitle: emptyLocalized(),
description: emptyLocalized(),
category: 'digital',
brand: '',
coverUrl: '/products/product-placeholder.svg',
price: 0,
originalPrice: 0,
stock: 0,
sales: 0,
rating: 5,
status: 'draft',
featured: false,
});
const form = reactive<EditorForm>(blankForm());
watch(
() => props.product,
(product) => {
const value = product
? {
sku: product.sku,
name: { ...product.name },
subtitle: { ...product.subtitle },
description: { ...product.description },
category: product.category,
brand: product.brand,
coverUrl: product.coverUrl,
price: product.priceCents / 100,
originalPrice: product.originalPriceCents / 100,
stock: product.stock,
sales: product.sales,
rating: product.rating,
status: product.status,
featured: product.featured,
}
: blankForm();
Object.assign(form, value);
validationError.value = '';
activeLocale.value = 'zh-CN';
},
{ immediate: true },
);
const submit = () => {
const localizedComplete = locales.every(
(locale) =>
form.name[locale] && form.subtitle[locale] && form.description[locale],
);
if (!localizedComplete) {
validationError.value = t('common.shop.editor.translationRequired');
return;
}
validationError.value = '';
emit('save', {
sku: form.sku,
name: { ...form.name },
subtitle: { ...form.subtitle },
description: { ...form.description },
category: form.category,
brand: form.brand,
coverUrl: form.coverUrl,
priceCents: Math.round(form.price * 100),
originalPriceCents: Math.round(form.originalPrice * 100),
stock: Math.trunc(form.stock),
sales: Math.trunc(form.sales),
rating: form.rating,
status: form.status,
featured: form.featured,
});
};
</script>
<style scoped lang="scss">
.product-editor {
display: grid;
gap: var(--space-5);
width: min(100%, 47.5rem);
max-height: min(92dvh, 60rem);
padding: var(--space-6);
overflow: auto;
background: var(--color-surface);
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
box-shadow: var(--shadow-dialog);
}
header,
footer,
.locale-tabs {
display: flex;
gap: var(--space-3);
align-items: center;
justify-content: space-between;
}
header {
small {
font-size: var(--text-caption);
font-weight: 650;
color: var(--color-text-secondary);
}
h2 {
margin: var(--space-1) 0 0;
font-size: var(--text-section-title);
}
> button {
width: var(--touch-target);
min-height: var(--touch-target);
font-size: 1.5rem;
background: transparent;
border: 0;
}
}
.form-grid {
display: grid;
gap: var(--space-4);
}
.form-grid--two {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
label {
display: grid;
gap: var(--space-2);
font-size: var(--text-secondary);
font-weight: 600;
}
input,
select,
textarea {
width: 100%;
min-height: var(--touch-target);
padding: 0.625rem var(--space-3);
color: var(--color-text);
background: var(--color-surface);
border: 1px solid var(--color-border-strong);
border-radius: var(--radius-md);
}
textarea {
resize: vertical;
}
.locale-section {
display: grid;
gap: var(--space-4);
padding-top: var(--space-4);
border-top: 1px solid var(--color-border);
}
.locale-tabs {
justify-content: flex-start;
}
.locale-tabs button {
min-height: 2.25rem;
padding: 0 var(--space-2);
color: var(--color-text-secondary);
background: transparent;
border: 1px solid var(--color-border);
border: 0;
border-bottom: 2px solid transparent;
border-radius: 0;
}
.locale-tabs button.active {
font-weight: 600;
color: var(--color-text);
background: transparent;
border-bottom-color: var(--color-primary);
}
.checkbox {
display: flex;
flex-direction: row;
align-items: center;
align-self: end;
min-height: var(--touch-target);
}
.checkbox input {
width: 1.125rem;
min-height: 1.125rem;
}
.form-error {
margin: 0;
color: var(--color-danger);
}
footer {
position: sticky;
bottom: calc(var(--space-6) * -1);
padding: var(--space-4) 0 calc(var(--space-1) + env(safe-area-inset-bottom));
background: var(--color-surface);
}
footer button {
min-height: var(--touch-target);
padding: 0 var(--space-4);
color: var(--color-text);
background: var(--color-background-soft);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
}
footer .primary {
color: var(--color-primary-contrast);
background: var(--color-primary);
border-color: var(--color-primary);
}
@media (max-width: 480px) {
.form-grid--two {
grid-template-columns: 1fr;
}
}
@media (min-width: 768px) {
.product-editor {
margin: auto;
border-radius: var(--radius-lg);
}
}
</style>

View File

@ -0,0 +1,21 @@
import { onBeforeUnmount, onMounted, ref } from 'vue';
export function useNetworkStatus() {
const isOnline = ref(
typeof navigator === 'undefined' ? true : navigator.onLine,
);
const update = () => {
isOnline.value = navigator.onLine;
};
onMounted(() => {
window.addEventListener('online', update);
window.addEventListener('offline', update);
});
onBeforeUnmount(() => {
window.removeEventListener('online', update);
window.removeEventListener('offline', update);
});
return { isOnline };
}

View File

@ -0,0 +1,58 @@
import { computed, ref } from 'vue';
interface PullToRefreshOptions {
onRefresh: () => Promise<unknown>;
threshold?: number;
}
export function usePullToRefresh({
onRefresh,
threshold = 64,
}: PullToRefreshOptions) {
const startY = ref(0);
const distance = ref(0);
const refreshing = ref(false);
const ready = computed(() => distance.value >= threshold);
const isAtTop = (event: TouchEvent) => {
const target = event.currentTarget as HTMLElement | null;
return (target?.closest('.page-scroll')?.scrollTop ?? window.scrollY) <= 0;
};
const onTouchStart = (event: TouchEvent) => {
if (refreshing.value || !isAtTop(event)) return;
startY.value = event.touches[0]?.clientY ?? 0;
};
const onTouchMove = (event: TouchEvent) => {
if (!startY.value || refreshing.value || !isAtTop(event)) return;
const currentY = event.touches[0]?.clientY ?? startY.value;
distance.value = Math.min(
88,
Math.max(0, (currentY - startY.value) * 0.45),
);
};
const reset = () => {
startY.value = 0;
distance.value = 0;
};
const onTouchEnd = async () => {
if (!startY.value) return;
if (!ready.value) {
reset();
return;
}
refreshing.value = true;
distance.value = threshold;
try {
await onRefresh();
} finally {
refreshing.value = false;
reset();
}
};
return { distance, onTouchEnd, onTouchMove, onTouchStart, ready, refreshing };
}

View File

@ -0,0 +1,122 @@
import { computed, ref, shallowRef } from 'vue';
import { defaultChatProvider } from '@/services/ai/fetchStreamProvider';
import type { ChatMessage, ChatProvider, ChatUsage } from '@/types/ai';
function createId(prefix: string) {
return `${prefix}-${globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(16).slice(2)}`}`;
}
function createMessage(
role: ChatMessage['role'],
content: string,
status: ChatMessage['status'] = 'ready',
): ChatMessage {
return { id: createId(role), role, content, createdAt: Date.now(), status };
}
export function useStreamingChat(provider: ChatProvider = defaultChatProvider) {
const messages = ref<ChatMessage[]>([]);
const error = ref<Error | null>(null);
const activeController = shallowRef<AbortController | null>(null);
const conversationId = ref(createId('conversation'));
const usage = ref<ChatUsage | null>(null);
const streaming = computed(() => activeController.value !== null);
const run = async (context: ChatMessage[]) => {
messages.value.push(createMessage('assistant', '', 'streaming'));
const assistant = messages.value[messages.value.length - 1];
if (!assistant) return;
const controller = new AbortController();
activeController.value = controller;
error.value = null;
usage.value = null;
try {
for await (const chunk of provider.chat(context, {
signal: controller.signal,
conversationId: conversationId.value,
})) {
if (chunk.type === 'start') conversationId.value = chunk.conversationId;
if (chunk.type === 'delta') assistant.content += chunk.delta;
if (chunk.type === 'usage')
usage.value = {
inputTokens: chunk.inputTokens,
outputTokens: chunk.outputTokens,
};
if (chunk.type === 'error') throw new Error(chunk.message);
if (chunk.type === 'done') break;
}
assistant.status = 'ready';
// catch 参数若命名为 error 会遮蔽外层 error ref故用 caught 并禁用命名规则
// oxlint-disable-next-line unicorn/catch-error-name
} catch (caught) {
if (controller.signal.aborted) {
assistant.status = 'stopped';
} else {
const normalized =
caught instanceof Error ? caught : new Error('Chat request failed');
assistant.status = 'error';
error.value = normalized;
}
} finally {
if (activeController.value === controller) activeController.value = null;
}
};
const sendMessage = async (content: string) => {
const normalized = content.trim();
if (!normalized || streaming.value) return;
const userMessage = createMessage('user', normalized);
messages.value.push(userMessage);
await run([...messages.value]);
};
const stop = () => activeController.value?.abort();
const findLastMessageIndex = (
predicate: (message: ChatMessage) => boolean,
) => {
for (let index = messages.value.length - 1; index >= 0; index -= 1) {
const message = messages.value[index];
if (message && predicate(message)) return index;
}
return -1;
};
const regenerate = async (assistantId?: string) => {
if (streaming.value) return;
const targetIndex = assistantId
? messages.value.findIndex((message) => message.id === assistantId)
: findLastMessageIndex((message) => message.role === 'assistant');
if (targetIndex < 0) return;
const context = messages.value.slice(0, targetIndex);
if (!context.some((message) => message.role === 'user')) return;
messages.value = context;
await run([...context]);
};
const retry = () => {
const index = findLastMessageIndex((message) => message.status === 'error');
return regenerate(index >= 0 ? messages.value[index]?.id : undefined);
};
const clear = () => {
stop();
messages.value = [];
error.value = null;
usage.value = null;
conversationId.value = createId('conversation');
};
return {
messages,
conversationId,
usage,
streaming,
error,
sendMessage,
stop,
regenerate,
retry,
clear,
};
}

View File

@ -0,0 +1,18 @@
import { onBeforeUnmount, onMounted, ref } from 'vue';
export function useVisualViewport() {
const height = ref<number>();
const update = () => {
height.value = window.visualViewport?.height;
};
onMounted(() => {
update();
window.visualViewport?.addEventListener('resize', update);
window.visualViewport?.addEventListener('scroll', update);
});
onBeforeUnmount(() => {
window.visualViewport?.removeEventListener('resize', update);
window.visualViewport?.removeEventListener('scroll', update);
});
return { viewportHeight: height };
}

View File

@ -1,92 +1,348 @@
<template>
<div class="main-page">
<van-nav-bar :title="$t($route.meta.title as string)" :left-arrow="!tabbarVisible" @click-left="goBack" />
<div class="main-box" :class="{ tabbar: tabbarVisible, border: showBorder }">
<RouterView v-slot="{ Component }" v-if="$route.meta.keepAlive">
<keep-alive>
<component :is="Component" :key="$route.path" />
</keep-alive>
</RouterView>
<RouterView v-if="!$route.meta.keepAlive" :key="$route.path" />
</div>
<nut-tabbar
unactive-color="#364636"
active-color="#1989fa"
v-model="activeTab"
v-show="tabbarVisible"
@tab-switch="tabSwitch"
safe-area-inset-bottom
<div class="app-shell">
<header
v-if="!route.meta.hideHeader"
class="top-bar"
:class="{ 'top-bar--root': isRootPage }"
>
<nut-tabbar-item v-for="item in tabItem" :key="item.key" :tab-title="$t(`common.tabbar.${item.key}`)" :icon="item.icon" />
</nut-tabbar>
<button
v-if="!isRootPage"
class="icon-button"
type="button"
:aria-label="t('common.global.back')"
@click="router.back()"
>
</button>
<span v-else />
<span class="top-bar__title">{{ pageTitle }}</span>
<span />
</header>
<p v-if="!isOnline" class="offline-banner" role="status">
{{ t('common.global.offlineBanner') }}
</p>
<main class="page-scroll" :class="{ 'page-scroll--with-nav': isRootPage }">
<RouterView v-slot="{ Component, route: childRoute }">
<KeepAlive>
<component
:is="Component"
v-if="childRoute.meta.keepAlive"
:key="childRoute.name"
/>
</KeepAlive>
<component
:is="Component"
v-if="!childRoute.meta.keepAlive"
:key="childRoute.fullPath"
/>
</RouterView>
</main>
<RouterLink
v-if="!route.meta.hideAiEntry"
class="ai-entry"
:class="{ 'ai-entry--with-nav': isRootPage }"
to="/ai/chat"
:aria-label="t('common.ai.open')"
>
<SvgIcon name="ai" />
<span>{{ t('common.ai.open') }}</span>
</RouterLink>
<nav
v-if="isRootPage"
class="tab-bar"
:aria-label="t('common.global.mainNavigation')"
>
<RouterLink to="/home" class="tab-bar__brand"
><SvgIcon name="logo" /><strong>H5 Studio</strong></RouterLink
>
<RouterLink
v-for="item in tabs"
:key="item.to"
:to="item.to"
class="tab-bar__item"
>
<SvgIcon :name="item.icon" />
<span>{{ item.label }}</span>
</RouterLink>
<label class="tab-bar__language">
<span class="visually-hidden">{{ t('common.language.label') }}</span>
<select
:value="locale"
:aria-label="t('common.language.label')"
@change="changeLanguage"
>
<option
v-for="option in languageOptions"
:key="option.value"
:value="option.value"
>
{{ t(option.label) }}
</option>
</select>
</label>
</nav>
</div>
</template>
<script lang="ts" setup name="BasicLayoutPage">
import { Home, Horizontal, My, Location } from '@nutui/icons-vue';
<script setup lang="ts">
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { useNetworkStatus } from '@/composables/useNetworkStatus';
import { setLang } from '@/locales';
import type { SupportedLocale } from '@/locales';
import type { SvgIconName } from '@/types/icon';
const tabItem = [
{ key: 'home', icon: Home },
{ key: 'list', icon: Horizontal },
{ key: 'member', icon: My },
{ key: 'demo', icon: Location },
];
const router = useRouter();
const activeTab = ref(0);
const tabbarVisible = ref(true);
const showBorder = ref(true);
const route = useRoute();
watch(
() => route.path,
(path) => {
const currentKey = path.replace('/', '');
const judgeRoute = tabItem.some((item) => item.key === currentKey);
activeTab.value = tabItem.findIndex((item) => item.key === currentKey);
tabbarVisible.value = judgeRoute;
showBorder.value = judgeRoute;
},
{ immediate: true },
);
const tabSwitch = (_item: any, index: number) => {
const tab = tabItem[index];
if (tab) {
router.push(`/${tab.key}`);
}
activeTab.value = index;
};
const goBack = () => {
router.go(-1);
};
const route = useRoute();
const router = useRouter();
const { locale, t } = useI18n();
const { isOnline } = useNetworkStatus();
const rootNames = new Set(['home', 'shop', 'examples', 'member']);
const isRootPage = computed(() => rootNames.has(String(route.name)));
const pageTitle = computed(() =>
route.meta.title ? t(route.meta.title) : 'Vue H5',
);
const tabs = computed<Array<{ to: string; label: string; icon: SvgIconName }>>(
() => [
{ to: '/home', label: t('common.tabbar.home'), icon: 'home' },
{ to: '/shop', label: t('common.tabbar.shop'), icon: 'shop' },
{ to: '/examples', label: t('common.tabbar.examples'), icon: 'examples' },
{ to: '/member', label: t('common.tabbar.member'), icon: 'user' },
],
);
const languageOptions: Array<{ value: SupportedLocale; label: string }> = [
{ value: 'zh-CN', label: 'common.language.zh' },
{ value: 'en-US', label: 'common.language.en' },
{ value: 'ja-JP', label: 'common.language.ja' },
];
const changeLanguage = (event: Event) =>
void setLang((event.target as HTMLSelectElement).value);
</script>
<style scoped lang="scss">
.nut-navbar {
margin-bottom: 0;
.app-shell {
display: flex;
flex-direction: column;
width: 100%;
height: 100dvh;
background: var(--color-background);
}
.top-bar {
display: grid;
grid-template-columns: 3.25rem 1fr 3.25rem;
align-items: center;
min-height: 3.25rem;
padding-top: env(safe-area-inset-top);
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
}
.top-bar__title {
overflow: hidden;
text-overflow: ellipsis;
font-size: var(--text-body);
font-weight: 650;
text-align: center;
white-space: nowrap;
}
.icon-button {
width: var(--touch-target);
min-height: var(--touch-target);
font-size: 1.75rem;
color: var(--color-text);
background: transparent;
border: 0;
}
.page-scroll {
flex: 1;
min-height: 0;
overflow: hidden auto;
overscroll-behavior-y: contain;
-webkit-overflow-scrolling: touch;
}
.page-scroll--with-nav {
padding-bottom: calc(4rem + env(safe-area-inset-bottom));
}
.offline-banner {
padding: var(--space-2) var(--space-4);
font-size: var(--text-secondary);
color: var(--color-warning);
text-align: center;
background: var(--color-warning-soft);
}
.tab-bar {
position: fixed;
right: 0;
bottom: 0;
left: 0;
z-index: 20;
display: grid;
grid-template-columns: repeat(4, 1fr);
min-height: 3.75rem;
padding-bottom: env(safe-area-inset-bottom);
background: var(--color-surface);
background: color-mix(in srgb, var(--color-surface) 94%, transparent);
border-top: 1px solid var(--color-border);
backdrop-filter: blur(12px);
}
.tab-bar__brand {
display: none;
}
.tab-bar__language {
display: none;
}
.ai-entry {
position: fixed;
right: max(var(--space-4), env(safe-area-inset-right));
bottom: max(var(--space-4), env(safe-area-inset-bottom));
z-index: 19;
display: inline-flex;
gap: var(--space-2);
align-items: center;
justify-content: center;
min-width: var(--touch-target);
min-height: var(--touch-target);
padding: 0;
font-size: var(--text-secondary);
font-weight: 650;
color: var(--color-primary-contrast);
text-decoration: none;
background: var(--color-primary);
border: 1px solid color-mix(in srgb, var(--color-primary) 70%, #000);
border-radius: var(--radius-lg);
box-shadow: 0 4px 14px rgb(24 24 27 / 14%);
.svg-icon {
font-size: 1.125rem;
}
.main-page {
display: flex;
flex-direction: column;
width: 100dvw;
height: 100dvh;
span {
display: none;
}
}
.main-box {
flex: auto;
min-height: 0;
overflow: hidden auto;
.ai-entry--with-nav {
bottom: calc(4.5rem + env(safe-area-inset-bottom));
}
.tab-bar__item {
display: flex;
flex-direction: column;
gap: 0.125rem;
align-items: center;
justify-content: center;
font-size: 0.6875rem;
color: var(--color-text-secondary);
text-decoration: none;
.svg-icon {
font-size: 1.25rem;
}
&.router-link-active {
color: var(--color-primary);
}
}
@media (min-width: 768px) {
.top-bar--root {
display: none;
}
.page-scroll--with-nav {
padding-top: 4.75rem;
padding-bottom: 0;
scrollbar-gutter: stable both-edges;
}
.tab-bar {
inset: 0.75rem 1.5rem auto;
grid-template-columns: minmax(7rem, 1fr) repeat(4, auto) auto;
gap: var(--space-1);
width: min(calc(100% - 3rem), var(--content-max-width));
min-height: 3.25rem;
padding: 0.25rem;
margin: 0 auto;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-float);
}
.tab-bar__brand {
display: flex;
gap: var(--space-2);
align-items: center;
align-self: stretch;
padding: 0 var(--space-3);
color: var(--color-text);
text-decoration: none;
.svg-icon {
font-size: 1.25rem;
color: var(--color-primary);
}
}
.border {
padding-right: 30px;
padding-left: 30px;
.tab-bar__item {
flex-direction: row;
gap: var(--space-2);
min-width: 5rem;
padding: 0 var(--space-3);
font-size: var(--text-secondary);
border-radius: var(--radius-md);
&.router-link-active {
color: var(--color-text);
background: var(--color-background-soft);
}
.svg-icon {
font-size: 1rem;
}
}
.tab-bar__language {
display: grid;
align-items: center;
padding: 0 var(--space-1);
select {
min-height: var(--touch-target);
padding: 0 var(--space-2);
color: var(--color-text);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
}
}
.ai-entry {
right: max(1.5rem, calc((100vw - var(--content-max-width)) / 2 + 1.5rem));
bottom: var(--space-6);
padding: 0 var(--space-3);
span {
display: inline;
}
}
}
@supports not (height: 100dvh) {
.app-shell {
height: 100vh;
}
}
</style>

View File

@ -2,33 +2,44 @@ import { createI18n } from 'vue-i18n';
import type { App } from 'vue';
const LOCALE_KEY = 'lang';
const DEFAULT_LOCALE = 'zh-CN';
export const SUPPORTED_LOCALES = ['zh-CN', 'en-US', 'ja-JP'] as const;
export type SupportedLocale = (typeof SUPPORTED_LOCALES)[number];
export const DEFAULT_LOCALE: SupportedLocale = 'zh-CN';
export function isSupportedLocale(locale: string): locale is SupportedLocale {
return SUPPORTED_LOCALES.includes(locale as SupportedLocale);
}
/**
*
*/
const modules = import.meta.glob('./langs/**/*.json');
const localeLoaders: Record<string, () => Promise<any>> = {};
const localeLoaders: Record<string, () => Promise<Record<string, unknown>>> =
{};
Object.keys(modules).forEach((path) => {
// ./langs/zh-CN/common.json
const match = path.match(/\.\/langs\/([^/]+)\/(.+)\.json$/);
if (!match) return;
const locale: any = match[1];
const locale = match[1];
if (!locale) return;
if (!localeLoaders[locale]) {
localeLoaders[locale] = async () => {
const messages: Record<string, any> = {};
const messages: Record<string, unknown> = {};
for (const p in modules) {
const m = p.match(new RegExp(`./langs/${locale}/(.+)\\.json$`));
if (!m) continue;
const namespace: any = m[1];
const namespace = m[1];
if (!namespace) continue;
if (modules[p]) {
const mod: any = await modules[p]();
const mod = (await modules[p]()) as {
default: Record<string, unknown>;
};
messages[namespace] = mod.default;
}
}
@ -53,7 +64,9 @@ export const i18n = createI18n({
*
*/
export async function setLang(locale?: string) {
const target = locale || localStorage.getItem(LOCALE_KEY) || DEFAULT_LOCALE;
const requested =
locale || localStorage.getItem(LOCALE_KEY) || DEFAULT_LOCALE;
const target = isSupportedLocale(requested) ? requested : DEFAULT_LOCALE;
if (!i18n.global.availableLocales.includes(target)) {
const loader = localeLoaders[target];
@ -66,6 +79,7 @@ export async function setLang(locale?: string) {
i18n.global.locale.value = target;
localStorage.setItem(LOCALE_KEY, target);
document.documentElement.lang = target;
return target;
}
/**

View File

@ -1,14 +1,28 @@
export type langType = {
export type LangType = {
title: string;
tabbar: {
home: string;
list: string;
examples: string;
member: string;
demo: string;
};
routes: {
home: string;
examples: string;
query: string;
request: string;
icons: string;
ui: string;
member: string;
aiChat: string;
login: string;
offline: string;
};
language: {
en: string;
zh: string;
ja: string;
};
introduction: string;
home: {

View File

@ -1,25 +1,418 @@
{
"title": "VUE H5 development template",
"title": "Vue H5 Mobile Template",
"tabbar": {
"home": "Home",
"list": "List",
"member": "Member",
"demo": "demo"
"shop": "Shop",
"examples": "Examples",
"ai": "AI",
"member": "Profile"
},
"routes": {
"home": "Home",
"shop": "Curated Shop",
"cart": "Cart",
"productDetail": "Product Details",
"productAdmin": "Product Management",
"examples": "Engineering Examples",
"query": "TanStack Query",
"request": "Type-safe Request",
"projects": "Project Workspace",
"workspace": "Delivery Workspace",
"mobile": "Mobile Capabilities",
"icons": "SVG Icons",
"ui": "UI Framework",
"member": "Profile",
"aiChat": "AI Chat",
"login": "Login",
"offline": "Offline"
},
"language": {
"label": "Language",
"zh": "Chinese",
"en": "English",
"zh": "Chinese"
"ja": "Japanese"
},
"global": {
"back": "Back",
"backHome": "Back home",
"loading": "Loading…",
"retry": "Retry",
"online": "Online",
"mainNavigation": "Main navigation",
"offlineBanner": "You are offline. Some cached content remains available."
},
"introduction": "A rapid development vue3 of mobile terminal template",
"home": {
"support": "support"
"eyebrow": "VUE H5 TEMPLATE · V2",
"title": "A Vue 3 starting point for real mobile products.",
"description": "Type-safe APIs, streaming AI, server state, and a selectable UI framework come with working implementations.",
"browseShop": "Browse the shop",
"tryAi": "Try streaming AI",
"viewExamples": "Explore examples",
"metricsLabel": "Template capabilities",
"typeSafe": "Type safe",
"optionalUi": "UI options",
"realStreaming": "Real streaming",
"architecture": "Architecture",
"stateBoundary": "Clear state boundaries",
"clientState": "Client state",
"clientStateDescription": "Authentication, themes, and feature flags shared across pages.",
"serverState": "Server state",
"serverStateDescription": "Caching, retries, cancellation, mutations, and infinite scrolling.",
"featured": "Featured example",
"aiTitle": "Provider-neutral AI chat",
"aiDescription": "Model streams with AsyncIterable and connect to any trusted AI gateway.",
"openAi": "Open AI chat",
"shopTitle": "A commerce flow you can integrate",
"shopDescription": "Product lists, details, search, pagination, and localized administration run against Gin and PostgreSQL.",
"openShop": "Open the shop"
},
"list": {
"details": "list details"
"examples": {
"eyebrow": "Production patterns",
"title": "More than a component playground",
"description": "Every example maps to a real architecture boundary, type, and test.",
"aiTitle": "AI Chat and Streaming",
"aiDescription": "SSE, abort, Markdown, regenerate",
"queryTitle": "TanStack Query",
"queryDescription": "Queries, mutations, infinite queries",
"requestTitle": "Type-safe Request",
"requestDescription": "400500, business errors, timeout, and request IDs",
"workspaceTitle": "Gin Delivery Workspace",
"workspaceDescription": "View, create, edit, delete, and filter real records",
"mobileTitle": "Mobile Browser Capabilities",
"mobileDescription": "Network status, clipboard, Web Share, and safe area",
"offlineTitle": "PWA and Offline Fallback",
"offlineDescription": "App-shell caching, offline fallback, and API cache boundaries",
"iconsTitle": "SVG Sprite",
"iconsDescription": "Auto-loaded assets/icons and SvgIcon",
"uiTitle": "UI Framework Strategy",
"uiDescription": "Choose Vant, NutUI, or Varlet for production"
},
"btn": {
"confirm": "confirm",
"cancel": "cancel"
"shop": {
"eyebrow": "MOBILE COMMERCE DEMO",
"title": "Useful goods for everyday life",
"description": "A complete product catalog with search, filters, pagination, details, and administration backed by Gin and PostgreSQL.",
"searchLabel": "Search products",
"searchPlaceholder": "Search products, brands, or categories",
"search": "Search",
"sortLabel": "Sort",
"catalog": "CATALOG",
"productCount": "{count} products",
"manage": "Manage products",
"featured": "Featured",
"sales": "{count} sold",
"stock": "{count} in stock",
"loadFailed": "Unable to load products",
"empty": "No products found",
"emptyDescription": "Try another keyword or category.",
"loadingMore": "Loading…",
"loadMore": "Load more",
"pullRefresh": "Pull to refresh",
"releaseRefresh": "Release to refresh",
"refreshing": "Refreshing…",
"addToCartLabel": "Add {name} to cart",
"addedToCart": "Added to cart · {count} items",
"categories": {
"all": "All",
"digital": "Digital",
"lifestyle": "Lifestyle",
"home": "Home",
"outdoor": "Outdoor"
},
"sort": {
"featured": "Featured",
"sales": "Best sellers",
"priceAsc": "Price: low to high",
"priceDesc": "Price: high to low"
},
"status": {
"draft": "Draft",
"on_sale": "On sale",
"sold_out": "Sold out"
},
"detail": {
"quality": "Quality assured",
"shipping": "Fast shipping",
"returns": "7-day returns",
"about": "ABOUT THIS PRODUCT",
"productDetails": "Product details",
"addCart": "Add to cart",
"buyNow": "Buy now",
"decreaseQuantity": "Decrease quantity",
"increaseQuantity": "Increase quantity",
"cartAdded": "Added {count} item(s) to your cart",
"checkoutDemo": "Selected {count} item(s); connect your checkout in a real project"
},
"editor": {
"editEyebrow": "EDIT PRODUCT",
"createEyebrow": "NEW PRODUCT",
"editTitle": "Update product information",
"createTitle": "Create a product",
"cancel": "Cancel",
"brand": "Brand",
"category": "Category",
"status": "Status",
"contentLanguage": "Content language",
"name": "Product name",
"subtitle": "Selling point",
"description": "Description",
"coverUrl": "Cover image URL",
"price": "Price",
"originalPrice": "Original price",
"stock": "Stock",
"sales": "Sales",
"rating": "Rating",
"featured": "Feature this product",
"translationRequired": "Complete the name, selling point, and description in Chinese, English, and Japanese.",
"saving": "Saving…",
"save": "Save product"
},
"admin": {
"eyebrow": "COMMERCE OPERATIONS",
"title": "Product management",
"description": "Create localized products, maintain pricing and inventory, and control availability. Data persists in PostgreSQL.",
"create": "New product",
"total": "Products",
"onSale": "On sale",
"lowStock": "Low stock",
"searchPlaceholder": "Search SKU, name, or brand",
"allStatus": "All statuses",
"edit": "Edit",
"delete": "Delete",
"empty": "No products match these filters.",
"editorLabel": "Product editor",
"operationFailed": "Product operation failed",
"deleteConfirm": "Delete “{name}”?"
}
},
"cart": {
"eyebrow": "Mobile cart",
"title": "Shopping cart",
"shortTitle": "Cart",
"open": "Open shopping cart",
"description": "You have {count} items. Adjust quantities before checkout.",
"items": "Cart items",
"selectItem": "Select {name}",
"empty": "Your cart is empty",
"emptyDescription": "Browse the shop and add something useful.",
"browse": "Continue shopping",
"remove": "Remove",
"selectAll": "Select all",
"total": "Total",
"checkout": "Checkout ({count})",
"checkoutDemo": "{count} items selected; this template does not process real payments"
},
"request": {
"eyebrow": "Type-safe client",
"title": "Normalize errors, not presentation",
"description": "The request layer owns tokens, request IDs, unwrapping, and error normalization; pages decide how errors appear.",
"errorMatrix": "Error matrix",
"errorTitle": "Common request failure scenarios",
"trigger": "Trigger error",
"redirectNote": "A 401 follows the real auth flow by clearing the session and redirecting to login. Other errors stay on this page with their normalized result.",
"scenarios": {
"badRequest": "Bad request",
"badRequestDescription": "HTTP 400 for malformed or invalid request parameters.",
"unauthorized": "Session expired",
"unauthorizedDescription": "HTTP 401 triggers global sign-out and a safe redirect.",
"forbidden": "Insufficient permission",
"forbiddenDescription": "HTTP 403 means the user is signed in but cannot access the resource.",
"notFound": "Resource not found",
"notFoundDescription": "HTTP 404 for deleted resources or invalid addresses.",
"conflict": "Version conflict",
"conflictDescription": "HTTP 409 for concurrent updates or unique-key conflicts.",
"validation": "Business validation",
"validationDescription": "HTTP 200 with business code 422 becomes a business error in the interceptor.",
"serverError": "Server failure",
"serverErrorDescription": "HTTP 500 keeps retry and diagnostic details available to the page.",
"timeout": "Request timeout",
"timeoutDescription": "A short timeout verifies timeout error normalization."
},
"unknownError": "Unknown error"
},
"workspace": {
"eyebrow": "REAL BUSINESS FLOW",
"title": "Delivery workspace",
"description": "The same typed client can use Vite Mock or Gin with PostgreSQL, including view, create, edit, and delete operations.",
"create": "New project",
"createTitle": "Create delivery project",
"filter": "Status filter",
"allStatus": "All statuses",
"refresh": "Refresh data",
"loading": "Loading projects…",
"empty": "No projects match the current filter.",
"noDescription": "No project description",
"name": "Project name",
"namePlaceholder": "For example: Mobile shop launch",
"projectDescription": "Project description",
"descriptionPlaceholder": "Describe goals, scope, and delivery criteria",
"projectStatus": "Project status",
"view": "View",
"edit": "Edit",
"delete": "Delete",
"cancel": "Cancel",
"save": "Save project",
"saving": "Saving…",
"close": "Close",
"createdAt": "Created",
"updatedAt": "Updated {date}",
"unknownError": "Project operation failed",
"deleteConfirm": "Delete “{name}”?",
"mode": { "create": "Create", "edit": "Edit", "view": "Project details" },
"status": { "active": "Active", "paused": "Paused", "archived": "Archived" }
},
"mobile": {
"eyebrow": "MOBILE WEB APIs",
"title": "Browser capabilities and fallback",
"description": "Uses standard browser capabilities and provides a clear fallback when unavailable.",
"network": "Network status",
"networkDescription": "Listens for online and offline events without mixing network state into server cache.",
"offline": "Offline",
"clipboard": "Clipboard",
"clipboardDescription": "Copies text through the Clipboard API.",
"copy": "Copy sample text",
"copied": "Sample text copied",
"share": "System share",
"shareDescription": "Uses Web Share when available and disables the action otherwise.",
"shareAction": "Share this page",
"shared": "System share opened",
"safeArea": "Safe area",
"safeAreaDescription": "Bottom navigation and floating actions avoid notches and home indicators.",
"adapted": "Adapted"
},
"icons": {
"eyebrow": "Product icons",
"title": "Typed SVG sprite",
"description": "Icons auto-load from src/assets/icons and stay separate from UI framework icons."
},
"query": {
"eyebrow": "Server state",
"description": "Pinia does not store this data. Query Client owns caching, retries, mutations, and pagination.",
"queryMutation": "Query and mutation",
"checklist": "Release checklist",
"refresh": "Refresh",
"loadingTasks": "Loading tasks…",
"done": "Done",
"todo": "Todo",
"infinite": "Infinite query",
"feed": "Engineering feed",
"loadingMore": "Loading…",
"loadMore": "Load more",
"end": "You reached the end",
"tasks": {
"1": "Review the mobile checkout flow",
"2": "Connect generated API types",
"3": "Test the offline experience"
},
"feedItems": {
"performanceTitle": "Mobile performance budget",
"streamingTitle": "Streaming UI patterns",
"architectureTitle": "Type-safe request layers",
"summary": "A practical note for production mobile H5 teams · #{index}",
"performance": "Performance",
"ai": "AI",
"architecture": "Architecture"
}
},
"member": {
"guestTitle": "Save your template workspace",
"guestDescription": "Sign in to demonstrate token injection, 401 expiry, and a user query. The mock needs no real account.",
"login": "Try login",
"signedIn": "Signed in",
"loading": "Loading…",
"plan": "{role} · {plan} plan",
"session": "Session storage",
"sessionDescription": "Survives refresh and clears with the tab",
"serverProfile": "Server profile",
"serverProfileDescription": "Cached by TanStack Query for 30 seconds",
"logout": "Sign out"
},
"login": {
"back": "Back home",
"eyebrow": "Welcome back",
"title": "Sign in to H5 Studio",
"description": "The development mock accepts any non-empty username and password.",
"realDescription": "With the real backend, use demo / demo1234. The login token works for both Gin APIs and FastAPI AI.",
"username": "Username",
"usernamePlaceholder": "For example: demo-user",
"password": "Password",
"passwordPlaceholder": "Enter any non-empty password",
"submitting": "Signing in…",
"submit": "Sign in",
"failed": "Sign-in failed. Please try again."
},
"projects": {
"eyebrow": "REAL BACKEND FLOW",
"title": "Delivery workspace",
"description": "This page calls the Gin business service directly. Login issues the JWT and TanStack Query owns remote state.",
"createTitle": "Create project",
"name": "Project name",
"namePlaceholder": "For example: Mobile launch",
"projectDescription": "Description",
"descriptionPlaceholder": "Describe the delivery goal",
"creating": "Creating…",
"create": "Create project",
"serverState": "SERVER STATE",
"listTitle": "My projects",
"refresh": "Refresh",
"loading": "Loading projects…",
"empty": "No projects yet. Create the first one.",
"noDescription": "No description",
"nextStatus": "Change status",
"delete": "Delete",
"unknownError": "Project operation failed",
"status": { "active": "Active", "paused": "Paused", "archived": "Archived" }
},
"ai": {
"open": "AI Assistant",
"assistant": "H5 Assistant",
"status": "Mock stream · provider neutral",
"back": "Back",
"newChat": "New chat",
"emptyTitle": "What should we solve today?",
"emptyDescription": "This is an abortable SSE stream, not a one-shot timer.",
"stopped": "Generation stopped",
"failed": "Generation failed: {message}",
"copied": "Copied",
"copy": "Copy",
"regenerate": "Regenerate",
"scrollBottom": "Scroll to bottom",
"disclaimer": "AI can make mistakes. Verify important information.",
"inputLabel": "Message the AI",
"placeholder": "Type a message…",
"stop": "Stop generating",
"send": "Send message",
"prompts": [
"How should Vue Query and Pinia divide responsibilities?",
"Explain this template's streaming architecture",
"Write a Vue 3 composable example"
]
},
"ui": {
"eyebrow": "Build-time selection",
"title": "{framework} adapter",
"description": "VITE_UI_FRAMEWORK selects one resolver and page alias at build time. Other UI packages stay out of the production graph.",
"current": "This production build resolves only {framework}.",
"buttons": "Buttons and states",
"primary": "Primary action",
"default": "Secondary action",
"danger": "Danger action",
"settings": "Settings list",
"notifications": "Notifications",
"notificationsDescription": "Receive important service updates",
"plan": "Current plan",
"pro": "Pro",
"progress": "Task progress",
"progressDescription": "Feedback for uploads and background jobs",
"tag": "Mobile friendly"
},
"offline": {
"title": "You are offline",
"description": "The PWA app shell is cached; API requests are not cached by the Service Worker by default.",
"back": "Back home"
},
"errorBoundary": {
"title": "Something went wrong",
"description": "The error was isolated by a boundary. You can reload the page.",
"reload": "Reload"
}
}

View File

@ -0,0 +1,421 @@
{
"title": "Vue H5 モバイルテンプレート",
"tabbar": {
"home": "ホーム",
"shop": "ショップ",
"examples": "サンプル",
"ai": "AI",
"member": "マイページ"
},
"routes": {
"home": "ホーム",
"shop": "セレクトショップ",
"cart": "カート",
"productDetail": "商品詳細",
"productAdmin": "商品管理",
"examples": "実装サンプル",
"query": "TanStack Query",
"request": "型安全なリクエスト",
"projects": "プロジェクト管理",
"workspace": "デリバリー管理",
"mobile": "モバイル機能",
"icons": "SVG アイコン",
"ui": "UI フレームワーク",
"member": "マイページ",
"aiChat": "AI チャット",
"login": "ログイン",
"offline": "オフライン"
},
"language": { "label": "言語", "zh": "中国語", "en": "英語", "ja": "日本語" },
"global": {
"back": "戻る",
"backHome": "ホームへ戻る",
"loading": "読み込み中…",
"retry": "再試行",
"online": "オンライン",
"mainNavigation": "メインナビゲーション",
"offlineBanner": "オフラインです。一部のキャッシュ済みコンテンツは利用できます。"
},
"home": {
"eyebrow": "VUE H5 TEMPLATE · V2",
"title": "実際のモバイルプロダクト向け Vue 3 スターター。",
"description": "型安全 API、ストリーミング AI、サーバー状態、選択式 UI フレームワークを実装済みで提供します。",
"browseShop": "ショップを見る",
"tryAi": "ストリーミング AI を試す",
"viewExamples": "実装例を見る",
"metricsLabel": "テンプレート機能",
"typeSafe": "型安全",
"optionalUi": "UI 選択肢",
"realStreaming": "リアルストリーム",
"architecture": "アーキテクチャ",
"stateBoundary": "明確な状態管理",
"clientState": "クライアント状態",
"clientStateDescription": "認証、テーマ、機能フラグなどページ間で共有する状態。",
"serverState": "サーバー状態",
"serverStateDescription": "キャッシュ、再試行、キャンセル、更新、無限スクロール。",
"featured": "注目サンプル",
"aiTitle": "プロバイダー非依存 AI チャット",
"aiDescription": "AsyncIterable でストリームを表現し、任意の信頼できる AI ゲートウェイに接続できます。",
"openAi": "AI チャットを開く",
"shopTitle": "実際に連携できるモバイルショップ",
"shopDescription": "商品一覧、詳細、検索、ページング、多言語管理を Gin と PostgreSQL の実データで動かします。",
"openShop": "ショップを開く"
},
"examples": {
"eyebrow": "本番パターン",
"title": "コンポーネント展示だけではありません",
"description": "各サンプルは実際の設計境界、型、テストに対応しています。",
"aiTitle": "AI チャットとストリーミング",
"aiDescription": "SSE、中止、Markdown、再生成",
"queryTitle": "TanStack Query",
"queryDescription": "クエリ、更新、無限クエリ",
"requestTitle": "型安全なリクエスト",
"requestDescription": "400〜500、業務エラー、タイムアウト、リクエスト ID",
"workspaceTitle": "Gin デリバリー管理",
"workspaceDescription": "詳細、作成、編集、削除、状態フィルター",
"mobileTitle": "モバイルブラウザ機能",
"mobileDescription": "ネットワーク、クリップボード、Web Share、セーフエリア",
"offlineTitle": "PWA とオフライン",
"offlineDescription": "アプリシェル、オフラインフォールバック、API キャッシュ境界",
"iconsTitle": "SVG スプライト",
"iconsDescription": "assets/icons と SvgIcon の自動読み込み",
"uiTitle": "UI フレームワーク戦略",
"uiDescription": "Vant / NutUI / Varlet を本番用に一つ選択"
},
"shop": {
"eyebrow": "モバイルコマース実装例",
"title": "毎日の暮らしに、良いものを",
"description": "商品一覧、検索、絞り込み、ページング、詳細、管理機能を Gin と PostgreSQL に接続した実践的なサンプルです。",
"searchLabel": "商品を検索",
"searchPlaceholder": "商品、ブランド、カテゴリを検索",
"search": "検索",
"sortLabel": "並び順",
"catalog": "商品一覧",
"productCount": "全 {count} 商品",
"manage": "商品を管理",
"featured": "おすすめ",
"sales": "販売数 {count}",
"stock": "在庫 {count}",
"loadFailed": "商品を読み込めませんでした",
"empty": "商品が見つかりません",
"emptyDescription": "別のキーワードやカテゴリをお試しください。",
"loadingMore": "読み込み中…",
"loadMore": "さらに読み込む",
"pullRefresh": "引いて更新",
"releaseRefresh": "離して更新",
"refreshing": "更新中…",
"addToCartLabel": "{name}をカートに追加",
"addedToCart": "カートに追加しました · 全 {count} 点",
"categories": {
"all": "すべて",
"digital": "デジタル",
"lifestyle": "ライフスタイル",
"home": "ホーム",
"outdoor": "アウトドア"
},
"sort": {
"featured": "おすすめ順",
"sales": "売れ筋順",
"priceAsc": "価格が安い順",
"priceDesc": "価格が高い順"
},
"status": {
"draft": "下書き",
"on_sale": "販売中",
"sold_out": "売り切れ"
},
"detail": {
"quality": "品質保証",
"shipping": "迅速発送",
"returns": "7日間返品対応",
"about": "商品について",
"productDetails": "商品詳細",
"addCart": "カートに追加",
"buyNow": "今すぐ購入",
"decreaseQuantity": "数量を減らす",
"increaseQuantity": "数量を増やす",
"cartAdded": "{count} 点をカートに追加しました",
"checkoutDemo": "{count} 点を選択しました。実案件では決済フローを接続できます"
},
"editor": {
"editEyebrow": "商品を編集",
"createEyebrow": "商品を追加",
"editTitle": "商品情報を更新",
"createTitle": "新しい商品を作成",
"cancel": "キャンセル",
"brand": "ブランド",
"category": "カテゴリ",
"status": "状態",
"contentLanguage": "コンテンツ言語",
"name": "商品名",
"subtitle": "商品の特徴",
"description": "商品説明",
"coverUrl": "カバー画像 URL",
"price": "販売価格",
"originalPrice": "通常価格",
"stock": "在庫",
"sales": "販売数",
"rating": "評価",
"featured": "おすすめ商品にする",
"translationRequired": "中国語、英語、日本語の商品名、特徴、説明をすべて入力してください。",
"saving": "保存中…",
"save": "商品を保存"
},
"admin": {
"eyebrow": "ショップ運営",
"title": "商品管理",
"description": "多言語商品を作成し、価格、在庫、販売状態を管理します。データは PostgreSQL に保存されます。",
"create": "商品を追加",
"total": "商品数",
"onSale": "販売中",
"lowStock": "在庫僅少",
"searchPlaceholder": "SKU、商品名、ブランドを検索",
"allStatus": "すべての状態",
"edit": "編集",
"delete": "削除",
"empty": "条件に一致する商品がありません。",
"editorLabel": "商品エディター",
"operationFailed": "商品操作に失敗しました",
"deleteConfirm": "「{name}」を削除しますか?"
}
},
"cart": {
"eyebrow": "モバイルカート",
"title": "ショッピングカート",
"shortTitle": "カート",
"open": "カートを開く",
"description": "{count} 点の商品があります。数量を調整して購入へ進めます。",
"items": "カートの商品",
"selectItem": "{name}を選択",
"empty": "カートは空です",
"emptyDescription": "ショップで気になる商品を追加してみましょう。",
"browse": "買い物を続ける",
"remove": "削除",
"selectAll": "すべて選択",
"total": "合計",
"checkout": "購入へ({count}",
"checkoutDemo": "{count} 点を選択しました。このテンプレートでは実決済を行いません"
},
"request": {
"eyebrow": "型安全クライアント",
"title": "エラーを統一し、表示方法は統一しない",
"description": "リクエスト層は Token、リクエスト ID、レスポンス展開、エラー正規化を担当し、表示はページが決定します。",
"errorMatrix": "エラーマトリクス",
"errorTitle": "一般的なリクエスト失敗",
"trigger": "エラーを発生",
"redirectNote": "401 は実際の認証フローと同様にセッションを削除してログインへ移動します。その他は正規化結果をこのページに表示します。",
"scenarios": {
"badRequest": "不正なリクエスト",
"badRequestDescription": "HTTP 400。形式不正や無効なパラメータを表します。",
"unauthorized": "セッション期限切れ",
"unauthorizedDescription": "HTTP 401。全体ログアウトと安全なリダイレクトを実行します。",
"forbidden": "権限不足",
"forbiddenDescription": "HTTP 403。ログイン済みでも操作権限がありません。",
"notFound": "リソースなし",
"notFoundDescription": "HTTP 404。削除済みリソースや誤った URL を表します。",
"conflict": "バージョン競合",
"conflictDescription": "HTTP 409。同時更新や一意制約の競合に使用します。",
"validation": "業務検証エラー",
"validationDescription": "HTTP 200 + 業務コード 422 を interceptor が business error に変換します。",
"serverError": "サーバーエラー",
"serverErrorDescription": "HTTP 500。ページ側で再試行と診断情報を保持します。",
"timeout": "タイムアウト",
"timeoutDescription": "短いタイムアウトでエラー正規化を確認します。"
},
"unknownError": "不明なエラー"
},
"workspace": {
"eyebrow": "実ビジネス連携",
"title": "デリバリープロジェクト管理",
"description": "同じ型付きクライアントで Vite Mock または Gin + PostgreSQL を利用し、詳細、作成、編集、削除を実行します。",
"create": "新規プロジェクト",
"createTitle": "デリバリープロジェクトを作成",
"filter": "状態フィルター",
"allStatus": "すべての状態",
"refresh": "データ更新",
"loading": "プロジェクトを読み込み中…",
"empty": "現在の条件に一致するプロジェクトはありません。",
"noDescription": "説明なし",
"name": "プロジェクト名",
"namePlaceholder": "例:モバイルショップ公開",
"projectDescription": "プロジェクト説明",
"descriptionPlaceholder": "目標、範囲、完了条件を入力",
"projectStatus": "プロジェクト状態",
"view": "詳細",
"edit": "編集",
"delete": "削除",
"cancel": "キャンセル",
"save": "保存",
"saving": "保存中…",
"close": "閉じる",
"createdAt": "作成日時",
"updatedAt": "{date} 更新",
"unknownError": "プロジェクト操作に失敗しました",
"deleteConfirm": "「{name}」を削除しますか?",
"mode": { "create": "作成", "edit": "編集", "view": "プロジェクト詳細" },
"status": {
"active": "進行中",
"paused": "一時停止",
"archived": "アーカイブ済み"
}
},
"mobile": {
"eyebrow": "MOBILE WEB API",
"title": "ブラウザ機能とフォールバック",
"description": "標準ブラウザ機能のみを使用し、非対応時は明確に代替表示します。",
"network": "ネットワーク状態",
"networkDescription": "オンライン・オフラインイベントを監視し、サーバーキャッシュとは分離します。",
"offline": "オフライン",
"clipboard": "クリップボード",
"clipboardDescription": "Clipboard API でテキストをコピーします。",
"copy": "サンプルをコピー",
"copied": "サンプルをコピーしました",
"share": "システム共有",
"shareDescription": "Web Share 対応時のみ利用し、非対応時は無効にします。",
"shareAction": "このページを共有",
"shared": "システム共有を開きました",
"safeArea": "セーフエリア",
"safeAreaDescription": "下部ナビとフローティング操作はノッチやホームインジケーターを避けます。",
"adapted": "対応済み"
},
"icons": {
"eyebrow": "プロダクトアイコン",
"title": "型付き SVG スプライト",
"description": "src/assets/icons から自動読み込みし、UI フレームワークのアイコンとは分離します。"
},
"query": {
"eyebrow": "サーバー状態",
"description": "このデータは Pinia に保存しません。キャッシュ、再試行、更新、ページングは Query Client が管理します。",
"queryMutation": "クエリと更新",
"checklist": "リリースチェックリスト",
"refresh": "更新",
"loadingTasks": "タスクを読み込み中…",
"done": "完了",
"todo": "未完了",
"infinite": "無限クエリ",
"feed": "エンジニアリングフィード",
"loadingMore": "読み込み中…",
"loadMore": "さらに読み込む",
"end": "最後まで読み込みました",
"tasks": {
"1": "モバイル決済フローを確認",
"2": "生成 API 型を接続",
"3": "オフライン体験をテスト"
},
"feedItems": {
"performanceTitle": "モバイル性能予算",
"streamingTitle": "ストリーミング UI パターン",
"architectureTitle": "型安全なリクエスト層",
"summary": "本番モバイル H5 チーム向け実践ノート · #{index}",
"performance": "パフォーマンス",
"ai": "AI",
"architecture": "アーキテクチャ"
}
},
"member": {
"guestTitle": "テンプレートワークスペースを保存",
"guestDescription": "ログインすると Token 注入、401 失効、ユーザークエリを確認できます。実アカウントは不要です。",
"login": "ログインを試す",
"signedIn": "ログイン済み",
"loading": "読み込み中…",
"plan": "{role} · {plan} プラン",
"session": "セッションストレージ",
"sessionDescription": "再読み込み後も保持し、タブを閉じると削除",
"serverProfile": "サーバープロフィール",
"serverProfileDescription": "TanStack Query が 30 秒キャッシュ",
"logout": "ログアウト"
},
"login": {
"back": "ホームへ戻る",
"eyebrow": "おかえりなさい",
"title": "H5 Studio にログイン",
"description": "開発用 Mock は空でないユーザー名とパスワードを受け付けます。",
"realDescription": "実バックエンドでは demo / demo1234 を使用します。ログイン Token は Gin API と FastAPI AI の両方で利用できます。",
"username": "ユーザー名",
"usernamePlaceholder": "例demo-user",
"password": "パスワード",
"passwordPlaceholder": "任意のパスワードを入力",
"submitting": "ログイン中…",
"submit": "ログイン",
"failed": "ログインに失敗しました。再度お試しください。"
},
"projects": {
"eyebrow": "実バックエンド連携",
"title": "デリバリープロジェク管理",
"description": "Gin ビジネスサービスに直接接続します。ログインが JWT を発行し、TanStack Query がサーバー状態を管理します。",
"createTitle": "プロジェクトを作成",
"name": "プロジェクト名",
"namePlaceholder": "例:モバイルリリース",
"projectDescription": "説明",
"descriptionPlaceholder": "今回の目標を入力",
"creating": "作成中…",
"create": "作成する",
"serverState": "サーバー状態",
"listTitle": "マイプロジェクト",
"refresh": "更新",
"loading": "プロジェクトを読み込み中…",
"empty": "プロジェクトがありません。最初の一件を作成しましょう。",
"noDescription": "説明なし",
"nextStatus": "状態を変更",
"delete": "削除",
"unknownError": "プロジェクト操作に失敗しました",
"status": {
"active": "進行中",
"paused": "一時停止",
"archived": "アーカイブ済み"
}
},
"ai": {
"open": "AI アシスタント",
"assistant": "H5 アシスタント",
"status": "Mock ストリーム · プロバイダー非依存",
"back": "戻る",
"newChat": "新しいチャット",
"emptyTitle": "今日は何を一緒に解決しますか?",
"emptyDescription": "一括タイマーではなく、中止可能な実際の SSE ストリームです。",
"stopped": "生成を停止しました",
"failed": "生成失敗:{message}",
"copied": "コピー済み",
"copy": "コピー",
"regenerate": "再生成",
"scrollBottom": "一番下へ移動",
"disclaimer": "AI は誤ることがあります。重要な情報は確認してください。",
"inputLabel": "AI へのメッセージ",
"placeholder": "メッセージを入力…",
"stop": "生成を停止",
"send": "送信",
"prompts": [
"Vue Query と Pinia の役割分担は?",
"このテンプレートのストリーミング設計を説明して",
"Vue 3 composable の例を書いて"
]
},
"ui": {
"eyebrow": "ビルド時選択",
"title": "{framework} アダプター",
"description": "VITE_UI_FRAMEWORK で Resolver とページエイリアスを一つ選択し、他の UI パッケージは本番グラフに含めません。",
"current": "現在の本番ビルドは {framework} のみ解決します。",
"buttons": "ボタンと状態",
"primary": "主要アクション",
"default": "サブアクション",
"danger": "危険な操作",
"settings": "設定リスト",
"notifications": "通知",
"notificationsDescription": "重要なサービス通知を受信",
"plan": "現在のプラン",
"pro": "プロ",
"progress": "タスク進捗",
"progressDescription": "アップロードとバックグラウンド処理のフィードバック",
"tag": "モバイル対応"
},
"offline": {
"title": "オフラインです",
"description": "PWA アプリシェルはキャッシュ済みですが、API リクエストは Service Worker に既定でキャッシュされません。",
"back": "ホームへ戻る"
},
"errorBoundary": {
"title": "ページで問題が発生しました",
"description": "エラーは境界で隔離されました。ページを再読み込みできます。",
"reload": "再読み込み"
}
}

View File

@ -1,24 +1,409 @@
{
"title": "VUE H5开发模板",
"title": "Vue H5 移动端模板",
"tabbar": {
"home": "首页",
"list": "列表",
"shop": "商城",
"examples": "示例",
"ai": "AI",
"member": "我的"
},
"routes": {
"home": "首页",
"shop": "精选商城",
"cart": "购物车",
"productDetail": "商品详情",
"productAdmin": "商品管理",
"examples": "工程示例",
"query": "TanStack Query",
"request": "类型安全请求",
"projects": "项目工作台",
"workspace": "交付项目管理",
"mobile": "移动端能力",
"icons": "SVG 图标",
"ui": "UI 框架",
"member": "我的",
"demo": "示例"
"aiChat": "AI 对话",
"login": "登录",
"offline": "离线"
},
"language": {
"en": "英文",
"zh": "中文"
"language": { "label": "语言", "zh": "中文", "en": "英语", "ja": "日语" },
"global": {
"back": "返回",
"backHome": "返回首页",
"loading": "加载中…",
"retry": "重试",
"online": "在线",
"mainNavigation": "主导航",
"offlineBanner": "网络已断开,部分缓存内容仍可使用"
},
"introduction": "一个快速开发vue3的移动端模板",
"home": {
"support": "支持"
"eyebrow": "VUE H5 TEMPLATE · V2",
"title": "为真实移动业务准备的 Vue 3 起点。",
"description": "类型安全 API、流式 AI、服务端状态与单选 UI 框架,都有可运行的实现。",
"browseShop": "浏览精选商城",
"tryAi": "体验流式 AI",
"viewExamples": "查看工程示例",
"metricsLabel": "模板能力概览",
"typeSafe": "类型安全",
"optionalUi": "可选 UI",
"realStreaming": "真实流式",
"architecture": "架构",
"stateBoundary": "清晰的状态边界",
"clientState": "客户端状态",
"clientStateDescription": "认证、主题、功能开关等跨页面客户端状态。",
"serverState": "服务端状态",
"serverStateDescription": "缓存、重试、请求取消、数据变更与无限滚动。",
"featured": "精选示例",
"aiTitle": "供应商无关的 AI 对话",
"aiDescription": "使用 AsyncIterable 建模流,可替换为任意可信 AI 网关。",
"openAi": "打开 AI 对话",
"shopTitle": "可联调的移动商城",
"shopDescription": "商品列表、详情、搜索、分页和三语后台 CRUD 由 Gin 与 PostgreSQL 提供真实数据。",
"openShop": "打开商城"
},
"list": {
"details": "列表详情"
"examples": {
"eyebrow": "生产实践",
"title": "不只是组件演示",
"description": "每个示例都对应真实架构边界、类型和测试。",
"aiTitle": "AI 对话与流式输出",
"aiDescription": "SSE、中止、Markdown、重新生成",
"queryTitle": "TanStack Query",
"queryDescription": "查询、数据变更、无限查询",
"requestTitle": "类型安全请求",
"requestDescription": "400500、业务错误、超时与请求 ID",
"workspaceTitle": "Gin 交付项目管理",
"workspaceDescription": "查看、新建、编辑、删除与状态筛选",
"mobileTitle": "移动端浏览器能力",
"mobileDescription": "网络状态、剪贴板、Web Share 与安全区",
"offlineTitle": "PWA 与离线页",
"offlineDescription": "应用壳缓存、离线回退与 API 缓存边界",
"iconsTitle": "SVG 雪碧图",
"iconsDescription": "自动加载 assets/icons 与 SvgIcon",
"uiTitle": "UI 框架策略",
"uiDescription": "Vant / NutUI / Varlet 生产环境单选"
},
"btn": {
"confirm": "确认",
"cancel": "取消"
"shop": {
"eyebrow": "移动商城示例",
"title": "把日常好物带回生活",
"description": "完整演示商品浏览、搜索筛选、分页、详情和后台管理,并连接真实 Gin 与 PostgreSQL 服务。",
"searchLabel": "搜索商品",
"searchPlaceholder": "搜索商品、品牌或分类",
"search": "搜索",
"sortLabel": "排序",
"catalog": "商品目录",
"productCount": "共 {count} 件商品",
"manage": "管理商品",
"featured": "精选",
"sales": "已售 {count}",
"stock": "库存 {count}",
"loadFailed": "商品加载失败",
"empty": "没有找到商品",
"emptyDescription": "试试其他关键词或分类。",
"loadingMore": "加载中…",
"loadMore": "加载更多",
"pullRefresh": "下拉刷新",
"releaseRefresh": "松开刷新",
"refreshing": "正在刷新…",
"addToCartLabel": "将{name}加入购物车",
"addedToCart": "已加入购物车,共 {count} 件",
"categories": {
"all": "全部",
"digital": "数码",
"lifestyle": "生活方式",
"home": "家居",
"outdoor": "户外"
},
"sort": {
"featured": "精选优先",
"sales": "销量优先",
"priceAsc": "价格从低到高",
"priceDesc": "价格从高到低"
},
"status": { "draft": "草稿", "on_sale": "在售", "sold_out": "售罄" },
"detail": {
"quality": "品质保障",
"shipping": "快速发货",
"returns": "七天退换",
"about": "商品介绍",
"productDetails": "商品详情",
"addCart": "加入购物车",
"buyNow": "立即购买",
"decreaseQuantity": "减少数量",
"increaseQuantity": "增加数量",
"cartAdded": "已将 {count} 件商品加入购物车",
"checkoutDemo": "已选择 {count} 件商品;支付流程可在业务项目中接入"
},
"editor": {
"editEyebrow": "编辑商品",
"createEyebrow": "新增商品",
"editTitle": "更新商品资料",
"createTitle": "创建新商品",
"cancel": "取消",
"brand": "品牌",
"category": "分类",
"status": "状态",
"contentLanguage": "内容语言",
"name": "商品名称",
"subtitle": "商品卖点",
"description": "商品描述",
"coverUrl": "封面图片地址",
"price": "销售价",
"originalPrice": "划线价",
"stock": "库存",
"sales": "销量",
"rating": "评分",
"featured": "设为精选商品",
"translationRequired": "请完整填写中文、英文和日文的名称、卖点与描述。",
"saving": "保存中…",
"save": "保存商品"
},
"admin": {
"eyebrow": "商城运营后台",
"title": "商品管理",
"description": "创建多语言商品、维护价格库存,并控制上架状态。数据持久化到 PostgreSQL。",
"create": "新增商品",
"total": "当前商品",
"onSale": "在售商品",
"lowStock": "低库存",
"searchPlaceholder": "搜索 SKU、名称或品牌",
"allStatus": "全部状态",
"edit": "编辑",
"delete": "删除",
"empty": "没有符合条件的商品。",
"editorLabel": "商品编辑器",
"operationFailed": "商品操作失败",
"deleteConfirm": "确定删除“{name}”吗?"
}
},
"cart": {
"eyebrow": "移动购物车",
"title": "购物车",
"shortTitle": "购物车",
"open": "打开购物车",
"description": "已选购 {count} 件商品,可调整数量后结算。",
"items": "购物车商品",
"selectItem": "选择{name}",
"empty": "购物车还是空的",
"emptyDescription": "去商城挑选一些真正想要的商品吧。",
"browse": "继续逛商城",
"remove": "移除",
"selectAll": "全选",
"total": "合计",
"checkout": "结算({count}",
"checkoutDemo": "已选择 {count} 件商品;模板不处理真实支付"
},
"request": {
"eyebrow": "类型安全客户端",
"title": "统一错误,不统一提示方式",
"description": "请求层只负责 Token、请求 ID、响应解包与错误归一化页面决定错误如何呈现。",
"errorMatrix": "错误矩阵",
"errorTitle": "常见请求失败场景",
"trigger": "触发错误",
"redirectNote": "401 会按真实鉴权流程清理会话并跳转登录页,其余错误会留在当前页面展示归一化结果。",
"scenarios": {
"badRequest": "请求参数错误",
"badRequestDescription": "HTTP 400适合无法解析或格式不正确的请求。",
"unauthorized": "登录已失效",
"unauthorizedDescription": "HTTP 401会触发全局退出与安全重定向。",
"forbidden": "权限不足",
"forbiddenDescription": "HTTP 403用户已登录但无权操作资源。",
"notFound": "资源不存在",
"notFoundDescription": "HTTP 404目标资源已删除或地址错误。",
"conflict": "数据版本冲突",
"conflictDescription": "HTTP 409常用于并发更新或唯一键冲突。",
"validation": "业务校验失败",
"validationDescription": "HTTP 200 + 业务码 422由响应拦截器转为 business error。",
"serverError": "服务端异常",
"serverErrorDescription": "HTTP 500页面保留重试与问题定位信息。",
"timeout": "请求超时",
"timeoutDescription": "使用短超时验证 timeout 类型归一化。"
},
"unknownError": "未知错误"
},
"workspace": {
"eyebrow": "真实业务联调",
"title": "交付项目管理",
"description": "同一套类型化接口可切换 Vite Mock 或 Gin + PostgreSQL完整演示查看、新建、编辑和删除。",
"create": "新建项目",
"createTitle": "创建交付项目",
"filter": "状态筛选",
"allStatus": "全部状态",
"refresh": "刷新数据",
"loading": "正在加载项目…",
"empty": "当前筛选条件下没有项目。",
"noDescription": "暂无项目说明",
"name": "项目名称",
"namePlaceholder": "例如:移动商城首发",
"projectDescription": "项目说明",
"descriptionPlaceholder": "写下目标、范围和交付标准",
"projectStatus": "项目状态",
"view": "查看",
"edit": "编辑",
"delete": "删除",
"cancel": "取消",
"save": "保存项目",
"saving": "保存中…",
"close": "关闭",
"createdAt": "创建时间",
"updatedAt": "更新于 {date}",
"unknownError": "项目操作失败",
"deleteConfirm": "确定删除“{name}”吗?",
"mode": { "create": "新建", "edit": "编辑", "view": "项目详情" },
"status": { "active": "进行中", "paused": "已暂停", "archived": "已归档" }
},
"mobile": {
"eyebrow": "移动 Web API",
"title": "浏览器能力与降级",
"description": "只调用标准浏览器能力,并在不支持时提供明确降级。",
"network": "网络状态",
"networkDescription": "监听在线与离线事件,不把网络状态放进服务端缓存。",
"offline": "离线",
"clipboard": "剪贴板",
"clipboardDescription": "通过 Clipboard API 复制文本。",
"copy": "复制示例文本",
"copied": "示例文本已复制",
"share": "系统分享",
"shareDescription": "支持时调用 Web Share不支持时禁用操作。",
"shareAction": "分享当前页",
"shared": "已打开系统分享",
"safeArea": "安全区",
"safeAreaDescription": "底部导航和浮动操作会避开设备刘海与 Home Indicator。",
"adapted": "已适配"
},
"icons": {
"eyebrow": "产品图标",
"title": "类型化 SVG 雪碧图",
"description": "图标由 src/assets/icons 自动加载,不与 UI 框架图标系统混用。"
},
"query": {
"eyebrow": "服务端状态",
"description": "Pinia 不保存这些数据。缓存、重试、数据变更与分页由 Query Client 管理。",
"queryMutation": "查询与数据变更",
"checklist": "发布检查清单",
"refresh": "刷新",
"loadingTasks": "正在加载任务…",
"done": "已完成",
"todo": "待处理",
"infinite": "无限查询",
"feed": "工程动态",
"loadingMore": "加载中…",
"loadMore": "加载更多",
"end": "已经到底了",
"tasks": {
"1": "检查移动结账流程",
"2": "接入自动生成的 API 类型",
"3": "测试离线体验"
},
"feedItems": {
"performanceTitle": "移动端性能预算",
"streamingTitle": "流式界面模式",
"architectureTitle": "类型安全请求层",
"summary": "面向生产移动 H5 团队的实践记录 · #{index}",
"performance": "性能",
"ai": "AI",
"architecture": "架构"
}
},
"member": {
"guestTitle": "保存你的模板工作区",
"guestDescription": "登录后可演示 Token 注入、401 失效和用户查询Mock 接口无需真实账号。",
"login": "登录体验",
"signedIn": "已登录",
"loading": "加载中…",
"plan": "{role} · {plan} 方案",
"session": "会话存储",
"sessionDescription": "刷新保留,关闭标签页清除",
"serverProfile": "服务端资料",
"serverProfileDescription": "由 TanStack Query 缓存 30 秒",
"logout": "退出登录"
},
"login": {
"back": "返回首页",
"eyebrow": "欢迎回来",
"title": "登录 H5 Studio",
"description": "开发环境 Mock 接受任意非空用户名与密码。",
"realDescription": "真实后端请使用演示账号 demo / demo1234登录 Token 同时用于 Gin API 与 FastAPI AI。",
"username": "用户名",
"usernamePlaceholder": "例如demo-user",
"password": "密码",
"passwordPlaceholder": "输入任意非空密码",
"submitting": "登录中…",
"submit": "登录",
"failed": "登录失败,请稍后重试"
},
"projects": {
"eyebrow": "真实业务联调",
"title": "交付项目管理",
"description": "这个页面直接连接 Gin 业务服务JWT 由登录接口签发,远程状态由 TanStack Query 管理。",
"createTitle": "创建项目",
"name": "项目名称",
"namePlaceholder": "例如:移动端发布",
"projectDescription": "项目说明",
"descriptionPlaceholder": "写下本次交付目标",
"creating": "创建中…",
"create": "创建项目",
"serverState": "服务端状态",
"listTitle": "我的项目",
"refresh": "刷新",
"loading": "正在加载项目…",
"empty": "还没有项目,先创建一个吧。",
"noDescription": "暂无说明",
"nextStatus": "切换状态",
"delete": "删除",
"unknownError": "项目操作失败",
"status": { "active": "进行中", "paused": "已暂停", "archived": "已归档" }
},
"ai": {
"open": "AI 助手",
"assistant": "H5 助手",
"status": "Mock 流 · 供应商无关",
"back": "返回",
"newChat": "新对话",
"emptyTitle": "今天想一起解决什么?",
"emptyDescription": "这是可中止的真实 SSE 流,不是一次性定时返回。",
"stopped": "已停止生成",
"failed": "生成失败:{message}",
"copied": "已复制",
"copy": "复制",
"regenerate": "重新生成",
"scrollBottom": "滚动到底部",
"disclaimer": "AI 可能会犯错,请核对重要信息。",
"inputLabel": "发送给 AI 的消息",
"placeholder": "输入消息…",
"stop": "停止生成",
"send": "发送消息",
"prompts": [
"Vue Query 和 Pinia 应该如何分工?",
"解释这个模板的流式架构",
"写一个 Vue 3 组合式函数示例"
]
},
"ui": {
"eyebrow": "构建期单选",
"title": "{framework} 适配器",
"description": "通过 VITE_UI_FRAMEWORK 在构建时选择唯一 Resolver 和页面别名,其他 UI 包不会进入生产依赖图。",
"current": "当前生产构建只解析 {framework}。",
"buttons": "按钮与状态",
"primary": "主要操作",
"default": "次要操作",
"danger": "危险操作",
"settings": "设置列表",
"notifications": "消息通知",
"notificationsDescription": "接收重要服务提醒",
"plan": "当前方案",
"pro": "专业版",
"progress": "任务进度",
"progressDescription": "上传与异步任务反馈",
"tag": "移动端友好"
},
"offline": {
"title": "当前处于离线状态",
"description": "PWA 应用外壳已缓存API 请求不会被 Service Worker 默认缓存。",
"back": "返回首页"
},
"errorBoundary": {
"title": "页面出现了问题",
"description": "错误已被边界隔离,你可以重新加载页面。",
"reload": "重新加载"
}
}
}

View File

@ -3,23 +3,38 @@ import App from './App.vue';
import { setupI18n } from '@/locales';
import router from '@/router';
import store from '@/store';
import './assets/font/iconfont.css';
import { useUserStore } from '@/store/modules/user';
import { configureApiClient } from '@/api/client';
import { configureStreamingClient } from '@/services/ai/fetchStreamProvider';
import { setupVueQuery } from '@/plugins/query';
import 'virtual:svg-icons-register';
import '@/styles/index.scss';
import '@nutui/nutui/dist/packages/toast/style/css';
import '@nutui/nutui/dist/packages/notify/style/css';
import '@nutui/nutui/dist/packages/dialog/style/css';
import '@nutui/nutui/dist/packages/imagepreview/style/css';
async function bootstrap() {
const app = createApp(App);
const app = createApp(App);
app.use(store);
const userStore = useUserStore(store);
// 路由
app.use(router);
const clientRuntime = {
getAccessToken: () => userStore.token || undefined,
onUnauthorized: () => {
userStore.logout();
if (router.currentRoute.value.name !== 'login') {
return router.replace({
name: 'login',
query: { redirect: router.currentRoute.value.fullPath },
});
}
},
};
configureApiClient(clientRuntime);
configureStreamingClient(clientRuntime);
// 国际化
await setupI18n(app);
setupVueQuery(app);
await setupI18n(app);
app.use(router);
app.mount('#app');
}
// 状态管理
app.use(store);
app.mount('#app');
void bootstrap();

30
src/plugins/query.ts Normal file
View File

@ -0,0 +1,30 @@
import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query';
import type { VueQueryPluginOptions } from '@tanstack/vue-query';
import type { App } from 'vue';
import { isApiError } from '@/types/api/common';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
gcTime: 5 * 60_000,
refetchOnWindowFocus: false,
retry: (failureCount, error) => {
if (
isApiError(error) &&
error.status &&
error.status >= 400 &&
error.status < 500
)
return false;
return failureCount < 2;
},
},
mutations: { retry: false },
},
});
export function setupVueQuery(app: App) {
const options: VueQueryPluginOptions = { queryClient };
app.use(VueQueryPlugin, options);
}

View File

@ -1,14 +1,36 @@
import { watch } from 'vue';
import { createRouter, createWebHistory } from 'vue-router';
import type { Router } from 'vue-router';
import routes from './routes';
import { i18n } from '@/locales';
import store from '@/store';
import { useUserStore } from '@/store/modules/user';
const router: Router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: routes,
routes,
scrollBehavior: () => ({ top: 0 }),
});
router.beforeEach(async (_to, _from, next) => {
next();
function updateDocumentTitle(titleKey?: unknown) {
const title = titleKey
? String(i18n.global.t(String(titleKey)))
: String(i18n.global.t('common.title'));
document.title = `${title} · Vue H5`;
}
router.beforeEach((to) => {
const userStore = useUserStore(store);
if (to.meta.requiresAuth && !userStore.token)
return { name: 'login', query: { redirect: to.fullPath } };
if (to.meta.guestOnly && userStore.token) return { name: 'member' };
updateDocumentTitle(to.meta.title);
return true;
});
watch(i18n.global.locale, () =>
updateDocumentTitle(router.currentRoute.value.meta.title),
);
export default router;

16
src/router/meta.d.ts vendored Normal file
View File

@ -0,0 +1,16 @@
import 'vue-router';
export {};
declare module 'vue-router' {
interface RouteMeta {
title?: string;
section?: 'home' | 'shop' | 'examples' | 'ai' | 'member';
keepAlive?: boolean;
requiresAuth?: boolean;
guestOnly?: boolean;
hideHeader?: boolean;
hideAiEntry?: boolean;
fullscreen?: boolean;
}
}

View File

@ -1,69 +1,149 @@
import type { RouteRecordRaw } from 'vue-router';
const useMock = String(import.meta.env.VITE_USE_MOCK) === 'true';
export const routes: RouteRecordRaw[] = [
{
path: '/',
redirect: '/home',
component: () => import('@/layout/index.vue'),
redirect: { name: 'home' },
children: [
{
path: 'home',
name: 'home',
component: () => import('@/views/home/index.vue'),
meta: {
title: 'common.tabbar.home',
title: 'common.routes.home',
section: 'home',
keepAlive: true,
hideHeader: true,
},
},
{
path: 'examples',
name: 'examples',
component: () => import('@/views/examples/index.vue'),
meta: {
title: 'common.routes.examples',
section: 'examples',
keepAlive: true,
},
},
{
path: 'list',
component: () => import('@/views/list/index.vue'),
path: 'shop',
name: 'shop',
component: () => import('@/views/shop/index.vue'),
meta: {
title: 'common.tabbar.list',
title: 'common.routes.shop',
section: 'shop',
keepAlive: true,
hideHeader: true,
},
},
{
path: 'shop/products/:id',
name: 'product-detail',
component: () => import('@/views/shop/detail.vue'),
meta: {
title: 'common.routes.productDetail',
section: 'shop',
hideHeader: true,
hideAiEntry: true,
},
},
{
path: 'shop/cart',
name: 'cart',
component: () => import('@/views/shop/cart.vue'),
meta: {
title: 'common.routes.cart',
section: 'shop',
hideAiEntry: true,
},
},
{
path: 'shop/admin/products',
name: 'product-admin',
component: () => import('@/views/shop/admin.vue'),
meta: {
title: 'common.routes.productAdmin',
section: 'shop',
requiresAuth: true,
},
},
{
path: 'examples/query',
name: 'query-example',
component: () => import('@/views/examples/query.vue'),
meta: { title: 'common.routes.query', section: 'examples' },
},
{
path: 'examples/request',
name: 'request-example',
component: () => import('@/views/examples/request.vue'),
meta: { title: 'common.routes.request', section: 'examples' },
},
{
path: 'examples/workspace',
name: 'workspace-example',
component: () => import('@/views/examples/projects.vue'),
meta: {
title: 'common.routes.workspace',
section: 'examples',
requiresAuth: true,
},
},
{ path: 'examples/projects', redirect: { name: 'workspace-example' } },
{
path: 'examples/mobile',
name: 'mobile-example',
component: () => import('@/views/examples/mobile.vue'),
meta: { title: 'common.routes.mobile', section: 'examples' },
},
{
path: 'examples/icons',
name: 'icons-example',
component: () => import('@/views/examples/icons.vue'),
meta: { title: 'common.routes.icons', section: 'examples' },
},
{
path: 'ui-framework',
name: 'ui-framework',
component: () => import('@/views/ui/index.vue'),
meta: { title: 'common.routes.ui', section: 'examples' },
},
{
path: 'member',
name: 'member',
component: () => import('@/views/member/index.vue'),
meta: {
title: 'common.tabbar.member',
keepAlive: true,
},
},
{
path: 'demo',
component: () => import('@/views/demo/index.vue'),
meta: {
title: 'common.tabbar.demo',
keepAlive: true,
},
},
{
name: 'listDetails',
path: '/details',
component: () => import('@/views/list/details/index.vue'),
meta: {
title: 'common.list.details',
border: false,
},
meta: { title: 'common.routes.member', section: 'member' },
},
],
},
{
name: 'login',
path: '/login',
component: () => import('@/views/login/index.vue'),
path: '/ai/chat',
name: 'ai-chat',
component: () => import('@/views/ai/chat.vue'),
meta: {
title: '',
keepAlive: true,
title: 'common.routes.aiChat',
section: 'ai',
fullscreen: true,
requiresAuth: !useMock,
},
},
// 匹配不到重定向会主页
{
// 找不到路由重定向到首页
path: '/:pathMatch(.*)',
redirect: '/home',
path: '/login',
name: 'login',
component: () => import('@/views/login/index.vue'),
meta: { title: 'common.routes.login', guestOnly: true, fullscreen: true },
},
{
path: '/offline',
name: 'offline',
component: () => import('@/views/system/offline.vue'),
meta: { title: 'common.routes.offline', fullscreen: true },
},
{ path: '/:pathMatch(.*)*', redirect: { name: 'home' } },
];
export default routes;

Some files were not shown because too many files have changed in this diff Show More