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 { withCurrentStoreId, withCurrentStorePage } from "@/utils/store";
|
||||
|
||||
@@ -9,6 +9,34 @@ type ListOptions = {
|
||||
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>> {
|
||||
if (options?.allStores) {
|
||||
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`);
|
||||
}
|
||||
|
||||
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 {
|
||||
list,
|
||||
detail,
|
||||
@@ -62,5 +102,8 @@ export default {
|
||||
update,
|
||||
transferStore,
|
||||
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>
|
||||
<div v-can="permissionCodes.user.read" class="user-list">
|
||||
<div class="store-menu">
|
||||
<div
|
||||
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-item
|
||||
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="isNoMore ? '已经到底了' : '上拉加载更多'" />
|
||||
</t-pull-down-refresh>
|
||||
<t-button v-can="permissionCodes.user.create" class="add" theme="primary" type="button" @click="toCreate">
|
||||
<template #icon>
|
||||
<t-icon name="add" />
|
||||
</template>
|
||||
新增
|
||||
</t-button>
|
||||
<div class="actions">
|
||||
<t-button v-can="permissionCodes.user.create" theme="primary" type="button" @click="inviteVisible = true">邀请注册</t-button>
|
||||
<t-button v-can="permissionCodes.user.create" theme="primary" type="button" @click="toCreate">新增用户</t-button>
|
||||
</div>
|
||||
</div>
|
||||
<user-invite v-model:visible="inviteVisible" :store-id="selectedStoreId === allStoreFilterValue ? undefined : selectedStoreId" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -60,6 +63,7 @@ import { usePermissionSettingsStore } from "@/store/permissionSettingsStore";
|
||||
import { buildFallbackAvatar } from "@/utils/avatar";
|
||||
import { buildEffectiveRoleCodeSet, getManagedUserRoleList, getRoleDisplayName } from "@/utils/role";
|
||||
import { gaoRoleCodes, permissionCodes } from "@/utils/permission";
|
||||
import UserInvite from "@/pages/user/component/UserInvite.vue";
|
||||
|
||||
defineOptions({
|
||||
name: "UserList"
|
||||
@@ -69,6 +73,7 @@ const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
const currentStoreStore = useCurrentStoreStore();
|
||||
const permissionStore = usePermissionSettingsStore();
|
||||
const inviteVisible = ref(false);
|
||||
|
||||
const isGlobalManager = computed(() => userStore.hasRoles(gaoRoleCodes.globalManager));
|
||||
|
||||
@@ -242,10 +247,14 @@ onMounted(async () => {
|
||||
|
||||
<style scoped lang="less">
|
||||
.user-list {
|
||||
padding: calc(var(--app-nav-height) + 4rem) 0 6rem 0;
|
||||
padding: calc(var(--app-nav-height) + 1rem) 0 6rem 0;
|
||||
display: grid;
|
||||
gap: .75rem;
|
||||
|
||||
&.has-store-filter {
|
||||
padding-top: calc(var(--app-nav-height) + 4rem);
|
||||
}
|
||||
|
||||
.store-menu {
|
||||
top: var(--app-nav-height);
|
||||
left: 0;
|
||||
@@ -273,11 +282,14 @@ onMounted(async () => {
|
||||
}
|
||||
}
|
||||
|
||||
.add {
|
||||
.actions {
|
||||
left: 1rem;
|
||||
right: 1rem;
|
||||
bottom: calc(.75rem + env(safe-area-inset-bottom, 0));
|
||||
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 MainLayout from "@/layout/MainLayout.vue";
|
||||
import Login from "@/pages/Login.vue";
|
||||
import InviteRegister from "@/pages/InviteRegister.vue";
|
||||
import chartRoutes from "@/router/routes/chartRoutes";
|
||||
import customerRoutes from "@/router/routes/customerRoutes";
|
||||
import { managementAccountRoutes, managementSettingRoutes, storeRoutes } from "@/router/routes/managementRoutes";
|
||||
@@ -60,6 +61,18 @@ const router = createRouter({
|
||||
darkBodyBackground: "#10161f"
|
||||
}
|
||||
},
|
||||
{
|
||||
path: "gao/invite/register",
|
||||
name: "InviteRegister",
|
||||
component: InviteRegister,
|
||||
meta: {
|
||||
depth: 1,
|
||||
navBarVisible: false,
|
||||
tabBarVisible: false,
|
||||
bodyBackground: "#F5F5F8",
|
||||
darkBodyBackground: "#10161f"
|
||||
}
|
||||
},
|
||||
{
|
||||
path: "",
|
||||
component: MainLayout,
|
||||
@@ -86,7 +99,7 @@ const router = createRouter({
|
||||
|
||||
router.beforeEach(async (to) => {
|
||||
const userStore = useUserStore();
|
||||
if (to.path === "/login") {
|
||||
if (to.path === "/login" || to.path === "/gao/invite/register") {
|
||||
return userStore.isLogged() ? "/management" : true;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,23 @@ import UserList from "@/pages/user/UserList.vue";
|
||||
import UserDetail from "@/pages/user/UserDetail.vue";
|
||||
import UserEdit from "@/pages/user/UserEdit.vue";
|
||||
import UserRoleAssign from "@/pages/user/UserRoleAssign.vue";
|
||||
import UserInvitePoster from "@/pages/user/UserInvitePoster.vue";
|
||||
|
||||
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",
|
||||
name: "UserList",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BarcodeFormat, EncodeHintType, QRCodeWriter } from "@zxing/library";
|
||||
import { Time } from "timi-web";
|
||||
|
||||
export interface QrCodePage {
|
||||
pageIndex: number;
|
||||
@@ -23,6 +24,13 @@ export interface QrCodeGrid {
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface InvitePosterOptions {
|
||||
code: string;
|
||||
storeName: string;
|
||||
roleName: string;
|
||||
expireAt: number;
|
||||
}
|
||||
|
||||
const pageWidth = 2480;
|
||||
const pageHeight = 3508;
|
||||
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> {
|
||||
const printWindow = window.open("", "_blank");
|
||||
if (!printWindow) {
|
||||
@@ -177,6 +193,67 @@ function renderQrCodePage(codes: string[], maxCodeLength = 0): HTMLCanvasElement
|
||||
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> {
|
||||
const imageList = Array.from(document.images);
|
||||
await Promise.all(imageList.map(image => {
|
||||
|
||||
Reference in New Issue
Block a user