JutarryWu 5 tháng trước cách đây
mục cha
commit
b4ae02da99
85 tập tin đã thay đổi với 3436 bổ sung13 xóa
  1. 68 0
      .commitlintrc.js
  2. 17 0
      .env.test
  3. 609 0
      .idea/workspace.xml
  4. 4 0
      .lintstagedrc
  5. 1 0
      .node-version
  6. 17 0
      plop-templates/component/index.hbs
  7. 66 0
      plop-templates/component/prompt.js
  8. 21 0
      plop-templates/page/index.hbs
  9. 54 0
      plop-templates/page/prompt.js
  10. 13 0
      plop-templates/store/index.hbs
  11. 28 0
      plop-templates/store/prompt.js
  12. 13 0
      plopfile.js
  13. 0 0
      public/.nojekyll
  14. 16 0
      src/api/modules/user.ts
  15. 0 0
      src/assets/icons/403.svg
  16. 0 0
      src/assets/icons/404.svg
  17. 0 1
      src/assets/icons/about/box.svg
  18. 0 5
      src/assets/icons/about/car.svg
  19. 0 3
      src/assets/icons/about/logout.svg
  20. 1 0
      src/assets/icons/example-crown.svg
  21. 1 0
      src/assets/icons/example-emotion-laugh-line.svg
  22. 1 0
      src/assets/icons/example-emotion-line.svg
  23. 1 0
      src/assets/icons/example-emotion-unhappy-line.svg
  24. 1 0
      src/assets/icons/example-star.svg
  25. 1 0
      src/assets/icons/example-vip.svg
  26. BIN
      src/assets/images/bg-main.png
  27. BIN
      src/assets/images/logo.png
  28. BIN
      src/assets/logo.png
  29. 35 0
      src/assets/styles/globals.css
  30. 63 0
      src/assets/styles/nprogress.css
  31. 55 0
      src/assets/styles/resources/utils.scss
  32. 1 0
      src/assets/styles/resources/variables.scss
  33. BIN
      src/assets/tabbar/about.png
  34. BIN
      src/assets/tabbar/about_n.png
  35. BIN
      src/assets/tabbar/comp.png
  36. BIN
      src/assets/tabbar/comp_n.png
  37. BIN
      src/assets/tabbar/home.png
  38. BIN
      src/assets/tabbar/home_n.png
  39. 147 0
      src/components/AppSetting/index.vue
  40. 20 0
      src/components/Auth/index.vue
  41. 20 0
      src/components/AuthAll/index.vue
  42. 49 0
      src/components/NotAllowed/index.vue
  43. 259 0
      src/components/PageLayout/index.vue
  44. 47 0
      src/components/PageMain/index.vue
  45. 78 0
      src/components/SvgIcon/index.vue
  46. 38 0
      src/components/Trend/index.vue
  47. 65 0
      src/components/VanFieldCalendar/index.vue
  48. 58 0
      src/components/VanFieldDatePicker/index.vue
  49. 51 0
      src/components/VanFieldPicker/index.vue
  50. 47 0
      src/mock/user.ts
  51. 132 0
      src/router/guards.ts
  52. 29 0
      src/settings.default.ts
  53. 24 0
      src/settings.ts
  54. 42 0
      src/store/modules/keepAlive.ts
  55. 72 0
      src/store/modules/settings.ts
  56. 87 0
      src/types/auto-imports.d.ts
  57. 1 4
      src/types/components.d.ts
  58. 108 0
      src/types/global.d.ts
  59. 12 0
      src/types/route-meta.d.ts
  60. 5 0
      src/types/shims.d.ts
  61. 42 0
      src/ui-kit/HBadge.vue
  62. 28 0
      src/ui-kit/HButton.vue
  63. 84 0
      src/ui-kit/HDialog.vue
  64. 25 0
      src/ui-kit/HInput.vue
  65. 83 0
      src/ui-kit/HSlideover.vue
  66. 52 0
      src/ui-kit/HTabList.vue
  67. 26 0
      src/ui-kit/HToggle.vue
  68. 10 0
      src/ui-provider/index.ts
  69. 15 0
      src/ui-provider/index.vue
  70. 35 0
      src/utils/composables/useAuth.ts
  71. 6 0
      src/utils/composables/useGlobalProperties.ts
  72. 13 0
      src/utils/composables/usePage.ts
  73. 6 0
      src/utils/dayjs.ts
  74. 19 0
      src/utils/directive.ts
  75. 3 0
      src/utils/eventBus.ts
  76. 16 0
      src/utils/system.copyright.ts
  77. 49 0
      src/views/[...all].vue
  78. 8 0
      src/views/index.vue
  79. 130 0
      src/views/login.vue
  80. 21 0
      src/views/reload.vue
  81. 49 0
      src/views/user/index.vue
  82. 32 0
      stylelint.config.js
  83. 39 0
      themes/index.ts
  84. 14 0
      tsconfig.node.json
  85. 153 0
      vite/plugins.ts

+ 68 - 0
.commitlintrc.js

@@ -0,0 +1,68 @@
+/** @type {import('cz-git').UserConfig} */
+export default {
+  rules: {
+    // @see: https://commitlint.js.org/#/reference-rules
+  },
+  prompt: {
+    alias: { fd: 'docs: fix typos' },
+    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:     ✨  新增功能 | A new feature', emoji: ':sparkles:' },
+      { value: 'fix', name: 'fix:      🐛  修复缺陷 | A bug fix', emoji: ':bug:' },
+      { value: 'docs', name: 'docs:     📝  文档更新 | Documentation only changes', emoji: ':memo:' },
+      { value: 'style', name: 'style:    💄  代码格式 | Changes that do not affect the meaning of the code', emoji: ':lipstick:' },
+      { value: 'refactor', name: 'refactor: ♻️   代码重构 | A code change that neither fixes a bug nor adds a feature', emoji: ':recycle:' },
+      { value: 'perf', name: 'perf:     ⚡️  性能提升 | A code change that improves performance', emoji: ':zap:' },
+      { value: 'test', name: 'test:     ✅  测试相关 | Adding missing tests or correcting existing tests', emoji: ':white_check_mark:' },
+      { value: 'build', name: 'build:    📦️  构建相关 | Changes that affect the build system or external dependencies', emoji: ':package:' },
+      { value: 'ci', name: 'ci:       🎡  持续集成 | Changes to our CI configuration files and scripts', emoji: ':ferris_wheel:' },
+      { value: 'revert', name: 'revert:   ⏪️  回退代码 | Revert to a commit', emoji: ':rewind:' },
+      { value: 'chore', name: 'chore:    🔨  其他修改 | Other changes that do not modify src or test files', emoji: ':hammer:' },
+    ],
+    useEmoji: false,
+    emojiAlign: 'center',
+    themeColorCode: '',
+    scopes: [],
+    allowCustomScopes: true,
+    allowEmptyScopes: true,
+    customScopesAlign: 'bottom',
+    customScopesAlias: 'custom',
+    emptyScopesAlias: 'empty',
+    upperCaseSubject: false,
+    markBreakingChangeMode: true,
+    allowBreakingChanges: ['feat', 'fix'],
+    breaklineNumber: 100,
+    breaklineChar: '|',
+    skipQuestions: [],
+    issuePrefixs: [
+      // 如果使用 gitee 作为开发管理
+      { value: 'link', name: 'link:     链接 ISSUES 进行中' },
+      { value: 'closed', name: 'closed:   标记 ISSUES 已完成' },
+    ],
+    customIssuePrefixsAlign: 'top',
+    emptyIssuePrefixsAlias: 'skip',
+    customIssuePrefixsAlias: 'custom',
+    allowCustomIssuePrefixs: true,
+    allowEmptyIssuePrefixs: true,
+    confirmColorize: true,
+    maxHeaderLength: Number.POSITIVE_INFINITY,
+    maxSubjectLength: Number.POSITIVE_INFINITY,
+    minSubjectLength: 0,
+    scopeOverrides: undefined,
+    defaultBody: '',
+    defaultIssues: '',
+    defaultScope: '',
+    defaultSubject: '',
+  },
+}

+ 17 - 0
.env.test

@@ -0,0 +1,17 @@
+# 应用配置面板
+VITE_APP_SETTING = false
+# 页面标题
+VITE_APP_TITLE = Fantastic-mobile 基础版
+# 接口请求地址,会设置到 axios 的 baseURL 参数上
+VITE_APP_API_BASEURL = /
+# 调试工具,可设置 eruda 或 vconsole,如果不需要开启则留空
+VITE_APP_DEBUG_TOOL =
+
+# 是否在打包时启用 Mock
+VITE_BUILD_MOCK = true
+# 是否在打包时生成 sourcemap
+VITE_BUILD_SOURCEMAP = true
+# 是否在打包时开启压缩,支持 gzip 和 brotli
+VITE_BUILD_COMPRESS =
+# 是否在打包后生成存档,支持 zip 和 tar
+VITE_BUILD_ARCHIVE =

+ 609 - 0
.idea/workspace.xml

@@ -0,0 +1,609 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="AutoImportSettings">
+    <option name="autoReloadType" value="SELECTIVE" />
+  </component>
+  <component name="ChangeListManager">
+    <list default="true" id="b9dc0b93-aea2-4509-84d8-c1e57bc059b1" name="更改" comment="init">
+      <changelist_data name="JutarryWu" email="wuzihao30109@163.com" date="1726732440000" />
+      <change afterPath="$PROJECT_DIR$/.commitlintrc.js" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/.env.test" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/.lintstagedrc" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/.node-version" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/plop-templates/component/index.hbs" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/plop-templates/component/prompt.js" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/plop-templates/page/index.hbs" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/plop-templates/page/prompt.js" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/plop-templates/store/index.hbs" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/plop-templates/store/prompt.js" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/plopfile.js" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/public/.nojekyll" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/src/api/modules/user.ts" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/src/assets/icons/403.svg" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/src/assets/icons/404.svg" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/src/assets/icons/example-crown.svg" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/src/assets/icons/example-emotion-laugh-line.svg" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/src/assets/icons/example-emotion-line.svg" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/src/assets/icons/example-emotion-unhappy-line.svg" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/src/assets/icons/example-star.svg" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/src/assets/icons/example-vip.svg" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/src/assets/images/logo.png" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/src/assets/styles/globals.css" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/src/assets/styles/nprogress.css" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/src/assets/styles/resources/utils.scss" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/src/assets/styles/resources/variables.scss" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/src/components/AppSetting/index.vue" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/src/components/Auth/index.vue" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/src/components/AuthAll/index.vue" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/src/components/NotAllowed/index.vue" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/src/components/PageLayout/index.vue" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/src/components/PageMain/index.vue" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/src/components/SvgIcon/index.vue" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/src/components/Trend/index.vue" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/src/components/VanFieldCalendar/index.vue" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/src/components/VanFieldDatePicker/index.vue" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/src/components/VanFieldPicker/index.vue" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/src/mock/user.ts" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/src/router/guards.ts" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/src/settings.default.ts" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/src/settings.ts" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/src/store/modules/keepAlive.ts" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/src/store/modules/settings.ts" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/src/types/auto-imports.d.ts" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/src/types/global.d.ts" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/src/types/route-meta.d.ts" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/src/types/shims.d.ts" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/src/ui-kit/HBadge.vue" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/src/ui-kit/HButton.vue" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/src/ui-kit/HDialog.vue" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/src/ui-kit/HInput.vue" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/src/ui-kit/HSlideover.vue" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/src/ui-kit/HTabList.vue" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/src/ui-kit/HToggle.vue" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/src/ui-provider/index.ts" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/src/ui-provider/index.vue" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/src/utils/composables/useAuth.ts" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/src/utils/composables/useGlobalProperties.ts" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/src/utils/composables/usePage.ts" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/src/utils/dayjs.ts" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/src/utils/directive.ts" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/src/utils/eventBus.ts" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/src/utils/system.copyright.ts" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/src/views/[...all].vue" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/src/views/index.vue" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/src/views/login.vue" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/src/views/reload.vue" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/src/views/user/index.vue" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/stylelint.config.js" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/themes/index.ts" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/tsconfig.node.json" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/vite/plugins.ts" afterDir="false" />
+      <change beforePath="$PROJECT_DIR$/src/assets/icons/about/box.svg" beforeDir="false" />
+      <change beforePath="$PROJECT_DIR$/src/assets/icons/about/car.svg" beforeDir="false" />
+      <change beforePath="$PROJECT_DIR$/src/assets/icons/about/logout.svg" beforeDir="false" />
+      <change beforePath="$PROJECT_DIR$/src/assets/images/bg-main.png" beforeDir="false" />
+      <change beforePath="$PROJECT_DIR$/src/assets/logo.png" beforeDir="false" />
+      <change beforePath="$PROJECT_DIR$/src/assets/tabbar/about.png" beforeDir="false" />
+      <change beforePath="$PROJECT_DIR$/src/assets/tabbar/about_n.png" beforeDir="false" />
+      <change beforePath="$PROJECT_DIR$/src/assets/tabbar/comp.png" beforeDir="false" />
+      <change beforePath="$PROJECT_DIR$/src/assets/tabbar/comp_n.png" beforeDir="false" />
+      <change beforePath="$PROJECT_DIR$/src/assets/tabbar/home.png" beforeDir="false" />
+      <change beforePath="$PROJECT_DIR$/src/assets/tabbar/home_n.png" beforeDir="false" />
+      <change beforePath="$PROJECT_DIR$/src/types/components.d.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/types/components.d.ts" afterDir="false" />
+    </list>
+    <option name="SHOW_DIALOG" value="false" />
+    <option name="HIGHLIGHT_CONFLICTS" value="true" />
+    <option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
+    <option name="LAST_RESOLUTION" value="IGNORE" />
+  </component>
+  <component name="FileTemplateManagerImpl">
+    <option name="RECENT_TEMPLATES">
+      <list>
+        <option value="Less File" />
+        <option value="Vue Composition API Component" />
+        <option value="TypeScript File" />
+        <option value="tsconfig.json" />
+        <option value="Vue3模板" />
+      </list>
+    </option>
+  </component>
+  <component name="Git.Settings">
+    <option name="RECENT_BRANCH_BY_REPOSITORY">
+      <map>
+        <entry key="$PROJECT_DIR$" value="dev" />
+      </map>
+    </option>
+    <option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
+  </component>
+  <component name="GitToolBoxStore">
+    <option name="recentBranches">
+      <RecentBranches>
+        <option name="branchesForRepo">
+          <list>
+            <RecentBranchesForRepo>
+              <option name="branches">
+                <list>
+                  <RecentBranch>
+                    <option name="branchName" value="master" />
+                    <option name="lastUsedInstant" value="1726741484" />
+                  </RecentBranch>
+                  <RecentBranch>
+                    <option name="branchName" value="20240919-Jutarry" />
+                    <option name="lastUsedInstant" value="1726733720" />
+                  </RecentBranch>
+                  <RecentBranch>
+                    <option name="branchName" value="dev" />
+                    <option name="lastUsedInstant" value="1726731162" />
+                  </RecentBranch>
+                  <RecentBranch>
+                    <option name="branchName" value="dev-20240819" />
+                    <option name="lastUsedInstant" value="1724153421" />
+                  </RecentBranch>
+                </list>
+              </option>
+              <option name="repositoryRootUrl" value="file://$PROJECT_DIR$" />
+            </RecentBranchesForRepo>
+          </list>
+        </option>
+      </RecentBranches>
+    </option>
+  </component>
+  <component name="ProjectColorInfo">{
+  &quot;associatedIndex&quot;: 8
+}</component>
+  <component name="ProjectId" id="2kjRG9Fsx6MvF8u7E8ChN03TFjf" />
+  <component name="ProjectViewState">
+    <option name="hideEmptyMiddlePackages" value="true" />
+    <option name="showLibraryContents" value="true" />
+  </component>
+  <component name="PropertiesComponent"><![CDATA[{
+  "keyToString": {
+    "RunOnceActivity.ShowReadmeOnStart": "true",
+    "git-widget-placeholder": "master",
+    "javascript.nodejs.core.library.configured.version": "20.13.1",
+    "javascript.nodejs.core.library.typings.version": "20.13.0",
+    "last_opened_file_path": "E:/WorkSpace/Web/insomnia-cognition-h5/src/types",
+    "list.type.of.created.stylesheet": "Less",
+    "node.js.detected.package.eslint": "true",
+    "node.js.detected.package.standard": "true",
+    "node.js.detected.package.tslint": "true",
+    "node.js.selected.package.eslint": "(autodetect)",
+    "node.js.selected.package.standard": "",
+    "node.js.selected.package.tslint": "(autodetect)",
+    "nodejs_package_manager_path": "pnpm",
+    "settings.editor.selected.configurable": "preferences.pluginManager",
+    "ts.external.directory.path": "E:\\WorkSpace\\Web\\insomnia-cognition-h5\\node_modules\\typescript\\lib",
+    "vue.rearranger.settings.migration": "true"
+  },
+  "keyToStringList": {
+    "vue.recent.templates": [
+      "Vue Composition API Component"
+    ]
+  }
+}]]></component>
+  <component name="RecentsManager">
+    <key name="CopyFile.RECENT_KEYS">
+      <recent name="E:\WorkSpace\Web\insomnia-cognition-h5\src\types" />
+      <recent name="E:\WorkSpace\Web\insomnia-cognition-h5\src\utils" />
+      <recent name="E:\WorkSpace\Web\insomnia-cognition-h5\src\store" />
+      <recent name="E:\WorkSpace\Web\insomnia-cognition-h5\src\api" />
+      <recent name="E:\WorkSpace\Web\insomnia-cognition-h5\src\api\modules" />
+    </key>
+    <key name="MoveFile.RECENT_KEYS">
+      <recent name="E:\WorkSpace\Web\insomnia-cognition-h5\src\views\cognitiveTasks\BreadthTraining\components" />
+      <recent name="E:\WorkSpace\Web\insomnia-cognition-h5\src\assets\images" />
+      <recent name="E:\WorkSpace\Web\insomnia-cognition-h5\src\assets\icons" />
+      <recent name="E:\WorkSpace\Web\insomnia-cognition-h5\src\styles" />
+    </key>
+  </component>
+  <component name="SharedIndexes">
+    <attachedChunks>
+      <set>
+        <option value="bundled-js-predefined-1d06a55b98c1-0b3e54e931b4-JavaScript-WS-241.17011.90" />
+      </set>
+    </attachedChunks>
+  </component>
+  <component name="SpellCheckerSettings" RuntimeDictionaries="0" Folders="0" CustomDictionaries="0" DefaultDictionary="应用程序级" UseSingleDictionary="true" transferred="true" />
+  <component name="TaskManager">
+    <task active="true" id="Default" summary="默认任务">
+      <changelist id="b9dc0b93-aea2-4509-84d8-c1e57bc059b1" name="更改" comment="" />
+      <created>1723794900486</created>
+      <option name="number" value="Default" />
+      <option name="presentableId" value="Default" />
+      <updated>1723794900486</updated>
+      <workItem from="1723794901948" duration="2742000" />
+      <workItem from="1723797698495" duration="11478000" />
+      <workItem from="1723859606021" duration="802000" />
+      <workItem from="1723860427125" duration="11114000" />
+      <workItem from="1723871627851" duration="11748000" />
+      <workItem from="1723884252610" duration="13911000" />
+      <workItem from="1723974237119" duration="5573000" />
+      <workItem from="1723992250992" duration="6082000" />
+      <workItem from="1724037062628" duration="406000" />
+      <workItem from="1724037513757" duration="11402000" />
+      <workItem from="1724053015355" duration="75325000" />
+      <workItem from="1724317270501" duration="14000" />
+      <workItem from="1724383409394" duration="11217000" />
+      <workItem from="1724478841989" duration="22000" />
+      <workItem from="1724903305084" duration="4982000" />
+      <workItem from="1725417782248" duration="1465000" />
+      <workItem from="1725506917072" duration="697000" />
+      <workItem from="1725608929876" duration="1001000" />
+      <workItem from="1726126375188" duration="978000" />
+      <workItem from="1726196050458" duration="5789000" />
+      <workItem from="1726726752821" duration="5807000" />
+    </task>
+    <task id="LOCAL-00001" summary="新增图片">
+      <option name="closed" value="true" />
+      <created>1723794930384</created>
+      <option name="number" value="00001" />
+      <option name="presentableId" value="LOCAL-00001" />
+      <option name="project" value="LOCAL" />
+      <updated>1723794930384</updated>
+    </task>
+    <task id="LOCAL-00002" summary="feat(组件类): 新增倒计时组件">
+      <option name="closed" value="true" />
+      <created>1723795981941</created>
+      <option name="number" value="00002" />
+      <option name="presentableId" value="LOCAL-00002" />
+      <option name="project" value="LOCAL" />
+      <updated>1723795981941</updated>
+    </task>
+    <task id="LOCAL-00003" summary="feat(图片): 新增空间定位训练图片">
+      <option name="closed" value="true" />
+      <created>1723797224845</created>
+      <option name="number" value="00003" />
+      <option name="presentableId" value="LOCAL-00003" />
+      <option name="project" value="LOCAL" />
+      <updated>1723797224845</updated>
+    </task>
+    <task id="LOCAL-00004" summary="feat(路由及样式): 新增空间定位训练框架">
+      <option name="closed" value="true" />
+      <created>1723797270785</created>
+      <option name="number" value="00004" />
+      <option name="presentableId" value="LOCAL-00004" />
+      <option name="project" value="LOCAL" />
+      <updated>1723797270785</updated>
+    </task>
+    <task id="LOCAL-00005" summary="feat(认知任务): 新增空间定位训练框架">
+      <option name="closed" value="true" />
+      <created>1723797335061</created>
+      <option name="number" value="00005" />
+      <option name="presentableId" value="LOCAL-00005" />
+      <option name="project" value="LOCAL" />
+      <updated>1723797335061</updated>
+    </task>
+    <task id="LOCAL-00006" summary="feat(认知任务): 新增广度训练框架">
+      <option name="closed" value="true" />
+      <created>1723797612572</created>
+      <option name="number" value="00006" />
+      <option name="presentableId" value="LOCAL-00006" />
+      <option name="project" value="LOCAL" />
+      <updated>1723797612572</updated>
+    </task>
+    <task id="LOCAL-00007" summary="feat(认知任务): 新增广度训练框架">
+      <option name="closed" value="true" />
+      <created>1723798037413</created>
+      <option name="number" value="00007" />
+      <option name="presentableId" value="LOCAL-00007" />
+      <option name="project" value="LOCAL" />
+      <updated>1723798037413</updated>
+    </task>
+    <task id="LOCAL-00008" summary="feat(认知任务): 空间定位算法实现">
+      <option name="closed" value="true" />
+      <created>1723805618287</created>
+      <option name="number" value="00008" />
+      <option name="presentableId" value="LOCAL-00008" />
+      <option name="project" value="LOCAL" />
+      <updated>1723805618287</updated>
+    </task>
+    <task id="LOCAL-00009" summary="feat(认知任务): init">
+      <option name="closed" value="true" />
+      <created>1723858294672</created>
+      <option name="number" value="00009" />
+      <option name="presentableId" value="LOCAL-00009" />
+      <option name="project" value="LOCAL" />
+      <updated>1723858294672</updated>
+    </task>
+    <task id="LOCAL-00010" summary="feat(认知任务): init">
+      <option name="closed" value="true" />
+      <created>1724028305315</created>
+      <option name="number" value="00010" />
+      <option name="presentableId" value="LOCAL-00010" />
+      <option name="project" value="LOCAL" />
+      <updated>1724028305315</updated>
+    </task>
+    <task id="LOCAL-00011" summary="feat(认知任务): init">
+      <option name="closed" value="true" />
+      <created>1724028617479</created>
+      <option name="number" value="00011" />
+      <option name="presentableId" value="LOCAL-00011" />
+      <option name="project" value="LOCAL" />
+      <updated>1724028617479</updated>
+    </task>
+    <task id="LOCAL-00012" summary="feat(认知任务): 完成后端联调API搭建、完成main页面优化">
+      <option name="closed" value="true" />
+      <created>1724048619646</created>
+      <option name="number" value="00012" />
+      <option name="presentableId" value="LOCAL-00012" />
+      <option name="project" value="LOCAL" />
+      <updated>1724048619646</updated>
+    </task>
+    <task id="LOCAL-00013" summary="feat(认知任务): 完成图片拼图的页面构建">
+      <option name="closed" value="true" />
+      <created>1724060810431</created>
+      <option name="number" value="00013" />
+      <option name="presentableId" value="LOCAL-00013" />
+      <option name="project" value="LOCAL" />
+      <updated>1724060810431</updated>
+    </task>
+    <task id="LOCAL-00014" summary="feat(认知任务): 组件TS 更新">
+      <option name="closed" value="true" />
+      <created>1724060939993</created>
+      <option name="number" value="00014" />
+      <option name="presentableId" value="LOCAL-00014" />
+      <option name="project" value="LOCAL" />
+      <updated>1724060939993</updated>
+    </task>
+    <task id="LOCAL-00015" summary="feat(认知任务): 完成后端联调API搭建、完成main页面优化">
+      <option name="closed" value="true" />
+      <created>1724061022044</created>
+      <option name="number" value="00015" />
+      <option name="presentableId" value="LOCAL-00015" />
+      <option name="project" value="LOCAL" />
+      <updated>1724061022044</updated>
+    </task>
+    <task id="LOCAL-00016" summary="feat(认知任务): 组件更新">
+      <option name="closed" value="true" />
+      <created>1724062207151</created>
+      <option name="number" value="00016" />
+      <option name="presentableId" value="LOCAL-00016" />
+      <option name="project" value="LOCAL" />
+      <updated>1724062207151</updated>
+    </task>
+    <task id="LOCAL-00017" summary="feat(认知任务): 1、拼图图片更改 2、广度训练页面优化">
+      <option name="closed" value="true" />
+      <created>1724065221014</created>
+      <option name="number" value="00017" />
+      <option name="presentableId" value="LOCAL-00017" />
+      <option name="project" value="LOCAL" />
+      <updated>1724065221014</updated>
+    </task>
+    <task id="LOCAL-00018" summary="feat(认知任务): 组件文件更新">
+      <option name="closed" value="true" />
+      <created>1724065373975</created>
+      <option name="number" value="00018" />
+      <option name="presentableId" value="LOCAL-00018" />
+      <option name="project" value="LOCAL" />
+      <updated>1724065373975</updated>
+    </task>
+    <task id="LOCAL-00019" summary="feat(认知任务): 图片拼图文件修改">
+      <option name="closed" value="true" />
+      <created>1724065561729</created>
+      <option name="number" value="00019" />
+      <option name="presentableId" value="LOCAL-00019" />
+      <option name="project" value="LOCAL" />
+      <updated>1724065561729</updated>
+    </task>
+    <task id="LOCAL-00020" summary="feat(认知任务): 图片拼图、空间定向接口对接及页面优化完成">
+      <option name="closed" value="true" />
+      <created>1724130157160</created>
+      <option name="number" value="00020" />
+      <option name="presentableId" value="LOCAL-00020" />
+      <option name="project" value="LOCAL" />
+      <updated>1724130157160</updated>
+    </task>
+    <task id="LOCAL-00021" summary="feat(认知任务): 完成图片命名任务">
+      <option name="closed" value="true" />
+      <created>1724153306659</created>
+      <option name="number" value="00021" />
+      <option name="presentableId" value="LOCAL-00021" />
+      <option name="project" value="LOCAL" />
+      <updated>1724153306659</updated>
+    </task>
+    <task id="LOCAL-00022" summary="feat(认知任务): 优化图片命名音效逻辑">
+      <option name="closed" value="true" />
+      <created>1724153879301</created>
+      <option name="number" value="00022" />
+      <option name="presentableId" value="LOCAL-00022" />
+      <option name="project" value="LOCAL" />
+      <updated>1724153879301</updated>
+    </task>
+    <task id="LOCAL-00023" summary="feat(认知任务): 更新页面名称、优化广度训练的TS类型">
+      <option name="closed" value="true" />
+      <created>1724154551752</created>
+      <option name="number" value="00023" />
+      <option name="presentableId" value="LOCAL-00023" />
+      <option name="project" value="LOCAL" />
+      <updated>1724154551752</updated>
+    </task>
+    <task id="LOCAL-00024" summary="feat(认知任务): 部署服务后优化">
+      <option name="closed" value="true" />
+      <created>1724159852777</created>
+      <option name="number" value="00024" />
+      <option name="presentableId" value="LOCAL-00024" />
+      <option name="project" value="LOCAL" />
+      <updated>1724159852777</updated>
+    </task>
+    <task id="LOCAL-00025" summary="feat(认知任务): 优化">
+      <option name="closed" value="true" />
+      <created>1724394126886</created>
+      <option name="number" value="00025" />
+      <option name="presentableId" value="LOCAL-00025" />
+      <option name="project" value="LOCAL" />
+      <updated>1724394126886</updated>
+    </task>
+    <task id="LOCAL-00026" summary="feat(认知任务): 优化">
+      <option name="closed" value="true" />
+      <created>1724396434546</created>
+      <option name="number" value="00026" />
+      <option name="presentableId" value="LOCAL-00026" />
+      <option name="project" value="LOCAL" />
+      <updated>1724396434546</updated>
+    </task>
+    <task id="LOCAL-00027" summary="feat(认知任务): 优化">
+      <option name="closed" value="true" />
+      <created>1726126542723</created>
+      <option name="number" value="00027" />
+      <option name="presentableId" value="LOCAL-00027" />
+      <option name="project" value="LOCAL" />
+      <updated>1726126542723</updated>
+    </task>
+    <task id="LOCAL-00028" summary="init">
+      <option name="closed" value="true" />
+      <created>1726732470277</created>
+      <option name="number" value="00028" />
+      <option name="presentableId" value="LOCAL-00028" />
+      <option name="project" value="LOCAL" />
+      <updated>1726732470277</updated>
+    </task>
+    <task id="LOCAL-00029" summary="修改后台请求接口路径">
+      <option name="closed" value="true" />
+      <created>1726732700840</created>
+      <option name="number" value="00029" />
+      <option name="presentableId" value="LOCAL-00029" />
+      <option name="project" value="LOCAL" />
+      <updated>1726732700840</updated>
+    </task>
+    <task id="LOCAL-00030" summary="修改基础配置1">
+      <option name="closed" value="true" />
+      <created>1726737001708</created>
+      <option name="number" value="00030" />
+      <option name="presentableId" value="LOCAL-00030" />
+      <option name="project" value="LOCAL" />
+      <updated>1726737001708</updated>
+    </task>
+    <task id="LOCAL-00031" summary="修改后台请求接口路径及全局样式">
+      <option name="closed" value="true" />
+      <created>1726738052902</created>
+      <option name="number" value="00031" />
+      <option name="presentableId" value="LOCAL-00031" />
+      <option name="project" value="LOCAL" />
+      <updated>1726738052902</updated>
+    </task>
+    <task id="LOCAL-00032" summary="utils方法迁移">
+      <option name="closed" value="true" />
+      <created>1726738380003</created>
+      <option name="number" value="00032" />
+      <option name="presentableId" value="LOCAL-00032" />
+      <option name="project" value="LOCAL" />
+      <updated>1726738380003</updated>
+    </task>
+    <task id="LOCAL-00033" summary="具体游戏文件优化">
+      <option name="closed" value="true" />
+      <created>1726740487871</created>
+      <option name="number" value="00033" />
+      <option name="presentableId" value="LOCAL-00033" />
+      <option name="project" value="LOCAL" />
+      <updated>1726740487871</updated>
+    </task>
+    <task id="LOCAL-00034" summary="具体游戏文件优化">
+      <option name="closed" value="true" />
+      <created>1726741463644</created>
+      <option name="number" value="00034" />
+      <option name="presentableId" value="LOCAL-00034" />
+      <option name="project" value="LOCAL" />
+      <updated>1726741463644</updated>
+    </task>
+    <task id="LOCAL-00035" summary="具体游戏文件优化">
+      <option name="closed" value="true" />
+      <created>1726741550737</created>
+      <option name="number" value="00035" />
+      <option name="presentableId" value="LOCAL-00035" />
+      <option name="project" value="LOCAL" />
+      <updated>1726741550737</updated>
+    </task>
+    <option name="localTasksCounter" value="36" />
+    <servers />
+  </component>
+  <component name="TypeScriptGeneratedFilesManager">
+    <option name="version" value="3" />
+  </component>
+  <component name="Vcs.Log.Tabs.Properties">
+    <option name="RECENT_FILTERS">
+      <map>
+        <entry key="Branch">
+          <value>
+            <list>
+              <RecentGroup>
+                <option name="FILTER_VALUES">
+                  <option value="dev" />
+                </option>
+              </RecentGroup>
+              <RecentGroup>
+                <option name="FILTER_VALUES">
+                  <option value="origin/dev" />
+                </option>
+              </RecentGroup>
+              <RecentGroup>
+                <option name="FILTER_VALUES">
+                  <option value="origin/20240919-Jutarry" />
+                </option>
+              </RecentGroup>
+              <RecentGroup>
+                <option name="FILTER_VALUES">
+                  <option value="20240820-Jutarry" />
+                </option>
+              </RecentGroup>
+              <RecentGroup>
+                <option name="FILTER_VALUES">
+                  <option value="origin/20240817-Jutarry" />
+                </option>
+              </RecentGroup>
+            </list>
+          </value>
+        </entry>
+      </map>
+    </option>
+    <option name="TAB_STATES">
+      <map>
+        <entry key="MAIN">
+          <value>
+            <State>
+              <option name="FILTERS">
+                <map>
+                  <entry key="branch">
+                    <value>
+                      <list>
+                        <option value="dev" />
+                      </list>
+                    </value>
+                  </entry>
+                </map>
+              </option>
+            </State>
+          </value>
+        </entry>
+      </map>
+    </option>
+  </component>
+  <component name="VcsManagerConfiguration">
+    <MESSAGE value="feat(路由及样式): 新增空间定位训练框架" />
+    <MESSAGE value="feat(认知任务): 新增空间定位训练框架" />
+    <MESSAGE value="feat(认知任务): 新增广度训练框架" />
+    <MESSAGE value="feat(认知任务): 空间定位算法实现" />
+    <MESSAGE value="feat(认知任务): init" />
+    <MESSAGE value="feat(认知任务): 完成图片拼图的页面构建" />
+    <MESSAGE value="feat(认知任务): 组件TS 更新" />
+    <MESSAGE value="feat(认知任务): 完成后端联调API搭建、完成main页面优化" />
+    <MESSAGE value="feat(认知任务): 组件更新" />
+    <MESSAGE value="feat(认知任务): 1、拼图图片更改 2、广度训练页面优化" />
+    <MESSAGE value="feat(认知任务): 组件文件更新" />
+    <MESSAGE value="feat(认知任务): 图片拼图文件修改" />
+    <MESSAGE value="feat(认知任务): 完成图片命名任务" />
+    <MESSAGE value="feat(认知任务): 图片拼图、空间定向接口对接及页面优化完成" />
+    <MESSAGE value="feat(认知任务): 1" />
+    <MESSAGE value="feat(认知任务): 优化图片命名音效逻辑" />
+    <MESSAGE value="feat(认知任务): 更新页面名称、优化广度训练的TS类型" />
+    <MESSAGE value="feat(认知任务): 部署服务后优化" />
+    <MESSAGE value="feat(认知任务): 优化" />
+    <MESSAGE value="修改基础配置1" />
+    <MESSAGE value="修改后台请求接口路径" />
+    <MESSAGE value="修改后台请求接口路径及全局样式" />
+    <MESSAGE value="utils方法迁移" />
+    <MESSAGE value="具体游戏文件优化" />
+    <MESSAGE value="init" />
+    <option name="LAST_COMMIT_MESSAGE" value="init" />
+  </component>
+</project>

+ 4 - 0
.lintstagedrc

@@ -0,0 +1,4 @@
+{
+  "*.{ts,tsx,vue}": "eslint --cache --fix",
+  "*.{css,scss,vue}": "stylelint --cache --fix"
+}

+ 1 - 0
.node-version

@@ -0,0 +1 @@
+20

+ 17 - 0
plop-templates/component/index.hbs

@@ -0,0 +1,17 @@
+<script setup lang="ts">
+{{#if isGlobal}}
+defineOptions({
+  name: '{{ properCase name }}',
+})
+{{/if}}
+</script>
+
+<template>
+  <div>
+    <!-- 布局 -->
+  </div>
+</template>
+
+<style scoped>
+/* 样式 */
+</style>

+ 66 - 0
plop-templates/component/prompt.js

@@ -0,0 +1,66 @@
+import fs from 'node:fs'
+
+function getFolder(path) {
+  const components = []
+  const files = fs.readdirSync(path)
+  files.forEach((item) => {
+    const stat = fs.lstatSync(`${path}/${item}`)
+    if (stat.isDirectory() === true && item !== 'components') {
+      components.push(`${path}/${item}`)
+      components.push(...getFolder(`${path}/${item}`))
+    }
+  })
+  return components
+}
+
+export default {
+  description: '创建组件',
+  prompts: [
+    {
+      type: 'confirm',
+      name: 'isGlobal',
+      message: '是否为全局组件',
+      default: false,
+    },
+    {
+      type: 'list',
+      name: 'path',
+      message: '请选择组件创建目录',
+      choices: getFolder('src/views'),
+      when: (answers) => {
+        return !answers.isGlobal
+      },
+    },
+    {
+      type: 'input',
+      name: 'name',
+      message: '请输入组件名称',
+      validate: (v) => {
+        if (!v || v.trim === '') {
+          return '组件名称不能为空'
+        }
+        else {
+          return true
+        }
+      },
+    },
+  ],
+  actions: (data) => {
+    let path = ''
+    if (data.isGlobal) {
+      path = 'src/components/{{properCase name}}/index.vue'
+    }
+    else {
+      path = `${data.path}/components/{{properCase name}}/index.vue`
+    }
+
+    const actions = [
+      {
+        type: 'add',
+        path,
+        templateFile: 'plop-templates/component/index.hbs',
+      },
+    ]
+    return actions
+  },
+}

+ 21 - 0
plop-templates/page/index.hbs

@@ -0,0 +1,21 @@
+<script setup lang="ts">
+defineOptions({
+  name: '{{ properCase componentName }}',
+})
+
+definePage({
+  meta: {
+    title: '新建页面',
+  },
+})
+</script>
+
+<template>
+  <PageLayout navbar>
+    <!-- 布局 -->
+  </PageLayout>
+</template>
+
+<style scoped>
+/* 样式 */
+</style>

+ 54 - 0
plop-templates/page/prompt.js

@@ -0,0 +1,54 @@
+import path from 'node:path'
+import fs from 'node:fs'
+
+function getFolder(path) {
+  const components = []
+  const files = fs.readdirSync(path)
+  files.forEach((item) => {
+    const stat = fs.lstatSync(`${path}/${item}`)
+    if (stat.isDirectory() === true && item !== 'components') {
+      components.push(`${path}/${item}`)
+      components.push(...getFolder(`${path}/${item}`))
+    }
+  })
+  return components
+}
+
+export default {
+  description: '创建页面',
+  prompts: [
+    {
+      type: 'list',
+      name: 'path',
+      message: '请选择页面创建目录',
+      choices: getFolder('src/views'),
+    },
+    {
+      type: 'input',
+      name: 'name',
+      message: '请输入文件名',
+      validate: (v) => {
+        if (!v || v.trim === '') {
+          return '文件名不能为空'
+        }
+        else {
+          return true
+        }
+      },
+    },
+  ],
+  actions: (data) => {
+    const relativePath = path.relative('src/views', data.path)
+    const actions = [
+      {
+        type: 'add',
+        path: `${data.path}/{{dotCase name}}.vue`,
+        templateFile: 'plop-templates/page/index.hbs',
+        data: {
+          componentName: `${relativePath} ${data.name}`,
+        },
+      },
+    ]
+    return actions
+  },
+}

+ 13 - 0
plop-templates/store/index.hbs

@@ -0,0 +1,13 @@
+const use{{ properCase name }}Store = defineStore(
+  // 唯一ID
+  '{{ camelCase name }}',
+  () => {
+    const someThing = ref(0)
+
+    return {
+      someThing,
+    }
+  },
+)
+
+export default use{{ properCase name }}Store

+ 28 - 0
plop-templates/store/prompt.js

@@ -0,0 +1,28 @@
+export default {
+  description: '创建全局状态',
+  prompts: [
+    {
+      type: 'input',
+      name: 'name',
+      message: '请输入模块名称',
+      validate: (v) => {
+        if (!v || v.trim === '') {
+          return '模块名称不能为空'
+        }
+        else {
+          return true
+        }
+      },
+    },
+  ],
+  actions: () => {
+    const actions = [
+      {
+        type: 'add',
+        path: 'src/store/modules/{{camelCase name}}.ts',
+        templateFile: 'plop-templates/store/index.hbs',
+      },
+    ]
+    return actions
+  },
+}

+ 13 - 0
plopfile.js

@@ -0,0 +1,13 @@
+import { promises as fs } from 'node:fs'
+
+export default async function (plop) {
+  plop.setWelcomeMessage('请选择需要创建的模式:')
+  const items = await fs.readdir('./plop-templates')
+  for (const item of items) {
+    const stat = await fs.lstat(`./plop-templates/${item}`)
+    if (stat.isDirectory()) {
+      const prompt = await import(`./plop-templates/${item}/prompt.js`)
+      plop.setGenerator(item, prompt.default)
+    }
+  }
+}

+ 0 - 0
public/.nojekyll


+ 16 - 0
src/api/modules/user.ts

@@ -0,0 +1,16 @@
+import api from '../index'
+
+export default {
+  // 登录
+  login: (data: {
+    account: string
+    password: string
+  }) => api.post('user/login', data, {
+    baseURL: '/mock/',
+  }),
+
+  // 获取权限
+  permission: () => api.get('user/permission', {
+    baseURL: '/mock/',
+  }),
+}

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 0 - 0
src/assets/icons/403.svg


Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 0 - 0
src/assets/icons/404.svg


Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 0 - 1
src/assets/icons/about/box.svg


+ 0 - 5
src/assets/icons/about/car.svg

@@ -1,5 +0,0 @@
-<svg fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24"
-  stroke="currentColor">
-  <path
-    d="M17 10a4 4 0 0 0-4-4h-2a4 4 0 0 0-4 4m5-4v4M3 16h2m4 0h6m4 0h2v-3a3 3 0 0 0-3-3h-12a3 3 0 0 0-3 3v3M9 16a2 2 0 1 1-4 0a2 2 0 0 1 4 0zM19 16a2 2 0 1 1-4 0a2 2 0 0 1 4 0z" />
-</svg>

+ 0 - 3
src/assets/icons/about/logout.svg

@@ -1,3 +0,0 @@
-<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1653881144049" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4823" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><style type="text/css">@font-face { font-family: element-icons; src: url("chrome-extension://moombeodfomdpjnpocobemoiaemednkg/fonts/element-icons.woff") format("woff"), url("chrome-extension://moombeodfomdpjnpocobemoiaemednkg/fonts/element-icons.ttf ") format("truetype"); }
-@font-face { font-family: feedback-iconfont; src: url("//at.alicdn.com/t/font_1031158_u69w8yhxdu.woff2?t=1630033759944") format("woff2"), url("//at.alicdn.com/t/font_1031158_u69w8yhxdu.woff?t=1630033759944") format("woff"), url("//at.alicdn.com/t/font_1031158_u69w8yhxdu.ttf?t=1630033759944") format("truetype"); }
-</style></defs><path d="M689.664 172.992l0 133.781333c89.28 58.176 148.394667 158.698667 148.394667 273.216 0 180.032-145.984 325.994667-326.058667 325.994667-180.032 0-326.016-145.962667-326.016-325.994667 0-101.781333 46.634667-192.618667 119.722667-252.437333L305.706667 186.773333C164.373333 261.098667 67.968 409.216 67.968 579.946667 67.968 825.173333 266.794667 1024 512.042667 1024c245.205333 0 443.989333-198.826667 443.989333-444.053333C956.010667 397.888 846.464 241.536 689.664 172.992z" p-id="4824" fill="#21a675"></path><path d="M577.344 459.989333c0 28.693333-29.248 51.989333-65.344 51.989333l0 0c-36.053333 0-65.322667-23.274667-65.322667-51.989333L446.677333 51.989333C446.677333 23.274667 475.946667 0 512 0l0 0c36.096 0 65.344 23.274667 65.344 51.989333L577.344 459.989333z" p-id="4825" fill="#21a675"></path></svg>

+ 1 - 0
src/assets/icons/example-crown.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" class="icon" viewBox="0 0 1024 1024"><path fill="#DCA54F" d="m136.533 273.067 669.048 422.058c10.65 6.725 15.838 20.48 12.697 33.588-3.106 13.073-13.824 22.186-26.077 22.22H238.42c-27.306 0-50.346-22.425-53.726-52.326l-48.162-425.54z"/><path fill="#DCA54F" d="M834.219 698.607c-3.345 29.9-26.385 52.326-53.692 52.326H245.794c-12.288 0-41.984-9.113-45.124-22.186-3.175-13.073 2.048-26.863 12.697-33.588l668.98-422.092-48.128 425.506z"/><path fill="#E7B15C" d="m512 170.667 298.428 490.598a61.44 61.44 0 0 1 2.184 59.324c-9.489 18.705-27.818 30.344-47.718 30.344H512V170.667z"/><path fill="#F2D59C" d="M512 170.667 196.062 666.01a61.44 61.44 0 0 0-2.185 59.323c9.49 18.705 27.341 25.6 47.24 25.6H512V170.667z"/><path fill="#E7B15C" d="M459.776 153.327c0 18.193 9.967 34.987 26.112 44.1a53.35 53.35 0 0 0 52.224 0c16.145-9.113 26.112-25.941 26.112-44.1 0-28.126-23.381-50.927-52.224-50.927s-52.224 22.801-52.224 50.927zM851.319 255.18c-.41 18.432 9.455 35.67 25.771 45.022 16.316 9.318 36.523 9.318 52.873 0 16.315-9.353 26.18-26.59 25.77-45.056-.614-27.648-23.825-49.8-52.224-49.8-28.364 0-51.541 22.152-52.19 49.834zm-783.018 0c-.444 18.432 9.42 35.67 25.736 45.022 16.316 9.318 36.523 9.318 52.873 0 16.316-9.353 26.18-26.59 25.77-45.056-.614-27.648-23.825-49.8-52.223-49.8-28.365 0-51.542 22.152-52.19 49.834z"/><path fill="#DCA54F" d="M238.933 819.2h546.134q34.133 0 34.133 34.133 0 34.134-34.133 34.134H238.933q-34.133 0-34.133-34.134 0-34.133 34.133-34.133z"/></svg>

+ 1 - 0
src/assets/icons/example-emotion-laugh-line.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" class="icon" viewBox="0 0 1024 1024"><path d="M512 85.333c235.648 0 426.667 191.019 426.667 426.667S747.648 938.667 512 938.667 85.333 747.648 85.333 512 276.352 85.333 512 85.333zm0 85.334a341.333 341.333 0 1 0 0 682.666 341.333 341.333 0 0 0 0-682.666zm0 298.666c85.333 0 156.459 14.208 213.333 42.667a213.333 213.333 0 0 1-426.666 0c56.874-28.459 128-42.667 213.333-42.667zM362.667 298.667A106.667 106.667 0 0 1 467.2 384H258.133a106.667 106.667 0 0 1 104.534-85.333zm298.666 0A106.667 106.667 0 0 1 765.867 384H556.8a106.667 106.667 0 0 1 104.533-85.333z"/></svg>

+ 1 - 0
src/assets/icons/example-emotion-line.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" class="icon" viewBox="0 0 1024 1024"><path d="M512 938.667C276.352 938.667 85.333 747.648 85.333 512S276.352 85.333 512 85.333 938.667 276.352 938.667 512 747.648 938.667 512 938.667zm0-85.334a341.333 341.333 0 1 0 0-682.666 341.333 341.333 0 0 0 0 682.666zM341.333 554.667h341.334a170.667 170.667 0 1 1-341.334 0zm0-85.334a64 64 0 1 1 0-128 64 64 0 0 1 0 128zm341.334 0a64 64 0 1 1 0-128 64 64 0 0 1 0 128z"/></svg>

+ 1 - 0
src/assets/icons/example-emotion-unhappy-line.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" class="icon" viewBox="0 0 1024 1024"><path d="M512 938.667C276.352 938.667 85.333 747.648 85.333 512S276.352 85.333 512 85.333 938.667 276.352 938.667 512 747.648 938.667 512 938.667zm0-85.334a341.333 341.333 0 1 0 0-682.666 341.333 341.333 0 0 0 0 682.666zm-213.333-128a213.333 213.333 0 0 1 426.666 0H640a128 128 0 0 0-256 0h-85.333zm42.666-256a64 64 0 1 1 0-128 64 64 0 0 1 0 128zm341.334 0a64 64 0 1 1 0-128 64 64 0 0 1 0 128z"/></svg>

+ 1 - 0
src/assets/icons/example-star.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" class="icon" viewBox="0 0 1024 1024"><path fill="#FFC500" d="M512 768 262.059 899.413a28.444 28.444 0 0 1-41.245-30.009l47.702-278.3L66.332 394.012a28.444 28.444 0 0 1 15.759-48.526l279.438-40.59L486.485 51.684a28.444 28.444 0 0 1 51.03 0L662.47 304.896l279.438 40.59a28.444 28.444 0 0 1 15.759 48.526l-202.184 197.12 47.73 278.272a28.444 28.444 0 0 1-41.273 29.98L512 768z"/><path fill="#FED902" d="M512 768 262.059 899.413a28.444 28.444 0 0 1-41.245-30.009l47.702-278.3c36.124-190.805 67.128-286.208 93.013-286.208 38.827 0 393.955 261.774 393.955 286.208 0 16.299-81.18 75.264-243.484 176.896z"/></svg>

+ 1 - 0
src/assets/icons/example-vip.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" class="icon" viewBox="0 0 1024 1024"><path fill="#FFA100" d="M270.219 121.212h483.474a29.257 29.257 0 0 1 23.347 11.645l188.416 249.885a29.257 29.257 0 0 1-1.843 37.42l-429.67 467.587a29.257 29.257 0 0 1-43.037.059L60.416 421.595a29.257 29.257 0 0 1-1.931-37.39l188.328-251.26a29.257 29.257 0 0 1 23.406-11.703z"/><path fill="#FFC663" d="m768.293 121.212 197.163 261.56a29.257 29.257 0 0 1-1.843 37.39L532.714 889.066a11.703 11.703 0 0 1-20.304-7.9L512 257.025l256.293-135.84z"/><path fill="#FFF" d="M721.598 386.34a29.257 29.257 0 0 1 .995 1.025l22.733 23.873a29.257 29.257 0 0 1 0 40.346l-189.411 198.89-22.733 23.874a29.257 29.257 0 0 1-1.726 1.668l1.755-1.668a29.491 29.491 0 0 1-19.456 9.011 28.935 28.935 0 0 1-18.08-4.915 30.193 30.193 0 0 1-4.857-4.096l1.96 1.872-.965-.877-.995-.995-22.733-23.874-189.41-198.89a29.257 29.257 0 0 1 0-40.375l22.732-23.844a29.257 29.257 0 0 1 42.364 0L512 563.96l168.229-176.596a29.257 29.257 0 0 1 41.37-1.024z"/></svg>

BIN
src/assets/images/bg-main.png


BIN
src/assets/images/logo.png


BIN
src/assets/logo.png


+ 35 - 0
src/assets/styles/globals.css

@@ -0,0 +1,35 @@
+:root {
+  --g-navbar-height: 50px;
+  --g-tabbar-height: 60px;
+
+  color-scheme: light;
+
+  &.dark {
+    color-scheme: dark;
+  }
+}
+
+html {
+  overscroll-behavior: none;
+}
+
+body {
+  box-sizing: border-box;
+  margin: 0;
+}
+
+* {
+  box-sizing: inherit;
+}
+
+/* 全局样式 */
+#app {
+  overflow: hidden auto;
+  font-size: 14px;
+  background-color: var(--g-bg);
+}
+
+.medium-zoom-overlay,
+.medium-zoom-image--opened {
+  z-index: 1100;
+}

+ 63 - 0
src/assets/styles/nprogress.css

@@ -0,0 +1,63 @@
+#nprogress {
+  pointer-events: none;
+
+  .bar {
+    position: fixed;
+    top: 0;
+    left: 0;
+    z-index: 2000;
+    width: 100%;
+    height: 2px;
+    background: rgb(var(--ui-primary));
+  }
+
+  .peg {
+    position: absolute;
+    right: 0;
+    display: block;
+    width: 100px;
+    height: 100%;
+    box-shadow: 0 0 10px rgb(var(--ui-primary)), 0 0 5px rgb(var(--ui-primary));
+    opacity: 1;
+    transform: rotate(3deg) translate(0, -4px);
+  }
+
+  .spinner {
+    position: fixed;
+    top: 11px;
+    right: 14px;
+    z-index: 2000;
+    display: block;
+
+    .spinner-icon {
+      box-sizing: border-box;
+      width: 18px;
+      height: 18px;
+      border: solid 2px transparent;
+      border-top-color: rgb(var(--ui-primary));
+      border-left-color: rgb(var(--ui-primary));
+      border-radius: 50%;
+      animation: nprogress-spinner 400ms linear infinite;
+    }
+  }
+}
+
+.nprogress-custom-parent {
+  position: relative;
+  overflow: hidden;
+
+  #nprogress .spinner,
+  #nprogress .bar {
+    position: absolute;
+  }
+}
+
+@keyframes nprogress-spinner {
+  0% { transform: rotate(0deg); }
+  100% { transform: rotate(360deg); }
+}
+
+@keyframes nprogress-spinner {
+  0% { transform: rotate(0deg); }
+  100% { transform: rotate(360deg); }
+}

+ 55 - 0
src/assets/styles/resources/utils.scss

@@ -0,0 +1,55 @@
+// @mixin 通过 @include 调用使用
+// % 通过 @extend 调用使用
+
+// 文字超出隐藏,默认为单行超出隐藏,可设置多行
+@mixin text-overflow($line: 1, $fixed-width: true) {
+  @if $line == 1 and $fixed-width == true {
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  } @else {
+    display: box;
+    overflow: hidden;
+    -webkit-box-orient: vertical;
+    -webkit-line-clamp: $line;
+  }
+}
+
+// 定位居中,默认水平居中,可选择垂直居中,或者水平垂直都居中
+@mixin position-center($type: x) {
+  position: absolute;
+
+  @if $type == x {
+    left: 50%;
+    transform: translateX(-50%);
+  }
+
+  @if $type == y {
+    top: 50%;
+    transform: translateY(-50%);
+  }
+
+  @if $type == xy {
+    top: 50%;
+    left: 50%;
+    transform: translateX(-50%) translateY(-50%);
+  }
+}
+
+// 文字两端对齐
+%justify-align {
+  text-align: justify;
+  text-align-last: justify;
+}
+
+// 清除浮动
+%clearfix {
+  zoom: 1;
+
+  &::before,
+  &::after {
+    display: block;
+    clear: both;
+    content: "";
+  }
+}

+ 1 - 0
src/assets/styles/resources/variables.scss

@@ -0,0 +1 @@
+// 全局变量

BIN
src/assets/tabbar/about.png


BIN
src/assets/tabbar/about_n.png


BIN
src/assets/tabbar/comp.png


BIN
src/assets/tabbar/comp_n.png


BIN
src/assets/tabbar/home.png


BIN
src/assets/tabbar/home_n.png


+ 147 - 0
src/components/AppSetting/index.vue

@@ -0,0 +1,147 @@
+<script setup lang="ts">
+import { useClipboard } from '@vueuse/core'
+import Message from 'vue-m-message'
+import settingsDefault from '@/settings.default'
+import { getTwoObjectDiff } from '@/utils'
+import eventBus from '@/utils/eventBus'
+import useSettingsStore from '@/store/modules/settings'
+
+defineOptions({
+  name: 'AppSetting',
+})
+
+const settingsStore = useSettingsStore()
+
+const isShow = ref(false)
+
+onMounted(() => {
+  eventBus.on('global-app-setting-toggle', () => {
+    isShow.value = !isShow.value
+  })
+})
+
+const { copy, copied, isSupported } = useClipboard()
+
+watch(copied, (val) => {
+  if (val) {
+    Message.success('复制成功,请粘贴到 src/settings.ts 文件中!', {
+      zIndex: 2000,
+    })
+  }
+})
+
+function handleCopy() {
+  copy(JSON.stringify(getTwoObjectDiff(settingsDefault, settingsStore.settings), null, 2))
+}
+</script>
+
+<template>
+  <HSlideover v-model="isShow" title="应用配置">
+    <div class="rounded-2 bg-rose/20 px-4 py-2 text-sm/6 c-rose">
+      <p class="my-1">
+        应用配置可实时预览效果,但只是临时生效,要想真正应用于项目,可以点击下方的「复制配置」按钮,并将配置粘贴到 src/settings.ts 文件中。
+      </p>
+      <p class="my-1">
+        注意:在生产环境中应关闭该模块。
+      </p>
+    </div>
+    <div>
+      <div class="my-4 flex items-center justify-between gap-4 whitespace-nowrap text-sm font-500 after:(h-[1px] w-full bg-stone-2 content-empty dark-bg-stone-6) before:(h-[1px] w-full bg-stone-2 content-empty dark-bg-stone-6)">
+        颜色主题风格
+      </div>
+      <div class="flex items-center justify-center pb-4">
+        <HTabList
+          v-model="settingsStore.settings.app.colorScheme"
+          :options="[
+            { icon: 'i-ri:sun-line', label: '明亮', value: 'light' },
+            { icon: 'i-ri:moon-line', label: '暗黑', value: 'dark' },
+            { icon: 'i-codicon:color-mode', label: '系统', value: '' },
+          ]"
+          class="w-60"
+        />
+      </div>
+    </div>
+    <div>
+      <div class="my-4 flex items-center justify-between gap-4 whitespace-nowrap text-sm font-500 after:(h-[1px] w-full bg-stone-2 content-empty dark-bg-stone-6) before:(h-[1px] w-full bg-stone-2 content-empty dark-bg-stone-6)">
+        底部版权
+      </div>
+      <div class="flex items-center justify-between gap-4 rounded-2 px-4 py-2">
+        <div class="flex flex-shrink-0 items-center gap-2 text-sm">
+          是否启用
+        </div>
+        <HToggle v-model="settingsStore.settings.copyright.enable" />
+      </div>
+      <div class="flex items-center justify-between gap-4 rounded-2 px-4 py-2">
+        <div class="flex flex-shrink-0 items-center gap-2 text-sm">
+          日期
+        </div>
+        <HInput v-model="settingsStore.settings.copyright.dates" :disabled="!settingsStore.settings.copyright.enable" />
+      </div>
+      <div class="flex items-center justify-between gap-4 rounded-2 px-4 py-2">
+        <div class="flex flex-shrink-0 items-center gap-2 text-sm">
+          公司
+        </div>
+        <HInput v-model="settingsStore.settings.copyright.company" :disabled="!settingsStore.settings.copyright.enable" />
+      </div>
+      <div class="flex items-center justify-between gap-4 rounded-2 px-4 py-2">
+        <div class="flex flex-shrink-0 items-center gap-2 text-sm">
+          网址
+        </div>
+        <HInput v-model="settingsStore.settings.copyright.website" :disabled="!settingsStore.settings.copyright.enable" />
+      </div>
+      <div class="flex items-center justify-between gap-4 rounded-2 px-4 py-2">
+        <div class="flex flex-shrink-0 items-center gap-2 text-sm">
+          备案
+        </div>
+        <HInput v-model="settingsStore.settings.copyright.beian" :disabled="!settingsStore.settings.copyright.enable" />
+      </div>
+    </div>
+    <div>
+      <div class="my-4 flex items-center justify-between gap-4 whitespace-nowrap text-sm font-500 after:(h-[1px] w-full bg-stone-2 content-empty dark-bg-stone-6) before:(h-[1px] w-full bg-stone-2 content-empty dark-bg-stone-6)">
+        其它
+      </div>
+      <div class="flex items-center justify-between gap-4 rounded-2 px-4 py-2">
+        <div class="flex flex-shrink-0 items-center gap-2 text-sm">
+          是否启用权限
+        </div>
+        <HToggle v-model="settingsStore.settings.app.enablePermission" />
+      </div>
+      <div class="flex items-center justify-between gap-4 rounded-2 px-4 py-2">
+        <div class="flex flex-shrink-0 items-center gap-2 text-sm">
+          载入进度条
+        </div>
+        <HToggle v-model="settingsStore.settings.app.enableProgress" />
+      </div>
+      <div class="flex items-center justify-between gap-4 rounded-2 px-4 py-2">
+        <div class="flex flex-shrink-0 items-center gap-2 text-sm">
+          哀悼模式
+        </div>
+        <HToggle v-model="settingsStore.settings.app.enableMournMode" />
+      </div>
+      <div class="flex items-center justify-between gap-4 rounded-2 px-4 py-2">
+        <div class="flex flex-shrink-0 items-center gap-2 text-sm">
+          色弱模式
+        </div>
+        <HToggle v-model="settingsStore.settings.app.enableColorAmblyopiaMode" />
+      </div>
+      <div class="flex items-center justify-between gap-4 rounded-2 px-4 py-2">
+        <div class="flex flex-shrink-0 items-center gap-2 text-sm">
+          返回顶部
+        </div>
+        <HToggle v-model="settingsStore.settings.app.enableBackTop" />
+      </div>
+      <div class="flex items-center justify-between gap-4 rounded-2 px-4 py-2">
+        <div class="flex flex-shrink-0 items-center gap-2 text-sm">
+          动态标题
+        </div>
+        <HToggle v-model="settingsStore.settings.app.enableDynamicTitle" />
+      </div>
+    </div>
+    <template v-if="isSupported" #footer>
+      <HButton block @click="handleCopy">
+        <SvgIcon name="i-ep:document-copy" />
+        复制配置
+      </HButton>
+    </template>
+  </HSlideover>
+</template>

+ 20 - 0
src/components/Auth/index.vue

@@ -0,0 +1,20 @@
+<script setup lang="ts">
+defineOptions({
+  name: 'Auth',
+})
+
+const props = defineProps<{
+  value: string | string[]
+}>()
+
+function check() {
+  return useAuth().auth(props.value)
+}
+</script>
+
+<template>
+  <div>
+    <slot v-if="check()" />
+    <slot v-else name="no-auth" />
+  </div>
+</template>

+ 20 - 0
src/components/AuthAll/index.vue

@@ -0,0 +1,20 @@
+<script setup lang="ts">
+defineOptions({
+  name: 'AuthAll',
+})
+
+const props = defineProps<{
+  value: string[]
+}>()
+
+function check() {
+  return useAuth().authAll(props.value)
+}
+</script>
+
+<template>
+  <div>
+    <slot v-if="check()" />
+    <slot v-else name="no-auth" />
+  </div>
+</template>

+ 49 - 0
src/components/NotAllowed/index.vue

@@ -0,0 +1,49 @@
+<script setup lang="ts">
+defineOptions({
+  name: 'NotAllowed',
+})
+
+const router = useRouter()
+
+const data = ref({
+  inter: Number.NaN,
+  countdown: 5,
+})
+
+onUnmounted(() => {
+  data.value.inter && window.clearInterval(data.value.inter)
+})
+
+onMounted(() => {
+  data.value.inter = window.setInterval(() => {
+    data.value.countdown--
+    if (data.value.countdown === 0) {
+      data.value.inter && window.clearInterval(data.value.inter)
+      goBack()
+    }
+  }, 1000)
+})
+
+function goBack() {
+  router.push('/')
+}
+</script>
+
+<template>
+  <div class="min-h-screen flex flex-col items-center justify-center">
+    <SvgIcon name="403" class="text-[300px] -mt-9xl" />
+    <div class="flex flex-col items-center gap-4">
+      <h1 class="m-0 text-6xl font-sans">
+        403
+      </h1>
+      <div class="mx-0 text-xl text-stone-5">
+        抱歉,你无权访问该页面
+      </div>
+      <div>
+        <HButton @click="goBack">
+          {{ data.countdown }} 秒后,返回首页
+        </HButton>
+      </div>
+    </div>
+  </div>
+</template>

+ 259 - 0
src/components/PageLayout/index.vue

@@ -0,0 +1,259 @@
+<script setup lang="ts">
+import { useElementSize } from '@vueuse/core'
+import useSettingsStore from '@/store/modules/settings'
+
+defineOptions({
+  name: 'PageLayout',
+})
+
+withDefaults(
+  defineProps<{
+    /** 是否启用导航栏,默认使用应用配置 `navbar.enable` */
+    navbar?: boolean
+    /** 是否启用标签栏,默认使用应用配置 `tabbar.enable` */
+    tabbar?: boolean
+    /** 是否展示底部版权信息,默认使用应用配置 `copyright.enable` */
+    copyright?: boolean
+    /** 是否启用返回顶部按钮,默认使用应用配置 `app.enableBackTop` */
+    backTop?: boolean
+  }>(),
+  {
+    navbar: undefined,
+    tabbar: undefined,
+    copyright: undefined,
+    backTop: undefined,
+  },
+)
+
+const emits = defineEmits<{
+  scroll: [Event]
+  reachTop: []
+  reachBottom: []
+}>()
+
+const route = useRoute()
+const settingsStore = useSettingsStore()
+
+const layoutRef = ref()
+defineExpose({
+  ref: layoutRef,
+})
+function handleMainScroll(e: Event) {
+  handleNavbarScroll()
+  handleTabbarScroll()
+  handleBackTopScroll()
+  emits('scroll', e)
+  if ((e.target as HTMLElement).scrollTop === 0) {
+    emits('reachTop')
+  }
+  if (Math.ceil((e.target as HTMLElement).scrollTop + (e.target as HTMLElement).clientHeight) >= (e.target as HTMLElement).scrollHeight) {
+    emits('reachBottom')
+  }
+}
+onMounted(() => {
+  handleNavbarScroll()
+  handleTabbarScroll()
+  handleBackTopScroll()
+})
+onActivated(() => {
+  handleNavbarScroll()
+  handleTabbarScroll()
+  handleBackTopScroll()
+})
+
+// Navbar
+// 计算出左右两侧的最大宽度,让左右两侧的宽度保持一致
+const startSideRef = ref()
+const endSideRef = ref()
+const sideWidth = ref(0)
+onMounted(() => {
+  const { width: startWidth } = useElementSize(startSideRef, undefined, { box: 'border-box' })
+  const { width: endWidth } = useElementSize(endSideRef, undefined, { box: 'border-box' })
+  watch([startWidth, endWidth], (val) => {
+    sideWidth.value = Math.max(...val)
+  }, {
+    immediate: true,
+  })
+})
+const navbarScrollTop = ref(0)
+function handleNavbarScroll() {
+  navbarScrollTop.value = layoutRef.value.scrollTop
+}
+
+// Tabbar
+const showTabbarShadow = ref(false)
+function handleTabbarScroll() {
+  const scrollTop = layoutRef.value.scrollTop
+  const clientHeight = layoutRef.value.clientHeight
+  const scrollHeight = layoutRef.value.scrollHeight
+  showTabbarShadow.value = Math.ceil(scrollTop + clientHeight) < scrollHeight
+}
+const tabbarList = computed(() => {
+  if (settingsStore.settings.tabbar.list.length > 0) {
+    return settingsStore.settings.tabbar.list
+  }
+  return []
+})
+function getIcon(item: any) {
+  if (route.fullPath === item.path) {
+    return item.activeIcon ?? item.icon ?? undefined
+  }
+  else {
+    return item.icon ?? undefined
+  }
+}
+
+// 返回顶部
+const backTopScrollTop = ref(0)
+function handleBackTopScroll() {
+  backTopScrollTop.value = layoutRef.value.scrollTop
+}
+function handleBackTopClick() {
+  layoutRef.value.scrollTo({
+    top: 0,
+    behavior: 'smooth',
+  })
+}
+</script>
+
+<template>
+  <div ref="layoutRef" class="relative h-vh flex flex-col overflow-auto overscroll-none supports-[(height:100dvh)]:h-dvh" @scroll="handleMainScroll">
+    <!-- Navbar -->
+    <header
+      v-show="navbar ?? settingsStore.settings.navbar.enable" class="navbar w-full flex-center bg-[var(--g-navbar-bg)] text-[var(--g-navbar-color)] transition-all pt-safe h+safe-t-[var(--g-navbar-height)]" :class="{
+        'shadow-top': navbarScrollTop,
+      }"
+    >
+      <div
+        class="h-full flex items-center justify-start" :style="{
+          ...(sideWidth && { width: `${sideWidth}px` }),
+        }"
+      >
+        <div ref="startSideRef" class="h-full flex-center whitespace-nowrap">
+          <div class="h-full flex-center whitespace-nowrap px-2">
+            <slot name="navbar-start" />
+          </div>
+        </div>
+      </div>
+      <div class="min-w-0 flex-1 text-center text-sm">
+        <div class="truncate">
+          {{ settingsStore.title }}
+        </div>
+      </div>
+      <div
+        class="h-full flex items-center justify-end" :style="{
+          ...(sideWidth && { width: `${sideWidth}px` }),
+        }"
+      >
+        <div ref="endSideRef" class="h-full flex-center whitespace-nowrap">
+          <div class="h-full flex-center whitespace-nowrap px-2">
+            <slot name="navbar-end" />
+          </div>
+        </div>
+      </div>
+    </header>
+    <div
+      class="relative flex flex-1 flex-col transition-margin" :class="{
+        'mt+safe-[var(--g-navbar-height)]': navbar ?? settingsStore.settings.navbar.enable,
+        'mb+safe-[var(--g-tabbar-height)]': tabbar ?? settingsStore.settings.tabbar.enable,
+      }"
+    >
+      <slot />
+      <!-- 版权信息 -->
+      <Transition
+        v-bind="{
+          enterActiveClass: 'ease-out',
+          enterFromClass: 'opacity-0',
+          enterToClass: 'opacity-100',
+          leaveActiveClass: 'ease-in',
+          leaveFromClass: 'opacity-100',
+          leaveToClass: 'opacity-0',
+        }"
+      >
+        <div v-if="copyright ?? settingsStore.settings.copyright.enable" class="copyright relative flex flex-wrap items-center justify-center p-4 text-sm text-stone-5 mix-blend-difference">
+          <span class="px-1">Copyright</span>
+          <SvgIcon name="i-ri:copyright-line" class="text-lg" />
+          <span v-if="settingsStore.settings.copyright.dates" class="px-1">{{ settingsStore.settings.copyright.dates }}</span>
+          <template v-if="settingsStore.settings.copyright.company">
+            <a v-if="settingsStore.settings.copyright.website" :href="settingsStore.settings.copyright.website" target="_blank" rel="noopener" class="px-1 text-center text-stone-5 no-underline">{{ settingsStore.settings.copyright.company }}</a>
+            <span v-else class="px-1">{{ settingsStore.settings.copyright.company }}</span>
+          </template>
+          <a v-if="settingsStore.settings.copyright.beian" href="https://beian.miit.gov.cn/" target="_blank" rel="noopener" class="px-1 text-center text-stone-5 no-underline">{{ settingsStore.settings.copyright.beian }}</a>
+        </div>
+      </Transition>
+    </div>
+    <!-- Tabbar -->
+    <footer
+      v-show="tabbar ?? settingsStore.settings.tabbar.enable" class="tabbar w-full bg-[var(--g-tabbar-bg)] transition-all pb-safe h+safe-b-[calc(var(--g-tabbar-height))]" :class="{
+        'shadow-bottom': showTabbarShadow,
+      }"
+    >
+      <div class="h-full flex-center px-4">
+        <slot name="tabbar">
+          <RouterLink
+            v-for="item in tabbarList" :key="JSON.stringify(item)" class="flex flex-1 flex-col items-center gap-[2px] text-[var(--g-tabbar-color)] no-underline transition-all" :class="{
+              'text-[var(--g-tabbar-active-color)]!': route.fullPath === item.path,
+            }" :to="item.path" replace
+          >
+            <SvgIcon v-if="getIcon(item)" :name="getIcon(item) ?? ''" :class="item.text ? 'text-6' : 'text-8'" />
+            <div v-if="item.text" class="text-xs">
+              {{ item.text }}
+            </div>
+          </RouterLink>
+        </slot>
+      </div>
+    </footer>
+    <!-- 返回顶部 -->
+    <Transition
+      v-bind="{
+        enterActiveClass: 'ease-out duration-300',
+        enterFromClass: 'opacity-0 translate-y-4',
+        enterToClass: 'opacity-100 translate-y-0',
+        leaveActiveClass: 'ease-in duration-200',
+        leaveFromClass: 'opacity-100 scale-100',
+        leaveToClass: 'opacity-0 scale-50',
+      }"
+    >
+      <div
+        v-if="(backTop ?? settingsStore.settings.app.enableBackTop) && backTopScrollTop >= 200" class="backtop h-12 w-12 flex cursor-pointer items-center justify-center rounded-full bg-white shadow-lg ring-1 ring-stone-3 ring-inset active:bg-stone-1 dark-bg-dark dark-ring-stone-7 dark-active:bg-stone-9" :class="{
+          'bottom+safe-[calc(var(--g-tabbar-height)+16px)]!': tabbar ?? settingsStore.settings.tabbar.enable,
+        }" @click="handleBackTopClick"
+      >
+        <SvgIcon name="i-icon-park-outline:to-top-one" class="text-6" />
+      </div>
+    </Transition>
+  </div>
+</template>
+
+<style scoped>
+.navbar {
+  position: fixed;
+  top: 0;
+  left: 0;
+  z-index: 1000;
+  width: 100%;
+
+  &.shadow-top {
+    box-shadow: 0 10px 10px -10px var(--g-border-color);
+  }
+}
+
+.tabbar {
+  position: fixed;
+  bottom: 0;
+  left: 0;
+  z-index: 1000;
+  width: 100%;
+
+  &.shadow-bottom {
+    box-shadow: 0 -10px 10px -10px var(--g-border-color);
+  }
+}
+
+.backtop {
+  position: fixed;
+  right: 16px;
+  bottom: 16px;
+  z-index: 1000;
+}
+</style>

+ 47 - 0
src/components/PageMain/index.vue

@@ -0,0 +1,47 @@
+<script setup lang="ts">
+defineOptions({
+  name: 'PageMain',
+})
+
+const props = withDefaults(
+  defineProps<{
+    title?: string
+    collaspe?: boolean
+    height?: string
+  }>(),
+  {
+    title: '',
+    collaspe: false,
+    height: '',
+  },
+)
+
+const titleSlot = !!useSlots().title
+
+const isCollaspe = ref(props.collaspe)
+function unCollaspe() {
+  isCollaspe.value = false
+}
+</script>
+
+<template>
+  <div
+    class="page-main relative m-4 flex flex-col bg-[var(--g-container-bg)] transition-background-color-300" :class="{
+      'of-hidden': isCollaspe,
+    }" :style="{
+      height: isCollaspe ? height : '',
+    }"
+  >
+    <div v-if="titleSlot || title" class="title-container border-b-1 border-b-[var(--g-bg)] border-b-solid px-4 py-3 transition-border-color-300">
+      <slot name="title">
+        {{ title }}
+      </slot>
+    </div>
+    <div class="main-container p-4">
+      <slot />
+    </div>
+    <div v-if="isCollaspe" class="collaspe absolute bottom-0 w-full cursor-pointer from-transparent to-[var(--g-container-bg)] bg-gradient-to-b pb-2 pt-10 text-center" @click="unCollaspe">
+      <SvgIcon name="i-ep:arrow-down" class="text-xl op-30 transition-opacity hover-op-100" />
+    </div>
+  </div>
+</template>

+ 78 - 0
src/components/SvgIcon/index.vue

@@ -0,0 +1,78 @@
+<script setup lang="ts">
+import { UseImage } from '@vueuse/components'
+import { Icon } from '@iconify/vue'
+
+defineOptions({
+  name: 'SvgIcon',
+})
+
+const props = defineProps<{
+  name: string
+  flip?: 'horizontal' | 'vertical' | 'both'
+  rotate?: number
+  color?: string
+  size?: string | number
+}>()
+
+const outputType = computed(() => {
+  const hasPathFeatures = (str: string) => {
+    return /^\.{1,2}\//.test(str) || str.startsWith('/') || str.includes('/')
+  }
+  if (/^https?:\/\//.test(props.name) || hasPathFeatures(props.name) || !props.name) {
+    return 'img'
+  }
+  else if (/i-[^:]+:[^:]+/.test(props.name)) {
+    return 'unocss'
+  }
+  else if (props.name.includes(':')) {
+    return 'iconify'
+  }
+  else {
+    return 'svg'
+  }
+})
+
+const style = computed(() => {
+  const transform = []
+  if (props.flip) {
+    switch (props.flip) {
+      case 'horizontal':
+        transform.push('rotateY(180deg)')
+        break
+      case 'vertical':
+        transform.push('rotateX(180deg)')
+        break
+      case 'both':
+        transform.push('rotateX(180deg)')
+        transform.push('rotateY(180deg)')
+        break
+    }
+  }
+  if (props.rotate) {
+    transform.push(`rotate(${props.rotate % 360}deg)`)
+  }
+  return {
+    ...(props.color && { color: props.color }),
+    ...(props.size && { fontSize: typeof props.size === 'number' ? `${props.size}px` : props.size }),
+    ...(transform.length && { transform: transform.join(' ') }),
+  }
+})
+</script>
+
+<template>
+  <i class="relative h-[1em] w-[1em] flex-inline items-center justify-center fill-current leading-[1em]" :style="style">
+    <i v-if="outputType === 'unocss'" class="h-[1em] w-[1em]" :class="name" />
+    <Icon v-else-if="outputType === 'iconify'" :icon="name" />
+    <svg v-else-if="outputType === 'svg'" class="h-[1em] w-[1em]" aria-hidden="true">
+      <use :xlink:href="`#icon-${name}`" />
+    </svg>
+    <UseImage v-else-if="outputType === 'img'" :src="name" class="h-[1em] w-[1em]">
+      <template #loading>
+        <i class="i-line-md:loading-loop h-[1em] w-[1em]" />
+      </template>
+      <template #error>
+        <i class="i-tdesign:image-error h-[1em] w-[1em]" />
+      </template>
+    </UseImage>
+  </i>
+</template>

+ 38 - 0
src/components/Trend/index.vue

@@ -0,0 +1,38 @@
+<script setup lang="ts">
+defineOptions({
+  name: 'Trend',
+})
+
+const props = withDefaults(
+  defineProps<{
+    value: string
+    type?: 'up' | 'down'
+    prefix?: string
+    suffix?: string
+    reverse?: boolean
+  }>(),
+  {
+    type: 'up',
+    prefix: '',
+    suffix: '',
+    reverse: false,
+  },
+)
+
+const isUp = computed(() => {
+  let isUp = props.type === 'up'
+  if (props.reverse) {
+    isUp = !isUp
+  }
+  return isUp
+})
+</script>
+
+<template>
+  <div class="flex items-center transition" :class="`${isUp ? 'c-green' : 'c-red'}`">
+    <span v-if="prefix" class="prefix">{{ prefix }}</span>
+    <span class="text">{{ value }}</span>
+    <span v-if="suffix" class="suffix">{{ suffix }}</span>
+    <SvgIcon name="i-ep:caret-top" :rotate="isUp ? 0 : 180" class="ml-1 transition" />
+  </div>
+</template>

+ 65 - 0
src/components/VanFieldCalendar/index.vue

@@ -0,0 +1,65 @@
+<script setup lang="ts">
+import type { CalendarProps, FieldProps } from 'vant'
+import { pick } from 'lodash-es'
+import dayjs from '@/utils/dayjs'
+
+defineOptions({
+  name: 'VanFieldCalendar',
+})
+
+const props = withDefaults(
+  defineProps<{
+    // field
+    label?: FieldProps['label']
+    name?: FieldProps['name']
+    id?: FieldProps['id']
+    size?: FieldProps['size']
+    placeholder?: FieldProps['placeholder']
+    border?: FieldProps['border']
+    colon?: FieldProps['colon']
+    required?: FieldProps['required']
+    center?: FieldProps['center']
+    arrowDirection?: FieldProps['arrowDirection']
+    labelClass?: FieldProps['labelClass']
+    labelWidth?: FieldProps['labelWidth']
+    labelAlign?: FieldProps['labelAlign']
+    leftIcon?: FieldProps['leftIcon']
+    rightIcon?: FieldProps['rightIcon']
+    rules?: FieldProps['rules']
+    // calendar
+    color?: CalendarProps['color']
+    minDate?: CalendarProps['minDate']
+    maxDate?: CalendarProps['maxDate']
+    formatter?: CalendarProps['formatter']
+    showConfirm?: CalendarProps['showConfirm']
+    confirmText?: CalendarProps['confirmText']
+    firstDayOfWeek?: CalendarProps['firstDayOfWeek']
+    round?: CalendarProps['round']
+    // 自定义
+    format?: string
+    valueFormat?: string
+  }>(),
+  {
+    format: 'YYYY-MM-DD',
+    valueFormat: '',
+  },
+)
+
+const fieldProps = computed(() => pick(props, ['label', 'name', 'id', 'size', 'placeholder', 'border', 'colon', 'required', 'center', 'arrowDirection', 'labelClass', 'labelWidth', 'labelAlign', 'leftIcon', 'rightIcon', 'rules']))
+const calendarProps = computed(() => pick(props, ['color', 'minDate', 'maxDate', 'formatter', 'showConfirm', 'confirmText', 'firstDayOfWeek', 'round']))
+
+const value = defineModel<string>()
+const valueStr = computed(() => value.value && dayjs(value.value).format(props.format))
+const valueDate = computed(() => dayjs(value.value).toDate())
+const showCalendar = ref(false)
+
+function onConfirm(date: Date) {
+  value.value = dayjs(date).format(props.valueFormat)
+  showCalendar.value = false
+}
+</script>
+
+<template>
+  <van-field :model-value="valueStr" v-bind="fieldProps" is-link readonly @click="showCalendar = true" />
+  <van-calendar v-model:show="showCalendar" v-bind="calendarProps" :default-date="valueDate" teleport="body" @confirm="onConfirm" />
+</template>

+ 58 - 0
src/components/VanFieldDatePicker/index.vue

@@ -0,0 +1,58 @@
+<script setup lang="ts">
+import type { DatePickerProps, FieldProps, PopupProps } from 'vant'
+import { pick } from 'lodash-es'
+
+defineOptions({
+  name: 'VanFieldDatePicker',
+})
+
+const props = defineProps<{
+  // field
+  label?: FieldProps['label']
+  name?: FieldProps['name']
+  id?: FieldProps['id']
+  size?: FieldProps['size']
+  placeholder?: FieldProps['placeholder']
+  border?: FieldProps['border']
+  colon?: FieldProps['colon']
+  required?: FieldProps['required']
+  center?: FieldProps['center']
+  arrowDirection?: FieldProps['arrowDirection']
+  labelClass?: FieldProps['labelClass']
+  labelWidth?: FieldProps['labelWidth']
+  labelAlign?: FieldProps['labelAlign']
+  leftIcon?: FieldProps['leftIcon']
+  rightIcon?: FieldProps['rightIcon']
+  rules?: FieldProps['rules']
+  // popup
+  round?: PopupProps['round']
+  // date-picker
+  columnsType?: DatePickerProps['columnsType']
+  minDate?: DatePickerProps['minDate']
+  maxDate?: DatePickerProps['maxDate']
+  formatter?: DatePickerProps['formatter']
+}>()
+
+const fieldProps = computed(() => pick(props, ['label', 'name', 'id', 'size', 'placeholder', 'border', 'colon', 'required', 'center', 'arrowDirection', 'labelClass', 'labelWidth', 'labelAlign', 'leftIcon', 'rightIcon', 'rules']))
+const popupProps = computed(() => pick(props, ['round']))
+const datePickerProps = computed(() => pick(props, ['columnsType', 'minDate', 'maxDate', 'formatter']))
+
+const value = defineModel<string[]>()
+const valueDate = ref<string[]>(value.value ?? [])
+const valueStr = computed(() => {
+  return value.value ? value.value.join('-') : ''
+})
+
+const showPicker = ref(false)
+function onConfirm({ selectedValues }: { selectedValues: string[] }) {
+  value.value = selectedValues
+  showPicker.value = false
+}
+</script>
+
+<template>
+  <van-field :model-value="valueStr" v-bind="fieldProps" is-link readonly @click="showPicker = true" />
+  <van-popup v-model:show="showPicker" v-bind="popupProps" position="bottom" teleport="body">
+    <van-date-picker v-model="valueDate" v-bind="datePickerProps" @confirm="onConfirm" @cancel="showPicker = false" />
+  </van-popup>
+</template>

+ 51 - 0
src/components/VanFieldPicker/index.vue

@@ -0,0 +1,51 @@
+<script setup lang="ts">
+import type { FieldProps, PickerOption, PopupProps } from 'vant'
+import { pick } from 'lodash-es'
+
+defineOptions({
+  name: 'VanFieldPicker',
+})
+
+const props = defineProps<{
+  // field
+  label?: FieldProps['label']
+  name?: FieldProps['name']
+  id?: FieldProps['id']
+  type?: FieldProps['type']
+  size?: FieldProps['size']
+  placeholder?: FieldProps['placeholder']
+  border?: FieldProps['border']
+  colon?: FieldProps['colon']
+  required?: FieldProps['required']
+  center?: FieldProps['center']
+  arrowDirection?: FieldProps['arrowDirection']
+  labelClass?: FieldProps['labelClass']
+  labelWidth?: FieldProps['labelWidth']
+  labelAlign?: FieldProps['labelAlign']
+  autosize?: FieldProps['autosize']
+  leftIcon?: FieldProps['leftIcon']
+  rightIcon?: FieldProps['rightIcon']
+  rules?: FieldProps['rules']
+  // popup
+  round?: PopupProps['round']
+  // picker
+  columns?: PickerOption[]
+}>()
+
+const fieldProps = computed(() => pick(props, ['label', 'name', 'id', 'type', 'size', 'placeholder', 'border', 'colon', 'required', 'center', 'arrowDirection', 'labelClass', 'labelWidth', 'labelAlign', 'autosize', 'leftIcon', 'rightIcon', 'rules']))
+const popupProps = computed(() => pick(props, ['round']))
+const pickerProps = computed(() => pick(props, ['columns']))
+
+const value = defineModel<string | number>()
+const valuePicker = ref<any>([value.value])
+const valueStr = computed(() => props.columns?.find((item: any) => item.value === value.value)?.text)
+
+const showPicker = ref(false)
+</script>
+
+<template>
+  <van-field :model-value="valueStr" v-bind="fieldProps" is-link readonly @click="showPicker = true" />
+  <van-popup v-model:show="showPicker" v-bind="popupProps" position="bottom" teleport="body">
+    <van-picker :model-value="valuePicker" v-bind="pickerProps" @confirm="({ selectedOptions }) => { value = selectedOptions[0]?.value; showPicker = false }" @cancel="showPicker = false" />
+  </van-popup>
+</template>

+ 47 - 0
src/mock/user.ts

@@ -0,0 +1,47 @@
+import { defineFakeRoute } from 'vite-plugin-fake-server/client'
+import Mock from 'mockjs'
+
+export default defineFakeRoute([
+  {
+    url: '/mock/user/login',
+    method: 'post',
+    response: ({ body }) => {
+      return {
+        error: '',
+        status: 1,
+        data: Mock.mock({
+          account: body.account,
+          token: `${body.account}_@string`,
+          avatar: 'https://fantastic-mobile.hurui.me/logo.png',
+        }),
+      }
+    },
+  },
+  {
+    url: '/mock/user/permission',
+    method: 'get',
+    response: ({ headers }) => {
+      let permissions: string[] = []
+      if (headers.token?.indexOf('admin') === 0) {
+        permissions = [
+          'permission.browse',
+          'permission.create',
+          'permission.edit',
+          'permission.remove',
+        ]
+      }
+      else if (headers.token?.indexOf('test') === 0) {
+        permissions = [
+          'permission.browse',
+        ]
+      }
+      return {
+        error: '',
+        status: 1,
+        data: {
+          permissions,
+        },
+      }
+    },
+  },
+])

+ 132 - 0
src/router/guards.ts

@@ -0,0 +1,132 @@
+import type { Router } from 'vue-router/auto'
+import { useNProgress } from '@vueuse/integrations/useNProgress'
+import '@/assets/styles/nprogress.css'
+import useSettingsStore from '@/store/modules/settings'
+import useUserStore from '@/store/modules/user'
+import useKeepAliveStore from '@/store/modules/keepAlive'
+
+// 鉴权
+function setupAuth(router: Router) {
+  router.beforeEach(async (to, from, next) => {
+    const settingsStore = useSettingsStore()
+    const userStore = useUserStore()
+    if (to.meta.auth) {
+      if (userStore.isLogin) {
+        // 获取用户权限
+        if (settingsStore.settings.app.enablePermission) {
+          !userStore.isGetPermissions && await userStore.getPermissions()
+        }
+        next()
+      }
+      else {
+        next({
+          name: 'login',
+          query: {
+            redirect: to.fullPath,
+          },
+        })
+      }
+    }
+    else {
+      next()
+    }
+  })
+}
+
+// 进度条
+function setupProgress(router: Router) {
+  const { isLoading } = useNProgress(null, {
+    showSpinner: false,
+    parent: '#app',
+  })
+  router.beforeEach((to, from, next) => {
+    const settingsStore = useSettingsStore()
+    if (settingsStore.settings.app.enableProgress) {
+      isLoading.value = true
+    }
+    next()
+  })
+  router.afterEach(() => {
+    const settingsStore = useSettingsStore()
+    if (settingsStore.settings.app.enableProgress) {
+      isLoading.value = false
+    }
+  })
+}
+
+// 标题
+function setupTitle(router: Router) {
+  router.afterEach((to) => {
+    const settingsStore = useSettingsStore()
+    settingsStore.setTitle(to.meta.title ?? '')
+  })
+}
+
+// 页面缓存
+function setupKeepAlive(router: Router) {
+  router.afterEach((to, from) => {
+    const keepAliveStore = useKeepAliveStore()
+    if (to.fullPath !== from.fullPath) {
+      // 判断当前页面是否开启缓存,如果开启,则将当前页面的 name 信息存入 keep-alive 全局状态
+      if (to.meta.cache) {
+        const componentName = to.matched.at(-1)?.components?.default.name
+        if (componentName) {
+          keepAliveStore.add(componentName)
+        }
+        else {
+          // turbo-console-disable-next-line
+          console.warn('[Fantastic-mobile] 该页面组件未设置组件名,会导致缓存失效,请检查')
+        }
+      }
+      // 判断离开页面是否开启缓存,如果开启,则根据缓存规则判断是否需要清空 keep-alive 全局状态里离开页面的 name 信息
+      if (from.meta.cache) {
+        const componentName = from.matched.at(-1)?.components?.default.name
+        if (componentName) {
+        // 通过 meta.cache 判断针对哪些页面进行缓存
+          switch (typeof from.meta.cache) {
+            case 'string':
+              if (from.meta.cache !== to.name) {
+                keepAliveStore.remove(componentName)
+              }
+              break
+            case 'object':
+              if (!from.meta.cache.includes(to.name)) {
+                keepAliveStore.remove(componentName)
+              }
+              break
+          }
+          // 通过 meta.noCache 判断针对哪些页面不需要进行缓存
+          if (from.meta.noCache) {
+            switch (typeof from.meta.noCache) {
+              case 'string':
+                if (from.meta.noCache === to.name) {
+                  keepAliveStore.remove(componentName)
+                }
+                break
+              case 'object':
+                if (from.meta.noCache.includes(to.name)) {
+                  keepAliveStore.remove(componentName)
+                }
+                break
+            }
+          }
+        }
+      }
+    }
+  })
+}
+
+// 其他
+function setupOther(router: Router) {
+  router.afterEach(() => {
+    document.documentElement.scrollTop = 0
+  })
+}
+
+export default function setupGuards(router: Router) {
+  setupAuth(router)
+  setupProgress(router)
+  setupTitle(router)
+  setupKeepAlive(router)
+  setupOther(router)
+}

+ 29 - 0
src/settings.default.ts

@@ -0,0 +1,29 @@
+// 该文件为系统默认配置,请勿修改!!!
+
+const globalSettingsDefault: RecursiveRequired<Settings.all> = {
+  app: {
+    colorScheme: 'light',
+    enableMournMode: false,
+    enableColorAmblyopiaMode: false,
+    enablePermission: false,
+    enableProgress: true,
+    enableDynamicTitle: false,
+    enableBackTop: true,
+  },
+  navbar: {
+    enable: false,
+  },
+  tabbar: {
+    enable: false,
+    list: [],
+  },
+  copyright: {
+    enable: false,
+    dates: '',
+    company: '',
+    website: '',
+    beian: '',
+  },
+}
+
+export default globalSettingsDefault

+ 24 - 0
src/settings.ts

@@ -0,0 +1,24 @@
+import { defaultsDeep } from 'lodash-es'
+import settingsDefault from '@/settings.default'
+
+const globalSettings: Settings.all = {
+  // 请在此处编写或粘贴配置代码
+  tabbar: {
+    list: [
+      {
+        path: '/',
+        icon: 'i-ic:sharp-home',
+        activeIcon: 'i-ic:twotone-home',
+        text: '主页',
+      },
+      {
+        path: '/user',
+        icon: 'i-ic:baseline-person',
+        activeIcon: 'i-ic:twotone-person',
+        text: '我的',
+      },
+    ],
+  },
+}
+
+export default defaultsDeep(globalSettings, settingsDefault) as RecursiveRequired<Settings.all>

+ 42 - 0
src/store/modules/keepAlive.ts

@@ -0,0 +1,42 @@
+const useKeepAliveStore = defineStore(
+  // 唯一ID
+  'keepAlive',
+  () => {
+    const list = ref<string[]>([])
+
+    function add(name: string | string[]) {
+      if (typeof name === 'string') {
+        !list.value.includes(name) && list.value.push(name)
+      }
+      else {
+        name.forEach((v) => {
+          v && !list.value.includes(v) && list.value.push(v)
+        })
+      }
+    }
+    function remove(name: string | string[]) {
+      if (typeof name === 'string') {
+        list.value = list.value.filter((v) => {
+          return v !== name
+        })
+      }
+      else {
+        list.value = list.value.filter((v) => {
+          return !name.includes(v)
+        })
+      }
+    }
+    function clean() {
+      list.value = []
+    }
+
+    return {
+      list,
+      add,
+      remove,
+      clean,
+    }
+  },
+)
+
+export default useKeepAliveStore

+ 72 - 0
src/store/modules/settings.ts

@@ -0,0 +1,72 @@
+import settingsDefault from '@/settings'
+
+const useSettingsStore = defineStore(
+  // 唯一ID
+  'settings',
+  () => {
+    const settings = ref(settingsDefault)
+
+    const prefersColorScheme = window.matchMedia('(prefers-color-scheme: dark)')
+    const currentColorScheme = ref<Exclude<Settings.app['colorScheme'], ''>>()
+    watch(() => settings.value.app.colorScheme, (val) => {
+      if (val === '') {
+        prefersColorScheme.addEventListener('change', updateTheme)
+      }
+      else {
+        prefersColorScheme.removeEventListener('change', updateTheme)
+      }
+    }, {
+      immediate: true,
+    })
+    watch(() => settings.value.app.colorScheme, updateTheme, {
+      immediate: true,
+    })
+    function updateTheme() {
+      let colorScheme = settings.value.app.colorScheme
+      if (colorScheme === '') {
+        colorScheme = prefersColorScheme.matches ? 'dark' : 'light'
+      }
+      currentColorScheme.value = colorScheme
+      switch (colorScheme) {
+        case 'light':
+          document.documentElement.classList.remove('dark')
+          break
+        case 'dark':
+          document.documentElement.classList.add('dark')
+          break
+      }
+    }
+    watch([
+      () => settings.value.app.enableMournMode,
+      () => settings.value.app.enableColorAmblyopiaMode,
+    ], (val) => {
+      document.documentElement.style.removeProperty('filter')
+      if (val[0] && val[1]) {
+        document.documentElement.style.setProperty('filter', 'grayscale(100%) invert(80%)')
+      }
+      else if (val[0]) {
+        document.documentElement.style.setProperty('filter', 'grayscale(100%)')
+      }
+      else if (val[1]) {
+        document.documentElement.style.setProperty('filter', 'invert(80%)')
+      }
+    }, {
+      immediate: true,
+    })
+
+    const title = ref('')
+    // 设置网页标题
+    function setTitle(val: string) {
+      title.value = val
+    }
+
+    return {
+      settings,
+      currentColorScheme,
+      title,
+      setTitle,
+    }
+  },
+)
+
+export default useSettingsStore

+ 87 - 0
src/types/auto-imports.d.ts

@@ -0,0 +1,87 @@
+/* eslint-disable */
+/* prettier-ignore */
+// @ts-nocheck
+// noinspection JSUnusedGlobalSymbols
+// Generated by unplugin-auto-import
+export {}
+declare global {
+  const EffectScope: typeof import('vue')['EffectScope']
+  const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
+  const computed: typeof import('vue')['computed']
+  const createApp: typeof import('vue')['createApp']
+  const createPinia: typeof import('pinia')['createPinia']
+  const customRef: typeof import('vue')['customRef']
+  const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
+  const defineComponent: typeof import('vue')['defineComponent']
+  const definePage: typeof import('unplugin-vue-router/runtime')['definePage']
+  const defineStore: typeof import('pinia')['defineStore']
+  const effectScope: typeof import('vue')['effectScope']
+  const getActivePinia: typeof import('pinia')['getActivePinia']
+  const getCurrentInstance: typeof import('vue')['getCurrentInstance']
+  const getCurrentScope: typeof import('vue')['getCurrentScope']
+  const h: typeof import('vue')['h']
+  const inject: typeof import('vue')['inject']
+  const isProxy: typeof import('vue')['isProxy']
+  const isReactive: typeof import('vue')['isReactive']
+  const isReadonly: typeof import('vue')['isReadonly']
+  const isRef: typeof import('vue')['isRef']
+  const mapActions: typeof import('pinia')['mapActions']
+  const mapGetters: typeof import('pinia')['mapGetters']
+  const mapState: typeof import('pinia')['mapState']
+  const mapStores: typeof import('pinia')['mapStores']
+  const mapWritableState: typeof import('pinia')['mapWritableState']
+  const markRaw: typeof import('vue')['markRaw']
+  const nextTick: typeof import('vue')['nextTick']
+  const onActivated: typeof import('vue')['onActivated']
+  const onBeforeMount: typeof import('vue')['onBeforeMount']
+  const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave']
+  const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate']
+  const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
+  const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
+  const onDeactivated: typeof import('vue')['onDeactivated']
+  const onErrorCaptured: typeof import('vue')['onErrorCaptured']
+  const onMounted: typeof import('vue')['onMounted']
+  const onRenderTracked: typeof import('vue')['onRenderTracked']
+  const onRenderTriggered: typeof import('vue')['onRenderTriggered']
+  const onScopeDispose: typeof import('vue')['onScopeDispose']
+  const onServerPrefetch: typeof import('vue')['onServerPrefetch']
+  const onUnmounted: typeof import('vue')['onUnmounted']
+  const onUpdated: typeof import('vue')['onUpdated']
+  const provide: typeof import('vue')['provide']
+  const reactive: typeof import('vue')['reactive']
+  const readonly: typeof import('vue')['readonly']
+  const ref: typeof import('vue')['ref']
+  const resolveComponent: typeof import('vue')['resolveComponent']
+  const setActivePinia: typeof import('pinia')['setActivePinia']
+  const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix']
+  const shallowReactive: typeof import('vue')['shallowReactive']
+  const shallowReadonly: typeof import('vue')['shallowReadonly']
+  const shallowRef: typeof import('vue')['shallowRef']
+  const storeToRefs: typeof import('pinia')['storeToRefs']
+  const toRaw: typeof import('vue')['toRaw']
+  const toRef: typeof import('vue')['toRef']
+  const toRefs: typeof import('vue')['toRefs']
+  const toValue: typeof import('vue')['toValue']
+  const triggerRef: typeof import('vue')['triggerRef']
+  const unref: typeof import('vue')['unref']
+  const useAttrs: typeof import('vue')['useAttrs']
+  const useAuth: typeof import('../utils/composables/useAuth')['default']
+  const useCssModule: typeof import('vue')['useCssModule']
+  const useCssVars: typeof import('vue')['useCssVars']
+  const useGlobalProperties: typeof import('../utils/composables/useGlobalProperties')['default']
+  const useLink: typeof import('vue-router/auto')['useLink']
+  const usePage: typeof import('../utils/composables/usePage')['default']
+  const useRoute: typeof import('vue-router')['useRoute']
+  const useRouter: typeof import('vue-router')['useRouter']
+  const useSlots: typeof import('vue')['useSlots']
+  const watch: typeof import('vue')['watch']
+  const watchEffect: typeof import('vue')['watchEffect']
+  const watchPostEffect: typeof import('vue')['watchPostEffect']
+  const watchSyncEffect: typeof import('vue')['watchSyncEffect']
+}
+// for type re-export
+declare global {
+  // @ts-ignore
+  export type { Component, ComponentPublicInstance, ComputedRef, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, VNode, WritableComputedRef } from 'vue'
+  import('vue')
+}

+ 1 - 4
src/types/components.d.ts

@@ -7,9 +7,9 @@ export {}
 /* prettier-ignore */
 declare module 'vue' {
   export interface GlobalComponents {
+    AppSetting: typeof import('./../components/AppSetting/index.vue')['default']
     Auth: typeof import('./../components/Auth/index.vue')['default']
     AuthAll: typeof import('./../components/AuthAll/index.vue')['default']
-    CountDown: typeof import('./../components/CountDown/index.vue')['default']
     HBadge: typeof import('./../ui-kit/HBadge.vue')['default']
     HButton: typeof import('./../ui-kit/HButton.vue')['default']
     HDialog: typeof import('./../ui-kit/HDialog.vue')['default']
@@ -20,7 +20,6 @@ declare module 'vue' {
     NotAllowed: typeof import('./../components/NotAllowed/index.vue')['default']
     PageLayout: typeof import('./../components/PageLayout/index.vue')['default']
     PageMain: typeof import('./../components/PageMain/index.vue')['default']
-    RoundSlider: typeof import('./../components/RoundSlider/index.vue')['default']
     RouterLink: typeof import('vue-router')['RouterLink']
     RouterView: typeof import('vue-router')['RouterView']
     SvgIcon: typeof import('./../components/SvgIcon/index.vue')['default']
@@ -28,7 +27,5 @@ declare module 'vue' {
     VanFieldCalendar: typeof import('./../components/VanFieldCalendar/index.vue')['default']
     VanFieldDatePicker: typeof import('./../components/VanFieldDatePicker/index.vue')['default']
     VanFieldPicker: typeof import('./../components/VanFieldPicker/index.vue')['default']
-    VoiceImp: typeof import('./../components/VoiceImp/index.vue')['default']
-    WuIsCorrect: typeof import('./../components/WuIsCorrect/index.vue')['default']
   }
 }

+ 108 - 0
src/types/global.d.ts

@@ -0,0 +1,108 @@
+type RecursiveRequired<T> = {
+  [P in keyof T]-?: RecursiveRequired<T[P]>
+}
+type RecursivePartial<T> = {
+  [P in keyof T]?: RecursivePartial<T[P]>
+}
+
+declare namespace Settings {
+  interface app {
+    /**
+     * 颜色方案
+     * @默认值 `''` 跟随系统
+     * @可选值 `'light'` 明亮模式
+     * @可选值 `'dark'` 暗黑模式
+     */
+    colorScheme?: '' | 'light' | 'dark'
+    /**
+     * 是否开启哀悼模式
+     * @默认值 `false`
+     */
+    enableMournMode?: boolean
+    /**
+     * 是否开启色弱模式
+     * @默认值 `false`
+     */
+    enableColorAmblyopiaMode?: boolean
+    /**
+     * 是否开启权限功能
+     * @默认值 `false`
+     */
+    enablePermission?: boolean
+    /**
+     * 是否开启载入进度条
+     * @默认值 `true`
+     */
+    enableProgress?: boolean
+    /**
+     * 是否开启动态标题
+     * @默认值 `false`
+     */
+    enableDynamicTitle?: boolean
+    /**
+     * 是否开启返回顶部按钮
+     * @默认值 `true`
+     */
+    enableBackTop?: boolean
+  }
+  interface navbar {
+    /**
+     * 是否启用
+     * @默认值 `true`
+     */
+    enable?: boolean
+  }
+  interface tabbar {
+    /**
+     * 是否启用
+     * @默认值 `false`
+     */
+    enable?: boolean
+    /**
+     * 导航菜单
+     */
+    list?: {
+      path: string
+      icon?: string
+      activeIcon?: string
+      text?: string
+    }[]
+  }
+  interface copyright {
+    /**
+     * 是否开启底部版权,同时在路由 meta 对象里可以单独设置某个路由是否显示底部版权信息
+     * @默认值 `false`
+     */
+    enable?: boolean
+    /**
+     * 网站运行日期
+     * @默认值 `''`
+     */
+    dates?: string
+    /**
+     * 公司名称
+     * @默认值 `''`
+     */
+    company?: string
+    /**
+     * 网站地址
+     * @默认值 `''`
+     */
+    website?: string
+    /**
+     * 网站备案号
+     * @默认值 `''`
+     */
+    beian?: string
+  }
+  interface all {
+    /** 应用设置 */
+    app?: app
+    /** 顶部导航栏 */
+    navbar?: navbar
+    /** 底部导航栏 */
+    tabbar?: tabbar
+    /** 底部版权设置 */
+    copyright?: copyright
+  }
+}

+ 12 - 0
src/types/route-meta.d.ts

@@ -0,0 +1,12 @@
+import type { RouteNamedMap } from 'vue-router/auto-routes'
+
+export {}
+
+declare module 'vue-router' {
+  interface RouteMeta {
+    title?: string
+    cache?: boolean | keyof RouteNamedMap | (keyof RouteNamedMap)[]
+    noCache?: keyof RouteNamedMap | (keyof RouteNamedMap)[]
+    auth?: boolean | string | string[]
+  }
+}

+ 5 - 0
src/types/shims.d.ts

@@ -0,0 +1,5 @@
+declare interface Window {
+  // extend the window
+}
+
+declare module 'vue-esign'

+ 42 - 0
src/ui-kit/HBadge.vue

@@ -0,0 +1,42 @@
+<script setup lang="ts">
+const props = defineProps<{
+  value: string | number | boolean
+}>()
+
+const show = computed(() => {
+  switch (typeof props.value) {
+    case 'string':
+      return props.value.length > 0
+    case 'number':
+      return props.value > 0
+    case 'boolean':
+      return props.value
+    default:
+      return props.value !== undefined && props.value !== null
+  }
+})
+
+const transitionClass = ref({
+  enterActiveClass: 'ease-in-out duration-500',
+  enterFromClass: 'opacity-0',
+  enterToClass: 'opacity-100',
+  leaveActiveClass: 'ease-in-out duration-500',
+  leaveFromClass: 'opacity-100',
+  leaveToClass: 'opacity-0',
+})
+</script>
+
+<template>
+  <div class="relative inline-flex">
+    <slot />
+    <Transition v-bind="transitionClass">
+      <span
+        v-if="show"
+        class="absolute start-[50%] top-0 z-20 whitespace-nowrap rounded-full bg-ui-primary px-1.5 text-xs text-ui-text ring-1 ring-light -translate-y-[50%] dark-ring-dark"
+        :class="{ '-indent-9999 w-1.5 h-1.5 px-0! start-[100%]! -translate-x-[50%] rtl:(translate-x-[50%]) before:(content-empty block bg-ui-primary w-full h-full rounded-full absolute start-0 top-0 animate-ping)': value === true }"
+      >
+        {{ value }}
+      </span>
+    </Transition>
+  </div>
+</template>

+ 28 - 0
src/ui-kit/HButton.vue

@@ -0,0 +1,28 @@
+<script setup lang="ts">
+const props = withDefaults(
+  defineProps<{
+    block?: boolean
+    outline?: boolean
+    disabled?: boolean
+  }>(),
+  {
+    block: false,
+    outline: false,
+    disabled: false,
+  },
+)
+
+const buttonClass = computed(() => [
+  'focus-outline-none focus-visible-outline-0 cursor-pointer disabled-cursor-not-allowed disabled-opacity-75 flex-shrink-0 gap-x-1.5 px-2.5 py-1.5 border-size-0 font-medium text-sm rounded-md select-none',
+  props.block ? 'w-full flex justify-center items-center' : 'inline-flex items-center',
+  props.outline
+    ? 'shadow-sm ring-1 ring-inset ring-ui-primary text-ui-primary bg-white dark-bg-dark hover-not-disabled-bg-ui-primary/10 dark-hover-not-disabled-bg-ui-primary/10 focus-visible-ring-2'
+    : 'shadow-sm text-ui-text bg-ui-primary hover-bg-ui-primary/75 disabled-bg-ui-primary/90 focus-visible-ring-inset focus-visible-ring-2',
+])
+</script>
+
+<template>
+  <button :disabled="disabled" :class="buttonClass">
+    <slot />
+  </button>
+</template>

+ 84 - 0
src/ui-kit/HDialog.vue

@@ -0,0 +1,84 @@
+<script setup lang="ts">
+import { Dialog, DialogDescription, DialogPanel, DialogTitle, TransitionChild, TransitionRoot } from '@headlessui/vue'
+
+withDefaults(
+  defineProps<{
+    appear?: boolean
+    title?: string
+    noTitle?: boolean
+    preventClose?: boolean
+    overlay?: boolean
+  }>(),
+  {
+    appear: false,
+    noTitle: false,
+    preventClose: false,
+    overlay: false,
+  },
+)
+
+const emits = defineEmits<{
+  close: []
+}>()
+
+const isOpen = defineModel<boolean>({
+  default: false,
+})
+
+const slots = useSlots()
+
+const overlayTransitionClass = ref({
+  enter: 'ease-in-out duration-500',
+  enterFrom: 'opacity-0',
+  enterTo: 'opacity-100',
+  leave: 'ease-in-out duration-500',
+  leaveFrom: 'opacity-100',
+  leaveTo: 'opacity-0',
+})
+
+const transitionClass = computed(() => {
+  return {
+    enter: 'ease-out duration-300',
+    enterFrom: 'opacity-0 translate-y-4 lg-translate-y-0 lg-scale-95',
+    enterTo: 'opacity-100 translate-y-0 lg-scale-100',
+    leave: 'ease-in duration-200',
+    leaveFrom: 'opacity-100 translate-y-0 lg-scale-100',
+    leaveTo: 'opacity-0 translate-y-4 lg-translate-y-0 lg-scale-95',
+  }
+})
+
+function close() {
+  isOpen.value = false
+  emits('close')
+}
+</script>
+
+<template>
+  <TransitionRoot as="template" :appear="appear" :show="isOpen">
+    <Dialog class="fixed inset-0 z-2000 flex" @close="!preventClose && close()">
+      <TransitionChild as="template" :appear="appear" v-bind="overlayTransitionClass">
+        <div class="fixed inset-0 bg-stone-2/75 transition-opacity dark-bg-stone-8/75" :class="{ 'backdrop-blur-sm': overlay }" />
+      </TransitionChild>
+      <div class="fixed inset-0 overflow-y-auto">
+        <div class="min-h-full flex items-end justify-center p-4 text-center lg-items-center">
+          <TransitionChild as="template" :appear="appear" v-bind="transitionClass">
+            <DialogPanel class="relative w-full flex flex-col overflow-hidden rounded-xl bg-white text-left shadow-xl lg-my-8 lg-max-w-lg dark-bg-stone-8">
+              <div v-if="!noTitle" flex="~ items-center justify-between" px-4 py-3 border-b="~ solid stone/15" text-6>
+                <DialogTitle m-0 text-lg text-dark dark-text-white>
+                  {{ title }}
+                </DialogTitle>
+                <SvgIcon name="i-carbon:close" cursor-pointer @click="close" />
+              </div>
+              <DialogDescription m-0 overflow-y-auto p-4 text-start>
+                <slot />
+              </DialogDescription>
+              <div v-if="!!slots.footer" flex="~ items-center justify-end" px-4 py-3 border-t="~ solid stone/15">
+                <slot name="footer" />
+              </div>
+            </DialogPanel>
+          </TransitionChild>
+        </div>
+      </div>
+    </Dialog>
+  </TransitionRoot>
+</template>

+ 25 - 0
src/ui-kit/HInput.vue

@@ -0,0 +1,25 @@
+<script setup lang="ts" generic="T extends string | number">
+withDefaults(
+  defineProps<{
+    placeholder?: string
+    disabled?: boolean
+  }>(),
+  {
+    disabled: false,
+  },
+)
+
+const value = defineModel<T>()
+
+const inputRef = ref()
+
+defineExpose({
+  ref: inputRef,
+})
+</script>
+
+<template>
+  <div class="relative w-full">
+    <input v-model="value" type="text" :placeholder="placeholder" :disabled="disabled" class="relative block w-full border-0 rounded-md bg-white px-2.5 py-1.5 text-sm shadow-sm ring-1 ring-stone-2 ring-inset disabled-cursor-not-allowed dark-bg-dark disabled-opacity-50 focus-outline-none focus-ring-2 dark-ring-stone-8 focus-ring-ui-primary placeholder-stone-4 dark-placeholder-stone-5">
+  </div>
+</template>

+ 83 - 0
src/ui-kit/HSlideover.vue

@@ -0,0 +1,83 @@
+<script setup lang="ts">
+import { Dialog, DialogDescription, DialogPanel, DialogTitle, TransitionChild, TransitionRoot } from '@headlessui/vue'
+import { OverlayScrollbarsComponent } from 'overlayscrollbars-vue'
+
+const props = withDefaults(
+  defineProps<{
+    appear?: boolean
+    side?: 'left' | 'right'
+    title?: string
+    preventClose?: boolean
+    overlay?: boolean
+  }>(),
+  {
+    appear: false,
+    side: 'right',
+    preventClose: false,
+    overlay: false,
+  },
+)
+
+const emits = defineEmits<{
+  close: []
+}>()
+
+const isOpen = defineModel<boolean>({
+  default: false,
+})
+
+const slots = useSlots()
+
+const overlayTransitionClass = ref({
+  enter: 'ease-in-out duration-500',
+  enterFrom: 'opacity-0',
+  enterTo: 'opacity-100',
+  leave: 'ease-in-out duration-500',
+  leaveFrom: 'opacity-100',
+  leaveTo: 'opacity-0',
+})
+
+const transitionClass = computed(() => {
+  return {
+    enter: 'transform transition ease-in-out duration-300',
+    leave: 'transform transition ease-in-out duration-200',
+    enterFrom: props.side === 'left' ? '-translate-x-full' : 'translate-x-full',
+    enterTo: 'translate-x-0',
+    leaveFrom: 'translate-x-0',
+    leaveTo: props.side === 'left' ? '-translate-x-full' : 'translate-x-full',
+  }
+})
+
+function close() {
+  isOpen.value = false
+  emits('close')
+}
+</script>
+
+<template>
+  <TransitionRoot as="template" :appear="appear" :show="isOpen">
+    <Dialog class="fixed inset-0 z-2000 flex justify-end" @close="!preventClose && close()">
+      <TransitionChild as="template" :appear="appear" v-bind="overlayTransitionClass">
+        <div class="fixed inset-0 bg-stone-2/75 transition-opacity dark-bg-stone-8/75" :class="{ 'backdrop-blur-sm': overlay }" />
+      </TransitionChild>
+      <TransitionChild v-bind="transitionClass" :key="JSON.stringify(transitionClass)" as="template" :appear="appear">
+        <DialogPanel relative max-w-md w-full w-screen flex flex-1 flex-col bg-white dark-bg-stone-8 focus-outline-none>
+          <div flex="~ items-center justify-between" p-4 border-b="~ solid stone/15" text-6>
+            <DialogTitle m-0 text-lg text-dark dark-text-white>
+              {{ title }}
+            </DialogTitle>
+            <SvgIcon name="i-carbon:close" cursor-pointer @click="close" />
+          </div>
+          <DialogDescription m-0 flex-1 of-y-hidden>
+            <OverlayScrollbarsComponent :options="{ scrollbars: { autoHide: 'leave', autoHideDelay: 300 } }" defer class="h-full p-4">
+              <slot />
+            </OverlayScrollbarsComponent>
+          </DialogDescription>
+          <div v-if="!!slots.footer" flex="~ items-center justify-end" px-3 py-2 border-t="~ solid stone/15">
+            <slot name="footer" />
+          </div>
+        </DialogPanel>
+      </TransitionChild>
+    </Dialog>
+  </TransitionRoot>
+</template>

+ 52 - 0
src/ui-kit/HTabList.vue

@@ -0,0 +1,52 @@
+<script setup lang="ts" generic="T">
+import { Tab, TabGroup, TabList } from '@headlessui/vue'
+
+const props = defineProps<{
+  options: {
+    icon?: string
+    label: any
+    value: T
+  }[]
+}>()
+
+const emits = defineEmits<{
+  change: [T]
+}>()
+
+const value = defineModel<T>()
+
+const selectedIndex = computed({
+  get() {
+    return props.options.findIndex(option => option.value === value.value)
+  },
+  set(val) {
+    value.value = props.options[val].value
+  },
+})
+
+watch(value, (val) => {
+  val && emits('change', val)
+})
+
+function handleChange(index: number) {
+  value.value = props.options[index].value
+}
+</script>
+
+<template>
+  <TabGroup :selected-index="selectedIndex" @change="handleChange">
+    <TabList class="inline-flex select-none items-center justify-center rounded-md bg-stone-1 p-1 ring-1 ring-stone-2 dark-bg-stone-9 dark-ring-stone-8">
+      <Tab v-for="(option, index) in options" :key="index" v-slot="{ selected }" as="template">
+        <button
+          class="w-full inline-flex items-center justify-center gap-1 break-keep border-size-0 rounded-md bg-inherit px-2 py-1.5 text-sm text-dark ring-stone-2 ring-inset dark-text-white focus-outline-none focus-ring-2 dark-ring-stone-8" :class="{
+            'cursor-default bg-white dark-bg-dark-9': selected,
+            'cursor-pointer opacity-50 hover-(opacity-100)': !selected,
+          }"
+        >
+          <SvgIcon v-if="option.icon" :name="option.icon" class="flex-shrink-0" />
+          {{ option.label }}
+        </button>
+      </Tab>
+    </TabList>
+  </TabGroup>
+</template>

+ 26 - 0
src/ui-kit/HToggle.vue

@@ -0,0 +1,26 @@
+<script setup lang="ts">
+import { Switch } from '@headlessui/vue'
+
+withDefaults(
+  defineProps<{
+    disabled?: boolean
+    onIcon?: string
+    offIcon?: string
+  }>(),
+  {
+    disabled: false,
+  },
+)
+
+const enabled = defineModel<boolean>()
+</script>
+
+<template>
+  <Switch v-model="enabled" :disabled="disabled" class="relative h-5 w-10 inline-flex flex-shrink-0 cursor-pointer border-2 border-transparent rounded-full p-0 vertical-middle disabled-cursor-not-allowed disabled-opacity-50 focus-outline-none focus-visible-ring-2 focus-visible-ring-offset-2 focus-visible-ring-offset-white dark-focus-visible-ring-offset-gray-900" :class="[enabled ? 'bg-ui-primary' : 'bg-stone-3 dark-bg-stone-7']">
+    <span class="pointer-events-none relative inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition-margin duration-200 ease-in-out dark-bg-dark" :class="[enabled ? 'ms-5' : 'ms-0']">
+      <span class="absolute inset-0 h-full w-full flex items-center justify-center">
+        <SvgIcon v-if="(enabled && onIcon) || (!enabled && offIcon)" :name="(enabled ? onIcon : offIcon) as string" class="h-3 w-3 text-stone-7 dark-text-stone-3" />
+      </span>
+    </span>
+  </Switch>
+</template>

+ 10 - 0
src/ui-provider/index.ts

@@ -0,0 +1,10 @@
+import type { App } from 'vue'
+import Vant from 'vant'
+import 'vant/lib/index.css'
+import '@vant/touch-emulator'
+
+function install(app: App) {
+  app.use(Vant)
+}
+
+export default { install }

+ 15 - 0
src/ui-provider/index.vue

@@ -0,0 +1,15 @@
+<script setup lang="ts">
+import { Locale } from 'vant'
+import zhCN from 'vant/es/locale/lang/zh-CN'
+import useSettingsStore from '@/store/modules/settings'
+
+const settingsStore = useSettingsStore()
+
+Locale.use('zh-CN', zhCN)
+</script>
+
+<template>
+  <VanConfigProvider :theme="settingsStore.currentColorScheme" class="min-h-vh supports-[(min-height:100dvh)]:min-h-dvh">
+    <slot />
+  </VanConfigProvider>
+</template>

+ 35 - 0
src/utils/composables/useAuth.ts

@@ -0,0 +1,35 @@
+import useSettingsStore from '@/store/modules/settings'
+import useUserStore from '@/store/modules/user'
+
+export default function useAuth() {
+  function hasPermission(permission: string) {
+    const settingsStore = useSettingsStore()
+    const userStore = useUserStore()
+    if (settingsStore.settings.app.enablePermission) {
+      return userStore.permissions.includes(permission)
+    }
+    else {
+      return true
+    }
+  }
+
+  function auth(value: string | string[]) {
+    let auth
+    if (typeof value === 'string') {
+      auth = value !== '' ? hasPermission(value) : true
+    }
+    else {
+      auth = value.length > 0 ? value.some(item => hasPermission(item)) : true
+    }
+    return auth
+  }
+
+  function authAll(value: string[]) {
+    return value.length > 0 ? value.every(item => hasPermission(item)) : true
+  }
+
+  return {
+    auth,
+    authAll,
+  }
+}

+ 6 - 0
src/utils/composables/useGlobalProperties.ts

@@ -0,0 +1,6 @@
+import type { ComponentInternalInstance } from 'vue'
+
+export default function useGlobalProperties() {
+  const { appContext } = getCurrentInstance() as ComponentInternalInstance
+  return appContext.config.globalProperties
+}

+ 13 - 0
src/utils/composables/usePage.ts

@@ -0,0 +1,13 @@
+export default function usePage() {
+  const router = useRouter()
+
+  function reload() {
+    router.push({
+      name: 'reload',
+    })
+  }
+
+  return {
+    reload,
+  }
+}

+ 6 - 0
src/utils/dayjs.ts

@@ -0,0 +1,6 @@
+import dayjs from 'dayjs'
+import 'dayjs/locale/zh-cn'
+
+dayjs.locale('zh-cn')
+
+export default dayjs

+ 19 - 0
src/utils/directive.ts

@@ -0,0 +1,19 @@
+import type { App } from 'vue'
+
+export default function directive(app: App) {
+  // 注册 v-auth 和 v-auth-all 指令
+  app.directive('auth', {
+    mounted: (el, binding) => {
+      if (!useAuth().auth(binding.value)) {
+        el.remove()
+      }
+    },
+  })
+  app.directive('auth-all', {
+    mounted: (el, binding) => {
+      if (!useAuth().authAll(binding.value)) {
+        el.remove()
+      }
+    },
+  })
+}

+ 3 - 0
src/utils/eventBus.ts

@@ -0,0 +1,3 @@
+import mitt from 'mitt'
+
+export default mitt()

+ 16 - 0
src/utils/system.copyright.ts

@@ -0,0 +1,16 @@
+// 请勿删除
+if (import.meta.env.PROD) {
+  const copyright_common_style = 'font-size: 14px; margin-bottom: 2px; padding: 6px 8px; color: #fff;'
+  const copyright_main_style = `${copyright_common_style} background: #e24329;`
+  const copyright_sub_style = `${copyright_common_style} background: #707070;`
+  if (navigator.language.toLowerCase() === 'zh-cn') {
+    // eslint-disable-next-line no-console
+    console.info('%c由%cFantastic-mobile%c驱动', copyright_sub_style, copyright_main_style, copyright_sub_style, '\nhttps://fantastic-mobile.hurui.me')
+  }
+  else {
+    // eslint-disable-next-line no-console
+    console.info('%cPowered by%cFantastic-mobile', copyright_sub_style, copyright_main_style, '\nhttps://fantastic-mobile.hurui.me')
+  }
+}
+
+export {}

+ 49 - 0
src/views/[...all].vue

@@ -0,0 +1,49 @@
+<script setup lang="ts">
+definePage({
+  meta: {
+    title: '找不到页面',
+  },
+})
+
+const router = useRouter()
+
+const data = ref({
+  inter: Number.NaN,
+  countdown: 5,
+})
+
+onUnmounted(() => {
+  data.value.inter && window.clearInterval(data.value.inter)
+})
+
+onMounted(() => {
+  data.value.inter = window.setInterval(() => {
+    data.value.countdown--
+    if (data.value.countdown === 0) {
+      data.value.inter && window.clearInterval(data.value.inter)
+      goBack()
+    }
+  }, 1000)
+})
+
+function goBack() {
+  router.push('/')
+}
+</script>
+
+<template>
+  <div class="min-h-screen flex flex-col items-center justify-center">
+    <SvgIcon name="404" class="text-[300px] -mt-9xl" />
+    <div class="flex flex-col items-center gap-4">
+      <h1 class="m-0 text-6xl font-sans">
+        404
+      </h1>
+      <div class="mx-0 text-xl text-stone-5">
+        抱歉,你访问的页面不存在
+      </div>
+      <HButton @click="goBack">
+        {{ data.countdown }} 秒后,返回首页
+      </HButton>
+    </div>
+  </div>
+</template>

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 8 - 0
src/views/index.vue


+ 130 - 0
src/views/login.vue

@@ -0,0 +1,130 @@
+<script setup lang="ts">
+import useUserStore from '@/store/modules/user'
+
+definePage({
+  name: 'login',
+  meta: {
+    title: '登录',
+  },
+})
+
+const router = useRouter()
+const route = useRoute()
+const userStore = useUserStore()
+
+const redirect = ref(route.query.redirect?.toString() ?? '/')
+
+const loginForm = ref({
+  account: '',
+  password: '',
+})
+function handleLogin() {
+  userStore.login({
+    account: loginForm.value.account,
+    password: loginForm.value.password,
+  }).then(() => {
+    router.replace(redirect.value)
+  })
+}
+
+function testAccount(account: string) {
+  loginForm.value.account = account
+  loginForm.value.password = '123456'
+  handleLogin()
+}
+</script>
+
+<template>
+  <PageLayout :navbar="false">
+    <div class="mx-4 flex flex-1 flex-col justify-center gap-8">
+      <img src="@/assets/images/logo.png" class="mx-auto h-24 w-24">
+      <van-form @submit="handleLogin">
+        <van-cell-group inset>
+          <van-field v-model="loginForm.account" name="用户名" label="用户名" placeholder="用户名" :rules="[{ required: true, message: '请填写用户名' }]" />
+          <van-field v-model="loginForm.password" type="password" name="密码" label="密码" placeholder="密码" :rules="[{ required: true, message: '请填写密码' }]" />
+        </van-cell-group>
+        <div class="mt-8 px-4">
+          <van-button round block type="primary" native-type="submit">
+            登录
+          </van-button>
+          <van-divider>
+            演示账号一键登录
+          </van-divider>
+          <div class="text-center space-x-4">
+            <van-button type="primary" size="small" plain @click="testAccount('admin')">
+              admin
+            </van-button>
+            <van-button size="small" plain @click="testAccount('test')">
+              test
+            </van-button>
+          </div>
+        </div>
+      </van-form>
+    </div>
+    <svg width="100%" viewBox="0 0 1440 590" xmlns="http://www.w3.org/2000/svg" class="svg pointer-events-none transition duration-300 delay-150 ease-in-out"><defs><linearGradient id="gradient" x1="0%" y1="50%" x2="100%" y2="50%"><stop offset="5%" stop-color="#F78DA7" /><stop offset="95%" stop-color="#8ED1FC" /></linearGradient></defs><path d="M 0,600 L 0,150 C 154.10714285714283,165.39285714285714 308.21428571428567,180.78571428571428 424,163 C 539.7857142857143,145.21428571428572 617.2500000000001,94.25 735,94 C 852.7499999999999,93.75 1010.7857142857142,144.21428571428572 1135,162 C 1259.2142857142858,179.78571428571428 1349.607142857143,164.89285714285714 1440,150 L 1440,600 L 0,600 Z" stroke="none" stroke-width="0" fill="url(#gradient)" fill-opacity="0.53" class="path-1 transition-all duration-300 delay-150 ease-in-out" /><defs><linearGradient id="gradient" x1="0%" y1="50%" x2="100%" y2="50%"><stop offset="5%" stop-color="#F78DA7" /><stop offset="95%" stop-color="#8ED1FC" /></linearGradient></defs><path d="M 0,600 L 0,350 C 144.10714285714286,333.7857142857143 288.2142857142857,317.57142857142856 389,313 C 489.7857142857143,308.42857142857144 547.25,315.5 657,321 C 766.75,326.5 928.7857142857142,330.42857142857144 1068,335 C 1207.2142857142858,339.57142857142856 1323.607142857143,344.7857142857143 1440,350 L 1440,600 L 0,600 Z" stroke="none" stroke-width="0" fill="url(#gradient)" fill-opacity="1" class="path-2 transition-all duration-300 delay-150 ease-in-out" /></svg>
+  </PageLayout>
+</template>
+
+<style scoped>
+.svg {
+  position: absolute;
+  bottom: 0;
+  z-index: 0;
+}
+
+.path-1 {
+  animation: path-anim-1 4s;
+  animation-timing-function: linear;
+  animation-iteration-count: infinite;
+}
+
+@keyframes path-anim-1 {
+  0% {
+    d: path("M 0,600 L 0,150 C 154.10714285714283,165.39285714285714 308.21428571428567,180.78571428571428 424,163 C 539.7857142857143,145.21428571428572 617.2500000000001,94.25 735,94 C 852.7499999999999,93.75 1010.7857142857142,144.21428571428572 1135,162 C 1259.2142857142858,179.78571428571428 1349.607142857143,164.89285714285714 1440,150 L 1440,600 L 0,600 Z");
+  }
+
+  25% {
+    d: path("M 0,600 L 0,150 C 93.35714285714286,124.89285714285714 186.71428571428572,99.78571428571429 297,90 C 407.2857142857143,80.21428571428571 534.5,85.75 658,114 C 781.5,142.25 901.2857142857142,193.21428571428572 1031,203 C 1160.7142857142858,212.78571428571428 1300.357142857143,181.39285714285714 1440,150 L 1440,600 L 0,600 Z");
+  }
+
+  50% {
+    d: path("M 0,600 L 0,150 C 86.85714285714286,184.78571428571428 173.71428571428572,219.57142857142858 306,199 C 438.2857142857143,178.42857142857142 616,102.49999999999999 753,78 C 890,53.500000000000014 986.2857142857142,80.42857142857143 1094,101 C 1201.7142857142858,121.57142857142857 1320.857142857143,135.78571428571428 1440,150 L 1440,600 L 0,600 Z");
+  }
+
+  75% {
+    d: path("M 0,600 L 0,150 C 106.82142857142858,152.5 213.64285714285717,155 325,144 C 436.35714285714283,133 552.2499999999999,108.49999999999999 694,108 C 835.7500000000001,107.50000000000001 1003.3571428571429,131.00000000000003 1132,142 C 1260.642857142857,152.99999999999997 1350.3214285714284,151.5 1440,150 L 1440,600 L 0,600 Z");
+  }
+
+  100% {
+    d: path("M 0,600 L 0,150 C 154.10714285714283,165.39285714285714 308.21428571428567,180.78571428571428 424,163 C 539.7857142857143,145.21428571428572 617.2500000000001,94.25 735,94 C 852.7499999999999,93.75 1010.7857142857142,144.21428571428572 1135,162 C 1259.2142857142858,179.78571428571428 1349.607142857143,164.89285714285714 1440,150 L 1440,600 L 0,600 Z");
+  }
+}
+
+.path-2 {
+  animation: path-anim-2 4s;
+  animation-timing-function: linear;
+  animation-iteration-count: infinite;
+}
+
+@keyframes path-anim-2 {
+  0% {
+    d: path("M 0,600 L 0,350 C 144.10714285714286,333.7857142857143 288.2142857142857,317.57142857142856 389,313 C 489.7857142857143,308.42857142857144 547.25,315.5 657,321 C 766.75,326.5 928.7857142857142,330.42857142857144 1068,335 C 1207.2142857142858,339.57142857142856 1323.607142857143,344.7857142857143 1440,350 L 1440,600 L 0,600 Z");
+  }
+
+  25% {
+    d: path("M 0,600 L 0,350 C 111.64285714285711,384.82142857142856 223.28571428571422,419.64285714285717 356,421 C 488.7142857142858,422.35714285714283 642.5000000000002,390.25 752,390 C 861.4999999999998,389.75 926.7142857142856,421.35714285714283 1034,420 C 1141.2857142857144,418.64285714285717 1290.6428571428573,384.32142857142856 1440,350 L 1440,600 L 0,600 Z");
+  }
+
+  50% {
+    d: path("M 0,600 L 0,350 C 139.60714285714283,359.3571428571429 279.21428571428567,368.7142857142857 402,374 C 524.7857142857143,379.2857142857143 630.7500000000001,380.5 740,371 C 849.2499999999999,361.5 961.7857142857142,341.2857142857143 1079,336 C 1196.2142857142858,330.7142857142857 1318.107142857143,340.3571428571429 1440,350 L 1440,600 L 0,600 Z");
+  }
+
+  75% {
+    d: path("M 0,600 L 0,350 C 136.53571428571428,364.5357142857143 273.07142857142856,379.07142857142856 370,362 C 466.92857142857144,344.92857142857144 524.2500000000001,296.25 654,282 C 783.7499999999999,267.75 985.9285714285716,287.92857142857144 1129,305 C 1272.0714285714284,322.07142857142856 1356.0357142857142,336.0357142857143 1440,350 L 1440,600 L 0,600 Z");
+  }
+
+  100% {
+    d: path("M 0,600 L 0,350 C 144.10714285714286,333.7857142857143 288.2142857142857,317.57142857142856 389,313 C 489.7857142857143,308.42857142857144 547.25,315.5 657,321 C 766.75,326.5 928.7857142857142,330.42857142857144 1068,335 C 1207.2142857142858,339.57142857142856 1323.607142857143,344.7857142857143 1440,350 L 1440,600 L 0,600 Z");
+  }
+}
+</style>

+ 21 - 0
src/views/reload.vue

@@ -0,0 +1,21 @@
+<script setup lang="ts">
+definePage({
+  name: 'reload',
+  meta: {
+    title: '刷新页面',
+    navbar: {
+      enable: false,
+    },
+  },
+})
+
+const router = useRouter()
+
+onMounted(() => {
+  router.go(-1)
+})
+</script>
+
+<template>
+  <div />
+</template>

+ 49 - 0
src/views/user/index.vue

@@ -0,0 +1,49 @@
+<script setup lang="ts">
+import useUserStore from '@/store/modules/user'
+
+definePage({
+  meta: {
+    title: '个人中心',
+    auth: true,
+  },
+})
+
+const userStore = useUserStore()
+
+const avatarError = ref(false)
+watch(() => userStore.avatar, () => {
+  if (avatarError.value) {
+    avatarError.value = false
+  }
+})
+</script>
+
+<template>
+  <PageLayout :navbar="false" tabbar>
+    <div class="flex flex-1 flex-col gap-8 p-4">
+      <div class="flex flex-1 flex-col gap-4">
+        <div class="flex items-center justify-end gap-4">
+          <HBadge :value="10">
+            <SvgIcon name="i-carbon:notification" class="text-6" />
+          </HBadge>
+          <SvgIcon name="i-carbon:settings" class="text-6" />
+        </div>
+        <div class="flex items-center gap-4">
+          <img v-if="userStore.avatar && !avatarError" :src="userStore.avatar" :onerror="() => (avatarError = true)" class="h-20 w-20 rounded-full bg-dark p-2 dark-bg-light">
+          <SvgIcon v-else name="i-carbon:user-avatar-filled-alt" class="text-20 text-gray-400" />
+          <div>
+            <div class="text-8 font-bold">
+              Hi, {{ userStore.account }}
+            </div>
+            <div class="mt-1 text-stone-5">
+              这是个人中心示例页面噢~
+            </div>
+          </div>
+        </div>
+      </div>
+      <HButton block @click="userStore.logout()">
+        登出
+      </HButton>
+    </div>
+  </PageLayout>
+</template>

+ 32 - 0
stylelint.config.js

@@ -0,0 +1,32 @@
+export default {
+  extends: [
+    'stylelint-config-standard-scss',
+    'stylelint-config-standard-vue/scss',
+    'stylelint-config-recess-order',
+    '@stylistic/stylelint-config',
+  ],
+  plugins: [
+    'stylelint-scss',
+  ],
+  rules: {
+    'at-rule-no-unknown': null,
+    'no-descending-specificity': null,
+    'property-no-unknown': null,
+    'font-family-no-missing-generic-family-keyword': null,
+    'selector-class-pattern': null,
+    'scss/double-slash-comment-empty-line-before': null,
+    'scss/no-global-function-names': null,
+    '@stylistic/max-line-length': null,
+    '@stylistic/block-closing-brace-newline-after': [
+      'always',
+      {
+        ignoreAtRules: ['if', 'else'],
+      },
+    ],
+  },
+  allowEmptyInput: true,
+  ignoreFiles: [
+    'node_modules/**/*',
+    'dist*/**/*',
+  ],
+}

+ 39 - 0
themes/index.ts

@@ -0,0 +1,39 @@
+import { hex2rgba } from '@unocss/preset-mini/utils'
+
+export const lightTheme = {
+  // 颜色主题
+  'color-scheme': 'light',
+  // 内置 UI
+  '--ui-primary': hex2rgba('#0f0f0f')!.join(' '),
+  '--ui-text': hex2rgba('#fcfcfc')!.join(' '),
+  // 主体
+  '--g-bg': '#f2f2f2',
+  '--g-container-bg': '#fff',
+  '--g-border-color': '#DCDFE6',
+  // 导航栏
+  '--g-navbar-bg': '#fff',
+  '--g-navbar-color': '#0f0f0f',
+  // 标签栏
+  '--g-tabbar-bg': '#fff',
+  '--g-tabbar-color': '#6f6f6f',
+  '--g-tabbar-active-color': '#0f0f0f',
+}
+
+export const darkTheme = {
+  // 颜色主题
+  'color-scheme': 'dark',
+  // 内置 UI
+  '--ui-primary': hex2rgba('#e5e5e5')!.join(' '),
+  '--ui-text': hex2rgba('#242b33')!.join(' '),
+  // 主体
+  '--g-bg': '#0a0a0a',
+  '--g-container-bg': '#141414',
+  '--g-border-color': '#15191e',
+  // 导航栏
+  '--g-navbar-bg': '#141414',
+  '--g-navbar-color': '#e5e5e5',
+  // 标签栏
+  '--g-tabbar-bg': '#141414',
+  '--g-tabbar-color': '#6f6f6f',
+  '--g-tabbar-active-color': '#e5e5e5',
+}

+ 14 - 0
tsconfig.node.json

@@ -0,0 +1,14 @@
+{
+  "compilerOptions": {
+    "composite": true,
+    "module": "ESNext",
+    "moduleResolution": "bundler",
+    "allowSyntheticDefaultImports": true,
+    "skipLibCheck": true
+  },
+  "include": [
+    "package.json",
+    "vite.config.ts",
+    "vite/**/*.ts"
+  ]
+}

+ 153 - 0
vite/plugins.ts

@@ -0,0 +1,153 @@
+import path from 'node:path'
+import process from 'node:process'
+import type { PluginOption } from 'vite'
+import VueRouter from 'unplugin-vue-router/vite'
+import vue from '@vitejs/plugin-vue'
+import vueJsx from '@vitejs/plugin-vue-jsx'
+import vueLegacy from '@vitejs/plugin-legacy'
+import VueDevTools from 'vite-plugin-vue-devtools'
+import autoImport from 'unplugin-auto-import/vite'
+import { VueRouterAutoImports } from 'unplugin-vue-router'
+import components from 'unplugin-vue-components/vite'
+import Unocss from 'unocss/vite'
+import { createSvgIconsPlugin } from 'vite-plugin-svg-icons'
+import { vitePluginFakeServer } from 'vite-plugin-fake-server'
+import { compression } from 'vite-plugin-compression2'
+import Archiver from 'vite-plugin-archiver'
+import TurboConsole from 'unplugin-turbo-console/vite'
+import banner from 'vite-plugin-banner'
+import boxen from 'boxen'
+import picocolors from 'picocolors'
+
+export default function createVitePlugins(viteEnv, isBuild = false) {
+  const vitePlugins: (PluginOption | PluginOption[])[] = [
+    VueRouter({
+      routesFolder: './src/views',
+      dts: './src/types/typed-router.d.ts',
+      exclude: ['**/components', '**/_*/**', '**/_*'],
+    }),
+    vue(),
+    vueJsx(),
+    vueLegacy({
+      renderLegacyChunks: false,
+      modernPolyfills: [
+        'es.array.at',
+      ],
+    }),
+
+    // https://github.com/vuejs/devtools-next
+    viteEnv.VITE_OPEN_DEVTOOLS === 'true' && VueDevTools(),
+
+    // https://github.com/unplugin/unplugin-auto-import
+    autoImport({
+      imports: [
+        'vue',
+        'pinia',
+        VueRouterAutoImports,
+        {
+          'vue-router/auto': ['useLink'],
+        },
+      ],
+      dts: './src/types/auto-imports.d.ts',
+      dirs: [
+        './src/utils/composables/**',
+      ],
+    }),
+
+    // https://github.com/unplugin/unplugin-vue-components
+    components({
+      dirs: [
+        'src/components/*',
+        'src/ui-kit',
+      ],
+      deep: false,
+      include: [/\.vue$/, /\.vue\?vue/, /\.tsx$/],
+      dts: './src/types/components.d.ts',
+    }),
+
+    Unocss(),
+
+    // https://github.com/vbenjs/vite-plugin-svg-icons
+    createSvgIconsPlugin({
+      iconDirs: [path.resolve(process.cwd(), 'src/assets/icons/')],
+      symbolId: 'icon-[dir]-[name]',
+      svgoOptions: isBuild,
+    }),
+
+    // https://github.com/condorheroblog/vite-plugin-fake-server
+    vitePluginFakeServer({
+      logger: !isBuild,
+      include: 'src/mock',
+      infixName: false,
+      enableProd: isBuild && viteEnv.VITE_BUILD_MOCK === 'true',
+    }),
+
+    // https://github.com/nonzzz/vite-plugin-compression
+    viteEnv.VITE_BUILD_COMPRESS?.split(',').includes('gzip') && compression(),
+    viteEnv.VITE_BUILD_COMPRESS?.split(',').includes('brotli') && compression({
+      exclude: [/\.(br)$/, /\.(gz)$/],
+      algorithm: 'brotliCompress',
+    }),
+
+    viteEnv.VITE_BUILD_ARCHIVE && Archiver({
+      archiveType: viteEnv.VITE_BUILD_ARCHIVE,
+    }),
+
+    // https://github.com/unplugin/unplugin-turbo-console
+    TurboConsole(),
+
+    // https://github.com/chengpeiquan/vite-plugin-banner
+    banner(`
+/**
+ * 由 Fantastic-mobile 提供技术支持
+ * Powered by Fantastic-mobile
+ * https://fantastic-mobile.hurui.me/
+ */
+    `),
+
+    {
+      name: 'vite-plugin-debug-plugin',
+      transform: (code, id) => {
+        if (/src\/main.ts$/.test(id)) {
+          if (viteEnv.VITE_APP_DEBUG_TOOL === 'eruda') {
+            code = code.concat(`
+              import eruda from 'eruda'
+              eruda.init()
+            `)
+          }
+          else if (viteEnv.VITE_APP_DEBUG_TOOL === 'vconsole') {
+            code = code.concat(`
+              import VConsole from 'vconsole'
+              new VConsole()
+            `)
+          }
+          return {
+            code,
+            map: null,
+          }
+        }
+      },
+    },
+
+    {
+      name: 'appInfo',
+      apply: 'serve',
+      async buildStart() {
+        const { bold, green, cyan, bgGreen, underline } = picocolors
+        // eslint-disable-next-line no-console
+        console.log(
+          boxen(
+            `${bold(green(`由 ${bgGreen('Fantastic-mobile')} 驱动`))}\n\n${underline('https://fantastic-mobile.hurui.me')}\n\n当前使用:${cyan('基础版')}`,
+            {
+              padding: 1,
+              margin: 1,
+              borderStyle: 'double',
+              textAlignment: 'center',
+            },
+          ),
+        )
+      },
+    },
+  ]
+  return vitePlugins
+}

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác