使用vuex实现登录注册功能及&auth模块


大纲链接 §

[toc]


vuex


为什么用 vuex

  • 当项目的许多不同层级组件都需要用到数据和传递数据,即 多个组件共享状态
  • 单级数据传递方式props/emits、祖孙通信project/inject或者中央总线eventBus满足不了这样复杂的数据传递方式的同时又要保证代码逻辑简洁清晰
  • 因此抽象出一个统一状态管理的对象,以一个 全局单例模式管理,保证状态变化可预测,并保 单向数据流的简洁性

vuex 使用详解

由于使用 Composition API 代替 OptionalAPI ,所以mapStatemapGetters等映射方法不再使用

vuex 使用 TypeScript 支持

声明 Vue 的自定义类型 ComponentCustomProperties @/types/vuex.d.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// 声明 Vue 的自定义类型 ComponentCustomProperties

import {ComponentCustomProperties} from 'vue';
import {Store} from 'vuex';

declare module '@vue/runtime-core' {
  // 声明自己的 store state
  interface State {
    count: number;
    ...
  }

  // 为 `this.$store` 提供类型声明
  interface ComponentCustomProperties {
    $store: Store<State>;
  }
}

使用 useStore() 组合式函数类型声明

  • 希望 useStore 返回类型化的 store,必须执行以下步骤:
  1. 定义类型化的 InjectionKey, 定义 export const key: InjectionKey<Store<State>> = Symbol();
  2. store 安装到 Vue 应用时,提供该类型化的 InjectionKey,挂载app.use(store, key);
  3. 将类型化的 InjectionKey 传给 useStore 方法 ,

@/store/index.ts 使用 Vue 的 InjectionKey 接口和自己的 store 类型定义来定义 key

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import {createStore, Store} from 'vuex';
import {InjectionKey} from 'vue';

// 为 store state 声明类型
export interface State {
  count: number;
  ...
}

// 定义 injection key
export const key: InjectionKey<Store<State>> = Symbol();

// 创建一个新的 store 实例
export const store = createStore<State>({
  state() {
    return {
      count: 0,
      ...
    };
  },
  mutations: {
    increment(state) {
      state.count++;
    },
    ...
  }
});

main.tsstore 安装到 Vue 应用时传入定义好的 injection key

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
import App from '@/App.vue';
import {createApp} from 'vue';
import {store, key} from '@/store';
import router from '@/router';

const app = createApp(App);
app.use(store, key);
app.use(router);
app.mount('#app');

  • main.ts 里面先挂载好 vuex, 再挂路由
  • 否则在导航守卫中 router.beforeEach 中使用会提示 vuex 还未安装

使用时,将上述 injection key 传入 useStore 方法可以获取类型化的 store

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// vue 组件
import { useStore } from 'vuex';
import { key } from '@/store';

export default {
  setup () {
    const store = useStore(key)

    store.state.count // 类型为 number
  }
}

简化 useStore 用法

  • 每次使用useStore,都需引入 InjectionKey 并将其传入 useStore 到使用过的任何地方,重复冗余
  • 定义自己的组合式函数来检索类型化的 store

@/store/index.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import {InjectionKey} from 'vue';
import {createStore, Store, useStore as baseUseStore} from 'vuex';

// 为 store state 声明类型
export interface State {
  count: number;
}

// 定义 injection key
export const key: InjectionKey<Store<State>> = Symbol();

// 创建一个新的 store 实例
export const store = createStore<State>({
  state() {
    return {
      count: 0
    };
  },
  mutations: {
    increment(state) {
      state.count++;
    }
  }
});

// 定义自己的 `useStore` 组合式函数
export function useMyStore () {
  return baseUseStore(key)
}

通过引入自定义的组合式函数,不用提供 injection key 和类型声明就可以直接得到类型化的 store

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// vue 组件
import { useMyStore } from '@/store'

export default {
  setup () {
    const store = useMyStore()

    store.state.count // 类型为 number
  }
}

  • 通过调用 useStore 函数,在 setup 钩子函数中访问 store
  • 这与在组件中使用选项式 API 访问 this.$store 是等效的

使用组合式API 访问 State 和 Getter

  • 组件中,为了访问 stategetter,需要创建 computed 引用,来 保留响应性
  • 这与在选项式 API 中创建计算属性等效
  • 官方文档 访问 State 和 Getter
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// vue 组件
import { computed } from 'vue'
import { useMyStore } from '@/store'

export default {
  setup () {
    const store = useMyStore()
    
    // 在 computed 函数中访问 state
    const count = computed(() => {
      return store.state.count
    })
    
    // 在 computed 函数中访问 getter
    const double = computed(() => {
      return store.getters.double
    })

    return {
      count,
      double
    }
  }
}
  • 保留响应性
    • const count = computed(() => (store.state.count))
    • onst double = computed(() => (store.getters.double)

使用组合式API 访问 MutationAction

  • 要使用 mutationaction 时,只需要在 setup 钩子函数中调用 commitdispatch 函数
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// vue 组件
import { useMyStore } from '@/store'

export default {
  setup () {
    const store = useMyStore()

    return {
      // 使用 mutation
      increment: () => store.commit('increment'),

      // 使用 action
      asyncIncrement: () => store.dispatch('asyncIncrement')
    }
  }
}
  • 状态变更
    • const increment = () => store.commit('increment')
    • const asyncIncrement = () => store.dispatch('asyncIncrement')

Vuex + TS添加Module的类型

实现 Vuex4 typescript 中自动将modules的类型添加到state

  • vuex4 中使用 typescript 定义类型模块modules时,如果不做一些处理,在使用Hooks获取store然后取子模块的state会丢失类型
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
store
 ┣ modules
 ┃ ┣ auth
 ┃ ┃ ┣ authStore.ts
 ┃ ┃ ┣ index.ts
 ┃ ┃ ┗ interface.ts
 ┃ ┗ blog
 ┃ ┃ ┣ blogStore.ts
 ┃ ┃ ┣ index.ts
 ┃ ┃ ┗ interface.ts
 ┣ index.ts
 ┗ interface.ts
  • state类型定义单独放在一个文件中处理,方便管理
  • 各个子模块的类型定义,单独放在子模块的文件夹中处理,最后统一在根节点上
  • 如果有很多公用的类型可以抽取出来

参考


vuex在项目中的目录结构

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
store
 ┣ modules
 ┃ ┣ auth
 ┃ ┃ ┣ authStore.ts
 ┃ ┃ ┣ index.ts
 ┃ ┃ ┗ interface.ts
 ┃ ┗ blog
 ┃ ┃ ┣ blogStore.ts
 ┃ ┃ ┣ index.ts
 ┃ ┃ ┗ interface.ts
 ┣ index.ts
 ┗ interface.ts

定义 root 顶层 state 的数据类型, 在 @/store/interface.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// 定义 root 顶层 state 的数据类型, 在 store/interface.ts 中
// 比如在state上定义属性 test 的类型, 然后默认导出该类型定义
import AuthModuleTypes from '@/store/modules/auth/interface';
import BlogModuleTypes from '@/store/modules/blog/interface';

export default interface RootStateTypes {
    authStore: AuthModuleTypes;
    blogStore: BlogModuleTypes;
}
a

Store入口index.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
import {InjectionKey} from 'vue';
import {createStore, Store, useStore as baseUseStore} from 'vuex';
import authStore from '@/store/modules/auth/';
import blogStore from '@/store/modules/blog/';
import RootStateTypes from '@/store/interface';

// 定义 injection key 在注册store时需要用到 app.use(store, key);
export const key: InjectionKey<Store<RootStateTypes>> = Symbol('vue-store');

/*
const modules = {
  authStore,
  blogStore,
};

type modulesState = {
  [key in keyof typeof modules]: Exclude<Exclude<typeof modules[key]['state'], undefined>, () => any>
}
// 为 store state 声明类型
export interface State extends modulesState{}

// 通过 infer 获取 单个store 的 state 的类型
type StoreState<T> = T extends { state: infer S } ? S : T;

/!* 

当类型 T 兼容 { state: infer S } 时,返回state的类型S 

*!/

// 原始类型为:{ state: { a, b... }
// StoreState<T>为: { a, b... }

// 替换 modules 的 value 的类型,重新组织成正确的类型
//  替换类型 T 的 value 的类型成 StoreState
type ModulesState<T> = {
  [key in keyof T]?: StoreState<T[key]>
}

export type State = ModulesState<typeof modules>;
// 原始类型为: { moduleA: { state: { cateA, cateB ... };
// 新类型State为:{ moduleA: { cateA, cateB ... };

export default createStore<State>({
  modules
});

*/

// 创建一个新的 store 实例
export const store = createStore<RootStateTypes>({
  // state: {},
  // getters: {},
  // mutations: {},
  // actions: {},
  modules: {
    authStore,
    blogStore,
  }
});

// 定义自己的 useStore 组合式函数
/* vue组件 setup 中使用
* import { useStore } from '@/store';
* const store = useStore(key); // 可实现调用
* */

export function useStore() {
  return baseUseStore<RootStateTypes>(key);
}


vuex在项目中的使用 auth 模块

@/store/modules/auth/interface声明类型

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import {ActionContext} from 'vuex';
import {responseData} from '@/types/responseData';
import RootStateTypes from '@/store/interface';

// 定义了modules子模块后会发现
// 使用的时候会提示类型报错
// 如 modules: { testModule { state: {name: age }}}
//
// store.state.testModule.name 调用时就会出现类型报错, 但是值是存在的
// 可以使用如下方式处理 定义 modules 中子模块

export default interface AuthModuleTypes {
  isLogin: boolean
  userData: Pick<responseData, 'data'> | null,
}

export type logString = {
  username: string,
  password: string
}

// const TestModule: Module<TestModulesTypes, RootStateTypes>
// 第一个泛型参数为当前子模块的state类型定义
// 第二个参数为根级state的类型定义
// 只要定义了state的类型,在所有使用state的地方均会自动推断,不需要再定义
export type ActionContextType = ActionContext<AuthModuleTypes, RootStateTypes>;
  • const TestModule: Module<TestModulesTypes, RootStateTypes>
  • ActionContext<AuthModuleTypes, RootStateTypes>;

@/store/modules/auth/authStore.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
import auth from '@/api/auth';
import {responseData, userAuthInfo} from '@/types/responseData';
import RootStateTypes from '@/store/interface';
import {Module} from 'vuex';
import AuthModuleTypes, {ActionContextType} from '@/store/modules/auth/interface';

const state = (): AuthModuleTypes => {
  return {
    userData: null,
    isLogin: false
  };
};

const getters = {
  // 计算属性监听变化
  userData: (state: AuthModuleTypes) => state.userData,
  isLogin: (state: AuthModuleTypes) => state.isLogin
};

const mutations = {
  setUser(state: AuthModuleTypes, payload: AuthModuleTypes) {
    state.userData = payload.userData;
  },
  setLogin(state: AuthModuleTypes, payload: AuthModuleTypes) {
    state.isLogin = payload.isLogin;
  }
};

const actions = {
  login({commit}: ActionContextType, {username, password}: userAuthInfo): Promise<responseData> {
    return auth.login({username, password})
      .then(res => {
        commit('setUser', {userData: res.data});
        commit('setLogin', {isLogin: true});
        return res;
      });
  },
  async register({commit}: ActionContextType, {username, password}: userAuthInfo): Promise<responseData> {
    let res = await auth.register({username, password});
    commit('setUser', {userData: res.data});
    commit('setLogin', {isLogin: true});
    return res;
  },
  async logout({commit}: ActionContextType) {
    await auth.logout();
    // 注销用户
    commit('setUser', {userData: null});
    commit('setLogin', {isLogin: false});
  },
  async checkLogin({commit, state}: ActionContextType): (Promise<Boolean>) {
    // 已处于登录状态,直接返回 true,短路先验
    if (state.isLogin) return true;

    // 处于非登录状态
    // 向服务器发出 auth.getInfo() 请求,验证用户是否处于登录状态
    const res = await auth.getInfo();

    // 将返回结果的 user 和 isLogin 设置到 本地状态
    commit('setLogin', {isLogin: res.isLogin});
    if (!res.isLogin) return false;
    commit('setUser', {userData: res.data});
    return true;
  }
};

const authModule: Module<AuthModuleTypes, RootStateTypes> = {
  state,
  getters,
  mutations,
  actions,
};

export default authModule;

  • 第一个泛型参数为当前子模块AuthModuleTypes的 state 类型定义
  • 第二个参数为根级 state 的类型定义 RootStateTypes
  • 只要定义了 state 的类型,在所有使用state的地方均会自动推断,不需要再定义

@/store/modules/auth/index

1
2
3
4
5
6
7
// store 文件夹下一级目录 auth 有index.ts 初始化vuex的配置
// interface.ts 设置root层state类型
// modules用来存放vuex子模块定义

import authStore from '@/store/modules/auth/authStore';

export default authStore;

BlogModule模块

@/store/modules/blog/

@/store/modules/blog/

@/store/modules/blog/


登录注册的功能实现

@/pages/login/Login.vue登录

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
<script setup lang="ts" name="Login">
import UserInput from '@/components/user-authentication/UserInput.vue';
import UserSubmitBtnTip from '@/components/user-authentication/UserSubmitBtnTip.vue';
import {ref,} from 'vue';
import {useRouter} from 'vue-router';
import {useStore} from '@/store';
import {logString} from '@/store/modules/auth/interface';

const store = useStore();
const router = useRouter();

const username = ref('');
const password = ref('');

const asyncLogin = (logString: logString) => {
  return store.dispatch('login', logString);
};

const onLogin = (logString: logString) => {
  asyncLogin(logString)
    .then(() => {
      // 成功,跳转首页
      router.push({path: '/'});
    },);
};

</script>

<template>
  <section class="login">
    <UserInput title="用户名"
               errorText="当前用户名已注册"
               v-model:username="username"/>

    <UserInput title="密码"
               inputType="password"
               errorText="当前用户名或密码不匹配"
               v-model:password="password"
               @keyup.enter="onLogin({username, password})"/>

    <UserSubmitBtnTip btnName="立即登录"
                      tipText="没有账号?"
                      linkTo="/register"
                      linkText="注册新用户"
                      @click="onLogin({username, password})"/>
  </section>
</template>

<style lang="scss" scoped>
...
</style>

  • 调用store.dispatch('login', logString);

@/pages/register/Register.vue注册

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
<script setup lang="ts" name="Register">
import {ref} from 'vue';
import UserInput from '@/components/user-authentication/UserInput.vue';
import UserSubmitBtnTip from '@/components/user-authentication/UserSubmitBtnTip.vue';

const username = ref('');
const password = ref('');

</script>

<template>
  <section class="login">
    <UserInput title="用户名"
               errorText="当前用户名已注册"
               :doubleBind="username"/>

    <UserInput title="密码"
               inputType="password"
               errorText="当前用户名或密码不匹配"
               :doubleBind="password"/>

    <UserInput title="确认密码"
               inputType="password"
               placeholder="请重复输入一遍密码"
               errorText="两次密码输入不一致"/>

    <UserSubmitBtnTip btnName="立即注册"
                      tipText="已有账号?"
                      linkTo="/login"
                      linkText="立即登录"/>
  </section>
</template>

<style lang="scss" scoped>
...
</style>

vuex只能在使用 state 时,能够享受到TS的完全加持,但是却不能很好地对 mutation , action 以及 getter 进行很友好的类型检查



改用 Pinia 代替Vuex 操作状态管理

Vuex 没有为 this.$store 属性提供开箱即用的TypeScript 类型声明

初始化 pinia

  • 通过 createPinia 初始化 Pinia,并将其挂载到 Vue 的实例上

src/main.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'

// 创建 pinia
const pinia = createPinia()

const app = createApp(App)
// 挂载到 Vue 实例上
app.use(pinia)
app.mount('#app')

创建 src/store/index.ts 文件,用于存放 store

1
2
3
4
5
6
import { defineStore } from 'pinia'

// 定义 store 名为 myFirstStore 是 store 的名称,该名称必须唯一,不可重复
export const useStore = defineStore('myFirstStore', {
  
})
  • defineStore() 的第一个参数用于设置 store 的容器名称,该名称必须唯一,不可重复!

store的目录结构

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
store
 ┣ modules
 ┃ ┣ auth
 ┃ ┃ ┣ authStore.ts
 ┃ ┃ ┣ index.ts
 ┃ ┃ ┗ interface.ts
 ┃ ┗ blog
 ┃ ┃ ┣ blogStore.ts
 ┃ ┃ ┣ index.ts
 ┃ ┃ ┗ interface.ts
 ┣ index.ts
 ┣ interface.ts
 ┗ store.ts

@/store/modules/auth/authStore

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
import {defineStore} from 'pinia';
import auth from '@/api/auth';
import {responseData} from '@/types/responseData';
import AuthModuleTypes, {logString} from '@/store/modules/auth/interface';
import {message} from 'ant-design-vue';

type getUser = Pick<AuthModuleTypes, 'userData'>;
type getIsLogin = Pick<AuthModuleTypes, 'isLogin'>;

export const useAuthStore = defineStore('authStore', {
  state: (): AuthModuleTypes => {
    return {
      // 自动推导属性类型
      userData: null,
      isLogin: false
    };
  },
  getters: {
    // 无需计算属性监听变化
    getUser: (state: AuthModuleTypes) => state.userData,
    getIsLogin: (state: AuthModuleTypes) => state.isLogin
  },
  actions: {
    setUser(payload: getUser) {
      this.userData = payload.userData;
    },
    setLogin(payload: getIsLogin) {
      this.isLogin = payload.isLogin;
    },
    // async Promise
    login({username, password}: logString) {
      return auth.login({username, password})
        .then(res => {
          this.setUser({userData: res.data});
          this.setLogin({isLogin: true});
          message.success(res.msg);
          return res;
        });
    },
    async register({username, password}: logString): Promise<responseData> {
      const res = await auth.register({username, password});
      // this.setUser({userData: res.data});
      this.setUser({userData: null});
      // this.setLogin({isLogin: true});
      this.setLogin({isLogin: false});
      message.success(res.msg);
      message.info('请重新登录');
      // 提示注册成功 重新登录
      return res;// 做进一步的处理
    },
    async checkLogin(): Promise<boolean> {
      // 已处于登录状态,直接返回 true,短路先验
      console.log('this.isLogin test first', this.isLogin);
      if (this.isLogin) {return true;}

      // 处于非登录状态
      // 向服务器发出 auth.getInfo() 请求,验证用户是否处于登录状态
      const res = await auth.getInfo();

      // 将返回结果的 isLogin 设置到 本地状态
      this.setLogin({isLogin: res.isLogin});
      res.isLogin
        ? (this.setUser({userData: res.data})) // 服务器 验证用户 已登录
        : (this.setUser({userData: null})); // 服务器 验证用户 未登录

      return res.isLogin;

    },
    async logout() {
      // 注销用户
      this.userData = null;
      this.isLogin = false;
      return await auth.logout();
    }
  },
});


@/store/modules/auth/interface

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import {responseData} from '@/types/responseData';

export default interface AuthModuleTypes {
  isLogin: boolean
  userData: responseData['data'] | null,
}

export type logString = {
  username: string,
  password: string
}


@/store/modules/auth/index

1
2
3
import {useAuthStore} from '@/store/modules/auth/authStore';

export default useAuthStore;

pinia 遇坑

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { createRouter } from 'vue-router'

const router = createRouter({
  // ...
})

// ❌ Depending on the order of imports this will fail
const store = useStore()

router.beforeEach((to, from, next) => {
  // we wanted to use the store here
  if (store.isLoggedIn) next()
  else next('/login')
})

// Good way below

router.beforeEach((to) => {
  // ✅ This will work because the router starts its navigation after
  // the router is installed and pinia will be installed too
  const store = useStore()

  if (to.meta.requiresAuth && !store.isLoggedIn) return '/login'
})


https://juejin.cn/post/7057950109832577054?utm_source=gold_browser_extension | Pinia初体验 - 掘金 https://juejin.cn/post/7036745610954801166#heading-33 | Vite2 + Vue3 + TypeScript + Pinia 搭建一套企业级的开发脚手架【值得收藏】 - 掘金 https://juejin.cn/post/7057439040911441957 | Pinia进阶:优雅的setup(函数式)写法+封装到你的企业项目 - 掘金 https://juejin.cn/post/7056894606650114085#heading-3 | Vue3.2+Vite+TS+pinia+router4搭建 - 掘金 http://localhost:3000/#/create https://pinia.vuejs.org/getting-started.html | Installation | Pinia https://stackblitz.com/github/piniajs/example-vue-3-vite?file=src%2Fcomponents%2FPiniaLogo.vue | Piniajs - Example Vue 3 Vite - StackBlitz https://pinia.vuejs.org/introduction.html#comparison-with-vuex | Introduction | Pinia https://juejin.cn/post/6986847203885056036 | Pinia 快速入门 - 掘金 https://juejin.cn/post/6977648886869393444 | Pinia,替代Vue.js的容器 - 掘金 https://segmentfault.com/a/1190000041246156 | 新一代状态管理工具,Pinia.js 上手指南 - SegmentFault 思否 http://jspang.com/detailed?id=82 | 技术胖-Pinia入门视频教程 全新一代状态管理工具Pinia -Vue3全家桶系列 https://jishuin.proginn.com/p/763bfbd61629 | Pinia是Vuex的良好替代品吗?-技术圈 https://zhuanlan.zhihu.com/p/413389658 | Vuex4 对 TypeScript 并不友好,所以我选择 Pinia - 知乎 https://juejin.cn/post/7041188884864040991 | vite + vue3 + setup + pinia + ts 项目实战 - 掘金



参考文章

相关文章


  • 作者: Joel
  • 文章链接:
  • 版权声明
  • 非自由转载-非商用-非衍生-保持署名