refactor: clean up Member module and update Console

- Remove redundant PointsRule repositories (use single PointsRuleRepository)
- Clean up Member migrations and consolidate to single Init migration
- Update Console frontend API and components for Tenant
- Add H5LinkService for member H5 integration
This commit is contained in:
movingsam 2026-02-18 23:34:40 +08:00
parent cd9be23693
commit 5cbfb2866c
5 changed files with 480 additions and 1 deletions

View File

@ -71,4 +71,13 @@ export namespace TenantApi {
export async function updateTenantSettings(id: number, data: FenglingApi.Tenant.TenantSettings) {
return requestClient.put<void, FenglingApi.Tenant.TenantSettings>(`${apiPrefix}/tenants/${id}/settings`, data);
}
export interface H5LinkResult {
link: string;
qrCodeBase64: string;
}
export async function getH5Link(id: number) {
return requestClient.get<H5LinkResult>(`${apiPrefix}/tenants/${id}/h5-link`);
}
}

View File

@ -13,6 +13,10 @@ export namespace FenglingApi {
expiresAt?: string
description?: string
createdAt: string
customDomain?: string
basePath?: string
logo?: string
h5BaseUrl?: string
}
export interface CreateTenantDto {
@ -25,6 +29,10 @@ export namespace FenglingApi {
expiresAt?: string
status?: string
description?: string
customDomain?: string
basePath?: string
logo?: string
h5BaseUrl?: string
}
export interface UpdateTenantDto {
@ -36,6 +44,10 @@ export namespace FenglingApi {
expiresAt?: string
status?: string
description?: string
customDomain?: string
basePath?: string
logo?: string
h5BaseUrl?: string
}
export interface TenantSettings {

View File

@ -114,6 +114,31 @@ const handleSave = async () => {
}
};
const qrCodeVisible = ref(false);
const qrCodeUrl = ref('');
const currentTenantName = ref('');
const handleCopyLink = async (row: FenglingApi.Tenant.Tenant) => {
try {
const { link } = await TenantApi.getH5Link(row.id);
await navigator.clipboard.writeText(link);
ElMessage.success('Link copied to clipboard');
} catch (error) {
ElMessage.error('Failed to copy link');
}
};
const handleShowQrCode = async (row: FenglingApi.Tenant.Tenant) => {
try {
const { link, qrCodeBase64 } = await TenantApi.getH5Link(row.id);
qrCodeUrl.value = `data:image/png;base64,${qrCodeBase64}`;
currentTenantName.value = row.name;
qrCodeVisible.value = true;
} catch (error) {
ElMessage.error('Failed to generate QR code');
}
};
onMounted(() => {
loadTenants();
});
@ -157,9 +182,11 @@ onMounted(() => {
</el-table-column>
<el-table-column prop="expiresAt" label="Expires At" width="180" />
<el-table-column prop="createdAt" label="Created At" width="180" />
<el-table-column label="Actions" width="200" fixed="right">
<el-table-column label="Actions" width="280" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="handleEdit(row)">Edit</el-button>
<el-button size="small" type="success" @click="handleCopyLink(row)">Copy Link</el-button>
<el-button size="small" type="warning" @click="handleShowQrCode(row)">QR Code</el-button>
<el-popconfirm title="Are you sure to delete this tenant?" @confirm="handleDelete(row.id)">
<template #reference>
<el-button size="small" type="danger">Delete</el-button>
@ -218,5 +245,14 @@ onMounted(() => {
<el-button type="primary" @click="handleSave">Save</el-button>
</template>
</el-dialog>
<el-dialog v-model="qrCodeVisible" :title="currentTenantName + ' - QR Code'" width="300px">
<div style="text-align: center;">
<img :src="qrCodeUrl" alt="QR Code" style="max-width: 250px;" />
</div>
<template #footer>
<el-button @click="qrCodeVisible = false">Close</el-button>
</template>
</el-dialog>
</div>
</template>

View File

@ -0,0 +1,221 @@
<script lang="ts" setup>
import { ref, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
const router = useRouter()
const route = useRoute()
const phone = ref('')
const code = ref('')
const countdown = ref(0)
const isLoading = ref(false)
const mode = ref<'login' | 'register'>('login')
const redirect = computed(() => route.query.redirect as string || '/')
const phoneRules = [
{ required: true, message: '请输入手机号', trigger: 'blur' },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }
]
const codeRules = [
{ required: true, message: '请输入验证码', trigger: 'blur' },
{ pattern: /^\d{4,6}$/, message: '请输入4-6位验证码', trigger: 'blur' }
]
//
const sendCode = async () => {
if (countdown.value > 0) return
if (!phone.value || !/^1[3-9]\d{9}$/.test(phone.value)) {
alert('请输入正确的手机号')
return
}
countdown.value = 60
const timer = setInterval(() => {
countdown.value--
if (countdown.value <= 0) {
clearInterval(timer)
}
}, 1000)
// TODO: API
console.log('发送验证码到:', phone.value)
}
// /
const handleSubmit = async () => {
if (!phone.value || !code.value) {
alert('请填写完整信息')
return
}
isLoading.value = true
// TODO: API
setTimeout(() => {
isLoading.value = false
router.push(redirect.value)
}, 1500)
}
//
const handleWechatLogin = () => {
// TODO:
console.log('微信登录')
}
//
const handleAlipayLogin = () => {
// TODO:
console.log('支付宝登录')
}
</script>
<template>
<div class="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 flex flex-col">
<!-- 背景装饰 -->
<div class="absolute inset-0 overflow-hidden">
<div class="absolute top-1/4 -left-32 w-64 h-64 bg-purple-500/20 rounded-full blur-3xl"></div>
<div class="absolute bottom-1/4 -right-32 w-64 h-64 bg-pink-500/20 rounded-full blur-3xl"></div>
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-96 h-96 bg-indigo-500/10 rounded-full blur-3xl"></div>
</div>
<!-- 顶部 Logo 区域 -->
<div class="relative z-10 pt-16 px-8 text-center">
<div class="inline-flex items-center justify-center w-20 h-20 bg-gradient-to-br from-indigo-500 to-purple-600 rounded-3xl shadow-2xl shadow-purple-500/30 mb-6">
<span class="text-4xl">🔔</span>
</div>
<h1 class="text-3xl font-bold text-white mb-2">风铃</h1>
<p class="text-slate-400">享受品质生活</p>
</div>
<!-- 表单区域 -->
<div class="relative z-10 flex-1 mt-12 px-6">
<div class="max-w-sm mx-auto">
<!-- Tab 切换 -->
<div class="flex bg-slate-800/50 rounded-2xl p-1 mb-8">
<button
@click="mode = 'login'"
class="flex-1 py-3 rounded-xl text-sm font-medium transition-all"
:class="mode === 'login' ? 'bg-gradient-to-r from-indigo-500 to-purple-500 text-white shadow-lg' : 'text-slate-400 hover:text-white'"
>
登录
</button>
<button
@click="mode = 'register'"
class="flex-1 py-3 rounded-xl text-sm font-medium transition-all"
:class="mode === 'register' ? 'bg-gradient-to-r from-indigo-500 to-purple-500 text-white shadow-lg' : 'text-slate-400 hover:text-white'"
>
注册
</button>
</div>
<!-- 手机号输入 -->
<div class="mb-4">
<label class="block text-sm text-slate-400 mb-2">手机号</label>
<input
v-model="phone"
type="tel"
placeholder="请输入手机号"
maxlength="11"
class="w-full bg-slate-800/80 border border-slate-700 rounded-2xl px-5 py-4 text-white placeholder-slate-500 focus:outline-none focus:border-purple-500 focus:ring-2 focus:ring-purple-500/20 transition-all"
/>
</div>
<!-- 验证码输入 -->
<div class="mb-6">
<label class="block text-sm text-slate-400 mb-2">验证码</label>
<div class="flex gap-3">
<input
v-model="code"
type="tel"
placeholder="请输入验证码"
maxlength="6"
class="flex-1 bg-slate-800/80 border border-slate-700 rounded-2xl px-5 py-4 text-white placeholder-slate-500 focus:outline-none focus:border-purple-500 focus:ring-2 focus:ring-purple-500/20 transition-all"
/>
<button
@click="sendCode"
:disabled="countdown > 0"
class="px-4 py-4 bg-slate-800/80 border border-slate-700 rounded-2xl text-sm font-medium transition-all"
:class="countdown > 0 ? 'text-slate-500 cursor-not-allowed' : 'text-purple-400 hover:text-purple-300 hover:border-purple-500'"
>
{{ countdown > 0 ? `${countdown}s` : '获取验证码' }}
</button>
</div>
</div>
<!-- 登录按钮 -->
<button
@click="handleSubmit"
:disabled="isLoading"
class="w-full py-4 bg-gradient-to-r from-indigo-500 to-purple-500 rounded-2xl text-white font-medium shadow-lg shadow-purple-500/30 hover:shadow-purple-500/40 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
<span v-if="isLoading" class="inline-flex items-center gap-2">
<svg class="animate-spin w-5 h-5" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
处理中...
</span>
<span v-else>{{ mode === 'login' ? ' ' : ' ' }}</span>
</button>
<!-- 第三方登录 -->
<div class="mt-8">
<div class="flex items-center gap-4 mb-6">
<div class="flex-1 h-px bg-slate-700"></div>
<span class="text-slate-500 text-sm">其他登录方式</span>
<div class="flex-1 h-px bg-slate-700"></div>
</div>
<div class="flex justify-center gap-6">
<button
@click="handleWechatLogin"
class="w-14 h-14 bg-slate-800/80 rounded-2xl flex items-center justify-center text-2xl hover:bg-slate-700 transition-colors"
>
💬
</button>
<button
@click="handleAlipayLogin"
class="w-14 h-14 bg-slate-800/80 rounded-2xl flex items-center justify-center text-2xl hover:bg-slate-700 transition-colors"
>
💳
</button>
</div>
</div>
</div>
</div>
<!-- 底部协议 -->
<div class="relative z-10 p-6 text-center">
<p class="text-slate-500 text-xs">
登录即表示同意
<a href="#" class="text-purple-400">用户协议</a>
<a href="#" class="text-purple-400">隐私政策</a>
</p>
</div>
</div>
</template>
<style scoped>
/* 页面进入动画 */
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.relative.z-10 {
animation: slideUp 0.5s ease-out;
}
.relative.z-10:nth-child(2) { animation-delay: 0.1s; }
.relative.z-10:nth-child(3) { animation-delay: 0.2s; }
</style>

View File

@ -0,0 +1,201 @@
<script lang="ts" setup>
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
// -
const isLoggedIn = ref(false)
const userInfo = ref({
nickname: '会员用户',
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Fengling',
level: 5,
levelName: '黄金会员',
points: 1280,
coupons: 3,
orders: 12
})
//
const menuGroups = ref([
{
title: '我的资产',
items: [
{ icon: '🪙', label: '积分中心', path: '/points', requiresAuth: false },
{ icon: '👑', label: '会员等级', path: '/level', requiresAuth: false },
{ icon: '🎫', label: '我的卡券', path: '/coupons', requiresAuth: true },
]
},
{
title: '订单管理',
items: [
{ icon: '📦', label: '我的订单', path: '/orders', requiresAuth: true },
{ icon: '📍', label: '收货地址', path: '/addresses', requiresAuth: true },
]
},
{
title: '账户设置',
items: [
{ icon: '👤', label: '个人资料', path: '/profile', requiresAuth: true },
{ icon: '🔒', label: '账户安全', path: '/security', requiresAuth: true },
{ icon: '⚙️', label: '设置', path: '/settings', requiresAuth: false },
]
}
])
const handleMenuClick = (item: any) => {
if (item.requiresAuth && !isLoggedIn.value) {
router.push('/login?redirect=' + item.path)
return
}
router.push(item.path)
}
const goToLogin = () => {
router.push('/login?redirect=/')
}
</script>
<template>
<div class="min-h-screen bg-gradient-to-b from-slate-50 to-slate-100">
<!-- 顶部用户区域 -->
<div class="relative bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500 pb-24 pt-12">
<!-- 装饰元素 -->
<div class="absolute top-4 right-4 w-24 h-24 bg-white/10 rounded-full blur-xl"></div>
<div class="absolute -bottom-8 -left-8 w-32 h-32 bg-white/10 rounded-full blur-2xl"></div>
<div class="relative z-10 px-6">
<div class="flex items-center gap-4">
<!-- 头像 -->
<div class="relative">
<img
:src="userInfo.avatar"
class="w-20 h-20 rounded-full border-4 border-white/30 shadow-lg"
alt="avatar"
/>
<div v-if="isLoggedIn" class="absolute -bottom-1 -right-1 bg-yellow-400 text-yellow-900 text-xs font-bold px-2 py-0.5 rounded-full">
Lv.{{ userInfo.level }}
</div>
</div>
<!-- 用户信息 -->
<div class="flex-1 text-white">
<template v-if="isLoggedIn">
<h2 class="text-xl font-bold">{{ userInfo.nickname }}</h2>
<p class="text-white/80 text-sm">{{ userInfo.levelName }}</p>
</template>
<template v-else>
<h2 class="text-xl font-bold">欢迎来到风铃</h2>
<button
@click="goToLogin"
class="mt-2 text-sm bg-white/20 hover:bg-white/30 px-4 py-1.5 rounded-full transition-colors"
>
登录 / 注册
</button>
</template>
</div>
</div>
</div>
</div>
<!-- 快捷数据卡片 -->
<div class="relative px-6 -mt-16 z-20">
<div class="flex gap-4">
<!-- 积分卡片 -->
<div
@click="handleMenuClick(menuGroups[0].items[0])"
class="flex-1 bg-white rounded-2xl p-4 shadow-md hover:shadow-lg transition-all cursor-pointer group"
>
<div class="flex items-center gap-3">
<div class="w-12 h-12 bg-amber-100 rounded-xl flex items-center justify-center text-2xl group-hover:scale-110 transition-transform">
🪙
</div>
<div>
<p class="text-2xl font-bold text-slate-800">{{ isLoggedIn ? userInfo.points : '---' }}</p>
<p class="text-xs text-slate-500">积分</p>
</div>
</div>
</div>
<!-- 卡券卡片 -->
<div
@click="handleMenuClick(menuGroups[0].items[2])"
class="flex-1 bg-white rounded-2xl p-4 shadow-md hover:shadow-lg transition-all cursor-pointer group"
>
<div class="flex items-center gap-3">
<div class="w-12 h-12 bg-rose-100 rounded-xl flex items-center justify-center text-2xl group-hover:scale-110 transition-transform">
🎫
</div>
<div>
<p class="text-2xl font-bold text-slate-800">{{ isLoggedIn ? userInfo.coupons : '---' }}</p>
<p class="text-xs text-slate-500">卡券</p>
</div>
</div>
</div>
<!-- 订单卡片 -->
<div
@click="handleMenuClick(menuGroups[1].items[0])"
class="flex-1 bg-white rounded-2xl p-4 shadow-md hover:shadow-lg transition-all cursor-pointer group"
>
<div class="flex items-center gap-3">
<div class="w-12 h-12 bg-blue-100 rounded-xl flex items-center justify-center text-2xl group-hover:scale-110 transition-transform">
📦
</div>
<div>
<p class="text-2xl font-bold text-slate-800">{{ isLoggedIn ? userInfo.orders : '---' }}</p>
<p class="text-xs text-slate-500">订单</p>
</div>
</div>
</div>
</div>
</div>
<!-- 功能菜单 -->
<div class="px-6 mt-6 pb-8">
<template v-for="group in menuGroups" :key="group.title">
<div class="mb-6">
<h3 class="text-sm font-semibold text-slate-500 mb-3 px-2">{{ group.title }}</h3>
<div class="bg-white rounded-2xl overflow-hidden shadow-sm">
<template v-for="(item, index) in group.items" :key="item.path">
<div
@click="handleMenuClick(item)"
class="flex items-center gap-4 p-4 hover:bg-slate-50 cursor-pointer transition-colors"
:class="{ 'border-t border-slate-100': index > 0 }"
>
<span class="text-2xl">{{ item.icon }}</span>
<span class="flex-1 text-slate-700 font-medium">{{ item.label }}</span>
<span v-if="item.requiresAuth && !isLoggedIn" class="text-xs text-slate-400">
需登录
</span>
<span class="text-slate-300"></span>
</div>
</template>
</div>
</div>
</template>
</div>
</div>
</template>
<style scoped>
/* 可选:添加一些微动画 */
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.bg-white {
animation: fadeInUp 0.4s ease-out;
}
.bg-white:nth-child(2) { animation-delay: 0.1s; }
.bg-white:nth-child(3) { animation-delay: 0.2s; }
</style>