Add immediate startup login and loading states for mini program home
This commit is contained in:
+14
-3
@@ -2,15 +2,26 @@ App({
|
|||||||
globalData: { token: "", user: null },
|
globalData: { token: "", user: null },
|
||||||
onLaunch() {
|
onLaunch() {
|
||||||
this.globalData.token = wx.getStorageSync("pet_token") || "";
|
this.globalData.token = wx.getStorageSync("pet_token") || "";
|
||||||
|
// Start immediately; pages await the same promise and display failures themselves.
|
||||||
|
this.login().catch(() => {});
|
||||||
},
|
},
|
||||||
async login() {
|
async login() {
|
||||||
|
if (this.loginPromise) return this.loginPromise;
|
||||||
if (this.globalData.token) return this.globalData.token;
|
if (this.globalData.token) return this.globalData.token;
|
||||||
if (!this.loginPromise)
|
|
||||||
this.loginPromise = (async () => {
|
this.loginPromise = (async () => {
|
||||||
const { request } = require("./utils/api");
|
const { request } = require("./utils/api");
|
||||||
const { code } = await wx.login();
|
const { code } = await new Promise((resolve, reject) =>
|
||||||
if (!code) throw new Error("微信登录未取得凭证,请重试");
|
wx.login({
|
||||||
|
timeout: 10000,
|
||||||
|
success: resolve,
|
||||||
|
fail: () => reject(new Error("暂时无法连接微信,请重新连接")),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (!code) throw new Error("微信登录未取得凭证,请重新连接");
|
||||||
const data = await request("/auth/login", "POST", { code }, false);
|
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.token = data.token;
|
||||||
this.globalData.user = data.user;
|
this.globalData.user = data.user;
|
||||||
wx.setStorageSync("pet_token", data.token);
|
wx.setStorageSync("pet_token", data.token);
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
Component({});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{ "component": true }
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<view class="skeleton" aria-label="正在准备首页内容">
|
||||||
|
<view class="hero-placeholder"><view class="bone line short"></view><view class="bone line title"></view><view class="bone line"></view><view class="bone art"></view></view>
|
||||||
|
<view class="bone heading"></view>
|
||||||
|
<view class="card-placeholder"><view class="bone line title"></view><view class="bone line"></view><view class="bone action"></view></view>
|
||||||
|
<view class="stats-placeholder"><view class="bone"></view><view class="bone"></view><view class="bone"></view></view>
|
||||||
|
<view class="bone heading"></view>
|
||||||
|
<view class="card-placeholder"><view class="bone line"></view><view class="bone line short"></view></view>
|
||||||
|
<view class="loading-copy">正在准备今天的小日常…</view>
|
||||||
|
</view>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
Component({
|
||||||
|
properties: { error: String },
|
||||||
|
methods: {
|
||||||
|
retry() {
|
||||||
|
this.triggerEvent("retry");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{ "component": true }
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<view class="startup {{error?'failed':''}}">
|
||||||
|
<view class="glow"></view>
|
||||||
|
<view class="brand-wrap"><image class="brand" src="/assets/logo-corgi-v2.png" mode="aspectFit" /></view>
|
||||||
|
<view class="brand-name">肉垫计划</view>
|
||||||
|
<view class="brand-tagline">给陪伴一个温暖的地方</view>
|
||||||
|
<view wx:if="{{error}}" class="connection-error">
|
||||||
|
<view class="message">暂时没能连接</view>
|
||||||
|
<view class="error-copy">{{error}}</view>
|
||||||
|
<button class="retry" bindtap="retry">重新连接</button>
|
||||||
|
</view>
|
||||||
|
<view wx:else class="preparing" role="status">
|
||||||
|
<view class="dots"><view></view><view></view><view></view></view>
|
||||||
|
<view class="message">正在准备今天的小日常</view>
|
||||||
|
</view>
|
||||||
|
<view class="footer">陪伴它们的每一天,都值得记录</view>
|
||||||
|
</view>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+80
-18
@@ -14,16 +14,22 @@ Page({
|
|||||||
walkCount: 0,
|
walkCount: 0,
|
||||||
name: "铲屎官",
|
name: "铲屎官",
|
||||||
loading: true,
|
loading: true,
|
||||||
|
ready: false,
|
||||||
|
phase: "auth",
|
||||||
|
error: "",
|
||||||
},
|
},
|
||||||
onShow() {
|
onShow() {
|
||||||
this.load();
|
|
||||||
this.updateEnvironmentLabel();
|
this.updateEnvironmentLabel();
|
||||||
if (!this.environmentAt || Date.now() - this.environmentAt > 15 * 60 * 1000) {
|
this.load();
|
||||||
this.refreshEnvironment();
|
},
|
||||||
}
|
onUnload() {
|
||||||
|
this.disposed = true;
|
||||||
},
|
},
|
||||||
updateEnvironmentLabel() {
|
updateEnvironmentLabel() {
|
||||||
this.setData({ headerSubtitle: dateLabel() + " · " + (this.weatherLabel || "轻触获取天气") });
|
this.setData({
|
||||||
|
headerSubtitle:
|
||||||
|
dateLabel() + " · " + (this.weatherLabel || "轻触获取天气"),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
async refreshEnvironment(event) {
|
async refreshEnvironment(event) {
|
||||||
if (this.environmentBusy) return;
|
if (this.environmentBusy) return;
|
||||||
@@ -31,44 +37,84 @@ Page({
|
|||||||
const tapped = !!event;
|
const tapped = !!event;
|
||||||
let positioned = false;
|
let positioned = false;
|
||||||
try {
|
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 (settings.authSetting["scope.userLocation"] === false) {
|
||||||
if (!tapped) throw new Error("location-denied");
|
if (!tapped) throw new Error("location-denied");
|
||||||
const enabled = await new Promise((resolve, reject) => wx.openSetting({ success: resolve, fail: reject }));
|
const enabled = await new Promise((resolve, reject) =>
|
||||||
if (!enabled.authSetting["scope.userLocation"]) throw new Error("location-denied");
|
wx.openSetting({ success: resolve, fail: reject }),
|
||||||
|
);
|
||||||
|
if (!enabled.authSetting["scope.userLocation"])
|
||||||
|
throw new Error("location-denied");
|
||||||
}
|
}
|
||||||
this.weatherLabel = "正在获取天气";
|
this.weatherLabel = "正在获取天气";
|
||||||
this.updateEnvironmentLabel();
|
this.updateEnvironmentLabel();
|
||||||
const point = await new Promise((resolve, reject) => wx.getLocation({
|
const point = await new Promise((resolve, reject) =>
|
||||||
|
wx.getLocation({
|
||||||
type: "gcj02",
|
type: "gcj02",
|
||||||
success: resolve,
|
success: resolve,
|
||||||
fail: reject,
|
fail: reject,
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
positioned = true;
|
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.setData({ locationName: result.location_name });
|
||||||
this.weatherLabel = result.weather
|
this.weatherLabel = result.weather
|
||||||
? `${result.weather.description} ${result.weather.temperature}°C`
|
? `${result.weather.description} ${result.weather.temperature}°C`
|
||||||
: "天气暂不可用";
|
: "天气暂不可用";
|
||||||
this.environmentAt = result.weather ? Date.now() : 0;
|
this.environmentAt = result.weather ? Date.now() : 0;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const denied = /denied|deny|auth/.test(error.errMsg || error.message || "");
|
const denied = /denied|deny|auth/.test(
|
||||||
this.setData({ locationName: positioned ? "位置暂不可用" : denied ? "开启定位" : "定位暂不可用" });
|
error.errMsg || error.message || "",
|
||||||
|
);
|
||||||
|
this.setData({
|
||||||
|
locationName: positioned
|
||||||
|
? "位置暂不可用"
|
||||||
|
: denied
|
||||||
|
? "开启定位"
|
||||||
|
: "定位暂不可用",
|
||||||
|
});
|
||||||
this.weatherLabel = denied ? "轻触开启定位" : "轻触重试";
|
this.weatherLabel = denied ? "轻触开启定位" : "轻触重试";
|
||||||
if (tapped && !denied) wx.showToast({ title: positioned ? "位置和天气获取失败,请重试" : "定位失败,请重试", icon: "none" });
|
if (tapped && !denied)
|
||||||
|
wx.showToast({
|
||||||
|
title: positioned ? "位置和天气获取失败,请重试" : "定位失败,请重试",
|
||||||
|
icon: "none",
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
this.environmentBusy = false;
|
this.environmentBusy = false;
|
||||||
this.updateEnvironmentLabel();
|
this.updateEnvironmentLabel();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async load() {
|
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 {
|
try {
|
||||||
|
await getApp().login();
|
||||||
|
authenticated = true;
|
||||||
|
if (this.disposed) return;
|
||||||
|
this.setData({ phase: "data" });
|
||||||
const [pets, tasks, walks, me] = await Promise.all([
|
const [pets, tasks, walks, me] = await Promise.all([
|
||||||
secure("/pets"),
|
secure("/pets"),
|
||||||
secure("/tasks"),
|
secure("/tasks"),
|
||||||
secure("/walks"),
|
secure("/walks"),
|
||||||
secure("/me"),
|
secure("/me"),
|
||||||
]);
|
]);
|
||||||
|
if (this.disposed) return;
|
||||||
const chosen = wx.getStorageSync("walk_pet_ids") || [],
|
const chosen = wx.getStorageSync("walk_pet_ids") || [],
|
||||||
month = localDate().slice(0, 7);
|
month = localDate().slice(0, 7);
|
||||||
this.setData({
|
this.setData({
|
||||||
@@ -77,13 +123,29 @@ Page({
|
|||||||
tasks: tasks.items,
|
tasks: tasks.items,
|
||||||
walkCount: walks.filter((w) => w.started_at.slice(0, 7) === month)
|
walkCount: walks.filter((w) => w.started_at.slice(0, 7) === month)
|
||||||
.length,
|
.length,
|
||||||
name: me.nickname || "铲屎官",
|
name: me.nickname || "毛孩家长",
|
||||||
loading: false,
|
loading: false,
|
||||||
|
ready: true,
|
||||||
|
phase: "ready",
|
||||||
});
|
});
|
||||||
} catch (e) {
|
// Location permission and weather never hold up the first usable screen.
|
||||||
this.setData({ loading: false });
|
if (
|
||||||
toast(e);
|
!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 || "暂时没能连接,请稍后重试",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
addPet() {
|
||||||
|
wx.navigateTo({ url: "/pages/pet-edit/pet-edit" });
|
||||||
},
|
},
|
||||||
pick(e) {
|
pick(e) {
|
||||||
this.setData({ selected: e.detail.selected });
|
this.setData({ selected: e.detail.selected });
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
"disableScroll": true,
|
"disableScroll": true,
|
||||||
"usingComponents": {
|
"usingComponents": {
|
||||||
"tab-bar": "/components/tab-bar/index",
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
<page-layout id="page-layout" title="{{locationName}}" subtitle="{{headerSubtitle}}" location="{{true}}" bind:locationtap="refreshEnvironment">
|
<startup-state wx:if="{{phase==='auth' || phase==='auth-error'}}" error="{{error}}" bind:retry="load" />
|
||||||
|
<page-layout wx:else id="page-layout" title="{{locationName}}" subtitle="{{headerSubtitle}}" location="{{true}}" bind:locationtap="refreshEnvironment">
|
||||||
<view class="screen">
|
<view class="screen">
|
||||||
|
<home-skeleton wx:if="{{loading && !ready}}" />
|
||||||
|
<view wx:elif="{{!ready && error}}" class="card home-error">
|
||||||
|
<app-icon kind="paw" size="54" />
|
||||||
|
<view class="error-title">暂时没能准备好首页</view>
|
||||||
|
<view class="muted">{{error}}</view>
|
||||||
|
<button class="primary" bindtap="load">重新加载</button>
|
||||||
|
</view>
|
||||||
|
<block wx:else>
|
||||||
|
<view wx:if="{{error}}" class="refresh-error"><text>更新失败,保留上次的日常记录</text><text class="link" bindtap="load">重试</text></view>
|
||||||
|
<view wx:elif="{{loading}}" class="refresh-hint">正在更新今天的小日常…</view>
|
||||||
<view class="hero">
|
<view class="hero">
|
||||||
<view class="hero-copy">
|
<view class="hero-copy">
|
||||||
<text class="hero-small">今日的主角</text>
|
<text class="hero-small">今日的主角</text>
|
||||||
@@ -21,9 +32,16 @@
|
|||||||
<view class="muted">选择同行的狗狗,一起出门转转。</view>
|
<view class="muted">选择同行的狗狗,一起出门转转。</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
<view wx:if="{{!pets.length}}" class="first-pet">
|
||||||
|
<view class="first-pet-title">认识你的第一位毛孩子</view>
|
||||||
|
<view class="muted">先建立档案,再一起出门收集小快乐。</view>
|
||||||
|
<button class="primary start" bindtap="addPet">添加毛孩子</button>
|
||||||
|
</view>
|
||||||
|
<block wx:else>
|
||||||
<view class="picker-label">这次带谁?可以多选</view>
|
<view class="picker-label">这次带谁?可以多选</view>
|
||||||
<pet-picker pets="{{pets}}" selected="{{selected}}" bind:change="pick" />
|
<pet-picker pets="{{pets}}" selected="{{selected}}" bind:change="pick" />
|
||||||
<button class="primary start" bindtap="start">开始遛狗 →</button>
|
<button class="primary start" bindtap="start">开始遛狗 →</button>
|
||||||
|
</block>
|
||||||
</view>
|
</view>
|
||||||
<view class="stats">
|
<view class="stats">
|
||||||
<view>
|
<view>
|
||||||
@@ -75,6 +93,7 @@
|
|||||||
今天还没有任务,给狗狗安排一件小事吧
|
今天还没有任务,给狗狗安排一件小事吧
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
</block>
|
||||||
</view>
|
</view>
|
||||||
</page-layout>
|
</page-layout>
|
||||||
<tab-bar current="/pages/home/home" />
|
<tab-bar wx:if="{{ready}}" current="/pages/home/home" />
|
||||||
|
|||||||
@@ -157,3 +157,53 @@
|
|||||||
.task-main .muted {
|
.task-main .muted {
|
||||||
font-size: 22rpx;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
+12
-7
@@ -6,23 +6,29 @@ function request(path, method = "GET", data, auth = true) {
|
|||||||
url: baseURL + path,
|
url: baseURL + path,
|
||||||
method,
|
method,
|
||||||
data,
|
data,
|
||||||
|
timeout: 15000,
|
||||||
header: token && auth ? { Authorization: "Bearer " + token } : {},
|
header: token && auth ? { Authorization: "Bearer " + token } : {},
|
||||||
success(res) {
|
success(res) {
|
||||||
if (res.data && res.data.code === 0) resolve(res.data.data);
|
if (res.data && res.data.code === 0) resolve(res.data.data);
|
||||||
else {
|
else {
|
||||||
if (res.statusCode === 401) {
|
if (res.statusCode === 401 && auth) invalidateToken(token);
|
||||||
wx.removeStorageSync("pet_token");
|
|
||||||
getApp().globalData.token = "";
|
|
||||||
}
|
|
||||||
const error = new Error((res.data && res.data.message) || "请求失败");
|
const error = new Error((res.data && res.data.message) || "请求失败");
|
||||||
error.status = res.statusCode;
|
error.status = res.statusCode;
|
||||||
reject(error);
|
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) {
|
async function secure(path, method, data) {
|
||||||
await getApp().login();
|
await getApp().login();
|
||||||
try {
|
try {
|
||||||
@@ -53,6 +59,7 @@ function uploadOnce(path) {
|
|||||||
else {
|
else {
|
||||||
const error = new Error(body.message || "上传失败");
|
const error = new Error(body.message || "上传失败");
|
||||||
error.status = res.statusCode;
|
error.status = res.statusCode;
|
||||||
|
if (res.statusCode === 401) invalidateToken(token);
|
||||||
reject(error);
|
reject(error);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -69,8 +76,6 @@ async function upload(path) {
|
|||||||
return await uploadOnce(path);
|
return await uploadOnce(path);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.status !== 401) throw error;
|
if (error.status !== 401) throw error;
|
||||||
wx.removeStorageSync("pet_token");
|
|
||||||
getApp().globalData.token = "";
|
|
||||||
await getApp().login();
|
await getApp().login();
|
||||||
return uploadOnce(path);
|
return uploadOnce(path);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, "网络异常");
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user