idea插件开发-Analysing

        Analysing主要是平台提供的对正在编辑的源码进行操作的高级功能,比如错误检查、高亮显示、注解器等功能。

一、控制语法错误突出显示     

        IntelliJ 平台提供了一种用于分析 PSI 树和突出显示开箱即用的语法错误的机制。在构建代码的 PSI 树时,解析器会尝试根据语言语法使用标记。当它遇到语法错误时,将创建一个PsiErrorElement并添加到 PSI 树中,并附上适当的错误描述。在代码分析守护进程中,IDE 访问树中的每个 PSI 元素,当遇到PsiErrorElement 时,IDE会收集有关它的信息并在编辑器中突出显示代码时使用。       

        这些错误提示有时干扰性会比较大,可以通过注册com.intellij.highlightErrorFilter扩展,并实现shouldHighlightErrorElement()方法来控制。

示例1:忽略 HTML 文件中不匹配的结束标记

final class HtmlClosingTagErrorFilter extends HighlightErrorFilter {
  @Override
  public boolean shouldHighlightErrorElement(@NotNull final PsiErrorElement element) {
    final PsiFile psiFile = element.getContainingFile();
    if (psiFile == null || psiFile.getViewProvider().getBaseLanguage() != HTMLLanguage.INSTANCE
                            && HTMLLanguage.INSTANCE != element.getLanguage()) return true;

    return !skip(element);
  }

  public static boolean skip(@NotNull PsiErrorElement element) {
    final PsiElement[] children = element.getChildren();
    if (children.length > 0) {
      if (children[0] instanceof XmlToken && XmlTokenType.XML_END_TAG_START == ((XmlToken)children[0]).getTokenType()) {
        if (XmlPsiBundle.message("xml.parsing.closing.tag.matches.nothing").equals(element.getErrorDescription())) {
          return true;
        }
      }
    }
    return false;
  }
}

示例2:忽略注入到 Markdown 代码块中的代码中的所有语法错误

internal class MarkdownCodeFenceErrorHighlightingIntention : IntentionAction {
  class CodeAnalyzerRestartListener: MarkdownSettings.ChangeListener {
    override fun settingsChanged(settings: MarkdownSettings) {
      val project = settings.project
      val editorManager = FileEditorManager.getInstance(project) ?: return
      val codeAnalyzer = DaemonCodeAnalyzerImpl.getInstance(project) ?: return
      val psiManager = PsiManager.getInstance(project)
      editorManager.openFiles
        .asSequence()
        .filter { FileTypeRegistry.getInstance().isFileOfType(it, MarkdownFileType.INSTANCE) }
        .mapNotNull(psiManager::findFile)
        .forEach(codeAnalyzer::restart)
    }
  }

  override fun getText(): String = MarkdownBundle.message("markdown.hide.problems.intention.text")

  override fun getFamilyName(): String = text

  override fun isAvailable(project: Project, editor: Editor?, file: PsiFile?): Boolean {
    if (file?.fileType != MarkdownFileType.INSTANCE || !MarkdownSettings.getInstance(project).showProblemsInCodeBlocks) {
      return false
    }
    val element = file?.findElementAt(editor?.caretModel?.offset ?: return false) ?: return false
    return PsiTreeUtil.getParentOfType(element, MarkdownCodeFence::class.java) != null
  }

  override fun invoke(project: Project, editor: Editor?, file: PsiFile?) {
    setHideErrors(project, true)
    val notification = MarkdownNotifications.group.createNotification(
      MarkdownBundle.message("markdown.hide.problems.notification.title"),
      MarkdownBundle.message("markdown.hide.problems.notification.content"),
      NotificationType.INFORMATION
    )
    notification.addAction(object: NotificationAction(MarkdownBundle.message("markdown.hide.problems.notification.rollback.action.text")) {
      override fun actionPerformed(e: AnActionEvent, notification: Notification) {
        setHideErrors(project, false)
        notification.expire()
      }
    })
    notification.notify(project)
  }

  private fun setHideErrors(project: Project, hideErrors: Boolean) {
    MarkdownSettings.getInstance(project).update {
      it.showProblemsInCodeBlocks = !hideErrors
    }
  }

  override fun startInWriteAction(): Boolean = false
}

二、代码检查和纠正

        IntelliJ 平台提供了专为静态代码分析而设计的工具,称为代码检查,可帮助用户维护和清理代码,而无需实际执行代码。自定义代码检查可以作为 IntelliJ 平台插件实现。

在 Settings | Editor | Inspections 中可以设置,如下图

        本章会创建一个自定义检查规则,实现在代码中检查在两个String变量中使用== or !=的情况并提示比如把a!=b切换成!a.equals(b),然后添加到 Settings | Editor | InspectionsJava | Probable Bugs 组中。

1、插件配置文件

注册com.intellijlocalInspection扩展点,最基本的检查必须声明implementationClass和language属性,也可以在localInspection中定义其它属性。两种类型的检查扩展:

  • com.intellij.localInspection:用于一次对一个文件进行操作的检查,并且还可以在用户编辑文件时“即时”操作。
  • com.intellijglobalInspection:用于跨多个文件操作的检查,并且关联的修复可能会在文件之间重构代码等。
<extensions defaultExtensionNs="com.intellij">
    <!--
      Extend the IntelliJ Platform local inspection type and connect it to the implementation class in this plugin.
      <localInspection> type element is applied within the scope of a file under edit.
      It is preferred over <inspectionToolProvider>
        @see intellij.platform.resources.LangExtensionPoints
        @see com.intellij.codeInspection.InspectionProfileEntry

      Attributes:
        - language - inspection language ID
        - shortName - not specified, will be computed by the underlying implementation classes
        - bundle - name of the message bundle for the "key" attribute
        - key - the key of the message to be shown in the Settings | Editor | Inspections panel
        - groupPath - defines the outermost grouping for this inspection in
            the Settings | Editor | Inspections panel. Not localized.
        - groupBundle - the name of a message bundle file to translate groupKey
            In this case, reuse an IntelliJ Platform bundle file from intellij.platform.resources.en
        - groupKey - the key to use for translation subgroup name using groupBundle file.
            In this case, reuse the IntelliJ Platform subcategory "Probable bugs"
        - enabledByDefault - inspection state when the Inspections panel is created.
        - level - the default level of error found by this inspection, e.g. INFO, ERROR, etc.
            @see com.intellij.codeHighlighting.HighlightDisplayLevel
        - implementationClass= the fully-qualified name of the inspection implementation class
    -->
    <localInspection language="JAVA"
                     bundle="messages.InspectionBundle"
                     key="inspection.comparing.string.references.display.name"
                     groupPath="Java"
                     groupBundle="messages.InspectionsBundle"
                     groupKey="group.names.probable.bugs"
                     enabledByDefault="true"
                     level="WARNING"
                     implementationClass="org.intellij.sdk.codeInspection.ComparingStringReferencesInspection"/>
  </extensions>

2、设置国际化文件

        创建resources\messages\InspectionBundle.properties文件,内容如下:

inspection.comparing.string.references.display.name=SDK: '==' or '!=' used instead of 'equals()'
inspection.comparing.string.references.problem.descriptor=SDK: String objects compared with equality operation
inspection.comparing.string.references.use.quickfix=SDK: Use equals()

        工具类实现

public final class InspectionBundle extends DynamicBundle {

  private static final InspectionBundle ourInstance = new InspectionBundle();

  @NonNls
  public static final String BUNDLE = "messages.InspectionBundle";

  private InspectionBundle() {
    super(BUNDLE);
  }

  public static @Nls String message(@NotNull @PropertyKey(resourceBundle = BUNDLE) String key,
                                    Object @NotNull ... params) {
    return ourInstance.getMessage(key, params);
  }

}

 3、Inspection类实现

        Java文件的检查实现一般都基于AbstractBaseJavaLocalInspectionTool来实现,例如ComparingStringReferencesInspection,AbstractBaseJavaLocalInspectionTool类提供检查 Java、类、字段和方法的方法。
        localInspection类型是基于LocalInspectionTool来实现的。LocalInspectionTool为IntelliJ 平台中各种语言和框架提供了许多子检查类。所以也可以基于LocalInspectionTool来实现。检查实现类的主要职责是提供:

  • PsiElementVisitor遍历被检查文件的PSI树的对象;
  • LocalQuickFix修复已识别问题的类(可选);
  • 要在检查设置对话框中显示的选项面板(可选);

Visitor功能实现

   Vistor类评估文件的 PSI 树的元素是否对检查感兴趣。ComparingStringReferencesInspection.buildVisitor()方法会创建一个匿名访问者类,以JavaElementVisitor遍历正在编辑的Java文件的 PSI 树,检查可疑语法。匿名类重写visitBinaryExpression(),它检查 aPsiBinaryExpression的运算符是否为==or!=以及两个操作数类型是否为String。

quick fix功能实现

        示例中的quick fix功能是在基于ReplaceWithEqualsQuickFix实现的,内部主要使用LocalquickFix提供的功能。关键的逻辑定义在ReplaceWithEqualsQuickFix.applyFix(),它可以把PSI树转换成表达式:

  • 得到一个PsiElementFactory;
  • 创建一个新的PsiMethodCallExpression;
  • 将原来的左右操作数代入新的PsiMethodCallExpression;
  • 用 替换原来的二进制表达式PsiMethodCallExpression;
    •         以上完整代码如下:
    • public class ComparingStringReferencesInspection extends AbstractBaseJavaLocalInspectionTool {
      
        private final ReplaceWithEqualsQuickFix myQuickFix = new ReplaceWithEqualsQuickFix();
      
        /**
         * This method is overridden to provide a custom visitor
         * that inspects expressions with relational operators '==' and '!='.
         * The visitor must not be recursive and must be thread-safe.
         *
         * @param holder     object for the visitor to register problems found
         * @param isOnTheFly true if inspection was run in non-batch mode
         * @return non-null visitor for this inspection
         * @see JavaElementVisitor
         */
        @NotNull
        @Override
        public PsiElementVisitor buildVisitor(@NotNull final ProblemsHolder holder, boolean isOnTheFly) {
          return new JavaElementVisitor() {
      
            /**
             * Evaluate binary psi expressions to see if they contain relational operators '==' and '!=',
             * AND they are of String type.
             * The evaluation ignores expressions comparing an object to null.
             * IF these criteria are met, register the problem in the ProblemsHolder.
             *
             * @param expression The binary expression to be evaluated.
             */
            @Override
            public void visitBinaryExpression(PsiBinaryExpression expression) {
              super.visitBinaryExpression(expression);
              IElementType opSign = expression.getOperationTokenType();
              if (opSign == JavaTokenType.EQEQ || opSign == JavaTokenType.NE) {
                // The binary expression is the correct type for this inspection
                PsiExpression lOperand = expression.getLOperand();
                PsiExpression rOperand = expression.getROperand();
                if (rOperand == null || isNullLiteral(lOperand) || isNullLiteral(rOperand)) {
                  return;
                }
                // Nothing is compared to null, now check the types being compared
                if (isStringType(lOperand) || isStringType(rOperand)) {
                  // Identified an expression with potential problems, register problem with the quick fix object
                  holder.registerProblem(expression,
                          InspectionBundle.message("inspection.comparing.string.references.problem.descriptor"),
                          myQuickFix);
                }
              }
            }
      
            private boolean isStringType(PsiExpression operand) {
              PsiType type = operand.getType();
              if (!(type instanceof PsiClassType)) {
                return false;
              }
              PsiClass resolvedType = ((PsiClassType) type).resolve();
              if (resolvedType == null) {
                return false;
              }
              return "java.lang.String".equals(resolvedType.getQualifiedName());
            }
      
            private static boolean isNullLiteral(PsiExpression expression) {
              return expression instanceof PsiLiteralExpression &&
                      ((PsiLiteralExpression) expression).getValue() == null;
            }
          };
        }
      
        /**
         * This class provides a solution to inspection problem expressions by manipulating the PSI tree to use 'a.equals(b)'
         * instead of '==' or '!='.
         */
        private static class ReplaceWithEqualsQuickFix implements LocalQuickFix {
      
          /**
           * Returns a partially localized string for the quick fix intention.
           * Used by the test code for this plugin.
           *
           * @return Quick fix short name.
           */
          @NotNull
          @Override
          public String getName() {
            return InspectionBundle.message("inspection.comparing.string.references.use.quickfix");
          }
      
          /**
           * This method manipulates the PSI tree to replace 'a==b' with 'a.equals(b)' or 'a!=b' with '!a.equals(b)'.
           *
           * @param project    The project that contains the file being edited.
           * @param descriptor A problem found by this inspection.
           */
          public void applyFix(@NotNull Project project, @NotNull ProblemDescriptor descriptor) {
            PsiBinaryExpression binaryExpression = (PsiBinaryExpression) descriptor.getPsiElement();
            IElementType opSign = binaryExpression.getOperationTokenType();
            PsiExpression lExpr = binaryExpression.getLOperand();
            PsiExpression rExpr = binaryExpression.getROperand();
            if (rExpr == null) {
              return;
            }
      
            PsiElementFactory factory = JavaPsiFacade.getInstance(project).getElementFactory();
            PsiMethodCallExpression equalsCall =
                    (PsiMethodCallExpression) factory.createExpressionFromText("a.equals(b)", null);
      
            equalsCall.getMethodExpression().getQualifierExpression().replace(lExpr);
            equalsCall.getArgumentList().getExpressions()[0].replace(rExpr);
      
            PsiExpression result = (PsiExpression) binaryExpression.replace(equalsCall);
      
            if (opSign == JavaTokenType.NE) {
              PsiPrefixExpression negation = (PsiPrefixExpression) factory.createExpressionFromText("!a", null);
              negation.getOperand().replace(result);
              result.replace(negation);
            }
          }
      
          @NotNull
          public String getFamilyName() {
            return getName();
          }
      
        }
      
      }

      4、帮助文档实现

        检查描述是一个 HTML 文件。当从列表中选择检查时,描述将显示在检查设置对话框的右上面板中。LocalInspectionTool在检查实现的类层次结构中需遵循一些隐藏式的约定。

  • 检查描述文件应位于$RESOURCES_ROOT_DIRECTORY$/inspectionDescriptions/下。如果要将检查描述文件放在别处,则getDescriptionUrl()在检查实现类中重写;
  • 描述文件的名称应为检查描述或检查实现类提供的检查$SHORT_NAME$.html 。如果未提供短名称,IntelliJ 平台将通过Inspection从实现类名称中删除后缀来计算一个;
<!-- Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<html>
<body>
Reports usages of <code>==</code> and <code>!=</code> when comparing instances of String.
<p>
    Quick-fix replaces operator with <code>equals()</code> call.
</p>
<p>
<!--可以设置面板-->
    See <em>Includes</em> tab in <a href="settings://fileTemplates">Settings | Editor | File and Code Templates</a> to configure.
</p>
</body>
</html>

5、运行

 三、控制突出显示

        通过IntelliJ平台提供的几种机制(语法错误、注释器、检查)分析代码的结果被转换为用于在编辑器中突出显示代码的突出显示信息。允许插件决定哪些突出显示信息将在编辑器中可见,插件要注册com.intellij.daemon.highlightInfoFilter扩展点,并实现其accept()方法,当返回true时,会生成HighlightInfo信息给Editor。

1、示例:在调试器代码编辑器中禁止报告未处理的异常

public class DebuggerHighlightFilter implements HighlightInfoFilter {
  @Override
  public boolean accept(@NotNull HighlightInfo highlightInfo, PsiFile file) {
    return highlightInfo.type != HighlightInfoType.UNHANDLED_EXCEPTION ||
           file == null ||
           !DefaultCodeFragmentFactory.isDebuggerFile(file);
  }
}

2、示例:使用 Lombok时禁止报告项目中的误报错误

public class LombokHighlightErrorFilter implements HighlightInfoFilter {

  private static final class Holder {
    static final Pattern LOMBOK_ANY_ANNOTATION_REQUIRED =
      Pattern.compile(JavaErrorBundle.message("incompatible.types", "lombok.*AnyAnnotation\\[\\]", "__*"));

    static final Map<HighlightSeverity, Map<TextAttributesKey, List<LombokHighlightFilter>>> registeredFilters;
    static final Map<HighlightSeverity, Map<TextAttributesKey, List<LombokHighlightFixHook>>> registeredHooks;

    static {
      registeredFilters = new HashMap<>();
      registeredHooks = new HashMap<>();

      for (LombokHighlightFilter highlightFilter : LombokHighlightFilter.values()) {
        registeredFilters.computeIfAbsent(highlightFilter.severity, s -> new HashMap<>())
          .computeIfAbsent(highlightFilter.key, k -> new ArrayList<>())
          .add(highlightFilter);
      }

      for (LombokHighlightFixHook highlightFixHook : LombokHighlightFixHook.values()) {
        registeredHooks.computeIfAbsent(highlightFixHook.severity, s -> new HashMap<>())
          .computeIfAbsent(highlightFixHook.key, k -> new ArrayList<>())
          .add(highlightFixHook);
      }
    }
  }

  public LombokHighlightErrorFilter() {
  }

  @Override
  public boolean accept(@NotNull HighlightInfo highlightInfo, @Nullable PsiFile file) {
    if (null == file) {
      return true;
    }

    Project project = file.getProject();
    if (!LombokLibraryUtil.hasLombokLibrary(project)) {
      return true;
    }

    PsiElement highlightedElement = file.findElementAt(highlightInfo.getStartOffset());
    if (null == highlightedElement) {
      return true;
    }

    // check exceptions for highlights
    boolean acceptHighlight = Holder.registeredFilters
      .getOrDefault(highlightInfo.getSeverity(), Collections.emptyMap())
      .getOrDefault(highlightInfo.type.getAttributesKey(), Collections.emptyList())
      .stream()
      .filter(filter -> filter.descriptionCheck(highlightInfo.getDescription(), highlightedElement))
      .allMatch(filter -> filter.accept(highlightedElement));

    // check if highlight was filtered
    if (!acceptHighlight) {
      return false;
    }

    // handle rest cases
    String description = highlightInfo.getDescription();
    if (HighlightSeverity.ERROR.equals(highlightInfo.getSeverity())) {
      //Handling onX parameters
      if (OnXAnnotationHandler.isOnXParameterAnnotation(highlightInfo, file)
        || OnXAnnotationHandler.isOnXParameterValue(highlightInfo, file)
        || (description != null && Holder.LOMBOK_ANY_ANNOTATION_REQUIRED.matcher(description).matches())) {
        return false;
      }
    }

    // register different quick fix for highlight
    Holder.registeredHooks
      .getOrDefault(highlightInfo.getSeverity(), Collections.emptyMap())
      .getOrDefault(highlightInfo.type.getAttributesKey(), Collections.emptyList())
      .stream()
      .filter(filter -> filter.descriptionCheck(highlightInfo.getDescription()))
      .forEach(filter -> filter.processHook(highlightedElement, highlightInfo));

    return true;
  }

  private enum LombokHighlightFixHook {

    UNHANDLED_EXCEPTION(HighlightSeverity.ERROR, CodeInsightColors.ERRORS_ATTRIBUTES) {
      private final Pattern pattern = preparePattern(1);
      private final Pattern pattern2 = preparePattern(2);

      @NotNull
      private static Pattern preparePattern(int count) {
        return Pattern.compile(JavaErrorBundle.message("unhandled.exceptions", ".*", count));
      }

      @Override
      public boolean descriptionCheck(@Nullable String description) {
        return description != null && (pattern.matcher(description).matches() || pattern2.matcher(description).matches());
      }

      @Override
      public void processHook(@NotNull PsiElement highlightedElement, @NotNull HighlightInfo highlightInfo) {
        PsiElement importantParent = PsiTreeUtil.getParentOfType(highlightedElement,
          PsiMethod.class, PsiLambdaExpression.class, PsiMethodReferenceExpression.class, PsiClassInitializer.class
        );

        // applicable only for methods
        if (importantParent instanceof PsiMethod) {
          AddAnnotationFix fix = new AddAnnotationFix(LombokClassNames.SNEAKY_THROWS, (PsiModifierListOwner) importantParent);
          highlightInfo.registerFix(fix, null, null, null, null);
        }
      }
    };

    private final HighlightSeverity severity;
    private final TextAttributesKey key;

    LombokHighlightFixHook(@NotNull HighlightSeverity severity, @Nullable TextAttributesKey key) {
      this.severity = severity;
      this.key = key;
    }

    abstract public boolean descriptionCheck(@Nullable String description);

    abstract public void processHook(@NotNull PsiElement highlightedElement, @NotNull HighlightInfo highlightInfo);
  }

  private enum LombokHighlightFilter {
    // ERROR HANDLERS

    //see com.intellij.java.lomboktest.LombokHighlightingTest.testGetterLazyVariableNotInitialized
    VARIABLE_MIGHT_NOT_BEEN_INITIALIZED(HighlightSeverity.ERROR, CodeInsightColors.ERRORS_ATTRIBUTES) {
      @Override
      public boolean descriptionCheck(@Nullable String description, PsiElement highlightedElement) {
        return JavaErrorBundle.message("variable.not.initialized", highlightedElement.getText()).equals(description);
      }

      @Override
      public boolean accept(@NotNull PsiElement highlightedElement) {
        return !LazyGetterHandler.isLazyGetterHandled(highlightedElement);
      }
    },

    //see com.intellij.java.lomboktest.LombokHighlightingTest.testFieldNameConstantsExample
    CONSTANT_EXPRESSION_REQUIRED(HighlightSeverity.ERROR, CodeInsightColors.ERRORS_ATTRIBUTES) {
      @Override
      public boolean descriptionCheck(@Nullable String description, PsiElement highlightedElement) {
        return JavaErrorBundle.message("constant.expression.required").equals(description);
      }

      @Override
      public boolean accept(@NotNull PsiElement highlightedElement) {
        return !FieldNameConstantsHandler.isFiledNameConstants(highlightedElement);
      }
    },

    // WARNINGS HANDLERS
    //see com.intellij.java.lomboktest.LombokHighlightingTest.testBuilderWithDefaultRedundantInitializer
    VARIABLE_INITIALIZER_IS_REDUNDANT(HighlightSeverity.WARNING, CodeInsightColors.NOT_USED_ELEMENT_ATTRIBUTES) {
      private final Pattern pattern = Pattern.compile(
        JavaBundle.message("inspection.unused.assignment.problem.descriptor2", "(.+)", "(.+)"));

      @Override
      public boolean descriptionCheck(@Nullable String description, PsiElement highlightedElement) {
        return description != null && pattern.matcher(description).matches();
      }

      @Override
      public boolean accept(@NotNull PsiElement highlightedElement) {
        return !BuilderHandler.isDefaultBuilderValue(highlightedElement);
      }
    },

    // field should have lazy getter and should be initialized in constructors
    //see com.intellij.java.lomboktest.LombokHighlightingTest.testGetterLazyInvocationProduceNPE
    METHOD_INVOCATION_WILL_PRODUCE_NPE(HighlightSeverity.WARNING, CodeInsightColors.WARNINGS_ATTRIBUTES) {
      private final CommonProblemDescriptor descriptor = new CommonProblemDescriptor() {
          @Override
          public @NotNull String getDescriptionTemplate() {
            return JavaAnalysisBundle.message("dataflow.message.npe.method.invocation.sure");
          }

          @Override
          public @NotNull QuickFix @Nullable [] getFixes() {
            return null;
          }
        };
      @Override
      public boolean descriptionCheck(@Nullable String description, PsiElement highlightedElement) {
        return ProblemDescriptorUtil.renderDescriptionMessage(descriptor, highlightedElement).equals(description);
      }

      @Override
      public boolean accept(@NotNull PsiElement highlightedElement) {
        return !LazyGetterHandler.isLazyGetterHandled(highlightedElement)
          || !LazyGetterHandler.isInitializedInConstructors(highlightedElement);
      }
    };

    private final HighlightSeverity severity;
    private final TextAttributesKey key;

    LombokHighlightFilter(@NotNull HighlightSeverity severity, @Nullable TextAttributesKey key) {
      this.severity = severity;
      this.key = key;
    }

    /**
     * @param description            of the current highlighted element
     * @param highlightedElement     the current highlighted element
     * @return true if the filter can handle current type of the highlight info with that kind of the description
     */
    abstract public boolean descriptionCheck(@Nullable String description, PsiElement highlightedElement);

    /**
     * @param highlightedElement the deepest element (it's the leaf element in PSI tree where the highlight was occurred)
     * @return false if the highlight should be suppressed
     */
    abstract public boolean accept(@NotNull PsiElement highlightedElement);
  }
}

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

korgs

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值