add user invite

This commit is contained in:
Timi
2026-08-15 01:33:52 +08:00
parent e4d02a714f
commit c810162926
8 changed files with 764 additions and 13 deletions
+45 -2
View File
@@ -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
}; };
+281
View File
@@ -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>
+121
View File
@@ -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
View File
@@ -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> </div>
新增
</t-button>
</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;
} }
} }
+189
View File
@@ -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
View File
@@ -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;
} }
+15
View File
@@ -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",
+77
View File
@@ -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 => {