feat: 增加环境切换
This commit is contained in:
+73
-24
@@ -2,11 +2,13 @@ import { fetchPageConfig } from './services/site-api';
|
||||
import { normalizePreviewImages } from './config/assets';
|
||||
import { defaultPrintConfig } from './config/config';
|
||||
import { getUser } from './utils/auth';
|
||||
import { apiContextKey, getApiConfig, isCurrentApiConfig } from './config/api';
|
||||
import { generateUUID, getAppUUID, setAppUUID } from './utils/uuid';
|
||||
|
||||
let _refreshGeneration = 0;
|
||||
let _loadGeneration = 0;
|
||||
|
||||
const STORAGE_KEY_PAGE_CONFIG = 'pageConfig';
|
||||
const pageConfigStorageKey = () => `doodle_page_config_v2:${apiContextKey()}`;
|
||||
|
||||
// Pending resolvers for getPageConfig() callers waiting on config
|
||||
let _configResolvers: Array<(config: PageConfigData | null) => void> = [];
|
||||
@@ -65,6 +67,23 @@ App<IAppOption>({
|
||||
|
||||
onHide() {},
|
||||
|
||||
/** 切环境不重启 App:作废旧加载、释放等待者并重置共享业务状态。 */
|
||||
resetApiState() {
|
||||
_refreshGeneration++;
|
||||
_loadGeneration++;
|
||||
this.globalData.user = null;
|
||||
this.globalData.pageConfig = undefined;
|
||||
this.globalData.pageConfigReady = false;
|
||||
this.globalData.pendingCategoryTabId = undefined;
|
||||
for (const resolve of _configResolvers) resolve(null);
|
||||
_configResolvers = [];
|
||||
try {
|
||||
wx.removeStorageSync('favorites_active_tab');
|
||||
} catch {
|
||||
/* 临时页签偏好清理失败不影响环境与用户隔离。 */
|
||||
}
|
||||
},
|
||||
|
||||
getPrintConfig(): PrintConfig {
|
||||
return this.globalData.printConfig || defaultPrintConfig;
|
||||
},
|
||||
@@ -74,32 +93,54 @@ App<IAppOption>({
|
||||
wx.setStorageSync('printConfig', config);
|
||||
},
|
||||
|
||||
async loadPageConfig() {
|
||||
async loadPageConfig(forceRefresh = false) {
|
||||
let context: ReturnType<typeof getApiConfig>;
|
||||
try {
|
||||
context = getApiConfig();
|
||||
} catch {
|
||||
// 地址被移除时结束首页等待,开发者仍可进入 Debug 改选可用环境。
|
||||
this.globalData.pageConfig = undefined;
|
||||
this.globalData.pageConfigReady = true;
|
||||
for (const resolve of _configResolvers) resolve(null);
|
||||
_configResolvers = [];
|
||||
return false;
|
||||
}
|
||||
const generation = ++_loadGeneration;
|
||||
const current = () =>
|
||||
generation === _loadGeneration && isCurrentApiConfig(context);
|
||||
const storageKey = pageConfigStorageKey();
|
||||
let config: PageConfigData | null = null;
|
||||
|
||||
// L1: 数据预拉取
|
||||
try {
|
||||
const res = await new Promise<{ fetchedData: string }>(
|
||||
(resolve, reject) => {
|
||||
wx.getBackgroundFetchData({
|
||||
fetchType: 'pre',
|
||||
success: resolve as any,
|
||||
fail: reject,
|
||||
});
|
||||
},
|
||||
);
|
||||
if (res.fetchedData) {
|
||||
const parsed = JSON.parse(res.fetchedData);
|
||||
config = parsed?.data || parsed;
|
||||
// 仅开发版跳过旧线上预拉取,保证环境切换后的内容来自目标服务。
|
||||
// 体验/正式版继续沿用原预拉取模式、地址与旧云端任务。
|
||||
if (context.version !== 'develop')
|
||||
try {
|
||||
const res = await new Promise<{ fetchedData: string }>(
|
||||
(resolve, reject) => {
|
||||
wx.getBackgroundFetchData({
|
||||
fetchType: 'pre',
|
||||
success: resolve as any,
|
||||
fail: reject,
|
||||
});
|
||||
},
|
||||
);
|
||||
if (res.fetchedData) {
|
||||
const parsed = JSON.parse(res.fetchedData);
|
||||
config = parsed?.data || parsed;
|
||||
}
|
||||
} catch {
|
||||
// 预拉取失败,继续降级
|
||||
}
|
||||
} catch {
|
||||
// 预拉取失败,继续降级
|
||||
}
|
||||
if (!current()) return false;
|
||||
|
||||
// L2: storage 缓存
|
||||
if (!config) {
|
||||
if (!config && !forceRefresh) {
|
||||
try {
|
||||
const cached = wx.getStorageSync(STORAGE_KEY_PAGE_CONFIG);
|
||||
const cached =
|
||||
wx.getStorageSync(storageKey) ||
|
||||
(context.version !== 'develop'
|
||||
? wx.getStorageSync('pageConfig')
|
||||
: null);
|
||||
if (cached && typeof cached === 'object') {
|
||||
config = cached;
|
||||
}
|
||||
@@ -117,6 +158,7 @@ App<IAppOption>({
|
||||
/* 新后端不可用时继续展示空状态,本地生成仍可使用。 */
|
||||
}
|
||||
}
|
||||
if (!current()) return false;
|
||||
|
||||
// 三种数据入口都先转换预览图,旧缓存会随成功加载写回新地址。
|
||||
config = normalizePreviewImages(config);
|
||||
@@ -126,7 +168,7 @@ App<IAppOption>({
|
||||
this.globalData.pageConfig = config;
|
||||
// 成功获取后写入 storage 缓存
|
||||
try {
|
||||
wx.setStorageSync(STORAGE_KEY_PAGE_CONFIG, config);
|
||||
wx.setStorageSync(storageKey, config);
|
||||
} catch {
|
||||
// storage 写入失败不影响主流程
|
||||
}
|
||||
@@ -140,16 +182,23 @@ App<IAppOption>({
|
||||
_configResolvers = [];
|
||||
// 预拉取/缓存先渲染,再用新后端刷新,避免旧静态配置长期覆盖管理修改。
|
||||
if (hadCachedConfig) void this.refreshPageConfig();
|
||||
return !!config;
|
||||
},
|
||||
|
||||
async refreshPageConfig() {
|
||||
const context = getApiConfig();
|
||||
const storageKey = pageConfigStorageKey();
|
||||
const generation = ++_refreshGeneration;
|
||||
try {
|
||||
const config = normalizePreviewImages(await fetchPageConfig());
|
||||
if (generation !== _refreshGeneration) return;
|
||||
if (
|
||||
generation !== _refreshGeneration ||
|
||||
!isCurrentApiConfig(context)
|
||||
)
|
||||
return;
|
||||
this.globalData.pageConfig = config;
|
||||
try {
|
||||
wx.setStorageSync(STORAGE_KEY_PAGE_CONFIG, config);
|
||||
wx.setStorageSync(storageKey, config);
|
||||
} catch {
|
||||
/* 缓存失败不影响当前数据。 */
|
||||
}
|
||||
|
||||
@@ -66,13 +66,21 @@
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
|
||||
.nav-bar__title-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.nav-bar__title {
|
||||
font-size: @fs-nav-title;
|
||||
font-weight: @fw-nav-title;
|
||||
color: @color-nav-title;
|
||||
letter-spacing: @ls-nav-title;
|
||||
line-height: 1;
|
||||
flex: 1;
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -86,4 +94,14 @@
|
||||
.nav-bar__placeholder {
|
||||
width: 100%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
.nav-bar__environment {
|
||||
margin-right: 16rpx;
|
||||
padding: 6rpx 12rpx;
|
||||
border-radius: 12rpx;
|
||||
background: @brand;
|
||||
color: @text-title;
|
||||
font-size: 20rpx;
|
||||
line-height: 1.2;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getNavInnerPx } from '../../utils/navMetrics';
|
||||
import { getApiEnvironment } from '../../config/api';
|
||||
|
||||
const HOME_TAB_URL = '/pages/home/home';
|
||||
const HOME_ROUTE = 'pages/home/home';
|
||||
@@ -24,6 +25,7 @@ Component({
|
||||
leftActionType: 'none' as 'none' | 'back' | 'home' | 'brand',
|
||||
/** 仅首页(Tab 根页)在 logo 旁展示「涂鸦丫」;其余主 Tab 只展示 logo */
|
||||
showBrandName: false,
|
||||
apiEnvironment: '',
|
||||
},
|
||||
lifetimes: {
|
||||
attached() {
|
||||
@@ -44,6 +46,12 @@ Component({
|
||||
},
|
||||
methods: {
|
||||
syncLeftAction() {
|
||||
this.setData({
|
||||
apiEnvironment:
|
||||
wx.getAccountInfoSync().miniProgram.envVersion === 'develop'
|
||||
? getApiEnvironment()
|
||||
: '',
|
||||
});
|
||||
const pages = getCurrentPages();
|
||||
const currentRoute = pages[pages.length - 1]?.route || '';
|
||||
const isTabBarPage = TAB_BAR_ROUTES.has(currentRoute);
|
||||
|
||||
@@ -22,7 +22,12 @@
|
||||
wx:elif="{{leftActionType === 'home'}}"
|
||||
class="toy-icon toy-icon-home-o nav-bar__back-icon"></text>
|
||||
</view>
|
||||
<text class="nav-bar__title">{{title}}</text>
|
||||
<view class="nav-bar__title-group">
|
||||
<text class="nav-bar__title">{{title}}</text>
|
||||
<text wx:if="{{apiEnvironment}}" class="nav-bar__environment"
|
||||
>{{apiEnvironment}}</text
|
||||
>
|
||||
</view>
|
||||
<view class="nav-bar__right">
|
||||
<slot name="right"></slot>
|
||||
</view>
|
||||
|
||||
+112
-27
@@ -1,37 +1,122 @@
|
||||
export type ApiEnvironment = 'local' | 'online';
|
||||
export type ApiEnvironment = 'local' | 'staging' | 'production';
|
||||
type MiniProgramVersion = 'develop' | 'trial' | 'release';
|
||||
|
||||
/** 统一接口入口;本地真机调试时将 localBaseUrl 的 IP 改为电脑局域网 IP。 */
|
||||
const LOCAL_BASE_URL = 'http://127.0.0.1:8800/api/v1';
|
||||
const STORAGE_KEY = 'doodle_develop_api_environment_v1';
|
||||
let selectedEnvironment: ApiEnvironment | undefined;
|
||||
let revision = 0;
|
||||
|
||||
/** 暂时共用本地地址;部署后分别替换 staging/production,真机 local 使用电脑局域网 IP。 */
|
||||
export const apiConfig = {
|
||||
developmentEnvironment: 'local' as ApiEnvironment,
|
||||
localBaseUrl: 'http://127.0.0.1:8800/api/v1',
|
||||
// 线上域名尚未确定:填写完整 HTTPS 地址,例如 https://你的域名/api/v1。
|
||||
onlineBaseUrl: '',
|
||||
localBaseUrl: LOCAL_BASE_URL,
|
||||
stagingBaseUrl: LOCAL_BASE_URL,
|
||||
productionBaseUrl: LOCAL_BASE_URL,
|
||||
timeout: 10000,
|
||||
};
|
||||
|
||||
/** develop 可切换本地/线上;体验版、正式版和未知运行环境均只允许线上入口。 */
|
||||
export function resolveApiConfig(version: MiniProgramVersion | string) {
|
||||
const environment: ApiEnvironment =
|
||||
version === 'develop' ? apiConfig.developmentEnvironment : 'online';
|
||||
const baseUrl = (
|
||||
environment === 'local'
|
||||
? apiConfig.localBaseUrl
|
||||
: apiConfig.onlineBaseUrl
|
||||
)
|
||||
.trim()
|
||||
.replace(/\/+$/, '');
|
||||
if (!baseUrl) throw new Error('请先配置线上接口地址');
|
||||
if (
|
||||
!/^https?:\/\/[^/?#\s]+\/api\/v1$/.test(baseUrl) ||
|
||||
(environment === 'online' && !baseUrl.startsWith('https://'))
|
||||
) {
|
||||
throw new Error('接口地址配置不正确');
|
||||
}
|
||||
return { environment, baseUrl, timeout: apiConfig.timeout };
|
||||
export const API_ENVIRONMENTS: ApiEnvironment[] = [
|
||||
'local',
|
||||
'staging',
|
||||
'production',
|
||||
];
|
||||
|
||||
export function isApiEnvironment(value: unknown): value is ApiEnvironment {
|
||||
return API_ENVIRONMENTS.includes(value as ApiEnvironment);
|
||||
}
|
||||
|
||||
/** 请求时读取微信包版本,避免依赖 App 启动顺序,也不接受本地缓存覆盖正式版环境。 */
|
||||
export function getApiConfig() {
|
||||
return resolveApiConfig(wx.getAccountInfoSync().miniProgram.envVersion);
|
||||
/** 仅开发包读取本机选择;体验、正式及未知包始终连接 production。 */
|
||||
export function getApiEnvironment(
|
||||
version: string = wx.getAccountInfoSync().miniProgram.envVersion,
|
||||
): ApiEnvironment {
|
||||
if (version === 'develop' && selectedEnvironment === undefined) {
|
||||
try {
|
||||
const stored: unknown = wx.getStorageSync(STORAGE_KEY);
|
||||
if (isApiEnvironment(stored)) selectedEnvironment = stored;
|
||||
} catch {
|
||||
/* 无缓存时使用开发默认值。 */
|
||||
}
|
||||
}
|
||||
return version === 'develop'
|
||||
? (selectedEnvironment ?? apiConfig.developmentEnvironment)
|
||||
: 'production';
|
||||
}
|
||||
|
||||
export function resolveApiConfig(version: MiniProgramVersion | string) {
|
||||
return resolveEnvironment(getApiEnvironment(version), version);
|
||||
}
|
||||
|
||||
function resolveEnvironment(environment: ApiEnvironment, version: string) {
|
||||
const baseUrl = apiConfig[`${environment}BaseUrl`]
|
||||
.trim()
|
||||
.replace(/\/+$/, '');
|
||||
if (!baseUrl) throw new Error(`请先配置 ${environment} 接口地址`);
|
||||
// 用户指定的本机占位地址暂允许 HTTP;实际云端入口仍须使用 HTTPS。
|
||||
if (
|
||||
!/^https?:\/\/[^/?#\s]+\/api\/v1$/.test(baseUrl) ||
|
||||
(environment !== 'local' &&
|
||||
!baseUrl.startsWith('https://') &&
|
||||
baseUrl !== LOCAL_BASE_URL)
|
||||
)
|
||||
throw new Error('接口地址配置不正确');
|
||||
return {
|
||||
environment,
|
||||
baseUrl,
|
||||
timeout: apiConfig.timeout,
|
||||
revision,
|
||||
version,
|
||||
};
|
||||
}
|
||||
|
||||
export function getApiConfig() {
|
||||
const { appId = '', envVersion } = wx.getAccountInfoSync().miniProgram;
|
||||
return { ...resolveApiConfig(envVersion), appId };
|
||||
}
|
||||
|
||||
/** 即使三个地址相同也按环境隔离;develop 不与体验/正式包共享业务缓存。 */
|
||||
export function apiContextKey(config = getApiConfig()): string {
|
||||
const scope = config.version === 'develop' ? 'develop' : 'public';
|
||||
return [config.appId, scope, config.environment, config.baseUrl]
|
||||
.map(encodeURIComponent)
|
||||
.join(':');
|
||||
}
|
||||
|
||||
/** 版本号阻止 A→B→A 后旧 A 请求覆盖当前页面。 */
|
||||
export function isCurrentApiConfig(config: ReturnType<typeof getApiConfig>) {
|
||||
const current = getApiConfig();
|
||||
return (
|
||||
config.revision === current.revision &&
|
||||
apiContextKey(config) === apiContextKey(current)
|
||||
);
|
||||
}
|
||||
|
||||
/** 写入成功后才提交内存选择;配置失败保留原环境,不自动回退。 */
|
||||
export function selectApiEnvironment(environment: ApiEnvironment): boolean {
|
||||
if (wx.getAccountInfoSync().miniProgram.envVersion !== 'develop')
|
||||
throw new Error('仅开发版可切换环境');
|
||||
if (!isApiEnvironment(environment)) throw new Error('未知接口环境');
|
||||
resolveEnvironment(environment, 'develop');
|
||||
if (getApiEnvironment() === environment) return false;
|
||||
wx.setStorageSync(STORAGE_KEY, environment);
|
||||
selectedEnvironment = environment;
|
||||
revision++;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getApiEnvironmentOptions() {
|
||||
return API_ENVIRONMENTS.map((environment) => {
|
||||
try {
|
||||
return {
|
||||
environment,
|
||||
baseUrl: resolveEnvironment(environment, 'develop').baseUrl,
|
||||
available: true,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
environment,
|
||||
baseUrl: '尚未配置有效地址',
|
||||
available: false,
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -430,14 +430,17 @@ page {
|
||||
}
|
||||
|
||||
.profile-editor__actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 24rpx;
|
||||
display: flex;
|
||||
padding-top: 20rpx;
|
||||
}
|
||||
|
||||
.profile-editor__action {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 92rpx;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 24rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -447,6 +450,14 @@ page {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.profile-editor__action::after {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.profile-editor__action + .profile-editor__action {
|
||||
margin-left: 24rpx;
|
||||
}
|
||||
|
||||
.profile-editor__action--ghost {
|
||||
background: @bg-header;
|
||||
color: @text-title;
|
||||
|
||||
@@ -115,6 +115,11 @@ Page({
|
||||
},
|
||||
|
||||
onOpenProfileEditor() {
|
||||
console.log(
|
||||
'onOpenProfileEditor',
|
||||
this.data.userName,
|
||||
this.data.avatarUrl,
|
||||
);
|
||||
this.setData({
|
||||
showProfileEditor: true,
|
||||
draftNickName:
|
||||
|
||||
@@ -225,16 +225,19 @@
|
||||
</view>
|
||||
|
||||
<view class="profile-editor__actions">
|
||||
<view
|
||||
<button
|
||||
class="profile-editor__action profile-editor__action--ghost"
|
||||
disabled="{{isSavingProfile}}"
|
||||
bindtap="onCloseProfileEditor">
|
||||
取消
|
||||
</view>
|
||||
<view
|
||||
</button>
|
||||
<button
|
||||
class="profile-editor__action profile-editor__action--primary {{isSavingProfile ? 'profile-editor__action--disabled' : ''}}"
|
||||
loading="{{isSavingProfile}}"
|
||||
disabled="{{isSavingProfile}}"
|
||||
bindtap="onSubmitProfile">
|
||||
{{isSavingProfile ? '保存中...' : '保存'}}
|
||||
</view>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</van-popup>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
let pendingWrites = 0;
|
||||
|
||||
/** 覆盖登录等待、上传及重试全过程,防止写操作进行中切换服务端。 */
|
||||
export function beginApiWrite() {
|
||||
pendingWrites++;
|
||||
return () => {
|
||||
pendingWrites--;
|
||||
};
|
||||
}
|
||||
|
||||
export function hasPendingApiWrites() {
|
||||
return pendingWrites > 0;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getApiConfig } from '../config/api';
|
||||
import { beginApiWrite } from './api-activity';
|
||||
import {
|
||||
ApiRequestError,
|
||||
requestApi,
|
||||
@@ -30,6 +31,21 @@ const blocked = new Set([
|
||||
export async function apiRequest<T>(
|
||||
path: string,
|
||||
options: RequestOptions & { auth?: 'required' | 'none' } = {},
|
||||
): Promise<T> {
|
||||
const finish =
|
||||
options.upload || (options.method && options.method !== 'GET')
|
||||
? beginApiWrite()
|
||||
: undefined;
|
||||
try {
|
||||
return await performApiRequest<T>(path, options);
|
||||
} finally {
|
||||
finish?.();
|
||||
}
|
||||
}
|
||||
|
||||
async function performApiRequest<T>(
|
||||
path: string,
|
||||
options: RequestOptions & { auth?: 'required' | 'none' } = {},
|
||||
): Promise<T> {
|
||||
// Authorization 由会话层统一提供;公开请求不附带调用方误传的凭证。
|
||||
const header = Object.fromEntries(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getApiConfig } from '../config/api';
|
||||
import { getApiConfig, isCurrentApiConfig } from '../config/api';
|
||||
|
||||
export class ApiRequestError extends Error {
|
||||
constructor(
|
||||
@@ -28,7 +28,15 @@ export function requestApi<T>(
|
||||
if (!path.startsWith('/') || path.startsWith('//'))
|
||||
throw new Error('接口路径不正确');
|
||||
return new Promise((resolve, reject) => {
|
||||
// 公开请求、上传和登录共用检查,旧环境的响应不进入上层业务回调。
|
||||
const stale = () => {
|
||||
if (isCurrentApiConfig(config)) return false;
|
||||
reject(new ApiRequestError('接口环境已变化,请重新操作'));
|
||||
return true;
|
||||
};
|
||||
if (stale()) return;
|
||||
const success = (res: { statusCode: number; data: unknown }) => {
|
||||
if (stale()) return;
|
||||
const body = res.data as {
|
||||
code?: number;
|
||||
data?: T;
|
||||
@@ -42,7 +50,7 @@ export function requestApi<T>(
|
||||
: '接口请求失败,请稍后重试',
|
||||
res.statusCode,
|
||||
typeof body?.reason === 'string' &&
|
||||
/^[A-Z_]{1,64}$/.test(body.reason)
|
||||
/^[A-Z_]{1,64}$/.test(body.reason)
|
||||
? body.reason
|
||||
: '',
|
||||
),
|
||||
@@ -60,8 +68,12 @@ export function requestApi<T>(
|
||||
);
|
||||
} else resolve(body.data as T);
|
||||
};
|
||||
const fail = () =>
|
||||
reject(new ApiRequestError('连接失败,请确认后端已启动及网络配置'));
|
||||
const fail = () => {
|
||||
if (!stale())
|
||||
reject(
|
||||
new ApiRequestError('连接失败,请确认后端已启动及网络配置'),
|
||||
);
|
||||
};
|
||||
if (options.upload) {
|
||||
// wx.uploadFile 由运行时生成 multipart boundary,不能自行设置 JSON Content-Type。
|
||||
wx.uploadFile({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getApiConfig } from '../config/api';
|
||||
import { getApiConfig, apiContextKey, isCurrentApiConfig } from '../config/api';
|
||||
import { ApiRequestError, requestApi } from './api-transport';
|
||||
|
||||
export interface ServerUser {
|
||||
@@ -32,7 +32,7 @@ export function getAuthContext() {
|
||||
if (!appId) throw new ApiRequestError('无法识别小程序身份');
|
||||
return {
|
||||
...config,
|
||||
key: `doodle_auth_v1:${encodeURIComponent(appId)}:${encodeURIComponent(config.baseUrl)}`,
|
||||
key: `doodle_auth_v2:${apiContextKey(config)}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -70,7 +70,18 @@ function readState(context: Context): SessionState {
|
||||
generation: 0,
|
||||
};
|
||||
try {
|
||||
const stored = wx.getStorageSync(context.key);
|
||||
// 旧版会话只迁入原默认环境,不能复制到同址的 staging/production 开发配置。
|
||||
const legacyDefault =
|
||||
context.version === 'develop'
|
||||
? context.environment === 'local'
|
||||
: context.environment === 'production';
|
||||
const stored =
|
||||
wx.getStorageSync(context.key) ||
|
||||
(legacyDefault
|
||||
? wx.getStorageSync(
|
||||
`doodle_auth_v1:${encodeURIComponent(wx.getAccountInfoSync().miniProgram.appId)}:${encodeURIComponent(context.baseUrl)}`,
|
||||
)
|
||||
: null);
|
||||
if (stored) {
|
||||
state.history = 'used';
|
||||
if (
|
||||
@@ -145,7 +156,7 @@ export async function ensureSession(
|
||||
force = false,
|
||||
explicitStart = false,
|
||||
): Promise<AuthSession> {
|
||||
if (getAuthContext().key !== context.key)
|
||||
if (!isCurrentApiConfig(context))
|
||||
throw new ApiRequestError('接口环境已变化,请重新操作');
|
||||
const state = readState(context);
|
||||
if (state.history === 'blocked' && !explicitStart)
|
||||
@@ -184,11 +195,13 @@ export async function ensureSession(
|
||||
);
|
||||
break;
|
||||
} catch (error) {
|
||||
if (!(
|
||||
error instanceof ApiRequestError &&
|
||||
error.reason === 'WECHAT_CODE_INVALID' &&
|
||||
attempt === 0
|
||||
))
|
||||
if (
|
||||
!(
|
||||
error instanceof ApiRequestError &&
|
||||
error.reason === 'WECHAT_CODE_INVALID' &&
|
||||
attempt === 0
|
||||
)
|
||||
)
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -210,10 +223,7 @@ export async function ensureSession(
|
||||
user: parseServerUser(result.user),
|
||||
};
|
||||
// 清理、切换环境期间迟到的登录响应不得恢复会话或写入当前界面。
|
||||
if (
|
||||
state.generation !== generation ||
|
||||
getAuthContext().key !== context.key
|
||||
)
|
||||
if (state.generation !== generation || !isCurrentApiConfig(context))
|
||||
throw new ApiRequestError('登录状态已变化,请重新操作');
|
||||
state.session = session;
|
||||
state.history = 'used';
|
||||
@@ -222,6 +232,7 @@ export async function ensureSession(
|
||||
} catch (error) {
|
||||
if (
|
||||
state.generation === generation &&
|
||||
isCurrentApiConfig(context) &&
|
||||
error instanceof ApiRequestError &&
|
||||
[
|
||||
'ACCOUNT_NOT_FOUND',
|
||||
@@ -244,6 +255,14 @@ export async function ensureSession(
|
||||
}
|
||||
}
|
||||
|
||||
/** 保留各环境已保存会话和账户历史,只作废尚未完成的登录任务。 */
|
||||
export function invalidatePendingAuthentication() {
|
||||
for (const state of states.values()) {
|
||||
state.generation++;
|
||||
state.flight = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** 同一原请求只恢复一次;迟到的旧 401 直接复用较新凭证。 */
|
||||
export async function recoverSession(context: Context, failed: AuthSession) {
|
||||
const state = readState(context);
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
getApiEnvironment,
|
||||
selectApiEnvironment,
|
||||
type ApiEnvironment,
|
||||
} from '../config/api';
|
||||
import { hasPendingApiWrites } from './api-activity';
|
||||
import { invalidatePendingAuthentication } from './auth-session';
|
||||
|
||||
let switching = false;
|
||||
|
||||
/** 统一切换入口:写入中不切换,保留分环境会话,重建全部页面以丢弃旧业务状态。 */
|
||||
export async function switchApiEnvironment(environment: ApiEnvironment) {
|
||||
if (wx.getAccountInfoSync().miniProgram.envVersion !== 'develop')
|
||||
throw new Error('仅开发版可切换环境');
|
||||
if (switching) throw new Error('正在切换环境,请稍候');
|
||||
if (getApiEnvironment() === environment) return false;
|
||||
const busyPage = getCurrentPages().some(
|
||||
(page) => page.data.debugPublishLoading || page.data.isSavingProfile,
|
||||
);
|
||||
if (hasPendingApiWrites() || busyPage)
|
||||
throw new Error('请等待当前保存或上传完成后再切换');
|
||||
switching = true;
|
||||
try {
|
||||
selectApiEnvironment(environment);
|
||||
invalidatePendingAuthentication();
|
||||
const app = getApp<IAppOption>();
|
||||
app.resetApiState();
|
||||
// reLaunch 只重建页面,不重新执行 App.onLaunch;目标数据需要主动初始化。
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
wx.reLaunch({
|
||||
url: '/pages/home/home',
|
||||
success: () => resolve(),
|
||||
fail: () => reject(new Error('环境已切换,请重新进入首页')),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
app.warmAuthentication();
|
||||
if (!(await app.loadPageConfig(true)))
|
||||
throw new Error('已切换,目标接口连接失败');
|
||||
}
|
||||
return true;
|
||||
} finally {
|
||||
switching = false;
|
||||
}
|
||||
}
|
||||
@@ -12,28 +12,6 @@ page {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.debug-page__hero {
|
||||
padding: 48rpx 40rpx;
|
||||
background: @bg-white;
|
||||
border-radius: @radius-xl;
|
||||
box-shadow: @shadow;
|
||||
}
|
||||
|
||||
.debug-page__hero-title {
|
||||
display: block;
|
||||
font-size: 40rpx;
|
||||
font-weight: 800;
|
||||
color: @text-title;
|
||||
}
|
||||
|
||||
.debug-page__hero-desc {
|
||||
display: block;
|
||||
margin-top: 16rpx;
|
||||
font-size: 26rpx;
|
||||
line-height: 1.6;
|
||||
color: @text-secondary;
|
||||
}
|
||||
|
||||
.debug-page__section {
|
||||
margin-top: 32rpx;
|
||||
}
|
||||
@@ -125,3 +103,65 @@ page {
|
||||
font-size: 24rpx;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.debug-page__environment {
|
||||
padding: 32rpx;
|
||||
background: @bg-white;
|
||||
border-radius: @radius-xl;
|
||||
box-shadow: @shadow;
|
||||
}
|
||||
|
||||
.debug-page__environment-url {
|
||||
display: block;
|
||||
word-break: break-all;
|
||||
color: @text-secondary;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.debug-page__environment-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
|
||||
/* 明确按钮高度及整行点击区域,避免原生 button 被全局样式压缩。 */
|
||||
.debug-page__environment-options .debug-environment-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 96rpx;
|
||||
flex-shrink: 0;
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0 24rpx;
|
||||
border: 0;
|
||||
font-size: 28rpx;
|
||||
line-height: 1.4;
|
||||
color: @text-title;
|
||||
background: @bg-header;
|
||||
border-radius: 20rpx;
|
||||
}
|
||||
|
||||
.debug-page__environment-options .debug-environment-option::after {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.debug-page__environment-options
|
||||
.debug-environment-option
|
||||
+ .debug-environment-option {
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
|
||||
.debug-page__environment-options .debug-environment-option--selected {
|
||||
background: @brand;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.debug-page__environment-note {
|
||||
display: block;
|
||||
margin-top: 20rpx;
|
||||
color: @text-secondary;
|
||||
font-size: 22rpx;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
import {
|
||||
getApiEnvironment,
|
||||
getApiEnvironmentOptions,
|
||||
isApiEnvironment,
|
||||
} from '../../config/api';
|
||||
import { switchApiEnvironment } from '../../services/environment-switch';
|
||||
|
||||
type DebugEntry = {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -47,7 +54,45 @@ const DEBUG_ENTRIES: DebugEntry[] = [
|
||||
Page({
|
||||
data: {
|
||||
entries: DEBUG_ENTRIES,
|
||||
isDevEnv: true,
|
||||
isDevEnv: false,
|
||||
currentEnvironment: '',
|
||||
currentBaseUrl: '',
|
||||
environments: getApiEnvironmentOptions(),
|
||||
isSwitchingEnvironment: false,
|
||||
},
|
||||
|
||||
onShow() {
|
||||
const environment = getApiEnvironment();
|
||||
const environments = getApiEnvironmentOptions();
|
||||
this.setData({
|
||||
currentEnvironment: environment,
|
||||
currentBaseUrl:
|
||||
environments.find((item) => item.environment === environment)
|
||||
?.baseUrl || '',
|
||||
environments,
|
||||
});
|
||||
},
|
||||
|
||||
async onSelectEnvironment(e: WechatMiniprogram.TouchEvent) {
|
||||
if (!this.data.isDevEnv || this.data.isSwitchingEnvironment) return;
|
||||
const environment: unknown = e.currentTarget.dataset.environment;
|
||||
if (!isApiEnvironment(environment)) return;
|
||||
this.setData({ isSwitchingEnvironment: true });
|
||||
wx.showLoading({ title: '切换环境中', mask: true });
|
||||
let errorMessage = '';
|
||||
try {
|
||||
await switchApiEnvironment(environment);
|
||||
} catch (error) {
|
||||
errorMessage = error instanceof Error ? error.message : '切换失败';
|
||||
} finally {
|
||||
wx.hideLoading();
|
||||
// 成功时本页已被 reLaunch 销毁,失败时才需要恢复按钮。
|
||||
if (getCurrentPages().includes(this)) {
|
||||
this.setData({ isSwitchingEnvironment: false });
|
||||
this.onShow();
|
||||
}
|
||||
}
|
||||
if (errorMessage) wx.showToast({ title: errorMessage, icon: 'none' });
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
|
||||
@@ -1,9 +1,24 @@
|
||||
<view class="debug-page">
|
||||
<view class="debug-page__hero">
|
||||
<text class="debug-page__hero-title">Debug 工具页</text>
|
||||
<text class="debug-page__hero-desc">
|
||||
仅开发环境可见,用于发布前检查和同步调试数据。
|
||||
</text>
|
||||
<view wx:if="{{isDevEnv}}" class="debug-page__environment">
|
||||
<text class="debug-page__section-title"
|
||||
>接口环境 · {{currentEnvironment}}</text
|
||||
>
|
||||
<text class="debug-page__environment-url">{{currentBaseUrl}}</text>
|
||||
<view class="debug-page__environment-options">
|
||||
<button
|
||||
wx:for="{{environments}}"
|
||||
wx:key="environment"
|
||||
class="debug-environment-option {{currentEnvironment === item.environment ? 'debug-environment-option--selected' : ''}}"
|
||||
data-environment="{{item.environment}}"
|
||||
disabled="{{!item.available || isSwitchingEnvironment}}"
|
||||
bindtap="onSelectEnvironment">
|
||||
{{item.environment}}
|
||||
<text wx:if="{{!item.available}}">(未配置)</text>
|
||||
</button>
|
||||
</view>
|
||||
<text class="debug-page__environment-note"
|
||||
>切换后自动返回首页;下次打开开发版继续使用所选环境。</text
|
||||
>
|
||||
</view>
|
||||
|
||||
<view class="debug-page__section">
|
||||
|
||||
Reference in New Issue
Block a user