From 42aea1010110a9e7258491ac13714a62b04c2723 Mon Sep 17 00:00:00 2001 From: Blizzard Date: Mon, 5 Oct 2026 11:03:33 +0800 Subject: [PATCH] Add immediate startup login and loading states for mini program home --- pets-fe/app.js | 37 ++-- pets-fe/components/home-skeleton/index.js | 1 + pets-fe/components/home-skeleton/index.json | 1 + pets-fe/components/home-skeleton/index.wxml | 9 + pets-fe/components/home-skeleton/index.wxss | 73 ++++++++ pets-fe/components/startup-state/index.js | 8 + pets-fe/components/startup-state/index.json | 1 + pets-fe/components/startup-state/index.wxml | 16 ++ pets-fe/components/startup-state/index.wxss | 128 +++++++++++++ pets-fe/pages/home/home.js | 104 ++++++++--- pets-fe/pages/home/home.json | 4 +- pets-fe/pages/home/home.wxml | 23 ++- pets-fe/pages/home/home.wxss | 50 +++++ pets-fe/utils/api.js | 19 +- tests/miniprogram-startup.test.cjs | 193 ++++++++++++++++++++ 15 files changed, 623 insertions(+), 44 deletions(-) create mode 100644 pets-fe/components/home-skeleton/index.js create mode 100644 pets-fe/components/home-skeleton/index.json create mode 100644 pets-fe/components/home-skeleton/index.wxml create mode 100644 pets-fe/components/home-skeleton/index.wxss create mode 100644 pets-fe/components/startup-state/index.js create mode 100644 pets-fe/components/startup-state/index.json create mode 100644 pets-fe/components/startup-state/index.wxml create mode 100644 pets-fe/components/startup-state/index.wxss create mode 100644 tests/miniprogram-startup.test.cjs 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, "网络异常"); +});