这篇文章将记录我如何用 PHP,从零搭建一个全自动、可部署在自己网站上的 Switch 游戏数据中心api。用来展示自己的游戏时长。
效果展示
或者这种
一、 接口获取与逆向分析
任天堂官方并没有开放公开的 API。我们利用了社区逆向工程解析出的 Web API。
整个调用链路非常复杂,分为三步:
Python点击显示
import requests, json, base64, hashlib, re, sys, os
def NS_GetSessionToken(client_id, ua):
session = requests.Session()
auth_code_verifier = base64.urlsafe_b64encode(os.urandom(32))
auth_cv_hash = hashlib.sha256()
auth_cv_hash.update(auth_code_verifier.replace(b"=", b""))
auth_code_challenge = base64.urlsafe_b64encode(auth_cv_hash.digest())
app_head = {
'Host': 'accounts.nintendo.com',
'Connection': 'keep-alive',
'Cache-Control': 'max-age=0',
'Upgrade-Insecure-Requests': '1',
'User-Agent': 'Mozilla/5.0 (Nintendo Switch; WebApplet) AppleWebKit/609.4 (KHTML, like Gecko) NF/6.0.2.15.4 NintendoBrowser/5.1.0.22433',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
'DNT': '1',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
'Accept-Encoding': 'gzip, deflate, br',
}
body = {
'state': '',
'redirect_uri': 'npf{}://auth'.format(client_id),
'client_id': client_id,
'scope': 'openid user user.mii user.email user.links[].id',
'response_type': 'session_token_code',
'session_token_code_challenge': auth_code_challenge.replace(b"=", b"").decode('utf-8'),
'session_token_code_challenge_method': 'S256',
'theme': 'login_form'
}
url = 'https://accounts.nintendo.com/connect/1.0.0/authorize'
r = session.get(url, headers=app_head, params=body)
post_login = r.history[0].url
print("\n在浏览器打开这个链接:")
print(post_login)
print("\n登录你的账号,右键点击“选择此人”按钮,复制链接地址,粘贴到下面:")
while True:
try:
use_account_url = input("")
session_token_code = re.search('session_token_code=([^&]+)', use_account_url)
if not session_token_code:
print("链接格式不对,请重试:", end=' ')
continue
app_head = {
'User-Agent': ua,
'Accept-Language': 'en-US',
'Accept': 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
'Host': 'accounts.nintendo.com',
'Connection': 'Keep-Alive',
'Accept-Encoding': 'gzip'
}
body = {
'client_id': client_id,
'session_token_code': session_token_code.group(1),
'session_token_code_verifier': auth_code_verifier.replace(b"=", b"").decode('utf-8')
}
url = 'https://accounts.nintendo.com/connect/1.0.0/api/session_token'
r = session.post(url, headers=app_head, data=body)
print("HTTP状态:", r.status_code)
if r.status_code == 200:
session_token = json.loads(r.text).get("session_token")
return session_token
else:
print("获取失败,响应:", r.text[:500])
sys.exit(1)
except KeyboardInterrupt:
sys.exit(1)
except Exception as e:
print("发生错误:", str(e))
def NS_GetAccessToken(client_id, session_token):
session = requests.Session()
body = '{"client_id":"' + client_id + '","session_token":"' + session_token + \
'","grant_type":"urn:ietf:params:oauth:grant-type:jwt-bearer-session-token"}'
url = 'https://accounts.nintendo.com/connect/1.0.0/api/token'
r = session.post(url, headers={'Content-Type': 'application/json'}, data=body)
print("AccessToken HTTP状态:", r.status_code)
if r.status_code != 200:
print("AccessToken 响应:", r.text)
sys.exit(1)
return json.loads(r.text)
def NS_GetPlayHistory(access_token, ua):
session = requests.Session()
url = 'https://app-api.znej.nintendo.com/api/v2.0/users/me/play_histories'
# 注意:这里更新了较新的 User-Agent
header = {
'Authorization': access_token['token_type'] + ' ' + access_token['access_token'],
'User-Agent': ua,
'gentry-locale': 'jp-JP'
}
r = session.get(url, headers=header)
print("PlayHistory HTTP状态:", r.status_code)
print("PlayHistory 响应:")
print(r.text)
return r.text
if __name__ == "__main__":
client_id = "5c38e31cd085304b"
# 【重要修改】将 1.13.0 更新为较新的 3.3.1 版本
ua = 'com.nintendo.znej/3.3.1 (Linux; Android 13)'
print("--- 开始获取 Session Token ---")
st = NS_GetSessionToken(client_id, ua)
print("\n成功获取 session_token:", st)
print("\n--- 开始获取 Access Token ---")
at = NS_GetAccessToken(client_id, st)
print("\n--- 开始获取游玩记录 ---")
NS_GetPlayHistory(at, ua)
- 获取
session_token(有效期长达两年)。 - 用
session_token换取有效期为 15 分钟的access_token。 - 携带
access_token请求play_histories获取游玩记录。
系统架构概览
1. 认证与基础配置
config.php:全局配置文件(数据库信息、客户端 ID、User-Agent)。crypto.php:负责对核心凭证(Session Token)进行 AES-256-GCM 加密与解密。bootstrap_token.php:用于首次手动注入 Session Token 的脚本。
2. 核心采集引擎
collect.php:拉取数据的核心。通过双刷(先用繁中触发更新,再拉简中)获取最新游玩时长与中文名字。
3. 数据接口层
API.php:对外提供 JSON 数据的接口。它具有“冷却机制”和“后台自动采集触发”能力,用于给前端网页提供数据。
4. 详情补充层
detail.php:接收titleId参数,去第三方开源接口拉取中文简介和价格信息,填补官方 API 不提供简介的空白。
config.php(基础环境)
作用:定义常量。
为什么这么做:将数据库密码、任天堂的 Client_ID 和 User_Agent 统一管理。如果你的服务器到期换新,只需修改这一个文件即可。
安全提醒:主密钥(Master Key)不要放在代码库中,而是放在 ../secret/master.key 里,防止被公开在 Web 目录下。
<?php
// ==========================================
// 1. 主密钥配置
// ==========================================
// 优先从环境变量读取
$masterKeyB64 = getenv('SWITCH_MASTER_KEY');
if (!$masterKeyB64) {
// ⚠️ 请将这里的路径替换为你服务器上实际的绝对路径
$keyFile = __DIR__ . '/../secret/master.key';
if (!file_exists($keyFile)) {
die("缺少主密钥文件\n");
}
$masterKeyB64 = trim(file_get_contents($keyFile));
}
define('MASTER_KEY', base64_decode($masterKeyB64)); // 32 字节
// ==========================================
// 2. 数据库配置(请替换为你宝塔里的真实信息)
// ==========================================
// ⚠️ 请将 dbname= 后面的内容替换为你的数据库名
define('DB_DSN', 'mysql:host=127.0.0.1;dbname=你的数据库名;charset=utf8mb4');
// ⚠️ 请替换为你的数据库用户名
define('DB_USER', '你的数据库用户名');
// ⚠️ 请替换为你的数据库密码
define('DB_PASS', '你的数据库密码');
// ==========================================
// 3. 任天堂客户端参数(固定值,请勿修改)
// ==========================================
define('NS_CLIENT_ID', '5c38e31cd085304b');
define('NS_UA', 'com.nintendo.znej/1.13.0 (Android/7.1.2)');
crypto.php(加密保护)
作用:加密和解密。
为什么这么做:任天堂的 session_token 有效期长达两年。如果你把它明文存在数据库里,一旦数据库被拖库,黑客就能直接登录你的任天堂账号。这里使用 AES-256-GCM 加密,即使数据库泄露,没有主密钥,他们也拿不到明文 Token。
<?php
require_once __DIR__ . '/config.php';
function encryptToken(string $plain): string {
$iv = random_bytes(12);
$tag = '';
$cipher = openssl_encrypt(
$plain, 'aes-256-gcm', MASTER_KEY, OPENSSL_RAW_DATA, $iv, $tag
);
if ($cipher === false) throw new RuntimeException('加密失败');
// 存成 base64(iv + tag + cipher)
return base64_encode($iv . $tag . $cipher);
}
function decryptToken(string $stored): string {
$raw = base64_decode($stored, true);
if ($raw === false || strlen($raw) < 28) throw new RuntimeException('密文格式错误');
$iv = substr($raw, 0, 12);
$tag = substr($raw, 12, 16);
$cipher = substr($raw, 28);
$plain = openssl_decrypt(
$cipher, 'aes-256-gcm', MASTER_KEY, OPENSSL_RAW_DATA, $iv, $tag
);
if ($plain === false) throw new RuntimeException('解密失败');
return $plain;
}
bootstrap_token.php(首次授权)
作用:将你通过 Python 获取的长期 session_token 加密存入数据库。
为什么这么做:这是整个系统的起点。你只需要在服务器终端运行一次 php bootstrap_token.php,粘贴那串 eyJhbGci...,它就会调用 crypto.php 加密并存入 switch_token 表。
<?php
require_once __DIR__ . '/crypto.php';
$pdo = new PDO(DB_DSN, DB_USER, DB_PASS, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
$pdo->exec("CREATE TABLE IF NOT EXISTS switch_token (
id TINYINT PRIMARY KEY,
token_enc TEXT NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
echo "粘贴你的 session_token:";
$token = trim(fgets(STDIN));
if ($token === '') exit("token 为空\n");
$stmt = $pdo->prepare("REPLACE INTO switch_token (id, token_enc) VALUES (1, ?)");
$stmt->execute([encryptToken($token)]);
echo "已加密写入数据库\n";
collect.php(核心拉取器)
作用:拉取游玩记录并写入数据库。
为什么这么做:
- 增量写入:不仅会全量覆盖历史统计表
dim_switch_game_play_history,还会根据“总时长减去历史累计”,将新增的游玩时长增量写入明细表dwd_switch_game_played_record。
<?php
require_once __DIR__ . '/crypto.php';
function httpRequest(string $method, string $url, array $headers = [], ?string $body = null): array {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_TIMEOUT => 30,
]);
if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
$resp = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err = curl_error($ch);
curl_close($ch);
if ($resp === false) throw new RuntimeException("cURL 错误: $err");
return [$code, $resp];
}
function getAccessToken(string $sessionToken): array {
$body = json_encode([
'client_id' => NS_CLIENT_ID,
'session_token' => $sessionToken,
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer-session-token',
]);
[$code, $resp] = httpRequest('POST', 'https://accounts.nintendo.com/connect/1.0.0/api/token', [
'Content-Type: application/json',
], $body);
if ($code !== 200) throw new RuntimeException("换 access_token 失败: HTTP $code $resp");
return json_decode($resp, true);
}
// 🌟 修改点 1:增加 $locale 参数,允许指定语言
function getPlayHistory(array $accessToken, string $locale = 'zh-CN'): array {
[$code, $resp] = httpRequest('GET',
'https://app-api.znej.nintendo.com/api/v2.0/users/me/play_histories',
[
'Authorization: ' . $accessToken['token_type'] . ' ' . $accessToken['access_token'],
'User-Agent: ' . NS_UA,
// 使用传入的语言
'gentry-locale: ' . $locale,
]
);
if ($code !== 200) throw new RuntimeException("拉取记录失败: HTTP $code $resp");
return json_decode($resp, true);
}
function utcToBeijing(string $utc): string {
$dt = new DateTime($utc, new DateTimeZone('UTC'));
$dt->setTimezone(new DateTimeZone('Asia/Shanghai'));
return $dt->format('Y-m-d H:i:s');
}
// ---- 主流程 ----
$pdo = new PDO(DB_DSN, DB_USER, DB_PASS, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
// 1. 从数据库读取加密的 session_token
$row = $pdo->query("SELECT token_enc FROM switch_token WHERE id=1")->fetch();
if (!$row) {
fwrite(STDERR, "数据库中没有 session_token,请先运行 bootstrap_token.php 写入\n");
exit(1);
}
$sessionToken = decryptToken($row['token_enc']);
// 2. 拉数据
$accessToken = getAccessToken($sessionToken);
// 🌟 修改点 2:执行你的“双刷”逻辑
try {
// 第一步:用繁体中文 (zh-TW) 触发后台更新
// 我们不需要它的返回结果,只要它去请求一下,任天堂服务器就会去拉取最新数据
getPlayHistory($accessToken, 'zh-TW');
// 第二步:用简体中文 (zh-CN) 正式拉取最新数据
// 此时因为第一步已经触发了更新,这里拿到的就是最新的简体数据了
$history = getPlayHistory($accessToken, 'zh-CN');
} catch (Exception $e) {
// 如果繁体拉取失败(比如偶尔网络波动),为了不影响主流程,我们可以直接回退到简体拉取
// 或者你也可以直接把错误抛出去
$history = getPlayHistory($accessToken, 'zh-CN');
}
// 3. 写入历史统计表(整体替换)
$pdo->beginTransaction();
$pdo->exec("DELETE FROM dim_switch_game_play_history");
$stmt = $pdo->prepare("
INSERT INTO dim_switch_game_play_history
(titleId, titleName, platform, imageUrl, lastUpdatedAt,
firstPlayedAt, lastPlayedAt, totalPlayedDays, totalPlayedMinutes)
VALUES (?,?,?,?,?,?,?,?,?)
");
foreach ($history['playHistories'] as $g) {
$stmt->execute([
$g['titleId'],
$g['titleName'],
$g['platform'] ?? '',
$g['imageUrl'],
utcToBeijing($g['lastUpdatedAt']),
utcToBeijing($g['firstPlayedAt']),
utcToBeijing($g['lastPlayedAt']),
(int)$g['totalPlayedDays'],
(int)$g['totalPlayedMinutes'],
]);
}
$pdo->commit();
// 4. 增量写入明细表
$pdo->exec("
INSERT INTO dwd_switch_game_played_record
SELECT NULL, t1.titleId, t1.titleName, t1.lastPlayedAt,
COALESCE(t1.totalPlayedMinutes - t2.play_time, t1.totalPlayedMinutes),
NOW(), NOW()
FROM dim_switch_game_play_history t1
LEFT JOIN (
SELECT title_id, SUM(play_time) AS play_time, MAX(last_played_at) AS last_played_at
FROM dwd_switch_game_played_record GROUP BY title_id
) t2 ON t1.titleId = t2.title_id
WHERE t2.last_played_at IS NULL OR t1.lastPlayedAt != t2.last_played_at
");
echo "采集完成,共 " . count($history['playHistories']) . " 款游戏\n";
API.php(对外接口)
作用:对前端提供 JSON 数据。
为什么这么做:
- 避免卡顿:如果前端直接去请求
collect.php,一旦任天堂接口抽风,整个网页就会卡死。API.php只负责从数据库读数据,毫秒级返回。 - 自动触发:它内置了“冷却时间”(例如1小时)。当有用户访问网页时,它会检查上次更新数据的时间。如果超过1小时,它会在后台静默启动一个进程去执行
collect.php,然后立刻把旧的数据库数据返回给前端。实现“用户无感知,后台悄咪咪更新”。
<?php
// 允许任何域名跨域访问(方便本地测试和前端接入)
header("Access-Control-Allow-Origin: *");
header("Access-Control-Allow-Methods: GET, POST, OPTIONS");
header("Access-Control-Allow-Headers: Content-Type");
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/crypto.php';
header('Content-Type: application/json; charset=utf-8');
// 设定冷却时间(单位:秒)。3600 表示 1 小时。
// 也就是说,无论网页被访问多少次,1小时内只会去任天堂拉取一次数据。
$cooldown_seconds = 1;
try {
$pdo = new PDO(DB_DSN, DB_USER, DB_PASS, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION
]);
// 1. 检查是否到了可以采集的时间
// 我们直接利用 switch_token 表的 updated_at 字段,或者新建一个状态表。这里为了简单,直接查表里最新的一条记录时间。
// 实际上,为了不破坏原有的 token 表,我们查一下明细表最后一次插入的时间即可。
$lastUpdate = $pdo->query("
SELECT MAX(update_time) FROM dwd_switch_game_played_record
")->fetchColumn();
$now = time();
$lastUpdateTime = $lastUpdate ? strtotime($lastUpdate) : 0;
// 如果距离上次更新超过冷却时间,则触发采集
if (($now - $lastUpdateTime) > $cooldown_seconds) {
// 为了防止并发访问导致同时采集,使用文件锁
$lockFile = __DIR__ . '/collect.lock';
$fp = fopen($lockFile, 'w');
// 尝试获取排他锁 (非阻塞)
if (flock($fp, LOCK_EX | LOCK_NB)) {
// 获取到锁,说明当前没有其他请求在采集,开始执行采集脚本
// 使用 exec 在后台静默执行,避免阻塞网页响应
$phpPath = '/usr/bin/php'; // 你的 PHP CLI 路径
$scriptPath = __DIR__ . '/collect.php';
// 如果是直接同步执行(阻塞网页直到采集完),用下面的代码:
// exec("$phpPath $scriptPath > /dev/null 2>&1");
// 如果是后台异步执行(网页秒开,后台慢慢采),加上 & 符号:
exec("$phpPath $scriptPath > /dev/null 2>&1 &");
// 释放锁
flock($fp, LOCK_UN);
}
fclose($fp);
}
// 2. 从数据库查询最新数据并返回给前端
// 注意:如果是刚触发采集,可能数据还没入库。这里读的是上次采集的数据。
$rows = $pdo->query("
SELECT titleId, titleName, platform, imageUrl, lastPlayedAt,
totalPlayedDays, totalPlayedMinutes
FROM dim_switch_game_play_history
ORDER BY lastPlayedAt DESC
")->fetchAll(PDO::FETCH_ASSOC);
// 3. 输出 JSON
echo json_encode([
'code' => 200,
'msg' => 'success',
'data' => $rows
], JSON_UNESCAPED_UNICODE);
} catch (Exception $e) {
http_response_code(500);
echo json_encode([
'code' => 500,
'msg' => '服务器错误: ' . $e->getMessage()
], JSON_UNESCAPED_UNICODE);
}
如何部署?(环境要求:PHP 7.4+,MySQL 5.7+,宝塔面板)
建库与建表
在宝塔中创建一个数据库。导入以下表结构:
dim_switch_game_play_history(记录所有游戏的总体数据)dwd_switch_game_played_record(记录每次游玩的明细)switch_token(存储加密的 Token)
你只需要在宝塔的 “数据库” -> 找到你的库 -> “管理 (phpMyAdmin)” -> 点击顶部的“SQL”标签页,把下面的代码全部粘贴进去,点击“执行”即可。
-- 1. 存储加密 Token 的表
CREATE TABLE IF NOT EXISTS `switch_token` (
`id` TINYINT PRIMARY KEY,
`token_enc` TEXT NOT NULL,
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 2. 记录所有游戏的总体数据表(维表)
CREATE TABLE IF NOT EXISTS `dim_switch_game_play_history` (
`titleId` varchar(255) NOT NULL,
`titleName` varchar(255) DEFAULT NULL,
`platform` varchar(64) DEFAULT NULL,
`imageUrl` varchar(512) DEFAULT NULL,
`lastUpdatedAt` datetime DEFAULT NULL,
`firstPlayedAt` datetime DEFAULT NULL,
`lastPlayedAt` datetime DEFAULT NULL,
`totalPlayedDays` int DEFAULT NULL,
`totalPlayedMinutes` int DEFAULT NULL,
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`titleId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 3. 记录每次游玩的明细表(事实表,用于增量计算)
CREATE TABLE IF NOT EXISTS `dwd_switch_game_played_record` (
`id` int NOT NULL AUTO_INCREMENT,
`title_id` varchar(255) DEFAULT NULL,
`title_name` varchar(255) DEFAULT NULL,
`last_played_at` datetime DEFAULT NULL,
`play_time` int DEFAULT NULL,
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
配置 config.php
修改里面的数据库连接信息、账号、密码。
生成主密钥
在终端执行:
php -r "echo base64_encode(random_bytes(32)), PHP_EOL;"
将输出的字符串写入到 ../secret/master.key 文件中,并赋予读写权限(chmod 600)。
获取 session_token 并注入
用你之前的 Python 脚本获取 session_token,然后在服务器执行:
php bootstrap_token.php
把 Token 粘贴进去。
测试采集
执行:
php collect.php
如果终端输出 采集完成,共 xx 款游戏,说明数据已经成功入库。
访问https://你的域名/API/xxxxxx/API.php 就可以返回数据啦!
数据结构
整个数据结构分为外层状态控制和内层数据列表。
code: 200:HTTP 状态码,表示请求成功(如果失败可能会是 400、500 等)。msg: "success":请求结果的文字描述,成功就是 success。data:这是一个数组(Array),里面包含了所有的游戏记录。前端 JavaScript 就是通过循环遍历这个data数组,把一个个游戏卡片渲染到网页上的。
data 数组里的每一个 { ... } 对象,代表一款游戏的详细信息。以一个游戏为例:
{
"titleId": "010062b01525c000",
"titleName": "PERSONA4 The GOLDEN",
"platform": "HAC",
"imageUrl": "https://atum-img-lp1.cdn.nintendo.net/i/c/...512",
"lastPlayedAt": "2026-11-12 00:24:54",
"totalPlayedDays": 16,
"totalPlayedMinutes": 1149
}
各个字段的详细含义如下:
1. titleId(字符串)
这是游戏在任天堂服务器中的唯一标识码。前端在渲染时,通常用它来做卡片的唯一 ID(例如 id="game-010062b01525c000"),以防止 DOM 冲突。
2. titleName(字符串)
游戏的完整官方名称,前端直接将其显示在卡片的标题区域。需要注意的是,部分游戏的名字非常长(比如“超级马力欧兄弟 惊奇…”),前端必须配置 CSS 的省略号(text-overflow: ellipsis)或跑马灯效果,否则很容易撑破页面布局。
3. platform(字符串)
代表该游戏运行所在的设备平台。前端可以通过判断这个字段来决定显示哪种图标:如果值是 "HAC",代表是 Switch 1 游戏,显示一代卡带图标;如果值是 "BEE",则代表是 Switch 2 游戏,显示二代卡带图标。
4. imageUrl(字符串)
这是游戏官方封面的图片直链。可以直接用作 <img> 标签的 src 属性。注意,JSON 字符串里的斜杠会带有转义符(例如 https:\/\/...),但前端的 JavaScript 在解析 JSON 后,会自动将其处理成正常的链接格式。
5. lastPlayedAt(字符串)
记录玩家最后一次游玩该游戏的时间。格式为 年-月-日 时:分:秒。前端常截取前半部分(日期)用于在详情弹窗中展示“最后游玩时间”。
6. totalPlayedDays(数字)
累计游玩该游戏的天数。前端直接读取该数字,并在详情页显示“游玩天数:X 天”。
7. totalPlayedMinutes(数字)
累计游玩的总分钟数。这是前端展示时间的核心计算字段。因为直接显示分钟数(如 1149 分钟)不够直观,前端通常会用代码将其换算成小时和分钟:

Comments NOTHING