Springboot thymeleaf i18n国际化多语言选择
原作者
源码:https://github.com/zzzzbw/Spring-Boot-I18n-Pro
https://github.com/zzzzbw/Spring-Boot-I18n-Pro/tree/starter 【starter】分支
原文
https://zzzzbw.cn/article/7
这里只是一个整理,没有按步骤一步一步的复制,初学者请看原作者的原文
目的:
从文件夹中直接加载多个国际化文件
后台设置前端页面显示国际化信息的文件
利用拦截器和注解自动设置前端页面显示国际化信息的文件
因为业务需要,对此功能进行了扩展,新功能->
Springboot thymeleaf i18n国际化多语言选择->2.业务流程内部返回 对应的语言 https://blog.csdn.net/fenglailea/article/details/89882786
利用拦截器和注解自动设置前端页面显示国际化信息的文件
虽然已经可以指定对应的国际化信息,但是这样要在每个controller里的HttpServletRequest中设置国际化文件实在太麻烦了,所以现在我们实现自动判定来显示对应的文件。
首先我们创建一个注解,这个注解可以放在类上或者方法上。
package com.zbw.i18n.annotation;import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;/*** @auther zbw* @create 2018/4/4 17:59*/
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface I18n {/*** 国际化文件名*/String value();
}
然后我们把这个创建的I18n
注解放在DashboardController
控制器中或控制器的方法中,控制器方法中看后续部分案例
package com.zbw.i18n.controller;import com.zbw.i18n.annotation.I18n;
import com.zbw.i18n.model.Merchant;
import com.zbw.i18n.model.Shop;
import com.zbw.i18n.model.User;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;import java.util.ArrayList;
import java.util.List;/*** @author zbw* @create 2018/4/3 16:23*/
@I18n("dashboard")
@Controller
@RequestMapping("dashboard")
public class DashboardController {@GetMappingpublic String dashboard(Model model) {List<Merchant> merchants = new ArrayList<>();for (int i = 0; i < 10; i++) {Merchant merchant = new Merchant();merchants.add(merchant);}List<Shop> shops = new ArrayList<>();for (int i = 0; i < 15; i++) {Shop shop = new Shop();shops.add(shop);}List<User> users = new ArrayList<>();for (int i = 0; i < 5; i++) {User user = new User();users.add(user);}model.addAttribute("merchants", merchants);model.addAttribute("shops", shops);model.addAttribute("users", users);return "system/dashboard";}
}
为了显示他的效果,我们再创建一个HelloController,同时也创建对应的’demo’的国际化文件,内容也都是一个’hello’。
注意这个HelloController 在源码中是没有的,在
starter
分支中才有
@Controller
public class HelloController {@GetMapping("/hello")public String index() {return "system/hello";}@I18n("dashboard")@GetMapping("/dashboard")public String dashboard() {return "dashboard";}@I18n("merchant")@GetMapping("/merchant")public String merchant() {return "merchant";}
}
@I18n("shop")
@Controller
public class ShopController {@GetMapping("shop")public String shop() {return "shop";}
}
@Controller
public class UserController {@GetMapping("user")public String user() {return "user";}
}
我们把I18n注解分别放在HelloController下的dashboard和merchant方法下,和ShopController类上。
准备工作都做好了,现在看看如何实现根据这些注解自动的指定国际化文件。
package com.zbw.i18n.compoment;import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.support.ResourceBundleMessageSource;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;import javax.annotation.PostConstruct;
import java.io.File;
import java.io.IOException;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.ResourceBundle;
import java.util.jar.JarFile;
import java.util.stream.Collectors;
import java.util.zip.ZipEntry;/*** @author zbw* @create 2018/4/3 18:20*/
@Component("messageSource")
public class MessageResourceExtension extends ResourceBundleMessageSource {private final static Logger logger = LoggerFactory.getLogger(MessageResourceExtension.class);/*** 指定的国际化文件目录*/@Value(value = "${spring.messages.baseFolder:i18n}")private String baseFolder;/*** 父MessageSource指定的国际化文件*/@Value(value = "${spring.messages.basename:message}")private String basename;public static String I18N_ATTRIBUTE = "i18n_attribute";@PostConstructpublic void init() {logger.info("init MessageResourceExtension...");if (!StringUtils.isEmpty(baseFolder)) {try {this.setBasenames(getAllBaseNames(baseFolder));} catch (IOException e) {logger.error(e.getMessage());}}//设置父MessageSourceResourceBundleMessageSource parent = new ResourceBundleMessageSource();//是否是多个目录if (basename.indexOf(",") > 0) {parent.setBasenames(basename.split(","));} else {parent.setBasename(basename);}//设置文件编码parent.setDefaultEncoding("UTF-8");this.setParentMessageSource(parent);}@Overrideprotected String resolveCodeWithoutArguments(String code, Locale locale) {// 获取request中设置的指定国际化文件名ServletRequestAttributes attr = (ServletRequestAttributes) RequestContextHolder.currentRequestAttributes();final String i18File = (String) attr.getAttribute(I18N_ATTRIBUTE, RequestAttributes.SCOPE_REQUEST);if (!StringUtils.isEmpty(i18File)) {//获取在basenameSet中匹配的国际化文件名String basename = getBasenameSet().stream().filter(name -> StringUtils.endsWithIgnoreCase(name, i18File)).findFirst().orElse(null);if (!StringUtils.isEmpty(basename)) {//得到指定的国际化文件资源ResourceBundle bundle = getResourceBundle(basename, locale);if (bundle != null) {return getStringOrNull(bundle, code);}}}//如果指定i18文件夹中没有该国际化字段,返回null会在ParentMessageSource中查找return null;}/*** 获取文件夹下所有的国际化文件名** @param folderName 文件名* @return* @throws IOException*/private String[] getAllBaseNames(final String folderName) throws IOException {URL url = Thread.currentThread().getContextClassLoader().getResource(folderName);if (null == url) {throw new RuntimeException("无法获取资源文件路径");}List<String> baseNames = new ArrayList<>();if (url.getProtocol().equalsIgnoreCase("file")) {// 文件夹形式,用File获取资源路径File file = new File(url.getFile());if (file.exists() && file.isDirectory()) {baseNames = Files.walk(file.toPath()).filter(path -> path.toFile().isFile()).map(Path::toString).map(path -> path.substring(path.indexOf(folderName))).map(this::getI18FileName).distinct().collect(Collectors.toList());} else {logger.error("指定的baseFile不存在或者不是文件夹");}} else if (url.getProtocol().equalsIgnoreCase("jar")) {// jar包形式,用JarEntry获取资源路径String jarPath = url.getFile().substring(url.getFile().indexOf(":") + 2, url.getFile().indexOf("!"));JarFile jarFile = new JarFile(new File(jarPath));List<String> baseJars = jarFile.stream().map(ZipEntry::toString).filter(jar -> jar.endsWith(folderName + "/")).collect(Collectors.toList());if (baseJars.isEmpty()) {logger.info("不存在{}资源文件夹", folderName);return new String[0];}baseNames = jarFile.stream().map(ZipEntry::toString).filter(jar -> baseJars.stream().anyMatch(jar::startsWith)).filter(jar -> jar.endsWith(".properties")).map(jar -> jar.substring(jar.indexOf(folderName))).map(this::getI18FileName).distinct().collect(Collectors.toList());}return baseNames.toArray(new String[0]);}/*** 把普通文件名转换成国际化文件名** @param filename* @return*/private String getI18FileName(String filename) {filename = filename.replace(".properties", "");for (int i = 0; i < 2; i++) {int index = filename.lastIndexOf("_");if (index != -1) {filename = filename.substring(0, index);}}return filename.replace("\\", "/");}
}
简单讲解一下这个拦截器。
首先,如果request中已经有I18N_ATTRIBUTE
,说明在Controller
的方法中指定设置了,就不再判断。
然后判断一下进入拦截器的方法上有没有I18n
的注解,如果有就设置I18N_ATTRIBUTE
到request
中并退出拦截器,如果没有就继续。
再判断进入拦截的类上有没有I18n
的注解,如果有就设置I18N_ATTRIBUTE
到request
中并退出拦截器,如果没有就继续。
最后假如方法和类上都没有I18n
的注解,那我们可以根据Controller名
自动设置指定的国际化文件,比如UserController
那么就会去找user
的国际化文件。
在MessageResourceExtension
重写resolveCodeWithoutArguments
方法(如果有字符格式化的需求就重写resolveCode
方法)。
在我们重写的resolveCodeWithoutArguments
方法中,从HttpServletRequest
中获取到‘I18N_ATTRIBUTE’(等下再说这个在哪里设置),这个对应我们想要显示的国际化文件名,然后我们在BasenameSet
中查找该文件,再通过getResourceBundle
获取到资源,最后再getStringOrNull
获取到对应的国际化信息
resolveCodeWithoutArguments为了解决多写前缀问题。
dashboard.properties
中的国际化信息为dashboard.hello
而merchant.properties
中的是merchant.hello
,这样每个都要写一个前缀岂不是很麻烦,现在我想要在dashboard
和merchant
的国际化文件中都只写’hello’但是显示的是dashboard
或merchant
的国际化信息。
拦截器完成了,现在把拦截器配置到系统中。修改I18nApplication
启动类:
package com.zbw.i18n;import com.zbw.i18n.interceptor.MessageResourceInterceptor;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.LocaleResolver;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import org.springframework.web.servlet.i18n.CookieLocaleResolver;
import org.springframework.web.servlet.i18n.LocaleChangeInterceptor;import java.util.Locale;@SpringBootApplication
@Configuration
public class I18nApplication {public static void main(String[] args) {SpringApplication.run(I18nApplication.class, args);}@Beanpublic LocaleResolver localeResolver() {CookieLocaleResolver slr = new CookieLocaleResolver();slr.setDefaultLocale(Locale.CHINA);slr.setCookieMaxAge(3600);slr.setCookieName("Language");return slr;}@Beanpublic WebMvcConfigurer webMvcConfigurer() {return new WebMvcConfigurer() {//拦截器@Overridepublic void addInterceptors(InterceptorRegistry registry) {registry.addInterceptor(new LocaleChangeInterceptor()).addPathPatterns("/**");registry.addInterceptor(new MessageResourceInterceptor()).addPathPatterns("/**");}};}
}
其中I18nApplication.java
设置了一个CookieLocaleResolver
,采用cookie
来控制国际化的语言。设置LocaleChangeInterceptor
和MessageResourceInterceptor
拦截器来拦截国际化语言的变化。
继承ResourceBundleMessageSource
package com.zbw.i18n.compoment;import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.support.ResourceBundleMessageSource;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;import javax.annotation.PostConstruct;
import java.io.File;
import java.io.IOException;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.ResourceBundle;
import java.util.jar.JarFile;
import java.util.stream.Collectors;
import java.util.zip.ZipEntry;/*** @author zbw* @create 2018/4/3 18:20*/
@Component("messageSource")
public class MessageResourceExtension extends ResourceBundleMessageSource {private final static Logger logger = LoggerFactory.getLogger(MessageResourceExtension.class);/*** 指定的国际化文件目录*/@Value(value = "${spring.messages.baseFolder:i18n}")private String baseFolder;/*** 父MessageSource指定的国际化文件*/@Value(value = "${spring.messages.basename:message}")private String basename;public static String I18N_ATTRIBUTE = "i18n_attribute";@PostConstructpublic void init() {logger.info("init MessageResourceExtension...");if (!StringUtils.isEmpty(baseFolder)) {try {this.setBasenames(getAllBaseNames(baseFolder));} catch (IOException e) {logger.error(e.getMessage());}}//设置父MessageSourceResourceBundleMessageSource parent = new ResourceBundleMessageSource();//是否是多个目录if (basename.indexOf(",") > 0) {parent.setBasenames(basename.split(","));} else {parent.setBasename(basename);}//设置文件编码parent.setDefaultEncoding("UTF-8");this.setParentMessageSource(parent);}@Overrideprotected String resolveCodeWithoutArguments(String code, Locale locale) {// 获取request中设置的指定国际化文件名ServletRequestAttributes attr = (ServletRequestAttributes) RequestContextHolder.currentRequestAttributes();final String i18File = (String) attr.getAttribute(I18N_ATTRIBUTE, RequestAttributes.SCOPE_REQUEST);if (!StringUtils.isEmpty(i18File)) {//获取在basenameSet中匹配的国际化文件名String basename = getBasenameSet().stream().filter(name -> StringUtils.endsWithIgnoreCase(name, i18File)).findFirst().orElse(null);if (!StringUtils.isEmpty(basename)) {//得到指定的国际化文件资源ResourceBundle bundle = getResourceBundle(basename, locale);if (bundle != null) {return getStringOrNull(bundle, code);}}}//如果指定i18文件夹中没有该国际化字段,返回null会在ParentMessageSource中查找return null;}/*** 获取文件夹下所有的国际化文件名** @param folderName 文件名* @return* @throws IOException*/private String[] getAllBaseNames(final String folderName) throws IOException {URL url = Thread.currentThread().getContextClassLoader().getResource(folderName);if (null == url) {throw new RuntimeException("无法获取资源文件路径");}List<String> baseNames = new ArrayList<>();if (url.getProtocol().equalsIgnoreCase("file")) {// 文件夹形式,用File获取资源路径File file = new File(url.getFile());if (file.exists() && file.isDirectory()) {baseNames = Files.walk(file.toPath()).filter(path -> path.toFile().isFile()).map(Path::toString).map(path -> path.substring(path.indexOf(folderName))).map(this::getI18FileName).distinct().collect(Collectors.toList());} else {logger.error("指定的baseFile不存在或者不是文件夹");}} else if (url.getProtocol().equalsIgnoreCase("jar")) {// jar包形式,用JarEntry获取资源路径String jarPath = url.getFile().substring(url.getFile().indexOf(":") + 2, url.getFile().indexOf("!"));JarFile jarFile = new JarFile(new File(jarPath));List<String> baseJars = jarFile.stream().map(ZipEntry::toString).filter(jar -> jar.endsWith(folderName + "/")).collect(Collectors.toList());if (baseJars.isEmpty()) {logger.info("不存在{}资源文件夹", folderName);return new String[0];}baseNames = jarFile.stream().map(ZipEntry::toString).filter(jar -> baseJars.stream().anyMatch(jar::startsWith)).filter(jar -> jar.endsWith(".properties")).map(jar -> jar.substring(jar.indexOf(folderName))).map(this::getI18FileName).distinct().collect(Collectors.toList());}return baseNames.toArray(new String[0]);}/*** 把普通文件名转换成国际化文件名** @param filename* @return*/private String getI18FileName(String filename) {filename = filename.replace(".properties", "");for (int i = 0; i < 2; i++) {int index = filename.lastIndexOf("_");if (index != -1) {filename = filename.substring(0, index);}}return filename.replace("\\", "/");}
}
在项目下创建一个类继承ResourceBundleMessageSource
或者ReloadableResourceBundleMessageSource
,起名为MessageResourceExtension
。并且注入到bean
中起名为messageSource
,这里我们继承ResourceBundleMessageSource
。
注意这里我们的Component
名字必须为messageSource
,因为在初始化ApplicationContext
的时候,会查找bean
名为messageSource
的bean
。这个过程在AbstractApplicationContext.java
中,我们看一下源代码
/**
* Initialize the MessageSource.
* Use parent's if none defined in this context.
*/
protected void initMessageSource() {ConfigurableListableBeanFactory beanFactory = getBeanFactory();
if (beanFactory.containsLocalBean(MESSAGE_SOURCE_BEAN_NAME)) {this.messageSource = beanFactory.getBean(MESSAGE_SOURCE_BEAN_NAME, MessageSource.class);
...
}
}
...
在这个初始化MessageSource
的方法中,beanFactory
查找注入名为MESSAGE_SOURCE_BEAN_NAME(messageSource)
的bean
,如果没有找到,就会在其父类中查找是否有该名的bean
。
MessageResourceExtension
依次解释一下几个方法。
1.init()
方法上有一个@PostConstruct
注解,这会在MessageResourceExtension
类被实例化之后自动调用init()
方法。这个方法获取到baseFolder
目录下所有的国际化文件并设置到basenameSet
中。并且设置一个ParentMessageSource
,这会在找不到国际化信息的时候,调用父MessageSource
来查找国际化信息。
2. getAllBaseNames()
方法获取到baseFolder
的路径,然后调用getAllFile()
方法获取到该目录下所有的国际化文件的文件名。
3. getAllFile()
遍历目录,如果是文件夹就继续遍历,如果是文件就调用getI18FileName()
把文件名转为i18n/basename/
格式的国际化资源名。
在
getAllBaseNames()
方法中会先判断项目的Url
形式为文件形式还是jar
包形式。
如果是文件形式则就以普通文件夹的方式读取,这里还用了java8
中的Files.walk()
方法获取到文件夹下的所有文件,比原来自己写递归来读取方便多了。
如果是jar
包的形式,那么就要用JarEntry
来处理文件了。
首先是获取到项目jar包所在的的目录,如E:/workspace/java/Spring-Boot-I18n-Pro/target/i18n-0.0.1.jar
这种,然后根据该目录new
一个JarFile
。
接着遍历这个JarFile
包下的资源,这会把我们项目jar
包下的所有文件都读取出来,所以我们要先找到我们i18n
资源文件所在的目录,通过.filter(jar -> jar.endsWith(folderName + "/"))
获取资源所在目录。
接下来就是判断JarFile
包下的文件是否在i18n
资源目录了,如果是则调用getI18FileName()
方法将其格式化成我们所需要的名字形式。
经过这段操作就实现了获取jar包下i18n
的资源文件名了。
所以简单来说就是在MessageResourceExtension
被实例化之后,把’i18n’文件夹下的资源文件的名字,加载到Basenames
中。现在来看一下效果。
首先我们在application.properties
文件中添加一个spring.messages.baseFolder=i18n
,这会把i18n
这个值赋值给MessageResourceExtension
中的baseFolder
。
在启动后看到控制台里打印出了init
信息,表示被@PostConstruct
注解的init()方法已经执行。
[ main] c.z.i.c.MessageResourceExtension : init MessageResourceExtension...
然后我们再创建两组国际化信息文件:dashboard
和merchant
,里面分别只有一个国际化信息:dashboard.hello
和merchant.hello
。
源码:https://github.com/zzzzbw/Spring-Boot-I18n-Pro
https://zzzzbw.cn/article/7
Springboot thymeleaf i18n国际化多语言选择相关推荐
- SpringBoot系列之i18n国际化多语言支持教程
SpringBoot系列之i18n国际化多语言支持教程 文章目录 1.环境搭建 2.resource bundle资源配置 3.LocaleResolver类 4.I18n配置类 5.Thymelea ...
- springboot配置i18n国际化
springboot实现i18n国际化,无需引入其他jar包,springboot已经内置了,只需要配置即可. 国际化主要是根据不同的国际语言来决定返回数据的语言. 添加properties文件,作为 ...
- 浏览器扩展开发 - i18n 国际化多语言配置
原文地址:https://waynegong.cn/posts/954.html Chrome 浏览器扩展进行 i18n 国际化多语言适配需要进行三处修改: 在特定目录按照特定格式编写多语言配置的 m ...
- 【vue-element-admin】4.x 添加 i18n 国际化多语言切换
花裤衩前辈的vue-element-admin模块在4.x的大版本中去除了对i18n国际化的支持,本次因项目需要,在一个基于 vue-element-admin V4.2.1 版本模板开发的项目中,需 ...
- ☘gMIS吉密斯i18n国际化多语言更新
gMIS吉密斯部署和使用范围日益扩大,跨国多语言版本成为迫切需要的功能,早在2018年年中就考虑要增加这一功能--在gMIS吉密斯中实现多语言版本的支持.以期实现gMIS吉密斯的跨行业.跨地区和国际化 ...
- SpringBoot 系列教程(四十六):SpringBoot集成i18n国际化配置
一.概述 软件的国际化:软件开发时,要使它能同时应对世界不同地区和国家的访问,并针对不同地区和国家的访问,提供相应的.符合来访者阅读习惯的页面或数据. 国际化(internationalization ...
- SpringBoot实现i18n国际化配置(超详细之跟着走就会系列)
一.新增国际化资源文件 在resources文件下新建i18n文件,并新建国际化资源文件.如图: 点击新增Resource Bundle文件. 我们在Resource bundle base name ...
- angular i18n 国际化 多语言
参考扬帆天下博客:http://www.cnblogs.com/yangfantianxia/p/7878823.html 在他的基础上把设置语言的部分写在app.component.ts里,这样就变 ...
- SpringBoot RESTful 风格 API 多语言国际化i18n解决方案
文章目录 1 摘要 2 核心代码 2.1 多语言枚举类 2.2 多语言处理工具类 2.3 多语言的API返回状态码枚举类 2.4 多语言 API 接口返回结果封装 2.5 i18n 国际化多语言配置文 ...
- 一篇文章解决springboot+thymeleaf多语言国际化
1.前言 博主最近在写一个多语言的项目,因为之前没实际接触过多语言的设计,所以写这篇文章记录下这次多语言开发的过程. 博主的开发环境是:Springboot1.5.6 + thymeleaf,需要注意 ...
最新文章
- append 降低数组位数_腿粗有理!研究发现腿部脂肪多,能大幅降低患高血压的风险!...
- Django使用缓存笔记
- 已解决AttributeError set object has no attribute items(亲测)
- css跑道_如何不超出跑道:计划种子的简单方法
- 查询ElasticSearch:用SQL代替DSL
- Python系列之入门篇——HDFS
- 全网独家分享,软件测试就该这么学,3个月进大厂!
- macbook数据线连接手机_MacBook可以为iPhone进行快充吗?用MacBook为iPhone充电好不好?...
- java家谱树_青锋家谱系统-基于springboot+orgtree的青锋家谱树管理系统
- SPSS统计描述分析
- python 将繁体转换成简体
- 计算机如何启动论文,论文在电脑上开始怎么写_初学者怎么在电脑上写论文_在电脑上写稿子的全部步骤...
- 营养保健品公司网站建设策划书
- Linux下tftp服务器/客户端安装
- PHP中利用PHPMailer配合QQ邮箱实现发邮件
- Pytorch:NLP 迁移学习、NLP中的标准数据集、NLP中的常用预训练模型、加载和使用预训练模型、huggingface的transfomers微调脚本文件
- 使用单选框、复选框,让用户选择
- UVa514 Rails(铁轨)
- 零数科技创始人林乐博士荣登“长三角G60科创走廊创业榜单—星耀G60”
- Certificate doesn't match any of the subject alternative names问题的解决
热门文章
- UVA 10246 Asterix and Obelix
- WIN32API之常用进程、线程函数
- 索罗斯说,我投机了,但我不觉得我做错了什么,我做的都是合法的。
- BOW( opencv源码)
- 拓端tecdat|R语言计算资本资产定价模型(CAPM)中的Beta值和可视化
- python定义一维数组
- java netty rpc框架_Java编写基于netty的RPC框架
- 清华大学操作系统OS学习(六)——进程和线程
- 计算机地图制图的点状符号制作,计算机地图制图验手册汇编.doc
- ps2018 html面板,28组经典PS 2018插件合集