update user invite style

This commit is contained in:
Timi
2026-08-22 00:53:39 +08:00
parent 7ff8ed7f3d
commit 94c17e6d81
12 changed files with 285 additions and 133 deletions
+349
View File
@@ -0,0 +1,349 @@
<template>
<div class="register-page">
<section v-if="isLoading" class="register-panel"><t-loading text="正在校验邀请码" /></section>
<section v-else-if="!isValid" class="register-panel invalid">
<div class="brand">
<logo />
<h1>糕店客户管理</h1>
</div>
<h2>邀请码已失效</h2>
<p class="tips gray" v-text="invalidMessage" />
</section>
<section v-else class="register-panel">
<div class="brand">
<logo />
<h1>加入糕店客户管理系统</h1>
</div>
<p class="tips gray" v-text="expireText" />
<t-form class="form" label-align="left" label-width="6rem">
<t-form-item label="手机号">
<t-input v-model.trim="formModel.phoneNo" type="tel" inputmode="tel" borderless placeholder="请输入手机号" />
</t-form-item>
<t-form-item label="用户名">
<t-input v-model.trim="formModel.name" borderless placeholder="请输入登录用户名" />
</t-form-item>
<t-form-item label="密码">
<t-input v-model="formModel.password" type="password" borderless placeholder="请输入登录密码" />
</t-form-item>
<t-form-item label="确认密码">
<t-input v-model="formModel.confirmPassword" type="password" borderless placeholder="请再次输入登录密码" />
</t-form-item>
<t-form-item label="图形验证码">
<t-input v-model.trim="formModel.captcha" borderless placeholder="请输入验证码" />
</t-form-item>
<div class="captcha-wrapper">
<captcha ref="captchaRef" class="captcha" :width="120" :height="40" @error="handleCaptchaError" />
</div>
<t-form-item label="短信验证码">
<t-input v-model.trim="formModel.smsCode" borderless placeholder="请输入短信验证码" />
</t-form-item>
<div class="send-wrapper">
<t-button
class="send-sms"
type="button"
size="extra-small"
theme="primary"
:content="sendSmsText"
:disabled="!canSendSms"
@click="sendSms"
/>
</div>
</t-form>
<t-button class="submit" theme="primary" size="medium" :loading="isSubmitting" :disabled="isExpired || isSubmitting" @click="register">注册并登录</t-button>
</section>
</div>
</template>
<script setup lang="ts">
import { Toast } from "tdesign-mobile-vue";
import { Captcha, NotifyAPI, useUserStore, type LoginResponse, Time } from "timi-web";
import ManagedUserAPI from "@/api/ManagedUserAPI";
import { useCurrentStoreStore } from "@/store/currentStoreStore";
import Logo from "@/pages/user/component/Logo.vue";
type CaptchaExpose = { captchaId: string; update: () => Promise<void> };
defineOptions({ name: "InviteRegister" });
const route = useRoute();
const router = useRouter();
const userStore = useUserStore();
const currentStoreStore = useCurrentStoreStore();
const code = String(route.query.code || "");
const isLoading = ref(true);
const isValid = ref(false);
const expireAt = ref(0);
const invalidMessage = ref("邀请码无效或已过期,请联系邀请人重新生成。");
const isSubmitting = ref(false);
const captchaRef = ref<CaptchaExpose | null>(null);
const formModel = reactive({
phoneNo: "",
name: "",
password: "",
confirmPassword: "",
captcha: "",
smsCode: "",
notifyDetailId: ""
});
const currentTime = ref(Date.now());
const isExpired = computed(() => !expireAt.value || currentTime.value >= expireAt.value);
const expireText = computed(() => isExpired.value ? "邀请码已失效" : `有效期至 ${Time.toDateTime(expireAt.value)}`);
const sendSmsCountdown = ref(0);
let sendSmsTimer: number | undefined;
const isSendingSms = computed(() => 0 < sendSmsCountdown.value);
const sendSmsText = computed(() => isSendingSms.value ? `${sendSmsCountdown.value} 秒后重新发送` : "发送验证码");
const canSendSms = computed(() => (
!isExpired.value
&& !isSendingSms.value
&& /^1\d{10}$/.test(formModel.phoneNo)
&& 4 === formModel.captcha.length
));
watch(() => formModel.phoneNo, () => {
if (!formModel.notifyDetailId) {
return;
}
formModel.notifyDetailId = "";
formModel.smsCode = "";
});
async function validateInvite(): Promise<void> {
if (!/^\w{10}$/.test(code)) {
invalidMessage.value = "邀请码格式不正确。";
return;
}
const result = await ManagedUserAPI.validateInvite(code);
expireAt.value = result.expireAt;
isValid.value = result.valid && Date.now() < result.expireAt;
}
function validateForm(): boolean {
if (!/^1\d{10}$/.test(formModel.phoneNo)) {
Toast.warning("请输入正确的 11 位手机号");
return false;
}
if (!formModel.name || formModel.name.length > 191) {
Toast.warning("请输入不超过 191 个字符的用户名");
return false;
}
if (formModel.password.length < 6) {
Toast.warning("密码至少需要 6 个字符");
return false;
}
if (formModel.password !== formModel.confirmPassword) {
Toast.warning("两次输入的密码不一致");
return false;
}
if (!formModel.notifyDetailId || !/^\d{6}$/.test(formModel.smsCode)) {
Toast.warning("请先获取并输入短信验证码");
return false;
}
return true;
}
function getCaptchaId(): string {
return captchaRef.value?.captchaId || "";
}
function clearSmsTimer(): void {
if (sendSmsTimer === undefined) {
return;
}
window.clearInterval(sendSmsTimer);
sendSmsTimer = undefined;
}
function startSmsTimer(): void {
clearSmsTimer();
sendSmsCountdown.value = 60;
sendSmsTimer = window.setInterval(() => {
sendSmsCountdown.value -= 1;
if (!(0 < sendSmsCountdown.value)) {
clearSmsTimer();
}
}, 1000);
}
async function sendSms(): Promise<void> {
if (!canSendSms.value) {
return;
}
const captchaId = getCaptchaId();
if (!captchaId) {
Toast.warning("图形验证码尚未加载,请稍后重试");
await captchaRef.value?.update();
return;
}
try {
formModel.notifyDetailId = await NotifyAPI.smsSend({
captchaId,
captcha: formModel.captcha,
data: {
detailList: [{ sendTo: formModel.phoneNo }]
}
});
startSmsTimer();
Toast.success(`验证码已发送,请检查 ${formModel.phoneNo} 手机短信(易保网络)`);
} catch {
formModel.notifyDetailId = "";
} finally {
formModel.captcha = "";
await captchaRef.value?.update();
}
}
async function register(): Promise<void> {
if (isSubmitting.value || isExpired.value || !validateForm()) {
if (isExpired.value) {
Toast.warning("邀请码已失效,请联系邀请人重新生成");
}
return;
}
isSubmitting.value = true;
try {
const result = await ManagedUserAPI.registerByInvite({
data: {
code,
phoneNo: formModel.phoneNo,
name: formModel.name,
password: formModel.password
},
detailId: formModel.notifyDetailId,
captcha: formModel.smsCode
});
await userStore.updateLoginUser(result as LoginResponse);
await currentStoreStore.loadAfterLogin();
Toast.success("注册成功");
await router.replace("/management");
} catch {
formModel.smsCode = "";
} finally {
isSubmitting.value = false;
}
}
async function handleCaptchaError(): Promise<void> {
Toast.warning("验证码加载失败,请点击验证码重试");
}
let expireTimer = 0;
onMounted(async () => {
expireTimer = window.setInterval(() => {
currentTime.value = Date.now();
}, 1000);
try {
await validateInvite();
} catch {
invalidMessage.value = "邀请码校验失败,请稍后重试。";
} finally {
isLoading.value = false;
}
});
onUnmounted(() => {
window.clearInterval(expireTimer);
clearSmsTimer();
});
</script>
<style scoped lang="less">
.register-page {
width: 100%;
padding: 4.5rem 1rem 1.5rem;
display: flex;
min-height: 100vh;
background: #F5F5F8;
align-items: flex-start;
justify-content: center;
.register-panel {
gap: 1rem;
width: 100%;
padding: 1.5rem;
display: flex;
max-width: 28rem;
background: rgba(255, 255, 255, .94);
box-shadow: 0 .75rem 2rem rgba(15, 23, 42, .08);
border-radius: 1.4rem;
flex-direction: column;
&.invalid {
text-align: center;
h2 {
margin: 0;
}
}
.brand {
display: grid;
place-items: center;
h1 {
margin: .875rem 0 0;
color: #0f172a;
font-size: 1.5rem;
font-weight: 700;
}
}
.tips {
margin: 0;
font-size: .875rem;
text-align: center;
}
.form {
margin: 0;
.send-wrapper,
.captcha-wrapper {
display: flex;
margin-top: .5rem;
justify-content: end;
.captcha {
border: 1px solid var(--td-border-level-1-color);
}
}
.send-sms {
flex: 0 0 7.5rem;
}
}
.submit {
gap: .5rem;
width: 8rem;
margin: .5rem auto 0;
display: flex;
}
}
}
:global(html.theme-dark) {
.register-page {
background: #10161f;
.register-panel {
background: rgba(15, 23, 42, .88);
box-shadow: 0 .75rem 2rem rgba(2, 6, 23, .32);
.brand h1,
h2 {
color: #e2e8f0;
}
p,
.expire {
color: #94a3b8;
}
:deep(.tui-captcha) {
color: #94a3b8;
background: #0f172a;
}
}
}
}
</style>
+2 -17
View File
@@ -2,7 +2,7 @@
<div class="login-page">
<section class="login-panel">
<div class="brand">
<div class="logo"><span>糕</span></div>
<logo />
<h1>糕店客户管理</h1>
</div>
<t-segmented class="login-type" v-model="loginType" :options="loginTypeOptions" />
@@ -15,6 +15,7 @@
<script setup lang="ts">
import PasswordLogin from "@/pages/user/component/PasswordLogin.vue";
import SmsLogin from "@/pages/user/component/SmsLogin.vue";
import Logo from "@/pages/user/component/Logo.vue";
defineOptions({
name: "LoginIndex"
@@ -74,22 +75,6 @@ const loginTypeOptions = [
font-weight: 700;
}
}
.logo {
width: 5rem;
height: 5rem;
color: #FFF;
display: grid;
background: #0f766e;
box-shadow: 0 .75rem 1.5rem rgba(15, 118, 110, .22);
place-items: center;
border-radius: 1.25rem;
span {
font-size: 2rem;
font-weight: 800;
}
}
}
:global(html.theme-dark) {
+18 -8
View File
@@ -5,8 +5,20 @@
<div v-else class="empty" v-text="message" />
</section>
<div v-if="posterImage" class="actions">
<t-button class="bg-white" variant="outline" theme="primary" block @click="copyLink">复制链接</t-button>
<t-button theme="primary" block :loading="isDownloading" @click="savePoster">保存海报</t-button>
<t-button
class="button bg-white"
variant="outline"
theme="primary"
block
@click="copyLink"
>复制链接</t-button>
<t-button
class="button"
theme="primary"
block
:loading="isDownloading"
@click="savePoster"
>保存海报</t-button>
</div>
</div>
</template>
@@ -105,13 +117,11 @@ onMounted(() => {
}
.actions {
--td-button-border-radius: 1rem;
gap: .75rem;
left: 1rem;
right: 1rem;
bottom: calc(.75rem + env(safe-area-inset-bottom, 0));
display: grid;
position: fixed;
grid-template-columns: 1fr 1fr;
width: 100%;
display: flex;
}
}
+3 -8
View File
@@ -37,10 +37,10 @@
<t-cell title="修改密码" arrow hover @click="toPassword" />
</t-cell-group>
<t-button
class="logout bg-white"
theme="danger"
class="red bg-white cell-button"
variant="text"
block
@click="confirmLogout"
variant="outline"
>退出登录</t-button>
<input
ref="avatarInputRef"
@@ -252,11 +252,6 @@ onUnmounted(() => {
color: var(--td-text-color-primary);
line-height: 1.5rem;
}
.logout {
margin: 1rem auto;
padding: 0 2rem;
}
}
:global(html.theme-dark) {
+25
View File
@@ -0,0 +1,25 @@
<template>
<div class="logo">
<span>糕</span>
</div>
</template>
<script setup lang="ts">
</script>
<style scoped lang="less">
.logo {
color: #FFF;
width: 5rem;
height: 5rem;
display: grid;
background: #0f766e;
box-shadow: 0 .75rem 1.5rem rgba(15, 118, 110, .22);
place-items: center;
border-radius: 1.25rem;
span {
font-size: 2rem;
font-weight: 800;
}
}
</style>
+5 -5
View File
@@ -30,7 +30,7 @@
</template>
<script setup lang="ts">
import { Toast } from "tdesign-mobile-vue";
import { Message } from "@/utils/message";
import { Captcha, NotifyAPI, UserAPI, useUserStore } from "timi-web";
import { useCurrentStoreStore } from "@/store/currentStoreStore";
@@ -107,7 +107,7 @@ async function sendSms(): Promise<void> {
const captchaId = getCaptchaId();
if (!captchaId) {
Toast.warning("图形验证码尚未加载,请稍后重试");
Message.warning("图形验证码尚未加载,请稍后重试");
await captchaRef.value?.update();
return;
}
@@ -125,7 +125,7 @@ async function sendSms(): Promise<void> {
}
});
startSmsTimer();
Toast.success(`验证码已发送,请检查 ${formModel.mobilePhone} 手机短信`);
Message.success(`验证码已发送,请检查 ${formModel.mobilePhone} 手机短信(易保网络)`, 5000);
} catch {
formModel.notifyDetailId = "";
} finally {
@@ -136,11 +136,11 @@ async function sendSms(): Promise<void> {
function validateForm(): boolean {
if (!/^1\d{10}$/.test(formModel.mobilePhone)) {
Toast.warning("请输入正确的 11 位手机号");
Message.warning("请输入正确的 11 位手机号");
return false;
}
if (!formModel.notifyDetailId || !formModel.smsCode) {
Toast.warning("请先获取并输入短信验证码");
Message.warning("请先获取并输入短信验证码");
return false;
}
return true;
+24 -18
View File
@@ -14,14 +14,6 @@
<button class="close" type="button" @click="popupVisible = false">×</button>
</header>
<t-form class="form" label-align="left">
<t-form-item
v-if="isGlobalManager"
label="目标门店"
arrow
@click="openPicker('store')"
>
<span v-text="selectedStoreName" />
</t-form-item>
<t-form-item
label="目标角色"
arrow
@@ -29,6 +21,14 @@
>
<span v-text="selectedRoleName" />
</t-form-item>
<t-form-item
v-if="shouldSelectStore"
label="目标门店"
arrow
@click="openPicker('store')"
>
<span v-text="selectedStoreName" />
</t-form-item>
</t-form>
</section>
</t-dialog>
@@ -47,8 +47,8 @@
import { Toast } from "tdesign-mobile-vue";
import ManagedUserAPI from "@/api/ManagedUserAPI";
import { useCurrentStoreStore } from "@/store/currentStoreStore";
import { gaoRoleCodes } from "@/utils/permission";
import { RoleAPI, useUserStore } from "timi-web";
import { coreRoleCodes, gaoRoleCodes } from "@/utils/permission";
import { RoleAPI, type Role, useUserStore } from "timi-web";
defineOptions({ name: "UserInvite" });
@@ -62,17 +62,20 @@ const emit = defineEmits<{
const router = useRouter();
const userStore = useUserStore();
const currentStoreStore = useCurrentStoreStore();
const isGlobalManager = computed(() => userStore.hasRoles(gaoRoleCodes.globalManager));
const hasGlobalStoreScope = computed(() => userStore.hasRoles([coreRoleCodes.admin, gaoRoleCodes.globalManager], "or"));
const popupVisible = computed({
get: () => props.visible,
set: value => emit("update:visible", value)
});
const formModel = reactive({ roleId: "", storeId: "" });
const roleList = ref<Array<{ id?: string; name?: string; code?: string }>>([]);
const roleList = ref<Role[]>([]);
const isGenerating = ref(false);
const isLoaded = ref(false);
const roleOptions = computed(() => roleList.value.filter(item => item.id).map(item => ({ label: item.name || item.code || "未命名角色", value: item.id as string })));
const storeOptions = computed(() => currentStoreStore.storeList.filter(item => item.id).map(item => ({ label: item.name || item.code || "未命名门店", value: item.id as string })));
const selectedRole = computed(() => roleList.value.find(item => item.id === formModel.roleId));
const isGlobalManagerRole = computed(() => selectedRole.value?.code === "GLOBAL_MANAGER");
const shouldSelectStore = computed(() => hasGlobalStoreScope.value && !!selectedRole.value && !isGlobalManagerRole.value);
const selectedRoleName = computed(() => roleOptions.value.find(item => item.value === formModel.roleId)?.label || "请选择角色");
const selectedStoreName = computed(() => storeOptions.value.find(item => item.value === formModel.storeId)?.label || "请选择门店");
const pickerVisible = ref(false);
@@ -85,14 +88,14 @@ async function loadData(): Promise<void> {
return;
}
const roleRequest = RoleAPI.manageableList("GAO");
if (isGlobalManager.value) {
if (hasGlobalStoreScope.value) {
await currentStoreStore.loadStoreList();
formModel.storeId = currentStoreStore.storeList.some(item => item.id === props.storeId)
? props.storeId || ""
: currentStoreStore.currentStoreId;
}
roleList.value = await roleRequest;
if (!isGlobalManager.value) {
if (!hasGlobalStoreScope.value) {
const store = await currentStoreStore.loadLoginStore();
formModel.storeId = store?.id || "";
}
@@ -109,6 +112,9 @@ function confirmPicker(): void {
const value = String(pickerValue.value[0] || "");
if (pickerType.value === "role") {
formModel.roleId = value;
if (isGlobalManagerRole.value) {
formModel.storeId = "";
}
} else {
formModel.storeId = value;
}
@@ -116,20 +122,20 @@ function confirmPicker(): void {
}
async function generateInvite(): Promise<void> {
if (!formModel.roleId || (isGlobalManager.value && !formModel.storeId)) {
Toast.warning(isGlobalManager.value ? "请选择角色和门店" : "请选择角色");
if (!formModel.roleId || (shouldSelectStore.value && !formModel.storeId)) {
Toast.warning(shouldSelectStore.value ? "请选择角色和门店" : "请选择角色");
return;
}
isGenerating.value = true;
try {
const result = await ManagedUserAPI.invite(isGlobalManager.value ? { roleId: formModel.roleId, storeId: formModel.storeId } : { roleId: formModel.roleId });
const result = await ManagedUserAPI.invite(shouldSelectStore.value ? { roleId: formModel.roleId, storeId: formModel.storeId } : { roleId: formModel.roleId });
popupVisible.value = false;
await router.push({
name: "UserInvitePoster",
query: {
code: result.code,
expireAt: String(result.expireAt),
storeName: isGlobalManager.value ? selectedStoreName.value : currentStoreStore.currentStoreName,
storeName: isGlobalManagerRole.value ? "糕店客户管理系统" : shouldSelectStore.value ? selectedStoreName.value : currentStoreStore.currentStoreName,
roleName: selectedRoleName.value,
inviterName: getCurrentUserName()
}