首页 前端知识 TypeScript 学习笔记(六):索引签名类型、映射类型

TypeScript 学习笔记(六):索引签名类型、映射类型

2024-05-03 18:05:47 前端知识 前端哥 694 180 我要收藏

一、索引签名类型

1. 索引类型查询操作符 keyof

keyof可以用于获取某种类型的所有键,其返回类型是联合类型。

keyofObject.keys 略有相似,只不过 keyofinterface 的键

interface Info {
  name: string;
  age: number;
}
let infoProp: keyof Info;
infoProp = "name";
infoProp = "age";
infoProp = "no"; // error 不能将类型“"no"”分配给类型“"name" | "age"”
 

通过例子可以看到,这里的keyof Info其实相当于"name" | “age”。通过和泛型结合使用,TS 就可以检查使用了动态属性名的代码:

// 这里使用泛型,并且约束泛型变量K的类型是"keyof T",也就是类型T的所有字段名组成的联合类型
function getValue<T, K extends keyof T>(obj: T, names: K[]): T[K][] { 
// 指定getValue的返回值类型为T[K][],即类型为T的值的属性值组成的数组
  return names.map(n => obj[n]); 
}
const info = {
  name: "lison",
  age: 18
};
let values: string[] = getValue(info, ["name"]);
values = getValue(info, ["age"]); // error 不能将类型“number[]”分配给类型“string[]” 
  • 接口
interface Person {
  name: string;
  age: number;
  location: string;
}

type K1 = keyof Person; // "name" | "age" | "location"
type K2 = keyof Person[];  // number | "length" | "push" | "concat" | ...
type K3 = keyof { [x: string]: Person };  // string | number

  • 基本数据类型
let K1: keyof boolean; // let K1: "valueOf"
let K2: keyof number; // let K2: "toString" | "toFixed" | "toExponential" | ...
let K3: keyof symbol; // let K1: "valueOf"

class Person {
    name: string = 'lisi';
}

let sname = keyof Person
sname = 'name'

如果把sname = ‘name’改为sname = ‘yui’的话,TypeScript 编译器会提示以下错误信息:

Type '"yui"' is not assignable to type '"name"'.

报错原因:

keyof Person 获取到的类型是‘name’

来看个简单的例子:

  let a: 1;
  a = 1;
  a = 2;

上的例子,将a的类型为1,那么这个变量的值只能是1,不能为其他的,当a = 2执行的时候,就会报错
在这里插入图片描述

  • 案例
function prop(obj: object, key: string) {
  return obj[key];
}

在上面代码中,为了避免调用 prop 函数时传入错误的参数类型,我们为 obj 和 key 参数设置了类型,分别为{}string 类型。然而,针对上述的代码,TypeScript 编译器会输出以下错误信息:

在这里插入图片描述
解决方法:

function prop<T extends object, K extends keyof T>(obj: T, key: K) {
  return obj[key];
}

在以上代码中,我们使用了 TypeScript 的泛型泛型约束首先定义了T类型并使用extends关键字约束该类型必须是object类型的子类型,然后使用keyof操作符获取 T 类型的所有键,其返回类型是联合类型,最后利用extends关键字约束 K 类型必须为keyof T联合类型的子类型。

type Todo = {
  id: number;
  text: string;
  done: boolean;
}

const todo: Todo = {
  id: 1,
  text: "Learn TypeScript keyof",
  done: false
}

function prop<T extends object, K extends keyof T>(obj: T, key: K) {
  return obj[key];
}

const id = prop(todo, "id"); // const id: number
const text = prop(todo, "text"); // const text: string
const done = prop(todo, "done"); // const done: boolean
const date = prop(todo, "date"); // 会报错

变量 date 访问todo对象中不存在的属性,那么就会报错
在这里插入图片描述

a. typeof与keyof的结合

const COLORS = {
  red: 'red',
  blue: 'blue'
}

// 首先通过typeof操作符获取color变量的类型,然后通过keyof操作符获取该类型的所有键,
// 即字符串字面量联合类型 'red' | 'blue',取自于k
type Colors = keyof typeof COLORS 
let color: Colors;
color = 'red' // Ok
color = 'blue' // Ok

// Type '"yellow"' is not assignable to type '"red" | "blue"'.
color = 'yellow' // Error

在这里插入图片描述

b. T[K] 索引访问

  interface Eg1 {
    name: string,
    readonly age: number,
  }
  // string
  type V1 = Eg1['name']
  // string | number
  type V2 = Eg1['name' | 'age']
  // any 类型“Eg1”上不存在属性“age2222”
  type V3 = Eg1['name' | 'age2222']
  // string | number
  type V4 = Eg1[keyof Eg1]

结论:

T[keyof T]的方式,可以获取到T所有key的类型组成的联合类型;

T[keyof T]的方式,获取到的是T中的key且同时存在于K时的类型组成的联合类型;

注意:如果[]中的key有不存在T中的,则是any;因为ts也不知道该key最终是什么类型,所以是any;且也会报错。

c. & 交叉类型注意点

交叉类型取的多个类型的并集,但是如果相同key但是类型不同,则该keynever

interface Eg1 {
    name:string,
    age:number
}

interface Eg2 {
    name:string,
    age:string,
    color: string,
}

上面两个接口定义的name,age属性名是相同的,但是属性的类型并不相同:

type T = Eg1 & Eg2

T的类型为 {name: string; age: never; color: string}

注意:age因为Eg1和Eg2中的类型不一致,所以交叉后age的类型是never

d. extends关键词特性(重点)

  • 用于接口,表示继承
 interface T1 {
  name: string,
}

interface T2 {
  sex: number,
}

/**
 * @example
 * T3 = {name: string, sex: number, age: number}
 */
interface T3 extends T1, T2 {
  age: number,
}

注意:接口支持多重继承,语法为逗号隔开。如果是type实现继承,则可以使用交叉类型type A = B & C & D

  • 表示条件类型,可用于条件判断

表示条件判断,如果前面的条件满足,则返回问号后的第一个参数,否则第二个。类似于js的三元运算。

/**
 * @example
 * type A1 = 1
 */
type A1 = 'x' extends 'x' ? 1 : 2;

/**
 * @example
 * type A2 = 2
 */
type A2 = 'x' | 'y' extends 'x' ? 1 : 2;

/**
 * @example
 * type A3 = 1 | 2
 */
type P<T> = T extends 'x' ? 1 : 2;
type A3 = P<'x' | 'y'>

**提问:**为什么 A2A3 的值不一样?

  • 如果用于简单的条件判断,则是直接判断前面的类型是否可分配给后面的类型
  • 若extends前面的类型是泛型,且泛型传入的是联合类型时,则会依次判断该联合类型的所有子类型是否可分配给extends后面的类型(是一个分发的过程)。

总结,就是 extends 前面的参数为联合类型时则会分解(依次遍历所有的子类型进行条件判断)联合类型进行判断。然后将最终的结果组成新的联合类型。

如果不想被分解(分发),做法也很简单,可以通过简单的元组类型包裹以下:

type P<T> = [T] extends ['x'] ? 1 : 2;
/**
 * type A4 = 2;
 */
type A4 = P<'x' | 'y'>

条件类型的分布式特性文档

2. 索引访问操作符 []

a. 操作符[]

索引访问操作符也就是[],其实和我们访问对象的某个属性值是一样的语法,但是在 TS 中它可以用来访问某个属性的类型。

b. 案例

  • 例一:
interface Info {
  name: string;
  age: number;
}
type NameType = Info["name"];
let name: NameType = 123; // error 不能将类型“123”分配给类型“string”
 
  • 例二:
function getProperty<T, K extends keyof T>(o: T, name: K): T[K] {
  return o[name]; // o[name] is of type T[K]
}

这个函数中,两个参数的类型分别为泛型 T 和 K,而函数的返回值类型为T[K],只要函数的返回值也是这种形式,即访问参数 o 的参数 name 属性,即可。

  • 例三:

与结合接口的例子:

interface Obj<T> {
  [key: number]: T;
}
const key: keyof Obj<number>; // key的类型为number

如果索引类型为 number,那么实现该接口的对象的属性名必须是 number 类型;但是如果接口的索引类型是 string 类型,那么实现该接口的对象的属性名设置为数值类型的值也是可以的,因为数值最后还是会先转换为字符串。这里一样,如果接口的索引类型设置为 string 的话,keyof Obj等同于类型number | string:

  interface Obj<T> {
    [key: string]: T;
  }
  let key: keyof Obj<number>; // key的类型为number | string
  // key = 123; // right
  key = '123'; // right
  • 例四:

也可以使用访问操作符,获取索引签名的类型:

interface Obj<T> {
  [key: string]: T;
}
const obj: Obj<number> = {
  age: 18
};
let value: Obj<number>["age"]; // value的类型是number,也就是name的属性值18的类型
  • 例五:

当tsconfig.json里strictNullChecks设为false时,通过Type[keyof Type]获取到的,是除去never & undefined & null这三个类型之后的字段值类型组成的联合类型,来看例子:

interface Type {
  a: never;
  b: never;
  c: string;
  d: number;
  e: undefined;
  f: null;
  g: object;
}
type test = Type[keyof Type];
// test的类型是string | number | object

strictNullChecks设为true时

  interface Type {
    a: never;
    b: never;
    c: string;
    d: number;
    e: undefined;
    f: null;
    g: object;
  }
  type test = Type[keyof Type];
  // test的类型是string | number | object | null | undefined 
  • 例六:
interface AnyObject {
  [key: string]: number
}
let obj: AnyObject = {
  a: 1,
  b: 2
}
// 对象obj中只能出现为number类型的值
  1. 使用 [key: string] 来约束该接口中允许出现的属性名称。表示只要是string类型的属性名称,都可以出现在对象中。
  2. 这样,对象 obj 中就可以出现任意多个属性(比如,a、b等)。
  3. key 只是一个占位符,可以换成任意合法的变量名称。
  4. 隐藏的前置知识:JS 中对象({})的键是 string 类型的。
  • 例七:

数组对应的泛型接口:

interface MyArray<T> {
  [n: number]: T   // 数组中每一项都是T类型(T是该数组的类型变量)
}

let arr: MyArray<number> = [1, 3, 5]   
// 数组中每一项都是number类型,换句话说,这是一个number[]

  1. MyArray 接口模拟原生的数组接口,并使用 [n: number]来作为索引签名类型。
  2. 该索引签名类型表示:只要是 number 类型的键(索引)都可以出现在数组中,或者说数组中可以有任意多个元素。
  3. 同时也符合数组索引是 number 类型这一前提。

二、映射类型

1. 概述

  • 根据的类型创建出的类型, 我们称之为映射类型。
  • 注意:映射类型只能在类型别名中使用,不能在接口中使用。

2. 基础案例

案例一 根据联合类型创建新类型:

根据联合类型创建新类型:

// 类型 ProKeys 有x/y/z,另一个类型Type1中也有x/y/z,并且Type1中的x/y/z类型相同
type ProKeys = 'x' | 'y' | 'z';

// 现在这种书写方式相当于x/y/z重复书写了两次。
type Type1 = {x: number; y:number; z:number}

上面这种情况就可以使用映射类型来进行简化:

type ProKeys = 'x' | 'y' | 'z';
type Type2 = {[Key in ProKeys]:number}
  1. 映射类型是基于索引签名类型的,所以该语法也使用了 []
  2. Key in ProKeys 表示 Key 可以是PorKeys 联合类型中的任意一个
  3. 使用映射类型创建的新对象 Type2 和 Type1 结构完全相同
案例二 根据对象类型创建新类型:

根据对象类型创建新类型:

type Props = {a: number; b: string; c: boolean};
type Type3 = {[key in keyof Props]: number}
// 相当于
type Type3 = {a: number; b: number; c: number};
  1. 首先,先执行 keyof Props 获取对象类型 Props所有建的联合类型(‘a’|‘b’|‘c’)
  2. 然后,key in …就表示Key可以是Props中所有的键名称中的任意一个。
案例三 keyof和映射类型的例子:

我们可以使用这个接口实现一个有且仅有一个 age 属性的对象,但如果我们想再创建一个只读版本的同款对象,那我们可能需要再重新定义一个接口,然后让 age 属性 readonly。如果接口就这么简单,你确实可以这么做,但是如果属性多了,而且这个结构以后会变,那就比较麻烦了。这种情况我们可以使用映射类型,下面来看例子:

interface Info {
  age: number;
}
type ReadonlyType<T> = { readonly [P in keyof T]: T[P] }; // 这里定义了一个ReadonlyType<T>映射类型
type ReadonlyInfo = ReadonlyType<Info>;
let info: ReadonlyInfo = {
  age: 18
};
info.age = 28; // error Cannot assign to 'age' because it is a constant or a read-only property

在这里插入图片描述
这个例子展示了如何通过一个普通的接口创建一个每个属性都只读的接口,这个过程有点像定义了一个函数,这个函数会遍历传入对象的每个属性并做处理。同理你也可以创建一个每个属性都是可选属性的接口:

  interface Info {
    age: number;
  }
  type ReadonlyType<T> = { readonly [P in keyof T]?: T[P] };
  type ReadonlyInfo = ReadonlyType<Info>;
  let info: ReadonlyInfo = {age: 18};
  console.log(info)

注意 :

这里用到了一个新的操作符 in,TS 内部使用了 for … in,定义映射类型,这里涉及到三个部分:

  • 类型变量,也就是上例中的 P,它就像 for … in 循环中定义的变量,用来在每次遍历中绑定当前遍历到的属性名;
  • 属性名联合,也就是上例中 keyof T,它返回对象 T 的属性名联合;
  • 属性的结果类型,也就是 T[P]

实际上,TS 内置了这两种映射类型,泛型工具类型(比如,Partial< Type>、Record<Keys,Type>)

type Pick<T, K extends keyof T> = { [P in K]: T[P] };
// 上面语句的意思是 keyof T 拿到 T 所有属性名,然后 in 进行遍历,将值赋给 P,最后 T[P] 取得相应属性的值
type Record<K extends keyof any, T> = { [P in K]: T };
 
案例四 Pick:

Pick 例子:

interface Info {
  name: string;
  age: number;
  address: string;
}
const info: Info = {
  name: "lison",
  age: 18,
  address: "beijing"
};
function pick<T, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> { // 这里我们定义一个pick函数,用来返回一个对象中指定字段的值组成的对象
  let res = {} as Pick<T, K>;
  keys.forEach(key => {
    res[key] = obj[key];
  });
  return res;
}
const nameAndAddress = pick(info, ["name", "address"]); // { name: 'lison', address: 'beijing' }
案例五 Record:

Record例子:

它适用于将一个对象中的每一个属性转换为其他值的场景。

function mapObject<K extends string | number, T, U>(
  obj: Record<K, T>,
  f: (x: T) => U
): Record<K, U> {
  let res = {} as Record<K, U>;
  for (const key in obj) {
    res[key] = f(obj[key]);
  }
  return res;
}
 
const names = { 0: "hello", 1: "world", 2: "bye" };
const lengths = mapObject(names, s => s.length); // { 0: 5, 1: 5, 2: 3 }

我们输入的对象属性值为字符串类型,输出的对象属性值为数值类型。

案例六 Partial:

Partial< Type >的实现:

type Partial<T> = {
	[P in keyof T]?:T[P];
}

type Props = {a: number; b: string; c: boolean};
type PartialProps = Partial<Props>
  1. keyof T 即 keyof Props 表示获取 Props的所有键,也就是:‘a’|‘b’|‘c’
  2. 在 [] 后面添加 ?(问号),表示将这些属性变为可选的,以此来实现Partial的功能
  3. 冒号后面的T[P]表示获取T中每个键对应的类型,比如:a 对应的 是 number
  4. 最终,新类型 PartialProps 和旧类型 Props 结构完全相同,只是让所有类型都变成可选了。
案例七 keyof 和映射类型支持用 number 和 symbol 命名的属性的例子:

keyof 和映射类型支持用 number 和 symbol 命名的属性的例子:

const stringIndex = "a";
const numberIndex = 1;
const symbolIndex = Symbol();
type Obj = {
  [stringIndex]: string;
  [numberIndex]: number;
  [symbolIndex]: symbol;
};
type keys = keyof Obj;
let key: keys = 2; // error
let key: keys = 1; // right
let key: keys = "b"; // error
let key: keys = "a"; // right
let key: keys = Symbol(); // error
let key: keys = symbolIndex; // right
const stringIndex = "a";
const numberIndex = 1;
const symbolIndex = Symbol();
type Obj = {
  [stringIndex]: string;
  [numberIndex]: number;
  [symbolIndex]: symbol;
};
type ReadonlyType<T> = { readonly [P in keyof T]?: T[P] };
let obj: ReadonlyType<Obj> = {
  a: "aa",
  1: 11,
  [symbolIndex]: Symbol()
};
obj.a = "bb"; // error Cannot assign to 'a' because it is a read-only property
obj[1] = 22; // error Cannot assign to '1' because it is a read-only property
obj[symbolIndex] = Symbol(); // error Cannot assign to '[symbolIndex]' because it is a read-only property
案例八 元组和数组上的映射类型:

元组和数组上的映射类型:

在元组和数组上的映射类型会生成新的元组和数组,并不会创建一个新的类型,这个类型上会具有 push、pop 等数组方法和数组属性。来看例子:

type MapToPromise<T> = { [K in keyof T]: Promise<T[K]> };
type Tuple = [number, string, boolean];
type promiseTuple = MapToPromise<Tuple>;
let tuple: promiseTuple = [
  new Promise((resolve, reject) => resolve(1)),
  new Promise((resolve, reject) => resolve("a")),
  new Promise((resolve, reject) => resolve(false))
];

这个例子中定义了一个MapToPromise映射类型。它返回一个将传入的类型的所有字段的值转为Promise,且Promise的resolve回调函数的参数类型为这个字段类型。我们定义了一个元组Tuple,元素类型分别为number、string和boolean,使用MapToPromise映射类型将这个元组类型传入,并且返回一个promiseTuple类型。当我们指定变量tuple的类型为promiseTuple后,它的三个元素类型都是一个Promise,且resolve的参数类型依次为number、string和boolean。

2.1 同态

两个相同类型的代数结构之间的结构保持映射。

这四个内置映射类型中,Readonly、Partial 和 Pick 是同态的,而 Record 不是,因为 Record 映射出的对象属性值是新的,和输入的值的属性值不同。

3. 索引查询类型

T[P]语法,在TS中叫做索引查询(访问)类型,用于查询属性的类型。

type Props = {a: number; b: string; c: boolean};
type TypeA = Props['a'];
// 相当于
type TypeA = number;
  • Props[‘a’] 表示查询类型 Props 中属性 a 对应的类型 number。
  • 注意:[] 中的属性必须存在于被查询类型中,否则就会报错。

索引查询类型也可以同时查询多个索引的类型。

type Props = {a: number; b: string; c: boolean};

// 使用字符串字面量的联合类型,获取属性 a 和 b 对应的类型
type TypeA = Props['a' | 'b'];	// string | number

// 使用 keyof 操作符获取 Props 中所有键对应的类型
type TypeA = Props[keyof Props];	// string | number | boolean

4. 由映射类型进行推断

还原映射之前的类型, 这种操作我们称之为 拆包

type Proxy<T> = { // 这里定义一个映射类型,他将一个属性拆分成get/set方法
  get(): T;
  set(value: T): void;
};
type Proxify<T> = { [P in keyof T]: Proxy<T[P]> }; // 这里再定义一个映射类型,将一个对象的所有属性值类型都变为Proxy<T>处理之后的类型
function proxify<T>(obj: T): Proxify<T> { // 这里定义一个proxify函数,用来将对象中所有属性的属性值改为一个包含get和set方法的对象
  let result = {} as Proxify<T>;
  for (const key in obj) {
    result[key] = {
      get: () => obj[key],
      set: value => (obj[key] = value)
    };
  }
  return result;
}
let props = {
  name: "lison",
  age: 18
};
let proxyProps = proxify(props);
console.log(proxyProps.name.get()); // "lison"
proxyProps.name.set("li");
 

我们来看下这个例子,这个例子我们定义了一个函数,这个函数可以把传入的对象的每个属性的值替换为一个包含 get 和 set 两个方法的对象。最后我们获取某个值的时候,比如 name,就使用 proxyProps.name.get()方法获取它的值,使用 proxyProps.name.set()方法修改 name 的值。

接下来我们来看如何进行拆包:

function unproxify<T>(t: Proxify<T>): T { // 这里我们定义一个拆包函数,其实就是利用每个属性的get方法获取到当前属性值,然后将原本是包含get和set方法的对象改为这个属性值
  let result = {} as T;
  for (const k in t) {
    result[k] = t[k].get(); // 这里通过调用属性值这个对象的get方法获取到属性值,然后赋给这个属性,替换掉这个对象
  }
  return result;
}
let originalProps = unproxify(proxyProps);
 

5. 增加或移除特定修饰符

映射类型增加了增加或移除特定修饰符的能力,使用+和-符号作为前缀来指定增加还是删除修饰符。首先来看我们如何通过映射类型为一个接口的每个属性增加修饰符,我们这里使用+前缀:

interface MyInterface {
    name: string;
    age: number;
}

type MyType<T> = {
    +readonly [P in keyof T]: T[P];
}

type test = MyType<MyInterface>;

type UnMyType<T> = {
    -readonly [P in keyof T]: T[P];
}

type test2 = UnMyType<test>;

映射类型增加了增加或移除特定修饰符的能力,使用+和-符号作为前缀来指定增加还是删除修饰符。

首先来看我们如何通过映射类型为一个接口的每个属性增加修饰符,我们这里使用+前缀:

interface Info {
  name: string;
  age: number;
}
type ReadonlyInfo<T> = { +readonly [P in keyof T]+?: T[P] };
let info: ReadonlyInfo<Info> = {
  name: "lison"
};
info.name = ""; // error

在这里插入图片描述

ReadonlyInfo创建的接口类型,属性是可选的,所以我们在定义 info 的时候没有写 age 属性也没问题,同时每个属性是只读的,所以我们修改 name 的值的时候报错。我们通过+前缀增加了 readonly 和?修饰符。当然,增加的时候,这个+前缀可以省略,也就是说,上面的写法和type ReadonlyInfo = { readonly [P in keyof T]?: T[P] }是一样的。我们再来看下怎么删除修饰符:

interface Info {
  name: string;
  age: number;
}
type RemoveModifier<T> = { -readonly [P in keyof T]-?: T[P] };
type InfoType = RemoveModifier<Readonly<Partial<Info>>>;
let info1: InfoType = {
  // error missing "age"
  name: "lison"
};
let info2: InfoType = {
  name: "lison",
  age: 18
};
info2.name = ""; // right, can edit

这个例子我们定义了去掉修饰符的映射类型 RemoveModifier,Readonly<Partial>则是返回一个既属性可选又只读的接口类型,所以 InfoType 类型则表示属性必含而且非只读。

TS 内置了一个映射类型Required,使用它可以去掉 T 所有属性的?修饰符。

6. 重映射

重映射就是在索引后加一个 as 语句,表明索引转换成什么,它可以用来对索引类型做过滤和转换。

返回 never 代表过滤掉,否则保留。

 type person ={
  name:'lisi',
  age:20,
  gender:true,
 }
// 比如过滤出类型为 string 的索引:
 type FilterString<T> = {
  [Key in keyof T as T[Key] extends string ? Key: never]: T[Key];
}
type res =FilterString<person>

在这里插入图片描述
还可以对索引做转换,比如修改索引名,加上 get:

 type person ={
  name:'lisi',
  age:20,
  gender:true,
 }

type Getters<T extends Record<any, any>> = {
  [Key in keyof T as `get${Capitalize<Key & string>}`]: T[Key];
}
type res =Getters<person>

在这里插入图片描述
T extends xxx 是给类型参数的约束,表示只能传入这种类型。这里的 Record 类型是生成索引类型的,我们上面介绍过,所以 T extends Record<any, any> 就是约束了这里只能传入索引类型。

as 后面是把索引转换成什么,我们是在原来的基础上做了修改,加上了 get,并且后面内容首字母大写,这个 Capitalize 也是 TS 内置的类型。

这两个例子分别说明了 重映射 as 可以用来做索引类型的过滤和转换,可以对索引类型做更灵活的编程。

实现 key 和 value 的互换:

type person ={
  name:'lisi',
  age:20,
  gender:true,
 }

 type Flip<T extends Record<any, any>> = {
  [Key in keyof T as `${T[Key]}`]: Key
}
type res =Flip<person>

在这里插入图片描述
支持重映射之后,映射类型可以对索引类型做更多的修改。

转载请注明出处或者链接地址:https://www.qianduange.cn//article/6842.html
标签
评论
发布的文章

String转Json的几种方式

2024-05-09 11:05:04

iOS ------ JSONModel源码

2024-05-09 11:05:02

java去除 json 中的 \n, \t, \r

2024-05-09 11:05:57

大家推荐的文章
会员中心 联系我 留言建议 回顶部
复制成功!