Browse Source

initial commit

fengxici 9 months ago
commit
feac196157
50 changed files with 6796 additions and 0 deletions
  1. 9 0
      .env.dev
  2. 8 0
      .env.prod
  3. 18 0
      .eslintrc.cjs
  4. 28 0
      .gitignore
  5. 8 0
      .prettierrc.json
  6. 6 0
      .vscode/extensions.json
  7. 46 0
      README.md
  8. 69 0
      auto-imports.d.ts
  9. 42 0
      components.d.ts
  10. 14 0
      env.d.ts
  11. 13 0
      index.html
  12. 4688 0
      package-lock.json
  13. 42 0
      package.json
  14. BIN
      public/favicon.ico
  15. 7 0
      src/App.vue
  16. 121 0
      src/api/Login.ts
  17. 20 0
      src/api/QrCodeLogin.ts
  18. BIN
      src/assets/GitHub-Mark.png
  19. 73 0
      src/assets/base.css
  20. BIN
      src/assets/devices.png
  21. 1 0
      src/assets/logo.svg
  22. 35 0
      src/assets/main.css
  23. BIN
      src/assets/wechat_login.png
  24. 41 0
      src/components/HelloWorld.vue
  25. 86 0
      src/components/TheWelcome.vue
  26. 87 0
      src/components/WelcomeItem.vue
  27. 7 0
      src/components/icons/IconCommunity.vue
  28. 7 0
      src/components/icons/IconDocumentation.vue
  29. 7 0
      src/components/icons/IconEcosystem.vue
  30. 22 0
      src/components/icons/IconGitee.vue
  31. 7 0
      src/components/icons/IconSupport.vue
  32. 19 0
      src/components/icons/IconTooling.vue
  33. 11 0
      src/main.ts
  34. 59 0
      src/router/index.ts
  35. 16 0
      src/util/GlobalUtils.ts
  36. 133 0
      src/util/http/Http.ts
  37. 10 0
      src/util/http/LoginRequest.ts
  38. 9 0
      src/util/http/Reqeust.ts
  39. 55 0
      src/util/pkce/index.ts
  40. 167 0
      src/views/consent/Consent.vue
  41. 109 0
      src/views/device/Activate.vue
  42. 52 0
      src/views/device/Activated.vue
  43. 70 0
      src/views/index/Index.vue
  44. 364 0
      src/views/login/Login.vue
  45. 57 0
      src/views/login/OAuthRedirect.vue
  46. 61 0
      src/views/login/PkceRedirect.vue
  47. 20 0
      tsconfig.app.json
  48. 11 0
      tsconfig.json
  49. 15 0
      tsconfig.node.json
  50. 46 0
      vite.config.ts

+ 9 - 0
.env.dev

@@ -0,0 +1,9 @@
+NODE_ENV=development
+BASE_URL=
+VITE_OAUTH_ISSUER=/api
+# VITE_OAUTH_CLIENT_ID=opaque-client
+VITE_OAUTH_CLIENT_ID=messaging-client
+VITE_PKCE_CLIENT_ID=pkce-message-client
+VITE_OAUTH_CLIENT_SECRET=123456
+VITE_PKCE_REDIRECT_URI=http://www.test.com:5173/PkceRedirect
+VITE_OAUTH_REDIRECT_URI=http://www.test.com:5173/OAuth2Redirect

+ 8 - 0
.env.prod

@@ -0,0 +1,8 @@
+NODE_ENV=product
+BASE_URL=
+VITE_OAUTH_ISSUER=http://kwqqr48rgo.cdhttp.cn
+VITE_OAUTH_CLIENT_ID=messaging-client
+VITE_PKCE_CLIENT_ID=pkce-message-client
+VITE_OAUTH_CLIENT_SECRET=123456
+VITE_PKCE_REDIRECT_URI=http://k7fsqkhtbx.cdhttp.cn/PkceRedirect
+VITE_OAUTH_REDIRECT_URI=http://k7fsqkhtbx.cdhttp.cn/OAuth2Redirect

+ 18 - 0
.eslintrc.cjs

@@ -0,0 +1,18 @@
+/* eslint-env node */
+require('@rushstack/eslint-patch/modern-module-resolution')
+
+module.exports = {
+  root: true,
+  'extends': [
+    'plugin:vue/vue3-essential',
+    'eslint:recommended',
+    '@vue/eslint-config-typescript',
+    '@vue/eslint-config-prettier/skip-formatting'
+  ],
+  parserOptions: {
+    ecmaVersion: 'latest'
+  },
+  rules: {
+    "vue/multi-word-component-names": "off"
+  }
+}

+ 28 - 0
.gitignore

@@ -0,0 +1,28 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+.DS_Store
+dist
+dist-ssr
+coverage
+*.local
+
+/cypress/videos/
+/cypress/screenshots/
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?

+ 8 - 0
.prettierrc.json

@@ -0,0 +1,8 @@
+{
+  "$schema": "https://json.schemastore.org/prettierrc",
+  "semi": false,
+  "tabWidth": 2,
+  "singleQuote": true,
+  "printWidth": 100,
+  "trailingComma": "none"
+}

+ 6 - 0
.vscode/extensions.json

@@ -0,0 +1,6 @@
+{
+  "recommendations": [
+    "Vue.volar",
+    "Vue.vscode-typescript-vue-plugin"
+  ]
+}

+ 46 - 0
README.md

@@ -0,0 +1,46 @@
+# login-example
+
+This template should help get you started developing with Vue 3 in Vite.
+
+## Recommended IDE Setup
+
+[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin).
+
+## Type Support for `.vue` Imports in TS
+
+TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin) to make the TypeScript language service aware of `.vue` types.
+
+If the standalone TypeScript plugin doesn't feel fast enough to you, Volar has also implemented a [Take Over Mode](https://github.com/johnsoncodehk/volar/discussions/471#discussioncomment-1361669) that is more performant. You can enable it by the following steps:
+
+1. Disable the built-in TypeScript Extension
+    1) Run `Extensions: Show Built-in Extensions` from VSCode's command palette
+    2) Find `TypeScript and JavaScript Language Features`, right click and select `Disable (Workspace)`
+2. Reload the VSCode window by running `Developer: Reload Window` from the command palette.
+
+## Customize configuration
+
+See [Vite Configuration Reference](https://vitejs.dev/config/).
+
+## Project Setup
+
+```sh
+npm install
+```
+
+### Compile and Hot-Reload for Development
+
+```sh
+npm run dev
+```
+
+### Type-Check, Compile and Minify for Production
+
+```sh
+npm run build
+```
+
+### Lint with [ESLint](https://eslint.org/)
+
+```sh
+npm run lint
+```

+ 69 - 0
auto-imports.d.ts

@@ -0,0 +1,69 @@
+/* eslint-disable */
+/* prettier-ignore */
+// @ts-nocheck
+// noinspection JSUnusedGlobalSymbols
+// Generated by unplugin-auto-import
+export {}
+declare global {
+  const EffectScope: typeof import('vue')['EffectScope']
+  const computed: typeof import('vue')['computed']
+  const createApp: typeof import('vue')['createApp']
+  const customRef: typeof import('vue')['customRef']
+  const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
+  const defineComponent: typeof import('vue')['defineComponent']
+  const effectScope: typeof import('vue')['effectScope']
+  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 markRaw: typeof import('vue')['markRaw']
+  const nextTick: typeof import('vue')['nextTick']
+  const onActivated: typeof import('vue')['onActivated']
+  const onBeforeMount: typeof import('vue')['onBeforeMount']
+  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 shallowReactive: typeof import('vue')['shallowReactive']
+  const shallowReadonly: typeof import('vue')['shallowReadonly']
+  const shallowRef: typeof import('vue')['shallowRef']
+  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 useCssModule: typeof import('vue')['useCssModule']
+  const useCssVars: typeof import('vue')['useCssVars']
+  const useDialog: typeof import('naive-ui')['useDialog']
+  const useLoadingBar: typeof import('naive-ui')['useLoadingBar']
+  const useMessage: typeof import('naive-ui')['useMessage']
+  const useNotification: typeof import('naive-ui')['useNotification']
+  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, InjectionKey, PropType, Ref, VNode, WritableComputedRef } from 'vue'
+}

+ 42 - 0
components.d.ts

@@ -0,0 +1,42 @@
+/* eslint-disable */
+/* prettier-ignore */
+// @ts-nocheck
+// Generated by unplugin-vue-components
+// Read more: https://github.com/vuejs/core/pull/3399
+export {}
+
+declare module 'vue' {
+  export interface GlobalComponents {
+    HelloWorld: typeof import('./src/components/HelloWorld.vue')['default']
+    IconCommunity: typeof import('./src/components/icons/IconCommunity.vue')['default']
+    IconDocumentation: typeof import('./src/components/icons/IconDocumentation.vue')['default']
+    IconEcosystem: typeof import('./src/components/icons/IconEcosystem.vue')['default']
+    IconGitee: typeof import('./src/components/icons/IconGitee.vue')['default']
+    IconSupport: typeof import('./src/components/icons/IconSupport.vue')['default']
+    IconTooling: typeof import('./src/components/icons/IconTooling.vue')['default']
+    NButton: typeof import('naive-ui')['NButton']
+    NCard: typeof import('naive-ui')['NCard']
+    NCheckbox: typeof import('naive-ui')['NCheckbox']
+    NCheckboxGroup: typeof import('naive-ui')['NCheckboxGroup']
+    NCountdown: typeof import('naive-ui')['NCountdown']
+    NDivider: typeof import('naive-ui')['NDivider']
+    NForm: typeof import('naive-ui')['NForm']
+    NFormItemRow: typeof import('naive-ui')['NFormItemRow']
+    NIcon: typeof import('naive-ui')['NIcon']
+    NImage: typeof import('naive-ui')['NImage']
+    NInput: typeof import('naive-ui')['NInput']
+    NInputGroup: typeof import('naive-ui')['NInputGroup']
+    NList: typeof import('naive-ui')['NList']
+    NListItem: typeof import('naive-ui')['NListItem']
+    NScrollbar: typeof import('naive-ui')['NScrollbar']
+    NSpace: typeof import('naive-ui')['NSpace']
+    NTable: typeof import('naive-ui')['NTable']
+    NTabPane: typeof import('naive-ui')['NTabPane']
+    NTabs: typeof import('naive-ui')['NTabs']
+    NThing: typeof import('naive-ui')['NThing']
+    RouterLink: typeof import('vue-router')['RouterLink']
+    RouterView: typeof import('vue-router')['RouterView']
+    TheWelcome: typeof import('./src/components/TheWelcome.vue')['default']
+    WelcomeItem: typeof import('./src/components/WelcomeItem.vue')['default']
+  }
+}

+ 14 - 0
env.d.ts

@@ -0,0 +1,14 @@
+/// <reference types="vite/client" />
+interface ImportMetaEnv {
+    readonly BASE_URL: string
+    readonly VITE_OAUTH_ISSUER: string
+    readonly VITE_PKCE_CLIENT_ID: string
+    readonly VITE_OAUTH_CLIENT_ID: string
+    readonly VITE_PKCE_REDIRECT_URI: string
+    readonly VITE_OAUTH_REDIRECT_URI: string
+    readonly VITE_OAUTH_CLIENT_SECRET: string
+}
+
+interface ImportMeta {
+    readonly env: ImportMetaEnv
+}

+ 13 - 0
index.html

@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8">
+    <link rel="icon" href="/favicon.ico">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>Vite App</title>
+  </head>
+  <body>
+    <div id="app"></div>
+    <script type="module" src="/src/main.ts"></script>
+  </body>
+</html>

File diff suppressed because it is too large
+ 4688 - 0
package-lock.json


+ 42 - 0
package.json

@@ -0,0 +1,42 @@
+{
+  "name": "login-example",
+  "version": "0.0.0",
+  "private": true,
+  "scripts": {
+    "dev": "vite --mode dev",
+    "build": "run-p type-check build-only",
+    "preview": "vite preview",
+    "build-only": "vite build",
+    "build-only:dev": "vite build --mode dev",
+    "build-only:prod": "vite build --mode prod",
+    "type-check": "vue-tsc --noEmit -p tsconfig.app.json --composite false",
+    "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
+    "format": "prettier --write src/"
+  },
+  "dependencies": {
+    "@types/crypto-js": "^4.1.2",
+    "axios": "^1.4.0",
+    "crypto-js": "^4.1.1",
+    "vue": "^3.3.4",
+    "vue-router": "^4.2.4"
+  },
+  "devDependencies": {
+    "@rushstack/eslint-patch": "^1.2.0",
+    "@tsconfig/node18": "^2.0.1",
+    "@types/node": "^18.16.17",
+    "@vitejs/plugin-vue": "^4.2.3",
+    "@vue/eslint-config-prettier": "^7.1.0",
+    "@vue/eslint-config-typescript": "^11.0.3",
+    "@vue/tsconfig": "^0.4.0",
+    "eslint": "^8.39.0",
+    "eslint-plugin-vue": "^9.11.0",
+    "naive-ui": "^2.34.4",
+    "npm-run-all": "^4.1.5",
+    "prettier": "^2.8.8",
+    "typescript": "~5.0.4",
+    "unplugin-auto-import": "^0.16.6",
+    "unplugin-vue-components": "^0.25.1",
+    "vite": "^4.3.9",
+    "vue-tsc": "^1.6.5"
+  }
+}

BIN
public/favicon.ico


+ 7 - 0
src/App.vue

@@ -0,0 +1,7 @@
+<script setup lang="ts">
+import { RouterView } from 'vue-router'
+</script>
+
+<template>
+  <RouterView />
+</template>

+ 121 - 0
src/api/Login.ts

@@ -0,0 +1,121 @@
+import { base64Str } from '@/util/pkce'
+import loginRequest from '../util/http/LoginRequest'
+
+/**
+ * 从认证服务获取AccessToken
+ * @param data 获取token入参
+ * @returns 返回AccessToken对象
+ */
+export function getToken(data: any) {
+    const headers: any = {
+        'Content-Type': 'application/x-www-form-urlencoded'
+    }
+    if (data.client_secret) {
+        // 设置客户端的basic认证
+        headers.Authorization = `Basic ${base64Str(`${data.client_id}:${data.client_secret}`)}`
+        // 移除入参中的key
+        delete data.client_id
+        delete data.client_secret
+    }
+    // 可以设置为AccessToken的类型
+    return loginRequest.post<any>({
+        url: '/oauth2/token',
+        data,
+        headers
+    })
+}
+
+/**
+ * 获取图片验证码
+ * @returns 返回图片验证码信息
+ */
+export function getImageCaptcha() {
+    return loginRequest.get<any>({
+        url: '/getCaptcha'
+    })
+}
+
+/**
+ * 提交登录表单
+ * @param data 登录表单数据
+ * @returns 登录状态
+ */
+export function loginSubmit(data: any) {
+    return loginRequest.post<any>({
+        url: '/authentication/captcha',
+        data: getFormData(data),
+        headers: {
+            // 'Content-Type': '/x-www-form-urlencoded'
+            'Content-Type': 'multipart/form-data'
+        }
+    })
+}
+
+function getFormData(object: any) {
+    const formData = new FormData()
+    Object.keys(object).forEach(key => {
+        const value = object[key]
+        if (Array.isArray(value)) {
+            value.forEach((subValue, i) =>
+                formData.append(key + `[${i}]`, subValue)
+            )
+        } else {
+            formData.append(key, object[key])
+        }
+    })
+    return formData
+}
+
+/**
+ * 根据手机号获取短信验证码
+ * @param params 手机号json,会被转为QueryString
+ * @returns 登录状态
+ */
+export function getSmsCaptchaByPhone(params: any) {
+    return loginRequest.get<any>({
+        url: '/getSmsCaptcha',
+        params
+    })
+}
+
+/**
+ * 获取授权确认页面相关数据
+ * @param queryString 查询参数,地址栏参数
+ * @returns 授权确认页面相关数据
+ */
+export function getConsentParameters(queryString: string) {
+    return loginRequest.get<any>({
+        url: `/oauth2/consent/parameters${queryString}`
+    })
+}
+
+/**
+ * 提交授权确认
+ * @param data 客户端、scope等
+ * @param requestUrl 请求地址(授权码与设备码授权提交不一样)
+ * @returns 是否确认成功
+ */
+export function submitApproveScope(data: any, requestUrl: string) {
+    return loginRequest.post<any>({
+        url: requestUrl,
+        data,
+        headers: {
+            'Content-Type': 'application/x-www-form-urlencoded'
+        }
+    })
+}
+
+/**
+ * 验证设备码
+ * @param data user_code,设备码
+ * @returns 是否确认成功
+ */
+export function deviceVerification(data: any) {
+    return loginRequest.post<any>({
+        url: `/oauth2/device_verification`,
+        data,
+        headers: {
+            'Content-Type': 'application/x-www-form-urlencoded'
+        }
+    })
+}

+ 20 - 0
src/api/QrCodeLogin.ts

@@ -0,0 +1,20 @@
+import loginRequest from '../util/http/LoginRequest'
+
+/**
+ * 生成二维码
+ */
+export function generateQrCode() {
+    return loginRequest.get<any>({
+        url: '/qrCode/login/generateQrCode'
+    })
+}
+
+/**
+ * 获取二维码信息
+ * @param qrCodeId 二维码id
+ */
+export function fetch(qrCodeId: string) {
+    return loginRequest.get<any>({
+        url: `/qrCode/login/fetch/${qrCodeId}`
+    })
+}

BIN
src/assets/GitHub-Mark.png


+ 73 - 0
src/assets/base.css

@@ -0,0 +1,73 @@
+/* color palette from <https://github.com/vuejs/theme> */
+:root {
+  --vt-c-white: #ffffff;
+  --vt-c-white-soft: #f8f8f8;
+  --vt-c-white-mute: #f2f2f2;
+
+  --vt-c-black: #181818;
+  --vt-c-black-soft: #222222;
+  --vt-c-black-mute: #282828;
+
+  --vt-c-indigo: #2c3e50;
+
+  --vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
+  --vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
+  --vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
+  --vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
+
+  --vt-c-text-light-1: var(--vt-c-indigo);
+  --vt-c-text-light-2: rgba(60, 60, 60, 0.66);
+  --vt-c-text-dark-1: var(--vt-c-white);
+  --vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
+}
+
+/* semantic color variables for this project */
+:root {
+  --color-background: var(--vt-c-white);
+  --color-background-soft: var(--vt-c-white-soft);
+  --color-background-mute: var(--vt-c-white-mute);
+
+  --color-border: var(--vt-c-divider-light-2);
+  --color-border-hover: var(--vt-c-divider-light-1);
+
+  --color-heading: var(--vt-c-text-light-1);
+  --color-text: var(--vt-c-text-light-1);
+
+  --section-gap: 160px;
+}
+
+@media (prefers-color-scheme: dark) {
+  :root {
+    --color-background: var(--vt-c-black);
+    --color-background-soft: var(--vt-c-black-soft);
+    --color-background-mute: var(--vt-c-black-mute);
+
+    --color-border: var(--vt-c-divider-dark-2);
+    --color-border-hover: var(--vt-c-divider-dark-1);
+
+    --color-heading: var(--vt-c-text-dark-1);
+    --color-text: var(--vt-c-text-dark-2);
+  }
+}
+
+*,
+*::before,
+*::after {
+  box-sizing: border-box;
+  margin: 0;
+  font-weight: normal;
+}
+
+body {
+  min-height: 100vh;
+  color: var(--color-text);
+  background: var(--color-background);
+  transition: color 0.5s, background-color 0.5s;
+  line-height: 1.6;
+  font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu,
+    Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
+  font-size: 15px;
+  text-rendering: optimizeLegibility;
+  -webkit-font-smoothing: antialiased;
+  -moz-osx-font-smoothing: grayscale;
+}

BIN
src/assets/devices.png


+ 1 - 0
src/assets/logo.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>

+ 35 - 0
src/assets/main.css

@@ -0,0 +1,35 @@
+@import './base.css';
+
+#app {
+  max-width: 1280px;
+  margin: 0 auto;
+  padding: 2rem;
+
+  font-weight: normal;
+}
+
+a,
+.green {
+  text-decoration: none;
+  color: hsla(160, 100%, 37%, 1);
+  transition: 0.4s;
+}
+
+@media (hover: hover) {
+  a:hover {
+    background-color: hsla(160, 100%, 37%, 0.2);
+  }
+}
+
+@media (min-width: 1024px) {
+  body {
+    display: flex;
+    place-items: center;
+  }
+
+  #app {
+    display: grid;
+    grid-template-columns: 1fr 1fr;
+    padding: 0 2rem;
+  }
+}

BIN
src/assets/wechat_login.png


+ 41 - 0
src/components/HelloWorld.vue

@@ -0,0 +1,41 @@
+<script setup lang="ts">
+defineProps<{
+  msg: string
+}>()
+</script>
+
+<template>
+  <div class="greetings">
+    <h1 class="green">{{ msg }}</h1>
+    <h3>
+      You’ve successfully created a project with
+      <a href="https://vitejs.dev/" target="_blank" rel="noopener">Vite</a> +
+      <a href="https://vuejs.org/" target="_blank" rel="noopener">Vue 3</a>.
+    </h3>
+  </div>
+</template>
+
+<style scoped>
+h1 {
+  font-weight: 500;
+  font-size: 2.6rem;
+  position: relative;
+  top: -10px;
+}
+
+h3 {
+  font-size: 1.2rem;
+}
+
+.greetings h1,
+.greetings h3 {
+  text-align: center;
+}
+
+@media (min-width: 1024px) {
+  .greetings h1,
+  .greetings h3 {
+    text-align: left;
+  }
+}
+</style>

+ 86 - 0
src/components/TheWelcome.vue

@@ -0,0 +1,86 @@
+<script setup lang="ts">
+import WelcomeItem from './WelcomeItem.vue'
+import DocumentationIcon from './icons/IconDocumentation.vue'
+import ToolingIcon from './icons/IconTooling.vue'
+import EcosystemIcon from './icons/IconEcosystem.vue'
+import CommunityIcon from './icons/IconCommunity.vue'
+import SupportIcon from './icons/IconSupport.vue'
+</script>
+
+<template>
+  <WelcomeItem>
+    <template #icon>
+      <DocumentationIcon />
+    </template>
+    <template #heading>Documentation</template>
+
+    Vue’s
+    <a href="https://vuejs.org/" target="_blank" rel="noopener">official documentation</a>
+    provides you with all information you need to get started.
+  </WelcomeItem>
+
+  <WelcomeItem>
+    <template #icon>
+      <ToolingIcon />
+    </template>
+    <template #heading>Tooling</template>
+
+    This project is served and bundled with
+    <a href="https://vitejs.dev/guide/features.html" target="_blank" rel="noopener">Vite</a>. The
+    recommended IDE setup is
+    <a href="https://code.visualstudio.com/" target="_blank" rel="noopener">VSCode</a> +
+    <a href="https://github.com/johnsoncodehk/volar" target="_blank" rel="noopener">Volar</a>. If
+    you need to test your components and web pages, check out
+    <a href="https://www.cypress.io/" target="_blank" rel="noopener">Cypress</a> and
+    <a href="https://on.cypress.io/component" target="_blank">Cypress Component Testing</a>.
+
+    <br />
+
+    More instructions are available in <code>README.md</code>.
+  </WelcomeItem>
+
+  <WelcomeItem>
+    <template #icon>
+      <EcosystemIcon />
+    </template>
+    <template #heading>Ecosystem</template>
+
+    Get official tools and libraries for your project:
+    <a href="https://pinia.vuejs.org/" target="_blank" rel="noopener">Pinia</a>,
+    <a href="https://router.vuejs.org/" target="_blank" rel="noopener">Vue Router</a>,
+    <a href="https://test-utils.vuejs.org/" target="_blank" rel="noopener">Vue Test Utils</a>, and
+    <a href="https://github.com/vuejs/devtools" target="_blank" rel="noopener">Vue Dev Tools</a>. If
+    you need more resources, we suggest paying
+    <a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">Awesome Vue</a>
+    a visit.
+  </WelcomeItem>
+
+  <WelcomeItem>
+    <template #icon>
+      <CommunityIcon />
+    </template>
+    <template #heading>Community</template>
+
+    Got stuck? Ask your question on
+    <a href="https://chat.vuejs.org" target="_blank" rel="noopener">Vue Land</a>, our official
+    Discord server, or
+    <a href="https://stackoverflow.com/questions/tagged/vue.js" target="_blank" rel="noopener"
+      >StackOverflow</a
+    >. You should also subscribe to
+    <a href="https://news.vuejs.org" target="_blank" rel="noopener">our mailing list</a> and follow
+    the official
+    <a href="https://twitter.com/vuejs" target="_blank" rel="noopener">@vuejs</a>
+    twitter account for latest news in the Vue world.
+  </WelcomeItem>
+
+  <WelcomeItem>
+    <template #icon>
+      <SupportIcon />
+    </template>
+    <template #heading>Support Vue</template>
+
+    As an independent project, Vue relies on community backing for its sustainability. You can help
+    us by
+    <a href="https://vuejs.org/sponsor/" target="_blank" rel="noopener">becoming a sponsor</a>.
+  </WelcomeItem>
+</template>

+ 87 - 0
src/components/WelcomeItem.vue

@@ -0,0 +1,87 @@
+<template>
+  <div class="item">
+    <i>
+      <slot name="icon"></slot>
+    </i>
+    <div class="details">
+      <h3>
+        <slot name="heading"></slot>
+      </h3>
+      <slot></slot>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.item {
+  margin-top: 2rem;
+  display: flex;
+  position: relative;
+}
+
+.details {
+  flex: 1;
+  margin-left: 1rem;
+}
+
+i {
+  display: flex;
+  place-items: center;
+  place-content: center;
+  width: 32px;
+  height: 32px;
+
+  color: var(--color-text);
+}
+
+h3 {
+  font-size: 1.2rem;
+  font-weight: 500;
+  margin-bottom: 0.4rem;
+  color: var(--color-heading);
+}
+
+@media (min-width: 1024px) {
+  .item {
+    margin-top: 0;
+    padding: 0.4rem 0 1rem calc(var(--section-gap) / 2);
+  }
+
+  i {
+    top: calc(50% - 25px);
+    left: -26px;
+    position: absolute;
+    border: 1px solid var(--color-border);
+    background: var(--color-background);
+    border-radius: 8px;
+    width: 50px;
+    height: 50px;
+  }
+
+  .item:before {
+    content: ' ';
+    border-left: 1px solid var(--color-border);
+    position: absolute;
+    left: 0;
+    bottom: calc(50% + 25px);
+    height: calc(50% - 25px);
+  }
+
+  .item:after {
+    content: ' ';
+    border-left: 1px solid var(--color-border);
+    position: absolute;
+    left: 0;
+    top: calc(50% + 25px);
+    height: calc(50% - 25px);
+  }
+
+  .item:first-of-type:before {
+    display: none;
+  }
+
+  .item:last-of-type:after {
+    display: none;
+  }
+}
+</style>

File diff suppressed because it is too large
+ 7 - 0
src/components/icons/IconCommunity.vue


File diff suppressed because it is too large
+ 7 - 0
src/components/icons/IconDocumentation.vue


File diff suppressed because it is too large
+ 7 - 0
src/components/icons/IconEcosystem.vue


File diff suppressed because it is too large
+ 22 - 0
src/components/icons/IconGitee.vue


+ 7 - 0
src/components/icons/IconSupport.vue

@@ -0,0 +1,7 @@
+<template>
+  <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
+    <path
+      d="M10 3.22l-.61-.6a5.5 5.5 0 0 0-7.666.105 5.5 5.5 0 0 0-.114 7.665L10 18.78l8.39-8.4a5.5 5.5 0 0 0-.114-7.665 5.5 5.5 0 0 0-7.666-.105l-.61.61z"
+    />
+  </svg>
+</template>

+ 19 - 0
src/components/icons/IconTooling.vue

@@ -0,0 +1,19 @@
+<!-- This icon is from <https://github.com/Templarian/MaterialDesign>, distributed under Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) license-->
+<template>
+  <svg
+    xmlns="http://www.w3.org/2000/svg"
+    xmlns:xlink="http://www.w3.org/1999/xlink"
+    aria-hidden="true"
+    role="img"
+    class="iconify iconify--mdi"
+    width="24"
+    height="24"
+    preserveAspectRatio="xMidYMid meet"
+    viewBox="0 0 24 24"
+  >
+    <path
+      d="M20 18v-4h-3v1h-2v-1H9v1H7v-1H4v4h16M6.33 8l-1.74 4H7v-1h2v1h6v-1h2v1h2.41l-1.74-4H6.33M9 5v1h6V5H9m12.84 7.61c.1.22.16.48.16.8V18c0 .53-.21 1-.6 1.41c-.4.4-.85.59-1.4.59H4c-.55 0-1-.19-1.4-.59C2.21 19 2 18.53 2 18v-4.59c0-.32.06-.58.16-.8L4.5 7.22C4.84 6.41 5.45 6 6.33 6H7V5c0-.55.18-1 .57-1.41C7.96 3.2 8.44 3 9 3h6c.56 0 1.04.2 1.43.59c.39.41.57.86.57 1.41v1h.67c.88 0 1.49.41 1.83 1.22l2.34 5.39z"
+      fill="currentColor"
+    ></path>
+  </svg>
+</template>

+ 11 - 0
src/main.ts

@@ -0,0 +1,11 @@
+import './assets/main.css'
+import router from './router'
+
+import { createApp } from 'vue'
+import App from './App.vue'
+
+const app = createApp(App)
+
+app.use(router)
+
+app.mount('#app')

+ 59 - 0
src/router/index.ts

@@ -0,0 +1,59 @@
+import { createRouter, createWebHistory } from 'vue-router'
+
+const router = createRouter({
+    history: createWebHistory(import.meta.env.BASE_URL),
+    routes: [
+        {
+            path: '/',
+            name: 'index',
+            component: () => import('../views/index/Index.vue')
+        },
+        {
+            path: '/login',
+            name: 'login',
+            component: () => import('../views/login/Login.vue')
+        },
+        {
+            path: '/consent',
+            name: 'consent',
+            // route level code-splitting
+            // this generates a separate chunk (About.[hash].js) for this route
+            // which is lazy-loaded when the route is visited.
+            component: () => import('../views/consent/Consent.vue')
+        },
+        {
+            path: '/activate',
+            name: 'activate',
+            // route level code-splitting
+            // this generates a separate chunk (About.[hash].js) for this route
+            // which is lazy-loaded when the route is visited.
+            component: () => import('../views/device/Activate.vue')
+        },
+        {
+            path: '/activated',
+            name: 'activated',
+            // route level code-splitting
+            // this generates a separate chunk (About.[hash].js) for this route
+            // which is lazy-loaded when the route is visited.
+            component: () => import('../views/device/Activated.vue')
+        },
+        {
+            path: '/PkceRedirect',
+            name: 'PkceRedirect',
+            // route level code-splitting
+            // this generates a separate chunk (About.[hash].js) for this route
+            // which is lazy-loaded when the route is visited.
+            component: () => import('../views/login/PkceRedirect.vue')
+        },
+        {
+            path: '/OAuth2Redirect',
+            name: 'OAuth2Redirect',
+            // route level code-splitting
+            // this generates a separate chunk (About.[hash].js) for this route
+            // which is lazy-loaded when the route is visited.
+            component: () => import('../views/login/OAuthRedirect.vue')
+        }
+    ]
+})
+
+export default router

+ 16 - 0
src/util/GlobalUtils.ts

@@ -0,0 +1,16 @@
+/**
+ * 根据参数name获取地址栏的参数
+ * @param name 地址栏参数的key
+ * @returns key对用的值
+ */
+export function getQueryString(name: string) {
+    const reg = new RegExp('(^|&)' + name + '=([^&]*)(&|$)', 'i')
+
+    const r = window.location.search.substr(1).match(reg)
+
+    if (r != null) {
+        return decodeURIComponent(r[2])
+    }
+
+    return null
+}

+ 133 - 0
src/util/http/Http.ts

@@ -0,0 +1,133 @@
+// index.ts
+import axios from "axios";
+import type { AxiosInstance, AxiosRequestConfig, InternalAxiosRequestConfig, AxiosResponse } from "axios";
+
+type Result<T> = {
+    code: number;
+    message: string;
+    result: T;
+};
+
+// 导出Request类,可以用来自定义传递配置来创建实例
+export class Request {
+    // axios 实例
+    instance: AxiosInstance;
+    // 基础配置,url和超时时间
+    baseConfig: AxiosRequestConfig = {};
+
+    constructor(config: AxiosRequestConfig) {
+        // 使用axios.create创建axios实例
+        this.instance = axios.create(Object.assign(this.baseConfig, config));
+
+        this.instance.interceptors.request.use(
+            (config: InternalAxiosRequestConfig) => {
+                // 一般会请求拦截里面加token,用于后端的验证
+                let token: any = ''
+                token = localStorage.getItem("_token")
+                if(token){
+                    if (config.url !== '/oauth2/token') {
+                        config.headers!.Authorization = `Bearer ${token}`;
+                    }
+                }else{
+                    token = localStorage.getItem("accessToken")
+                    if (token && config.url !== '/oauth2/token') {
+                        config.headers!.Authorization = `${token.token_type} ${token.access_token}`;
+                    }
+                }              
+
+                return config;
+            },
+            (err: any) => {
+                // 请求错误,这里可以用全局提示框进行提示
+                return Promise.reject(err);
+            }
+        );
+
+        this.instance.interceptors.response.use(
+            (res: AxiosResponse) => {
+                // 直接返回res,当然你也可以只返回res.data
+                // 系统如果有自定义code也可以在这里处理
+                return res.data;
+            },
+            (err: any) => {
+                if (err.code === 'ERR_NETWORK') {
+                    return Promise.reject(err);
+                }
+                // 这里用来处理http常见错误,进行全局提示
+                let messageText = "";
+                switch (err.response.status) {
+                    case 400:
+                        messageText = "请求参数错误(400)";
+                        break;
+                    case 401:
+                        messageText = "未授权,请重新登录(401)";
+                        // 这里可以做清空storage并跳转到登录页的操作
+                        break;
+                    case 403:
+                        messageText = "拒绝访问(403)";
+                        break;
+                    case 404:
+                        messageText = "请求路径出错(404)";
+                        break;
+                    case 408:
+                        messageText = "请求超时(408)";
+                        break;
+                    case 500:
+                        messageText = "服务器错误(500)";
+                        break;
+                    case 501:
+                        messageText = "服务未实现(501)";
+                        break;
+                    case 502:
+                        messageText = "网络错误(502)";
+                        break;
+                    case 503:
+                        messageText = "服务不可用(503)";
+                        break;
+                    case 504:
+                        messageText = "网络超时(504)";
+                        break;
+                    case 505:
+                        messageText = "HTTP版本不受支持(505)";
+                        break;
+                    default:
+                        messageText = `连接出错(${err.response.status})!`;
+                }
+                err.response.statusText = messageText
+                // 这里错误消息可以使用全局弹框展示出来
+                // 比如element plus 可以使用 ElMessage
+                // ElMessage({
+                //   showClose: true,
+                //   message: `${message},请检查网络或联系管理员!`,
+                //   type: "error",
+                // });
+                // 这里是AxiosError类型,所以一般我们只reject我们需要的响应即可
+                return Promise.reject(err.response);
+            }
+        );
+    }
+
+    // 定义请求方法
+    request<T>(config: AxiosRequestConfig): Promise<AxiosResponse<T>> {
+        return this.instance.request(config);
+    }
+
+    get<T>(config?: AxiosRequestConfig<T>): Promise<AxiosResponse<T>> {
+        return this.request<T>({ ...config, method: 'GET' })
+    }
+    post<T>(config?: AxiosRequestConfig<T>): Promise<AxiosResponse<T>> {
+        return this.request<T>({ ...config, method: 'POST' })
+    }
+    delete<T>(config?: AxiosRequestConfig<T>): Promise<AxiosResponse<T>> {
+        return this.request<T>({ ...config, method: 'DELETE' })
+    }
+    put<T>(config?: AxiosRequestConfig<T>): Promise<AxiosResponse<T>> {
+        return this.request<T>({ ...config, method: 'put' })
+    }
+    patch<T>(config?: AxiosRequestConfig<T>): Promise<AxiosResponse<T>> {
+        return this.request<T>({ ...config, method: 'PATCH' })
+    }
+}
+
+// 默认导出Request实例
+export default Request

+ 10 - 0
src/util/http/LoginRequest.ts

@@ -0,0 +1,10 @@
+import Request from './Http'
+
+const loginRequest = new Request({
+    // 认证服务地址
+    baseURL: import.meta.env.VITE_OAUTH_ISSUER,
+    timeout: 60 * 1000,
+    withCredentials: false
+})
+
+export default loginRequest

+ 9 - 0
src/util/http/Reqeust.ts

@@ -0,0 +1,9 @@
+import Request from './Http'
+
+const request = new Request({
+    // 网关或其它后端服务地址
+    baseURL: '',
+    timeout: 60 * 1000
+})
+
+export default request

+ 55 - 0
src/util/pkce/index.ts

@@ -0,0 +1,55 @@
+import CryptoJS from 'crypto-js'
+
+/**
+ * 生成 CodeVerifier
+ *
+ * return CodeVerifier
+ */
+export function generateCodeVerifier() {
+    return generateRandomString(32)
+}
+
+/**
+ * 生成随机字符串
+ * @param length 随机字符串的长度
+ * @returns 随机字符串
+ */
+export function generateRandomString(length: number) {
+    let text = ''
+    const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
+    for (let i = 0; i < length; i++) {
+        text += possible.charAt(Math.floor(Math.random() * possible.length))
+    }
+    return text
+}
+
+/**
+ * 生成 Code Challenge
+ * @param code_verifier 上边生成的 CodeVerifier
+ * @returns Code Challenge
+ */
+export function generateCodeChallenge(code_verifier: string) {
+    return base64URL(CryptoJS.SHA256(code_verifier))
+}
+
+/**
+ * 将字符串base64加密后在转为url string
+ * @param str 字符串
+ * @returns bese64转码后转为url string
+ */
+export function base64URL(str: CryptoJS.lib.WordArray) {
+    return str
+        .toString(CryptoJS.enc.Base64)
+        .replace(/=/g, '')
+        .replace(/\+/g, '-')
+        .replace(/\//g, '_')
+}
+
+/**
+ * 将字符串加密为Base64格式的
+ * @param str 将要转为base64的字符串
+ * @returns 返回base64格式的字符串
+ */
+export function base64Str(str: string) {
+    return CryptoJS.enc.Base64.stringify(CryptoJS.enc.Utf8.parse(str));
+}

+ 167 - 0
src/views/consent/Consent.vue

@@ -0,0 +1,167 @@
+<script setup lang="ts">
+import { type Ref, ref } from 'vue'
+import { createDiscreteApi } from 'naive-ui'
+import { getQueryString } from '@/util/GlobalUtils'
+import { getConsentParameters, submitApproveScope } from '@/api/Login'
+
+const { message } = createDiscreteApi(['message'])
+
+// 获取授权确认信息响应
+const consentResult: Ref<any> = ref()
+// 所有的scope
+const scopes = ref()
+// 已授权的scope
+const approvedScopes = ref()
+// 提交/拒绝按钮加载状态
+const loading = ref(false)
+debugger;
+/**
+ * 初始化需要授权确认的客户端与scope
+ */
+getConsentParameters(window.location.search)
+  .then((result: any) => {
+    debugger
+    if (result.success) {
+      consentResult.value = result.data
+      scopes.value = [...result.data.previouslyApprovedScopes, ...result.data.scopes]
+      approvedScopes.value = result.data.previouslyApprovedScopes.map((e: any) => e.scope)
+    } else {
+      message.warning(result.message)
+    }
+  })
+  .catch((e: any) => {
+    message.warning(`获取客户端与scope信息失败:${e.message || e.statusText}`)
+  })
+
+/**
+ * 提交授权确认
+ *
+ * @param cancel true为取消
+ */
+const submitApprove = (cancel: boolean) => {
+  debugger
+  if (!consentResult.value) {
+    message.warning(`初始化未完成,无法提交`)
+    return
+  }
+  loading.value = true
+  console.log(consentResult)
+  const data = new FormData()
+  if (!cancel) {
+    // 如果不是取消添加scope
+    if (
+      approvedScopes.value !== null &&
+      typeof approvedScopes.value !== 'undefined' &&
+      approvedScopes.value.length > 0
+    ) {
+      approvedScopes.value.forEach((e: any) => data.append('scope', e))
+    }
+  }
+  data.append('state', consentResult.value.state)
+  data.append('client_id', consentResult.value.clientId)
+  data.append('user_code', consentResult.value.userCode)
+
+  submitApproveScope(
+    // @ts-ignore
+    new URLSearchParams(data),
+    consentResult.value.requestURI
+  )
+    .then((result: any) => {
+      if (result.success) {
+        window.location.href = result.data
+      } else {
+        if (result.message && result.message.indexOf('access_denied') > -1) {
+          // 可以跳转至一个单独的页面提醒.
+          message.warning('您未选择scope或拒绝了本次授权申请.')
+        } else {
+          message.warning(result.message)
+        }
+      }
+    })
+    .catch((e: any) => {
+      message.warning(`提交授权确认失败:${e.message || e.statusText}`)
+    })
+    .finally(() => (loading.value = false))
+}
+</script>
+
+<template>
+  <header>
+    <img alt="Vue logo" class="logo" src="../../assets/logo.svg" width="125" height="125" />
+
+    <div class="wrapper">
+      <HelloWorld msg="OAuth 授权请求" />
+    </div>
+  </header>
+
+  <main>
+    <n-card v-if="consentResult && consentResult.userCode">
+      您已经提供了代码
+      <b>{{ consentResult.userCode }}</b>
+      ,请验证此代码是否与设备上显示的代码匹配。
+    </n-card>
+    <br />
+    <n-card :title="`${consentResult.clientName} 客户端`" v-if="consentResult">
+      <template #header-extra>
+        账号:
+        <b>{{ consentResult.principalName }}</b>
+      </template>
+      此第三方应用请求获得以下权限
+    </n-card>
+    <n-scrollbar style="max-height: 230px">
+      <n-checkbox-group v-model:value="approvedScopes">
+        <n-list>
+          <n-list-item v-for="scope in scopes" :key="scope">
+            <template #prefix>
+              <n-checkbox :value="scope.scope"> </n-checkbox>
+            </template>
+            <n-thing :title="scope.scope" :description="scope.description" />
+          </n-list-item>
+        </n-list>
+      </n-checkbox-group>
+    </n-scrollbar>
+    <br />
+    <n-button type="info" :loading="loading" @click="submitApprove(false)" strong>
+      &nbsp;&nbsp;&nbsp;&nbsp;确&nbsp;&nbsp;&nbsp;&nbsp;定&nbsp;&nbsp;&nbsp;&nbsp;
+    </n-button>
+    &nbsp;&nbsp;&nbsp;&nbsp;
+    <n-button type="warning" :loading="loading" @click="submitApprove(true)">
+      &nbsp;&nbsp;&nbsp;&nbsp;拒&nbsp;&nbsp;&nbsp;&nbsp;绝&nbsp;&nbsp;&nbsp;&nbsp;
+    </n-button>
+  </main>
+</template>
+
+<style scoped>
+header {
+  line-height: 1.5;
+}
+
+.logo {
+  display: block;
+  margin: 0 auto 2rem;
+}
+
+@media (min-width: 1024px) {
+  header {
+    display: flex;
+    place-items: center;
+    padding-right: calc(var(--section-gap) / 2);
+  }
+
+  .logo {
+    margin: 0 2rem 0 0;
+  }
+
+  header .wrapper {
+    display: flex;
+    place-items: flex-start;
+    flex-wrap: wrap;
+  }
+}
+
+b,
+h3,
+::v-deep(.n-card-header__main) {
+  font-weight: bold !important;
+}
+</style>

+ 109 - 0
src/views/device/Activate.vue

@@ -0,0 +1,109 @@
+<script setup lang="ts">
+import { ref } from 'vue'
+import { createDiscreteApi } from 'naive-ui'
+import { deviceVerification } from '@/api/Login'
+import { getQueryString } from '@/util/GlobalUtils'
+
+const { message } = createDiscreteApi(['message'])
+
+// 提交按钮加载状态
+const loading = ref(false)
+
+const userCode = ref({
+  userCode: getQueryString('userCode')
+})
+
+/**
+ * 验证设备码
+ */
+const submit = () => {
+  if (!userCode.value.userCode) {
+    message.warning(`请输入设备码`)
+    return
+  }
+  loading.value = true
+  const data = {
+    user_code: userCode.value.userCode
+  }
+
+  deviceVerification(data)
+    .then((result: any) => {
+      if (result.success) {
+        window.location.href = result.data
+      } else {
+        message.warning(result.message)
+      }
+    })
+    .catch((e: any) => {
+      message.warning(`提交设备码失败:${e.message || e.statusText}`)
+    })
+    .finally(() => (loading.value = false))
+}
+
+// 如果地址栏有参数直接提交
+if (userCode.value.userCode) {
+  submit()
+}
+</script>
+
+<template>
+  <header>
+    <img alt="Vue logo" class="logo" src="../../assets/devices.png" width="125" height="125" />
+
+    <div class="wrapper">
+      <HelloWorld msg="设备激活" />
+    </div>
+  </header>
+
+  <main>
+    <n-card> 输入激活码对设备进行授权。 </n-card>
+    <br />
+    <n-card>
+      <n-form-item-row label="Activation Code">
+        <n-input
+          v-model:value="userCode.userCode"
+          placeholder="User Code,格式:XXXX-XXXX,错误的格式后端会报错"
+          maxlength="9"
+          show-count
+          clearable
+        />
+      </n-form-item-row>
+      <n-button type="info" :loading="loading" @click="submit" block strong> 提交 </n-button>
+    </n-card>
+  </main>
+</template>
+
+<style scoped>
+header {
+  line-height: 1.5;
+}
+
+.logo {
+  display: block;
+  margin: 0 auto 2rem;
+}
+
+@media (min-width: 1024px) {
+  header {
+    display: flex;
+    place-items: center;
+    padding-right: calc(var(--section-gap) / 2);
+  }
+
+  .logo {
+    margin: 0 2rem 0 0;
+  }
+
+  header .wrapper {
+    display: flex;
+    place-items: flex-start;
+    flex-wrap: wrap;
+  }
+}
+
+b,
+h3,
+::v-deep(.n-card-header__main) {
+  font-weight: bold !important;
+}
+</style>

+ 52 - 0
src/views/device/Activated.vue

@@ -0,0 +1,52 @@
+<script lang="ts" setup></script>
+<template>
+  <header>
+    <img alt="Vue logo" class="logo" src="../../assets/devices.png" width="125" height="125" />
+
+    <div class="wrapper">
+      <HelloWorld msg="设备激活" />
+    </div>
+  </header>
+
+  <main>
+    <div style="font-size: 30px">
+      您已成功激活您的设备。
+      <br />
+      请返回到您的设备继续。
+    </div>
+  </main>
+</template>
+<style scoped>
+header {
+  line-height: 1.5;
+}
+
+.logo {
+  display: block;
+  margin: 0 auto 2rem;
+}
+
+@media (min-width: 1024px) {
+  header {
+    display: flex;
+    place-items: center;
+    padding-right: calc(var(--section-gap) / 2);
+  }
+
+  .logo {
+    margin: 0 2rem 0 0;
+  }
+
+  header .wrapper {
+    display: flex;
+    place-items: flex-start;
+    flex-wrap: wrap;
+  }
+}
+
+b,
+h3,
+::v-deep(.n-card-header__main) {
+  font-weight: bold !important;
+}
+</style>

+ 70 - 0
src/views/index/Index.vue

@@ -0,0 +1,70 @@
+<template>
+  <div class="welcome">
+    <n-card class="navbar"><b>Spring Authorization Server 前后端分离示例项目</b></n-card>
+    <n-space class="features">
+      <n-card title="登录页面" @click="pathRoute('/login')" hoverable> /login </n-card>
+      <n-card title="授权确认页面" @click="pathRoute('/consent')" hoverable> /consent</n-card>
+      <n-card title="设备码验证页面" @click="pathRoute('/activate')" hoverable> /activate</n-card>
+      <n-card title="验证成功页面" @click="pathRoute('/activated')" hoverable> /activated </n-card>
+      <br />
+      <n-card title="授权码模式" @click="pathRoute('/OAuth2Redirect')" hoverable>
+        发起授权码模式的授权申请
+      </n-card>
+      <n-card title="PKCE模式" @click="pathRoute('/PkceRedirect')" hoverable>
+        发起PKCE模式的授权申请
+      </n-card>
+      <n-card title="Token展示" hoverable v-if="accessToken">
+        <n-table :single-line="false">
+          <thead>
+            <tr>
+              <th style="width: 110px">Key</th>
+              <th>Value</th>
+            </tr>
+          </thead>
+          <tbody>
+            <tr v-for="(v, k) in accessToken" :key="k">
+              <td>{{ k }}</td>
+              <td>{{ v }}</td>
+            </tr>
+          </tbody>
+        </n-table>
+      </n-card>
+    </n-space>
+  </div>
+</template>
+
+<script setup lang="ts">
+import router from '../../router'
+// import { createDiscreteApi } from 'naive-ui'
+
+// const { message } = createDiscreteApi(['message'])
+// 从缓存中获取token
+const accessToken = JSON.parse(String(localStorage.getItem('accessToken')))
+
+/**
+ * 根据路径跳转路由
+ * @param path 路由路径
+ */
+const pathRoute = (path: string) => {
+  router.push({ path })
+}
+
+// const todo = () => {
+//   message.info('待开发')
+// }
+</script>
+
+<style scoped>
+.welcome {
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+}
+.features {
+  padding: 8px;
+}
+.features div {
+  cursor: pointer;
+}
+</style>

+ 364 - 0
src/views/login/Login.vue

@@ -0,0 +1,364 @@
+<script setup lang="ts">
+import { ref } from 'vue'
+import router from '../../router'
+import { getQueryString } from '@/util/GlobalUtils'
+import { generateQrCode, fetch } from '@/api/QrCodeLogin'
+import { type CountdownProps, createDiscreteApi } from 'naive-ui'
+import {
+  getImageCaptcha,
+  getSmsCaptchaByPhone,
+  goAuthorizate,
+  loginSubmit
+} from '@/api/Login'
+import Request from '../../util/http/Http'
+const { message } = createDiscreteApi(['message'])
+
+// 登录按钮加载状态
+const loading = ref(false)
+
+// 定义登录提交的对象
+const loginModel = ref({
+  code: '',
+  username: '',
+  password: '',
+  loginType: '',
+  captchaId: '',
+  captcha: ''
+})
+
+// 图形验证码的base64数据
+let captchaImage = ref('')
+// 图形验证码的值
+let captchaCode = ''
+// 是否开始倒计时
+const counterActive = ref(false)
+// 是否显示三方登录
+const showThirdLogin = ref(true)
+
+// 定义二维码信息的对象
+const qrCodeInfo = ref({
+  qrCodeStatus: 0,
+  expired: false,
+  avatarUrl: '',
+  name: '',
+  scopes: []
+})
+
+// 生成二维码响应数据
+const getQrCodeInfo = ref({
+  qrCodeId: '',
+  imageData: ''
+})
+
+// 是否自动提交授权确认(二维码登录自动提交)
+const autoConsentKey: string = 'autoConsent'
+
+/**
+ * 获取图形验证码
+ */
+const getCaptcha = () => {
+  getImageCaptcha()
+    .then((result: any) => {
+      if (result.success) {
+        captchaCode = result.data.code
+        captchaImage.value = result.data.imageData
+        loginModel.value.captchaId = result.data.captchaId
+      } else {
+        message.warning(result.message)
+      }
+    })
+    .catch((e: any) => {
+      message.warning(`获取图形验证码失败:${e.message}`)
+    })
+}
+
+/**
+ * 提交登录表单
+ * @param type 登录类型,passwordLogin是密码模式,smsCaptcha短信登录
+ */
+const submitLogin = (type: string) => {
+  debugger
+  loading.value = true
+  loginModel.value.loginType = type
+  loginModel.value.captcha = loginModel.value.code
+  loginSubmit(loginModel.value)
+    .then((result: any) => {
+      if (result.success) {
+        // debugger
+        localStorage.setItem("_token",result.data)
+        // 移除自动提交缓存
+        localStorage.removeItem(autoConsentKey)
+        // message.info(`登录成功`)
+        let target = getQueryString('target')
+        if (target) {
+          debugger
+          window.location.href = target + '&token='+result.data;
+        } else {
+          // 跳转到首页
+          router.push({ path: '/' })
+        }
+      } else {
+        message.warning(result.message)
+      }
+    })
+    .catch((e: any) => {
+      message.warning(`登录失败:${e.message}`)
+    })
+    .finally(() => {
+      loading.value = false
+    })
+}
+
+/**
+ * 获取短信验证码
+ */
+const getSmsCaptcha = () => {
+  if (!loginModel.value.username) {
+    message.warning('请先输入手机号.')
+    return
+  }
+  if (!loginModel.value.code) {
+    message.warning('请先输入验证码.')
+    return
+  }
+  if (loginModel.value.code !== captchaCode) {
+    message.warning('验证码错误.')
+    return
+  }
+  getSmsCaptchaByPhone({ phone: loginModel.value.username })
+    .then((result: any) => {
+      if (result.success) {
+        message.info(`获取短信验证码成功,固定为:${result.data}`)
+        counterActive.value = true
+      } else {
+        message.warning(result.message)
+      }
+    })
+    .catch((e: any) => {
+      message.warning(`获取短信验证码失败:${e.message}`)
+    })
+}
+
+/**
+ * 切换时更新验证码
+ * @param name tab的名字
+ */
+const handleUpdateValue = (name: string) => {
+  // 二维码登录时隐藏三方登录
+  showThirdLogin.value = name !== 'qrcode'
+  if (!showThirdLogin.value) {
+    refreshQrCode()
+  } else {
+    getCaptcha()
+  }
+}
+
+/**
+ * 生成二维码
+ */
+const refreshQrCode = () => {
+  generateQrCode()
+    .then((r) => {
+      getQrCodeInfo.value.qrCodeId = r.data.qrCodeId
+      getQrCodeInfo.value.imageData = r.data.imageData
+      // 开始轮询获取二维码信息
+      fetchQrCodeInfo(r.data.qrCodeId);
+    })
+    .catch((e: any) => {
+      message.warning(`生成二维码失败:${e.message}`)
+    })
+}
+
+/**
+ * 根据二维码id轮询二维码信息
+ * @param qrCodeId 二维码id
+ */
+const fetchQrCodeInfo = (qrCodeId: string) => {
+  fetch(qrCodeId)
+    .then((r: any) => {
+      if (r.success) {
+        qrCodeInfo.value = r.data
+        if (qrCodeInfo.value.qrCodeStatus !== 0 && qrCodeInfo.value.avatarUrl) {
+          // 只要不是待扫描并且头像不为空
+          getQrCodeInfo.value.imageData = qrCodeInfo.value.avatarUrl
+        }
+
+        if (r.data.qrCodeStatus !== 2 && !qrCodeInfo.value.expired) {
+          if (!showThirdLogin.value) {
+            // 显示三方登录代表不是二维码登录,不轮询;否则继续轮询
+            // 1秒后重复调用
+            setTimeout(() => {
+              fetchQrCodeInfo(qrCodeId)
+            }, 1000);
+          }
+          return
+        }
+        if (qrCodeInfo.value.expired) {
+          // 二维码过期
+          return
+        }
+        if (qrCodeInfo.value.qrCodeStatus === 2) {
+          // 已确认
+          let href = getQueryString('target')
+          if (href) {
+            // 确认后将地址重定向
+            window.location.href = href
+          } else {
+            // 跳转到首页
+            router.push({ path: '/' })
+          }
+        }
+      } else {
+        message.warning(r.message)
+      }
+    })
+    .catch((e: any) => {
+      message.warning(`获取二维码信息失败:${e.message || e.statusText}`)
+    })
+}
+
+/**
+ * 倒计时结束
+ */
+const onFinish = () => {
+  counterActive.value = false
+}
+
+/**
+ * 倒计时显示内容
+ */
+const renderCountdown: CountdownProps['render'] = ({ hours, minutes, seconds }) => {
+  return `${seconds}`
+}
+
+/**
+ * 根据类型发起OAuth2授权申请
+ * @param type 三方OAuth2登录提供商类型
+ */
+const thirdLogin = (type: string) => {
+  window.location.href = `${import.meta.env.VITE_OAUTH_ISSUER}/oauth2/authorization/${type}`
+}
+
+getCaptcha()
+</script>
+
+<template>
+  <header>
+    <img alt="Vue logo" class="logo" src="../../assets/logo.svg" width="125" height="125" />
+
+    <div class="wrapper">
+      <HelloWorld msg="统一认证平台" />
+    </div>
+  </header>
+
+  <main>
+    <n-card title="">
+      <n-tabs default-value="signin" size="large" justify-content="space-evenly" @update:value="handleUpdateValue">
+        <n-tab-pane name="signin" tab="账号登录">
+          <n-form>
+            <n-form-item-row label="用户名">
+              <n-input v-model:value="loginModel.username" placeholder="手机号 / 邮箱" />
+            </n-form-item-row>
+            <n-form-item-row label="密码">
+              <n-input v-model:value="loginModel.password" type="password" show-password-on="mousedown"
+                placeholder="密码" />
+            </n-form-item-row>
+            <n-form-item-row label="验证码">
+              <n-input-group>
+                <n-input v-model:value="loginModel.code" placeholder="请输入验证码" />
+                <n-image @click="getCaptcha" width="130" height="34" :src="captchaImage" preview-disabled />
+              </n-input-group>
+            </n-form-item-row>
+          </n-form>
+          <n-button type="info" :loading="loading" @click="submitLogin('passwordLogin')" block strong>
+            登录
+          </n-button>
+        </n-tab-pane>
+        <n-tab-pane name="signup" tab="短信登录">
+          <n-form>
+            <n-form-item-row label="手机号">
+              <n-input v-model:value="loginModel.username" placeholder="手机号 / 邮箱" />
+            </n-form-item-row>
+            <n-form-item-row label="验证码">
+              <n-input-group>
+                <n-input v-model:value="loginModel.code" placeholder="请输入验证码" />
+                <n-image @click="getCaptcha" width="130" height="34" :src="captchaImage" preview-disabled />
+              </n-input-group>
+            </n-form-item-row>
+            <n-form-item-row label="验证码">
+              <n-input-group>
+                <n-input v-model:value="loginModel.password" placeholder="请输入验证码" />
+                <n-button type="info" @click="getSmsCaptcha" style="width: 130px" :disabled="counterActive">
+                  获取验证码
+                  <span v-if="counterActive">
+                    (
+                    <n-countdown :render="renderCountdown" :on-finish="onFinish" :duration="59 * 1000"
+                      :active="counterActive" />
+                    )</span>
+                </n-button>
+              </n-input-group>
+            </n-form-item-row>
+          </n-form>
+          <n-button type="info" :loading="loading" @click="submitLogin('smsCaptcha')" block strong>
+            登录
+          </n-button>
+        </n-tab-pane>
+        <n-tab-pane name="qrcode" tab="扫码登录" style="text-align: center">
+          <div style="margin: 5.305px">
+            <n-image width="300" :src="getQrCodeInfo.imageData" preview-disabled />
+          </div>
+        </n-tab-pane>
+      </n-tabs>
+      <n-divider style="font-size: 80%; color: #909399">
+        {{ showThirdLogin ? '其它登录方式' : '使用app扫描二维码登录' }}
+      </n-divider>
+      <div class="other_login_icon" v-if="showThirdLogin">
+        <IconGitee :size="32" @click="thirdLogin('gitee')" class="icon_item" />
+        <img width="36" height="36" @click="thirdLogin('github')" src="../../assets/GitHub-Mark.png" class="icon_item" />
+        <img width="28" height="28" @click="thirdLogin('wechat')" src="../../assets/wechat_login.png" class="icon_item" />
+      </div>
+    </n-card>
+  </main>
+</template>
+
+<style scoped>
+.other_login_icon {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 0 10px;
+  position: relative;
+  margin-top: -5px;
+}
+
+.icon_item {
+  cursor: pointer;
+}
+
+header {
+  line-height: 1.5;
+}
+
+.logo {
+  display: block;
+  margin: 0 auto 2rem;
+}
+
+@media (min-width: 1024px) {
+  header {
+    display: flex;
+    place-items: center;
+    padding-right: calc(var(--section-gap) / 2);
+  }
+
+  .logo {
+    margin: 0 2rem 0 0;
+  }
+
+  header .wrapper {
+    display: flex;
+    place-items: flex-start;
+    flex-wrap: wrap;
+  }
+}
+</style>

+ 57 - 0
src/views/login/OAuthRedirect.vue

@@ -0,0 +1,57 @@
+<script setup lang="ts">
+import router from '../../router'
+import { getToken } from '@/api/Login'
+import { createDiscreteApi } from 'naive-ui'
+import { generateCodeVerifier } from '@/util/pkce'
+import { getQueryString } from '@/util/GlobalUtils'
+
+const { message } = createDiscreteApi(['message'])
+
+// 生成state
+let state: string = generateCodeVerifier()
+
+// 获取地址栏授权码
+const code = getQueryString('code')
+
+if (code) {
+  debugger
+  // 从缓存中获取 codeVerifier
+  const state = localStorage.getItem('state')
+  // 校验state,防止cors
+  const urlState = getQueryString('state')
+  if (urlState !== state) {
+    message.warning('state校验失败.')
+  } else {
+    // 从缓存中获取 codeVerifier
+    getToken({
+      grant_type: 'authorization_code',
+      client_id: import.meta.env.VITE_OAUTH_CLIENT_ID,
+      client_secret: import.meta.env.VITE_OAUTH_CLIENT_SECRET,
+      redirect_uri: import.meta.env.VITE_OAUTH_REDIRECT_URI,
+      code,
+      state
+    })
+      .then((res: any) => {
+        localStorage.setItem('accessToken', JSON.stringify(res))
+        router.push({ path: '/' })
+      })
+      .catch((e) => {
+        message.warning(`请求token失败:${e.data.error || e.message || e.statusText}`)
+      })
+  }
+} else {
+  debugger
+  // 缓存state
+  localStorage.setItem('state', state)
+  var redirectUri = encodeURIComponent(import.meta.env.VITE_OAUTH_REDIRECT_URI)
+  var url = `${import.meta.env.VITE_OAUTH_ISSUER}/oauth2/authorize?client_id=${
+    import.meta.env.VITE_OAUTH_CLIENT_ID
+  }&response_type=code&scope=openid%20profile%20message.read%20message.write&redirect_uri=${
+    redirectUri
+  }&state=${state}`
+  console.log(url)
+  window.location.href = url
+}
+</script>
+
+<template>加载中...</template>

+ 61 - 0
src/views/login/PkceRedirect.vue

@@ -0,0 +1,61 @@
+<script setup lang="ts">
+import router from '../../router'
+import { getToken } from '@/api/Login'
+import { getQueryString } from '@/util/GlobalUtils'
+import { createDiscreteApi } from 'naive-ui'
+import { generateCodeVerifier, generateCodeChallenge } from '@/util/pkce'
+
+const { message } = createDiscreteApi(['message'])
+
+// 生成CodeVerifier
+let codeVerifier: string = generateCodeVerifier()
+// codeChallenge
+let codeChallenge: string = generateCodeChallenge(codeVerifier)
+// 生成state
+let state: string = generateCodeVerifier()
+
+// 获取地址栏授权码
+const code = getQueryString('code')
+
+if (code) {
+  // 从缓存中获取 codeVerifier
+  const state = localStorage.getItem('state')
+  // 校验state,防止cors
+  const urlState = getQueryString('state')
+  if (urlState !== state) {
+    message.warning('state校验失败.')
+  } else {
+    // 从缓存中获取 codeVerifier
+    const code_verifier = localStorage.getItem('codeVerifier')
+    getToken({
+      grant_type: 'authorization_code',
+      client_id: import.meta.env.VITE_PKCE_CLIENT_ID,
+      redirect_uri: import.meta.env.VITE_PKCE_REDIRECT_URI,
+      code,
+      code_verifier,
+      state
+    })
+      .then((res: any) => {
+        localStorage.setItem('accessToken', JSON.stringify(res))
+        router.push({ path: '/' })
+      })
+      .catch((e) => {
+        message.warning(`请求token失败:${e.data.error || e.message || e.statusText}`)
+      })
+  }
+} else {
+  // 缓存state
+  localStorage.setItem('state', state)
+  // 缓存codeVerifier
+  localStorage.setItem('codeVerifier', codeVerifier)
+  window.location.href = `${
+    import.meta.env.VITE_OAUTH_ISSUER
+  }/oauth2/authorize?response_type=code&client_id=${
+    import.meta.env.VITE_PKCE_CLIENT_ID
+  }&redirect_uri=${encodeURIComponent(
+    import.meta.env.VITE_PKCE_REDIRECT_URI
+  )}&scope=message.write%20message.read&code_challenge=${codeChallenge}&code_challenge_method=S256&state=${state}`
+}
+</script>
+
+<template>加载中...</template>

+ 20 - 0
tsconfig.app.json

@@ -0,0 +1,20 @@
+{
+  "extends": "@vue/tsconfig/tsconfig.dom.json",
+  "include": [
+    "env.d.ts",
+    "src/**/*",
+    "src/**/*.vue"
+  ],
+  "exclude": [
+    "src/**/__tests__/*"
+  ],
+  "compilerOptions": {
+    "composite": true,
+    "baseUrl": ".",
+    "paths": {
+      "@/*": [
+        "./src/*"
+      ]
+    }
+  }
+}

+ 11 - 0
tsconfig.json

@@ -0,0 +1,11 @@
+{
+  "files": [],
+  "references": [
+    {
+      "path": "./tsconfig.node.json"
+    },
+    {
+      "path": "./tsconfig.app.json"
+    }
+  ]
+}

+ 15 - 0
tsconfig.node.json

@@ -0,0 +1,15 @@
+{
+  "extends": "@tsconfig/node18/tsconfig.json",
+  "include": [
+    "vite.config.*",
+    "vitest.config.*",
+    "cypress.config.*",
+    "nightwatch.conf.*",
+    "playwright.config.*"
+  ],
+  "compilerOptions": {
+    "composite": true,
+    "module": "ESNext",
+    "types": ["node"]
+  }
+}

+ 46 - 0
vite.config.ts

@@ -0,0 +1,46 @@
+import { fileURLToPath, URL } from 'node:url'
+
+import AutoImport from 'unplugin-auto-import/vite'
+import Components from 'unplugin-vue-components/vite'
+import { NaiveUiResolver } from 'unplugin-vue-components/resolvers'
+
+import { defineConfig } from 'vite'
+import vue from '@vitejs/plugin-vue'
+
+// https://vitejs.dev/config/
+export default defineConfig({
+  plugins: [
+    vue(),
+    AutoImport({
+      imports: [
+        'vue',
+        {
+          'naive-ui': [
+            'useDialog',
+            'useMessage',
+            'useNotification',
+            'useLoadingBar'
+          ]
+        }
+      ]
+    }),
+    Components({
+      resolvers: [NaiveUiResolver()]
+    })
+  ],
+  resolve: {
+    alias: {
+      '@': fileURLToPath(new URL('./src', import.meta.url))
+    }
+  },
+  server: {
+    host: '0.0.0.0',
+    proxy: {
+      '/api': {
+        target: 'http://localhost:8080',
+        changeOrigin: true,
+        rewrite: path => path.replace(/^\/api/, '')
+      }
+    }
+  }
+})