GitCode 口袋工具:Flutter + Dio 打造随身的 GitCode 搜索助手
本文完整讲解项目背景、架构设计、关键代码与实战体验,附带上手步骤与扩展建议,适合想快速构建 GitCode API 工具的 Flutter 开发者。
一、背景与目标
-
GitCode 开放搜索 API 需要 Access Token 才能调用,官方界面在小屏设备上操作不便。
-
我们构建一个轻量「口袋工具」:输入关键字 + Token 即可查询用户或仓库。
-
技术栈:Flutter 3.x、Material 3 设计体系、Dio 网络库;整体结构遵循「配置层 + 网络层 + UI 层」。
项目展示:
可以查询用户:

可以查询仓库:

二、工程结构概览
lib/
├─ core/
│ ├─ app_config.dart // 工程级配置(例如 demo token)
│ └─ gitcode_api.dart // GitCode API 封装(Dio)
└─ main.dart // Flutter UI 与交互逻辑

依赖配置:
pubspec.yaml
Dart
# 提供 iOS 风格(Cupertino 风格)的图标。
cupertino_icons: ^1.0.8
# 一个强大的 HTTP 网络请求库,用于发送网络请求(GET、POST、PUT、DELETE 等)。
dio: ^5.7.0
2.1 配置层:AppConfig
Dart
1:5:lib/core/app_config.dart
/// 应用级别的常量配置统一放在这里,便于集中管理与调优。
class AppConfig {
AppConfig._();
/// 体验用的占位 Token。正式环境请替换为你自己的 GitCode 访问令牌,
/// 并优先考虑使用安全存储或远程配置方案,避免泄露敏感信息。
static const demoToken = '<你的 GitCode 个人访问令牌>';
}
-
实际部署时将
demoToken替换为自己的 Personal Access Token。 -
建议通过
.env或远程配置管理生产环境 Token,这里仅用于演示。
2.2 网络层:GitCodeApiClient
-
基于
Dio封装了用户查询、搜索用户、搜索仓库等接口。 -
统一超时 5 秒,所有请求都带
Authorization: Bearer <token>头部。 -
使用
GitCodeApiException统一承载错误信息,方便 UI 层展示友好提示。
2.3 UI 层:GitCodePocketToolApp
-
Material 3 主题 +
SegmentedButton控制搜索模式。 -
Form+TextFormField做输入校验;FilledButton.icon触发查询,含加载态。 -
结果区复用
_UserTile/_RepoTile两种组件,保证结构清晰。
三、GitCode API Client 总览
-
文件
lib/core/gitcode_api.dart -
功能:统一封装 GitCode REST v5 API 的请求细节(超时、鉴权、错误处理)并提供用户/仓库搜索模型。
-
使用场景:Flutter/OpenHarmony 应用中查询 GitCode 用户资料、搜索用户与仓库。
3.1 架构速览
-
GitCodeApiClient :持有
Dio实例,暴露fetchUser、searchUsers、searchRepositories三个公开方法以及一个内部搜索降级_searchLoginByKeyword、统一请求头_buildHeaders。 -
模型层 :
GitCodeUser、GitCodeSearchUser、GitCodeRepository;以及两个工具函数_safeInt、_safeBool保证解析鲁棒性。 -
错误建模 :
GitCodeApiException对外抛出用户友好文案,避免泄露底层异常。
3.2 请求流程详解
-
构造客户端
-
可注入自定义
Dio(便于测试/Mock)。 -
默认 Base URL
https://api.gitcode.com/api/v5,5 秒连接和接收超时。
-
-
fetchUser(username, personalToken)
-
先 trim、校验非空,再发起
/users/{login}请求。 -
响应码分支:
-
200:直接解析GitCodeUser。 -
401:提示 token 未授权。 -
404:若允许且携带 token,则调用_searchLoginByKeyword,获取真实 login 后二次请求;否则提示未找到。 -
其他:抛出通用失败。
-
-
捕获
DioException,针对connectionTimeout/receiveTimeout、badResponse给出定制信息。
-
-
searchUsers(keyword, personalToken, perPage, page)
-
强制携带 token,支持分页。
-
校验 keyword 非空,perPage [1,50],page [1,100]。
-
401 → token 权限不足;200 且 body 存在 → 映射为
GitCodeSearchUser。
-
-
searchRepositories(keyword, personalToken, {language, sort, order})
-
参数校验逻辑与用户搜索类似,额外对语言、排序字段做 optional 透传。
-
优先读取
stargazers_count,若无则回退stars_count。
-
-
降级搜索
_searchLoginByKeyword-
必须携带 token,命中 401 直接提示。
-
只拉取
per_page=1的最相关结果,提取login字段供fetchUser二次调用。 -
任何
DioException都吞掉并返回null,避免打断主流程。
-
-
通用请求头
_buildHeaders- 仅在 token 非空场景附加
Authorization: Bearer <token>,避免无意义 header。
- 仅在 token 非空场景附加
3.3 数据模型与解析策略
-
GitCodeUser :展示在个人主页的完整信息;
_safeInt确保 JSON int/string 兼容;createdAt保留原始 ISO 字符串,交由 UI 决定格式。 -
GitCodeSearchUser:搜索列表项,字段较少,保留 avatar/name/homepage。
-
GitCodeRepository:
-
owner结构兼容 name/path。 -
isPrivate通过_safeBool兼容 0/1、'true'/'false'。 -
stars兼容历史字段名称。
-
-
异常类 GitCodeApiException:所有对外错误均使用人类可读 message。
3.4 使用建议
-
幂等性 :同一 login 重复调用
fetchUser没有副作用,可配合缓存。 -
Token 策略:公共数据 API 可匿名访问,但 404 时的 search fallback 依赖 token;建议在设置页提示用户填入。
-
错误提示 :直接使用
GitCodeApiException.message显示给用户即可;其它异常使用通用重试提示。 -
测试 :可注入 Mock
Dio,模拟不同 status code 和异常类型。
3.5 代码详解
以下代码保持与
lib/core/gitcode_api.dart一致,并按块拆分,每段代码前都附带用途说明,便于逐段理解。
1. 客户端构造与依赖
导入 dio,初始化 GitCodeApiClient 及默认超时配置。
Dart
import 'package:dio/dio.dart';
/// GitCode API 的轻量封装,负责统一请求参数、超时和错误处理。
class GitCodeApiClient {
GitCodeApiClient({Dio? dio})
: _dio = dio ??
Dio(
BaseOptions(
baseUrl: 'https://api.gitcode.com/api/v5', // GitCode REST v5
connectTimeout: const Duration(seconds: 5), // 快速失败,保证 UI 体验
receiveTimeout: const Duration(seconds: 5),
),
);
final Dio _dio;
2. fetchUser:获取用户详情与搜索降级
校验用户名→请求 /users/{login}→按状态码处理→必要时 fallback 走搜索。
Dart
/// 通过登录名获取用户详情,可选地携带 token 进行授权访问。
Future<GitCodeUser> fetchUser(
String username, {
String? personalToken,
bool allowSearchFallback = true, // 首次失败时可尝试模糊搜索获取真实 login
}) async {
final trimmed = username.trim();
if (trimmed.isEmpty) {
throw const GitCodeApiException('用户名不能为空');
}
try {
final response = await _dio.get<Map<String, dynamic>>(
'/users/${Uri.encodeComponent(trimmed)}',
options: Options(
headers: _buildHeaders(personalToken),
responseType: ResponseType.json,
validateStatus: (status) => status != null && status < 500, // 统一在代码里处理 4xx
),
);
final statusCode = response.statusCode ?? 0;
switch (statusCode) {
case 200:
final data = response.data;
if (data == null) {
throw const GitCodeApiException('接口返回为空,请稍后重试');
}
return GitCodeUser.fromJson(data);
case 401:
throw const GitCodeApiException('未授权,请检查 access token 或权限');
case 404:
if (allowSearchFallback &&
personalToken != null &&
personalToken.isNotEmpty) {
final fallbackLogin = await _searchLoginByKeyword(
trimmed,
personalToken: personalToken,
);
if (fallbackLogin != null && fallbackLogin != trimmed) {
return fetchUser(
fallbackLogin,
personalToken: personalToken,
allowSearchFallback: false, // 防止递归 fallback
);
}
}
throw GitCodeApiException(
'未找到用户 $trimmed,若输入的是展示昵称,请改用登录名或携带 token 搜索。',
);
default:
throw GitCodeApiException(
'查询失败 (HTTP $statusCode),请稍后重试',
);
}
} on DioException catch (error) {
if (error.type == DioExceptionType.connectionTimeout ||
error.type == DioExceptionType.receiveTimeout) {
throw const GitCodeApiException('请求超时,请检查网络后重试');
}
if (error.type == DioExceptionType.badResponse) {
throw GitCodeApiException(
'请求异常:HTTP ${error.response?.statusCode ?? '-'}',
);
}
throw GitCodeApiException(error.message ?? '未知网络错误');
}
}
3. _buildHeaders:按需附加鉴权头
当 token 存在时才拼接 Bearer,避免空 header。
Dart
/// 构造通用请求头,必要时附加 Bearer token。
Map<String, String> _buildHeaders(String? personalToken) {
return {
if (personalToken != null && personalToken.isNotEmpty)
'Authorization': 'Bearer $personalToken',
};
}
4. _searchLoginByKeyword:昵称到 login 的降级映射
命中 404 时调用搜索接口尝试获取真实 login,异常时静默返回 null。
Dart
/// 当用户输入昵称时,通过搜索接口尝试获取真实 login。
Future<String?> _searchLoginByKeyword(
String keyword, {
required String personalToken,
}) async {
try {
final response = await _dio.get<List<dynamic>>(
'/search/users',
queryParameters: {
'q': keyword,
'per_page': 1, // 只取第一个最相关结果即可
'access_token': personalToken,
},
options: Options(
headers: _buildHeaders(personalToken),
responseType: ResponseType.json,
validateStatus: (status) => status != null && status < 500,
),
);
if ((response.statusCode ?? 0) == 200) {
final list = response.data;
if (list == null || list.isEmpty) {
return null;
}
final first = list.first;
if (first is Map<String, dynamic>) {
return first['login'] as String?;
}
} else if ((response.statusCode ?? 0) == 401) {
throw const GitCodeApiException('搜索需要有效的 access token');
}
} on DioException {
return null; // 搜索失败不影响主流程,静默降级
}
return null;
}
5. searchUsers:带分页的用户搜索
强制要求 token,校验分页参数范围并映射结果集合。
Dart
/// 调用 `/search/users`,返回符合关键字的用户简要信息。
Future<List<GitCodeSearchUser>> searchUsers({
required String keyword,
required String personalToken,
int perPage = 10,
int page = 1,
}) async {
final trimmed = keyword.trim();
if (trimmed.isEmpty) {
throw const GitCodeApiException('请输入搜索关键字');
}
final response = await _dio.get<List<dynamic>>(
'/search/users',
queryParameters: <String, dynamic>{
'q': trimmed,
'access_token': personalToken,
'per_page': perPage.clamp(1, 50),
'page': page.clamp(1, 100),
},
options: Options(
headers: _buildHeaders(personalToken),
responseType: ResponseType.json,
validateStatus: (status) => status != null && status < 500,
),
);
final statusCode = response.statusCode ?? 0;
if (statusCode == 401) {
throw const GitCodeApiException('Token 无效或权限不足,无法搜索用户');
}
if (statusCode != 200 || response.data == null) {
throw GitCodeApiException('搜索用户失败 (HTTP $statusCode)');
}
return response.data!
.whereType<Map<String, dynamic>>()
.map(GitCodeSearchUser.fromJson)
.toList();
}
6. searchRepositories:仓库搜索及可选过滤
与用户搜索类似,额外支持语言、排序、顺序等可选参数。
Dart
/// 调用 `/search/repositories`,支持语言、排序等可选参数。
Future<List<GitCodeRepository>> searchRepositories({
required String keyword,
required String personalToken,
String? language,
String? sort,
String? order,
int perPage = 10,
int page = 1,
}) async {
final trimmed = keyword.trim();
if (trimmed.isEmpty) {
throw const GitCodeApiException('请输入搜索关键字');
}
final queryParameters = <String, dynamic>{
'q': trimmed,
'access_token': personalToken,
'per_page': perPage.clamp(1, 50),
'page': page.clamp(1, 100),
if (language != null && language.isNotEmpty) 'language': language,
if (sort != null && sort.isNotEmpty) 'sort': sort,
if (order != null && order.isNotEmpty) 'order': order,
};
final response = await _dio.get<List<dynamic>>(
'/search/repositories',
queryParameters: queryParameters,
options: Options(
headers: _buildHeaders(personalToken),
responseType: ResponseType.json,
validateStatus: (status) => status != null && status < 500,
),
);
final statusCode = response.statusCode ?? 0;
if (statusCode == 401) {
throw const GitCodeApiException('Token 无效或权限不足,无法搜索仓库');
}
if (statusCode != 200 || response.data == null) {
throw GitCodeApiException('搜索仓库失败 (HTTP $statusCode)');
}
return response.data!
.whereType<Map<String, dynamic>>()
.map(GitCodeRepository.fromJson)
.toList();
}
}
7. GitCodeUser 模型
描述完整用户信息,兼容缺失字段。
Dart
class GitCodeUser {
GitCodeUser({
required this.login,
required this.avatarUrl,
this.name,
this.bio,
this.htmlUrl,
this.publicRepos,
this.followers,
this.following,
this.createdAt,
});
factory GitCodeUser.fromJson(Map<String, dynamic> json) {
return GitCodeUser(
login: json['login'] as String? ?? '',
avatarUrl: json['avatar_url'] as String? ?? '',
name: json['name'] as String?,
bio: json['bio'] as String?,
htmlUrl: json['html_url'] as String?,
publicRepos: _safeInt(json['public_repos']),
followers: _safeInt(json['followers']),
following: _safeInt(json['following']),
createdAt: json['created_at'] as String?,
);
}
/// GitCode 唯一登录名,可用于后续接口请求。
final String login;
/// 头像的完整 URL,用于展示用户头像。
final String avatarUrl;
/// 用户在个人资料中填写的展示昵称,可能为空。
final String? name;
/// 个人简介或签名信息。
final String? bio;
/// 用户主页链接,通常指向 GitCode Web。
final String? htmlUrl;
/// 用户公开仓库的数量。
final int? publicRepos;
/// 粉丝(followers)数量。
final int? followers;
/// 关注(following)数量。
final int? following;
/// 账号创建时间,ISO 时间字符串。
final String? createdAt;
}
8. _safeInt:宽松的整型转换
对 int 与 String 类型进行兼容解析。
Dart
/// 将接口返回的动态数值转换为 int,兼容字符串与数字。
int? _safeInt(dynamic value) {
if (value == null) {
return null;
}
if (value is int) {
return value;
}
if (value is String) {
return int.tryParse(value);
}
return null;
}
9. GitCodeApiException:统一异常出口
封装用户可读的错误信息。
class GitCodeApiException implements Exception {
const GitCodeApiException(this.message);
final String message;
@override
String toString() => 'GitCodeApiException: $message';
}
10. GitCodeSearchUser:搜索列表模型
用于展示搜索结果中的用户条目。
Dart
class GitCodeSearchUser {
GitCodeSearchUser({
required this.login,
required this.avatarUrl,
this.name,
this.htmlUrl,
this.createdAt,
});
factory GitCodeSearchUser.fromJson(Map<String, dynamic> json) {
return GitCodeSearchUser(
login: json['login'] as String? ?? '',
avatarUrl: json['avatar_url'] as String? ?? '',
name: json['name'] as String?,
htmlUrl: json['html_url'] as String?,
createdAt: json['created_at'] as String?,
);
}
/// 搜索结果中的登录名。
final String login;
/// 搜索结果中的头像地址。
final String avatarUrl;
/// 搜索结果中的展示名称。
final String? name;
/// 用户主页链接。
final String? htmlUrl;
/// 用户创建时间。
final String? createdAt;
}
11. GitCodeRepository:仓库搜索模型
兼容多种字段命名与 owner 结构。
Dart
class GitCodeRepository {
GitCodeRepository({
required this.fullName,
required this.webUrl,
this.description,
this.language,
this.updatedAt,
this.stars,
this.forks,
this.watchers,
this.ownerLogin,
this.isPrivate,
});
factory GitCodeRepository.fromJson(Map<String, dynamic> json) {
final owner = json['owner'];
return GitCodeRepository(
fullName: json['full_name'] as String? ?? '',
webUrl: json['web_url'] as String? ?? '',
description: json['description'] as String?,
language: json['language'] as String?,
updatedAt: json['updated_at'] as String?,
stars: _safeInt(json['stargazers_count']) ?? _safeInt(json['stars_count']),
forks: _safeInt(json['forks_count']),
watchers: _safeInt(json['watchers_count']),
ownerLogin: owner is Map<String, dynamic>
? owner['name'] as String? ?? owner['path'] as String?
: null,
isPrivate: _safeBool(json['private']),
);
}
/// 仓库的全名,形如 "owner/repo"。
final String fullName;
/// 仓库在 GitCode 上的访问 URL。
final String webUrl;
/// 仓库描述,可能为空。
final String? description;
/// 主要编程语言。
final String? language;
/// 最近一次更新时间。
final String? updatedAt;
/// Stargazers 数量,优先读取 `stargazers_count`。
final int? stars;
/// Fork 数量。
final int? forks;
/// Watchers 数量。
final int? watchers;
/// 仓库所有者的登录名或路径。
final String? ownerLogin;
/// 是否私有仓库。
final bool? isPrivate;
}
12. _safeBool:多格式布尔值解析
兼容 0/1、true/false 以及大小写差异。
Dart
/// GitCode 接口可能返回 0/1、'true'/'false',统一转成 bool。
bool? _safeBool(dynamic value) {
if (value == null) {
return null;
}
if (value is bool) {
return value;
}
if (value is int) {
return value != 0;
}
if (value is String) {
if (value == '1' || value.toLowerCase() == 'true') {
return true;
}
if (value == '0' || value.toLowerCase() == 'false') {
return false;
}
}
return null;
}
3.6 官方借鉴网站
搜索用户文档:
搜索用户 | GitCode 帮助文档
https://docs.gitcode.com/docs/apis/get-api-v-5-search-users搜索仓库文档:
搜索仓库 | GitCode 帮助文档
https://docs.gitcode.com/docs/apis/get-api-v-5-search-repositories
四、关键代码与设计解析
4.1 Material App 与首页结构
Dart
12:135:lib/main.dart
class GitCodePocketToolApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'GitCode 口袋工具',
theme: ThemeData(
colorSchemeSeed: Colors.indigo,
useMaterial3: true,
),
home: const GitCodePocketToolPage(),
);
}
}
-
colorSchemeSeed仅需一个主色即可生成整套 Material 3 调色板。 -
GitCodePocketToolPage是核心状态组件,负责输入、网络请求与结果展示。
4.2 状态与搜索流程
Dart
36:214:lib/main.dart
class _GitCodePocketToolPageState extends State<GitCodePocketToolPage> {
final _keywordController = TextEditingController();
final _tokenController = TextEditingController();
final _formKey = GlobalKey<FormState>();
final _client = GitCodeApiClient();
QueryMode _mode = QueryMode.user;
List<GitCodeSearchUser> _userResults = const [];
List<GitCodeRepository> _repoResults = const [];
String? _errorMessage;
bool _isLoading = false;
bool _obscureToken = true;
Future<void> _performSearch() async {
if (_isLoading) return;
if (!_formKey.currentState!.validate()) return;
FocusScope.of(context).unfocus();
setState(() { _isLoading = true; _errorMessage = null; ... });
final token = _tokenController.text.trim();
try {
if (_mode == QueryMode.user) {
final users = await _client.searchUsers(...);
setState(() => _userResults = users);
} else {
final repos = await _client.searchRepositories(...);
setState(() => _repoResults = repos);
}
} on GitCodeApiException catch (error) {
setState(() => _errorMessage = error.message);
} catch (error) {
setState(() => _errorMessage = '未知错误:$error');
} finally {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
}
-
通过
_mode控制两种搜索逻辑;结果列表在 setState 中更新。 -
所有异常被转换成
_errorMessage再交给_InfoBanner展示。
4.3 搜索模式切换
Dart
239:266:lib/main.dart
Widget _buildModeSwitcher() {
return SegmentedButton<QueryMode>(
segments: const [
ButtonSegment(value: QueryMode.user, icon: Icon(Icons.person_outline), label: Text('用户')),
ButtonSegment(value: QueryMode.repository, icon: Icon(Icons.bookmark_outline), label: Text('仓库')),
],
selected: <QueryMode>{_mode},
onSelectionChanged: (selection) {
final next = selection.first;
if (next != _mode) {
setState(() {
_mode = next;
_errorMessage = null;
_userResults = const [];
_repoResults = const [];
});
}
},
);
}
- 切换模式时顺便清空历史结果和错误,避免状态污染。
4.4 结果卡片组件
Dart
382:430:lib/main.dart
class _RepoTile extends StatelessWidget {
const _RepoTile(this.repo);
@override
Widget build(BuildContext context) {
return Card(
elevation: 0,
margin: const EdgeInsets.only(bottom: 12),
child: ListTile(
leading: const Icon(Icons.folder_outlined),
title: Text(repo.fullName, style: const TextStyle(fontWeight: FontWeight.w600)),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (repo.description?.isNotEmpty == true)
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(repo.description!, maxLines: 2, overflow: TextOverflow.ellipsis),
),
Wrap(
spacing: 8,
runSpacing: 4,
children: [
if (repo.language != null) _StatChip(label: '语言', valueLabel: repo.language),
_StatChip(label: 'Star', value: repo.stars),
_StatChip(label: 'Fork', value: repo.forks),
],
),
const SizedBox(height: 4),
Text('更新 ${repo.updatedAt ?? '-'}', style: const TextStyle(fontSize: 12)),
Text(repo.webUrl, style: const TextStyle(fontSize: 12, color: Colors.blue),
],
),
),
);
}
}
-
Wrap让统计标签在小屏仍能自动换行。 -
_StatChip复用 Chip 样式,显示数值 + 标签,保持一致视觉。
4.5 API 客户端亮点
Dart
3:214:lib/core/gitcode_api.dart
class GitCodeApiClient {
GitCodeApiClient({Dio? dio})
: _dio = dio ?? Dio(BaseOptions(
baseUrl: 'https://api.gitcode.com/api/v5',
connectTimeout: const Duration(seconds: 5),
receiveTimeout: const Duration(seconds: 5),
));
Future<List<GitCodeSearchUser>> searchUsers({ ... }) async {
final response = await _dio.get<List<dynamic>>(
'/search/users',
queryParameters: {
'q': trimmed,
'access_token': personalToken,
'per_page': perPage.clamp(1, 50),
'page': page.clamp(1, 100),
},
options: Options(
headers: _buildHeaders(personalToken),
validateStatus: (status) => status != null && status < 500,
),
);
if ((response.statusCode ?? 0) == 401) {
throw const GitCodeApiException('Token 无效或权限不足,无法搜索用户');
}
if (response.statusCode != 200 || response.data == null) {
throw GitCodeApiException('搜索用户失败 (HTTP $statusCode)');
}
return response.data!
.whereType<Map<String, dynamic>>()
.map(GitCodeSearchUser.fromJson)
.toList();
}
}
-
per_page和page做范围限制,避免过大分页影响体验。 -
validateStatus允许 4xx 在response中返回,自行处理错误文案。 -
_searchLoginByKeyword作为兜底逻辑,在用户输入昵称时仍尽量返回正确数据。
五、环境准备与运行步骤
5.1 环境准备
5.2 下载仓库压缩包
仓库地址:
gitcode_pocket_tool - AtomGit | GitCode
https://gitcode.com/byyixuan/gitcode_pocket_tool
5.3 配置访问令牌
GitCode - 全球开发者的开源社区,开源代码托管平台
https://gitcode.com/setting/token-classic
配置令牌名称和到期时间,之后向下滑找到新建访问令牌按钮,点击此按钮新建:


注意重要:新建成功后,一定要复制好这个访问令牌,并且保存好,不要让别人知道!

5.4 解压项目并打开
打开之后目录如图所示:

点击app_config.dart文件,并将你的访问令牌复制到此位置:
注意:这只是调式项目仅供学习参考,正式环境下,需要将访问令牌隐式配置到本地,不在其中显示。

5.5 开始启动项目
替换成功后,在DevEco Studio中编译运行此项目,点击右上角绿色运行按钮,启动过程中耐心等待。注意,虚拟机启动需要保证虚拟机连接上网络:

如图所示启动成功:

5.6 项目展示
可以查询用户:

可以查询仓库:
