feat(license): 在线授权数据缓存秒开 + 请求 loading

- 进页面读 localStorage 缓存即时渲染在线授权数据(key 按站点 host 隔离),
  后台并发 license/refresh + system/license 刷新,右上角显示「刷新中」转圈,
  数据回来静默替换并更新缓存;非在线状态自动清除缓存
- 首次无缓存显示骨架占位 + 加载中
- 发送验证码改回 setting/email 内联样式(iView search + enter-button +
  spinner 全局 loading),去除独立按钮
This commit is contained in:
kuaifan 2026-06-23 07:10:25 +00:00
parent fa9e56944a
commit 889aca311a
3 changed files with 185 additions and 75 deletions

View File

@ -2504,3 +2504,4 @@ App Store账号
验证码已发送至(*) 验证码已发送至(*)
(*)秒后重发 (*)秒后重发
请输入邮箱和验证码 请输入邮箱和验证码
刷新中

View File

@ -2,45 +2,65 @@
<div class="setting-item submit license-setting"> <div class="setting-item submit license-setting">
<Tabs v-model="mode"> <Tabs v-model="mode">
<TabPane :label="$L('在线授权')" name="online"> <TabPane :label="$L('在线授权')" name="online">
<div v-if="onlineActive" class="license-box"> <div class="setting-component-item">
<ul class="online-info"> <div class="setting-scroll">
<li><em>{{$L('账号')}}:</em><span>{{online.account}}</span></li> <!-- 首次进入且无缓存骨架占位 + 加载中有缓存则直接渲染下方真实数据 -->
<li><em>{{$L('套餐')}}:</em><span>{{online.plan || '-'}}</span></li> <div v-if="firstLoading" class="license-box">
<li><em>{{$L('使用人数')}}:</em><span>{{online.people || $L('无限制')}}</span></li> <div class="online-refreshing"><i class="online-spin"></i>{{$L('加载中...')}}</div>
<li><em>{{$L('授权有效期')}}:</em><span>{{online.valid_until ? fmt(online.valid_until) : $L('永久')}}</span></li> <ul class="online-info">
<li> <li><em>{{$L('账号')}}:</em><span class="online-skeleton"></span></li>
<em>{{$L('当前状态')}}:</em> <li><em>{{$L('套餐')}}:</em><span class="online-skeleton sk-sm"></span></li>
<span :class="{warning: online.status !== 'active'}">{{stageText(online.status)}}</span> <li><em>{{$L('使用人数')}}:</em><span class="online-skeleton sk-xs"></span></li>
</li> <li><em>{{$L('授权有效期')}}:</em><span class="online-skeleton"></span></li>
</ul> <li><em>{{$L('当前状态')}}:</em><span class="online-skeleton sk-sm"></span></li>
<div class="setting-footer"> </ul>
<Button :loading="onlineAction === 'logout'" :disabled="onlineBusy && onlineAction !== 'logout'" @click="onlineLogout">{{$L('退出在线授权')}}</Button>
</div> </div>
<template v-else>
<div v-if="onlineActive" class="license-box">
<!-- 后台刷新中指示缓存秒开后仍刷新最新数据 -->
<div v-if="onlineRefreshing" class="online-refreshing"><i class="online-spin"></i>{{$L('刷新中')}}</div>
<ul class="online-info">
<li><em>{{$L('账号')}}:</em><span>{{online.account}}</span></li>
<li><em>{{$L('套餐')}}:</em><span>{{online.plan || '-'}}</span></li>
<li><em>{{$L('使用人数')}}:</em><span>{{online.people || $L('无限制')}}</span></li>
<li><em>{{$L('授权有效期')}}:</em><span>{{online.valid_until ? fmt(online.valid_until) : $L('永久')}}</span></li>
<li>
<em>{{$L('当前状态')}}:</em>
<span :class="{warning: online.status !== 'active'}">{{stageText(online.status)}}</span>
</li>
</ul>
</div>
<Form v-else :model="onlineForm" v-bind="formOptions" @submit.native.prevent>
<FormItem :label="$L('邮箱')">
<Input
v-model="onlineForm.email"
:class="codeCountdown > 0 ? 'setting-send-input' : 'setting-input'"
search @on-search="emailSend"
:enter-button="sendBtnText"
:disabled="onlineBusy"
:placeholder="$L('请输入邮箱')"/>
</FormItem>
<FormItem v-if="codeSent" :label="$L('邮箱验证码')">
<Input v-model="onlineForm.code" class="setting-input" :placeholder="$L('请输入验证码')"/>
<div class="online-tip">{{$L('验证码已发送至(*)', maskedEmail)}}</div>
</FormItem>
</Form>
</template>
</div> </div>
<template v-else> <div v-if="!firstLoading" class="setting-footer">
<Form :model="onlineForm" v-bind="formOptions" @submit.native.prevent> <template v-if="onlineActive">
<FormItem :label="$L('邮箱')"> <Button :loading="onlineAction === 'logout'" :disabled="onlineBusy && onlineAction !== 'logout'" type="primary" @click="onlineLogout">{{$L('退出在线授权')}}</Button>
<div class="online-email-row"> </template>
<Input v-model="onlineForm.email" class="online-email-input" :placeholder="$L('请输入邮箱')" @on-enter="emailSend"/> <template v-else>
<Button :loading="onlineAction === 'send'"
:disabled="codeCountdown > 0 || (onlineBusy && onlineAction !== 'send')"
@click="emailSend">
{{codeCountdown > 0 ? $L('(*)秒后重发', codeCountdown) : $L('发送验证码')}}
</Button>
</div>
</FormItem>
<FormItem v-if="codeSent" :label="$L('邮箱验证码')">
<Input v-model="onlineForm.code" class="setting-input" :placeholder="$L('请输入验证码')"/>
<div class="online-tip">{{$L('验证码已发送至(*)', maskedEmail)}}</div>
</FormItem>
</Form>
<div class="setting-footer">
<Button :loading="onlineAction === 'login'" :disabled="onlineBusy && onlineAction !== 'login'" type="primary" @click="onlineLogin">{{$L('登录授权')}}</Button> <Button :loading="onlineAction === 'login'" :disabled="onlineBusy && onlineAction !== 'login'" type="primary" @click="onlineLogin">{{$L('登录授权')}}</Button>
<Button :loading="onlineAction === 'trial'" :disabled="onlineBusy && onlineAction !== 'trial'" type="success" @click="trialSubmit">{{$L('申请试用')}}</Button> <Button :loading="onlineAction === 'trial'" :disabled="onlineBusy && onlineAction !== 'trial'" @click="trialSubmit">{{$L('申请试用')}}</Button>
</div> </template>
</template> </div>
</div>
</TabPane> </TabPane>
<TabPane :label="$L('离线授权')" name="offline"> <TabPane :label="$L('离线授权')" name="offline">
<div class="setting-component-item">
<div class="setting-scroll">
<template v-if="onlineActive"> <template v-if="onlineActive">
<div class="license-box"> <div class="license-box">
<ul class="online-info"> <ul class="online-info">
@ -49,20 +69,11 @@
<li><em>MAC:</em><span>{{infoJoin(formData.macs)}}</span></li> <li><em>MAC:</em><span>{{infoJoin(formData.macs)}}</span></li>
</ul> </ul>
</div> </div>
<div v-if="!offlineRebindShow" class="setting-footer"> <Form v-if="offlineRebindShow" :model="formData" v-bind="formOptions" @submit.native.prevent>
<Button type="primary" @click="offlineRebindShow = true">{{$L('绑定离线 License')}}</Button> <FormItem label="License">
</div> <Input v-model="offlineRebindLicense" type="textarea" :autosize="{minRows: 2,maxRows: 5}" :placeholder="$L('请输入License...')" />
<template v-else> </FormItem>
<Form :model="formData" v-bind="formOptions" @submit.native.prevent> </Form>
<FormItem label="License">
<Input v-model="offlineRebindLicense" type="textarea" :autosize="{minRows: 2,maxRows: 5}" :placeholder="$L('请输入License...')" />
</FormItem>
</Form>
<div class="setting-footer">
<Button :loading="loadIng > 0" type="primary" @click="offlineRebindSubmit">{{$L('提交')}}</Button>
<Button :loading="loadIng > 0" @click="offlineRebindCancel">{{$L('取消')}}</Button>
</div>
</template>
</template> </template>
<template v-else> <template v-else>
<Form ref="formData" :model="formData" v-bind="formOptions" @submit.native.prevent> <Form ref="formData" :model="formData" v-bind="formOptions" @submit.native.prevent>
@ -140,18 +151,34 @@
</div> </div>
</FormItem> </FormItem>
</Form> </Form>
<div class="setting-footer">
<Button :loading="loadIng > 0" type="primary" @click="submitForm">{{$L('提交')}}</Button>
<Button :loading="loadIng > 0" @click="resetForm">{{$L('重置')}}</Button>
</div>
</template> </template>
</div>
<div class="setting-footer">
<template v-if="onlineActive">
<Button v-if="!offlineRebindShow" type="primary" @click="offlineRebindShow = true">{{$L('绑定离线 License')}}</Button>
<template v-else>
<Button :loading="loadIng > 0" type="primary" @click="offlineRebindSubmit">{{$L('提交')}}</Button>
<Button :loading="loadIng > 0" @click="offlineRebindCancel">{{$L('取消')}}</Button>
</template>
</template>
<template v-else>
<Button :loading="loadIng > 0" type="primary" @click="submitForm">{{$L('提交')}}</Button>
<Button :loading="loadIng > 0" @click="resetForm">{{$L('重置')}}</Button>
</template>
</div>
</div>
</TabPane> </TabPane>
</Tabs> </Tabs>
</div> </div>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
.setting-scroll {
flex: 1;
overflow-y: auto;
}
.license-box { .license-box {
position: relative;
padding-top: 6px; padding-top: 6px;
> ul { > ul {
&.online-info { &.online-info {
@ -170,6 +197,8 @@
line-height: 22px; line-height: 22px;
padding-bottom: 6px; padding-bottom: 6px;
display: flex; display: flex;
align-items: center;
gap: 6px;
&.warning { &.warning {
font-weight: 500; font-weight: 500;
color: #ed4014; color: #ed4014;
@ -179,9 +208,6 @@
font-style: normal; font-style: normal;
opacity: 0.8; opacity: 0.8;
} }
> span {
padding-left: 6px;
}
.information { .information {
display: flex; display: flex;
align-items: center; align-items: center;
@ -194,18 +220,44 @@
} }
} }
} }
.online-email-row { .online-refreshing {
position: absolute;
top: 6px;
right: 0;
display: flex; display: flex;
align-items: center; align-items: center;
max-width: 460px; font-size: 12px;
.online-email-input { line-height: 18px;
flex: 1; color: #808695;
margin-right: 8px; .online-spin {
} display: inline-block;
.ivu-btn { width: 12px;
flex-shrink: 0; height: 12px;
margin-right: 5px;
border: 2px solid #d9e3ef;
border-top-color: #2d8cf0;
border-radius: 50%;
animation: license-spin 0.7s linear infinite;
} }
} }
@keyframes license-spin {
to { transform: rotate(360deg); }
}
.online-skeleton {
display: inline-block;
width: 150px;
height: 14px;
border-radius: 4px;
background: linear-gradient(90deg, #eef0f3 25%, #e2e6ea 37%, #eef0f3 63%);
background-size: 400% 100%;
animation: license-skeleton 1.3s ease infinite;
&.sk-sm { width: 90px; }
&.sk-xs { width: 60px; }
}
@keyframes license-skeleton {
0% { background-position: 100% 50%; }
100% { background-position: 0 50%; }
}
.online-tip { .online-tip {
font-size: 12px; font-size: 12px;
line-height: 20px; line-height: 20px;
@ -236,7 +288,7 @@ export default {
offlineRebindShow: false, offlineRebindShow: false,
offlineRebindLicense: '', offlineRebindLicense: '',
onlineIng: 0, onlineIng: 0,
onlineAction: '', // 线'' | 'send' | 'login' | 'trial' | 'logout' loading/ onlineAction: '', // 线'' | 'login' | 'trial' | 'logout' loading/
onlineForm: { onlineForm: {
email: '', email: '',
code: '', code: '',
@ -245,10 +297,13 @@ export default {
maskedEmail: '', // maskedEmail: '', //
codeCountdown: 0, // codeCountdown: 0, //
codeTimer: null, codeTimer: null,
firstLoading: true, //
onlineRefreshing: false,// 线
} }
}, },
mounted() { mounted() {
this.onlineRefresh(); this.loadOnlineCache(); //
this.onlineRefresh(); //
}, },
beforeDestroy() { beforeDestroy() {
this.clearCodeTimer(); this.clearCodeTimer();
@ -264,11 +319,21 @@ export default {
return this.online.mode === 'online'; return this.online.mode === 'online';
}, },
// 线///退 // 线//退
onlineBusy() { onlineBusy() {
return this.onlineAction !== ''; return this.onlineAction !== '';
}, },
// 仿 setting/email
sendBtnText() {
return this.codeCountdown > 0 ? this.$L('(*)秒后重发', this.codeCountdown) : this.$L('发送验证码');
},
// 线 key host
onlineCacheKey() {
return 'license-online-cache::' + (typeof window !== 'undefined' ? window.location.host : '');
},
// 线 = license 线 // 线 = license 线
offlineBound() { offlineBound() {
return !this.onlineActive && !!String(this.formData.license || '').trim(); return !this.onlineActive && !!String(this.formData.license || '').trim();
@ -338,7 +403,7 @@ export default {
systemSetting(save) { systemSetting(save) {
this.loadIng++; this.loadIng++;
this.$store.dispatch("call", { return this.$store.dispatch("call", {
url: 'system/license', url: 'system/license',
data: Object.assign(this.formData, { data: Object.assign(this.formData, {
type: save ? 'save' : 'get' type: save ? 'save' : 'get'
@ -350,6 +415,7 @@ export default {
} }
this.formData = data; this.formData = data;
this.formData_bak = $A.cloneJSON(this.formData); this.formData_bak = $A.cloneJSON(this.formData);
this.writeOnlineCache(data.online); // 线
// Tab线 线线线 线 线线 // Tab线 线线线 线 线线
if (!this.tabInited) { if (!this.tabInited) {
this.tabInited = true; this.tabInited = true;
@ -365,21 +431,59 @@ export default {
} }
}).finally(_ => { }).finally(_ => {
this.loadIng--; this.loadIng--;
this.firstLoading = false; //
}); });
}, },
onlineRefresh() { onlineRefresh() {
// 线 // 线
this.onlineRefreshing = true;
this.$store.dispatch("call", { this.$store.dispatch("call", {
url: 'license/refresh', url: 'license/refresh',
method: 'post', method: 'post',
}).catch(() => { }).catch(() => {
// //
}).finally(() => { }).finally(() => {
this.systemSetting(); this.systemSetting().finally(() => {
this.onlineRefreshing = false;
});
}); });
}, },
// 线线 Tab
loadOnlineCache() {
try {
const raw = window.localStorage.getItem(this.onlineCacheKey);
if (!raw) {
return;
}
const cached = JSON.parse(raw);
if (cached && cached.mode === 'online') {
this.$set(this.formData, 'online', cached);
this.firstLoading = false;
if (!this.tabInited) {
this.tabInited = true;
this.mode = 'online';
}
}
} catch (e) {
//
}
},
// /线线 online 线
writeOnlineCache(online) {
try {
if (online && online.mode === 'online') {
window.localStorage.setItem(this.onlineCacheKey, JSON.stringify(online));
} else {
window.localStorage.removeItem(this.onlineCacheKey);
}
} catch (e) {
//
}
},
infoJoin(val, def = null) { infoJoin(val, def = null) {
if ($A.isArray(val)) { if ($A.isArray(val)) {
val = val.join(",") val = val.join(",")
@ -454,17 +558,18 @@ export default {
$A.messageError('请输入邮箱'); $A.messageError('请输入邮箱');
return; return;
} }
this.onlineAction = 'send'; // 仿 setting/emailsearch + spinner loading
this.onlineCall('license/email/send', { this.$store.dispatch("call", {
email: this.onlineForm.email, url: 'license/email/send',
data: {email: this.onlineForm.email},
method: 'post',
spinner: true,
}).then(({data}) => { }).then(({data}) => {
this.codeSent = true; this.codeSent = true;
this.maskedEmail = data?.email || ''; this.maskedEmail = data?.email || '';
this.startCodeCountdown(); this.startCodeCountdown();
}).catch(() => { }).catch(({msg}) => {
// onlineCall $A.messageError(msg);
}).finally(() => {
this.onlineAction = '';
}); });
}, },

View File

@ -58,6 +58,10 @@ body.dark-mode-reverse {
box-shadow: none; box-shadow: none;
} }
.ivu-input-search {
color: #303133 !important;
}
.el-dropdown-menu { .el-dropdown-menu {
border-color: #e3e8ed; border-color: #e3e8ed;
box-shadow: 0 2px 12px 0 rgba(255, 255, 255, 0.1); box-shadow: 0 2px 12px 0 rgba(255, 255, 255, 0.1);