diff --git a/pets-fe/app.js b/pets-fe/app.js
index 8d6cf13..d48a175 100644
--- a/pets-fe/app.js
+++ b/pets-fe/app.js
@@ -2,22 +2,33 @@ App({
globalData: { token: "", user: null },
onLaunch() {
this.globalData.token = wx.getStorageSync("pet_token") || "";
+ // Start immediately; pages await the same promise and display failures themselves.
+ this.login().catch(() => {});
},
async login() {
+ if (this.loginPromise) return this.loginPromise;
if (this.globalData.token) return this.globalData.token;
- if (!this.loginPromise)
- this.loginPromise = (async () => {
- const { request } = require("./utils/api");
- const { code } = await wx.login();
- if (!code) throw new Error("微信登录未取得凭证,请重试");
- const data = await request("/auth/login", "POST", { code }, false);
- this.globalData.token = data.token;
- this.globalData.user = data.user;
- wx.setStorageSync("pet_token", data.token);
- return data.token;
- })().finally(() => {
- this.loginPromise = null;
- });
+ this.loginPromise = (async () => {
+ const { request } = require("./utils/api");
+ const { code } = await new Promise((resolve, reject) =>
+ wx.login({
+ timeout: 10000,
+ success: resolve,
+ fail: () => reject(new Error("暂时无法连接微信,请重新连接")),
+ }),
+ );
+ if (!code) throw new Error("微信登录未取得凭证,请重新连接");
+ const data = await request("/auth/login", "POST", { code }, false);
+ if (!data || typeof data.token !== "string" || !data.token) {
+ throw new Error("登录未完成,请重新连接");
+ }
+ this.globalData.token = data.token;
+ this.globalData.user = data.user;
+ wx.setStorageSync("pet_token", data.token);
+ return data.token;
+ })().finally(() => {
+ this.loginPromise = null;
+ });
return this.loginPromise;
},
});
diff --git a/pets-fe/components/home-skeleton/index.js b/pets-fe/components/home-skeleton/index.js
new file mode 100644
index 0000000..b79c512
--- /dev/null
+++ b/pets-fe/components/home-skeleton/index.js
@@ -0,0 +1 @@
+Component({});
diff --git a/pets-fe/components/home-skeleton/index.json b/pets-fe/components/home-skeleton/index.json
new file mode 100644
index 0000000..67f8e03
--- /dev/null
+++ b/pets-fe/components/home-skeleton/index.json
@@ -0,0 +1 @@
+{ "component": true }
diff --git a/pets-fe/components/home-skeleton/index.wxml b/pets-fe/components/home-skeleton/index.wxml
new file mode 100644
index 0000000..fc514f1
--- /dev/null
+++ b/pets-fe/components/home-skeleton/index.wxml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+ 正在准备今天的小日常…
+
diff --git a/pets-fe/components/home-skeleton/index.wxss b/pets-fe/components/home-skeleton/index.wxss
new file mode 100644
index 0000000..c66e51b
--- /dev/null
+++ b/pets-fe/components/home-skeleton/index.wxss
@@ -0,0 +1,73 @@
+.bone {
+ background: linear-gradient(100deg, #f3e7dc 20%, #fff8ef 45%, #f3e7dc 70%);
+ background-size: 220% 100%;
+ animation: shimmer 1.6s ease-in-out infinite;
+ border-radius: 15rpx;
+}
+.hero-placeholder {
+ position: relative;
+ min-height: 330rpx;
+ background: #fff0e2;
+ border-radius: 38rpx;
+ padding: 45rpx 35rpx;
+ box-sizing: border-box;
+}
+.line {
+ height: 22rpx;
+ width: 65%;
+ margin: 24rpx 0;
+}
+.short {
+ width: 25%;
+}
+.title {
+ height: 38rpx;
+ width: 48%;
+}
+.art {
+ position: absolute;
+ right: 35rpx;
+ top: 62rpx;
+ width: 170rpx;
+ height: 190rpx;
+ border-radius: 40rpx;
+}
+.heading {
+ width: 230rpx;
+ height: 33rpx;
+ margin: 48rpx 0 28rpx;
+}
+.card-placeholder {
+ padding: 28rpx;
+ background: white;
+ border-radius: 35rpx;
+}
+.action {
+ height: 80rpx;
+ margin-top: 42rpx;
+ border-radius: 24rpx;
+}
+.stats-placeholder {
+ display: flex;
+ gap: 14rpx;
+ margin-top: 20rpx;
+}
+.stats-placeholder view {
+ height: 140rpx;
+ flex: 1;
+ border-radius: 28rpx;
+}
+.loading-copy {
+ text-align: center;
+ color: #a48d7e;
+ font-size: 22rpx;
+ margin-top: 32rpx;
+}
+@keyframes shimmer {
+ 0% {
+ background-position: 100% 0;
+ }
+ 100% {
+ background-position: -100% 0;
+ }
+}
diff --git a/pets-fe/components/startup-state/index.js b/pets-fe/components/startup-state/index.js
new file mode 100644
index 0000000..d470371
--- /dev/null
+++ b/pets-fe/components/startup-state/index.js
@@ -0,0 +1,8 @@
+Component({
+ properties: { error: String },
+ methods: {
+ retry() {
+ this.triggerEvent("retry");
+ },
+ },
+});
diff --git a/pets-fe/components/startup-state/index.json b/pets-fe/components/startup-state/index.json
new file mode 100644
index 0000000..67f8e03
--- /dev/null
+++ b/pets-fe/components/startup-state/index.json
@@ -0,0 +1 @@
+{ "component": true }
diff --git a/pets-fe/components/startup-state/index.wxml b/pets-fe/components/startup-state/index.wxml
new file mode 100644
index 0000000..16ae6eb
--- /dev/null
+++ b/pets-fe/components/startup-state/index.wxml
@@ -0,0 +1,16 @@
+
+
+
+ 肉垫计划
+ 给陪伴一个温暖的地方
+
+ 暂时没能连接
+ {{error}}
+
+
+
+
+ 正在准备今天的小日常
+
+
+
diff --git a/pets-fe/components/startup-state/index.wxss b/pets-fe/components/startup-state/index.wxss
new file mode 100644
index 0000000..4825c25
--- /dev/null
+++ b/pets-fe/components/startup-state/index.wxss
@@ -0,0 +1,128 @@
+.startup {
+ position: fixed;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ z-index: 80;
+ background: linear-gradient(160deg, #fff9f3, #fff0e2 65%, #f7edf7);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 60rpx;
+ box-sizing: border-box;
+ overflow: hidden;
+ color: #694c3f;
+}
+.glow {
+ position: absolute;
+ width: 520rpx;
+ height: 520rpx;
+ border-radius: 50%;
+ background: radial-gradient(circle, #ffe1c2, transparent 70%);
+ top: 18%;
+ pointer-events: none;
+}
+.brand-wrap {
+ position: relative;
+ animation: breathe 2.4s ease-in-out infinite;
+}
+.brand {
+ width: 172rpx;
+ height: 172rpx;
+ border-radius: 55rpx;
+ box-shadow: 0 20rpx 60rpx #bd84551c;
+}
+.brand-name {
+ font-size: 45rpx;
+ font-weight: 600;
+ letter-spacing: 5rpx;
+ margin-top: 34rpx;
+}
+.brand-tagline {
+ font-size: 23rpx;
+ color: #9d7965;
+ margin-top: 14rpx;
+ letter-spacing: 2rpx;
+}
+.preparing,
+.connection-error {
+ min-height: 170rpx;
+ margin-top: 64rpx;
+ text-align: center;
+ position: relative;
+}
+.message {
+ font-size: 25rpx;
+ color: #886c5f;
+}
+.dots {
+ display: flex;
+ justify-content: center;
+ gap: 12rpx;
+ margin-bottom: 25rpx;
+}
+.dots view {
+ width: 10rpx;
+ height: 10rpx;
+ border-radius: 50%;
+ background: #d99a75;
+ animation: pulse 1.2s ease-in-out infinite;
+}
+.dots view:nth-child(2) {
+ animation-delay: 0.15s;
+}
+.dots view:nth-child(3) {
+ animation-delay: 0.3s;
+}
+.error-copy {
+ font-size: 23rpx;
+ line-height: 1.8;
+ color: #9d7965;
+ margin-top: 15rpx;
+ max-width: 570rpx;
+ overflow-wrap: break-word;
+}
+.retry {
+ font-size: 25rpx;
+ background: #b95738;
+ color: white;
+ border-radius: 24rpx;
+ padding: 0 44rpx;
+ margin-top: 26rpx;
+ line-height: 82rpx;
+}
+.retry::after {
+ border: 0;
+}
+.failed .brand-wrap {
+ animation: none;
+}
+.footer {
+ position: absolute;
+ bottom: calc(65rpx + env(safe-area-inset-bottom));
+ font-size: 20rpx;
+ color: #a88c78;
+ letter-spacing: 1rpx;
+}
+@keyframes breathe {
+ 0%,
+ 100% {
+ transform: translateY(0) scale(1);
+ }
+ 50% {
+ transform: translateY(-10rpx) scale(1.025);
+ }
+}
+@keyframes pulse {
+ 0%,
+ 100% {
+ opacity: 0.35;
+ transform: scale(0.85);
+ }
+ 50% {
+ opacity: 1;
+ transform: scale(1.1);
+ }
+}
diff --git a/pets-fe/pages/home/home.js b/pets-fe/pages/home/home.js
index b801be1..d1b298c 100644
--- a/pets-fe/pages/home/home.js
+++ b/pets-fe/pages/home/home.js
@@ -14,16 +14,22 @@ Page({
walkCount: 0,
name: "铲屎官",
loading: true,
+ ready: false,
+ phase: "auth",
+ error: "",
},
onShow() {
- this.load();
this.updateEnvironmentLabel();
- if (!this.environmentAt || Date.now() - this.environmentAt > 15 * 60 * 1000) {
- this.refreshEnvironment();
- }
+ this.load();
+ },
+ onUnload() {
+ this.disposed = true;
},
updateEnvironmentLabel() {
- this.setData({ headerSubtitle: dateLabel() + " · " + (this.weatherLabel || "轻触获取天气") });
+ this.setData({
+ headerSubtitle:
+ dateLabel() + " · " + (this.weatherLabel || "轻触获取天气"),
+ });
},
async refreshEnvironment(event) {
if (this.environmentBusy) return;
@@ -31,44 +37,84 @@ Page({
const tapped = !!event;
let positioned = false;
try {
- const settings = await new Promise((resolve, reject) => wx.getSetting({ success: resolve, fail: reject }));
+ const settings = await new Promise((resolve, reject) =>
+ wx.getSetting({ success: resolve, fail: reject }),
+ );
if (settings.authSetting["scope.userLocation"] === false) {
if (!tapped) throw new Error("location-denied");
- const enabled = await new Promise((resolve, reject) => wx.openSetting({ success: resolve, fail: reject }));
- if (!enabled.authSetting["scope.userLocation"]) throw new Error("location-denied");
+ const enabled = await new Promise((resolve, reject) =>
+ wx.openSetting({ success: resolve, fail: reject }),
+ );
+ if (!enabled.authSetting["scope.userLocation"])
+ throw new Error("location-denied");
}
this.weatherLabel = "正在获取天气";
this.updateEnvironmentLabel();
- const point = await new Promise((resolve, reject) => wx.getLocation({
- type: "gcj02",
- success: resolve,
- fail: reject,
- }));
+ const point = await new Promise((resolve, reject) =>
+ wx.getLocation({
+ type: "gcj02",
+ success: resolve,
+ fail: reject,
+ }),
+ );
positioned = true;
- const result = await secure("/environment", "GET", { latitude: point.latitude, longitude: point.longitude });
+ const result = await secure("/environment", "GET", {
+ latitude: point.latitude,
+ longitude: point.longitude,
+ });
this.setData({ locationName: result.location_name });
this.weatherLabel = result.weather
? `${result.weather.description} ${result.weather.temperature}°C`
: "天气暂不可用";
this.environmentAt = result.weather ? Date.now() : 0;
} catch (error) {
- const denied = /denied|deny|auth/.test(error.errMsg || error.message || "");
- this.setData({ locationName: positioned ? "位置暂不可用" : denied ? "开启定位" : "定位暂不可用" });
+ const denied = /denied|deny|auth/.test(
+ error.errMsg || error.message || "",
+ );
+ this.setData({
+ locationName: positioned
+ ? "位置暂不可用"
+ : denied
+ ? "开启定位"
+ : "定位暂不可用",
+ });
this.weatherLabel = denied ? "轻触开启定位" : "轻触重试";
- if (tapped && !denied) wx.showToast({ title: positioned ? "位置和天气获取失败,请重试" : "定位失败,请重试", icon: "none" });
+ if (tapped && !denied)
+ wx.showToast({
+ title: positioned ? "位置和天气获取失败,请重试" : "定位失败,请重试",
+ icon: "none",
+ });
} finally {
this.environmentBusy = false;
this.updateEnvironmentLabel();
}
},
async load() {
+ if (this.loadPromise) return this.loadPromise;
+ this.loadPromise = this.loadHome().finally(() => {
+ this.loadPromise = null;
+ });
+ return this.loadPromise;
+ },
+ async loadHome() {
+ this.setData({
+ loading: true,
+ error: "",
+ phase: this.data.ready ? "data" : "auth",
+ });
+ let authenticated = false;
try {
+ await getApp().login();
+ authenticated = true;
+ if (this.disposed) return;
+ this.setData({ phase: "data" });
const [pets, tasks, walks, me] = await Promise.all([
secure("/pets"),
secure("/tasks"),
secure("/walks"),
secure("/me"),
]);
+ if (this.disposed) return;
const chosen = wx.getStorageSync("walk_pet_ids") || [],
month = localDate().slice(0, 7);
this.setData({
@@ -77,14 +123,30 @@ Page({
tasks: tasks.items,
walkCount: walks.filter((w) => w.started_at.slice(0, 7) === month)
.length,
- name: me.nickname || "铲屎官",
+ name: me.nickname || "毛孩家长",
loading: false,
+ ready: true,
+ phase: "ready",
+ });
+ // Location permission and weather never hold up the first usable screen.
+ if (
+ !this.environmentAt ||
+ Date.now() - this.environmentAt > 15 * 60 * 1000
+ ) {
+ this.refreshEnvironment();
+ }
+ } catch (error) {
+ if (this.disposed) return;
+ this.setData({
+ loading: false,
+ phase: !authenticated && !this.data.ready ? "auth-error" : "data-error",
+ error: error.message || "暂时没能连接,请稍后重试",
});
- } catch (e) {
- this.setData({ loading: false });
- toast(e);
}
},
+ addPet() {
+ wx.navigateTo({ url: "/pages/pet-edit/pet-edit" });
+ },
pick(e) {
this.setData({ selected: e.detail.selected });
wx.setStorageSync("walk_pet_ids", e.detail.selected);
diff --git a/pets-fe/pages/home/home.json b/pets-fe/pages/home/home.json
index 2f486b5..07951d1 100644
--- a/pets-fe/pages/home/home.json
+++ b/pets-fe/pages/home/home.json
@@ -2,6 +2,8 @@
"disableScroll": true,
"usingComponents": {
"tab-bar": "/components/tab-bar/index",
- "pet-picker": "/components/pet-picker/index"
+ "pet-picker": "/components/pet-picker/index",
+ "startup-state": "/components/startup-state/index",
+ "home-skeleton": "/components/home-skeleton/index"
}
}
diff --git a/pets-fe/pages/home/home.wxml b/pets-fe/pages/home/home.wxml
index 7aef008..9acbdb1 100644
--- a/pets-fe/pages/home/home.wxml
+++ b/pets-fe/pages/home/home.wxml
@@ -1,5 +1,16 @@
-
+
+
+
+
+
+ 暂时没能准备好首页
+ {{error}}
+
+
+
+ 更新失败,保留上次的日常记录重试
+ 正在更新今天的小日常…
今日的主角
@@ -21,9 +32,16 @@
选择同行的狗狗,一起出门转转。
+
+ 认识你的第一位毛孩子
+ 先建立档案,再一起出门收集小快乐。
+
+
+
这次带谁?可以多选
+
@@ -75,6 +93,7 @@
今天还没有任务,给狗狗安排一件小事吧
+
-
+
diff --git a/pets-fe/pages/home/home.wxss b/pets-fe/pages/home/home.wxss
index 366f3d8..7b5a6a8 100644
--- a/pets-fe/pages/home/home.wxss
+++ b/pets-fe/pages/home/home.wxss
@@ -157,3 +157,53 @@
.task-main .muted {
font-size: 22rpx;
}
+.home-error {
+ text-align: center;
+ padding: 70rpx 35rpx;
+ display: flex;
+ align-items: center;
+ flex-direction: column;
+ gap: 26rpx;
+ margin-top: 40rpx;
+}
+.error-title {
+ font-size: 30rpx;
+ font-weight: 600;
+}
+.home-error .muted {
+ font-size: 24rpx;
+ line-height: 1.8;
+}
+.home-error button {
+ margin-top: 15rpx;
+ min-width: 300rpx;
+}
+.refresh-error {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ font-size: 22rpx;
+ background: #fff0e2;
+ color: #886c5f;
+ padding: 20rpx 24rpx;
+ border-radius: 22rpx;
+ margin-bottom: 20rpx;
+}
+.refresh-hint {
+ text-align: center;
+ font-size: 22rpx;
+ color: #a48d7e;
+ margin-bottom: 20rpx;
+}
+.first-pet {
+ padding-top: 30rpx;
+}
+.first-pet-title {
+ font-size: 27rpx;
+ font-weight: 600;
+ margin-bottom: 12rpx;
+}
+.first-pet .muted {
+ font-size: 23rpx;
+ line-height: 1.8;
+}
diff --git a/pets-fe/utils/api.js b/pets-fe/utils/api.js
index 26a5d1f..edd5f8d 100644
--- a/pets-fe/utils/api.js
+++ b/pets-fe/utils/api.js
@@ -6,23 +6,29 @@ function request(path, method = "GET", data, auth = true) {
url: baseURL + path,
method,
data,
+ timeout: 15000,
header: token && auth ? { Authorization: "Bearer " + token } : {},
success(res) {
if (res.data && res.data.code === 0) resolve(res.data.data);
else {
- if (res.statusCode === 401) {
- wx.removeStorageSync("pet_token");
- getApp().globalData.token = "";
- }
+ if (res.statusCode === 401 && auth) invalidateToken(token);
const error = new Error((res.data && res.data.message) || "请求失败");
error.status = res.statusCode;
reject(error);
}
},
- fail: reject,
+ fail: () => reject(new Error("网络连接失败,请检查网络后重试")),
});
});
}
+function invalidateToken(usedToken) {
+ const app = getApp();
+ // A late response using the old token must not clear a newly refreshed session.
+ if (app.globalData.token && app.globalData.token !== usedToken) return;
+ wx.removeStorageSync("pet_token");
+ app.globalData.token = "";
+ app.globalData.user = null;
+}
async function secure(path, method, data) {
await getApp().login();
try {
@@ -53,6 +59,7 @@ function uploadOnce(path) {
else {
const error = new Error(body.message || "上传失败");
error.status = res.statusCode;
+ if (res.statusCode === 401) invalidateToken(token);
reject(error);
}
} catch (e) {
@@ -69,8 +76,6 @@ async function upload(path) {
return await uploadOnce(path);
} catch (error) {
if (error.status !== 401) throw error;
- wx.removeStorageSync("pet_token");
- getApp().globalData.token = "";
await getApp().login();
return uploadOnce(path);
}
diff --git a/tests/miniprogram-startup.test.cjs b/tests/miniprogram-startup.test.cjs
new file mode 100644
index 0000000..03b7bb0
--- /dev/null
+++ b/tests/miniprogram-startup.test.cjs
@@ -0,0 +1,193 @@
+const test = require("node:test");
+const assert = require("node:assert/strict");
+const fs = require("node:fs");
+const vm = require("node:vm");
+const tick = () => new Promise((resolve) => setImmediate(resolve));
+
+function runtime(cachedToken = "") {
+ let app;
+ const storage = new Map(cachedToken ? [["pet_token", cachedToken]] : []);
+ const requests = [],
+ logins = [];
+ const wx = {
+ getStorageSync: (key) => storage.get(key),
+ setStorageSync: (key, value) => storage.set(key, value),
+ removeStorageSync: (key) => storage.delete(key),
+ login: (options) => logins.push(options),
+ request: (options) => {
+ requests.push(options);
+ if (options.url.endsWith("/auth/login"))
+ options.success({
+ statusCode: 200,
+ data: {
+ code: 0,
+ data: { token: "fresh-token", user: { nickname: "毛孩家长9527" } },
+ },
+ });
+ else if (options.header.Authorization === "Bearer fresh-token")
+ options.success({ statusCode: 200, data: { code: 0, data: "ok" } });
+ },
+ };
+ const module = { exports: {} };
+ vm.runInNewContext(fs.readFileSync("pets-fe/utils/api.js", "utf8"), {
+ wx,
+ getApp: () => app,
+ require: () => ({ baseURL: "https://test.local/api" }),
+ module,
+ });
+ vm.runInNewContext(fs.readFileSync("pets-fe/app.js", "utf8"), {
+ wx,
+ App: (value) => {
+ app = value;
+ },
+ require: () => module.exports,
+ });
+ return { app, api: module.exports, requests, logins, storage };
+}
+
+test("启动即登录,多个首页请求共用登录任务并携带 token", async () => {
+ const r = runtime();
+ r.app.onLaunch();
+ const pending = [
+ r.api.secure("/pets"),
+ r.api.secure("/tasks"),
+ r.api.secure("/me"),
+ ];
+ assert.equal(r.logins.length, 1);
+ assert.equal(r.requests.length, 0);
+ r.logins[0].success({ code: "wechat-code" });
+ assert.deepEqual(await Promise.all(pending), ["ok", "ok", "ok"]);
+ assert.equal(
+ r.requests.filter((req) => req.url.endsWith("/auth/login")).length,
+ 1,
+ );
+ assert.ok(
+ r.requests
+ .slice(1)
+ .every((req) => req.header.Authorization === "Bearer fresh-token"),
+ );
+ assert.equal(r.storage.get("pet_token"), "fresh-token");
+});
+
+test("缓存 token 过期时只重新登录一次,延迟的旧 401 不清除新 token", async () => {
+ const r = runtime("expired");
+ r.app.onLaunch();
+ const first = r.api.secure("/pets"),
+ second = r.api.secure("/tasks");
+ await tick();
+ const old = r.requests.slice();
+ assert.equal(old.length, 2);
+ old[0].success({ statusCode: 401, data: { message: "expired" } });
+ await tick();
+ assert.equal(r.logins.length, 1);
+ r.logins[0].success({ code: "new-code" });
+ await first;
+ old[1].success({ statusCode: 401, data: { message: "expired" } });
+ await second;
+ assert.equal(r.app.globalData.token, "fresh-token");
+ assert.equal(r.logins.length, 1);
+});
+
+function home(login, secure) {
+ let definition;
+ const wx = { getStorageSync: () => [], navigateTo: () => {} };
+ vm.runInNewContext(fs.readFileSync("pets-fe/pages/home/home.js", "utf8"), {
+ wx,
+ Date,
+ getApp: () => ({ login }),
+ Page: (value) => {
+ definition = value;
+ },
+ require: (name) =>
+ name.includes("date")
+ ? { localDate: () => "2026-10-05" }
+ : { secure, toast: () => {} },
+ });
+ const page = {
+ ...definition,
+ data: { ...definition.data },
+ setData(value) {
+ Object.assign(this.data, value);
+ },
+ };
+ page.refreshEnvironment = () => new Promise(() => {});
+ return page;
+}
+function data(path) {
+ return Promise.resolve(
+ path === "/pets" || path === "/walks"
+ ? []
+ : path === "/tasks"
+ ? { items: [] }
+ : { nickname: "毛孩家长9527" },
+ );
+}
+
+test("登录和数据都准备好才显示首页,天气未完成也能进入", async () => {
+ let finishLogin,
+ calls = 0;
+ const login = new Promise((resolve) => {
+ finishLogin = resolve;
+ });
+ const page = home(
+ () => login,
+ (path) => {
+ calls++;
+ return data(path);
+ },
+ );
+ const loading = page.load();
+ page.load();
+ assert.equal(page.data.phase, "auth");
+ assert.equal(page.data.ready, false);
+ assert.equal(calls, 0);
+ finishLogin("token");
+ await loading;
+ assert.equal(calls, 4);
+ assert.equal(page.data.ready, true);
+ assert.equal(page.data.loading, false);
+ assert.equal(page.data.phase, "ready");
+ assert.equal(page.data.pets.length, 0);
+});
+
+test("首次登录失败可重试,失败期间不请求业务数据", async () => {
+ let attempts = 0,
+ calls = 0;
+ const page = home(
+ async () => {
+ if (++attempts === 1) throw new Error("无法连接");
+ return "token";
+ },
+ (path) => {
+ calls++;
+ return data(path);
+ },
+ );
+ await page.load();
+ assert.equal(page.data.phase, "auth-error");
+ assert.equal(page.data.ready, false);
+ assert.equal(calls, 0);
+ await page.load();
+ assert.equal(page.data.ready, true);
+ assert.equal(page.data.error, "");
+});
+
+test("首页请求失败不显示空数据状态,刷新失败保留已有记录", async () => {
+ let fail = true;
+ const page = home(
+ async () => "token",
+ (path) => (fail ? Promise.reject(new Error("网络异常")) : data(path)),
+ );
+ await page.load();
+ assert.equal(page.data.phase, "data-error");
+ assert.equal(page.data.ready, false);
+ fail = false;
+ await page.load();
+ assert.equal(page.data.ready, true);
+ page.setData({ pets: [{ id: "existing" }] });
+ fail = true;
+ await page.load();
+ assert.equal(page.data.ready, true);
+ assert.equal(page.data.pets[0].id, "existing");
+ assert.equal(page.data.error, "网络异常");
+});