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
@ -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
@ -1,4 +1,17 @@
|
||||
VITE_TOKEN_KEY=tokenKey
|
||||
|
||||
VITE_URL_PREFIX=/api
|
||||
# 所有环境共享的基础配置(可被 .env.[mode] 覆盖)
|
||||
# 可用变量说明:
|
||||
# VITE_API_BASE_URL - 接口请求基础路径(src/utils/request 读取)
|
||||
# VITE_API_TARGET - 开发环境真实后端地址;配置后 /api 请求经 vite proxy 转发(默认不配置,走 mock)
|
||||
# VITE_USE_MOCK - 是否开启数据 mock
|
||||
# VITE_USE_ERUDA - 是否开启 eruda 调试工具
|
||||
# VITE_USE_COMPRESS - 是否开启 gzip 压缩
|
||||
# VITE_USE_REPORT - 是否生成打包体积报告
|
||||
# VITE_USE_HTTPS - 是否开启本地 https
|
||||
# VITE_PWA_ENABLED - 是否开启 PWA
|
||||
# VITE_IMAGE_OPTIMIZE - 是否在生产构建优化图片
|
||||
# VITE_UI_FRAMEWORK - vant / nutui / varlet,production 单选
|
||||
|
||||
VITE_API_BASE_URL=/api
|
||||
VITE_AI_API_BASE_URL=/api/ai
|
||||
VITE_REQUEST_ID_ENABLED=true
|
||||
VITE_UI_FRAMEWORK=vant
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
# 是否开启数据mock
|
||||
VITE_USE_MOCK=true
|
||||
|
||||
# Token Key
|
||||
VITE_TOKEN_KEY=Authorization
|
||||
# 接入真实后端时关闭 Mock;AI 路径优先代理到 FastAPI,其他 /api 请求代理到 Gin
|
||||
# VITE_AI_API_TARGET=http://localhost:8001
|
||||
# VITE_API_TARGET=http://localhost:8002
|
||||
|
||||
# 是否开启调试工具
|
||||
VITE_USE_ERUDA=true
|
||||
VITE_USE_ERUDA=false
|
||||
|
||||
# 是否开启压缩
|
||||
VITE_USE_COMPRESS=false
|
||||
@ -16,5 +17,8 @@ VITE_USE_REPORT=false
|
||||
# 是否开启https
|
||||
VITE_USE_HTTPS=false
|
||||
|
||||
# 是否开启PWA
|
||||
VITE_USE_PWA=false
|
||||
# 开发环境不注册 Service Worker,避免缓存干扰调试
|
||||
VITE_PWA_ENABLED=false
|
||||
|
||||
# 图片优化仅在 production build 执行
|
||||
VITE_IMAGE_OPTIMIZE=false
|
||||
|
||||
25
.env.example
Normal 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
@ -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
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
@ -0,0 +1,35 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
# 不要在这里写 version:pnpm/action-setup 会读 package.json 的 packageManager
|
||||
# 字段,两处同时声明会以 ERR_PNPM_BAD_PM_VERSION 失败。
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Verify lint, types, unit tests and build
|
||||
run: pnpm check
|
||||
|
||||
- name: Install Playwright Chromium
|
||||
run: pnpm exec playwright install --with-deps chromium
|
||||
|
||||
- name: End-to-end tests
|
||||
run: pnpm test:e2e
|
||||
18
.github/workflows/release.yml
vendored
Normal 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
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -1,10 +0,0 @@
|
||||
/dist/*
|
||||
.local
|
||||
.output.js
|
||||
/node_modules/**
|
||||
.npmrc
|
||||
|
||||
**/*.svg
|
||||
**/*.sh
|
||||
|
||||
/public/*
|
||||
3
.release-please-manifest.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
".": "2.0.0"
|
||||
}
|
||||
4
.vscode/extensions.json
vendored
@ -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
@ -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 6–8px radius, cards/dialogs at 8–12px, and routine transitions between 120–200ms. Shadows are reserved for floating navigation, popovers and dialogs.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
pnpm lint
|
||||
pnpm typecheck
|
||||
pnpm test
|
||||
pnpm test:coverage
|
||||
pnpm test:e2e
|
||||
pnpm build
|
||||
pnpm check
|
||||
pnpm api:generate
|
||||
```
|
||||
|
||||
Run the smallest relevant check while iterating, then `pnpm check`. Run E2E when a route, auth, request mock or AI stream changes.
|
||||
|
||||
## Protected Decisions
|
||||
|
||||
- Do not restore implicit file routing without a measured benefit and a migration plan.
|
||||
- Do not cache authenticated API requests in the Service Worker by default.
|
||||
- Do not globally load all three UI frameworks.
|
||||
- Do not render AI or Markdown HTML without sanitization.
|
||||
- Do not move server state into Pinia or couple the request client to Router/UI feedback.
|
||||
- Preserve pnpm as the only package manager and `pnpm-lock.yaml` as the only lockfile.
|
||||
|
||||
## PR Checklist
|
||||
|
||||
- [ ] Change is placed in the correct architectural layer.
|
||||
- [ ] Public types and OpenAPI output are updated.
|
||||
- [ ] Loading, empty, error, offline and aborted states are considered.
|
||||
- [ ] Mobile keyboard, safe-area, touch target and accessibility behavior are preserved.
|
||||
- [ ] Security review covers HTML, URLs, tokens, env values and production Mock leakage.
|
||||
- [ ] Unit/component tests cover core logic; critical flows have E2E coverage.
|
||||
- [ ] Chinese, English, and Japanese docs stay synchronized.
|
||||
- [ ] `pnpm check` passes; relevant Playwright tests pass.
|
||||
10
CHANGELOG.md
Normal 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
@ -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` 实现持久化
|
||||
- **viewport(vw)适配** — 基于 `cnjm-postcss-px-to-viewport`,自动处理 UI 库 375/750 设计稿差异
|
||||
- **Axios + useFetch 双请求方案** — 支持传统 Axios 和 `@vueuse/core` 的 `createFetch`
|
||||
- **vue-i18n 多语言** — 按需懒加载语言包
|
||||
- **文件路由** — 基于 `vite-plugin-pages` 自动生成路由
|
||||
- **丰富的 Vite 插件** — Mock、Eruda 调试、PWA、QRCode、图片压缩、gzip 压缩、打包分析等
|
||||
- **代码规范** — ESLint flat config + Prettier + Stylelint + Husky + lint-staged
|
||||
- **Docker 部署** — 内置 Dockerfile + Nginx 配置
|
||||
|
||||
## 环境要求
|
||||
|
||||
| 工具 | 版本 |
|
||||
| ------- | ---------- |
|
||||
| Node.js | >= 20.10.0 |
|
||||
| pnpm | >= 9.12.0 |
|
||||
- Vue 3.5、Vite 8 / Rolldown、TypeScript 5.9、Vue Router 4、Pinia 3
|
||||
- `@tanstack/vue-query` 管理 Server State,Pinia 只管理 Client State
|
||||
- Axios Type-safe Client:`ApiResponse<T>`、`ApiError`、Request ID、Token、401、Timeout 与 Network Error
|
||||
- `pnpm api:generate` 从本地 OpenAPI Schema 生成后端契约类型
|
||||
- AI Chat:POST SSE、`ReadableStream`、`AbortController`、Regenerate、Retry、Markdown、代码块与 DOMPurify
|
||||
- 真实商城:12+ 三语商品、搜索分类、下拉刷新、分页加载、详情、购物车与商品管理
|
||||
- 交付项目管理:查看、新建、编辑、删除和状态筛选,Vite Mock / Gin + PostgreSQL 共用契约
|
||||
- SVG Sprite:`src/assets/icons` 自动加载及类型化 `<SvgIcon />`
|
||||
- Vant / NutUI / Varlet 构建期单选,不把三套 UI 同时放入 production graph
|
||||
- 可选 PWA;Service Worker 只缓存 App Shell 和静态图片,不默认缓存 API
|
||||
- Sharp + SVGO 仅在 production build 按开关优化 png/jpeg/webp/svg
|
||||
- Vitest + Vue Test Utils + Playwright;当前核心逻辑 line coverage 81.31%
|
||||
- 克制的响应式 Design System:统一 typography、spacing、radius、color、control 和 motion tokens,移动端底栏在桌面自动切换为紧凑顶栏
|
||||
- 中文、English、日本語三语懒加载,正文、导航和页面标题响应式同步
|
||||
- ESLint、Prettier、Stylelint、Husky、lint-staged、Commitlint、GitHub Actions 与 Release Please
|
||||
|
||||
## 快速开始
|
||||
|
||||
要求 Node.js `>=22.12.0`、pnpm `>=9.12.0`。
|
||||
|
||||
```bash
|
||||
# 拉取项目
|
||||
git clone https://github.com/sunniejs/vue-h5-template.git
|
||||
|
||||
# 进入项目目录
|
||||
cd vue-h5-template
|
||||
|
||||
# 安装依赖
|
||||
pnpm install
|
||||
|
||||
# 启动项目
|
||||
cp .env.example .env.local
|
||||
pnpm dev
|
||||
|
||||
# 打包
|
||||
pnpm build
|
||||
|
||||
# 预览打包结果
|
||||
pnpm preview
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
开发环境默认启用 Mock,登录可以输入任意非空用户名和密码。底部第三个标签是工程示例;右下角 AI 悬浮入口打开 `/ai/chat`,Mock Server 会从 `POST /api/ai/chat` 逐 chunk 返回 SSE。
|
||||
|
||||
```
|
||||
├── build/ # Vite 构建相关配置
|
||||
│ ├── vite/plugins/ # Vite 插件配置(auto-import、component、mock、eruda 等)
|
||||
│ └── utils.ts # 构建工具函数
|
||||
├── mock/ # Mock 数据
|
||||
├── public/ # 静态资源
|
||||
├── src/
|
||||
│ ├── api/ # 接口管理
|
||||
│ ├── assets/ # 项目资源(字体、图片等)
|
||||
│ ├── layout/ # 布局组件
|
||||
│ ├── locales/ # 国际化语言包
|
||||
│ ├── router/ # 路由配置
|
||||
│ ├── store/ # Pinia 状态管理
|
||||
│ ├── styles/ # 全局样式 & SCSS 变量
|
||||
│ ├── utils/ # 工具函数(Axios 封装、useFetch 封装)
|
||||
│ ├── views/ # 页面组件
|
||||
│ ├── App.vue # 根组件
|
||||
│ └── main.ts # 入口文件
|
||||
├── types/ # TypeScript 类型声明
|
||||
├── .env.* # 多环境变量配置
|
||||
├── vite.config.mts # Vite 配置
|
||||
├── postcss.config.js # PostCSS 配置(viewport 适配)
|
||||
├── Dockerfile # Docker 部署配置
|
||||
└── nginx.conf # Nginx 配置
|
||||
可直接访问 `/shop`、`/shop/cart`、`/examples/request` 和 `/examples/workspace`。请求示例包含 400/401/403/404/409、业务 422、500 和 timeout;交付项目页在 Mock 与真实后端两种模式下都支持完整增删改查。
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
pnpm lint # ESLint + Stylelint
|
||||
pnpm typecheck # vue-tsc project references
|
||||
pnpm test # Vitest unit/component tests
|
||||
pnpm test:coverage # 60%+ core coverage gate
|
||||
pnpm test:e2e # Playwright 核心流程、响应式和 dark mode
|
||||
pnpm build # production build
|
||||
pnpm check # lint + typecheck + test + build
|
||||
pnpm api:generate # OpenAPI -> TypeScript
|
||||
```
|
||||
|
||||
## 集成的 Vite 插件
|
||||
`pnpm check` 默认不含 E2E,避免每次本地提交都启动浏览器;CI 在 `check` 之后单独运行 Playwright。
|
||||
|
||||
| 插件 | 说明 |
|
||||
| ------------------------------- | -------------------- |
|
||||
| `unplugin-auto-import` | 按需自动引入 API |
|
||||
| `unplugin-vue-components` | 按需自动引入组件 |
|
||||
| `vite-plugin-pages` | 文件系统路由 |
|
||||
| `vite-plugin-mock` | 本地 Mock 数据 |
|
||||
| `@zhaojjiang/vite-plugin-eruda` | 移动端调试工具 |
|
||||
| `vite-plugin-svg-icons` | SVG 图标 |
|
||||
| `vite-plugin-compression` | Gzip 压缩 |
|
||||
| `vite-plugin-imagemin` | 图片压缩 |
|
||||
| `vite-plugin-pwa` | PWA 支持 |
|
||||
| `vite-plugin-qrcode` | 开发时二维码 |
|
||||
| `vite-plugin-restart` | 配置文件修改自动重启 |
|
||||
| `vite-plugin-progress` | 构建进度条 |
|
||||
| `@vitejs/plugin-basic-ssl` | 本地 HTTPS |
|
||||
| `rollup-plugin-visualizer` | 打包分析 |
|
||||
## 目录边界
|
||||
|
||||
## 其他版本
|
||||
```text
|
||||
src/
|
||||
├── api/ # Axios client 与按业务域拆分的 API modules
|
||||
├── components/ # 可复用、无页面路由职责的 UI
|
||||
├── composables/ # Vue 生命周期/响应式编排
|
||||
├── layout/ # App Shell、导航、safe-area
|
||||
├── plugins/ # Vue Query 等 App 插件
|
||||
├── router/ # 显式路由表、meta 类型与守卫
|
||||
├── services/ # Provider/transport 等框架无关业务服务
|
||||
├── store/ # Pinia client state
|
||||
├── types/ # API、AI、Icon 领域类型
|
||||
├── utils/ # 无 Vue 生命周期的纯函数
|
||||
└── views/ # 懒加载页面
|
||||
```
|
||||
|
||||
- **vue-h5-template-lite**(纯 JS 版)— [点击查看](https://github.com/sunniejs/vue-h5-template/tree/vue-h5-template-lite)
|
||||
- **vue2-h5-template**(Vue 2 版)— [点击查看](https://github.com/sunniejs/vue-h5-template/tree/vue2-h5-template)
|
||||
- **monorepo 版**(Monorepo 架构)— [点击查看](https://github.com/fonghehe/vue-h5-template)
|
||||
完整规则见 [`AGENTS.md`](./AGENTS.md)。该文件也是 Codex、Claude Code、Cursor 和 Copilot 的首要工程上下文。
|
||||
|
||||
## 文档
|
||||
## 状态管理
|
||||
|
||||
详细的使用文档请查看 [在线文档](https://sunniejs.github.io/vue-h5-template/)
|
||||
- Pinia:登录会话、购物车、主题、Feature Flags 等客户端状态。
|
||||
- TanStack Query:远程数据、缓存、重试、取消、Mutation、Pagination 与 Infinite Query。
|
||||
- AI 对话:由 `useStreamingChat` 编排会话生命周期;Provider 传输协议位于 `services/ai`,不放进 Pinia 或 Query Cache。
|
||||
|
||||
如果对你有帮助,欢迎 Star 支持 ⭐
|
||||
## API 与 OpenAPI
|
||||
|
||||
## 关于我
|
||||
页面不直接调用 Axios,也不写 `catch (error: any)`。在 `src/api/modules` 增加函数,在页面/Query composable 中消费,并用 `isApiError` 收窄错误。
|
||||
|
||||
扫描添加下方的微信并备注加交流群,交流学习,及时获取代码最新动态。
|
||||
替换后端 Schema:
|
||||
|
||||
<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>;
|
||||
}
|
||||
```
|
||||
|
||||
[](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并经过 DOMPurify;Redirect 仅接受站内绝对路径;Mock 生产默认关闭;鉴权 Token 示例使用 `sessionStorage`。生产项目优先采用 SameSite + Secure + HttpOnly Cookie/BFF,并在网关配置 CSP、CSRF 和速率限制。
|
||||
|
||||
## 文档与发布
|
||||
|
||||
架构、后端联调、商城、请求、状态、AI Chat、UI、测试、部署和 v2 迁移文档维护在仓库 `docs` 分支,并提供中文、英文和日文同步内容。合并 Conventional Commits 后,Release Please 自动维护版本、CHANGELOG 与 GitHub Release。
|
||||
|
||||
本轮完整决策、性能数据、Breaking Changes 和后续路线见 [`V2_UPGRADE_REPORT.md`](./V2_UPGRADE_REPORT.md)。
|
||||
|
||||
## Roadmap
|
||||
|
||||
- P0 已实现:Auth 基线、路由权限、错误边界、网络状态、深色系统主题、懒路由、安全 Markdown、PWA/API 缓存隔离、依赖/提交/测试基线。
|
||||
- P1 推荐按业务引入:Gateway/JWKS 身份、自动导出 Gin OpenAPI、数据库备份/回滚、Feature Flags、Analytics/Error Reporting adapter、Upload、Virtual List、WebView Bridge、Performance Monitoring、显式主题切换。
|
||||
- P2 可选:QR/Camera/Web Share、Deep Link、SSR/SSG、CDN external、原生 App Bridge。它们依赖产品与部署环境,不应成为模板默认负担。
|
||||
|
||||
## License
|
||||
|
||||
|
||||
7
SECURITY.md
Normal 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.
|
||||
@ -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 = '';
|
||||
@ -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;
|
||||
}
|
||||
|
||||
90
build/vite/plugins/aiMock.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -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),
|
||||
});
|
||||
};
|
||||
|
||||
@ -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),
|
||||
});
|
||||
};
|
||||
|
||||
@ -10,7 +10,7 @@ export const ConfigCompressPlugin = () => {
|
||||
verbose: true, // 默认即可
|
||||
disable: false, //开启压缩(不禁用),默认即可
|
||||
deleteOriginFile: false, //删除源文件
|
||||
threshold: 10240, //压缩前最小文件大小
|
||||
threshold: 10_240, //压缩前最小文件大小
|
||||
algorithm: 'gzip', //压缩算法
|
||||
ext: '.gz', //文件类型
|
||||
});
|
||||
|
||||
14
build/vite/plugins/imageOptimizer.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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',
|
||||
});
|
||||
};
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@ -1,10 +0,0 @@
|
||||
/**
|
||||
* @name ConfigQrcodePlugin
|
||||
* @description 引入qrcode插件,用于在浏览器中显示当前页面的二维码
|
||||
*/
|
||||
|
||||
import { qrcode } from 'vite-plugin-qrcode';
|
||||
|
||||
export const ConfigQrcodePlugin = () => {
|
||||
return qrcode();
|
||||
};
|
||||
@ -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'],
|
||||
});
|
||||
};
|
||||
@ -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,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
@ -0,0 +1 @@
|
||||
export default { extends: ['@commitlint/config-conventional'] };
|
||||
160
e2e/app.spec.ts
Normal 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
@ -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();
|
||||
});
|
||||
@ -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/**']),
|
||||
);
|
||||
@ -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" />
|
||||
|
||||
758
mock/index.ts
@ -1,22 +1,754 @@
|
||||
import type { MockMethod, Recordable } from 'vite-plugin-mock';
|
||||
import type { MockMethod } from 'vite-plugin-mock';
|
||||
import type { Product, ProductInput } from '../src/api/modules/products';
|
||||
import type {
|
||||
Project,
|
||||
ProjectInput,
|
||||
ProjectUpdateInput,
|
||||
} from '../src/api/modules/projects';
|
||||
|
||||
interface Response {
|
||||
body: Recordable;
|
||||
query: Recordable;
|
||||
interface MockContext {
|
||||
body: Record<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[];
|
||||
|
||||
@ -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
@ -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
@ -0,0 +1,112 @@
|
||||
import { defineConfig } from 'oxfmt';
|
||||
|
||||
/**
|
||||
* oxfmt 配置文件,移植自 vue-h5-template monorepo(turborepo-demo)的 @vh5/oxfmt-config。
|
||||
* 详见:https://oxc.rs/docs/guide/usage/formatter/config-file-reference.html
|
||||
*/
|
||||
export default defineConfig({
|
||||
/**
|
||||
* 单行长度,适配 prettier 的 80
|
||||
* Default:100
|
||||
*/
|
||||
printWidth: 80,
|
||||
/**
|
||||
* 缩进宽度
|
||||
* Default:2
|
||||
*/
|
||||
tabWidth: 2,
|
||||
/**
|
||||
* Markdown、MDX、YAML 文件格式化包裹
|
||||
* type: always | never | preserve
|
||||
* Default: preserve
|
||||
*/
|
||||
proseWrap: 'never',
|
||||
/**
|
||||
* 结尾添加分号
|
||||
* Default:true
|
||||
*/
|
||||
semi: true,
|
||||
/**
|
||||
* 使用单引号
|
||||
* Default:false
|
||||
*/
|
||||
singleQuote: true,
|
||||
/**
|
||||
* 对象属性添加引号
|
||||
* Default:as-needed
|
||||
*/
|
||||
quoteProps: 'as-needed',
|
||||
/**
|
||||
* 将多行元素的 > 放在最后一行的末尾,而不是单独放在下一行
|
||||
* Default:false
|
||||
*/
|
||||
bracketSameLine: false,
|
||||
/**
|
||||
* 对象字面量的大括号间添加空格
|
||||
* Default:true
|
||||
*/
|
||||
bracketSpacing: true,
|
||||
/**
|
||||
* 箭头函数参数总是使用括号
|
||||
* type: always | avoid
|
||||
* Default:always
|
||||
*/
|
||||
arrowParens: 'always',
|
||||
/**
|
||||
* 配置 package.json 排序,但 oxfmt 不支持 pnpm-workspace
|
||||
* Default:true
|
||||
*/
|
||||
sortPackageJson: false,
|
||||
/**
|
||||
* 配置 import 排序
|
||||
* Default:false
|
||||
*/
|
||||
sortImports: false,
|
||||
/**
|
||||
* 多行结构中的后置逗号
|
||||
* Default:all
|
||||
*/
|
||||
trailingComma: 'all',
|
||||
/**
|
||||
* 行尾换行符
|
||||
* type: lf | crlf | cr
|
||||
* Default: lf
|
||||
*/
|
||||
endOfLine: 'lf',
|
||||
/**
|
||||
* 在文件最后插入一个换行
|
||||
* Default:true
|
||||
*/
|
||||
insertFinalNewline: true,
|
||||
/**
|
||||
* 控制格式化文件中的嵌入语言(如 CSS-in-JS 或 JS-in-Vue)
|
||||
* Default:auto
|
||||
*/
|
||||
embeddedLanguageFormatting: 'auto',
|
||||
/**
|
||||
* Vue/HTML/Angular/Handlebars 的空白敏感度(oxfmt 会格式化 <template>)
|
||||
* type: css | strict | ignore
|
||||
* Default:css
|
||||
*/
|
||||
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
@ -0,0 +1,435 @@
|
||||
import { defineConfig } from 'oxlint';
|
||||
|
||||
/**
|
||||
* oxlint 配置,移植自 vue-h5-template monorepo(turborepo-demo)的 @vh5/oxlint-config。
|
||||
* 单仓库环境无法直接引用 workspace 包,故将全套规则展开为独立配置。
|
||||
*
|
||||
* 规则来源:eslint 严格预设(correctness/suspicious)+ typescript strict 预设
|
||||
* + unicorn + import + vue + vitest + node 等插件。
|
||||
*/
|
||||
export default defineConfig({
|
||||
// —— plugins:oxlint 支持的插件,将默认开启的也显式配置 ——
|
||||
// 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 target),replaceAll 是 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
151
package.json
@ -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
@ -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
7
pnpm-workspace.yaml
Normal file
@ -0,0 +1,7 @@
|
||||
packages:
|
||||
- '.'
|
||||
|
||||
allowBuilds:
|
||||
'@parcel/watcher': true
|
||||
core-js: true
|
||||
esbuild: true
|
||||
@ -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
@ -0,0 +1,7 @@
|
||||
export default {
|
||||
plugins: {
|
||||
autoprefixer: {
|
||||
overrideBrowserslist: ['Android >= 8', 'iOS >= 15', 'Chrome >= 80'],
|
||||
},
|
||||
},
|
||||
};
|
||||
@ -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',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
1
public/products/aurora-headphones.svg
Normal 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 |
1
public/products/mori-coffee.svg
Normal 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 |
1
public/products/nova-lamp.svg
Normal 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 |
1
public/products/product-placeholder.svg
Normal 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 |
1
public/products/trail-backpack.svg
Normal 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 |
10
release-please-config.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"packages": {
|
||||
".": {
|
||||
"release-type": "node",
|
||||
"package-name": "vue-h5-template",
|
||||
"changelog-path": "CHANGELOG.md",
|
||||
"include-component-in-tag": false
|
||||
}
|
||||
}
|
||||
}
|
||||
14
src/App.vue
@ -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
@ -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));
|
||||
},
|
||||
};
|
||||
@ -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
@ -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);
|
||||
}
|
||||
25
src/api/modules/examples.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
62
src/api/modules/products.ts
Normal 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}`,
|
||||
);
|
||||
}
|
||||
37
src/api/modules/projects.ts
Normal 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}`);
|
||||
}
|
||||
20
src/api/modules/requestExamples.ts
Normal 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
@ -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 });
|
||||
}
|
||||
@ -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
@ -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 |
1
src/assets/icons/cart.svg
Normal 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 |
1
src/assets/icons/examples.svg
Normal 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 |
1
src/assets/icons/home.svg
Normal 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 |
1
src/assets/icons/logo.svg
Normal 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 |
1
src/assets/icons/shop.svg
Normal 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 |
1
src/assets/icons/user.svg
Normal 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 |
118
src/components/ai/ChatComposer.vue
Normal 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>
|
||||
83
src/components/ai/SafeMarkdown.vue
Normal 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>
|
||||
87
src/components/auth/LoginForm.vue
Normal 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>
|
||||
60
src/components/common/AppErrorBoundary.vue
Normal 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>
|
||||
23
src/components/common/SvgIcon.vue
Normal 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>
|
||||
201
src/components/shop/ProductCard.vue
Normal 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>
|
||||
453
src/components/shop/ProductEditor.vue
Normal 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>
|
||||
21
src/composables/useNetworkStatus.ts
Normal 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 };
|
||||
}
|
||||
58
src/composables/usePullToRefresh.ts
Normal 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 };
|
||||
}
|
||||
122
src/composables/useStreamingChat.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
18
src/composables/useVisualViewport.ts
Normal 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 };
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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": "400–500, 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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
421
src/locales/langs/ja-JP/common.json
Normal 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": "再読み込み"
|
||||
}
|
||||
}
|
||||
@ -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": "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 生产环境单选"
|
||||
},
|
||||
"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": "重新加载"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
43
src/main.ts
@ -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
@ -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);
|
||||
}
|
||||
@ -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
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||