本项目使用的开放源代码许可证为: GPL-3.0-OR-LATER
本项目是一款基于12306 Web API 的创新行程助手,旨在为用户提供便捷的火车出行信息查询功能,帮助用户轻松获取相关的列车信息并优化出行体验。 我们基于12306 Web API 提供了丰富的功能,通过直观友好的用户界面和强大的功能组合,为用户提供全面的火车出行解决方案。
本应用通过合理设计卡片服务,极大地方便了需要出差的商务旅行人士或残障人士乘坐火车/高铁。 便于商务旅行人士规划自身行程,便于聋哑人或视障人士向他人展示信息寻求帮助。 致力于不断改进和优化行程助手,以满足用户的需求和期望。 通过本项目,我们希望为用户提供一个简单易用、功能明确的火车出行助手,帮助用户更好地规划和享受火车出行的便利。
├── README.md
├── AppScope
├── entry
│ ├── ets
│ │ ├── cardability2x2 卡片能力
│ │ ├── components UI组件
│ │ ├── pages UI界面
│ │ ├── railegoreminder 卡片界面
│ │ └── utils 工具集
│ └── resources
└── lib
└── lib12306.har WebApi封装库
铁易行利用OpenHarmony卡片服务能力,设计了一套完整的行程提醒服务系统。
铁易行的UI设计,参考了许多国内外优秀APP。目前ARKUI(API9)中Tab、TabContent在界面设计中限制过多, 因此铁易行单独实现了一套Appbar/TabBar框架,使界面能够更高度自定义、更美观、更令人眼舒适。 铁易行在UI设计上也遵循极简的设计美学,摒弃了不需要的按钮,统一了样式和颜色。
本项目代码可迁移到更多服务场景,如航班提醒、出行提醒、旅游建议等,业务广泛,对旅游服务公司的商用价值高, 同时对个人也是很好的出行助手。
铁易行通用AppBar有两个必填参数和一个可选参数,参数列表如下:
参数名 | 类型 | 必须 | 参数释义 |
---|---|---|---|
pageTitle | string | 是 | 页面标题 |
tools | AppBarTool[] | 是 | AppBar右侧工具 |
isMainPage | boolean | 否 | 页面是否为主页面 |
参数需求如下:
@Link pageTitle: string;
private isMainPage: boolean = false;
@Link tools: AppBarTool[];
AppBarTool类型定义如下:
export interface AppBarTool {
// 工具按钮类型,文本或图片
type: 'string' | 'icon',
// 工具按钮资源,若为文本,则为文本资源,否则为图片资源
elementResource: Resource,
// 按钮触摸回调
callback: () => void
}
该组件实现代码位于common/src/main/ets/components/AppBar.ets
中
铁易行通用TabBar有三个必填参数和两个可选参数,参数列表如下:
参数名 | 类型 | 必须 | 参数释义 |
---|---|---|---|
tabSelectIndex | number | 是 | 指示当前选中的tab |
pageTitle | string | 是 | 当前tab页面标题 |
tools | AppBarTool[] | 是 | 当前tab的AppBar工具 |
entries | TabBarEntry[] | 否 | TabBar页面切换按钮 |
bgColor | ResourceColor | 否 | TabBar阴影颜色 |
参数需求如下:
@Link tabSelectIndex: number;
@Link pageTitle: string;
@Link tools: AppBarTool[];
private entries: TabBarEntry[];
private bgColor: ResourceColor = Color.White;
TabBarEntry类型定义如下:
export interface TabBarEntry {
title: string,
icon: {
normal: Resource,
active: Resource,
size?: number
},
tools?: AppBarTool[]
}
该组件实现代码位于common/src/main/ets/components/TabBar.ets
中
SettingItemGroup
设计SettingItemGroup
是为了更方便地搭建铁易行SettingPage,而实现的一个UI Component。
它包含一个可选参数和一个必填参数,参数列表如下:
参数名 | 类型 | 必须 | 参数释义 |
---|---|---|---|
title | ResourceStr | 否 | 该Group的标题,可缺省 |
settingItems | string | 是 | 该Group中的子设置项 |
settingItems
的结构如下
export interface SettingItemInterface {
icon?: Resource,
title: ResourceStr,
type?: 'normal' | 'switch',
content?: ResourceStr,
callback?: () => void,
switchCallback?: (isOn: boolean) => void,
switchDefaultValue?: boolean,
foregroundColor?: ResourceColor
}
该组件实现代码位于common/src/main/ets/components/SettingItemGroup.ets
中
以下图片均为暗黑模式下的情况
在首屏展示前,应用将进行以下动作:
Promise.all([SettingUtils.getDarkMode(), SettingUtils.getActiveFetchData(), SettingUtils.getTabBarTextDisplay(), SettingUtils.getSortType()])
在首屏展示过程中,应用将进行以下动作:
此时,若一切准备就绪,将根据用户登录状态来决定进入哪一个页面
界面说明
该按钮用于加载进入铁易行的功能测试模式(预览模式), 单击此按钮后无需登录,即可使用铁易行的全部功能。
未出行订单和已出行订单数据均源于本地Mock数据。
进入该模式后,在设置页面的版本号后会出现"预览模式"标识,以提醒用户当前数据仅供测试使用。
在设置界面中单击注销
即可退出该模式,继续登录自己的账号。
铁易行的行程管理功能依赖于您的12306账号,登录12306过程中将会需要以下信息
为确保数据安全,铁易行会按照12306API要求进行加密处理,所有数据仅存储在您机器本地,铁易行没有任何云端存储服务。
手机号码输入框的设计需要符合现代设计要求,使用户尽可能多地获取输入信息。
输入框左侧标注+86
,告知用户必须使用中国大陆手机号进行登录。
当用户输入手机号时,铁易行会根据输入位数,对手机号进行分割显示。
当输入框获取焦点时,由于输入法会将界面顶起,铁易行图标会从顶部向下运动,继续出现在用户视图中。
当输入法失去焦点时,铁易行图标会恢复到原本的位置。
当输入法内存在文本时,单击右侧的清空按钮即可删除所有内容。
输入框主体由Stack构成
当用户单击下一步,但没有勾选已知晓时,将会弹出一个弹窗,通知用户阅读隐私政策,并提供进入下一步的按钮。
待出行订单列表AppBar标题为待出行
,右侧有两个工具,分别提供检索和刷新功能
Content由List组成,为解耦代码,订单信息由子组件生成,其代码如下
@Extend(Text)
function TextDarkMode(mode: boolean, active?: boolean) {
.fontColor(active ? $r('app.color.primary_blue') : mode ? $r('app.color.dark_text_color') : $r('app.color.text_color'))
}
@Component
export struct OrderItem {
@Link detailPageData: any;
@Link showDetailPage: boolean;
@StorageProp('isDarkMode') isDarkMode: boolean = false;
private order: any;
build() {
Row() {
Column() {
Row() {
Image($r('app.media.ic_train_crh'))
.margin({ right: 12 })
.size({ width: 48, height: 48 })
Text() {
Span(this.order.tickets[0].stationTrainDTO.station_train_code)
Span('/')
Span(this.order.tickets[0].stationTrainDTO.to_station_name)
Span('站/')
Span(this.order.start_train_date_page.substring(5))
if (this.order.tickets[0].ticket_status_name.lastIndexOf('退票') != -1) {
Span('/已退票')
}
}
.TextDarkMode(this.isDarkMode)
}
}
Image($r('app.media.ic_goto_arrow'))
.size({ width: 32, height: 32 })
}
.justifyContent(FlexAlign.SpaceBetween)
.backgroundColor(this.isDarkMode ? $r('app.color.dark_foreground') : $r('app.color.start_window_background'))
.padding(12)
.height(64)
.width('100%')
.onClick(() => {
this.detailPageData = this.order;
this.showDetailPage = !this.showDetailPage;
})
}
}
对ListItem进行左滑操作,将可以看到一个响铃图标,单击该图标,可以手动将对应订单设置为桌面提醒卡片的数据源。 单击ListItem,将会出现Panel组件,其中显示了该订单的详细信息,其中包括:
单击Panel右上方的X号即可关闭该界面
与待出行订单列表(Tab)的区别:
订单搜索页提供模块化订单搜索功能:
其中ListItem与已完成订单列表(Tab)中行为一致。
铁路搜索页提供综合搜索功能,搜索目标包含以下内容:
根据不同的查询结果类型,单击即可跳转到对应页面查看详细内容。
单击右上角的车票
即可进行车票查询
车票查询页提供查询对应出发-到达站之间给定日期的车票。 查询结果包含:
单击城市即可选择,左侧为出发站点,右侧为到达站点。 城市列表显示在Panel中,提供一个搜索框进行查询。 遵循最佳实践 由于用户输入是高频事件,为降低性能影响,对搜索进行了防抖处理, 防抖时间为0.5s。 搜索后单击城市即可选择。
在微信、支付宝等支付方式接入OpenHarmony并稳定后,铁易行将基于车票查询页尝试提供车票购买功能。
铁路公告页提供最新的铁路客运服务公告及历史信息。
在该页单击,即可跳转到pages/BrowserPage
页面,使用Web视图显示公告内容,加载速度取决于网络环境及设备。
设置页提供软件设置项配置能力。
提供软件版本信息及软件运行模式信息
跳转到关于
页面
切换暗黑/日间模式
当开启时,铁易行在每次回到主页或启动软件时,都会主动从服务器拉取最新的用户订单信息。 如果您希望下载权力掌握在自己手里,请将其关闭。
在确认后,退出当前账号
选择在订单视图中,行程如何根据日期进行排序。
当开启时,导航栏将显示详细说明
在确认后,删除所有本地缓存,卡片服务数据也将被清空
PreferenceManager
设计铁易行实现了单实例的PreferenceManager以管理用户首选项数据库,代码如下
// 铁易行 | 开源鸿蒙铁路出行助理
// This file is part of RailEGo
// Copyright (C) 2023 Guo Tingjin
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import preferences from '@ohos.data.preferences';
import { RailEGoConstants } from '../Constants';
import { Logger } from '../utils/Logger';
const TAG = "PreferenceManager";
export class PreferenceManager {
private settingStore: preferences.Preferences;
public constructor(context: Context) {
preferences.getPreferences(context, RailEGoConstants.PREFERENCE_MANAGER.PREFER_STORE_KEY)
.then(preference => {
this.settingStore = preference;
})
}
public static getInstance(): PreferenceManager {
if (globalThis.__preferenceManager__ == undefined) {
globalThis.__preferenceManager__ = new PreferenceManager(globalThis.appContext);
}
return globalThis.__preferenceManager__;
}
public set(k: string, v: preferences.ValueType): void {
this.settingStore.put(k, v, () => {
this.settingStore.flush()
})
}
public async get<T extends preferences.ValueType>(k: string, def: T): Promise<T> {
return await new Promise((ret) => {
this.settingStore.has(k, (e, r) => {
!e && r ?
this.settingStore.get(k, def, (e, v: T) => {
!e ? ret(v) : Logger.err(TAG, "error while getting value of", k, e)
}) : ret(def);
})
})
}
}
该管理器充分利用了TypeScript泛型语法,使不同类型的首选项可以使用同一函数获取,提高了代码复用率。
ApiManager
设计ApiManager
是单实例的,高度封装的API管理层,它与TaskManager一起提供了所有API访问途径,调用API可通过以下示例代码:
ApiMan.getInstance()
.someApiName()
.then(someReturnValueFromApiCall => {
// do something with cookie
})
.catch(someRejectReasonFromApiCall => {
// handle this exception
})
ApiManager
实现代码如下
/**
* This file is part of RailEGo
* Copyright (c) 2023 Guo Tingjin <dev@peercat.cn>
* RailEGo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* RailEGo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {
BypassStationRequestParams,
LoginGetVerifyCheckParams,
LoginNewAppTokenRequestParams,
LoginTokenRequestParams,
LoginUamTkRequestParams,
QueryOrderRequestParams,
QueryTicketCheckParams,
ScheduleRequestParams,
SmsVerifyCodeRequestParams,
TicketPriceRequestParams
} from './QueryParams';
import {
BypassStationResponse,
CheckLoginVerifyResponse,
GetMessageCodeResponse,
NewAppTokenResponse,
ScheduleResponse,
TicketCheckResponse,
TicketPriceResponse,
UserConfResponse
} from './Responses';
import { TaskMan } from './TaskMan';
export class ApiMan {
public static getInstance(): ApiMan {
if (globalThis.apiMan === undefined)
globalThis.apiMan = new ApiMan();
return globalThis.apiMan;
}
/**
* 请求列车计划
* @param options
* @param callback
*/
public async requestSchedules(options: ScheduleRequestParams): Promise<ScheduleResponse> {
const response = await TaskMan.getInstance().requestSchedule(options);
return response;
}
/**
* 请求沿途经过站点
* @param options
* @returns
*/
public async requestBypass(options: BypassStationRequestParams): Promise<BypassStationResponse> {
const response = await TaskMan.getInstance().requestBypass(options);
return response;
}
public async requestCookie(): Promise<string> {
const response = await TaskMan.getInstance().requestCookie();
return response;
}
public async requestUserConf(cookies: string): Promise<UserConfResponse> {
const response = await TaskMan.getInstance().requestUserConf(cookies);
return response;
}
public async requestTicketCheck(options: QueryTicketCheckParams): Promise<TicketCheckResponse> {
const response = await TaskMan.getInstance().requestTicketCheck(options);
return response;
}
/**
* 请求票价
* @param options
* @param callback
*/
public requestTicketPrice(options: TicketPriceRequestParams): Promise<TicketPriceResponse> {
return;
}
public async requestCheckLoginVerify(options: LoginGetVerifyCheckParams): Promise<CheckLoginVerifyResponse> {
const response = await TaskMan.getInstance().requestCheckLoginVerify(options);
return response;
}
public async requestGetMessageCode(options: SmsVerifyCodeRequestParams): Promise<GetMessageCodeResponse> {
const response = await TaskMan.getInstance().requestGetMessageCode(options);
return response;
}
public async requestUamTk(options: LoginUamTkRequestParams): Promise<string> {
const response = await TaskMan.getInstance().requestLoginUamTk(options);
return response;
}
public async requestNewAppToken(options: LoginNewAppTokenRequestParams): Promise<NewAppTokenResponse> {
const response = await TaskMan.getInstance().requestLoginNewAppToken(options);
return response;
}
/**
* 校验从requestUamTk中获得的uamTk cookie,获得最终的登录cookie
*/
public async requestLoginToken(options: LoginTokenRequestParams) {
const response = await TaskMan.getInstance().requestLoginToken(options);
return response;
}
public async requestStations() {
const response = await TaskMan.getInstance().requestStations();
return response;
}
public async requestUserOrder(options: QueryOrderRequestParams) {
const response = await TaskMan.getInstance().requestUserOrder(options);
return response;
}
public async requestLoginUKey(cookie: string) {
const response = await TaskMan.getInstance().requestLoginUKey(cookie);
return response;
}
public async requestNews() {
const response = await TaskMan.getInstance().requestNews();
return response;
}
public async search(kw: string) {
const response = await TaskMan.getInstance().search(kw);
return response;
}
}
LazyStation
设计LazyStation
是为了流畅展示车站列表而重载的IDataSourceImpl,实际上,该类可以利用泛型封装成可以直接使用的LazyList,
而不是交给开发者自行封装,例如封装如下:
// This file is part of libNMC, which is the foundation of ohos-weather.
// Copyright (C) 2023 Tingjin<dev@peercat.cn>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
export class LazyList<T> implements IDataSource {
private list: T[] = []
private listener: DataChangeListener
constructor(list: T[]) {
this.list = list
}
totalCount(): number {
return this.list.length
}
getData(index: number): T {
return this.list[index]
}
registerDataChangeListener(listener: DataChangeListener): void {
this.listener = listener
}
unregisterDataChangeListener() {
}
}
利用如此封装的类,即可直接使用LazyList<Station>
定义可以用于LazyForEach
的懒数据源。
UrlBuilder
设计为便于代码维护,抽象一个UrlBuilder类用于构造请求地址(EndPoints),实现如下:
// 铁易行 | 开源鸿蒙铁路出行助理
// This file is part of RailEGo
// Copyright (C) 2023 Guo Tingjin
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import { Logger } from './Logger';
export interface UrlParam {
key: string;
value?: string;
};
const TAG = "UrlBuilder-->"
export class UrlBuilder {
private _params: UrlParam[] = [];
private _path: string[] = [];
private _root: string = "";
public constructor(root: string) {
this._root = root;
}
public path(p: string): UrlBuilder {
this._path.push(p);
return this;
}
public param(k: string, v?: string): UrlBuilder {
this._params.push({ key: k, value: v });
return this;
}
public build(...args: string[]): string {
if (this._root.endsWith('/')) {
this._root = this._root.substring(0, this._root.length - 1);
}
let path = "";
for (let p of this._path) {
if (p.endsWith('/')) {
p = p.substring(0, p.length - 1)
}
if (!p.startsWith('/')) {
p = '/' + p;
}
path += p;
}
let params = "";
for (let i = 0; i < this._params.length; i++) {
let p = this._params[i];
let v = p.value ?? args[i] ?? ''
params += `${i == 0 ? '?' : '&'}${p.key}=${encodeURI(v)}`
}
let url = `${this._root}${path}${params}`;
Logger.info(TAG, "Url built", url);
return url;
}
}
使用示例:
构建目标为https://www.shanghaiairport.com/cn/flight.aspx?flightno=MU6445&time=06:30&date=2023-11-10
代码:
const root: string = 'https://www.shanghaiairport.com/cn';
function requestFlightInfo(flightNo: string, time: string, date: string) {
return new UrlBuilder(root)
.path('flight.aspx')
.param('flightno', flightNo)
.param('time', time)
.param('date', date)
.build();
}
也可以在设置param
时不立刻指定其值,而是在build()
中指定:
new UrlBuilder(root)
.path('flight.aspx')
.param('flightno')
.param('time')
.param('date')
.build(flightNo, time, date);
铁易行使用的部分图标来自于iconfont,资源版权归属于原作者。 铁易行的其它代码版权,已按照文件头部版权标注声明。
要使用铁易行,您需要在铁易行内登录12306账号。铁易行不会与除了12306的其它服务器进行通信。 您输入的所有凭据以及铁易行从12306获取的您的行程信息,均只会存储在您的机器中, 不与任何一方共享。
铁易行使用了lib12306.har包,其中包含分布式键值对数据库实现的Cookie存储管理(CookieMan)以及 利用分布式数据调度实现的高性能Api池(TaskMan/ApiMan)
行程排序功能
在CredentialVerify页面单击“功能测试”,下拉加载mock数据,检查行程是否按照时间排序。未出行订单中,列车发车时间离当前越近,排序越前。 历史订单中,列车发车时间离当前越近,排序越前。
待使用/历史行程搜索功能
在CredentialVerify页面单击“功能测试”,下拉加载mock数据,单击右上角搜索按钮进入搜索页面测试
行程信息提醒功能(卡片)
新建卡片后,在LoginCredentialVerify页面单击“功能测试”,下拉加载mock数据,退回桌面,双击卡片, 观察是否显示行程信息。若未显示,检查mock数据最新待使用订单的发车时间是否晚于当前时间(系统)。
检票口查询功能
新建卡片后,在LoginCredentialVerify页面单击“功能测试”,下拉加载mock数据,退回桌面,双击卡片, 观察卡片中是否请求到检票口。并非所有车辆车站均能够获取到检票口,这是由于部分车站过小,导致仅有一个检票口。
列车状态提醒功能(卡片)
新建卡片后,在LoginCredentialVerify页面单击“功能测试”,下拉加载mock数据,退回桌面,双击卡片, 观察右上角倒计时是否出现。
12306账号登录功能
打开APP后,跟随提示进行登录操作。
用户行程获取功能
登录后,在主界面下拉刷新获取数据。
SettingItemGroup
和SettingItemInterface
,提供更丰富的设置项自定义能力;NetworkDataUtils.ets
、NetworkMockData.ets
、ObjectUtils.ets
,解耦代码,优化页面逻辑,实现业务分离;清除缓存
以及设置项注销
的确认窗口中,确认
与取消
按钮的位置;OrderItem
,取消了年份显示,取消了车站代码显示,增加了订单状态提示;requestCheckLoginVerify
参数异常的问题;注销
失败,页面不跳转,缓存不清空的问题;OrderSearch
页面的布局;DetailItem
组件;此处可能存在不合适展示的内容,页面不予展示。您可通过相关编辑功能自查并修改。
如您确认内容无涉及 不当用语 / 纯广告导流 / 暴力 / 低俗色情 / 侵权 / 盗版 / 虚假 / 无价值内容或违法国家有关法律法规的内容,可点击提交进行申诉,我们将尽快为您处理。