add user invite
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { axios, type Page, type PageResult, type Role } from "timi-web";
|
import { axios, type LoginResponse, type Page, type PageResult, type Role } from "timi-web";
|
||||||
import type { ManagedUser } from "@/types/ManagedUser";
|
import type { ManagedUser } from "@/types/ManagedUser";
|
||||||
import { withCurrentStoreId, withCurrentStorePage } from "@/utils/store";
|
import { withCurrentStoreId, withCurrentStorePage } from "@/utils/store";
|
||||||
|
|
||||||
@@ -9,6 +9,34 @@ type ListOptions = {
|
|||||||
storeId?: string;
|
storeId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type InviteRequest = {
|
||||||
|
storeId?: string;
|
||||||
|
roleId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InviteResult = {
|
||||||
|
code: string;
|
||||||
|
expireAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InviteValidateResult = {
|
||||||
|
valid: boolean;
|
||||||
|
expireAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InviteRegisterData = {
|
||||||
|
code: string;
|
||||||
|
phoneNo: string;
|
||||||
|
name: string;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InviteRegisterRequest = {
|
||||||
|
captchaId: string;
|
||||||
|
captcha: string;
|
||||||
|
data: InviteRegisterData;
|
||||||
|
};
|
||||||
|
|
||||||
async function list(page: Page<ManagedUser>, options?: ListOptions): Promise<PageResult<ManagedUser>> {
|
async function list(page: Page<ManagedUser>, options?: ListOptions): Promise<PageResult<ManagedUser>> {
|
||||||
if (options?.allStores) {
|
if (options?.allStores) {
|
||||||
return await axios.post(`${BASE_URI}/list`, page);
|
return await axios.post(`${BASE_URI}/list`, page);
|
||||||
@@ -55,6 +83,18 @@ async function roleList(): Promise<Role[]> {
|
|||||||
return await axios.post(`${BASE_URI}/role/list`);
|
return await axios.post(`${BASE_URI}/role/list`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function invite(request: InviteRequest): Promise<InviteResult> {
|
||||||
|
return await axios.post(`${BASE_URI}/invite`, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateInvite(code: string): Promise<InviteValidateResult> {
|
||||||
|
return await axios.post(`${BASE_URI}/invite/validate`, { code });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function registerByInvite(request: InviteRegisterRequest): Promise<LoginResponse> {
|
||||||
|
return await axios.post(`${BASE_URI}/invite/register`, request);
|
||||||
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
list,
|
list,
|
||||||
detail,
|
detail,
|
||||||
@@ -62,5 +102,8 @@ export default {
|
|||||||
update,
|
update,
|
||||||
transferStore,
|
transferStore,
|
||||||
remove,
|
remove,
|
||||||
roleList
|
roleList,
|
||||||
|
invite,
|
||||||
|
validateInvite,
|
||||||
|
registerByInvite
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,281 @@
|
|||||||
|
<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">
|
||||||
|
<div class="logo"><span>糕</span></div>
|
||||||
|
<h1>糕店客户管理</h1>
|
||||||
|
</div>
|
||||||
|
<h2>邀请链接已失效</h2>
|
||||||
|
<p v-text="invalidMessage" />
|
||||||
|
</section>
|
||||||
|
<section v-else class="register-panel">
|
||||||
|
<div class="brand">
|
||||||
|
<div class="logo"><span>糕</span></div>
|
||||||
|
<h1>加入糕店客户管理</h1>
|
||||||
|
</div>
|
||||||
|
<p class="expire" v-text="expireText" />
|
||||||
|
<t-form class="form" label-align="left">
|
||||||
|
<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="验证码">
|
||||||
|
<div class="captcha-row">
|
||||||
|
<t-input v-model.trim="formModel.captcha" borderless placeholder="请输入验证码" />
|
||||||
|
<captcha ref="captchaRef" class="captcha" @error="handleCaptchaError" />
|
||||||
|
</div>
|
||||||
|
</t-form-item>
|
||||||
|
</t-form>
|
||||||
|
<t-button class="submit" theme="primary" size="medium" :loading="isSubmitting" :disabled="isExpired" @click="register">注册并登录</t-button>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Toast } from "tdesign-mobile-vue";
|
||||||
|
import { Captcha, useUserStore, type LoginResponse, Time } from "timi-web";
|
||||||
|
import ManagedUserAPI from "@/api/ManagedUserAPI";
|
||||||
|
import { useCurrentStoreStore } from "@/store/currentStoreStore";
|
||||||
|
|
||||||
|
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: "" });
|
||||||
|
const currentTime = ref(Date.now());
|
||||||
|
const isExpired = computed(() => !expireAt.value || currentTime.value >= expireAt.value);
|
||||||
|
const expireText = computed(() => isExpired.value ? "邀请链接已失效" : `有效期至 ${Time.toDateTime(expireAt.value)}`);
|
||||||
|
|
||||||
|
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 (!captchaRef.value?.captchaId || !formModel.captcha) {
|
||||||
|
Toast.warning("请输入图形验证码");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
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({
|
||||||
|
captchaId: captchaRef.value?.captchaId || "",
|
||||||
|
captcha: formModel.captcha,
|
||||||
|
data: {
|
||||||
|
code,
|
||||||
|
phoneNo: formModel.phoneNo,
|
||||||
|
name: formModel.name,
|
||||||
|
password: formModel.password
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await userStore.updateLoginUser(result as LoginResponse);
|
||||||
|
await currentStoreStore.loadAfterLogin();
|
||||||
|
Toast.success("注册成功");
|
||||||
|
await router.replace("/management");
|
||||||
|
} catch {
|
||||||
|
formModel.captcha = "";
|
||||||
|
await captchaRef.value?.update();
|
||||||
|
} 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);
|
||||||
|
});
|
||||||
|
</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;
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: .875rem 0 0;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.expire,
|
||||||
|
p,
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expire,
|
||||||
|
p {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: .875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form {
|
||||||
|
margin: 0;
|
||||||
|
padding: .25rem 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.captcha-row {
|
||||||
|
gap: .75rem;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.t-input {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.tui-captcha) {
|
||||||
|
flex: 0 0 7.5rem;
|
||||||
|
border: 1px solid var(--td-border-level-1-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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>
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
<template>
|
||||||
|
<div class="poster-page">
|
||||||
|
<section class="poster-wrap">
|
||||||
|
<img v-if="posterImage" class="poster" :src="posterImage" alt="邀请注册海报" />
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Toast } from "tdesign-mobile-vue";
|
||||||
|
import { createInvitePosterImage, downloadInvitePoster } from "@/utils/qrCode";
|
||||||
|
|
||||||
|
defineOptions({ name: "UserInvitePoster" });
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const posterImage = ref("");
|
||||||
|
const isDownloading = ref(false);
|
||||||
|
const message = ref("邀请海报参数无效");
|
||||||
|
const code = String(route.query.code || "");
|
||||||
|
const expireAt = Number(route.query.expireAt || 0);
|
||||||
|
const storeName = String(route.query.storeName || "");
|
||||||
|
const roleName = String(route.query.roleName || "");
|
||||||
|
const registerLink = `${window.location.origin}/gao/invite/register?code=${encodeURIComponent(code)}`;
|
||||||
|
const posterOptions = computed(() => ({
|
||||||
|
code: registerLink,
|
||||||
|
storeName,
|
||||||
|
roleName,
|
||||||
|
expireAt
|
||||||
|
}));
|
||||||
|
|
||||||
|
function createPoster(): void {
|
||||||
|
if (!/^\w{10}$/.test(code) || !expireAt) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
posterImage.value = createInvitePosterImage(posterOptions.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePoster(): Promise<void> {
|
||||||
|
if (isDownloading.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isDownloading.value = true;
|
||||||
|
try {
|
||||||
|
await downloadInvitePoster(posterOptions.value, "GAO-邀请注册海报.png");
|
||||||
|
Toast.success("海报已保存");
|
||||||
|
} catch {
|
||||||
|
Toast.error("海报保存失败");
|
||||||
|
} finally {
|
||||||
|
isDownloading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyLink(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(registerLink);
|
||||||
|
Toast.success("注册链接已复制");
|
||||||
|
} catch {
|
||||||
|
Toast.error("复制失败,请检查浏览器权限");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
try {
|
||||||
|
createPoster();
|
||||||
|
} catch {
|
||||||
|
message.value = "海报生成失败,请重新生成";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.poster-page {
|
||||||
|
gap: 1rem;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: calc(var(--app-nav-height) + 1rem) 1rem 6rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.poster-wrap {
|
||||||
|
width: min(100%, 28rem);
|
||||||
|
padding: .5rem;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 .75rem 2rem rgba(15, 118, 110, .2);
|
||||||
|
border-radius: 1.25rem;
|
||||||
|
|
||||||
|
.poster {
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
border-radius: .875rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
padding: 5rem 1rem;
|
||||||
|
color: var(--td-text-color-secondary);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
gap: .75rem;
|
||||||
|
left: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
bottom: calc(.75rem + env(safe-area-inset-bottom, 0));
|
||||||
|
display: grid;
|
||||||
|
position: fixed;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html.theme-dark) .poster-page {
|
||||||
|
.poster-wrap {
|
||||||
|
background: #0f172a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+22
-10
@@ -1,6 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-can="permissionCodes.user.read" class="user-list">
|
<div
|
||||||
<div class="store-menu">
|
v-can="permissionCodes.user.read"
|
||||||
|
class="user-list"
|
||||||
|
:class="{ 'has-store-filter': isGlobalManager }"
|
||||||
|
>
|
||||||
|
<div v-if="isGlobalManager" class="store-menu">
|
||||||
<t-dropdown-menu v-if="isGlobalManager">
|
<t-dropdown-menu v-if="isGlobalManager">
|
||||||
<t-dropdown-item
|
<t-dropdown-item
|
||||||
v-model="selectedStoreId"
|
v-model="selectedStoreId"
|
||||||
@@ -39,13 +43,12 @@
|
|||||||
<div v-if="!pageResult.list.length" class="load-state" v-text="isLoading ? '正在加载用户' : '暂无用户'" />
|
<div v-if="!pageResult.list.length" class="load-state" v-text="isLoading ? '正在加载用户' : '暂无用户'" />
|
||||||
<div v-if="pageResult.list.length" class="load-state" v-text="isNoMore ? '已经到底了' : '上拉加载更多'" />
|
<div v-if="pageResult.list.length" class="load-state" v-text="isNoMore ? '已经到底了' : '上拉加载更多'" />
|
||||||
</t-pull-down-refresh>
|
</t-pull-down-refresh>
|
||||||
<t-button v-can="permissionCodes.user.create" class="add" theme="primary" type="button" @click="toCreate">
|
<div class="actions">
|
||||||
<template #icon>
|
<t-button v-can="permissionCodes.user.create" theme="primary" type="button" @click="inviteVisible = true">邀请注册</t-button>
|
||||||
<t-icon name="add" />
|
<t-button v-can="permissionCodes.user.create" theme="primary" type="button" @click="toCreate">新增用户</t-button>
|
||||||
</template>
|
|
||||||
新增
|
|
||||||
</t-button>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<user-invite v-model:visible="inviteVisible" :store-id="selectedStoreId === allStoreFilterValue ? undefined : selectedStoreId" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -60,6 +63,7 @@ import { usePermissionSettingsStore } from "@/store/permissionSettingsStore";
|
|||||||
import { buildFallbackAvatar } from "@/utils/avatar";
|
import { buildFallbackAvatar } from "@/utils/avatar";
|
||||||
import { buildEffectiveRoleCodeSet, getManagedUserRoleList, getRoleDisplayName } from "@/utils/role";
|
import { buildEffectiveRoleCodeSet, getManagedUserRoleList, getRoleDisplayName } from "@/utils/role";
|
||||||
import { gaoRoleCodes, permissionCodes } from "@/utils/permission";
|
import { gaoRoleCodes, permissionCodes } from "@/utils/permission";
|
||||||
|
import UserInvite from "@/pages/user/component/UserInvite.vue";
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
name: "UserList"
|
name: "UserList"
|
||||||
@@ -69,6 +73,7 @@ const router = useRouter();
|
|||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
const currentStoreStore = useCurrentStoreStore();
|
const currentStoreStore = useCurrentStoreStore();
|
||||||
const permissionStore = usePermissionSettingsStore();
|
const permissionStore = usePermissionSettingsStore();
|
||||||
|
const inviteVisible = ref(false);
|
||||||
|
|
||||||
const isGlobalManager = computed(() => userStore.hasRoles(gaoRoleCodes.globalManager));
|
const isGlobalManager = computed(() => userStore.hasRoles(gaoRoleCodes.globalManager));
|
||||||
|
|
||||||
@@ -242,10 +247,14 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<style scoped lang="less">
|
<style scoped lang="less">
|
||||||
.user-list {
|
.user-list {
|
||||||
padding: calc(var(--app-nav-height) + 4rem) 0 6rem 0;
|
padding: calc(var(--app-nav-height) + 1rem) 0 6rem 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: .75rem;
|
gap: .75rem;
|
||||||
|
|
||||||
|
&.has-store-filter {
|
||||||
|
padding-top: calc(var(--app-nav-height) + 4rem);
|
||||||
|
}
|
||||||
|
|
||||||
.store-menu {
|
.store-menu {
|
||||||
top: var(--app-nav-height);
|
top: var(--app-nav-height);
|
||||||
left: 0;
|
left: 0;
|
||||||
@@ -273,11 +282,14 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.add {
|
.actions {
|
||||||
left: 1rem;
|
left: 1rem;
|
||||||
right: 1rem;
|
right: 1rem;
|
||||||
bottom: calc(.75rem + env(safe-area-inset-bottom, 0));
|
bottom: calc(.75rem + env(safe-area-inset-bottom, 0));
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
gap: .75rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
<template>
|
||||||
|
<t-dialog
|
||||||
|
v-model:visible="popupVisible"
|
||||||
|
:cancel-btn="null"
|
||||||
|
confirm-btn="生成邀请海报"
|
||||||
|
:close-on-overlay-click="!pickerVisible"
|
||||||
|
:z-index="10000"
|
||||||
|
destroy-on-close
|
||||||
|
@confirm="generateInvite"
|
||||||
|
>
|
||||||
|
<section class="invite-popup">
|
||||||
|
<header class="header">
|
||||||
|
<h2>配置邀请信息</h2>
|
||||||
|
<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
|
||||||
|
@click="openPicker('role')"
|
||||||
|
>
|
||||||
|
<span v-text="selectedRoleName" />
|
||||||
|
</t-form-item>
|
||||||
|
</t-form>
|
||||||
|
</section>
|
||||||
|
</t-dialog>
|
||||||
|
<t-popup v-model="pickerVisible" placement="bottom" :z-index="11000">
|
||||||
|
<t-picker
|
||||||
|
v-model="pickerValue"
|
||||||
|
:columns="pickerColumns"
|
||||||
|
:title="pickerType === 'role' ? '选择角色' : '选择门店'"
|
||||||
|
@cancel="pickerVisible = false"
|
||||||
|
@confirm="confirmPicker"
|
||||||
|
/>
|
||||||
|
</t-popup>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
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";
|
||||||
|
|
||||||
|
defineOptions({ name: "UserInvite" });
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
visible: boolean;
|
||||||
|
storeId?: string;
|
||||||
|
}>();
|
||||||
|
const emit = defineEmits<{
|
||||||
|
"update:visible": [value: boolean];
|
||||||
|
}>();
|
||||||
|
const router = useRouter();
|
||||||
|
const userStore = useUserStore();
|
||||||
|
const currentStoreStore = useCurrentStoreStore();
|
||||||
|
const isGlobalManager = computed(() => userStore.hasRoles(gaoRoleCodes.globalManager));
|
||||||
|
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 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 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);
|
||||||
|
const pickerType = ref<"role" | "store">("role");
|
||||||
|
const pickerValue = ref<Array<string | number>>([]);
|
||||||
|
const pickerColumns = computed(() => [pickerType.value === "role" ? roleOptions.value : storeOptions.value]);
|
||||||
|
|
||||||
|
async function loadData(): Promise<void> {
|
||||||
|
if (isLoaded.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const roleRequest = RoleAPI.manageableList("GAO");
|
||||||
|
if (isGlobalManager.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) {
|
||||||
|
const store = await currentStoreStore.loadLoginStore();
|
||||||
|
formModel.storeId = store?.id || "";
|
||||||
|
}
|
||||||
|
isLoaded.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPicker(type: "role" | "store"): void {
|
||||||
|
pickerType.value = type;
|
||||||
|
pickerValue.value = [type === "role" ? formModel.roleId : formModel.storeId];
|
||||||
|
pickerVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmPicker(): void {
|
||||||
|
const value = String(pickerValue.value[0] || "");
|
||||||
|
if (pickerType.value === "role") {
|
||||||
|
formModel.roleId = value;
|
||||||
|
} else {
|
||||||
|
formModel.storeId = value;
|
||||||
|
}
|
||||||
|
pickerVisible.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateInvite(): Promise<void> {
|
||||||
|
if (!formModel.roleId || (isGlobalManager.value && !formModel.storeId)) {
|
||||||
|
Toast.warning(isGlobalManager.value ? "请选择角色和门店" : "请选择角色");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isGenerating.value = true;
|
||||||
|
try {
|
||||||
|
const result = await ManagedUserAPI.invite(isGlobalManager.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,
|
||||||
|
roleName: selectedRoleName.value
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
isGenerating.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.visible, async visible => {
|
||||||
|
if (!visible) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await loadData();
|
||||||
|
} catch {
|
||||||
|
Toast.error("邀请配置加载失败");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.invite-popup {
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close {
|
||||||
|
border: 0;
|
||||||
|
padding: 0 .25rem;
|
||||||
|
color: #64748b;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form {
|
||||||
|
margin: 1rem 0 0 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html.theme-dark) .invite-popup {
|
||||||
|
background: #0f172a;
|
||||||
|
|
||||||
|
.header h2 {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+14
-1
@@ -2,6 +2,7 @@ import { createRouter, createWebHistory, type RouteLocationNormalized } from "vu
|
|||||||
import RootLayout from "@/layout/RootLayout.vue";
|
import RootLayout from "@/layout/RootLayout.vue";
|
||||||
import MainLayout from "@/layout/MainLayout.vue";
|
import MainLayout from "@/layout/MainLayout.vue";
|
||||||
import Login from "@/pages/Login.vue";
|
import Login from "@/pages/Login.vue";
|
||||||
|
import InviteRegister from "@/pages/InviteRegister.vue";
|
||||||
import chartRoutes from "@/router/routes/chartRoutes";
|
import chartRoutes from "@/router/routes/chartRoutes";
|
||||||
import customerRoutes from "@/router/routes/customerRoutes";
|
import customerRoutes from "@/router/routes/customerRoutes";
|
||||||
import { managementAccountRoutes, managementSettingRoutes, storeRoutes } from "@/router/routes/managementRoutes";
|
import { managementAccountRoutes, managementSettingRoutes, storeRoutes } from "@/router/routes/managementRoutes";
|
||||||
@@ -60,6 +61,18 @@ const router = createRouter({
|
|||||||
darkBodyBackground: "#10161f"
|
darkBodyBackground: "#10161f"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "gao/invite/register",
|
||||||
|
name: "InviteRegister",
|
||||||
|
component: InviteRegister,
|
||||||
|
meta: {
|
||||||
|
depth: 1,
|
||||||
|
navBarVisible: false,
|
||||||
|
tabBarVisible: false,
|
||||||
|
bodyBackground: "#F5F5F8",
|
||||||
|
darkBodyBackground: "#10161f"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "",
|
path: "",
|
||||||
component: MainLayout,
|
component: MainLayout,
|
||||||
@@ -86,7 +99,7 @@ const router = createRouter({
|
|||||||
|
|
||||||
router.beforeEach(async (to) => {
|
router.beforeEach(async (to) => {
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
if (to.path === "/login") {
|
if (to.path === "/login" || to.path === "/gao/invite/register") {
|
||||||
return userStore.isLogged() ? "/management" : true;
|
return userStore.isLogged() ? "/management" : true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,23 @@ import UserList from "@/pages/user/UserList.vue";
|
|||||||
import UserDetail from "@/pages/user/UserDetail.vue";
|
import UserDetail from "@/pages/user/UserDetail.vue";
|
||||||
import UserEdit from "@/pages/user/UserEdit.vue";
|
import UserEdit from "@/pages/user/UserEdit.vue";
|
||||||
import UserRoleAssign from "@/pages/user/UserRoleAssign.vue";
|
import UserRoleAssign from "@/pages/user/UserRoleAssign.vue";
|
||||||
|
import UserInvitePoster from "@/pages/user/UserInvitePoster.vue";
|
||||||
|
|
||||||
const userRoutes: RouteRecordRaw[] = [
|
const userRoutes: RouteRecordRaw[] = [
|
||||||
|
{
|
||||||
|
path: "/management/users/invite/poster",
|
||||||
|
name: "UserInvitePoster",
|
||||||
|
component: UserInvitePoster,
|
||||||
|
meta: {
|
||||||
|
depth: 5,
|
||||||
|
navBarVisible: true,
|
||||||
|
navBarCanBack: true,
|
||||||
|
navBarTitle: "邀请注册海报",
|
||||||
|
tabBarVisible: false,
|
||||||
|
bodyBackground: "#F0FDFA",
|
||||||
|
darkBodyBackground: "#10161f"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/management/users",
|
path: "/management/users",
|
||||||
name: "UserList",
|
name: "UserList",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { BarcodeFormat, EncodeHintType, QRCodeWriter } from "@zxing/library";
|
import { BarcodeFormat, EncodeHintType, QRCodeWriter } from "@zxing/library";
|
||||||
|
import { Time } from "timi-web";
|
||||||
|
|
||||||
export interface QrCodePage {
|
export interface QrCodePage {
|
||||||
pageIndex: number;
|
pageIndex: number;
|
||||||
@@ -23,6 +24,13 @@ export interface QrCodeGrid {
|
|||||||
pageSize: number;
|
pageSize: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface InvitePosterOptions {
|
||||||
|
code: string;
|
||||||
|
storeName: string;
|
||||||
|
roleName: string;
|
||||||
|
expireAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
const pageWidth = 2480;
|
const pageWidth = 2480;
|
||||||
const pageHeight = 3508;
|
const pageHeight = 3508;
|
||||||
const pagePadding = 80;
|
const pagePadding = 80;
|
||||||
@@ -77,6 +85,14 @@ export async function downloadQrCodeImages(options: DownloadQrCodeOptions): Prom
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function downloadInvitePoster(options: InvitePosterOptions, fileName: string): Promise<void> {
|
||||||
|
await downloadCanvas(renderInvitePoster(options), fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createInvitePosterImage(options: InvitePosterOptions): string {
|
||||||
|
return renderInvitePoster(options).toDataURL("image/png");
|
||||||
|
}
|
||||||
|
|
||||||
export async function printQrCodeImages(options: PrintQrCodeOptions): Promise<void> {
|
export async function printQrCodeImages(options: PrintQrCodeOptions): Promise<void> {
|
||||||
const printWindow = window.open("", "_blank");
|
const printWindow = window.open("", "_blank");
|
||||||
if (!printWindow) {
|
if (!printWindow) {
|
||||||
@@ -177,6 +193,67 @@ function renderQrCodePage(codes: string[], maxCodeLength = 0): HTMLCanvasElement
|
|||||||
return canvas;
|
return canvas;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderInvitePoster(options: InvitePosterOptions): HTMLCanvasElement {
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = 1200;
|
||||||
|
canvas.height = 1820;
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("当前浏览器不支持 Canvas");
|
||||||
|
}
|
||||||
|
|
||||||
|
const teal = "#0F766E";
|
||||||
|
const darkTeal = "#115E59";
|
||||||
|
context.fillStyle = "#F0FDFA";
|
||||||
|
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
context.fillStyle = teal;
|
||||||
|
context.fillRect(0, 0, canvas.width, 640);
|
||||||
|
context.fillStyle = "#CCFBF1";
|
||||||
|
context.beginPath();
|
||||||
|
context.arc(1140, 40, 220, 0, Math.PI * 2);
|
||||||
|
context.fill();
|
||||||
|
context.fillStyle = "#FFF";
|
||||||
|
context.textAlign = "left";
|
||||||
|
context.textBaseline = "middle";
|
||||||
|
context.font = "700 80px Microsoft YaHei, sans-serif";
|
||||||
|
context.fillText("邀请您加入", 64, 120);
|
||||||
|
context.font = "700 110px Microsoft YaHei, sans-serif";
|
||||||
|
context.fillText(options.storeName, 64, 280);
|
||||||
|
context.font = "700 64px Microsoft YaHei, sans-serif";
|
||||||
|
context.fillText(`成为:${options.roleName}`, 64, 420);
|
||||||
|
|
||||||
|
context.fillStyle = "#FFFFFF";
|
||||||
|
context.shadowColor = "rgba(15, 118, 110, .18)";
|
||||||
|
context.shadowBlur = 36;
|
||||||
|
context.shadowOffsetY = 12;
|
||||||
|
context.beginPath();
|
||||||
|
context.roundRect(120, 520, 960, 960, 40);
|
||||||
|
context.fill();
|
||||||
|
context.shadowColor = "transparent";
|
||||||
|
context.shadowBlur = 0;
|
||||||
|
context.shadowOffsetY = 0;
|
||||||
|
|
||||||
|
const qrSize = 920;
|
||||||
|
const qrLeft = (canvas.width - qrSize) / 2;
|
||||||
|
const qrTop = 540;
|
||||||
|
drawQrCode(context, options.code, qrLeft, qrTop, qrSize);
|
||||||
|
context.textAlign = "center";
|
||||||
|
context.fillStyle = "#64748B";
|
||||||
|
context.font = "400 40px Microsoft YaHei, sans-serif";
|
||||||
|
context.fillText(`有效期至 ${Time.toDateTime(options.expireAt)}`, 600, 1430);
|
||||||
|
context.fillStyle = darkTeal;
|
||||||
|
context.font = "400 52px Microsoft YaHei, sans-serif";
|
||||||
|
context.fillText("糕店客户管理系统", 600, 1550);
|
||||||
|
context.fillStyle = "#64748B";
|
||||||
|
context.font = "400 40px Microsoft YaHei, sans-serif";
|
||||||
|
context.fillText("客户档案 | 事件登记 | 积分激励 | 门店协同 | AI 分析", 600, 1645);
|
||||||
|
context.fillStyle = "#94A3B8";
|
||||||
|
context.font = "400 40px Microsoft YaHei, sans-serif";
|
||||||
|
context.fillText("本邀请仅限本人使用,请勿转发", 600, 1760);
|
||||||
|
|
||||||
|
return canvas;
|
||||||
|
}
|
||||||
|
|
||||||
async function waitPrintImagesLoaded(document: Document): Promise<void> {
|
async function waitPrintImagesLoaded(document: Document): Promise<void> {
|
||||||
const imageList = Array.from(document.images);
|
const imageList = Array.from(document.images);
|
||||||
await Promise.all(imageList.map(image => {
|
await Promise.all(imageList.map(image => {
|
||||||
|
|||||||
Reference in New Issue
Block a user