Flutter嵌入式SDK插件embeddedsdk的功能使用

发布于 1周前 作者 wuwangju 来自 Flutter

Flutter嵌入式SDK插件embeddedsdk的功能使用

Beyond Identity Flutter SDK

Beyond-Identity-768x268

嵌入式

嵌入式SDK是一个全面的SDK解决方案,提供了整个体验嵌入到您的产品中。用户无需下载Beyond Identity验证器。


安装

在您的依赖项中添加Beyond Identity嵌入式SDK:

dependencies:
  embeddedsdk: x.y.z

然后运行隐式的flutter pub get命令。


使用

有关更多信息,请参阅文档

示例应用

运行Android示例应用
  1. 从仓库根目录运行flutter pub get
  2. 从示例目录运行flutter run或使用Android Studio。确保已启动Android设备。
运行iOS示例应用
  1. 从仓库根目录运行flutter pub get
  2. example/ios目录运行pod install --repo-update
  3. 从示例目录运行flutter run或使用XCode。

示例代码

以下是一个完整的示例代码,展示了如何使用embeddedsdk插件进行基本操作。

import 'dart:async';
import 'dart:convert';

import 'package:embeddedsdk/embeddedsdk.dart';
import 'package:embeddedsdk_example/config.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:uni_links/uni_links.dart';
import 'package:http/http.dart' as http;
import 'package:http_interceptor/http_interceptor.dart';
import 'package:uuid/uuid.dart';

// 全局变量用于处理初始URI
bool _initialUriIsHandled = false;

class MyApp extends StatefulWidget {
  const MyApp({Key? key}) : super(key: key);

  [@override](/user/override)
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  final bool _enableLogging = true;

  Domain _initDomain = Domain.us;
  String _initUSTenantText = '';
  String _initEUTenantText = '';

  final _createUserController = TextEditingController();
  String _createUserText = '';

  final _recoverUserController = TextEditingController();
  String _recoverUserText = '';

  Uri? _initialUri;
  Uri? _latestUri;
  Object? _err;
  StreamSubscription? _sub;

  Credential? _credentialRegistered;
  String _credentialRegisteredText = '';

  List<Credential>? _credentials;
  String _credentialsText = '';

  String _exportTokenText = '';

  String _importText = '';
  final _importTokenController = TextEditingController();

  String _deleteCredentialResult = '';

  String _authorizationCode = '';
  String _authorizationCodeText = '';
  String _authorizationExchangeTokenText = '';

  String _authTokenText = '';
  PKCE? _pkce;

  // 导出更新回调函数
  exportUpdateCallback(Map<String, String?> data) async {
    debugPrint("Export callback invoked $data");
    String? status = data["status"];
    String exportTokenText = "";
    if (status != null) {
      switch (status) {
        case ExtendCredentialsStatus.update:
          if (data["token"] != null) {
            exportTokenText = "Extend token = ${data["token"]}";
          } else {
            exportTokenText = "error getting the token | data = $data";
          }
          break;
        case ExtendCredentialsStatus.finish:
          exportTokenText = "Credential extended successfully";
          break;
        case ExtendCredentialsStatus.error:
          exportTokenText = data["errorMessage"] ?? "error getting the errorMessage | data = $data";
          break;
      }
    } else {
      exportTokenText = "extend credential status was null";
    }

    if (!mounted) return;

    setState(() {
      _exportTokenText = exportTokenText;
    });
  }

  [@override](/user/override)
  void initState() {
    super.initState();
  }

  [@override](/user/override)
  void dispose() {
    _sub?.cancel();
    Embeddedsdk.cancelExtendCredentials();
    _importTokenController.dispose();
    super.dispose();
  }

  // 初始化美国租户
  void _initUSTenant() async {
    String initUSTenantText = '';

    setState(() {
      _initEUTenantText = initUSTenantText;
      _initUSTenantText = initUSTenantText;
    });

    initUSTenantText = _initTenant(Domain.us);

    setState(() {
      _initUSTenantText = initUSTenantText;
    });
  }

  // 初始化欧洲租户
  void _initEUTenant() async {
    String initEUTenantText = '';

    setState(() {
      _initEUTenantText = initEUTenantText;
      _initUSTenantText = initEUTenantText;
    });

    initEUTenantText = _initTenant(Domain.eu);

    setState(() {
      _initEUTenantText = initEUTenantText;
    });
  }

  // 初始化租户
  String _initTenant(Domain domain) {
    String initTenantText = '';

    try {
      _initDomain = domain;

      Embeddedsdk.initialize(BuildConfig.getPublicClientId(_initDomain), _initDomain, "Gimmie your biometrics", BuildConfig.REDIRECT_URI, _enableLogging);
      _handleInitialUri();
      _handleIncomingLinks();

      initTenantText = "Initialized Client on $_initDomain";
    } on Exception catch (e) {
      initTenantText = "Error initializing $e";
    }

    return initTenantText;
  }

  // 创建演示用户
  void _createDemoUser() async {
    String createUserText = '';
    http.Client client = InterceptedClient.build(interceptors: [LoggingInterceptor()]);

    try {
      var uuid = const Uuid().v4().toString();
      var response = await client.post(
        Uri.parse(BuildConfig.getCreateUserUrl(_initDomain)),
        headers: <String, String>{
          'Content-Type': 'application/json; charset=UTF-8',
          'Accept': 'application/json; charset=UTF-8',
          'Authorization': 'Bearer ${BuildConfig.getApiToken(_initDomain)}',
        },
        body: jsonEncode({
          'binding_token_delivery_method': 'email',
          'external_id': _createUserController.text,
          'email': _createUserController.text,
          'user_name': uuid,
          'display_name': uuid,
        }),
      );

      createUserText = response.body;

    } on Exception catch (e) {
      createUserText = "Error creating user $e";
    } finally {
      client.close();
    }

    setState(() {
      _createUserText = createUserText;
    });
  }

  // 恢复现有用户
  void _recoverDemoUser() async {
    String recoverUserText = '';
    http.Client client = InterceptedClient.build(interceptors: [LoggingInterceptor()]);

    try {
      var response = await client.post(
        Uri.parse(BuildConfig.getRecoverUserUrl(_initDomain)),
        headers: <String, String>{
          'Content-Type': 'application/json; charset=UTF-8',
          'Accept': 'application/json; charset=UTF-8',
          'Authorization': 'Bearer ${BuildConfig.getApiToken(_initDomain)}',
        },
        body: jsonEncode({
          'binding_token_delivery_method': 'email',
          'external_id': _recoverUserController.text,
        }),
      );

      recoverUserText = response.body;

    } on Exception catch (e) {
      recoverUserText = "Error recovering user $e";
    } finally {
      client.close();
    }

    setState(() {
      _recoverUserText = recoverUserText;
    });
  }

  // 交换授权码以获取令牌
  void _exchangeAuthzCodeForTokens() async {
    String responseText = '';
    if (_authorizationCode.isNotEmpty) {
      http.Client client = InterceptedClient.build(interceptors: [LoggingInterceptor()]);

      Map<String, String> params = {
        'code': _authorizationCode,
        'redirect_uri': BuildConfig.REDIRECT_URI,
        'grant_type': 'authorization_code',
      };

      if (_pkce != null) {
        params['code_verifier'] = _pkce!.codeVerifier;
      }

      try {
        var response = await client.post(
          Uri.parse(BuildConfig.getTokenEndpoint(_initDomain)),
          headers: <String, String>{
            'Content-Type': 'application/x-www-form-urlencoded',
            'Authorization': 'Basic ${base64Encode(utf8.encode('${BuildConfig.getConfidentialClientId(_initDomain)}:${BuildConfig.getConfidentialClientSecret(_initDomain)}'))}',
          },
          encoding: Encoding.getByName('utf-8'),
          body: params,
        );

        responseText = response.body;

      } on Exception catch (e) {
        responseText = "Error exchanging authorization code for tokens $e";
      } finally {
        client.close();
      }
    } else {
      responseText = "Get Authorization Code first by clicking the Authorize button";
    }

    setState(() {
      _authorizationExchangeTokenText = responseText;
      _pkce = null;
    });
  }

  // 处理传入链接
  void _handleIncomingLinks() {
    _sub = uriLinkStream.listen((Uri? uri) {
      if (!mounted) return;
      debugPrint('got uri: $uri');
      _registerCredentialsWithUrl(uri);
      setState(() {
        _latestUri = uri;
        _err = null;
      });
    }, onError: (Object err) {
      if (!mounted) return;
      debugPrint('got err: $err');
      setState(() {
        _latestUri = null;
        if (err is FormatException) {
          _err = err;
        } else {
          _err = null;
        }
      });
    });
  }

  // 处理初始URI
  Future<void> _handleInitialUri() async {
    if (!_initialUriIsHandled) {
      _initialUriIsHandled = true;

      try {
        final uri = await getInitialUri();
        if (uri == null) {
          debugPrint('no initial uri');
        } else {
          debugPrint('got initial uri: $uri');
        }
        _registerCredentialsWithUrl(uri);
        if (!mounted) return;
        setState(() {
          _initialUri = uri;
        });
      } on PlatformException {
        debugPrint('failed to get initial uri');
      } on FormatException catch (err) {
        if (!mounted) return;
        debugPrint('malformed initial uri');
        setState(() => _err = err);
      }
    }
  }

  // 注册凭据
  Future<void> _registerCredentialsWithUrl(Uri? registerUri) async {
    Credential? credential;
    String regCredText = '';

    if (registerUri != null) {
      try {
        String uri = registerUri.toString().replaceAll(":?", "://host/register?");
        debugPrint("Register with = $uri");
        credential = await Embeddedsdk.registerCredentialsWithUrl(uri);
        regCredText = "Credential successfully registered";
      } on PlatformException catch (e) {
        debugPrint("platform exception = $e");
        regCredText = "Error registering credentials = $e";
      } on Exception catch (e) {
        debugPrint("exception = $e");
        regCredText = "Error registering credentials = $e";
      }
    }

    if (!mounted) return;

    setState(() {
      _credentialRegistered = credential;
      _credentialRegisteredText = regCredText;
    });
  }

  // 获取凭据
  Future<void> _getCreds() async {
    List<Credential>? credentials;
    String credText = '';

    try {
      credentials = await Embeddedsdk.getCredentials();
      credText = "Credentials = $credentials";
    } on PlatformException catch (e) {
      debugPrint("platform exception = $e");
      credText = "Error getting credentials = $e";
    } on Exception catch (e) {
      debugPrint("exception = $e");
      credText = "Error getting credentials = $e";
    }

    if (!mounted) return;

    setState(() {
      _credentials = credentials;
      _credentialsText = credText;
    });
  }

  // 创建PKCE
  Future<void> _createPkce() async {
    PKCE? pkce;

    try {
      pkce = await Embeddedsdk.createPkce();
    } on PlatformException {
      pkce = PKCE(codeVerifier: "error", codeChallenge: "error", codeChallengeMethod: "error");
    }

    if (!mounted) return;

    setState(() {
      _pkce = pkce;
    });
  }

  // 授权
  Future<void> _authorize() async {
    String authz;
    String authzText;

    Embeddedsdk.initialize(BuildConfig.getConfidentialClientId(_initDomain), _initDomain, "Gimmie your biometrics", BuildConfig.REDIRECT_URI, _enableLogging);

    try {
      authz = await Embeddedsdk.authorize("openid", _pkce?.codeChallenge);
      authzText = "Authorization code = $authz";
    } on PlatformException catch (e) {
      authz = '';
      authzText = "Error getting authz code | error = $e";
    }

    if (!mounted) return;

    setState(() {
      _authorizationCode = authz;
      _authorizationCodeText = authzText;
    });
  }

  // 扩展凭据
  Future<void> _extendCredentials() async {
    String exportToken;
    String exportTokenText;

    try {
      exportTokenText = "Export started";
      Embeddedsdk.extendCredentials(List.generate(1, (index) => BuildConfig.DEMO_TENANT_HANDLE), exportUpdateCallback);
    } on PlatformException {
      exportTokenText = "Error exporting credential";
    }

    if (!mounted) return;

    setState(() {
      _exportTokenText = exportTokenText;
    });
  }

  // 取消扩展凭据
  Future<void> _cancelExtendCredentials() async {
    String cancelText = "";

    try {
      await Embeddedsdk.cancelExtendCredentials();
      cancelText = "Extend credentials cancelled";
    } on PlatformException {
      cancelText = "Error cancelling extend credentials";
    }

    if (!mounted) return;

    setState(() {
      _exportTokenText = cancelText;
    });
  }

  // 认证
  Future<void> _authenticate() async {
    TokenResponse token;
    String tokenText = "";

    Embeddedsdk.initialize(BuildConfig.getPublicClientId(_initDomain), _initDomain, "Gimmie your biometrics", BuildConfig.REDIRECT_URI, _enableLogging);

    try {
      token = await Embeddedsdk.authenticate();
      tokenText = token.toString();
    } on PlatformException {
      tokenText = 'Error getting auth token';
    }

    if (!mounted) return;

    setState(() {
      _authTokenText = tokenText;
    });
  }

  // 删除凭据
  Future<void> _deleteCredential() async {
    String handle;

    try {
      handle = await Embeddedsdk.deleteCredential(BuildConfig.DEMO_TENANT_HANDLE);
    } on PlatformException catch (e) {
      handle = "could not delete credential $e";
    }

    if (!mounted) return;

    setState(() {
      _deleteCredentialResult = handle;
    });
  }

  // 注册凭据
  Future<void> _registerCredentials() async {
    String importText = '';

    try {
      await Embeddedsdk.registerCredentialsWithToken(_importTokenController.text);
      importText = "Credentials successfully registered";
    } on PlatformException catch (e) {
      importText = "Error registering credentials $e";
    }

    if (!mounted) return;

    setState(() {
      _importText = importText;
      _importTokenController.text = "";
    });
  }

  // 构建UI
  [@override](/user/override)
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(
          title: const Text('Embedded SDK example app'),
        ),
        body: Container(
          color: Colors.grey[300],
          child: SingleChildScrollView(
            padding: const EdgeInsets.all(4),
            child: Column(
              children: [
                _card([
                  _title("Tenant Utils"),
                  _description("\nTenants:"),
                  _buttonTextGroup("US Tenant", _initUSTenant, _initUSTenantText),
                  _buttonTextGroup("EU Tenant", _initEUTenant, _initEUTenantText),
                  _subTitle("\nNote: Before doing anything else, you must select a tenant"),
                ]),
                _card([
                  _title("Demo Utils"),
                  _description("\nCreate user for testing. Get an email with registration link."),
                  _buttonInputTextGroup("Create Demo User", "User Email", _createUserController, _createDemoUser, _createUserText),
                  _description("\nRecover existing user for testing. Get an email with recovery link."),
                  _buttonInputTextGroup("Recover Demo User", "User Email", _recoverUserController, _recoverDemoUser, _recoverUserText),
                ]),
                _card([
                  _title("\nEmbedded SDK Functionality"),
                  _title("\nCredentials"),
                  _subTitle("Credential is what identifies the user on this device."),
                  Text(_credentialRegisteredText),
                  _buttonTextGroup("Get Credentials", _getCreds, _credentialsText),
                  _buttonTextGroup("Delete Credential", _deleteCredential, _deleteCredentialResult),
                ]),
                _card([
                  _title("Extended/Register Credentials"),
                  _subTitle("Before authenticating on another device, the credential needs to be transferred to that device."),
                  _description("\nTransfer a credential from this to another device. Extend Credentials will generate an extended credential that can be used to register the credential on another device\nNOTE: Lock screen needs to be set on the device!"),
                  _buttonTextGroup("Extend Credentials", _extendCredentials, _exportTokenText),
                  _description("\nExtending Credentials blocks the Embedded SDK from performing other operations. The extended credential needs to finish or be explicitly cancelled."),
                  _buttonTextGroup("Cancel Credentials Extend", _cancelExtendCredentials, ""),
                  _description("To register credential from another device, enter the extended credential generated on that device."),
                  _buttonInputTextGroup("Register Credentials", 'Extend credential from other device.', _importTokenController, _registerCredentials, _importText),
                ]),
                _card([
                  _title("Access and ID token"),
                  _subTitle("After successful authentication you will get an Access and ID token, used to get information on the user and authenticate on APIs. The flow of getting the tokens will depend on your OIDC client configuration."),
                  _subTitle("There are 2 types of clients, Confidential (with client secret) and Public (without client secret)."),
                  _title("OIDC Public client"),
                  _subTitle("Public clients are unable to keep the secret secure (e.x. front end app with no backend)"),
                  _description("Use authenticate function when your client is configured as public, it will go through the whole flow and get the Access and ID tokens"),
                  _buttonTextGroup("Authenticate", _authenticate, _authTokenText),
                  _title("\nOIDC Confidential client"),
                  _subTitle("Confidential client are able to keep the secret secure (e.x. your backend)"),
                  _description("(OPTIONAL) Use PKCE for increased security. If the flow is started with PKCE it needs to be completed with PKCE. Read more in the documentation."),
                  _buttonTextGroup("Generate PKCE challenge", _createPkce, _pkce?.toString() ?? ""),
                  _description("\nUse authorize function when your client is configured as confidential. You will get an authorization code that needs to be exchanged for Access and ID token."),
                  _buttonTextGroup("Authorize", _authorize, _authorizationCodeText),
                  _subTitle("\nExchange Authorization Code for Access and ID token."),
                  _description("NOTE: To exchange the authorization code for Access and ID token we need the client secret."),
                  _description("For demo purposes, we're storing the client secret on the device. DO NOT DO THIS IN PROD!"),
                  _buttonTextGroup("Exchange Authz Code for Tokens", _exchangeAuthzCodeForTokens, _authorizationExchangeTokenText),
                ])
              ],
            ),
          ),
        ),
      ),
    );
  }

  // 构建卡片
  Widget _card(List<Widget> widgets) {
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          children: widgets,
        ),
      ),
    );
  }

  // 构建标题
  Widget _title(String titleText) {
    return Row(
      mainAxisSize: MainAxisSize.max,
      children: [
        Text(
          titleText,
          style: const TextStyle(
            fontWeight: FontWeight.w900,
            fontSize: 24,
          ),
        )
      ],
    );
  }

  // 构建子标题
  Widget _subTitle(String subTitleText) {
    return Row(
      mainAxisSize: MainAxisSize.max,
      children: [
        Flexible(
          child: Text(
            subTitleText,
            style: const TextStyle(
              fontWeight: FontWeight.w500,
              fontSize: 16,
            ),
          ),
        ),
      ],
    );
  }

  // 构建描述
  Widget _description(String description) {
    return Row(
      mainAxisSize: MainAxisSize.max,
      children: [
        Flexible(
          child: Text(
            description,
            style: const TextStyle(
              fontSize: 12,
            ),
          ),
        ),
      ],
    );
  }

  // 构建按钮与文本组
  Widget _buttonTextGroup(String buttonLabel, VoidCallback callback, String text) {
    return Column(
      mainAxisSize: MainAxisSize.max,
      children: [
        ElevatedButton(
          child: Text(buttonLabel),
          onPressed: callback,
          style: ElevatedButton.styleFrom(
            fixedSize: const Size.fromWidth(double.maxFinite),
          ),
        ),
        SelectableText(text),
      ],
    );
  }

  // 构建带输入框的按钮
  Widget _buttonInputTextGroup(
    String buttonLabel,
    String inputLabel,
    TextEditingController controller,
    VoidCallback callback,
    String text,
  ) {
    return Column(
      mainAxisSize: MainAxisSize.max,
      children: [
        ElevatedButton(
          child: Text(buttonLabel),
          onPressed: callback,
          style: ElevatedButton.styleFrom(
            fixedSize: const Size.fromWidth(double.maxFinite),
          ),
        ),
        TextFormField(
          decoration: InputDecoration(
            border: const UnderlineInputBorder(),
            labelText: inputLabel,
          ),
          controller: controller,
        ),
        SelectableText("\n$text"),
      ],
    );
  }
}

更多关于Flutter嵌入式SDK插件embeddedsdk的功能使用的实战系列教程也可以访问 https://www.itying.com/category-92-b0.html

1 回复

更多关于Flutter嵌入式SDK插件embeddedsdk的功能使用的实战系列教程也可以访问 https://www.itying.com/category-92-b0.html


Flutter 的嵌入式 SDK 插件(如 embeddedsdk)通常用于将 Flutter 应用嵌入到现有的原生应用中,或者将 Flutter 模块作为原生应用的一部分来使用。这种插件的主要功能是帮助开发者在原生应用(如 Android 或 iOS)中集成 Flutter 模块,并实现 Flutter 与原生代码之间的通信。

以下是 embeddedsdk 插件的主要功能和使用方法:


1. 集成 Flutter 模块到原生应用

  • Android: 使用 FlutterEngineFlutterFragmentFlutterView 将 Flutter 模块嵌入到 Android 应用中。
  • iOS: 使用 FlutterEngineFlutterViewController 将 Flutter 模块嵌入到 iOS 应用中。

示例(Android):

val flutterEngine = FlutterEngine(context)
flutterEngine.dartExecutor.executeDartEntrypoint(DartExecutor.DartEntrypoint.createDefault())
val flutterFragment = FlutterFragment.withCachedEngine("my_engine_id").build()
supportFragmentManager.beginTransaction().replace(R.id.fragment_container, flutterFragment).commit()

示例(iOS):

let flutterEngine = FlutterEngine(name: "my_engine_id")
flutterEngine.run()
let flutterViewController = FlutterViewController(engine: flutterEngine, nibName: nil, bundle: nil)
self.present(flutterViewController, animated: true, completion: nil)

2. Flutter 与原生代码的通信

  • MethodChannel: 用于在 Flutter 和原生代码之间传递方法调用和数据。
  • EventChannel: 用于从原生代码向 Flutter 发送事件流。
  • BasicMessageChannel: 用于简单的消息传递。

示例(MethodChannel):

  • Flutter 端:
    final platform = MethodChannel('com.example.app/channel');
    final String result = await platform.invokeMethod('getDataFromNative');
    print(result);
  • Android 端:
    MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "com.example.app/channel").setMethodCallHandler { call, result ->
        if (call.method == "getDataFromNative") {
            result.success("Data from Android")
        }
    }
  • iOS 端:
    let channel = FlutterMethodChannel(name: "com.example.app/channel", binaryMessenger: flutterEngine.binaryMessenger)
    channel.setMethodCallHandler { call, result in
        if call.method == "getDataFromNative" {
            result("Data from iOS")
        }
    }

3. 共享 FlutterEngine

  • 在多个 Flutter 页面之间共享同一个 FlutterEngine,以减少资源消耗。
  • 示例:
    val flutterEngine = FlutterEngine(context)
    flutterEngine.dartExecutor.executeDartEntrypoint(DartExecutor.DartEntrypoint.createDefault())
    // 在多个 FlutterFragment 中使用同一个引擎

4. 自定义 Flutter 入口

  • 通过 DartEntrypoint 指定自定义的 Dart 入口函数,而不是默认的 main 函数。
  • 示例:
    val entrypoint = DartExecutor.DartEntrypoint("lib/custom_entry.dart", "customMain")
    flutterEngine.dartExecutor.executeDartEntrypoint(entrypoint)

5. 处理 Flutter 生命周期

  • 在原生应用中管理 Flutter 模块的生命周期,确保 Flutter 引擎在适当的时候启动和销毁。
  • 示例(Android):
    override fun onResume() {
        super.onResume()
        flutterEngine.lifecycleChannel.appIsResumed()
    }
    override fun onPause() {
        super.onPause()
        flutterEngine.lifecycleChannel.appIsInactive()
    }

6. 调试和热重载

  • 在嵌入 Flutter 模块时,仍然可以使用 Flutter 的热重载和调试功能。
  • 确保 Flutter 模块以调试模式运行,并连接到 Flutter 开发工具。

7. 性能优化

  • 使用 FlutterEngineGroup 在多个 Flutter 实例之间共享资源,减少内存占用。
  • 示例:
    val engineGroup = FlutterEngineGroup(context)
    val flutterEngine = engineGroup.createAndRunDefaultEngine(context)

8. 处理平台视图

  • 在 Flutter 中嵌入原生视图(如 Android 的 View 或 iOS 的 UIView),使用 PlatformView 实现。

示例(Flutter 端):

Widget build(BuildContext context) {
  return AndroidView(
    viewType: 'com.example.native_view',
    creationParams: {'text': 'Hello from Flutter'},
    creationParamsCodec: StandardMessageCodec(),
  );
}
回到顶部
AI 助手
你好,我是IT营的 AI 助手
您可以尝试点击下方的快捷入口开启体验!